모던 자바 인 액션 책을 읽고 정리한 내용입니다.

Repository

Part6 함수형 프로그래밍과 자바 진화의 미래

Chapter18. 함수형 관점으로 생각하기

시스템 구현과 유지보수

  • 프로그램 내에 synchronized라는 키워드가 발견된다면 업그레이드 관리 요청 제안을 거절하라. 동시성 버그를 고치는 일은 정말 어렵다.
  • 자바 8의 스트림을 이용하면 잠금(locking) 문제를 신경 쓰지 않을 수 있다. 단, 상태 없는 동작이어야 한다는 조건을 만족해야 한다. 즉, 스트림 처리 파이프라인의 함수는 다른 누군가가 변수의 값을 바꿀 수 있는 상태에 있는 변수를 사용하지 않는다.
  • 시스템의 각 부분의 상호 의존성을 가르키는 결합성(coupling)과 시스템의 다양한 부분이 서로 어떤 관계를 갖는지 가리키는 응집성(cohension)이라는 소프트웨어 엔지니어링 도구로 프로그램 구조를 평가할 수 있다.
  • 실질적으로 프로그래머가 가장 많이 겪게 되는 코드 크래시는 예상하지 못한 변수값 때문에 발생한다. 함수형 프로그래밍이 제공하는 부작용 없음(no side effect)불변성(immutability)이라는 개념이 이 문제를 해결 하는데 도움을 준다.
  • 공유된 가변 데이터
    • 변수가 예상하지 못한 값을 갖는 이유는 결국 시스템의 여러 메서드에서 공유된 가변 데이터 구조를 읽고 갱신하기 때문이다. 공유 가변 데이터 구조를 사용하면 프로그램 전체에서 데이터 갱신 사실을 추적하기 어려워진다.
    • 자신을 포함하는 클래스의 상태 그리고 다른 객체의 상태를 바꾸지 않으며 return 문을 통해서만 자신의 결과를 반환하는 메서드를 순수(pure) 메서드 또는 부작용 없는(side-effect free) 메서드라고 부른다.
      • 함수 내에 포함되지 못한 기능을 부작용이라고 한다. 다음은 부작용의 예다.
        • 자료구조를 고치거나 필드에 값을 할당(setter 메서드 같은 생성자 이외의 초기화 동작)
        • 예외 발생
        • 파일에 쓰기 등의 I/O 동작 수행
    • 불변 객체를 이용해서 부작용을 없애는 방법도 있다. 불변 객체는 인스턴스화한 다음에는 객체의 상태를 바꿀 수 없는 객체이므로 함수 동작에 영향을 받지 않는다. 즉, 인스턴스화한 불변 객체의 상태는 결코 예상하지 못한 상태로 바뀌지 않는다. 따라서 불변 객체는 복사하지 않고 공유할 수 있으며, 객체의 상태를 바꿀 수 없으므로 스레드 안전성을 제공한다.
    • 부작용 없는 시스템 컴포넌트에서는 메서드가 서로 간섭하는 일이 없으므로 잠금을 사용하지 않고도 멀티코어 병렬성을 사용할 수 있다.
    • 부작용 없는 시스템의 개념은 함수형 프로그래밍에서 유래되었다.
  • 선언형 프로그래밍
    • ‘어떻게(how)’에 집중하는 프로그래밍 형식이다. 때로는 이를 명령형 프로그래밍이라고 부리기도 한다.
    • ‘어떻게’가 아닌 ‘무엇을’에 집중하는 방식도 있다. ‘무엇을’에 집중하는 방식을 선언형 프로그래밍이라고 부른다. 선언형 프로그래밍에서는 우리가 원하는 것이 무엇이고 시스템이 어떻게 그 목표를 달성할 것인지 등의 규칙을 정한다. 문제 자체가 코드로 명확하게 드러난다는 점이 선언형 프로그래밍의 강점이다.
  • 왜 함수형 프로그램인가?
    • 함수형 프로그래밍은 선언형 프로그래밍을 따르는 대표적인 방식이며, 부작용이 없는 계산을 지향한다.

