overcurried

다시보는 커링

February 12, 2021

/

🍛

커링을 설명하는 글은 이미 인터넷에 많이 있습니다. 그렇기에 이 글은 단순히 커링을 설명하는 글이 되어서는 안 됩니다. 한편, 대부분의 커링을 설명하는 글은 커링의 정의를 소개하는 선에서 그칩니다. 그리고 그렇기에 이 글은 커링을 깊게, 아주 깊게 설명하는 글이 될 예정입니다.

어쩌면 생각치도 못하고 지나갔을 흥미로운 이야기들을, 혹은 왜 그런지 의문을 품고 있었을 질문들에 대한 시원한 답들을 지금 풀어보도록 하겠습니다.

커링이란 무엇인가?

커링은 다인수 함수를 일인수 함수의 열로 나타내는 표현 방식입니다. 함수에게 있어 중요한 것은 연산에 필요한 값들을 모두 제공받는 일이지 이를 몇 번에 걸쳐 나누어 받는지가 아니라는 아이디어에 기반하고 있습니다.

const f = (x, y, z) => x + y + z;
const fC1 = x => y => z => x + y + z;
const fC2 = x => (y, z) => x + y + z;
const fC3 = (x, y) => z => f(x, y, z);

// 위 네 개의 함수는 모두 요구하는 값과 그 값을 가지고 하는 계산이 일치합니다.

주어진 함수에 커링을 적용하는 방식은 간단합니다. 그저 모든 매개변수를 쪼개어 한 개씩 받도록 함수를 잘게 나누면 그만입니다. 예컨대 매개변수 n, m을 받아야 한다면 n을 받고, m을 받아 연산을 수행하고 결과를 반환하는 함수를 반환하는 함수를 만들면 됩니다.

const f = (n, m) => ...
const curriedF = n => m => ...

커링을 꼭 프로그래머가 직접 손으로만 해야 하는 것은 아닙니다. 함수를 이용하여 함수를 커링하는 과정을 추상화 할 수도 있습니다.

/** 
 * 귀납적으로 정의한 커링 함수
 * 
 * 1. 0인수 함수를 커링하면 0인수 함수입니다.
 * 2. 1인수 함수를 커링하면 1인수 함수입니다.
 * 3. n + 1인수 함수를 커링하면 커링된 n인수 함수를 반환하는 1인수 함수입니다.
 *    (n + 1인수 함수는 n인수 함수를 반환하는 1인수 함수와 같기 때문입니다.)
 * **/

function curry(f) {
  function makeNArgFunction(arity, f) {
    return Object.defineProperty(f, 'length', { value: arity });
  }

  if (f.length <= 0) return f;
  else return arg => curry(makeNArgFunction(f.length - 1, (...args) => f(arg, ...args)));
}

커링의 시사점

커링은 형태는 다르지만 동작은 같은 함수들이 존재할 수 있음을 시사합니다. 다른 말로는, 하나의 함수가 여러 해석을 가질 수도 있고 여러 함수를 하나의 함수로 통합하여 생각할 수도 있음을 커링은 우리에게 제시합니다. 이 아이디어는 간단하게는 각 함수가 한 개의 매개변수만 가질 수 있는 람다 대수에서 다인수 함수를 표현하는 데에 쓰이며 더 나아가서는 함수에 대해 고찰할 때에도 유용하게 사용됩니다. 예컨대, <A, B>(xs: A[], f: (x: A) => B) => B[] 타입의 map 함수를 커링하면 <A>(xs: A[]) => <B>(f: (x: A) => B) => B[] 타입의 함수가 됩니다. 그리고 다시 이 함수의 두 매개변수의 순서를 바꾸면 <A, B>(f: (x: A) => B) => (xs: A[]) => B[] 타입의 함수가 됩니다. 눈치 채신 분도 계시겠지만, map 함수를 커링하고 두 매개변수의 위치를 바꾸어 만들어 낸 이 함수는 lift라 불리는 함수입니다. 주어진 일반적인 값에 대한 일반적인 함수를 배열에 대해 작동하는 특수한 함수로 다루는 값의 차원을 끌어올려 주는 함수이지요. 이렇게 단순히 커링과 매개변수의 위치를 바꾸는 작업을 통해 하나의 함수에 대한 다양한 해석이 가능합니다.

function map<A, B>(xs: A[], f: (x: A) => B): B[] {
  if (xs.length === 0) return [];
  else return [f(xs[0]), ...map(xs.slice(1), f)];
}

const lift: <A, B>(f: (x: A) => B) => (xs: A[]) => B[] = f => xs => map(xs, f);

function inc(x: number): number {
  return x + 1;
} 

const incForArray = lift(inc);

incForArray([1, 2, 3, 4, 5]); // [2, 3, 4, 5, 6] 반환

부분 적용과 커링

함수의 매개변수 중 일부를 고정하는 기술로 부분 적용이 있습니다. 부분 적용을 사용하면 함수의 추상도를 낮추어 더 구체적인 함수를 만들어 낼 수 있습니다.

function fixParameter(f, ...args1) {
  return (...args2) => f(...args1, ...args2);
}

function add(n, m) {
  return n + m;
}

const inc = fixParameter(add, 1);

inc(2); // 3 반환

만약 부분 적용을 수행한 함수가 커링된 함수라면, 별도의 부분 적용을 위한 함수나 그에 준하는 중간자가 필요하지 않습니다.

const add = n => m => n + m;
const inc = add(1);

inc(2); // 3 반환

커링의 전제

커링은 일인수 함수의 열은 다인수 함수와 같다는 전제에 기반하고 있습니다. 직관적으로는 함수에게 있어 중요한 부분은 계산이지 값을 어떻게 받느냐가 아니라는 아이디어를 바탕으로 이 전제를 수용할 수 있겠습니다만, 조금 더 엄밀하게 이 전제를 검토해 보도록 합시다.

