본문 바로가기

JAVA

자바 함수형 프로그래밍 기술 7가지

 

출처:

https://deepu.tech/functional-programming-in-java-for-beginners/

7 Functional programming techniques in Java - A primer

Functional programming concepts in Java for beginners.

deepu.tech

 

일단 제네릭의 기본은 알게 되었고, 그 다음은 함수형 프로그래밍이다. 함수형 방식은 자바스크립트에서나 단순하게 사용해본 상황에서 여러 함수들이 난무하는 리액티브 프로그래밍에서는 작성된 코드 자체를 이해하기 쉽지 않았다. 급할 수 록 돌아가라는 말이 있듯이 일단 함수형 프로그래밍에 대한 기본부터 공부해 보고자 위의 사이트를 방문하였다.

 

추가: 재귀가 나오는 부분 부터는 개인적으로 이해가 미흡한 부분이 많다. 알고리즘력의 부재로;;;;

 

원제: 7 Functional programming techniques in Java - A primer

 

함수형 프로그래밍(functional programming(FP))이 과대하게 광고되고 있으며 많은 전문가들이 사용하고 있지만 만능은 아니다. 다른 프로그래밍 패러다임이나 스타일들과 같이 함수형 프로그래밍은 장단점을 가지고 있으며, 누군가는 여러 패러다임 중 한가지를 더 선호할 수 도 있다. 여러분이 자바개발자이고 함수형 프로그래밍을 사용하고자 한다면 걱정하지마라, 하스켈(Haskell)이나 클로저(Clojure)와 같은 함수형 프로그래밍 지향 언어를 배울 필요는 없다. 모든 함수형 프로그래밍 개념들을 상세히 설명하지는 않으며, 함수형 프로그래밍과 관련된 것들 중 자바로 할 수 있는 것들에 대해 초첨을 맞출 것이다. 일반적인 함수형 프로그래밍의 장단점에 대해서는 토론하지 않을 것이다.

함수형 프로그래밍이란 ?

위키피디아에서는 다음과 같이 설명한다.

Functional programming is a programming paradigm—a style of building the structure and elements of computer programs—that treats computation as the evaluation of mathematical functions and avoids changing-state and mutable data.
함수형 프로그램은 일종의 프로그래밍 패러다임이며 컴퓨터프로그램의들의 구조와 요소들을 구성하는 방식이다. 수학적인 함수들처럼 계산되도록 하며 상태변경과 데이터 변경을 피한다.

 

함수형 프로그래밍에는 두가지 매우 중요한 규칙들이 있는데 다음과 같다.

  • 데이터를 변경하지 않음(No Data Mutations):데이터 객체는 생성된 후 부터는 변경되지 않는다.
  • 상태가 암묵적이지 않음(No implicit state): 숨겨져 있거나 암묵적인 상태는 피한다. 함수형 프로그래밍에서 상태는 제거되지 않으며 대신 명시적으로 보인다.

 

이 규칙들은 다음을 의미한다.

  • 예상하지 못한 동작들을 막음(No side effects): 함수나 오퍼레이션은 자신의 함수범위 바깥의 어떤 상태도 변경을 금한다. 다시말하면 함수는 호출자에게 값을 반환만 하고 외부상태의 어떤것에도 영향을 미치지 않으며, 이 것은 프로그램들이 이해하기 더 쉬워진다는 것을 의미한다.
  • 순수 함수만 사용: 함수형 코드는 동일한 입력에 대해서는 결과도 동일하다(idempotent). 함수는 전달받은 인자들만을 기반으로 값을 반환해야 하며, 전역 상태에 영향을 미치거나 의존적이어서는 안된다. 이러한 함수들은 같은 입력값들에 대해 항상 같은 결과를 반환한다.

자바에 적용될 수 있는 함수형 프로그래밍 개념들이 아래와 같은데, 밑에서 다룰 것이다.

  • Higher-order-functions- 인자로 하나 이상의 함수들을 받으며, 결과로 하나의 함수를 반환한다.
  • Closures
  • Currying
  • Recursion
  • Lazy evaluations
  • Referential transparency

함수형 프로그래밍을 사용한다는 것이 모든 것이다 또는 아무것도 아니다 라는 의미가 아니다. 우리는 특히 자바에서 객체지향 개념들을 보완하기 위해 함수형 프로그래밍 개념들을 사용할 수 있다. 함수형 프로그래밍의 장점들은 우리가 사용하는 패러다임이나 언어와 상관없이 언제든지 활용될 수 있다. 앞으로 이 것들을 확인 할 것이다.

