[ANN] Expronicon v0.8: type-stable rust-like algebra data type & pretty printing Julia expr & more

I’m excited to announce a few new features to the Expronicon package’s recent releases (v0.8), as it starts
getting stable I’m going to release a 1.0 version for the next stage if there are no significant changes in the future.

Type-stable algebra data types (ADT)

There have been many attempts to support ADT from a package, such as MLStyle’s @datatype, the Unityper package, and ErrorTypes that try to mimic rust-like error types. It is crucial to many compiler-like programs, such as symbolic engines (Symbolics.jl), parsers, error handling, etc.

One issue with the previous implementation was type stability, e.g in MLStyle the @datatype is actually implemented as lowering the ADT-like syntax into a set of Julia struct types, and a corresponding abstract type then supporting it with pattern match as control flow. This as a result will not be type stable when the constructed object needs to re-dispatch to different variant types.

The other issue is the pattern match, e.g Unityper supports ADT throw @compactify but there is no pattern matching support for the definition, resulting in not-so-idiomatic dispatches such as this example

foo!(xs) = for i in eachindex(xs)
    @inbounds x = xs[i]
    @inbounds xs[i] = @compactified x::AT begin
        A => D()
        B => A()
        C => B()
        D => A()
    end
end

The new ADT support in Expronicon get you both: type stability with pattern match with a set of new interfaces for the variants (“subtypes” of the ADT), you can construct the ADT in rust-enum-like syntax

@adt Food begin
    Apple
    Orange
    Banana
end
@adt Message begin
    Info(::String)
    Warning(::String)
    Error(::String)
end
@adt Animal begin
    struct Cat
        name::String = "Tom"
        age::Int = 3
    end
    struct Dog
        name::String = "Jack"
        age::Int = 5
    end
end

Pattern Match

and use MLStyle’s pattern match to dispatch methods for them, the following is an example from rust’s documentation rewrite with Expronicon in Julia

@adt Message begin
    Quit

    struct Move
        x::Int
        y::Int = 1
    end

    Write(::String)

    ChangeColor(::Int, ::Int, ::Int)
end

and you can match the variants using the exact same syntax as how you construct them

positional constructor pattern

julia> @match Move(1, 2) begin
           Move(x, y) => x + y
           _ => false
       end
3

keyword constructor pattern

julia> @match Move(1, 2) begin
           Move(;x) => x
           _ => false
       end
1

or you can match the singletons

julia> @match Quit begin
        Quit => true
        _ => false
    end
true

Type-stable error handling

In many Julia programmers’ programs, we use error/result types to handle errors, e.g

however, they all have the issue that causes type-instability in your function, now with Expronicon’s ADT,
you can easily do it in a type-stable way

@adt Result begin
    OK
    Error(::String)
end

Reflections

Expronicon’s ADT supports a rich set of reflections, you can query the variant type via the variant_type function, and a full list of auto-generated reflections is available here: https://github.com/Roger-luo/Expronicon.jl/blob/main/src/adt/traits.jl

As fast as Unityper

benchmarking with the example in Unityper’s README.

julia> using BenchmarkTools

julia> @btime UnityperBench.foo!($xs)
  57.834 μs (0 allocations: 0 bytes)

julia> @btime ExproniconBench.foo!($ys)
  57.625 μs (0 allocations: 0 bytes)

julia> @btime NaiveBench.foo!($gs)
  93.375 μs (10000 allocations: 312.50 KiB)

Limitations

In order to enforce type-stability, we are not able to support generic ADT anymore (e.g the Option type in rust), this is because implementing generic ADT in an efficient way requires Julia compiler to understand the type and be able to infer the type accordingly. Otherwise, we will have many different copies of the same singleton type if implemented with macros, e.g

@adt Option{T} begin
    None
    Result(::T)
end

because None is not parametric, we will require the Julia compiler to recognize the None object as the same type/object no matter what T is. This will break the type-stability guarantee, which I currently don’t have a good solution to.

(reworked) pretty printing for Julia expression

A new pretty printer has been implemented for Julia expression with syntax highlighting in the terminal, e.g

and a strict inline printer

this is useful when you trying to debug your generated expression while working on Julia’s meta-programming.

Documentation

As some have noticed (on slack), we recently put up a new documentation website that uses vitepress as an experiment to replace Documenter based websites. Although Documenter has been a very convenient tool, I believe we need to catch up on the progress of the frontend community and adopt new tools such as vuejs and reactjs, there has been very nice progress on static site generator (SSG) in these two community such as vitepress and https://docusaurus.io/.

what vitepress/docusaurus provide for documentation websites?

  • super fast - a static single page solution to the documentation
  • reactive - you can notice all the widgets are super reactive because they are generated through AOT compilation and optimized
  • fast search - they both have good search engines integrated, so you don’t have to wait for a few seconds to get what you want

Limitations

currently, if one wants to generate cross-reference or API reference from docstring, one will need to write a Julia script using Documenter to generate such. This is a bit annoying while developing since this will cause the vitepress/docusaurus local server not to be able to track what has changed. Not to say expanding @example etc. in the documentation.

These limitations do not seem not solvable, but gonna require more effort and likely require writing vuejs/reactjs components to support it, which I don’t have more time to work on. But I hope this early work can inspire people who are interested in writing Documenter alternatives.

26 Likes

What does

@adt Food begin
    Apple
    Orange
    Banana
end

expand to?


What is the difference between

@adt Animal begin
struct Cat
x::Int
end
end

and

@adt Animal begin
Cat(x::Int)
end
1 Like

It’s a complicated expansion, I’m not sure what exactly you want to know about the code it generates. But as a user, you shouldn’t be worrying about what it generates but only need to know what interface it provides. If you want to know what the macro does, you can explore the compilation passes here https://github.com/Roger-luo/Expronicon.jl/blob/main/src/adt/emit.jl#L239

is not a valid syntax. I don’t think any example like this is provided above. If you are asking what a constructor call means, it means anonymous fields. It saves you a few seconds for thinking a good field name when the structure is super simple (like the message example above)

1 Like

Have you seen the way that ErrorTypes.jl (and SumTypes.jl) handle this? We use implicit convert to do it.

julia> @sum_type Option{T} begin
           None()
           Result{T}(::T)
       end;

julia> None()
Option{Uninit}(None())

julia> let x::Option{Int} = None()
           x
       end           
Option{Int64}(None())
3 Likes