Weekly Flutter

Generic을 파다가 variance를 파다가 리스코프의 법칙을 파다가..

플럭 2023. 7. 6. 14:54

다트의 Generic의 동작 방식에 대해 공부를 하다가

찜찜하게 마음속으로 계속 걸리는 Variance에 대한 확실한 개념 정리가 필요하다 생각이 들어 글을 적는다..

1. 변성이란? (공변, 반공변, 무공변)

가변성 또는 변성이라 불리는 variance는 한마디로 서로 다른 타입 간에 어떤 관계가 있는지를 나타내는 개념이다. 

  • 공변(covariant) : A가 B의 하위 타입일 때, T<A> 가 T<B>의 하위 타입이면 T는 공변 (직관적임)
  • 반공변 : A가 B의 하위 타입일 때, T<A> 가 T<B>의 상위 타입이면 T는 반공변
  • 불공변 또는 무공변(invariant) : A가 B의 하위 타입일 때, T<A> 가 T<B>사이에 상속 관계가 없으면 T는 불공변 또는 무공변

가변성은 제네릭(Generic) 타입에서 특히 중요한 역할을 하는데

프로그래밍 언어마다 제네릭 타입에 대한 변성이 다르게 구현되어있다. 

 

1- 1. 자바의 Generic

예시로 Java의 코드를 보자. (다트에서는 어떻게 동작할지 예상해보면서 읽어주세욥)

List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // 컴파일 오류
animals.add(Animal()); // 런타임 오류 (ClassCastException)

위 예제에서 Dog가 Animal의 하위 타입이라고 가정했을 때, List<Dog>이 List<Animal>의 하위타입이 되진 않는다.

이것을 무공변성이라고 한다.

 

무공변은 타입 S가 T의 하위 타입일 때, Box[S]와 Box[T] 사이에 상속 관계가 없는 것(상위타입도 하위타입도 아님)으로 본다는 것이다. 

 

따라서 Java에서는 List<Dog>으로 되어있는 dogs를 List<Animal>타입을 지닌 변수에 할당할 수 없다.

만일 List<Dog>으로 되어있는 dogs가 animals에 할당이 가능하다면 

animals에 Animal() 인스턴스를 추가하는게 가능해야하기 때문이다. 

 

따라서 공변 처리를 해주고 싶을 때 와일드카드라는 것을 사용해주는데

List<? extends Animal>  animals = dogs

이런식으로 선언해주면 Animal과 Animal의 하위 타입들을 포함하는 리스트를 선언해주는 것이여서 dogs를 할당할 수 있다. 

 

 

1- 2. 다트의 Generic

반면에 다트는?

class Animal {}
class Dog extends Animal {}

List<Dog> dogs = [];
List<Animal> animals = dogs; // Dart에서는 가능
animals.add(Animal()); //에러

위 예제에서도 알 수 있듯, 다트는 List<Dog>가 List<Animal>의 하위 타입으로 판단한다(공변성).

고로 List<Animal> 변수에 List<Dog>를 할당할 수 있다.

하지만 animals 리스트에 Animal 인스턴스를 추가할 수는 없다.

List<Dog>는 List<Animal>의 하위 타입으로 간주되기는 하지만,
리스트에 Animal 인스턴스를 추가할 때 발생할 수 있는 모순을 방지하기 위해 에러를 발생시킨다. 

다트는 상대적으로 더 최근에 개발된 언어이며, 타입 시스템에서 유연성과 코드의 간결성을 강조하기 때문에 제네릭은 공변성을 갖게 되었다.  제네릭이 공변성을 가졌을 때의 장점은 다형성을 주기 용이하기 때문이다. 제네릭이 무공변이라면 모든 타입에서 공통적으로 사용되는 메소드를 만들기 어렵기 때문이다.

제네릭이 무공변일 때의 어려움은 자바의 제네릭과 와일드카드가 등장하게 된 배경을 설명하는 글에서 잘 설명해주고 있따. : https://mangkyu.tistory.com/241

 

