CHAP19 - Modern-Java-in-Action/Online-Study GitHub Wiki

챕터19 함수형 프로그래밍 기법

이번장에서 다룰 내용들

  • 고차원함수, 커링, 영구 자료구조, 게으른 리스트, 패턴 매칭
  • 참조 투명성을 이용한 캐싱, 콤비네이터

19.1 함수는 모든곳에

19.1.1 고차원 함수 : 함수를 입력|출력하는 함수

  • 고차원 함수란, 다음 하나 이상의 동작을 수행하는 함수
    • 하나 이상의 함수를 인수로 받음
    • 함수를 결과로 반환
      • 미분에서 f(x)=x^2f(x)=2x로 변환하는 것처럼
  • 스트림 연산으로 전달하는 함수에 부작용이 없어야 하는 것처럼
    • 고차 함수의 인수로 전달하는 함수에도 부작용이 없어야 한다
    • 아니면 부정확한 결과가 나오거나, 경쟁상태에 빠질 수 있다
    • 실무에서는 어떤 부작용을 일으킬지 정확하게 문서화해야 한다

19.1.2 커링 : 함수를 만드는 함수

  • 커링이란, x, y 두 인수를 받는 함수 f를, 한 개 인수만 받는 g함수로 대체
  • 커링 기법을 적용한 예시
//일반적인 변환함수
static double converter(double x, double f, double b){
  return x * f + b;
}

//사용할때마다 변환할 값과 함께 기준값을 지정해주어야 한다
converter(36.5, 9, 32);
//변환함수 팩토리함수
static DoubleUnaryOperator curriedConverter(double f, double b){
  return (double x) -> x * f + b;
}

//원하는대로 함수를 만들어 사용할 수 있다.
DoubleUnaryOperator convertCtoF = curriedConverter(9.0/5, 32);
DoubleUnaryOperator convertUSDtoGBP = curriedConverter(0.6, 0);
DoubleUnaryOperator convertKmtoMi = curriedConverter(0.6214, 0);

//사용할 때는 변환할 값만 apply한다.
convertCtoF.applyAsDouble(1000);

19.2 영속 자료구조: 영향받지 않는 자료구조

  • 영속이란, 저장된 값이 누군가에 의해 영향을 받지 않는 상태이다.

    • 인수로 전달된 자료구조를 변경하지 않아야 한다.
    • 결과로 전달된 자료구조를 변경하지 않아야 한다.
      • 필드를 final 선언해서 제약을 걸 수 있다.
        • 필드를 다른 객체로 대체할 수는 없지만, 필드 내부 값을 변경할 수는 있다는 점에 주의. 필요한 곳에 final을 적절히 사용해야 한다.
  • 결과값을 반환할 때

    • 기존 자료구조 일부를 참조하는 새로운 자료구조
      • 값이 빠르게 변경되는 곳
      • ex) 데이터수집
    • 기존 자료구조 값만 복사
      • 연산이 오래 걸리는 곳
      • ex) 데이터분석

19.2.1 리스트 예제

  • 연산도중 가변객체를 갱신하는 경우, 기존 객체를 참조하는 다른 연산에서 오류가 발생할 수 있다.
  • A-B-C 리스트를 참조해서 A-C까지 가중치 합을 구하는 연산
    • 시나리오1 : 기존 리스트 중간에 새로운 노드를 추가 A-B-Z-C
      • 기존 리스트를 참조하던 연산은 예상하던 결과값을 받지 못할 수 있다.
    • 시나리오2 : 기존 리스트에서 A-B를 참조하는 새로운 Z-C 리스트를 생성하여 연산
      • 기존 리스트를 참조하던 연산도 예상 결과값을 얻을 수 있다.

19.2.2 트리 예제

19.3 스트림과 게으른 평가

  • 이번 장에서 알아볼 것들
    • 스트림은 한 번만 소비할 수 있다.
    • 스트림은 재귀적으로 정의할 수 없다.

