함수형 인터페이스

함수형 인터페이스 - 제네릭

함수형 인터페이스도 인터페이스이기 때문에 제네릭을 도입할 수 있다.

제네릭을 사용하지 않은 경우

public class GenericMain1 {
    public static void main(String[] args) {
        StringFunction upperCase = s -> s.toUpperCase();
        String result1 = upperCase.apply("hello");
        System.out.println("result1 = " + result1);

        NumberFunction square = n -> n * n;
        Integer result2 = square.apply(3);
        System.out.println("result2 = " + result2);
    }

    @FunctionalInterface
    interface StringFunction {
        String apply(String s);
    }

    @FunctionalInterface
    interface NumberFunction {
        Integer apply(Integer i);
    }
}

두 개의 함수형 인터페이스가 제공하는 apply() 메서드는 둘 다 하나의 인자를 받아서 결과를 반환한다. 다만 입력하는 타입과 반환 타입이 다를 뿐이다. 이렇게 매개변수나 반환 타입이 다를 때마다 계속 함수형 인터페이스를 만들 수는 없을 것이다.

Object 타입으로 합치는 경우

public class GenericMain2 {
    public static void main(String[] args) {
        ObjectFunction upperCase = s -> ((String) s).toUpperCase();
        String result1 = (String) upperCase.apply("hello");
        System.out.println("result1 = " + result1);

        ObjectFunction square = n -> (Integer) n * (Integer) n;
        Integer result2 = (Integer) square.apply(3);
        System.out.println("result2 = " + result2);
    }

    @FunctionalInterface
    interface ObjectFunction {
        Object apply(Object s);
    }
}

Object는 모든 타입의 부모이기 때문에 모든 타입을 처리하는 함수형 인터페이스를 만들 수 있다. 그러나 안전하지 않은 캐스팅 과정이 필요하기 때문에 타입 안전성이 떨어지는 문제가 발생한다.

제네릭 적용

public class GenericMain5 {
    public static void main(String[] args) {
        GenericFunction<String, String> upperCase = s -> s.toUpperCase();
        String result1 = upperCase.apply("hello");
        System.out.println("result1 = " + result1);

        GenericFunction<Integer, Integer> square = n -> n * n;
        Integer result2 = square.apply(3);
        System.out.println("result2 = " + result2);
    }

    @FunctionalInterface
    interface GenericFunction<T, R> {
        R apply(T t);
    }
}

함수형 인터페이스에 제네릭을 도입해서 코드 재사용과 타입 안전성까지 높일 수 있다. GenericFunction 함수형 인터페이스는 매개변수가 1개이고, 반환값이 있는 모든 람다에 사용할 수 있다.

제네릭 적용 함수형 인터페이스 사용 예제

public class GenericMain6 {
    public static void main(String[] args) {
        //문자열을 대문자로 변환
        GenericFunction<String, String> toUpperCase = s -> s.toUpperCase();

        //문자열의 길이 구하기
        GenericFunction<String, Integer> stringLength = s -> s.length();

        //숫자의 제곱 구하기
        GenericFunction<Integer, Integer> square = x -> x * x;

        //짝수 여부 확인하기
        GenericFunction<Integer, Boolean> isEven = num -> num % 2 == 0;

        System.out.println(toUpperCase.apply("hello"));
        System.out.println(stringLength.apply("hello"));
        System.out.println(square.apply(3));
        System.out.println(isEven.apply(3));
    }

    @FunctionalInterface
    interface GenericFunction<T, R> {
        R apply(T t);
    }
}

이렇게 제네릭을 활용하면 타입 안전성을 보장하면서도 유연한 코드를 작성할 수 있다. 또한 코드의 중복을 줄이고 유지보수성을 높이는데 큰 도움이 된다.


람다와 타겟 타입

위에서 만든 GenericFunction 함수형 인터페이스는 코드 중복을 줄이고 유지보수성을 높여주지만 2가지 문제가 있다.

  1. 모든 개발자들이 비슷한 함수형 인터페이스를 개발해야 한다.

