Dart 제네릭

기본 배열 타입 List의 API 문서를 확인하면 실제 타입이 List<E>임을 알 수 있습니다. <...> 각괄호 기호는 제네릭 타입(매개변수화 타입), 즉 정식 타입 매개변수를 가진 타입을 의미합니다. 관례상 대부분의 타입 변수는 E, T, S, K, V처럼 한 글자로 이름을 지정합니다.

제네릭을 사용하는 이유

  • 제네릭의 핵심 역할은 타입 안정성을 보장하는 것입니다
  • 제네릭을 올바르게 지정하면 더 최적화된 런타임 코드가 생성됩니다;
  • 제네릭을 사용하면 코드 중복을 크게 줄일 수 있습니다.

문자열만 저장하는 리스트를 만들고 싶다면 List<String>으로 선언합니다. 이것이 바로 문자열 리스트입니다.

문자열 전용 리스트로 정의한 뒤 문자열이 아닌 타입의 값을 넣으려 하면 오류가 발생합니다.

// 정적 타입 검사 오류
var names = <String>[];
names.addAll(['Seth', 'Kathy', 'Lars']);
names.add(42); // 컴파일 오류: 42는 문자열이 아닙니다Code language: PHP (php)
D:\dartdemo\firstdart>dart run
Building package executable...
Failed to build firstdart:firstdart:
bin/firstdart.dart:5:12: Error: The argument type 'int' can't be assigned to the parameter type 'String'.
        names.add(42); // 컴파일 오류: 42는 문자열이 아닙니다Code language: PHP (php)

코드 중복 줄이기

객체를 캐시하는 추상 인터페이스가 필요하다고 가정해 보겠습니다:

abstract class ObjectCache {
  Object getByKey(String key);
  void setByKey(String key, Object value);
}
Code language: JavaScript (javascript)

나중에 문자열만 저장하는 캐시 인터페이스가 또 필요해지면 별도로 새로 만들어야 합니다:

abstract class StringCache {
  String getByKey(String key);
  void setByKey(String key, String value);
}
Code language: Dart (dart)

그 뒤 숫자 전용 캐시도 필요하게 되고…… 거의 똑같은 코드를 반복해서 작성해야 합니다.

예를 들어 아래와 같은 코드를 작성하게 됩니다:

abstract class IntCache {
  int getByKey(String key);
  void setByKey(String key, int value);
}Code language: Dart (dart)

제네릭을 사용하면 타입 매개변수가 있는 범용 인터페이스 한 개만 정의해 이 문제를 한 번에 해결할 수 있습니다:

abstract class Cache<T> {
  T getByKey(String key);
  void setByKey(String key, T value);
}Code language: Dart (dart)

여기서 T는 타입 자리 표시자로, 개발자가 나중에 전달할 임의의 실제 타입을 의미합니다.

구체적인 예시

// 제네릭 추상 캐시 기본 클래스
abstract class Cache<T> {
  T? getByKey(String key);
  void setByKey(String key, T value);
  void remove(String key);
}

// 메모리 캐시 구현 (Map 기반)
class MemoryCache<T> implements Cache<T> {
  final Map<String, T> _storage = {};

  @override
  T? getByKey(String key) {
    return _storage[key];
  }

  @Override
  void setByKey(String key, T value) {
    _storage[key] = value;
  }

  @Override
  void remove(String key) {
    _storage.remove(key);
  }
}

void main() {
  // 1. 문자열 캐시 예시
  final Cache<String> strCache = MemoryCache<String>();
  strCache.setByKey("name", "Tom");
  print(strCache.getByKey("name")); // Tom

  // 2. 정수 int 캐시
  final Cache<int> intCache = MemoryCache<int>();
  intCache.setByKey("age", 26);
  print(intCache.getByKey("age")); // 26

  // 3. 사용자 정의 객체 캐시
  final Cache<User> userCache = MemoryCache<User>();
  userCache.setByKey("u1", User("Jack", 18));
  final User? user = userCache.getByKey("u1");
  print(user?.name); // Jack

  // 삭제 테스트
  userCache.remove("u1");
  print(userCache.getByKey("u1")); // null
}

