자바스크립트의 커링

Currying in Javascript

원문
https://medium.com/@kevincennis/currying-in-javascript-c66080543528


자바스크립트에서의 커링

최근 나는 함수형 프로그래밍에 대해 많은 생각을 해왔다. 함수형 프로그래밍은 일종의 커링함수 만들어가는 과정과 같이 재미있는 것이라 생각했다.
커링을 모르는 사람을 위해 설명을 하자면
커링은 n개의 인자를 가진 함수를 변형하여 하나의 인자를 받는 n개의 함수로 만드는 것이다.
각 부분이 적용된 함수들은 체인을 만들게 되고 마지막 체인에서 결국 value를 해결하게 된다. 아래 커링을 사용하는 간단한 예제가 있다.

function volume( l, w, h ) {
  return l * w * h;
}

var curried = curry( volume );
curried( 1 )( 2 )( 3 ); // 6

Disclaimer

이 포스트는

  • 클로저
  • 일급함수
  • Function#apply()

와 같은 것들에 대한 지식이 있다고 가정한다. 만약 당신이 이런 컨셉에 익숙치 않다면 더 읽기 전에 각 내용에 대해 알아 보고 오는게 좋다.

커링 함수를 작성하자

커링 함수를 구현할 때 제일 처음 떠오르는 생각은 커링할 함수를 인자로 받아야 한다는 것이다. 여기서부터 시작할 것이다.

function curry( fn ) {
}

그리고 다음으로 우리의 함수가 얼마나 많은 인자를 필요로 하는지 알아야 한다(이것을 “arity”라고한다). 이게 아니면, 우리는 언제 새로운 함수를 리턴하게 되고 언제 값을 돌려받을지는 알 수 없다. length 프로퍼티를 이용해 함수가 얼마나 많은 인자가 필요한지를 알 수 있다.

function curry( fn ) {
  var arity = fn.length;
}

여기서부터 점점 어려워진다. 기본적으로 커리된 함수가 호출될 때마다 우리는 새로운 인자를 클로저 안의 Array에 추가한다. 만약 커리된 원래 함수가 필요한 인의 개수와 그 Array의 원소의 개가 같게되면 우리는 원래의 함수를 호출하고 아니라면 새로운 함수를 리턴한다.
그러기 위해서는

  1. 우리는 이자의 목록을 소유할 클로저가 필요하고
  2. 전체 인자의 개수를 체크해 부분적으로 적용된 새로운 함수를 리턴하거나
  3. 혹은 모든 인수가 적용된 원래 함수의 결과 값을 리턴하는 함수가 필요하다.

나는 보통 ‘resolver’라는 즉시실행 함수로 구현한다.

function curry( fn ) {
  var arity = fn.length;
  return (function resolver() {
  }());
}

resolver 안에서 처음 해야 할 일은 전달받은 인자의 복사본을 만드는 것이다. memory라는 이름의 변수를 만들고 Array#slice를 이용해서 arguments 객체의 복사본을 만들 것이다.

function curry( fn ) {
  var arity = fn.length;
  return (function resolver() {
    var memory = Array.prototype.slice.call( arguments );
  }());
}

다음으로 resolver는 함수를 리턴해야 한다. 이 함수가 커리된 함수를 호출했을 때 실행되는 함수이다.

function curry( fn ) {
  var arity = fn.length;
  return (function resolver() {
    var memory = Array.prototype.slice.call( arguments );
    return function() {
    };
  }());
}

resolver 내부에서 리턴된 함수가 호출될 때는 인자가 전달이 되어야 하는데 이 인자는 memory라는 변수에 저장될 인자가 될 수도 있다. 그래서 먼저 slice()를 이용해 memory의 카피를 만든다.

function curry( fn ) {
  var arity = fn.length;
  return (function resolver() {
    var memory = Array.prototype.slice.call( arguments );
    return function() {
      var local = memory.slice();
    };
  }());
}

그리고 새로운 인자들을 Array#push를 이용해 추가한다.

function curry( fn ) {
  var arity = fn.length;
  return (function resolver() {
    var memory = Array.prototype.slice.call( arguments );
    return function() {
      var local = memory.slice();
      Array.prototype.push.apply( local, arguments );
    };
  }());
}

