Book/Kotlin in Action

Kotlin in Action: ch 9. 제네릭스

도라프 2023. 1. 11. 23:21

Generics(제네릭스)

Generics를 사용하면 타입 파라미터(type parameter)를 받는 타입을 정의할 수 있다. 제네릭 타입의 인스턴스를 만들려면 타입 파라미터를 구체적인 타입 인자(type argument)로 치환해야 한다. 예를 들어 List라는 타입이 있다면 그 안에 들어가는 원소의 타입을 안다면 쓸모가 있을 것이다. 타입 파라미터를 사용하면 “이 변수는 리스트다”라고 말하는 대신 정확하게 “이 변수는 문자열을 담는 리스트다”라고 말할 수 있다.

 

자바와 달리 코틀린에서는 제네릭 타입의 타입 인자(type argument)를 프로그래머가 명시하거나 컴파일러가 추론할 수 있어야 한다. 자바는 맨 처음에 제네릭 지원이 없었고 자바 1.5에 뒤늦게 제네릭을 도입했기 때문에 이전 버전과 호환성을 유지하기 위해 타입 인자가 없는 제네릭 타입(raw 타입)을 허용한다. 예를 들어 자바에서는 리스트 원소 타입을 지정하지 않고 List 타입의 변수를 선언할 수도 있다. 코틀린은 처음부터 제네릭을 도입했기 때문에 raw 타입을 지원하지 않고 제네릭 타입의 타입 인자를 (프로그래머가 직접 정의하든 타입 추론에 의해 자동으로 정의되든) 항상 정의해야 한다.

 

제네릭 함수는 아래와 같이 정의된다. 예제인 slice는 T 를 타입 파라미터로 받는다.

fun <T> List<T>.slice(indices: IntRange) : List<T>

// 사용 예시
val letters = ('a'...'z').toList()
letters.slice<Char>(0..2) // 타입 인자를 명시적으로 지정
letters.slice(10..13) // 컴파일러가 타입 인자를 추론. T가 Char 라는 사실을 추론

제네릭 함수를 정의할 때와 마찬가지 방법으로 제네릭 확장 프로퍼티를 선언할 수 있다.

val <T> List<T>.penultimate: T
	get() = this[size - 2]

타입 파라미터를 클래스 이름 뒤에 붙이면 클래스를 제네릭하게 만들 수 있다.

interface List<T> {
    operator fun get(index: Int): T // 인터페이스 안에서 T를 일반 타입처럼 사용할 수 있다.
}

제네릭 클래스를 확장하는 클래스(또는 제네릭 인터페이스를 구현하는 클래스)를 정의하려면 base type의 제네릭 파라미터에 대해 타입 인자를 지정해야 한다.

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

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

클래스가 자기 자신을 타입 인자로 참조할 수도 있다. Comparable 인터페이스를 구현하는 클래스가 이런 패턴의 예.

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

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

 

타입 파라미터 제약

타입 파라미터 제약(type parameter constraint)은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.

// T(타입 파라미터)에 대한 상한(upper bound) 타입을 Number 로 지정한 것.
fun <T : Number> List<T>.sum() : T

// 예시로 아래와 같이 사용할 수 있다.

// Int 타입은 Number를 확장하므로 sum() 함수를 사용할 수 있다.
listOf(1,2,3).sum()

타입 파라미터 T에 대한 상한을 정하고 나면 T 타입의 값을 그 상한 타입의 값으로 취급할 수 있다.

 

타입 파라미터에 여러 제약을 둘 수도 있다.

fun <T> ensureTrailingPeriod(seq: T) where T: CharSequence, T : Appendable {
    ...
}

 

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

아무런 상한을 정하지 않은 타입 파라미터는 Any? 를 상한으로 정한 것과 동일하다. 즉, 아래의 예제에서 타입 T에 ?가 붙어있지 않지만 실제로는 T에 해당하는 타입 인자로 널이 될 수 있는 타입을 넘길 수도 있다.

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

