3장, 양도하라

2022.01.17

  • 우리는 지루한 세부사항을 처리하거나 숨기기 위해서 추상화의 단계를 만든다.
  • 컴퓨터의 성능이 향상됨에 따라서 우리는 더 많은 일들을 언어나 런타임에 떠넘겨왔다.
  • 가비지 컬렉션 같은 저수주 세부사항의 조작을 런타임에 양도함으로써 찾아야 할 수많은 오류를 방지해주는 능력이야말로 함수형 사고의 가치라고 하겠다.

3.1 반복 처리에서 고계함수로

  • 고계함수 내에서 어떤 연산을 할 것인지를 표현하기만 하면, 언어가 그것을 능률적으로 처리할 것이다.
  • 사용하는 추상화 단계보다 한 단계 아래를 이해하라.

3.2 클로저

  • 모든 함수형 언어는 클로저를 포함한다.
  • 클로저(Closure)란 그 내부에서 참조되는 모든 인수에 대한 묵시적 바인딩을 지닌 함수를 칭한다.
    • 이 함수(또는 메서드)는 자신이 참조하는 것들의 문맥(context)을 포함한다.
  • 클로저는 함수형 언어나 프레임워크에서 코드 블록을 다양한 상황에서 실행하게 해주는 메커니즘으로 많이 쓰인다.
    • 클로저를 map()과 같은 고계함수에 자료 변형 코드 블록으로 전달하는 것이 대표적인 예이다.
1def Closure makeCounter() {
2  def local_variable = 0
3  return { return local_variable += 1 }
4}
5
6c1 = makeCounter()
7c1();
8c1();
9c1();
10
11c2 = makeCounter()
12println "C1 = ${c1()}, C2 = ${c2()}"
13// C1 = 4, C2 = 1
  • 클로저(Closure)란 단어의 어원이 문맥을 포괄함(enclosing context)이다.
  • 언어로 하여금 상태를 관리하게 하라.
  • 클로저는 지연 실행(deferred execution)의 좋은 예이다.
  • 명령형 언어는 상태로 프로그래밍 모델을 만든다. 클로저는 코드와 문맥을 한 구조로 캡슐화해서 행위의 모델을 만들 수 있게 해준다.
    • 이렇게 만든 클로저는 전통적인 자료구조처럼 주고받을 수도 있고, 적절한 시간과 장소에서 실행할 수도 있다.
  • 상태 대신 문맥을 잡으라.

3.3 커링과 부분적용

  • 커링과 부분 적용은 20세기 수학자인 해스컬 커리(Haskell Curry) 등의 작업을 통해 수학에서 유래한 언어 기술이다.
  • 이 기술은 여러 종류의 언어에서 볼 수 있고 모든 함수형 언어에 포함되어 있다.
  • 메서드의 인수의 개수를 조작할 수 있게 해주고, 주로 기본값을 주는 방법을 사용한다. 이를 인수 고정이라고도 부른다.

3.3.1 정의와 차이점

  • 커링(currying)은 다인수 함수를 일인수 함수들의 체인으로 바꿔주는 방법이다.
    • 함수의 호출자가 몇 개의 인수를 고정할지를 결정하며 적은 수의 인수를 가지는 함수를 유도해낸다.
  • 부분 적용(partial application)은 주어진 다인수 함수를 생략될 인수의 값을 미리 정해서 더 적은 수의 인수를 받는 하나의 함수로 변형하는 방법이다.
    • 이 방법은 이름이 의미하듯이 몇몇 인수에 값을 미리 적용하고 나머지 인수만 받는 함수를 리턴한다.

커링이나 부분 적용 모두 몇몇 인수의 값만 주면 인수가 몇 개 빠져도 호출할 수 있는 함수를 리턴해준다. 다만 커링은 체인의 다음 함수를 리턴하는 반면에, 부분 적용은 주어진 값을 인수에 바인딩시켜서 인수가 더 적은 하나의 함수를 만들어준다.

예를 들자면 process(x,y,z)의 완전히 커링된 버전은 proccess(x)(y)(z)이다.
process(x,y,z)의 인수 하나를 부분 적용하면 인수 두개 짜리의 process(y,z)가 된다.

  • 커링과 부분 적용은 팩토리 함수, 템플릿 메서드 패턴 묵시적 값 전달의 용도로 사용하면 좋다.

3.3.2 재귀

  • 재귀란 자신을 재참조하여 같은 프로세스를 반복하는 것.
  • 재귀는 항상 종료 조건을 보장해야 한다.
  • 이 또한 런타임에 세부사항을 양도하는 예가 되고, 함수형 프로그래밍과 깊은 관계가 있다.
  • 재귀는 움직이는 부분의 관리를 런타임에 양도하는 것이다. ( 개발자가 중간 값을 건드리지 못하게 하면 결국 그로 인한 오류도 생기지 않는다. )
1object CurryTest extends App {
2  def filter(xs: List[Int], p: Int => Boolean): List[Int] =
3    if (xs.isEmpty) xs
4    else if (p(x.head)) xs.head :: filter(xs.tail, p)
5    else filter(xs.tail, p)
6
7  def devidesBy(n: Int)(x: Int) = ((x % n ) == 0)
8
9  var nums = List(1, 2, 3, 4, 5, 6, 7, 8)
10  println(filter(nums, devidesBy(2)))
11  println(filter(nums, devidesBy(3)))
12}

스택 증가는 재귀가 좀 더 보편화되지 못하는 주된 이유 중 하나이다. 재귀는 보통 중간 값을 스택에 보관하게끔 구현되는데, 재귀에 최적화되지 않은 언어에서는 스택 오버플로를 유발하게 된다. 개발자가 런타임이 이 문제를 처리하는데 도움을 줄 수 있는 방법 중 하나는 꼬리 호출 최적화(tail-call optimization)이다. 재귀 호출이 함수에서 마지막 단계이면, 런타임이 스택을 증가시키지 않고 스택에 놓여 있는 결과를 교체할 수 있다.

3.4 스트림과 작업 재정렬

1public String cleanNamesP(List<String> names) {
2    if (names == null) return "";
3    return names
4      .stream()
5      .map(e -> capitalize(e))
6      .filter(n -> n.length() > 1)
7      .collect(Collectors.joining(","))
8  }

2장의 예제 코드와는 다르게 map() 작업이 filter()보다 먼저 실행된다. 명령형 사고로는 필터 작업이 먼저 와야 맵의 작업량이 줄어들기 때문에 좋지만, 함수형 언어에는 Stream이라는 추상 개념이 정의되어 있다. Stream은 여러모로 컬렉션과 흡사하지만 바탕 값(backing value)이 없다. collect() 함수가 호출되기 전까지는 map()filter()게으른 함수로, 실행을 미룬다. 영리한 런타임은 게으른 작업들을 재정렬할 수 있다.

런타임에 최적화를 맡기는 것이 양도의 중요한 예이다. 시시콜콜한 세부사항은 버리고 문제 도메인의 구현에 집중하게 되는 것이다.