널 세이프티 이해하기

널 세이프티 이해하기

널 세이프티와 관련된 다트 언어·라이브러리 변경에 대한 심층 분석이에요.

출처: Understanding null safety

본문

글: Bob Nystrom, 2020년 7월

널 세이프티(null safety)는 다트 2.0에서 원래의 불건전한(unsound) 선택적 타입 시스템을 사운드한 정적 타입 시스템으로 교체한 이후, 다트에 가한 가장 큰 변경이에요. 다트가 처음 출시됐을 때 컴파일 타임 널 세이프티는 긴 소개가 필요한 드문 기능이었어요. 오늘날 코틀린(Kotlin), 스위프트(Swift), 러스트(Rust) 등 많은 언어가 이제는 매우 익숙해진 이 문제에 각자의 답을 갖고 있답니다. 여기 예시가 있어요.

// Without null safety:
bool isEmpty(String string) => string.length == 0;

void main() {
  isEmpty(null);
}

이 다트 프로그램을 널 세이프티 없이 실행하면, .length 호출에서 NoSuchMethodError 예외가 발생해요. 널 값은 Null 클래스의 인스턴스이고, Null에는 "length" 게터가 없거든요. 런타임 실패는 정말 좋지 않아요. 특히 최종 사용자의 기기에서 실행되도록 설계된 다트 같은 언어에서는 더 그렇죠. 서버 애플리케이션이 실패하면 보통 아무도 눈치채기 전에 재시작할 수 있어요. 하지만 플러터 앱이 사용자 휴대폰에서 크래시하면, 사용자는 기분이 좋지 않아요. 사용자가 행복하지 않으면, 여러분도 행복하지 않아요.

개발자들은 다트 같은 정적 타입 언어를 좋아해요. 타입 체커가 컴파일 시점에, 보통은 IDE 안에서 바로 코드의 실수를 찾아내기 때문이에요. 버그를 빨리 찾을수록 빨리 고칠 수 있어요. 언어 설계자들이 "널 참조 오류를 고치는 것"에 대해 말할 때, 그들은 정적 타입 체커를 풍부하게 만들어서 널일 수도 있는 값에 .length를 호출하는 것 같은 위의 실수를 언어가 감지하도록 하는 것을 의미해요.

이 문제에 대한 단 하나의 정답은 없어요. 러스트와 코틀린은 각자 그 언어의 맥락에서 타당한 자신만의 접근 방식을 갖고 있어요. 이 문서는 다트를 위한 우리의 답의 모든 세부 사항을 안내해요. 그것은 정적 타입 시스템의 변경과, 널 세이프트 코드를 작성할 수 있을 뿐만 아니라 희망적으로 즐겁게 작성할 수 있게 해 주는 여러 다른 수정 사항과 새로운 언어 기능들을 포함해요.

이 문서는 깁니다. 더 짧은 것, 즉 시작하고 실행하는 데 알아야 할 것만 담은 것을 원한다면 개요(overview)부터 시작해요. 더 깊이 이해할 준비가 되고 시간이 있을 때 여기로 돌아와서, 언어가 널을 어떻게 다루는지, 왜 그렇게 설계했는지, 그리고 관용적이고 현대적인 널 세이프트 다트를 어떻게 작성하는지 이해해 보세요. (스포일러: 결국 오늘날 여러분이 다트를 쓰는 방식과 놀랍도록 비슷하게 되돌아옵니다.)

언어가 널 참조 오류를 다루는 다양한 방식에는 각자의 장단점이 있어요. 다음 원칙들이 우리가 내린 선택들을 이끌었어요.

  • 코드는 기본적으로 안전해야 해요. 명시적으로 안전하지 않은 기능을 쓰지 않는 새 다트 코드는 런타임에 절대 널 참조 오류를 던지지 않아요. 모든 가능한 널 참조 오류는 정적으로 잡혀요. 더 큰 유연성을 위해 검사 중 일부를 런타임으로 미루고 싶다면 그렇게 할 수 있지만, 코드에서 텍스트로 보이는 어떤 기능을 사용함으로써 그 선택을 해야 해요. 즉, 우리는 구명조끼를 주고 매번 물에 나갈 때마다 입는 걸 기억하라고 맡기지 않아요. 대신 가라앉지 않는 배를 줘요. 스스로 배 밖으로 뛰어내리지 않는 한 마르게 돼 있어요.
  • 널 세이프트 코드는 작성하기 쉬워야 해요. 대부분의 기존 다트 코드는 동적으로 올바르고 널 참조 오류를 던지지 않아요. 여러분은 지금의 다트 프로그램 방식을 좋아하고, 우리는 그렇게 계속 코드를 쓸 수 있게 하고 싶어요. 안전성이 사용성을 희생하거나, 타입 체커에게 벌을 지불하거나, 생각하는 방식을 크게 바꿔야 하는 것을 요구해서는 안 돼요.
  • 결과적으로 나오는 널 세이프트 코드는 완전히 사운드(sound)해야 해요. 정적 검사 맥락에서 "사운드음(soundness)"은 사람마다 다르게 의미해요. 우리에게는 널 세이프티 맥락에서, 표현식이 널을 허용하지 않는 정적 타입을 가지면 그 표현식의 어떤 실행도 결코 null로 평가될 수 없다는 뜻이에요. 언어는 이 보장을 주로 정적 검사를 통해 제공하지만 런타임 검사도 포함될 수 있어요. (다만 첫 번째 원칙을 기억하세요: 그런 런타임 검사가 일어나는 곳은 언제나 여러분의 선택이에요.)

사운드음은 사용자 신뢰에 중요해요. 대부분 떠 있는 배는 망망대해를 갈 용기를 주지 않아요. 하지만 용기 있는 컴파일러 해커들에게도 중요해요. 언어가 프로그램의 의미적 속성에 대해 단단한 보장을 할 때, 컴파일러는 그 속성이 참이라고 가정하는 최적화를 수행할 수 있어요. 널에 관해서라면, 불필요한 널 검사를 제거한 더 작은 코드와, 메서드를 호출하기 전에 수신자가 널이 아닌지 확인할 필요가 없는 더 빠른 코드를 생성할 수 있다는 뜻이에요.

한 가지 주의 사항: 우리는 완전히 널 세이프트한 다트 프로그램에서만 사운드음을 보장해요. 다트는 새로운 널 세이프트 코드와 오래된 레거시 코드가 섞인 프로그램을 지원해요. 이런 혼합 버전 프로그램에서는 널 참조 오류가 여전히 발생할 수 있어요. 혼합 버전 프로그램에서는 널 세이프트한 부분에서 모든 정적 안전성 이점을 얻지만, 전체 애플리케이션이 널 세이프트가 될 때까지 완전한 런타임 사운드음은 얻지 못해요.

널을 제거하는 것이 목표가 아니라는 점을 기억하세요. 널 자체에는 문제가 없어요. 반대로 값의 부재(absence)를 나타낼 수 있는 것은 정말 유용해요. 특별한 "부재" 값을 언어에 직접 내장하면 부재를 다루는 것이 유연하고 사용 가능해져요. 그것은 선택적 매개변수, 편리한 ?. 널 인지 연산자, 기본 초기화를 뒷받침해요. 나쁜 것은 널이 아니라, 널이 예상하지 못한 곳으로 가는 것이 문제를 일으키는 거예요.

따라서 널 세이프티에서 우리의 목표는 널이 프로그램을 통해 흐를 수 있는 곳에 대한 통제와 통찰을 주고, 크래시를 일으킬 곳으로는 흐를 수 없다는 확신을 주는 거예요.

타입 시스템에서의 널 가능성

널 세이프티는 정적 타입 시스템에서 시작해요. 다른 모든 것이 그 위에 놓이기 때문이죠. 여러분의 다트 프로그램에는 온갖 타입이 존재해요: intString 같은 원시 타입, List 같은 컬렉션 타입, 그리고 여러분과 여러분이 쓰는 패키지들이 정의하는 모든 클래스와 타입. 널 세이프티 이전에는 정적 타입 시스템이 그 어떤 타입의 표현식에도 null 값이 흘러들어오는 것을 허용했어요.

타입 이론 용어로 말하면, Null 타입은 모든 타입의 서브타입으로 취급됐어요.

어떤 표현식에 허용되는 연산 집합—게터, 세터, 메서드, 연산자—은 그 타입에 의해 정의돼요. 타입이 List이면 .add()[]를 호출할 수 있어요. int라면 +를 호출할 수 있어요. 하지만 널 값은 그런 어떤 메서드도 정의하지 않아요. null이 다른 어떤 타입의 표현식으로 흘러들어가는 것을 허용하면 그 어떤 연산도 실패할 수 있어요. 이것이 바로 널 참조 오류의 핵심이에요—모든 실패는 null에 없는 메서드나 프로퍼티를 null에서 찾으려는 데서 옵니다.

