자바의 제네릭(Generic)은 다양한 타입의 객체를 다루는 메서드나 컬렉션 클래스에 컴파일 시 컴파일 체크를 해주는 기능을 제공해 줍니다. 컴파일 시에 타입 체크를 해주기 때문에 객체의 타입 안정성을 높이고 형변환을 할 필요가 없어 편리합니다.
즉, 타입 안정성을 제공해 주기 때문에 의도하지 않은 타입의 객체가 저장되는 것을 방지하고, 저장된 객체를 꺼내올 때 잘못 형변환 되는 것을 막아줍니다.
이번 포스팅은 자바의 제네릭에 관한 내용을 폭넓게 다뤘습니다. 제네릭 자체가 내용이 많다 보니 이해하기가 어려웠는데 이번 포스팅을 보고 도움이 되길 바랍니다!!
제네릭 클래스 선언
제네릭 클래스는 아래와 같이 선언하면 됩니다.
public class Box<T> {
T item;
void setItem(T item) {
this.item = item;
}
T getItem() {
return item;
}
}
- T: 타입 변수/문자 (Type variable), 다른 알파벳 사용 가능 (K, E, V 등)
- Box: 원시 타입(Raw type)
- Box <T>: 제네릭 클래스, 'T의 Box'와 'T Box'로 읽습니다.
제네릭 클래스 사용법
원하는 타입을 꺽쇠 < > 안에 작성하여 객체를 생성하면 됩니다. 아래 코드를 통해 확인해 보겠습니다.
public class Main {
public static void main(String[] args) {
Box<String> box = new Box<String>();
box.setItem("String Box Item");
String strBox = box.getItem();
System.out.println(strBox); // 스트링 String Box Item 출력
Box<Integer> box2 = new Box<Integer>();
box2.setItem(1);
int integerBox = box2.getItem();
System.out.println(integerBox); // 숫자 1 출력
}
}
- Box <String>, Box<Integer>: 제네릭 타입 호출
- String, Integer: 매개변수화된 타입 (Parameterized type)
또한 아래와 같이 다른 클래스의 타입도 매개변수화된 타입으로 동작할 수 있습니다. 주의할 점은 제네릭을 사용할 때 Box<String> box = new Box <String>(); 이렇게 두 개의 < > 안에는 무조건 같은 타입의 객체만 작성할 수 있습니다. (JDK 1.7부터는 마지막 < >에 타입을 생략할 수 있습니다. 즉, Box box = new Box <>();)
class Paper{}
Box<Paper> paperBox = new Box<Paper>(); // Box<Paper> paperBox = new Box<>();
Box<Paper> newBox = new Box<Integer>(); // 불가!!
제네릭이 개발되기 전 코드와도 호환이 가능하기 위해 꺽쇠를 사용하지 않고 객체를 생성하는 것도 허용됩니다. 즉, 아래와 같이 객체를 생성할 수도 있습니다. 하지만 제네릭 타입을 지정하지 않았기 때문에 타입 안정성을 보장할 수 없어 안전하지 않다는 경고가 발생합니다.
Box box3 = new Box();
box3.setItem("Object Box"); // 경고 발생
String strBox3 = (String) box3.getItem(); // 형변환 필요
따라서, 타입을 지정해서 제네릭스를 사용하는 것을 권장합니다.
제네릭스와 static
제네릭스 타입 변수는 static멤버에 사용할 수 없습니다. 왜냐하면 제네릭스 타입 변수(T)는 인스턴스 변수이기 때문에 생성된 인스턴스의 타입마다 다른 타입을 가진 변수로 사용되기 때문입니다. 아시다시피 static 변수는 클래스 변수이므로 모든 인스턴스마다 동일한 값을 가져야 하는데 네릭스 타입 변수(T)로는 동일한 데이터 형을 가질 수 없습니다.
제네릭스와 배열
제네릭스 타입 변수(T)를 타입으로 하는 배열을 선언하여 참조 변수로 사용할 수는 있지만, new 연산자를 이용하여 네릭 타입의 배열을 생성하는 것은 허용되지 않습니다. 즉 아래 코드와 같습니다.
public void makeObject() {
T[] arr; // 참조 가능
Integer[] integerArr = new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9};
arr = (T[]) integerArr; // 형변환 필요
for (T value : arr) {
System.out.println(value);
}
// T arr2 = new T[10]; 불가!!
}
제네릭과 상속
현재까지는 제네릭스 타입 변수(T)에 정의할 수 있는 타입에 제한이 없었습니다. 하지만 경우에 따라서 T에 선언할 수 있는 타입에 제한을 걸고 싶을 수도 있습니다. 이때는 상속(extends)을 이용하여 T에 허용할 수 있는 객체 타입을 특정 타입의 자손들로 제한하여 설정할 수 있습니다. (이때, 특정 타입이 interface여도 extends를 사용합니다!)
아래 코드를 통해 확인해 보겠습니다.
public class Box<T> {
ArrayList<T> itemList = new ArrayList<>();
public void addList(T item) {
itemList.add(item);
}
}
class FoodBox<T extends Food> extends Box<T>{}
class Food {}
class Kimchi extends Food{}
class Rice extends Food{}
class Tablet {}
public class Main {
public static void main(String[] args) {
FoodBox<Food> foodBox = new FoodBox<>();
FoodBox<Kimchi> kimchiBox = new FoodBox<>();
FoodBox<Rice> riceBox = new FoodBox<>();
// FoodBox<Tablet> tabletBox = new FoodBox<Tablet>(); 불가
foodBox.addList(new Food());
foodBox.addList(new Kimchi());
foodBox.addList(new Rice());
kimchiBox.addList(new Kimchi());
// kimchiBox.addList(new Rice()); 불가 (같은 타입만 가능)
riceBox.addList(new Rice());
}
}
와일드카드
만약 아래와 같이 메서드를 정의하였다면 메서드 오버로딩이 성립되지 않습니다.
public void makeFoodBox(FoodBox<Food> box){
for(Food food: box.getList()){
System.out.println(food.toString());
}
}
public void makeFoodBox(FoodBox<Kimchi> box){
for(Food food: box.getList()){
System.out.println(food.toString());
}
}
즉, 타입 매개변수 T가 다른 것만으로는 오버로딩이 성립되지 않습니다. 이는 메서드 중복 정의로 간주됩니다. 따라서 제네릭을 이용하여 다양한 타입의 매개변수를 가진 메서드를 사용하고자 한다면 와일드카드를 이용해야 합니다.
와일드카드는 기호 '?'로 표현되고, 어떠한 타입도 가능하다는 의미를 가집니다. 와이들 카드를 이용하여 아래 3가지 경우의 조합을 만들 수 있습니다.
- <? extends T>: T와 그 자손들만 가능
- <? super T>: T와 그 조상들만 가능
- <?>: 제한 없음, 모든 타입이 가능 (<? extends Object>와 동치)
위 makeFoodBox() 메서드를 와일드카드를 이용하여 재정의 해 보겠습니다.
public void makeFoodBox(FoodBox<? extends Food> box){
for(Food food: box.getList()){
System.out.println(food.toString());
}
}
이렇게 작성하면 메서드를 여러 개 작성하지 않더라도 Food 객체와 Food를 상속하는 객체들을 다룰 수 있는 메서드를 작성할 수 있습니다. 만약 <? extends Food> 대신 <?>로 작성하면 어떻게 될까요?
<?>로 작성하면 FoodBox의 모든 종류의 타입이 makeFoodBox의 매개변수로 가능해져 foreach 문 안의 Food 타입의 참조변수를 사용할 수 없습니다.
하지만 우리 예제에서는 FoodBox가 아래와 같이 정의되어 있었습니다.
public class Box<T> {
ArrayList<T> itemList = new ArrayList<>();
public void addList(T item) {
itemList.add(item);
}
public ArrayList<T> getList() {
return itemList;
}
}
class FoodBox<T extends Food> extends Box<T>{}
따라서, 우리 예제에서 makeFoodBox의 매개변수로 <?>로 작성해도 오류가 나지 않습니다. 만약, FoodBox의 T 변수가 Food를 상속하지 않았다면 오류가 나게 될 것입니다.
제네릭 메서드
아래와 같이 메서드의 반환 타입 바로 앞에 제네릭 타입이 선언된 메서드를 제네릭 메서드라고 합니다.
제네릭 메서드 선언
<T> void genericMethod();
<T> String genericMethod2(T var);
주의할 점은, 제네릭 클래스에 정의된 타입 매개변수(T)와 제네릭 메서드 선언부에 정의된 타입 매개변수(T)는 문자만 같을 뿐 전혀 다른 것입니다. 만약 혼동이 된다면 아래와 같이 다른 문자로 구별하여 사용하면 좋을 것 같습니다.
public class Box<T> {
ArrayList<T> itemList = new ArrayList<>();
public void addList(T item) {
itemList.add(item);
}
public ArrayList<T> getList() {
return itemList;
}
<E> void genericMethod(){}
}
제네릭 메서드 활용
앞에서 만들었던 makeFoodBox() 메서드를 제네릭 메서드를 이용하여 변경할 수 있습니다.
// 앞에서 만들었던 코드
public void makeFoodBox(FoodBox<? extends Food> box){
for(Food food: box.getList()){
System.out.println(food.toString());
}
}
// 제네릭을 이용한 코드
// T는 같은 의미!!
public static <T extends Food> String makeFoodBox(FoodBox<T> box){
String result = "";
for(Food food: box.getList()){
result += food.toString();
}
return result;
}
제네릭 메서드를 이용하여 위와 같이 변경이 가능하기 때문에, 만약 메서드 내에 매개변수의 타입이 복잡하다면 제네릭 메서드를 통해 코드를 간단하게 만들 수 있습니다. 아래 코드를 통해 확인해 보세요!
public void cleanMethod(ArrayList<T> list, ArrayList<T> list2) {} // 복잡한 코드
public <T extends Food> void cleanMethod(ArrayList<T> list, ArrayList<T> list2){} // 제네릭을 이용한 코드
제네릭 메서드 사용법
제네릭 메서드를 호출할 때도 제네릭 클래스와 마찬가지로 꺽쇠 < > 안에 원하는 타입을 대입하여 호출합니다. 그러나 대부분의 경우 컴파일러가 타입을 추정하기 때문에 생략해도 괜찮습니다. 하지만 주의할 점은, <>를 생략하지 않을 경우에 메서드 참조를 위한 참조변수나 클래스 이름을 생략할 수 없습니다.
코드를 통해 확인해 보겠습니다.
public class Box<T> {
ArrayList<T> itemList = new ArrayList<>();
public void addList(T item) {
itemList.add(item);
}
public ArrayList<T> getList() {
return itemList;
}
<E> void genericMethod(){}
public static <T extends Food> String makeFoodBox(FoodBox<T> box){
String result = "";
for(Food food: box.getList()){
result += food.toString();
}
return result;
}
}
public class Main {
public static void main(String[] args) {
FoodBox<Food> foodBox = new FoodBox<>();
FoodBox<Kimchi> kimchiBox = new FoodBox<>();
FoodBox<Rice> riceBox = new FoodBox<>();
makeFoodBox(foodBox);
makeFoodBox(riceBox);
Box.<Kimchi>makeFoodBox(kimchiBox);
// <Food>makeFoodBox(foodBox); 불가!!
}
}
이렇게 제네릭에 관한 전반적인 내용을 다루어 보았습니다. 제네릭을 이용하면 다양한 타입의 객체를 같은 메서드와 클래스로 다룰 수 있다는 점이 편리할 것 같네요!! 제네릭 내용 자체가 방대하여 더 포스팅을 해야 할 것이 있을 것 같기도 합니다 ㅎㅎ
[참고자료]
남궁 성, [Java의 정석 3rd Edition], 도우출판, 2016
'Java' 카테고리의 다른 글
제네릭(Generic) 타입의 형변환 (0) | 2023.10.29 |
---|---|
Thread 실행 제어 (0) | 2023.10.27 |
Thread 구현과 실행 (Runnable vs Thread) (0) | 2023.10.25 |
자바의 애너테이션(Annotation) (2) | 2023.10.22 |
자바에서 애너테이션(Annotation) 직접 정의하기 (0) | 2023.10.22 |