지네릭스는 다양한 타입의 객체들을 다루는 메소드나 클래스에 컴파일 시 타입체크 (complie-time type check)를 해주는 기능이다.
컴파일 시 객체의 타입을 체크하기 때문에 객체 타입 안정성을 높이고 형변환의 번거로움을 줄일 수 있다.
지네릭스의 장점
1. 타입 안정성을 제공한다.
2. 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해진다.
class GItem<T> {
T gItem;
void setGItem(T gItem) { this.gItem = gItem; }
T getGItem() { return this.gItem; }
}
지네릭 클래스로의 변경은 간단하다. 클래스에 <T>를 붙여주면된다. 우리는 ‘T’를 ‘타입변수’라고 지칭한다.
다양한 곳에서 제네릭은 사용되고 있다. 다음은 흔히 사용되는 컬렉션 객체들이다.
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
...
}public interface Map<K,V> {
...
}두 사례만 놓고 봤을때, <E> <K,V> 가 사용되고있다. 알파벳이 다른 것은 문제되지않는다.
‘T’는 ‘Type’ ‘E’는 ‘Element’ ‘K’는 ‘Key’ ‘V’는 ‘Value’와 같은 약자일 뿐이다.
모두 ‘임의의 참조형 타입’을 뜻한다는 것은 변함이없다.
간단한 예제를 생성해보자.
class Box<T> {
ArrayList list = new ArrayList<>();
void add(T item) {list.add(item);}
T get(int index) {return (T) list.get(index);}
ArrayList<T> getList() {return list;}
int size() {return list.size();}
public String toString() {return list.toString();}
}
사실, ArrayList의 기능을 일부 발췌해 Box라고 명명했을뿐, ArrayList 기능적으로 같다.
Box라는 객체는 어떤 타입의 정보를 받을지는 모르겠지만,
런타임환경에서 그 타입을 체크하는 ‘타입변수’ <T> 를 선언했다.
우린 그때 그때 타입을 지정하여 Box란 클래스를 구성할 수 있게 된 것이다.
class Apple extends Fruit{
@Override
public String toString() {
return "A";
}
}
class Grape extends Fruit{
@Override
public String toString() {
return "G";
}
}
class Fruit {
public String toString() {return "F";}
}
다음과 같이 간단한 클래스를 3개를 생성해두고, Box<T> 제네릭 클래스를 활용해보자.
Box<Apple> box1 = new Box<>(); //ok
Box<Grape> box2 = new Box<>(); //ok
Box<Apple> box3 = new Box<Grape>(); // error. 당연한이야기지만, 한번에 하나의 타입허용.
box1.add(new Apple()); // ok
box2.add(new Grape()); // ok
box2.add(new Grape()); // error.
box3 같은 두가지 타입의 사용은 허용될 수 없다. (우리가 컬렉션을 사용할 경우도 마찬가지지 않은가. 단순하게 생각해보자.)box1 에는 Apple() 형식. box2 에는 Grape() 타입만 가능하다. (해당 타입만 가능)우리가 Box란 곳에는 과일만 담기로 가정했다고 해보자. 뜬금없이 과자가 들어가서는 안된다.
하지만 지금 형태로는 어떤 타입이든지 들어갈 수 있다.
타입을 한종류만 넣을 수 있게 제한하는 것만으로는 개발하는데 한계가 있다.
타입 종류를 지정할 수는 없을까?
Box<Toy> box4 = new Box<>();
Toy 형태의 객체도 Box에 넣을 수 있다.정답은 extends 에 있다. 타입변수 T 와 extends 를 사용하는 방법이다.
class Box<T extends Fruit> {
ArrayList list = new ArrayList<>();
void add(T item) {list.add(item);}
T get(int index) {return (T) list.get(index);}
ArrayList<T> getList() {return list;}
int size() {return list.size();}
public String toString() {return list.toString();}
}
Box<Apple> box1 = new Box<>(); //ok
Box<Grape> box2 = new Box<>(); //ok
Box<Toy> box3 = new Box<>(); // error. Fruit 자손들만 가능.
이제 T 타입의 객체는 Fruit 혹은 자손객체로 제한되었다.
만일 인터페이스를 통해 특정 기능을 강제해야할 때도, 제네릭 클래스에 extends 를 사용해야한다.
(인터페이스 구현을 뜻하는 implement 를 사용하지 않는 점이 특징이다.)
interface Boxing {}
class Box<T extends Boxing> {}
Fruit 로 타입변수를 제한하면서 인터페이스 구현이 필요하다면 & 를 활용한다.
class Box<T extends Fruit & Boxing> {}
다음과 같은 시나리오가 있다. Box엔 과일만 들어있고, 우린 이 과일들을 배송할 것이라고 가정해보자.
class Delivery {
static Truck going (Box<Fruit> box) {
String tmpList = "";
for(Fruit fruit : box.getList()) {
tmpList += fruit.toString() + " ";
}
return new Truck(tmpList);
}
// static Truck going (Box<Apple> box) {
// String tmpList = "";
// for(Fruit fruit : box.getList()) {
// tmpList += fruit.toString() + " ";
// }
// return new Truck(tmpList);
// }
}
위 코드에는 몇 가지 문제가 있다.
위와 같은 상황을 해결하기 위해 만들어진 것이 바로 와일드카드 ? 이다.
와일드카드는 어떤 형태도 될 수 있다.
<? extends T>와일드 카드의 상한 제한. T와 그 자손들만 가능
<? super T>와일드 카드의 하한 제한. T와 그 조상들만 가능
<?>제한없음. 모든 타입 가능 (<? extends Object>와 동일)
class Delivery {
static Truck going (Box<? extends Fruit> box) {
String tmpList = "";
for(Fruit fruit : box.getList()) {
tmpList += fruit.toString() + " ";
}
return new Truck(tmpList);
}
이로써 going() 메소드는 Fruit 의 자손들을 모두 가질 수 있게 되었다.
Box<Apple> box = new Box<>();
Box<Grape> box = new Box<>();
위 형태를 모두 받을 수 있는 것이다.
static Truck going (Box<?> box) {
String tmpList = "";
for(Fruit fruit : box.getList()) {
tmpList += fruit.toString() + " ";
}
return new Truck(tmpList);
}
위 형태도 당연히 가능하다. 모든 제약조건이 없는 형식을 받아들였기 때문이다.
하지만, 모든 제약조건이 없기때문에 반드시 특정 형태의 객체가 들어온다는 보장이 없어 오류의 가능성이 있다.
(물론 위 코드에선 Box 자체를 <T extends Fruit> 로 지정했기에 이미 필터링이 가능한 상황이다.)
지네릭 메서드도 활용방법은 같다. 단지 선언위치만 다르다.
T get(int index) {return (T) list.get(index);}
위와 같은 형태가 바로 지네릭 메서드 형태이다.
static Truck going (Box<? extends Fruit> box) {
String tmpList = "";
for(Fruit fruit : box.getList()) {
tmpList += fruit.toString() + " ";
}
return new Truck(tmpList);
}
static <T extends Fruit> Truck going (Box<T> box) {
String tmpList = "";
for(Fruit fruit : box.getList()) {
tmpList += fruit.toString() + " ";
}
return new Truck(tmpList);
}
두 코드에서 보듯 이런식으로 제네릭 메소드로 변환할 수도 있다.
Delivery.<Fruit>going(box3);
단, 해당클래스 명시는 생략할 수 없다.
결론을 말하면, 일반적인 형태에서 형변환은 불가하다. 하지만 와일드카드를 활용한다면 가능하다.
Box<Object> objBox = new Box<>();
Box<String> strBox = new Box<>();
//error. Box<Object> objBox = new Box<String>(); 과 같은 논리
objBox = (Box<Object>) strBox;
와일드 카드를 활용하면 조금 다른 결과를 도출할 수 있다.
Box<? extends Object> objBox = new Box<String>();
<? extends Object> 를 활용해 Object 의 자손은 추가될 수 있는 형태이기 때문에 String 형태의 객체는 당연히 형변환이 가능하다.
Box<? extends Fruit> fruitBox = new Box<Fruit>();
Box<? extends Fruit> appleBox = new Box<Apple>();
Box<? extends Fruit> grapeBox = new Box<Grape>();
Fruit 의 자손 객체는 모두 사용가능하다.
사실 제네릭스를 처음 봤을땐 굉장히 어려워보이고, 생소할 것이다.
하지만 반드시 이해해야할 지식이란 것은 확실하다. 자바공식API문서를 참고해본 사람은 알겠지만, 대부분 문서들이 제네릭스를 제외하면 이해하기 힘든 자료들이 대부분이다. 그렇기 때문에 우린 반드시 기본적으로 제네릭스를 이해하고 갈 필요가 있다.
뿐만 아니라, 새로 시스템을 구성&설계할 경우 제네릭스를 통해 더욱 효과적이고 범용성이 좋은 코드를 생성할 수도 있다. 따라서 정확한 이해도만 있다면 더욱 효과적인 개발을 이어나갈 수 있을 것이다.
참고자료
자바의정석