Kotlin in Action

[Kotlin in Action] part9_제네릭스

지 슈 2023. 1. 12. 23:43

제네릭 타입 파라미터

제네릭스를 사용하면 타입 파라미터를 사용하는 타입을 정의할 수 있다. 

타입 파라미터를 사용하면 구체적으로 원하는 타입을 정의할 수 있다. 

코틀린에서는 타입 인자도 추론할 수 있다. 

val authors = listOf("Dmitry", "Svetlana")

두 값이 문자열이기 때문에 컴파일러는 여기서 생기는 리스트가 문자열을 담는 리스트라는 것을 추론할 수 있다. 

 

만약 빈 리스트를 만든다고 하면 타입 인자를 추론할 수 없기 때문에 직접 변수의 타입 또는 타입 인자를 지정해야 한다. 

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

ㄴ 이 두 선언은 동등하다.

 

제네릭 함수와 프로퍼티

제네릭 함수는 모든 리스트를 다루는 함수를 만들고 싶을 때 사용한다. 제네릭 함수를 호출할 때는 반드시 구체적인 타입으로 타입 인자를 넘겨야 한다. 

제네릭 함수 slice는 T를 타입 파라미터로 받는다.

 

제네릭 함수를 선언할 때와 같이 제네릭 확장 프로퍼티를 선언할 수 있다. 

리스트의 마지막 원소 바로 앞의 원소를 반환하는 확장 프로퍼티

 

제네릭 클래스 선언

타입 파라미터를 넣은 <>를 클래스(or 인터페이스)의 이름 뒤에 붙이면 클래스(or 인터페이스)를 제네릭하게 만들 수 있다.

interface List<T> {  //List 인터페이스에 T라는 타입 파라미터를 정의
operator fun get(index: Int): T
// ...
}

제네릭 클래스를 확장하는 클래스(or 구현하는 인터페이스)를 정의하려면 타입 인자를 지정해야 한다. 

-> 구체적인 타입을 넘기거나 타입 파라미터로 받은 타입을 넘길 수 있다.

-> 클래스가 자기 자신을 타입 인자로 참조할 수도 있다. 아래의 코드를 보아라.

interface Comparable<T> {
fun compareTo(other: T): Int
}
class String : Comparable<String> {
override fun compareTo(other: String): Int = /* ... */
}

 

타입 파라미터 제약

타입 파라미터 제약: 클래스나 하수에 사용할 수 있는 타입 인자를 제한하는 기능

예를 들어 sum함수를 생각할 떄 List<Int>나 List<Double>에 적용할 수 있지만 List<String>에는 적용할 수 없다.

제약을 가하려면 타입 파라미터 이름 뒤에 : 를 표시하고 그 뒤에 상한 타입을 적으면 된다.

-> 상한 타입: 제네릭 타입의 타입  파라미터에 대한 제약 타입

만약 함수가 적용할 수 없는 타입 인자에 대해 호출이 되면 컴파일 오류가 난다.

 

아주 드물지만 타입 파라미터에 대해 둘 이상의 제약을 가해야 하는 경우도 있다.

 

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

항상 널이 될 수 없는 타입만 타입 인자로 받게 만들려면 타입 파라미터에 제약을 가해야 한다.

예를 들어

널이 될 수 없는 타입을 보장하려면 <T:Any?>가 아닌 <T:Any>를 상한으로 사용하면 된다.

이 타입은 T타입이 항상 널이 될 수 없는 타입이 되게 보장하기 때문이다.

 

 

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

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

코틀린 제네릭 타입 인자 정보는 런타임에 지워진다. 정보를 유지하지 않는다.

따라서 실행 시점에 컴파일러는 리스트가 어떤 타입으로 선언됐는지 알 수 없다.

 

또 다른 문제는 타입 소거로 인해 생긴다. 타입 인자를 저장하지 않기 때문에 실행 시점의 타입 인자는 검사 불가능하다.

ㄴ is 검사에서 타입 인자로 지정한 타입을 검사할 수는 없다. 

ㄴ 실행 시점에 어떤 값이 List인지는 알 수 있지만 List의 타입은 알 수 없다는 것이다.

 

: 근데 List인지는 어떻게 앎? -> 스타 프로젝션을 사용해서 알 수 있다. (9장에서 설명)

 

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

대개의 경우에 제네릭 함수의 타입 인자는 알아낼 수 없다. 실행 시점에 지워지기 때문이다.

그러나 하나의 예외가 있다.

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

>>> fun <T> isA(value: Any) = value is T
Error: Cannot check for instance of erased type: T
//이 함수를 인라인 함수로 만들고 타입 파라미터를 reified로 지정

-> 이렇게 하면 value의 타입이 T의 인스턴스인지 실행 시점에 검사할 수 있다.

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

 

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

실체화한 타입 파라미터에는 제약이 있다. 

▼ 아래의 경우에 실체화한 타입 파라미터를 사용할 수 있다.

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

