Memory Safety in C++
You can write C++ programs that are statically type safe and have no resource leaks. You can do that simply, without loss of performance, and without limiting C++’s expressive power.
- Bjarne Stroustrup, Herb Sutter and Gabriel Dos Reis
The Problem with Raw Pointers
See if you can spot the bug in the following code:
void foo(MyClass* data, size_t size) {
MyClass* temp = new MyClass[size];
do_work(data, temp, size);
if (work_invalid(data, temp, size)) {
return;
}
do_more_work(data, temp, size);
delete[] temp;
}
Got it?
How about in this one:
void bar(MyClass* data, size_t size) {
MyClass* copy1 = new MyClass[size];
MyClass* copy2 = new MyClass[size];
make_copies(data, copy1, copy2);
delete[] copy2;
delete[] copy1;
}
These “spot the bug” examples are the exact ones David Olsen used to kick off his CppCon 2022 Back to Basics presentation, which inspired this post. They perfectly illustrate the dangers of raw pointers.
Here are the problematic lines:
// In example 1:
void foo(MyClass* data, size_t size) {
// ...
if (work_invalid(data, temp, size)) {
return; // Skips the delete[] at the end of the function!
}
// ...
delete[] temp;
}
// In example 2:
void bar(MyClass* data, size_t size) {
MyClass* copy1 = new MyClass[size];
MyClass* copy2 = new MyClass[size]; // If this allocation throws an exception...
// ...
delete[] copy2;
delete[] copy1; // ...this line is never reached. copy1 is leaked!
}
Did you get them both? These are tricky, easy-to-miss bugs that lead to resource leaks.
What’s Wrong with Raw Pointers?
The type of pointers we learned about in school with big red warnings about dangling pointers and memory leaks.
int* x;
Shape* circle;
Vehicle* myCars;
try {
x = new int{8};
circle = new Shape();
myCars = new Vehicle[2];
// ...
} catch (...) {
delete[] myCars;
delete circle;
delete x;
}
Raw pointers can be used to point to objects, but they face some significant drawbacks.
- Single object and array types both look the same but have different valid operations.
- Ownership matters: only the owner of an object should free the memory, but what if there are multiple owners?
- Null pointers may or may not be valid depending on the object.
As David Olsen points out, raw pointers are fine for one specific case: a non-owning pointer to a single object. For non-owning pointers to arrays, it’s now better to use std::span (C++20) which bundles the pointer and the size into one non-owning object.
Smart Pointers to the Rescue
Typically when we refer to smart pointers, we are talking about standard library wrappers around raw pointers. These usually come with some “smart” features.
- RAII and automatic release of resources on destruction.
- Enforcing restrictions and safety checks such as disallowing null or preventing multiple owners.
0. unique_ptr
Enforces the assumption that it is the sole owner of the memory it points to. Guarantees the release of resources and does it implicitly.
void example() {
std::unique_ptr<int> x = std::make_unique<int>(8); // make_unique accepts arguments to pass to the type ctor
std::unique_ptr<Shape> circle_owner = std::make_unique<Shape>();
std::unique_ptr<Vehicle[]> myCars = std::make_unique<Vehicle[]>(3); // creates a dynamic array of size 3
do_math(*x);
circle_owner->set_fill();
std::cout << myCars[0].get_make() << std::endl;
} // Delete happens here automatically
We can use unique_ptr in a custom class with RAII to ensure memory safety.
Shape* make_shape(ShapeData data);
class ShapeWrapper{
private:
std::unique_ptr<Shape> owner;
public:
ShapeWrapper(ShapeData data) : owner(make_shape(data)) {}
~ShapeWrapper() = default;
// ...
}
One note about unique_ptr safety: unique_ptr does not enforce that it references a valid object.
std::unique_ptr<Shape> circle_owner = std::make_unique<Shape>();
Shape* circle2 = circle_owner.release(); // Release memory to raw pointer
circle_owner->set_fill(); // Runtime error!
However, it is possible to check if a unique_ptr is empty.
// ...
if (circle_owner) {
circle_owner->set_fill();
}
Passing ownership of a unique_ptr is simple enough using std::move.
std::unique_ptr<Vehicle> car = std::make_unique<Vehicle>();
// ...
std::unique_ptr<Vehicle> temp{ std::move(car) };
// ...
car = std::move(temp);
This can be implicit when passing into and out of functions as unique_ptr is move only. Pass by value in this case.
std::unique_ptr<Shape> combine(std::unique_ptr<Shape> shape1,
std::unique_ptr<Shape> shape2) {
auto result = std::make_unique<Shape>();
foo(shape1.get(), shape2.get(), result.get());
return result;
} // shape1 and shape2 will be deleted here and if the caller does nothing with result, it will also be properly destroyed
Some gotchas
- Creating 2
unique_ptrswith the same block of memory will result in a crash due to double free.
{
Vehicle* car = new Vehicle();
std::unique_ptr<Vehicle> c1{car};
std::unique_ptr<Vehicle> c2{car};
} // Crash!
It’s best not to create a unique_ptr from a raw pointer unless you know what you’re doing.
- Dangling pointers can still be made by calling
unique_ptr::get()
Vehicle* car = nullptr;
{
auto c1 = std::make_unique<Vehicle>();
car = c1.get();
} // car now becomes a dangling pointer
auto c2 = *car; // undefined behaviour
1. shared_ptr
Shared ownership of memory that is automatically freed when the last owner goes away.
// make_shared allocates both object and control block in a single call
std::shared_ptr<int> x = std::make_shared<int>(8);
std::shared_ptr<Shape> circle = std::make_shared<Shape>();
std::shared_ptr<Vehicle[]> myCars = std::make_shared<Vehicle[]>(3);
std::shared_ptr y(x);
cout << *y; // output: 8
This is managed with an atomic reference counter that is decremented in the shared_ptr destructor. When it reaches 0, the object is cleaned up.
One note about std::make_shared and C++ versions:
std::shared_ptrfor array types was added in C++17std::make_sharedfor array types was added in C++20
Some gotchas
- Creating 2
shared_ptrs with the same raw pointer will result in a crash due to double free.
{
Vehicle* car = new Vehicle();
std::shared_ptr<Vehicle> c1{car};
std::shared_ptr<Vehicle> c2{car};
} // Crash!
Only a single shared_ptr can be created from the raw pointer. The rest must be copied.
Here’s the correct code:
{
auto c1 = std::make_shared<Vehicle>();
std::shared_ptr<Vehicle> c2(c1);
std::shared_ptr<Vehicle> c3;
c3 = c2;
} // Proper cleanup
2. weak_ptr
Non-owning reference to an object managed by shared_ptr.
std::weak_ptr<int> w;
{
auto s = std::make_shared<int>(7);
w = s;
std::shared_ptr<int> t = w.lock();
if(t) {
std::cout << *t; // output: 7
}
}
std::shared_ptr<int> u = w.lock();
if (!u) {
std::cout << "Empty"; // output: "Empty"
}
What problem does this solve?
- Caching to keep a reference to an object as long as it is alive. This reference will not keep the object alive itself.
- Prevent dangling references by allowing object detection with the lock function as above.
3. Custom Deleters: unique_ptr
What problem does this solve?
- Cleanup that doesn’t include calling
delete
FILE* fp = fopen("readme.txt", "r");
fread(buffer, 1, N, fp);
fclose(fp); // This is a cleanup function we want managed with RAII
How do they work?
unique_ptrhas a custom Deleter template parameter
struct fclose_deleter {
void operator()(FILE* fp) const { fclose(fp); }
};
using unique_FILE = std::unique_ptr<FILE, fclose_deleter>;
{
unique_FILE fp(fopen("readme.txt", "r")); // create the unique_ptr instead of the raw FILE pointer
fread(buffer, 1, N, fp.get()); // change to .get()
} // fclose called
shared_ptraccepts a custom Deleter as a constructor parameter
struct fclose_deleter {
void operator()(FILE* fp) const { fclose(fp); }
};
{
std::shared_ptr<FILE> fp(fopen("readme.txt", "r"), fclose_deleter{}); // passed as param
fread(buffer, 1, N, fp.get()); // still use .get()
std::shared_ptr<FILE> fp2(fp);
} // fclose called when all shared_ptrs go out of scope
shared_ptr extras
0. Casting shared_ptr
This can be useful when you want to have shared_ptrs existing in a class hierarchy.
std::shared_ptr<Vehicle> v = create_vehicle(VehicleData);
std::shared_ptr<Car> c = std::dynamic_pointer_cast<Car>(v);
if(c) {
c->accelerate();
}
Other casts exist such as static_pointer_cast, const_pointer_cast and reinterpret_pointer_cast.
1. shared_ptr aliasing constructor
shared_ptr has a constructor that allows you to point to subobjects of managed objects.
struct Car {
float fuel;
Engine engine;
}
void foo(std::shared_ptr<Car> car) {
std::shared_ptr<Engine> engine(car, &car->engine);
// ...
}
This constructor allows shared_ptrs to use the same control block and reference counts but have unrelated object pointers.
2. shared_from_this
Finally, this pointer in a class that derives from enable_shared_from_this can be useful if you have objects managed by shared_ptr.
By returning this->shared_from_this() we return a shared_ptr to an object.
Notes:
- The class must inherit from
std::enable_shared_from_this<T> - It only works if the object is already managed by a
shared_ptr - Useful for returning
shared_ptr<this>safely without creating multiple control blocks
Take-aways and notes
unique_ptrandshared_ptrare great implementations of smart pointers, but they don’t prevent you from making your own as required.- Use smart pointers to represent ownership.
- Prefer
unique_ptrovershared_ptr, you can always swap later. - Use
make_uniqueandmake_sharedrather than direct constructors on smart pointers. - Pass/return
unique_ptrto transfer ownership between functions.