5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Scalaで実用的な列挙型(enum)を定義する ※enumeratum使用

Last updated at Posted at 2020-11-02

##1. はじめに
Scalaで列挙型(Javaでいうenum)を定義する場合、以下のようにsealed trait(またはabstract class)とcase objectの組み合わせで定義するのが常套手段となっています。

// 列挙メンバーの型を定義
sealed trait Fruits

// 列挙の各メンバーを内包するコンパニオンオブジェクトを定義
object Fruits {
  case object Apple  extends Fruits
  case object Orange extends Fruits
  case object Banana extends Fruits
}

参考:
ScalaのEnumerationは使うな Scalaで列挙型を定義するには
【Scala】列挙型を使おう | Developers.IO

こうすることで==やパターンマッチで判別できるようにはなるのですが、実務で使おうと思うとまだ手間が残ります。

例えばJavaのEnum#valuesのように列挙メンバーのリストが欲しくなったときは、それぞれの列挙に自前で実装する必要があります。

sealed trait Fruits

object Fruits {
  case object Apple  extends Fruits
  case object Orange extends Fruits
  case object Banana extends Fruits
  
  // 列挙メンバーのリストは自分で実装する必要がある
  lazy val values: IndexedSeq[Fruits] = 
    IndexedSeq(Fruits.Apple, Fruits.Orange, Fruits.Banana)
}

確かにこれでも動きはします。

しかしこの書き方の場合、要素の抜け漏れや記述順序ににも気を配らないといけません。
また列挙メンバーが増減する度に、リストの中身も合わせて修正する必要も出てきます。

更に、ここで定義したvaluesFruitsというobject固有のメソッドとなるため、これを使って列挙全体で使えるような汎用的な処理を記述するのも難しくなります。

##2. valuesの実装(enumeratum使用)
enumeratumは、上記のようにsealed trait(またはabstract class)とcase objectで定義した列挙型を、更に扱いやすくしてくれるライブラリです。

enumeratumをインストールするためには、build.sbtに以下の記述を追加します。
※執筆時点(2020年11月)の最新バージョンは1.6.1です。

build.sbt
lazy val enumeratumVersion = "1.6.1"

libraryDependencies ++= Seq(
    "com.beachape" %% "enumeratum" % enumeratumVersion
)

enumeratumを使うと、例えば先の列挙型は以下のようになります。

import enumeratum.{Enum, EnumEntry}

// ①列挙のメンバーとなる型のtraitにはEnumEntryを継承させる
sealed trait Fruits extends EnumEntry

// ②列挙メンバーを内包するobjectにはEnum[【列挙のメンバーの型】]を継承させる
object Fruits extends Enum[Fruits] {
  case object Apple  extends Fruits
  case object Orange extends Fruits
  case object Banana extends Fruits

  // ③valuesを定義する ※どんな列挙型でもfindValuesと書くだけでいい
  lazy val values: IndexedSeq[Fruits] = findValues
}

列挙のメンバーとなる型にはenumeratum.EnumEntryを、列挙のメンバーを内包するコンパニオンオブジェクトにはenumeratum.Enum[A]を継承させるだけです。
Enum[A]の型引数には列挙のメンバーとなる型(ここではFruitsトレイト)を指定します。

enumeratum.Enumトレイトは、抽象メソッドとしてvaluesメソッドを定義しています。
valuesメソッドは、自身の列挙メンバーを上から順に格納したIndexedSeqを返すことが想定されています。

Enum.scala
  def values: IndexedSeq[A]

そのためvaluesを各列挙型に個別に実装しなければいけないところまでは、enumeratumを使わないケースと同じです。
特筆すべき点は、enumeratum.Enumには列挙したメンバーのIndexedSeqを自動生成してくれるfindValuesというメソッドが用意されていることです。

Enum.scala
  protected def findValues: IndexedSeq[A] = macro EnumMacros.findValuesImpl[A]

