제네릭이란?
필자의 생각으로는 Generic의 등장 이전의 코드를 예로 생각했다. 이전에 List의 경우 모든 곳에서 다양한 타입을 받기 위하여 Object의 형태로 제작했을 것이다. 그런데 개발은 혼자 하는 것이 아니다. 이 때문에 String 만 들어갈 수 있는 List에 Integer를 다른 개발자가 넣을 수 있다. 이런 경우를 방지하고자 Generic이 등장했다고 생각한다.
제네릭은 언제 쓸까?
Generic은 다양한 곳에서 사용해야 하는 “클래스”를 “설계”할 때 사용한다.
여기서 클래스와 설계를 잘 기억해두길 바란다.
제네릭을 쓰면 장점?
제네릭을 사용하면 이러한 장점이 있다. 그리고 클래스를 설계할 때도 편하지 않을까? 제네릭이 없던 시절에 List를 만들려면 Object로 만들어 형변환 즉, 아래처럼 타입 캐스팅을 해줘야 했다. 하지만 제네릭을 사용하면 그럴 필요가 없어진다.
컴파일 타임에 타입 검사가 더욱 강화된다
제네릭
비 제네릭
타입 캐스팅 제거
제네릭
비 제네릭
제네릭은 어떤 타입에 사용이 가능 할까?
Reference Type이면 다 가능하다. 즉, < > 안에는 참조 타입이면 모두 들어간다.
중간에 알아두면 좋은 상식
Primitive Type 원시 타입 : 변수가 곧 값 자체
Reference Type 참조 타입 : 메모리상에 있는 객체가 있는 위치를 저장
예제로 보는 제네릭
우리에게 요청이 들어왔다. 그것은 좌표 즉, x, y 를 담을 수 있는 class를 설계해야 하는 것이다. 근데 문제는 x, y는 1도 가능하고, 1.1도 가능하고 -9223372036854775808 ~ 9223372036854775807 사이의 값도 가능하다.
아래의 코드에서도 알 수 있다. 제네릭을 사용하면 재사용성이 엄청나게 증가한다. 또한 불필요한 캐스팅도 필요하지도, 컴파일 시에 타입을 검사해줘서 잘못된 값을 넣을 일도 없다.
@Getter
public class Point<T, S> { // <- 지금은 T,S를 사용했다. 근데 T, S, U, V, W ..... ZZZ 까지 10만개도 상관없다(없는건 아니다).
private T x;
private S y;
public Point(T x, S y) {
this.x = x;
this.y = y;
}
}
public static void main(String[] args) {
// -9223372036854775808 ~ 9223372036854775807 사이 값을 사용하는 개발자
Point<Long, Long> longPoint = new Point<>(-922337203685477580L, 922337203685477580L);
System.out.println("long 사용 개발자 만족 : x - " + longPoint.getX() + " | y - " + longPoint.getY());
// 1.211 ~ 13.35523 사이 값을 사용하는 개발자
Point<Double, Double> doublePoint = new Point<>(1.211, 13.35523);
System.out.println("double 사용 개발자 만족 : x - " + doublePoint.getX() + " | y - " + doublePoint.getY());
// 1 ~ 11 사이 값을 사용하는 개발자
Point<Integer, Integer> intPoint = new Point<>(1, 11);
System.out.println("int 사용 개발자 만족 : x - " + intPoint.getX() + " | y - " + intPoint.getY());
}
long 사용 개발자 만족 : x - -922337203685477580 | y - 922337203685477580
double 사용 개발자 만족 : x - 1.211 | y - 13.35523
int 사용 개발자 만족 : x - 1 | y - 11
Java
복사
중첩 타입 제네릭도 만들 수 있을까?
여기서 말하는 중첩 타입 제네릭이란? <T, S<U>> 이런식으로 중첩되어 있는 형태이다. 물론 가능하다. 위의 예제를 통해서 한번 다시 해보자. 만약 X는 (Integer + Integer) 로 나타내고 싶고, Y는 (Long + Long)의 형태로 나타내려는 미친 클라이언트가 있다. 그럼 어떻게 해야 할까?
사실 우리는 이미 제네릭으로 만들었기 때문에 그냥 쓰라고 해도 된다. 아래의 예시를 보자. 이렇듯 제네릭은 무궁무진하게 재사용이 가능해진다.
public static void main(String[] args) {
// 미친 클라이언트
Point<Long, Long> longPoint = new Point<>(-922337203685477580L, 922337203685477580L);
Point<Integer, Integer> intPoint = new Point<>(1, 11);
Point<Point<Long, Long>, Point<Integer, Integer>> mixPoint = new Point<>(longPoint, intPoint);
}
Java
복사
클래스 제네릭과 메서드 제네릭의 차이는 뭘까?
클래스에 제네릭을 사용하는 것은 우리가 배웠다. 그런데 메서드에도 제네릭을 사용할 수 있다. 어렵다고 느낄 수 있다. 하지만 간단하다. 그냥 if(...){ int a = 1; } 일 때 if문 밖에서 a를 참조할 수 있는가? 없다. 제네릭도 동일하다. 그냥 메서드에서만 쓸려고 사용하는 것이 메서드 제네릭이다. 차이점은 아래의 코드를 보면 된다. 좀 전에 만든 longPoint의 x와 intPoint의 y를 print()라는 메서드에서 출력하고 있는 것이다.
@Getter
public class Point<T, S> {
private T x;
private S y;
public Point(T x, S y) {
this.x = x;
this.y = y;
}
public <E, U> void print(E e, U u){ // 문법상 이런식으로 적어야 한다.
System.out.println("E 출력 :" + e);
System.out.println("U 출력 :" + u);
}
}
...
// 미친 클라이언트
Point<Long, Long> longPoint = new Point<>(-922337203685477580L, 922337203685477580L);
Point<Integer, Integer> intPoint = new Point<>(1, 11);
Point<Point<Long, Long>, Point<Integer, Integer>> mixPoint = new Point<>(longPoint, intPoint);
mixPoint.print(longPoint.getX(), intPoint.getY());
Java
복사
근데 여기서 좀 더 깊이 다이빙 해보자. 만약에 <T, S>를 메서드 제네릭에서도 동일하게 사용하면 과연 어떤 제네릭을 사용할까? 메서드 제네릭에 들어온 친구들이 출력된다. 여기서 메서드 입장에서 가장 가까운 것을 선택한다. 왜냐하면 더 구체적이라서 Long, Integer가 출력된다. 이 원리는 잘 기억해두면 좋다. 메이븐에서도 비슷한 원리로 같은 의존성이 있으면 더 구체적인 것 넣는다. 그래서 개략적으로 예외나 에러가 발생할 때 유추할 수 있다. 모든 소프트웨어의 암묵적 룰 같은 거 같다.
@Getter
public class Point<T, S> {
private T x;
private S y;
public Point(T x, S y) {
this.x = x;
this.y = y;
}
public <T, S> void print(T t, S s){
System.out.println("누구의 T일까? 출력 :" + t.getClass());
System.out.println("누구의 S일까? 출력 :" + s.getClass());
}
}
// 미친 클라이언트
Point<Long, Long> longPoint = new Point<>(-922337203685477580L, 922337203685477580L);
Point<Integer, Integer> intPoint = new Point<>(1, 11);
Point<Point<Long, Long>, Point<Integer, Integer>> mixPoint = new Point<>(longPoint, intPoint);
mixPoint.print(longPoint.getX(), intPoint.getY());
누구의 T일까? 출력 :class java.lang.Long
누구의 S일까? 출력 :class java.lang.Integer
Java
복사
제네릭의 타입 한정
Bounded Type Parameters라고 보통한다. extends 키워드를 사용하며, 보통은 <T extends [Reference Type]> 형태로 사용된다.
타입 한정 왜 나온 걸까?
예시로 계산기를 들 수 있다. 동일하게 우리에게 Integer, Long, Double 모두 간단한 계산기를 만들어돌라고 의뢰가 들어왔다. 그럼 우리는 아까의 Point를 만들듯이 만들어보지 않을까?
1차 계산기
2차 계산기
이 타입 한정 덕분에 미친 클라이언트가 String을 집어넣는 일을 컴파일 타임부터 잡을 수 있게 되었다. 이처럼 한정자 즉, 클래스 내부에서 처리할 수 있는 상한선을 위해서 제네릭의 타입 한정이 등장한 것이다.
여기서 궁금증 super 는 없을까? 응 없다. 이유는 <T super Number>를 해석해보면 알 수 있다.
<T super Number> : Number 위로만 가능해
그럼 Number의 태초의 조상은 누굴까? Object이다. 근데 자세히 생각해보면 <T> 얘랑 다를 것이 하나도 없다.
그런데 여기서부터 조심해야 할 사항이 있다. 클래스를 설계할 때는 super가 없지만 매개변수로 제네릭 형을 받을 때는 super가 있다. 그건 아래에서 설명하겠다.
제네릭 간의 형변환(불공변 특징)
일단 공변과 반공변에 대해서 알아보자.
상호간 아무런 관계가 없는 남남이라는 것이다. 아래의 예시를 통해서 일단 공변과 반공변을 알아보겠다. Object의 자식은 String이다. 그렇기에 배열이 되더라도 그 관계는 변함이 없다. 그래서 서로가 될 수 있다.
String[] strs = new String[1];
Object[] os = strs; // 아래에서 위로 // 공변
String[] newStrs = (String[]) os; // 위에서 아래로 // 반공변
Java
복사
그럼 불공변은?
String이 Object의 자식이라도 서로 상관 관계가 전혀 없다는 말이다. 제네릭이 이러한 특성을 가진다. 그래서 해석할 때 < > 내부에 타입”만” 가능하다. 다음 코드를 통해 알 수 있다. 위의 코드를 동일하게 ArrayList<>로 바꾸면 에러가 난다.
제네릭의 와일드카드
우리는 앞서서 제네릭이 불공변이라는 것을 알았다. 와일드카드 이야기를 하는데 왜 갑자기 불공변을 이야기하는 것인지 궁금하지 않은가? 그렇다면 다음 코드를 보자.
이러한 불공변 특징 덕분;;에 Integer는 Number의 하위 타입임에도 불구하고 print(ArrayList<Number> list) 를 사용할 수 없게 된 것이다.
제네릭의 와일드카드의 등장
위의 코드에서 알 수 있듯이 이런 경우 때문에 와일드카드라는 개념이 등장한 것이다. 와일드카드의 경우
<?>, <? extends [ReferenceType]>, <? super [ReferenceType]> 3가지가 있다.
<?>를 이용한 코드 개선
<? extends [ReferenceType]>를 이용한 코드 개선
extends 까지 해버렸다. 자신감이 생긴 우리는 현재 비어있는 list에 add를 해주는 메서드를 만들어주기로 했다. 근데 이상하다. add가 안된다. 분명 제한도 잘 걸어주고 int를 넣는 것은 가능해야 하는데 불가능하다.
여기서부터 가장 어렵다.
ㅋㅋ 지옥문이 열린다.
이유는 만약 논리상 오류가 존재하기 때문이다. 제일 상단에 ArrayList<Integer>로 선언되어 있다. 그렇다면 메서드 입장에서는 어떠한 자료도 받을 수 있도록 ArrayList<? extends Number>로 되어 있다. 그럼 메서드 입장에서 바라볼 때와 실제 ArrayList의 제네릭 타입의 간극이 발생한다. 그래서 extends 를 이용하여 제네릭 파라미터를 받은 경우는 어떠한 자료형이건 set작업이 불가능하다. 단 null만 된다.
이것을 이해하려면 입장을 바꿔 생각해야 한다. 아래와 같이 비정상적인 상황이 발생할 수 있다. 그래서 아예 정상적인 상황조차 막아버린 것이다. 근데 왜 null은 add가 될까? 그 이유는 Reference Type은 null을 가질 수 있기 때문이다.
정상적일 때
main 입장 : ArrayList<Integer> 줄께 숫자 1 넣어줘
add 입장 : ArrayList<? extends Number> 로 받았으니깐. Number 아래에 있는 건 다 넣을 수 있겠지? 1넣어줄께
비정상적일 때
main 입장 : ArrayList<Integer> 줄께 숫자 1 넣어줘
미친 add 입장 : ArrayList<? extends Number> 로 받았으니깐. Number 아래에 있는 건 다 넣을 수 있겠지? 1.1123123333 넣어줄께 ㅋㅋ
자신만만하게 add해주는 메서드를 만들어 준다고 했는데 어떻게 할까? 그래서 우리가 아까 예제에서 사용해본적 없는 <? super [ReferenceType]> 가 슈퍼맨 같이 나타났다.
<? super [ReferenceType]>를 이용한 코드 개선
와일드카드 | 이름 | get (List<와일드카드> l ) 메서드 입장에서 | set (List<와일드카드> l ) 메서드 입장에서 |
<?> | 비한정 와일드카드
Unbounded Wildcards | Object로만 꺼낼 수 있음 | 불가능, null은 가능 |
<? extends [ReferenceType]> | 상한 경계 와일드카드
Upper Bounded Wildcards | 상한으로 설정 된 걸로 꺼냄 | 불가능, null은 가능 |
<? super [ReferenceType]> | 하한 경계 와일드카드
Lower Bounded Wildcards | Object로 꺼낼 수 있음 | 하한으로 설정된 객체부터 자손들을 넣을 수 있음 |
extends와 super의 구분
위의 extends와 super가 제네릭에서는 가장 어렵다. 그래서 한 가지 공식같이 만들어 놓은 것이 있다. PECS 공식이다. 자바 오라클 튜토리얼에서는 in, out 공식이라고 한다. 아래의 코드를 보면 이해하기 더 쉽다.
PECS
외부에서 온 데이터를 생산해야 한다면 <? extends T>를 사용 →여기서 생산이란 Integer 바꿔서 out에게 전달
외부에서 온 데이터를 소비해야 한다면 <? super T>를 사용 → 여기서 소비란 자기 자신에 그냥 할당
반대로 해보면 어케 될까?
받을때 super면 꺼낼 때 Object로 꺼내야한다. 그럼 내가 타입을 알 수 없기에 함부로 형변환을 못한다.
줄때 extends면 set이 아예 안된다. 그럼 어케넣냐
생산 - 뭐 받아서 작업해야 할 일 - 내가 받아서 작업하는데 그래서 이 객체가 최대치가 뭔임? 를 알아야함
소비 - 어디에 주거나, 할당해주거나 하는 작업 - 여기에 넣어줘라고 하는 애가 가지고 있는 하한이 어딘지를 알아야함
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1,2,3,4,5);
List<Number> numbers = new ArrayList<>();
copy(integers, numbers);
System.out.println(numbers);
}
public static void copy(List<? extends Integer> in, List<? super Number> out){
for (Integer integer : in) {
out.add(integer);
}
}
Java
복사
제네릭의 생성 과정
제네릭이 생겨난 이유에 대해서 우리는 이제 알고 있다. 컴파일 타임에 강한 타임에 검사를 통한 런타임 예외들의 축소이다. 그럼 컴파일 이후에 제네릭은 어케될까? 궁금하지 않는가?
일반적인 제네릭의 Erasure
만약 한정자가 들어가면 어떻게 될까?
제네릭 클래스 와 한정자가 들어갈 경우
만약 상속 관계라면 어떻게 될까?
제네릭 클래스의 상속 관계가 있을 경우
브릿지 메서드
브릿지 메서드 생성 과정 그대로 따라 해보기
힙오염
힙오염이란 단어 그대로 JVM의 힙 메모리 영역에 저장된 변수가 불량한 데이터를 참조하는 것이다. 즉, String str 이라는 변수가 int 1 을 참조하고 있는 것이다. 근데 생각해보면 이런 일이 가능할까? 아래의 코드를 보자 동작하지 않는다. ClassCastException이 발생한다. 원인은 String을 int 형으로 변환 시키려고 했기 때문이다. 그럼 그 다음 코드를 봐보자. 동작할까? 동작은 한다. 하지만 우리는 List에는 제네릭 타입을 무조건 명시해줘야 한다고 했다. 그리고 다음 코드를 보자. 그리고 생각해보자 동작할까? 동작한다. 우리가 Raw Type 즉, List를 그대로 쓴 것과 같은 현상이 발생한다.
// 코드는 동작할까?
String str = "string";
Object o1 = str;
Integer integer = (Integer) o1;
System.out.println(integer);
Java
복사
List list = new ArrayList();
list.add("aaaa");
list.add("bbbb");
Object o = list;
List convertList = (List) o;
convertList.add(1);
convertList.add(2);
System.out.println(convertList);
Java
복사
// 코드는 동작할까?
List<String> stringList = new ArrayList<>();
stringList.add("string");
stringList.add("next string");
Object o2 = stringList;
List<Integer> pollutionList = (List<Integer>) o2; // 오염 지역
pollutionList.add(1);
pollutionList.add(2);
System.out.println(pollutionList);
Java
복사
왜 이런일이 발생할까? 이유는 타입 소거 때문이다. 우리는 위에서 타입 소거를 통해 T → Object 로 변환해주는 걸로 배웠다. 이 때문에 실제 컴파일러가 컴파일한 이후의 코드는 List 즉, Raw Type 과 동일하게 되어 버리는 괴이한 현상이 일어난다. 이에 대한 땜빵 대책으로 아래와 같은 메서드가 만들어졌다.
List<String> list = Collections.checkedList(new ArrayList<>(), String.class);
이걸 사용하면 add에서 예외가 발생한다.
여담 구현이 어케된거지 볼려고 했지만 머리가 깨질 것 같아서 나중에...
Java
복사
여기서 알아두면 좋은 상식
타입 캐스팅 연산자는 컴파일러가 체크하지 않는다. 즉, 런타임에 예외가 난다.
제네릭 타입의 타입 파라미터는 컴파일 시점에 소거 된다.
제네릭 제약 조건 및 제한 사항
1.
원시 자료형은 못씀.
2.
new 로 생성 불가.
3.
static 필드는 사용 불가.
4.
if (list instanceof ArrayList<Integer>) 이렇게 못씀. if (list instanceof ArrayList<?>) 이건 가능.
5.
new T[] 못써요. 배열을 못 만듦.
6.
class MathException<T> extends Exception { /* ... */ } // 컴파일 타임 오류
class QueueFullException<T> extends Throwable { /* ... */ // 컴파일 타 오류|
try{…} catch (T e) { // 컴파일 타임 오류
public classCustomer <T extends Exception>{ 이건 된다.....
public voidp()throwsT{ }
}
Java
복사
7.
시그니처가 달라도 동일명에 제네릭을 사용하면 못써요.
public void a(Set<String> s){} // 얘는 String 이고
public void a(Set<Integer> s){} // 얘는 Integer 라도 못씀 이렇게는
Java
복사