/* 본 게시물은 ' Do it! 코틀린 프로그래밍 | with 황영덕 ' 의 내용을 토대로 작성되었습니다.
개인적으로 공부한 내용을 정리한 글이기 때문에 글에 오류가 있을 수 있습니다. */
참고 자료
[쾌락코딩] 변성(공변성 out, 반공변 in) 이해하기 1편 - 제네릭 : https://wooooooak.github.io/kotlin/2020/02/27/%EB%B3%80%EC%84%B1(in,-out)%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-1%ED%8E%B8/
#제네릭
코틀린으로 쓴 여러 코드들을 보다보면 <>를 자주 볼 수 있다. 예를 들어 array 를 선언할 때, arrayOf<Int>(1,2,3) 같이 사용되는 것을 확인할 수 있다. <> 기호에 대해 모른다면 이 것을 왜 썻는지 어떤 경우에 사용하는지 궁금했을 것이다. 오늘은 이 <> 와 관련된 '제네릭'을 공부해 볼 것이다. 처음 제네릭을 공부하면서 제네릭에 대한 일종의 거부감(?) 이 있었다. 아마도 이를 실제로 코드로 사용한 경험이 없기 때문이었던 것 같다. 오늘 한 번 이 공포감을 정복해보자.
1. 제네릭이란?
제네릭은 자료형을 일반화해 내부에서 그 자료형에 맞춰 교체하는 방법으로, 형식 매개변수(<T>) 라는 T를 이용해 다양한 자료형으로 대체할 수 있다. 컴파일 과정에서 자료형을 결정하는 방법이라고 생각하면 된다. 예시를 통해 이를 알아보자.
class Array<T> private constructor() {
val size: Int
operator fun get(index: Int): T
operator fun set(index: Int, value: T): Unit
operator fun iterator(): Iterator<T>
// ...
}
위 코드는 코틀린에서 정의된 Array 클래스이다. Array 뒤에 형식 매개변수인 <T> 가 왔다. T 에 Int 형이 들어가면 Array<Int> 의 클래스를 생성하고, T에 String 형이 들어가면 Array<String> 형이 생성된다. 이 처럼 Array 클래스를 자료형에 따라 생성할 수 있다. 이처럼 제네릭은 클래스 내부에서 사용할 자료형을 나중에 인스턴스를 생성할 때 확정한다. 즉, 제네릭은 클래스나 인터페이스 혹은 함수 등에서 동일한 코드로 여러 타입을 지원해주는 역할이다.
만약 제네릭이 없다면?
제너릭이 없었다면 Int 형을 받는 Array 클래스, String 형을 받는 Array 클래스를 따로 만들어야 했을 것이다. 혹은 Any 와 같이 String, Int의 공통 상위 클래스(Any)를 이용하여 범용적인 Array 클래스를 만들어야 할 것이다.
class Array<Any>(){
val size: Int
operator fun get(index: Int): Any
operator fun set(index: Int, value: Any): Unit
operator fun iterator(): Iterator<Any>
// ...
}
위 코드로 써도 자동으로 형 변환을 해주기 때문에 문제는 없다. 하지만, 어떤 타입의 객체를 넣어주었는지에 따라서 매번 형 변환을 해주어야 한다는 단점이 있다.
2. 제네릭을 왜 쓸까?
- 제네릭을 사용하면 객체의 자료형을 컴파일할 때 체크하기 떄문에 객체 자료형의 안정성을 높이고 형 변환의 번거로움이 줄어든다.
- 형 변환에 드는 시간을 줄여 성능을 높일 수 있다.
- 동일한 코드로 다양한 자료형을 지원하기 때문에 코드가 간결해진다.
- 인자의 자료형을 고정할 수 없거나 예측할 수 없을 때 형식 매개변수인 T를 이용해 실행 시간에 자료형을 결정할 수 있다.
*[쾌락코딩 님의 제네릭 관련 글] 을 참고하면 더욱 쉽게 제네릭을 이해할 수 있을 것이다.
3. 가변성
제네릭을 다뤄보기 전에 가변성에 대해 먼저 알아야한다. 가변성이란 형식 매개변수가 클래스 계층에 영향을 주는 것을 말한다. (제네릭에서의 상하위 관계라 이해하면 된다.) 예를 들어 보자.
val int: Int = 10
val num: Number = int // 하위 자료형인 Int 형을 상위 자료형인 Number 가 수용
하위 클래스는 상위 클래스가 수용할 수 있다. 다음 코드에서는 Int 형 변수를 Number형의 변수로 할당하여 형 변환이 이루어진다. 그렇다면 형식 매개변수로 <Number> 와 <Int> 의 관계도 상하위 관계일까?
답은 '아니다, 하지만 너가 원한다면 그렇게 만들 수 있다'이다.
가변성의 3가지 유형
이러한 가변성에는 3가지 유형이 있다.
용어 | 의미 |
공변성(out) |
T: 상위 자료형 T': 하위 자료형 -> Class<T>: 상위 자료형, Class<T'>: 하위 자료형 |
반공변성(in) |
T: 상위 자료형 T': 하위 자료형 -> Class<T>: 하위 자료형, Class<T'>: 상위 자료형 |
무변성 | T: 상위 자료형 T': 하위 자료형 -> Class<T> 와 Class<T'> 사이 관계 x |
*자료형에는 클래스 포함.
기본적으로 코틀린의 제네릭의 형식인자는 무변성이다. 무변성은 기존 자료형에서 상하위 관계가 있어도 형식 매개변수에서는 관계가 없다. 예시를 들어보자.
//<T> : 무변성으로 선언
class Wrapper<T>(val a: Int)
fun main(){
val wrapperNumber: Wrapper<Number> = Wrapper<Int>(10) // error! 자료형 불일치
}
Int 형의 상위 클래스인 Number이지만 형식 매개변수에서는 이러한 관계가 상관없다. 그렇다면 제네릭에서 기존의 자료형 관계(상하위 관계)를 사용할 수 있는 방법은 없을까? 제네릭에서 공변성과 반공변성을 구현해보자.
cf > 왜 기본적으로 코틀린의 제네릭의 형식인자는 무변성일까?
이러한 무변성은 컴파일 과정에서 에러를 잡아주고 런타임에 에러를 내지않는 방법이기 때문이다.
공변성
공변성은 형식 매개변수의 상하 자료형 관계가 성립하고, 그 관계가 그대로 인스턴스 자료형 관계로 이어지는 경우를 공변성이라고한다. 예를 들어 Int가 Number의 하위 자료형일 때 형식 매개변수 T에 대해 공변적이라고 한다. 공변성을 구현하려면 out 키워드를 이용하면 된다.
//<out T> : 공변성으로 선언
class Wrapper<out T>(val a: Int)
fun main(){
val wrapperNumber: Wrapper<Number> = Wrapper<Int>(10) // 관계 성립으로 객체 생성 가능
}
out 으로 인해 Wrapper<Number> 자료형이 Wrapper<Int>의 상위 자료형이 되어 Wrapper<Number> 에 Wrapper<Int> 할당이 가능하다.
제한
공변성인 경우 형식 매개변수는 세터를 통해 값을 설정하는 것이 제한된다. 즉, T를 특정 메서드의 매개변수로 사용할 수 없다. (값을 빼낸다, 읽기 전용이라 기억하면 좋다.)
out T 인 경우 반환 자료형에만 사용할 수 있다. 예를 통해 알아보자.
class MyList<out T>(private val items: MutableList<T>){
fun add(t: T){ // error!
items.add(t)
}
fun getValue(index: Int): T{
return items[index]
}
}
*out 을 사용하는 경우에 형식 매개변수를 갖는 프로퍼티는 var로 지정될 수 없고 val 만 허용된다. 만일 var를 사용하려면 다음과 같이 private으로 지정해야한다.
addAll() 와 같이 메서드의 파라미터로 형식 매개변수를 사용하기 위해서는 in T를 사용해야한다.
반공변성
반공병성은 자료형의 관계가 반대로 하여 인스턴스의 자료형이 상위 자료형이 된다. (공변성과 반대임)
//<in T> : 반공변성으로 선언
class Wrapper<in T>(val a: Int)
fun main(){
val wrapperNumber: Wrapper<Number> = Wrapper<Int>(10) // error!
val wrapperInt: Wrapper<Int> = Wrapper<Number>(10)
}
제한
반공변성인 경우 세터를 통해 값을 설정하는 것이 가능하다. 하지만, 게터를 통한 값을 읽는 것은 불가능하다. (in 인 경우 값을 넣는다, 쓰기 모드 전용 이라고 기억하자.)
class MyList<in T>(private val items: MutableList<T>){
fun add(t: T){
items.add(t)
}
fun getValue(index: Int): T{ //error
return items[index]
}
}
4. 제네릭 사용해보기
제네릭 클래스 / 메서드
제네릭 클래스는 형식 매개변수를 1개 이상 받는 클래스이다. 클래스를 선언할 때 자료형을 특정하지 않고 인스턴스를 생성하는 시점에서 클래스의 자료형을 정하는 것이다. 제네릭은 다음과 같이 사용할 수 있다.
클래스 메서드의 매개변수 / 반환 자료형
class GenericClass<T> {
fun genericMethod(a: T) : T{
// ...
}
}
클래스의 프로퍼티
class GenericClass<T>(val property: T){} // 주생성자로 프로퍼티 생성 가능
// error 예시
class GenericClass<T> {
var property: T // 프로퍼티는 초기화되거나 abstract 로 선언되어야함
}
제네릭 메서드
fun <T> genericMethod(a: T) : T {...}
형식 매개변수의 제한 및 null 제어
제네릭의 형식 매개변수를 제어하는 방법은 T: 원하는 자료형 으로 선언하면 된다.
class GenericClass<T: Int>{} // T를 Int형으로 제한
기본적으로 제네릭의 형식 매개변수는 null 가능한 형태로 선언된다. null을 포함하지 않으려면 T를 null을 포함하지 않는 자료형으로 제한하면 된다.
class GenericClass<T: Any>{}
val a = GenericClass<Int?>() // error!
다수 조건의 형식 매개변수를 제한하고 싶을 때는 'where' 키워드를 사용하면 된다. 'where'는 지정된 제한을 모두 포함하는 경우만 허용한다.
interface InterfaceA
interface InterfaceB
class HandlerA: InterfaceA, InterfaceB
class HandlerB: InterfaceB
class GenericClass<T> where T: InterfaceA, T: InterfaceB {}
val a = GenericClass<HandlerA>() // 생성 가능
val b = GenericClass<HandlerB>() // InterfaceA 를 가지지 않으므로 오류 발생