제네릭: 타입을 파라미터로 재사용하기

제네릭: 타입을 파라미터로 재사용하기

기본 배열 타입인 List의 API 문서를 보면, 실제 타입이 List<E>인 걸 확인할 수 있어요. 여기서 <...> 표기법이 List제네릭(혹은 파라미터화된) 타입으로 표시합니다. 즉 formal 타입 파라미터를 가지는 타입이라는 뜻이에요. 관례에 따라 대부분의 타입 변수는 E, T, S, K, V 같은 한 글자 이름을 써요. 이 글에서는 제네릭을 왜 쓰는지, 컬렉션 리터럴과 생성자에서 어떻게 쓰는지, 타입 제한(bound)과 제네릭 메서드까지 살펴볼게요.

출처: Dart 공식 문서 — Generics

왜 제네릭을 쓸까?

제네릭은 종종 타입 안전성을 위해 필요하지만, 코드가 실행되는 데 도움을 주는 것보다 더 많은 이점이 있어요.

  • 제네릭 타입을 제대로 지정하면 더 나은 코드가 생성된다.
  • 제네릭으로 코드 중복을 줄일 수 있다.

리스트에 문자열만 담으려고 한다면 List<String>으로 선언할 수 있어요("string의 list"라고 읽어요). 그렇게 하면 여러분, 동료 프로그래머, 그리고 도구가 리스트에 문자열이 아닌 값을 할당하는 게 아마 실수라고 감지할 수 있어요. 예를 들면 다음과 같아요.

var names = <String>[];
names.addAll(['Seth', 'Kathy', 'Lars']);
names.add(42); // Error

제네릭을 쓰는 또 다른 이유는 코드 중복을 줄이기 위해서예요. 제네릭을 쓰면 정적 분석 이점을 누리면서도 많은 타입 사이에서 하나의 인터페이스와 구현을 공유할 수 있어요. 예를 들어 객체를 캐시하는 인터페이스를 만든다고 해 볼게요.

abstract class ObjectCache {
  Object getByKey(String key);
  void setByKey(String key, Object value);
}

문자열 전용 버전이 필요해져서 또 다른 인터페이스를 만들고, 나중에는 숫자 전용 버전도 만들고 싶어질 거예요. 이런 인터페이스를 전부 만들 필요 없이, 제네릭 타입은 타입 파라미터를 받는 단일 인터페이스 하나로 끝낼 수 있어요.

abstract class Cache<T> {
  T getByKey(String key);
  void setByKey(String key, T value);
}

이 코드에서 T는 대체 타입이에요. 개발자가 나중에 정의할 타입이라고 생각할 수 있는 자리 표시자입니다.

컬렉션 리터럴 사용하기

List, set, map 리터럴은 파라미터화할 수 있어요. 파라미터화된 리터럴은 여태 본 리터럴과 같지만, 여는 괄호 앞에 <type>(list와 set용) 또는 <keyType,valueType>(map용)을 추가한다는 점만 달라요. 타입이 있는 리터럴의 예시를 볼게요.

var names = <String>['Seth', 'Kathy', 'Lars'];
var uniqueNames = <String>{'Seth', 'Kathy', 'Lars'};
var pages = <String, String>{
  'index.html': 'Homepage',
  'robots.txt': 'Hints for web robots',
  'humans.txt': 'We are people, not machines',
};

생성자에 파라미터화된 타입 사용하기

생성자를 쓸 때 하나 이상의 타입을 지정하려면 클래스 이름 바로 뒤에 앵글 브래킷(<...>)으로 타입을 넣어요. 예를 들어 다음과 같아요.

var nameSet = Set<String>.of(names);

다음 코드는 정수 키와 View 타입 값을 가지는 SplayTreeMap을 만듭니다.

var views = SplayTreeMap<int, View>();

제네릭 컬렉션과 그 안의 타입

Dart 제네릭 타입은 reified됩니다. 즉 런타임에도 타입 정보를 지니고 다닌다는 뜻이에요. 예를 들어 컬렉션의 타입을 검사할 수 있어요.

var names = <String>[];
names.addAll(['Seth', 'Kathy', 'Lars']);
print(names is List<String>); // true

파라미터화된 타입 제한하기

제네릭 타입을 구현할 때 타입 인자로 제공될 수 있는 타입을 제한하고 싶을 수 있어요. 인자가 특정 타입의 서브타입이어야 한다는 제한이죠. 이 제한을 bound라고 부르고 extends로 만들 수 있어요.

흔한 사용 사례는 타입을 Object의 서브타입으로 만들어(기본인 Object? 대신) null 허용이 아니게 하는 거예요.

class Foo<T extends Object> {
  // Any type provided to Foo for T must be non-nullable.
}

Object 말고 다른 타입에도 extends를 쓸 수 있어요. SomeBaseClass를 확장하는 예시를 볼게요. 그러면 SomeBaseClass의 멤버를 타입 T의 객체에 호출할 수 있어요.

class Foo<T extends SomeBaseClass> {
  // Implementation goes here...
  String toString() => "Instance of 'Foo<$T>'";
}

class Extender extends SomeBaseClass {
  ...
}

SomeBaseClass나 그 서브타입은 제네릭 인자로 써도 돼요.

var someBaseClassFoo = Foo<SomeBaseClass>();
var extenderFoo = Foo<Extender>();

제네릭 인자를 지정하지 않아도 괜찮아요.

var foo = Foo();
print(foo); // Instance of 'Foo<SomeBaseClass>'

SomeBaseClass가 아닌 타입을 지정하면 에러가 나요.

var foo = Foo<Object>();

자기 참조 타입 파라미터 제한 (F-bound)

bound로 타입 파라미터를 제한할 때, 그 bound를 타입 파라미터 자체로 돌아가 가리킬 수 있어요. 이것이 자기 참조 제약, 즉 F-bound를 만듭니다. 예를 들어 다음과 같아요.

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

int compareAndOffset<T extends Comparable<T>>(T t1, T t2) =>
    t1.compareTo(t2) + 1;

class A implements Comparable<A> {
  @override
  int compareTo(A other) => /* ...implementation... */ 0;
}

int useIt = compareAndOffset(A(), A());

F-bound T extends Comparable<T>는 T가 자기 자신과 비교 가능해야 한다는 뜻이에요. 그래서 A는 같은 타입의 다른 인스턴스와만 비교할 수 있어요.

제네릭 메서드 사용하기

메서드와 함수도 타입 인자를 허용해요.

T first<T>(List<T> ts) {
  // Do some initial work or error checking, then...
  T tmp = ts[0];
  // Do some additional checking or processing...
  return tmp;
}

여기서 first의 제네릭 타입 파라미터(<T>) 덕분에 타입 인자 T를 여러 곳에서 쓸 수 있어요.

  • 함수의 반환 타입(T)에서
  • 인자 타입(List<T>)에서
  • 지역 변수 타입(T tmp)에서

더 알아보기