fp-ts 라이브러리 입문

fp-ts 라이브러리를 공부하며 남기는 기록

fp-ts

fp-ts 는 함수형 프로그래밍을 위한 TypeScript 라이브러리이다.
이전에 Do it! 타입스크립트 프로그래밍 라는 책에서 TS를 통해 FP를 쪼금 맛봤었다.
당시에는 책에서 ramda 라이브러리를 사용해서 나도 ramda를 사용했었다.
하지만 ramda는 기본적으로 TS를 지원하지 않는 등 사용하기에 불편한 점이 많았다.
그래서 이번에는 fp-ts를 사용해보기로 했다.

마침 공식 문서에 올라와 있는 입문서에서 소개한 글이 있어, 이를 읽으면서 이해한 대로 정리해보기로 했다.

Eq

많은 사람들은 같다라는 개념은 굉장히 단순하고 명확하다고 생각한다.
하지만 같다라는 개념을 정의하려면 생각보다 많은 것을 고려해야 한다.
예를 들어 사람에게 11.0은 같다고 생각하지만 컴퓨터는 보통은 다르다고 생각한다.
대부분의 프로그래밍 언어는 1 == 1.0false 로 평가한다.
하지만 파이썬이나 JS 등의 언어는 1 == 1.0true 로 평가한다.
누가 맞고 틀린 걸까?
정답은 “둘다 맞다.” 이다.

수학에서 같다라는 개념을 ‘동치관계’라고 부른다.
동치관계는 다음과 같은 세가지 조건을 만족해야 한다.

  1. $a = a$: 자기 자신과는 항상 같다.
  2. $a = b \Leftrightarrow b = a$: $a$가 $b$와 같다면 $b$도 $a$와 같다.
  3. $a = b \land b = c \Rightarrow a = c$: $a$가 $b$와 같고 $b$가 $c$와 같다면 $a$는 $c$와 같다.

이 조건을 만족해야만 동치관계, 즉 ‘같다’라는 개념으로 쓸 수 있다.
이렇게 말하면 되게 엄격하게 들린다.
하지만 수학이라는 학문이 재밌는 게 말장난같은 부분이 있다.
이 조건들을 만족해야만 동치관계지만, 반대로 말하면 저 조건을 만족하면 어떤 개념이든 동치관계로 쓸 수 있다는 뜻이다.
즉, 사람이나 파이썬처럼 11.0이 같다고 보든, 일반적인 프로그래밍 언어처럼 11.0이 다르다고 보든, 저 세 조건만 만족하면 둘다 동치관계로 쓸 수 있다는 뜻이다.
따라서 둘다 정답이라는 것이다.

타입 클래스와 인터페이스

말이 길어졌는데 아무튼 이런 동치관계를 정의하기 위해 fp-ts에서는 Eq라는 타입 클래스를 제공한다.
타입 클래스는 타입을 위한 인터페이스라고 생각하면 된다.
A라는 타입이 B라는 타입 클래스라는 것은 B 타입 클래스가 제공하는 인터페이스를 A가 구현한다는 뜻이다.
Eq 타입 클래스는 다음과 같이 정의된다.

interface Eq<A> {
  /** `x`가 `y`와 같다면 `true`를 반환한다. */
  readonly equals: (x: A, y: A) => boolean
}

인스턴스는 어떤 타입 클래스를 구현한 타입을 말한다.
타입 A가 타입 클래스 Eq 의 인스턴스이기 위해서는 (Eq를 구현하기 위해서는) equals라는 함수(메소드)를 구현해야 한다. 반대로 A 라는 타입에 equals라는 함수가 적절히 구현되어 있다면 AEq 타입 클래스의 인스턴스라고 (Eq 타입 클래스를 구현한다고) 말할 수 있다.
\(A \in \operatorname{Eq} \Leftrightarrow \forall x, y, z \in A,\,\exists \operatorname{R}_{∼} \sub X×X s.t \\ 1.\ (x,x) \in \operatorname{R}_{∼}\\ 2.\ (x,y) \in \operatorname{R}_{∼} \Leftrightarrow (y,x) \in \operatorname{R}_{∼}\\ 3.\ (x,y) \in \operatorname{R}_{∼} \land (y,z) \in \operatorname{R}_{∼} \rightarrow (x,z) \in \operatorname{R}_{∼}\)

예를 들어 Eq<number> 는 다음과 같이 정의할 수 있다.

import { Eq } from 'fp-ts/Eq'
const eqNumber: Eq<number> = {
  equals: (x, y) => x === y,
};

elem 함수 구현

Eq 타입 클래스를 정의했다는 뜻은 두 값 간의 동등성을 비교할 수 있다는 뜻이다.
이를 통해 어떤 배열에서 특정 값이 존재하는지 확인하는 elem 함수(Array.prototype.includes)를 구현할 수 있다.

function elem<A>(E: Eq<A>): (a: A, as: Array<A>) => boolean {
  return (a, as) => as.some(item => E.equals(item, a));
}

2차원 위의 두 점에 대한 Eq 인스턴스를 구현해보자.

type Point = {
  x: number;
  y: number;
};

const eqPoint: Eq<Point> = {
  equals: (p1, p2) => p1.x === p2.x && p1.y === p2.y,
  // 원문에서는 다음과 같이 더 효율적인 코드도 제시했다.
  equals: (p1, p2) => p1 === p2 || (p1.x === p2.x && p1.y === p2.y),
};

물론 이렇게 직접 구현할 수도 있지만, fp-ts에서는 좀더 구현하기 쉽게 도와주는 함수 struct 를 제공한다.

import { struct } from 'fp-ts/Eq';
const eqPoint: Eq<Point> = struct({
  x: eqNumber,
  y: eqNumber,
});

보다시피 이미 구현해 놓은 eqNumber 인스턴스를 사용해서 eqPoint 인스턴스를 구현했다. 마찬가지로 eqPoint 또한 Eq 의 인스턴스이므로 한 번 더 사용할 수도 있다.

type Vector = {
  from: Point
  to: Point
}

const eqVector: Eq<Vector> = struct({
  from: eqPoint,
  to: eqPoint
})

