지금까지 Generic 기초 및 동작 원리에 대해서 살펴봤습니다.

 

Generic을 활용한 Type erasure 동작 방법을 정리하면 다음과 같습니다.

 

  1. 경계가 표시된 Type은 경계로 컴파일시에 치환된다
  2. Unbounded Wildcard(?) 타입은 Object 타입으로 치환된다
  3. 타입 안정성 보장을 위해서 캐스팅 연산을 수행한다.
  4. Generic Type에 대한 다형성 지원을 위해서 Bridge 메소드를 추가한다.

 

개인적으로 Generic이 도입은 Java를 사용함에 있어 많은 편의성을 가져다 줬지만, 이에 못지않은 불편함 또한 주었다고 생각합니다. 즉 Type erasure로 인하여 하위 호환성을 유지할 순 있었지만, 한참이 지난 지금도 런타임시 타입을 알지 못하는 불편함으로 인해 개발자가 코드 작성시에 주의를 기울여야합니다.

 

이번 포스팅에는 Type erasure에 의해서 런타임시 표현되는 Unknown Type 불일치로 인해 주의해야할 점을 다루도록 하겠습니다.


Reifiable Type

 

Java에서 Runtime시에 완전하게 오브젝트 정보를 표현할 수 있는 타입을 가르켜 Reifiable 하다고 합니다.

즉 Compile 단계에서 Type erasure에 의해서 지워지지 않는 타입 정보를 말합니다.

Reifiable 가능한 타입 정보는 다음과 같습니다.

 

  1. 원시 타입(int, double, float, byte 등등)
  2. Number, Integer와 같은 일반 클래스와 인터페이스 타입
  3. Unbounded Wildcard가 포함된 Parameterized Type(List<?>, ArrayList<?> 등)
  4. Raw Type(List, ArrayList, Map 등)

 

Unbounded Wildcard는 애초에 타입 정보를 전혀 명시하지 않았기 때문에, 컴파일시에 Type erasure 한다고 해도 잃을 정보가 없습니다. 따라서 Reifiable 하다고 볼 수 있으며, 컴파일시점에 Object로 치환됩니다. 

 

그외 나머지는 Generic을 사용하지 않았기 때문에 타입 정보가 그대로 남습니다.


반면 아래 타입의 경우에는 Reifiable 하지 않습니다.

 

  1. Generic Type(T)
  2. Parameterized Type(List<Number, ArrayList<String> 등)
  3. 경계가 포함된 Parameterized Type(List<? extends Number>, List<? super String> 등)

 

Reifiable 하지 않은 경우에 대해서 차근차근 살펴보겠습니다.

우선 Generic Type은 Type erasure에 의하여 삭제되는 것은 이전의 포스팅들을 통해서 이해되셨을 것으로 생각합니다.

 

Parameterized Type의 경우에는 Type erasure에 의하여 컴파일시에 Raw Type으로 변경됩니다. 

가령 List<String>, List<Integer>, List<List<String>>의 타입정보는 컴파일 시에 타입 안정성 검증 용도로 사용될 뿐 컴파일이 완료되면 Raw Type인 List로 치환됩니다. 따라서 Reifiable 하지 않습니다. 

 

경계가 포함된 Parameterized Type 또한, 컴파일 시 타입 안정성 검증 용도로만 사용되므로 동일합니다.

 

그렇다면 위와같은 특성으로 인하여 주의해야할 점이 무엇이 있을까요?


Instance Test

 

public class Main {
    public static void main(String[] args) {
        Integer  a = 3;
        System.out.println(isIntegerType(a));
    }
    public static boolean isIntegerType(Object b){
        return b instanceof Integer;
    }
}

 

전달받은 타입이 Integer인지 확인하는 메소드를 구현한다고 가정합시다. 가장 심플한 방법은 instanceof 문법을 사용해서 Integer 임을 확인할 수 있습니다.

 

그럼 해당 메소드를 Generic 메소드로 변경해도 문제 없을까요?

 

public class Main {
    public static void main(String[] args) {
        Integer  a = 3;
        System.out.println(isIntegerType(a));
    }
    public static <T> boolean isIntegerType(T b){
        return b instanceof Integer;
    }
}

 

위 코드를 수행하면 문제없이 동작합니다. 그 이유는 Integer 타입은 런타임시에도 Integer 타입이기 때문입니다.

 

 

public class Main {
    public static void main(String[] args) {
        List<Integer> ints = Arrays.asList(1,2,3);
        System.out.println(isIntegerList(ints));
    }
    public static <T> boolean isIntegerList(T b){
        return b instanceof List<Integer>; //Compile 에러 발생
    }
}

 

이번에는 List<Integer> 타입임을 확인하는 메소드를 생성한다고 가정해봅시다. 위와같이 만들었을 때 정상적으로 동작할까요?

 

우리는 런타임시에 List의 Type Argument가 Integer임을 확인하고 싶으나 컴파일 이후에는 Raw Type으로 변경됩니다. 따라서 List<Integer> 임을 확인할 수 없기 때문에 애초에 컴파일 에러가 발생합니다.

 

public class Main {
    public static void main(String[] args) {
        List<Integer> ints = Arrays.asList(1,2,3);
        System.out.println(isList(ints));
    }
    public static <T> boolean isList(T b){
        return b instanceof List;
    }
}

 

물론 Raw Type 자체는 Reifiable 하기 때문에, 위와 같이 Raw type으로 명시하면 컴파일 에러도 발생하지 않고 정상적으로 동작합니다. 하지만 여전히 해당 List의 Element 타입이 Integer임은 알 수 없습니다.

 

public class Main {
    public static void main(String[] args) {
        List<Integer> ints = Arrays.asList(1,2,3);
        System.out.println(isList(ints));
    }
    public static <T> boolean isList(T b){
        return b instanceof List<?>;
    }
}

 

가급적이면 Raw type을 사용하는 것은 지양하는 것이 좋으므로 Reifiable한 Wildcard를 사용하여 동일한 구문을 표현할 수 있습니다.

 

일반적으로 instanceof 문법을 사용한 코드는 code smell로 표현하여 잘 사용하지는 않지만, 만약 사용한다 할지라도 반드시 Reifiable한 타입에만 사용할 수 있다는점을 유의합시다.


캐스팅

 

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

 

위와 같이 Raw Type의 List에 Integer 타입 원소만 있다고 가정할 때, 이를 List<Integer> 타입으로 캐스팅할 수 있을까요?

 

실제로 어플리케이션으로 실행하면, 정상적으로 수행되는 것으로 보입니다.

 

 

하지만 자세히 보면, cast 관련 Warning이 발생된 것을 확인할 수 있습니다.

그 이유는 마찬가지로 컴파일이 완료된 이후에는 타입정보가 없어지는데, List<Integer> 타입으로의 안전한 캐스팅을 보장할 수 없기 때문입니다.

 

