만약 Number 의 Subtype인 모든 List의 원소를 저장하는 메소드를 디자인 하려면 어떻게 해야할까요?

 

class Data{
    private List<Number> list;
    public void addAll(List<Number> cols){
        this.list.addAll(cols);
    }
}

public class Main {
    public static void main(String[] args)  {
        Data data = new Data();

        List<Integer> ints = Arrays.asList(1, 2, 3);
        List<Double> dbls = Arrays.asList(1.0, 2.8);
        List<Number> nums = Arrays.asList(5,4.5);
        
        data.addAll(ints); //Compile 에러 발생
        data.addAll(dbls); //Compile 에러 발생
        data.addAll(nums);
    }
}

 

지금까지 학습한 내용으로는 위와 같이 Data 클래스를 디자인 할 수 있습니다.

main 함수를 보면 Integer, Double, Number 관련 List 인스턴스를 생성했습니다.

 

과연 우리의 의도대로 Data 객체내 addAll 함수를 호출할 수 있을까요?

 

이전 포스팅에서 살펴보았듯이 Generic은 invariant 하기 때문에, 동일 타입의 Number 타입의 List를 제외하고 두 리스트에서의 메소드 호출은 컴파일 에러를 발생시킵니다.

 

그렇다면 이 경우에는 어떻게 메소드 디자인을 디자인할 수 있을까요?

Java에서는 이러한 요구를 충족되는 다양한 Wildcard 기법을 제공합니다.

 

이번 포스팅에서는 상위 경계 타입 파라미터(Upper-bound Type Parameter)를 알아보도록 하겠습니다.

 

class Data{
    private List<Number> list;
    public void addAll(List<? extends Number> cols){
        this.list.addAll(cols);
    }
}

 

위 코드에서 addAll 메소드는 상위 경계 타입 파라미터를 적용하였습니다.

이로써 이전에 발생했던 List<Integer>, List<Double> 변수를 addAll 메소드의 cols 변수에 할당할 수 있습니다.

 

그렇다면 List<? extends Number>는 무엇을 의미할까요?

 

  • 전달되는 인자가 어떤 타입인지는 모르겠다. 이를 X 라고 하자
  • X <: Number 라면 List<X> <:  List<? extends Number>이다.

 

위와 같은 의미로 인하여 List<Integer> <:  List<? extends Number> 됩니다. 따라서 변수 할당이 가능합니다.

마찬가지로 List<Double> <:  List<? extends Number>이므로 변수 할당 가능합니다.

 

이로써 일반 클래스 처럼 타입 파라미터 또한 Subtitution Principle 적용 가능해졌습니다. 위와 같은 특성을 Covariant 하다고 합니다. Covariant는 자기 자신 뿐만 아니라 자기 자신의 Subtype으로 타입 변환이 가능함을 말합니다.

 

Integer <: Number 일때, List<Integer>는 List<Number>의 Subtype이 아니다.

Integer <: Number 일때, List<? extends Integer> <: List<? extends Number> 이다!

 

public class Main {
    public static void main(String[] args)  {
        List<? extends Integer> ints = Arrays.asList(1, 2);
        List<? extends Number> nums = ints;
        nums.forEach(System.out::println);
    }
}

 

따라서 Corvariant 특성을 이용하면 위와 같은 사용 또한 가능합니다.


Heap Polution 이슈 방지

 

public class Main{
   public void static main(int argc, String[] argv){
       List<Integer> ints = new ArrayList<>();
       ints.add(1);
       ints.add(2);
       List<Number> nums = ints; //Compile 에러 발생
   }
}
public class Main{
   public void static main(int argc, String[] argv){
       List<Integer> ints = new ArrayList<>();
       ints.add(1);
       ints.add(2);
       List<? extends Number> nums = ints; //할당 허용
       nums.add(3.14); //Compile 에러 발생
   }
}

 

기존에 문제되었던 코드에 상위 경계 타입 파라미터를 사용하면 변수 할당이 가능함을 확인했습니다. 하지만 이때 상위 경계 타입 적용 변수에 Number의 Subtype 값을 할당하려고 하면 이전에 발생하였던 문제(List<Integer> 타입이 Integer가 아닌 Double 등의 다른 값 참조 현상)가 재발하게됩니다.

 

따라서 상위 경계를 사용한 객체에 대해서는 Subtype 값을 할당하려고 시도하면 컴파일 단계에서 에러를 발생시킵니다.

 

그렇다면 어떠한 과정으로 컴파일 단계에서 Subtype 값 할당이 실패하게 되는 것일까요?

