제네릭은 정적 타입 방식으로 코드를 구현하는 자바에서 중요한 역할을 수행하며, 간단히 얘기하면 아직 정해지지 않은 클래스의 타입을 대체하는 수단 으로 정리할 수 있습니다.
얘를 들어 아래와 같이 자바에서는 배열을 정의할 때 정해진 길이 만큼 단일 데이터 타입만을 담도록 하는데요. 이때 ArrayList 컬렉션을 이용하면 데이터 타입에 국한되지 않고 가변적인 길이 만큼 데이터를 담는 것이 가능한데요.
근데 문제는, 일반적인 ArrayList는 전달 받는 데이터 타입에 국한이 되어있지 않으므로 할당할때는 편해도 꺼낼 때는 어떤 데이터 타입을 꺼낼지 모르는 문제가 발생할 때 자칫 타입 오류를 초래할 수도 있는데, 만약 가변적인 길이 만큼의 특정 데이터 타입을 담은 배열을 생성하고 싶을 때, 이때 제네릭을 사용하게 됩니다.
// 제네릭을 사용하지 않은 리스트 List numbers = new ArrayList<>(); // int 타입의 변수가 add 메서드에 의해 래핑 되어 Integer 타입으로 박싱 numbers.add(123); numbers.add("456"); Integer num = (Integer) numbers.get(0); // 어떤 타입이 들어가 있을 지 모르는 상황이므로 타입 캐스팅이 필요함 // 제네릭을 사용한 리스트 List numbers<Integer> = new ArrayList<>(); // int 타입의 변수가 add 메서드에 의해 래핑 되어 Integer 타입으로 박싱 numbers.add(123); // numbers.add("456"); // 컴파일 오류: 타입 불일치 Integer num = numbers.get(0); // 해당 배열에는 Integer 타입이 정의되어 있으므로 타입 캐스팅이 필요 없음
이 제네릭은 기본적으로 타입을 지정할 때 기본 타입의 경우 불변 타입의 특성상 원본을 변경할 수 없기 때문에 래퍼 클래스를 사용하여 제네릭을 지정하게 됩니다.
// 제네릭에 전달되는 타입은 기본 타입은 안되므로 Wrapper 클래스로 변환해야함 Integer[] arr1 = {1,2,3,4,5}; // int를 Integer로 변환 Double[] arr2 = {1.5, 2.5, 3.5}; // double을 Double로 변환 Character[] arr3 = {'h', 'e', 'l', 'l', 'o'}; // char를 Character로 변환 Boolean[] arr3 = {true, false}; // boolean을 Boolean으로
이러한 제네릭은 비단 배열에만 국한하여 사용하지 않고 클래스를 전달해주는 용도로도 사용되며, 다중 타입으로도 선언이 가능합니다.
- 클래스 정의부
// 임의의 T와 U 타입으로 필드 멤버 및 메서드의 반환 타입, 메서드등을 정의 class Room <T, U>{ private T furniture1; private U furniture2; public void setFurniture1(T furniture1) { this.furniture1 = furniture1; } public void setFurniture2(U furniture2) { this.furniture2 = furniture2; } @Override public String toString() { StringBuffer sb = new StringBuffer(); sb.append("첫번째 가구 : ").append(furniture1).append("\n"); sb.append("두번째 가구 : ").append(furniture2).append("\n"); return sb.toString(); } } class Chair{ @Override public String toString() { return "의자"; } } class Table{ @Override public String toString() { return "책상"; } } class Bed{ @Override public String toString() { return "침대"; } } class DressTable{ @Override public String toString() { return "화장대"; } }
- 실제 사용부
public static void main(String[] args) { // 객체를 생성할 때 생성한 클래스타입을 제네릭으로 지정 Room<Bed, DressTable> room1 = new Room<>(); Room<Table, Chair> room2 = new Room<>(); // 지정한 타입의 인스턴스 생성자를 매개변수로 전달하여 인스턴스 생성과 할당을 동시에. room1.setFurniture1(new Bed()); room1.setFurniture2(new DressTable()); room2.setFurniture1(new Table()); room2.setFurniture2(new Chair()); System.out.println(room1.toString()); System.out.println(room2.toString()); }
우선 클래스에 제네릭 타입을 지정하는 방법은, 해당 클래스의 뒷부분에 다이아몬드 연산자(<>) 안에 지정할 제네릭 타입명을 작성하면 되며, 이후 제네릭 타입을 클래스 안에서 자유롭게 사용하면 됩니다.
public class Box <T> { private T data; public void setData(T data) { this.data = data; } public T getData() { return data; } public <T> void printData(T data) { System.out.println(data); } }
위와 같이 Box 클래스의 타입을 제네릭 타입인 T로 설정했는데요. 저렇게 설정할 경우 해당 클래스를 호출할 때 타입을 지정 해주면 되므로 클래스의 타입을 동적으로 설정하는 것이 가능해 집니다.
Box<Integer> integerBox = new Box<Integer>(); // Box의 타입을 Integer로 설정할 때 Box<String> stringBox = new Box<String>(); // Box의 타입을 String으로 설정할 때 integerBox.setData(10); // 정수를 저장하는 Box에 10을 저장 stringBox.setData("Hello"); // 문자열을 저장하는 Box에 "Hello"를 저장
물론 단수 타입의 선언 뿐만 아니라 다중 타입의 선언 또한 가능
하며, 이때 정의되는 getter와 setter 또한 일반 클래스 선언과 동일하게 작성 해주면 됩니다.
public class Box<A, B, C> { private A dataA; private B dataB; private C dataC; public void setDataA(A data) { this.dataA = data; } public A getDataA() { return dataA; } public void setDataB(B data) { this.dataB = data; } public B getDataB() { return dataB; } public void setDataC(C data) { this.dataC = data; } public C getDataC() { return dataC; } }
인터페이스에서도 제네릭을 사용하는 것이 가능
한데요. 기존의 인터페이스는 하나의 인터페이스를 지정해 놓으면 그 인터페이스를 상속 받는 클래스들 각자가 입맛에 맞도록 메서드를 정의할 수 있었다면,
인터페이스에 제네릭을 부여하게 되면 경우에 따른 클래스에 따라 각각의 독립된 상속 클래스를 유연하게 생성할 수 있다는 장점
이 존재합니다.
아래의 코드 얘시를 들어 설명해 보겠습니다.
public interface HeadShop<C> { C Contract(); } public class SeasoningChickenStore{ public void seasoning(){ System.out.println("양념 치킨을 조리합니다.") } } public class FriedChickenStore{ public void fried(){ System.out.println("후라이드 치킨을 조리합니다.") } }
위의 예시 코드는 치킨 도매점과, 각각 양념, 후라이드를 주력으로 하는 상점들을 구현한 코드인데요.
아래의 코드에서는 호식과 네네 브랜드가 각각 치킨 도매상과 계약을 걸되, 호식은 양념가게를 주로하고, 네네 치킨은 후라이드가게를 주로 하는 계약을 체결하게 됩니다.
그리고 치킨 도매점에서 전수 받은 계약 방법(Contract) 를 각각 본인들의 사정에 맞도록 바꾸는데요. 네네치킨은 양념 치킨 가게를 계약의 주로, 호식이 치킨은 후라이드 가게를 계약의 주로 하도록 설정합니다.
public class neneChicken implements HeadShop<SeasoningChickenStore>{ @Override public SeasoningChickenStore Contract(){ return new SeasoningChickenStore(); } } public class hosicChicken implements HeadShop<FriedChickenStore>{ @Override public FriedChickenStore Contract(){ return new FriedChickenStore(); } }
이후 네네치킨과 호식치킨은 본점(neneMain, hosicMain)을 만든 후 각각 체인점(Sub)을 하나씩 내는데, 각각의 고유의 계약들(Contract)를 체결합니다.
그 뒤 체인점들은 각각의 계약 내용에 맞게 양념을 치거나 (seasoning), 튀겨서(fried) 수익을 내게 되죠.
neneChicken neneMain = new neneChicken(); hosicChicken hosicMain = new hosicChicken(); SeasoningChickenStore neneSub = neneMain.Contract(); FriedChickenStore hosicSub = hosicMain.Contract(); neneSub.seasoning(); // 양념 치킨을 조리합니다. hosicSub.fride(); // 후라이드 치킨을 조리합니다.
제네릭은 메서드에도 지정이 가능
한데요. 클래스명 뒤에 작성했던 것과는 달리 제어자
, 반환 타입
, 매개 변수
에 제네릭을 지정하는 것이 가능하며, 제어자 뒷쪽에 우선적으로 작성
해 줍니다.
public <T> Box<T> boxing(T t){ }
동적인 타입의 Box class의 객체들이 생성된 후, 클래스 안의 메서드들을 호출할 때 동적으로 메서드를 재생성하는 코드인데요.
printElement
는 반환값이 없으므로 제어자와 매개변수에만 제네릭을 일치시키면
되지만, plusCharacter
는 넘겨받은 String 타입을 기반으로 새로운 텍스트를 담은 Box 객체를 반환
하고 있기 때문에 반환 타입또한 제네릭을 지정
해주고, 이를 기반으로 매개변수와 다른 문자열을 결합한 매개변수를 담고 있는 새로운 Box 객체를 반환하는 메서드 입니다.
public class Box<T> { private T value; public T getValue() { return value; } // 제네릭 메서드 public <E> void printElement(E element) { System.out.println("Element: " + element); } public <F> Box<F> plusCharacter(F element) { return new Box<F>(element + " World"); // 입력된 값과 " World"를 결합하여 문자열을 생성하고 이를 매개변수로 한 새로운 Box 객체를 생성하여 반환 } public static void main(String[] args) { Box<Integer> integerBox = new Box<>(); integerBox.printElement(10); // 정수형 값을 출력 Box<String> stringBox = new Box<>(); Box<String> newBox = stringBox.plusCharacter("Hello"); // 문자열 값을 포함하는 Box 객체를 생성 newBox.printElement(newBox.getValue()); // 생성된 Box 객체의 값을 출력 } }
이처럼 살펴본 제네릭을 이용하여 타입에 따른 클래스 및 메서드 등의 동작을 유동적으로 변화시킬 수 있었는데요. 그러나 이러한 제네릭을 모든 타입에 적용하도록 사용하는건 자칫 특정 목적에 따라 설계된 클래스나 메서드의 동작 방식에 자칫 과부화를 일으킬 수
있습니다.
그래서 여러 타입을 받도록 하되 특정한 타입들만 받도록 제네릭을 제한할 필요
도 있는데, 이를 제한된 제네릭 타입
이라고 부릅니다.
public class MaxNumberFinder { public static <T extends Number> T findMax(T[] array) { if (array == null || array.length == 0) { return null; } T max = array[0]; for (int i = 1; i < array.length; i++) { T current = array[i]; if (current.doubleValue() > max.doubleValue()) { max = current; } } return max; } public static void main(String[] args) { Integer[] intArray = {3, 1, 7, 4, 2, 9}; System.out.println("Max Integer: " + findMax(intArray)); Double[] doubleArray = {3.5, 1.2, 7.8, 4.6, 2.9}; System.out.println("Max Double: " + findMax(doubleArray)); } }
앞서 살펴본 예시에서, 제한된 제네릭 파라미터처럼 특정 클래스를 직접 상속 받은 타입들로 제네릭 범위를 지정할 수 있었는데요.
좀 더 확장성을 넓혀서 직접 상속 뿐만 아니라 그 밑의 하위 자손들까지의 범위로 영역을 확장하고자 할때는 타입을 와일드카드(?)
로 대체하여 사용할 수 있습니다.
public class NumberProcessor { // 리스트 내부 요소들을 처리하는 메서드 public static void processList(List<? extends Number> list) { for (Number number : list) { System.out.println("Processing number: " + number); // 리스트 내부 요소를 처리하는 로직을 작성 } } // 처리된 데이터를 담은 리스트를 반환하는 메서드 public static List<? extends Number> getList() { List<Integer> processedData = new ArrayList<>(); processedData.add(1); processedData.add(2); processedData.add(3); // 처리된 데이터를 담은 리스트를 반환 return processedData; } public static void main(String[] args) { // 리스트 내부 요소들을 처리하는 메서드 사용 예시 List<Integer> intList = new ArrayList<>(); intList.add(10); intList.add(20); intList.add(30); processList(intList); // 처리된 데이터를 담은 리스트를 반환하는 메서드 사용 예시 List<? extends Number> processedList = getList(); System.out.println("Processed data: " + processedList); } }