Dart declaration-site variance
Dart declaration-site variance
실험적 기능에 대한 깊은 탐구입니다. declaration-site variance는 Dart 팀에서 제가 인턴십 프로젝트로 진행했고, 팀에서의 개인적인 경험은 Life as a Dart intern 글에 기록해 두었어요. declaration-site variance 기능의 주요 구현자로서, sound variance의 사용법과 이점을 공유해 드리고 싶어요.
본문
variance를 어떻게 쓰는지, 왜 수식어(modifier)를 쓰고 싶은지, 이 기능이 수식어를 쓰지 않는 클래스 위에서 어떻게 동작하는지, 그리고 이 기능이 우리에게 주는 이점이 무엇인지 다뤄볼게요.
참고: variance의 구현은 아직 끝나지 않았어요. 실험을 활성화하면(아래 지침) 가지고 놀 수 있지만, 최종 확정 전에 기능이 바뀔 수 있어요.
Dart의 declaration-site variance 기능에 본격적으로 들어가기 전에, variance가 무엇을 의미하고 어떻게 쓰이는지 잠깐 들러서 이야기해 볼게요.
Variance란 무엇인가?
variance를 간단히 소개하기 위해 이 예시를 볼게요.
main() {
Iterable<Object> objectIterable = <int>[1, 2, 3];
}
정수의 Iterable은 객체의 Iterable로 대체될 수 있어요. 정수는 객체이기 때문에, 객체가 쓰이는 모든 곳에서 Iterable 안에 쓰일 수 있기 때문이죠. 언어는 같은 제네릭 타입의 두 인스턴스화(여기서는 Iterable<int>와 Iterable<Object>)가, 그 타입 인자(int와 Object)가 하위 타입 관계라면 서로 하위 타입으로 간주한다고 말해요. 이 하위 타입 관계를 공변성(covariance) 이라고 해요.
이것은 편리하고 논리적이에요. 그런데 메서드 파라미터의 variance를 살펴보기 전까지는 말이죠. Dart에서 objectWriter를 만들고 싶다고 해볼게요.
class Writer<T> {
void write(T x) => print(x);
}
main() {
Writer<Object> objectWriter = Writer<int>();
objectWriter.write(2);
}
그런 다음 신나서 objectWriter에 String을 쓰려고 하면 런타임 오류가 발생해요. 컴파일러는 이 코드를 허용하지만, 실행하면 예외를 던져요.
main() {
// ...
// Runtime error!
// Unhandled exception: type 'String' is not a subtype of type 'int' of 'x'
objectWriter.write("Hello world!");
}
왜 이런 일이 생길까요? 반공변성(contravariance) 에서는 하위 타입 관계가 공변성과 비교해 뒤집혀요. objectWriter에는 어떤 객체든 쓸 수 있어야 하지만, 앞서 objectWriter가 사실 숨은 정수 writer라는 걸 알았죠.
끝으로 알아야 할 변성 타입은 불변성(invariance) 이에요. 불변 하위 타입 관계는, 두 불변 타입이 완전히 똑같은 타입이 아닌 한 그 사이에 하위 타입 관계가 없음을 의미해요.
class ReaderWriter<T> {
void write(T x) => print(x);
T read() => null;
}
main() {
ReaderWriter<int> intRW = ReaderWriter<Object>();
// 정수뿐 아니라 어떤 객체든 반환될 수 있어요. 안전하지 않죠.
intRW.read();
ReaderWriter<Object> anotherObjectRW = ReaderWriter<int>();
// 실제로는 정수 ReaderWriter인데요. 오류!
anotherObjectRW.write(“Hello world!”);
}
Dart의 variance 기능이란?
Dart 팀이 언어에 명시적 variance 수식어를 추가하자고 제안했으니, 기대할 수 있는 변경 사항 몇 가지를 미리 살펴볼게요.
Dart에는 클래스와 mixin의 타입 파라미터에 적용할 수 있는 variance 수식어가 생겨요. 문법은 C#의 variance 수식어와 비슷해요.
out, in, inout 키워드를 각각 공변, 반공변, 불변 타입 파라미터를 선언하는 데 쓸 수 있어요. 제네릭 타입과 함께 이렇게 사용해요.
class MyContravariantClass<in T> {}
mixin MyInvariantMixin<inout T> {}
제네릭 타입에 명시적 variance를 왜 정의하나요? 이 기능이 왜 필요한가요?
Dart의 정적 타입 시스템은 현재 모든 타입 파라미터를 공변으로 취급해요. 반환 타입처럼 안전하게 공변인 위치에 타입이 쓰이는 제네릭에는 이것이 정확하고 편리해요. 하지만 타입 인자가 반공변이나 불변이어야 할 때는 틀리죠.
class Writer<T> {
void write(T x) => print(x);
}
main() {
Writer<Object> objectWriter = Writer<int>();
objectWriter.write(“I’m a string!”);
}
objectWriter를 사용할 때 어떤 객체든 쓸 수 있을 거라고 기대해요. 안타깝게도 objectWriter는 정수만 씁니다. 컴파일러는 그것을 알 방법이 없고, 코드를 실행하면 무서운 런타임 오류를 만나요. unsoundness를 피하기 위해 Dart는 타입 인자를 안전하지 않은 방식으로 사용하면 런타임에 오류를 던져요.
다행히 variance 수식어를 추가하면 이 잘못된 사용이 런타임 오류에서 컴파일 타임 오류로 바뀌어요.
// 반공변 variance 수식어를 추가해요
class Writer<in T> {
void write(T x) => print(x);
}
main() {
// Compile-time error: The constructor returns type 'Writer<int>'
// that isn't of expected type 'Writer<Object>'.
Writer<Object> objectWriter = Writer<int>();
}
이게 훨씬 나아요. objectWriter.write("I'm a string!")를 쓰기 한참 전에 컴파일러가 뭔가 잘못됐다고 알려줘요.
이제 variance 수식어로 안전하게 타입이 붙은 파라미터를 추가하면 얻는 것을 살펴볼게요.
멤버의 타입 파라미터
제네릭 타입 파라미터를 out으로 표시하면, 그 타입을 반환 타입처럼 안전하게 공변인 위치가 아닌 메서드나 필드에서 쓰면 컴파일러가 정적 오류를 내요. 마찬가지로 in으로 표시된 타입 파라미터는 메서드 파라미터 타입처럼 안전하게 반공변인 위치에서만 쓸 수 있어요. inout으로 표시된 타입 파라미터는 어디서든 쓸 수 있고요.
도움이 될 만한 메서드 variance 위치 오류와 올바른 사용 예시를 보여드릴게요. 같은 오류 검사가 mixin에도 적용돼요.
class Reader<out T> {
// Compile-time error: Can't use 'out' type variable 'T' in an 'in' position.
void write(T x) => print(x);
// OK
T read() => null;
}
class Writer<in T> {
// Compile-time error: Can't use 'in' type variable 'T' in an 'out' position
// in the return type.
T read() => null;
// OK
void write(T x) => print(x);
}
class ReaderWriter<inout T> {
// Both OK
void write(T value) => print(x);
T read() => null;
}
필드에서도 오류가 발생할 수 있어요.
class Reader<out T> {
// Compile-time error: Can't use 'out' type variable 'T' in an 'in' position.
T readableWritableValue;
// OK
final T readableValue;
}
class Writer<in T> {
// Compile-time error: Can't use 'in' type variable 'T' in an 'out' position.
T readableWritableValue;
// Compile-time error: Can't use 'in' type variable 'T' in an 'out' position.
final T readableValue;
}
class ReaderWriter<inout T> {
// Both OK
T readableWritableValue;
final T readableValue;
}
할당과 하위 타입 관계
타입 파라미터를 잘못 쓰면 컴파일러가 보고하는 오류는 제네릭 클래스 작성자가 올바른 코드를 쓰도록 돕는 역할을 해요. 나머지 절반은 다른 사람들이 클래스를 올바르게 사용하도록 돕는 오류 집합이에요. sound variance 수식어와 함께 오는 변경 중 하나는 할당을 통해 볼 수 있는 하위 타입 관계 변경이에요.
제네릭 타입 파라미터가 공변이면, 타입 인자가 예상 타입의 타입 인자의 하위 타입일 때 할당할 수 있어요. 예를 들어 Reader<Object>를 기대하는 곳에 Reader<int>를 할당할 수 있어요.
class Reader<out T> { /* ... */ }
main() {
Reader<Object> objectReader = Reader<int>();
}
마찬가지로 제네릭 타입 파라미터가 반공변이면, 타입 인자가 예상 타입의 타입 인자의 상위 타입일 때 할당이 허용돼요. Writer<int>를 기대하는 곳에 Writer<Object>를 이렇게 할당할 수 있어요.
class Writer<in T> { /* ... */ }
main() {
Writer<int> intWriter = Writer<Object>();
}
불변 파라미터의 경우 타입 인자가 같은 타입이어야 해요.
class ReaderWriter<inout T> { /* ... */ }
main() {
ReaderWriter<int> intReader = ReaderWriter<int>();
}
인터페이스 상속
"그럼 오래된 클래스를 상속할 때도 variance로 더 강한 컴파일 타임 검사를 선택할 수 있나요?"라고 물을 수 있겠네요. 좋은 소식은 그렇게 할 수 있다는 거예요. 다만 몇 가지 제약이 있어요.
out파라미터는 공변이거나 기본 Dart 타입 variance를 가진 파라미터 위치만 상속할 수 있어요. 레거시 클래스에서 상속받은 메서드는 여전히 unsound하게 variant일 수 있고, 그래서 여전히 런타임 오류를 일으킬 수 있다는 점을 명심하세요. 그 외에는 variance 수식어가 있는 타입 파라미터를 가진 서브클래스의 모든 새 메서드는, 타입이 unsound한 위치에 있으면 오류를 내요.
class LegacyReader<T> {}
class NewReader<out T> extends LegacyReader<T> {} // OK
class Reader<out T> {}
class IntegerReader<out T> extends Reader<T> {} // OK
in파라미터는 반공변인 파라미터 위치만 상속할 수 있어요.
class Writer<in T> {}
class StringWriter<in T> extends Writer<T> {} // OK
inout파라미터는 모든 파라미터 위치를 상속할 수 있어요. 다만inout으로 정의된 파라미터는 다른 불변 위치에서만 상속될 수 있어요.
class ReaderWriter<inout T> {}
class NewReaderWriter<inout T> extends LegacyReader<T> {} // OK
class IntegerReaderWriter<inout T> extends Reader<T> {} // OK
class StringReaderWriter<inout T> extends Writer<T> {} // OK
variance 기능에 대해 어떻게 피드백을 줄 수 있나요?
최신 dev 채널로 variance 기능을 시도해 보시길 권해요. 이 예시를 가지고 놀면서 variance가 어떻게 동작하고 무엇을 할 수 있는지 감을 잡아 보세요.
class Writer<in T> {
void write(T value) => print(value);
}
class Reader<out T> {
final T value;
Reader(this.value);
T read() => value;
}
main() {
Writer<int> intWriter = Writer<Object>();
intWriter.write(2);
Reader<Object> objectReader =
Reader<String>("Wow, this is soundly variant!");
print(objectReader.value);
}
variance 기능이 아직 구현 중이기 때문에, 활성화하려면 실험 플래그를 설정해야 해요.
dart --enable-experiment=variance variance_example.dart
어떠한 피드백도 감사히 받을게요! 이 GitHub issue에서 의견을 알려주세요.
정리
기존 Dart 제네릭은 기본적으로 공변이어서, 새 클래스를 쓰기 시작하고 시작하기가 쉬워요. 하지만 그만큼 컴파일 타임보다 런타임에 더 많은 오류가 나타난다는 뜻이에요. 사용자는 추가 런타임 검사 비용을 치르고 있기도 하죠. variance의 핵심 아이디어는 사용자에게 컴파일 타임에 더 유익한 오류 검사를 제공하는 거예요.
Variance는 제네릭 클래스와 mixin의 파라미터에만 정의돼요. 사용자는 타입 파라미터 앞에 in, out, inout 키워드 중 하나를 추가해 variance 기능을 사용할 수 있어요. 추가로, 이런 수식어를 가진 새 제네릭 인터페이스는 variance 수식어가 없는 레거시 인터페이스를 상속할 수 있어요.
declaration-site variance를 사용하면 여러 가지 새 이점을 얻을 수 있어요.
- 인터페이스의 멤버 안에서 컴파일 타임 variant 위치 검사
- 다운·업 캐스팅 시 발생하는 성가신 런타임 오류 제거
- 선언된 variance에 따른 추가 하위 타입 관계 변경
- 더 유익하고 접근하기 쉬운 오류 검사
이제 그 objectWriter가 진짜로 어떤 객체의 writer인지 걱정하지 않아도 돼요. 그렇다는 걸 알게 되니까요.
더 알아보기
- Dart 타입 시스템 — soundness와 하위 타입 관계의 기본 개념.
- Generic type — 제네릭 클래스와 제네릭 메서드 사용법.