
동기
Generics 에 관해서는 이전에도 다룬 적이 있습니다. 다룬 지 2년 이상 지난 이 시점에서 문득, '나는 과연 Generics 에 대해 제대로 알고 있는가'란 의문이 들어 다시금 포스팅하게 되었습니다. 이번에는 out 과 in 키워드를 읽기 전용, 쓰기 전용의 관점에서 이해해보려고 노력했습니다.
변성
기본적으로 상속 관계에 따른 변성(Variacne)이 존재하기에, 우리는 다음과 같은 코드를 작성할 수 있습니다.
fun main() {
val animal: Animal = Bear()
}
open class Animal {}
class Bear : Animal() {}
이러한 코드를 작성할 수 있는 이유는 Bear 가 Animal 의 자식에 해당하기 때문에 Animal 의 모든 함수 및 프로퍼티를 상속받기 때문이고, 이러한 특성이 곧 '자식은 부모의 모든 필요(함수 호출, 프로퍼티 참조)에 부모를 대체할 수 있다'를 보증합니다.
animal 을 참조 및 사용하는 입장에서 중요한 것은 Animal 의 메서드와 프로퍼티이고, Bear 는 어차피 Animal 의 모든 함수와 프로퍼티를 보유하고 있으므로, 이를 참조 및 호출하는 입장에서는 참조하는 대상이 Animal 타입이든 Bear 타입이든 별 상관이 없게 됩니다.
이것이 변성(변하는 성질)이며, 저는 이를 OOP의 꽃이라 생각합니다.
Generics
좀 일관적이면 좋았을텐데, 아쉽게도 Generics 는 기본적으로 무공변적(Invariant)입니다. 그렇다고 Generics 를 적용한 클래스 자체가 상속 구조를 가지지 못하는 건 아닙니다. 전달되는 타입 파라미터에 변성이 없습니다. 즉, 다음과 같이 코드를 작성할 수 없습니다.
fun main() {
var generics = Generics<Animal>()
generics = Generics<Bear>() // 컴파일 에러
}
open class Animal {}
class Bear : Animal() {}
class Generics<T> {}
이유는 간단한데, Animal 은 Bear 의 수퍼 타입이지만, Generics<Animal> 은 기본적으로 Generics 타입이라 Generics<Animal> 이 Generics<Bear> 의 수퍼 타입이 될 수는 없기 때문입니다. 이를 가능케 하기 위한 방법이 Generics 의 타입 파라미터 앞에 out 키워드를 추가하는 것입니다.
fun main() {
var generics = Generics<Animal>()
generics = Generics<Bear>() // 컴파일 가능
}
open class Animal {}
class Bear : Animal() {}
class Generics<out T> {}
out 과 반대되는 개념인 in 키워드도 존재합니다. 이는 Generics<SubType> 에 Generics<SuperType> 을 할당할 수 있도록 합니다.
fun main() {
var generics = Generics<Bear>()
generics = Generics<Animal>() // 컴파일 가능
}
open class Animal {}
class Bear : Animal() {}
class Generics<in T> {}
out, in
Generics 는 애초에 무공변적이라, out 이나 in 을 사용하는 것에 비해 타입 안정성이 높습니다. 지정한 타입 외에 그 어떤 타입도 적용할 수 없으니까요. 그래서 기본적으로는 아무 키워드도 추가하지 않는 편이 가장 안전합니다. 하지만 이 경우, 다소 경직된 구조의 코드를 작성할 수 밖에 없도록 제한되기 때문에, 코드 재사용성이 떨어지게 됩니다.
즉, out 과 in 은 코드 범용성을 위해 무공변적인 Generics 가 가진 타입 안정성의 제한을 '일부 허용해주는' 역할을 수행합니다. 이를 통해 Generics 의 타입 안정성은 존중하면서, 동시에 유연한 코드를 작성할 수 있게 됩니다.
이걸 반대로 생각하면 'out 과 in 은 특정 행위를 제한한다' 라고 이해할 수도 있는데요. Generics 는 기본적으로 읽기 및 쓰기 작업이 모두 가능합니다. 안드로이드의 LiveData<T> 같은 걸 떠올리면 이해하기 쉽습니다. 여기에 out 또는 in 키워드를 적용하면 읽기 또는 쓰기 작업이 제한되는 겁니다.
즉, 타입 캐스팅에 대한 유연성을 허용함에 따라 발생할 수 있는 문제를 방지하기 위해 Generics 를 읽기 또는 쓰기 전용으로 변경한다 로 이해하는 편이 좋습니다.
Out
Out 은 Generics 에 공변성을 부여합니다. 즉, 타입 파라미터에 전달되는 타입의 전통적인 상속 구조를 존중하는 형태입니다. 이를 통해 Generics 는 읽기 전용이 됩니다.
val dog = Dog()
val wolf = Wolf()
var generics: Generics<Dog> = Generics(dog)
fun main() {
generics = Generics<Wolf>(wolf)
generics.getValue().bark()
}
open class Dog {
open fun bark() {
println("Bow-Wow")
}
}
class Wolf : Dog() {
fun barkAtTheMoon() {
println("Aw-woo")
}
}
class Generics<out T>(private val value: T) {
fun getValue(): T {
return value
}
}
전통적인 상속 구조가 적용되므로, generics.getValue().bark() 와 같은 코드가 정상적으로 컴파일됩니다. 이는 '선언한 타입의 하위 타입으로 재할당이 가능하게 하는 대신에 Generics 클래스를 선언할 때에 지정한 타입(수퍼 타입)으로만 값을 얻겠다'는 의미입니다. 그래서 Wolf 로 지정하고 재할당 해도 barkAtTheMoon() 을 호출할 수 없습니다.

