Case objects vs Enumerations in Scala

前端 未结 14 1018
误落风尘
误落风尘 2020-11-22 16:45

Are there any best-practice guidelines on when to use case classes (or case objects) vs extending Enumeration in Scala?

They seem to offer some of the same benefits.

相关标签:
14条回答
  • 2020-11-22 17:22

    One big difference is that Enumerations come with support for instantiating them from some name String. For example:

    object Currency extends Enumeration {
       val GBP = Value("GBP")
       val EUR = Value("EUR") //etc.
    } 
    

    Then you can do:

    val ccy = Currency.withName("EUR")
    

    This is useful when wishing to persist enumerations (for example, to a database) or create them from data residing in files. However, I find in general that enumerations are a bit clumsy in Scala and have the feel of an awkward add-on, so I now tend to use case objects. A case object is more flexible than an enum:

    sealed trait Currency { def name: String }
    case object EUR extends Currency { val name = "EUR" } //etc.
    
    case class UnknownCurrency(name: String) extends Currency
    

    So now I have the advantage of...

    trade.ccy match {
      case EUR                   =>
      case UnknownCurrency(code) =>
    }
    

    As @chaotic3quilibrium pointed out (with some corrections to ease reading):

    Regarding "UnknownCurrency(code)" pattern, there are other ways to handle not finding a currency code string than "breaking" the closed set nature of the Currency type. UnknownCurrency being of type Currency can now sneak into other parts of an API.

    It's advisable to push that case outside Enumeration and make the client deal with an Option[Currency] type that would clearly indicate there is really a matching problem and "encourage" the user of the API to sort it out him/herself.

    To follow up on the other answers here, the main drawbacks of case objects over Enumerations are:

    1. Can't iterate over all instances of the "enumeration". This is certainly the case, but I've found it extremely rare in practice that this is required.

    2. Can't instantiate easily from persisted value. This is also true but, except in the case of huge enumerations (for example, all currencies), this doesn't present a huge overhead.

    0 讨论(0)
  • 2020-11-22 17:22

    UPDATE: The code below has a bug, described here. The test program below works, but if you were to use DayOfWeek.Mon (for example) before DayOfWeek itself, it would fail because DayOfWeek has not been initialized (use of an inner object does not cause an outer object to be initialized). You can still use this code if you do something like val enums = Seq( DayOfWeek ) in your main class, forcing initialization of your enums, or you can use chaotic3quilibrium's modifications. Looking forward to a macro-based enum!


    If you want

    • warnings about non-exhaustive pattern matches
    • an Int ID assigned to each enum value, which you can optionally control
    • an immutable List of the enum values, in the order they were defined
    • an immutable Map from name to enum value
    • an immutable Map from id to enum value
    • places to stick methods/data for all or particular enum values, or for the enum as a whole
    • ordered enum values (so you can test, for example, whether day < Wednesday)
    • the ability to extend one enum to create others

    then the following may be of interest. Feedback welcome.

    In this implementation there are abstract Enum and EnumVal base classes, which you extend. We'll see those classes in a minute, but first, here's how you would define an enum:

    object DayOfWeek extends Enum {
      sealed abstract class Val extends EnumVal
      case object Mon extends Val; Mon()
      case object Tue extends Val; Tue()
      case object Wed extends Val; Wed()
      case object Thu extends Val; Thu()
      case object Fri extends Val; Fri()
      case object Sat extends Val; Sat()
      case object Sun extends Val; Sun()
    }
    

    Note that you have to use each enum value (call its apply method) to bring it to life. [I wish inner objects weren't lazy unless I specifically ask for them to be. I think.]

    We could of course add methods/data to DayOfWeek, Val, or the individual case objects if we so desired.

    And here's how you would use such an enum:

    object DayOfWeekTest extends App {
    
      // To get a map from Int id to enum:
      println( DayOfWeek.valuesById )
    
      // To get a map from String name to enum:
      println( DayOfWeek.valuesByName )
    
      // To iterate through a list of the enum values in definition order,
      // which can be made different from ID order, and get their IDs and names:
      DayOfWeek.values foreach { v => println( v.id + " = " + v ) }
    
      // To sort by ID or name:
      println( DayOfWeek.values.sorted mkString ", " )
      println( DayOfWeek.values.sortBy(_.toString) mkString ", " )
    
      // To look up enum values by name:
      println( DayOfWeek("Tue") ) // Some[DayOfWeek.Val]
      println( DayOfWeek("Xyz") ) // None
    
      // To look up enum values by id:
      println( DayOfWeek(3) )         // Some[DayOfWeek.Val]
      println( DayOfWeek(9) )         // None
    
      import DayOfWeek._
    
      // To compare enums as ordinals:
      println( Tue < Fri )
    
      // Warnings about non-exhaustive pattern matches:
      def aufDeutsch( day: DayOfWeek.Val ) = day match {
        case Mon => "Montag"
        case Tue => "Dienstag"
        case Wed => "Mittwoch"
        case Thu => "Donnerstag"
        case Fri => "Freitag"
     // Commenting these out causes compiler warning: "match is not exhaustive!"
     // case Sat => "Samstag"
     // case Sun => "Sonntag"
      }
    
    }
    

    Here's what you get when you compile it:

    DayOfWeekTest.scala:31: warning: match is not exhaustive!
    missing combination            Sat
    missing combination            Sun
    
      def aufDeutsch( day: DayOfWeek.Val ) = day match {
                                             ^
    one warning found
    

    You can replace "day match" with "( day: @unchecked ) match" where you don't want such warnings, or simply include a catch-all case at the end.

    When you run the above program, you get this output:

    Map(0 -> Mon, 5 -> Sat, 1 -> Tue, 6 -> Sun, 2 -> Wed, 3 -> Thu, 4 -> Fri)
    Map(Thu -> Thu, Sat -> Sat, Tue -> Tue, Sun -> Sun, Mon -> Mon, Wed -> Wed, Fri -> Fri)
    0 = Mon
    1 = Tue
    2 = Wed
    3 = Thu
    4 = Fri
    5 = Sat
    6 = Sun
    Mon, Tue, Wed, Thu, Fri, Sat, Sun
    Fri, Mon, Sat, Sun, Thu, Tue, Wed
    Some(Tue)
    None
    Some(Thu)
    None
    true
    

    Note that since the List and Maps are immutable, you can easily remove elements to create subsets, without breaking the enum itself.

    Here is the Enum class itself (and EnumVal within it):

    abstract class Enum {
    
      type Val <: EnumVal
    
      protected var nextId: Int = 0
    
      private var values_       =       List[Val]()
      private var valuesById_   = Map[Int   ,Val]()
      private var valuesByName_ = Map[String,Val]()
    
      def values       = values_
      def valuesById   = valuesById_
      def valuesByName = valuesByName_
    
      def apply( id  : Int    ) = valuesById  .get(id  )  // Some|None
      def apply( name: String ) = valuesByName.get(name)  // Some|None
    
      // Base class for enum values; it registers the value with the Enum.
      protected abstract class EnumVal extends Ordered[Val] {
        val theVal = this.asInstanceOf[Val]  // only extend EnumVal to Val
        val id = nextId
        def bumpId { nextId += 1 }
        def compare( that:Val ) = this.id - that.id
        def apply() {
          if ( valuesById_.get(id) != None )
            throw new Exception( "cannot init " + this + " enum value twice" )
          bumpId
          values_ ++= List(theVal)
          valuesById_   += ( id       -> theVal )
          valuesByName_ += ( toString -> theVal )
        }
      }
    
    }
    

    And here is a more advanced use of it which controls the IDs and adds data/methods to the Val abstraction and to the enum itself:

    object DayOfWeek extends Enum {
    
      sealed abstract class Val( val isWeekday:Boolean = true ) extends EnumVal {
        def isWeekend = !isWeekday
        val abbrev = toString take 3
      }
      case object    Monday extends Val;    Monday()
      case object   Tuesday extends Val;   Tuesday()
      case object Wednesday extends Val; Wednesday()
      case object  Thursday extends Val;  Thursday()
      case object    Friday extends Val;    Friday()
      nextId = -2
      case object  Saturday extends Val(false); Saturday()
      case object    Sunday extends Val(false);   Sunday()
    
      val (weekDays,weekendDays) = values partition (_.isWeekday)
    }
    
    0 讨论(0)
  • 2020-11-22 17:22

    I have a nice simple lib here that allows you to use sealed traits/classes as enum values without having to maintain your own list of values. It relies on a simple macro that is not dependent on the buggy knownDirectSubclasses.

    https://github.com/lloydmeta/enumeratum

    0 讨论(0)
  • 2020-11-22 17:23

    UPDATE: A new macro based solution has been created which is far superior to the solution I outline below. I strongly recommend using this new macro based solution. And it appears plans for Dotty will make this style of enum solution part of the language. Whoohoo!

    Summary:
    There are three basic patterns for attempting to reproduce the Java Enum within a Scala project. Two of the three patterns; directly using Java Enum and scala.Enumeration, are not capable of enabling Scala's exhaustive pattern matching. And the third one; "sealed trait + case object", does...but has JVM class/object initialization complications resulting in inconsistent ordinal index generation.

    I have created a solution with two classes; Enumeration and EnumerationDecorated, located in this Gist. I didn't post the code into this thread as the file for Enumeration was quite large (+400 lines - contains lots of comments explaining implementation context).

    Details:
    The question you're asking is pretty general; "...when to use caseclassesobjects vs extending [scala.]Enumeration". And it turns out there are MANY possible answers, each answer depending on the subtleties of the specific project requirements you have. The answer can be reduced down to three basic patterns.

    To start, let's make sure we are working from the same basic idea of what an enumeration is. Let's define an enumeration mostly in terms of the Enum provided as of Java 5 (1.5):

    1. It contains a naturally ordered closed set of named members
      1. There is a fixed number of members
      2. Members are naturally ordered and explicitly indexed
        • As opposed to being sorted based on some inate member queriable criteria
      3. Each member has a unique name within the total set of all members
    2. All members can easily be iterated through based on their indexes
    3. A member can be retrieved with its (case sensitive) name
      1. It would be quite nice if a member could also be retrieved with its case insensitive name
    4. A member can be retrieved with its index
    5. Members may easily, transparently and efficiently use serialization
    6. Members may be easily extended to hold additional associated singleton-ness data
    7. Thinking beyond Java's Enum, it would be nice to be able to explicitly leverage Scala's pattern matching exhaustiveness checking for an enumeration

    Next, let's look at boiled down versions of the three most common solution patterns posted:

    A) Actually directly using Java Enum pattern (in a mixed Scala/Java project):

    public enum ChessPiece {
        KING('K', 0)
      , QUEEN('Q', 9)
      , BISHOP('B', 3)
      , KNIGHT('N', 3)
      , ROOK('R', 5)
      , PAWN('P', 1)
      ;
    
      private char character;
      private int pointValue;
    
      private ChessPiece(char character, int pointValue) {
        this.character = character; 
        this.pointValue = pointValue;   
      }
    
      public int getCharacter() {
        return character;
      }
    
      public int getPointValue() {
        return pointValue;
      }
    }
    

    The following items from the enumeration definition are not available:

    1. 3.1 - It would be quite nice if a member could also be retrieved with its case insensitive name
    2. 7 - Thinking beyond Java's Enum, it would be nice to be able to explicitly leverage Scala's pattern matching exhaustiveness checking for an enumeration

    For my current projects, I don't have the benefit of taking the risks around the Scala/Java mixed project pathway. And even if I could choose to do a mixed project, item 7 is critical for allowing me to catch compile time issues if/when I either add/remove enumeration members, or am writing some new code to deal with existing enumeration members.


    B) Using the "sealed trait + case objects" pattern:

    sealed trait ChessPiece {def character: Char; def pointValue: Int}
    object ChessPiece {
      case object KING extends ChessPiece {val character = 'K'; val pointValue = 0}
      case object QUEEN extends ChessPiece {val character = 'Q'; val pointValue = 9}
      case object BISHOP extends ChessPiece {val character = 'B'; val pointValue = 3}
      case object KNIGHT extends ChessPiece {val character = 'N'; val pointValue = 3}
      case object ROOK extends ChessPiece {val character = 'R'; val pointValue = 5}
      case object PAWN extends ChessPiece {val character = 'P'; val pointValue = 1}
    }
    

    The following items from the enumeration definition are not available:

    1. 1.2 - Members are naturally ordered and explicitly indexed
    2. 2 - All members can easily be iterated through based on their indexes
    3. 3 - A member can be retrieved with its (case sensitive) name
    4. 3.1 - It would be quite nice if a member could also be retrieved with its case insensitive name
    5. 4 - A member can be retrieved with its index

    It's arguable it really meets enumeration definition items 5 and 6. For 5, it's a stretch to claim it's efficient. For 6, it's not really easy to extend to hold additional associated singleton-ness data.


    C) Using the scala.Enumeration pattern (inspired by this StackOverflow answer):

    object ChessPiece extends Enumeration {
      val KING = ChessPieceVal('K', 0)
      val QUEEN = ChessPieceVal('Q', 9)
      val BISHOP = ChessPieceVal('B', 3)
      val KNIGHT = ChessPieceVal('N', 3)
      val ROOK = ChessPieceVal('R', 5)
      val PAWN = ChessPieceVal('P', 1)
      protected case class ChessPieceVal(character: Char, pointValue: Int) extends super.Val()
      implicit def convert(value: Value) = value.asInstanceOf[ChessPieceVal]
    }
    

    The following items from the enumeration definition are not available (happens to be identical to the list for directly using the Java Enum):

    1. 3.1 - It would be quite nice if a member could also be retrieved with its case insensitive name
    2. 7 - Thinking beyond Java's Enum, it would be nice to be able to explicitly leverage Scala's pattern matching exhaustiveness checking for an enumeration

    Again for my current projects, item 7 is critical for allowing me to catch compile time issues if/when I either add/remove enumeration members, or am writing some new code to deal with existing enumeration members.


    So, given the above definition of an enumeration, none of the above three solutions work as they do not provide everything outlined in the enumeration definition above:

    1. Java Enum directly in a mixed Scala/Java project
    2. "sealed trait + case objects"
    3. scala.Enumeration

    Each of these solutions can be eventually reworked/expanded/refactored to attempt to cover some of each one's missing requirements. However, neither the Java Enum nor the scala.Enumeration solutions can be sufficiently expanded to provide item 7. And for my own projects, this is one of the more compelling values of using a closed type within Scala. I strongly prefer compile time warnings/errors to indicate I have a gap/issue in my code as opposed to having to glean it out of a production runtime exception/failure.


    In that regard, I set about working with the case object pathway to see if I could produce a solution which covered all of the enumeration definition above. The first challenge was to push through the core of the JVM class/object initialization issue (covered in detail in this StackOverflow post). And I was finally able to figure out a solution.

    As my solution is two traits; Enumeration and EnumerationDecorated, and since the Enumeration trait is over +400 lines long (lots of comments explaining context), I am forgoing pasting it into this thread (which would make it stretch down the page considerbly). For details, please jump directly to the Gist.

    Here's what the solution ends up looking like using the same data idea as above (fully commented version available here) and implemented in EnumerationDecorated.

    import scala.reflect.runtime.universe.{TypeTag,typeTag}
    import org.public_domain.scala.utils.EnumerationDecorated
    
    object ChessPiecesEnhancedDecorated extends EnumerationDecorated {
      case object KING extends Member
      case object QUEEN extends Member
      case object BISHOP extends Member
      case object KNIGHT extends Member
      case object ROOK extends Member
      case object PAWN extends Member
    
      val decorationOrderedSet: List[Decoration] =
        List(
            Decoration(KING,   'K', 0)
          , Decoration(QUEEN,  'Q', 9)
          , Decoration(BISHOP, 'B', 3)
          , Decoration(KNIGHT, 'N', 3)
          , Decoration(ROOK,   'R', 5)
          , Decoration(PAWN,   'P', 1)
        )
    
      final case class Decoration private[ChessPiecesEnhancedDecorated] (member: Member, char: Char, pointValue: Int) extends DecorationBase {
        val description: String = member.name.toLowerCase.capitalize
      }
      override def typeTagMember: TypeTag[_] = typeTag[Member]
      sealed trait Member extends MemberDecorated
    }
    

    This is an example usage of a new pair of enumeration traits I created (located in this Gist) to implement all of the capabilities desired and outlined in the enumeration definition.

    One concern expressed is that the enumeration member names must be repeated (decorationOrderedSet in the example above). While I did minimize it down to a single repetition, I couldn't see how to make it even less due to two issues:

    1. JVM object/class initialization for this particular object/case object model is undefined (see this Stackoverflow thread)
    2. The content returned from the method getClass.getDeclaredClasses has an undefined order (and it is quite unlikely to be in the same order as the case object declarations in the source code)

    Given these two issues, I had to give up trying to generate an implied ordering and had to explicitly require the client define and declare it with some sort of ordered set notion. As the Scala collections do not have an insert ordered set implementation, the best I could do was use a List and then runtime check that it was truly a set. It's not how I would have preferred to have achieved this.

    And given the design required this second list/set ordering val, given the ChessPiecesEnhancedDecorated example above, it was possible to add case object PAWN2 extends Member and then forget to add Decoration(PAWN2,'P2', 2) to decorationOrderedSet. So, there is a runtime check to verify that the list is not only a set, but contains ALL of the case objects which extend the sealed trait Member. That was a special form of reflection/macro hell to work through.


    Please leave comments and/or feedback on the Gist.

    0 讨论(0)
  • 2020-11-22 17:23

    Another disadvantage of case classes versus Enumerations when you will need to iterate or filter across all instances. This is a built-in capability of Enumeration (and Java enums as well) while case classes don't automatically support such capability.

    In other words: "there's no easy way to get a list of the total set of enumerated values with case classes".

    0 讨论(0)
  • 2020-11-22 17:26

    Case objects already return their name for their toString methods, so passing it in separately is unnecessary. Here is a version similar to jho's (convenience methods omitted for brevity):

    trait Enum[A] {
      trait Value { self: A => }
      val values: List[A]
    }
    
    sealed trait Currency extends Currency.Value
    object Currency extends Enum[Currency] {
      case object EUR extends Currency
      case object GBP extends Currency
      val values = List(EUR, GBP)
    }
    

    Objects are lazy; by using vals instead we can drop the list but have to repeat the name:

    trait Enum[A <: {def name: String}] {
      trait Value { self: A =>
        _values :+= this
      }
      private var _values = List.empty[A]
      def values = _values
    }
    
    sealed abstract class Currency(val name: String) extends Currency.Value
    object Currency extends Enum[Currency] {
      val EUR = new Currency("EUR") {}
      val GBP = new Currency("GBP") {}
    }
    

    If you don't mind some cheating, you can pre-load your enumeration values using the reflection API or something like Google Reflections. Non-lazy case objects give you the cleanest syntax:

    trait Enum[A] {
      trait Value { self: A =>
        _values :+= this
      }
      private var _values = List.empty[A]
      def values = _values
    }
    
    sealed trait Currency extends Currency.Value
    object Currency extends Enum[Currency] {
      case object EUR extends Currency
      case object GBP extends Currency
    }
    

    Nice and clean, with all the advantages of case classes and Java enumerations. Personally, I define the enumeration values outside of the object to better match idiomatic Scala code:

    object Currency extends Enum[Currency]
    sealed trait Currency extends Currency.Value
    case object EUR extends Currency
    case object GBP extends Currency
    
    0 讨论(0)
提交回复
热议问题