[Java] Generic에 대한 관찰 - 2

sania Ka·2021년 11월 30일
2

Java 기능 관찰

목록 보기
8/8

이전 글에서 자바에서 Generic의 대략적인 작동원리를 살펴보았으니, 이번에는 조금 더 심화해서 살펴보도록 하자.

이 글에서는 이전글과는 다르게, JDK7 이상의 문법을 사용한다.

Generic 사용법 (심화편)

타입 파라미터 여러 개 사용하기

타입 파라미터를 여러 개 사용 할 수 있다. 대표적인 라이브러리로는 Map이 있다.

Map<Integer,String> map = new HashMap<>();
map.put(0,"ABC");
map.get(0);

메서드 생성 코드

public <K,V> void func(K arg0,V arg1){
    ...
}

클래스 생성 코드

class Test<T0,T1,T2>{
    public void func(T0 arg0, T1 arg1, T2 arg2){
        ...
    }
}

Generic 타입 제한

<T extends [타입]>은 존재하지만, <T super [타입]>은 없다.

extends

extends 키워드를 사용해 특정 타입의 자식으로 타입 파라미터를 제한 할 수 있다. 이때 타입은 클래스와 인터페이스를 가리지 않는다.
기본적인 용법은 <T extends [제한타입]> 이다.

extends 예시

public <T extends Readable> void func(T readable){
    ...
}

위의 코드는 Readable을 상속받은 타입만 T로 지정할 수 있다.

만약 여러 개의 타입을 동시에 상속한 경우로 제한하고 싶다면 & 기호를 사용하면 된다.

다중 extends

public <T extends Readable & Closeable> void func(T reader){
   ...
}

위의 코드는 Readable과 Closeable을 동시에 상속받은 타입만 사용할 수 있다.

물론, 여러 개의 타입 파라미터에 각각 타입 제한을 거는것도 가능하다.

public <T0 extends Readable & Closeable, T1 extends Appendable & Closeable & Flushable> 
    void func(T0 reader, T1 writer){
        ...
}

와일드 카드

와일드카드 문법은 제네릭 파라미터의 "타입"보다, 제네릭 파라미터를 사용하는 "방법"이 더 중요할때 사용한다.

와일드카드가 필요한 상황

Integer List를 순회하며 출력하는 코드

public void printList(List<Integer> list){
    for (int i : list) {
        System.out.println(i);
    }
}

위의 코드는 단순히 IntegerList를 받아와 list를 순회하면서 값을 출력하는 코드이다. 만약 Integer뿐만 아니라 Double등의 List도 순회하면서 출력하게끔 하고싶다면 어떻게 해야할까?

1차적으로 생각할 수 있는 코드는 List<Object>를 인자로 사용하는 함수로 변경하는 것이다.

Object List를 순회하며 출력하는 코드

public void printList(List<Object> list){
    for (Object obj : list) {
        System.out.println(obj);
    }
}

Object List를 사용하는 코드

List<Object> list = new ArrayList<>();
list.add(1);
printList(list);

List<Object> list2 = new ArrayList<>();
list2.add(10.0);
printList(list2);

위의 코드는 Generic의 타입 안정성 기능을 전혀 사용할 수 없는 데다가, 기존 코드를 모두 고쳐야 하는게 문제가 된다.
만약 List<Integer>List<Double>등의 자료형을 쓰고 있었다면, 모두 List<Object>로 변환해야만 사용이 가능한 함수가 되어버리는 것이다.
개발자가 하고 싶은 일은 단순히 List를 순회하면서 요소들을 출력하는 것이지만, 편하지만 타입 안정성을 해치거나, 가능한 모든 자료형에 대한 함수를 작성하는 방법처럼 영 좋지못한 방법만이 가능한 것이다.

<?>

이렇게 타입보다는 행위 그 자체에 초점을 두는 함수들은 <?>를 사용할 수 있다.
<?>Unbounded wildcard이라고 부르며, 특정 타입에 종속되지 않고, 어떠한 타입이든 올 수 있다는 것을 의미한다. 위의 코드를 개선한 코드는 다음과 같다.

함수 선언

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

와일드카드 함수를 사용하는 코드

List<Integer> list = new ArrayList<>();
list.add(1);
printList(list);

List<Double> list2 = new ArrayList<>();
list2.add(10.0);
printList(list2);

이런식으로 작성한 코드는 모든 자료형마다 함수를 작성하거나, 호출자 쪽에서 Object List로 변경하여 코드를 실행할 필요가 없어진다.

<?>를 사용한 코드는 모든 타입이 Object로 취급된다. 따라서 Object의 기능만으로 돌아가거나, 타입과 상관없는 기능을 작성할때만 유효하다.

만약 특정 타입에 관계가 있는 기능을 작성해야한다면 어떻게 해야할까?

