아이엠 !나이롱맨😎
article thumbnail
반응형

 

재사용을 위한 과도한 유연성

다양한 타입에 동일한 로직을 적용하기 위해 코드 재사용을 과도하게 하려는 경우가 있습니다.

파라미터를 전부 Any로 받는 것이 대표적인 예시입니다.

open class Car
class K3 : Car()
class Avante : Car()

//“차에 대한 설명서” 함수
fun getInstruction(car: Array<Any>) {
    println("car = ${car.size}")
}

많이 극단적인 예제이기는 합니다..

open class Fruit
class Apple : Fruit()

fun main() {

    val k3 = K3("k3", "기아")
    val avante = Avante("아반떼", "현대")

		val fruits: Array<Apple> = arrayOf(Apple())
    getInstruction(fruits) // Type mismatch 에러
}

getIstruction 함수는 “차에 대한 설명서” 의도로 만든 것이지만 이를 모르고 과일인 Apple 타입을 가진 Array를 집어 넣을 수 있습니다. 인텔리제이가 워낙 똑똑해서 미리 이런 에러를 잡아 내긴 하지만 재사용을 위해 Any를 자주 사용하는 것은 타입 안정성을 저하시킬 수가 있습니다. ⇒ 제네릭을 사용하는 이유

 


제네릭은 기본적으로 불변성

자바에서는 기본적으로 제네릭은 타입 불변성을 강요했습니다. 제네릭 함수가 파라미터 타입 T 를 받는다면 T 의 부모 클래스나 자식 클래스를 사용하는 것이 불가능했습니다. 즉, 타입이 정확히 일치해야만 했습니다.

코드에 제약은 크게 걸수록 안정성은 높아진다고 생각합니다.

open class Car
class K3 : Car()
class Avante : Car()

fun getInstruction(car: Array<Car>) {
    println("car = ${car.size}")
}
fun main() {
    val cars = arrayOf(K3())

    getInstruction(cars)// Type mismatch 에러
}

제네릭은 불변성이므로 부모 클래스든, 자식 클래스든 타입이 정확히 일치하지 않으면 에러를 발생시킵니다.

fun getInstruction(car: List<Car>) {
    println("car = ${car.size}")
}

fun main() {
    val cars = listOf(K3())

    getInstruction(cars)
}

하지만 car: Array<Car> 를 car: List<Car> 로 바꿔준다면 위 코드는 문제 없이 컴파일이 됩니다.

 

제네릭은 기본적이고 불변성이고, 타입이 정확인 일치해야 받을 수 있는데 왜 Array<T>는 안되고, List<T>는 되는 걸까요?

 

Array<T> 는 가변(mutable) 이지만 List<T> 는 불변(immutable) 입니다. Array 의 아이템은 변경할 수 있지만 List 의 아이템은 변경할 수 없습니다. 변경할 수 없다는 점 때문에 안정성이 보장된다 생각하여 getInstruction(car: List<Car>)에서 받을 수 있습니다.

 

또한 Array<T> 는 class Array<T> 로 정의되어 있고, List<T> 는 interface List<out E> 로 정의되어있는데, 바로 List 제네릭 타입에 사용된 out 덕분입니다.

 


<out T> 으로 공변성(covariance) 사용하기

fun copyFromTo(from: Array<Car>, to: Array<Car>){
    for (i in from.indices){
        to[i] = from[i]
    }
}

fun main() {
    val cars1 = Array<Car>(3) { _ -> Car() }
    val cars2 = Array<Car>(3) { _ -> Car() }
    copyFromTo(cars1, cars2)
}

copyFromTo() 함수는 from 배열의 객체를 순회하면서 to 배열로 값을 넣어주는 함수입니다.

이때 전달 받은 두 배열의 크기는 동일하다고 가정하겠습니다.

 

위와 같이 구현 할 경우에는 별 문제 없이 동작합니다. 왜냐하면 copyFromTo() 함수의 파라미터로 Car 타입의 배열로 정의했고, 그에 맞춰 넘겨줬기 때문입니다.(불변성)

fun main() {
    val cars1 = Array<K3>(3) { _ -> K3() }
    val cars2 = Array<Car>(3) { _ -> Car() }
    copyFromTo(cars1, cars2) // type mismatch
}

하지만 위 코드는 에러가 발생합니다. 왜냐 코틀린은 Array<Car> 자리에 Array<K3> 를 전달하지 못하도록 막기 때문입니다.

(불변성 위배)

 

이때 out 을 사용하면 type mismatch 을 해결할 수 있습니다.(공변성 사용)

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

