peaches-book-study / effective-java

이펙티브 자바 3/E
0 stars 2 forks source link

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

Open jseok0917 opened 4 months ago

jseok0917 commented 4 months ago

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. 배열은 공변이고, 리스트는 불공변이다.


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)된다. 리스트는...


ArrayList<Integer> integerList;
ArrayList<String> stringList;
//이 코드는 컴파일하면,

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

public void overload(List<Integer> integerList) {}
public void overload(List<String> stringList) {}
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 컴파일러의 타입 소거 규칙

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로 치환되는 희한한 특성 때문에, 자바 제네릭에는 몇 가지 제약이 존재한다.

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

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

hyunsoo10 commented 4 months ago

엄청 자세하게 해주셨네요!

heon118 commented 4 months ago

배ㅇ열