Kotlin/Kotlin

Kotlin 제네릭에 대해 알아보기

Junuuu 2024. 2. 29. 00:01
728x90

개요

Java의 제네릭, Kotlin의 제네릭 등에 대해 학습하였고 포스팅도 여러 차례 썼지만 아직도 제네릭에 대한 이해도가 부족하다고 느낍니다.

이번기회에 확실하게 제네릭의 타입 변환에 대해 이해해보고자 합니다.

 

 

제네릭 사용하는 이유

예를들어 String을 담고 싶은 Box, Int를 담고 싶은 Box 클래스를 만들기 위해서는 어떻게 해야 할까요?

@Test
fun `다양한 타입을 구현하기 위한 방법-v1`(){
	IntBox(3)
	StringBox("Hello Generic")
}

data class IntBox(
	val value: Int,
)
    
data class StringBox(
	val value: String,
)

IntBox, StirngBox 클래스를 만들면 됩니다!

이제 이외의 여러 가지 100개의 타입이 추가됩니다.

100개의 타입을 담는 클래스를 직접 구현해줘야 합니다..

더 좋은 방법 없을까요?

 

data class SuperBox(
	var value: Any,
)

@Test
fun `다양한 타입을 구현하기 위한 방법 - v2`(){
	val anyValue: Any = SuperBox(3).value
	val intValue: Int = anyValue as Int
	intValue.plus(3)
	StringBox("Hello Generic")
}

 

SuperBox 클래스를 만들어 Any 타입으로 모든 타입을 받을 수 있도록 하면 해결할 수 있을 것 같습니다.

하지만 일일이 형변환을 수행해야 하며 컴파일에러가 발생하진 않지만 형변환을 잘못 수행하는 경우 런타임 시 ClassCastException이 발생할 수 있습니다.

 

물론 kotlin의 is 키워드를 활용하여 타입을 검사한다면 예외를 만나게 하지 않을 수 있지만 형변환을 일일히 수행한다는 자체가 불편합니다.

 

data class GenericBox<T>(
	val value: T,
)

fun `다양한 타입을 구현하기 위한 방법 - v3`(){
	val stringValue: String = GenericBox("String").value
}

GenericBox를 만들어서 제네릭을 활용한다면 이런 문제점들을 모두 해결할 수 있습니다.

불편한 형변환이 사라졌으며 1개의 클래스로 모든 일을 처리할 수 있습니다.

 

String을 넣고 Int로 받기위해서는 예외가 발생한다.

String을 초기에 타입으로 지정해 주었지만, Int를 꺼내오려고 하면 컴파일에러가 발생합니다.

 

상속과 제네릭

