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 = nullptrTypes
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 corruptionThe 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 errorFor 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)
{
...
}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.
volatile keyword is not a synchronization primitive.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); // ErrorWe 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); // OKenum class.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.
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.
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);
}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.
delete keyword does not support the placement syntax.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.
- For
Ninstances of typeT, request an allocation forsizeof(T) * N + sizeof(size_t)bytes fromoperator new[].
- Store
Nin the first 4 bytes.
- Construct
Ninstances 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())
{
....
}