この記事はなに?
case classを使いながらコンストラクタを隠蔽してファクトリー経由で生成するように強制したい。
ここでいう"コンストラクタ"はnew
で生成する通常コンストラクタに加えてcase classで自動生成されるapply
も対象としている。
まとめ
-
sealed abstract case class
を使う- こちらのコメントをご参照下さい(@aoiroaoino さんありがとうございます)
- きっちり隠蔽したければcase classは諦めてclassを使う
- 全部自前で実装するなら
- コードレビューで頑張れるならcase class使う
-
copy
とかが欲しければこちら
-
- 色々と頑張れるならメタプロ
case classについて
Scalaのcase classは非常に便利で、apply
やunapply
を自動で生やしてくれる。
case class User (id: Int, name: String)
case classについては普通にコンストラクタ経由でもapply
経由でも生成できる。
val alice = new User(1, "alice")
val bob = User.apply(2, "bob")
ちなみにscala -Xprint:typer
で生成されるapply
メソッドを見てみると以下のようになっている。
単にnewしてるだけ。
case <synthetic> def apply(id: Int, name: String): User = new User(id, name);
コンストラクタを隠す
コンストラクタをprivateにする
classはコンストラクタの前にprivate
をつけるとコンストラクタを隠蔽できる。
case class User private (id: Int, name: String)
こうするとコンストラクタは隠せるのでnew出来ない。
val alice = new User(1, "alice")
エラーメッセージはこんな感じ。
scala> new User(1, "alice")
:14: error: constructor User in class User cannot be accessed in object $iw
new User(1, "alice")
が、User.apply
は生成されているので自由に生成できてしまう。
val bob = User.apply(2, "bob")
applyをoverrideしてprivateにする
コンストラクタをprivateにするのとあわせて、case classで自動生成されるapply
を明示的にprivateとして実装してみる。
case class User private (id: Int, name: String)
object User { private def apply(id: Int, name: String) = ??? }
こうすると
val alice = new User(1, "alice")
val bob = User.apply(2, "bob")
どちらもコンパイルエラーとなってめでたし。
...とはならず、User
クラスにフィールドが増えた場合などにapply
のシグネチャを変更し忘れるとすり抜けてしまう。
// ageが増えた
case class User private (id: Int, name: String, age: Int)
// ↓は先ほどと同じ
object User { private def apply(id: Int, name: String) = ??? }
こうなると、以下のようになる
// これはコンパイルエラー
// val alice = new User(1, "alice")
// こちらは動く
val bob = User.apply(2, "bob", 20)
コードレビューで人間が目で見て頑張って防ぐこととなる。
じゃあどうする?
case classを諦めてclassを使う。
class User private (id: Int, name: String)
とするだけでコンストラクタは適切に隠蔽できる。
case classのunapply
とかcopy
とか、そのあたりの便利さとクラス定義としての厳密さのどちらを取るかはチーム次第、というありきたりな結論。
(番外編)applyを自動でprivateにする
人間が頑張るんじゃなくて自動でやりたい。
そうなるとメタプログラミングの出番。
自動でapply
を生成してprivate
にしてくれるやつを作ってみた。
scala-acase/NoApply.scala
@NoApply
アノテーションを付与する。
@NoApply case class User private (id: Int, name: String)
そうすると
// どちらもコンパイルエラーになる
// new User(1, "alice")
// User.apply(2, "bob")
のどちらもコンパイルが通らなくなる。
なので、意図通りにファクトリー経由での生成を強制できる。
@NoApply case class User private (id: Int, name: String)
object User {
def create(name: String) = apply(Random.nextInt(100), name)
}
User.create("alice") // User(xxx, "alice")
ここまでやるかどうかも、チーム次第。