Earlier, we introduced union types. Unions make it easy to define a fixed-number of variant structs that are all the same size. Traits may be used to define variant struct types whose sizes are not necessarily identical.
There are two flavors of trait-based variant types: closed and open.
Closed Trait-based Variant Types
Closed traits support only a fixed number of variant types, completely enumerated within the definition of the trait. The list of variant types cannot be extended beyond this. In this sense, it is closed (or sealed).
The definition for closed trait-based variant types looks nearly identical to a union type definition. The only difference is the definition begins with trait instead of union:
trait Event: time datetime struct ButtonEvent: button u8 pushed Bool struct KeyEvent: key Unicode struct QuitEvent
An initial value is created using one of the variant types' constructor or initializers, exactly the same as for unions.
When the base type is a trait (instead of union) ButtonEvent, KeyEvent, and QuitEvent values will have different sizes. Since they have different sizes, we cannot swap out a concrete value of one type for a value of another type in the same allocated memory.
Similarly, it is illegal to define a variable as holding a value of some trait type:
imm event Event = ...
We can only refer to trait-based values by reference:
imm event &Event = &someKeyEvent
Notice how we safely coerced a reference to a variant type automatically into a reference to the trait.
When working with a trait reference, we can only access a shared field or call a shared method (for example, event.time). This is possible because all possible variant types implement them.
To convert such a reference back to the concrete type it holds, pattern matching must be employed. This uses the hidden tag field to determine the concrete type.
Virtual Method dispatch
Sometimes we want all variant types to support a common set of methods whose signature is identical, but whose implementation varies according to the variant type. In such situations, the shared method is declared but not implemented as part of the trait. Then every variant type provides its own distinct implementation of that method.
This is illustrated by the 'double' method in this example:
trait Number: fn double(self &mut Self) struct Real: n f32 fn double(self &mut Self): n *= 2 struct Complex: r f32 i f32 fn double(self &mut Self): r *= 2 i *= 2
When we call the double method on a reference to a Number (e.g., number.double(), the correct double method is automatically invoked based on which variant type the reference points to. In effect, the tag field is used to determine the variant type. That leads to the right vtable which then points to the method to call.
This kind of virtual method dispatch can be accomplished in exactly the same way with union type variants.
Open Trait-based Variant Types
Where open traits differ from closed traits lies in the fact that the list of possible variants can be arbitrarily extended by other modules. This versatility carries a cost: we cannot know at compile-time how many variants there will be, and therefore we cannot assign each of them a guaranteed-unique tag value. As a result, open traits have no implicit (or explicit) tag field.
With open trait-based variant types, the variant types are defined separately from the trait, rather than inside. These variants may be defined anywhere, even in a different module than the trait.
To show that a variant type belongs to a specific trait, it explicitly specifies that it extends the named trait.
An open trait version of the Event example would lay out like this:
trait Event: time datetime struct ButtonEvent extends Event: button u8 pushed Bool struct KeyEvent extends Event: key Unicode struct QuitEvent extends Event
The absence of a tag field means that we cannot do pattern matching or method dispatch on a regular reference to an open trait. For those capabilities, we first need to obtain and then use a virtual reference to the trait.
Choosing the best variant type flavor
Cone supports three flavors of variant types: unions, closed traits and open traits. How do you know which one to choose?
- Only choose open traits if you really need to allow other modules to be able to extend the number of variant types. There is a memory and performance cost to this choice: virtual references are fat pointers that take up twice the space of regular references, and pattern match and dispatch are going to be slower.
- Choose closed traits (or open traits) if you are concerned about the memory cost of making all the variants be the same size.
- Otherwise, choose unions as often as possible:
- They can be passed by reference (&Event) or value (Event). Field access is faster by value (vs. reference).
- Allocated memory (stack or heap) can replace a value of one variant type with a value of different variant type.
- They are ideal for fast pool-based memory management, because all variants are the same size.
- References are half the size of fat pointers, improving performance and memory consumption.
- They are fast at pattern matching and method dispatch.