1. 제네릭 적용 및 용어와 관례
제네릭 적용
a. 제네릭 클래스
<>를 사용한 클래스를 제네릭 클래스라고 부른다.
- 이 기호(<>)를 보통 다이아몬드라고 부른다.
- 클래스명 오른쪽에 <T>처럼 선언하면 된다. 여기서 T를 타입 매개변수라고 한다. 이 타입 매개변수는 이후에 Integer, String 등으로 변할 수 있다.
- 클래스 내부에 T 타입이 필요한 곳에 T value처럼 타입 매개변수를 적어두면 된다.
public class GenericBox<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
b. 생성 시점에 원하는 타입 지정
<> 안에 타입 매개변수를 정의하며, 생성 시점에 원하는 타입을 지정할 수 있다.
- 제네릭을 도입한다고 해서 GenericBox<String>이나 GenericBox<Integer> 같은 코드가 실제로 만들어지는 것은 아니다. 대신에 자바 컴파일러가 개발자가 입력한 타입 정보를 기반으로 이런 코드가 있다고 가정한 뒤, 컴파일 과정에 타입 정보를 반영한다. 이 과정에서 타입이 맞지 않으면 컴파일 오류가 발생한다.
public class BoxMain {
public static void main(String[] args) {
// 생성 시점에 원하는 타입 지정
GenericBox<String> stringBox = new GenericBox<String>();
stringBox.set("hi");
String str = stringBox.get();
}
}
c. 타입 추론
자바는 왼쪽에 있는 변수를 선언할 때의 <Integer>를 보고, 오른쪽에 있는 객체를 생성할 때 필요한 타입 정보를 얻을 수 있다. 따라서 new GenericBox<>()처럼 타입 정보를 생략할 수 있다.
- 이렇게 자바가 스스로 타입 정보를 추론해서 개발자가 타입 정보를 생략할 수 있는 것을 타입 추론이라고 한다.
- 참고로 타입 추론은 자바 컴파일러가 타입을 추론할 수 있도록 읽을 수 있는 타입 정보가 주변에 있는 상황에서만 가능하다.
public class BoxMain {
public static void main(String[] args) {
...
// 타입 추론 - 생성하는 제네릭 타입 생략 가능
GenericBox<Integer> integerBox = new GenericBox<>();
}
}
제네릭 용어와 관례
a. 제네릭 & 메서드의 매개변수와 인자의 관계
제네릭의 핵심은 사용할 타입을 미리 결정하지 않는다는 점이다. 클래스 내부에서 사용하는 타입을 클래스를 정의하는 시점에 결정하는 것이 아니라, 실제 사용하는 생성 시점에 타입을 결정한다. 메서드의 매개변수와 인자의 관계와 비슷하다고 보면 된다.
- 메서드에 필요한 값을 메서드 정의 시점에 미리 결정하거나 인자를 통해 매개변수로 전달해 결정할 수 있다.
- 매개변수(String param)를 정의하고, 실행 시점에 인자(arg)를 통해 원하는 값을 매개변수에 전달하면 코드 재사용성을 크게 높일 수 있다.
void method(String param) {
...
}
///
void main() {
String arg = "hi";
method(arg);
}
- 제네릭도 앞서 설명한 메서드의 매개변수와 인자의 관계와 비슷하게 작동한다.
- 제네릭 클래스를 정의할 때 내부에서 사용할 타입을 미리 결정하는 것이 아니라, 해당 클래스를 실제로 사용하는 시점에 내부에서 사용할 타입을 결정하는 것이다.
- 차이가 있다면 메서드의 매개변수는 사용할 값에 대한 결정을 나중으로 미루는 것이고, 제네릭의 타입 매개변수는 사용할 타입에 대한 결정을 나중으로 미루는 것이다.
b. 용어 정리
제네릭 (Generic)
- 제네릭이라는 단어를 풀어보면, 특정 타입에 속한 것이 아니라 일반(범용)적으로 사용할 수 있다는 뜻이다.
제네릭 타입 (Generic Type)
- 클래스나 인터페이스를 정의할 때, 타입 매개변수를 사용하는 것을 말한다.
- 제네릭 클래스과 제네릭 인터페이스를 모두 합쳐서 제네릭 타입이라고 한다.
- 타입은 클래스, 인터페이스, 기본형 등을 모두 합쳐서 부르는 말이다.
타입 매개변수 (Type Parameter)
- 제네릭 타입이나 메서드에서 사용되는 변수로, 실제 타입으로 대체된다.
타입 인자 (Type Argument)
- 제네릭 타입을 사용할 때 제공되는 실제 타입이다.
c. 제네릭 명명 관례
타입 매개변수는 일반적으로 대문자를 사용하고, 용도에 맞는 단어의 첫 글자를 사용하는 관례를 따른다. 주로 사용하는 키워드는 다음과 같다.
- E(Element), K(Key), N(Number), T(Type), V(Value)
- S, U, V etc.(2nd, 3rd, 4th types)
d. 제네릭 기타
다음과 같이 한 번에 여러 타입 매개변수를 선언할 수 있다.
class Data<K, V> {}
제네릭의 타입 인자로 기본형(int, double 등)은 사용할 수 없다. 대신에 래퍼 클래스(Integer, Double)를 사용하면 된다.
e. 로 타입 - raw type
제네릭 타입을 사용할 때 다음과 같이 <>를 지정하지 않는 것을 로 타입(raw type) 또는 원시 타입이라고 부른다.
- 원시 타입을 사용하면 내부의 타입 매개변수가 Object로 된다고 이해하면 된다.
public class RawTypeMain {
public static void main(String[] args) {
GenericBox integerBox = new GenericBox();
// GenericBox<Integer> integerBox = new GenericBox<>(); // 권장
integerBox.set(10);
Integer result = (Integer) integerBox.get();
}
}
자바의 제네릭이 자바가 오랜 기간 사용된 이후에 등장했기 때문에 제네릭이 없던 시절의 과거 코드와의 하위 호환이 필요했다. 그래서 어쩔 수 없이 이런 원시 타입을 지원한다.
- 정리하자면, 로 타입을 사용하지 않는 게 좋다. 필요하다면 다음과 같이 타입 인자로 Object를 지정해서 사용하자.
GenericBox<Object> integerBox = new GenericBox<>();
2. 제네릭 타입
제네릭 도입과 실패
제네릭 타입을 선언하면 자바 컴파일러 입장에서 T에 어떤 값이 들어올지 예측할 수 없다.
- T에는 타입 인자로 어떤 타입이든(Integer도, Dog도, Object도) 들어갈 수 있다.
- 자바 컴파일러는 어떤 타입이 들어올지 모르기 때문에 T를 모든 객체의 최종 부모인 Object 타입으로 가정한다. 따라서 Object가 제공하는 메서드만 호출할 수 있다.
public class AnimalHospital<T> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
public void checkup() {
// T의 타입을 메서드 정의 시점에 알 수 없음
// Object의 기능만 사용 가능
animal.toString();
animal.equals(null);
// 컴파일 오류
// animal.sound();
}
public T getBigger(T target) {
// 컴파일 오류
// return animal.getSize() > target.getSize() ? animal : target;
return null;
}
}
타입 매개변수 제한
a. 적용
다음과 같이 타입 매개변수를 특정 타입으로 제한할 수 있다.
- 여기서 핵심은 <T extends Animal>이다. 타입 매개변수 T를 Animal과 그 자식만 받을 수 있도록 제한을 두는 것이다. 즉, T의 상한이 Animal이 된다.
- 이제 자바 컴파일러는 T에 입력될 수 있는 값의 범위를 예측할 수 있다.
- 타입 매개변수 T에는 타입 인자로 Animal, Dog, Cat만 들어올 수 있으므로 이를 모두 수용할 수 있는 Animal을 T의 타입으로 가정해도 문제가 없다.
- 따라서 Animal이 제공하는 getName(), getSize() 같은 기능을 사용할 수 있다.
public class AnimalHospital<T extends Animal> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
public void checkup() {
System.out.println("동물 이름: " + animal.getName());
System.out.println("동물 크기: " + animal.getSize());
animal.sound();
}
public T getBigger(T target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
- 이렇게 하면 타입 인자로 들어올 수 있는 값이 Animal과 그 자식으로 제한된다.
AnimalHospital<Animal>
AnimalHospital<Dog>
AnimalHospital<Cat>
타입 매개변수에 입력될 수 있는 상한을 지정해서 위에서 말한 문제를 해결했다. 특징은 다음과 같다.
- AnimalHospital<Integer>와 같이 동물과 전혀 관계가 없는 타입 인자를 컴파일 시점에 막을 수 있다.
- 상위 타입의 원하는 기능까지 사용할 수 있다.
- 코드 재사용성과 타입 안정성을 모두 얻을 수 있다.
3. 제네릭 메서드
제네릭 타입과 제네릭 메서드
a. 차이점
특정 메서드에 제네릭을 적용한 것을 제네릭 메서드라고 부른다. 앞서 살펴본 제네릭 타입과 제네릭 메서드 모두 제네릭을 사용하기는 하지만 서로 다른 기능을 제공한다.
- 제네릭 타입
- 정의: GenericClass<T>
- 타입 인자 전달: 객체를 생성하는 시점
- ex. new GenericClass<String>
- 제네릭 메서드
- 정의: <T> T genericMethod(T t)
- 타입 인자 전달: 메서드를 호출하는 시점
- ex. GenericMethod.<Integer>genericMethod(i)
b. 제네릭 메서드
제네릭 메서드는 클래스 전체가 아니라 특정 메서드 단위로 제네릭을 도입할 때 사용한다.
- 정의
- 제네릭 메서드를 정의할 때는 메서드의 반환 타입 왼쪽에 다이아몬드를 사용해서 <T>와 같이 타입 매개변수를 적어준다.
- 호출
- 제네릭 메서드를 실제 호출하는 시점에 다이아몬드를 사용해서 <Integer>와 같이 타입을 정하고 호출한다.
- 타입 매개변수 제한
- 제네릭 메서드도 제네릭 타입처럼 타입 매개변수를 제한할 수 있다.
- 참고로 Integer, Double, Long 같은 숫자 타입이 Number의 자식이다.
- 제네릭 메서드도 제네릭 타입처럼 타입 매개변수를 제한할 수 있다.
- 타입 추론
- 아래 MethodMain 코드를 보면, 자바 컴파일러는 genericMethod()에 전달되는 인자 i의 타입이 Integer라는 것을 알 수 있다. 또한 반환 타입이 Integer라는 것도 알 수 있다.
- 이런 정보를 통해 자바 컴파일러는 타입 인자를 추론할 수 있다.
public class GenericMethod {
public static Object objMethod(Object obj) {
return obj;
}
public static<T> T genericMethod(T t) {
return t;
}
public static <T extends Number> T numberMethod(T t) {
return t;
}
}
///
public class MethodMain {
public static void main(String[] args) {
Integer i = 10;
Object object = GenericMethod.objMethod(i);
// 타입 인자 명시적 전달
Integer result = GenericMethod.<Integer>genericMethod(i);
Integer integerValue = GenericMethod.<Integer>numberMethod(10);
Double doubleValue = GenericMethod.<Double>numberMethod(20.0);
}
}
인스턴스 메서드, static 메서드
a. 설명
제네릭 메서드는 다음과 같이 인스턴스 메서드와 static 메서드에 모두 적용할 수 있다.
// 제네릭 타입
class Box<T> {
// static 메서드에 제네릭 메서드 도입
static <V> V staticMethod(V v) {}
// 인스턴스 메서드에 제네릭 메서드 도입
<Z> Z instanceMethod(Z z) {}
}
참고로 제네릭 타입은 static 메서드에 타입 매개변수를 사용할 수 없다.
- 제네릭 타입은 객체를 생성하는 시점에 타입이 정해진다. 그런데 static 메서드는 인스턴스 단위가 아니라 클래스 단위로 동작하기 때문에 제네릭 타입과는 무관하다.
- 따라서 static 메서드에 제네릭을 도입하려면 제네릭 메서드를 사용해야 한다.
class Box<T> {
// 가능
T instanceMethod(T t) {}
// 제네릭 타입의 T 사용 불가능
static T staticMethod(T t) {}
}
b. 우선순위
static 메서드는 제네릭 메서드만 적용할 수 있고, 인스턴스 메서드는 제네릭 타입과 제네릭 메서드 모두 적용할 수 있었다. 제네릭 타입과 제네릭 메서드의 타입 매개변수를 같은 이름으로 사용하면 어떻게 될까?
- 제네릭 타입보다 제네릭 메서드가 높은 우선순위를 가진다. 따라서 printAndReturn()은 제네릭 타입과는 무관하게 제네릭 메서드가 적용된다.
- 여기서 적용된 제네릭 메서드의 타입 매개변수 T는 상한이 없다. 따라서 Object로 취급되기 때문에 Animal에 존재하는 메서드를 호출할 수 없다.
public class ComplexBox<T extends Animal> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
public <T> T printAndReturn(T t) {
return t;
}
}
참고
이름이 겹친다면 하나를 다른 이름으로 변경하도록 하자.
4. 와일드카드(wildcard)
제네릭 타입을 조금 더 편리하게 사용할 수 있는 와일드카드(wildcard)에 대해 알아보자.
- 참고로 와일드카드라는 뜻은 컴퓨터 프로그래밍에서 *나 ?와 같이 하나 이상의 문자들을 상징하는 특수 문자를 뜻한다.
- 와일드카드는 제네릭 타입이나, 제네릭 메서드를 선언하는 것이 아니라 이미 만들어진 제네릭 타입을 활용할 때 사용한다. 아래에서 사용할 간단한 제네릭 타입을 만들어 두자.
public class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
비제한 와일드카드
두 메서드는 비슷한 기능을 하는 코드이다. 하나는 제네릭 메서드를 사용하고, 하나는 일반적인 메서드에 와일드카드를 사용했다.
- 와일드카드는 Box<Dog>나 Box<Cat>처럼 타입 인자가 정해진 제네릭 타입을 전달받아서 활용할 때 사용한다.
- 와일드카드 ?는 모든 타입을 다 받을 수 있다는 뜻이다.
- 이렇게 ?만 사용해서 제한 없이 모든 타입을 다 받을 수 있는 와일드카드를 비제한 와일드카드라고 한다.
public class WildCardEx {
// 제네릭 메서드 O
// Box<Dog> dogBox를 전달 -> 타입 추론에 의해 타입 T가 Dog가 됨
static <T> void printGenericV1(Box<T> box) {
System.out.println("T = " + box.get());
}
// 제네릭 메서드 X
// Box<Dog> dogBox를 전달 -> 와일드카드 ?는 모든 타입을 받을 수 있음
static void printWildcardV1(Box<?> box) {
System.out.println("? = " + box.get());
}
}
위의 두 메서드를 분석해 보자.
- printGenericV1() 제네릭 메서드를 보면, 타입 매개변수가 존재한다. 그리고 특정 시점에 타입 매개변수에 타입 인자를 전달해서 타입을 결정해야 한다. 이런 과정은 매우 복잡하다.
- 반면, printWildcardV1() 메서드를 보면, 와일드카드는 일반적인 메서드에 사용할 수 있고, 단순히 매개변수로 제네릭 타입을 받을 수 있는 것뿐이다. 제네릭 메서드처럼 타입을 결정하거나 복잡하게 동작하지 않는다.
제네릭 타입이나 제네릭 메서드를 정의하는 게 꼭 필요한 상황이 아니라면, 더 단순한 와일드카드를 사용하는 것을 권장한다.
상한 와일드카드
제네릭 메서드와 마찬가지로 와일드카드에도 상한 제한을 둘 수 있다.
- 여기서는 <? extends Animal>처럼 지정했다.
- Animal과 그 하위 타입까지만 입력받으며, 다른 타입이 들어오면 컴파일 오류가 발생한다.
- box.get()을 통해 꺼낼 수 있는 타입의 최대 부모는 Animal이 된다. 따라서 Animal 타입으로 조회할 수 있다.
- 결과적으로 Animal 타입의 기능을 호출할 수 있다.
public class WildCardEx {
static <T extends Animal> void printGenericV2(Box<T> box) {
T t = box.get();
System.out.println("이름 = " + t.getName());
}
static void printWildcardV2(Box<? extends Animal> box) {
Animal animal = box.get();
System.out.println("이름 = " + animal.getName());
}
}
메서드의 타입들을 특정 시점에 변경하려면 제네릭 타입이나 제네릭 메서드를 사용해야 한다.
- printAndRetuenGeneric()은 전달한 타입을 명확하게 반환할 수 있다.
- printAndReturnWildcard()의 경우, 전달한 타입을 명확하게 반환할 수 없다. 여기서는 Animal 타입으로 반환한다.
public class WildCardEx {
static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
T t = box.get();
System.out.println("이름 = " + t.getName());
return t;
}
// Dog dog = WildcardEx.printAndReturnGeneric(dogBox); // Dog 가능
static Animal printAndReturnWildcard(Box<? extends Animal> box) {
Animal animal = box.get();
System.out.println("이름 = " + animal.getName());
return animal;
}
// Animal animal = WildcardEx.printAndReturnWildcard(dogBox); // Dog 불가능
}
하한 와일드카드
와일드카드는 상한뿐만 아니라 하한까지 지정할 수 있다.
- 여기서 핵심은 Box<? super Animal>이다.
- 이 코드는 ?가 Animal 타입을 포함한 Animal 타입의 상위 타입만 입력받을 수 있다는 뜻이다.
public class WildcardMain {
public static void main(String[] args) {
Box<Object> objBox = new Box<>();
Box<Animal> animalBox = new Box<>();
Box<Dog> dogBox = new Box<>();
Box<Cat> catBox = new Box<>();
// Animal 포함 상위 타입 전달 가능
writeBox(objBox);
writeBox(animalBox);
// 하한이 Animal
// writeBox(dogBox);
// writeBox(catBox);
Animal animal = animalBox.get();
System.out.println("animal = " + animal);
}
static void writeBox(Box<? super Animal> box) {
box.set(new Dog("멍멍이", 100));
}
}
5. 타입 이레이저
제네릭은 자바 컴파일 단계에서만 사용되고, 컴파일 이후에는 제네릭 정보가 삭제된다. 제네릭에 사용한 타입 매개변수가 모두 사라진다는 뜻이다.
- 쉽게 말하면 컴파일 전인 .java에는 제네릭의 타입 매개변수가 존재하지만, 컴파일 이후인 자바 바이트코드 .class에는 타입 매개변수가 존재하지 않는다.
타입 삭제 과정
a. 타입 매개변수 제한 X
어떻게 변하게 되는지는 다음 코드를 보면 확인할 수 있다. 100% 정확한 코드는 아니고 동작 과정만 설명한 코드라고 보면 된다.
- 제네릭 타입을 선언하고, 제네릭 타입에 Integer 타입 인자를 전달한다.
- 이렇게 하면 자바 컴파일러는 컴파일 시점에 타입 매개변수와 타입 인자를 포함한 제네릭 정보를 활용해서 new GenericBox<Integer>()를 이해한다.
// 제네릭 타입 선언 GenericBox.java
public class GenericBox<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
// 제네릭 타입에 Integer 타입 인자 전달 후 컴파일 시점
public class GenericBox<Integer> {
private Integer value;
public void set(Integer value) {
this.value = value;
}
public Integer get() {
return value;
}
}
- 컴파일이 모두 끝나면 자바는 제네릭과 관련된 정보를 삭제한다. 여기서 상한 제한 없이 선언한 타입 매개변수 T는 Object로 변환된다.
- 값을 반환받는 부분을 Object로 받지 않아도, 자바 컴파일러가 제네릭에서 타입 인자로 지정한 Integer로 캐스팅하는 코드를 추가해 준다. 이렇게 추가한 코드는 자바 컴파일러가 이미 검증하고 추가한 것이기 때문에 문제가 발생하지 않는다.
// 컴파일 후 GenericBox.class
// 상한 제한 없이 선언한 타입 매개변수 T가 Object로 변환됨
public class GenericBox {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
b. 타입 매개변수 제한 O
다음과 같이 타입 매개변수를 제한하면, 제한한 타입으로 코드를 변경한다.
- 컴파일 전 AnimalHospital.java
public class AnimalHospital<T extends Animal> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
public void checkup() {
animal.sound();
}
public T getBigger(T target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
///
// 사용 코드 예시
AnimalHospital<Dog> Hospital = new AnimalHospital<>();
...
Dog dog = animalHospital.getBigger(new Dog());
- 컴파일 후 AnimalHospital.class
- T의 타입 정보가 제거돼도 상한으로 지정한 Animal 타입으로 대체되기 때문에 Animal 타입의 메서드를 사용하는 데는 아무런 문제가 없다.
- 반환 받는 부분을 Animal로 받으면 안 되기 때문에 자바 컴파일러가 타입 인자로 지정한 Dog로 캐스팅하는 코드를 넣어준다.
public class AnimalHospital {
private Animal animal;
public void set(Animal animal) {
this.animal = animal;
}
public void checkup() {
animal.sound();
}
public Animal getBigger(Animal target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
c. 정리
자바의 제네릭은 단순하게 생각하면 개발자가 직접 캐스팅하는 코드를 컴파일러가 대신 처리해 주는 것이다. 자바는 컴파일 시점에 제네릭을 사용한 코드에 문제가 없는지 완벽하게 검증하기 때문에 자바 컴파일러가 추가하는 다운 캐스팅에는 문제가 발생하지 않는다.
자바의 제네릭 타입은 컴파일 시점에만 존재하고, 런타임 시에는 제네릭 정보가 지워지는데, 이것을 타입 이레이저라고 한다.
타입 이레이저 방식의 한계
위에서 말했듯이 컴파일 이후에는 제네릭 타입 정보가 존재하지 않는다. 즉, .class로 자바를 실행하는 런타임에는 개발자가 지정한 Box<Integer>, Box<String> 등의 타입 정보가 모두 제거된다.
- 따라서 런타임에 타입을 활용하는 다음과 같은 코드는 작성할 수 없다. 여기서 T는 런타임에 모두 Object가 돼 버린다.
- instanceof는 항상 Object와 비교하게 되며, 따라서 항상 참이 반환되는 문제가 발생한다. 자바는 이런 문제 때문에 타입 매개변수에 instanceof를 허용하지 않는다.
- 개발자가 의도한 것과는 다르게, new T도 항상 new Object가 돼버린다. 따라서 자바는 타입 매개변수에 new를 허용하지 않는다.
// 소스 코드
class EraserBox<T> {
public boolean instanceCheck(Object param) {
return param instanceof T; // 오류
}
public T create() {
return new T(); // 오류
}
}
// 런타임
class EraserBox {
public boolean instanceCheck(Object param) {
return param instanceof Object; // 오류
}
public Object create() {
return new Object(); // 오류
}
}