즉, 어차피 선언한 타입의 서브 타입만 재할당 될 수 있으므로, 해당 Generics 클래스를 통해 어느 값을 어느 시점에 획득하든 모두 선언한 타입의 서브 타입 객체일 것입니다. 그럼 그냥 여러 상속 구조 속 타입들 중 가장 안전한 수퍼 타입으로 객체를 획득하겠다는 겁니다.
수퍼 타입에 선언한 메서드나 프로퍼티는 모두 서브 타입에도 있기 때문에, 값을 획득해서 무얼하든 타입 안정성이 보장됩니다. 즉, 읽기 전용이 됩니다.
읽기 전용이 된다는 건 쓰기 작업을 할 수 없다는 걸 의미하며, 클래스 내 메서드의 파라미터로 타입 T 를 사용할 수 없습니다.
in
in 키워드는 Generics 에 반공변성을 부여합니다. 이는 서브 타입으로 할당한 Generics 를 수퍼 타입으로 할당할 수 있게 하는데요. 이를 통해 Generics 는 쓰기 전용이 됩니다.
val dog = Dog()
val wolf = Wolf()
var generics: Generics<Wolf> = Generics(wolf)
fun main() {
generics = Generics<Dog>(dog)
generics.setValue(wolf)
}
open class Dog {
open fun bark() {
println("Bow-Wow")
}
}
class Wolf : Dog() {
fun barkAtTheMoon() {
println("Aw-woo")
}
}
class Generics<in T>(private var value: T) {
fun setValue(newValue: T) {
value = newValue
}
}
이렇게 작성하는 경우, Generics 내에 선언하는 메서드의 반환 타입을 T 로 설정할 수 없게 됩니다. 즉, Generics 를 통해서 값을 획득하는 행위 자체가 불가능해지는데 이유는 다음과 같습니다.
변수 generics 를 Generics<Wolf> 로 선언한 뒤, 이를 Generics<Dog> 으로 재할당 하더라도 컴파일러는 이를 여전히 Generics<Wolf > 로 인식하고 있기 때문에, 만약 값을 획득하는 행위가 가능해진다면 다음과 같은 코드에서 문제가 발생할 것입니다.
generics.getValue().barkAtTheMoon()
in 은 데이터를 반환할 필요가 없고 그저 처리만 하는 경우에 유용하게 사용 될 수 있습니다.
Kotlin 내에서는 컬렉션의 Comparator 의 구현에 사용되고 있습니다. 특별한 기능을 수행하도록 만들기 위해 사용한다기 보다, 보다 더 유연하고 재사용성 높은 코드를 작성하기 위해 사용됩니다.
Generics 를 네트워크 관련 처리에 주로 사용해왔는데, 사용하면서도 '이게 왜 이렇게 동작하지?' 라 생각했던 기억이 있습니다. 꽤 자주 사용되는데, 그만큼 제대로 알기는 쉽지 않은 부분이라고 생각합니다. 두고 두고 봐야겠네요.
'Kotlin' 카테고리의 다른 글
Effective Kotlin, 좋았던 부분들. (0) | 2023.11.04 |
---|---|
Coroutines 파헤치기 (1) | 2023.10.06 |
Flow Cancellation (feat. Exception Handling) (0) | 2023.09.24 |
Coroutine Details (0) | 2023.06.22 |
Kotlin Closure (0) | 2023.05.04 |