7.3. Useful C++ idioms#

Important

For RSP students, please work through the provided C++ manual first to ensure you are familiar with the basics of the C++ syntax and the C++ build system we use (CMake, compiling, linking).

C++ was first standardized in 1998 and that version of C++ is called C++98. Between 2003 and 2020 many new features were introduced in the C++ language and its Standard Library, leading to different C++ versions, e.g., C++03 (from 2003), C++11, C++14, C++17, etc.

While a lot of current C++ code (and documentation) relies on core language features available since C++98, it is beneficial to know several handy more modern language features that appear in many ROS 2 C++ examples.

This section provides a short overview of some useful modern C++ idioms, namely

7.3.1. The auto keyword#

In C++ a variable’s type always needs to be defined when the variable is declared:

#include <iostream>
#include <string>

std::string greet(std::string name) { 
    return "hello " + name;
}

int main()
{
    int x = 1;
    double g = 9.8;
    std::string message = greet("Alice");

    std::cout << "x = " << x << std::endl;
    std::cout << "g = " << g << std::endl;
    std::cout << message << std::endl;

    return 0;
}

In cases where the compiler can deduce the correct type, you can use the auto keyword instead of the type, so the above can also be achieved with:

#include <iostream>
#include <string>

auto greet(std::string name) { 
    return "hello " + name;
}

int main()
{
    auto x = 1;
    auto g = 9.8;
    auto message = greet("Alice");

    std::cout << "x = " << x << std::endl;
    std::cout << "g = " << g << std::endl;
    std::cout << message << std::endl;

    return 0;
}

The auto keyword can also be used to write more compact for-loops over containers, such as std::vector:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};

    // Using auto with range-based for loop
    for (auto element : vec) {
        std::cout << element << " ";
    }
    std::cout << std::endl;

    return 0;
}

Important

Be aware of the following caveats:

  1. auto is not a new data type, nor will it make variables behave as in Python where you can change an existing variable’s type to a different type or class. Each variable still has a type as before (e.g. int, double or some class), you just don’t have to type it out explicitly anymore:

int main() {
    auto x = 1; // ok, compiler will infer x is of type int
    x = 2;      // no problem, assigning 2 to a type int
    x = "foo";  // will NOT work, cannot assign a string to a type int
    return 0;
}
  1. the compiler might not be able to figure out what you intended auto to be a stand-in for: e.g., auto s = "hello"; will make s of the type const char * and not of std::string;

  2. You cannot just put auto everywhere instead of a type, e.g. using auto in function arguments will not work because the compiler would not be able to deduce the type of the argument of the function:

// will NOT work
auto greet(auto name) { 
    return "hello " + name;
}

7.3.2. Function objects with std::function#

In C++11, the Standard Library introduced several useful tools for handling functions and function-like objects. These tools provide greater flexibility and simplicity when working with callbacks, event handlers, or any scenario where you need to pass around functions. Accordingly, there are now several common ways for defining a callback function in C++:

  • Use a regular C++ function.

  • Create a function with std::bind, e.g., to use a class member function as a callback. This will be discussed in Section 7.3.3.

  • Use a lambda function, a C++11 syntax to define anonymous functions within functions. This will be discussed in Section 7.3.4.

Unsurprisingly, you will encounter these concepts a lot when working later with the ROS 2 C++ API.

To facilitate working with these different types of functions, std::function provides a unified wrapper. It can store, copy, and call any callable target (like functions, lambda expressions, bind expressions, or other function objects) with a specified function signature (recall the signature consists of a function return type and argument types).

To use std::function include the <functional> header. The general syntax for std::function to refer to a function with arguments arg_types and return type return_type is std::function<return_type(arg_types)>. Here is a concrete example:

#include <iostream>
#include <functional>

// A simple function
void print_sum(int a, int b) {
    std::cout << (a + b) << std::endl;
}

int main() {
    // Declare a std::function
    std::function<void(int, int)> func;

    // Assign a function to std::function
    func = print_sum;

    // Call the function
    func(3, 4); // Outputs 7

    return 0;
}

Since we declared func as a variable of type std::function<void(int, int)>, it can be used to refer to any function with arguments int, int and return type void, such as the void print_sum(int a, int b) function here (note: the names of the arguments do not matter, only the types). Calling this func object will just call the function that was assigned to it with the same argument values. So func(3, 4) simply calls print_sum(3, 4).

Since std::function objects can be passed around, we can also use them in the function signature of functions that need to use a callback. In this example the use_callback function has a std::function argument, which means we can call it on any regular C++ function with the required function signature:

