Finalising Enumerations for Scala 3

Yeah, I gave that a try and they were really arcane errors. I’m also not particularly sure why those show up in the enum members, rather than the class:

enum Foo(ordinalArg: Int, val test: Int) {
  override val ordinal: Int = ordinalArg

  case One extends Foo(1, 2)
    // error overriding value ordinal in class Foo of type Int;
    //   method ordinal of type => Int needs `override` modifier

Based on the examples in the enum docs, I was pretty sure that annotating the constructor parameters with override val wouldn’t be needed, however subsequent testing shows that was an incorrect understanding on my part. Considering the previous de facto standard for encoding enums was using case classes, it might be worth calling that out in the docs.

1 Like

In the current desugaring, the enum class does not override ordinal, only individual cases, which are a further subclass. One of the reasons that ordinal can’t be overwritten is because currently the deriving.Mirror framework requires it to always reflect the compile time order of cases, so it can’t be user defined.

This is more of a problem because the current design removes the values array if you add a non-singleton case.

Non-singleton cases mean that there is no longer correspondence between values indexes and ordinals, meaning values is no longer a suitable API for deserialisation, unless you accept linear scanning.

For example, here we define a enum Color with a non-singleton case Rgb:

enum Color(val rgb: Int) {
  case Red           extends Color(0xf00)
  case Rgb(rgb: Int) extends Color(rgb)
  case Green         extends Color(0x0f0)
  case Blue          extends Color(0x00f)
}

the valid ordinals for fromOrdinal are Set(0, 2, 3). This set of ordinals can be recovered using macros at compiletime, so it is possible to generate a validOrdinal method with type class derivation for example, or create a type class that does the round trip but refines the type of a forwarder method to fromOrdinal with only the correct input values accepted statically.

One of the design constraints for enums is claiming the minimum API area possible and letting libraries add on their own functionality to suit different coding styles.

1 Like

I think methods values, ordinal and fromOrdinal make no sense for enums as they are in Scala 3 because those only work for constant enums.

There should be a discriminator to allow consistency. We can have enum for constant enums and enum class for other more advanced enums and enum adts.

Then enums only and not enum classs should have values, ordinal and fromOrdinal. enum classs should also have ordinal.

Of course, this needs to be investigated thoroughly but I think it is at least worth a look.

2 Likes

If it were not for requiring to add those methods to have intuitive support for Java’s enum API, these methods could all be added by type class derivation, which can assert at compiletime constraints such as “this enum contains only constant values” - this can still be done in order to make generic frameworks over any enum

This would be awesome but it targets mostly expert users.

I feel like the idea of separating the notions of constants enum and enums with products and/or generic will greatly simplify enums especially for new comers to the language.

The idea is simple: enum for constant enums and enum class for enums with products and/or generics.

Constant enums are by default java compatible so no need to extend java.lang.Enum.

During type class derivation we can provide way to to tell which type of enum is being used and there can be a reflection API to work with both.

I believe @odersky started out initially with separate notions of enum and enum class although I don’t remember why it was abandoned.

4 Likes

I’m starting to miss C/C+±style enums, which were just convenient thinly veiled Int constants.

During my years as a Java developer, I sometimes would add a Java enum, then follow the temptation to put serious functionality into it, then run up against some (at least to me) rather unexpected limitations, and eventually convert the enum into a regular (i.e. non-enum) class.

I find it hard to pin down what the problem was with Java enums, but that was my experience pretty consistently. I guess the idea of enums as a convenience and enums as a powerful tool don’t go well together.

1 Like

After some discussion we’ve decided to add an ordinal range check method, and to always allow iteration over values for the case where non-singleton values are declared

2 Likes

It would be good to discuss what would make a good API for backwards compatible enums - I have opened Backward compatible serialisation for enum values · Issue #10240 · lampepfl/dotty · GitHub for this

1 Like

This will be particularly useful for stuff like Protobuf, where you have a set of constant enum values, and a catch-all for unexpected values (currently clase class Unknown(value: Int) extends FooEnum). You rarely, if ever, want to iteration over the enum values to include Unknown, so excluding it from values is kind of perfect for this.

1 Like

Has anyone posted a “Miss me yet?” meme for scala.Enumeration?

(Edit: apologies in advance that the forum has no “thumbs down” button.)

You can still use scala.Enumeration in Scala 3, though, right? Or has it been deprecated because of the new enum design?

Oh, and because you asked.

3 Likes

I don’t see that it has even been deprecated.

PSA: Deprecation does not imply elimination.

I am pleased and embarrassed to learn that my newbie frustration is preserved on the internet. That must mean my suffering was not in vain.

The thread includes the classic reply:

You can use Java enums. But Scala will never have them.

That was a long time ago. Fortunately, I have forgotten all my pain points with Scala 2 and eagerly look forward to Scala 3.

3 Likes

sure, but practically no one uses it in Scala 2 anyway

1 Like

When an enum includes parameterized cases the values companion member is not generated (correctly, as it would need to include null elements). However the fromOrdinal member is generated, and is undefined at ordinals corresponding to parameterized cases. I find this surprising because fromOrdinal is not an inverse of ordinal in these cases (a roundabout way of saying it’s kind of weird to have fromOrdinal at all for such data types)

My initial suggestion was to generate fromOrdinal only in the same circumstances where values is generated; but it turns out readResolve for non-parameterized cases relies on fromOrdinal, so maybe we should make it private in this case?

4 Likes

The reasoning for fromOrdinal always being public is for a third party framework to always have a way to efficiently recover a singleton value from some canonical representation, which is needed for example maybe in a third party serialisation library, which would need to serialise the singleton values of an enum that also has parameterized cases. Perhaps ordinal is not the most obvious representation, or a fromOrdinal alternative should be left exclusively to frameworks to implement themselves.

1 Like

First, apologies that I’m not acquainted enough with internals to speak about the serialisation aspects.|

This reply is w.r.t. fromOrdinal / toOrdinal / values being generated at all or not, as opposed to whether fromOrdinal is changed to be a total function.

@tpolecat Yes, and since we are talking about inverse here, I think it’s also weird to have ordinal defined. But as it stands that one exists on the concrete value, or not, depending on whether it’s a nullary constructed value.

I do believe that fromOrdinal, toOrdinal and values should uniformly exist (or not) across the whole data type iff it is a coproduct of nullary data constructors.

package enumz

object Test {
  def main(args: Array[String]): Unit = {

    println(Nope.A.ordinal) // This should not compile
    //println(Nope.B.ordinal) // ok, value ordinal is not a member of object enumz.Nope
    //println(Nope.values) // ok, value values is not a member of object enumz.Nope.
                         // Although class Nope is an enum, it has non-singleton cases,
                         // meaning a values array is not definedbloop

    println(Nope.fromOrdinal(0)) // this should not compile
    println(Nope.fromOrdinal(1)) // this should not compile, blows up at runtime
  }
}


enum Nope {
  case A
  case B(x: Int)
  case C
}

Internals aside, any alternative behaviour is at the very least very surprising. Hopefully this is not a controversial opinion at all?

How about we make fromOrdinal return null instead of throwing an exception, that way those who want fromOrdinal to return an Option, need only wrap the call in Option(_).

So instead of Try(E.fromOrdinal(i)).toOption it’s simply Option(E.fromOrdinal(i)).

2 Likes

Does fromOrdinal really need to exist? If it’s added it will be in the autocomplete namespace of every enum, which is one of the headline features of Scala 3 and is so convenient that I expect sealed hierarchies will become extinct.
I don’t think it is a good idea to add another partial function to that autocomplete list. The proposed usage (for serialization) seems extremely sketchy to me as it is not stable if somebody decides to reorder the enum cases to be alphabetical or something. Java explicitly avoids this trap:

Enum constants are deserialized differently than ordinary serializable or externalizable objects. The serialized form of an enum constant consists solely of its name; field values of the constant are not transmitted. To deserialize an enum constant, ObjectInputStream reads the constant name from the stream; the deserialized constant is then obtained by calling the static method Enum.valueOf(Class, String) with the enum constant’s base type and the received constant name as arguments.

I guess there is an argument that this is much less efficient than using an Int but I see people troll themselves with List#head, Option#get with such regularity that it’s not funny anymore. People will use it if it is there and it won’t be able to be removed for a really long time.

EDIT: Or at least, could it be called unsafeFromOrdinal?

4 Likes

Yes, I think this needs to be supplied at least as one method out of several, or the feature is essentially broken for high-performance use. You can’t tolerate either exceptions (~1000x penalty for bad case) or Option (~3x slower for everything always). Given the high-performance version, you can get the others; but given the others, you’re always stuck.

So, yes, a null variant would be great. I don’t know if that should be the main variant. fromOrdinalOrNull, like applyOrNull, would be reasonable.

(I also agree that things that can’t possibly work shouldn’t compile.)

1 Like