제네릭이란?
제네릭은 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크를 해주는 기능이다.
객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형 변환의 번거로움이 줄어든다.
즉 장점은,
- 타입 안정성을 제공
- 타입 체크와 형 변환을 생략할 수 있으므로 코드가 간결해짐
제네릭은 클래스와 메서드에 선언할 수 있는데, 먼저 클래스에 선언하는 제네릭 타입에 대해 알아보자.
먼저 아래와 같은 Box 클래스 코드가 있다.
class Box{
Object item;
void setItem(Object item) { this.item = item; }
Object getItem(){ return item; }
}
위 코드를 아래와 같이 수정할 수 있다. 클래스를 제네릭 클래스로 변경하려면 클래스 옆에 <T>를 붙이면 된다.
Object를 모두 T로 수정했다.
class Box<T>{
T item;
void setItem(T item) { this.item = item; }
T getItem(){ return item; }
}
Box <T>에서 T를 타입 변수라고 한다. 타입 변수는 T가 아닌 다른 것을 사용해도 된다. (ex: E, K, V)
이제 제네릭 클래스가 된 Box클래스의 객체를 생성할 때는 참조 변수와 생성자에 타입 T 대신에 사용될 실제 타입을 지정해주어야 한다.
Box<String> b = new Box<String>(); //타입 T 대신, 실제 타입을 저장
b.setItem(new Object()); //에러. String 이외의 타입은 지정불가
b.setItem("123"); //정상적으로 작동된다.
String item = b.getItem(); //캐스팅할 필요가 없다.
위 코드에서 T대신에 String을 대신 지정했으므로 T 자리에 전부 String이 들어간 것으로 이해할 수 있다.
즉 아래 코드와 동일하다.
class Box{
String item;
void setItem(String item) { this.item = item; }
String getItem(){ return item; }
}
제네릭 도입되기 이전의 코드와 호환을 위해,
제네릭 클래스인데도 예전 방식으로 객체를 생성해도 된다. 그러나 안전하지 않다는 경고는 뜬다.
꼭 제네릭 클래스를 사용하면 타입을 지정해 경고가 나오지 않게 주의하자.
제네릭의 용어
제네릭 용어를 간단히 정리해보자.
- class Box <T>에서
- Box <T> : 제네릭 클래스.
- T : 타입 변수 또는 타입 매개변수.
- Box : 원시 타입
- 또한 타입 매개변수에 타입을 지정하는 것을 제네릭 타입 호출이라고 한다.
- 지정된 타입을 매개 변수화된 타입이라고 한다. 이것을 대입된 타입이라고 사용하겠다.
제네릭스의 제한
제네릭 클래스는 객체를 생성할 때, 객체별로 다른 타입을 지정하는 것이 적절하다.
제네릭스는 인스턴스 별로 다르게 동작하려고 만든 기능이기 때문이다.
그러나 모든 객체에 대해 동일하게 동작해야 하는 static멤버에 타입 변수 T를 사용할 수 없다.
T는 인스턴스 변수로 간주되기 때문이다.
이전에 배운 것처럼 static 멤버는 인스턴스 변수를 참조할 수 없다.
static멤버뿐 아니라 제네릭 타입의 배열을 생성하는 것도 허용되지 않는다.
제네릭 배열 타입의 참조 변수를 선언하는 것은 가능하지만, 'new T[10]과 같이 배열을 생성하는 것은 안 된다.
T[] itemArr; //가능
T[] tmpArr = new T[itemArr.length]; //불가능
제네릭 배열을 생성할 수 없는 것은 new 연산자 때문인데, 이 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야 한다.
그런데 위의 코드에 정의된 Box<T> 클래스를 컴파일하는 시점에서는 T가 어떤 타입이 될지 전혀 알 수 없다.
instanceof 연산자도 new 연산자와 같은 이유로 T를 피연산자로 사용할 수 없다.
제네릭 클래스의 객체 생성과 사용
아래와 같이 객체를 사용할 수 있다.
Box<Apple> appleBox = new Box<Apple>(); //OK
Box<Apple> appleBox = new Box<Grape>(); //Error
//Fruit가 Apple의 부모 클래스
Box<Fruit> appleBox = new Box<Apple>(); //Error 무조건 같은 타입이어야함.
//Box가 FruitBox의 부모 클래스
Box<Fruit> appleBox = new FruitBox<Apple>(); // 다형성 사용으로 통과
Box<Apple> appleBox = new Box<>(); // JDK 1.7 부터 OK
제한된 제네릭 클래스
제네릭 타입에 'extends'를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다.
class FruitBox<T extends Fruit> { //Fruit의 자손만 타입으로 지정가능
ArrayList<T> list = new ArrayList<T>();
}
게다가 add()의 매개변수의 타입 T도 Fruit와 그 자손 타입이 될 수 있으므로, 아래와 같이 다양한 과일을 담을 수 있다.
FruitBox<Fruit> fruitBox = new FruitBox<>();
fruitBox.add(new Apple()); //통과. Apple이 Fruit의 자손
fruitBox.add(new Grape()); //통과. Grape가 Fruit의 자손
만일 클래스가 아니라 인터페이스를 구현해야 한다면, 이때도 'extends'를 사용한다. 'implements'가 아니다!
와일드 카드
static Juice makeJuice(FruitBox<Fruit> Box){
String tmp = "";
for( Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
static Juice makeJuice(FruitBox<Apple> Box){
String tmp = "";
for( Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
위와 같이 오버 로딩하면, 컴파일 에러가 발생한다. 제네릭 타입이 다른 것만으로는 오버 로딩이 성립하지 않기 때문이다.
제네릭 타입은 컴파일러가 컴파일할 때만 사용하고 제거해버린다. 그래서 위의 두 메서드는 오버 로딩이 아니라 '메서드 중복 정의'이다.
이럴 때 사용하기 위해 고안된 것이 바로 와일드카드다. 와일드카드는 기호 '?'로 표현하는데, 와일드카드는 어떠한 타입도 될 수 있다.
아래와 같이 설명할 수 있다.
- <? extends T > : 와일드카드의 상한 제한. T와 그 자손들만 가능
- <? super T > : 와일드 카드의 하한 제한. T와 그 조상들만 가능
- <?> : 제한 없음. 모든 타입이 가능 <? extends Object>와 동일
와일드카드를 사용해서 makeJuice()의 매개변수 타입을 FruitBox <Fruit>에서 Fruit <? extends Fruit>으로 바꾸면 다음과 같다.
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)
제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것이다.
같은 타입 문자 T를 사용해도 같은 것이 아니라는 것에 주의해야 한다.
static멤버에는 타입 매개변수를 사용할 수 없지만, 위 코드와 같이 static 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능하다.
메서드에 선언된 제네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 이해하기 쉽다.
이 타입 매개변수는 매서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관이 없다.
앞서 나왔던 makeJuice 메서드를 제네릭 메서드로 바꾸어보자.
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<Fruit>();
FruitBox<Apple> fruitBox = new FruitBox<Apple>();
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)); //이건 에러. 클래스 이름 생략 불가
System.out.println(this.<Fruit>makeJuice(fruitBox));
System.out.println(Juicer.<Fruit>makeJuice(fruitBox));
제네릭 메서드는 매개변수의 타입이 복잡할 때도 유용하다.
만일 아래와 같은 코드가 있다면 타입을 별도로 선언함으로써 코드를 간결하게 만들 수 있다.
public static void printAll(ArrayList <? extends Product> list,
ArrayList <? extends Product> list2){
for(Unit u : list){
System.out.println(u);
}
}
public static <T extends Product >void printAll(ArrayList<T> list,
ArrayList<T> list2){
for(Unit u : list){
System.out.println(u);
}
}
본인이 생각하기에 제네릭 메서드를 사용하는 이유는 제네릭 타입이 매개변수에 들어가면 코드가 지저분해지므로,
반환 타입 옆으로 타입 매개변수를 뺀 것이다.
참고자료
자바의 정석 (남궁성) - 12 Chapter : 제네릭스
댓글