ともちんの Tech ブログ

Scala プログラマのための圏論入門 (7) 関手性

はじめに

前回、圏と圏との間の対応である関手について定義し、いくつかのインスタンスを見ていきました。

taretmch.hatenablog.com

本記事では関手についてさらに深掘り、今後の議論の際に必要となるいくつかの関手について説明します。

まず、型構築子に2つの型パラメータを持つ関手である双関手について説明します。双関手の一般的な定義は直積圏を用いて与えられるので、直積圏についても説明します。双関手を用れば、積と余積を構成可能であることを示します。

次に、以前見た Writer 圏は、Writer 関手でもあることを説明します。

Writer 関手を見たあとは、Reader 関手について説明します。Reader 関手は前章でも少し見まして、ある型を返すような関数を作る操作が関手であることが言えましたね。ここでは、Reader 関手に与える型パラメータを1つと固定する (返り値の型を指定する) のではなく、2つ指定する (引数と返り値の型を指定する) ことを考え、これと双関手の関係性について見ていきます。実は、2つの型パラメータをとる Reader 関手は、Profunctor と呼ばれる関手の1つであることがわかります。

最後に、Profunctor の具体例であって、圏論において重要な概念である Hom 関手について説明します。

少し難しいかもしれませんが、なるべくコードに落として学んでいければと思います!

サンプルコード

本記事のサンプルコードは、下記リポジトリに置いてあります。コンソールで動かしたい場合、リポジトリをクローンして試してみていただければと思います。

github.com

双関手

双関手 (bifunctor) は、型構築子に2つの型パラメータを持つ関手です。

双関手において、対象関数 F[_, _] を、2つの型 AB を型 F[A, B] に対応させるものとして定義します。

また、射関数 bimap を、射 A => CB => D に対して射 F[A, B] => F[C, D] を対応させるものとして定義します。

双関手の型クラスを定義すると、以下のようになります。

trait Bifunctor[F[_, _]]:

  /** Morphism mappings for Bifunctor */
  def bimap[A, B, C, D](f: A => C)(g: B => D): F[A, B] => F[C, D]

  def first[A, B, C](f: A => C): F[A, B] => F[C, B] = bimap(f)(identity[B])
  def second[A, B, D](g: B => D): F[A, B] => F[A, D] = bimap(identity[A])(g)

Bifunctor 型クラスは、対象関数として型構築子 F[_, _] をもち、射関数として bimap メソッドをもちます。first および second は、双関手の型パラメータの1つ目と2つ目の値に対してそれぞれ変換を施すものです。

bimap メソッドを定義すれば first メソッドと second メソッドを実装できますし、first メソッドと second メソッドを実装すれば bimap メソッドを実装することができます。

双関手

直積圏を定義する

双関手の一般的な定義を与えるために、直積圏 (product category) という概念を導入します。

2つの圏 ${\mathcal{C}}1$ と ${\mathcal{C}}2$ に対して、直積圏 ${\mathcal{C}}1 \times {\mathcal{C}}2$ を考えることができます。

直積圏 ${\mathcal{C}}1 \times {\mathcal{C}}2$ は、対象を ${\mathcal{C}}1$ の対象 $a$ と ${\mathcal{C}}2$ の対象 $b$ のペア $(a, b)$ とします。

そして、射を ${\mathcal{C}}1$ の射 $f: a \to c$ と ${\mathcal{C}}2$ の射 $g: b \to d$ のペア $(f, g)$ とします。Hamcat では、関数のペアをラッパークラスとして実装してみました:

case class ProductFunction[A, B, C, D](run: (A => C, B => D))

どちらも対象と射のペアをとっているだけですね。

射の合成についてもペアをとるだけです。${\mathcal{C}}1$ における射の合成 $h \cdot f$ と ${\mathcal{C}}2$ における射の合成 $k \cdot g$ に対して、$(h \cdot f, k \cdot g)$ は直積圏 ${\mathcal{C}}1 \times {\mathcal{C}}2$ における射の合成になります:

/** Composition of morphism in product category */
def andThen[E, H](v: ProductFunction[C, D, E, H]): ProductFunction[A, B, E, H] =
  run match
    case (f, g) => v.run match
      case (h, k) => ProductFunction((f andThen h, g andThen k))
                                                                                 
/** Composition of morphism in product category */
def compose[E, H](v: ProductFunction[E, H, A, B]): ProductFunction[E, H, C, D] =
  run match
    case (f, g) => v.run match
      case (h, k) => ProductFunction((f compose h, g compose k))

上記のように射の合成 andThen メソッドと compose メソッドを定義すると、Scala の直積圏における2つの射 (A => C, B => D)(C => E, D => H) を合成して (A => E, B => H) を構成できます。

恒等射も同様に、 $\mathcal{C}1$ の恒等射 $id_1$ と $\mathcal{C}2$ の恒等射 $id_2$ に対して $(id_1, id_2)$ が直積圏の恒等射になります。

/** Identity morphism */
def productIdentity[A, B]: ProductFunction[A, B, A, B] =
  ProductFunction((identity[A], identity[B]))

直積圏における射の合成の例

ProductFunction クラスは、Scala 圏と Scala 圏の直積圏の実装です。

対象は、2つの Scala 圏の対象、すなわち型 A と型 B のタプルです。例として、AInt とし、BLong としておきます。

/** Object declaration */
val obj = (3, 4L)

直積圏における射 func1func2 は、Scala 圏の2つの射、すなわち関数 A => C 関数 B => D のタプルです。func1 は第1引数として Int 型のインクリメント関数 increment を持ち、第2引数として Long 型の数を2倍する関数 doubleL を持ちます。func2 は第1引数として Int 型の数が偶数かどうか判定する関数 isEven を持ち、第2引数として Long 型の数が奇数かどうか判定する関数 isOddL を持ちます。

def increment: Int => Int = _ + 1
def doubleL: Long => Long = _ * 2
def isEven: Int => Boolean = _ % 2 == 0
def isOddL: Long => Boolean = _ % 2 == 1
import hamcat.arrow.ProductFunction

/** Morphism declaration */
val func1 = ProductFunction((increment, doubleL))
val func2 = ProductFunction((isEven, isOddL))

それぞれの関数の定義は以下のようになっています。

ProductFunction クラスには関数適用のために apply メソッドをはやしているので、以下のように関数適用の結果を出力できます。

/** Apply morphism to object */
val func1Apply: (Int, Long)        = func1(obj)
// func1Apply: (Int, Long) = (4, 8L)
val func2Apply: (Boolean, Boolean) = func2(obj)
// func2Apply: (Boolean, Boolean) = (false, false)

この直積圏における射の合成は、先ほど定義した andThen メソッドおよび compose メソッドを使って構築できます。この合成関数は、第1引数として Int 型の数が奇数かどうか判定する(インクリメントして偶数かどうか判定するので)関数を持ち、第2引数として常に false を返す(数を2倍したあと奇数かどうかを判定するので)関数を持ちます。

/** Compose morphism */
def func2ComposeFunc1 = func2 compose func1
def func1AndThenFunc2 = func1 andThen func2

これらの関数に (3, 4L) を適用すると以下の結果が返ります。

/** Apply composition of morphism */
val result1 = func2ComposeFunc1(obj)
// result1: (Boolean, Boolean) = (true, false)
val result2 = func1AndThenFunc2(obj)
// result2: (Boolean, Boolean) = (true, false)

双関手の一般的な定義

さて、2つの圏の対象と射、射の合成をそれぞれペアにすることによって、直積圏を定義しました。双関手の話に戻りましょう。

一般的に、双関手は以下のように定義されます。

定義:双関手
双関手 (bifunctor) とは、2つの圏 $\mathcal{C}$ と $\mathcal{D}$ の直積圏 $\mathcal{C} \times \mathcal{D}$ から圏 $\mathcal{E}$ への関手のことです。

Scala 圏において関手は自己関手となるので、Scala 圏における双関手 (すなわち Bifunctor) は Scala 圏と Scala 圏の直積から Scala 圏への関手になります。

すなわち、Bifunctor は、対象関数 F[_, _] として Scala の直積圏の対象 ABF[A, B] に対応させ、射関数 bimap として Scala の直積圏の射 A => CB => DF に関する射 F[A, B] => F[C, D] に対応させます。

なお、bimap の引数が (A => C, B => D) ではなく A => CB => D であるのは、使いやすさの観点からです。これらは、互いに同型であるので、どちらの形でも問題はありません。

def isomorpTupleToFunc1[A, B, C, D]: ((A => C, B => D)) => (A => C) => (B => D) = {
  case (f, g) => f => g
}

def isomorpFuncToTuple[A, B, C, D]: (A => C) => (B => D) => ((A => C, B => D)) =
  f => g => (f, g) 

では、双関手のいくつかの例をみていきましょう。

Tuple2 は双関手

積は、2つの型パラメータから構築されます。

val tuple: Tuple2[Int, String] = (33, "thirty three")

Tuple2 は2つの型パラメータを持つため、双関手の候補になります。

実際、積を構築する積関手 Tuple2 は、双関手の例です。

Scala の直積圏から型を1つずつとってきて、型構築子 Tuple2[_, _] によって Scala 圏の型 Tuple2 を構成します。

Tuple2 の射関数 bimap は以下のように定義されます。すなわち、直積圏の射 f: A => C, g: B => D が与えられると、それらを Tuple2 に関する射 Tuple2[A, B] => Tuple2[C, D] に引き上げます。

/** Product bifunctor */
given Bifunctor[Tuple2] with
  def bimap[A, B, C, D](f: A => C)(g: B => D): ((A, B)) => ((C, D)) =
    case (a, b) => (f(a), g(b))

Either もまた双関手

余積も積と同様、2つの型パラメータから構築されます。

val right: Either[Int, String] = Right("thirty three")

余積を構築する余積関手 Either は、双関手の例です。

積と同様に、Scala の直積圏から型を1つずつとってきて、型構築子 Either[_, _] によって Scala 圏の対象 Either 型に対応させます。

Either の射関数 bimap は以下のように定義されます。すなわち、直積圏の射 f: A => C, g: B => D が与えられると、それらを Either に関する射 Either[A, B] => Either[C, D] に引き上げます。

/** Coproduct bifunctor */
given Bifunctor[Either] with
  def bimap[A, B, C, D](f: A => C)(g: B => D): Either[A, B] => Either[C, D] =
    case Left(a)  => Left(f(a))
    case Right(b) => Right(g(b))

Writer 関手の再登場

以下の記事で、Kleisli 圏の例として Writer 圏を見ました。

taretmch.hatenablog.com

Writer 圏において、以下のような型 Writer を導入しました。

// L: Monoid
type Writer[L, A] = (L, A)

Writer 圏における対象は任意の型 A で、A から A への射は A => Writer[L, A] だと定義しました。

実は、Writer 圏における射の合成をうまく活用することによって、Writer 型についての fmap メソッドを実装することができます。そのため、Writer 型は関手であって、Writer 関手と呼ばれます。

/** Writer Functor */
case class Writer[L, A](run: (L, A)):

  def fmap[B](f: A => B)(using Monoid[L]): Writer[L, B] =
    (identity[Writer[L, A]] _ >=> (a => Writer.pure[L, B](f(a))))(this)

identity[Writer[L, A]] >=> が使えることに驚くかもしれませんが、うまくいきます。>=>A => Writer[L, B] の形をした関数が持つメソッドです。identity[Writer[L, A]]: Writer[L, A] => Writer[L, A] はこの形をしているので、>=> を呼び出すことができます。

Reader 関手

前回の記事では型 AR => A に対応させ、関数 A => BR => B に引き上げる Reader 関手を考えました。

/** Reader functor */
given [R]: Functor[Function1[R, *]] with
  def fmap[A, B](f: A => B): (R => A) => (R => B) =
    f compose _

この型構築子 Function1[_, _] は2つの型パラメータを持つので、双関手の候補であると考えられます。

ここでは、Function1 を双関手のインスタンスとして実装できるかどうかについて考えていきましょう。

Function1 は双関手か?

Bifunctorインスタンスでは、bimap を実装する必要があります。ただ、bimapfirst および second を実装することによって、これらを組み合わせて実装することができます。

