JDK 1.5에서 처음 도입된 지네릭스는 JDK1.8부터 도입된 람다식만큼 큰 변화였다. 그 당시만 해도 지네릭스는 선택적으로 사용하는 경우가 많았지만 이제는 지네릭스를 모르고는 Java API 문서조차 제대로 보기 어려울 만큼 중요한 위치를 차지하고 있다.
지네릭스는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크(compile-time type check)를 해주는 기능이다. 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다.
타입 안전성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입을 잘못 형변환되어 발생할 수 있는 오류를 줄여준 다는 뜻이다.
예를 들어, ArrayList와 같은 컬렉션 클래스는 다양한 종류의 객체를 담을 수 있긴 하지만 보통 한 종류의 객체를 담는 경우가 더 많다. 그런데도 꺼낼 때 마다 타입체크를 하고 형변환을 하는 것은 아무래도 불편할 수 밖에 없다. 게다가 원하지 않는 종류의 객체가 포함되는 것을 막을 방법이 없다는 것도 문제다. 이러한 문제들을 지네릭스가 해결해 준다.
지네릭스의 장점
1. 타입 안정성을 제공한다.
2. 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해 진다.
간단히 얘기하면 다룰 객체의 타입을 미리 명시해줌으로써 번거로운 형변환을 줄여준다는 얘기다.
지네릭 타입은 클래스와 메서드에 선언할 수 있는데, 먼저 클래스에 선언하는 지네릭 타입에 대해서 알아보자. 예를 들어 클래스 Box가 다음과 같이 정의되어 있다고 가정하자.
class Box {
Object item;
void setItem(Object item) {
this.item = item;
}
Object getItem() {
return item;
}
}
이 클래스를 지네릭 클래스로 변경하면 다음과 같이 클래스 옆에 <T>
를 붙이면 된다. 그리고 Object
를 모두 T
로 바꾼다.
class Box<T> { // 지네릭 타입 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>
와 같이 콤마 ,
를 구분자로 나열하면 된다. K는 Key(키)를 의미하고, V는 Value(값)을 의미한다. 무조건 T
를 사용하기보다 가능하면, 이처럼 상황에 맞게 의미있는 문자를 선택해서 사용하는 것이 좋다.
이들은 기호의 종류만 다를 뿐 임의의 참조형 타입
을 의미한다는 것은 모두 같다.
마치 수학식 f(x, y) = x + y;
가 f(k, v) = k + v;
와 다르지 않은 것처럼 말이다.
기존에는 다양한 종류의 타입을 다루는 메서드의 매개변수나 리턴타입으로 Object타입의 참조변수를 많이 사용했고, 그로 인해 형변환이 불가피했지만, 이젠 Object타입 대신 원하는 타입을 지정하기만 하면 되는 것이다.
이제 지네릭 클래스가 된 Box클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자에 타입 T대신에 사용될 실제 타입을 지정해주어야 한다.
Box<String> b = new Box<String>(); // 타입 T 대신, 실제 타입을 지정
b.setItem(new Object()); // 에러. String 이외의 타입은 지정불가
b.setItem("ABC"); // OK. String 타입이므로 가능
String item = b.getItem(); // (String) b.getItem()과 같이 형변환할 필요가 없음
위의 코드에서 타입 T대신 String타입을 지정해줬으므로, 지네릭 클래스 Box<T>
는 다음과 같이 정의된 것과 같다.
class Box { // 지네릭 타입을 String으로 지정
String item;
void setItem(String item) {
this.item = item;
}
String getItem() {
return item;
}
}
지네릭이 도입되기 이전의 코드와 호환을 위해, 지네릭 클래스인데도 예전의 방식으로 객체를 생성하는 것이 허용된다. 다만 지네릭 타입을 지정하지 않아서 안전하지 않다는 경고가 발생한다.
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
는 지네릭 클래스 Box<T>
의 타입 변수 또는 타입 매개변수라고 하는데, 메서드의 매개변수와 유사한 면이 있기 때문이다. 그래서 아래와 같이 타입 매개변수에 타입을 지정하는 것을 지네릭 타입 호출
이라고 하고, 지정된 타입 String
을 매개변수화된 타입(parameterized type)
이라고 한다. 매개변수화된 타입이라는 용어가 좀 길어서, 앞으로 이 용어 대신 대입된 타입
이라는 용어를 사용할 것이다.
Box<String> b = new Box<String>();
예를 들어, Box<String>
과 Box<Integer>
는 지네릭 클래스 Box<T>
에 서로 다른 타입을 대입하여 호출한 것일 뿐, 이 둘이 별개의 클래스를 의미하는 것은 아니다. 이는 마치 매개변수의 값이 다른 메서드 호출, add(3, 5)와 add(2, 4)가 서로 다른 메서드를 호출하는 것이 아닌 것과 같다.
컴파일 후에 Box<String>
과 Box<Integer>
는 이들의 원시 타입
인 Box로 바뀐다. 즉, 지네릭 타입이 제거된다.
지네릭스 클래스 Box의 객체를 생성할 때, 객체별로 다른 타입을 지정하는 것은 적절하다.
지네릭스는 이처럼 인스턴스별로 다르게 동작하게 하려고 만든 기능이다.
Box<Apple> appleBox = new Box<Apple>(); // OK. Apple객체만 저장가능
Box<Grape> grapeBox = new Box<Grape>(); // OK. Grape객체만 저장가능
그러나 모든 객체에 대해 동일하게 동작해야하는 static 멤버에 타입 변수 T를 사용할 수 없다. T는 인스턴스변수로 간주되기 때문이다. 이미 알고 있는 것처럼 static멤버는 인스턴스변수를 참조할 수 없다.
class Box<T> {
T[] itemArr; // OK. T타입의 배열을 위한 참조변수
...
T[] toArray() {
T[] tmpArr = new T[itemArr.length]; // 에러. 지네릭 배열 생성 불가
...
return tmpArr;
...
}
}
지네릭 배열을 생성할 수 없는 것은 new연산자 때문인데, 이 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야 한다. 그런데 위의 코드에 정의된 Box<T>
클래스를 컴파일하는 시점에서는 T가 어떤 타입이 될지 전혀 알 수 없다. instanceof 연산자도 new 연산자와 같은 이유로 T를 피연산자로 사용할 수 없다.
꼭 지네릭 배열을 생성해야할 필요가 있을때는, new연산자대신 Reflection API
의 newInstance()와 같이 동적으로 객체를 생성하는 메서드로 배열을 생성하거나, Object 배열을 생성해서 복사한 다음에 T[]
로 형변환하는 방법 등을 사용한다.
지네릭 클래스 Box<T>
가 다음과 같이 정의되어 있다고 가정하다. 이 Box<T>
의 객체에는 한 가지 종류, 즉 T타입의 객체만 저장할 수 있다. 전과 달리 ArrayList를 이용해서 여러 객체를 저장할 수 있도록 하였다.
class Box<T> {
ArrayList<T> list = new ArrayList<T>();
void add(T item) { item.add(item) };
T get(int i) { return list.get(i); }
ArrayList<T> getList() { return list; }
int size() { return list.size(); }
public String toString() { return list.toString(); }
}
Box<T>
의 객체를 생성할 때는 다음과 같이 한다. 참조변수와 생성자에 대입된 타입(매개 변수화된 타입)이 일치해야 한다. 일치하지 않으면 에러가 발생한다.
Box<Apple> appleBox = new Box<Apple>(); // OK
Box<Apple> appleBox = new Box<Grape>(); // 에러
두 타입이 상속관계에 있어도 마찬가지이다. Apple이 Fruit의 자손이라고 가정하자.
Box<Fruit> appleBox = new Box<Apple>(); // 에러. 대입 타입이 같은 것은 괜찮다.
단, 두 지네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮다. FruitBoxs는 Box의 자손이라고 가정하자.
Box<Apple> appleBox = new FruitBox<Apple>(); // OK. 다형성
JDK1.7부터는 추정이 가능한 경우 타입을 생략할 수 있게 되었다. 참조변수의 타입으로부터 Box가 Apple타입의 객체만 저장한다는 것을 알 수 있기 때문에, 생정자에 반복해서 타입을 지정해주지 않아도 되는 것이다.
Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box<>(); // OK. JDK1.7부터 생략가능
생성된 Box<T>
의 객체에 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> fruitBox = new Box<Fruit>();
fruitBox.add(new Fruit()); // OK.
fruitBox.add(new Apple()); // OK. void add(Fruit item)
타입 문자로 사용할 타입을 명시하면 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만, 그래도 여전히 모든 종류의 타입을 지정할 수 있다는 것에는 변함이 없다. 그렇다면 타입 매개변수 T에 지정할 수 있는 타입의 종류를 제한할 수 있는 방법은 없을까?
FruitBox<Toy> fruitBox = new FruitBox<Toy>();
fruitBox.add(new Toy()); // OK. 과일상자에 장난감을 담을 수 있다.
다음과 같이 지네릭 타입에 extends
를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다.
class FruitBox<T extends Fruit> { // Fruit의 자손만 타입으로 지정가능
ArrayList<T> list = new ArrayList<T>();
...
}
여전히 한 종류의 타입만 담을 수 있지만, Fruit클래스의 자손들만 담을 수 있다는 제한이 더 추가된 것이다.
FruitBox<Apple> appleBox = new FruitBox<Apple>(); // OK
FruitBox<Toy> toyBox = new FruitBox<Toy>(); // 에러. Toy는 Fruit의 자손이 아님
게다가 add()의 매개변수 타입 T도 Fruit와 그 자손 타입이 될 수 있으므로, 아래와 같이 여러 과일을 담을 수 있는 상자가 가능하게 된다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
fruitBox.add(new Apple()); // OK. Apple이 Fruit의 자손
fruitBox.add(new Grape()); // OK. Grape가 Fruit의 자손
다형성에서 조상타입의 참조변수로 자손타입의 객체를 가리킬 수 있는 것처럼, 매개변수화된 타입의 자손 타입도 가능한 것이다. 타입 매개변수 T에 Object를 대입하면, 모둔 종류의 객체를 저장할 수 있게 된다.
만일 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요하다면, 이때도 extends
를 사용한다. implements
를 사용하지 않는 다는 점에 주의하자.
interface Eatable {}
class FruitBox<T extends Eatable> { ... }
클래스 Fruit의 자손이면서 Eatable 인터페이스도 구현해야한다면 아래와 같이 &
기호로 연결한다.
class FruitBox<T extends Fruit & Eatable> { ... }
매개변수에 과일박스를 대입하면 주스를 만들어서 반환하는 Juicer라는 클래스가 있고, 이 클래스에는 과일을 주스로 만들어서 반환하는 makeJuice()라는 static메서드가 다음과 같이 정의되어 있다고 가정하자.
class Jucier {
static Juice makeJuice(FruitBox<Fruit> box) { // <Fruit>으로 지정
String tmp = "";
for(Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
}
Jucier클래스는 지네릭 클래스가 아닌데다, 지네릭 클래스라고 해도 static메서드에는 타입 매개변수 T를 매개변수에 사용할 수 없으므로 아예 지네릭스를 적용하지 않던가, 위와 같이 타입 매개변수 대신, 특정 타입을 지정해줘야 한다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Jucier.makeJuice(fruitBox)); // OK. FruitBox<Fruit>
System.out.println(Jucier.makeJuice(appleBox)); // 에러. FruitBox<Apple>
이렇게 지네릭 타입을 FruitBox<Fruit>
로 고정해놓으면, 위의 코드에서 알 수 있듯이 FruitBox<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) { // <Fruit>으로 지정
String tmp = "";
for(Fruit f : box.getList()) tmp += f + " ";
return new Juice(tmp);
}
그러나 위와 같이 오버로딩하면, 컴파일 에러가 발생한다. 지네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문이다. 지네릭 타입은 컴파일러가 컴파일할 때만 사용하고 제거해버린다. 그래서 위의 두 메서드는 오버로딩이 아니라 메서드 중복 정의
이다.
이럴 때 사용하기 위해 고안된 것이 바로 와일드 카드이다. 와일드 카드는 기호 ?
로 표현하는데, 와일드 카드는 어떠한 타입도 될 수 있다.
?
만으로는 Object와 다를게 없으므로, 다음과 같이 extends
와 super
로 상한(upper bound)과 하한(lower bound)을 제한할 수 있다.
<? extends T>
: 와일드 카드의 상한 제한. T와 그 자손들만 가능
<? super T>
: 와일드 카드의 하한 제한. T와 그 조상들만 가능
<?>
: 제한 없음. 모든 타입이 가능.<? extends Object>
와 동일
와일드 카드를 사용해서 makeJuice()의 매개변수 타입을 FruitBox<Fruit>
에서 FruitBox <? 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>
도 가능하게 된다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Jucier.makeJuice(fruitBox)); // OK. FruitBox<Fruit>
System.out.println(Jucier.makeJuice(appleBox)); // OK. FruitBox<Apple>
매개변수의 타입은 FruitBox<? extends Object>
로 하면, 모든 종류의 FruitBox가 이 메서드의 매개변수로 가능해진다. 대신, 전과 달리 box의 요소가 Fruit의 자손이라는 보장이 없으므로 아래의 for문에서 box에 저장된 요소를 Fruit타입의 참조변수로 못받는다.
static Juice makeJuice(FruitBox<? extends Object> box) {
String tmp = "";
for(Fruit f : box.getList()) tmp += f + " "; // 에러. Fruit이 아닐 수 있음
return new Juice(tmp);
}
그러나 실제로 테스트 해보면 문제없이 컴파일되는데 그 이유는 바로 지네릭 클래스 FruitBox를 재한했기 때문이다.
class FruitBox<T extends Fruit> extends Box<T> {}
컴파일러는 위 문장으로부터 모든 FruitBox의 요소들이 Fruit의 자손이라는 것을 알고 있으므로 문제 삼지 않는 것이다.
메서드의 선언부에 지네릭 타입이 선언된 메서드를 지네릭 메서드라 한다. 지네릭 타입의 선언 위치는 반환 타입 바로 앞이다.
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<? 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);
}
타입이 복잡한 경우에도 유용하다. 만일 아래와 같은 코드가 있다면 타입을 별도로 선언함으로써 코드를 간략히 할 수 있다.
public static void printAll(ArrayList<? extends Product> list,
ArrayList<? extends Product> list2) {
...
}
public static <T extends Product> void printAll(ArrayList<T> list,
ArrayList<T> list2) {
...
}
이번엔 좀 복잡하게 선언된 지네릭 메서드를 살펴보자.
public static <T extends Comparable<? super T>> void sort(List<T> list)
매개변수로 지정한 List<T>
를 정렬하겠다는건 알겠는데, 메서드에 선언된 지네릭 타입이 복잡하자. 이럴땐 일단 와일드 카드를 걷어내자
public static <T extends Comparable<T>> void sort(List<T> list)
List<T>
의 요소가 Comparable 인터페이스를 구현한 것이어야 한다는 뜻이다. 그럼 원래 코드를 다시 이해해보자.
public static <T extends Comparable<? super T>> void sort(List<T> list)
T
를 요소로 하는 List를 매개변수로 허용한다.T
는 Comparable 구현한 클래스이어야 하며(<T extends Comparable>
), T
또는 그 조상의 타입을 비교하는 Comparable이어야한다는 것(Comparable<? super T>
)을 의미한다. 만일 T가 Student이고, Person의 자손이라면, <? super T>
는 Student, Person, Object가 모두 가능하다.지네릭 타입과 원시 타입(raw type)간의 형변환이 가능할까? 잠시 생각해 본 다음에 아래의 코드를 보자.
Box box = null;
Box<Object> objBox = null;
box = (Box) objBox; // OK. 지네릭 타입 -> 원시 타입. 경고 발생
objBox = (Box<Object>) box; // OK. 원시 타입 -> 지네릭 타입. 경고 발생
위에서 알 수 있듯이, 지네릭 타입과 넌지네릭(non-generic) 타입간의 형변환은 항상 가능하다. 다만 경고가 발생할 뿐이다. 그러면, 대입된 타입이 다른 지네릭 타입 간에는 형변환이 가능할까?
Box<Object> objBox = null;
Box<String> strBox = null;
objBox = (Box<Object>)strBox; // 에러. Box<String> -> Box<Object>
strBox = (Box<String>)objBox; // 에러. Box<Object> -> Box<String>
불가능하다. 대입된 타입이 Object일지라도 말이다. 이 사실은 이미 배웠다. 아래의 문장이 안 된다는 얘기는 Box<Stirng>
을 Box<Object>
로 형변환될 수 없다는 사실을 간접적으로 알려주는 것이기 때문이다.
// Box<Object> objBox = (Box<Object>) new Box<String>();
Box<Object> objBox = new Box<String>(); // 에러
그러면 다음 문장은 어떨까?
Box<? extends Object> wBox = new Box<String>();
형변환이 된다. 그래서 전에 배운 makeJuice 메서드의 매개변수에 다형성이 적용될 수 있었던 것이다.
// 매개변수로 FruitBox<Fruit>, FruitBox<Apple>, FruitBox<Grape> 등이 가능
static Juice makeJuice(FruitBox<? extends Fruit> box) { ... }
FruitBox<? extends Fruit> box = new FruitBox<Fruit>(); // OK.
FruitBox<? extends Fruit> box = new FruitBox<Apple>(); // OK.
FruitBox<? extends Fruit> box = new FruitBox<Grape>(); // OK.
반대로의 형변환도 성립하지만, 확인되지 않은 형변환이라는 경고가 발생한다.
FruitBox<? extends Fruit>
에 대입될 수 있는 타입이 여러 개인데다, FruitBox<Apple>
을 제외한 다른 타입은 FruitBox<Apple>
로 형변환될 수 없기 때문이다.
FruitBox<? extends Fruit> box = null;
FruitBox<Apple> appleBox = (FruitBox<Apple>) box; // OK. 미확인 타입으로 형변환 경고
컴파일러는 지네릭 타입을 이용해서 소스파일을 체크하고, 필요한 곳에 형변환을 넣어준다. 그리고 지네릭 타입을 제거한다. 즉, 컴파일된 파일(*.class)에는 지네릭 타입에 대한 정보가 없는 것이다.
이렇게 하는 주된 이유는 지네릭이 도입되기 이전의 소스 코드와의 호환성을 유지하기 위해서이다. JDK1.5부터 지네릭스가 도입되었지만, 아직도 원시 타입을 사용해서 코드를 작성하는 것을 허용한다. 그러나 앞으로 가능하면 원시 타입을 사용하지 않도록 하자. 언젠가는 분명히 새로운 기능을 위해 하위 호환성을 포기하게 될 때가 올 것이기 때문이다.
지네릭 타입의 제거 과정을 꽤 복잡하기 때문에 자세히 설명하기는 어렵다. 기본적인 과정만 살펴보자.
1. 지네릭 타입의 경계(bound)를 제거한다.
지네릭 타입이 <T extends Fruit>
라면 T는 Fruit로 치환된다. <T>
인 경우는 T는 Object로 치환된다. 그리고 클래스 옆의 선언은 제거된다.
2. 지네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가한다.
열거형은 서로 관련된 상수를 편리하게 선언하기 위한 것으로 여러 상수를 정의할 때 사용하면 유용하다. 원래 자바는 C언어와 달리 열거형이라는 것이 존재하지 않았으나 JDK1.5부터 새로 추가되었다. 자바의 열거형은 C언어의 열거형보다 더 향상된 것으로 열거형이 갖는 값뿐만 아니라 타입도 관리하기 때문에 보다 논리적인 오류를 줄일 수 있다.
class Card {
static final int CLOVER = 0;
static final int HEART = 0;
static final int DIAMOND = 0;
static final int SPADE = 0;
static final int TWO = 0;
static final int THREE = 0;
static final int FOUR = 0;
final int kind;
final int num;
}
class Card {
enum Kind { CLOVER, HEART, DIAMOND, SPADE }
enum Value { TWO, THREE, FOUR }
final Kind kind;
final Value value;
}
자바의 열거형은 타입에 안전한 열거형(typesafe enum)
이라서 실제 값이 같아도 타입이 다르면 컴파일 에러가 발생한다. 이처럼 값뿐만 아니라 타입까지 체크하기 때문에 타이벵 안전하다고 하는 것이다.
if(Card.CLOVER == Card.TWO) // true 이지만 fasle이어야 의미상 맞음
if(Card.Kind.CLOVER == Card.Value.Two) // 컴파일 에러. 값은 같지만 타입이 다름
그리고 더 중요한 것은 상수의 값이 바뀌면, 해당 상수를 참조하는 모든 소스를 다시 컴파일해야 한다는 것이다. 하지만 열거형 상수를 사용하면, 기존의 소스를 다시 컴파일하지 않아도 된다.
열거형을 정의하는 방법은 간단하다. 다음과 같이 괄호 { }안에 상수의 이름을 나열하기만 하면 된다.
enum Direction { EAST, SOUTH, WEST, NORTH }
이 열거형에 정의된 상수를 사용하는 방법은 열거형이름.상수명
이다. 클래스의 static 변수를 참조하는 것과 동일하다.
class Unit {
int x, y;
Direction dir;
void init() {
dir = Direction.EAST;
}
}
열거형 상수간의 비교에는 ==
를 사용할 수 있다. equals()
가 아닌 ==
로 비교가 가능하다는 것은 그만큼 빠른 성능을 제공한다는 얘기다. 그러나 <
, >
와 같은 비교연산자는 사용할 수 없고 compareTo()는 사용가능하다. 앞서 배운 것과 같이 compareTo()는 비교대상이 같으면 0, 왼쪽이 크면 양수, 오른쪽이 크면 음수를 반환한다.
if와 switch문과 같은 조건식에 열거형을 사용하는 예시를 살펴보자.
if (dir == Direction.EAST) { }
else if (dir == Direction.WEST) { }
switch(dir) {
case EAST: // Direction.EAST로 쓰면 안된다.
break;
case WEST:
break;
}
열거형 Direction에 정의된 모든 상수를 출력하려면 다음과 같이 한다.
Direction[] dArr = Direction.values();
for(Direction d : dArr) {
System.out.printf(d.name() + " " + d.ordinal());
}
메서드 | 설명 |
---|---|
Class<E> getDeclaringClass() | 열거형의 Class 객체를 반환 |
String name() | 열거형 상수의 이름을 문자열로 반환 |
int ordinal() | 열거형 상수가 정의된 순서를 반환(0부터 시작) |
T valueOf(Class<T> enumType, String name) | 지정된 열거형에서 name과 일치하는 열거형 상수를 반환 |
Enum 클래스에 정의된 ordinal()이 열거형 상수가 정의된 순서를 반환하지만, 이 값을 열거형 상수의 값으로 사용하지 않는 것이 좋다. 이 값은 내부적인 용도로만 사용되기 위한 것이기 때문이다.
열거형 상수의 값이 불연속적인 경우에는 다음과 같이 열거형 상수의 이름 옆에 원하는 값을 괄호()와 함께 적어주면 된다.
enum Direction { EAST(1), SOUTH(5), WEST(-1), NORTH(10) }
그리고 지정된 값을 저장할 수 있는 인스턴스 변수와 생성자를 새로 추가해주어야 한다. 이 떄 주의할 점은, 먼저 열거형 상수를 모두 정의한 다음에 다른 멤버들을 추가해야한다는 것이다.
enum Direction {
EAST(1), SOUTH(5), WEST(-1), NORTH(10); // 끝에 ; 추가
private final int value; // 정수를 저장할 필드(인스턴스 변수)를 추가
Direction(int value) { this.value = value; } // 생성자를 추가
public int getValue() { return value; }
}
Direction d = new Direction(1); // 에러!! 열거형의 생성자는 외부에서 호출불가
열거형 Direction에 새로운 생성자가 추가되었지만, 위와 같이 객체를 생성할 수 없다. 열거형의 생성자는 제어자가 묵시적으로 private
이기 때문이다.
필요하다면 하나의 열거형 상수에 여러 값을 지정할 수도 있다. 다만 그에 맞게 인스턴수 변수를 추가해주어야 한다.
enum Direction {
EAST(1, ">"), SOUTH(5, "V"), WEST(-1, "<"), NORTH(10, "^"); // 끝에 ; 추가
private final int value; // 정수를 저장할 필드(인스턴스 변수)를 추가
private final String symbol;
Direction(int value, String symbol) {
this.value = value;
this.symbol = symbol;
} // 생성자를 추가
public int getValue() { return value; }
public String getSymbol() { return symbol; }
}
열거형 Transportation은 운송수단의 종류 별로 상수를 정의하고 있으며, 각 운송 수단에는 기본요금(BASIC_FARE)이 책정되어 있다.
enum Transportation {
BUS(100), TRAIN(150), SHIP(100), AIRPLANE(300);
private final int BASIC_FARE;
private Transportation(int basicFare) {
BASIC_FARE = basicFare;
}
int fare() {
return BASIC_FARE;
}
}
그러나 이것만으로 부족하다. 거리에 따라 요금을 계산하는 방식이 각 운송수단마다 다를 것이기 때문이다. 이럴 때, 열거형에 추상 메서드 fare(int distance)
를 선언하면 각 열거형 상수가 이 추상 메서드를 반드시 구현해야 한다.
enum Transportation {
BUS(100) {
int fare(int distance) { return distance * BASIC_FARE; }
},
TRAIN(150) {
int fare(int distance) { return distance * BASIC_FARE; }
},
SHIP(100) {
int fare(int distance) { return distance * BASIC_FARE; }
},
AIRPLANE(300) {
int fare(int distance) { return distance * BASIC_FARE; }
},
abstract int fare(int distance);
protected final int BASIC_FARE;
Transportation(int basicFare) {
BASIC_FARE = basicFare;
}
public int getBasicFare() {
return BASIC_FARE;
}
}
위의 코드는 열거형에 정의된 추상 메서드를 각 상수가 어떻게 구현하는지 보여준다. 마치 익명 클래스를 작성한 것처럼 보일 정도로 유사하다. 위의 코드를 실행하면 아래와 같다.
public static void main(String args[]){
System.out.println(Transportation.BUS.fare(100)); // 10000
System.out.println(Transportation.TRAIN.fare(100)); // 150000
System.out.println(Transportation.SHIP.fare(100)); // 10000
System.out.println(Transportation.AIRPLANE.fare(100)); // 30000
}
자바를 개발한 사람들은 소스코드에 대한 문서를 따로 만들기보다 소스코드와 문서를 하나의 파일로 관리하는 것이 낫다고 생각했다. 그래서 소스코드의 주석 /** ~ */
에 소스코드에 대한 정보를 저장하고, 소스코드의 주석으로부터 HTML문서를 생성해내는 프로그램(javadoc.exe)을 만들어서 사용했다. 다음은 모든 애너테이션의 조상인 Annotation 인터페이스 소스코드의 일부이다.
/**
* The common interface extended by all annotation interfaces. Note that an
* interface that manually extends this one does <i>not</i> define
* an annotation interface. Also note that this interface does not itself
* define an annotation interface.
*
* More information about annotation interfaces can be found in section
* {@jls 9.6} of <cite>The Java Language Specification</cite>.
*
* The {@link java.lang.reflect.AnnotatedElement} interface discusses
* compatibility concerns when evolving an annotation interface from being
* non-repeatable to being repeatable.
*
* @author Josh Bloch
* @since 1.5
*/
/**
로 시작하는 주석안에 소스코드에 대한 설명들이 있고, 그안에 @
이 붙은 태그들이 있다. 미리 정의된 태그들을 이용해서 주석 안에 정보를 저장하고, javadoc.exe라는 프로그램 이 이 정보를 읽어서 문서를 작성하는데 사용한다.
이 기능을 응용하여, 프로그램의 소스코드 안에 다른 프로그램을 위한 정보를 미리 약속된 형식으로 포함시킨 것이 바로 애너테이션이다 애너테이션은 주석(comment)처럼 프로그래밍 언어에 영향을 미치지 않으면서도 다른 프로그램에게 유용한 정보를 제공할 수 있다는 장점이 있다.
자바에서 기본적으로 제공하는 애너테이션들은 몇 개 없다. 그나마 이들의 일부는 메타 애너테이션(meta annotation)
으로 애너테이션을 정의하는데 사용되는 애너테이션의 애너테이션이다. 아직 여러 분들은 대부분 새로운 애너테이션을 정의하기보다는 이미 작성된 애너테이션을 사용하는 경우가 많을 것이다.
메서드 앞에만 붙일 수 있는 애너테이션으로, 조상의 메서드를 오버라이딩하는 것이라는걸 컴파일러에게 알려주는 역할을 한다. 아래의 코드에서와 같이 오버라이딩할 때 조상 메서드의 이름을 잘못 써도 컴파일러는 이것이 잘못된 것인지 알지 못한다.
class Parent {
void parentMethod() { }
}
class Child extends Parent {
@Override
void parentmethod() { } // 조상 메서드의 이름을 잘못 적었음.
}
오버라이딩할 때는 이처럼 메서드의 이름을 잘못 적는 경우가 많은데, 컴파일러는 그저 새로운 이름의 메서드가 추가된 것으로 인식할 뿐이다. 게다가 실행 시에도 오류가 발생하지 않고 조상 메서드가 호출되므로 어디서 잘못되었는지 알아내기 어렵다.
class Child extends Parent {
@Override
void parentmethod() { } // 조상 메서드의 이름을 잘못 적었음.
}
그러나 위의 오른쪽 코드와 같이 메서드 앞에 @Override
라고 애너테이션을 붙이면, 컴파일러가 같은 이름의 메서드가 조상에 있는지 확인하고 없으면, 에러메시지를 출력한다. 오버라이딩할 때 메서드 앞에 @Override
를 붙이는 것이 필수는 아니지만, 알아내기 어려운 실수를 미연에 방지해주므로 반드시 붙이도록 하자.
새로운 버젼의 JDK가 소개될 때, 새로운 기능이 추가될 뿐만 아니라 기존의 부족했던 기능들을 개선하기도 한다. 이 과정에서 기존의 기능을 대체할 것들이 추가되어도, 이미 여러 곳에서 사용되고 있을지 모르는 기존의 것들을 함부로 삭제할 수 없다.
그래서 생각해낸 방법이 더 이상 사용되지 않는 필드나 메서드에 @Deprecated
를 붙이는 것이다. 이 애너테이션이 붙은 대상은 다른 것으로 대체되었으니 더 이상 사용하지 않을 것을 권한다는 의미이다.
함수형 인터페이스(functional interface)
를 선언할 때, 이 애너테이션을 붙이면 컴파일러가 함수형 인터페이스
를 올바르게 선언했는지 확인하고, 잘못된 경우 에러를 발생시킨다. 필수는 아니지만, 붙이면 실수를 방지할 수 있으므로 함수형 인터페이스
를 올바르게 선언했는지 확인하고, 잘못된 경우 에러를 발생시킨다.
컴파일러가 보여주는 경고메시지가 나타나지 않게 억제해준다 이전 예제에서처럼 컴파일러의 경고메시지는 무시하고 넘어갈 수도 있지만, 모두 확인하고 해결해서 컴파일 후에 어떠한 메시지도 나타나지 않게 해야한다. 그러나 경우에 따라서는 경고가 발생할 것을 알면서도 묵인해야 할 때가 있는데, 이 경우 경고를 그대로 놔두면 컴파일러할 때마다 메시지가 나타난다.
이 중에서 주로 사용되는 것은 deprecation
, unchecked
, rawtypes
, vararges
정도이다.
메서드에 선언된 가변인자의 타입이 non-reifiable
타입일 경우, 해당 메서드를 선언하는 부분과 호출하는 부분에서 unchecked
경고가 발생한다. 해당 코드에서 문제가 없다면 이 경고를 억제하기 위해 @SafeVarargs
를 사용해야 한다.
이 애너테이션은 static이나 final이 붙은 메서드와 생성자에만 붙일 수 있다. 즉 오버라이드될 수 있는 메서드에는 사용할 수 없다는 뜻이다.
지네릭스에서 살펴본 것과 같이 어떤 타입들은 컴파일 이후에 제거된다. 컴파일 후에도 제거되지 않는 타입을 non-reifiable
타입이라고 한다. 지네릭 타입들은 대부분 컴파일 시에 제거되므로 non-reifiable
타입이다.
앞에서 설명한 것과 같이 메타 애너테이션은 애너테이션을 위한 애너테이션
, 즉 애너테이션에 붙이는 애너테이션으로 애너테이션을 정의할 때 애너테이션의 적용대상(target)이나 유지기간(retention)등을 지정하는데 사용된다.
애너테이션이 적용가능한 대상을 지정하는데 사용된다. 아래는 @SuppressWarnings
를 정의한 것인데, 이 애너테이션에 적용할 수 있는 대상을 @Target
으로 지정하였다. 앞서 언급한 것과 같이 여러 개의 값을 지정할 때는 배열에서처럼 괄호 {}를 사용해야 한다.
@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VAIABLE})
@Retention(RetentionPolicy.SOURCE)
public @interface SuppressWarnings {
String[] value();
}
@Target
으로 지정할 수 있는 애너테이션 적용대상의 종류는 아래와 같다.
import static java.lang.annotation.ElementType.*;
@Target({FIELD, TYPE, TYPE_USE}) // 적용대상이 FIELD, TYPE, TYPE_USE
public @interface MyAnnotation { }
@MyAnnotation // 적용대상이 TYPE인 경우
class MyClass {
@MyAnnotation // 적용대상이 FIELD인 경우
int i,
@MyAnnotation // 적용대상이 TYPE_USE인 경우
MyClass mc;
}
애너테이션이 유지(retention)되는 기간을 지정하는데 사용된다. 애너테이션의 유지정책의 종류는 다음과 같다.
유지 정책 | 의미 |
---|---|
SOURCE | 소스 파일에만 존재, 클래스파일에는 존재하지 않음. |
CLASS | 클래스 파일에 존재. 실행시에 사용불가. 기본값 |
RUNTIME | 클래스 파일에 존재. 실행시에 사용가능 |
@Override
나 @SuppressWarnings
처럼 컴파일러가 사용하는 애너테이션은 유지 정책이 SORUCE
이다. 컴파일러를 직접 작성할 것이 아니면, 이 유지정책은 필요 없다.
유지 정책은 RUNTIME
으로 하면, 실행 시에 리플랙션(reflection)
을 통해 클래스 파일에 저장된 애너테이션의 정보를 읽어서 처리할 수 있다. @FunctionalInterface
는 @Override
처럼 컴파일러가 체크해주는 애너테이션이지만, 실행 시에도 사용되므로 유지정책이 RUNTIME
으로 되어 있다.
유지 정책 CLASS
는 컴파일러가 애너테이션의 정보를 클래스 파일에 저장할 수 있게 한다. 그러나 클래스 파일이 JVM에 로딩될 때는 애너테이션의 정보가 무시되어 실행 시에 애너테이션에 대한 정보를 얻을 수 없다. 이것이 CLASS
가 유지정책의 기본값임에도 불구하고 잘 사용되지 않는 이유이다.
애너테이션에 대한 정보가 javadoc으로 작성된 문서에 포함되도록 한다. 자바에서 제공하는 기본 애너테이션 중에 @Override
와 @SuppressWarnings
를 제외하고는 모두 이 메타 애너테이션이 붙어 있다.
애너테이션이 자손 클래스에 상속되도록 한다. @Inherited
가 붙은 애너테이션을 조상 클래스에 붙이면, 자손 클래스도 이 애너테이션이 붙은 것과 같이 인식된다.
보통은 하나의 대상에 한 종류의 애너테이션을 붙이는데, @Repeatable
이 붙은 애너테이션은 여러 번 붙일 수 있다.
@Repeatable(ToDos.class) // TODO 애너테이션을 여러 번 반복해서 쓸 수 있게 한다.
@interface Todo {
...
}
예를 들어 @Todo
라는 애너테이션이 위와 같이 정의되어 있을 때, 다음과 같이 MyClass에 @Todo
를 여러 번 붙이는 것이 가능하다.
@Todo("delete test codes.")
@Todo("override inherited methods")
class MyClass {
...
}
일반적으로 애너테이션과 달리 같은 이름의 애너테이션이 여러 개가 하나의 대상에 적용될 수 있기 때문에, 이 애너테이션을 하나로 묶어서 다룰 수 있는 애너테이션도 추가로 정의되어야 한다.
@interface ToDos { // 여러 개의 ToDo애너테이션을 담을 컨테이너 애너테이션
ToDo[] value(); // ToDo애너테이션 배열타입의 요소를 선언. 이름이 반드시 value이어야 함
}
@Repeatable(ToDos.class)
@interface Todo {
...
}
네이티브 메서드(native method)에 의해 참조되는 상수 필드
에 붙이는 애너테이션이다.
네이티브 메서드는 JVM이 설치된 OS의 메서드를 말한다. 네이티브 메서드는 보통 C언어로 작성되어 있는데, 자바에서는 메서드의 선언부만 정의하고 구현은 하지 않는다. 그래서 추상 메서드처럼 선언부만 있고 몸통이 없다.
public class Object {
public final native void notifyAll();
private final native void wait0(long timeoutMillis) throws InterruptedException;
...
}
네이티브 메서드는 자바로 정의되어 있기 때문에 호출하는 방법은 자바의 일반 메서드와 다르지 않지만 실제로 호출되는 것은 OS의 메서드이다.
그냥 아무런 내용도 없는 네이티브 메서드를 선언해놓고 호출한다고 되는 것은 아니고, 자바에 정의된 네이티브 메서드와 OS의 메서드를 연결해주는 작업이 추가로 필요하다. 이 역할은 JNI(Java Native Interface)가 하는데, JNI는 이 책의 범위를 벗어나므로 설명은 생략한다.
지금까지 애너테이션을 사용하는 방법에 대해 살펴보았는데, 이제 직접 애너테이션을 만들어서 사용해볼 것이다. 새로운 애너테이션을 정의하는 방법은 아래와 같다. @
기호를 붙이는 것을 제외하면 인터페이스를 정의하는 것과 동일한다.
@interface 애너테이션이름 {
타입 요소이름(); // 애너테이션의 요소를 선언한다.
...
}
엄밀히 말해서 @Override
는 애너테이션이고 Override
는 애너테이션의 타입
이다.
애너테이션 내에 선언된 메서드를 애너테이션의 요소(element)
라고 하며, 아래에 선언된 TestInfo 애너테이션은 다섯 개의 요소를 갖는다.
@interface TestInfo {
int count();
String testedBy();
String[] testTools();
TestType testType();
DateTime testDate();
}
@interface DateTime {
String yymmdd();
String hhmmss();
}
애너테이션의 요소는 반환값이 있고 매개변수는 없는 추상 메서드의 형태를 가지며, 상속을 통해 구현하지 않아도 된다. 다만, 애너테이션을 적용할 때 이 요소들의 값을 빠짐없이 지정해주어야 한다ㅣ 요소의 이름도 같이 적어주므로 순서는 상관 없다.
@TestInfo(
count = 3, testedBy = "kim",
testTools = {"Junit", "AutoTester"},
testType = TesetTyoe,FIRST,
testDate = @DateTime(yymmdd = "240216", hhmmss = "235959")
)
public class NewClass {
...
}
애너테이션의 각 요소는 기본값을 가질 수 있으며, 기본값이 있는 요소는 애너테이션을 적용할 때 값을 지정하지 않으면 기본값이 사용된다.
@interface TestInfo {
int count() default 1; // 기본값 1로 지정
]
@TestInfo // @TestInfo(count = 1)과 동일
public class NewClass {
...
}
애너테이션 요소가 오직 하나뿐이고 이름이 value인 경우, 애너테이션을 적용할 때 요소의 이름을 생략하고 값만 적어도 된다.
@interface TestInfo {
String value();
]
@TestInfo("passed")
public class NewClass {
...
}
요소의 타입이 배열인 경우, 괄호{ }를 사용해서 여러 개의 값을 지정할 수 있다.
@interface TestInfo {
String[] testTools();
]
@Test(testTools = {"Junit", "AutoTester"}) // 값이 여러 개일때
@Test(testTools = "Junit") // 값이 하나일 때는 괄호 { } 생략 가능
@Test(testTools = { }) // 값이 없을 때는 괄호 { } 반드시 필요
기본값을 지정할 때도 마찬가지로 괄호 { }를 사용할 수 있다.
@interface TestInfo {
String[] info() default {"aaa", "bbb"}; // 기본값이 여러 개인 경우 { } 사용
String[] info2() default "ccc"; // 기본 값이 하나인 경우, { } 생략가능
}
@TestInfo
@TestInfo(info2 = {})
class NewClass { ... }
요소의 타입이 배열일 때도 요소의 이름이 value이면, 요소의 이름을 생략할 수 있다.
모든 애너테이션의 조상은 Annotation이다. 그러나 애너테이션은 상속이 허용되지 않으므로 아래와 같이 명시적으로 Annotation을 조상으로 지정할 수 없다.
@interface TestInfo extends Annotation { // 에러.
...
}
게다가 Annotation은 애너테이션이 아니라 일반적인 인터페이스로 정의되어 있다.
public interface Annotation {
boolean equals(Object obj);
int hashCode();
Class<? extends Annotation> annotationType();
}
위의 코드는 AnnotationTest 클래스에 적용된 모든 애너테이션에 대해 toString(), hashCode(), equals()를 호출한다.
값을 지정할 필요가 없는 경우, 애너테이션의 요소를 하나도 정의하지 않을 수 있다.
Serializable이나 Cloneable 인터페이스처럼, 요소가 하나도 정의도지 않은 애너테이션을 마커 애너테이션이라 한다.
애너테이션의 요소를 선언할 때 반드시 지켜야 하는 규칙은 다믕과 같다.
- 요소의 타입은 기본형, String, enum, 애너테이션, Class만 허용된다.
- ()안에 매개변수를 선언할 수 없다.
- 예외를 선언할 수 없다.
- 요소를 타입 매개변수로 정의할 수 없다.
커밋 너무 잘보고있어요 :) 전 코테준비하면서 이제 JAVA공부 막시작한 취준생입니다
혹시 취준생이신가요?