19.3.1 재귀적으로 정의하는 스트림

  • 기존 알고리즘

    • 후보 수 n을 2~sqrt(n)으로 나누어 떨어지는지 확인
  • 새로운 알고리즘

    • 1단계 스트림 숫자 얻기 : 2부터 시작하는 무한 숫자 스트림
    • 2단계 머리 획득 : 첫번째 요소를 획득 obj.findFirst()
    • 3단계 꼬리 필터링 : 꼬리를 획득 obj.skip(1), 필터링 진행 obj.filter(n->n%head!=0)
    • 4단계 재귀적으로 소수 스트림 생성
  • 실행하면 오류가 발생한다! 왜냐하면..

    • 스트림은 한번만 소비될 수 있다.
      • head()에서 findFirst()로 소비되고
      • tail()에서 skip()을 호출하니 오류가 발생한 것
    • 스트림은 '재귀적 정의를 허용하지 않는다.'
      • 대신 실제로 필요할때 스트림을 '게으르게 평가'하면 위 알고리즘대로 작동시킬 수 있다.
        • 스칼라에서는 #:: 연산자를 사용한다.
        • 앞서 배운 것처럼 ()->something 서플라이어로 감싸도 되겠다.
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
	at java.base/java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:203)
	at java.base/java.util.stream.IntPipeline.<init>(IntPipeline.java:91)
	at java.base/java.util.stream.IntPipeline$StatefulOp.<init>(IntPipeline.java:655)
	at java.base/java.util.stream.SliceOps$2.<init>(SliceOps.java:231)
	at java.base/java.util.stream.SliceOps.makeInt(SliceOps.java:231)
	at java.base/java.util.stream.IntPipeline.skip(IntPipeline.java:410)
	at CHAPTER19.PrimeNumber.tail(Chapter19Main.java:23)
	at CHAPTER19.PrimeNumber.primes(Chapter19Main.java:30)
	at CHAPTER19.Chapter19Main.main(Chapter19Main.java:7)
package CHAPTER19;

import java.util.stream.IntStream;

public class Chapter19Main {
    public static void main(String[] args){
        PrimeNumber.primes(PrimeNumber.numbers()).forEach(System.out::println);
    }
}

class PrimeNumber{
    static IntStream numbers(){
        return IntStream.iterate(2, n -> n + 1);
    }

    static int head(IntStream numbers){
        return numbers.findFirst().getAsInt();
    }

    static IntStream tail(IntStream numbers){
        return numbers.skip(1);
    }

    static IntStream primes(IntStream numbers){
        int head = head(numbers);
        return IntStream.concat(
                IntStream.of(head),
                primes(tail(numbers).filter(n->n%head!=0))
        );
    }

}

19.3.2 게으른 리스트 만들기

  • 게으른 리스트
    • tail을 void -> T 서플라이어로 대체, tail을 호출했을 때 평가함
    • 소수 판별도 프리디케이트로, 호출했을 때 평가함
  • 꼬리호출 재귀가 지원되지 않기에, 아래 예제코드는 스택오버플로우가 발생한다
  • 로직을 이해하는 데 어려웠다.
    • 두번째 코드의 시작점은 printall
    • tail을 재귀적으로 평가해 primes의 인수로 넘겨 소수가 아닌 수를 걸러낸다
      • 평가한 tail이 head로 filter해서 나누어떨어지지 않으면
        • 소수이므로 새로운 서플라이어를 전달하고 종료한다

          • ⮑ 다시 primes로 돌아가 스택오버플로가 날때까지 반복한다
        • 소수가 아니므로 tail을 다시 평가하고 filter를 재귀호출한다

package CHAPTER19;

import java.util.function.Predicate;
import java.util.function.Supplier;