trait Bifunctor[F[_, _]]:

  /** Morphism mappings for Bifunctor */
  def bimap[A, B, C, D](f: A => C)(g: B => D): F[A, B] => F[C, D]

  def first[A, B, C](f: A => C): F[A, B] => F[C, B] = bimap(f)(identity[B])
  def second[A, B, D](g: B => D): F[A, B] => F[A, D] = bimap(identity[A])(g)

まず、Bifunctorsecond メソッドは、前章でみた Reader 関手の射関数です。

/** Reader bifunctor ? */
given Bifunctor[Function1] with
  override def second[A, B, C, D](g: B => D): (A => B) => (A => D) =
    fab => g compose fab

では、first メソッドはどうでしょうか?

def first[A, B, C](f: A => C)(fa: A => B) => (C => B) = ???

シグネチャを見ると分かる通り、関数 A => CA => B の組み合わせでは C => B を構成することができません。

first メソッドを定義できない、すなわち射関数 bimap を定義できないことから、Function1 は双関手でないと言えます。

Function1 に対して first メソッドを定義する

前項で見た通り、関数 A => CA => B からは C => B を構成することはできませんでした。

override def first[A, B, C](f: A => C): (A => B) => (C => B) = ???

しかしながら、関数 f: A => C の矢印を反転させて oppF: C => A を受け取れば、first メソッドを定義できます。

override def first[A, B, C](oppF: C => A): (A => B) => (C => B) = fa => fa compose oppF

少し言い換えると、直積圏における第1要素の射の矢印を入れ替えれば、第1要素の射を Function1 に関する射に引き上げることができました。

さて、ある圏において、対象はそのままで射の矢印を入れ替えたものを、その圏の双対圏と呼ぶことができましたね。これはすなわち、Function1 が Scala の双対圏と Scala 圏の直積圏から Scala 圏への関手となっていると言うことができます。first メソッドは、Scala の双対圏からの射関手であると言えます。

共変関手と反変関手

一般に、ある圏 $\mathcal{C}$ の双対圏 ${\mathcal{C}}^{op}$ からある圏 $\mathcal{D}$ への関手のことを反変関手 (contravariant functor) と呼びます。

一方で、これまで話してきた標準の関手は共変関手 (covariant functor) と呼ばれます。

共変関手と同様に、反変関手の型クラス Contravariant は以下のように定義されます。対象関数 F[_] は反変関手と同じです。射関数 contramap は、射 B => AF に関する射 F[A] => F[B] に引き上げます。

// Contravariant functor
trait Contravariant[F[_]]:
  def contramap[A, B](f: B => A): F[A] => F[B]

Function1 の第1引数に対して Contravariantインスタンスを以下のように実装できます。型の変数は違うものの、先ほどの見た first メソッドと同じ実装になっているはずです。

/** Reader contravariant functor */
given [R]: Contravariant[Function1[*, R]] with
  def contramap[A, B](f: B => A): Function1[A, R] => Function1[B, R] =
    _ compose f
override def first[A, B, C](oppF: C => A): (A => B) => (C => B) = fa => fa compose oppF

Profunctor

共変関手と反変関手の概念を用いると、型構築子 Function1[_, _] は第1引数に関して反変であり、第2引数に関して共変であると言われます。

このように、1つ目の型パラメータが反変で2つ目の型パラメータが共変であるような型構築子は、Profunctor と呼ばれます。Profunctor も関手であって、対象関数は F[_, _] です。射関数 bimap は、第1引数 C => A を反変関手のように引き上げ、第2引数 B => D を共変関手のように引き上げることによって F[A, B] => F[C, D] に引き上げます。

// Profunctor
trait Profunctor[F[_, _]]:

  def bimap[A, B, C, D](f: C => A)(g: B => D): F[A, B] => F[C, D]

先ほど見たように、Function1Profunctor です。Function1Profunctorインスタンスは、以下のように定義されます。

/** Reader profunctor */
given Profunctor[Function1] with
  def bimap[A, B, C, D](f: C => A)(g: B => D): (A => B) => (C => D) =
    g compose _ compose f

なお、Profunctor の一般的な定義は双対圏を用いて与えられます:

定義:Profunctor
圏 $\mathcal{C}$ から $\mathcal{D}$ への Profunctor とは、$\mathcal{D}$ の双対圏 ${\mathcal{D}}^{op}$ と $\mathcal{C}$ の直積圏 ${\mathcal{D}}^{op} \times \mathcal{C}$ から集合圏 Set への関手です。

Scala 圏は集合圏の拡張であるため、Scala 圏の双対圏と Scala 圏の直積から Scala 圏への関手を Profunctor と定義することができます。

Hom 関手

最後に、圏論における重要な概念である Hom 集合 (hom set) について紹介します。一般に、ある圏 $\mathcal{C}$ の Hom 集合は、$\mathcal{C}$ の全ての射の集まり $\mathcal{C}(a, b)$ ($a$ と $b$ は $\mathcal{C}$ における任意の対象)として定義されます。

Scala 圏における Hom 集合は、射 $a \to b$ の集まり、すなわち Function1[A, B] です。Function1[A, B] を構成する操作は Profunctor で、Scala の双対圏と Scala 圏の直積圏から Scala 圏への関手のことでした。

これと同様に、Hom 集合を構成する操作も Profunctor であって、Hom 関手 と呼ばれます。Profunctor として捉えると、ある圏 $\mathcal{C}$ の Hom 関手とは、直積圏 ${\mathcal{C}}^{op} \times \mathcal{C}$ から集合圏 Set への関手であると考えられます。

${\mathcal{C}}^{op} \times \mathcal{C}$ の射 $(F^{op}: e \to a, g: b \to f)$ を Hom 関手によって引き上げると、Hom 集合 $\mathcal{C}(a, b)$ から Hom 集合 $\mathcal{C}(e, f)$ の集合に変換されます。ここで、$\mathcal{C}(a, b)$ のなんらかの要素 $h$ (これは射 $a \to b$ の一つ)をとってきて

$g \cdot h \cdot F^{op}$

とすると、これは射 $e \to f$ となり、$\mathcal{C}(e, f)$ の要素が得られます。

まとめ

  • 2つの圏 $\mathcal{C}$ と $\mathcal{D}$ の直積圏とは、対象・射・射の合成それぞれを $\mathcal{C}$ と $\mathcal{D}$ のペアとして定義した圏のことである。
    • 対象:(A, B)
    • 射:(A => C, B => D)
    • 射の合成:((C => E) compose (A => C), (D => H) compose (B => D))
  • 双関手は型構築子に2つの型パラメータを持った関手である。
    • 直積圏からの関手と定義される:$\mathcal{C} \times \mathcal{D} \to \mathcal{E}$
    • 積関手である Tuple2 は、双関手である。
    • 余積関手である Either は、双関手である。
  • Writer も関手である。
  • Function1 は型構築子に2つの型パラメータを持つが、双関手ではない。
  • 双対圏からの関手を反変関手という。
  • 標準の圏からの関手を共変関手という。
  • 第1引数が反変で、第2引数が共変な関手を Profunctor という。
    • Function1 は Profunctor である。
    • Profunctor は、双対圏との直積圏から集合圏への関手として定義される: ${\mathcal{C}}^{op} \times \mathcal{D} \to Set$
  • ある圏 $\mathcal{C}$ の Hom 集合とは、$\mathcal{C}$ の全ての射の集まりのことである。
    • Scala 圏の Hom 集合は Function1[A, B] である。
    • 2つの対象 $a$ と $b$ から Hom 集合 $\mathcal{C}(a, b)$ を作る操作は関手であり、Hom 関手と呼ばれる。
    • Hom 関手は、Profunctor である:${\mathcal{C}}^{op} \times \mathcal{C} \to Set$

Scala プロジェクトのドキュメントを mdoc で書き、Honkit と GitHub Actions を使ってGitHub Pages に自動デプロイする

Scala のプロジェクトのドキュメントを mdoc で書いて、Honkit と GitHub Pages でドキュメントを公開し、GitHub Actions でデプロイを自動化する、までをやってみました。

サンプルコード

本記事で作成したサンプルコードです。

github.com

サンプルコードは下記の GitHub Pages にデプロイされています。

taretmch.github.io

プロジェクトの作成

まず、ベースとなる Scala プロジェクトを作成します。

ドキュメントを作りたい既存のプロジェクトがあれば、そのプロジェクトを用いてください。

% sbt new sbt/scala-seed.g8
[info] welcome to sbt 1.3.13 (Oracle Corporation Java 10.0.1)
[info] loading settings for project global-plugins from idea.sbt ...
[info] loading global plugins from /Users/tomoki/.sbt/1.0/plugins
[info] set current project to scala (in build file:/Users/tomoki/src/scala/)
[info] set current project to scala (in build file:/Users/tomoki/src/scala/)

A minimal Scala project.

name [Scala Seed Project]: honkit-seed

Template applied in /Users/XXX/src/scala/./honkit-seed

mdoc で Scala プロジェクトのドキュメントを書く

プロジェクトのドキュメントをそのまま Markdown で書くのもいいですが、Scala のドキュメントの場合 mdoc を使うと便利です。

これを使うと、以下のような Markdown ファイルを作成したとき

sbt mdoc のようなコマンドを実行すると式の評価結果が表示されます。

通常のコンパイルと同様のエラーを出してくれます。REPL で実行したコードを貼り付ける、のような作業をしなくて良いので、重宝しています。

mdoc のインストール

mdoc は、sbt のプラグインです。早速インストールしてみましょう。

最新のインストール方法は公式ドキュメントをご確認ください。 project/plugins.sbt ファイルにプラグインを追加し build.sbt に設定を追加すれば完了です。

project/plugins.sbt
addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.2.18" )

本サンプルプロジェクトでは、 docs ディレクトリを一つのプロジェクトとし、 docs/src/main にドキュメントを作っていくことにします。docs プロジェクトに MdocPlugin を有効化して、作業ディレクトリ (mdocIn) に docs/src/main を指定し、コンパイル後のファイル (mdocOut) を docs/mdoc に置くよう指定しています。

build.sbt
lazy val docs = (project in file("docs"))
  .settings(name := "docs-seed")
  .enablePlugins(MdocPlugin)
  .settings(mdocIn := file("docs/src/main"))
  .settings(mdocOut := file("docs/mdoc"))

はじめての mdoc

簡単なドキュメントを作ってみましょう。

以下の内容で docs/src/main/README.md を作成します。

そして、sbt コンソール上の docs プロジェクトにて mdoc コマンドを実行します。

% sbt docs/mdoc

すると、docs/mdoc/README.md というファイルが生成されているかと思います。

内容は次のように、式の評価結果が表示されているのではないでしょうか。

Honkit で Markdown ファイルから静的サイトを生成する

続いて、mdoc で書いた Markdown ファイル群を、Honkit を使って静的サイトにします。

Honkit は、現在開発が終了している OSS の GitBook の後継で、Markdown ファイルから HTML, PDF, EPUB などのファイルを生成できます。

Honkit のインストール

Honkit をインストールするには NodeJS 10 以上が必要です。まずはプロジェクトを初期化します。

% yarn init

設定は適当にしちゃってください。次に、プロジェクトにパッケージを追加しましょう。

% yarn add honkit --dev

すると、package.json が以下のようになっているかと思います。

package.json
{
  "name": "honkit-seed",
  "version": "1.0.0",
  "repository": "git@github.com:taretmch/mdoc-honkit-seed.git",
  "author": "taretmch <your-mail@example.com>",
  "license": "MIT",
  "devDependencies": {
    "honkit": "^3.6.17"
  }
}

これで honkit のインストールは完了しました。

Honkit の設定

次に、Honkit の設定を書いていきます。Honkit の設定ファイルは GitBook と同様 book.json です。静的サイトの生成に使用する Markdown ファイルのパス(今回は ./docs/mdoc です。mdoc の出力先のディレクトリを指定しましょう)を指定し、mdoc ビルド後のファイルから静的サイトを生成するようにします。

book.json
{
  "root": "./docs/mdoc/",
  "title": "mdoc と Honkit で Scala ドキュメントを GitHub Pages に公開する",
  "author": "taretmch",
  "language": "ja"
}

