제네릭

반응형
반응형

제네릭은 왜 사용할까?

제네릭은 JDK1.5부터 새롭게 등장했다.
그러면 제네릭이 도입되면서 어떤것이 바뀌었는지 고민해보자.
제네릭을 사용하면 타입 안정성이 증가하고, 타입체크와 형 변환을 생략할 수 있어서 코드가 간결해 진다고 한다.
과연 사실일까?

제네릭을 사용하는 대표적인 클래스로는 List가 있다.

List list = new ArrayList();

이 리스트는 어떠한 값이든 저장할 수 있다.

List list = new ArrayList();
list.add(2);
list.add("Hello");
list.add(100L);
list.add(1.5);

int, string, long, double다양한 타입이 들어왔다.

리스트

이것을 출력해보자.

for (Object object : list) {
  System.out.println(object);
}

물론, 모든 타입을 저장하는 리스트를 만들려고 할 수 있다.
하지만 나는 이 리스트에는 int만을 넣고 싶다면 어떻게 해야할까?

JDK1.5이전에는 다음처럼 코딩을 해야 되었다.

list.add(123);
list.add((int)123L);
list.add(new Integer('a'));

값을 추가할때, int타입으로 형변환을 해야 되었다.
물론 값을 추가만 이렇게 한다면 괞찬을 수도 있을지도 모른다.
그러면 for을 돌리면 object가 아닌 int가 나올까?

for (Object o : list) {
   System.out.println(o);
}

그러면 제네릭을 사용하면 이 코드가 어떻게 변할까?
코드가 이렇게 간결해 졌다.

List<Integer> list = new ArrayList();

또한,

더 이상 다른 타입은 들어오지 않게 되었으며,
이것을 for문으로 돌려보면

for (Integer integer : list) {
  System.out.println(integer);
}

놀랍게도 Object가 Integer로 변환했다는 걸 알 수 있다.
누가 봐도 이 리스트는 Integer이라는 것을 확신을 가질 수 있게 되었다.

그런데 두 개의 소스는 바이트 코드가 같을까?
만약, 바이트 코드가 같다면, 아니 비슷하다면 코드가 짧은게 훨씬게 더 좋을까?

  public static main([Ljava/lang/String;)V
    // parameter  args
   L0
    LINENUMBER 9 L0
    NEW java/util/ArrayList
    DUP
    INVOKESPECIAL java/util/ArrayList.<init> ()V
    ASTORE 1
   L1
    LINENUMBER 10 L1
    ALOAD 1
    BIPUSH 123
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    INVOKEINTERFACE java/util/List.add (Ljava/lang/Object;)Z (itf)
    POP
   L2
    LINENUMBER 11 L2
    ALOAD 1
    BIPUSH 123
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    INVOKEINTERFACE java/util/List.add (Ljava/lang/Object;)Z (itf)
    POP
}

123이라는 값이 들어갔다는걸 알 수 있다.

그렇다면 제네릭을 사용할 때는 어떨까?

  public static main([Ljava/lang/String;)V
    // parameter  args
   L0
    LINENUMBER 9 L0
    NEW java/util/ArrayList
    DUP
    INVOKESPECIAL java/util/ArrayList.<init> ()V
    ASTORE 1
   L1
    LINENUMBER 10 L1
    ALOAD 1
    BIPUSH 123
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    INVOKEINTERFACE java/util/List.add (Ljava/lang/Object;)Z (itf)
    POP
   L2
    LINENUMBER 11 L2
    ALOAD 1
    BIPUSH 123
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    INVOKEINTERFACE java/util/List.add (Ljava/lang/Object;)Z (itf)
    POP
 }

두 개의 바이트코드는 마치 데카코마니처럼 완전히 똑같다.
그렇다면 코드가 더 짧은 제네릭을 사용하는것이 실수 방지에도 좋지 않을까?
만약에 나는 무조건 실수 하지 않을 자신이 있다면, 제네릭을 사용하지 않아도 되지만, 인간은 무조건 실수한다,
당장은 실수하지 않을 수 는 있지만, 평생 실수하지 않을 거라는 보장을 할 수 없다.
그럴빠에 차라리 언어의 도움으로 실수를 최소하는것이 더 좋지 않을까?
list에 값을 넣을때 아무 생각없이 값을 넣어도 ide가 알아서 틀렸다고 알려줄것이다.
그러면 작은 차이가 큰 결과를 불러올 수 있다.

