반응형

1. 개요

 자바 8 이전에는 빈 값에 대한 처리 선택지가 '예외' 혹은 'null 반환'이었다. 이 둘 모두 허점이 있다.

예외는 정말 예외적인 상황에서만 사용해야한다는 것과(빈 값이라고 예외를 날려서는 안될 케이스도 있다. ex. 검색결과)

예외를 생성할 때 스택 추적을 하므로 이에 대한 비용 문제가 있다. null을 반환하면 위 문제가 생기지 않지만 언젠가, 그리고 어디선가 NullPointException 이 발생하여 시스템 버그를 초래할 수 있다. 그런데 자바 8 이후 또 하나의 선택지가 생겼다. 그게 바로 Optional<T> 이다. 

 


2. Optional<T> 이란?

 옵셔널은 1개의 null이 아닌 T 타입 객체를 담거나, 아무것도 담지 않을 수 있는 불변 컬렉션이다. 옵셔널을 사용하지 않는다면 보통 T를 반환할테지만 상황에 따라 아무것도 반환하지 않아야 할때가 있다면 T 대신 Optional<T> 를 반환하도록 선언하면 된다. 옵셔널을 반환하는 메서드는 예외를 던지는 메서드보다 유연하고 사용하기 쉬우며, null을 반환하는 메서드보다 오류 가능성이 낮다.

 


3. max 값을 구하는 예제

 

최댓값을 구하는 메서드이다. 파라미터로 들어온 Collection의 요소가 없을 경우 예외를 발생시키고 있다.

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

 

 

이처럼 예외 처리가 메서드 내에 강하게 결합되어 있기 때문에 여러가지 한계를 맞이하게 된다. 이를 호출하는 클래스의 상황에 따라 예외 메시지를 다르게 하고싶을 수도, 예외를 발생시키지 않을 수도 있지 않은가? 하지만 이 방식은 그게 쉽지않다.

예외 메시지를 다르게 하고싶다면 클라이언트는 이 런타임 예외가 발생한다는 것을 알고 있어야하고, 이 예외에 대해 외부에서 catch 문에서 예외 전환 로직을 작성해야한다. 원치않는 결합도가 생겨버렸다.

예외를 발생시키고 싶지 않다면 마찬가지로 catch 문에서 예외를 잡고 아무것도 수행하지 않도록 설정해야한다. 이상하다.

 

이를 Optional 로 구현해보면 어떨까?

public static <E extends Comparable<E>> Optional<E> max2(Collection<E> c){
    if(c.isEmpty())
        return Optional.empty(); // 빈 옵셔널을 반환함으로써 클래스 외부에서 예외를 발생시킬 수도, 시키지 않을 수도 있다. Null도 아니다!

    E result = null;
    for(E e: c)
        if(result == null || e.compareTo(result) > 0)
            result = Objects.requireNonNull(e);

    return Optional.of(result); // result 를 참조하는 Optional 타입을 반환한다.
}

 

 

빈 옵셔널은 Optional.empty(), 값이 든 옵셔널은 Optional.of(value)로 처리한게 끝이다. 이 방식은 위 방식과는 다르게 호출한 클라이언트에서 Optional 값에 대한 처리가 가능하다. 원하는 예외메시지를 발생시킬수도, 발생시키지 않을수도 있고, 더 나아가 기본 값을 넣을 수도 있다. 앞선 코드보다 훨씬 유연하고 깔끔한 것을 볼 수 있다.

Integer maxValue = max2(list).orElse(0); // 빈 옵셔널일 경우 0으로 설정

//클라이언트에 따른 예외 메시지 변경
Integer maxValue2 = max2(list).orElseThrow(() -> new IllegalArgumentException("요청 리스트가 비어있습니다."));

 

 

참고로 Optional.of(value)에 null을 넣으면 NPE가 발생하므로 주의해야한다. 또한 옵셔널을 반환하는 메서드는 절대 null을 반환하면 안된다. 옵셔널을 도입한 취지를 저버리는 행위이기 때문이다.

 


4. Optional 을 사용해야하는 기준

그렇다면 null, 예외를 던지는 대신 옵셔널을 선택하는 기준은 뭘까? 옵셔널은 검사 예외와 취지가 비슷하다. 즉, 반환 값이 없을 수도 있음을 API 사용자에게 명확히 알려준다. 반환 값이 없을 경우에 대한 처리를 사용자가 작성해야하므로 null 보다 안전하고 깔끔하게 처리할 수 있다.

 

클라이언트 입장에서 이를 사용한다면 클라이언트는 옵셔널에 대한 처리를 클라이언트 코드에서 선택하면 된다.

1) 기본 값 설정

int maxValue = max2(list).orElse(0);  // 기본 값 설정

 

2) 원하는 예외 설정

// 원하는 예외 설정
String maxString = max2(wordList).orElseThrow(() -> new RuntimeException("단어 리스트가 비어있습니다. 리스트를 확인해주세요"));

 

3) 항상 값이 유효하다고 가정할때 설정

int value = max2(list).get(); // 항상 값이 있다고 가정하고 get!

 

 

이 외에도 filter, map, ifPresent 등 다양한 메서드들을 지원하고 있다. 앞선 기본 메서드로 처리가 힘들다면 API 문서를 참조해 문제를 해결해줄 수 있는 메서드가 있는지 찾아보자. 만약 적합한 메서드를 찾지 못했다면 isPresent 메서드를 활용할 수 있다. isPresent 메서드는 가스레인지의 안전벨브 역할로, 옵셔널 값이 채워져있다면 true, 비어있다면 false를 리턴한다. 

 

자바 9 버전부터 지원하는 Optional.map 메서드를 사용해 원하는 타입을 갖는 Optional 객체로 변환할 수도 있다. 아래는 Integer 타입이 들어있는 최대값을 받아 String 타입으로 변환한다. 값이 없을 경우에 대해 "N/A" 값이 반환되도록 orElse 체인 메서드로 처리했다.

String res = max2(list).map(val -> Integer.toString(val)).orElse("N/A");

 

 

참고로 이를 지원하지 않는 자바 8 버전의 경우 아래와 같이 작성할 수 있다. isPresent로 필터링 후 get으로 빼온다.

streamOfOptionals
    .filter(Optional::isPresent)
    .map(Optional::get)

 

 

자바 9에서는 Optional 에 stream() 메서드가 추가되었다. Optional을 stream으로 변환해주는 어댑터다. 옵셔널에 값이 있으면 그 값을 원소로 담은 스트림으로 한단계 벗겨주는 것이다. 어찌됐든 이런 저런 메서드들을 많이 지원하니 찾아보는 것을 권장한다.

 


5. 반환값으로 옵셔널을 사용했을때의 단점

결합도를 낮추고 유연성과 코드의 깔끔함을 더해주지만 구조상 단점이 있다. 객체를 박싱하는 옵셔널 특성 상 박싱하고자 하는 객체가 많을수록 Optional 객체를 생성하고 박싱하는 비용이 발생한다. 언박싱할때도 비용이 발생하는 건 마찬가지이다. 때문에 리스트, 스트림, 배열 등에 사용할 요소들에 대해 별 생각없이 Optional 을 사용한다면 성능 측면에서 문제가 발생할 수 있다. 성능이 중요한 상황에서는 옵셔널을 사용하지 않는 것이 좋다.

 


6. 기본 타입을 담는 옵셔널도 있다.

int, long, double 과 같은 기본 타입에 대해 Optional 을 사용할 땐 Integer, Long, Double 과 같은 참조 타입으로 박싱하여 사용하지 말고 OptionalInt, OptionalLong, OptionalDouble 과 같은 옵셔널 타입을 사용하자. 박싱된 기본 타입을 담은 옵셔널을 반환하는 일은 없도록 해야한다.

 


7. 정리

반환 값이 없을 수도 있는 메서드라면 옵셔널 사용을 고려해야한다. 하지만 성능 저하가 뒤따르니, 성능에 민감한 메서드라면 null을 반환하거나 예외를 던지는 편이 나을 수 있다.

반응형
반응형

개요

