MicroAd Developers Blog

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

圏論初心者が関手に入門し、Scalaで実装した話

はじめに

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

過去の記事では、圏やモノイドについて書きましたが、今回の記事では「関手」について共有させていただきたいと思います。

過去の記事:

以降、関手について説明しますが、数学用語としてそれについて言及する場合は「関手」という言葉を使用し、関数型プログラミング用語としてそれについて言及する場合は「ファンクタ」という言葉を使用したいと思います。

関手とは

関手とは「圏と圏を、構造を保ちつつ、対応付けるもの」です。 以下は関手の定義です。

定義:圏 Cから圏 Dへの関手とは、次の条件を満たす対応 Fです。

(1)  Cの各対象 X Dの各対象 F(X)に対応させる
(2)  Cの射 f: X \rightarrow Y Dの射 F(f): F(X) \rightarrow F(Y)に対応させる
(3)  Cの各対象 Xの恒等射 id_Xに対して F(id_X) = id_F(X)
(4) 任意の射 f: X \rightarrow Yおよび g: Y \rightarrow Zに対して F(f \circ g) = F(f) \circ F(g)

f:id:tobita_yoshiki:20201005220027p:plain

(1)と(2)についてですが、これは単に圏 C, D間で射と対象を対応付けているだけです。

(3)に関しては、圏 Cの対象の恒等射を関手 Fで写したものは、圏 Dの対応する対象の恒等射と等しくなくてはならないということを表しています。

(4)に関しては、 f gを合成してから Fで写したものと、 f g Fで写してから合成したものは、等しくなくてはならないということを表しています。

つまり、(1)〜(3)の条件では圏と圏の間で射と対象を対応付けていて、(3)は射の合成を対応付けています。

関手の例: 大小関係を保存する関手

関手の例として、「圏と圏の間で大小関係の構造を保つ関手」について説明したいと思います。

前順序集合とは

関手の具体例について言及する前に、まず、前順序集合について説明したいと思います。

前順序関係とは、集合 P上の二項関係「 \leq」のことで、次の条件を満たすものです。 *1

  • 反射律:  Pの任意の要素 aに対し、 a \leq aが成り立つ
  • 推移律:  Pの任意の要素 a, b, cに対し、 a \leq bかつ b \leq cなら a \leq cが成り立つ

そして、前順序関係が定まった集合を前順序集合といいます。

以降、この記事では前順序関係の例として大小関係を取り扱います。 例えば、自然数 Nは任意の数に対して、反射律と推移律が成り立つため、前順序集合です。

大小関係の構造を保つ関手

前順序集合 X, Yを圏とみなしたときに、大小関係を保つ関手 Fは下図のようになります。

f:id:tobita_yoshiki:20201006194935p:plain

図中の圏 C, Dですが、これらはそれぞれ「前順序集合を圏とみなしたもの」を表しています。 圏 C, Dの各射は大小関係を表していて、例えば、図中の f: 1 \rightarrow 2 1 \leq 2を表しています。 以下の表に、図中の各射が表す大小関係を示します。

図中の射 射が表す大小関係
 f: 1 \rightarrow 2  1 \leq 2
 g: 2 \rightarrow 3  2 \leq 3
 f \circ g : 1 \rightarrow 3  1 \leq 3
 F(f): 2 \rightarrow 4  2 \leq 4
 F(g): 4 \rightarrow 6  4 \leq 6
 F(f) \circ F(g) : 2 \rightarrow 6  2 \leq 6

そして、この関手の例では、 Fは元の圏の数を 2 倍にするものとしています。 また、関手 Fは、 F(g \circ f) = F(g) \circ F(f)という条件も満たしていて、例えば、 圏 C 1 \leq 3、圏 D 2 \leq 6が成り立ちます。

恒等射に関しては、例えば圏 C 1 \leq 1、圏 D 2 \leq 2が成り立ち、 F(id_X) = id_F(X)という条件を満たすことが確認できます。

 Cでは 1 \leq 2 \leq 3という大小関係がありますが、関手 Fによって、圏 Dでもこの大小関係が引き継がれ 2 \leq 4 \leq 6となっており、大小関係の構造が保たれていることが確認できます。

Scala で関手を実装する

まず、Scalaの型と関数の集まりをゆるく圏であると考えるため、以下のような圏を定義してみます。

 す:

  • 対象はScalaの型で、例えば、StringInt 等、良い振る舞いをする(well-behaved)もの
  • 射は引数を1つとるScalaの関数で、例えば、String#length: String -> Int等、同じく良い振る舞いをするもの

