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

매개변수에 의한 다형성 - 제네릭 함수 / 제네릭 타입

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

최종 수정 : 2024-06-08

매개변수에 의한 다형성

1. 제네릭 함수

매개변수에 의한 다형성(parametric ploymorphism)은 타입 매개변수를 통해 다형성을 만드는 기능으로, 제니릭스(generics)라고도 부른다.

 

매개변수에 의한 다형성을 언어에 추가하는 것은 언어에 큰 변화를 가져오기에 상당히 어려운 일이다. 그럼에도 매개변수에 의한 다형성을 어떻게든 추가하려고 노력하는 것이다. 이것만 봐도 정적 타입 언어에 얼마나 필요한지 짐작할 수 있다.

 

Q. 어떤 큰 변화를 가져오는가??

 

Any choose(Any v1, Any v2) {
  print(v1); print(v2); print(...);
  Int input = readInt();
  return (input == 0) ? v1 : v2;
}

모든 문제를 해결한 것일까? 아쉽게도 아니다. 결과 타입도 Any로 바뀌었기 때문에 choose 함수는 인자로 들어온 값이 무슨 타입인지 기억하지 못한다.

 

정녕 좋은 해결 방법은 없는 걸까? 매개변수에 의한 다형성이 없다면 아쉽게도 그렇다. 하지만 매개변수에 의한 다형성이 있다면 이야기가 다르다. 지금까지는 타입을 인자로 받는다는 개념이 아예 존재하지 않았다. 이 불가능한 일을 가능하게 만드는 기능이 바라 매개변수에 의한 다형성이다.

T choose(T v1, T v2) {
  print(v1); print(v2); print(...);
  Int input = readInt();
  return (input == 0) ? v1 : v2;
}

타입 매개변수 T는 아무 타입이 될 수 있다. 또, 함수 몸통에서 T가 나타내는 타입을 사룡하려면 T라고 쓰면 된다. 다만 매개변수는 몸통에서만 사용되지만 타입 매개변수는 더 많은 곳에서 사용된다. 몸통뿐 아니라 매개변수 타입 표시와 결과 타입 표시에도 사용될 수 있다.

 

한 개 이상의 타입 매개변수를 가지는 함수를 제니릭 함수(generic function)라고 부른다. 매개변수 개수만큼 함수에 인자를 넘기면 되는 것처럼, 타입 매개변수 개수만큼 타입 인자를 넘기면 된다.

 

제네릭 함수를 정의할 때는 타입 검사기가 제네릭 함수 정의를 검사하는 방식을 이해하는 게 좋다. 대부분의 언어에서는 제네릭 함수 정의를 검사할 때 타입 매개변수가 아무 타입이나 나타낼 수 있다고 가정한다.

T mul<T>(T v1, T v2, T v3) {
  return v1 * v2 * v3;
}

이 코드는 말이 되지 않는다. 제네릭은 모든 타입이 올 수 있지만 주어진 코드는 숫자타입에서만 유효하기 때문이다.

 

function choose<T>(v1: T, v2: T): T { return ... ? v1 : v2; }
let str: string = choose<string>("Korean", "Foreigner");
let num: number = choose<number>(1, 2);

 

# 제네릭 메서드

제네릭 메서드는 클래스 안에 정의된다는 점만 제외하면 제네릭 함수와 똑같다.

class Chooser {
  choose<T>(v1: T, v2: T):T { return ... ? v1 : v2 }
}
let c: Chooser = new Chooser();
c.chooser<number>(1, 2);

 

# 타입 인자 추론

매개변수에 의한 다형성은 코드 중복을 크게 줄여 주는 대신 불편한 점이 있다. 꼬박꼬박 타입 인자를 써 줘야 한다는 점이다. 이런 불편함을 해소하기 위해 매개변수에 의한 다형성을 제공하는 대부분의 언어는 타입인자 추론을 함께 제공한다. 타입 추론의 일종으로, 제네릭 함수나 제네릭 메서드를 호출할 때 개발자가 타입 인자를 생략할 수 있도록 하는 기능이다.

 

타입 인자 추론은 많은 경우 타입 검사기에 쉬운 일이다. 하지만 코드가 복잡해지다 보면 타입 검사기가 타입 인자 추론에 실패할 수 있다. 또는 타입 인자 추론에는 성공했지만 내가 예상했던 것과 다른 타입이 타입 인자로 사용되어 프로그램의 다른 부분에서 타입 검사를 실패하게 만들 수 있다. 그러니 타입 인자 추론이 언제나 내가 원하는 대로 되지는 않는다는 사실을 항상 기억해야 한다. 이럴 떄는 생략한 타입 읹나를 하나씩 다시 넣어 보는 것이 도움이 될 수 있다.

function choose<T>(v1: T, v2: T): T { return ... ? v1: v2; }
let num: number = choose(1, 2);

 

# 힌들리-밀너 타입 추론

