ケースクラスとパターンマッチング

パターンマッチングは、Scalaをはじめとする関数型言語に一般的な機能です。CやJavaのswitch文に似ていますが、より強力な機能です。しかし、パターンマッチングの真価を発揮するには、標準ライブラリまたはユーザが定義したケースクラス(case class)によるデータ型の定義が必要になります。

簡単なケースクラスによるデータ型を定義してみます。

sealed abstract class DayOfWeek
case object Sunday extends DayOfWeek
case object Monday extends DayOfWeek
case object Tuesday extends DayOfWeek
case object Wednesday extends DayOfWeek
case object Thursday extends DayOfWeek
case object Friday extends DayOfWeek
case object Saturday extends DayOfWeek

これは、一週間の曜日を表すデータ型です。CやJavaのenumに似ていますね。実際、同じように使うことができます。たとえば、以下のようにDayOfWeek型の変数にSundayを代入することができます。

val x: DayOfWeek = Sunday

objectまたはその他のデータ型は、パターンマッチングのパターンを使うことができます。この例では、このDayOfWeek型を継承した各objectをパターンマッチングのパターンを使うことができます。パターンマッチングの構文は再度書くと、

<対象式> match {
  (case <パターン> (if <ガード>)? '=>'
    (<式> (;|<改行>))*
  )+
}

のようになります。DayOfWeekの場合、次のようにして使うことができます。

x match {
  case Sunday => 1
  case Monday => 2
  case Tuesday => 3
  case Wednesday => 4
  case Thursday => 5
  case Friday => 6
}
// res0: Int = 1

これは、xがSundayなら1を、Mondayなら2を…返すパターンマッチです。ここで、パターンマッチで漏れがあった場合、コンパイラが警告してくれます。この警告は、sealed修飾子をスーパークラス/トレイトに付けることによって、その(直接の)サブクラス/トレイトは同じファイル内にしか定義できないという性質を利用して実現されています。この用途以外でsealedはめったに使われないので、ケースクラスのスーパークラス/トレイトにはsealedを付けるものだと覚えておけば良いでしょう。

これだけだと、CやJavaの列挙型とあまり変わらないように見えますが、それらと異なるのは各々のデータは独立してパラメータを持つことができることです。また、パターンマッチの際はそのデータ型の種類によって分岐するだけでなく、データを分解することができることが特徴です。

例として四則演算を表す構文木を考えてみます。各ノードExpを継承し(つまり、全てのノードは式である)、二項演算を表すノードはそれぞれの子としてlhs(左辺)、rhs(右辺)を持つこととします。葉ノードとして整数リテラル(Lit)も入れます。これはIntの値を取るものとします。また、二項演算の結果として小数が現れた場合は小数部を切り捨てることとします。これを表すデータ型をScalaで定義すると次のようになります。

sealed abstract class Exp
case class Add(lhs: Exp, rhs: Exp) extends Exp
case class Sub(lhs: Exp, rhs: Exp) extends Exp
case class Mul(lhs: Exp, rhs: Exp) extends Exp
case class Div(lhs: Exp, rhs: Exp) extends Exp
case class Lit(value: Int) extends Exp

全てのデータ型にcase修飾子がついているので、これらのデータ型はパターンマッチングのパターンとして使うことができます。この定義から、1 + ((2 * 3) / 2)という式を表すノードを構築します。

val example = Add(Lit(1), Div(Mul(Lit(2), Lit(3)), Lit(2)))
// example: Add = Add(
//   lhs = Lit(value = 1),
//   rhs = Div(
//     lhs = Mul(lhs = Lit(value = 2), rhs = Lit(value = 3)),
//     rhs = Lit(value = 2)
//   )
// )

このexampleノードを元に四則演算を定義する関数を定義してみます。関数の定義の詳細は後ほど説明しますが、ここでは雰囲気だけをつかんでください。

def eval(exp: Exp): Int = exp match {
  case Add(l, r) => eval(l) + eval(r)
  case Sub(l, r) => eval(l) - eval(r)
  case Mul(l, r) => eval(l) * eval(r)
  case Div(l, r) => eval(l) / eval(r)
  case Lit(v) => v
}

この定義をREPLに読み込ませて、eval(example)として、

