1. 코드는 같은데 타입만 다른 여러 클래스를 하나로 묶어서 재사용한다.
2. 타입 안정성 : 모든 타입이 가능하게 하려 설정한 Object로 생길 수 있는 런타임 오류를 제네릭으로 컴파일이 확인하게 한다.
3. 캐스팅 제거 : 제네릭은 컴파일 시 타입을 확정하기 때문에 Object 가 아니라 그 타입으로 반환이 되기 때문에 캐스팅을 일일이 하지 않아도 된다.
상황 🚨
: "값을 받아서 저장하고 다시 반환하는 기능"의 클래스를 만들었다. 그리고 이 기능을 다른 타입의 값으로도 사용하려고 한다.
이렇게 되면 클래스 = 타입 이기 때문에 다른 타입의 값으로 이 기능을 사용하려면 그 타입의 클래스를 만들어서 똑같은 코드로 설정해야한다.
그런데 코드는 다 같은데 타입만 다르다고 계속 타입별로 클래스를 생성하는건 비효율적이다. 그래서 하나의 클래스를 가지고 재사용하는 식으로 코드를 만들려 한다.
이때 처음 생각한게 Object 타입 ‼️
최상위 클래스라서 모든 타입을 담을 수 있다. 하지만 값을 저장할 때는 괜찮지만 값을 꺼낼 때 Object 타입으로 반환이 되기 때문에 직접 캐스팅을 해야하는 단점이 있다. 이때 캐스팅을 잘 못했을 경우, 컴파일러는 타입이 적혀있지 않은 변수는 타입을 알지 못하기 때문에 나중에 런타임 오류가 발생한다.
package generic.ex1;
public class ObjectBox {
private Object value;
public void set(Object object) {
this.value = value;
}
public Object get() {
return value;
}
}
package generic.ex1;
public class BoxMain2 {
public static void main(String[] args) { // Object 타입으로 값을 저장하고 꺼낼 때 자식 타입으로 꺼내는 코드
// Integer 타입
ObjectBox integerBox = new ObjectBox();
integerBox.set(10);
Integer integer = (Integer) integerBox.get();
System.out.println("integer = " + integer);
// String 타입
ObjectBox stringBox = new ObjectBox();
stringBox.set("hello");
String str = (String) stringBox.get();
System.out.println("str = "+str);
// 잘못된 타입의 인수 전달 시 (Integer 타입에 String 타입도 포함시켜버림)
integerBox.set("문자100"); // 값을 저장할 때는 Object 로 둘다 가능하지만..!
Integer result = (Integer) integerBox.get(); // 꺼낼 때 문제 발생 🚨
System.out.println("result = "+result);
}
}
📌 제네릭 핵심 : 사용할 타입을 미리 결정하지 않는다.
package generic.ex1;
public class GenericBox <T>{
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
➡️ 정의할 때 타입 매개변수를 사용하는 클래스나 인터페이스.
클래스를 정의할 때 내부에서 사용할 타입을 정하는 것이 아니라, 해당 클래스를 실제 사용하는 생성 시점에 내부에서 사용할 타입을 결정한다.
➡️ 제네릭은 타입을 바꾸며 사용하기 때문에 타입을 주고 받아야 한다. 메서드의 매개변수와 비슷하게 타입 매개변수에 타입 인자를 전달한다.
➡️ 일반적으로 대문자를 사용하며 사용 용도에 맞는 단어의 첫글자를 사용함.
➡️ <T,V> 같이 한번에 여러 타입 매개변수를 선언할 수도 있음.
➡️ <> 를 사용해서 타입을 지정하지 않은 제네릭 타입은 Object 로 실행된다. (raw type)
➡️ 인자는 매개변수를 통해서 전달되는 값을 말한다. 이 인자는 어떤 타입의 값으로 이걸 통해서 제네릭 타입에서 사용할 타입을 결정한다.
➡️ 타입 매개변수를 통해 받는 타입인자(Integer, String 등)
핵심 : 어디에서 T를 선언했고 어디에서 T를 사용하는지
결국 둘 다 T 타입을 사용한다는 점만 본다면 맞다. 하지만 같은 것은 아니다.
<T> : 매개변수에 올 수 없다. 선언부에만 사용한다.
(도시락) GenericBox<T> : 제네릭 클래스에 T라는 타입을 넣어둔 구체적인 타입으로 이미 타입이 정해진 것.
(재료) T : 이미 선언된 타입 매개변수를 사용하는 것, 타입 그 자체로 아직 타입이 정해진게 아니고 자리를 표시하는 것 (어떤 타입도 다 들어올 수 있음)
class GenericBox<T>
다이아몬드 기호 <> 안에 타입 매개변수를 정의하면 된다. (<> 가 있으면 선언이구나, 선언된거구나 하면 됨)
여기서 주의해야할 부분은 타입 매개변수는 그 클래스의 타입을 말해주는게 아니다!!!! 그냥 타입을 담는 박스라는 것을 명심하자.
GenericBox<Integer> box = new GenericBox<>();
box.set(10); // ⭕️
box.set("제네릭10"); // ❌
사용하는 쪽에서 제네릭 타입을 생성하면서 <> 안에 원하는 타입을 정의하면 제네릭 타입은 그 타입의 클래스로 변경된다. 이렇게 설정하면 해당 클래스의 메서드를 호출할 때 값은 설정한 타입의 값만 허락되게된다. 타입 안전성이 높아짐
class Box<T> {
private T value;
public Box(T value) { this.value = value; }
public T get() { return value; }
}
// 제네릭 메서드
static <T> void printBox(Box<T> box) {
System.out.println(box.get());
}
Box<String> stringBox = new Box<>("Hello");
printBox(stringBox);
printBox(new Box<>("Hello"));
➡️ 이러면 메서드 printBox(Box<T> box) 는 자동으로 String 으로 추론된다. 즉 받는 쪽 메서드의 타입 매개변수 T 는 호출 시점에서 자동으로 결정됨.
즉, 두 코드 다 결국 같은 동작을 한다. 차이점은 단지 중간 변수 선언 유무뿐이고, 타입 추론과 실행 결과는 동일하다.
T는 기본이 Object라고 했다. 이 T를 내가 원하는 타입들만 가능하도록 다형성을 통해서 제한할 수 있다.
package generic.ex2;
import generic.animal.Animal;
import generic.animal.Cat;
import generic.animal.Dog;
public class AnimalMain1 {
public static void main(String[] args) {
Animal animal = new Animal("동물", 0);
Dog dog = new Dog("가나디", 100);
Cat cat = new Cat("야옹이", 50);
Box<Dog> dogBox = new Box<>();
dogBox.set(dog);
Dog findDog = dogBox.get();
System.out.println("findDog = "+ findDog);
Box<Cat> catBox = new Box<>();
catBox.set(cat);
Cat findCat = catBox.get();
System.out.println("findCat = " + findCat);
Box<Animal> animalBox = new Box<>();
animalBox.set(animal);
Animal findAnimal = animalBox.get();
System.out.println("findAnimal = "+findAnimal);
}
}
package generic.animal;
public class Animal {
private String name;
private int size;
public Animal(String name, int size) {
this.name = name;
this.size = size;
}
public String getName() {
return name;
}
public int getSize() {
return size;
}
public void sound() {
System.out.println("동물 울음 소리");
}
@Override
public String toString() {
return "Animal{" +
"name='" + name + '\'' +
", size=" + size +
'}';
}
}
package generic.animal;
public class Cat extends Animal {
public Cat(String name, int size) {
super(name, size);
}
@Override
public void sound() {
System.out.println("냐옹");
}
}
Animal 이라는 상위 타입에 Dog, Cat 이라는 하위타입을 만들었다. 그리고 제네릭 타입 Box 를 만들고 각각 하위타입을 타입 매개변수로 선언해서 인스턴스를 생성했다.
(Animal 을 인터페이스로는 할 수 없다. 공통필드와 공통 로직 구현, 생성자 모두 불가능하기 때문이다. 이 코드는 Animal 클래스에 공통 로직을 만들고 사용할 생각임)
메서드를 호출할 때 Box<타입 매개변수> 인 것만 값을 보낼 수 있게 된다. 제네릭으로 캐스팅하지 않아도 반환값이 선언한 타입으로 반환된다.
상위타입을 타입 매개변수로 하면, 인수로 그 타입의 하위타입도 전달이 가능하다. ➡️ 다형성
⚠️ 하지만 이럴 경우에 사용할 수 있는 기능은 상위타입에 선언된 메서드만 호출 가능하다. 하위 타입의 고유 메서드는 호출 불가능. 컴파일러는 현재 변수 타입이 보장하는 메서드만 호출을 허락하기 때문이다.
이렇게 다형성을 사용하는 방법으로 중복 코드를 해결했지만 호출할 때 타입 안정성이 보장되지 않아 예외가 발생할 가능성이 있다.
(반환 타입이 Animal 이라서 다운 캐스팅을 해야한다. 이 떄 다운 캐스팅을 잘못하면 런타임 에러 발생 🚨)
제네릭의 장점(코드 재사용 줄임, 타입 안정성 있음) 으로 제한해보자.
package generic.ex3;
import generic.animal.Animal;
public class HospitalV2<T extends Animal> {
/* 제네릭만 사용할 경우 T 만으로는 컴파일러가 타입을 알 수가 없다. 이럴 경우 컴파일러는 Object 타입으로 검사.
아래의 getName(), getSize() 같은 메서드는 Object 에 선언되어있지 않기 때문에 컴파일 오류가 난다. */
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> 으로 제네릭에 Animal 을 상속 받는 타입만 타입 매개변수로 선언될 수 있다.
➡️ 제네릭 타입이라 이미 호출할 때 타입이 정해진 걸 컴파일러가 알 수 있다. 그래서 메서드도 하위 타입의 메서드를 사용할 수 있다.
➡️ 호출 할 때도, 선언해둔 타입이 아니면 컴파일에러로 타입 안전성도 지킬 수 있다.
package generic.ex3;
public class AnimalHospitalMainV1 {
public static void main(String[] args) {
HospitalV2<Dog> dogHospital = new HospitalV2<>(); // ✅ 제네릭과 다형성을 함께 사용
HospitalV2<Cat> catHospital = new HospitalV2<>();
Dog dog = new Dog("멍멍이1", 100);
Cat cat = new Cat("냐옹이1", 300);
dogHospital.set(dog);
dogHospital.checkup();
catHospital.set(cat);
catHospital.checkup();
}
}
제네릭 메서드는 해당 메서드의 타입이 호출 시에 결정되는 메서드로 해당 메서드의 타입은 이 메서드 안에서만 적용된다.
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;
}
}
제네릭 타입과 제네릭 메서드는 다른 개념이다 ‼️
➡️ numberMethod(T t) 메서드에 <T extends Animal> 의 타입 매개변수를 선언했다. 이 메서드에서는 이제 Animal과 그 하위 타입이 사용된다. 라고 컴파일러가 인식.
➡️ 제네릭 타입은 생성될 때, 제네릭 메서드는 호출될 때 타입 매개변수가 결정된다. 또 제네릭 메서드는 호출할 때마다 타입 매개변수가 새로 결정된다.
➡️ 타입 매개변수에 제한을 해두면 Object 와 달리 하위 타입의 메서드를 사용할 수 있다.
삼항 연산자 💡
조건 ? 참 : 거짓➡️ 조건이 참이면 참의 변수를 반환, 거짓이면 거짓의 변수를 반환animal.getSize() > animal.getSize() ? animal : target
제네릭 메서드는 인스턴스, static 에 모두 적용할 수 있다. 제네릭은 컴파일 시점에 처리되고, 런타임에는 타입 정보가 사라진다. (컴파일러가 런타임 때 자동으로 캐스팅해줌)
주의⚠️
제네릭 타입에서 static 메서드는 제네릭 타입 매개변수 T를 사용할 수 없다. 제네릭 타입 = 런타임 시 결정, static = 클래스 레벨로 컴파일 시점에 결정이 되어있어야 함
하지만, static 메서드가 제네릭 메서드가 되는 건 가능하다. 왜냐하면 제네릭 메서드는 호출 시 컴파일러가 타입을 추론해서 결정하기 때문에 객체와 아무 관련이 없다.
package generic.ex4;
public class MethodMain1 {
public static void main(String[] args) {
Integer i = 10; // 이 코드를 보고 i 가 Integer 인걸 컴파일러가 추론 가능하다.
Object object = GenericMethod.objMethod(i);
System.out.println("명시적 타입 인자 전달");
Integer result = GenericMethod.<Integer>genericMethod(i);
Integer integerValue = GenericMethod.<Integer>numberMethod(10);
Double doubleValue = GenericMethod.<Double>numberMethod(20.0);
System.out.println("타입 추론");
Integer result1 = GenericMethod.genericMethod(i);
Integer integerValue1 = GenericMethod.numberMethod(10);
Double doubleValue1 = GenericMethod.numberMethod(20.0);
}
}
package generic.ex4;
import generic.animal.Animal;
public class ComplexBox <T extends Animal> {
private T animal;
public void set(T t) {
this.animal = animal;
}
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 처럼 같을 경우 제네릭 메서드에서는 어떤 타입을 우선시 할까?
✔️ 정답 : 제네릭 타입보다 제네릭 메서드가 높은 우선순위를 가진다.
(이렇게 이름이 겹치면 둘 중 하나를 다른 이름으로 변경하는 것이 좋다.)
제네릭 타입을 조금 더 편리하게 사용할 수 있는 것이다. 와이드 카드는 컴퓨터 프로그래밍에서 하나 이상의 문자들을 상징하는 특수문자를 뜻하며 여러 타입이 들어올 수 있는 자리 라는 뜻이다.
와일드 카드는 클래스에 선언할 수 없다 ‼️
package generic.ex5;
import generic.animal.Animal;
import generic.animal.Cat;
import generic.animal.Dog;
public class WildcardMain1 {
public static void main(String[] args) {
Box<Object> objectBox = new Box<>();
Box<Dog> dogBox = new Box<>();
Box<Cat> catBox = new Box<>();
dogBox.set(new Dog("멍멍이",100));
WildcardEx.printGenericV1(dogBox);
WildcardEx.printWildcardV1(dogBox);
WildcardEx.printGenericV2(dogBox);
WildcardEx.printWildcardV2(dogBox);
Dog dog = WildcardEx.printAndReturnGeneric(dogBox);
Animal animal = WildcardEx.printAndReturnWildcard(dogBox);
}
}
➡️ 여러 메서드에 dogBox를 전달했다.
package generic.ex5;
import generic.animal.Animal;
public class WildcardEx {
static <T> void printGenericV1(Box<T> box) {
System.out.println("T = "+box.get());
}
// ✅ 와일드 카드
static void printWildcardV1(Box<?> box) {
System.out.println("? = "+box.get());
}
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());
}
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;
}
}
printGenericV1()
: Box<T> 라는 제네릭 타입을 생성할 때 정의한 타입을 가져온다는 뜻이다.
printWildcardV1()
: Box<?> 를 타입 매개변수로 가진다. <?>에서 ? 는 어떤 타입이든 다 가능하다는 표시다. 어떤 타입으로 생성했는지 모르지만 Box<T> 라는 제네릭 클래스로 생성한 그 타입의 인자를 가져온다는 뜻이다.
printGenericV2()
: static <T extends Animal> void printGenericV2(Box<T> box) 로 메서드에서 Animal과 그 하위 타입만 사용한다고 선언한 후, Box<T> (= T extends Animal) 타입의 인자를 받아온다.
printWildcardV2()
: (Box<? extends Animal> box) , 어떤 타입 인지 확실히 알지 못하지만‼️ (컴파일러가) Animal 과 그 하위 타입만 인자로 받을 수 있다고 설정함.
☝️ 제네릭은 컴파일러의 시점으로 바라봐야 한다.
컴파일러가<T>라고 적힌 걸 보면 생성 시 선언한 타입을 보고 그게 어떤 타입인지 정확히 알고 실행하지만,<?>이면 "타입을 모르겠다" 는 의미로 타입 정보를 이용한 모든 체크를 포기하고 가능한 최소한의 안전한 동작만 허용한다.
그 타입만 vs 어떤 타입이든 그냥 돌려
static <?> void print(Box<?> box) {...}
➡️ ? 만 선언된 와일드 카드를 말함, 어떤 타입이라도 다 가능하다.
static void printWildcardV2(Box<? extends Animal> box) {
Animal animal = box.get();
System.out.println("이름 = "+animal.getName());
}
➡️ ? 를 extends 로 상한 타입을 걸어두는 것. 그 상한 타입과 상한 타입의 하위 타입만 사용가능함.
static void writeBox(Box<? super Animal> box) {
box.set(new Dog("멍멍이", 100));
}
➡️ super 로 제한을 해서 Animal 타입을 포함한 그 상위 타입만 입력받을 수 있다는 뜻이다.
와일드 카드는 제네릭을 정의할 때 사용하는 게 아니다. 타입 인자가 전달된 제네릭 타입을 활용할 때 사용한다.
제네릭 메서드를 사용해야 하는 경우 (꾸며져 있는 박스)
: 메서드 내부에서 T의 기능을 사용해야 할 때, 정확한 타입으로 알아야 하는 작업을 할 때,
(메서드의 타입들을 특정시점에 변경하려면 제네릭 타입이나 제네릭 메서드를 사용해야 한다.)
와일드 카드를 사용하는게 더 좋은 경우 (평범하게 담는 역할만 하는 박스)
: 메서드가 타입의 기능을 쓰지 않고, 담는 역할/꺼내는 역할만 할 때 사용. 그래서 재사용도가 높아진다. 대신 타입 안정성을 더 엄격하게 제한 (안전하지 않은 동작 제한)
제네릭은 자바 컴파일 단계에서만 사용되고, 컴파일 이후에는 제네릭 정보가 삭제된다. .class 인 바이트 코드에서는 타입 매개변수가 존재하지 않는다.
컴파일 후에는 제한이 없을 경우에는 Object, 제한이 있을 경우에는 제한한 타입으로 코드를 변경한다. 런타임 시점에서는 반환 받는 부분을 변경된 타입으로 하면 안되기 때문에 자바 컴파일러가 자동으로 캐스팅하는 코드를 넣어준다.
중요 : 타입 이레이저는 Object로 바꾼다 가 아니라 T라는 타입 정보를 없앤다는 뜻이다.
⚠️ 한계 ⚠️
컴파일 후에 제네릭 타입의 정보가 존재하지 않기 때문에 런타임에 타입을 활용하는 instanceof 와 new T() 는 사용할 수 없다. 왜냐하면 제네릭은 컴파일 개념이기 때문.
Box<String> box = new Box<>();
String s = box.get();
Box box = new Box();
Object tmp = box.get(); // T는 사라지고 Object
String s = (String) tmp; // 컴파일러가 넣어준 캐스팅
instanceof, new T(): 캐스팅과 instanceof 는 완전히 다른 개념이다.
캐스팅 : 타입을 검사하는 게 아니라, 타입을 강제로 변환하는 명령.
instanceof : 런타임 타입이 무엇인지 검사하는 기능
그래서 컴파일러가 T는 타입 변수일 뿐이라서 런타임에 고정되지 않는데? instanceof 는 런타임에 타입을 비교하는 건데 그러면 안되겠네? 하고 컴파일 에러를 발생시킴.