LoginSignup
2
2

More than 5 years have passed since last update.

ScalaのEnumとcase objectの比較(play-jsonによるJsStringとの相互変換)

Last updated at Posted at 2016-10-13

Scala Enum で検索しますと、Enumは使うべきではない case object で表現すべきとの記事が真っ先に上がります。とはいえEnumの長所がないわけではありません。

以下play-jsonを用いたJSONとの相互変換を行った結果を比較します。
JSON上、JsStringで表現されるEnumを実装する上で、
case objectを用いる場合とEnumを用いる場合の二種類を試しました。

1 - case objectを用いる場合

// 1 - case objectを用いる場合
// 共通に用いる箇所
trait JsConverter {
  val className: String
  val typeName : String
  implicit val format = Format[Percentage.type](Reads {
    case JsString(`typeName`) => JsSuccess(Percentage)
    case _                    => JsError(s"${className}のシリアライズに失敗しました")
  }, Writes { e =>
    JsString(typeName)
  })
}
// 1 - case objectを用いる場合
// 個々で書く必要がある箇所
sealed abstract class Sample(val typeName: String)

case object Sam extends Sample("sam") with JsConverter {
  val className = "Sam"
}

case object Ple extends Sample("ple") with JsConverter {
  val className = "Ple"
}

object Sample {
  val list = List(Sam, Ple)

  implicit val format = Format[Sample](Reads[Sample] {
    case JsString("sam") => JsSuccess(Sam)
    case JsString("ple") => JsSuccess(Ple)
    case _               => JsError("Sampleのシリアライズに失敗しました")
  }, Writes[Sample] { entity => JsString(entity.typeName) })
}

長所

  • 以下のようなcase文を作成する場合は抜け漏れをコンパイル時に教えてくれます。
// 以下はmatchが不十分
numberType match {
  case Percentage => println("percentage")
}

短所

  • case objectを用いる場合、親となる抽象クラス(およびtrait)にcase classではないため、applyを独自に実装する必要があります(もしくはJsReadsに同様の内容を書く必要がある)。

  • 新たにcase objectを追加する際には、case object を増やすだけではなく、全ての要素を持つlist および format のcase文など複数箇所を更新する必要があります。

2 - Enumを用いる場合

参照:

// 3 - Enumを用いる場合
// 共通に用いる箇所
import scala.util.control.Exception._
object EnumUtils {
  def enumReads[E <: Enumeration](enum: E): Reads[E#Value] = {
    new Reads[E#Value] {
      def reads(json: JsValue): JsResult[E#Value] = json match {
        case JsString(s) => (allCatch either {
          enum.withName(s)
        }).fold(t => JsError(s"Enumerationのシリアライズに失敗しました: ${t}"), e => JsSuccess(e))
        case _           => JsError("Enumerationのシリアライズに失敗しました: 文字列が予測されています")
      }
    }
  }

  def enumWrites[E <: Enumeration]: Writes[E#Value] = {
    new Writes[E#Value] {
      def writes(v: E#Value): JsValue = JsString(v.toString)
    }
  }

  def enumFormat[E <: Enumeration](enum: E): Format[E#Value] = {
    Format(enumReads(enum), enumWrites)
  }
}
// 3 - Enumを用いる場合
// 個々で書く必要がある箇所
object Sample extends Enumeration {
  type Sample = Value
  val Sam = Value("sam")
  val Ple = Value("ple")

  implicit val format = EnumUtils.enumFormat(Sample)
}

長所

  • 個々のEnumを設定する際、記述する分量が少なく済みます。
  • 要素を増やすときにもobject内に一つ要素を増やすだけで良いため、ヒューマンエラーを招きづらくなります。

短所

  • 個々の要素がcase objectではなくなるため、以下のmatch文を警告はしてくれません。
// 以下はmatchが不十分
sample match {
  case Sam => println("sam")
}

したがって以下の書き方

sample match {
  case Sam => println("sam")
  case _   => println("ple")
}

になるのですが、

新しく要素を追加した際

object Sample extends Enumeration {
  type Sample = Value
  val Sam = Value("sam")
  val Ple = Value("ple")
  val Extra = Value("extra") // <- これを追加

  implicit val format = EnumUtils.enumFormat(Sample)
}

コンパイラから以下のように意図しない分岐になっていることを検知できません。

sample match {
  case Sam => println("sam")
  case _   => println("ple") // sampleがExtraであるときもこちらに流れる
}

この性質が嫌われるため、case objectが使用されることが多いです。

まとめ

以上のように、JsStringと変換可能なEnumを実現しようとして

  • case objectを用いる場合
  • Enumを用いる場合

の2パターンを考えました。

Enumの代わりにcase objectを用いる場合、確かに

sample match {
  case S => // something
  case A => // something
  case E => // something
}

というようなケースの抜け漏れを検知してくれますが、
Enumの withNames メソッドに匹敵するものを再発明しなければならないですし、
要素追加時に更新箇所が増えてしまうのが冗長です。

Enumを用いる場合は、
要素追加時にcase文の抜け漏れがないか一つ一つチェックする必要があります。

それぞれに長所短所があることを分かった上で用いる必要があると思われます。

利用箇所が多岐に渡ることが予測される場合、
https://github.com/lloydmeta/enumeratum などのライブラリを導入してしまうのがもっとも根本的な解決になるのかもしれません。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2