제네릭은 나중에 지정할 타입에 대해 작동하는 코드를 의미한다. 타입을 미리 정해놓지 않았기 때문에, 더 일반적인 코드로 작성할 수 있게 한다. 아래의 예시를 통해 제네릭스를 알아보자
기존에 여러 타입에 대해 작동하는 코드를 작성할 때는 상속을 활용하여 구성할 수 있다. 이는 기반 클래스로부터 파생된 클래스를 활용하는 방법이다.
open class Pet
class Dog : Pet()
class Cat: Pet()
private fun eat(pet: Pet){
println("Taste is good")
}
fun main(){
eat(Dog())
eat(Cat())
}
아래와 같이 Cat과 Dog을 같은 기반 클래스 Pet을 상속받게 함으로써, 여러 타입에 대해 작동하는 코드를 구성할 수있다. 그러나 해당 방법은 Pet을 상속받아야 한다. 이는 다형적인 함수이지만 사실은 단일 계층만 가능하는 모순적인 상황이 발생한다.
코드가 미리 정해지지 않은 타입에 대해 동작한다면, 제약 없이 일반적으로 사용할 수 있을 것 같다.
해당 상황에서 제네릭을 사용할 수 있다. 아래의 예시를 통해 확인하자.
class Dog
class Cat
private fun <T> eat(pet: T){
println("Taste is good")
}
fun main(){
eat(Dog())
eat(Cat())
}
Dog과 Cat의 기반 클래스를 따로 만들지 않더라도, Cat과 Dog에 대해 동작하는 코드를 만들 수 있다.
Any
그럼 단일 계층이 아닌, 모든 클래스의 기반 클래스인 Any를 사용하면 되지 않을까? Any를 사용할 때에는 단어 그대로 Any(다운 캐스트 없이, 실제 모든 타입을 다룰 때)를 사용할 때에는 유용하고 더 간단한 해법이 된다.
Any를 다운 캐스팅 하여 사용한다면, 런타임 시에 타입을 확인하며, 잘못된 타입인 경우 런타임 에러를 발생시킨다. 그럼 제네릭은 다를까? 아래에서 제네릭은 정말 다른지 확인해보자.
타입 파라미터 제약
타입 파라미터 제약은 제네릭이기는 하지만, 상속처럼 다른 클래스를 상속해야 한다고 지정하는 것이다. 타입 파라미터 제약은 아래와 같은 형태를 가지고 있다.
fun <T: Pet> T.cry() = "멍멍 야옹"
그럼 일반 상속이나 제네릭을 통해 구현한 것과 어떤 차이가 있을까? 아래의 예시를 통해 확인하자.
interface Pet{
val name: String
fun cry(): String
}
class Dog(override val name: String): Pet{
override fun cry(): String = "강아지는 멍멍"
}
class Cat(override val name: String): Pet{
override fun cry(): String = "고양이는 야옹"
}
fun Pet.getInheritance(): Pet{ // 상속
val p: Pet = this
p.cry()
return p
}
fun <T> T.getGenerics(): T{ // 제네릭
val p: T = this
// p.cry()
return p
}
fun <T: Pet> T.getGenericsWithType(): T{ // 타입 파라미터 제약
val p: T = this
p.cry()
return p
}
fun main(){
println(Dog("진호개").getInheritance())
println(Cat("진호냥").getGenerics())
println(Dog("진호개").getGenericsWithType())
}
getInheritance()는 Pet의 확장이므로 함수 내부에서 cry()에 접근할 수 있다. 하지만 제네릭이 아니기 때문에 기반 타입인 Pet으로만 반환할 수 있다. 제네릭인 getGenerics()는 입력 받은 타입 T 그대로 반환할 수 있으나, T 타입을 알 수 없어 Pet의 멤버에 접근할 수 없다.
하지만 제약을 가한 getGenericsWithType() 함수에서는 멤버에 접근할 수 있으며, 정확한 타입을 반환받을 수 있다.
타입 변성
제네릭은 코틀린에서 타입 불변성을 가진다. 타입 불변성은 제네릭 타입을 사용하는 클래스 혹은 인터페이스에 자기 자신만 대입이 가능하다는 것을 의미한다. 다시 말해, 해당 타입의 부모 및 자식을 대입할 수 없다는 것을 의미한다. 아래의 예시를 통해 확인하자.
아래와 같이 Pet 클래스를 기반으로 하는 파생 클래스 Cat, Dog이 있다.
open class Pet
class Cat: Pet()
class Dog: Pet()
일반적인 상속관계에서는, Pet 클래스를 인자 타입으로 하는 Box 클래스에 Cat 객체를 전달할 수 있다.
class Box(private var pet: Pet)
fun main(){
val petBox = Box(Cat())
}
하지만 Box가 상속이 아닌 제네릭인 경우 타입이 일치하지 않는 에러가 발생한다.
class Box<T>(private var contents: T){
fun put(item: T){ contents = item} // 내용물을 수정하는 함수
fun get(): T = contents // 내용물을 가져오는 함수
}
fun main(){
val catBox = Box<Cat>(Cat())
// val petBox : Box<Pet> = catBox > Error - Type mismatch:
}
Cat 클래스는 Pet 클래스의 파생 클래스이지만, 위의 Box 객체는 기반이나 파생 클래스가 아닌 Pet 클래스 그 자체를 원한다.
이러한 이유는 아래와 같은 상황을 방지하기 위함이다.
fun main(){
val catBox = Box<Cat>(Cat())
// catBox.put(Dog()) > 해당 경우를 막기 위함
}
Box에 Cat 클래스를 넘겨 고양이 박스를 만들었다. 그러나 만약 Cat Box를 Pet Box에 대입하는 것이 가능하다면, Dog도 Pet이므로, Dog도 catBox에 넣을 수 있다. 이렇게 제네릭에서는 타입 안정성을 해칠 위험이 있어, 타입 불변성을 지원한다.
처음 필자가 공부하면서, 정말 헷갈렸던 부분이다. "당연히 Cat과 Dog는 같은 기반 클래스를 가져도, 다른 클래스니까 당연히 안되는 거 아닌가? 왜 타입 안정성을 해치치?" 라고 생각했다. 이는 직접 제네릭이 아닌 상속을 통해 구현해보면 알 수 있다.
class BoxWithInheritance(private var contents: Pet){
fun put(item: Pet){ contents = item }
fun get(): Pet = contents
}
fun main(){
val catBox = Box2(Cat())
catBox.put(Dog())
}
위와 같이, catBox를 생성해도 Dog를 넣을 수 있다는 것을 확인하였다. 그렇다면 이런 생각이 들 수 있다.
put() 함수(박스 안의 내용물을 바꾸는 함수)만 금지하면, CatBox에 Dog이 들어가는 것을 막을 수 있지 않을까?
코틀린에서는 out 애너테이션을 통해 put() 함수를 암시적으로 막고, 파생 클래스를 기반 클래스에 대입하는 것을 허용한다.
(어떤 글의 필자는 부모와 자식도 못알아보는게 딱해, 전능한 코틀린께서 그것만은 허락하게 해주셨다고 표현하셨다. 키키)
class OutBox<out T>(private var contents: T){
fun get(): T = contents
}
fun main(){
val outCatBox: OutBox<Cat> = OutBox(Cat())
val outPetBox: OutBox<Pet> = outCatBox
println(outCatBox.get())
println(outPetBox.get())
}
위와 같이 put() 함수를 소거하여, Dog를 catBox에 넣을 수 없게 되었지만, outCatBox를 outPetBox에 대입할 수 있다. 이를 통해 타입 안정성을 확보할 수 있게 되었다.
catBox에는 Dog가 들어가면 안되지만, petBox에는 Dog이 들어갈 수 있다.
하지만 petBox에 Dog이 들어간 경우 해당 contents가 Dog의 것인지 Pet의 것인지, 확인할 수 없다. 그럼 어떻게 해야 할까?
이전과 반대로 get() 함수(내용물을 읽을 수 있는 함수)를 금지하면 된다.
코틀린에서는 in 애너테이션을 통해 get() 함수를 암시적으로 막고, 기반 클래스를 파생 클래스에 대입하는 것을 허용한다.
class InBox<in T>(private var contents: T){
fun put(item: T){ contents = item}
}
fun main(){
val inBoxPet: InBox<Pet> = InBox(Pet())
val inBoxCat: InBox<Cat> = inBoxPet
inBoxPet.put(Cat())
inBoxCat.put(Cat())
}
이렇게 해서 필자가 이해한대로 글을 작성해 보았다. 아래에서 부터는 사전식으로 소개한다.
제네릭
지정되지 않은 어떠한 타입이며, 여러 데이터 타입을 가질 수 있다.
무공변
기반 클래스와 파생 클래스가 다른 Class(기저 타입)으로 묶였을 때, 둘 사이에 아무런 하위 타입 관계가 없다. 따라서 어느 방향으로든 대입될 수 없다.
Cat이 Pet을 상속해도, Box<Cat>과 Box<Pet>은 둘 사이의 관계가 없어, 한쪽으로 대입될 수 없다.
공변 : out
기반 클래스와 파생 클래스가 다른 Class로 묶였을 때, 둘 사이에 여전히 하위 관계가 유지되는 상황을 의미한다.
Cat이 Pet을 업캐스트 하는 방향과 같이, OutBox<Cat>은 OutBox<Pet>으로 업캐스트 할 수 있다.
반공변 : in
기반 클래스와 파생 클래스가 다른 Class로 묶였을 때, 둘 사이에 여전히 하위 관계가 반대 방향으로 변하는 상황을 의미한다.
Cat이 Pet을 업캐스트 하는 방향과 반대로, InBox<Pet>은 InBox<Cat>로 업캐스트 할 수 있다.
참고 문헌
아토믹 코틀린 7장 : 파워툴
https://blog.naver.com/tgyuu_/223007532130
코틀린 제네릭 박살내기...! reified, where, in, out, 공변성, 반공변성(Kotlin)
들어가기전에... 해당 글의 마지막 챕터인 reified를 이해하기 위해서는 inline 에 대해서 알고 있어야 합...
blog.naver.com
https://hungseong.tistory.com/30
[Android, Kotlin] 제네릭의 in, out 키워드는 무엇일까?
(0) 학습 계기 깃허브의 좋은 소스코드 예제를 분석하며 공부 중, 몇 번씩 봐왔지만 정확한 의미는 몰랐던 in, out에 관한 키워드가 등장했다. 문제의 소스코드다. 필요한 부분만 축약해서 보자. sea
hungseong.tistory.com
https://www.youtube.com/watch?v=Z4FZzWsyCe0