MicroAd Developers Blog

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

Scala依存型の調査

こんにちは。 マイクロアドでサーバーサイドエンジニアをしている飛田です。 今回はScalaの依存型について調査してみましたので、共有させていただきます。

依存型について調査したきっかけは、 Scalaで型レベル”だけ”でクイックソートという記事で、 こちらは型を使ってクイックソートを行う(コンパイル結果がクイックソートの結果になる)という内容になっています。

こちらを拝見して、Scalaの型の柔軟性にかなりのポテンシャルを感じましたので、 Scalaの型に関するトピックとして、「依存型」について共有させていただきます。

依存型とは

依存型とは定義が値に依存する型のことです。 例えば、以下のコードの型 hoge.Thogeの値に依存している依存型になります。

trait Hoge {
    type T
    val t: T
}
val hoge = new Hoge {
    type T = String
    val t: String = "HOGE"
}
val value: hoge.T = "FUGA"

この機能を使うことによって、異なる型の値を型安全に抽象化することができます。 以下、その具体例としてwrapOptionという関数を実装してみます。

依存型の使用例

依存型の使用例として、 任意の値をOptionでラップする関数 wrapOption というものを考えてみます。

こちらは任意の値を Optionで初期化する関数です。 引数として任意の型の値がきた場合は Someでラップ、Optionの値がきたらそれをそのまま返すようにします。 ただし、(依存型の柔軟性を示すために)Stringがきた場合はそのまま Stringを返すようにします。

この関数は、戻り値の型が引数の型に依存するという特徴があるにも関わらず、 Any型などを使わずに型安全に呼び出せるという特徴があります。

// シグネチャ
def wrapOption[T](t: T)(implicit ???): ??? = ???

// 期待する挙動
wrapOption(1) // Some(1): Option[Int]
wrapOption(Some("hoge")) // Some(“hoge”): Option[String]
wrapOption(None) // None: Option[Nothing]
wrapOption("fuga") // fuga: String … String型だけ特殊

wrapOptionの大枠の実装

まず、WrapOptionというトレイトを定義してやり、 引数として任意の値がきた場合に Some でラップします。

trait WrapOption[-T] { // 型パラメータを反変にしていますが、理由は後述します
  type Out
  def apply(t: T): Out
}

// 任意の値をSomeでラップする
implicit def default[T] = new WrapOption[T] {
  override type Out = Option[T]
  override def apply(t: T): Option[T] = Some(t)
}

def wrapOption[T](t: T)(implicit ev: WrapOption[T]): ev.Out = ev.apply(t)

println(wrapOption(100)) // Some(100)
println(wrapOption(Some(1.1))) // Some(Some(1.1))
println(wrapOption(None)) // Some(None)
println(wrapOption("a")) // Some(a)

上記コードの関数 wrapOption ですが、暗黙の引数 evによって、 挙動が決まるようになっています。

今回のケースですと、default 関数によって生成された WrapOption[T]#applyで 引数の値が Someでラップされるようになりました。

Stringがきた場合はそのまま Stringを返すようにする

次に、Stringがきた場合はそのまま Stringを返すようにコードを改修します。 単純に以下のような whenString を定義し、wrapOption("a")のように呼び出してやればいけそうですが、

type Aux[T, O] = WrapOption[T] {
  type Out = O
}

implicit def whenString[T]: Aux[String, String] = new WrapOption[String] {
  override type Out = String
  override def apply(t: String): String = t
}

// whenStringは以下のようなシグネチャにすることもできますが、見た目を整えるために、型エイリアスAuxを定義して使用しています
// implicit def whenString[T]: WrapOption[String]{type Out = String} = ???

wrapOptionの引数の暗黙のパラメータの解決をする際に、 先ほど定義したdefaultメソッドと今回定義したwhenStringが競合し、コンパイルエラーになります。

Scalaの暗黙のパラメータ優先度について

Scalaでは、暗黙のパラメータを解決する際に、相対的な重みを算出することによって、 暗黙のパラメータを1つに絞られます。

参考: https://eed3si9n.com/ja/implicit-parameter-precedence-again/

今回のケースですと、以下の2つの理由により、コンパイルエラーが起きます。

  • defaultwhenStringの相対的な重みが同じ
  • defaultの型WrapOption[T]whenStringの型WrapOption[String]を内包してしまっている

相対的な重みの算出方法には、 派生元のオブジェクト/クラスに対して派生先のオブジェクト/クラスは相対的な重みが+1付与されるという仕組みがあります。

