Java

모던 자바 인 액션 - PART 6

wwns 2023. 10. 4. 23:21
반응형

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

함수형이란 무엇인지 앞에서 설명한 적이 있었다

  • 전역 변수를 사용하거나 변경하지 않아 side effect를 없애는 방식의 pure function
  • 함수를 인자로 받거나 함수를 반환값으로 이용 할 수 있음
  • 불변성
    • 불변성을 통해 Thread safe, 병렬처리, 함수 조합을 안전하게 제공
      이번 챕터에서는 함수형 프로그래밍의 개념, 기법을 좀 더 자세히 살펴본다

시스템 구현과 유지보수

실제 경험해본 적은 없는 대규모 소프트웨어 시스템 업그레이드 관리를 누군가가 요청했을 때
노련한 개발자라면 synchronized라는 키워드가 발견된다면 제안을 거절하라, synchronized가 없다면 시스템의 구조를 자세히 검토해보라는 풍문이 떠돌 정도라고 한다

그 만큼 변수의 값을 바꿀 수 있는 상태에 있는 변수를 사용하는 부분은 유지보수하기 쉽지 않다는 것을 의미한다

실질적으로 많은 프로그래머가 유지보수 중 코드 크래시 디버깅 문제를 가장 많이 겪게 되며 코드 크래시는 예상하지 못한 변숫값 때문에 발생할 수 있다

왜 그리고 어떻게 변숫값이 바뀐 걸까? (전역 변수의 사용?)
함수형 프로그래밍이 제공하는 부작용 없음 (no side effect), 불변성(immutablility)이라는 개념이 이 문제를 해결하는 데 도움을 준다

공유된 가변 데이터

  • 변수가 예상하지 못한 값을 갖는 이유는 우리가 유지보수하는 시스템의 여러 메서드에서 공유된 가변 데이터 구조를 읽고 갱신하기 때문이다

리스트를 참조하는 여러 클래스가 있다고 가정

  • 리스트의 소유자는 어느 클래스가 되는가?
  • 이들 중 하나의 클래스가 리스트를 갱신하면 어떻게 되는가?
    • 다른 클래스는 리스트가 갱신되었다는 사실을 알고 있을까?
    • 다른 클래스에 리스트가 갱신되었음을 어떻게 알려줄 수 있는가?
    • 리스트의 갱신을 알리는 것과 사본을 만드는 것?
      이처럼 공유 가변 데이터 구조를 사용하면 프로그램 전체에서 데이터 갱신 사실을 추적하기 어려워짐

-> 예상하지 못하게 자료구조의 값이 바뀔 일이 없도록한다면 유지보수가 쉬움
자신을 포함하는 클래스의 상태, 다른 객체의 상태를 바꾸지 않으며 return 문을 통해서만 자신의 결과를 반환하는 메서드를 순수 메서드, 부작용 없는 메서드라고 부른다

구체적인 부작용의 예

  • 자료구조를 고치거나 필드에 값을 할당(Setter 메서드 같은 생성자 외의 초기화 동작)
  • 예외 발생
  • 파일에 쓰기 등의 I/O 동작 수행

불변 객체를 이용해서 부작용을 없애는 방법도 존재

  • 불변 객체는 인스턴스화한 다음에는 객체의 상태를 바꿀 수 없는 객체이므로 함수 동작에 영향을 받지 않음

선언형 프로그래밍

함수형 프로그래밍의 기반을 이루는 선언형 프로그래밍

프로그램으로 시스템을 구현하는 방식은 크게 두 가지로 구분할 수 있음

  • 이 일을 먼저하고, 그 다음에 값을 갱신하고, ..처럼 어떻게 수행할 것인지에 집중하는 방법 -> 고전의 객체지향 프로그래밍에서 이용하는 방식, 명령형 프로그래밍
  • 어떻게가 아닌 무엇을에 집중하는 방식 -> 스트림 API로 내부 반복을 통해 문제를 어떻게 푸는지 명확하게 보여주는 선언형 프로그래밍

선언형 프로그래밍에서는 우리가 원하는 것이 무엇이고, 시스템이 어떻게 그 목표를 달성할 것인지 등의 규칙을 정한다
-> 문제 자체가 코드로 명확하게 드러난다는 점이 선언형 프로그래밍의 강점

왜 함수형 프로그래밍인가?

  • 함수형 프로그래밍은 선언형 프로그래밍을 따르는 대표적인 방식이며, 부작용이 없는 계산을 지향
  • 람다 표현식을 이용해서 보여준 것 처럼 작업을 조합하거나 동작을 전달하는 등의 언어 기능은 선언형을 활용해서 자연스럽게 읽고 쓸 수 있는 코드를 구현하는 데 도움을 줌

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

함수형 프로그래밍에서 함수란 수학적인 함수와 같다

  • 함수는 0개 이상의 인수를 가지며, 한 개 이상의 결과를 반환하지만 부작용이 없어야 한다

  • 다른 객체의 필드를 고치거나 상태 변화가 없어야한다

  • 함수, if-then-else 등의 수학적 표현만 사용하는 방식을 순수 함수형 프로그래밍이라고 함

  • 시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용하는 방식을 함수형 프로그래밍이라 한다