원문에서는 struct 와 같은 함수를 조합자(combinator)라고 부른다.
조합자란 FP의 뿌리 중 하나인 조합 논리(Combinatory logic) 에서 유래한 용어로, 그냥 함수라 보면 된다.
일반적으로 함수는 영어로 기능function, 기능을 수행하는 것이라는 뜻이다. 기존 관념으로는 함수란 한 번에 하나의 기능을 수행만 할 수 있다고 생각해왔다.
하지만 조합논리에서는 함수와 함수를 조합해서 또다른 함수를 만들어서 사용하는 관점으로 보기 때문에 조합자라고 부른다.
마치 레고를 조합해서 또다른 레고 조각을 만들 듯이 말이다.

아무튼 fp-ts/Eq 모듈에는 어떤 타입이 Eq 타입 클래스의 인스턴스라면 사용할 수 있는 다양한 조합자들이 정의되어 있다.
또 다른 모듈에서도 Eq 타입 클래스의 인스턴스를 사용할 수 있는 조합자들이 정의되어 있다. 원문에서는 두 가지 조합자를 추가로 소개했다.
먼저 getEqfp-ts/Eq 가 아닌 다른 타입의 모듈에 있는 조합자로, 해당 타입의 Eq 인스턴스를 만든다. 예를 들어 fp-ts/Array 모듈의 getEq 는 배열의 Eq 인스턴스를 만든다.

import { getEq } from 'fp-ts/Array'

const eqArrayOfPoints: Eq<Array<Point>> = getEq(eqPoint);
const point1: Point = { x: 1, y: 2 };
const point2: Point = { x: 2, y: 3 };
const point3: Point = { x: 3, y: 4 };
const points1: Array<Point> = [point1, point2, point3];
const points2: Array<Point> = [point1, point2, point3];
const points3: Array<Point> = [point1, point3, point2];
console.log(
  eqArrayOfPoints.equals(points1, points2),
  eqArrayOfPoints.equals(points1, points3)
); // true false

원문에서는 마지막으로 fp-ts/Eq 모듈의 contramap 이라는 조합자를 소개했다.
contramap 은 두 값을 비교하기 전에 먼저 어떤 함수를 적용해서 값을 변환하는 조합자이다.


import { contramap } from "fp-ts/Eq";

type User = {
  userId: number;
  name: string;
};

const user1: User = { userId: 1, name: "Giulio" };
const user2: User = { userId: 1, name: "Giulio Canti" };
const user3: User = { userId: 2, name: "Giulio" };

/** 두 User의 userId 의 동등성 비교 */
const eqUserId = contramap(({ userId }: User) => userId)(N.Eq);
console.log(
  eqUserId.equals(user1, user2), // true
  eqUserId.equals(user1, user3) // false
);

/** 두 User의 name 의 동등성 비교 */
const eqUserName = contramap(({ name }: User) => name)(S.Eq);
console.log(
  eqUserName.equals(user1, user2), // false
  eqUserName.equals(user1, user3) // true
);

Ord

Ord 는 전순서를 보장하는 타입 클래스이다.
어떤 타입(집합)이 전순서라는 말은 해당 타입의 모든 값을 순서대로 나열할 수 있다, 임의의 두 값의 크기를 비교할 수 있다는 뜻이다.
예를 들어 수학의 실수는 전순서 집합이지만, 복소수는 전순서 집합이 아니다.

import { Eq } from 'fp-ts/Eq'

type Ordering = -1 | 0 | 1

interface Ord<A> extends Eq<A> {
  /**
   * x < y : -1
   * x = y :  0
   * x > y :  1
   */
  readonly compare: (x: A, y: A) => Ordering
}

이때 비교하는 함수는 다음과 같은 성질을 만족시켜야 한다.

  1. $\operatorname{compare}(x, x) \le 0$: $x$는 항상 자기 자신과 같다(같거나 작다).
  2. $\operatorname{compare}(x, y) <= 0 \land \operatorname{compare}(x, y) <= 0 \Rightarrow \operatorname{compare}(x, y) = 0$: $x$는 $y$보다 작거나 같고, $y$는 $x$보다 작거나 같다면 $x$와 $y$가 같다.
  3. $\operatorname{compare}(x, y) <= 0 \land \operatorname{compare}(y, z) <= 0 \Rightarrow \operatorname{compare}(x, z) <= 0$: $x$가 $y$보다 작거나 같고, $y$가 $z$보다 작거나 같다면 $x$는 $z$보다 작거나 같다.
  4. $-1 \le \operatorname{compare}(x, y) \le 1$: 임의의 두 값은 비교할 수 있어야 한다. 즉, 임의의 값은 다른 값에 비해 작거나 같거나 크거나 세 가지 경우 중 하나이다.
\[X \in \operatorname{Ord} \Leftrightarrow \\\forall x, y, z \in X,\,\exists \operatorname{R}_{\le} \sub X×X\ s.t \\ 1.\ x \le x \\ 2.\ x \le y \land y \le x \Rightarrow x = y \\ 3.\ x \le y \land y \le z \Rightarrow x \le z \\ 4.\ x \le y \lor y \le x\]

Ord 구현

예를 들어 number 는 다음과 같이 Ord 타입 클래스의 인스턴스를 구현할 수 있다.

const ordNumber: Ord<number> = {
  equals: (x, y) => x === y,
  compare: (x, y) => (x < y ? -1 : x > y ? 1 : 0)
}

혹은 fromCompare 라는 조합자를 사용해서 다음과 같이 구현할 수도 있다.

import { fromCompare } from 'fp-ts/Ord'

const ordNumber: Ord<number> = fromCompare((x, y) => (x < y ? -1 : x > y ? 1 : 0))

Ord 사용

이를 통해 min 함수를 다음과 같이 구현할 수 있다.

function min<A>(O: Ord<A>): (x: A, y: A) => A {
  return (x, y) => (O.compare(x, y) === 1 ? y : x);
}

만약 조금 복잡한 타입이라면 Eqcontramap 처럼 Ordcontramap 을 사용해서 Ord 인스턴스를 만들 수 있다.

