타입을 변수처럼 받을 수 있도록 하는 방법
자바와 마찬가지로 코틀린 클래스는 타입 패러미터를 가질 수 있다.
class Box<T>(t: T){
val value = t
}
일반적으로 제네릭 클래스의 인스턴스를 생성할 때 타입을 전달해야 한다. 하지만 파라미터의 타입을 추론할 수 있다면 생략할 수 있다.
val box: Box<Int> = Box<Int>(1)
val vox = Box(1)
변성(Variance) in & out
코틀린의 리스트와 배열은 자기 자신의 타입만 허용하는 무공변입니다.
변성이란 배열과 리스트 등 에서 무공변인 제네릭을 공변, 반공변으로 바꾸는 법입니다.
즉,배열과 리스트 등 에서 상위 또는 하위 타입의 원소를 받는 방법입니다.
코틀린에서 변성을 선언하는 시점은 2가지이다.
- 선언 위치 변성 : 클래스나 인터페이스를 선언할 때 클래스 자체에 가변성을 지정하는 방법
- 사용 위치 변성 : 메서드 매개변수나 제네릭 클래스를 생성할 때와 같이 사용 위치에서 가변성을 지정하는 방식이다.
우선 코틀린의 변성을 알기 전 자바의 와일드카드에 대해 알 필요가 있다.
와일드카드란?
제네릭에 하위타입을 허용시키는 방법입니다
위 요약을 보면 제네릭은 하위타입을 넣을 수 없다는 말인데, String타입의 변수를 Object타입의 변수에 대입 가능할까?
String a = "a";
Object b = a;
당연히 가능하다. 그렇다면 String 리스트 변수를 Object 리스트 변수에 할당할 수 있을까???
Object는 최상위이고, 변수에서도 가능했으니 리스트도 똑같이 가능할 것이라 생각할 수 있다.
List<String> strs = new ArrayList<>();
List<Object> objs = strs;
objs.add(1);
String리스트를 Object리스트에 할당하였다. 그리고 Object리스트에 1을 넣어줬다.
이제 String리스트의 첫 번째 원소를 String s에 할당해 보자
List<String> strs = new ArrayList<>();
List<Object> objs = strs; //자바는 이를 허용하지 않는다.
objs.add(1);
String s = strs.get(0);
위 코드가 정상적으로 수행된다고 가정하면 String리스트에 Integer원소를 넣는 것이 가능해진다!!!!
당연하게도 말이 안 된다. 타입이 다를 뿐 아니라 리스트 타입은 무공변이기 때문이다.
다른 예로 어떤 컬렉션이든 받아서 원소를 출력하는 메서드를 만들어보자.
public void printAnything(Collection<Object> c){
for(Object e : c)
System.out.println(e)
}
public static void main(String[] args){
Collection<Integer> c = new ArrayList<>();
c.add(123)
printAnythine(c);
}
위 함수가 의도대로 동작할까? 안된다. 조금 전과 같은 이유로 컬렉션의 타입은 무공변이기 때문이다.
그렇다면 방법이 없는 걸까? 와일드카드를 쓰면 된다.
와일드카드는 '? extends T'로 쓸 수 있다.
public void printAnything(Collection<? extends Number> c){
for(Number e : c)
System.out.println(e)
}
public static void main(String[] args){
Collection<Integer> c = new ArrayList<>();
c.add(123)
printAnythine(c);
}
코드를 간략히 보면 Number를 상속하는 어떤 타입의 컬렉션을 받아서 원소를 출력해 준다.
와일드카드 타입 인자 ? extends Number
는 Number 클래스를 상속받고 있으므로 Number타입뿐만 아니라 Number의 하위 타입까지 패러미터 c의 타입으로 받을 수 있다.
즉 extends bound(upper bound)를 가진 와일드카드는 타입을 공변으로 만든다.
이 방법이 어떻게 동작하는지에 대한 핵심은 간단하다. 컬렉션에서 항목을 가져올 수만 있다면 String 컬렉션을 사용하고 Object로 읽어도 괜찮다. 역으로 컬렉션에 항목을 넣을 수만 있다면 Obejct 컬렉션에 String을 넣어도 괜찮기 때문이다.
선언 위치 변성(Declaration-site variance)
클래스나 인터페이스를 선언할 때 클래스 제네릭 자체에 변성을 지정하는 방식이다.
자바에서 인터페이스 Source <T>에
파라미터로 T 타입을 갖는 메서드가 없고 오직 T타입을 리턴하는 메서드만 있다고 가정해 보자
java
interface Source<T>{
T nextT();
}
void demo(Source<String> strs){
Source<Object> objs = strs; //허용하지 않음
}
Source <String> 인스턴스에
대한 레퍼런스를 Source <Object> 변수에
저장할 수 있다. 하지만 자바는 이를 허용하지 않는다.
이를 고치려면 인터페이스에 Source <? extends Object> (Object를 상속하는 와일드카드)
선언해야 하는데 이렇게 하면 타입선언이 길어진다.
코틀린에서는 우리가 와일드카드를 작성할 필요 없이 컴파일러에게 알려주는 방법을 제공한다. 이를 클래스가 컴파일러에서 선언될 때 지정한다고 해서 선언 위치 변성(Declaration-site variance)이라 한다.
변성 어노테이션 'out'으로 선언할 수 있다.
abstract class Source<out T> {
abstract fun nextT(): T
}
fun demo(strs: Source<String>){
val objects: Source<Any> = strs
}
이렇게 하게 되면 공변 타입 패러미터 T를 리턴만 하고 소비하지 않는다는 것을 명시할 수 있다. 따라서 Source <Any> 타입인 objects 변수에 Source <String> 타입의 strs 패러미터를 할당할 수 있다.
out 이외에 코틀린은 추가 변성 어노테이션 in을 제공한다. 이는 타입 패러미터를 반공변으로 만든다. 즉, 자기 자신과 부모만 올 수 있으며 타입 T에 대해 소비만 할 수 있고 리턴은 할 수 없다. 반공변의 좋은 예가 Comparable이다.
abstract class Comparable<in T> {
abstract fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>){
x.compareTo(1.0) //1.0은 Double타입, Number타입의 하위 타입이다.
// 타입 T는 반공변이기 때문에 Comparable<Double>타입에 x를 할당할 수 있다.
val y: Comparable<Double> = x
}
사용 위치 변성(Use-site variance) : 타입 프로젝션
메서드 매개변수나 제네릭 클래스를 생성할 때와 같이 패러미터의 타입에서 변성을 지정하는 방식이다.
타입 패러미터 T를 out으로 선언하면 매우 편리하며, 하위 타입에 대한 문제를 피할 수 있다. 하지만 어떤 클래스는 T만 리턴하도록 제한할 수 없다. 배열이 좋은 예다.
class Array<T>(val size: Int){
...
}
이 클래스는 T에 대해 공변이나 반공변일 수 없다. 이는 배열을 유연하지 못하게 만든다. 다음 함수를 보자
fun copy(from: Array<Any>, to: Array<Any>){
assert(from.size == to.size)
for( i in from.indices)
to[i] = from[i]
}
이 함수는 from배열의 원소를 to배열에 복사하는 함수다. 실제로 써보자
val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3){ "0" }
copy(ints, any)
여기서 문제가 발생한다. Array <T>는
T에 대해 무공변이므로 Array <Int>가
올 수 없다. 각각 상대의 하위 타입이 아니기 때문이다. 만약 허용한다면 예외가 발생할 수 있다. 예를 들어 from이 Int배열인데 String배열을 쓰는 시도를 할 수 있고, 이는 ClassCastException을 야기할 수 있다.
우리는 예외가 발생하지 않도록 다음과 같이 from에 쓸 수 있다.
fun copy(from: Array<out Any>, to: Array<Any>)
변성 어노테이션 out을 from 배열의 원소타입에 넣어주게 되면 to 배열의 원소에 하위타입의 원소가 들어갈 수 있도록 허용해 준다.
fun main() {
val t = arrayOf(1, 2, 3)
val any = Array<Any>(3){"0"}
copy(t, any)
for(i in any)
println(i)
}
fun copy(from: Array<out Any>, to: Array<Any>){
assert(from.size == to.size)
for( i in from.indices)
to[i] = from[i]
}
-결과-
1
2
3
위 코드에서 일어난 일을 타입 프로젝션(type projection)이라고 부른다. from은 단순 배열이 아니고 제한된(projected) 배열이다. 이제 from에서는 타입 패러미터 T를 리턴한 메서드만 호출할 수 있는데, 이 경우에는 get()만 호출할 수 있음을 의미한다. 이는 자바의 Array <? extends Object>
에 대응하지만 더 간단한 방법이다.
타입 프로젝션에 in을 사용할 수도 있다.
fun fill(dest: Array<in String>, value: String){
...
}
Array <in String>
은 자바의 Array <? super String>
와 같다. fill() 함수에 Object배열이나 CharSequence배열을 패러미터로 줄 수 있다.
스타 프로젝션(Star-projections)
타입 인자에 대해 알지 못하지만 안전한 방법으로 타입 인자를 사용하고 싶을 때가 있다. 여기서 안전한 방법은 제네릭 타입의 프로젝션을 정의하는 것이다. 그 제네릭 타입의 모든 인스턴스는 그 프로젝션의 하위 타입이 된다.
코틀린은 이를 위해 스타 프로젝션을 제공한다.
Foo <out T>
에서 T는 상위 한계로 TUpper를 가진 공변 타입 패러미터면,Foo <*>는
Foo <out Tupper>
와 동일하다. Tupper하위 클래스가 올 수 있으므로 T를 알 수 없을 때 안전하게 TUpper의 값을 읽을 수 있다는 것을 의미한다.Foo <in T>
에서 T가 반공변 타입 파라미터면,Foo <*>는
Foo <in Nothing>
과 동일하다. 이는 T를 알 수 없을 때 안전하게Foo <*>에
쓸 수 없다는 것을 의미한다.Foo <T>에서
T가 상위 한계로 TUpper를 가진 무공 변 타입 패러미터면,Foo <*>의
값을 읽는 것은Foo <out TUpper>
와 동일하고 값을 쓰는 것은Foo <in Nothing>
과 동일하다.
쉽게 말하면 어떤 자료형이든 들어올 수 있으나, 구체적으로 결정된 뒤 해당 타입의 하위 타입만 담을 수 있도록 제한하는 것이다.
제네릭 함수
함수 이름 앞에 타입 패러미터를 적으면 된다.
fun <T> singletonList(item: T): List<T>{ ... }
val l = singletonList<Int>(1)
제네릭 제한
타입 패러미터에 올 수 있는 타입들은 제네릭 제한에 제약받는다.
상위 한계
fun <T : Comparable<T>> sort(list: List<T>) { ... }
콜론 뒤에 지정한 타입이 상위 한계이다. 지정한 타입의 하위 타입만 T에 사용할 수 있다는 의미이다.
sort(listOf(1, 2, 3)) //Int는 Comparable<Int>의 하위타입이다.
sort(listOf(HashMap<Int, String>())) // comparable<HashMap<Int, String>>의 하위 타입이 아니다.
기본 상위 한계는 Any?이다. 한 개 이상의 상위 한계를 지정하려면 where 절을 사용해야 한다.
fun <T> cloneWhenGreater(list: List<T>, threshold: T): List<T>
where T : Comparable,
T : Cloneable {
return list.filter { it > threshold }.map { it.clone() }
}
제네릭은 직접 함수를 만들거나 라이브러리를 사용할 때 코드분석하면서 많이 접하는 개념이다. 특히 라이브러리는 예외사항을 줄이기 위해 in & out을 쓰는 경우가 많으니 in & out은 반드시 알고 넘어가면 좋다. 선언위치변성과 사용위치성은 단어가 어려울 뿐이지 요약하면 다음과 같다.
선언위치변성 : 클래스, 인터페이스에 사용한 제네릭에 in & out 키워드로 상위 또는 하위 타입을 허용하는 것
사용위치변성 : Array와 같이 타입에 사용한 제네릭에 in & out 키워드로 상위 또는 하위 타입을 허용하는 것
in : 나는 부모님 집에 포함(in) 돼있다
out : 나는 부모님 집을 벗어나(out) 자식들과 같이 있다.
'코틀린' 카테고리의 다른 글
Enum(열거형) 클래스 (0) | 2023.09.18 |
---|---|
중첩 클래스와 내부 클래스 (0) | 2023.09.18 |
봉인된 클래스 및 인터페이스(Seald Class & Interface) (0) | 2023.04.10 |
데이터 클래스(Data Class) (0) | 2023.03.20 |
확장(Extensions) (0) | 2023.03.07 |