Dart 타입 시스템

Dart 타입 시스템 (The Dart type system)

Dart 언어는 **타입 안전(type safe)**해요. 정적 타입 검사와 런타임 검사를 함께 사용해서, 변수의 값이 항상 변수의 static 타입과 일치하도록 보장하죠. 이를 가리켜 'sound typing'이라고도 불러요. 타입은 필수지만, 타입 표기(type annotation)는 타입 추론 덕분에 선택적이에요.

출처: Dart 공식 문서 - The Dart type system

본문

정적 타입 검사가 주는 이점 중 하나는, Dart의 정적 분석기(static analyzer)를 통해 컴파일 타임에 버그를 찾을 수 있다는 거예요.

대부분의 정적 분석 오류는 제네릭 클래스에 타입 표기를 추가하면 고칠 수 있어요. 가장 흔한 제네릭 클래스는 컬렉션 타입인 List<T>Map<K,V>예요.

예를 들어 다음 코드에서 printInts() 함수는 정수 리스트를 출력하고, main()은 리스트를 만들어 printInts()에 전달해요.

void printInts(List<int> a) => print(a);

void main() {
  final list = [];
  list.add(1);
  list.add('2');
  printInts(list);
}

이 코드는 printInts(list) 호출 지점의 list에서 타입 오류를 일으켜요.

error - The argument type 'List<dynamic>' can't be assigned to the parameter type 'List<int>'. - argument_type_not_assignable

이 오류는 List<dynamic>에서 List<int>로의 불건전(unsound)한 암시적 캐스트를 지적해요. list 변수의 static 타입은 List<dynamic>이에요. var list = []라는 초기화 선언이 분석기에게 dynamic보다 더 구체적인 타입 인자를 추론할 충분한 정보를 주지 않았기 때문이죠. 그런데 printInts() 함수는 List<int> 타입의 매개변수를 기대하니 타입이 어긋나게 돼요.

리스트를 만들 때(<int>) 타입 표기를 추가하면, 분석기는 문자열 인자를 int 매개변수에 할당할 수 없다고 알려줘요. list.add('2')의 따옴표를 제거하면 정적 분석을 통과하고 오류나 경고 없이 실행되는 코드가 돼요.

void printInts(List<int> a) => print(a);

void main() {
  final list = <int>[];
  list.add(1);
  list.add(2);
  printInts(list);
}

사운드니스(soundness)란?

사운드니스는 프로그램이 특정 잘못된 상태에 빠지지 않도록 보장하는 개념이에요. sound 타입 시스템이란, 표현식이 평가되어 그 표현식의 static 타입과 일치하지 않는 값을 만들어내는 상태에 빠질 수 없는 것을 뜻해요. 예를 들어 표현식의 static 타입이 String이라면, 런타임에 그 표현식을 평가할 때 반드시 문자열만 얻는다는 게 보장돼요.

Dart의 타입 시스템은 Java와 C#의 타입 시스템처럼 sound해요. 정적 검사(컴파일 타임 오류)와 런타임 검사를 조합해 soundness를 강제하죠. 예를 들어 Stringint에 할당하는 건 컴파일 타임 오류예요. as String으로 객체를 String으로 캐스팅하는 건, 그 객체가 String이 아니면 런타임 오류로 실패해요.

사운드니스의 이점 (The benefits of soundness)

sound 타입 시스템은 여러 이점을 줘요.

  • 컴파일 타임에 타입 관련 버그를 드러낸다. sound 타입 시스템은 코드가 타입에 대해 모호하지 않도록 강제하므로, 런타임에는 찾기 까다로운 타입 버그를 컴파일 타임에 발견할 수 있어요.
  • 더 읽기 쉬운 코드. 값이 실제로 명시된 타입을 가진다고 믿을 수 있으니 코드를 더 쉽게 읽을 수 있어요. sound한 Dart에서 타입은 거짓말을 하지 않아요.
  • 더 유지보수하기 쉬운 코드. sound 타입 시스템에서는 한 부분의 코드를 바꿀 때, 타입 시스템이 그로 인해 깨진 다른 코드를 경고로 알려줘요.
  • 더 나은 AOT(Ahead Of Time) 컴파일. 타입 없이도 AOT 컴파일은 가능하지만, 생성된 코드가 훨씬 덜 효율적이에요.

