코틀린의 제네릭스는 자바와 비슷한 점이 많다. 자바와 다른 부분도 많으니 이 점을 살펴보자!
제네릭 타입 파라미터
코틀린의 제네릭 타입 파라미터는 자바와 매우 비슷하지만 몇 가지 새로운 기능에 대해 배워보자.
타입 파라미터 제약
코틀린은 타입 파라미터 제한으로 클래스나 함수에 사용할 수 있는 타입 인자를 제한할 수 있다.
1 | fun <T: Number> List<T>.sum(): T |
이렇게 함수를 선언하면 숫자를 원소로 갖는 리스트 객체만 sum
함수를 호출할 수 있게 된다!! 와우~
타입 파라미터 제약을 사용한 예시를 살펴보자
1 | fun <T: Person> List<T>.hello() { |
타입 파라미터 제한에 따라 함수도 제한한 예시이다.
여기서 더 실전적인 예시를 보자.
1 | fun <T : Comparable<T>> max(first: T, second: T) : T { |
Comparable을 구현한 타입, 즉 원소 간 비교가 가능한 타입 파라미터만 사용할 수 있는 함수를 구현했다.
타입 파라미터에 여러 제약 걸기
1 | fun <T> ensureTrailingPeriod(seq: T) |
함수 시그니처 뒷 쪽에 where 절로 타입 파라미터가 여러 조건을 만족하도록 제한을 걸 수 있다. 위 예시에서는 T가 CharSequence와 Appendable의 하위 타입이어야 가능함을 의미한다.
타입 파라미터를 널이 될 수 없는 타입으로 한정
타입 파라미터에 아무런 제약 없이 사용하면 Any?
를 상한으로 하는 것과 마찬가지다. 만약 널을 허용하지 않는 타입으로 제한하고 싶으면 <T: Any>
로 상한을 걸어주자.
터
실행 시 제네릭스의 동작: 소거된 타입 파라미터와 실체화된 타입 파라미터
JVM의 제네릭스는 보통 타입 소거를 사용해 구현된다. 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 뜻이다.
실행 시점의 제네릭: 타입 검사와 캐스트
코틀린도 자바와 마찬가지로 제네틱 타입 인자 정보는 런타임에 지워진다. 타입 소거는 저장해야 하는 타입 정보의 크기가 줄어들어서 메모리 사용량이 줄어드는 장점은 있다.
하지만 타입 소거 때문에 실행 시점에 타입 인자를 검사할 수 없다. 예를 들면 다음과 같은 코드는 컴파일 에러가 발생한다.
1 | if (value is List<String>) { ... } |
왜냐면 실행 시점에서 value가 List인지 아닌지는 명확히 판별할 수 있지만, List<String>인지, List<Long>인지는 확인할 수 없기 때문이다.
하지만 방법은 있다. 스타 프로젝션을 사용하면 된다.
1 | if (value is List<*>) { ... } |
인자를 알 수 없는 제네릭 타입을 표현할 때 스타 프로젝션을 사용한다.(자바의 와일드카드와 비슷)
스타 프로젝션을 사용하면 as나 as? 캐스팅도 가능하다. 문제는 타입 파라미터를 정확히 모르니 컴파일은 가능해도 런타임에 캐스팅 에러가 발생할 수 있다.
실체화한 타입 파라미터를 사용한 함수 선언
타입 소거에 의해 런타임에는 타입 인자 정보를 알 수 없다. 하지만 코틀린 인라인 함수의 타입 인자는 알 수 있다!!!
1 | inline fun <reified T> isA(value: Any) = value is T |
인라인 함수의 타입 파라미터 앞에 reified로 지정하면 실행 시점에서 타입 파라미터를 사용할 수 있다. 이때 파라미터에 람다를 받지 않는데도 타입 파라미터를 실체화 하기 위해서 inline 했읆을 주목하자. 즉 성능을 위해 inline 한 게 아니라 타입 파라미터 사용을 위해서 inline 선언한 것이다. 이때 해당 함수의 길이에 따라 성능 문제가 발생할 수 있다. 그럴 경우 반드시 필요한 부분을 분리해서 성능을 최적화 할 수 있다.
왜 inline은 타입 파라미터를 실체화 할 수 있나?
컴파일러가 인라인 함수의 본문을 바이트코드로 호출한 곳에 집어 넣을 때, 호출 할 때 제네릭 타입에 전달된 클래스를 알 수 있어서 타입 파라미터 자리에 정확한 클래스를 넣을 수 있다.
위 예시의 경우 다음과 같이 인라이닝된다.
1 | println("hello" is String) |
자바에서는 실체화한 타입 파라미터를 가진 inline 함수를 호출할 수 없다.
자바에서 인라인 함수를 호출할 수 있다. 이 경우 일반 함수처럼 사용되서 인라이닝 되지 않는다. 실체화 된 타입 파라미터가 있는 inline 함수의 경우 반드시 인라이닝이 되어야 하기 때문에 자바에서는 해당 함수를 호출하지 못하도록 했다.
실체화한 타입 파라미터의 제약
실체화한 타입 파라미터는 몇가지 제약이 있다. 실체화한 타입 파라미터 개념 자체에서 오는 제약과 코틀린에서 실체화한 타입 파라미터를 구현하는 과정에서 오는 제약이 있다. 따라서 구현이 달라지면 나중에 완화될 제약이 있다.
가능한 것
- 타임 검사와 캐스틍 (is, as 등)
- 코틀린 리플렉션 API (::class 등)
- 코틀린 타입에 대응하는 java.lang.Class 얻기 (::class.java)
- 다른 함수를 호출할 때 타입 인자로 사용
불가능한 것
- 타입 파라미터 클래스의 인스턴스 생성
- 타입 파라미터 클래스의 동반 객체 메서드 호출
- 실체화된 타입 파라미터 자리에 실체화 되지 않은 타입 파라미터로 받은 타입을 넘기기
- 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기
변성: 제네릭과 하위 타입
변성 개념은 List<Any>
와 List<String>
과 같이 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념이다.
변성이 있는 이유: 인자를 함수에 넘기기
List<Any>
를 인자로 받는 함수에 List<String>
객체를 전달해도 괜찮을까?
보통 Any
가 더 상위 객체이니 별 문제 없을 것 같다. 하지만 경우에 따라 컴파일 에러가 발생한다. (책에서는 경우에 따라 런타임에서 문제가 될 수 있다고 했지만 실제로는 아예 컴파일이 되지 않는다.)
문제가 되는 경우: 원소 추가, 변경하는 경우
아래 코드를 보면 리스트의 원소를 추가, 변경 가능한 MutableList를 매개변수로 갖는 함수에게 타입 인자가 하위 계층인 리스트를 전달하면 컴파일 자체가 되지 않는다.
그렇지 않은 함수는 별 문제 없이 잘된다.
1 | fun addContent(contents: MutableList<Any>) { |
클래스, 타입, 하위 타입
클래스와 티입의 구분
타입과 클래스는 다른 개념이다. 예를 들어 String이라는 클래스는 코틀린에서 String
과 String?
두가지 타입을 만들어낸다. 즉 하나의 클래스에서 두가지의 타입이 나온다.
그렇다면 List는 어떨까? List는 하나의 클래스이다. 다만 타입 매개변수에 따라 수많은 타입이 가능하다. (List<String>, List<Int> 등…) 이제 타입과 클래스가 구분된다는 의미가 와닿는다.
하위 타입, 상위 타입
타입 A 자리에 타입 B가 와도 문제가 없을 때 B가 A의 하입 타입이다 라고 말한다. 반면 A는 B의 상위 타입이라고 한다. 컴파일러는 변수에 값을 대입할 때 변수의 타입이 값의 타입보다 상위 타입인지 확인한다.
간단한 경우 하위 타입은 하위 클래스와 근본적으로 같다. 하지만 널 가능성과 제네릭 클래스의 경우 하위 타입과 하위 클래스의 차이가 두드러진다.
Int는 Int?의 하위 타입이라고 볼 수 있다. 하지만 두 타입 모두 같은 클래스를 기반으로 한 타입이다.
제네릭 클래스의 경우 List<Any>는 List<String>의 상위 타입이라고 볼 수 있다. 하지만 MutableList<Any>는 MutableList<String>의 상위 타입이라고 볼 수 없다. (이유는 문제가 되는 이유라는 소제목을 보자.)
즉 타입 인자가 서로 다른 타입이 들어갔을 경우 두 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않는 상황을 무공변이라고 한다! (참고로 자바에서는 모든 클래스가 무공변이라 한다.)
지
공변성: 하위 타입 관계를 유지
같은 제네릭 클래스 기반의 서로 다른 타입 인자를 가진 두 인스턴스의 하위 타입 관계가 유지되면 공변이라고 얘기한다.
코틀린에서 공변한 클래스를 선언하는 방법을 소개한다.
1 | interface Producer<out T> { |
out 키워드를 타입 파라미터 앞에 붙여주자. 공변인 타입 파라미터는 함수 정의에 사용된 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않더라도 그 클래스의 인스턴스를 함수 인자나 반환 값으로 사용할 수 있다. 즉 하위 관계인 타입 인자가 함수에 사용되어도 된다는 의미다.
아웃, 인, 생산, 소비
모든 클래스를 공변으로 만들면 안된다. 타입 안정성을 보장하기 위해 공변적 파라미터는 항상 아웃 위치에 있어야 한다.이는 클래스가 타입의 값을 생산(반환)할 수는 있지만, 타입 값을 소비(파라미터 타입 선언)할 수 없다는 의미다.
1 | interface Transformer<T> { |
정리하자면 공변성은 타입 인자를 아웃(생산, 반환) 위치에만 사용하는 클래스만 가능하다.
공변의 규칙에서 자유로운 생성자 & private 메서드
공변은 인스턴스가 생성되고 나서 하위 타입 관계를 유지할 수 있도록 하는 규칙이므로 생성자는 관련없다. 그리고 인, 아웃 규칙은 외부에서 볼 수 있는 클래스 API 관점에서 정의이다. private 메서드는 인, 아웃 둘 다 해당하지 않는다.
반공변성: 뒤집힌 하위 타입 관계
반공변성은 공변성의 반대다. 즉 타입 A가 타입 B의 하위 타입인데, Consumer<B>가 Consumer<A>의 하위 타입이 될 때 반공변성이라고 한다. 인 아웃 규칙도 반대다. 인 위치에서만 타입이 사용되어야 반공변성이 성립한다.
1 | interface Consumer<in T> { |
사용 지점 변성: 타입이 언급되는 지점에서 변성 지정
클래스에서 in과 out으로 타입 매개변수에 변성을 지정해주지 않고 타입이 언급되는 곳마다 변성을 적용하는 방법이 사용 지점 변성이다. 자바에서 와일드카드 기능을 통해 ? extends String
이런 식으로 사용 지점 변성을 사용한다. (물론 기존의 선언 지점 변성이 코드 중복을 줄여주는 장점이 있어 더 간결하다.)
1 | fun <T, R> copyData(source: MutableList<T>, destination: MutableList<T>) { |
무공변인 MutableList를 인자로 받아 인과 아웃에서 모두 사용하고 있다.
1 | fun <T, R> copyData(source: MutableList<out T>, destination: MutableList<in T>) { |
in, out 키워드를 통해 적절한 변성을 적용했다. 이때 in, out 키워드를 붙인 타입에서는 타입 프로젝션이 일어난다. 일반적인 MutableList가 아닌 변성이 적용된 MutableList 타입으로 만들어서, 해당 변성에 맞는 메서드만 호출할 수 있게 된다.
스타 프로젝션: 타입 인자 대신 * 사용
스타 프로젝션은 어떤 구체적인 타입이 타입 인자로 정해졌는데, 정확히 어떤 타입인지 추론할 수 없을 때 사용한다. 일반적으로 타입 인자가 중요하지 않은 경우 주로 사용한다. MutableList<*>
를 통해 스타 프로젝션에 대해 이해해보자.
Any?와 차이
MutableList<*>
와 MutableList<Any?>
는 의미가 다르다. 후자는 어떤 객체든 담을 수 있는 리스트이다. 전자는 구체적인 타입 인자가 정해졌는데 추론할 수 없는 상황이므로 아무 객체나 담으면 안된다. 반면 전자에서 값을 꺼낼 때는 타입을 정확히 추론할 수는 없지만 Any?의 하위타입일 것은 명확하므로 반환하는 타입은 Any?가 된다. 따라서 MutableList<*>
는 아웃 프로젝션 타입이 된다. 즉 생성(반환)만 되고 소비(인자로 사용)은 안되는 타입이다!