Learning C++ part 2
Table of Contents
Understanding const in C++
In C++, the const keyword is used to declare that a value or object should not be modified. It improves program safety, readability, and helps the compiler catch unintended side effects.
1. Constant Function Parameters
When you pass parameters by reference or pointer, marking them const ensures that the function will not modify the original argument. This conveys clear intent and enables the function to accept both const and non-const arguments safely.
1void printData(const std::string& data); // data cannot be modified inside the functionWithout const, passing a large object by reference could unintentionally allow modification of the original data. Adding const prevents this and enables the compiler to enforce immutability.
2. Constant Member Functions
Declaring a member function as const means it will not modify any member variables of the object. This allows you to call such functions even on const objects, ensuring that the method behaves safely in a read-only context.
1class MyClass {
2public:
3 int getValue() const; // This function cannot modify member variables
4};Inside a const function, the this pointer becomes a pointer to const, preventing modification of the object's state. Attempting to modify a member variable inside such a function will cause a compile-time error.
3. Constant Pointers
The placement of const with pointers determines whether the pointer itself, the data it points to, or both are immutable.
1const int* ptrToConstData; // Pointer to constant data (data cannot change)
2int* const constPointer; // Constant pointer to mutable data (pointer cannot change)
3const int* const bothConst; // Both pointer and data are constantIn the example below, we define an integer variable x with a value of 5 and a pointer xp of type const int*. This means that the data being pointed to (the value of x) cannot be modified through xp — attempting *xp = 10; would result in a compiler error. However, the pointer itself is not constant, so xp can be reassigned to point to another variable, such as xp = &y;. In this example, incrementing x directly using ++x; is perfectly valid because the restriction applies only to modifications through the pointer, not to the original variable itself.
1int x {5};
2const int *xp {&x};
3++x;4. Constant Parameters
Declaring a function parameter as const tells both the compiler and the reader that the function will not modify the argument passed in. This not only provides a safety guarantee but also increases flexibility — the function can now accept a wider range of arguments, including constants, temporaries, and literals that a non-const reference normally cannot bind to.
1void printValue(int& x) { // non-const reference
2 std::cout << x << std::endl;
3}
4
5int a = 5;
6printValue(a); // ✅ works
7printValue(10); // ❌ error: cannot bind non-const lvalue reference to rvalue
8printValue(a + 1); // ❌ same errorA non-const reference (int&) can only bind to existing, modifiable variables — not to temporaries, literals, or const-qualified values. To make the function more flexible, you can use a const reference instead.
1void printValue(const int& x) {
2 std::cout << x << std::endl;
3}
4
5int a = 5;
6const int b = 6;
7
8printValue(a); // ✅ modifiable variable
9printValue(b); // ✅ const variable
10printValue(10); // ✅ literal
11printValue(a + 1); // ✅ temporary expressionBy marking the parameter as const, you make the function both safer and more versatile — allowing it to accept a broader range of arguments while ensuring that the original data remains unmodified.
constexpr in C++
The constexpr keyword in C++ specifies that a variable or function can be evaluated at compile time. This enables the creation of constant expressions — values known and fixed during compilation instead of runtime.
The motivation behind constexpr is performance optimization: shifting computations from runtime to compile time. Once a program is compiled, it may be executed many times, so performing calculations during compilation saves runtime overhead.
When applied to a variable, constexpr ensures that its value is a constant expression and implicitly const, meaning it cannot be modified after initialization.
constexpr functions can be executed at compile time if all their arguments are constant expressions. This makes their results usable in contexts requiring compile-time constants, such as array sizes or template arguments.
To qualify as a constexpr function, the function must:
constexpr functionsBy performing computations at compile time, constexpr improves execution speed and can reduce memory usage by removing redundant runtime calculations.
1#include <iostream>
2
3constexpr int product(int x, int y) {
4 return x * y;
5}
6
7int main() {
8 int arr[product(2, 3)] = {1, 2, 3, 4, 5, 6};
9 std::cout << arr[5];
10 return 0;
11}In the example above, the function call product(2, 3) is evaluated at compile time, eliminating the need for a runtime calculation.
A constructor declared with the constexpr specifier is known as a constexpr constructor. It enables the creation of objects that can be evaluated entirely at compile time, as long as their arguments and member initializations are constant expressions. Aconstexpr constructor is implicitly inline, reducing function call overhead and improving performance. Essentially, it allows compile-time object construction when all required information is known during compilation.
Restrictions for a constexpr constructor:
1struct Point {
2 int x, y;
3 constexpr Point(int _x, int _y) : x{_x}, y{_y} {}
4};
5
6int main() {
7 constexpr Point a[] = {{0, 0}, {1, 0}, {2, 1}};
8}In this example, the array a[] is declared as constexpr and initialized with constant values. Since both the constructor and its arguments are compile-time constants, the entire initialization is evaluated during compilation. The same constexpr constructor can still be used for runtime object creation, but in that case, it simply won't be evaluated at compile time.
Overall, constexpr is a cornerstone of modern C++, improving efficiency by enabling compile-time computation and reducing runtime overhead.
Implicit conversion of constructors
1class Point
2{
3 int x, y;
4public:
5 Point();
6 explicit Point(int);
7 Point(int, int);
8};
9
10Point p = Point{1, 2}; // explicit construction
11void foo(Point);
12foo(1); // implicit conversionIn C++, constructors that take a single argument can be used for implicit type conversions. In the example above, calling foo(1) automatically converts 1 into aPoint object using the single-argument constructor. While this can be convenient, it may also lead to unexpected conversions. To prevent this and require explicit intent from the programmer, mark such constructors with the explicit keyword.
Preprocessor Stage
Before the actual compilation begins, the C++ preprocessor runs to handle all preprocessing directives — instructions that begin with the # symbol. These directives tell the compiler how to prepare the code before translation. Common directives include #include,#define,#if,#ifdef, and #pragma.
The #include directive literally copies the contents of another file (such as a header) into your source code. This allows you to reuse code across multiple files without duplicating it.
The #define directive is used to create macros — symbolic names or expressions that are replaced by their defined values before compilation. For example, defining #define PI 3.14159 causes every instance of PIin the code to be replaced with 3.14159.
Conditional directives such as#if,#ifdef, and #ifndef allow sections of code to be included or excluded based on certain conditions. This is useful when compiling code for different platforms, configurations, or debugging modes.
Finally, #pragma provides compiler-specific instructions, such as disabling certain warnings or controlling memory alignment. While not part of the C++ standard, pragmas can help fine-tune compiler behavior.
std::span
std::span is a feature introduced in C++20 that provides a lightweight, non-owning view over a contiguous sequence of objects. It allows you to safely and efficiently work with arrays, std::vector, std::array, or any other contiguous data structure — without managing the underlying memory yourself.
A std::span does not own the memory it references. Instead, it simply provides a view (or reference) into an existing block of data. Because of this, it's important that the lifetime of the underlying data is managed externally — once the data is destroyed or goes out of scope, the std::span becomes invalid.
Before C++20, if you wanted a function to operate on a range of elements, you typically had two choices — both with drawbacks. The first approach was to use a raw pointer and a size:
1void printArray(const int* arr, std::size_t size);This approach is unsafe because it requires you to manually track the size and ensure that the pointer actually refers to a valid, contiguous memory block. There is no automatic bounds checking, so misuse can easily lead to undefined behavior.
The second approach was to use a std::vector<int>&:
1void printArray(const std::vector<int>& v);While this is safer, it ties your function specifically to std::vector. If your data is stored in a std::array<int, 10> or a C-style array int arr[10], you would need to overload your function for each container type — which leads to unnecessary code duplication.
std::span solves this problem elegantly. It provides a single, unified way to view any contiguous data structure, regardless of how the data is stored. You can think of it as a safer and more expressive version of a (pointer, size) pair.
1#include <span>
2#include <iostream>
3#include <vector>
4#include <array>
5
6void printSpan(std::span<const int> data) {
7 for (auto x : data)
8 std::cout << x << " ";
9 std::cout << "\n";
10}
11
12int main() {
13 int arr[] = {1, 2, 3, 4, 5};
14 std::vector<int> vec = {10, 20, 30, 40, 50};
15 std::array<int, 3> stdarr = {7, 8, 9};
16
17 printSpan(arr); // works
18 printSpan(vec); // works
19 printSpan(stdarr); // works
20}Internally, a std::span<T> is extremely lightweight — it typically contains just two members:
T* ptr_; — a pointer to the first element, and std::size_t size_; — the number of elements in the span.
Since std::span does not own its data, it behaves much like a reference or view. If the underlying data is destroyed, the std::span becomes invalid — similar to a dangling pointer. For this reason, std::span is best used for short-lived operations such as function parameters, where the data's lifetime is guaranteed during the function call.
Execution Policy in C++
In C++, algorithms such as std::sort or std::for_each can be executed under different execution policies. These policies tell the compiler how the algorithm should run — either sequentially, parallelly, or with vectorized optimizations that use CPU hardware efficiently.
The four standard execution policies provided in the std::execution namespace are:sequenced_policy, parallel_policy, parallel_unsequenced_policy, and unsequenced_policy.
std::execution::sequenced_policy
This policy runs the algorithm sequentially, one element at a time, using a single thread. It's the default mode when no execution policy is specified. Because it runs step-by-step, it avoids data races and is ideal for smaller tasks where parallelization overhead would not bring performance benefits.
1std::vector<int> v = {5, 2, 3, 1, 4};
2std::sort(std::execution::seq, v.begin(), v.end());In this example, std::execution::seq ensures the sorting operation is executed strictly in order, one element at a time.
std::execution::parallel_policy
This policy allows the algorithm to run in parallel using multiple threads. The data may be divided into chunks and processed simultaneously on multiple CPU cores, which can significantly improve performance for large datasets.
1std::vector<int> v1 = {1, 2, 3, 4, 5};
2std::vector<int> v2(5);
3
4std::transform(std::execution::par, v1.begin(), v1.end(), v2.begin(),
5 [](int x) { return x * x; });Here, std::execution::par lets std::transform run on multiple threads, potentially speeding up the computation. However, the benefit depends on the hardware and task size — for small inputs, parallel setup overhead may outweigh the performance gain.
std::execution::parallel_unsequenced_policy
This policy combines parallel execution with unsequenced (unordered) execution. It allows the algorithm to run in parallel across multiple threads and use vectorization (SIMD – Single Instruction, Multiple Data) within those threads. The order of execution is not guaranteed, meaning the results may be non-deterministic if your function depends on order.
1std::vector<int> v = {1, 2, 3, 4, 5};
2std::for_each(std::execution::par_unseq, v.begin(), v.end(),
3 [](int x) { std::cout << x << " "; });The policy std::execution::par_unseq uses both threading and SIMD optimizations where possible. It provides maximum performance but should only be used for functions that are thread-safe and independent of execution order.
std::execution::unsequenced_policy
This policy runs the algorithm vectorized on a single thread using SIMD instructions. Unlike parallel_policy, it does not use multiple threads, but still performs multiple operations simultaneously at the CPU instruction level.
1std::vector<int> v = {1, 2, 3, 4, 5};
2std::for_each(std::execution::unseq, v.begin(), v.end(),
3 [](int x) { std::cout << x << " "; });This can offer fast performance on CPUs that support SIMD vectorization, but the order of execution is not guaranteed. It's efficient for independent computations that can safely run in any order without causing data races.
In summary, execution policies give you control over how algorithms use hardware resources. Use seq for predictable, ordered operations, par for multithreaded workloads, par_unseq for maximum performance through threading + SIMD, and unseq for single-threaded-but-vectorized execution.
SIMD (Single Instruction Multiple Data)
SIMD, which stands for Single Instruction Multiple Data, is a powerful technique that allows a single CPU instruction to operate on multiple data elements simultaneously. Instead of processing one element at a time, SIMD uses special vector registers that hold several values at once, enabling efficient parallel computation within a single core.
For example, consider a simple loop that adds two arrays element-wise:
1for (int i = 0; i < 4; ++i)
2 a[i] = b[i] + c[i];Without SIMD, the CPU executes each addition in sequence. Conceptually, it performs the following steps one by one:
1load b[0] into a register
2load c[0] into a register
3add them
4store result in a[0]
5
6load b[1]
7load c[1]
8add
9store a[1]
10
11load b[2] ...
12load c[2] ...
13...This means the processor performs four separate sets of load,add, and store instructions — a total of around 12–16 instructions to process just four elements.
Modern CPUs, however, include vector registers that can hold multiple values at once (for instance, four 32-bit floats in a single 128-bit SSE register). With SIMD instructions, the same loop can be executed in parallel:
1register b_vec = load 4 floats from b[0..3]
2register c_vec = load 4 floats from c[0..3]
3register sum_vec = add b_vec + c_vec
4store sum_vec into a[0..3]Now, instead of four separate load–add–store sequences, the CPU performs the entire operation in roughly three instructions: one vector load, one vector addition, and one vector store. This drastically reduces instruction count and improves throughput without requiring multithreading.
SIMD is widely used in performance-critical applications such as image processing, physics simulations, and numerical computing, where the same operation is applied to large blocks of data. Libraries likeEigen, OpenCV, and NumPy take advantage of SIMD to accelerate computation behind the scenes.
Return Value Optimization (RVO)
Return Value Optimization (RVO) is a compiler optimization in C++ that eliminates unnecessary copy or move operations when a function returns an object by value. Instead of constructing a temporary object and then copying or moving it into the caller's storage, the compiler is allowed to construct the object directly in the caller's memory location.
By avoiding extra object constructions and destructions, RVO significantly improves performance and reduces memory traffic. Modern C++ code relies heavily on RVO, making returning objects by value both efficient and idiomatic.
There are two commonly discussed forms of RVO:
Unnamed Return Value Optimization (URVO)
URVO occurs when a function returns a prvalue (a temporary object that is not explicitly named). Since C++17, this optimization is guaranteed by the language standard. This means the object is constructed directly in the caller's storage, and no copy or move constructor is ever invoked, even if they have observable side effects.
1struct MyObject {
2 MyObject() { /* ... */ }
3 MyObject(const MyObject&) { /* copy constructor */ }
4};
5
6MyObject createObject() {
7 return MyObject(); // prvalue, URVO is guaranteed in C++17+
8}
9
10int main() {
11 MyObject obj = createObject();
12 return 0;
13}Named Return Value Optimization (NRVO)
NRVO applies when a function returns a named local variable. In this case, the compiler may optimize away the copy or move by constructing the local variable directly in the caller's storage. Unlike URVO, NRVO is not guaranteed, but most modern compilers will apply it whenever possible.
1struct MyObject {
2 MyObject() { /* ... */ }
3 MyObject(const MyObject&) { /* copy constructor */ }
4};
5
6MyObject createNamedObject() {
7 MyObject localObj; // named local variable
8 return localObj; // NRVO may be applied
9}
10
11int main() {
12 MyObject obj = createNamedObject();
13 return 0;
14}