티스토리 뷰
다트 언어는 타입 안전성을 갖추고 있다. 정적 타입 검사와 런타임 검사의 조합을 사용하여 변수의 값이 항상 변수의 타입과 일치하는 것을 보장한다. 이러한 특성 때문에 'sound typing'이라고도 불린다.
타입 주석은 필수적이지만, 다트는 타입 추론이 가능하므로 선택적으로 생략할 수도 있다.
정적 타입 검사의 이점은 컴파일 시간에 버그를 찾을 수 있다는 점이다. 예를 들어 다음 코드에서 printInts() 함수는 List<int>를 출력하고, main() 함수는 리스트를 생성해서 printInts()에 전달하는 예제이다.
void printInts(List<int> a) => print(a);
void main() {
final list = [];
list.add(1);
list.add('2');
printInts(list); // Error
}
위의 코드를 실행하면 list에 대한 타입 오류가 발생한다.
error - The argument type 'List<dynamic>' can't be assigned
to the parameter type 'List<int>'. - argument_type_not_assignable
이 오류는 list 변수의 정적 타입은 List<dynamic>이라서 발생하게 된다.
리스트 생성시 <int> 타입 주석을 추가하면 문자열인 '2'를 추가할 수 없다는 경고가 발생하고, 이를 정수로 수정하면 오류나 경고 없이 실행되는 코드가 된다.
void printInts(List<int> a) => print(a);
void main() {
final list = <int>[];
list.add(1);
list.add(2);
printInts(list);
}
타당성(soundness)이란 무엇인가?
타당성은 프로그램이 유효하지 않은 상태에 빠지지 않도록 보장한다. 타당한 타입 시스템(sound type system)은 어떤 표현식이 그 표현식의 정적 타입과 일치하지 않는 값으로 평가되는 상태에 절대 도달하지 않음을 의미한다. 예를 들어, 표현식의 정적 타입이 String인 경우, 런타임에서 이를 평가하면 반드시 문자열만 얻게 되는 것을 보장한다.
다트의 타입 시스템은 Java와 C#의 타입 시스템처럼 타당성을 갖추고 있다. 다트는 정적 검사(컴파일 타임 오류)와 런타임 검사의 조합을 통해 타당성을 유지한다. 예를 들어, String을 int에 할당하려 하면 컴파일 타임 오류가 발생한다. 객체를 as String으로 캐스팅할 때, 해당 객체가 문자열이 아니면 런타임 오류가 발생한다.
타당성의 이점
타당성 타입 시스템에는 여러 가지 이점이 있다.
- 컴파일 시간에 타입 관련 버그를 발견할 수 있다. 타당성 타입 시스템은 코드가 타입에 대해 모호하지 않도록 강제하기 때문에, 런타임에서 찾기 힘들 수 있는 타입 관련 버그를 컴파일 시간에 발견할 수 있다.
- 코드의 가독성이 높아진다. 값이 지정된 타입을 실제로 갖는다는 것을 확신할 수 있기 때문에 코드가 읽기 쉽다. 다트에서는 타입이 거짓말을 할 수 없다.
- 유지 보수가 쉬워진다. 타당성 타입 시스템을 사용하면 한 부분의 코드를 변경할 때 다른 부분의 코드가 손상되었음을 타입 시스템이 경고해 줄 수 있다.
- 더 나은 미리 컴파일(AOT) 컴파일이 가능해진다. 타입 없이도 AOT 컴파일은 가능하지만, 생성된 코드의 효율은 훨씬 떨어진다.
정적 분석 통과를 위한 팁
대부분의 정적 타입 규칙은 이해하기 쉽다.
다음은 덜 명확한 몇 가지 규칙이다.
- 메서드 오버라이딩 시 타당한 반환 타입을 사용하자.
- 메서드 오버라이딩 시 타당한 매개변수 타입을 사용하자.
- dynamic 리스트를 타입화된 리스트로 사용하지 말자.
다음은 이러한 규칙을 상세히 설명하기 위해 사용할 타입 계층 구조이다.
메서드 오버라이딩 시 타당한 반환 타입 사용
하위 클래스의 메서드의 반환 타입은 상위 클래스의 반환 타입과 동일하거나 하위 타입이어야 한다.
다음은 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 => ...
}
class HoneyBadger extends Animal {
@override
void chase(Animal a) {...}
@override
Root get parent => ... // 실패
}
메서드 오버라이딩 시 타당한 매개변수 타입 사용
오버라이딩된 메서드의 매개변수는 상위 클래스의 매개변수와 동일한 타입이거나 상위 타입이어야 한다. 원래 매개변수의 타입을 하위 타입으로 대체해서 매개변수의 타입을 좁히는 것을 지양해야 한다. 매개변수를 하위 타입으로 좁히게 되면 더 이상 상위 타입을 매개변수로 받을 수 없게 된다.
만약 하위 타입을 사용해야 하는 정당한 이유가 있는 경우에는 covariant 키워드를 사용할 수 있다.
Animal 클래스의 Chase(Animal) 메서드를 예를 들어 보자.
class Animal {
void chase(Animal a) {...}
}
chase() 메서드는 Animal을 매개변수로 사용한다. HoneyBadger는 무엇이든 추적한다면 Object로 오버라이드해도 괜찮다.
class HoneyBadger extends Animal {
@Override
void chase(Object a) {...}
}
반면, 다음처럼 Animal의 하위 타입인 Mouse로 좁히려고 한다면 에러가 발생한다.
class Mouse extends Animal {...}
class Cat extends Animal {
@override
void chase(Mouse x) {...} // 에러
}
dynamic 리스트를 타입화된 리스트로 사용하지 않기
dynamic 리스트는 서로 다른 타입의 항목을 포함하는 리스트를 만들 때 유용하다. 하지만 타입 타당성을 유지하려면 dynamic 리스트 대시 타입화된 리스트를 사용해야 한다.
이 규칙은 제네릭 타입의 인스턴스에도 적용된다.
다음 코드는 Cat 타입만 포함할 리스트를 만들고 dynamic 리스트와 Cat으로 타입화된 리스트의 차이점을 보여준다. dynamic 리스트는 Dog 타입을 리스트에 할당해도 오류가 발생하지 않지만, Cat으로 타입화된 리스트는 오류를 생성하게 된다.
List<Cat> foo = <dynamic>[Dog()]; // 오류
var bar1 = <dynamic[Dog(), Cat()]; // 정상
List<dynamic> bar2 = <dynamic[Dog(), Cat()]; // 정상
런타임 체크
런타임 체크는 컴파일 시간에 감지할 수 없는 타입 안전성 문제를 다룬다. 예를 들어, 다음 코드는 Dog 리스트를 Cat 리스트로 캐스팅하는 경우로, 이것은 불가능하므로 런타임 시 에러가 발생한다.
// 실패
void main() {
List<Animal> animals = [Dog()];
List<Cat> cats = animals as List<Cat>;
}
타입 추론
분석기는 필드, 메서드, 지역변수 및 제네릭 타입 인자에 대한 대부분의 타입을 추론할 수 있다. 분석기가 충분한 정보를 얻을 수 없어서 특정 타입으로 추론할 수 없는 경우에는 dynamic 타입으로 추론한다.
다음은 타입 추론이 제네릭과 함께 사용되는 예시이다. 이 예시에서 arguments라는 변수는 다양한 타입의 값을 String 키와 쌍으로 가진 맵이다. 변수에 명시적으로 타입을 지정하는 경우, 다음과 같이 작성할 수 있다.
Map<String, dynamic> arguments = {'argA': 'hello', 'argB': 42};
대신 var 또는 final을 사용하여 다트에 타입을 추론하게 할 수도 있다.
var arguments = {'argA': 'hello', 'argB': 42}; // Map<String, Object>
맵 리터럴은 항목에 대한 타입을 추론하고, 변수는 맵 리터럴의 타입을 추론한다. 이 맵에서 키는 모두 문자열이지만 값은 다른 타입(String과 int)을 갖는다. 따라서 맵 리터럴은 String과 int의 상위 타입인 Object으로 추론하여 Map<String, Object> 타입이 되고, arguments 변수도 그와 같은 타입을 가진다.
필드와 메서드 추론
지정된 타입이 없고 상위 클래스의 필드 또는 메서드를 오버라이드하는 경우에는 상위 클래스의 타입을 기반으로 추론한다.
선언되지 않았거나 상속받지 않은 필드는 선언된 초기 값에 기반하여 추론된 타입을 얻는다.
정적 필드 추론
정적 필드와 변수는 초기 값을 기반으로 타입이 추론된다. 단 추론 중에 순환을 만나는 경우에는 추론은 실패한다.
다음 예제에서 a타입은 b의 타입을 알아야 하고 b타입은 a타입을 알아야 하는 순환이 발생하게 된다.
class Example {
static var a = b + 1;
static var b = a + 1;
}
지역 변수 추론
지역 변수의 타입은 초기 값에 기반하여 추론된다. 이후의 할당은 고려되지 않는다.
추론된 타입이 의도치 않게 너무 정확할 수도 있다. 그런 경우에는 원하는 타입 주석을 추가하면 해결할 수 있다.
// 실패
var x = 3; // x는 int로 추론됩니다.
x = 4.0;
// 성공
num y = 3; // num은 double 또는 int일 수 있습니다.
y = 4.0;
타입 인자 추론
생성자 호출 및 제네릭 메서드 호출의 타입 인자는 문맥의 하향 정보와 생성자 또는 제네릭 메서드의 인수의 상향 정보의 조합에 따라 추론된다. 추론이 원하는 대로 작동하지 않는 경우에는 타입 인자를 명시적으로 지정할 수 있다.
// 상향 정보를 이용 : []를 <int>[]로 추론한다.
List<int> listOfInt = [];
// 하향 정보를 이용 : listOfDouble을 <double>List로 추론한다.
var listOfDouble = [3.0];
// 하향 정보를 이용 : ints를 Iterable<int>과 같이 추론한다.
var ints = listOfDouble.map((x) => x.toInt());
마지막 예시에서 x는 하향 정보를 사용하여 double로 추론된다. 클로저의 반환 타입은 상향 정보를 사용하여 int로 추론된다. map 타입은 클로저의 상향 정보를 사용하여 Iterable<int>로 추론한다.
타입 대체
메서드를 오버라이드할 때는 이전 메서드의 타입을 새로운 메서드 타입으로 대체하는 것이다. 마찬가지로 함수에 인수를 전달할 때는 선언된 타입을 가진 매개변수를 실제 인수로 대체하는 것이다. 언제 어떤 타입을 서브타입이나 슈퍼타입으로 대체할 수 있을까?
타입을 대체할 때는 소비자와 생산자의 개념을 생각하는 것이 도움이 된다. 소비자는 타입을 소비하고 생산자는 타입을 생성한다.
소비자의 타입은 슈퍼 타입으로 대체할 수 있고, 생산자는 서브타입으로 대체할 수 있다.
간단한 타입 할당
객체를 객체에 할당할 때, 언제 타입을 다른 타입으로 대체할 수 있을까? 그 답은 객체가 소비자인지 생산자인지에 따라 달라진다.
Cat c는 소비자이고 Cat()은 생성자이다.
Cat c = Cat();
소비자 위치에서 특정 타입(Cat)을 소비하는 것을 아무 타입(Animal)을 소비하는 것으로 대체하는 것이 안전하다. 따라서 Cat c를 Animal c로 대체하는 것은 허용된다. 왜냐하면 Animal이 Cat의 슈퍼 타입이기 때문이다.
Animal c = Cat(); // 성공
그러나 Cat c를 MaineCoon c로 대체하는 것은 타입 안전성을 깨뜨린다. 왜냐하면 상위 클래스가 Lion과 같은 다른 동작을 가진 Cat의 타입을 제공할 수 있기 때문이다.
MaineCoon c = Lion(); // 에러
생산자 위치에서는 특정 타입(Cat)을 생성하는 것을 더 구체적인 타입(Lion)으로 대체하는 것이 안전하다. 따라서 다음은 허용된다.
Cat c = Lion(); // 성공
제네릭 타입 할당
제네릭 타입에 대해서도 같은 규칙이 적용된다. Animal 리스트 계층 구조를 고려해 보자. List<Cat>은 List<Animal>의 서브 타입이고, List<MaineCoon>의 슈퍼 타입이다.
다음 예제에서 List<MaineCoon>을 myCats에 할당할 수 있다. 왜냐하면 List<MaineCoon>은 List<Cat>의 서브 타입이기 때문이다.
List<MaineCoon> myMaineCoons = ...
List<Cat> myCats = myMaineCoons; // 성공
그렇다면 반대 방향으로 할당할 수 있을까? Animal 리스트를 Cat 리스트에 할당할 수 있을까?
List<Animal> myAnimals = ...
List<Cat> myCats = myAnimals; // 실패
이 할당은 정적 분석을 통과하지 못한다. 왜냐하면 다트는 암시적 타입 변환을 지원하지 않기 때문이다.
이러한 유형의 코드가 정적 분석을 통과하도록 하려면 명시적 타입 변환을 사용해야 한다.
List<Cat> myCats = myAnimals as List<Cat>;
위의 코드는 런타임 실행 시에 실패한다. Animal 타입을 Cat 타입으로 변환할 수 없기 때문이다.
메서드
메서드를 오버라이드할 때도 생산자와 소비자의 규칙이 적용된다.
소비자(예: chase 메서드)의 경우 매개변수 타입을 슈퍼타입으로 대체할 수 있다. 생산자(예: parent 게터)의 경우, 반환 타입을 서브 타입으로 대체할 수 있다.
'다트 공식 문서 번역' 카테고리의 다른 글
다트] 패턴 종류 (0) | 2024.07.27 |
---|---|
다트] 패턴 (0) | 2024.07.27 |
다트] 타입 별칭, 인라인 함수 타입 (0) | 2024.07.24 |
다트] 제네릭 (0) | 2024.07.24 |
다트] 컬렉션 - 리스트(list), 셋(set), 맵(map) (0) | 2024.07.22 |