iBetter Books
수정

앞 챕터에서 제네릭이 왜 필요한지 이해했습니다. 이제 직접 만들어봅니다. 제네릭 클래스와 제네릭 함수를 작성하는 방법, 여러 타입 파라미터를 사용하는 방법, 그리고 Dart의 타입 추론이 어떻게 코드를 더 간결하게 만들어주는지 살펴봅니다.

제네릭 클래스

가장 간단한 제네릭 클래스부터 시작합니다. 무언가를 담는 상자, Box<T>입니다.

class Box<T> {
  T value;

  Box(this.value);

  T get() => value;

  void set(T newValue) {
    value = newValue;
  }

  @override
  String toString() => 'Box($value)';
}

void main() {
  Box<int> intBox = Box<int>(42);
  Box<String> strBox = Box<String>('안녕하세요');

  print(intBox.get());    // 42
  print(strBox.get());    // 안녕하세요

  intBox.set(100);
  print(intBox);          // Box(100)

  // 잘못된 타입은 컴파일 오류가 됩니다.
  // intBox.set('문자열');  // 오류: String은 int에 대입 불가
}

class Box<T>에서 <T>는 "이 클래스는 타입 파라미터 T를 받는다"는 선언입니다. 클래스 내부에서는 T를 일반 타입처럼 사용할 수 있습니다. 필드 타입, 메서드 매개변수, 반환 타입 모두 T로 쓸 수 있습니다.

Box<int>를 만들면 그 클래스 안의 T가 모두 int로 대체됩니다. Box<String>을 만들면 TString이 됩니다. 클래스를 두 번 작성하지 않고도 두 가지 타입에 대응합니다.

제네릭 클래스를 활용한 스택

실용적인 예제로 스택(Stack)을 만들어봅니다. 스택은 마지막에 넣은 것을 먼저 꺼내는 자료 구조입니다.

class Stack<E> {
  final List<E> _items = [];

  void push(E item) {
    _items.add(item);
  }

  E pop() {
    if (isEmpty) {
      throw StateError('스택이 비어 있습니다.');
    }
    return _items.removeLast();
  }

  E peek() {
    if (isEmpty) {
      throw StateError('스택이 비어 있습니다.');
    }
    return _items.last;
  }

  bool get isEmpty => _items.isEmpty;
  int get size => _items.length;

  @override
  String toString() => 'Stack($_items)';
}

void main() {
  // 정수 스택
  final Stack<int> intStack = Stack<int>();
  intStack.push(1);
  intStack.push(2);
  intStack.push(3);
  print(intStack);        // Stack([1, 2, 3])
  print(intStack.pop());  // 3
  print(intStack.peek()); // 2

  // 문자열 스택
  final Stack<String> strStack = Stack<String>();
  strStack.push('첫 번째');
  strStack.push('두 번째');
  print(strStack.pop());  // 두 번째
}

List<E>도 제네릭입니다. Stack<E> 안에서 EList의 타입 인수로 그대로 전달합니다. 제네릭이 제네릭 안에 들어가는 구조입니다.

제네릭 함수

클래스만이 아니라 함수도 제네릭으로 만들 수 있습니다. 앞 챕터에서 살짝 봤던 first<T> 함수를 다시 봅니다.

T first<T>(List<T> items) {
  if (items.isEmpty) {
    throw StateError('리스트가 비어 있습니다.');
  }
  return items[0];
}

T last<T>(List<T> items) {
  if (items.isEmpty) {
    throw StateError('리스트가 비어 있습니다.');
  }
  return items[items.length - 1];
}

List<T> reversed<T>(List<T> items) {
  return items.reversed.toList();
}

void main() {
  print(first<int>([10, 20, 30]));             // 10
  print(last<String>(['사과', '바나나', '딸기'])); // 딸기
  print(reversed<int>([1, 2, 3]));              // [3, 2, 1]
}

함수 이름 뒤에 <T>를 붙이고, 매개변수와 반환 타입에 T를 사용합니다. 호출할 때 <int>, <String>처럼 타입 인수를 전달합니다.

타입 추론 — 생략해도 된다

Dart는 타입 추론(type inference)을 지원합니다. 컴파일러가 문맥을 보고 타입을 추론해주기 때문에, 타입 인수를 명시하지 않아도 되는 경우가 많습니다.

T first<T>(List<T> items) => items[0];

void main() {
  // 명시적으로 타입 인수 전달
  int a = first<int>([1, 2, 3]);

  // 타입 추론 — [1, 2, 3]이 List<int>이므로 T는 int
  int b = first([1, 2, 3]);

  // 타입 추론 — ['사과', '바나나']가 List<String>이므로 T는 String
  var c = first(['사과', '바나나']);

  print(a);  // 1
  print(b);  // 1
  print(c);  // 사과
}