함수형 프로그래밍이란 무엇인가?

  • 함수형이라는 말은 ‘수학의 함수처럼 부작용이 없는’을 의미한다. ‘함수 그리고 if-then-else 등의 수학적 표현만 사용’하는 방식을 순수 함수형 프로그래밍이라고 하며 ‘시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용’하는 방식을 함수형 프로그래밍이라 한다.
  • 함수형 자바
    • 자바로는 완벽한 순수 함수형 프로그래밍을 구현하기 어렵다. 예를 들어 자바의 I/O 모델 자체에는 부작용 메서드가 포함된다. nextLine을 호출하면 파일의 행을 소비한다. 즉 두 번 호출하면 다른 결과가 반환될 가능성이 있다. 하지만 시스템의 컴포넌트가 순수한 함수형인 것처럼 동작하도록 코드를 구현할 수 있다. 자바에서는 순수 함수형이 아니라 함수형 프로그램을 구현할 것이다. 실제 부작용이 있지만 아무도 이를 보지 못하게 함으로써 함수형을 달성할 수 있다.
    • 함수나 메서드에서 참조하는 객체가 있다면 그 객체는 불변 객체여야 한다. 즉 객체의 모든 필드가 final 이어야 하고 모든 참조 필드는 불변 객체를 직접 참조해야 한다. 예외적으로 메서드 내에서 생성한 객체의 필드는 갱신할 수 있다는 사실을 살펴볼 것이다. 단, 새로 생성한 객체의 필드 갱신이 외부에 노출되지 않아야 하고 다음에 메서드를 다시 호출한 결과에 영향을 미치지 않아야 한다.
    • 함수형이라고 말할 수 있으려면 이 외에도 다른 조건을 만족해야 한다. 함수형이라면 함수나 메서드가 어떤 예외도 일으키지 않아야 한다. 예외를 사용하지 않고 나눗셈 같은 함수를 표현하려면 어떻게 해야 할까? 바로 Optional<T>를 사용하면 된다.
  • 참조 투명성
    • ‘부작용을 감춰야 한다’라는 제약은 참조 투명성(referential transparency) 개념으로 귀결된다. 즉, 같은 인수로 함수를 호출했을 때 항상 같은 결과를 반환한다면 참조적으로 투명한 함수라고 표현한다.
    • 참조 투명성은 비싸거나 오랜 시간이 걸리는 연산을 기억화(memorization) 또는 캐싱(caching)을 통해 다시 계산하지 않고 저장하는 최적화 기능도 제공한다.
  • 객체지향 프로그래밍과 함수형 프로그래밍: 프로그래밍 형식을 스펙트럼으로 표현하자면 스펙트럼의 한 쪽 끝에는 모든 것을 객체로 간주하고 프로그램이 객체의 필드를 갱신하고, 메서드를 호출하고, 관련 객체를 갱신하는 방식으로 동작하는 익스트림 객체지향 방식이 위치한다. 스펙트럼의 반대쪽 끝에는 참조적 투명성을 중시하는, 즉 변화를 허용하지 않는 함수형 프로그래밍 형식이 위치한다. 실제로 자바 프로그래머는 이 두 가지 프로그래밍 형식을 혼홥한다.

재귀와 반복

  • 순수 함수형 프로그래밍 언어에서는 while, for 같은 반복문을 포함하지 않는다. 부작용 연산을 원천적으로 제거했기 때문이다. 그럼 어떻게 프로그램을 구현해야 할까? 이론적으로 반복을 이용하는 모든 프로그램은 재귀로도 구현할 수 있는데 재귀를 이용하면 변화가 일어나지 않는다.
public class Recursion {

  // 반복 방식의 팩토리얼
  static int factorialIterative(int n) {
    int r = 1;
    // 매 반복마다 변수 r과 i가 갱신
    for (int i = 1; i <= n; i++) {
      r *= i;
    }
    return r;
  }