이러한 이유때문에 제네릭을 사용해야된다.

제네릭 클래스 만들기.

제네릭을 이용해서 클래스를 만들어보자.

public class Document<T> {

}

제네릭은 다음처럼 구성되어있다.

클래스 타입은 제네릭을 사용하는 원래 타입이라는 의미로 원시 타입이라고 부르며 영어로 raw type이라고 한다.
자료형에는 그 어떤 값도 다 넣을 수 있다.이를 타입 변수 또는 매개변수라고 한다.

참고로 T는 문자열로 그 어떤값도 넣을 수 있다.

public class Document<HELLO> {

}

이렇게도 작성할 수 있지만, 간단하게 어떤 것이 들어갈것인지 힌트를 준다고 생각하면 된다.
예를들어 T => TYPE 자료형 타입이 들어가는 구나
              E => Element 요소가 들어가는 구나(주로 컬렉션에서 많이 보인다.)
이런식으로 생각하면 된다.
위에서 이 부분을 매개변수라고도 표현했는데,
그 이유는 

public class Document<T,D,S,C> {

}

값을 계속적으로 넣을 수 있기 때문이다. 마치 메소드의 매개변수와 비슷한 느낌이다.

그렇다면 이것을 어떻게 사용할까?

private T value;
public void write(T value) {
  this.value = value;
}
public T print() {
  return value;
}


생각해보면 뭔지는 잘 모르겠지만 어떠한 타입이 들어오는데,
그 타입을 사용하는 느낌이다.
이것을 직접 사용해보자.

Document<String> document = new Document<>();
document.write("안녕하세요");
System.out.println(document.print());

 이렇게 사용하면 된다. (JDK1.7부터 <>에 생략이 가능해졌다.)

제네릭의 제한

new키워드로  객체를 생성할 수 없다.

그렇기 때문에 제네릭으로 배열을 만들 수 없다. 그렇다면 제네릭 배열은 만드는 건 불가능할까?
2가지 방법으로 만들 수 있다.

동적으로 만드는 방법

private T[] array;

public void makeArr(Class<T> clazz, int size) {
  array = (T[]) Array
      .newInstance(clazz, size);
}
Document<Integer> document = new Document<>();
document.makeArr(Integer.class,5);

이제 값을 넣어보자.

public void input(int index,T t) {
    array[index] = t;
}

public T[] getArray() {
    return array;
}

...

Integer[] array = document.getArray();
document.input(0,3);
document.input(1,5);
document.input(2,10);
document.input(3,70);
document.input(4,10);

for (Integer i : array) {
      System.out.println(i);
}

배열이 성공적으로 생성되었다.

Object배열을 만든 뒤, 그 배열을 복사하는 방법

private T[] array;
  private Object[] origin;

  public void makeArr(int size) {
    origin = new Object[size];
    this.array = (T[]) origin.clone();
  }

  public void input(int index,T t) {
    array[index] = t;
  }

  public int getArray() {
    return array.length;
}

...

Document<Integer> document = new Document<>();
document.makeArr(5);
document.input(0,1);
document.input(1,22);
document.input(2,15);
document.input(3,8);
document.input(4,10);

document.printValue(0);
document.printValue(1);
document.printValue(2);
document.printValue(3);
document.printValue(4);

저 코드를 더 줄일 수 있겠지만, 이 파트는 제네릭임이기 때문에 넘어가자.
실제 구현은 Collections.toArray()을 보면 확인 할 수 있다.

public <T> T[] toArray(T[] a) {
    // We don't pass a to c.toArray, to avoid window of
    // vulnerability wherein an unscrupulous multithreaded client
    // could get his hands on raw (unwrapped) Entries from c.
    Object[] arr = c.toArray(a.length==0 ? a : Arrays.copyOf(a, 0));

    for (int i=0; i<arr.length; i++)
        arr[i] = new UnmodifiableEntry<>((Map.Entry<? extends K, ? extends V>)arr[i]);

    if (arr.length > a.length)
          return (T[])arr;

    System.arraycopy(arr, 0, a, 0, arr.length);
    if (a.length > arr.length)
    a[arr.length] = null;
    return a;
}

