본문 바로가기
코드잇 스프린트 6기/TS_Study Together

두 다형성의 만남

by 학습하는 청년 2024. 6. 10.

최종 수정 : 2024-06-10

두 다형성의 만남

1. 제네릭 클래스와 상속

전통적으로 객체 지향 언어와 서브타입에 의한 다형성을, 함수형 언어가 매개변수에 의한 다형성을 지원햇다. 하지만 최근에는 한 언어가 두 종류의 다형성을 모두 제공하는 경우가 흔하다. 두 다형성이 만나면 각 다형성이 제공하는 기능만으로 끝이 아니다. 두 가지가 함께 있을 때에만 존재할 수 있는 흥미롭고 유용한 기능들이 탄생한다.

 

제네릭 클래스가 있을 때 타입들 사이의 서브타입 관계는 어떻게 될까? A가 B를 상속하면 A가 B의 서브타입이라고 했다. 이 원리가 제네릭 클래스에도 그대로 적용된다.

abstract class List<T> {
  abstract get(idx: number): T;
}

class ArrayLst<T> {
  get(idx: number): T { ... }
}

class BitVector {
  get(idx: number): boolean { ... }
}

let l1: List<string> = new ArrayList<string>();
let l2: List<boolean> = new ArrayList<boolean>();
let l3: List<boolean> = new BitVector();

구조에 의한 서브타입을 사용하므로 각 타입이 가지고 있는 필드와 메서드를 고려해 서브타입 여부를 판단한다. List<string>이 가지고 있는 get 메서드와 동일한 시그니처의 메서드를 ArrayList<string>도 가지고 있으므로 ArrayList<boolean>과 BitVector는 List<boolean>의 서브타입이다.


2. 타입 매개변수 제한

타입 검사기는 제네릭 함수의 정의를 검사할 때 타입 매개변수가 아무 타입이나 나타낼 수 있디고 간주한다. 따라서 T가 타입 매개변수일 때 T 타입의 매개변수를 출력하궈나 반환할 수는 있어도 덧셈이나 곱셈 같이 특별한 능력이 필요한 곳에는 사용할 수 없다. 제네릭 함수를 정의한다는 것은 여러 타입으로 사용될 수 있는 함수를 만드는 일이니, 인자로 주어질 값이 특별한 능력을 가진다고 가정할 수 없다. 반대로 인자가 특별한 능력을 가져야만 한다면 그 함수는 여러 타입으로 사용될 수 있는 함수가 아니다. 그러면 제네릭 함수가 될 필요가 없다.

 

T elder<T <: Person>(T p, T q) {
  return (p.age >= q.age) ? p : q;
}

T <: Person은 타입 매개변수 T의 상한(upper bound)을 Person으로 지정한 것으로, 직관적으로는 "T가 최대 Person 타입까지 커질 수 있다"라는 의미다. 모든 값을 포함하는 타입인 최대 타입이 직관적으로 '가장 큰 타입'이듯이, 타입이 크다는 말은 더 많은 값을 포함한다는 뜻이다. 즉, T <: Person의 뜻은 T에 해당하는 타입이 아무리 많은 값을 포함해 봤자 Person이 한계라는 것이다. 다시 말해, "T가 Person의 서브타입이다."라고 말할 수 있다.

 

class Person { age: number; }
class Student extends Person { grade: number; }
function elder<T extends Person>(p: T, q: T): T {
  return (p.age >= q.age) ? p : q;
}

let p: Person = elder<Person>(new Person(), new Person());
let s: Student = elder<Student>(new Student(), new Student());
class Group<T extends Person> {
  p: T;
  sortByAge(): void {
    let age: number = this.p.age;
    ...
  }
}

타입스크립트는 상한을 지정할 때 <: 대신 extends라는 키워드를 사용한다.

 

function elder<T extends { age: number; }>(p: T, q: T):T {
  return (p.age >= q.age) ? p : q;
}

구조를 드러내는 타입을 상한으로 사용할 수도 있다.

 

# 재귀적 타입 매개변수 제한

타입 매개변수가 자기 자신을 제한하는 데 사용될 수 있다. 이를 재귀적 타입 매개변수 제한(F-bounded quantification)이라 부른다. 재귀 함수가 자기 자신을 호출하는 함수인 것과 비슷하다. 재귀적 타입 매개변수 제한은 굉장히 중요하고 유용한 기능이다.

(추가 공부 필요)

 

abstract class Comparable<T> {
  Boolean gt(T that);
}

어떤 값의 타입이 Comparable<A>라는 것은 그 값이 A 타입의 값을 인자로 받는 gt 메서드를 가진다는 뜻이다.

 

Void sort<T <: compable<T>>(List<T> let) {
  ... if (Lst.get(i).gt(lst.ge(j))) { ... } ...
}

