👑

C++ Best Practices

CategoryC++

Macros

#define Delete(ptr) \
    delete ptr, \
    ptr = nullptr

Types

The smallest allocation is typically one byte.

Therefore, an empty struct or class will have a size of one byte.

struct T
{
};

Outputs "1":

std::cout << sizeof(T) << endl;

Explicit Constructors

By default, C++ allows implicit conversions when a type contains a constructor that takes one required parameter. However, in many cases, it would not make sense to allows such conversions.

class Player
{
public:
    Player(uint32_t health);
    ...
};

We do not want to allow this initialization:

auto player = 50;

To prevent such implicit conversions, the constructors have to be made explicit.

class Player
{
public:
    explicit Player(uint32_t health);
    ...
};

If we compile the same initialization, we get an error:

auto player = 50; // error: No viable conversion from 'int' to 'Player'

Now, we need to call the constructor explicitly:

auto player = Player(50);

Casts

Static cast

The C-style cast isn't checked by the compiler and can fail at run-time.

uint8_t x = 10;                 // 1 byte
uint32_t* ptr1 = (uint32_t*)&x; // 4 bytes

*ptr1 = 5; // run-time error: stack corruption

The C++-style cast is checked by the compiler and produce compile-time errors. It cannot fail at run-time.

uint32_t* ptr2 = static_cast<uint32_t*>(&x); // compile-time error

For this reason, it is preferable to use C++-style casts over C-style casts.

Reinterpret Cast

The reinterpret cast handles conversions between unrelated types.

It performs a binary copy of the data (like the C-style cast) that is platform-dependent and should be use with caution.

Pointer Aliasing

Because of pointer aliasing, the C++ compiler will not perform some optimizations if two pointers can potentially have overlapping memory regions.

void Copy(char* to, const char* from)
{
    for (size_t i = 0; i < strlen(from); ++i)
    {
        to[i] = from[i];
    }
}

In the Copy function, the compiler assumes that a write to to can potentially write to from, because the result of the call to strlen(from) could change in each loop iteration.

There are several ways to allow the compiler to optimize the function.

A simple solution is to use a local variable to store the result from strlen(from).

void Copy(char* to, const char* from)
{
    size_t length = strlen(from);
    for (size_t i = 0; i < length; ++i)
    {
        to[i] = from[i];
    }
}

Another solution is to use the restrict modifier to indicate that a symbol is not aliased.

void Copy(char* __restrict to, const char* __restrict from)
{
    ...
}
ℹ️
The restrict modifier can also be used on methods to indicate that the members of a class are not aliased.
class Xor
{
private:
    uint8_t _key;

public:
    Xor(uint8_t key) : _key(key) {}
    void Encrypt(uint8_t buffer) __restrict;
};

Memory Copy

On a 64-bit platform, 8-byte boundary gives the best performance for memcpy.

memcpy runs fastest if the difference between the source and destination is a multiple of 8.

If the destination buffer is not aligned, initial work must be done to copy enough bytes to align the destination.

If the source buffer is not aligned, then extra work must be done for every byte that is copied in order to fix the alignment.

If the source has a 4-byte alignment but not an 8-byte alignment, then memcpy falls back to a 4-byte copy routine.

If the source has an alignment of less than 4 bytes, then memcpy falls back to code that uses single-byte reads and shifts to align the data.

volatile

A variable declared as volatile indicates to the compiler that no reads or writes with this variable will be optimized away.

⚠️
The volatile keyword is not a synchronization primitive.
Using a volatile variable instead of a synchronization primitive can create race conditions on some platforms because of instruction reordering.

Template Type Deduction

When calling a template function, if an argument is omitted, the compiler performs template argument deduction to detemine the correct type.

We define a templated Max function to return the maximum between two value.

template <typename T>
T Max(T a, T b)
{
  return (a > b) ? a : b;
}

With immediate values, the compiler can easily determine the type to be int.

int max = Max(24, 42);

However, If we define an enum type, the compiler will fail to resolve the enum type to an int.

enum MyEnum
{
		Value24 = 24,
		Value42 = 42,
		...
};

This will not compile:

int max = Max(24, Value42); // Error

We obtain these errors:

No matching function for call to 'Max'

Candidate template ignored: deduced conflicting types for parameter 'T' ('int' vs. 'MyEnum')

