fp-ts로 Typescript 함수형 프로그래밍 시작하기 3 (Semigroup)

본 포스트는 fp-ts 공식 문서의 Learning Resources에 있는 Getting Started에서 소개하는 문서들을 번역하며 학습한 문서입니다. 원본 문서는 링크에서 확인할 수 있으며 작성한 코드들은 여기에서 확인할 수 있습니다.

fp-ts 시작하기 (Semigroup)

Semigroup은 함수형 프로그래밍의 근본적인 추상화이므로 이 글의 내용이 평소보다 길어질 것입니다.

일반적인 정의

SemigroupA가 비어 있지 않은 집합이고 *A에 대한 이진 연관 연산인 쌍 (A, *)입니다. 즉, A의 두 요소를 입력으로 받고 A의 요소를 출력으로 반환하는 함수입니다.

*: (x: A, Y: A) => A

연관성은 아래의 동식이 모든 A에 대한 x, y, z에 대해 유지됨을 의미한다.

(x * y) * z = x * (y * z)

연관성은 단순히 표현식을 괄호로 묶는 것에 대해 걱정할 필요가 없으며 x * y * z를 쓸 수 있다는 것을 의미합니다.

Semigroup은 병렬화 가능한 연산의 본질을 포착합니다.

Semigroup의 예시는 아래와 같이 많이 있습니다.

  • (number, *): 여기에서 * 연산은 일반적인 숫자의 곱입니다.
  • (string, +): 여기에서 + 연산은 일반적인 문자열 연결입니다.
  • (boolean, &&): 여기에서 && 연산은 일반적인 논리곱입니다.

이 외에도 많은 예시가 있습니다.

타입 클래스 정의

fp-ts에서 fp-ts/lib/Semigroup모듈에 포함된 타입 클래스 Semigroup은 TypeScript의 interface로 구현됩니다. 여기서 작업 *concat으로 명명됩니다.

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

Semigroup은 아래의 규칙이 유지되어야 합니다.

  1. 연관성(Associativity): A의 모든 x, y, z에 대하여 concat(concat(x, y), z) = concat(x, concat(y, z))를 만족한다.

concat이라는 이름은 배열에 대해 특히 의미가 있지만, 인스턴스를 구현하는 맥락 및 타입 A에 따라 Semigroup 연산은 다른 의미로 해석될 수 있습니다.

  • 연쇄(concatenation)
  • 병합(merging)
  • 퓨전(fusion)
  • 선택(selection)
  • 부가(addition)
  • 치환(substitution)

이 외에도 다른 많은 의미로 해석될 수 있습니다.

인스턴스

아래의 semigroupProduct 인스턴스가 (number, *)을 구현하는 방법입니다.

/** number 타입의 곱셈 `Semigroup` */
export const semigroupProduct: Semigroup<number> = {
  concat: (x, y) => x * y,
};

동일한 타입에 대해 서로 다른 Semigroup 인스턴스를 정의할 수 있습니다. 아래는 semigroupProductSum 인스턴스로 (number, +)의 구현입니다. 여기서 +는 일반적인 number 타입의 더하기 연산입니다.

/** number 타입의 덧셈 `Semigroup` */
const semigroupSum: Semigroup<number> = {
  concat: (x, y) => x + y,
};

다른 예시로 string 타입을 사용할 수 있습니다.

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

인스턴스를 찾을 수 없습니다!

타입 A가 주어졌을 때 A에서 연관 연산을 찾을 수 없으면 어떻게 할 수 있을까요? 아래의 구성을 사용하여 모든 타입에 대해 (사소한) Semigroup 인스턴스를 만들 수 있습니다.

/** 항상 첫 번째 인자를 반환한다. */
function getFirstSemigroup<A = never>(): Semigroup<A> {
  return { concat: (x, y) => x };
}

/** 항상 두 번째 인자를 반환한다. */
function getLastSemigroup<A = never>(): Semigroup<A> {
  return { concat: (x, y) => y };
}

또 다른 기술은 A자유 Semigroup이라고하는 Array<A> (*)에 대한 Semigroup 인스턴스를 정의하는 것입니다.

function getArraySemigroup<A = never>(): Semigroup<Array<A>> {
  return { concat: (x, y) => x.concat(y) };
}

그리고 A의 요소를 Array<A>의 단일 요소에 매핑합니다.

function of<A>(a: A): Array<A> {
  return [a];
}

(*)는 엄밀히 말하면 A의 비어 있지 않은 배열에 대한 Semigroup 인스턴스입니다.