// 사용자 정의 엔티티 클래스
class User {
  final String name;
  final int age;
  User(this.name, this.age);
}Code language: Dart (dart)

제네릭 컬렉션 리터럴 사용하기

List, Set, Map 리터럴은 모두 매개변수화 제네릭을 지원합니다:

  • List / Set: 왼쪽 대괄호 앞에 <타입> 작성
  • Map: 왼쪽 중괄호 앞에 <키타입, 값타입> 작성

예시:

// 문자열 리스트
var names = <String>['Seth', 'Kathy', 'Lars'];

// 문자열 집합 (중복 자동 제거)
var uniqueNames = <String>{'Seth', 'Kathy', 'Lars'};

// 문자열 키, 문자열 값 딕셔너리
var pages = <String, String>{
  'index.html': '메인 페이지',
  'robots.txt': '크롤러 안내 파일',
  'humans.txt': '개발자용 설명 파일',
};
Code language: PHP (php)

실행 예제

void main() {
  // 1. List<String> 문자열 리스트: 순서 보장, 중복 허용, 인덱스로 값 조회 가능
  var names = <String>['Seth', 'Kathy', 'Lars', 'Seth'];
  print("=== List 리스트 ===");
  print(names); // [Seth, Kathy, Lars, Seth] 중복 허용
  print(names[0]); // 인덱스로 첫 요소 가져오기: Seth
  names.add("Mike"); // 끝에 요소 추가
  print("추가 후: $names");

  // 2. Set<String> 문자열 집합: 순서 없음, 중복 자동 삭제, 인덱스 없음
  var uniqueNames = <String>{'Seth', 'Kathy', 'Lars', 'Seth'};
  print("\n=== Set 집합 (중복 자동 제거) ===");
  print(uniqueNames); // {Seth, Kathy, Lars} 중복 Seth 자동 삭제
  print(uniqueNames.contains("Kathy")); // 존재 여부 확인: true
  uniqueNames.remove("Lars");
  print("Lars 삭제 후: $uniqueNames");

  // 3. Map<String, String> 키-값 딕셔너리: 키는 유일, 키로 값 조회
  var pages = <String, String>{
    'index.html': '메인 페이지',
    'robots.txt': '크롤러 안내 파일',
    'humans.txt': '개발자용 설명 파일',
  };
  print("\n=== Map 딕셔너리 ===");
  print(pages);
  print(pages['index.html']); // 키로 값 조회: 메인 페이지
  pages["about.html"] = "소개 페이지"; // 키-값 추가
  print("about.html 추가 후: $pages");

  // 자주 사용하는 순회 예시
  print("\n=== List 순회 ===");
  for (var name in names) {
    print("- $name");
  }

  print("\n=== Map 모든 키-값 순회 ===");
  for (var entry in pages.entries) {
    print("파일: ${entry.key} → 설명: ${entry.value}");
  }
}Code language: Dart (dart)

생성자에 제네릭 매개변수 사용

생성자를 호출할 때 클래스 이름 뒤 각괄호 안에 제네릭 타입을 지정합니다:

var nameSet = Set<String>.of(names);Code language: JavaScript (javascript)

아래 코드는 키 타입 int, 값 타입 View인 딕셔너리를 생성합니다:

var views = SplayTreeMap<int, View>();
Code language: HTML, XML (xml)
import 'dart:collection';

void main() {
  var names = <String>['Seth', 'Kathy', 'Lars', 'Seth'];
  var nameSet = Set<String>.of(names);
  print("원본 리스트: $names");
  print("중복 제거 Set: $nameSet");

  // SplayTreeMap은 dart:collection 임포트 필요
  var views = SplayTreeMap<int, String>();
  views[5] = "설정 페이지";
  views[2] = "메인 페이지";
  views[9] = "마이 페이지";
  views[1] = "시작 화면";

  print("\n정렬된 TreeMap:");
  for (var e in views.entries) {
    print("${e.key} : ${e.value}");
  }
}Code language: PHP (php)

제네릭 컬렉션의 런타임 타입

타입 리피케이션(reified)

Dart의 제네릭은 리피케이션(reified)을 지원합니다: 런타임에 제네릭 타입 정보가 완전히 유지됩니다.

런타임에서 컬렉션의 구체적인 제네릭 타입을 직접 검사할 수 있습니다:

