戦略 Scala 日記

素人プログラマの思考のセンス

case classはコンパニオンオブジェクトのなかで定義すべき理由

Scalaでデータなどを扱うときにはcase classを使うのが一般的である。
このcase classはコンパニオンオブジェクトに書くのが当たり前のようになっているがそれはなぜか。

ScalaでもJavaと同様、インナークラスを定義できる。
しかし、その違いとして「パス依存性」という特徴がある。
インナークラスの型はどのアウタークラスから生成されたかで決まる。

REPLでアウタークラスとインナークラスを定義し、インスタンスを2つ作る。
var変数にインスタンスを再代入しようとすると、type mismatchエラーとなることがわかる。

scala> class Outer {
     |   class Inner {
     |     private val hoge = "hoge"
     |   }
     | }
defined class Outer

scala> val o1 = new Outer
o1: Outer = Outer@18769467

scala> val o2 = new Outer
o2: Outer = Outer@368102c8

scala> val in1 = new o1.Inner
in1: o1.Inner = Outer$Inner@77556fd

scala> val in2 = new o2.Inner
in2: o2.Inner = Outer$Inner@1996cd68

scala> var in = in1
in: o1.Inner = Outer$Inner@77556fd

scala> in = in2
<console>:13: error: type mismatch;
 found   : o2.Inner
 required: o1.Inner
       in = in2
            ^

scala> in1.isInstanceOf[Outer#Inner]
res0: Boolean = true

インナークラスの型は、どのインスタンスから生成されたかに依存している。(パス依存性)
その型を汎用的に表記するためにはOuter#Innerのように記述する必要がある。

さて、このようなパス依存性の特徴を確認したうえで、MVCで設計したControllerからServiceトレイトを利用する例を考えてみよう。

UserController.scala

import UserService._

class UserController extends Controller with UserService {

  def list: Seq[User] = getUsers

}

UserService.scala

object UserService {
  // データベースから取得するレコードの代わりにモックとしてこのデータを利用する
  val users: Seq[(Int, String)] = Seq((1, "hoge"), (2, "fuga"), (3, "foo"), (4, "bar"))
}

trait UserService {
  import UserService._

  case class User(id: Int, name: String)

  def getUsers: Seq[User] = users.map(u => User(id = u._1, name = u._2))

}

よくある実装のしかただが、ここではあえてUserServiceトレイトの中にcase class User()を定義している。
sbt consoleからUserController複数インスタンス化し、listの値を比較してみよう。

scala> val userController1 = new UserController
userController1: UserController = UserController@297eb9d9

scala> val userController2 = new UserController
userController2: UserController = UserController@4faf14f1

scala> userController1.list == userController2.list
res0: Boolean = false

同一のデータ(users)を利用しているのだから、どのインスタンスから実行しても結果は同値になるはずなのだが、 userController1.list == userController2.listとして比較した結果は、falseとして返っている。

これが、先に説明したScalaのインナークラスにおける「パス依存性」に起因する問題である。
このように、今回のUserのようにインナークラスはインスタンスに依存するため、このままではテストなどが正常に行えない。

そこで、このcase class UserUserServiceのコンパニオンオブジェクトに定義するように修正する。
objectはシングルトンであるから、そこに定義しているインナークラスがインスタンスに依存する問題を解消できる。

UserService.scala

object UserService {
  val users: Seq[(Int, String)] = Seq((1, "hoge"), (2, "fuga"), (3, "foo"), (4, "bar"))

  // case class をコンパニオンオブジェクトで定義する
  case class User(id: Int, name: String)
}

trait UserService {
  import UserService._

  def getUsers: Seq[User] = users.map(u => User(id = u._1, name = u._2))

}

これを、先ほどと同様にsbt consoleからインスタンス化しその値を比較しよう。

scala> val userController1 = new UserController
userController1: UserController = UserController@3094148b

scala> val userController2 = new UserController
userController2: UserController = UserController@32fef5c6

scala> userController1.list == userController2.list
res0: Boolean = true

今度は各インスタンスから呼び出したlistメソッドの結果は同値となった。

このように、ScalaではTraitやclassの中にcase classを定義すると、それらは実体化する際アウタークラスのインスタンスに依存するため、 かならずコンパニオンオブジェクトなど、objectのなかで定義することを心がけなければならない。