제네릭이란? 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크(compile-time type check)를 해주는 기능이다. 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다.
보통의 ArrayList에는 한 타입의 객체만 담는다(여러 타입이 가능하긴 함). 이 때 저장할 객체의 타입을 지정해 주면, 지정한 타입 외에 다른 타입의 객체가 저장되면 에러가 발생할 것이다.
// Tv객체만 저장할 수 있는 ArrayList를 생성
ArrayList<Tv> tvList = new ArrayList<Tv>();
tvList.add(new Tv()); // OK
tvList.add(new Audio()); // 컴파일 에러.
저장된 객체를 꺼낼 때에는 형변환 할 필요가 없어 편리하다 이미 어떤 타입의 객체들이 저장돼 있는지 알고있기 때문이다.
// 제네릭 미사용
ArrayList tvList = new ArrayList();
tvList.add(new Tv());
Tv t = (Tv)tvList.get(0); // Tv객체가 아닐 수 있기에.
// 제네릭 사용
ArrayList<Tv> tvList = new ArrayList<Tv>();
tvList.add(new Tv());
Tv t = tvList.get(0); // Tv무조건 Tv관련 객체임.
제네릭의 장점
1. 타입 안정성을 제공한다.
2. 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해 진다.
제네릭에서 사용되는 용어들은 자칫 헷갈리기 쉽다. 다음과 같이 제네릭 클래스 Box가 선언됐을 때,
class Box<타입> {}
Box<타입> 제네릭 클래스. '타입 의 Box' 또는 '타입 Box'라고 읽는다.
T 타입 변수 또는 타입 매개변수.(T는 타입문자)
BOX 원시 타입(raw type)
아래와 같이 타입 매개변수에 타입을 지정하는 것을 '제네릭 타입 호출'이라 하고, 지정된 타입 'String'을 '매개변수화된 타입(parameterized type)'이라 한다.
컴파일 후에 Box<문자열>과 Box<정수형>은 이들의 '원시 타입'인 Box로 바뀐다.
ArrayList클래스의 선언에서 클래스 이름 옆의 '<>'안에 있는 E를 '타입 변수'라고 한다. 일반적으로는 'Type'의 T를 주로 사용하는 편이다.
public class ArrayList<E> extends AbstractList<E> {
...
private transient E[] elementData;
public boolean add(E o) { ... }
public E get(int index) { ... }
...
}
Map과 같이 타입 변수가 여러 개인 경우에는 콤마를 구분자로 나눈다
Map<K, V>
제네릭스가 없던 JDK의 ArrayList코드이다.
public class ArrayList extends AbstractList {
...
private transient Object[] elementData;
public boolean add(Object o) { ... }
public Object get(int index) { ... }
...
}
ArrayList와 같은 제네릭 클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자의 <>에 실제 타입을 지정한다.
// 타입 변수 E 대신에 실제 타입 Tv를 대입해야함.
ArrayList<Tv> tvList = new ArrayList<Tv>();
이 때, 지정된 타입 Tv를 '대입된 타입(parameterized type)'이라 한다.
타입이 지정되면 ArrayList는 아래 코드와 같이 작동한다고 생각하면 된다.
public class ArrayList extends AbstractList<E> {
...
private transient Tv[] elementData;
public boolean add(Tv o) { ... }
public Tv get(int index) { ... }
...
}
제네릭 클래스의 객체를 생성할 때 참조변수에 지정된 제네릭 타입과 생성자에 지정된 제네릭 타입이 같아야만 한다. 클래스 Tv와 Product가 서로 상속관계에 있어도 일치해야만 한다.
ArrayList<Tv> list = new ArrayList<Tv>(); // OK. 일치
ArrayList<Product> list = new ArrayList<Tv>(); // 에러. 불일치
...
class Product { }
class Tv extends Product { }
class Audio extends Product { }
하지만 제네릭 타입이 아닌 클래스의 타입 간에 다형성을 적용하는것은 가능하다. 이 경우에도 제네릭 타입은 일치해야만 한다.
List<Tv> list = new ArrayList<Tv>(); // OK. 다형성. ArrayList가 List를 구현
List<Tv> list = new LinkedList<Tv>(); // Ok. 다형성. LinkedList가 List를 구현
ArrayList에 Product의 자손들만 저장하고 싶을 때는 참조변수 제네릭과 생성자 제네릭을 그냥 모두 Product로 만들고 아래 Tv, Audio같은 자식 클래스를 저장하면 된다.
ArrayList<Product> lsit = new ArrayList<Product>();
list.add(new Product());
list.add(new Tv()); // OK.
list.add(new Audio()); // OK.
대신 ArrayList에 저장된 객체를 꺼낼 때, 형변환이 필요하게 된다.
Product p = list.get(0); // Product객체는 형변환이 필요없다.
Tv t = (Tv)list.get(1); // Product의 자식 객체들은 형변환이 필요하다.
import java.util.*;
class Product {}
class Tv extends Product {}
class Audio extends Product {}
public class GenericsExample {
public static void main(String[] args) {
ArrayList<Product> productList = new ArrayList<Product>();
ArrayList<Tv> tvList = new ArrayList<Tv>();
// ArrayList<Product> tvList = new ArrayList<Tv>(); // 에러.
// List<Tv> tvList = new ArrayList<Tv>(); // OK. 다형성
productList.add(new Tv());
productList.add(new Audio());
tvList.add(new Tv());
tvList.add(new Tv());
printAll(productList);
// printAll(tvList); // 컴파일 에러 발생
}
public static void printAll(ArrayList<Product> list) {
for (Product p : list)
System.out.println(p);
}
}
타입 문자로 사용할 타입을 명시할 수 있다지만 그래도 모든 종류의 타입을 지정할 수 있기에 코드 작성자가 의도한 타입을 사용하지 않을 가능성이 있다. 이 때 타입 매개변수 T에 지정할 수 있는 타입의 종류를 제한할 수 없을까?
FruitBox<Toy> fruitBox = new FruitBox<Toy> ();
fruitBox.add(new Toy()); // OK. 과일 상자에 장난감을 담아버렸다.
아래와 같이 제네릭 타입에 'extends'를 사용하면, 특정 타입의 자손들만 매개변수에 대입할 수 있게 된다.
class FruitBox<T extends Fruit> { // Fruit의 자손만 타입으로 지정 가능
ArrayList<T> list = new ArrayList<T>();
...
}
여전히 한 종류의 타입을 대입하지만 Fruit클래스의 자손들만 담을 수 있게 된다.
FruitBox<Apple> appleBox = new FruitBox<Apple>(); // OK.
FruitBox<Toy> toyBox = new FruitBox<Toy>(); // 에러. Toy는 Fruit클래스의 자식이 아니다.
만인 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요하다면, 이때도 'extends'를 사용한다. 'implements'를 사용하지 않는다는 점을 유의하자.
interface Eatable {}
class FruitBox<T extends Eatable> { ... }
만약 Fruit의 자손이면서 Eatable 인터페이스도 구현해야 한다면 아래와 같이 '&'을 사용한다.
class FruitBox<T extends Fruit & Eatable> { ... }
제네릭 클래스 Box의 객체를 생성할 떄, 객체별로 다른 타입을 지정하는 것은 적절하다.
Box<Apple> appleBox = new Box<Apple>();
Box<Grape> appleBox = new Box<Grape>();
그러나 모든 객체에 대해 동일하게 동작해야 하는 static멤버에 타입 변수 T를 사용할 수는 없다. T는 인스턴스 변수로 간주되기 때문이다. static 멤버는 인스턴스 변수를 참조할수 없지 않는가.
class Box<T> {
static T item; // 에러
static int compare(T t1, T t2) { ... } // 에러
...
}
또 제네릭 타입의 배열의 생성하는것도 불허된다. 제네릭 배열 타입의 참조변수를 선언하는 것은 가능하나 'new T[10]'과 같이 배열을 생성하는 것은 안된다는 뜻이다.
class Box<T> {
T[] itemArr; // OK. T타입의 배열을 위한 참조변수
...
T[] toArray() {
T[] tmpArr = new T[itemArr.length]; // 에러. 제네릭 배열 생성불가
...
return tmpArr;
}
...
}
제네릭 배열을 생성할수 없는 이유는 new때문인데, 이 연산자는 컴파일 시점에 타입T가 뭔지 정확히 알아야 한다. 그런데 위의 코드에 정의된 Box<타입>클래스를 컴파일 하는 시점에서는 T가 어떤 타입이 될지 전혀 알 수 없기 때문에 new T[]는 동작하지 않는 것이다.
같은 이유로 instanceof도 T를 피연산자로 사용할 수 없다.
제네릭 클래스를 생성할 때 참조변수의 제네릭 타입과 생성자의 제네릭 타입은 일치해야 한다.
그렇다면 제네릭 타입에 다형성을 적용할 방법은 없을까? 제네릭 타입으로 '와일드 카드'를 사용하면 된다. 와일드 카드는 기호'?'를 사용하고 아래와 같이 'extends'와 'super'로 상한(upper bound)와 하한(lower bound)를 제한할 수 있다.
<? extends T> 와일드 카드의 상한 제한. T와 그 자손들만 가능
<? super T> 와일드 카드의 하한 제한. T와 그 부모들만 가능
<?> 제한 없음, 모든 타입이 가능<? extends Object>와 동일
이렇게 와일드 카드를 사용하면 아래의 코드와 같이 하나의 참조변수로 다른 제네릭 타입이 지정된 객체를 다룰 수 있다.(Tv와 Auido가 Product의 자식이라고 가정)
// 제네릭 타입이 '? extends Product'이면, Product와 Product의 모든 자식이 OK
ArrayList<? extends Product> list = new ArrayList<Tv>(); // OK
ArrayList<? extends Product> list = new ArrayList<Audio>(); // OK
와일드 카드를 아래와 같이 메서드의 매개변수에 적용하면,
static Juice makeJuice(FruitBox<? extends Fruit> box) {
String tmp = "";
for(Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
다음과 같이 제네릭 타입이 다른 여러 객체를 매개변수로 지정 가능하다.(Apple이 Fruit의 자식이라고 가정)
System.out.println(Juicer.makeJuice(new FruitBox<Fruit>())); //OK
System.out.println(Juicer.makeJuice(new FruitBox<Apple>())); //OK
메서드의 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라 한다.
static <T> void sort(List<T> list, Comparator<? super T> c)
위 제네릭 메서드는 Collections.sort() 메서드이다.
만약 제네릭 클래스 안의 제네릭 메서드가 존재할 때 둘 다 T라는 타입변수를 가져도 상관없다. iv, lv같은 개념이다.
class FruitBox<T> {
...
static <T> void sort(List<T> list, Comparator<? super T> c) {
...
}
}
앞서 알아본 것과 같이 static 멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 제네릭 타입을 선언하는 것은 상관없다.
Box box = null;
Box<Object> objBox = null;
box = (Box)objBox; // OK. 지네릭 타입 -> 원시 타입. 경고 발생
objBox = (Box<Object>)box; // OK 원시 타입 -> 제네릭 타입. 경고 발생
제네릭 타입과 non-제네릭 타입간의 형변환은 항상 가능하다. 다만 경고가 발생한다.
Box<Object> objBox = null;
Box<String> strBox = null;
objBox = (Box<Object>)strBox; // 에러. Box<String> -> Box<Object>
strBox = (Box<String>)objBox; // 에러.
위 코드처럼 대입된 타입이 다른 제네릭 타입 간에는 형변환이 항상 불가능 하다. 만약 위 코드가 됐다면 아래 코드도 됐을 것이다.
Box<String> strBox = new Box<Object>();
또 아래 코드처럼은 형변환이 되기 때문에 와일드카드 매개변수의 다형성이 성립된다.
Box<? extends Object> wBox = new Box<String>();
제네릭 타입은 런타임 시에 일어날 오류들을 컴파일타임에 미리 잡아주는 역할을 한다. 따라서 JDK1.5 이전버전에는 없었던 기능이기에 이들간의 호환을 위해서 컴파일이 끝나면 제네릭은 없어진다.
즉 컴파일된 파일(*.class)에서는 제네릭 타입에 대한 정보가 없다.
이 제네릭의 제거 과정은 꽤 복잡하다고 한다. 따라서 아래처럼 기본적인 내용만 살표보자.
1. 제네릭 타입의 경계(bound)를 제거한다.
class Box<T extends Fruit> {
void add(T t) {
...
}
}
// 제네릭 타입이 <T extends Fruit>라면 T는 Fruit으로 치환된다.
//<T>인 경우는 T는 Object로 치환된다. 그리고 클래스 옆의 제네릭 타입 선언은 제거된다.
class Box {
void add(Fruit t) {
...
}
}
2. 제네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가한다.
T get(int i) {
return list.get(i);
}
//List의 get()은 Object타입을 반환하므로 형볂놘이 필요하다.
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);
}