널 아님 타입과 널 가능 타입

널 세이프티는 타입 계층을 바꿈으로써 그 문제를 뿌리에서 제거해요. Null 타입은 여전히 존재하지만 더 이상 모든 타입의 서브타입이 아니에요. 대신 타입 계층은 이렇게 생겼어요.

Null이 더 이상 서브타입이 아니므로, 특별한 Null 클래스를 제외한 어떤 타입도 null 값을 허용하지 않아요. 우리는 모든 타입을 기본적으로 널 불가(non-nullable)하게 만들었어요. String 타입의 변수가 있다면 항상 문자열을 담고 있을 거예요. 자, 모든 널 참조 오류를 고쳤어요.

만약 우리가 널이 전혀 유용하지 않다고 생각했다면 여기서 멈출 수 있었을 거예요. 하지만 널은 유용해서 여전히 그것을 다룰 방법이 필요해요. 선택적 매개변수(optional parameters)가 좋은 예시적인 경우예요. 이 널 세이프트 다트 코드를 생각해 보세요.

// Using null safety:
void makeCoffee(String coffee, [String? dairy]) {
  if (dairy != null) {
    print('$coffee with $dairy');
  } else {
    print('Black $coffee');
  }
}

여기서 우리는 dairy 매개변수가 어떤 문자열이나 null 값을 받아들이되 다른 것은 받지 않기를 원해요. 그것을 표현하기 위해, 밑에 깔린 기본 타입 String의 끝에 ?를 붙여서 dairy에 널 가능(nullable) 타입을 줘요. 내부적으로 이것은 기본 타입과 Null 타입의 합집합(union)을 정의하는 것과 본질적으로 같아요. 다트에 완전한 기능의 합집합 타입이 있다면 String?String|Null의 축약형이 되는 거예요.

널 가능 타입 사용하기

널 가능 타입을 가진 표현식이 있으면 그 결과로 무엇을 할 수 있을까요? 우리의 원칙이 기본적으로 안전하므로, 그 답은 "별로 없다"예요. 기본 타입의 메서드를 호출하는 것을 허용할 수 없어요—값이 null이면 실패할 수 있으니까요.

// Hypothetical unsound null safety:
void bad(String? maybeString) {
  print(maybeString.length);
}

void main() {
  bad(null);
}

이건 우리가 실행을 허용한다면 크래시할 거예요. 안전하게 접근할 수 있게 허용하는 유일한 메서드와 프로퍼티는 기본 타입과 Null 클래스 양쪽 모두가 정의한 것들이에요. 그것은 toString(), ==, hashCode뿐이에요. 그래서 널 가능 타입을 맵 키로 쓰거나, 집합에 저장하거나, 다른 값과 비교하거나, 문자열 보간에 사용할 수 있지만, 그 정도가 전부예요.

그들은 널 불가 타입과 어떻게 상호작용할까요? 널 가능 타입을 기대하는 곳에 널 불가 타입을 전달하는 것은 항상 안전해요. 함수가 String?을 받아들인다면 String을 전달하는 것은 문제를 일으키지 않으므로 허용돼요. 우리는 모든 널 가능 타입을 그 기본 타입의 슈퍼타입으로 만들어서 이것을 모델링해요. 또한 null을 널 가능 타입을 기대하는 곳에 안전하게 전달할 수 있으므로, Null도 모든 널 가능 타입의 서브타입이 돼요.

하지만 반대 방향으로 가서 널 가능 타입을 밑에 깔린 널 불가 타입을 기대하는 곳에 전달하는 것은 안전하지 않아요. String을 기대하는 코드는 값에 String 메서드를 호출할 수 있어요. 거기에 String?을 전달하면 null이 흘러들어가서 실패할 수 있어요.

// Hypothetical unsound null safety:
void requireStringNotNull(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  String? maybeString = null; // Or not!
  requireStringNotNull(maybeString);
}

이 프로그램은 안전하지 않아서 허용해서는 안 돼요. 하지만 다트에는 항상 **암시적 다운캐스트(implicit downcasts)**라는 것이 있었어요. 예를 들어 Object 타입의 값을 String을 기대하는 함수에 전달하면 타입 체커가 허용해요.

// Without null safety:
void requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString);
}

사운드음을 유지하기 위해, 컴파일러는 requireStringNotObject()에 대한 인자에 as String 캐스트를 조용히 삽입해요. 그 캐스트는 실패해서 런타임에 예외를 던질 수 있지만, 컴파일 시점에 다트는 이를 괜찮다고 말해요. 널 불가 타입이 널 가능 타입의 서브타입으로 모델링되므로, 암시적 다운캐스트는 String?String을 기대하는 곳에 전달하도록 허용할 거예요. 그것을 허용하면 기본적으로 안전하다는 우리의 목표를 위반해요. 그래서 널 세이프티와 함께 우리는 암시적 다운캐스트를 완전히 제거하고 있어요.

이로 인해 requireStringNotNull() 호출이 컴파일 오류를 만들어 내는데, 그것이 여러분이 원하는 거예요. 하지만 그것은 모든 암시적 다운캐스트가 컴파일 오류가 된다는 뜻이기도 해요—requireStringNotObject() 호출을 포함해서요. 여러분이 직접 명시적 다운캐스트를 추가해야 해요.

// Using null safety:
void requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString as String);
}

우리는 이 변화가 전반적으로 좋다고 생각해요. 대부분의 사용자가 암시적 다운캐스트를 결코 좋아하지 않았다는 인상을 받았어요. 특히 다음과 같은 것에 데인 적이 있을 거예요.

// Without null safety:
List<int> filterEvens(List<int> ints) {
  return ints.where((n) => n.isEven);
}

버그가 보이나요? .where() 메서드는 게으르기(lazy) 때문에 Iterable을 반환하지 List를 반환하지 않아요. 이 프로그램은 컴파일되지만, 그 IterablefilterEvens가 반환한다고 선언한 List 타입으로 캐스팅하려다가 런타임에 예외를 던져요. 암시적 다운캐스트가 제거되면서 이것은 컴파일 오류가 돼요.

어디까지 왔죠? 그렇죠, 우리가 프로그램의 타입 우주를 두 부분으로 나눈 것처럼 됐어요.

널 불가 타입의 영역이 있어요. 그 타입들은 모든 흥미로운 메서드에 접근하게 해 주지만 절대 null을 담을 수 없어요. 그리고 대응하는 모든 널 가능 타입의 평행한 계열이 있어요. 그것들은 null을 허용하지만 별로 할 수 있는 게 없어요. 우리는 값을 널 불가 쪽에서 널 가능 쪽으로 흐르게 허용해요—그렇게 하는 것이 안전하니까—하지만 반대 방향은 허용하지 않아요.

그렇다면 널 가능 타입은 기본적으로 쓸모없어 보이네요. 메서드가 없고 빠져나갈 수도 없어요. 걱정 마세요, 우리는 값들을 널 가능 절반에서 다른 쪽으로 옮기는 데 도움을 주는 기능들의 모음을 곧 다룰 거예요.

Top과 Bottom

이 섹션은 조금 난해해요. 타입 시스템에 빠져 있지 않다면, 맨 끝의 두 불릿을 제외하고는 대부분 건너뛸 수 있어요. 프로그램의 모든 타입을, 서로 슈퍼타입·서브타입 관계인 것들 사이에 간선(edge)이 있다고 상상해 보세요. 이 문서의 그림처럼 그려 보면, Object 같은 슈퍼타입이 위쪽 근처에 있고 여러분만의 타입 같은 리프 클래스가 아래쪽 근처에 있는 거대한 방향 그래프를 이룰 거예요.

그 방향 그래프가 위쪽에서 꼭짓점으로 모여 서브타입(직접 또는 간접적으로)인 단일 타입이 있으면, 그 타입을 top 타입이라고 불러요. 마찬가지로, 맨 아래에 모든 타입의 서브타입인 이상한 타입이 있으면 bottom 타입이 있어요. (이 경우 방향 그래프는 격자(lattice)예요.)

타입 시스템에 top과 bottom 타입이 있으면 편리해요. 최소 상한(least upper bound)(타입 추론이 조건부 표현식의 타입을 두 분기의 타입으로부터 알아내는 데 사용) 같은 타입 수준 연산이 항상 타입을 만들어 낼 수 있기 때문이에요. 널 세이프티 이전에 Object는 다트의 top 타입이고 Null은 bottom 타입이었어요.