정적 분석을 통과하는 팁 (Tips for passing static analysis)

대부분의 정적 타입 규칙은 이해하기 쉬워요. 여기서는 상대적으로 덜 명확한 규칙을 다룰게요.

  • 메서드를 오버라이드할 때 sound한 반환 타입을 쓰세요.
  • 메서드를 오버라이드할 때 sound한 매개변수 타입을 쓰세요.
  • dynamic 리스트를 타입이 정해진 리스트로 쓰지 마세요.

이 규칙들을 다음 타입 계층을 사용하는 예시와 함께 자세히 볼게요.

메서드를 오버라이드할 때 sound한 반환 타입 쓰기

하위 클래스 메서드의 반환 타입은 상위 클래스 메서드의 반환 타입과 같은 타입이거나 그 하위 타입이어야 해요. Animal 클래스의 getter 메서드를 생각해 볼게요.

class Animal {
  void chase(Animal a) {
   ...
  }
  Animal get parent => ...
}

parent getter는 Animal을 반환해요. HoneyBadger 하위 클래스에서는 getter의 반환 타입을 HoneyBadger(또는 Animal의 다른 하위 타입)로 바꿀 수 있지만, 무관한 타입은 허용되지 않아요.

class HoneyBadger extends Animal {
  @override
  void chase(Animal a) {
   ...
  }

  @override
  HoneyBadger get parent => ...
}

반면 반환 타입을 Root 같은 무관한 타입으로 바꾸면 오류가 나요.

class HoneyBadger extends Animal {
  @override
  void chase(Animal a) {
   ...
  }

  @override
  Root get parent => ...
}

메서드를 오버라이드할 때 sound한 매개변수 타입 쓰기

오버라이드된 메서드의 매개변수는 상위 클래스의 해당 매개변수와 **같은 타입이거나 그 상위 타입(supertype)**이어야 해요. 원래 매개변수의 하위 타입으로 바꿔 매개변수 타입을 "좁히는(tighten)" 것은 하면 안 돼요.

Animal 클래스의 chase(Animal) 메서드를 볼게요.

class Animal {
  void chase(Animal a) {
   ...
  }
  Animal get parent => ...
}

chase() 메서드는 Animal을 받아요. 그런데 HoneyBadger는 뭐든지 쫓아요. chase() 메서드를 뭐든 받도록(Object) 오버라이드하는 건 괜찮아요.

class HoneyBadger extends Animal {
  @override
  void chase(Object a) {
   ...
  }

  @override
  Animal get parent => ...
}

반면 다음 코드는 chase() 메서드의 매개변수를 Animal에서 Animal의 하위 타입인 Mouse로 좁히고 있어요.

class Mouse extends Animal {
   ...
}

class Cat extends Animal {
  @override
  void chase(Mouse a) {
   ...
  }
}

이 코드는 타입 안전하지 않아요. 왜냐하면 고양이를 만들어 악어(Alligator)를 쫓게 하는 것이 가능해지기 때문이죠.

Animal a = Cat();
a.chase(Alligator()); // Not type safe or feline safe.

dynamic 리스트를 타입이 정해진 리스트로 쓰지 않기

서로 다른 종류의 것들을 담고 싶을 때 dynamic 리스트는 좋아요. 하지만 dynamic 리스트를 타입이 정해진 리스트로 사용할 수는 없어요. 이 규칙은 제네릭 타입의 인스턴스에도 적용돼요.

다음 코드는 Dog의 dynamic 리스트를 만들어 Cat 타입의 리스트에 할당하는데, 정적 분석 중 오류를 일으켜요.

void main() {
  List<Cat> foo = <dynamic>[Dog()]; // Error
  List<dynamic> bar = <dynamic>[Dog(), Cat()]; // OK
}

런타임 검사 (Runtime checks)

런타임 검사는 컴파일 타임에 감지할 수 없는 타입 안전성 문제를 다뤄요.