Honkit には README.mdSUMMARY.md というファイルが必要なので、生成します。

% npx honkit init
warn: no summary file in this book
info: create SUMMARY.md
info: initialization is finished

静的ファイルの生成は、 honkit servehonkit build によって行います。serve は localhost でサイトを公開するので、手元でサイトを見るのに最適です。

% npx honkit serve ./ ./honkit

localhost:4000 にアクセスすると、下記画像のようなサイトが表示されます。

f:id:tomoki0606:20210324233412p:plain
Honkit で生成したサンプルページ

ただ、ディレクトリの指定などが面倒なので yarn startyarn build でできるようにしちゃいましょう。package.json に以下の設定を追加します。

package.json
  "scripts": {
    "build": "npx honkit build ./ ./honkit",
    "start": "npx honkit serve ./ ./honkit"
  },

これで、mdoc で書いたファイルを Honkit によって静的サイトにすることができました。

GitHub Actions で GitHub Pages に自動デプロイする

では、Honkit で生成した静的サイトを GitHub Pages にデプロイします。

以下のようなファイル .github/workflows/ci.yml を作成します [1, 2, 3, 4]。設定のバリエーションについては参考リンク先をご覧ください。

.github/workflows/ci.yml
name: CI

on:
  push:
    branches:
      - master

jobs:
  deploy:
    runs-on: ubuntu-18.04
    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Setup Scala
      uses: olafurpg/setup-scala@v10
      with:
        java-version: "adopt@1.8"

    - name: Setup Node
      uses: actions/setup-node@v2
      with:
        node-version: '12.x'
    - name: Install dependencies
      run: yarn --frozen-lockfile

    - name: Build Mdoc
      run: sbt docs/mdoc
    - name: Build Honkit
      run: yarn build

    - name: Push to gh-pages
      uses: peaceiris/actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_branch: gh-pages
        publish_dir: honkit/

やっていることは以下の通りです。

  1. master ブランチにプッシュされたときに実行されることを宣言する
  2. ubuntu 上で、Scala と NodeJS をインストールする (Setup Scala, Setup Node)
  3. NodeJS の依存パッケージをインストールする (Install dependencies)
  4. mdoc を実行して Markdown ファイルを生成する (Build Mdoc)
  5. Honkit を実行して静的サイトを生成する (Build Honkit)
  6. gh-pages ブランチに honkit/ ディレクトリ下をプッシュする (Push to gh-pages)

このファイルをコミットして master ブランチに上げると、うまく GitHub Actions が走りました!

f:id:tomoki0606:20210325002049p:plain
GitHub Actions 実行中

gh-pages ブランチを確認してみると、Honkit で生成したファイルが作られていることがわかります。

f:id:tomoki0606:20210325002211p:plain
生成された gh-pages ブランチの内容

GitHub Pages の設定

最後に、GitHub リポジトリの Settings で gh-pages ブランチを GitHub Pages に公開する設定をすれば完了です。

f:id:tomoki0606:20210325002309p:plain
gh-pages ブランチを GitHub Pages に公開する設定

しばし待つと、、、サイトが公開されました!

f:id:tomoki0606:20210325002427p:plain
サイトを公開できました 🚀

下記リンクでアクセスできます。

criceta.com

おわりに

mdoc を使って書いた Scala プロジェクトのドキュメントを、Honkit によって静的サイトにし、GitHub Pages に公開しました。GitHub Actions で GitHub Pages に自動デプロイする設定を追加し、master ブランチにプッシュすれば GitHub Pages が自動で更新されるようになりました。

Scala のプロジェクトでドキュメントを作成・公開したいという方のご参考になればいいなと思い、本記事を執筆しました。よければ OSS のドキュメントを書いて、GitHub Pages に公開してみてください!

GitHub Actions の高速化や Honkit の各種設定についてここで述べられなかったので、別の機会にまとめていければと思います。 最後までお読みいただき、ありがとうございました。

参考リンク

[1] sbt Reference Manual - Setting up GitHub Actions with sbt, https://www.scala-sbt.org/1.x/docs/GitHub-Actions-with-sbt.html, 2021年3月24日閲覧.
[2] scala-text/scala_text, https://github.com/scala-text/scala_text, 2021年3月24日閲覧.
[3] GitHub Actions による GitHub Pages への自動デプロイ, https://qiita.com/peaceiris/items/d401f2e5724fdcb0759d, 2021年3月24日閲覧.
[4] Node.js のビルドとテスト, https://docs.github.com/ja/actions/guides/building-and-testing-nodejs, 2021年3月24日閲覧.

Scala プログラマのための圏論入門 (6) 関手

はじめに

これまで、圏は対象の集まりと射の集まりからなり、その例として Scala 圏 (対象は型 A、射は関数 A => B)、集合圏 (対象は集合 $A$、射は関数 $f: A \to B$)、Writer 圏 (対象は型 AA から B への射は関数 A => Writer[L, B]) などがあると説明しました。

taretmch.hatenablog.com

taretmch.hatenablog.com

ここでは、圏そのものの変換を考えます。

圏の間の対応が関手と呼ばれる概念です。関手があれば、圏の構造を維持したまま他のある圏に変換できるようになります。

本記事では、関手とは何かについて定義し、プログラミングにおける関手の例を示します。

サンプルコード

本記事のサンプルコードは、下記リポジトリに置いてあります。コンソールで動かしたい場合、リポジトリをクローンして試してみていただければと思います。

github.com

関手とは

関手 (functor) は、圏の構造を維持しながら、ある圏 $\mathcal{C}$ を別のある圏 $\mathcal{D}$ に変換する対応 $F: \mathcal{C} \to \mathcal{D}$ のことです。

関手の例としては Option 関手、List 関手、Writer 関手、モノイド準同型などがあります。モノイド準同型は、モノイド間の関手です。

圏は対象と射から構成されるので、圏を変換するには対象と射それぞれに関する対応を定義する必要があります。

Option 関手は、型 A の値を Option で包んで型 Option[A] に変換します。また、関数 A => B を関数 Option[A] => Option[B] に変換します。

同様に、List 関手は型 AList[A] に変換し、関数 A => BList[A] => List[B] に変換します。Writer 関手も型 AWriter[A] に変換し、関数 A => BWriter[A] => Writer[B] に変換します。

f:id:tomoki0606:20201213085531p:plain
関手

関手の例〜順序を保つ関手〜

数学の話になりますが、関手のわかりやすい例があるので、紹介させてください [1]。

まず、圏 $\mathcal{C}$ として、有限集合 (個数が有限個の集合) を対象とし、単射 (出力が被らない関数) が存在するかどうかという関係を射とします。つまり、集合 $A$ から $B$ への単射が存在するとき、またそのときに限って射 $A \xrightarrow{f} B$ があるとします。

f:id:tomoki0606:20201213091703p:plain
単射は、出力が被らない関数のこと

例えば、3つの集合 $A = \{a, b, c, d\}, B = \{e, g, h, i, j\}, C = \{k, l, m\}$ があるとします。$A$ から $B$ へは上図の通り単射が構成できます。一方で、$A$ から $C$ への関数はどのように構成しても出力が被ってしまう ($f(a, k), f(b, l), f(c, m), f(d, k)$ のように、必ず1つは被る) ため、$A$ から $C$ に射はありません。

f:id:tomoki0606:20201213092135p:plain
有限集合を対象とし、単射が存在するかどうかの関係性を射とする圏

この圏は言い換えれば、集合の要素数の大小を比較している圏になります。要素が多い集合から少ない集合への単射は存在しませんが、少ない集合から多い集合への単射は存在するからです。

ここで、もう1つ各自然数を対象として、大小関係 $\leq$ を射とする圏 $\mathcal{D}$ を考えます。

$\mathcal{C}$ から $\mathcal{D}$ への関手 $F$ を、有限集合についてはその要素数を対応させ、単射の存在については大小関係の存在に対応させるものとします。つまり、集合 $A$ に対する $F(A)$ は $A$ の要素数を表し、$A$ から $B$ への単射が存在するとき $F(A) \leq F(B)$ とします。

f:id:tomoki0606:20201213095900p:plain
順序を保つ関手

関手 $F$ は、有限集合の要素数の比較と、単射の存在の関係性とを言い換えた表現であることがわかります。この「順序を保つ関手」のように、関手は圏を言い換える、翻訳するような操作を表します。

対象関数

圏は対象と射から構成されるので、圏を変換するには対象と射それぞれに関する対応を定義する必要があります。

関手において、ある圏の対象をある圏の対象に変換するような対応を対象関数といいます。一般に、圏 $\mathcal{C}$ から $\mathcal{D}$ への関手 $F$ は、圏 $\mathcal{C}$ の対象 $A$ を $\mathcal{D}$ の対象 $F(A)$ に対応させます。Scala 圏から Scala 圏への関手 F の対象関数は、型 A を型 F[A] に対応させます。

Option 関手の例で言うと、Option 関手は型 A を型 Option[A] に対応させています。

def objOptFunc[A]: A =>  Option[A] = Option(_)

objOptFunc(3)
// res0: Option[Int] = Some(value = 3)
objOptFunc("Hoge")
// res1: Option[String] = Some(value = "Hoge")

順序を保つ関手の例で言うと、対象関数は有限集合 $A$ からその要素数への対応です。

射関数

関手において、ある圏の射を別のある圏の射に変換するような対応を射関数といいます。一般に、圏 $\mathcal{C}$ から $\mathcal{D}$ への関手 $F$ の射関数は、圏 $\mathcal{C}$ の射 $A \xrightarrow{f} B$ を $\mathcal{D}$ の射 $F(A) \xrightarrow{F(f)} F(B)$ に対応させます。Scala 圏から Scala 圏への関手 F の射関数 fmap は、関数 A => B を関数 fmap(f): F[A] => F[B] に対応させます。

Option 関手の例で言うと、射 f: A => Bfmap(f): Option[A] => Option[B] に対応させる必要があります。この対応は、標準ライブラリにある Option#map メソッドによって実現されます:

def isEven: Int => Boolean = n => n % 2 == 0

Option(3).map(isEven)
// res2: Option[Boolean] = Some(value = false)

f:id:tomoki0606:20201213100352p:plain
Option 関手

関手性

射関数が満たすべき性質として、以下の2つがあります。この2つの性質は関手性 (functor laws) と呼ばれます。

定義:関手性
1. $\mathcal{C}$ の射 $f, g$ の合成 $g \circ f$ について $F(g \circ f) = F(g) \circ F(f)$ が成り立つこと。
fmap(g compose f)(a) == (fmap(g) compose fmap(f)) (a)
2. $\mathcal{C}$ の任意の対象 $A$ の恒等射 $id_A$ について $F(id_A) = id_{F(A)}$ が成り立つこと。
fmap(identity[A])(a) == identity[F[A]](F(a))

1つ目の性質は、関手が射の合成を保存することを意味します。

// f: isEven
// g: negate
def negate: Boolean => Boolean = b => !b

// fmap(g compose f)
OptionFunctor.fmap(negate compose isEven)

// fmap(g) compose fmap(f)
OptionFunctor.fmap(negate) compose OptionFunctor.fmap(isEven)

OptionFunctor.fmap(negate compose isEven)(Option(3)) == (OptionFunctor.fmap(negate) compose OptionFunctor.fmap(isEven))(Option(3))
// res3: Boolean = true

f:id:tomoki0606:20201213101151p:plain
合成の保存

2つ目の性質は、関手が恒等射を保存することを意味します。

// fmap(identity[A])
OptionFunctor.fmap(identity[Int])

// identity[F[A]]
identity[Option[Int]]

OptionFunctor.fmap(identity[Int])(Option(3)) == identity[Option[Int]](Option(3))
// res4: Boolean = true

関手の定義

では、関手の定義を与えましょう。一般に、関手は以下のように定義されます。