메서드 시그니처를 신중히 설계하라. 개별 아이템으로 두기 애매한 API 설계 요령들을 정리한 아이템이다.


1. 메서드 이름을 신중히 짓자

메서드 명은 표준 명명 규칙을 따라야 한다. 아래는 점프 투 자바에서 제공한 명명 규칙이다.

https://wikidocs.net/1936

 

02-03 이름 짓는 규칙

자바 코드를 작성하면서 클래스, 메서드, 변수 등의 이름을 지을 때 개발자들이 가장 고민한다. 하지만 규칙을 알아 두면 부담을 크게 줄일 수 있다. 여러 사람이 프로그래밍할 때 …

wikidocs.net

 

첫째, 메서드 명은 동사로 한다.

둘째, 메서드 명은 소문자로 시작한다.

셋째, 카멜케이스 방식을 사용한다.

 

이 사항을 기본적으로 지키면서 이해하기 쉽고 일관되게 네이밍을 지어야 한다.

실제로 협업 간에는 변수, 클래스, 메서드 등에 대한 네이밍 컨벤션을 미리 정리해놓고 사용하기도 한다. 


2. 편의 메서드를 너무 많이 만들지 말자

편의메서드
편의를 위한 메서드로 클래스의 책임에 충실한 클래스와는 거리가 멀다는 특징이 있다.
예를들어 게시글을 CRUD 하는 BoardService 클래스에 max, min, extractString 과 같이 편의를 위한 메서드가 있다면 편의 메서드로 볼 수 있다.

 

메서드가 너무 많은 클래스는 유지보수가 어렵다. 인터페이스도 마찬가지이다. 아주 자주 쓰일 경우에만 별도의 약칭 메서드를 만들고, 그 외에는 편의 메서드를 만들지 않는게 좋다.

 


3. 매개변수 목록은 짧게 유지하자

매개변수는 4개 이하가 적당하다. 만약 긴 매개변수 목록을 유지해야한다면 아래 내용 적용을 고려해야한다.

 

첫째, 여러 메서드로 쪼갠다.

둘째, 매개변수 여러 개를 묶어주는 정적 멤버 클래스를 만들어 활용한다.

셋째, 빌더 패턴과 정적 멤버클래스를 함께 사용한다. 매개변수가 많거나 일부를 생략해도 될때 좋다.

 


4. 매개변수의 타입으로는 클래스보다 인터페이스를 사용하자

예를들어 HashMap을 매개변수로 사용하는 대신 Map을 사용할 경우 HashMap 뿐만 아니라 TreeMap, ConcurrentHashMap 등 다양한 타입의 구현체 클래스를 전달할 수 있다.

인터페이스 대신 클래스를 사용하면 클라이언트에게 특정 구현체만 사용하도록 제한하는 꼴이다.

 


5. boolean 보다는 원소 2개짜리 열거 타입이 낫다.

* 단, 메서드 이름상 boolean을 받아야 의미가 더 명확할 때는 예외다.

 

열거 타입을 사용하면 코드를 읽고 쓰기가 쉽고, 확장하기도 용이하다.

다음은 화씨온도와 섭씨온도를 원소로 정의한 열거타입이다.

public enum TemperatureScale { FAHRENHEIT, CELSIUS }

 

온도계 클래스의 정적 팩터리 메서드가 이 열거 타입을 입력받아 적합한 온도 인스턴스를 생성해준다고 생각해보자. Thermometer.newInstance(true) 보다는 Thermometer.newInstance(TemperatureScale.CELSIUS) 가 명확하다. 만약, 나중에 캘빈 온도도 지원해야 한다면 열거타입에 추가만 하면 된다.

반응형
반응형

 

자바 방패를 들고 있는 아기 바다표범

 

개요

자바는 C, C++ 에 비해 메모리 관리 측면에서 안전하다. 또 자바로 작성한 클래스는 불변식이 지켜진다. 개발자가 원한다면 어떤 객체 안의 멤버필드를 특정 조건을 만족하도록 클래스를 설계하고, 불변성을 갖도록 할 수 있다.

하지만 아무런 노력 없이 이러한 클래스를 만들 수 있는건 아니다. 프로그래머의 실수로 인해 의도하지 않는 변경이 일어날 수 있다. 외부에 의해 불변식이 깨질 수도 있다는 뜻이다.

 

일반적으로 객체를 생성할 때 수정자와 같은 장치가 없다면 외부에서 내부를 수정하는 것을 막아놨음을 의미한다. 하지만 자기도 모르게 내부를 수정하도록 허락하는 경우가 생긴다.


불변식을 지키고 싶었던 클래스

반응형

Period 클래스를 통해 객체를 생성할 경우 end 값이 start 보다 느리도록 설정된다. 유효성 검사도 하기때문에 문제가 없어보이지만 start나 end 값을 변경하여 불변식을 깨트릴 수 있다.

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 start = new Date();
Date end = new Date();

Period p = new Period(start, end);

// start 와 end 는 변경되....엔다
start.setTime(10);

 

외부 클래스에 의해 불변식이 깨져버렸다.

위와 같이 변경이 가능한 이유는 Date 클래스가 가변 객체이기 때문이다.

 

가변객체
클래스의 인스턴스가 생성된 이후 내부 상태 변경이 가능한 객체

 


Date 는 Instant or LocalDateTime 을 사용하자

이와 같은 문제는 자바 8에서 해결됐다. Date 대신 사용 가능한 불변 객체인 Instant나 LocalDateTime이 등장했기 때문이다. 가변적 성질을 가진 Date 사용은 지양하자.

그럼 모든 멤버필드를 불변객체로 사용하는건 어떨까??

 


모든 멤버필드를 불변객체로 사용하는 건 좀...

 위와 같이 불변식을 지키려고 모든 멤버필드를 불변객체로 사용하는 건 힘.들.다. 멤버필드는 충분히 가변 객체일 수도, 커스텀 클래스 타입일 수도 있는데, 이러한 객체를 모두 불변객체로 대체하거나 만드려면 너모 힘들기 때문이다.

그럼 Date 와 같은 가변객체를 사용하는 상황에서 Period 인스턴스의 내부를 보호하려면 어떻게 해야할까?

바로 방어적 복사, defensive copy 를 사용해야 한다.

 

다시금 자바 방패를 들고 있는 아기 바다표범

 

 


Defensive Copy 를 적용하여 인스턴스 내부 보호하기

 

Defensive Copy는 인스턴스 내부 필드를 설정할 때, 파라미터로 온 값을 그대로 할당하는 게 아닌 복사하는 방법이다.

아래와 같이 매개변수로 받은 가변객체에 대해 똑같은 타입의 새로운 객체 생성(복사)한 후 멤버필드에 할당하고 있다.

public final class Period {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end){

        this.start = new Date(start.getTime()); // defensive copy
        this.end = new Date(end.getTime()); // defensive copy

        if(start.compareTo(end) > 0){
            throw new IllegalArgumentException(start +" 가 "+ end +" 보다 늦다.");
        }
    }

    public Date start(){
        return start;
    }

    public Date end(){
        return end;
    }
}

 

 

불변식을 헤쳤던 코드를 적용해보니 아래의 start와 Period 내부에 생성된 start는 엄연히 다른 인스턴스이므로 불변식 지킬 수 있게 되었다.

public static void main(String[] args) {

    Date start = new Date();
    Date end = new Date();

    Period p = new Period(start, end);

    // start는 변경되지 않는다!!
    start.setTime(10);
}

 

 

 

라고 생각했지만 가변 객체는 가변 객체. 어떻게든 멤버필드를 외부에서 가져간다면, 불변식은 깨지게 된다. 아래처럼 말이다.

// start 는 변경된다.
p.start().setTime(10);

 

 

이런 상황에서 불변성을 지키려면 어떻게 해야할까? 멤버 필드에 할당할 때와 마찬가지로, 멤버 필드를 리턴할 때도  defensive copy 를 적용하면 된다. 이로써 Period 인스턴스는 불변식을 지키게 되었다.

public Date start(){
        return new Date(start.getTime());
    }

    public Date end(){
        return new Date(end.getTime());
    }
}

 