따라서 타입 인자를 non-null 로 제한하고 싶다면, 타입 파라미터에 아래와 같이 제약을 가해야 한다.

타입 파라미터를 null이 될 수 없는 타입으로 제약하기만 하면 타입 인자로 null이 될 수 있는 타입이 들어오는 일을 막을 수 있다.

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

 

reified 타입 파라미터를 사용한 함수 선언

JVM의 제네릭스는 보통 타입 소거(type erasure)를 사용해 구현된다. 이는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 뜻.

단, 함수를 inline 으로 선언하고 reified 타입 파라미터를 사용하면 타입 인자가 지워지지 않게 할 수 있다.

 

자바와 마찬가지로 코틀린 제네릭 타입 인자 정보는 런타임에 지워진다. 예를 들어, List<String> 객체를 만들고 그 안에 문자열을 여럿 넣더라도 실행 시점에는 그 객체를 오직 List로만 볼 수 있다. 그 List 객체가 어떤 타입의 원소를 저장하는지 실행 시점에는 알 수 없다.

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

컴파일러는 위의 두 리스트를 서로 다른 타입으로 인식하지만 실행 시점에 그 둘은 완전히 같은 타입의 객체다.

타입 소거(type erasure)로 생기는 한계는?

  • 실행 시점에 타입 인자를 검사 할 수 없다. 예를 들어, 어떤 리스트가 문자열로 이뤄진 리스트인지 다른 객체로 이뤄진 리스트인지를 실행 시점에 검사할 수 없다. 즉, is 체크로 지정한 타입을 확인할 수 없다.
    • fun <T> isA(value: Any) = value is T // 아래와 같은 오류 발생.
      // Error: Cannot check for instance of erased type: T
       
    • 만약 리스트라는 타입을 체크하고 싶다면, if (value is List<*>) 로 star projection을 사용하면 됨
  • (장점) 저장해야 하는 정보의 크기가 줄어들어서 전반적인 메모리 사용량이 줄어든다는 장점은 있다.

as나 as? 캐스팅에도 제네릭 타입을 사용할 수 있으나, base 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공한다는 점을 조심해야 한다.

 

 

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

 

위에서 본 예시 isA 함수를 인라인 함수로 만들고 타입 파라미터를 reified로 지정하면 value의 타입이 T의 인스턴스인지를 실행 시점에 체크할 수 있다.

inline fun <reified T> isA(value: Any) = value is T

isA<String>("abc") // true 반환

reified 타입 파라미터를 사용하는 예시로, 표준 라이브러리 함수인 filterIsInstance다.

인라인 함수에서만 reified 타입 인자를 쓸 수 있는 이유
이전에도 설명했듯이, 컴파일러는 인라인 함수의 본문을 구현한 바이트코드를 그 함수가 호출되는 모든 지점에 삽입한다. 컴파일러는 실체화한 타입 인자를 사용해 인라인 함수를 호출하는 각 부분의 정확한 타입 인자를 알 수 있다. 따라서 컴파일러는 타입 인자로 쓰인 구체적인 클래스를 참조하는 바이트코드를 생성해 삽입할 수 있다. 타입 파라미터가 아니라 구체적인 타입을 사용하므로 만들어진 바이트코드는 실행 시점에 일어나는 타입 소거의 영향을 받지 않는 것. 자바 코드에서는 reified 타입 파라미터를 사용하는 inline 함수를 호출할 수 없다는 점을 기억하자.

 

인라인 함수에는 실체화한 타입 파라미터가 여럿 있거나 실체화한 타입 파라미터와 실체화하지 않은 타입 파라미터가 함께 있을 수도 있다.

성능을 좋게 하려면 인라인 함수의 크기를 계속 관찰해야 한다. 함수가 커지면 실체화한 타입에 의존하지 않는 부분을 별도의 일반 함수로 뽑아내는 편이 낫다.

 

