java와 함수형 프로그래밍 - 람다, 함수형 인터페이스, 스트림

람다와 함수형 인터페이스

  • 함수형 프로그래밍의 큰 특징 중 하나는 함수가 값으로서 동작하는 점이다.
  • 아래는 자바 8 이후로 추가된 함수형 프로그래밍으로 코드를 작성하였다.
public class LambdaTest {
    @Test
    void strategy_with_lambda_1(){
        Math sumInt = new Math(1, (acc, input) -> acc * input); // lambda

        sumInt.execute(1);
        sumInt.execute(2);
        sumInt.execute(3);
        sumInt.execute(4);
        sumInt.execute(5);

        assert sumInt.getResult() == 120;
    }

    @Test
    void strategy_with_lambda_2(){
        Math sumInt = new Math(0, Integer::sum); // method reference

        sumInt.execute(1);
        sumInt.execute(2);
        sumInt.execute(3);

        assert sumInt.getResult() == 6;
    }

    static class Math{
        private final Strategy strategy;
        private int acc;

        Math(int init, Strategy strategy) {
            this.acc = init;
            this.strategy = strategy;
        }

        public void execute(int input){
            acc = strategy.execute(acc, input);
        }

        public int getResult(){
            return acc;
        }
    }

    interface Strategy {
        int execute(int acc, int input);
    }
}
  • 값을 축적하는 클래스(Math)가 존재한다. 축적 방식은 생성자에서 해당 전략을 주입하는 방식으로 결정된다.
  • 곱셈의 경우 람다를 사용하였고, 덧셈의 경우 메서드 참조를 사용하였다.
  • 사실 람다는 익명함수의 변형이다. 익명함수는 무척 장황하나 람다는 간단하고 명확한 표현력을 가진다.

함수형 인터페이스

  • 위 코드는, Strategy 인터페이스를 직접 작성하였다.
  • 자바는 함수형 프로그래밍에 사용할 함수형 인터페이스를 자바 8에 람다와 함께 도입하였다. 직접 구현하는 인터페이스보다 자바의 표준을 사용하는 것을 권장한다. 개발자 간 커뮤니케이션 비용을 최소화 할 수 있다.
  • 다음 예제는 IntBinaryOperator을 사용했다.
    • Operator은 Function과 동일한 기능을 한다. 다만, 입력값과 출력값이 같은 데이터 타입일 경우 Operator를 사용한다.
    • Binary란 입력 값이 두 개를 의미한다. Unary는 하나이다.
    • IntBinaryOperator의 메서드 시그니처는 다음과 같다 : int applyAsInt(int left, int right);
import java.util.function.IntBinaryOperator;

public class LambdaWithFunctionalInterfaceTest {
    @Test
    void lambda_with_function(){
        Math sumInt = new Math(1, (acc, input) -> acc * input); // lambda

        sumInt.execute(1);
        sumInt.execute(2);
        sumInt.execute(3);
        sumInt.execute(4);
        sumInt.execute(5);

        assert sumInt.getResult() == 120;
    }

    static class Math{
        // 자바 표준 인터페이스 Operator를 사용하여 가독성과 범용성을 높힌다.
        private final IntBinaryOperator operation; 
        private int acc;

        Math(int init, IntBinaryOperator operation) {
            this.acc = init;
            this.operation = operation;
        }

        public void execute(int input){
            acc = operation.applyAsInt(acc, input);
        }

        public int getResult(){
            return acc;
        }
    }
}

스트림

  • 자바는 스트림 API를 통해 함수형 프로그래밍의 방점을 찍는다.
  • 나이가 15대인 사람의 이름을 추출한다고 가정한다. 기존의 배열과 컬렉션을 활용하는 것과 스트림을 사용하는 것을 비교해보자.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Stream;

public class StreamTest {
    @Test
    void with_collection(){
        List<Person> persons = List.of(new Person("kim", 12), new Person("lee", 16), new Person("choi", 21));
        List<String> names = new ArrayList<>();
        for (Person p : persons) {
            if(p.getAge()>15){
                names.add(p.getName());
            }
        }
        assert names.size()==2 && names.contains("choi") && names.contains("lee");
    }

    @Test
    void with_stream(){
        List<String> names = Stream.of(new Person("kim", 12), new Person("lee", 16), new Person("choi", 21))
                .filter(isOverAge(15)) // p -> p.getAge() > 15
                .map(Person::getName)
                .toList();

        assert names.size()==2 && names.contains("choi") && names.contains("lee");
    }

