타입 클래스 구현하기

타입 클래스 구현하기 (Implementing Type classes)

타입 클래스(type class)는 서브타이핑 없이도 닫힌(closed) 데이터 타입에 새 행동을 더할 수 있게 해주는 추상적이고 매개변수화된 타입이에요. 이 페이지에서는 Scala 3에서 타입 클래스를 어떻게 정의하고 활용하는지, 반군/모노이드, 펑터, 모나드 같은 실제 예시를 따라가며 배워볼게요.

출처: Scala 3 Reference

본문

타입 클래스는 서브타이핑을 쓰지 않고도 닫힌 데이터 타입에 새 행동을 추가할 수 있게 해주는 추상적이고 매개변수화된 타입이에요. 이는 여러 활용 사례에서 유용할 수 있어요. 예를 들어 다음과 같죠.

  • 내가 소유하지 않은 타입(표준 라이브러리나 3rd-party 라이브러리의 타입)이 그런 행동에 부합함을 표현할 때
  • 그런 행동을, 타입들 사이의 서브타이핑 관계(하나가 다른 하나를 상속) 없이 여러 타입에 대해 표현할 때 (예: 애드혹 다형성(ad hoc polymorphism))

그래서 Scala 3에서 타입 클래스는 파라미터가 하나 이상인 트레이트 그 자체인데, 그 구현은 extends 키워드로 정의되는 게 아니라 given 인스턴스로 정의돼요. 여기 흔한 타입 클래스들의 예가 있어요.

반군과 모노이드 (Semigroups and monoids)

다음은 Monoid 타입 클래스 정의예요.

trait SemiGroup[T]:
  extension (x: T) def combine (y: T): T

trait Monoid[T] extends SemiGroup[T]:
  def unit: T

String 타입에 대한 이 Monoid 타입 클래스의 구현은 다음과 같을 수 있어요.

given Monoid[String]:
  extension (x: String) def combine (y: String): String = x.concat(y)
  def unit: String = ""

Int 타입에 대해서는 다음과 같이 쓸 수 있어요.

given Monoid[Int]:
  extension (x: Int) def combine (y: Int): Int = x + y
  def unit: Int = 0

이 모노이드는 이제 다음 combineAll 메서드에서 컨텍스트 bound로 쓸 수 있어요.

def combineAll[T: Monoid as m](xs: List[T]): T =
  xs.foldLeft(m.unit)(_.combine(_))

펑터 (Functors)

어떤 타입의 Functor는 그 값들에 "매핑(map over)"할 수 있는 능력, 즉 값의 모양(shape)을 기억하면서 값을 변형하는 함수를 적용하는 능력을 제공해요. 예를 들어 원소를 버리거나 추가하지 않고 컬렉션의 모든 원소를 수정하는 경우죠. "매핑될 수 있는" 모든 타입을 F로 나타낼 수 있어요. F는 타입 생성자예요. 타입 인자가 주어지면 그 값의 타입이 구체화되죠. 그래서 우리는 F[_]라고 쓰는데, 이는 타입 F가 인자로 다른 타입을 받는다는 힌트예요. 일반적인 Functor의 정의는 따라서 이렇게 써요.

trait Functor[F[_]]:
  def map[A, B](x: F[A], f: A => B): F[B]

이는 이렇게 읽을 수 있어요. "타입 생성자 F[_]에 대한 Functor는 함수 f: A => B의 적용을 통해 F[A]F[B]로 변형할 수 있는 능력을 나타낸다." 여기서 Functor 정의를 타입 클래스라고 불러요. 이렇게 하면 List 타입에 대한 Functor 인스턴스를 정의할 수 있어요.

given Functor[List]:
  def map[A, B](x: List[A], f: A => B): List[B] =
    x.map(f) // List already has a `map` method

이 given 인스턴스가 스코프에 있으면, Functor 컨텍스트 bound가 기대되는 곳 어디든 컴파일러가 List를 쓰는 것을 받아줘요.

