요약. 제네릭의 무공변/공변/반공변
- 제네릭은 기본적으로 무공변/불공변성을 가진다
- 상한 경계(extends) 타입 변수 지정하여 제네릭은 공변성을 가질 수 있다
- 하한 경계(super) 타입 변수 지정하여 제네릭은 반공변성을 가질 수 있다
공변은 인터페이스 구현이나 상속(부모-자식 관계)을 생각하고,
반공변은 부모-자식관계가 반대로 된 것으로 생각하기
Generics 란?
- 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법
- 타입을 클래스 정의하는 시점이 아닌, 실제 사용하는 생성 시점에 결정
- Complie(컴파일) 단계에서 타입을 체크함으로써 코드의 안정성을 높임
- 코드 중복 줄이고 재활용성 높임
- JDK 1.5 부터 도입
Generics을 사용하는 이유
Generics 타입 지정함으로써
- complie(컴파일) 단계에서 타입 검사하여 타입의 안정성을 제공
- 타입 체크와 casting(형 변환)을 생략할 수 있으므로 코드가 간결해짐
Runtime 시점에서 Type Safe하지 않아 에러 발생할 경우 최악의 경우 서버가 다운 될 수 있기 때문에
Complie 시점에서 오류를 잡아내는게 Best 이다.
List 인터페이스에 타입 지정하지 않은 경우
- Java 1.5 이전에는 제네릭이 없었음
- raw type의 경우(ex. List, Set ..) 제네릭이 도입되기 이전 코드와의 호환성을 위해 제공될 뿐, 런타임에 예외가 발생할 수 있으니 사용해선 안된다
public static void main(String[] args) {
List list = new ArrayList<>();
list.add(10);
list.add(20);
list.add("30"); // String형
//컴파일은 통과하지만 런타임에서 ClassCastException 발생
Integer i = (Integer) list.get(2);
list.stream().forEach(System.out::println);
}
* List, List<Object>, List<?> 비교 - 이펙티브 자바 (Item26. 로 타입은 사용하지 말라)
- List : raw type으로 type safe 하지 않음(=안전하지 않다)
- List<Object> : 어떤 타입의 객체도 저장할 수 있는 매개변수화 타입, 사용자가 지정한 것을 명시적으로 나타냄 (=상대적으로 안전함)
- List<?> : 모종의 타입 객체만 저장할 수 있는 와일드 카드 타입, 마찬가지로 사용자 지정한 것을 명시적으로 나타냄(=상대적으로 안전함)
List 인터페이스에 타입 지정할 경우
- Java 1.5 부터 제네릭 지원하면서, 타입 지정하게 되면 컴파일 시점에서 타입 체크가 가능해지고, 불필요한 형변환을 생략하는 것이 가능해짐
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10);
list.add(20);
list.add(30); // complie 단계에서 type check 하므로 실수 방지
Integer i = list.get(2); // 형변환 생략가능
list.stream().forEach(System.out::println);
}
용어 정리
타입 변수 Naming Convention
- 제네릭에서 할당 받을 수 있는 타입은 Wrapper 클래스 또는 Reference 타입만 가능하다
- Primitive Type(int, double, float, long, .. )은 제네릭 타입 변수로 사용할 수 없다
<T> 를 타입 변수(Type Variable)이라 하고, 임의 참조형 타입을 의미함
제네릭 선언
제네릭 클래스 선언
기본적으로 클래스명 옆에 <타입변수> 지정하여 나타냄
class Unknown{}
class GenericsClass<T> {
T something;
public GenericsClass(T something) {
this.something = something;
}
void print() {
System.out.println(something.getClass().getName());
}
}
class Main {
public static void main(String[] args) {
GenericsClass<Unknown> genericsClass = new GenericsClass<>(new Unknown());
genericsClass.print(); // {패캐지경로}.Unknown 출력
}
}
Multiple Bound
- 제네릭 클래스 타입 지정시 여러 개의 subtype을 지정할 수 있다
- 이때 클래스 타입 매개 변수에 인터페이스와 클래스가 있으면, 클래스가 제일 앞에 와야 한다.
class A {}
interface B {}
interface C {}
class D <T extends A & B & C> {} // Ok
class E <T extends B & A & C> {} // Error
https://docs.oracle.com/javase/tutorial/java/generics/bounded.html
제네릭 메서드 선언
- 클래스의 타입 매개 변수를 사용하거나, '접근 지정자'와 '반환 타입' 사이에 타입 매개 변수 선언(<T>)하여 사용
- 제네릭 메서드에 타입 매개 변수를 직접 선언하여 사용하게 될 경우, 클래스의 타입 매개변수(T)와 메서드의 타입 매개변수(T)가 동일하게 표기하더라도 우선 순위는 메서드의 타입 매개 변수 선언이 먼저이게 된다
class Box<T> {
private T t;
public T get() { return t; }
public void set(T t) { this.t = t; }
public <U extends Number> void inspect(U u) {
System.out.println("T : " + t.getClass().getName());
System.out.println("U : " + u.getClass().getName());
}
}
public class Main {
public static void main(String[] args) {
Box<Integer> integerBox = new Box<>();
integerBox.set(new Integer(10));
/*출력
T : java.lang.Integer
U : java.lang.Double
*/
integerBox.inspect(new Double(10.1));
}
}
이때 제네릭 메서드를 선언하여 사용할 경우 주의할 점이 몇가지 있다.
(1) 제네릭 타입으로 인스턴스 생성 불가
(2) static 제네릭 메서드를 선언하게 될 경우, '접근 지정자'와 '리턴 타입' 사이에 타입 매개 변수 선언<T> 을 꼭 선언해줘야 한다.
static의 경우 어플리케이션 실행시 메모리에 올라가서 클래스 내 공용 자원으로 사용하게 되므로, 타입 선언을 하지 않은 상태에서 메모리 상에 올라간다는 것은 논리적으로 말이 되지 않기 때문에 컴파일 에러를 출력하게 된다.
-> 제네릭 메서드에 타입 매개변수 선언하게 될 경우 제네릭 클래스 타입 변수와 표현이 같더라도, 우선 순위는 제네릭 메서드의 타입 매개 변수가 가진다 ( 제네릭 메서드 타입 변수 != 제네릭 클래스 타입 변수 )
class Box<T> {
T t;
public Box() {
// Type parameter "T" cannot be instantiated directly
this.Box = new T(); // Error
}
public static T print(T t) { // compile Error
}
public static <T> print(T t) { // Ok
// do something
}
public static <T> void printAll(T t) { // Ok
// do something
}
public static <T extends Number> List<T> doSomething(List<T> list) { // Ok
return list;
}
}
*java.util.List 인터페이스에 보면 static method의 경우에도 메서드의 타입 매개 변수 선언(<E>) 하여 사용하고 있다
(3) 제네릭 메서드의 '리턴 타입'에는 한정적 와일드 카드 타입을 사용하면 안된다.
유연성을 높여주기는 커녕 클라이언트 코드에서도 와일드 카드 타입을 써야 하기 때문이다. 아래 예시에서 union3 static method가 이에 해당한다.
클래스 사용자가 와일드 카드 타입을 신경써야 한다면 그 API에 문제가 있을 가능성이 크다.
- 이펙티브 자바 p185
public static <E> List<E> union1(List<? extends E> listOne, List<? extends E> listTwo) {
return Stream.concat(listOne.stream(), listTwo.stream()).collect(Collectors.toList());
}
public static <E extends Number> List<E> union2(List<E> listOne, List<E> listTwo) {
return Stream.concat(listOne.stream(), listTwo.stream()).collect(Collectors.toList());
}
// bad case (m . m)
public static <E> List<? extends E> union3(List<? extends E> listOne, List<? extends E> listTwo) {
return Stream.concat(listOne.stream(), listTwo.stream()).collect(Collectors.toList());
}
@DisplayName("")
@Test
void union_method_test() {
// given
List<Number> listOne = Arrays.asList(1, 2, 3);
List<Number> listTwo = Arrays.asList(4.4, 5.5, 6.6);
// when
List<Number> mergedList1 = union1(listOne, listTwo);
List<Number> mergedList2 = union2(listOne, listTwo);
List<? extends Number> mergedList3 = union3(listOne, listTwo); // client code에서도 와일드 카드 사용(bad)
// then
Assertions.assertThat(mergedList1).hasSize(6);
Assertions.assertThat(mergedList2).hasSize(6);
Assertions.assertThat(mergedList3).hasSize(6);
}
https://docs.oracle.com/javase/tutorial/java/generics/wildcardGuidelines.html
Covariance (공변성) 과 Invariant (무공변성)
일반적으로 아래 두 가지 형태에 대해 공변성을 가진 다는 것은 쉽게 이해할 수 있을 것입니다.
(1) 인터페이스를 정의하고 이를 구현한 클래스 사용하는 경우
interface Animal {
void sound();
}
class Dog implements Animal {
@Override
public void sound() {
System.out.println("멍멍");
}
}
class Cat implements Animal {
@Override
public void sound() {
System.out.println("야옹");
}
}
public class Main {
public static void main(String[] args) {
Animal dog = new Dog();
Animal cat = new Cat();
dog.sound(); // 멍멍
cat.sound(); // 야옹
}
}
(2) 클래스 상속 관계 사용하는 경우
아래와 같이 하위 타입이 부모 타입에 서브 타입인 경우 공변성을 가진다고 말할 수 있겠습니다.
class Noodle {/*..*/}
class Pasta extends Noodle {/*..*/}
class Ramen extends Noodle {/*..*/}
public void Main {
public static void main(String[] args) {
Noodle pasta = new Pasta();
Noodle ramen = new Ramen();
}
}
참고. LSP(리스코프 치환) 법칙
그렇다면 제네릭 클래스의 경우에도 공변성을 가지는걸까? 아래 예시를 살펴보자
class Box<T> {
T t;
public Box() {}
public void set(T t) { /*.. */ }
// ...
}
public class Main {
public static void main(String[] args) {
Object[] obj = new Integer[10]; // Ok
Box<Number> box = new Box<Integer>(); // Error
Box<Number> boxNumber = new Box<Number>(); // Ok
Box<Integer> boxInteger = new Box<Integer>(); // Ok
}
}
- 일반적인 클래스의 경우 Integer는 Object의 subtype이므로 공변성을 가집니다
- 제네릭 클래스의 경우 Integer가 Number의 subtype일지라도, Box<Integer> 가 Box<Number>의 subtype이 아니므로 에러 발생한다 (=불공변/무공변)
기본적으로 제네릭의 경우 불공변/무공변성 을 가지므로 지정된/같은 타입만 대입 가능하게 합니다.
지정된/같은 타입만 대응 가능하다는게 문제가 없어 보일 수 있으나, Box<Integer>, Box<Double>, Box<String> 등과 같이 제네릭 클래스 타입 변수에 여러 타입을 사용하게 될 경우 아래와 같이 타입 종류별로 코드를 작성해야 할 것입니다. 이는 곧 아래와 같이 타입만 다르고 동일한 행위를 하는 중복 코드를 생산하게 될 것입니다.
public Box<T> {
T t;
// ...
public void printAll(List<Object> list) {/* do something */}
public void printAll(List<Integer> list) {/* do something */}
public void printAll(List<Double> list) {/* do something */}
public void printAll(List<String> list) {/* do something */}
}
자바에서는 제네릭 와일드 카드(Wildcard), 그리고 상한/하한 경계 와일드 카드 지정 통해 제네릭 또한 타입 제한과 공변성/반공변성을 가질 수 있도록 합니다.
- JDK 1.5 이상 Generic(제네릭) 지원
- JDK 1.7 이상 Type Inference(타입 추론) 지원
- JDK 1.8 이상 Type target 지원 <- 타입 추론에서 성능 향상
static <T> List<T> emptyList() {}
// Java 1.7
List<String> listOne = Collections.<String>emptyList();
// Java 1.8
List<String> listOne = Collections.emptyList();
참고. 타입 추론
https://docs.oracle.com/javase/tutorial/java/generics/genTypeInference.html
WildCards / Unbounded Wildcards
- WildCards의 경우 question mark (?, 물음표)를 뜻함
- Unbounded Wildcards의 경우 <?> 와 같이 나타냄
- 비한정 와일드 카드<?> 는 <? extends Object>의 줄임 표현으로 "any type", 즉 타입 제한이 없음.
- WildCards는 코드를 간단하게 그리고 유연하게 만들어 줌
아래 static method의 경우 List<Integer>는 List<Object>의 subtype(하위타입)이 아니므로 에러가 발생한다.
// 수정 전
public static void printList(List<Object> list) {
for(Object o : list) System.out.print(o);
System.out.println();
}
class Main {
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1,2,3);
printList(integers); // Error;
}
}
static method의 매개변수를 비한정적 와일드 카드<?>로 변경하면 정상적으로 실행된다.
// Unbounded Wildcards로 수정 후
public static void printList(List<?> list) {
for(Object o : list) System.out.print(o);
System.out.println();
}
class Main {
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1,2,3);
printList(integers); // 1 2 3
List<String> strings Arrays.asList("one", "two", "three");
printList(strings); // one two three
}
}
Oracle Java Docs를 살펴봤을 때 비한정적 와일드 카드를 아래 3가지 경우에 대해 사용하지 않을 것을 얘기합니다.
(1) generic method invocation(제네릭 메서드 호출)
(2) generic class instance creattion(제네릭 클래스 인스턴스 생성)
class Box<T> {
T t;
public Box() {
// Type parameter "T" cannot be instantiated directly
this.t = new T(); // Error
}
}
(3) supertype -- (추후 추가 예정, 예외 케이스 확인 못함)
비한정적 와일드카드가 만능으로 보일 거 같으나 예측이 불가능 코드가 들어오게 될 경우 문제가 발생할 수 있습니다.
상한/하한 경계 제한을 통해 예측하지 못한 타입을 사용하는 것을 제한할 수 있도록 자바에서는 지원합니다.
Generics 의 Covariance (공변성)
Generics은 기본적으로 무공변/불공변성을 가진다고 하였으나, 상한 경계 제한을 통해 공변성을 가질 수 있습니다.
Upper Bounded Wildcards (상한 경계 와일드 카드, 공변)
- ? (=WalidCards, question mark) 와 extends keyword 함께 사용하여 표기
- <? extends Fruit>의 뜻은 클래스 Apple이 클래스 Fruit의 하위 타입일 때, List<Apple>은 List<Fruit>의 하위 타입 성립함 의미
- 클래스 Fruit를 기준으로 Furit의 자식 클래스를사용가능하므로 클래스 Fruit가 상한 경계 기준이 된다
- 부모-자식 관계를 나타내므로 공변성 가짐
마치 클래스 상속과 인터페이스 구현체 간의 부모-자식 관계가 떠오르는 것처럼 상한 경계를 통해 제네릭에서도 공변성을 가지는 모습을 보여준다.
class Food {}
class Fruit extends Food {}
class Apple extends Fruit {}
class Tomato extends Fruit {}
class Meat extends Food {}
class Pork extends Meat {}
public class Main {
// 상한 경계 와일드 카드 사용한 경우
public static void printClassName(List<? extends Fruit> list) {
StringBuilder sb = new StringBuilder();
for(Fruit f : list) {
sb.append(f.getClass().getName()).append("\n");
}
System.out.println(sb);
}
public static void main(String[] args) {
List<Fruit> fruits = Arrays.asList(new Apple(), new Tomato());
List<Meat> meats = Arrays.asList(new Meat());
printClassName(fruits); // Ok , 사과와 토마토 클래스 명 출력
printClassName(meats); // Error, Meat를 출력하려면 상한 경계가 List<? extends Food>가 되야 한다
}
}
예시) Return Type 이 있는 제네릭 메서드에 상한 경계 지정하는 경우 (Baeldung 예시)
- case1의 경우 bad case로 리턴 타입을 한정적 와일드 카드 타입을 지정하여 클라이언트 코드에서도 신경써야 하는 좋지 않은 API 이다 ( 이펙티브 자바 p 185 읽어보기 )
// bad case. client code에서도 상한 경계 와일드 카드를 선언해야 한다.
public static <E> List<? extends E> mergeList1(List<? extends E> listOne, List<? extends E> listTwo) {
// do somethign
}
public static <E> List<E> mergeList2(List<? extends E> listOne, List<? extends E> listTwo) {
// do something
}
public static <E extends Number> List<E> mergeList3(List<E> listOne, List<E> listTwo) {
// do something
}
Generics 의 Contravariance (반공변성)
Lower Bounded Wildcards (하한 경계 와일드 카드, 반공변)
- ? (=WlidCards, question mark) 와 super keyword 함께 사용하여 표기
- <? super Fruit>의 뜻은 클래스 Food가 클래스 Fruit의 하위 타입일 때, List<Food>은 List<Fruit>의 하위 타입 성립함 의미
- 클래스 Fruit와 조상 클래스를 사용가능하므로 클래스 Fruit가 하한 경계 기준이 된다
- 마치 부모-자식 관계가 뒤집힌 형태로 LSP 법칙이 성립
- 반공변성(Contravariance) 가짐
하한 경계가 Fruit로 잡혔으므로, Fruit와 Fruit 조상만 대입 가능하다. (자식은 x)
반공변(Contravariance)이 용어가 어려운 데, 공변의 반대라고 생각하면 좀 더 이해하는데 도움이 되지 않을까 싶습니다.
class Food {}
class Fruit extends Food {}
class Apple extends Fruit {}
class Tomato extends Fruit {}
class Meat extends Food {}
class Pork extends Meat {}
public class Main {
// 하한 경계 와일드 카드 사용한 경우
public static void addAppleAndTomato(List<? super Fruit> fruits) {
fruits.add(new Apple());
fruits.add(new Tomato());
}
public static void main(String[] args) {
List<Food> food = new ArrayList<>();
List<Fruit> fruits = new ArrayList<>();
List<Apple> apples = new ArrayList<>();
addAppleAndTomato(food);
addAppleAndTomato(fruits);
// compile time error
// 하한 경계가 Fruit 이므로 매개변수 인자 사용 불가
// addAppleAndTomato(apples);
}
}
P.E.C.S (팩스, Producer-Extends, Consumer-Super)
이펙티브 자바 3판 p181
아이템 31. 한정적 와일드카드를 사용해 API 유연성을 높여라
아래와 같은 제네릭 클래스가 있다.
import java.util.Arrays;
import java.util.EmptyStackException;
class MyStack<T> {
private T[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
@SuppressWarnings("unchecked")
public MyStack() {
elements = (T[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(T e) {
ensureCapacity();
elements[size++] = e;
}
public T pop() {
if(size == 0) throw new EmptyStackException();
T result = elements[--size];
elements[size] = null; // 다쓴 참조 해제
return result;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if(elements.length == size) {
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
}
그리고 해당 제네릭 클래스에 아래 두 메서드를 추가할 경우
'클래스의 제네릭 타입'과 '메서드의 제네릭 타입'이 일치한다면 문제가 없을 수 있으나, 제네릭은 기본적으로 불공변/무공변성을 가지기 때문에 타입이 상이한 인자를 넣게 된 경우 컴파일 에러가 발생한다.
수정전
class MyStack<T> {
//..
public void pushAll(Iterable<T> src) {
for(T t : src) push(t);
}
public void popAll(Collection<T> dst) {
while(!isEmpty()) dst.add(pop());
}
}
public class Main {
public static void main(String[] args) {
MyStack<Number> numberMyStack = new MyStack<>();
Iterable<Integer> integers = Arrays.asList(1, 2, 3);
numberMyStack.pushAll(integers); // Compile Error : List<Integer>는 List<Number>의 하위 타입이 아님
Collection<Object> objects = new ArrayList<>;
numberMyStack.popAll(objects); // Compile Error : Collection<Object> 는 Collection<Number>의 하위 타입이 아님
}
}
이펙티브 자바 3판 p182 ~ 184
유연성을 극대화하려면
원소의 생산자나 소비자용 입력 매개변수에 와일드 카드 타입을 사용할 것을 권장
하고 있다. 한편 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 와일드 카드 타입을 써도 좋을게 없다. 타입을 정확히 지정해야 되는 상황으로 이때는 와일드 카드 타입을 쓰지 말아야 한다.
수정 후
"이펙티브 자바"에서는 매개변수화 타입 T가 생산자라면 <? extends T> 를 사용하고, 소비자라면 <? super T>를 사용하라고 말한다.
- pushAll의 경우 전달받은 인자의 값을 꺼내 제네릭 클래스 멤버 변수를 채워주므로 생산자(Producer)에 해당한다
- popAll의 경우 제네릭 클래스 멤버 변수에서 값을 꺼내 전달 받은 인자에 채워주므로 소비자(Consumer)에 해당한다
class MyStack<T> {
//..
// 꺼낼 경우 T 타입으로만 꺼낼 수 있음
// 저장은 x
public void pushAll(Iterable<? extends T> src) {
for(T t : src) push(t);
}
// 꺼낼 경우 최상위 Object 타입으로만 꺼낼 수 있음
// 저장은 T 타입과 T의 자손 타입만 가능
public void popAll(Collection<? super T> dst) {
while(!isEmpty()) dst.add(pop());
}
}
public class Main {
public static void main(String[] args) {
MyStack<Number> numberMyStack = new MyStack<>();
Iterable<Integer> integers = Arrays.asList(1, 2, 3);
numberMyStack.pushAll(integers); // Ok
Collection<Object> objects = new ArrayList<>;
numberMyStack.popAll(objects); // Ok
}
}
참고 & 살펴보기
Type Erasure (타입 소거)
- Compile 과정에만 제네릭 타입을 통해 타입 추론하여 타입 안전성(Type Safe)을 보장
- Runtime 에서는 제네릭이 없어지고, 컴파일러가 생성해 준 bridge method를 사용하게 됨
https://docs.oracle.com/javase/tutorial/java/generics/erasure.html
슈퍼타입토큰(Super Type Token)
https://yangbongsoo.gitbook.io/study/super_type_token
자바 제네릭
https://www.youtube.com/watch?v=QcXLiwZPnJQ
'공부 > Java' 카테고리의 다른 글
[Java] Enum values 조회 (Baeldung) (0) | 2023.07.26 |
---|---|
[Java] Enum values 배열을 리스트 변환 (Baeldung, Enum values to List) (0) | 2023.07.21 |
[Java] Annotation - 어노테이션 (자바의 정석) (0) | 2023.07.19 |
[Java] Primitive type (기본형 타입) (0) | 2023.05.31 |
[Java]Comparable 과 Comparator 인터페이스 (0) | 2021.09.30 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!