이때 주의 할점이 있습니다. 아래와 같은 코드가 그 경우입니다.

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

		from[0] = K3() // 얘!
}

 

Nothing을 요구

인텔리제이는 이렇게 말하고 있습니다.

 

제네릭 타입에 out 키워드를 넣어준 from은 Nothing 타입을 write 해주길 원하는데, 왜 K3 타입을 write하는가?”

 

이는 from이 <out T> 제네릭 타입을 쓰면서 공변성을 가지게 되어 생긴 문제인데,

공변성을 가지게 되면 값에 대한 read만 가능하고, write이 불가능해집니다.

 


Read만 가능하고 Write는 불가능한 이유

 

read만 가능한 이유

 

Array<out Car> 타입인 from은 부모가 Car인 것을 컴파일러가 인지하고 있습니다. 그렇다면 from의 값을 read 할 때에는 Car, K3, Avante 중 하나일 것도 알 것이며, 이는 모두를 포함하는 Car로 할당 해 줄 수 있으므로 read를 할 때에는 문제가 발생하지 않습니다.

 

write가 불가능한 이유

 

그러나 write의 경우에는 좀 다릅니다. 공변성을 사용한 from에게 실제로는 Array<K3>을 넘겨줬습니다.

그러나 메소드에서 from은 Array<out T>로 선언되어 있으므로, 실제 from이 Array<Car>인지, Array<K3>인지, Array<Avante>인지 모릅니다. from의 실제 Array Type을 모르는 메소드가 함부로 값을 write 할 수 없으므로 문제가 발생합니다.

 

즉, out 키워드를 사용하여 공변성을 사용할 경우에는 읽기만(read-only) 사용 가능하다고 볼 수 있는 것이죠.

 

*공변성 : A 타입이 B 타입의 서브 타입일 때, C<A>는 C<B>의 서브타입 입니다.

read-only 가 있다면 반대로 쓰기 전용(write-only) 도 있습니다.

 


<in T> 으로 반공변성(contravariance) 사용하기

공변성을 사용할 때 읽기만 가능하다고 해서 out을 사용하였는데, 반공변성에서는 반대로 쓰기만 가능하여 in을 사용합니다.

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

T 가 Car 타입이거나 Car 의 하위 클래스라면 아무 Array<T> 로부터 객체를 복사하는 것은 정상적입니다. 공변성은 코틀린이 from 파라미터를 유연하도록 하는 게 안전하다는 것을 보장해줍니다.

 

그렇다면 이번에는 to 파라미터를 살펴보겠습니다. to 파라미터의 타입은 변경 불가능한 Array<Car> 입니다.

to 파라미터에 Array<Car> 을 전달한다면 문제가 없겠지만, Car 의 부모 클래스를 전달하고 싶을 때는 어떨까요?

 

이때 in을 사용할 수 있습니다.

fun main() {
    val cars1 = Array<K3>(3) { _ -> K3() }
    val cars2 = Array<Any>(3) { _ -> Any() }
    copyFromTo(cars1, cars2)// type mismatch
}

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

위 코드처럼 하게 되면 제네릭의 불공변성 때문에 에러가 발생하게 됩니다.

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

따라서 in 을 붙어주게 되면 이를 해결할 수 있습니다.

to 파라미터에 원래 요청된 타입이나 그 타입의 조상 타입이 가능하게 하는 권한(반공변성)을 요청한 것입니다.

하지만 아래 코드는 에러를 발생시킵니다. 위에서 말씀 드린것처럼 반공변성은 Write만 가능하기 때문입니다.

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

    var any: Car = to[0]
}

이유는 간단합니다. 반공변성으로 인해 제네릭 타입 또는 조상 타입을 가능하게 하였으므로,

제네릭 타입으로 해당 값을 read 할 때 해당 값의 타입이 제네릭 타입의 조상 타입일 경우 문제가 발생하기 때문입니다.

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

    from[0] = K3() as Nothing

    var any: Any = to[0] as Any
}

위 코드와 같이 강제로 캐스팅 해주면 문제는 없습니다.

결국 타입의 안정성을 보장해주기 위해 제네릭을 사용하게 되는데 out, in 을 이용하여 제네릭이 갖는 타입에 대한 유연성을 조금이나마 늘려줄 수 있습니다.

 

in,out 사용 이유 ⇒ 제네릭을 통해 타입의 안정성 제한을 조금 느슨하게 하면서 코드의 유연성을 올려주기 위해!!

반응형

article prev thumbnail
article next thumbnail
profile on loading

Loading...