elevne's Study Note

Effective Java CH8: 메소드 본문

Backend/Effective Java

Effective Java CH8: 메소드

elevne 2023. 7. 28. 02:11

아이템 49. 매개변수가 유효한지 검사하라

 

메소드와 생성자 대부분은 입력 매개변수의 값이 특정 조건을 만족하기를 바란다. 이런 제약은 반드시 문서화해야 하며, 메소드 몸체가 시작되기 전에 검사해야 한다.

 

 

/*
* (현재 값 mod m) 값을 반환한다. 이 메소드는
* 항상 음이 아닌 BigInteger 을 반환한다는 점에서 remainder 메소드와 다르다
*
* @param m 계수 (양수여야 한다.)
* @return 현재 값 mod m
* @throws ArithmeticException m 이 0 보다 작거나 같으면 발생한다.
* */
public BigInteger mod(BigInteger m) {
    if (m.signum() <= 0) throw new ArithmeticException("계수(m)는 양수여야 합니다. " + m);
    return null;
}

 

 

그런데 위 메소드는 m 이 null 이면 m.signum() 메소드 호출 시 NullPointerException 이 발생한다. 위에는 m 이 null 일 때 NullPointerException 을 던진다는 말은 없는데 말이다. 그 이유는 이 설명을 BigInteger 클래스 수준에서 기술했기 때문이다. 클래스 수준 주석은 그 클래스의 모든 public 메소드에 적용되므로 각 메소드에 일일이 기술하는 것보다 훨씬 깔끔한 방법이다.

 

 

자바 7에서 추가된 java.util.Objects.requireNonNull 메소드는 유연하고 사용하기 편하다. 자바 9에서는 Objects 에 범위 검사를 할 수 있는 checkFromIndexSize, checkFromToIndex, checkIndex 라는 메소드들도 추가되었다.

 

 

public 이 아닌 메소드라면 assert 를 사용해 매개변수 유효성을 검증할 수도 있다. public 메소드에서는 파라미터의 값이 잘못된 경우에는 메소드에 알맞은 의미를 나타내지 않는 AssertionError 보다는 IllegalArgumentException 과 같이 알맞은 의미를 갖는 예외를 발생시키는 것이 좋다.

 

 

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;
    System.out.println("PASS");
}

 

 

public static void main(String[] args) {
    long[] a = {1, 2, 3};
    int offset = 1;
    int length = -1;
    sort(a, offset, length);
}

 

 

result

 

 

assert 를 사용하기 위해서는 vm argument -ea 를 추가해줘야 한다.

 

 

-ea

 

 

단언문은 몇 가지 면에서 일반적인 유효성 검사와 다르다. 첫 번째로, 실패하면 AssertionError 을 던진다. 두 번째로, 런타임에 아무런 효과도 아무런 성능저하도 없다. (단, java 실행 시 명령줄에서 -ea 혹은 -enableassertions 플래그를 설정하면 영향을 준다)

 

 

메소드가 직접 사용하지는 않으나 나중에 쓰기 위해 저장하는 매개변수는 특히 더 신경 써서 검사해야 한다. 생성자는 이 원칙의 특수한 사례이다. 생성자 매개변수의 유효성 검사는 클래스 불변식을 어기는 객체가 만들어지지 않게 하는 데 꼭 필요하다.

 

 

 

아이템 50. 적시에 방어적 복사본을 만들라

 

어떤 객체든 그 객체의 허락 없이는 외부에서 내부를 수정하는 일은 불가능하다. 하지만 주의를 기울이지 않으면 내부를 수정하도록 허락하는 경우가 생긴다.

 

 

public 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 DATE IS LATER THAN END DATE");
        this.start = start;
        this.end = end;
    }
    ...

 

 

public static void main(String[] args) {
    Date start = new Date();
    Date end = new Date();
    Period p = new Period(start, end);
    end.setYear(99);
}

 

 