    private static Predicate<Person> isOverAge(int age) {
        return p -> p.getAge() > age;
    }

    static class Person{
        String name;
        int age;

        public Person(String name, int age) {
            this.name = name;
            this.age = age;
        }
        // getter
    }
}
  • 컬렉션의 코드는 내부가 어떻게 동작하는지를 보여준다. List을 초기화하고 List을 반복문으로 돌려서 15세가 넘는 객체의 name을 꺼낸다.
  • 스트림의 방식은 선언적이다. 15세 이상을 필터링한다. 필터링된 값을 getName으로 변환한다. 그 결과를 list로 반환한다. 어떻게 할 것인지가 아닌 무엇을 할 것인지에 초점을 맞추는 함수형 프로그래밍의 특징을 스트림 역시 가진다.
  • 스트림의 장점은 자바에서의 함수형 프로그래밍의 도입으로 끝나지 않는다. 더 많은 기능과 장점을 내포한다.

Short Circuit과 Loop Fusion

  • 아래는 1) filter - 1부터 10의 숫자 중 짝수를 세 개 꺼내고 2) map - 각 각 3을 곱한 값 중 3) limit- 3개를 꺼내는 로직이다.
List<Integer> even = IntStream.range(1, 10)
        .filter(i -> {
            boolean isEven = i % 2 == 0;
            System.out.printf("%d는 %s입니다.%n", i, isEven ? "짝수" : "홀수");
            return isEven;
        })
        .map(i -> {
            int mul = i * 3;
            System.out.printf("%d에 3을 곱하면 %d입니다.%n", i, mul);
            return mul;
        })
        .limit(3)
        .boxed()
        .toList();

Assertions.assertThat(even).containsOnly(6, 12, 18);
  • 결과는 아래와 같다.
1는 홀수입니다.
2는 짝수입니다.
2에 3을 곱하면 6입니다.
3는 홀수입니다.
4는 짝수입니다.
4에 3을 곱하면 12입니다.
5는 홀수입니다.
6는 짝수입니다.
6에 3을 곱하면 18입니다.
  • 콘솔에 출력된 값을 미루어봐, filter는 1에서 6까지, map에서는 2,4,6에서만 로직을 수행함을 알 수 있다.
  • Short Circuit이란 값을 결정하기 위한 평가를 최소화하는 기법이다. 논리연산자를 두 개 사용하는 i%2==0 && i%3==0이 대표적이다. 마찬가지로 예제는 limit(3)에 도달할 때까지만 평가하였다.
  • 스트림은 filter와 map 등 중간 연산을 하나로 합친다. 이러한 특징을 loop fusion이라 한다.
  • 컬렉션으로 Short Circuit과 loop fusion을 구현할 수 있다. 하지만 스트림 덕분에 더 빠르고 명확하게 구현 가능하다. 스트림을 사용하지 않을 이유가 없다.

IntStream은 자바의 박싱 타입으로 인한 성능 저하를 해결하기 위한 스트림 객체이다. 최초에는 기본 타입 int로 동작한다. boxed()가 수행되면 그때 int가 Integer로 랩핑되어 사용된다.

병렬처리

  • 스트림은 단 하나의 메서드 추가(parallel())로 병렬 처리를 지원한다.
List<Integer> list = IntStream.range(1, 4)
        .parallel()
        .map(i -> {
            int mul = i * 5;
            System.out.printf("[%s] %d * 5 = %d%n", Thread.currentThread().getName(), i, mul);
            return mul;
        })
        .boxed()
        .toList();

Assertions.assertThat(list).containsOnly(5, 10, 15);
[Test worker] 3 * 5 = 15
[ForkJoinPool.commonPool-worker-1] 1 * 5 = 5
[ForkJoinPool.commonPool-worker-2] 2 * 5 = 10
  • 위의 로그를 살펴보면 ForkJoinPool을 사용함을 확인할 수 있다.
  • ForkJoinPool은 분할 정복으로 로직을 수행하는 스레드 풀이다. 분할 정복이란 데이터를 처리하기 좋은 크기만큼 잘게 짜르고, 잘라진 데이터를 병렬적으로 처리하는 방식을 의미한다.
  • 배열은 분할 정복으로 처리하기 좋은 데이터이다. IntStream의 요소인 int가 최종적으로 분할된 값이며 해당 값이 멀티 스레드에 의해 처리되었음을 확인할 수 있다.
  • 멀티스레드를 사용하기 때문에 순서가 중요한 로직에 사용할 수 없다.