이 처럼 제내릭은 인스턴스를 만들기 위해는 생각보다 쉽지 않다는걸 알 수 있다.
또, 제네릭은 컴파일시 타입이 결정되야 된다고 한다.

억지로 배열만들지 말고 이미 만들어진 컬렉션을 사용하는것이 더 좋을지도..

제한된 제네릭 클래스

위에서 클래스<T>에서 T는 어떤 클래스든지 들어올 수 있다.
그렇다면 범위를 줄일 수는 없을까?

2가지 방법으로 T의 범위를 줄일 수 있다.

extends

이건 상속에서 본것 같다. 
예를 들어,

public class Box {

}

상자가 있다.

public class Box<T> {

}

이렇게 하면 상자에는 어떤것이든지 넣을 수 있다.
그런데 이 상자에는 과일만을 넣어야 된다.
즉, 과일 상자라는 거다. 그러면 장난감이라던지 책은 넣으면 안된다.
그러면 과일 상자가 아니기 때문이다.
현재는 장난감, 책을 다 넣을 수 있다.

뭔가 조치가 필요하다.
바로 이렇게 하면 된다.

public class Box<T extends Fruit> {

}

이 뜻은 T는 무조건 Fruit의 자손이라는 뜻이며, 누가봐도 T는 과일이라는걸 알 수 있다.(is-a)[T는 과일 이다.]
결국 T는 Fruit에 상속 받았다는걸 알 수 있다.
이제 상자에는 과일만 넣을 수 있게 되었다.

이것을 다이어그램으로 그려보자.

지금까지 상속이 된 경우를 확인해봤다.
반대인 경우인 super을 사용하려 했지만, 클래스에서 만들때는 되지 않았다.


위를 해석해보면 T는 Friut의 상위 클래스여야 된다.
그러니까 제네릭을 키워드(T)로 만들때 T는 Friut보다 커질 수 없음을 암시한다.
참고로 후에 학습하는 와일드카드를 이용하면 super를 사용 할 수 있다.

제내릭 메소드

다음과 같은 메소드가 존재한다.

private T item;
public void pack(T item) {
   this.item = item;
}


즉 어떤 아이템이 들어온다는 뜻이된다.
그러면 위 처럼 특정 아이템만 포장할 수는 없을까?
위에서 학습한 것을 이용하면 된다.
그러면 이렇게 작성하면 될까?

public void pack(Box<T extends Fruit> item) {
    this.item = item;
  }

하지만 이 방법은 컴파일에러가 발생한다.
그 이유는 이것을 여러개 만들어보면 짐작할 수 있다.
사실 메소드를 잘못 작성했다.
메소드를 해석해보면 상자에 상자를 넣는 코드가 되버렸다.
그냥 해보자.

Box<Fruit> fruitBox = new Box<>();
Box<Apple> appleBox = new Box<>();
Box<Grape> grapeBox = new Box<>();
Box<Toy> toyBox = new Box<>();

과일 상자에 사과상자와 포도상자를 넣는다고 해보자.

fruitBox.pack(appleBox);
fruitBox.pack(grapeBox);

근데 이상하다.
현재 메소드는

public void pack(Box<T> item) {
    this.item = item;
}

이런식으로 되어있다. 그렇다는건 T에는 클래스 제네릭의 T와 일치해야된다는 뜻인데.
알다시피 일치하지 않는다.

그러면 어떻게 해야할까?
이럴때 사용하는 것이 와일드 카드다.

와일드 카드

어떻게 보면 와일드 카드는 메소드 오버라이딩과 같은 역할을 하게 된다.
왜냐하면,

fruitBox.pack(appleBox);
fruitBox.pack(grapeBox);

과일상자에 들어간것을 확인해보면 appleBox와 grapeBox다 근데 
이 들은 

Box<Apple> appleBox = new Box<>();
Box<Grape> grapeBox = new Box<>();

서로 다른 클래스임을 알 수 있다.
그렇다는건 두 개를 동시에 넣는건 불가능하다.
그래서 와일드 카드로 어떤것이든지 다 들어갈 수 있게 해줘야한다.

private Box<?> item;
public void pack(Box<?> item) {
  this.item = item;
}

 현재 상황은 item은 장난감상자도 허용한다.