이 코드는 재귀적 타입 매개 변수 제한의 예다. T가 반드시 Comparable<T>의 서브타입이어야 한다는 뜻의 코드이다. "T가 Comparable<T>의 서브타입일 때 T 타입의 값을 T 타입의 값과 비교할 수 있다"라는 결론이 나온다.

 

abstract class Comparable<T> {
  abstract gt(that: T): boolean;
}

function sort<T extends Comparable<T>>(lst: Array<T>): void {
  if (lst[...].gt(lst.get[...])) { ... }
}

class Person extends Comparable<Person> {
  age: number;
  gt(that: Person): boolean { return this.age > that.age; }
}

let people: Array<Person> = [];
sort<Person>(people);

let의 타입이 List<T>이니 let.get(i)와 lst.get(j)의 타입은 모두 T다. T가 Comparable<T>의 서브타입이니 lst.get(i)를 Comparable<T> 타입의 부품으로 취급할 수 있다. Comparable<T> 타입의 값은 반드시 gt 메서드를 가지며 그 메서드의 매개변수 타입은 T다. 따라서 lst.get(i)는 gt 메서드를 가지며 그 메서드는 lst.get(j)를 인자로 받을 수 있다. sort는 타입 검사를 통과하는 정말로 올바른 구현인 셈이다.

 

재귀적 타입 매개변수 제한은 제네릭 클래스를 정의할 때도 가능하다.

class SortedList<T <: Comparable<T>> { ... }

3. 가변성

상속을 통해 만들어진 제네릭 타입의 서브 타입 관계에 제네릭 타입 사이의 서브타입 관계로 추가로 정의하는 기능을 '가변성(variance)'라고 한다. 또한 하나의 제네틱 타입에서 타입 인자만 다르게 하여 얻은 타입들 사이의 서브타입 관계를 만든다.

 

"어떤 제네릭 타입은 타입 인자의 서브타입 관계를 보존하지만, 어떤 제네릭 타입은 그렇지 않다". 그러므로 제네릭 타입과 타입 인자 사이의 관계를 분류할 수 있다. 이 분류를 '가변성'이라고 하며 제네릭 타입과 타입 인자 사이의 관계를 뜻한다.

 

abstract class List1<T> {
  T get(Int idx);
}

List1<Student> students = ...;
List1<Person> people = student;

Person p = people.get(...);
p.age ...

 

abstract class List2<T> {
  T get(Int idx);
  Void add(T t);
}

List2<Student> students = ...;
List2<Person> people = students;

 

# 공변(covariance)

제네릭 타입이 타입 인자의 서브타입 관계를 보존하는 것이다. List1이 여기에 해당한다. B가 A의 서브타입일 때 List<B>가 List<A>의 서브타입이다. 약간 달리 표현하면, 타입 인자가 A에서 서브타입인 B로 변할 때 List<A> 역시 서브타입인 List<B>로 변한다고 말할 수 있다. 그래서 제네릭 타입이 타입 인자와 '함께 변한다'는 뜻을 갖는다.

 

# 불변(invariance)

제네릭 타입이 타입 인자의 서브타입 관계를 무시하는 것이다.List2가 이 경우다. B가 A의 서브타입이더라도 List2<B>와 List2<A> 사이에는 아무런 관계가 없다. List2<B>와 List2<A>가 그냥 다른 타입인 것이다. 다시 말해 타입 인자가 A에서 서브타입인 B로 변할 때 List2<A>는 그냥 다른 타입인 List2<B>가 될 뿐이지, List2<A>의 서브타입으로 변하는 것은 아니다. 따라서 타입 인자가 서브타입으로 변해도 제네릭 타입은 서브타입으로 '안 변한다'는 뜻이다.

 

# 반변(contravariance)

제네릭 타입이라고 표현하지 않았지만, 함수 타입도 제네릭 타입이다. 함수 타입 T => S는 두 개의 타입 매개변수를 가진 제네릭 탕비이다. T => S 대신 Function<T, S>라고 쓸 수 있다.

"함수 타입은 매개변수 타입의 서브타입 관계를 뒤집고 결과 타입의 서브타입인 관계를 유지한다."

즉, 함수 타입과 결과 타입 사이의 관계는 공변이다. 한편 함수 타입과 매개변수 타입 사이의 관계는 반변이다.

 

결과 타입을 C로 고정할 때 B가 A의 서브타입이면 B => C는 A => C의 슈퍼타입이다. 타입 인자가 A에서 서브타입인 B로 변할 때 A => C는 타입 인자와는 반대 방향으로 움직여 슈퍼타입인 B => C로변한다고 할 수 있다. 그러므로 제네릭 타입이 타입 인자와 '반대로 변한다'는 의미를 갖는다.

 