GenericFunction 함수형 인터페이스는 매개변수가 1개이고 반환값이 있는 모든 람다에 사용할 수 있다. 그런데 람다를 사용하려면 함수형 인터페이스가 필수이기 때문에 모든 개발자들이 모두 비슷하게 GenericFunction을 각각 만들어서 사용해야 한다. 그리고 비슷한 모양의 함수형 인터페이스가 계속 만들어질 것이다.

  1. 개발자 A와 개발자 B가 만든 함수형 인터페이스는 서로 호환되지 않는다.

public class TargetType1 {
    public static void main(String[] args) {
        //람다 직접 대입 : 문제 없음
        FunctionA fa = i -> "value=" + i;
        FunctionB fb = i -> "value=" + i;

//        FunctionB targetB = fa; //컴파일 에러
    }

    @FunctionalInterface
    interface FunctionA {
        String apply(Integer i);
    }

    @FunctionalInterface
    interface FunctionB {
        String apply(Integer i);
    }
}
  • 람다를 함수형 인터페이스에 대입할 때는 두 개의 함수형 인터페이스 모두 메서드 시그니처가 동일하므로 문제없이 잘 대입된다.

  • 그러나 컴파일 오류가 발생하는 부분은 자바 언어상 전혀 다른 인터페이스이므로 서로 호환되지 않는다.

람다는 그 자체만으로는 구체적인 타입이 정해져 있지 않고, 타겟 타입이라고 불리는 맥락(대입되는 참조형)에 의해 타입이 결정된다.

FunctionA fa = i -> "value=" + i;
//람다 부분이 FunctionA라는 타겟 타입을 만나서 비로소 FunctionA 타입으로 결정된다.

FunctionB fb = i -> "value=" + i;
//동일한 람다라도 이번에는 FunctionB 타입으로 타겟팅되어 유효하게 컴파일된다.

즉 람다는 그 자체만으로는 구체적인 타입이 정해져 있지 않고, 대입되는 함수형 인터페이스(타겟 타입)에 의해 비로소 타입이 결정된다. 이렇게 타입이 결정되고 나면 이후에는 다른 타입에 대입하는 것이 불가능하다. 함수형 인터페이스를 다른 함수형 인터페이스에 대입하는 것은 타입이 서로 다르기 때문에 메서드의 시그니처가 같아도 대입이 되지 않는다.

자바는 이런 문제들을 해결하기 위해 필요한 함수형 인터페이스 대부분을 기본으로 제공한다.


자바가 제공하는 함수형 인터페이스

자바가 제공하는 함수형 인터페이스를 사용하면 비슷한 함수형 인터페이스를 불필요하게 만드는 문제와 함수형 인터페이스의 호환성 문제까지 해결할 수 있다.

기본 함수형 인터페이스

자바가 제공하는 대표적인 기본 함수형 인터페이스로는 Function, Consumer, Supplier, Runnable이 있다.

👆

  • 함수형 인터페이스들은 대부분 제네릭을 활용하므로 종류가 많을 필요는 없다.

  • 함수형 인터페이스는 대부분은 java.util.function 패키지에 위치한다. (Runnablejava.lang 패키지에 위치)

  • 하나의 매개변수를 받고, 결과를 반환하는 함수형 인터페이스

  • 일반적인 함수의 개념에 가장 가깝다.

  • 데이터 변환, 필드 추출 등에 주로 사용된다.

import java.util.function.Function;

public class FunctionMain {
    public static void main(String[] args) {
        //익명 클래스
        Function<String, Integer> function1 = new Function<String, Integer>() {
            @Override
            public Integer apply(String string) {
                return string.length();
            }
        };
        System.out.println("function1 = " + function1.apply("hello")); // function1 = 5

        //람다
        Function<String, Integer> function2 = string -> string.length();
        System.out.println("function2 = " + function2.apply("hello")); // function2 = 5
    }
}

  • 입력 값을 받고 결과를 반환하지 않는 연산을 수행하는 함수형 인터페이스

  • 입력 받은 데이터를 기반으로 내부적으로 처리만 하는 경우에 유용하다.

    • 예) 컬렉션에 값 추가, 콘솔 출력, 로그 작성, DB 저장 등

