티스토리 뷰

kotlin

kotlin에서의 generics

LichKing 2019. 11. 13. 14:36

기본적으로 kotlin의 generics 는 자바와 많은 부분에서 비슷하다. 하지만 몇가지 달라진점, 추가된점들이 있는데 그 것들을 정리한다. 기본적으로 자바의 generics 을 어느정도 이해하고있다는 가정하에 얘기가 진행되므로 generics 전체를 자세히 설명하지는 않는다.

 

1. Star Projection

* 를 이용해서 표현한다. 특정 타입을 지정하지않을때 사용하며, 자바의 와일드카드라고 생각하면 된다. 와일드카드와 동일하게 타입을 캡쳐하지않기때문에 타입안정성을 보장받지못한다. 다만 자바의 와일드카드처럼 한정적 와일드카드(bounded wildcard) 문법은 지원하지않는다.

// 와일드카드처럼 사용 가능.
val list: List<*> = listOf(1, 2, 3)

// 타입을 캡쳐하지않기때문에 엘리먼트가 Int 임을 알지못한다. 때문에 아래 코드는 컴파일 불가.
val element: Int = list1[0]

// bounded wildcard 를 지원하지않기때문에 아래같은 코드는 사용 불가.
val list1: List<*: Number> = listOf(1, 2, 3)
val list1: List<* extends Number> = listOf(1, 2, 3)

기존 자바에서 bounded wildcard 를 이용해 구현했던 공변(covariance)/반공변(contra variance)은 다른 문법으로 지원한다. 이에 대한 내용은 아래에서 다룬다.

 

2. Type Reified

자바에서는 제네릭으로 전달되는 타입 파라미터를 구체화하지 못한다. 그래서 아래와 같은 표현은 사용 불가하다.

<T> void method() throws Exception {
  // T 타입에 대한 정보를 런타임에 얻을 수 없기때문에 컴파일 불가.
  T t = T.class.newInstance()
}

<T> void method(Class<T> clazz) throws Exception {
  // 이처럼 T 타입에 대한 정보를 갖고있는 Class 객체를 전달해서 호출.
  T t = clazz.newInstance()
}

 

코틀린도 자바와 동일하게 기본적으로는 타입 파라미터의 정보는 런타임에 사라진다. 다만 inline 과 reified 라는 제어자를 이용해서 T 타입에 대한 정보를 가져올 수 있다. 이는 코틀린이 대단한걸 구현했다기보다는 코틀린 컴파일러가 inline 제어자가 붙은 함수에 대해 바이트코드를 직접 복붙해주기때문에 가능한 syntax sugar 이다.

inline fun <reified T> method() {
    val t: T = T::class.java.newInstance()
}

자바에서 Class<T> 타입을 메서드 파라미터로 받는 이유중 상당수는 타입 파라미터를 구체화할 수 없기때문이다. 그래서 주로 타입을 알아야하는 deserialize 관련 코드에 많이 등장한다. 대표적인 예로들면 json 을 이용하는 Jackson 이나 Gson 같은 라이브러리들이다.

String json = "{\"age\":31}";
ObjectMapper objectMapper = new ObjectMapper();
// readValue 는 그 자체로 제네릭 메서드지만 타입을 구체화할 수 없으므로 deserialize 해야할 타입의 Class 정보가 따로 필요하다.
Person person = objectMapper.readValue(json, Person.class);

코틀린은 type reified 와 강력한 타입 추론을 통해 평소 원했던대로 아래와 같이 사용할 수 있다.

val mapper = ObjectMapper()
val json = "{\"age\":31}"
// 메서드에 타입 아규먼트를 전달해 타입을 구체화할 수 있다.
val person1 = mapper.readValue<Person>(json)
// 메서드가 아닌 리턴받는 변수에 타입을 명시함으로써 타입 추론을 통해 타입을 구체화할 수 있다.
val person2: Person = mapper.readValue(json)

둘 중 어느 방법을 사용해도 자바를 사용할때처럼 Class<T> 를 직접 전달하지않아도 된다. 물론 함수가 inline + reified 로 구현되지않았다면 자바를 사용할때처럼 타입을 직접 전달해줘야한다.

 

3. use-site variance, declaration-site variance

공변/반공변을 직접적으로 다루기전에 사용하는 위치에 대해 먼저 얘기해보자. 자바에서는 공변/반공변을 다루기 위해 변수를 선언할때 사용한다.

public boolean addAll(Collection<? extends E> c)
public void sort(Comparator<? super E> c)

ArrayList에 정의된 addAll() 메서드와 sort() 메서드이다. PECS(Producer-Extends Consumer-Super) 규칙에 의해 producer 인 Collection 에는 extends 가(공변), consumer 인 Comparator 에는 super 가(반공변) 적용되어있다. 이렇게 사용하는 곳에서 공변/반공변을 지정하는걸 use-site variance 라고 표현한다. 자바는 언어 문법차원에서 use-site variance 만 지원하고있다. 이에 반해 코틀린은 declaration-site variance 문법을 지원하고있다. 이는 선언할때 공변/반공변을 지정한다는 내용이다.

 

PECS 규칙은 사용이 어려운 제네릭을 조금이라도 더 쉽게 사용하자는 의미에서 제안된 규칙이지만 PECS 규칙 자체가 오히려 어렵기도했었다. 다만 쉽게 설명하자면 T 타입이 return 타입에 들어가면 producer, 파라미터에 들어가면 consumer 라고 볼 수 있다. 예를들어 List 의 get() 메서드는 producer 이고, List 의 add() 메서드는 consumer 인 셈이다.

 

