JDK1.5에서 처음 도입된 지네릭스는 JDK1.8부터 도입된 람다식만큼 큰 변화였다고 한다. 그 당시만 해도 지네릭스는 선택적으로 사용하는 경우가 많았지만 이제는 지네릭스를 모르고는 Java API문서조차 제대로 보기 어려울 만큼 중요한 위치를 차지하고 있다.
지네릭스는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크(compile - time type check)를 해주는 기능이다.
지네릭스의 장점
1. 타입 안정성을 제공한다.
2. 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해 진다.
타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준다는 뜻이다.
지네릭 타입은 클래스와 메서드에 선언할 수 있는데, 우선 클래스에 선언하는 경우를 알아보자.
class Box {
Object item;
void setItem(Object item) { this.item = item; }
Object getItem() { return item; }
}
// 위의 클래스를 지네릭 클래스로 변경하면 다음과 같이
// 클래스 옆에 '<T>'를 붙이면 된다. 그리고 'Object'를 모두 'T'로 바꾼다.
class Box<T> { // 지네릭 타입 T를 선언
T item;
void setItem(T item) { this.item = item; }
T getItem() { return item; }
}
T를 타입 변수(type variable라고 하며, 첫 글자를 따온 것이므로, 꼭 T가 아닌 다른 것을 사용해도 된다. ArrayList<E>에서 'Element'의 'E'를, Map<K,V>에서 'Key, Value'의 'K'와 'V'를 사용하는 것처럼 말이다.
이들은 기호의 종류만 다를 뿐 임의의 참조형 타입을 의미한다는 것은 모두 같다.
지네릭 클래스가 된 Box클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자 타입 T대신에 사용될 실제 타입을 지정해주어야 한다.
Box<String> b = new Box<String>(); // 타입 T 대신, 실제 타입을 지정
b.setItem(new Object()); // 에러. String 이외의 타입은 지정불가
b.setItem("ABC"); // OK. String 타입이므로 가능
String item = (String) b.getItem(); // 형변환이 필요없음
위의 코드에서 타입 T대신에 String 타입을 지정해줬으므로, 아래와 같이 정의된 것과 같다.
class Box {
String item;
void setItem(String item) { this.item = item; }
String getItem() { return item; }
}
지네릭이 도입되기 이전의 코드와 호환을 위해, 지네릭 클래스인데도 예전의 방식으로 객체를 생성하는 것이 허용된다. 다만 지네릭 타입을 지정하지 않아서 안전하지 않다는 경고가 발생한다.
아마, 전 챕터들에서도 지네릭 타입을 지정하지 않고 선언하는 경우 각자 사용하는 IDE에서 다음과 같은 경고가 나왔을 것이다.
"unchecked or unsafe operation" 이라던가
"Raw use of parameterized class '~~'" 같은...
Box<T> 지네릭 클래스, 'T의 Box' 또는 'T Box'라고 읽는다.
T 타입 변수 또는 타입 매개변수.(T는 타입 문자)
Box 원시 타입(raw type)
Box<String> b = new Box<String>();
위와 같이 타입 매개변수에 타입을 지정하는 것을 지네릭 타입 호출이라고 하고, 지정된 타입 'String'을 매개변수화된 타입(parameterized type)이라고 한다.
지네릭 클래스 Box의 객체를 생성할 때, 객체별로 다른 타입을 지정하는 것은 적절하다. 지네릭스는 이처럼 인스턴스별로 다르게 동작하도록 하려고 만든 기능이니까.
Box<Apple> appleBox = new Box<Apple>(); // OK. Apple객체만 저장가능
Box<Grape> grapeBox = new Box(Grape>(); // OK. Grape객체만 저장가능
그러나 객체에 동일하게 동작해야하는 static멤버에는타입 변수 T를 사용할 수 없다. T는 인스턴스변수로 간주되기 때문이다.
static 멤버는 타입 변수에 지정된 타입, 즉 대입된 타입의 종류에 관계없이 동일한 것이어야 하기 때문이다. 그리고 지네릭 타입의 배열을 생성하는 것도 허용되지 않는다. 지네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만 new T[10]과 같이 배열을 생성하는 것은 안된다는 뜻이다. -> T가 정확히 뭔지 알아야 하기 때문 !
예제를 통해서 확인해보는 것이 빠르다.
import java.util.ArrayList;
class Fruit { public String toString() { return "Fruit"; }}
class Apple extends Fruit { public String toString() { return "Apple"; }}
class Grape extends Fruit { public String toString() { return "Grape"; }}
class Toy { public String toString() { return "Toy"; }}
public class FruitBoxEx1 {
public static void main(String[] args) {
Box<Fruit> fruitBox = new Box<Fruit>();
Box<Apple> appleBox = new Box<Apple>();
Box<Toy> toyBox = new Box<Toy>();
// Box<Grape> grapeBox = new Box<Apple>(); // 에러. 타입 불일치
fruitBox.add(new Fruit());
fruitBox.add(new Apple()); // OK. void add(Fruit item)
appleBox.add(new Apple());
appleBox.add(new Apple());
// appleBox.add(new Toy()); // 에러. Box<Apple>에는 Apple만 담을 수 있음
toyBox.add(new Toy());
// toyBox.add(new Apple()); // 에러. Box<Toy>에는 Apple을 담을 수 없음
System.out.println(fruitBox);
System.out.println(appleBox);
System.out.println(toyBox);
}
}
class Box<T>{
ArrayList<T> list = new ArrayList<T>();
void add(T item) { list.add(item); }
T get(int i) { return list.get(i); }
int size() { return list.size(); }
public String toString() { return list.toString(); }
}
/* 실행결과
[Fruit, Apple]
[Apple, Apple]
[Toy]
*/
타입 문자로 사용할 타입을 명시하면 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만, 그래도 여전히 모든 종류의 타입을 지정할 수 있다는 것에는 변함이 없다.
그럼, 개변수 T에 지정할 수 있는 타입의 종류를 제한할 수 있는 방법은 없을까?
다음과 같이 지네릭 타입에 extends를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다.
class FruitBox<T extends Fruit> { // Fruit의 자손만 타입으로 지정가능
ArrayList<T> list = new ArrayList<T>();
...
}
// ->
FruitBox<Apple> appleBox = new FruitBox<Apple>(); // OK
FruitBox<Toy> toyBox = new FruitBox<Toy>(); // 에러. Toy는 Fruit의 자손이 아님
// add()의 매개변수 타입 T도 Fruit와 그 자손 타입이 될 수 있다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
fruitBox.add(new Apple()); // OK. Apple이 Fruit의 자손
fruitBox.add(new Grape()); // OK. Grape가 Fruit의 자손
다형성에서 조상타입의 참조변수로 자손타입의 객체를 가리킬 수 있는 것처럼, 매개변수화된 타입의 자손 타입도 가능한 것이다. 타입 매개변수 T에 Object 를 대입하면, 모든 종류의 객체를 저장할 수 있게 된다.
만일 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요시, implements가 아닌 extends를 사용한다는 것에 주의하자 !
interface Eatable {}
class FruitBox<T extends Eatable> {...}
// Fruit의 자손이면서 Eatable인터페이스도 구현해야 한다면 '&' 기호로 연결
class FruitBox<T extends Fruit & Eatable> {...}
class Juicer {
static Juice makeJuice(FruitBox<Fruit> box) { // <Fruit>으로 지정
String tmp = "";
for(Fruit f : box.getList() ) tmp += f + " ";
return new Juice(tmp);
}
}
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>
Juicer의 makeJuice()에서 지네릭 타입을 FruitBox<Fruit>로 고정해 놓으면, Fruit<Apple> 타입의 객체는 makeJuice()의 매개변수가 될 수 없으므로, 여러 가지 타입의 매개변수를 갖는 makeJuice()를 만들 수 밖에 없다.
그러나 그렇게 오버로딩하면, 컴파일 에러가 발생한다. 지네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문이다. 지네릭 타입은 컴파일러가 컴파일할 때만 사용하고 제거해버린다. 그래서 위의 두 메서드는 오버로딩이 아니라 메서드 중복 정의 이다.
여기서 고안된 것이 바로
와일드 카드이다. 기호 '?'로 표현하며, 어떠한 타입도 될 수 있다.
?만으로는 Object타입과 다를 게 없으므로, 상한과 하한을 제한할 수 있다.
<? extends T> 와일드 카드의 상한 제한, T와 그 자손들만 가능
<? super T> 와일드 카드의 상한 제한. T와 그 조상들만 가능
<?> 제한 없음. 모든 타입이 가능. <? extends Object>와 동일
static Juice makeJuice(FruitBox<? extends Fruit> box){
String tmp;
for(Fruit f : box.getList() ) tmp += f + " ";
return new Juice(tmp);
}
위와 같이 와일드 카드를 사용하면, 이 메서드의 매개변수로 FruitBox<Fruit>뿐만 아니라, FruitBox<Apple>과 FruitBox<Grape>도 가능하게 된다.
메서드의 선언부에 지네릭 타입이 선언된 메서드를 지네릭 메서드라 한다.
Collections.sort()가 바로 지네릭 메서드이며, 지네릭 타입의 선언 위치는 반환타입 바로 앞이다.
static <T> void sort(List<T> list, Comparator<? super T> c)
지네릭 클래스에 정의된 타입 매개변수와 지네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것이다.
class FruitBox<T> {
...
static <T> void sort (List<T> list, Comparator<? super T> c) {...}
}
지네릭 클래스 FruitBox에 선언된 타입 매개변수 T와 지네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것이다. 그리고 sort()가 static메서드라는 것에 주목하자.
앞서 설명한 것처럼, static멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 지네릭 타입을 선언하고 사용하는 것은 가능하다.
메서드에 선언된 지네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 이해하기 쉬운데, 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관이 없다.
static Juice makeJuice(FruitBox<? extends Fruit> box) {}
// ->
static <T extends Fruit> Juice makeJuice(FruitBox<T> box) {}
// 호출할 때는 아래와 같이 타입 변수에 타입을 대입해야 한다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juicer.<Fruit>makeJuice(fruitBox));
System.out.println(Juicer.<Apple>makeJuice(appleBox));
// 대부분의 경우 컴파일러가 타입을 추정할 수 있기 때문에 생략 가능
아래의 메서드는 Collections 클래스의 sort()인데, 좀 전에 sort()와 달리 매개변수가 하나짜리이다.
// 원래의 매개변수 하나짜리 sort()
public static <T extends Comparable<? super T>> void sort(List<T> list)
// 이해를 위해 와일드 카드를 걷어낸 것
public static <T extends Comparable<T>> void sort(List<T> list)
와일드 카드가 들어간 것을 이해하자면,
T는 Comparable을 구현한 클래스이어야 하며 (<T extends Comparable>), T 또는 그 조상의 타입을 비교하는 Comparable 이어야 한다는 것(Comparable<? super T>)을 의미한다. 만일 T가 Student이고, Person의 자손이라면, <? super T>는 Student, Person, Object가 모두 가능하다.지네릭 타입과 넌지네릭(non-generic) 타입간의 형변환은 항상 가능하다. 다만 경고가 발생할 뿐.
또, 대입된 타입이 다른 지네릭 타입 간에는, 대입된 타입이 Object일지라도 불가능하다.
Box<? extends Object> wBox = new Box<String>();
위의 경우에는 형변환이 된다. 그래서 이전에 makeJuice메서드의 매개변수에 다형성이 적용될 수 있었던 것이다. 반대로의 형변환도 성립하지만, 확인되지 않은 형변환이라는 경고가 발생한다.
(중략)
컴파일러는 지네릭 타입을 이용해서 소스파일을 체크하고, 필요한 곳에 형변환을 넣어준다. 그리고 지네릭 타입을 제거한다. 즉, 컴파일된 파일(*.class)에는 지네릭 타입에 대한 정보가 없는 것이다.
~~ 기본적인 제거과정
1. 지네릭 타입의 경계(bound)를 제거한다.
지네릭 타입이 라면 T는 Fruit로 치환된다. 인 경우 T는 Object로 치환된다. 그리고 클래스 옆의 선언은 제거된다.
2. 지네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가한다.
// List의 get()은 Object타입을 반환하므로 형변환이 필요하다.
T get(int i){
return list.get(i);
}
/* ↓ */
Fruit get(int i){
return (Fruit)list.get(i);
}
// 와일드 카드가 포함되어 있는 경우에는 다음과 같이 적절한 타입으로의 형변환이 추가된다.
static Juice makeJuice(FruitBox<? extends Fruit> box){
String tmp;
for(Fruit f : box.getList() ) tmp += f + " ";
return new Juice(tmp);
}
/* ↓ */
static Juice makeJuice(FruitBox box){
String tmp;
Iterator it = box.getList().iterator();
while(it.hasNext()) {
tmp += (Fruit)it.next() + " ";
}
return new Juice(tmp);
}