01 September 2017
Move semantics are an important subject in two popular system programming languages: Rust and C++(11). Rust takes a fundamentally different approach to move semantics than C++, being move-by-default. This blog post will assume (beginner-level) knowledge of C++ and Rust and can serve as an introduction to move semantics as a concept before moving on to more technical explanations and reasoning behind certain implementations. It is especially interesting to people coming from Rust that want to know how to emulate the behaviour they have come to expect from that language.
Let's start immediately with a use case. Say we have a wrapper around a texture object, which refers to memory allocated on a GPU. We create this wrapper in some function, e.g. when loading the image file from our hard drive, and then we wish to store it in some collection (for example a map) so that we can use it again later without having to load the image and create the texture again.
struct TextureWrapper {
TextureWrapper(GPUTexture* tex): texture(tex) {};
~TextureWrapper() { FreeGPUMemory(texture); };
GPUTexture* texture;
}
struct TextureManager {
std::unordered_map<std::string, TextureWrapper> textures;
void loadTexture(const char* name) {
// Load image from file
// Create texture from image
TextureWrapper wrapper(texture);
textures.insert(std::make_pair(name, wrapper));
}
}
We have supplied a constructor and a destructor to the TextureWrapper
. As soon as the TextureWrapper
is destroyed, the GPU memory is freed.
In the TextureManager
, we load a texture and store it in the map.
Confident that you have written your texture loading code correctly, you go on to write code to render a sprite. However:
You go over your code again and you immediately see what's wrong: inserting wrapper
into textures
, you take a copy of the object and the original object is destroyed. The destructor is called and the wrapper
inside the map now contains a dangling pointer to GPU memory. So how do we fix this?
One solution that might seem obvious and perhaps even trivial, is to store pointers to TextureWrapper
s instead of the TextureWrapper
objects themselves:
struct TextureManager {
std::unordered_map<std::string, TextureWrapper*> textures;
void loadTexture(const char* name) {
// Load image from file
// Create texture from image
TextureWrapper wrapper = new TextureWrapper(texture);
textures.insert(std::make_pair(name, wrapper));
}
~TextureManager() {
// Call `delete` on all `TextureWrapper*`s in `textures`
}
}
Now we are sure that the GPU memory will only be freed when our TextureManager
goes out of scope (probably when the user quits our application). But we can do away with the (potentially faulty and wasteful) memory management by making
use of move semantics. Don't allocate memory on the heap unless you have to.
The default behaviour of the insert
operation (and of most methods and functions you will write in C++) is to take a copy of arguments. But what we want here, is for our TextureManager
to own TextureWrapper
s. They should not be owned
by the loadTexture
method, but by the manager itself. We want to move ownership from the method to the object.
First of all, it's a good idea to explicitly delete the copy constructor and assignment operator of TextureWrapper
:
struct TextureWrapper {
TextureWrapper(GPUTexture* tex): texture(tex) {};
TextureWrapper(const TextureWrapper&) = delete;
TextureWrapper& operator=(const TextureWrapper&) = delete;
~TextureWrapper() { FreeGPUMemory(texture); };
GPUTexture* texture;
}
If we try to compile the first version of our TextureManager
, the compiler will spit out an error message saying that we are trying to use an explicitly deleted copy constructor. Now, we want to create a move constructor for the TextureWrapper
.
struct TextureWrapper {
TextureWrapper(GPUTexture* tex): texture(tex) {};
TextureWrapper(const TextureWrapper&) = delete;
TextureWrapper& operator=(const TextureWrapper&) = delete;
TextureWrapper(TextureWrapper&& other) {
texture = other.texture;
other.texture = nullptr;
};
~TextureWrapper() { if(texture) FreeGPUMemory(texture); };
GPUTexture* texture;
}
Note the double ampersand. other
is an rvalue. This is a temporary object. In the move constructor, we copy the relevant information from the other object into the new object. Also note that we set other.texture
to a null pointer, and check
in the destructor whether texture
is not null. This makes sure that when the temporary object is destroyed, the GPU memory is not freed.
We can now fix our original code by explicitly moving ownership to the TextureManager
object by using std::move
:
void loadTexture(const char* name) {
// Load image from file
// Create texture from image
TextureWrapper wrapper(texture);
textures.insert(std::make_pair(name, std::move(wrapper)));
}
Now our code compiles and no longer crashes when we try to access the wrapped texture. What did we do? We told our compiler that we want to move ownership to the manager. Instead of taking a copy, we want to move the object over.
If you've ever worked with std::unique_ptr
before, you'll likely have encountered the same "problem" as above. These objects have no copy constructor (because only one object can own a unique_ptr
) and you have to explicitly move
the pointer if you wish to transfer ownership.
While Rust is move-by-default and requires explicit copies, C++ does not. It is important to know this if you wish to correctly manage your memory in C++. I suggest explicitly deleting copy constructors of classes and structs managing memory, to be sure that you don't make any mistakes. It is also a good idea to disable the assignment operator. Then implement them if you do need them. This will help you avoid subtle bugs that cannot be discovered by the C++ compiler.
More information on creating objects that cannot be copied can by found on Wikipedia.
More (in depth and technical) information on move semantics: