티스토리 뷰

Mobile/Kotlin

[Kotlin] PECS 원칙

[Ellie] 2020. 3. 26. 23:40

이전 포스팅에서 Covariance와 Contravariance에 대해 알아보았다. 이 포스팅을 읽기 전에 [Kotlin] Covariance & Contravariance을 먼저 보고 오면 이 글을 이해하는 데 도움이 될 것이다.

 


 

이번에는 이전 포스팅에서 언급했던 PECS 원칙과 read-only & write-only 제한에 대해 자세히 알아보자.

 

여기서 가장 중요한 부분은 자식 객체는 부모 객체로 바로 대입(치환, 변환)할 수 있지만, 부모 객체는 자식 객체로 바로 대입(치환, 변환)할 수 없다는 것이다.

 

바로 대입한다의 의미는 다음과 같다.

Child 클래스란 Parent 클래스에서 더 나아가 구체화 되었다고 볼 수 있다. 그렇기 때문에 Parent 클래스에 있는 변수나 메서드는 Child 클래스에서도 사용 가능하고, Child 클래스에서 독립적인 변수나 메서드를 정의할 수 있다.

 

그렇기 때문에 아래와 같이 자식 객체를 부모 객체로 치환하는 것은 문제가 되지 않는다. 자식 객체에는 반드시 부모 객체를 만들 때 필요한 변수나 메서드가 있기 때문이다.

val parent: Parent = Child()        // No problem

이를 업캐스팅이라고 하고, 이는 문제될 것이 없는 순조로운 치환이다.

 

하지만 반대로 부모 객체를 자식 객체로 만들기 위해서는 부모 타입에는 없고 자식 타입에만 있는 변수나 메서드가 필요하다. 그렇기 때문에 아래와 같이 Child 객체를 부모 객체로 치환 했다가 다시 Child 객체로 만들 때 필요한 것이다.

val parent: Parent = Child()
val child: Child = parent as Child

이를 다운 캐스팅이라고 하고, 이는 역방향의 치환으로 주의해서 사용해야 한다. 그렇지 않으면 런타임 시 ClassCastException이 발생할 수 있다.

 

 

이제 이 개념을 기반으로 왜 covariance와 contravariance가 read-only 또는 write-only인지 알아보자.

먼저 객체 지향에서 Generic의 특징은 reusable과 type safety이다. 여기서 type safety는 컴파일 타임에서 type error를 잡아준다는 의미인데, 이 type safety 때문에 covariance와 contravariance가 read-only 또는 write-only 일 수 밖에 없는 것이다.

 

문제는 Generic에서 Parameter로 들어오는 타입끼리 상속 관계라고 해도 Generic<Parent>와 Generic<Child>는 상속 관계가 아니기 때문에 서로 치환시킬 수 없다는 것이다.

이를 극복하기 위해 나온 것이 wildcard인 <?>이다. Generic<?>은 모든 Generic 클래스의 상위 클래스가 되기 때문에 어떤 Generic 객체라도 Generic<?> 타입의 변수로 치환될 수 있다. 이는 Kotlin에서 Generic<Any>와 같다.

 

여기서 나온 것이 Upper Bound Wildcard인 <? extends E>와 Lower Bound Wildcard인 <? super E>이다.

이 둘은 Wildcard와 동일하게 모든 Generic 객체가 들어올 수 있지만, Upper Bound Wildcard는 그 제한을 E를 포함한 E의 자식타입의 Generic 객체만 들어올 수 있도록 제한한 것이고 Lower Bound Wildcard는 그 제한을 E를 포함한 E의 부모타입의 Generic 객체만 들어올 수 있도록 제한한 것이다.

이를 통해 Invariance 했던 Generic 타입이 Covariance & Contravariance도 가능하게 되었다.

Upper Bound Wildcard는 최상위 부모 타입이 E로 제한 되어 있기 때문에 그 안에 자식 객체가 들어갈 수 있어 Covariance에 해당하고, Lower Bound Wildcard는 최하위 부모 타입이 E로 제한 되어 있기 때문에 그 안에 부모 객체가 들어갈 수 있어 Contravariance에 해당한다.

 

그렇다면 여기서 왜 read-only 또는 write-only라는 제한이 생긴 것일까? 그 이유는 Generic은 type safety 해야 하기 때문이다.

 

예를 들어, 상속 관계가 Being -> Person -> Student 순서라고 하자.

아래 예시는 Upper Bound Wildcard인 out의 예시이다.

fun doSomething(list: List<out Being>) {
    val b: Being = Being()
    list.add(b)           // compile error
}

위 코드에서 Upper Bound Wildcard로 선언한 List<? extends Being>으로 만든 list에 add가 불가능한 이유가 뭘까? 이유는 간단하다. 만약 List<Student> 인스턴스가 매개변수인 list로 들어온 경우, 이 list에는 더 이상 Person이나 Being 타입의 인스턴스를 저장할 수 없다. 왜냐하면 Student 타입에 그 부모 타입인 Person이나 Being 타입의 인스턴스를 넣을 수 없기 때문이다.

그렇다면 Being 객체가 아닌 최하위 객체인 Student 객체를 넣는 것으로 하면 어떨까?

이렇게 해도 마찬가지로 컴파일 에러가 뜬다. 그 이유는 Student가 최하위 객체임을 컴파일 타임에 보장할 수 없기 때문에 에러를 내는 것이다.

 

이런 이유로 Upper Bound Wildcard는 Covariance 성질을 갖지만 데이터 변경인 아래와 같은 메서드를 가질 수 없는 것이다.

class Group<out E>(val list: MutableList<E> = mutableListOf()) {
    fun add(e: E) = list.add(e)              // error
    fun remove(e: E) = list.remove(e)        // error
    fun set(i: Int, e: E) = list.set(i, e)   // error
}

 

하지만 이와 별개로 안에 데이터를 가져오는 것은 가능하다.

class Group<out E>(val list: MutableList<E> = mutableListOf()) {
    fun remove(i: Int) = elements.removeAt(i)
    fun get(i: Int): E = elements[i]
}
fun main() {
    // covariance
    val people: Group<Person> 
        = Group<Student>(mutableListOf(Student(), Student()))     
    val beings: Being = people.get(0)
    val person: Person = people.get(1)
    val student: Student = people.get(2)     // error
}

앞에 Covariance & Contravariance 부분에서 알아봤듯이 Group<out E>는 Covariance 성질을 가진다.

이 예제에서 people은 Generic<Person> 타입이기 때문에 get() 메서드로 가져온 인스턴스의 타입은 Person이 되고, 그렇기 때문에 Being이나 Person 타입으로 받을 수 있다. 하지만 Student 타입은 받을 수 없다.

 

이런 이유로 인해 Upper Bound Wildcard는 read-only이기 때문에 Producer라고 보는 것이다.

 

 

이제 반대로 Lower Bound Wildcard인 in 키워드에 대해 알아보자.

fun doSomething2(list: List<in Student>) {
    for (student: Student in list) {     // compile error
        // do something
    }
}

위 코드에서 Lower Bound Wildcard로 선언한 list에서 Student 타입 객체로 꺼내오려고 할 때 컴파일 오류가 난다. 그 이유는 list에서 item을 꺼내 올 때 그 item을 저장하기 위한 참조변수형을 결정 할 수 없기 때문이다. 예를 들어 매개변수로 List<Person>이 들어온 경우에는 Student 타입으로 Person 타입을 받을 수 없다.

여기서 만약 Being 타입으로 list의 item을 받는다고 해도 컴파일러는 Being의 부모 타입의 List가 들어오지 않는다는 보장을 할 수 없기 때문에 오류를 낸다.

 

하지만 메서드에서 Lower Bound Wildcard를 사용할 때 get이 전혀 안되는 것은 아니다. 

