Scalaで型クラスを使おう!

  • 2
    いいね
  • 0
    コメント

はじめに

某記事で、

implicitsなんて呼ばないで、各機能ごとに適切な名称で呼ぼう。あと、重要なのは型クラスだからそれだけ注意すればOK、という趣旨のことを書きましたが、どうやって使えばいいかは投げっぱなしだったのでその導入編だけでも書こうかと思います。この記事での目的は簡単で、

  • Scalaのコレクションのsumメソッド(とproductメソッド)を自作の有理数クラスに適用できるようにしよう

ということになります。実際のところ、sumメソッドを整数リストに対して呼び出す、たとえば

List(1, 2, 3).sum // => 6

でも、型クラスを「使って」はいるのですが、実際に型クラスに新しいインスタンスを追加していないので、使った実感がわかないと思います。そこで、有理数クラスです。実装が比較的簡単である上に、四則演算への対応が容易という点でこれを選ぶことにしました。

まずは、有理数クラス(Rational)の実装です。ここは、本題とあまり関係ないので駆け足で行きます:

class Rational(n: Int, d: Int) {
  private[this] val g = gcd(n.abs, d.abs)
  val numer = n / g
  val denom = d / g

  def this(n: Int) = this(n, 1)

  def unary_- : Rational = new Rational(-numer, denom)

  def negative: Boolean = numer < 0

  def positive: Boolean = numer > 0

  def + (that: Rational): Rational = {
    new Rational(
      numer * that.denom + that.numer * denom,
      denom * that.denom
    )
  }

  def + (i: Int): Rational = {
    new Rational(numer + i * denom, denom)
  }

  def - (that: Rational): Rational = {
    new Rational(
      numer * that.denom - that.numer * denom,
      denom * that.denom
    )
  }

  def - (i: Int): Rational = {
    new Rational(numer - i * denom, denom)
  }

  def * (that: Rational): Rational = {
    new Rational(numer * that.numer, denom * that.denom)
  }

  def * (i: Int): Rational = {
    new Rational(numer * i, denom)
  }

  def / (that: Rational): Rational = {
    new Rational(numer * that.denom, denom * that.numer)
  }

  def / (i: Int): Rational = {
    new Rational(numer, denom * i)
  }

  override def equals(that: Any): Boolean = that match {
    case that:Rational => this.numer == that.numer && this.denom == that.denom
    case _ => false
  }

  override def toString = numer +"/"+ denom

  private[this] def gcd(a: Int, b: Int): Int = {
    if (b == 0) a else gcd(b, a % b)
  }
}

なお、断りを入れておきますが、これは、私のオリジナルではなく、いわゆる「コップ本」(原著:Programming in Scala 3rd Edition)から持ってきたものです(自分でも書けるけど本題でないので労力を節約したかった)。

サンプルコードのライセンスについては、Apache 2.0 open source licenseとのことなのですが、ブログ記事で書く場合どうするのが適当かわからないので、出典とOSSライセンスで公開されているコードであることと、Apache 2.0 open source licenseへのリンクを明記するに留めます。本来ならばライセンス文のコピーも配布する必要があるとは思うのですが、Qiitaでテキストファイル添付の方法もわからないですし。

と前口上はこのくらいにして、本題に入りたいと思います。最初に書いた通り、List[Rational]sumを計算してもらえるようになりたいのでした。しかし、sumメソッドは当然ながらRationalクラスのことなど知りません。

ここ

をみても、sumの引数がないようにしか見えません。が、これは、初学者が型クラスを見てギョッとしないためのAPIリファレンスの配慮(?)であって、「Full Signature」をクリックすると、

def sum[B >: A](implicit num: Numeric[B]): B

という、完全なシグネチャが現れます。ここでポイントなのがNumeric[B]というやつです。こいつが、B型同士の演算方法を知っているので、そいつに教えてもらうというわけです。デザインパターンとしては、Strategyパターンに近いと言えばいいでしょうか。

じゃあNumeric見に行くか、と見に行くと…

numeric.png

なんか大量にいろんなメソッドが表示されました!実は、ScalaのNumericは反面教師にすべき型クラス設計で、一つの型に色々な演算を詰め込みすぎています。そのため、とりあえず加算だけできるようにしたい、という要求にうまく答えられないという情けない羽目に。どうしてこうなった、と言いたい。

愚痴っても仕方ないので、淡々と実装します。実際のところ、sumを計算したいだけならplusメソッドだけ実装すればいいのですが、それはなんか負けた気がして悔しいので一通り実装してみます。

