제네릭을 왜 쓰지?
제네릭을 사용하면 데이터의 타입을 특정 타입으로 고정할 수 있고, 어떤 타입의 객체든 받아올 수 있도록 해준다.
예를 들면 아래와 같이 사용할 수 있다.
// 제네릭 클래스 Box
public class Box<T> {
private List<T> values;
public Box() {
this.values = new ArrayList<>();
}
public void add(T t) {
this.values.add(t);
}
public T get(int index) {
return this.values.get(index);
}
}
Box에는 어떤 타입이든 들어갈 수 있다. 다만 어떤 타입이든 타입 하나로 고정되기 때문에, 해당 타입만을 받을 수 있다.
Box<String> stringBox = new Box<>();
stringBox.add("집에");
stringBox.add("가고");
stringBox.add("싶다");
String firstValue = stringBox.get(0); // 집에
🤔 그런데 Object가 모든 객체의 상위 타입이니까, Object를 사용하면 되지 않을까? Object에는 어떤 타입이든 넣어줄 수 있는데 제네릭이랑 뭐가 다르지?
public class ObjectBox {
private List<Object> values;
public ObjectBox() {
this.values = new ArrayList<>();
}
public void add(Object t) {
this.values.add(t);
}
public Object get(int index) {
return this.values.get(index);
}
}
ObjectBox 클래스는 제네릭 클래스와 다르게 Object들을 저장한다. 따라서 어떤 타입이든 들어올 수는 있다.(자바의 모든 객체는 Object를 상속받기 때문)
ObjectBox objectBox = new ObjectBox();
objectBox.add("집에");
stringBox.add("가고");
objectBox.add("싶다");
String firstValueOfObjectBox = (String) objectBox.get(0);
Object를 사용하면, 값을 빼올 때 캐스팅이 필요하다. 현재 Box 내부의 value는 Object로 관리되고 있기 때문에, String을 받아오려면 캐스팅이 필요하다. 이보다 더 심각한 문제가 있는데, 타입 안전성이 떨어진다는 것이다.
예시 코드로 확인해보자.
ObjectBox stringBox = new ObjectBox();
stringBox.add("집에");
stringBox.add("가고");
stringBox.add("싶다");
stringBox.add(6);
stringBox.add("시에");
String value1 = (String) stringBox.get(0);
String value2 = (String) stringBox.get(1);
String value3 = (String) stringBox.get(2);
String value4 = (String) stringBox.get(3);
String value5 = (String) stringBox.get(4);
stringBox는 Object를 받기 때문에, String을 모으는 용도를 의도했으나, 6이라는 Integer도 받을 수 있다. 만약 stringBox에 String만 들어가있다고 착각해서, 내부의 모든 값을 String으로 캐스팅하면 어떻게 될까?
Integer를 String으로 잘못된 캐스팅해주고 있는데, 컴파일 타임에 잡을 수 없다. 즉, 잘못된 코드를 작성하는 상황이 발생할 수 있다. 이러한 경우가 타입 안전성이 떨어지는 예시이다.
그런데 제네릭을 이용하면 타입 안전성을 보장할 수 있다. 제네릭 타입을 사용한다면 타입이 고정이 되기 때문이다.
Box<String> stringBox = new Box<>();
stringBox.add("집에");
stringBox.add("가고");
stringBox.add("싶다");
stringBox.add(6); // 에러 발생!
stringBox.add("시에");
이 경우, 우리는 Box 내부에서 사용할 타입을 String이라고 명시해줬다. 그렇기 때문에 stringBox에는 String만 들어갈 수 있고, 다른 타입을 넣으려고 시도하는 순간 컴파일 에러가 발생한다.
제네릭은 이렇게 타입 체크와 형변환을 생략할 수 있고, 타입 안전성을 보장할 수 있다는 장점이 있다.
제한된 타입 매개변수(Bounded Type Parameters)
제네릭을 사용하면, 말 그대로 어떤 타입이든 들어올 수 있다. 그런데, 제네릭으로 받아올 수 있는 타입을 제한하는 방법이 있다.
<T extends 상위클래스>
이런 형태로 사용하면 되는데, 이렇게 적으면 T에는 상위 클래스의 하위 타입만이 들어올 수 있다. 즉, 내가 제네릭으로 받아오고자 하는 타입을 제한할 수 있다.
예를 들어보자. "일단 뭐가 들어올지는 모르지만, 숫자 관련 타입(Integer, Double, Float ...)을 받아와야 하는 경우"가 있을 수 있다.
public class BoundedExample {
// T는 Number 또는 그 하위 타입만 가능
public static <T extends Number> double sum(T num1, T num2) {
return num1.doubleValue() + num2.doubleValue();
}
public static void main(String[] args) {
System.out.println(sum(5, 10)); // 15.0
System.out.println(sum(5.5, 10.5)); // 16.0
// 아래는 컴파일 오류
// System.out.println(sum("Hello", "World"));
}
}
이 경우 상위 타입을 Number로 제한해 줄 수 있다. 그렇게 하면 Number의 하위 클래스인 Integer, Double은 들어올 수 있으나 Number를 extends하지 않는 String은 들어올 수 없다.
와일드카드는 왜 쓰지?
와일드카드는 제네릭의 타입을 유연하게 처리할 수 있게 해주는 기법으로, 특정 타입을 지정하지 않고 범위를 확장하거나 축소할 수 있다. 와일드카드는 주로 메서드의 매개변수나 반환값에서 사용된다.
public void printList(List<?> list) {
for (Object obj : list) {
System.out.println(obj);
}
}
위 예시에서 List<?>는 어떤 타입의 리스트든 받을 수 있다는 의미이다. 즉, List<String>, List<Integer> 모두 가능하지만, List<?>로 선언되었기 때문에 해당 리스트에 값을 추가하거나 수정할 수는 없다.
뭐든 받을 수 있는건 T나 ?나 똑같은데 뭐가 다른걸까?
❗️제네릭은 List<T>에 List<String>을 받으면 T가 String으로 정해지지만, 와일드카드는 List<?>에 List<String>을 받아도 타입이 정해지지 않는다.
- T: 어떤 타입이든 받을 수 있다. 한 번 받으면 그 타입으로 고정된다. (이후에는 그 정해진 타입에 맞는 객체만 처리한다.)
- ?: 어떤 타입이든 들어올 수 있는데, 뭔지는 모른다.(뭐가 들어와도 상관이 없다.) 고정되지 않는다.
그런데 이렇게만 보면 와일드카드는 별로 쓸모가 없는 것 같은데 왜 생긴걸까? 그냥 제네릭을 쓰면 되는 게 아닐까?
챗 지피티에게 와일드카드를 왜 사용하는지 물어봤다.
와일드카드는 결국, 제네릭을 유연하게 다루기 위해 탄생했다고 한다.
그런데 제네릭이 어디가 안 유연한걸까? 제네릭은 모든 타입을 받을 수 있기에 매우 유연한 게 아닌가?
제네릭은 사실 불공변이다. 그래서 와일드카드를 이용하면 이를 개선할 수 있다.
불공변은 뭐지?
공변은 타입 계층에서 자식 타입을 부모 타입으로 바꿀 수 있는 경우를 말한다.
예를 들면 다음과 같다.
Object object = "123"
String을 상위 타입인 Object에 넣을 수 있다.
그렇다변 불공변은 자식 타입을 부모 타입으로 바꿀 수 없는 경우일 것이다.
List<Object> objects = new ArrayList<String>(); // 🔥 컴파일 에러 발생!
Object가 String의 상위 타입인데 왜 넣을 수 없을까?
제네릭은 불공변이기 때문이다. <Object>에 <String>을 넣을 수 없다.
제네릭은 타입 안전성을 보장하기 위해 불공변으로 설계되었다. 제네릭에서는 하위 타입의 객체를 상위 타입의 변수에 넣을 수 없도록 제한하는데, 이를 통해 런타임에서 타입 캐스팅 문제를 방지할 수 있다.
❓그런데 제네릭이 불공변이기 때문에 불편함(유연성 떨어짐)이 생길 수 있다.
List<Number> numberList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();
// numberList를 integerList에 할당하려고 하면 컴파일 에러 발생
// integerList = numberList;
그런데 와일드카드를 사용하면 불공변을 개선할 수 있다.
List<? extends Number> list = new ArrayList<Integer>();
Number number = list.get(0); // 읽기는 가능
// list.add(10); // 불가능 - 쓰기는 불가능
List<Integer>는 List<Number>로 변환할 수 없지만(불공변),
List<Integer>는 List<? extends Number>로 변환 가능하다.(불공변 개선)
와일드카드는 제네릭과 달리 고정되지 않고, 어떤 타입이든 들어올 수 있다는 의미이기 때문에, 단순히 <?> 로 사용하면 Object 타입과 다름이 없어진다.
그래서 와일드카드는 제네릭 타입의 범위를 제한하는 용도로 사용한다. 상위 제한은 extends, 하위 제한은 super로 할 수 있다. 그리고 이 내용은 PECS로 이어지는데....
PECS
to be continued...