예를 들어 다음 코드는 개들의 리스트를 고양이들의 리스트로 캐스팅하는 게 오류이므로 런타임에 예외를 던져요.

void main() {
  List<Animal> animals = <Dog>[Dog()];
  List<Cat> cats = animals as List<Cat>;
}

dynamic에서의 암시적 다운캐스트

static 타입이 dynamic인 표현식은 더 구체적인 타입으로 암시적으로 캐스트될 수 있어요. 실제 타입이 일치하지 않으면 캐스트가 런타임에 오류를 던져요. 다음 assumeString 메서드를 볼게요.

int assumeString(dynamic object) {
  String string = object; // Check at run time that `object` is a `String`.
  return string.length;
}

이 예시에서 objectString이면 캐스트는 성공해요. String의 하위 타입이 아닌, 예를 들어 int라면 TypeError가 던져져요.

final length = assumeString(1);

타입 추론 (Type inference)

분석기는 필드, 메서드, 지역 변수, 대부분의 제네릭 타입 인자에 대한 타입을 추론할 수 있어요. 분석기가 특정 타입을 추론할 충분한 정보가 없을 때는 dynamic 타입을 사용해요.

제네릭에서 타입 추론이 어떻게 동작하는지 예시로 볼게요. arguments라는 변수가 문자열 키와 다양한 타입의 값을 짝짓는 맵을 담고 있다고 해볼게요.

변수를 명시적으로 타입을 지정하면 이렇게 쓸 수 있어요.

Map<String, Object?> arguments = {'argA': 'hello', 'argB': 42};

아니면 varfinal을 쓰고 Dart가 타입을 추론하게 할 수도 있어요.

var arguments = {'argA': 'hello', 'argB': 42}; // Map<String, Object>

맵 리터럴은 자기의 항목들로부터 타입을 추론하고, 그런 다음 변수는 맵 리터럴의 타입으로부터 타입을 추론해요. 이 맵에서 키는 둘 다 문자열이지만 값은 서로 다른 타입(Stringint, 상한은 Object)이에요. 그래서 맵 리터럴은 타입이 Map<String, Object>이고, arguments 변수도 마찬가지예요.

필드와 메서드 추론

지정된 타입이 없고 상위 클래스의 필드나 메서드를 오버라이드하는 필드/메서드는, 상위 클래스 메서드나 필드의 타입을 상속해요.

선언되거나 상속된 타입이 없지만 초기값과 함께 선언된 필드는, 초기값을 바탕으로 타입이 추론돼요.

Static 필드 추론

static 필드와 변수는 초기화식(initializer)로부터 타입을 추론해요. 단, 순환(cycle)을 만나면 추론이 실패한다는 점을 기억하세요. 즉 어떤 변수의 타입을 추론하는 것이 그 변수의 타입을 아는 데 의존하는 상황이죠.

지역 변수 추론

지역 변수 타입은 있다면 초기화식으로부터 추론돼요. 이후의 할당은 고려되지 않아요. 이 때문에 너무 정밀한 타입이 추론될 수도 있는데, 그럴 때는 타입 표기를 추가하면 돼요.

var x = 3; // x is inferred as an int.
x = 4.0;

이 코드는 xint로 추론됐는데 double을 할당하니 오류예요. num으로 선언하면 numdouble이나 int가 될 수 있으니 문제없어요.

num y = 3; // A num can be double or int.
y = 4.0;

타입 인자 추론

생성자 호출과 제네릭 메서드 호출에 대한 타입 인자는, 발생 문맥의 **하향 정보(downward information)**와 생성자/제네릭 메서드 인자로부터의 **상향 정보(upward information)**를 조합해 추론돼요. 추론이 원하는 대로 동작하지 않는다면 언제든 타입 인자를 명시적으로 지정할 수 있어요.

// Inferred as if you wrote <int>[].
List<int> listOfInt = [];

// Inferred as if you wrote <double>[3.0].
var listOfDouble = [3.0];

// Inferred as Iterable<int>.
var ints = listOfDouble.map((x) => x.toInt());

