다시 하자 기초! 지네릭스
지네릭스 (Generics)
JDK1.5에서 처음 도입된 지네릭스는 JDK1.8부터 도입된 람다식만큼 큰 변화였다. 그 당시만 해도 지네릭스는 선택적으로 사용하는 경우가 많았지만 이제는 지네릭스를 모르고는 Java API문서조차 제대로 보기 어려울 만큼 중요한 위치를 차지
지네릭스란?
다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크를 해주는 기능이다. 객체의 타입 안전성을 높이고 형변환의 번거로움이 줄어든다.
안정성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준다는 뜻이다.
예를 들어 ArrayList는 다양한 객체를 담을 수 있지만 보통 한 종류의 객체를 담는 경우가 많다. 그런데도 꺼낼 때 마다 타입 체크를 하고 형변환을 하는 것은 아무래도 불편하다. 게다가 원하지 않는 종류의 객체가 포함되는 것을 막을 방법이 없다는 것도 문제다. 이러한 문제를 해결해 주는 것이 지네릭스다.
지네릭스의 장점
1. 타입 안정성을 제공한다.
2. 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해진다.
지네릭스 클래스의 선언
class Box {
Object item;
void setItem(Object item) { this.item = item; }
Object getItem(){ return item; }
}
//지네릭스 선언
class Box<T> {
T item;
void setItem(T item) { this.item = item; }
T getItem(){ return item; }
}
Box<T>에서 T를 '타입변수'라고 하며, 'Type'의 첫 글자에서 따온 것이다. 타입 변수는 T가 아닌 다른 것을 사용해도 된다. ArrayList<E>의 경우, 타입 변수 E는 'Element(요소)'의 첫 글자를 따서 사용했다. 타입 변수가 여러 개인 경우에는 Map<K,V>와 같이 콤마를 구분자로 나열하면 된다. K는 Key를 의미하고 V는 값을 의미한다. 무조건 T를 사용하기보다 가능하면, 이처럼 상황에 맞게 의미있는 문자를 선택해서 사용하는 것이 좋다.
이들은 기호의 종류만 다를 뿐 '임의의 참조형 타입'을 의미한다는 것은 모두 같다.
이제 지네릭 클래스가 된 Box클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자에 타입 T대신에 사용될 실제 타입을 지정해주면 된다.
Box<String> b = new Box<String>(); //타입 T 대신, 실제 타입 지정
b.setItem(new Object()); //에러. String이외의 타입은 지정불가
b.setItem("ABC"); //OK. String타입이므로 가능
String item = b.getItem(); //형변환이 필요없음
지네릭이 도입되기 이전의 코드와 호환을 위해, 지네릭 클래스인데도 예전의 방식으로 객체를 생성하는 것이 허용된다. 다만 지네릭 타입을 지정하지 않아서 안전하지 않단느 경고가 발생한다.
Box b = new Box(); //ok. T는 Object로 간주된다.
b.setItem("ABC"); //경고. unchecked or unsafe operation
b.setItem(new Object()); //경고. unchecked or unsafe operation
//아래와 같이 타입 변수 T에 Object타입을 지정하면, 타입을 지정하지 않은 것이 아니라
//알고 적은 것이므로 경고가 발생하지 않는다.
Box<Object> b = new Box<Object>();
b.setItem("ABC"); //경고발생 안함
b.setItem(new Object()); //경고발생 안함
호환성을 위해 지네릭스를 사용하지 않은 코드를 허용하는 것일 뿐, 앞으로 지네릭 클래스를 사용할 때는 반드시 타입을 지정해서 지네릭스와 관련된 경고가 나오지 않도록 하자.
지네릭스 용어
class Box<T> { }
Box<T> 지네릭 클래스. 'T의 Box' 또는 'T Box'라고 읽는다.
T 타입 변수 또는 타입 매개변수.(T는 타입문자)
Box 원시 타입(raw type)
타입 문자 T는 타입 매개변수라고도 불리는데 그 이유가 매개변수와 유사한 면이 있기 때문이다. 그래서 아래의 밑줄 같이 타입 매개변수에 타입을 지정하는 것을 '지네릭 타입 호출'이라고 하고, 지정된 타입 'String'을 '매개변수화된 타입'이라고 한다. 근데 이름이 너무 길어서 '대입된 타입'이라고 줄여서 부른다.
Box<String> b = new Box<String>();
지네릭스의 제한
지네릭 클래스 Box의 객체를 생성할 때, 객체별로 다른 타입을 지정하는 것은 적절하다. 지네릭스는 이처럼 인스턴스별로 다르게 동작하도록 하려고 만든 기능이니까.
Box<Apple> appleBox = new Box<Apple>();
Box<tomato> grapeBox = new Box<Tomato>();
그러나 모든 객체에 대해 동일하게 동작해야하는 static멤버에 타입 변수 T를 사용할 수 없다. T 는 인스턴스변수로 간주되기 때문이다. 이미 알고있는 것처럼 static멤버는 인스턴스변수를 참조할 수 없다. 그리고 지네릭 타입의 배열을 생성하는 것도 허용되지 않는다. 지네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만, 'new T[10]' 같이 배열을 생성하는 것은 안된다는 뜻이다.
class Box<T>{
T[] itemArr; //OK. T타입의 배열을 위한 참조변수
T[] toArray() {
T[] tmpArr = new T[itemArr.length]; //에러. 지네릭 배열 생성 불가
...
return tmpArr;
}
...
}
지네릭 배열을 생성할 수 없는 것은 new 연산자 때문인데, 이 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야한다. 그런데 위 코드에 정의된 Box<T>클래스를 컴파일하는 시점에서는 T가 어떤 타입이 될지 전혀 알 수 없다. instanceof연산자도 이와 같은 이유로 T를 피연산자로 사용이 불가하다.
꼭 지네릭 배열을 생성해야 할 필요가 있을 때는, new 연산자 대신 Reflection API의 newInstance()와 같이 동적으로 객체를 생성하는 메서드로 배열을 생성하거나, Object배열을 생성해서 복사한 다음에 T[]로 형변환하는 방법을 사용하자.
지네릭 클래스의 객체 생성과 사용
두 지네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮다. FruitBox는 Box의 자손일 때
Box<Apple> appleBox = new FruitBox<Apple>(); //OK. 다형성
JDK1.7부터는 추정이 가능한 경우 타입을 생략할 수 있게 되었다. 아래 두 문장을 동일하다.
Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box(); //JDK1.7부터 생략가능
생성된 Box<T>의 객체에 void add(T item)으로 객체를 추가할 때, 대입된 타입과 다른 타입의 객체를 추가할 수 없다.
그러나 타입 T가 Fruit인 경우 void add(Fruit item)가 되므로 Fruit의 자손들은 이 메서드의 매개변수가 될 수 있다.
Box<Apple> appleBox = new Box<Apple>();
appleBox.add(new Apple());
appleBox.add(new Tomato()); //에러. Box<Apple>에는 Apple객체만 추가 가능
//Apple이 Fruit의 자손이라고 가정
Box<Fruit> appleBox = new Box();
appleBox.add(new Apple());
appleBox.add(new Fruit()); //OK. void add(Fruit item)
하지만 데이터를 꺼낼 땐 강제 형변환을 해서 꺼내줘야한다.
제한된 지네릭 클래스
타입 문자로 사용할 타입을 명시하면 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만, 그래도 여전히 모든 종류의 타입을 지정할 수 있다는 것에는 변함이 없다. 그렇다면 타입 매개변수 T에 지정할 수 있는 타입의 종류를 제한할 수 있는 방법은 없을까? 바로 지네릭 타입에 extends를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다.
class FruitBox<T extends Fruit> { //Fruit의 자손만 타입으로 지정가능
ArrayList<T> list = new ArrayList<T>();
...
}
여전히 한 종류의 타입만 담을 수 있지만, Fruit클래스의 자손들만 담을 수 있다는 제한이 더 추가된 것.
게다가 add()의 매개변수의 타입 T도 Fruit와 그 자손 타입이 될 수 있으므로, 아래와 같이 여러 과일을 담을 수 있는 상자가 가능하게 된다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
fruitBox.add(new Apple()); //ok. Apple가 Fruit의 자손
fruitBox.add(new Tomato()); //ok. Tomato가 Fruit의 자손
만일 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요하다면, 이때도 extends를 사용한다. implements를 사용하지 않는다는 점에 주목하자. 클래스 Fruit의 자손이면서 Eatable인터페이스도 구현해야한다면 아래와 같이 & 기호로 연결한다.
class FruitBox<T extends Fruit & Eatable> { ... }
와일드 카드
매개변수에 과일박스를 대입하면 주스를 만들어서 반환하는 Juicer라는 클래스가 있고, 이 클래스에는 과일을 주스로 만들어서 반환하는 makeJuice()라는 static메서드가 있다.
Juicer클래스는 지네릭 클래스가 아닌데다, 지네릭 클래스여도 static메서드에는 타입 매개변수 T를 매개변수에 사용할 수 없으므로 아예 지네릭스를 적용하지 않던가, 아래와 같이 특정 타입을 지정해줘야한다.
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();
FruitBox<Apple> appleBox = new FruitBox();
...
System.out.print(Juicer.makeJuice(fruitBox)); //OK. FruitBox<Fruit>
System.out.print(Juicer.makeJuice(appleBox)); //에러. FruitBox<Apple>
이렇게 지네릭 타입을 FruitBox<Fruit>로 고정해 놓으면 위의 코드에서 알 수 있듯이 FruitBox<Apple>타입의 객체는 makeJuice()의 매개변수가 될 수 없으므로, 여러가지 타입의 매개변수를 갖는 makeJuice()를 만들 수밖에 없다.
하지만 지네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않는다. 메서드 중복정의가 될뿐이다.
이럴 때 고안된 것이 바로 와일드 카드다. 기호 ?로 표현하는데 어떠한 타입도 될 수 있다.
?만으로는 Object타입과 다를 게 없으므로, 다음과 같이 extends와 super로 상한과 하한을 제한할 수 있다.
<? extends T> 와일드 카드의 상한 제한. T와 그 자손들만 가능
<? super T> 와일드 카드의 하한 제한. T와 그 조상들만 가능
<?> 제한 없음. 모든 타입이 가능. <? extends Object>와 동일
와일드 카드를 사용해서 makeJuice()의 매개변수 타입을 바꾸면 다음과 같다.
FruitBox<Fruit> → FruitBox <? extends Fruit>
이제 FruitBox<Fruit>뿐만 아니라 FruitBox<Apple>과 FruitBox<Tomato>까지도 가능하게 된다.
대신, 전과 달리 box의 요소가 Fruit의 자손이라는 보장이 없으므로 for문에서 box에 저장된 요소를 Fruit타입의 참조변수로 못받는다.
지네릭 메서드
메서드의 선언부에 지네릭 타입이 선언된 메서드를 지네릭 메서드라 한다. 앞서 살펴본 것처럼, Collections.sort()가 바로 지네릭 메서드이며, 지네릭 타입의 선언 위치는 반환 타입 바로앞이다.
static <T> void sort(List<T> list, Comparator<? super T> c)
지네릭 클래스에 정의된 타입 매개변수와 지네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것이다. 같은 타입 문자 T를 사용해도 같은 것이 아니라는 것에 주의해야 한다.
class FruitBox<T> {
...
static <T> void sort(List<T> list, Comparator<? super T> c) {
...
}
}
위 코드처럼 지네릭 클래스 FruitBox에 선언된 타입 매개변수 T와 지네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것이다. 그리고 sort()가 static메서드라는 점에 주목하자. 앞서 설명한 것처럼 static멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 지네릭 타입을 선언하고 사용하는 것은 가능하다. 메서드에 선언된 지네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 이해하기 쉬운데, 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관없다.
앞서 했떤 makeJuice()를 지네릭 메서드로 바꾸면 다음과 같다.
static Juice makeJuice(FruitBox<Fruit> box) { //<Fruit>으로 지정
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);
}
지네릭 타입의 형변환
지네릭 타입과 원시 타입 간의 형변환이 가능할까? 가능하다.
Box box = null;
Box<Object> objBox = null;
box = (Box)objBox; //Ok. 지네릭 타입 -> 원시 타입. 경고 발생
objBox = (Box<Object>)box; //Ok. 원시 타입 ->지네릭 타입. 경고 발생
그렇다면 대입된 타입이 다른 지네릭 타입 간에는 형변환이 가능할까? 불가능하다.
Box<String> strBox = null;
Box<Object> objBox = null;
strBox = (Box<String>)objBox; //에러.
objBox = (Box<Object>)strBox; //에러.
하지만 아래와 같은 상속간에는 가능하다.
Box<String>와 Box<? extends Object>
Box<? extends Object> wBox = new Box<String>();
지네릭 타입의 제거
지네릭 타입 제거를 하는 이유는 이전 소스와 호환성을 유지하기 위해서다. JDK1.5부터 도입되었지만, 아직도 원시 타입을 사용해서 코드를 작성하는 것을 허용한다. 그러나 앞으로 가능하면 원시 타입을 사용하지 않도록 하자. 언젠가는 분명히 새로운 기능을 위해 하위 호환성을 포기하게 될 때가 올 것이기 때문이다.
1. 지네릭 타입의 경계(bound)를 제거한다.
지네릭 타입이 <T extends Fruit>라면 T는 Fruit으로 치환된다. <T>인 경우는 Object로 그리고 클래스 옆의 선언은 제거.
2. 지네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가한다.
List의 get()은 Object타입을 반환하므로 형변환이 필요하다. 와일드카드가 포함되어 있는 경우에는 다음과 같이 적절한 타입으로의 형변환이 추가된다.