DEV Community

Joshua Ballanco
Joshua Ballanco

Posted on • Originally published at manhattanmetric.com

Some Types - Part 2

(The full code from this post is available at https://github.com/jballanc/SomeTypes.jl)

In the first post in this series, I looked at how Julia's native type system is powerful enough to stand in for a more formal implementation of Sum Types (a.k.a. Tagged Unions), such as that found in the wonderful SumTypes.jl library. One problem with that comparison, however, was that using native Julia types necessitated creating instances of the types, whereas using SumTypes.jl one is able to follow a more traditional pattern. As a specific example, to create a game board for the "Count Your Chickens" game being simulated, it is possible to create an array of Square types directly when using SumTypes.jl:

board = [Empty, Regular(Sheep), Regular(Pig), Bonus(Tractor), Regular(Cow), ... ]
Enter fullscreen mode Exit fullscreen mode

With native Julia types, it was necessary to create an array of instances of the types instead:

board = [Empty(), Regular{Sheep}(), Regular{Pig}(), Bonus{Tractor}(), Regular{Cow}(), ... ]
Enter fullscreen mode Exit fullscreen mode

If these instances were being used to convey some information, that would be one thing, but the way the simulation was written, these instances serve only as markers to guide method dispatch. Specifically, if we look at the ismatch function we used in Part 1 to match results from the spinner to squares on the game board, we see that the entirety of the logic in this function derives from the type-signature of the two methods:

ismatch(_, _) = false
ismatch(::Square{T}, ::T) where {T<:Animal} = true
Enter fullscreen mode Exit fullscreen mode

For a small game board data structure, such as the one we used to simulate "Count Your Chickens", the overhead of having to instantiate and allocate each square is negligible, but it's not hard to imagine a situation where having to constantly instantiate types that are used only to guide dispatch could become burdensome. Could we, somehow, use native Julia types without having to instantiate them each time?

It turns out, we can! To understand how, we first need to have a look at what a type really is in Julia. The Julia REPL is a wonderful tool for exploring Julia, so if we fire it up and create a few objects:

julia> a = 1
1

julia> b = "hello"
"hello"

julia> struct Foo; end

julia> c = Foo()
Foo()
Enter fullscreen mode Exit fullscreen mode

...we can interrogate Julia as to what their type is:

julia> typeof(a)
Int64

julia> typeof(b)
String

julia> typeof(c)
Foo
Enter fullscreen mode Exit fullscreen mode

But what if we interrogate Julia about the type...of a type?

julia> typeof(Foo)
DataType
Enter fullscreen mode Exit fullscreen mode

In fact, this works not just for user-created types, but also for Julia's built-in types:

julia> typeof(typeof(a))
DataType
Enter fullscreen mode Exit fullscreen mode

In Julia, each type is an instance of the DataType. Further than that, as the Julia documentation states:

Every concrete value in the system is an instance of some DataType.

Neat! But how does this help us with eliminating the need to instantiate our game board types? That's where Type{} comes in. The parametric Type{} type gives us a way to directly reference the DataType instance that represents each type. You might need to read that last sentence a few times for it to make sense...or you can ask the REPL. Using the isa operator that tells us about the relationship between objects and their types:

julia> c isa Foo
true

julia> Foo isa DataType
true

julia> String isa DataType
true

julia> Foo isa Type{Foo}
true

julia> String isa Type{Foo}
false
Enter fullscreen mode Exit fullscreen mode

We can see that, as promised, the user-created Foo and the built-in String types are both instances of DataType, but this is not particularly helpful if we want to write methods that can distinguish between the two. For that we look at the Type{Foo} parametric type and see that it is the type that Foo is an instance of, but that String is not an instance of Type{Foo} (it would, instead, be an instance of Type{String}). Indeed, the only instance of Type{Foo} is the Foo type.

Returning, then, to our ismatch method, we can re-write the methods using Type{} for the method signature:

ismatch(_, _) = false
ismatch(::Type{<:Square{T}}, ::Type{T}) where {T<:Animal} = true
Enter fullscreen mode Exit fullscreen mode

Note that we're not just using Type{}, but we're also combining it with the ability to parameterize methods and limit the acceptable types for parameterization. Testing this out, we can now see that we can pass types, rather than instances of the types, to ismatch:

julia> ismatch(Empty, Pig)
false

julia> ismatch(Empty, Nothing)
false

julia> ismatch(Regular{Pig}, Pig)
true

julia> ismatch(Bonus{Pig}, Pig)
true

julia> ismatch(Bonus{Pig}, Cow)
false
Enter fullscreen mode Exit fullscreen mode

Finally, this allows us to update our definition of the game board to be nearly identical to the version using SumTypes.jl (just substituting curly-braces in place of parens):

board = [Empty, Regular{Sheep}, Regular{Pig}, Bonus{Tractor}, Regular{Cow}, ... ]
Enter fullscreen mode Exit fullscreen mode

It's worth noting that this technique, dispatching on the Type{} of a type, is not just a neat parlor trick. It is key to a number of advanced techniques in Julia such as Conversion and Promotion and "Holy traits".

So, now that we are no longer instantiating every square on the game board, everything should be much smoother and we would expect our game simulations to run faster, right? Well...

Next time, I'll start looking at some basic bench-marking of the various approaches to this problem, using SumTypes.jl, instances of native Julia types, and native Julia types themselves. The results are not what you'd expect!

Top comments (0)