💡 null을 반환하는 API는 성능이나 편리성 모두 저하된다.
📌 As-is
private final List<Cheese> cheesesInStock = List.of();
/**
* @return 매장 안의 모든 치즈 목록을 반환한다.
* 단, 재고가 하나도 없다면 null을 반환한다.
*/
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty() ? null
: new ArrayList<>(cheesesInStock);
}
// 클라이언트 코드
List<Cheese> cheeses = shop.getCheeses();
if (cheeses != null && cheeses.contains(Cheese.STILTON)) { // null guard
System.out.println("영차, 좋았어~");
}
- 컨테이너(컬렉션, 배열 등)가 비었을 때 null을 반환하는 메서드를 사용하면 null guard가 필수적이다.
- null guard를 잊어먹으면 잠재적인 오류로 남아있다가 수년 뒤에나 오류가 발생할 수도 있다.
- 반환하는 쪽이나 호출하는 쪽이나 모두 코드가 복잡해진다.
✒️List.copyOf()
코드 따라치고 있는데, 갑자기 코파일럿이 new ArrayList 대신 List.copyOf()를 추천하길래 호기심이 생겼다.
과연 뭐가 더 나을까?
결론으로 따지면 둘을 비교하는 건 좀 멍청한 짓이었다.
List.copyOf()가 얕은 복사를 수행하여 원하는 결과를 얻지 못할 것이라는 가정을 기반으로 docs를 뒤져봤는데, 역할이 단순히 복사만인 메서드는 아니었다.
static <E> List<E> copyOf(Collection<? extends E> coll) {
return ImmutableCollections.listCopy(coll);
}
static <E> List<E> listCopy(Collection<? extends E> coll) {
if (coll instanceof List12 || (coll instanceof ListN && ! ((ListN<?>)coll).allowNulls)) {
return (List<E>)coll;
} else {
return (List<E>)List.of(coll.toArray()); // implicit nullcheck of coll
}
}
우선 copy 메서드의 고질적인 문제점인 얕은 복사의 문제가 해결되지 않은 것은 맞았다.
보다 정확히 말하면, 1차원 까지는 다른 주소를 참조하도록 만들어주지만 내부 요소까지 copy를 온전히 수행하지는 못한다.
그리고 재미있는 점은 copyOf()로 복사한 리스트는 수정이 불가능하다.
이 이유가 좀 놀라웠는데, copyOf() 메서드의 특징이 아니라 ImmutableCollections 객체의 특징에 해당한다.
그리고 List.of() 메서드로 생성한 객체도 마찬가지로 수정이 불가능하다.
만약 해당 메서드들로 생성한 리스트에 add, set, remove 메서드를 호출하려 하면 UnsupportedOperationException을 던진다.
// List.of()
static <E> List<E> of() {
return (List<E>) ImmutableCollections.EMPTY_LIST;
}
// Arrays.asList()
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}
List<Integer> integers = Arrays.asList(1, 2, 3);
Collections.unmodifiableCollection(integers); // List.of()와 같은 효과
new ArrayList()에서 활용되는 객체는 Arrays 클래스 내부 클래스에 선언된 ArrayList 클래스다.
하지만 sort 메서드를 Overriding 하고 있어 완전한 Immutable이라 할 수는 없기 때문에, Collections.unmodifiableList() 메서드로 자체적으로 immutable 하도록 세팅하는 패턴이 들어간다.
하지만 List.of()는 그 패턴마저 없애버리려고 ImmutableCollections를 고안해낸 게 아닐까 싶다.
여튼 뭔가 이야기가 다른 데로 좀 새긴 했는데 결론은 이렇다.
- List.copyOf()는 완전한 copy를 수행하진 못한다.
- List.copyOf()로 반환된 컬렉션은 수정하지 못한다. (불변 리스트를 원한다면 좋을 수도)
📌 To-be
public List<Cheese> getCheeses() {
return new ArrayList<>(cheesesInStock);
}
- 빈 컨테이너 할당 비용보다 null을 반환하는 쪽이 낫다는 것은 틀린 주장이다.
- 성능 분석 결과 해당 스니펫이 성능 저하 주범이라 확인되지 않는 한(Item 67), 이 정도 성능 차이는 신경 쓸 수준이 못 된다.
- 빈 컬렉션과 배열은 굳이 새로 할당하지 않고도 반환할 수 있다.
📌 불변 컬렉션 반환
💡 이 방식은 최적화에 해당하므로, 꼭 실제 성능이 개선되는지 확인하고 사용하라
// 컬렉션의 경우
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty() ? Collections.emptyList()
: new ArrayList<>(cheesesInStock);
}
// 배열의 경우
public Cheese[] getCheeses() {
return cheesesInStock.toArray(new Cheese[0]);
}
- 가능성은 작지만, 사용 패턴에 따라 빈 컬렉션 할당이 성능을 눈에 띄게 저하시킬 수도 있다.
- 그럴 때는 똑같은 빈 '불변' 컬렉션을 반환하라. (불변 객체는 자유롭게 공유해도 안전하다.)
- 배열의 경우에도 null이 아닌 길이가 0인 배열을 반환하라.
private final List<Cheese> cheesesInStock = List.of();
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
public Cheese[] getCheeses() {
return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
- 만약 빈 배열을 반환하는 경우도 성능 저하가 우려된다면 항상 EMPTY_CHEESE_ARRAY를 넘겨라
✒️ 요소가 비어있지 않다면?
위 코드만 보면 자칫, getCheeses()는 언제나 빈 배열을 넘길 것이라 착각할 수도 있다.
하지만 List.toArray(EMPTY_CHEESE_ARRAY);가 아니라 cheesesInStock의 인스턴스 메서드임을 유의하자.
<T> T[] List.toArray(T[] a) 는 주어진 배열이 충분히 크다면 해당 배열에 원소를 담고, 아니라면 Cheese[] 타입의 배열을 새로 생성해서 반환한다.
즉, cheesesInStock에 원소가 하나라도 있다면, 빈 배열이 반환될 일은 없다.
📌 불변 컬렉션 반환
return cheesesInStock.toArray(new Cheese[cheesesInStock.size()]);
- 성능 개선을 목적으로 위 코드를 사용하지는 말자.
- toArray에 넘기는 배열을 미리 할당하는 방식은 오히려 성능 저하를 유발한다는 연구 결과가 있다.