定義:関手
圏 $\mathcal{C}$ から圏 $\mathcal{D}$ への関手 (functor) $F$ とは、以下を満たす対応のことです。
  1. $\mathcal{C}$ の射 $A \xrightarrow{f} B$ を $\mathcal{D}$ の射 $F(A) \xrightarrow{F(f)} F(B)$ に対応させること。
    fmap(f: A => B): F[A] => F[B]
    
  2. $\mathcal{C}$ の射 $f, g$ の合成 $g \circ f$ について $F(g \circ f) = F(g) \circ F(f)$ が成り立つこと。
    fmap(g compose f)(a) == (fmap(g) compose fmap(f))(a)
    
  3. $\mathcal{C}$ の任意の対象 $A$ の恒等射 $id_A$ について $F(id_A) = id_{F(A)}$ が成り立つこと。
    fmap(identity[A])(a) == identity[F[A]](F(a))
    

先ほどみたように、2 番目と 3 番目は関手性を表します。

なお、圏 $\mathcal{C}$ と $\mathcal{D}$ は同じであってもよく、特に圏 $\mathcal{C}$ から圏 $\mathcal{C}$ への関手は自己関手 (endofunctor) と呼ばれます。Scala 圏における関手は全て、自己関手です。

プログラミングにおける関手

前節では、関手の定義を与えました。本節では、Scala プログラミングにおける関手を考えていきます。

Functor 型クラス

関手は Scala において、Functor 型クラスとして実装できます。Functor 型クラスは、対象関数として型構築子 F[_] をもち、射関数として fmap メソッドを持ちます。

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

この射関数 fmap は、関手性を満たす必要があります。すなわち、関数の合成を保存し、恒等射を保存しなければいけません。残念ながら型クラスの定義にこのバリデーションをかけられないので、fmap が関手性を満たすかどうかは Functorインスタンスの実装によります。

fmap メソッドは関数を引き上げる (lift)、とも言われます。関数 A => B は、fmap によって F[_] 上の関数 F[A] => F[B] に引き上げられます。

Option 関手

Functorインスタンスは、implicit value によって実装することができます。ここでは、Option についての Functorインスタンスを定義してみましょう。

Option 関手のインスタンスは、以下のように実装できます。Functor に必要なパラメータは型構築子としての Option です。また、抽象メソッドである射関数 fmap を実装する必要があります。

/** Option functor */
implicit val OptionFunctor: Functor[Option] = new Functor[Option] {
  def fmap[A, B](f: A => B): Option[A] => Option[B] = _.map(f)
}

Option 関手の fmap メソッドは Option#map メソッドと同じです。

実際にこのインスタンスを使ってみましょう。

型クラスのインスタンスは、サンプルコードの hamcat.Implicits パッケージ内においてあります。コンソールにおいて hamcat.Implicits._ をインポートすれば、インスタンスが使えるようになります。

fmapisEven (偶数かどうかを判定する関数) と Option(3) を与えると、Option(3) の中の値に isEven を適用した結果 (すなわち Some(false)) が出力されます。

import hamcat.Implicits._

OptionFunctor.fmap(isEven)(Option(3))
// res7: Option[Boolean] = Some(value = false)

なお、毎回 OptionFunctor.fmap(...) と書くのは面倒ですし、不便です。この場合、以下のようにシンタックスを定義することによって Option#fmap メソッドとして呼び出せるようになります。

implicit class FunctorOps[F[_], A](v: F[A])(implicit functor: Functor[F]) {
  def fmap[B](f: A => B): F[B] = functor.fmap[A, B](f)(v)
}
Option(3).fmap(isEven)
// res8: Option[Boolean] = Some(value = false)

これは Enrich my library パターンと言われるものです。

では、この Option 関手の fmap メソッドが関手性を満たすかどうかについて調べてみましょう。

関手性とは、以下が成り立つことでした。

fmap(g compose f)(a) == (fmap(g) compose fmap(f)) (a)
fmap(identity[A])(a) == identity[F[A]](F(a))

1つ目について、以下のテストコードで検証します。

val increment:   Int => Int     = n => n + 1
val isEven:      Int => Boolean = n => n % 2 == 0
def identity[A]: A   => A       = a => a
val none:        Option[Int]    = None

"Option functor" should "射の合成を保存する" in {
  // fmap(g compose f) == fmap(g) compose fmap(f)
  // Case: Some(1)
  assert(
    OptionFunctor.fmap(isEven compose increment)(Option(1))
      ==
    (OptionFunctor.fmap(isEven) compose OptionFunctor.fmap(increment))(Option(1))
  )
  assert(Option(1).fmap(isEven compose increment) == Option(1).fmap(increment).fmap(isEven))

  // Case: None
  assert(
    OptionFunctor.fmap(isEven compose increment)(none)
      ==
    (OptionFunctor.fmap(isEven) compose OptionFunctor.fmap(increment))(none)
  )
  assert(none.fmap(isEven compose increment) == none.fmap(increment).fmap(isEven))
}

2つ目については、以下のテストコードで検証します。

it should "恒等射を恒等射へ写す" in {
  // fmap(identity[A]) == identity[F[A]]
  // Case: Some(1)
  assert(OptionFunctor.fmap(identity[Int])(Option(1)) == identity[Option[Int]](Option(1)))

  // Case: None
  assert(OptionFunctor.fmap(identity[Int])(none) == identity[Option[Int]](none))
}

テストを実行してみると、成功しました!ここで実装した fmap は関手性を満たしてそうです。

sbt:hamcat> core/testOnly hamcat.data.FunctorOptionSpec
[info] FunctorOptionSpec:
[info] Option functor
[info] - should 射の合成を保存する
[info] - should 恒等射を恒等射へ写す
[info] Run completed in 452 milliseconds.
[info] Total number of tests run: 2
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 6 s, completed 2020/12/13 10:36:27

Reader 関手

次の例として、型 A を、A を返すような任意の関数 R => A に変換するような関手を考えます。この関手は Reader 関手と呼ばれます。

Reader 関手で重要なことは、関数も関手であるということです。関数が関手であれば、型 R を受け取って A を返すような関数 R => A があったとき、AB に変換する関数 f: A => B を与えれば R から B の関数を取得することができます。

Reader 関手のインスタンスは、以下のように実装できます。対象関数として型構築子 Function1[R, ?] を渡し、射関数 fmap を実装します。ここで、Function1 は1変数関数を表す Scala 標準ライブラリの型です。Function1[R, ?]R => ? を表します。

/** Reader functor */
implicit def Function1Functor[R]: Functor[Function1[R, ?]] = new Functor[Function1[R, ?]] {
  def fmap[A, B](f: A => B): (R => A) => (R => B) = fa =>
    f compose fa
}

(なお、Function1[R, ?] という記法は通常だとコンパイルエラーになります。サンプルコードではエラーを回避するため、typelevel 社の kind-projector [2] というコンパイラプラグインをインストールしています)

fmap メソッドは、ただ2つの関数を合成しているだけです。関数 R => A があったとき、引数として関数 A => B を受け取ると関数 R => B が返されます。

これを使うと、例えば以下のようなことができます。

// 偶数かどうか判定する関数を奇数かどうか判定する関数に変換する
isEven.fmap(negate) (3)
// res9: Boolean = true

compose が関手の射関数であるのですね。

関手の合成

圏の対応を考えるとき、その対応の合成、すなわち関手の合成を定義したいですよね。圏を対象として関手を射とするような圏を考えるのであれば、関手の合成は必須です。

Scala 圏における関手は全て自己関手なので、自己関手どうしを合成することができるのかどうかについて考えてみましょう。

例えば、2つの関手 Option と List を合成してみるとどうなるでしょうか。

対象関数は、型 Int を Option 関手によって Option[Int] に変換し、List 関手によって List[Option[Int]] に変換するものとします。

val intOptionList: List[Option[Int]] = List(Some(1), Some(3), None, Some(4))
// intOptionList: List[Option[Int]] = List(
//   Some(value = 1),
//   Some(value = 3),
//   None,
//   Some(value = 4)
// )

次に、射関数は、List 関手の fmap メソッドと Option 関手の fmap メソッドの合成 fmapC と定義します。

def fmapL[A, B]: (A => B) => List[A] => List[B] = ListFunctor.fmap
def fmapO[A, B]: (A => B) => Option[A] => Option[B] = OptionFunctor.fmap

def fmapC[A, B]: (A => B) => List[Option[A]] => List[Option[B]] = fmapL.compose(fmapO[A, B])

この fmapC メソッドを用いると、2つの関手によって包まれた型 Int 上の関数

val increment: Int => Int = _ + 1
// increment: Int => Int = <function1>

List[Option[Int]] 上の関数として引き上げることができます。

fmapC(increment)(intOptionList)
// res10: List[Option[Int]] = List(
//   Some(value = 2),
//   Some(value = 4),
//   None,
//   Some(value = 5)
// )

これは、fmap メソッドを2回呼び出すことに等しいです。

intOptionList.fmap(_.fmap(increment))
// res11: List[Option[Int]] = List(
//   Some(value = 2),
//   Some(value = 4),
//   None,
//   Some(value = 5)
// )

外側の fmap は List 関手の射関数で、内側の fmap は Option 関手の射関数です。

関手の合成によって定義された射関数 fmapC は射の合成を保存しますし、恒等射を保存します。

// fmap(g compose f) == fmap(g) compose fmap(f)
fmapC(isEven compose increment)(intOptionList) == (fmapC(isEven) compose fmapC(increment))(intOptionList)
// res12: Boolean = true

// fmap(identity[A]) == identity[F[A]]
fmapC(identity[Int])(intOptionList) == identity[List[Option[Int]]](intOptionList)
// res13: Boolean = true

したがって、関手の合成もまた、関手であることがわかります。

自己関手の場合のみを取り上げましたが、自己関手でない一般の関手に関してもこれは成り立ちます。興味があれば証明してみてください。

まとめ

  • 関手は、ある圏を、構造を維持しながら別のある圏に変換する対応のこと。
    • 関手の例として、Option、List、Reader などがある。
    • 関手は、対象 A を対象 F[A] に対応させる。
    • 関手は、射 f: A => B を射 fmap(f): F[A] => F[B] に対応させる。
  • 関手が満たす以下の性質のことを、関手性と呼ぶ。
    • 射 f, g の合成 g . f について fmap(g compose f) == fmap(g) compose fmap(f) が成り立つこと。
    • 恒等射 identity[A] について fmap(identity[A]) == identity[F[A]] が成り立つこと。
  • Reader 関手は、ある型 A に対して、A を返す任意の関数 Function1[?, A] を対応させる関手である。

参考文献

[1] 西郷 甲矢人, 能美 十三, 数学への招待シリーズ 圏論への道案内〜矢印でえがく数学の世界〜, 技術評論社, 2019.

[2] typelevel/kind-projector, https://github.com/typelevel/kind-projector

[3] Bartosz Milewski, Category Theory for Programmers, 2019, https://github.com/hmemcpy/milewski-ctfp-pdf.

Scala プログラマのための圏論入門 (5) 積と余積

はじめに

本記事では、圏に関するいくつかの普遍的な構造について学んでいきます。

普遍的な構造として、順序集合における最小値に対応する始対象、最大値に対応する終対象、始対象と終対象との関連性である双対性、直積集合に対応する積、そして積と対をなす余積について定式化します。

始対象・終対象や積は、モナドなどのプログラミングにおいて重要な構造を定義するために必要な概念を、定義するために必要です。

積や余積は特に、代数的データ型と呼ばれるタプル、ケースクラス、トレイトの一般化です。

具体的な例を通して学んでいきましょう!

サンプルコード

本記事のサンプルコードは、下記リポジトリに置いてあります。コンソールで動かしたい場合、リポジトリをクローンして試してみていただければと思います。

github.com

始対象

始対象は、順序集合における最小値に対応する構造です。例えば、自然数の集合 $\{ 0, 1, 2, ..., n, ... \}$ に対して、順序 $\leq$ における最小値は $0$ です。自然数の集合の圏において、この $0$ を始対象と言えるように始対象を定式化していきます。

始対象の定義

まず、自然数 $n$ を対象として、射の集まりを以下のように定義してみます。ただし、$(a, b)$ とは「$a$ が $b$ 以下である」を意味し、対象 $a$ から対象 $b$ への射が存在することを表すこととします。

{
  (0, 0), (0, 1), (0, 2), (0, 3), ..., (0, n), ...
          (1, 1), (1, 2), (1, 3), ..., (1, n), ...
                  (2, 2), (2, 3), ..., (2, n), ...
                          ...
}