이에 비추어 볼때 기본적으로 불변(immutable)이자 읽기전용(read-only) 인 코틀린 표준 라이브러리의 List<T> 는 producer 역할만 한다고 볼 수 있다.

 

잠깐 얘기가 샜는데 결론은 코틀린은 클래스를 선언하는 시점에 공변/반공변을 지정할 수 있으며 List<T> 처럼 반공변을 지원할 필요가 없는 타입의 경우(코틀린의 List<T> 는 immutable 이기때문에 add() 와 같은 consumer 메서드가 존재하지않는다.) 선언 시점에 공변을 지정 할 수 있다.

public interface List<out E>

위는 코틀린 표준 라이브러리의 List<T> 인터페이스이며, out 으로 공변지원을 나타내고있다. 참고로 반공변은 in 제어자를 써서 표현한다. List<T> 는 타입 자체가 공변 타입이기때문에 별도의 구문 없이 아래와 같은 코드를 작성할 수 있다.

fun main() {
    val list: List<Int> = listOf(1, 2, 3)
    method(list)
}

fun method(list: List<Any>) {}

이 코드가 당연히 컴파일 되는걸 보고 이상하게 생각하지않을 수 있다. "Any 는 자바의 Object 같이 모든 타입의 조상이므로 List<Any> 가 List<Int> 를 받는게 당연한거 아니야?" 라고 생각하는 사람이 있다면 제네릭의 불공변(Invariant)을 알아보자. 참고로 아래의 자바 코드는 컴파일 되지않는다.

public static void main(String[] args) {            
    List<Integer> list = Arrays.asList(1, 2, 3);
    // List<Object> 와 List<Integer> 는 상하위관계가 아니라 그냥 다른 타입이다.
    method(list);                                   
}                                                   
                                                    
static void method(List<Object> list) {}            

자바에서 위 코드를 컴파일 되게 하려면 다음과 같이 작성해줘야한다.

public static void main(String[] args) {               
    List<Integer> list = Arrays.asList(1, 2, 3);       
    method(list);                                      
}                                                      

// 참고로 List<? extends Object> 는 List<?> 와 같다.
static void method(List<? extends Object> list) {}     

List<Object> 로 선언된 파라미터에 List<Integer> 를 보낼 수 있다는게 바로 공변을 적용한 것이며 사용처에만 공변을 적용할 수 있는(use-site variance) 자바는 저런식으로 항상 사용할때마다 작성해주어야 하지만 선언처에 적용할 수 있는(declaration-site variance) 코틀린은 클래스(인터페이스 포함) 선언처에 표현할 수 있다.

 

참고로 코틀린에서도 사용처에 공변을 선언할 수 있다.

fun main() {
    val list: MutableList<Int> = mutableListOf(1, 2, 3)
    method(list)
}

// 자바와 동일하게 사용처에 out을 명시해서 공변을 적용했다.
fun method(list: MutableList<out Any>) {}

 

4. 공변/반공변(covariance/contra variance)

위에서 거의 다 설명하긴했는데... 자바에서는 와일드카드와 extends/super 제어자를 이용해서 공변/반공변을 지원하는 반면 코틀린에서는 out/in 제어자를 이용한다. 이때문에 자바의 와일드카드와 비슷하게 사용되는 star projection 의 활용도가 자바보다는 많이 적어지게된다.

 

위에서 설명한대로 코틀린은 declaration-site variance 를 지원하기때문에 producer 혹은 consumer 역할만 하는 클래스에 대해선 선언부에 공변/반공변을 선언해서 편하게 사용할 수 있다. 대표적인 클래스(인터페이스)가 producer 역할만 하는 List<T> 인터페이스이다. 하지만 get() 과 add() 를 모두 지원하는 MutableList<T> 의 경우엔 producer 역할하는것도, consumer 역할만 하는것도 아니다. 이런 클래스에 대해선 당연하게도 선언시점에 설정을 할 수 없다.

expect class ArrayList<E> : MutableList<E>

out/in 을 이용해서 공변/반공변을 선언해놓으면 이후 컴파일러는 더욱 철저한 검사를 해준다. 가령 producer 역할을 할것으로 기대해 공변을 선언한 클래스가 consumer 역할을 한다거나 하는 일이 생기면 컴파일 에러를 발생시킨다.

class CustomList<out T> {
    // 공변으로 선언해 놓고 소비(consume) 하는 위치에 사용되면 컴파일 불가.
    fun add(t: T) {}
}

class CustomList<in T> {
    // 반공변으로 선언해 놓고 생산(produce) 하는 위치에 사용되면 컴파일 불가. 
    fun add(): T? { return null }
}

이는 함수 레벨에서 use-site variance 로 사용했을때도 마찬가지다.

fun <T> method(list: MutableList<out T>) {
    list.add(Any())
}

fun <T> method(list: MutableList<in T>) {
    val e: T = list[0]
}

이 두 함수는 모두 컴파일 되지 않는다.

 

이처럼 좀 더 쉬운 문법과 타이트한 컴파일러로 인해 제네릭을 사용하기가 자바에 비해 좀 더 편해졌다고 볼 수 있다.

 

참고자료

- kotlin in action

- kotlinlang.org

 

 

 

댓글
댓글쓰기 폼