[fp-ts] 타입스크립트로 함수형 프로그래밍 시작하기: Eq
포스트
취소
Preview Image

[fp-ts] 타입스크립트로 함수형 프로그래밍 시작하기: Eq

이 포스팅은 DEV Community에서 참고하여 공부하면서 정리한 문서입니다. 현재 주 개발 언어로 사용 중인 스칼라 코드가 갑작스럽게 등장할 수 있습니다.


fp-ts


fp-ts는 타입스크립트로 타입이 있는 함수형 프로그래밍을 할 수 있도록 제공하는 라이브러리이다.

타입스크립트에서 제공하지 않는 Option, Either, IO, Task, Functor, Applicative, Monad 등 함수형 타입을 제공한다.


타입 클래스 (Type Class)


타입 클래스란 구현하려는 기능을 나타내는 인터페이스 혹은 API 이다. 타입스크립트에서는 타입 클래스를 인터페이스로 작성할 수 있다.

먼저 동등성(equality)을 평가하는 기능을 가지고 있는 Eq 타입 클래스를 작성해보자.

1
2
3
interface Eq<A> {
  readonly equals (x: A, y: A) => boolean
}

타입 AEq 타입 클래스에 속해있으며 equals 함수에 정의되어 있다.


스칼라에서는 아래와 같이 트레이트를 사용하여 작성할 수 있다.

1
2
3
trait Eq[A] {
  def equals(x: A, y: A): Boolean
}

트레이트는 자바의 인터페이스와 비슷하지만 더 확장된 개념으로 자바에서는 인터페이스를 구현(implementation) 한다고 하지만 스칼라에서는 트레이트를 믹스인(mixin) 한다고 한다.


인스턴스 (Instance)


인스턴스는 타입 클래스의 구현을 제공하며 Eq 클래스 기준으로 A 타입에 대한 구현을 제공한다.

1
2
3
const eqNumber: Eq<number> = {
  equals: (x, y) => x === y
}

eqNumber 인스턴스는 몇 가지 규칙을 지켜야 한다.

  1. 반사성(Reflexivity): x가 무엇이든 equals(x, x) === true 이어야 한다.
  2. 대칭성(symmetry): equals(x, y) === true 라면 equals(y, x) === true 이어야 한다.
  3. 전이성(Transitivity): equals(x, y) === true, equals(y, z) === true 라면 equals(x, z) === true 이다.

스칼라에서는 인스턴스를 이렇게 정의할 수 있다.

1
2
3
4
// implicit에 대해서는 아래에서 살펴보자...
implicit val eqNumber: Eq[Int] = new Eq[Int] {
  override def equals(x: Int, y: Int): Boolean = x == y
}


다음과 같이 배열에 특정 요소가 존재하는지 평가하는 elem 함수를 정의할 수 있다.

1
2
3
4
5
6
function elem<A>(E: Eq<A>): (a: A, as: Array<A>) => boolean {
  return (a, as) => as.some(item => E.equals(item, a))
}

elem(eqNumber)(1, [1, 2, 3]) // true
elem(eqNumber)(4, [1, 2, 3]) // false

elem 함수는 Eq 타입 클래스를 구현한 eqNumber 인스턴스를 사용하여 as 배열에서 a가 존재하는지 평가한다.

이렇게 A에 대한 Eq 인스턴스를 인자로 넘겨서 함수에서 동등성에 대한 평가를 할 수 있다. 이 예제는 단순한 예제일 뿐이다.

만약 특정 기능을 타입 클래스로 추상화 해두고 필요에 따라 구현해둔다면 로직의 재사용성과 일관성, 함수 합성 등 다양한 함수형 프로그래밍의 장점을 보게 될 것이다.

스칼라에서는 이렇게 할 수 있다.

1
2
3
4
5
6
def elem[A](a: A, as: Seq[A])(implicit E: Eq[A]): Boolean = {
  as.exists(item => E.equals(item, a))
}

elem(1, Seq(1, 2, 3)) // true
elem(4, Seq(1, 2, 3)) // false

eqNumber를 암시적(implicit)으로 정의했기 때문에 생략이 가능하다. 스칼라의 특징 중 하나이다.


복합 타입에 대한 Eq 타입 클래스의 인스턴스도 정의할 수 있다.

1
2
3
4
5
6
7
8
type Point = {
  x: number
  y: number
}

const eqPoint: Eq<Point> = {
  equals: (p1, p2) => p1.x === p2.x && p1.y === p2.y
}

반사성을 고려한다면 아래와 같이 코드를 최적화 할 수 있다.

1
2
3
const eqPoint: Eq<Point> = {
  equals: (p1, p2) => p1 === p2 || (p1.x === p2.x && p1.y === p2.y)
}