これを図式で書くと、以下のような圏とみなせます。

f:id:tomoki0606:20201212160453p:plain
自然数の全順序集合

図式を見る通り、$0$ から任意の自然数 (対象) $n$ への順序 (射) $(0, n)$ が定義されており、一方で $0$ への順序は $(0, 0)$ のみであることがわかります。この圏における $0$ の性質を一般化すると、始対象を以下のように定義できます。

定義
始対象 (initial object) とは、圏において任意の対象への射をただ1つ持つような対象のことです。

始対象の例

自然数の集合において、$0$ から任意の自然数 $n$ への射 $(0, n)$ はただ1つ存在します。$0$ から $0$ への射 $(0, 0)$ は恒等射ですし、$0$ 以外の任意の自然数 $n$ についても $(0, n)$ が一意に存在します。

集合の圏 Set における始対象は、空集合 $\phi$ です。任意の集合 $A$ と空集合 $\phi$ に対して、 $\phi$ から $A$ への射がただ1つ存在します。

というのも、一般に、集合 $A$ から $B$ への関数は、入力 $a \in A$ に対応する $b \in B$ を一意に定める集合として定義されます。例えば、集合 $A = \{ 1, 3 \}$ から集合 $B = \{ 2, 4, 6 \}$ への関数の例として「入力の値に +1 したものを出力する」ような関数 $f: A \to B$ を考えます。この関数は、集合として書けば

$$f = \{ (1, 2), (3, 4) \}$$

と書くことができます。

このように考えれば、空集合 $\phi$ から任意の集合 $A$ への関数は空集合 $\phi$ であると言うことができ、これは明らかにただ1つだけ存在します。

では、Scala 圏 における始対象は何でしょうか。Scala 圏とは、型 A を対象とし、関数 A => B を射とするような圏です。Set空集合に対応する型は、Nothing でした(詳しくは以下の記事)。

taretmch.hatenablog.com

Scala 圏における始対象もまた、Nothing です。Nothing から任意の型 A への射は一意で存在します:

def absurd[A]: Nothing => A = { case _ => ??? }

f:id:tomoki0606:20201212162359p:plain
始対象

ちなみに、シングルトン集合は始対象ではありません。というのも、集合 { a } (a は任意の値) から空集合への射は (入力 a に対しての出力が定義されないので) 存在しないからです。また、{ a } から { true, false } への射は1つには限りません。

def f: Unit => Boolean = _ => true
def g: Unit => Boolean = _ => false

始対象の一意性

そして、始対象には、存在するなら同型を除いて一意である (unique up to isomorphism) という性質があります。これは、ただ始対象が一意に存在するというわけではなく、始対象と同型の対象を除いて一意に存在するという性質です。

同型については以下の記事で少し話しました。

taretmch.hatenablog.com

一度思い出してみましょう。対象 $A$ から対象 $B$ への射 $f$ に逆射 $f^{-1}$ が存在するとき、またそのときに限って $A$ と $B$ は同型であると言われます。つまり、同型な対象の間は相互に変換可能であることを意味します。

始対象が同型を除いて一意であるとは、任意の2つの始対象が同型であることを意味します。具体的に、2つの始対象を $I_1$ と $I_2$ として考えてみましょう。$I_1$ は始対象なので、定義より任意の対象への射をただ1つ持っていて、$I_1$ から $I_2$ への一意の射 $f: I_1 \to I_2$ が存在します。一方で、$I_2$ は始対象なので、定義より $I_2$ から $I_1$ への一意の射 $g: I_2 \to I_1$ が存在します。この2つの射を合成して $g \circ f$ を考えます。$g \circ f$ は $I_1$ から $I_1$ への射です。しかし、$I_1$ から $I_1$ への射はただ1つであり、圏の公理よりそれは恒等射です。すなわち

$$g \circ f = id_{I_1}$$

g compose f == identity[I1]

が成り立ちます。同様に $f \circ g$ を考えると、これは $I_2$ から $I_2$ への射です。$I_2$ から $I_2$ への射もただ1つであって、圏の公理よりそれは恒等射になります。

$$f \circ g = id_{I_2}$$

f compose g == identity[I2]

したがって、$f$ と $g$ は互いに逆射となっているため、2つの始対象 $I_1$ と $I_2$ は同型です。このような性質を同型を除いて一意であると言います。

始対象が複数存在するような圏をパッと思いつきませんが、始対象が複数存在するのであれば、それらは同型であると示せます。

終対象

次に、終対象についてです。

終対象は始対象と対をなす概念で、順序集合における最大値に対応する構造です。始対象の定義と同じように、終対象は以下のように定義されます。

定義
終対象 (terminal object) とは、圏において任意の対象からの射がただ1つ存在するような対象のことです。

集合の圏 Set における終対象は、シングルトン集合です。任意の集合 $A$ とシングルトン集合 $\{ a \}$ に対して、$A$ から $\{ a \}$ への射がただ1つ存在します。例えば、集合 $A = \{ 1, 2, 3, 4 \}$ から $\{ a \}$ への関数 $f$ は一意に存在します:

$$f = \{ (1, a), (2, a), (3, a), (4, a) \}$$

シングルトン集合は Unit 型に対応しており、Scala 圏における終対象は Unit 型です。任意の型 A から Unit への関数 unit がただ1つ存在します。

def unit[A]: A => Unit = _ => ()

f:id:tomoki0606:20201212174031p:plain
終対象

終対象も始対象同様、同型を除いて一意です。

2つの終対象を $T_1$ と $T_2$ とします。$T_1$ は終対象なので、定義より任意の対象からの射がただ1つ存在して、$T_2$ から $T_1$ への一意の射 $f: T_2 \to T_1$ が一意に存在します。一方で、$T_2$ は終対象なので、定義より $T_1$ から $T_2$ への一意の射 $g: T_1 \to T_2$ が存在します。この2つの射を合成して $g \circ f$ を考えます。$g \circ f$ は $T_2$ から $T_2$ への射です。しかし、$T_2$ から $T_2$ への射はただ1つであり、圏の公理よりそれは恒等射です。すなわち

$$g \circ f = id_{T_2}$$

g compose f == identity[T2]

が成り立ちます。同様に $f \circ g$ を考えると、これは $T_1$ から $T_1$ への射です。$T_1$ から $T_1$ への射もただ1つであって、圏の公理よりそれは恒等射になります。

$$f \circ g = id_{T_1}$$

f compose g == identity[T1]

したがって、$f$ と $g$ は互いに逆射であるため、2つの終対象 $T_1$ と $T_2$ は同型です。

双対性

終対象について考えるとき、終対象は始対象の対をなす概念であると言いました。

実際、終対象の定義は、始対象の定義の射の向きを変えたようなものであると思ったのではないでしょうか。

一般に、任意の圏 $\mathcal{C}$ に対して、対象はそのままで、全ての射の矢印を反転させ射の合成を再定義することによって双対圏 (opposite category) $\mathcal{C}^{op}$ を定義することができます。

例として、自然数を対象として順序 $\leq$ を射とする圏を考えます。自然数 $n$, $ m $ の間の射 $\leq$ は $n$ が $ m $ 以下であるとき $ n \xrightarrow{\leq} m $ であるとします。この圏の双対圏を考えてみると、全ての矢印が反転するので、射 ${\leq}^{op}$ は $n$ が $ m $ 以下であるとき $ n \xleftarrow{\leq^{op}} m $ となります。言い換えると、$n$ が $ m $ 以上であるとき $ n \xrightarrow{\leq^{op}} m $ となりますね。

ある圏の双対圏を考えることによって、圏の普遍的な構造を1つ構成したときその双対の構造も構成することができます。例えば、ある圏における始対象はその双対圏における終対象です。これから見る積の双対は余積です。

双対圏における構成には接頭辞 "余" ("co") がつけられることが多いです。積 (product) には余積 (coproduct) があり、モナド (monad) には余モナド (comonad) があり、極限 (limit) には余極限 (colimit) があります。ただし、矢印を2回反転させると元に戻るので、余余モナドなるものはありません。

さて、もう1つの普遍的な構造として、積 (product) について見ていきましょう。

簡単に言えば、積は2つの対象のタプルを表します。Scala において、積はタプルやケースクラスとして組み込まれています。

val intBoolTuple: (Int, Boolean) = (44, true)
// intBoolTuple: (Int, Boolean) = (44, true)
case class Pair(a: Int, b: Boolean)

val intBoolPair = Pair(44, true)
// intBoolPair: Pair = Pair(a = 44, b = true)

Scala には Product というトレイトがあり、これが積を表します。タプルやケースクラスはすべて Product を継承しています。

final case class Tuple2[@specialized(Int, Long, Double, Char, Boolean/*, AnyRef*/) +T1, @specialized(Int, Long, Double, Char, Boolean/*, AnyRef*/) +T2](_1: T1, _2: T2)
  extends Product2[T1, T2]

ケースクラスは、コンパイル時に自動的に Product をミックスインします [1]。

積の定義

積は、圏論において以下のように定義されます。

定義
圏の2つの対象 $A$ と $B$ に対して、対象 $C$ とその射 $ C \xrightarrow{ p_A } A $ および $ C \xrightarrow{ p_B } B $ の三つ組 $< C, p_A, p_B>$ が $A$ と $B$ の (product) であるとは、任意の対象 $X$ とその射 $X \xrightarrow{x_A} A$、$X \xrightarrow{x_B} B$ の三つ組 $<X, x_A, x_B>$ に対して $X$ から $C$ への一意の射 $ m $ が存在して $$ p_A \circ m = x_A, \quad p_B \circ m = x_B $$
projA compose m == xA
projB compose m == xB
が成り立つことを言います。このとき対象 $C$ を $A \times B$ と書きます。

f:id:tomoki0606:20201212180452p:plain
積の定義

なお、積は存在するなら、同型を除いて一意です。

AB の積 (A, B) について、射影 fstsnd は以下のように定義されます。

def fst[A, B]: ((A, B)) => A = _._1

def snd[A, B]: ((A, B)) => B = _._2

すなわち、積の射影は Tuple2#_1 メソッドと Tuple2#_2 メソッドです。

そして、ある型 X に対して、X から (A, B) への一意の関数 productFactorizer が存在します。

def productFactorizer[X, A, B](xA: X => A)(xB: X => B): X => ((A, B)) = x => (xA(x), xB(x))

積の例

積の例として、AStringBInt とし、XList[Int] とします。

そして、List[Int]String への射影と、Int への射影をそれぞれ以下のように定義します:

val listToString: List[Int] => String = _.toString
// listToString: List[Int] => String = <function1>

val listToInt: List[Int] => Int = _.length
// listToInt: List[Int] => Int = <function1>

このとき、List[Int] から積 (A, B) への一意の関数 listToTuple を構成できます:

val listToTuple: List[Int] => ((String, Int)) = productFactorizer(listToString)(listToInt)
// listToTuple: List[Int] => (String, Int) = <function1>

この関数に対して List(1, 2, 3, 4, 5) を与えると、String への射影 listToStringInt への射影 listToInt をそれぞれ適用したタプルが得られます。

listToTuple(List(1, 2, 3, 4, 5))
// res0: (String, Int) = ("List(1, 2, 3, 4, 5)", 5)

したがって、積 (A, B) には、A の成分と B の成分とで分解して計算できる性質があることがわかります。この性質がまさに、積の定義となっています。

実際、以下の図式は可換になります。

val list = List(1, 2, 3, 4, 5)
// list: List[Int] = List(1, 2, 3, 4, 5)

(fst compose listToTuple)(list) == listToString(list)
// res1: Boolean = true
(snd compose listToTuple)(list) == listToInt(list)
// res2: Boolean = true

f:id:tomoki0606:20201212195141p:plain
積の例

もう一つ、集合圏における積の例を見てみます [2]。集合 $A = \{ 7, 8, 9 \}$、$B = \{ a, b, c \}$、$X = \{ 1, 2, 3, 4 \}$ として、$X$ から $A$ および $B$ への射影 $x_A$ と $x_B$ を以下のようにします:

$$x_A = \{ (1, 7), (2, 8), (3, 9), (4, 7) \}$$

$$x_B = \{ (1, a), (2, b), (3, c), (4, c) \}$$