같음에는 두 부류가 있습니다. 두 존재가 완전히 같아 둘 중 하나에 대한 모든 진술이 같은 양상으로 다른 하나에게 적용되는 같음이 있는 반면, 두 존재가 특정 속성들에 대해서만 같아 그 속성들과 관련된 진술만을 공유하는 같음이 있습니다. 수학에서 첫 번째 같음은 equality라고 부르며, 두 번째 같음은 isomorphism이라 부릅니다.

1과 1은 같다. (equality, isomorphism 모두 해당)
(볼링 선수에게) 14파운드 짜리 파란 공과 14파운드 짜리 노란 공은 같다. (isomorphism)
(사진가에게) 내부가 빈 노란 정사각형과 내부가 꽉 채워진 노란 정사각형은 같다. (isomorphism)
(컴파일러에게) BSD식 중괄호 스타일이나 K&R식 중괄호 스타일이나 모두 같다. (isomorphisms)

다인수 함수는 호출을 단 한 번만 하면 그만이지만 일인수 함수는 호출을 매개변수의 수 만큼 해야 합니다. 이 차이 때문에 커링의 전제에서 말하는 같음은 equality가 절대 될 수 없습니다. 그리고 그렇기에 커링의 전제에서 말하는 같음은 isomorphism입니다.

여기서 멈추지 않고 더 나아가 위 전제가 참임을 증명해 봅시다. 두 존재 사이에 isomorphism이 있음을 증명하기 위해서는 두 개의 함수가 필요합니다. 한 존재를 다른 한 존재로 대응시키는 함수, 그리고 그 반대인 함수. 또한 더 나아가 이 두 함수는 서로 합성했을 때 두 존재 중 하나에 대한 항등 함수가 되어야 한다는 조건을 만족시켜야 합니다. 커링의 전제를 증명하는 이 경우에는 curry함수와 그 역함수인 uncurry 함수를 사용하겠습니다.

/**
 *  편의상 인수가 두 개인 함수와 그에 대응하는 일인수 함수의 열 사이의 isomorphism만 증명하겠습니다.
 *  증명을 확장하고 싶으신 분들은 수학적 귀납법과 튜플을 활용해 보세요.
 * **/

function curry2(f) {
  return x => y => f(x, y);
}

function uncurry2(f) {
  return (x, y) => f(x)(y);
}

각각 다인수 함수를 일인수 함수의 열로, 일인수 함수의 열을 다인수 함수로 대응시키는 이 두 함수를 가지고, 두 함수의 합성 함수가 모두 항등 함수임을 보이면 일인수 함수의 열은 다인수 함수와 같다는 커링의 전제가 증명됩니다.

// uncurry2와 curry2를 합성한 함수가 항등 함수임을 보이는 유도 과정

uncurry2(curry2((n, m) => n + m))
= uncurry2(x1 => y1 => ((n, m) => n + m)(x1, y1))
= (x2, y2) => (x1 => y1 => ((n, m) => n + m)(x1, y1)))(x2)(y2)
= (x2, y2) => (y1 => ((n, m) => n + m)(x2, y1)))(y2)
= (x2, y2) => ((n, m) => n + m)(x2, y2)
= (x2, y2) => x2 + y2
= (n, m) => n + m
// curry2와 uncurry2를 합성한 함수가 항등 함수임을 보이는 유도 과정

curry2(uncurry2(n => m => n + m))
= curry2((x1, y1) => (n => m => n + m)(x1)(y1))
= x2 => y2 => ((x1, y1) => (n => m => n + m)(x1)(y1))(x2, y2)
= x2 => y2 => (n => m => n + m)(x2)(y2)
= x2 => y2 => x2 + y2
= n => m => n + m

자동 커링과 하스켈의 함수 호출 문법

몇몇 프로그래밍 언어에서는 모든 함수가 기본적으로 커링되어 제공됩니다. 그 중에서도 하스켈은 커링에 최적화된 함수 호출 문법을 프로그래머에게 제공합니다. 하스켈의 함수 호출 문법에는 모든 함수가 커링되어 있다는 그 특성에 맞추어, 매개변수를 구분하는 컴마 따위의 표지가 존재하지 않으며 모든 함수 호출은 스페이스를 통해 구별됩니다. 예를 들어, 세 수를 더하는 함수 add를 하스켈에서 호출한다면 다음과 같은 코드를 사용합니다.

add 1 2 3

만약 자바스크립트에서 같은 함수를 호출한다면 아래와 같이 매 호출을 구별해 주는 소괄호를 사용하여 번잡한 코드를 작성해야 했을 겁니다.

add(1)(2)(3);

하스켈의 자동 커링 기능과 커링에 최적화된 함수 호출 문법은 프로그래머에게 더 나은 함수형 프로그래밍 경험을 제공해 줍니다.


이번 글에서는 커링에 대한 심도 깊은 이야기들을 풀어 보았습니다. 사실 커리-하워드 대응을 통해 보았을 때 커링이 논리학에서 무엇에 대응하는지와 같이 조금 더 수학적인 이야기들도 해보고 싶었습니다만, 제 능력과 이해가 부족해서 아직 거기까지는 논리정연하게 설명하기 어려워 다음 기회로 미루었습니다. 혹시 궁금하신 분들은 커리-하워드 대응에 대해 알아보시면서 커링을 다시 살펴보세요. 흥미로운 점을 관찰하실 수 있으실 겁니다.

저는 여기서 이만 말을 줄이도록 하겠습니다. 긴 글 읽어주셔서 감사합니다.


Personal blog of Jaewon Seo.
I believe that knowledge becomes valuable when we share it with others.