본문 바로가기

프로그래밍 언어 기초/KOTLIN

[Kotlin] 객체지향 프로그래밍 - 제네릭(Generic), in, out, where

개요

오늘은 코틀린의 제네릭(Generic)에 대해서 알아보도록 하겠습니다.


코틀린 제네릭(Generic)

개인적으로 객체지향 프로그래밍에서 정수는 제네릭(Generic)이라고 생각합니다.

지금까지 코틀린을 통한 객체지향 프로그래밍 기술에 대해 살펴보았는데요.

객체지향 프로그래밍(Object-Oriented Programming),

줄여서 OOP는 수많은 객체가 서로 얽히고설켜서 복잡한 프로그램을 동작하게 합니다.

특히, 상속(Inheritance)을 통해 객체 간의 상호 관계를 설정할 수 있으므로

최소한의 단위로 최대 경우의 수만큼 코드를 재사용할 수 있다는 점은 OOP의 가장 큰 매력이라고 생각합니다.

객체지향 언어를 사용하는 개발자는 이러한 OOP의 고유한 특성을 잘 활용하여,

중복되는 코드를 하나로 합치고 코드의 재사용을 통해 잘 동작하는 프로그램을 설계할 수 있습니다.

그런데, 이렇게 코드의 중복을 줄이는 과정을 반복하다 보면 한 가지 문제점을 마주하게 됩니다.

[Kotlin] 객체지향 프로그래밍 - 추상 클래스와 인터페이스 편에서 다룬 예시처럼

자동차, 오토바이, 비행기는 모두 `엔진`이라는 공통점을 가지지만,

엄밀히 따지면 `엔진`의 유형은 공학적으로 달라질 수 있어서 

완벽하게 모든 `엔진`에 대응할 수 있는 클래스나 객체를 만드는 것은 무척 어려워집니다.

인터페이스는 클래스를 구성하는 프로퍼티, 메서드를 추상화된 형태로 구현을 강제하기 때문에

코드의 중복을 줄이고, 다양한 상황에 대처할 수 있으며 개발자가 실수하지 않도록 도와줍니다.

하지만, 이 인터페이스도 결국에는 함수의 리턴 타입, 파라미터 등을 `미리` 정해주어야 하고

만약 같은 이름을 가진 함수가 여럿 필요하다면 오버 로딩을 통해 여러 개를 만들어야 할 수도 있습니다.

즉, 인터페이스도 때에 따라서는 모든 경우의 수에 대응하기 어려울 수 있다는 것입니다.

 

그런데, 만약 인터페이스처럼 더 축약된 형태로 공유할 수 있는 `무언가`가 있으면 어떨까요?


바로 이러한 상황에서 활용해볼 수 있는 기술이 제네릭(Generic)입니다.

코틀린 인터페이스에서는 추상화된 메서드나 프로퍼티를 통해 이를 상속받을 클래스에서 구현을 강제할 수 있었습니다. 

그래서 함수의 바디나 프로퍼티의 초기값과 같은 `실체` 부분을 만드는 것을 자식 클래스에 위임하고 결정할 수 있었습니다.

제네릭은 여기서 한 단계 더 나아가 프로퍼티의 타입, 메서드의 리턴 타입 

심지어는 클래스 그 `자체`도 추상화하여 이를 사용할 곳에서 `결정`하게 할 수 있습니다.

그렇기에 이를 잘 활용하면 제네릭은 모든 클래스에서 활용할 수 있는 `도구`를 만드는 데에도 활용할 수 있습니다.

아래의 코드를 살펴보도록 하겠습니다.

 

// Mutable List 생성
val numbers: MutableList<Int> = mutableListOf()
// 요소 추가
numbers.add(1)
numbers.add(2)


코틀린에서는 List나 MutableList를 통해 여러 개의 데이터를 하나의 변수로 관리할 수 있습니다.

그런데, 자세히 살펴보면 이 리스트는 다양한 자료형에 모두 대응할 수 있습니다.

 

위 예제 코드에서는 정수형(Int)을 다루는 리스트로 사용했지만,

여기서 더 나아가 임의의 클래스 A가 있다면, 이 클래스의 인스턴스도 담아서 사용할 수 있습니다.

 

이러한 활용이 가능한 것이 바로 제네릭(Generic) 덕분입니다.

