제네릭: in, out, where
제네릭: in, out, where
클래스에 타입 파라미터를 붙일 수 있다는 점은 자바와 같아요. Kotlin 클래스도 타입 파라미터를 가질 수 있죠.
class Box<T>(t: T) {
var value = t
}
이런 클래스의 인스턴스를 만들 때는 타입 인자만 제공하면 돼요.
val box: Box<Int> = Box<Int>(1)
생성자 인자에서 타입 인자를 추론할 수 있다면(예를 들어 생성자 인자가 Int 타입이라면) 굳이 명시하지 않아도 돼요.
val box = Box(1) // 1은 Int 타입이므로 컴파일러가 Box<Int>임을 알아내요
출처: Kotlin 공식 문서
본문
변성 (Variance)
자바 타입 시스템에서 가장 까다로운 부분 중 하나가 와일드카드 타입이에요. Kotlin에는 와일드카드가 없어요. 대신 선언 지점 변성(declaration-site variance) 과 타입 프로젝션(type projections) 을 제공해요.
자바의 변성과 와일드카드
자바가 왜 이 수수께끼 같은 와일드카드를 필요로 하는지부터 생각해 볼게요. 먼저, 자바의 제네릭 타입은 불변(invariant) 이에요. 즉 List<String>은 List<Object>의 하위 타입이 아니에요. List가 불변이 아니라면 자바 배열보다 나을 게 없을 거예요. 다음 코드처럼 컴파일은 되지만 런타임에서 예외가 터지는 사태가 생기거든요.
// Java
List<String> strs = new ArrayList<String>();
// Java는 여기서 컴파일 타임에 타입 불일치를 보고해요.
List<Object> objs = strs;
// 만약 그렇지 않았다면?
// String 리스트에 Integer를 넣을 수 있었을 거예요.
objs.add(1);
// 그러면 런타임에서 자바는
// ClassCastException: Integer cannot be cast to String 을 던져요
String s = strs.get(0);
자바는 런타임 안전성을 보장하기 위해 이런 것을 금지해요. 하지만 그 결과 불편한 점이 생기죠. 예를 들어 Collection 인터페이스의 addAll() 메서드를 생각해 볼게요. 이 메서드의 시그니처는 어떻게 생겼을까요? 직관적으로는 이렇게 쓰고 싶을 거예요.
// Java
interface Collection<E> ... {
void addAll(Collection<E> items);
}
하지만 이렇게 하면 다음 코드(매우 안전한 코드인데도)를 쓸 수 없게 돼요.
// Java
// addAll을 위처럼 단순하게 선언하면 다음 코드는 컴파일되지 않아요.
// Collection<String>은 Collection<Object>의 하위 타입이 아니니까요
void copyAll(Collection<Object> to, Collection<String> from) {
to.addAll(from);
}
그래서 addAll()의 실제 시그니처는 이렇게 되어 있어요.
// Java
interface Collection<E> ... {
void addAll(Collection<? extends E> items);
}
와일드카드 타입 인자 ? extends E는 이 메서드가 E 자체뿐 아니라 E 또는 그 하위 타입의 객체 컬렉션을 받아들인다는 뜻이에요. 덕분에 items에서 E 값을 안전하게 읽을 수는 있어요(이 컬렉션의 요소는 E의 하위 클래스 인스턴스니까요). 하지만 그 미지의 하위 타입이 어떤 객체를 받아들이는지 알 수 없으니 쓰기는 할 수 없어요. 이런 제약 대신 원하는 동작을 얻는 거죠. 즉 Collection<String> 은 Collection<? extends Object>의 하위 타입이 돼요. 다시 말해 extends 바운드(상한) 를 가진 와일드카드는 타입을 공변(covariant) 으로 만들어 줘요.
이게 왜 동작하는지 이해하는 핵심은 단순해요. 컬렉션에서 항목을 꺼내기만 할 수 있다면, String 컬렉션을 쓰면서 거기서 Object를 읽는 건 문제없어요. 반대로 컬렉션에 항목을 넣기만 할 수 있다면 Object 컬렉션을 가져다가 String을 넣는 것도 괜찮죠. 자바에는 이런 경우를 위한 List<? super String>이 있는데, String이나 그 상위 타입들을 받아들여요.
후자를 반변(contravariance) 이라고 해요. List<? super String>에서는 String을 인자로 받는 메서드만 호출할 수 있어요(add(String)이나 set(int, String) 같은 거죠). List<T>에서 T를 반환하는 뭔가를 호출하면 String이 아니라 Object를 받게 돼요.
조슈아 블로크(Joshua Bloch)는 그의 책 Effective Java, 3rd Edition의 아이템 31("한정적 와일드카드를 사용해 API 유연성을 높이라")에서 이 문제를 잘 설명해요. 그는 읽기만 하는 객체를 Producer, 쓰기만 하는 객체를 Consumer라고 이름을 붙이면서 다음을 권장해요.
"최대한의 유연성을 위해, 프로듀서나 컨슈머를 나타내는 입력 파라미터에는 와일드카드 타입을 사용하라."
그리고 이렇게 외우기 좋은 약어를 제안해요. PECS는 Producer-Extends, Consumer-Super를 뜻해요.
List<? extends Foo> 같은 producer 객체를 쓴다면 이 객체에 add()나 set()을 호출할 수는 없어요. 하지만 그렇다고 객체가 불변(immutable) 이라는 뜻은 아니에요. 예를 들어 clear()는 파라미터를 아무것도 받지 않으므로, clear()를 호출해서 리스트의 항목을 전부 지우는 건 막을 수 없죠.
와일드카드(또는 다른 변성)가 보장하는 건 오직 타입 안전성뿐이에요. 불변성은 완전히 별개의 얘기예요.
선언 지점 변성 (Declaration-site variance)
T를 파라미터로 받는 메서드는 없고 T를 반환하는 메서드만 있는 제네릭 인터페이스 Source<T>가 있다고 가정해 볼게요.
// Java
interface Source<T> {
T nextT();
}
그렇다면 Source<Object> 타입의 변수에 Source<String> 인스턴스의 참조를 저장하는 건 완전히 안전해요. 호출할 수 있는 consumer 메서드가 없으니까요. 하지만 자바는 이 사실을 모르고 여전히 금지해요.
// Java
void demo(Source<String> strs) {
Source<Object> objects = strs; // !!! Java에서는 허용되지 않음
// ...
}
이 문제를 고치려면 Source<? extends Object> 타입의 객체로 선언해야 하는데, 그러면 이 변수에 예전처럼 똑같은 메서드를 전부 호출할 수 있으니 더 복잡한 타입이 딱히 이득을 주지 않아요. 하지만 컴파일러는 그걸 모르죠.
Kotlin에는 이런 사실을 컴파일러에게 알려 주는 방법이 있어요. 이를 선언 지점 변성(declaration-site variance) 이라고 해요. Source의 타입 파라미터 T에 애너테이션을 붙여서, T가 Source<T>의 멤버에서 반환(생산) 만 되고 소비는 절대 안 되도록 보장할 수 있어요. 이때 out 수정자를 쓰면 돼요.
interface Source<out T> {
fun nextT(): T
}
fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // OK: T가 out 파라미터이므로
// ...
}
일반적인 규칙은 이래요. 클래스 C의 타입 파라미터 T가 out으로 선언되면, T는 C의 멤버에서 out 위치에만 나타날 수 있어요. 그 대신 C<Base>는 C<Derived>의 상위 타입이 될 수 있어요.
즉 클래스 C가 파라미터 T에 대해 공변(covariant) 이라고, 또는 T가 공변 타입 파라미터라고 말할 수 있어요. C를 T의 producer라고 생각하면 되고, T의 consumer는 아니에요.
out 수정자는 변성 애너테이션(variance annotation) 이라고 불러요. 타입 파라미터가 선언되는 지점에서 붙여 주므로 선언 지점 변성을 제공하죠. 이는 자바의 사용 지점 변성(use-site variance) 과 대비돼요. 자바에서는 타입 사용처의 와일드카드가 타입을 공변으로 만들거든요.
out과 짝을 이루는 변성 애너테이션으로 in도 있어요. in은 타입 파라미터를 반변(contravariant) 으로 만들어요. 즉 소비만 되고 생산은 절대 안 된다는 뜻이죠. 반변 타입의 대표적인 예가 Comparable이에요.
interface Comparable<in T> {
operator fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>) {
x.compareTo(1.0) // 1.0은 Double 타입이고, Number의 하위 타입이에요
// 따라서 x를 Comparable<Double> 타입의 변수에 대입할 수 있어요
val y: Comparable<Double> = x // OK!
}
in과 out이라는 단어는 그 의미가 자명해 보여요(C#에서 오래전부터 잘 쓰여 왔죠). 그래서 앞서 본 PECS 같은 약어는 사실 크게 필요하지 않아요. 더 높은 수준의 추상화로 다시 표현할 수도 있어요.
실존(The Existential) 변환: Consumer in, Producer out! :-)
타입 프로젝션 (Type projections)
사용 지점 변성: 타입 프로젝션
타입 파라미터 T를 out으로 선언하면 사용 지점에서 하위 타입 문제를 피할 수 있어서 정말 편해요. 하지만 어떤 클래스들은 실제로 T만 반환하도록 제한할 수 없는 경우가 있어요. 대표적인 예가 Array예요.
class Array<T>(val size: Int) {
operator fun get(index: Int): T { ... }
operator fun set(index: Int, value: T) { ... }
}
이 클래스는 T에 대해 공변도 반변도 될 수 없어요. 그리고 이로 인해 유연성이 떨어지게 되죠. 다음 함수를 생각해 볼게요.
fun copy(from: Array<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)
// ^ 타입이 Array<Int>인데 Array<Any>가 기대돼요
여기서 익숙한 문제가 다시 나타나요. Array<T>는 T에 대해 불변(invariant) 이라서 Array<Int>도 Array<Any>도 서로의 하위 타입이 아니에요. 왜일까요? 역시 copy가 예상치 못한 동작을 할 수 있기 때문이에요. 예를 들어 from에 String을 쓰려고 할 수 있는데, 실제로 Int 배열을 넘겼다면 나중에 ClassCastException이 터지겠죠.
copy 함수가 from에 쓰는 것을 막으려면 이렇게 하면 돼요.
fun copy(from: Array<out Any>, to: Array<Any>) { ... }
이것이 타입 프로젝션(type projection) 이에요. from은 단순한 배열이 아니라 제한된(투영된) 배열이라는 뜻이죠. 여기서는 타입 파라미터 T를 반환하는 메서드만 호출할 수 있어요. 이 경우에는 get()만 호출할 수 있다는 뜻이에요. 이게 바로 우리의 사용 지점 변성 접근 방식이고, 자바의 Array<? extends Object>에 대응하면서 조금 더 단순해요.
in으로도 타입을 투영할 수 있어요.
fun fill(dest: Array<in String>, value: String) { ... }
Array<in String>은 자바의 Array<? super String>에 대응해요. 즉 String, CharSequence, Object 배열을 fill() 함수에 넘길 수 있다는 뜻이에요.
스타 프로젝션 (Star-projections)
때로는 타입 인자에 대해 아무것도 모른다고 선언하고 싶지만, 그래도 안전한 방식으로 사용하고 싶을 때가 있어요. 여기서 말하는 안전한 방식이란, 그 제네릭 타입의 모든 구체적 인스턴스화가 그 프로젝션의 하위 타입이 되는 프로젝션을 정의하는 거예요.
Kotlin은 이를 위해 스타 프로젝션(star-projection) 문법을 제공해요.
T가 상한TUpper를 가진 공변 타입 파라미터인Foo<out T : TUpper>의 경우,Foo<*>는Foo<out TUpper>와 동등해요. 즉T를 모를 때Foo<*>에서TUpper값을 안전하게 읽을 수 있어요.T가 반변 타입 파라미터인Foo<in T>의 경우,Foo<*>는Foo<in Nothing>과 동등해요. 즉T를 모를 때Foo<*>에 쓰는 일은 안전하게 할 수 있는 게 없어요.T가 상한TUpper를 가진 불변 타입 파라미터인Foo<T : TUpper>의 경우,Foo<*>는 값을 읽을 때는Foo<out TUpper>와, 값을 쓸 때는Foo<in Nothing>과 동등해요.
제네릭 타입에 타입 파라미터가 여럿 있다면 각각을 독립적으로 투영할 수 있어요. 예를 들어 타입이 interface Function<in T, out U>로 선언되어 있다면 다음과 같은 스타 프로젝션을 쓸 수 있어요.
Function<*, String>은Function<in Nothing, String>을 뜻해요.Function<Int, *>은Function<Int, out Any?>를 뜻해요.Function<*, *>은Function<in Nothing, out Any?>를 뜻해요.
스타 프로젝션은 자바의 raw 타입과 아주 비슷하지만 안전해요.
캡처된 타입 (Captured types)
out T나 in T 같은 타입 프로젝션을 사용하면, 컴파일러는 내부적으로 미지의 구체 타입을 캡처된 타입(captured type)으로 표현해요. 캡처된 타입은 알려진 상한과 하한을 가진 미지의 타입이에요.
캡처된 타입은 이름을 붙일 수 없어서(non-denotable) Kotlin 코드에 직접 쓸 수는 없어요. 대신 CapturedType(out X) 같은 컴파일러 진단 메시지에서 가장 자주 볼 수 있어요. 예를 들어 다음 타입 불일치 진단에는 캡처된 타입이 들어 있어요.
val array: Array<out CharSequence> = arrayOf("str")
val item: Int = array.get(0)
// Initializer type mismatch: expected 'Int', actual 'CapturedType(out CharSequence)'
컴파일러는 읽기 연산에는 캡처된 타입의 상한을, 쓰기 연산에서는 어떤 값이 타입 안전한지 판단할 때 캡처된 타입의 하한을 사용해요.
// 투영된 타입은 Array<out CharSequence>
val array: Array<out CharSequence> = arrayOf("Kotlin")
// get() 읽기 연산은 캡처된 타입의 상한인 CharSequence를 사용해요
val item = array.get(0)
// set() 쓰기 연산은 캡처된 타입의 하한인 Nothing을 사용하므로,
// 결과적으로 오류가 발생해요
array.set(0, "New value")
// Receiver type 'Array<out CharSequence>' contains out projection
// which prohibits the use of 'fun set(index: Int, value: T): Unit'
이 예제를 정리하면:
- 변수
array는 투영된 타입Array<out CharSequence>를 가져요. 컴파일러는 투영된 타입 인자out CharSequence를 상한CharSequence, 하한Nothing을 가진 캡처된 타입으로 표현해요. get()연산에서는 컴파일러가 캡처된 타입을 그 상한인CharSequence로 근사하고,item의 타입을CharSequence로 추론해요.set()연산에서는 캡처된 타입의 하한이Nothing이에요.Nothing은 인스턴스가 없으므로 투영된 타입에 값을 쓰는 건 타입 안전하지 않고 오류가 나요.
제네릭 함수 (Generic functions)
타입 파라미터를 가질 수 있는 건 클래스만이 아니에요. 함수도 가질 수 있어요. 타입 파라미터는 함수 이름 앞에 위치해요.
fun <T> singletonList(item: T): List<T> {
// ...
}
fun <T> T.basicToString(): String { // 확장 함수
// ...
}
제네릭 함수를 호출할 때는 호출 지점에서 함수 이름 뒤에 타입 인자를 지정하면 돼요.
val l = singletonList<Int>(1)
컨텍스트에서 타입 인자를 추론할 수 있다면 생략해도 되므로, 다음 예제도 동작해요.
val l = singletonList(1)
제네릭 제약 (Generic constraints)
특정 타입 파라미터에 대체될 수 있는 모든 가능한 타입의 집합은 제네릭 제약(generic constraints) 으로 제한할 수 있어요.
상한 (Upper bounds)
가장 흔한 제약은 상한(upper bound) 이에요. 자바의 extends 키워드에 대응하죠.
fun <T : Comparable<T>> sort(list: List<T>) { ... }
콜론 뒤에 지정된 타입이 상한이에요. 즉 Comparable<T>의 하위 타입만 T로 대체될 수 있다는 뜻이에요. 예를 들면:
sort(listOf(1, 2, 3)) // OK. Int는 Comparable<Int>의 하위 타입
sort(listOf(HashMap<Int, String>())) // Error: HashMap<Int, String>은 Comparable<HashMap<Int, String>>의 하위 타입이 아님
기본 상한(따로 지정하지 않았을 때)은 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() }
}
전달된 타입은 where 절의 모든 조건을 동시에 충족해야 해요. 위 예제에서 T 타입은 둘 다, 즉 CharSequence와 Comparable을 모두 구현해야 해요.
확실히 널이 아닌 타입 (Definitely non-nullable types)
제네릭 자바 클래스·인터페이스와의 상호 운용을 더 쉽게 만들기 위해, Kotlin은 제네릭 타입 파라미터를 확실히 널이 아닌 타입(definitely non-nullable) 으로 선언하는 것을 지원해요.
제네릭 타입 T를 확실히 널이 아닌 타입으로 선언하려면 & Any와 함께 선언하면 돼요. 예: T & Any.
확실히 널이 아닌 타입은 널 가능한 상한을 가져야 해요.
확실히 널이 아닌 타입을 선언하는 가장 흔한 사례는 @NotNull을 인자로 포함하는 자바 메서드를 오버라이드할 때예요. 예를 들어 load() 메서드를 생각해 볼게요.
import org.jetbrains.annotations.*;
public interface Game<T> {
public T save(T x) {}
@NotNull
public T load(@NotNull T x) {}
}
Kotlin에서 load() 메서드를 성공적으로 오버라이드하려면 T1이 확실히 널이 아닌 타입으로 선언되어야 해요.
interface ArcadeGame<T1> : Game<T1> {
override fun save(x: T1): T1
// T1은 확실히 널이 아닌 타입
override fun load(x: T1 & Any): T1 & Any
}
Kotlin만 다룰 때는 확실히 널이 아닌 타입을 명시적으로 선언할 일이 거의 없어요. Kotlin의 타입 추론이 알아서 처리해 주거든요.
타입 소거 (Type erasure)
Kotlin이 제네릭 선언 사용에 대해 수행하는 타입 안전성 검사는 컴파일 타임에 이뤄져요. 런타임에서는 제네릭 타입의 인스턴스가 자신의 실제 타입 인자에 대한 정보를 전혀 갖고 있지 않아요. 이런 타입 정보는 소거(erased) 된다고 말해요. 예를 들어 Foo<Bar>와 Foo<Baz?>의 인스턴스는 모두 Foo<*>로 소거돼요.
제네릭 타입 검사와 캐스트
타입 소거 때문에 런타임에서 제네릭 타입 인스턴스가 특정 타입 인자로 생성됐는지 일반적인 방법으로는 검사할 수 없어요. 그래서 컴파일러는 ints is List<Int>나 list is T(타입 파라미터) 같은 is 검사를 금지해요. 하지만 스타 프로젝션 타입에 대해서는 인스턴스를 검사할 수 있어요.
if (something is List<*>) {
something.forEach { println(it) } // 항목들은 `Any?` 타입으로 취급돼요
}
마찬가지로 인스턴스의 타입 인자를 이미 정적으로(컴파일 타임에) 검사했다면, 타입의 비제네릭 부분이 포함된 is 검사나 캐스트를 할 수 있어요. 이 경우 꺾쇠는 생략해요.
fun handleStrings(list: MutableList<String>) {
if (list is ArrayList) {
// `list`는 `ArrayList<String>`으로 스마트 캐스트돼요
}
}
타입 인자를 고려하지 않는 캐스트에서도 타입 인자를 생략한 같은 문법을 쓸 수 있어요: list as ArrayList.
제네릭 함수 호출의 타입 인자도 컴파일 타임에만 검사돼요. 함수 본문 안에서는 타입 파라미터를 타입 검사에 쓸 수 없고, 타입 파라미터로의 타입 캐스트(foo as T)도 검사되지 않아요. 유일한 예외는 reified 타입 파라미터를 가진 인라인 함수인데, 이 함수는 실제 타입 인자를 각 호출 지점에서 인라인해요. 덕분에 타입 파라미터에 대한 타입 검사와 캐스트가 가능해지죠. 다만 검사나 캐스트에 사용되는 제네릭 타입 인스턴스에는 앞서 설명한 제약이 여전히 적용돼요. 예를 들어 타입 검사 arg is T에서 arg가 제네릭 타입의 인스턴스라면, 그 타입 인자는 여전히 소거돼요.
//sampleStart
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>>() // 컴파일되지만 타입 안전성을 깨뜨려요!
// 샘플을 펼치면 더 자세히 볼 수 있어요
//sampleEnd
fun main() {
println("stringToSomething = " + stringToSomething)
println("stringToInt = " + stringToInt)
println("stringToList = " + stringToList)
println("stringToStringList = " + stringToStringList)
//println(stringToStringList?.second?.forEach() {it.length}) // 리스트 항목이 String이 아니므로 ClassCastException이 발생해요
}
검사되지 않은 캐스트 (Unchecked casts)
foo as List<String>처럼 구체적인 타입 인자를 가진 제네릭 타입으로의 타입 캐스트는 런타임에서 검사할 수 없어요. 이런 검사되지 않은 캐스트는 타입 안전성이 높은 수준의 프로그램 로직으로 보장되지만 컴파일러가 직접 추론할 수는 없을 때 사용할 수 있어요. 아래 예제를 볼게요.
fun readDictionary(file: File): Map<String, *> = file.inputStream().use {
TODO("Read a mapping of strings to arbitrary elements.")
}
// 이 파일에는 `Int` 값들이 들어 있는 맵을 저장했어요
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>
마지막 줄의 캐스트에 경고가 나타나요. 컴파일러는 이것을 런타임에서 완전히 검사할 수 없고, 맵 안의 값들이 Int라는 보장도 해 주지 못해요.
검사되지 않은 캐스트를 피하려면 프로그램 구조를 다시 설계하면 돼요. 위 예제에서는 타입별로 타입 안전한 구현을 제공하는 DictionaryReader<T>와 DictionaryWriter<T> 인터페이스를 쓸 수 있어요. 합리적인 추상화를 도입해서 검사되지 않은 캐스트를 호출 지점에서 구현 세부사항으로 옮길 수도 있고, 제네릭 변성을 제대로 활용하는 것도 도움이 돼요.
제네릭 함수에서는 reified 타입 파라미터를 사용하면 arg as T 같은 캐스트가 검사되지만, arg의 타입이 자체적인 타입 인자를 가져서 소거되는 경우는 예외예요.
검사되지 않은 캐스트 경고는 해당 문이나 선언에 @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
JVM에서는 배열 타입(Array<Foo>)이 요소의 소거된 타입에 대한 정보를 보존해요. 그래서 배열 타입으로의 타입 캐스트는 부분적으로만 검사돼요. 요소 타입의 널 가능성과 실제 타입 인자는 여전히 소거되죠. 예를 들어 foo as Array<List<String>?> 캐스트는 foo가 어떤 List<*>를 담고 있는 배열이라면 그 리스트가 널 가능이든 아니든 성공해요.
타입 인자의 언더스코어 연산자 (Underscore operator for type arguments)
타입 인자에는 언더스코어 연산자 _를 쓸 수 있어요. 다른 타입들이 명시적으로 지정되어 있을 때 인자의 타입을 자동으로 추론하게 하려면 이걸 쓰면 돼요.
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() {
// SomeImplementation이 SomeClass<String>에서 파생되므로 T는 String으로 추론돼요
val s = Runner.run<SomeImplementation, _>()
assert(s == "Test")
// OtherImplementation이 SomeClass<Int>에서 파생되므로 T는 Int로 추론돼요
val n = Runner.run<OtherImplementation, _>()
assert(n == 42)
}
더 알아보기 (Learn more)
- 동등성 — 재정의된 타입에서 동등성 비교가 어떻게 작동하는지 보여줘요.
- 비동기 프로그래밍 기법 — 제네릭을 활용하는 비동기 패턴을 소개해요.