Perfect. So we're going to welcome back Nikolay Vasquez who's going to talk about some
type tips and tricks. Hopefully we all pick something you'll use for what? Thank you very
much.
All right, yeah thanks for having me, this is. Happy to be back at Boston, my second
time speaking, so I'm looking forward to it. So the topic today is about using Rust type
system in advanced ways that will assume a decent knowledge of Rust beforehand, not going
to be covering many basics. So if you get lost, it's non-tyrely or fault, but otherwise
you can look up references for what I cover. So before I get started, what of the motivations
for this talk is that a lot of the techniques I use are in my benchmarking library
Devon, a show of hands who here is sort of Devon? Okay, nice, so decent amount. So this
was my talk at Boston last year was introducing Devon and so it takes advantage of the type
system in very interesting ways as you'll see. And before I get started, I'm hoping to
make a full-time job and build a team around it, so I'm looking for sponsorship to help
make that a reality. So please check out the project and send money towards if you can.
Thank you.
So the topic that I'm going to be covering are types that it's conditional typing, polymorphic
callbacks, and type a ratio. Type states are an extension of the builder pattern, so you
might be familiar with this in Rust where rather than calling a function that takes all
the parameters you can instead provide individual options where some of them are always
necessary. However, one nice thing about the type state pattern is that you can make some
options actually be necessary. So for example, if we remove the name method call here,
we can actually get a compiler saying that the default name is not convertible to a string.
And so if the user doesn't provide a name here for the config, then you get a compiler
instead of a runtime error.
And the way this works is you have to set a default generic state where we know that this
is not going to implement into string. And so as a result, when we later end up calling
a name, we know that, okay, well, the string method, sorry, the name method needed
to be provided at some point. And so the way the sense of working is essentially, you
have a state transition from a default name on set state to then providing some generic
and name, and then that wouldn't be checks until you call build. You could also provide
the name into string at the name call site itself as well, but in the end, this will end
up getting checked. So this technique is not just good for configuration with data, but
you can also use it to configure how execution works. And so in Devon, there's this
venture that you can take optionally, if you want to provide context to your benchmarks.
And then you pass it closer by default to iteratively, keep calling the function and measure
its performance. However, what you can also do instead is you can pass another closure
to generate inputs for every single iteration. And so what this now does is it makes another
method available called bench values, as well as it makes other, other intermediate
builder methods also available. So for example, if you wanted to count throughput of bytes
from your given inputs, this can be provided as a statically check string rather than just
something that gets checked dynamically at runtime. So if we were to remove the width inputs,
we would see that when we call bench values, it's saying that there's unsatisfied
trade balance. And the way this works is that the venture type will take a venture
config by default. And then that is itself a public and private type, which essentially means
that it is, well, it's technically public and never gets actually exported by the crate. So
it never gets possible to use outside of the crate. And so we can wrap all of our generic
parameters inside of this venture config. Here we only just have one which is generate input.
And so as a default, it's essentially like the unit type where you have an empty
tuple, but instead it's an unnameable public and private type. And this default itself is not a
function. And so when you try to call the bench method, as if you had bench values, as if you
had called a provider function, then that won't work at compile time. And we can see how this
gets enforced later in the impulse where the bench method just is implemented on the default
venture type with the default venture config. And then we have a bench values method that will
take the generic parameter that was provided before and then actually invoke it to perform the
bench working. So types of things give us a lot of benefits. They allow us to move
checking your inputs to compile time. And this ends up having runs on performance improvements
in that you only end up paying for fields that you end up setting as well as if. So rather than another
run time improvement, is that rather than be checking at run time whether your inputs are valid,
you instead just get the compile errors. And that ends up being also just a lot
clearer for users as to how they're using your library incorrectly. Another topic I'd like to
cover is conditional typing, or what I like to call dependent types at home. So say you have a
function to log the throughput of bytes that you're processing. And you may consider that that
should take use size because lengths for slices use use size. And for context use size is essentially
just an unsigned integer that's the same size as a pointer. So it can be the length of the whole
address space. And if we had this as use size and we wanted it to work with not just slices but we
also wanted it to work with files, well file lengths require use 64. And so now we have this
type mismatch. And if we ended up trying to use this for slices then we end up saying, okay well
sure we can pass it to use 64. But say for example, let's just say use size ended up being coming
larger than use 64 in a hypothetical 128 bit platform that rust with support. And you could try to
support both of these by wrapping them in an enum. And one of the main downsides of this is now you
have a whole extra word just to determine what is the use size of 64 just to determine the size.
And so there is a bit of a performance penalty here. You could also consider, well let's just use
unions instead, however now you end up reaching for unsafe. So the very creative alternative to this
is you can end up making a type alias to either use size or use 64. And the way that you end up doing
that is using the trade system. So you could parameterize a trade based off of a type or a constant
condition. In this case what I'm doing is I'm making this use size 64 associated type
be available for the empty tuple type just as a placeholder. And we make it available for the condition
of is pointer greater than use 64 true. We make it be use size otherwise if use size as smaller than
use 64 then we want this associated type to be use 64. And if we end up using this we can use this
lovely syntax of okay empty tuple as proxy use 64 and access the associated type. But we can see
that we're actually using use size or use 64 we can assign it to an integer directly and it's not
just like wrapping it with an enum or a union. And so we're using the type directly itself.
I published a great called content that provides a generic type alias for this technique and so
you could also define use size 64 in terms of this type alias. And we see here that you can just
use it for example to assign it to an integer. And if you're going to use ends up taking different
types as the input for the callback. So in this next version of Devon we'll have run time benchmark
registration and to make this API very flexible. It could either take a Devon venture which
has a subtle difference from Devon bench that I'm not going to get into but it'll be clear
in the API eventually. And sorry it means right bunch of twice there but we see that we are able to
invoke the same exact API but we're able to provide it with three different types of closures. One
two of them that take parameters that are different parameters and then one that doesn't take any
parameters. And the way that this flexibility is achieved is with a separate trait where the
key feature here is that we're providing this marker type that essentially allows us to
generate an implement this trait generically without having conflicting implementations.
So in the actual implementation we see that we end up passing as the marker type
fn of bencher, fn of bencher, and then empty fn. And this is just to essentially implement
kind of a different trait for every instance. And we know that we're not going to get a closure
that is going to implement both fn, fn, and fn bencher. And so we don't end up having any
ambiguity errors when we end up trying to use this. And Devon's not the only library that uses
this technique. This technique is also utilized by Bevy's ECS engine where you can handle
quite a flexible number of parameters as dependency injection. And the way that this ends up working
in practice, or at least the way it's implemented rather, is using the same underlying technique
where we have this generic trait for all of the functions that we're going to run against.
And it takes this marker parameter to essentially indicate to the compiler that, okay, these are
separate implementations, and you don't end up with the error of multiple trait,
implementing the same trait across multiple generic types, which is the limitation of the
compiler right now until we end up getting specialization, which may never land, we'll see.
So the actual implementation for this in Bevy ends up becoming pretty gnarly, I
don't recommend that your use case has to be this complicated, but for them they had specific
lifetime needs for this parameter that I'm not going to get into how that works, you can
dig into this or spend yourself. But this is just another example of how this technique is used
to provide a much more flexible API in a wide-they-use game engine. So as I covered, this works really
well if you want to implement dependency injection in your library, it allows your API to be much
more flexible. And as a result of this flexibility, you end up with fewer functions to a smaller
API surface to expose and internally because you're operating over a unified trait for
different implementations, the internal implementation can also end up being simpler.
And the last topic I'd like to cover is type of ratio. So you're probably familiar with the
type of ratio that's provided by the standard library or by the language out of the box,
and this is with the dying trait. And while this ends up or also known as trade objects,
while this ends up working fairly well, it is limited in some ways. So for example, if you wanted
a slice of type of raised objects, then you can't really erase a slice to be
dying trait slice. That's just not a feature that's available right now in the language.
And so what Devon does instead to get past this is implement its own VTable for type of
ratio. So you end up having a bunch of different operations that are the first operations
end up being for the identities. So displaying to users what types are you operating over,
and then for the actual type ID to be able to distinguish, okay, when I'm actually
recreating these objects, like are these actually the same type that I expect them to be,
as well as if you want to co-locate objects to the same type, then this type ID ends up becoming important.
I don't recommend using the address to an erase type or the address of the type ID function
itself, which is something that some people do. Type ID itself is guaranteed to provide the uniqueness
whereas if you're across different compilation units, you may end up with different addresses,
but the same type ID. So do invoke the function of the type ID. And then we have other
properties for memory management. So the actual size to determine, okay, when we're storing this
in a slice, what's the actual stride? How far do you need to implement a pointer to actually
iterate over a slice, and then for the allocating slices, we sort of function to provide that.
And then actual functionality on these array objects hints at what's to come. We see that these
actual provided values don't end up getting used. They have an underscore in front of them
to hint that the values end up getting dropped. And the reason for that is we're going to magically
transport these closures into the implementation. And the reason that this is legal is we can guarantee
a compile time that the size of these closures is zero. And so because it's a zero size type as well
as it's not a special token type, guaranteed by copies, send, sync, et cetera, and static,
we can guarantee that we can actually just construct one of these out of thin error.
And this is how you're able to go from a generic closure type to being able to create a function
pointer that invokes it separately. So you can also use this technique to implement certain like
FFI patterns. But yeah, it's a neat trick that ends up actually ironically simplifying a lot of
the code I end up having in Devon. So to recap on type of ratio, by using custom V tables,
you have a lot more fine control over the operations that you can do. So for example, to go back
to what I was mentioning with these different operations. With Devon, you end up being able to,
there's an option of, so for example, there's an option of bull here with the Equality function
because what I'm not going to cover here, but one technique that you can do is you can use DRF specialization
to check, okay, is this type actually equatable or doesn't not implement that? Likewise,
you can use DRF specialization to pick DRF's display, and as well as checking if as
RF to stirs possible. So that's why two of these functions are valuable, and the other one
has the same signature between display and DRF. So ends up being the same regardless.
But anyway, so that's why there's a lot more flexibility is just you're able to
define exactly what the operations that are being provided are, not just based off of the direct
trait implementations. However, this does require decent amount of unsafe codes, so use that
you're on risk. I highly recommend running your code through Mary. If you're going to do this,
it's able to catch memory leaks, undefined behavior. It's excellent to all.
So these are all the techniques that I covered. They're definitely very niche, however, knowing
that they exist can actually save your bacon. So I'm very happy that I've been able to take advantage
of them, and maybe you all can come up with some personal stuff to do as well.
And that is all. It's what they're quicker than I expected.
And so I'm open-stating questions. So yeah, just one last thing, like I said, I'm looking for
sponsorship or contributors on Devon. Can you please stay seated and feel the questions over? Thank you.
So like I said at the beginning, I'm hoping to really make Devon a world-class benchmarking
library, so I'm looking for sponsorship and contributors. So please reach out to me,
either any of these by email, get Hub, pass it on. And yeah, otherwise, I'm happy to answer questions
about anything I covered here. Are there any questions?
So first off, awesome talk. And one of the issues I've often had with APIs that use
apps like these is that the docs are really hard to us because you're only finding which values
are okay, pass. And similarly, the compiler is often less helpful. Is there something you've faced
in Devon and have you talked about it? Sorry, I can't hear the microphone. Is this better?
A little bit. Okay, I think a little more, sorry. I will try to talk slowly. So often it's hard.
If you're using an API like that uses these things to find out in the docs like which values are
okay to pass on. And the compiler is often also an helpful. Is there something you've also
encountered in Devon and is it something you've talked about? I'm sorry, please repeat the question
one more time. You can actually very difficult to hear from here. Yeah, sorry. Often with APIs
like these, the documents like documentation is hard to read because the types are difficult.
And the compiler is often an helpful. Is this something you've also encountered in Devon and
thought about? Sorry, the last thing I heard was compiler something. Sorry, it's so difficult.
Because you've passed some functions there and stuff like this. And the compiler is
sometimes it's unhelpful with it. So is there anything that you do not have to mitigate this or
a documentage? Yeah, so unfortunately when it comes to resolving certain trade bounds, the compiler
will, the Rust compiler will not be that much better than trying to use C++ templates where
it will just try to leave you the wrong direction. I guess my advice here is if you do
require trade bounds, try to require them, I guess as early as you need them, and that
there are at least like no at the first use site, what's the associated issue? So for example,
in the typestate builder example I gave, the name method didn't actually require that the
end type implements into string, but perhaps it would actually be better to have that there because
if you end up not invoking that name method or rather, if you end up invoking that name method
the wrong way, then you get that error there instead of right at the part you called build.
Aside from that, I don't know, you're just kind of at the mercy of the compiler giving good
error messages. If you want good progress on that, go sponsor Esteban Cooper. He does a great job
with improving the compiler messages. Any more questions?
I think for your nice search, I have a question about your typestate example, so it's of course nice
to have a possibility to fail at compile time when you use a pair incorrectly, but the error messages
are not very nice, is there a way to improve them? The error messages are not very clear, so it
doesn't say that your name is missing, it just says that type doesn't have a limitation.
Yeah, I guess my answer to that is similar to the lot for strikes.
I guess potentially that could be used to improve the error message when you end up calling
build without the right trait impulse, so yeah theoretically that would help. I don't know
about that, thank you. Thank you. Anybody else?
No, thank you very much Nikolei. Thank you.