제네릭 클래스 자체에 변성을 주는 방법이 있습니다. 방법은 간단합니다. 제네릭 타입 파라미터에 out 혹은 in만 작성해주시면 됩니다.
class Cage<out T> {
private val animals = mutableListOf<T>()
}
class Cage<in T> {
private val animals = mutableListOf<T>()
}
클래스 자체에 변성을 주었을 때 주의하여야 할 사항이 한 가지 있습니다.
메서드 파라미터에 변성을 주었을 때와 마찬가지로 생산과 소비에 대한 제한이 생기게 됩니다.
class Cage<out T> {
private val animals = mutableListOf<T>()
fun getFirst(): T {
return animals.first()
}
}

out 키워드 작성 시 put과 같은 데이터를 넣는 메서드는 사용할 수 없게 됩니다.
반대로 in은 어떨까요?

out 키워드를 작성했을 때는 에러를 발생하지 않았던 getFirst() 메서드에서 에러가 발생합니다.
위 결과로 클래스 자체에 변성을 주었을 때도 메서드와 동일한 제한 사항이 생긴다는 것을 알 수 있습니다.
방금 알아보았던 선언 지점 변성을 활용한 코틀린 표준 라이브러리에 대해 알아보겠습니다.

out을 활용한 예시로 List 인터페이스가 있습니다. 코틀린을 어느 정도 사용해 보셨다면 List 타입을 사용하였을 때 add() 메서드와 같은 데이터를 넣는 행동이 불가능하다는 것을 알고 계실 겁니다. 그 이유는 List 인터페이스에 out 키워드로 변성을 주었기 때문입니다.
그러나 contains()는 파라미터를 받아 기능을 수행할 수 있습니다. 어떻게 가능할까요?
그것은 바로 제네릭 타입 E 옆에 선언해준 @UnsafeVariance 어노테이션 덕분입니다.
타입 안전하다고 생각하는 곳에 이 어노테이션을 작성하여 허용해 줄 수 있습니다.

in을 활용한 예시로 Comparable 이라는 인터페이스가 있습니다.
코틀린에서는 제네릭 타입 파라미터 T에 특정 타입과 특정 타입의 하위 타입만 받도록 설정해줄 수 있습니다. 방법은 간단합니다.
class Cage<T : Animal> { ... }
다음 코드와 같이 타입 T 옆에 콜론과 특정 타입을 작성하시면 됩니다.
이렇게 되면 Animal 타입과 그 하위 타입을 제외한 다른 타입을 가진 Cage를 생성할 수 없게 됩니다. 컴파일 시점에 에러가 발생한다는 것도 알 수 있습니다.

함수 작성 시 반복되는 코드가 자주 작성될 경우 제네릭 타입을 활용하여 다양한 타입에 대응하는 함수도 구현할 수 있습니다.
제네릭 함수가 존재하지 않는다면 다양한 타입에 대응하는 코드를 작성해주어야 합니다.
fun List<String>.hasIntersection(other: List<String>): Boolean {
return (this.toSet() intersect other.toSet()).isNotEmpty()
}
fun List<Int>.hasIntersection(other: List<String>): Boolean {
return (this.toSet() intersect other.toSet()).isNotEmpty()
}
...
하지만 제네릭 함수를 사용한다면 이러한 문제를 해결할 수 있습니다.
fun <T> List<T>.hasIntersection(other: List<T>): Boolean {
return (this.toSet() intersect other.toSet()).isNotEmpty()
}
다음과 같이 작성해 일반 함수와 확장 함수에 적용이 가능합니다.
이렇게 제네릭을 사용하게 되면 반복되는 불필요한 코드 작성을 줄일 수 있고, 재사용성 있는 코드를 작성할 수 있습니다.
Raw Type이란 타입 파라미터가 없는 제네릭 타입을 말합니다. 자바 코드로 예시를 보여드리겠습니다.
List list = List.of(1, 2, 3)
다음과 같이 코드를 작성하게 되면 첫 번째 작성한 List에 하이라이팅 되면서 경고를 알립니다.
그러나 코틀린은 Raw Type 객체를 생성할 수 없습니다. 자바와의 차이점이죠. 하지만 코틀린도 자바와 같이 런타임 때 타입 정보가 사라집니다. 이것을 타입 소거 (Type erasure) 라고 합니다.
예시를 통해 타입 소거를 확인해 봅시다.
fun checkIntList(data: Any) {
if (data is List<Int>) { ... }
}
Cannot check for instance of erased type: List 이라는 에러를 발생 시키며 타입이 사라진다는 것을 알 수 있습니다. 즉, 런타임 때는 정보가 사라지기에 List 인지 확인할 수 없습니다.
List인지 확인할 수 있는 방법이 있는데 star pojection을 활용하는 방법입니다.
fun checkIntList(data: Any) {
if (data is List<*>) { ... }
}
다음 코드와 같이 *를 사용하면 최소한 List 타입인지 확인하는 코드를 작성할 수 있습니다.
start projection은 해당 타입 파라미터에 어떤 타입이 들어 있을지 모른다는 의미입니다. 어떤 타입의 List인지만 모를 뿐 List의 데이터를 저장하는 기능은 불가능하지만 가져오는 기능은 사용할 수 있다는 장점이 있습니다.
타입 소거는 제네릭 함수에서도 발생합니다. T 타입의 타입과 값을 출력하는 확장 함수가 있다고 생각해 봅시다.
fun <T> T.printValueWithType() {
println("${T::class.java.name}: $this")
}
위 코드는 컴파일 시 에러를 발생시킵니다. 그 이유는 런타임 시 제네릭 타입 T도 타입 소거가 발생해 타입 정보를 알 수 없기 때문입니다. 이렇게 되면 특정 타입에 대한 확장 함수를 여러 개 작성해야 합니다.
그러나 T의 타입 정보를 가져오는 방법이 존재합니다.
바로 reified 키워드와 inline 함수를 사용하는 것입니다.
inline fun <reified T> T.printValueWithType() {
println("${T::class.java.name}: $this")
}
이렇게 작성하면 에러가 사라지고 우리가 원하는 기능을 수행하는 확장 함수를 구현할 수 있게 됩니다.
inline 함수는 코드 본문을 함수 호출 지점으로 이동 시켜 컴파일되는 함수이기 때문에 가능한 일입니다. 하지만 한계도 존재합니다. reified 키워드가 붙은 타입 T를 이용해 T의 인스턴스 생성, T의 compaion object는 가져올 수 없습니다.
그러나 이러한 키워드를 잘 활용하면 다양한 상황에 대처할 수 있는 함수를 만들 수 있습니다.