JDK 1.5 이후 도입된 제네릭의 개념은 최근에는 사용하지 않으면 이상할 정도로 많이 쓰이는 기능이다.
클래스나 필드 옆에 <>로 붙어서 선언을 하는 데 1회차 공부때는 이걸 굳이 왜하지? 라는 생각이 들었다.
모든 타입을 다 사용하면 더 좋은거 아닌가 라고 생각하였지만, 형변환의 소요를 줄일 수 있다는 큰 장점이 있어 제네릭은 사용하지 않을 수 없는 기능이다.
제네릭의 가장 큰 2가지 장점이다. 의도하지 않는 타입이 저장되는 것을 방지할 수 있기 때문에 타입의 안정성을 제공하고, 앞에서 언급했듯이 형변환을 생략할 수 있어 코드를 좀 더 간결하게 만들 수 있다.
다음 예시 클래스를 보자.
class Box{
Object item;
void setItem(Object item){
this.item = item;
}
Object getItem(){
return item;
}
}
이 Box 클래스는 제네릭을 설명할 때 가장 많이 인용되는 예시이다.
인스턴스 변수로 item을 Object 타입으로 선언하고 값을 대입해주는 set, 반환하는 get 메서드 모두 Object 타입으로 설정하였다.
그런데 만약, 나는 item과 관련된 타입은 전부 String으로 선언하고 싶다면 어떻게 해야할까?
class Box{
String item;
void setItem(String item){
this.item = item;
}
String getItem(){
return item;
}
}
그냥 관련된 모든 타입을 변경해주면 된다. 하지만, 이 코드가 몇백 줄이 넘는 코드라면? 그리고 Integer 타입으로만 선언해야 하는 요청이 들어온다면?
시간을 들여서 일일히 바꾸면 되겠지만 비효율적이기도 하고 사람이라면 수정하다가 실수가 발생할 수 있다.
다시 본론으로 돌아와서, Box 클래스를 다음과 같이 선언해보자.
class Box<T>{
T item;
void setItem(T item){
this.item = item;
}
T getItem(){
return item;
}
} //여기서 T는 Type의 약자로 E나 F 등 다른 글자를 사용해도 상관없다
모든 타입을 T로 바꾸고 클래스명 옆에 를 추가하였다.
그리고 Box 클래스의 객체를 다음과 같이 생성해보겠다.
Box<String> stringBox = new Box<>();
T를 String으로 넣었기 때문에 Box< T > 클래스에 있는 T 타입은 전부 String으로 바뀌는 것이다.
class Box<String>{
String item;
void setItem(String item){
this.item = item;
}
String getItem(){
return item;
}
}
Box<String> stringBox = new Box<>();
stringBox.setItem(123); //불가능
타입을 String으로 제한했기 때문에 set 메서드의 파라미터로 String 타입이 아닌 객체가 들어갈 수 없는 것이다.
그렇다면 Box< T >는 인스턴스 생성 시 반드시 타입을 지정해주어야 할까?
다른 기능들과 마찬가지로 호환성이라는 문제 때문에 자바에서는 이러한 경우에 타입을 Object로 자동 설정한다.
Box box = new Box();
//Box<Object> box = new Box<>(); //T는 Object로 간주된다
변수나 메서드 앞에 T 타입으로 선언하였지만, static 멤버의 경우 T 타입 선언이 제한된다.
static 멤버는 모든 객체에 대하여 동일하게 작동해야 하지만 타입 변수 T로 선언해버리면 의미가 없기 때문이다.
static T Item; // 불가능
static T getItem(){
return item;
} //불가능
static 멤버의 경우 "클래스명.static멤버명"으로 호출을 하며 이는 인스턴스 생성이 불필요하다.
하지만, 위의 예시처럼 반환 타입을 T로 선언해버리면 T를 설정한 인스턴스 생성이 불가피하기 때문에 컴파일 에러가 발생하는 것이다.
배열의 경우도 마찬가지이다.
T[] arrayT = new String[]; // 가능
//T[] arrayT = new T[]; //불가능
첫 번째의 경우는 가능하다. 하지만 두 번째 경우는 불가능하다. 왜일까?
new 연산자의 특성 상 컴파일 시점에서 new 연산자의 타입을 알아야 한다. 하지만, T로 선언해버리면 컴파일 시점에 무슨 타입인지 알 수 없기 때문에 컴파일 에러가 발생하는 것이다.
제네릭 클래스 Box< T >를 사용하여 객체를 생성해보자.
Box 중 과일을 담는 appleBox, orangeBox를 만들어보겠다.
public class Main {
public static void main(String[] args) {
Box<Apple> appleBoxBox = new Box<>();
Box<Orange> orangeBox = new Box<>();
}
}
class Box<T>{
T item;
void setItem(T item){
this.item = item;
}
T getItem(){
return item;
}
}
class Apple{}
class Orange{}
appleBox는 Apple 타입만 저장가능하고, orangeBox는 Orange 타입만 저장가능하다.
확실히 제네릭을 사용하여서 특정 타입만 다루는 클래스 생성에 있어 코드 소요가 줄어들었다. 하지만, 과일 종류만 담는 fruitBox를 만들고 싶다면 어떻게 해야할까?
T 타입을 Fruit로 지정해버리면 다른 과일이 못들어오게 된다.
이때 사용해야 하는 것이 상속이다.
Apple과 Orange가 Fruit 범주에 속하기 때문에 Fruit를 우선 상속받는다.
그 후 제네릭 타입을 다음과 같이 변경한다.
public class Main {
public static void main(String[] args) {
Box<Fruit> fruitBox = new Box<>();
Apple apple = new Apple();
Orange orange = new Orange();
fruitBox.setItem(apple);
fruitBox.setItem(orange);
}
}
class Box<T>{
T item;
void setItem(T item){
this.item = item;
}
T getItem(){
return item;
}
}
class Fruit{}
class Apple extends Fruit{}
class Orange extends Fruit{}
Fruit의 자손 클래스로 Apple과 Orange가 있기 때문에 제네릭 타입을 Fruit로 설정해두면 그 자손 타입의 객체도 해당이 된다.
물론, 다형성에서 배웠겠지만 반대의 경우는 불가능하다.
(자손 클래스로 제네릭 타입 설정 후 조상 타입의 객체를 사용하는 것)
한 가지 주의할 점은 참조변수의 제네릭 타입과 new 연산자의 제네릭 타입은 동일해야 한다. 즉, 자손 클래스 타입이라 할지라도 참조변수와 연산자의 타입이 다르면 안된다는 것이다.
Box<Fruit> box = new Box<Apple>(); // 불가능
제네릭 클래스에 선언되는 타입의 제한을 두고 싶을 경우 어떻게 해야할까?
특정 클래스의 자손 클래스 타입만 제네릭 타입으로 사용할 수 있는 경우를 예로 들어보자.
이때 사용하는 키워드가 "T extends 조상 클래스명"이다.
위의 과일 상자를 예시로 만들어보겠다.
public class Main {
public static void main(String[] args) {
FruitBox<Fruit> fruitBox = new FruitBox<>();
FruitBox<Apple> appleBox = new FruitBox<>();
FruitBox<Orange> orangeBox = new FruitBox<>();
//FruitBox<Object> objectBox = new FruitBox();
}
}
class Box<T>{
T item;
void setItem(T item){
this.item = item;
}
T getItem(){
return item;
}
}
class FruitBox<T extends Fruit> extends Box<T>{}
class Fruit{}
class Apple extends Fruit{}
class Orange extends Fruit{}
FruitBox는 Box< T >를 상속받기 때문에 모든 멤버에 접근이 가능하다. FruitBox에 들어갈 수 있는 제네릭 타입을 Fruit를 상속받는 클래스인 T로 제한을 두었기 때문에 Fruit 클래스의 자손이 아닌 Object는 들어갈 수 없는 것이다.
특정 인터페이스를 구현한 클래스들만 제네릭 타입으로 지정할 수 있다.
인터페이스를 구현하는 것이기 때문에 implements 키워드를 사용할 것이라고 생각할 수 있으나, 이때도 마찬가지로 extends 키워드를 사용한다.
public class Main {
public static void main(String[] args) {
FruitBox<Fruit> fruitBox = new FruitBox<>();
FruitBox<Apple> appleBox = new FruitBox<>();
FruitBox<Orange> orangeBox = new FruitBox<>();
FruitBox<Machine> machineBox = new FruitBox<>();
}
}
class Box<T>{
T item;
void setItem(T item){
this.item = item;
}
T getItem(){
return item;
}
}
class FruitBox<T extends Eatable> extends Box<T>{}
class Fruit implements Eatable{}
class Apple extends Fruit{}
class Orange extends Fruit{}
class Machine implements Eatable{}
interface Eatable{}
타입의 제한을 Eatable 인터페이스를 구현한 클래스로 설정하였기 때문에 Machine 클래스는 Fruit 클래스의 자손이 아니지만 FruitBox의 타입으로 들어갈 수 있는 것이다.
Apple, Orange 클래스는 Eatable 인터페이스를 구현한 Fruit 클래스를 상속받기 때문에 동일하게 Eatable을 구현한다.
static 멤버는 모든 객체에 대하여 동일하게 작동해야 하기 때문에 T 타입 변수 사용이 제한된다고 언급했었다.
만약, 과일 클래스만 제네릭 타입으로 하는 FruitBox 클래스 타입의 객체를 파라미터로 가지는 static 메서드를 정의하려면 어떻게 해야할까?
static void example(FruitBox<Fruit> Box){}
static void example(FruitBox<Apple> Box){}
static void example(FruitBox<Orange> Box){}
제네릭 타입이 다르니까 메서드 오버로딩이 가능하겠지? 라고 생각할 수 있다.
하지만 제네릭 타입이 다른 것만으로는 오버로딩이 성립되지 않아 위의 예시는 컴파일 에러가 발생한다.
그럼 방법이 정말 없을까?
와일드 카드라는 기능을 사용하면 해결할 수 있다.
- < ? extends T > : T와 그 자손들만 가능(상한 제한)
- < ? super T > : T와 그 조상들만 가능(하한 제한)
- < ? > : 제한없이 모든 타입 가능( <? extends Object> 와 같음)
위의 예제에 적용해보자.
static void example(FruitBox<? extends Fruit> Box){}
메서드 중복 정의의 문제도 발생하지 않고 static 멤버의 제네릭 제한도 발생하지 않는다.
다음은 와일드 카드 기능을 사용한 주스 제작하는 예제이다.
public class Main {
public static void main(String[] args) {
Apple apple = new Apple();
FruitBox<Fruit> fruitBox = new FruitBox<>();
fruitBox.addItem(apple);
Juice juice = Juicer.juiceMaker(fruitBox);
juice.printRecipe();
}
}
class FruitBox<T extends Fruit> extends Box<T>{}
class Fruit{}
class Apple extends Fruit{
@Override
public String toString() {
return "Apple";
}
}
class Orange extends Fruit{
@Override
public String toString() {
return "Orange";
}
}
class Juice{
String name;
Juice(String name){
this.name = name;
}
void printRecipe(){
System.out.println("이 주스는 " + this.name + "으로 만들어졌습니다.");
}
}
class Juicer{
static Juice juiceMaker(FruitBox<? extends Object> box){
String tmp = "";
for(Fruit f : box.getList()){
tmp += f + " ";
}
return new Juice(tmp);
}
}
class Box<T>{
ArrayList<T> list = new ArrayList<>();
void addItem(T item){
list.add(item);
}
T getItem(int i){
return list.get(i);
}
ArrayList<T> getList(){
return this.list;
}
}
여기서 한 가지 짚고 넘어가야 할 점이 있다.
static 메서드인 juiceMaker에서 파라미터로
FruitBox<? extends Object> Box 를 사용했는데 바로 아래 반복문에서 어떻게 Fruit를 타입으로 사용가능한 것일까?
Object와 그 자손으로 타입을 지정한다면 어떤 타입이 올지 모르는데 변수 타입을 Fruit로 하는 것이 가능할까?
이유는 바로 처음에 FruitBox 클래스를 선언할 때 타입 제한을 T extends Fruit로 하였기 컴파일러는 FruitBox의 요소들이 Fruit와 그 자손 클래스임을 알고 있어 컴파일 에러가 발생하지 않는 것이다.
반대로 만약, static 메서드의 파라미터 타입을
static Juice juiceMaker(FruitBox<? extends Apple> box)
이렇게 했다면 저 메서드를 호출할 경우 사용가능한 타입은 Apple과 그 자손클래스 뿐일 것이다.
제네릭 클래스는 제네릭 타입을 선언한 클래스라고 할 수 있다. 그렇다면 제네릭 타입을 메서드 선언부에 선언한 메서드는 제네릭 메서드라고 할 수 있다.
class FruitBox<T>{
static <T> void setter(List<T> list){}
}
위의 예시에서 클래스와 메서드 모두 T 타입 변수가 제네릭 타입으로 선언되어있다. 하지만, 클래스에 선언되어 있는 T와 메서드에 선언되어 있는 T는 별개로 구분해야 한다.
FruitBox<Apple> fruitBox = new FruitBox<>();
이렇게 클래스 타입을 정해도 메서드와는 무관하다는 것이다.
앞에서 static 멤버에는 타입 매개변수 사용이 불가능하다고 했지만, 메서드의 경우 선언부에 제네릭 타입으로 사용하는 것은 가능하다.
대표적인 예로 Collections.sort() 메서드도 static 메서드이지만 제네릭 타입을 선언하고 사용한다.
와일드 카드에서 다룬 juiceMaker 라는 메서드를 다시 한 번 봐보자.
static Juice juiceMaker(FruitBox<? extends Object> box){
String tmp = "";
for(Fruit f : box.getList()){
tmp += f + " ";
}
return new Juice(tmp);
}
여기서는 메서드에 제네릭 타입을 따로 선언을 하지 않았지만, 제네릭 타입을 사용한다면 코드를 좀 더 간결하게 바꿀 수 있다.
static <T extends Object> Juice juiceMaker(FruitBox<T> box){
String tmp = "";
for(Fruit f : box.getList()){
tmp += f + " ";
}
return new Juice(tmp);
}
선언부에 제네릭 타입의 제한을 두고 매개변수에는 T만 타입변수로 두어 좀 더 간결하게 바뀌었다.
매개변수가 여러 개인 경우 이 차이가 더 드러난다.
static void set(List<? extends Object> list1, List<? extends Object> list2, Map<? extends Object> map1){}
static void<T extends Object> set(List<T> list1, List<T> list2, Map<T> map1){}
제네릭 메서드는 호출 간에 반드시 타입 변수에 타입을 대입해야 한다.
또, 원래는 같은 클래스 내에 있는 멤버들끼리는 메서드 이름만으로 호출이 가능하나 제네릭 메서드는 예외적으로 클래스명이나 this를 붙여주어야 한다.
다른 이유가 있는 건 아니고 기술적인 규칙이기 때문에 지켜야 한다.
Juicer.<Fruit> JuiceMaker(fruitBox);
this.<Fruit> JuiceMaker(fruitBox);
다음 예시를 보자.
Box box = new Box();
Box<String> stringBox = new Box<>();
Box<Integer> integerBox = new Box<>();
box = (Box)stringBox;
stringBox = (Box<String>)box;
//integerBox = (Box<Integer>)stringBox;
첫 번째 인스턴스는 원시타입, 두 번째는 String 타입의 제네릭 인스턴스, 마지막은 Integer 타입의 제네릭 인스턴스이다.
원시타입과 제네릭 타입 간의 형변환에서는 컴파일 에러가 발생하지 않고 경고만 발생한다.
하지만, 다른 제네릭 타입 간의 형변환은 불가능하다.
제네릭 타입 간 형변환을 하려면 와일드 카드가 포함된 제네릭 타입으로 형변환을 거쳐야 한다.
Box box = new Box();
Box<String> stringBox = new Box<>();
Box<?> wildBox = new Box<>();
//Box<? extends Object> wildBox = new Box<Object>(); 와 동일
stringBox = (Box<String>) wildBox;
우선 와일드 카드 ? 를 제네릭 타입으로 가지는 참조변수는 "? extends Object" 와 동일하다.
Object를 포함한 그 자손 클래스이기 때문에 사실상 제한이 없는 것이다. 하지만, 컴파일 시 미확인 타입의 객체는 생성이 불가능하기 때문에 new 연산자에 들어가는 제네릭 타입은 ? 가 될 수 없다.
예시에는 new 연산자의 제네릭 타입이 생략되어 있지만 사실 Object 타입이 들어가있다.
? 타입의 클래스는 String 타입의 클래스로 형변환이 가능하다. 미확정 타입으로 경고가 발생하지만 컴파일 에러는 발생하지 않는다.
반대의 경우도 마찬가지이다.
즉, 제네릭 타입과 와일드 카드를 가진 타입 간의 형변환에서는 컴파일 에러가 발생하지 않는 것이다.
그렇다면 String 타입에서 Integer 타입으로 형변환은 어떻게 해야 할까?
위에서 언급했듯이 String -> ? -> Integer 순으로 형변환은 컴파일 에러가 발생하지 않는다.
Box box = new Box();
Box<String> stringBox = new Box<>();
Box<Integer> integerBox = new Box<>();
Box<?> wildBox = new Box<>();
wildBox = (Box<?>) stringBox; //String -> ? 형변환
integerBox = (Box<Integer>) wildBox; //? -> Integer 형변환
public class Main {
public static void main(String[] args) {
new Collection<>(0).print();
}
public static class Collection<T>{
T value;
public Collection(T t) {
this.value = t;
}
public void print(){
new Printer().print(value);
}
}
}
class Printer{
void print(Integer a){
System.out.println("A" + a);
}
void print(Object a){
System.out.println("B" + a);
}
void print(Number a){
System.out.println("C" + a);
}
}
이 코드를 실행하면 자연스럽게 0은 Integer 타입이니까 Printer 클래스에서 파라미터 타입이 Integer인 메서드가 실행된다고 생각할 것이다.
하지만 제네릭의 타입 소거로 인해 실제 실행되는 메서드는 Object 타입의 파라미터를 가진 메서드이다.
0은 Integer 타입이므로 컴파일러는 Collection<Integer>로 추론을 한다. 그래서 사용자 입장에서는 value의 타입이 Integer로 보일 것이다.
하지만, 제네릭은 컴파일 후에 타입이 지워진다.
Collection 클래스가 컴파일 후에는 아래와 같이 된다.
public static class Collection<T extends Integer>{
Object value;
public Collection(Object t) {
this.value = t;
}
public void print(){
new Printer().print(value);
}
}
즉, 컴파일 시점에서는 제네릭이 남아있지만 컴파일 이후인 런타임 시점에서는 제네릭이 사라지는 것이다.
제네릭이 소거되는 이유는 Collection 클래스 안에서는 T가 무슨 타입인지 모르기 때문에 컴파일러는 Type Erasure 규칙을 적용하여 T를 Object 타입으로 보는 것이다.
따라서, 런타임 시점에 0은 Object 타입이 되므로 Printer 클래스의 메서드 중 파라미터 타입이 Object인 메서드가 호출되는 것이다.
만약 Integer 타입인 메서드를 출력하고 싶다면 Collection<T extends Integer>로 클래스를 정의하면 된다.
컴파일 후에 T를 소거하므로 Collection<Integer>가 되기 때문이다.