마지막 예시에서 x는 하향 정보로 double로 추론돼요. 클로저의 반환 타입은 상향 정보로 int로 추론되죠. Dart는 이 반환 타입을 map() 메서드의 타입 인자(<int>)를 추론할 때 상향 정보로 사용해요.

바운드(bounds)를 사용한 추론

바운드를 사용한 추론 기능을 쓰면, Dart의 타입 추론 알고리즘은 기존 제약 조건과 선언된 타입 바운드를 결합해 제약을 생성해요. 단순히 노력 기반(best-effort) 근사치를 쓰는 게 아니란 뜻이죠.

이 기능은 특히 F-bounded 타입에서 중요해요. 아래 예시에서 바운드를 사용한 추론은 XB에 바인딩할 수 있음을 올바르게 추론해요. 이 기능이 없었다면 타입 인자를 f<B>(C())처럼 명시적으로 지정해야 해요.

class A<X extends A<X>> {}

class B extends A<B> {}

class C extends B {}

void f<X extends A<X>>(X x) {}

void main() {
  f(B()); // OK.

  // OK. Without using bounds, inference relying on best-effort approximations
  // would fail after detecting that `C` is not a subtype of `A<C>`.
  f(C());

  f<B>(C()); // OK.
}

intnum처럼 Dart에서 흔히 쓰는 타입을 쓰는 더 현실적인 예시를 볼게요.

X max<X extends Comparable<X>>(X x1, X x2) => x1.compareTo(x2) > 0 ? x1 : x2;

void main() {
  // Inferred as `max<num>(3, 7)` with the feature, fails without it.
  max(3, 7);
}

바운드를 사용한 추론을 쓰면 Dart는 타입 인자를 분해해서, 제네릭 타입 매개변수의 바운드에서 타입 정보를 추출할 수 있어요. 덕분에 다음 예시의 f 같은 함수가 구체적인 iterable 타입(ListSet)과 요소 타입을 둘 다 보존할 수 있어요. 바운드 추론 이전에는 타입 안전성이나 구체적인 타입 정보를 잃지 않고는 이게 불가능했어요.

(X, Y) f<X extends Iterable<Y>, Y>(X x) => (x, x.first);

void main() {
  var (myList, myInt) = f([1]);
  myInt.whatever; // Compile-time error, `myInt` has type `int`.

  var (mySet, myString) = f({'Hello!'});
  mySet.union({}); // Works, `mySet` has type `Set<String>`.
}

바운드 추론이 없었다면 myIntdynamic 타입이었을 거예요. 이전 추론 알고리즘은 잘못된 표현식 myInt.whatever를 컴파일 타임에 잡아내지 못하고 런타임에 던졌을 거예요. 반대로 mySet.union({})은 바운드 추론이 없으면 컴파일 타임 오류였을 텐데, 이전 알고리즘이 mySetSet이라는 정보를 보존하지 못했기 때문이죠.

바운드를 사용한 추론 알고리즘에 대한 더 자세한 내용은 디자인 문서를 참고해 주세요.

타입 대체하기 (Substituting types)

메서드를 오버라이드할 때는 옛 메서드의 어떤 타입을 새 메서드의 새로운 타입으로 대체하는 거예요. 함수에 인자를 전달할 때도, 어떤 타입을 가진 것(선언된 타입의 매개변수)을 다른 타입을 가진 것(실제 인자)으로 대체하는 거죠. 어떤 타입을 가진 것을 하위 타입이나 상위 타입으로 대체하는 게 언제 가능할까요?

타입을 대체할 때는 **소비자(consumer)**와 **생산자(producer)**로 생각하면 도움이 돼요. 소비자는 타입을 흡수하고, 생산자는 타입을 생성해요.

  • 소비자의 타입은 상위 타입으로 대체할 수 있어요.
  • 생산자의 타입은 하위 타입으로 대체할 수 있어요.

단순 타입 할당과 제네릭 타입 할당 예시를 하나씩 볼게요.

단순 타입 할당 (Simple type assignment)