<? extends T>

모든 타입이 Object로 취급되는 <?> 과는 다르게 extends 키워드를 사용하여 함수의 호출자가 특정 타입의 자식들만 사용할 수 있도록 강제 할 수 있다.
만약 위의 함수에서 전체 요소를 순회하여 출력하는 것과 더불어, 정수 값들의 합을 출력하게 끔 변경한다고 하자. 이런 경우에는 Number를 제외한 타입이 사용된다면 정상적인 결과가 출력되지 않을 것이다.
이럴때 사용 할 수 있는 것이 <? extends T> 문법이다.

<? extends T>를 사용한 문법

public <T extends Number> void printList(List<? extends T> list){
    long sum = 0L;
    for (Number num : list) {
        System.out.println(num);
        sum += num.longValue();
    }
    System.out.println(sum);
}

물론 이렇게 작성하면 코드가 난잡해지므로 아래와 같이 사용할 수도 있다.

<? extends Number>를 사용한 문법

public void printElemAndSum(List<? extends Number> list){
    long sum = 0L;
    for (Number num : list) {
        System.out.println(num);
        sum += num.longValue();
    }
    System.out.println(sum);
}

이처럼 작성을 하면 의도한 대로 숫자형 이외의 자료형이 List에 포함되지 못하도록 제한 할 수 있게된다.

<? super T>

extends와는 반대로 super를 사용하면 함수의 호출자가 특정 타입의 부모 타입들만 사용하도록 강제 할 수도 있다.
간단한 코드 예시를 먼저 보자.

public void addLoggers(List<? super Writer> list){
    list.add(new BufferedWriter(new OutputStreamWriter(System.out)));
    list.add(new FileWriter("log.txt"));
}

위의 코드는 로그를 표준 출력과 특정 파일에 내용을 작성 할 수 있는 Writer들을 list에 추가해준다.
list의 경우 writer 상위의 객체만 포함한다.
그로 인해 list에 어떠한 클래스가 담기건, writer 까지만의 기능을 사용한다는 보장이 되는 것이다.

List<Writer> list = new ArrayList<>();
addLoggers(list);
for (Writer writer : list) {
    writer.write("aaa");
    writer.flush();
}

따라서 list에 BufferedWriter가 담기건, FileWriter가 담기건 간에 Writer 또는 그 상위 타입들의 기능만을 사용하게 된다.

<? super T>를 사용한다면, ?는 최소한 T의 기능까지는 구현이 보장되어야 하며, 그 하위 타입인지는 관심이 없다고 정리할 수 있겠다.

PECS와 타입 제한에 대한 추가 설명

Producer Extends, Consumer Super의 앞글자만 딴 명칭이다.
위의 <? extends T><? super T> 문법이 헷갈릴때 이것을 기억하면 좋다.
외부에서 데이터를 생산한다면(Producer), extends를, 외부에서 데이터를 소모한다면(Consumer), super를 사용하라. 로 이해하면 좋겠다.

오라클 공식문서에서는 In과 Out의 개념으로 설명하고 있다. (개인적으로는 이게 더 이해가 잘 된다.)

An "in" variable serves up data to the code. Imagine a copy method with two arguments: copy(src, dest). The src argument provides the data to be copied, so it is the "in" parameter.
An "out" variable holds data for use elsewhere. In the copy example, copy(src, dest), the dest argument accepts data, so it is the "out" parameter.
...
An "in" variable is defined with an upper bounded wildcard, using the extends keyword.
An "out" variable is defined with a lower bounded wildcard, using the super keyword.

요약하자면, in은 extends를, out은 super를 사용하면 된다.

간단한 코드 예제를 보자.

public void copyList(List<? extends Reader> in, List<? super Reader> out){
    for (Reader integer : in) {
        out.add(integer);
    }
}

in의 데이터를 out에 추가하는 간단한 메서드이다.
in의 요소들은 최소한 Reader라는 것이 보장이 되고, out에는 최소한 Reader의 기능까지는 사용 할 수 있는 요소들이 있다는 것이 보장된다.

만약 in이 super로, out이 extends로 순서가 바뀌게 된다면?

  • in에는 Reader를 포함한 Closeable, Object등이 포함될 수 있고, 심지어 Closeable 또는 Object로 캐스팅한 전혀 다른 타입이 포함 될 수 있다. 따라서 기본적으로 Object로 처리되며, 강제 캐스팅을 하는 경우 정상적인 동작을 보장할 수 없다.
  • out는 Reader를 포함한 서브타입들의 리스트가 될 수 있다. 따라서 out은 List<Reader>일수도, List<BufferedReader>일 수도 List<InputStreamReader>일수도 있다.
    만약 out이 List<Reader>라면 Reader 또는 그 하위타입인 InputStreamReader를 삽입할 수 있겠지만, List<BufferedReader>라면 불가능하다. 따라서 컴파일 에러가 발생하게 된다.

