해당 게시글은 mostly-adequate-guide 의 시리즈들을 번역했습니다.
해당 게시글의 저작권은 mostly-adequate-guide 의 저작권인 Attribution-ShareAlike 4.0 International 을 준수합니다.
🤖 AI가 요약한 글이에요!
이 장에서는 모노이드(Monoid) 의 개념을 세미그룹(Semigroup) 을 통해 설명합니다.
모노이드는 결합 법칙을 만족하는 이항 연산(concat
)과 항등원(empty
)을 가진 대수 구조입니다.
Sum
,Product
,Any
,All
,Array
,String
등 다양한 타입이 모노이드가 될 수 있으며, 데이터 병합, 논리 결합, 문자열 연결 등 다양한 조합 연산에 사용됩니다.
펑터(Identity
,Either
,Task
)도 내부 값이 모노이드일 경우 모노이드가 될 수 있어, 복잡한 구조의 조합을 간결하게 표현할 수 있습니다.
fold
함수는 모노이드의concat
과empty
를 사용하여 배열과 같은 구조를 안전하게 단일 값으로 축약하는 데 유용합니다.
Chapter 13: 모노이드가 모든 것을 하나로 모은다 (Monoids bring it all together)
거친 조합 (Wild combination)
이 장에서는 세미그룹(semigroup) 을 통해 모노이드(monoids) 를 살펴볼 것입니다. 모노이드는 수학적 추상화의 머리카락에 붙은 껌과 같습니다. 그것들은 여러 분야에 걸쳐 있는 아이디어를 포착하여, 비유적으로나 문자 그대로 모든 것을 하나로 모읍니다. 그것들은 계산하는 모든 것을 연결하는 불길한 힘입니다. 우리 코드 베이스의 산소, 그것이 실행되는 기반, 양자 얽힘이 인코딩된 것입니다.
모노이드는 조합(combination) 에 관한 것입니다. 하지만 조합이란 무엇일까요? 그것은 누적에서 연결, 곱셈, 선택, 합성, 순서 지정, 심지어 평가까지 많은 것을 의미할 수 있습니다! 여기서 많은 예제를 보겠지만, 우리는 모노이드 산의 기슭을 살금살금 걷는 정도일 뿐입니다. 인스턴스는 풍부하고 응용 프로그램은 방대합니다. 이 장의 목표는 여러분이 자신만의 모노이드를 만들 수 있도록 좋은 직관을 제공하는 것입니다.
덧셈 추상화하기 (Abstracting addition)
덧셈에는 제가 논의하고 싶은 몇 가지 흥미로운 특성이 있습니다. 추상화 안경을 통해 살펴봅시다.
우선, 그것은 이항 연산(binary operation) 입니다. 즉, 두 개의 값을 받아 하나의 값을 반환하며, 모두 같은 집합 내에 있습니다.
// 이항 연산
1 + 1 = 2
보세요? 정의역에 두 개의 값, 공역에 하나의 값, 모두 같은 집합 - 숫자입니다. 어떤 사람들은 숫자가 "덧셈에 대해 닫혀 있다(closed under addition)"고 말할 수도 있습니다. 즉, 어떤 숫자를 섞든 타입은 절대 변하지 않는다는 의미입니다. 이는 결과가 항상 다른 숫자이므로 연산을 연결할 수 있음을 의미합니다:
// 임의의 개수의 숫자에 대해 실행할 수 있습니다
1 + 7 + 5 + 4 + ...
그것에 더해 (계산된 말장난이군요...), 우리는 결합 법칙(associativity) 을 가지고 있어 연산을 원하는 대로 그룹화할 수 있습니다. 부수적으로, 결합 법칙을 만족하는 이항 연산은 작업을 청크(chunk)하고 분산할 수 있기 때문에 병렬 계산을 위한 레시피입니다.
// 결합 법칙
(1 + 2) + 3 = 6
1 + (2 + 3) = 6
이제, 순서를 재배열할 수 있게 해주는 교환 법칙(commutativity) 과 혼동하지 마세요. 덧셈의 경우 그것이 성립하지만, 현재로서는 그 속성에 특별히 관심이 없습니다 - 우리의 추상화 요구에는 너무 구체적입니다.
생각해보니, 어쨌든 우리의 추상 슈퍼클래스에는 어떤 속성이 있어야 할까요? 어떤 특성이 덧셈에 특정적이고 어떤 것이 일반화될 수 있을까요? 이 계층 구조 속에 다른 추상화가 있습니까, 아니면 모두 하나의 덩어리입니까? 추상 대수학의 인터페이스를 구상할 때 우리의 수학적 선조들이 적용한 것이 바로 이런 종류의 사고입니다.
공교롭게도, 그 옛날 추상주의자들은 덧셈을 추상화할 때 군(group) 이라는 개념에 도달했습니다. 군은 음수의 개념을 포함한 모든 부가 기능을 가지고 있습니다. 여기서는 결합 법칙을 만족하는 이항 연산자에만 관심이 있으므로 덜 구체적인 인터페이스인 세미그룹(Semigroup) 을 선택할 것입니다. 세미그룹은 결합 법칙을 만족하는 이항 연산자 역할을 하는 concat
메서드를 가진 타입입니다.
덧셈에 대해 구현하고 Sum
이라고 부릅시다:
const Sum = (x) => ({
x,
concat: (other) => Sum(x + other.x),
});
다른 Sum
과 concat
하고 항상 Sum
을 반환한다는 점에 유의하세요.
여기서는 일반적인 프로토타입 방식 대신 객체 팩토리를 사용했는데, 주된 이유는 Sum
이 포인티드(pointed) 가 아니고 new
를 입력할 필요가 없기 때문입니다. 어쨌든, 작동하는 모습은 다음과 같습니다:
Sum(1).concat(Sum(3)); // Sum(4)
Sum(4).concat(Sum(37)); // Sum(41)
이처럼, 우리는 구현이 아닌 인터페이스에 맞춰 프로그래밍할 수 있습니다. 이 인터페이스는 군론(group theory)에서 유래했기 때문에 수세기의 문헌이 뒷받침합니다. 무료 문서!
이제, 언급했듯이 Sum
은 포인티드도 아니고 펑터도 아닙니다. 연습 삼아, 돌아가서 법칙을 확인하여 이유를 알아보세요. 알겠습니다, 그냥 알려드리겠습니다: 숫자만 담을 수 있으므로, 기본 값을 다른 타입으로 변환할 수 없기 때문에 map
은 여기서 의미가 없습니다. 그것은 정말 제한적인 map
이 될 것입니다!
그렇다면 이것이 왜 유용할까요? 음, 어떤 인터페이스와 마찬가지로, 다른 결과를 얻기 위해 인스턴스를 교체할 수 있습니다:
const Product = (x) => ({ x, concat: (other) => Product(x * other.x) });
const Min = (x) => ({ x, concat: (other) => Min(x < other.x ? x : other.x) });
const Max = (x) => ({ x, concat: (other) => Max(x > other.x ? x : other.x) });
하지만 이것은 숫자에만 국한되지 않습니다. 다른 타입들을 봅시다:
const Any = (x) => ({ x, concat: (other) => Any(x || other.x) });
const All = (x) => ({ x, concat: (other) => All(x && other.x) });
Any(false).concat(Any(true)); // Any(true)
Any(false).concat(Any(false)); // Any(false)
All(false).concat(All(true)); // All(false)
All(true).concat(All(true)); // All(true)
[1, 2].concat([3, 4]); // [1,2,3,4]
"miracle grow".concat("n"); // miracle grown"
Map({ day: "night" }).concat(Map({ white: "nikes" })); // Map({day: 'night', white: 'nikes'})
이것들을 충분히 오래 쳐다보면 매직아이 포스터처럼 패턴이 튀어나올 것입니다. 어디에나 있습니다. 우리는 데이터 구조를 병합하고, 논리를 결합하고, 문자열을 만들고 있습니다... 거의 모든 작업을 이 조합 기반 인터페이스로 밀어 넣을 수 있는 것 같습니다.
지금까지 Map
을 몇 번 사용했습니다. 제대로 소개되지 않았다면 죄송합니다. Map
은 단순히 Object
를 감싸서 우주의 구조를 변경하지 않고 몇 가지 추가 메서드로 장식할 수 있도록 합니다.
내가 가장 좋아하는 펑터는 모두 세미그룹입니다. (All my favourite functors are semigroups.)
지금까지 본 펑터 인터페이스를 구현하는 타입들은 모두 세미그룹도 구현합니다. Identity
(이전에는 Container로 알려진 아티스트)를 살펴봅시다:
Identity.prototype.concat = function (other) {
return new Identity(this.__value.concat(other.__value));
};
Identity.of(Sum(4)).concat(Identity.of(Sum(1))); // Identity(Sum(5))
Identity.of(4).concat(Identity.of(1)); // TypeError: this.__value.concat is not a function
그것은 __value
가 세미그룹일 때만 세미그룹입니다. 버터핑거 행글라이더처럼, 하나를 잡고 있는 동안 하나입니다.
다른 타입들도 비슷한 동작을 합니다:
// 오류 처리와 결합
Right(Sum(2)).concat(Right(Sum(3))); // Right(Sum(5))
Right(Sum(2)).concat(Left("some error")); // Left('some error')
// 비동기 결합
Task.of([1, 2]).concat(Task.of([3, 4])); // Task([1,2,3,4])
이것은 이러한 세미그룹들을 계단식 조합으로 쌓을 때 특히 유용해집니다:
// formValues :: Selector -> IO (Map String String)
// validate :: Map String String -> Either Error (Map String String)
formValues("#signup").map(validate).concat(formValues("#terms").map(validate)); // IO(Right(Map({username: 'andre3000', accepted: true})))
formValues("#signup").map(validate).concat(formValues("#terms").map(validate)); // IO(Left('one must accept our totalitarian agreement'))
serverA.get("/friends").concat(serverB.get("/friends")); // Task([friend1, friend2])
// loadSetting :: String -> Task Error (Maybe (Map String Boolean))
loadSetting("email").concat(loadSetting("general")); // Task(Maybe(Map({backgroundColor: true, autoSave: false})))
첫 번째 예제에서는 IO
안에 Either
안에 Map
을 결합하여 폼 값을 유효성 검사하고 병합했습니다. 다음으로, 여러 다른 서버를 호출하고 Task
와 Array
를 사용하여 비동기 방식으로 결과를 결합했습니다. 마지막으로, Task
, Maybe
, Map
을 쌓아 여러 설정을 로드, 파싱 및 병합했습니다.
이것들은 chain
되거나 ap
될 수 있지만, 세미그룹은 우리가 하고 싶은 것을 훨씬 더 간결하게 포착합니다.
이것은 펑터를 넘어섭니다. 사실, 전적으로 세미그룹으로 구성된 것은 무엇이든 그 자체로 세미그룹임이 밝혀졌습니다: 키트를 연결할 수 있다면, 전체를 연결할 수 있습니다.
const Analytics = (clicks, path, idleTime) => ({
clicks,
path,
idleTime,
concat: (other) =>
Analytics(
clicks.concat(other.clicks),
path.concat(other.path),
idleTime.concat(other.idleTime)
),
});
Analytics(Sum(2), ["/home", "/about"], Right(Max(2000))).concat(
Analytics(Sum(1), ["/contact"], Right(Max(1000)))
);
// Analytics(Sum(3), ['/home', '/about', '/contact'], Right(Max(2000)))
보세요, 모든 것이 스스로 멋지게 결합하는 방법을 알고 있습니다. 알고 보니, Map
타입을 사용하는 것만으로도 같은 일을 무료로 할 수 있었습니다:
Map({
clicks: Sum(2),
path: ["/home", "/about"],
idleTime: Right(Max(2000)),
}).concat(
Map({ clicks: Sum(1), path: ["/contact"], idleTime: Right(Max(1000)) })
);
// Map({clicks: Sum(3), path: ['/home', '/about', '/contact'], idleTime: Right(Max(2000))})
우리는 원하는 만큼 이것들을 쌓고 결합할 수 있습니다. 그것은 단순히 숲에 나무를 하나 더 추가하거나, 코드베이스에 따라 산불에 불꽃을 하나 더 추가하는 문제입니다.
기본적이고 직관적인 동작은 타입이 담고 있는 것을 결합하는 것이지만, 내부에 있는 것을 무시하고 컨테이너 자체를 결합하는 경우가 있습니다. Stream
과 같은 타입을 고려해보세요:
const submitStream = Stream.fromEvent("click", $("#submit"));
const enterStream = filter(
(x) => x.key === "Enter",
Stream.fromEvent("keydown", $("#myForm"))
);
submitStream.concat(enterStream).map(submitForm); // Stream()
우리는 두 스트림의 이벤트를 하나의 새로운 스트림으로 캡처하여 이벤트 스트림을 결합할 수 있습니다. 또는, 세미그룹을 담고 있어야 한다고 주장함으로써 결합할 수도 있었습니다. 사실, 각 타입에 대해 가능한 많은 인스턴스가 있습니다. Task
를 생각해보세요. 우리는 둘 중 더 빠르거나 늦은 것을 선택하여 결합할 수 있습니다. 오류를 무시하는 효과가 있는 Left
에서 단락(short circuiting)하는 대신 항상 첫 번째 Right
를 선택할 수 있습니다. 이러한 대안적인 인스턴스 중 일부를 구현하는 Alternative 라는 인터페이스가 있으며, 일반적으로 계단식 조합보다는 선택에 중점을 둡니다. 그러한 기능이 필요하다면 살펴볼 가치가 있습니다.
아무것도 아닌 것을 위한 모노이드 (Monoids for nothing)
우리는 덧셈을 추상화하고 있었지만, 바빌로니아인들처럼, 우리는 0의 개념이 부족했습니다 (그것에 대한 언급은 0번 있었습니다).
0은 항등원(identity) 역할을 합니다. 즉, 0
에 더해진 모든 요소는 바로 그 요소를 반환합니다. 추상화 측면에서, 0
을 일종의 중립적이거나 빈(empty) 요소로 생각하는 것이 도움이 됩니다. 이항 연산의 왼쪽과 오른쪽 모두에서 동일하게 작동하는 것이 중요합니다:
// 항등원
1 + 0 = 1;
0 + 1 = 1;
이 개념을 empty
라고 부르고 그것으로 새로운 인터페이스를 만들어 봅시다. 많은 스타트업처럼, 우리는 지독하게 정보가 없지만 편리하게 구글 검색이 가능한 이름인 모노이드(Monoid) 를 선택할 것입니다. 모노이드의 레시피는 어떤 세미그룹이든 가져와서 특별한 항등 요소를 추가하는 것입니다. 타입 자체에 empty
함수로 구현할 것입니다:
Array.empty = () => [];
String.empty = () => "";
Sum.empty = () => Sum(0);
Product.empty = () => Product(1);
Min.empty = () => Min(Infinity);
Max.empty = () => Max(-Infinity);
All.empty = () => All(true);
Any.empty = () => Any(false);
언제 빈 항등 값이 유용할까요? 그것은 0이 왜 유용한지 묻는 것과 같습니다. 아무것도 묻지 않는 것과 같습니다...
다른 아무것도 없을 때, 우리는 누구에게 의지할 수 있을까요? 0입니다. 우리는 몇 개의 버그를 원할까요? 0입니다. 그것은 안전하지 않은 코드에 대한 우리의 허용치입니다. 새로운 시작입니다. 궁극적인 가격표입니다. 그것은 경로에 있는 모든 것을 전멸시킬 수도 있고 위기 상황에서 우리를 구할 수도 있습니다. 황금 구명환이자 절망의 구덩이입니다.
코드 측면에서, 그것들은 합리적인 기본값에 해당합니다:
const settings = (prefix = "", overrides = [], total = 0) => {
/* ... */
};
const settings = (
prefix = String.empty(),
overrides = Array.empty(),
total = Sum.empty()
) => {
/* ... */
};
또는 다른 아무것도 없을 때 유용한 값을 반환하기 위해:
sum([]); // 0
그것들은 또한 누산기(accumulator)를 위한 완벽한 초기값입니다...
집 무너뜨리기 (Folding down the house)
마침 concat
과 empty
는 reduce
의 처음 두 자리에 완벽하게 들어맞습니다. 실제로 empty 값을 무시함으로써 세미그룹 배열을 reduce
할 수 있지만, 보시다시피, 그것은 위태로운 상황으로 이어집니다:
// concat :: Semigroup s => s -> s -> s
const concat = (x) => (y) => x.concat(y);
[Sum(1), Sum(2)].reduce(concat); // Sum(3)
[].reduce(concat); // TypeError: Reduce of empty array with no initial value
다이너마이트가 터집니다. 마라톤에서 발목을 삔 것처럼, 우리는 런타임 예외에 직면했습니다. 자바스크립트는 우리가 달리기를 시작하기 전에 운동화에 권총을 묶는 것을 기꺼이 허용합니다 - 보수적인 종류의 언어라고 생각하지만, 배열이 비어 있을 때는 우리를 즉시 멈추게 합니다. 어쨌든 무엇을 반환할 수 있을까요? NaN
, false
, -1
? 프로그램에서 계속 진행하려면 올바른 타입의 결과가 필요합니다. 실패 가능성을 나타내기 위해 Maybe
를 반환할 수도 있지만, 우리는 더 잘할 수 있습니다.
커링된 reduce
함수를 사용하고 empty
값이 선택 사항이 아닌 안전한 버전을 만들어 봅시다. 이제부터는 fold 로 알려질 것입니다:
// fold :: Monoid m => m -> [m] -> m
const fold = reduce(concat);
초기 m
은 우리의 empty
값 - 우리의 중립적인 시작점입니다. 그런 다음 m
의 배열을 가져와 하나의 아름다운 다이아몬드 같은 값으로 으깨버립니다.
fold(Sum.empty(), [Sum(1), Sum(2)]); // Sum(3)
fold(Sum.empty(), []); // Sum(0)
fold(Any.empty(), [Any(false), Any(true)]); // Any(true)
fold(Any.empty(), []); // Any(false)
fold(Either.of(Max.empty()), [Right(Max(3)), Right(Max(21)), Right(Max(11))]); // Right(Max(21))
fold(Either.of(Max.empty()), [
Right(Max(3)),
Left("error retrieving value"),
Right(Max(11)),
]); // Left('error retrieving value')
fold(IO.of([]), [".link", "a"].map($)); // IO([<a>, <button class="link"/>, <a>])
마지막 두 개에 대해서는 타입 자체에 정의할 수 없기 때문에 수동 empty
값을 제공했습니다. 그것은 전적으로 괜찮습니다. 타입이 지정된 언어는 스스로 그것을 알아낼 수 있지만, 여기서는 전달해야 합니다.
모노이드가 아닌 것 (Not quite a monoid)
모노이드가 될 수 없는, 즉 초기 값을 제공할 수 없는 일부 세미그룹이 있습니다. First
를 보세요:
const First = (x) => ({ x, concat: (other) => First(x) });
Map({ id: First(123), isPaid: Any(true), points: Sum(13) }).concat(
Map({ id: First(2242), isPaid: Any(false), points: Sum(1) })
);
// Map({id: First(123), isPaid: Any(true), points: Sum(14)})
우리는 몇 개의 계정을 병합하고 First
ID를 유지할 것입니다. 그것에 대한 empty
값을 정의할 방법이 없습니다. 그것이 유용하지 않다는 의미는 아닙니다.
대통일 이론 (Grand unifying theory)
군론인가 범주론인가? (Group theory or Category theory?)
이항 연산의 개념은 추상 대수학 어디에나 있습니다. 사실, 그것은 범주(category) 의 주요 연산입니다. 그러나 항등원 없이는 범주론에서 우리의 연산을 모델링할 수 없습니다. 이것이 우리가 군론의 세미그룹으로 시작한 다음, empty를 갖게 되면 범주론의 모노이드로 점프하는 이유입니다.
모노이드는 사상(morphism)이 concat
이고, empty
가 항등원이며, 합성이 보장되는 단일 객체 범주를 형성합니다.
합성을 모노이드로 (Composition as a monoid)
정의역이 공역과 같은 집합에 있는 a -> a
타입의 함수를 자기 사상(endomorphisms) 이라고 합니다. 이 아이디어를 포착하는 Endo
라는 모노이드를 만들 수 있습니다:
const Endo = (run) => ({
run,
concat: (other) => Endo(compose(run, other.run)),
});
Endo.empty = () => Endo(identity);
// 사용 예시
// thingDownFlipAndReverse :: Endo [String] -> [String]
const thingDownFlipAndReverse = fold(Endo.empty(), [
Endo(reverse),
Endo(sort),
Endo(append("thing down")),
]);
thingDownFlipAndReverse.run(["let me work it", "is it worth it?"]);
// ['thing down', 'let me work it', 'is it worth it?']
모두 같은 타입이므로 compose
를 통해 concat
할 수 있으며 타입은 항상 일치합니다.
모나드를 모노이드로 (Monad as a monoid)
join
이 두 개의 (중첩된) 모나드를 가져와 결합 법칙 방식으로 하나로 압축하는 연산이라는 것을 눈치챘을 수도 있습니다. 그것은 또한 자연 변환 또는 "펑터 함수"입니다. 이전에 언급했듯이, 객체로서 펑터와 사상으로서 자연 변환으로 범주를 만들 수 있습니다. 이제, 그것을 자기 펑터(Endofunctors), 즉 같은 타입의 펑터로 특화하면, join
은 우리에게 자기 펑터 범주에서 모노이드, 즉 모나드로 알려진 것을 제공합니다. 코드에서 정확한 공식을 보여주려면 약간의 속임수가 필요한데, 구글 검색을 권장하지만, 그것이 일반적인 아이디어입니다.
어플리커티브를 모노이드로 (Applicative as a monoid)
어플리커티브 펑터조차도 범주론에서 느슨한 모노이드 펑터(lax monoidal functor) 로 알려진 모노이드 공식을 가지고 있습니다. 인터페이스를 모노이드로 구현하고 그것으로부터 ap
를 복구할 수 있습니다:
// concat :: f a -> f b -> f [a, b]
// empty :: () -> f ()
// ap :: Functor f => f (a -> b) -> f a -> f b
const ap = compose(
map(([f, x]) => f(x)),
concat
);
요약 (In summary)
보시다시피, 모든 것은 연결되어 있거나 연결될 수 있습니다. 이 심오한 깨달음은 모노이드를 앱 아키텍처의 광범위한 영역에서부터 가장 작은 데이터 조각에 이르기까지 강력한 모델링 도구로 만듭니다. 애플리케이션의 일부로 직접적인 누적이나 조합이 있을 때마다 모노이드를 생각해보기를 권장합니다. 그런 다음 그것을 익히면, 정의를 더 많은 애플리케이션으로 확장하기 시작하세요 ( 모노이드로 얼마나 많은 것을 모델링할 수 있는지 놀랄 것입니다).