#include <iostream>
#include <functional>

void print_sum(int a, int b) {
    std::cout << (a + b) << std::endl;
}

void use_callback(const std::function<void(int, int)>& callback) {
    callback(1, 2);
    callback(3, 4);
    callback(5, 6);
}

int main() {
    use_callback(print_sum); // will print 3, 7, 11
    
    return 0;
}

7.3.3. Binding function arguments with std::bind#

std::bind allows you to “freeze” some arguments of a function, creating a new function that requires fewer arguments. Essentially, it creates a new function object by “binding” values for some arguments of a given function. The resulting function object stores the given argument values and can then be used just as a regular function.

The basic syntax is:

#include <functional>

// Basic usage
auto new_function = std::bind(function, arg1, arg2, ..., _N);

In the above function is the function to bind, and arg1, arg2, …, _N are its arguments to bind. _N is a placeholder that represents the position of unbound arguments in the new function.

We could declare new_function to be a std::functional object type explicitly, but it is often more convenient to use the auto keyword and let the compiler deduce the correct type returned by std::bind.

Here is an example of a simple add(a, b) function with two arguments being reduced to a function (object) add_ten(b) with one argument by binding the value of a to 10:

#include <iostream>
#include <functional>

// A simple function
int add(int a, int b) {
    return a + b;
}

int main() {
    // Bind the `add` function, setting its first argument to 10.
    // Its second argument will become the first (_1) argument of the new function.
    auto add_ten = std::bind(add, 10, std::placeholders::_1);

    // Now add_ten only takes one argument
    std::cout << add_ten(5) << std::endl; // Outputs 15

    return 0;
}

A very useful feature of std::bind is that it also allows you to bind a class member function to a specific object of that class, enabling their use as callbacks in functions that accept only standard function objects (e.g., std::function). This is particularly useful when working with libraries or APIs that require callback functions, such as the ROS C++ client library.

By using std::bind, you can create a callable object that encapsulates both the member function and the instance of the class it should operate on. This callable object can then be passed to functions expecting a callback, ensuring that the member function is called on the correct instance when the callback is triggered. Here is an example:

#include <iostream>
#include <functional>

// A simple class with an internal state (counter) and a member function
class Printer {
    int count_;
public:
    void print_str(const std::string& data) {
        std::cout << "Message " << count_ << ": " << data << std::endl;
        count_++;
    }
};

// A function that calls some callback function for strings
void use_callback(const std::function<void(const std::string&)>& callback) {
    callback("First time calling callback!");
    callback("Second time calling callback!");
    callback("Third time calling callback!");
}

int main() {
    Printer printer;
    
    // Using std::bind to create a (const!) function object for the member function
    auto bound_print_str = std::bind(&Printer::print_str, &printer, std::placeholders::_1);
    
    // Passing the bound member function as a callback now WILL work
    use_callback(bound_print_str);
    
    return 0;
}

In this example use_callback accepts a std::function object for functions that with a single std::string argument. It then calls this callback three times with some input. With the callback design, use_callback does not need to know what the callback exactly does, nor if it is a regular C++ function or a class member function.

Here we use std::bind to pass the print_str member function for the printer object as the callback. Each time use_callback calls this callback, the print_str member function will update the internal message counter count_ of printer, and output the message with the count to the standard output stream.

Compiling and running this C++ program should print on the terminal

Message 0: First time calling callback!
Message 1: Second time calling callback!
Message 2: Third time calling callback!

7.3.4. Lambda function syntax#

Lambda functions, introduced in C++11, are a convenient way to define anonymous functions directly in the code of another function. They are especially useful for short snippets of code that are used only in a limited scope, such as in algorithms, callbacks, or as arguments to other functions.

Lambda functions are part of the C++ syntax itself, and therefore there is no header to include to use this functionality. The basic syntax for a lambda function is as follows:

[capture](parameters) -> return_type {
    // lambda function body
};

In the above:

  • capture: Specifies which variables from the surrounding scope should be available to the code inside the body of the lambda function. We say these variables from the surrounding scope are captured by the lambda function. We’ll look at an example of this concept in a moment.

  • parameters: The parameters for the lambda function, similar to those in regular functions.

  • return_type: (Optional) Specifies the return type of the lambda. If omitted, the return type is deduced automatically.

  • function body: The code that defines what the lambda does.

We call lambda functions anonymous since the syntax does not specify any function name. To use the function, you must assign it to a variable, so you can call the function using that variable’s name. It is convenient to use the auto keyword to let the compiler determine the type of the lambda function for you, similar to how we used it with std::bind.