import * as N from "fp-ts/number";
import * as S from "fp-ts/string";
import { contramap } from "fp-ts/Ord";

interface User {
  name: string;
  age: number;
}
const guido = { name: "Guido", age: 45 };
const giulio = { name: "Giulio", age: 48 };

const byAge: Ord<User> = contramap(({ age }: User) => age)(N.Ord);
const getYounger = min(byAge);
const byName: Ord<User> = contramap(({ name }: User) => name)(S.Ord);
const getLexicalFirst = min(byName);

console.log(getYounger(guido, giulio).name); // Guido
console.log(getLexicalFirst(guido, giulio).name); // Giulio

비슷하게 max 를 구현하면 getOlder. GetLexicalLast 함수도 간단하게 만들 수 있다.

function max<A>(O: Ord<A>): (x: A, y: A) => A {
  return (x, y) => (O.compare(x, y) === -1 ? y : x);
}
const getOlder = max(byAge);
const getLexicalLast = max(byName);
console.log(getOlder(guido, giulio).name); // Giulio
console.log(getLexicalLast(guido, giulio).name); // Guido

Semigroup

Semigroup 은 결합법칙을 만족하는 이항연산을 가진 타입 클래스이다.
한국어로는 반군 이라고 번역된다.

interface Semigroup<A> {
  readonly concat: (x: A, y: A) => A
}

concat 하면 Array.prototype.concat 이 떠오르겠지만, 여기서 concat 은 결합법칙을 만족하는 어떤 연산이든 가능하다.
결합법칙은 $(x \circ y) \circ z = x \circ (y \circ z)$ 이다.
함수 형식으로 쓰면 concat(concat(x, y), z) = concat(x, concat(y, z)) 이다.
예를 들어 수의 사칙연산 중 덧셈과 곱셈은 결합법칙을 만족한다. \((1 + 2) + 3 = 1 + (2 + 3)\\ (1 \times 2) \times 3 = 1 \times (2 \times 3)\) 하지만 뺄셈과 나눗셈은 결합법칙을 만족하지 않는다. \((1 - 2) - 3 \ne 1 - (2 - 3)\\ (1 \div 2) \div 3 \ne 1 \div (2 \div 3)\)

Semigroup 구현

이렇게만 말하면 추상적이다 보니 이해가 잘 안될 수 있다.
하지만 추상적인 만큼 다양한 연산이 결합법칙을 만족하고, 이를 통해 다양한 타입을 Semigroup 타입 클래스의 인스턴스로 만들 수 있다.

/** `number`의 덧셈 */
const semigroupSum: Semigroup<number> = {
  concat: (x, y) => x + y
}
/** `number`의 곱셈 */
const semigroupProduct: Semigroup<number> = {
  concat: (x, y) => x * y
}
/** `string`의 결합 */
const semigroupString: Semigroup<string> = {
  concat: (x, y) => x + y
}
/** 항상 첫 인자를 반환한다 */
function getFirstSemigroup<A = never>(): Semigroup<A> {
  return { concat: (x, y) => x }
}
/** 항상 마지막 인자를 반환한다 */
function getLastSemigroup<A = never>(): Semigroup<A> {
  return { concat: (x, y) => y }
}
/** `Array.prototype.concat` */
function getArraySemigroup<A = never>(): Semigroup<Array<A>> {
  return { concat: (x, y) => x.concat(y) }
}

이전에 나온 Ord 를 이용해 maxminSemigroup 인스턴스를 만들 수도 있다.

import * as N from "fp-ts/number";
import { min, max } from "fp-ts/Semigroup";

/** Takes the minimum of two values */
const semigroupMin: Semigroup<number> = min(N.Ord);

/** Takes the maximum of two values  */
const semigroupMax: Semigroup<number> = max(N.Ord);

semigroupMin.concat(2, 1); // 1
semigroupMax.concat(2, 1); // 2

struct 를 이용해 복잡한 타입도 간단히 Semigroup 인스턴스를 만들 수 있다.

import { struct } from "fp-ts/Semigroup";

interface Point {
  x: number;
  y: number;
}
interface Vector {
  from: Point;
  to: Point;
}

const semigroupPoint: Semigroup<Point> = struct({
  x: semigroupSum,
  y: semigroupSum,
});
const semigroupVector: Semigroup<Vector> = struct({
  from: semigroupPoint,
  to: semigroupPoint,
});

심지어 function 으로도 Semigroup 인스턴스를 만들 수 있다.


import { getSemigroup } from "fp-ts/function";
import { SemigroupAll } from "fp-ts/boolean";

/** `semigroupAll` is the boolean semigroup under conjunction */
const semigroupPredicate: Semigroup<(p: Point) => boolean> =
  getSemigroup(SemigroupAll)<Point>();

const isPositiveX = (p: Point): boolean => p.x >= 0;
const isPositiveY = (p: Point): boolean => p.y >= 0;
const isPositiveXY = semigroupPredicate.concat(isPositiveX, isPositiveY);

Semigroup 사용

concatAll 이라는 조합자를 Semigroup 인스턴스에 사용하면 해당 인스턴스의 타입의 배열의 모든 인자를 concat 하는 함수를 간단히 만들 수 있다.
Array.prototype.reduce 와 비슷하다.

import { concatAll } from "fp-ts/Semigroup";
// const concatAll = <A>(S: Semigroup<A>) => (startWith: A) => (as: ReadonlyArray<A>) => as.reduce(S.concat, startWith);

const sum = concatAll(semigroupSum)(0);
const product = concatAll(semigroupProduct)(1);

console.log(sum([1, 2, 3, 4])); // 10
console.log(product([1, 2, 3, 4])); // 24

fp-ts 에는 Option 이라는 타입이 있다.
해당 타입은 nullish 한 값을 다루는 타입이다.
예를 들어 웹에서 정보를 요청하거나 사용자의 입력이나 파일을 읽었을 때를 가정하자.
원하는 값을 얻어냈을 수도 있고, 없을 수도 있다.
이런 경우를 상정하지 않고 코드를 작성했다가는 런타임 에러가 신나게 터질 것이다.
이를 안전하게 다루기 위해 사용하는 것이 Option 이다.