▼ but, 아래의 경우에는 실체화한 타입 파라미터를 사용할 수 없다.

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

 

변성: 제네릭과 하위 타입

변성: 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념.

-> 변성을 잘 활용하면 사용하기 편하고 타입 안전성을 보장하는 API를 만들 수 있다.

 

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

예를 들어서 String 클래스는 Any를 확장하기 때문에 Any 타입 값을 받는 파라미터에 String값을 넘겨도 안전하다.

하지만 Any와 String이 List 인터페이스의 타입 인자로 들어갈 때는 안전하지 않다.

-> List<Any> 타입의 파라미터를 받는 함수에 List<String>을 넘길 수 없다.

리스트의 원소를 추가하거나 변경하면 타입 불일치가 생길 수도 있기 때문.

(그렇지 않은 경우에는 안전할 수도 있다.)

 

클래스, 타입, 하위 타입

타입과 클래스의 차이.

제네릭 클래스가 아닌 클래스에서는 클래스 이름을 타입으로 쓸 수 있다.

하지만 클래스 이름을 널이 될 수 있는 타입에도 쓸 수 있다. -> 모든 코틀린 클래스가 최소 둘 이상의 타입을 구성 가능

 

제네릭 클래스의 경우: 올바른 타입을 얻으려면 타입 인자가 구체적이어야 한다. 

하위 타입: 타입 a의 값이 필요한 곳에 타입 b를 넣어도 된다면 타입 b는 타입a의 하위 타입.

상위 타입: 그 반대

 

 

컴파일러는 변수 대입 또는 함수 인자 전달 시 항상 하위 타입 검사를 한다.

fun test(i: Int) { 
val n: Number = i //Int가 Number의 하위 타입이라서 컴파일된다.
fun f(s: String) { /*...*/ }
f(i) //Int가 String의 하위 타입이 아니라서 컴파일 되지 않는다.
}

간단한 경우에 하위 타입은 하위 클래스와 같다.

하지만 널이 될 수 있는 타입의 경우, 하위 타입과 하위 클래스는 같지 않다.

-> 널이 될 수 없는 타입의 값을 널이 될 수 있는 타입의 변수에 저장 : 가능

-> 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 변수에 저장 : 불가능

 

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

공변적: a가 b의 하위 타입이면 List<A>는 List<B>의 하위 타입이다. 이러한 클래스나 인터페이스는 공변적.

 

코틀린에서 제네릭 클래스가 타입 파라미터에 대해 공변적임을 표시하려면 타입 파라미터 앞에 out을 넣어야 함.

interface Producer<out T> { //클래스가 T에 대하여 공변적이라고 선언
fun produce(): T
}

-> 그러면 타입 파라미터와 타입 인자의 타입이 정확히 같지 않아도 함수 인자나 반환값으로 사용할 수 있다.

=> 공변적인 클래스로 만들면 코드를 적절히 바꿀 수 있다.

but, 모든 클래스를 공변적으로 만들 수는 없다. 타입 파라미터를 공변적으로 지정하면 파라미터의 사용법이 제한된다.

 

-> 타입 파라미터를 사용할 수 있는 지점은 모두 in과 out위치로 나눈다.

타입 파라미터 T가 함수의 반환 타입으로 사용된다면 T는 아웃 위치에 있다. -> T타입의 값을 생산

타입 파라미터 T가 함수의 파라미터 타입으로 쓰인다면 T는 인 위치에 있다. -> T타입의 값을 소비

 

 

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

반공변성: 타입b가 타입a의 하위 타입인 경우 Consumer<a>가 Consumer<b>의 하위 타입이다.

 

반공변성의 경우에도 공변성과 마찬가지로 in 키워드를 타입인자에 붙이면 그 타입 인자를 오직 인 위치에서만 사용 가능.

 

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

-> 클래스 정의에 변성을 직접 적으면 그 클래스를 사용하는 모든 곳에 변성이 적용된다.

 

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

위에 언급했듯이 클래스 정의에 변성을 직접 적으면 그 클래스를 사용하는 모든 곳에 변성이 적용된다

ㄴ 선언 지점 변성이라 부른다.

 

-> 자바에서는 타입 파라미터가 있는 타입을 사용할 때마다 어떤 타입으로 대치할 수 있는지 명시해야 한다.

ㄴ 사용 지점 변성이라 부른다.

ㄴ 코틀린도 이를 지원한다.

따라서 변성을 선언할 수 없는 경우에도 특정 타입 파라미터가 나타나는 지점에서 변성을 정할 수 있다. 

 

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

스타 프로젝션: 제네릭 타입 인자 정보가 없음을 표현

-> 원소 타입이 알려지지 않은 리스트: List<*>

=> 제네릭 클래스의 타입 인자가 어떤 타입인지 정보가 없거나 타입 인자가 어떤 타입인지 중요하지 않을 때 스타 프로젝션 구문을 사용할 수 있다.