https://github.com/whiteship/live-study
백기선님 자바 기초 스터디 14주차
목표
자바의 제네릭에 대해 학습하세요.
학습할 것 (필수)
- 제네릭 사용법
- 제네릭 주요 개념 (바운디드 타입, 와일드 카드)
- 제네릭 메소드 만들기
- Erasure
제네릭 사용법
제네릭
제네릭은 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크를 해주는 기능이다.
제네릭의 장점
- 타입의 안정성을 제공한다.
- 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해진다.
타입의 안정성이라는 것은 의도하지 않는 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준다는 뜻이다.
쉽게 말해서, 다룰 객체의 타입을 미리 명시해줌으로써 번거로운 형변환을 줄여준다 얘기다.
제네릭 클래스의 선언
제네릭 타입은 클래스와 메서드에 선언할 수 있는데, 먼저 클래스에 선언하는 제네릭 타입에 대해 알아보자.
class Box<T> {
T item;
void setItem(T item) {
this.item = item;
}
T getItem() {
return item;
}
}
Box<T>에서 T를 '타입 변수(type variable)'라고 하며, 'Type'의 첫 글자에서 따온 것이다. 타입 변수는 T가 아닌 다른 것을 사용해도 된다. ArrayList<E>의 경우, 타입 변수 E는 'Element'의 첫 글자를 따서 사용했고, Map<K, V>의 경우, 'Key, Value'를 의미한다.
또한, Box를 제네릭 클래스의 '원시 타입(raw type)'이라고 한다.
이제 제네릭 클래스가 된 Box클래스의 객체를 생성할 때는 다음과 같이 타입 변수에 실제 타입을 지정해주어야 한다.
Box<String> b = new Box<>(); // 타입 T 대신, 실제 타입을 지정
b.setItem(new Object()); // 에러. String이외의 타입은 지정불가
b.setItem("ABC"); // OK. String타입이므로 가능
String item = b.getItem(); // 형변환이 필요없음
제네릭 클래스는 타입만 잘 지정해주면 객체별로 다른 타입을 지정할 수 있다.
Box<String> b = new Box<String>();
Box<Integer> b = new Box<Integer>();
그러나 모든 객체에 대해 동일하게 동작해야 하는 static멤버에 타입 변수 T를 사용할 수 없다. static멤버는 대입된 타입의 종류와 관계없이 동일한 것이어야 하기 때문이다.
class Box<T> {
static T item; // 에러
}
그리고 제네릭 타입의 배열을 생성하는 것도 허용되지 않는다. 제네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만, 'new T[10]'과 같이 배열을 생성하는 것은 안 된다는 뜻이다.
class Box<T> {
T[] itemArr; // OK. T타입의 배열을 위한 참조변수
T[] toArray() {
T[] tmpArr = new T[itemArr.length];
return tmpArr;
}
}
new연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야 한다. 그런데 위의 코드는 컴파일 시점에서는 T가 어떤 타입이 될지 전혀 알 수 없다.
제네릭 클래스의 객체 생성과 사용
제네릭 클래스 Box<T>가 다음과 같이 정의되어 있다고 하자.
class Box<T> {
ArrayList<T> list = new ArrayList<T>();
void add(T item) {
list.add(item);
}
}
Box<T>의 객체를 생성할 때는 참조변수와 생성자에 대입된 타입이 일치해야 한다.
Box<Apple> appleBox = new Box<Apple>(); // OK
Box<Apple> appleBox = new Box<Grape>(); // 에러
설령 두 타입이 상속 관계에 있어도 마찬가지이다. Apple이 Fruit의 자손이라고 가정하자.
Box<Fruit> appleBox = new Box<Apple>(); // 에러. 대입된 타입이 다르다.
단, 두 제네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮다. FruitBox는 Box의 자손이라고 가정하자.
Box<Apple> appleBox = new FuitBox<Apple>(); // OK. 다형성
JDK 1.7부터는 추정이 가능한 경우 타입을 생략할 수 있게 되었다 참조변수의 타입으로부터 Box가 Apple타입의 객체만 저장한다는 것을 알 수 있기 때문이다. 따라서 아래의 두 문장은 동일하다.
Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box<>(); // OK. JDK 1.7부터 생략가능
'void add(T item)'으로 객체를 추가할 때, 대입된 타입과 다른 객체는 추가할 수 없다.
Box<Apple> appleBox = new Box<Apple>();
appleBox.add(new Apple()); // OK.
appleBox.add(new Grape()); // 에러. Box<Apple>에는 Apple객체만 추가가능
그러나 타입 T가 'Fruit'인 경우, 'void add(Fruit item)'이 되므로 Fruit의 자손들은 이 메서드의 매개변수가 될 수 있다. Apple이 Fruit의 자손이라고 가정하자.
Box<Fruit> appleBox = new Box<Fruit>();
appleBox.add(new Fruit()); // OK.
appleBox.add(new Apple()); // OK. void add(Fruit item)
제네릭 주요 개념 (바운디드 타입, 와일드 카드)
바운디드 타입
타입 변수 T에 지정할 수 있는 타입의 종류를 제한할 수 있는 방법은 없을까?
다음과 같이 제네릭 타입에 'extends'를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다.
class FruitBox<T extends Fruit> {}
만약 클래스가 아니라 인터페이스를 구현한 클래스만 대입하게 하고 싶다면, 이때도 'extends'를 사용한다. 'implements'를 사용하지 않는 다는 점에 주의하자.
interface Eatable {}
class FruitBox<T extends Eatable> {}
클래스가 Fruit의 자손이면서 Eatable인터페이스를 구현한 클래스만 대입하게 하고 싶다면, 아래와 같이 '&'기호로 연결한다.
class FruitBox<T extends Fruit & Eatable> {}
와일드 카드
과일을 주스로 만들어서 반환하는 기능을 가지는 Juicer클래스가 있다고 하자.
class Juicer {
static Juice makeJuice(FruitBox<Fruit> box) { // <Fruit>으로 지정
String tmp = "";
for (Fruit f : box.getList()) {
tmp += f + " ";
}
return new Juice(tmp);
}
}
Juicer클래스는 제네릭 클래스가 아닌데다, 제네릭 클래스라고 해도 static메서드에는 타입 변수 T를 매개변수에 사용할 수 없다.
따라서, 위와 같이 타입 매개변수 대신, 특정 타입을 지정해줘야 한다.
이렇게 제네릭 타입을 'FuritBox<Fruit>'으로 고정해 놓으면, 'Fruit<Apple>'타입의 객체는 makeJuice()의 매개변수가 될 수 없으므로, 다음과 같이 여러 가지 타입의 매개변수를 갖는 makeJuice()를 만들 수 밖에 없다.
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);
}
그러나 위와 같이 오버로딩하면, 컴파일 에러가 발생한다. 제네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문이다. 제네릭 타입은 컴파일러가 컴파일 할 때만 사용하고 제거해버린다. 그래서 위의 두 메서드는 오버로딩이 아니라 메서드 중복 정의이다.
이럴 때 사용하기 위해 고안된 것이 바로 '와일드 카드'이다. 와일드 카드는 기호 '?'로 표현하는데, 와일드 카드는 어떠한 타입도 될 수 있다.
'?'만으로는 Object타입과 다를게 없으므로, 다음과 같이 'extends'와 'super'로 상한과 하한을 제한할 수 있다.
- <? extends T> 와일드 카드의 상한 제한. T와 그 자손들만 가능
- <? super T> 와일드 카드의 하한 제한. T와 그 조상들만 가능
- <?> 제한 없음. 모든 타입이 가능
와일드 카드를 사용해서 makeJuice()의 매개변수 타입을 지정해보자.
static Juice makeJuice(FruitBox<? extends Fruit> box) {
String tmp = "";
for (Fruit f : box.getList()) {
tmp += f + " ";
}
return new Juice(tmp);
}
이제 이 메서드의 매개변수로 FruitBox<Fruit>뿐만 아니라, Fruit<Apple>도 가능하게 된다.
제네릭 메소드 만들기
메서드의 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라 한다. 제네릭 타입의 선언 위치는 반환 타입 바로 앞이다.
class FruitBox<T> {
static <T> void sort(List<T> list, Comparator<? super T> c) {
...
}
}
제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것이다. 같은 타입 문자 T를 사용해도 같은 것이 아니라는 것에 주의해야 한다. 위 코드에서 제네릭 클래스 FruitBox에 선언된 타입 매개변수 T와 제네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것이다.
그리고 sort()가 static메서드라는 것에 주목하자. 앞서 설명한 것처럼, static멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능하다.
앞서 나왔던 makeJuice()를 제네릭 메서드로 바꾸면 다음과 같다.
static <T extends Fruit> Juice makeJuice(FruitBox<T> box) {
String tmp = "";
for (Fruit f : box.getList()) {
tmp += f + " ";
}
return new Juice(tmp);
}
이제 이 메서드를 호출할 대는 아래와 같이 타입 변수에 타입을 대입해야 한다. 그러나 대부분의 경우 컴파일러가 타입을 추정할 수 있기 대문에 생략해도 된다.
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));
한 가지 주의할 점은 같은 클래스 내에 있는 멤버들끼리는 'this'나 클래스이름을 생략하고 메서드 이름만으로 호출이 가능하지만, 대입된 타입이 있을 때는 반드시 써 줘야 한다.
System.out.println(<Fruit>makeJuice(fruitBox)); // 에러. 클래스 이름 생략불가
System.out.println(this.<Fruit>makeJuice(fruitBox)); // OK
System.out.println(Juicer.<Fruit>makeJuice(fruitBox)); // OK
Erasure
컴파일러는 제네릭 타입을 체크하고, 필요한 곳에 형변환을 넣어준다. 그리고 제네릭 타입을 제거한다. 그래서 컴파일된 파일(.class)에는 제네릭 타입에 대한 정보가 없는 것이다. 이렇게 하는 주된 이유는 제네릭이 도입되기 이전의 소스 코드와의 호환성을 유지하기 위해서이다.
제네릭 타입의 제거 과정을 간단하게 알아보자.
1. 제네릭 타입의 경계를 제거한다.
제네릭 타입이 <T extends Fruit>라면 T는 Fruit로 치환된다. <T>인 경우는 T는 Object로 치환된다. 그리고 클래스 옆의 제네릭 선언은 제거된다.
class Box<T extends Fruit> {
void add(T t) {
...
}
}
->
class Box {
void add(Fruit t) {
...
}
}
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();
for (it.hasNext()) {
tmp += (Fruit) it.next() + " ";
}
return new Juice(tmp);
}
'java > java' 카테고리의 다른 글
[Java] Mockito (0) | 2021.09.28 |
---|---|
[Java] JUnit 5 (0) | 2021.09.24 |
[Java] I/O (1) | 2021.09.08 |
[Java] 애노테이션 (1) | 2021.09.05 |
[Java] 다중 조건 정렬 (0) | 2021.08.29 |