OptionSomeNone 이라는 두 가지 상태로 나뉜다.
Some 은 값이 있음을 나타내고, None 은 값이 없음을 나타낸다.
Option 만약 매번 모든 값이 Some 임을 검사를 해야한다면 너무 귀찮을 것이다. 여기서 Apply 라는 타입 클래스를 사용하는데, 이에 대해서는 나중에 다루기로 하자. 아무튼 Apply 타입 클래스의 Option 인스턴스를 이용하면 이를 간단히 해결할 수 있다.

import { some, none, Apply } from "fp-ts/Option";
import { getApplySemigroup } from "fp-ts/Apply";

const concatIfSome = getApplySemigroup(Apply)
const sumIfSome = concatIfSome(semigroupSum);

const some1 = some(1);
const some2 = some(2);
const sumNoneSome1 = sumIfSome.concat(none, some1);
const sumNoneSome2 = sumIfSome.concat(none, some2);
const sumSome1Some2 = sumIfSome.concat(some1, some2);

console.log(
  sumNoneSome1, // None
  sumNoneSome2, // None
  sumSome1Some2 // Some(3)
);

Apply

원문 블로그에서는 Apply 를 다루지 않았지만, Apply 를 다루고 넘어가자.

Functor 빠르게 훑기

그런데 이를 위해서는 Functor 라는 개념을 알아야 한다.
너무 걱정 말자. 사실은 우리가 다 알고 있는 것을 Functor 라고 부를 뿐이다. Functor 는 추후 본격적으로 다룰 것이기 때문에 간단히만 설명하자면, Functormap 이라는 함수를 가진 타입 클래스이다.
정말 별거 없다. 수학적으로 다루지만 않는다면 말이다.
Functor 는 어떤 값을 담고 있는 컨테이너를 다루기 위하여 만들어졌다. 그러니 이후로 컨테이너라고 하면 Functor 인스턴스라고 생각하면 된다.
map 함수는 두 개의 인자를 받는다.
첫번째 인자로 변환할 값이 담긴 컨테이너를 받는다. 두번째 인자로 컨테이너에 담긴 값을 다른 값으로 변환하는 함수를 인자로 받는다.
map 함수는 첫번째 인자에 담긴 값을 변환한 후, 변환된 값을 담은 컨테이너를 반환한다.
다음은 Functor 의 인터페이스를 간단하게 축약해놓은 것이다.

interface Functor<F> {
  map<A, B>(arg0: F<A>, arg1: (x: A) => B): Functor<B>;
}

우리에게 아주 익숙한 예시로는 Array.prototype.map 이 있다.

const nums = [1, 2, 3, 4, 5];
const strs = nums.map((n) => n.toString());

Array 는 보다시피 어떤 값을 담고 있다. Array 는 자신이 담고 있는 값을 변환한 후, 변환된 값을 담은 Array 를 반환하는 함수 map 을 가지고 있다.
따라서 ArrayFunctor 의 인스턴스이다.

아까 다룬 OptionFunctor 의 인스턴스이다.

import * as O from "fp-ts/Option";
O.Functor.map(O.some(1), (n) => n * 2); // some(2)
O.Functor.map(O.none, (n) => n * 2); // none

Option 은 어떤 값을 담고 있다.
Option은 자신이 담고 있는 값을 변환한 후, 변환된 값을 담은 Option 를 반환하는 함수 map 을 가지고 있다.
따라서 OptionFunctor 의 인스턴스이다.

Apply 정의

ApplyFunctor 를 확장한 타입 클래스이다.
map 함수에 추가로 ap 라는 함수를 가지고 있으면 되는데, 이 둘은 서로 비슷하다.
map 함수인데 인자 순서가 다르고 함수 또한 컨테이너1에 담겨있을 뿐이다.
좀더 자세히 설명하자면, ap 함수는 두 개의 인자를 받는다.
첫번째 인자는 변환할 함수를 담고 있는 컨테이너이다.
두번째 인자는 변환할 값이 담긴 컨테이너이다.
ap 함수는 첫번째 인자에 담긴 함수를 두번째 인자에 담긴 값에 적용한 후, 적용된 값을 담은 컨테이너를 반환한다.

다음은 Apply 의 인터페이스를 간단하게 축약해놓은 것이다.

interface Apply<F> extends Functor<F> {
  ap<A, B>(arg0: F<(x: A) => B>, arg1: F<A>): Functor<B>;
}

Apply 구현

apmap 함수와 비슷한 것에서 눈치챌 수 있다시피 Functor 는 손쉽게 Apply 로 확장할 수 있다.
Functor 에서 들었던 Array, Option 은 모두 Apply 의 인스턴스로 구현되어 있다.

import * as A from "fp-ts/Array";
import * as O from "fp-ts/Option";

const double = (n: number) => n * 2;

console.log(A.Functor.map([1, 2, 3], double)); // [2, 4, 6]
console.log(A.Apply.ap([double], [1, 2, 3])); // [2, 4, 6]

console.log(O.Functor.map(O.some(1), double)); // some(2)
console.log(O.Apply.ap(O.some(double), O.some(1))); // some(2)

위 예시를 보면 map 함수와 ap 함수가 거의 같은 일을 하고 있다는 것을 알 수 있다.

Apply 예시

Semigroup 에서 마지막으로 들었던 예시를 다시 보자.
만약 OptionApply 인스턴스가 구현되지 않았다면 어떻게 구현해야 했을까?
Option<number> 를 더하는 Semigroup 인스턴스부터 새로 구현해야 했을 것이다.

import {some, none, Option, isSome} from "fp-ts/Option";

const semigroupOptionSum: Semigroup<Option<number>> = {
  concat: (x, y) =>
    isSome(x) && isSome(y)
    ? some(semigroupSum.concat(x.value, y.value))
    : none,
};

