안전한 초기화
안전한 초기화 (Safe Initialization)
객체가 초기화되기 전에 그 필드를 건드리는 실수는 런타임의 골치 아픈 버그로 이어지기 쉬워요. Scala 3은 -Wsafe-init 옵션으로 켜는 실험적인 안전 초기화 검사를 제공해서, 이런 '초기화 전 접근'을 컴파일 타임에 잡아줍니다. 검사기가 어떤 원리로 동작하는지 예시부터 원칙까지 살펴볼게요.
본문
Scala 3은 컴파일러 옵션 -Wsafe-init으로 켤 수 있는 실험적인 안전 초기화 검사를 구현해요.
초기화 검사기의 설계와 구현은 논문 Safe object initialization, abstractly [3]에 설명되어 있어요.
빠르게 훑어보기 (A Quick Glance)
어떻게 동작하는지 감을 잡기 위해 먼저 몇 가지 예시를 보여드릴게요.
부모-자식 상호작용 (Parent-Child Interaction)
다음 코드 조각이 주어졌다고 합시다:
abstract class AbstractFile:
def name: String
val extension: String = name.substring(4)
class RemoteFile(url: String) extends AbstractFile:
val localFile: String = s"${url.##}.tmp" // error: usage of `localFile` before it's initialized
def name: String = localFile
검사기가 보고할 내용:
-- Warning: tests/init/neg/AbstractFile.scala:7:4 ------------------------------
7 | val localFile: String = s"${url.##}.tmp" // error: usage of `localFile` before it's initialized
| ^
| Access non-initialized field value localFile. Calling trace:
| -> val extension: String = name.substring(4) [ AbstractFile.scala:3 ]
| -> def name: String = localFile [ AbstractFile.scala:8 ]
내부-외부 상호작용 (Inner-Outer Interaction)
다음 코드가 주어졌다고 합시다:
object Trees:
class ValDef { counter += 1 }
class EmptyValDef extends ValDef
val theEmptyValDef = new EmptyValDef
private var counter = 0 // error
검사기가 보고할 내용:
-- Warning: tests/init/neg/trees.scala:5:14 ------------------------------------
5 | private var counter = 0 // error
| ^
| Access non-initialized field variable counter. Calling trace:
| -> val theEmptyValDef = new EmptyValDef [ trees.scala:4 ]
| -> class EmptyValDef extends ValDef [ trees.scala:3 ]
| -> class ValDef { counter += 1 } [ trees.scala:2 ]
함수 (Functions)
다음 코드가 주어졌다고 합시다:
abstract class Parent:
val f: () => String = () => this.message
def message: String
class Child extends Parent:
val a = f()
val b = "hello" // error
def message: String = b
검사기가 보고할 내용:
-- Warning: tests/init/neg/features-high-order.scala:7:6 -----------------------
7 | val b = "hello" // error
| ^
|Access non-initialized field value b. Calling trace:
| -> val a = f() [ features-high-order.scala:6 ]
| -> val f: () => String = () => this.message [ features-high-order.scala:2 ]
| -> def message: String = b [ features-high-order.scala:8 ]
설계 목표 (Design Goals)
우리는 다음과 같은 설계 목표를 세워요:
- 건전(Sound): 검사가 항상 종료하고, 흔하고 합리적인 사용에 대해 건전하다(과도한 근사, over-approximation).
- 표현력(Expressive): 흔하고 합리적인 초기화 패턴을 지원한다.
- 친절(Friendly): 단순한 규칙, 최소한의 문법 오버헤드, 정보성 있는 오류 메시지.
- 모듈성(Modular): 모듈식 검사, 프로젝트 경계를 넘는 분석 없음.
- 빠름(Fast): 즉각적인 피드백.
- 단순(Simple): 핵심 타입 시스템의 변경 없음, 단순한 이론으로 설명 가능.
합리적인 사용에 우리는 다음 사용 사례를 포함해요(이에 국한되진 않습니다):
- 초기화 중
this와 외부this의 필드 접근 - 초기화 중
this와 외부this의 메서드 호출 - 초기화 중 내부 클래스 인스턴스화 및 그런 인스턴스의 메서드 호출
- 함수에서 필드 캡처
원칙 (Principles)
목표를 달성하기 위해 우리는 몇 가지 근본 원칙을 지켜요: 쌓아올림(stackability), 단조성(monotonicity), 스코프성(scopability), 그리고 권위(authority).
**쌓아올림(Stackability)**은 클래스의 모든 필드가 클래스 본문 끝에서 초기화된다는 뜻이에요. Scala는 모든 필드가 주 생성자 끝에서 초기화되도록 요구함으로써 이 속성을 문법적으로 강제해요. 아래의 언어 기능은 예외죠:
var x: T = _
예외 같은 제어 효과(control effects)는 이 속성을 깨뜨릴 수 있어요. 다음 예시가 보여주죠:
class MyException(val b: B) extends Exception("")
class A:
val b = try { new B } catch { case myEx: MyException => myEx.b }
println(b.a)
class B:
throw new MyException(this)
val a: Int = 1
위 코드에서 제어 효과가 예외에 감싸진 초기화되지 않은 값을 순간이동(teleport)시켜요. 구현에서는 던져지는 값들이 전이적으로 초기화되어야 함을 보장함으로써 이 문제를 피해요.
**단조성(Monotonicity)**은 객체의 초기화 상태가 뒤로 가지 않아야 한다는 뜻이에요. 초기화된 필드는 계속 초기화된 상태이고, 초기화된 객체를 가리키던 필드가 나중에 초기화 중인 객체를 가리키지 않아야 하죠. 예시로, 다음 코드는 거부됩니다:
trait Reporter:
def report(msg: String): Unit
class FileReporter(ctx: Context) extends Reporter:
ctx.typer.reporter = this // ctx now reaches an uninitialized object
val file: File = new File("report.txt")
def report(msg: String) = file.write(msg)
위 코드에서 ctx가 전이적으로 초기화된 객체를 가리킨다고 가정해 봅시다. 3행의 할당은 완전히 초기화되지 않은 this를 ctx에서 도달 가능하게 만들어요. 이것은 필드 사용이 초기화되지 않은 필드에 간접적으로 도달할 수 있게 하므로 위험해요.
단조성은 힙 단조 타입스테이트(heap monotonic typestate)라 불리는 잘 알려진 기법에 기반해, 에일리어싱이 있는 상황에서 건전성을 보장해요 [1]. 대략 말하면 초기화 상태가 뒤로 가지 않아야 한다는 뜻이에요.
**스코프성(Scopability)**은 부분적으로 생성된 객체에 접근할 수 있는 부수 채널(side channel)이 없다는 뜻이에요. 코루틴, 분리 제어(delimited control), 재개 가능한 예외 같은 제어 효과는 값을 스택 위쪽(스코프 밖)에서 현재 스코프가 도달 가능하게 운반할 수 있으므로 이 속성을 깨뜨려요. 정적 필드도 순간이동 역할을 해서 이 속성을 깨뜨릴 수 있어요. 구현에서는 순간이동된 값이 전이적으로 초기화되어야 함을 강제할 필요가 있어요.
위 세 원칙은 초기화에 대한 지역적 추론(local reasoning)에 기여해요. 이는 다음을 의미해요:
초기화된 환경은 초기화된 값만 만들어 낼 수 있다.
예를 들어 new 표현식의 인자들이 전이적으로 초기화되었다면 결과도 그렇고, 메서드 호출의 수신자와 인자들이 전이적으로 초기화되었다면 결과도 그래요.
초기화에 대한 지역적 추론은 전체 프로그램 분석을 피하므로 빠른 초기화 검사기를 가능하게 해요.
권위(authority)의 원칙은 단조성과 함께 간다. 단조성의 원칙이 초기화 상태가 뒤로 가지 못한다고 규정하는 반면, 권위의 원칙은 초기화 상태가 에일리어싱 때문에 임의의 위치에서 앞으로 가지 못한다고 규정해요. Scala에서 우리는 필드가 필수 초기화자로 정의되었을 때, 또는 객체가 전이적으로 초기화되는 지역적 추론 지점에서 클래스 본문의 객체 초기화 상태를 전진시킬 수 있어요.
추상 값 (Abstract Values)
객체의 초기화 상태에는 세 가지 근본적인 추상화가 있어요:
- Cold: cold 객체는 초기화되지 않은 필드를 가질 수 있어요.
- Warm: warm 객체는 모든 필드가 초기화되었지만 cold 객체에 도달할 수 있어요.
- Hot: hot 객체는 전이적으로 초기화되었어요. 즉 warm 객체에만 도달해요.
초기화 검사기에서 Warm 추상화는 내부 클래스와 여러 생성자를 처리하도록 리파인드돼요:
Warm[C] { outer = V, ctor, args = Vs }: 클래스C의 warm 객체인데,C의 직접적인 outer가V이고, 생성자는ctor, 생성자 인자는Vs예요.
초기화 검사기는 각 구체 클래스를 따로 검사해요. 추상화 ThisRef는 현재 초기화 중인 객체를 나타내요:
ThisRef[C]: 초기화 중인 클래스C의 현재 객체.
현재 객체의 초기화 상태는 추상 힙(abstract heap)에 추상 객체로 저장돼요. 추상 힙은 warm 객체의 필드 값 캐시 역할도 해요. Warm과 ThisRef는 추상 힙에 저장된 추상 객체의 "주소"예요.
함수와 조건식 표현을 지원하기 위해 두 가지 추상화가 더 도입돼요:
Fun(e, V, C): 추상 함수 값인데,e는 코드,V는 함수 본문 안this의 추상 값, 그리고 함수는 클래스C안에 있어요.Refset(Vs): 추상 값들의 집합Vs.
값 v는 다음 중 하나라도 참이면 효과적으로 hot(effectively hot) 이에요:
v가Hot이다.v가ThisRef이고 하부 객체의 모든 필드가 할당되었다.v가Warm[C] { ... }이고,C가 내부 클래스를 포함하지 않고;v에 어떤 메서드를 호출해도 초기화 오류가 없고 메서드 반환 값이 효과적으로 hot이며;v의 각 필드가 효과적으로 hot이다.
v가Fun(e, V, C)이고, 함수를 호출해도 오류가 없고 함수 반환 값이 효과적으로 hot이다.- 루트 객체(
ThisRef가 가리키는)는 효과적으로 hot이다.
효과적으로 hot인 값은 전이적으로 초기화된 것으로 간주할 수 있어서 메서드 인자나 재할당의 RHS로 안전하게 누출될 수 있어요. 초기화 검사기는 가능할 때마다 hot이 아닌 값을 효과적으로 hot으로 승격하려고 해요.
규칙 (Rules)
확립된 원칙과 설계 목표로 다음 규칙이 부과돼요:
-
필드 접근
e.f또는 메서드 호출e.m()은e가 cold이면 불법이에요.cold 값은 사용되어선 안 돼요.
-
필드 접근
e.f는e가 값ThisRef이고f가 초기화되지 않았으면 유효하지 않아요. -
할당
o.x = e에서 표현식e는 효과적으로 hot이어야 해요.이것이 시스템에서 단조성이 강제되는 방식이에요. 초기화
val f: T = e에서는 표현식e가 hot이 아닌 값을 가리킬 수 있다는 점을 주의하세요. -
메서드 호출의 인자는 효과적으로 hot이어야 해요.
생성자에서
this의 이스케이프는 흔히 안티패턴으로 여겨져요.하지만 hot이 아닌 값을 다른 생성자에 인자로 전달하는 것은 허용돼요. 순환 데이터 구조 생성을 지원하기 위해서죠. 검사기는 이스케이프된 초기화되지 않은 객체가 사용되지 않도록 보장해요. 즉 이스케이프된 객체에 메서드를 호출하거나 필드에 접근하는 것은 허용되지 않아요.
예외는 케이스 클래스의 합성
apply를 호출하는 경우예요. 예를 들어Some.apply(e)메서드 호출은new Some(e)로 해석되므로e가 hot이 아니어도 유효해요.이 규칙의 또 다른 예외는 파라메트릭 메서드 호출이에요. 예를 들어
List.apply(e)에서 인자e는 hot이 아닐 수 있어요. 그 경우 파라메트릭 메서드 호출의 결과 값은 cold로 취급돼요. -
효과적으로 hot인 인자에 대한 hot 값의 메서드 호출은 hot 결과를 만들어 내요.
이 규칙은 초기화에 대한 지역적 추론으로 보장돼요.
-
ThisRef와 warm 값에 대한 메서드 호출은 정적으로 해석되고 해당 메서드 본문이 검사돼요. -
new 표현식
new p.C(args)에서p와args의 값이 효과적으로 hot이면 결과 값도 hot이에요.이것은 초기화에 대한 지역적 추론으로 보장돼요.
-
new 표현식
new p.C(args)에서p와args의 어떤 값이 효과적으로 hot이 아니면, 결과 값은Warm[C] { outer = Vp, args = Vargs }형태를 가져요. 클래스C의 초기화 코드는 non-hot 값이 제대로 사용되는지 확인하기 위해 다시 검사돼요.위에서
Vp는p의 확장(넓혀진) 값이에요.p가 warm 값Warm[D] { outer = V, args }이고 그것을Warm[D] { outer = Cold, args }로 넓힐 때 확장이 일어나요.변수
Vargs는 non-hot 값을Cold로 넓힌args의 값들을 나타내요.넓히기의 동기는 추상 도메인을 유한하게 만들고 초기화 검사의 종료를 보장하기 위한 거예요.
-
패턴 매치의 스크루티니와
return·throw문의 값은 효과적으로 hot이어야 해요.
모듈성 (Modularity)
분석은 구체 클래스의 주 생성자를 진입점으로 취해요. 그것은 상위 클래스의 생성자를 따르는데, 상위 클래스는 다른 프로젝트에 정의되어 있을 수도 있어요. 분석은 다른 프로젝트에 정의된 상위 클래스를 분석할 때 TASTy를 활용해요.
프로젝트 경계를 넘는 것은 모듈성에 대한 우려를 일으켜요. 객체 지향 프로그래밍에서 상위 클래스와 하위 클래스는 밀접하게 결합된다는 것은 잘 알려져 있어요. 예를 들어 상위 클래스에 메서드를 추가하려면 하위 클래스를 재컴파일해서 안전한 재정의를 검사해야 해요.
초기화도 이 점에서 예외가 아니에요. 객체의 초기화는 본질적으로 하위 클래스와 상위 클래스의 밀접한 상호작용을 수반해요. 상위 클래스가 다른 프로젝트에 정의되어 있다면, 분석의 건전성을 위해 프로젝트 경계를 넘는 것을 피할 수 없어요.
한편 프로젝트 경계를 가로지르는 상속은 오랫동안 검토되어 왔고, open 클래스의 도입이 여기서 우려를 완화해요. 예를 들어 초기화 검사는 open 클래스의 생성자가 this에 대한 메서드 호출을 포함하지 않도록 강제하거나, 계약으로서 어노테이션을 도입할 수 있어요.
이 주제에 대한 커뮤니티의 피드백을 환영해요.
백도어 (Back Doors)
때때로 검사기가 보고하는 경고를 억제하고 싶을 수 있어요. 표현식 e에 대해 검사기를 건너뛰라고 e: @unchecked라고 쓰거나, 옛 트릭인 일부 필드를 lazy로 표시할 수 있어요.
주의사항 (Caveats)
- 이 시스템은 Java나 Scala 2 클래스를 확장할 때 안전성 보장을 제공할 수 없어요.
- 전역 객체의 안전 초기화는 부분적으로만 검사돼요.
참고 문헌 (References)
- Fähndrich, M. and Leino, K.R.M., 2003, July. Heap monotonic typestates. In International Workshop on Aliasing, Confinement and Ownership in object-oriented programming (IWACO).
- Fengyun Liu, Ondřej Lhoták, Aggelos Biboudis, Paolo G. Giarrusso, and Martin Odersky. A type-and-effect system for object initialization. OOPSLA, 2020.
- Fengyun Liu, Ondřej Lhoták, Enze Xing, Nguyen Cao Pham. Safe object initialization, abstractly. Scala 2021.