그렇다면 Instance Test에서는 에러를 발생시켰는데, 캐스팅에서는 Warning을 발생시킨 것일까요?

다음 예제코드를 보며, 그 이유를 알아보도록 하겠습니다.

 

public static List<Integer> getAsIntegerList(List<?> objs){
    objs.forEach(o->{
        if(!(o instanceof Integer))
            throw new ClassCastException();
    });
    return (List<Integer>)objs;
}

 

List 오브젝트를 전달받아, 해당 List안에 있는 원소가 모두 Integer 일경우에는 List<Integer>로 캐스팅하여 반환하는 메소드를 구현한다고 가정해봅시다.

 

위 코드에는 이미 List 원소를 탐색하면서 Integer가 아닌 원소가 하나라도 존재한다면 ClassCastException이 발생합니다.

 

즉 List<Integer>로 캐스팅하는 코드에 도달한다면 이미 objs 리스트에는 모든 원소가 Integer 임이 반드시 보장됩니다.

따라서 java에서는 개발자가 의도적으로 위 코드와 같은 캐스팅을 수행할 수 있을 수도 있기때문에 에러가 아닌 Warning을 발생시킵니다.

 

따라서, 만약 위와같이 의도적으로 Non-Reifiable 타입에 대해서 캐스팅을 수행했다면 @SuppressWarnings("unchecked") 어노테이션을 붙여서 컴파일 시점에 Warning이 발생하지 않도록 처리해야합니다.


Exception

 

public class MyException<T> extends Exception{}


try {
   ....
}catch (MyException<Integer> e){
   ....   
}

 

Throwable을 상속받는다면 Generic 클래스를 생성할 수 없습니다. 또한 catch 구문에서 Parameterized Type을 사용할 수 없습니다. 이는 이전의 설명과 마찬가지로 런타임 시점에 타입확인이 불가하기 때문입니다.

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

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

Integer 값이 들어있는 List 원소중 가장 큰 값을 리턴하는 함수를 만든다고 가정해봅시다.

 

public static int max(Collection<Integer> cols){
    return cols.stream().max((o1, o2) -> o1.compareTo(o2)).get();
}

 

대략 위와같이 구현할 수 있습니다. 그럼 Integer 타입이아닌 비교가 가능한 모든 클래스 타입이 사용될 수 있도록 하려면 어떻게 해야할까요?

 

그동안 학습한 내용을 적용하면서 이번 포스팅 주제를 다루어보겠습니다.

 

Step 1. Generic 사용

public static <T> T max(Collection<T> cols){
    return cols.stream().max((o1, o2) -> o1.compareTo(o2)).get(); //Compile 에러 발생
}

 

Get and Put 원리 고민없이 작성한다면 위와같이 작성할 수 있습니다. 여기서 첫번째 등장하는 <T>는 T 타입의 경계를 지정합니다. 이는 첫번째 포스팅에서 클래스 멤버 변수 타입의 경계 설정과 동일한 개념입니다.

 

하지만 이를 실행하면 두 원소의 대소 비교부분인 compareTo 메소드에서 컴파일 에러가 발생하게됩니다.

과연 무엇이 문제일까요?

 

첫번째 등장하는 <T> 구문은 T 타입의 경계를 지정한다고 했습니다.

위 코드는 Generic 타입이 존재한다는 것만 지정할 뿐, 해당 타입에 대한 경계를 지정하지 않았습니다.

이는 어떠한 타입이 지정되어도 상관없다는 의미입니다. 문제는 여기서 발생합니다.

 

compareTo 메소드를 사용하기 위해서는 해당 클래스에 Comparable 인터페이스가 구현되어있어야 합니다.

하지만 T 타입에는 어떠한 타입도 허용가능하므로 문제가 발생할 수 있습니다. 따라서 허용되지 않습니다.

 

이와 같은 문제를 해결하기 위해서는 Comparable 인터페이스가 구현된 타입만 지정되도록 경계를 정의해야합니다. 

 

Step2. 타입 경계 적용

 public static <T extends Comparable<T>> T max(Collection<T> cols){
    return cols.stream().max((o1, o2) -> o1.compareTo(o2)).get();
 }

 

메소드 레벨에서 타입 경계를 Comparable로 지정하면, Comparable Subtype 만 지정될 수 있습니다. 따라서 메소드 내부에서 compareTo 사용이 가능합니다.

 

Step3. Get and Put 원리 적용

public static <T extends Comparable<? super T>> T max(Collection<? extends T> cols){
    return cols.stream().max((o1, o2) -> o1.compareTo(o2)).get();
}

 

Get and Put 원리를 적용하면 위와같이 변경 가능합니다. 그 이유는 Collection 내부에서 바깥으로 T의 Subtype 값을 꺼내며, Comparable 인터페이스 compareTo 메소드안으로 값을 넣기 때문입니다.

 

Step4. Method Reference 적용

public static <T extends Comparable<? super T>> T max(Collection<? extends T> cols){
    return cols.stream().max(T::compareTo).get();
}

 

추가로 Generic 타입에 메소드 레퍼런스를 적용하면 위와같이 작성할 수 있습니다.


Bridge 메소드

 

public interface Comparable{
	public int compareTo(Object o);
}    

 

이번에는 Bridge 메소드에 대해서 알아보겠습니다. Java 1.4 버전 이하에서는 Generic 지원이 없었기 때문에 Comparable 인터페이스 구조는 위와 같습니다. 이때 파라미터는 모든 타입을 허용해야하므로 Object 타입인 것을 확인할 수 있습니다.

 

class Integer implements Comparable{
    private final int value;
    public Integer(int value) { this.value = value; }
    public int compareTo(Integer o){
        return (this.value < o.value) ? -1 : (value == o.value ? 0 : 1 );
    }
    @Override
    public int compareTo(Object o) {
        return compareTo((Integer)o);
    }
}

 

이를 구현해야하는 Integer, Double 등의 클래스에서는 올바른 비교 처리를 위해 Object를 자기 자신의 클래스 타입으로 치환하여 처리하도록 부가적인 메소드 구현이 필요했습니다.

 

public interface Comparable<T>{
	public int compareTo(T o);
}    
class Integer implements Comparable<Integer>{
    private final int value;
    public Integer(int value) { this.value = value; }
    @Override
    public int compareTo(Integer o){
        return (this.value < o.value) ? -1 : (value == o.value ? 0 : 1 );
    }
}

 

Generic 등장 이후 compareTo 메소드에 Object가 아닌 명확한 타입 지정이 가능해졌습니다.

따라서 이를 구현하는 클래스에서도 Object 타입이 아닌 자신이 지정한 타입으로의 메소드 구현이 가능해졌습니다.

그 결과 Bridge 메소드 없이 1개의 메소드만 구현하면 됩니다.

 

