[테코톡] Generics

RGunny·2021년 7월 7일
0

techtalk

목록 보기
1/1

우아한 테크톡 정리

Generics

제네릭이란?

JDK1.5에 처음 도입되었습니다.

다양한 타입의 객체들을 다루는 메서드나 클래스에 컴파일 시의 타입 체크를 해주는 기능

  • Generics add stability to your code by making more of your bugs detectable at compile time. – Oracle Javadoc

타입 체크란?

Type Checking(자료형 검사)는 자료형의 제약 조건을 지키는 지 검증하는 것

좀 더 깊게 보자면, Statkc type chekingDynamic type checking이 있는데,
자료형 검사를 컴파일 시에 하는 가 실행 시간에 하는 가에 따른 차이입니다.

하지만 저는 자바를 사용하므로 정적 타입 체크만 알아보겠습니다.
변수를 만들 때 최초에 자료형을 초기화해주고 따로 형변환(Type Casting)을 하지 않는 이상 그 자료형은 고정됩니다.
자료형 검사를 컴파일 시에 하므로 자료형으로 인한 오류를 쉽게 잡아낼 수 있습니다.

타입 체크

List<String> stringList = new ArrayList<>();

stringList.add(1);                  // error
stringList.add('이것은되는가?');      // error
stringList.add("이것이 의도한 타입");

제네릭-컴파일오류

위의 코드를 실행하면 Compile Error가 발생하며 컴파일 시에 다음과 같이 오류가 나게 됩니다.

자바 타입 확인

변수명.getClass().getName()

String str =  "ABC"; // String
Integer integer = 123;
int i = 123;        // int
List<String> list = new ArrayList<>(); // ArrayList

System.out.println(str.getClass().getName());
System.out.println(integer.getClass().getName());
System.out.println(list.getClass().getName());
System.out.println(i.getClass().getName()); // error

자바-타입체크
다음과 같이 타입을 확인할 수 있습니다.
추가적으로 intprimitive type이므로 reference type이 아니기 때문에 당연히 확인할 수 없습니다.


제네릭을 사용하지 않는 경우 문제점

본격적으로 제네릭에 대해 알아보겠습니다.

혹시 Object 클래스를 사용하면 굳이 제네릭을 사용할 필요는 없지 않을까요?
자바의 모든 클래스는 Object 클래스를 상속받기 때문에 Object 타입으로 받게 된다면 어떤 타입이든 받을 수 있습니다.

public class ObjectArrayList {
    private int size;
    private Object[] elementData = new Object[5];

    public void add(Object value) {
        elementData[size++] = value;
    }

    public Object get(int index) {
        return elementData[index];
    }

    public static void main(String[] args) {
        ObjectArrayList list = new ObjectArrayList();

        list.add(10);
        list.add(100);

        Integer value1 = (Integer) list.get(0);
        Integer value2 = (Integer) list.get(1);

        System.out.println(value1 + value2);
    }
}

Object로 ArrayList를 만드는 다음의 코드를 실행하면 오류 없이 110이라는 값이 나오게 됩니다.

add() 메서드가 파라미터로 Object를 받기 때문에 어떤 데이터 탕칩이든 받을 수 있습니다. 따라서 get() 메서드 사용 시 형변환만 잘 시켜준다면 어떤 데이터 타입이든 저장할 수 있습니다.

public static void main(String[] args) {
    ObjectArrayList list = new ObjectArrayList();

    list.add("10");
    list.add("100");

    Integer value1 = (Integer) list.get(0);
    Integer value2 = (Integer) list.get(1);

    System.out.println(value1 + value2);
}

add() 메서드에 넣는 데이터 타입을 String으로 바꾸어 실행해본다면 컴파일까지는 문제가 없지만,
Runtime Error가 발생하며 다음과 같은 오류가 나오게 됩니다.

Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
	at techtalk.ObjectArrayList.main(ObjectArrayList.java:21)

java.lang.ClassCastException 이 발생하는데, 즉, 타입 캐스팅이 잘못됐다는 오류 메세지입니다. String을 넣고 Integer로 형변환했기 때문에 발생한 오류입니다.