예를 들어 이런 테스트 메서드를 쓸 수 있어요.

def assertTransformation[F[_]: Functor, A, B](expected: F[B], original: F[A], mapping: A => B): Unit =
  assert(expected == summon[Functor[F]].map(original, mapping))

그리고 이렇게 사용할 수 있어요. 예를 들어

assertTransformation(List("a1", "b1"), List("a", "b"), elt => s"${elt}1")

이것이 첫걸음이지만, 실제로는 map 함수가 타입 F에서 직접 접근 가능한 메서드였으면 좋겠어요. 그래야 F의 인스턴스에서 직접 map을 호출하고 summon[Functor[F]] 부분을 없앨 수 있죠. Monoid 예시에서처럼 확장 메서드가 그것을 도와줘요. Functor 타입 클래스를 확장 메서드로 다시 정의해 볼게요.

trait Functor[F[_]]:
  extension [A](x: F[A])
    def map[B](f: A => B): F[B]

List에 대한 Functor 인스턴스는 이제 이렇게 돼요.

given Functor[List]:
  extension [A](xs: List[A])
    def map[B](f: A => B): List[B] =
      xs.map(f) // List already has a `map` method

이것이 assertTransformation 메서드를 단순화해요.

def assertTransformation[F[_]: Functor, A, B](expected: F[B], original: F[A], mapping: A => B): Unit =
  assert(expected == original.map(mapping))

map 메서드는 이제 original에 직접 쓰여요. original의 타입이 F[A]이고 map을 정의하는 Functor[F[A]]의 given 인스턴스가 스코프에 있기 때문에 확장 메서드로 사용 가능해요.

모나드 (Monads)

Functor[List]map을 타입 A => B의 매핑 함수에 적용하면 List[B]가 돼요. 그래서 타입 A => List[B]의 매핑 함수에 적용하면 List[List[B]]가 돼요. 리스트의 리스트를 관리하고 싶지 않다면, 값들을 단일 리스트로 "평평하게(flatten)" 하고 싶을 수 있어요.

바로 여기가 Monad가 등장하는 자리예요. 타입 F[_]에 대한 MonadFunctor[F]에 두 연산이 더 추가된 것이에요.

  • flatMap — 타입 A => F[B]의 함수가 주어졌을 때 F[A]F[B]로 바꾸는 연산,
  • pure — 단일 값 AF[A]를 만드는 연산.

이 정의를 Scala 3로 옮기면 다음과 같아요.

trait Monad[F[_]] extends Functor[F]:

  /** The unit value for a monad */
  def pure[A](x: A): F[A]

  extension [A](x: F[A])
    /** The fundamental composition operation */
    def flatMap[B](f: A => F[B]): F[B]

    /** The `map` operation can now be defined in terms of `flatMap` */
    def map[B](f: A => B) = x.flatMap(f.andThen(pure))

end Monad

List

List는 다음 given 인스턴스를 통해 모나드가 될 수 있어요.

given listMonad: Monad[List]:
  def pure[A](x: A): List[A] =
    List(x)
  extension [A](xs: List[A])
    def flatMap[B](f: A => List[B]): List[B] =
      xs.flatMap(f) // rely on the existing `flatMap` method of `List`

MonadFunctor의 하위 타입이므로 List는 펑터이기도 해요. Functormap 연산은 이미 Monad 트레이트가 제공하므로, 인스턴스가 그것을 명시적으로 정의할 필요는 없어요.

Option

Option은 같은 종류의 행동을 가진 또 다른 타입이에요.

given optionMonad: Monad[Option]:
  def pure[A](x: A): Option[A] =
    Option(x)
  extension [A](xo: Option[A])
    def flatMap[B](f: A => Option[B]): Option[B] = xo match
      case Some(x) => f(x)
      case None => None

Reader

