본문 바로가기
Book

[독서 기록] 모던 자바 인 액션 18장-21장, 함수형 프로그래밍과 자바 진화의 미래

by Renechoi 2023. 1. 13.

 

모던 자바 인 액션 18장-21장, 함수형 프로그래밍과 자바 진화의 미래 

 


'어떻게'에 집중하는 프로그래밍 형식은 고전의 객체지향 프로그래밍에서 이용하는 방식이다. 때로는 이를 명령형 프로그래밍이라고 부르기도 하는데 다음 코드에서 보여주는 것처럼 (할당, 조건문, 분기문, 루프 등) 명령어가 컴퓨터의 저수준 언어와 비슷하게 생겼기 때문이다. 

 

Transaction mostExpensive = transactions.get(0);
if(mostExpensive == null) {
	throw new IllegalArgumentException("Empty list");
    
    }
    
for (Transaction t: transactions.subList(1, transactions.size())) {
	if(t.getValue() > mostExpensive.getValue()) {
    	mostExpensive = t;
    }
}

 

'어떻게'가 아닌 '무엇을'에 집중하는 방식도 있다. 4장과 5장에서 스트림 Api로 다음과 같은 질의를 만들 수 있었다. 

 

Optional<Transaction> mostExpensive = transactions.stream()
													.max(comparing(Transaction::getValue());

- 570 

 

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

- 571p 

 

 

실직적으로 자바로는 완벽한 순수 함수형 프로그래밍을 구현하기 어렵다. -> 부작용을 일으키지 않음으로써 

- 573p

 

 

예외를 사용하지 않고 나눗셈 같은 함수를 표현하려면 어떻게 해야 할까? 바로 Optional<T>를 사용하면 이 문제를 해결할 수 있다. double sqrt (double) 대신 Optional<Double> sqrt (double)을 이용하면 예외 없이도 결과값으로 연산을 성공적으로 수행했는지 아니면 요청된 연산을 성공적으로 수행하지 못했는지 확인할 수 있다. 

- 574p 

 

 

참조 투명성 

=> 같은 인수로 함수를 호출했을 때 항상 같은 결과를 반환 

- 575p

 

 

 

concat 메서드 정의 

 

static List<List<Integer>> concat(List<List<Integer>> a, List<List<Integer>> b) {
	a.addAll(b);
    return a;
}

static List<List<Integer>> concat(List<List<Integer>> a, List<List<Integer>> b) {
	List<List<Integer>> r = new ArrayList<>(a);
    r.addAll(b);
    return r;
}

왜 두 번째 버전이 더 좋은 코드일까? 두 번째 버전의 concat은 순수 함수다. 내부적으로는 리스트 r에 요소를 추가하는 변화가 발생하지만 반환 결과는 오로지 인수에 의해 이루어지며 인수의 정보는 변경하지 않는다. 

 

- 578p 

 

순수 함수형 프로그래밍 언어에서는 while, for 같은 반복문을 포함하지 않는다. 왜 그럴까? 이러한 반복문 때문에 변화가 자연스럽게 코드에 스며들 수 있기 때문이다. 예를 들어 while 루프의 조건문을 갱신해야 할 때가 있다. 그렇지 않으면 루프가 아예 실행되지 않거나 무한으로 반복될 수 있다. 

 

 

... 이론적으로 반복을 이용하는 모든 프로그램은 재귀로도 구현할 수 있는데 재귀를 이용하면 변화가 일어나지 않는다. 재귀를 이용하면 루프 단계마다 갱신되는 반복 변수를 제거할 수 있다. 다음은 팩토리얼 함수로 반복과 재귀 방식으로 해결할 수 있는 고전적 학교 문제다. 

 

반복 방식의 팩토리얼 

static int factorialIterative(int n) {
	int r = 1;
    for (int i = 1; i<= n; i++) {
    	r *= 1;
    }
    return r; 
}

 

재귀 방식의 팩토리얼 

static long factorialRecursive(long n) {
	return n ==1 ? 1 : n * factorialRecursive(n-1);
}

 

첫 번째 예제는 일반적인 루프를 사용한 코드로 매 반복마다 변수 r과 i가 갱신된다. 두 번째 예제는 재귀 방식의 코드로 좀 더 수학적인 형식으로 문제를 해결한다. 

 

자바 8 스트림 버전 

 

static long factorialStreams(long n) {
	return LongStream.rangeClosed(1, n).reduce(1, (long a, long b) -> a * b); 
}

 

- 581 p 

 

 

 

 

함수형 프로그래밍이란 함수나 메서드가 수학의 함수처럼 동작함을, 즉 부작용 없이 동작함을 의미했다. 

- 585p 

 

 

일반값처럼 취급할 수 있는 함수를 일급 함수라고 한다. 바로 자바 8이 이전 버전과 구별되는 특징 중 하나가 일급 함수를 지원한다는 점이다. 자바 8에서는 :: 연산자로 메서드 참조를 만들거나 (int x) -> x + 1 같은 람다 표현식으로 직접 함숫값을 표현해서 메서드를 함숫값으로 사용할 수 있다. 자바 8에서는 다음과 같은 메서드 참조로 Integer.parseInt를 저장할 수 있다. 

Function<String, Integer> strToInt = Integer::parseInt;

 

 

- 586p 

 

 

고차원 함수

-> 하나 이상의 함수를 인수로 받음

-> 함수를 결과로 반환 

- 587p 

 

 

기존 로직을 활용해서 변환기를 특정 상황에 적용할 수 있는 방법이 있다. 다음은 커링이라는 개념을 활용해서 한 개의 인수를 갖는 변환 함수를 생산하는 '팩토리'를 정의하는 코드다. 

 

static DoubleUnaryOperator curriedConverter(double f, double b) {
	return (double x) -> x * f + b; 
}

 

위 메서드에 변환 요소 (f)와 기준치 (b)만 넘겨주면 우리가 원하는 작업을 수행할 함수가 반환된다. 예를 들어 다음은 팩토리를 이용해서 원하는 변환기를 생성하는 코드다. 

 

DoubleUnaryOperator convertCtoF = curreidConverter(9.0/5, 32);

DoubleUnaryOperator는 applyAsDouble이라는 메서드를 정의하므로 다음처럼 변환기를 사용할 수 있다. 

double gbp = convertUSDtoGBP.applyAsDouble(1000);

결과적으로 기존의 변환 로직을 재활용하는 유연한 코드를 얻었다! 

 

우리가 어떤 작업을 했는지 다시 생각해보자. x, f, b라는 세 인수를 converter 메서드로 전달하지 않고 f, b 두 가지 인수로 함수를 요청했으며 반환된 함수에 인수 x를 이용해서 x * f + b 라는 결과를 얻었다. 

 

- 589p 

 

커링의 이론적 정의 

 

커링은 x와 y라는 두 인수를 받는 함수 f를 한 개의 인수를 받는 g라는 함수로 대체하는 기법이다. 이때 g라는 함수 역시 하나의 인수를 받는 함수를 반환한다. 함수 g와 원래 함수 f가 최종적으로 반환하는 값은 같다. 즉, f(x,y) = (g(x))(y)가 성립한다. 

- 589p 

 

 

 

어떤 사람은 '나는 일부 사용자만 볼 수 있게 트리를 갱신하면서도 다른 일부 사용자는 이를 알아차릴 수 없게 하고 싶다'고 말한다. 두 가지 방법이 있다. 하나는 고전적인 자바 해법이다(어떤 값을 갱신할 때 먼저 복사해야 하는지 주의 깊게 확인). 다른 하나는 함수형 해법이다. 즉, 갱신을 수행할 때마다 논리적으로 새로운 자료구조를 만든 다음에(변화가 일어나지 않도록) 사용자에게 적절한 버전의 자료구조를 전달할 수 있다. 

- 596p 

 

 

소수를 생성하는 재귀 스트림 예제 

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. 소수를 선택할 숫자 스트림이 필요하다.

2. 스트림에서 첫 번째 수를 가져온다. 이 숫자는 소수다(처음에 이 숫자는 2) 

3. 이제 스트림의 꼬리에서 가져온 수로 나누어떨어지는 모든 수를 걸러 제외시킨다. 

4. 이렇게 남은 숫자만 포함하는 새로운 스트림에서 소수를 찾는다. 이제 1번부터 다시 이 과정을 반복하게 된다. 따라서 이 알고리즘은 재귀다. 

 

- 597p 

 

 

1. 스트림 숫자 얻기 

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

 

2. 머리 획득 

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

 

3. 꼬리 필터링 

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

 

IntStream numbers = numbers();
int head = head(numbers);
IntStream filtered = tail(numbers).filter(n -> n % head !=0);

 

 

4. 재귀적으로 소수 스트림 생성 

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

 

 

- 596 ~ 598p 

 

 

lazy evaluation 

 

게으른 리스트 만들기 

 

스트림에 최종 연산을 적용해서 실제 계산을 해야 하는 상황에서만 실제 연산이 이루어진다. 

- 599 ~ 600p 

 

 

이 뒤부터는 내용 이해 불가 . . . . . . 

 

 

반응형