[Kotlin]9장. 제네릭스

sonnie
lucky-sonnie
Published in
10 min readFeb 17, 2021

9.1 제네릭 타입 파라미터

val readers: MutableList<String> = mutableListOf()
val readers = mutableListOf<String>()

위의 선언은 동등하다.

9.1.1 제네릭 함수와 프로퍼티

예를 들어 slice 함수 정의를 살펴보자.

함수의 타입 파라미터 T가 수신 객체와 반환 타입에 쓰인다. 수신 객체와 반환 타입 모두 List<T>다. 이런 함수를 구체적인 리스트에 대해 호출할 때 타입 인자를 명시적으로 지정할 수 있다. 하지만 실제로는 대부분 컴파일러가 타입 인자를 추론할 수 있으므로 그럴 필요가 업다.

val letters = ('a'..'z').toList()
letters.slice<Char>(0..2) >> [a, b, c]
letters.slice<Char>(10..13) >> [k, l, m, n ] // 컴파일러는 T가 Char라는 사실을 추론한다.

제네릭 고차함수를 호출할 수도 있다.

fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T>
>>> readers.filter{ it !in authors}

제네릭 확장 프로퍼티를 선언할 수 있다.

val <T> List<T>.penultimate: T
get() = this[size - 2] // 모든 리스트 타입에 이 제네릭 확장 프로퍼티를 사용할 수 있다.
listOf(1,2,3,4).penultimate // 타입 파라미터 T는 int로 추론된다.

확장 프로퍼티만 제네릭하게 만들 수 있다.

9.1.2 제네릭 클래스 선언

class ArrayList<T> : List<T>{
override fun get(index: Int): T = ...
}

클래스가 자기 자신을 타입 인자로 참조할 수도 있다. Comparable 인터페이스를 구현하는 클래스가 이런 패턴의 예다. 비교 가능한 모든 값은 자신을 같은 타입의 다른 값과 비교하는 방법을 제공해야만 한다.

interface Comparable<T>{
fun compareTo(other: T): Int
}

9.1.3 타입 파라미터 제약

타입 파라미터 제약은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다. 어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상한으로 지정하면 그 제네릭 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나 그 상한 타입의 하위 타입이어야 한다. 제약을 가하려면 타입 파라미터 이름 뒤에 콜론(:)을 표시하고 그 뒤에 상한 타입을 적으면 된다.

fun <T: Number> List<T>.sum(): T

(자바에서는 <T extends Number> T sum(List<T> list) 처럼 extends를 써서 같은 개념을 표현한다. )
타입 파라미터 T에 대한 상한을 정하고 나면 T 타입의 값을 그 상한 타입의 값으로 취급할 수 있다.

fun <T : Number> oneHalf(value: T): Double{
return value.toDouble() / 2.0 // Number 클래스에 정의된 메소드를 호출한다.
}

9.1.4 타입 파라미터를 널이 될 수 없는 타입으로 한정

아무런 상한을 정하지 않은 타입 파라미터는 결과적으로 Any?를 상한으로 정한 파라미와 같다.

class Processor<T>{
fun process(value: T){
value?.hashCode() // value는 널이 될 수 있다.
}
}
class Processor<T: Any>{
fun process(value: T){
value.hashCode() // value는 널이 될 수 없다.
}
}

컴파일러는 타입 인자인 String?가 Any의 자손 타입이 아니므로 Processor<String?> 같은 코드를 거부한다. (String?는 Any?의 자손 타입이며, Any?는 Any보다 덜 구체적인 타입이다. )

9.2 실행 시 제네릭스의 동작: 소거된 타입 파라미터와 실체화된 타입 파라미터

JVM의 제네릭스는 실행 시점에 제네릭 클래스의 인스턴스에 인자 정보가 들어있지 않다. 하지만 함수를 inline으로 만들면 타입 인자가 지워지지 않게 할 수 있다.