참고: concat은 배열의 메서드로, Semigroup 연산의 이름에 대한 초기 선택을 설명합니다.

A의 자유 Semigroup은 요소가 A 요소의 비어있지 않은 유한 시퀀스일 수 있는 Semigroup입니다.

Ord로 파생시키기

타입 A에 대한 Semigroup 인스턴스를 만드는 또 다른 방법이 있습니다. A에 대한 Ord 인스턴스가 이미있는 경우 이를 Semigroup으로 “변환”할 수 있습니다.

아래 코드는 실제로 가능한 두 Semigroup입니다.

원문에서는 getMeetSemigroup, getJoinSemigroup, ordNumber를 사용하라고 작성되어 있지만, 최신 버전의 fp-ts에서는 deprecated 되어 있으며 min, max, Ord를 사용하면 됩니다.

import { Ord } from 'fp-ts/lib/number';
import { max, min } from 'fp-ts/lib/Semigroup';

/** 2개의 값 중 작은 값을 반환한다.  */
const semigroupMin: Semigroup<number> = min(Ord);

/** 2개의 값 중 큰 값을 반환한다.  */
const semigroupMax: Semigroup<number> = max(Ord);

작성한 semigroupMin, semigroupMax 인터페이스는 아래와 같이 테스트할 수 있습니다.

  • semigroupMin 인터페이스를 테스트하는 코드
describe('Semigroup 인터페이스를 구현한 semigroupMin 인스턴스 테스트', () => {
  it('semigroupMin 인스턴스 concat 함수 테스트', () => {
    expect(semigroupMin.concat(2, 1)).toBe(1);
  });
});
  • semigroupMax 인터페이스를 테스트하는 코드
describe('Semigroup 인터페이스를 구현한 semigroupMax 인스턴스 테스트', () => {
  it('semigroupMax 인스턴스 concat 함수 테스트', () => {
    expect(semigroupMax.concat(2, 1)).toBe(2);
  });
});

semigroupMin 인터페이스의 concat 함수는 인자로 받은 두 개의 값 중 작은 값을 반환하는지 확인하며 semigroupMax 인터페이싀 concat 함수는 인자로 받은 두 개의 값 중 큰 값을 반환하는지 확인합니다.

좀 더 복잡한 타입에 대한 Semigroup 인스턴스를 작성해 보겠습니다.

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

const semigroupPoint: Semigroup<Point> = {
  concat: (p1, p2) => ({
    x: semigroupSum.concat(p1.x, p2.x),
    y: semigroupSum.concat(p1.y, p2.y),
  }),
};

위 코드의 대부분 자주 사용하는 구문입니다. 좋은 소식은 각 필드에 대해 Semigroup 인스턴스를 제공 할 수 있다면 Point와 같은 구조체에 대해 Semigroup 인스턴스를 만들 수 있다는 것입니다.

실제로 fp-ts/lib/Semigroup 모듈은 getStructSemigroup 콤비네이터를 지원합니다.

원문에서는 getStructSemigroup를 사용하라고 작성되어 있지만, 최신 버전의 fp-ts에서는 deprecated 되어 있으며 struct를 사용하면 됩니다.

import { struct } from 'fp-ts/lib/Semigroup';

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

계속해서 방금 정의 된 인스턴스로 struct를 사용할 수 있습니다.

type Vector = {
  from: Point;
  to: Point;
};

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

structfp-ts에서 제공하는 유일한 콤비네이터가 아닙니다. 여기에 함수에 대한 Semigroup 인스턴스를 파생시킬 수 있는 콤비네이터가 있습니다. S에 대한 Semigroup 인스턴스가 주어지면, 모든 A에 대해 시그니처 (a: A) => S에 해당하는 Semigroup 인스턴스를 도출할 수 있다.

원문에서는 getFunctionSemigroup, semigroupAll를 사용하라고 작성되어 있지만, 최신 버전의 fp-ts에서는 deprecated 되어 있으며 getSemigroup, SemigroupAll를 사용하면 됩니다.

import type { Semigroup } from 'fp-ts/lib/Semigroup';
import type { Point } from './semigroupPoint';
import { getSemigroup } from 'fp-ts/lib/function';
import { SemigroupAll } from 'fp-ts/lib/boolean';

/** `semigroupAll`은 결합 된 boolean Semigroup입니다. */
export const semigroupPredicate: Semigroup<
  (p: Point) => boolean
> = getSemigroup(SemigroupAll)<Point>();

이제 Points에서 두 predicate 함수를 “병합”할 수 있습니다.

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

