함수 합성
개요
사용자 입력을 저장하기 전에 앞뒤 공백을 지우고 소문자로 바꾼 뒤 연속된 공백을 하나로 줄이려고 함수를 부르면, collapse(lower(trim(s)))처럼 호출이 중첩되어 실행 순서(trim, lower, collapse)와 반대 방향으로 읽어야 한다. 설정에 따라 단계를 빼거나 더할 때도 이 호출식을 경우마다 새로 써야 한다.
함수 합성(function composition)은 두 함수를 이어 새 함수 하나를 만든다. 수학 표기 (g ∘ f)(x) = g(f(x))에서는 오른쪽의 f부터 실행되며, 여기서는 이렇게 이어지는 함수를 단계(step)라고 부른다. 앞 단계의 출력 타입과 다음 단계의 입력 타입이 맞아야 둘을 이을 수 있다.
trim, lower, collapse를 차례로 이어 만든 Func<string, string> 하나가 파이프라인(pipeline)이다. 호출부에서는 어느 단계가 들어 있는지 몰라도 이 함수 하나를 받아 부르면 된다.
흐름
점선 상자 안의 세 단계는 Then으로 이어져 Func<string, string> 하나가 된다. 단계 목록이 비면 입력을 그대로 돌려주는 항등 함수(identity)만 남는다.
역사
Haskell 표준 Prelude는 합성 연산자 (.)를 f . g = \ x -> f (g x)로 정의하고, infixr 9 .로 오른쪽 결합을 선언한다. 수학 표기와 같이 오른쪽 함수가 먼저 실행된다. 같은 Prelude에 항등 함수 id도 정의되어 있다.1
F#은 합성 연산자 >>와 파이프라인 연산자 |>를 둔다. f >> g는 f를 먼저 적용하므로 적은 순서와 실행 순서가 같다. F# 문서는 >>가 함수 둘을 받아 함수를 돌려주고, |>는 값과 함수를 받아 값을 돌려준다고 구분하며, |>의 정의를 let (|>) x f = f x로 싣는다.2
프로그램 단위에서는 Unix 파이프가 앞 명령의 출력을 다음 명령의 입력으로 잇는다. Ritchie에 따르면 파이프는 M. D. McIlroy의 제안으로 1972년 Unix에 들어갔다. 파이프는 프로세스의 입출력 스트림을 잇는 운영체제 기능이므로, 함수 합성과는 비유로만 연결된다.3
왜 사용하는가?
설정에서 읽은 단계 목록을 이어 실행 중에도 함수를 만들 수 있고, 그렇게 합성한 함수는 변수에 담거나 인자로 넘긴다. 각 단계는 작은 함수로 남아 따로 테스트할 수 있다.
합성은 결합 법칙을 따라 f.Then(g).Then(h)와 f.Then(g.Then(h))가 같은 입력에 같은 결과를 내므로, 긴 단계 목록을 앞에서부터 하나씩 접어 함수 하나로 만들 수 있다. 항등 함수는 어느 쪽에 붙여도 결과를 바꾸지 않아 빈 목록의 시작값이 되는데, 이런 결합 법칙과 항등원을 함께 갖는 구조는 모노이드 문서에서 다룬다.
trim.Then(lower)처럼 인자 이름 없이 기존 함수만 이어 정의하는 방식을 point-free 스타일이라고 한다. 조합이 짧으면 흐름이 잘 보이는데, 길어지면 중간 값에 이름이 없어서 오히려 읽기 어렵다.
파일을 쓰거나 공유 상태를 바꾸는 단계를 넣으면 파이프라인에도 그 부수 효과가 그대로 남는다. 그러면 같은 입력에 같은 출력이라는 가정이 깨져 단계별 검사만으로 전체를 판단하기 어렵고, 중간 단계가 예외를 던지면 뒤는 실행되지 않은 채 호출부로 예외가 올라간다. 긴 체인은 중간 값을 담은 변수도 없어서 어느 단계가 실패했는지 찾거나 중단점을 걸어 값을 살피기 불편하다.
예제
C#에는 F#의 >>에 해당하는 합성 연산자가 없어 Func<A, B>에 확장 메서드 Then을 붙였다. f.Then(g)가 x => g(f(x))를 돌려주게 하면 타입 인자는 컴파일러가 추론하고, 출력과 입력 타입이 맞지 않는 조합도 컴파일 단계에서 거부한다.
using System;
Func<int, int> addOne = x => x + 1;
Func<int, int> timesTwo = x => x * 2;
Func<int, string> show = x => "<" + x + ">";
// 중첩 호출은 안쪽부터 읽어야 실행 순서가 보인다.
Console.WriteLine(show(timesTwo(addOne(2))));
// Then으로 이으면 적은 순서대로 실행된다.
var addThenDouble = addOne.Then(timesTwo).Then(show);
Console.WriteLine(addThenDouble(2));
// 순서를 바꾸면 다른 함수가 된다.
var doubleThenAdd = timesTwo.Then(addOne).Then(show);
Console.WriteLine(doubleThenAdd(2));
// show.Then(addOne)은 컴파일되지 않는다. show의 출력(string)이 addOne의 입력(int)과 맞지 않는다.
static class FuncExtensions
{
// f.Then(g)는 x => g(f(x))이다. f가 먼저 실행된다.
public static Func<A, C> Then<A, B, C>(this Func<A, B> f, Func<B, C> g) => x => g(f(x));
}
<6>
<6>
<5>Then으로 이은 addThenDouble은 중첩 호출과 같은 값을 내지만, 순서를 바꾼 doubleThenAdd는 다른 값을 낸다.
범위 넓힌 예제
설정 문자열에서 단계 이름을 읽어 실행 중에 파이프라인을 만든다. 이름 목록은 LINQ의 Select로 함수 목록이 되고, Aggregate가 항등 함수에서 시작해 Then으로 하나씩 접는다. 알 수 없는 이름은 합성 전에 걸러 내고, 빈 문자열에서 실패하는 initial 단계로 중간 예외도 보인다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
var catalog = new Dictionary<string, Func<string, string>>
{
["trim"] = s => s.Trim(),
["lower"] = s => s.ToLowerInvariant(),
["collapse"] = s => Regex.Replace(s, @"\s+", " "),
// 첫 글자만 남긴다. 빈 문자열이면 예외가 난다.
["initial"] = s => s[..1],
};
Func<string, string> identity = s => s;
Run("trim,lower,collapse", " Hello WORLD ");
Run("", " Hello WORLD ");
Run("trim,shout", " Hello ");
Run("trim,initial", " ");
// 묶는 위치가 달라도 같은 입력에 같은 결과를 낸다.
var left = catalog["trim"].Then(catalog["lower"]).Then(catalog["collapse"]);
var right = catalog["trim"].Then(catalog["lower"].Then(catalog["collapse"]));
Console.WriteLine("same result: " + (left(" A B ") == right(" A B ")));
void Run(string config, string input)
{
var names = config.Split(',', StringSplitOptions.RemoveEmptyEntries);
var unknown = names.Where(name => !catalog.ContainsKey(name)).ToArray();
if (unknown.Length > 0)
{
Console.WriteLine($"[{config}] unknown step: {string.Join(", ", unknown)}");
return;
}
// 항등 함수에서 시작해 단계를 하나씩 뒤에 붙인다. 목록이 비면 항등 함수가 그대로 남는다.
var pipeline = names
.Select(name => catalog[name])
.Aggregate(identity, (composed, step) => composed.Then(step));
try
{
Console.WriteLine($"[{config}] \"{pipeline(input)}\"");
}
catch (ArgumentOutOfRangeException e)
{
// 합성된 함수 밖에서는 어느 단계가 실패했는지 알 수 없다.
Console.WriteLine($"[{config}] failed: {e.GetType().Name}");
}
}
static class FuncExtensions
{
// f.Then(g)는 x => g(f(x))이다. f가 먼저 실행된다.
public static Func<A, C> Then<A, B, C>(this Func<A, B> f, Func<B, C> g) => x => g(f(x));
}
[trim,lower,collapse] "hello world"
[] " Hello WORLD "
[trim,shout] unknown step: shout
[trim,initial] failed: ArgumentOutOfRangeException
same result: True빈 설정에서는 항등 함수가 남아 입력이 그대로 나오고, trim,initial에서는 trim이 공백뿐인 입력을 빈 문자열로 만든 뒤 initial이 예외를 던진다. 호출부에 남는 것은 예외 타입뿐이라 실패한 단계 이름까지 알려면 단계마다 이름을 붙여 감싸거나 결과 타입에 실패를 담아야 하는데, 여기서는 그 장치를 넣지 않았다. 마지막 줄의 비교 역시 입력 하나에서 결과가 같았음을 보여 줄 뿐 두 함수가 같다는 증명은 아니다.
TypeScript 예제
TypeScript에서도 then을 직접 정의하되 단계 목록은 reduce로 접는다. JavaScript의 slice는 빈 문자열을 받아도 예외를 내지 않으므로 initial에서 RangeError를 직접 던져 C# 예제와 같은 실패를 나타낸다.
type Step = (s: string) => string;
// then(f, g)는 x => g(f(x))이다. f가 먼저 실행된다.
const then = <A, B, C>(f: (a: A) => B, g: (b: B) => C) => (a: A): C => g(f(a));
const identity: Step = (s) => s;
const catalog = new Map<string, Step>([
["trim", (s) => s.trim()],
["lower", (s) => s.toLowerCase()],
["collapse", (s) => s.replace(/\s+/g, " ")],
// 첫 글자만 남긴다. JavaScript의 slice는 빈 문자열에서 예외를 내지 않으므로 직접 던진다.
["initial", (s) => {
if (s.length === 0) throw new RangeError("empty input");
return s.slice(0, 1);
}],
]);
function run(config: string, input: string): void {
const names = config.split(",").filter((name) => name.length > 0);
const unknown = names.filter((name) => !catalog.has(name));
if (unknown.length > 0) {
console.log(`[${config}] unknown step: ${unknown.join(", ")}`);
return;
}
// 항등 함수에서 시작해 단계를 하나씩 뒤에 붙인다. 목록이 비면 항등 함수가 그대로 남는다.
const pipeline = names
.map((name) => catalog.get(name)!)
.reduce((composed, step) => then(composed, step), identity);
try {
console.log(`[${config}] "${pipeline(input)}"`);
} catch (e) {
// 합성된 함수 밖에서는 어느 단계가 실패했는지 알 수 없다.
if (!(e instanceof RangeError)) throw e;
console.log(`[${config}] failed: ${e.name}`);
}
}
run("trim,lower,collapse", " Hello WORLD ");
run("", " Hello WORLD ");
run("trim,shout", " Hello ");
run("trim,initial", " ");[trim,lower,collapse] "hello world"
[] " Hello WORLD "
[trim,shout] unknown step: shout
[trim,initial] failed: RangeError실행 순서와 결과는 C# 예제와 같고, 예외에도 실패한 단계 이름은 붙지 않는다.
같이 보기
예제 실행 환경: C#은 독립된 .NET 10 콘솔 프로그램, TypeScript는 ES2022 이상으로 컴파일한 뒤 Node.js에서 실행한다.
각주
- Simon Marlow (편집). Haskell 2010 Language Report. 9장 Standard Prelude. 합성 연산자 (.)와 항등 함수 id의 정의, infixr 9 . 선언. ↩
- Microsoft. Functions (F#). F# 언어 참조, Pipelines 절과 Function composition 절. ↩
- Dennis M. Ritchie. The Evolution of the Unix Time-sharing System. 1979년 Language Design and Programming Methodology 학회 발표, AT&T Bell Laboratories Technical Journal 63(6) (1984) 재수록. Pipes 절. ↩