하지만 여기서 한가지 의문이 발생합니다.

 

'Java는 하위 호환성을 중요시 여기는 언어인데, 위와 예시와 같이 Generic 도입에 따라서 메소드 수가 줄어들게 되면, 이는 Integer 클래스 구조가 변경되므로 하위 호환성을 유지할 수 있을까?'

 

 

실제 구조가 변경되었는지 확인을 위해 Reflection을 활용해서 런타임시 compareTo 메소드 정보를 출력해보겠습니다.

 

public class Main {
    public static void main(String[] args) {
        Stream.of(Integer.class.getDeclaredMethods())
                .filter(m -> "compareTo".equals(m.getName()))
                .map(Method::toGenericString)
                .forEach(System.out::println);
    }
}
public int java.lang.Integer.compareTo(java.lang.Object)
public int java.lang.Integer.compareTo(java.lang.Integer)

 

출력 결과 2개의 메소드가 존재함을 확인할 수 있습니다.

이를 통해 알 수 있는 사실은 Generic 타입 파라미터가 포함된 메소드를 오버로딩 할경우 Bridge 메소드를 자동으로 추가하여 레거시 코드와 호환성을 유지한다는 점입니다.

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

7. Reification  (0) 2020.03.10
5. Wildcard  (0) 2020.03.08
4. Wildcard with super(하위 경계)  (0) 2020.03.06
3. Wildcard with extends(상위 경계)  (2) 2020.03.06
2. Subtyping, Substitution 원리  (0) 2020.03.05

Unbounded Wildcard

 

Collections 클래스에는 reverse API가 있습니다. 해당 API는 주어진 Collection내 원소들을 역순으로 뒤집어주는 기능을 수행합니다.

 

public class Collections {
    ....
    public static void reverse(List<?> list){}
    ....
}

 

위 파라미터 변수를 살펴보면 wildcard가 사용되었지만 그 어떠한 경계도 지정되지 않은 형태로 사용되었습니다.

이는 말 그대로 타입 변수가 어떠한 것이든 상관이 없음을 의미합니다.

 

자세히 보면 List<Object> 와 의미가 비슷하다고 생각할 수 있습니다. 

하지만 List<?>와 List<Object>는 다릅니다.

 

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

 

List<Object>에는 Object 의 Subtype이 할당될 수 있지만, List<?>에는 오직 null 만이 할당될 수 있습니다. null만 할당이 가능하다는 점에서 List<? extends Object>와 같습니다.

 

Unbounded Wildcard 의미는 전달되는 변수의 타입이 무엇인지 정확히는 모르나 타입은 존재하며, 관심대상은 파라미터 타입이 아닌 객체가 제공하는 기능입니다.

 

즉 위 예시의 경우에는 관심대상은 List 인터페이스가 제공하는 기능이지 그 안에서 제공되는 타입들이 제공되는 기능은 관심대상은 아닙니다.

 

 

Unbounded Wildcard 내용을 정리하면 다음과 같습니다.

  • Object 클래스에 의하여 제공되는 기본적인 기능을 사용할 경우
  • 타입 파라미터에 의존적이지 않은 Generic Class에서 제공되는 기능을 사용하는 경우
  • 매개 변수로 전달되는 실제 타입은 관심 대상이 아니다.

Wildcard Capture

 

Get and Put 원리를 적용하여 reverse API를 직접 구현해봅시다. 메소드 수행 동작상에서 List의 원소가 제공하는 기능은 관심대상이 아니기에 다음과 같이 Spec을 정의할 수 있습니다.

 

public static void reverse(List<?> list)

 

메소드를 구현하면 대략 다음과 같습니다.

 

public static void reverse(List<?> list){
    List<Object> tmp = new ArrayList<>(list);
    for(int i =0;i < tmp.size();i++){
        list.set(i, tmp.get(i));
    }
}
Error:(12, 32) java: incompatible types: java.lang.Object cannot be converted to capture#1 of ?

 

위 메소드에서 tmp 리스트의 타입 인자가 Object인 이유는 전달받는 타입 정보를 모르기 때문에 모든 타입을 수용할 수 있도록 Object로 지정하였습니다.

 

하지만 메소드를 수행하면 에러가 발생하는 것을 볼 수 있습니다.

무엇이 문제였을까요?

 

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

 

상위 경계 포스팅에서 다루었던 내용을 잠시 복습해보겠습니다.

 

Wildcard로 타입 인자가 지정되었을 때 타입에 대한 정보를 모르기때문에, 위 인터페이스에서 set 메소드를 호출할때마다 이를 지칭하기 위해 capture# of ?와 같은 형태로 컴파일러가 임의의 타입 변수를 할당합니다. 이를 Wildcard Capture라고 합니다.

 

문제는 알지 못하는 임의의 타입 T를 가르키는 capture# of ? 변수는 컴파일러가 내부적으로만 사용하고 타입 제약조건을 공식화하는데 사용하지 않는다는 점입니다. 

 

따라서 Object 타입의 값을 알 수 없는 List에 집어넣으려고 하기 때문에 에러가 발생합니다.

 

public static void reverse(List<T> list){
    List<T> tmp = new ArrayList<>(list);
    for(int i =0;i < tmp.size();i++){
        list.set(i, tmp.get(i));
    }
}

 

가장 심플한 방법은 reverse API를 위와 같이 타입 파라미터를 명시하는 방법입니다.

하지만, 관심대상이 아닌 T 타입을 컴파일러 제약때문에 API에 넣는다는 것은 그다지 좋은 디자인이 아닙니다.

따라서 API 디자인 규칙을 잘 준수하면서도 기능 동작을 구현하기위해 일종의 컴파일러 트릭을 사용합니다.

 

 

위 코드는 Helper 메소드를 사용하여 문제를 해결하였습니다. 즉 reverse 메소드를 호출할 때는 Wildcard Capture 정보를 타입 제약 조건으로 사용되지 않았습니다. 하지만 내부적으로 reverseHelper 메소드를 호출할때, 파라미터 타입에 T 타입을 명시하였기 때문에, capture# of ?와 같은 정보를 타입으로써 지정할 수 있습니다. 

 

이후 동일 타입내에서 값을 추출하고 넣음을 보장할 수 있기때문에 값 할당이 허용됩니다.


Wildcard 제한

 

public class Main {
    public static void main(String[] args)  {
        List<?> list = new ArrayList<?>(); //Compile 에러 발생
    }
}

 

 

Wildcard를 사용하면, 이를 기반으로 인스턴스 생성이 불가합니다.

만약, 타입 자동 추론을 이용하여 위 그림과 같이 list를 생성 할지라도, add 수행시 알 수 없는 타입이기 때문에 null외에 값 허용이 안됩니다.

 