const isPositiveXY = semigroupPredicate.concat(isPositiveX, isPositiveY);

작성한 isPositiveXY 함수는 아래와 같이 테스트할 수 있습니다.

describe('semigroupPredicate 인스턴스를 이용해 만든 isPositiveXY 테스트', () => {
  it('isPositiveXY 함수 테스트', () => {
    expect(isPositiveXY({ x: 1, y: 1 })).toBeTruthy();
    expect(isPositiveXY({ x: 1, y: -1 })).toBeFalsy();
    expect(isPositiveXY({ x: -1, y: 1 })).toBeFalsy();
    expect(isPositiveXY({ x: -1, y: -1 })).toBeFalsy();
  });
});

SemigroupAll을 이용했기 때문에 semigroupPredicate 인스턴스의 concat 함수에 전달된 두 함수 모두 true를 반환해야 isPositiveXY 함수가 true를 반환합니다. x, y 모두 0 이상의 값이 전달되었을 경우 true가 반환되었는지 확인합니다.

Folding

정의에 따라 concatA의 두 요소에서만 작동합니다. 더 많은 요소를 연결하려면 어떻게 할 수 있을까요?

fold 함수는 Semigroup 인스턴스, 초깃값 및 요소 배열을 사용합니다.

원문에서는 fold, semigroupSum, semigroupProduct를 사용하라고 작성되어 있지만, 최신 버전의 fp-ts에서는 deprecated 되어 있으며 concatAll, SemigroupSum, SemigroupProduct를 사용하면 됩니다.

import { SemigroupSum, SemigroupProduct } from 'fp-ts/lib/number';
import { concatAll } from 'fp-ts/lib/Semigroup';

const sum = concatAll(SemigroupSum);
const product = concatAll(SemigroupProduct);

작성한 sum 함수와 product 함수는 아래와 같이 테스트할 수 있습니다.

  • sum 함수를 테스트하는 코드
describe('concatAll, SemigroupSum를 사용한 sum 함수 테스트', () => {
  it('sum함수 테스트', () => {
    expect(sum(0)([1, 2, 3, 4])).toBe(10);
    expect(sum(10)([1, 2, 3, 4])).toBe(20);
  });
});
  • product 함수를 테스트하는 코드
describe('concatAll, SemigroupProduct를 사용한 product 함수 테스트', () => {
  it('product함수 테스트', () => {
    expect(product(1)([1, 2, 3, 4])).toBe(24);
    expect(product(10)([1, 2, 3, 4])).toBe(240);
  });
});

원문의 fold 함수는와 다르게 concatAll 함수는 초깃값을 인자로 받고 concat을 사용할 배열을 전달받아 값을 반환하는 함수를 반환한다. 따라서 sum(0)([1, 2, 3, 4]), product(1)([1, 2, 3, 4]) 와 같이 함수 호출 연산자를 두 번 사용해 테스트할 수 있다.

타입 생성자를 위한 Semigroup

Option<A> 두 개를 “병합”하려면 어떻게 할 수 있을까요? 네 가지 경우가 있습니다.

x y concat(x, y)
none none none
some(a) none none
none some(a) none
some(a) some(b) ?

마지막 하나에 문제가 있습니다. 두 개의 A타입 some 객체를 “병합”하려면 무언가가 필요합니다.

두 개의 A를 “병합”하는 것이 Semigroup이 하는 일입니다! A에 대한 Semigroup 인스턴스를 요구한 다음 Option<A>에 대한 Semigroup 인스턴스를 파생 할 수 있습니다. 이것이 getApplySemigroup 콤비네이터가 작동하는 방식입니다.

원문에서는 Option/getApplySemigroup, semigroupSum, 를 사용하라고 작성되어 있지만, 최신 버전의 fp-ts에서는 deprecated 되어 있으며 Apply/getApplySemigroup, SemigroupSum를 사용하면 됩니다.

import { SemigroupSum } from 'fp-ts/lib/number';
import { getApplySemigroup } from 'fp-ts/lib/Apply';
import { Apply } from 'fp-ts/lib/Option';

const S = getApplySemigroup(Apply)(SemigroupSum);

작성한 Option 타입을 지원하는 Semigroup은 아래와 같이 테스트할 수 있습니다.

