ANN: EnumX.jl -- improved enums for Julia

I wrote a small package EnumX that I want to introduce.

Julia has builtin support for enums with the @enum macro, but such enums have some drawbacks, where the namespace/scope of the instances is the most annoying. This, and other problems with @enum have been discussed a lot on this forum (for example here, here, and here). I decided to package some ideas from those threads into EnumX.jl, to make enums nicer to work with.


EnumX provides the @enumx macro, which, on the surface, is similar to @enum and have the same surface syntax, but with some nice improvements. Let’s look at an example: This defines an enum Fruit with the two instances Apple and Banana:

julia> using EnumX

julia> @enumx Fruit Apple Banana

The instances are accessed using dot-syntax:

julia> Fruit.Apple
Fruit.Apple = 0

julia> Fruit.Banana
Fruit.Banana = 1

The secret sauce here is that Fruit is a module, and the enum-type is instead defined as Fruit.T (this name can be configured, see the README):

julia> Fruit.T
Enum type Fruit.T <: Enum{Int32} with 2 instances:
 Fruit.Apple  = 0
 Fruit.Banana = 1

This solves the problem with scope – the only name that is occupied by this enum is the module name Fruit, which means that it is possible to use “common names” for instances without polluting the namespace. It also means that it is possible to define another enum in the same namespace, with the same instance name(s), e.g.:

julia> @enumx AnotherFruit Apple Banana

@enumx has some other niceties such as:

  • Support for duplicate values, e.g. @enumx Fruit Apple = 1 Banana = 1)
  • Reusing previous instances for value initialization, e.g. @enumx Fruit Apple = 3 Banana = Apple)
  • Support for docstrings:
    """
    Documentation for Fruit enum.
    """
    @enumx Fruit begin
        "Documentation for Fruit.Apple instance."
        Apple
        "Documentation for Fruit.Banana instance."
        Banana
    end
    

Other than that, @enumx should be a drop-in replacement for @enum, and should support the same syntax and the same features, see the README for more details.

74 Likes

This is really cool. Coming from Rust, I’ve always wondered why enums aren’t first class citizen in so many languages. In Julia it happens often to have some kind of “mode” with which operate things, and having a small set of possible values to set said mode is just very handy.

3 Likes

Beautiful, thank you!

2 Likes

This is really nice. I appreciate that the syntax mimics that of @enum, but always found it a bit strange that the name of the enumeration (in this case Fruit) is just part of the list of names.

It would look clearer if it said

@enumx Fruit: Apple Banana
# or
@enumx Fruit(Apple, Banana)

for example. Would that be possible to use as an optional syntax?

4 Likes

Both @enum and @enumx support block syntax, so you can use

@enumx Fruit begin
    Apple
    Banana
end

which clearly separates the name and the instances. Alternatively, adding the type annotation for the name, which also looks a bit like a separator (like : in your example)

@enumx Fruit::Int32 Apple Banana

It’s not difficult to support the other syntaxes though…

11 Likes

nicely done. thank you.

Hi @fredrikekre, there seems to be an overhead when using @match on @enumx vs @enum, can you please take a look?

using EnumX
using Match

@enum FruitEnum begin
    Apple = 1
    Banana=2
end

@enumx FruitX begin
    Apple = 1
    Banana=2
end

function EatEnum(food::FruitEnum)
    @match food begin
        Apple => "very tasty"
        Banana => "maybe not"
    end
end

function EatEnumX(food::FruitX.T)
    @match food begin
        FruitX.Apple => "very tasty"
        FruitX.Banana => "maybe not"
    end
end

But matching on them generates vastly different code:

julia> @code_native EatEnum(Apple)
        .text
; ┌ @ REPL[14]:1 within `EatEnum`
        movabsq $140133313093552, %rax          # imm = 0x7F7354591FB0
; │ @ REPL[14]:3 within `EatEnum`
        retq
        nopl    (%rax,%rax)
; â””

julia> @code_native EatEnumX(FruitX.Apple)
        .text
; ┌ @ REPL[15]:3 within `EatEnumX`
        cmpl    $2, %edi
        movabsq $140131860384528, %rax          # imm = 0x7F72FDC28B10
        movabsq $140133346115592, %rcx          # imm = 0x7F7356510008
        cmoveq  %rax, %rcx