함수형 자바

실질적으로 자바는 완벽한 순수 함수형 프로그래밍을 구현하기 어렵다

  • 순수 함수형이 아니라 함수형 프로그램을 구현할 것
  • 실제 부작용이 있지만 아무도 이를 보지 못하게 함으로써 함수형을 달성할 수 있음

만약 메서드의 작업이 어떤 필드의 값을 증가시켰다가 빠져 나올 때 필드의 값을 돌려놓는다고 가정하면
단일 스레드에서는 부작용이 없기 때문에 함수형이라고 간주
하지만 다른 스레드가 필드의 값을 중간에 확인한다든가 동시에 메서드를 호출한다면 이 메서드는 함수형이 아니게 됨
메서드의 바디를 잠금으로써 문제를 해결할 수 있으며 이 때 함수형이라고 할 수 있음
하지만 병렬로 호출할 수 없음
-> 함수형으로 구현했지만, 프로그램의 실행속도가 느려지게 된 것

따라서 함수형은

  • 지역 변수만을 변경해야 함
  • 참조하는 객체가 있다면 불변 객체여야 함
  • 예외적으로 메서드 내에서 생성한 객체의 필드는 갱신할 수 있지만, 새로 생성한 객체의 필드 갱신이 외부에 노출되지 않아야 하며 다음에 메서드를 호출한 결과에 영향을 미치지 않아야 함

함수형은 예외가 발생할 수 있을 경우 함수형에 위배되는 제어 흐름이 발생하여 함수형을 위배할 수 있다
-> 함수형은 어떠한 예외도 일으키지 않아야 한다

  • 값을 나누는 메서드에서 0으로 나누는 경우 예외 처리가 필요함
    • Optional을 사용하여 예외 없이도 결과 값으로 연산을 성공적으로 수행했는지, 요청된 연산을 성공적으로 수행하지 못했는지 확인할 수 있음

함수형에서는 비함수형 동작을 감출 수 있는 상황에서만 부작용을 포함하는 라이브러리 함수를 사용해야 한다

  • insertAll이라는 메서드 내에서 List.add를 호출하기 전에 미리 리스트를 복사함으로써 라이브러리 함수에서 일으키는 부작용을 감춘다
    • List.add는 요소를 추가하는 비함수형 메서드인데, 함수형 내부에서 비함수형을 호출하여 어떤 필드를 수정하게 되면 부작용이 발생
    • 미리 리스트를 복사하고 add한다는 것은 새로 생성한 객체의 필드를 갱신하는 함수형 특징을 활용하는 것으로 볼 수 있다

참조 투명성

부작용을 감춰야 한다라는 제약은 참조 투명성개념으로 귀결된다

  • 같은 인수로 함수를 호출했을 때 항상 같은 결과를 반환한다면 참조적으로 투명한 함수
  • 함수는 어떤 입력이 주어져을 때 언제, 어디서 호출하든 같은 결과를 생성해야 한다

Scanner가 키보드 입력을 받는다면 참조 투명성을 위배
final int 변수를 더하는 연산에서는 두 변수를 바꿀 수 없으므로 항상 같은 결과를 생성

자바에서는 참조 투명성과 관련한 작은 문제가 있다

  • List를 반환하는 메서드를 두 번 호출한다고 가정
    • 두 번의 호출 결과로 같은 요소를 포함하지만 서로 다른 메모리 공간에 생성된 리스트를 참조
    • 결과 리스트가 가변 객체라면 리스트를 반환하는 메서드는 참조적으로 투명한 메서드가 아니라는 결론이 나옴
    • 결과 리스트를 불변의 순수값으로 사용할 것이라면 두 리스트가 같은 객체라고 볼 수 있으므로 리스트 생성 함수는 참조적으로 투명한 것으로 간주

-> 일반적으로 함수형 코드에서는 이런 함수를 참조적으로 투명한 것으로 간주한다

객체지향 프로그래밍과 함수형 프로그래밍

프로그래밍 형식을 스펙트럼으로 표현하면
한 쪽 끝에는 모든 것을 객체로 간주하고, 프로그램이 객체의 필드를 갱신, 메서드를 호출하여 관련 객체를 갱신하는 방식의 익스트림 객체지향 방식이 위치
반대쪽 끝에는 참조적 투명성을 중시하는, 즉 변화를 허용하지 않는 함수형 프로그래밍 형식이 위치
-> 객체지향과 함수형은 정반대의 프로그래밍 방식

실제로 자바 프로그래머는 이 두 가지 프로그래밍 형식을 혼합하여 사용한다

재귀와 반복

순수 함수형 프로그래밍 언어에선 while, for 같은 반복문을 포함하지 않는다
반복문 때문에 변화가 자연스럽게 코드에 스며들 수 있기 때문

다른 누군가가 변화를 알아차리지만 못한다면 아무 상관이 없다 -> 지역 변수는 자유롭게 갱신이 가능
하지만 루프 내부에서 프로그램의 다른 부분과 공유되는 객체의 상태를 변화시키면 안됨

