제네릭은 제대로 알고 사용해야 한다…
✔️ 제네릭은 Java 5에서 처음으로 도입된 개념이다.
Java는 초기 개발 단계에서 제네릭을 고려하지 않았다.
제네릭 도입 전의 컬렉션은 타입 안전성을 제공하지 않았기 때문에 런타임 에러(ClassCastException) 발생 위험이 높았는데,
이후 제네릭이 도입되면서 기존의 컬렉션 클래스와 인터페이스를 타입 안전하게 만들기 위해 제네릭 타입 파라미터를 추가하게 된다.
그로 인해 제네릭이 없던 버전의 코드와 제네릭이 도입된 이후의 코드의 호환성을 유지해야 하는 문제가 발생하게 되는데,
여기서 오늘의 주제 중 하나인 "타입 소거"를 Java 5(1.5)에서 도입하게 된다.
타입 소거
제네릭 타입 정보를 컴파일 타임에만 유지하고, 런타임에는 제거하여 원시 타입(raw type)으로 변환하는 기법
<예시>
컴파일 타임에는 List<String>과 List<Int>가 서로 다른 타입으로 간주되지만,
런타임에는 List<String>과 List<Int> 모두 제네릭 타입이 제거되어 List로 취급된다.
<타입 안전성>
컴파일 타임 : 타입 안전성 보장
런 타임 : 타입 안전성 보장되지 않음
이로 인해서 Java에서는 지금도 raw type 객체를 생성할 수 있다. (타입 안전하지 않아 권장되지 않는 방법이다)
List list = List.of(1, 2, 3);
반면 제네릭을 사용하면 컴파일러가 컴파일 타임에 타입을 체크하므로 타입 안전하다.
✔️ Kotlin은 Java와 개발 단계부터 달랐다
Kotlin은 언어 개발 단계에서부터 제네릭 도입을 고려했기 때문에, 원시 타입 객체의 사용을 제한함으로써
타입 안전성 부족으로 인한 런타임 에러를 방지한다.
fun foo() {
// One type argument expected for interface List<out E>라는 에러가 발생한다.
val list: List = listOf(1, 2, 3)
}
✔️ JVM과 타입 소거의 관계
컴파일 타임에서 컴파일러는 제네릭 타입 파라미터를 구체화된 타입으로 대체하게 되며,
해당 과정이 끝나면 구체화되지 않은 제네릭 타입 정보를 소거한다.
JVM은 자바 바이트 코드를 실행하는 가상 머신인데,
제네릭 타입 파라미터는 컴파일 타임 이후에 타입이 소거되어 바이트 코드에 포함되지 않는다.
따라서 런타임에 제네릭 타입 정보를 확인할 수 없는 것이다.
✔️ Kotlin 또한 타입이 소거된다
Kotlin 또한 JVM 위에서 동작하는 언어이기 때문에 런타임 때 타입 정보가 소거된다.
이를 통해 Java와의 상호 운용성, 런타임 성능 향상 등의 이점을 챙길 수 있다.
fun checkListType(list: List<Any>) {
// Cannot check for instance of erased type: List<String> 에러 발생
if (list is List<String>) {
// Something else
}
}
위 코드에서 List<Any> 타입의 list 객체는 런타임에 타입이 소거되어 단순 List로 취급되기 때문에
컴파일러는 이를 감지하고 에러를 발생시킨다.
런타임에 타입 정보를 확인할 수 있는 방법은 없을까?
✔️ Star projection
Star projection *
제네릭 타입 파라미터를 알 수 없거나 특정하지 않을 때 제네릭 타입을 다루는 방법.
이를 통해 특정 타입 파라미터를 알지 못해도 제네릭 타입을 다룰 수 있다
Star projection을 사용하면 런타임에 타입 정보가 제거되어도 제네릭 타입을 다룰 수 있게 된다.
fun foo(any: Any) {
if (any is List<*>) {
val listItem: Any? = any[0]
}
}
위 코드는 any가 원시타입 List의 인스턴스인지 확인한다.
하지만 각 원소에 대한 타입은 알지 못하므로 Any? 타입으로 다뤄야 한다.
✔️ 제네릭 함수에서도 타입 정보가 소거된다
아래 메소드는 런타임에 T가 어떤 타입인지 알 수 없기 때문에 리플렉션을 통해 타입 정보를 가져오려는 시도는 컴파일 에러를 발생시킨다.
fun <T> T.toStringType(): String {
// Cannot use 'T' as reified type parameter. Use a class instead. 에러 발생
return "${T::class.java.name} type"
}
하지만 우리는 가끔 런타임에 T의 타입 정보를 가져와야하는 경우가 있다.
특정 리스트에 T 타입의 원소가 하나라도 존재하는지 확인하는 확장 함수를 예시로 들어보자.
fun <T> List<T>.hasAnyInstanceOfString(): Boolean {
return this.any { it is String }
}
fun <T> List<T>.hasAnyInstanceOfInt(): Boolean {
return this.any { it is Int }
}
우리는 T의 정보를 알 수 없기 때문에, 만약 여러 종류의 타입에 대한 함수가 필요하다면 위처럼
타입마다 메소드를 새로 작성해줘야 하는 번거로움이 발생한다.
fun <T> List<*>.hasAnyInstanceOf(): Boolean {
// Cannot check for instance of erased type: T 에러 발생
return this.any { it is T::class }
}
위 메소드 또한 T의 타입 정보를 런타임에 알 수 없기 때문에 컴파일 에러가 발생하게 된다.
그럼 여러 타입을 하나의 메소드로 처리할 순 없을까?
여기서 reified 키워드를 사용하면 가능하다.
✔️ reified
inline 함수에서 제네릭 타입 파라미터에 reified 키워드를 사용하면 런타임에 타입 파라미터 T의 정보가 소거되지 않게 된다.
아래 메소드는 런타임에서도 제네릭 타입 파라미터인 T의 정보가 소거되지 않아 타입 체크가 가능하다.
inline fun <reified T> List<*>.hasAnyInstanceOf(): Boolean {
return this.any { it is T }
}
✔️ 왜 reified 키워드는 inline 함수에서만 사용할 수 있을까?
(1) inline 함수의 특성
inline 함수는 컴파일 타임에 함수 호출을 함수의 본문으로 대체하는 방식으로 동작한다.
이 과정에서 제네릭 타입 파라미터도 실제 타입으로 대체된다.
이로 인해 타입 정보를 사용해야 할 경우에 reified 키워드를 통해 구체화할 수 있게 된다.
(2) 타입의 구체화
제네릭은 타입을 일반화시키는 기법이다.
inline 함수의 본문이 호출부에 삽입될 때, reified 타입 파라미터는 구체화된 타입으로 대체된다.
구체화된 타입 정보는 런타임에 타입 소거되지 않으므로 런타임에도 타입 정보를 사용할 수 있다.
'개발 > Kotlin' 카테고리의 다른 글
[Kotlin] 테스트 코드 (2) - TDD/BDD와 디자인 패턴 (0) | 2023.07.02 |
---|---|
[Kotlin] 테스트 코드 (1) - 테스트 코드란? (0) | 2023.06.28 |
[안드로이드] 테스트 코드 - JUnit의 예외 처리 (expected, assertThrows, doThrow) (0) | 2023.06.26 |
Singleton 패턴이란? (0) | 2022.12.01 |
연산자 개수에 따른 차이(&, |와 &&,||의 차이) (0) | 2022.07.25 |