Skip to content

Item 28. 배열보다는 리스트를 사용하라 #28

@jseok0917

Description

@jseok0917

Chapter : 5. 제네릭

Item : 28. 배열보다는 리스트를 사용하라

Assignee : jseok0917


🍑 서론

제네릭

  • 한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크를 해주는 기능
  1. 타입안정성을 높여줌. 사용될 타입을 미리 지정하지 않고 클래스나 메서드를 정의할 수 있다.
  2. 형변환의 번거로움을 줄여줌. 클래스나 메서드에서 사용될 수 있는 데이터 타입을 파라미터화할 수 있다.
    • 타입에 의존하는 클래스나 메서드는 문제가 발생할 수 있음.
//제네릭을 사용하지 않는 경우
//컴파일러는 Box에 어떤 타입의 객체가 저장되는지 알지 못한다.
public class Box {
    private Object item;

    public void setItem(Object item) {
        this.item = item;
    }

    public Object getItem() {
        return item;
    }
}


//제네릭을 사용하는 경우
//컴파일러는 Box에 T라는 타입의 객체가 저장됨을 알 수 있다.
public class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}

제네릭을 사용하는 이유

  1. 컴파일러가 컴파일 시점에 형변환 오류와 같은 변수 타입과 관련된 오류를 잡아낼 수 있다.
    • 프로젝트 규모가 커질수록, 타입과 관련하여 발생하는 런타임 시점의 오류를 잡아내기가 어려워진다.
  2. 변수 타입을 명시함으로써 코드가독성과 유지보수가 용이하다.
  3. 컴파일러가 변수 타입을 알기 때문에 코드가 더 효율적으로 최적화될 수 있고, 실행시간 단축 및 성능이 향상된다.
import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        // 제네릭을 명시하지 않은 경우
        ArrayList my_list = new ArrayList();
        my_list.add("Hello");
        my_list.add(123);

        // 아래 코드는 문법상 허용된다.
        // 그러나 런타임 시점에 오류가 발생한다.
        for (Object item : my_list) {
            String str = (String) item; // 형변환 시도
            System.out.println(str.toUpperCase()); // 런타임에 ClassCastException 발생
        }

    }
}

🍑 본론

1. 배열은 공변이고, 리스트는 불공변이다.

  • 불공변성 : 자료형 간의 대체가 없음. 즉, 하위 자료형이나 상위 자료형으로의 대체가 허용되지 않는다.

  • 공변성 : 자료형 간의 대체가 가능. 즉, 하위 자료형을 상위 자료형으로 대체할 수 있다.

  • 간단하게 얘기해서

    • T가 T'의 부모이면 C<T>도 C<T'>의 부모

import java.util.ArrayList;
import java.util.List;

class Animal {}
class Cat extends Animal {}

//공변성과 불공변성의 차이
Cat cat = new Cat();
Animal animal = cat; 

Cat[] cat_array = new Cat[5];
Animal[] animal_array = cat_array //할당 가능

List<Cat> cat_list = new List<Cat>();
List<Animal> animal_list = cat_list //할당 불가능

  • 공변성으로 발생하는 문제의 예시
public class test04 {
    public static void main(String[] args) {
        Cat[] cats = new Cat[5];
        cats[0] = new Cat();

        // 배열을 Animal[]로 캐스팅
        Animal[] animals = cats;

        // 다른 종류의 동물 객체를 추가
        animals[1] = new Animal();

        // cats 배열을 순회하면서 각 요소를 출력
        for (Cat cat : cats) {
            System.out.println(cat); // 런타임 시점에서 Unchecked Error인 ArrayStoreException 발생한다.
        }
    }
}

  • 제네릭을 사용할 경우 다음과 같이 타입 안정성을 보장할 수 있다.
public class test03 {
    public static void main(String[] args) {
        List<Cat> cats = new ArrayList<>();
        cats.add(new Cat());

        // 불공변성으로 인해 List<Animal>으로 캐스팅 불가능
        List<Animal> animals = cats; // 컴파일 시점에서 Check Error인 Type Mismatch 에러가 발생한다.

        // 동일한 리스트를 사용하여 다른 동물을 추가
        animals.add(new Animal());

        for (Cat cat : cats) {
            System.out.println(cat);
        }
    }
}

  • 배열과 리스트를 섞어서 사용할 경우, 공변성으로 인해 다음과 같은 문제가 발생할 수 있다.
