Search code examples
javaenumsjava-record

Why can't enum and record be combined?


Recently I was just creating another enum type. I took advantage of the fact that in Java, an enum is a special type of class (and not a named integer constant, like in C#). I made it with two fields, an all-arg constructor and getters for both fields.

This is an example:

enum NamedIdentity {

    JOHN(1, "John the Slayer"),
    JILL(2, "Jill the Archeress");

    int id;
    String nickname;

    NamedIdentity(int id, String nickname) {
        this.id = id;
        this.nickname = nickname;
    }

    id id() {
        return this.id;
    }

    String nickname() {
        return this.nickname;
    }
}

Then I thought that Java 14's record keyword would have saved me the boilerplate code that this feature was trying to save me. This, as far as I know, doesn't combine with enums. If enum records would have existed, the abovementioned code would then look like this:

enum record NamedIdentity(int id, String nickname) {
    JOHN(1, "John the Slayer"),
    JILL(2, "Jill the Archeress");
}

My question is: is there a reason that enum records don't exist? I could imagine several reasons, including, but not limited to:

  • The number of use-cases for this feature would be too small, the Java language would benefit more if we, as Java language designers, designed and implemented other features.
  • This is hard to implement, due to the internal implementation of the enum type.
  • We, as Java language designers, simply didn't think about this, or we have not received such a request from the community yet, so we didn't prioritize it.
  • This feature would have semantical issues, or the implementation of this feature could cause semantic ambiguity or otherwise confusion.

Solution

  • tl;dr

    • Technical limitation: Multiple inheritance prevents mixing Enum with Record superclasses.
    • Workaround: Keep a record as member field of your enum
    NamedIdentity.JILL.detail.nickname()  // ➣ "Jill the Archeress"
    

    Multiple inheritance

    You asked:

    is there a reason that enum records don't exist?

    The technical reason is that in Java, every enum is implicitly a subclass of Enum class while every record is implicitly a subclass of Record class.

    Java does not support multiple inheritance. So the two cannot be combined.

    Perhaps a workaround could have been devised. The Java team considered a feature to be able to combine enum with record, but decided against it for whatever reasons. See Comment above by Brian Goetz, the Java Language Architect at Oracle.

    Semantics

    But more important is the semantics.

    An enum is for declaring at compile time a limited set of named instances. Each of the enum objects is instantiated when the enum class loads. At runtime, we cannot instantiate any more objects of that class (well, maybe with extreme reflection/introspection code, but I’ll ignore that).

    A record in Java is not automatically instantiated. Your code instantiated as many objects of that class as you want, by calling new. So not at all the same.

    Boilerplate reduction is not the goal of record

    You said:

    I thought that Java 14's record keyword would have saved me the boilerplate code

    You misunderstand the purpose of record feature. I suggest you read JEP 395, and watch the latest presentation by Brian Goetz on the subject.

    As commented by Johannes Kuhn, the goal of record is not to reduce boilerplate. Such reduction is a pleasant side-effect, but is not the reason for the invention of record.

    A record is meant to be a “nominal tuple” in formal terms.

    • Tuple means a collection of values of various types laid out in a certain order, or as Wikipedia says: “finite ordered list (sequence) of elements”.
    • Nominal means the elements each have a name.

    A record is meant to be a simple and transparent data carrier. Transparent means all its member fields are exposed. Its getter methods are simply named the same as the field, without using the get… convention of JavaBeans. The default implementation of hashCode and equals is to inspect each and every member field. The intention for a record is to focus on the data being carried, not behavior (methods).

    Furthermore, a record is meant to be shallowly immutable. Immutable means you cannot change the primitive values, nor can you change the object references, in an instance of a record. The objects within a record instance may be mutable themselves, which is what we mean by shallowly. But values of the record’s own fields, either primitive values or object references, cannot be changed. You cannot re-assign a substitute object as one of the record’s member fields.

    • When you have a limited set of instances known at compile time, use enum.
    • When you are writing a class whose primary job is to immutably and transparently carry a group of data fields, use record.

    Worthy question

    I can see where the two concepts could intersect, where at compile time we know of a limited set of immutable transparent collections of named values. So your question is valid, but not because of boilerplate reduction. Brian Goetz said the Java team did indeed consider this idea.

    Workaround: Store a record on your enum

    The workaround is quite simple: Keep a record instance on your enum.

    You could pass a record to your enum constructor, and store that record as a member field on the enum definition.

    Make the member field final. That makes our enum immutable. So, no need to mark private, and no need to add a getter method.

    First, the record definition.

    package org.example;
    
    public record Performer(int id , String nickname)
    {
    }
    

    Next, we pass an instance of record to the enum constructor.

    package org.example;
    
    public enum NamedIdentity
    {
        JOHN( new Performer( 1 , "John the Slayer" ) ),
        JILL( new Performer( 2 , "Jill the Archeress" ) );
    
        final Performer performer;
    
        NamedIdentity ( final Performer performer ) { this.performer = performer; }
    }
    

    If the record only makes sense within the context of the enum, we can nest the two together rather than have separate .java files. The record feature was built with nesting in mind, and works well there.

    Naming the nested record might be tricky in some cases. I imagine something like Detail might do as a plain generic label if no better name is obvious.

    package org.example;
    
    public enum NamedIdentity
    {
        JOHN( new Performer( 1 , "John the Slayer" ) ),
        JILL( new Performer( 2 , "Jill the Archeress" ) );
    
        final Performer performer;
    
        NamedIdentity ( final Performer performer ) { this.performer = performer; }
    
        public record Performer(int id , String nickname) {}
    }
    

    To my mind, this workaround is a solid solution. We get clarity in the code with the bonus of reducing boilerplate. I like this as a general replacement for keeping a bunch of data fields on the enum, as using a record makes the intention explicit and obvious. I expect to use this in my future work, thanks to your Question.

    Let's exercise that code.

    for ( NamedIdentity namedIdentity : NamedIdentity.values() )
    {
        System.out.println( "---------------------" );
        System.out.println( "enum name: " + namedIdentity.name() );
        System.out.println( "id: " + namedIdentity.performer.id() );
        System.out.println( "nickname: " + namedIdentity.performer.nickname() );
    }
    System.out.println( "---------------------" );
    
    

    When run.

    ---------------------
    enum name: JOHN
    id: 1
    nickname: John the Slayer
    ---------------------
    enum name: JILL
    id: 2
    nickname: Jill the Archeress
    ---------------------
    

    Declare locally

    FYI, now in Java 16+, we can declare enums, records, and interfaces locally. This came about as part of the work done to create the records feature. See JEP 395: Records.

    So enums, records, and interfaces can be declared at any of three levels:

    • In their own .java. file.
    • Nested within a class.
    • Locally, within a method.

    I mention this in passing, not directly relevant to this Question.