함수 조합기 (Combinator)


그런데 이렇게 모든 타입의 동등성 평가를 위한 인스턴스를 만드는 것은 너무 번거롭다.

Point 타입의 프로퍼티들은 모두 number 타입이고 우리는 이미 eqNumber 인스턴스를 구현했으므로 이를 사용하여 Point 타입의 동등성 비교를 하는 것이 좋겠다.

fp-ts는 이런 것들을 하기 위한 함수 조합기(combinator)를 제공한다.

1
2
3
4
5
6
7
8
9
10
import {struct, Eq} from 'fp-ts/Eq'

const eqPoint: Eq<Point> = struct({
  x: eqNumber,
  y: eqNumber
})

const p1: Point = { x: 1, y: 2 }
const p2: Point = { x: 1, y: 2 }
eqPoint.equals(p1, p2) // true

참고 문서에서 사용한 getStructEq는 deprecated 되었다. 사실 Eqfp-ts에서 제공하는 타입 클래스다.


Point 타입을 가지는 다른 복합 타입이 있다면 eqPoint 를 조합하여 Eq 인스턴스를 정의할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Vector = {
  from: Point
  to: Point
}

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

const v1: Vector = {
  from: { x: 1, y: 2 },
  to: { x: 1, y: 2 }
}
const v2: Vector = {
  from: { x: 2, y: 4 },
  to: { x: 1, y: 2 }
}
eqVector.equals(v1, v2) // false

fp-ts/Eq 패키지의 struct를 사용하여 이렇게 Eq 인스턴스를 조합할 수 있었다.

배열을 위한 Eq 인스턴스를 만들기 위한 함수 조합기도 제공한다.

1
2
3
4
5
6
7
import {getEq} from 'fp-ts/Array'

const eqArrayOfPoints: Eq<Array<Point>> = getEq(eqPoint)

const points1: Array<Point> = [{x: 1, y: 1}, {x: 2, y: 2}]
const points2: Array<Point> = [{x: 1, y: 1}, {x: 2, y: 2}]
eqArrayOfPoints.equals(points1, points2) // true


만약 어떤 타입이 있을 때 특정 프로퍼티의 동등성 비교로 해당 타입의 동등성을 평가하고 싶다면 contramap 함수 조합기를 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import {contramap, Eq} from 'fp-ts/Eq'
import {Eq as eqNumber} from 'fp-ts/number' // 기본 타입의 Eq 인스턴스도 fp-ts에서 기본 제공한다.

type User = {
  userId: number
  name: string
}

// userId를 평가하여 User의 동등성을 비교한다.
const eqUser: Eq<User> = contramap((user: User) => user.userId)(eqNumber)

const u1 = { userId: 1, name: 'alan' }
const u2 = { userId: 1, name: 'baegoon' }
eqUser.equals(u1, u2) // true

const u3 = { userId: 1, name: 'Bae Sangwoo' }
const u4 = { userId: 2, name: 'Ryu Songyi' }
eqUser.equals(u3, u4) // false

특정 프로퍼티를 비교한다고 설명했지만 정확한 의미는 아니다. contramap의 시그니처를 보자.

1
const contramap: <A, B>(f: (b: B) => A) => (fa: Eq<A>) => Eq<B>

B에 대한 동등성 평가를 할 때 특별한 평가 규칙이 있다면 규칙으로 사용될 함수 f를 제시하고 그 반환 타입인 A를 평가하는 Eq 인스턴스로 동등성을 평가한다.

다른 예로 위에서 작성한 Vector 타입의 다른 규칙의 Eq 인스턴스를 만들어보자.

1
2
3
4
5
// Vector 타입의 from 프로퍼티만 비교한다. -> eqPoint 인스턴스 사용
const eqVector2: Eq<Vector> = contramap((v: Vector) => v.from)(eqPoint)

// Vector 타입의 from 프로퍼티의 x, y의 합을 비교한다. -> eqNumber 인스턴스 사용
const eqVector3: Eq<Vector> = contramap((v: Vector) => v.from.x + v.from.y)(eqNumber)

contramap에 대해서 지금은 이 정도로만 간단히 사용법을 알아보는 것으로 마무리하자. (더 많은 이해가 필요하다.)

이렇게 Eq 타입 클래스를 알아보면서 간단히 fp-ts 라이브러리에 대해 알아보고 함수형 프로그래밍에서 타입을 처리하는 법을 간단히 알아봤다.

다음은 대소 비교를 하는 Ord 타입 클래스에 대해 알아보자.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

[Live Study] 15주차 과제: 람다식

-