타입 매개변수가 하나뿐인 제네릭 타입에서, 제네릭 타입의 이름은 G, 타입 매개변수의 이름은 T라고 하자. 우리가 궁금한 것은 G의 가변성을 판단하는 방법이다. 결론부터 말하자면, G가 출력에만 사용하면 '공변',입력에만 사용하면 '반변', 출력과 입력 모두에 사용하면 '불변'이다.

G가 T를 출력이나 입력에 사용한다는 말은 어떤 뜻일까? 객체가 값을 출력하려면 메세드에서 그 값을 반환해야 한다. 메서드의 결과 타입이 T라는 뜻이다. 반대로 객체가 값을 입력받으려면 그 값을 메서드의 인자로 받아야 한다. 그러니 T를 입력에 사용한다는 말은 메서드의 매개변수 타입이 T임을 의미한다. 즉, G가 T를 출력에 사용한다는 것은 G의 메서드 중 결과 타입이 T인 메서드가 있다는 말이고, 입력에 사용한다는 것은 G의  메서드 중 매개변수 타입이 T인 메서드가 있다는 말이다.

타입 검사기의 서브타입 판단 방법이 두 가지인 것처럼 가변성 판단 방법 역시 두 가지다. 하나는 개발자가 제네릭 타입을 정의할 때 가변성을 지정(declaration-site variance)하도록 한 뒤 그에 따르는 것이고, 다른 하나는 사용할 때 가변성을 지정(use-site variance)하도록 한 뒤 그에 따르는 것이다.

 

# 정의할 때 가변성 지정하기

가변성은 각 제네릭 타입의 고유한 속성이다. 따라서 제네릭 타입을 정의할 때 가변성을 지정하는 게 가장 직관적이다. 개발자는 제네릭 타입의 각 타입 매개변수에 가변성을 표시함으로써 공변, 반변, 불변 중 하나를 고를 수 있다. 아무런 표시도 붙이지 않을 경우 기본으로 불변이 선택된다.

 

정의할 때 가변성을 지정하는 방법은 개발자를 양자택일의 기로에 내몬다. 서브타입 관계를 추가하는 대신 기능이 빠진 타입을 만들거나, 기능을 다 갖춘 타입을 만드는 대신 서브타입 관계를 포기하거나.

 

그나마 함수형 언어에서는 이 단점이 상대적으로 덜 드러난다. 함수형 프로그래밍에서는 대부분의 경우 수정할 수 없는(immutable) 자료 구조만 사용해 프로그램을 작성하기 때문이다. 하지만 수정할 수 없는 자료 구조라 하더라도 입력에 타입 매개변수를 사용하는 경우가 있다. append 메서드가 그 예다.

abstract class ReadOnlyList<T> {
  ...
  ReadOnlyList<T> append(T t);
}

append 메서드는 리스트를 그대로 둔 채 주어진 원소를 원래 리스트의 맨 끝에 추가하면 만들어지는 리스트를 새롭게 만들어 반환한다. 이런 메서드는 함수형 프로그래밍에서도 필요하다. 문제는 append가 T를 입력에 사용한다는 점이다. 따라서 append를 제공하려면 ReadOnlyList가 불변이 되어야 한다., 이처럼 정의할 때 가변성을 지정하는 방법은 함수형 언어에서조차 문제를 일으킨다.

 

다만 해결 방법이 아예 없는 것은 아니다. 타입 매개변수의 하한을 지정함으로써 ReadOnlyList를 공변으로 유지하면서 append 메서드를 제공할 수 있다.

abstract class ReadOnlyList<out T> {
  ...
  ReadOnlyList<S> append<S >: T>(S s);
}

이처럼 제네릭 타입을 정의할 때 가변성을 지정하면 직관적이긴 해도 불편함이 생긴다. 그래서 가변성을 지정하는 시점을 제네릭 타입을 사용할 때로 미룬다.

 

class Person {}
class Student { grade: number; }
class ReadOnly<T> {
  get: (idx: number) => T = ...;
}

class Map<T, S> {
  get: (t: T) => S = ...;
  add: (t: T, s: S) => Void = ...;
}

let l: ReadOnly<Person> = new ReadOnlyList<Student>();
let m: Map<Student, number> = new Map<Person, number>();

구조에 의한 서브타입을 사용하므로 가변성을 명시적으로 지정할 필요가 없다. 타입 검사기가 매번 타입에 정의된 필드와 메서드를 고려해 서브타입 여부를 결정함으로써 자연스럽게 가변성을 알아낸다.

 

# 사용할 때 가변성 지정하기

제네릭 타입을 사용할 때 가변성을 지정하는 경우, 제네릭 타입을 정의할 때 가변성을 지정할 수 없다.

댓글