제네릭을 도입하지 않은 예제
동물병원을 만드는데, 개 병원은 개만, 고양이 병원은 고양이만 받을 수 있어야 한다.
public class CatHospital {
private Cat animal;
public void set(Cat animal) {
this.animal = animal;
}
public void checkUp() {
System.out.println("동물 이름: " + animal.getName());
System.out.println("동물 이름: " + animal.getSize());
animal.sound();
}
public Cat bigger(Cat target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
public class DogHospital {
private Dog animal;
public void set(Dog animal) {
this.animal = animal;
}
public void checkUp() {
System.out.println("동물 이름: " + animal.getName());
System.out.println("동물 이름: " + animal.getSize());
animal.sound();
}
public Dog bigger(Dog target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
package generic.test.ex3;
import generic.animal.Cat;
import generic.animal.Dog;
public class AnimalHospitalMainV0 {
public static void main(String[] args) {
DogHospital dogHospital = new DogHospital();
CatHospital catHospital = new CatHospital();
Dog dog = new Dog("멍멍이1", 100);
Cat cat = new Cat("야옹이1", 300);
dogHospital.set(dog);
dogHospital.checkUp();
catHospital.set(cat);
catHospital.checkUp();
// 문제1. 개 병원에 고양이 전달
// dogHospital.set(cat); // 컴파일 오류
// 문제2. 개 타입 반환
dogHospital.set(dog);
Dog biggerDog = dogHospital.bigger(new Dog("멍멍이2", 200));
System.out.println("biggerDog = " + biggerDog);
}
}
개 병원과 고양이 병원을 각각 별도의 클래스로 만들었기 때문에 개 병원은 개만, 고양이 병원은 고양이만 받을 수 있다.
코드 재사용성은 매우 낮으나 타입 안정성은 높아졌다.
다형성 시도
package generic.test.ex3;
import generic.animal.Animal;
public class AnimalHospitalV1 {
private Animal animal;
public void set(Animal animal) {
this.animal = animal;
}
public void checkUp() {
System.out.println("동물 이름: " + animal.getName());
System.out.println("동물 크기: " + animal.getSize());
animal.sound();
}
public Animal bigger(Animal target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
package generic.test.ex3;
import generic.animal.Animal;
import generic.animal.Cat;
import generic.animal.Dog;
public class AnimalHospitalMainV1 {
public static void main(String[] args) {
AnimalHospitalV1 dogHospital = new AnimalHospitalV1();
AnimalHospitalV1 catHospital = new AnimalHospitalV1();
Dog dog = new Dog("멍멍이1", 100);
Cat cat = new Cat("야옹이1", 300);
dogHospital.set(dog);
dogHospital.checkUp();
catHospital.set(cat);
catHospital.checkUp();
// 문제1. 개 병원에 고양이 전달
dogHospital.set(cat); // 매개변수 체크 실패, 컴파일 오류가 발생하지 않음
// 문제2. 개 타입 반환
dogHospital.set(dog);
// 다운 캐스팅 필요
Dog biggerDog = (Dog) dogHospital.bigger(new Dog("멍멍이", 300));
System.out.println("biggerDog = " + biggerDog);
}
}
Dog와 Cat에는 Animal이라는 부모 타입이 있다. 다형성을 사용해 중복을 제거해보았다.
checkUp, bigger, 등등 다양한 메서드는 모두 Animal 타입이 제공하는 메서드이므로 문제 없이 호출이 가능했다.
하지만 dogHospital에 cat이 전달 될 수 있다. 이는 문법상 오류가 없으므로 컴파일러가 체크해주지 않는다.
위 코드와 반대로 코드 재사용성은 높아지고, 타입 안정성은 낮아졌다.
제네릭 도입과 실패
위 문제들을 해결하기 위해 제네릭 클래스를 도입했다.
컴파일 오류가 발생한다.
그 이유는 T는 아직 정의되지 않았으므로 컴파일러는 어떤 타입이 들어올 지 예측할 수 없다. 이 클래스엔 Animal에 대한 정보가 없다.
개발자인 우리는 알지만, 컴파일러는 Integer가 들어올 지, String이 들어올 지 모르는 것이다.
따라서 지금 AnimalHospitalV2 클래스는 Object의 공통 메서드밖에 사용할 수 없다. (toString 혹은 equals 등)
public class AnimalHospitalMainV2 {
public static void main(String[] args) {
AnimalHospitalV2<Dog> dogHospital = new AnimalHospitalV2<>();
AnimalHospitalV2<Cat> CatHospital = new AnimalHospitalV2<>();
AnimalHospitalV2<Integer> IntegerHospital = new AnimalHospitalV2<>();
AnimalHospitalV2<Object> ObjectHospital = new AnimalHospitalV2<>();
}
}
또한 우리는 동물병원을 만드는데, 동물과 전혀 관련없는 타입이 전달될 수 있다는 점이다.
우리는 타입 매개변수를 최소한 Animal이나 그 자식 타입으로 제한하고 싶다.
타입 매개변수 제한
package generic.ex3;
import generic.animal.Animal;
public class AnimalHospitalV3<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 bigger(T target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
<T extends Animal>로 매개변수를 제한했다. T를 Animal과 그 자식 타입만 받을 수 있도록 제한을 둔 것이다.
따라서 이전에 사용하지 못했던 getName(), getSize() 메서드 등을 사용할 수 있게 되었다.
package generic.ex3;
import generic.animal.Cat;
import generic.animal.Dog;
public class AnimalHospitalMainV3 {
public static void main(String[] args) {
AnimalHospitalV3<Dog> dogHospital = new AnimalHospitalV3<>();
AnimalHospitalV3<Cat> catHospital = new AnimalHospitalV3<>();
Dog dog = new Dog("멍멍이1", 100);
Cat cat = new Cat("야옹이1", 300);
dogHospital.set(dog);
dogHospital.checkUp();
catHospital.set(cat);
catHospital.checkUp();
// 문제1. 개 병원에 고양이 전달
// dogHospital.set(cat); // 컴파일 오류 발생
// 문제2. 개 타입 반환
dogHospital.set(dog);
// 다운 캐스팅 필요없음
Dog biggerDog = dogHospital.bigger(new Dog("멍멍이", 300));
System.out.println("biggerDog = " + biggerDog);
}
}
개 병원에 고양이를 전달하는 경우 컴파일 오류가 발생한다. 그리ㄷ고 다운캐스팅도 할 필요가 없어졌다.
이로 인해 타입 안정성 문제 (개 병원에 고양이 전달, 다운 캐스팅의 위험), 그리고 제네릭 도입문제
(어떤 타입의 매개변수가 들어올 수 있던 점, Object의 메서드만 사용 가능했던 점) 가 해결되었다.
제네릭 메서드
메서드에 제네릭을 적용할 수 있다.
package generic.ex4;
public class GenericMethod {
public static Object objMethod(Object obj) {
System.out.println("Object print: " + obj);
return obj;
}
public static <T> T genericMethod(T t) {
System.out.println("Generic print: " + t);
return t;
}
public static <T extends Number> T numberMethod(T t) {
System.out.println("bound print: " + t);
return t;
}
}
package generic.ex4;
public class MethodMain1 {
public static void main(String[] args) {
Integer i = 10;
Object object = GenericMethod.objMethod(i);
// 타입 인자(Type Argument) 명시적 전달
System.out.println("명시적 타입 인자 전달");
Integer result = GenericMethod.<Integer>genericMethod(i);
System.out.println("result = " + result);
Integer integerValue = GenericMethod.<Integer>numberMethod(10);
Double doubleValue = GenericMethod.<Double>numberMethod(20.5);
// 제네릭 메서드 타입추론
System.out.println("타입 추론");
Integer result1 = GenericMethod.genericMethod(i);
Integer integerValue2 = GenericMethod.numberMethod(10);
Double doubleValue3 = GenericMethod.numberMethod(20.5);
}
}
제네릭 메서드의 정의 : <T> T genericMethod(T t)
타입 인자 전달 시점 : 메서드를 호출할 때 [GenericMethod.<Integer>genericMethod(i)]
제네릭 메서드는 클래스 전체가 아닌 특정 메서드 단위로 제네릭을 도입할 때 사용한다.
호출하는 시점에서 타입 인자를 정하고 호출한다.
타입 매개변수 제한
제네릭 메서드에도 타입 매개변수를 제한할 수 있다. (extends)
extends Number를 사용함으로 Number의 자식 객체만 사용할 수 있다.
타입 추론
제네릭 메서드 역시 일일이 다이아몬드 (<>)를 사용하지 않아도 추론이 가능하다. 메서드에 전달되는 인자의 타입을 보고
추론할 수 있다. 또한 반환 타입이 Integer result라는 것도 알 수 있다.
인스턴스 메서드, static 메서드
class Box<T> {
T instanceMethod(T t) {..} // 가능
static T staticMethod(T t) {..} // 불가능
static <V> V staticMethod(V t) {..} // 가능
}
제네릭 메서드는 인스턴스 메서드와 static 메서드 둘다 적용 가능하다.
하지만 스태틱 메서드에서 제네릭 클래스의 타입 매개변수를 사용할 수 없다.
제네릭 타입은 생성시점에 타입이 정해지지만, static은 클래스 단위이기 때문에 제네릭 타입과 무관하다.
제네릭 타입과 제네릭 메서드의 우선순위
package generic.ex4;
import generic.animal.Animal;
public class ComplexBox<T extends Animal> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
//----------------------------------------------------------------------------
public <Z> Z printAndReturn(Z z) {
System.out.println("animal.className: " + animal.getClass().getName());
System.out.println("t.className:" + z.getClass().getName());
return z;
}
//----------------------------------------------------------------------------
public <T> T printAndReturn(T t) {
System.out.println("animal.className: " + animal.getClass().getName());
System.out.println("t.className:" + t.getClass().getName());
return t;
}
}
제네릭 타입도 T, 제네릭 메서드도 T일 경우, 어떤게 우선순위를 가질까?
제네릭 메서드가 우선순위를 가진다. 그렇지만 printAndReturn() 메서드의 T는 제네릭 타입의 T와 무관하다.
그렇기에 제네릭 메서드의 T는 Object 타입으로 취급되며, Animal의 메서드를 사용할 수 없다.
(그냥 처음부터 이름을 다르게 작성하면 된다.)
와일드카드
비제한 와일드카드
와일드 카드를 사용하면 제네릭을 조금 더 편리하게 사용할 수 있다.
// 제네릭 메서드
static <T> void printGenericV1(Box<T> box) {
System.out.println("T = " + box.get());
}
// wildcard 사용
static void printWildcardV1(Box<?> box) {
System.out.println("? = " + box.get());
}
와일드카드는 ?를 사용해 정의한다.
와일드카드는 제네릭 타입이나, 제네릭 메서드를 선언하는것이 아닌, 이미 만들어진 제네릭 타입을 활용할 때 사용한다.
두 메서드는 비슷한 기능을 하는 코드다. 하지만 와일드카드는 제네릭 타입이나 제네릭 메서드를 정의할 때 사용하는 것이 아닌
Box<Dog>, Box<Cat> 처럼 타입 인자가 정해진 제네릭 타입을 전달받아서 활용할 때 사용하는 것이다.
?는 모든 타입을 다 받을 수 있는 와일드카드라고 해서 비제한 와일드카드라고 한다.
printGenericV1은 타입 매개변수가 존재한다. 그리고 특정 시점에 타입 매개변수에 타입 인자를 전달해서 타입을 결정한다.
이 과정은 매우 복잡하다.
printWildcardV1은 일반적인 메서드에 사용할 수 있고, 단순히 매개변수로 제네릭 타입을 받을 수 있다. 복잡하지 않다.
제네릭 타입 혹은 메서드를 정의해야하는것이 아니라면 단순한 와일드카드를 사용하는것을 권장한다.
상한 와일드카드
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());
}
와일드카드에도 상한 제한을 둘 수 있다. (extends Animal)
box.get()를 통해서 꺼낼 수 있는 타입의 최대 부모는 Animal로 제한된다. 따라서 Animal 타입으로 조회 가능하다.
타입 매개변수가 꼭 필요한 경우
static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
T t = box.get();
System.out.println("이름 = " + t.getName());
return t;
}
static Animal printAndReturnWildcard(Box<? extends Animal> box) {
Animal animal = box.get();
System.out.println("이름 = " + animal.getName());
return animal;
}
와일드카드는 제네릭을 정의할 때 사용하는 것이 아니라고 했다. 타입 인자가 전달된 제네릭 타입을 활용할 때 사용한다.
위 printAndReturnGeneric() 메서드는 타입 매개변수를 정의했기 때문에 명확한 타입을 전달할 수 있다.
(dogBox 혹은 catBox)
반면 printAndReturnWildcard() 메서드는 리턴값이 Animal이다. 따라서 명확하게 타입을 리턴할 수 없다.
Animal animal = WildcardEx.printAndReturnWildcard(dogBox);
인자를 catBox가 되어도 컴파일오류가 나지 않는다. 명확하게 하기 위해선 다운캐스팅이 필요한데 권장하지 않는다.
메서드를 특정 시점에 변경하려면 제네릭 타입이나, 제네릭 메서드를 사용해야한다.
와일드 카드는 이미 만들어진 제네릭 타입을 활용할 때 사용한다!!
하한 와일드카드
public class WildcardMain2 {
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);
// 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));
}
}
와일드 카드는 상한뿐 아니라 하한도 설정 가능하다. (super Animal)
이렇게 되었을 때 Animal의 상위 타입만 입력받을 수 있따는 뜻이다. (Object 가능, 자기 자신 Animal 가능, 하위 Dog, Cat 불가)
타입 이레이저
제네릭은 자바 컴파일 단계에서만 사용되고, 컴파일 이후에는 제네릭의 정보가 삭제된다.
제네릭에 사용한 타입 매개변수가 모두 사라지는 것이다. 쉽게 말해 컴파일 전인 .java에는 제네릭의 타입 매개변수가 존재하지만,
컴파일 이후인 바이트코드 .class에는 타입 매개변수가 존재하지 않는다.
제네릭 타입에 Integer를 전달했다고 가정해보자.
컴파일 이전의 제네릭 타입은 <T> ➡️ <Integer>가 된다.
컴파일 이후 제네릭 타입은 <T>는 Object로 변환된다.
public class GenericBox {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
컴파일 이후엔 T는 Object로 변환되기 때문에, main.class에선 어떤 변화가 생기냐면
void main() {
GenericBox box = new GenericBox();
box.set(10);
Integer result = (Integer) box.get();
}
컴파일러가 Integer로 캐스팅하는 코드를 추가해준다.
값을 반환받는 부분이 Object로 받으면 안되기 때문에 컴파일러가 스스로 검증하고 추가해준 것이다.
타입 매개변수 제한일 경우엔 상한으로 변경된다. (Animal)
마찬가지로 제네릭 타입에 Dog를 전달했다면 위와같이 컴파일 전은 <Dog>, 컴파일 후는 Animal로 변환된다.
다운캐스팅 코드도 컴파일러가 추가해준다.
단순하게 생각하면 제네릭은 개발자가 직접 캐스팅하는 코드를 컴파일러가 대신 처리해주는 것이라고 생각하자.
컴파일 시점에만 존재하고, 런타임 시점에는 사라지기 때문에 이것을 타입 이레이저라고 한다.
타입 이레이저의 한계
컴파일 이후엔 제네릭의 타입 정보가 남아있지 않다고 했다. 따라서 런타임 시점에는 우리가 지정한 타입 정보가 모두 삭제된다.
class EraserBox<T> {
public boolean instanceCheck(Object param) {
return param instanceof T; // 오류
}
public void create() {
return new T(); // 오류
}
}
런타임 시점에 T는 Object가 된다. 따라서 instanceof는 항상 Object와 비교하게 된다.
또한 new T() 도 new Object() 가 되어버린다. 이는 개발자가 의도한대로 작동하지 않는 것이 된다.
따라서 타입 매개변수에 instanceof와 new를 허용하지 않는다.
'Java' 카테고리의 다른 글
컬렉션 프레임워크 - LinkedList (1) | 2025.01.03 |
---|---|
컬렉션 프레임워크 - ArrayList (2) | 2024.12.27 |
제네릭 (1) (1) | 2024.12.12 |
예외 처리 (3) (0) | 2024.12.11 |
예외 처리 (2) (0) | 2024.12.10 |