티스토리 뷰
널 안전성은 다트 2.0에서 도입된 가장 큰 변화이다. 다트가 처음 출시되었을 때는 널 안전성은 긴 소개를 필요로 하는 희귀한 기능이었다. 하지만 오늘날 Kotlin, Swift, Rust 등 다양한 언어들이 이 문제에 대해 각자의 해답을 제시하고 있다.
다음은 널 안전성을 사용하는 않는 경우의 코드이다.
// 널 안전성을 사용하지 않은 경우:
bool isEmpty(String string) => string.length == 0;
void main() {
isEmpty(null);
}
이 다트 프로그램을 널 안전성을 사용하지 않고 실행하면 .length 호출 시 NoSuchMethodError 에러가 발생한다. 널 값은 Null 클래스의 인스턴스이며, Null에는 length라는 게터가 없다.
개발자달은 정적 타입 언어인 다트를 좋아하는데, 이는 타입 체커가 IDE에서 바로 컴파일 타임에 코드의 실수를 찾아내기 때문이다. 버그를 빨리 발견할수록 빨리 수정할 수 있다. 널일 수 있는 값에 .length를 호출하려는 시도와 같은 실수를 감지할 수 있게 하려면 정적 타입 체커를 강화한다는 의미가 된다.
이 문서는 다트가 널을 어떻게 처리하는지, 왜 그렇게 설계했는지, 그리고 안전한 널 사용법에 대해서 알려준다.
타입 시스템의 널 허용성
널 안전성이 도입되기 전에는 정적 타입 시스템은 모든 타입에 널 값을 허용했다. 타입 이론 용어로, Null 타입은 모든 타입의 하위 타입으로 취급되었다.
하지만 Null 타입은 List의 멤버나 int의 멤버를 가지지 않는다. 따라서 널 값에 이러한 멤버에 접근하려고 하면 널 참조 오류가 발생한다.
널-가능 타입과 널-불가능 타입
널 안전성은 타입 계층을 변경하여 이 문제를 근본적으로 해결한다. Null 타입은 여전히 존재하지만 더 이상 모든 타입의 하위 타입이 아니다.
널이 더 이상 하위 타입이 아니기 때문에 Null 클래스 외에는 어떤 타입도 널 값을 허용하지 않게 된다. 이제 모든 타입을 기본적으로 널-불가능 타입으로 만들었다. 만약 int 타입의 변수를 만들면, 이것은 항상 정수값을 가지게 된다. 이로써 모든 널 참조 오류를 해결되었다.
널이 전혀 유용하지 않다고 생각할 수도 있다. 그러나 널은 여전히 유용하므로 이를 처리할 방법이 필요하다. 선택적 매개변수는 이를 잘 설명해 준다. 다음은 널 안전성이 적용된 다트 코드이다.
// 널 안정성을 적용된 코드 : dairy는 널-가능 타입의 선택적 매개변수이다.
void makeCoffee(String coffee, [String? dairy]) {
if (dairy != null) {
print('$coffee with $dairy');
} else {
print('Black $coffee');
}
}
위 코드에서 우리는 dairy 매개변수가 문자열이나 널 값을 허용하기 위해 String의 ?를 붙여 널-가능 타입으로 정의했다. 그래서 makeCoffee() 함수를 호출할 때 2번째 인자를 전달하지 않으면 dairy는 자동으로 null이 된다. null은 값이 없음을 나타내기 때문에 코드의 의미가 더 명확해진다.
널-가능 타입 사용하기
널-가능 타입만 사용한다면 사실할 수 있는 게 많이 없다. 값이 널일 경우 기본 타입의 멤버를 사용할 수 없기 때문이다.
void bad(String? maybeString) {
print(maybeString.length);
}
void main() {
bad(null);
}
위 코드를 실행하면 myabeString은 널 값이므로 length 게터가 없으므로 에러가 발생한다. 널 값은 toString(), ==, hashCode만 멤버로 가지고 있다.
널-가능 타입과 널-불가능 타입은 어떻게 상호작용할까? 널-불가능 타입을 널-가능 타입에 전달하는 것은 항상 가능해야 한다. 그리고 널 값을 널-가능 타입에 전달하는 것도 항상 가능해야 한다. 하지만 널-가능 타입을 널-불가능 타입에 전달하는 것은 안전하지 않다.
다트에 널 안전성이 적용되기 전에는 항상 암시적 다운캐스트라고 불리는 기능을 가지고 있었다. 예를 들어, Object 타입의 값을 String을 기대하는 함수에 전달하면, 타입 검사기가 이를 허용했다.
// 널 안전성 적용 전에는 가능했던 코드
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
타임 검사 시 requireStringNotObject()의 인수에 대해 as String가 적용되었다. 이 캐스트는 실패할 수 있고 런타임에 예외를 던질 수 있지만, 컴파일 타임에는 다트가 이를 허용했다. 암시적 다운캐스트가 적용되면 String?을 String을 기대하는 곳에 전달해도 as String이 적용될 수 있게 되므로 널 안전성이 깨지게 된다. 따라서 널 안전성에서는 암시적 다운 캐스트를 완전히 제거해야 한다.
상위 타입과 하위 타입
방향성 그래프가 최상위에 하나의 타입으로 모이는 경우 그 타입을 상위 타입이라고 부른다. 반대로 그래프의 맨 아래에 모든 타입의 하위 타입이 되는 타입을 하위 타입이라고 부른다.
널 안전성을 도입하기 전에는 Object가 다트의 상위 타입이었고, Null이 하위 타입이었다.
이제는 Object는 널이 아닌 타임이므로 더 이상 상위 타입이 아니다. Null은 Object의 하위 타이도 아니다. Obejct?가 상위 타입이 된다. 그리고 새로운 하위 타입인 Never를 추가했다.
Object?와 Never는 다음과 같은 의미로 사용된다.
- Obejct? : 모든 타입의 값을 허용하려면 Obejct?를 사용한다. Obejct는 널이 아닌 모든 값을 허용한다.
- Never : 아마도 사용할 일이 거의 없을 것이다. Never은 함수가 절대 반환되지 않는다는 의미로 사용될 수 있다.
정확성 보장
이제 타입은 널-가능 타입과 널-불가능 타입으로 나누었다. 그리고 널-불가능 타입은 절대 널이 될 수 없다.
암시적 다운캐스트를 제거하고 널을 최하위 타입에서 제거하면, 프로그램 내에서 할당이나 함수(또는 메서드) 호출 시 인수 전달의 널 안전성은 확보되었다. 이제 널이 끼어들 수 있는 주요 지점은 변수가 처음 생길 때와 함수에서 벗어날 때이다.
잘못된 반환
함수가 널-불가능 반환 타입을 가지면, 함수가 종료될 때 값을 반환하는 반환문에 도달해야 합니다. 다트는 널 안전성 적용 전에는 반환문을 누락하여도 허용되었습니다.
// 널 안전성 적용 전에는 작동했던 코드
String missingReturn() {
// 반환문 없음.
}
이 코드를 분석하면 반환을 잊었다는 힌트를 주었지만 동작하는 데에는 큰 문제가 없었다. 함수의 본문 끝에 도달하면 다트는 암시적으로 널 값을 반환하였기 때문이다.
널 안전성에서는 널-불가능 반환 타입은 널 값을 반환할 수 없기 때문에 함수가 값을 반환하지 않으면 컴파일 오류가 발생하게 된다.
초기화되지 않은 변수
널 안전성 적용 전에는 변수를 선언할 때, 명시적인 초기값을 주지 않으면 다트는 변수를 기본적으로 널 값으로 초기화했다. 이는 편리하지만, 변수의 타입이 널-불가능이면 안전하지 않다. 그래서 널-불가능 변수에 대해 엄격하게 해야 한다.
- 최상위 변수와 static 변수 선언에는 초기값이 있어야 한다.
- 인스턴스 변수는 선언 시 초기값을 가지거나 생성자에서 초기화되거나 생성자 초기화 목록에서 초기화되어야 한다.
- 옵션 매개변수는 기본값을 가져야 한다.
- 로컬 변수는 사용되기 전에 반드시 할당되어야 한다.
최상위 변수와 static 변수 선언에는 초기값이 있어야 한다
최상위 변수와 static 변수는 프로그램 어디에서나 접근하고 할당할 수 있기 때문에 변수에 값이 사용되기 전에 할당되었는지를 보장할 수 없다. 유일하게 안전한 방법은 선언 시 초기화를 요구하는 것이다.
// 최상위 변수
int topLevel = 0;
class SomeClass {
// static 변수
static int staticField = 0;
}
인스턴스 변수는 선언 시 초기값을 가지거나 생성자에서 초기화되거나 생성자 초기화 목록에서 초기화 되어야 한다
인스턴스 변수는 다양한 방법으로 초기화를 할 수 있다.
- 선언 시 초기화
- 생성자 형식으로 초기화
- 생성자 초기화 목록에서 초기화
class SomeClass {
int atDeclaration = 0; // 선언 시 초기화
int initializingFormal;
int initializationList;
SomeClass(this.initializingFormal) // 생성자 형식으로 초기화
: initializationList = 0; // 생성자 초기화 목록에서 초기화
}
즉, 인스턴스 변수는 생성자 본문에 도달하기 전에 값을 가지면 괜찮다.
옵셔널 매개변수는 기본값을 가져야 한다
옵셔널 위치 매개변수 또는 명명된 매개변수에 인수를 전달하지 않으면 다트는 기본값이 사용된다. 따라서 해당 매개변수는 널-가능 타입일 때는 인수가 전달되지 않으면 자동으로 널 값이 되게 하거나 널-불가능 타입일 때는 기본값을 지정해야 한다.
로컬 변수는 사용되기 전에 반드시 할당되어야 한다
로컬 변수는 가장 유연한 경우이다. 널-불가능 로컨 변수는 초기값을 가질 필요가 없다. 하지만 로컬 변수가 사용되기 전에는 반드시 할당되어야 한다.
// 널 안전성 사용:
int tracingFibonacci(int n) {
int result;
if (n < 2) {
result = n;
} else {
result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
}
print(result);
return result;
}
이러한 제한은 널-불가능 타입에만 적용되고, 널-가능 타입은 초기화가 없으면 자동으로 널 값이 된다.
흐름 분석
흐름 분석은 컴파일러에 존재해 왔다. 주로 사용자에게 숨겨져 있으며 컴파일러 최적화에 사용되었지만, 일부 최신 언어는 이와 동일한 기술을 가시적인 언어 기능으로 제공하기 시작했다. 다트는 이미 타입 승격의 형태로 약간의 흐름 분석을 사용하고 있다.
// 널 안전성 유무에 관계없이 적용되는 코드
bool isEmptyList(Object object) {
if (object is List) {
return object.isEmpty; // <-- OK!
} else {
return false;
}
}
여기서 주목할 점은 OK!로 표시된 부분이다. isEmpty 게터는 Object 타입에는 없고 List에 정의된 것다. 하지만 타입 검사기는 모든 is 표현식과 프로그램의 제어 흐름 경로를 살펴본다. 만약 is 표현식이 true일 때만 실행되는 본문이 있디만, 해당 본문 내부에서 변수 타입이 is로 테스트된 타입으로 '승격'된다.
위의 예제에서 obecjt.isEmpty는 obejct가 List인 경우에만 실행된다. 따라서 다트는 해당 if 문의 본문에서 obejct를 List 타입으로 승격한다.
이는 유용한 기능이지만 상당히 제한적이다. 널 안전성 이전에는 기능적으로 동일한 다음 프로그램은 작동하지 않았다.
// 널 안정성 없이:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty; // <-- 오류!
}
널 안전성을 위해, 이 제한된 분석을 여러 가지 방법으로 더욱 강력하게 만들어져야 한다.
도달 가능한 분석
널 안전성을 위해서 함수를 분석할 때, 이제 return, break, throw 그리고 함수에서 일찍 종료될 수 있는 모든 방법을 고려하게 만들어 졌다. 따라서 이제 다음 함수가 완전히 유효하다.
// 널 안전성 사용:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty; // <--- OK!
}
if 문이 object가 List가 아닐 때, 함수가 종료하므로 다트는 이후 obejct를 List로 승격한다. 이는 널 관련 코드뿐만 아니라 많은 다트 코드에 도움이 되는 매우 멋진 개선이다.
도달할 수 없는 코드에 대한 Never
이 도달 가능성 분석을 직접 프로그래밍할 수도 있다. 표현식이 하위 타입인 Never를 가진다는 것은 해당 표현이 절대 성공적으로 평가를 끝낼 수 없다는 것을 의미한다. 이는 반드시 예외를 던지거나 중단되는 것을 의미한다.
따라서 throw 표현식의 정적 타입은 Never이다. Never 타입은 코어 라이브러리에 선언되어 있으며, 타입 주석으로 사용할 수 있다.
특정 종류의 예외를 쉽게 던지기 위한 도우미 함수를 가지고 있다고 가정해 보자.
// 널 안전성 사용:
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}
그러면 다음과 같이 사용할 수 있다.
// 널 안전성 사용:
class Point {
final double x, y;
bool operator ==(Object other) {
if (other is! Point) wrongType('Point', other);
return x == other.x && y == other.y;
}
// 생성자와 hashCode...
}
이 프로그램은 오류없이 분석된다. == 메서드의 마지막 줄에서 other.x와 other.y를 주목하자. 이 메서드에는 아무런 return이나 throw 문이 없지만, other가 Point로 승격된다. 제어 흐름 분석은 wrongType()의 반환 타입이 Never임을 알고, 이는 if문이 참이면 반드시 중단된다는 것을 의미한다. 그래서 == 메서드의 마지막 줄의 other은 Point일 때만 도달할 수 있기 때문에 다트는 이를 Point로 승격한다.
이처럼 Never는 다트의 도달 가능성 분석을 확장하게 한다.
확실한 할당 분석
널 안전성 도입 이전에는 지역 변수를 초기화하는 방법이 복잡할 때는 final을 사용하는 것이 어려울 수 있었다.
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로 사용할 수 있는 제약 조건을 만족시킨다.
널 체크에서의 타입 승격
널-가능 변수가 널이 아닌 경우, 이 값을 널-불가능 타입으로 승격하여 멤버에 접근할 수 있도록 하는 것이 편할 것이다. 향상된 흐름 분석은 지역 변수와 매개변수 그리고 private final 필드의 경우에 이를 가능하게 한다.
널-가능 타입을 널-불가능 타입으로 승격하기 위해 == null과 != null 표현식을 사용하면 된다.
// 널 안전성 사용:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments != null) {
result += ' ' + arguments.join(' ');
}
return result;
}
여기서 arguments는 널-가능 타입이다. 일반적으로 join()를 바로 호출하는 것이 금지 된다. 하지만 arguments가 널이 아닌지 확인하는 if문을 통해서 if 본문에서는 arguments가 List<String> 타입으로 승격이 되어 join()을 사용할 수 있게 된다.
위 코드는 다음과 같이 작성할 수도 있다.
// 널 안전성 사용:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments == null) return result;
return result + ' ' + arguments.join(' ');
}
그리고 as를 사용한 명시적 캐스트, 할당, 그리고 후위 ! 연산자도 승격을 유발한다.
// 추가 설명 필요함??
불필요한 코드 경고
향상된 흐름 분석으로 승격이 되는 경우, 필요하지 않는 코드가 발생할 수 있다. 다트는 이런 불필요한 코드를 감지해서 알려준다.
// 널 안전성 사용:
String checkList(List<Object>? list) {
if (list == null) return 'No list';
if (list?.isEmpty ?? false) {
return 'Empty list';
}
return 'Got something';
}
위 코드에서 두 번째 if 문에서 ?.에는 경고가 표소된다. 이 시점에서 list가 이미 널이 아님을 알고 있기 때문이다.
널 허용 타입 작업하기
흐름 분석을 통해서 널-가능 타입에서 널-불가능 타입으로 승격할 수 있었다. 하지만 흐름 분석은 지역 변수, 매개변수, 그리고 private fianl 필드에만 적용된다.
다트는 널 안전성 도입 이전에 가졌던 유연성을 최대한 회복하기 위해 일부 영역에서 사용가능한 새로운 기능을 추가했다.
더 향상된 널 인식 메서드 ?.
다트의 널 인식 연산자 ?.는 널 안전성 도입 이전부터 존재했다. 널 인식 연산자를 사용해서 멤버에 접근 시 리시버가 널일 경우 이후 과정을 건너뛰고 널로 평가된다.
String? notAString = null;
print(notAString?.length); // "null" 출력
그러나 다음과 같이 연속으로 널 인식 메서드가 사용되면 length를 어떻게 해석해야 할까? notAString이 널-가능 타입이라서? 아니면 length 멤버가 널-가능 타입이라서?
String? notAString = null;
print(notAString?.length?.isEven);
이를 해결하기 위해 다트는 C#에서 아이디어를 차용했다. 널 인식 연산자를 사용할 때, 리시버가 null로 평가되면 이후 과정을 건너뛰기 때문에 이전 리시버가 아닌 현재 리시버를 기준으로 멤버를 접근하면 된다.
따라서 위의 코드에서 length는 널-가능 타입이기 때문에 널 인식 메서드로 isEven에 접근하게 된다.
만약 length가 널-불가능 타입이라면 다음처럼 일반적인 점 접근으로 isEven에 접근하면 된다.
String? notAString = null;
print(notAString?.length.isEven);
널 인식 메서드 외에도 널 인식 캐스케이드 ?..와 널 인식 인덱스 연산자 ?[]도 추가되었다.
// 널 인식 캐스케이드:
receiver?..method();
// 널 인식 인덱스 연산자:
receiver?[index];
널-불가능을 보장하는 연산자
널-가능 타입 사용 사례에서 정적 분석을 만족시키는 방식으로 안전성을 입증할 수 없는 다음 같은 경우가 있다.
// 잘못된 널 안전성 사용:
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()}';
}
}
위 코드를 실행하려고 하면 toString() 메서드 안의 toUpperCase() 호출에서 컴파일 오류가 발생한다. 사람이 볼 때는 error가 사용되는 이 시점에서 null이 아님을 알 수 있지만 타입 체커는 이를 인식하지 못한다.
이를 해결 하기 위해서 as 캐스트를 사용할 수도 있다. 하지만 캐스팅은 정적 안전성을 잃게 되므로, 캐스팅이 실패할 경우 런타임 예외가 발생하니 늘 조심해서 사용해야 한다.
// 널 안전성 사용:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${(error as String).toUpperCase()}';
}
이런 '널 허용성 제거'는 자주 발생하는 문제이기 때문에 간단한 새로운 구문인 호위 느낌표(!)를 도입했다. 이를 널 확인 연산자라고 부르며, 왼쪽의 표현식을 널-불가능 타입으로 캐스팅한다. 따라서 위의 코드는 다음과 동일하게 된다.
// 널 안전성 사용:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error!.toUpperCase()}';
}
널 확인 연산자는 기본 타입이 복잡할 때 특히 유용하다. 예를 들어 Map<TransactionProviderFactor, List<Set<ResponseFilter>>와 복잡한 타입으로 as 캐스트를 하게 된다면 매우 번거로울 것이다.
하지만 널 확인 연산자도 as 캐스트와 마찬가지로 정적 안전성을 잃게 되니, 널-가능 타입이 널이 아닌 경우에만 조심해서 사용해야 한다.
지연 초기화 변수
타입 검사기가 코드의 안전성을 입증하기 힘든 위치는 최상위 변수와 필드 주변이다. 다음은 그 예이다.
// 잘못된 예제!!!!
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가 사용하기 전에 초기화될 것임을 알 수 있다. 그러나 정적 분석으로는 이를 확인하는 것이 불가능하다. 이러한 단순한 예제에서는 가능하지만, 일반적인 경우에는 어려운 문제이다.
그래서 타입 검사기는 필드와 최상위 변수의 사용을 분석할 수 없기 때문에 널-불가능 타입은 선언 시점에서, 또는 인스턴스 필드의 경우에는 생성자를 통해, 초기화해야 한다는 보수적인 규칙을 가지게 된다. 따라서 위의 클래스는 컴파일 오류가 된다.
오류를 수정하려면 필드는 널-가능으로 만들고 사용 시, 널 확인 연산자를 사용하면 된다.
// 널 안전성 사용:
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature! + ' coffee';
}
이 방법은 잘 작동하지만, 클래스 유지 보수자에게 혼란스러운 신호가 된다. _temperature를 널-가능으로 표시하면 널이 유용하고 의미있는 값이 된다. 하지만 _temperature는 사용 전에 항상 값을 가지게 되므로 null은 의미가 없다.
그래서 초기화를 늦게 한다는 것을 명시하기 위해 새로운 수정자인 late를 추가했다.
// 널 안전성 사용:
class Coffee {
late String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
_temperature 필드는 널-불가능 타입이 되었지만 초기화되지 않았다. 그리고 사용 시 널 확인도 필요 없다.
late 수정자는 초기화를 하지 않으니, 필드가 읽힐 때마다 런타임 체크로 값이 할당되었는지 확인한다. 할당되지 않았는데 접근하게 되면 예외가 발생한다.
널이 필요없다면 late 수정자를 사용하는 것이 더 나은 정적 안전성을 제공한다. 널-불가능이므로 널이나 널-가능 타입으로 할당하려고 하면 컴파일 오류가 발생한다. late 수정자는 초기화만 지연할 수 있게 하지만 여전히 널-불가능을 기억하자.
지연 초기화
late 수정자는 초기화가 있는 필드에도 사용할 수 있다.
// 널 안전성 사용:
class Weather {
late int _temperature = _readThermometer();
}
이렇게 하면 초기화가 지연된다. 인스턴스가 생성되자 말자 실행되지 않고, 필드가 처음으로 접근될 때 실행된다. 즉, 이는 최상위 변수나 정적 필드의 초기화와 정확히 동일하게 작동한다. 초기화 표현식이 비용이 많이 들거나 필요하지 않을 수 있을 때 유용하다.
지연 초기화는 인스턴스 필드에서 late를 사용할 때 추가적인 장점을 제공한다. 일반적으로 인스턴스 필드 초기화는 this에 접근할 수 없으므로 필드 초기화가 완료될때 까지 접근할 수 없다. 그러나 late 필드를 사용하면 더 이상 그런 제한이 없으므로, this에 접근하거나 인스턴스 메서드를 호출하거나 필드에 접근할 수 있다.
지연 final 변수
late와 final을 조합할 수도 있다.
// 널 안전성 사용:
class Coffee {
late final String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
late와 final을 선언 시 초기화를 하지 않아도 되지만 할당을 한 번만 할 수 있다. 이 사실은 런타임에서 검증된다. 두 번 할당하려고 하면 에러가 발생한다. 이는 나중에 초기화되며 이후에는 변경 불가능한 상태를 모델링하는 좋은 방법이다.
필수 명명된 매개변수
명명된 매개변수에 호출자가 항상 값을 전달하게 하려면, required를 명명된 매개변수 앞에 배치하면 된다. 그러면 필수 명명된 매개변수가 된다.
function({int? a, required int? b, int? c, required int? d}) {}
a와 c는 선택사항이므로 생략할 수 있지만, b와 d는 필수이며 반들시 전달해야 한다. required는 널 허용성과 무관한다. 널이 의미가 있으면 널-가능 타입을 사용하고, 널이 필요없다면 불-불가능 타입으르 사용하면 된다.
function({int? a, required int b, int? c, required int d}) {}
추상 필드
다트의 멋진 기능 중 하나는 균인 접근 원칙(uniform access principle)을 유지한다는 것이다. 필드는 getter와 setter를 구변할 수 없다. 어떤 다트 클래스의 속성이 계산되거나 저장되는지는 구현 세부 사항이다. 이 때문에 추상 클래스를 사용하여 인터페이스를 정의할 때, 필드 선언을 사용하는 것이 일반적이다.
abstract class Cup {
abstract Beverage contents;
}
최종 구현은 사용자가 구현하도록 하고 확장하지 않도록 하는 것이다.
필드 구문을 게터와 세터로 작성한다면 다음과 같다.
abstract class Cup {
Beverage get contents;
set contents(Beverage);
}
널-가능 필드 다루기
final인 널-가능 필드는 타입 승격이 가능하지만, 일반적인 널-가능 필드는 타입 승격이 되지 않는다.
// 널 안전성 사용, 잘못된 예:
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가 널인지 확인하고, 널이 아니면 + 연산을 수행한다. 하지만 이 코드는 컴파일 에러가 발생한다.
정적 분석으로는 널-가능 필드를 확인하는 지점과 사용하는 지점 사이에 필드의 값이 변하지 않았다는 것을 입증할 수가 없다. 예를 들어, 서브클래스에서 게터로 재정의하여 두 번째 접근 시에는 널을 반환하게 만들 수 있다. 따라서 final인 널-가능 타입 필드만 타입 승격이 가능하다.
여기서 가장 간단한 경우는 필드 사용시 !를 사용하는 것이다. 이는 중복처럼 보일 수 있지만 현재 사용 가능한 방식이 된다.
void checkTemp() {
if (_temperature != null) {
print('Ready to serve ' + _temperature! + '!');
}
}
다른 유용한 방법은 필드를 로컬 변수에 복사한 다음 그 변수를 사용하는 것이다. 타입 승격이 로컬 변수에는 적용되기 때문이다. 이 방법을 사용할 때, 값을 변경해야 하는 경우에는 필드에 다시 저장하는 것을 잊지 말자.
// 널 안전성 사용:
void checkTemp() {
var temperature = _temperature;
if (temperature != null) {
print('Ready to serve ' + temperature + '!');
}
}
널-가능과 제네릭
제네릭은 몇 가지 비직관적인 방식으로 널-가능과 상호 작용하지만, 그 의미를 이해하면 합리적인 것을 알 수 있다.
// 널 안전성 사용:
class Box<T> {
final T object;
Box(this.object);
}
void main() {
Box<String>('a string');
Box<int?>(null);
}
Box의 정의에서 T는 널-가능 타입인가? 아니면 널-불가능 타입인가? 답은 T는 잠재적으로 널-가능 타입이다. 잠재적으로 널-가능 타입과 널-불가능 타입의 모든 제한을 가지게 된다. 이는 T를 다루는 것을 어렵게 만든다.
필드의 타입 매개변수에 널이 필요하다면, 타입 매개변수를 널-가능으로 만들 수 있다.
// 널 안전성 사용:
class Box<T> {
T? object; // 타입 매개변수를 널-가능으로 만듬
Box.empty();
Box.full(this.object);
}
위의 코드에서 object 선언에 있는 ?에 주목하자. 이 필드는 명시적으로 널-가능 타입을 가지므로 초기화하지 않아도 된다.
널-가능을 제거해야 한다면, 올바른 방법은 명시적인 as T 캐스트를 사용하는 것이다. ! 연산자를 널인 경우 예외가 발생하게 된다.
// 널 안전성 사용:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
T unbox() => object as T;
}
void main() {
var box = Box<int?>.full(null);
print(box.unbox());
}
제네릭 타입은 적용할 수 있는 타입 인수의 종류를 제한하는 제약이 있을 수 있다.
// 널 안전성 사용:
class Interval<T extends num> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty => max <= min;
}
제약이 널-불가능이면 타임 매개변수도 널-불가능이다. 이는 널-불가능이므로 필드와 변수를 초기화해야 한다. 이 제약으로 타입 매개변수의 모든 메서드를 호출할 수 있게 된다.
널 허용 제약도 사용할 수 있다.
// 널 안전성 사용:
class Interval<T extends num?> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty {
var localMin = min;
var localMax = max;
// min 또는 max가 없으면 열린 간격을 의미합니다.
if (localMin == null || localMax == null) return false;
return localMax <= localMin;
}
}
이것은 클래스 본문에서 타입 매개변수를 널-가능으로 취급할 수 있는 유연성을 제공하지만, 널-가능의 제한도 가지고 있다.
요약
- 타입은 기본적으로 널-불가능이며, ?를 추가하여 널-가능으로 만든다.
- 옵셔널 매개변수는 널-가능이어야 하거나 기본값을 가져야 한다.
- required를 사용하여 명명된 매개변수를 필수로 만들 수 있다.
- 널-불가능 최상위 변수와 static 필드는 초기값이 있어야 한다.
- 널-불가능 인스턴스 필드는 생성자 본문이 시작되기 전에 초기화해야 한다.
- 널 인식 연산자 ?.를 사용시 수신자가 널이면 단락 평가된다.
- ! 연산자는 널-가능 피연산자를 널-불가능 타입으로 캐스팅한다.
- 흐름 분석을 통해서 널-가능 로컬 변수 및 매개변수(다트3.2부터는 private final 필드)를 타입 승격해서 사용할 수 있다.
- late 수정자는 런타임 검사의 대가로 널-불가능 타입과 final을 지연 초기화할 수 있다.
'다트 공식 문서 번역' 카테고리의 다른 글
다트] 엄격한 널 안전성 (0) | 2024.08.13 |
---|---|
다트] 비동기 지원 (0) | 2024.08.12 |
다트] 다트에서의 동시성 (0) | 2024.08.12 |
다트] 클래스 수정자 조합 (0) | 2024.08.12 |
다트] API 유지 보수를 위한 클래스 수정자 (0) | 2024.08.11 |