Here’s a basic example of a lambda function:

#include <iostream>

int main() {
    // A lambda that adds two numbers
    auto add = [](int a, int b) -> int {
        return a + b;
    };

    // Using the lambda
    std::cout << add(3, 4) << std::endl; // Outputs 7

    return 0;
}

In this example we did not “capture” any variables from the surrounding function, resulting in empty [] brackets, because this function body only operates on its own two arguments, a and b.

In many use cases we do want a lambda function to capture variables from their surrounding scope. This is specified in the capture clause ([]). There are several ways to capture variables:

  • By value: [x,y,z] captures variables x, y, z by value.

  • By reference: [&x] captures x by reference.

  • By value for all variables: [=] captures all variables used in the lambda by value.

  • By reference for all variables: [&] captures all variables used in the lambda by reference.

Here is an example:

#include <iostream>

int main() {
    int x = 10;
    int y = 20;

    // Capture x by value and y by reference
    auto f = [x, &y]() {
        std::cout << "x: " << x << ", y: " << y << std::endl;
    };

    f(); // Outputs: x: 10, y: 20

    x = 30;
    y = 40;
    f(); // Outputs: x: 10, y: 40 (!!!)

    return 0;
}

Note how x was captured by value, hence its value within the lambda function remained fixed once the lambda function is defined. Variable y was captured by reference, so each time the lambda function is called the current value of y will be retrieved.

We can also pass lambda functions as a callback to any function that takes std::function with the correct signature as argument. Here is an example that behaves similar to the class member function callback before, but this time the count message counter is not stored as a class member but as a regular variable on the stack of the main() function:

#include <iostream>
#include <functional>

void use_callback(const std::function<void(const std::string&)>& callback) {
    callback("First time calling callback!");
    callback("Second time calling callback!");
    callback("Third time calling callback!");
}

int main() {
    int count;
    
    // Create a callback using a lambda function
    auto print_str = [&count](const std::string& data) -> void {
        std::cout << "Message " << count << ": " << data << std::endl;
        count++;
    };
    
    // Using lambda function as a callback
    use_callback(print_str);
    
    return 0;
}

7.3.5. Shared pointers with std::shared_ptr#

You have learned about C++ pointers, and how they can be used to refer to variables allocated on the heap using the new and delete keywords.

Heap memory has several benefits:

  • Objects on the heap memory can exist longer than the scope they were created in.

  • the heap is typically (much) larger than the stack, allowing for bigger objects to be stored there.

Drawbacks of using heap memory:

  • Heap memory is slower to access than stack memory.

  • You must carefully manage the memory of heap variables as programmer. For every object created with new you will need to ensure it will be properly destroyed at some moment using delete. Failing to call delete can result in memory leaks where your program keeps using more memory without ever giving it back, causing it or the computer to crash at some point.

Shared pointers provide a mechanism to address this last drawback, which we will illustrate by first introducing an example using new and delete, and then by adapting this example to use std::shared_ptr instead.

Tip

In general, if you can, declare variables on the stack instead of on the heap! This will result in more efficient and safer code since you can let the stack manage the memory.

7.3.5.1. Challenges of managing ownership with new and delete#

Let’s take a look at an example of managing a class on the heap, first using new and delete:

#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed" << std::endl; }
    ~MyClass() { std::cout << "MyClass destructed" << std::endl; }
    void display() { std::cout << "Hello from MyClass!" << std::endl; }
};

// A function that creates an object of MyClass on the heap using new
MyClass * make_my_instance() {
    MyClass* ptr = new MyClass();

    // NB.: No `delete` here because we want to return the heap object,
    // and we expect the calling function to properly delete the heap memory later
    return ptr;
}

int main() {
    MyClass* ptr = make_my_instance();
    
    // Using the instance
    ptr->display();

    // Deleting the instance to free memory,
    // even though we have no `new` in this main function.
    delete ptr;

    return 0;
}

In this code the make_my_instance() function is responsible for creating an object of the MyClass class on the heap, and then returning the pointer to the calling function (in this case main()). This implicitly means that now this calling main() function should take care of calling delete on the object. Conceptually, we can think of this as transferring ownership of the object from make_my_instance() to main(), as now main() is responsible for managing the object.

While this is a trivial example, it exemplifies how new and delete are typically not used in the same scope (because then you could just use a stack variable), which can make it difficult to track which function is responsible for managing what objects. For instance, what if make_my_instance() would have just returned a pointer to an already existing global instance of MyClass? Global instances should not be released with delete, so figuring out if main() should or should not delete the object requires carefully studying the code and tracking object ownership.

7.3.5.2. Managing ownership with shared pointers#

Shared pointers, provided by the std::shared_ptr in C++11, provide an alternative to manually managing pointers with *, new and delete. They are a type of smart pointer that manage the lifetime of dynamically allocated objects through reference counting.

Reference counting means that each std::shared_ptr has a reference count, which tracks the total number of std::shared_ptr owning the same object. So, when a new std::shared_ptr is created, either through copying or assignment, the reference count increases. When a std::shared_ptr is destroyed, the reference count decreases. When the reference count reaches zero, the managed object is deleted. They ensure that the memory for an object is automatically deallocated when the last std::shared_ptr to the object is destroyed or reset.

The reference counting mechanism simplifies memory management, and prevents memory leaks.

To use std::shared_ptr, include the header <memory>. Creating a shared pointer can then be done in several ways (here we just manage an int data type on the heap), such as:

#include <memory>
 
std::shared_ptr<int> p1(new int(10));          // Directly using new (NOT recommended)
auto p2 = std::make_shared<int>(20);           // Preferred way using std::make_shared
std::shared_ptr<int> p3 = p2;                  // Copy constructor, increases reference count of p2 and p3

By using std::make_shared we can even completely avoid calling new and delete altogether. Now let’s revisit our earlier example, but now using a std::shared_ptr:

#include <iostream>
#include <memory>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed" << std::endl; }
    ~MyClass() { std::cout << "MyClass destructed" << std::endl; }
    void display() { std::cout << "Hello from MyClass!" << std::endl; }
};

// A function that creates an object of MyClass on the heap using make_shared(..)
std::shared_ptr<MyClass> make_my_instance() {

    // Create and return a new object using a shared pointer 
    std::shared_ptr<MyClass> ptr = std::make_shared<MyClass>();

    return ptr;
}

int main() {
    // Creating an instance of MyClass using std::make_shared
    std::shared_ptr<MyClass> ptr = make_my_instance();
    
    // Using the instance
    // NB.: the `->` syntax can be used to dereference the shared pointer just as a normal C++ pointer
    ptr->display();

    // NB.: NO delete needed!!!
    // When the last shared_ptr is destroyed (at the end of its scope), it will delete the object automatically
    return 0;
}

If you compile and run this code, you will see that the object is properly created and destroyed, even though no new and delete was used:

MyClass constructed
Hello from MyClass!
MyClass destructed

In addition to automatic memory management, this reduces the risk of memory leaks in the presence of exceptions, since delete will be implicitly called even when a stack terminates due to exception being thrown.

Still, they are no silver bullet, and you should be aware of some caveats:

  • The reference counting mechanism introduces some (minor) computational overhead (due to atomic operations required for thread safety).

  • Shared pointers can create reference cycles, where two or more objects reference each other, preventing memory from being freed as the reference counter will never reach zero, even when no other code refers to the object anymore. This can be resolved using “weak pointers” (std::weak_ptr), but those are out of scope for this overview.

Note

Python also makes extensive use of reference counting for its memory management. Using std::shared_ptr therefore somewhat mimicks the use of variables and objects in Python.

Python is not immune to the problem of reference cycles, and therefore it has a so-called “garbage collector” which once in a while scans all known Python objects in memory, and traces all their references in search of such cycles. The extensive use of reference counters, memory locks and the garbage collector are some of the reasons why Python is generally slower to execute than C++ where you can optimize the memory management for your target use case.

7.3.6. Summary#

  • The auto keyword can be used to simplify writing type declarations in cases where the compiler can figure out the correct type from variable initialization values or function return types (where it can deduce the type automatically).

  • std::bind and std::function are powerful tools introduced in C++11 that allow for greater flexibility in handling functions. std::bind helps create new function objects by binding some arguments, while std::function acts as a wrapper that can store and call any callable target with a specified signature. Combining these tools can lead to cleaner and more maintainable code, especially in scenarios involving callbacks and event handling.

  • Lambda functions in C++11 provide a concise and powerful way to create anonymous functions. They can capture variables from their surrounding scope, making them highly versatile. When combined with std::function and standard algorithms, lambdas enable you to write cleaner and more expressive code.

  • A std::shared_ptr is a type of smart pointer that simplifies tracking ownership of data on the heap. Objects managed using shared pointers can be passed around to other functions which accept shared pointers, and the std::shared_ptr implementation will count how many of these pointers are still referencing the object. We call this mechanism reference counting. When the last shared pointer is deleted and the objects reference count hits zero, the object will be automatically deleted.