방어적 복사본을 유효성 검사 이전에 하자!

 

눈치챘을 지 모르지만 위 코드에서 특이한 부분이 하나 있다. 유효성 검사보다 방어적 복사본을 만드는 코드가 먼저 위치한다. 유효성 검사를 먼저하는게 당연하다 생각할 수 있지만 이유가 있다.

public Period(Date start, Date end){

    this.start = new Date(start.getTime()); // 방어적 복사 먼저
    this.end = new Date(end.getTime()); // 방어적 복사 먼저

    if(start.compareTo(end) > 0){ // 그 다음 유효성 검사
        throw new IllegalArgumentException(start +" 가 "+ end +" 보다 늦다.");
    }
}

 

 

 

이유?!

유효성 검사를 먼저 할 경우 멀티스레드 환경에서 불변식을 헤치는 상황이 발생할 수 있기 때문이다.

유효성 검사를 끝낸 직후 방어적 복사를 하기 전에 다른 스레드에서 파라미터로 들어온 가변객체의 값을 변경이라도 한다면 불변식이 깨져버린다. 

유효성 체크를 먼저하다가 불변식이 깨져버린 상황

 

 

만약 방어적 복사를 유효성 검사보다 선행한다면 어떨까? start.setTime(23) 이 호출되어도 인스턴스 내부에 영향을 미치지 않게 되는 것이다.


방어적 복사의 사용 시기

 

매개변수를 방어적으로 복사하는 목적은 외부로부터 들어오는 매개변수가 가변객체이고, 이를 인스턴스 내부에서 갖게 될때  인스턴스의 불변식을 지키기 위함이다. 인스턴스 내에 멤버필드를 추가할 때에는 '변경될 수 있는 멤버필드인가'를 항상 고려해야한다. 변경되도 상관 없다면 방어적 복사를 할 필요가 없지만, 불변식을 가져야 한다면 방어적 복사를 사용해야 한다. 멤버필드의 불변성을 확신할 수 없을 때도 방어적 복사를 사용하는 것이 좋다.

 


되도록 불변 객체를 조합해 객체를 구성하자

 

불변식을 지키기 위한 코드를 작성하려면 생각보다 많은 고민과 시간을 쏟아야 할것 같지 않은가? 또한 방어적 복사는 멤버필드를 새로 생성하기 때문에 성능 저하가 수반된다. 즉, 불변식을 지키는 클래스를 설계할 때에는 가변 객체보다는 불변 객체를 조합해 구성하는 것이 좋다.

 


정리

불변식을 지키고자 하는 클래스가 매개변수로 받거나 반환하는 멤버필드가 가변객체라면 반드시 방어적 복사(defensive copy)를 해야한다. 클래스 설계시 의도적으로 수정자 메서드를 생성하지 않았다면 '방어적 복사'를 떠올려야 할 때이다.

 

 

 

반응형
반응형

개요

 어떤 메서드를 구현할 때 매개변수가 유효하다는 것을 당연하게 여기곤 한다. 이렇게 가정한 상태에서 비지니스 로직을 구현하곤 한다. 어떤 이들은 이러한 사태를 방지하기 위해 생성자나 메서드 호출 부 앞단에 유효성 검사하는 메서드를 추가하기도 한다.

 어찌됐든, 유효한 매개변수를 당연시하게 될 경우 여러 문제가 발생할 수 있다. 메서드 수행 중간에 개발자가 생각지 못했던 예외가 발생한다거나, 메서드가 잘 수행됐지만 잘못된 값을 반환하거나 하는 등의 문제이다.

 

매개변수로 인한 예외는 문서화하라

public 과 protected 메서드는 매개변수 값이 잘못됐을 때 던지는 예외를 문서화 하길 권장하고 있다. @throws 자바독 태그를 사용하면 된다. 이렇게 문서화를 하는 이유는 유효하지 않은 매개변수에 대한 것을 개발자도 인지하고 있고, 다른 개발자에게도 알려주기 위함이라고 생각한다. 왜? 접근제어자에 따라 어디서든 호출될 수 있기 때문이다.

 

    /**
     * 현재 값 mod m 값을 반환한다.
     * 항상 음이 아닌 BigInteger를 반환한다.
     * 
     * @param m 계수(양수여야 한다.)
     * @return 현재 값 mod m
     * @throws ArithmeticException m이 0보다 작거나 같으면 발생한다.
     */
    public BigInteger mod(BigInteger m){
        if(m.signum() <= 0)
            throw new ArithmeticException("계수(m)은 양수여야 합니다. "+ m);

        return m.mod(m);
    }

 

 

m 이 null인 경우도 있잖아요?? 그럼 NullPointException 도 추가해야하는거 아닌가요?

추가하지 않는다. 그 이유는 이 설명을 mod 와 같은 개별 메서드가 아닌 매개변수 자체, 즉, BigInteger 클래스 수준에서 기술했기 때문이다. 클래스 수준 주석은 그 클래스의 모든 public 메서드에 적용되므로 각 메서드에 일일이 기술하는 것보다 깔끔하다.

 

아래는 BigInteger 클래스 내에 주석을 보면 [이 클래스의 모든 메서드와 생성자는 입력 매개변수에 대해 null 개체 참조가 전달되면 NullPointerException 이 발생한다]고 기재되어 있다.

* <p>All methods and constructors in this class throw
* {@code NullPointerException} when passed
* a null object reference for any input parameter.

 

어찌됐던 Null 검사는 해야하지 않나요?

 

순수하게 null을 체크하고자 한다면 자바 7에 추가된 java.util.Objects.requireNonNull 메서드를 사용하면 된다. a != null 과 같은 방법보다 훨씬 유연한 방법이다. 원하는 예외 메시지를 지정할 수도 있고, 입력을 그대로 반환하니 이를 활용할 수도 있다. 반환 값을 무시하고 순수한 null 검사 목적으로 사용해도 된다.

 

Null Check + 예외 메시징 처리

Integer value = null;
Objects.requireNonNull(value, "value 값이 null입니다.");

 

Null Check 와 예외 메시징 처리를 동시에 수행한다.

 

 

체크 입력 값 그대로 반환

Integer value2 = 3;
Integer value3 = Objects.requireNonNull(value2, "value 값이 null입니다.");
System.out.println(value3); // 3

 

private 는 매개변수로 인한 예외를 문서화하지 않나요?

굳이 문서화할 필요가 없다. 왜냐하면 public 이나 protected 는 외부에서 호출이 가능하다. 특히 public 은 어디서든 호출이 가능하기 때문에 매개변수로 인한 예외 가능성이 다분하다. 이에 반해 private 는 클래스 내에서만 호출 가능하다. 즉, 유효한 매개변수가 들어온다는 것을 충분히 보증할 수 있고, 또 그렇게 해야 한다. 이런 상황에서는 예외가 아닌 단언문(assert)를 사용해 매개변수 유효성을 검증할 수 있다.

 

private static void sort(long a[], int offset, int length){
    assert a != null;
    assert offset >= 0;
    assert length >= 0;
    ...
}

 

여기서 핵심은 이 단언문들은 자신이 단언한 조건이 무조건 참이라고 선언하는 것이다. 단언문은 몇가지 면에서 유효성 검사와 다르다. 첫째, 실패하면 AssertionError를 던진다. 둘째, 런타임에 아무런 효과도, 성능 저하도 없다.

 

그럼 무조건 매개변수 유효성 검사를 해야하나요?

예외는 있다. 유효성 검사 비용이 지나치게 높거계산 과정에서 암묵적으로 검사가 수행될 때다. 예를들어 Collections.sort(List) 처럼 객체 리스트를 정렬하는 메서드의 경우 리스트 안의 객체들은 모두 상호 비교될 수 있어야 하며, 이 과정에서 사실상 유효성 검사가 이루어진다. 그 객체와 비교할 때 비교될 수 없는 타입의 데이터가 있을 경우 ClassCastException 이 발생하기 때문이다.

 sort() 메서드 실행 초반부에 파라미터로 들어온 List 에 대한 유효성 검사를 한다면, 사실상 두 번의 유효성 검사를 한 격이다. 단, 이런 암묵적 유효성 검사의 너무 의존하는 것은 좋지 않다.

 

