Search

Generics: in, out, where

Kotlin 클래스는 Java와 마찬가지로 타입 파라미터를 가질 수 있다.
class Box<T>(t: T) { var value = t }
Kotlin
복사
이러한 클래스의 인스턴스를 생성하려면 타입 파라미터를 명시적으로 지정하면 된다.
val box: Box<Int> = Box<Int>(1)
Kotlin
복사
하지만 생성자 인수 등에서 타입을 유추할 수 있는 경우 타입 파라미터를 생략할 수 있다.
val box = Box(1) // 1 has type Int, so the compiler figures out that it is Box<Int>
Kotlin
복사

변성

Java의 타입 시스템에서 가장 까다로운 측면 중 하나는 와일드카드 유형이다.
Kotlin에는 와일드카드가 없는 대신 선언-사이트 가변성타입 투영이 있다.

Java에서의 분산과 와일드카드

Java에 와일드카드가 필요한 이유는 Java의 제네릭 타입은 불변적이다.
불변적은 List<String> 이 List<Object>의 하위 타입이 아님을 의미한다.
만약 List가 공변적이라면 아래 코드가 컴파일되고 런타임에 예외를 발생시킨다.
List<String> strs = new ArrayList<String>(); // Java는 컴파일 타임에 타입 불일치를 보고합니다. List<Object> objs = strs; // 만약 이 코드가 컴파일 되었다면? // 우리는 String의 리스트에 Integer를 넣을 수 있었을 것입니다. objs.add(1); // 그리고 런타임에 Java는 // ClassCastException을 발생시킵니다: Integer cannot be cast to String String s = strs.get(0);
Kotlin
복사
Java는 이런 문제를 방지하기 위해 제네릭 타입을 불변으로 유지하여 런타임 안전성을 보장한다.
그러나 이는 몇가지 제약을 가지며 Collection 인터페이스의 addAll() 메서드를 예를 들 수 있다.
interface Collection<E> ... { void addAll(Collection<E> items); } void copyAll(Collection<Object> to, Collection<String> from) { to.addAll(from); // 컴파일되지 않습니다. }
Kotlin
복사
위 코드는 컴파일되지 않고 이를 해결하기 위해 실제 addAll()은 이렇게 작성되어있다.
interface Collection<E> ... { void addAll(Collection<? extends E> items); }
Kotlin
복사
와일드카드 타입인 ? extends E 는 이 메서드가 E의 하위 타입을 포함한 객체의 컬렉션을 받아들일 수 있음을 나타낸다.
이는 E의 하위 타입의 인스턴스를 안전하게 읽을 수 있지만 해당 컬렉션에 쓰기는 할 수 없음을 의미한다.
이러한 방식으로 와일드카드의 extends 상한 경계는 타입을 공변적으로 만들 수 있다.
컬렉션에서 항목을 가져오기만 한다면 String의 컬렉션을 사용하고 Object를 읽는 것은 문제가 없다.
반대로 컬렉션에 항목을 추가만 한다면 String이나 그 슈퍼타입을 받아들이는 List<? super String>을 사용하는 것이 안전하다.
Effective Java 의 저자인 Joshua Bloch 는 이런 상황에서 공변성과 반공변성의 개념을 생산자와 소비자 개념으로 설명한다.
생산자(Producer) : 데이터를 제공하는 객체
소비자(Consumer) : 데이터를 사용하는 객체
이를 바탕으로 PECS(Producer-Extends, Consumer-Super)라는 mnemonic을 제안한다.
List<? extends Foo> 생산자 객체를 사용하는 경우 .add(), .set()을 호출할 수 없지만 이것은 객체가 변경 불가능하다는 것을 의미하지는 않는다. 예를 들어, clear()는 매개변수를 전혀 받지 않으므로 목록에서 모든 항목을 제거할 수 있다. 와일드카드가 보장하는 유일한 것은 타입 안전성이고 불변성은 완전히 다른 이야기이다.
파라미터로 T를 받는 메서드가 없고 오로지 T를 반환하는 메서드만 있는 Source<T> 제네릭 인터페이스가 있다고 가정해보자.
// Java interface Source<T> { T nextT(); }
Kotlin
복사
그러면 Source<Object> 타입의 변수에 Source<String>의 인스턴스에 대한 참조를 저장하는 것은 소비자 메서드가 호출되지 않기 떄문에 안전할 것이다.
하지만 Java는 타입 안전성을 보장하는데 초점을 맞추어 이것을 금지하고 있다.
// Java void demo(Source<String> strs) { Source<Object> objects = strs; // !!! Not allowed in Java // ... }
Kotlin
복사
Java에서 Source<String>을 Source<Object>로 사용하는 것이 불가능한 이유는 Java의 타입 시스템이 사용 지점 가변성을 사용하기 때문이다.
이는 특정 타입 변수가 사용되는 지점에서 공변성과 반공변성을 결정하고 Java는 복잡한 와일드카드와 제네릭 타입 변환 규칙을 사용하여 이 문제를 해결하려고 한다.

Kotlin에서의 선언 지점 가변성

Kotlin에서는 이 문제를 선언 지점 가변성을 통해 해결한다.
제네릭 타입의 타입 파라미터를 선언할 때 inout 키워드를 사용하여 공변성과 반공변성을 명시적으로 지정할 수 있다.
소비자는 in, 생산자는 out!

in

제네릭 타입 파라미터가 반공변일 때 in 키워드를 사용하여 지정하고 이는 타입 파라미터가 오직 입력 파라미터로만 사용될 수 있음을 의미한다.
interface Comparable<in T> { operator fun compareTo(other: T): Int }
Kotlin
복사
fun demo(x: Comparable<Number>) { x.compareTo(1.0) // 1.0은 Double 타입이며 Number의 하위 타입 val y: Comparable<Double> = x // OK }
Kotlin
복사
Comparable<Number>를 Comparable<Double>로 변환할 수 있는 이유는 Comparable이 T를 소비하기만 하고 반환하지 않기 때문이다.
또한 Comparable<Number>가 Comparable<Double>의 상위 타입이 될 수 있기 때문이다.

out

제네릭 타입 파라미터가 공변일 때 out 키워드를 사용하여 지정하고 이는 타입 파라미터가 오직 반환만 될 수 있고 소비될 수 없다는 것을 의미한다.
interface Source<out T> { fun nextT(): T }
Kotlin
복사
fun demo(strs: Source<String>) { val objects: Source<Any> = strs // OK // ... }
Kotlin
복사
Source<String>을 Source<Any> 변수에 할당할 수 있는 이유는 Source가 T를 생산하지만 소비하지 않고 반환만 하기 때문이다.
또한 Source<String>이 Source<Any>의 하위 타입이기 때문이다.

타입 프로젝션

제네릭 타입을 사용하는 코드에서 타입 불일치 문제를 해결할 수 있는 방법이다.

사용 지점 가변성

제네릭 타입의 가변성을 정의할 때 in, out 키워드를 사용하지만 때때로 제네릭 타입 자체가 가변성을 지원하지 않는 경우가 있다.
class Array<T>(val size: Int) { operator fun get(index: Int): T { ... } operator fun set(index: Int, value: T) { ... } }
Kotlin
복사
Array<T>는 T에 대해 공변성이나 반공변성을 지원하지 않는다.
fun copy(from: Array<Any>, to: Array<Any>) { assert(from.size == to.size) for (i in from.indices) to[i] = from[i] }
Kotlin
복사
Array<Int>와 Array<Any>를 사용할 때 Array<Int>와 Array<Any>는 서로 하위 타입이 아니기 때문에 문제가 발생할 수 있다.
fun copy(from: Array<out Any>, to: Array<Any>) { assert(from.size == to.size) for (i in from.indices) to[i] = from[i] } // 실제 적용 val ints: Array<Int> = arrayOf(1, 2, 3) val any = Array<Any>(3) { "" } copy(ints, any) // ^ type is Array<Int> but Array<Any> was expected
Kotlin
복사
이를 해결하기 위해 Array<out Any>와 같은 타입 프로젝션을 사용할 수 있다.
Array<out Any>는 from 배열이 Any 또는 그하위 타입을 반환할 수 있도록 제한된다는 의미이다.
이 경우 from 배열에서 get() 메서드만 호출할 수 있으며 set() 메서드는 호출할 수 없다.
fun fill(dest: Array<in String>, value: String) { // `dest`에 `value`를 안전하게 쓸 수 있습니다. }
Kotlin
복사
반대로 in 키워드를 사용하여 Array<in String> 과 같은 프로젝션을 정의할 수도 있다.
이는 Array에 String을 쓸 수 있도록 하지만 읽을 때는 제한적일 수 있다.
여기서 Array<in String>은 String 또는 그상위 타입인 CharSequence, Object 와 같은 타입을 사용할 수 있다.

스타 프로젝션

제네릭 타입의 타입 파라미터를 모르는 상황에서 안전하게 사용하고자 할 때 유용하다.
* 기호를 사용하여 스타 프로젝션을 정의할 수 있다.
동작 방식은 다음과 같다.
공변성 (out T)
Foo<out T : TUpper>에서 Foo<*>Foo<out TUpper>와 같다.
T가 어떤 타입이든지 Foo<*>에서 TUpper 타입의 값을 안전하게 읽을 수 있다는 것이다.
반공변성 (in T)
Foo<in T>에서 Foo<*>Foo<in Nothing>과 같다.
T가 어떤 타입이든지 Foo<*>에서 값을 쓸 수 없다는 것이다.
불변성 (T)
Foo<T : TUpper>에서 Foo<*>Foo<out TUpper>로 읽기에는 적합하고 Foo<in Nothing>으로 쓰기에는 적합하다.
interface Function<in T, out U>
Kotlin
복사
Function<*, String>
Function<in Nothing, String>과 같다.
T를 알 수 없기 때문에 T에 값을 쓸 수 없고 U로 String을 안전하게 읽을 수 있다.
Function<Int, *>
Function<Int, out Any?>와 같다.
T는 Int로 고정되고 U는 어떤 타입으로든 읽을 수 있다.
Function<* ,*>
Function<in Nothing, out Any?>와 같다.
T와 U의 타입이 모두 불확실하므로 T에 값을 쓰거나 U를 읽을 수 있다.

제네릭 함수

함수 정의 시에 타입 파라미터를 사용하는 함수이다.
이러한 함수는 타입에 구애받지 않고 다양한 타입을 처리할 수 있도록 설계된다.
제네릭 함수를 사용하면 함수의 타입을 호출 시점에 구체적으로 지정할 수 있으며, 타입에 의존하지 않는 코드를 작성할 수 있다.

제네릭 함수 정의하기

제네릭 함수를 정의할 때는 함수 이름 앞에 타입 파라미터를 선언한다.
타입 파라미터는 일반적으로 <T>와 같은 형태로 표현되며 T는 타입 파라미터의 이름으로 실제 호출 시에 구체적인 타입으로 대체된다.
// 기본 제네릭 함수 fun <T> singletonList(item: T): List<T> { return listOf(item) } // 확장 함수로써 제네릭 함수 fun <T> T.basicToString(): String { return this.toString() }
Kotlin
복사

제네릭 함수 호출하기

제네릭 함수를 호출할 때는 타입 인자를 지정할 수 있으며 호출 시점에 타입을 추론할 수 있다.
// 기본 제네릭 함수 호출 val l = singletonList<Int>(1) // 타입 추론 제네릭 함수 호출 val l = singletonList(1)
Kotlin
복사

제네릭 제약 조건

주어진 타입 파라미터를 대체할 수 있는 모든 기능 유형의 집합은 제네릭 제약조건에 의해 제한될 수 있다.

상한

가장 일반적인 제약 유형은 상한값으로 Java의 extends 키워드에 해당된다.
fun <T : Comparable<T>> sort(list: List<T>) { ... }
Kotlin
복사
콜론 뒤에 지정된 타입은 상위 경계이며 T의 하위 타입만 Comparable<T>로 대체될 수 있음을 나타낸다.
sort(listOf(1, 2, 3)) // OK. Int is a subtype of Comparable<Int> sort(listOf(HashMap<Int, String>())) // Error: HashMap<Int, String> is not a subtype of Comparable<HashMap<Int, String>>
Kotlin
복사
기본 상한값(지정된 것이 없는 경우)은 Any? 이다.
<> 안에는 상한값을 하나만 지정할 수 있고 같은 타입 파라미터에 상한값이 두 개 이상 필요한 경우 별도의 where 절이 필요하다.
fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String> where T : CharSequence, T : Comparable<T> { return list.filter { it > threshold }.map { it.toString() } }
Kotlin
복사
전달된 타입은 where 절의 모든 조건을 동시에 만족해야한다.
위의 예에서 T 타입은 CharSequence 와 Comparable을 둘 다 구현해야한다.

명확한 non-nullable 타입

Java와의 상호 운용성을 고려하여 Kotlin은 제네릭 타입 파라미터를 명확하게 non-nullable한 것으로 선언하는 것을 지원한다.
명확하게 non-nullable한 타입은 값이 null이 될 수 없는 타입을 의미한다.
제네릭 타입 T에 대한 명확하게 non-nullable한 타입을 선언하려면 & Any 와 함께 선언한다.
명확하게 non-nullable한 타입은 null을 허용하는 상한을 가져야 한다.
// Java로 작성된 인터페이스 import org.jetbrains.annotations.*; public interface Game<T> { public T save(T x) {} @NotNull public T load(@NotNull T x) {} } // Kotlin으로 작성된 하위 인터페이스 interface ArcadeGame<T1> : Game<T1> { override fun save(x: T1): T1 // T1 is definitely non-nullable override fun load(x: T1 & Any): T1 & Any }
Kotlin
복사
Kotlin에서 load() 메서드를 성공적으로 재정의하려면 T1 을 명확하게 non-nullable한 타입으로 선언해야 한다.
Kotlin으로만 작업하는 경우 Kotlin의 타입 추론이 이를 처리해주기 때문에 명시적으로 선언할 필요는 없다.

타입 소거

Kotlin에서 제네릭 선언을 사용할 때 타입 안전성 검사는 컴파일 타임에 수행된다.
런타임에서 제네릭 타입의 인스턴스는 실제 타입 인자에 대한 정보를 갖고 있지 않고 소거된다.
Foo<Bar>와 Foo<Baz?>의 인스턴스는 모두 Foo<*>로 소거된다.

제네릭 타입 검사 및 캐스팅

타입 소거로 인해 런타임에서는 특정 타입 인자로 생성된 제네릭 타입의 인스턴스를 검사할 수 있는 일반적인 방법이 없다.
컴파일러는 ints is List<Int> 나 list is T 와 같은 is 체크를 금지한다.
그러나 인스턴스를 스타 프로젝션 타입 (List<*>) 으로 검사하는 것은 가능하다.
if (something is List<*>) { something.forEach { println(it) } // 이 항목들은 `Any?`로 타입이 지정됩니다 }
Kotlin
복사
또한 인스턴스의 타입 인자가 컴파일 타임에 이미 검사된 경우 비제네릭 부분에 대해 is 체크나 캐스팅을 수행할 수 있다.
이 경우 <> 는 생략된다.
제네릭 타입 인자를 고려하지 않는 캐스팅의 경우에도 각 괄호를 생략할 수 있다. (list as ArrayList)
fun handleStrings(list: MutableList<String>) { if (list is ArrayList) { // `list`는 `ArrayList<String>`으로 스마트 캐스팅 됩니다 } }
Kotlin
복사

인라인 함수와 구체화된 타입 파라미터

제네릭 함수 호출의 타입 인자는 컴파일 타임에만 검사된다.
함수 본문 내에서는 타입 파라미터를 사용한 타입 체크는 불가능하며 foo as T 와 같은 타입 캐스팅은 검사되지 않는다.
그러나 구체화된 타입 파라미터를 사용하는 인라인 함수에서는 예외적으로 타입 검사와 캐스팅이 가능하다.
구체화된 타입 파라미터는 함수 호출 시 실제 타입 인자가 인라인되므로 타입 검사와 캐스팅이 가능하다.
inline fun <reified A, reified B> Pair<*, *>.asPairOf(): Pair<A, B>? { if (first !is A || second !is B) return null return first as A to second as B } val somePair: Pair<Any?, Any?> = "items" to listOf(1, 2, 3) val stringToSomething = somePair.asPairOf<String, Any>() val stringToInt = somePair.asPairOf<String, Int>() val stringToList = somePair.asPairOf<String, List<*>>() val stringToStringList = somePair.asPairOf<String, List<String>>() // Compiles but breaks type safety! // Expand the sample for more details
Kotlin
복사

체크되지 않은 캐스팅

타입 소거로 인해 런타임에서 foo as List<String> 같은 구체적인 제네릭 타입으로의 캐스팅은 체크되지 않는다.
이러한 캐스팅은 고수준의 프로그램 로직에서 타입 안전성이 암묵적으로 보장될 때 사용할 수 있다.
fun readDictionary(file: File): Map<String, *> = file.inputStream().use { TODO("Read a mapping of strings to arbitrary elements.") } // We saved a map with `Int`s into this file val intsFile = File("ints.dictionary") // Warning: Unchecked cast: `Map<String, *>` to `Map<String, Int>` val intsDictionary: Map<String, Int> = readDictionary(intsFile) as Map<String, Int>
Kotlin
복사
Map<String, *> 에서 Map<String, Int> 로의 캐스팅은 체크되지 않은 캐스팅이며 컴파일러는 이 캐스팅이 런타임에 안전한지 확인할 수 없다.
이런 경우엔 경고가 나타나며 프로그램 구조 재설계, 변성, 구체화된 타입 파라미터 등을 통해 체크되지 않은 캐스팅을 피하는 것이 좋다.

@Suppress(”UNCHECKED_CAST”)

체크되지 않은 캐스팅 경고를 억제하려면 @Suppress(”UNCHECKED_CAST”) 어노테이션을 사용한다.
inline fun <reified T> List<*>.asListOfType(): List<T>? = if (all { it is T }) @Suppress("UNCHECKED_CAST") this as List<T> else null
Kotlin
복사

JVM에서의 배열 타입

JVM에서는 배열 타입 (Array<Foo>)이 타입 소거 후에도 요소의 타입 정보를 일부 유지한다.
배열 타입으로의 캐스팅은 부분적으로 체크되며 요소 타입의 null 가능성 및 실제 타입 인자는 여전히 소거된다.
예를 들어, foo as Array<List<String>?> 캐스팅은 foo가 어떤 List<*>를 포함하고 있으면 성공한다.

타입 인자에 대한 밑줄 연산자

밑줄 연산자 _ 는 타입 인자를 추론하는데 사용할 수 있다.
이 연산자는 다른 타입 인자들이 명시적으로 지정되었을 때 해당 타입 인자를 자동으로 추론하도록 도와준다.
abstract class SomeClass<T> { abstract fun execute(): T } class SomeImplementation : SomeClass<String>() { override fun execute(): String = "Test" } class OtherImplementation : SomeClass<Int>() { override fun execute(): Int = 42 } object Runner { inline fun <reified S: SomeClass<T>, T> run(): T { return S::class.java.getDeclaredConstructor().newInstance().execute() } } fun main() { // T가 SomeImplementation에서 SomeClass<String>을 상속받았으므로 String으로 추론됩니다. val s = Runner.run<SomeImplementation, _>() assert(s == "Test") // T가 OtherImplementation에서 SomeClass<Int>를 상속받았으므로 Int로 추론됩니다. val n = Runner.run<OtherImplementation, _>() assert(n == 42) }
Kotlin
복사
타입 인자 T를 받는 SomeClass<T> 제네릭 추상 클래스
SomeClass<String>, SomeClass<Int>를 상속받아 구현한 SomeImplementation, OtherImplementation 클래스
SomeClass<T>를 상속받는 클래스를 받는 구조화된 타입 인자 S와 S에서 추론된 타입 인자 T
_ 를 사용함으로써 타입 인자 T를 명시적으로 지정하지 않고도 타입 인자 S를 통해 자동으로 추론해서 사용