Using the + unary operator tells the compiler to perform the type promotion first, before deducing the template arguments.

int max = Max(24, +Value42); // OK
Unfortunately, it does not work when using an enum class.

Static variables in functions

Defining a static variable is inside a function can reduce the performances:

enum class RasterizerMode
{
    CullNone,
    CullClockwise,
    CullCounterClockwise,
    Wireframe
};

inline RasterizerState RasterizerModeToState(RasterizerMode mode)
{
		static RasterizerState states[] =
	  {
				{
				    FillModeSolid,
				    CullModeNone,
				},
				{
				    FillModeSolid,
				    CullModeFront,
				},
				{
				    FillModeSolid,
				    CullModeBack,
				},
				{
				    FillModeWireframe,
				    CullModeNone,
				}
		};
	
		return states[mode];
}

The static variable should be declared outside of the function.

static const RasterizerState s_RasterizerStates[] =
{
		static RasterizerState states[] =
	  {
				{
				    FillModeSolid,
				    CullModeNone,
				},
				{
				    FillModeSolid,
				    CullModeFront,
				},
				{
				    FillModeSolid,
				    CullModeBack,
				},
				{
				    FillModeWireframe,
				    CullModeNone,
				}
		};
};

inline RasterizerState RasterizerModeToState(RasterizerMode mode)
{
		return RasterizerStates[mode];
}

Temporary Allocations

When adding elements to a container in a loop, performances can be impacted because of multiple heap allocations to resize the container.

std::vector<Task> _pendingTasks;

void OnTaskComplete()
{
	  std::vector<Task> tasksToSchedule;
	
	  for (auto& task : _pendingTasks)
	  {
		    if (task->CanBeScheduled())
		    {
			      tasksToSchedule.push_back(task);
			      ...
		    }
	  }
	
	  for (auto& task : tasksToSchedule)
	  {
		    ...
	  }
}

As the lifetime of the elements is scoped to the function, the performances can be improved by reserving enough memory in advance with the reserve method.

void OnTaskComplete()
{
	  std::vector<Task> tasksToSchedule;
	  tasksToSchedule.reserve(_pendingTasks.size());
	
	  for (auto& task : _pendingTasks)
	  {
		    if (task->CanBeScheduled())
		    {
			      tasksToSchedule.push_back(task);
			      ...
		    }
	  }
	
	  for (auto& task : tasksToSchedule)
	  {
		    ...
	  }
}

Delegates

Delegates can be implemented using function pointers and template specialization.

Free functions

A function pointers is declared with the typedef keyword.

typedef void (*MyFunction)(int);

Since C++11, a function pointer can be declared as an alias with the using keyword.

using MyFunction = void (*)(int);

The std::function wrapper type can also be used.

🔥
The std::function class allocates memory.
using MyFunction = std::function<void(int arg)>;

The function pointer can be stored in a variable and the function can be called.

void Print(int value)
{
    cout << value << endl;
}

void main()
{
    MyFunction delegate = Print;
    delegate(42);
}

Member functions

The same mecanism can be used with static member functions, as long as they are accessible.

The delegate has the same signature.

class MyClass
{
public:
    static void Print(int value)
    {
        cout << value << endl;
    }
};

void main()
{
    MyFunction delegate = MyClass::Print;
    delegate(42);
}

The function pointer to an instance method has a different signature.

using MyFunction = void (MyClass::*)(int);

To call the function pointer, we specify the instance.

MyFunction delegate = &MyClass::Print;
auto obj = MyClass();
(obj.*delegate)(42);

Allocation

calloc

When allocating memory with malloc, you must use memset to initialize the memory to 0.

On systems that implemented malloc through virtual memory, calling memset forces the virtual memory system to map the corresponding pages into physical memory in order to initialize them.

Instead, the calloc function reserves the required virtual address space for the memory but the block of memory is not initialized until it is actually used.

The implementation of calloc is platform-dependent.

New operator

The new operator allocates a block of memory.

void* operator new(size_t);

When calling the new operator, the compiler allocates memory for the type and calls the constructor for non-POD types.

The size_t parameter is provided by the compiler using the same value given by sizeof(T) for a type T.

There is a global operator new and class operator new. The class operator new typically calls the global operator new.

Global operator new

The global operator new can be overloaded to implement a custom allocation.

Additional parameters can also be provided.