Object는 이제 널 불가이므로 더 이상 top 타입이 아니에요. Null은 그 서브타입이 아니에요. 다트에는 이름 붙은 top 타입이 없어요. top 타입이 필요하다면 Object?가 필요해요. 마찬가지로 Null은 더 이상 bottom 타입이 아니에요. 그것이 bottom 타입이었다면 모든 것이 여전히 널 가능했을 거예요. 대신 우리는 Never라는 새로운 bottom 타입을 추가했어요.

실제로 이것이 의미하는 바는:

  • 어떤 타입의 값이라도 허용한다는 것을 나타내고 싶다면 Object 대신 Object?를 사용해요. 사실 Object를 쓰는 것은 꽤 드물어지는데, 그 타입이 "이상하게 금지된 널 값을 제외한 어떤 가능한 값"을 의미하기 때문이에요.
  • 드물게 bottom 타입이 필요할 때는 Null 대신 Never를 사용해요. 이것은 함수가 결코 반환하지 않는다는 것을 나타내는 데 특히 유용해서 **도달 가능성 분석(reachability analysis)**을 도와 줘요. bottom 타입이 필요한지 모르겠다면, 아마 필요 없을 거예요.

올바름 보장하기

우리는 타입의 우주를 널 가능과 널 불가 절반으로 나눴어요. 사운드음과, 여러분이 요청하지 않는 한 런타임에 절대 널 참조 오류를 얻지 못한다는 원칙을 유지하기 위해, 우리는 null이 널 불가 쪽의 어떤 타입에도 결코 나타나지 않도록 보장해야 해요.

암시적 다운캐스트 제거와 Null을 bottom 타입으로 제거하는 것은 프로그램을 통해 타입이 흐르는 주요 지점들—할당을 통해서, 그리고 함수 호출에서 인자에서 매개변수로—을 모두 덮어요. null이 숨어들어올 수 있는 주요 나머지 지점은 변수가 처음 생겨날 때와 함수를 떠날 때예요. 그래서 몇 가지 추가 컴파일 오류가 있어요.

유효하지 않은 반환(Invalid returns)

함수가 널 불가 반환 타입을 가진다면, 함수를 통과하는 모든 경로가 값을 반환하는 return 문에 도달해야 해요. 널 세이프티 이전에 다트는 누락된 반환에 대해 꽤 관대했어요. 예를 들어:

// Without null safety:
String missingReturn() {
  // No return.
}

이걸 분석하면 아마 return을 잊었다는 부드러운 힌트를 얻지만, 아니어도 큰일은 아니에요. 함수 본문의 끝에 실행이 도달하면 다트는 암시적으로 null을 반환하기 때문이에요. 모든 타입이 널 가능하므로, 기술적으로 이 함수는 안전해요—아마 원하는 게 아니지만요.

사운드한 널 불가 타입에서는 이 프로그램은 완전히 틀리고 안전하지 않아요. 널 세이프티 아래에서, 널 불가 반환 타입을 가진 함수가 확실히 값을 반환하지 않으면 컴파일 오류를 얻어요. "확실히"라는 말은, 언어가 함수를 통과하는 모든 제어 흐름 경로를 분석한다는 뜻이에요. 그 모두가 무언가를 반환하기만 하면 만족돼요. 그 분석은 꽤 똑똑해서, 심지어 이 함수도 괜찮아요.

// Using null safety:
String alwaysReturns(int n) {
  if (n == 0) {
    return 'zero';
  } else if (n < 0) {
    throw ArgumentError('Negative values not allowed.');
  } else {
    if (n > 1000) {
      return 'big';
    } else {
      return n.toString();
    }
  }
}

새로운 흐름 분석에 대해서는 다음 섹션에서 더 깊이 다룰 거예요.

초기화되지 않은 변수

변수를 선언할 때 명시적 초기화를 주지 않으면, 다트는 변수를 null로 기본 초기화해요. 그것은 편리하지만, 변수의 타입이 널 불가라면 당연히 완전히 안전하지 않아요. 그래서 널 불가 변수에 대해 규칙을 엄격하게 해야 해요.

  • Top-level 변수와 static 필드 선언은 초기화를 가져야 해요. 이들은 프로그램 어디에서든 접근·할당될 수 있으므로, 컴파일러가 사용 전에 변수에 값이 주어졌다고 보장하는 것은 불가능해요. 유일한 안전한 선택은 선언 자체가 올바른 타입의 값을 만들어 내는 초기화 표현식을 갖도록 요구하는 거예요.
// Using null safety:
int topLevel = 0;

class SomeClass {
  static int staticField = 0;
}
  • Instance 필드는 선언에서 초기화를 갖거나, 초기화 형식 매개변수(initializing formal)를 사용하거나, 생성자의 초기화 리스트에서 초기화해야 해요. 전문 용어가 많네요. 여기 예시가 있어요.
// Using null safety:
class SomeClass {
  int atDeclaration = 0;
  int initializingFormal;
  int initializationList;

  SomeClass(this.initializingFormal)
      : initializationList = 0;
}

다시 말해, 생성자 본문에 도달하기 전에 필드가 값을 갖기만 하면 괜찮아요.

  • 지역 변수(local variables)가 가장 유연한 경우예요. 널 불가 지역 변수는 초기화가 필요하지 않아요. 이것은 완전히 괜찮아요.
// Using null safety:
int tracingFibonacci(int n) {
  int result;
  if (n < 2) {
    result = n;
  } else {
    result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
  }

  print(result);
  return result;
}

규칙은 지역 변수가 사용되기 전에 반드시 확실히 할당(definitely assigned)되어야 한다는 것뿐이에요. 이에도 앞서 언급한 새로운 흐름 분석에 의존해요. 변수 사용으로 가는 모든 경로가 먼저 변수를 초기화하기만 하면 사용은 괜찮아요.

  • 선택 매개변수(optional parameters)는 기본값을 가져야 해요. 선택적 위치 또는 명명 매개변수에 인자를 전달하지 않으면, 언어가 기본값으로 채워 넣어요. 기본값을 지정하지 않으면 기본 기본값이 null인데, 매개변수의 타입이 널 불가면 안 되죠.

그래서 매개변수를 선택적으로 만들고 싶다면, 널 가능하게 만들거나 유효한 널 불가 기본값을 지정해야 해요.

이 제한들은 어렵게 들리지만 실제로는 그리 나쁘지 않아요. 그것들은 final 변수 주변의 기존 제한과 매우 비슷해서, 여러분은 아마 눈치채지 못한 채 수년간 그렇게 작업해 왔을 거예요. 또한 이것들은 널 불가 변수에만 적용된다는 점을 기억하세요. 항상 타입을 널 가능하게 만들고 기본 초기화를 null로 받을 수 있어요.

그래도 규칙이 마찰을 일으켜요. 다행히도, 우리는 이런 새로운 제한이 여러분을 느리게 하는 가장 흔한 패턴에 윤활유를 바르는 새로운 언어 기능들을 갖추고 있어요. 하지만 그 전에 흐름 분석에 대해 이야기할 시간이에요.

흐름 분석(Flow analysis)

제어 흐름 분석은 컴파일러에서 수년간 있어 왔어요. 주로 사용자에게 숨겨져 있고 컴파일러 최적화 중에 사용되지만, 몇몇 최신 언어는 보이는 언어 기능에 같은 기법을 쓰기 시작했어요. 다트는 이미 타입 승격(type promotion) 형태로 흐름 분석을 한 줄 담고 있어요.

// With (or without) null safety:
bool isEmptyList(Object object) {
  if (object is List) {
    return object.isEmpty; // <-- OK!
  } else {
    return false;
  }
}

표시된 줄에서 objectisEmpty를 호출할 수 있는 것을 주목하세요. 그 메서드는 Object가 아니라 List에 정의돼 있어요. 이게 동작하는 이유는 타입 체커가 프로그램의 모든 is 표현식과 제어 흐름 경로를 보기 때문이에요. 어떤 제어 흐름 구조의 본문이 변수에 대한 특정 is 표현식이 참일 때만 실행된다면, 그 본문 안에서 변수의 타입은 검사된 타입으로 "승격(promoted)"돼요.

여기 예시에서 if 문의 then 분기는 object가 실제로 리스트를 담고 있을 때만 실행돼요. 그래서 다트는 object를 선언된 타입 Object 대신 List로 승격해요. 이것은 편리한 기능이지만 꽤 제한적이에요. 널 세이프티 이전에, 다음의 기능적으로 동일한 프로그램은 동작하지 않았어요.

// Without null safety:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty; // <-- Error!
}

다시, object가 리스트를 담고 있을 때만 .isEmpty 호출에 도달할 수 있으므로 이 프로그램은 동적으로 올바르지만, 타입 승격 규칙이 return 문이 두 번째 문에 object가 리스트일 때만 도달할 수 있게 한다는 것을 알아보기엔 충분히 똑똑하지 않았어요.

