MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

圏論初心者が自然変換について学んでみた!

こんにちは。マイクロアドでソフトウェアエンジニアをしている飛田と申します。私は主に UNIVERSE Ads というプロダクトの開発に携わっています。 UNIVERSE Ads では、より関数型ライクな設計や実装を取り入れることにより、高い保守性と効率性を目指しています。

マイクロアドでは、プロダクトの一部にdoobieというDB操作ライブラリを使っていまして、 詳細は割愛しますが、その中でTransactorという自然変換を行うものが登場します。

今回はライブラリの背景にある概念を知るために、「自然変換」の概要について学びましたので、ここで共有させていただきます。

過去の記事:

自然変換とは

以前の記事で、関手とは「圏と圏を、構造を保ちつつ、対応付けるもの」と書きましたが、 自然変換とは「関手から関手への射で、関手の構造を保つもの」です。

定義:圏 Cから圏 Dへの関手 F, Gが存在するとき、以下の条件を満たす tを自然変換といいます。

  • tは Cの各対象 Xに対して射 F(X) \xrightarrow{t_X} G(X)を対応させます。
  •  Cの任意の射 X  \xrightarrow{f} Yに対して G(f) \circ t_x = t_y \circ F(f)が成り立ちます。

つまり、自然変換とはの関手 Fから Gへの射 tのあつまりで、以下の図式を可換にするものです。

f:id:tobita_yoshiki:20210526184446p:plain

Scalaの自然変換の例

ListからOptionへの自然変換 headOption について取り上げたいと思います。

例えばStringからIntへの射lengthが存在したとき、 headOptionが自然変換であるためには下記の図式が可換になる必要があります。

f:id:tobita_yoshiki:20210517175958p:plain

以下のコードは、 ListOptionをファンクタとして実装し、headOptionが自然変換かどうかを軽くチェックするものです。

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

val listFunctor = new Functor[List] {
  override def map[A, B](f: A => B): List[A] => List[B] = _.map(f)
}

val optionFunctor = new Functor[Option] {
  override def map[A, B](f: A => B): Option[A] => Option[B] = _.map(f)
}

def headOption[A](list: List[A]): Option[A] = list.headOption
def length(str: String): Int = str.length
  
val lengthAfterHeadOption: List[String] => Option[Int] = optionFunctor.map(length) compose headOption[String]
val headOptionAfterLength = headOption[Int] _ compose listFunctor.map(length)

val testData = List("Foo", "Bar", "FooBar")
assert(lengthAfterHeadOption(testData) == headOptionAfterLength(testData))

lengthAfterHeadOptionheadOptionAfterLengthという2種類の合成射があり、射が辿る経路はそれぞれ異なりますが、 関数が返す最終的な値は同じなり、コード末尾にあるassert文の結果は成功します。

考察など

初めて自然変換について学んだ感想ですが、何が「自然」なのかを理解するのが難しく思えました。 Scalaのサンプルコードを見ますと、確かにheadOptionlengthを合成した関数は、合成の順序にかかわらず同じ値を返しますが、 なぜそうなるかを考えると結構奥が深そうです。

ListファンクタとOptionファンクタはそれぞれ構造を保ち、List#mapはListの長さを保ちながら各要素を変換し、Option#mapSomeもしくはNoneのデータ構造を保ちながら中身の要素を変換します。それぞれが保つ構造を上手く加味して、headOptionlengthを合成した関数が合成の順序にかかわらず同じ値を返すようになり、その結果、headOptionlengthの関係が整合的になることが「自然」なのかなと思いました。

おわりに

ご覧いただきありがとうございました! 今回の記事は自然変換の簡単な説明になりましたが、 次回以降の記事ではScalaの実装で実践的に自然変換を使う例などが書ければと思います!

参考