해당 인스턴스의 concat 함수는 두 인자가 some인지, 값을 가지고 있는지 부터 검사해야 한다.
둘다 some 이라면, 두 값을 추출해내야 한다.
그리고 두 값을 semigroupSum.concat 에 넣어서 반환 받은 값을 some 으로 감싸서 반환한다.
만약 하나라도 none 이라면 none 을 반환한다.
간단한 두 수 더하기니까 그나마 이정도에 끝났지만, 만약 더 복잡한 연산이라면 더 복잡해지고 구현하기도 힘들 것이다.
하지만 Apply 를 사용하면 이를 간단히 해결할 수 있다.

import { Apply } from "fp-ts/Option";

const getSemigroupOption = getApplySemigroup(Apply);
const semigroupOptionSum = getSemigroupOption(semigroupSum);

getApplySemigroup 는 컨테이너(Apply 의 인스턴스)를 인자로 받아 또다른 함수를 반환한다.
반환 받은 함수는 Semigroup 의 인스턴스를 인자로 받는데, 이 인스턴스는 컨테이너에 담긴 값의 Semigroup 인스턴스이다.
그리고 이를 통해 반환 받은 함수는 Semigroup 의 인스턴스이고, 이는 컨테이너의 내부 값을 변환하는 함수이다.

듣기에는 뭔가 어려워 보인다.
하지만 풀어써서 어려워 보일 뿐 코드로 단 두 줄에 완료되는 매우 간단한 과정이다.

Apply를 사용함으로써 얻는 이점은 Array 를 통해 확인할 수 있다. 만약 Apply 가 없다면 다음과 같은 과정으로 ArraySemigroup 인스턴스를 만들어야 했을 것이다.

const semigroupArraySum: Semigroup<Array<number>> = {
  concat: (x, y) =>
    x.map((a) => y.map((b) => semigroupSum.concat(a, b)))
      .reduce((acc, a) => acc.concat(a), []),
};

과정에 대한 설명은 생략하겠다.
하지만 보다시피 이중 반복문이 들어가고 reduce 를 사용하는 등 구조가 복잡해진다.
Apply 를 사용하지 않은 semigroupOptionSum 와 전혀 다른 구조의 코드이다.
즉 컨테이너가 바뀌면 매번 코드를 새로 짜야한다.

그에 비해 Apply 를 사용하면 다음과 같이 구현할 수 있다.

import { Apply } from "fp-ts/Array";

const getSemigroupArray = getApplySemigroup(Apply);
const semigroupArraySum = getSemigroupArray(semigroupSum);

자세히 보지 않으면 Apply 를 통해 구현한 semigroupOptionSum 과 구분하기 어려울 정도로 코드가 똑같다.
Apply 를 이용하면 이렇게 코드를 손쉽게 재사용할 수 있다.
또, 함수형 패러다임의 놀라운 힘을 보여주는 예시이기도 하다.

Monoid

MonoidSemigroup 을 확장한 타입 클래스이다.
Monoidconcat 의 항등원을 가지고 있다.
항등원이란 다른 어떤 값과 같이 계산해도 항상 같은恒等元 값으로 되돌려주는 값이다.
예를 들어 덧셈의 항등원은 $0$, 곱셈의 항등원은 $1$ 이다.
어떤 수에 0을 더하든, $0+x$ 든 $x+0$ 이든 $x$ 라는 값이 나온다.
마찬 가지로 어떤 수에 1을 곱하든, $1 \times x$ 든 $x \times 1$ 이든 $x$ 라는 값이 나온다.
문자열끼리 결합하는 연산의 항등원은 ""(빈 문자열)이다.
"" + "hello" === "hello" + "" === "hello" 이다.
fp-ts 에서는 항등원을 empty 라고 부른다.

interface Monoid<A> extends Semigroup<A> {
  readonly empty: A
}

Monoid 구현

어려운 내용은 아니니 바로 예시를 보자.

/** number 덧셈에서의 Monoid */
const monoidSum: Monoid<number> = {
  concat: (x, y) => x + y,
  empty: 0
}

/** number 곱셈에서의 Monoid */
const monoidProduct: Monoid<number> = {
  concat: (x, y) => x * y,
  empty: 1
}

/** string 결합에서의 Monoid */
const monoidString: Monoid<string> = {
  concat: (x, y) => x + y,
  empty: ''
}

/** boolean 연언(논리곱)에서의 Monoid */
const monoidAll: Monoid<boolean> = {
  concat: (x, y) => x && y,
  empty: true
}

/** boolean 선언(논리합)에서의 Monoid */
const monoidAny: Monoid<boolean> = {
  concat: (x, y) => x || y,
  empty: false
}

하지만 다음과 같은 타입은 Monoid 가 될 수 없다.

const semigroupSpace: Semigroup<string> = {
  concat: (x, y) => x + ' ' + y
}

빈 문자열이든 뭐든 인자에 들어간 값과 다른 값이 나올 수 밖에 없다.
따라서 항등원이 존재하지 않기 때문에 Monoid 가 될 수 없다.

복잡한 구조도 Eq, Semigroup 에서 처럼 struct 조합자를 이용해 간단히 만들 수 있다.

import { Monoid, struct } from "fp-ts/Monoid";
import * as N from "fp-ts/number";

interface Point {
  x: number;
  y: number;
}
const monoidPoint: Monoid<Point> = struct({
  x: N.MonoidSum,
  y: N.MonoidSum,
});

interface Vector {
  from: Point;
  to: Point;
}
const monoidVector: Monoid<Vector> = struct({
  from: monoidPoint,
  to: monoidPoint,
});

Monoid 사용

Semigroup 과 마찬가지로 concatAll 을 사용할 수 있다.
더 좋은 점은 startWith 를 따로 지정해주지 않아도 항등원을 알아서 가져다 사용한다.

import * as S from "fp-ts/string";
import * as B from "fp-ts/boolean";

console.log(
  concatAll(N.MonoidSum)([1, 2, 3, 4]), // 10
  concatAll(N.MonoidProduct)([1, 2, 3, 4]), // 24
  concatAll(S.Monoid)(["a", "b", "c"]), // 'abc'
  concatAll(B.MonoidAll)([true, false, true]), // false
  concatAll(B.MonoidAny)([true, false, true]) // true
);