  // 재귀 방식의 팩토리얼
  static long factorialRecursive(long n) {
    return n == 1 ? 1 : n * factorialIterative((int) (n - 1)));
  }

  // 스트림 팩토리얼
  static long factorialStreams(long n) {
    return LongStream.rangeClosed(1, n)
        .reduce(1, (long a, long b) -> a * b);
  }
}
  • 효율성 측면에서 반복 코드보다 재귀 코드가 더 비싸다. 재귀 함수를 호출할 때마다 호출 스택에 각 호출시 생성되는 정보를 저장할 새로운 스택 프레임이 만들어진다. 즉, 재귀 팩토리얼의 입력값에 비례해서 메모리 사용량이 증가한다. 따라서 큰 입력값을 사용하면 StackOverflowError가 발생한다.
    • 함수형 언어에서는 꼬리 호출 최적화(tail call optimization)라는 해결책을 제공한다.
    // 꼬리 재귀 팩토리얼
    static long factorialTailRecursive(long n) {
      return factorialHelper(1, n);
    }
    
    private static long factorialHelper(long acc, long n) {
      return n == 1 ? acc : factorialHelper(acc * n, n -1);
    }
    
    • factorialHelper에서 재귀 호출이 가장 마지막에서 이루어지므로 꼬리 재귀다. 반면 이전의 factorialRecursive에서 마지막으로 수행한 연산은 n과 재귀 호출의 결과값의 곱셈이다.
    • 중간 결과를 각각의 스택 프레임으로 저장해야 하는 일반 재귀와 달리 꼬리 재귀에서는 컴파일러가 하나의 스택 프레임을 재활용할 가능성이 생긴다.
    • 안타깝게도 자바는 이와 같은 최적화를 제공하지 않는다. 그럼에도 여전히 고전적인 재귀보다는 여러 컴파일러 최적화 여지를 남겨둘 수 있는 꼬리 재귀를 적용하는 것이 좋다. 스칼라, 그루비 같은 최신 JVM 언어는 이와 같은 재귀를 반복으로 변환하는 최적화를 제공한다.

Chapter19. 함수형 프로그래밍 기법

함수는 모든 곳에 존재한다

  • 함수형 언어 프로그래머는 함수형 프로그래밍이라는 용어를 좀 더 폭넓게 사용한다. 즉, 함수를 마치 일반값처럼 사용해서 인수로 전달하거나, 결과로 반환받거나, 자료구조에 저장할 수 있음을 의미한다.
  • 일반값처럼 취급할 수 있는 함수를 일급 함수(first-class function)라고 한다.
  • 고차원 함수
    • 다음 중 하나 이상의 동작을 수행하는 함수를 고차원 함수(higher-order function)라 부른다.
      • 하나 이상의 함수를 인수로 받음
      • 함수를 결과로 반환
    • 예: 함수를 인수로 받아서 다른 함수로 반환하는 정적 메서드 Comparator.comparing
  • 커링(currying)
    • 예제: 국제화 지원 시, 단위 변환 문제
      • 보통 변환 요소(conversion factor)와 기준치 조정 요소(baseline adjustment factor)가 단위 변환 결과를 좌우한다.
      • 섭씨를 화씨로 변환하는 공식이다.
      • f(x) = x * 9 / 5 + 30
      • 다음과 같은 패턴으로 단위를 표현할 수 있다.
        • 변환 요소를 곱합
        • 기준치 조정 요소를 적용
        static double converter(double x, double f, double b) {
          return x * f + b;
        }
        
      • 온도 뿐만 아니라 킬로미터와 마일 등의 단위도 변환해야 한다.
      • 위와 같이 메서드를 만들면 로직을 재활용하지 못한다는 단점이 있다.
      • 커링이라는 개념을 활용해서 한 개의 인수를 갖는 변환 함수를 생성하는 팩토리(factory)를 코드로 정의하면, 원하는 변환기를 생성할 수 있다.
      static DoubleUnaryOperator curriedConverter(double f, double b) {
        return x -> x * f + b;
      }
      
      // 팩토리를 이용해 원하는 작업을 수행하는 함수 생성
      DoubleUnaryOperator convertCtoF = curriedConverter(9.0 / 5, 32);
      DoubleUnaryOperator convertUSDtoGBP = curriedConverter(0.6, 0);
      DoubleUnaryOperator convertKmToMi = curriedConverter(0.6214, 0);
      
      double gbp = convertUSDtoGBP.applyAsDouble(1000);
      
    • 이론적 정의: x와 y라는 두 인수를 받는 함수 f를 한 개의 인수를 받는 g라는 함수로 대체하는 기법이다.