Date 가 가변이라는 사실을 이용하면 어렵지 않게 위와 같이 불변식을 깨뜨릴 수 있다. (물론 java8 이후로는 Data 대신 Instant, LocalDateTime, ZonedDateTime 을 사용하면 된다) 위와 같이 Date 를 사용하는 상황이라면 생성자에서 받은 가변 매개변수 각각을 방어적으로 복사해야 한다. 그런 다음 Period 인스턴스 안에서는 원본이 아닌 복사본을 사용하는 것이다.

 

 

public Period(Date start, Date end) {
    this.start = new Date(start.getTime());
    this.end = new Date(start.getTime());
    if (this.start.compareTo(this.end) > 0) throw new IllegalArgumentException("START DATE IS LATER THAN END DATE");
}

 

 

위 생성자는 매개변수의 유효성을 검사하기 전에 방어적 복사본을 만들고, 이 복사본으로 유효성을 검사한다. 순서가 부자연스러워 보여도 반드시 이렇게 작성해야 한다. 멀티스레딩 환경이라면 원본 객체의 유효성을 검사한 후 복사본을 만드는 그 찰나의 취약한 순간에 다른 스레드가 원본 객체를 수정할 위험이 있기 때문이다.

 

 

또, 위에서는 clone 메소드를 사용하지 않았다. Date 는 final class 가 아니므로 clone 이 Date 가 정의한 것이 아닐 가능성이 있기 때문이다. 매개변수가 제 3자에 의해 확장될 수 있는 타입이라면 방어적 복사본을 만들 때 clone 을 사용해서는 안된다.

 

 

생성자를 수정하면 앞선 공격을 막을 수는 있지만, Period 인스턴스는 아직도 변경 가능하다. 접근자 메소드가 내부의 가변정보를 직접 드러내기 때문이다. 접근자에서도 가변 필드의 방어적 복사본을 반환해야 한다. 이 때에는 Period 가 가지고 있는 Date 객체가 java.util.Date 임이 확실하기에 clone 메소드를 사용해도 된다.

 

 

public Date start() { return (Date) start.clone(); }
public Date end() { return (Date) end.clone(); }

 

 

 

아이템 51. 메소드 시그니처를 신중히 설계하라

 

메소드 이름은 신중히 짓는다: 항상 표준 명명 규칙을 따른다 (아이템 68)

 

편의 메소드를 너무 많이 만들지는 않는다: 메소드가 너무 많은 클래스는 익히고, 사용하고, 문서화하고, 테스트하고, 유지보수하기 어렵다.

 

매개변수 목록은 짧게 유지한다: 4 개 이하가 좋다. 같은 타입의 매개변수 여러 개가 연달아 나오는 경우가 특히 해롭다. 과하게 긴 매개변수 목록을 짧게 줄여주는 기술 세 가지가 있다.

 

  1. 여러 메소드로 쪼개기
  2. 매개변수 여러 개를 묶어주는 도우미 클래스 만들기
  3. 객체 생성에 사용한 빌더 패턴을 메소드 호출에 응용하기 (모든 매개변수를 하나로 추상화한 객체를 정의하고 클라이언트에서 이 객체의 세터 메소드를 호출해 필요한 값을 설정하게 하는 것)

매개변수의 타입으로는 클래스보다는 인터페이스가 낫다 (+boolean 보다는 원소 2개짜리 열거 타입이 낫다)

 

 

 

아이템 52. 다중정의는 신중히 사용하라

 

public class CollectionClassifier {

    public static String classify(Set<?> s) {
        return "SET";
    }
    public static String classify(List<?> l) {
        return "LIST";
    }
    public static String classify(Collection<?> c){
        return "COLLECTION";
    }

    public static void main(String[] args) {
        Collection<?>[] collections = {
                new HashSet<>(),
                new ArrayList<>(),
                new HashMap<String, String>().values()
        };
        for (Collection<?> c : collections) {
            System.out.println(classify(c));
        }
    }

}

 

 

result

 

 