널 세이프티를 위해 우리는 이 제한된 분석을 가져와 여러 방식으로 훨씬 더 강력하게 만들었어요.

도달 가능성 분석(Reachability analysis)

먼저, 타입 승격이 조기 반환과 다른 도달할 수 없는 코드 경로에 대해 똑똑하지 않다는 오래된 불만을 고쳤어요. 함수를 분석할 때 이제 return, break, throw 및 함수에서 실행이 조기에 종료될 수 있는 다른 모든 방식을 고려해요. 널 세이프티 아래에서 이 함수는:

// Using null safety:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty;
}

이제 완벽하게 유효해요. objectList가 아닐 때 if 문이 함수를 빠져나가므로, 다트는 두 번째 문에서 objectList로 승격해요. 이것은 정말 좋은 개선이라 널 가능성과 무관한 많은 다트 코드에 도움이 돼요.

도달할 수 없는 코드를 위한 Never

이 도달 가능성 분석을 프로그래밍할 수도 있어요. 새로운 bottom 타입 Never에는 값이 없어요. (어떤 값이 동시에 String, bool, int일 수 있을까요?) 그렇다면 표현식이 Never 타입을 갖는 것은 무엇을 의미할까요? 그 표현식이 결코 성공적으로 평가를 끝낼 수 없다는 뜻이에요. 예외를 던지거나, 중단되거나, 아니면 표현식 결과를 기대하는 주변 코드가 실행되지 않도록 해야 해요.

사실, 언어에 따르면 throw 표현식의 정적 타입은 Never예요. Never 타입은 핵심 라이브러리에 선언되어 있고 타입 애너테이션으로 사용할 수 있어요. 특정 종류의 예외를 더 쉽게 던지는 헬퍼 함수가 있을 수도 있겠죠.

// Using null safety:
Never wrongType(String type, Object value) {
  throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}

이렇게 사용할 수 있어요.

// Using null safety:
class Point {
  final int x, y;

  Point(this.x, this.y);

  Point operator +(Object other) {
    if (other is int) return Point(x + other, y + other);
    if (other is! Point) wrongType('int | Point', other);

    print('Adding two Point instances together: $this + $other');
    return Point(x + other.x, y + other.y);
  }

  // toString, hashCode, and other implementations...
}

이 프로그램은 오류 없이 분석돼요. + 메서드의 마지막 줄이 other에서 .x.y에 접근하는 것을 주목하세요. 함수에 어떤 return이나 throw도 없지만 Point로 승격됐어요. 제어 흐름 분석은 wrongType()의 선언된 타입이 Never라는 것, 즉 if 문의 then 분기가 어쨌든 중단해야 한다는 것을 알아요. 마지막 문은 otherPoint일 때만 도달할 수 있으므로 다트가 그것을 승격해요.

즉, 자신의 API에서 Never를 사용하면 다트의 도달 가능성 분석을 확장할 수 있어요.

확실한 할당 분석(Definite assignment analysis)

지역 변수와 함께 이걸 간단히 언급했었어요. 다트는 널 불가 지역 변수가 읽히기 전에 항상 초기화되도록 보장해야 해요. 우리는 확실한 할당 분석을 사용해서 그것을 가능한 한 유연하게 해요. 언어는 각 함수 본문을 분석하고 모든 제어 흐름 경로를 통해 지역 변수와 매개변수에 대한 할당을 추적해요. 변수의 어떤 사용에 도달하는 모든 경로에서 변수가 할당되기만 하면, 변수는 초기화된 것으로 간주돼요. 이것은 초기화가 없는 변수를 선언한 다음 복잡한 제어 흐름을 사용해 나중에 초기화하게 해 줘요—변수가 널 불가 타입일 때조차 말이죠.

우리는 또한 final 변수를 더 유연하게 만들기 위해 확실한 할당 분석을 사용해요. 널 세이프티 이전에는, 어떤 재미있는 방식으로 초기화해야 한다면 지역 변수에 final을 사용하기가 어려울 수 있었어요.

// Using null safety:
int tracingFibonacci(int n) {
  final int result;
  if (n < 2) {
    result = n;
  } else {
    result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
  }

  print(result);
  return result;
}

result 변수가 final인데 초기화가 없으므로 이건 오류였을 거예요. 널 세이프티 아래의 더 똑똑한 흐름 분석으로는 이 프로그램이 괜찮아요. 분석은 result가 모든 제어 흐름 경로에서 정확히 한 번 확실히 초기화된다는 것을 알 수 있어서, 변수를 final로 표시하기 위한 제약이 충족돼요.

널 검사에 대한 타입 승격

더 똑똑한 흐름 분석은 널 가능성과 무관한 많은 다트 코드에 도움이 돼요. 하지만 지금 이런 변경을 하는 것은 우연이 아니에요. 우리는 타입을 널 가능과 널 불가 집합으로 분할했어요. 널 가능 타입의 값을 가지면 실제로 유용한 것을 할 수 없어요. 값이 null인 경우에는 그 제한이 좋아요. 크래시를 막아 주니까요.

하지만 값이 null이 아니라면, 그것을 널 불가 쪽으로 옮겨서 메서드를 호출할 수 있게 하는 것이 좋을 거예요. 흐름 분석은 지역 변수와 매개변수(그리고 다트 3.2부터는 private final 필드)에 대해 이것을 하는 주요 방법 중 하나예요. 우리는 타입 승격을 == null!= null 표현식도 보도록 확장했어요.

널 가능 타입의 지역 변수를 검사해서 null이 아닌지 확인하면, 다트는 그 변수를 밑에 깔린 널 불가 타입으로 승격해요.

// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments != null) {
    result += ' ' + arguments.join(' ');
  }
  return result;
}

여기서 arguments는 널 가능 타입이에요. 보통은 그것에 .join()을 호출하는 것이 금지돼요. 하지만 값이 null이 아닌지 확인하는 if 문으로 그 호출을 보호했기 때문에, 다트는 그것을 List<String>?에서 List<String>으로 승격하고 메서드를 호출하거나 널 불가 리스트를 기대하는 함수에 전달하게 해 줘요.

이것은 꽤 사소해 보이지만, 널 검사에 대한 이런 흐름 기반 승격이 대부분의 기존 다트 코드가 널 세이프티 아래에서 동작하게 만드는 것이에요. 대부분의 다트 코드는 동적으로 올바르고, 메서드를 호출하기 전에 null을 검사함으로써 널 참조 오류를 던지는 것을 실제로 피해요. 널 검사에 대한 새로운 흐름 분석은 그 동적 올바름을 증명 가능한 정적 올바름으로 바꿔 줘요.

물론 이것은 우리가 도달 가능성에 대해 하는 더 똑똑한 분석과도 잘 동작해요. 위 함수는 이렇게도 쓸 수 있어요.

// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments == null) return result;
  return result + ' ' + arguments.join(' ');
}

언어는 어떤 종류의 표현식이 승격을 일으키는지에 대해서도 더 똑똑해졌어요. 명시적 == null이나 != null은 당연히 동작해요. 하지만 as를 사용한 명시적 캐스트, 할당, 또는 (나중에 다룰) 후위 ! 연산자도 승격을 일으켜요. 일반적인 목표는 코드가 동적으로 올바르고 정적으로 그것을 알아내는 것이 합리적이라면, 분석이 그렇게 알아낼 만큼 영리해야 한다는 거예요.

타입 승격은 원래 지역 변수에서만 동작했고, 이제 다트 3.2부터는 private final 필드에서도 동작한다는 점을 주목하세요. 비지역 변수 작업에 대한 자세한 정보는 Working with nullable fields를 참조해요.

불필요한 코드 경고

더 똑똑한 도달 가능성 분석과 null이 프로그램을 통해 흐를 수 있는 곳을 아는 것은 null을 처리하는 코드를 추가하도록 보장하는 데 도움을 줘요. 하지만 같은 분석을 사용해 필요하지 않은 코드를 감지할 수도 있어요. 널 세이프티 이전에 이런 걸 썼다면:

// Using null safety:
String checkList(List<Object> list) {
  if (list?.isEmpty ?? false) {
    return 'Got nothing';
  }
  return 'Got something';
}

다트는 그 널 인지 ?. 연산자가 유용한지 아닌지 알 방법이 없었어요. 알려진 바로는 null을 함수에 전달할 수도 있었으니까요. 하지만 널 세이프트 다트에서, 그 함수를 이제 널 불가인 List 타입으로 애너테이션했다면, list가 결코 null이 될 수 없다는 것을 알아요. 그것은 ?.가 결코 유용한 일을 하지 않을 것임을 시사해요. 그러니 .을 쓰면 되고 그래야 해요.