; │┌ @ matchutils.jl:12 within `ismatch`
; ││┌ @ Base.jl:119 within `==`
        cmpl    $1, %edi
        movabsq $140131860384400, %rax          # imm = 0x7F72FDC28A90
; │└└
        cmovneq %rcx, %rax
        retq
        nopl    (%rax)
; â””
1 Like

Seems to be a bug or wrong usage of @match – your EatEnum function returns "very tasty" for any input:

julia> @macroexpand @match food begin
           Apple => "very tasty"
           Banana => "maybe not"
       end
quote
    "very tasty"
end
julia> @macroexpand @match food begin
           FruitX.Apple => "very tasty"
           FruitX.Banana => "maybe not"
       end
quote
    if Match.ismatch(FruitX.Apple, food)
        "very tasty"
    else
        begin
            if Match.ismatch(FruitX.Banana, food)
                "maybe not"
            else
                nothing
            end
        end
    end
end

Edit: See Match.jl#56, Match.jl#72.

1 Like

Thanks for pointing that out, how silly of me not to do the basic testing and assuming it works. (But it seems I wasn’t alone in this, per comments in the issue to Match.jl)

So the following indeed works:

using EnumX
using Match

@enumx FruitX begin
    Apple = 1
    Banana = 2
end

function EatEnumX(food::FruitX.T)
    @match food begin
        FruitX.Apple => "very tasty"
        FruitX.Banana => "maybe not"
    end
end

@assert EatEnumX(FruitX.Apple) == "very tasty"
@assert EatEnumX(FruitX.Banana) == "maybe not"

@macroexpand @match food begin
    FruitX.Apple => "very tasty"
    FruitX.Banana => "maybe not"
end

What about MLStyle.jl (mentioned in the issues) with EnumX?

using EnumX
using MLStyle
using MLStyle.AbstractPatterns: literal

@enumx FruitX begin
    Apple = 1
    Banana = 2
end

MLStyle.is_enum(::FruitX.T) = true
MLStyle.pattern_uncall(e::FruitX.T, _, _, _, _) = literal(e)

function EatEnumX(food::FruitX.T)
    @match food begin
        FruitX.Apple => "very tasty"
        FruitX.Banana => "maybe not"
    end
end

doesn’t work, getting this error:

LoadError: PatternCompilationError(:(#= In[1]:15 =#), ErrorException("unknown pattern syntax :(FruitX.Apple)"))

Isn’t it supposed to be &FruitX.Apple? Though maybe the raw form should work too, since it works on Julia enums.

1 Like

You’re right, that indeed works, thanks!! :star_struck:

In fact, it then works even without is_enum and pattern_uncall, and furthermore it translates to LLVM’s switch statement!

using EnumX
using MLStyle

@enumx FruitX begin
    Apple = 1
    Banana = 2
end

function EatEnumX(food::FruitX.T)
    @match food begin
        &FruitX.Apple => "very tasty"
        &FruitX.Banana => "maybe not"
    end
end

What’s the reason why the EnumX doesn’t define Base.convert(::Type{baseT}, x::MyEnum.T) = baseT(x)?

I came across this when trying to define ArrowTypes hooks for my EnumX type. To my surprise, this isn’t enough:

ArrowTypes.ArrowType(::Type{MyEnumX.T}) = Base.Enums.basetype(MyEnumX.T)
ArrowTypes.arrowname(::Type{MyEnumX.T}) = Symbol("JuliaLang.MyEnumX")
ArrowTypes.JuliaType(::Val{Symbol("JuliaLang.MyEnumX")}) = MyEnumX.T

The reason for this is that the default way that ArrowTypes constructs types is by calling convert. I think this a sensible default as it’s the one most likely to work. For serializing into ArrowTypes I can make it work by spelling out the way to do it:

ArrowTypes.toarrow(x::MyEnumX.T) = Base.Enums.basetype(MyEnumX.T)(x)

But it appears to me that if EnumX has a natural conversion into baseT (normally Int32), that probably should come defined in the package:

Base.convert(::Type{baseT}, x::MyEnum.T) = baseT(x)

Thoughts?