타입 승격이 실패하는 이유 정리하기
타입 승격이 실패하는 이유 정리하기
if (x != null)로 널 체크를 했는데도 컴파일러가 "이 변수는 여전히 nullable이야" 하고 에러를 내는 경험, 한 번쯤 있으시죠? 그건 바로 **타입 승격(type promotion)**이 어떤 이유로 실패했기 때문이에요. 이 글에서는 타입 승격이 실패하는 여러 이유와 그 해결 방법을 하나씩 살펴볼게요.
본문
타입 승격은 흐름 분석(flow analysis)이 타입 T인 변수가 실제로는 T의 하위 타입 S의 객체를 가리킨다고 건전하게(soundly) 확인했을 때, 그 변수를 S로 올려 주는 것을 말해요. 특별한 경우로, 변수가 nullable 타입인데 흐름 분석이 null이 아니라고 확인하면, 그 변수를 대응하는 non-nullable 타입으로 승격해 주기도 해요.
그런데 많은 상황이 타입의 건전성을 약화시켜서 타입 승격을 실패하게 만들어요. 이 페이지에서는 타입 승격이 실패하는 이유를 나열하고, 각각을 고치는 팁을 함께 정리했어요.
흐름 분석과 타입 승격에 대해 더 배우고 싶다면 Understanding null safety 페이지를 확인해 보세요.
필드 승격에 대해 지원되지 않는 언어 버전 (Unsupported language version for field promotion)
원인: 필드를 승격하려고 하는데, 필드 승격은 언어 버전과 관련이 있어서 코드가 3.2 이전 언어 버전으로 설정돼 있었어요. 이미 SDK 버전이 Dart 3.2 이상이라도, 코드가 명시적으로 더 이른 언어 버전을 대상으로 하고 있을 수 있어요. 이런 일은 다음 중 하나 때문에 생겨요.
pubspec.yaml의 SDK 제약 조건이 3.2 미만의 하한(lower bound)을 선언하거나,- 파일 맨 위에
// @dart=version주석이 있는데 그version이 3.2보다 낮은 경우.
예시:
// @dart=3.1
class C {
final int? _i;
C(this._i);
void f() {
if (_i != null) {
int i = _i; // ERROR
}
}
}
메시지: '_i' refers to a field. It couldn't be promoted because field promotion is only available in Dart 3.2 and above.
해결: 라이브러리가 3.2보다 이른 언어 버전을 쓰지 않도록 해요. 파일 맨 위에 낡은 // @dart=version 주석이 있는지, 또는 pubspec.yaml에 낡은 SDK 제약 하한이 있는지 확인해 보세요.
지역 변수만 승격 가능 (Dart 3.2 이전) — Only local variables can be promoted
원인: 프로퍼티를 승격하려고 하는데, Dart 3.2 이전 버전에서는 지역 변수만 승격할 수 있어요. 그리고 여러분이 3.2 이전 버전을 쓰고 있는 거죠.
예시:
class C {
int? i;
void f() {
if (i == null) return;
print(i.isEven); // ERROR
}
}
메시지: 'i' refers to a property so it couldn't be promoted.
해결: Dart 3.1 이하를 쓰고 있다면 3.2 이상으로 업그레이드해요. 더 오래된 버전을 계속 써야 한다면 아래의 "기타 원인과 해결 방법(Other causes and workarounds)"을 읽어 보세요.
기타 원인과 해결 방법 (Other causes and workarounds)
이 페이지의 나머지 예시들은 버전 불일치와는 관계없는 승격 실패 이유를 다뤄요. 필드와 지역 변수 실패 모두에 대해 예시와 해결 방법을 함께 보여 드릴게요.
일반적으로 승격 실패를 고치는 방법은 다음 중 하나 또는 그 이상이에요.
- 프로퍼티의 값을, 필요한 non-nullable 타입을 가진 지역 변수에 할당하기.
- 명시적 null 검사(예:
i == null) 추가하기. - 표현식이 null일 수 없다고 확신하면
!나as를 중복 검사로 사용하기.
다음은 i의 값을 담는 지역 변수(이름을 i로 지을 수 있어요)를 만드는 예시예요.
class C {
int? i;
void f() {
final i = this.i;
if (i == null) return;
print(i.isEven);
}
}
이 예시에는 인스턴스 필드가 나오지만, 인스턴스 게터, 정적 필드나 게터, 최상위 변수나 게터, this를 사용해도 똑같이 적용돼요.
그리고 i!를 사용하는 예시는 이렇습니다.
print(i!.isEven);
this를 승격할 수 없음 (Can't promote this)
원인: this를 승격하려고 하는데, this에 대한 타입 승격은 아직 지원되지 않아요. 자주 마주치는 this 승격 시나리오 중 하나는 확장 메서드(extension method)를 작성할 때예요. 확장 메서드의 on 타입이 nullable 타입이라면, this가 null인지 확인하는 null 체크를 하고 싶겠죠.
예시:
extension on int? {
int get valueOrZero {
return this == null ? 0 : this; // ERROR
}
}
메시지: `this` can't be promoted.
해결: this의 값을 담을 지역 변수를 만든 다음 null 체크를 해요.
extension on int? {
int get valueOrZero {
final self = this;
return self == null ? 0 : self;
}
}
private 필드만 승격 가능 (Only private fields can be promoted)
원인: 필드를 승격하려고 하는데, 그 필드가 private이 아니에요. 프로그램의 다른 라이브러리가 public 필드를 게터(getter)로 덮어쓸(override) 가능성이 있어요. 게터는 안정적인 값을 반환하지 않을 수도 있고, 컴파일러가 다른 라이브러리가 뭘 하는지 알 수 없기 때문에, non-private 필드는 승격될 수 없어요.
예시:
class Example {
final int? value;
Example(this.value);
}
void test(Example x) {
if (x.value != null) {
print(x.value + 1); // ERROR
}
}
메시지: 'value' refers to a public property so it couldn't be promoted.
해결: 필드를 private으로 만들면 컴파일러가 외부 라이브러리가 그 값을 덮어쓸 수 없다고 확신할 수 있어서, 승격해도 안전해져요.
class Example {
final int? _value;
Example(this._value);
}
void test(Example x) {
if (x._value != null) {
print(x._value + 1);
}
}
final 필드만 승격 가능 (Only final fields can be promoted)
원인: 필드를 승격하려고 하는데, 그 필드가 final이 아니에요. 컴파일러 입장에서 non-final 필드는 검사 시점과 사용 시점 사이에 언제든 수정될 수 있으므로, non-final nullable 타입을 non-nullable 타입으로 승격하는 것은 안전하지 않아요.
예시:
class Example {
int? _mutablePrivateField;
Example(this._mutablePrivateField);
void f() {
if (_mutablePrivateField != null) {
int i = _mutablePrivateField; // ERROR
}
}
}
메시지: '_mutablePrivateField' refers to a non-final field so it couldn't be promoted.
해결: 필드를 final로 만들어요.
class Example {
final int? _immutablePrivateField;
Example(this._immutablePrivateField);
void f() {
if (_immutablePrivateField != null) {
int i = _immutablePrivateField; // OK
}
}
}
게터는 승격할 수 없음 (Getters can't be promoted)
원인: 게터를 승격하려고 하는데, 승격할 수 있는 건 인스턴스 필드뿐이고 인스턴스 게터는 아니에요. 컴파일러는 게터가 매번 같은 결과를 반환한다는 걸 보장할 방법이 없어요. 안정성을 확인할 수 없으니 게터는 승격하기에 안전하지 않아요.
예시:
import 'dart:math';
abstract class Example {
int? get _value => Random().nextBool() ? 123 : null;
}
void f(Example x) {
if (x._value != null) {
print(x._value.isEven); // ERROR
}
}
메시지: '_value' refers to a getter so it couldn't be promoted.
해결: 게터를 지역 변수에 할당해요.
import 'dart:math';
abstract class Example {
int? get _value => Random().nextBool() ? 123 : null;
}
void f(Example x) {
final value = x._value;
if (value != null) {
print(value.isEven); // OK
}
}
외부 필드는 승격할 수 없음 (External fields can't be promoted)
원인: 필드를 승격하려고 하는데, 그 필드가 external로 표시돼 있어요. 외부 필드는 본질적으로 외부 게터라서 승격되지 않아요. 구현이 Dart 밖의 코드이기 때문에, 컴파일러가 외부 필드가 호출될 때마다 같은 값을 반환할 거라고 보장할 수 없어요.
예시:
class Example {
external final int? _externalField;
void f() {
if (_externalField != null) {
print(_externalField.isEven); // ERROR
}
}
}
메시지: '_externalField' refers to an external field so it couldn't be promoted.
해결: 외부 필드의 값을 지역 변수에 할당해요.
class Example {
external final int? _externalField;
void f() {
final i = _externalField;
if (i != null) {
print(i.isEven); // OK
}
}
}
같은 라이브러리 안의 게터와 충돌 (Conflict with getter elsewhere in library)
원인: 필드를 승격하려고 하는데, 같은 라이브러리의 다른 클래스에 같은 이름의 구체적인 게터(concrete getter)가 있어요.
예시:
import 'dart:math';
class Example {
final int? _overridden;
Example(this._overridden);
}
class Override implements Example {
@override
int? get _overridden => Random().nextBool() ? 1 : null;
}
void testParity(Example x) {
if (x._overridden != null) {
print(x._overridden.isEven); // ERROR
}
}
메시지: '_overridden' couldn't be promoted because there is a conflicting getter in class 'Override'.
해결: 게터와 필드가 연관되어 있고 이름을 공유해야 한다면(위 예시처럼 하나가 다른 하나를 오버라이드하는 경우), 값을 지역 변수에 할당해 타입 승격을 활성화할 수 있어요.
import 'dart:math';
class Example {
final int? _overridden;
Example(this._overridden);
}
class Override implements Example {
@override
int? get _overridden => Random().nextBool() ? 1 : null;
}
void testParity(Example x) {
final i = x._overridden;
if (i != null) {
print(i.isEven); // OK
}
}
관련 없는 클래스에 대한 참고 (Note about unrelated classes): 위 예시에서는 필드 _overridden을 승격하는 게 왜 안전하지 않은지 명확해요. 필드와 게터 사이에 오버라이드 관계가 있기 때문이죠. 하지만 클래스들이 서로 관련이 없어도 충돌하는 게터는 필드 승격을 막아요. 예를 들면:
import 'dart:math';
class Example {
final int? _i;
Example(this._i);
}
class Unrelated {
int? get _i => Random().nextBool() ? 1 : null;
}
void f(Example x) {
if (x._i != null) {
int i = x._i; // ERROR
}
}
다른 라이브러리가 두 관련 없는 클래스를 같은 클래스 계층으로 합치는 클래스를 가질 수도 있어서, 이 경우 함수 f 안의 x._i 참조가 Unrelated._i로 디스패치될 수 있어요.
class Surprise extends Unrelated implements Example {}
void main() {
f(Surprise());
}
해결: 필드와 충돌하는 대상이 진짜로 관련이 없다면, 서로 다른 이름을 줘서 문제를 우회할 수 있어요.
class Example {
final int? _i;
Example(this._i);
}
class Unrelated {
int? get _j => Random().nextBool() ? 1 : null;
}
void f(Example x) {
if (x._i != null) {
int i = x._i; // OK
}
}
같은 라이브러리 안의 승격 불가 필드와 충돌 (Conflict with non-promotable field elsewhere in library)
원인: 필드를 승격하려고 하는데, 같은 라이브러리의 다른 클래스에 같은 이름의 승격 불가능한 필드(이 페이지에 나열된 다른 이유 중 하나 때문에)가 있어요.
예시:
class Example {
final int? _overridden;
Example(this._overridden);
}
class Override implements Example {
@override
int? _overridden;
}
void f(Example x) {
if (x._overridden != null) {
print(x._overridden.isEven); // ERROR
}
}
이 예시가 실패하는 이유는 런타임에 x가 실제로는 Override의 인스턴스일 수 있어서, 승격이 건전하지 않기 때문이에요.
메시지: 'overridden' couldn't be promoted because there is a conflicting non-promotable field in class 'Override'.
해결: 필드들이 실제로 연관되어 있고 이름을 공유해야 한다면, 값을 final 지역 변수에 할당해서 타입 승격을 활성화할 수 있어요.
class Example {
final int? _overridden;
Example(this._overridden);
}
class Override implements Example {
@override
int? _overridden;
}
void f(Example x) {
final i = x._overridden;
if (i != null) {
print(i.isEven); // OK
}
}
필드들이 관련이 없다면, 둘 중 하나를 리네이밍해서 충돌하지 않게 해요. "관련 없는 클래스에 대한 참고"를 읽어 보세요.
암시적 noSuchMethod 포워더와의 충돌 (Conflict with implicit noSuchMethod forwarder)
원인: private이고 final인 필드를 승격하려고 하는데, 같은 라이브러리의 다른 클래스에 필드와 같은 이름의 암시적 noSuchMethod 포워더가 있어요. noSuchMethod가 호출 때마다 안정적인 값을 반환한다는 보장이 없기 때문에 건전하지 않아요.
예시:
import 'package:mockito/mockito.dart';
class Example {
final int? _i;
Example(this._i);
}
class MockExample extends Mock implements Example {}
void f(Example x) {
if (x._i != null) {
int i = x._i; // ERROR
}
}
이 예시에서 _i는 MockExample 안에 컴파일러가 생성한 건전하지 않은 암시적 noSuchMethod 포워더(역시 이름이 _i)로 해석될 수 있기 때문에 승격할 수 없어요. 컴파일러는 MockExample이 선언에서 Example을 implements하면서 _i의 게터를 지원하겠다고 약속했지만 그 약속을 지키지 않았기 때문에, 이 암시적 _i 구현을 만들어요. 그래서 정의되지 않은 게터 구현을 Mock의 noSuchMethod 정의가 처리하고, 이것이 같은 이름의 암시적 noSuchMethod 포워더를 만드는 거예요. 이 실패는 관련 없는 클래스의 필드 사이에서도 발생할 수 있어요.
메시지: '_i' couldn't be promoted because there is a conflicting noSuchMethod forwarder in class 'MockExample'.
해결: 해당 게터를 정의해 줘서 noSuchMethod가 그 구현을 암시적으로 처리하지 않게 해요.
import 'package:mockito/mockito.dart';
class Example {
final int? _i;
Example(this._i);
}
class MockExample extends Mock implements Example {
@override
late final int? _i;
}
void f(Example x) {
if (x._i != null) {
int i = x._i; // OK
}
}
게터를 late로 선언하는 건 mock을 일반적으로 쓰는 방식과 일관되게 하기 위한 거예요. mock이 관련되지 않은 시나리오에서 이 타입 승격 실패를 해결하기 위해 게터를 late로 선언할 필요는 없어요.
승격 후에 쓰여질 가능성 (Possibly written after promotion)
원인: 승격된 이후에 쓰여질 수 있는 변수를 승격하려고 해요.
예시:
void f(bool b, int? i, int? j) {
if (i == null) return;
if (b) {
i = j; // (1)
}
if (!b) {
print(i.isEven); // (2) ERROR
}
}
해결: 이 예시에서 흐름 분석이 (1)에 도달하면 i를 non-nullable int에서 다시 nullable int?로 강등(demote)해요. 사람이라면 (2)의 접근이 안전하다는 걸 알 수 있어요. (1)과 (2)를 모두 포함하는 코드 경로가 없기 때문이죠. 하지만 흐름 분석은 서로 다른 if 문의 조건 사이의 상관관계를 추적하지 않기 때문에 그걸 알아채지 못해요. 두 if 문을 합치면 문제를 고칠 수 있어요.
void f(bool b, int? i, int? j) {
if (i == null) return;
if (b) {
i = j;
} else {
print(i.isEven);
}
}
루프가 없는 직선적 제어 흐름(straight-line control flow)의 경우, 흐름 분석은 강등 여부를 결정할 때 할당의 오른쪽을 고려해요. 그 결과, 이 코드를 고치는 또 다른 방법은 j의 타입을 int로 바꾸는 거예요.
void f(bool b, int? i, int j) {
if (i == null) return;
if (b) {
i = j;
}
if (!b) {
print(i.isEven);
}
}
이전 루프 반복에서 쓰여질 가능성 (Possibly written in a previous loop iteration)
원인: 이전 루프 반복에서 쓰여졌을 수 있는 것을 승격하려고 하기 때문에 승격이 무효화됐어요.
예시:
void f(Link? p) {
if (p != null) return;
while (true) { // (1)
print(p.value); // (2) ERROR
var next = p.next;
if (next == null) break;
p = next; // (3)
}
}
흐름 분석이 (1)에 도달하면 앞을 내다보고 (3)에서 p에 대한 쓰기를 봐요. 하지만 앞을 내다보는 중이기 때문에 아직 할당 오른쪽의 타입을 파악하지 못해서, 승격을 유지해도 안전한지 알 수 없어요. 안전을 위해 승격을 무효화하는 거예요.
해결: null 체크를 루프 맨 위로 옮기면 문제를 고칠 수 있어요.
void f(Link? p) {
while (p != null) {
print(p.value);
p = p.next;
}
}
이 상황은 switch 문에서도 생길 수 있어요. case 블록에 라벨이 있으면 라벨이 붙은 switch 문으로 루프를 구성할 수 있기 때문이에요.
void f(int i, int? j, int? k) {
if (j == null) return;
switch (i) {
label:
case 0:
print(j.isEven); // ERROR
j = k;
continue label;
}
}
역시 null 체크를 루프 맨 위로 옮기면 문제를 고칠 수 있어요.
void f(int i, int? j, int? k) {
switch (i) {
label:
case 0:
if (j == null) return;
print(j.isEven);
j = k;
continue label;
}
}
try에서 쓰여질 수 있는 후의 catch (In catch after possible write in try)
원인: 변수가 try 블록에서 쓰여졌을 수 있는데, 실행이 이제 catch 블록에 있어요.
예시:
void f(int? i, int? j) {
if (i == null) return;
try {
i = j; // (1)
// ... 추가 코드 ...
if (i == null) return; // (2)
// ... 추가 코드 ...
} catch (e) {
print(i.isEven); // (3) ERROR
}
}
이 경우 흐름 분석은 i.isEven(3)을 안전하다고 보지 않아요. try 블록 안에서 예외가 언제 발생했을지 알 방법이 없으므로, 보수적으로 (1)과 (2) 사이, 즉 i가 잠재적으로 null이었을 때 발생했을 수 있다고 가정하기 때문이에요. 비슷한 상황이 try와 finally 블록 사이, catch와 finally 블록 사이에서도 발생할 수 있어요. 구현의 역사적 산출물 때문에 이런 try/catch/finally 상황은 루프에서처럼 할당의 오른쪽을 고려하지 않아요.
해결: 문제를 고치려면 catch 블록이 try 블록 안에서 변경되는 변수의 상태에 대한 가정에 의존하지 않도록 해요. 예외는 try 블록 중 언제든, 아마 i가 null일 때도 발생할 수 있다는 걸 기억하세요. 가장 안전한 해결은 catch 블록 안에 null 체크를 추가하는 거예요.
try {
// ...
} catch (e) {
if (i != null) {
print(i.isEven); // (3) 위 줄의 null 체크 덕분에 OK
} else {
// i가 null인 경우를 처리
}
}
또는 i가 null인 동안에는 예외가 발생할 수 없다고 확신한다면, 그냥 ! 연산자를 써도 돼요.
try {
// ...
} catch (e) {
print(i!.isEven); // (3) `!` 덕분에 OK
}
하위 타입 불일치 (Subtype mismatch)
원인: 변수의 현재 승격된 타입의 하위 타입이 아닌 타입(또는 승격 시도 시점에 하위 타입이 아니었던 타입)으로 승격하려고 해요.
예시:
void f(Object o) {
if (o is Comparable /* (1) */) {
if (o is Pattern /* (2) */) {
print(o.matchAsPrefix('foo')); // (3) ERROR
}
}
}
이 예시에서 o는 (1)에서 Comparable로 승격되지만, (2)에서는 Pattern으로 승격되지 않아요. Pattern이 Comparable의 하위 타입이 아니기 때문이에요. (만약 승격된다면 Comparable의 메서드를 쓸 수 없게 되기 때문이라는 게 근거예요.) Pattern이 Comparable의 하위 타입이 아니라고 해서 (3)의 코드가 죽은 코드(dead code)라는 뜻은 아니에요. o가 Comparable과 Pattern을 모두 구현하는 String 같은 타입을 가질 수도 있으니까요.
해결: 가능한 해결책 중 하나는 새 지역 변수를 만들어서 원래 변수는 Comparable로, 새 변수는 Pattern으로 승격되게 하는 거예요.
void f(Object o) {
if (o is Comparable /* (1) */) {
Object o2 = o;
if (o2 is Pattern /* (2) */) {
print(
o2.matchAsPrefix('foo'),
); // (3) OK; o2가 `Pattern`으로 승격됨
}
}
}
하지만 나중에 코드를 수정하는 누군가가 Object o2를 var o2로 바꾸고 싶어 할 수도 있어요. 그렇게 바꾸면 o2가 Comparable 타입이 되어서, 객체가 Pattern으로 승격될 수 없는 문제가 다시 생겨요. 중복 타입 검사가 더 나은 해결책일 수 있어요.
void f(Object o) {
if (o is Comparable /* (1) */) {
if (o is Pattern /* (2) */) {
print((o as Pattern).matchAsPrefix('foo')); // (3) OK
}
}
}
때로는 더 정밀한 타입을 사용하면 해결되는 경우도 있어요. 3번째 줄이 문자열에만 관심이 있다면, 타입 검사에 String을 쓸 수 있어요. String은 Comparable의 하위 타입이므로 승격이 동작해요.
void f(Object o) {
if (o is Comparable /* (1) */) {
if (o is String /* (2) */) {
print(o.matchAsPrefix('foo')); // (3) OK
}
}
}
지역 함수에 의한 쓰기 캡처 (Write captured by a local function)
원인: 변수가 지역 함수나 함수 표현식에 의해 쓰기 캡처(write-captured)됐어요.
예시:
void f(int? i, int? j) {
var foo = () {
i = j;
};
// ... foo 사용 ...
if (i == null) return; // (1)
// ... 추가 코드 ...
print(i.isEven); // (2) ERROR
}
흐름 분석은 foo의 정의에 도달하는 순간 그게 언제든 호출될 수 있다고 판단하므로, 더 이상 i를 전혀 승격하는 게 안전하지 않다고 봐요. 루프와 마찬가지로 이 강등은 할당 오른쪽의 타입과 무관하게 일어나요.
해결: 때로는 승격이 쓰기 캡처 앞에 오도록 로직을 재구성할 수 있어요.
void f(int? i, int? j) {
if (i == null) return; // (1)
// ... 추가 코드 ...
print(i.isEven); // (2) OK
var foo = () {
i = j;
};
// ... foo 사용 ...
}
또 다른 방법은 지역 변수를 만들어서 쓰기 캡처되지 않게 하는 거예요.
void f(int? i, int? j) {
var foo = () {
i = j;
};
// ... foo 사용 ...
var i2 = i;
if (i2 == null) return; // (1)
// ... 추가 코드 ...
print(i2.isEven); // (2) `i2`가 쓰기 캡처되지 않았으므로 OK
}
또는 중복 검사를 할 수도 있어요.
void f(int? i, int? j) {
var foo = () {
i = j;
};
// ... foo 사용 ...
if (i == null) return; // (1)
// ... 추가 코드 ...
print(i!.isEven); // (2) `!` 검사 덕분에 OK
}
현재 클로저나 함수 표현식 밖에서 쓰여짐 (Written outside of the current closure or function expression)
원인: 변수가 클로저나 함수 표현식 밖에서 쓰여지는데, 타입 승격 위치는 클로저나 함수 표현식 안이에요.
예시:
void f(int? i, int? j) {
if (i == null) return;
var foo = () {
print(i.isEven); // (1) ERROR
};
i = j; // (2)
}
흐름 분석은 foo가 언제 호출될지 결정할 방법이 없다고 판단해요. (2)의 할당 이후에 호출될 수도 있으니, 승격이 더 이상 유효하지 않을 수 있는 거예요. 루프와 마찬가지로 이 강등은 할당 오른쪽의 타입과 무관하게 일어나요.
해결: 지역 변수를 만드는 게 해결책이에요.
void f(int? i, int? j) {
if (i == null) return;
var i2 = i;
var foo = () {
print(i2.isEven); // (1) `i2`는 나중에 바뀌지 않으므로 OK
};
i = j; // (2)
}
예시: 특히 까다로운 경우는 이렇게 생겼어요.
void f(int? i) {
i ??= 0;
var foo = () {
print(i.isEven); // ERROR
};
}
이 경우 사람이라면 승격이 안전하다는 걸 알 수 있어요. i에 대한 유일한 쓰기가 non-null 값이고 foo가 만들어지기 전에 일어나니까요. 하지만 흐름 분석은 그렇게 똑똑하지 않아요.
해결: 역시 지역 변수를 만드는 게 해결책이에요.
void f(int? i) {
var j = i ?? 0;
var foo = () {
print(j.isEven); // OK
};
}
이 해결책이 동작하는 이유는 j가 초기값(i ?? 0) 덕분에 non-nullable 타입(int)으로 추론되기 때문이에요. j가 non-nullable 타입이라서, 나중에 할당되든 안 되든 j는 결코 null 값을 가질 수 없어요.
현재 클로저나 함수 표현식 밖에서의 쓰기 캡처 (Write captured outside of the current closure or function expression)
원인: 승격하려는 변수가 클로저나 함수 표현식 밖에서 쓰기 캡처됐는데, 이 변수의 이번 사용은 승격을 시도하는 클로저나 함수 표현식 안에 있어요.
예시:
void f(int? i, int? j) {
var foo = () {
if (i == null) return;
print(i.isEven); // ERROR
};
var bar = () {
i = j;
};
}
흐름 분석은 foo와 bar가 어떤 순서로 실행될지 알 방법이 없다고 판단해요. 사실 bar는 foo를 실행하는 도중에도 실행될 수 있어요(foo가 bar를 호출하는 무언가를 호출하기 때문에). 그래서 foo 안에서 i를 승격하는 건 안전하지 않아요.
해결: 가장 좋은 해결책은 지역 변수를 만드는 거예요.
void f(int? i, int? j) {
var foo = () {
var i2 = i;
if (i2 == null) return;
print(i2.isEven); // i2는 이 클로저에 지역적이므로 OK
};
var bar = () {
i = j;
};
}
await나 yield를 가로질러 승격이 사라짐 (Promotion lost across an await or yield)
원인: 실행이 await 표현식이나 yield 문을 가로질러 중단(suspend)되면 흐름 분석이 타입 승격을 버려요. 이 승격 실패가 발생하려면 다음 조건이 모두 충족돼야 해요.
- 함수나 메서드가 지역 변수나 파라미터에 쓰기를 포함한다.
- 그 함수가 변수를 승격하는 내부 함수나 클로저를 포함한다.
- 내부 함수가
await나yield로 스스로를 중단시켜, 외부 함수가 계속 실행되게 한다. - 내부 함수가 중단 후에 지역 변수를 다시 사용하려 한다.
이전 SDK 릴리스에서는 중단 후에 지역 변수를 사용해도 승격된 타입이 유지됐어요. 하지만 그 동작은 건전하지 않았어요. 내부 함수가 중단돼 있는 동안 외부 함수가 계속 실행되어 변수의 값을 수정할 수 있기 때문이에요. Dart 3.13부터는 건전성을 보장하기 위해, 내부 함수가 중단되면 흐름 분석이 승격을 버려요.
예시: 컴파일러는 승격을 유지하는 게 명백히 안전한 경우에도 중단 지점을 가로질러 승격을 버려요.
import 'dart:async';
Future<void> example(String? extraInfo) async {
extraInfo ??= 'No extra info'; // (1)
unawaited(() async {
log('Doing some asynchronous task...');
log(extraInfo!);
await longTask(); // (2)
log('Done!');
log(extraInfo); // (3) ERROR
}());
}
Future<void> longTask() async {
await Future<void>.delayed(const Duration(seconds: 10));
}
void log(String s) {
print(s);
}
이 예시에서 (1)에서 extraInfo에 값을 할당한다는 것은 그게 실질적으로 final이 아니라는 뜻이에요. 흐름 분석은 클로저가 캡처한 수정된 변수는 (2)에서 클로저가 중단된 동안 바뀔 수 있다고 보수적으로 가정해요. (1)의 할당이 언제나 클로저가 실행되기 전에 일어난다 하더라도 말이죠. 건전성을 보장하기 위해 흐름 분석은 승격을 버려요. 그 결과 (3)에서 extraInfo는 String?으로 강등되고, 이는 log(String s)와 호환되지 않아 컴파일 타임 에러가 나요.
메시지: 이 상황이 발생하면 컴파일러나 분석기가 에러와 컨텍스트 메시지를 내보내요.
example.dart:10:9: Error: The argument type 'String?' can't be assigned to the parameter type 'String'.
log(extraInfo);
^
example.dart:8:5: Context: Variable 'extraInfo' could not be promoted due to an 'await' or 'yield'.
Try checking the type of the variable after the 'await' or 'yield'. See http://dart.dev/go/non-promo-suspension
await longTask();
^
해결: 이 컴파일 타임 에러를 고치려면, 중단 지점 이후에 변수의 non-null 타입을 다시 검증하거나, 명시적 null 검사를 추가하거나, non-null 단언 !를 사용하면 돼요.
import 'dart:async';
Future<void> example(String? extraInfo) async {
extraInfo ??= 'No extra info';
unawaited(() async {
log('Doing some asynchronous task...');
log(extraInfo!);
await longTask();
log('Done!');
log(extraInfo!);
}());
}
Future<void> longTask() async {
await Future<void>.delayed(const Duration(seconds: 10));
}
void log(String s) {
print(s);
}
또는 중단 전에 변수를 final 지역 변수에 할당하거나, await 표현식 뒤에 extraInfo != null을 다시 확인하는 방식도 있어요.