Basic C++ Practices
Overview
- This document some basic and generally applicable practices toward writing better
C++code - The practices apply to
C++23and beyond - This is not meant to be comprehensive but rather to highlight key ideas from elsewhere.
Variable Declaration and Initialization
What?
- The rule of thumb here is Almost Always Auto
- In most cases, when declaring local variables, it makes sense to use
auto
- In most cases, when declaring local variables, it makes sense to use
- Below are some examples:
auto x = y; // inferred as int auto y = 2.0; // inferred as double auto z = 2u; // inferred as unsigned int auto q = uint16_t{45}; // explicit type
Why?
autodeclarations require an initialization- In other words,
auto x;results in a compilation error
- In other words,
autoselects the best general-purpose integer type for the machineautodoes not prevent you from explicitly specifying types
Why Not?
- Some objects (e.g., in linear algebra libraries) rely on proxy objects to, for example, implement lazy evaluation
- You usually do not want a varaible that refers to these proxy objects
Use std::vector<>
What?
- A safe, dynamically re-sizable, array
- Like a python List
- A most versatile data-structure of first resort
auto v = std::vector<int>{2, 3, 4}; auto x = v.at(2); // 4 v.at(0) = 1; // v == {1, 3, 4}
How?
- Always use
v.at(index)to into the vector- Ensures run-time check of the index, will throw an exception if out-of-bounds
- (Almost) Never use
v[index]- No run-time check of the bounds
- Boosts performance, but if your code has bugs can cause major problems
- Only use if you have determined, through measurements, that you cannot afford the run-time check.
- Avoid indexing and use a range-based for loop, with Constrained Algorithms or std::algorithms
- Usually it's a good idea, but when doing mathematical code indexes can be useful.
Why?
std::vectoris a relatively straightforward data-structure with a known performance profilestd::vectoris usually good enough- Modern computers favor linear algorithms with minimal branching, which fits well with
std::vector
Caveats
- Don't use
std::vector<bool>because it does not conform to the proper container interface - Use
std::vector<uint8_t>,std::deque<bool>orstd::bitset.- All have various trade-offs,
std:"vector<uint8_t>at least behaves like a normal vector
- All have various trade-offs,
- See Meyers, Scott Effective STL, Chapter 2, Item 18
- It's a fine data structure, just named incorrectly
Alternatives?
std::array
For fixed-size arrays, you can use std::array
- Need to know the size at compile time
- Can enhance performance of
std::vector, but that performance boost is likely unnecessary unless measured to be - Still provide
.at()for bounds-checked access
built-in array
There is usually no need to use built-in arrays, especially in this class
Use Range-based for loops
- In C++ you can iterate over ranges of items using a range-based for loop
- Basically anything with a
.begin()and.end()iterator can be iterated over with such a loop std::vectoris one such container that satisfies this requirement
- Basically anything with a
- There are three primary forms to use. Which to use essentially follows the same rules as parameter passing.
for(auto v : myvector)- Here, each element in
myvectoris copied intov - This mode is appropriate when the type held by myvector is small (e.g.,
int,double, etc.)
- Here, each element in
for(const auto & v : myvector)- Here each \(v\) is presented as a reference to a const element of the
myvector - The for loop may inspect but may not call non-const methods on the object
- This mode is appropriate if the type held by myvector is large/custom (e.g., a class you wrote)
- Here each \(v\) is presented as a reference to a const element of the
for(auto & v : myvector)- Here, each element is presented as a reference to a mutable element to
myvector - This mode is appropriate only if you wish to modify the elements in
myvectorin the loop
- Here, each element is presented as a reference to a mutable element to
- Don't force yourself into using
range-basedfor: sometimes indexes are useful, so if you need them, use them - The appropriate type for iterating over the elements of the vector is
std::size_t.for(std::size_t i = 0; i < myvector.size(); ++i)
Memory
Overview
- C++ provides programmers with direct access to the memory where variables and objects are stored
- This provides absolute control over the program, but also creates the chance to introduce bugs
The Stack
- The stack is a part of memory where local variables are stored
- Local variables also include the parameters to the function
- When a function is called, it's local variables are placed on the stack
- When a function returns, it's local variables are removed from the stack
- Thus, the memory used for storing local variables
- Is automatically allocated upon entering a function
- Is automatically de-allocated upon leaving a function
- Local variables do not persist beyond a function call
The Heap
- The heap is an area of memory managed by the programmer
- Objects can be allocated on the heap with the
newoperator- Calling
newallocates memory and calls the Object's constructor
- Calling
- Objects can be de-allocated from the heap with the
deleteoperator- Calling
deletecalls the object's destructor and de-allocates it's memory
- Calling
new []anddelete []can allocate and de-allocate arrays of objects- If your program forgets to de-allocate memory (e.g., does not call
delete) it can leak memory - If your program accidentally de-allocates memory twice it can crash and create a security bug
Pointers
- Every variable has a memory address.
- Pointers store the memory address of a variable
- In Modern C++ pointers should almost never be used.
- However, a basic understanding of pointers is useful
Consider the following code:
auto var = 20; //int * pvar = &var; auto pvar = &var; //int ** ppvar = &pvar auto ppvar = &pvar;
Memory might be laid out as below:
| Address | Value | Variable |
|---|---|---|
| 0x1000 | 20 | var |
| 0x1004 | 0x00001000 | pvar |
| 0x1008 | 0x00001004 | ppvar |
- The variable
varholds20. - The address of
varis&varand it is0x1000(in reality the address would be 64 bits on an x86_64 machine). - The pointer
pvar == &var.- To access var through
pvarit can be de-referenced. - For example
*pvar == 20and*pvar = 40; var == 40
- To access var through
- Pointers are in-and-of-themselves variables and are stored at memory locations
&pvar == = 0x1004in this case
nullptrindicates that a pointer is invalid and does not point to anythingnullptrpointers should never be dereferenced
- Pointers allow passing the address of data, rather than copying the data itself
- Only the address needs to be copied, not the whole data structure
- Pointers can be re-assigned to point to new objects
References
- References are like pointers that are:
- Never
nullptr - Cannot be re-asssigned
- Are always dereferenced
- Never
- Taking the address of a reference returns the address of the underlying variable
- References are primarily used for two purposes
- Passing objects to functions
- Referring to the current element in range-based for loops
Here is an example of how they work (illustrative only)
auto x = 2; auto & y = x; y = 3; // now x == 3
Smart Pointers
unique_ptr
unique_ptr<T>creates a smart pointer with single ownership- Only one
unique_ptrat a time can referenceobj - When the
unique_ptrgoes out of scope,objis destroyed - Cannot be copied
- Ownership can be transferred to a new
unique_ptrby moving from it - Create with
std::make_unique
Recommendations
- In general, pointers should be avoided. They can be used in a few situation
- Interfacing with C code that uses pointers
- Non-owning use in contexts when references don't make sense.
- Someone else is responsible for managing memory lifetime
- The value pointed at may be
nullptr - The context is not a parameter passing context
- References should be used only for parameter passing and range-based for loops
- Don't use references as member variables (they prevent copying your object)
- In most cases you don't need to allocate your own memory
- Containers like
std::vectorinstead std::shared_ptr<>, created withstd::make_sharedstd::unique_ptr<>, created withstd::make_unique- There is no need to use
newordeletein this class
- Containers like
- If you must allocate memory (e.g., to make your own data structures)
- Use Objects: Memory is allocated in constructors and deallocated in destructors
- See: Scott Meyers Effective C++, Chapter 3, Item 13
Parameter Passing
Pass By Value
- Pass by value is the preferred method for passing small objects to functions
- Consider
void func(MyObject obj)- In this example,
objis passed by value - This means that the copy constructor of
objis called- If
objhas no copy constructor, pass by value cannot be used
- If
- Copying large objects can be time consuming, but is trivial for small objects
- In this example,
Pass By Reference to Const
- Pass by reference to const is a method for passing objects without requiring copying
- Consider
void func(const MyClass & obj)andvoid func(MyClass const & obj)- Both examples are exactly the same semantically
- The first is more common, the second is more consistent with other uses of
const - No copy is made when passing by reference to
const funccan cannot modifyobj(see for precisely how/why)funcborrowsobj- The usage of
objmust end whenfuncreturns - This means that
funcshould not store a pointer/reference toobjanywhere- Some other part of the code owns
objand could destroy obj, which would lead to a dangling reference (very bad)
- Some other part of the code owns
- The usage of
Pass by unique_ptr
- Pass the
unique_ptrby value:void func(unique_ptr<MyClass> obj) - The caller must transfer ownership to
funcusingstd::move: funcnow becomes responsible for managing the lifetime ofobjHere is an example
auto obj = std::make_unique<MyClass>(MyClass constructor arguments); func(std::move(obj)); // x cannot be used
Other Methods
These notes don't explain these methods, but they are here for completeness
- Mutable reference:
void myfunc(MyClass & out)- Like Python pass-by-reference
- An implementation of "Output Parameters"
outcan be modified inmyfuncand those changes are seen outside the function- Not usually needed because in C++ it is okay to Return By Value
lvaluereference:void myfunc(MyClass && lvalue)- forwarding reference:
template<class T> myfunc(T && myval) - Pointer:
void myfunc(MyClass * p)- Useful for interfacing with C
- The pointer can also be
const
Returning Objects
- In modern C++, there are powerful guarantees of Copy Elission
- In many cases, returning an object by value will not incur additional costs
- As for all performance-related questions: if you are concerned that returning by value is too costly, measure.
To return multiple objects by value use
std::pairorstd::tuplestd::tuple<int, char, double> stuff() { // The return type is known so we can // call the constructor directly with braces return {1, 'b', 2.0}; } // structured bindings let us get separate variables // for each item in the tuple easily auto [x, y, z] = stuff(); // x == 1 // y == 'b' // z == 2.0
Logical const
- In C++
constmeans logically constant, meaning that outside observers cannot see that the object has changed - logical const is conceptually different than
bitwise-const: the internal state of an object is allowed to change - The status of const is enforced by the compiler
const auto x = 2means that the value ofxwill not changevoid myfunc(const MyClass & obj)means that- Only
constmember functions ofobjcan be called publicmember variables ofobjare treated as if declaredconst
- Only
void MyClass::member() const- Within this method, the member variables are treated as if declared
const
- Within this method, the member variables are treated as if declared
- Effectively:
- Only
constmethods can be called onconst objects constmethods can't modify theobject- Therefore the compiler enforces
const
- Only
- In practice:
- It is possible to circumvent
const - The
mutablekeyword allows a member variable to be changed by aconstfunction- This is okay only if the change is not observable outside the class and is
thread safe - There are sometimes reasons to do this (e.g., caching computations), but it's much easier if you avoid
- This is okay only if the change is not observable outside the class and is
- The
const_cast<>can be used to cast constness away- Basically never do this, it is not always possible anyway (because some data can be stored in read-only memory for example)
- It is possible to circumvent