public class MyLinkedListMain{
    public static void main(String args[]){
        /* 게으르게 재귀적으로 늘어나는 숫자 리스트 */
        System.out.println("1. ===========================");
        MyLinkedList<Integer> numbers = from(2);
        int two = primes(numbers).head();
        System.out.println(two);
        int three = primes(numbers).tail().head();
        System.out.println(three);
        int five = primes(numbers).tail().tail().head();
        System.out.println(five);

        /* 계속 수행하면 스택오버플로우가 발생한다 */
        System.out.println("2. ===========================");
        try{
            printAll(primes(from(2)));
        } catch(StackOverflowError e){
            //e.printStackTrace();
            System.out.println("stack overflow");
        } catch(IllegalStateException e){
            System.out.println("BREAK");
        }
    }

    public static MyLinkedList<Integer> primes(MyLinkedList<Integer> numbers){
        return new MyLinkedList<Integer>(
                numbers.head(),
                ()->primes(
                        numbers.tail()
                                .filter(n->{
                                            System.out.print("filter "+n+" by "+numbers.head()+"/ ");
                                            return (n%numbers.head()!=0);
                                        }
                                )
                ));
    }

    public static MyLinkedList<Integer> from(int n){
        System.out.print("from@"+n+"/ ");
        return new MyLinkedList<Integer>(n, ()->from(n+1));
    }

    public static void printAll(MyLinkedList<Integer> list){
        if(list.isEmpty()) return;
        System.out.println(list.head());
        printAll(primes(list.tail()));
    }
}

class MyLinkedList<T> implements MyList<T> {
    public static int cnt = 0;
    private final T head;
    private final Supplier<MyLinkedList<T>> tail;

    public MyLinkedList(T head, Supplier<MyLinkedList<T>> tail){
        this.head = head;
        this.tail = tail;
    }

    @Override
    public T head() {
        return head;
    }

    @Override
    public MyLinkedList<T> tail() {
        //제한을 둠
        if((int)head>=50) throw new IllegalStateException("BREAK");
        return tail.get();
    }

    @Override
    public boolean isEmpty(){
        return false;
    }

    public MyLinkedList<T> filter(Predicate<T> p){
        return isEmpty() ?
                this :
                p.test(head()) ? //평가한 tail이 head로 filter해서 나누어떨어지지 않으면
                        new MyLinkedList<T>(head(), ()->tail().filter(p)) : //참이면 소수이므로, 새로운 서플라이어를 전달하고 종료
                        tail().filter(p); //거짓이면 소수가 아니므로, tail을 다시 평가하고 filter를 재귀호출
    }
}

interface MyList<T>{
    T head();
    MyList<T> tail();
    default boolean isEmpty(){
        return true;
    }
}

class Empty<T> implements MyList<T>{
    @Override
    public T head(){
        throw new UnsupportedOperationException();
    }

    @Override
    public MyList<T> tail(){
        throw new UnsupportedOperationException();
    }
}
  • 그런데 사실 위 알고리즘은 에라토스테네스의 체가 아니다. 덜 효율적인 알고리즘이다.

    • 에라토스테네스의 체 : 소수를 찾고, 그 소수의 배수를 목록에서 모두 지운다. 반복한다.

      • 2 -> 4, 6, 8, 10이 모두 없어진다.
    • 19.3절의 알고리즘 : n++보다 작은 소수로 n을 나눠본다. 나눠지지 않으면 소수로 판단하고 소수 목록에 추가한다. 반복한다.

      • 2, 3, 4 순서대로 평가하기 때문에 최적화되어 있지 않다.
      • 그럼에도 불구하고 무한한 수에 대하여 연산을 계속할 수 있다는 장점이 있다.

