[Kotlin] Generic - 제네릭과 타입안정성
프로젝트를 진행하면서 코드의 재사용을 위해 제네릭 함수를 사용하거나, Collection 과 같은 제네릭 클래스들을 빌트인 자료구조로 편하게 사용하곤 합니다. 이때까지 제네릭은 객체지향 프로그래밍에서 고급 기법으로 학부시절에도 그 이론을 이해하기가 너무 어려웠습니다. 그래서 관련 아티클들을 읽고, Kotlin-In-Action 책으로 코틀린 개발자들이 자바의 제네릭을 좀 더 사용하기에, 또 이해하기 쉽도록 구현하고 설명한 내용들을 학습해보았고, 이를 토대로 정리해 보고자 합니다.
제네릭
제네릭의 어원인 Generic 은 “일반적인” 이라는 뜻입니다. 그 단어에서 유추 가능하듯, 어떤 클래스나 인터페이스 또는 함수를 타입에 관계없이 일반화 하는 형태로 사용됩니다. 가령, 일반화한다는 것은 공통적인 것들을 추출한다 는 의미입니다. 가장 흔하게 추상클래스가 그 역할을 위해 사용되곤 합니다. 그와 달리 핵심적인 것만 드러내고 구체적인 것들은 구현체에 의존하게 하는 추상화와 같다고 생각해서는 안됩니다. 인터페이스와 추상클래스의 차이가 그 예시입니다. 이 둘은 같지 않지만 결국 코드의 재 사용성과 리펙토링의 용이를 높여준다는 점에서의 공통적인 목표를 위해 사용되고 있습니다.
자바에서 제네릭은 여러 타입들에 공통적인 기능들을 수행하기 위해 정의하는 기법 이라고 볼 수 있습니다. 외부에서 제네릭 클래스의 인스턴스가 생성되거나, 제네릭 함수가 호출되는 부분에서 타입이 정해지기 때문에 외부에서 결정하는 타입에 의존하게 됩니다. 이 부분을 잠깐 생각해봐도 타입에 대한 안정성 문제가 발생하지 않을까? 하는 생각이 들 수 있을겁니다. 해당 문제는 변성을 다루면서 어떻게 타입 안정성을 검사하는지 이해하실 수 있을 겁니다. 먼저, 제네릭이 뭔지 이해하기 위해 가장 흔하게 사용되는 Collection 의 List 를 예시로 살펴보겠습니다.
public interface List<out E> : Collection<E> {
override val size: Int
override fun isEmpty(): Boolean
override fun contains(element: @UnsafeVariance E): Boolean
override fun iterator(): Iterator<E>
override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
public operator fun get(index: Int): E
public fun indexOf(element: @UnsafeVariance E): Int
public fun lastIndexOf(element: @UnsafeVariance E): Int
public fun listIterator(): ListIterator<E>
public fun listIterator(index: Int): ListIterator<E>
public fun subList(fromIndex: Int, toIndex: Int): List<E>
}
자바의 List 는 Doubly-LinkedList 로 데이터를 관리하고, 접근을 위한 기능과 연산자들을 제공하는 인터페이스 입니다.
우리는 Int, Double, String 등의 여러 자료형들을 관리하는 리스트를 생성할 수 있고, 리스트의 선언부에서는 어떤 자료형으로 내부 데이터를 다룰지 관심을 두지 않습니다. 이처럼 내부에서 사용할 데이터에 대해서 고려하지 않고, 리스트라는 자료구조를 제공하기 위한 책임만 갖고, 그에 대한 기능들을 제공하기 위해 공통적인 것들만 추상적으로 모아 놓았습니다. 나중에 해당 구현체 또는 구현체의 인스턴스가 생성될 때, 어떤 타입을 다룰지 결정하게 됩니다.
변성
List<out T> 의 형태에서 T를 타입 파라미터 혹은 타입 인자 라고 부릅니다. 제네릭의 타입 파라미터 앞에 붙은 out 은 변성의 개념중 공변성을 나타내기 위한 키워드 입니다. 또, List 와 같은 제네릭 인터페이스나 클래스를 기저타입 이라고 부릅니다.
List<Number> 을 예시로 들면, List 는 기저타입, 변성은 선언부에서 out 이 붙은 공변성, 그리고 타입 파라미터는 Number 가 됩니다. 그렇다면, List<Number> 타입의 함수 인자로 정수 리스트를 전달할 수 있을까요? 정답은 가능하다 입니다. 다른 예시로, Array<Number> 타입의 함수 인자로 정수 리스트를 전달할 수 있을까요? 정답은 불가능하다 입니다.
무공변성
이를 이해하려면, 변성의 개념을 먼저 이해해야 합니다. 변성이란, 제네릭 타입이 타입 파라미터의 간의 관계로 부터 어떤 영향을 받는가 를 말합니다.
기본적으로 코틀린에서 변성에 대해 아무 표시도 하지 않으면 무공변성 을 따르게 됩니다. 무공변성은 제네릭 타입이 타입 파라미터 간의 관계로 부터 아무런 영향도 받지 않습니다. 따라서, 실제로 타입 파라미터간에 부모-자식 관계를 갖더라도, 제네릭은 그렇지 않게 됩니다.
앞서 본, Array<Number> 는 Array<T> 로, 무공변성을 따릅니다. 따라서, Int 는 Number 의 하위타입 이지만, Array<Number> 와 Array<Int> 는 아무런 관계가 없고 함수 인자로 전달할 경우 컴파일 에러가 발생하게 됩니다.
fun test(b: Array<Number>) {
b.set(4, 1)
}
fun abcd() {
val array: Array<Int> = arrayOf(1,2)
test(array) // --> Compile Error 발생: Type mismatch. Required: Array<Number> Found: Array<Int>
}
이렇게 되는 이유는 타입 안정성과 관련이 있습니다. 더 자세한 내용은 이후 설명에서 더 다루겠습니다.
공변성
그에 비해, List는 공변성을 나타내는 out 키워드를 선언부에 작성했습니다.(이를 “선언 지점 변성” 이라고도 합니다.) 공변성은 제네릭 타입간의 관계가 타입 파라미터 간의 관계를 유지하는 것 을 의미합니다. 따라서, 타입 파라미터간에 부모-자식의 관계를 가졌다면 제네릭에서도 부모-자식 관계를 유지하기 때문에, Number 의 하위타입인 Int 타입 파라미터를 가지는 List를 List<Number> 함수 인자로 전달할 수 있습니다.
fun test(a: List<Number>) {
a.get(0)
}
fun abcd() {
val list: List<Int> = listOf(1,2)
test(list) // --> Compile success
}
반공변성
마지막으로 제네릭 타입 파라미터 앞에 in 키워드를 명시하면 반공변성을 나타냅니다. 이는 제네릭 타입간의 관계가 타입 파라미터간의 관계에서 역전되는 것 을 말합니다. 즉, 부모-자식 관계가 역전되어 MutableList<in Number> 를 함수 인자로 요구하는 경우, MutableList<Int>를 전달할 수 없습니다.
fun test(a: MutableList<in Number>) {
// TODO
}
fun abcd() {
val list: MutableList<Int> = mutableListOf(1,2)
test(list) // --> Compile Error 발생: Type mismatch. Required: MutableList<Number> Found: MutableList<Int>
}
여기서 예시가 조금 달라졌습니다. List 가 아닌, MutableList 로 반공변성의 예시를 들었습니다. 만약 MutableList 가 아닌, List를 함수 인자로 받을 때, in 키워드로 프로젝션(제약을 가함)할 경우(이를 사용 지점 변성 이라고 합니다.), 다음과 같은 컴파일 에러가 발생합니다.
Projection is conflicting with variance of the corresponding type parameter of List. Remove the projection or replace it with ‘*‘
위의 오류는 선언부(클래스)에서 읽기 전용(out)이라 명시한 상태에서, 사용부(함수)에서 반대의 변성(in)을 지정하여 충돌이 발생했다는 컴파일 에러 입니다. 이러한 오류가 발생한 이유는 List의 경우 변경 가능한 연산자와 기능이 제공되지 않는 읽기 전용 이고, 따라서 선언 지점에서 out 키워드를 명시하였는데, 사용 지점에서 in-프로젝션하여 충돌이 발생됬기 때문입니다.
읽기(out) 전용
List 는 읽기와 관련된 기능만 제공하는 읽기 전용이고, 그렇기 때문에 out 이라는 키워드를 명시한 것을 보았습니다. 그리고 앞서 out 이 붙으면 공변성을 따르고, 제네릭 타입간의 관계가 타입 파라미터의 관계를 유지한다는 점도 이해했습니다. 그렇다면 공변성은 왜 제네릭 타입간의 관계가 유지되게 하는 걸까요?
fun test(a: List<Number>): Number {
return a.get(0)
}
fun abcd() {
val list: List<Int> = listOf(1,2)
test(list) // --> Compile success
}
해당 예시를 다시 살펴보면, List<Number> 를 파라미터로 요구하는 test 함수로 List<Int> 를 전달하고 있습니다. test 함수 내부에서는 단순히 리스트의 첫번째 요소를 읽어 해당 값을 다시 Number 타입으로 반환하기 때문에 문제가 발생하지 않습니다.(Int 는 Number의 하위타입이고, List 는 공변성을 따르므로 타입 파라미터간의 관계를 유지하여 전달 받을 수 있으며, 값인 Int 도 Number 의 하위타입이기 때문에 다형성의 원리에 따라 캐스팅 가능합니다.)
만약, 반대로 List<Int> 를 함수 인자로 요구하는 상황에서 List<Number> 를 전달했다면 어땠을까요? Number 에는 숫자로써 Int 뿐만아니라 Double, Long, Float 과 같은 숫자들도 모두 포함됩니다. 따라서 공변성의 원리 없이 컴파일 단계에서 에러를 노출하지 않았다면, Int 와의 부모-자식 관계가 아닌 전혀 다른 타입에 대해서 전달받아 첫번째 요소를 꺼내어 Int 로 캐스팅 하려고 할 때, 런타임 예외가 발생했을 것 입니다.
여기서 중요한 점은, 해당 타입을 꺼내서 반환한다는 점입니다. 반환하는 자리 즉, 리턴타입의 위치를 out 위치(생산하는 위치) 라고 얘기하며, out 키워드가 달린 공변성의 타입 파라미터는 항상 out 위치에만 존재할 수 있습니다. 반대로 in 키워드가 달린 반공변성은 함수의 매개변수 위치(소비하는 위치)에만 존재할 수 있으며, 해당 내용은 밑에서 다루겠습니다.
이와 별개로, 여기서 test 함수를 호출하는 부분에서는 왜 예외가 발생하지 않느냐는 생각이 들 수 있습니다. 컴파일단계에서 오류를 발생시키지 않을 경우, 런타임에서는 제네릭의 타입 파라미터를 소거하여 기억하지 않기 때문에 List 에 대해서는 알지만 타입 파라미터는 알지 못합니다. (그렇기 때문에 컴파일 타임에서 타입 안정성을 검사하는 것은 매우 중요하고, 런타임에 캐스팅에 대한 안정성을 높이기 위해 이를 알아야 한다면 reified 키워드를 명시할 수도 있습니다. 해당 내용은 다음 챕터에서 다루겠습니다.)
따라서 List 를 요구하는 test 함수 호출에 List를 전달하기 때문에, 여기서는 문제가 발생하지 않으며, 값을 반환할 때 타입 파라미터의 타입으로 캐스팅하면서 예외가 발생한다는 점을 추가로 기억해 두시면 좋을 것 같습니다.
fun test(a: List<Int>): Int { // --> 만약, 상위타입이 전달이 가능했다면?
return a.get(0) // --> 여기서 런타임에 Cannot be Casting Exception 발생
}
fun abcd() {
val list: List<Number> = listOf(1.0f, 2L, 3.0)
test(list)
}
쓰기(in) 전용
out 키워드를 명시하여 공변성을 따르는 제네릭이 왜 타입 파라미터간의 관계를 유지하게 되는지 이해했습니다. 그렇다면 반대로 반공변성도 한번 살펴보겠습니다.
open class GrandParent
open class Parent : GrandParent()
class Child : Parent()
class Uncle : GrandParent()
fun inputData(input: Parent, dest: MutableList<in Parent>) {
dest.add(input)
}
fun test() {
inputData(input = Parent(), dest = mutableListOf<GrandParent>())
}
해당 코드는 증조, 증조를 상속하는 부모, 증조를 상속하는 삼촌, 마지막으로 부모를 상속하는 자식 클래스가 있을 때, 제네릭 리스트에 삽입(소비)하는 예시입니다. 쓰기(소비)전용으로 만들기 위해 inputData 함수의 dest 타입인 MutableList 에 in-프로젝션을 적용했습니다.
위 상황에서, Parent 타입 파라미터를 요구하는 MutableList 에 GrandParent 타입 파라미터의 MutableList 를 전달하는 경우, 삽입(소비)하는 MutableList 의 타입 파라미터(GrandParent)가 삽입하는 Parent 인스턴스의 부모 이기 때문에 아무런 문제가 없습니다.
하지만, Parent 타입 파라미터의 MutableList 를 요구하는 함수 인자에 Child 타입 파라미터의 MutableList 를 전달하면, 상속관계에서 부모 인스턴스인 Parent() 는 자식인 Child() 로 바로 캐스팅 될 수 없기 때문에 예외가 발생합니다.
fun inputData(input: Parent, dest: MutableList<in Parent>) {
dest.add(input)
}
fun test() {
inputData(input = Parent(), dest = mutableListOf<Child>()) // --> 캐스팅 예외 발생: Child cannot be casting to Parent
}
다운캐스팅은 자식을 부모로 캐스팅한 후 복구하기 위해 다시 자식으로 캐스팅할 때 가능하기 때문입니다. 따라서, 런타임에 발생할 수 있는 예외를 반공변성에 의해 “Type mismatch - Required: Parent, Found: Child” 라는 컴파일 오류로 발생시켜 주는 것 입니다.
이를 통해 좀 더 타입에 대한 안정성을 미리 확보할 수 있습니다.
이처럼 위에서 잠깐 보았듯이, 함수의 매개변수(in 위치)로 타입 파라미터의 타입을 입력받아 소비하는 경우, 제네릭에서 동등하거나, 그보다 더 상위 타입이어야만 문제가 발생하지 않습니다.
물론, 현재 예시로 든 MutableList 는 무공변성 입니다. 이제 이 인터페이스의 내부에 존재하는 기능들을 한번 살펴보겠습니다.
public interface MutableList<E> : List<E>, MutableCollection<E> {
override fun add(element: E): Boolean // <<- in 위치
override fun remove(element: E): Boolean // <<- in 위치
override fun addAll(elements: Collection<E>): Boolean // <<- in 위치
public fun addAll(index: Int, elements: Collection<E>): Boolean // <<- in 위치
override fun removeAll(elements: Collection<E>): Boolean // <<- in 위치
override fun retainAll(elements: Collection<E>): Boolean // <<- in 위치
override fun clear(): Unit
public fun add(index: Int, element: E): Unit // <<- in 위치
public fun removeAt(index: Int): E // <<- Out 위치
override fun listIterator(): MutableListIterator<E> // <<- Out 위치
override fun listIterator(index: Int): MutableListIterator<E> // <<- Out 위치
override fun subList(fromIndex: Int, toIndex: Int): MutableList<E> // <<- Out 위치
}
해당 인터페이스는 함수의 입력 매개변수 위치(in 위치)와 리턴 위치(out 위치) 에 타입 파라미터 E 를 위치시킴으로써 읽기(out) 와 쓰기(in) 의 모든 기능을 제공하기 때문에 타입 안정성에 대한 문제로 자신의 타입만 받을 수 있는 무공변성으로 선언된 것을 확인할 수 있습니다.
변성을 통한 타입 안정성
결론적으로, “제네릭은 타입에 관계없이 순수한 기능과 속성에 대한 일반화 개념이고, 객체지향 프로그래밍 내에서 타입을 생성 및 호출 시점에 결정 시킴으로써 발생하는 타입 안정성 문제를 해결하기 위해 변성이라는 성질을 사용한다” 고 정리할 수 있을 것 같습니다.
그렇다면, 클래스나 인터페이스는 선엄 지점 변성을 통해 타입 안정성을 확보할 수 있지만, 사용지점변성을 이용할 수 없거나 사용하지 않고 어떻게 타입 안정성을 높일 수 있을까요?
제네릭 함수
앞서 설명했듯이, 자바의 제네릭은 런타임에서 타입 파라미터의 정보를 소거하기 때문에 제네릭에 대한 정보만 알 뿐 타입 파라미터에 대한 정보는 알지 못합니다. 따라서, 소거되었기 때문에 인스턴스의 타입을 검사할 수 없다고 컴파일 에러가 발생합니다.
fun <T> test(a: List<T>) {
when (a) {
is List<String> -> // TODO --> Cannot check for instance of erased type: List<String>
is List<Int> -> // TODO --> Cannot check for instance of erased type: List<Int>
}
}
그러면서 IDEA 는 Change type arugments to <*> 라는 것을 하도록 추천합니다. 이것이 의미하는 게 뭘까요? 바로 스타 프로젝션 입니다.
스타 프로젝션
스타 프로젝션은 제네릭의 타입 파라미터를 정확히 알 수 없는 경우 사용합니다. 정확히 알 수 없다는 것은 타입 파라미터가 존재하긴 하지만, 무엇인지 알 수 없음을 의미합니다. 무엇인지 모르기 때문에 MutableList<*> 에는 값을 넣을 수 없는 읽기 전용 으로 사용됩니다. 타입 파라미터가 무엇인지 모르는 상태에서 아무 값이나 넣으면 문제가 생길 수 있기 때문입니다.
스타 프로젝션은 무슨타입 파라미터 인지는 모르지만, 해당 제네릭에 어떤 값이 존재한다는 것은 분명합니다. 따라서 코틀린에서 최상위 타입인 Any? 의 하위타입인 어떤 타입을 읽을 수 있다는 것을 의미하게 됩니다. 이는 정확히 스타 프로젝션이 <out Any?> 로 동작함 을 의미합니다.
스타 프로젝션을 이용하면, List에 is 나 as 를 사용해도 컴파일 에러가 발생하지 않게 됩니다. 하지만, List<*>의 의미는 결국 List 이지만 타입 파라미터는 알지 못하기 때문에 타입 파라미터에 대한 캐스팅에서는 런타임에 잠재적 예외가 발생할 수 있습니다.
fun <T> test(a: List<T>) {
if(a is List<*>)
a.first() as String // --> T 가 String 이 아닌 경우, 런타임에 ClassCastException 예외 발생
}
이럴 때 타입 파라미터에 대해 실체화(reified)하게 되면, 런타임에 대한 타입 안정성을 확보할 수 있습니다.
실체화된 타입
실체화는 inline 함수, 프로퍼티 또는 클래스에 제네릭 타입 파라미터로 reified 키워드를 함께 명시하는 형태로 선언할 수 있습니다.
inline fun <reified T> test(a: Any): Boolean {
return a is T
}
fun main() {
test<String>("")
}
타입 검사 또는 캐스팅(is 또는 as) 뿐만 아니라, reified 키워드는 자바 클래스에 접근하거나, 코틀린 리플렉션을 이용할 때도 사용할 수 있습니다.
inline fun <reified T> T.getClassName(): String = T::class.java.name
댓글남기기