이러한 제한을 둔 배경에는 Wildcard는 구체적인 타입 파라미터가 지정된 객체를 가르키는 것이기 때문에, 타입을 알 수 없는 Wildcard가 지정된 인스턴스 생성은 바람직하지 않기 때문입니다.

 

하지만 Wildcard가 쓰여있다고 해서 모든 객체가 생성 불가능한 것은 아닙니다.

Nested 형태로 Wildcard가 명시된 구조에서는 생성 가능합니다.

 

public class Main {
    public static void main(String[] args)  {
        List<List<?>> lists = new ArrayList<List<?>>();
        List<Integer> ints = Arrays.asList(1, 2, 3);
        List<Double> dbls = Arrays.asList(1.0, 2.0);
        lists.add(ints);
        lists.add(dbls);
    }
}

 

다만, 위에서도 볼 수 있듯이 Nested 형태 Wildcard가 포함된 인스턴스 또한 다른 List의 타입 파라미터를 가르키는 용도로 사용될 뿐 Wildcard 자체가 타입으로써 사용되지는 않습니다.

 

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

7. Reification  (0) 2020.03.10
6. Generic 메소드  (0) 2020.03.10
4. Wildcard with super(하위 경계)  (0) 2020.03.06
3. Wildcard with extends(상위 경계)  (2) 2020.03.06
2. Subtyping, Substitution 원리  (0) 2020.03.05
public class Collections {
    ....
    public static <T> void copy(List<? super T> dest, List<? extends T> src){}
    ....
}

 

Collections 클래스는 List와 관련된 여러 유용한 기능을 제공합니다. 그 중 copy API를 통해서 이번절 내용을 살펴보겠습니다. 

copy는 src 변수가 가르키는 List의 데이터 값을 dest 변수가 가르키는 List에 저장하는 기능을 제공합니다.

 

여기서 우리는 지금까지 보지 못한 구문을 또 발견하게됩니다.

 

  1. ? super T
  2. <T>

 

2번째 내용은 다음 포스팅에서 다루기로하며, 이번 절에서는 ? super T 구문에 대해서 살펴보겠습니다. 

 

? super T는 무엇을 의미할까요?

이는 T 타입 뿐만 아니라 T 타입의 모든 Supertype까지 허용하겠다는 의미입니다.

즉 T 타입이 Integer라면 List<Integer> 외에 List<Number> 및 List<Object> 형태 할당을 허용하겠다는 의미입니다.

이러한 특성을 Contravariant 라고 합니다.

 

또한, Parameter Type을 T 타입 이상으로 제한한다고해서 하위 경계(Lower bound)라고 말합니다.

 

예제를 통해서 개념을 살펴보겠습니다.

 

public class Main {
    public static void main(String[] args)  {
        List<Object> objs = Arrays.asList(2, 3.14, "Penguin");
        List<Integer> ints = Arrays.asList(1, 2);
        Collections.copy(objs, ints);
        System.out.println(objs);
    }
}

 

위 코드는 Object List에 Integer List 값을 추가하는 코드입니다.

여기에서 Collections.copy 메소드는 objs 객체, ints 객체에도 속하지 않습니다. 따라서 메소드 호출 시에 어떠한 타입으로 호출될지 컴파일러가 추론하거나 개발자가 명시적로 호출시 타입을 지정해야합니다.

 

이때 지정될 수 있는 타입의 종류는 Integer, Number(Integer의 Supertype 이므로), Object입니다.

 

  1. Collections.copy(objs, ints);
  2. Collections.<Integer>copy(objs, ints);
  3. Collections.<Number>copy(objs,ints);
  4. Collections.<Object>copy(objs,ints);

 

public static <T> void copy(List<T> dest, List<? extends T> src){}

 

이러한 상황에서 만약 copy 메소드의 dst 변수 타입이 하위 경계가 아닌 T 타입으로 설계되어 있었다면 어떠한 타입으로 추론될까요?

 

public static <Object> void copy(List<Object> dest, List<? extends Object> src){}

 

위 코드를 만족시키기 위해서는 추론되는 타입이 Object 타입만 허용 가능합니다. 그 이유는 dest에 할당되는 Parameterized Type이 List<Object>이기 때문입니다.

 

따라서 위의 4가지 경우 중 1번과 4번 메소드 호출만 성공하고, 나머지 2개의 타입은 컴파일 오류가 발생합니다.

 

public static <T> void copy(List<? super T> dest, List<? extends T> src){}

 

이번에는 원래의 copy 메소드처럼 상위 경계와 하위 경계를 같이 쓰게되는 경우에는 어떻게 될까요? 결과는 위 4가지 어떠한 타입으로 추론되더라도 문제가 없습니다.

 

타입을 직접 명시하는 경우를 기준으로 하나하나씩 살펴볼까요?

 

 

1.  Collections.<Integer>copy()

public static <Integer> void copy(List<? super Integer> dest, List<? extends Integer> src){}

 

Object는 Integer의 SuperType이고, Integer는 자기 자신에 대한 Subtype이므로 위와 같이 추론될 경우 할당 가능합니다.


2. Collections.<Number>copy()

public static <Number> void copy(List<? super Number> dest, List<? extends Number> src){}

 Object는 Number Class의 SuperType이고, Integer <: Number 이므로 할당 가능합니다.


3. Collections.<Object>copy()

public static <Object> void copy(List<? super Object> dest, List<? extends Object> src){}

Object는 모든 Class의 SuperType이므로 할당 가능합니다.

 

 

위 사실들을 기반으로 사용될 수 있는 타입 파라미터 종류를 조합해보면 다음과 같은 결과를 얻을 수 있습니다.

 

1. copy(List<T> dst, List<T> src) 
   - 동일 타입만이 가능하므로, 위 예제에서는 해당 메소드를 사용할 수 없습니다.


2. copy(List<T> dst, List<? extends T> src) 
  - dst가 Object 타입이므로 Object 타입 추론될 경우에만 허용 가능합니다. 

3. copy(List<? super T> dst, List<T> src) 
  - src가 Integer 이므로 Integer 타입 추론될 경우에만 허용 가능합니다. 

4. copy(List<? suepr T> dst, List<? extends T> src) 
  - Object, Number, Integer 타입 어떤 것이 오든지 허용 가능합니다.

 

 

이를 통해 알 수 있는 사실은 적절한 경계를 사용함으로서 메소드의 타입을 추론하는데 있어서 한정된 타입만 사용되지 않도록하여 유연한 API 설계를 가능하도록 지원합니다.

 

그렇다면 하위 경계는 언제 주로 활용될까요?

전달받은 구조 내부로 T나 T의 Subtype으로 값을 입력하는 경우 주로 사용됩니다.

 