정리

 메서드나 생성자를 작성할 때 매개변수들에 어떤 제약이 있을지 생각해야 한다. 그 제약들을 문서화하고 메서드 코드 시작 부분에서 명시적으로 검사해야 한다. 이 노력은 유효성 검사가 실제 오류를 처음 걸러낼 때 보상받을 수 있다. 하지만 이번 아이템을 "매개변수에 제약을 두는게 좋다"로 해석하면 안된다. 메서드는 최대한 범용적으로 설계하는게 좋기 때문이다. 

반응형
반응형

개요

 스트림을 처음 접하면 이해하기 어렵고, 스트림으로 뭔가를 구현하더라도 이로인한 이점을 파악하기 어렵다. 스트림은 함수형 프로그래밍에 기초한 패러다임이기 때문이다. 스트림의 이점을 이해하고 사용하려면 이 패러다임까지 함께 이해해야한다.

 


함수형 프로그래밍이란?

함수형 프로그래밍(functional programming)은 자료 처리를 함수의 계산으로 취급하고
상태와 가변 데이터를 멀리하는 프로그래밍 패러다임

 

데이터 처리를 원시적인 계산 코드가 아닌 정형화된 함수로 처리하는 것이다. 사용하는 함수는 상태 값이나 가변 데이터를 멀리하도록 구현해야하는데 이러한 함수를 '순수 함수'라고 한다.

 

입력만이 결과에 영향을 주는 함수. 즉, 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는 함수를 순수 함수라 한다. - 이펙티브 자바 中

 

 

순수 함수는 가변 및 상태 값을 참조하지 않기에 입력 값에 의해 결과 값이 정해지는 특성을 갖는다. 만약 상태 값을 참조하게 된다면 입력 값에 의해 결과 값이 달라지는 '부작용'이 발생할 수 있다. 결국 이번 주제인 '부작용 없는 함수''순수 함수'를 말한다.

 

순수 함수는 개발자가 커스텀하여 만들 수도 있지만, 스트림 API에서 제공하는 함수를 사용하는 것도 좋은 방법이다. 스트림에서 제공하는 공식적인 함수이므로 부작용이 없고, 성능 최적화가 되어있으며, 40가지 이상의 다양한 함수를 제공하기 때문이다.

 

먼저 스트림 API를 사용했지만, 저자는 스트림 코드라고 인정하지 않는 코드를 살펴보자.


단어 빈도표 예제

 

다음은 텍스트 파일에서 단어별 수를 세어 빈도표로 만드는 코드이다. 스트림, 람다, 메서드 참조를 사용했고, 결과도 올바르지만 이를 스트림 코드라 하지 않는다. 스트림을 잘못 사용했고, 사용하지 않았을 때보다 가독성이 떨어지기 때문이다.

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word ->
            freq.merge(word.toLowerCase(), 1L, Long::sum));
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

 

문제는 외부 상태인 빈도 수(freq)를 수정하는 람다 부분이다. 이 코드의 모든 데이터 처리 작업이 최종 연산(종단 연산)인 forEach 구문에서 일어나고 있는데, forEach는 스트림 계산 결과를 보고할 때만 사용하는 것이 권장된다. 연산 결과를 보여주는 일 이상을 하니 좋은 코드라 할 수 없다.

 


스트림의 최종 연산이 뭔가요?

 

최종 연산 (종단 연산)
스트림의 요소를 '소비' 해가며 결과를 만들어내는 연산이다. 소비를 하기 때문에 최종 연산 후 스트림은 재사용할 수 없는 빈 상태가 된다.

 

더 이상 추가적인 연산을 할 수 없는 상태이기에 최종 연산이라고 한다. 최종 연산에 사용되는 대표 함수를 몇개 기재한다.

함수 내용
void forEach() 요소를 하나씩 소비해가며 지정된 작업 수행(병렬 스트림에서 순서 보장 X)
void forEachOrdered() 요소를 하나씩 소비해가며 지정된 작업 수행(병렬 스트림에서 순서 보장 O)
long count() 요소 개수 반환
Optional max() 요소 중 최대 값을 참조하는 Optional 반환
Optional min() 요소 중 최소 값을 참조하는 Optional 반환
Optional findFirst() 첫번째 요소를 참조하는 Optional 반환
Optional findAny() 첫번째 요소를 참조하는 Optional 반환 (병렬 스트림에서는 첫번째 요소 보장 X)
boolean allMatch() 모든 요소가 특정 조건을 만족하는지 여부를 반환
boolean anyMatch() 하나의 요소라도 특정 조건을 만족하는지 여부를 반환
boolean noneMatch() 모든 요소가 특정 조건을 불만족 하는지에 대한 여부를 반환
reduce() 요소를 하나씩 빼며 지정된 연산 처리 후 결과를 반환
collect() 요소들을 컬렉션으로 반환

 

 


 

최종 연산이 나와서 말인데... 중간 연산은 뭔가요?

 

중간 연산
스트림 생성부터 시작해서 최종 연산 직전까지의 연산이다. 최종 연산과 달리 체이닝 메서드 방식으로 여러 개의 중간 연산이 수행될 수 있다.

 

함수 내용
Stream<T> distinct() 요소의 중복 제거
Stream<T> filter() 요소에 대한 필터링 조건 추가
Stream<T> limit() 요소 개수 제한
Stream<T> skip() 처음 n개의 요소 건너뛰기 
Stream<T> sorted() 요소 정렬
Stream<T> peek() 요소에 대한 작업 수행, forEach와는 다르게 요소를 소비하지 않음

 


다시 단어 빈도표 예제로

다시 돌아가서 위 코드를 올바른 스트림 코드로 작성한다면 아래와 같다.

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

 이 코드는 collector를 사용하는데 collector는 스트림을 사용하려면 꼭 배워야하는 개념이다. 참고로 collector는 java.util.stream.Collectors 클래스를 말하며 groupingBy는 Collectors 클래스가 지원하는 static 메서드이다. 앞서 언급했던 '부작용' 없는 함수 중 하나인 것이다. groupingBy 메서드는 요소들을 그룹핑하여 Map 타입으로 반환한다. counting 메서드는 동일한 단어의 수를 반환하는데, 최종적으로 key는 소문자 단어, value는 단어의 수를 가진 Map이 생성된다.

 

collector는 이러한 메서드를 40개 이상 지원한다. 복잡한 세부 사항을 잘 몰라도 사용 가능하다.

이러한 메서드를 활용해 빈도표에서 가장 흔한 단어 10개를 뽑아내는 스트림 파이프라인을 작성해보자.

 

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toString, counting()));

    List<String> list = freq.keySet().stream()
            .sorted(comparing(freq::get).reversed())
            .limit(10)
            .collect(Collectors.toList());

    list.forEach(System.out::println);
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

comparing 메서드는 비교할 키를 받아 비교자를 생성하는 메서드이며, freq의 value 값을 비교 키로 하기 위해 freq::get 메서드를 파라미터로 전달하고 있다. 그 후 내림차순 정렬을 위해 reversed() 메서드를 호출한다.

 


toMap 사용해보기

collector에서 제공하는 메서드 중 toMap(keyMapper, valueMapper)은 스트림으로 가장 간단하게 맵을 만들 수 있는 메서드이다. 사용법도 쉬운게 키에 매핑하는 함수, 값에 매핑하는 함수를 인수로 넘기면 된다.

List<String> values = List.of("one","two","three","four");
Map<String, Integer> map = values.stream().collect(Collectors.toMap(s -> s, String::length));