객체를 객체에 할당할 때, 언제 한 타입을 다른 타입으로 대체할 수 있을까요? 답은 그 객체가 소비자인지 생산자인지에 달려 있어요.

Cat c가 소비자이고 Cat()이 생산자인 다음 단순 할당을 볼게요.

Cat c = Cat();

소비 위치에서는, 특정 타입(Cat)을 소비하는 것을 무엇이든 소비하는 것(Animal)으로 대체해도 안전해요. AnimalCat의 상위 타입이므로, Cat cAnimal c로 대체하는 건 허용돼요.

Animal c = Cat();

하지만 Cat cMaineCoon c로 대체하면 타입 안전성이 깨져요. 상위 클래스가 Lion처럼 다른 동작을 하는 Cat 타입을 제공할 수 있기 때문이죠.

MaineCoon c = Cat();

생산 위치에서는, 타입(Cat)을 생산하는 것을 더 구체적인 타입(MaineCoon)으로 대체해도 안전해요. 그래서 다음은 허용돼요.

Cat c = MaineCoon();

제네릭 타입 할당 (Generic type assignment)

제네릭 타입에도 같은 규칙이 적용될까요? 네, 그래요. 동물 리스트의 계층을 생각해 보면, Cat의 리스트는 Animal의 리스트의 하위 타입이고 MaineCoon의 리스트의 상위 타입이에요.

다음 예시에서 List<MaineCoon>List<Cat>의 하위 타입이므로, MaineCoon 리스트를 myCats에 할당할 수 있어요.

List<MaineCoon> myMaineCoons = ...
List<Cat> myCats = myMaineCoons;

반대 방향은 어떨까요? Animal 리스트를 List<Cat>에 할당할 수 있을까요?

List<Animal> myAnimals = ...
List<Cat> myCats = myAnimals;

이 할당은 정적 분석을 통과하지 못해요. 왜냐하면 Animal 같은 non-dynamic 타입에서는 허용되지 않는 암시적 다운캐스트를 만들기 때문이에요.

이런 코드가 정적 분석을 통과하게 하려면 명시적 캐스트를 쓰면 돼요.

List<Animal> myAnimals = ...
List<Cat> myCats = myAnimals as List<Cat>;

다만 명시적 캐스트도 캐스팅되는 리스트(myAnimals)의 실제 타입에 따라 런타임에 실패할 수 있어요.

메서드 (Methods)

메서드를 오버라이드할 때도 생산자/소비자 규칙이 적용돼요.

  • 소비자(chase(Animal) 메서드 같은)에 대해서는 매개변수 타입을 상위 타입으로 대체할 수 있어요.
  • 생산자(parent getter 메서드 같은)에 대해서는 반환 타입을 하위 타입으로 대체할 수 있어요.

공변(covariant) 매개변수

드물게 쓰이는 몇몇 코딩 패턴은 매개변수 타입을 하위 타입으로 오버라이드해 타입을 좁히는 데 의존하는데, 이는 유효하지 않아요. 이런 경우 covariant 키워드를 사용해 분석기에게 의도적으로 그렇게 하고 있다고 알릴 수 있어요. 이러면 static 오류가 사라지고, 대신 런타임에 잘못된 인자 타입을 검사하게 돼요.

covariant를 어떻게 쓸 수 있는지 볼게요.

class Animal {
  void chase(Animal x) {
   ...
  }
}

class Mouse extends Animal {
   ...
}

class Cat extends Animal {
  @override
  void chase(covariant Mouse x) {
   ...
  }
}

이 예시는 하위 타입에서 covariant를 쓰는 걸 보여주지만, covariant 키워드는 상위 클래스 메서드나 하위 클래스 메서드 어디에나 둘 수 있어요. 보통은 상위 클래스 메서드에 두는 게 가장 좋아요. covariant 키워드는 단일 매개변수에 적용되며 setter와 필드에서도 지원돼요.

더 알아보기 (Learn more)

  • Dart 공식 문서 - The Dart type system 원문 살펴보기
  • 관련 자료: 타입 승격(type promotion) 실패 고치기, sound null safety로 코드 작성하기, 분석 옵션 파일로 분석기와 린터 설정하기