CppCon 2017: Bob Steagall “How to Write a Custom Allocator”

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
Welcome, everybody, to the 237th talk on allocators at CppCon 2017. (audience laughs) My name's Bob Steagall. I'm here on my own dime representing myself, and I'd like to talk a little bit about how to write a custom allocator. I think you'll find by the end of the talk, it's really more along the lines of guidelines for how to write a custom allocator. There's so many different things that you can do. It's hard to prescribe anything except what I think are some useful principles. I was walking back to my hotel a couple of evenings ago, and I happened to be walking along with John McFarlane, who gave a talk a couple of days ago. And he made probably the most cogent and profound observation about allocators, I think that I have ever heard. We've all heard heroic tales of other people doing this. (audience laughs) That being said, what I'd like to do is provide a little bit of useful background in understanding the modern allocator facilities. So, I'll try to get through the background stuff pretty quickly. And also, I should say I've got a lot of slides. The slides are numbered, so if you have questions, I'd ask you to please hold until the end, unless I've done something really super egregiously wrong. So, what is an allocator and why would you wanna write your own? I think I'll try to address that a little bit. I'd like to provide a little bit of background, go under the surface a little bit to understand the original allocator facilities. I'm going to spend a fair bit of time trying to describe the modern allocator facilities at a fairly shallow level. I think in order to understand that the principles on the last couple of slides about how to write an allocator, it's necessary to understand a little bit of how the allocator facilities themselves work. And I say facilities because there's no longer simply an allocator class. There's a whole set of classes that work together to provide allocation services to the new standard containers and other things. I'll spend a few slides talking about the modern allocators from a container's perspective. As you write your allocator, I think it's useful to understand how a container will use your allocator. It's much more complex than it used to be before C++11. And as I said, I'd like to end with a few guidelines. I'd originally wanted to give the talk over two sessions, but there wasn't enough time in the program. So, I'm gonna end on guidelines instead of a lot of examples. So, let's talk about what an allocator is, or what the purpose of an allocator is. In the 4th edition of the C++ programming language, Bjarne Stroustrup says that the basic purpose is to provide a source of memory for some given type, and a place to return that memory to once it's no longer needed. Earlier in the week, I'm stealing a quote from Alisdair here, an allocator is a service that grants exclusive use of a region of memory to a client. And the corollary to this that I didn't put on the slide is that hopefully the client will be good and give that memory back at some point. So, what's a standard library allocator? Well, it's a kind of object that used by the standard library to manage memory. Usually, we see it as an implementation detail of the standard containers, as a template parameter. It's that thing off to the right that's got a default argument that nobody ever really looks at or messes with, and just kind of pretends it doesn't exist, because it can be painful if you don't pretend. Allocators were invented by Stepanov many years ago as part of the original STL. It was partly an attempt to solve the problem of different sized pointer types on DOS, which was the dominant operating system of the time. There was this funny 16-bit addressing model with segments and offsets, but you could address more than 16 bits of memory. And consequently, there were pointers that had different sizes. And there was a desire to abstract those weird things away from the compilers, or the containers. They didn't need to think about that or be aware of it. After some review a long time ago by the committee long ago, there was also a desire to make the library itself more flexible and independent of these underlying pointer types. I think also there was a recognition that containers have special needs. They need an interface that's more granular, a finer level of control than the new and delete operators. They want to perform allocation, construction, destruction, and deallocation as four separate and distinct things. And this is what the service that allocators provide. Most importantly perhaps, they separate allocation from construction, which is different than the new operator. And they separate destruction from deallocation, which is different than the way the delete operator works. They encapsulate information about the allocation strategy. And by that, I mean the algorithms by which the allocator carves up blocks of memory into chunks, and hands those chunks to its client. The containers don't need to know what that is. They encapsulate information about the addressing model. I just mentioned one possible addressing model, that of near and far pointers under DOS. As it turns out in Modern C++, you can have addressing models besides that which is provided by native pointers. And of course, it goes without saying that they hide memory management and address model details from the containers. The containers don't need to know about these things. They just have a very simple interface that they use to get the memory that they need to construct objects, destroy objects, and give the memory back. And if we're lucky, a well-written allocator will support reuse and you can use it across container types, if that's suitable. So, why would you wanna write your own allocator? It seems like a lot of work, and actually it is. Well, the reason that's most often given is you want to get higher performance in some way. You might desire to implement some sort of stack-based allocator. You have a container, you don't wanna go to the heap. Perhaps you only need a fixed limited amount of memory and you know you're never gonna need more than that. Maybe you want to somehow exploit locality of reference with data that's also on the stack. You may want to have a heap, whether it's on the stack or not, that is dedicated exclusively to a particular container. I'm never gonna share elements with anybody else. I'm never gonna swap elements with anybody else. Maybe you're doing some sort of embedded thing, and you've got a linked list of stuff, and you're never gonna share it, or delete it, or move it, or swap it. So, maybe that's something that makes sense for your application. You might want to have per-thread allocation. Perhaps you're doing something where your memory that you allocate is only gonna be used by containers in their respective threads. And memory that's allocated in one thread is never gonna be deallocated in another thread. Or maybe you wanna write an allocator that doesn't have the overhead of locking and unlocking like you would with a multi-threaded allocator. You might wanna do some sort of pooled or slab-based allocation strategy, which might be appropriate if your allocation pattern is one in which you have a set of sizes. All of the chunks that you allocate fall into one of a small set of sizes. And you can have pools that allocate from a certain size, or slabs that allocate from a certain size. Perhaps you think that you can get some better performance that way. You could try to implement an arena allocation strategy, where you are building up some data, some data structures, and you're gonna allocate everything from an arena and everything that points to everything else exists inside of that arena, everything is constructed inside of that arena. Maybe there are no system resources here that you have to worry about giving back to the OS. And when you're done, to destroy everything, you just flush the arena. You don't have to destroy the elements. You just let the arena go and everything winks out. Maybe, which could be potentially more practical, you wanna write a debug allocator. You have some memory allocation thing that you're trying to debug. Or you want to instrument your allocation. You want to measure how many allocations of what size you're doing. Or perhaps you have a test allocator where you want to simulate some sort of error conditions that can only occur when you're trying to allocate something. And closer to my heart, I'm interested in the idea of relocatable data. Building data structures in such a way that they are address independent. For example, building data structures in shared memories such that those data structures can be correctly accessed by different processes in which the segments of shared memory are mapped to different addresses in those processes. Or you might try to do something, what I call a self-contained heap, where you build a structure in a sequence of bytes, and you wanna do a socket send of those bytes to somebody else. So, when they get those bytes, they wanna be able to use it without any sort of fancy deserialization. It so happens I have a couple of pictures of this, which I'm proud that I made and I'm gonna reuse very quickly. Shared memory data structures. Imagine you have two processes, P1 and P2, and I've got some segments in shared memory, S1 through 32. And inside those shared memory segments, I'm making something here which is supposed to look like a binary tree, with the root being in S1 there. In process P1, the operating system has mapped all of those segments into a set of addresses. In process P2, each of those segments has been mapped into a different address. And yet, I wanna be able to use that tree in both processes without any special serialization or deserialization. I want to abstract that addressing model and hide it in a fancy pointer type. And I want my containers just to work without needing to know about it. A second application that is of the self-contained heap or self-contained DOM. Imagine for a second this is represent some document object model. Maybe it's a very simple thing that I've stuck in a red-black tree. Maybe it's some strings or something. Well, I can imagine creating a data structure where on the left there, you've got three things which comprise the essence of my tree structure, and they are placement constructed into the beginning of some buffer, and there's a heap behind, a tail buffer so to speak. My allocator points into that buffer, and I have pointers inside the data structure which point to my left-most node and my root. I would like to be able to traverse that tree, no matter what address this buffer lies at. I should be able to take buffer, memcpy it to a totally different address and use that tree. With Modern C++ allocators and the facilities that support fancy pointers, or synthetic pointers, or pointer-like types, whatever they're called, you can do this. And I've given a couple of talks on that. This is not the subject of the talk today. So, I wanna give a very quick history of allocators, because I think there's some important background here. So, let's take a quick look back in time. The old picture was very simple, containers were parameterized in term of an allocator. And they used the services of that allocator directly. The containers were free to assume in C++98 and 03 that a pointer was a T star. A const pointer was a T const star. A void pointer was a void star, and so on and so forth down the list. They were also free to assume that any two instances of a given allocator template compared equal. They were also free to assume that any two instances of an allocator were interchangeable. And by that, what I mean is the little code snippet you see on the last line. In old school allocators, you could allocate some bytes from allocator instance b and deallocate them in allocator instance b. In fact, allocator a and b didn't even need to be of the same type. You could have an allocator of int that you could allocate some bytes from, and you could deallocate it with an allocator of double. So, the old allocator was actually a pretty simple beast, very straightforward. There was a set of typedefs at the top which provided definitions that were used by the containers. The attempt to abstract these details that I talked about before. There was this funny thing called a rebinder, and I'll come back to that in a couple of minutes. There were four important functions. There were a few other functions, but I'm concentrating on the important ones here. Allocate and deallocate, construct and destroy, which performed the four services that I talked about before, allocation, construction, destruction, and deallocation. And finally, there were the comparison operators. So, allocation and deallocation are almost trivial. The standard required them, or said that they should occur by using operator new for allocation, and global operator delete for deallocation. Construction and destruction services are likewise very simple. They use placement new for construction, and directly invoke an object's destructor for destruction. An interesting thing to notice here, and this is part of the standard. I copied this stuff out of an old copy of the standard. Is that these member functions actually work in terms of this typedef pointer, p. Now, point the type pointer is a T star, but they chose to use the typedef here in the signature of the member function. I've never quite understood why they did that because pointer was required to be a T star. Finally, comparisons under this model are trivial. All instances of the old default allocator, and even the new default allocator, are required to always compare equal for all instances. Let's go back for a minute to this strange rebind thing. What does it do? Its purpose is to transform an allocator specialization of one type, one template argument, into an allocator specialization of a different template argument. So, before we had template type aliases, the way we did this was by defining a small struct that had a member that we would use to do that type transformation. So, here's a quick example of what that would look like. Suppose that we were implementing a list. Well, the template signature of list is it's parameterized by the element type T and an allocator of T. But in a linked list, you don't allocate Ts, you allocate list nodes of T. I never need to allocate a T, I need to allocate a list node. So, what the rebinder did was provide a way to transform the type, allocator of T, into allocator of list node of T. And so, in the highlighted text along the top there, you can see the egregious syntax that's required to make that happen. So, you could then create this new allocator type from that, extract the type of the node, the pointer to the node, which is what you wanted, and then maybe you would have some member function that allocated a node and constructed an element into that node as part of the allocation. And that's reflected by the little function that you see at the bottom. So, there was some consequences resulting from these allocator requirements. Because pointers were always assumed to be T star, that meant that there was no support for fancy pointers, or pointer-like types, or anything that could address shared memory or create a self-contained heap. Because implementations assume that instances were always equal and interchangeable, that meant your containers could not be used with stateful allocators. If you had an allocator that needed to maintain some state, like the thread id that I'm operating in, or the container to which I belong, you couldn't do that. So, there were a lot of interesting problems that you couldn't solve with those allocators. And also, in a corollary problem, scoped allocation was tricky. Consider the case where you wanted to create a nested hierarchy of stuff. Perhaps you wanna create a map that maps strings to vector of list of strings. Well, it's kind of tricky to set all of the templates up, all of the allocator types up so that everything allocates from the same allocator, from the same instance of the same allocator. And perhaps you would wanna do that because you managed to create some sort of stateful arena allocator. So, it was kind of tricky to make that work correctly in the old standard, although it was not impossible. So, I'd like to wade ankle deep into the new things. So, to a large extent, this talk is about plumbing. As an allocator author, you need to know how to connect the plumbing from your allocator to the plumbing that expected by the standard containers. Now, when you first dig in to the new standard allocation, it kind of looks like the picture on the left. That's what I felt when I first started learning about it. But after a while, you come to realize it's a little more orderly than that, and it's really closer to the picture on the right. There is method to the madness underneath the surface. So, with C++11, there are a lot of changes in the standard to address the shortcomings of the old school allocators. And this is a non-exhaustive list of different places in the standard that requirements were added that provide these new capabilities. And they're not all in one place, they're spread all over the place. And the numbers keep changing. So, what I've done, if you wanna look these things up, is provide the tags by which these requirements are found in the standard. They're the bits that are italicized. So, we have things that define what a nullablepointer is, the basis of a pointer-like type. We have a requirement that defines what an allocator is conceptually and its relationship to the allocator traits. We have a thing that describes something called pointer traits, which is used by allocator traits that we'll come to in a few minutes. We have allocator traits, probably the star of the whole show, which describes a uniform interface to allocator types that are used by the containers. We have this thing called a scoped allocator adapter that's described in the allocator.adaptor section, which helps setting up those nested hierarchies of standard containers, and supports a deep propagation of allocators. And then we have another very important section, the general container requirements, which defines what an allocator-aware container is. And this allocator awareness is the key, in my mind, one of the most important if not the most important difference between allocators in Modern C++ and containers in Modern C++, and the allocators and containers in old school C++. So, this is a picture of the model that I have in my head. I don't claim that it's correct, but it's the way I visualize thing. And this is sort of a bastardization of UML, the dotted arrow means the word, uses. So, working upward on the diagram, the standard containers now get all of their information about memory from the allocator traits template. The allocator traits template is the interface, the wrapper to any kind of allocator that you might wanna use. Now, the allocator traits template builds a picture of the storage services that are provided by the allocator itself, and it also uses services of the pointer traits to transform information that it gets from the allocator into other pieces of information that it finds useful. It presents this picture to the standard containers through its public interface. And we'll see what that public interface is in a couple of minutes. So, the allocator, this is the piece that you write, this is the thing that provides, at a minimum, allocation-deallocation services. There's a very small number of things that a minimal allocator has to provide in order to work with a standard container. There is a larger number of things that you can override to customize its behavior. And as I said earlier, the pointer traits template is used to transform information received from the allocator for the purposes of allocator traits. As you can guess, it's a way of transforming type information about pointers that's provided by the allocator to pointers that the container might need. For example, a pointer of T star to a pointer of list node of T star. So, you heard me mention allocator awareness a couple of moments ago, and what does that mean? Well, pedantically speaking, it means fulfilling the requirements that you'll find in Table 86 of the recent draft standard. And I think the standard that will be adopted here, or has been voted in and we'll see published soon. There's a large number of these requirements, I'm not gonna reproduce them here. I'm just gonna say, "If you're really interested, "go look at this table." But in general, allocator awareness means doing something that makes sense when a container uses an allocator that is not the default allocator. And in the case of Modern C++, this means being able to support synthetic pointers for addressing operations, having nontraditional addressing models, having allocators that can contain state, having allocators that can be non-equal and non-interchangeable, or allocators that can have non-trivial copy, move, and swap semantics. The old school allocator was completely stateless. As I said, every instance of that allocator was in a sense equal to every other instance. You don't have to do that anymore. You have a lot more power at your fingertips. So, I mentioned on this slide the word, propagate, having to do with copy, move, and swap semantics. So now, I'd like to talk a little bit about quickly what propagation means, allocator propagation. Say that three times fast. So, there are two kinds of propagation, lateral propagation and deep propagation. At least that's the terminology that's in common usage. So, lateral propagation refers to what happens to a container's allocator during the process of copy construction, move construction, copy assignment, move assignment, or swap. Deep propagation refers to the process of nesting the allocator type and instance of the outermost container in a container hierarchy down into the innermost container. Consider again the previous example of a map that map strings to vector of list of strings. And you wanna pass the same allocator instance down to all of those guys so they can allocate from the same arena. The scoped allocator adapter was designed to help with exactly this situation. I'm not gonna say much more about that because it's not really super relevant to this conversation today. So, the focus today is going to be on lateral propagation. So, of course, there are consequences. For library implementers, they really had their work cut out for 'em. Containers now had to be rewritten to perform all of their operations using the allocator traits template. They had to support non-equal allocators. Over the years, they started to support non-equal allocators but now they're required to. They had to support lateral propagation in copy, move, and swap operations. And they also included a new set of constructors called the allocator extended constructors to support deep propagation. Again, I'm not gonna really mention those today. On a lighter note, containers no longer used these nested typedefs called reference or const reference that you saw in the old school default allocator. Allocator traits construct uses perfect forwarding, avoiding a possible inefficiency that occurred with the old school construct of the old default allocator. And interestingly enough, I pointed this out before, construct and destroy as part of allocator traits now have T star in their signature instead of the pointer type alias. So, if you actually, if you have a if you have a pointer, some synthetic pointer to some type, you have to provide a conversion operator to the native pointer of that type if you wanna use allocator traits construct to construct an instance using that pointer. For library users, life got easier. If somebody has a library with a stateful allocator in it, and you think it works, it should be pretty easy to use. It's relatively safe and straightforward now to do scoped allocation with nested container hierarchies. Some mutating container operations may lose their noexcept guarantee, depending on the implementation. For allocator implementers, it was kind of a mixed bag. Part of it was easier and part of it's more difficult. Certainly, some of the verbiage was removed. There's no need to implement construct and destroy, or have reference or const reference typedefs. And really, the work that you need to do is in thinking about what properties and services that you wanna specify, or that you wanna override the ones that are provided by allocator traits. And that's actually the hard work, deciding for my new allocator, how am I gonna specify copy, move, or swap operations if it's stateful? How am I gonna represent pointers if I wanna use some sort of nontraditional memory addressing model? So, let's take a quick partial look at what the containers expect. Here's a phony container which has a signature that's at the top of the container that's kind of similar to most of the containers. There's a typedef for value type. There's value type, which is required. There's a typedef called allocator type. There's a typedef now for allocator traits, which is a specialization of allocator traits in terms of the allocator type. Then there are these typedefs for pointer, const pointer, size type, and difference type. And now, these are expressed in terms of the equivalent type from allocator traits. Allocator traits is providing information to the container, which the container is going to use internally. And down at the bottom, the containers still use these typedefs reference and const reference, but they don't get them from the allocator anymore, they implement them themselves. So now, let's jump knee deep into allocator traits. As I said, this is sort of the wiring that holds it all together. And I'd like to talk about the important public parts of the allocator traits interface. As I said, it provides a uniform interface to allocators that's used by the containers. It attempts to get information from an allocator and normalize it in a way that the container can use. If there is some bit of information that is required by a container that's not provided by your allocator, the allocator traits will very cleverly pick what it thinks is a reasonable default for it. It supplies a lot of boilerplate that had to be part of the old school allocators, and it's also backwardly compatible with those allocators. It allows you to customize behavior by creating non-default properties and functions. So, let's look at the top half of the traits right now. You can see that several of these things are here. By the way, I should say what you're gonna see for the next few slides is not real C++, it's sort of a C++-ish pseudocode to convey the example. A lot of these things that happen underneath the surface are very scary metaprogramming and they will take a talk unto themselves, a talk that Alisdair gave three years ago on how to make allocators work. So, from the perspective of the container, the things that required by your allocator, your allocator has to provide value type as a nested typedef. Then there's a bunch of things that are inferred. And by inferred, if your allocator does not provide values for these things, or types for these things, these are things that allocator traits will make something up for you. The bottom half that I wanna show here has to do with the services that are provided. Now, we've got allocate and deallocate again, there's a second allocate overload. There are functions here, construct and destroy, as before. You'll notice that construct uses a template parameter pack and perfect forwarding so that the case of the default allocation doesn't, the case of constructing a vector of default constructed elements is maximally efficient. One thing I wanna point out that's a part of propagation is this weird function at the bottom of the traits called select on container copy construction. And we'll get to what that is in a couple of minutes. So, for the various traits types, the types that are inferred, how does this happen? Well, what I've tried to create here is effectively for each of these traits that the allocator traits expresses, I've tried to represent the logic, or the priority, the set of priorities that the allocator traits uses when deciding what these things ought to be. So, for example, let's look at pointer at the top. If your allocator alloc provides a pointer typedef, it will be used. Otherwise, allocator traits will make the pointer its pointer typedef be value type star. Likewise, for void pointer, if your allocator provides a typedef for void pointer, that's what will be used. Otherwise, allocator traits will use the rebinder from pointer traits to find out what is the correct void pointer type that goes with the type pointer that in which your, the type pointer that goes with value type. And I'm not gonna go over all of these, but hopefully, that will be clear what I mean. Likewise, there are these four nested typedefs having to do with propagation, or three having to do with propagation and one having to do with comparison. And again, it's the same sort of thing here. If an allocator provides a typedef for one of these, which has to be either std false type or std true type, that will be used. Otherwise, the default value for these propagation traits is to be false. In other words, the default action for an allocator is to not propagate on copy assignment, or move assignment, or swap. For is always equal, this is a trait which lets the container know whether two instance of an allocator type are always equal. If it knows at compile time that it's always equal, there are some optimizations that it can do in its code. Otherwise, allocator traits looks to see, is this allocator an empty object? And if it's an empty object, it assumes that they are always equal. - [Alisdair] Bob. Yes. - [Alisdair] I believe those are actually values rather than types when you put the example up there. Otherwise it's-- Did I get that wrong? - [Alisdair] I think so. Okay. - [Man] No, it's type. - [Alisdair] It conveys the same information. - [Man] It's always equal as a type. Okay. - [Man] False type and true type. There are false type and true type, right? Okay. The allocator traits also defines this thing, another rebinder, a thing to rebind allocators, and it looks to see if there is a rebinder that's specified by the allocator. Otherwise, if your allocator is a template which has an element type as its first template argument, and some zero more arguments, template arguments afterwards, it will use that. It will use that as the rebound type. And if that doesn't exist, or is not the case, then it's an ill-formed nested type. And then there are another rebinder which rebinds the allocator traits themselves. In terms of the services, allocator traits will define the first load of allocate and also deallocate. And it defines these in terms of calling the corresponding allocate and deallocate functions of your allocator. Those are member functions that your allocator's required to provide. The second overload of allocate is chosen at compile time. If your allocator provides the second overload, it will be used, otherwise, the default will be to use the first overload. Likewise, with construct, if your allocator provides a construct member function, it will be used. Otherwise, placement new will be used. With destroy, if your allocator provides a destroy member function, that will be used. Otherwise, the object type will be destroyed directly by invoking its destructor. There is similar logic for max size, which is not really interesting. But the select on container copy construction is kind of interesting. This is a mechanism by which an allocator at one time tells a container at copy construction time whether or not it should make a copy of an allocator. And we'll see an example of how that's used. The logic is to look and see, does your allocator define it's member function? If not, it's just gonna return the argument. Now, I'd like to take a couple of minutes and look at the pointer traits helper. So, what does it do? Well, it describes the properties of pointers and pointer-like types. It also provides the types of differences between pointers. If you have a non-standard pointer which is being used to implement iterators, and you want your iterator differences to work correctly, that depends on taking the difference of the pointers upon which those iterators might be based. So, the idea is to, besides pointer differences, is to provide the pointed-to type. So, given the type T star, give me the type T. Given the type fancy pointer of T, give me the type T. So, given some pointer type, give me the type to which that thing points. They're also used to provide a transformation from one pointer type to another pointer type. They will have a rebinder, as well. Given the type T star, get me the type U star. Given the type fancy pointer of T, give me fancy pointer of U. And T and U don't have to be different types. They can be cv-qualified differences of the same type. And I'd like to say just a couple of words about pointer-like types, what the standard calls pointer-like types. I call them fancy pointers or synthetic pointers. They're only mentioned four times in the recent draft standard. And the only real substance behind them is in the requirements for NullablePointer. And you can see those requirements in tabular form in Table 28. A NullablePointer is something that must satisfy several common requirements, EqualityComparable, DefaultConstructible, CopyConstructible, CopyAssignable, and Destructible. They have to have swappable lvalues. I have to be able to swap two of these things. If I default initialize it, I'm allowed to create an indeterminate result. Using that may result in undefined behavior. If I value initialize it, I'm required to create a value that represents a null result. Now, in standard, this idea of null result is not really defined. Construction and assignment from a null pointer must also produce a null result. And I will say I don't have it on the slide there, that if a pointer-like type contains a null result, then comparing it directly with the nullptr must return true. So, in effect it represents nullptr, but it doesn't have to exactly have to be nullptr. These types have to be contextually convertible to bool, so you can use typical if p syntax. And there are a set of fundamental operations, which I'm not gonna list here, but which are in this Table 28, in which a synthetic pointer may not throw exceptions. So, let's go back and look quickly at pointer traits. This is a little bit of text which I've cribbed from the standard. These are relatively simple traits. There is a primary template which is undefined, at least in the standard. There is a partial specialization for T star. And so, given this partial specialization, there's a type pointer, which is T star. It's easy to get the element type, which is T. The default difference type for two T stars is ptrdiff t. There's a rebinder, which here unlike the old school allocators is parameterized typedef that allows one to take a T star and from it get a U star. And there's this function that's required that takes a reference to the element type T and turns it into a pointer. In this case, a T star, but something which is this type pointer. And the default example in the standard is to use std addressof. Now, it's also possible that you wanna have some sort of synthetic or fancy pointer type. And so, confirming implementations also provides this other partial specialization. So here, the nested type pointer is actually equal to the type of the template argument. And I'm sort of trying to show the same thing here, the sequence of steps that this template takes to deduce what the typedef ought to be. So, for example, element type. If your typeptr defines a nested type element type, that's it. If ptr is a class template which has at least one argument, the type of that first argument is the type of the element. If neither of those things happen, then a specialization or instantiation of this is ill-formed. Likewise, with difference type, it looks to see is there a nested difference type in your pointer type, if not, it's a prtdiff t. And it also looks to see does your pointer type have a rebinder, and if it does, it uses that. Otherwise, it looks to see is your pointer type a template, and it rebinds it by creating a pointer type specialization with a different first argument. There is likewise the pointer to function which takes a reference. And the idea here is to turn it into a pointer of your synthetic pointer type. This may or may not actually be well-defined at runtime. If you could, for example, have a set of synthetic pointers who only have meaning when pointing to things inside a relatively small buffer. And that buffer may be very far away from your stack. And if you have something that's on your stack, and you use this function to get a pointer to that thing on the stack, which is very far away and out of range for that buffer over which that pointer has meaning, that pointer will not have meaning. And probably computed at one time, but it's probably an error. So, we've looked at allocator traits and pointer traits, so let's look at allocators themselves. Here's an example taken from the text of the standard itself of a minimal allocator interface. This is the minimum amount of work you have to do to create a standard conforming allocator. You have to provide a nested value type alias. You provide a constructor which may take some parameters. You have to have a converting constructor from allocators of a different type. You have to provide the allocate and deallocate member functions. And you'll also have to provide compares equal and compares not equal operators. Very straightforward, very simple to use. This is a stateless allocator, there's no member data. Any specialization can be constructed from any other specialization. Now, notice that if we think about this in terms, what is allocator traits gonna think about this? Allocator traits is gonna be parameterized in terms of this, and it's gonna have to return some type information to the container. Well, we're interested in the propagation traits. And the propagation traits are by default all false. So, when this thing is parameterized or specialized by allocator traits, and that's presented to the container, the container is going to think that this does not propagate on copy assignment. It does not propagate on move assignment. It does not propagate on swap. And because it's empty though, the allocator traits is going to believe that all instances of this thing compare equal. The default allocator is slightly more complex. It's basically the same idea. The real differences here are the fact that there is a default constructor, and that the propagate on container move assignment and is always equal nested typedefs are actually defined here. The is always equal is defined true type, I think to ensure that it's deliberately specified that all instances of this allocator is true. And propagate on move assignment is true, so that containers can maintain the no except property on move assignment. Likewise with the old school allocator, the implementation is quite straightforward. Allocate calls global operator new, deallocate calls global operator delete. Notice I don't have construct and destroy functions because those are provided by allocator traits. Likewise, the equals and not equals comparison operators return true and false respectively, indicating that all instances of this type always compare equal. That's not the whole story. There's a new kid in town. Starting with C++17 as part of the std pmr namespace, there are now these new things called polymorphic memory resources, and I personally think that this is a good idea. They provide the ability to tailor the allocation behavior of a container at runtime through dynamic polymorphism, rather than at compile time through static polymorphism with different template types. The way they work, very simply is that client allocator... the client allocator types stores a pointer to a base class memory resource. And to actually create a memory resource, something that allocates memory, you derive your new type from this base class and you implement a very small number of member functions. In this particular case, there is no lateral propagation. Alisdair said it very well on Monday. When you do this, when you use PMR and you instantiate an allocator, an allocator sticks for life of the container. It doesn't get swapped, it doesn't get propagated out. You create a container with a PMR-based memory resource, that resource sticks with the container for life. So, what's this base class look like? Well, it's pretty straightforward. There is this max align nested integer which tells the size of alignment. There are these public functions for allocator and deallocate which are required by allocator traits. There's a runtime function for is equal. And then the actual dirty work is done behind the scenes by these private virtual functions that you would override in a derived class to actually provide these services. For example, in the standard there is a PMR called monotonic buffer resource. It's publicly derived from memory resource. It's got far more constructors than I have listed here. I wanted to keep it all on one slide. There's a couple of additional member functions. Of interest here is the fact that the copy constructor and the copy assignment operator have been deleted. Copying cannot be done, and by extension, moving cannot be done. And that goes along, that confirms the idea or is in coincidence with the idea that these things do not propagate. They stick for life with the container. In this particular case, you can see that for this type, the do allocate, do deallocate, and do is equal functions are actually overridden and implemented in this type. And that's how you provide that behavior. There are a couple of more of these. There's a synchronized, what's it called, Alisdair, synchronized memory buffer, unsynchronized memory buffer? There is the general buffer, the global memory buffer. There's more than monotonic buffer resource. Pools, thank you, pools. Synchronized memory pool, unsynchronized memory pool. So, to work with the memory resources, there is now in the PMR namespace, this allocator type that's designed to work specifically with these resources, the polymorphic allocator. And as an example of how one might implement that, you can see that I've stored in the private section here, a pointer to my memory resource. And I have my second constructor there as one that takes a pointer to that resource. And what this means is that this resource has to be instantiated if you want it to work nicely, have a longer lifetime than the allocator, which means having a longer lifetime than the container. The memory resource has to have a lifetime that is a superset of the lifetime of the allocators and containers that use that resource. Also, in this case, this allocator, the allocator, the copy semantics and by extension, move semantics are deleted. They take on the default values for the propagation traits, nothing propagates. Because this is not an empty class, and because this does not specifically create the is always equal trait, allocator traits will deduce that not all instances of this are equal. And so, there will actually be a runtime comparison to determine, are two resources equal? And that will be handled by this member function, do is equal. And in the examples that I've seen, really it determines if two resources are equal by looking at the value of the pointer in the allocator. If both things point to the same resource, then the allocator is equal. The standard also provides some type aliases, basic strings. I've listed a few of 'em here, deques and sets, which you can use to create containers that use the PMR resources. I'm running low on time here. So, I'd like to talk very briefly, I'll probably skate through these next slides very quickly. A question was raised the other day about, how do I write a container, and how do I make my containers do the right thing with the new allocator resources? And so, what I'm going to attempt to do, or what I've attempted to do in the next set of slides in this section, is provide a sort of pseudocode outline of how you would write your default constructor, copy constructor, move constructor, copy assignment operator, move assignment operator, and swap member function. And this is not real C++ code, but hopefully, it will give you the idea of how these things work. So, this is a repeat of the previous slide, this is the top half of my phony container. Here are the important special functions and the swap function. Here, for purposes of exposition is how I'm implementing my phony container. I've got something that I'm just calling my implementation data. I've got an instance of my allocator type. And for the purposes of my outline functions, I've got two helper functions, assign from, and clear and delete memory. Just so I don't have to write lots of extra code. So, for copy construction, in the first copy constructor, I'm copy constructing another container and I don't have an allocator argument. This is where that funny select on copy construction function comes from, and this is where it's used. So really, the logic here is unless the allocator, m alloc, has a member function that returns itself or it returns a temporary, I am going to copy the allocator that's part of other. So, what that means is, I'm either going to copy from the allocator in the other, or it's going to be some temporary object because it doesn't propagate. I probably didn't explain that very well, but maybe I'll come back to it. The second copy constructor is very straightforward. I'm just going to assign for my other implementation and I'm gonna copy construct my allocator. Move construction is actually pretty straightforward. In the first, I'm going to move from my implementation and I'm gonna move from my other allocator, which may be trivial or maybe copying. In the second move constructor, I'm going to move from my implementation. Because I've provided a const reference to the allocator, I'm going to have to copy construct from that. Copy assignment is a little more difficult, and this is where this is more pseudocode than real code. And by the way, this pseudocode was generated by looking at the class std vector from Clang 4.0. It's much more complex code in there, but it's very well written. And Howard Hannett had a post on stack overflow a couple of years ago and said, "If you really wanna understand "this stuff, look at std vector in Clang," and that's what I did. So, I recommend looking at that code. Start with std vector and look at the other containers if you wanna understand it. So, I wanna see, all right, is my propagation trait true? If it's true and my allocator is different than the other allocator, I wanna deallocate my memory. I wanna assign the other allocator to my allocator. And then I wanna assign my elements. If my allocator doesn't propagate, I don't need to do that stuff with the allocator. I'm just gonna assign from the other elements. Pretty straightforward. Move assignment is trickier though. Move assignment and swap are probably the trickiest. So here, if... if I propagate on move assignment, in other words, true type is the value of the traits list, the nested trait. Then I wanna clear my memory. I wanna move my allocator from my source. I wanna move my implementation from my source. And then I'm done. If my allocator trait says that allocators are always equal, or this allocator happens to compare equal to the allocator from the right-hand side, well, I'm still gonna clear and deallocate my memory that's holding my elements right now. But I am just going to move my implementation from the other container. I don't need to do anything with the allocator, because I know that the allocators are equal. Finally, if neither of those conditions hold, then I'm just going to do a, effectively, a move assignment. And here is a case where you may lose the no throw guarantee, even though I'm moving elements from the old container into the new container. I may need to allocator memory, and an exception may be generated in that case. Alisdair. (man speaking off microphone) Is it intentional-- (man speaking off microphone) Yes. Swap is also tricky. If propagate is set true, then I can swap my implementation, I can swap my allocator. If my allocator traits say my allocators are always equal, either by the value of the traits type or some runtime boolean test, then I'll just swap my implementation, there's no need to swap the allocators. However, if my allocators do not propagate or they are not equal, then attempting to swap in that circumstance is undefined behavior. Why is it undefined behavior? It's undefined behavior because the standard requires all iterators to remain valid after a swap. Now, in the last bottom of the if ladder there, I may have to allocate elements. In which case, the iterators to my old elements may no longer be valid, depending on what is the type of the container. All right, I'm getting very close to the end here. Finally, I'd like to share in the next two or three slides, some guidelines for how to think, or questions to ask yourself if you try to write your own allocator. So, design decisions. First thing you wanna think about is the plumbing question, the big plumbing question. Do you need to write a traditional allocator with all of that stuff in it, or do you wanna create a new memory resource and just reuse the PMR? These days, I would recommend start with PMR first. It's a lot easier, it's a lot simpler. Structural management for your allocator. In any case, you need to think about the addressing model, the storage model, the addressing model, how do you dereference data? The storage model, where am I going to get my memory from? Is it S-break, is it SHEMAT, is it some other function? What's my big source of raw memory? The pointer interface, am I using natural pointers or some sort of synthetic pointer that implements my addressing model? In the case of PMR, addressing model and pointer interface, they're fixed for you. They are T star, there's no real decision to make there. Your allocation strategy, how are you gonna carve up big segments of bytes into little chunks that are used by your containers? Concurrency management. If you're gonna write an allocator that is gonna be used in a multi-threaded application, you need to think about thread safety. If you're writing an allocator for a database engine or something like that, you might even need to think about transaction safety, keeping transaction logs so that you can commit and roll back and do transaction-safe allocation. So, if you're really like pain and you wanna build a traditional allocator, I strongly recommend review the minimal and default allocator interfaces, and read the sections in the standards, those requirements that I listed several slides ago. And decide how to specify the properties and functionalities that you want. Now, I haven't mentioned this and I'm only gonna say it very quickly, you can put all of that stuff in the allocator itself, in your custom allocator. You're also allowed to partially specialize allocator traits and pointer traits. I don't recommend doing that. I recommend put everything in your allocator. You need to decide as I mentioned before, synthetic pointers or ordinary pointers. Do I need a self-contained heap or shared memory, in which case you're gonna wanna write a synthetic pointer, or can I get by with ordinary pointers because I don't care about that? If they're synthetic, don't forget to write a rebinder in your pointer type. If they're synthetic, you have to define that to pointer member function, and you have to think very hard about the implementation of that function, like I said. If your synthetic pointer is such that it can consistently cover the entirety of the address space, then it's easy. If the semantic of your synthetic pointer is such that it's only applicable over a small part of your address space, then you need to think about what to do if you wanna take a pointer to something that's not part of that little segment. What does that mean? You gotta decide that for yourself. Do specify the rebinder for your allocator. Do specify your allocator's nested value type. Actually, you have to do that. If the instances always compare equal, then define the nested type alias and set it to true type. That'll buy you a little bit of speed at runtime, because the containers will make compile time decisions about what to do. If the instances do not propagate on copy construction, define a select on copy construction member function as part of your allocator that returns a temporary value. That temporary value is what will be propagated into the containers allocator. If you want your instances to propagate on copy assignment, then define the nested type alias as true type. Same thing for move assignment and the same thing for swap. You need to think about what the semantic of your allocator is, and does copying, moving, and swapping instances of the allocator, does it make sense? If it does, set these things true. It's hard to think of cases where it does, they're probably fairly rare, but if they do, you need to think about it and do the right thing. If you're going the PMR root, which I recommend starting out with, the real question you have to answer is, which base class do I wanna work with? You can always start with memory resource. There are the others that are derived from memory resource. Perhaps you want to override their behavior, although their member functions may be set final. I don't remember. But it's easy enough to create something new from memory resource, and create some sort of, not create, but test different allocation strategies for your application. And not worry about synthetic pointers or propagation traits or any of the other stuff you have to worry about with traditional allocators. Yes, I'm running over. Okay, well, anyways, thank you for attending. Sorry I ran over. (applauding)
Info
Channel: CppCon
Views: 35,274
Rating: undefined out of 5
Keywords: Bob Steagall, CppCon 2017, Computer Science (Field), + C (Programming Language), Bash Films, conference video recording services, conference recording services, nationwide conference recording services, conference videography services, conference video recording, conference filming services, conference services, conference recording, conference live streaming, event videographers, capture presentation slides, record presentation slides, event video recording
Id: kSWfushlvB8
Channel Id: undefined
Length: 63min 40sec (3820 seconds)
Published: Tue Oct 24 2017
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.