본문 바로가기

Kotlin

out, in 제대로 알기

Unsplash, Pierre Bamin.

 

코틀린 프로젝트 내에서 이런 저런 함수들을 타고 타고 가다보면, out, in 이 계속 보입니다.

런타임에 데이터 타입을 결정하는 제네릭(Generic) 과 관련이 있어 보입니다.

코틀린의 제네릭은 자바의 그 것과 같지만, out, in 등의 새로운 키워드가 사용됩니다.

제대로 된 사용법을 알아보고 기록하고자 합니다.


 

코틀린의 클래스, 인터페이스는 타입 인자를 가질 수 있습니다. 자바와 똑같습니다.

다음은 그 구현입니다.

 

class Generic<T>(t: T) {
	var value = t
}

 

Generics 클래스의 인스턴스 생성을 위해, 우리는 타입을 지정하여야 합니다.

 

fun main() {
    val instance = Generic<String>("Aiming Driven Development")
    println(instance.value)
}

class Generic<T>(t: T) {
    var value = t
}

 

하지만, 타입이 추론될 수 있다면 굳이 타입을 선언하지 않아도 됩니다.

 

fun main() {
    val instance = Generic("Aiming Driven Development")
    println(instance.value)
}

 

이미 IDE 차원에서 <String> 을 회색 글씨로 바꿔버립니다. 필요 없으니 삭제하라고 말입니다.

 


 

위처럼, 제네릭의 기본적인 사용법은 굉장히 간단, 간편합니다. 하지만 문제가 있습니다.

프로그램은 현실 세계의 문제를 해결하기 위해 존재하고, 그 문제의 묘사를 위해 클래스와 인터페이스가 존재합니다. 결국, 클래스는 특정한 기능을 수행할 수 있어야하고, 인터페이스는 클래스에게 특정 행위에 대한 대략적 명세를 전달할 수 있어야만 합니다.

 

이 과정 중에서, 코드의 확장성에 집착하여 제네릭을 사용하는 대신 Any 를 쓸 수 있습니다.

당연히 문제가 생길 수 밖에 없습니다. 특정한 책임과 목적을 위해 만들어진 클래스가 다른 목적에 개방될 수 있기 때문입니다.

 

코틀린에서는 이러한 경우를 미연에 방지하기 위해 out, in 키워드를 제공합니다.

 


🤔 out, in 키워드는 왜 존재하나요?

코틀린이 제공하는 기본 클래스는 모두 Any 클래스를 상속받습니다.

그럼 뭔가 Generic<Any>() 타입으로 선언된 변수 generic 에 Generic<String>() 을 할당할 수 있을 것 같지 않나요? 

다운 캐스팅에 비해 업 캐스팅은 제약이 적고, 리스코프 치환 원칙도 준수해야 할 의무가 있으니 말입니다.

 

var generic: Generic = Generic<Any>()
generic = Generic<String>()

 

 

위처럼 하면 될 것 같은데, 안 됩니다.

이유는 간단합니다. 변수 generic 은 Any 타입 데이터가 아니라 Generic<Any> 타입이니까요.

변수 generic 이 Any 타입이었다면 당연히 가능합니다. 다음과 같이 말입니다.

 

var generic: Any = Generic<Any>()
generic = Generic<String>()

 

 

🤔 그럼 리스코프 치환 원칙에 위배되는 것 아닌가요?

그렇진 않습니다. 두 타입의 관계가 규명되어 있어도, 제네릭의 경우에도 꼭 통해야하지는 않으니까요. 다만, 이는 런타임 이전의 이야기이고, 런타임에 타입들의 관계 적용을 위해서는 out, in 이 필요합니다.

 


 

 

class Generic<T> {
}

open class A() {

}

open class B : A() {

}

class C : B() {

}

 

B는 A를 상속받고, C는 B를 상속받습니다. 

 

 

위에서 설명했듯, B는 A의 서브 타입이고 C의 수퍼 타입이지만, Generic<B> 는 Generic<A> 의 서브 타입도 아니고 Generic<C> 의 수퍼 타입도 아닙니다. 그러므로, 당연히 Generic<A> 와 Generic<C> 는 Generic<B> 를 대체할 수 없습니다.

 