SemigroupgetApplySemigroup 처럼 Applicative 로 부터 Monoid 를 만드는 getApplicativeMonoid 조합자도 있다.
Applicative 에 대해서는 추후에 다룰테니 어떤 느낌인지 간단히만 보자.

import * as O from "fp-ts/Option";
import { getApplicativeMonoid } from "fp-ts/Applicative";

const some1 = O.some(1);
const some2 = O.some(2);

const getMonoidOption = getApplicativeMonoid(O.Applicative);
const sumIfAllSome = getMonoidOption(N.MonoidSum);

console.log(
  sumIfAllSome.concat(some1, some2), // some(3)
  sumIfAllSome.concat(some1, O.none), // none
  sumIfAllSome.concat(O.none, some2), // none
  sumIfAllSome.concat(O.none, O.none) // none
);

물론 둘다 Some 일 때만 Some 을 반환하는 게 안전할 것이다.
하지만 둘 중 하나만 Some 이라면 해당 값을 그대로 반환해줬으면 하는 경우도 있을 것이다.
그럴 때는 getMonoid 를 사용하면 된다.

const sumIfAnySome = O.getMonoid(N.MonoidSum);

console.log(
  sumIfAnySome.concat(some1, some2), // some(3)
  sumIfAnySome.concat(some1, O.none), // some(1)
  sumIfAnySome.concat(O.none, some2), // some(2)
  sumIfAnySome.concat(O.none, O.none) // none
);

만약 첫, 혹은 마지막 Some 을 반환하고 싶다면 각각 Semigroupfirst, last 조합자를 사용할 수도 있다.

import { first, last } from "fp-ts/Semigroup";

const getFirsSome = O.getMonoid<number>(first());
console.log(
  getFirsSome.concat(some1, some2), // some(1)
  getFirsSome.concat(some1, O.none), // some(1)
  getFirsSome.concat(O.none, some2), // some(2)
  getFirsSome.concat(O.none, O.none) // none
);

const getLastSome = O.getMonoid<number>(last());
console.log(
  getLastSome.concat(some1, some2), // some(2)
  getLastSome.concat(some1, O.none), // some(1)
  getLastSome.concat(O.none, some2), // some(2)
  getLastSome.concat(O.none, O.none) // none
);

원문 블로그는 마지막으로 다음과 같은 예시를 남겼다.

/** VSCode 설정 */
interface Settings {
  /** 글씨체 설정 */
  fontFamily: Option<string>;
  /** 글씨 크기 설정 */
  fontSize: Option<number>;
  /** 미니맵에서 보여줄 최대 열 수 */
  maxColumn: Option<number>;
}

const getLastMonoid = <A>() => O.getMonoid<A>(last());
const monoidSettings: Monoid<Settings> = struct({
  fontFamily: getLastMonoid<string>(),
  fontSize: getLastMonoid<number>(),
  maxColumn: getLastMonoid<number>(),
});

const workspaceSettings: Settings = {
  fontFamily: some("Courier"),
  fontSize: none,
  maxColumn: some(80),
};

const userSettings: Settings = {
  fontFamily: some("Fira Code"),
  fontSize: some(12),
  maxColumn: none,
};

/** userSettings overrides workspaceSettings */
console.log(monoidSettings.concat(workspaceSettings, userSettings));
/*
{ fontFamily: some("Fira Code"),
  fontSize: some(12),
  maxColumn: some(80) }
*/

범주론

원문 블로그에서는 이후 범주론에 대해 얕게 다루는데 크게 중요한 내용은 없는 것 같아서 생략한다.
원문 블로그는 이 글 전체를 한 줄로 요약했다.

functional programming is all about composition 함수형 프로그래밍은 합성이 전부다.

Functor

함수의 합성

TS에서 임의의 두 함수를 합성하기 위한 함수 compose 를 생각해보자.

function compose<A, B, C>(g: (b: B) => C, f: (a: A) => B): (a: A) => C {
  return (a: A) => g(f(a));
};

g 의 인자와 f 의 반환 타입이 같아야 한다.
‘뭐 이렇게 당연한 소리를…’ 싶은 말이다.
하지만 만약 그 둘이 다르다면 어떨까?
일반적인 g: (c: C) => D, f: (a: A) => B 라면 어떻게 될까? 물론 때에 따라 방법이 다를 것이다.
예를 들어 numberstring 으로 변환한다면 그대로 문자열로 변환할 수도 있고, 이진법으로 변환해서 변환할 수도 있고,… 아무튼 여러가지 방법이 있을 것이다.

그러나 FP에서 찾고자 하는 것은 좀더 일반적인 방법론이다.
즉 프로그램을 만드는 방법을 일반화 하고자 하는 것이다.
여기서 일반이라는 말은 일반인의 일반처럼 튀지 않는, 통상적, 전형적이라는 일반적이라는 게 아니라 일반 상대성이론의 일반처럼 무엇이든, 총체적인, 모든 것을 포괄하는 것을 말한다. 즉 프로그램의 구성 방식을 일반화하는 것이 FP의 최종 목적이라 할 수 있다.

map

모든 경우를 한 번에 해결할 수는 없으니 차근차근 생각해보자.
예를 들어 g: (c: C) => D, f: (a: A) => B 에서 B = F<C> 인 경우를 가정해보자.
이때 FArray, Option 같은 임의의 타입 구조체이다.
이 둘을 어떻게 합성시키는 것이 자연스러울까?
이 역시 쉽지 않으니 차근차근 해결해보자.

첫번째로 Array 를 생각해보자.
g: (c: C) => D, f: (a: A) => Array<C> 인 경우를 생각해보자.
만약 f 에서 [1, 2, 3] 이 반환됐다면, 여기에 g: x => x + 1 라는 함수를 어떻게 적용시키는게 자연스러울까?
보통이라면 [1, 2, 3] 를 각 원소에 g 를 적용시키는 것이 자연스럽다고 생각할 것이다.
즉, fg 를 합성하는 과정에서 다음과 같은 과정을 거치는 것이 자연스러울 것이다.

function map<A, C, D>(g: (c: C) => D, f: (a: A) => Array<C>): (a: A) => Array<D> {
  return (a: A) => f(a).map(g);
};