List<String>[] stringLists = new List<String>[1];
List<Integer> intList = List.of(42);
Object[] objects = stringLists; // Object는 String의 부모클래스 이므로 할당가능(공변성)
objects[0] = intList; //object에 원소를 할당하면, stringLists 참조했으므로 stringLists[0] 도 바뀜
String s = stringLists[0].get(0); // 


2. 배열은 실체화(reify)된다. 리스트는...

  • 실체화 : 자신의 타입 정보를 런타임에도 알고 있는 것(정확히는 런타임시에도 타입을 지속적으로 체크)
  • 비실체화 : 자신의 타입정보를 런타임 시점에 소거(erasure)하여 컴파일 타임보다 정보를 적게 가지는 것
    • 제네릭 Type Erasure : 컴파일 타입에만 타입 제약 조건을 정의하고, 런타임에는 타입을 제거

  • 타입소거란?
ArrayList<Integer> integerList;
ArrayList<String> stringList;
//이 코드는 컴파일하면,

ArrayList integerList;
ArrayList stringList;
//이렇게 같은 타입으로 변한다. 따라서, 다음과 같은 오버로딩은 불가능하다.

public void overload(List<Integer> integerList) {}
public void overload(List<String> stringList) {}
  • 타입 소거(erasure)로 인한 문제점
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> stringList = new ArrayList<>();
        stringList.add("Hello");
        stringList.add("World");

        // Object 배열 생성
        Object[] objArray = new Object[2];

        // 제네릭 리스트의 내용을 배열로 복사
        copyListToArray(stringList, objArray);

        // 배열 요소 출력
        for (Object obj : objArray) {
            // ClassCastException 발생 가능
            String str = (String) obj;
            System.out.println(str);
        }
    }

    // 제네릭 리스트를 배열로 복사하는 메서드
    public static <T> void copyListToArray(List<T> list, T[] array) {
        for (int i = 0; i < list.size(); i++) {
            array[i] = list.get(i);
        }
    }
}


//위의 예제에서는 List<String>을 Object[]로 복사하려고 시도했습니다. 
//그러나 자바에서는 제네릭의 타입 정보는 런타임에 소거되기 때문에, 
//copyListToArray 메서드는 컴파일 시에는 제대로 동작하지만 런타임에는 제네릭 타입 정보가 소거되어
// Object 배열에 실제로는 String이 아닌 Object로 채워지게 됩니다. 
//이렇게 되면 배열에서 값을 가져올 때 ClassCastException이 발생할 수 있습니다.
//따라서, reify한 제네릭과 reify하지 않은 배열을 혼합해서 사용할 때는 주의해야 합니다. 
//가능하다면 제네릭을 사용한 컬렉션을 배열로 변환하는 것은 피하는 것이 좋습니다.

배열로 형변환 시, 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 대부분 E[]대신 List를 사용하면 해결된다.

public class Chooser {
    private final Object[] choiceArray;

    public Chooser(Collection choices) {
        choiceArray = choices.toArray();
    }

    //실제로 이 메서드를 써먹으려면
    //반환된 Object를 다시 형변환해주는 과정이 필요하다.
    public Object choose() {
        Random rnd = ThreadLocalRandom.current();
        return choiceArray[rnd.nextInt(choiceArray.length)];
    }
}
public class Chooser<T> {
    private final T[] choiceArray;

    public Chooser(Collection<T> choices){
        choiceArray = choices.toArray();
    }

    //choose 메서드는 그대로
}

//근데 이건 왼쪽이 T[]인데 오른쪽은 object[]이므로 타입이 서로 호환되는지 알 수 없으므로 컴파일에러 발생

public class Chooser<T> {
    private final T[] choiceArray;

    public Chooser(Collection<T> choices){
        //형변환하면 컴파일에러가 생기지 않는다.
        choiceArray = (T[]) choices.toArray();
    }

    //choose 메서드는 그대로
}

//컴파일 에러가 이제 발생하지 않지만, 그대신 경고가 뜸
//why? 명시적 캐스팅은 오류의 원인을 프로그래머가 책임지는것이므로