fromIntとかtoIntとかtoFloatとかtoDoubleとかどう考えても余計なものにしか見えないのですが…。

compareRationalをソートしないなら要らないかと思いますが、一応それっぽい実装をつけてみました(判定を分子が負数かどうかだけでやってるのは手抜きです。ちゃんと考えるとバグってる可能性もありそうですが、別に負数の計算しないのでとりあえず良しとします。

object Rational {
  implicit object NumericRational extends Numeric[Rational] {
    override def plus(x: Rational, y: Rational): Rational = x + y

    override def minus(x: Rational, y: Rational): Rational = x - y

    override def times(x: Rational, y: Rational): Rational =  x * y

    override def negate(x: Rational): Rational = -x

    override def fromInt(x: Int): Rational = new Rational(x, 1)

    override def toInt(x: Rational): Int = x.numer / x.denom

    override def toLong(x: Rational): Long = toInt(x).toLong

    override def toFloat(x: Rational): Float = (x.numer.toFloat / x.denom)

    override def toDouble(x: Rational): Double = (x.numer.toDouble / x.denom)

    override def compare(x: Rational, y: Rational): Int = {
      val r = (x - y)
      if(r.negative) -1 else if (r.positive) 1 else 0
    }
  }
}

ここで、NumericRationalオブジェクトにimplicitと付加したのがまずポイントで、この修飾子はこのオブジェクトは何かの型クラス(ここではNumeric)のインスタンスですよとコンパイラに知らせるためにあります。

NumericRationalオブジェクトをRationalのコンパニオンオブジェクトの直下に置いたのがもう一つのポイントです。コンパイラはList[Numeric[Rational]]sumするためにNumeric[Rational]のオブジェクトが欲しいわけですが、このとき、型パラメータの部分、つまり、Rationalのコンパニオンオブジェクトは必ず探索対象になります。なので、こうしておけば、どんな文脈でもList[Numeric[Rational]]sumが計算できるわけです。さて、ようやく実装が(バグ有るかもですが)できたので動かしてみます。

assert(List(new Rational(1, 3), new Rational(1, 4), new Rational(1, 5)).sum == new Rational(47, 60))

最初は、(1 / 3) + (1 / 4) + (1 / 5)を計算させるというもので、ちゃんと47 /60で正解しました。ヤッタ。

次は、(1 / 3) * (1 / 4) * (1 / 5)を計算させてみましょう。実は、timesメソッドを実装したご利益として、リストの要素の積を計算するproductメソッドが使えるようになったのです。

assert(List(new Rational(1, 3), new Rational(1, 4), new Rational(1, 5)).product== new Rational(1, 60))

これも 1 / 60 で正解。

というわけで、余計なメソッドがたくさんあることについては文句を言いたいところですが、これで型クラス(ここではNumeric[B])のインスタンス(Numeric[Rational])を作ることの意義がなんとなくわかってもらえたのではないかと思います。

要は、Numeric[B]というのは、B型に対して、B型同士の加算、B型同士の減算、B型同士の乗算、B型の符号反転を実装してね、というプロトコルに対して、

そのプロトコルを満たしたimplicitなobjectであるNumericRationalを実装することで、Numeric[Rational]標準的な演算としてこういうものを提供しますよ、と宣言しているわけです。

標準的なと書いたのは、モノによってはプロトコルが同じだけど演算結果が違うということもたまにあるからです。普段はほとんど遭遇しないだろうとはいえ。

まとめ

  • Scalaの標準ライブラリの型クラスであるNumeric[B]を使い、そのインスタンスであるNumeric[Rational]型のオブジェクトを定義しました。
  • 結果として、List[Rational]上のsumproductを「ただで」手に入れることができました。
    • もちろん、Rational同士の演算はいずれにせよ定義しなければいけないわけですが、sumproductには一切手が入っていないわけです。
  • 決して、Scalaの標準ライブラリのNumeric[B]は真似してはいけません。余計なものが入りすぎています。
    • その割には、除算を定義した型クラスに関するサポートがあんまりなく。
    • というと語弊があって、実はFractional[T]というまさにそのための型クラスが存在しているのですが、標準ライブラリ内で使用されていないようです。 そのせいで、原理的にも実装的にもあってしかるべき(はず)averageのようなメソッドがコレクションに定義されていない(というか出来ない)という悲しいことに…。

Fractionalに関しては十分に調査したとは言えないので、ここで使ってるよ!とかの情報提供があればお待ちしています。あと、そこ、Numeric[B]のプロトコル違反だよ!とかの指摘も歓迎します。