자 이제 우리는 지금까지의 부분 적용된 함수의 체인으로 전달받은 모든 인자들이 담긴 새로운 배열을 얻게 되었다. 마지막으로 해야 할 일은 전달받은 인자의 개수와 커리된 함수의 arity와 비교해야 한다. 만약 개수가 같다면 우리는 원래 함수를 호출하게 된다. 만약 아니라면 우리는 resolver를 이용해 다른 memory에 저장된 모든 인자를 갖는 다른 함수를 리턴한다.

function curry( fn ) {
  var arity = fn.length;
  return (function resolver() {
    var memory = Array.prototype.slice.call( arguments );
    return function() {
      var local = memory.slice(), next;
      Array.prototype.push.apply( local, arguments );
      next = local.length >= arity ? fn : resolver;
      return next.apply( null, local );
    };
  }());
}

이해하기 쉽지는 않은 코드일 것이다. 예제를 하나하나 설명하겠다.

function volume( l, w, h ) {
  return l * w * h;
}

var curried = curry( volume );

Okay, curried는 volume을 우리의 커리함수에 인자로 전달해 실행한 결과이다. 잠깐 되돌아본다면 이때 일어나는 일은 아래와 같다.

  1. 우리는 volume의 arity를 저장한다.(3이다)
  2. resolver를 인자 없이 즉시 실행한다, memory가 빈 상태라는 의미이다.
  3. resolver는 무명함수를 리턴한다.

자 이제는 우리의 커리된 함수를 실행하고 그 결과를 length에 저장한다.

function volume( l, w, h ) {
  return l * w * h;
}
var curried = curry( volume );
var length = curried( 2 );

각 과정을 자세히 설명하면,

  1. 여기서 우리가 실행한 것은 정확히 resolver에 의해 리턴된 무명함수이다.
  2. memory의 복사본을 만들고(비어있었던) 이것을 local이라고 부른다.
  3. 전달받은 인자 (2)를 local 배열에 추가한다.
  4. local의 개수가 volume의 arity보다 작으니 우리는 이전까지 가지고 있던 인자의 목록과 함께 resolver를 다시 실행한다. 이것은 새로운 클로저에서 새로운 이전에 전달받은 인자(2)를 포함한 새로운 memory 배열을 만든다.
  5. 마침내, resolver는 새로운 클로저와 그 안의 새로운 memory 배열에 접근할 수 있는 새로운 함수를 만들어 리턴한다.

그래서 다시 우리는 무명함수를 얻게 된다. 그러나 이번에는 memory 객체가 비어있지 않고 (2)라는 인자를 갖고 있게 된다. 만약 다시 length 함수를 호출하게 되면 이 프로세스가 반복된다.

function volume( l, w, h ) {
  return l * w * h;
}

var curried = curry( volume );
var length = curried( 2 );
var lengthAndWidth = length( 3 );
  1. 다시 실제로 실행되는 것은 resolver가 리턴한 무명함수이고,
  2. 이번에는 resolver가 이전의 인자들을 가지고 있어 우리는 [2]라는 배열의 복사본을 얻게 된다.
  3. 그리고 새로운 인자인 3을 local 배열에 추가하고,
  4. 여전히 local의 개수가 arity보다 적기 때문에 지금까지 전달된 인자들과 resolver를 이용해 새로운 함수를 리턴한다.

자 이제는 우리의 lengthAndWidth 함수를 호출해 결과 값을 얻어올 차례이다.

function volume( l, w, h ) {
  return l * w * h;
}
var curried = curry( volume );
var length = curried( 2 );
var lengthAndWidth = length( 3 );
console.log( lengthAndWidth( 4 ) ); // 24

지금부터는 단계가 끝부분에서 약간 달라진다.

  1. 다시 한번 resolver가 리턴한 무명함수가 실행되고,
  2. 이번에도 resolver는 이전 두 개의 인자를 가지고 있어 [2,3]와 같은 배열이 local에 할당된다.
  3. 새로운 인자인 4가 local 배열에 추가되고,
  4. 이번에는 arity와 local의 개수가 같게되어 새로운 함수를 리턴하는 대신 지금까지 쌓아온 인자를 volume에 전달하여 호출해 실행한 결과 값인 24를 얻게 된다.

Wrapping up

아직까지는 나는 일상적인 작업에서 커링을 꼭 써야 하는 유즈케이스를 찾지는 못 했다. 하지만 나는 이런 함수를 작성하는 과정이 함수형 프로그래밍의 이해도를 높이고 클로저나 1급 함수에 대한 개념을 강화할 수 있을 거라 생각한다.