findValuesはマクロを使い、既に定義されている列挙メンバーを自動で読み取り、上から列挙した順にIndexedSeqに詰めて返してくれます。

なので下記のようにvaluesの実装部分にfindValuesと書くだけで、わざわざ列挙メンバーを一つずつ記述せずとも、望みのリストを取得できます。

  lazy val values: IndexedSeq[Fruits] = findValues

マクロの都合上、(enumeratum.Enumを継承する)全ての列挙で個別にこの一文を記述する必要がありますが、順序や記述漏れ、メンバーの増減を気にしながら毎回手作業でIndexedSeqを実装するのと比べれば、かかる手間は雲泥の差ではないでしょうか。

また、valuesメソッド自体も定義した列挙固有のメソッドではなくenumeratum.Enumのメソッドになったので、それを利用した汎用メソッドなども定義できるようになります。


なお本来のenumeratum.Enum#valuesdefで定義されていますが、実装時は上記のようにvalに変更することをおすすめします。1
defのまま実装してしまうと、valuesの呼び出しの度に毎回findValuesが実行されてしまうからです。
通常、列挙の中身はアプリケーションの開始から終了まで変わらないので、findValuesを一度呼び出した時点で変数にキャッシュした方が効率的です。

またこれは個人的なこだわり程度の話ですが、全ての列挙でvaluesを使うわけでもないので、自分が使うときはlazyキーワードをつけて遅延初期化させています。


上記のようにenumeratumを使って実装した列挙に対してvaluesを呼び出すと、以下のように列挙したメンバーが列挙順に格納されたIndexedSeq(※実体はVecor)が得られることがわかります。

scala> Fruits.values
val res0: IndexedSeq[Fruits] = Vector(Apple, Orange, Banana)

これで、JavaのEnum#values相当の動きをScalaの列挙でも実現できるようになりました。

##3. 列挙メンバーの名前を取得する/名前から列挙メンバーを取得する
enumeratum.Enumを継承した列挙はまた、JavaでいうEnum#nameおよびEnum#valueOf相当のメソッドを使うこともできるようになります。

enumeratumにおいては、JavaのEnum#nameenumeratum.EnumEntry#entryNameが、Enum#valueOfenumeratum.Enum#withNameがそれぞれ対応します。
使用例は下記のとおりです。

// entryNameで列挙メンバー名の文字列を取得できる。
scala> Fruits.Orange.entryName
val res0: String = Orange

// withNameで列挙メンバー名の文字列から該当するobjectを取得できる。
scala> Fruits.withName("Apple")
val res1: Fruits = Apple

// withNameに列挙に存在しない文字列を渡すとNoSuchElementExceptionになる。
scala> Fruits.withName("Melon")
java.util.NoSuchElementException: Melon is not a member of Enum (Apple, Orange, Banana)
  at enumeratum.Enum.$anonfun$withName$1(Enum.scala:81)
(中略)

// withNameOptionは列挙メンバー名の文字列から該当するobjectをSomeに詰めて返す。
scala> Fruits.withNameOption("Banana")
val res3: Option[Fruits] = Some(Banana)

// withNameOptionに列挙に存在しない文字列を渡すとNoneを返す。
scala> Fruits.withNameOption("Melon")
val res4: Option[Fruits] = None

この他にも、大文字小文字を考慮したり、スネークケースやキャメルケースを考慮する機能も用意されています。
詳しくはenumeratumのREADMEを参照してください。

##3. 列挙に個別の値を設定する
列挙の重要な役割として、いわゆる「区分値」のようなものを持たせ、数値定数などの代わりに型安全に管理するという使い方があります。
これは下記のように、sealed abstract classcase objectを組み合わせることで実現できます。

/** 発送状況enum
  * @param value 発送状況区分値
  */
sealed abstract class DeliveryStatus(val value: Int)

object DeliveryStatus {
  case object OrderConfirmed extends DeliveryStatus(1) // 注文済み
  case object Shipped        extends DeliveryStatus(2) // 発送中
  case object Delivered      extends DeliveryStatus(3) // 発送済
  case object Failed         extends DeliveryStatus(9) // 発送失敗
}