Java의 와일드카드가 존재하는 것처럼 Dart에서도 공변을 나타내는 키워드인 covariant 키워드가 있다.

그렇다면 covariant 키워드는 어디에 사용하는 것일까?

이를 알려면 OOP의 5대 원칙인 SOLID 원칙에서의 L을 담당하고있는 리스코프 치환 원칙을 알아야한다. 

SOLID 원칙이 궁금하다면 참고할만한 좋은 글 : https://fe-developers.kakaoent.com/2023/230330-frontend-solid/

2. 리스코프 치환법칙이란?

리스코프 치환법칙이란

Subtypes must be substitutable for their base types.

컴퓨터 프로그램에서 Child가 Parent의 하위타입이라면 필요한 프로그램의 속성의 변경 없이 Parent의 객체를 Child의 객체로 교체(치환)할 수 있어야 한다는 원칙이다. 쉽게 말해서 자식 클래스와 부모 클래스 간의 행위에는 일관성이 있어야 한다는 뜻이다.

상속(is-a)으로 이어진 관계에서 예상 못할 행동을 하지 말라 

이 규칙에 따르면, 부모 클래스의 인스턴스를 사용하는 곳에서 자식 클래스의 인스턴스를 대신 사용해도 동작에 아무런 문제가 없어야 한다. 이를 위해서는 몇 가지 규칙을 따라야 하는데 코드를 보며 그 원칙에 대해 배워보자. 

      Animal
     /     \
  Mammal   Reptile
  /   \
Dog   Cat

이러한 클래스의 계층이 있다고 가정해보자. 

class Base {
  void takeObject(Mammal mammal) {
    // ...
  }

  Mammal returnObject() {
    // ...
  }
}

class Derived extends Base {
  // ...
}

Base의 takeObject method를 보면 Mammal 타입을 인자로 받는다. 

리스코프원칙에 따라 Derived는 Base를 상속 받았기 때문에 Derived의 인스턴스 또한 Base의 인터페이스를 만족해야한다. 

 

만약에 Derived가 다음과 같이 오버라이드를 했다면 왜 에러가 발생할까?

class Derived extends Base {
  @override
  void takeObject(Dog mammal) { // ERROR
    // ...
  }
}

Base 인터페이스를 만족해야한다면 Base에서 정의한 것과 같이 takeObject에 Cat 인수를 받을 수 있어야하는데,

Dog로 해두면 Cat을 받는 것을 만족할 수 없기 때문이다. (내가 가장 헷갈린 부분이다..)

**중요 부모가 하는 일을 자식이 할 수 있어야한다! 

 

하지만 다음과 같이 오버라이드를 하면 만족하게된다

class Derived extends Base {
  @override
  void takeObject(Animal mammal) { // OK
    // ...
  }
}

 

Base.takeObject가 Mammal을 인자로 가져야한다는 걸 준수하기 때문이다.

모든 Mammal도 Animal이기 때문에 모든 Mammal과 함께 Derived.takeObject를 호출하는 것이 성립된다.

 

따라서 함수의 파라미터는 반공변성을 띈다고 할 수 있다. 이렇게 동작하지 않는다면, 타입 안정성을 보장받지 못하기 때문이다. 

 

3. covariant란?

여기서 파라미터가 공변을 따르도록 도와주는 키워드가 covariant 이다. 

class Derived extends Base {
  @override
  void takeObject(covariant Dog mammal) { // OK
    // ...
  }
}

covariant 키워드를 사용하면 Derived.takeObject에서 Dog만 받도록 처리할 수 있다. 

covariant 키워드가 존재하는 이유는 한마디로 함수의 파라미터가 반공변성을 띄기 때문에,
공변을 띄게 하여 처리하고싶을 때를 위해 존재한다고 보면 된다!

class Animal {
  void chase(Animal x) { ... }
}

class Mouse extends Animal { ... }

class Cat extends Animal {
  @override
  void chase(covariant Mouse x) { ... }
}

