3. A Quick Tour of Modern C++ (C++23)#
Welcome! This tutorial is a fast-paced introduction to some powerful and modern C++ features and best practices. We’ll focus on writing cleaner, more efficient, and more readable code.
Prerequisites: You’ll need a modern C++ compiler that supports C++23 features, such as GCC 14+ or Clang 17+. To compile the examples, use the -std=c++23 flag. For example:
g++ -std=c++23 my_program.cpp
3.1. Best Practices: The Foundation#
Writing good code isn’t just about making it work; it’s about making it maintainable and clear for yourself and others.
3.1.1. Functions & Don’t Repeat Yourself (DRY)#
Instead of writing the same logic multiple times, encapsulate it in a function. This makes your code reusable, easier to debug, and more readable.
Example: Imagine that you are simulating a particle system and need to compute the kinetic energy. You might write something like
double ke1 = 0.5 * m1 * v1 * v1;
double ke2 = 0.5 * m2 * v2 * v2;
which is duplicating code. In these cases, and when you are sure that will use this computations later, better use a function
double kineticEnergy(double mass, double velocity) {
return 0.5 * mass * velocity * velocity;
}
...
double ke1 = kineticEnergy(m1, v1);
double ke2 = kineticEnergy(m2, v2);
If later you find a bug in your kinetic energy computation, you will have to fix only a specific line in your code, not several lines.
Modern c++ even allows you to use pipes,
#include <iostream>
#include <vector>
#include <ranges>
#include <print>
// A clear, reusable function adhering to DRY
double celsius_to_fahrenheit(double celsius) {
return (celsius * 9.0 / 5.0) + 32.0;
}
int main() {
// Real-world data: sensor readings, with an error value of -999.0
std::vector<double> sensor_readings_celsius = {22.5, 23.1, -999.0, 21.9, 22.7, 23.5};
// A lambda to define what "valid" means in this context
auto is_valid_reading = [](double temp) {
return temp > -273.15; // A simple physical check
};
// Chain operations together with pipes: filter, then transform
auto processed_readings_fahrenheit = sensor_readings_celsius
| std::views::filter(is_valid_reading)
| std::views::transform(celsius_to_fahrenheit);
// Use std::println for clean output
std::println("Processed sensor readings (Fahrenheit):");
for (double temp : processed_readings_fahrenheit) {
std::println("{:.2f}°F", temp);
}
return 0;
}
3.1.2. Avoid using namespace std;#
Avoiding using namespace std; prevents naming conflicts. When you use the entire std namespace, you might unintentionally use a name from std that is the same as a name you defined in your code, leading to unexpected behavior or compilation errors. It’s better to explicitly qualify names with std::, like std::cout and std::vector.
3.1.3. Use Standard C++ Headers#
Always use the modern, standard C++ headers, which don’t have a .h extension.
Bad (C-style/deprecated):
<stdio.h>,<stdlib.h>,<math.h>, etc. They are not inside thestd::namespace and are not guaranteed to fulfill the given c++ standard.Good (C++ style):
<iostream>,<vector>,<string>,<random>,<cmath>
Always check what the standard headers give you: go to https://cppreference.com
3.2. Printing with std::print (C++23)#
std::print is a new, modern way to print to the console. It’s often faster and safer than std::cout and uses Python-style formatting, which is very powerful.
3.2.1. Basic Printing & Formatting#
You use {} as placeholders for your variables. It’s simple and intuitive.
#include <print>
#include <string>
#include <vector>
int main() {
std::string name = "Alex";
int age = 30;
double height = 1.75;
// Simple printing
std::print("Hello, world!\n");
// Printing variables
std::print("Name: {}, Age: {}\n", name, age);
// Formatting floating-point numbers (e.g., scientific)
std::println("Height: {:.16e} meters", height);
// Printing a container (like a vector) requires std::format
std::vector<int> numbers = {10, 20, 30};
std::println("Numbers: {}\n", numbers);
std::println("Numbers: {::>05}\n", numbers);
return 0;
}
You can also use it to print to a file
#include <print>
#include <fstream> // For file streams
#include <string>
int main() {
std::ofstream my_file("output.txt"); // Opens a file to write to
if (!my_file) {
std::print("Error opening file!\n");
return 1;
}
std::string user = "Maria";
int score = 95;
// Print to the file stream instead of the console
std::print(my_file, "User: {}, Score: {}\n", user, score);
my_file.close(); // Good practice to close the file
std::print("Data successfully written to output.txt\n");
return 0;
}
Another example, with formatting options
#include <print>
#include <fstream>
#include <vector>
#include <string>
struct Measurement {
std::string location;
double temperature;
double pressure;
double humidity;
};
int main() {
std::vector<Measurement> data = {
{"New York", 22.5, 1013.25, 68.0},
{"Los Angeles", 28.7, 1015.1, 45.0},
{"Chicago", 18.3, 1009.8, 72.0},
{"Houston", 31.2, 1014.7, 85.0}
};
// Console output with formatting
std::println("┌{:─<15}┬{:─<8}┬{:─<10}┬{:─<10}┐", "", "", "", "");
std::println("│{:^15}│{:^8}│{:^10}│{:^10}│", "Location", "Temp°C", "Press(hPa)", "Humid%");
std::println("├{:─<15}┼{:─<8}┼{:─<10}┼{:─<10}┤", "", "", "", "");
for (const auto& m : data) {
std::println("│{:<15}│{:>6.1f}°C│{:>10.1f}│{:>9.0f}%│",
m.location, m.temperature, m.pressure, m.humidity);
}
std::println("└{:─<15}┴{:─<8}┴{:─<10}┴{:─<10}┘", "", "", "", "");
// File output
std::ofstream file("weather_data.csv");
if (file) {
std::print(file, "Location,Temperature_C,Pressure_hPa,Humidity_Percent\n");
for (const auto& m : data) {
std::print(file, "{},{:.1f},{:.1f},{:.0f}\n",
m.location, m.temperature, m.pressure, m.humidity);
}
}
return 0;
}
3.2.2. Random Number Generation with #
Forget the old C-style rand(). It produces low-quality random numbers and isn’t thread-safe. The modern C++ approach in the
Seed Source (std::random_device): A high-quality, non-deterministic source of randomness to "seed" our engine. Think of it as the initial unpredictable push that starts a sequence.
Engine (std::mt19937): The workhorse that generates a long sequence of pseudo-random numbers from the initial seed. It's fast and has excellent statistical properties.
Distribution (std::uniform_int_distribution, etc.): This shapes the raw output from the engine into the range and distribution you need (e.g., integers from 1 to 6, or real numbers from 0.0 to 1.0).
Example: Let’s simulate a Monte-Carlo computation for \(\pi\):
REF: https://en.wikipedia.org/wiki/Monte_Carlo_method?useskin=vector
#include <print>
#include <random>
#include <chrono>
#include <cmath>
double estimatePi(int num_samples, unsigned seed = 42) {
std::mt19937 engine(seed); // Fixed seed for reproducibility
std::uniform_real_distribution<double> dist(0.0, 1.0);
int points_in_circle = 0;
for (int i = 0; i < num_samples; ++i) {
double x = dist(engine);
double y = dist(engine);
if (x*x + y*y <= 1.0) {
points_in_circle++;
}
}
return 4.0 * points_in_circle / num_samples;
}
int main() {
std::println("Monte Carlo π Estimation:");
std::println("True value: {:.10f}", M_PI);
for (int samples : {1000, 10000, 100000, 1000000}) {
double pi_est = estimatePi(samples);
double error = std::abs(pi_est - M_PI);
std::println("Samples: {:>7}, π ≈ {:.6f}, Error: {:.6f}",
samples, pi_est, error);
}
return 0;
}
3.2.3. Exercise: Gamma Distribution, File I/O, and PDF Calculation#
Here’s an exercise to practice generating random numbers, working with file input/output, and performing some basic statistical analysis:
Task:
Generate Random Numbers: Use the
<random>library to generate a large number of random samples from a Gamma distribution. Choose appropriate parameters (alpha and beta) for the distribution.Write to File: Write the generated random numbers to a text file, with one number per line. Use
std::printor file streams (std::ofstream).Read from File: Read the numbers back from the file into a
std::vectoror other suitable container. Use file streams (std::ifstream).Compute PDF: Although directly computing the PDF from samples is tricky, you can approximate it by creating a histogram of the data.
Determine the range of your data.
Divide the range into a suitable number of bins.
Count how many numbers fall into each bin.
Normalize the counts to get an estimated probability density for each bin.
Print Results: Print some summary statistics of the read data (e.g., mean, variance) and perhaps the bin counts or normalized counts from your approximate PDF calculation.
Hints:
You’ll need
<random>for generation,<fstream>for file I/O, and potentially<vector>,<cmath>, and<numeric>for data storage and calculations.Remember to include the necessary headers and handle potential file opening errors.
For the PDF approximation, consider using algorithms from
<algorithm>or writing a simple loop to iterate through your data and bins.
This exercise will help you solidify your understanding of random number generation, file handling, and basic data processing in C++.
3.2.4. Timing Code with std::chrono#
Need to know how long a piece of code takes to run? std::chrono is the modern, portable, and precise way to measure time.
The basic pattern is:
Get the time before your code runs (start).
Run your code.
Get the time after your code finishes (end).
Calculate the difference (end - start).
Convert the duration into the units you want (e.g., milliseconds, microseconds).
Example: Let’s time a simple loop.
#include <print>
#include <chrono> // The main header!
#include <vector>
void some_long_task() {
// Simulate work by creating and filling a large vector
std::vector<int> v;
for (int i = 0; i < 1'000'000; ++i) {
v.push_back(i);
}
}
int main() {
// 1. Get the start time
auto start = std::chrono::high_resolution_clock::now();
// 2. Run the code you want to measure
some_long_task();
// 3. Get the end time
auto end = std::chrono::high_resolution_clock::now();
// 4. Calculate the duration
auto duration = end - start;
// 5. Convert to desired units and print
auto duration_ms = std::chrono::duration_cast<std::chrono::milliseconds>(duration);
std::print("The task took {} milliseconds.\n", duration_ms.count());
}
Another example: benchmarking sorting algorithms
#include <print>
#include <chrono>
#include <vector>
#include <algorithm>
#include <random>
#include <string>
template<typename Func>
auto benchmark(const std::string& name, Func&& func) {
auto start = std::chrono::high_resolution_clock::now();
func();
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::println("{}: {} μs", name, duration.count());
return duration;
}
int main() {
// Generate realistic dataset sizes
for (int size : {1000, 10000, 100000}) {
std::println("\nBenchmarking with {} elements:", size);
// Create test data
std::vector<int> data(size);
std::random_device rd;
std::mt19937 gen(rd());
std::iota(data.begin(), data.end(), 1);
std::shuffle(data.begin(), data.end(), gen);
// Test different sorting algorithms
auto data_copy = data;
benchmark("std::sort", [&]() {
std::sort(data_copy.begin(), data_copy.end());
});
data_copy = data;
benchmark("std::stable_sort", [&]() {
std::stable_sort(data_copy.begin(), data_copy.end());
});
data_copy = data;
benchmark("std::partial_sort (50%)", [&]() {
std::partial_sort(data_copy.begin(),
data_copy.begin() + data_copy.size()/2,
data_copy.end());
});
}
return 0;
}
3.3. More about functions#
3.3.1. Function overloading and templates#
This example demonstrates two ways to achieve similar functionality for different data types:
Function Overloading: We define multiple functions with the same name (
sum) but different parameter types. The compiler chooses the correct function based on the arguments provided.Templates: A template function (
genericSum) allows you to write a single function definition that can work with different data types. The compiler generates the appropriate code for the specific types used when the function is called. Templates are generally preferred for their conciseness and ability to work with custom types.
#include <iostream>
// Function Overloading
int sum(int a, int b) {
return a + b;
}
double sum(double a, double b) {
return a + b;
}
// Template for Sum
template <typename T>
T genericSum(T a, T b) {
return a + b;
}
int main() {
// Using overloaded functions
std::cout << "Sum of integers (overloaded): " << sum(5, 3) << std::endl;
std::cout << "Sum of doubles (overloaded): " << sum(5.5, 3.2) << std::endl;
// Using template function
std::cout << "Sum of integers (template): " << genericSum(10, 20) << std::endl;
std::cout << "Sum of doubles (template): " << genericSum(10.5, 20.3) << std::endl;
return 0;
}
Cell In[1], line 3
// Function Overloading
^
SyntaxError: invalid syntax
3.3.2. Lambda Functions#
A lambda function (or lambda expression) is a concise way to define an anonymous function object directly where you need it. They are particularly useful for short, simple operations, especially when used with algorithms or ranges.
The basic syntax of a lambda is:
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
// Use a lambda to print even numbers
std::cout << "Even numbers:";
std::for_each(numbers.begin(), numbers.end(), [](int n) {
if (n % 2 == 0) {
std::cout << " " << n;
}
});
std::cout << std::endl;
// Use a lambda with capture
int threshold = 3;
std::cout << "Numbers greater than " << threshold << ":";
std::for_each(numbers.begin(), numbers.end(), [threshold](int n) {
if (n > threshold) {
std::cout << " " << n;
}
});
std::cout << std::endl;
return 0;
}
3.3.3. Functors (Function Objects)#
A functor, or function object, is a class that overloads the operator(). This allows objects of the class to be called like functions. Functors are often used in C++ Standard Library algorithms and can maintain state between calls, unlike simple functions or lambdas (without captures).
#include <iostream>
#include <vector>
#include <algorithm>
// Define a functor to multiply by a value
class Multiplier {
private:
int factor;
public:
Multiplier(int f) : factor(f) {}
int operator()(int x) const {
return x * factor;
}
};
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};
std::vector<int> multiplied_numbers(numbers.size());
// Use the functor with std::transform
Multiplier multiplyBy5(5);
std::transform(numbers.begin(), numbers.end(), multiplied_numbers.begin(), multiplyBy5);
std::cout << "Original numbers:";
for (int n : numbers) {
std::cout << " " << n;
}
std::cout << std::endl;
std::cout << "Multiplied numbers:";
for (int n : multiplied_numbers) {
std::cout << " " << n;
}
std::cout << std::endl;
return 0;
}
Here’s a table comparing Lambda Functions, Templates, and Functors:
Feature |
Lambda Function |
Template |
Functor (Function Object) |
|---|---|---|---|
Definition |
Anonymous function object defined inline |
Code pattern for generic programming |
Class that overloads |
Syntax |
Concise |
|
Class definition + |
Use Case |
Short, simple, often with algorithms/ranges |
Generic functions/classes working with different types |
More complex operations, statefulness, with algorithms |
State |
Can capture variables from scope |
No state maintained within the template definition itself |
Can maintain state within the class instance |
Type Deduction |
Automatic (often) |
Automatic (often) |
Based on the class type |
Flexibility |
Good for simple tasks |
High, works with various types |
High, can have complex logic and state |
Readability |
Good for simple tasks, can be less readable for complex ones |
Good for generic code, can be complex for advanced cases |
Can be more verbose than lambdas, good for encapsulating logic |
3.4. Additional C++ Practices#
Here are a few more essential practices in modern C++ development: logging, testing, and package management.
3.4.1. Logging with spdlog#
spdlog is a fast, header-only logging library. It’s simple to use and very efficient.
To use spdlog, you typically add it as a dependency using a package manager (discussed later) and include the necessary header.
#include <spdlog/spdlog.h>
int main() {
// Basic logging messages
spdlog::info("Welcome to spdlog!");
spdlog::warn("This is a warning message.");
spdlog::error("This is an error message.");
// Logging with formatting
spdlog::critical("Factorial of {} is {}", 5, 120);
return 0;
}
3.4.2. Testing with Catch2#
Catch2 is a modern, C++-native test framework for unit tests, TDD and BDD. It’s easy to get started with and provides powerful features for writing and organizing tests.
The code demonstrates a simple test case for a factorial function:
#define CATCH_CONFIG_MAIN: This macro tells Catch2 to provide its ownmain()function, which is the entry point for the test executable. You should only define this in one.cppfile.#include <catch2/catch_test_macros.hpp>: Includes the necessary header for Catch2 macros likeTEST_CASEandREQUIRE.int factorial(int number) { ... }: The function being tested.TEST_CASE( "Factorials are computed", "[factorial]" ) { ... }: Defines a test case named “Factorials are computed” with the tag “[factorial]”.REQUIRE(...): These are assertions that check if the condition inside the parentheses is true. If anyREQUIREfails, the test case fails. The code includes assertions for the factorial of 1, 2, 3, and 10.
#define CATCH_CONFIG_MAIN // This tells Catch to provide a main() - only do this in one cpp file
#include <catch2/catch_test_macros.hpp>
// Function to be tested
int factorial(int number) {
return number <= 1 ? number : factorial(number - 1) * number;
}
TEST_CASE( "Factorials are computed", "[factorial]" ) {
REQUIRE( factorial(1) == 1 );
REQUIRE( factorial(2) == 2 );
REQUIRE( factorial(3) == 6 );
REQUIRE( factorial(10) == 3628800 );
}
3.4.3. Package Management#
Managing external libraries (dependencies) in C++ can be complex. Unlike languages with built-in package managers (like Python’s pip or Node.js’s npm), C++ traditionally relied on manual compilation and linking. Modern package managers simplify this process significantly.
Two popular C++ package managers are vcpkg (from Microsoft) and Conan. They help automate downloading, building, and integrating libraries into your project.
While the specifics of using each manager vary, the general idea is:
Install the package manager: Download and set up vcpkg or Conan on your system.
Install dependencies: Use the package manager’s command-line interface to specify the libraries you need for your project (e.g.,
vcpkg install spdlogorconan install Catch2).Integrate with your build system: Configure your build system (like CMake) to find and use the libraries installed by the package manager. Both vcpkg and Conan provide CMake integration.
Here’s a brief comparison of some alternatives:
Feature |
vcpkg |
Conan |
System Package Managers (e.g., apt, brew) |
Manual Installation |
|---|---|---|---|---|
Philosophy |
Port-based, builds from source (primarily) |
Recipe-based, supports binaries & source |
Binary distribution (primarily) |
Build from source or use pre-built bins |
Platform Support |
Good (Windows, Linux, macOS) |
Excellent (Windows, Linux, macOS, others) |
Varies by OS |
Manual, can be complex |
Integration |
Excellent with CMake |
Excellent with CMake, MSBuild, others |
Varies, often requires manual config |
Entirely manual |
Binary Management |
Basic caching |
Robust, supports creating/consuming binaries |
Standard OS package management |
Manual |
Dependency Resolution |
Good |
Excellent, handles complex graphs |
Basic |
Manual |
Ease of Use |
Relatively easy to get started with |
Can have a steeper learning curve initially |
Easy for available packages |
Complex, error-prone |
Choosing between vcpkg and Conan often depends on your project’s needs, team preferences, and the specific libraries you require. System package managers are convenient for widely used libraries but may not have the latest versions or support all libraries. Manual installation is generally discouraged for complex projects due to its difficulty and maintenance overhead.
3.5. APPENDIX : Version Control with Git#
Git is a free and open-source distributed version control system designed to handle everything from small to very large projects with speed and efficiency. It’s essential for tracking changes in your code, collaborating with others, and managing different versions of your project.
Here’s a table of some of the most important and common Git commands:
Command |
Description |
Scope |
|---|---|---|
|
Initializes a new Git repository in the current directory. |
Local |
|
Copies a remote repository to your local machine. |
Remote/Local |
|
Stages changes for the next commit. Use |
Local |
|
Shows the status of changes as untracked, modified, or staged. |
Local |
|
Shows changes not yet staged. Use |
Local |
|
Records staged changes to the repository with a descriptive message. |
Local |
|
Shows the commit history. |
Local |
|
Lists, creates, or deletes branches. |
Local |
|
Switches to a different branch. Use |
Local |
|
Merges changes from one branch into the current branch. |
Local |
|
Fetches changes from the remote repository and merges them into the current branch. |
Remote/Local |
|
Uploads local commits to the remote repository. |
Remote/Local |
|
Adds a remote repository URL. |
Remote |
|
Downloads commits, files, and refs from a remote repository into your local repo. |
Remote |
Git workflow overview
+----------------+
| Working Dir |
| (your files) |
+----------------+
|
git add |
v
+----------------+
| Staging Area |
| (index) |
+----------------+
|
git commit -m |
v
+----------------+
| Local Repo |
| (commits) |
+----------------+
|
git push |
v
+-------------------+
| Remote Repo |
| (GitHub, gitlab,) |
+-------------------+
Working Directory → Where you edit files.
Staging Area → A “preview” list of changes to commit.
Local Repository → Permanent record of commits on your machine.
Remote Repository → Shared version hosted online (GitHub, GitLab, Bitbucket).
3.6. Git Branches#
3.6.1. What is a Branch?#
A branch in Git is a pointer to a line of development.
Every repository starts with one branch (usually called
main).When you create a branch, you’re making a copy of the project history at that point.
You can make commits on that branch independently of the main line of development.
Later, you can merge your branch back into
main(or another branch).
3.6.2. Why Use Branches?#
https://learngitbranching.js.org/
Safe experimentation
Try new ideas without breaking the stable code.
If it doesn’t work, just delete the branch.
Parallel development
Different people (or teams) can work on different features at the same time.
Organized workflow
Common convention:
main→ stable, production-ready code.dev→ active development.feature-*→ new features.bugfix-*→ patches and fixes.
Easy context switching
Move between tasks without losing progress.
Collaboration
Branches are the basis for pull requests or merge requests on platforms like GitHub/GitLab.
3.6.3. Branch Commands#
git branch # list branches
git branch <name> # create a new branch
git checkout <name> # switch to a branch
git checkout -b <name> # create and switch to a new branch
git merge <branch> # merge a branch into the current one
git branch -d <name> # delete a branch (safe, if merged)
git push -u origin <branch> # push branch to remote
3.6.4. Typical branch workflow#
git checkout main
git pull
git checkout -b feature-login # create new branch
# ... work, edit, add, commit ...
git push -u origin feature-login
git checkout main
git merge feature-login
git push