Scopes and RAII
Scopes
Section titled “Scopes”Scopes have two roles in C++:
- They serve to make variables not visible outside of them;
- They control the lifetimes of local objects with automatic storage duration: when the scope ends, such objects are destroyed and their destructors free up resources.
Note that this doesn’t apply to dynamically allocated memory:
newreturns a pointer, and that pointer is itself a local object of the scope. When the scope ends, the pointer is destroyed, but the object in heap memory is not affected in any way.
int main(){ { // `pointer` is a regular local variable: it lives in this scope. int* pointer = new int{5}; }
// The scope has ended: the pointer is gone, but the int it pointed to // is still sitting in heap memory, untouched (and now unreachable — a memory leak).
return 0;}You can say that scopes allow you to control the lifetimes of automatic objects.
Scopes for limiting the visibility
Section titled “Scopes for limiting the visibility”You can create scopes by using { ... }.
int main(){ int a = 1;
{ int b = 2; a = 5; // allowed, `a` is visible in outer scope b = 10; // allowed, `b` is visible in the current scope }
b = 10; // not allowed, `b` is not visible, because its scope has ended // (the brace has been closed).
a = 15; // still allowed, because it's visible in the current scope
return 0;}Functions must define a scope for their body.
You can redeclare variables with the same name if they are in different scopes. They are not guaranteed to end up pointing at the same memory.
int main(){ int a = 5;
// Redeclaring the variable in the same scope is not allowed. // int a;
{ // Redeclaring it here is also not allowed. // int a;
int b = 10; }
{ // This is allowed, because the scopes don't interact. int b = 20;
{ // This would be disallowed. // int b = 30; } }
return 0;}A scope within another scope is called a nested scope. You can nest scopes indefinitely.
In general, putting things within other things of the same kind is called nesting.
There’s also the concept of the global scope, which is the scope that’s outside of any function.
Scopes for cleaning up resources
Section titled “Scopes for cleaning up resources”Conceptually, a local variable with automatic storage duration is destroyed at the end of the scope.
For simple types like int, it means literally nothing.
For a complex type, like std::string, this means
returning the memory allocated for the character array to the C++ runtime.
Destructor
Section titled “Destructor”You can define your own logic to be called when your object is destroyed by using a destructor. A destructor is a special function without a return type that is called in this context automatically. You can call it manually as well, but carefully: for a regular automatic object, the destructor will run again when the scope ends, and an object must not be destroyed twice.
Let’s just illustrate how it works.
#include <iostream>
struct Demo{ int i;
// This is a destructor ~Demo() { std::cout << "Destroying demo" << this->i << std::endl; }};
int main(){ Demo demo1{1}; Demo demo2{2}; std::cout << "Demos created" << std::endl;
return 0;
// The compiler implicitly adds the following: // `demo2.~Demo();` // `demo1.~Demo();`}Which prints:
Demos createdDestroying demo2Destroying demo1You can split the destructor declaration and the definition the same way you do with regular methods.
Note that the destructor will only be called in that context. If you reassign the variable, it won’t be called for the object already in that memory.
int main(){ Demo demo1{1}; Demo demo2{2}; demo2 = demo1; // This copies the object's bytes, but doesn't call the destructor of `demo2`.
return 0;
// `demo2.~Demo()` runs, printing "Destroying demo 1" // `demo1.~Demo()` runs, printing "Destroying demo 1"}To make sure that the same object isn’t deleted more than once in this situation, we’ll have to overload the assigment operator.
Implicit destructors
Section titled “Implicit destructors”Say you had a struct with a field whose type has a destructor.
#include <iostream>
struct Test{ ~Test() { std::cout << "Destroying test" << std::endl; }};
struct Person{ Test test;};
int main(){ Person person; person.test = {}; return 0;
// The compiler automatically calls the destructor of each field. // `person.test.~Test();`
// It will also delete the temporary object used for the assignment, // but that's explained in more detail later.}Constructors
Section titled “Constructors”Constructors are special functions without a return type used to initialize objects. Constructors cannot be called in any other context.
Constructors were created to allow initializing private data members (fields) in OOP. Constructors can be more involved than that, but it’s generally discouraged to make them complex.
Constructors used to initialize new objects
Section titled “Constructors used to initialize new objects”If they get out of hand, you can switch to using factory functions.
For example, let’s define a parameterless constructor (a default constructor), which prints to the console.
#include <iostream>
class Demo{ int memory;
public: Demo() { // memory will be unitialized at this point (contain garbage). std::cout << "Created " << this->memory << std::endl; this->memory = 5; }
~Demo() { // memory is 5 here. std::cout << "Destroyed " << this->memory << std::endl; }};
int main(){ { // This actually calls the parameterless constructor. // C++ designers decided that objects must never be unitialized. Demo demo;
// Compiler implicitly added: // demo.~Demo(); }
{ // Explicitly calls the parameterless constructor. Demo demo{};
// Equivalent syntax: // Demo demo = Demo();
// Compiler implicitly added: // demo.~Demo(); }
return 0;}A practical use case would be e.g. to allocate memory, and clean it up in the destructor.
We can do this by using the new and delete operators.
#include <iostream>#include <assert.h>
class Buffer{// private: size_t length; int* firstElement;
public: Buffer(size_t elementCount) // Writes `elementCount` to length directly, before our code is run. // This is called a member initializer. : length(elementCount) //, anotherField(8) { // NOTE: firstElement contains garbage here.
// We need to allocate memory, which won't be readable in the initializer. // This is why I decided to write it in the constructor body. this->firstElement = new int[elementCount];
// The elements are initially uninitialized. }
~Buffer() { delete[] firstElement; }
int& elementAt(size_t index) { assert(index < this->length); return firstElement[index]; }};
int main(){ Buffer buffer{5};
// Prints garbage. std::cout << buffer.elementAt(0) << std::endl;
// Do some stuff with the buffer. int& secondElement = buffer.elementAt(1); secondElement = 10;
return 0;
// The compiler inserted the following automatically. // buffer.~Buffer();}Yes, reading uninitialized memory above is formally undefined behavior: we’re doing it on purpose to observe the garbage value.
Breaking the code above
Section titled “Breaking the code above”It is still extremely easy to break the code above and end up with memory leaks or repeated memory deletes:
int main(){ Buffer buffer1{10}; Buffer buffer2{20}; // This does a memberwise copy. buffer2 = buffer1; return 0;
// the buffer with length 20 is never deallocated // the buffer with length 10 is deallocated twice (runtime error)}Copy constructors
Section titled “Copy constructors”You can define a constructor that takes in an object reference,
which is going to be called on copy initialization.
This includes regular definitions with { }, or definitions with an immediate assignment.
#include <iostream>
class Demo{ int id;
public: // We need a constructor with parameters to be able to create an object in the first place. Demo(int idParameter) : id(idParameter) { }
Demo(const Demo& other) : id(other.id) { std::cout << "Copying " << other.id << std::endl; }};
int main(){ Demo a{1};
// Calls the copy constructor. Demo b{a};
// Also calls the copy constructor. Demo c = a;
// This does NOT call the copy constructor. // It just does a memberwise copy. b = c;}The
constin the copy constructor parameter is not there by accident: without it, you can’t copy a const object or a temporary. If a type has a reason to forbid that, taking a non-const reference makes it possible — but for a regular copyable type,const Demo&is what you write.
Moving an object
Section titled “Moving an object”Moving an object into another object makes one steal the resources of the other. This is often used when constructing objects, or writing values into fields to avoid calling the copy constructor.
Consider the following example, which creates 2 copies of std::string:
struct Person{ std::string name;};
int main(){ // Calls the constructor of `std::string`, // which copies the characters into a dynamically-allocated buffer. std::string name = "John Brown";
Person person;
// Makes a copy of `name` by allocating another buffer and copying the characters. // It then writes this copy into `person.name`. person.name = name;
return 0;
// The compiler implicitly adds the following: // `person.name.~string();` (from the implicit destructor of Person which deletes all fields) // `name.~string();`}So what we do is we move name into person.name, instead of copying it.
In this case, this means, in essence, copying the string object into person.name,
and then clearing it from the name variable, so that it no longer refers to the buffer.
So it becomes unusable after the call.
🤓 The moved-from string being “cleared” here is an implementation detail. The standard technically guarantees less: a moved-from object is left in a valid but unspecified state (it may not even be empty) — there are simply no more requirements on its contents. In practice, though, every modern compiler on any modern hardware leaves
std::stringandstd::vectorempty after a move, just as described above.
#include <iostream>
struct Person{ std::string name;};
int main(){ std::string name = "John Brown"; Person person;
std::cout << name << std::endl; // prints "John Brown" std::cout << person.name << std::endl; // prints nothing
person.name = std::move(name);
std::cout << name << std::endl; // prints nothing std::cout << person.name << std::endl; // prints "John Brown"
return 0;}Move constructors
Section titled “Move constructors”In general, you can define constructors that take in rvalue references (&&). This basically means a reference from which you’re supposed to steal resources.
Don’t get hung up on the details here, I will explain what rvalues are later.
You can use rvalue references as regular parameters as well.
#include <iostream>
class Demo{ int* memoryPointer;
int getValue() { if (memoryPointer == nullptr) return 0; return *memoryPointer; }
public: Demo(int value) { // Allocate an int on the heap (just as a demo, I know this is pointless), // and store the pointer to it in `memoryPointer`. this->memoryPointer = new int{value}; }
~Demo() { std::cout << "Destructor called for " << this->getValue() << std::endl; // Deallocate (deleting a nullptr is allowed). delete this->memoryPointer; }
// Move constructor Demo(Demo&& other) { std::cout << "Move constructor called for " << other.getValue() << std::endl;
// Steal the pointer from the other object. this->memoryPointer = other.memoryPointer;
// Clear the pointer from the other object. // `nullptr` is the same as `0`, it just zeros it out. other.memoryPointer = nullptr; }};
int main(){ Demo demo1{1}; Demo demo2{2};
// This calls the move constructor. Demo demo3 = std::move(demo1);
return 0;
// The compiler implicitly adds the following: // demo3.~Demo(); (clears memory with 1) // demo2.~Demo(); (clears memory with 2) // demo1.~Demo(); (doesn't do anything)}Return value optimization (RVO)
Section titled “Return value optimization (RVO)”If you return an object from a function, its destructor will not be called. In fact, the move constructor won’t be called either. It will just write the result directly into the memory of the variable declared for the result.
🤓 For a named variable like
demo1below, this is an implementation detail (NRVO). The standard technically guarantees copy elision only for temporaries — e.g.return Demo{1};(guaranteed since C++17). For a named object, if NRVO doesn’t kick in, the move or copy constructor is called anddemo1is destroyed as usual. In practice, though, every modern compiler on any modern hardware elides the copy here too.
I have not described how returning objects from functions actually works, but that’s because I don’t know the actual mechanics myself.
It may be returned in one or more of the registers, and then copied into the variable on the stack, or the function might take a hidden “output” address, where it will write the result. I don’t know in which situation which implementation is used by the compiler, and whether there are more clever ways to do this.
If you do know or want to learn more, I’d appreciate it if you made a PR with a brief explanation, or linked some resources.
Demo test(){ Demo demo1{1}; Demo demo2{2}; return demo1;
// `demo2.~Demo();` inserted by the compiler, as usual. // Destructor call for `demo1` NOT inserted here.}
int main(){ // Compiler might implement this by passing `test` a hidden pointer to the local `demo`, // and giving that memory to the local `demo1` in `test`, // such that the memory of `demo1` isn't actually stored on the stack frame of `test`. // You can think of `demo1` as being a reference to `demo`. // Do note that this isn't what the compiler will necessarily do, // it's just one way it could implement this behavior. Demo demo = test(); return 0;
// `demo.~Demo();` inserted by the compiler, as usual.}RVO is the reason you should not be moving objects out of functions.
Demo test(){ Demo demo{1}; // DO NOT do this, this will make an extra copy. // This will call the move constructor into the output memory, // as well as trigger the destructor of the local `demo` at the end of the function. return std::move(demo);}lvalue and rvalue
Section titled “lvalue and rvalue”lvalue means a value that may appear on the left hand side of an assignment. This is equivalent to saying that an lvalue is a value that has storage, meaning it is stored in some memory. In other words, lvalues have a memory address.
An rvalue does not have any storage, it could be either a temporary value or a constant. rvalues may appear on the right hand side of an assignment.
A more precise definition (expression categories)
lvalue and rvalue are categories of expressions, not positions in an assignment.
An lvalue is an expression that has identity: a named variable,
an array element, a dereferenced pointer. It has an address,
and it can usually stand on the left hand side of =.
An rvalue is an expression without identity: a temporary value (the result of arithmetic,
the literal 5) that lives until the end of the current expression.
The subtlety: an rvalue doesn’t necessarily lack an address or storage. For example,
std::move(x)is an rvalue (more precisely, an xvalue), even though it refers to a very real object with an address. That’s why the definitions above are simplifications that break on references andstd::move.
int a;
// a is an lvalue, 5 is an rvaluea = 5;
{ int b;
// b and a are both lvalues // lvalues may appear on the right hand side b = a;}
{ std::vector<int> vec; // vec is an lvalue, {} is an rvalue vec = {};}
{ int& t = a; // t is an lvalue t = 6;}
{ int* t = &a; // *t is an lvalue *t = 7;}rvalue references are denoted as T&& — a reference to an object whose resources are meant to be “stolen”.
You can force an expression to be treated as an rvalue by using std::move:
on its own it doesn’t move anything, it just casts the expression to an rvalue reference.
std::vector<int> a{};a.push_back(5);
{ // This makes a copy of a in t std::vector<int> t{ a }; a[0] = 6; assert(t[0] == 5); assert(a[0] == 6);}{ // a becomes empty, now t posesses the memory that was previously pointed at by a std::vector<int> t{ std::move(a) }; assert(a.size() == 0); assert(t[0] == 5);}It can be used to avoid making copies. In the example below, only one copy of the strings Test and Magic will be created.
Note that you shouldn’t see a difference in the memory usage due to small string optimization, but if the strings are large enough, it will be copied into dynamically allocated memory more times than required.
void addBook(Book&& book){ *someMemory = std::move(book);}
// ...
Book temp;temp.author = "Test";temp.title = "Magic";addBook(std::move(temp));assert(temp.author == "");