명시적 널
명시적 널 (Explicit Nulls)
명시적 널(explicit nulls)은 Scala 타입 시스템을 바꾸는 opt-in 기능으로, 참조 타입(즉 AnyRef를 확장하는 모든 것)을 널이 될 수 없게(non-nullable) 만들어요.
본문
명시적 널은 Opt-in 기능으로, 참조 타입(즉 AnyRef를 확장하는 모든 것)을 널이 될 수 없게 만드는 Scala 타입 시스템 변경이에요.
즉 다음 코드는 더 이상 타입 체크를 통과하지 못해요:
val x: String = null // error: found `Null`, but required `String`
대신 타입을 널 가능하게 만들려면 유니온 타입(union type)을 써요:
val x: String | Null = null // ok
널 가능 타입은 런타임에 null 값을 가질 수 있어요. 그래서 널 여부를 확인하지 않고 멤버를 선택하는 것은 안전하지 않아요.
x.trim // error: trim is not member of String | Null
명시적 널은 -Yexplicit-nulls 플래그로 켭니다.
자세한 내용은 계속 읽어 보세요.
새 타입 계층 (New Type Hierarchy)
원래 Null은 모든 참조 타입의 하위 타입이에요.
명시적 널이 켜지면 타입 계층이 바뀌어서 Null이 모든 참조 타입의 하위 타입이 아니라 Any와 Matchable의 하위 타입만 돼요. 즉 null은 더 이상 AnyRef와 그 하위 타입들의 값이 아니에요.
이것이 새 타입 계층이에요:
소거(erasure) 후에는 Null이 모든 참조 타입의 하위 타입으로 남아요(JVM이 강제하니까요).
널 다루기 (Working with Null)
널 가능 값을 더 쉽게 다루기 위해 표준 라이브러리에 몇 가지 유틸리티를 추가할 것을 제안해요. 지금까지 다음이 유용하다는 걸 알게 됐어요:
널 가능성을 "벗겨내는" 확장 메서드 .nn
extension [T](x: T | Null)
inline def nn: T =
assert(x != null)
x.asInstanceOf[T]
이것은 x: String | Null이 주어졌을 때 x.nn이 String 타입이라는 뜻이에요. 그래서 평소 쓰던 모든 메서드를 그 위에 호출할 수 있죠. 물론 x가 null이면 x.nn은 NPE를 던져요.
가변 변수에는 .nn을 직접 쓰지 마세요. 변수의 타입에 알 수 없는 타입을 도입할 수 있기 때문이에요.
unsafeNulls 언어 기능.
이걸 import하면 T | Null을 T로 쓸 수 있어요. 일반 Scala(명시적 널 없음)와 비슷하게요.
자세한 내용은 UnsafeNulls 섹션을 보세요.
불건전함 (Unsoundness)
새 타입 시스템은 null에 대해 불건전(unsound)해요. 즉 여전히 String 같은 널이 아닌 타입을 가진 표현식이 있지만 실제 값은 null인 경우가 있어요.
불건전함이 생기는 이유는 클래스에서 초기화되지 않은 필드가 null로 시작하기 때문이에요:
class C:
val f: String = foo(f)
def foo(f2: String): String = f2
val c = new C()
// c.f == "field is null"
위의 불건전함은 컴파일러가 -Wsafe-init 옵션으로 잡아낼 수 있어요. 더 자세한 내용은 안전한 초기화(safe initialization)에서 볼 수 있어요.
동등성 (Equality)
우리는 여전히 Null과 참조 타입 사이의 이중 등호(==와 !=), 참조(eq와 ne) 비교, 그리고 패턴 매칭을 허용해요. 타입이 널이 아니더라도, Java 메서드나 초기화되지 않은 값 때문에 생길 수 있는 null 값의 가능성은 여전히 고려해야 해요.
Java 상호 운용과 유연 타입 (Java Interoperability and Flexible Types)
Java에서 온 참조 타입을 다룰 때는 이 타입들의 암묵적 널 가능성을 다루는 게 중요해요. 이 타입들을 Scala로 나타내는 가장 정확한 방법은 널 가능 타입을 쓰는 거지만, 널 가능 타입이 많은 걸 직접 다루는 건 성가실 수 있어요. Java 라이브러리와의 상호 작용을 원활하게 하기 위해 우리는 유연 타입(flexible type) 개념을 도입해요.
T?로 표기되는 유연 타입은 독특한 경계를 가진 추상 타입으로 동작해요. 경계는 T | Null ... T이고, T | Null <: T? <: T를 보장해요. 하위 타입 규칙은 Java에서 온 참조 타입을 문맥에 따라 널 가능 또는 널 불가로 취급해요. 이 개념은 Kotlin의 플랫폼 타입(platform types)에서 영감을 받았어요. 이런 타입에 대한 null 검사를 완화함으로써 Scala는 자기 안전 보장을 Java의 그것과 맞춰요. 특히 유연 타입은 비-기호화 가능(non-denotable)해요. 사용자가 코드에서 명시적으로 쓸 수 없고, 컴파일러만이 이 타입을 구성하거나 추론할 수 있다는 뜻이에요.
결과적으로 유연 타입을 가진 값은 널 가능 값과 널 불가 값 양쪽으로 역할할 수 있어요. 게다가 널 가능 값과 널 불가 값 모두 함수 호출 중에 유연 타입을 가진 파라미터로 전달될 수 있어요. 유연 타입의 멤버 함수를 호출하는 것은 허용되지만, 값이 런타임에 실제로 null이라면 NullPointerException을 일으킬 수 있어요.
// Considering class J is from Java
class J {
// Translates to def f(s: String?): Unit
public void f(String s) {
}
// Translates to def g(): String?
public String g() {
return "";
}
}
// Use J in Scala
def useJ(j: J) =
val x1: String = ""
val x2: String | Null = null
j.f(x1) // Passing String to String?
j.f(x2) // Passing String | Null to String?
j.f(null) // Passing Null to String?
// Assign String? to String
val y1: String = j.g()
// Assign String? to String | Null
val y2: String | Null = j.g()
// Calling member functions on flexible types
j.g().trim().length()
Java 클래스를 소스든 바이트코드든 로드할 때, Scala 컴파일러는 널 가능성을 반영하도록 그 멤버들의 타입을 동적으로 조정해요. 이 조정은 필드의 참조 타입뿐 아니라 메서드의 인자 타입과 반환 타입에 유연 타입을 추가하는 것을 포함해요.
규칙을 다음 예시로 설명할게요:
처음 두 규칙은 간단해요. 참조 타입은 널 가능하게(nullify) 하지만 값 타입(value type)은 그렇게 하지 않아요.
class C {
String s;
int x;
}
==>
class C:
val s: String?
val x: Int
타입 파라미터도 널 가능하게 만들어요. Java에서 타입 파라미터는 항상 널 가능하니까요. 그래서 다음 코드는 컴파일돼요.
class C<T> { T foo() { return null; } }
==>
class C[T] { def foo(): T? }
추가해야 할 중복된 널 가능 타입의 수를 줄일 수 있어요. 다음을 생각해 봐요:
class Box<T> { T get(); }
class BoxFactory<T> { Box<T> makeBox(); }
==>
class Box[T] { def get(): T? }
class BoxFactory[T] { def makeBox(): Box[T]? }
BoxFactory[String]이 있다고 가정해 봐요. 그 위에서 makeBox()를 호출하면 Box[T]?를 반환하지, Box[T?]?를 반환하지 않아요. 이는 언뜻 보면 불건전해 보일 수 있어요("박스 안에 null이 있으면 어쩌지?"), 하지만 Box[String]에서 get()을 호출하면 String?을 반환하므로 건전해요.
컴파일되고 있는 Scala 코드에서 접근 가능한 필드나 메서드의 인자 타입 또는 반환 타입에 전이적으로(transitively) 나타나는 모든 Java 정의 클래스에 패치를 적용해야 한다는 점에 주목하세요. 미친 리플렉션 마법이 없는 한, 그런 Java 클래스는 모두 애초에 타이퍼(Typer)에게 보여야 하므로 패치될 거라고 생각해요.
일반 클래스가 Scala로 정의되어 있으면 타입 인자를 패치해요.
class BoxFactory<T> {
Box<T> makeBox(); // Box is Scala-defined
List<Box<List<T>>> makeCrazyBoxes(); // List is Java-defined
}
==>
class BoxFactory[T]:
def makeBox(): Box[T?]?
def makeCrazyBoxes(): java.util.List[Box[java.util.List[T]?]]?
이 경우 Box가 Scala로 정의되어 있으므로 Box[T?]?를 얻게 돼요. 이게 필요한 이유는, 널 가능화 함수가 (모듈적으로) Java 클래스에만 적용되고 Scala 클래스에는 적용되지 않기 때문에, Box에게 널 가능 값을 담고 있다는 걸 알려줄 방법이 필요하기 때문이에요.
List는 Java로 정의되어 있으므로 그 타입 인자는 패치하지 않아요. 하지만 그 안쪽은 여전히 널 가능화해야 해요.
단순 리터럴 상수(final) 필드는 널이 아니라고 알려져 있으므로 널 가능화하지 않아요:
class Constants {
final String NAME = "name";
final int AGE = 0;
final char CHAR = 'a';
final String NAME_GENERATED = getNewName();
}
==>
class Constants:
val NAME: String("name") = "name"
val AGE: Int(0) = 0
val CHAR: Char('a') = 'a'
val NAME_GENERATED: String | Null = getNewName()
NotNull 어노테이션으로 표시된 필드나 메서드의 반환 타입은 패치하지 않아요.
class C {
@NotNull String name;
@NotNull List<String> getNames(String prefix); // List is Java-defined
@NotNull Box<String> getBoxedName(); // Box is Scala-defined
}
==>
class C:
val name: String
def getNames(prefix: String?): java.util.List[String] // we still need to nullify the paramter types
def getBoxedName(): Box[String?] // we don't append `Null` to the outmost level, but we still need to nullify inside
어노테이션이 컴파일러가 NotNull으로 인식하게 되려면 아래 목록에서 와야 해요. 갱신된 목록은 Definitions.scala를 확인하세요.
// A list of annotations that are commonly used to indicate
// that a field/method argument or return type is not null.
// These annotations are used by the nullification logic in
// JavaNullInterop to improve the precision of type nullification.
// We don't require that any of these annotations be present
// in the class path, but we want to create Symbols for the
// ones that are present, so they can be checked during nullification.
@tu lazy val NotNullAnnots: List[ClassSymbol] = ctx.getClassesIfDefined(
"javax.annotation.Nonnull" ::
"edu.umd.cs.findbugs.annotations.NonNull" ::
"androidx.annotation.NonNull" ::
"android.support.annotation.NonNull" ::
"android.annotation.NonNull" ::
"com.android.annotations.NonNull" ::
"org.eclipse.jdt.annotation.NonNull" ::
"org.checkerframework.checker.nullness.qual.NonNull" ::
"org.checkerframework.checker.nullness.compatqual.NonNullDecl" ::
"org.jetbrains.annotations.NotNull" ::
"lombok.NonNull" ::
"io.reactivex.annotations.NonNull" :: Nil map PreNamedString)
유연 타입은 -Yno-flexible-types 플래그로 비활성화할 수 있어요. 대신 평범한 유니온 타입 | Null이 사용됩니다.
오버라이드 검사 (Override check)
Scala 클래스와 Java 클래스 사이의 오버라이딩을 검사할 때, 이 기능에서는 사용자가 Java 라이브러리를 다루도록 돕기 위해 Null 타입에 대한 규칙이 완화돼요.
Java 메서드 String f(String x)가 있다고 가정해 봐요. 이 메서드를 Scala에서 다음 형태 중 아무 것으로나 오버라이드할 수 있어요:
def f(x: String | Null): String | Null
def f(x: String): String | Null
def f(x: String | Null): String
def f(x: String): String
일부 정의는 불건전함을 일으킬 수 있다는 점에 주목하세요. 예를 들어 반환 타입이 널 가능이 아닌데 실제로 null 값이 반환되는 경우가 있어요.
흐름 타이핑 (Flow Typing)
우리는 간단한 형태의 흐름에 민감한 타입 추론(flow-sensitive type inference)을 추가했어요. 아이디어는 이래요. p가 안정 경로(stable path) 또는 추적 가능한 변수라면, p가 null과 비교됐을 때 p가 null이 아니라는 것을 알 수 있어요. 이 정보는 then if-문의 then/else 분기 등으로 전파될 수 있어요.
예시:
val s: String | Null = ???
if s != null then
// s: String
// s: String | Null
assert(s != null)
// s: String
테스트가 p == null이면 else 쪽에도 비슷한 추론을 할 수 있어요:
if s == null then
// s: String | Null
else
// s: String
흐름 추론 목적상 ==와 !=는 비교로 간주됩니다.
논리 연산자 (Logical Operators)
논리 연산자(&&, ||, !)도 지원해요:
val s: String | Null = ???
val s2: String | Null = ???
if s != null && s2 != null then
// s: String
// s2: String
if s == null || s2 == null then
// s: String | Null
// s2: String | Null
else
// s: String
// s2: String
조건 안에서 (Inside Conditions)
&&와 ||가 단락(short-circuit)된다는 점을 고려해, 조건 안에서의 타입 특수화도 지원해요:
val s: String | Null = ???
if s != null && s.length > 0 then // s: String in `s.length > 0`
// s: String
if s == null || s.length > 0 then // s: String in `s.length > 0`
// s: String | Null
else
// s: String
매치 케이스 (Match Case)
매치 문에서 널이 아닌 경우를 감지할 수 있어요.
val s: String | Null = ???
s match
case _: String => // s: String
case _ =>
가변 변수 (Mutable Variable)
일부 로컬 가변 변수의 널 가능성을 감지할 수 있어요. 간단한 예시는:
class C(val x: Int, val next: C | Null)
var xs: C | Null = C(1, C(2, null))
// xs is trackable, since all assignments are in the same method
while xs != null do
// xs: C
val xsx: Int = xs.x
val xscpy: C = xs
xs = xscpy // since xscpy is non-null, xs still has type C after this line
// xs: C
xs = xs.next // after this assignment, xs can be null again
// xs: C | Null
로컬 가변 변수를 다룰 때는 두 가지 질문이 있어요:
흐름 타이핑 동안 로컬 가변 변수를 추적할지 여부. 변수가 클로저에서 할당되지 않으면 로컬 가변 변수를 추적해요. 예를 들어 다음 코드에서 x는 클로저 y가 할당하므로 x에 대해 흐름 타이핑을 하지 않아요.
var x: String | Null = ???
def y =
x = null
if x != null then
// y can be called here, which would break the fact
val a: String = x // error: x is captured and mutated by the closure, not trackable
로컬 가변 변수의 특정 사용에 대해 흐름 타이핑을 생성하고 사용할지 여부. 우리는 로컬 변수의 정의와 같은 메서드에 속한 사용에 대해서만 흐름 타이핑을 하길 원해요. 예를 들어 다음 코드에서 x가 클로저에 의해 할당되지 않더라도, 우리는 등장 중 하나에서만 흐름 타이핑을 쓸 수 있어요(다른 등장은 중첩 클로저 안에서 일어나니까요).
var x: String | Null = ???
def y =
if x != null then
// not safe to use the fact (x != null) here
// since y can be executed at the same time as the outer block
val _: String = x
if x != null then
val a: String = x // ok to use the fact here
x = null
더 많은 예시를 보세요.
현재 우리는 가변 변수 접두부를 가진 경로는 추적할 수 없어요. 예를 들어 x가 가변이라면 x.a 같은 경우요.
지원하지 않는 관용구 (Unsupported Idioms)
다음은 지원하지 않아요:
널 가능성과 무관한 흐름 사실 (예: if x == 0 then { // x: 0.type not inferred })
널이 아닌 경로 사이의 별칭 추적
val s: String | Null = ???
val s2: String | Null = ???
if s != null && s == s2 then
// s: String inferred
// s2: String not inferred
UnsafeNulls
널 가능 값을 많이 다루는 것은 어려워서 unsafeNulls라는 언어 기능을 도입해요. 이 "불안전한" 스코프 안에서는 모든 T | Null 값을 T로 쓸 수 있어요.
사용자는 scala.language.unsafeNulls를 import해서 그런 스코프를 만들 수 있고, -language:unsafeNulls로 이 기능을 전역적으로 켤 수도 있어요(마이그레이션 목적으로만).
T가 참조 타입(AnyRef의 하위 타입)이라고 가정할 때, 이 unsafe-nulls 스코프에서는 다음 불안전 연산 규칙이 적용돼요:
T의 멤버는 T | Null에서도 찾을 수 있어요.
타입 T의 값은 T | Null 및 Null과 비교될 수 있어요.
T1이 명시적-널 하위 타입 규칙(Null이 Any의 직접 하위 타입인 규칙)에서 T2의 하위 타입이 아닐 때, T1이 일반 하위 타입 규칙(Null이 모든 참조 타입의 하위 타입인 규칙)에서 T2의 하위 타입이라면, T2용으로 설계된 확장 메서드와 암시적 변환을 T1에 쓸 수 있어요.
T1이 명시적-널 하위 타입 규칙에서 T2의 하위 타입이 아닐 때, T1이 일반 하위 타입 규칙에서 T2의 하위 타입이라면, 타입 T1의 값을 T2로 쓸 수 있어요.
추가로, null은 AnyRef(즉 Object)로 쓸 수 있어요. 그래서 그 위에서 .eq나 .toString을 선택할 수 있어요.
unsafeNulls 안의 프로그램은 일반 Scala와 비슷한 의미론을 가지지만, 동등하지는 않아요.
예를 들어 다음 코드는 unsafe nulls를 써도 컴파일되지 않아요. Java 상호 운용 때문에 get 메서드의 타입이 T | Null이 되니까요.
def head[T](xs: java.util.List[T]): T = xs.get(0) // error
컴파일러는 T가 참조 타입인지 알 수 없으므로 T | Null을 T로 캐스트할 수 없어요. 오류를 고치려면 사용자가 xs.get(0) 뒤에 .nn을 직접 넣어야 해요. 그러면 타입에서 Null이 벗겨져요.
이 unsafeNulls의 의도는 사용자에게 명시적 널로 가는 더 나은 마이그레이션 경로를 주는 거예요. Scala 2나 일반 Scala 3 프로젝트는 컴파일 옵션에 -Yexplicit-nulls -language:unsafeNulls를 추가해서 시도해 볼 수 있어요. 약간의 수동 수정이 예상돼요. 나중에 완전한 명시적 널 기능으로 마이그레이션하려면 -language:unsafeNulls를 빼고 필요할 때만 import scala.language.unsafeNulls를 추가하면 돼요.
def f(x: String): String = ???
def nullOf[T >: Null]: T = null
import scala.language.unsafeNulls
val s: String | Null = ???
val a: String = s // unsafely convert String | Null to String
val b1 = s.trim // call .trim on String | Null unsafely
val b2 = b1.length
f(s).trim // pass String | Null as an argument of type String unsafely
val c: String = null // Null to String
val d1: Array[String] = ???
val d2: Array[String | Null] = d1 // unsafely convert Array[String] to Array[String | Null]
val d3: Array[String] = Array(null) // unsafe
class C[T >: Null <: String] // define a type bound with unsafe conflict bound
val n = nullOf[String] // apply a type bound unsafely
unsafeNulls가 없으면 이 모든 불안전 연산은 타입 체크되지 않아요.
unsafeNulls는 확장 메서드와 암시적 검색에서도 동작해요.
import scala.language.unsafeNulls
val x = "hello, world!".split(" ").map(_.length)
given Conversion[String, Array[String]] = _ => ???
val y: String | Null = ???
val z: Array[String | Null] = y
이진 호환성 (Binary Compatibility)
명시적 널 이전의 Scala 바이너리와 -Yexplicit-nulls 없이 컴파일된 새 라이브러리와의 이진 호환성 전략은, 타입을 변경하지 않고 호환되되 불건전(unsound)하게 남기는 것입니다.