public void searchForGold(List<String> l, Stats stats) {
  for(String s : l) {
    if("gold".equals(s)) {
        stats.incrementFor("gold");  // 공유된 객체의 상태를 변화!
    }
  }
}

순수 함수형 프로그래밍 언어에서는 부작용 연산을 원칙적으로 제거했다
-> 재귀를 이용하면 변화가 일어나지 않으며, 루프 단계마다 갱신되는 반복 변수를 제거할 수 있다

// 반복 방식의 팩토리얼
static int factorialIterative(int n) {
    int r = 1;
    for (int i = 1; i <= n; i++) {
        r *= i;
    }
    return r;
}
// 재귀 방식의 팩토리얼
static long factorialRecursive(long n) {
    return n == 1 ? 1 : n * factorialRecursive(n-1);
}
  • 첫번째 예제에서는 일반적인 루프를 사용하여 변수 r과 i가 갱신
  • 두번째 예제에서는 재귀 방식의 코드로 좀 더 수학적인 형식으로 문제를 해결

효율성 측면으로 두 방식을 비교해본다면 무조건 반복보다는 재귀가 좋다고 주장하는 함수형 프로그래밍 광신도의 주장에 주의해야 한다고 한다
일반적으로 반복 코드보다 재귀 코드가 더 비싸다 -> 호출 스택에 각 호출 시 생성되는 정보를 저장할 새로운 스택 프레임이 만들어지기 때문

그렇지만 함수형 언어에서는 꼬리 호출 최적화라는 해결책을 제공한다

  • 중간 결과를 각각의 스택 프레임으로 저장해야 하는 일반 재귀와 달리 꼬리 재귀에서는 컴파일러가 하나의 스택 프레임을 재활용할 가능성이 생김
    스크린샷 2023-10-01 오후 8 30 43
// 꼬리 재귀 팩토리얼
static long factorialTailRecursive(long n) {
  return factorialHelper(1, n);
}
static long factorialHelper(long acc, long n) {
  return n == 1 ? acc : factorialHelper(acc*n, n-1);
}

factorialHelper의 정의에서는 중간 결과(팩토리얼의 부분결과)를 함수의 인수로 직접 전달

스크린샷 2023-10-01 오후 8 32 48

안타깝게도 자바는 이와 같은 최적화를 제공하지 않지만 고전적인 재귀보다는 여러 컴파일러 최적화 여지를 남겨둘 수 있는 꼬리 재귀를 사용하는 것이 좋다
스칼라, 그루비 같은 최신 JVM 언어는 이와 같은 재귀를 반복으로 변환하는 최적화를 제공한다

자바8에서는 반복을 스트림으로 대체해서 변화를 피할 수 있다 또한 반복을 재귀로 바꾸면 간결하고, 부작용이 없는 알고리즘을 만들 수 있다

정리

  • 공유된 가변 자료구조를 줄이는 것은 장기적으로 프로그램을 유지보수하고 디버깅하는데 도움이 된다
    • 작은 메모리를 최적화하겠다고, 가변 자료구조를 공유하는것은 유지보수에 큰 비용을 지불하게 한다
  • 함수형 프로그래밍은 부작용이 없는 메서드와 선언형 프로그래밍 방식을 지향한다
  • 함수형 메서드는 입력 인수와 출력 결과만을 갖는다
  • 같은 인수값으로 함수를 호출했을 때 항상 같은 값을 반환하면 참조 투명성을 갖는 함수다. while 루프 같은 반복문은 재귀로 대체할 수 있다
  • 자바에서는 고전 방식의 재귀보다는 꼬리 재귀를 사용해야 추가적인 컴파일러 최적화를 기대할 수 있다

Chapter19 함수형 프로그래밍 기법

학술적 지식뿐 아니라 실용적 기법을 통해 좀 더 고급적인 함수형 프로그래밍 기법을 소개

함수는 모든 곳에 존재한다

함수를 마치 일반값처럼 사용해서 인수로 전달하거나, 결과로 반환받거나, 자료구조에 저장할 수 있는 것을 함수형 프로그래밍이라고 한다

  • 일반값처럼 취급할 수 있는 함수를 일급 함수라고 한다
  • 자바 8에서는 ::연산자로 메서드 참조를 만들거나 (int x) -> x + 1 같은 람다 표현식으로 직접 함숫값을 표현해서 메서드를 함숫값으로 사용할 수 있다

고차원 함수

-> Comparator c = comparing(Apple::getWeight);

함수형 프로그래밍 커뮤니티에 따르면 Comparator.comparing처럼 다음 중 하나 이상의 동작을 수행하는 함수를 고차원 함수라 부른다

  • 하나 이상의 함수를 인수로 받음
  • 함수를 결과로 반환

부작용과 고차원 함수

  • 부작용을 포함하는 함수를 사용하면 부정확한 결과가 발생하거나 레이스 컨디션 때문에 예상치 못한 결과가 발생할 수 있다

고차원 함수를 적용할 때도 같은 규칙이 적용된다

  • 고차원 함수나 메서드를 구현할 때 어떤 인수가 전달 될 지 알 수 없으므로 인수가 부작용을 포함할 가능성을 염두에 둬야함
  • 함수를 인수로 받아 사용하면서 코드가 정확히 어떤 작업을 수행하고 프로그램의 상태를 어떻게 바꿀지 예측하기 어려워짐 -> 디버깅도 어려워짐
  • 인수로 전달된 함수가 어떤 부작용을 포함하게 될 지 정확하게 문서화하는 것이 좋음

