제네릭이란?
자바에서 제네릭은 *클래스나 메소드에서 사용할 내부 데이터 타입을 외부에서 지정하는 기법*을 의미한다.
리스트나 맵 등 주로 컬렉션을 다룰 때 많이 봤을 것이다!
ArrayList<Integer> list = new ArrayList<>();
위에서 꺽쇠 괄호에 해당하는 부분이 바로 제네릭이다. 해당 자료형에서 다룰 타입을 외부에서 지정해주는 의미이다.
- 타입 파라미터
제네릭은 <>
꺽쇠 괄호를 사용하는데, 이를 다이아몬드 연산자라 칭한다.
이러한 꺽쇠 괄호 안에 식별자 기호를 지정함으로써 파라미터화 할 수 있다. 마치 메소드에서 매개변수를 받아 사용하는 것과 비슷해 제네릭에서는 타입 매개변수 or 타입 파라미터라고 부른다.
이러한 타입 매개변수는 제네릭을 이용해 클래스나 인터페이스, 메소드를 설계할 때 사용된다.
아래 예시코드를 살펴보자.
public class FruitBox<T> {
List<T> box = new ArrayList<>();
public void addFruit(T t) {
box.add(t);
}
}
과일을 담을 수 있는 박스를 리스트로 표현했고, 리스트에 담길 수 있는 타입은 외부에서 제네릭을 활용해 T로 표현하고 있다.
이를 인스턴스화 시켜보자!
public static void main(String[] args) {
// Integer 타입으로 인스턴스 생성
FruitBox<Integer> intFruitBox = new FruitBox<>();
intFruitBox.addFruit(1);
intFruitBox.addFruit(2);
// String 타입으로 인스턴스 생성
FruitBox<String> stringFruitBox = new FruitBox<>();
stringFruitBox.addFruit("apple");
stringFruitBox.addFruit("banana");
}
FruitBox 클래스가 타입 매개변수 T를 활용해 설계되었고, 이에 대한 인스턴스를 생성할 때 구체적인 타입을 지정하며 생성해주는 모습이다. 실제로 인스턴스를 생성하는 시점에서 매개변수로 넘긴 Integer, String 등의 구체적인 타입이 클래스 레벨에서 T 타입으로 지정해둔 부분으로 모두 전파되어 타입이 구체적으로 설정 되는 것이다. 이를 전문 용어로 *구체화(Specialization)* 이라 한다.
- 타입 파라미터 생략
new 연산자를 활용해 인스턴스를 생성할 때 위 코드를 보면 제네릭을 구체적으로 명시해주지 않고 있다.
이 부분까지 적으면 사실 중복해서 타입을 명시하는 것이여서, 굳이 적지 않아도 자바는 알아서 타입을 추론한다.
- 타입 파라미터 기호 네이밍
위에서는 타입 파라미터 기호를 <T>
로 표현했지만 사실 어떤 기호가 와도 무방하다.
하지만 우리가 반복문을 사용할 때 i, j, k를 사용하듯 제네릭도 마찬가지로 관용적인 표현들이 있다.
아래 표를 통해 정리해두겠다.
타입 | 설명 |
---|---|
<T> | 타입(Type) |
<E> | 요소(Element) |
<K> | 키 값 |
<V> | 매핑된 값 또는 리턴 값 |
<N> | 숫자(Number) |
<S, U, V> | 2, 3, 4번째에 선언된 타입 |
제네릭을 사용하는 이유
제네릭을 사용하지 않은 상황과 제네릭을 사용한 상황을 비교해 무슨 차이가 있는지 알아보자.
먼저 제네릭을 사용하지 않은 상황 예시이다.
class Apple {}
class Banana {}
class FruitBox {
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);
}
모든 타입의 과일을 받아주기 위해 FruitBox 클래스 내 배열을 Object 타입으로 생성했다.
아래 실행문은 Apple타입의 배열을 생성하고 해당 배열을 FruitBox에 담아둔 모습이다.
컴파일 단계에서는 오류가 없지만 실제로 실행해보면 런타임 에러가 발생한다. 왜일까?
개발자가 착각하고 맨 마지막 줄처럼 Apple 타입의 박스에서 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.getFruit(0);
// 컴파일 타임에서 체크 가능한 오류
Banana banana = (Banana) box.getFruit(1);
// 캐스팅도 필요 없어짐
Apple apple = box.getFruit(0);
}
결과적으로 제네릭을 사용하는 이유는 다음과 같습니다.
1. 컴파일 타임에 자료형의 오류에 대한 검증을 수행하여, 런타임에 안전한 코드 실행을 보장한다.
2. 반환값에 대한 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있고, 형변환이 없어지므로 가독성이 좋아진다.
제네릭 메소드
제네릭 메소드는 무엇일까? 제네릭 클래스 내부에서 타입 파라미터를 사용하는 메소드가 제네릭 메소드일까?
정답은 아니다! 이것은 그냥 타입 파라미터로 타입을 지정한 일반적인 메소드이다.
class FruitBox<T> {
public T addBox(T x, T y) {
// ...
}
}
제네렉 메소드란, 메소드의 선언부에 <T>
가 선언된 메소드를 의미한다.
위에서 살펴본 일반적인 메소드는 클래스의 제네릭 타입 파라미터 <T>
에서 설정된 타입을 받아와 사용할 뿐이다.
제네릭 메소드는 *해당 T와 독립적으로 운용 가능하도록 동적으로 타입 파라미터 <T>
를 받아온다.*
또한 제네릭 메소드는 제네릭 클래스와 관계없이 일반적인 클래스에서도 사용할 수 있다.
말로만 들으면 어렵다! 예시를 통해 살펴보자.
public class GenericsMethod {
public <T> void printClassName(T t) {
System.out.println(t.getClass().getName());
}
}
public static void main(String[] args) {
GenericsMethod gm = new GenericsMethod();
gm.printClassName(1);
gm.printClassName(1.5);
}
출력 결과 :
java.lang.Integer
java.lang.Double
위 상황은 제네릭 클래스가 아닌 일반 클래스에서 제네릭 메소드를 사용한 상황이다.
단순히 타입 파라미터로 넘어온 클래스 정보를 보여주는 메소드이다.
위에서 설명한 것처럼, *제네릭 클래스가 아닌 일반 클래스에서도 제네릭 메소드가 사용가능한 모습이다.*
이번엔 조금 변형해서 해당 클래스를 <T>
타입의 제네릭 클래스로 변경해보자.
public class GenericsMethod<T> {
public <T> void printClassName(T t) {
System.out.println(t.getClass().getName());
}
}
public static void main(String[] args) {
GenericsMethod<String> gm = new GenericsMethod();
gm.printClassName(1);
gm.printClassName(1.5);
}
출력 결과 :
java.lang.Integer
java.lang.Double
<T>
타입의 제네릭 클래스로 변경했고, 실제 인스턴스는 <String>
타입으로 생성했다.
이후 동일하게 진행했지만 역시 출력 결과는 동일했다.
*제네릭 클래스의 타입 파라미터 <T>
와 제네릭 메소드의 타입 파라미터 <T>
가 독립적으로 운용된 모습이다.*
사실 정석으로 제네릭 메소드를 호출하는 방법은 아래와 같다.
public static void main(String[] args) {
GenericsMethod<String> gm = new GenericsMethod();
gm.<Integer>printClassName(1);
gm.<Double>printClassName(1.5);
}
자바 컴파일러는 제네릭 타입에 들어갈 데이터 타입을 메소드의 매개변수를 통해 추정할 수 있기 때문에 제네릭 메소드의 타입 파라미터를 생략하고 호출할 수 있던 것이다.
타입 한정 키워드
<T>
에 들어올 타입을 다양한 방식으로 제한할 수 있다.
특정 클래스를 상속받는 클래스만 올 수 있도록 제한하던지, 다양한 이유로 타입 한정 키워드를 사용할 수 있다.
예시를 통해 사용하는 이유와 방법을 살펴보자.
예시에 사용되는 상속도는 아래 그림과 같다.
- extends (상한 경계)
아래와 같은 클래스가 있다.
public class FruitBox<T> {
List<Fruit> box = new ArrayList<>();
public void add(T t) {
box.add(t); //컴파일 에러
}
}
위와 같은 상황에서 box 리스트는 Fruit에 해당되는 클래스만 담아야 할 것이다.
하지만 현재 타입 파라미터 <T>
가 제한없이 열려있다. 만약 Vegetable이 타입 매개변수로 들어오려고 하는 상황처럼 잘못된 상황이 발생할 수 있기 때문에 *컴파일러가 경고를 미리 보여준다.*
따라서 타입 파라미터의 경계를 설정해줘야 한다. 지금 상황에서는 Fruit을 상속받는 클래스만 들어와야하니, <T extends Fruit>
을 사용하면 적절한 코드가 될 것이다.
이처럼 위를 제한하기 때문에 *상한 경계 방식*이라고 부른다.
public class FruitBox<T extends Fruit> {
List<Fruit> box = new ArrayList<>();
public void add(T t) {
box.add(t); //컴파일 에러 사라짐
}
}
- super (하한 경계)
위 예시에서는 Fruit을 포함한 하위 클래스만이 타입 파라미터로 들어오도록 제한했다.
반대로, Fruit을 포함해 상위 클래스만이 타입 파라미터로 들어오도록 제한하고 싶다면 <T super Fruit>
을 사용하면 된다.
아래로 들어오는 것을 제한하기 때문에 하한 경계라고 부른다.
제네릭 와일드 카드
- 비경계 와일드카드 <?>
비경계 와일드카드 <?>
은 모든 타입이 인자가 될 수 있다.
사용 예시를 살펴보자.
public static void printList(List<Object> list) {
for(Object ele : list) {
System.out.println(ele + " ");
}
}
List<String> strings = new ArrayList<>();
printList(strings); // 컴파일 에러
위 코드는 어떤 타입이던 받을 수 있도록 Object
로 타입 파라미터를 설정한 리스트를 이용해 모든 요소를 출력하는 코드이다.
하지만 실행문을 보면 String
타입으로 지정한 리스트는 컴파일 에러를 보여주고 있다. 이유가 뭘까?
제네릭의 경우 타입 파라미터가 오로지 똑같은 타입만 받기 때문에, 다형성을 이용할 수 없어서 그렇다!
-> 일반적인 상속 관계를 이용할 수 없다.
*임의의 타입 A의 리스트 List<A>
는 List<Object>
의 서브 타입이 아니다.*
따라서 어떤 타입이던지 상관없이 출력하기 위해서는 비경계 와일드카드인 <?>
을 사용해야 한다.
public static void printList(List<?> list) {
for(Object ele : list) {
System.out.println(ele + " ");
}
}
List<String> strings = new ArrayList<>();
printList(strings); // 컴파일 에러 사라짐
*임의의 타입 A의 리스트 List<A>
는 List<?>
의 서브 타입이다.*
비경계 와일드카드의 특징을 살펴보자.
List<?>
에서get()
한 원소는Object
타입이다.- 비경계 와일드카드의 원소는 어떤 타입도 올 수 있기 때문에, 모든 타입의 공통 조상인
Object
로 처리된다. - 상한 경계가 생긴다면? 이 부분은 바뀔 수 있을 것 같다! 뒤에서 살펴보자.
- 비경계 와일드카드의 원소는 어떤 타입도 올 수 있기 때문에, 모든 타입의 공통 조상인
List<?>
에는null
만 삽입할 수 있다.- 비경계 와일드카드의 원소가 어떤 타입인지 알 수 없기 때문에 타입 안정성을 지키기 위해
null
만 삽입할 수 있다. - 하한 경계가 생긴다면? 이 부분은 바뀔 수 있을 것 같다! 뒤에서 살펴보자.
- 비경계 와일드카드의 원소가 어떤 타입인지 알 수 없기 때문에 타입 안정성을 지키기 위해
- 상한 경계 와일드카드 <? extend T>
바로 특징을 살펴보자!
List<? extends T>
에서get()
한 원소는T
타입이다.- 상한 경계 와일드카드의 원소는
T
또는T
의 하위 클래스이다. - 따라서 최고 공통 조상인
T
로 읽으면 어떤 타입으로 오든T
로 읽을 수 있다.
- 상한 경계 와일드카드의 원소는
List<? extends T>
에는null
만 삽입할 수 있다.- 상한 경계 와일드카드의 원소가 어떤 타입인지 알 수 없기 때문에 타입 안정성을 지키기 위해
null
만 삽입할 수 있다.
- 상한 경계 와일드카드의 원소가 어떤 타입인지 알 수 없기 때문에 타입 안정성을 지키기 위해
- 하한 경계 와일드카드 <? super T>
바로 특징을 살펴보자!
List<? super T>
에서get()
한 원소는Object
타입이다.- 하한 경계 와일드카드의 원소는 어떤 타입이던지 올 수 있기 때문에, 모든 타입의 공통 조상인
Object
로 처리된다.
- 하한 경계 와일드카드의 원소는 어떤 타입이던지 올 수 있기 때문에, 모든 타입의 공통 조상인
List<? super T>
에는T
또는T
의 하위 클래스만 삽입할 수 있다.- 하한 경계 와일드카드의 모든 원소는
T
, 혹은T
의 상위 클래스이다. - 따라서 이젠
null
만 삽입할 수 있는 것이 아니고T
혹은T
의 하위 클래스를 삽입할 수 있다.
- 하한 경계 와일드카드의 모든 원소는
와일드 카드는 제네릭에서 가장 어려운 부분이다. 예시를 잘 살펴보며 기억하고 복기하도록 하자.
출처 :
'Java' 카테고리의 다른 글
[Java] 가비지 컬렉션(Garbage Collection) (1) | 2024.01.12 |
---|---|
[Java] 자바 가상 머신(JVM) 의 내부 구조 (1) | 2024.01.12 |
[Java] 컬렉션 프레임워크 (0) | 2024.01.02 |
[Java] Stream API (1) | 2023.12.21 |
[Java] 함수형 인터페이스(Functional Interface)와 람다식(Lambda Expression) (0) | 2023.12.14 |