자바에서 함수형 프로그래밍

자바에서 함수형 프로그래밍 개념들의 일부를 어떻게 적용할지 알아보자. 현재 LTS 버전인 Java 11을 사용할 것이다.

일급 객체와 고차함수 (First-class and higher-order functions)

일급 함수(일급 시민 함수)라는 것은 변수에 함수를 할당 할 수 있다는 뜻이다. 다른 함수에 인자로 함수를 전달하거나 다른 함수로 함수를 반환한다. 자바는 이것을 지원하지 못하기 때문에 closures, currying, higher-order-functions 와 같은 개념들을 작성하기가 조금 불편하다.

 

자바에서 일급함수에 가장 가까운 것이 람다표현(Lambda expressions)이다.Function,Consumer,Predicate,Supplier와 같은 함수형 인터페이스들이 준비되어 있으며java.util.function패키지 내에 존재한다.

 

함수는 하나 이상의 함수를 파라미터로 받거나 결과로 다른 함수를 함수를 반환할 수 있는 경우에만 고차함수(higher-order-function)로 본다. 우리가 자바에서 고차함수를 사용할 수 있는 가장 근접한 방법은 람다 표현과 내장된 Functional 인터페이스들을 사용하는 것이다.

 

아래 코드는 고차함수 기능을 아주 잘 보여줬다고 할 수는 없으나 자바에서 어떻게 표현하는지 알아보는데 나쁘지 않다고 생각한다.

public class HocSample {
    public static void main(String[] args) {
        var list = Arrays.asList("Orange", "Apple", "Banana", "Grape");

        // mapForEach 메소드에 배열하나와 FnFactory의 내부 익명 클래스 인스턴스를 인자로
        // 전달한다. 
        var out = mapForEach(list, new FnFactory<String, Object>() {
            @Override
            public Object execute(final String it) {
                return it.length();
            }
        });
        System.out.println(out); // [6, 5, 6, 5]
    }

    // 배열하나와 FnFactory 인스턴스를 받는다. 
    static <T, S> ArrayList<S> mapForEach(List<T> arr, FnFactory<T, S> fn) {
        var newArray = new ArrayList<S>();
        // We are executing the method from the FnFactory instance
        arr.forEach(t -> newArray.add(fn.execute(t)));
        return newArray;
    }

    // @FunctionalInterface 표시된 인터페이스에 메소드를 2개 이상 추가하려고 하면 컴파일 오류가
    // 발생한다. 
    @FunctionalInterface // 아무것도 하지 않는다 정보성이다.
    public interface FnFactory<T, S> {
        // 구현될 익명 클래스의 조건을 정의한다. 
        S execute(T it);
    }
}

 

위의 것을 이미 만들어져 있는Function인터페이스와 람다 표현 문법을 사용해서 더 단순화 할 수 있다.

public class HocSample {
    public static void main(String[] args) {
        var list = Arrays.asList("Orange", "Apple", "Banana", "Grape");
        // mapForEach 메소드에 인자로 배열 하나와 람다 표현을 전달한다. 
        var out = mapForEach(list, it -> it.length());
        // 더 단순화 시킨다면 "mapForEach(list, String::length);" 이렇게 작성할 수 있다.
        System.out.println(out); // [6, 5, 6, 5]
    }
    // 인자로 하나의 배열과 Function인스턴스를 인자로받는다. 
    // 위의 예제에서 사용된 인터페이스를 내장 클래스로 대체하였다. 
    static <T, S> ArrayList<S> mapForEach(List<T> arr, Function<T, S> fn) {
        var newArray = new ArrayList<S>();
        // Function 인스턴스를 실행시킨다. 
        arr.forEach(t -> newArray.add(fn.apply(t)));
        return newArray;
    }
}

 

이 개념들을 사용해서 아래와 같이 클로저와 커링을 작성할 수 있다.

