Struct Element with limited number or Values

Viewed 374

I want to create a struct

mutable struct myStruct
    id::string
end

I do want id to be a string, but I want it to take only 1 of 3 values (e.g. "a", "b" or "c"). My idea is that could theoretically speed up operations.

  1. Am I / would I be right about the speed increase?
  2. Is this possible and how?
2 Answers

I don't know of any way to let id be a String, while constraining it to only have only one of 3 possible values in such a way that the compiler could take advantage of it.

However, if leaving the String domain is an option, you have a few possibilities. Let's try and benchmark some of them here. For the sake of the example, let's assume that this id field is used to test the equality between two MyStruct instances.

A possible (very minimalistic) generic implementation of such a type would be the following, where parameter T determines the type used for the id.

mutable struct MyType{T}
    id :: T
end

# Two MyType instances are equal if their ids are equal
import Base.==
==(a::MyType{T}, b::MyType{T}) where {T} = (a.id == b.id)

Now let's benchmark the equality operator in various cases. First, the base case using strings as identifiers:

julia> using BenchmarkTools

julia> a = MyType("apple")
MyType{String}("apple")

julia> b = MyType("banana")
MyType{String}("banana")

julia> @btime $a == $b
  4.326 ns (0 allocations: 0 bytes)
false

We would expect integers to be faster, and this is indeed the case:

julia> a = MyType(1)
MyType{Int64}(1)

julia> b = MyType(2)
MyType{Int64}(2)

julia> @btime $a == $b
  1.882 ns (0 allocations: 0 bytes)
false

But perhaps the API is less convenient that way. Using an enumerated type might more readable (users don't have to remember which value conrresponds to "apple"). It also prevents using any value that was not predefined, while keeping the same performance:

julia> @enum Fruit begin
           apple
           banana
       end

julia> a = MyType(apple)
MyType{Fruit}(apple)

julia> b = MyType(banana)
MyType{Fruit}(banana)

julia> @btime $a == $b
  1.816 ns (0 allocations: 0 bytes)
false

A final option that might be worth considering is to use symbols for keys, which preserves (IMO) the useability as well as the performance:

julia> a = MyType(:apple)
MyType{Symbol}(:apple)

julia> b = MyType(:banana)
MyType{Symbol}(:banana)

julia> @btime $a == $b
  1.883 ns (0 allocations: 0 bytes)
false

Nothing prevents anyone from building an illegal value such as MyStruct(:cheddar), but it fits nicely in a context where strings are manipulated. For example, if you get the id as a string somewhere in your workflow, you can easily convert it to a symbol before constructing the MyStruct instance:

julia> id = "apple"
"apple"

julia> a = MyType(Symbol(id))
MyType{Symbol}(:apple)

Use Julia's typing system for this! No external libraries required.

abstract type Fruit end
struct Apple <: Fruit end 
struct Banana <: Fruit end
struct Coconut <: Fruit end

struct MyType{T<:Fruit}
    #could be some fields here...
end

a = MyType{Apple}()
a2 = MyType{Apple}()
b = MyType{Banana}()

This approach has two advantages:

  • you are actually able to limit the set of values
  • comparisons can be performed in the compile time so there is no time cost

Just an unbeatable performance benchmark:

julia> using BenchmarkTools

julia> @btime $a == $b
  0.001 ns (0 allocations: 0 bytes)
false

julia> @btime $a == $a2
  0.001 ns (0 allocations: 0 bytes)
true

EDIT

answer to the question by @Cameron Bieganek

Do you have an opinion or guidance on when it makes more sense to define singleton types by hand and when it makes more sense to use an Enum?

My personal opinion is the following :

  • whenever the type value is known already at the compile time always use singleton types
  • whenever a small set of types is known already at the compile time (qualifying for a small union) use singleton times
  • if you plan to implement different processing depending on the type use singleton types - broadcasting will be faster than a series of if statements
  • when designing an API (your own Julia package) use singleton types since you can use Julia's documenting features
  • when lots of values (e.g. Strings) that are being generated during the runtime and you plan to compare them for equality - use Symbols. Good example is parsing a CSV file where column contains nominal values.
  • in other cases consider using Enums OR Symbols - use the code readability as the criteria.
Related