정적 분석이 그것을 감지할 만큼 정밀해졌으니, 이런 불필요한 코드에 대한 경고를 추가했어요. 널 불가 타입에 널 인지 연산자나 심지어 == null·!= null 같은 검사를 사용하면 경고로 보고돼요.

그리고 물론 이것은 널 불가 타입 승격과도 어울려요. 변수가 널 불가 타입으로 승격되고 나면, 그것을 null에 대해 중복해서 다시 검사하면 경고를 얻어요.

// Using null safety:
String checkList(List<Object>? list) {
  if (list == null) return 'No list';
  if (list?.isEmpty ?? false) {
    return 'Empty list';
  }
  return 'Got something';
}

여기 ?.에 경고가 생겨요. 그 지점이 실행될 때는 list가 null이 될 수 없다는 것을 이미 알기 때문이에요. 이런 경고의 목표는 무의미한 코드를 치우는 것만이 아니에요. 불필요한 null 검사를 제거함으로써 남아 있는 의미 있는 검사가 두드러지게 해요. 여러분이 코드를 보고 null이 어디로 흐를 수 있는지 알 수 있게 하고 싶어요.

널 가능 타입 작업하기

우리는 이제 null을 널 가능 타입 집합 안으로 몰아넣었어요. 흐름 분석으로, 우리는 일부 널 불가 값을 안전하게 울타리를 넘어 사용할 수 있는 널 불가 쪽으로 옮길 수 있어요. 그것은 큰 진전이지만, 여기서 멈추면 결과 시스템은 여전히 고통스러울 만큼 제한적이에요. 흐름 분석은 지역 변수, 매개변수, 그리고 private final 필드에만 도움이 돼요.

널 세이프티 이전에 다트가 가졌던 유연성을 최대한 되찾고—어떤 곳에서는 그 너머로 나아가기 위해—몇 가지 다른 새로운 기능들이 있어요.

더 똑똑한 널 인지 메서드

다트의 널 인지 연산자 ?.는 널 세이프티보다 훨씬 오래됐어요. 런타임 의미는 수신자가 null이면 오른쪽의 프로퍼티 접근을 건너뛰고 표현식이 null로 평가된다는 거예요.

// Without null safety:
String notAString = null;
print(notAString?.length);

예외를 던지는 대신 이것은 "null"을 출력해요. 널 인지 연산자는 다트에서 널 가능 타입을 사용 가능하게 만드는 멋진 도구예요. 널 가능 타입에 메서드 호출을 허용할 수는 없지만, 널 인지 연산자는 사용하게 할 수 있고 해요. 널 세이프티 이후 버전의 프로그램은:

// Using null safety:
String? notAString = null;
print(notAString?.length);

이전 것과 똑같이 동작해요.

하지만 다트에서 널 인지 연산자를 사용해 봤다면, 메서드 체인에서 사용할 때 불편함을 겪었을 거예요. 잠재적으로 없는 문자열의 길이가 짝수인지 확인하고 싶다고 해 보세요(별로 현실적이지 않은 문제지만, 저를 따라와 주세요).

// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);

이 프로그램은 ?.를 사용하는데도 런타임에 여전히 예외를 던져요. 문제는 .isEven 표현식의 수신자가 왼쪽의 전체 notAString?.length 표현식의 결과라는 거예요. 그 표현식은 null로 평가되므로, .isEven을 호출하려다 널 참조 오류를 얻어요. 다트에서 ?.를 써 봤다면 한 번 쓰고 나면 체인의 모든 프로퍼티나 메서드에 널 인지 연산자를 적용해야 한다는 것을 어렵게 배웠을 거예요.

String? notAString = null;
print(notAString?.length?.isEven);

이건 짜증나고, 더 나쁘게는 중요한 정보를 흐리게 해요. 이것을 생각해 보세요.

// Using null safety:
showGizmo(Thing? thing) {
  print(thing?.doohickey?.gizmo);
}

질문 하나: Thingdoohickey 게터가 null을 반환할 수 있나요? 결과에 ?.를 쓰고 있으니 그럴 수 있는 것처럼 보여요. 하지만 두 번째 ?.는 단지 thing이 null인 경우를 처리하기 위해 있는 것일지도 몰라요—doohickey의 결과가 아니라요. 알 수가 없어요.

이 문제를 다루기 위해 우리는 C#의 같은 기능 설계에서 똑똑한 아이디어를 빌렸어요. 메서드 체인에서 널 인지 연산자를 사용할 때, 수신자가 null로 평가되면 메서드 체인의 나머지 전체가 단락(short-circuit)되어 건너뛰어져요. 이것은 doohickey가 널 불가 반환 타입을 가진다면, 여러분이 이렇게 쓸 수 있고 써야 한다는 뜻이에요.

// Using null safety:
void showGizmo(Thing? thing) {
  print(thing?.doohickey.gizmo);
}

사실, 그렇게 하지 않으면 두 번째 ?.에 불필요한 코드 경고를 받을 거예요. 이런 코드를 보게 된다면:

// Using null safety:
void showGizmo(Thing? thing) {
  print(thing?.doohickey?.gizmo);
}

그것은 doohickey 자체가 널 가능 반환 타입을 가진다는 것을 확실히 의미한다는 걸 알 수 있어요. 각 ?.는 null을 메서드 체인으로 흘러들게 할 수 있는 고유한 경로에 대응해요. 이것은 메서드 체인에서 널 인지 연산자를 더 간결하고 더 정밀하게 만들어 줘요.

그 김에 다른 널 인지 연산자 몇 개도 추가했어요.

// Using null safety:

// Null-aware cascade:
receiver?..method();

// Null-aware index operator:
receiver?[index];

널 인지 함수 호출 연산자는 없지만, 이렇게 쓸 수 있어요.

// Allowed with or without null safety:
function?.call(arg1, arg2);

널 아님 단언 연산자(Not-null assertion operator)

흐름 분석을 사용해 널 가능 변수를 세계의 널 불가 쪽으로 옮기는 것의 좋은 점은, 그렇게 하는 것이 증명 가능하게 안전하다는 거예요. 이전에는 널 가능했던 변수에 널 불가 타입의 안전성이나 성능을 포기하지 않고 메서드를 호출할 수 있어요.

하지만 널 가능 타입의 많은 유효한 사용은 정적 분석을 만족시키는 방식으로 안전하다고 증명될 수 없어요. 예를 들어:

// Using null safety, incorrectly:
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok()
      : code = 200,
        error = null;
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  @override
  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${error.toUpperCase()}';
  }
}

이걸 실행하려 하면 toUpperCase() 호출에서 컴파일 오류를 얻어요. error 필드는 성공 응답에서는 값이 없을 것이므로 널 가능이에요. 클래스를 검사해 보면 error가 null일 때 메시지에 접근하지 않는다는 것을 볼 수 있어요. 하지만 그러려면 code의 값과 error의 널 가능성 사이의 관계를 이해해야 해요. 타입 체커는 그 연결을 볼 수 없어요.

즉, 코드의 인간 유지 관리자인 우리는 error가 사용 지점에서 null이 아니라는 것을 알고, 그것을 단언할 방법이 필요해요. 보통 as 캐스트로 타입을 단언하고, 여기서도 똑같이 할 수 있어요.

// Using null safety:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${(error as String).toUpperCase()}';
}

error를 널 불가 String 타입으로 캐스팅하면 캐스트가 실패할 경우 런타임 예외를 던져요. 그 외에는 메서드를 호출할 수 있는 널 불가 문자열을 줘요.

"널 가능성 캐스팅 제거"는 꽤 자주 나와서 우리는 새로운 축약 문법을 가졌어요. 후위 느낌표(!)는 왼쪽의 표현식을 받아 밑에 깔린 널 불가 타입으로 캐스팅해요. 그래서 위 함수는 이와 동등해요.

// Using null safety:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${error!.toUpperCase()}';
}

이 한 글자 "뱅 연산자(bang operator)"는 밑에 깔린 타입이 장황할 때 특히 편리해요. 어떤 타입에서 단일 ?만 캐스팅해서 제거하려고 as Map<TransactionProviderFactory, List<Set<ResponseFilter>>>를 써야 한다면 정말 짜증날 거예요.

물론 어떤 캐스트처럼 !를 사용하면 정적 안전성이 일부 손실돼요. 사운드음을 보존하려면 캐스트를 런타임에 검사해야 하고 실패해서 예외를 던질 수 있어요. 하지만 이 캐스트들이 어디에 삽입되는지 통제권은 여러분에게 있고, 코드를 훑어보면 항상 볼 수 있어요.

Late 변수

