앞 챕터에서 제네릭이 왜 필요한지 이해했습니다. 이제 직접 만들어봅니다. 제네릭 클래스와 제네릭 함수를 작성하는 방법, 여러 타입 파라미터를 사용하는 방법, 그리고 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>을 만들면 T가 String이 됩니다. 클래스를 두 번 작성하지 않고도 두 가지 타입에 대응합니다.
제네릭 클래스를 활용한 스택
실용적인 예제로 스택(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> 안에서 E를 List의 타입 인수로 그대로 전달합니다. 제네릭이 제네릭 안에 들어가는 구조입니다.
제네릭 함수
클래스만이 아니라 함수도 제네릭으로 만들 수 있습니다. 앞 챕터에서 살짝 봤던 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는 이를 보고 T가 int임을 자동으로 추론합니다. 직접 <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>와 같은 방식으로 동작합니다.
다음 챕터에서는 타입 파라미터에 제약을 거는 방법을 살펴봅니다. 어떤 타입이든 받는 게 아니라, 특정 조건을 만족하는 타입만 받도록 제한하는 것입니다.