자바에서 제네릭(Generics)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법이다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다.
자바에서 배열과 함께 자주 쓰이는 자료형이 List인데, 다음과 같이 클래스 선언 문법에 꺽쇠 괄호<>로 되어 있는 코드를 본 적이 있을 것이다.
ArrayList<String> list = new ArrayList<>();
저 꺽쇠 괄호가 바로 제네릭이다. 괄호 안에는 타입명을 기재한다. 그러면 저 List 클래스의 자료형 타입은 String으로 지정되어 문자열 데이터만 List에 담을 수 있게 된다.
아래 코드처럼 배열과 리스트의 형태를 비교해 보자. 선언하는 키워드나 문법 순서에 약간씩 차이는 있어도, 자료형명을 선언하고 자료형의 타입을 지정하는 점은 동일하다.
String[] array = new String[10];
ArrayList<String> list = new ArrayList<>(10);
이처럼 제네릭은 배열에서 타입을 지정하듯이 Collection 클래스나 method에서 사용할 내부 데이터 타입을 parameter 넘기듯이 외부에서 지정하는 방식이다. 즉 타입이 변수가 된 것이다.
변수 선언 시에 타입 지정을 해 주는 것처럼, 객체에 타입을 지정해 준다고 생각하면 더욱 이해가 쉽다.
제네릭은 <> 꺽쇠 괄호를 키워드를 연산자로 사용하는데, 이를 다이아몬드 연산자라고 한다. 그리고 이 꺽쇠 괄호 안에 식별자 기호를 지정해서 파라미터화할 수 있다. 이것을 마치 메소드가 parameter를 받아 사용하는 것과 비슷하다고 하여 제네릭의 type parameter라고 부른다.
ArrayList<String> list = new ArrayList<>();
// String이 type parameter
이 type parameter는 제네릭을 사용한 클래스나 메소드를 설계할 때 쓰인다.
예를 들어 제네릭을 활용한 클래스를 다음과 같이 정의할 수 있다. 클래스명 옆에 기호로 제네릭을 붙이면 된다. 그리소 클래스 내부에서 식별자 기호 T를 클래스의 필드와 메소드에서 자유롭게 사용하는 것이다.
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
제네릭 클래스 생성 후 이를 인스턴스화할 수 있다. 마치 parameter를 전달하는 것처럼 생성 코드에서 꺽쇠 괄호 안에 넣고 싶은 타입을 지정해 주면 된다. 여기서 지정해 준 타입이 제네릭 클래스의 선운문 부분에서 type parameter의 역할을 모두 수행해 줄 것이다.
// 제네릭 타입 매개변수에 정수 타입 할당
FruitBox<Integer> intBox = new FruitBox<>();
// 제네릭 타입 매개변수에 실수 타입 할당
FruitBox<Double> doubleBox = new FruitBox<>();
// 제네릭 타입 매개변수에 문자열 타입 할당
FruitBox<String> stringBox = new FruitBox<>();
// 클래스도 할당 가능
FruitBox<RedFruit> redFruitBox = new FruitBox<>();
안에 구체적인 타입을 넣어 주었을 때, 클래스 내부에서 T 타입으로 지정된 멤버나 메소드에 전파시키는 행위가 일어난다. 따라서 객체 생성 이후에는 타입이 구체적으로 설정된다. 이를 구체화(Specialization)이라고 한다.
제네릭은 실행 부분에서의 동적 할당이기 때문에 참조 클래스의 type parameter만 가능하다.
제네릭 객체를 사용하는 문법 형태를 보면 양쪽 두 군데에 다이아몬드 연산자를 모두 지정한 것을 볼 수 있다. 하지만 맨 앞에서 클래스명과 함께 타입을 지정해 주었는데, 굳이 생성자까지 제네릭을 지정해 줄 필요는 없다.
따라서 jdk 1.7 버전 이후부터 new 생성자 부분의 제네릭 타입을 생략할 수 있게 되었다. 제네릭 나름대로 타입 추론을 해서 생략된 곳을 넣어 주기 때문에 문제가 없는 것이다.
FruitBox<RedFruitBox> redFruitBox = new FruitBox<RedFruitBox>();
// 다음과 같이 new 생성자 부분의 제네릭 type parameter 생략 가능
FruitBox<RedFruitBox> redFruitBox = new FruitBox<>();
제네릭에서 할당받을 수 있는 타입은 Reference 타입뿐이다. 즉 int, double, long 등의 원시 타입은 type parameter로 사용할 수 없다.
Wrapper 클래스에 대해 공부할 때, int가 있는데 왜 Integer가 또 있는지 고민해 보게 된다. 이 개념이 여기서도 적용된다고 보면 된다.
// 기본 원시 타입은 사용 불가능
List<int> intList = new List<>();
// Wrapper 클래스로 넘겨 주어야 함 (Wrapper 클래스의 메소드를 내부에서 사용)
List<Integer> integerList = new List<>();
또한 제네릭의 type parameter에 클래스가 타입으로 온다는 것은 클래스끼리의 상속으로 관계를 맺는 객체지향의 다형성 원리가 그대로 적용된다는 것이다. FruitBox에는 Fruit 객체도 들어올 수 있으며, Fruit을 상속받는 Apple, Banana 객체도 들어올 수 있다.
제네릭은 반드시 한 개만 사용하라는 법은 없다. 만일 타입 지정이 여러 개가 필요한 경우 얼마든지 2개, 3개 만들 수 있다.
제네릭 타입의 구분은 꺽쇠 괄호 안에 쉼표(,)로 한다. 예를 들어 <T, U> 등을 통해 복수로 type parameter를 지정할 수 있다. 그리고 당연히 클래스를 초기화할 때 type parameter를 두 개 넘겨 주어야 한다.
import java.util.ArrayList;
import java.util.List;
class Apple{}
class Banana{}
Class FruitBox<T, N> {
List<T> apples = new ArrayList<>();
List<U> bananas = new ArrayList<>();
public void add(T apple, U banana) {
apples.add(apple);
bananas.add(banana);
}
}
public class Main {
public static void main(String[] args {
// 복수 제네릭 타입
FruitBox<Apple, Banana> box = new FruitBox<>;
box.add(new Apple(), new Banana());
box.add(new Apple(), new Banana());
}
}
제네릭 객체를 제네릭 type parameter로 받는 형식도 표현 가능하다. ArrayList 자체도 하나의 타입으로써 제네릭 type parameter가 될 수 있기 때문에 이렇게 중첩 형식으로 사용할 수 있는 것이다.
public static void main(String[] args) {
// LinkedList<String>을 원소로 저장하는 ArrayList
ArrayList<LinkedList<String>> list = new ArrayList<>();
LinkedList<String> listOne = new LinkedList<>();
listOne.add("aa");
listOne.add("bb");
LinkedList<String> listTwo = new LinkedList<>();
listTwo.add("AA");
listTwo.add("BB");
System.out.println(list); // [[aa, bb], [AA, BB]]
}
지금까지 제네릭 기호를 와 같이 써서 표현했지만 사실 식별자 기호는 문법적으로 정해진 것은 없다.
우리가 for 문을 돌 때 루프 변수를 i로 사용하는 것과 같은 결이다. 따라서 for 문의 i, j, k처럼 T, U, S로 이어나간다.
말하고 싶은 대로 아무 단어나 넣어도 괜찮지만, 통상적인 네이밍을 사용해 convention을 사용하면 소통을 더욱 용이하게 할 수 있다.
T: 타입(Type)
E: 요소(Element)
K: 키(Key)
V: 값(Variable)
N: 숫자(Number)
S, U, V: 2번째, 3번째, 4번째에 선언된 타입
제네릭은 자바 1.5에 추가된 스펙이다. 그래서 JDK 1.5 이전 버전에서는 여러 타입을 다루기 위해 인수나 반환값으로 Object 타입을 사용했다. 하지만 Object 타입을 사용할 경우 반환된 Object 객체를 하나하나 내가 원하는 형태로 형변환을 해 줘야 했다. 이 과정에서 발생하는 런타임 에러도 무시할 수 없을 것이다.`
아래 예제는 Object 타입으로 선언한 배열에 Apple과 Banana 객체 타입을 저장하고 이를 다시 가져오는 것이다.
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.getField(0);
Banana banana = (Banana) box.getField(1);
}
그런데 실행해 보면 ClassCastException이라는 런타임 에러가 발생하게 된다. 객체를 가져올 때 형변환도 잘해 주어 문제가 없는 것 같은데, 뭐가 문제일까?
문제는 간단하다. Apple 객체 타입의 배열을 FruitBox에 넣었는데, 개발자가 착각하고 Banana로 받아 버린 것이다. 하지만 이는 런타임 에러이기 때문에 실행하기 전까지는 컴파일 단계에서 에러임을 알 수 없다.
제네릭을 사용하면 이런 실수를 사전에 방지할 수 있다. 왜냐하면 코드를 실행하기 전에 컴파일 단계에 미리 에러를 찾아 알려 주기 때문이다.
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<Apple> box = new FruitBox<>(arr);
Apple apple = (Apple) box.getField(0);
Banana banana = (Banana) box.getField(1); // 컴파일 에러 발생
}
이렇듯 제네릭은 클래스나 메서드를 정의할 때 타입 파라미터로 객체의 서브 타입을 지정해 줌으로써 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거하여 개발을 용이하게 해 준다.
Object로 받아서 캐스팅하는 코드에서는 Object -> Apple,. Banana로 다운캐스팅을 통해 가져왔다. 이는 추가적인 오버헤드가 발생하는 것과 같다.`
반면 제네릭은 미리 타입을 지정하고 제한해 놓기 때문에 형 변환의 번거로움을 줄일 수 있으며 가독성도 좋아진다는 장점이 있다.
제네릭 타입 자체로 타입을 지정하여 객체를 생성하는 것은 불가능하다.
class Sample<T> {
public void someMethod() {
T t = new T();
}
}
아래처럼 static 변수의 데이터 타입으로 제네릭은 올 수 없다. 그 이유는 static은 클래스가 동일하게 공유하는 변수로써 제네릭 객체가 생성되기도 전에 이미 자료 타입이 정해져 있어야 하기 때문이다. 즉 컴파일 타임과 런타임 사이의 오류이다.
타입스크립트에도 제네릭의 개념이 있는데요, 표현도 비슷하고 개념도 거의 비슷한 것 같아요!
잘 쓰면 참 유용한 개념이라고 생각하는데, 잘 쓰기가 너무 어려워서 문제예요 ☹️
바쁜 와중에 좋은 글 감사합니다 잘 읽고 가요 🤠