타입 체커가 코드의 안전성을 증명할 수 없는 가장 흔한 곳은 top-level 변수와 필드 주변이에요. 여기 예시가 있어요.

// Using null safety, incorrectly:
class Coffee {
  String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

void main() {
  var coffee = Coffee();
  coffee.heat();
  coffee.serve();
}

여기서 heat() 메서드가 serve()보다 먼저 호출돼요. 그것은 _temperature가 사용되기 전에 널 불가 값으로 초기화될 것임을 의미해요. 하지만 정적 분석이 그것을 판단하는 것은 실현 가능하지 않아요. (이런 사소한 예시에서는 가능할지 몰라도, 클래스의 각 인스턴스 상태를 추적하려는 일반적인 경우는 다루기 어려워요.)

타입 체커는 필드와 top-level 변수의 사용을 분석할 수 없으므로, 널 불가 필드는 선언에서(또는 인스턴스 필드의 경우 생성자 초기화 리스트에서) 초기화되어야 한다는 보수적인 규칙을 가져요. 그래서 다트는 이 클래스에 컴파일 오류를 보고해요.

필드를 널 가능하게 만든 다음 사용 지점에서 널 아님 단언 연산자를 쓰면 오류를 고칠 수 있어요.

// Using null safety:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature! + ' coffee';
}

이건 잘 동작해요. 하지만 클래스의 유지 관리자에게 혼란스러운 신호를 보내요. _temperature를 널 가능으로 표시함으로써, null이 그 필드에 유용하고 의미 있는 값이라는 것을 암시해요. 하지만 그게 의도가 아니에요. _temperature 필드는 결코 null 상태로 관찰되어서는 안 돼요.

지연 초기화(delayed initialization)가 있는 상태의 흔한 패턴을 다루기 위해 우리는 새로운 수정자 late를 추가했어요. 이렇게 사용할 수 있어요.

// Using null safety:
class Coffee {
  late String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

_temperature 필드가 널 불가 타입인데도 초기화되지 않았다는 것을 주목하세요. 또한 사용할 때 명시적 널 아님 단언도 없어요. late의 의미에 적용할 수 있는 모델이 몇 가지 있지만, 저는 이렇게 생각해요: late 수정자는 "이 변수의 제약을 컴파일 타임 대신 런타임에 강제해라"라는 뜻이에요. "late"라는 단어가 언제 변수의 보장을 강제하는지를 묘사하는 것과 거의 같아요.

이 경우 필드가 확실히 초기화되지 않으므로, 필드를 읽을 때마다 값이 할당되었는지 확인하는 런타임 검사가 삽입돼요. 할당되지 않았다면 예외가 던져져요. 변수에 String 타입을 주는 것은 "문자열 외의 값을 가진 나를 절대 보지 못할 것"이라는 뜻이고, late 수정자는 "그것을 런타임에 검증해라"라는 뜻이에요.

어떤 면에서 late 수정자는 ?를 쓰는 것보다 더 "마법적"이에요. 필드의 어떤 사용도 실패할 수 있고 사용 지점에 텍스트로 보이는 것이 없으니까요. 하지만 이런 동작을 얻으려면 선언에 late를 써야 하고, 거기서 수정자를 보는 것이 유지 관리 가능할 만큼 충분히 명시적이라고 우리는 믿어요.

그 대가로 널 가능 타입을 쓰는 것보다 더 나은 정적 안전성을 얻어요. 필드의 타입이 이제 널 불가이므로, 필드에 null이나 널 가능 String을 할당하려는 것은 컴파일 오류예요. late 수정자는 초기화를 미루게 하지만, 여전히 그것을 널 가능 변수처럼 취급하는 것은 금지해요.

지연 초기화(Lazy initialization)

late 수정자에는 다른 특별한 힘도 있어요. 역설적으로 들리지만, 초기화가 있는 필드에도 late를 쓸 수 있어요.

// Using null safety:
class Weather {
  late int _temperature = _readThermometer();
}

이렇게 하면 초기화가 지연(lazy)이 돼요. 인스턴스가 생성되는 즉시 실행하는 대신, 필드에 처음 접근할 때 지연되어 실행돼요. 즉, top-level 변수나 static 필드의 초기화와 정확히 똑같이 동작해요. 초기화 표현식이 비싸고 필요하지 않을 수도 있을 때 편리해요.

초기화를 지연해서 실행하면 인스턴스 필드에 late를 쓸 때 추가 보너스를 줘요. 보통 인스턴스 필드 초기화는 this에 접근할 수 없어요. 모든 필드 초기화가 완료되기 전까지 새 객체에 접근할 수 없으니까요. 하지만 late 필드에서는 그게 더 이상 사실이 아니어서, this에 접근하거나, 메서드를 호출하거나, 인스턴스의 필드에 접근할 수 있어요.

Late final 변수

latefinal을 결합할 수도 있어요.

// Using null safety:
class Coffee {
  late final String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

일반 final 필드와 달리, 선언이나 생성자 초기화 리스트에서 필드를 초기화할 필요가 없어요. 나중에 런타임에 할당할 수 있어요. 하지만 한 번만 할당할 수 있고, 그 사실은 런타임에 검사돼요. 여기서 heat()chill()을 둘 다 호출하는 것처럼 두 번 이상 할당하려 하면 두 번째 할당이 예외를 던져요. 이것은 결국 초기화되고 그 이후로 불변인 상태를 모델링하는 좋은 방법이에요.

즉, 새로운 late 수정자를 다트의 다른 변수 수정자와 결합하면 코틀린의 lateinit과 스위프트의 lazy의 기능 공간 대부분을 덮어요. 약간의 지역 지연 평가가 필요하다면 지역 변수에도 쓸 수 있어요.

필수 명명 매개변수(Required named parameters)

널 불가 타입의 null 매개변수를 절대 볼 수 없도록 보장하기 위해, 타입 체커는 모든 선택 매개변수가 널 가능 타입이거나 기본값을 가질 것을 요구해요. 기본값이 없는 널 불가 타입의 명명 매개변수를 원한다면 어떨까요? 그것은 호출자가 항상 그것을 전달하도록 요구하고 싶다는 뜻이에요. 즉, 명명됐지만 선택적이지 않은 매개변수를 원하는 거예요.

다트의 다양한 매개변수 종류를 이 표로 시각화해요.

             mandatory    optional
            +------------+------------+
positional  | f(int x)   | f([int x]) |
            +------------+------------+
named       | ???        | f({int x}) |
            +------------+------------+

분명하지 않은 이유로 다트는 오랫동안 이 표의 세 모서리를 지원했지만 명명+필수 조합은 비워 두었어요. 널 세이프티와 함께 우리는 그것을 채웠어요. 매개변수 앞에 required를 두면 required 명명 매개변수를 선언해요.

// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}

여기서 모든 매개변수는 이름으로 전달되어야 해요. ac는 선택적이라 생략할 수 있어요. bd는 필수라 반드시 전달해야 해요. required-ness는 널 가능성과 독립적이라는 점을 주목하세요. 널 가능 타입의 required 명명 매개변수도 있을 수 있고, 널 불가 타입의 선택 명명 매개변수도 있을 수 있어요(기본값이 있다면).

이것은 널 세이프티와 무관하게 다트를 더 좋게 만든다고 생각하는 또 다른 기능이에요. 단순히 언어를 더 완성도 있게 느끼게 해 줘요.

추상 필드(Abstract fields)

다트의 멋진 기능 중 하나는 **균일 접근 원칙(uniform access principle)**을 유지한다는 거예요. 인간적으로 말하면, 필드가 게터와 세터와 구별할 수 없다는 뜻이에요. 어떤 다트 클래스의 "프로퍼티"가 계산되는지 저장되는지는 구현 세부 사항이에요. 이 때문에 추상 클래스로 인터페이스를 정의할 때 필드 선언을 쓰는 것이 일반적이에요.

abstract class Cup {
  Beverage contents;
}

의도는 사용자가 그 클래스를 구현만 하고 확장하지 않는다는 거예요. 필드 문법은 게터/세터 쌍을 쓰는 더 짧은 방법일 뿐이에요.

abstract class Cup {
  Beverage get contents;
  set contents(Beverage);
}

하지만 다트는 이 클래스가 결코 구체 타입으로 쓰이지 않을 것임을 알지 못해요. contents 선언을 실제 필드로 봐요. 그리고 안타깝게도 그 필드는 널 불가이고 초기화가 없어서 컴파일 오류를 얻어요.

하나의 해결책은 두 번째 예시처럼 명시적 추상 게터/세터 선언을 쓰는 거예요. 하지만 그것은 조금 장황해서, 널 세이프티와 함께 우리는 명시적 추상 필드 선언에 대한 지원도 추가했어요.

abstract class Cup {
  abstract Beverage contents;
}

이것은 두 번째 예시와 정확히 똑같이 동작해요. 주어진 이름과 타입의 추상 게터와 세터를 선언할 뿐이에요.

널 가능 필드 작업하기

이 새로운 기능들은 많은 흔한 패턴을 덮고 대부분의 경우 null을 다루는 것을 꽤 고통 없게 만들어요. 하지만 그래도 널 가능 필드는 여전히 어려울 수 있다는 것이 우리의 경험이에요. 필드를 late 널 불가로 만들 수 있는 경우에는 완벽해요. 하지만 많은 경우 필드에 값이 있는지 확인해야 하고, 그러려면 null을 관찰할 수 있도록 필드를 널 가능하게 만들어야 해요.

private이면서 final인 널 가능 필드는 (몇 가지 특별한 이유가 없다면) 타입 승격이 가능해요. 어떤 이유로든 필드를 private final로 만들 수 없다면 여전히 우회 방법이 필요해요.

예를 들어 이것이 동작할 거라고 기대할 수도 있어요.

// Using null safety, incorrectly:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  void checkTemp() {
    if (_temperature != null) {
      print('Ready to serve ' + _temperature + '!');
    }
  }

