Effective Java : 아이템 44. 표준 함수형 인터페이스를 사용하라

     

     

    아이템 44. 표준 함수형 인터페이스를 사용하라.

    • 자바는 람다식 등장 이후 템플릿 메서드 패턴보다 함수형 인터페이스를 생성자 / 팩토리 메서드에 전달되는 방식이 많이 사용됨. 
    • 함수형 인터페이스는 기본 제공되는 것을 우선적으로 사용해야 함. API에 대해서 학습해야 할 부분이 적어서 잘 습득할 수 있기 때문임. 
    • 커스텀 함수형 인터페이스를 구현할 때는 다음 경우 중 하나를 만족할 때임.
      • 필요한 동작을 함수형 인터페이스에서 구현할 수 없을 때 (값을 받아서 에러를 던지는 것 같은 작업들) 
      • 함수형 인터페이스의 이름이 클래스의 의미를 명확하게 설명할 때.
      • 함수형 인터페이스의 이름 / 메서드 명을 통해서 규약이 정의될 때
      • 함수형 인터페이스가 적절한 default 메서드를 제공할 때. 
    • @FunctionalInterface 사용 이유는 함수형 인터페이스임을 명시하고, 함수형 인터페이스 규약(1개의 메서드 이상인 경우)을 따르지 않는 경우 컴파일 에러를 발생시킴. 
    • 서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의하는 경우, 컴파일 에러가 발생할 수 있으므로 그렇게 사용하면 안됨. 
      • public void Action(Function<int, boolean> func)
      • public void Action(Predicate<int> func)
      • 예를 들면 위와 같은 메서드를 동시에 같은 클래스에 정의하고, 인스턴스에서 이 메서드를 호출하면 컴파일 에러가 발생함. 

     

     


    자바는 람다식 등장 이후로 많이 바뀜. 

    자바가 람다를 지원하면서 API를 작성하는 모범 사례도 많이 바뀌었다. 상위 클래스의 기본 메서드를 재정의해 사용하는 템플릿 메서드 패턴의 매력이 많이 줄었다. 아래처럼 사용하던 템플릿 패턴은 더 이상 현대의 프로그래밍에는 적절하지 않을 수 있다는 점이다.

    public class User {
        private final Parent parent;
        public User(Parent parent) {this.parent = parent;}
    	
        // 템플릿 메서드 패턴
        public void doSomething() {
            parent.doSomething();
        }
    }
    
    
    public abstract class Parent {
        public void saySomething() {System.out.println("hello");}
        public abstract void doSomething();
    }
    
    public class Children extends Parent{
        public void doSomething() {System.out.println("hi");}
    }

    클래스를 재정의해서 사용하는 것보다는 생성자 / 팩토리 메서드에 람다식을 전달하는 것이 클래스를 늘리는 것보다 더 깔끔할 수 있다는 것이다. 

    LinkedHashMap 클래스를 예시로 한번 살펴보자. LinkedHashMap은 proteteted 메서드로 removeEldestEntry()라는 메서드를 제공한다. 이 메서드는 put()이 호출되면 내부적으로 호출되는데, 재정의 해서 사용한다면 맵의 entry에서 오래된 Key 값을 제거하는 방식으로도 사용할 수 있다. 

    // LinkedHashMap.class
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

    만약 이 부분을 구현하려면 다음과 같이 새롭게 클래스를 하나 만들어야 할 것이다. 예를 들어 아래처럼 LinkedHashMap을 상속받은 CacheLinkedHashMap 클래스를 생성하고, removeEldestEntry() 메서드를 아래처럼 재정의하면 Map의 Entry에 100개의 원소가 있을 때 마다 하나씩 원소를 제거할 것이다. 

    public class CacheLinkedHashMap extends LinkedHashMap {
    
        @Override
        protected boolean removeEldestEntry(Map.Entry eldest) {
            return super.size() > 100;
        }
    }

    그런데 위의 문제점은 클래스 자체를 재정의 해야하기 때문에 코드의 양이 많아질 수 있다는 것이다.

    public class LinkedHashMap{
        private final Predicate<Map.Entry> judge;
        public LinkedHashMap(Predicate<Map.Entry> function) {this.judge = function;}
        protected boolean removeEldestEntry(Map.Entry eldest) {return this.judge.test(eldest);}
        public static void main(String[] args) {
            LinkedHashMap map = new LinkedHashMap(entry -> false);
        }
    }

    만약 LinkedHashMap을 위의 코드처럼 바꿔줄 수 있다면 LinkedHashMap의 상속없이, 람다식을 전달하는 것만으로 깔끔하게 처리할 수 있다. 위에 전달된 람다식에서는 항상 false를 반환하기 때문에 캐싱 역할을 할 수는 없다. 그렇지만 언제든지 람다식만 재정의하면서 필요한 부분을 깔끔하게 재정의 할 수 있다. 


    람다식을 적극활용하는 경우, 함수형 인터페이스는? 

    위처럼 템플릿 메서드 패턴을 사용하는 대신 함수형 인터페이스를 적극한다면, 각종 함수형 인터페이스를 매개변수로 받는 생성자나 팩토리 메서드를 많이 생성해야한다. 함수형 인터페이스를 사용할 때는 람다의 java.util.function 패키지에서 제공해주는 기본 함수형 인터페이스를 사용하는 것이 좋다. 

    이렇게 해야 함수형 인터페이스가 공통적으로 사용하는 API의 개념 수가 줄어들어 익히기 쉽기 때문이다. 총 43개의 인터페이스가 있으며, 기본적으로는 6개의 인터페이스만 기억해서 사용하면 된다. 

    인터페이스 함수 시그니처
    UnaryOperator<T> T apply(T t) String::toLowerCase
    BinaryOperator<T> T apply(T t1, T t2) BigInteger::add
    Predicate<T> boolean test(T t) Collection:isEmpty
    Function<T,R> R apply(T t) Arrays:asList
    Supplier<T> T get() Instant::now
    Consumer<T> void accpet(T t) System.out::println

    대표적으로 다음 6개의 인터페이스만 알면 되고, 나머지는 모두 변형되는 형태의 인터페이스가 제공된다. 이것은 내가 클래스를 작성할 때도 위를 고려해서 작성해야하고, 쓸 때도 이것을 고려해야한다는 것을 의미한다. 

    // 함수형 인터페이스는 기본적으로 제공되는 (java.util.fuction)을 사용하자.
    public class MyCustomCastor {
    
    	// 기본 함수형 인터페이스를 쓴다. 
        private final Function<Integer, String> caster;
    
        public MyCustomCastor(Function<Integer, String> caster) {
            this.caster = caster;
        }
        
        public String doCast(int number) {
            return caster.apply(number);
        }
    }

     


    함수형 인터페이스를 정의해야 할 때는?

    기본적으로는 제공되는 함수형 인터페이스를 사용하는 것이 좋다. 그렇다면 언제 함수형 인터페이스를 직접 정의해야할까? 아래 세 가지 경우 중 하나라도 해당되면 커스텀 함수형 인터페이스를 만드는 것이 좋다. 

    • 자주 쓰이며, 이름 자체가 용도를 명확히 설명해준다. 
    • 반드시 따라야 하는 규약이 있다.
    • 유용한 default 메서드를 제공할 수 있다. 

    자바에서 제공되는 이런 인터페이스는 Comparator가 존재한다. Compartor 함수형 인터페이스 자체는 ToIntFunction 함수형 인터페이스와 동일하다. 그렇지만 Comparator라는 이름이 클래스의 의도를 명확히 설명해주고 있으며, 내부에는 reversed() 같이 유용한 default 메서드도 제공되고 있다. 

    또한 Comparator가 많이 사용되면서 사용되어야 하는 규약까지 형성되어 있다. 예를 들어 o1 < o2 같은 비교체계들 말이다.

    @FunctionalInterface
    public interface Comparator<T> {
        int compare(T o1, T o2);
        default Comparator<T> reversed() {return Collections.reverseOrder(this);}
    
    	...
    }
    
    @FunctionalInterface
    public interface ToIntFunction<T> {
        int applyAsInt(T value);
    }

    이런 식으로 함수형 인터페이스를 커스텀하게 정의해서 사용하려고 한다면, 위의 세 가지 경우 중 하나라도 해당되는 것이 있는지 살펴보고 선택하면 된다. 

     


    @FunctionalInterface 어노테이션 사용 이유

    @FunctionalInterface 어노테이션을 사용하는 이유는 @Override를 사용하는 이유와 동일하다. @Override를 사용하는 것처럼 '이것은 함수형 인터페이스'라는 것을 명확하게 알려주기 위해서 사용한다. 정리하면 다음 세 가지 이유다.

    1. 해당 클래스가 람다용으로 설계된 것임을 알려줌. 
    2. 인터페이스에 1개의 메서드만 존재(함수형 인터페이스가 아닌 경우) 하는게 아니면 컴파일 에러를 발생해줌.
    3. 다른 개발자가 유지보수 할 때, 컴파일 에러를 발생시켜 함수형 인터페이스가 아닌 경우 컴파일 에러를 발생해줌. 

    예를 들어 2개의 메서드가 인터페이스 내에 있으면 함수형 인터페이스가 아니다. 컴파일러는 @FunctionalInterface 어노테이션에 컴파일 에러를 표시해준다. 

     


    함수형 인터페이스를 사용할 때 주의점

    책에는 다음 내용이 있다. 

    서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안된다. 클라이언트에게 불필요한 모호함만 안겨줄 뿐이며, 이 모호함으로 인해 실제로 문제가 일어나기도 한다. 

    이 내용은 다음과 같은 문제가 있음을 알려준다. 

    @FunctionalInterface
    static interface Action {
        int execute(int value1, int value2);
    }
    
    @FunctionalInterface
    static interface Transformer{
        int translate(int value1, int value2);
    }

    위처럼 서로 다른 이름의 함수형 인터페이스지만, 같은 타입의 인자를 받고 반환형이 같은 경우가 있다. 그리고 아래 코드를 살펴보자.

    public class CautionCaseFunctionalInterface {
    
        public static void main(String[] args) {
            CautionCaseFunctionalInterface sut = new CautionCaseFunctionalInterface();
            sut.doSomething((x,y) -> x + y); // 결정할 수 없음. 
        }
    
        public void doSomething(Action action) {
            log.info("action called");
            action.execute(5, 10);
        }
    
        public void doSomething(Transformer transformer) {
            log.info("transformer called");
            transformer.translate(5, 10);
        }
    
        
    }

    doSomething()를 호출하면서 람다식을 넘겨줄 때, 이 람다식이 Action / Transformer에 해당되는지 컴파일러는 알 수 없게 된다. 왜냐하면 (x,y) -> x + y 라는 람다식은 Action / Transformer 인터페이스에 모두 적용이 가능하기 때문이다. 따라서 컴파일 에러가 발생하게 된다.

    자바 내부에서도 이렇게 구현된 것이 있다. 바로 ExecutorService인데 submit() 메서드가 여러 개 정의되어있고, 각 submit()은 서로 다른 함수형 인터페이스인 Callable / Runnable을 둘다 첫번째 매개변수로 받는다. 마찬가지로 위와 같이 컴파일 에러가 발생할 가능성이 있다. 

    public interface ExecutorService extends Executor {
    
    	...
        
    	<T> Future<T> submit(Callable<T> task);
    	Future<?> submit(Runnable task);
    
    ...
    }

    정리하면 반환 타입, 매개변수가 같은 서로 다른 함수형 인터페이스를 각각 매개변수로 받는 메서드가 존재할 때, 해당 메서드는 같은 위치에 '함수형 인터페이스'를 받지 않도록 해야한다. 아래와 같이 처리해 볼 수 있다. 

    // 함수형 인터페이스를 받는 매개변수 위치를 변경 (세번째임)
    public void doSomething(int n1, int n2, Action action) {
        log.info("action called");
        action.execute(5, 10);
    }
    
    // 함수형 인터페이스를 받는 매개변수 위치를 변경 (첫번째임)
    public void doSomething(Transformer transformer, int n1, int n2) {
        log.info("transformer called");
        transformer.translate(5, 10);
    }
    
    public static void main(String[] args) {
    
        GoodCaseFunctionalInterface sut = new GoodCaseFunctionalInterface();
        sut.doSomething(1, 2, (x,y) -> x + y);
    }

    댓글

    Designed by JB FACTORY