9.2.1 실행 시점의 제네릭: 타입 검사와 캐스트

val list1: List<String> = listOf("a","b")
val list2: List<Int> = listOf(1,2,3)

컴파일러는 두 리스트를 서로 다른 타입으로 인식하지만 실행 시점에 그 둘은 완전히 같은 타입의 객체다(List). 타입 소거로 인해 생기는 한계는 타입 인자를 따로 저장하지 않기 때문에 실행 시점에 타입 인자를 검사할 수 없다.

코틀린에서 스타 프로젝션 사용해 어떤 값이 집합이나 맵이 아니라 리스트라는 사실을 확인할 수 있다.

if(value is List<*>){ ... }

타입 파라미터가 2개 이상이라면 모든 타입 파라미터에 *를 포함시켜야 한다. 위의 코드에서 value가 List 임을 알 수는 있지만 그 원소 타입은 알 수 없다.

as나 as? 캐스팅에도 여전히 제네릭 타입을 사용할 수 있다. 하지만 기저 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공한다. ( List<String>과 List<Int>인 경우 )

코틀린 컴파일러는 컴파일 시점에 타입 정보가 주어진 경우 is 검사를 수행할 수 있다.

fun printSum(c: Collection<Int>){
if(c is List<Int>){ println(c.sum()) }

9.2.2 실체화한 타입 파라미터를 사용한 함수 선언

인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.

inline fun <reified T> isA(value: Any) = value is T
>>> println(isA<String>("abc"))
true
>>> printlnO(isA<String>(123))
false

위의 함수는 타입 파라미터를 reified로 지정하여 value의 타입이 T의 인스턴스인지 실행시점에 검사할 수 있다.

인라인 함수에서만 실제화한 타입 인자를 쓸 수 있는 이유는 컴파일러가 실체화한 타입 인자를 사용해 인라인 함수를 호출하는 각 부분의 정확한 타입 인자를 알 수 있기 때문이다. 따라서 컴파일러는 타입 인자로 쓰인 구체적인 클래스를 참조하는 바이트코드를 생성해 삽입할 수 있다.

9.2.3 실체화한 타입 파라미터로 클래스 참조 대신

val serviceImpl = ServiceLoader.load(Service::class.java) // 자바 코드
val serviceImpl = loadService<Service>() // 코틀린 코드

9.2.4 실체화한 타입 파라미터의 제약

실체화한 타입 파라미터를 사용하는 경우

  • 타입 검사와 캐스팅(is, !is, as, as?)
  • 10장에서 설명할 코틀린 리플렉션 API (::class)
  • 코틀린 타입에 대응하는 java.lang.Class를 얻기(::class.java)
  • 다른 함수를 호출할 때 타입 인자로 사용

단점

  • 타입 파라미터 클래스의 인스턴스 생성하기
  • 타입 파라미터 클래스의 동반 객체 메소드 호출하기
  • 실체화한 타입 파라미터를 요구하는 함수를 호출하면서 실체화하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기
  • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기

9.3 변성: 제네릭과 하위 타입

변성 개념은 List<String>List<Any> 와 같이 기저 타입이 갇고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념이다.

9.3.1 변성이 있는 이유: 인자를 함수에 넘기기

String 클래스는 Any를 확장하므로 Any 타입 값을 파라미터로 받는 함수에 String 값을 넘겨도 안전하다.

9.3.2 클래스, 타입, 하위 타입

타입과 클래스를 혼용해 쓰지만 실제로 그 둘은 다르다.

제네릭 클래스가 아닌 클래스에서는 클래스 이름을 바로 타입으로 쓸 수 있다. 예를 들어 var x: String 이라도 쓰면 String 클래스의 인스턴스를 저장하는 변수를 정의할 수 있다. 하지만 var x: String? 처럼 같은 클래스 이름을 널이 될 수 있는 타입에도 쓸 수 있다. 모든 코틀린 클래스가 적어도 둘 이상의 타입을 구성할 수 있다는 뜻이다.

제네릭 타입을 인스턴스화할 때 타입 인자로 서로 다른 타입이 들어가면 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않으면 그 제네릭 타입을 무공변(invariant)라도 말한다. A가 B의 하위 타입이면 List<A>는 List<B>의 하위 타입이다. 그런 클래스나 인터페이스를 공변적이라 말한다.

9.3.3 공변성: 하위 타입 관계를 유지

Producer<T>를 예로 공변성 클래스를 설명해보자. A가 B의 하위 타입일 때 Producer<A>가 Producer<B> 의 하위 타입이면 Producer는 공변적이다. 코틀린에서 제네릭 클래스가 타입 파라미터에 대해 공변적임을 표시하려면 타입 파라미터 이름 앞에 out을 넣어야 한다.

interface Producer<out T> {
fun produce(): T
}

9.3.4 반공변성: 뒤집힌 하위 타입 관계

반공변 클래스의 하위 타입 관계는 공변 클래스의 경우와 반대다.

interface Comparator<in T> {
fun compare(e1: T, e2: T): Int{ ... }
}

이 인터페이스의 메소드는 T 타입의 값을 소비하기만 한다. 이는 T가 in 위치에서만 쓰인다는 뜻이다. 따라서 T 앞에는 in 키워드를 붙여야만 한다.

타입 B가 타입A의 하위 타입인 경우 Consumer<A>가 Consumer<B>의 하위 타입인 관계가 성립하면 제네릭 클래스 Consumer<T> 는 타입 인자 T에 대해 반공변이다. 예를 들어 Consumer<Animal>은 Consumer<Cat>의 하위 타입이다.

클래스나 인터페이스가 어떤 타입 파라미터에 대해서는 공변적이면서 다른 타입 파라미터에 대해서는 반공변적일 수도 있다. Function 인터페이스가 고전적인 예다.

interface Function1 <in P, out R>{
operator fun invoke(p: P) : R
}

함수 Function1 의 하위 타입 관계는 첫 번째 타입 인자의 하위 타입 관계와는 반대지만 두 번째 타입 인자의 하위 타입 관계와는 같음을 뜻한다.

fun enumerateCats(f: (Cat) -> Number) { ... }
fun Animal.getIndex(): Int = ...
>>>enumerateCats(Animal::getIndex) // animal은 cat의 상위 타입이며 Int는 Number의 하위 타입이므로, 이 코드는 올바른 코틀린 식이다.

9.3.5 사용 지점 변성: 타입이 언급되는 지점에서 변성 지점

클래스를 선언하면서 변성을 지정하면 그 클래스를 사용하는 모든 장소에 변성 지정자가 영향을 끼치므로 편리하다. 이런 방식을 선언 지점 변성이라 부른다.

fun <T> copyData(source: MutableList<T>, destination: MutableList<T>){
for(item in source) destination.add(item)
}

이 함수는 컬렉션의 원소를 다른 컬렉션으로 복사한다. 두 컬렉션 모두 무공변 타입이지만 원본 컬렉션에서는 읽기만 하고 대상 컬렉션에는 쓰기만 한다. 이 경우 두 컬렉션의 원소 타입이 정확하게 일치할 필요가 없다.

9.3.6 스타 프로젝션: 타입 인자 대신 * 사용

제네릭 타입 인자 정보가 없음을 표현하기 위해 스타 프로젝션을 사용한다. 예를 들어 원소 타입이 알려지지 않은 리스트는 List<*> 라는 구문으로 표현할 수 있다.

MutableList<*> 는 MutableList<Any?>와 같지 않다. MutableList<Any?>는 모든 타입의 원소를 담을 수 있다는 사실을 알 수 있다. 반면 MutableList<*>는 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지만 그 원소의 타입을 정확히 모른다는 사실을 표현한다.

--

--