제네릭이란?
제네릭은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미합니다.
흔히 ArrayList, LinkedList를 생성할 때 사용해 본 경험이 있으실 겁니다.
//타입 + 배열 자료형
int[] arr = new int[3];
//리스트 자료형 + <타입>
ArrayList<Integer> list1 = new ArrayList<Integer>();
ArrayList<String> list2 = new ArrayList<String>();
ArrayList<Double> list3 = new ArrayList<Double>();
이처럼 클래스 안에 < > 로 되어 있는 부분이 바로 제네릭입니다. 배열의 타입을 지정하듯이 리스트 자료형 같은 래퍼 클래스나 메서드에서 사용할 데이터 타입(type)을 매개변수(parameter) 주듯이 외부에서 지정하는 이른바 타입을 변수화 한 기능이라고 생각하시면 됩니다.
제네릭의 장점
1. 제네릭을 사용하면 잘못된 타입이 들어올 수 있는 것을 컴파일 단계에서 방지할 수 있습니다.
2. 클래스 외부에서 타입을 지정해주기 때문에 따로 타입을 체크하고 변환해 줄 필요가 없습니다.
3. 비슷한 기능을 지원하는 경우 코드의 재사용성이 높아집니다.
이렇게 글만 보면 이해가 안되실 수 있습니다. 그래서 예시 코드를 작성해 보겠습니다.
예시 1.
//1번 예시
class Apple {}
class Banana {}
class FruitBox {
// 모든 클래스 타입을 받기 위해 최고 조상인 Object 타입으로 설정
private Object[] fruit;
public FruitBox(Object[] fruit) {
this.fruit = fruit;
}
public Object getFruit(int index) {
return fruit[index];
}
}
public static void main(String[] args) {
Apple[] arr = {
new Apple(),
new Apple()
};
FruitBox box = new FruitBox(arr);
Apple apple = (Apple) box.getFruit(0);
Banana banana = (Banana) box.getFruit(1);
}
이렇게 코드를 실행한다면 ClassCastException 런타임 에러가 발생합니다. Apple 객체 타입을 FruitBox에 넣었는데 Banana로 형변환해서 가져오려고 했기 때문에 발생한 현상입니다. 따라서 제네릭을 사용한다면 컴파일 에러가 발생하여이런 실수도 사전에 방지할 수 있게 됩니다.
예시 2.
Apple[] arr = { new Apple(), new Apple(), new Apple() };
FruitBox box = new FruitBox(arr);
// 가져온 타입이 Object 타입이기 때문에 일일히 다운캐스팅을 해야함 - 쓸데없는 성능 낭비
Apple apple1 = (Apple) box.getFruit(0);
Apple apple2 = (Apple) box.getFruit(1);
Apple apple3 = (Apple) box.getFruit(2);
위 코드에서 Apple 배열을 FruitBox의 Object 배열 객체에 넣었으므로 배열을 가져올땐 이처럼 다운캐스팅을 해야 했습니다. 하지만 제네릭은 미리 타입을 지정 & 제한해 주기 때문에 형 변환의 번거로움을 줄일 수 있으며, 타입 검사에 들어가는 메모리를 줄일 수 있고, 가독성도 좋아집니다.
class FruitBox<T> {
private T[] fruit;
public FruitBox(T[] fruit) {
this.fruit = fruit;
}
public T getFruit(int index) {
return fruit[index];
}
}
public static void main(String[] args) {
Apple[] arr = {
new Apple(),
new Apple()
};
FruitBox box = new FruitBox(arr);
Apple apple = box.getFruit(0);
Apple apple = box.getFruit(1);
Apple apple = box.getFruit(2);
}
제네릭 사용 방법
- 클래스, 인터페이스 또는 메서드에 선언할 수 있습니다.
- 동시에 여러 타입을 선언 할 수 있습니다.
- 와일드카드를 이용하여 타입에 대해 유연한 처리를 가능하게 해 줍니다.
- 제네릭 선언 및 정의 시 타입 상속 관계를 지정할 수 있습니다.
보통 제네릭은 아래 표의 타입들이 많이 쓰입니다.
타입 | 설명 |
<T> | Type |
<E> | Element |
<K> | Key |
<V> | Value |
<N> | Number |
1. 클래스 및 인터페이스 선언
public class Car<T> { }
public interface CarInterface <T>{ }
기본적으로 제네릭 타입의 클래스나 인터페이스의 경우 위와 같이 선언합니다.
2. 멀티 타입 파라미터 선언
public class Car<T, K> { }
public interface CarInterface <T, K>{ }
//HashMap은 아래와 같이 선언이 되어있을 것입니다.
public class HashMap <K,V>{ }
이렇듯 내부에서 사용할 데이터 타입을 외부에서 지정할 수 있습니다. 이렇게 선언한 제네릭 클래스를 사용하려면 객체를 생성해야 하는데 이때 구체적인 타입을 명시해 주어야 하는 것입니다.
public class Main {
public static void main(String[] args) {
Car<String,Integer> car = new Car<String, Integer>();
}
}
이 예시라면 T는 String, K는 Integer가 됩니다. 이때 주의해야 할 점은 타입 파라미터로는 Integer, String, Double과 같은 Wrapper클래스와 사용자가 정의한 클래스 등 참조 타입만 들어갈 수 있습니다.
3. 제네릭 메서드
class Car<T> {
// 제네릭 타입 변수
private T name;
//제네릭 파라미터 메서드
void set(T name){
this.name = name;
}
//제네릭 타입 반환 메서드
T get(){
return name;
}
//제네릭 메서드
<T> T genericMethod(T o){
return o;
}
}
public class Main {
public static void main(String[] args) {
Car<String> car = new Car<String>();
Car<Integer> car2 = new Car<Integer>();
car.set("KIA");
System.out.println(car.get());
System.out.println(car.genericMethod("SSANGYOUNG").getClass().getName());
System.out.println(car2.genericMethod(3).getClass().getName());
System.out.println(car.genericMethod(3).getClass().getName());
System.out.println(car.get().getClass().getName());
}
}
//출력 결과
KIA
java.lang.String
java.lang.Integer
java.lang.Integer
java.lang.String
제네릭 메서드를 호출할 때 직접 타입 파라미터를 다르게 지정해 주거나, 다른 타입의 데이터를 매개변수에 넘긴다면 독립적인 타입을 가진 메서드를 운용하게 됩니다. 하나의 메서드로 String, Integer 외에도 모든 타입에 대응할 수 있게, 즉, 하나의 정적 메서드로 선언하는 것이 필요할 때 제네릭 메서드를 사용합니다.
주의사항.
//1번코드 : 컴파일 에러
public static T addAge(int n) {
// ...
}
//2번코드 : 가능
public static <T> T addAge(T age) {
// ...
}
1번 코드는 잘못 되었습니다. 그 이유는 클래스 레벨의 제네릭 타입은 static 멤버에서 직접 사용될 수 없습니다. 왜냐하면 static 멤버는 클래스가 로드될 때 초기화하고, 그 시점에서는 제네릭 타입이 구체화되지 않았기 때문입니다.
하지만 2번 코드(제네릭 메서드)는 메서드 자체에 타입 매개변수를 선언합니다. 이 경우, 메서드가 호출될 때 마다 타입 매개변수에 실제 타입이 지정되어, 메서드의 실행이 이루어집니다.
그 예시로 public static <T> T addAge(T age)에서 <T>는 메서드 호출 시에 결정되는 타입 매개변수입니다. 따라서, 이 메서드는 static으로 선언되어도 문제가 없으며, 각각의 메서드 호출은 다른 T 타입 인자를 받을 수 있습니다. 메서드의 호출마다 각기 다른 타입의 인수를 처리할 수 있기 때문에, static이면서도 유연하게 타입을 다룰 수 있습니다.
public static <T> T addAge(T age) 메서드가 가능한 이유는, 이 메서드가 제네릭 타입 매개변수를 포함하고 있고, 이 타입 매개변수는 메서드가 호출될 때마다 실제 타입으로 대체되기 때문입니다. static이어도 각 호출마다 다른 타입 매개변수로 작동할 수 있으므로, 이는 타입 안전성을 보장하면서도 유연성을 제공합니다.
제네릭 타입 한정
제네릭을 사용함으로써 클래스의 타입을 컴파일 시점에 타입 안정성을 확보하는것은 좋지만 문제는 너무 자유롭다는것이 단점입니다. 예를들어 계산기 클래스가 있다고 해보겠습니다. 정수, 실수 구분없이 모두 받을 수 있기 때문에 제네릭 클래스로 만들어 주었습니다. 하지만 < T > 로 지정하게 된다면 숫자뿐만 아니라 String이나 다른 클래스도 대입이 가능한 것이 문제입니다.
class Calculator<T> {
void add(T a, T b) {}
void min(T a, T b) {}
void mul(T a, T b) {}
void div(T a, T b) {}
}
public class Main {
public static void main(String[] args) {
// 제네릭에 아무 타입이나 모두 할당이 가능
Calculator<Number> cal1 = new Calculator<>();
Calculator<Object> cal2 = new Calculator<>();
Calculator<String> cal3 = new Calculator<>();
Calculator<Main> cal4 = new Calculator<>();
}
}
따라서 개발자의 의도대로 원하는 자료형만 들어오도록, 다른 클래스의 자료형이 들어오지 않도록 나온것이 제한된 타입 매개변수 입니다.
class Calculator<T extends Number> {
void add(T a, T b) {}
void min(T a, T b) {}
void mul(T a, T b) {}
void div(T a, T b) {}
}
public class Main {
public static void main(String[] args) {
// 제네릭에 Number 클래스만 받도록 제한
Calculator<Number> cal1 = new Calculator<>();
Calculator<Integer> cal2 = new Calculator<>();
Calculator<Double> cal3 = new Calculator<>();
// Number 이외의 클래스들은 오류
Calculator<Object> cal4 = new Calculator<>();
Calculator<String> cal5 = new Calculator<>();
Calculator<Main> cal6 = new Calculator<>();
}
}
제네릭 캐스팅 문제
제네릭은 전달받은 타입으로만 서로 캐스팅이 가능하기 때문에 형변환이 불가능합니다.
// 배열은 OK
Object[] arr = new Integer[1];
// 제네릭은 ERROR
List<Object> list = new ArrayList<Integer>();
그 이유는 제네릭 객체에 요소를 넣거나 가져올때, 캐스팅 문제로 애로사항이 발생하기 때문입니다.
public static void main(String[] args) {
Apple[] integers = new Apple[]{
new Apple(),
new Apple(),
new Apple(),
};
print(integers);
}
public static void print(Fruit[] arr) {
for (Object e : arr) {
System.out.println(e);
}
}
이렇게 배열을 Object로 출력은 가능하지만 제네릭으로 바꾸면 컴파일 에러가 발생합니다.
public static void main(String[] args) {
List<Integer> lists = new ArrayList<>(Arrays.asList(1, 2, 3));
print(lists); // ! 컴파일 에러 발생
}
public static void print(List<Object> list) {
for (Object e : list) {
System.out.println(e);
}
}
이렇게 제네릭으로 바꿔도 안되는 이유는 배열같은 경우는 파라미터가 넘어갈 때 Integer[] 배열 타입이 Object[] 배열로 업캐스팅이 되어 문제가 없지만, 제네릭 같은 경우 타입 파라미터가 오로지 똑같은 타입만 받기 때문에 다형성을 이용할 수 없어서 문제가 발생합니다.
제네릭 와일드 카드
따라서 제네릭 간의 형변환을 성립되게 하기 위해서는 제네릭의 와일드 카드 ? 문법을 이용해야 합니다.
1. <? extends T> : 상위 클래스 제한
타입 파라미터를 대치하는 구체적인 타입으로 상위 타입이나 상위 타입의 하위 타입만 올 수 있습니다.
2. <? super T> :하위 클래스 제한
타입 파라미터를 대치하는 구체적인 타입으로 하위 타입이나 하위 타입의 상위 타입만 올 수 있습니다.
3. <?> : 제한 없음
타입 파라미터를 대치하는 구체적인 타입으로 모든 클래스나 인터페이스 타입이 올 수 있습니다.
public class Main {
public static void main(String[] args) {
List<? extends Object> list = new ArrayList<String>();
List<? super String> list2 = new ArrayList<Object>();
List<?> list3 = new ArrayList<>();
}
}
그러면 어떻게 사용해야 할까요?
public class Noodle {}
public class Pasta extends Noodle{}
public class Category<T> {
private T t;
public void set(T t){
this.t = t;
}
public T get(){
return t;
}
}
public class CategoryHelper {
public void popNoodle(Category<? extends Noodle> category){
Noodle noodle = category.get(); //가져오는건 가능
category.set(new Noodle()); //저장은 불가능 컴파일 에러
}
public void pushNoodle(Category<? super Noodle > category){
category.set(new Noodle()); //저장은 가능
Noodle noodle = category.get(); //가져오는건 불가능 컴파일 에러
}
}
extends에서 가져오는게 가능한 이유는 Noodle의 하위 타입이 어떤게 들어올지 모르고 최상위 타입은 Noodle까지 들어오게 됩니다. 따라서 최상단인 Noodle타입으로 안전하게 가져올 수 있습니다.
하지만 만약 Noodle의 하위객체로 만든 Pasta를 만들면 그 상위 타입인 Noodle 객체를 넣은순 없습니다. 그러므로 저장은 불가능합니다.
반대로 super에서 저장이 가능한 이유는 제일 하위 타입이 Noodle로 정해졌기 때문에 Noodle타입으로 저장이 가능합니다.
하지만 조회가 불가능한 이유는 내가 만든 인스턴스가 Noodle의 상위타입일수도 있기 때문에 조회는 불가능합니다.
그렇다면 언제 무엇을 써야할까요?
PECS ( Producer - Extends, Consumer - Super)
Effective Java 3/E 에서는 PECS라는 공식이 있습니다. 말그대로 생산을 할때는 extends를, 소비를 할때는 super를 사용하는 공식입니다.
public class NoodleCategory <E>{
private List<E> list = new ArrayList<>();
public void pushAll(Collection<? extends E> box){
for(E e: box){
list.add(e);
}
}
public void popAll(Collection<? super E> box){
box.addAll(list);
list.clear();
}
}
이렇게 extends 로 상한제한을 하면 타입을 안전하게 가져올 수 있기 때문에 이를 인스턴스에 넣어 원소를 생성할 수 있습니다. 이럴때는 extends를 사용하는것이 적절합니다.
그리고 super는 하한제한을 하면 타입에 안전하게 값을 넣을 수 있기 때문에 인스턴스 list의 원소를 소비해서 box에 원소를 추가할 수 있습니다. 이렇게 소비를 하는 곳에서는 super를 사용하는 것이 적절합니다.
'CS > Java' 카테고리의 다른 글
[Java] 추상클래스와 인터페이스의 차이 (0) | 2024.04.19 |
---|---|
[Java] 오버로딩(Overloading)과 오버라이딩(Overriding) 차이 (0) | 2024.04.14 |
[Java] Garbage Collection(GC) 이란? (0) | 2024.03.01 |
[Java] Java 메모리 영역 (0) | 2024.02.19 |
[Java] Java 동작 과정 (0) | 2024.02.15 |