SET, LIST, COLLECTION 을 차례로 출력하지 않고 COLLECTION 만 3 번 출력한다. 이처럼 직관과 어긋나는 이유는 오버라이드 한 메소드는 동적으로 선택되고, 오버로드한 메소드는 정적으로 선택되기 때문이다.

 

 

API 사용자가 매개변수를 넘기면서 어떤 다중정의 메소드가 호출될지를 모를다면 프로그램이 오작동하기 쉽다. 런타임에 이상하게 행동할 것이며 사용자들은 문제를 찾느라 시간을 많이 허비하게 된다. 안전하고 보수적으로 다중정의를 하려면 매개변수가 같은 다중정의는 만들지 않는다. 다중정의하는 대신 메소드 이름을 다르게 지어주는 방법도 열려있다. ObjectOutputStream 클래스는 writeBoolean, writeInt, writeLong 과 같이 전부 다른 이름을 붙여주었다. 한 편, 생성자는 이름을 다르게 지을 수 없으니 두 번째 생성자부터는 무조건 다중정의가 된다. 하지만 정적 팩토리라는 대안을 사용할 수 있는 경우가 많다.

 

 

자바 4 까지는 모든 기본 타입이 참조 타입과 근본적으로 달랐지만, 자바 5에서 오토박싱에 도입되며 다중정의가 헷갈리게 되었다.

 

 

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);
    }
    System.out.println(set + " " + list);
}

 

 

result

 

 

두 컬렉션의 결과가 다르게 나온다. set.remove(i) 의 시그니처는 remove(Object) 이다. 다중정의된 다른 메소드가 없으니 기대한 대로 동작하여 집합에서 0 이상의 수들을 제거한다. 한편, list.remove(i) 는 다중정의된 remove(int index) 를 선택한다. 그런데 이 remove 는 지정한 위치의 원소를 제거하는 기능을 수행한다. 이는 list.remove 의 인수를 Integer 로 형변환하여 올바른 다중정의 메소드를 선택하게 하면 해결된다. 혹은 Integer.valueOf() 를 이용해 i 를 Integer 로 변환한 후 list.remove 에 전달해도 된다. 자바 4 까지의 List 에서는 Object 와 int 가 근본적으로 달라서 문제가 없었다. 그런데 제네릭과 오토박싱이 등장하며 두 메소드의 매개변수 타입이 더는 근본적으로 다르지 않게 되었다.

 

 

자바 8 에서 도입한 람다와 메소드 참조 역시 다중정의 시의 혼란을 키웠다.

 

 

public static void main(String[] args) {

    new Thread(System.out::println).start();

    ExecutorService exec = Executors.newCachedThreadPool();
    exec.submit(System.out::println);

}

 

 

위 두 코드는 비슷해 보이지만, 아래 쪽의 코드는 컴파일 오류가 발생한다. 넘겨진 인수는 모두 System.out::println 으로 동일하고 양쪽 모두 Runnable 을 받는 형제 다중정의한다. 하지만, submit 다중정의 메소드 중에는 Callable<T> 를 받는 메소드가 있다. 모든 println 이 void 를 반환하니 반환값이 있는 Callable 과 헷갈릴 이유가 없다고 생각할 수 있지만, 다중정의 해소(적절한 다중정의 메소드를 찾는 알고리즘)는 이렇게 동작하지 않는다. 만약 println 이 다중정의 없이 단 하나만 존재했다면 submit 메소드는 제대로 컴파일됐을 것이다. 메소드를 다중정의할 때 서로 다른 함수형 인터페이스더라도 같은 위치의 인수로 받아서는 안된다. (서로 다른 함수형 인터페이스더라도 서로 근본적으로 다르지는 않다)

 

 

 

아이템 53. 가변인수는 신중히 사용하라

 

가변인수 (varargs) 메소드는 명시한 타입의 인수를 0 개 이상 받을 수 있다. 만약 인수가 1 개 이상이어야 한다면 인수 개수에 따라 예외를 던지게 할 수 있을 것이다. 하지만 이는 컴파일 타임이 아닌 런타임에 실패한다는 단점, 코드가 너무 지저분하다는 단점을 가진다. 이보다 더 나은 방법으로, 매개변수를 2 개 받도록 할 수 있다.

 

 