こうすることで、プログラム内では列挙型で演算しつつ、画面やDBなどへデータを渡すときには内部の区分値を保存することができるようになります。
ですがこの書き方の場合、「列挙 → 区分値」はいいのですが、「区分値 → 列挙」への変換が難しくなります

// 列挙 → 区分
def deliveryStatusToValue(deliveryStatus: DeliveryStatus): Int = deliveryStatus.value // 内部の値を取り出すだけでいい

// 区分値 → 列挙
def valueToDeliveryStatus(value: Int): DeliveryStatus = ??? // どうする?

これはenumeratumを使うなどして、列挙のSeqを取得できれば実装自体は可能です。

sealed abstract class DeliveryStatus(val value: Int) extends EnumEntry

object DeliveryStatus extends Enum[DeliveryStatus] {
  case object OrderConfirmed extends DeliveryStatus(1) // 注文済み
  case object Shipped        extends DeliveryStatus(2) // 発送中
  case object Delivered      extends DeliveryStatus(3) // 発送済
  case object Failed         extends DeliveryStatus(9) // 発送失敗

  lazy val values: IndexedSeq[DeliveryStatus] = findValues

  // valuesがあれば、区分値からの逆引きMapを作れる
  lazy val valueToDeliveryStatusMap: Map[Int, DeliveryStatus] =
    values.map(v => v.value -> v).toMap

  // 区分値からの逆引きMapを使って、区分値から列挙を取得する
  def withValue(value: Int): DeliveryStatus =
    withValueOption(value).get

  def withValueOption(value: Int): Option[DeliveryStatus] =
    valueToDeliveryStatusMap.get(value)
}
// withValueメソッドを使うことで区分値から列挙への変換が可能になる
scala>  DeliveryStatus.withValue(9)
val res0: DeliveryStatus = Failed

どうでしょうか?
確かに実装はできたものの、区分値を使う全ての列挙型でこのコードをコピペしないといけないと考えると、少々気が滅入るのではないでしょうか。

この問題も、enumeratumを使うことで解決できます。

これまではenumeratum.Enum[A]を使っていました。
列挙に値を持たせたい場合は、enumeratum.valuesパッケージ内のValueEnum[ValueType, A]およびValueEnumEntry[ValueType]を使用します。

ValueEnum.scala
sealed trait ValueEnum[ValueType, EntryType <: ValueEnumEntry[ValueType]] {
  def values: IndexedSeq[EntryType]
ValueEnumEntry.scala
sealed trait ValueEnumEntry[ValueType] {
  def value: ValueType
}

ValueEnumEnumと同様valuesメソッドを定義しています。
また値を持つため、値の型引数(ValueType)も追加されています。

ValueEnumEntryvalueという抽象メソッドを定義しているため、このtraitを継承した列挙メンバーはvalueを実装する必要があります。

なおValueEnumおよびValueEnumEntry自体はsealedで宣言されているので、実際に使うときはその派生クラスとして宣言されている各種の型に応じた抽象型を使うことになります。
執筆時点(2020年11月)では、値に使える型としてInt, Long, Short, String, Byte, Charの6種類がサポートされています。

// ValueEnum.scalaで定義されている各種trait
trait IntEnum[A <: IntEnumEntry]       extends ValueEnum[Int, A]
trait LongEnum[A <: LongEnumEntry]     extends ValueEnum[Long, A]
trait ShortEnum[A <: ShortEnumEntry]   extends ValueEnum[Short, A]
trait StringEnum[A <: StringEnumEntry] extends ValueEnum[String, A] 
trait ByteEnum[A <: ByteEnumEntry]     extends ValueEnum[Byte, A]
trait CharEnum[A <: CharEnumEntry]     extends ValueEnum[Char, A] 

// ValueEnumEntry.scalaで定義されている各種抽象クラス
abstract class IntEnumEntry    extends ValueEnumEntry[Int]
abstract class LongEnumEntry   extends ValueEnumEntry[Long]
abstract class ShortEnumEntry  extends ValueEnumEntry[Short]
abstract class StringEnumEntry extends ValueEnumEntry[String]
abstract class ByteEnumEntry   extends ValueEnumEntry[Byte]
abstract class CharEnumEntry   extends ValueEnumEntry[Char]

このValueEnumを使ってDeliveryStatusを書き換えると、以下のようになります。
DeliveryStatusでは区分値にIntを使っていたので、ここではIntEnumおよびIntEnumEntryを使うこととします。

import enumeratum.values.{IntEnum, IntEnumEntry}

// ①継承元をIntEnumEntryに変更 ※valueを定義する必要あり
sealed abstract class DeliveryStatus(val value: Int) extends IntEnumEntry

// ②継承元をIntEnumに変更
object DeliveryStatus extends IntEnum[DeliveryStatus] {
  case object OrderConfirmed extends DeliveryStatus(1) // 注文済み
  case object Shipped        extends DeliveryStatus(2) // 発送中
  case object Delivered      extends DeliveryStatus(3) // 発送済
  case object Failed         extends DeliveryStatus(9) // 発送失敗