first([1, 2, 3])에서 [1, 2, 3]List<int>입니다. Dart는 이를 보고 Tint임을 자동으로 추론합니다. 직접 <int>를 쓰지 않아도 됩니다.

클래스도 마찬가지입니다.

class Box<T> {
  T value;
  Box(this.value);
}

void main() {
  // 명시적 타입 인수
  Box<int> box1 = Box<int>(42);

  // 타입 추론 — 42가 int이므로 T는 int
  var box2 = Box(42);

  // 타입 추론 — '안녕'이 String이므로 T는 String
  var box3 = Box('안녕');

  print(box2.value);  // 42
  print(box3.value);  // 안녕
}

타입 추론이 가능하면 굳이 타입 인수를 쓰지 않아도 됩니다. 코드가 더 간결해집니다. 하지만 추론이 불분명할 때는 명시적으로 써주는 편이 코드를 읽기 쉽게 합니다.

여러 타입 파라미터 — Pair<K, V>

타입 파라미터는 하나가 아니어도 됩니다. 두 값을 함께 묶는 Pair<K, V>를 만들어봅니다.

class Pair<K, V> {
  final K key;
  final V value;

  const Pair(this.key, this.value);

  @override
  String toString() => 'Pair($key, $value)';
}

void main() {
  Pair<String, int> score = Pair('수학', 95);
  Pair<int, bool> flag = Pair(1, true);
  Pair<String, List<int>> record = Pair('점수 목록', [85, 90, 78]);

  print(score);   // Pair(수학, 95)
  print(flag);    // Pair(1, true)
  print(record);  // Pair(점수 목록, [85, 90, 78])

  // 타입 추론으로 더 간결하게
  var name = Pair('이름', '다트');
  print(name);    // Pair(이름, 다트)
}

Map<K, V>도 내부적으로 이런 구조를 활용합니다. K는 Key, V는 Value를 의미합니다.

세 개 이상도 가능하지만, 파라미터가 너무 많아지면 코드를 읽기 어려워집니다. 세 개 이상이 필요하다면 클래스나 레코드(PART 09에서 다룹니다)를 만드는 편이 낫습니다.

제네릭 메서드 — 클래스 안의 제네릭 함수

클래스의 인스턴스 메서드도 자체 타입 파라미터를 가질 수 있습니다. 클래스의 타입 파라미터와 별개입니다.

class Converter {
  // 이 메서드만의 타입 파라미터 <R, T>
  R convert<R, T>(T input, R Function(T) transform) {
    return transform(input);
  }
}

void main() {
  final converter = Converter();

  // int → String 변환
  String result1 = converter.convert<String, int>(
    42,
    (n) => '숫자는 $n입니다.',
  );

  // String → int 변환
  int result2 = converter.convert<int, String>(
    '123',
    (s) => int.parse(s),
  );

  print(result1);  // 숫자는 42입니다.
  print(result2);  // 123
}

Converter 클래스 자체는 타입 파라미터가 없습니다. convert 메서드만 <R, T>를 가집니다. 이렇게 하면 클래스 수준이 아니라 메서드 수준에서만 타입을 유연하게 처리할 수 있습니다.

런타임에서 타입 확인하기

제네릭 타입은 런타임에서도 확인할 수 있습니다.

class Box<T> {
  T value;
  Box(this.value);

  Type get valueType => T;
  bool isType<S>() => value is S;
}

void main() {
  var box = Box<int>(42);
  print(box.valueType);     // int
  print(box.isType<int>()); // true
  print(box.isType<String>());  // false

  // 타입 파라미터 자체도 비교 가능합니다.
  print(box is Box<int>);    // true
  print(box is Box<String>); // false
}

Dart는 런타임에서도 제네릭 타입 정보를 보존합니다. 이를 구체화된 제네릭(reified generics)이라고 합니다. Java에서는 타입 이레이저(type erasure) 때문에 런타임에 제네릭 타입이 사라지지만, Dart는 그렇지 않습니다.

정리

제네릭 클래스는 class 이름<T>로 선언하고, 제네릭 함수는 함수 이름 뒤에 <T>를 붙입니다. 여러 타입 파라미터가 필요하면 <K, V>처럼 콤마로 구분합니다. Dart의 타입 추론 덕분에 많은 경우 타입 인수를 직접 쓰지 않아도 됩니다.

직접 만든 Box<T>, Stack<E>, Pair<K, V>가 Dart 표준 라이브러리의 List<T>, Map<K, V>와 같은 방식으로 동작합니다.

다음 챕터에서는 타입 파라미터에 제약을 거는 방법을 살펴봅니다. 어떤 타입이든 받는 게 아니라, 특정 조건을 만족하는 타입만 받도록 제한하는 것입니다.