-
제네릭의 변성, 공변, 반공변 등등의 개념들스터디 노트 2023. 10. 30. 13:38
정말 좋은 강의가 있어서 공유드립니다.
지네릭(제네릭)의 변성은 정말 헷갈리는 기능이죠.
너무 간편하고 강력한 것은 알겠으나, 그 근본 원리를 제대로 파악하지 못한다면 쓸 때마다 헷갈리고 찾아봐야합니다.
알고리즘이 찾아준 최범균님의 간편 정리 영상이 있어서 한 번 보면서 정리해보려 합니다.
일반적으로 제네릭을 쓰는 이유는 컴파일 시점에서 타입을 검증하여 컴파일 에러를 발생시켜줍니다.
List<String> codes = new ArrayList<>(); codes.add("1"); // 컴파일 OK codes.add(1); // 컴파일 에러
이를 통해 우리는 타입에 알맞은 코드를 작성할 수 있게 됩니다.
강력한 컴파일 검증 기능을 제공한다는 것이 제네릭의 가장 큰 장점이지요.
자, 아래에 Tiger라는 클래스는 Animal이라는 타입을 상속받아 구현합니다.
public class Tiger extends Animal { ... }
그리고 Tiger를 사용할 때 상위 기반 타입으로 교체하여 객체를 생성할 수 있게 됩니다.
Animal animal = new Tiger();
그런데 여기서 제네릭의 구성이 상하위타입으로 제공이 되면 헷갈리기 시작합니다.
다음 Cage 예제를 보시죠.
public class Cage<T> { private List<T> animals = ...; public void push(T animal) { this.animals.add(animal); } public List<T> getAll() { return animals; } }
위 예제와 같이 List<T>를 가진 animals List가 있습니다.
push를 통해 제네릭 타입의 animal을 입력하는 메소드와 animals List를 전달하는 메소드를 가지고 있습니다.
그런데 여기서 Cage<Animal>을 선언하고 Tiger를 담으려 하면 컴파일 에러가 납니다 🤔
Cage<Animal> cage = new Cage<Tiger>(); // 컴파일 에러!!!
이 예제에서 볼 수 있듯 Animal은 Tiger의 상위 타입이니까 Cage<Animal>에 당연히 Cage<Tiger>를 담을 수 있을 것 같지만 아닌거죠. 그 말은 즉슨, Cage<Animal>은 Cage<Tiger>의 상위 타입이 아니라는 말이 됩니다.
그럼 왜 아닐까요?
만약 상하위 타입이 맞다고 생각을 해보고 아래 예제를 보겠습니다.
public class Tiger extends Animal { .... } public class Lion extends Animal { .... } Cage<Tiger> ct = new Cage<Tiger>(); Cage<Animal> ca = ct; // 이게 된다면? ca.push(new Lion()); // Lion도 Animal의 하위타입이므로 push로 정상적으로 입력이 되야 합니다. List<Tiger> tigers = ct.getAll(); // 그럼 ct에는 Tiger가 아니라 Lion 리스트가 리턴되죠.
이런 특성을 우리는 '무변성(invariant)' 이라고 합니다.
쉽게 말해 A가 B의 상위 타입일 때, GenericType<A>가 GenericType<B>의 상위 타입이 아니라면 변성은 없다는 것입니다.
Aninal animal = new Tiger(); // ✅ Animal은 Tiger의 상위 타입이기에 가능! Cage<Animal> ca = new Cage<Tiger>(); // ❌ Cage<Animal>은 Cage<Tiger>의 상위 타입이 아니기에 불가능!
무변성(혹은 무공변)의 특성을 가질 때의 문제는 다음과 같습니다.
// 육식동물 우리에 고기먹이를 주는 사육사의 기능을 구현하는 예제입니다. public class Animal {} public class Carnivore extends Animal {} public class Tiger extends Carnivore {} public class Lion extends Carnivore {} public class Zookeeper { public void giveMeat(Cage<Carnivore> cage, Meat meat) { // 육식 동물 우리에 고기를 주는 기능 ... } } // 공변이 아닐 경우 아래처럼 컴파일 에러가 발생 Zookeeper zk = new Zookeeper(); Cage<Tiger> ct = new Cage<>(); zk.giveMeat(ct, meat); // Cage<Carnivore>가 Cage<Tiger>의 상위 타입이 아니므로 에러!❌
그럼 위와 같은 무변성(무공변)의 문제를 어떻게 해결할 수 있을까요?
'공변(convariant)' 개념을 활용하면 해결할 수 있습니다.
A가 B의 상위 타입이며 T<A>가 T<B>의 상위 타입이면 공변한다고 할 수 있습니다.
즉, 제네릭 타입이 파라미터 안에 오는 타입간의 상속관계를 추종할 때 공변 관계는 성립한다고 할 수 있습니다.말이 헷갈리네요 ㅎㅎ 다음 예제를 한 번 보시죠.
public class Zookeeper { // <T>의 파라미터 클래스의 타입 상속 관계를 추종하도록 설정(extends 사용) public void giveMeat(Cage<? extends Carnivore> cage, Meat meat) { List<Carnivore> cs = cage.getAll(); .... } } Zookeeper zk = new Zookeeper(); // Cage<? extends Carnivore> 타입에 Cage<Tiger>를 할당할 수 있 // (Tiger는 Carnivore의 하위타입이기 때문!) Cage<Tiger> ct = new Cage<>(); zk.giveMeat(ct, someMeat); // Cage<? extends Carnivore> 타입에 Cage<Lion> 할당 가능 // 위와 동일한 이유로! Cage<Lion> cl = new Cage<>(); zk.giveMeat(cl, anyMeat);
위 예제에서 사용한 extends를 간편하게 얘기하면 '최소한 <T>에 들어오는 타입은 Carnivore를 상속한 클래스여야해' 입니다.
그런데, 공변을 활용하면 모든 문제가 다 해결되는가? 그것은 아닙니다.
공변에서 제네릭 타입을 사용하는 메서드에 값이 전달하면 컴파일 에러가 납니다.
아래 코드를 통해 확인해보겠습니다.
Cage<Tiger> ct = new Cage<Tiger>(); Cage<? extends Carnivore> cage = ct; // OK! cage.push(new Tiger()); // push(? extends Carnivore)이기 때문에 컴파일 에러가 납니다. // cage의 실제타입이 Cage<Tiger>인지 Cage<Lion>인지 알 수 없습니다.
결국 Cage는 Tiger인지 Lion인지 실제 그 타입을 확실히 알 수가 없기에 100% 신뢰를 할 수 없다는 문제점이 생깁니다.
이를 해결하기 위해 사용되는 개념이 '반공변(contravariant)' 입니다.
반공변이란 A가 B의 상위 타입이고, T<A>가 T<B>의 하위 타입이면 이를 반공변이라고 말합니다.
이는 super를 사용해서 반공변 처리가 가능하게 됩니다.다음 예제로 확인해보도록 하겠습니다.
Cage<Tiger> ct = new Cage<>(); // Cage<? super Tiger> 타입에 Cage<Tiger>를 할당할 수 있습니다. Cage<? super Tiger> ctt = ct; ctt.push(new Tiger()); // ctt는 최소 Cage<Tiger>이거나 그 상위 타입이니 컴파일✅ Cage<Carnivore> ct2 = new Cage<>(); // Cage<? super Tiger> 타입에 Cage<Carnivore>를 할당할 수 있습니다. Cage<? super Tiger> ctt2 = ct2; ctt2.push(new Tiger()); // ctt2는 최소 Cage<Tiger>이거나 그 상위 타입이니 컴파일✅
이 예제에서는 구현을 완료하면 결국 최소한 Cage<Tiger>보다는 상위 타입을 push 할 수 있다는 것을 알 수 있습니다.
이러한 개념이 너무 어렵기 때문에 이펙티브 자바에서는 PECS라는 용어가 나옵니다.
이는 'Producer-extends, Consumer-super'의 줄임말입니다.
즉, 값을 제공하면 extends를 쓰고, 값을 사용하면 super로 쓰면 된다는 말입니다.
다시 말해서 제네릭 타입으로 구성된 메소드가 제네릭 타입을 리턴하면 extends를 사용하고, 제네릭 타입으로 구성된 메소드가 어떤 제네릭 타입을 파라미터로 사용할 땐 super로 쓰면 됩니다.
// Producer-extends example public void giveMeat(Cage<? extends Carnivore> cage, Meat meat) { List<Carnivores> cs = cage.getAll(); // cage.getAll()은 Cage입장에서 보면 보유하고 있는 제네릭 타입의 데이터를 // 서비스에 제공하는 것이므로 extends를 사용 ... }
// Consumer-super example Cage<? super Tiger> ctt = ct; // 값을 사용하면 super // 즉, Cage의 메소드에 T에 해당하는 그 무언가를 만들어서 넣는다면 super를 쓰면 됩니다. ctt.push(new Tiger());
마치며
오늘은 최범균님의 고퀄리티 영상인 제네릭 타입의 변성에 대해 스터디를 해보았습니다.
이 내용을 머리 속에 완벽하게 탑재하여 제네릭을 자유롭게 사용하면 조금 더 확장성이 높은 코드를 구현할 수 있게 됩니다.
'스터디 노트' 카테고리의 다른 글
도커 + 카프카 명령어 (0) 2023.11.08 스프링 환경에서 웹 애플리케이션 설정 메커니즘 정리 (0) 2023.11.01 REST API의 개념과 설계 방법 (0) 2023.10.24 Spring MVC Component의 동작구조(그림주의) (0) 2023.10.23 HTTP Status Code (0) 2023.10.20