そして、積 $A \times B$ から $A$ および $B$ への射影 $p_A$ と $p_B$ をそれぞれ、第1要素を取得する関数と第2要素を取得する関数として考えます。このとき、上の射影 $x_A$ と $x_B$ のペアは $X$ から $A \times B$ への関数 $f$ に一意に読み替えることができます。

$$f = \{ (1, (7, a)), (2, (8, b)), (3, (9, c)), (4, (7, c)) \}$$

この例について、以下の図式は可換になります。

f:id:tomoki0606:20201212183202p:plain
集合圏における積の例

積のモノイド的な性質 

2つの対象 $A$ と $B$ の積 $A \times B$ は、見方を変えれば二項演算と考えることができます。

Scala 圏における積は二項演算として、数の乗算に対応します。二項演算としての性質を少し見ていきましょう。

まず、結合律について。3つの型 A B C の積は、タプルをネストさせることによって定義できます。

((A, B), C)
(A, (B, C))

f:id:tomoki0606:20201212184115p:plain
A, B, C の積(タプル)

これら2つのタプルは、同型です。同型射 (つまり、逆射を持つ射) は、以下のように定義できます。

def isomorTuple1[A, B, C]: (((A, B), C)) => ((A, (B, C))) = {
  case ((a, b), c) => (a, (b, c))
}

def isomorTuple2[A, B, C]: ((A, (B, C))) => (((A, B), C)) = {
  case (a, (b, c)) => ((a, b), c)
}

isomorTuple1(((44, "hoge"), true))
// res10: (Int, (String, Boolean)) = (44, ("hoge", true))
isomorTuple2((44, ("hoge", true)))
// res11: ((Int, String), Boolean) = ((44, "hoge"), true)

なお、これらと同型である3-タプル (A, B, C) も積です。

def isomorTuple3[A, B, C]: (((A, B), C)) => ((A, B, C)) = {
  case ((a, b), c) => (a, b, c)
}

def isomorTuple4[A, B, C]: ((A, B, C)) => (((A, B), C)) = {
  case (a, b, c) => ((a, b), c)
}

以上のように、型の積について結合律が満たされることを確認できました。

では次に、単位律について考えてみます。

二項演算としての積における単位律とは、単位元の型 U に対して型 AU の積 UxA および AxU が、A と同型になることを言います。

型の積における単位元は、Unit 型です。以下のような同型射が存在するので、タプル (Unit, A)(A, Unit) はどちらも A と同型です。

def isomorProduct1[A]: A => ((Unit, A)) = a => ((), a)
def isomorProduct2[A]: ((A, Unit)) => A = {
  case (a, ()) => a
}

def isomorProduct3[A]: A => ((A, Unit)) = a => (a, ())
def isomorProduct4[A]: ((A, Unit)) => A = {
  case (a, ()) => a
}

以上のことから、型の積はモノイドの性質を満たしていることがわかります。

このようなことから、Scala 圏はモノイダル圏であると言われます。※モノイダル圏の厳密な定義はここでは与えません。

余積

最後に、積の双対概念である余積についてです。

余積の定義

余積は、積の定義において射を反転させたものです。すなわち、以下のように定義されます。

定義
圏の2つの対象 $A$ と $B$ に対して、対象 $C$ と射 $A \xrightarrow{i_A} C$、$B \xrightarrow{i_B} C$ の三つ組 $< C, i_A, i_B >$ が $A$ と $B$ の余積 (coproduct) であるとは、他の同様の三つ組 $< X, x_A, x_B >$ に対して $C$ から $X$ への一意の射 $x$ が存在して $$ x \circ i_A = x_A $$ $$ x \circ i_B = x_B $$
x compose injA == xA
x compose injB == xB
が成り立つことを言います。このとき対象 $C$ を $A+B$ と書きます。また、$i_A$ および $i_B$ を入射 (injection) と呼びます。

f:id:tomoki0606:20201212185003p:plain
余積の定義

積は Scala においてタプルやケースクラスとして表すことができますが、余積は Scala において Either によって表すことができます。

AB の余積 Either[A, B] について、射 $i_A$ と $i_B$ は以下のように定義されます。

def injA[A, B](a: A): Either[A, B] = Left(a)
def injB[A, B](b: B): Either[A, B] = Right(b)

すなわち、余積の入射は Left.apply メソッドと Right.apply メソッドです。

そして、ある型 X に対して、Either[A, B] から X への一意の関数 factorizer が存在します。

def factorizer[X, A, B](xA: A => X)(xB: B => X): Either[A, B] => X = {
  case Left(a) => xA(a)
  case Right(b) => xB(b)
}

余積の例

余積の例として、対象 a を型 string とし、対象 b を型 int とし、対象 x を型 boolean とします。

そして、string から boolean への入射と、int からの入射をそれぞれ以下のように定義します:

val strtobool: string => boolean = _.contains("a")
// strtobool: string => boolean = <function1>
val iseven: int => boolean = _ % 2 == 0
// iseven: int => boolean = <function1>

このとき、余積 either[string, int] から型 boolean への一意の関数 eithertobool を構成できます。この関数は、string については文字列 "a" を含むかどうか判定し、int が偶数かどうかを判定する関数です。

val eithertobool: either[string, int] => boolean = coproductfactorizer(strtobool)(iseven)
// eithertobool: either[string, int] => boolean = <function1>

この関数に対して string および int を与えると、それぞれに対して関数が適用されます:

eithertobool(left("abcdefg"))
// res6: boolean = true
eithertobool(right(3))
// res7: boolean = false

either について、以下の図式は可換になります。

f:id:tomoki0606:20201212215405p:plain
余積としての Either

余積のモノイド的な性質

積は、乗算に対応する二項演算と考えることができました。

Scala 圏において、余積 Either[A, B] は加算に対応する二項演算と考えることができます。

余積における単位元Nothing 型です。

def isomorCoproductLeft[A]: A => Either[A, Nothing] = a => Left(a)
def isomorCoproductLeftInv[A]: Either[A, Nothing] => A = {
  case Left(a)  => a
  case Right(_) => ??? // Right は Nothing なので来ない
}

isomorCoproductLeft("Oh my god!")
// res20: Either[String, Nothing] = Left(v = "Oh my god!")
isomorCoproductLeftInv(Left("Oh my god!"))
// res21: String = "Oh my god!"
def isomorCoproductRight[B]: B => Either[Nothing, B] = b => Right(b)
def isomorCoproductRightInv[B]: Either[Nothing, B] => B = {
  case Right(b) => b
  case Left(_)  => ??? // Left は Nothing なので来ない
}
isomorCoproductRight("Good")
// res22: Either[Nothing, String] = Right(v = "Good")
isomorCoproductRightInv(Right("Good"))
// res23: String = "Good"

以上のことから、余積もまたモノイドの性質を満たしていることがわかります。

まとめ

  • 始対象とは、圏において任意の対象への射をただ1つ持つような対象のことである。
    • 集合圏における始対象は空集合Scala 圏における始対象は Nothing である。
    • 始対象は同型を除いて一意である。
  • 終対象とは、圏において任意の対象からの射がただ1つ存在するような対象のことである。
    • 集合圏における終対象はシングルトン集合、Scala 圏における終対象は Unit である。
    • 終対象は同型を除いて一意である。
  • ある圏の双対圏とは、その圏の対象はそのままに、射の向きを反転させた圏のことである。
    • ある圏の始対象はその双対圏の終対象であり、ある圏の終対象はその双対圏の始対象である。
  • 2つの対象 AB の積 A x B の例は、タプルやケースクラスである。
    • Unit を単位元する積について、Scala 圏はモノイダル圏となる。
  • 2つの対象 AB の余積 A + B の例は、Either である。
    • 余積は積の双対概念である。
    • Nothing を単位元とする余積について、Scala 圏はモノイダル圏となる。

おわりに

本記事では、圏の普遍的な構造を導入しました。これらの概念は以降の概念の説明によく用いられるので、忘れたときは適宜思い出してください。

個人的に、積と余積の定義が難しいなと感じます。もっとわかりやすい解説や例があれば、Twitter やコメントなどで教えていただきたいです。

次回は関手について学びます!ではまた。

参考文献

[1] Scalaクラスメモ, http://www.ne.jp/asahi/hishidama/home/tech/scala/class.html , 2020年11月13日閲覧.

[2] 雪田修一, 圏論入門 Haskell で計算する具体例から, 日本評論社, 2020.

[3] Bartosz Milewski, Category Theory for Programmers, 2019, https://github.com/hmemcpy/milewski-ctfp-pdf.

Writer 圏における射の合成と、恒等射と、関手について[Scala]

はじめに

今日は社内で圏論勉強会を開催しました(本日のテーマは双関手と Profunctor でした)。

その際に、Writer 圏の射の合成と恒等射からWriter 関手を構成することができると話しました。

Writer 圏の射と関数の違いについて議論が白熱し、Writer 圏についての補足が別途必要だなと思ったので、この記事に残します。

まず、Writer 圏とは?

Writer 圏については以下の記事で紹介しておりますので、こちらも併せてご覧ください。

taretmch.hatenablog.com

詳しくは上記記事を見ていただきたいですが、本記事でもざっくりと説明しますと、Writer 圏は、あらゆる関数の適用に際してロギングをとるような圏のことをいいます。

まず、Writer のデータ構造は LA のタプルです:

case class Writer[L, A](run: (L, A))

Writer 圏における対象は型 A で、対象 A から対象 B への射は関数 A => Writer[L, B] と定義されます。ここで、L はロギングのデータ型で、モノイドである必要があります。

この系が圏であるためには射の合成が定義されている必要があって、これは >=> 演算子によって与えられます:

/** Composition of morphism */
implicit class WriterOps[L, A, B](m1: A => Writer[L, B])(implicit sg: Semigroup[L]) {
  def >=>[C](m2: B => Writer[L, C]): A => Writer[L, C] = a => {
    val (logB, b) = m1(a).run
    val (logC, c) = m2(b).run
    Writer((logB |+| logC, c))
  }
}

射の合成は、ログを結合し、射 A => B, B => C を合成するものだとわかります。なお、|+| は独自の二項演算子で、Semigroup#combine メソッドのエイリアスです。

また、恒等射は以下のように与えられます:

/** Identity */
def pure[L, A](a: A)(implicit mn: Monoid[L]): Writer[L, A] = Writer((mn.empty, a))

ログについてはモノイドの単位元を渡しており、対象 A についてはその値をそのまま返します。モノイドの単位元を渡すことによって、既存のログをそのまま返せるようになります。

Writer 圏における射、型と関数、射の合成を図式で書くと、以下のようになります。

f:id:tomoki0606:20201212152529p:plain
Writer 圏において、射と関数は異なる意味を持つ。そのため、射の合成と関数の合成も異なる意味を持つ

Scala 圏において、対象は型であって、射は関数でした。そのため射と関数は全く同一になるというイメージがありますが、Writer 圏の場合はそうではありません。

Writer 圏において、対象は Scala 圏と同じく型ですが、射は関数の中でも、A => Writer[L, B] と、返り値の型が Writer[L, _] によってラップされている関数になります。なお、関数 A => Writer[L, B] は型 A から Writer[L, B] への射なのではなく、これは対象 A から対象 B への射になります。

勉強会にて議論が白熱したのが、射と関数が上の図のように異なることです。射と関数が混同してわけわからなくなると思うので、射と関数は別物!と意識してみてください。

Writer 圏における射の合成と恒等射を用いて、関手を構成する

さて、Writer 圏の定義に基づいて、射の合成と恒等射を定義しました。

これらを用いて、データ型 WriterFunctorインスタンスを実装することができます。FunctorScala 圏における自己関手の型クラスであって、その抽象メソッドとして射関数 fmap を持ちます:

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

Writerfmap メソッドは、Writer 圏の射の合成 >=> と恒等射 pure によって実装できます。以下がその実装です。

/** Writer */
case class Writer[L, A](run: (L, A)) {

  /** Functor mapping */
  def fmap[B](f: A => B)(implicit mn: Monoid[L]): Writer[L, B] =
    (identity[Writer[L, A]] >=> (a => Writer.pure[L, B](f(a))))(this)
}