@Test
fun `제네릭의 슈퍼타입`(){
	val `상위타입으로 캐스팅`: Animal = GenericBox(Cat()).value
	val `하위타입으로 캐스팅`: KoreanCat = GenericBox(Cat()).value //컴파일 에러 발생


open class Animal() //부모

open class Cat: Animal(){ //Animal의 자식
	fun cries(){
		println("야옹")
	}
}

class KoreanCat: Cat(){ //Animal ,Cat의 자식
	val nation = "한국"
}

Animal - Cat, Dog 등의 상속관계에서 제네릭은 어떻게 동작할까요?

상위타입으로 캐스팅은 컴파일에러가 발생하진 않지만 하위타입으로 캐스팅은 컴파일에러가 발생합니다.

 

List와 ArrayList의 제네릭

kotlin에서 List는 불변성을 가지고 mutableList는 가변성을 가집니다.

이런 가변성과 불변성이 제네릭에서 큰 차이를 가져옵니다.

 

List는 Animal(상위타입)으로 Dog, Cat(하위타입)을 가질 수 있습니다.

@Test
fun `List - Animal, Dog을 인자로 받을 수 있다`(){
	val animalsWithDog: List<Dog> = listOf(Dog())
	val animalsWithAnimal: List<Animal> = listOf(Animal())
	receiveAnimal(animalsWithDog)
	receiveAnimal(animalsWithAnimal)
}


private fun receiveAnimal(animals: List<Animal>){
	println(animals.size)
	//어.. Dog로 들어왔는데 Cat을 add하거나 조작하면 어떻게 되죠?
}

Animal을 인자로 받는다는 것은 Dog, Cat, Animal 등을 함수인자로 넘길 수 있음을 의미합니다.

그러면 Dog를 넘겼는데 메서드에서 Cat을 add 하면 어떻게 될까요?

List는 add, remove 등의 함수가 없는 불변성을 가지기 때문에 이런 문제에서 해방될 수 있습니다.

 

MutableList는 Animal(상위타입)으로 Dog, Cat(하위타입)을 가질 수 없습니다.

    @Test
    fun `MutableList - Animal은 인자로 받을 수 있지만 Dog는 받을 수 없다`(){
        val animalsWithDog: MutableList<Dog> = mutableListOf(Dog())
        val animalsWithAnimal: MutableList<Animal> = mutableListOf(Animal())
        receiveAnimalWithMutableList(animalsWithDog) //Dog는 하위타입이여서 컴파일에러가 발생!
        receiveAnimalWithMutableList(animalsWithAnimal)
    }


    private fun receiveAnimalWithMutableList(animals: MutableList<Animal>){
        println(animals.size)
        animals.add(Dog()) //Cat으로 제네릭 타입이 들어왔는데 Dog를 추가하면?
        animals.add(Cat()) //Dog로 제네릭 타입이 들어왔는데 Cat를 추가하면?
    }

TypeMistacth 컴파일 에러가 발생하면서 해당상황 자체를 막아버립니다.

 

 

Kotlin에서는 이런 상황을 어떻게 구분할까요? - out

public interface List<out E> : Collection<E> {...}
public interface MutableList<E> : List<E>, MutableCollection<E> {

 

List의 경우 out 키워드가 존재하고 MutableList의 경우에는 out 키워드가 존재하지 않습니다.

out 키워드를 통해서 하위타입을 사용할 수 있게 지원합니다.

 

MutableList와 out 합치기

    @Test
    fun `MutableList out과 함께라면 - Animal, Dog을 인자로 받을 수 있다`(){
        val animalsWithDog: MutableList<Dog> = mutableListOf(Dog())
        val animalsWithAnimal: MutableList<Animal> = mutableListOf(Animal())
        receiveAnimalWithMutableList(animalsWithDog) //더 이상 여기서 컴파일 에러가 발생하지 않음
        receiveAnimalWithMutableList(animalsWithAnimal)
    }


    private fun receiveAnimalWithMutableList(animals: MutableList<out Animal>){
        println(animals.size)
        animals.add(Dog()) //컴파일 에러 발생
        animals.add(Cat()) //컴파일 에러 발생
    }

out을 통하여 약간의 제약을 풀어달라고 요청할 수 있습니다.

제네릭 하위 타입이 허용되지만 add 등의 수정을 하려고 하면 컴파일 에러가 발생하게 됩니다.

즉 read만 가능해지면서 mutableList이지만 불변성을 띄게 됩니다.

 

 

 

그러면 상위 타입에 쓰기를 활용하고 싶다면? - in

이번에는 in 키워드를 통하여  컴파일러에게 상위타입으로 변환할 수 있도록 권한을 요청할 수 있습니다.

@Test
fun `in 키워드 활용기`(){
	val cats = mutableListOf(Cat(),Cat(),Cat())
	val `최상위 부모로 변환하기` = mutableListOf(Any(), Any(), Any())
	copyFromTo(cats, `최상위 부모로 변환하기`) // Error! type mismatch - 컴파일 에러 발생
	println(`최상위 부모로 변환하기`)
}

private fun copyFromTo(from: MutableList<out Animal>, to: MutableList<Animal>){
	for (i in from.indices) {
		to[i] = from[i]
	}
}

Any는 최상위 부모 클래스입니다.

즉, 어떤 클래스던 간에 Any 클래스로 변환될 수 있습니다.

하지만 코틀린에서는 제네릭이 이런 상황도 방지합니다.

 

이때 in 키워드를 활용해 주면 더 이상 컴파일 에러가 발생하지 않습니다.

@Test
fun `in 키워드 활용기`(){
	val cats = mutableListOf(Cat(),Cat(),Cat())
	val `최상위 부모로 변환하기` = mutableListOf(Any(), Any(), Any())
	copyFromTo(cats, `최상위 부모로 변환하기`) // 더 이상 컴파일에러가 발생하지 않는다!
	println(`최상위 부모로 변환하기`)
}

private fun copyFromTo(from: MutableList<out Animal>, to: MutableList<in Animal>){
	for (i in from.indices) {
		to[i] = from[i]
	}
}

 

 

 

 

 

 

참고자료

https://readystory.tistory.com/201