해당 게시글은 mostly-adequate-guide 의 시리즈들을 번역했습니다.
해당게시글의 저작권은 mostly-adequate-guide 의 저작권인 Attribution-ShareAlike 4.0 International 을 준수합니다.
🤖 AI가 요약한 글이예요 !
이 글은 함수형 프로그래밍의 핵심 기법인 함수 합성(function composition) 에 대해 설명합니다.
함수 합성은 여러 함수를 연결하여 하나의 새로운 함수를 만드는 과정으로, 데이터가 함수들을 통해 파이프라인처럼 흐르게 합니다.
compose
함수를 사용하여 오른쪽에서 왼쪽으로 함수를 실행하는 방법을 보여주고, 중첩된 함수 호출보다 가독성이 좋음을 강조합니다.
함수 합성의 결합 법칙(associativity) 을 설명하며, 함수 그룹화 방식에 상관없이 결과가 동일함을 보여줍니다.
포인트프리(pointfree) 스타일 코딩을 소개하며, 데이터를 명시적으로 언급하지 않고 함수를 조합하는 방식의 장점(간결성, 일반성)과 단점(가독성 저하 가능성)을 설명합니다.
합성 디버깅을 위한trace
함수 사용법을 예시로 보여줍니다.
마지막으로, 함수 합성이 카테고리 이론(Category Theory) 에 기반함을 설명하며, 객체(타입), 사상(순수 함수), 합성, 항등원(id
함수)의 개념을 소개합니다.
챕터 05: 합성으로 코딩하기
함수형 사육
여기 compose
함수가 있습니다:
const compose =
(...fns) =>
(...args) =>
fns.reduceRight((res, fn) => [fn.call(null, ...res)], args)[0];
... 겁먹지 마세요! 이것은 compose의 레벨-9000-슈퍼-사이어인 형태입니다. 이해를 돕기 위해, 가변 인자 구현을 잠시 접어두고 두 함수를 함께 합성할 수 있는 더 간단한 형태를 고려해 봅시다. 일단 이것을 이해하면, 추상화를 더 밀어붙여 단순히 임의의 수의 함수에 대해 작동한다고 생각할 수 있습니다 (우리는 이것을 증명할 수도 있습니다!). 여기 친애하는 독자 여러분을 위한 더 친근한 compose가 있습니다:
const compose2 = (f, g) => (x) => f(g(x));
f
와 g
는 함수이고 x
는 그것들을 통해 "파이프"되는 값입니다.
합성은 함수 사육처럼 느껴집니다. 당신은 함수 사육사로서, 결합하고 싶은 특성을 가진 두 함수를 선택하고 그것들을 함께 으깨어 새로운 함수를 탄생시킵니다. 사용법은 다음과 같습니다:
const toUpperCase = (x) => x.toUpperCase();
const exclaim = (x) => `${x}!`;
const shout = compose(exclaim, toUpperCase);
shout("send in the clowns"); // "SEND IN THE CLOWNS!"
두 함수의 합성은 새로운 함수를 반환합니다. 이것은 완벽하게 말이 됩니다: 어떤 타입의 두 단위(이 경우 함수)를 합성하면 바로 그 타입의 새로운 단위를 얻어야 합니다. 레고 두 개를 끼웠다고 링컨 로그를 얻지는 않습니다. 여기에는 이론이 있습니다, 때가 되면 발견하게 될 어떤 근본적인 법칙이 있습니다.
우리의 compose
정의에서, g
는 f
보다 먼저 실행되어 오른쪽에서 왼쪽으로 데이터 흐름을 만듭니다. 이것은 많은 함수 호출을 중첩하는 것보다 훨씬 읽기 쉽습니다. compose가 없다면 위 코드는 다음과 같이 읽힐 것입니다:
const shout = (x) => exclaim(toUpperCase(x));
안쪽에서 바깥쪽 대신, 우리는 오른쪽에서 왼쪽으로 실행합니다. 이것은 왼쪽 방향으로의 한 걸음이라고 생각합니다 (썰렁!). 순서가 중요한 예시를 살펴봅시다:
const head = (x) => x[0];
const reverse = reduce((acc, x) => [x, ...acc], []); // reduce는 부록에 정의되어 있다고 가정
const last = compose(head, reverse);
last(["jumpkick", "roundhouse", "uppercut"]); // 'uppercut'
reverse
는 리스트를 뒤집고 head
는 첫 번째 항목을 가져옵니다. 이것은 효과적이지만 비효율적인 last
함수를 만듭니다. 여기서 합성에서의 함수 순서는 명백해야 합니다. 왼쪽에서 오른쪽 버전을 정의할 수도 있지만, 현재 상태로는 수학적 버전을 훨씬 더 가깝게 반영합니다. 맞습니다, 합성은 수학 책에서 바로 나온 것입니다. 사실, 아마도 모든 합성에 대해 성립하는 속성을 살펴볼 때일 것입니다.
// 결합 법칙 (associativity)
compose(f, compose(g, h)) === compose(compose(f, g), h);
합성은 결합 법칙(associative) 을 따릅니다. 즉, 두 함수를 어떻게 그룹화하든 상관없다는 뜻입니다. 따라서 문자열을 대문자로 만들기로 선택하면 다음과 같이 작성할 수 있습니다:
compose(toUpperCase, compose(head, reverse));
// 또는
compose(compose(toUpperCase, head), reverse);
compose
호출을 어떻게 그룹화하든 상관없으므로 결과는 동일합니다. 이를 통해 가변 인자 compose를 작성하고 다음과 같이 사용할 수 있습니다:
// 이전에는 두 개의 compose를 작성해야 했지만, 결합 법칙 덕분에
// 원하는 만큼 함수를 compose에 전달하고 그룹화 방식을 결정하게 할 수 있습니다.
const arg = ["jumpkick", "roundhouse", "uppercut"];
const lastUpper = compose(toUpperCase, head, reverse);
const loudLastUpper = compose(exclaim, toUpperCase, head, reverse);
lastUpper(arg); // 'UPPERCUT'
loudLastUpper(arg); // 'UPPERCUT!'
결합 법칙의 한 가지 즐거운 이점은 어떤 함수 그룹이든 추출하여 그들만의 합성으로 묶을 수 있다는 것입니다. 이전 예제를 리팩토링해 봅시다:
const loudLastUpper = compose(exclaim, toUpperCase, head, reverse);
// -- 또는 ---------------------------------------------------------------
const last = compose(head, reverse);
const loudLastUpper = compose(exclaim, toUpperCase, last);
// -- 또는 ---------------------------------------------------------------
const last = compose(head, reverse);
const angry = compose(exclaim, toUpperCase);
const loudLastUpper = compose(angry, last);
// 더 많은 변형...
포인트프리 (Pointfree)
포인트프리(Pointfree) 스타일은 데이터를 말할 필요가 없다는 것을 의미합니다. 실례합니다. 그것은 작동하는 데이터를 결코 언급하지 않는 함수를 의미합니다. 일급 함수, 커링, 합성은 모두 이 스타일을 만들기 위해 잘 어울립니다.
// 포인트프리가 아님: 데이터 word를 언급함
const snakeCase = (word) => word.toLowerCase().replace(/\s+/gi, "_");
// 포인트프리
const snakeCase = compose(replace(/\s+/gi, "_"), toLowerCase); // replace, toLowerCase는 커링되었다고 가정
replace
를 어떻게 부분 적용했는지 보세요? 우리가 하는 일은 데이터를 각 1개 인자 함수를 통해 파이핑하는 것입니다. 커링을 사용하면 각 함수가 데이터를 받아서 작동하고 전달하도록 준비할 수 있습니다. 주목할 또 다른 점은 포인트프리 버전에서는 함수를 구성하기 위해 데이터가 필요하지 않지만, 포인트풀 버전에서는 다른 어떤 것보다 먼저 word
가 사용 가능해야 한다는 것입니다.
다른 예시를 살펴봅시다.
// 포인트프리가 아님: 데이터 name을 언급함
const initials = (name) =>
name.split(" ").map(compose(toUpperCase, head)).join(". ");
// 포인트프리
// 참고: 9장에서 소개된 'join' 대신 부록의 'intercalate'를 사용합니다!
const initials = compose(
intercalate(". "),
map(compose(toUpperCase, head)),
split(" ")
); // intercalate, map, split 등은 커링되었다고 가정
initials("hunter stockton thompson"); // 'H. S. T'
포인트프리 코드는 다시 한번 불필요한 이름을 제거하고 간결하고 일반적으로 유지하는 데 도움이 될 수 있습니다. 포인트프리는 함수형 코드에 대한 좋은 리트머스 테스트입니다. 왜냐하면 입력을 출력으로 받는 작은 함수가 있다는 것을 알려주기 때문입니다. 예를 들어, while 루프는 합성할 수 없습니다. 하지만 주의하세요, 포인트프리는 양날의 검이며 때로는 의도를 모호하게 만들 수 있습니다. 모든 함수형 코드가 포인트프리인 것은 아니며 그래도 괜찮습니다. 가능하면 포인트프리를 목표로 하고 그렇지 않으면 일반 함수를 사용합시다.
디버깅
흔한 실수는 map
과 같은 두 인자 함수를 먼저 부분 적용하지 않고 합성하는 것입니다.
// 잘못됨 - angry에 배열을 전달하게 되고 map은 무엇으로 부분 적용되었는지 알 수 없음.
const latin = compose(map, angry, reverse); // map, angry, reverse는 커링되었다고 가정
latin(["frog", "eyes"]); // error
// 올바름 - 각 함수는 1개의 인자를 기대함.
const latin = compose(map(angry), reverse); // map(angry)는 배열을 받는 함수를 반환
latin(["frog", "eyes"]); // ['EYES!', 'FROG!'])
합성 디버깅에 어려움을 겪고 있다면, 이 유용하지만 순수하지 않은 trace
함수를 사용하여 무슨 일이 일어나고 있는지 확인할 수 있습니다.
const trace = curry((tag, x) => {
// curry는 부록에 정의되어 있다고 가정
console.log(tag, x);
return x;
});
const dasherize = compose(
intercalate("-"), // intercalate는 부록에 정의되어 있다고 가정
toLower,
split(" "),
replace(/\s{2,}/gi, " ")
); // compose, toLower, split, replace는 커링되었다고 가정
dasherize("The world is a vampire");
// TypeError: Cannot read property 'apply' of undefined
뭔가 잘못되었습니다. trace
를 사용해 봅시다.
const dasherize = compose(
intercalate("-"),
toLower,
trace("after split"), // split 이후의 값을 확인
split(" "),
replace(/\s{2,}/gi, " ")
);
dasherize("The world is a vampire");
// after split [ 'The', 'world', 'is', 'a', 'vampire' ]
아! 배열에 대해 작동하므로 이 toLower
를 map
해야 합니다.
const dasherize = compose(
intercalate("-"),
map(toLower), // map을 사용하여 배열의 각 요소에 toLower 적용
split(" "),
replace(/\s{2,}/gi, " ")
);
dasherize("The world is a vampire"); // 'the-world-is-a-vampire'
trace
함수를 사용하면 디버깅 목적으로 특정 지점의 데이터를 볼 수 있습니다. Haskell 및 PureScript와 같은 언어에는 개발 편의성을 위해 유사한 함수가 있습니다.
합성은 프로그램을 구성하는 도구가 될 것이며, 운 좋게도 일이 잘 풀리도록 보장하는 강력한 이론에 의해 뒷받침됩니다. 이 이론을 살펴봅시다.
카테고리 이론 (Category Theory)
카테고리 이론(Category Theory) 은 집합론, 타입 이론, 군론, 논리 등 여러 다른 분야의 개념을 형식화할 수 있는 수학의 추상적인 분야입니다. 주로 객체, 사상, 변환을 다루며, 이는 프로그래밍과 매우 유사합니다. 다음은 각 개별 이론에서 본 동일한 개념의 차트입니다.
카테고리 이론 (Category Theory) | 집합론 (Set Theory) | 타입 이론 (Type Theory) |
---|---|---|
객체 (Object) | 집합 (Set) | 타입 (Type) |
사상 (Morphism) | 함수 (Function) | 함수 (Function) |
합성 (Composition) | 함수 합성 | 함수 합성 |
항등 사상 (Identity Morphism) | 항등 함수 | 항등 함수 |
죄송합니다, 당신을 놀라게 할 의도는 없었습니다. 이 모든 개념에 대해 깊이 알고 있을 것이라고 기대하지 않습니다. 제 요점은 우리가 얼마나 많은 중복을 가지고 있는지 보여주어 카테고리 이론이 왜 이러한 것들을 통합하려고 하는지 알 수 있도록 하는 것입니다.
카테고리 이론에는... 카테고리(category) 라는 것이 있습니다. 이는 다음 구성 요소를 가진 컬렉션으로 정의됩니다:
- 객체(objects) 의 컬렉션
- 사상(morphisms) 의 컬렉션
- 사상에 대한 합성(composition) 의 개념
- 항등원(identity) 이라고 하는 특별한 사상
카테고리 이론은 많은 것을 모델링할 만큼 추상적이지만, 지금 우리가 관심을 갖는 타입과 함수에 적용해 봅시다.
객체의 컬렉션: 객체는 데이터 타입이 될 것입니다. 예를 들어, String
, Boolean
, Number
, Object
등입니다. 우리는 종종 데이터 타입을 가능한 모든 값의 집합으로 봅니다. Boolean
을 [true, false]
의 집합으로, Number
를 가능한 모든 숫자 값의 집합으로 볼 수 있습니다. 타입을 집합으로 취급하는 것은 집합론을 사용하여 작업할 수 있기 때문에 유용합니다.
사상의 컬렉션: 사상은 우리의 표준적인 일상적인 순수 함수가 될 것입니다.
사상에 대한 합성의 개념: 이것은 여러분이 짐작했듯이 우리의 새로운 장난감인 compose
입니다. 우리는 compose
함수가 결합 법칙을 따른다고 논의했는데, 이는 카테고리 이론의 모든 합성에 대해 성립해야 하는 속성이므로 우연이 아닙니다.
다음은 합성을 보여주는 이미지입니다:
함수 합성 다이어그램
다음은 코드의 구체적인 예시입니다:
const g = (x) => x.length; // String -> Number
const f = (x) => x === 4; // Number -> Boolean
const isFourLetterWord = compose(f, g); // String -> Boolean
항등원이라고 하는 특별한 사상: id
라는 유용한 함수를 소개합시다. 이 함수는 단순히 입력을 받아 그대로 뱉어냅니다. 살펴보세요:
const id = (x) => x;
"도대체 이게 무슨 쓸모가 있단 말인가?"라고 자문할 수도 있습니다. 다음 장들에서 이 함수를 광범위하게 사용할 것이지만, 지금은 우리의 값을 대신할 수 있는 함수, 즉 일상적인 데이터로 가장하는 함수라고 생각하세요.
id
는 compose와 잘 어울려야 합니다. 다음은 모든 단항(unary: 인자 하나짜리 함수) 함수 f에 대해 항상 성립하는 속성입니다:
// 항등원 (identity)
(compose(id, f) === compose(f, id)) === f;
// true
이봐요, 이것은 숫자의 항등원 속성과 똑같습니다! 즉시 명확하지 않다면 시간을 좀 가지세요. 헛됨을 이해하세요. 우리는 곧 id
가 곳곳에서 사용되는 것을 보게 될 것이지만, 지금은 주어진 값을 대신하는 함수라는 것을 알 수 있습니다. 이것은 포인트프리 코드를 작성할 때 매우 유용합니다.
자, 여기 타입과 함수의 카테고리가 있습니다. 이것이 첫 소개라면, 카테고리가 무엇이며 왜 유용한지에 대해 여전히 약간 모호할 것이라고 상상합니다. 우리는 이 책 전반에 걸쳐 이 지식을 기반으로 구축할 것입니다. 지금 당장, 이 장에서, 이 줄에서, 당신은 적어도 그것이 합성에 관한 몇 가지 지혜, 즉 결합 법칙과 항등원 속성을 제공한다는 것을 알 수 있습니다.
다른 카테고리에는 무엇이 있냐고요? 글쎄요, 노드를 객체로, 엣지를 사상으로, 합성을 경로 연결로 하는 방향 그래프에 대한 카테고리를 정의할 수 있습니다. 숫자를 객체로, >=
를 사상으로 하는 카테고리를 정의할 수 있습니다 (실제로 모든 부분 순서 또는 전체 순서는 카테고리가 될 수 있습니다). 카테고리는 무수히 많지만, 이 책의 목적상 위에서 정의한 것만 다룰 것입니다. 우리는 충분히 표면을 훑었고 이제 넘어가야 합니다.
요약하자면
합성은 파이프 시리즈처럼 함수들을 함께 연결합니다. 데이터는 애플리케이션을 통해 흘러야 합니다 - 순수 함수는 결국 입력에서 출력으로 가므로, 이 사슬을 끊으면 출력을 무시하게 되어 소프트웨어를 쓸모없게 만듭니다.
우리는 다른 어떤 것보다 합성을 디자인 원칙으로 삼습니다. 이것은 앱을 단순하고 합리적으로 유지하기 때문입니다. 카테고리 이론은 앱 아키텍처, 부수 효과 모델링, 정확성 보장에 큰 역할을 할 것입니다.
이제 이것을 실제로 보는 것이 도움이 될 시점에 도달했습니다. 예제 애플리케이션을 만들어 봅시다.
연습 문제
다음 각 연습 문제에서는 다음과 같은 형태의 Car 객체를 고려할 것입니다:
{
name: 'Aston Martin One-77',
horsepower: 750,
dollar_value: 1850000,
in_stock: true,
}
다음 함수를 고려하십시오:
// reduce, add는 부록에 정의되어 있다고 가정
const average = (xs) => reduce(add, 0, xs) / xs.length;