fmap によって関数 f: A => B を Writer 上の関数 Writer[L, A] => Writer[L, B] に引き上げたいとします。そのためには、identity[Writer[L, A]]: Writer[L, A] => Writer[L, A] によって Writer 圏における射 Writer[L, A] -> A を作り、この射と関数 a => Writer.pure[L, B](f(a)): A => Writer[L, B] (これは A から B への射)を合成させれば、結果として Writer[L, A] から Writer[L, B] への関数が得られます。

f:id:tomoki0606:20201212011638j:plain

fmap の実装を関数の型のみに着目して考えると、以下のようになります。

(Writer[L, A] => Writer[L, A]) >=> (A => Writer[L, B]): Writer[L, A] => Writer[L, B]

一方で、Writer 圏における射のみに着目して考えると、以下のようにシンプルになります。

$$(A \to B) \circ (Writer[L, A] \to A)$$

上記2つの表現は見た目は違えど、同じ意味を表します。

以上のことから、Writer 圏における射の合成と恒等射によって Writer 型の関手を実装できるので、Writer クラスは Writer 関手であると言えます。

おわりに

Writer 関手というと、Scala 圏から Writer 圏の関手だと勘違いしていたのですが、Writer というデータ型に関しての Scala 圏における自己関手、であるんですね。

Writer 圏って見かけによらず複雑で難しいですね。

CTFP は、どうしてこれを4章に持ってきたんだろう...大きな疑問。

Scala プログラマのための圏論入門 (4) Kleisli 圏の例

はじめに

ここまでで、型と純粋関数を圏としてモデル化する方法をみてきました。その際に計算効果を持つ非純粋な関数をモデル化するための概念として、モナドが出てきましたね。

ここでは、計算効果についてイメージを深めるため、計算効果の例をモナドの概念を出さない程度に考えていきます。

前回の記事

taretmch.hatenablog.com

サンプルコード

本記事のサンプルコードは、下記リポジトリに置いてあります。コンソールで動かしたい場合、リポジトリをクローンして試してみていただければと思います。

github.com

ロギング関数の合成

計算効果の例として、プログラムの実行をロギングする関数について考えます。

例えば、論理値の否定を返す関数 negate の実行と、整数が偶数であるかを判別する関数 isEven の実行のログをとりたいとします。 negate の処理自体は論理値を論理値に変換するものなので、型は Boolean => Boolean になりそうです。しかし、ここではログも一緒に出力したいので、返り値の型を (String, Boolean) として定義してみます。 isEven 関数も同様です。

def negate: Boolean => (String, Boolean) = n => ("negate ", !n)
def isEven: Int => (String, Boolean) = n => ("isEven ", n % 2 == 0)

ここで、 negateisEven を合成して、整数が奇数であるかを判別する関数 isOdd を作ることを考えます。2つの関数 f: A => Bg: B => C を合成するには、一方の出力の型、つまり f の出力 B、ともう一方の入力の型、つまり g の入力 B が一致する必要がありました。ところが、 negateisEven は型が一致せず、通常の方法では合成できません。代わりに、以下のように定義するとうまくいきそうです:

def isOdd: Int => (String, Boolean) = n => {
  val (log1, res1) = isEven(n)
  val (log2, res2) = negate(res1)
  (log1 + log2, res2)
}

実は、今考えたような構造は Writer 圏と呼ばれます。次の節では Writer 圏を定式化していきます。

Writer 圏

先ほど見たロギング関数の合成の例を圏論の言葉に落とし込むと、Writer 圏というものを考えることができます。

Writer 圏の対象と射

Writer 圏では、次のようなデータ型 Writer を導入します。

type Writer[A] = (String, A)

すなわち、型 Writer[A] はログを表現する型 String と計算の出力 A のタプルです。

この圏では、対象として型を採用し、対象 A から B への射として以下のようにラップされた関数を採用します:

f: A => Writer[B]

f:id:tomoki0606:20201115140912p:plain
Writer 圏における射

2つの関数 negate: Boolean => (String, Boolean)isEven: Int => (String, Boolean) は、以下のように書き換えれば Writer 圏における射とみなせます。

def negate: Boolean => Writer[Boolean] = n => ("negate ", !n)
def isEven: Int => Writer[Boolean] = n => ("isEven ", n % 2 == 0)

f:id:tomoki0606:20201115141029p:plain
Writer 圏における射の例

Writer 圏における射の合成

Writer 圏における射の合成は、 isOdd 関数の定義を抽象化したものと考えることができます。関数 f: A => Writer[B]、関数 g: B => Writer[C] を合成した関数 f >=> g: A => Writer[C] を定義してみましょう。

implicit class WriterOps[A, B](f: A => Writer[B]) {
  def >=>[C](g: B => Writer[C]): A => Writer[C] =
    a => {
      val (log1, b) = f(a)
      val (log2, c) = g(b)
      (log1 + log2, c)
    }
}

>=> 演算は fish 演算子と呼ばれるもので、引数で受け取った2つの関数を合成した関数を返します。

val isOdd = isEven >=> negate
// isOdd: Int => (String, Boolean) = <function1>

isOdd(3)
// res0: (String, Boolean) = ("isEven negate ", true)

f:id:tomoki0606:20201115141203p:plain
Writer 圏における射の合成

これで、Writer 圏における関数合成は定義できました!

しかし、>=> はただ関数の合成を定義したものです。実際に Writer 型のインスタンスがあったときには >=> を使うことができず、不便です。つまり、関数 f: A => Writer[B] を型 Writer[A] に対して実行するには、別途定義する必要があります。

そのような方法を、Scala プログラマにはおなじみの flatMap として定義することができます。

implicit class WriterOps2[A](v: Writer[A]) {
  def flatMap[B](f: A => Writer[B]): Writer[B] = {
    val (log1, a) = v
    val (log2, b) = f(a)
    (log1 + log2, b)
  }
}

この flatMap メソッドを使うことによって、isEven メソッドを適用したあとに negate メソッドを適用する、のような書き方ができるようになります。これは isOdd メソッドと同じ結果を返します。

isEven(3).flatMap(negate(_))
// res1: (String, Boolean) = ("isEven negate ", true)
isOdd(3)
// res2: (String, Boolean) = ("isEven negate ", true)

Writer 圏は圏の公理を満たすか

ここで一度、圏が満たすべき性質に立ち戻ってみましょう。1つ目は関数の合成が結合律を満たすこと、2つ目は任意の対象に対して恒等射が存在することでした。

この合成 >=> は、結合律を満たすでしょうか?

タプルの各要素に分解して考えてみましょう。1つ目の要素の合成は、 String の連結です。これは自明に結合律を満たしますね。

// str1, str2, str3 はいずれも String 型とする
str1 ++ str2 ++ str3 == (str1 ++ str2) ++ str3 == str1 ++ (str2 ++ str3)

2つ目の要素の合成は、標準的な関数合成です。これも結合律を満たしますね。

h compose g compose f == (h compose g) compose f == h compose (g compose f)

>=> の内部では文字列の連結と標準的な関数合成をしているだけなので、>=> は結合律を満たします!

次に、恒等射の存在について考えてみましょう。これまでの議論に恒等射は出ていないので、新しく定義します。

Writer 圏における恒等射の性質として、1つ目の要素であるログ文字列をそのまま返し、2つ目の要素である計算結果もそのまま返す必要があります。すなわち、1つ目の要素に空文字列を渡し、2つ目の要素には引数と同じ値を渡せば良いです。したがって、恒等射 pure は以下のように定義できます。

def pure[A](a: A): Writer[A] = ("", a)

Int 型の数 3 に pure[Int]: Int => Writer[Int] を適用すると元の値と空のログが返ってくるかどうか、確認します。

pure(3)
// res3: (String, Int) = ("", 3)

型としては Int から Writer[Int] への変換になっていますが、Writer 圏においてはこれが対象 Int から Int への射であると定義しました。

Writer 型の値に pure を適用するには、flatMap メソッドを用います。

("apply ", 3).flatMap(pure(_))
// res4: (String, Int) = ("apply ", 3)

Writer 型については元の値が返ってくることを確認できました。pure は任意の型 A に対して存在するので、pure は恒等射です。したがって、Writer 圏は単位律を満たします。

以上のことから、ここで定義した Writer 圏は圏であるといえます。Writer 圏についてまとめると、次の図のようになります。

f:id:tomoki0606:20201115171432p:plain
Writer 圏の例

Writer 圏のより一般的な定義

これはおまけですが、ここで定義した Writer 圏はもう少し抽象的にすることができます。Writer 圏が結合律と単位律を満たすためには、文字列の連結が結合律を満たし、文字列の連結に関する単位元 "" が定義されている必要がありました。つまり、ログを表現する型がモノイド的な性質を満たしていれば、Writer 圏は圏として定義できるはずです。

したがって、型 Writer は、以下のように定義していたものを

type Writer[A] = (String, A)

以下のようにパラメータ化して定義できます。

type Writer[L, A] = (L, A)

ただし、型 L はモノイドであることが条件です。

これまでの議論を元に、Writer というデータ型を以下のように定義できます。Writer のデータそのものはタプル (L, A) です。Writer 圏において関数を適用するための flatMap メソッド、関数合成のための >=> メソッド、恒等射の pure メソッドが定義されています。

case class Writer[L, A](run: (L, A)) {

  def flatMap[B](m2: A => Writer[L, B])(implicit logSemigroup: Semigroup[L]): Writer[L, B] = {
    val (log1, a) = run
    val (log2, b) = m2(a).run
    Writer((log1 |+| log2, b))
  }
}

object Writer {

  /** Identity */
  def pure[L, A](a: A)(implicit logMonoid: Monoid[L]): Writer[L, A] = Writer((logMonoid.empty, a))

  /** Morphism */
  implicit class WriterOps[L, A, B](m1: A => Writer[L, B])(implicit logGroup: Semigroup[L]) {
    def >=>[C](m2: B => Writer[L, C]): A => Writer[L, C] =
      a => m1(a).flatMap(m2(_))
  }
}

Kleisli 圏

前節で議論した Writer 圏は、実は Kleisli 圏 (Kleisli category) と呼ばれるものの例です。Kleisli 圏はモナドに基づく圏ですので、厳密な定義はモナドを学んでから見ていきます。

Kleisli 圏では、対象を型、型 A から型 B への射を型 F に対して A => F[B] である関数とします。ここで F は自己関手という概念に対応することを後の章で見ていきます。F の例としては ListOption などがあります。それぞれの Kleisli 圏において、射の合成は独自の方法で実装されます。これはまさに1つの自由度であり、ロギングやエラーなどの計算効果に対して表示的意味論を与えることができます。

Cats における Writer と Kleisli

Scala関数型プログラミングを行うためのライブラリとして Cats というライブラリがあります。

Cats の有名なデータ型として EitherTSemigroup などがありますが、 WriterKleisli についても定義されています。本節では、Cats の Writer と Kleiseli に触れて、Writer 圏と Kleisli 圏へのイメージを深めていきます。

Cats のインストール

2020年10月23日現在、Cats の最新バージョンは 2.2.0 です。sbt を用いて Cats を使えるようにするには、 build.sbt の libraryDependencies に以下を追加します。

libraryDependencies ++= Seq(
  "org.typelevel" %% "cats-kernel" % "2.2.0",
  "org.typelevel" %% "cats-core"   % "2.2.0"
)

Writer

Cats において、Writer は以下のように定義されています。

type Writer[L, V] = WriterT[Id, L, V]
object Writer {
  def apply[L, V](l: L, v: V): WriterT[Id, L, V] = WriterT[Id, L, V]((l, v))

  def value[L: Monoid, V](v: V): Writer[L, V] = WriterT.value(v)

  def tell[L](l: L): Writer[L, Unit] = WriterT.tell(l)

  def listen[L, V](writer: Writer[L, V]): Writer[L, (V, L)] =
    WriterT.listen(writer)
}

Writer[L, V] の実体は WriterT[Id, L, V] です。WriterTシグネチャは以下のようになっています。