describe('Option타입을 지원하는 appliedSemigroup 인스턴스 테스트', () => {
  let result;
  it('appliedSemigroup 테스트 (some + none)', () => {
    result = appliedSemigroup.concat(some(1), none);
    expect(result).toBe(none);
    expect(isNone(result)).toBeTruthy();
  });
  it('appliedSemigroup 테스트 (some + some)', () => {
    result = appliedSemigroup.concat(some(1), some(2));
    expect(result).toMatchObject(some(3));
    expect(isSome(result)).toBeTruthy();
  });
});

some 객체와 none 객체를 더할 경우 none을 반환하는지 확인하고 some 객체와 some 객체를 더할 경우 두 some 객체의 value가 더해진 some 객체가 반환되는지 확인합니다.

부록

Semigroup이 여러 데이터를 하나로 “연결”, “병합”또는 “결합”하고 싶을 때 도움이되는 것을 보았습니다.

마지막 예제(Fantas, Eel, Specification 4 : Semigroup에서 수정됨)로 모두 마무리하겠습니다.

아래와 같은 고객 정보를 저장하는 시스템을 구축한다고 가정해 보겠습니다.

interface Customer {
  name: string;
  favouriteThings: Array<string>;
  registeredAt: number;
  lastUpdatedAt: number;
  hasMadePurchase: boolean;
}

어떤 이유로든 같은 사람에 대한 중복 기록이 생길 수 있습니다. 우리에게 필요한 것은 Semigroup이 하는 병합 전략입니다.

원문에서 사용하는 패키지 중 최신 버전의 fp-ts에서는 deprecated 되어 있는 것이 많아 아래에 목록으로 작성하겠습니다.

Deprecated Packages
import { Semigroup, struct, max, min } from 'fp-ts/lib/Semigroup';
import { getMonoid } from 'fp-ts/lib/Array';
import { Ord } from 'fp-ts/lib/number';
import { contramap } from 'fp-ts/lib/Ord';
import { SemigroupAny } from 'fp-ts/lib/boolean';

const semigroupCustomer: Semigroup<Customer> = struct({
  // 더 긴 이름을 유지한다.
  name: max(contramap((s: string) => s.length)(Ord)),
  // 항목을 축적한다.
  // getMonoid는 Semigroup을 위한 `Array<string>`을 반환한다.
  favouriteThings: getMonoid<string>(),
  // 가장 이전의 날짜를 유지한다.
  registeredAt: min(Ord),
  // 가장 최근의 날짜를 유지한다.
  lastUpdatedAt: max(Ord),
  // 분리된 boolean Semigroup
  hasMadePurchase: SemigroupAny,
});

작성한 semigroupCustomer 인터페이스는 아래와 같이 테스트할 수 있습니다.

describe('Semigroup 인터페이스를 구현한 semigroupCustomer 인스턴스 테스트', () => {
  it('semigroupCustomer 인스턴스 concat 함수 테스트', () => {
    expect(
      semigroupCustomer.concat(
        {
          name: 'Giulio',
          favouriteThings: ['math', 'climbing'],
          registeredAt: new Date(2018, 1, 20).getTime(),
          lastUpdatedAt: new Date(2018, 2, 18).getTime(),
          hasMadePurchase: false,
        },
        {
          name: 'Giulio Canti',
          favouriteThings: ['functional programming'],
          registeredAt: new Date(2018, 1, 22).getTime(),
          lastUpdatedAt: new Date(2018, 2, 9).getTime(),
          hasMadePurchase: true,
        }
      )
    ).toMatchObject({
      name: 'Giulio Canti',
      favouriteThings: ['math', 'climbing', 'functional programming'],
      registeredAt: new Date(2018, 1, 20).getTime(),
      lastUpdatedAt: new Date(2018, 2, 18).getTime(),
      hasMadePurchase: true,
    });
  });
});

semigroupCustomer 인터페이스의 concat 함수는 전달된 두 개의 Customer 타입 객체를 병합한다. name 속성은 둘 중 더 긴 것으로 유지하고, favouriteThings 속성은 두 속성을 합친다. registeredAt 속성은 둘 중 더 이전의 시간을 유지하며 lastUpdatedAt 속성은 최근 시간을 유지하고 hasMadePurchase 속성은 true가 있으면 true로 유지합니다.

따라서 주어진 조건에 맞게 Customer 타입 객체가 병합되었는지 확인할 수 있다.

getMonoid 함수는 Array<string>에 대한 Semigroup을 반환합니다. 실제로 monidSemigroup 이상의 것을 반환합니다.

그래서 monoid는 무엇일까요? 다음 포스트에서는 Monoids에 대해 이야기하겠습니다.


Written by@Minsu Kim
Software Engineer at Devsisters Corp.