for(Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

 

키 함수는 s -> s 로 List의 원소 값이 그대로 들어가고, 벨류 함수는 String::length 로 원소의 길이가 들어가도록 하였다. 하지만 만약 키가 중복될 경우 파이프라인에서 예외가 발생한다.

List<String> values = List.of("one","two","three","four","one");// 키 중복
Map<String, Integer> map = values.stream().collect(Collectors.toMap(s -> s, String::length));

for(Map.Entry<String, Integer> entry : map.entrySet()){ // IllegalStateException 발생 !
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

 

이러한 충돌을 다루는 전략으로 머지 함수를 제공한다. 함수의 형태는 BinaryOperator<U> 이며, 여기서 U는 해당 맵의 값 타입이다. 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다. 예컨데 병합 함수가 새로운 값으로 대체하는 함수라면 기존 값 대신 새로운 값으로 대체되도록 하려면 아래와 같이 작성할 수 있다.

Map<String, Integer> map = values.stream()
	.collect(Collectors.toMap(s -> s, String::length, (oldVal, newVal) -> newVal));

 

groupingBy는 입력으로 분류 함수(classifier)를 받고 출력으로 원소들을 카테고리별로 모아 놓은 맵을 담은 collector를 반환한다. 분류 함수는 입력 받은 원소가 속하는 카테고리를 반환한다. 그리고 이 카테고리가 해당 원소의 맵 키로 쓰이며, 해당 카테고리가 속하는 원소들을 담은 리스트는 값으로 쓰이게 된다.

List<String> values = List.of("one","two","three","four","five","six","seven");

Map<Integer,List<String>> map = values.stream().collect(groupingBy(s -> s.length()));

for(Map.Entry<Integer, List<String>> entry : map.entrySet()){
    System.out.println(entry.getKey());

    for(String str : entry.getValue()){
        System.out.println(str);
    }
}

 


정리

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수에 있다. 이러한 함수는 직접 만들어도 되지만 스트림에서 제공하는 함수가 매우 다양하기 때문에 이를 사용하는 것도 좋은 방법이다.

 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용하고 계산 자체에는 이용하지 말자.

 마지막으로 스트림을 잘 사용하려면 collector를 잘 알아둬야한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

반응형
반응형

확장할 수 없는 열거 타입

열거 타입은 거의 모든 상황에서 타입 안전 열거 패턴보다 우수하다. 단, 예외가 하나 있으니, 타입 안전 열거 패턴은 확장할 수 있지만, 열거 타입은 그럴 수 없다는 점이다.

 

확장 시 에러가 발생하는 열거타입

public enum AEnum {
    A,B,C
}

public enum BEnum extends AEnum{ // enum은 확장할 수 없다는 에러 발생
    D,E,F
}

 


확장형 열거타입에 어울리는 연산코드

연산 코드의 각 원소는 특정 기계가 수행하는 연산을 뜻한다. 기본으로 더하기, 빼기, 곱하기, 나누기 연산을 제공한다고 가정했을 때 제곱, 나머지 연산과 같은 확장된 연산을 제공해야 할 경우가 있다. 그런데 앞서 말했듯 열거타입은 확장이 불가능하다. 하지만 확장의 효과를 내는 방법이 있다. 바로 인터페이스를 사용하는 것이다. 기본적인 연산에 대한 열거 타입 클래스를 인터페이스를 사용하여 구현하였다.

public interface Operation {
    double apply(double x, double y);
}

public enum BasicOperation implements Operation{
    PLUS("+"){
        @Override
        public double apply(double x, double y) {
            return x+y;
        }
    },
    MINUS("-"){
        @Override
        public double apply(double x, double y) {
            return x-y;
        }
    },
    TIMES("*"){
        @Override
        public double apply(double x, double y) {
            return x*y;
        }
    },
    DIVIDE("/"){
        @Override
        public double apply(double x, double y) {
            return x/y;
        }
    };

    private final String symbol;

    BasicOperation(String symbol){
        this.symbol = symbol;
    }

    @Override
    public String toString() {
        return symbol;
    }
}

 

특별 연산이 추가된다면 인터페이스를 구현하자

만약 특별 연산이 추가되어야 한다면 새로운 열거 타입 클래스에서 인터페이스를 구현하면 된다.

 

public enum ExtendedOperation implements Operation{
    EXP("^"){
        @Override
        public double apply(double x, double y) {
            return Math.pow(x,y);
        }
    },

    REMAINDER("%"){
        @Override
        public double apply(double x, double y) {
            return x % y;
        }
    };

    private final String symbol;
    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }


    @Override
    public String toString() {
        return symbol;
    }
}

 

 

테스트

기본 열거 타입 대신 확장된 열거 타입을 넘겨 확장된 열거 타입의 원소 모두를 사용할 수 있다.

public static void main(String[] args) {
    test(ExtendedOperation.class, 3, 3);
}

public static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, double x, double y){
	for(Operation op : opEnumType.getEnumConstants()){
		System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x,y));
    }
}

 

 main 메서드는 test 메서드에 ExtendedOperation의 class 리터럴을 넘겨 확장된 연산들이 무엇인지 알려준다.

타입 매개변수 부분인 <T extends Enum<T> & Operation> 코드는 타입 매개변수 T가 Enum<T>. 즉, 열거 타입임과 동시에 Operation의 하위 타입이어야 한다는 것이다. 이는 Enum을 통한 원소 순회와 Operation 인터페이스의 메서드를 호출하기 위함이다.

 

이게 복잡하다면 아래 방법을 사용할 수 있다.

public static void main(String[] args) {
    test(Arrays.asList(ExtendedOperation.values()), 3, 3);
}

public static void test(Collection<? extends Operation> opSet, double x, double y){
    for(Operation op : opSet){
        System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x,y));
    }
}

 

ExtendedOperation의 상수 인스턴스를 List로 만든 후 각각의 상수 인스턴스에서 값을 순회하며 출력하는 방식이다. 이 코드는 위 방법보다 덜 복잡하고 유연해졌다. Operation을 구현하는 여러 클래스들에서 본인이 필요한 상수 인스턴스들을 추출하여 List로 넘겨주기만 한다면 다양한 연산들을 호출할 수 있기 때문이다.

 


정리

 열거 타입 자체는 확장할 수 없지만, 인터페이스와 그 인터페이스를 구현하는 기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있다. 이렇게 하면 클라이언트는 이 인터페이스를 구현해 자신만의 열거 타입을 만들 수 있다. 하지만 이런 구조는 너무 생소할 뿐더러 오히려 시스템의 복잡도를 높힐 수 있다는 생각이 든다. 만약 확장해야한다면 Enum보다는 클래스를 사용하여 구현하는게 더 좋지않을까?? 이번 내용은 크게 와닿지 않는 것 같다.

반응형
반응형

개요

 일반적으로 어플리케이션 내에서 사용되는 상수는 Enum을 통해 관리한다. Enum이 등장하기 전에는 어떤 방식으로 상수를 관리했고, Enum이 기존의 방식보다 어떤 장점을 갖고 있는지 알아보자.


Enum이 등장하기 전 상수 관리

public class Constant {

    public static final int PIZZA_S_DIAMETER = 14;
    public static final int PIZZA_M_DIAMETER = 16;
    public static final int PIZZA_L_DIAMETER = 18;
    
    public static final int TORTILLA_S_SIZE = 4;
    public static final int TORTILLA_M_SIZE = 6;
    public static final int TORTILLA_L_SIZE = 8;
}

 

위와 같이 상수만을 정의하는 클래스를 만들고 외부에서 바로 접근 가능하고, 변하지 않도록 public static final 키워드를 통해 정의한다. 이러한 구현 방식을 정수 열거 패턴이라고 한다.

 


정수 열거 패턴의 단점

1. 타입 안전을 보장할 방법이 없다.

 상수를 타입으로 구분하지 못하기 때문에 PIZZA, TORTILLA와 같은 접두어를 썼다. 하지만 이 값을 받는 메서드나 클래스에서는 타입으로 값을 받기 때문에 int 타입으로 받게 된다. 그 결과 PIZZA 관련 상수를 받아야할 메서드에 TORTILLA 관련 상수를 보내도 컴파일 에러가 발생하지 않지만 의도한 값을 받지 못했기 때문에 추후 로직에서 버그가 발생할 수 있다.

public class Pizza {

    private final int size;

    public Pizza(int size){
        this.size = size;
    }
}

 

