C++ Best Practices
Category | C++ |
---|
Macros
- A macro can be written on multiple line by ending a line with the
\
character.
- A macro can contain multiple statements separated by the
,
character.
#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.
- MSVC:
__restrict
- Clang/GCC:
__restrict__
void Copy(char* __restrict to, const char* __restrict from)
{
...
}
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.
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
Static variables in functions
Defining a static variable is inside a function can reduce the performances:
❌
- Branching will occur to check if the variable was initialized.
- Cache trashing will occur because of the initialization code.
- The function will not be inlined if the static variable is too large.
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.
✔
- No branching.
- No initialization code.
- The function is simple and guaranteed to be inlined.
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.
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.
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);
}
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.
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.
- For
N
instances of typeT
, request an allocation forsizeof(T) * N + sizeof(size_t)
bytes fromoperator new[]
.
- Store
N
in the first 4 bytes.
- Construct
N
instances usingplacement new
, starting atptr + sizeof(size_t)
- Return
ptr + sizeof(size_t)
to the user.
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())
{
....
}