제네릭은 이처럼 클래스에서 다룰 변수의 타입을 파라미터화 하여 다양한 경우에 대응할 수 있도록 해줍니다.

코틀린에서 제네릭은 아래와 같이 사용할 수 있습니다.

 

class Box<T>(t: T) {
    var boxData = t
}

class Example {
    val sampleValue = 123;
}

fun main() {
   val myBox1 = Box<Int>(1);
   println(myBox1.boxData); // 1
   val myBox2 = Box<Example>(Example());
   println(myBox2.boxData); // Example@52cc8049
}


위 코드에서 제네릭이 적용된 클래스 Box는 프로퍼티로 boxData를 가집니다.

이때 프로퍼티 boxData는 아직 자료형이 결정되지 않은 형태로,

메인 함수에서 myBox1 , myBox2 변수를 만드는 과정에서 <>에 들어오는 자료형을 통해 프로퍼티의 자료형을 확정합니다.

따라서, 서로 다른 자료형인 데이터를 Box 클래스 하나로 다룰 수 있는 것입니다.

 


in 과 out

제네릭에서 사용할 수 있는 문법 중, in과 out이 있습니다.

제네릭에서 in과 out은 타입 매개변수의 변성(variance)을 정의합니다.

in은 타입 매개변수가 해당 클래스의 메소드에 대해 입력(인자로 사용)으로만 사용됨을 나타내고, 

out은 타입 매개변수가 해당 클래스의 메소드에 대해 출력(리턴 값으로 사용)으로만 사용됨을 나타냅니다.

 

interface Source<out T> {
    fun next(): T
}

interface Sink<in T> {
    fun consume(t: T)
}


위 코드에서 인터페이스 Source는 out 키워드를 통해 제네릭 T가 반환 값(return)으로 사용된다는 것을 명시했습니다.

그리고 인터페이스 Sink는 in 키워드를 통해 제네릭 T가 입력값으로 사용됨을 명시했습니다.

그렇다면, 아래와 같이 Source 인터페이스를 변경하면 어떻게 될까요?

 

interface Source<out T> {
    fun next(): T
    fun input(t:T)
}

 

결과는 아래와 같은 에러가 발생하고, 코틀린에서 이를 허용하지 않고 있음을 알려줍니다.

Type parameter T is declared as 'out' but occurs in 'in' position in type T

 

반대로 아래와 같이 Sink 인터페이스를 변경해보도록 하겠습니다.

interface Sink<in T> {
    fun consume(t: T)
    fun output():T
}

 

이 경우에도 마찬가지로 아래와 같은 에러가 발생하고, 역시 허용하지 않음을 알려줍니다.

Type parameter T is declared as 'in' but occurs in 'out' position in type T

 

제네릭은 근본적으로 너무 다양한 경우에 대해서 활용할 수 있으므로

잘못 사용할 때 코드의 일관성을 해치고, 가독성을 저해할 수도 있습니다.

코틀린에서는 이러한 문제점을 보완하는 방법으로 in과 out을 제공하고 있으며,

자유도가 높은 제네릭의 용례를 제한하여 일관되고 가독성 높은 코드를 작성하도록 강제할 수 있습니다.


상위 타입 제한 (Upper Bound)

코틀린에서 제네릭은 상위 타입을 제한할 수 있습니다.

이는 타입으로 들어올 값이 특정 타입의 하위 클래스임을 보장하는 것을 말합니다.

보다 쉬운 이해를 위해 예시를 한 가지 들어보겠습니다.

예를 들어 어떤 클래스 A와 B가 있다고 하겠습니다.

클래스 B는 A를 상속받아, 클래스 A가 부모 클래스, 클래스 B가 자식 클래스의 관계를 형성했다고 합니다.

따라서 클래스 A는 클래스 B의 상위 클래스가 되었습니다.

 

그리고 제네릭을 사용하여 A, B 클래스만 사용할 수 있는 함수를 만든다고 했을 때,

코틀린에서는 제네릭에 콜론(:)을 사용하여 상위 타입을 제한할 수 있습니다.

 

open class A;
class B : A();
class C;