Java에서 Object 클래스와 같은 Kotlin의 Any 타입을 사용할 수 있다. 하지만 여기서 Any 타입으로 받고 Student 타입으로 다운캐스팅을 해서 사용하면 컴파일 오류는 사라지겠지만 유지보수가 힘들고 안정적이지 않은 코드가 되기 때문에 Lower Bound Wildcard로 선언한 객체에서 데이터를 가져오는 행위는 하지 않는 것이 좋다.

 

그런 이유로 클래스에 Lower Bound Wildcard를 적용했을 때는 아예 타입을 반환할 수 없도록 컴파일러가 제한한다.

class Group<in E>(private val elements: MutableList<E> = mutableListOf()) {
    fun get(i: Int): E = elements[i]              // error
    fun remove(i: Int) = elements.removeAt(i)     // error
    fun set(i: Int, e: E) = elements.set(i, e)    // error
}

E 타입의 인스턴스를 리턴하는 get()도 에러가 나지만, index로 item에 접근하는 remove와 set도 에러가 나는 것을 확인할 수 있다.

컴파일 오류의 내용은 다음과 같다. “Type parameter E is declared as ‘in’ but occurs in ‘out’ position in type E.” 타입 파라미터를 ‘in’키워드로 제한했으면서 안에 데이터를 꺼내려고 하고 있다는 것이다.

 

하지만 객체를 받아서 데이터를 넣고 삭제하는 것은 가능하다.

class Group<in E>(private val elements: MutableList<E> = mutableListOf()) {
    fun add(e: E) = elements.add(e)
    fun remove(e: E) = elements.remove(e)
}
fun main() {
    // contravariance
    val people: Group<Person> = Group<Being>()
    people.add(Student())
    people.add(Person())
    
    // error
    people.add(Being())
}

Group의 타입 파라미터를 in 키워드로 E의 Super 클래스도 들어올 수 있도록 했기 때문에 Contravariance에 의해 Group<Person> 타입의 people 객체에 Group<Being>타입의 인스턴스가 들어갈 수 있다.

그리고 여기서 add의 인자로는 Person과 그의 자식 타입만 들어올 수 있다. 그 이유는 Group<Person> 타입의 people 객체의 elements의 타입이 MutableList<Person>으로 정해졌기 때문에 그 item으로는 Person과 그의 자식 타입만 들어갈 수 있기 때문이다. 그렇기 때문에 people에 Being 타입의 인스턴스를 넣으려고 하면 에러가 난다.

 

이런 이유로 인해 Lower Bound Wildcard는 write-only이기 때문에 Consumer라고 보는 것이다.

 

즉, 정리를 하자면 다음과 같다.

  • Generic 타입 파라미터에 아무 제한 없이 E로 선언하면, Invariance로 <>에 들어온 클래스의 상속 관계와는 상관 없이 동일한 클래스일 때만 서로 대입이 가능하다. 또한 타입이 명확히 정해져 있기 때문에 데이터를 변경하고 접근하는 것이 가능하다.
  • Generic 타입 파라미터에 “out” 키워드로 자신의 클래스와 자신의 하위 클래스만 들어오도록 제한하면, Covariance로 Generic<Parent>에 Generic<Child> 대입이 가능하다. 이는 데이터를 변경하는 것은 불가능하고 접근해서 가져오는 것만 가능하다. 이를 read-only라고 하고 그렇기 때문에 Producer 클래스라고 한다.
  • Generic 타입 파라미터에 “in” 키워드로 자신의 클래스와 자신의 상위 클래스만 들어오도록 제한하면, Contravariance로 Generic<Child>에 Generic<Parent> 대입이 가능하다. 이는 데이터에 접근하는 것은 불가능하고 변경하는 것은 가능하다. 이를 write-only라고 하고 그렇기 때문에 Consumer 클래스라고 한다.

 

다음 포스팅에서는 마지막으로 Java와 Kotlin의 Covariance의 차이를 알아볼 것이다.

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함