그러므로 in(읽기 연산)에는 extends를, out(쓰기 연산)에는 super를 사용하면 된다.


가이드 관련 추가 내용

In the case where the "in" variable can be accessed using methods defined in the Object class, use an unbounded wildcard.
In the case where the code needs to access the variable as both an "in" and an "out" variable, do not use a wildcard.

in이 Object클래스에 정의된 메서드만 사용한다면, unbounded wildcard(<?>)를 사용하고 변수가 in과 out의 역할을 동시에 한다면, 와일드카드를 사용하지 말라고 한다.


번외 1: <T super [타입]>은 어디갔나요?

<T super [타입]> 문법은 존재하지 않는다.
만약 <T super HashMap>이라고 사용한다면, 기본적으로 Type Erasure를 통해 Object로 변환되기 때문에 어떤 타입인지 추론이 되지 않는 T는 결국 Object와 다르지 않다.

만약 타입정보 소거가 이루어지지 않는다고 할지라도 문제가 있다.
자바는 단일 클래스, 다중 인터페이스 상속을 지원한다. 그러나 타입 파라미터는 클래스와 인터페이스를 가리지 않으므로 T가 어떤 타입이 되는지 모호해진다는 문제가 발생한다.

<T super HashMap>에서 계층구조를 따라 T에는 AbstractMap, Map, Cloneable, Serializable, Object가 모두 올 수 있게 된다. 이러한 경우에는 T를 특정할수 없게되니 전혀 쓸모가 없는 코드라고 할 수 있다.

<? super HashMap>과는 다르게 T를 사용하는 것은 타입 파라미터에도 관심이 있는 경우이므로, Object와 다르지 않은 <T super [타입]> 문법은 의미가 없고, 존재하지도 않는다.


번외 2: <?> vs <? extends Object>

두개의 차이는 Reifiable Type인지 아닌지이다.

Reifiable Type?

구체화 가능 타입 정도로 해석하면 좋다.
Runtime시에 완전하게 오브젝트 정보를 표현 할 수 있는 타입을 의미한다. 바꾸어말하면, 컴파일시에 타입 정보가 소거되지 않는 타입이다.

A reifiable type is a type whose type information is fully available at runtime. This includes primitives, non-generic types, raw types, and invocations of unbound wildcards. (Oracle 문서)

  1. int, long등의 primitives.
  2. String, Runnable 등의 non-generic types.
  3. 순수한 List, Map등의 raw types.
  4. List<?>, Map<?,?>등의 unbound wildcards.
    으로 정리할 수 있다.

따라서 List<?>는 컴파일시 정보가 소거되지 않는 Reifiable Type이고, List<? extends Object>는 컴파일시 정보가 소거되는 Non-Reifiable Type이다.
이 차이로 인해서 Non-Refiable 타입인 List<? extends Object>는 런타임시에는 List로 취급되는데, 타입 정보가 지워졌으므로, List<? extends Object>인지, List<Integer>인지 확인 할 방법이 없다.
타입정보가 없기 때문에, 몇몇 기능들을 사용 할 수 없다.

instanceof 연산

List<?>

List<Integer> list = new ArrayList<>();
if(list instanceof List<?>){
    System.out.println("instance");
}

위 코드는 정상적으로 실행된다. 이 코드는 List의 세부타입에 대해 궁금한 것이 아닌, 단순히 List인지 확인하는 코드이기 때문이다. 물론 List<?>이 아닌 List로 작성해도 동작한다.

List<? extends Object>

List<Integer> list = new ArrayList<>();
if(list instanceof List<? extends Object>){
    System.out.println("instance");
}

위 코드는 컴파일에러가 발생한다. 이 코드는 List인지 확인하는것 뿐만 아니라 세부타입이 Object를 상속했는지도 확인하는 코드이기 때문이다. 타입 정보가 지워졌으므로, 이를 확인할 방법이 없기 때문에 컴파일 에러가 발생하게 된다. List<? extends Object>List<Integer>로 변경한다고 해도 동작하지 않는다.

Generic Array creation

List<?>

List<?>[] arrayOfList = new List<?>[1];
arrayOfList[0] = new ArrayList<Integer>();

위 코드는 정상적으로 실행된다.

List<? extends Object>

List<? extends Object>[] arrayOfList2 = new List<? extends Object>[1];
arrayOfList2[0] = new ArrayList<Integer>();

위 코드는 컴파일 에러가 발생한다. 마찬가지로 new List<? extends Object>[1]를 실행할때 타입 정보가 없기 때문이다. 코드를 변경하여 new List<?>[1]로 선언하면 동작한다.

0개의 댓글