final case class WriterT[F[_], L, V](run: F[(L, V)])`

ここで、F はタプル (L, V) をラップするような型を表します。例えば ListOption などがあります。 Writer の定義では FId を渡していました。 Id[A] は型パラメータ A に対して A そのものを表します。したがって、Writer[L, V] はタプル (L, V) そのものであると言えます。この結論は、前節で見た Writer[L, A] の定義と同じですね。

まず、Writerインスタンスを作ってみましょう。

import cats._, cats.data._, cats.implicits._
// import cats._
// import cats.data._
// import cats.implicits._

val w1 = Writer("w1 ", 1)
// val w1: cats.data.WriterT[cats.Id,String,Int] = WriterT((w1 ,1))

w1.run
// val res1: cats.Id[(String, Int)] = ("w1 ",1)

おお!Writer[String, Int]インスタンスを作って、そのタプルを取り出すことができました。

次に、negate: Boolean => Writer[String, Boolean]isEven: Int => Writer[String, Boolean] を定義してみましょうか。

def negate(b: Boolean): Writer[String, Boolean] = Writer("negate ", !b)
// def negate(b: Boolean): cats.data.Writer[String,Boolean]

def isEven(n: Int): Writer[String, Boolean] = Writer("isEven ", n % 2 == 0)
// def isEven(n: Int): cats.data.Writer[String,Boolean]

isEven(3)
// val res2: cats.data.Writer[String,Boolean] = WriterT((isEven ,false))

negate(false)
// val res3: cats.data.Writer[String,Boolean] = WriterT((negate ,true))

Writer を使って、2つの関数 negateisEven を定義することができましたね!

では、この2つの関数を合成して、 isOdd 関数を定義します。

def isOdd(n: Int): Writer[String, Boolean] = isEven(n).flatMap(negate)
// def isOdd(n: Int): cats.data.Writer[String,Boolean]

isOdd(3)
// val res6: cats.data.Writer[String,Boolean] = WriterT((isEven negate ,true))

おなじみ flatMap を使うことによって Writer は合成できました。flatMap の定義も、前項で定義した >=> 演算子とほとんど変わらないように見えます。興味がある方はぜひ見てみてください。

以上のように、Cats における Writer 型は本章で議論した Writer 圏を表現していることがわかります。機会があれば使ってみてください。

Kleisli

Kleisli 圏についても見ておきましょう。Kleisli 圏の定義にはモナドの概念が必要なので定義していませんが、Writer 圏との共通点はあるはずです。Cats の Kleisli を通してその性質を見ます。

Kleisliシグネチャは以下のようになっています。

final case class Kleisli[F[_], -A, B](run: A => F[B])

なんとなく Writer と似ているのではないでしょうか。簡単に表現するならば、 Kleisli[F[_], -A, B] は関数 f: A => F[B] のラッパーです。なぜラップするのかというと、これまで見てきた通り、f: A => F[B] のようにラップされた関数を合成するには合成の方法を独自で定義する必要があるからです。例えば、関数 f: A => F[B] と関数 g: B => F[C] を合成することを考えると、単に g compose f とはできません。型が異なるので、f の出力を g の入力として渡せないからです。

実際に、Kleisli を使っていくつか関数を実装してみましょう。negate: Boolean => Writer[String, Boolean]isEven: Int ~> Writer[String, Int] は、 関数 f: A => Writer[String, B] の形をしています。これらを Kleisli を使って定義すると、以下のようになります。

val negate = Kleisli { (b: Boolean) => Writer("negate ", !b) }
// val negate: cats.data.Kleisli[[V]cats.data.WriterT[cats.Id,String,V],Boolean,Boolean] = Kleisli($Lambda$4552/511930924@2db3af55)

val isEven = Kleisli { (n: Int) => Writer("isEven ", n % 2 == 0) }
// val isEven: cats.data.Kleisli[[V]cats.data.WriterT[cats.Id,String,V],Int,Boolean] = Kleisli($Lambda$4557/1514049729@6542cb5f)

次に、これらの関数を合成して isOdd 関数を定義します。Kleisli の合成には compose メソッドや andThen メソッドを用います。

val isOdd1 = negate.compose(isEven)
// val isOdd1: cats.data.Kleisli[[V]cats.data.WriterT[cats.Id,String,V],Int,Boolean] = Kleisli(cats.data.Kleisli$$$Lambda$4598/167638236@701815d6)

val isOdd2 = isEven.andThen(negate)
// val isOdd2: cats.data.Kleisli[[V]cats.data.WriterT[cats.Id,String,V],Int,Boolean] = Kleisli(cats.data.Kleisli$$$Lambda$4598/167638236@7a81bba1)

さて、Kleisli は関数 f: A => F[B] に合成の構造を持たせたラッパーなので、関数に引数を適用して出力を得るには run を呼び出してラップしている関数を得る必要があります。

isEven.run(1)
// val res0: cats.data.WriterT[cats.Id,String,Boolean] = WriterT((isEven ,false))

isOdd1.run(1)
// val res2: cats.data.WriterT[cats.Id,String,Boolean] = WriterT((isEven negate ,true))

isOdd2.run(1)
// val res3: cats.data.WriterT[cats.Id,String,Boolean] = WriterT((isEven negate ,true))

Kleisli 圏は、Writer 圏が持つ射の合成をより抽象化して定義したものであることがわかりました。後々になりますが、モナドを使って Kleisli 圏を定義します。そのときにまた、本記事で述べたことを思い出してもらえると幸いです。

まとめ

  • 関数 f: A => F[B] が圏の公理を満たすよう、射の合成を定義したものが Kleisli 圏である。
  • Writer 圏は Kleisli 圏の例である。
  • Writer 圏では、期待する出力 B の他にログ L を出力する関数 f: A => Writer[L, B] を射として考える。
    • L は、その射において結合律を満たし、射に関する単位元を持つ必要がある。すなわち、L はモノイドの性質を満たす必要がある。
  • Writer 圏における射の合成は >=> 演算子として定義できる。

参考文献

Scala プログラマのための圏論入門 (3) いろいろな圏

はじめに

ここまで、圏とは何かについて述べ、Scala の型と関数を圏として捉えることができることを述べました。

taretmch.hatenablog.com

taretmch.hatenablog.com

しかし、まだ圏のイメージがつかない方が少なくないはず。本章では、具体的な圏の例を通して、圏への理解を深めていきます。

空圏

空圏は、対象が1つもない圏のことです。対象がないということは射もありません。

なぜ対象が1つもないものが圏と言えるのかについて、考えてみましょう。

まず、圏は対象の集まりと射の集まりからなります。この「集まり」は集合と捉えることもできますが、集合でない場合もあります。

対象が1つもない場合、対象の集まりは集合と捉えることができて、それは空集合になります。また、射の集まりも空集合です。

射の合成は任意の射について定義されていればよく、射が存在しない場合では射の結合律は常に満たされていると言えます。

そして、恒等射は任意の対象に対して存在していればよく、対象が存在しない場合では単位律が常に満たされていると言えます。

したがって、空圏は圏と言えます。

単純なグラフ

状態遷移図のように単純なグラフは、圏とみなすことができます。

一般に、そのような単純なグラフは頂点の集合 $N$ と辺の集合 $E$ とのペア $(N, E)$ からなります。

グラフを圏と解釈すると、頂点を対象として、辺を射として捉えることができます。

f:id:tomoki0606:20201115033149p:plain
単純なグラフ

順序集合

ある集合 $A$ とその集合上の関係 $R$ とのペア $(A, R)$ である順序集合もまた、圏の例です。

例えば、自然数全体の集合 $\mathbb{N} = \{ 0, 1, ..., n, ... \}$ と関係 $\leq$ (〇〇以下である) の順序集合 $(\mathbb{N}, \leq)$ は、圏として以下のように図式化されます。

f:id:tomoki0606:20201115033226p:plain
圏としての順序集合の例

自然数全体の集合と関係 $\leq$ の順序集合では、自然数が対象、自然数間の関係、例えば $0 \leq 1$、$1 \leq 3$ などが射になります。

集合としてのモノイド

モノイド (Monoid) は、単純な構造ではありますが、非常に強力な概念です。

加算と乗算のどちらの算術演算にも、このモノイドという概念が密接に関わっています。

モノイドは群論における概念ですが、圏の一例でもあります。コンピュータサイエンスでは、情報理論の分野に現れることがあると思います。

プログラミングにおいても、文字列やリスト、畳み込み可能なデータ構造、Future などとして現れます。

さて、モノイドの定義についてですが、モノイドは、二項演算を備えた集合として定義されます。この二項演算が満たすべき性質は、結合律と、単位元が存在することのみです。

単位元は、加算における 0、乗算における 1 のことを表します。結合律は、加算の場合だと

$$(a + b) + c = a + (b + c)$$

が満たされることです。

f:id:tomoki0606:20201115033809p:plain
加算モノイド

結合律と単位元を備えた型クラス Monoid は、以下のように定義できます。

trait Monoid[A] {
  def combine(v1: A, v2: A): A
  def empty: A
}

combine が結合律を満たす二項演算で、empty がその二項演算の単位元を表します。

ここで注意することは、この型クラスの定義だけでは二項演算の結合律と単位元の存在を保証できないことです。したがって、二項演算がモノイドの性質を満たすかどうかは、その実装によります。

Int の加算に関するモノイドのインスタンス IntMonoid は、以下のように定義できます。

implicit val IntMonoid: Monoid[Int] = new Monoid[Int] {
  def combine(a: Int, b: Int): Int = a + b
  def empty: Int = 0
}
// IntMonoid: Monoid[Int] = repl.MdocSession$App$$anon$1@573dce4
IntMonoid.combine(1, 3)
// res0: Int = 4
IntMonoid.empty
// res1: Int = 0
IntMonoid.combine(2, IntMonoid.empty)
// res2: Int = 2

圏としてのモノイド

前節では、モノイドを集合と二項演算のペアとして考えました。

しかし、モノイドには圏論においてもう少し重要な側面があります。それは、対象がただ1つの圏をモノイドと言えることです。

先ほど見たモノイドの性質 - 二項演算の結合律と単位元の存在 - は、まさに圏の公理における射の結合律と単位律です。そして、モノイドは二項演算をただ1つの集合に対して定義しています。これは、ただ1つの対象からそれ自身への射を定義していることと同義です。

圏の特別な場合がモノイドであることから、モノイドの重要性が見えてきましたね。そして、モノイドが満たすなんらかの性質や構造を見ることができれば、それを一般化して圏が満たすなんらかの性質や構造として議論できることがわかります。

では、加算のモノイドを圏として捉えるとどのようになるか、考えてみましょう。

加算は二項演算なので、2つの入力があります。ここで、1つだけ入力 $n$ を与えれば、$n$ を足す演算 $n$-adder なるものを考えることができます。

この演算にもう一つの入力 $ m $ を与えれば、$m + n$ が出力として得られます。これは言うまでもなく $ m $ と $n$ の加算です。

$n$-adder は、数を対象とする圏における射であるとみなせます。この射 $n$-adder は結合律を満たすでしょうか?

加算に関する結合律は

$$(a + b) + c = a + (b + c)$$

とかけます。これを $n$-adder で書くと

$c$-adder ($b$-adder ($a$)) = $a$-adder ($c$-adder ($b$))

となります。関数をカリー化させることでもう少しわかりやすく書くと

add(add (b) (a)) (c) = add (add (c) (b)) (a)

となります。これは満たされそうですね。

次に単位律です。圏における単位律は、任意の対象について恒等射が存在する、というものでした。

任意の自然数 $n$ に対する恒等射は $0$-adder です。$n + 0 = n$ であるからです。

以上のことから、モノイドは圏の公理における結合律と単位律を満たします。

f:id:tomoki0606:20201115034207p:plain
圏としてのモノイド

まとめ

  • 対象が1つもない圏も、また圏である。
  • 単純なグラフは、圏とみなせる。
  • 順序集合は圏の例である。
  • モノイドとは、ある集合における関数の合成が結合律を満たし、関数に対する単位元が存在するような系のことである。
      • モノイド (Int, add)
      • 加算 add(n1, n2) は結合律を満たす
      • 加算 add の単位元は 0
  • 対象がただ1つの圏をモノイドという。
      • 対象: Int
      • 射: add: Int => Int => Int
      • add (n) は結合律を満たす
      • 恒等射: add (0)

参考文献