커링

커링의 이론적 정의

  • 커링은 x와 y라는 두 인수를 받는 함수 f를 한 개의 인수를 받는 g라는 함수로 대체하는 기법
  • f(x, y) = (g(x))(y)가 성립
    함수를 모듈화하고 코드를 재사용하는 데 도움을 주는 기법

영속 자료구조

함수형 프로그램에서는 함수형 자료구조, 불변 자료구조 등의 용어도 사용하지만 보통 영속 자료구조라고 부른다

파괴적인 갱신과 함수형

자료구조를 갱신할 때 발생할 수 있는 문제가 존재한다

  • A에서 B까지 기차여행을 의미하는 가변 TrainJourney 클래스가 존재한다고 가정
  • 단방향 연결 리스트로 구현됨
  • X에서 Y까지, 별도의 Y에서 Z까지의 여행을 나타내는 TrainJourney객체가 있을 때 둘을 link하여 하나의 여행을 만들고자 함
// 파괴적인 갱신
static TrainJourney link(TrainJourney a, TrainJourney b) {
    if(a==null) return b;
    TrainJourney t = a;
    while(t.onward != null) {
        t = t.onward;    // 단방향 연결 리스트의 맨 마지막으로 이동
    }
    t.onward = b;
    return a;
}
  • a의 TrainJourney에서 마지막 여정을 찾아 a의 리스트 끝 부분에 b로 대체
  • 결과적으로 두 인수 a, b에서 a를 갱신하면서 파괴적인 갱신이 일어난다
  • a에 의존하던 코드들은 원하는대로 동작하지 않게 됨
    • 서울역에서 구미역으로 여정을 진행하고 있던 a
    • 갑자기 이 여정이 서울역에서 구미역을 거쳐 부산역까지 도착하는 여정으로 변경되는 상황이 발생
// 함수형 해결 방법
static TrainJourney append(TrainJourney a, TrainJourney b) {
  return a == null ? b : new TrainJourney(a.price, append(a.onward, b));
}
  • 기존 자료구조를 변경하지 않음
  • TrainJourney 전체를 새로 만들지도 않음
  • 주의할 점은 append의 결과를 갱신하지 말아야 함
    • append의 결과를 갱신하면 b로 전달된 기차 정보도 바뀜

스크린샷 2023-10-02 오전 11 11 17

  • 첫번째 TrainJourney 노드를 복사한 값이 결과에 포함되며, 두 번째 TrainJourney와 공유됨

트리를 사용한 다른 예제

HashMap 같은 인터페이스를 구현할 때는 이진 탐색 트리가 사용된다

이진 탐색 트리에 새로운 노드를 추가할 때 문제가 발생한다

  • update 메서드가 탐색한 트리를 그대로 반환하게하자 (새로운 노드를 추가하지 않으면 그대로 반환)
  • update가 즉석에서 트리를 갱신할 수 있으며, 전달한 트리가 그대로 반환된다는 사실, 원래 트리가 비어있으면 새로운 노드가 반환될 수 있음
    update는 기존 트리를 변경하므로 모든 사용자가 변경에 영향을 받음

함수형 접근법 사용

  • 새로운 키/값 쌍을 저장할 새로운 노드를 만든다
  • 트리의 루트에서 새로 생성한 노드의 경로에 있는 노드들도 새로 만든다
  • 인수로 전달된 트리 자료구조를 변화시키지 않고 새로운 트리를 반환한다
    스크린샷 2023-10-02 오전 11 49 11

만약 어떤 사람이 나는 일부 사용자만 볼 수 있게 트리를 갱신하면서도 다른 일부 사용자는 이를 알아차릴 수 없게 하고 싶다고 말한다면 두 가지 방법이 있다

  • 고전적인 자바 해법(어떤 값을 갱신할 때 먼저 복사해야 하는지 주의 깊게 확인)
  • 함수형 해법 (갱신을 수행할 때마다 논리적으로 새로운 자료구조를 만든 다음에 사용자에게 적절한 버전의 자료구조를 전달)

스트림과 게으른 평가

스트림은 단 한 번만 소비할 수 있다는 제약이 있어 재귀적으로 정의할 수 없다
이와 같은 제약 때문에 어떤 문제가 발생할까?

자기 정의 스트림

소수를 생성하는 재귀 스트림을 살펴보자 (알고리즘 측면에서는 여러 가지 면에서 부족한 코드다)