inline fun <reified T> loadService() {
    return ServiceLoader.load(T::class.java) // ::class 로 타입 파라미터의 클래스를 가져온다.
}

 

reified 타입 파라미터의 제약

실체화한 타입 파라미터는 유용한 도구지만 몇 가지 제약이 있다.

reified 타입 파라미터로 할 수 있는 것들

  • 타입 검사와 캐스팅 (is, as)
  • 코틀린 리플렉션 API
  • java.lang.Class 를 얻기 (::class.java)
  • 다른 함수를 호출할 때 타입 인자로 사용

reified 타입 파라미터로 할 수 없는 것들

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

변성(Variance): 제네릭과 하위 타입

변성 개념은 List<String>과 List <Any>와 같이 베이스 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 것. 변성을 잘 활용하면 사용에 불편하지 않으면서 타입 안전성을 보장하는 API를 만들 수 있다.

 

List <Any> 타입의 파라미터를 받는 함수에 List <String>을 넘기면 안전할까?

  • Any 타입 값을 파라미터로 받는 함수에 String값을 넘기는 것은 안전.
  • But, Any와 String이 List 인터페이스의 타입 인자로 들어가는 경우에는 안전하지 않다.

아래의 예시를 통해, MutableList<Any>가 필요한 곳에 MutableList<String>을 넘기면 안 된다는 사실을 알 수 있다.

fun addAnswer(list: MutableList<Any>) {
   list.add(42)
}

val strings = mutableListOf("abc", "bac")
addAnswer(strings) // Type mismatch 오류가 뜬다. Required: MutableList<Any>,  Found:MutableList<String>
strings.maxBy { it.length } // ClassCastException: Integer cannot be cast to String

하지만 원소 추가나 변경이 없는 경우에는 List<String>을 List<Any> 대신 넘겨도 안전하다. 코틀린에서는 리스트의 변경 가능성에 따라 적절한 인터페이스를 선택하면 안전하지 못한 함수 호출을 막을 수 있다.

List와 MutableList 인터페이스의 변성은 다르다.

public interface List<out E> : Collection<E> {
    // Query Operations

    override val size: Int
    override fun isEmpty(): Boolean
    override fun contains(element: @UnsafeVariance E): Boolean
    override fun iterator(): Iterator<E>
	...
}

public interface MutableList<E> : List<E>, MutableCollection<E> {
    ...
}

 

 

하위 타입(subtype) 이란?

어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 A의 하위 타입은 타입 B이다. 예를 들어,

  • Number의 하위 타입은 Int이다. == Int의 상위 타입은 Number이다.
  • 즉, 타입 A를 상속 및 구현한 타입 B는, 타입 A의 하위 타입이다.
  • null이 될 수 없는 타입은 null 이 될 수 있는 타입의 하위 타입이다.

제네릭 타입을 인스턴스 화할 때 타입 인자로 서로 다른 타입이 들어가면 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않으면 그 제네릭 타입을 무공변성(invariance)이라고 말한다.

예를 들어, MutableList<Any> - MutableList<String> 의 관계. 이 둘은 서로 하위 타입이 아니다.

 

A가 B의 하위 타입이면 List<A>는 List<B>의 하위 타입이다. 이런 클래스나 인터페이스를 공변적(covariance)라고 한다.

 

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

아래의 Producer<T>를 예로, A가 B의 하위 타입일 때 Producer<A>가 Producer<B>의 하위 타입이면 Producer는 공변적(covariance)이다. 이를 하위 타입 관계가 유지된다고 말한다.

타입 파라미터 이름 앞에 out을 넣어주면 된다.

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

클래스의 타입 파라미터를 공변적으로 만들면, 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않더라도 그 클래스의 인스턴스를 함수 인자나 반환 값으로 사용할 수 있다.

 

// 이런 관계의 클래스들이 있다고 하자.
interface Animal
open class Kitten: Animal
open class Cat: Kitten()

class Herd<T: Animal> {
    // ...
}

fun feedAnimal(animals: Herd<Animal>) {
    // ...
}

