목표
자바의 제네릭에 대해 학습하세요.
학습할 것 (필수)
- 제네릭 사용법
- 제네릭 주요 개념 (바운디드 타입, 와일드 카드)
- 제네릭 메소드 만들기
- Erasure
Generics 를 사용하는 이유
제네릭은 클래스, 인터페이스 및 메서드를 정의할 때 타입(클래스 및 인터페이스)이 매개변수가 되도록 합니다.
메서드 선언에 사용되는 형식(formal) 매개 변수와 매우 비슷합니다. 제네릭은 타입 매개변수를 입력받아서 동일한 코드를 재사용 할 수 있는 방법을 제공합니다.
private void method(Type type) { … }
차이점은 형식(formal) 매개 변수에 대한 입력은 값이고 타입 매개변수에 대한 입력은 이라는 것입니다.
제네릭의 장점
- 컴파일 타임에 더 강력한 타입 검사
- Java 컴파일러는 강력한 타입 검사를 제네릭 코드에 적용하고 코드가 타입 안정성을 위반하는 경우 오류를 발생시킵니다.
- 런타임시 발생한 오류를 찾아내서 수정하는 것보다 컴파일 타임의 오류를 수정하는 것이 보다 쉽습니다.
- 캐스트 제거 (타입 변환 제거)
- 제네릭이 없는 아래의 코드는 캐스팅이 필요합니다.
- List list = new ArrayList();
- list.add("안녕하세요");
- String s = (String)list.get(0);
- 제네릭을 사용하여 코드를 작성하면 캐스팅이 필요하지 않습니다.
- 제네릭이 없는 아래의 코드는 캐스팅이 필요합니다.
- 프로그래머가 제네릭 알고리즘을 구현할 수 있도록 합니다.
- 프로그래머는 제네릭을 사용하여 아래의 특성을 가진 제네릭 알고리즘을 구현할 수 있습니다.
- 다양한 타입의 컬렉션에서 작동
- 사용자 정의 가능
- 타입 세이프
- 읽기 쉬워짐
제네릭 타입(Generic Types)
제네릭 타입은 타입을 통해 매개변수화 된 제네릭 클래스 또는 인터페이스 입니다.
public class Box {
private Object object;
public void set(Object object) {
this.object = object;
}
public Object get() { return object; }
}
메서드가 object필드를 초기화 하거나 반환하므로 기본 자료형(primitive type) 이 아니라면 원하는 대로 자유롭게 전달할 수 있습니다.
컴파일 타임에 클래스가 어떻게 사용되는지 확인할 방법이 없습니다.
- 어떤 코드는 Integer를 Box에 넣고 가져올것으로 예상하는 반면
- 다른 어떤 코드는 실수로 String을 전달하여 런타임 오류가 발생할 수 있습니다.
제네릭 클래스는 다음과 같은 형식으로 정의 됩니다.
클래스 이름 <T1, T2, …, Tn> { ….. }
‘<>’ 로 구분 된 타입 매개변수 구역은 클래스 이름 뒤에 옵니다. 이걸 지정 타입 파라미터(또는 입력 변수) 라고 합니다.
제네릭을 사용하도록 Box를 업데이트 하려면 아래와 같이 할 수 있습니다.
public class Box<T> { // T는 “Type”을 의미합니다.
private T t;
public void set(T t) { this.t = t; }
public T get() { return t; }
}
Object로 선언된 것을 모두 T로 바꿨습니다. 타입 변수는 모든 클래스 타입, 인터페이스 타입, 배열 타입 또는 다른 타입 변수와 같이 사용자가 지정하는 기본이 아닌 타입일 수 있습니다.
위와 같은 변경을 사용해서 제네릭 인터페이스도 만들 수 있습니다.
타입 매개 변수 명명 규칙
타입 매개변수 이름은 단일 대문자 입니다. 이 규칙이 없으면 타입 변수와 일반 클래스, 인터페이스 이름의 차이를 구분하기 어려울 것입니다.
- E - 요소 (Java Collections Framework에서 광범위하게 사용됨)
- K - 키
- N - 숫자(Number)
- T - 타입
- V - Value
- S,U,V etc. - 2nd, 3rd, 4th types
이들은 나타내는 기호만 다를 뿐 '임의의 참조형 타입'을 의미한다는것은 모두 같습니다.
제네릭 타입 호출 및 인스턴스화
코드 내에서 제네릭 Box 클래스를 참조하려면 T를 Integer와 같은 구체적인 값으로 대체하는 제네릭 타입 호출을 수행해야합니다.
Box<Integer> integerBox;
일반 메소드 호출과 유사한 것으로 제네릭 타입 호출을 생각할 수 있겠지만, 메서드에 인자를 전달하는 대신 Box 클래스 자체에 타입 인자를 전달합니다.
“타입 매개변수(Type Parameter)”와 “타입 인자(Type Argument)”라는 용어를 같은 의미로 사용하지만 두 용어는 동일하지 않습니다.
Foo<T>의 T는 타입 매개변수이고 Foo<String> f = new Foo<>(); 의 String은 타입 인자 입니다.
원래 제네릭 클래스의 인스턴스를 생성하려면 다음과 같이 생성자에도 입력 변수를 넣어주어야 했습니다..
Box<Integer> integerBox = new Box<Integer>();
Java 7 이상에서는 컴파일러가 컨텍스트에서 타입 인수를 결정하거나 추론할 수 있는 한 일반 클래스의 생성자를 호출하는데 필요한 타입 인수를 비어있는 타입 인수로 바꿀 수 있습니다.
Box<Integer> integerBox = new Box<>();
제네릭 클래스의 객체를 생성할 때 주의해야할 규칙
제네릭 클래스의 객체를 생성할 때는 참조변수와 생성자에 대입된 타입(타입 인자)이 일치해야 합니다.
-> 일치하지 않으면 에러가 발생
Box<Apple> appleBox = new Box<Apple>(); // 정상
Box<Apple> appleBox = new Box<Grape>(); // 에러 발생, 대입된 타입이 일치하지 않음
참조 변수와 생성자에 대입된 타입(타입 인자)가 상속관계에 있어도 마찬가지로 에러가 발생합니다.
아래의 예제에서 Apple이 Fruit의 자손 타입이라고 가정한다면 에러가 발생합니다.
Box<Fruit> appleBox = new Box<Apple>(); // 에러 발생, 대입된 타입이 상속 관계여도 불가능
두 제네릭 클래스의 타입이 상속 관계에 있고, 참조 변수와 생성자에 대입된 타입이 같은것은 가능합니다.
아래의 예제에서 FruitBox 는 Box의 자손 타입이라고 가정합니다.
Box<Apple> appleBox = new FruitBox<Apple>(); // 다형성 가능
위의 문장은 대입된 타입이 아닌 제네릭 클래스 자체가 상속의 관계에 있어서 다형성을 가질 수 있었습니다.
타입 매개변수의 인스턴스는 생성할 수 없습니다.
public static <E> void append (List<E> list) {
E e = new E(); // 컴파일 타임 오류
list.add(e)
}
매개변수가 있는 타입의 개체를 생성, 캐치 또는 던질 수 없습니다.
제네릭 클래스는 Throwable 클래스를 직접 또는 간접적으로 확장 할 수 없습니다. 예를 들어 아래의 클래스는 컴파일 타임 오류를 발생시킵니다.
// Throwable을 간접 확장
class MathException <T> extends Exception { ... } // 컴파일 타임 오류
// Throwable을 직접 확장
class QueueFullException <T> extends Throwable {... } // 컴파일 타임 오류
메서드는 타입 매개변수의 인스턴스를 캐치할 수 없습니다.
public static <T extends Exception, J> void execute (List <J> jobs) {
try {
for (J job : job) {
...
}
} catch (T e) {// 컴파일 타임 오류
...
}
}
아래처럼 throws 절에서는 타입 매개변수를 사용할 수 있습니다.
class Parser <T extends Exception> {
public void parse (File file) throws T {
...
}
}
다중 타입 변수(Multiple Type Parameters)
제네릭 클래스는 여러개의 타입 매개변수를 가질 수 있습니다.
public interface Pair<K, V> {
public K getKey();
public V getValue();
}
public class OrderedPair<K, V> implements Pair<K, V> {
private K key;
private V value;
public OrderedPair (K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() { return key; }
public V getValue() { return value; }
}
위의 클래스는 아래처럼 사용할 수 있습니다.
Pair<String, Integer> p1 = new OrderedPair<> (“Even”, 8);
Pair<String, String> p2 = new OrderedPair<>(“hello”, “”world);
타입 매개변수 (K, V) 를 매개 변수화 된 타입으로 대체할 수도 있습니다.
OrderedPair<String, Box<Integer>> p = new OrderedPair<>("Primes”, new Box<Integer>(1));
위의 코드에서 처럼 매개변수화 된 타입인 'Box<Integer>' 를 타입 인자로 넣을 수 있습니다.
쉽게 말하자면 V 자리에 Box<Integer> 가 들어간것입니다.
원시 타입 (Raw Type)
Box<T> 클래스의 원시 타입은 아래와 같습니다.
Box rawBox = new Box();
이 원시 타입은 제네릭이 아닌 클래스또는 인터페이스와 다릅니다.
위의 코드는 Box<Object> rawBox = new Box<>(); 와 같습니다.
원시 타입은 5.0 이전의 많은 API 클래스(Collections 클래스)가 제네릭을 사용하지 않았기 때문에 제네릭이 도입되기 이전 코드와의 호환성 제공을 위해 매개 변수화 된 타입을 원시 타입에 할당할 수 있습니다.
매개변수화 된 타입에 원시 타입을 할당하면 다음과 같이 경고가 발생합니다.(안전하지 않다는 경고만 발생할 뿐 실행은 됩니다.)
Box rawBox = new Box(); // 원시 타입
Box<Integer> intBox = rawBox; // 경고, 확인되지 않은 전환
원시 타입을 사용하여 해당 제네릭 타입에 정의된 제네릭 메서드를 호출하는 경우에도 경고가 표시됩니다.
Box<String> stringBox = new Box<>();
Box rawBox = stringBox;
rawBox.set(8); // 경고, set(T)에 대한 확인되지 않은 호출
이 경고는 원시 타입이 제네릭 타입 검사를 우회하여 안전하지 않은 코드를 잡아내는것을 런타임으로 미루는 것입니다.
원시 타입은 제네릭이 도입되기 이전의 코드와 호환성을 유지하기 위해서 허용하는 것일 뿐 제네릭 클래스를 사용할 때 원시 타입은 사용하지 말고 반드시 타입을 지정해서 제네릭스와 관련된 경고가 나오지 않도록 해야합니다. (원시 타입을 사용하는 것은 제네릭을 사용하지 않고 제네릭 클래스처럼 사용하는것과 같음)
제네릭 메서드(Generic Methods)
제네릭 메서드는 자체 타입 매개 변수를 사용하는 메서드입니다. 제네릭 타입을 선언하는 것과 비슷하지만 타입 매개변수의 범위는 선언된 메서드로 제한됩니다. 제네릭 클래스 생성자 뿐만 아니라 static 및 non-static 제네릭 메서드에 사용하는것이 허용됩니다.
아래의 코드에서 제네릭 클래스 FruitBox<T> 의 T 와 제네릭 메서드 sort에 정의된 '<T>' 는 이름은 T로 같지만 별개의 타입 매개변수를 의미합니다.
제네릭 메서드의 구문에는 메서드의 반환 타입 앞에 나타나는 <> 안에 타입 매개변수 목록이 포함됩니다. 정적 제네릭 메서드의 경우 타입 매개 변수 섹션이 메서드의 반환 형식 앞에 나타나야 합니다.
public class Util {
public static<K, V> boolean compare(Pair<K, V>, p1, Pair<K, V> p2) {
return p1.getKey().equals(p2.getKey()) && p1.getValue().equals(p2.getValue());
}
}
public class Pair<K, V> {
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public void setKey(K key) { this.key = key; }
public void setValue(V value) { this.value = value; }
public K getKey() { return key; }
public V getValue() { return value; }
}
compare 메서드를 호출하는 구문은 아래와 같습니다.
Pair<Integer, String> p1 = new Pair<>(1, “apple”);
Pair<Integer, String> p2 = new Pair<>(2, “pear”);
boolean same = Util.<Integer, String>compare(p1, p2);
위의 코드는 <> 안에 타입을 명시했지만 일반적으로 이것은 생략할 수 있습니다. 컴파일러는 필요한 타입을 추론합니다.
Pair<Integer, String> p1 = new Pair<>(1, “apple”);
Pair<Integer, String> p2 = new Pair<>(2, “pear”);
boolean same = Util.compare(p1, p2);
타입 추론이라고 하는 이 기능을 사용하면 <> 사이에 타입을 지정하지 않고도 제네릭 메서드를 호출할 수 있습니다.
바운디드 타입 매개 변수(Bounded Type Parameter)
Bound의 사전적 의미는 경계 입니다.
매개변수가 있는 타입에서 타입 인수(Type Argument)로 사용할 수 있는 타입을 제한하려는 경우가 있을 수 있습니다. 예를 들어 숫자에 대해 동작하는 메서드는 Number 또는 해당 하위 클래스의 인스턴스만 허용하려고 할 수 있습니다. 이것이 바운디드 타입 매개 변수의 용도 입니다.
이를 선언하려면 타입 매개변수의 이름, extends 키워드, 상위 바운드를 나열해야 합니다.
public class Box<T> {
public T t;
public void set(T t) {
this.t = t;
}
public T get() {
return t;
}
public <U extends Number> void inspect(U u) {
System.out.println( “T : ” + t.getClass().getName());
System.out.println(“U : ” + u.getClass().getName());
}
public static void main(String[] args) {
Box<Integer> integerBox = new Box<Integer>();
integerBox.set(new Integer(10));
integerBox.inpect(“문자열”); // 오류 발생
}
}
위의 제한된 타입 매개변수를 포함하도록 제네릭 메서드를 정의하면 main메서드에서 inspect 메서드 호출에 String이 포함되어 있으므로 컴파일이 실패합니다.
Box.java:21: <U>inspect(U) in Box<java.lang.Integer> cannot
be applied to (java.lang.String)
integerBox.inspect("10");
^
1 error
제네릭 타입을 인스턴스화 하는데 사용할 수 있는 타입을 제한하는 것 외에도 바운디드 타입 매개변수를 사용하여 바운드에 정의 된 메서드를 호출 할 수 있습니다.
public class NaturalNumber<T extends Integer> {
private T n;
public NaturalNumber(T n) { this.n = n; }
public boolean isEven() {
return n.intValue() % 2 == 0;
}
…
}
isEven 메서드는 n의 Integer클래스에 정의된 intValue메서드를 호출합니다.
다중 바운드 (Multiple Bounds)
앞의 예제는 하나의 바운디드 타입이 있는 타입 매개변수를 보여 주지만 타입 매개 변수는 여러 바운드(Bound)를 가질 수 있습니다.
<T extends B1 & B2 & B3>
- 바운드가 여러개인 타입 변수는 바운드에 나열된 모든 타입의 하위 타입 이어야 합니다.
- T는 B1, B2, B3 세개의 타입을 모두 상속하거나 구현한 타입이어야 한다는 의미
- 바운드 중 클래스와 인터페이스가 있다면 클래스를 먼저 지정해야 합니다.
class A {...}
interface B {...}
interface C {...}
class D<T extends A & B & C> {...}
- 만약 바운드 A를 먼저 지정하지 않으면 컴파일 타임 오류가 발생합니다.
class D <T extends B & A & C> {...} // 컴파일 타임 오류 발생!
제네릭 메서드와 바운디드 타입 매개변수
(Generic Methods and Bounded Type Parameters)
바인딩 된 타입 매개변수는 제네릭 알고리즘 구현의 핵심입니다.
아래의 예제는 지정된 요소 elem보다 큰 배열 T[] 의 요소 수를 계산하는 메서드입니다.
public static <T> int countGreaterThan (T[] arr, T elem) {
int count = 0;
for (T e : arr) {
if (e > elem) { // 컴파일러 오류
++count;
}
}
return count;
}
메서드 구현은 간단하지만 '>' 연산자는 기본 자료형(primitive type)에만 적용 되므로 컴파일 되지 않습니다.
'>' 연산자를 사용하여 개체를 비교할 수 없습니다. 문제를 해결하려면 Comparable<T> 인터페이스로 제한되는 타입 매개변수를 사용해야합니다.
public interface Comparable<T> {
public int compareTo(T o);
}
이것을 적용해서 코드를 바꿔보면 아래와 같이 수정할 수 있습니다.
public static <T extends Comparable<T>> int countGreaterThan(T[] arr, T element) {
int count = 0;
for (T e : arr) {
if (e.compareTo(element) > 0) {
++count;
}
}
return count;
}
제네릭, 상속 및 하위 타입
타입이 호환되는 경우 한 타입의 객체를 다은 타입의 객체에 할당할 수 있습니다. 예를 들어, Object는 Integer의 상위 타입 중 하나이기 때문에 Integer를 Object에 할당할 수 있습니다.
아래의 코드는 모두 허용이 되는 코드입니다.
Object obj = new Object();
Integer Integer = new Integer(10);
obj = integer;
public void someMethod(Number n) {...}
someMethod (new Integer(10));
someMethod (new Double(10.1));
Box<Nubmer> box = new Box<>();
box.add(new Integer(10));
box.add(new Double(10.1));
그렇다면 아래의 코드는 가능할까요?
public void boxTest(Box<Number> n) {...}
boxTest(new Box<Integer> integerBox);
boxTest(new Box<Double> doubleBox);
위의 코드는 허용되지 않습니다. Number와 Integer의 관계는 상속관계가 맞지만 Box<Number>와 Box<Integer>는 상속관계가 아닙니다. Box<Number>와 Box<Integer>의 공통 부모는 Object 입니다.
따라서 Box<Number>와 Box<Integer>는 관계가 없는 제네릭 클래스입니다.
두 제네릭 클래스간의 관계를 만드는 방법에 대해서는 와일드 카드에 있습니다.
제네릭 클래스 및 하위 타입
일반적인 클래스와 인터페이스가 extends나 implements 로 관계를 맺듯이 제네릭 클래스(또는 인터페이스)도 extends 또는 implement로 관계를 맺어줄 수 있습니다.
대표적인 예로 Collectio<E> 클래스, List<E> 클래스, ArrayList<E> 클래스 가 있습니다.
ArrayList<String>은 List<String>의 하위 타입 입니다.
List<String>은 Collection<String>의 하위 타입 입니다.
타입 변수(위에서는 <String>을 의미함)를 변환하지 않는한 타입같의 하위 타입 관계는 유지됩니다.
interface payloadList<E, P> extends List<E> {
void setPayload (int index, P val);
...
}
Payload<String, String>
Payload<String, Integer>
Payload<String, Exception>
세가지 모두 List<String>의 하위 타입입니다.
여기서 의문이 생겨 하나 더 실험해 보았습니다.
Payload<Integer, String> 은 List의 하위타입 일까요?
정답은 아닙니다.
선언한 타입 변수가 같아야 관계가 있다고 할 수 있습니다. 이 예제에서는 'E' 라는 타입 변수가 같아야 관계를 맺은거라고 할 수 있습니다.
타입 추론
타입 추론은 각 메서드 호출과 해당 선언을 살펴보고 해당 호출을 적용 할 수 있는 타입 변수를 결정하는 Java 컴파일러의 기능입니다. 추론 알고리즘은 변수의 타입이 사용 가능한 경우 할당되거나 반환되는 타입을 결정합니다.
결국, 추론 알고리즘은 모든 인수와 함께 작동하는 가장 구체적인 타입을 찾으려고 합니다.
타입 추론 및 제네릭 메서드
제네릭 메서드는 '<>' 사이에 형식을 지정하지 않고 제네릭 메서드처럼 일반 메서드를 호출 할 수 있는 타입 유추가 있습니다.
public class Box<T> {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
import java.util.ArrayList;
import java.util.List;
public class BoxDemo {
public static <U> void addBox(U u, List<Box<U>> boxes) {
Box<U> box = new Box<>();
box.set(u);
boxes.add(box);
}
public static <U> void outputBoxes(List<Box<U>> boxes) {
int counter = 0;
for (Box<U> box : boxes) {
U boxContent = box.get();
System.out.println("Box #" + counter + " contains [" + boxContent.toString() + "]");
counter++;
}
}
public static void main(String[] args) {
ArrayList<Box<Integer>> boxArrayList = new ArrayList<>();
BoxDemo.<Integer>addBox(Integer.valueOf(10), boxArrayList);
BoxDemo.addBox(Integer.valueOf(20), boxArrayList);
BoxDemo.addBox(Integer.valueOf(30), boxArrayList);
BoxDemo.outputBoxes(boxArrayList);
}
}
위의 코드는 main메서드에서 제네릭 메서드를 사용하는데 타입 매개변수를 명시하는 경우나 명시하지 않는경우 모두 정상적으로 작동합니다.
제네릭 메서드 addBox는 U 라는 하나의 타입 매개변수를 정의합니다. 일반적으로 Java 컴파일러는 제네릭 메서드 호출에서 타입 매개변수를 추론할 수 있습니다. 따라서 대부분의 경우 타입 매개변수를 지정할 필요가 없습니다.
예를들어 제네릭 메서드를 호출하려면 다음과 같이 형식 타입 매개변수를 지정할 수 있습니다.
BoxDemo.<Integer>addBox(Integer.valueOf(10), boxArrayList);
타입 명시를 생략하면 Java컴파일러가 타입 매개변수가 Integer와 같음을 자동으로 추론합니다. (메서드의 인자에서)
BoxDemo.addBox(Integer.valueOf(20), boxArrayList);
제네릭 클래스의 타입 추론 및 인스턴스화
컴파일러가 컨텍스트에서 타입 변수를 유추할 수 있으면 제네릭 클래스의 생성자를 호출하는데 필요한 타입 변수를 비어있는 타입 매개변수"<>"로 바꿀 수 있습니다.
제네릭 및 비 제네릭 클래스의 타입 유추 및 제네릭 생성자
생성자는 제네릭 클래스, 제네릭이 아닌 클래스 모두 제네릭 일 수 있습니다. 즉, 고유한 타입 매개변수를 선언합니다.
class MyClass<X> {
<T> MyClass(T t) {
...
}
}
MyClass 클래스는 아래와 같이 생성할 수 있습니다.
new MyClass<Integer>("")
제네릭 클래스 MyClass<X>의 타입 매개변수 X에 대해 Integer 타입을 명시적으로 지정합니다.
이 제네릭 클래스의 생성자에는 타입 매개변수 T가 포함되어 있습니다. 컴파일러는 이 제네릭 클래스의 생성자의 타입 매개변수 T에 대해 String 형식을 유추합니다.(이 생성자의 실제 매개 변수는 "" -> String 이기 때문입니다.)
Java 7 이전 릴리스의 컴파일러는 제네릭 메서드와 유사하게 일반 생성자의 실제 타입 매개변수를 추론할 수 있습니다. 그러나 Java 7 이상의 컴파일러는 '<>' 를 사용하는 경우 인스턴스화 되는 제네릭 클래스의 실제 타입 매개변수를 추론할 수 있습니다.
MyClass<Integer> myObject = new MyClass<>("");
추론 알고리즘은 타입을 추론하기 위해 호출 인자, 대상 타입 및 예상 가능한 반환 타입만 사용한다는 점에 유의 해야합니다. 추론 알고리즘은 런타임에 작동하는것이 아닙니다. (즉, 컴파일 타임에 추론 알고리즘이 작동합니다.)
타겟 타입
Java 컴파일러는 타겟 타입을 활용하여 제네릭 메서드 호출의 타입 매개변수를 추론합니다. 표현식의 타겟 타입은 표현식이 나타나는 위치에 따라 Java 컴파일러가 예상하는 데이터 타입입니다.
아래는 Collections.emptyList 메서드입니다.
static <T> List<T> emptyList();
List<String> listOne = Collections.emptyList();
위 문장은 List<String>의 인스턴스가 필요합니다. 이 데이터 타입은 타겟 타입 입니다.
emptyList 메서드는 List<T> 타입의 값을 반환하므로 컴파일러는 타입 인수 T가 String값이어야 한다고 추론합니다.
이것은 Java 7 및 8 모두 작동합니다.
타입 명시하면 아래와 같이 T 값을 지정할 수 있습니다.(생략 하지 않으면)
List<String> listOne = Collections.<String>emptyList();
위의 문장은 타입 변수를 생략해도 아무런 문제가 발생하지 않지만 아래의 문장은 JavaSE7 에서 컴파일 오류가 발생합니다.
void processStringList(List<String> stringList) {
...
}
processStringList(Collections.emptyList());
에러 메시지를 보면 "List<Object>를 List<String> 으로 변환할 수 없습니다." 라고 나옵니다.
컴파일러는 타입 변수 'T' 에 대한 값이 필요하므로 Object값으로 시작합니다. 결과적으로 Collections.emptyList의 호출은 processStringList 메서드의 인자로 호환되지 않는 List<Object> 타입의 값을 반환해줍니다. 다시 말해 아래처럼 넣은 것입니다.
List<Object> emptyList = Collections.emptyList();
processStringList(emptyList);
따라서 Java 7 에서는 다음과 같이 타입 변수 값을 명시해줘야 합니다.
processStringList(Collections.<String>emptyList());
하지만 Java 8 부터는 더 이상 필요하지 않습니다. 타겟 타입이 무엇인지에 대한 개념은 메서드 인수를 포함하도록 확장되었습니다.
예시의 경우 processStringList에는 List<String> 타입의 인자가 필요합니다. Collections.emptyList 메서드는 List<T>의 값을 반환하므로 대상 형식(메서드의 인자로 받는 타입)인 List<String>을 사용하여 컴파일러는 타입 변수 T의 값이 String 이라는 것을 추론합니다.
따라서 Java 8 부터는 아래와 같이 사용가능합니다.
processStringList(Collections.emptyList());
와일드 카드
매개변수에 과일박스를 대입하면 주스를 만들어서 반환하는 Juicer라는 클래스가 있고, 이 클래스에는 과일을 주스로 만들어서 반환하는 makeJuicer() 라는 static 메서드가 다음과 같이 정의되어 있다고 하면
class Juicer {
static Juice makeJuice(FruitBox<Fruit> box) { // <Fruit>으로 지정
String tmp = "";
for (Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
}
Juicer 클래스는 제네릭 클래스도 아니고 제네릭 클래스라고 해도 static 메서드에는 타입 매개변수 T를 매개변수에 사용할 수 없으므로 아예 제네릭스를 적용하지 않던가, 위와 같이 타입 매개변수 대신, 특정 타입을 지정해줘야 합니다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
System.out.println(Juicer.makeJuice(fruitBox)); // OK, FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); // 에러 발생, FruitBox<Apple>
// makeJuice의 인자는 FruitBox<Fruit> 타입이어야 합니다.
제네릭 타입을 FruitBox<Fruit>으로 고정하면 FruitBox<Apple>은 매개변수가 될 수 없으므로 다양한 타입을 사용하기 위해서는
다른 타입을 매개변수로 받는 makeJuice 메서드를 만들 수 밖에 없습니다.
static Juice makeJuice(FruitBox<Fruit> box) { // <Fruit>으로 지정
String tmp = "";
for (Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
static Juice makeJuice(FruitBox<Apple> box) { // <Apple>으로 지정
String tmp = "";
for (Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
하지만 위처럼 여러개의 메서드를 정의하면 컴파일 에러가 발생합니다. 오버로딩해서 만든 메서드 같지만 지네릭 타입이 다른것만으로는 오버로딩이 성립하지 않기 때문입니다.
제네릭 타입은 컴파일 타임에만 사용하고 버려지기 때문에 두 메서드는 오버로딩이 아닌 '메서드 중복정의' 입니다.
이런 문제를 해결하기 위해 사용된 것이 바로 와일드 카드 입니다.
제네릭 코드에서 와일드 카드라고 하는 물음표 '?' 는 알 수 없는 타입을 나타내며 어떤 타입이든 될 수 있다는 것을 의미합니다. 와일드 카드는 매개변수, 필드 또는 지역 변수의 타입으로 다양한 상황에서 사용할 수 있습니다. 때로는 반환 타입으로도 사용됩니다.(더 구체적으로 작성하는 것이 더 나은 프로그래밍) 와일드 카드는 제네릭 메서드 호출, 제네릭 클래스 인스턴스 생성 또는 수퍼 타입에 대한 타입 변수로 사용되지 않습니다.
상한 바운디드 와일드 카드 (Upper Bounded Wildcards / 상한 제한 와일드 카드)
상한 와일드 카드를 사용하여 변수에 대한 제한을 완화 할 수 있습니다.
예를 들어 List<Integer>, List<Double> 및 List<Number>들을 매개변수로 사용하여 정상적으로 동작하는 메서드를 작성한다고 가정해 보겠습니다. 상한 와일드 카드를 사용하면 관계를 맺어줄 수 있습니다.
상한 와일드 카드를 선언하려면 와일드 카드 문자 '?' , extends 키워드, 상한을 차례로 사용합니다. 여기서 extends 는 "extends"(클래스) 또는 "implements"(인터페이스)을 모두 의미합니다.
List<? extends Foo>
Integer, Double 및 Float과 같은 Number 목록 및 Number 하위 타입에서 작동하는 메서드를 작성하려면 List<? Number>를 확장합니다. List<Number>는 Number 타입 목록과만 일치하는 반면 List<? extends Number> 는 Number 타입 또는 하위 클래스의 목록과 일치합니다.
public static void process(List<? extends Foo> list) { ... }
상한 와일드 카드, <? extends Foo>, 여기서 Foo는 모든 타입이며 Foo의 모든 하위 타입과 일치합니다. 프로세스 메서드는 Foo 타입으로 list 요소에 액세스 할 수 있습니다.
public static void process(List<? extends Foo> list) {
for (Foo element : list) {
...
}
}
위의 문장에서 forEach 안에 Foo 클래스에 정의된 모든 메서드를 Foo 타입의 변수 element를 이용해서 사용할 수 있습니다.
아래에 리스트의 모든 요소를 더해서 반환해주는 메서드 sumOfList 메서드가 있습니다.
public static double sumOfList(List<? extends Number> list) {
double s = 0.0;
for (Number n : list) {
s += n.doubleValue();
}
return s;
}
List<Integer> li = Arrays.asList(1,2,3);
System.out.println("sum = " + sumOfList(li));
List<Double> ld = Arrays.asList(1.2, 2.3, 3.5);
System.out.println("sum = " + sumOfList(ld));
위의 코드는 모두 정상 작동하여 각각 6.0, 7.0을 출력합니다.
class FruitBox<T extends Fruit> {
ArrayList<T> list = new ArrayList<>();
public void add(T t) {
list.add(t);
}
public void remove(T t) {
list.remove(t);
}
public ArrayList<T> getList() {
return list;
}
}
class Juicer {
static Juice makeJuice(FruitBox<? extends Fruit> box {
String tmp = "";
for (Fruit f : box.getList()) {
tmp += f + " ";
}
return new Juice(tmp);
}
}
위의 예제에서 makeJuice 메서드의 타입 매개변수를 FruitBox<? extend Object> 로 바꾼다면 어떻게 될까요?
모든 종류의 FruitBox가 이 메서드의 타입 변수로 가능해지지만 box의 요소가 Fruit의 자손이라는 보장이 없으므로 아래의 for문에서 box에 저장된 요소를 Fruit타입의 참조변수로 받을 수 없습니다.
static Juice makeJuice(FruitBox<? extends Object> box {
String tmp = "";
for (Fruit f : box.getList()) { // 에러, Fruit이 아닐 수 있음
tmp += f + " ";
}
return new Juice(tmp);
}
이 코드는 실제로는 실행이 됩니다. 그 이유는 제네릭 클래스 FruitBox는 "FruitBox<? extends Fruit>" 로 타입이 Fruit을 상속받은 타입으로 한정되어 있기 때문에 컴파일러는 문제가 없다고 판단하는 것입니다.
언바운디드 와일드 카드 (Unbounded Wildcards / 제한되지 않은 와일드 카드)
언바운디드 와일드 카드 타입은 와일드 카드 문자 '?'를 사용하여 지정됩니다. (예시. List<?>)
언바운디드 와일드 카드 타입이 사용될 수 있는 시나리오는 다음과 같습니다.
- Object클래스에서 제공하는 기능을 사용하여 구현할 수 있는 메서드를 작성하는 경우
- 코드가 타입 매개변수에 의존하지 않는 제네릭 클래스의 메서드를 사용하는 경우.
- 예를들어, List.size 또는 List.clear
- 사실, Class<?>는 Class<T> 와 같이 대부분의 메서드가 T에 의존하지 않기 때문에 자주 사용됩니다.
public static void printList (List <Object> list) {
for (Object element : list) {
System.out.println(element + " ");
}
System.out.println();
}
printList의 목표는 모든 타입의 List를 출력하는 것이지만 위의 코드는 해당 목표를 수행할 수 없습니다.
현재 printList의 매개변수에는 Object 인스턴스를 가진 List만 들어갈 수 있습니다.
즉, List<Integer>, List<String>, List<Double> 등은 List<Object>의 하위 타입이 아니므로 출력할 수 없는 것입니다.
public static void printList(List<?> list) {
for (Object obj : list) {
System.out.print(obj + " ");
}
System.out.println();
}
이렇게 하면 <?> 안에 어떤 타입의 요소도 추가할 수 없습니다. (null은 추가가 가능)
구체적인 타입 A의 경우 List<A>는 List<?>의 하위 타입이므로 printList를 사용하여 모든 타입의 list를 출력할 수 있습니다.
List<Object>와 List<?>는 동일하지 않다.
Object또는 Object의 하위 타입을 List<Object>에 삽입할 수 있습니다. 그러나 List<?>에는 null을 삽입할 수 있습니다.
하한 와일드 카드 (Lower Bounded Wildcards)
상한 경계 와일드 카드는 알수 없는 타입을 특정 타입 또는 해당 타입의 하위 타입으로 제한하고 extends 키워드를 사용해서 제한할 타입을 정의합니다. 하한 와일드 카드는 비슷한 방식으로 알 수 없는 타입을 특정 타입 또는 해당 타입의 super 타입으로 제한합니다.
하한 와일드 카드는 아래와 같이 사용할 수 있습니다.
List<? super Integer> list
Integer 객체를 List에 넣는 메서드에 유연성을 최대화 하기 위해 메서드가 List<Integer>, List<Number>, List<Object> 의 매개변수에도 작동할 수 있도록 합니다.
Integer, Number, Object와 같은 Integer 상위 타입에서 작동하는 메서드를 작성하려면 List<? super Integer> 로 정의하면 됩니다.
바운디드 와일드 카드를 사용하면 Integer 뿐만 이었습니다.
public static void addNumbers(List<? super Integer> list) {
for (int i = 0; i <= 10; i++) {
list.add(i);
}
}
와일드 카드 및 하위 입력
앞서 Box<Integer> 와 Box<Number>는 상속 관계가 아닌 관계가 없는 다른 클래스라고 했습니다.
그러나 와일드 카드를 사용하여 제네릭 클래스(또는 제네릭 인터페이스) 간의 관계를 만들 수 있습니다.
class A { ... }
class B extends A { ... }
B b = new B();
A a = b;
일반적으로 위의 코드는 정상적인 코드입니다.
이 예제는 정규 클래스의 상속이 하위 타입 규칙을 따르는 것을 보여줍니다. B가 A를 확장하면 클래스 B는 클래스 A의 하위 타입입니다. 이 규칙은 제네릭 타입에 적용되지 않습니다.
List<B> listB = new ArrayList<>();
List<A> listA = listB; // 컴파일 에러
Integer가 Number의 하위 타입이라는 점을 생각하고 List<Integer>와 List<Number>간의 관계를 생각해보면 List<Number>의 하위 타입은 List<Integer>이다. 라고 오해하기 쉽습니다.
하지만 실제로 이 두 타입은 관련이 없습니다.
List<Number> 및 List<Integer>의 공통 부모는 List<?> 입니다.
코드가 List<Integer>의 요소를 통해 Number의 메서드에 액세스 할 수 있도록 이러한 클래스 간의 관계를 만들려면 상한 와일드 카드를 사용합니다.
List<? extends Integer> intList = new ArrayList<>();
List<? extends Number> numList = new ArrayList<>(); // List<? extends Integer>는 List<? extends Number>의 하위타입이 맞다.
아래는 상한 및 하한 와일드 카드로 선언된 여러 List클래스 간의 관계를 보여줍니다.
와일드 카드 캡쳐 및 헬퍼 메서드(Wildcard Capture and Helper Methods)
이 부분은 아직 이해가 잘 되지않아 조금 더 공부후에 정리하겠습니다!
경우에 따라 컴파일러는 와일드 카드 타입을 추론합니다. 예를들어 List는 List<?>로 정의 될 수 있지만 실행문을 읽을 때 컴파일러는 코드에서 특정 타입을 유추합니다. 이것을 와일드 카드 캡쳐 라고 합니다.
대부분의 경우 "capture of" 라는 문구가 포함된 오류 메시지가 표시되는 경우를 제외하고는 와일드 카드 캡쳐에 대해 걱정할 필요는 없습니다.
아래의 예제는 "capture of" 라는 문구가 포함된 오류를 발생시킵니다.
public class WildcardError {
void foo(List<?> list) {
list.set(0, list.get(0));
}
}
이 예제에서 컴파일러는 'list' 입력 매개변수의 타입을 Object로 처리합니다.
foo 메서드가 List.set(int, E) 메서드를 호출하면 컴파일러는 목록에 삽입되는 객체의 타입을 확인할 수 없으므로 오류가 발생합니다.
이러한 타입의 오류가 발생하면 일반적으로 컴파일러가 잘못된 타입을 변수에 할당하고 있다고 생각합니다. 그 이유는 Java에서 Generics가 추가되어 컴파일 타임에 타입 안전성(타입 세이프)를 강화됐기 때문입니다.
public class WildcardError {
void foo(List<?> list) {
list.set(0, list.get(0));
}
}
public class WildcardFixed {
void foo(List<?> i) {
fooHelper(i);
}
// 타입 추론을 해서 와일드 카드를 캡쳐할 수 있도록 헬퍼 메서드를 생성합니다.
private <T> void fooHelper(List<T> list) {
list.set(0, list.get(0));
}
}
fooHelper 메서드 덕분에 컴파일러는 추론을 사용하여 성공적으로 컴파일 됩니다.
와일드 카드 사용을 위한 가이드라인
제네릭 프로그래밍을 배울 때 가장 혼란스러운 부분 중 하나는 상한 와일드 카드를 사용할 시기와 하한 와일드 카드를 사용할 시기를 결정하는 것입니다.
이것을 결정하기 위해 변수를 두가지 기능으로 생각하면 도움이 됩니다.
- IN 변수
- IN 변수는 코드에 데이터를 제공합니다. 두개의 인자를 가지는 copy(src,dest)가 있는 복사 메서드를 생각해보면, src 인자는 복사할 데이터를 제공하므로 IN 변수 입니다.
- OUT 변수
- OUT 변수는 다른 곳에서 사용할 데이터를 보유합니다. copy(src, dest) 에서 dest 인자는 데이터를 받아들이므로 OUT 변수입니다.
와일드 카드 사용 가이드 라인
- IN 변수는 extends 키워드를 사용하여 상한 와일드 카드를 정의합니다.
- OUT 변수는 super 키워드를 사용하여 하한 와일드 카드를 정의합니다.
- Object 클래스에 정의된 메서드를 사용하여 IN 변수에 액세스 할 수 있는 경우 언바운드 와일드 카드를 사용합니다.
- IN 또는 OUT 변수로 변수에 액세스 해야하는 경우 와일드 카드를 사용하지 않습니다.
1. Generic 에서 Primitive 타입을 선언할 수 없는 이유?
-> primitive 타입은 Object 를 상속받지 않았기 때문에
2. 왜 Generic 에서는 다음과 같이 배열을 선언할 수 없는가
class Box<T> {
T[] itemArr; // 정상, T타입의 배열을 위한 참조변수는 가능
T[] toArray() {
T[] tmpArr = new T[itemArr.length]; // 에러, 제네릭 배열 생성 불가
...
return tmpArr;
}
}
new 연산자 때문 -> new 연산자는 컴파일 시점에 타입 T가 무엇인지 정확히 알아야 합니다.
하지만 위의 코드에 정의된 Box<T> 클래스를 컴파일 하는 시점에서는 T가 어떤 타입이 될지 전혀 알 수 없습니다.
intanceOf 연산자에도 같은 이유로 T(타입 매개변수)를 피 연산자로 사용할 수 없습니다.
타입 삭제 (Erasure)
컴파일러는 제네릭 타입을 이용해서 소스파일을 체크하고, 필요한 곳에 형변환을 넣어줍니다. 그리고 제네릭 타입을 제거합니다. 즉, 컴파일된 파일(*.class)에는 제네릭 타입에 대한 정보가 없는것입니다.
제네릭을 구현하기 위해 Java 컴파일러는 다음의 타입 삭제를 적용합니다.
- 제네릭 타입의 모든 타입 매개변수를 해당 범위(타입 매개변수가 제한되지 않은 경우 Object)로 변경합니다. 따라서 생성된 바이트 코드에는 제네릭이 제거된 코드만 남아있습니다.
- 확장된 제네릭 타입에서 다형성을 보존하는 브리지 메서드를 생성합니다.
타입 삭제는 매개변수화 된 타입에 대해 새 클래스가 생성되지 않도록 합니다. 이 말은 제네릭은 런타임에 오버헤드를 발생시키지 않습니다.
제네릭 타입 삭제
타입 삭제는 다음과 같이 진행됩니다.
class Box<T> {
public T data;
public List<T> list = new ArrayList<>();
public Box(List<T> list) {
this.list = list;
}
public T getData() { return data; }
}
타입 매개변수가 언바운드(제한되지 않음) 이므로 Java컴파일러는 이를 Object로 대체합니다.
class Box {
public Object data;
public List list = new ArrayList();
public Box(List list) {
this.list = list;
}
public Object getData() { return data; }
}
다음은 바운드 타입 매개변수를 사용합니다.
class FruitBox<T extends Fruit> {
public T data;
public List<T> list = new ArrayList<>();
public Box(List<T> list) {
this.list = list;
}
public T getData() { return data; }
}
Java 컴파일러는 바인딩 된 타입 매개변수 T를 첫번째 바인딩 된 클래스인 Fruit으로 대체합니다.
class FruitBox {
public Fruit data;
public List list = new ArrayList();
public Box(List list) {
this.list = list;
}
public Fruit getData() { return data; }
}
제네릭 메서드 삭제
Java 컴파일러는 제네릭 메서드 인자의 타입 매개변수로 삭제합니다.
public static <T> int count(T[] arr, T t) {
int cnt = 0;
for (T e : arr) {
if (e.equals(t)) {
++cnt;
}
}
return cnt;
}
T는 제한되지 않았기 때문에 Java 컴파일러는 이를 Object로 대체합니다.
public static int count(Object[] arr, Object t) {
int cnt = 0;
for (Object e : arr) {
if (e.equals(t)) {
++cnt;
}
}
return cnt;
}
제한을 둔 경우도 제네릭 타입 삭제와 같습니다.
class Shape { ... }
class Circle extends Shape { ... }
class Rectangle extends Shape { ... }
public static <T extends Shape> void draw (T shape) { ... }
컴파일러는 위의 draw 메서드의 T 를 Shape로 대체합니다.
public static void draw (Shape shape) { ... }
타입 삭제 및 브리지 메서드의 효과
때로는 타입 삭제로 예상치 못한 상황이 발생할 수 있습니다.
public class Node<T> {
public T data;
public Node(T data) {
this.data = data;
}
public void setData(T data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node<Integer> {
public MyNode(Integer data) { super (data); }
public void setData(정수 데이터) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
MyNode myNode = new MyNode(5);
Node node = myNode; // 원시 타입을 사용해서 경고 발생
node.setData("Hello");
Integer x = myNode.data; // ClassCastException 이 발생
위의 문장에서 타입 삭제를 하면 아래와 같습니다.
MyNode myNode = new MyNode(5);
Node node = (MyNode)myNode;
node.setData("Hello");
Integer x = (String) myNode.data;
위의 문장에서 발생하는 문제는 아래와 같습니다.
- node.setData("Hello"); -> MyNode 클래스의 객체에서 setData(Object) 메서드가 실행되도록 합니다.
- (MyNode 클래스는 Node에서 setData(Object)를 상속받았습니다.
- setData(Object) 본문에서 n이 참조하는 객체의 데이터 필드는 String이 할당됩니다.
- myNode를 통해 참조되는 동일한 객체의 데이터 필드에 액세스 할 수 있으며 정수가 될 것으로 예상합니다.
- (myNode는 Node<Integer>인 MyNode 이기 때문에)
- String을 Integer에 할당하려고 하면 Java 컴파일러에 의해 ClassCastException이 발생합니다.
브리지 메서드
- 매개 변수가 있는 클래스를 확장하는 클래스 또는 인터페이스
- 매개 변수가 있는 인터페이스를 구현하는 클래스 또는 인터페이스
위의 두 경우를 컴파일 할 때 컴파일러는 타입 삭제 프로세스의 일부로 브리지 메서드라는 합성 메서드를 만들어야 할 수 있습니다.
일반적으로 브리지 메서드에 대해서는 걱정할 필요가 없어서 stack trace에 나타날 경우 당황할 수 있습니다.
앞에서 보았던 Node 와 MyNode의 타입 삭제후 코드는 다음과 같습니다.
public class Node {
public Object data;
public Node(Object data) { this.data = data; }
public void setData(Object data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node {
public MyNode(Integer data) { super(data); }
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
타입 삭제 후 메서드 시그니쳐가 이 일치하지 않습니다. Node 메서드는 setData(Obejct)가 되고 MyNode 메서드는 setData(Integer)가 됩니다. 따라서 MyNode setData메서드는 Node setData 메서드를 오버라이드 하지 않습니다.
이 문제를 해결하고 타입 삭제 후 제네릭 타입의 다형성을 보존하기 위해 Java 컴파일러는 하위 타입이 예상대로 작동하는지 확인하는 브리지 메서드를 생성합니다.
MyNode 클래스의 경우 컴파일러는 setData에 대해 다음 브리지 메서드를 생성합니다.
class MyNode extends Node {
// 브리지 메서드
public void setData(Object data) {
setData((Integer) data);
}
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
위의 코드를 보면, 타입 삭제 후 Node 클래스의 setData 메서드와 동일한 메서드 시그니쳐를 가진 bridge 메서드는 원래 setData 메서드에 위임 됩니다.
참고문헌
- docs.oracle.com/javase/tutorial/java/generics/index.html
- medium.com/@joongwon/java-java%EC%9D%98-generics-604b562530b3
-
-
-
'java-liveStudy' 카테고리의 다른 글
15주차. 람다식 (0) | 2021.03.05 |
---|---|
13주차. I/O (0) | 2021.02.15 |
12주차. 애너테이션 (0) | 2021.01.31 |
11주차. Enum (0) | 2021.01.24 |
10주차 과제. 멀티쓰레드 프로그래밍 (0) | 2021.01.17 |
댓글