•
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에서는 이 문제를 선언 지점 가변성을 통해 해결한다.
•
제네릭 타입의 타입 파라미터를 선언할 때 in 과 out 키워드를 사용하여 공변성과 반공변성을 명시적으로 지정할 수 있다.
•
소비자는 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를 통해 자동으로 추론해서 사용