가장 큰 문제점은 Compile Error가 아닌, Runtime Error가 발생한다는 것입니다.

이를 해결하기 위해 각각의 Type을 가질 수 있는 ArrayList를 만든다면 코드의 중복이 생기기 시작합니다. add()get() 메서드는 같은 역할을 하지만 파라미터 타입과 반환 타입이 달라 인터페이스나 상속으로 해결할 수도 없습니다.

제네릭 사용

class GenericArrayList<T> {
    private int size;
    private Object[] elementData = new Object[5];

    public void add(T value) {
        elementData[size++] = value;
    }

    public T get(int index) {
        return (T) elementData[index];
    }

    public static void main(String[] args) {
        GenericArrayList<Integer> genericIntList = new GenericArrayList<>();

        genericIntList.add(10);
        genericIntList.add(100);

        int value1 = genericIntList.get(0);
        int value2 = genericIntList.get(1);

        System.out.println(value1 + value2);
        
        String value = genericIntList.get(0); // Compile Error
    }

<T>가 바로 제네릭입니다. GenericArrayList 객체를 생성할 때 타입을 지정하면, 생성되는 객체 안에 T의 위치에 지정한 타입이 들어가는 것으로 컴파일러가 인식합니다. 정확하게는 Raw 타입으로 사용하는데 컴파일러에 의해 필요한 곳에 형변환 코드가 추가된 것입니다.

ex) List<String>List로만 사용하는 것