  String serve() => _temperature! + ' coffee';
}

checkTemp() 안에서 _temperature가 null인지 확인해요. 아니면 접근해서 +를 호출해요. 안타깝게도 이것은 허용되지 않아요.

흐름 기반 타입 승격은 private이면서 final인 필드에만 적용할 수 있어요. 그렇지 않으면 정적 분석이 null을 검사한 지점과 사용하는 지점 사이에 필드의 값이 변하지 않는다는 것을 증명할 수 없어요. (병리적인 경우, 필드 자체가 두 번째로 호출될 때 null을 반환하는 서브클래스의 게터로 재정의될 수 있다는 것을 생각해 보세요.)

그래서 우리는 사운드음을 신경 쓰므로, public 및/또는 non-final 필드는 승격되지 않고 위 메서드는 컴파일되지 않아요. 이건 짜증나요. 여기 같은 단순한 경우에는 필드 사용에 !를 붙이는 것이 최선이에요. 중복처럼 보이지만, 그것이 오늘날 다트가 동작하는 방식이에요.

도움이 되는 또 다른 패턴은 필드를 먼저 지역 변수로 복사한 다음 그 지역 변수를 사용하는 거예요.

// Using null safety:
void checkTemp() {
  var temperature = _temperature;
  if (temperature != null) {
    print('Ready to serve ' + temperature + '!');
  }
}

타입 승격은 지역 변수에 적용되므로 이제 잘 동작해요. 값을 바꿔야 한다면, 지역 변수에만 저장하지 말고 필드로 다시 저장하는 것을 기억하세요.

이것과 다른 타입 승격 문제를 다루는 방법에 대한 자세한 정보는 Fixing type promotion failures를 참조해요.

널 가능성과 제네릭

대부분의 현대 정적 타입 언어처럼 다트에는 제네릭 클래스와 제네릭 메서드가 있어요. 이것들은 널 가능성과 몇 가지 직관에 반하는 방식으로 상호작용하지만, 결과를 곰곰이 생각해 보면 말이 돼요. 먼저, "이 타입이 널 가능한가?"는 더 이상 단순한 예/아니오 질문이 아니에요. 이것을 보세요.

// Using null safety:
class Box<T> {
  final T object;
  Box(this.object);
}

void main() {
  Box<String>('a string');
  Box<int?>(null);
}

Box의 정의에서 T는 널 가능 타입일까요, 널 불가 타입일까요? 보이듯이 둘 다로 인스턴스화할 수 있어요. 답은 T가 **잠재적으로 널 가능한 타입(potentially nullable type)**이라는 거예요. 제네릭 클래스나 메서드의 본문 안에서 잠재적으로 널 가능한 타입은 널 가능 타입과 널 불가 타입 양쪽의 모든 제한을 가져요.

전자는 Object에 정의된 몇 안 되는 것 외에는 그에 어떤 메서드도 호출할 수 없다는 뜻이에요. 후자는 그 타입의 필드나 변수를 사용하기 전에 반드시 초기화해야 한다는 뜻이에요. 이것은 타입 매개변수로 작업하기를 꽤 어렵게 만들 수 있어요.

실제로 몇 가지 패턴이 나타나요. 타입 매개변수를 어떤 타입으로든 인스턴스화할 수 있는 컬렉션류 클래스에서는 그 제한을 그냥 감당해야 해요. 대부분의 경우, 여기 예시처럼, 타입 인자의 타입 값을 필요할 때마다 접근할 수 있도록 보장하는 것을 의미해요. 다행히도 컬렉션류 클래스는 요소에 메서드를 거의 호출하지 않아요.

값에 접근할 수 없는 곳에서는 타입 매개변수의 사용을 널 가능하게 만들 수 있어요.

// Using null safety:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);
}

object 선언의 ?를 주목하세요. 이제 필드는 명시적으로 널 가능 타입이라 초기화하지 않고 두는 것이 괜찮아요.

여기 T?처럼 타입 매개변수 타입을 널 가능하게 만들면 널 가능성을 캐스팅해서 제거해야 할 수도 있어요. 그렇게 하는 올바른 방법은 ! 연산자가 아니라 명시적 as T 캐스트를 쓰는 거예요.

// Using null safety:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);

  T unbox() => object as T;
}

! 연산자는 값이 null이면 항상 던져요. 하지만 타입 매개변수가 널 가능 타입으로 인스턴스화됐다면, null은 T에 대한 완벽하게 유효한 값이에요.

// Using null safety:
void main() {
  var box = Box<int?>.full(null);
  print(box.unbox());
}

이 프로그램은 오류 없이 실행되어야 해요. as T를 사용하면 그렇게 돼요. !를 사용하면 예외를 던질 거예요.

다른 제네릭 타입은 적용될 수 있는 타입 인자의 종류를 제한하는 어떤 경계(bound)를 가져요.

// Using null safety:
class Interval<T extends num> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty => max <= min;
}

경계가 널 불가라면 타입 매개변수도 널 불가예요. 이것은 널 불가 타입의 제한—필드와 변수를 초기화하지 않고 둘 수 없다—을 가진다는 뜻이에요. 여기 예시 클래스는 필드를 초기화하는 생성자가 있어야 해요.

그 제한의 대가로, 경계에 선언된 타입 매개변수 타입의 값에 어떤 메서드든 호출할 수 있어요. 하지만 널 불가 경계가 있으면 제네릭 클래스의 사용자가 널 가능 타입 인자로 인스턴스화하는 것을 막아요. 대부분 클래스에 합리적인 제한일 거예요.

널 가능 경계도 사용할 수 있어요.

// Using null safety:
class Interval<T extends num?> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty {
    var localMin = min;
    var localMax = max;

    // No min or max means an open-ended interval.
    if (localMin == null || localMax == null) return false;
    return localMax <= localMin;
  }
}

이것은 클래스 본문에서 타입 매개변수를 널 가능으로 취급하는 유연성을 얻지만 널 가능성의 한계도 가진다는 뜻이에요. 먼저 널 가능성을 처리하지 않는 한 그 타입의 변수에 아무것도 호출할 수 없어요. 여기 예시에서 우리는 필드를 지역 변수에 복사하고, <=을 사용하기 전에 흐름 분석이 그것들을 널 불가 타입으로 승격하도록 지역 변수들을 null에 대해 검사해요.

널 가능 경계가 사용자가 널 불가 타입으로 클래스를 인스턴스화하는 것을 막지는 않는다는 점을 주목하세요. 널 가능 경계는 타입 인자가 널 가능일 수 있다는 뜻이지, 반드시 그렇다는 뜻이 아니에요. (사실, extends 절을 쓰지 않을 때 타입 매개변수의 기본 경계는 널 가능 경계 Object?예요.) 널 가능 타입 인자를 요구하는 방법은 없어요. 타입 매개변수의 사용이 확실히 널 가능하고 암시적으로 null로 초기화되기를 원한다면, 클래스 본문 안에서 T?를 사용할 수 있어요.

핵심 라이브러리 변경 사항

언어에는 여러 가지 다른 작은 조정이 있지만 사소해요. on 절이 없는 catch의 기본 타입이 dynamic 대신 Object가 됐어요. switch 문의 fallthrough 분석은 새 흐름 분석을 사용해요.