근데 위에서 과일상자만 넣고 싶다면 장난감 상자는 허용하면 안된다.

바운디드 타입

이때 위에서 학습한 extends를 이용하자.

public void pack(Box<? extends Fruit> item) {
    this.item = item;
}

 이렇게 되면 과일상자인것만 상자에 넣을 수 있게 되었으며,
장난감 상자는 넣을 수 없게 되었다.

그러면 이번에도 super는 되지 않는 걸까?

public void pack(Box<? super Apple> item) {
    this.item = item;
}

정상적으로 동작된다.
이를 해석해보면 뭐가 들어오는지는 잘모르겠는데 아무튼 Apple보다 크거나 같아야된다.

? extends Fruit : ?는 Fruit이다.
? super Fruit : Fruit은 ?이다.

라고 해석되어진다.

이들을 경계를 지정한다는 의미에서  바운디드 타입이라고 부른다.

지금까지 

public class Box<T> {
}


이 제네릭을 사용했다. 그러면 이 제네릭을 지우면 어떻게 될까?
당연히 에러가 발생한다.
왜냐하면 더 이상 제네릭을 사용하지 않기 때문이다.
그러면 방법이 없을까?

다행히 메소드에 만드는 방법도 존재한다.

public <T> void pack(Fruit<T> item) {
    
}

현재 void앞에 <T>가 보일것이다. 이것으로 파라미터에 무엇이 들어가는지 예측하게 해준다.
여기서도 와일드 카드나 바운디드 타입을 넣을 수 있다.

public <T> void pack(T item) {

}

하지만 아쉽게도? 앞에 <T>는 클래스와 마찬가지로 와일드카드나 super가 불가능하다.

이것을 극한으로 사용하게 되면 다음처럼 사용할 수 있다.