이전에 Runtime E별도의 형변환 없이 지정한 타입과 다른 타입의 참조변수를 선언하면 이전에 Runtime Error가 나던 것과 달리 Compile Error`가 발생하는 것이 중요합니다.

맨 아래 줄을 실행 시 다음과 같은 에러가 발생합니다.

java: incompatible types: java.lang.Integer cannot be converted to java.lang.String

제네릭 사용법

제네릭 클래스 선언

제네릭 클래스는 다음과 같은 형식으로 정의됩니다.

class name <T1, T2, ..., Tn>

<>로 구분 된 타입 매개 변수는 클래스 이름 뒤에 오며, 객체가 생성될 때 타입 파라미터를 받는 부분입니다.

타입 파라미터와 일반 클래스 또는 인터페이스 이름의 차이를 구분하기 위해 정해진 규칙에 따라 타입 파라미터는 단일 대문자를 사용합니다.

일반적으로 사용되는 타입 매개변수 이름입니다.

E - element
K - key
N - number
T - type
V - value
S, U, V - 2nd, 3rd, 4th declared types

예시

public interface Map<K, V>{
	Set<Map.Entry<K, V>> etrySet();
}

public interface List<E>{
	Iterator<E> iterator();


public interface BiFunction<T, S, R>{ // R: return type
	R ally(T t, S s);
}

사실 아무 이름이나 지정해도 컴파일하는 데 문제가 없지만, 네이밍은 약속이므로 컨벤션을 꼭 지키면서 사용합시다!
(돌고 돌아 본인이 편해집니다.)

Bounded Type, Wild Card

  • 제네릭 타입에는 여러가지가 있습니다.

바운디드 타입 매개변수 (Bounded type parameter)

제네릭 타입에서 타입 인자로 사용할 수 있는 타입을 제한하려는 경우가 있을 수 있습니다.
바운디드 타입은 특정 타입의 서브 타입으로 제한합니다.
클래스나 인터페이스를 설계할 때, 가장 흔하게 사용하는 방식입니다.

바운디트 타입 파라미터를 선언하려면 타입 파라미터의 이름, extend, 상위 바운드를 나열합니다.

<T extends UpperBound>

여기에서 extendsimplements의 기능까지 포함하기 때문에 상위 바운드는 인터페이스가 될 수 있습니다.

여러 개의 상위 바운드를 가질 수도 있습니다.

<T extends B1 & B2 & B3>

만약 여러 개의 상위 바운드 중에 클래스가 있다면 해당 상위 바운드가 가장 앞에 와야 합니다.
그렇지 않을 시 Compile Error가 발생합니다.

<T extends ClassA * InterfaceA & InterfaceB>

예제

유연하게는 만들고 싶고, 모두 허용(타입에 대해)하자니 명세상 와서는 안 되는 타입도 들어와지고...

적당히 유연하게 만들고 싶을 때가 많을 것 같습니다.

예를 들어, 아래와 같이 숫자에 대해서만 작동하는 메서드는 Number 또는 해당 하위 클래스의 인스턴스만 허용하려 할 수 있습니다.

public class BoundedType <T extends Number> {
    public void set(T value) {}

    public static void main(String[] args) {
        BoundedType<Integer> boundedType = new BoundedType<>();

        boundedType.set("HI");
    }

}

이 때, boundedType.set("HI"); 부분에서 Compile Error가 발생합니다.
BoundType 클래스는 타입 파라미터로 <T extends Number>를 선언하고 있기 때문에
Number의 서브 타입만 허용하게 됩니다.
IntegerNumber의 서브 타입이기 때문에 선언이 가능하지만, set() 메서드 인자로 문자열을 입력했기 때문에 컴파일 에러가 발생하게 됩니다.

추가적으로, Bounded Typeextends를 사용하기 때문에 상위 바운드에 해당하는 클래스의 메서드를 코드에서 사용할 수 있다는 특징이 있습니다.

와일드 카드 (Wild Card)

제네릭 타입 코드에서 와일드 카드라고 하는 물음표(?)는 알 수 없는 유형을 나타냅니다. 와일드 카드는 다음과 같은 다양한 상황에서 사용할 수 있습니다.

매개변수, 필드 또는 지역 변수의 타입 그리고 때로는 리텁 타입으로도 사용됩니다.
와일드 타드는 제네릭 메서드 호출, 제네릭 클래스 인스턴스 생성 또는 슈퍼 타입에 대한 유형 인수로는 사용되지 않습니다.

Upper Bounded Wildcards

Upper Bounded Wildcards를 사용하여 바운디드 타입의 상위 제한을 완화할 수 있습니다.

<? extends UpperBound>

이런 제네릭 타입은 UpperBound 클래스 또는 인터페이스의 하위 타입과 매칭될 수 있습니다.

예를 들어, List<Number>List<Integer>의 상위 클래스가 아닙니다. 따라서 List<Number>를 파라미터로 가지는 메서드에 List<Integer>를 인자로 호출하면 다음과 같은 Compile Error가 발생하게 됩니다.

public class UpperBoundedWildCard {

    public static double sumOfList(List<Number> list) {
        double s = 0.0;
        for (Number n : list)
            s += n.doubleValue();
        return s;
    }

    public static void main(String[] args) {
        List<Integer> li = Arrays.asList(1, 2, 3);

        System.out.println("sum = " + sumOfList(li)); // Compile Error
    }
}
java.util.List<java.lang.Integer> cannot be converted to java.util.List<java.lang.Number>

위와 같은 제한을 완화하기 위해 Upper Bounded Wildcards를 사용할 수 있습니다.
List<Integer>도 메서드 인자로 사용할 수 윘도록 와일드 카드를 추가하는 것인데, List<? extends Number>List`보다 덜 제한적입니다.

public class UpperBoundedWildCard {
    
    /**
	Upper Bounded Wildcards
    */
    public static double sumOfList(List<? extends Number> list) {
        double s = 0.0;
        for (Number n : list)
            s += n.doubleValue();
        return s;
    }

    public static void main(String[] args) {
        List<Integer> li = Arrays.asList(1, 2, 3);

        System.out.println("sum = " + sumOfList(li));
    }
}

아래의 코드는 같은 기능을 하는 메서드를 와일드 카드 없이 구현한 것입니다.

public class UpperBoundedWildCard {

    public static <T extends Number> double sumOfList(List<T> list) {
        double s = 0.0;
        for (T n : list)
            s += n.doubleValue();
        return s;
    }