여러분에게 정말 중요한 나머지 변경들은 핵심 라이브러리에 있어요. 위대한 널 세이프티 모험을 시작하기 전에, 우리는 세계를 크게 깨뜨리지 않고는 핵심 라이브러리를 널 세이프트하게 만드는 방법이 없을지 걱정했어요. 그렇게 암울하지는 않았답니다. 몇 가지 중요한 변경이 있지만 대체로 마이그레이션은 순조로웠어요. 대부분의 핵심 라이브러리는 null을 받아들이지 않아 자연스럽게 널 불가 타입으로 옮겨가거나, 받아들여서 널 가능 타입으로 우아하게 받아들여요.

하지만 몇 가지 중요한 모서리가 있어요.

Map 인덱스 연산자는 널 가능

이건 실제 변경이라기보다 알아둬야 할 것이에요. Map 클래스의 인덱스 [] 연산자는 키가 없으면 null을 반환해요. 이것은 그 연산자의 반환 타입이 V 대신 V?로 널 가능해야 함을 의미해요.

우리는 그 메서드가 키 없음일 때 예외를 던지고 더 쓰기 쉬운 널 불가 반환 타입을 주도록 바꿀 수도 있었어요. 하지만 인덱스 연산자를 쓰고 키가 없는지 null을 검사하는 코드는 매우 흔해요—우리 분석에 따르면 모든 사용의 대략 절반이에요. 그 모든 코드를 깨뜨리는 것은 다트 생태계를 불태우는 것이었을 거예요.

대신 런타임 동작은 같고 따라서 반환 타입은 널 가능이어야 해요. 이것은 보통 맵 조회 결과를 즉시 사용할 수 없다는 뜻이에요.

// Using null safety, incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.

이것은 널 가능 문자열에 .length를 호출하려 하므로 컴파일 오류를 줘요. 키가 있다는 것을 아는 경우에는 !를 사용해 타입 체커에게 가르칠 수 있어요.

// Using null safety:
var map = {'key': 'value'};
print(map['key']!.length); // OK.

우리는 이걸 대신 해 주는 메서드를 Map에 추가하는 것을 고려했어요: 키를 찾고, 없으면 던지고, 아니면 널 불가 값을 반환하는 거예요. 하지만 이름을 뭐라고 지을까요? 한 글자 !보다 짧은 이름은 없고, 호출 지점에 내장 의미를 가진 !를 보는 것보다 명확한 메서드 이름도 없어요. 그래서 맵에서 반드시 존재하는 요소에 접근하는 관용적인 방법은 []!를 쓰는 거예요. 곧 익숙해질 거예요.

이름 없는 List 생성자 없음

List의 이름 없는 생성자는 주어진 크기의 새 리스트를 만들지만 어떤 요소도 초기화하지 않아요. 이것은 널 불가 타입의 리스트를 만든 다음 요소에 접근한다면 사운드음 보장에 아주 큰 구멍을 뚫는 일이에요.

그걸 피하기 위해 우리는 그 생성자를 완전히 제거했어요. 널 세이프트 코드에서 List()를 호출하는 것은 널 가능 타입이라도 오류예요. 무섭게 들리지만, 실제로 대부분의 코드는 리스트 리터럴, List.filled(), List.generate()로, 또는 다른 컬렉션을 변환한 결과로 리스트를 만들어요. 어떤 타입의 빈 리스트를 만들고 싶은 경계 사례를 위해 우리는 새로운 List.empty() 생성자를 추가했어요.

완전히 초기화되지 않은 리스트를 만드는 패턴은 다트에서 항상 어울리지 않게 느껴졌고, 이제는 더욱 그래요. 이것 때문에 깨진 코드가 있다면, 리스트를 만들어 내는 여러 다른 방법 중 하나를 쓰면 항상 고칠 수 있어요.

널 불가 리스트에 더 큰 길이를 설정할 수 없음

잘 알려지지 않았지만 Listlength 게터에는 대응하는 세터도 있어요. 길이를 더 짧은 값으로 설정해 리스트를 자를 수 있어요. 그리고 더 긴 길이로 설정해 초기화되지 않은 요소로 리스트를 채울 수도 있어요.

널 불가 타입의 리스트에서 그렇게 하면, 나중에 그 쓰지 않은 요소에 접근할 때 사운드음을 위반할 거예요. 그걸 막기 위해, length 세터는 리스트가 널 불가 요소 타입을 가지고 더 긴 길이로 설정할 때에만(그리고 그때만) 런타임 예외를 던져요. 모든 타입의 리스트를 자르는 것은 여전히 괜찮고, 널 가능 타입의 리스트를 늘리는 것도 괜찮아요.

ListBase를 확장하거나 ListMixin을 적용하는 자신만의 리스트 타입을 정의한다면 이것의 중요한 결과가 있어요. 이 두 타입 모두 이전에 삽입된 요소를 위한 공간을 만들기 위해 length를 설정함으로써 insert() 구현을 제공했어요. 그것은 널 세이프티에서 실패하므로, 대신 우리는 (ListBase가 공유하는) ListMixininsert() 구현을 add()를 호출하도록 바꿨어요. 상속된 insert() 메서드를 사용할 수 있으려면 커스텀 리스트 클래스에 add() 정의를 제공해야 해요.

반복 전이나 후에 Iterator.current에 접근할 수 없음

Iterator 클래스는 Iterable을 구현하는 타입의 요소를 순회하는 데 사용되는 변경 가능한 "커서" 클래스예요. 어떤 요소에 접근하기 전에 moveNext()를 호출해 첫 번째 요소로 전진해야 해요. 그 메서드가 false를 반환하면 끝에 도달했고 더 이상 요소가 없는 거예요.

예전에는 current를 첫 moveNext() 호출 전이나 반복이 끝난 후에 호출하면 null을 반환했어요. 널 세이프티에서는 current의 반환 타입이 E?E가 아니어야 한다는 뜻이에요. 그것은 결국 모든 요소 접근에 런타임 null 검사가 필요하다는 뜻이에요.

거의 아무도 그 잘못된 방식으로 현재 요소에 접근하지 않으므로 그 검사들은 쓸모없을 거예요. 대신 우리는 current의 타입을 E로 만들었어요. 반복 전이나 후에 그 타입의 값이 가능할 수 있으므로, 부적절할 때 호출하면 이터레이터의 동작을 정의되지 않은 것으로 남겨 두었어요. 대부분의 Iterator 구현은 StateError를 던져요.

요약

널 세이프티 주변의 모든 언어와 라이브러리 변경을 통한 아주 상세한 여정이었어요. 많은 것이지만, 이것은 꽤 큰 언어 변경이에요. 더 중요한 것은, 다트가 여전히 응집력 있고 사용 가능하게 느껴지는 지점에 도달하고 싶었다는 거예요. 그것은 타입 시스템뿐 아니라 주변의 많은 다른 사용성 기능을 바꿔야 함을 요구했어요. 우리는 널 세이프티가 덧붙여진 것처럼 느껴지기를 원하지 않았어요.

가져가야 할 핵심 요점은:

  • 타입은 기본적으로 널 불가이고, ?를 추가해 널 가능하게 만들어요.
  • 선택 매개변수는 널 가능하거나 기본값을 가져야 해요. required로 명명 매개변수를 필수가 아닌 선택으로 만들 수 없게 할 수 있어요. 널 불가 top-level 변수와 static 필드는 초기화를 가져야 해요. 널 불가 인스턴스 필드는 생성자 본문이 시작되기 전에 초기화되어야 해요.
  • 널 인지 연산자 뒤의 메서드 체인은 수신자가 null이면 단락돼요. 새 널 인지 캐스케이드(?..)와 인덱스(?[]) 연산자가 있어요. 후위 널 아님 단언 "뱅" 연산자(!)는 널 가능 피연산자를 밑에 깔린 널 불가 타입으로 캐스팅해요.
  • 흐름 분석은 널 가능 지역 변수와 매개변수(그리고 다트 3.2부터는 private final 필드)를 안전하게 사용 가능한 널 불가 것으로 바꿔 줘요. 새 흐름 분석은 타입 승격, 누락된 반환, 도달할 수 없는 코드, 변수 초기화에 대해 더 똑똑한 규칙을 가져요.
  • late 수정자는 런타임 검사를 대가로, 그렇지 않으면 할 수 없는 곳에서 널 불가 타입과 final을 쓸 수 있게 해 줘요. 또한 지연 초기화된 필드도 줘요.
  • List 클래스는 초기화되지 않은 요소를 막도록 변경됐어요.

마지막으로, 이 모든 것을 흡수하고 코드를 널 세이프티의 세계로 옮기고 나면, 컴파일러가 최적화할 수 있는 사운드 프로그램을 얻게 되고, 런타임 오류가 발생할 수 있는 모든 곳이 코드에서 보여요. 거기에 도달하기 위한 노력이 그만한 가치가 있다고 느끼길 바라요.

더 알아보기