trait WrapOption[-T] {
  type Out
  def apply(t: T): Out
}

trait LowPriorityWrapOption {
  // 暗黙のパラメータの相対的重み: 0
  implicit def default[T] = new WrapOption[T] {
    override type Out = Option[T]
    override def apply(t: T): Option[T] = Some(t)
  }
}

object WrapOption extends LowPriorityWrapOption {
  type Aux[T, O] = WrapOption[T] {
    type Out = O
  }

  def wrapOption[T](t: T)(implicit ev: WrapOption[T]): ev.Out = ev.apply(t)

  // 暗黙のパラメータの相対的重み: +1
  implicit def whenString[T]: Aux[String, String] = new WrapOption[String] {
    override type Out = String
    override def apply(t: String): String = t
  }
}

LowPriorityWrapOption#defaultWrapOption#whenStringですが、 whenStringの方に相対的な重みが+1されます。

その結果、wrapOption("何かしらの文字列型の値")..などと呼び出した場合に、暗黙のパラメータの解決ができるようになります。

Optionの値がきたらそれをそのまま返すようにする

最後にOptionがきた場合はそのまま Optionを返すようにコードを改修する必要がありますが、 こちらの機能は単純に、以下のような whenOptionを定義してやるだけで実装できます。

object WrapOption extends LowPriorityWrapOption {
  ...

  implicit def whenOption[T]: Aux[Option[T], Option[T]] = new WrapOption[Option[T]] {
    override type Out = Option[T]
    override def apply(t: Option[T]): Option[T] = t
  }
}

コードの全容

trait WrapOption[-T] {
  type Out
  def apply(t: T): Out
}

trait LowPriorityWrapOption {
  implicit def default[T] = new WrapOption[T] {
    override type Out = Option[T]
    override def apply(t: T): Option[T] = Some(t)
  }
}

object WrapOption extends LowPriorityWrapOption {
  type Aux[T, O] = WrapOption[T] {
    type Out = O
  }

  def wrapOption[T](t: T)(implicit ev: WrapOption[T]): ev.Out = ev.apply(t)

  implicit def whenOption[T]: Aux[Option[T], Option[T]] = new WrapOption[Option[T]] {
    override type Out = Option[T]
    override def apply(t: Option[T]): Option[T] = t
  }

  implicit def whenString[T]: Aux[String, String] = new WrapOption[String] {
    override type Out = String
    override def apply(t: String): String = t
  }
}

// 動作チェック用
object WrapOptionTest extends App {
  val res: Option[Int] = WrapOption.wrapOption(1)
  println(res) // Some(1)

  val res1: Option[String] = WrapOption.wrapOption(Some("hoge"))
  println(res1) // Some(hoge)

  val res2: Option[Nothing] = WrapOption.wrapOption(None)
  println(res2) // None

  val res3 = WrapOption.wrapOption("test")
  println(res3) // test
}

補足: WrapOption[-T]で反変を使っている件について

WrapOption.apply(Some("hoge"))のようなメソッド呼び出しがされた際に、 暗黙のパラメータとしてwhenOptionが使用されるようにさせたいため、 WrapOptionの型パラメータTを反変にしています。

implicit def whenOption[T]: Aux[Option[T], Option[T]] = new WrapOption[Option[T]] {
  override type Out = Option[T]
  override def apply(t: Option[T]): Option[T] = t
}

(Some[T]およびNoneOption[T]のサブタイプで) WrapOption[Some[T]] および WrapOption[None]WrapOption[Option[T]]のサブタイプとして扱うようにしました。

(参考)Scala公式サイトのドキュメントより引用:

ジェネリッククラスの型パラメータAはアノテーション-Aを利用して反変にできます。 これはクラスとその型パラメータの間で、共変と似ていますが反対の意味のサブタイプ関係を作ります。 class Writer[-A]ではAが反変になっているので、AがBのサブタイプであるようなAとBに対し、Writer[B]がWriter[A]のサブタイプであることを示します。

おわりに

依存型を使ったプログラムですが、型が値に依存したり、暗黙のパラメータの相対的重みを調節したり...とややこしい概念が出てくるため、 実際にプロダクトで使用する際には、コードのメンテナンス性や属人性について注意を払う必要を感じました。

しかしながら、依存型を活用することにより、型を柔軟に取り扱えるようになりそうかと思っていまして、 かなり技術的に興味深いと思います。

ご閲覧ありがとうございました!