Pizza firstPizza = new Pizza(Constant.PIZZA_S_DIAMETER); // s 사이즈 피자
Pizza secondPizza = new Pizza(30); // 30 사이즈 피자 >> 버그가 발생할 수도...
Pizza thirdPizza = new Pizza(Constant.TORTILLA_S_SIZE); // 또띠아 S 사이즈 피자 >> 버그가 발생할 수도...

 

 

2. 문자열로 출력했을 때 의미를 알 수 없다.

 상수 값을 출력하거나 이 값을 파라미터로 받은 메서드에서 이를 디버깅하면 값만 출력된다. 이 상태에서는 'S 사이즈 피자의 길이' 라는 의미는 알 수 없고 14라는 값만 알 수 있는 것이다. 14가 어떤 의미를 가지고 있는지도 함께 알려줄 수 있다면 디버깅 시 값의 의미를 파악하거나 출처를 알기 훨씬 쉬워질것이다.

 

3. 순회 방법이 까다롭다.

같은 열거그룹에 속한 모든 상수를 한 바퀴 순회하는 방법도 마땅지 않다. 예를들어 현재 존재하는 모든 피자 사이즈를 출력하고 싶다면 개발자가 일일이 하드코딩하거나 리플렉션을 사용해야 한다.

Class<Constant> constant = Constant.class;

for(Field field : constant.getFields()){
    if(field.getName().startsWith("PIZZA")){
        System.out.println(field.getName());
    }
}

 

 


 

 

Enum을 통한 상수 관리

 Enum 타입 자체는 클래스이며, 상수 하나당 인스턴스를 만들어 public static final 필드로 공개하는 방식이다. 아래의 예제는 내부적으로 각각의 size를 가진 S 인스턴스, M 인스턴스, L 인스턴스를 PizzaSize 타입으로 생성하고 이를 public static final로 공개하는 것이다.

 

public enum PizzaSize {
    S(14), M(16), L(18);
    
    private final int size;
    PizzaSize(int size){
        this.size = size;
    }
}

 

public enum TortillaSize {
    S(4), M(6), L(8);

    private final int size;

    TortillaSize(int size){
        this.size = size;
    }
}

 

 


열거 패턴의 단점을 극복한 Enum 타입

1. 컴파일 타임에서의 타입 안전성을 제공한다.

 이제 상수를 타입으로 구분할 수 있으므로 Pizza 클래스의 생성자 메서드에서 int 가 아닌 PizzaSize 타입으로 값을 받을 수 있다. 이로써 PizzaSize 타입이 아닌 다른 타입이 들어올 경우 컴파일 에러가 발생하게 된다. 컴파일 타임에서의 타입 안정성을 제공받게 되었다.

 

public class Pizza {

    private final PizzaSize size; // PizzaSize로 수정

    public Pizza(PizzaSize size){ // PizzaSize로 수정
        this.size = size;
    }
}

 

Pizza firstPizza = new Pizza(PizzaSize.S); // s 사이즈 피자
Pizza secondPizza = new Pizza(30); // 30 사이즈 피자 >> 컴파일 에러!!
Pizza thirdPizza = new Pizza(TortillaSize.S); // 또띠아 S 사이즈 피자 >> 컴파일 에러!!

 

2. 문자열로 출력했을 때 의미를 알 수 있다.

Enum에는 값과 이름이 존재하기 때문에 문자열로 출력했을 때 이 값의 의미를 알 수 있다. 또한 메서드 레벨에서 Enum 타입으로 값을 받기 때문에 디버깅 시 값에 대한 의미를 파악하기 쉽다. 그냥 14가 들어왔을 때 보다 PizzaSize.size = 14, PizzaSize.name = "S"로 들어온다면 피자 사이즈 S는 14라는 것과, 이 값이 PizzaSize 라는 Enum에서 관리된다는 출처도 알 수 있다.

디버깅 시 size에 대한 의미를 알 수 있다.

 

 

3. 순회 방법이 간단하다

 Enum 타입은 정의된 상수 값을 배열에 담아 반환하는 정적 메서드인 values를 제공한다. 정의된 상수를 모두 출력하고 싶다면 values 메서드를 사용하면 된다. 리플렉션을 사용했던 방식에 비하면 매우 간단함을 알 수 있다.

Arrays.stream(PizzaSize.values()).forEach(System.out::println);

 

 

 

4. 메서드나 멤버 필드를 추가할 수 있다.

 enum도 결국은 클래스이다. 멤버 필드나 메서드를 추가하여 피자 사이즈 관련 책임을 부여할 수 있다. 예를들어 피자 사이즈별로 사용되어야 하는 밀가루 양이 정해져있고 이 공식이 size * 30g 이라고 가정해보자. 이를 외부에서 구할 수도 있지만 size에 따라 밀가루 양이 결정되므로 size를 관리하는 PizzaSize 에게 책임을 위임해도 된다.

 

public enum PizzaSize {
    S(14), M(16), L(18);

    private final int size;

    private static final int SIZE_PER_FLOUR = 30; // 가중치
    
    PizzaSize(int size){
        this.size = size;
    }

    public int amountOfFlour(){
        return size * SIZE_PER_FLOUR;
    }
}

 

System.out.println(PizzaSize.S.amountOfFlour()); // 420
System.out.println(PizzaSize.M.amountOfFlour()); // 480
System.out.println(PizzaSize.L.amountOfFlour()); // 540

 

 

amountOfFlour 라는 메서드를 통해 모든 사이즈의 피자가 필요한 밀가루 양을 구하고 있다. 사이즈에 따라 밀가루 양을 구하는 로직이 다르지 않기 때문에 가능한 것이었다.

 

그럼 반대로 인스턴스마다 처리해야하는 로직이 다를 경우는 어떻게 처리해야 할까?

 


값에 따라 분기하는 Enum 타입

 

public enum Operation {
    PLUS, MINUS, TIMES, DIVIDE;
    
    public double apply(double x, double y){
        switch (this){
            case PLUS : return x+y;
            case MINUS : return x-y;
            case TIMES : return x*y;
            case DIVIDE: return x/y;
        }
        
        throw new AssertionError("알 수 없는 연산 : "+this);
    }
}

 

 위 코드는 동작하기는 하나 좋은 코드는 아니다. 새로운 상수를 추가하면 case 문도 추가해야 한다. 만약 이를 깜빡한다면 컴파일은 되지만 새로 추가한 연산을 수행하려 할 때 "알 수 없는 연산"이라는 런타임 에러가 발생한다.

 이를 구현하는 좋은 방법은 열거 타입에 apply 라는 추상 메서드를 선언하고 각 인스턴스에서 이를 재정의하는 방법이다.

 


추상화 메서드를 재정의하는 Enum 타입

public enum Operation {
    
    PLUS {public double apply(double x, double y){return x+y;}},
    MINUS {public double apply(double x, double y){return x+y;}},
    TIMES {public double apply(double x, double y){return x+y;}},
    DIVIDE {public double apply(double x, double y){return x+y;}};
    
    abstract double apply(double x, double y);
}

 

 이 경우 상수를 추가하게 되면 반드시 apply 메서드를 반드시 정의해야 한다. 정의하지 않을 경우 컴파일 에러가 발생한다. 또한 분기처리 로직이 사라져 코드가 훨씬 깔끔해졌다.


 

정리

 Enum 타입은 정수 열거 패턴 방식의 단점을 극복할 수 있으며, 상수를 단순 값이 아닌 상태와 책임을 갖는 싱글톤 인스턴스 형태로 동작하게 한다는 점에서 객체지향적으로 설계가 가능하고, 디버깅이나 출력 시 보다 의미있는 정보를 제공할 수 있다.

반응형
반응형

개요

 이전 포스팅에서 매개변수화 타입을 사용하는 클래스에 유연성을 더하기 위해 한정적 와일드카드를 사용했고, 그 결과 자식 타입까지 허용 가능하도록 구현하였다. 타입의 다형성을 활용한 것이다. 이는 다형성을 활용하지 못하는 타입은 접근이 불허하다는 한계가 있다는 뜻이다. 이 한계를 타입 안전 이종 컨테이너 방식으로 극복할 수 있다.

 


즐겨찾기 기능 구현

 타입별로 즐겨 찾는 인스턴스를 저장하고 조회할 수 있는 즐겨찾기 기능을 구현해보자. 

 