public class ClosureSample {
    // Function 인터페이스의 인스턴스를 반환하는 고차함수다. 
    Function<Integer, Integer> add(final int x) {
        // 이 부분이 클로저인데, Function 인터페이스의 익명 내부 클래스 인스턴가 외부의 변수를
        // 사용하는 것이다. 여기서는 add 메소드에 전달된 x 변수를 사용하고 있다. 
        Function<Integer, Integer> partial = new Function<Integer, Integer>() {
            @Override
            public Integer apply(Integer y) {
                // 변수 x 는 final로 선언된 이 메소드 범위 밖에서 온 것이다. 
                return x + y;
            }
        };
        // 클로저 함수 인스턴스를 반환한다. 
        return partial;
    }

    public static void main(String[] args) {
        ClosureSample sample = new ClosureSample();

        // 아래 방식이 커링이라고 하는것 같은데.. add 메소드에 여러 값을 할당한다. 
        // sample.add(10) 는 int x 에 10을 할당해 놓고, partial 클래스의
        // add10.apply(5) 를 호출해서 이전에 할당해 둔 10과 5를 더한 결과가 반환된다. 
        Function<Integer, Integer> add10 = sample.add(10);
        Function<Integer, Integer> add20 = sample.add(20);
        Function<Integer, Integer> add30 = sample.add(30);

        System.out.println(add10.apply(5)); // 15
        System.out.println(add20.apply(5)); // 25
        System.out.println(add30.apply(5)); // 35
    }
}

 

람다 표현을 사용해서 아래와 같이 단순화 시킬 수 있다.

public class ClosureSample {
    // Function 인터페이스의 인스턴스를 반환하는 고차함수다. 
    Function<Integer, Integer> add(final int x) {
        // 클로저로 람다 표현이 반환된다. 
        // 변수 x 는 위의 final 로 받아둔 외부 변수다. 
        return y -> x + y;
    }

    public static void main(String[] args) {
        ClosureSample sample = new ClosureSample();

        // we are currying the add method to create more variations
        Function<Integer, Integer> add10 = sample.add(10);
        Function<Integer, Integer> add20 = sample.add(20);
        Function<Integer, Integer> add30 = sample.add(30);

        System.out.println(add10.apply(5));
        System.out.println(add20.apply(5));
        System.out.println(add30.apply(5));
    }
}

 

자바에는 많은 고차 함수들이 준비되어 있는데 예를들어java.util.Collections패키지에는 sort 메소드가 있다.

List<String> list = Arrays.asList("Apple", "Orange", "Banana", "Grape");

// 아래 부분은 "Collections.sort(list, Comparator.naturalOrder());" 으로 단순화 시킬 수 있다.
Collections.sort(list, (String a, String b) -> {
    return a.compareTo(b);
});

System.out.println(list); // [Apple, Banana, Grape, Orange]

 

자바 스트림 API 는 forEach, map과 같은 많은 고차함 수 있는 제공한다.

순수 함수 (Pure functions)

순수 함수는 전달 받은 파라미터에 따라서만 값을 반환하며 전역 상태에 영향을 미치거나 영향받지 않는다. 자바에서는 checked exception 이 수행되는 몇 케이스들만 제외하면 이렇게 동작하게 하는게 가능하다.

 

아래 코드는 순수 함수다. 입력 값이 같으면 같은 값을 출력하며 그 동작을 예측하기 매우 쉽다. 필요하다면 이 메소드를 안전하게 캐싱할 수 있다.

public static int sum(int a, int b) {
    return a + b;
}

 

이 함수에 아래와 같이 추가 적인 코드를 추가하면 외부상태에 영향을 미치는 부작용을 갖게 되며 행위를 예측할 수 없는 상태가 된다.

static Map map = new HashMap<String, Integer>();

public static int sum(int a, int b) {
    var c = a + b;
    map.put(a + "+" + b, c);
    return c;
}

 

따라서 함수는 순수하고 단순하게 유지하도록 하자.

재귀 (Recursion)

함수형 프로그래밍은 루프보다는 재귀를 선호하는데, 자바에서는 스트림 API 를 사용하거나 재귀함수를 작성하여 구현할 수 있다. 숫자 팩토리얼을 계산하는 예를 확인해 보자.

 

아래 예제에서 성능 측정은JMH로 실행되었으며, 오퍼레이션 당 나노초로 표시되었다.

 

반복문을 사용한 접근은 다음과 같다

public class FactorialSample {
    // benchmark 9.645 ns/op
    static long factorial(long num) {
        long result = 1;
        for (; num > 0; num--) {
            result *= num;
        }
        return result;
    }