static int min(int firstArg, int... remainingArgs) {
    int min = firstArg;
    for (int arg : remainingArgs) {
        if (arg < min) min = arg;
    }
    return min;
}

 

 

그런데 성능에 민감한 상황이라면 가변인수가 걸림돌이 될 수 있다. 가변인수 메소드는 호출될 때마다 배열을 새로 하나 할당하고 초기화한다. 이 비용을 감당할 수는 없지만 가변인수의 유연성이 필요할 때는, 인수가 0 개인 것부터 4 개인 것까지 총 5 개를 오버로드하면 된다. (마지막 메소드는 가변인수를 사용) EnumSet 의 정적팩토리도 이 기법을 사용해 열거타입 집합 생성 비용을 최소화한다.

 

 

 

아이템 54. null 이 아닌, 빈 컬렉션이나 배열을 반환하라

 

만일 메소드가 특정 상황에서 null 을 반환한다면, 클라이언트는 이 null 을 처리하는 코드를 추가로 작성해야 한다. 컬렉션이나 배열 같은 컨테이너가 비었을 때 null 을 반환하는 메소드를 사용할 때면 항시 그러한 작업을 해줘야 하는 것이다.

 

 

때로는 빈 컨테이너를 할당하는 데도 비용이 드니 null 을 반환하는 쪽이 낫다는 주장도 있다. 하지만 이는 두 가지 면에서 틀렸다고 한다. 첫 번째로 성능 저하의 주범이라고 확인되지 않는 한 이 정도의 성능 차이는 신경 쓸 수준이 못 된다. 두 번째로, 빈 컬렉션과 배열은 굳이 새로 할당하지 않고도 반환할 수 있다. Collections.emptyList, emptySet, emptyMap 을 사용하면 된다. 배열의 경우에는 미리 [0] 배열을 static final 로 생성해두고 이를 넘겨줄 수 있다.

 

 

 

아이템 55. 옵셔널 반환은 신중히 하라

 

자바 버전 8 부터, 메소드가 특정 조건에서 값을 반환할 수 없을 때 취할 수 있는 선택지로 Optional<T> 를 사용하는 방법이 추가되었다. Optional<T> 는 null 이 아닌 T 타입 참조를 하나 담거나, 혹은 아무것도 담지 않을 수 있다. 보통은 T 를 반환해야 하지만, 특정 조건에서는 아무것도 반환하지 않아야 할 때 T 대신에 Optional<T> 를 반환하도록 선언하면 된다. 그러면 유효한 반환값이 없을 때는 빈 결과를 반환하는 메소드가 만들어진다. 옵셔널을 반환하는 메소드는 예외를 던지는 메소드보다 유연하고 사용하기 쉬우며, null 을 반환하는 메소드보다 오류 가능성이 적다.

 

 

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);
}

 

 

null 값도 허용하는 옵셔널을 만들려면 Optional.ofNullable(value) 를 사용할 수 있으니 옵셔널을 반환하는 메소드에서는 절대 null 을 반환하지 않는다.

 

 

스트림의 종단 연산 중 상당 수가 옵셔널을 반환하다고 한다.

 

 

public static <E extends Comparable<E>> Optional<E> max2(Collection<E> c) {
    return c.stream().max(Comparator.naturalOrder());
}

 

 

그렇다면 null 을 반환하거나 예외를 던지는 대신 옵셔널 반환을 선택해야 하는 기준은 무엇일까? 옵셔널은 검사 예외와 취지가 비슷하다고 한다. 즉, 반환 값이 없을 수도 있음을 API 사용자에게 명확히 알려주는 것이다.

 

 

어느 메소드가 옵셔널을 반환한다면, 그 메소드를 사용하는 클라이언트는 값을 받지 못했을 때 취할 행동을 선택해야 한다.

 

 