import java.util.function.Consumer;

public class ConsumerMain {
    public static void main(String[] args) {
        //익명 클래스
        Consumer<String> consumer1 = new Consumer<String>() {
            @Override
            public void accept(String string) {
                System.out.println("string = " + string);
            }
        };
        consumer1.accept("Hello, Consumer!"); // string = Hello, Consumer!

        //람다
        Consumer<String> consumer2 = string -> System.out.println("string = " + string);
        consumer2.accept("Hello, Consumer!"); // string = Hello, Consumer!
    }
}

  • 입력을 받지 않고 어떤 데이터를 공급해주는 함수형 인터페이스

  • 객체나 값 생성, 지연 초기화 등에 주로 사용된다.

import java.util.Random;
import java.util.function.Supplier;

public class SupplierMain {
    public static void main(String[] args) {
        //익명 클래스
        Supplier<Integer> supplier1 = new Supplier<Integer>() {
            @Override
            public Integer get() {
                return new Random().nextInt(10);
            }
        };
        System.out.println("supplier1.get() = " + supplier1.get()); //랜덤 값

        //람다
        Supplier<Integer> supplier2 = () -> new Random().nextInt(10);

        System.out.println("supplier2.get() = " + supplier2.get()); //랜덤 값
    }
}

Runnable

  • 입력값도 반환값도 없는 함수형 인터페이스

  • java.lang 패키지에 위치하며, 하위 호한을 위해 기존 패키지에 위치한다.

  • 주로 멀티스레딩에서 스레드에 작업을 정의할 때 사용한다.

public class RunnableMain {
    public static void main(String[] args) {
        //익명 클래스
        Runnable runnable1 = new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello, Runnable!");
            }
        };
        runnable1.run();

        //람다
        Runnable runnable2 = () -> System.out.println("Hello, Runnable!");
        runnable2.run();
    }
}

특화 함수형 인터페이스

특화 함수형 인터페이스는 의도를 명확하게 만든 조금 특별한 함수형 인터페이스로, PredicateOperator가 있다.

  • 입력 값을 받아서 true 또는 false로 구분(판단)하는 함수형 인터페이스

  • 조건 검사, 필터링 등의 용도로 많이 사용된다.

import java.util.function.Predicate;

public class PredicateMain {
    public static void main(String[] args) {
        //익명 클래스
        Predicate<Integer> predicate1 = new Predicate<Integer>() {
            @Override
            public boolean test(Integer integer) {
                return integer % 2 == 0;
            }
        };

        System.out.println(predicate1.test(10)); //true

        //람다
        Predicate<Integer> predicate2 = integer -> integer % 2 == 0;
        System.out.println(predicate2.test(11)); //false
    }
}

🤔 Predicate가 꼭 필요한가?

사실 PredicateFunction<T, Boolean>으로 충분히 대체할 수 있다. 그럼에도 불구하고 Predicate를 별도로 만든 이유는 다음과 같다.

  1. 의미의 명확성

    • Predicate<T>는 "이 함수는 조건을 검사하거나 필터링 용도로 쓰인다"라는 의도가 분명하다.

    • Function<T, Boolean>은 "이 함수는 무언가를 계산해 Boolean을 반환한다" 라고 볼 수도 있지만, 조건 검사라는 목적이 분명히 드러나지 않을 수 있다.

  2. 가독성 및 유지보수성

    • Predicate<T>라는 패턴을 사용함으로써 "조건을 판단하는 함수"라는 의미 전달이 명확해진다.

    • boolean 판단 로직이 들어가는 부분에서 Predicate<T>를 사용하면 코드 가독성과 유지보수성이 향상된다.

      • 이름도 명시적이고, 제네릭에 Boolean을 적지 않아도 된다.