    public static void main(String[] args) {
        List<Integer> li = Arrays.asList(1, 2, 3);
        System.out.println("sum = " + sumOfList(li));
    }
}

두 방식의 차이로

public static double sumOfList(List<? extends Number> list) : 와일드 카드는 참조가 불가능하기 때문에 메서드에서 참조될 수 없습니다.

public static <T extends Number> double sumOfList(List<T> list): T elem; 처럼 메서드 내에서 타입을 참조하여 사용할 수 있습니다.

Unbounded Wildcards

List<?>와 같은 형태로 물음표<?> 만 가지고 정의됩니다. 내부적으로 Object로 정의되어 사용되고 모든 타입의 인자를 받을 수 있습니다. 타입 파라미터에 의존하지 않는 메서드만 사용하거나 Object 메서드에서 제공하는 기능으로 충분할 경우에 사용합니다.

  • Object 클래스에서 제공하는 기능만을 사용하여 구현할 수 있는 메서드를 작성하는 경우
  • 타입 매개변수에 의존하지 않는 제네릭 클래스의 메소드를 사용하는 경우
    (예를 들어, List.size() 또는 List.clear())
public class UnBoundedWildCard {

    public static void printList(List<?> list) {
        for (Object elem : list)
            System.out.println(elem + " ");
        System.out.println();
    }

    public static void main(String[] args) {
        List<Integer> li = Arrays.asList(1, 2, 3);
        List<String>  ls = Arrays.asList("one", "two", "three");
        printList(li);
        printList(ls);
    }
}

다만, List<?>List<Object>와 동일하게 작동하는 것은 아닙니다.
List<Object>에는 어떤 객체든 담을 수 있지만,
List<?>에는 오직 null만 담을 수 있습니다.

Lower Bounded Wildcards

Lower Bounded Wildcards를 사용하여 제네틱 타입을 특정 타입의 상위 클래스로 제한할 수 있습니다.

<? super LowerBound>
public static void addNumToList(List<Integer> list) {
        for (int i = 1; i <= 10; i ++) {
            list.add (i);
        }
    }

위의 메서드를 main에서 실행하게 되면, 다음과 같은 Compile Error가 발생하게 됩니다.

java.util.List<java.lang.Number> cannot be converted to java.util.List<java.lang.Integer>
public class LowerBoundedWildCard {

    public static void addNumToList(List<? super Integer> list) {
        for (int i = 1; i <= 10; i ++) {
            list.add (i);
        }
    }

    public static void main(String[] args) {
        List<Number> li = Arrays.asList(1, 2, 3);

        addNumToList(li);
    }
}

