iBetter Books
수정

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에서 intComparable<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.nameanimal.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, TemperatureComparable을 구현한 모든 타입에 대해 동작합니다.

공변성 — 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>처럼 동작하지는 않습니다. 읽기 전용 맥락에서는 안전하지만, 쓰기 맥락에서는 주의가 필요합니다.

다음 챕터에서는 기존 클래스에 새 메서드를 추가하는 확장 메서드를 알아봅니다. 클래스를 수정하지 않고도 새 기능을 붙이는 방법입니다.