목적(조건 검사)과 용도(필터링 등)에 대해 더 분명히 표현하고, 가독성과 유지보수를 위해 Predicate<T>라는 별도의 함수형 인터페이스가 생겼다.

  • 동일한 타입의 값들을 받아서 동일한 타입의 결과를 반환하는 함수형 인터페이스

  • 주로 입력과 반환이 동일한 타입의 연산에 사용된다.

하나의 피연산자에 대해 연산을 수행하는 UnaryOperator두 개의 피연산자에 대해 연산을 수행하는 BinaryOperator를 제공한다.

import java.util.function.BiFunction;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.UnaryOperator;

public class OperatorMain {
    public static void main(String[] args) {
        //UnaryOperator
        Function<Integer, Integer> square1 = x -> x * x;
        UnaryOperator<Integer> square2 = x -> x * x;
        System.out.println(square1.apply(5)); //25
        System.out.println(square2.apply(5)); //25

        //BinaryOperator
        BiFunction<Integer, Integer, Integer> addition1 = (a, b) -> a + b;
        BinaryOperator<Integer> addition2 = (a, b) -> a + b;
        System.out.println(addition1.apply(1, 2)); //3
        System.out.println(addition2.apply(1, 2)); //3
    }
}

👆 Operator를 제공하는 이유

Function<T, R>BiFunction<T, U, R>만으로도 사실상 거의 모든 함수형 연산을 구현할 수 있다. 하지만 Operator를 별도로 제공하는 이유는 다음과 같다.

  1. 의도(목적)의 명시성

    • Operator는 입력과 출력 타입이 동일한 연산을 수행한다는 것을 명확히 드러낸다.

    • Function<T, R>이나 BiFunction<T, U, R> 만으로 처리한다면, 타입이 같은 연산임을 코드만 보고 바로 파악하기 힘들다.

  2. 가독성과 유지보수성

    • Operator를 사용하면 같은 타입을 받아 같은 타입으로 결과를 내는 연산이라는 사실이 명확하게 전달된다.

    • 제네릭을 적는 코드의 양도 하나로 줄일 수 있다.

기본형 지원 함수형 인터페이스

자바는 기본형을 지원하는 함수형 인터페이스도 제공한다.

import java.util.function.IntFunction;
import java.util.function.IntToLongFunction;
import java.util.function.IntUnaryOperator;
import java.util.function.ToIntFunction;

public class PrimitiveFunction {
    public static void main(String[] args) {
        // 기본형 매개변수, IntFunction, LongFunction, DoubleFunction
        IntFunction<String> function = x -> "숫자: " + x;
        System.out.println("function.apply(100) = " + function.apply(100));
        
        // 기본형 반환, ToIntFunction, ToLongFunction, ToDoubleFunction
        ToIntFunction<String> toIntFunction = s -> s.length();
        System.out.println("toIntFunction = " + toIntFunction.applyAsInt("hello"));
        
        // 기본형 매개변수, 기본형 반환
        IntToLongFunction intToLongFunction = x -> x * 100L;
        System.out.println("intToLongFunction = " + intToLongFunction.applyAsLong(10));
        
        // IntUnaryOperator: int -> int
        IntUnaryOperator intUnaryOperator = x -> x * 100;
        System.out.println("intUnaryOperator = " + intUnaryOperator.applyAsInt(10));
        
        // 기타 - IntConsumer, IntSupplier, IntPredicate
    }
}

👆 기본형 지원 함수형 인터페이스가 존재하는 이유

  • 오토박싱/언박싱으로 인한 성능 비용을 줄이기 위해

  • 자바 제네릭의 primitive 타입을 직접 다룰 수 없다는 한계를 극복하기 위해

    • 자바의 제네릭은 기본형 타입을 직접 다룰 수 없다. 예를 들어 Function<int, R> 같은 식으로는 선언할 수 없다.

Last updated