    public static void main(String[] args) {
        System.out.println(factorial(20)); // 2432902008176640000
    }
}

 

같은 내용을 함수형 프로그래밍에서 선호하는 재귀함수를 사용하면 다음과 같다.

public class FactorialSample {
    // benchmark 19.567 ns/op
    static long factorialRec(long num) {
        return num == 1 ? 1 : num * factorialRec(num - 1);
    }

    public static void main(String[] args) {
        System.out.println(factorialRec(20)); // 2432902008176640000
    }
}

 

하향식 재귀 접근은 대부분의 경우 반복접근보다 더 느릴 수 있다. 장점은 코드의 단순성과 가독성이다. 또한 모든 함수 호출은 스택에 프레임으로 저장되어야 하기 때문에 스택 오버플로우 오류를 발생시킬 수 도 있다. 이 문제를 피하기 위해, 특히 재귀가 매우 많이 발생할 때 tail recursion(개인적으로 이해 못함) 사용을 선호한다. tail recursion 에서 재귀 호출은 함수가 실행한 마지막 것 이 되며, 따라서 그 함수 스택 프레임은 컴파일러에 의해 저장될 필요가 없다. 대부분의 컴파일러들은 반복 코드가 퍼포먼스 문제를 피하기 위해 최적화되는 방식과 같은 방식으로 tail recursion을 최적화 할 수 있다. 하지만 자바 컴파일러는 이 최적화를 하지 못한다.

 

아래 코드는 같은 함수를 tail recursion을 사용하여 작성되었으나 자바는 이를 최적화 하지 못한다. 해결방법이 있긴 하지만 벤치마크들에서 더 낫게 수행된다.

public class FactorialSample {
    // benchmark 16.701 ns/op
    static long factorialTailRec(long num) {
        return factorial(1, num);
    }

    static long factorial(long accumulator, long val) {
        return val == 1 ? accumulator : factorial(accumulator * val, val - 1);
    }

    public static void main(String[] args) {
        System.out.println(factorialTailRec(20)); // 2432902008176640000
    }
}

 

재귀용으로 자바 스트림 라이브러리를 사용할 수 있으나 현재 일반적인 재귀보다 느리다.

public class FactorialSample {
    // benchmark 59.565 ns/op
    static long factorialStream(long num) {
        return LongStream.rangeClosed(1, num)
                .reduce(1, (n1, n2) -> n1 * n2);
    }

    public static void main(String[] args) {
        System.out.println(factorialStream(20)); // 2432902008176640000
    }
}

 

가독성과 불변성을 위해 자바 코드를 작성할 때는 스트림 API와 재귀 사용을 고려하자. 퍼포먼스가 중요하다면 또는 반복 횟수가 매우 크다면 표준 루프를 사용하자.

늦은 실행 (Lazy evaluation)

Lazy 실행 또는 non-strict 실행은 필요할 때까지 실행을 지연시키는 프로세스다. 보통 자바는 strict 실행을 하지만 &&, ||, ? 같은 피연산자에 대해서는 lazy 실행을 한다. 자바 코드를 작성할 때 이 연산자들을 이용해서 lazy 실행을 활용할 수 있다.

 

다음 예제는 자바가 구문을 즉시 실행하는 예제다.

public class EagerSample {
    public static void main(String[] args) {
        System.out.println(addOrMultiply(true, add(4), multiply(4))); // 8
        System.out.println(addOrMultiply(false, add(4), multiply(4))); // 16
    }

    static int add(int x) {
        System.out.println("executing add"); // this is printed since the functions are evaluated first
        return x + x;
    }

    static int multiply(int x) {
        System.out.println("executing multiply"); // this is printed since the functions are evaluated first
        return x * x;
    }

    static int addOrMultiply(boolean add, int onAdd, int onMultiply) {
        return (add) ? onAdd : onMultiply;
    }
}

 

결과는 다음과 같으며, 함수들이 모두 실행되는 것을 확인할 수 있다.

executing add
executing multiply
8
executing add
executing multiply
16

 

위의 코드를 람다 표현과 고차함수를 써서 늦은 실행이 되도록 할 수 있는 버전의 코드는 다음과 같다.

