펑터
개요
상품 가격에 배송비 3000원을 더하는 p => p + 3000은 한 줄이면 되지만, 배열에 적용하려면 가격을 하나씩 꺼내 더하고 새 배열에 넣어야 하며, 가격이 없거나 조회가 실패할 수 있다면 값이나 성공 여부부터 확인해야 한다. 같은 계산에도 값을 담은 구조가 달라질 때마다 꺼내고 다시 담는 코드가 붙는 것이다.
펑터(Functor)는 이 꺼내고 담는 일을 구조의 map 연산에 맡긴다. map에 함수 f를 주면 안의 값에 적용한 뒤 바깥 구조를 유지해서 돌려주는데, 배열은 길이와 순서를 유지하며 원소마다 적용한다. Option은 값이 있는 Some과 없는 None을 구분해 Some일 때만 f를 부르고, 성공(Ok)과 오류(Err)를 담는 Result도 Ok에만 적용하고 Err는 그대로 넘긴다. C# LINQ에서는 Select가 이 map에 해당한다.
구조를 보존한다는 약속을 두 법칙으로 적는다. 받은 값을 그대로 돌려주는 x => x로 map했을 때 원래 값과 같아야 한다는 항등 법칙, f 다음 g를 적용하는 함수로 한 번 map해도 f와 g로 나눠 map한 결과와 같아야 한다는 합성 법칙이다. 첫 법칙은 원소를 빼거나 순서를 바꾸지 않는다는 뜻이고, 두 번째는 단계를 합치거나 나눠도 결과가 같다는 뜻이다.
흐름
같은 f를 받아도 리스트는 리스트로, Option은 Option으로, Result는 Result로 나온다. Err는 f를 거치지 않아 오류 메시지가 그대로 남는다.
역사
펑터라는 이름은 수학의 카테고리 이론에서 왔다. Samuel Eilenberg와 Saunders Mac Lane은 1942년 미국수학회에서 발표하고 1945년 Transactions of the American Mathematical Society에 실은 논문 "General Theory of Natural Equivalences"에서 카테고리와 펑터를 정의했다. 이 논문에서 펑터는 대상(object)을 대상으로 보내는 함수와 사상(mapping)을 사상으로 보내는 함수의 짝이고, 항등 사상을 항등 사상으로 보내며 사상의 합성을 보존해야 한다.1 프로그래밍에서 쓰는 두 법칙은 이 두 조건에 대응한다.
Haskell은 이 패턴을 타입 클래스 Functor로 묶었다. Haskell 2010 Report는 class Functor f where fmap :: (a -> b) -> f a -> f b를 정의하고, 인스턴스가 fmap id = id와 fmap (f . g) = fmap f . fmap g를 만족해야 한다고 적는다. 리스트, IO, Maybe가 이 클래스에 속한다.2 여기서 f는 Int 같은 완성된 타입이 아니라 Maybe처럼 타입 인자를 하나 더 받아야 완성되는 타입 생성자다. 타입 생성자를 대상으로 하는 클래스(constructor class)는 Haskell 1.3에서 언어에 들어왔다.3 base 라이브러리 문서는 fmap을 "preserving the structure of f", 즉 f의 구조를 보존하면서 함수를 적용하는 연산으로 설명한다.4
C#에는 Functor 같은 공통 추상이 없다. C# 사양에서 타입 매개변수는 값 형식이나 참조 형식 하나를 가리킨다.5 그래서 Option<>처럼 타입 인자를 아직 받지 않은 타입 생성자를 매개변수로 받아 "Select를 가진 모든 F"를 인터페이스 하나로 적을 수 없고, 타입마다 자기 Select를 둔다. 대신 쿼리 구문은 문법적으로 메서드 호출로 바뀐다. 사양은 from x in e select v를 ( e ) . Select ( x => v )로 번역한다고 정하고, 그 Select가 인스턴스 메서드든 확장 메서드든 상관하지 않는다.6
왜 사용하는가?
호출부에서는 p => p + 3000 같은 계산 함수만 배열, Option, Result에 넘기면 된다. 값을 꺼내 다시 담는 처리와 값이 없거나 실패했는지 확인하는 분기가 구조 안으로 들어가니, 같은 확인을 호출할 때마다 적을 필요가 없다.
법칙을 지키는 map이라면 Select(f).Select(g)와 Select(x => g(f(x)))가 같은 결과를 낸다. 부수 효과가 없는 f와 g라면 단계를 나누거나 합치는 리팩터링이 결과를 바꾸지 않는다.
원소를 거르거나 None을 Some으로 바꾸고 Err를 복구하는 일은 구조를 보존하는 map의 몫이 아니다. f가 Option을 돌려주면 Option<Option<int>>처럼 겹치는데, 이를 펼치면서 다음 단계로 잇는 연산은 모나드가 다루며 C#에서는 SelectMany가 그 자리에 있다.
컴파일러가 확인하는 것은 이름과 시그니처이지 두 법칙이 아니다. 원소를 버리는 Select도 컴파일되고 쿼리 구문에 쓰일 수 있으며, C#에는 공통 추상이 없어 아무 펑터에나 동작하는 함수를 한 번에 적기도 어렵다.
예제
배열의 가격마다 배송비를 더한 뒤 문자열로 바꿔 보자. LINQ의 Select로 쓰든 쿼리 구문으로 쓰든 map 역할은 같다.
using System;
using System.Collections.Generic;
using System.Linq;
int[] prices = [12000, 0, 45000];
// Select는 각 원소에만 함수를 적용하고, 원소 수와 순서는 그대로 둔다.
var withShipping = prices.Select(p => p + 3000);
// 쿼리 구문도 컴파일러가 withShipping.Select(p => $"{p} KRW")로 바꾼다.
var labels = from p in withShipping select $"{p} KRW";
Console.WriteLine(string.Join(", ", withShipping));
Console.WriteLine(string.Join(", ", labels));
Console.WriteLine($"count: {prices.Length} -> {labels.Count()}");
15000, 3000, 48000
15000 KRW, 3000 KRW, 48000 KRW
count: 3 -> 3원소 수는 3에서 3으로 그대로이고, 값만 바뀐다.
범위 넓힌 예제
같은 두 함수 addShipping과 format을 배열, Option, Result에 적용하고, 두 법칙을 값으로 비교한다. Option과 Result는 .NET 표준 라이브러리에 없어서 record로 직접 정의하고 각자 Select를 둔다. Task에는 await로 결과를 꺼내 f를 적용하는 Select를 붙이고, 마지막에는 법칙을 어기는 Select를 하나 만든다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
Func<int, int> addShipping = p => p + 3000;
Func<int, string> format = p => $"{p} KRW";
Func<int, string> addThenFormat = p => format(addShipping(p));
// 같은 두 함수를 리스트, Option, Result에 그대로 적용한다.
int[] prices = [12000, 0];
Option<int>[] options = [new Option<int>.Some(12000), new Option<int>.None()];
Result<int>[] results = [new Result<int>.Ok(12000), new Result<int>.Err("price missing")];
Console.WriteLine(string.Join(", ", prices.Select(addShipping).Select(format)));
foreach (var o in options) Console.WriteLine(o.Select(addShipping).Select(format));
foreach (var r in results) Console.WriteLine(r.Select(addShipping).Select(format));
// 항등 법칙: Select(x => x)는 원래 값과 같다.
// 합성 법칙: Select(addThenFormat)는 Select(addShipping).Select(format)과 같다.
var listComp = prices.Select(addThenFormat).SequenceEqual(prices.Select(addShipping).Select(format));
Console.WriteLine($"list: id={prices.Select(x => x).SequenceEqual(prices)}, comp={listComp}");
foreach (var o in options)
{
var comp = o.Select(addThenFormat).Equals(o.Select(addShipping).Select(format));
Console.WriteLine($"option: id={o.Select(x => x).Equals(o)}, comp={comp}");
}
foreach (var r in results)
{
var comp = r.Select(addThenFormat).Equals(r.Select(addShipping).Select(format));
Console.WriteLine($"result: id={r.Select(x => x).Equals(r)}, comp={comp}");
}
// Select만 있으면 쿼리 구문이 Option과 Task에도 쓰인다.
Console.WriteLine(from p in options[0] select addThenFormat(p));
Console.WriteLine(await (from p in Task.FromResult(12000) select addThenFormat(p)));
// 컴파일러는 법칙을 검사하지 않는다. 첫 원소를 버리는 Select도 그대로 컴파일된다.
var batch = new LossyBatch<int>([12000, 0]);
var same = from x in batch select x;
Console.WriteLine($"lossy: {string.Join(", ", same.Items)}, id={same.Items.SequenceEqual(batch.Items)}");
abstract record Option<T>
{
public abstract Option<U> Select<U>(Func<T, U> f);
// 값이 있을 때만 f를 부른다.
public sealed record Some(T Value) : Option<T>
{
public override Option<U> Select<U>(Func<T, U> f) => new Option<U>.Some(f(Value));
}
// 값이 없으면 f를 부르지 않고 None을 돌려준다.
public sealed record None : Option<T>
{
public override Option<U> Select<U>(Func<T, U> f) => new Option<U>.None();
}
}
abstract record Result<T>
{
public abstract Result<U> Select<U>(Func<T, U> f);
// 성공 값에만 f를 적용한다.
public sealed record Ok(T Value) : Result<T>
{
public override Result<U> Select<U>(Func<T, U> f) => new Result<U>.Ok(f(Value));
}
// 실패는 f를 부르지 않고 같은 오류 메시지로 넘긴다.
public sealed record Err(string Error) : Result<T>
{
public override Result<U> Select<U>(Func<T, U> f) => new Result<U>.Err(Error);
}
}
static class TaskFunctor
{
// await로 결과를 꺼내 f를 적용한다. 작업이 실패하면 await가 그 예외를 다시 던진다.
public static async Task<U> Select<T, U>(this Task<T> task, Func<T, U> f) => f(await task);
}
// 법칙을 어기는 Select: 구조(원소 수)를 바꾼다.
sealed class LossyBatch<T>(T[] items)
{
public T[] Items => items;
public LossyBatch<U> Select<U>(Func<T, U> f) => new(items.Skip(1).Select(f).ToArray());
}
15000 KRW, 3000 KRW
Some { Value = 15000 KRW }
None { }
Ok { Value = 15000 KRW }
Err { Error = price missing }
list: id=True, comp=True
option: id=True, comp=True
option: id=True, comp=True
result: id=True, comp=True
result: id=True, comp=True
Some { Value = 15000 KRW }
15000 KRW
lossy: 0, id=FalseNone과 Err는 f를 거치지 않고 그대로 나오며, 비교한 다섯 입력에서는 두 법칙이 성립한다. 이 결과는 해당 입력에 대한 확인으로, 모든 입력에 대한 증명과는 구별한다. 첫 원소를 버리는 LossyBatch도 Select가 있으니 똑같이 컴파일되지만 from x in batch select x가 원래 값과 달라진다(id=False). Task용 Select는 작업이 실패하면 예외를 그대로 전파하므로 Result처럼 실패를 값으로 넘기지 않는다. ContinueWith는 결과 값 대신 완료된 Task 자체를 인자로 넘겨 비슷한 일을 하며, map으로 쓰려면 실패와 취소를 직접 확인해야 한다.7
TypeScript 예제
TypeScript에서도 배열의 map이 같은 역할을 하며, Option과 Result는 kind로 경우를 나누는 유니언 타입과 구조별 map 함수로 적는다.
type Option<T> = { kind: "some"; value: T } | { kind: "none" };
type Result<T> = { kind: "ok"; value: T } | { kind: "err"; error: string };
// 값이 있을 때만 f를 부르고, 없으면 none을 그대로 돌려준다.
function mapOption<T, U>(o: Option<T>, f: (x: T) => U): Option<U> {
return o.kind === "some" ? { kind: "some", value: f(o.value) } : o;
}
// 실패는 f를 부르지 않고 같은 오류로 넘긴다.
function mapResult<T, U>(r: Result<T>, f: (x: T) => U): Result<U> {
return r.kind === "ok" ? { kind: "ok", value: f(r.value) } : r;
}
const addShipping = (p: number): number => p + 3000;
const format = (p: number): string => `${p} KRW`;
const addThenFormat = (p: number): string => format(addShipping(p));
const id = <T>(x: T): T => x;
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
const prices = [12000, 0];
const options: Option<number>[] = [{ kind: "some", value: 12000 }, { kind: "none" }];
const results: Result<number>[] = [{ kind: "ok", value: 12000 }, { kind: "err", error: "price missing" }];
console.log(prices.map(addShipping).map(format).join(", "));
for (const o of options) console.log(JSON.stringify(mapOption(mapOption(o, addShipping), format)));
for (const r of results) console.log(JSON.stringify(mapResult(mapResult(r, addShipping), format)));
// 항등 법칙과 합성 법칙을 값으로 비교한다.
const listComp = same(prices.map(addThenFormat), prices.map(addShipping).map(format));
console.log(`list: id=${same(prices.map(id), prices)}, comp=${listComp}`);
for (const o of options) {
const comp = same(mapOption(o, addThenFormat), mapOption(mapOption(o, addShipping), format));
console.log(`option: id=${same(mapOption(o, id), o)}, comp=${comp}`);
}
for (const r of results) {
const comp = same(mapResult(r, addThenFormat), mapResult(mapResult(r, addShipping), format));
console.log(`result: id=${same(mapResult(r, id), r)}, comp=${comp}`);
}
15000 KRW, 3000 KRW
{"kind":"some","value":"15000 KRW"}
{"kind":"none"}
{"kind":"ok","value":"15000 KRW"}
{"kind":"err","error":"price missing"}
list: id=true, comp=true
option: id=true, comp=true
option: id=true, comp=true
result: id=true, comp=true
result: id=true, comp=truenone과 err는 f를 부르지 않고 받은 객체를 그대로 돌려준다. 여기서는 값을 JSON.stringify로 비교하므로 키 순서가 같은 단순 객체에 한해 맞는 비교다.
같이 보기
예제 실행 환경: C#은 독립된 .NET 10 콘솔 프로그램, TypeScript는 ES2022 이상으로 컴파일한 뒤 Node.js에서 실행한다.
각주
- Samuel Eilenberg, Saunders MacLane. "General Theory of Natural Equivalences". Transactions of the American Mathematical Society 58 (1945), pp. 231-294. 첫 쪽에 1942년 9월 8일 학회 발표로 적혀 있다. 3절의 functor 정의와 조건 (3.1), (3.3). ↩
- Haskell 2010 Language Report, 6.3.5 The Functor Class ↩
- Changes from Haskell 1.2 to Haskell 1.3, constructor classes 항목. ↩
- base 4.22.0.0, Data.Functor, class Functor 설명. ↩
- C# 언어 사양, 8.5 Type parameters ↩
- C# 언어 사양, 12.23.3.6 Select clauses. 번역 대상 메서드가 인스턴스 메서드나 확장 메서드일 수 있다는 내용은 12.23.3.1 General. ↩
- Task<TResult>.ContinueWith, continuationFunction 매개변수 설명. ↩