공변성은 서브타입 관계가 제네릭 타입에도 적용되는 것을 의미한다. 즉, Integer는 Number의 서브타입이므로, 공변성을 지원하는 경우 List는 List로 처리될 수 있다.
List<Number> numbers = new ArrayList<Integer>(); // 컴파일 오류
하지만 결론만 먼저 말하면 제네릭은 서브타입을 지원하지 않기 때문에 이렇게 쓰면 안됨
배열의 공변성 예제
배열은 공변성이 있으므로, 하위 타입의 배열을 상위 타입의 배열로 사용할 수 있다.
@Test
void arrayCovarianceTest() {
Integer[] integers = new Integer[]{1, 2, 3}; // Integer 배열 생성
printArray(integers); // Integer[]를 Object[]로 전달 가능
}
void printArray(Object[] arr) {
for (Object e : arr) {
System.out.println(e); // 배열 요소 출력
}
}
위의 코드에서 Integer[]는 Object[]의 하위 타입으로 간주된다. 따라서 printArray(Object[] arr) 메서드에 Integer[]를 인자로 전달해도 문제가 발생하지 않는다.
불공변성은 제네릭 타입의 상속 관계가 명시적으로 정의되지 않는 한 유지되지 않는 것을 의미한다.
즉, 제네릭 타입은 불공변성을 가진다고 말할 수 있다.
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
public class GenericInvarianceExample {
@Test
void genericInvarianceTest() {
List<Integer> list = Arrays.asList(1, 2, 3); // List<Integer> 생성
printCollection(list); // 컴파일 오류 발생
}
void printCollection(Collection<Object> c) { // Collection<Object> 파라미터
for (Object e : c) {
System.out.println(e); // 컬렉션 요소 출력
}
}
}
제네릭의 불공변성 예제
제네릭 타입은 불공변성을 가지므로, List를 List로 사용할 수 없다.
@Test
void genericInvarianceTest() {
List<Integer> list = Arrays.asList(1, 2, 3); // List<Integer> 생성
printCollection(list); // 와일드카드 타입으로 인해 호출 가능
}
void printCollection(Collection<?> c) {
for (Object e : c) {
System.out.println(e); // 컬렉션 요소 출력
}
}
위의 코드에서 List는 List
의 하위 타입이 아니기 때문에, printCollection(Collection c) 메서드에 List를 전달하면 컴파일 오류가 발생한다.;
제네릭 타입은 불공변성이기 때문에, List와 List는 아무런 관계가 없다.
이러한 제네릭의 불공변 때문에 와일드카드(제네릭의 ?타입)가 등장할 수 밖에 없었다.
2. 제네릭
1) 제네릭이란?
자바에서 제네릭(Generics)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. 제네릭은 런타임 형변환 오류를 방지하기 위해, 자바 5(JDK 1.5)부터 도입되었다. 컴파일러가 안전하게 자동으로 형변환을 추가해줄 수 있게 되었다.
객체별로 다른 타입의 자료가 저장될 수 있도록 한다.
자바에서 배열과 함께 자주 쓰이는 자료형이 리스트(List)인데, 다음과 같이 클래스 선언 문법에 꺾쇠 괄호 <> 로 되어있는 코드 형태가 있다.
ArrayList<String> list = new ArrayList<>();
꺾쇠 괄호가 바로 제네릭이다. 괄호 안에는 타입명을 기재한다.
제네릭 형식: 클래스/인터페이스 이름<실제 타입 매개변수>
그렇게 되면 저 리스트 클래스 자료형의 타입은 String 타입으로 지정되어 문자열 데이터만 리스트에 적재할 수 있게 된다.
아래 그림과 같이 배열과 리스트의 선언문 형태를 비교해보면 이해하기 쉬울 것이다. 선언하는 키워드나 문법 순서가 다를뿐, 결국 자료형명을 선언하고 자료형의 타입을 지정한다는 점은 같다고 볼 수 있다.
이처럼 제네릭은 배열의 타입을 지정하듯이 리스트 자료형 같은 컬렉션 클래스나 메소드에서 사용할 내부 데이터 타입(type)을 파라미터(parameter) 주듯이 외부에서 지정하는 이른바 타입을 변수화 한 기능이라고 이해하면 된다.
제네릭 타입을 하나 정의하면, 그에 딸린 로 타입(Raw Type)도 함께 정의된다.
☄️ 제네릭 타입 매개변수
제네릭은 <> 를 사용하는데 이를 다이아몬드 연산자라고 한다. 그리고 이 꺾쇠 괄호 안에 식별자 기호를 지정함으로써 파라미터화 할 수 있다. 이것을 마치 메소드가 매개변수를 받아 사용하는 것과 비슷하여 제네릭의 타입 매개변수(parameter) / 타입 변수 라고 부른다.
List<T> 타입 매개변수
List<String> stringList = new Array<String>(); //매개변수화된 타입
☄️ 타입 파라미터 정의
타입 매개변수는 제네릭 클래스를 설계할 때 사용된다. 제네릭을 통해 코드의 타입 안정성을 높이고 반복적인 코드를 줄일 수 있다.
예제: 제네릭을 사용한 클래스 정의
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
는 타입 매개변수이다. 클래스 내부에서 T를 타입처럼 사용할 수 있다.
타입 파라미터 사용 예시
제네릭 클래스를 만들었다면, 실제 인스턴스를 생성할 때 타입을 지정해야 한다. 이때 타입 매개변수가 지정되면 해당 타입으로 모든 T가 대체된다. 이를 구체화라고 한다.
예제: 타입 매개변수 할당
// 정수 타입 할당
FruitBox<Integer> intBox = new FruitBox<>();
// 실수 타입 할당
FruitBox<Double> doubleBox = new FruitBox<>();
// 문자열 타입 할당
FruitBox<String> stringBox = new FruitBox<>();
// 클래스 타입 할당 (예: Apple 클래스)
FruitBox<Apple> appleBox = new FruitBox<>();
이런 방식으로 제네릭을 사용하면 타입 안정성이 확보된다.
타입 추론
JDK 1.7 이후부터는 제네릭 객체를 생성할 때 오른쪽에 있는 타입 매개변수를 생략할 수 있다. 컴파일러가 타입을 추론하기 때문이다.
// 타입 매개변수 생략 전
FruitBox<Apple> appleBox = new FruitBox<Apple>();
// 타입 매개변수 생략 후
FruitBox<Apple> appleBox = new FruitBox<>();
컴파일러가 앞에서 지정된 타입을 보고 알아서 추론해준다.
제네릭에서 할당 가능한 타입
제네릭에서 할당할 수 있는 타입은 참조 타입(Reference Type)만 가능하다.
즉, int, double 같은 기본 타입(Primitive Type)은 사용할 수 없다. 대신 래퍼 클래스(Wrapper Class)를 사용하면 된다.
// Primitive 타입은 사용 불가
// List<int> intList = new ArrayList<>(); // 오류 발생
// Wrapper 클래스를 사용해야 함
List<Integer> integerList = new ArrayList<>();
기본 타입을 사용하려면 래퍼 클래스를 이용해야 한다.
제네릭과 다형성
제네릭 타입도 객체 지향의 다형성 원리를 그대로 적용받는다. 부모 타입을 사용한 제네릭이라면, 그 자식 클래스 객체도 타입으로 넣을 수 있다.
예제: 다형성 적용
class Fruit {}
class Apple extends Fruit {}
class Banana extends Fruit {}
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
public class Main {
public static void main(String[] args) {
FruitBox<Fruit> box = new FruitBox<>();
// 다형성 적용: 부모 타입으로 자식 객체도 추가 가능
box.add(new Fruit());
box.add(new Apple());
box.add(new Banana());
}
}
이렇게 하면 다양한 타입의 객체를 한 번에 다룰 수 있다.
복수 타입 파라미터
제네릭 타입은 한 개만 쓰라는 법은 없다.
여러 개의 타입이 필요하다면 <T, U> 같은 형식으로 여러 개를 쓸 수 있다.
예제: 복수 타입 파라미터
class Apple {}
class Banana {}
class FruitBox<T, U> {
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());
}
}
여러 타입의 데이터를 동시에 다룰 수 있어 유용하다.
중첩 타입 파라미터
제네릭 객체를 또 다른 제네릭 타입으로 쓸 수도 있다. 예를 들어, ArrayList<LinkedList>처럼 말이다.
예제: 중첩 타입 파라미터
import java.util.ArrayList;
import java.util.LinkedList;
public class Main {
public static void main(String[] args) {
// LinkedList<String>을 원소로 저장하는 ArrayList
ArrayList<LinkedList<String>> list = new ArrayList<>();
LinkedList<String> node1 = new LinkedList<>();
node1.add("aa");
node1.add("bb");
LinkedList<String> node2 = new LinkedList<>();
node2.add("11");
node2.add("22");
list.add(node1);
list.add(node2);
System.out.println(list);
}
}
이런 식으로 복잡한 자료 구조도 다룰 수 있다.
ArrayList는 중첩된 제네릭 타입으로, 이를 풀어서 설명하면 다음과 같은 뜻이다.
ArrayList는 ArrayList라는 타입의 객체이며, 그 안에 들어가는 원소들이 LinkedList 타입이다. 각 LinkedList는 String 타입의 요소들을 저장할 수 있는 연결 리스트이다.
이를 좀 더 구체적으로 설명하자면:
ArrayList는 여러 개의 LinkedList를 담는 ArrayList이다.
각 LinkedList는 문자열(String)을 저장할 수 있다.
큰 상자(ArrayList)에 여러 개의 작은 상자(LinkedList)가 들어있고, 각 작은 상자에는 문자열(String)이 담겨 있다.
타입 파라미터 네이밍 규칙
타입 파라미터의 기호는 정해진 게 없다. 하지만 보통 약속된 대로 사용하는 게 좋다. 예를 들어 는 타입, 는 요소(Element)를 의미한다. 아래는 자주 쓰는 기호이다:
타입 기호설명
타입 (Type)
요소 (Element), 주로 컬렉션에서 사용
키 (Key), Map<K, V>에서 사용
값 (Value)
숫자 (Number)
<S, U, V>
여러 타입 매개변수를 사용할 때
는 타입을 의미하고, 는 컬렉션의 요소를 의미하는 등 상황에 맞게 네이밍을 사용하면 된다.
위의 내용을 그림으로 정리하면 아래와 같다.
2) 제네릭의 등장 이전 및 도입 배경
제네릭(generic)은 자바 5부터 사용할 수 있다. 제네릭을 지원하기 전에는 컬렉션에서 객체를 꺼낼 때마다 형변환을 해야 했다. 그래서 누군가 실수로 엉뚱한 타입의 객체를 넣어두면 런타임에 형변환 오류가 나곤 했다.
반면, 제네릭을 사용하면 컬렉션이 담을 수 있는 타입을 컴파일러에 알려주게 된다.
그래서 컴파일러는 알아서 형변환 코드를 추가할 수 있게 되고, 엉뚱한 타입의 객체를 넣으려는 시도를 컴파일 과정에서 차단하여 더 안전하고 명확한 프로그램을 만들어 준다. 꼭 컬렉션이 아니더라도 이러한 이점을 누릴 수 있으나, 코드가 복잡해진다는 단점이 따라온다.
제네릭이 도입되기 전에는 컬렉션의 요소를 다루는 메서드(= 로 타입)는 타입 안전성을 보장하지 못했다.
컬렉션의 타입 매개변수를 명시할 수 없기 때문에, 모든 요소는 Object 타입으로 처리되었고, 타입 캐스팅이 필요한 상황에서 문제가 발생할 수 있었다.
예시 1: 컬렉션 요소 출력
void printCollection(Collection c) {
Iterator i = c.iterator(); // 타입 지정 없이 Iterator 사용
while (i.hasNext()) {
System.out.println(i.next()); // 모든 요소는 Object 타입으로 간주
}
}
위 코드에서 컬렉션의 요소들은 Object 타입으로 취급되기 때문에, 특정 타입으로 다루려면 타입 캐스팅이 필요하다. 이는 런타임에 타입 에러를 발생시킬 수 있는 잠재적인 위험을 내포하고 있다.
예시 2: 컬렉션 요소 합 구하기
int sum(Collection c) {
int sum = 0;
Iterator i = c.iterator();
while (i.hasNext()) {
// 문제: 컬렉션의 요소가 Integer가 아닐 수도 있음
sum += Integer.parseInt(i.next().toString()); // 런타임 오류 가능성
}
return sum;
}
위 메서드는 Collection에 있는 요소들이 Integer 타입이라고 가정하고 작성된 것이다. 하지만 만약 String과 같은 다른 타입의 요소를 가진 컬렉션을 전달하면, 컴파일 시에는 문제가 없지만 런타임에 ClassCastException이 발생할 수 있다.
위와 같은 문제를 해결하기 위해 Java 개발자들은 타입을 지정하여 컴파일 시점에 타입 안전성을 보장할 수 있는 방법을 고안하였고, 그 결과 제네릭이 등장하게 되었다.
제네릭을 사용하면, 컬렉션이나 메서드에 타입 매개변수를 지정할 수 있어 컴파일 시점에 타입을 검사할 수 있다. 이렇게 하면 런타임 오류의 가능성을 줄이고, 코드의 안정성과 가독성을 높일 수 있다.
3) 와일드카드의 등장 이유
제네릭을 사용하면 컬렉션에 타입을 지정할 수 있어 컴파일 시점에 타입 안전성을 보장할 수 있다. 예를 들어, Collection 타입을 사용하여 숫자들의 합을 구하는 메서드를 작성할 수 있다.
수정된 코드 예제
int sum(Collection<Integer> c) {
int sum = 0;
for (Integer e : c) { // Collection의 요소 타입을 Integer로 제한
sum += e;
}
return sum;
}
위 코드에서는 Collection 타입을 사용하여, 컬렉션이 Integer 타입의 요소만 포함하도록 제한했다. 컴파일 시점에 타입 검사가 이루어져, 다른 타입의 컬렉션이 전달되면 컴파일 오류가 발생한다. 이로써 타입 안전성을 보장할 수 있다.
제네릭 타입은 불공변성을 가진다. 즉, Collection와 Collection
는 아무런 관계가 없다. 제네릭이 도입되기 전에는 가능했던 작업이 이제는 불가능해진 경우가 발생할 수 있다.
아래와 같이 printCollection 메서드를 작성하고 List를 전달하려고 하면, 컴파일 오류가 발생한다.
@Test
void genericTest() {
List<Integer> list = Arrays.asList(1, 2, 3);
printCollection(list); // 컴파일 오류: Collection<Object>는 Collection<Integer>와 호환되지 않음
}
void printCollection(Collection<Object> c) {
for (Object e : c) {
System.out.println(e);
}
}
Collection는 Collection의 상위 타입이 아니기 때문에, 제네릭 타입에서는 서로 호환되지 않는다.
이로 인해 printCollection 메서드에 List를 전달하려고 하면 컴파일 오류가 발생한다. 이는 제네릭의 불공변성으로 인한 문제이다.
☄️ 와일드카드의 도입
위와 같은 문제를 해결하기 위해 와일드카드(?)가 도입되었다. 와일드카드를 사용하면 제네릭 타입을 보다 유연하게 사용할 수 있으며, 모든 타입의 컬렉션에서 공통으로 사용할 수 있는 메서드를 작성할 수 있다.
☄️ 와일드카드 타입 사용 예제
void printCollection(Collection<?> c) {
for (Object e : c) { // 와일드카드 타입으로 컬렉션의 요소를 다룸
System.out.println(e);
}
}
@Test
void genericTest() {
List<Integer> list = Arrays.asList(1, 2, 3);
printCollection(list); // 이제 컴파일 오류 없이 호출 가능
}
Collection<?>는 비한정적 와일드카드 타입으로, 어떤 타입의 컬렉션이라도 인자로 받을 수 있다. List, List, List 등 다양한 타입의 컬렉션을 모두 전달할 수 있어, 보다 유연한 메서드를 작성할 수 있다. 단, 와일드카드 타입에서는 컬렉션에 새로운 요소를 추가할 수 없고, null만 허용된다. 이는 타입 안전성을 유지하기 위함이다.
Collection는 Collection의 상위 타입이 아니기 때문에, 제네릭 타입에서는 서로 호환되지 않는다. 이로 인해 printCollection 메서드에 List를 전달하려고 하면 컴파일 오류가 발생한다.
오류는 이상적으로 컴파일할때 발견하는 것이 좋지만, 로 타입을 사용한다면 런타임에나 오류를 발견할 수 있다.
3) 제네릭 지원 이후에는?
제네릭을 지원한 이후에는 매개변수화된 컬렉션 타입으로 타입 안전성을 확보한다. 제네릭을 사용하면 타입 선언 자체에 Stamp 인스턴스만 취급한다라는 것이 녹아든다.
private final Collection<Stamp> stamps = ...;
stamps.add(new Coin()); // 컴파일 오류 발생
컴파일 오류가 바로 발생한다. 컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가 하여 절대 실패하지 않음을 보장한다.
제네릭을 사용하여 컬렉션의 타입을 지정함으로써, 컴파일러가 타입 안전성을 보장할 수 있다.
4) 로 타입(Raw Type)과 제네릭 코드의 비교
로 타입을 사용한 코드
import java.util.ArrayList;
import java.util.List;
public class RawTypeExample {
public static void main(String[] args) {
// 로 타입 사용
List list = new ArrayList(); // 타입 매개변수를 지정하지 않음
list.add("Hello");
list.add(123); // 문자열과 숫자를 모두 추가할 수 있음
// 컬렉션의 요소를 가져올 때마다 타입 캐스팅이 필요함
String str = (String) list.get(0);
Integer num = (Integer) list.get(1);
System.out.println(str); // 출력: Hello
System.out.println(num); // 출력: 123
}
}
위 예제에서 List는 로 타입으로 사용되었다. 이 경우, 리스트에 어떤 타입의 객체든 추가할 수 있기 때문에, 각 요소를 꺼낼 때 타입 캐스팅이 필요하다. 만약 잘못된 타입으로 캐스팅하려고 하면 ClassCastException이 발생할 수 있다.
제네릭을 사용한 코드
import java.util.ArrayList;
import java.util.List;
public class GenericTypeExample {
public static void main(String[] args) {
// 제네릭을 사용하여 List<String>으로 선언
List<String> list = new ArrayList<>();
list.add("Hello");
// list.add(123); // 컴파일 오류: 정수는 추가할 수 없음
// 타입 캐스팅이 필요 없음
String str = list.get(0);
System.out.println(str); // 출력: Hello
}
}
위 코드에서 List은 제네릭 타입을 사용하여 선언되었다.
이제 이 리스트에는 String 타입만 저장할 수 있으며, 컴파일 시점에 타입 오류가 발생할 가능성을 줄일 수 있다. 요소를 가져올 때도 타입 캐스팅이 필요하지 않다.
5) 왜 로 타입보다 제네릭을 사용해야 할까?
먼저 하나의 그림으로 정리해보면 사진과 같음
타입 안전성이 확보된다.
private final Collection<Stamp> stamps = ...;
위 예제처럼 컴파일러가 stamps 에는 Stamp 의 인스턴스만 넣어야 함을 인지하기 때문에, 다른 엉뚱한 타입의 인스턴스는 컴파일 에러를 내뱉게 된다.
올바른 인스턴스라면 컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가하기 때문에 그 이후부터는 정상적으로 작동할 것이다.
로 타입은 제네릭이 안겨주는 안전성과 표현력이 없다.
하지만 그럼에도 로 타입이 존재하고 있는 것은, 로 타입을 사용하는 메서드에 매개변수화 타입의 인스턴스를 넘겨도 동작해야 했기 때문이다.
따라서 이러한 마이그레이션 호환성을 위해 로 타입을 지원하고 제네릭 구현에는 소거방식을 도입하게 된다. 소거 방식이란, 런타임에 타입 정보가 사라지는 것을 의미한다.
6) 로 타입은 권장되지 않는다(List와 List
의 차이)
로 타입을 쓰면 제네릭이 안겨주는 안전성과 표현력을 모두 잃게 된다. 제네릭이 등장하기 이전의 코드와의 호환성을 위해서 로 타입이 남겨져 있다.
List와 같은 로 타입은 권장하지 않지만 List
는 괜찮다. 모든 타입을 허용한다는 의사를 컴파일러에게 명확하게 전달한 것이기 때문이다
그렇다면 List와 List의 차이는 무엇일까?
List는 제네릭 타입과 무관한 것이고 List
는 모든 타입을 허용한다는 것이다.
다시 말해서 매개변수로 List를 받는 메서드에 List을 넘길 수 있지만, 제네릭의 하위 규칙 때문에 List
를 받는 메서드에는 매개변수로 넘길 수 없다.
List은 로 타입인 List의 하위 타입이지만 List
의 하위 타입은 아니기 때문이다. 위의 공변 설명에서 함 그래서 List와 같은 매개변수화 타입을 사용할 때와 달리 List같은 로 타입을 사용하면 타입 안전성을 잃게 된다.
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
unsafeAdd(strings, Integer.valueOf(42));
String s = strings.get(0);
}
// 로 타입
private static void unsafeAdd(List list, Object o) {
list.add(o);
}
위의 코드는 컴파일은 성공하지만 로 타입인 List를 사용하여 unchecked call to add(E) as a member of raw type List... 라는 경고 메시지가 발생된다. 그런데 실행을 하게 되면 strings.get(0)의 결과를 형변환하려 할 때 ClassCastException이 발생한다. Integer를 String으로 변환하려고 시도했기 때문이다.
위 코드를 List로 변경하면?
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
unsafeAdd(strings, Integer.valueOf(42));
String s = strings.get(0);
}
// List<Object>
private static void unsafeAdd(List<Object> list, Object o) {
list.add(o);
}
컴파일 오류가 발생하며 incompatible types: List cannot be converted to List
... 라는 메시지가 출력된다. 실행 시점이 아닌 컴파일 시점에 오류를 확인할 수 있어 보다 안전하다.
4. 제네릭의 한계 극복 : 와일드 카드
제네릭을 사용하는 이유는 타입 안전성을 보장하고, 코드의 가독성과 유지보수성을 높이기 위함이다.
하지만 모든 상황에서 특정 타입을 명시할 수 없는 경우가 있기 때문에 비한정적 와일드카드(?)와 로 타입(Raw Type)이 존재한다.
와일드카드는 Java의 제네릭 타입에서 유연성을 높이기 위해 도입된 기능으로, 제네릭 타입 매개변수를 특정하지 않고 사용할 수 있게 해준다. 주로 세 가지 형태로 사용되며, 각각의 의미와 사용법이 조금씩 다르다.
사실 들어가기 전 한마디를 하자면, 우리가 직접 코드를 짤 때 쓰는 것보다는 라이브러리나 클래스 열어보면 많이 정의되어 있는 것을 볼 수 있음. 즉 분석할 때 알고 있으면 도움이 많이 됨
Java에서는 다음 세 가지 형태의 와일드카드를 사용할 수 있다.
비한정적 와일드카드 (?)
상한 경계 와일드카드 (? extends T)
하한 경계 와일드카드 (? super T)
이 세 가지 와일드카드는 서로 다른 상황에서 제네릭 타입의 유연성을 높이기 위해 사용된다.
1) 원소의 타입을 모른채 쓰고 싶다면? 비한정적 와일드 카드 타입 (Set<?>)
표기법: <?>
비한정적 와일드카드 타입은 "아무 타입이나" 허용한다는 의미로, 제네릭 타입 매개변수가 무엇이든 상관없이 사용할 수 있도록 한다.
제네릭 타입의 안전성을 유지하면서도, 실제 타입 매개변수에 의존하지 않는 메서드를 작성할 수 있다.
Set<?>와 같은 비한정적 와일드카드 타입은 Set, Set 등 어떤 타입의 Set이라도 사용할 수 있다.
사용 사례: 컬렉션의 원소 타입이 무엇인지 신경 쓰지 않고, 모든 타입의 컬렉션을 처리하고자 할 때 사용한다. 예를 들어, List<?>는 어떤 타입의 리스트든 받을 수 있다.
제약사항: 비한정적 와일드카드를 사용하는 컬렉션에는 null 외의 다른 값을 추가할 수 없다.
public class TypeTest {
private static void addToWildList(final List<?> list, final Object o) {
// 컴파일 오류: 제네릭 타입에 의존성이 있음
// list.add(o);
// null은 허용됨
list.add(null);
}
public static void main(String[] args) {
List<String> list = new ArrayList<>();
String s = "kimtaeng";
addToWildList(list, s); // Okay! 메서드 호출 자체는 문제없음
}
}
2) 불변성을 강조하며, 안전한 읽기 작업에 적합한 상한 경계 와일드카드 (? extends T)
표기법: <? extends T>
의미: 특정 타입 T와 그 하위 타입만을 허용한다.
사용 사례: 주로 읽기 전용 작업에 사용된다. 예를 들어, 특정 클래스의 하위 클래스 타입을 다룰 때 유용하다.
제약사항: 와일드카드 타입으로 컬렉션에 추가할 수 있는 요소의 타입이 불명확하므로, 컬렉션에 요소를 추가할 수 없다.
void printNumbers(List<? extends Number> list) {
for (Number n : list) {
System.out.println(n);
}
}
위 코드에서 List<? extends Number>는 List, List 등 Number의 하위 타입을 인자로 받을 수 있다.
public void read(Collection<?> list) {
list.add(null);
}
읽기 전용이라는 게 파라미터로 와일드카드 타입인 컬렉션 받으면 add 가 Null 밖에 안되고 읽기만 가능해서 그렇다는 것
?가 들어가면 결국 타입을 알수 없다는 건데 List<? extends Number> list = new ArrayList();라는 정의가 있을 때 컴파일러는 list가 적어도 Number라는 건 알지만 실제로 어떤 타입인지 모르니까 그 다음에 list.add(1.0); 으로 Integer 아닌 값을 넣어도 타입을 몰라서 이부분까지 체크를 못해준다고 함 타입이 다를 수 있는데 컴파일러가 체크해줄수 없어서 막아둔 듯 하다. 반대로 하한제한은 컴파일러가 타입 체크를 해줄 수 있어서 쓰기 작업도 허용해준다고 함
다시 말하면, 상한 제한인 한정적 와일드카드가 읽기 전용인 건 타입 안정성 때문인데 읽을 때는 상위 타입으로 처리하면 되지만 ?는 실제로 어떤 타입이 들어가는지 알 수 없다는 거라서 그렇다고 함 List라고 해도 실제로 Double이 들어갈지 어떤 타입이 들어가는지 모르니까 타입 불일치가 있을수 있고 이건 컴파일타임에 체크할 수 없어서 막아두었다고 함
3) 가변성을 강조하며, 안전한 추가 작업에 적합한 하한 경계 와일드카드 (? super T)
표기법: <? super T>
의미: 특정 타입 T와 그 상위 타입만을 허용한다.
사용 사례: 컬렉션에 안전하게 요소를 추가할 수 있도록 보장한다. T와 그 상위 타입만을 허용하므로, 예를 들어 List<? super Integer>는 Integer, Number, Object 타입의 요소를 추가할 수 있다.
제약사항: 요소를 읽을 때는 Object 타입으로 반환된다.
void addIntegers(List<? super Integer> list) {
list.add(10);
list.add(20);
}
위 코드에서 List<? super Integer>는 List, List, List와 같은 상위 타입의 리스트를 인자로 받을 수 있으며, Integer 타입의 값을 추가할 수 있다.
4) 로 타입 (Set)
로 타입은 제네릭 도입 이전의 컬렉션 타입으로, 타입 안전성을 보장하지 않는다.
로 타입을 사용할 경우, 어떤 타입의 객체든 추가할 수 있으며, 컴파일 시점에 타입 검사가 이루어지지 않아 런타임 오류의 가능성이 높다.
제네릭 타입의 타입 정보가 런타임에 지워지기 때문에 Set과 Set은 동일하게 취급된다.
public class TypeTest2 {
public static void main(String[] args) {
List raw = new ArrayList<String>(); // Okay! 로 타입은 타입 안전성을 제공하지 않음
List<?> wildcard = new ArrayList<String>(); // Okay! 비한정적 와일드카드
raw.add("Hello"); // Okay! 로 타입은 어떤 타입의 원소도 추가 가능
raw.add(1); // 컴파일러가 타입 검사를 하지 않기 때문에 가능
// wildcard.add("Hello"); // 컴파일 오류: 비한정적 와일드카드 타입은 null 외에 추가할 수 없음
List<String> list = new ArrayList<>(); // 제네릭 타입 사용
list.add("Hello"); // String 타입의 원소만 추가 가능
// list.add(1); // 컴파일 오류: 정수는 추가할 수 없음
// 메서드 호출은 가능
wildcard.size(); // Okay!
wildcard.clear(); // Okay!
}
}
5) 와일드카드 사용 시 주의사항
비한정적 와일드카드 (?)는 컬렉션의 타입 매개변수를 알 수 없으므로, 안전한 타입을 보장하기 위해 null 외의 값을 추가할 수 없다.
상한 경계 와일드카드 (? extends T)는 주로 읽기 전용 작업에 사용되며, 컬렉션에 요소를 추가하는 것은 불가능하다.
하한 경계 와일드카드 (? super T)는 안전하게 요소를 추가할 수 있지만, 요소를 읽을 때는 Object로 반환되므로 타입 캐스팅이 필요할 수 있다.
6) 로 타입과의 차이
로 타입 (List)은 타입 안전성이 보장되지 않으며, 제네릭의 장점을 활용할 수 없다. 타입을 지정하지 않으면 컴파일 시점에 타입 오류를 검출할 수 없고, 런타임 오류가 발생할 수 있다.
와일드카드 (List<?>, List<? extends T>, List<? super T>)를 사용하면 제네릭의 타입 안전성을 유지하면서도, 유연한 타입 처리가 가능하다.
7) 와일드카드 사용 시 유의점
와일드카드는 제네릭 메서드 작성 시 유용하며, 특히 라이브러리 설계에서 API의 유연성을 높이는 데 큰 도움이 된다.
클래스 리터럴에서는 로 타입을 사용해야 하며, List<?>.class나 List.class는 허용되지 않다.
instanceof 연산자와 함께 사용할 때도 로 타입을 사용해야 합니다. 제네릭 타입 정보는 런타임에 지워지기 때문ㅇ이다.
// 로 타입을 사용하여 instanceof 연산자 적용
if (o instanceof Set) {
Set<?> set = (Set<?>) o;
}
8) PECS(Producer-Extends, Consumer-Super) 공식
그렇다면 도대체 언제 super를 사용해야 하고, 언제 extends를 사용해야 하는지 헷갈릴 수 있다. 그래서 이펙티브 자바에서는 PECS라는 공식을 만들었는데, 이는 Producer-Extends, Consumer-Super의 줄임말이다. 즉, 컬렉션으로부터 와일드카드 타입의 객체를 꺼내서 생성하면(produce) extends를, 갖고 있는 객체를 컬렉션에 사용(consumer)하여 더하면 super를 사용하라는 것이다.
void printCollection(Collection<? extends MyParent> c) {
for (MyParent e : c) {
System.out.println(e);
}
}
void addElement(Collection<? super MyParent> c) {
c.add(new MyParent());
}
printCollection 같은 경우에는 컬렉션으로부터 원소들을 꺼내면서 와일드카드 타입 객체를 생성(produce)하고 있다. 반대로 addElement의 경우에는 컬렉션에 해당 타입의 원소를 추가함으로써 객체를 사용(consume)하고 있다. 그러므로 와일드카드 타입의 객체를 produce하는 printCollection은 extends가, 객체를 consume하는 addElement에는 super가 적합한 것이다.
9) 로 타입과 비한정적 와일드카드 타입의 차이
특성로 타입 (Set)비한정적 와일드카드 (Set<?>)
타입 안전성
보장되지 않음
보장됨
타입 불변식 유지
위반하기 쉬움
타입 불변식 유지
원소 추가
어떤 타입의 원소도 추가 가능
null 외에는 추가할 수 없음
메서드 호출
타입에 관계없이 사용 가능
제네릭 타입에 의존하지 않는 메서드만 사용 가능
사용 가능 상황
하위 버전과의 호환성 필요 시, 클래스 리터럴, instanceof
제네릭 타입에 의존하지 않는 메서드 작성 시
10) 로 타입이 필요한 예외적인 상황
☄️ 클래스 리터럴
제네릭 타입은 클래스 리터럴에서 사용할 수 없다. List.class와 같은 로 타입만 사용할 수 있으며, List.class나 List<?>.class는 허용되지 않는다.
Class<List> listClass = List.class; // Okay!
☄️ instanceof 연산자
제네릭 타입 정보는 런타임에 제거되므로, instanceof 연산자는 로 타입이나 비한정적 와일드카드 타입에서만 사용할 수 있다. Set<?>을 사용해 타입 캐스팅을 할 수 있다.
if (o instanceof Set) {
Set<?> s = (Set<?>) o; // 로 타입 대신 비한정적 와일드카드 타입으로 형변환
}
⭐ 결론
비한정적 와일드카드 (?): 모든 타입을 허용하지만, 읽기 전용 작업에 적합하며 null 외에는 값을 추가할 수 없다.
상한 경계 와일드카드 (? extends T): 특정 타입의 하위 타입만 허용하며, 주로 읽기 전용 작업에 사용된다.
하한 경계 와일드카드 (? super T): 특정 타입의 상위 타입만 허용하며, 컬렉션에 안전하게 요소를 추가할 때 사용된다.
와일드카드는 제네릭 타입의 유연성을 높이기 위한 중요한 도구이며, 각각의 와일드카드 타입을 적절히 사용하면 코드의 재사용성과 안전성을 동시에 확보할 수 있다. API의 확장느낌으로 클래스 등에서 자주 쓰임으로 알아두면 좋음
⭐ 전체 용어 정리 및 사용 코드
1) 매개변수화 타입 (Parameterized Type)
타입 매개변수를 사용해 실제 타입으로 지정된 제네릭 타입
List<String> list = new ArrayList<>(); // List의 타입 매개변수로 String을 지정
list.add("Hello"); // String 타입의 요소를 추가
2) 실제 타입 매개변수 (Actual Type Parameter)
매개변수화 타입에서 구체적으로 지정된 타입 List
// 매개변수화 타입에서 String이 실제 타입 매개변수
List<String> list = new ArrayList<>(); // 여기서 String이 실제 타입 매개변수로 사용됨
3) 제네릭 타입 (Generic Type)
타입 매개변수를 가지는 클래스나 인터페이스 E
// 제네릭 클래스를 정의할 때 타입 매개변수 T를 사용
public class Box<T> {
private T content; // T 타입의 변수를 선언
public void setContent(T content) {
this.content = content; // T 타입의 값을 설정
}
public T getContent() {
return content; // T 타입의 값을 반환
}
}
// Box 클래스를 사용하는 예제
Box<Integer> intBox = new Box<>(); // Integer 타입의 Box 생성
intBox.setContent(123); // Integer 값 설정
System.out.println(intBox.getContent()); // 출력: 123
Box<String> strBox = new Box<>(); // String 타입의 Box 생성
strBox.setContent("Hello, Generics"); // String 값 설정
System.out.println(strBox.getContent()); // 출력: Hello, Generics
4) 정규 타입 매개변수 (Formal Type Parameter)
제네릭 타입 또는 제네릭 메서드에서 사용되는 타입 매개변수
// 제네릭 클래스 Box의 정의에서 E가 정규 타입 매개변수
public class Box<E> {
private E content; // E 타입의 변수를 선언
public void setContent(E content) {
this.content = content; // E 타입의 값을 설정
}
public E getContent() {
return content; // E 타입의 값을 반환
}
}
5) 비한정적 와일드카드 타입 (Unbounded Wildcard Type)
타입 매개변수를 ?로 지정하여 어떤 타입이든 허용함 List<?>
// 와일드카드 타입을 사용한 메서드 정의
public void printList(List<?> list) {
// List<?>는 어떤 타입의 리스트든 받을 수 있음
for (Object elem : list) {
// 와일드카드 타입이므로 요소를 Object로 취급
System.out.println(elem);
}
}
// 사용 예제
List<String> stringList = Arrays.asList("Apple", "Banana", "Orange");
List<Integer> intList = Arrays.asList(1, 2, 3);
printList(stringList); // 출력: Apple, Banana, Orange
printList(intList); // 출력: 1, 2, 3
6) 로 타입 (Raw Type)
제네릭 타입에서 타입 매개변수를 사용하지 않은 형태 List
// 제네릭 타입의 타입 매개변수를 사용하지 않은 경우
List rawList = new ArrayList(); // 로 타입으로 정의
rawList.add("Hello"); // String 타입의 값 추가
rawList.add(123); // Integer 타입의 값 추가
// 로 타입 사용 시 컴파일러가 타입 안전성을 보장하지 않음
for (Object obj : rawList) {
System.out.println(obj); // 출력: Hello, 123
}
7) 한정적 타입 매개변수 (Bounded Type Parameter)
특정 타입 또는 그 하위 타입으로 제한된 타입 매개변수
// 타입 매개변수 E가 Number 또는 그 하위 타입이어야 함
public <E extends Number> void printNumber(E number) {
System.out.println(number); // Number 타입의 값을 출력
}
// 사용 예제
printNumber(123); // 출력: 123 (Integer)
printNumber(45.67); // 출력: 45.67 (Double)
// printNumber("Hello"); // 컴파일 에러: String은 Number의 하위 타입이 아님
8) 재귀적 타입 한정 (Recursive Type Bound)
자기 자신을 타입 매개변수로 참조하는 타입 한정 <T extends Comparable>
// T가 Comparable<T> 인터페이스를 구현해야 함
public class Node<T extends Comparable<T>> {
private T value; // T 타입의 값을 저장
private Node<T> next; // 다음 노드를 가리키는 포인터
public Node(T value) {
this.value = value; // 노드의 값을 설정
}
public T getValue() {
return value; // 노드의 값을 반환
}
}
// 사용 예제
Node<Integer> node = new Node<>(10); // Integer 타입의 노드 생성
System.out.println(node.getValue()); // 출력: 10
9) 한정적 와일드카드 타입 (Bounded Wildcard Type)
와일드카드 타입이 특정 타입 또는 그 하위/상위 타입으로 제한됨 List<? extends Number>
// Number 또는 그 하위 타입을 요소로 갖는 리스트를 인자로 받음
public void printNumbers(List<? extends Number> list) {
for (Number num : list) {
System.out.println(num); // Number 타입의 요소를 출력
}
}
// 사용 예제
List<Integer> intList = Arrays.asList(1, 2, 3);
List<Double> doubleList = Arrays.asList(1.1, 2.2, 3.3);
printNumbers(intList); // 출력: 1, 2, 3
printNumbers(doubleList); // 출력: 1.1, 2.2, 3.3
10) 제네릭 메서드 (Generic Method)
타입 매개변수를 사용하는 메서드 static List asList(E[] a)
// 제네릭 타입 매개변수를 사용하는 메서드 정의
public static <E> List<E> asList(E[] array) {
return Arrays.asList(array); // 배열을 리스트로 변환하여 반환
}
// 사용 예제
String[] stringArray = {"Hello", "World"};
List<String> stringList = asList(stringArray); // 제네릭 메서드를 사용하여 리스트 생성
System.out.println(stringList); // 출력: [Hello, World]
11) 타입 토큰 (Type Token)
런타임에 제네릭 타입 정보를 제공하기 위해 사용하는 클래스 리터럴 String.class
// 런타임에 타입 정보를 얻기 위해 사용하는 타입 토큰
public <T> T createInstance(Class<T> clazz) throws Exception {
// 클래스 타입 T를 기반으로 새로운 인스턴스를 생성
return clazz.getDeclaredConstructor().newInstance();
}
// 사용 예제
String str = createInstance(String.class); // String 클래스의 인스턴스 생성
System.out.println(str); // 출력: 빈 문자열 (String의 기본 생성자 사용)
코틀린에서의 제네릭... 자바의 단점을 다 커버해줄까?
1) 제네릭이란?
코틀린에서도 제네릭은 자바와 마찬가지로 클래스나 함수가 다양한 타입을 처리할 수 있도록 한다. 코틀린의 제네릭은 자바보다 더 강력하고 간결한 문법을 제공한다.
2) 제네릭 클래스
// 제네릭 클래스 정의
class Box<T>(var value: T)
// 사용 예
val stringBox = Box("Hello")
println(stringBox.value)
3) 제네릭 함수
// 제네릭 함수 정의
fun <T> printArray(array: Array<T>) {
for (element in array) {
println(element)
}
}
// 사용 예
val stringArray = arrayOf("A", "B", "C")
printArray(stringArray)
4) 코틀린의 in과 out 키워드
코틀린에서는 공변성(Covariance)과 반공변성(Contravariance)을 명시적으로 나타내기 위해 in과 out 키워드를 사용한다.
out 키워드 (공변성):
T를 반환하는 데 사용된다.
읽기 전용이다.
예: Producer는 T를 생성하거나 반환한다.
class Box<out T>(val value: T) // T는 읽기 전용
in 키워드 (반공변성):
T를 입력받는 데 사용된다.
쓰기 전용이다.
예: Consumer는 T를 소비한다.
class Box<in T> {
fun setValue(value: T) { /* ... */ }
}
5) 와일드카드 대신 사용: *
코틀린은 자바의 ? 와일드카드 대신 *을 사용하여 불특정한 타입을 나타낸다.
예:
fun printList(list: List<*>) {
for (item in list) {
println(item)
}
}
6) 코틀린에서의 로 타입 (Raw Type)
자바에서는 제네릭 타입을 명시하지 않은 경우 로 타입 (Raw Type) 이라고 한다. 코틀린에서는 로 타입을 사용할 수 없으며, 항상 타입을 명시해야 한다. 따라서 타입 안정성을 보장하고, 컴파일 시에 타입 검사를 강제한다.
예를 들어, 자바에서는 다음과 같은 로 타입을 사용할 수 있다:
List list = new ArrayList(); // 로 타입 사용
list.add("Hello");
list.add(123); // 서로 다른 타입 추가 가능
코틀린에서는 다음과 같이 타입을 명시해야 한다:
val list: MutableList<Any> = mutableListOf() // 모든 타입을 허용하려면 Any 사용
list.add("Hello")
list.add(123)
로 타입을 사용할 수 없기 때문에 코틀린에서는 제네릭 타입을 명확히 지정하여 타입 안정성을 높인다.
자바와 코틀린 제네릭의 주요 차이점
키워드 차이:
자바: ? extends T, ? super T
코틀린: out T, in T
타입 소거 (Type Erasure):
자바: 런타임에 제네릭 타입 정보가 제거된다.
코틀린: 동일하게 타입 소거가 적용되지만, reified 키워드를 통해 런타임 타입을 유지할 수 있다.
inline fun <reified T> checkType(value: Any) {
if (value is T) {
println("Value is of type \${T::class.simpleName}")
}
}
기본 타입:
자바: 제네릭은 항상 참조 타입만 사용한다.
코틀린: List와 같은 기본 타입 제네릭을 지원한다.
로 타입 (Raw Type):
자바: 로 타입을 사용할 수 있다.
코틀린: 로 타입을 사용할 수 없으며, 항상 타입을 명시해야 한다.
요약
자바는 강력하지만 복잡한 제네릭 문법을 사용하며, ? 와일드카드와 extends/super로 타입을 제한한다. 코틀린은 간결한 문법 (in, out)과 *를 사용하며, reified를 통해 런타임에서도 제네릭 타입을 사용할 수 있는 이점이 있다. 코틀린에서는 로 타입을 사용할 수 없기 때문에 타입 안정성을 높이고, 컴파일 시 타입 검사를 강제한다.
주저리 주저리 🔥
왜 아직 자바 공화국인가...🧐
결국 확실히.. 자바의 안 좋은 점 대부분은 코틀린이 잘 보완해준다는 것... 자바랑 거의 100% 호환도 된다는 데 심지어 파ㅊ님이라는 트친님이 말한 말 중 아래와 같은 말이 나오는 것 보면 호환 잘텐데...
자바는 코틀린 바이너리를 디컴파일하면 나오는 언어입니다 보안 전문가들만 만지면 되는 거예요
그런데 왜 아직 자바 공화국일까?
이유는 리팩토링 비용과 굳이 잘 돌아가는 것 건드려야 하는 이유가 없어서 일 것 같긴 함 자바가 지금 23까지 나왔는데 8버전을 쓰는 곳들이 대부분인 거보면.. 대체제가 있어도 안 써서 우리나라 백은 자바가 대부분 잡을 것 같긴 하다라는 생각이 듦
제네릭 어디까지 알아야 할까? 🙄
제네릭은 쓰는 방법만 알고 와일드 카드 부분은 거의 쓰는 게 아니라 라이브러리 뜯어볼 때 참고용으로 알고 있는 정도로 공부하면 되지 않을까? 라는 생각이 들었음
그리고 스터디 시간에 이야기 나온 게 있는데 제네릭을 너무 많이 좋아하는 팀리더가 있을 경우, 팀 전체가 힘들어질 수 있다고 했었음
결국..사실 제네릭이든 스트림이든 팀 컨벤션에 맞춰서 적절히 쓰는 게 정답이지 않을까 싶다..