  // ③valuesはEnumのときと同じくfindValuesと書くだけ
  lazy val values: IndexedSeq[DeliveryStatus] = findValues
}

見た目はenumeratum.Enumを使ったときとほとんど変わりません。
ValueEnumの各種派生クラスはfindValuesを実装しているため、valuesの記述も同じです。
しかしValueEnumおよびValueEnumEntryを継承することで、先に書いたような区分値からの逆引きをこのままで使えるようになります。

// withValueで区分値から該当するobjectを取得できる。
scala> DeliveryStatus.withValue(9)
val res0: DeliveryStatus = Failed

// withValueに列挙に存在しない区分値を渡すとNoSuchElementExceptionになる。
scala> DeliveryStatus.withValue(10)
java.util.NoSuchElementException: 10 is not a member of ValueEnum (1, 2, 3, 9)
  at enumeratum.values.ValueEnum.$anonfun$withValue$1(ValueEnum.scala:58)
(中略)

// withValueOptは区分値から該当するobjectをSomeに詰めて返す。
scala> DeliveryStatus.withValueOpt(2)
val res2: Option[DeliveryStatus] = Some(Shipped)

// withValueOptに列挙に存在しない区分値を渡すとNoneを返す。
scala> DeliveryStatus.withValueOpt(10)
val res3: Option[DeliveryStatus] = None

ボイラープレートがぐっと減り、Scalaでも値付きの列挙を定義しやすくなりましたね!

##4. enumeratumを使う際の注意点

Scalaで列挙を定義する際の心強い味方となってくれるenumeratumですが、注意点として、enumeratum.Enumenumeratum.values.ValueEnum間には一切の継承関係が存在しません。
そのためentryNamewithNameメソッドなどはenumeratum.Enumを継承した列挙でしか使えませんし、valuesも単なる同名・同シグニチャのメソッドがEnumValueEnumの両方に別々に定義されているだけです。
なので、EnumValueEnumのどちらでも使える汎用的な処理を書きたいときに少し困ることがあるかもしれません。

この辺りの対処にも手を伸ばしたいところではありますが、当記事はここで一旦筆を置かせていただきます。

##5. おわりに
Scalaで列挙型を定義する際にsealed traitcase objectを組み合わせるのはよく知られた手法ですが、enumeratumを使用することで、更に実用的な列挙を簡単に実装できるようになります。
Javaの高機能なenumに慣れ親しんでいる方や、プログラム内になるべくボイラープレートを書きたくない方は、使用を検討してみることをおすすめいたします。


最後までお読み頂きありがとうございました。
質問や不備についてはコメント欄かTwitterまでお願いします。

  1. enumeratumのREADMEでもそうしています。

5
4
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
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?