이런 식으로 chase를 하는데 Cat이라는 자식 클래스는 Mouse만 chase 하도록 제한해주고 싶은 예외 케이스 때 covariant 카워드를 사용해줄 수 있다. 

 

이렇게 알아본 리스코프 치환법칙을 준수하기 위한 첫 번째 원칙은, 자식 클래스에서 오버라이드하는 함수의 인자 타입은 부모 클래스의 함수 인자 타입과 같거나 더 상위의 타입이어야 한다는 것이다. 

 

두 번째 원칙은 자식 클래스에서 오버라이드 하는 함수의 반환 타입은 부모 클래스의 함수 반환 타입과 같거나 더 구체적인 타입이어야 한다는 것! 그 이유는 리스코프 치환법칙을 따르기 위해선 함수의 리턴 타입이 공변성을 띄어야하기 때문이다. 

class Derived extends Base {
  @override
  Dog returnObject() { // OK, a `Dog` is a `Mammal`, as required by `Base`
    // ...
  }
}

Base 클래스의 returnObject 함수의 리턴타입은 Mammal이다. 따라서 Mammal의 하위 타입인 Dog를 Derived.returnObject()에서 반환하더라도 Base 클래스의 리턴 동작에 위배하지 않는다. Dog는 Mammal이기 때문이다. 

class Derived extends Base {
  @override
  Animal returnObject() { // ERROR: Could return a `Reptile`, which is not a `Mammal`
    // ...
  }
}

반면에 Animal과 같이 부모의 함수 반환 타입보다 더 넓은 범위의 Animal을 반환하게 되면 문제가 생긴다. 

부모 클래스의 인터페이스를 준수해야하는데 Reptile을 뱉을 수 있는건 이 인터페이스를 위배하기 때문이다. 

4. 다형성이란?

 최근 수정한 코드이다. 걸음수는 각각 OS에 따라 다르게 수집된다 . 따라서 디바이스에서 걸음수를 수집하는 PedometerManager는 AOS와 iOS로 각기 나뉘어져있었는데, 이를 상위 클래스를 하나 만들고 상위 클래스의 메소드들을 오버라이드하여 iOS와 AOS 매니저를 구현하는 것으로 수정했다. 

 따라서 상위 클래스인 FietPedometer 타입을 지닌 하나의 deviceHealthManager 변수로 각기 자식의 인스턴스를 받아 deviceHealthManager의 메소드를 호출하여 동작을 처리했다. 이렇듯 상위 클래스 타입을 사용하여 하위 클래스의 인스턴스를 참조하는 경우, 변수의 정적 타입은 상위 클래스이지만 실제로 실행되는 메소드는 할당된 하위 클래스의 오버라이드된 메소드이다. 이렇게 하위 클래스의 메소드를 호출하는 것은 다형성의 한 예이다. 

다형성(polymorphism)은 '여러 모양'을 의미하는 그리스 단어이고 다형성에서 형은 타입(type)을 의미한다. 쉽게 말하면 프로그래밍 언어에서 다형성이란, 한 객체가 여러 타입을 가질 수 있다는 뜻이다. 또는 동일한 요청(메시지)에 대해 서로 다른 방식으로 응답할 수 있는 능력을 이야기하기도 한다. 다형성을 구현하기 위해 다양한 방법을 사용할 수 있지만, 실무에서는 보통 부모의 클래스의 메서드를 자식클래스에서 오버라이드하며 다형성을 부여해준다. 따라서 상속 관계에서 타입이 어떤 관계를 가지고 있는지 이해하고 잘 사용할 수 있어야한다. 

 

다음 먼슬리 플러터에서는 OOP의 정의와 핵심 개념들을 다룰 예정이다.

그러면 이 산발적인 지식들도 한 곳으로 모이겠지.. 

 

https://stackoverflow.com/questions/72596495/dart-why-should-overriding-methods-parameter-be-wider-than-parents-one-p

https://merrily-code.tistory.com/202