void* operator new(size_t size, const char* file, int line)
{
    ...
}
void* operator new(size_t size, const char* file, int line)
{
    ...
}

The additional parameters are provided after the new operator.

T* obj = new (__FILE__, __LINE__) T;

It's also possible to overload the operator with template parameters.

template <class ALLOCATOR>
void* operator new(size_t size, ALLOCATOR& allocator, const char* file, int line)
{
    return allocator.Allocate(size);
}
⚠️
The overload can be called explicitly, but the constructor for the type must be called manually using the placement new operator.

Class operator new

The class operator new can be overridden to add additional functionality before calling the global operator new.

#include <iostream>

class T
{
    static void* operator new(size_t size)
    {
        cout << "new with size " << size << '\n';
        return ::operator new(size);
    }

    static void* operator new[](size_t size)
    {
        cout << "new with size " << size << '\n';
        return ::operator new(size);
    }
};

Placement new operator

The placement new operator doesn't allocate memory but can be called to invoke a constructor on a block of memory.

void* operator new(size_t, void*)

With the placement new operator, an object can be created in-place from an allocated block of memory.

The destructor still need to be called manually.

Delete operator

The delete operator deallocates a block of memory that was previously allocated by a matching new operator.

void operator delete(void*);

When calling the delete operator, the compiler calls the destructor for non-POD types and deallocates the memory.

⚠️
The delete keyword does not support the placement syntax.
⚠️
When the new operator is overloaded, the default version of the delete operator is called, even if a corresponding overload of the delete operator exists (unless an exception is thrown during a call to new).
void* operator new(std::size_t size, const char* file, int line)
{
  ...
}

void operator delete(void* ptr, const char* file, int line)
{
  ...
}

T* obj = new (__FILE__, __LINE__) T;

// Calls delete(void*)
delete obj;

A specific overload of the operator delete can be called explicitly, but the destructor for the type is must be called manually.

T* obj = new (__FILE__, __LINE__) T;

// Calls the destructor manually
obj->~T();

// Calls delete(void*, const char*, int)
operator delete (obj, __FILE__, __LINE__);

New[] and Delete[] operators

The new[] operator allocates an array of objects.

void* operator new[](size_t);

The delete[] operator deallocate a block of memory that was previously allocated by a matching operator new[].

void operator delete[](void*);

When calling the new[] operator, the compiler allocates memory for the array and automatically invoke the placement new operator on each element, which in turn calls their constructors.

When calling the delete[] operator, the compiler automatically calls the destructors on each element of the array in reverse-order, and deallocates the memory.

The operator new[] and operator delete[] can also be overloaded.

void* operator new[](size_t size, const char* file, int line)
{
  ...
}

void operator delete[](void* ptr, const char* file, int line)
{
  ...
}

When calling the operator new[] with a fundamental type, the size parameter is computed by the compiler from the size of the array and the size of the object type (sizeof(T)).

// Requests 12 bytes, 3 * sizeof(int32_t)
auto ptr = new int32_t[3];

However, when calling the operator new[] with a non-POD type, the size parameter is computed differently.

The matching operator delete[] needs to call the destructors on each element of the array, so the compiler must know how many instances are to be deleted.

The compiler stores the number of instances in a word value before the array. When calling the operator new[], the compiler adds 4 or 8 bytes to the size parameter.

When the operator new[] is overloaded, the compiler adds a 4 or 8 byte to the memory address that is returned.

uint8_t data[4096];
size_t offset;

void* operator new[](size_t size)
{
  void* ptr = (void*)((uintptr_t)data + offset + size);
  offset += size;
  return ptr;
}

// ptr is set to data + offset + size + sizeof(size_t)
auto ptr = new T[3];

Patterns

Pimpl Idiom

The pimpl idiom is a compile-time encapsulation.

It's a technique to hide implementation, minimize coupling, and separate interfaces.

A pimpl type is implemented with an opaque data member that points to the private implementation.

You can declare the member to the opaque implementation as a unique_ptr to automatically release the memory when the object is destroyed.

// MyClass.h
class MyClass
{
public:
    MyClass();
    ...

private:
    class Details;
    std::unique_ptr<Details> _details;
};
// MyClass.cpp
class MyClass::Details
{
    ...
};

MyClass::MyClass(): _details(new Details())
{
    ....
}