제네릭 함수를 정의할 때도 타입 추론을 하는 방식 중 하나이다. 다른 방식은 힌들리-밀너 타입 추론이 다루지 못하는 더 어려운 타입도 다룰 수 있다. 그럼에도 이 책에서는 제네릭 함수를 정의할 때 타입 추론을 하는 것을 그냥 힌들리-밀너 타입 추론이라 부른다. 어차피 나머지 방식도 힌들리-밀너 타입 추론의 확장이라 볼 수 있기 때문이다.

 

일부 언어에서는 여기서 더 나아가 제네릭 함수를 정의할 때조차 타입 추론을 한다. 이를 힌들리-밀너 타입 추론이라 부른다. 타입 추론 알고리즘을 전산학자 힌들러(Roger Hindley)와 밀너(Robin Milner)가 독립적으로 발명했기에 이런 이름이 붙었다. 어떤 사람들은 힌들리-밀너 타입 추론 대신 렛 다형성(let polymorphism)이라는 용어를 사용하는데, 사용하는 대표적인 언어인 오캐멀에서 함수를 정의할 때 사용하는 키워드가 let인 데서 온 것이다.

 

제네릭 함수를 정의할 떄 타입 추론을 한다는 것이 무슨 뜻일까? 이는 타입 매개변수를 쓰지 않아도 함수가 자동으로 제네릭 함수가 될 수 있다는 말이다. 매개변수 타입과 결과 타입까지도 추론하기에 다음과 같이 함수를 정의할 수 있다.

function choose(v1, v2) {
  print(v1); print(v2); print(...);
  Int input = readInt();
  return (input == 0) ? v1 : v2;
}

그러면 타입 검사기가 알아서 위 코드를 다음처럼 바꾼다.

T choose<T>(T v1, T v2) { ... }

 

힌들리-밀너 타입 추론은 굉장히 똑똑해서 제네릭 함수로 만들어야 할 때와 그렇지 않을 때를 정확히 구분한다.

function mult(v1, v2, v3) {
  return v1 * v2 * v3;
}

타입 검사기는 mult를 제네릭 함수로 만들지 않는다.

 

Int mult(Int v1, Int v2, Int v3) { ... }

이런 영리한 타입 추론이 어떻게 가능한지 자세히 몰라도 된다. 그저 타입 추론이 주는 혜택을 누리면 그만이다. 다만 타입 추론의 기본적인 원리를 이해하는 것은 매개변수에 의한 다형성을 이해하는 데도 도움이 된다.

 

힌들리-밀너 타입 추론의 강력함은 개발자에게 양날의 검이다. 함수를 정의할 때도 타입 추론을 해서 자동으로 제네릭 함수를 만들어 주는 능력은 꽤나 편리하다. 고민할 필요가 없기 때문이다. 기능에 집중한 채 몸통만 열심히 작성하면 된다. 하지만 어떤 함수가 자동으로 제네릭 함수가 되었다는 사실을 개발자가 눈치채지 못한다면 문제가 생긴다. 특히 타입 검사기가 내뱉는 오류 메시지를 이해하기 매우 어려워진다. 따라서 힌들리-밀너 타입 추론을 제공하는 언어를 사용할 때는 우선 매개변수 타입 표시 없이 코드를 작성하다가 이해하기 어려운 오류 메시지가 나오면 함수에 매개변수 타입 표시를 추가해 보는 게 좋다.

(ps. TypeScript는 힌들리-밀너 타입 추론을 지원하지 않는다!)


2. 제네릭 타입

타입 매개변수를 추가할 수 있는 곳은 함수분이 아니다. 타입에 타입 매개변수를 추가하면 제네릭 타입(generic type)이 된다. 기본적인 타입조차도 제네릭 타입으로 표현될 정도로 제네릭 타입은 유용하다.

 

예를 들어, 동적 타입 언어에서야 리스트(배열)를 그냥 사용하면 그만이지만 정적 타입 언어에서는 리스트의 타입을 알아야 한다. 리스트 말고 흔히 볼 수 있는 제네릭 타입으로는 맵(map)이 있다. 맵은 사전(dictionary)이라고도 하는 자료 구조로, 키 값들과 값들로 이루어진다. 맵의 가장 중요한 기능은 주어니 키에 대응되는 값을 알려 주는 것이다. 이 밖에도 여러 타입이 제네릭 타입으로 표현된다. 자료 구조의 타입이 대개 제네릭 타입이다.

let l: Array<number> = [1, 2,];
let m: Map<number, string> = new Map([[1, "one"], [2, "two"]]);

 

# 제네릭 클래스

자신만의 제네릭 타입을 직접 정의하고 싶을 때 제네릭 클래스(generic class)라는 기능을 사용하면 된다. 제네릭 클래스는 타입 매개변수를 가진 클래스다. 정의할 때는 제네릭 함수와 비슷하게 타입 매개변수를 명시해야 하고, 사용할 때는 제네릭 타입으로서 리스트(배열)나 맵과 비슷한 방식으로 사용된다.

class Chooser<T> {
  v1: T;
  v2: T;
  choose(): T { return ... ? this.v1 : this.v2; }
}
let c: Chooser<number> = new Chooser<number>();
let n: number = c.choose();

댓글