위와 같은 제한을 완화하기 위해 Lower Bounded Wildcards를 사용할 수 있습니다.
List<Number>도 메서드 인자로 사용할 수 윘도록 와일드 카드를 추가하는 것인데, List<? super Integer>List`보다 덜 제한적입니다.

Wild Card Capture

Helper Methods를 이용하여 컴파일러가 와일드 카드 타입을 유추할 수 있도록 도와주는 방식을 Wild Card Capture라고 합니다.

컴파일러는 기본적으로 List<?>에 대해 List<Object>로 처리하려고 하며 set() 메서드에 엘리먼트 타입을 컴파일 타임에 확인할 수 없기 때문에 Compile Error가 발생합니다.

public class WildCardCapture {

    static void foo (List<?> i) {
        // Compile Error
        i.set(0, i.get(2));
    }

    public static void main(String[] args) {
        List<Integer> li = Arrays.asList(1,2,3);
        System.out.println(li);
        foo(li);
        System.out.println(li);
    }
}

아래와 같이 헬퍼 메서드를 추가하여 컴파일러가 와일드 카드 타입을 추론할 수 있게 만듭니다.

public class WildCardCapture {

    static void foo (List<?> i) {
        originalMethodNameHelper(i);
    }

    private static <T> void originalMethodNameHelper(List<T> i) {
        i.set(0, i.get(2));
    }

    public static void main(String[] args) {
        List<Integer> li = Arrays.asList(1,2,3);
        System.out.println(li);
        foo(li);
        System.out.println(li);
    }
}

Wildcard Caution:

  • null을 추가할 수 있습니다.
  • clear를 호출할 수 있습니다.
  • iterator를 가져오고 remove를 호출할 수 있습니다.
  • 와일드 카드를 캡쳐하고, List에서 읽은 요소를 쓸 수 있습니다.

Type Erasure

컴파일러는 컴파일 시 타입 파라미터를 사용하는 대상의 타입을 컴파일러가 정하는 타입으로 대체하는 Type Erasure를 실행하게 됩니다. 컴파일된 바이트코드에서는 T 대신 특정 타입으로 대체되어 있습니다.

Type Erasure의 규칙은 다음과 같습니다.

  • 제네릭 타입의 타입 파라미터가 상하한이 있는 경우에는 타입 파라미터를 한계 타입으로, 없는 경우 모든 타입 파라미터를 Object로 바꿉니다. 따라서 생성된 바이트 코드에는 보통의 클래스, 인터페이스 및 메서드만 포함됩니다.
  • type-safety를 유지하기 위해 필요한 경우 타입 캐스팅을 사용할 수 있습니다.
  • 제네릭 타입을 상속받은 클래스에서는 다형성을 유지하기 위해 브리지 메서드를 생성합니다.

Gererics 사용 예시

Erasure of Generic Types

Java 컴파일러는 type erasure 프로세스로서 모든 타입 파라미터를 지우고 타입 파라미터가 바인드 된 경우 첫 번째 바인드로 대체하고 타입 파라미터가 바인드 되지 않은 경우 Object로 대체합니다.

다음은 타입 파라미터가 바인드 되지 않은 상태이므로 Object로 대체됩니다.

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; }
    // ...
}

다음 예제에서 일반 Node 클래스는 bounded type parameter를 사용합니다.

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; }
    // ...
}

Java 컴파일러는 바인딩 된 유형 파라미터 T 를 첫 번째 바인딩 된 클래스 인 Comparable로 대체합니다.

public class Node {

    private Comparable data;
    private Node next;

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

    public Comparable getData() { return data; }
    // ...
}

제네릭 타입 주의사항

  • Cannot Instantiate Generic Types with Primitive Types
Pair<int, char> p = new Pair<>(8, 'a');  // compile-time error
  • Cannot Create Instances of Type Parameters
public static <E> void append(List<E> list) {
    E elem = new E();  // compile-time error
    list.add(elem);
}
  • Cannot Declare Static Fields Whose Types are Type Parameters
public class MobileDevice<T> {
    private static T os; // compile-time error
}
  • Cannot Use Casts or instanceof With Parameterized Types
public static <E> void rtti(List<E> list) {
    if (list instanceof ArrayList<Integer>) {  // compile-time error
    }
}
  • Cannot Create Arrays of Parameterized Types
List<Integer>[] arrayOfLists = new List<Integer>[2];  // compile-time error
// Unbounded Wild card 사용 시 가능 (list가 ArrayList인 지 확인하기 위함)
// 런타임은 타입 파라미터를 추적하지 않기 때문에 ArrayList<Integer>인지 ArrayList<String>간의 차이를 알 수 없기 때문
public static void rtti(List<?> list) {
    if (list instanceof ArrayList<?>) {  // OK; instanceof requires a reifiable type
        // ...
    }
}
  • Cannot Create, Catch, or Throw Objects of Parameterized Types
// Extends Throwable indirectly
class MathException<T> extends Exception { /* ... */ }    // compile-time error

// Extends Throwable directly
class QueueFullException<T> extends Throwable { /* ... */ // compile-time error
  • Cannot Overload a Method Where the Formal Parameter Types of Each Overload Erase to the Same Raw Type
public class Example {
    public void print(Set<String> strSet) { }
    public void print(Set<Integer> intSet) { }
}

결론

Generics을 사용해야 하는 이유

  1. 강력한 타입 체크(Type Checking) 지원
  2. 형변환(Type Casting)을 하지 않다도 됨

References

0개의 댓글