public class LazySample {
    public static void main(String[] args) {
        // 클로저 동작하는 람다 표현
        Function<Integer, Integer> add = t -> {
            System.out.println("executing add");
            return t + t;
        };
        // 클로저 동작하는 람다 표현
        Function<Integer, Integer> multiply = t -> {
            System.out.println("executing multiply");
            return t * t;
        };
        // 일반 함수 대신 람다 클로저를 전달한다.
        System.out.println(addOrMultiply(true, add, multiply, 4));
        System.out.println(addOrMultiply(false, add, multiply, 4));
    }

    // 고차 함수다
    static <T, R> R addOrMultiply(
            boolean add, Function<T, R> onAdd,
            Function<T, R> onMultiply, T t
    ) {
        // 자바는 ? 연산자에 대해서는 늦게 실행하기 때문에, 필요한 메소드만 실행된다
        return (add ? onAdd.apply(t) : onMultiply.apply(t));
    }
}

아래의 출력과 같이 필요한 함수들만 실행된 것을 확인 할 수 있다.

executing add
8
executing multiply
16

 

타입 시스템 (Type System)

자바는 강력한 타입 시스템을 가지고 있는데var키워드의 도입으로 인해 상당히 괜찮은 타입 추론기능도 가지고 있다. 다른 함수형 언어에는 있으나 자바에 없는 것은case 클래스들 뿐이다. 향후 자바 버전에 대해value classes(코드는 클래스처럼, 동작은 int 처럼)와 case 클래스 도입에 대한 제안 있다.

참조 투명성 (Referential transparency)

Functional programs do not have assignment statements, that is, the value of a variable in a functional program never changes once defined. This eliminates any chances of side effects because any variable can be replaced with its actual value at any point of execution. So, functional programs are referentially transparent.
함수형 프로그램들은 할당 구문을 갖지 않는다. 즉, 함수형 프로그램내에 변수의 값은 한번 정의되면 절대 변경되지 않는다. 어떠한 변수도 실행시점에 실 값으로 교체될 수 있는데, 이 방식은 이러한 부작용의 발생을 제거한다. 따라서 함수형 프로그램들은 참조적으로 투명하다.

위키피디아

 

자바에서 데이터 변경을 막는 방법은 많지 않은데 순수 함수를 사용하거나 다른 개념들을 사용하여 데이터의 변경이나 값 재할당을 피하는 것이다. 변수들에는 재할당으로 인한 변경을 회파하기 위해 non-access 식별자인final키워드를 사용할 수 있다.

 

예를들어, 아래의 코드는 컴파일 오류가 발생할 것이다.

final var list = Arrays.asList("Apple", "Orange", "Banana", "Grape");
list = Arrays.asList("Earth", "Saturn");

 

변수들이 다른 객체 참조들을 가지고 있을 경우, 도움이 되지 않을 것이다. 아래코드는 final 키워드와는 상관없이 동작한다.

final var list = new ArrayList<>();

list.add("Test");
list.add("Test 2");

 

final 키워드는 참조되고 있는 변수의 내부 상태의 변경을 허용한다. 따라서 함수형 프로그래밍 관점에서 final 키워드는 상수들과 재할당에만 유용하다.

데이터 구조

함수형 프로그래밍을 사용할 때, Stacks, Maps, Queues 와 같은 함수형 데이터 타입들을 사용하도록 권장한다. map은 함수형 프로그래밍에서 데이터 저장소로서 배열이나 해시 셋 보다 낫다.

결론

이 기사는 자바에서 함수형 프로그래밍 기술들을 적용하려고 하는 사람들을 위한 소개글이다. 자바에서 할 수 있는 것은 더 많으며 자바 8은 함수형 프로그래밍을 더 쉽게 만들수 있도록 스트림 API, Optional 인터페이스, 함수형 인터페이스들과 같은 많은 API 를 추가했다. 함수형 프로그래밍이 만능은 아니지만 코드를 더 이해하기 쉽고, 유지보수성과 테스트성을 올려주는 많은 유용한 기술들을 제공한다. 명령형 프로그래밍, 객체지향 프로그래밍과 함께 완벽하게 공존할 수 있다.

 

'JAVA' 카테고리의 다른 글

Java 제네릭 기본  (0) 2020.04.26
Apache Pulsar 정리 (kafka와 비교)  (0) 2019.12.15
XSD 를 Java 소스로 변환하기 - 메이븐 프로젝트 활용  (0) 2019.12.14
Calendar 예제  (0) 2011.05.28
JSON 테스트  (0) 2011.05.28