public static Stream<Integer> primes(int n) {
    return Stream.iterate(2, i -> i + 1)
            .filter(MyMathUtils::isPrime)
            .limit(n);
}
public static boolean isPrime(int candidate) {
    int candidateRoot = (int) Math.sqrt((double) candidate);
    return IntStream.rangeClosed(2, candidateRoot)
            .noneMatch(i -> candidate % i == 0);
}
  • 1단계 : 스트림 숫자 얻기
    • IntStream.iterate 메서드를 이용하면 무한 숫자 스트림을 얻을 수 있다
  • 2단계 : 머리 획득
    • IntStream은 첫 번째 요소를 반환하는 findFirst라는 메서드를 제공
    • static int head(IntStream numbers) { return numbers.findFirst().getAsInt(); }
  • 3단계 : 꼬리 필터링
    • 스트림의 꼬리를 얻는 메서드를 정의
    • ```java
      static IntStream tail(IntStream numbers) {
      return numbers.skip(1);
      }
  • 4단계 : 재귀적으로 소수 스트림 생성
    • 반복적으로 머리를 얻어서 스트림을 필터링하려 할 수 있다
    • static IntStream primes(IntStream numbers) {
          int head = head(numbers);
          return IntStream.concat(IntStream.of(head), primes(tail(numbers).filter(n -> n % head != 0)));
      }
      

안타깝게도 4단계 코드를 실행하면 stream has already been operated upon or closed라는 에러갑 발생
-> findFirst와 skip이라는 두 개의 최종 연산을 사용했음

게으른 평가

위의 문제보다 더 심각한 문제가 있다
IntStream.concat은 두 개의 스트림 인스턴스를 인수로 받는데, 두 번째 인수가 primes를 직접 재귀적으로 호출하면서 무한 재귀에 빠진다

  • 자바 8의 스트림 규칙은 재귀적 정의 허용하지 않음으로 데이터베이스 같은 질의를 표현하고 병렬화할 수 있는 능력을 얻을 수 있다
    결론적으로 primes를 게으르게 평가하는 방식으로 문제를 해결할 수 있다

  • Supplier를 이용해서 게으른 리스트를 만들면 꼬리가 모두 메모리에 존재하지 않게 할 수 있음

  • Supplier로 리스트의 다음 노드를 생성

요청할 때만 자료구조를 생성하게 함으로써 모든 데이터를 생성해놓는 것이 아니라 필요한 부분까지 생성해놓을 수 있으며 이를 캐싱하여 사용할 수 있다
게으른 자료구조는 강력한 프로그래밍 도구라는 사실을 기억하자

패턴 매칭

함수형 프로그래밍을 구분하는 또 하나의 중요한 특징이 패턴 매칭이다 (자바에서는 지원하지 않음)
자바에서는 if-then-else나 switch문을 통해 조건을 처리해야 했다
자료형이 복잡해지면서 이러한 작업을 처리하는데 필요한 코드의 양도 증가

방문자 디자인 패턴

표현식이나 작업을 처리하는 메서드를 구현해야 한다면 코드가 매끄럽지 못해진다
자바에서는 방문자 디자인 패턴으로 자료형을 언랩할 수 있다

  • 방문자 클래스는 지정된 뎅이터 형식의 인스턴스를 입력으로 받아 인스턴스의 모든 멤버에 접근하여 알고리즘을 처리

패턴 매칭의 힘

패턴 매칭이라는 단순한 해결방법도 존재한다

// 스칼라 문법의 패턴 매칭
def simplifyExpression(expr: Expr): Expr = expr match {
    case BinOp("+", e, Number(0)) => e  // 0 더하기
    case BinOp("*", e, Number(1)) => e  // 1 곱하기
    case BinOp("/", e, Number(1)) => e  // 1 나누기
    case _ => expr  // expr을 단순화할 수 없다
}

위 코드는 expr이 BinOp인지 확인하고 expr에서 세 컴포넌트를 추출한 다음 패턴 매칭을 시도한다
-> 스칼라의 패턴매칭은 다수준이라고 할 수 있음

자바로도 패턴 매칭을 흉내낼 수 있다

자바의 switch와 if-then-else가 패턴 매칭에는 도움이 되지 않지만 람다를 이용하면 단일 수준의 패턴 매칭을 간단하게 표현할 수 있으므로 여러 개의 if-then-else 구문이 연결되는 상황을 깔끔히 정리할 수 있다

  • 람다를 이용하여 각 표현식을 단일 수준으로 처리할 수 있음
  • 각 함수형의 결과를 합쳐서 패턴 매칭을 적용시킬 수 있음

기타 정보

함수형과 참조 투명성이라는 특성과 관련된 주제를 살펴본다

  • 효율성
  • 같은 결과를 반환하는 것과 관련된 염려 사항

캐싱 또는 기억화

네트워크 범위 내에 존재하는 노드 수를 계산하는 computeNumberOfNodes(Range)라는 부작용 없는 메서드가 있다고 가정
네트워크는 불변이지만 computeNumberOfNodes를 호출했을 때 구조체를 재귀적으로 탐색해야 하므로 노드 계산 비용이 비싸다

-> 표준적인 해결책으로 기억화 기법이 있으며, 기억화는 메서드에 래퍼로 캐시(HashMap 같은)를 추가하는 기법

만약 캐시로 HashMap을 사용하면 HashMap은 동기화되지 않아 스레드 안전성이 없다

  • HashMap 대신 잠금으로 보호되는 HashTable, 잠금 없이 동시 실행을 지원하는 ConcurrentHashMap을 사용할 수 있다?
  • 다중 코어에서 numberOfNodes를 동시에 호출하면 성능이 저하될 수 있음

맵에 Range가 있는지 찾는 과정과 인수, 결과 쌍을 맵에 추가하는 동작 사이에서 레이스 컨디션이 발생하기 때문에 이러한 부분을 고려해야 했다

-> 가장 좋은 방법은 함수형 프로그래밍을 사용해서 동시성과 가변 상태가 만나는 상황을 완전히 없애는 것이다

같은 객체를 반환함은 무엇을 의미?

  • 참조 투명성이란 인수가 같다면 결과도 같아야 한다라는 규칙을 만족함을 의미
  • 같은 인수로 fupdate 메서드를 호출해서 반환받은 t2, t3는 서로 다른 참조다 (새로운 노드를 추가해서 새로운 트리를 반환했었음)
    • 서로 참조가 다르기 때문에 참조 투명성을 갖지 않는다고 하는가?
    • 함수형 프로그래밍에서는 데이터가 변경되지 않으므로 같다는 의미는 ==이 아니라 구조적인 값이 같다는 것을 의미

콤비네이터

함수형 프로그래밍에서는 두 함수를 인수로 받아 다른 함수를 반환하는 등 함수를 조합하는 고차원 함수를 많이 사용하게 된다
이처럼 함수를 조합하는 기능을 콤비네이터라고 부른다

static <A, B, C> Function<A, C> compose(Function<B, C> g, Function<A, B> f) {
    return x -> g.apply(f.apply(x));
}
  • compose함수는 f와 g를 인수로 받아서 f의 기능을 적용한 다음에 g의 기능을 적용하는 함수를 반환
  • 이 함수를 활용하면 콤비네이터로 내부 반복을 수행하는 동작을 정의할 수 있음

정리

  • 일급 함수란 인수로 전달하거나, 결과로 반환하거나, 자료구조에 저장할 수 있는 함수
  • 고차원 함수란 한 개 이상의 함수를 인수로 받아서 다른 함수를 반환하는 함수다. 자바에서는 comparing, andThen, compose 등의 고차원 함수를 제공
  • 커링은 함수를 모듈화하고 코드를 재사용할 수 있도록 지원하는 기법
  • 영속 자료구조는 갱신될 때 기존 버전의 자신을 보존, 결과적으로 자신을 복사하는 과정이 따로 필요하지 않다
  • 자바의 스트림은 스스로 정의할 수 없다
  • 게으른 리스트는 자바 스트림보다 비싼 버전으로 간주할 수 있다
    • 데이터를 요청했을 때 Supplier를 이용해서 요소를 생성하며, 자료구조의 요소를 생성하는 역할을 수행
  • 패턴 매칭은 자료형을 언랩하는 함수형 기능이다. 자바의 switch문을 일반화할 수 있다
  • 참조 투명성을 유지하는 상황에서는 계산 결과를 캐시할 수 있다
  • 콤비네이터는 둘 이상의 함수나 자료구조를 조합하는 함수형 개념

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

자바와 마찬가지로 스칼라는 컬렉션을 함수형으로 처리하는 개념(스트림과 비슷한 연산), 일급 함수, 디폴트 메서드 등을 제공한다
하지만 스칼라는 자바에 비해 더 다양하고 심화된 함수형 기능을 제공한다
스칼라와 자바에 적용된 함수형의 기능을 살펴보면서 자바의 한계가 무엇인지 확인할 수 있는 시간이 될 것이다

함수

스칼라의 함수는 어떤 작업을 수행하는 일련의 명령어 그룹
자바에서는 클래스와 관련된 함수에 메서드라는 이름이 사용된다

  • 스칼라의 함수는 일급값
  • Integer, String처럼 함수를 인수로 전달하거나 결과로 반환하거나 변수에 저장할 수 있음
    def isJavaMentioned(tweet: String) : Boolen = tweet.contains("Java")
    def isShortTweet(tweet: String) : Boolean = tweet.lenght < 20
    Boolean을 반환하는 프레디케이트로 표현

익명 함수와 클로저

스칼라도 익명 함수의 개념을 지원
자바의 람다 표현식으로 함수형 인터페이스의 인스턴스를 만들 수 있다 비슷한 방식으로 스칼라도 익명 함수를 만들 수 있다

val isLongTweet : String => Boolean =
    (tweet : String) => tweet.length() > 60    // 익명함수
  • 자바에서는 람다 표현식을 사용할 수 있도록 Predicate, Function, Consumer 등의 내장 함수형 인터페이스를 제공
  • 스칼라는 트레이트를 지원 (일단 트레이트는 인터페이스와 같다고 생각하자)

클로저란 함수의 비지역 변수를 자유롭게 참조할 수 있는 함수의 인스턴스를 가리킴

  • 람다 표현식에는 람다가 정의된 메서드의 지역 변수를 고칠 수 없다는 제약 -> 암시적으로 final로 취급됨 -> 람다는 변수가 아닌 값을 닫는다

스칼라의 익명 함수는 값이 아니라 변수를 캡처할 수 있음
-> 지역 변수를 캡처하고 함수내에서 증가시킬 수 있음

커링

커링기법이란 x, y라는 두 인수를 가진 f라는 함수가 있을 때 이는 하나의 인수를 받는 g라는 함수 그리고 g라는 함수는 다시 나머지 인수를 받는 함수로 반환되는 상황으로 볼 수 있다는 것

static int multiply(int x, int y) {
    return x * y;
}
int r = multiply(2, 10);

이 함수는 전달된 모든 인수를 사용하는데, multiply 메서드를 분할할 수 있다

static Function<Integer, Integer> multiplyCurry(int x) {
    return (Integer y) -> x * y;
}

multiplyCurry가 반환하는 함수는 x와 인수 y를 곱한 값을 캡처한다 다음처럼 map과 multiplyCurry를 연결해서 각 요소에 2를 곱할 수 있다

Stream.of(1, 3, 5, 7)
    .map(multiplyCurry(2))
    .forEach(System.out::println);        // 2, 6, 10, 14

클래스와 트레이트

스칼라의 클래스와 인터페이스는 자바에 비해 더 유연함을 제공한다

간결성을 제공하는 스칼라의 클래스

스칼라는 완전한 객체지향 언어이므로 클래스를 만들고 객체로 인스턴스화할 수 있음
스칼라에서는 생성자, 게터, 세터가 암시적으로 생성되므로 코드가 훨씬 단순해짐

class Student(var name: String, var id: Int) // 클래스 선언
val s = new Student("Raoul", 1);
s.id = 1337;    

스칼라 트레이트와 자바 인터페이스

스칼라는 트레이트라는 유용한 추상 기능도 제공 -> 자바의 인터페이스를 대체

  • 트레이트는 다중 상속을 지원하므로 자바의 인터페이스와 디폴트 메서드 기능이 합쳐진 것으로 이해할 수 있다
trait Sized {    
  var size : Int = 0
  def isEmpty() = size == 0
}

class Box extends Sized // 트레이트에서 상속받은 클래스

class Box    // 상속받지 않고 인스턴스화할 때 트레이트를 조합하여 사용할 수 있음
val b1 = new Box() with Sized // 객체를 인스턴스화 할 때 트레이트를 조합함
println(b1.isEmpty()) // true
val b2 = new Box()
b2.isEmpty() // 컴파일 에러: Box 클래스 선언이 Sized를 상속하지 않음

Chapter21 결론 그리고 자바의 미래

자바 8의 기능 리뷰

자바 8에 추가된 대부분의 새로운 기능은 자바에서 함수형 프로그래밍을 쉽게 적용할 수 있도록 도와준다
이렇게 큰 변화가 생긴 이유는 두 가지 추세 때문

  • 멀티코어 프로세서의 파워를 충분히 활용해야 한다는 것
    • 개별 CPU 코어의 속도가 빨라지고 있음
    • 코드를 병렬로 실행해야 더 빠르게 코드를 실행할 수 있음
  • 데이터 소스를 이용해서 주어진 조건과 일치하는 모든 데이터를 추출하고, 결과에 어떤 연산을 적용하는 등 선언형으로 데이터를 처리하는 방식 -> 데이터 컬렉션을 다루는 추세
    • 데이터 컬렉션을 처리하려면 불변값을 생산할 수 있는 불변 객체와 불변 컬렉션이 필요

동작 파라미터화(람다와 메서드 참조)

메서드로 전달되는 값이 Function<T, R>, Predicate, BiFunction<T, U, R> 등의 형식을 가지게 할 수 있음

스트림

기존의 컬렉션에 람다를 활용한 filter, map 등의 메서드를 추가해서 데이터베이스 질의 같은 기능을 제공하는 비교적 쉬운 방법을 선택할 수 있었지만 새로운 스트림 API를 만듦

  • 컬렉션에 세 가지 연산을 적용한다고 가정했을 때 컬렉션은 각각의 연산을 하나씩 수행해야한다
  • 스트림 API는 이들 연산을 파이프라인이라는 게으른 형식의 연산으로 구성하여 한 번의 탐색으로 파이프라인의 모든 연산을 수행한다
  • 큰 데이터 집합일수록 스트림의 데이터 처리 방식이 효율적이며, 또한 메모리 캐시 등의 관점에서도 커다란 데이터 집합일수록 탐색 횟수를 최소화하는 것이 중요

또한 멀티코어 CPU를 활용해서 병렬로 요소를 처리하는 기능도 매우 중요
-> 스트림으 parallel 메서든 스트림을 병렬로 처리하도록 지정하는 역할

  • 상태 변화는 병렬성의 가장 큰 걸림돌
  • 함수형 개념은 map, filter 등의 연산을 활용하는 스트림의 병렬 처리의 핵심으로 자리잡음
    • 부작용이 없는 연산, 람다와 메서드 참조로 파라미터화된 메서드, 내부반복 지원

Optional 클래스

Optional는 에러가 잘 발생할 수 있는 계산을 수행하면서 값이 없을 때 에러를 발생시킬 수 있는 null 대신 정해진 데이터 형식을 제공할 수 있다

  • 값이 없는 상황을 사용자 코드에서 확인하는 것이 아니라 라이브러리에서 확인하는 것은 -> 내부반복 vs 외부반복과 같은 의미

디폴트 메서드

디폴트 메서드 덕분에 인터페이스 설계자는 메서드의 기본 구현을 제공할 수 있음
인터페이스에 새로운 기능을 추가했을 때 기존의 모든 고객이 새로 추가된 기능을 구현하지 않을 수 있게 되었다

자바 10 지역 변수형 추론

람다 표현식에서 형식 추론이 있었다
형식이 생략되면 컴파일러가 형식을 추론한다

자바의 지역 변수에서도 한 개의 식별자로 구성된 형식에 형식 추론을 사용하면 다양한 장점이 생긴다

  • 다른 형식으로 교체할 때 편집 작업이 줄어듦
  • 형식의 크기가 커지면서 제네릭이 다른 제네릭 형식에 의해 파라미터화될 수 있는데 이런 상황에서 가독성이 좋아질 수 있음

자바 10에서는 지역 변수형 추론을 지원하며 초깃값이 없을 때는 var을 사용할 수 없음

자바의 미래

풍부한 형식의 제네릭

자바 5에서 제네릭을 소개햇을 때 제네릭이 기존 JVM과 호환성을 유지해야 했다

  • ArrayList이나 ArrayList 모두 런타임 표현이 같게 되었다 -> 이를 제네릭 다형성의 삭제 모델이라 함
    이 때문에 약간의 런타임 비용을 지불하게 되었으며 제네릭 형식의 파라미터로 객체만 사용할 수 있게 되었다 (기본형은 제네릭 형식으로 사용할 수 없음)

GC는 ArrayList의 13이라는 요소가 int인지 Integer인지 분간할 수 없음

  • 가비지 컬렉션이 필드가 참조인지 기본형인지 알 수 있도록 충분한 형식 정보를 런타임에 유지해야하며 이를 다형성 구체화 모델, 구체화된 제네릭이라 부른다
    • ArrayList를 쓰지 않으며 제네릭 다형성의 삭제 모델을 사용
  • 자바의 가장 큰 걸림돌은 과거 버전과의 호환성으로 리플렉션을 사용하는 JVM과 기존 프로그램은 제네릭이 사라지길 원한다..

컴파일러가 Integer 와 int 를 같은 값으로 취급할 수는 없을까?

컴파일러가 Integer와 int를 JVM에 맞게 같은 값으로 최적화해준다면 문제를 해결할 수 있다
하지만 자바에 Complex라는 형식을 추가할 때 박싱과 관련된 어떤 문제가 발생한다

class Complex {
    public final double re;
    public final double le;
    public Complex(double re, double le) {
        this.re=re;
        this.le=le;
    }
    public static Complex add(Complex a, Complex b) {
        return new Complex(a.re + b.re, a.le + b.le);
    }
}
  • Complex는 참조 형식이므로 어떤 동작을 수행하려면 객체를 생성해야 함
  • Complex에 대응되는 complex라는 기본형이 필요하며 Complex를 언박싱한 객체를 JVM이 지원하지 않는다
    • 탈출 분석(escape analysis), 즉 언박싱 동작이 괜찮은지 결정하는 컴파일러 최적화 기법이 있지만 이는 자바 1.1 이후에 제공되는 객체에만 적용된다

변수 형식: 모든 것을 기본형이나 객체형으로 양분하지 않는다

  • 값 형식(value type)은 불변이며 레퍼런스 식별자를 포함하지 않는다
  • 기본형값은 넓은 의미에서 값 형식의 일종이다
  • 값 형식에서는 하드웨어가 비트 단위로 비교해서 int 가 같은지 검사하는 것처럼 == 도 기본적으로 요소 단위의 비교로 값이 같은지 확인한다
  • 값 형식에는 레퍼런스 식별자가 없으므로 저장 공간을 적게 차지한다
  • 값 형식은 데이터 접근뿐만 아니라 하드웨어 캐시 활용에도 좋은 성능을 제공할 가능성이 크다
  • 값 형식에는 참조 식별자가 없으므로 컴파일러가 자유롭게 값 형식을 박싱하거나 언박싱할 수 있다

한 함수에서 complex 를 다른 함수의 인수로 전달하면 컴파일러가 자연스럽게 두 개의 double 로 전달할 것이다
하지만 더 큰 값 형식을 인수로 전달하면 컴파일러가 인수를 박싱한 다음에 이러한 변환을 눈치 채지 못할 만큼 자연스럽게 변환해서 레퍼런스로 사용자에게 전달할 수 있다

현재 자바에 값 형식을 추가하는 논의가 진행 중이다 아직도 이슈로 등재되어있음..

  • 박싱, 제네릭, 값 형식: 상호 의존 문제, 박싱과 언박싱 관계를 해결해야 모든 문제를 해결할 수 있음

결론

자바 8, 9에 추가된 주요 기능을 살펴봤는데, 자바 9에는 자바 역사를 통틀어 가장 큰 변화가 일어났으며 자바 5에서 제네릭이 추가되었던 버전과 비슷한 변화

자바 8, 9, 10, 11이 중요한 초석을 하나씩 추가하면서 릴리스되었지만 이런 변화는 앞으로도 계속되어야 한다

반응형