19.4 패턴 매칭: 강력한 switch로 다중 조건검사 추상화

  • 다음과 같은 상황을 자바에서는 어떻게 표현할 수 있을까?

    • f(0) = 1, f(n) = n * f(n-1)
    • 다이나믹 알고리즘 문제풀이를 할 때, 이런 경우 dp[0]=1if(n==0) return 1;등 직접 처리해줘야했다. 다른 방법은 없을까?
  • 보다 구체적인 예를 들면

    • new BiOperation("+", new Number(5), new Number(0))new Number(5)로 단순화하려면 어떻게 해야할까?
    • 메서드 인수의 형식과 값으로, 덧셈 그리고 더하는 숫자가 0인지 확인한다.
    • 메서드 시그니처를 직접 검사할 수는 없으므로, 인수를 각각 검사한다.
Expression simplyfyExpression(Expression expr){
	if(expr instanceof BiOperation
		&& ((BiOperation)expr).opname.equals("+")
		&& ((BiOperation)expr).right intanceof Number
		&& ((Number)((BiOperation)expr).right).value.equals(0))
			return (BiOperation)expr.left;
}

19.4.1 방문자 디자인 패턴: 객체 처리로직을 위임한다면

  • 방문자 디자인 패턴으로 처리로직을 '위임'할 수 있다.
    • 방문자 디자인 패턴이란?
    • 어떤 점이 나아진걸까?
      • 처리로직을 위임
        • 기존 객체의 멤버를 수정하지 않고 연산을 추가
        • 어디서 해당 객체를 처리하는지 분명하게 표현
public class SimplifyVisitor{
	public Expr visit(BinOp e){
		if("+".equals(e.opname) && e.right.instanceof Number && ...){
			return e.left;
		}
		return e;
	}
}

class BinOp extends Expr{
	public Expr accept(SimplifyExprVisitor v){
		return v.visit(this);
	}
}
  • 방문자 디자인 패턴은

  • 위 코드의 경우 어떤 점이 나아진걸까...

    • 장점
      • 이항연산 객체 자기자신을 처리하는 로직을 다른 객체로 위임한다.
    • 단점
      • 이항연산이 아니라 삼항연산을 간략하게 표현하려면, visit을 새로 작성해야한다. 혹은 이항연산 멤버가 바뀌면 visit을 새로 작성해야 한다.
      • 연산 객체와 간략하게 하는 객체가 서로 많이 의존하게 된다.
    • 내 생각
      • 주체.메서드(대상)대상.메서드(주체)의 결과는 같다.
      • 그러나 대상.메서드(주체) 패턴은, 대상을 처리할 로직을 주체로 위임한다는 점을 더 분명하게 표현할 수 있다.
      • 위와 같은 패턴을 계속해서 이어서 사용하면, 기존 클래스 멤버는 유지한채로 새로운 연산을 추가할 수 있다.

19.4.2 패턴매칭: switch/case가 더 많은 형식을 지원한다면

  • 만약, switch/case에서 메서드 시그니처를 기준으로 구별할 수 있다면 더 간단히 표현할 수 있을 것이다.
    • 스칼라에서는 아래와 같은 패턴매칭을 지원한다.
    • 자바는 지원안한다.
      • 기본형, 열거형, 일부 기본형 래퍼클래스, 문자열 등을 사용할 수 있다.
def simplifyExpression(expr: Expr): Expr = expr match{
	case BinOp("+", e, Number(0)) => e
	case BinOp("*", e, Number(1)) => e
	case BinOp("/", e, Number(1)) => e
	case _ => expr
}
  • 그러나 우리에겐 람다가 있다. 람다로 패턴매칭을 흉내내보자.
    • 스칼라의 경우 다중 switch문이라 할 수 있다.
      • 먼저 BinOp 메서드인지 확인하고
      • 인수로 전달된 것이 무엇인지 확인한다
    • 자바의 경우 아래와 같은 코드로 보다 범용적인 switch를 만들 수 있다.
//사용
myIf(n%2==0, ()->42억*42억, ()->42억);
myIf(do(something).IsSucceed(), ()->successHandler, ()->failHandler);

