자바(SE)

[Generic]raw 타입을 사용하지 말자.

  • -

이번 포스트에서는 List와 List<Object>그리고 List<?>에 대해서 알아보고 왜 raw type을 사용하면 안되는지  생각해보자. (이 글은 Effective Java 2판 규칙 23을 참조했습니다.)

다 같은거 아닐까?

 

이제는 쓰지 말아야할 row 타입의 List

그냥 List로 사용하는 경우 즉 무인자 자료형으로 사용하는 경우는 이제 지양해야 한다. 

다음과 같이 String을 담기로 결심하고 작성한 rawListForString이 있다고 생각해보자. 

List rawListForString = new ArrayList(); // 문자열을 관리하자!!
rawListForString.add("Hello");

초기의 생각은 명확했지만 이 레퍼런스가 돌고 돌아 모진 풍파를 만나다보면 정체성은 모호해진다. 만약 아래와 같은 메서드가 있다고 생각해보자.

// list에 무언가를 추가할 수 있는 기능!
private void addSome(List list) { // 이 리스트에는 무엇을 저장할 수 있을까?
    list.add(123);
}

이제 위에서 만들었던 rawListForString을 addSome에 넣어버리면 슬픈 일이 발생할 수 있다.

List rawListForString = new ArrayList();
rawListForString.add("Hello");
// 레퍼런스가 돌고 돌아 어디선가는..
addSome(rawListForString);
        
for(int i=0; i<rawListForString.size(); i++) {
    String str = (String)rawListForString.get(i); // 분명히 문자열이겠지??
    System.out.println(str.length());
}

addSome 메서드를 통해서 Integer가 추가되었기 때문에 ClassCastException이 발생하는것은 당연한 일이다. 즉 우리의 결심이나 주석, 메서드 이름은 전혀 중요치 않다 실수는 어디서든 발생할 수 있기 때문이다.

따라서 타입 파라미터를 사용할 수 있는 곳에서는 당연히 타입 파라미터를 써야한다. Raw Type은 그냥 과거의 코드와의 호환성을 위해서 남겨진 잔재라고 생각해야 한다.

 

뭐든지 담고 싶다면 List<Object>

만약 뭐든지 담고 싶은 List를 만들고 싶다면 List<Object>를 써주면 된다. List<Object>는 컴파일러에게 어떤 객체 타입이라도 다 받을 수 있다고 알려준다.

List<Object> genericListForObject = new ArrayList<Object>();
genericListForObject.add("123");
genericListForObject.add(123);
int sum = 0;
for(int i=0; i<genericListForObject.size(); i++) {
    Object obj = genericListForObject.get(i);
    // 당연히 Object로만 담겨있으므로 타입을 확인해야 한다.
    if(obj instanceof String) {
        sum+=Integer.parseInt((String)obj);
    }else if(obj instanceof Integer) {
        sum+=(Integer)obj;
    }
}
System.out.println(sum);

다시한번 확인하면 List는 type에 대한 검사를 완전히 생략한 것이고 List<Object>는 어떤 객체 타입이라도 담을 수 있게 하용한 것이다. 

이에 따라서 어떤 일이 발생하냐면 List에는 List<Integer>을 할당할 수 있지만 List<Object>에는 List<Integer>을 할당할 수 없다.

public void subTest1() {
    List<Integer> ilist = new ArrayList<>();
    addUnSafe(ilist);
    int sum = 0;
    for(Integer num: ilist) { // runtime error
        sum+=num;
    }

    //addSafe(ilist);        // compile error
}
   
private void addUnSafe(List list) {      // 이 리스트에는 무엇을 저장할 수 있을까?
    list.add("Hello");
}
   
private void addSafe(List<Object> list) { // 이 리스트에는 무엇을 저장할 수 있을까?
    list.add("Hello");
}

위의 코드에서 List<Integer>를 addUnSafe에 넘겨주면 잘 받아주는데 그냥 List로 받기 때문에 String을 넣어도 컴파일 타임에는 전혀 문제가 되지 않는다. 하지만 다시 원래의 코드로 돌아가서 Integer로 사용하면 런타임에 오류가 발생한다.(타입 체크를 하지 않기 때문이다.) 즉 프로그램이 개발되고 한참 후에야 디버깅이 가능하다.

반면 List<Integer>를 addSafe에 넘겨주면 compile error가 발생해서 바로 디버깅이 가능하다. generic에 대한 하위 타입 정의 규칙에 따르면 List<Integer>는 List<Object>의 하위 타입이 아니기 때문이다.  이처럼  List와 같은 무인자 자료형을 사용하면 형 안전성을 잃게 되지만 List<Object>를 사용하면 형 안전성을 유지한다.

 