1. HashMap을 통한 구현

Map<Class<?>, Object> favorites = new HashMap<>();

favorites.put(String.class, "hi");
favorites.put(Integer.class, 1);

String favoriteString = (String)favorites.get(String.class);
Integer favoriteInteger = (Integer)favorites.get(Integer.class);

System.out.println(favoriteString);
System.out.println(favoriteInteger);

 

HashMap을 통해 간단하게 구현할 수 있지만 단점이 있다.

 

단점 1. 타입 안전성을 보장받지 못한다.

 Key를 Integer.class로 하고, value를 String 타입으로 넣어도 컴파일 에러가 발생하지 않는다. 이에 따라 런타임 시 해시맵의 Integer.class의 값을 조회할 때 ClassCastException이 발생하게 된다.

favorites.put(String.class, "hi");
favorites.put(Integer.class, "bye"); // 컴파일 에러는 발생하지 않는다.

..

Integer favoriteInteger = (Integer)favorites.get(Integer.class); // 런타임 에러 발생 !

 

단점 2. 조회 시 정적 타입 캐스팅이 필요하다

 모든 타입을 받아야 하기에 Map의 값을 Object 타입으로 선언하였다. 이에 따라 값을 조회할 때 정적인 타입 캐스팅이 필요하다. Integer.class에 대한 값을 조회할 때는 Integer 타입으로, String.class에 대한 값을 조회할 때는 String 타입으로 개발자가 직접 캐스팅해야한다.

 

 

2. 타입 안전 이종 컨테이너 패턴을 통한 구현

타입 안전 이종 컨테이너 패턴
 키 값을 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하는 패턴이다. 이에 따라 키와 값의 타입이 보장된다.

 

 

public class Favorites {

    private final Map<Class<?>, Object> favorites = new HashMap<>();

    public <T> void putFavorite(Class<T> type, T instance){
        favorites.put(Objects.requireNonNull(type), instance);
    }

    public <T> T getFavorite(Class<T> type){
        return type.cast(favorites.get(type));
    }
}

 

 

 HashMap 인스턴스를 감싸는 Favorites라는 래퍼 클래스를 만들고, 값을 넣거나 뺄 때 '키' 값에 매개변수화 타입 값을 함께 제공하고 있다. 이 방식이 Map 방식의 단점을 모두 극복했는지 알아보자.

 

1. 타입 안정성을 보장받는다.

Favorites f = new Favorites();

f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 123);
f.putFavorite(Class.class, Favorites.class);

String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);

 

 putFavorite 메서드에서 사용하는 제네릭 타입에 의해 내부 Map 인스턴스의 Key로 사용할 클래스 타입과 값이 모두 같은 타입임이 컴파일 타임에 보장된다.

 

만약 아래와 같이 String.class에 대해 123이라는 Integer 타입의 값을 사용한다면 메서드 시그니처에 맞지 않다는 컴파일 에러가 발생하게 된다. 즉, 기존 Map을 직접 사용한 방식과는 다르게 타입 안정성이 보장되고 있다.

f.putFavorite(String.class, 123); // 메서드 시그니처에 맞지 않는다는 컴파일 에러가 발생!

 

2. 조회 시 동적 타입 캐스팅이 가능하다.

public <T> T getFavorite(Class<T> type){
	return type.cast(favorites.get(type));
}

 

 조회 시 Class 클래스의 cast() 메서드를 사용하고 있다. 이 메서드는 매개변수로 들어온 값을 자신의 타입으로 캐스팅 할 수 있다면 캐스팅 후 반환하고, 캐스팅이 불가능할 경우 ClassCastException을 반환하는 메서드이다.

 어차피 getFavorite 메서드의 파라미터로 들어온 클래스의 타입 T와 favorites.get(type)을 통해 조회한 클래스의 타입 T 는 일치할 수 밖에 없으므로 type.cast 메서드를 아주 적절하게 사용할 수 있는 부분이다.

 

public <T> T getFavorite(Class<T> type){
    return (T)favorites.get(type);
}

 

물론 위와 같이 정적 형변환 방식으로 수정해도 되지만, 비검사 형변환이므로 '경고'가 발생하게 된다. 타입 안정성이 보장되는 부분이기에 @SuppressWarnings와 주석을 남겨야 한다.

 

 


제약 사항

Class 타입을 로 타입으로 넘길 경우 타입 안정성이 깨진다.

List<String> stringList = new ArrayList<>();
stringList.add("hi");

f.putFavorite(List<String>.class, stringList); // 컴파일 에러 발생

 

 List<String>과 List<Integer>에 대한 값을 Favorites 클래스로 관리하기 위해 putFavorite 메서드에 List<Class>.class 형태로 파라미터를 넘길 경우 경우 컴파일 에러가 발생한다. List<String>.class 구문 자체가 문법 에러를 발생시키기 때문이다.

 

List<String> stringList = new ArrayList<>();
stringList.add("hi");

List<Integer> integerList = new ArrayList<>();
integerList.add(1);

f.putFavorite(List.class, stringList);
f.putFavorite(List.class, integerList); // 덮어 씌워진다.

 

이를 해결하기 위해 위와 같이 로 타입으로 값을 넘기게 되면 List<String>과 List<Integer> 모두 같은 키를 공유하게 되므로 List.class 키에 대한 값은 마지막에 넣은 값으로 덮어 씌워지게 된다. List<String>과 List<Integer> 타입에 대한 값을 따로 관리하려 했던 목적을 이루지 못했다.

 


정리

 타입 매개변수를 사용하는 컬렉션 API는 한 컨테이너가 다룰 수 있는 타입의 수가 고정되어 있다. 하지만 타입 안정 이종 컨테이너 패턴을 사용하면 타입에 제약이 없는 컨테이너로 만들 수 있다.

 

 

 

반응형
반응형

 

매개변수화 타입은 유연할까?

매개변수화 타입은 불공변이다. 불공변은 계층적으로 설계된 타입을 아예 다른 타입으로 인식하는 성질이다. 일반적인 레퍼런스 타입의 경우 다형성을 활용하여 상위 타입 변수에 하위 타입 인스턴스가 할당될 수 있지만, 매개변수화 타입은 계층 관계가 있다 한들 그저 다른 타입으로 인식하기 때문에 할당이 불가능하다. 다형성을 활용할 수 없기 때문에 타입에 대해 유연하다고 할 수 없는 것이다.

List<Object> a = new ArrayList<String>(); // 불공변. 컴파일 에러
Object b = "hi";

List<Animal> c = new ArrayList<Cat>(); // 불공변. 컴파일 에러
Animal d = new Cat();

 

 


매개변수화 타입도 유연해질수 있다

매개변수화 타입도 여러 타입을 받을 수 있도록 유연해지는 방법이 있다. 바로 한정적 와일드카드를 사용하는 것이다. 먼저 기본적인 매개변수화 타입을 사용하는 스택 클래스를 보고 타입 유연성에 대한 문제점을 인지해보자.

 


스택 클래스의 문제점

아래는 필자가 간단하게 구현한 스택 클래스이다. Stack 인스턴스 생성 시 E 라는 타입 매개변수를 받고 있으며, 이때 받은 타입 매개변수가 여러 메서드에서 사용되고 있다. 이 스택 클래스의 첫번째 문제점은 pushAll에서 찾을 수 있다. 

public class Stack<E> {

    private final List<E> elementList = new ArrayList<>();
    private int size = 0;
    
    public void push(E element){
        elementList.add(element);
        size++;
    }

    public void pushAll(List<E> anotherList){
        elementList.addAll(anotherList);
        size += anotherList.size();
    }

    public void flush(List<E> anotherList){
        while(!isEmpty()){
            anotherList.add(pop());
        }
    }

    private E pop(){
        E element = elementList.get(--size);
        elementList.remove(size);
        return element;
    }

    private boolean isEmpty(){
        return elementList.isEmpty();
    }

    public void print(){
        elementList.forEach(System.out::println);
    }
}

 


Number 타입 엘리멘트 저장용 스택