fun <T:A> sayConvertibility(item:T){
    println("item is able to convert to A!")
}
fun main() {

    val instanceA = A();
    val instanceB = B();
    val instanceC = C();

    sayConvertibility(instanceA);
    sayConvertibility(instanceB);
    // sayConvertibility(instanceC);
}

 

위 코드를 살펴보면 함수 sayConvertibility에는 제네릭이 적용되어 있습니다.

그런데, 콜론(:)을 사용한 A 타입에 대한 상위 타입 제한(Upper Bound)을 적용하였습니다.

따라서, 함수 sayConvertibility는 A 클래스를 포함한 자식 클래스만 사용할 수 있습니다.

메인 함수에서 위와 같이 클래스 A, B, C의 인스턴스를 생성했을 때
클래스 A와 B는 부모-자식 관계에 있으므로 함수 sayConvertibility를 사용할 수 있습니다.

그러나 클래스 C는 이들과 아무런 관계가 없으므로 함수 sayConvertibility를 사용할 수 없습니다.

따라서 현재 코드에서 메인 함수를 실행시키면 아래와 같은 오류가 발생합니다.

 

Type mismatch: inferred type is C but A was expected


이는 함수 sayConvertibility에 상위 타입 제한을 통해 제네릭의 범위를 제한했기 때문입니다.

이처럼 코틀린 제네릭에서는 상위 타입을 통해 특정 클래스를 포함하는 자식 클래스로 그 범위를 제한할 수 있습니다.

 


하위 타입 제한 (Lower Bound)

코틀린에서는 이와 반대로 하위 타입을 제한할 수 있습니다.

이는 상위 타입과는 반대로 특정 제네릭 타입을 포함해 상위 타입이어야 함을 지정하는 것입니다.

마찬가지로, 더 쉬운 이해를 위해 예시를 한 가지 들어보도록 하겠습니다.

만약 부모-자식-손자의 관계를 갖는 클래스 Parent, Child, Grandson이 있다고 합니다.

이때 부모, 자식에만 적용되는 함수를 만들어야 한다면,

Child 클래스를 기준으로 하위 타입 제한(Lower Bound)을 적용해볼 수 있습니다.

아래의 코드를 한 번 보도록 하겠습니다.

 

fun <T> appendItem(list: MutableList<in T>, item: T) {
    list.add(item)
}


함수 appendItem에는 제네릭이 적용되어 있고, list 파라미터에 <in T> 을 통해 하위 타입 제한이 적용되어 있습니다.

이때, 함수 appendItem의 파라미터 item에 Child의 인스턴스가 온다면,

파라미터 list에는 Child보다 상위 클래스를 갖는 요소가 담긴 리스트(List)가 와야 합니다.

이를 코드로 작성해보면 아래와 같습니다.

 

open class Parent;
open class Child : Parent();
class Grandson : Child();

fun <T> appendItem(list: MutableList<in T>, item: T) {
    list.add(item)
}

fun main() {
    // 하위 타입 제한
    val parentList = mutableListOf(Parent())
    val childList = mutableListOf(Child())
    val grandsonList = mutableListOf(Grandson())

    val item = Child()
    appendItem(parentList, item);
    appendItem(childList, item);
    // appendItem(grandsonList, item); // Type mismatch: inferred type is Child but Grandson was expected
}


위 코드에서는 클래스 Parent, Child, Grandson을 요소로 갖는 리스트를 3개 만들었습니다.

 

그리고 함수 appendItem을 통해 Child의 인스턴스 item을 추가합니다.

이때, 함수 appendItem에는 하위 타입 제한(Lower Bound)이 적용되어 있으므로

함수 appendItem을 통해 Child 인스턴스를 grandsonList에 추가할 수 없습니다.

왜냐하면, 클래스 Grandson은 Child 클래스를 기준으로 자식 클래스이므로 Child보다 상위 클래스가 아니기 때문입니다.


널 가능 타입 제한 (Nullable Type Bound)

코틀린의 자료형은 크게 Null을 허용하는 변수와 허용하지 않는 변수로 나눌 수 있습니다.

프로그램에서 null은 본래 초기화된 데이터의 상태를 나타낼 목적으로 설계된 특수 값이지만,

 

이 값은 실행 중인 프로그램에서 예기치 못한 각종 오류를 발생시키고

궁극적으로는 NullPointerException을 일으켜 프로그램을 비정상적으로 종료 되게 합니다.