1. out

C는 B를 상속받기 때문에, B를 C가 대체하는 것은 리스코프 치환 원칙을 잘 지키는 것입니다.

우리는 이를 제네릭에서도 똑같이 통하게 하고 싶습니다.

 

간단합니다. Generic 클래스의 타입 파라미터에 out 키워드를 붙여줍니다.

 

class Generic<out T> {
    
}

 

클래스의 타입 파라미터 T가 out 으로 선언되면, Generic<수퍼 타입> 은 Generic<서브 타입> 의 수퍼 클래스가 될 수 있습니다. 다른 말로, 클래스 C는 타입 파라미터 T에 대해 공변(共變)성을 띤다고 할 수 있고, 이를 타입 파라미터 T는 공변적이다 라고 할 수 있습니다.

 

 

이제부터 Generic<C>() 는 Generic<B> 를 대체할 수 있습니다. 리스코프 치환 원칙에 위배되지 않는 것입니다. 또한, out 키워드는 제네릭의 타입을 모든 함수의 반환 형식으로 표시되고, 멤버로는 불변값으로 생성되도록 합니다. 즉, 읽기 전용으로 만듭니다. 쓰기 작업을 수행하려고 하면 컴파일 오류가 발생합니다.

 

class Generic<out T : B> {

}

 

제네릭 타입의 상한선(Upper Class)을 지정해 줄 수도 있습니다. 위와 같이 <out T : B> 로 선언해두면

B 또는 B를 상속받는 클래스만 해당 Generic 으로 객체 생성이 가능합니다. 예제에서는 B와 C만 가능합니다. 재미있는 점은, <out T: B> 로 클래스를 작성해도 해당 제네릭 클래스 객체를 B가 아닌 C로 생성하면, 그 제네릭은 C 또는 C를 상속받는 클래스가 아니면 이를 대체할 수 없게 됩니다.

 

 

Generic<A> 는 당연히 생성이 불가능합니다. 상한선을 B로 정해두었기 때문이죠.

이후 Generic<C> 를 생성합니다. Generic<C> 는 out 키워드로 인해 공변적인 Generic 에 의해 Generic<B> 로 대체될 수 있을 것 같지만, 컴파일 오류가 발생합니다. 한 번 타입이 정해지면, Upper Class 와 관계없이 그 타입이 상한선이 되는 것입니다.

 

2. in

코틀린은 out 에 대해 상호보완적 키워드인 in 도 제공합니다.

이는 타입 파라미터를 반(反)공변적으로 만들어 줍니다. 즉, T' 가 T의 서브 타입이면 Generic<T> 는 Generic<T'> 의 서브 타입임을 선언하는 것입니다. 

 

이 역시 간단합니다. out 을 in 으로 바꿔줍니다.

 

class Generic<in T> {

}

 

 

 out 의 경우와 반대로, 이번에는 Generic<C>() 는 할당 불가, Generic<A>() 는 가능합니다.

또한, out 은 읽기 전용인 것에 반하여 in 은 쓰기 전용이 됩니다. 즉, 오브젝트 내의 함수의 매개 변수에 사용될 수 있고, 필드에 가변값으로 생성되도록 합니다. 당연히 읽기 작업을 하려고 하면 컴파일 오류가 발생합니다.

 

in 에서도 역시 out 과 같이 상한선을 설정해 줄 수 있는데, 다음과 같습니다.

 

class Generic<in T : B> {
  
}

 

이와 같이 작성해 줄 경우, B 또는 B 의 서브 타입으로만 해당 클래스의 객체를 생성할 수 있습니다.

 

정리

공변/반공변은 제네릭 파라미터에 선언된 클래스들 간의 수퍼/서브 타입이 결정되어 있다면 그들 사이에 규명된 관계를 제네릭 사이에서도 통하도록 해줍니다.

 


 

평소에 정말 헷갈리던 부분이었는데, 정리를 하고 나니 마음이 편안해집니다.