Number 타입 엘리멘트를 저장하기 위해 Number 타입의 스택을 생성하였다. Number 클래스는 Double, Integer와 같은 숫자 타입 클래스의 상위 클래스이며, push 메서드를 통해 값을 넣을 수 있다. push 메서드는 그저 제네릭 타입의 매개변수를 받고있기 때문이다. 여기서는 Double 타입으로 오토박싱될 1.1 값을 통해 push 메서드를 호출하고 있다. 

Stack<Number> stack = new Stack<>();
stack.push(1.1);

 

 

문제는 매개변수화 타입을 받는 pushAll에서 발생한다. Double 타입의 리스트를 받지 못하는 것이다.

컴파일 타임에서의 pushAll 메서드 매개변수 타입은 List<Number> 인데 List<Double> 타입의 값을 매개변수로 사용하려 했기 때문이다. List<Number>와 List<Double>은 타입이 다르므로 컴파일 에러가 발생한다.

List<Double> list = new ArrayList<>();
list.add(2.2);
list.add(3.3);

stack.pushAll(list); // 컴파일 에러 발생

 


한정적 와일드카드를 통해 유연성을 높여보자.

한정적 와일드 카드를 사용하여 Number 타입과 같거나 서브 타입에 대한 타입 매개변수를 갖는 리스트로 변경하였다. List<Double> 타입의 Double은 Number의 하위타입이므로 컴파일 에러가 발생하지 않게 된다. 

public void pushAll(List<? extends E> anotherList){
    elementList.addAll(anotherList);
    size += anotherList.size();
}

 

 

여기서 끝이 아니다 flush 메서드도 비슷한 문제가 있다. 아래 테스트 케이스를 보자.

Integer 타입의 스택 인스턴스를 생성하고, Integer 리스트로 스택의 데이터를 모두 추출하여 전달하고 있다. 여기서의 문제도 마찬가지로 유연성이다. Stack을 Integer 타입으로 생성하면 flush 시 List<Integer> 타입의 매개변수밖에 받지 못한다.

Stack<Integer> stack = new Stack<>();
stack.push(1);

List<Integer> numberList = new ArrayList<>();
stack.flush(numberList);

 

 

 Integer 상위 타입인 Number 리스트로 받을 수 있을 것이라 생각했지만 컴파일 에러가 발생해버린다.

List<Number> numberList = new ArrayList<>();
stack.flush(numberList); // 타입 불일치 컴파일에러 발생

 

 

이것도 마찬가지로 한정적 와일드카드를 사용하여 해결 가능하다. pushAll과의 차이는 extends가 아닌 super를 사용하는 것이다. <? super E> 는 E 클래스의 상위 타입을 의미한다. 즉, Number는 Integer의 상위타입이므로 컴파일 에러가 발생하지 않게 된다.

public void flush(List<? super E> anotherList){
    while(!isEmpty()){
        anotherList.add(pop());
    }
}

 

 

이로써 수정된 Stack 클래스는 다형성을 지원하는 것처럼 유연한 클래스로 변경되었다.


팩스(PECS)

 PECS는 Producer-Extends, Consumer-Super의 약자로, 들어온 매개변수화 타입이 생산자라면 extends를 사용하고 소비자라면 super를 사용하라는 공식이다.

 pushAll 메서드는 들어오는 매개변수화 타입 인스턴스를 엘리멘트에 추가하기 위해 외부에서 생성해 들어온 값이다. 즉 생산자이므로 List<? extends E>를 사용하고, flush 메서드는 들어오는 매개변수화 타입 인스턴스에 엘리멘트를 소비한다. 즉, 소비자이므로 List<? super E> 를 사용한다.

 


 

정리

 와일드카드 타입을 사용하면 API가 유연해진다. 생산자와 소비자를 잘 구분하여 적절한 한정적 와일드카드를 사용하자. 공식을 활용하는 것도 좋지만 왜 이런 공식이 나왔는지를 이해하고 사용하는 것이 중요하다고 생각한다.

반응형
반응형
반응형

개요

 제네릭을 사용하면 많은 비검사 경고들을 마주한다. 필자의 경우 이러한 경고들은 IDE의 도움을 받아 수정했으며, 수정 자체에 큰 의의를 두진 않았다. 하지만 이러한 경고를 제거하는 것만으로 그 코드는 ClassCaseException이 발생할 일이 없는 타입 안전성을 보장하는 코드가 된다고 한다. 이제 비검사 경고를 제거하는 올바른 방법을 알아보자.

 


컴파일러의 도움을 받아 제거하라

 대부분의 경고는 컴파일러가 알려준 대로 수정하면 사라진다.

Set<Coin> coinSet = new HashSet();

 위 코드는 '매개변수화된 클래스 HashSet을 원시사용 했다.' 라는 경고가 발생한다. 매개변수화된 클래스는 제네릭 클래스를 나타낸다. 즉, 제네릭 클래스인데 타입 매개변수를 사용하지 않았다는 경고이다.

 

Set<Coin> coinSet = new HashSet<Coin>();
Set<Coin> coinSet = new HashSet<>(); // 컴파일러의 추론 기능 활용

 위와 같이 타입 매개변수를 명시하여 경고를 해결할 수도 있지만 다이아몬드 연산자(<>) 만으로 해결할 수 있다. 컴파일러가 올바른 실제 타입 매개변수를 추론해주기 때문이다.

 


경고를 제거할 수 없지만 타입 안전함이 확신된다면 경고를 숨겨라

 타입 관련 경고를 제거하려면 @SuppressWarnings("unchecked") 어노테이션 사용하면 된다. 이 어노테이션은 개별 지역변수 선언부터 클래스 전체까지 어떤 선언에도 달 수 있지만 가능한한 좁은 범위에 적용해야 한다. 보통 변수 선언, 아주 짧은 메서드, 생성자에 사용되며, 절대 클래스 전체에 적용해서는 안된다.

 

 

아래는 ArrayList의 toArray 메서드이다.

public <T> T[] toArray(T[] a) {
    if (a.length < size)
        // Make a new array of a's runtime type, but my contents:
        return (T[]) Arrays.copyOf(elementData, size, a.getClass());
    System.arraycopy(elementData, 0, a, 0, size);
    if (a.length > size)
        a[size] = null;
    return a;
}

 

 이 중 아래 코드에서 '확인되지 않는 형변환' 경고가 발생한다.

return (T[]) Arrays.copyOf(elementData, size, a.getClass());

 

return 문에는 해당 어노테이션을 적용할 수 없으므로, 아래와 같이 변수를 선언하고 어노테이션을 적용해준다. 또한 경고를 무시해도 되는 안전한 이유를 항상 주석으로 남겨둔다.

public <T> T[] toArray(T[] a) {
    if (a.length < size) {
        
        // 생성한 배열과 매개변수로 받은 배열의 타입이 모두 T[]로 같으므로 올바른 형변환이다.
        @SuppressWarnings("unchecked")
        T[] result = (T[]) Arrays.copyOf(elementData, size, a.getClass());
        return result;
    }
    System.arraycopy(elementData, 0, a, 0, size);
    if (a.length > size)
        a[size] = null;
    return a;
}

 

 


SuppressWarnings 옵션

옵션 내용
all 모든 경고
cast 캐스트 연산자 경고
dep-ann 사용하지 말아야할 주석 경고
deprecation 사용하지 말아야할 메서드 경고
fallthrough switch 문 break 누락 경고
finally 반환하지 않은 finally 블럭 경고
null null 경고
rawtypes 제네릭을 사용하는 클래스 매개변수가 불특정일때 경고
unchecked 검증되지 않은 연산자 경고
unused 사용하지 않은 코드 관련 경고

 


정리

 비검사 경고는 중요하니 무시하지 말자. 모든 비검사 경고는 런타임에 ClassCastException을 일으킬 수 있다. 경고를 없앨 방법을 찾지 못했다면, 그 코드가 타입 안전함을 증명하고 가능한 한 범위를 좁혀 @SuppressWarnings("unchecked") 어노테이션으로 경고를 숨기자. 그런 다음 경고를 숨긴 근거를 주석으로 남긴다.

반응형

+ Recent posts