void main() {
    var names = <String>[];
	names.addAll(['Seth', 'Kathy', 'Lars']);
	print(names is List<String>); // true 출력
}Code language: PHP (php)

비교 추가: Java 제네릭은 타입 소거(Type Erasure)를 사용해 런타임에 제네릭 매개변수를 삭제합니다. Java에서는 객체가 List인지만 판단할 수 있고 List<String>List<int>를 구분할 수 없습니다.

* 제네릭 매개변수 타입 제한하기

바운드(bound) 관련 내용입니다. 클래스를 아직 배우지 않았다면 개념만 파악하면 됩니다.

제네릭 정의 시 전달하는 타입이 특정 부모 클래스의 자식 클래스만 오도록 제약할 수 있습니다. 이 제약을 타입 바운드라고 부르며 extends 키워드로 구현합니다.

1 널 불가 타입으로 제한

제네릭이 널 허용 타입이 아닌 타입만 받도록 강제하기 (기본 상한은 널 허용 Object?):

class Foo<T extends Object> {
  // 전달하는 T는 널 허용 타입일 수 없음
}
Code language: JavaScript (javascript)

2 지정한 부모 클래스의 자식 클래스로 제한

extends로 기본 클래스를 지정하면 기본 클래스의 모든 멤버 메서드를 직접 호출할 수 있습니다:

class Foo<T extends SomeBaseClass> {
  String toString() => "인스턴스 Foo<$T>";
}

class Extender extends SomeBaseClass {}
Code language: JavaScript (javascript)

유효한 타입 인자: 부모 클래스 자체, 모든 자식 클래스

var someBaseClassFoo = Foo<SomeBaseClass>();
var extenderFoo = Foo<Extender>();
Code language: HTML, XML (xml)

제네릭 매개변수를 생략하면 기본으로 바운드 타입이 사용됩니다:

var foo = Foo();
print(foo); // 출력: 인스턴스 Foo<SomeBaseClass>
Code language: PHP (php)

관계 없는 타입을 전달하면 정적 컴파일 오류가 발생합니다:

var foo = Foo<Object>(); // 컴파일 오류: Object는 SomeBaseClass의 자식 클래스가 아님
Code language: JavaScript (javascript)

* 자기 참조 타입 바운드 (F-bound 유계 제네릭)

제네릭 제약을 걸 때 바운드에 자기 자신의 타입 매개변수를 참조시켜 자기 제약을 만들 수 있으며 이를 F-bound라고 부릅니다.

표준 예시: 비교 가능 인터페이스 Comparable<T>

abstract interface class Comparable<T> {
  int compareTo(T o);
}

// T는 Comparable<T>를 구현해야 하며 같은 타입 객체끼리만 비교 가능
int compareAndOffset<T extends Comparable<T>>(T t1, T t2) =>
    t1.compareTo(t2) + 1;

class A implements Comparable<A> {
  @override
  int compareTo(A other) {
    // 비교 로직 구현
    return 0;
  }
}

int res = compareAndOffset(A(), A());
Code language: PHP (php)

T extends Comparable<T> 제약의 의미: T는 동일한 타입 인스턴스끼리만 비교할 수 있다.

* 제네릭 메서드

일반 함수, 클래스 메서드도 제네릭 타입 매개변수를 지원합니다:

T first<T>(List<T> ts) {
  // 사전 검증 로직
  T tmp = ts[0];
  // 후처리 로직
  return tmp;
}
Code language: PHP (php)

실제 예시 확인

T first<T>(List<T> ts) {
  if (ts.isEmpty) {
    throw ArgumentError("리스트는 비어 있을 수 없습니다");
  }
  T tmp = ts[0];
  return tmp;
}

void main() {
  List<String> strList = ["apple", "banana", "orange"];
  String firstStr = first(strList);
  print(firstStr); //apple

  List<int> numList = [10, 20, 30];
  int firstNum = first(numList);
  print(firstNum);//10
}Code language: Dart (dart)

제네릭 매개변수 <T>는 세 곳에 적용됩니다:

  1. 함수 반환 타입 T
  2. 매개변수 타입 List<T>
  3. 지역 변수 타입 T tmp

Previous:

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다