ここで「良い振る舞いをする」とは、関数であれば例えば、副作用のない(純粋)関数が含まれると考えられます。 また逆に例外を発生させる関数など、(結合律や単位律などの)圏として満たすべき公理を壊しそうなものは、実装上可能であっても取り除いて考えることにします。

そして、圏 すの自己関手(圏 すから す自身への関手)として Functor というトレイトをScalaで定義すると、以下のようになります。 こちらのトレイトを実装する際は、冒頭で述べた関手の定義(1)〜(4)を満たすようにする必要があります。

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

f: A => Bは元の圏の射を表していて、F[A] => F[B]は関手によって写された後の射を表しています。 圏と圏の間で対象の対応を考える場合は、対象の恒等射をmapの引数に渡してやれば、対象を対応付けることができます。

f:id:tobita_yoshiki:20201023113234p:plain

List ファンクタ

それでは上述した圏 すの自己関手の例として List ファンクタについて説明したいと思います。 以下は List ファンクタの実装例です。ScalaのList自体がファンクタなので、mapの実装は単にList#mapを呼び出すだけになっています。

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

以下は List ファンクタのサンプルプログラムおよび概念図です。

// 元の圏の射
val length: String => Int = _.length
val toDouble: Int => Double = _.toDouble

// 元の圏の対象
val strValue: String = "abc"
val intValue: Int = length(strValue)
val doubleValue: Double = toDouble(intValue)

// ファンクタによって写された射
val mappedLength: List[String] => List[Int] = listFunctor.map(length)
val mappedToDouble: List[Int] => List[Double] = listFunctor.map(toDouble)
val mappedToDoubleLength: List[String] => List[Double] = listFunctor.map(toDouble compose length)

// ファンクタによって写された対象
val mappedStrValue: List[String] = List(strValue)
val mappedIntValue: List[Int] = List(intValue)
val mappedDoubleValue: List[Double] = List(doubleValue)

f:id:tobita_yoshiki:20201021182912p:plain

サンプルプログラムでは、 lengthtoDoubleという射を定義していて、listFunctorで射をList[A] => List[B]に変換するようにしています。 対象と射の対応関係ですが、各射 length, toString, toDoubleは、map(length), map(toStr), map(toDouble)に、各対象String, Int, DoubleList[String], List[Int], List[Double]に対応付いています。

また、恒等射の対応関係に関しては、以下の条件が成り立ちます。

// 恒等射の対応関係のチェック
List(strValue).map(identity) == mappedStrValue // true
List(intValue).map(identity) == mappedIntValue // true
List(doubleValue).map(identity) == mappedDoubleValue // true

射の合成順序に関しては、以下の条件が成り立ちます。

// 射の合成順序のチェック
val value = "関数が等しいかどうかテストするための値"

// F(g ∘ f)
val Fgf = List((toDouble compose length)(value))
// F(g) ∘ F(f)
val FgFf = (listFunctor.map(toDouble) compose listFunctor.map(length))(List(value))

// F(g ∘ f) == F(g) ∘ F(f)
Fgf == FgFf // true

雑感

今回、関手(ファンクタ)について調べてみた個人的な雑感ですが、実際のプロダクトの設計にファンクタを取り入れるためには、さらに圏論について理解を深める必要があるかと思いました。

関数型ライブラリなどを使用する際に、ラリブラリで事前に用意されているファンクタのmapを利用して、プログラムを書いたりすることは簡単そうですが、 一方で、実際のプロダクトにファンクタを設計として組み込むというのは、難しそうです。理想を言えば「仕様書を読んで、適切なデータ構造でモデリングして、それをファンクタとして実装する」レベルまでファンクタを使いこなせるようになりたいですね。

今後はより、ファンクタに関する深い理解をするために、関数型っぽい開発をしているOSSのコード等を読んで、実際のアプリケーションでどのようにファンクタが応用されているのかを調査していきたいと思います。

終わりに

ご覧いただきありがとうございます! 将来的には、ファンクタのような概念をUNIVERSE Adsに組み込んで、より品質の高いプロダクトを作っていきたいと思います。

マイクロアドでは 「広告配信システムをつくりたい!」 「関数型プログラミング言語をガンガン使っていきたい!」というエンジニアを積極的に募集しています。 ご興味を持たれた方はこちらから是非ご応募下さい!

recruit.microad.co.jp

*1: \leq」という記号を使いましたが、前順序関係は反射律と推移律を満たす集合上の二項関係で、大小関係に限った話ではありません。