public class Main {
    public static void main(String[] args)  {
        List<Integer> ints = new ArrayList<>();
        ints.add(1);
        ints.add(3);
        List<? super Integer> ints2 = ints;
        ints.add(4);
        
        for(Integer i : ints2){   //Compile 에러 발생
           System.out.println(i);
        }
    }
}

 

위 예제와 같이 ints2 객체에서는 Integer Subtype 값 입력이 가능합니다.

반면, 해당 구조체로부터 값을 가져오려는 경우에는 컴파일 에러가 발생합니다.

그 이유는, 컴파일러 입장에서 해당 객체는 Integer의 상위 타입은 모두 올 수 있는데, 그것을 Integer 타입 변수로 할당 받는 것은 적합하지 않기 때문입니다.

 

하지만 하위 경계에도 예외가 존재합니다. 즉 하위 경계는 주로 값 입력을 위해 사용되나, 특정 타입에 경우에는 값을 가져올 수 있습니다. 그것은 바로 Object 타입입니다.

 

public class Main {
    public static void main(String[] args)  {
        List<Integer> ints = new ArrayList<>();
        ints.add(1);
        ints.add(3);
        List<? super Integer> ints2 = ints;
        ints.add(4);
        
        for(Object i : ints2){  
           System.out.println(i);
        }
    }
}

 

그 이유는 Object는 모든 클래스의 부모 클래스이기 때문입니다.

아래 예시를 통해서 자세히 알아보겠습니다.

 

List<? super Integer> 변수 사용시, 어떠한 타입이 올지 모르므로 해당 타입을 임의의 타입 변수 X라고 가정해봅시다.

이때 Integer <: X이며, X <: Object 입니다.

 

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

 

따라서 X의 Supertype인 Object의 반환은 허용됩니다.


Get and Put Principle

 

지금까지 상위, 하위 경계를 학습하였습니다.

이전의 예시들에서 확인하였듯이 Wildcard는 사용할 수 있을 때 가능하면 사용하는 것이 좋은 습관입니다.

그 이유는 WIldcard 사용을 통해서 해당 API 호출하는 입장에서 전달하는 파라미터 내용이 내부적으로 어떻게 사용될지 유추가 가능할 뿐더러 유연한 타입 제공이 가능하기 때문입니다.

 

그렇다면 언제, 어떤 Wildcard를 사용하는 것이 좋을까요?

이를 결정하기 위한 간단한 원칙이 있습니다.(Get and Put Principle)

 

  • 하위 경계(? extends T) : 구조 바깥으로 T나 T의 Supertype으로 값을 읽어야 하는 경우 사용하자
  • 상위 경계(? super T) : 구조 내부로 T나 T의 Subtype으로 값을 입력해야하는 경우 사용하자

 

이전에 살펴본 copy 메소드를 통해 해당 원칙이 어떻게 적용되었는지 확인해봅시다.

 

public static <T> void copy(List<? super T> dst, List<? extends T> src){}

 

Get and Put 원칙을 인지한 상태에서 위와 같은 API를 보면 다음과 같은 생각을 할 수 있습니다.

 

'src 변수에 전달하는 List 객체에서는 내부에서 값을 추출하기 위한 용도로 사용되며, dst 변수에 전달하는 객체에서는 값을 입력하는 용도로 사용되겠구나'

 

이번에는 Number 타입을 지닌 Collection의 합을 구하는 메소드를 직접 디자인한다면 어떻게 타입을 설계하는것이 좋을지 살펴보도록 하겠습니다.

 

public static double sum(Collection<Number> cols){
    double s = 0.0;
    for(Number col : cols)
    	s = s + col.doubleValue();
    return s;
}    

 

먼저 위와 같이 Number 타입 지정을 고려해볼 수 있습니다. 하지만 이는 이전에 줄곧 얘기한 List<Number> 만 허용 가능한 메소드이기에 사용의 제약이 있습니다. 또한 Get and Put 원리에 따르면 해당 Collection에 별도 값 입력을 하지 않기 때문에 상위 경계(? extends Number)를 사용하는 것이 적합해보입니다.

 

이번에는 n을 입력하면 0부터 n-1까지 값을 채워주는 함수를 만들어 보겠습니다.

 

public static void assign(Collection<? super Integer> ints, int n){
    for(int i = 0 ; i < n; i++) ints.add(i);
}    

 

위 경우에는 해당 Collection 안에 값을 채워넣는 작업이 수행되므로 하위 경계를 입력하는 것이 적합해보입니다.

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

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

만약 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

Subtyping

 

이번 포스팅을 시작하기 앞서 Subtyping개념을 먼저 알아보겠습니다.

 

 

위와 같이 A 클래스가 가진 Spec이 요구되는 상황에서 B의 클래스가 A 역할을 대체하여 사용할 수 있을 때 B를 A의 Subtype, A는 B의 Supertype이라고 불립니다. 또한 모든 참조타입은 자기자신에 대해서 Subtype입니다.

(※ Subtype을 기호로 나타내면 <: 로 표현되며 A <: B 일 경우 A는 B의 Subtype 입니다.)

 

 

광의적인 의미에서 A 클래스의 Spec을 B 클래스에도 코드상에서 준수하고 있으면 이를 Subtype 관계로 보지만, Java에서는 상속 관계에 놓여있을 때만 교체가 가능하기에 이에 집중해서 살펴보겠습니다.

 

예를 통해서 알아보겠습니다.

 

 

ArrayList는 List의 인터페이스를 구현하였습니다. 따라서 List가 사용될 수 있는 영역에는 ArrayList로 대체가 가능합니다.

따라서, 이때 ArrayList는 List의 Subtype, List는 Supertype입니다.

 

 

Java에서 기본적으로 제공되는 라이브러리 예를 통해 해당 개념을 적용해보겠습니다.

 

  • Integer <: Number
  • Double <: Number
  • ArrayList<E> <:  List<E>
  • Collection<E> <:  Iterable<E>

 

 

Subtyping은 이행적인 특징을 지니고 있습니다.

즉 A <: B이고, B <: C 이면 A <: C입니다.

따라서, 위 예에서 List<E> <:  Iterable<E> 라고 볼 수 있습니다.


Substitution Principle(치환 원리)

 

Wiki에는 해당 원리에 대하여 다음과 같이 정의하고 있습니다.

if S is a subtype of T, then objects of type T may be replcaed with objects of type S

요약하자면, 주어진 타입 변수는 해당 타입 어떠한 Subtype으로도 대체가 가능합니다.

 

Collection<E> 인터페이스에 정의된 add 메소드 사용예를 통해서 해당 원리을 적용해보겠습니다.

interface Collection<E>{
  ...
  public boolean add(E elt);
  ...
}

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

 

nums 객체의 Type Argument는 Number 입니다.

따라서 add 메소드 파라미터 Type은 Number로 추론되므로 메소드 호출시, IntegerDouble 값을 할당할 수 있습니다.

 