//구현
static <T> T myIf(boolean b, Supplier<T> truecase, Supplier<T> falsecase){
	return b ? truecase.get() : falsecase.get();
}

연산을 간단히하는 기존예제는 아래와 같이 바꿀 수 있다.

  • 그러면 어떤 점이 더 나아지나?
    • 추상화: 조건문을 메서드 내부로 모두 감췄다.
    • 다형성: TriFunction, 그리고 서플라이어를 다시 정의하면, 숫자뿐 아니라 다른 형식에도 사용할 수 있다.
      • 이를테면 서플라이어를 객체 내부 문자열 값을 읽는 것으로 바꾸고
      • TriFunction에서는 문자열 덧셈에서 뒤에 빈 문자열이 오는지 확인하고, 왼쪽 인수만 반환한다든지
    • 코드의 양은 변함없다.
//사용
simplify(new BiOperation("+", new Number(5), new Number(0)));

//구현
interface TriFunction<S, T, U, R>{
  R apply(S s, T t, U u);
}

static <T> T patternMatchExpr(
	Expr e,
  TriFunction<String, Expr, Expr, T> binopcase,
  Function<Integer, T> numcase,
  Supplier<T> defaultcase){
  
  return
    (e instanceof BinOp) ?
    	binopcase.apply(((BinOp)e).opname, ((BinOp)e).left,
                      									 ((BinOp)e).right) :
  	(e instanceof Number) ?
      numcase.apply(((Number)e).val) :
  		defaultcase.get();
}

public static Expr simplify(Expr e){
  TriFunction<String, Expr, Expr, Expr> binopcase = 
    (opname, left, right) -> {
  		if("+".equals(opname)){
        if(left instanceof Number && ((Number)left).val==0){
          return right;
        }
        if(right instanceof Number && ((Number)right).val==0){
          return left;
        }
      } 
    	if("*".equals(opname)){
        //do something;
      }
    	return new BinOp(opname, left, right);
  };
  Function<Integer, Expr> numcase = val -> new Number(val);
  Supplier<Expr> defaultcase = () -> new Number(0);
  
  return patternMatchExpr(e, binopcase, numcase, defaultcase);
}

19.5 기타

  • 함수형 부수 효과 (Side Effect), 참조 투명성 (Referential Transparency) – Jinwoo's Blog (wordpress.com)

    • 부수효과/부작용 : 함수내 실행으로 함수 외부가 영향을 받는것
    • 참조투명성 : 함수/메서드가 함수 외부의 영향을 받지 않는 것
      • 함수의 결과는 입력 파라미터에 의존하고, 함수 외부(데이터베이스 등)에 의존하지 않는 것.
      • 특징
        • 자기충족적: 함수 외부에 의존하는 코드가 없고, 함수 사용자 입장에서는 유효한 매개변수만 전달하면 된다.
        • 결정론적: 동일한 매개변수에 대해서는 항상 동일한 결과가 나온다.
        • 예외를 던지지 않는다. 예외는 버그이고, 함수 사용자가 다룰 수 있는 것으로 보지 않는다.
        • 다른 코드가 예기치 않게 실패하는 조건을 만들지 않는다. 매개변수의 값을 변경하거나 함수 외부의 데이터를 변경하지 않는다.
        • 데이터베이스, 파일시스템, 네트워크 등 외부 요인으로 동작이 멈추지 않는다.
    • 함수형 프로그래밍으로 얻을 수 있는 이득
      • 결정론적이므로 프로그램을 광범위하게 테스트하는 대신 해당 함수가 올바르게 동작하는지만 확인하면 오류의 원인을 찾을 수 있다.
      • 부수효과가 없으므로 테스트용 가짜 객체를 만들 필요가 없다. (테스트 중에도 대상 객체를 변경하지 않으므로)
      • 부수효과가 없고, 외부 요인에서 자유롭고, 예외가 없고, 값의 변경이 일어나지 않고, 동시성 문제에서 자유로우므로, 함수를 조합해서 사용하기가 더 쉽다.
  • 콤비네이터: 두 개 이상 함수를 인수로 받아 다른 함수를 반환하는 메서드/함수