Monad의 또 다른 예는 Reader 모나드예요. 이는 ListOption 같은 데이터 타입이 아니라 함수에 대해 동작해요. 같은 파라미터를 모두 필요로 하는 여러 함수를 결합하는 데 쓸 수 있어요. 예를 들어 어떤 설정(configuration), 컨텍스트, 환경 변수 등에 접근해야 하는 여러 함수들 말이죠.

Config 타입을 정의하고, 그것을 쓰는 두 함수를 정의해 볼게요.

trait Config
// ...
def compute(i: Int)(config: Config): String = ???
def show(str: String)(config: Config): Unit = ???

computeshow를 단일 함수로 결합하고 싶을 수 있어요. Config를 파라미터로 받고 연산 결과를 보여주는 함수로요. 그리고 파라미터를 여러 번 명시적으로 전달하는 것을 피하기 위해 모나드를 쓰고 싶어요. 올바른 flatMap 연산을 가정한다면 이렇게 쓸 수 있어요.

def computeAndShow(i: Int): Config => Unit = compute(i).flatMap(show)

대신에

show(compute(i)(config))(config)

flatMap을 정의해 볼게요. 먼저 Config가 주어지면 Result를 생산하는 함수를 나타내는 ConfigDependent라는 타입을 정의할 거예요.

type ConfigDependent[Result] = Config => Result

모나드 인스턴스는 이렇게 생길 거예요.

given configDependentMonad: Monad[ConfigDependent]:

  def pure[A](x: A): ConfigDependent[A] =
    config => x

  extension [A](x: ConfigDependent[A])
    def flatMap[B](f: A => ConfigDependent[B]): ConfigDependent[B] =
      config => f(x(config))(config)

end configDependentMonad

ConfigDependent 타입은 타입 람다(type lambda)로 쓸 수도 있어요.

type ConfigDependent = [Result] =>> Config => Result

이 문법을 쓰면 앞의 configDependentMonad는 이렇게 돼요.

given configDependentMonad: Monad[[Result] =>> Config => Result]:

  def pure[A](x: A): Config => A =
    config => x

  extension [A](x: Config => A)
    def flatMap[B](f: A => Config => B): Config => B =
      config => f(x(config))(config)

end configDependentMonad

이 패턴을 우리 Config 트레이트가 아닌 다른 종류의 환경과도 쓰고 싶을 가능성이 높아요. Reader 모나드는 Config를 타입 파라미터로 추상화할 수 있게 해주는데, 다음 정의에서는 그것을 Ctx라 이름 붙였어요.

given readerMonad: [Ctx] => Monad[[X] =>> Ctx => X]:

  def pure[A](x: A): Ctx => A =
    ctx => x

  extension [A](x: Ctx => A)
    def flatMap[B](f: A => Ctx => B): Ctx => B =
      ctx => f(x(ctx))(ctx)

end readerMonad

요약 (Summary)

타입 클래스의 정의는 추상 멤버를 가진 매개변수화된 타입, 이를테면 트레이트로 표현돼요. 서브타입 다형성(subtype polymorphism)과 타입 클래스를 이용한 애드혹 다형성의 주요 차이는, 타입 클래스 정의가 그 타입에 대해 어떻게 구현되는가에 있어요. 타입 클래스의 경우, 구체 타입에 대한 구현은 given 인스턴스 정의로 표현되며, 그것은 동작 대상인 값과 함께 암시적 인자로 제공돼요. 서브타입 다형성의 경우 구현이 클래스의 부모(parent)에 혼합되고, 다형적 연산을 수행하는 데 오직 하나의 용어(term)만 필요해요. 타입 클래스 방식은 설정에 더 많은 노력이 들지만 더 확장성이 좋아요. 클래스에 새 인터페이스를 추가하려면 그 클래스의 소스 코드를 바꿔야 해요. 반면 타입 클래스의 인스턴스는 어디서든 정의할 수 있어요.

마무리하자면, 트레이트와 given 인스턴스가 확장 메서드, 컨텍스트 bound, 타입 람다 같은 다른 구성 요소와 결합하면 타입 클래스를 간결하고 자연스럽게 표현할 수 있다는 걸 봤어요.