이것이 가능한 이유는 Integer <: Number, Double <: Number 이기 때문입니다.

그렇기에 위 코드를 컴파일하면 아무런 문제 없이 완료됩니다.

 

그렇다면 해당 개념을 확장하여 다음과 같은 생각 또한 해볼 수 있습니다.

 

Integer <: Number이니까 List<Integer> <:  List<Number> 라고 볼 수 있지 않을까?

 

위 문장만을 봐서는 가능할 것 같습니다.

예시 코드를 보면서 가능 여부를 확인해보겠습니다.

public class Main{
   public void static main(int argc, String[] argv){
       List<Integer> ints = Arrays.asList(1,2);
       List<Number> nums = ints; //Compile 에러 발생
   }
}

위 코드를 실행하면 컴파일 에러가 발생합니다. 과연 무엇이 문제였을까요?

 

 

위 코드가 정상적으로 컴파일된다면, 두 객체는 Heap 같은 메모리 영역을 바라보게 될 것입니다. 

지금까지는 아무런 이상이 없는 것 처럼 보입니다.

 

public boolean add(Number elt){}

 

하지만 List<Number> 타입이 해당 메모리 영역을 가르킬 수 있으면 add 함수의 타입 파라미터는 Number로 추론됩니다. 따라서 Substitution Principle을 적용한다면 Number의 Subtype인 Double 값을 논리적으로 할당할 수 있습니다.

 

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 에러 발생
       nums.add(3.14);
   }
}

 

실제로 Intellij 에서 위 코드를 입력하면 Compile 에러 발생라인은 컴파일전에 에러가 발생하고 있다고 highlight 표시해주지만 nums 객체의 add 함수호출부는 아무런 표시를 하지 않는 것을 확인할 수 있습니다.

 

 

 

위 구조에서의 문제는 해당 객체가 저장된 공간은 Parameterized Type이 List<Integer>인 ints 객체 또한 가르키는 영역이라는 점입니다.

만약 nums 객체에 ints를 할당할 수 있다면 자신이 가르키고 있는 영역에 int가 아닌 다른 타입이 존재할 수 있기 때문에 타입 안정성이 깨지게됩니다.

 

이러한 문제를 해결하기 위해서 Java에서는 해당 경우에 Substitution Principle을 적용하지 않습니다. 즉 List<Integer>는 List<Number>의 Subtype이 아닙니다. 

 

이번에는 반대로 한번 생각해보겠습니다. 

 

List<Number> <: List<Integer>일까?

 

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

 

Number 타입은 Integer의 Supertype이지 Subtype이 아닙니다. 따라서 위 경우에 nums 객체 값을 ints에 할당 가능하다면 List<Integer> 타입인 ints 입장에서는 Number 타입의 다른 Subtype이 존재하는 영역을 가르키게 됩니다.

 

문제는 ints 객체가 가르키는 메모리 영역으로부터 데이터를 읽고자 한다면 Integer 타입이 아닌 Double 타입을 참조할 수 있습니다. 따라서 이러한 경우에는 타입 안정성이 깨지게 됩니다. 그러한 이유로 이 또한 허용되지 않습니다.

 

지금까지 살펴본 내용을 정리해보겠습니다.

 

  • List<Integer>는 List<Number>의 Subtype이 아니다.
  • List<Number>는 List<Integer>의 Subtype이 아니다.
  • List<Integer>는 자기 자신에 대하여 Subtype이다.
  • List<E>는 Collection<E>를 상속받으므로 List<Integer>는 Collection<Integer>의 Subtype이다.

 

위 정리를 살펴보면, 주어진 타입과 동일한 타입을 지닌 객체에 대해서만 변수 할당이 가능한 것을 확인할 수 있습니다. 일반화를 하면 Generic을 사용하면 타입의 상속 관계와 상관 없이 동일 타입만 가능한 Invariant 특성을 지니고 있습니다.

 

하지만 이렇게만 사용한다면, Generic을 사용함에 있어 많은 제약이 따를 수 밖에 없습니다.

이전에 살펴본 코드를 잠시 다시 살펴보겠습니다.

 

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 에러 발생
   }
}

 

위 코드는  nums 객체 할당이후 Number 타입의 다른 Subtype 할당이 가능했기 때문에 문제가 발생했습니다.

 

하지만 해당 nums 객체가 지정하는 타입에 대하여 Subtype 값 할당은 하지 않고 단순히 참조의 목적이라면, List<Number>에 List<Integer> 객체를 할당하는 것은 논리적으로 문제 없어 보입니다.

 

위와 같은 상황에서 사용할 수 있는 방법이 없을까요?

다음 포스팅에서 다룰 Wildcard를 통해서 해당 이슈를 다루어보도록 하겠습니다.

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

6. Generic 메소드  (0) 2020.03.10
5. Wildcard  (0) 2020.03.08
4. Wildcard with super(하위 경계)  (0) 2020.03.06
3. Wildcard with extends(상위 경계)  (2) 2020.03.06
1. Generic 기초  (3) 2020.03.04

Java는 대표적인 정적 타입 언어입니다. 즉 컴파일 시점에 사용하는 변수의 타입이 결정되며, 따라서 명시적으로 변수의 타입을 지정하거나 추론이 가능해야합니다.

 

만약 클래스내 일부 변수에 대하여 int형 또는 String형으로 상황에 따라 각기 다른 데이터를 담고 싶다면 어떻게 해야할까요?

 

class IntegerData{
    int data;
    double var2;
    public IntegerData(int data) {this.data = data;}
    public int getData() {return data;}
    public void method1(){}
    public void method2(){}
}

class StringData {
    String data;
    double var2;
    public StringData(String data) {this.data = data;}
    public String getData() {return data;}
    public void method1(){}
    public void method2(){}
}

public class Main {
    public static void main(String[] args) {
        IntegerData a = new IntegerData(1);
        StringData b = new StringData("1");
        
        int c = a.getData();
        String d = b.getData();
    }
}

 

먼저 타입별 클래스를 만드는 방법을 먼저 떠올릴 수 있습니다.

하지만 일부 데이터 타입만 다름에도 불구하고 매번 클래스를 만드는 것은 매우 비효율적입니다.

 

그렇다면 어떻게 문제를 해결할 수 있을까요?

 

Idea) 모든 클래스는 Object를 상속받으니, Object 타입으로 변수를 지정하자

 

class Data{
    Object data;
    public Data(Object data) {this.data = data;}
    public Object getData() {return data;}
}

public class Main {
    public static void main(String[] args) {
        Data a = new Data(1);
        Data b = new Data("1");
        
        int c = (int) a.getData();
        String d = (String) b.getData();
    }
}

 

Object 타입으로 변수를 생성하면, Object 객체에 모든 변수 값을 저장할 수 있습니다. 따라서, 타입별로 클래스를 만들지 않아도되니 효율적입니다. 하지만 데이터를 가져올 때에는 원래 타입으로 캐스팅이 필요합니다.

 