이번엔 Option인 경우, g: (c: C) => D, f: (a: A) => Option<C> 을 생각해보자.
보통이라면 반환된 Option 에 실제 값이 있다면 그 값을 g 에 적용시키는 것이 자연스럽다고 생각할 것이다.
즉, fg 를 합성하는 과정에서 다음과 같은 과정을 거치는 것이 자연스러울 것이다.

function map<A, C, D>(g: (c: C) => D, f: (a: A) => Option<C>): (a: A) => Option<D> {
  return isNone(f(a)) ? none : some(g(f(a).value));
};

마지막으로 Task(Promise 등을 일반화한 fp-ts 의 타입) 인 경우, g: (c: C) => D, f: (a: A) => Task<C> 인 경우를 생각해보자.
보통이라면 반환된 Taskthen 함수에 g 를 넘기는 것이 자연스럽다고 생각할 것이다.
즉, fg 를 합성하는 과정에서 다음과 같은 과정을 거치는 것이 자연스러울 것이다.

function map<A, C, D>(g: (b: C) => D): (f: A) => Task<C> {
  return f => () => f().then(g)
}

즉, 이런 식으로 임의의 구조체 타입이 들어온다면 그 구조체를 그 속에 있는 값에 적용시키는 것이 자연스러울 것이다.
일반적인 FP 에서는 이 함수를 map 이라 부르며, 이 함수가 구현되어 있다면 Functor 인스턴스라고 한다.

HKT

먼저 fp-ts 에서 Functor 를 구현하기 위해서는 조금 복잡한 과정을 거쳐야 한다.
이는 TS가 고차타입(HKT, Higher-Kinded Type) 을 지원하지 않기 때문이다.
내년이면 해당 이슈가 열린 지 10년이다… 언제쯤 해줄런지… ㅠㅠ
아무튼 HKT 는 다음과 같은 경우 사용한다.

interface Type<F> {
  attribute: F<number>;
}

이와 같은 코드를 작성하면 'F' 형식이 제네릭이 아닙니다.ts(2315) 라는 에러를 볼 수 있을 것이다.
TS는 아직 제네럴 타입이 제네릭임을 인식하지 못하기 때문이다.
이를 해결하기 위해 HKT 라는 타입을 임시방편으로 사용한다.

import { HKT } from 'fp-ts/HKT'

interface Type<F> {
 attribute: HKT<F, number>
}

HKT<F, number>F<number> 와 같은 의미이다.

Functor 예시

원문 블로그는 다음과 같은 예시를 들었다.

import { Functor1 } from 'fp-ts/Functor'

export const URI = 'Response'

export type URI = typeof URI

declare module 'fp-ts/HKT' {
  interface URItoKind<A> {
    Response: Response<A>
  }
}

export interface Response<A> {
  url: string
  status: number
  headers: Record<string, string>
  body: A
}

function map<A, B>(fa: Response<A>, f: (a: A) => B): Response<B> {
  return { ...fa, body: f(fa.body) }
}

// `Response` 를 위한 `Functor` 인스턴스
export const functorResponse: Functor1<URI> = {
  URI,
  map
}

먼저 Functor 안스턴스로 만들 타입의 이름을 담은 URI 라는 값과 동명의 해당 값의 리터럴 타입이 필요하다.
그리고 fp-ts/HKTURItoKind 라는 인터페이스에 해당 타입을 선언해줘야 고차 타입처럼 사용할 수 있한다.
참고로 fp-ts 에서는 URItoKindURItoKind2, URItoKind3 처럼 Functor 의 타입 개수에 따라 다양한 인터페이스를 제공한다. MS님 이슈 좀 해결해주세요

그리고 Functor 인스턴스로 만들 타입을 정의한다.
예시에서는 응답을 처리하기 위한 Response 라는 타입을 만들었다.
이를 위해 요청을 보내고 응답을 받으려는 url, 응답 상태 코드 status, 그리고 응답의 headersbody 를 담을 수 있도록 했다.
그 중에서도 body 를 처리하기 위한 Functor 이기 때문에 body 의 타입을 제네럴 타입으로 두었다.

그리고 map 함수를 구현해준다.
첫 번째 인자로 Response 를, 두번째 인자로 body를 처리하기 위한 함수를 받는다.
그리고 다른 값들은 그대로 두고 body 만 인자로 받은 함수를 적용시킨 값을 넣은 새로운 Response 를 반환한다.
최종적으로 URImap 을 넣은 Functor 인스턴스를 구현한다.

Applicative

커링

직전에는 map 함수를 이용해 g: (c: C) => D, f: (a: A) => F<C> 를 합성하는 방법을 알아봤다.
하지만 이 때 g 는 하나의 인자 밖에 받지 못한다.
이럴 때는 어떻게 해야할까?
물론 여러가지 방법이 있을 수 있지만, 원문에서는 해결 방법으로 커링을 제시한다.

커링은 여러 인자를 받는 함수를 하나의 인자만 받는 함수들의 연속으로 바꾸는 것이다.
예를 들어 g: (b: B, c: C) => D 라는 함수가 있다면, g: (b: B) => (c: C) => D 라는 함수로 바꿀 수 있다.
이제 map 함수를 이용하면 g: (b: B) => (c: C) => D, f: (a: A) => F<C> 를 합성할 수 있을 것 같다.
하지만 map(g, f(a))F<(c: C) => D> 를 반환한다.
그렇다면 이 컨테이너 속 함수를 어떻게 사용해야할까?

Apply 다시 보기

이를 해결하기 위해 만들어진 것이 Applyap 이다.
ap 는 컨테이너 속 함수에 컨테이너 속 값을 적용시키는 함수이다.

interface Apply<F> extends Functor<F> {
  ap: <C, D>(fcd: F<(c: C) => D>, fc: F<C>) => F<D>
}

Applicative 구현

여기서 더 나아가 임의의 컨테이너 타입에 주어진 값을 담아주는 함수가 있으면 편리할 것이다.
이런 함수를 of 라고 하고, Apply 아면서 이 함수를 가지고 있는 타입 클래스를 Applicative 이라고 한다.2

