T는 어떤 타입이든 받을 수 있습니다. 하지만 때로는 "어떤 타입이든"이 너무 넓습니다. 예를 들어 "크기를 비교할 수 있는 타입"만 받고 싶을 때가 있습니다. 사과와 바나나를 비교할 수는 없으니까요. 이때 extends 키워드로 타입 파라미터에 제약을 겁니다.
제약이 없으면 생기는 문제
두 값 중 더 큰 것을 반환하는 함수를 만들어봅니다.
T max<T>(T a, T b) {
// 오류: T 타입에 > 연산자가 있다는 보장이 없습니다.
return a > b ? a : b;
}
T는 어떤 타입이든 될 수 있습니다. List, File, 사용자 정의 클래스도 될 수 있습니다. 이런 타입들은 > 연산자가 없습니다. 컴파일러는 "T에 > 연산자가 있는지 알 수 없다"며 오류를 냅니다.
해결책은 "크기 비교가 가능한 타입만 받겠다"고 선언하는 것입니다.
T extends로 타입 범위 좁히기
T max<T extends Comparable>(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
void main() {
print(max<int>(10, 20)); // 20
print(max<double>(3.14, 2.71)); // 3.14
print(max<String>('사과', '바나나')); // 바나나 (사전순)
// 비교 불가능한 타입은 컴파일 오류
// print(max<List<int>>([1], [2])); // 오류!
}
<T extends Comparable>은 "T는 Comparable을 구현한 타입이어야 한다"는 의미입니다. int, double, String은 모두 Comparable을 구현하므로 사용할 수 있습니다. 임의의 클래스는 Comparable을 구현하지 않으면 사용할 수 없습니다. Dart에서 int는 Comparable<num>을 구���하기 때문에, 바운드를 Comparable<T>가 아닌 Comparable로 지정하는 것이 호환성이 좋습니다.
Comparable<T>는 Dart 표준 라이브러리에 정의된 추상 클래스입니다. compareTo 메서드를 가지고 있습니다.
a.compareTo(b) < 0 → a가 b보다 작다
a.compareTo(b) == 0 → a와 b가 같다
a.compareTo(b) > 0 → a가 b보다 크다
사용자 정의 클래스에 Comparable 적용
사용자 정의 타입도 Comparable을 구현하면 제약된 제네릭에서 사용할 수 있습니다.
class Temperature implements Comparable<Temperature> {
final double celsius;
Temperature(this.celsius);
@override
int compareTo(Temperature other) {
return celsius.compareTo(other.celsius);
}
@override
String toString() => '$celsius°C';
}
T max<T extends Comparable>(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
void main() {
Temperature summer = Temperature(35.0);
Temperature winter = Temperature(-5.0);
// Temperature가 Comparable<Temperature>를 구현했으므로 사용 가능합니다.
Temperature hotter = max<Temperature>(summer, winter);
print(hotter); // 35.0°C
}
Comparable<Temperature>를 구현한 Temperature는 이제 max<T extends Comparable>에서 사용할 수 있습니다. 제네릭 함수를 수정하지 않았습니다. 기존 코드는 그대로고, 새 타입이 조건을 만족하면 자동으로 확장됩니다.
클래스 타입으로 제약 걸기
Comparable 같은 인터페이스뿐 아니라, 일반 클래스나 추상 클래스로도 제약을 걸 수 있습니다.
// PART 05에서 배운 추상 클래스 패턴입니다.
abstract class Animal {
String get name;
String speak();
}
class Dog extends Animal {
@override
String get name => '강아지';
@override
String speak() => '멍!';
}
class Cat extends Animal {
@override
String get name => '고양이';
@override
String speak() => '야옹!';
}
// T는 Animal의 하위 타입만 가능합니다.
void makeSound<T extends Animal>(T animal) {
print('${animal.name}이(가) 말합니다: ${animal.speak()}');
}
void main() {
makeSound(Dog()); // 강아지이(가) 말합니다: 멍!
makeSound(Cat()); // 고양이이(가) 말합니다: 야옹!
// Animal이 아닌 타입은 컴파일 오류
// makeSound(42); // 오류!
}
<T extends Animal>은 T가 Animal 클래스 또는 그 하위 클래스여야 한다는 의미입니다. 함수 내부에서 animal.name과 animal.speak()를 안전하게 호출할 수 있습니다. 컴파일러가 T에 그 메서드들이 있음을 알기 때문입니다.
바운드 타입을 활용한 정렬
제약을 활용하면 제네릭 컬렉션에 유용한 유틸리티를 만들 수 있습니다.
List<T> sorted<T extends Comparable<T>>(List<T> items) {
List<T> copy = List.from(items);
copy.sort((a, b) => a.compareTo(b));
return copy;
}
T smallest<T extends Comparable<T>>(List<T> items) {
if (items.isEmpty) throw StateError('리스트가 비어 있습니다.');
return items.reduce((a, b) => a.compareTo(b) <= 0 ? a : b);
}
void main() {
List<int> numbers = [5, 2, 8, 1, 9, 3];
print(sorted(numbers)); // [1, 2, 3, 5, 8, 9]
print(smallest(numbers)); // 1
List<String> fruits = ['바나나', '사과', '딸기', '감'];
print(sorted(fruits)); // [감, 딸기, 바나나, 사과]
print(smallest(fruits)); // 감
}
함수 하나가 int, double, String, Temperature 등 Comparable을 구현한 모든 타입에 대해 동작합니다.
공변성 — List은 List인가
이제 조금 까다로운 주제입니다. PART 05에서 Cat extends Animal을 배웠습니다. 그렇다면 List<Cat>은 List<Animal>의 하위 타입일까요.
직관적으로는 "그렇다"고 생각하기 쉽습니다. 고양이 목록은 동물 목록의 일종이니까요. 하지만 Dart에서는 주의가 필요합니다.
class Animal {
String get name => '동물';
}
class Cat extends Animal {
@override
String get name => '고양이';
}
class Dog extends Animal {
@override
String get name => '강아지';
}
void main() {
List<Cat> cats = [Cat(), Cat()];
// Dart에서는 이 대입이 가능합니다.
List<Animal> animals = cats;
// 하지만 여기서 문제가 생깁니다.
animals.add(Dog()); // 런타임 오류!
// cats는 List<Cat>인데, Dog를 넣으려 했으니 오류가 납니다.
}
List<Cat>을 List<Animal>에 대입할 수 있는 것처럼 보이지만, 그 List<Animal>을 통해 Dog를 추가하려 하면 런타임 오류가 발생합니다. cats는 실제로 List<Cat>이기 때문입니다.
이런 상황을 방지하려면 읽기 전용 타입을 사용합니다.
void main() {
List<Cat> cats = [Cat(), Cat()];
// Iterable이나 List를 읽기 전용으로 쓰면 안전합니다.
Iterable<Animal> animals = cats; // 읽기만 가능
for (var animal in animals) {
print(animal.name); // 고양이
}
// animals.add(Dog()); // 컴파일 오류 — Iterable에는 add가 없습니다.
}
Iterable<Animal>은 읽기만 가능합니다. add가 없습니다. 따라서 List<Cat>을 Iterable<Animal>로 쓰는 것은 안전합니다.
공변성의 핵심은 이것입니다. 읽기만 한다면 List<Cat>을 List<Animal>처럼 사용해도 괜찮습니다. 하지만 쓰기(추가/수정)가 섞이면 위험해질 수 있습니다. Dart는 이를 런타임에 검사합니다.
정리
<T extends 상위타입>으로 타입 파라미터의 범위를 제한합니다. 이를 통해 제네릭 함수나 클래스 내부에서 특정 메서드나 속성을 안전하게 사용할 수 있습니다. Comparable<T>는 비교 연산이 필요한 제네릭에서 자주 쓰이는 표준 제약입니다.
공변성은 제네릭의 미묘한 부분입니다. Cat extends Animal이라고 해서 List<Cat>이 언제나 List<Animal>처럼 동작하지는 않습니다. 읽기 전용 맥락에서는 안전하지만, 쓰기 맥락에서는 주의가 필요합니다.
다음 챕터에서는 기존 클래스에 새 메서드를 추가하는 확장 메서드를 알아봅니다. 클래스를 수정하지 않고도 새 기능을 붙이는 방법입니다.