음.. 캐스팅을 개발자가 명시적으로 지정해야한다는 약간의 불편함은 있지만 그런대로 괜찮아 보입니다.

하지만 정말 이대로 괜찮다고 말할 수 있을까요?

 

문제점

 

public class Main {
    public static void main(String[] args) {
        Data a = new Data(1);
        String b = (String) a.getData();
        System.out.println(b);
    }
}

 

개발자의 실수로 Data 객체에 들어있는 값의 타입이 String으로 착각하여 String 변수에 이를 담았다고 가정해봅시다.

실제로는 담겨져있는 int 타입의 변수에 대해서 String 타입으로 캐스팅을 수행하니 오류가 나야 정상입니다.

하지만 위 소스를 컴파일을 해보면 정상적으로 컴파일됩니다. 그 이유는 지정된 타입은 Object 이고, Object 타입은 String 타입의 부모 타입이기 때문입니다.

 

실행 결과 : Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

 

따라서, 에러가 발생하는 시점은 Application을 구동해서 데이터를 가져오는 코드가 실행되는 시점입니다. 즉 해당 방법은 TypeSafety 하지 않은 방법으로 Runtime 시점에 오류를 알 수 있는점은 잠재적인 오류를 내포하고 있으므로 좋지 못합니다.

 

public class Main {
    public static void main(String[] args) {
        List list = new ArrayList();
        list.add(1);
        String a = (String) list.get(0);
    }
}

java 1.4 doc ArrayList get 메소드

 

Generic이 지원되지 않는 Java 1.4 버전 이하에서는 우리가 흔히 사용하는 ArrayList 에서 여러 데이터 형을 담기 위하여 Object 타입으로 저장하였습니다. 또한 반환시에도 Object 타입으로 반환하였습니다. 따라서 Runtime 오류를 피하기 위해서 개발자가 조심해서 캐스팅을 수행해야했습니다.


Generic 등장

 

class Data<T>{
    T data;
    public Data(T data) { this.data = data; }
    public T getData() {return data; }
}

public class Main {
    public static void main(String[] args) {
        Data<Integer> a = new Data<>(1);
        Data<String> b = new Data<>("1");

        int c = a.getData();
        String d = b.getData();
    }
}

 

이전에 살펴보았듯이 Object 타입으로 저장하는 것은 개발자가 명시적으로 타입을 예측하여 캐스팅을 수행해야했기에 잠재적 코드 결함율이 발생할 수 있었습니다. 따라서 이를 해결하고자 Java 진영에서는 1.5 버전부터 Generic을 지원하였습니다. Generic은 특정 변수에 대하여 객체 생성시 원하는 타입으로 지정할 수 있도록 도와줍니다.

 

위 코드와 같이 Data 클래스에 대하여 객체 생성시 <> 안에 원하는 Reference 타입을 명시해주면, 컴파일러가 해당 객체의 data 타입은 명시적으로 지정된 타입이라는 것을 보장해줍니다. 

 

기존에는 반환 값이 Object였기 때문에 명시적으로 캐스팅을 수행해야했지만, Generic을 사용하면 컴파일러가 타입을 보장해주기 때문에 명시적 캐스팅이 불필요합니다.

 

 

마치 위 그림과 같이 타입을 지정하게되면 각각의 클래스가 별도로 존재하는 것처럼 논리적으로 생각할 수 있습니다.

 

public class Main {
    public static void main(String[] args) {
        Data<Integer> a = new Data<>(1);
        String d = (String)a.getData();
    }
}

 

따라서 위와 같이 개발자의 실수로 Integer 데이터를 String 변수에 담으려고 해도 컴파일 단계에서 잘못된 타입임을 알고 에러를 표시하기 때문에 TypeSafety합니다.

 

도입부에 이해를 돕기 위해 논리적으로 Class 여러개가 생성된 그림을 보여드렸습니다. 그렇다면 실제 내부 구현도 위와 같이 되어있을까요? 지금부터 Generic 내부에 대해서 살펴보면서 원리를 알아보겠습니다.


용어

 

학습하기 전 관련 용어 일부를 알아보겠습니다.

 

 

Type Parameter : Generic 타입을 명시하기 위한 Placeholder 입니다. 즉 위 코드에서 Data 클래스의 T가 이에 해당됩니다.

 

Type Argument : 실제 Generic 타입에 명시된 타입을 의미합니다. 위 코드에서는 Integer가 해당됩니다.

 

Parameterized Type : Type argument에 의하여 Type Parameter가 치환된 전체 데이터 타입을 의미합니다. 위 코드에서는 Data<Integer>가 이에 해당됩니다.

 

위 세가지 용어가 혼동되지 않도록 주의하며, 본격적으로 Generic에 대해서 알아보겠습니다.


Type Erasure

 

class Data<T>{
    T data;
    public Data(T data) { this.data = data; }
    public T getData() {return data; }
}

public class Main {
    public static void main(String[] args) {
        Data<Integer> a = new Data<>(1);
        int c = a.getData();
    }
}

 

동작 원리를 이해하기 위해 컴파일된 바이트 코드를 보는 것이 가장 좋지만, 이해를 돕기위해 위와 같이 Generic 문법을 사용한 자바 코드를 컴파일 한다음 해당 class 파일을 다시 디컴파일 결과를 보겠습니다.

 

 

먼저 Data 클래스를 보겠습니다. 기존에 Type Parameter T로 선언되었던 부분이 전부 Object 클래스로 타입이 변경되었습니다.

 

main 호출부에서도 Parameterized Type이 Data<T>였던 클래스 정보가 Data와 같이 변경되었고, 값을 가져오는 부분에서 캐스팅이 발생되었음을 확인할 수 있습니다.

 

자세히 보면 이는 Generic이 사용되기 이전에 작성하던 방식과 동일한 결과입니다.

 

이를 통해 알 수 있는 사실은 Generic을 사용하더라도 사용자가 지정한 Type은 사라지고 기존과 같이 Object 타입으로 지정되며, 대신 명시적으로 값을 가져오는 코드에 컴파일러가 캐스팅하는 코드를 삽입하여 Type 안정성을 보장해준다는 점입니다. 

 

 

또한, 컴파일러가 개발자가 지정한 Type을 알고 있기 때문에, 중간에 개발자가 실수로 잘못된 캐스팅을 시도하는 코드가 있다면 잘못 캐스팅 되었음을 알 수 있습니다.

 

public class Main {
    public static void main(String[] args) throws NoSuchFieldException {
        Data<Integer> a = new Data<>(1);
        System.out.println(a.getClass().getDeclaredField("data"));
    }
}
실행결과 : java.lang.Object Data.data

 

