타입으로 견고하게 다형성으로 유연하게 ‐ 매개변수에 의한 다형성 - thought-corner/backend-roadmap GitHub Wiki

타입으로 견고하게 다형성으로 유연하게 - 매개변수에 의한 다형성

  • 타입 자체를 인자로 받는다.
  • 그리고 그 타입에 대해 아무것도 모르기 때문에, 오히려 모든 타입에 대해 옳을 수 있다.
  • 제네릭은 없던 타입 정보를 만들어주는 장치가 아니라, 타입 검사 시점을 런타임에서 컴파일 타임으로 되돌리는 장치다.
ObjectStack stack = new ObjectStack();
stack.push("문자열");
stack.push(42);                       // 컴파일 통과한다
String value = (String) stack.pop();  // ClassCastException
  • 매개변수가 없으면 모든 것을 최대 타입(Object)으로 올려버리는 수밖에 없다.
  • 그러면 넣을 때 아무거나 들어가고, 꺼낼 때 캐스팅 책임이 호출자에게 넘어간다.
  • 그 캐스팅이 맞는지는 컴파일러가 검증해줄 수 없다. 정보가 이미 지워졌기 때문이다.

두 종류의 다형성이 있다

무엇이 달라지나 자바에서
서브타입 다형성 이 여러 타입일 수 있다 상속, 인터페이스, 오버라이딩
매개변수 다형성 타입이 인자다 제네릭
  • 서브타입 다형성은 "이 자리에 올 수 있는 것이 여럿"이라는 이야기고, 매개변수 다형성은 "이 코드가 타입 하나를 비워두고 쓰였다"는 이야기다.
  • 서브타입 쪽은 공통 상위 타입으로 좁혀서 다루고, 매개변수 쪽은 타입을 비워둔 채로 다룬다.
  • 좁히면 정보가 사라지고, 비워두면 정보가 호출 지점까지 그대로 살아 있다.

모르는 것이 힘이다 — 파라메트리시티

static <T> T identity(T value) { return value; }
  • T에 대해 아는 것이 하나도 없으니 새 T를 만들 수도, 변형할 수도, 검사할 수도 없다.
  • 인자를 그대로 돌려주는 것 말고는 물리적으로 할 수 있는 일이 없다.
  • 타입 시그니처만으로 구현이 거의 결정되는 이 성질을 파라메트리시티라고 부른다.
  • 시그니처에 T만 적혀 있다면, 그 함수가 T를 가지고 이상한 짓을 할 수 없다는 것이 타입만 보고 보장된다. 구현을 안 읽어도 되는 범위가 시그니처에 적혀 있는 셈이다.

시그니처는 관계만 적는다

static <T, R> List<R> map(List<T> source, Function<? super T, ? extends R> mapper)
  • 입력 원소 타입과 함수의 입력 타입이 같고, 함수의 출력 타입과 결과 원소 타입이 같다.
Lists.map(List.of("a", "bb"), String::length);   // T = String,  R = Integer
Lists.map(List.of(1, 2), n -> "#" + n);          // T = Integer, R = String
해본 것 나온 에러
List<String> r = Lists.map(List.of(1, 2), n -> n * 2); inference variable R has incompatible bounds
Lists.reduce(List.of(1, 2), "", (acc, n) -> n); cannot infer type-variable(s) T,R,E
  • incompatible bounds라는 표현이 그 증거다. 컴파일러는 R의 값을 모른 채, R에 걸린 제약들이 동시에 만족 가능한지만 본다.

두 축이 갈리는 지점 — 타입 파라미터의 수명

언제 정해지나 언제 끝나나
제네릭 함수 호출 지점마다 그 호출이 끝나면
제네릭 타입 타입을 적는 순간 그 값이 사는 내내 따라다닌다
Stack<String> stack = new Stack<>();   // 여기서 E = String이 확정되고
stack.push(42);                        // 그 값이 사는 내내 따라다닌다
// error: incompatible types: int cannot be converted to String
  • 제네릭 함수는 다음 줄에서 완전히 다른 타입으로 다시 쓸 수 있다.
  • 제네릭 타입은 그럴 수 없다. 그게 제약이 아니라 그 값에 대한 약속을 붙들어두는 방식이다.

타입이 코드를 따라 흐른다

  • Stack이 "타입 하나를 받는 틀"이라면, Pair는 타입이 이동하는 것을 보여준다.
public Pair<B, A> swap()
  • 값만 자리를 바꾸는 게 아니라 타입도 함께 바꾼다.
  • 그리고 그 사실이 시그니처에 적혀 있어서, 호출한 쪽에서 컴파일러가 이어서 검증한다.
Pair<String, Integer> p = Pair.of("a", 1).swap();
// error: Pair<Integer,String> cannot be converted to Pair<String,Integer>

소거

  • 타입 인자는 런타임에 지워진다.
  • 그래서 컴파일 타임에만 존재하는 것을 런타임에 쓰려고 하면 전부 막힌다.
해본 것 나온 에러
E[] array = new E[10]; generic array creation
o instanceof Stack<String> Object cannot be safely cast to Stack<String>
Stack<String> strings = new Stack<>();
Stack<Integer> integers = new Stack<>();
assertSame(strings.getClass(), integers.getClass());   // 통과한다

소거를 안고 가는 방법

  • new E[...]가 안 되니 Object[]를 만들어 캐스팅한다.
@SuppressWarnings("unchecked")
public Stack() {
    this.elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
  • elementsprivate이다.
  • 배열 자체가 외부로 새어나가지 않는다.
  • 값이 들어오는 경로는 push 하나뿐이고, 거기 들어오는 것은 항상 E다.
  • E가 아닌 것이 이 배열에 들어갈 경로가 없다. 안전을 컴파일러가 아니라 사람이 보장하는 자리이므로, 그 전제를 글로 남겨야 나중에 깨졌는지 확인할 수 있다.

무공변과 PECS

List<Number> nums = List.of(1, 2, 3);
// error: incompatible types: List<Integer> cannot be converted to List<Number>
  • IntegerNumber의 서브타입인데 List<Integer>List<Number>의 서브타입이 아니다. 제네릭은 기본이 무공변이다.
public void pushAll(Iterable<? extends E> source)      // 생산자 - extends
public void popAll(Collection<? super E> destination)  // 소비자 - super
  • Iterable<E>라고 썼다면 Stack<Number>List<Integer>를 넣지 못한다.
⚠️ **GitHub.com Fallback** ⚠️