이 글은 자바의 정석 3판을 완독한 후 작성한 내용입니다.
학습 과정에서 자주 접하게 된 지네릭스(Generics)가 헷갈려 정리할 필요성을 느꼈습니다. 훗날 제가 복습할 때나 처음 지네릭스를 접하는 분들에게 작은 도움이 되길 바랍니다.
다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크(compile-time type check)를 해주는 기능이다.
객체의 타입을 컴파일 시에 체크하기 때문에 타입 안정성이 높아지고, 불필요한 형변환이 줄어든다.
쉽게 말해서, 다룰 객체의 타입을 미리 명시해줌으로써 번거로운 형변환을 줄여준다.
-> 여기서 핵심은, 지네릭스의 타입 정보는 “컴파일 시점에만” 쓰이고, 런타임에는 사라진다(type erasure)라는 점이다.
컴파일 시점: 문법·타입을 검사하는 단계 (잘못된 타입 사용 시 에러 발생)
런타임 시점: 프로그램이 실제 실행되는 단계 (이때는 타입 정보가 지워져 Box<String>과 Box<Integer>가 같은 클래스 취급됨)
- 타입 안정성을 제공한다.
- 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해진다.
지네릭스는 클래스와 메서드에 선언할 수 있는데 먼저 클래스에 선언하는 지네릭 타입에 대해서 설명하겠다.
먼저 지네릭스를 사용하지 않은 클래스 예시:
class Box {
Object item;
void setItem(Object item) { this.item = item; }
Object getItem() { return item; }
}
Object 대신 타입 변수 T를 사용하면, 클래스 옆에 <T>를 붙인다.
class Box<T> {
T item;
void setItem(T item) { this.item = item; }
T getItem() { return item; }
}
타입 변수는 꼭 T일 필요는 없다. 의미에 맞는 문자로 자유롭게 사용 가능하다.
Box<T> : T(Type)ArrayList<E> : E(Element)Map<K, V> : K(Key), V(Value)여러 개를 쓰려면 콤마(,) 로 구분한다.
Box<String> b = new Box<String>(); // 실제 타입 지정
b.setItem(new Object()); // Error
b.setItem("ABC"); // OK
String item = (String) b.getItem(); // 왼쪽에 보이는 (String)을 안적어도됨. 형변환 필요없음
타입이 지정되면, 마치 Box<String>이 아래와 같이 정의된 것처럼 동작한다:
class Box<String> {
String item;
void setItem(String item) { this.item = item; }
String getItem() { return item; }
}
Box b = new Box();처럼 타입을 지정하지 않으면 경고 발생
→ 반드시 타입을 명시하는 습관을 들이자.
아래와 같이 지네릭 클래스가 선언 되었을 때를 예시로 설명하겠다.
class Box<T> {}
Box<T> : 지네릭 클래스. 'T'의 'Box'또는 'T Box'라고 읽는다.T는 인스턴스 변수처럼 동작하므로 static 멤버에는 쓸 수 없다.
class Box<T> {
static T item; // 에러
static int compare(T t1, T t2) {...} // 에러
}
참조 변수 선언은 가능하지만, new T[]는 불가능하다.
class Box<T> {
T[] itemArr; // OK T타입의 배열을 위한 참조변수
...
T[] toArray() {
T[] tmpArr = new T[itemArr.length]; // Error 지네릭 배열 생성 불가
...
return tmpArr;
}
...
}
new 연산자는 컴파일 시점에 타입을 알아야 배열을 만들 수 있다.
하지만 지네릭 클래스는 컴파일할 때 T가 어떤 타입인지 알 수 없으므로 허용되지 않는다.
Box<T> 객체를 생성할 때는 참조 변수와 생성자에 지정된 타입이 일치해야 한다.
일치하지 않으면 컴파일 에러가 발생한다.
Box<Apple> appleBox = new Box<Apple>(); // OK
Box<Apple> appleBox = new Box<Grape>(); // Error
Apple이 Fruit의 자손이라고 가정
Box<Fruit> appleBox = new Box<Apple>(); // Error 대입된 타입이 다름.
클래스 간 상속 + 대입된 타입 일치 → OK
FruitBox는 Box의 자손이라고 가정
Box<Apple> appleBox = new FruitBox<Apple>(); // OK 다형성
컴파일러가 타입을 추론할 수 있으면 생성자 쪽 타입 생략 가능
Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box();
생성된 Box<T> 객체에 void add(T item)을 호출할 때,
대입된 타입과 다른 객체는 추가 불가
Box<Apple> appleBox = new Box<Apple>();
appleBox.add(new Apple()); // OK
appleBox.add(new Grape()); // Error
타입 매개변수 T가 Fruit이면 → void add(Fruit item)
즉, Fruit와 그 자손들(Apple 등) 추가 가능
Apple이 Fruit의 자손이라고 가정한다.
Box<Fruit> appleBox = new Box<Fruit>();
appleBox.add(new Fruit()); // OK
appleBox.add(new Apple()); // OK
타입 변수 T는 기본적으로 어떤 타입이든 대입 가능하다.
FruitBox<Toy> fruitBox = new FruitBox<Toy>();
fruitBox.add(new Toy()); // OK.
extends 키워드를 사용하면 특정 클래스의 자손만 타입으로 지정할 수 있다.
class FruitBox<T extends Fruit> {
ArrayList<T> list = new ArrayList<T>();
...
}
여전히 한 종류의 타입만 담을 수 있지만, Fruit클래스의 자손들만 담을 수 있다는 제한이 더 추가된 것이다.
FruitBox<Apple> appleBox = new FruitBox<Apple>(); // OK.
FruitBox<Toy> toBox = new FruitBox<Toy>(); // 에러. Toy는 Fruit의 자손이 아님
제한된 타입은 조상 타입 참조 변수에 자손 객체를 담는 것처럼 동작한다.
즉, Fruit를 상한 제한으로 지정하면, 그 자손 클래스들도 추가 가능하다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
fruitBox.add(new Apple()); // OK. Apple이 Fruit의 자손
fruitBox.add(new Grape()); // OK. Grape가 Fruit의 자손
다형성에서 조상타입의 참조변수로 자손타입의 객체를 가릴킬 수 있는 것처럼, 매개변수화된 타입의 자손 타입도 가능한 것이다.
클래스가 아니라 특정 인터페이스를 구현해야 한다는 제한도 가능하다.
(주의: implements가 아니라 extends를 사용해야 함)
interface Eatable {}
class FruitBox<T extends Eatable> { ... }
클래스와 인터페이스를 동시에 제한할 수도 있다.
(여러 개의 인터페이스는 & 기호로 연결한다.)
class FruitBox<T extends Fruit & Eatable> { ... }
이제 FruitBox에는 Fruit의 자손이면서 Eatable을 구현한 클래스만 타입 매개변수 T에 대입될 수 있다.
와일드 카드를 사용하면 좋은 문제 상황을 예들어 설명하겠다.
지네릭 메서드에서 매개변수를 FruitBox<Fruit>로 고정하면,
FruitBox<Apple> 같은 다른 타입은 받을 수 없음.
static Juice makeJuice(FruitBox<Fruit> box) { ... }
Juicer.makeJuice(new FruitBox<Fruit>()); // OK.
Juicer.makeJuice(new FruitBox<Apple>()); // Error.
해결하려고 makeJuice(FruitBox<Fruit>), makeJuice(FruitBox<Apple>) 등 오버로딩하면?
→ 컴파일 에러 (지네릭 타입 정보는 컴파일 시점에만 쓰이고 런타임에는 사라짐 → 중복 정의로 간주됨)
지네릭 타입을 유연하게 받기 위해 도입된 기호.
<? extends T> : 와일드 카드의 상한 제한. T와 그 자손들만 가능<? super T> : 와일드 카드의 하한 제한. T와 그 조상들만 가능<T> : 제한 없음. 모든 타입이 가능. <? extends Object>와 동일참고 : 지네릭 클래스와 달리 와일드 카드에는 '&'를 사용할 수 없다. 즉 <? extends T & E>와 같이 할 수 없다.
static Juice makeJuice(FruitBox<? extends Fruit> box) {
String tmp = "";
for (Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
FruitBox<Fruit> fruitBox = new FruitBox<>();
FruitBox<Apple> appleBox = new FruitBox<>();
Juicer.makeJuice(fruitBox); // OK.
Juicer.makeJuice(appleBox); // OK.
static <T> void sort(List<T> list, Comparator<? super T> c)
지네릭 클래스에 선언된 타입 매개변수와는 완전히 별개
static 메서드에도 사용 가능
메서드에 선언된 타입 매개변수는 지역 변수처럼 해당 메서드 내부에서만 유효
일반 메서드 :
static Juice makeJuice(FruitBox<? extends Fruit> box) {
String tmp = "";
for (Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
지네릭 메서드로 변경 :
static <T extends Fruit>Juice makeJuice(FruitBox<T> box) {
String tmp = "";
for (Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
FruitBox<Fruit> fruitBox = new FruitBox<>();
FruitBox<Apple> appleBox = new FruitBox<>();
// 타입 명시
System.out.println(Juicer.<Fruit>makeJuice(fruitBox));
System.out.println(Juicer.<Apple>makeJuice(appleBox));
// 대부분은 타입 추정 가능 (생략 가능)
System.out.println(Juicer.makeJuice(fruitBox));
System.out.println(Juicer.makeJuice(appleBox));
주의 : 타입을 명시해야 하는 경우에는 클래스/참조변수도 반드시 적어야 함
System.out.println(<Fruit>makeJuice(fruitBox)); // Error.
System.out.println(this.<Fruit>makeJuice(fruitBox)); // OK.
System.out.println(Juicer.<Fruit>makeJuice(fruitBox)); // OK.
Box box = null;
Box<Object> objBox = null;
box = (Box)objBox; // OK. (경고 발생)
objBox = (Box<Object>)box; // OK. (경고 발생)
Box<Object> objBox = null;
Box<String> strBox = null;
objBox = (Box<Object>)strBox; // Error.
strBox = (Box<String>)objBox; // Error.
Box<Object> objBox = new Box<String>(); // Error.
Box<String> → Box<? extends Object> 변환은 가능Box<? extends Object> wBox = new Box<String>(); // OK.
makeJuice() 메서드가 FruitBox<? extends Fruit>를 받도록 하면,
FruitBox<Fruit>, FruitBox<Apple>, FruitBox<Grape> 모두 전달 가능
static Juice makeJuice(FruitBox<? extends Fruit> box) { ... }
FruitBox<? extends Fruit> box1 = new FruitBox<Fruit>(); // OK.
FruitBox<? extends Fruit> box2 = new FruitBox<Apple>(); // OK.
FruitBox<? extends Fruit> box3 = new FruitBox<Grape>(); // OK.