List<?>는 언제 써먹어야할까?

만약 무언지 모르는 어떤 요소를 가지고 있는 List l1과 l2에 혹시나 같은 요소가 있는지 알고 싶다면 어떻게 해야할까?

이 일을 처리하기 위해서는 List에는 어떤 요소가 담겨있는지 모르기 때문에 메서드의 파라미터가 매우 다양해져야 한다.(List<Object>가 List<Integer>의 상위 타입이 아님을 상기하자!!)

따라서 이처럼 List 요소의 자료형을 모르고 특별한 관심이 없는 경우는 그냥 raw 타입을 사용해야하지 않을까? (List로는 List<String>, List<Integer>를 다 받을 수 있으니까)

 

public boolean hasSameElement1(List l1, List l2) {
    for(Object obj: l1) {
        if(l2.contains(obj)) {
            return true;
        }
    }
    l1.add("some"); // 이런 코드가 생길 수도 있다.. - runtime error
    l2.add(1);
    return false;
}

public void hasSameElementTest() {
    List<Integer> l1 = Arrays.asList(1,2,3);
    List<String> l2 = Arrays.asList("Hello", "Generic", "World");
    boolean result = hasSameElement1(l1, l2);   
}

 

하지만 여기에 사용된 raw 타입은 중간에 다른 타입이 삽입될 수 있기 때문에 위험하다. 

이처럼 Generic type을 사용하고 싶은데 실제 타입이 무언지 모르거나 관심 없는 경우에  사용할 수 있는 것이 <?>라는 비 한정적 와일드카드(unbounded wildcard)이다. List<?>는 어떤 자료형의 목록 이라고 생각하면 된다. 위 코드를 <?>로 바꿔보자.

public boolean hasSameElement2(List<?> l1, List<?> l2) {
    for(Object obj: l1) {
        if(l2.contains(obj)) {
            return true;
        }
    }
    return false;
}

이 메서드는 다음과 같이 다양한 Generic type을 이용해서 호출해볼 수 있다.

public void hasSameElement1Test() {
    List<Integer> l1 = Arrays.asList(1,2,3);
    List<String> l2 = Arrays.asList("Hello", "Generic", "World");
    boolean result2 = hasSameElement2(l1, l2);
}

 

그럼 <?>는 어떻게 타입의 안정성을 유지할 수 있을까? 

List<?> 에는 new ArrayList<Integer>가 할당될 수도 있고 new ArrayList<String>이 할당될 수도 있다. 즉 List<?>만 보고는 어떤 타입의 요소를 갖는지 알수 없게된다.

따라서 List<?> 에는 null 값을 제외한 어떠한 값도 넣을 수 없게 막아서 형 안전성을 유지한다.

public boolean hasSameElement2(List<?> l1, List<?> l2) {
    for(Object obj: l1) {
        if(l2.contains(obj)) {
            return true;
        }
    }

    l1.add(null);         // null은 할당 가능
    l2.add(new Object()); // compile error
    return false;
}

결국 List<?>에는 어떤 타입도 넣을 수 없고 어떤 타입의 자료형을 꺼낼 수 있을 지 알수가 없다. 이런 제약이 좀 불만이면 generic method를 사용하거나 한정적 와일드카드(bounded wildcard)를 사용할 수 있다.

 

어쩔 수 없이 raw 타입을 써야하는 경우?

우리의 코드에서 raw 타입을 추방해야 하는게 맞지만 어쩔 수 없이 raw 타입을 써야하는 경우가 2가지 존재한다. 이 두 가지 예외 상황은 Generic 정보가 compile time에만 존재하고 runtime에는 존재하지 않기 때문에 발생한다.

첫 번째 경우는 코드에서 Class의 리터럴을 사용해야 하는 경우이다. List.class는 사용할 수 있지만 List<String>.class의 형태로는 사용할 수 없다.

public void needRawType(){
    //parse(List<String>.class); // 이렇게는 전달할 수 없다.
    parse(List.class);      
}
  
public <T> void parse(Class<T> c) {
    System.out.println(c.getCanonicalName());
}

두 번째의 경우는 instanceof 연산자에서 타입을 체크하는 경우이다. 이 경우도 Generic type을 사용할 수 없다. 이런 경우 일단 raw 타입으로  체크한 후 <?>를 이용하는 것이 가장 깔끔하다. 

public void testType(Object obj) {
    // 오류: Type Object cannot be safely cast to List<String>
    /*
        if(obj instanceof List<String>) {
            System.out.println("문자열의 목록");
        }
    */

    if (obj instanceof List) {
        List<?> l = (List<?>) obj;
        System.out.println(l);
    }
}
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.