코틀린은 제네릭에서도 이러한 Null과 관련된 제약을 부여할 수 있습니다.

만약 코틀린에서 다룰 수 있는 어떤 값이라도 가능한데,

Null만 허용하지 않겠다고 한다면 아래와 같이 제네릭을 적용할 수 있습니다.

 

fun <T : Any> processItem(item: T) {
    // T는 널이 아닌 타입이어야 함
}


이때, 함수 processItem에는 null이 올 수 있는 값이 들어올 수 없습니다.

즉 null을 허용하지 않으며 만약 null 허용 인자가 들어오면 Type mismatch 오류를 발생시킵니다.

fun main() {
    // null 가능 타입 제한
    val itemA = 123;
    val itemB = "Hello Kotlin!"
    val itemC = null

    processItem(itemA);
    processItem(itemB);
    // processItem(itemC); // Type mismatch: inferred type is Nothing? but TypeVariable(T) was expected
}

where

코틀린은 제네릭에 where를 사용하여 특정 타입에 대한 조건을 설정하거나,

여러 제네릭 타입 간의 관계를 명시할 수도 있습니다.

 

fun <T> printList(list: List<T>) where T : Number {
    list.forEach {
        println(it);
    }
}

 

위의 예제에서 함수 printList는 제네릭 T를 요소로 하는 리스트를 출력하는 함수입니다.

이때, T에 올 수 있는 값은 where 절을 통해 Number로 제한하고 있습니다.

 

코틀린에서 Number 클래스는 숫자형 자료형의 상위 객체이므로

결과적으로 printList 함수는 숫자 데이터만 출력할 수 있습니다.

 

fun <T> printList(list: List<T>) where T : Number{
    list.forEach {
        println(it);
    }
}

fun main() {
   printList(listOf(1,2,3));
   // printList(listOf("a", "b", "c")) // Type mismatch: inferred type is String but Number was expected
}


위 코드에서 함수 printList를 두 번 호출하고 있습니다.

처음에는 1, 2, 3과 같은 Int형 리스트를 매개 변수로 넘겨주고 있고,

 

두 번째에서는 "a", "b", "c"와 같은 문자열 리스트를 매개 변수로 넘겨주고 있습니다.

이 경우, 첫 번째 printList 에서는 정상적으로 결과가 출력되나 

두 번째에서는 Type mismatch 에러가 발생하여 사용할 수 없음을 알려주고 있습니다.

 

다음으로 조금 더 복잡한 예시를 살펴보겠습니다.

 

class Animal;

class Person(val name: String, val age: Int) : Comparable<Person> {
    override fun compareTo(other: Person): Int {
        return this.age - other.age
    }
}

fun <T> printClassList(list: List<T>)
    where T : Any,
          T : Comparable<T> {
    list.forEach {
        if(it is Person){
        println(it.name);
        }
    }
}

fun main() {

   val personList = listOf(Person("Alice", 30), Person("Bob", 17))
   printClassList(personList);

   val animalList = listOf(Animal(), Animal())
   printClassList(animalList);
}


위 코드에서는 클래스 Animal과 Person 두 개를 선언했습니다.

그리고 함수 printClassList를 제네릭을 통해 구현했는데, 

where 절을 통해 Comparable을 구현했는지 검사하는 조건을 추가했습니다.

그리고 메인 함수에서 각각 Animal과 Person의 배열을 만들고

printClassList 함수를 통해 출력을 시도하면 다음의 에러를 마주합니다.

Type mismatch: inferred type is Animal but Comparable<TypeVariable(T)> was expected

 


이는 함수 printClassList가 제네릭과 where을 통해 Comparable를 구현했는지 검사하는 부분을 추가했기 때문입니다.

클래스 Person은 Comparable을 구현했기 때문에 

Person 인스턴스를 담은 리스트 personList를 함수 printClassList에 넣으면 정상적으로 동작합니다.

 

하지만 클래스 Animal은 Comparable을 구현하지 않았기 때문에, 

Animal 인스턴스를 담은 리스트 animalList를 함수 printClassList에 넣으면 오류가 발생하는 것입니다.

이처럼 코틀린에서 where를 사용해 제네릭의 조건문으로써 활용할 수 있습니다.