 Hello folks, welcome back to another crust of rust. It's been a little while since we did one of these. I've had this one sort of in the back of my head for a while of it would be good to talk about the different collection types in Rust. And the docs for stood collections is pretty good. If you read through it, it talks about when should you use which collection and gives you essentially like a bulleted list of what each one is for. It talks about the performance characteristics of these different implementations. It talks about the sort of complexity of the different sequence types and map types. So like this is a very good page and if you wanna take the time to read through it I highly recommend that you do. What I'm gonna do today is in some sense summarize the information here but we're also gonna dive a little bit deeper into what are the actual representations that are being used here and talk about sort of how you think about which of these you should be choosing at a given point in time. I'm not necessarily expecting this to be a super long one. I'm guessing maybe an hour and a half to two hours. And we'll see and hopefully there are a bunch of questions we can address as we go through as well. Okay, so in stood collections which is the sort of place where the various implementations of data structures in the standard library lives. We have implementations for sequences, maps, sets and other things which currently only contains a single collection. One of the things that's a little interesting here is this set of types has not really grown since Rust 1.0 and there are a couple of reasons for this. It could be that there are other data structures that should be in here but one of the reasons is this set is usually good enough for most use cases. And the other one is I think that there's been a sort of observation that there's a lot of value in having the implementations of these live outside of the standard library so that they can be iterated on more rapidly so that we can have multiple different implementations that make different trade-offs. Once you put something in the standard library you're sort of, you're not quite locking yourself into one data structure as we'll see in a second for what happened to HashMap but you are locking yourself into an API. You can't really do breaking changes of the standard library. And so as a result, once you sort of, once you stick something into the standard library you sort of lock it in place and you might be able to change the implementation behind the scenes but people are gonna rely on basically every aspect of the thing you put in there. So even if you change it in such a way that it has slight runtime characteristic performance differences then that too might be something that ends up breaking a bunch of users or at least making life hard for them. So what we've seen increasingly is that new data structures whatever form they might hold do not end up in the standard library and instead develop as just completely different crates. And so that's also why this misc type is sort of, you know, it's a one item collection if you will. One item category is because it's not clear that we want lots more here because we might as well just have them be separate crates. And this set of things that are in there at the moment are sort of very fundamental types where maybe it makes sense to have them in the standard library because people are gonna reach for them all of the time and it's a little unfortunate if they have to look for an external crate every time they wanted to use say a vector, right? So the way we're gonna do this is we're gonna first we're gonna look at these in the order of the categories here. So we'll look at sequence types first. So first of all, we have vectors and vectors I'm guessing you're all pretty familiar with. I'm not gonna necessarily go into how vectors work because there's not a lot to it but I will give you a very high level of review. So a vector in Rust is a collection of a raw vector which we'll get to in a second and a use size. So I just click the source link top right here which you can't see behind my face. There's one top right of every page and next to methods and stuff. You see the definition is a raw vector and a length and the raw vector here is sort of an internal type to the standard library that represents a vector that is of a given size. And when I say size here, I mean capacity. Like it is just a chunk of contiguous memory of a certain size. And the raw vector does not know how many items are in it. It just treats it all as potentially uninitialized memory. And the VEC wrapper around it keeps track of how long is this vector? So how many items in the vector are actually allocated? And so vectors have this distinction between capacity and length. The capacity is how many things can I store in this vector without having to allocate more memory? And the length is how many items are currently in here? So the moment the length hits the capacity, then trying to push another element onto the vector would require the vectors underlying allocation to grow. And usually the way that ends up happening is you allocate a new chunk of memory often that is twice as long and then you copy over all the elements that are in the vector and then you push the new item that was added. And that's sort of hidden by the vector type in that all of its operations will just transparently reallocate this raw vector whenever it's necessary, whenever you reach the capacity. But this is one of the reasons that for the vector type, and you will see this for the other collections too, for the vector type you'll often want to use when you're able to this with capacity field. So what with capacity we'll do is allocate a vector whose backing memory is enough to hold this many items so that you can push that many items without having to do reallocations. If you don't do this, if you do new, it'll allocate a fairly small vector and then every time you fill it up, it's gonna reallocate and copy. And so as a result, if you're gonna push, let's say, a million elements onto this vector, if you use this new and then you push a million times versus use with capacity a million and then you push a million times, the new and push is gonna be way slower than what the with capacity is gonna be because it's gonna have to do all this reallocation and copy work as well. And you'll see this in some of the other methods on here. So you have things like try reserve, which is make sure that this vector has capacity enough to hold this many additional items. And you can query the capacity as you need. And it's useful to remember that vectors work in this way because often this can be a sort of performance implication too where you find that your application suddenly has these like latency spikes. And usually those latency spikes are when a collection had to resize. And we'll see this for things like hash maps too where hash maps have the same property. When they fill up, you have to allocate a new sort of bucket allocation for the backing of the hash map, copy all the existing things over and then do the insertions to that new item. And you might wonder, well, what is the default capacity? And that is a somewhat sophisticated question. If you look at new here, you see it uses raw vector new and raw vector new. Let's see if I can find that here. No, rust slang rusts. So if we dig into library, it's an alloc. You'll find that a lot of the standard library collections are in the alloc crate and not in std. So there's core alloc and std. std relies on operating system primitives. alloc just requires a memory allocator and core requires nothing. And a lot of the collections are in alloc except for hash map, which requires randomness which comes from the operating system. So alloc source rawVec, I'm guessing that's gonna be a rawVec.rs. Yep, right here. Let's see if we can dig up new. Self new in here. Yeah, so as you see here for the raw vector implementation, I don't want symbols. You'll see that it tries to figure out what is the smallest vector that they're gonna be willing to allocate. And that's gonna depend on the size of t. So if you have a vector of very large objects, you might be willing to have a shorter vector capacity. If you have a smaller t, like a u8 inside of the vector, you might want to make sure that it's larger to begin with because you expect that you're gonna be pushing a lot more of them. As you see here, the default size for a vector is eight for anything that's a single byte. It's four for anything that is less than one k and then it's one for anything that's larger. And then the growth operation, let's see if I can find that. I didn't have it linked, which I arguably should have. Grow. Yeah, so the new capacity is gonna be twice. It's gonna be whichever is more of how much is required. So if we know that we're gonna push 1,000 elements, we're gonna grow it by at least 1,000 and two times the current capacity. And we make sure that it's always greater than the minimum non-zero capacity. And so basically the vector ends up doubling in size each time. And someone asked in chat here, what does the A here is, the A equals global is which memory allocator? And this is a nightly only feature that lets you switch out which memory allocator is used to allocate the backing buffer. And the other thing that I wanted to talk about for vectors in particular is that vectors are, as I'm assuming many of you already know, vectors are a sequence where all the elements follow one by one and you're not allowed to have gaps in a vector. And so if you have the items from one through 10, if you remove item five, what actually happens is item five is erased from the vector and then all the later elements in the sequence are shifted over by one. And this might mean that let's say you have a vector of a million elements and you remove the first one, you're gonna do a mem copy of 999,999 items. And so as a result, removals from vectors can be pretty expensive. There is an operation called swap remove right here. And what swap remove will do is when you remove an item, it'll take the last element of the list and then put it in place of the one that you just erased. And so that way nothing has to shift because there are no gaps. The downside of swap remove is it doesn't preserve the order of the vector. So if the order of the vector matters, then you don't wanna use swap remove. But if it doesn't, swap remove is a lot more efficient than remove is. There are a couple of unsafe operations of vector two that are useful to know about like set length. So set length is a way to both truncate a vector, although for truncation, you'll usually want to use the actual truncate method which is not unsafe. But the more important way you can use set length is to extend the length of a vector into uninitialized memory or into memory that you have initialized yourself. So the trick here with set length is imagine that you allocate a thousand element vector, but initially the length is zero. And you have some, let's say you do FFI to C or something and you pass C a pointer to the vector. So the backing memory of the vector which you can do using the Asmute pointer here which gives you a raw pointer to the stuff. And then the C API is gonna write into that memory and it tells you back I wrote 500 elements. Then now on the Rust side of things you wanna say this vector now has a length of 500. I know the first 500 items have been initialized. They're no longer sort of erased or uninitialized memory. And so that's when you would use something like set length to tell the vector implementation, just trust me the first 500 slots of this backing memory are in fact valid entities now. And so you can unsafely set the length of the vector and from that point forward you can index into those items. The things after the length are still gonna be considered uninitialized memory and are not gonna be accessible to you without unsafe. And I think set length will panic if the length is greater than capacity, at the very least it's undefined behavior because the length cannot be more than the capacity that would be a buffer overflow read. And so that's right, yeah. So capacity is the maximum amount, it's not the maximum amount you can allocate, it's the maximum number of items you can have without reallocating and length is the actual amount of items that are in the vector. So the capacity is the amount of memory that's being used and the length is the amount of memory that is initialized and accessible. There are a couple of other neat things here, like retain which is a useful method to know about. Let me see if I can, where is retain? Retain right here. So retain is a way where you can take a vector and you can say remove all the items that match the given predicate. So where the following closure returns false. And the reason why retain is valuable compared to looping yourself is first of all looping yourself gets annoying with the borrow checker because you're looping over a collection while trying to mutate the collection. But the other reason why retain is nice is because if you remove a bunch of elements, it's gonna be smarter about that shifting that I talked about. If you remove multiple items, it doesn't have to remove one and then shift everything, remove one, shift everything, remove one, shift everything. It's gonna remove all the ones you tell it to remove and then sort of optimize the way that it does shifting so that it's not quite as expensive. Anything else special I wanna talk about with vector? So vector dereferences to a slice, which is pretty, should be unsurprising, does a continuous piece of memory just like a slices. And so when you have a vector, you can use it as though it were a slice and access all the methods that are on slices. The other thing that's useful to know about vectors is like boxes, they have a leak method where you can take a vector, you can call a veck leak and pass in the vector and that will give you back a mutable reference to a slice of the underlying memory that will live forever. So this is doing the same thing as box leak does, which is just leaves it on the heap, never deallocates it and gives you a essentially static reference to that memory and this can be useful if you have some vector of let's say configuration data that you wanna share across your entire application rather than have to pass this vector around, especially if you end up wanting to read from it from multiple cases, you can just leak it and then pass a reference that ends up being static everywhere in your program. You could also do this with reference counting and the like, but if you don't actually care about this being deallocated ever because it truly lives for the lifetime of the program, something like veck leak can be really useful. Why doesn't capacity fill with default values? So the reason why capacity doesn't fill with default values is because vector, and I think there's a note about this in the documentation, see if I can find it. Guarantees, right? So due to its incredibly fundamental nature, veck makes a lot of guarantees about its design. And I recommend you read this section, it's pretty interesting, but it's essentially observing the vectors are a very low level primitive. It's used everywhere, like a hash map is implemented in part in terms of a vector, right? And so you really want vector to not do anything or at least be able to opt out of any behavior that you might not want. And one such example of such a behavior would be if creating a new vector of a given capacity, if that ended up doing that many memory writes in order to fill those values, then vector would be a lot slower for use cases where you are okay with that memory being uninitialized. Like imagine you're about to do like a read system call or read from a TCP socket or something into a buffer. Well, it doesn't matter what memory is in that buffer before you read into it because you're gonna overwrite it anyway. So if you were forced to write all zeros into it before you did the sys call, then now you're imposing additional overhead on every single call for something that's just gonna be overwritten anyway. Okay, I think that's all I wanted to know. There's one more thing I wanna talk about for vectors which is the trait implementations. Where are the trait implementations down here? So it implements a bunch of things as you would expect like asref and asmute and debug and whatnot. But the one I wanted to talk about here is extend. So vectors and most collections implement the extend trait, which is saying if you have an iterator that produces items and you have a vector, you can tell the vector to extend itself with the items that are yielded by the iterator. And one of the things that's neat about this extend implementation is it actually tries to be kind of smart. So if you go look at the source here. Here. Well, there's a lot of magic here to deal with specialization and stuff. But the basic idea is, I'm not gonna find the code here right now. The basic idea here is that in extend, you could imagine the implementation of extend was just for each item yielded by the iterator, push it into the vector. But extend can be a lot smarter. For example, if you pass a vector to another vector, when you extend, then you know the size of that other vector. So you can resize the capacity of the vector to fit all the items from the other vector and then just mem copy all of them at once rather than all this overhead of looping and yielding from an iterator and pushing. So you can just chuck them all at once with a mem copy and then set length and then you're done. Similarly, if you have some other iterator where you happen to know the number of items in the iterator, so you know that this is an iterator over like 50 elements, you can do the same thing. You can reserve that much memory so that you know at least you won't have to reallocate while pushing. This matters especially if you might have to reallocate multiple times during that push. You can also have cases with iterators where you know that they can yield up to 100 elements. So this might happen if you have let's say a vector that you iterate over and then you call a filter. Then if you know that the length of the original vector was 100 and you know there's a filter there, you know that it can produce at most 100 elements. And so the implementation of extend can make use of this where it can sort of query the upper bound on how many items might be yielded and then use that to inform reservations of capacity before doing all these pushes. So that's pretty nice. There's a bunch of other convenience methods here. There are really more about slices than anything else like binary searches and sort and those kind of things. So we're not gonna go into those in too much detail. Okay, so that's vectors. Any questions about vectors before we move on? How do you drop a leaked box or vector? You can't. So when you leak them, you do leak them. They will be leaked for the remainder of the program. They get deallocated when your program exits. Okay, so let's move on in the list. So the next thing in sequences, move on the list, get it, cause it's a sequence. Oh, how does the VEC macro work? The VEC macro is fairly uninteresting actually. It just allocates a, it's a macro where if you get no elements, then it turns into VEC new. If it gets any number of elements, it will do VEC with capacity, that number, and then push all those items and then return the resulting vector. So it's a fairly uninteresting vector actually. The small VEC crate differs from VEC in that it has a, so the vectors are all heap allocated. So the raw VEC type we looked at is an allocation on the heap that you get through something like box new. And that means that every time you have a vector, you're gonna do a memory allocation. And you're gonna like have to do pointer chasing where you're gonna have to follow that, the pointer of the allocation into the heap and then read the items from there. Other crates like small VEC, and there are a lot of implementations like this. Small VEC is not the only one, but we can look at it. So what small VEC does is it has this, this const n as well as this constant generic that tells it how many items should you keep on the stack. So you'll see that the way this is actually implemented as a small VEC here is an enum of either a local buff, meaning a vector whose capacity is allocated on the stack, not the heap, or it's a remote buff, which is, it's a normal vector that's on the heap. And when it's local like this, you don't have any memory allocation. You just keep, it's as if you declared an array on the stack of that many elements. And so it'll keep track of like, what is the length and what is the array? And it treats the array as the capacity. So if we look at local VEC here, I'm guessing if we do the, look at the source, local VEC here, you see it's just an array of maybe uninitialized of that length. And then it keeps track of the actual length. So the idea being that you don't have to do allocation as long as you have fewer than or equal to, if as long as you have up to and including n items, if you're more than n items, you do a heap allocation and you copy them all over. And this can generally mean that you have fewer heap allocations, slightly faster performance because you don't need to do indirection via the heap. And so that's the general benefit of a type like this. Do, do, do, do, do, do. Do any of the data structures have a stable ABI, say for CFFI? They don't have a, there's no ABI in Rust. There's no stable ABI in Rust. But VEC, for example, guarantees the way that it lays out memory, guarantees this contiguous, for example, and so you can use the backing memory from a vector to construct a array pointer that you then pass to C, for example. So it depends on what you mean by the data structure, right? Like you won't be able to call, let's say the vector push method from C over FFI, but you can allocate a vector in Rust and pass a pointer to it and the length to C and C will be able to operate it with it. That's not the case for the other collection types quite as much, it's mostly a property of VEC. Why does VEC implement into iter but not, I don't know, why does it implement into iter but not iterator? Okay, so this is also worth talking about. So if we look down at vector and we look for iterator, you'll see that vector implements the into iterator trait, which is the thing that you can call on a thing to turn it into an iterator, but it does not implement the iterator trait. You see there's only into iterator implementations. The reason for this is because a vector is not an iterator, right? Because an iterator needs to keep track of which item am I currently, or am I going to yield next or alternatively, which one did I just yield? And the vector doesn't have that. The vector is three fields, a pointer to the backing memory, the capacity and the length. And for an iterator, you would need an additional item which is the item that I'm about to yield. And so that's what happens when you call into iterator on a vector, is it produces a new type that has a reference to the vector and that reference is either read only if you got the iterator from a shared reference or it is mutable if you got it from a mutable reference to the VEC or it's owned if you have an owned VEC that you turned into an iterator. So it has a reference to the underlying vector and then it has an item or an index thing. So if you look here at here into iterator, let's see if I can find, it's gonna be annoying to find. But if you looked at the actual type that's used here, then you'll see that, oh, maybe I can get to it here. Yes, great. There's a pointer and there's a pointer to the end and it uses this instead of an index because it happens to be better for certain things for zero size types and whatnot, but it basically keeps track of where in the vector am I going to yield next? So that's the reason why those are different. Isn't there a size limit on the stack? There is, so usually you don't want to use a small VEC and have like a thousand elements. It also means that if you pass it across function call boundaries for example, it's gonna have to be copied over between the stack frames whereas with a heap allocation you just pass the heap pointer instead. Why is the lifetime of VEC leak not static? The lifetime of leak is any lifetime you want. And the reason why it's this way rather than returning a static is it makes it a little bit easier for the bar checker to handle this call. If you returned static, then it would need to shorten the lifetime into whatever lifetime you need. If it always returned a static mutable reference, then now wherever you put it, it's gonna put it in as a static. And this ties into how variance works where that means that if it's inside of something that's invariant, then the invariance is now gonna be on static and you might want it to not be on static. So having it be generic ends up with a, it ends up being a slightly, a thing that runs you into trouble less often than static. But they're sort of equivalent. Is there any reason not to use small VEC over VEC? Yes, so the reason not to use small VEC is there's a little bit of an additional cost to keeping track of whether the thing is on the stack or the heap, right? That means you have to branch on an additional field telling you whether it's local or not. And the other reason is because with anything that has allocation on the stack, anytime you pass it in a function call, anytime you pass it by ownership, rather than the boring it out as a reference, the whole thing that's on the stack needs to be copied over. And so you might start to see mem copies show up just because your stack frames are getting pretty large. Whereas with the vector, it's always just three fields. That also means that if you put a small VEC into a struct, the size of that struct is gonna grow by the stack size of that small VEC. It makes things larger. You could implement iterator for VEC by defining next as remove zero, but it would be bad. Yeah, you don't wanna do that. The equivalent you have is dot drain. So there's a drain method on vectors. And what drain does is you pass it a range and the range can be open-ended, meaning drain everything in the vector. And that gives you back a type that is an iterator that will remove all the items from the vector that it walks. But there's no sensible way in which you could implement iterator for vector, for vector itself. Okay. Let's move on to the next collection, which is VEC DQ or VEC DEC. I don't actually know whether it's pronounced VEC DEC or VEC DQ, but it doesn't really matter. So the description for this type is a double-ended Q implemented with a growable ring buffer. And if you don't know what all these words means, I wouldn't blame you. It's actually a very, very simple data structure. It is a ring buffer. And a ring buffer is a vector. And let me maybe draw this. So it might be useful. Okay. So for a vector, what we have is a memory allocation, right? This is zero. This is the capacity. And then we have at some point in the middle, we have the length, right? So everything that's in here is, oops. Everything that's in here is filled and everything that's beyond there is uninitialized memory. Great. Okay. So what then is a VEC DQ? Well, a VEC DQ or a ring buffer is similar. It's a contiguous memory allocation, but it has two pointers. It has a start and it has an end. And anything that's between start and end is considered initialized. And anything that's outside start and end is not. And the reason it's called a ring buffer is because let's imagine that you kept pushing on to end here. So when you push on to end, what happens is that the start stays in the same place. And then as you push on to end, it might go to the end of the capacity and then it will start from the beginning again. And then end might end up being over here. And so it's still now the case that the start, oops, the start is over here, but the end now wraps around. So it forms a ring. And the way that you know that the ring buffer is full is when end is equal to start. There's an off by one here. But when the end reaches start, that means that your length has reached your capacity. And so if you try to push when end equal start, then you do the double the capacity, take all the items and push them on to the new or copy them over to the new allocation. And then now you have twice the capacity, you can push more things. So that's a ring buffer. That's the basic premise of how it works. Now the reason why ring buffers are nice is because they can act both as a stack and as a queue. So if you remember from the computer science or just in general in other parts of life, a stack is the thing that we usually draw this way where a new thing that you add that you push onto the stack goes to the top. So when you push, you get it goes onto the top of the stack and then the other operation you have on a stack is pop, which removes the top of the stack. So a stack is often known as a L-I-F-O. So it's a last in, first out. So the last thing that was pushed is the first thing to be popped. So that's a stack. And a queue is one where, oops, we often draw it this way. And then when we push, it goes onto this end and people draw this in both directions. It doesn't really matter. So we push things onto one end, but we pop things off the other end. And so this then is usually called a F-I-F-O. So a first in, first out. The first thing that was pushed is going to be the first thing that is popped. So a stack is last in, first out. A queue is first in, first out. So the thing that's been in the queue the longest gets to be the next thing to be pushed off. Yes, hi cats, you wanna come in here? Hello. So these two data structures are both useful for a lot of different things for algorithms or just in general for keeping track of things like sending messages over a channel, that's a queue. The stack is used for things like function calls where when you call a function, you push onto the stack, the frame for that new function call and when that function returns, you pop from the stack. So these are very versatile data structures and they can both be implemented using a VECD queue or a VECDEC, right? So with a VECDEC, if you want it to operate with a queue and well actually let's go back and look at the data types involved. So a VECDEC has the following methods. It has pop front, pop back, push front, push back. Now you could do this on a vector too. You can insert something at the front. The problem with a vector that starts at zero is that if you stick a thing at the beginning of a vector the way you have to do that is take all the elements of the vector, shift them over by one to make room and then stick the item in, right? Which means a giant mem copy to move all the things. Whereas if you have a VECDEC then a push to the front just means decrementing start and then adding the element there. If you wanna push something to the end it means increment end and then push the thing there. And so pushing onto either end is a cheap operation. You never need to shift anything around. And similarly you can remove from either end. On a vector it's very efficient to remove from the end because you just remove that item and decrease the length by one. But removing from the beginning is really costly because you have to shift all the elements over. Whereas with a VECDEC if you wanna remove something from the end then it's easy. You remove the item and you decrement end by one. And if you wanna remove it from the start you remove the item and then you increment start by one. And it all just kind of works. So VECDECs are a really versatile data structure. And I like that it's in the standard library. I use it quite often. As I might make you wonder well if VECDECs are so magical then why don't we use them everywhere? And there are a couple of reasons for that. One of them is that they are a little bit less efficient. And the biggest reason why they're less efficient is if you look at this bottom case here. So this case where the ring buffer has grown around, then imagine that you are for example iterating over these elements. So you have a, you're just like doing a loop where you're starting to walk here and you call like iterator or something and you're walking. So the way that the hardware will work is in general as you're walking any kind of memory if it detects that you're walking something like one index at a time what your CPU will do is like read ahead. So when you're here the CPU might load in the memory that's over here. And so when you get here it's gonna load in the memory that's over here. And it's never gonna use this part of the memory because it's outside the bounds. It doesn't know that you're gonna read this memory next. So it's sort of defragmented in a sense. And so you lose a little bit of efficiency this way which can matter a lot for especially typed loops. The other reason why it can be less efficient is because computing the index for any given access. Like if I say I want element number four then element number four is gonna be start plus four but it's not just start plus four because imagine that here. So imagine I have a vector over here and imagine that for whatever reason start is over here which can easily happen, right? I push a bunch of things and then I pop from the front so start sort of slides towards the end. So if start is at the end start plus four would be over here somewhere and that obviously won't work. And so you have to have this decently complicated logic for determining if I give you I want index four to compute well where in the allocation does indent item number four live? And so there's this overhead to every access to a Vectek that you don't have with a vector because of its more convoluted semantics. The other downside like there are a couple of downsides the other downside to a Vectek is you can't turn it into a slice because the memory of a Vectek is not necessarily contiguous. So if you have a slice the assumption is that it's a contiguous piece of memory that's what a slice is but for a Vectek that's not necessarily true if I wanna take a slice of let's say this one right here then the slice would start here and end here and that is not a contiguous piece of memory. And so as a result you cannot take a Vectek and turn it into a slice. So it does not implement D ref to slice which means that there's a bunch of other methods that you don't get access to like a binary search for example which is implemented not on vector but on slice but a Vectek does not D ref to slice so you don't get access to that. Now there are ways to take a Vectek and try to turn it into something that's easier to work with. So for example you'll see that there is make contiguous. So what make contiguous does is as its name implies it takes the vector and it tries to or it doesn't try to it does it shifts the vectors such that start is zero. So what Vectek will do if you had a situation like what we had here where the end is wrapped around is it would take start and it would let me do this in a different color maybe it would take start and it would move start over here and it would take the end and it would move the end over to the end of wherever this ends up being. So this is all gonna shift left and then this end is gonna be copied on to the end. This is a fairly convoluted operation because if you thought about it just naively if I took and mem copied all of the things following start to the start of the vector I'd be overriding the stuff that is now in the end of the vector or in the Vectek, right? So I can't do that. So you have to do a bunch of sneaky stuff to make this copying safe but the standard library does that for you in this make contiguous operation but it does a lot of mem copying it's a fairly slow operation but the advantage is that after you do a make contiguous you know that start is zero and you know that the end doesn't wrap around and as a result make contiguous can return you a mutable reference to a slice because at that point in time it is contiguous. The moment you have you know after you've called make contiguous if you drop this reference then now that mutable borrow of self is no longer in effect and so at this point anything can happen someone might say pop from the front or push so that it wraps around and so you might have to call make contiguous again if you wanted to be able to do anything else. The other way that you can get out slices is is using the ass slices and ass mute slices methods. So what these will do is give you two slices one slice of from the start to the end of the allocation and one slice that is the from the start of the slice no from the actually I think the way this is implemented is yeah it's from the start until either end or the end of the allocation whichever comes first and then the second slice is if it wrapped around the slice from the start of the allocation until the end pointer and so this will tell you then like whether you're currently wrapping around and it will give you a slice to each one and you can do things like well if one of them is empty then I know all the elements are in the other and now I have a contiguous slice. Let's see. Oh Vectek uses head plus length not start plus end that's fine I mean they're essentially equivalent. Can you chain the two iterators from ass slices? You can but you don't need to so even though a Vectek is not contiguous it does implement into iterator because it can easily do iteration for you. It's just that it cannot be turned into a slice specifically and it has the other things we talked about like drain it has retain as well I think it has truncate it has reserve so it generally looks and feels a lot like a vector just not in the ways in which it can't one of the downsides of not it not de-referencing to slices is that you there are decent amount of methods that are provided on slices that needed to be manually re-implemented for Vectek because it just doesn't work the same when you don't have contiguous memory. So binary search for example I forget whether they've implemented it here yeah. So the implementation of binary search on Vectek and it doesn't get to use the same one that's implemented on slices that's why it was added in a much later version and it just is a separate binary search by it's just a separate implementation from the slices so it basically constructs the two slices does binary search on both of them and then or does binary search on the one where the item has to be because it has to do it has to deal with the fact that there are two slices here. The other thing that's kind of interesting that you have and you have this on Vector2 is rotate left and rotate right. So what rotate left and rotate right does is it will take rotate left will shift all items left wrapping around any element that the element that's on the left most side and rotate right will do the opposite. And I think that's all I really wanted to talk about for Vectek. It's a very, very handy data structure when you need to be able to manipulate either end of something. Why is there no is contiguous method or try as contiguous slice? It's a good question. I don't think there's a great answer for that. You could add one. In practice though, you could just call as slices and see if the second one is empty. That's gonna tell you. And that's also try as contiguous slice which is if the second one is non-empty that means that you can't get a slice and if it is empty the first slice is the contiguous slice without needing any unsafe code. What complexity is rotate? Rotate is O of N. It has to shift every element but it only has to do it once. Why do rotate left and right have differently named arguments? That's a very good question. I don't know. That sounds like something you could file a PR to fix. How does Vectek implement extend? Well, I mean, it can easily implement extend, right? If you think about the naive implementation it's just while there is an item yielded by the iterator pass to extend then just push back the items that you get. So that's fairly straightforward. And it too does the same kind of optimizations like Vectek does where if the iterator if we know that the iterator's gonna yield a certain number of items so we can reserve that additional capacity to make sure we have the space for it and don't need to add. Seems like rotate should be a lot more effective compared to Vectek. No, it's not more effective compared to Vectek. And the reason why it's not is because the problem with rotate is that you want this item to now be over here. And you can't do that just by moving start and end. So you could move start to, oh, actually no, you're right. You can do that. If you make the start be here you push it to the end and then you make the end to be here. So you're right, it is a lot more efficient than for Vectek. We're totally right. Why does make contiguous ensure start is a storage index of zero? Couldn't it just make sure end is greater than start? Ooh, it's a good question whether it does that optimization. It might, let's see, make contiguous. I think I wrote part of this so I should remember. Yeah, so if it is already contiguous then it just returns the mutable slice. So it does do that. It doesn't guarantee that it starts at zero. What do you do if you have to prepare a buff array which has a specific alignment, such as systems programming with 16 bytes of alignment? So for alignment you would have to do that on the data structure that you're storing. The vector itself is not gonna enforce that alignment for you. I did the original implementation of the optimized Vectek extend implementation. It's really nice to read. Great, let's take a look. Extend for, extend. Let's go look, extend implementation. Yeah, but it's all turned into a spec extend now and spec extend is more annoying to find. Spec extend is like a specialized extend is what this stands for. So the idea is that you wanna specialize the implementation of extend to exactly which type the iterator is so that you can be smarter about if the iterator came from a vector for example, then you know its size. And yeah, it's contiguous, does exist privately. From what I understand, the standard lib doesn't have additions. Does that mean that it will have to stay backwards compatible forever? Yeah, the API has to stay backwards compatible. Okay, so that's Vectek and Vectek. Linked list is an interesting one because so there's been some debate about whether a linked list even should be in the standard library. The reason why, and part of the reason is because usually when you want to use a linked list it's because of some very specific properties about how you can work on linked list in efficient manner. So you can embed the next and previous pointers into the underlying data structure that you have for example, you can do things like intrusive linked lists and it's not entirely clear that a linked list from the standard library adds a huge amount of value. That said, a linked list is especially a doubly linked was can be really annoying to implement correctly in Rust. There's a great article called learning Rust when the entirely too many linked lists that goes into some of the details of just how hard it is to get the borrow checker to work out right and not make weird mistakes about things like mutable iterators over linked lists. And you'll see in two in the documentation for linked lists it says it's almost always better to use Vectek or Vectek because array based containers are generally faster and more memory efficient to make better use of the CPU cache. So that's all entirely true. The main benefit that you get from linked lists is that it is fairly easy to unlink them in the middle and to link them back together later on. That is the main benefit you get from a linked list. There are a couple of others about like the amount of memory it uses doesn't grow as capacity grows but grows by element. So there are some other things too but in general the really nice thing about linked lists is because you just have, I'll draw it because it's easier to do that way. So when you have a linked list, the way a linked list works is you have a bunch of nodes that represent the elements that you're storing and they are linked together like this and in a doubly linked list the links goes both ways so that you can easily iterate both backwards and forwards. And the nice things that you can do with a linked list is let's say I wanted to insert an element right here but what I can do is I can just sever these two. I can add my new node and then I can update the next and previous pointers here and the next and previous pointers here and notice that I didn't have to touch this part of the graph and I didn't have to touch of the list and I didn't have to touch this. So here there could be like a million other nodes and over here there could be a million other nodes but the operation of doing an insert at an arbitrary point or need a removal at an arbitrary point is order of one. So it's very cheap to do. And so we can see this if we go back to the documentation for collections, linked lists here, append is O of one because there's never a reallocation, insert and remove is min of i and n minus i. Right, so okay, so the reason for this complexity is because this gets back to one of the downsides. In order for us to figure out, in order for us to do this removal, we need to find this node and this node. And the only way to find that node and this node if this is like index i is to walk all the links all the way up to i. So if we want to remove i, we have to walk all the way to i first to find that node so that we can update the next and previous pointers. And so it's not as though this is like just inserts and removals at arbitrary points are free. It is once you know where you are in the list, then a removal or addition or insertion is very cheap. And so that's often why the way the linked lists end up being used is if you are in a space where you have already walked the list or you're already in the process of walking the list and then or you are passed a pointer to an item that you want to do something with or that you might want to remove, then you might want a linked list where you're able to do these like little tweaks in the middle of the data structure or in the middle of the sequence without having to do these expensive shift operations you have to do with vectors. Does it make sense to make a linked list of T256 array to avoid reallocation of the vector? No. You very rarely want a linked list. It is only if you have this very specific property where you already know where in the linked list you want to operate and the operation you want to do is an insertion or a removal. The other thing that's nice here is like for a linked list, it doesn't actually matter whether you're inserting one or many. So you can take an existing linked list. So this is already doubly linked and you can take this whole list and splice it into an existing linked list and you still only have to update this pointer to point here, this pointer to point here, this pointer to point here and this pointer to point here. So splicing in a full linked list is still only order one. So it's still a constant time operation. But again, assuming that you already know where you want to splice in. And so if you look at the implementation of linked list, it tends to implement the reason why it's unclear why it's in the standard libraries because it has these operations like push front, push back. And the problem is that that's not usually why you would want to use linked list. The reason you want to use one is so that you have access to these very low level details like manipulated in the next and previous pointer. But those pointers are crucial to maintaining the integrity of the linked list. And so in the standard library, you don't get access to the fields like the next and previous pointer. Those are private fields, but they are the way in which you can be efficient about your use of a linked list. And so this makes it unclear when you would ever use one of these in your standard library from the standard library instead of implementing it yourself. Because the only reason to use it is to make use of the next and previous pointer manipulation directly yourself and you can't do that for the standard library. Now, there is some work on this in Knightly using these cursor types. So the idea is that you start by getting a cursor to either the front or the back of the linked list. And when you have one of these cursors, the cursor allows you to move to the next node, move to the previous node, peek at the next and peek at the previous. And if we look at cursor mute, it allows you to do insert after insert before, splice after splice before, split before split after. So the idea is that instead of walking by index, you walk using this cursor type and then you use the cursor to do this kind of splicing or insertion and removal. But it is Knightly only, and I think it's been Knightly only for quite some time. Yeah, 2019. And so there's just not that much call for this. You just do it yourself instead very often. And so as a result, that this type is relatively underused in the standard library. Okay, great. Yeah, so a cursor here is like a node handle. That's a good way to think about it. It has a handle to a given node in the linked list, which you can then do the sort of standard linked list operations on. And yeah, it's a doubly linked list. So you can move in either direction given a cursor. That's also why the complexity here is the minimum of i and n minus one of i and n minus i, I mean, is because if you say I want item number 40, then the implementation will look at, well, how many items are there in the linked list? And if there are 50 elements in the list, it'll walk from the back instead of from the front. So it'll be n minus i operations to get to the ith element. If you're asking for index 40 and there are 100 elements, it'll walk from the front instead. And that way it's order i instead of n minus i. How do you turn a linked list into a slice? You can't. So a linked list is just not a contiguous representation of memory at all. Every node is its own allocation, which is one of the reasons why they say it's less memory efficient than these other implementations. So the way you would have to do this if you wanted to turn it into a slice is you would have to iterate over the linked list and collect into something like a vector. And then from the vector, you can get a slice. All right, so those are the sequence types, vec, vectec and linked list. Let's now talk about maps and sets. And the reason I want to talk about them together is because I really want to talk about these by column, not by row. So I want to talk about hash map and hash set and Btree map and Btree set as two separate things. And the reason why this is useful is in part, if we go look at hash set and Btree set, we look at the source. Really, there's so many indirections. Okay, the Btree one we'll show it. So for Btree set, the definition of a Btree set is a Btree map where the key is the set type and the value is just the empty value. Think of it as a unit struct. It is just the value doesn't matter. And if you think about this, it makes sense. So if you have a map, then the keys in the map are unique, which means that the keys in the map are a set. And so the way that you implement a set on top of a map type is you just have the keys be the values of the set and then the values be empty. And so that way to figure out whether something is in the set or not, right? Let's say you want a set of U8s. So what we're gonna say is that is a map from U8 to nothing. The way that you check if a given U8 is in the set by using the map is the U8-4 is in the set if it is a key in the map. And if it's not a key in the map, then it's not in the set. To insert four into the set, you insert it as a key into the map so that that contains check, or contains key check, checks out later on. So that's the implementation that happens for BtreeMap to BtreeSet or BtreeSet to BtreeMap and same for HashSet to HashMap. And you can do a couple of optimizations here, but in general, it's just all of the methods that you see on something like a BtreeSet is really just forwarding into the appropriate calls on the underlying map. So if you look at the implementation of difference, for example, oh, difference is awful. Let's do intersection, that's even worse. Let's do union maybe, that's unhelpful. All right, fine, let's do contains. So you see whether a set contains a given value is just whether the map contains the key of that value. So it's a very straightforward mapping. And then for some of them, like, you know, if you wanna implement, let's say, intersection, what you have to do, right, is walk all the items of self, all the keys of the map in self, and then for each key in self, see whether that key in self exists in the key set of the map inside of the other set. So you loop all your own keys and you check whether those keys are contained in the map of the other set. Sorry for intersection. And then for difference, you know, the algorithm changes, but the idea is the same, that you do this on the key sets of the different maps. Does that difference make sense? And so it doesn't really cost more space. The values are empty. It just means that the values are not really used, but the values are gonna be zero size types. So it shouldn't really make a difference. It depends a little bit on the underlying data structure. It's not always free, but for these, the mapping is mostly irrelevant. It ends up being almost as efficient as a sort of native implementation of a set. Because the zero size types sort of get eliminated. The other thing is like you get the same properties as you get for the key material of the underlying map type. So, you know, for hash set, it requires that the type T implements hash because hash maps key type relies on that type implementing hash. For B tree set is the same thing. It requires that the type T implements ordering, because the B tree map key type requires ORD. And as a result, you also get the resulting properties. So a B tree set is indeed ordered because the keys of a B tree map are ordered. And that's what the set maps do. Are B tree sets ordered? Yes, B tree sets are ordered. Why use a set over a vector? The reason to use a set over a vector is because you want to eliminate duplicates usually. That's the main property of a set is that it is a collection where you know that all of the items are unique. And it's very efficient to check whether an item is already in the set. For a vector, that's not as efficient, right? If you use a vector as a set, the way that you would have to look for whether a given item is present in the set is you would have to walk the vector and see if any of the items are equal. Or you could keep a sorted vector and then do a binary search over the vector, but then it's still log of n compared to a hash map which is lookups are order one-ish. It's not actually order one, but it's kind of close enough. I'm gonna talk about hash maps versus B tree maps or hash versus B tree in general. Okay. So now we can sort of ignore the set types. Although, let's see, what do I want to talk about for sets? Is there anything specifically about, or anything about sets in general I want to talk about? It implements the standard set operations, you know, intersection, difference, symmetric difference, union. It's just disjoint is superset is subset. Anything else? Sets are pretty straightforward. The main things to know about is extend for a set will obviously ignore duplicates because that's the property of sets. Sets also implement bit and, bit or and bit XOR, which allow you to do, essentially use the sort of bitwise operators like a pipe for a bitwise or or a single ampersand for bitwise and in order to take unions of sets or intersection of sets. So if we look at bit and for example, so if you have two B3 sets and you do a bitwise and between the two sets, then what you get is the intersection of the sets. This is equivalent to calling intersection. You see right here. And similarly for bit or, it's the same as calling the union and XOR is the symmetric difference. I think that's all. Sets are pretty straightforward really. Is a set as fast as a vector? It's different. So a set is faster than a vector if what you want from the vector is a set. And then too it depends on the kind of operations you're in workload you're gonna do to that set. But in general, the set types are gonna be better for sets than a general vector is gonna be. Great. And it has been native try data structure. I don't think we're gonna get tries in the standard library. I think the standard library is actually not gonna grow its set of collections probably ever. Again, because it's just so much better for it to be in a separate crate. There would have to be a pretty compelling reason for a data structure to be brought in here. It would have to be used just like everywhere. Again, linked list is unclear whether it should be here which is considered a fairly fundamental data structure because the sort of limitations of the standard library is such that you often want to just do it linked list in particular you kind of want to do yourself. Okay, so now let's talk about the difference between hash map and B tree map. So hash maps and B tree maps both implement the map abstraction. So the map abstraction is you have keys and every key maps to a value and you don't have duplicate keys hence why they work for sets. And so if we scroll down here and look at the complexities you see that hash maps look pretty attractive. Gets are constant time, inserts are constant time, removals are constant time. You don't get range and you don't get append. B tree maps, all of these are log n except for append which is n plus m. And this might be if you're not used to complexities then these are weird. But what you might see is that for hash maps these have caveats to them. So there's this little tilde and there's also this little star and it's because hash maps are a little weird. This tilde means amortized. So what that means is if you did lots of these then in expectation or in average they're all gonna be constant time. The reason why it's not actually constant time is primarily because of resizes. So if you, well forget it's a little bit different forget it's because of linear probing but let's do inserts. So inserts when you insert an item into a map, a hash map then the hash map has a limited capacity because it's backed by buckets. I'll talk a little bit about the internal data search of a hash map in a second. There's also a separate video on that that I'll link up here sometime. But it's basically backed by a vector. And so the vector has a limited capacity. When the map exceeds the capacity of the vector it needs to double the size of the vector, copy all the elements over and then do the insert into there. And so it is amortized order one because the resizes happen every two X. And as a result they happen less and less frequently. And so if you do lots of inserts you'll generally experience the insertion being order one. And because of the way that the resizes work you can think of when you do a resize that cost think of it as being spread over all the insertions that didn't have to do a resize and therefore they're kind of all order one that's what amortized means in this context. And so that's what the star is that's the resizing amortization. The tilde amortization here is because of something called linear probing. And in order to understand linear probing we need to talk about how hash maps work. So let's do that real quick. This will be a data sector refresher. Okay, so what is a hash map? A hash map, ooh that's a awful color, let's do this. A hash map, wow, that was supposed to be a straight line. That was also supposed to be a straight line. Okay, so a hash map is really just a vector and you usually draw the vector down, you don't have to. And so this is zero and this is n or the capacity. And when you want to insert, let's say you do an insert and let's use numerical values for now just because they're easier. Insert seven with some value foo. What happens? Well, you take seven and you run it through a hash. And what a hash does is it'll take the key and it will hash it which turns it into an integer. And turning an arbitrary thing into an integer that depends on the size of the integer but it necessarily means that you're reducing the space of possible values. So multiple values might map or multiple keys might map to the same hash. We'll get back to that in a second but for now let's just assume that a hash is a way to take an arbitrary data type value and turn it into a number. And so what comes out of the hash is some number and in the case where the input type is numbers is not guaranteed to be the same number but let's just assume for now that it is. So seven gets hashed, turns into seven. And then what a hash map will do is it will take the hash and use that as an index into the vector. So zero, one, two, three, four, five, six, seven. So this key is gonna go into this slot. And a slot usually has sort of storage space for the key and storage space for the value. And so the key slot is gonna get seven in it and the value is gonna get this foo in there. And then when someone does a lookup so if someone does a get of seven then the same thing is gonna happen. The seven is gonna go through hash out of that comes in this case seven again and that's gonna tell it, oh, we'll go look at this bucket and then it's gonna take the value out of there and return that back to the get. So it's a very, very basics of a hash map. Okay, so very roughly this is what happening. Now, if you get a, and then over time if you insert lots of values you're gonna start to fill up the vector and at some point it's gonna be full and when it's full then you resize the vector so you now have twice as many buckets and then you can add more elements in. So the next question then becomes, well, what if someone now does insert eight and say bar? So the key here is different but let's say that eight when you put it through this hash comes out also a seven. It can happen, right? You're reducing the space here. Imagine the keys were strings instead of numbers. There are an infinite number of strings but there are only so many U32s or U64s. So you are going to have collisions. Well, now that is gonna hit the same bucket as what the key seven hit. It's gonna hit bucket number seven. And so as a result, when it looks into this bucket it sees, oh, this is already occupied. It already has a key and that key is not equal. The existing key is not equal to eight. And so I'm not allowed to use this bucket. So when you run into these kinds of hash collisions then the hash might be implementation as a choice to make. There's some implementations that will actually keep a, every bucket is like a linked list. So you actually can store multiple items inside of each bucket. This has some upsides which is you don't, you always know the bucket and then you just walk the linked list the downside is now you need a linked list which means memory allocation because you need every item in the linked list is a separate heap allocation. The other thing it means is that now you end up with this linear walk. So let's say that I was looking for key eight. Well, now I need to walk this linked list all the way out to the end potentially to find key eight. And if I have to do an insert then I have to walk all of them if the key is already not there. And so the complexity ends up worse like in general it tends to behave less well. And so that is one option that's not what the standard library does. It does not have a linked collision handling. Instead it uses something called linear probing and there are multiple different kinds of implementations of linear probing as well. There's also quadratic probing and SIMD and I'm gonna sort of ignore some of the details here because they're not really relevant. The basic idea for this probing approach is that you actually do multiple steps of hashing. So if you find that the first bucket that you were after is already taken then what you will do is you hash and the exact mechanisms are gonna vary but you can do things like, well if my first bucket was taken then what I'll do is I'll do a hash of this and this. And so now that's gonna give me some other bucket number like say six. And so then I'm gonna go look at number six instead and here this is empty and so I can take this slot. And this still works for gets. So if someone now does a get of eight then they're gonna hash eight through hash as supposed to say hash go to seven. It's gonna look in seven and see oh but the key that's stored there is the key seven not the key eight. And so then it's gonna do this hash of these two together through hash and that's gonna go to now bucket number six it goes in bucket number six finds the key eight is indeed there and so as a result returns that value. And so what you're gonna end up with when you have this kind of probing is that over time your keys are gonna be a little bit further away. So they're not gonna be O of one, right? Cause they might need, you might need to do this extra hash multiple times. It doesn't have to be a hash there are other ways to do it too but you might have to look in multiple buckets before you find the key you're actually after, right? And so it's not quite order one cause it's not gonna be in the first bucket you look at but it's gonna be you're gonna find it pretty quickly. It also depends on the nature of your hash function the nature of your keys how many of them hash to the same value. So like this is definitely tricky. It's actually pretty hard to analyze the cost of a hash map because it depends on so many things like the hash function. As you'll see the hash also gets invoked a lot of times. So the hashing function needs to be pretty fast and the standard library ships with a hashing implementation that is decently fast but definitely not among the fastest you can find out there. So you can look up a bunch of crates that provide faster hash functions which then significantly improve the performance of your hash maps. So let's see there were some questions. When you do get seven, when you do a get seven how does it know which bucket to look in without comparing to the keys in all the buckets? So the way it does, so for the get seven right it has the key it runs the key through the hash and that tells it bucket seven. So it looks in bucket seven and in bucket seven it finds that the key slot indeed contains the key value seven. I should have used strings here and therefore it knows it's on the right bucket. Why insert though isn't changing a value the same operation is inserting. Yeah, so if you wanna update a value in a map it's the same thing as an insert. You can do slightly better than that but you can think of as updating a value as sort of equivalent to an insert. But you can also do, you know, in Rust you can do a get mute of a key and what that will give you back is a mutable reference to the value at that key. How many buckets does a hash map have and how many values can each bucket store? So each in this kind of implementation each bucket can only store one value like one key value pair. And the number of buckets follows a similar kind of rules to vectors but the minimum capacity is usually larger. So I forget what the default size is I think it's something like 64 buckets or 128 buckets is like the thing you get when you do hash map new. If you do hash map with capacity it has that kind of constructor too then it'll allocate way more buckets for you to begin with or however many buckets you say and then it'll grow the number of buckets by two times every time it determines that it needs to resize. You'll also find with the hash maps that the fewer buckets you have the more collisions you're gonna have. So if you try to like fill a hash map so you try to insert like 90 items in a hundred bucket map your performance is gonna be worse than if you have 50 items in a hundred bucket map just because of the collisions which lead to this kind of probing. Why doesn't the standard library use a faster hash implementation in the first place? There are a couple of reasons for this the primary one is that the implementation in the standard library is a cryptographic hash. So they try to make it really hard to predict what the hash output of a given key is gonna be and the reason they do this is because imagine that you are running a web service of some kind and you're using something that comes in from the user's request as your key. Well, that when you hash the value it turns into the bucket key and if the user controls the key and they can easily determine the hash function or determine what the output of the hash is gonna be then what a malicious user can do is send you lots of requests with different keys that all hash to the same value and as a result they can basically do a sort of denial of service attack to you where they're forcing your hash map into the worst possible performance by filling all the buckets because of all the collisions that they generate. So what the standard library does in its default implementation is that when you create a new map it'll generate essentially a random number that is gonna use to seed all the hashes for access to that map. So an attacker even if they know the hashing algorithm they don't know that seed that's per map and so as a result they can't figure out how to cause keys to generate the same hashes and cause these collisions and cause a performance collapse. And the hashing function is a cryptographic hash so it's very hard to reverse. So even if a user learns what hash they had they can't then use that to determine what the seed was to generate additional collisions down the line. So the standard library basically tries to be secure by default but as a result it's also less performant by default which is why you're able to slot in your own hashing algorithm. So if you go look at hash map here you'll see that there's a key of value an S here and S is the randomized state that's being used and the default one is from the standard library random state which has this seed that it uses for hashing and if we look down a little bit further you'll see that the requirement for S is that it implements build hasher and build hasher needs to be able to build a hasher and a hasher is a thing that can hash a value and when you hash a value it takes something that implements where we have it that implements hash which is the thing that you derive for a lot of types. So it's all a little convoluted. I'm not gonna go through all the call chains here but the basic idea is that the hash map is generic over what kind of hasher you wanna use so you can swap in your own faster version if you really want to. Yeah and so the reason for the complexity of the reason why remove is amortized is because once you have this probing if you remove a key then you need to go to check anything that would be probed after you so anything that might have collided with you and sort of shift those up because otherwise let's say here that the key seven was removed then a lookup of eight would now go into the bucket that's supposed to hold eight, right, bucket seven find that it's empty and go and conclude well eight isn't in the map. And so that's why when you remove the key seven you now need to move the key eight into that bucket so that the probing still succeeds. So that's convoluted too and needs to be amortized. What can downsides of these faster hashing algorithms be? Usually the downside is partially complexity and partially this kind of collision resistance. Wouldn't you need constant time per request for cryptographic security anyway? This isn't about cryptographic guarantees but it's not about the map itself. So it's not constant time access to the map. It's a cryptographic hash in the sense that it has a pre-image resistance like there's a bunch of properties of hashes that make them cryptographically secure but that doesn't make the whole data structure suitable for using cryptography necessarily. The lookup is not non-deterministic to be clear. The lookups are deterministic. It's just that they are if you have two different hash map instances then they're not gonna use quite the same hashing. They're gonna use the same hashing algorithm but not the same hashing result. They're not gonna seem to get the same hashing results which is fine because they're in different maps. How would you know that something is collided? You would not generally know that the collisions are hidden from you. This probing happens behind the scenes. The only way you would know is that the performance is gonna, if you have a collision then lookups are gonna be slightly slower for the key that has the collision. Okay. So let's then look at hash maps now that we have a little bit of an understanding of what they do. So you see you can construct them with a capacity that tells you basically how many buckets should there be. You can construct it with a given hasher if you wanna use a different hasher than the standard one. Use a capacity and hasher. You can iterate over all the keys and the way this works is you just iterate over all the buckets or all the buckets that have the key slot filled with something. You can iterate over the values which is the same you iterate over all of the buckets and for each bucket you yield the value from the bucket. And if you just call iterate like dot iter then what you get back is something that implements iterator where it yields the key and the value. And you can do that because for every bucket it stores the key and the value in the bucket. Drain, retain all the same things we've talked about. Oh, shrink to fit I haven't talked about for the other data structures either. So this is, if you imagine you do a bunch of inserts into a data structure and it grows a lot and then you do a bunch of removals then the capacity of the vector or Vectek or hash map is not gonna shrink because it grows when you need space but it doesn't know that you want to reduce the size of the allocation. You can use operations like shrink to fit to say measure how many items are currently in the data structure and then reduce the capacity to fit only those items. And so that's also a resize. It's just a resize down instead of a resize up. So it still requires you to copy over items and that kind of stuff. What's interesting about hash maps in particular is that when you do a resize it might change the hash value that you have because usually imagine that the hash was like 23 then you have to do it modulo the number of buckets so that you get one of the actually valid buckets but that means that if you double the number of buckets the hash function kind of changes because now a hash of 23 might actually be valid it might be a valid bucket. So usually when you grow a hash map it's not just a mem copy it also requires redistributing the keys amongst the buckets. Get key value, get many which lets you look up many keys at once which is likely only for now. Insert, yeah so the thing to remember with insert is that it is also an update. So an insert if there's already a value for the given key is gonna replace the value for that key and it returns you back the value that was there if there was one. And removed gives you the value that was removed. Remove entry is you give it a key and it gives you back both the key and the value. And the reason this might be useful is because you'll see this is in direction through borrow. We're not gonna talk too much about borrow but essentially what it means is imagine that you have a map where the key type is string capital S. You can do lookups into it using STRs like lowercase str. Even though those are not the same type but the borrow trait here, the generic over borrow means that you can use a borrowed version of the key that's stored in the map to do lookups. And so in this case, if you have a capital S string stored as the key, you might do a lookup with just a string literal and like an STR and for remove entry what you get back is gonna be the actual string capital S and the value. And this might be valuable because it might mean that you can reuse that allocation for something else down the line. So the other thing that I wanna talk about for hash maps and the same as the case for B-tree maps is the entry API. This is something that I haven't seen in a lot of other languages. It's not, I don't know that it's unique to Rust but it is a very common way to work with maps in Rust. The entry API and I think there's maybe a better explanation of it up here somewhere. The entry API takes a key. So you call dot entry on a map, you give it a key and what it gives you back is a reference to the bucket or to the key value pair the would go there. And if it's in fact, if we go look at it it's gonna be a little bit easier to explain. So what entry gives you back is one of these entry things. An entry is an enum that's either occupied or vacant. So an occupied entry, if you have one of those then that represents an item that is in the map and you can do things like get the key, get the value, you can replace the value, you can remove this key value pair from the map. So it gives you a way to manipulate the item that's already in there. But if you did an entry and there was nothing there for that key you get back the vacant variant instead. And for a vacant entry, the thing that you can do is look at the key or you can insert something into that empty slot. So the advantage of doing this is normally you'd have to write code like if let sum equals map dot get mute and then potentially change that value else map dot insert key and then value. And what that would do is if you have a get mute and an insert, you're doing two rounds of hashing, two rounds of bucket lookups. If you use entry, you only do one because you do the whole lookup and then what you get back, this vacant entry has a pointer directly to the bucket. And so now if you choose to insert something in there you don't then need to redo all the hashing because you have a pointer directly to the bucket. And entry provides a bunch of other nice APIs or insert which lets you do dot entry some key dot or insert which means give me a mutable reference to the value or if there is no value insert this one and then give me a mutable reference to that value. And so this is a really handy way to work with these collections. I wanted to see if I think there's an example of the use of or insert and or insert with and or default as well if you go up to the top here. Yeah, so player stats dot entry health dot or insert 100. Right, so this is gonna if the health is already set in the map it's gonna keep whatever their current health is but if it's not then it's gonna set it to 100. And it can also hear you say you say dot entry attack or insert 100 and that gives you back a reference that you can then modify. And so what this means is if the attack modifier is already set in this map then you're gonna be modifying whatever it's currently set to. If it's not already set you're gonna insert 100 and then modify it. So this is a very nice interface for working with a value in a map that may or may not be set. Wouldn't it be easier to set it to empty but have the key of seven still there's so the eight doesn't need to be moved. It might be easier but then you're gonna end up with a so the proposal here is in the case where you remove the key seven from the map then instead of trying to figure out where the key eight is and move it into that bucket that's now vacant just leave that bucket that had seven in it and just keep storing the key seven in there so that gets still work. The reason you wanna do the work to move eight around is both to recover performance so that eight no longer needs to do this unnecessary probing. It's partially because when you remove you give back ownership of the key and so the caller's gonna expect that that key be dropped and so if you keep it around you're not gonna drop it and so for example memory allocation is not gonna go down and so those two reasons mean that you generally want to do this emptying you don't wanna leave around a value or a key that's no longer actually there. Why when iterating a hash map it iterates also through the empty buckets with iter. It doesn't well so what happens when you call iter on a map is it walks the buckets but it only yields key value pairs from the buckets that are non-empty and the reason why it has to do this is because what else would it do? How would it iterate over only the buckets that have stuff in them? There isn't really something that says in the hash map all it really stores is a pointer to the buckets and sometimes maybe the length but in order to also in order to be able to sort of if someone told me to iterate over this right then and all I have is this pointer how would I know how to sort of get to skip all these empty ones and start there? I would need to store some additional state and this gets weird if like imagine this has some stuff in it and this has some stuff in it I would need to know to go here and then I would need to know to skip this one and then I would need to know to skip these ones and so suddenly that turns into a lot of extra bookkeeping that increases the size and complexity of the data structure and thus often reduces its performance as well. So this sort of brings us to the implementation of the standard library hash map. So it used to be the case that the standard library hash map was its implementation lived entirely in the standard library and I forget exactly what the implementation was I think it was linear probing it worked okay, it was just like a little bit of an unoptimized implementation and then this crate called hash brown came around and hash brown is an implementation of a a high performance hash map implementation called Swiss table that came out of Google and someone basically ported that to Rust and one of the really cool things they did was they did lots of benchmarks of this implementation it's been very optimized it doesn't use linear probing it uses like quadratic probing where it looks at multiple buckets and it can use SIMD to look at them in parallel it's all sorts of fancy it has lots of optimizations Swiss table does and hash brown on top of it and it turns out that it's just faster than the standard library hash map in every way and the interface is compatible with the standard library interface so it took a while but they actually now have replaced the entire standard library implementation of hash map with the hash brown crate so if you look at the source over here you see that the implementation of hash map in the standard library is just base colon colon hash map and if we go up to the top here you see that it uses hash brown hash map as base so the implementation of hash map no longer lives in the standard library it lives in the separate crate called hash brown so if you go over here is this crate over here and Amano is the person who implemented that and maintains the hash brown crate it's done a lot of other really cool work in Rust 2 and it's really neat if you go look at the implementation of hash brown it has a lot of cool optimizations that make it just really really really fast it also means that you can choose to use hash brown yourself directly rather than using what comes in the standard library there's not a huge amount of reason to do this the main reason is because it comes with a different hasher by default it uses a hasher called ahash which is faster than what comes out of the standard library in general though it's like apart from the different in hashing it's fairly similar to what the standard library ends up using and the way that this is usable in the standard library and it has some more sort of things like rayon which is nice but one of the reasons why let's see if I can dig this up ummm it might just be over here yeah, so you may still want to use this crate instead of the standard library since it works in environments without std it only requires alex remember how I talked about earlier that vec and vektac and btremap all live in alex, not in std and that's because the hashmap implementation in the standard library needs randomness which comes from the operating system which requires std for the sort of collision resistance that they build in, the hash brown implementation does not require that you use the default hasher. And so it can be used in environments where you only have alloc. So it can be used in embedded systems and kernels and that kind of stuff. But otherwise, the implementation is completely the same as what is in the standard library. They're sort of drop-in replaceable. The hasher is, as they say here, it's much faster, but it does not provide the same kind of hash than I will serve as a resistance. I don't think this is still true. I think the standard library now has this, to by virtue of using hash brown, but I forget whether that's the case. And you see, there's a comparison here of, it was in Rust 136 is when the implementation of hash map got replaced with hash brown. And this is a comparison of the performance of different workloads of the old hash map implementation and the one in hash brown. And you see it's just like faster across the board, often by significant amounts because it uses a smarter implementation, SIMD, all of that good stuff. It even supports like arena allocation and stuff, which is nice. Okay. So that's the standard library, you know? That's the hash map implementation, the standard library. I don't know that there's anything else I really wanna talk about there. Right. So one of the downsides of hash map is that it requires that the key is hashable. Not every type is hashable. Many are, but not all of them are. And it also doesn't impose any kind of ordering as we'll see with Btree map, which means that if you iterate over a hash map, you get the keys back in random order. You don't get them back in the order that you inserted them in. You don't get them back in sorted order. It's just random order. And the order can vary every time you call it, or it's not even a stable iteration order, which is something that sometimes comes back to bite people. So often a source of non-determinism in their programs. Okay. Do we have questions about hash map before I move on to Btree map? How'd you put the toolbar at the bottom? Oh, that's, you can look at my Firefox configuration. It's like a custom user Chrome CSS. It's in my Github configurations repository. Maybe I missed I'd be using hash brown as the same as the standard library hash map performance wise. Yes. The standard library hash map is hash brown, but it uses the standard library hasher in the hash collision resistant one instead of the one that hash brown uses. How do you have different hash implementations if you're deriving hash? You don't and you don't have to. So if we look at the, where's the hash trait there? So you'll see that there are two traits. There's hash and there's hasher. So hash is what data types implement. And what all hash does is it has this method called hash that takes a hasher. And if we look at how to implement hasher or implementing hash, you'll see what it does is it just calls hash on the hasher that's passed in. So the hash trait is really just a way to sort of walk a data structure. And there's nothing here about like, Shah one or turning things into an integer. It is just calling the hasher or providing the data to the hasher. Hasher is the trait that actually defines the hashing algorithm. And so if you look at the bottom here, hasher is implemented for SIP hasher which is the one that's the default from the standard library. And then if you look at a hash, for example, it also implements hasher. So the hasher trait is the thing that dictates the implementation or the hashing mechanism. And the hash trait implements how to walk a data structure. Think of this as the difference in between serialize and serializer in CERTI where serialize implements the way to walk the data structure and serializer implements the data format. Okay, so I think that leaves us then with the Btree map. So let's go look at Btree map. The collection source code has a lot of unsafe. Why is that? The implementation of data structures generally requires unsafe because you're often doing a lot of raw pointer manipulation, for example. So it's usually tricky to write any kind of efficient collection, especially once you want to do things like, SIMD or this kind of quadratic probing. There's a bunch of complexity that you just can't express purely using safe code. And so you end up using unsafe code a lot in the standard library and in implementing collections. That's just a necessity of their implementation. Basically the safety properties you have to guarantee in these collections is often, they're often too sophisticated for the borrower checker to check on your behalf. All right, so Btree map. So Btree map is a map implementation just like hash map is, right? So they both implement the map abstraction. And the main difference between Btree map and hash map in terms of their interface is that Btree map does not require that the key is hash. That doesn't require that it's hashable. Instead it requires that the key is orderable. So it requires that you can put all the keys in some order. And the reason why it does this is because a Btree map fundamentally works very differently than a hash map does. So if we go down here and look at how would you do a Btree map? Well, a Btree map is this, so let's start with the binary tree that many of you are probably familiar with already. So a binary tree is one way to implement a map. The idea is that let's fill this maybe with values to reduce something like seven, three, one, four, nine, eight, 12. Let's say that I here want to do a lookup for four. Let me do that in a different color. So I want to do a lookup for four. So the properties of a binary tree is that you, and there are all sorts of guides on how a binary tree works. I'm not gonna go into too much detail, but the basic idea is that every node in the tree has two children. The children on the left have a lower value than the root node, and the children on the right have a higher value than the root node. So you see here for seven, all of the things that are to the left of seven are lower than seven. All the things to the right of seven are higher than seven. So if I want to do a lookup for four, I go and compare it to the root of the tree and four is less than seven, so therefore I go left. Four is greater than three, so I go right. And then I end up at a node where the value is same as what I was looking up, and so I've found my node, and then you can imagine that instead of this just holding four, it's actually a bigger node that holds four and some value, and so I can return the value, right? So this is the way that you do lookups in a binary tree. And so you can implement maps this way, it works totally fine, it's pretty common. Now the downside of using a binary tree is that, well, there are a couple of things. The first one is that every circle here is an allocation. Wow, I started well and then it went poorly. So every node here is an allocation as you end up with a lot of overhead because you have to allocate every single node and you have to keep for every value, right? For every value in this tree, you have to store the key, you have to store the value, you have to store the left pointer, and you have to store the right pointer. So there's a lot of overhead here. Imagine that the keys and values are like U8s or something. Then the left and right are both pointers, so you're storing, you know, no longer storing K plus V, you're storing K plus V plus two times pointer size, two times U size, times the number of elements. And so that overhead grows really, really large. And then the, even just following these means that if you wanna look up four, you have to follow a lot of pointers. You have to follow log end pointers to go all the way to the bottom of the tree, which is where you might need to search in order to find a given key. So it's a really long path to go down here. And that means that a binary tree is often considered a, it has some nice performance properties, but in terms of runtime characteristics, there are a lot of downsides to them. And so that's when we get to the abstraction of a B tree. And so a B tree is a little bit different. A B tree is one where instead of having the nodes hold one value, the nodes hold B values, hence the name of the data structure. So let's say that the root node here, so this is a B equals four tree. So here, what we're gonna do is keep pointers to the left or the right of each sort of collection of four things. And then another one over here. So we still construct a tree, but you see the tree is a lot shallower. So now up here, we might have, you know, seven, wow. Okay, fine, let's do an actual seven. Seven, nine, 10, 14. And then all of the values over here are gonna be greater than 14. And all the values here are gonna be less than seven. So, you know, two, three, four, and this is gonna have 16 and 20 or something. And so now if I wanted to say, look up the value four, what I would do is I would go in here, I would see that it is less than the lowest value in this node, so I need to go left. And then I look through all of the values here, and here I can use a binary search within this node and find that it's here. So I only needed to traverse one pointer in order to find four. And so this is more efficient in terms of following, doing sort of pointer chasing. It's also more efficient in terms of allocations because now I only have to allocate this one, this one, and this one, so it's fewer allocations. The overhead is less because here I kept what, four, five, six, seven, eight, nine. I had nine items, but I only have two pointers. As I'm reducing the amount of overhead that goes to just storing pointers. I still have to store the length and the capacity for each of these. And so, or actually the capacity I know is equal to four so I don't need to store it because it's equal to B. So I only need to store the length. And so this ends up being a much more compact and cache-friendly data structure too. It's cache-friendly because the root node, for example, all of these items are probably gonna be kept in the data cache for the CPU because you always have to go through the root to do any lookups. And so the binary search in here is gonna be a lot more efficient because it's generally in cache. Same with the nodes that are higher up in the tree, you're usually gonna hit faster. The complexity of the data structure is roughly the same as a binary tree. It's a little bit harder to insertions because you need to imagine that I insert, imagine I wanna insert a value that's gonna go in here. Well, now 14 is gonna be pushed out in order to make room for that item that goes in here. So they're a little bit more complicated to maintain but tends to have a somewhat better performance profile. Log in is only if it's balanced. There are a bunch of caveats here to the data structure that I'm not going through but basically this is the idea. And you can see here that this requires that the types are ordered because you need to be able to say that things that are greater than 14 or things that are less than seven and all the ones in between here we know are greater than or equal to seven and less than or equal to 14. So this all relies on items being orderable, the keys being orderable relative to each other. So it's a very, very different data structure to a hash map. And it is no longer roughly constant time, like amortized constant time to do lookups or insertions, instead it's log n where log n is the how deep the tree is essentially. And yes, B is the same for every node. So I forget what the B is in the standard library implementation. Oh, it does linear search within nodes not binary search, which is interesting. And the B, where is the B? Is it defined at the top of this file maybe? Yeah, and there's like rules for how you split and stuff. There's all sorts of details to this that I don't want to go into too deeply. Just trying to see if I can find, yeah, so it's a linear search, not a binary search but it can be a binary search. They use a linear search because if B is small enough the overhead of a linear search is actually pretty significant. I wanted to see if why is B not just a constant that is easy to find. All right, fine. So this is in alloc collections, BtreeMap, alloc collections, BtreeNode, nope. BtreeMaps I think are always maintained as balanced. You never have one long chain that's much longer than the others, for example. Just trying to see if I can find where the damn B is. Aha, B is six. So the Rust Ender Library, BtreeMap is a Btree where B is six. And that's just, they just found that six was the right value here. There's no way for you to customize it. That's just what it is. And so you might wonder, well, why would I ever choose a BtreeMap over a HashMap given that the HashMap has generally constant time lookups and insertions and such. And there are a couple of reasons. One of them is the BtreeMap is sorted by definition. So if you iterate over a BtreeMap you always get the keys in order. This alone is nice. It means that you don't need to sort things because they're kept in a sorted manner always. The other is if you have key types that aren't hashable then BtreeMap is gonna let you use those as the keys regardless. Usually though, anything that's orderable is hashable but the reverse is not necessarily true. Like for example, floating point numbers are hashable but they're not orderable. So they can be used as keys and HashMaps but not in BtreeMaps. So it's usually more this property if you want things to be sorted. The other thing that's nice about BtreeMaps is that their allocation behavior is a little nicer. So usually the overhead of a BtreeMap is relatively smaller than the overhead of a HashMap because remember HashMap needs to keep number of buckets to keep collisions low. So usually you end up, I forget what the saturation of HashBrown is but I think it's somewhere around like 60 to 70% to tries to keep the buckets about, let's say 70% full. In a BtreeMap, the nodes are generally fairly full and the tree is fairly full so you're not wasting quite as much memory. So in a HashMap, if you have a thousand items, you probably have let's say 1500 buckets. So that means you have 1.5 times the memory usage of your actual data. Whereas with a BtreeMap, that number is gonna be smaller. It's still gonna be more than one. Like the factor of the overhead is gonna be more than one or the required memory use is gonna be a factor of more than one. It's gonna be something like 1.1 or 1.2 but it's gonna be smaller. So if memory use matters a lot to you, the BtreeMap might actually give you slightly better allocation cost. And then the last thing that's really valuable with a BtreeMap, which the standard library is still lacking a little bit in this regard, is that it has a property that's somewhat similar to linked lists in that you have this cursor interface where at least in theory, I can do something like look up key four and then say give me an iterator that starts from key four. And then I'm gonna walk right or left from there. So like, what is the next key after four? What is the previous key before three? And so you can have this sort of lookup interface that's really nice. You can do things like remove the first item because they're orderable. There's a total order to them so you know what's first. You can remove the last one. And I wanna see if they have added any of the... Oh, you can also append a BtreeMap to another because at least in theory, if I gave you a new BtreeMap that started from 21, so I have this whole structure over here, I can reuse a lot of the allocated nodes that are internal to the tree. I can reuse those when I try to append this. I can't just stick it onto the right. Usually you wanna rebalance it, but I can at least reuse the allocations. In a HashMap, you can't really do that because it's just one giant allocation. And so if I gave you another HashMap, you just take the values out and then drop my allocation. But in a BtreeMap, all of those six item allocations that are over in the new tree, I can reuse when I grow my existing ones. Yeah, so range is the way that you can get an iterator that starts at a given value and then iterates from there, or a given key rather. And iterates from there or to there. You can get mutable iterators too. So you can say, give me a mutable iterator that starts at four and then walk all the items from four and upwards and that walk will actually be decently efficient. With a HashMap, there's no equivalent to that. And similarly to how you can sort of merge Btrees, you can also split a Btree. And because it gets to reuse the allocations for the split. So when you split a Btree, imagine we split it right, imagine we decided to split it somewhere over here, right? Then this allocation is gonna go to the right map and this allocation is gonna go to the left map. And then this allocation can go either way and then we're gonna have to do one allocation. But that still means that if this was a giant tree, we're only doing one allocation even though we'd split this giant tree. And the things under there, we might not even need to rearrange at all. We might have to rotate a little bit to get things into the new slightly emptier root node, but it's a very efficient operation to split a Btree. It's not true for a HashMap. Lower bound, upper bound, it's just min and max. Indexing, ordering. Yeah, so it looks like they don't really have a, unless this is on range mute. As a range mute, you can walk in either direction. I was trying to find, I know there was a proposal for cursors for Btree maps, but I guess it's not in here yet. That's a shame. Let me see if I can dig up that. Tracking issue for Btree map cursors. Oh, I see, so that's the proposal is the lower bound be use of this, I see. So you say, give me a cursor to the Btree map starting at this value and then let's see what you can do with a cursor. Right, so this is similar to the cursor that we saw in linked lists where you can move left, you can move right, but also you can do things like modify the values and the keys that you're walking through or actually just the values. The key, you can't change the key because if you change the key so that it should be somewhere else in the tree, your data structure is now broken, so that's why it's unsafe. But you can modify the values as you walk through them. Okay. Yeah, so you see when we looked at the sort of complexity operations for maps, you see the range, there's no equivalent for an hash map and similarly append, there's no equivalent for an hash map and same thing for split. There's no meaningful way to split a hash map because there's no ordering. Okay, so that's Btree map. Any questions about Btrees before we move on? Can you please comment on the unchecked versions that I see here? There's no unchecked in Btree. There's an unchecked in lower bound cursors I think. Yeah, so the unchecked here for key mute, this is again because if you're walking a Btree and if I give you a mutable reference to the key of the item you're currently at, then if you change that key, you might change. Like imagine you're on four here and I give you a mutable reference to four and you change it to 19. Then now the data structure is invalid because 19 is supposed to be over on the right here but it's still over here and you just like rewrote it to 19. That's not okay. And so that's why that operation is unsafe and the unchecked in the name is sort of implying this that we're not gonna check that you're not messing up the data structure hence this operation is unsafe. Curious about the number B, why is it six? I suppose it's a compromise. Yeah, so the larger the number is, the more efficient the data structure is but the more space it wastes, right? So imagine that you have this be set this, sorry, if you have this set to like 20 then you're gonna have way fewer pointer chases but you're linearly searching the buckets is gonna be slower, which again is what Russ currently does. And two, you're gonna end up with more space that's unused in each of these nodes. So you'll notice here, because B is four, we're wasting this one, we're wasting this one and we're wasting this one. So we're wasting sort of three capacity, if you will by nodes that are slots for values that aren't there. But if B was 20, then in this map, we have one, two, three, we have nine items, right? And so in this case, we would have an allocation of B, wow, that was supposed to be a B, B equals 20. If we had B equals 20, we would have nine items that were actually occupied, but then we would have over here, we would have 11 unused slots. So we're wasting a bunch more memory. And so the larger the value is, the more you waste memory but the more efficient the data structure is. And so I think six was just like, six seems like a good decision. And here I used four, so it's wasting even less, but it means that it's chasing more pointers. B2 maps don't necessarily require more memory than hash maps. Often the overhead is less. Why can't the list of buckets in the hash map be appended with another list of buckets? Well, so first of all, they're different allocations. So you can't append allocations to one another. That doesn't work. The reason it works here is because they're pointers to separate allocations already. So you can reuse them across the data structure. But the other reason is because even if you could append the vectors, the hashes would no longer work out because remember when you take a hash of a key, you take it modulo the number of buckets. And so if the number of buckets changes because you appended, then now the modulo changes which means the hash changes, which means you might need to relocate a lot of the entries here and a lot of the entries here for the data structures to remain valid. If the complexity of merging two B-tree maps as N plus M is it not similar to removing elements from one hash map and merge it into the second hash map? It's similar, but the difference is that the N plus M is not amortized. For a hash map and it would be amortized N plus M and also it would require a reallocation. Whereas for the B-tree maps, you can reuse all the allocations which also helps a lot for the actual performance. How do you guarantee that the key changing is correct? You don't, that's why it's unsafe. You as the caller have to ensure that you did not change the ordering of the key. And the way that might happen, right, is imagine you have a key that has a bunch of, you have a key type that has a bunch of fields in it. But the implementation of ORD, the ORD trait for that structure only uses one of the fields. Well, then if you change one of the other fields, you know you haven't changed the ordering so therefore the operation is safe. You mentioned the B-tree map is cache friendly, but it didn't seem to be the case for me. Like empirically you tested it out because it's supposed to be more cache efficient because each of the B nodes, certainly towards the root of the tree are likely gonna be in the data cache of your CPU. And so that's gonna be faster. I'm surprised B isn't two to the power N, wouldn't that help for cache lookups? It doesn't matter whether the B is a power of two. What matters is whether the B times the size of the data type is a power of two. But that's more likely to be the case if B is a power of two is true. I genuinely don't know why they chose six. Why can't we modify B? I imagine that you might be able to do benchmark driven optimization to find the right B value. It's interesting actually, I wonder whether we could make B-tree map have a const generic B now that would be six by default, but you could change. I think there's some parts of the algorithm where you code specifically to the B, but even then having a const generic should be doable. But at the time there weren't const generics so B-tree map is not generic over it. These days that might be an interesting thing that we could do. Yeah, so why didn't they make it generic over const B? It's because const generic didn't exist in Rust 1.0. Are you covering probabilistic collections? No, I mean, hash map is arguably a probabilistic collection, but no, you're thinking of something like a Bloom filter? No, I'm only covering the ones that are in the standard library. Lookup is expected constant time for B-tree map? No, it's log n, order of log n or upper bound log n because you might need to traverse all the way down to the leaves and the leaves are log n down. Can't you remove and then read with the new key and then it's safe? Oh, I mean, yeah, you can always, if you wanted to change the key, you can remove the old key and then insert it with the new key, that's fine. But that specific API was to get a mutable reference to a key that's still in the map and that has to be unsafe. With this have a negative impact on compiler performance. If you made the B-tree map be a const generic, it probably increases the compile time, but it shouldn't affect the runtime. Might B-tree lookup be faster than hash map lookup for smallish maps if the hash algorithm takes a long time? Yes, small B-trees can be faster than small hash maps. Sometimes you don't even wanna use a B-tree, you just wanna use a vector, it's faster just walk a single vector. But this is where you benchmark to your actual workloads. If you use many different Bs when the compiler have to generate instructions for all the different versions, that's also true. So if we had B-tree maps be const generic over B, then people would end up using multiple different Bs and different parts of their application and as a result, they would have to compile all of B-tree multiple times once for each B. Hash map is more secure, that's not true. They're the same amount of secure. The hash map has a hash denial of service resistance, but the B-tree map doesn't need that because it doesn't have to hash. If B was large enough, it would probably be worth it to switch to a binary search. Unclear, so the, well, that's true for the implementation of searching within a node, yes, if that's what you mean. When is a B-tree faster than a hash map or a vector? When you measure that it is. There's not a hard rule there. It's not like when N is this much because it depends on N, depends on the size of your CPU caches. It depends on the size of the data type that's in there. It depends on the architecture, so it's really not a straightforward answer. Okay, let's go to the very last data structure, which is the only one under miscellaneous, which is binary heap. Okay, so a binary heap is a, a binary heap is a heap which hopefully you know what a heap is, but otherwise let's do a very quick overview. So a heap is a data type. I don't know why I need to draw this, actually I don't. I might draw later, but a heap is a data type where you have two operations. You have insert, I think they call it insert. You have push, I don't like that they call it push, but fine, you have push and you have pop. And unlike a stack where, you know, the stack, you push and it lands on the top and you pop and you pop off the top with a, with a heap instead what you do is think of it as an actual heap. And when you push something onto the heap, what actually happens is you should think of the heap as actually I know how to describe this. Instead of using heap, I'm gonna describe this as a glass of water. I guess it's gonna be a square glass of water or a glass of liquids. And you'll see why I draw all these lines, but let's imagine that these are liquids. And what we're gonna do is when you push something in here, what actually happens is this thing is gonna sink down until it hits whatever its weight is. And then when you do a pop, what's gonna happen is a pop is gonna pop whatever is at the top of the glass. So the thing that is the lightest or the heaviest. Now, whether light things go to the bottom or heavy things go to the bottom or whether high value things go to the bottom or high value things go to the top is the difference between a max heap and a min heap. So in a max heap, the highest value things go to the top. So in a max heap, then greater than goes up. In a min heap, then lower values go up. So it's kind of a priority queue. It's close enough, but essentially it is a way to chuck a bunch of values into a group. And when you pull things out of the group, you're gonna get them in order. But crucially, a heap does not guarantee anything about ordering. It doesn't guarantee that you can walk them, it's not a map, it is purely a collection where the one operation you can do to get elements out is get me the max or get me the min. So that's the way to think about it is you can push however many things you want in there and when you do the pop operation, what that really means is get me the max or get me the min depending on whether it's a max heap or a min heap. In the case of the Rust and the library, the binary heap is a max heap. And what that means, so it is a priority queue, what it means is that when you do the pop operations, you see you can push a bunch of values and when you do pop, what you get back is the value that is at the top of the heap. And then you can do pop again and then you get the value that is now at the top of the heap and so on and eventually you get none. If you iterate over a heap, it gives you the elements in random order. So there's no ordering within the heap. It is just a way for you to always be able to get whatever is the max. So this peak, which peaks at the max, there's pop that removes the max and you can ask for the length. And you might go, well, why wouldn't I just use a B tree set for this? Like a B tree set gives you the same kind of properties so you can add stuff to it, but you can also pop the highest value. And the main reason is because a binary heap is more efficiently implemented than a B tree map is or a B tree set because it doesn't need to retain all this internal ordering. In fact, the way a binary heap often works, and I forget whether the standard library does, but I think it probably does, is it turns out that you can implement a binary heap and this is a really fun data search to implement. I recommend you try it if you haven't already. You can implement a binary heap using just a single vector. And it turns out that there's a really cool algorithm that lets you like subdivide the vector in such a way that the element that's at the front is always the max. And that when you remove the max, there's a sequence of operations that you do. So you do a bunch of, like let's say we remove the max, there's like a bunch of swaps that you do that are dictated by some particular part of the algorithm. And after you've done those swaps, then the thing that is now the max is at the front. But if you walked the vectors start to finish, you get the max and then you get the elements in seemingly random order. Essentially what it does is it flattens a binary tree inside a vector. It's very cool, but ultimately the only property it gives you is it gives you the max at the beginning of the vector, which is what the heap gives you. Now, one thing to be aware of for the binary heap is that if I can find it, where is my, I'll talk about the min heap in a second. Yeah, so the operations are push, which is order one roughly. The reason it's amortized is because it might need to resize the vector, grow the vector to fit the elements as I amortized. Peking is order one because it just looks at the front of the vector. And pop is log n because when you remove an item, it needs to do all these swaps so that the new max propagates to the front of the vector and that's log of n. And I wanted to see if they give you, no push. Yeah, so there's a cost to pushing one at a time. It turns out that if you append, it's a lot more efficient. There's all sorts of rules around this where you can be efficient about adding multiple items at once in the order in which you add them. This is all data structures and algorithm stuff that we don't need to get into a lot. But the specific thing I wanted to talk about was duplicates, but it's interesting that it's not documented here. Let's see, if we go to play dot rustlang and we use standard collections, binary heap, new, and then do x.push2, x.push2, while at some x.pop, print line, Z. Run. So this is the other thing that's a difference between a binary set or a btree map and using a binary heap is that the binary heap allows duplicates. It just makes sure that whatever value has the max value gets popped first, but if multiple things evaluate to have the same ordering, like if they return ordering equal to each other, then it's just gonna yield them in some arbitrary order. So it's like an unstable data structure. So here we get two twice because there are two twos that have been pushed. With a set, that would not be true. So if you push two to a set twice, it would only yield the value two once because it eliminates duplicates. And yeah, the binary heap implementation to turn into a vector is not super fancy. Like someone pointed out in the chat, it's a binary tree that's written into the array line by line, depth by depth, if you will. But it's a very cool data structure to try to implement to yourself. The reason why it's called a binary heap is because it's a binary tree represented in a vector format that happens to implement a heap. A heap is a data structure, but you can represent it using a vector, which is why it's cool. Creating a heap from a vector iterator is not asymptomatically, that's not right, it's asymptotically more efficient than a loop with push operation. So you can, if you add a bunch of elements to a heap all at once, that's way more efficient than pushing them one at a time, it turns out. Why would I ever want to use a binary heap instead of a vector? Well, for a vector, you would need to sort the vector to make sure that you have the max of the front. And the downside of a vector is if you ever wanted to insert something, the thing that you insert might turn out to now be the max. And so then you have to shift all the elements and put them left. That's not true with the binary heap representation. You only need to do log n swaps in order to get it to the front. When would I need the heap algorithm? A vec can do all that the binary heap can do. It's true, you could do this yourself with a vector, but it's actually pretty hard to implement a heap efficiently on a vector unless you just implement this data structure at which point you might use the binary heap anyway. Especially around things like, if you insert a new value into the vector, if you just have a vector and you want to iterate the values in order, then yes, you can take the vector, you sort it and you just walk it in order, totally fine. The reason why a binary heap is useful is if you are adding things to this data structure over time, then if you had a vector, maintaining the vector so that it is sorted is actually fairly expensive. Whereas with the binary heap, you can do that because the whole vector doesn't need to be in order. Only the max needs to be available and the binary heap provides an efficient way to do that. And then we could also say, if what you want is to walk all the items in order and you don't care about duplicates, then you might use a binary map because that way you can actually walk all of the elements without having to do this log n reordering every time you pop the max. Is the heap in binary heap in any way related to the heap where allocated data lives or just two unrelated uses of heaps of things? They're unrelated. So a binary heap happens to be allocated on the heap because it's backed by a vector, but there's no other relation. It's entirely just the abstract notion of the English word heap, like a heap of things, a pile of things. A binary heap is not associated with the heap. What would be an example use case for the binary heap algorithm? So using a heap like this often comes down to priority queues which is why this is, it says a priority queue implemented with a binary heap. And this comes into things like, imagine that you're writing something that you're writing some of the processes of jobs, you have a kind of, you have a I was gonna say CPU schedule or I'm trying to think of something less weird. Let's say you're writing a task manager where you have like a web service somewhere and it wants to send a bunch of emails. And so you have a thread and what that thread is doing is just looking for what is the next email I should send. It pulls it off some kind of queue and then it sends the email and then it takes the next thing from the queue sends the email and so on. Now imagine that some emails are more important than others. So some emails need to go out with like high priority. They need to go out before your whole backlog of like newsletters or something, things like password resets. So you might want to make sure that those get processed, if any are there, those get processed before all the newsletter emails. For that you can use a heap because it implements a priority queue. So you would have the ordering function for the jobs that you put in this queue order things that are password resets to be higher ordering a more closer to the max value than your newsletter ones, like a higher value for some integer, for example. And so as a result, you still take all the things that come out of your job producers and stick them in a heap. But when this thread pulls stuff out of the heap, it's gonna end up pulling the emails with higher priority first. Hence it's a priority queue. I'm scrolling up through the questions here. Then does non-nightly drain return and sorted order? No, so there's been some debate about this actually. So if you look at the iteration for binary heap, it returns them in arbitrary order. It does not return them in sorted order. And the reason for this is because you can't, or rather it would be really expensive to do so because the in-memory representation of the binary heap isn't sorted. It only guarantees that the max element is at the front. And so when you call itter, what it will actually do is walk the vector, which is effectively in arbitrary order as far as observation goes. There's intuiter sorter, and intuiter in theory could do this. Intuiter could keep giving you the max by calling pop in a loop. But then intuiter would be really slow to iterate because remember pop is a log n operation. So now you're doing n log n to iterate over all the things in the heap. Whereas intuiter sorted is a nightly only API that's going to yield the items from the iterator in max order. The question was about drain, and I think drain is also arbitrary order, yeah. So there's no, I don't think it on nightly either it returns them in max order. Yeah, so when I say priority queue, there's also sometimes called a partial sort or a partial ordering where the newsletter emails are maybe not sorted in relation to each other. Like it doesn't matter which one you send first because they all, it's one instance of the newsletter. So maybe it's sorted by like when the date of the newsletter, but within them, like whether you send to Alice or Bob first doesn't really matter. But it matters to you send the password reset so that category of emails before you send the newsletter emails. So it's a partial ordering in the sense that there's no ordering within the newsletters but there's an ordering across newsletters and other types of emails. Heapsort of course is the other case in which you could use a binary heap because that's what a heapsort is. Is it quicker than a radix sort of the list? So a radix sort is a very fast sorting algorithm that you can use when all of your values are integers. So if you have a vector, you could do a radix sort on the vector really efficiently and that way you have them sorted and now you can iterate over them in order. The question still becomes maintaining that over time. It could be that you can just like insert the new, if you wanted to do a push into the vector, you push at the end so you don't shift all the elements and then you run a new radix sort. That would probably be more efficient but it only works if your values are only integers and it only works because radix sort is so damn effective. Do you think there's any way we can make a min heap more ergonomic? It's very annoying to have to re-implement. Okay, so this is the last thing I was gonna talk about with binary heaps, which is this is a max heap but what if what you want is a min heap? You want the things with the lowest value to come out first, like lower numbered priority, like priority zero is most important. Well, the way that you do this is either you change the order implementation for your data type so that it returns things in the opposite order or there's an example for this here, min heap, there's a type in the standard library called standard compare reverse and it is a, it's a single value tuple type that just holds a T and the only thing this does is it reverses the implementation of ORD of the inner type. So the implementation of ORD for this type is, so normally compare is self compared with other and what it will do is do other compared with self. That is the only thing this type does. And so what you can do if you want a min heap is just before you stick your value into the heap, you wrap it in the standard compare reverse which is like rep or transparent and everything. And then when you pull it out, you take out the inner value from the reverse to get back the original value. As you see here, they import reverse here, they create a binary heap, they push reverse of one, reverse of five, reverse of two and now when you pop them, you get the smallest values out first and you can do like dot zero to extract the original value you put in there. So that's the way that you would get this max heap implementation just be a min heap is you just reverse the meaning of max and min. Okay, is there anything else I wanna talk about for binary heap? I don't think so. All right, so we're at the two and a half hour mark. So I'm gonna stop there. Here's what I'm gonna say though. So I tease this a little bit in the tweet. There are two types that some may call collections and it's option and result. So option is really a vector of capacity one. It's one way you can think about option and result is a map from Boolean to a bi-genius type. It's a single element map from a Boolean to a bi-genius type, which is really weird. I don't recommend thinking about them in collections in this way but they kind of are collections. Like if you look at them, they have operations like, where is my, they implement things like into iterator. They implement from iterator. So they kind of are collections. They're just a really weird kind of collection. Some might call them monads but I'm not gonna get into that debate. Now the other way to think about option and result is that they are ways to, they are collections that are specialized. And the reason why people often call them collections without thinking about the M word is because they think of the iterator trait. And in particular, the iterator trait has a method called collect. And when you collect, what you do is you take an iterative items and you collect them into a collection, that is the result of that iteration and collection. And you can collect into vectors, you can collect into binary heaps, you can collect into hash maps, whatever you might want. But you can also collect into options and you can collect into results. And so as a result, people might think, well, that means result is a collection, option is a correction. In practice though, that's not quite true. Like if you really dig into it and you look at the from iterator implementation for option, you'll see that it's not from iterator of here. It is not an implementation of from iterator where the iterator yields type T and it collects into an option T. It is an iterator where the items are options. And what it yields is the first thing that is, it yields none if anything is none. And otherwise it collects into an inner type that it's itself from iterator. That's confusing. Let me see if I can do that better. So if you do zero to 10 dot, do I still need to do this? I forget, dot collect. This would not work. This is not a thing you can do. This doesn't work. Value of type option usage cannot be built from iterator over elements of type integer. If I do that, then it works just fine. And so this is an argument that option is not a collection. What does work though, is this. Actually, let me, yeah, so I can do this. And this is really saying that option is a functor. It's really saying that option can sort of map through a collection for into iterator. Result is the same way. Where if you have an iterator over things that are option, you can produce an option over some other collection from the iterators. And what this wrapping does is it means that if any of these things are none, then this is gonna produce none. It's not gonna produce a vector at all. But if all of them are some, then it's gonna produce a vector of the inner values. It's sort of like an unwrap or none kind of a deal. Where if any of the iterator's items are none, or if you think of result, which is the more obvious one, if any of the iterated values are error, then the thing we collect into is gonna be an error rather than an okay. So if I do result here and I do this, what this is saying is if all of the items yielded are okays, which they are here, then get an okay vector of those values. But if any of them are error, so if I do here chain standard iter ones, so here this is gonna yield 10 okay values and then an error value, then this collect will not produce an okay vector, it will return with just error of this. And so the result here is not a collection, but it can wrap a collection and an iterator. So it's not really a collection itself. Oh yeah, it's more of an applicative than a functor, I guess. I'm gonna ignore trying to dive too deep into it because I think it's just gonna be confusing. So one of the ways that you can see this is like result and option, do not implement a lot of the other things that we expect from collections like extend, right? Because they're not really collections in that way, but they feel a lot like collections and that's because they share some properties with collections and I will not name the kinds of properties they share. My face is blocking the code. Oh, I'm sorry for the chain, there you go. Okay, I think that's all I wanted to talk about today. Yeah, applicative is an applicative functor, that's what I thought. Functor is basically right, but it happens to also be applicative. Okay, I think that's all I wanted to talk about today. Is there anything else that you all are curious about for the standard library collections before we end for today? Yo, is that a monod from the category of endofunctors? It might just be. Oh yeah, sure. This is the worst kind of collection. So the unit type is a collection, unlike option and result. The unit type implements extend and it implements from iterator. And you might think this is stupid. Like why is it that you can collect into unit to discard all of the values? And the reason why you can do this is because it turns out to be useful if what you're looking for is errors. So actually we can use the example that's right here. So you have a vector, you wanna write all of those values out into standard out and the right ln macro returns a result that is either the number of bytes that were written or error. And let's say that I wanna do all these writes and I want to know whether it failed or not. So what I get, but I don't actually care about the okay values. I don't care about the number of bytes written that's not relevant to me. All I care about these are the errors. So I don't actually wanna collect like a vector of the bytes that were written or anything like that. And so I can collect into a result where the okay type is unit. And what that will do is it'll discard all the okay values but still produce an okay value if all the iterated items were okay. And if any of the iterated items were error then this will produce an error which I can then inspect. And so that's why it's actually useful for the unit type to be a collection that just discards all these inputs. So it's an always empty collection. I didn't talk about plane arrays because they are collections but they're not, they're not the kind of collection I wanna talk about today. Let's see, I'm curious how efficient very small hash maps are. Is it very much frowned upon if you create and drop a bunch of tiny hash maps quite often? Tiny hash maps are, it really depends for tiny hash maps it really depends on how many lookups you're gonna do into them. Because hash maps are a little bit, they're a little wasteful if you have a very small number of items because very often it's just as fast to like search a small vector than it is to do the hashing and lookup in a hash map that's small. It's not, they're not that expensive to construct but I think allocating one is really just allocating a vector. So that's not gonna cost you too much but it's unclear that the runtime performance is gonna be worthwhile compared to just having a vector you search. What if there's more than one error? Collect will take or collecting into result will take the first error and then stop taking from the iterator. And the other reason why the, actually I'm not gonna talk about that it's not worthwhile. Can you point to some other cool collection crates that are useful to know about? The main ones are things around concurrency, right? If you want something like a concurrent hash map then you could do something like a dash map or flurry which we did a previous stream on or you could do things like look at the crossbeam crate which has a bunch of these things. So that's like the main other set of collections and then you often find data structures that are optimized for very particular use cases. I mean, EV map is an example of this where you have a hash map that's optimized for concurrency where you have read heavy operations. Trying to think what else is like common data, so you have hash brown, you have index map. So index map is a pretty common one. So a hash map when you do inserts the yield items in arbitrary order if you iterate. Index map is a little different. When you insert something it's still a hash map behind the scenes but it also remembers the order in which the keys appeared. So when you iterate over an index map which you can use interchangeably with a hash map when you iterate over an index map you get the keys back in insertion order which is sometimes really useful. Oh yeah, there's also just the category over on crates.io. There's a category called data structures. Data structures, 2,800 crates. If you look at recent downloads you'll see there's a hash brown we talked about, small vec we talked about, index map we talked about. Sembrer brown clear if it's a data structure either we can mostly ignore. Bytes which is more about strings. Cross beam as I mentioned. Generic arrays which is really the same as small vec kind of. Slab which is an allocator which we can ignore. A hash which we talked about but isn't really a data structure. Tiny vec which is like small vec. So those are really the, the pet graph is like a very different kind of data structure for storing graphs which we're not going to talk about here. There are many ways to do it and pet graph has multiple ones implemented. Bit sets are kind of interesting. Perfect hash maps. So this is for, if you know all of your keys at compile time you can generate an optimized hashing function for them so that you get a perfect hash map. One where every key maps to exactly one bucket. You don't need to do any probing in anything. So that can be really good if you have a data structure or a data set that happens to work for this. And you know you could argue that the NPSC channels in the standard library are also collections, right? They're a queue, that's exactly what they are. Although there now I think the implementation has been swapped with the NPSC queue from cross beam and there are a bunch of different other implementations of concurrent queues. We're not going to go into those too much. Okay, I think that's the end of what I wanted to talk about. Next stream, I don't know when it's gonna be probably not for a little while. I'm hoping that I'll get to keep to the schedule but I'm moving to Norway in the start of June and so I'll be in the middle of moving so we'll see whether I can do another stream by then or whether the next stream will be in Norway which will be exciting. And I may have to change the time of the streams a little bit. But thank you all for coming out. I hope you found out this was useful and this will be on YouTube of course or you might already be watching it on YouTube by the time you see me seeing this. All right, thank you folks.