val herd = Herd<Cat>()
feedAnimal(herd) // inferred type is Herd<Cat>, but Herd<Animal> was expected 라는 오류가 발생.

Cat 은 Animal의 하위 타입이지만, 위에서 Herd는 무공변성 클래스이기 때문에, Herd<Cat> 은 Herd<Amimal>의 하위 타입 관계가 성립하지 않아 feedAnimal(herd) 에서 오류가 발생한다.

위에서 설명했듯이, 제네릭 클래스에서도 하위 타입 관계를 유지하려면 공변적으로 만들면 된다.

 

아래와 같이 out 으로 타입 파라미터 T 를 공변적으로 만들었다. 그럼 아래와 같은 관계가 성립한다.

  • Cat 은 Animal의 하위 타입.
  • Herd<Cat> 은 Herd<Animal>의 하위 타입. 
class Herd<out T: Animal> { // out 을 붙여 T는 이제 공변적!
    // ...
}

val herd = Herd<Cat>()
feedAnimal(herd) // 오류 없이 이렇게 사용 가능~

모든 클래스를 공변적으로 만들 수는 없다. 공변적으로 만들면 안전하지 못한 클래스도 있다. 타입 파라미터를 공변적으로 지정하면 클래스 내부에서 그 파라미터를 사용하는 방법을 제한한다. 타입 안정성을 보장하기 위해 공변적 파라미터는 항상 out 위치에만 있어야 한다. 이는 클래스가 T 타입의 값을 생산할 수는 있지만 T 타입의 값을 소비할 수는 없다는 뜻.

out 키워드는 T의 사용법을 제한하며 T로 인해 생기는 하위 타입 관계의 타입 안정성을 보장한다.

 

 

아웃 위치란?

interface Producer<out T>(val value: T) { // 생성자 파라미터는 in / out 어느 쪽도 아니라는 점을 기억하자!

    fun produce(): T // 리턴 값 선언하는 여기

    fun produceList(): List<T>

    private fun test(param: T) { // private 메소드의 파라미터도 in / out 어느 쪽도 아니다! 
    }
}

 

 공변성(covariance) 특징 간단 정리

  • 타입 파라미터 T 앞에 out 키워드를 붙여 해당 파라미터를 공변적으로 만든다.
  • 하위 타입 관계가 유지된다.
  • 타입 파라미터인 T를 아웃 위치에서만 사용할 수 있다.

 

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

반공변성(contravariance)은 이름 그대로 공변성과 반대의 개념을 가진다.

interface Consumer<in T> { // 클래스가 T에 대해 반공변적이라고 선언.
    fun consume(t: T)
}

 

  • 타입 파라미터 T 앞에 in 키워드를 붙여 해당 파라미터를 공변적으로 만든다.
  • 하위 타입 관계와 정반대 방향이다.
    • 타입 B가 타입 A의 하위 타입이라면, Consumer<A>가 Consumer<B>의 하위 타입
  • 타입 파라미터인 T를 인 위치에서만 사용할 수 있다.
    • 함수 내 파라미터 위치에서만 사용할 수 있다.

무공 변성 vs 공변성 vs 반공변성

무공변성 공변성 반공변성
MutableList<T> Producer<out T> Consumer<in T>
하위 타입 관계가 성립 X 타입 인자의 하위 타입 관계가 제네릭 타입에서도 유지된다 타입 인자의 하위 타입 관계가 제네릭 타입에서 뒤집힌다
  Producer<Cat>은 Producer<Animal>의 하위 타입이다 Consumer<Animal>은 Consumer<Cat>의 하위 타입이다
T를 아무 위치에서나 사용 가능 T를 out 위치에서만 사용 가능 T를 in위치에서만 사용 가능

 

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

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

  • MutableList<Any?> ≠ MutableList<*>
    • MutableList<Any?> : 모든 타입의 원소를 담을 수 있다
    • MutableList<*> : 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지만, 그 원소의 타입을 정확히 모른다