쾌락코딩

변성(공변성 out, 반공변 in) 이해하기 1편 - 제네릭

|

이 글은 변성을 이해하기 위한 글이지만, 기초부터 닦기 위해 제네릭에 대한 설명도 포함한다. 제네릭을 완벽히 알고있다면, 변성(공변성 out, 반공변 in) 이해하기 2편 부터 봐도 무방하다.

변성이란?

타입이 있는 언어에는 변성이란 개념이 있다. 변성이란, (kotlin in action의 말을 인용하자면) List<String>List<Any>와 같이 기저 타입(List)가 같고, 타입 인자(String, Any)가 다른 여러 타입이 어떤 관계가 있는지 설명하는 개념이다. 예를 들어 방금 위에서 보았듯이 StringAny의 하위 타입이다(따라서 Any타입의 변수에 String 객체를 할당해도 괜찮다). 그렇다면 MutableList<String> 역시 MutableList<Any>의 하위 타입인가? 를 정의 하는 개념이다.

Generic부터 차근차근

변성은 제네릭 클래스를 사용하기때문에 나타나는 개념이다. 따라서 먼저 제네릭을 간단히 살펴보자.

제네릭이란

제네릭의 탄생 원인은 두 가지 관점에서 볼 수 있다.

  1. 형변환을 할 때 생기는 성능적인 이슈 해결
  2. 보다 더 안정적이고 범용적인 api 설계
  • 참고로 제네릭은 동적 타이핑 언어에만 있다. 동적 타이핑 언어인 파이썬이나 자바스크립트에서는 제네릭이라는 편의 장치가 없는데, 그 이유는 동적타이핑 언어의 경우 클래스나 함수를 만들 때, 즉 선언할 때 타입을 고려하지 않기 때문이다. 언제든 동적으로 타입이 변경되는 것을 허용하기 때문에 애초에 모든게 제네릭 한 요소들인 것이다. 반면 자바스크립트에 정적 타입을 지원하는 타입스크립트에서는 제네릭이 존재한다.

성능적인 목적

우선 예시로 사용할 Animal, Cat, Dog 클래스를 만들어 보았다.

abstract class Animal {
    abstract fun eat()
}
class Cat: Animal() {
    override fun eat() {
        println("야옹야옹")
    }
}
class Dog: Animal() {
    override fun eat() {
        println("멍멍")
    }
}

위에서 만든 동물 클래스에 먹이를 주는 feed()라는 함수를 만들어 보자. 함수 하나로 Cat이든 Dog든 상관없이 Animal이기만 하면 먹이를 줄 수 있게끔 범용적인 함수를 만들려고 한다.

fun feed(animal: Animal) {
    animal.eat()
}

위의 함수는 파라미터로 Animal을 받기 때문에 Cat이 오든, Dog가 오든 상관없이 먹이를 줄 수 있다. 이 코드가 그렇게 동작 할 수 있는 이유는 자동 형변환 연산이 있기 때문이다. cat, dog 객체를 넘기면 자동으로 Animal 타입으로 형변환 되어 Animal으로 간주되기에 eat()함수를 호출 할 수 있다. 아주 미미한 차이이긴 하지만, 이렇게 형변환 연산을 거치는 것은 분명히 거치지 않는 것보다는 런타임시 성능적인 저하를 일으킨다. 그렇기 때문에 이런 형변환을 최대한 줄여보기 위한 방안 중 하나로 나온 것이 제네릭이다.

그렇다면 제네릭을 사용해서 코드를 다시 작성해보자.

fun <T: Animal> feed(animal: T) {
    animal.eat()
}

fun main() {
    feed<Cat>(Cat()) // 야옹야옹
}

메인 함수를 보면, feed(Cat())처럼 사용할 수 있는데, 형변환이 일어나지 않는다. 제네릭 함수로 선언했기 때문에 feed함수는 컴파일 단계에서 아래와 같이 컴파일 된다고 한다.

fun feed(animal: Cat) {
    animal.eat()
}

런타임시 형변환이 일어나는게 아니라서 성능상 이슈가 없다.

안정적이고 범용적인 개발 목적

제네릭을 사용하면 안정적이고 범용적인 api 개발이 가능한데, 사실상 위에서 설명한 형변환과 맥락을 같이 한다.

제네릭 없이도 범용적인 api를 만들 수 있다(다만 안전하지 못하다). 제네릭을 사용한 범용 클래스와 제네릭을 사용하지 않고 만든 범용 클래스를 비교해보자.

먼저 제네릭을 사용한MyList라는 클래스를 만들어 보자(단지 List에서 필요한 기능만 가져다 쓰는 Wrapping 클래스이니 실용성은 없다. 예시로만 바라보자).

class MyList<T>() {
    private val myList: mutableListOf<T>()
    fun add(e: T) {
        myList.add(e)
    }
    fun get(index: Int): T {
        return myList.get(index)
    }
}

제네릭을 사용해서 어떤 타입의 객체가 오든 범용적으로 사용할 수 있는 MyList를 만들었다.

위 코드에서 타입 파라미터를 선언한 <> 부분을 지우고, T가 쓰인 곳을 모두 Any로 바꾸면 역시 범용적인 클래스가 된다.

class MyList() {
    private val myList = mutableListOf<Any>() // List도 같은 원리로 제네릭을 제거할 순 있지만, 이미 제공되는 클래스니 건들지 않겠다.
    fun add(e: Any) {
        myList.add(Any)
    }
    fun get(index: Int): Any {
        return myList.get(index)
    }
}

myList가 Any를 타입으로 받기 때문에, 어떤 객체든 올 수 있는 범용적인 api가 되었다. 다만, MyList의 get()을 사용하여 내가 add해주었던 객체를 꺼내올때는, 어떤 타입의 객체를 넣어주었는지에 따라서 매번 형변환을 해주어야 한다는 단점이 있다. 만약 String 타입을 add 했는데, 꺼내올때 실수로 Int형으로 형변환을 하면 런타임 에러가 나게됨으로 안전하지 못한 api다.

지금까지 제네릭에 대해 알아보았다. 정적 타입 언어에서는 제네릭을 사용함으로써 생기는 재미난 현상이 있다. 바로 변성이다. 조금 복잡하지만 알아두면 더 범용적인 api를 개발할 수 있다.

본젹적인 변성에 대한 내용은 변성(공변성 out, 반공변 in) 이해하기 2편 - 변성이란에서 알아보자.

Comments