영속 자료기법

  • 함수형 프로그램에서는 함수형 자료구조, 불변 자료구조 등의 용어도 사용하지만 보통은 영속 자료구조라 부른다.
  • 파괴적인 갱신과 함수형
    • 단순한 명령형 메서드

      /**
       * X에서 Y까지, Y에서 Z까지의 여행을 나타내는 TrainJourney 객체를 연결해 하나의 여행을 만드는 메서드 (X -> Y -> Z)
      * */
      static TrainJourney link(TrainJourney a, TrainJourney b) {
        // a의 리스트 끝부분을 가리키는 null을 리스트 b로 대체
        // 문제점: link를 호출하면 a의 경로가 갱신
        // 결과적으로 부작용을 수반하는 메서드가 됨
        if (a == null) return b;
      
        TrainJourney t = a;
        while (t.onward != null) {
          t = t.onward;
        }
        t.onward = b;
        return a;
      }
      
    • 함수형

      /**
       * 함수형 해결 방법 사용, 기존 자료구조를 변경하지 않는다.
      * */
      static TrainJourney append(TrainJourney a, TrainJourney b) {
        return a == null ? b : new TrainJourney(a.price, append(a.onward, b));
      }
      
  • 이와 같은 함수형 자료구조를 영속(persistent, 저장된 값이 다른 누군가에 의해 영향을 받지 않는 상태)이라고 한다.

스트림과 게으른 평가

  • 스트림은 단 한 번만 소비할 수 있다는 제약이 있어서 재귀적으로 정의할 수 없다.
  • 지금까지 적극적으로(eagerly) 기능을 실행하는 것보다 게으른(lazy) 편이 좋다고 가정했다. 전통적인 실행 방법에서처럼 모든 값을 계산하는 것보다는 요청했을 때 값을 계산하는 것이 여러 면에서 좋다. 하지만, 현실에서는 상황이 단순하지 않다.
  • 게으른 자료구조는 강력한 프로그래밍 도구라는 사실을 기억하자. 애플리케이션을 구현하는 데 도움을 준다면 게으른 자료구조를 사용하자. 하지만 게으른 자료구조 때문에 효율성이 떨어진다면 전통적인 방식으로 코드를 구현하자.

패턴 매칭(pattern mathcing)

  • 패턴 매칭은 거의 모든 함수형 언어에서 제공하지만 자바에서 지원하지 않는다.

기타 정보

  • 캐싱 또는 기억화
    • 기억화(memorization)는 메서드 래퍼로 캐시(HashMap 같은)를 추가하는 기법이다. 래퍼가 호출되면 인수, 결과 쌍이 캐시에 존재하는지 먼저 확인한다. 캐시에 값이 존재하면 캐시에 저장된 값을 즉시 반환한다. 캐시에 값이 존재하지 않으면 결과를 계산한 다음에 새로운 인수, 결과 쌍을 캐시에 저장하고 결과를 반환한다.
    • 엄밀히 따지면 다수의 호출자가 공유하는 자료구조를 갱신하는 기법이므로 순수 함수형 해결방식은 아니지만 감싼 버전의 코드는 참조 투명성을 유지할 수 있다.
    • HashMap은 동기화되지 않으므로 스레드 안전성이 없는 코드다. HashMap 대신 잠금으로 보호되는 HashTable이나 잠금 없이 동시 실행을 지원하는 ConcurrentHashMap을 사용할 수 있지만 다중 코어에서 동시에 호출하면 성능이 크게 저하된다.
    • 가장 좋은 방법은 함수형 프로그래밍을 사용해서 동시성과 가변 상태가 만나는 상황을 완전히 없애는 것이다. 캐싱을 구현할 것인지 여부와는 별개로 코드를 함수형으로 구현했다면 우리가 호출하려는 메서드가 공유된 가변 상태를 포함하지 않음을 미리 알 수 있으므로 동기화 등을 신경 쓸 필요가 없어진다.
  • 콤비네이터
    • 함수형 프로그래밍에서 두 함수를 인수로 받아 다른 함수로 반환하는 등 함수를 조합하는 고차원(higher-order) 함수를 많이 사용하게 된다. 이처럼 함수를 조합하는 기능을 콤비네이터(combinator)라고 부른다.

Chapter20. OOP와 FP의 조화: 자바와 스칼라 비교

  • 스칼라는 객체지향과 함수형 프로그래밍을 혼합한 언어다. 보통 정적 형식의 프로그래밍 언어로 함수형 기능을 수행하면서도 JVM에서 수행되는 언어이므로 자바 느낌을 원하는 프로그래머가 찾는다.
  • 스칼라 코드에서 모든 자바 라이브러리를 사용할 수 있다.