이펙티브 자바 스터디를 하고 있는데, 2주전까지의 파트는 제네릭
이었음 보통 Controller 짤 때 Map에 때려 넣는 곳이 진짜 많은데 Map이랑 같이 제네릭을 많이 쓰는 것 같음
그래서 한 번 공부하면 머리에 남지도 않으니 제네릭에 대한 것 전부 다 정리해보자 그리고 코틀린에서는 겸사겸사 제네릭 어떻게 쓰는지, 있는지도 알아보자.
배열은 공변이고, 제네릭은 불공변이다.공변은 자기 자신과 자식 객체로 타입 변환을 허용해주는 것이다.
Java의 모든 클래스는
Object
를 직접 또는 간접적으로 상속받는다. 이는 모든 참조 타입이Object
타입으로 취급될 수 있음을 의미한다.
String
, Integer
, Double
, List
, Map
등 모든 참조 타입은 Object
타입의 변수에 할당 가능하다. Object obj = "Hello"; // String -> Object
Object obj2 = 10; // Integer -> Object
int
, double
, boolean
등 기본 타입이 있다. 이들은 Object
를 직접 상속받지 않으며, 래퍼 클래스(Wrapper Class)를 통해 간접적으로 Object
계층 구조에 참여한다.int
-> Integer
, double
-> Double
.Object
타입으로 사용할 수 있도록 지원한다. 이를 오토박싱(Auto-boxing)이라고 한다.Object obj = 42; // int는 Integer로 오토박싱되어 Object에 할당
A가 B의 하위 타입일 때, T<A>
가 T<B>
의 하위 타입이면 T는 공변이라고 한다. 타입 A가 B의 하위 타입(subtype)이라면, A[]는 B[]의 하위 타입이 될 수 있는 성질이다.
공변성은 서브타입 관계가 제네릭 타입에도 적용되는 것을 의미한다. 즉, Integer
는 Number
의 서브타입이므로, 공변성을 지원하는 경우 List<Integer>
는 List<Number>
로 처리될 수 있다.
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[]
를 인자로 전달해도 문제가 발생하지 않는다.
A가 B의 하위 타입일 때, T<A>
가 T<B>
의 하위 타입이 아니면, T는 불공변이라고 한다. 제네릭 타입 List<Object>
와 List<Integer>
가 있을 때, Integer가 Object의 하위 타입이더라도 List<Object>
는 List<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<Integer>
를List<Object>
로 사용할 수 없다.
@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<Integer>
는 List<Object>
의 하위 타입이 아니기 때문에, printCollection(Collection<Object> c)
메서드에 List<Integer>
를 전달하면 컴파일 오류가 발생한다.;
제네릭 타입은 불공변성이기 때문에,
List<Integer>
와List<Object>
는 아무런 관계가 없다.
이러한 제네릭의 불공변 때문에 와일드카드(제네릭의 ?타입)가 등장할 수 밖에 없었다.
자바에서 제네릭(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
를 타입처럼 사용할 수 있다.제네릭 클래스를 만들었다면, 실제 인스턴스를 생성할 때 타입을 지정해야 한다. 이때 타입 매개변수가 지정되면 해당 타입으로 모든 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<String>>
처럼 말이다.
예제: 중첩 타입 파라미터
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<LinkedList>는 중첩된 제네릭 타입으로, 이를 풀어서 설명하면 다음과 같은 뜻이다.
ArrayList<LinkedList>는 ArrayList라는 타입의 객체이며, 그 안에 들어가는 원소들이 LinkedList 타입이다. 각 LinkedList는 String 타입의 요소들을 저장할 수 있는 연결 리스트이다.
이를 좀 더 구체적으로 설명하자면:
큰 상자(ArrayList)에 여러 개의 작은 상자(LinkedList)가 들어있고,
각 작은 상자에는 문자열(String)이 담겨 있다.
타입 파라미터의 기호는 정해진 게 없다. 하지만 보통 약속된 대로 사용하는 게 좋다. 예를 들어 <T>
는 타입, <E>
는 요소(Element)를 의미한다. 아래는 자주 쓰는 기호이다:
타입 기호 | 설명 |
---|---|
<T> | 타입 (Type) |
<E> | 요소 (Element), 주로 컬렉션에서 사용 |
<K> | 키 (Key), Map<K, V> 에서 사용 |
<V> | 값 (Value) |
<N> | 숫자 (Number) |
<S, U, V> | 여러 타입 매개변수를 사용할 때 |
<T>
는 타입을 의미하고, <E>
는 컬렉션의 요소를 의미하는 등 상황에 맞게 네이밍을 사용하면 된다.위의 내용을 그림으로 정리하면 아래와 같다.
제네릭(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 개발자들은 타입을 지정하여 컴파일 시점에 타입 안전성을 보장할 수 있는 방법을 고안하였고, 그 결과 제네릭이 등장하게 되었다.
제네릭을 사용하면, 컬렉션이나 메서드에 타입 매개변수를 지정할 수 있어 컴파일 시점에 타입을 검사할 수 있다. 이렇게 하면 런타임 오류의 가능성을 줄이고, 코드의 안정성과 가독성을 높일 수 있다.
제네릭을 사용하면 컬렉션에 타입을 지정할 수 있어 컴파일 시점에 타입 안전성을 보장할 수 있다. 예를 들어, Collection<Integer>
타입을 사용하여 숫자들의 합을 구하는 메서드를 작성할 수 있다.
수정된 코드 예제
int sum(Collection<Integer> c) {
int sum = 0;
for (Integer e : c) { // Collection의 요소 타입을 Integer로 제한
sum += e;
}
return sum;
}
위 코드에서는 Collection<Integer>
타입을 사용하여, 컬렉션이 Integer
타입의 요소만 포함하도록 제한했다. 컴파일 시점에 타입 검사가 이루어져, 다른 타입의 컬렉션이 전달되면 컴파일 오류가 발생한다. 이로써 타입 안전성을 보장할 수 있다.
제네릭 타입은 불공변성
을 가진다. 즉, Collection<Integer>
와 Collection<Object>
는 아무런 관계가 없다. 제네릭이 도입되기 전에는 가능했던 작업이 이제는 불가능해진 경우가 발생할 수 있다.
아래와 같이 printCollection
메서드를 작성하고 List<Integer>
를 전달하려고 하면, 컴파일 오류가 발생한다.
@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<Object>
는Collection<Integer>
의 상위 타입이 아니기 때문에, 제네릭 타입에서는 서로 호환되지 않는다.
이로 인해 printCollection
메서드에 List<Integer>
를 전달하려고 하면 컴파일 오류가 발생한다. 이는 제네릭의 불공변성으로 인한 문제이다.
위와 같은 문제를 해결하기 위해 와일드카드(?
)가 도입되었다. 와일드카드를 사용하면 제네릭 타입을 보다 유연하게 사용할 수 있으며, 모든 타입의 컬렉션에서 공통으로 사용할 수 있는 메서드를 작성할 수 있다.
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<Integer>
, List<String>
, List<Object>
등 다양한 타입의 컬렉션을 모두 전달할 수 있어, 보다 유연한 메서드를 작성할 수 있다. 단, 와일드카드 타입에서는 컬렉션에 새로운 요소를 추가할 수 없고, null
만 허용된다. 이는 타입 안전성을 유지하기 위함이다.
Collection<Object>
는 Collection<Integer>
의 상위 타입이 아니기 때문에, 제네릭 타입에서는 서로 호환되지 않는다. 이로 인해 printCollection
메서드에 List<Integer>
를 전달하려고 하면 컴파일 오류가 발생한다.와일드 카드에 대한 설명 블로그 : https://mangkyu.tistory.com/241
위에서 저렇게 언급된 로 타입이란 뭘까?
로 타입은 제네릭(Generic) 타입에서 타입 매개변수를 전혀 사용하지 않은 때를 말한다. (ex)
List
). 또한 타입 선언에서 제네릭 타입 정보가 전부 지워진 것처럼 동작한다.
로 타입의 단점을 나타내주는 몇 가지 예시를 들어보자.
// Stamp 인스턴스만 취급한다.
private final Collection stamps = ...;
stamps.add(new Coin(...));
실수로 도장 대신 동전을 넣어도 오류 없이 컴파일 되고 실행되는 문제가 발생한다.
for(Iterator i = stamps.iterator(); i.hasNext(); ){
Stamp stamp = (Stamp) i.next(); //ClassCastException
stamp.cancle();
}
오류는 이상적으로 컴파일할때 발견하는 것이 좋지만, 로 타입을 사용한다면 런타임에나 오류를 발견할 수 있다.
제네릭을 지원한 이후에는 매개변수화된 컬렉션 타입으로 타입 안전성을 확보한다. 제네릭을 사용하면 타입 선언 자체에 Stamp 인스턴스만 취급한다라는 것이 녹아든다.
private final Collection<Stamp> stamps = ...;
stamps.add(new Coin()); // 컴파일 오류 발생
컴파일 오류가 바로 발생한다. 컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환
을 추가 하여 절대 실패하지 않음을 보장한다.
제네릭을 사용하여 컬렉션의 타입을 지정함으로써, 컴파일러가 타입 안전성을 보장할 수 있다.
로 타입을 사용한 코드
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>
은 제네릭 타입을 사용하여 선언되었다.
이제 이 리스트에는
String
타입만 저장할 수 있으며, 컴파일 시점에 타입 오류가 발생할 가능성을 줄일 수 있다. 요소를 가져올 때도 타입 캐스팅이 필요하지 않다.
먼저 하나의 그림으로 정리해보면 사진과 같음
private final Collection<Stamp> stamps = ...;
위 예제처럼 컴파일러가 stamps
에는 Stamp
의 인스턴스만 넣어야 함을 인지하기 때문에, 다른 엉뚱한 타입의 인스턴스는 컴파일 에러를 내뱉게 된다.
올바른 인스턴스라면 컴파일러는 컬렉션에서 원소를 꺼내는 모든 곳에 보이지 않는 형변환을 추가하기 때문에 그 이후부터는 정상적으로 작동할 것이다.
하지만 그럼에도 로 타입이 존재하고 있는 것은, 로 타입을 사용하는 메서드에 매개변수화 타입의 인스턴스를 넘겨도 동작해야 했기 때문이다.
따라서 이러한 마이그레이션 호환성을 위해 로 타입을 지원하고 제네릭 구현에는 소거방식을 도입하게 된다. 소거 방식이란, 런타임에 타입 정보가 사라지는 것을 의미한다.
6) 로 타입은 권장되지 않는다(List
와 List<Object>
의 차이)
로 타입을 쓰면 제네릭이 안겨주는 안전성과 표현력을 모두 잃게 된다. 제네릭이 등장하기 이전의 코드와의 호환성을 위해서 로 타입이 남겨져 있다.
List
와 같은 로 타입은 권장하지 않지만 List<Object>
는 괜찮다. 모든 타입을 허용한다는 의사를 컴파일러에게 명확하게 전달한 것이기 때문이다
그렇다면
List와 List<Object>의 차이
는 무엇일까?
List
는 제네릭 타입과 무관한 것이고 List<Object>
는 모든 타입을 허용한다는 것입니다.
다시 말해서 매개변수로 List
를 받는 메서드에 List<String>
을 넘길 수 있지만, 제네릭의 하위 규칙 때문에 List<Object>
를 받는 메서드에는 매개변수로 넘길 수 없다.
List<String>
은 로 타입인 List
의 하위 타입이지만 List<Object>
의 하위 타입은 아니기 때문이다. 위의 공변 설명에서 함 그래서 List<Object>
와 같은 매개변수화 타입을 사용할 때와 달리 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<Object>
로 변경하면?
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<String> cannot be converted to List<Object>...
라는 메시지가 출력된다. 실행 시점이 아닌 컴파일 시점에 오류를 확인할 수 있어 보다 안전하다.
제네릭을 사용하는 이유는 타입 안전성을 보장하고, 코드의 가독성과 유지보수성을 높이기 위함이다.
하지만 모든 상황에서 특정 타입을 명시할 수 없는 경우가 있기 때문에 비한정적 와일드카드(
?
)와 로 타입(Raw Type)이 존재한다.
와일드카드는 Java의 제네릭 타입에서 유연성을 높이기 위해 도입된 기능으로, 제네릭 타입 매개변수를 특정하지 않고 사용할 수 있게 해준다. 주로 세 가지 형태로 사용되며, 각각의 의미와 사용법이 조금씩 다르다.
사실 들어가기 전 한마디를 하자면, 우리가 직접 코드를 짤 때 쓰는 것보다는 라이브러리나 클래스 열어보면 많이 정의되어 있는 것을 볼 수 있음. 즉 분석할 때 알고 있으면 도움이 많이 됨
Java에서는 다음 세 가지 형태의 와일드카드를 사용할 수 있다.
비한정적 와일드카드 (?
)
상한 경계 와일드카드 (? extends T
)
하한 경계 와일드카드 (? super T
)
이 세 가지 와일드카드는 서로 다른 상황에서 제네릭 타입의 유연성을 높이기 위해 사용된다.
Set<?>
)Set<?>
와 같은 비한정적 와일드카드 타입은 Set<String>
, Set<Integer>
등 어떤 타입의 Set
이라도 사용할 수 있다.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! 메서드 호출 자체는 문제없음
}
}
? extends 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이 들어갈지 어떤 타입이 들어가는지 모르니까 타입 불일치가 있을수 있고 이건 컴파일타임에 체크할 수 없어서 막아두었다고 함
? super T
)
void addIntegers(List<? super Integer> list) {
list.add(10);
list.add(20);
}
위 코드에서 List<? super Integer>는 List, List, List와 같은 상위 타입의 리스트를 인자로 받을 수 있으며, Integer 타입의 값을 추가할 수 있다.
Set
)Set<String>
과 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!
}
}
비한정적 와일드카드 (?
)는 컬렉션의 타입 매개변수를 알 수 없으므로, 안전한 타입을 보장하기 위해 null 외의 값을 추가할 수 없다.
상한 경계 와일드카드 (? extends T
)는 주로 읽기 전용 작업에 사용되며, 컬렉션에 요소를 추가하는 것은 불가능하다.
하한 경계 와일드카드 (? super T
)는 안전하게 요소를 추가할 수 있지만, 요소를 읽을 때는 Object로 반환되므로 타입 캐스팅이 필요할 수 있다.
로 타입 (List
)은 타입 안전성이 보장되지 않으며, 제네릭의 장점을 활용할 수 없다. 타입을 지정하지 않으면 컴파일 시점에 타입 오류를 검출할 수 없고, 런타임 오류가 발생할 수 있다.
와일드카드 (List<?>
, List<? extends T>
, List<? super T>
)를 사용하면 제네릭의 타입 안전성을 유지하면서도, 유연한 타입 처리가 가능하다.
와일드카드는 제네릭 메서드 작성 시 유용하며, 특히 라이브러리 설계에서 API의 유연성을 높이는 데 큰 도움이 된다.
클래스 리터럴에서는 로 타입을 사용해야 하며, List<?>.class나 List.class는 허용되지 않다.
instanceof 연산자와 함께 사용할 때도 로 타입을 사용해야 합니다. 제네릭 타입 정보는 런타임에 지워지기 때문ㅇ이다.
// 로 타입을 사용하여 instanceof 연산자 적용
if (o instanceof Set) {
Set<?> set = (Set<?>) o;
}
그렇다면 도대체 언제 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가 적합한 것이다.
특성 | 로 타입 (Set ) | 비한정적 와일드카드 (Set<?> ) |
---|---|---|
타입 안전성 | 보장되지 않음 | 보장됨 |
타입 불변식 유지 | 위반하기 쉬움 | 타입 불변식 유지 |
원소 추가 | 어떤 타입의 원소도 추가 가능 | null 외에는 추가할 수 없음 |
메서드 호출 | 타입에 관계없이 사용 가능 | 제네릭 타입에 의존하지 않는 메서드만 사용 가능 |
사용 가능 상황 | 하위 버전과의 호환성 필요 시, 클래스 리터럴, instanceof | 제네릭 타입에 의존하지 않는 메서드 작성 시 |
제네릭 타입은 클래스 리터럴에서 사용할 수 없다. List.class
와 같은 로 타입만 사용할 수 있으며, List<String>.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의 확장느낌으로 클래스 등에서 자주 쓰임으로 알아두면 좋음
타입 매개변수를 사용해 실제 타입으로 지정된 제네릭 타입
List<String> list = new ArrayList<>(); // List의 타입 매개변수로 String을 지정
list.add("Hello"); // String 타입의 요소를 추가
매개변수화 타입에서 구체적으로 지정된 타입
List<E>
// 매개변수화 타입에서 String이 실제 타입 매개변수
List<String> list = new ArrayList<>(); // 여기서 String이 실제 타입 매개변수로 사용됨
타입 매개변수를 가지는 클래스나 인터페이스
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
제네릭 타입 또는 제네릭 메서드에서 사용되는 타입 매개변수
// 제네릭 클래스 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 타입의 값을 반환
}
}
타입 매개변수를
?
로 지정하여 어떤 타입이든 허용함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
제네릭 타입에서 타입 매개변수를 사용하지 않은 형태
List
// 제네릭 타입의 타입 매개변수를 사용하지 않은 경우
List rawList = new ArrayList(); // 로 타입으로 정의
rawList.add("Hello"); // String 타입의 값 추가
rawList.add(123); // Integer 타입의 값 추가
// 로 타입 사용 시 컴파일러가 타입 안전성을 보장하지 않음
for (Object obj : rawList) {
System.out.println(obj); // 출력: Hello, 123
}
특정 타입 또는 그 하위 타입으로 제한된 타입 매개변수
<E extends Number>
// 타입 매개변수 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의 하위 타입이 아님
자기 자신을 타입 매개변수로 참조하는 타입 한정
<T extends Comparable<T>>
// 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
와일드카드 타입이 특정 타입 또는 그 하위/상위 타입으로 제한됨
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
타입 매개변수를 사용하는 메서드
static <E> List<E> 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]
런타임에 제네릭 타입 정보를 제공하기 위해 사용하는 클래스 리터럴
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의 기본 생성자 사용)
코틀린에서도 제네릭은 자바와 마찬가지로 클래스나 함수가 다양한 타입을 처리할 수 있도록 한다. 코틀린의 제네릭은 자바보다 더 강력하고 간결한 문법을 제공한다.
// 제네릭 클래스 정의
class Box<T>(var value: T)
// 사용 예
val stringBox = Box("Hello")
println(stringBox.value)
// 제네릭 함수 정의
fun <T> printArray(array: Array<T>) {
for (element in array) {
println(element)
}
}
// 사용 예
val stringArray = arrayOf("A", "B", "C")
printArray(stringArray)
in
과 out
키워드코틀린에서는 공변성(Covariance)과 반공변성(Contravariance)을 명시적으로 나타내기 위해 in
과 out
키워드를 사용한다.
out
키워드 (공변성):
T
를 반환하는 데 사용된다.Producer<T>
는 T
를 생성하거나 반환한다.class Box<out T>(val value: T) // T는 읽기 전용
in
키워드 (반공변성):
T
를 입력받는 데 사용된다.Consumer<T>
는 T
를 소비한다.class Box<in T> {
fun setValue(value: T) { /* ... */ }
}
*
코틀린은 자바의 ?
와일드카드 대신 *
을 사용하여 불특정한 타입을 나타낸다.
예:
fun printList(list: List<*>) {
for (item in list) {
println(item)
}
}
자바에서는 제네릭 타입을 명시하지 않은 경우 로 타입 (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<Int>
와 같은 기본 타입 제네릭을 지원한다.로 타입 (Raw Type):
자바는 강력하지만 복잡한 제네릭 문법을 사용하며, ?
와일드카드와 extends
/super
로 타입을 제한한다. 코틀린은 간결한 문법 (in
, out
)과 *
를 사용하며, reified
를 통해 런타임에서도 제네릭 타입을 사용할 수 있는 이점이 있다. 코틀린에서는 로 타입을 사용할 수 없기 때문에 타입 안정성을 높이고, 컴파일 시 타입 검사를 강제한다.
결국 확실히.. 자바의 안 좋은 점 대부분은 코틀린이 잘 보완해준다는 것... 자바랑 거의 100% 호환도 된다는 데 심지어 파ㅊ님이라는 트친님이 말한 말 중 아래와 같은 말이 나오는 것 보면 호환 잘텐데...
자바는 코틀린 바이너리를 디컴파일하면 나오는 언어입니다
보안 전문가들만 만지면 되는 거예요
그런데 왜 아직 자바 공화국일까?
이유는 리팩토링 비용과 굳이 잘 돌아가는 것 건드려야 하는 이유가 없어서 일 것 같긴 함 자바가 지금 23까지 나왔는데 8버전을 쓰는 곳들이 대부분인 거보면.. 대체제가 있어도 안 써서 우리나라 백은 자바가 대부분 잡을 것 같긴 하다라는 생각이 듦
제네릭은 쓰는 방법만 알고 와일드 카드 부분은 거의 쓰는 게 아니라 라이브러리 뜯어볼 때 참고용으로 알고 있는 정도로 공부하면 되지 않을까? 라는 생각이 들었음
그리고 스터디 시간에 이야기 나온 게 있는데 제네릭을 너무 많이 좋아하는 팀리더가 있을 경우, 팀 전체가 힘들어질 수 있다고 했었음
결국..사실 제네릭이든 스트림이든 팀 컨벤션에 맞춰서 적절히 쓰는 게 정답이지 않을까 싶다..
출처