public void pack(Fruit<? extends Fruit<? super Fruit<?>>> item) {

이것을 해석하려면 뒤에서 부터 읽어야 한다.

Fruit<?> : 어떤 과일이든 들어올 수 있다. 이를 R이라고 하자.
Fruit<? super R> R은 ?이다. 즉, 어떤 과일이든 ? 라는 뜻이 된다. 이를 M이라 하자.
Fruit<? extends M> ? 는 M 이다. 즉, ?은 어떤 과일이다. 

결국 어떤 과일이 들어와야 된다는 뜻이된다.
그러면 과일은 총 3개 Friut,Apple,Grape이다. 그러면 모두 될까?

다행히 컴파일에러가 발생하지 않는다.

그러면 Fruit<?>와 뭐가 다른걸까?
다른 점이라고는 

Fruit<?>
Fruit<? extends Fruit<? super Fruit<?>>> item

아래는 제네릭으로 쌓여있는 클래스는 넣을 수 없다는것을 알 수 있다.

제네릭 타입의 제거(Erasure)

컴파일러는 제네릭 타입을 이용해서 소스 파일을 체크하고,
필요한 곳에 형 변환을 넣어준다.
즉, 컴파일된 파일에는 제네릭 타입에 대한 정보가 없다.

import java.util.ArrayList;

public class Ori {
  public Ori() {
  }

  public static void main(String[] args) {
    new ArrayList();
  }
}

이렇게 하는 주 목적은 이전 소스 코드와 호환성을 유지하기 위함이라고 한다.
이렇게

List<String> arr = new ArrayList<String>();
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<Integer>();
List list = new ArrayList();

각기 다른 제네릭 4가지를 준비했다.
첫번째는 JDK1.5에서 제네릭을 활용했을때이며,
두번째는 JDK1.7에서 변경된 제네릭 타입 제거를 뜻한다.
세번째는 타입이 다른 경우이며,
마지막 하나는 JDK1.5이전에서 List를 사용할때의 경우이다.
이것들이 컴파일시에는 어떻게 변하는지 살펴보자.

import java.util.ArrayList;

public class Ori {
  public Ori() {
  }

  public static void main(String[] args) {
    new ArrayList();
    new ArrayList();
    new ArrayList();
    new ArrayList();
  }
}

놀랍게도 4가지 모두 같은 결과라는 것을 알 수 있다.
그러면 여기서 의문것이 이것들은 어떻게 서로를 알아볼까?
바이트 코드를 열어보자.

public static main([Ljava/lang/String;)V
    // parameter  args
   L0
    LINENUMBER 9 L0
    NEW java/util/ArrayList
    DUP
    INVOKESPECIAL java/util/ArrayList.<init> ()V
    ASTORE 1
   L1
    LINENUMBER 10 L1
    NEW java/util/ArrayList
    DUP
    INVOKESPECIAL java/util/ArrayList.<init> ()V
    ASTORE 2
   L2
    LINENUMBER 11 L2
    NEW java/util/ArrayList
    DUP
    INVOKESPECIAL java/util/ArrayList.<init> ()V
    ASTORE 3
   L3
    LINENUMBER 12 L3
    NEW java/util/ArrayList
    DUP
    INVOKESPECIAL java/util/ArrayList.<init> ()V
    ASTORE 4
   L4
    LINENUMBER 13 L4
    RETURN
   L5
    LOCALVARIABLE args [Ljava/lang/String; L0 L5 0
    LOCALVARIABLE arr Ljava/util/List; L1 L5 1
    // signature Ljava/util/List<Ljava/lang/String;>;
    // declaration: arr extends java.util.List<java.lang.String>
    LOCALVARIABLE strings Ljava/util/List; L2 L5 2
    // signature Ljava/util/List<Ljava/lang/String;>;
    // declaration: strings extends java.util.List<java.lang.String>
    LOCALVARIABLE integers Ljava/util/List; L3 L5 3
    // signature Ljava/util/List<Ljava/lang/Integer;>;
    // declaration: integers extends java.util.List<java.lang.Integer>
    LOCALVARIABLE list Ljava/util/List; L4 L5 4
    MAXSTACK = 2
    MAXLOCALS = 5
}

바이트 코드를 열어보니,
L0~L4까지 List를 만들고 L5에 제네릭에 설정된 값을 넣고 있다.
이런식으로 서로가 어떤 타입인지 알 수 있는거라 짐작 할 수 있을 것 같다.

그런데 위에서 JDK1.7이후에는 원시타입을 더 이상 작성하지 않아도 변경되었다.
이제 본격적으로 제네릭 타입의 제거 과정을 살펴보자.

제네릭 타입 제거

1) unbounded 사용할때,

public class Node<T> {
  private T data;
  private Node<T> next;

  public Node(T data, Node<T> next) {
    this.data = data;
    this.next = next;
  }

  public T getData() {
    return data;
  }
}

public class Node {
  private Object data;
  private Node next;

  public Node(Object data, Node next) {
    this.data = data;
    this.next = next;
  }

  public Object getData() {
    return data;
  }
}

 

2)bounded 사용할때

public class Node<T extends Comparable<T>> {
  private T data;
  private Node<T> next;

  public Node(T data, Node<T> next) {
    this.data = data;
    this.next = next;
  }

  public T getData() {
    return data;
  }
}

public class Node {
  private Object data;
  private Comparable next;

  public Node(Object data, Comparable next) {
    this.data = data;
    this.next = next;
  }

  public Object getData() {
    return data;
  }
}

 

2. 메소드 제네릭 타입 제거

public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray) {
      if (e.equals(elem)) {
        cnt++;
      }
    }
    return cnt;
  }

public static int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (Object e : anArray) {
      if (e.equals(elem)) {
        cnt++;
      }
    }
    return cnt;
  }

즉, T는 Object로 bounded된 타입은 extends된 타입으로 대체된다.
제네릭은 과거 소스코드와 호환이 시키기 위해 많은 노력이 있다는 사실을 컴파일 소스와 바이트 코드를 보면서 느낄 수 있었다.
다음에 제네릭을 공부할때는 조금더 디테일하게 공부하면 좋을 것 같다는 생각이 든다. 

출처: 자바의 정석
docs.oracle.com/javase/tutorial/java/generics/erasure.html

 

 

반응형

'프로그래밍 언어 > 자바' 카테고리의 다른 글

람다  (0) 2021.03.01
자바9 모듈  (0) 2021.02.22
멀티쓰레드 프로그래밍  (0) 2021.02.13
I/O  (0) 2021.02.11
어노테이션  (0) 2021.01.31

댓글

Designed by JB FACTORY