Capture 변환 과정을 통해 이를 알아보도록 하겠습니다.


Capture 변환

 

List<Integer>와 같이 명시적으로 타입을 정의하면 List에서 제공되는 메소드가 호출되었을 때는 다음과 같이 논리적으로 생각해볼 수 있습니다.

 

public interface List<Integer> extends Collection<Ineteger>{
    ...
    Integer get(int index);
    void add(Integer element);
    ...
}

 

이 경우 add에 들어오는 값은 반드시 Integer 타입으로 지정되어있기 때문에 값 할당을 허용합니다.

그렇다면 List<? extends Number>로 타입이 지정되면 컴파일러는 어떤 타입으로 이를 추론할까요?

 

public interface List<?> extends Collection<?>{
    ...
    ? get(int index);
    void add(? element);
    ...
}

 

전달받는 파라미터 정보를 명시하지 않았기 때문에 타입을 추론할 수 없습니다. 따라서 컴파일시 내부적으로 임의의 타입 변수(capture of ?)으로 지정합니다. 

 

public interface List<X> extends Collection<X>{
    ...
    X get(int index);
    void add(X element);
    ...
}

만약 컴파일러가 지정한 임의의 타입을 X라고 간단하게 정의하면 List 인터페이스는 다음과 같이 추론될 수 있습니다.

 

그리고 List<? extend Number> 구문을 통해서 컴파일러는 적어도 해당 타입이 Number의 Subtype임을 알 수는 있습니다. 

 

결론적으로 위와 과정을 거쳐 컴파일러가 알 수 있는 사실은 다음과 같습니다.

Number의 Subtype이지만 무슨 타입인지는 모르겠다.

 

이러한 상황에서 만약 add 메소드를 호출하면 어떻게 될까요?

예시 코드를 통해 알아보도록 하겠습니다.

 

public class Main {
    public static void main(String[] args)  {
        List<Integer> ints = new ArrayList<>();
        ints.add(1);
        List<? extends  Number> nums = ints;
        Number number = nums.get(0);
        nums.add(number);
    }
}

 

X <: Number 이지만 무엇인지는 알 수 없습니다.

여기에 Number 값을 넣기 위해서는 Number <: X 이어야 합니다.

 

하지만 이는 성립되지 않습니다. 만약 X의 실제 타입이 Integer 였다면, Number <: Integer 라는 말인데 이는 성립되지 않기 때문입니다. 

 

따라서, 아무리 입력하고자 하는 값이 Number의 Subtype일지라도 X 타입 변수내에서는 타입 정보를 정확히 알지 못하기 때문에 null 이외에 어떠한 값 입력을 허용하지 않습니다.

 

public class Main{
   public void static main(int argc, String[] argv){
       List<Integer> ints = new ArrayList<>();
       ints.add(1);
       ints.add(2);
       List<? extends Number> nums = ints;
       nums.add(null);
   }
}

 

null이 허용 가능한 이유는 null은 모든 타입과 호환되는 값이기 때문입니다. 

 

그렇다면 해당 문법은 언제 사용해야 할까요?

일반적으로 상위 경계 타입 파라미터 타입은 조 바깥으로 T나 T의 Supertype으로 값을 읽어야 하는 경우 사용하며, 추론되는 E의 Subtype 값을 집어 넣을 수 없습니다.

 

List<? extends Number>를 예로 내용 정리하면 다음과 같습니다.

 

  • 관심대상은 List 클래스에서 제공되는 기능이다.(size 메소드, isEmpty 메소드 등)
  • 관심대상은 Number 클래스에서 제공되는 기능이다.(실제 들어있는 값, equals 메소드 등)
  • Number 타입의 Subtype들은 관심대상이 아니다.
  • 해당 변수에서는 구조체 내부 정보를 가져오기 위한 용도로 주로 사용된다.

 

개인적으로는 마지막 부분이 시사하는 바가 크다고 생각합니다.

이를 통해 앞으로 라이브러리 메소드를 사용할때 상위 한정자가 쓰여있다면, '메소드내에서 내가 전달한 구조에서 값을 꺼내는 용도로 사용하겠구나' 라는 의미를 파악하면 좋을 것 같습니다.

'JAVA > Generic' 카테고리의 다른 글

6. Generic 메소드  (0) 2020.03.10
5. Wildcard  (0) 2020.03.08
4. Wildcard with super(하위 경계)  (0) 2020.03.06
2. Subtyping, Substitution 원리  (0) 2020.03.05
1. Generic 기초  (3) 2020.03.04

+ Recent posts