eval(example)
// res1: Int = 4

のように表示されれば成功です。きちんと、1 + ((2 * 3) / 2)という式の計算結果が出ていますね。ここで注目すべきは、パターンマッチングによって、

  1. ノードの種類と構造によって分岐する
  2. ネストしたノードを分解する
  3. ネストしたノードを分解した結果で変数を束縛する

という3つの動作が同時に行えていることです。 これがケースクラスを使ったデータ型とパターンマッチングの組み合わせの強力さです。また、このmatch式の中で、たとえば case Lit(v) => vの行を書き忘れた場合、DayOfWeekの例と同じように、

<console>:16: warning: match may not be exhaustive.
It would fail on the following input: Lit(_)
       def eval(exp: Exp): Int = exp match {

記述漏れがあることを指摘してくれますから、ミスを防ぐこともできます。

変数宣言におけるパターンマッチング

match式中のパターンマッチングのみを扱ってきましたが、実は変数宣言でもパターンマッチングを行うことができます。

たとえば、次のようなケースクラスPointがあったとします。

case class Point(x: Int, y: Int)

このケースクラスPointに対して、

val Point(x, y) = Point(10, 20)
// x: Int = 10
// y: Int = 20

とすると、x10に、y20に束縛されます。もしパターンにマッチしなかった場合は、例外 scala.MatchError が発生してしまうので、変数宣言におけるパターンマッチングは、それが必ず成功すると型情報から確信できる場合にのみ使うようにしましょう。

ケースクラスによって自動生成されるもの

オブジェクトの章で少し触れましたが、ケースクラスはクラスに対して、いくらか追加の自動生成を行います。このテキストでは特に重要なものについて触れます。

  • プライマリコンストラクタ引数を公開します(valを付けたかのように扱われる)
  • インスタンス間の同値比較が行えるようにequals()hashCode()canEqual()が定義されます
    • つまり、クラスが同じで、プライマリコンストラクタ引数の値すべてが一致しているかどうかで同値判定するようになります
  • 型とプライマリコンストラクタ引数を使ったtoString()が定義されます
  • コンパニオンオブジェクトにプライマリコンストラクタ引数と対応するapply()が定義されます

ラフに言うと、タプルに近い感覚でクラスを操作できるようになるということです。 REPLでの実行例を示します。

scala> case class Point(x: Int, y: Int)
defined class Point

scala> val p = Point(1, 2)
p: Point = Point(1,2)

scala> println(p.x, p.y)
(1,2)

scala> Point(1, 2) == Point(1, 2)
res4: Boolean = true

scala> Point(1, 2) == Point(3, 4)
res5: Boolean = false

本節で紹介しているケースクラスの機能は、あくまで便利さ簡潔さのためだけのものです。クラスに実装を足すことでも同等の振る舞いを持たせることもできます。例えば、前節で定義したPointをケースクラスを使わずに定義するとしたら、次のようになるでしょう。

class Point(val x: Int, val y: Int) {
  override def equals(that: Any): Boolean = that match {
    case thatPoint: Point =>
      thatPoint.canEqual(this) && this.x == thatPoint.x && this.y == thatPoint.y
    case _ =>
      false
  }

  override def hashCode(): Int = x.hashCode ^ y.hashCode

  def canEqual(that: Any): Boolean = that.isInstanceOf[Point]

  override def toString(): String = "Point(" + x + ", " + y + ")"
}

object Point {
  def apply(x: Int, y: Int): Point = new Point(x, y)
}

比べてみると、ケースクラスによって大幅に記述量が減っていることがわかると思います。 Pointが典型例でしたが、一般に、データ構造のようなものを表すクラスをケースクラスにすると便利なことがあります。

また、比較周りの実装が想像よりずっと複雑だと思った方もいるかもしれません。比較を「期待通り」に実装するのは、比較対象との継承関係を考慮する必要があるなど、それなりに難しいことが知られています。しかも、クラスのフィールド変数が増えるなど、クラスが何か変化するたびに修正が発生しかねない箇所でもあります。そういう問題を考えなくて済む点でもケースクラスは便利です。

最後に改めて述べておきたいと思いますが、ケースクラスでは紹介したもの以外にもメソッド定義が増えます。例えば、パターンマッチの実現機構であるunapply()や、複製のためのcopy()については触れませんでした。興味のある方はぜひ調べてみてください。

練習問題

DayOfWeek型を使って、ある日の次の曜日を返すメソッドnextDayOfWeek

def nextDayOfWeek(d: DayOfWeek): DayOfWeek = ???

をパターンマッチを用いて定義してみましょう。

練習問題

二分木(子の数が最大で2つであるような木構造)を表す型TreeBranch, Emptyを考えます:

sealed abstract class Tree
case class Branch(value: Int, left: Tree, right: Tree) extends Tree
case object Empty extends Tree

子が2つで左の子の値が2、右の子の値が3、自分自身の値が1の木構造はたとえば次のようにして定義することができます。

val tree: Tree = Branch(1, Branch(2, Empty, Empty), Branch(3, Empty, Empty))
// tree: Tree = Branch(
//   value = 1,
//   left = Branch(value = 2, left = Empty, right = Empty),
//   right = Branch(value = 3, left = Empty, right = Empty)
// )

このような木構造に対して、

  1. 最大値を求めるmaxメソッド:
  2. 最小値を求めるminメソッド:
  3. 深さを求めるdepthメソッド:
def max(tree: Tree): Int = ???
def min(tree: Tree): Int = ???
def depth(tree: Tree): Int = ???

をそれぞれ定義してみましょう。なお、

depth(Empty) == 0
depth(Branch(10, Empty, Empty)) == 1
depth(Branch(10, Branch(20,
                    Empty,
                    Empty
                 ), Empty)) == 2
// 右のBranchの方が、左のBranchよりも深い
depth(Branch(10, Branch(20,
                    Empty,
                    Empty
                 ), Branch(30,
                    Branch(40,
                        Empty,
                        Empty
                    ),
                 Empty))) == 3

です。

余裕があれば木構造を、

左の子孫の全ての値 <= 自分自身の値 < 右の子孫の全部の値

となるような木構造に変換するsortメソッド:

def sort(tree: Tree): Tree = ???

を定義してみましょう。なお、sortメソッドは、葉ノードでないノードの個数と値が同じであれば元の構造と同じでなくても良いものとします。

部分関数

これまでの説明の中で、無名関数とパターンマッチングについて説明してきましたが、この2つの機能を組み合わせた部分関数(PartialFunction)がScalaには存在します。説明の前に、まず、具体的なユースケースを挙げます:

List(1, 2, 3, 4, 5).collect { case i if i % 2 == 1 => i * 2 }
// res7: List[Int] = List(2, 6, 10)

ここで、collectメソッドは pf: PartialFunction[A, B] を引数に取り、pf.isDefinedAt(i)true になる要素のみを残し、さらに、pf.apply(i) の結果の値を元にした新しいコレクションを返します。

isDefinedAt

i % 2 == 1

の部分から自動的に生成され、パターンがマッチするときのみ真になるように定義されます。collectはこのisDefinedAt メソッドを使うことで、filtermap に相当する処理を同時に行うことができています。

PartialFunction は、自分でクラスを継承して作ることも可能ですが、一般的には、

{
  case pat1 => exp1
  case pat2 => exp2
  ...
  case patn => expn
}

という形の式から、自動的に生成されます。isDefinedAtが真になる条件は、pat1 ... patnのいずれかの条件にマッチすることです。

PartialFunctionを使う機会は実用的にはそれほど多いわけではありませんが、collectやサードパーティのライブラリで使うことがしばしばあるので、覚えておくと良いでしょう。

注意点として、 { case .... } という形式は、あくまで PartialFunction 型が要求されているときにのみ PartialFunction が生成されるのであって、通常の FunctionN 型が要求されたときには、違う意味を持つということです。たとえば、以下のような定義があったとします。

val even: Int => Boolean = {
  case i if i % 2 == 0 => true
  case _ => false
}
// even: Int => Boolean = <function1>

このとき、この定義は、無名関数とパターンマッチを組み合わせたものと同じ意味になります。この点にだけ注意してください。

val even: Int => Boolean = (x => x match {
  case i if i % 2 == 0 => true
  case _ => false
})
// even: Int => Boolean = <function1>

results matching ""

    No results matching ""