public static void optionalEg(List<String> words) {
    // 기본값 설정
    String lastWordInLexicon = max(words).orElse("NO WORD");
    // 값이 없을 때 원하는 예외 던지기
    String lastWordInLexicon2 = max(words).orElseThrow(RuntimeException::new);
    // 항상 값이 있다고 가정
    String lastWordInLexicon3 = max(words).get();
}

 

 

간혹 기본값을 설정하는 비용이 커서 부담이 된다면, orElse() 대신 Supplier<T> 를 인수로 받는 orElseGet() 메소드를 사용할 수 있다. 값이 처음 필요할 때 Supplier<T> 를 사용해 생성하므로 초기 생성 비용을 낮출 수 있다.

 

 

isPresent() 메소드는 옵셔널이 채워져있으면 true, 아니면 false 를 반환한다. 이 메소드로 원하는 모든 작업을 수행할 수 있긴 하지만, isPresent 로 쓴 코드 중 상당 수는 앞서 사용된 다른 메소드로 대체할 수 있기에 신중히 사용해야 한다.

 

 

스트림을 사용한다면 옵셔널들을 Stream<Optional<T>> 로 받아서, 그 중 채워진 옵셔널들에서 값을 뽑아 Stream<T> 이 건네담아 처리하는 경우가 드물지 않다.

 

 

public static <T> List<T> stream(List<Optional<T>> list) {
    return list.stream()
                .filter(Optional::isPresent)
                .map(Optional::get)
                .collect(Collectors.toList());
}

 

 

자바 9 에서는 Optional 에 stream 메소드가 추가되었다. 이 메소드는 Optional 을 stream 으로 변환해준다. 옵셔널에 값이 있으면 그 값을 원소로 담은 스트림으로, 값이 없다면 빈 스트림으로 변환한다. 이를 Stream 의 flatMap 메소드와 조합하여 아래와 같이 응용할 수 있다.

 

 

public static <T> List<T> stream2(List<Optional<T>> list) {
    return list.stream()
            .flatMap(Optional::stream)
            .collect(Collectors.toList());
}

 

 

반환값으로 옵셔널을 사용하는게 무조건 좋은 것은 아니다. 컬렉션, 스트림, 배열, 옵셔널 같은 컨테이너 타입은 옵셔널로 감싸서는 안된다고 한다. 예를 들어 빈 Optional<List<T>> 를 반환하는 것보다는 빈 List<T> 를 반환하는 게 좋다는 것이다.

 

 

옵셔널은 결과가 없을 수 있으며, 클라이언트가 이 상황을 특별하게 처리해야 하는 경우에 사용한다. 그런데 만약 박싱된 기본 타입을 담는 옵셔널을 사용한다면, 이는 값을 두 번 감싸는 것이기에 비용이 더 늘어난다. 그래서 자바 API 설계자는 int, long, double 전용 옵셔널 클래스 OptionalInt, OptionalLong, OptinoalDouble 클래스를 준비해두었다. 박싱된 기본 타입 옵셔널을 반환하지 않고 위 클래스를 사용하는게 좋다.

 

 

 

아이템 56. 공개된 API 요소에는 항상 문서화 주석을 사용하라

 

Javadoc 유틸리티를 사용하여 소스코드 파일에서 문서화 주석이라는 특수한 형태로 설명을 추려 API 문서로 변환할 수 있다.

 

 

API 를 문서화하려면 공개된 모든 클래스, 인터페이스, 메소드, 필드 선언에 문서화 주석을 달아야한다. 직렬화할 수 있는 클래스라면 직렬화 형태에 대해서도 적는다. 메소드용 문서화 주석에는 해당 메소드와 클라이언트 사이의 규약을 명료하게 기술한다. 해당 메소드가 무엇을 하는지, 해당 메소드를 호출하기 위한 전제조건, 성공적으로 수행한 후 만족해야 하는 사후조건들을 모두 나열한다.

 

 

 

 

 

 

 

 

Reference:

Effective Java