한 단계 더 나아가자면, 컴파일 시점에는 내가 지정한 Type 정보를 알 수 있지만 컴파일 이후에 생성된 바이트코드에서는 Object로 Type이 지정되므로 런타임 시점에는 Type 확인이 불가하다는 점입니다.

 

실제 위 코드와 같이 Reflection을 활용하여 런타임시 Data 인스턴스의 data 타입을 확인해보면 Object 타입임을 알 수 있습니다.

 

런타임 시점에 Type 정보를 알 수 없는 것은 꽤나 불편한 제약으로 느껴집니다. 그렇다면 Java는 왜 이러한 제약사항이 존재함에도 불구하고 Type Erasure를 통해서 명시된 Type을 삭제했을까요?

 

class Stub{
    List list;
}

 

예를들어 기존에 jdk 1.5 미만을 사용하던 라이브러리에서 Java Collection을 사용하려면 위와 같이 사용되었을 것입니다.

 

만약 Generic을 사용한 Collection 클래스에 대해 컴파일 하는 시점에 Type이 사용자가 지정한 Type으로 변경된다면, 그에 따른 바이트 코드 또한 변경될 것입니다.

 

따라서 낮은 버전(1.4 이하) Collection을 사용하는 라이브러리에서 상위 버전으로 업그레이드를 위해서는 변경에 따른 코드 수정이 필요합니다. 즉 New Feature 도입으로 인하여 하위 호환성 문제가 발생할 수 있습니다.

 

Java는 아주 대표적으로 하위 호환성을 중요시 여기는 언어입니다. 따라서 하위 버전의 호환성 유지를 위하여 Type Erasure를 통해 기존 코드와 상위 버전 코드간의 호환성이 잘 유지되도록 지원하였습니다.

 

그렇다면 Generic 프로그래밍을 지원하는 모든 언어가 Type erasure를 사용할까요?

 

정답은 그렇지 않습니다. 대표적으로 C++가 이에 해당합니다.

C++은 컴파일시에 실제 지정한 데이터타입에 대하여 코드를 생성합니다. 따라서 때로는 머신에 최적화된 코드를 생성하기 용이하기도 합니다. 하지만 타입만큼 코드가 생성되므로 Java에 비해 상대적으로 코드 크기가 커질 수 있는 단점 또한 존재합니다.


타입 경계

 

class Data<T>{
    T data;
    public Data(T data) { this.data = data; }
    public T getData() {return data; }
}

 

이전 내용을 통해 Type erasure에 의하여 T 타입이 Object 타입으로 변경됨을 확인하였습니다.

그렇다면 Generic으로 정의한 모든 타입이 컴파일시 Object로 타입이 변환될까요?

 

위 예제를 기반으로 T 타입이 Object로 변경된 이유를 다시 살펴보겠습니다.

 

위 코드에서 T 타입이 의미하는바는 우리가 지정한 어느 타입이여도 상관이 없음을 나타냅니다.

이는 Java를 통해 구현하는 모든 클래스 타입을 지정할 수 있음을 의미합니다. 즉 타입의 경계가 없습니다.

 

출처 : https://docs.oracle.com/javase/tutorial/java/IandI/subclasses.html

 

따라서, 런타임시 내가 지정한 어떠한 타입이라도 허용이 가능한 가장 최상위 클래스인 Object로 타입이 변경되는 것입니다.

만약 다음과 같은 요구사항이 있다고 가정해봅시다.

 

'Data 클래스에서 data 변수는 Integer, Double과 같은 Number 타입만 허용 가능하다.'

 

이 경우에는 어떻게 해야할까요?

 

방법1. 개발자끼리 명시적으로 합의하여 Number 타입이하로만 사용하게끔 조심한다.

방법2. T 타입으로 지정할 수 있는 범위를 설정하여, 잘못 지정시 컴파일 에러를 발생시킨다.

 

너무나 당연하게도, 방법 2로하는게 가장 안전할 것입니다.

Java에서는 이를 위해서 타입 경계 기능을 제공합니다.

 

class Data<T extends Number>{
    T data;
    public T getData() {
        return data;
    }
    public void setData(T data) {
        this.data = data;
    }
}

 

 

위 코드와 같이 T에 대한 경계(Number 타입 이하로만 설정)를 지정하면, 이를 사용하는 Data 클래스 사용시 타입을 Number 이하로만 설정할 수 있습니다.

 

public class Main {
    public static void main(String[] args)  {
        Data<Object> obj = new Data<>();
    }
}

Error:(14, 14) java: type argument java.lang.Object is not within bounds of type-variable T

Error:(14, 36) java: incompatible types: cannot infer type arguments for Data<>
    reason: inference variable T has incompatible bounds
      equality constraints: java.lang.Object
      lower bounds: java.lang.Number

 

만약 main 함수에서 Data 클래스 정의를 Number의 하위 타입이 아닌 다른 클래스로 지정하게되면 위와 같은 에러를 발생하게 됩니다.

 

그렇다면 위와 같이 경계를 지정한 다음 컴파일하게되면 타입은 무엇으로 지정되어있을까요?

이전과 마찬가지로 컴파일한 내용을 다시 디컴파일해서 확인해보겠습니다.

 

class Data
{

    Data()
    {
    }

    public Number getData()
    {
        return data;
    }

    public void setData(Number number)
    {
        data = number;
    }

    Number data;
}

 

아마 눈치를 채셨겠지만, 디컴파일을 하게되면 Data 클래스의 T 타입이 Object가 아닌 Number 타입으로 변경된 것을 확인할 수 있습니다.

 

그 이유는 타입 경계로 인하여 Number 이하의 타입만 허용가능하기 때문입니다.

 

정리하면 다음과 같습니다.

  • T 타입에 대한 경계를 지정하지 않으면 가장 최상위인 Object로 컴파일시 변경된다.
  • T 타입에 대한 경계를 지정하면, 해당 타입으로 컴파일시 변경된다.

 

그렇다면 타입의 경계를 여러개 지정이 필요한 경우에는 어떻게 할까요?

 

class Data<T extends Number & Comparable<T>>{
    private T data;

    public T getData() {
        return data;
    }
    public void setData(T data) {
        this.data = data;
    }
}

 

위와 같이 Number 타입이면서 Comparable 인터페이스를 구현한 타입만 지정하게끔 설정할 수 있습니다.

그리고 이때 컴파일 시에는 지정된 경계중에 가장 왼쪽에 위치한 Number 클래스로 타입이 지정됩니다.

 

이때 주의할 점은 경계를 구체적인 클래스 타입이 가장 첫 번째로 지정되어야합니다.


마치며

 

이번 포스팅에서는 Generic 기초와 Type erasure에 대해서 살펴보았습니다. 다음 포스팅에서는 Subtyping에 대해서 살펴보겠습니다.

 

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

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

+ Recent posts