//리스트를 써서 제네릭을 쓰면 깔끔하다
public class Chooser<T> {
    private final List<T> choiceList;

    public Chooser(Collection<T> choices){
        choiceList = new ArrayList<>(choices);
    }

    public T choose(){
        Random rnd = ThreadLocalRandom.current();
        return choiceList.get(rnd.nextInt(choiceList.size()));
    }
}

🍑 결론

  • 배열보다 리스트를 사용해라. 둘의 가장 큰 차이는 제네릭을 사용할 수 있냐 없냐. 제네릭의 사용하면 타입안정성, 즉 컴파일 시 타입으로 인한 오류를 잡아낼 수 있다. 런타임 에러는 항상 치명적일 수 있음을 인지하자!

번외

Java 컴파일러의 타입 소거 규칙

  • unbounded Type(<?>, )는 Object로 변환
  • bound type()의 경우는 Object가 아닌 Comprarable로 변환
  • 제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메소드에만 소거 규칙을 적용
  • 타입 안정성 보존을 위해 필요하다면 type casting
  • 확장된 제네릭 타입에서 다형성을 보존하기 위해 bridge method를 생성

bound type의 타입 소거

// 컴파일 할 때 (타입 소거 전) 
public class Test<T> {
    public void test(T test) {
        System.out.println(test.toString());
    }
}
// 런타임 때 (타입 소거 후)
public class Test {
    public void test(Object test) {
        System.out.println(test.toString());
    }
}

타입이 컴파일 타임에만 유효하고, 런타임엔 전부 Object로 치환되는 희한한 특성 때문에, 자바 제네릭에는 몇 가지 제약이 존재한다.

  • 원시 타입의 제네릭 타입 생성 불가
  • 제네릭 타입 인스턴스화 불가
  • 제네릭 타입의 static 필드 선언 불가
  • 제네릭 타입으로의 형변환이나 instanceof 사용 불가
  • 제네릭 타입의 배열 생성 불가
  • 제네릭 타입이 포함된 클래스 생성, catch/throw 불가
  • 타입 소거가 됐을 때 동일한 메서드 오버로딩 불가
  • 자바의 제네릭은 각종 제약을 가질 뿐더러 타입 안정성을 완벽하게 보장하지 않으며 잘못된 사용 시 "힙 오염 (Heap pollution)"을 유발하는 주범이 된다.

그럼에도 왜 이런 방식을 사용하나?

C#에서는 제네릭을 조금 다른 방식으로 구현한다.
다음과 같은 C# 코드가 있다고 가정해보자.

List<int> integerList;
List<string> stringList;


//컴파일러는 코드 내에서 List가 사용하는 제네릭 타입 (int, string)을 전부 확인한다. 그리고 List의 코드에서 제네릭 타입 파라미터를 int, string으로 치환한 특수 버전 List를 2개 생성하여 각각 List<int>, List<string>에 대입한다. 이러한 방식은 자바와 달리 완벽한 타입 검사를 지원하고, 타입이 실체화 되어있기 때문에 타입의 동적인 사용이 가능하다.
// 자바와 달리 제네릭 타입의 동적인 생성 가능
new T();
//초창기 자바에는 제네릭이 존재하지 않았다. 
//따라서 현재 제네릭을 지원하는 컬렉션 프레임워크 등도 제네릭 없이, Object 타입으로 구현이 되어 있었다.

// 제네릭이 지원되기 전 ArrayList
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);
//그러다 2004년 JDK 1.5 출시와 함께 제네릭이 도입되었지만 이미 수많은 코드들이 Object를 구현하는 방식으로 짜여져 있었고, 
//이들과 호환성을 유지하기 위해 자바는 제네릭 타입을 런타임에 소거하는 방식을 택했다. 
//제네릭을 사용하더라도 런타임엔 Object로 치환되기 때문에 기존 코드들과 호환성을 유지할 수 있는 것이다.

Referenced by https://velog.io/@bedshanty/%EC%9E%90%EB%B0%94%EC%9D%98-%ED%83%80%EC%9E%85-%EC%86%8C%EA%B1%B0-Type-erasure

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions