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 the std:: 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 header is far superior. It involves three components:

    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\):

https://upload.wikimedia.org/wikipedia/commons/d/d4/Pi_monte_carlo_all.gif

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:

  1. 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.

  2. Write to File: Write the generated random numbers to a text file, with one number per line. Use std::print or file streams (std::ofstream).

  3. Read from File: Read the numbers back from the file into a std::vector or other suitable container. Use file streams (std::ifstream).

  4. 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.

  5. 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:

  1. 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.

  2. 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 operator()

Syntax

Concise [](){}

template <typename T> ...

Class definition + operator() overload

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 own main() function, which is the entry point for the test executable. You should only define this in one .cpp file.

  • #include <catch2/catch_test_macros.hpp>: Includes the necessary header for Catch2 macros like TEST_CASE and REQUIRE.

  • 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 any REQUIRE fails, 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:

  1. Install the package manager: Download and set up vcpkg or Conan on your system.

  2. Install dependencies: Use the package manager’s command-line interface to specify the libraries you need for your project (e.g., vcpkg install spdlog or conan install Catch2).

  3. 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

git init

Initializes a new Git repository in the current directory.

Local

git clone <url>

Copies a remote repository to your local machine.

Remote/Local

git add <file>

Stages changes for the next commit. Use . to stage all changes.

Local

git status

Shows the status of changes as untracked, modified, or staged.

Local

git diff

Shows changes not yet staged. Use --staged to see staged changes.

Local

git commit -m "message"

Records staged changes to the repository with a descriptive message.

Local

git log

Shows the commit history.

Local

git branch

Lists, creates, or deletes branches.

Local

git checkout <branch>

Switches to a different branch. Use -b <new-branch> to create and switch.

Local

git merge <branch>

Merges changes from one branch into the current branch.

Local

git pull

Fetches changes from the remote repository and merges them into the current branch.

Remote/Local

git push

Uploads local commits to the remote repository.

Remote/Local

git remote add origin <url>

Adds a remote repository URL.

Remote

git fetch

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).

Check https://git-scm.com/cheat-sheet

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/

  1. Safe experimentation

    • Try new ideas without breaking the stable code.

    • If it doesn’t work, just delete the branch.

  2. Parallel development

    • Different people (or teams) can work on different features at the same time.

  3. Organized workflow

    • Common convention:

      • main → stable, production-ready code.

      • dev → active development.

      • feature-* → new features.

      • bugfix-* → patches and fixes.

  4. Easy context switching

    • Move between tasks without losing progress.

  5. 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