19.5.1 캐싱 또는 기억해서 쓰기: 파라미터 같으면 결과도 같으므로

  • 트리 형식의 토폴로지를 갖는 네트워크에서 특정 범위 내 노드 수를 계산하는 부작용 없는 메서드
    • 반복해도 네트워크에 변경은 가지 않지만, 연산 비용이 비싸다.
    • 이미 연산한 범위를 해시맵 등에 넣어 참조하면 연산을 줄일 수 있다.
      • 다만, 모두 공유하는 가변객체를 수정하는 것이므로 완전히 '함수형'이라고 하기는 어렵다.
      • 또한 해시맵은 스레드 세이프하지 않는 자료구조이다.
final Map<Range, Integer> numberOfNodes = new HashMap<>();

//직접구현
Integer computeNumberOfNodesUsingCache(Range range){
	Integer result = numberOfNodes.get(range);
	if(result!=null) return result;
	result = computeNumberOfNodes(range);
	numberOfNodes.put(range, result);
	return result;
}

//computeIfAbsent사용
Integer computeNumberOfNodesUsingCache(Range range){
	return numberOfNodes.computeIfAbsent(range, this::computeNumberOfNodesUsingCache);
}

19.5.2 '같은 객체를 반환함'이란, ==인가 equals인가

  • 함수형 프로그래밍에서 인수가 같다면 결과도 같아야 한다.
  • 19.2.3절의 이진트리 예제에서는 일부 노드를 새로 생성해서 반환했다. 이 메서드는 참조투명성(=인수가 같다면 결과도 같다)을 가질까?
    • 매번 새로운 객체를 반환하므로 참조투명하지 않다.
    • 기존객체를 변경하지 않았고 같은 값을 가지므로 참조투명하다.

19.5.3 콤비네이터: 두 함수를 인수로 받아 다른 함수를 반환

  • 데이터를 받아 f에 연속적으로 n번 적용하는 루프 연산을 이렇게 구현할 수 있다.
repeat(3, (Integer x) -> 2*x);

static <A> Function<A,A> repeat(int n, Function<A,A> f){
	return n==0 ? x->x : compose(f, repeat(n-1, f));
}

19.6 정리

  • 함수를 조합해서 사용할 수 있다. 모듈화하고 재사용하기 편하다.

    • 일급함수는 인수로 전달하거나, 결과로 반환하거나, 자료구조에 저장할 수 있다.

    • 고차함수는 하나 이상의 함수를 인수로 받아 다른 함수를 반환하는 함수다.

    • 커링은 함수를 모듈화하고 코드를 재사용할 수 있도록 지원한다.

    • 콤비네이터는 둘 이상의 함수나 자료구조를 조합하는 함수형 개념이다.

  • 부작용이 없는 함수를 사용하면 자료구조가 변경될 걱정을 하지 않아도 된다.

    • 영속 자료구조는 기존 버전의 자신을 보존한다.
  • 서플라이어를 사용해서 필요에 따라 '게으르게' 생성되는 리스트를 만들 수 있다.

    • 자바 스트림은 재귀적으로 정의할 수 없기에 적용할 수 없다.
    • 직접 서플라이어를 사용해 게으른 객체 리스트를 만들 수 있다.
  • 함수형으로 동작을 추상화하고 전달함으로써 switch를 일반화할 수 있다.

  • 참조투명성(=같은 파라미터, 같은 결과)을 유지하는 상황에서는 계산 결과를 캐시할 수 있다.


The Genuine Sieve of Eratosthenes (hmc.edu)

  • 주제: 에라토스테네스의 체 알고리즘, 스트림 게으른 연산
⚠️ **GitHub.com Fallback** ⚠️