interface Applicative<F> extends Apply<F> {
  of: <A>(a: A) => F<A>
}

간단한 예시와 함께 Applicative 인스턴스를 을 구현해보자.

// Array
import { flatten } from 'fp-ts/Array'

const applicativeArray = {
  map: <A, B>(fa: Array<A>, f: (a: A) => B): Array<B> => fa.map(f),
  of: <A>(a: A): Array<A> => [a],
  ap: <A, B>(fab: Array<(a: A) => B>, fa: Array<A>): Array<B> =>
    flatten(fab.map(f => fa.map(f)))
}

// Option
import { Option, some, none, isNone } from 'fp-ts/Option'

const applicativeOption = {
  map: <A, B>(fa: Option<A>, f: (a: A) => B): Option<B> =>
    isNone(fa) ? none : some(f(fa.value)),
  of: <A>(a: A): Option<A> => some(a),
  ap: <A, B>(fab: Option<(a: A) => B>, fa: Option<A>): Option<B> =>
    isNone(fab) ? none : applicativeOption.map(fa, fab.value)
}

// Task
import { Task } from 'fp-ts/Task'

const applicativeTask = {
  map: <A, B>(fa: Task<A>, f: (a: A) => B): Task<B> => () => fa().then(f),
  of: <A>(a: A): Task<A> => () => Promise.resolve(a),
  ap: <A, B>(fab: Task<(a: A) => B>, fa: Task<A>): Task<B> => () =>
    Promise.all([fab(), fa()]).then(([f, a]) => f(a))
}

여기서 flatten 은 여러 겹의 배열을 하나의 배열로 펴주는 함수로, Array.prototype.flat 과 같은 역할을 하는 함수이다.

export interface Pointed<F> {
  readonly of: <A>(a: A) => HKT<F, A>
}
export interface Applicative<F> extends Apply<F>, Pointed<F> {}

Monad

flatMap

위에서 잠깐 사용한 flatten 함수를 다시 보자.
SNS 에서 어떤 사용자의 구독자의 구독자를 구하는 함수를 생각해보자.

import * as A from "fp-ts/Array";

interface User {
  followers: Array<User>;
}

const getFollowers = (user: User) => user.followers;
const getFollowersOfFollowers = (user: User) =>
  A.map(getFollowers)(getFollowers(user));

보다시피 getFollowersOfFollowersArray<Array<User>> 를 반환한다.
하지만 중첩된 배열 보다는 Array<User> 형식이 더 사용하기 편할 것이다.
바로 이럴 때 사용하는 것이 flatten 함수이다.

const getFollowersOfFollowers = (user: User) =>
  A.flatten(A.map(getFollowers)(getFollowers(user)));

이번엔 Option 을 사용하면서 다음과 같은 경우를 생각해보자.

import * as O from "fp-ts/Option";
import { head } from "fp-ts/Array";

const inverse = (n: number) => (n === 0 ? O.none : O.some(1 / n));
const inverseHead = (arr: Array<number>) => O.map(inverse)(head(arr));

'fp-ts/Array'.head 함수는 배열이 비어있지 않을 경우 첫번째 원소를 some 으로 감싸서 반환하고, 비어있는 경우는 none 을 반환한다.
inverse 는 0이 아닌 경우에만 역수를 구해주는 함수이다.
따라서 inverseHeadOption<Option<number>> 를 반환할 것이다.
중첩된 Option 을 풀어내기 위해 다음과 같은 flatten 함수를 작성해보자.

const flatten = <A>(option: O.Option<O.Option<A>>) =>
  O.isSome(option) ? option.value : O.none;

사실 이 함수는 fp-ts/Option 에 이미 구현이 되어있다.
이제 inverseHead 를 다음과 같이 수정할 수 있다.

const inverseHead = (arr: Array<number>) =>
  O.flatten(O.map(inverse)(head(arr)));

위에서 정의한 getFollowersOfFollowersinverseHead 를 비교해보자.
둘 다 F.flatten(F.map(A => B)(F<A>)) 의 형태를 띄고 있다.
이런 형태의 함수를 FP 에서는 보통 flatMap 이라고 한다.
fp-ts 에서는 chain 이라는 이름으로 사용한다.

const getFollowersOfFollowers = (user: User) =>
  A.chain(getFollowers)(getFollowers(user));
const inverseHead = (arr: Array<number>) =>
  O.chain(inverse)(head(arr));

Apply 를 확장시켜 chain 을 가진 타입 클래스를 Chain 이라고 한다.

export interface Chain<F> extends Apply<F> {
  readonly chain: <A, B>(fa: F<A>, f: (a: A) => F<B>) => F<B>
}

Monad

fp-tsMonadApplicativeChain 을 합친 타입 클래스이다.

export interface Monad<F> extends Applicative<F>, Chain<F> {}

이를 풀어 쓰면 다음과 같다.

export interface Monad<F> extends {
  readonly map: <A, B>(fa: F<A>, f: (a: A) => B) => F<B>; // Functor
  readonly ap: <A, B>(fab: F<(a: A) => B>, fa: F<A>) => F<B>; // Apply
  readonly of: <A>(a: A) => F<A>; // Applicative
  readonly chain: <A, B>(fa: F<A>, f: (a: A) => F<B>) => F<B>; // Chain
}

Monad 의 함수들은 다음과 같은 법칙을 따른다.

compose(chain(of), f) = f
compose(chain(f), of) = f
compose(chain(h), compose(chain(g), f)) = compose(chain(compose(chain(h), g)), f)
  1. 여기서부터 컨테이너는 Apply 인스턴스라고 생각하자. 이후로도 컨테이너라는 값이 맥락에 따라 다른 의미로 사용될 수 있다. 하지만 결국 그 맥락에서 설명하는 타입 클래스의 인스턴스를 말하는 것이니 너무 걱정하진 말자. ↩︎

  2. 좀더 정확히 말하자면, fp-ts 내부적으로는 of 함수를 가지고 있는 Pointed 타입 클래스가 있고, ApplyPointed 를 합친 타입 클래스를 Applicative 라 한다. ↩︎


© 2021. All rights reserved.

Powered by Hydejack v7.5.2