BOOK 6 - 이펙티브 자바(8)
8장 메서드
매개변수가 유효한지 검사하라
- 메서드와 생성자는 대체로 입력 매개변수의 값이 특정 조건을 만족하기를 바란다.
- 이런 제약은 반드시 문서화해야 하며, 메서드 몸체가 시작하기 전에 검사해야 한다.
- “오류는 가능한 한 발생한 곳에서 가까이 빨리 잡아야 한다”는 원칙의 사례이다.
매개변수 검사를 제대로 하지 못했을 때 문제점
- 메서드가 수행되는 중간에 모호한 예외를 던지며 실패할 수 있다.
- 메서드가 잘 수행되지만 잘못된 결과를 반환할 수 있다.
- 메서드는 문제없이 수행됐지만, 어떤 객체를 이상한 상태로 만들어 놓아서 미래의 알 수 없는 시점에 이 메서드와는 관련 없는 오류를 발생시킬 수 있다.
- 즉, 실패 원자성 을 어기는 결과를 낳을 수 있다.
null 검사
-
매개변수가 null일 때 메서드는
NullPointerException을 던질 수 있다. -
@Nullable이나 이와 비슷한 어노테이션을 사용해 특정 매개변수는 null이 될 수 있다고 알려줄 수도 있지만, 표준적인 방법은 아니다. -
자바 7에 추가된
java.util.Objects.requireNonNull메서드는 유연하고 사용하기도 편하다.- 직접 null 검사를 수동으로 하지 않아도 된다.
public static <T> T requireNonNull(T obj) { if (obj == null) throw new NullPointerException(); return obj; }- 이 메서드는 명시성과 빠른 실패를 위해 사용한다.
- 참고 : https://velog.io/@rockpago/Objects.requireNonNull
범위 검사
- 자바 9에서
Objects에 범위 검사 기능도 더해졌다. checkFromIndexSize,checkFromToIndex,checkIndex- null 검사 메서드만큼 유연하지는 않다.
- 리스트와 배열 전용으로 설계되었고, 닫힌 범위는 다루지 못한다.
- 이런 제약이 걸림돌이 되지 않는 상황에서는 유용하고 편하게 쓸 수 있다.
Asserts
- 공개되지 않은 메서드라면 해당 메서드가 호출되는 상황을 직접 통제할 수 있다.
- 따라서 오직 유효한 값만이 메서드에 넘겨지리라는 것을 보증할 수 있고, 그렇게 해야 한다.
- 즉,
public이 아닌 메서드라면assert단언문을 사용해 매개변수 유효성을 검증할 수 있다.
private static void sort(long a[], int offset, int length) {
assert a != null;
assert offset >= 0 && offset <= a.length;
assert length >= 0 && length <= a.length - offset;
...
}
- 단언문들은 자신이 단언한 조건이 무조건 참이라고 선언한다.
일반적인 유효성 검사와 다른 점
- 실패하면
AssertionError를 던진다. - 런타임에 아무런 효과도, 아무런 성능 저하도 없다.
- 디버깅 용도로 사용되기 때문에, 최종 실행환경에서는 제외된다.
- 참고 : https://offbyone.tistory.com/294
그 외 유의사항
- 메서드가 직접 사용하지는 않으나 나중에 사용하기 위해 저장하는 매개변수는 특히 더 신경써서 검사해야 한다.
- 생성자의 경우가 특수한 사례이다.
- 생성자 매개변수의 유효성 검사는 클래스 불변식을 어기는 객체가 만들어지지 않게 하는 데 꼭 필요하다.
- 메서드 몸체 실행 전에 매개변수 유효성을 검사해야 한다는 규칙도 예외는 있다.
- 유효성 검사 비용이 지나치게 높거나 실용적이지 않을 때, 혹은 계산 과정에서 암묵적으로 검사가 수행될 때다.
- 예를 들어,
Collections.sort(List)의 경우, 리스트 안의 객체들은 모두 상호 비교될 수 있어야 하며, 비교될 수 없는 타입의 객체가 들어있다면ClassCastException을 던진다. - 따라서 비교 전에 리스트 안의 모든 객체가 비교 가능한지 검사할 필요가 없다.
- 하지만 암묵적 유효성 검사에 너무 의존하면 실패 원자성을 해칠 수 있으니 주의해야 한다.
- 매개변수에 제약을 두는 게 좋다는 의미가 아니다. 메서드는 최대한 범용적으로 설계해야 한다.
- 메서드가 건네받은 값으로 무언가 제대로 된 일을 할 수 있다면 매개변수 제약은 적을수록 좋다.
적시에 방어적 복사본을 만들라
- 자바는 C, C++ 같이 버퍼 오버런, 배열 오버런, 와일드 포인터같은 메모리 충돌 오류에서 안전하다.
- 하지만, 다른 클래스로부터의 침범을 아무런 노력 없이 다 막을 수 있는 건 아니다.
- 클라이언트가 해당 클래스의 불변식을 깨뜨릴 수 있다고 가정하고 방어적으로 프로그래밍 해야 한다.
불변식을 지키지 못한 경우 - Period 예제
- 어떤 객체든 그 객체의 허락 없이는 외부에서 내부를 수정하는 일은 불가능하다.
- 하지만 주의를 기울이지 않으면 자기도 모르게 내부를 수정하도록 허락하는 경우가 생긴다.
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
if (start.compareTo(end) > 0) {
throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
}
this.start = start;
this.end = end;
}
public Date start() {
return start;
}
public Date end() {
return end;
}
}
- 이 클래스는 불변처럼 보이고, 시작 시각이 종료 시각보다 늦을 수 없다는 불변식이 지켜질 것 같다.
- 하지만
Date가 가변이라는 사실을 이용하면 불변식을 깨뜨릴 수 있다.
첫번째 공격
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78);
해결방법 1 - Instant 사용
Date대신 자바 8 이후로 추가된 불변인Instant를 사용한다.- 혹은
LocalDateTime이나ZonedDateTime을 사용해도 된다.
- 혹은
해결방법 2 - 방어적 복사
- 많은 API와 내부 구현에
Date같은 낡은 값 타입을 사용한 잔재가 남아있다. - 이 경우에는 생성자에서 받은 가변 매개변수 각각을 방어적으로 복사하여
Period인스턴스 내부를 보호한다. - 그런 다음
Period인스턴스 안에서는 원본이 아닌 복사본을 사용한다.
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0) {
throw new IllegalArgumentException(this.start + "가 " + this.end + "보다 늦다.");
}
}
주의점
1. 매개변수의 유효성을 검사하기 전에 방어적 복사본을 만들고, 이 복사본으로 유효성을 검사했다.
- 멀티스레딩 환경에서 원본 객체의 유효성을 검사한 후 복사본을 만드는 그 찰나의 취약한 순간에 다른 스레드가 원본 객체를 수정할 위험이 있기 때문이다.
- 이를 검사시점/사용시점 (time-of-check/time-of-use) 공격 혹은 TOCTOU 공격이라 한다.
- 방어적 복사에
Date의clone메서드를 사용하지 않았다.
Date는final이 아니므로clone이Date가 정의한 게 아닐 수 있다.- 즉,
clone이 악의를 가진 하위 클래스의 인스턴스를 반환할 수도 있다.
- 즉,
- 따라서 매개변수가 제 3자에 의해 확장될 수 있는 타입이라면, 방어적 복사본을 만들 때
clone을 사용해서는 안된다.
두번째 공격
- 접근자 메서드가
Period인스턴스 내부의 가변 정보를 직접 드러내기 때문에Period인스턴스는 아직 변경 가능하다.
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end().setYear(78);
해결 방법
- 접근자가 가변 필드의 방어적 복사본을 반환하도록 한다.
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
- 이렇게 하면 모든 필드가 객체 안에 완벽하게 캡슐화되었다.
그 외 유의사항
- 클라이언트가 제공한 객체의 참조를 내부의 자료구조에 보관해야 할 때면 항상 그 객체가 잠재적으로 변경될 수 있는지를 체크해야 한다.
- 변경될 수 있는 객체라면 그 객체가 임의로 변경되어도 그 클래스가 문제없이 동작할지를 따져본다.
- 확신할 수 없다면 복사본을 만들어 저장해야 한다.
- 길이가 1이상인 배열은 무조건 가변이다.
- 따라서 내부에서 사용하는 배열을 클라이언트에 반환할 떄는 항상 방어적 복사를 수행해야 한다.
- 방어적 복사에는 성능 저하가 따르고, 또 항상 쓸 수 있는 것도 아니다.
- 호출자가 컴포넌트 내부를 수정하지 않으리라 확신하면 방어적 복사를 생략할 수 있다.
- 방어적 복사를 생략해도 되는 상황은 해당 클래스와 그 클라이언트가 상호 신뢰할 수 있을 때, 혹은 불변식이 깨지더라도 그 영향이 오직 호출한 클라이언트로 국한될 때로 한정해야 한다.
메서드 시그니처를 신중히 설계하라
API 설계 요령
-
메서드 이름을 신중히 짓자.
- 이해할 수 있고, 같은 패키지에 속한 다른 이름들과 일관되게 짓는게 최우선이다.
-
편의 메서드를 너무 많이 만들지 말자.
- 모든 메서드는 각각 자신의 소임을 다해야 한다.
- 메서드가 너무 많은 클래스는 사용하고 테스트하고 유지보수하기 어렵다.
- 클래스나 인터페이스는 자신의 각 기능을 완벽히 수행하는 메서드로 제공해야 한다.
-
매개변수 목록은 짧게 유지하자.
- 4개 이하가 좋다.
- 같은 타입의 매개변수 여러개가 연달아 나오는 경우가 특히 해롭다.
- 매개변수 순서를 기억하기 어렵고, 실수로 순서를 바꿔 입력해도 그대로 컴파일되고 실행된다.
-
매개변수의 타입으로는 클래스보다는 인터페이스가 낫다.
- 인터페이스 대신 클래스를 사용하면 클라이언트에게 특정 구현체만 사용하도록 제한하는 꼴이며, 입력 데이터가 다른 형태로 존재한다면 명시한 특정 구현체의 객체로 옮겨담느라 복사 비용을 치러야 한다.
-
매개변수 타입
boolean보다는 원소 2개짜리 열거 타입이 낫다.- 코드를 읽고 쓰기가 더 쉬워진다.
- 아래와 같이 온도 열거 타입이 있을 때,
boolean타입 매개변수를 전달하는 것보다 열거 타입 매개변수를 전달하는 게 명확하다.
public enum TemperatureScale { FAHRENHEIT, CELSIUS } Thermometer.newInstance(true); // boolean 타입 Thermometer.newInstance(TemperatureScale.CELSIUS); // 열거 타입- 나중에
KELVIN값을 추가해 캘빈온도도 지원할수도 있다.
긴 매개변수 목록을 줄여주는 기술
- 여러 메서드로 나눈다.
- 쪼개진 메서드 각각은 원래 매개변수 목록의 부분집합을 받는다.
- 직교성을 높여 오히려 메서드 수를 줄여주는 효과도 있다.
- 직교성이 높다는 의미는 ‘공통점이 없는 기능들이 잘 분리되어 있다’, ‘기능을 원자적으로 쪼개 제공한다’ 로 해석할 수 있다.
- 기능을 원자적으로 쪼개다보면, 중복이 줄고 결합성이 낮아져 코드를 수정, 테스트하기 쉬워진다.
- 일반적으로 직교성이 높은 설계는 가볍고 구현하기 쉽고 유연하고 강력하다.
- 특정 조합의 패턴이 상당히 자주 사용되거나 최적화하여 성능을 크게 개선할 수 있다면 직교성이 낮아지더라도 편의 기능으로 제공하는 편이 나을 수도 있다.
- 예를 들어,
List인터페이스는subList와indexOf메서드를 제공한다.- 이 두 메서드를 조합해 지정된 범위의 부분리스트에서의 인덱스를 찾을 수 있다.
- 매개변수 여러개를 묶어주는 도우미 클래스를 만든다.
- 일반적으로 이런 도우미 클래스는 정적 멤버 클래스로 둔다.
- 특히 매개변수 몇 개를 독립된 하나의 개념으로 볼 수 있을 때 추천하는 기법이다.
- 예를 들어, 카드게임 예제에서 숫자(
rank)와 무늬(suit)를 뜻하는 두 매개변수를 묶어 도우미 클래스를 만들어 하나의 매개변수로 주고 받을 수 있다.
- 객체 생성에 사용한 빌더 패턴을 메서드 호출에 응용한다.
- 매개변수가 많고, 그 중 일부는 생략해도 괜찮을 때 도움이 된다.
- 모든 매개변수를 하나로 추상화한 객체를 정의하고, 클라이언트에서 이 객체의 setter 메서드를 호출해 필요한 값을 설정하게 하는 것이다.
- 클라이언트는 먼저 필요한 매개변수를 다 설정한 다음,
execute메서드를 호출해 앞서 설정한 매개변수들의 유효성을 검사한다.
- 매개변수 여러개를 묶어주는 도우미 클래스를 만든다.
다중정의는 신중히 사용하라
컬렉션 분류기 예제 - 다중정의 오류
public class CollectionClassifier {
public static String classify(Set<?> s) {
return "집합";
}
public static String classify(List<?> list) {
return "리스트";
}
public static String classify(Collection<?> c) {
return "그 외";
}
public static void main(String[] args) {
Collection<?>[] collections = {
new HashSet<String>(),
new ArrayList<BigInteger>(),
new HashMap<String, String>().values()
};
for (Collection<?> c : collections) {
System.out.println(classify(c));
}
}
}
- 위 코드를 실행하면
"집합", "리스트", "그 외"를 출력할 것 같지만 실제로는"그 외"만 세 번 연달아 출력된다. - 다중정의된 세
classify중 어느 메서드를 호출할지가 컴파일 타임에 정해지기 때문이다. for문 안의c는 런타임에는 타입이 매번 달라지지만, 컴파일타임에는 항상Collection<?>타입이다.- 따라서 항상 세번째 메서드인
classify(Collection<?>)만 호출되는 것이다.
- 따라서 항상 세번째 메서드인
재정의 VS 다중정의 메서드 호출 메커니즘
- 재정의한 메서드는 동적으로 선택되고, 다중정의한 메서드는 정적으로 선택된다.
- 즉, 메서드를 재정의했다면 해당 객체의 런타임 타입이 어떤 메서드를 호출할지의 기준이 된다.
class Wine {
String name() { return "포도주"; }
}
class SparklingWine extends Wine {
@Override
String name() { return "발포성 포도주"; }
}
class Champagne extends SparklingWine {
@Override
String name() { return "샴페인"; }
}
public class Overriding {
public static void main(String[] args) {
List<Wine> wineList = List.of(new Wine(), new SparklingWine(), new Champagne());
for (Wine wine : wineList) {
System.out.println(wine.name());
}
}
}
- 위 코드를 실행하면
"포도주", "발포성 포도주", "샴페인"을 출력한다. -
다중정의 메서드 사이에서는 객체의 런타임 타입은 전혀 중요하지 않고, 오직 매개변수의 컴파일타임 타입에 의해 선택된다.
- 이처럼 재정의한 메서드와 다중정의한 메서드의 호출 메커니즘이 다르기 때문에 헷갈릴 수 있는 코드는 작성하지 않는 게 좋다.
- 특히나 공개 API라면 사용자가 매개변수를 넘기면서 어떤 다중정의 메서드가 호출될지 모른다면 프로그램이 오동작하기 쉽다.
다중정의 혼란을 피하는 법
- 안전하고 보수적으로 가려면 매개변수 수가 같은 다중정의는 만들지 않는게 좋다.
- 가변인수를 사용하는 메서드라면 다중정의를 아예 하지 말아야 한다.
- 다중정의 대신 메서드 이름을 다르게 지어주는 방법이 있다.
- 예를 들어,
ObejctOutputStream클래스의write메서드는 모든 기본 타입과 일부 참조 타입용 변형을 가지고 있다. - 하지만 다중정의가 아닌 모든 메서드에 다른 이름을 지어주는 방법을 택했다.
writeBoolean,writeInt,writeLong같은 식이다.
- 예를 들어,
- 생성자는 이름을 다르게 지을 수 없기 때문에 두번째 생성자부터는 무조건 다중정의가 된다.
- 하지만 정적 팩터리라는 대안을 활용할 수 있다.
- 매개변수 수가 같은 다중정의 메서드가 많더라도, 매개변수 중 하나 이상이 ‘근본적으로 다르다’면 헷갈릴 일이 없다.
근본적으로 다른 매개변수
- 근본적으로 다르다는 것은 두 타입의 값을 서로 어느 쪽으로든 형변환할 수 없다는 뜻이다.
- 이 조건만 충족하면 어느 다중정의 메서드를 호출할지가 매개변수의 런타임 타입만으로 결정된다.
- 예를 들어,
ArrayList에는int를 받는 생성자와Collection을 받는 생성자가 있는데, 이 두 타입은 서로 형변환이 불가능하므로 어떤 상황에서든 두 생성자 중 어느 것이 호출될지 헷갈릴 일은 없을 것이다.
오토박싱
- 자바 4까지는 모든 기본 타입이 모든 참조 타입과 근본적으로 달랐지만, 자바 5에서 오토박싱이 도입되면서 더는 근본적으로 다르지 않게 되었다.
public class SetList {
public static void main(String[] args) {
Set<Integer> set = new TreeSet<>();
List<Integer> list = new ArrayList<>();
for (int i = -3; i < 3; i++) {
set.add(i);
list.add(i);
}
for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove(i);
}
}
}
- 프로그램을 실행했을 때 두 컬렉션의 원소가 모두
[-3, -2, -1]이리라고 기대하지만, 실제로는set의 원소는[-3, -2, -1]이고,list의 원소는[-2, 0, 2]가 된다. List인터페이스가remove(Object)와remove(int)를 다중정의했기 때문이다.set.remove(i)의 메서드 시그니처는remove(Object)이다.- 다중정의된 메서드가 없으니 기대한대로 동작하여 집합에서 입력받은 값의 원소를 제거한다.
list.remove(i)는 다중정의된remove(int index)를 선택한다.- 이 메서드는 ‘지정한 위치’의 원소를 제거하는 기능을 수행한다.
- 따라서 차례로 0번, 1번, 2번 원소가 제거되어
[-2, 0, 2]가 남게 된다.
-
이 문제는
list.remove의 인수를Integer로 형변환하여 올바른 다중정의 메서드를 선택하게 하면 해결된다. - 자바 언어에 오토박싱과 제네릭을 더한 결과
List인터페이스가 취약해졌다.
람다와 메서드참조
- 자바 8에서 도입한 람다와 메서드 참조 역시 다중정의 시의 혼란을 키웠다.
// 1번
new Thread(System.out::println).start();
// 2번
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);
- 1번과 2번이 모두
Runnable을 받는 메서드가 다중정의되어 있는 비슷한 모습이지만 2번만 컴파일 오류가 발생한다. submit의 다중정의 메서드 중에는Callable<T>를 받는 메서드도 있다는 점이 원인이다.- 만약
println이 다중정의 없이 단 하나만 존재했다면 이 코드는 제대로 컴파일이 됐을 것이다. - 지금은 참조된 메서드(
println)와 호출한 메서드(submit) 양쪽 다 다중정의되어 다중정의 해소 알고리즘이 기대처럼 동작하지 않는다.- 기술적으로 부정확한 메서드 참조라고 한다.
- 암시적 타입 람다식(implicitly lambda expression)이나 부정확한 메서드 참조 같은 인수 표현식은 목표 타입이 선택되기 전에는 그 의미가 정해지지 않기 때문에 적용성 테스트 때 무시된다.
- 메서드를 다중정의할 때, 서로 다른 함수형 인터페이스라도 같은 위치의 인수로 받아서는 안된다.
- 즉, 서로 다른 함수형 인터페이스라도 서로 근본적으로 다르지 않다는 뜻이다.
정리
- 다중정의된 메서드 중 하나를 선택하는 규칙은 매우 복잡하며, 자바가 버전업될수록 더 복잡해지고 있어 이 모두를 이해하고 사용하는 프로그래머는 극히 드물 것이다.
- 일반적으로 매개변수 수가 같을 때는 다중정의를 피하는 것이 좋다.
- 불가능하다면, 헷갈릴만한 매개변수는 형변환하여 정확한 다중정의 메서드가 선택되도록 해야 한다.
가변인수는 신중히 사용하라
- 가변인수 메서드는 명시한 타입의 인수를 0개 이상 받을 수 있다.
- 가변인수 메서드를 호출하면 가장 먼저 인수의 개수와 길이가 같은 배열을 만들고, 인수들을 이 배열에 저장하여 메서드에 건네준다.
인수가 1개 이상이어야 하는 경우
- 최솟값을 찾는 메서드의 경우, 인수 0개를 받을 수도 있도록 설계하는 것은 좋지 않다.
static int min(int... args) {
if (args.length == 0) {
throw new IllegalArgumentException("인수가 1개 이상 필요합니다.");
}
int min = args[0];
for (int i = 1; i < args.length; i++) {
if (args[i] < min) {
min = args[i];
}
}
return min;
}
args유효성 검사를 명시적으로 해야하고, 인수를 0개 넣어 호출하면 컴파일타임이 아닌 런타임에 실패한다.
해결 방법
- 매개변수를 2개 받도록 하면된다.
- 즉, 첫 번째로 평범한 매개변수를 받고, 두 번째로 가변인수를 받으면 된다.
static int min(int firstArg, int... remainigArgs) {
int min = firstArg;
for (int arg : remainigArgs) {
if (arg < min) {
min = arg;
}
}
return min;
}
가변인수 메서드 성능 최적화
- 성능에 민감한 상황이라면 가변인수가 걸림돌이 될 수 있다.
- 메서드가 호출될 때마다 배열을 새로 할당하고 초기화하는 비용을 감당할 수는 없지만 가변인수의 유연성이 필요할 때 선택할 수 있는 패턴이 있다.
- 예를 들어, 해당 메서드 호출의 95%가 인수를 3개 이하로 사용한다고 해보자.
- 그렇다면 아래처럼 인수가 0개인 것 부터 4개인 것 까지 총 5개의 메서드를 다중정의하자.
- 마지막 다중정의 메서드가 인수 4개 이상인 5%의 호출을 담당하는 것이다.
public void foo() {}
public void foo(int a1) {}
public void foo(int a1, int a2) {}
public void foo(int a1, int a2, int a3) {}
public void foo(int a1, int a2, int a3, int... rest) {}
null이 아닌, 빈 컬렉션이나 배열을 반환하라
private final List<Cheese> chessesInStock = ...;
public List<Cheese> getCheeses() {
return chessesInStock.isEmpty() ? null : new ArrayList<>(cheesesInStock);
}
- 위 코드처럼
null을 반환하는 경우가 있다면, 클라이언트는null상황을 처리하는 코드를 추가로 작성해야 한다.
List<Cheese> cheeses = shop.getCheeses();
if (cheese != null && cheeses.contains(Cheese.STILTON)) {
System.out.println("That's right");
}
- 컬렉션이나 배열같은 컨테이너가 비었을 때 null을 반환하는 메서드를 사용하면 위와 같은 방어 코드를 항상 넣어줘야 한다.
빈 컬렉션 반환 예제
- 아래와 같이 빈 컬렉션이나 배열을 굳이 새로 할당하지 않고 반환할 수 있다.
public List<Cheese> getCheeses() {
return new ArrayList<>(cheesesInStock);
}
- 또는 항상 똑같은 빈 불변 컬렉션을 반환하는 방법도 있다.
Collections.emptyList나Collections.emptySet이 그 예다.
길이가 0인 배열 반환 예제
- 배열을 쓸 때도 절대 null을 반환하지 말고 길이가 0인 배열을 반환하는 것이 좋다.
public Cheese[] getCheeses() {
return cheesesInStock.toArray(new Cheese[0]);
}
- 길이 0짜리 배열을 미리 선언해두고 매번 그 배열을 반환하는 방법도 있다.
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
public Cheese[] getCheeses() {
return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
옵셔널 반환은 신중히 하라
- 자바 8 이전에는 메서드가 특정 조건에서 값을 반환할 수 없을 때 두가지 선택지가 있었다.
- 예외를 던지거나, null을 반환하는 것이다.
- 하지만 예외는 진짜 예외적인 상황에서만 사용해야 하며, 예외를 생성할 때 stack trace 전체를 캡쳐하므로 비용도 많이 든다.
- null을 반환하는 경우는 별도의 null 처리 코드를 추가해야 한다.
- 자바 8에서
Optional<T>이 생기면서null이 아닌T타입 참조를 하나 담거나, 혹은 아무것도 담지 않는 선택지가 생겼다. - Optional은 원소를 최대 1개 가질 수 있는 ‘불변’컬렉션이다.
Optional<T>이Collection<T>을 구현하지는 않았지만, 원칙적으로 그렇다는 의미다.
Optional의 사용
- 보통은
T를 반환해야 하지만 특정 조건에서는 아무것도 반환하지 않아야 할 때T대신Optional<T>를 반환하도록 선언하면 된다. - 옵셔널을 반환하는 메서드는 예외를 던지는 메서드보다 유연하고 사용하기 쉬우며, null을 반환하는 메서드보다 오류 가능성이 작다.
public static <E extends Comparable<E>> E max(Collection<E> c) {
if (c.isEmpty()) {
throw new IllegalArgumentException("빈 컬렉션");
}
E result = null;
for (E e : c) {
if (result == null || e.compareTo(result) > 0) {
result = Objects.requireNonNull(e);
}
}
return result;
}
- 위 메서드에 빈 컬렉션을 건네면
IllegalArgumentException을 던진다. - 이를 Optional을 반환하도록 수정하면 다음과 같다.
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {
if (c.isEmpty()) {
return Optional.empty();
}
E result = null;
for (E e : c) {
if (result == null || e.compareTo(result) > 0) {
result = Objects.requireNonNull(e);
}
}
return Optional.of(result);
}
- 적절한 정적 팩터리를 사용해 옵셔널을 생성해 반환해주기만 하면된다.
- 빈 옵셔널은
Optional.empty()로 만들고, 값이 든 옵셔널은Optional.of(value)로 생성한다. Optional.of(value)에 null을 넣으면NullPointerException을 던지니 유의하자.- null 값도 허용하는 옵셔널을 만들려면
Optional.ofNullable(value)
- 빈 옵셔널은
- 옵셔널을 반환하는 메서드에서는 절대 null을 반환하지 말자.
- 이는 옵셔널의 취지를 완전히 무시하는 것이다.
스트림과 옵셔널
- 스트림의 종단 연산 중 상당수가 옵셔널을 반환한다.
- 앞의
max메서드를 스트림버전으로 다시 작성한다면 다음과 같다.
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {
return c.stream().max(Comparator.naturalOrder());
}
Stream의max연산이 우리에게 필요한 옵셔널을 생성해준다.
옵셔널 활용
null을 반환하거나 예외를 던지는 대신 옵셔널 반환을 선택해야 하는 기준은 무엇일까?- 옵셔널은 검사 예외와 취지가 비슷하다. 즉, 반환값이 없을 수도 있음을 API 사용자에게 명확히 알려준다.
- 메서드가 옵셔널을 반환한다면 클라이언트는 값을 받지 못했을 때 취할 행동을 선택해야 한다.
활용 1 - 기본값 설정
String lastWordInLexicon = max(words).orElse("단어 없음");
-
기본값을 설정하는 비용이 커서 부담될 경우
Supplier<T>를 인수로 받는orElseGet을 사용하면, 값이 처음 필요할 때 생성하므로 초기 설정 비용을 낮출 수 있다.
활용 2 - 예외 던지기
Toy myToy = max(toys).orElseThrow(TemperTantrumException::new);
- 실제 예외가 아니라 예외 팩터리를 건넸다.
- 이렇게 하면 예외가 실제로 발생하지 않는 한 예외 생성 비용이 들지 않는다.
활용 3 - 값 바로 꺼내기
Element lastNobleGas = max(Elements.NOBLE_GASES).get();
- 옵셔널에 값이 채워져 있다고 확신한다면 곧바로 값을 꺼내 사용할 수 있다.
- 잘못 판단해 옵셔널에 값이 없다면
NoSuchElementException이 발생한다.
그 외 다양한 메서드
- 더 특별한 쓰임에 대비한 메서드들도 준비되어 있다.
isPresent
- 안전 밸브 역할의 메서드로, 옵셔널이 채워져 있으면 true를, 비어있으면 false를 반환한다.
isPresent를 쓴 코드 중 상당수는 앞서 언급한 메서드들로 대체할 수 있으며 더 명확하고 용법에 맞는 코드가 되기 때문에 신중히 사용하는게 좋다.
Optional<ProcessHandle> parentProcess = ph.parent();
System.out.println("부모 PID: " + (parentProcess.isPresent() ?
String.valueOf(parentProcess.get().pid()) : "N/A"));
map
- 위 코드는
map메서드를 사용하여 아래와 같이 다듬을 수 있다.
System.out.println("부모 PID" +
ph.parent().map(h -> String.valueOf(h.pid())).orElse("N/A"));
스트림에서의 활용
- 스트림을 사용한다면 옵셔널들을
Stream<Optional<T>>로 받아서 그 중 채워진 옵셔널들에서 값을 뽑아Stream<T>에 건네 담아 처리하는 경우도 있다.
streamOfOptionals
.filter(Optional::isPresent)
.map(Optional::get)
- 자바 9에서는
Optional에stream()메서드가 추가되었다.Optional을Stream으로 변환해주는 어댑터이다.- 옵셔널에 값이 있으면 그 값을 원소로 담은 스트림으로, 없다면 빈 스트림으로 변환한다.
- 이를
Stream의flatMap메서드와 조합하면 앞의 코드를 다음처럼 바꿀 수 있다.
streamOfOptionals.flatMap(Optional::stream)
그 외 유의사항
- 컬렉션, 스트림, 배열, 옵셔널 같은 컨테이너 타입은 옵셔널로 감싸면 안된다.
- 빈
Optional<List<T>>를 반환하기보다는 빈List<T>를 반환하는 게 좋다.
- 빈
- 결과가 없을 수 있으며, 클라이언트가 이 상황을 특별하게 처리해야 한다면
Optional<T>을 반환한다. - Optional도 엄연히 새로 할당하고 초기화해야하는 객체이고, 그 안에서 값을 꺼내려면 메서드를 호출해야 하니 한단계를 더 거치는 셈이다.
- 따라서 성능이 중요한 상황에서는 옵셔널이 맞지 않을 수 있다.
- 박싱된 기본 타입을 담는 옵셔널은 기본 타입 자체보다 무거울 수 밖에 없기 때문에 전용 옵셔널 클래스들이 존재한다.
OptionalInt,OptionalLongOptionalDouble이 그것이다.- 이렇게 대체재까지 있으니 박싱된 기본 타입을 담은 옵셔널을 반환하는 일은 없도록 한다.
- 옵셔널을 컬렉션의 키, 값, 원소나 배열의 원소로 사용하는게 적절한 상황은 거의 없다.
- 마찬가지로, 옵셔널을 인스턴스 필드에 저장해두는 것도 대부분 적절하지 않다.
- 즉, 옵셔널을 반환값 이외의 용도로 쓰는 경우는 매우 드물다.