1. 제네릭 (Generic)이란?
제네릭(Generic)은 컴파일 시 타입 체크를 강화하여 타입 안전성을 제공하는 자바의 기능입니다. 제네릭을 사용하면 클래스, 인터페이스, 메서드가 다양한 타입을 처리할 수 있게 됩니다. 이를 통해 코드의 재사용성을 높이고, 잘못된 타입 사용을 컴파일 시점에서 방지할 수 있습니다.
정리하면, 타입을 유연하게 처리하며, 잘못된 타입 사용으로 발생할 수 있는 런타임 타입 에러를 컴파일 과정에서 검출하기 위해 사용하는 기능입니다.
1.1 제네릭의 필요성
1) 제네릭 도입 이전 문제
제네릭이 도입되기 전에는, 모든 타입을 담을 수 있는 Object
타입을 이용하여 데이터를 처리했습니다. 컬렉션 예시로, ArrayList
같은 자료 구조에 타입 제한이 없었고, 어떤 객체든 추가할 수 있었습니다. 하지만 데이터를 추출할 때마다 명시적으로 타입 캐스팅이 필요했으며, 캐스팅 과정에서 잘못된 타입으로 변환할 경우 런타임 에러가 발생했습니다.
import java.util.ArrayList;
public class NonGenericExample {
public static void main(String[] args) {
ArrayList list = new ArrayList(); // 타입을 지정하지 않음
list.add("Hello"); // String 추가
list.add(123); // Integer 추가
String str = (String) list.get(0); // 형 변환 필요
System.out.println(str);
String numStr = (String) list.get(1); // ClassCastException 발생 (런타임 에러)
}
}
위 코드에서 list.add(123)
로 정수(Integer)가 추가되었지만, String으로 잘못된 캐스팅을 시도하면서 런타임 에러가 발생합니다. 이는 코드 작성 시 문제가 드러나지 않고, 프로그램을 실행했을 때만 알 수 있기 때문에 매우 위험한 방식입니다.
2) 제네릭 도입의 이유
이러한 문제를 해결하고자 JDK 1.5에서 제네릭이 도입되었습니다. 제네릭을 사용하면, 컬렉션이나 메서드에 구체적인 타입을 지정할 수 있으며, 이로 인해 다음과 같은 이점을 얻을 수 있습니다.
- 컴파일 시점에서 타입 체크: 런타임 에러가 발생하기 전에 컴파일러가 타입 일관성을 검사하여 안전성을 보장합니다.
- 캐스팅 불필요: 명시적 타입 캐스팅을 하지 않아도 되므로, 코드가 더 간결해지고 타입 오류를 줄일 수 있습니다.
- 코드의 재사용성: 동일한 코드를 여러 타입에 대해 재사용할 수 있으므로, 유연한 프로그래밍이 가능합니다.
import java.util.ArrayList;
public class GenericExample {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>(); // String 타입 지정
list.add("Hello");
// list.add(123); // 컴파일 에러: Integer는 추가할 수 없음
String str = list.get(0); // 형 변환 필요 없음
System.out.println(str);
}
}
이 코드는 컴파일 시점에 타입 오류를 감지하며, 형변환이 불필요해지고 코드가 안전해집니다.
1.3 공변과 불공변
제네릭과 관련된 중요한 개념으로는 공변(covariant), 반공변(contravariant), 그리고 불공변(invariant)이 있습니다. 이를 이해하면 제네릭 타입 간의 상속 관계와 타입 변환의 제한을 명확히 알 수 있습니다.
1) 공변 (Covariant)
- 정의: A가 B의 하위 타입일 때, T<A>가 T<B>의 하위 타입이라면 T는 공변입니다.
- 예시
- 배열은 공변입니다:
Integer[]
는Object[]
의 하위 타입입니다. - 하지만, 제네릭 타입은 공변이 아닙니다:
List<Integer>
는List<Object>
의 하위 타입이 아닙니다.
- 배열은 공변입니다:
- 요약:
S
가T
의 하위 타입이면,S[]
는T[]
의 하위 타입입니다.
Integer[] integers = {1, 2, 3};
Object[] objects = integers; // 가능 (배열은 공변) -> Integer[]는 Object[]의 하위 타입
위 코드에서 Integer[]
배열은 Object[]
배열로 참조할 수 있습니다. 이는 공변이기 때문에 가능하며, 배열의 타입 간 상속 관계가 그대로 적용되기 때문입니다.
- 배열은 공변 예시 코드
public class ArrayCovarianceExample {
public static void main(String[] args) {
Integer[] integers = new Integer[]{1, 2, 3}; // Integer 배열 생성
printArray(integers); // Object 배열을 받는 메서드에 Integer 배열 전달
}
// Object[]를 인수로 받는 메서드
public static void printArray(Object[] arr) {
for (Object e : arr) {
System.out.println(e);
}
}
}
/*
출력 결과
1
2
3
*/
배열은 공변이기 때문에, 하위 타입의 배열을 상위 타입으로 받을 수 있으며, 이를 활용해 메서드에서도 하위 타입의 배열을 상위 타입으로 처리할 수 있습니다. 예를 들어, 배열의 요소를 출력하는 메서드를 작성할 때 Object[]
배열을 인수로 받는 메서드에 Integer[]
를 전달할 수 있습니다.
이것이 가능한 이유는 배열이 공변이기 때문입니다. Integer
는 Object
의 하위 타입이며, Integer[]
역시 Object[]
의 하위 타입으로 간주되어 타입 변환이 가능합니다.
2) 반공변 (Contravariant)
- 정의: A가 B의 하위 타입일 때, T<B>가 T<A>의 하위 타입이라면 T는 반공변입니다.
- 예시
- 제네릭에서 반공변은 주로
<? super T>
로 구현됩니다. 이는T
의 상위 타입만 허용합니다.
- 제네릭에서 반공변은 주로
- 요약:
S
가T
의 하위 타입이면,T[]
는S[]
의 하위 타입입니다.
List<? super Integer> list = new ArrayList<Object>(); // Integer의 상위 타입 허용
- 제네릭에서 반공변 구현 예시 코드
import java.util.ArrayList;
import java.util.List;
public class ContravariantExample {
public static void main(String[] args) {
List<? super Integer> list = new ArrayList<Object>(); // Integer의 상위 타입 허용
list.add(1); // Integer 추가 가능
list.add(2);
// list.add("String"); // 컴파일 에러: String은 Integer의 상위 타입이 아님
System.out.println(list); // [1, 2]
}
}
<? super Integer>
Integer
타입뿐만 아니라,Integer
의 상위 타입(Number
,Object
등)을 가지는 리스트에도 안전하게 값을 추가할 수 있습니다.list.add("String")
은 허용되지 않습니다. 이는String
이Integer
의 상위 타입이 아니기 때문입니다.
- 반공변과 메서드 활용 예시 코드
import java.util.ArrayList;
import java.util.List;
public class ContravariantMethodExample {
public static void addNumbers(List<? super Integer> list) {
list.add(10); // Integer 추가
list.add(20); // Integer 추가
}
public static void main(String[] args) {
List<Number> numberList = new ArrayList<>(); // Number는 Integer의 상위 타입
addNumbers(numberList); // 안전하게 Integer 추가 가능
System.out.println(numberList); // [10, 20]
}
}
- 반공변은 특정 타입의 값을 추가하거나 설정할 때 유용합니다.
- 위 예제에서,
addNumbers
메서드는Integer
타입과 그 상위 타입을 허용하므로,Number
타입 리스트에 안전하게Integer
를 추가할 수 있습니다.
3) 불공변 / 무공변 (Invariant)
- 정의: A가 B의 하위 타입이더라도, T<A>와 T<B>는 서로 다른 타입으로 간주됩니다.
- 예시
List<Integer>
와List<Object>
는 서로 아무런 관계가 없습니다.- 이는 제네릭의 타입 안전성을 위해 설계된 특징입니다.
- 요약:
List<S>
와List<T>
는 서로 다른 타입입니다.
List<Integer> integerList = Arrays.asList(1, 2, 3);
List<Object> objectList = integerList; // 컴파일 에러: 제네릭은 불공변
위 코드에서는 List<Integer>
가 List<Object>
로 대체될 수 없기 때문에 컴파일 에러가 발생합니다. 제네릭은 불공변이므로, 타입 간의 상속 관계가 제네릭 타입 간에는 적용되지 않습니다.
- 제네릭은 불공변 예시 코드
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
public class GenericInvariantExample {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3);
printCollection(list); // 컴파일 에러 발생
}
// Object 타입의 컬렉션을 받는 메서드
public static void printCollection(Collection<Object> c) {
for (Object e : c) {
System.out.println(e);
}
}
}
/*
에러 발생
error: incompatible types: List<Integer> cannot be converted to Collection<Object>
*/
제네릭은 불공변이기 때문에, 컬렉션을 다루는 메서드에서 상위 타입을 허용하도록 선언하더라도, 하위 타입의 제네릭을 전달할 수 없습니다. 코드를 보면, List<Integer>
를 Collection<Object>
를 받는 메서드에 전달하려고 하면 컴파일 에러가 발생합니다.
컴파일 에러가 발생하는 이유는 List<Integer>
는 Collection<Object>
의 하위 타입이 아니기 때문입니다.
4) 다형성과의 관계
- 공변, 반공변, 불공변은 언뜻 다형성(polymorphism)에서의 업캐스팅, 다운캐스팅과 유사해 보일 수 있습니다.
- 배열의 경우 다형성이 적용되어 공변을 허용하지만, 제네릭 타입에서는 타입 안정성을 위해 공변이 기본적으로 지원되지 않습니다.
참고) 왜 제네릭은 불공변인가?
배열과는 다르게, 제네릭은 타입 안전성을 보장하기 위해 불공변성을 채택했습니다. 제네릭이 불공변이어야 잘못된 타입 사용을 막을 수 있습니다. 만약 List<Integer>
가 List<Object>
의 하위 타입으로 간주되었다면, Object
타입의 객체(예: String
)를 List<Integer>
에 추가할 수 있었을 것입니다. 이는 타입 안전성을 해치고, 런타임 에러를 초래할 수 있습니다.
List<Integer> integerList = Arrays.asList(1, 2, 3);
List<Object> objectList = integerList; // 만약 허용된다면...
objectList.add("String"); // 타입 불일치 문제 발생
Integer num = integerList.get(0); // Integer로 캐스팅 시 런타임 에러
위 코드에서, objectList.add("String")
과 같은 코드가 허용되면 정수 리스트에 문자열을 추가할 수 있습니다. 그러나 리스트의 원소가 Integer
타입이라고 가정했기 때문에 런타임 시 타입 오류가 발생하게 됩니다.
1.4 제네릭과 와일드카드의 등장
제네릭이 도입되기 전에는 타입 안정성이 없어서 런타임 시 에러가 발생할 수 있었습니다. 제네릭이 도입됨으로써 컴파일 시점에 타입 체크가 가능해졌지만, 제네릭이 불공변이기 때문에 특정 타입의 컬렉션을 모두 처리하는 메서드를 정의할 때 제한이 생겼습니다.
이 문제를 해결하기 위해 와일드카드(?
)가 등장했습니다.
와일드카드는 제네릭의 타입 불일치 문제를 해결하기 위한 방편으로, 제네릭이 불공변인 상황에서 타입 유연성을 제공하여 상속 관계를 활용한 타입 처리를 가능하게 합니다.
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
public class WildcardExample {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3);
printCollection(list); // 와일드카드를 사용하면 정상 동작
}
// 와일드카드 ? 를 사용하여 모든 타입 허용
public static void printCollection(Collection<?> c) {
for (Object e : c) {
System.out.println(e);
}
}
}
위 코드에서는 와일드카드(?
)를 사용하여 타입 유연성을 높였으며, List<Integer>
를 Collection<?>
로 처리할 수 있게 되었습니다. 와일드카드를 통해 타입 안정성을 유지하면서 제네릭의 유연성을 확보할 수 있습니다.
와일드카드의 더 자세한 내용은 다음 [JAVA] 시리즈인 와일드카드 파트에서 설명하겠습니다.
1.5 제네릭 사용법
1) 제네릭을 선언하는 방법
제네릭은 클래스, 인터페이스, 메서드에 적용할 수 있습니다. 제네릭을 사용하면 클래스, 인터페이스, 메서드에서 타입을 유연하게 처리할 수 있고, 구체적인 타입은 사용 시점에 결정됩니다.
- 클래스에서 제네릭을 선언할 때는 클래스 이름 뒤에
<T>
와 같은 형식으로 타입 매개변수를 선언합니다. - 인터페이스도 마찬가지로 인터페이스 이름 뒤에
<T>
형식으로 타입 매개변수를 선언합니다. - 메서드에서는 메서드 선언부에 리턴 타입 앞에
<T>
와 같은 형식으로 타입 매개변수를 선언합니다.
2) 여러 타입 매개변수 사용
제네릭은 동시에 여러 개의 타입 매개변수를 선언할 수 있습니다. 여러 타입을 선언할 때는 콤마(,
)로 구분합니다.
class Pair<K, V> { // K와 V 두 개의 타입 매개변수를 사용
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
}
public class MultipleGenericsExample {
public static void main(String[] args) {
Pair<String, Integer> pair = new Pair<>("Age", 25); // String, Integer로 타입 지정
System.out.println("Key: " + pair.getKey() + ", Value: " + pair.getValue());
}
}
- 이 예제에서
Pair<K, V>
클래스는 두 개의 타입 매개변수를 받아들이며, 이를 통해 두 개의 서로 다른 타입을 처리할 수 있습니다.
3) 와일드카드 (Generic Wildcards)
자바 제네릭에서는 와일드카드(?
)를 사용하여 타입을 유연하게 처리할 수 있습니다. 와일드카드는 알 수 없는 타입을 의미하며, 상한 제한이나 하한 제한을 지정할 수 있습니다.
- 상한 제한(Upper Bound):
<? extends T>
는 T의 하위 클래스만 허용합니다. - 하한 제한(Lower Bound):
<? super T>
는 T의 상위 클래스만 허용합니다.
import java.util.ArrayList;
import java.util.List;
public class WildcardExample {
// 상한 제한: Number와 그 하위 클래스만 허용
public static void printNumbers(List<? extends Number> list) {
for (Number num : list) {
System.out.println(num);
}
}
public static void main(String[] args) {
List<Integer> intList = new ArrayList<>();
intList.add(1);
intList.add(2);
intList.add(3);
printNumbers(intList); // Integer는 Number의 하위 클래스이므로 허용
}
}
List<? extends Number>
는Number
와 그 하위 클래스만 허용하는 제네릭 리스트입니다.
1.6 제네릭에서 자주 사용하는 타입 인자
자바 제네릭을 사용할 때 자주 사용되는 타입 매개변수에 대한 표준화된 약어들이 있습니다. 이는 자바 개발자들 사이에서 관습적으로 많이 사용되며, 의미를 쉽게 이해할 수 있도록 돕습니다.
타입 인자 | 의미 |
---|---|
T |
Type: 일반적으로 임의의 타입을 나타낼 때 사용합니다. |
E |
Element: 컬렉션과 같은 자료구조의 요소를 나타낼 때 사용합니다. |
K |
Key: 맵(Map)의 키를 나타낼 때 사용합니다. |
V |
Value: 맵(Map)의 값을 나타낼 때 사용합니다. |
N |
Number: 숫자를 나타낼 때 사용합니다. |
이러한 약어를 사용하면 코드를 읽을 때 타입 매개변수의 의미를 쉽게 파악할 수 있습니다. 제네릭 클래스나 메서드를 정의할 때, 적절한 이름을 사용하면 코드 가독성을 높이는 데 도움이 됩니다.
2. 제네릭의 기본 개념
2.1 제네릭 클래스
제네릭 클래스(Generic Class)는 타입 매개변수(Type Parameter)를 사용하여 여러 타입을 처리할 수 있는 클래스를 정의하는 방법입니다. 제네릭 클래스를 사용하면 특정 타입에 종속되지 않고, 클래스 정의 시에는 구체적인 타입을 명시하지 않은 채 구현할 수 있으며, 클래스의 인스턴스를 만들 때 타입을 명시하여 사용할 수 있습니다.
1) 제네릭 클래스의 정의
제네릭 클래스는 클래스 이름 뒤에 꺽쇠(<>
)를 사용해 타입 매개변수를 선언하여 정의됩니다. 자바에서는 관습적으로 대문자 알파벳 한 글자로 타입 매개변수를 선언하는 것이 일반적이며, 보통 T
(Type), E
(Element), K
(Key), V
(Value) 등이 사용됩니다.
2) 제네릭 클래스의 문법
class ClassName<T> { // 여기서 T는 타입 매개변수
private T field; // T 타입의 필드를 선언
public void setField(T field) { // T 타입의 파라미터를 받는 메서드
this.field = field;
}
public T getField() { // T 타입을 반환하는 메서드
return field;
}
}
T
는 타입 매개변수로, 구체적인 타입으로 대체될 예정입니다. 실제로 이 클래스를 사용할 때는 타입을 명시해야 합니다.
3) 제네릭 클래스 사용 시 장점
- 타입 안전성: 클래스 인스턴스를 만들 때 특정 타입을 명시할 수 있으므로 잘못된 타입이 들어가는 실수를 방지합니다. 이는 컴파일 시점에 타입 체크가 이루어지기 때문에 런타임 에러를 방지할 수 있습니다.
- 재사용성: 클래스 코드를 여러 타입에 대해 재사용할 수 있습니다. 타입별로 클래스를 다시 작성할 필요가 없습니다.
- 제네릭 클래스 사용 예시 코드
// T는 사용자가 지정하는 타입을 나타냄
class Box<T> {
private T item;
public void set(T item) {
this.item = item;
}
public T get() {
return item;
}
}
public class GenericClassExample {
public static void main(String[] args) {
// Box 클래스에 String 타입을 지정
Box<String> stringBox = new Box<>(); // T가 String으로 대체됨
stringBox.set("Hello");
System.out.println("String Box: " + stringBox.get());
// Box 클래스에 Integer 타입을 지정
Box<Integer> integerBox = new Box<>(); // T가 Integer로 대체됨
integerBox.set(123);
System.out.println("Integer Box: " + integerBox.get());
}
}
Box<T>
는 제네릭 클래스입니다.T
는 타입 매개변수로, 사용자가 클래스를 사용할 때 원하는 타입으로 대체됩니다.Box<String>
: T가 String으로 대체되어, 문자열만 저장할 수 있는 박스가 됩니다.Box<Integer>
: T가 Integer로 대체되어, 정수만 저장할 수 있는 박스가 됩니다.
2.2 제네릭 인터페이스
제네릭 인터페이스(Generic Interface)는 인터페이스 선언에 타입 매개변수를 지정하여 타입에 유연한 인터페이스를 정의하는 방법입니다. 제네릭 인터페이스를 사용하면 다양한 타입에 대해 동일한 동작을 제공하는 재사용 가능한 인터페이스를 만들 수 있습니다.
1) 제네릭 인터페이스의 정의
제네릭 인터페이스는 인터페이스 이름 뒤에 꺽쇠(<>
)를 사용하여 타입 매개변수를 선언합니다. 제네릭 인터페이스를 구현하는 클래스는 구체적인 타입을 명시하거나 제네릭 클래스로 구현할 수 있습니다.
2) 제네릭 인터페이스의 문법
interface DataStore<T> { // T는 제네릭 타입 매개변수
void save(T data); // T 타입 데이터를 저장하는 메서드
T load(); // T 타입 데이터를 로드하는 메서드
}
3) 제네릭 인터페이스의 장점
- 타입 안전성: 제네릭을 사용하여 인터페이스를 정의하면, 잘못된 타입을 사용하는 실수를 방지할 수 있습니다.
- 유연성: 제네릭 인터페이스는 다양한 타입에 대해 유연한 처리를 지원하며, 같은 인터페이스를 다른 타입에 대해 재사용할 수 있습니다.
- 재사용성: 제네릭 인터페이스는 한 번 정의되면 다양한 타입에 대해 재사용할 수 있습니다.
- 구체적인 타입을 지정하는 경우 예시 코드
// String 타입을 사용하는 DataStore 구현
class StringDataStore implements DataStore<String> {
private String data;
@Override
public void save(String data) {
this.data = data;
}
@Override
public String load() {
return data;
}
}
public class GenericInterfaceExample {
public static void main(String[] args) {
DataStore<String> store = new StringDataStore(); // String 타입 명시
store.save("Hello World");
System.out.println(store.load()); // "Hello World" 출력
}
}
- 제네릭 클래스로 구현하는 경우 예시 코드
// 제네릭 클래스로 DataStore 구현
class GenericDataStore<T> implements DataStore<T> {
private T data;
@Override
public void save(T data) {
this.data = data;
}
@Override
public T load() {
return data;
}
}
public class GenericInterfaceExample2 {
public static void main(String[] args) {
DataStore<Integer> intStore = new GenericDataStore<>(); // Integer 타입 명시
intStore.save(123);
System.out.println(intStore.load()); // 123 출력
DataStore<String> stringStore = new GenericDataStore<>(); // String 타입 명시
stringStore.save("Hello Generics");
System.out.println(stringStore.load()); // "Hello Generics" 출력
}
}
2.3 제네릭 메서드
제네릭 메서드(Generic Method)는 메서드 자체에서 타입 매개변수를 선언하여 다양한 타입을 처리할 수 있는 메서드를 말합니다. 제네릭 메서드는 클래스가 제네릭일 필요는 없으며, 메서드의 타입 매개변수를 독립적으로 정의할 수 있습니다.
1) 제네릭 메서드의 정의
제네릭 메서드는 메서드 리턴 타입 앞에 타입 매개변수를 명시합니다. 이렇게 하면 해당 메서드에서 사용할 수 있는 타입을 유연하게 지정할 수 있습니다.
2) 제네릭 메서드의 문법
class ClassName {
// 제네릭 메서드 선언, <T>는 이 메서드에서 사용할 타입 매개변수
public <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
}
<T>
는 이 메서드의 타입 매개변수입니다. 이 메서드는 호출될 때T
가 구체적인 타입으로 대체됩니다.- 제네릭 메서드는 특정 클래스에 속하지 않아도 됩니다. 즉, 클래스 자체가 제네릭일 필요는 없습니다.
3) 제네릭 메서드 사용 시 장점
- 타입에 독립적인 메서드를 정의할 수 있습니다. 특정 메서드가 여러 타입을 처리해야 할 때 유연한 방법을 제공합니다.
- 코드 재사용성이 높아집니다. 동일한 로직을 여러 타입에 대해 중복해서 작성할 필요가 없습니다.
- 제네릭 메서드 사용 예시 코드
public class GenericMethodExample {
// <T>를 통해 메서드가 여러 타입을 처리할 수 있게 정의
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
public static void main(String[] args) {
Integer[] intArray = {1, 2, 3, 4}; // Integer 타입 배열
String[] strArray = {"A", "B", "C"}; // String 타입 배열
// Integer 타입 배열 출력
// 1
// 2
// 3
// 4
printArray(intArray); // T가 Integer로 대체됨
// String 타입 배열 출력
// A
// B
// C
printArray(strArray); // T가 String으로 대체됨
}
}
<T>
는 타입 매개변수이며, 컴파일 시점에 타입이 결정됩니다.printArray
메서드는 다양한 타입의 배열을 처리할 수 있습니다. 이 예제에서intArray
는Integer[]
타입으로,strArray
는String[]
타입으로 호출됩니다.
2.4 제네릭 클래스와 제네릭 메서드의 차이점
- 제네릭 클래스는 클래스 수준에서 타입 매개변수를 정의하고, 그 클래스의 모든 메서드와 필드에서 동일한 타입을 사용합니다.
- 제네릭 메서드는 메서드 수준에서 타입 매개변수를 정의하고, 메서드가 호출될 때마다 타입을 유연하게 지정할 수 있습니다. 제네릭 메서드는 클래스 자체가 제네릭일 필요는 없습니다.
2.5 제네릭 타입과 제네릭 메서드의 순위
자바에서 제네릭 클래스와 제네릭 메서드를 함께 사용할 때, 제네릭 메서드의 타입 매개변수가 제네릭 클래스의 타입 매개변수보다 우선 적용됩니다. 이는 같은 이름의 타입 매개변수를 사용할 때 혼란을 초래할 수 있으므로 주의가 필요합니다.
1) 우선순위와 작동 원리
- 제네릭 메서드의 타입 매개변수는 해당 메서드 내에서만 적용되며, 메서드 호출 시 구체적인 타입이 결정됩니다.
- 제네릭 클래스의 타입 매개변수는 클래스 전체에서 적용되지만, 메서드 내에서는 제네릭 메서드의 타입 매개변수가 우선 적용됩니다.
- 제네릭 클래스와 제네릭 메서드의 타입 매개변수 우선순위 예시 코드
class ComplexBox<T extends Animal> { // 제네릭 클래스 타입 매개변수 T
private T animal;
public void set(T animal) {
this.animal = animal;
}
public T get() {
return animal;
}
// 제네릭 메서드 선언 (T와 다른 타입 매개변수 Z 사용)
public <Z> Z printAndReturn(Z z) {
System.out.println(z.toString());
return z;
}
}
class Animal {
public String getName() {
return "Animal";
}
}
public class GenericPriorityExample {
public static void main(String[] args) {
ComplexBox<Animal> box = new ComplexBox<>();
box.set(new Animal());
// 제네릭 메서드 사용 시 클래스의 타입 매개변수와 무관
String result = box.<String>printAndReturn("Hello, Generics!");
System.out.println("Result: " + result);
// printAndReturn 메서드 내에서 T가 아닌 Z가 적용
Object obj = box.printAndReturn(new Object());
System.out.println("Result: " + obj);
}
}
ComplexBox<T extends Animal>
: 클래스 레벨의 타입 매개변수T
는Animal
및 그 하위 타입으로 제한됩니다.<Z> Z printAndReturn(Z z)
: 메서드 레벨의 타입 매개변수Z
는 클래스의 타입 매개변수T
와 무관하게 동작하며, 호출 시점에 타입이 결정됩니다.- 메서드 우선순위
box.printAndReturn("Hello")
호출 시,T
대신Z
가 적용되어 String 타입으로 동작합니다.box.printAndReturn(new Object())
는Z
가 Object로 대체됩니다. 이때, 클래스의 타입 매개변수T
는 영향을 미치지 않습니다.
2) 이름 충돌 방지
만약 클래스와 메서드에서 같은 이름의 타입 매개변수를 사용할 경우, 혼동을 줄이기 위해 서로 다른 이름을 사용하는 것이 좋습니다.
public class ComplexBox<T extends Animal> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
// 타입 매개변수 이름을 Z로 변경하여 충돌 방지
public <Z> Z printAndReturn(Z z) {
System.out.println(z.toString());
return z;
}
}
3) 주의점
- 제네릭 메서드가 적용되면, 클래스 레벨의 타입 매개변수는 무시됩니다.
- 제네릭 타입과 메서드의 타입이 겹칠 경우, 메서드 내에서 혼동이 발생할 수 있으므로 타입 이름을 명확히 구분해야 합니다.
2.6 제네릭 타입 제한 (Bounded Type)
제네릭에서 타입 제한을 통해 특정 타입이나 그 상위/하위 타입만 허용할 수 있습니다. 이를 통해 제네릭을 사용할 때 타입 안정성을 유지하면서도, 더 구체적인 타입을 요구할 수 있습니다. 이때 제네릭 타입 제한은 클래스 선언부에서 직접 설정할 수도 있고, 와일드카드(?
)를 이용해 메서드에서 유연하게 처리할 수도 있습니다.
1) 상한 제한 (Upper Bounded Wildcards)
상한 제한은 T extends 클래스
로 지정하여 특정 클래스와 그 하위 클래스만 허용하는 방식입니다. 즉, 제네릭 타입 파라미터가 특정 클래스 또는 그 하위 클래스여야 한다는 조건을 설정할 수 있습니다.
class NumberBox<T extends Number> { // T는 Number 또는 그 하위 클래스만 허용
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
public class BoundedTypeExample {
public static void main(String[] args) {
NumberBox<Integer> intBox = new NumberBox<>(); // Integer는 Number의 서브클래스
intBox.set(100);
System.out.println(intBox.get());
// NumberBox<String> strBox = new NumberBox<>(); // 컴파일 에러: String은 Number의 서브클래스가 아님
}
}
T extends Number
는Number
클래스의 하위 클래스만 제네릭 타입으로 사용할 수 있다는 것을 의미합니다. 따라서Integer
,Double
,Float
같은 숫자 타입만 허용됩니다.- 컴파일 시점에 타입이 제한되므로,
NumberBox<String>
과 같은 잘못된 타입 선언을 방지할 수 있습니다.
2) 하한 제한 (Lower Bounded Wildcards)
하한 제한은 와일드카드(? super T
)를 통해 특정 타입과 그 상위 타입만 허용하는 방식입니다. 이는 주로 객체를 추가하거나 수정하는 메서드에서 사용되며, 입력이나 추가 작업이 필요할 때 유용합니다.
하한 제한을 사용하면, 특정 타입뿐만 아니라 그 상위 클래스에서도 안전하게 값을 추가할 수 있습니다.
import java.util.List;
import java.util.ArrayList;
public class LowerBoundedWildcardExample {
// Integer의 상위 클래스인 Number나 Object만 허용
public static void addNumbers(List<? super Integer> list) {
list.add(1); // Integer 타입 값 추가 가능
list.add(2);
}
public static void main(String[] args) {
List<Number> numberList = new ArrayList<>(); // Number는 Integer의 상위 타입
addNumbers(numberList); // Number 타입 리스트에 Integer 추가 가능
System.out.println(numberList); // [1, 2] 출력
List<Object> objectList = new ArrayList<>(); // Object는 Integer의 상위 타입
addNumbers(objectList); // Object 타입 리스트에도 Integer 추가 가능
System.out.println(objectList); // [1, 2] 출력
}
}
<? super Integer>
는 Integer와 그 상위 클래스(예:Number
,Object
)만 허용하는 제네릭 리스트입니다.- 이 방식은 객체를 추가하는 경우에 유용하며,
Number
나Object
와 같은 상위 타입 리스트에도 안전하게 값을 추가할 수 있습니다.
3) 하한 제한과 와일드카드의 관계
하한 제한은 주로 와일드카드(? super T
)를 사용하여 메서드나 제네릭 클래스에서 상위 클래스까지도 허용하고 싶을 때 사용됩니다. 즉, 상위 클래스와의 유연한 타입 처리를 지원합니다.
- 상한 제한(Upper Bounded)은
<? extends T>
형태로, 특정 클래스와 그 하위 클래스만 허용합니다. - 하한 제한(Lower Bounded)은
<? super T>
형태로, 특정 클래스와 그 상위 클래스만 허용합니다.
하한 제한은 제네릭 메서드에서 와일드카드를 통해 사용되므로, 더 자세한 설명은 와일드카드 파트에서 설명하겠습니다.
4) 와일드카드와의 차이점
- 타입 제한은 제네릭 클래스나 인터페이스 선언 시 타입 매개변수에 대한 제한을 설정합니다. 예를 들어,
T extends Number
는 제네릭 클래스 선언 시 특정 타입 제한을 명확히 지정하는 것입니다. - 와일드카드는 메서드 매개변수에서 사용되며, 상한(
? extends T
) 또는 하한(? super T
)을 통해 메서드의 파라미터 타입을 유연하게 처리할 수 있습니다. 와일드카드는 제네릭 클래스나 메서드 호출 시점에 입력 파라미터의 타입을 제한하는 방식으로 사용됩니다.
3. 정리
- 제네릭이란?
- 제네릭은 컴파일 시점에 타입을 체크하여 타입 안정성을 제공하는 기능으로, 다양한 타입을 처리하면서도 코드 재사용성을 높입니다.
- 제네릭의 장점
- 컴파일 시 타입 체크 강화: 런타임 에러를 방지
- 명시적 타입 캐스팅 제거: 코드 가독성과 유지보수성 향상
- 재사용성: 동일한 코드로 여러 타입을 처리 가능
- 주요 개념
- 공변:
S
가T
의 하위 타입이면,S[]
는T[]
의 하위 타입이다. (배열은 공변) - 반공변:
S
가T
의 하위 타입이면,T<S>
는T<T>
의 하위 타입이다. (주로<? super T>
로 구현) - 불공변:
List<S>
와List<T>
는 서로 다른 타입으로 간주된다. - 와일드카드: 타입 유연성을 위해
? extends
와? super
를 사용
- 공변:
- 활용
- 제네릭 클래스: 타입 매개변수를 통해 유연한 클래스 정의
- 제네릭 메서드: 메서드 자체에서 타입 매개변수 선언 가능
- 타입 제한: 특정 타입이나 상하위 타입만 허용(
T extends
,? super
등)
- 제네릭 사용 시 주의
- 타입 안정성을 저해하는 모호한 설정은 피해야 함
- 제네릭 타입과 제네릭 메서드가 같은 타입 매개변수 이름을 사용할 경우, 혼동을 피하기 위해 명확히 구분하는 것이 중요
'BackEnd > JAVA' 카테고리의 다른 글
[JAVA] 얕은 복사 (Shallow Copy) VS 깊은 복사 (Deep Copy) (2) | 2025.02.06 |
---|---|
[JAVA] 와일드카드 (Generic Wildcards) 정리 (1) | 2025.02.05 |
[JAVA] 스레드 (Thread) 정리 (2) | 2025.01.25 |
[JAVA] 인터페이스 심화: 다중 상속, 다중 구현 (3) | 2025.01.20 |
[JAVA] try-with-resources 정리 (0) | 2025.01.19 |