Mixins in Zig
What are mixins?
Mixins are a way to mix in some common functionality into multiple structs. For example if you have
a File
and TcpSocket
and they have their own different implementations of read(buffer: []u8)
method and you want to add convenience methods like readInt()
, readStruct()
and similar that
just call the read()
method and format the result, you would usually have to write those methods
in one struct and then copy them to the other. Now if you find bugs in some of them you have to
remember to fix it in two places, or if you experiment with improving them you then need to remember
to apply the same improvements in other places, etc. Instead you can write these methods once in a
separate struct and then just mix them in, or in other words make them a part of other structs.
Currently the Zig standard library is not using this approach so there is another solution for this but it has its own problems and I plan to analyze and propose alternatives to it in a future post.
Note that the example I gave only mixes in additional behavior while, in general, mixins might also allow mixing in state, or in other words additional fields. In Zig you can’t mix in additional fields only functions and consts, but mixin code does have access to its modules global variables. The cases where you actually need to mix in state are very rare and I couldn’t come up with a single non contrived example to show here.
How are mixins done in Zig
Currently Zig has a usingnamespace
keyword that will make all the consts and methods of the given
struct available in the current namespace, which in Zig is always another struct. Note that there is
a proposal to actually change it to mixin
or
something like it so it might be different when you read this.
Let’s see how would the example I gave above actually be implemented. First we will define the methods we want to mix in.
|
|
So the first thing is that struct that you want to mix into other types has to be generic if you
want it to provide additional methods to target struct. The reason is that the first argument to
struct methods must be of the type of that struct and the only way for mixin code to know the type
of target struct is to pass it as a parameter of the generic function that generates it. The word
comptime
before the parameter in Zig means that the value passed for that parameter must be known
at compile time. You can read more about it
here and
here.
You might also notice that the mixin function accepts any kind of type as its argument but later
code then expects that that type has a read()
method on a highlighted fifth line. If a user passes
something that doesn’t have a read method with that specific signature they will get a compile
error. Something like this:
error: no member named 'read' in struct 'TargetStruct'
The line on which the error is reported is the one in above mixin struct. In some situations this might be completely fine but in others it might be better to provide a better error like this:
|
|
This will report the error on one of the @compileError
lines but it will also show the line where
it was mixed in.
In order to mix the above behavior into a File struct you would just do this:
|
|
That one line does the job. You would add the same to TcpSocket
and get the job done.
Example with color types
Different image and graphic libraries support different formats of representing pixels. Most common
way is to store it as three values for red, green and blue channels. Still there could be a
different number of bits for each channel, there could additionally be an alpha channel and the
order of channels in memory could be different. How can we easily represent all those variations
with structs and also provide them all with some common conversion methods like fromU32Rgba()
and
toU32Rgba()
?
Using the mixins we can easily provide these methods:
|
|
In this example you can also see how we used if (@hasField(Self, "a"))
in the mixin struct in
order to support alpha channel only if it exists in the target struct.
The scaleToIntColor()
function makes sure that the final value is scaled to the target number of
bits even if the original value uses a different number of bits. For example, if u5 is used minimum
value will be 0 and maximum will be 31 but if we need to convert that to u8, 0 needs to stay 0 but
32 needs to become 255, while 16 will need to become 132, for example. This is how it looks like:
|
|
So if we need to convert from bigger to smaller type we just shift the bits we don’t need out using right shift and then truncate to the smaller type. But if we need to convert from smaller to bigger type we do some math in order to properly scale the value from a smaller range to a bigger range.
Conditional mixin
Since Zig supports expressions that return types there is a way to use mixins to only define methods
in certain cases. For example lets say that we want RgbMethods
above to also provide
toPremultipliedAlpha()
method but only if target type has an alpha channel. We can do it like
this:
|
|
Here we used inline mixin within RgbMethods mixin that we only mix in if target type has a
field
:).
Conclusion
This programming pattern is very powerful and can solve some tricky problems in a very elegant way.
One downside to these kind of solutions is that they generate a lot of code when compiled, because now the compiler is copying that code for you. In some cases the optimizer might be able to consolidate it if you are making an optimized build but there is no way to know upfront if that will work. On the other hand that amount of compiled code will most likely be insignificant in any kind of useful program so there are very limited cases where that size might be a factor.
Also note that Zig compiler will only generate code for definitions that are actually used by the program and not for every possible definition which also reduces this problem a lot.
What do you think about this pattern? Do you see any other downsides to using it? If you interested in discussing it join us on Ziggit forum.