9

Robustness and Performance

C++ is often the first choice when it comes to selecting an object-oriented programming language with performance and flexibility as key goals. Modern C++ provides language and library features, such as rvalue references, move semantics, and smart pointers.

When combined with good practices for exception handling, constant correctness, type-safe conversions, resource allocation, and releasing, C++ enables developers to write better, more robust, and performant code. This chapter's recipes address all of these essential topics.

This chapter includes the following recipes:

  • Using exceptions for error handling
  • Using noexcept for functions that do not throw exceptions
  • Ensuring constant correctness for a program
  • Creating compile-time constant expressions
  • Creating immediate functions
  • Performing correct type casts
  • Using unique_ptr to uniquely own a memory resource
  • Using shared_ptr to share a memory resource
  • Implementing move semantics
  • Consistent comparison with the operator <=>

We will start this chapter with a couple of recipes that deal with exceptions.

Using exceptions for error handling

Exceptions are responses to exceptional circumstances that can appear when a program is running. They enable the transfer of the control flow to another part of the program. Exceptions are a mechanism for simpler and more robust error handling, as opposed to returning error codes, which could greatly complicate and clutter the code. In this recipe, we will look at some key aspects related to throwing and handling exceptions.

Getting ready

This recipe requires you to have basic knowledge of the mechanisms of throwing exceptions (using the throw statement) and catching exceptions (using try...catch blocks). This recipe is focused on good practices around exceptions and not on the details of the exception mechanism in the C++ language.

How to do it...

Use the following practices to deal with exceptions:

  • Throw exceptions by value:
    void throwing_func()
    {
      throw std::runtime_error("timed out");
    }
    void another_throwing_func()
    {
      throw std::system_error(
        std::make_error_code(std::errc::timed_out));
    }
    
  • Catch exceptions by reference, or in most cases, by constant reference:
    try
    {
      throwing_func();
    }
    catch (std::exception const & e)
    {
      std::cout << e.what() << '
    ';
    }
    
  • Order catch statements from the most derived class to the base class of the hierarchy when catching multiple exceptions from a class hierarchy:
    auto exprint = [](std::exception const & e)
    {
      std::cout << e.what() << '
    ';
    };
    try
    {
      another_throwing_func();
    }
    catch (std::system_error const & e)
    {
      exprint(e);
    }
    catch (std::runtime_error const & e)
    {
      exprint(e);
    }
    catch (std::exception const & e)
    {
      exprint(e);
    }
    
  • Use catch(...) to catch all exceptions, regardless of their type:
    try
    {
      throwing_func();
    }
    catch (std::exception const & e)
    {
      std::cout << e.what() << '
    ';
    }
    catch (...)
    {
      std::cout << "unknown exception" << '
    ';
    }
    
  • Use throw; to rethrow the current exception. This can be used to create a single exception handling function for multiple exceptions.

    Throw the exception object (for example, throw e;) when you want to hide the original location of the exception:

    void handle_exception()
    {
      try
      {
        throw; // throw current exception
      }
      catch (const std::logic_error & e)
      { /* ... */ }
      catch (const std::runtime_error & e)
      { /* ... */ }
      catch (const std::exception & e)
      { /* ... */ }
    }
    try
    {
      throwing_func();
    }
    catch (...)
    {
      handle_exception();
    }
    

How it works...

Most functions have to indicate the success or failure of their execution. This can be achieved in different ways. Here are several possibilities:

  • Return an error code (with a special value for success) to indicate the specific reason for failure:
    int f1(int& result)
    {
      if (...) return 1;
      // do something
      if (...) return 2;
      // do something more
      result = 42;
      return 0;
    }
    enum class error_codes {success, error_1, error_2};
    error_codes f2(int& result)
    {
      if (...) return error_codes::error_1;
      // do something
      if (...) return error_codes::error_2;
      // do something more
      result = 42;
      return error_codes::success;
    }
    
  • A variation of this is to return a Boolean value to only indicate success or failure:
    bool g(int& result)
    {
      if (...) return false;
      // do something
      if (...) return false;
      // do something more
      result = 42;
      return true;
    }
    
  • Another alternative is to return invalid objects, null pointers, or empty std::optional<T> objects:
    std::optional<int> h()
    {
      if (...) return {};
      // do something
      if (...) return {};
      // do something more
      return 42;
    }
    

In any case, the return value from the functions should be checked. This can lead to complex, cluttered, and hard to read and maintain real-world code. Moreover, the process of checking the return value of a function is always executed, regardless of whether the function was successful or failed. On the other hand, exceptions are thrown and handled only when a function fails, which should happen more rarely than successful executions. This can actually lead to faster code than code that returns and tests error codes.

Exceptions and error codes are not mutually exclusive. Exceptions should be used only for transferring the control flow in exceptional situations, not for controlling the data flow in a program.

Class constructors are special functions that do not return any value. They are supposed to construct an object, but in the case of failure, they will not be able to indicate this with a return value. Exceptions should be a mechanism that constructors use to indicate failure. Together with the resource acquisition is initialization (RAII) idiom, this ensures the safe acquisition and release of resources in all situations. On the other hand, exceptions are not allowed to leave a destructor. When this happens, the program abnormally terminates with a call to std::terminate(). This is the case for destructors called during stack unwinding, due to the occurrence of another exception. When an exception occurs, the stack is unwound from the point where the exception was thrown to the block where the exception is handled. This process involves the destruction of all local objects in all those stack frames.

If the destructor of an object that is being destroyed during this process throws an exception, another stack unwinding process should begin, which conflicts with the one already under way. Because of this, the program terminates abnormally.

The rule of thumb for dealing with exceptions in constructors and destructors is as follows:

1. Use exceptions to indicate the errors that occur in constructors.

2. Do not throw or let exceptions leave destructors.

It is possible to throw any type of exception. However, in most cases, you should throw temporaries and catch exceptions by constant reference. The following are some guidelines for exception throwing:

  • Prefer throwing either standard exceptions or your own exceptions derived from std::exception or another standard exception. The reason for this is that the standard library provides exception classes that are intended to be the first choice for representing exceptions. You should use the ones that are available already and when these are not good enough, build your own based on the standard ones. The main benefits of this are consistency and helping users catch exceptions via the base std::exception class.
  • Avoid throwing exceptions of built-in types, such as integers. The reason for this is that numbers carry little information to the user, who must know what it represents, while an object can provide contextual information. For instance, the statement throw 42; tells nothing to the user, but throw access_denied_exception{}; carries much more implicit information from the class name alone, and with the help of data members it carries anything useful or necessary about the exceptional situation.
  • When using a library or framework that provides its own exception hierarchy, prefer throwing exceptions from this hierarchy or your own exceptions derived from it, at least in the parts of the code that are tightly related to it. The main reason for this is to keep the code that utilizes the library APIs consistent.

There's more...

As mentioned in the preceding section, when you need to create your own exception types, derive them from one of the standard exceptions that are available, unless you are using a library or framework with its own exception hierarchy. The C++ standard defines several categories of exceptions that need to be considered for this purpose:

  • The std::logic_error represents an exception that indicates an error in the program logic, such as an invalid argument, an index beyond the bounds of a range, and so on. There are various standard derived classes, such as std::invalid_argument, std::out_of_range, and std::length_error.
  • The std::runtime_error represents an exception that indicates an error beyond the scope of the program or that cannot be predicted due to various factors, including external ones, such as overflows and underflows or operating system errors. The C++ standard also provides several derived classes from std::runtime_error, including std::overflow_error, std::underflow_error, std::system_error, and std::format_error in C++20.
  • Exceptions prefixed with bad_, such as std::bad_alloc, std::bad_cast, and std::bad_function_call, represent various errors in a program, such as failure to allocate memory, failure to dynamically cast or make a function call, and so on.

The base class for all these exceptions is std::exception. It has a non-throwing virtual method called what() that returns a pointer to an array of characters representing the description of the error.

When you need to derive custom exceptions from a standard exception, use the appropriate category, such as logical or runtime error. If none of these categories is suitable, then you can derive directly from std::exception. The following is a list of possible solutions you can use to derive from a standard exception:

  • If you need to derive from std::exception, then override the virtual method what() to provide a description of the error:
    class simple_error : public std::exception
    {
    public:
      virtual const char* what() const noexcept override
      {
        return "simple exception";
      }
    };
    
  • If you derive from std::logic_error or std::runtime_error and you only need to provide a static description that does not depend on runtime data, then pass the description text to the base class constructor:
    class another_logic_error : public std::logic_error
    {
    public:
      another_logic_error():
        std::logic_error("simple logic exception")
      {}
    };
    
  • If you derive from std::logic_error or std::runtime_error but the description message depends on runtime data, provide a constructor with parameters and use them to build the description message. You can either pass the description message to the base class constructor or return it from the overridden what() method:
    class advanced_error : public std::runtime_error
    {
      int error_code;
      std::string make_message(int const e)
      {
        std::stringstream ss;
        ss << "error with code " << e;
        return ss.str();
      }
    public:
      advanced_error(int const e) :
        std::runtime_error(make_message(e).c_str()),error_code(e)
      {
      }
      int error() const noexcept
      {
        return error_code;
      }
    };
    

For a complete list of the standard exception classes, you can visit the https://en.cppreference.com/w/cpp/error/exception page.

See also

  • Handling exceptions from thread functions, in Chapter 8, Leveraging Threading and Concurrency, to understand how to handle exceptions thrown in a worker thread from the main thread or the thread where it was joined.
  • Using noexcept for functions that do not throw exceptions, to see how to inform the compiler that a function should not throw exceptions.

Using noexcept for functions that do not throw exceptions

Exception specification is a language feature that can enable performance improvements, but on the other hand, when done incorrectly, it can abnormally terminate the program. The exception specification from C++03, which allowed you to indicate what types of exceptions a function could throw, has been deprecated and replaced with the new C++11 noexcept specification. This specification only allows you to indicate that a function does not throw exceptions, but not the actual exceptions types that it throws. This recipe provides information about the modern exception specifications in C++, as well as guidelines on when to use them.

How to do it...

Use the following constructs to specify or query exception specifications:

  • Use nothrow in a function declaration to indicate that the function is not throwing any exception:
    void func_no_throw() noexcept
    {
    }
    
  • Use nothrow(expr) in a function declaration, such as template metaprogramming, to indicate that the function may or may not throw an exception based on a condition that evaluates to bool:
    template <typename T>
    T generic_func_1()
      noexcept(std::is_nothrow_constructible_v<T>)
    {
      return T{};
    }
    
  • Use the noexcept operator at compile time to check whether an expression is declared to not throw any exception:
    template <typename T>
    T generic_func_2() noexcept(noexcept(T{}))
    {
      return T{};
    }
    template <typename F, typename A>
    auto func(F&& f, A&& arg) noexcept
    {
      static_assert(!noexcept(f(arg)), "F is throwing!");
      return f(arg);
    }
    std::cout << noexcept(func_no_throw) << '
    ';
    

How it works...

As of C++17, exception specification is part of the function type, but not part of the function signature; it may appear as part of any function declarator. Because exception specification is not part of the function signature, two function signatures cannot differ only in the exception specification. Prior to C++17, exception specification was not part of the function type and could only appear as part of lambda declarators or top-level function declarators; they could not appear even in typedef or type alias declarations. Further discussions on exception specification refer solely to the C++17 standard.

There are several ways in which the process of throwing an exception can be specified:

  • If no exception specification is present, then the function could potentially throw exceptions.
  • noexcept(false) is equivalent to no exception specification.
  • noexcept(true) and noexcept indicate that a function does not throw any exception.
  • throw() was equivalent to noexcept(true) but was deprecated until C++20, when it was removed altogether.

Using exception specifications must be done with care because, if an exception (either thrown directly or from another function that is called) leaves a function marked as non-throwing, the program is terminated immediately and abnormally with a call to std::terminate().

Pointers to the functions that do not throw exceptions can be implicitly converted to pointers to functions that may throw exceptions, but not vice versa. On the other hand, if a virtual function has a non-throwing exception specification, this indicates that all the declarations of all the overrides must preserve this specification unless an overridden function is declared as deleted.

At compile time, it is possible to check whether a function is declared to be non-throwing or not using the operator noexcept. This operator takes an expression and returns true if the expression is declared as either non-throwing or false. It does not evaluate the expression it checks.

The noexcept operator, along with the noexcept specifier, is particularly useful in template metaprogramming to indicate whether a function may throw exceptions for some types. It is also used with static_assert declarations to check whether an expression breaks the non-throwing guarantee of a function, as seen in the examples in the How to do it... section.

The following code provides more examples of how the noexcept operator works:

int double_it(int const i) noexcept
{
  return i + i;
}
int half_it(int const i)
{
  throw std::runtime_error("not implemented!");
}
struct foo
{
  foo() {}
};
std::cout << std::boolalpha
  << noexcept(func_no_throw()) <<  '
'              // true
  << noexcept(generic_func_1<int>()) <<  '
'        // true
  << noexcept(generic_func_1<std::string>()) <<  '
'// true
  << noexcept(generic_func_2<int>()) << '
'         // true
  << noexcept(generic_func_2<std::string>()) <<  '
'// true
  << noexcept(generic_func_2<foo>()) <<  '
'        // false
  << noexcept(double_it(42)) <<  '
'                // true
  << noexcept(half_it(42)) <<  '
'                  // false
  << noexcept(func(double_it, 42)) <<  '
'          // true
  << noexcept(func(half_it, 42)) << '
';            // true

It is important to note that the noexcept specifier does not provide compile-time checking for exceptions. It only represents a way for users to inform the compiler that a function is not expected to throw exceptions. The compiler can use this to enable certain optimizations. An example is the std::vector, which moves elements if their move constructor is noexcept and copies them otherwise.

There's more...

As mentioned earlier, a function declared with the noexcept specifier that exits due to an exception causes the program to terminate abnormally. Therefore, the noexcept specifier should be used with caution. Its presence can enable code optimizations, which help increase performance while preserving the strong exception guarantee. An example of this is library containers.

The strong exception guarantee specifies that either an operation is completed successfully, or that it is completed with an exception that leaves the program in the same state it was before the operation started. This ensures commit-or-rollback semantics.

Many standard containers provide some of their operations with a strong exception guarantee. An example is vector's push_back() method. This method could be optimized by using the move constructor or move assignment operator instead of the copy constructor or copy assignment operator of the vector's element type. However, in order to preserve its strong exception guarantee, this can only be done if the move constructor or assignment operator does not throw exceptions. If either does, then the copy constructor or the assignment operator must be used instead.

The std::move_if_noexcept() utility function does this if the move constructor of its type argument is marked with noexcept. The ability to indicate that move constructors or move assignment operators do not throw exceptions is probably the most important scenario where noexcept is used.

Consider the following rules for the exception specification:

  • If a function could potentially throw an exception, then do not use any exception specifier.
  • Mark only those functions with noexcept that are guaranteed not to throw an exception.
  • Mark only those functions with noexcept(expression) that could potentially throw exceptions based on a condition.

These rules are important because, as already noted previously, throwing an exception from a noexcept function will immediately terminate the program with a call to std::terminate().

See also

  • Using exceptions for error handling, to explore the best practices for using exceptions in the C++ language.

Ensuring constant correctness for a program

Although there is no formal definition, constant correctness means objects that are not supposed to be modified (are immutable) remain unmodified indeed. As a developer, you can enforce this by using the const keyword for declaring parameters, variables, and member functions. In this recipe, we will explore the benefits of constant correctness and how to achieve it.

How to do it...

To ensure constant correctness for a program, you should always declare the following as constants:

  • Parameters to functions that are not supposed to be modified within the function:
    struct session {};
    session connect(std::string const & uri,
                    int const timeout = 2000)
    {
      /* do something */
      return session { /* ... */ };
    }
    
  • Class data members that do not change:
    class user_settings
    {
    public:
      int const min_update_interval = 15;
      /* other members */
    };
    
  • Class member functions that do not modify the object state, as seen from the outside:
    class user_settings
    {
      bool show_online;
    public:
      bool can_show_online() const {return show_online;}
      /* other members */
    };
    
  • Function locals whose value do not change throughout their lifetime:
    user_settings get_user_settings()
    {
      return user_settings {};
    }
    void update()
    {
      user_settings const us = get_user_settings();
      if(us.can_show_online()) { /* do something */ }
      /* do more */
    }
    

How it works...

Declaring objects and member functions as constant has several important benefits:

  • You prevent both accidental and intentional changes of the object, which, in some cases, can result in incorrect program behavior.
  • You enable the compiler to perform better optimizations.
  • You document the semantics of the code for other users.

Constant correctness is not a matter of personal style but a core principle that should guide C++ development.

Unfortunately, the importance of constant correctness has not been, and is still not, stressed enough in books, C++ communities, and working environments. But the rule of thumb is that everything that is not supposed to change should be declared as constant. This should be done all the time and not only at later stages of development, when you might need to clean up and refactor the code.

When you declare a parameter or variable as constant, you can either put the const keyword before the type (const T c) or after the type (T const c). These two are equivalent, but regardless of which of the two styles you use, reading of the declaration must be done from the right-hand side. const T c is read as c is a T that is constant and T const c as c is a constant T. This gets a little bit more complicated with pointers. The following table presents various pointer declarations and their meanings:

Expression

Description

T* p

p is a non-constant pointer to a non-constant T.

const T* p

p is a non-constant pointer to a T that is constant.

T const * p

p is a non-constant pointer to a constant T (same as the prior point).

const T * const p

p is a constant pointer to a T that is constant.

T const * const p

p is a constant pointer to a constant T (same as the prior point).

T** p

p is a non-constant pointer to a non-constant pointer to a non-constant T.

const T** p

p is a non-constant pointer to a non-constant pointer to a constant T.

T const ** p

Same as T const ** p.

const T* const * p

p is a non-constant pointer to a constant pointer, which is a constant T.

T const * const * p

Same as T const * const * p.

Placing the const keyword after the type is more natural because it is consistent with the reading direction, from right to left. For this reason, all the examples in this book use this style.

When it comes to references, the situation is similar: const T & c and T const & c are equivalent, which means c is a reference to a constant T. However, T const & const c, which would mean that c is a constant reference to a constant T does not make sense because references—aliases of a variable—are implicitly constant in the sense that they cannot be modified to represent an alias to another variable.

A non-constant pointer to a non-constant object, that is, T*, can be implicitly converted to a non-constant pointer to a constant object, T const *. However, T** cannot be implicitly converted to T const ** (which is the same with const T**). This is because this could lead to constant objects being modified through a pointer to a non-constant object, as shown in the following example:

int const c = 42;
int* x;
int const ** p = &x; // this is an actual error
*p = &c;
*x = 0;              // this modifies c

If an object is constant, only the constant functions of its class can be invoked. However, declaring a member function as constant does not mean that the function can only be called on constant objects; it could also mean that the function does not modify the state of the object, as seen from the outside. This is a key aspect, but it is usually misunderstood. A class has an internal state that it can expose to its clients through its public interface.

However, not all the internal states might be exposed, and what is visible from the public interface might not have a direct representation in the internal state. (If you model order lines and have the item quantity and item selling price fields in the internal representation, then you might have a public method that exposes the order line amount by multiplying the quantity by the price.) Therefore, the state of an object, as visible from its public interface, is a logical state. Defining a method as constant is a statement that ensures the function does not alter the logical state. However, the compiler prevents you from modifying data members using such methods. To avoid this problem, data members that are supposed to be modified from constant methods should be declared mutable.

In the following example, computation is a class with the compute() method, which performs a long-running computation operation. Because it does not affect the logical state of the object, this function is declared constant. However, to avoid computing the result of the same input again, the computed values are stored in a cache. To be able to modify the cache from the constant function, it is declared mutable:

class computation
{
  double compute_value(double const input) const
  {
    /* long running operation */
    return input;
  }
  mutable std::map<double, double> cache;
public:
  double compute(double const input) const
  {
    auto it = cache.find(input);
    if(it != cache.end()) return it->second;
    auto result = compute_value(input);
    cache[input] = result;
    return result;
  }
};

A similar situation is represented by the following class, which implements a thread-safe container. Access to shared internal data is protected with mutex. The class provides methods such as adding and removing values, and also methods such as contains(), which indicate whether an item exists in the container. Because this member function is not intended to modify the logical state of the object, it is declared constant. However, access to the shared internal state must be protected with the mutex. In order to lock and unlock the mutex, both mutable operations (that modify the state of the object) and the mutex must be declared mutable:

template <typename T>
class container
{
  std::vector<T>     data;
  mutable std::mutex mt;
public:
  void add(T const & value)
  {
    std::lock_guard<std::mutex> lock(mt);
    data.push_back(value);
  }
  bool contains(T const & value) const
  {
    std::lock_guard<std::mutex> lock(mt);
    return std::find(std::begin(data), std::end(data), value)
           != std::end(data);
  }
};

The mutable specifier allows us to modify the class member on which it was used, even if the containing object is declared const. This is the case of the mt member of the std::mutex type, which is modified even within the contains() method, which is declared const.

Sometimes, a method or an operator is overloaded to have both constant and non-constant versions. This is often the case with the subscript operator or methods that provide direct access to the internal state. The reason for this is that the method is supposed to be available for both constant and non-constant objects. The behavior should be different, though: for non-constant objects, the method should allow the client to modify the data it provides access to, but for constant objects, it should not. Therefore, the non-constant subscript operator returns a reference to a non-constant object, and the constant subscript operator returns a reference to a constant object:

class contact {};
class addressbook
{
  std::vector<contact> contacts;
public:
  contact& operator[](size_t const index);
  contact const & operator[](size_t const index) const;
};

It should be noted that, if a member function is constant, even if an object is constant, the data that's returned by this member function may not be constant.

There's more...

The const qualifier of an object can be removed with a const_cast conversion, but this should only be used when you know that the object was not declared constant. You can read more about this in the Performing correct type casts recipe.

See also

  • Creating compile-time constant expressions, to learn about the constexpr specifier and how to define variables and functions that can be evaluated at compile time.
  • Creating immediate functions, to learn about the C++20 consteval specifier, which is used to define functions that are guaranteed to be evaluated at compile time.
  • Performing correct type casts, to learn about the best practices for performing correct casts in the C++ language.

Creating compile-time constant expressions

The possibility to evaluate expressions at compile time improves runtime execution because there is less code to run and the compiler can perform additional optimizations. Compile-time constants can be not only literals (such as a number or string), but also the result of a function's execution. If all the input values of a function (regardless of whether they are arguments, locals, or globals) are known at compile time, the compiler can execute the function and have the result available at compile time. This is what generalized the constant expressions that were introduced in C++11, which were relaxed in C++14 and further more in C++20. The keyword constexpr (short for constant expression) can be used to declare compile-time constant objects and functions. We have seen this in several examples in the previous chapters. Now, it's time to learn how it actually works.

Getting ready

The way generalized constant expressions work has been relaxed in C++14 and C++20, but this introduced some breaking changes to C++11. For instance, in C++11, a constexpr function was implicitly const, but this is no longer the case in C++14. In this recipe, we will discuss generalized constant expressions, as defined in C++20.

How to do it...

Use the constexpr keyword when you want to:

  • Define non-member functions that can be evaluated at compile time:
    constexpr unsigned int factorial(unsigned int const n)
    {
      return n > 1 ? n * factorial(n-1) : 1;
    }
    
  • Define constructors that can be executed at compile time to initialize constexpr objects and member functions to be invoked during this period:
    class point3d
    {
      double const x_;
      double const y_;
      double const z_;
    public:
      constexpr point3d(double const x = 0,
                        double const y = 0,
                        double const z = 0)
        :x_{x}, y_{y}, z_{z}
      {}
      constexpr double get_x() const {return x_;}
      constexpr double get_y() const {return y_;}
      constexpr double get_z() const {return z_;}
    };
    
  • Define variables that can have their values evaluated at compile time:
    constexpr unsigned int size = factorial(6);
    char buffer[size] {0};
    constexpr point3d p {0, 1, 2};
    constexpr auto x = p.get_x();
    

How it works...

The const keyword is used for declaring variables as constant at runtime; this means that, once initialized, they cannot be changed. However, evaluating the constant expression may still imply runtime computation. The constexpr keyword is used for declaring variables that are constant at compile time or functions that can be executed at compile time. constexpr functions and objects can replace macros and hardcoded literals without any performance penalties.

Declaring a function as constexpr does not mean that it is always evaluated at compile time. It only enables the use of the function in expressions that are evaluated during compile time. This only happens if all the input values of the function can be evaluated at compile time. However, the function may also be invoked at runtime. The following code shows two invocations of the same function, first at compile time, and then at runtime:

constexpr unsigned int size = factorial(6);
// compile time evaluation
int n;
std::cin >> n;
auto result = factorial(n);
// runtime evaluation

There are some restrictions in regard to where constexpr can be used. These restrictions have evolved over time, with changes in C++14 and C++20. To keep the list in a reasonable form, only the requirements that need to be satisfied in C++20 are shown here:

  • A variable that is constexpr must satisfy the following requirements:
    • Its type is a literal type.
    • It is initialized upon declaration.
    • The expression used for initializing the variable is a constant expression.
    • It must have constant destruction. This means that it must not be of a class type or an array of a class type; otherwise, the class type must have a constexpr destructor.
  • A function that is constexpr must satisfy the following requirements:
    • It is not a coroutine.
    • The return type and the type of all its parameters are all literal types.
    • There is at least one set of arguments for which the invocation of the function would produce a constant expression.
    • The function body must not contain goto statements, labels (other than case and default in a switch), and local variables that are either of non-literal types, or of static or thread storage duration.
  • A constructor that is constexpr must satisfy the following requirements, in addition to the preceding ones required for functions:
    • There is no virtual base class for the class.
    • All the constructors that initialize non-static data members, including base classes, must also be constexpr.
  • A destructor that is constexpr, available only since C++20, must satisfy the following requirements, in addition to the preceding ones required for functions:
    • There is no virtual base class for the class.
    • All the destructors that destroy non-static data members, including base classes, must also be constexpr.

For a complete list of requirements in different versions of the standard, you should read the online documentation available at https://en.cppreference.com/w/cpp/language/constexpr.

A function that is constexpr is not implicitly const (as of C++14), so you need to explicitly use the const specifier if the function does not alter the logical state of the object. However, a function that is constexpr is implicitly inline. On the other hand, an object that is declared constexpr is implicitly const. The following two declarations are equivalent:

constexpr const unsigned int size = factorial(6);
constexpr unsigned int size = factorial(6);

There are situations when you may need to use both constexpr and const in a declaration, as they would refer to different parts of the declaration. In the following example, p is a constexpr pointer to a constant integer:

static constexpr int c = 42;
constexpr int const * p = &c;

Reference variables can also be constexpr if, and only if, they alias an object with static storage duration or a function. The following snippet provides an example:

static constexpr int const & r = c;

In this example, r is a constexpr reference that defines an alias for the compile-time constant variable c, defined in the previous snippet.

There's more…

In C++20, a new specifier was added to the language. This specifier is called constinit and is used for ensuring that variables with static or thread storage duration have static initialization. In C++, initialization of variables can be either static or dynamic. Static initialization can be either zero initialization (when the initial value of an object is set to zero) or constant initialization (when the initial value is set to a compile-time expression). The following snippet shows examples of zero and constant initialization:

struct foo
{
  int a;
  int b;
};
struct bar
{
  int   value;
  int*  ptr;
  constexpr bar() :value{ 0 }, ptr{ nullptr }{}
};
std::string text {};  // zero-initialized to unspecified value
double arr[10];       // zero-initialized to ten 0.0
int* ptr;             // zero-initialized to nullptr
foo f = foo();        // zero-initialized to a=0, b=0
foo const fc{ 1, 2 }; // const-initialized at runtime
constexpr bar b;      // const-initialized at compile-time

A variable that has static storage could have either static or dynamic initialization. In the latter case, hard to find bugs may appear. Imagine two static objects that are initialized in different translation units.

When the initialization of one of the two objects depends on the other object, then the order they are initialized in is important. This is because the object that is depending on the object must be initialized first. However, the order of the initialization of the translation units is not deterministic, so there is no guarantee on the order of these objects' initialization. However, variables with static storage duration that have static initialization are initialized at compile time. This implies that these objects can be safely used when performing dynamic initialization of translation units.

This is what the new specifier, constinit, is intended for. It ensures that a variable with static or thread-local storage has static initialization, and, therefore, its initialization is performed at compile time:

int f() { return 42; }
constexpr int g(bool const c) { return c ? 0 : f(); }
constinit int c = g(true);  // OK
constinit int d = g(false); /* error: variable does not have
                                      a constant initializer */

It can also be used in a non-initializing declaration to indicate that a variable with thread storage duration is already initialized, as shown in the following example:

extern thread_local constinit int data;
int get_data() { return data; }

The constexpr, constinit, and consteval specifiers cannot be used in the same declaration.

We will learn about consteval in the next recipe, Creating immediate functions.

See also

  • Creating immediate functions, to learn about the C++20 consteval specifier, which is used to define functions that are guaranteed to be evaluated at compile time.
  • Ensuring constant correctness for a program, to explore the benefits of constant correctness and how to achieve it.

Creating immediate functions

Constexpr functions enable the evaluation of functions at compile time, provided that all their inputs, if any, are also available at compile time. However, this is not a guarantee and constexpr functions may also execute at runtime, as we have seen in the previous recipe, Creating compile-time constant expressions. In C++20, a new category of functions has been introduced: immediate functions. These are functions that are guaranteed to always be evaluated at compile time; otherwise, they produce errors. Immediate functions are useful as replacements for macros and may be important in the possible future development of the language with reflection and meta-classes.

How to do it…

Use the consteval keyword when you want to:

  • Define non-member functions or function templates that must be evaluated at compile time:
    consteval unsigned int factorial(unsigned int const n)
    {
      return n > 1 ? n * factorial(n-1) : 1;
    }
    
  • Define constructors that must be executed at compile time to initialize constexpr objects and member functions to be invoked only at compile time:
    class point3d
    {
      double x_;
      double y_;
      double z_;
    public:
      consteval point3d(double const x = 0,
                        double const y = 0,
                        double const z = 0)
        :x_{x}, y_{y}, z_{z}
      {}
      consteval double get_x() const {return x_;}
      consteval double get_y() const {return y_;}
      consteval double get_z() const {return z_;}
    };
    

How it works…

The consteval specifier was introduced in C++20. It can only be applied to functions and function templates and defines them as immediate functions. This means that any function invocation must be evaluated at compile time and therefore produce a compile-time constant expression. If the function cannot be evaluated at compile time, the program is ill-formed and the compiler issues an error.

The following rules apply to immediate functions:

  • Destructors, allocation, and deallocation functions cannot be immediate functions.
  • If any declaration of a function contains the consteval specifier, then all the declarations of that function must also include it.
  • The consteval specifier cannot be used together with constexpr or constinit.
  • An immediate function is an inline constexpr function. Therefore, immediate functions and function templates must satisfy the requirements applicable to constexpr functions.

Here is how we can use the factorial() function and the point3d class defined in the previous section:

constexpr unsigned int f = factorial(6);
std::cout << f << '
';
constexpr point3d p {0, 1, 2};
std::cout << p.get_x() << ' ' << p.get_y() << ' ' << p.get_z() << '
';

However, the following sample produces compiler errors because the immediate function factorial() and the constructor of point3d cannot be evaluated at compile time:

unsigned int n;
std::cin >> n;
const unsigned int f2 = factorial(n); // error
double x = 0, y = 1, z = 2;
constexpr point3d p2 {x, y, z};       // error

It is not possible to take the address on an immediate function unless it is also in a constant expression:

using pfact = unsigned int(unsigned int);
pfact* pf = factorial;
constexpr unsigned int f3 = pf(42);   // error
consteval auto addr_factorial()
{
  return &factorial;
}
consteval unsigned int invoke_factorial(unsigned int const n)
{
  return addr_factorial()(n);
}
constexpr auto ptr = addr_factorial();  // ERROR: cannot take the pointer
                                        // of an immediate function
constexpr unsigned int f2 = invoke_factorial(5); // OK

Because immediate functions are not visible at runtime, their symbols are not emitted for them and debuggers will not be able to show them.

See also

  • Ensuring constant correctness for a program, to explore the benefits of constant correctness and how to achieve it.
  • Creating compile-time constant expressions, to learn about the constexpr specifier and how to define variables and functions that can be evaluated at compile time.

Performing correct type casts

It is often the case that data has to be converted from one type into another type. Some conversions are necessary at compile time (such as double to int); others are necessary at runtime (such as upcasting and downcasting pointers to the classes in a hierarchy). The language supports compatibility with the C casting style in either the (type)expression or type(expression) form. However, this type of casting breaks the type safety of C++.

Therefore, the language also provides several conversions: static_cast, dynamic_cast, const_cast, and reinterpret_cast. They are used to better indicate intent and write safer code. In this recipe, we'll look at how these casts can be used.

How to do it...

Use the following casts to perform type conversions:

  • Use static_cast to perform type casting of non-polymorphic types, including casting of integers to enumerations, from floating point to integral values, or from a pointer type to another pointer type, such as from a base class to a derived class (downcasting) or from a derived class to a base class (upcasting), but without any runtime checks:
    enum options {one = 1, two, three};
    int value = 1;
    options op = static_cast<options>(value);
    int x = 42, y = 13;
    double d = static_cast<double>(x) / y;
    int n = static_cast<int>(d);
    
  • Use dynamic_cast to perform type casting of pointers or references of polymorphic types from a base class to a derived class or the other way around. These checks are performed at runtime and may require that run-time type information (RTTI) is enabled:
    struct base
    {
      virtual void run() {}
      virtual ~base() {}
    };
    struct derived : public base
    {
    };
    derived d;
    base b;
    base* pb = dynamic_cast<base*>(&d);         // OK
    derived* pd = dynamic_cast<derived*>(&b);   // fail
    try
    {
      base& rb = dynamic_cast<base&>(d);       // OK
      derived& rd = dynamic_cast<derived&>(b); // fail
    }
    catch (std::bad_cast const & e)
    {
      std::cout << e.what() << '
    ';
    }
    
  • Use const_cast to perform conversion between types with different const and volatile specifiers, such as removing const from an object that was not declared as const:
    void old_api(char* str, unsigned int size)
    {
      // do something without changing the string
    }
    std::string str{"sample"};
    old_api(const_cast<char*>(str.c_str()),
            static_cast<unsigned int>(str.size()));
    
  • Use reinterpret_cast to perform a bit reinterpretation, such as conversion between integers and pointer types, from pointer types to integer, or from a pointer type to any other pointer type, without involving any runtime checks:
    class widget
    {
    public:
      typedef size_t data_type;
      void set_data(data_type d) { data = d; }
      data_type get_data() const { return data; }
    private:
      data_type data;
    };
    widget w;
    user_data* ud = new user_data();
    // write
    w.set_data(reinterpret_cast<widget::data_type>(ud));
    // read
    user_data* ud2 = reinterpret_cast<user_data*>(w.get_data());
    

How it works...

The explicit type conversion, sometimes referred to as C-style casting or static casting, is a legacy of the compatibility of C++ with the C language and enables you to perform various conversions including the following:

  • Between arithmetical types
  • Between pointer types
  • Between integral and pointer types
  • Between const or volatile qualified and unqualified types

This type of casting does not work well with polymorphic types or in templates. Because of this, C++ provides the four casts we saw in the examples earlier. Using these casts leads to several important benefits:

  • They express user intent better, both to the compiler and others that read the code.
  • They enable safer conversion between various types (except for reinterpret_cast).
  • They can be easily searched in the source code.

static_cast is not a direct equivalent of explicit type conversion, or static casting, even though the name might suggest that. This cast is performed at compile time and can be used to perform implicit conversions, the reverse of implicit conversions, and conversion from pointers to types from a hierarchy of classes. It cannot be used to trigger a conversion between unrelated pointer types, though. For this reason, in the following example, converting from int* to double* using static_cast produces a compiler error:

int* pi = new int{ 42 };
double* pd = static_cast<double*>(pi);   // compiler error

However, converting from base* to derived* (where base and derived are the classes shown in the How to do it... section) does not produce a compiler error but a runtime error when trying to use the newly obtained pointer:

base b;
derived* pd = static_cast<derived*>(&b); // compilers OK, runtime error
base* pb1 = static_cast<base*>(pd);      // OK

On the other hand, static_cast cannot be used to remove const and volatile qualifiers. The following snippet exemplifies this:

int const c = 42;
int* pc = static_cast<int*>(&c);         // compiler error

Safely typecasting expressions up, down, or sideways along an inheritance hierarchy can be performed with dynamic_cast. This cast is performed at runtime and requires that RTTI is enabled. Because of this, it incurs a runtime overhead. Dynamic casting can only be used for pointers and references. When dynamic_cast is used to convert an expression into a pointer type and the operation fails, the result is a null pointer. When it is used to convert an expression into a reference type and the operation fails, an std::bad_cast exception is thrown. Therefore, always put a dynamic_cast conversion to a reference type within a try...catch block.

RTTI is a mechanism that exposes information about object data types at runtime. This is available only for polymorphic types (types that have at least one virtual method, including a virtual destructor, which all base classes should have). RTTI is usually an optional compiler feature (or might not be supported at all), which means using this functionality may require using a compiler switch.

Though dynamic casting is performed at runtime, if you attempt to convert it between non-polymorphic types, you'll get a compiler error:

struct struct1 {};
struct struct2 {};
struct1 s1;
struct2* ps2 = dynamic_cast<struct2*>(&s1); // compiler error

reinterpret_cast is more like a compiler directive. It does not translate into any CPU instructions; it only instructs the compiler to interpret the binary representation of an expression as it was of another, specified type. This is a type-unsafe conversion and should be used with care. It can be used to convert expressions between integral types and pointers, pointer types, and function pointer types. Because no checks are done, reinterpret_cast can be successfully used to convert expressions between unrelated types, such as from int* to double*, which produces undefined behavior:

int* pi = new int{ 42 };
double* pd = reinterpret_cast<double*>(pi);

A typical use of reinterpret_cast is to convert expressions between types in code that uses operating system or vendor-specific APIs. Many APIs store user data in the form of a pointer or an integral type. Therefore, if you need to pass the address of a user-defined type to such APIs, you need to convert values of unrelated pointer types or a pointer type value into an integral type value. A similar example was provided in the previous section, where widget was a class that stored user-defined data in a data member and provided methods for accessing it: set_data() and get_data(). If you need to store a pointer to an object in widget, then use reinterpret_cast, as shown in this example.

const_cast is similar to reinterpret_cast in the sense that it is a compiler directive and does not translate into CPU instructions. It is used to cast away const or volatile qualifiers, an operation that none of the other three conversions discussed here can do.

const_cast should only be used to remove const or volatile qualifiers when the object is not declared const or volatile. Anything else incurs undefined behavior, as shown in the following example:

int const a = 42;
int const * p = &a;
int* q = const_cast<int*>(p);
*q = 0; // undefined behavior

In this example, the variable p points to an object (the variable a) that was declared constant. By removing the const qualifier, the attempt to modify the pointed object introduces undefined behavior.

There's more...

When using explicit type conversion in the form (type)expression, be aware that it will select the first choice from the following list, which satisfies specific casts requirements:

  1. const_cast<type>(expression)
  2. static_cast<type>(expression)
  3. static_cast<type>(expression) + const_cast<type>(expression)
  4. reinterpret_cast<type>(expression)
  5. reinterpret_cast<type>(expression) + const_cast<type>(expression)

Moreover, unlike the specific C++ casts, static cast can be used to convert between incomplete class types. If both type and expression are pointers to incomplete types, then it is not specified whether static_cast or reinterpret_cast is selected.

See also

  • Ensuring constant correctness for a program, to explore the benefits of constant correctness and how to achieve it.

Using unique_ptr to uniquely own a memory resource

Manual handling of heap memory allocation and releasing it is one of the most controversial features of C++. All allocations must be properly paired with a corresponding delete operation in the correct scope. If the memory allocation is done in a function and needs to be released before the function returns, for instance, then this has to happen on all the return paths, including the abnormal situation where a function returns because of an exception. C++11 features, such as rvalues and move semantics, have enabled the development of smart pointers; these pointers can manage a memory resource and automatically release it when the smart pointer is destroyed. In this recipe, we will look at std::unique_ptr, a smart pointer that owns and manages another object or an array of objects allocated on the heap, and performs the disposal operation when the smart pointer goes out of scope.

Getting ready

In the following examples, we will use the ensuing class:

class foo
{
  int a;
  double b;
  std::string c;
public:
  foo(int const a = 0, double const b = 0, std::string const & c = "")
    :a(a), b(b), c(c)
  {}
  void print() const
  {
    std::cout << '(' << a << ',' << b << ',' << std::quoted(c) << ')'
              << '
';
  }
};

For this recipe, you need to be familiar with move semantics and the std::move() conversion function. The unique_ptr class is available in the std namespace in the <memory> header.

How to do it...

The following is a list of typical operations you need to be aware of when working with std::unique_ptr:

  • Use the available overloaded constructors to create an std::unique_ptr that manages objects or an array of objects through a pointer. The default constructor creates a pointer that does not manage any object:
    std::unique_ptr<int>   pnull;
    std::unique_ptr<int>   pi(new int(42));
    std::unique_ptr<int[]> pa(new int[3]{ 1,2,3 });
    std::unique_ptr<foo>   pf(new foo(42, 42.0, "42"));
    
  • Alternatively, use the std::make_unique() function template, available in C++14, to create std::unique_ptr objects:
    std::unique_ptr<int>   pi = std::make_unique<int>(42);
    std::unique_ptr<int[]> pa = std::make_unique<int[]>(3);
    std::unique_ptr<foo>   pf = std::make_unique<foo>(42, 42.0, "42");
    
  • Use the std::make_unique_for_overwrite() function template, available in C++20, to create an std::unique_ptr to objects or an array of objects that are default initialized. These objects should later be overwritten with a determined value:
    std::unique_ptr<int>   pi = std::make_unique_for_overwrite<int>();
    std::unique_ptr<foo[]> pa = std::make_unique_for_overwrite<foo[]>();
    
  • Use the overloaded constructor, which takes a custom deleter if the default delete operator is not appropriate for destroying the managed object or array:
    struct foo_deleter
    {
      void operator()(foo* pf) const
      {
        std::cout << "deleting foo..." << '
    ';
        delete pf;
      }
    };
    std::unique_ptr<foo, foo_deleter> pf(
        new foo(42, 42.0, "42"),
        foo_deleter());
    
  • Use std::move() to transfer the ownership of an object from one std::unique_ptr to another:
    auto pi = std::make_unique<int>(42);
    auto qi = std::move(pi);
    assert(pi.get() == nullptr);
    assert(qi.get() != nullptr);
    
  • To access the raw pointer to the managed object, use get() if you want to retain ownership of the object or release() if you want to release the ownership as well:
    void func(int* ptr)
    {
      if (ptr != nullptr)
        std::cout << *ptr << '
    ';
      else
        std::cout << "null" << '
    ';
    }
    std::unique_ptr<int> pi;
    func(pi.get()); // prints null
    pi = std::make_unique<int>(42);
    func(pi.get()); // prints 42
    
  • Dereference the pointer to the managed object using operator* and operator->:
    auto pi = std::make_unique<int>(42);
    *pi = 21;
    auto pf = std::make_unique<foo>();
    pf->print();
    
  • If an std::unique_ptr manages an array of objects, operator[] can be used to access individual elements of the array:
    std::unique_ptr<int[]> pa = std::make_unique<int[]>(3);
    for (int i = 0; i < 3; ++i)
      pa[i] = i + 1;
    
  • To check whether std::unique_ptr can manage an object or not, use the explicit operator bool or check whether get() != nullptr (which is what the operator bool does):
    std::unique_ptr<int> pi(new int(42));
    if (pi) std::cout << "not null" << '
    ';
    
  • std::unique_ptr objects can be stored in a container. Objects returned by make_unique() can be stored directly. An lvalue object could be statically converted to an rvalue object with std::move() if you want to give up the ownership of the managed object to the std::unique_ptr object in the container:
    std::vector<std::unique_ptr<foo>> data;
    for (int i = 0; i < 5; i++)
      data.push_back(
    std::make_unique<foo>(i, i, std::to_string(i)));
    auto pf = std::make_unique<foo>(42, 42.0, "42");
    data.push_back(std::move(pf));
    

How it works...

std::unique_ptr is a smart pointer that manages an object or an array allocated on the heap through a raw pointer. It performs an appropriate disposal when the smart pointer goes out of scope, is assigned a new pointer with operator=, or it gives up ownership using the release() method. By default, the operator delete is used to dispose of the managed object. However, the user may supply a custom deleter when constructing the smart pointer. This deleter must be a function object, either an lvalue reference to a function object or a function, and this callable object must take a single argument of the type unique_ptr<T, Deleter>::pointer.

C++14 has added the std::make_unique() utility function template to create an std::unique_ptr. It avoids memory leaks in some particular contexts, but it has some limitations:

  • It can only be used to allocate arrays; you cannot use it to initialize them, which is possible with an std::unique_ptr constructor.

    The following two pieces of sample code are equivalent:

    // allocate and initialize an array
    std::unique_ptr<int[]> pa(new int[3]{ 1,2,3 });
    // allocate and then initialize an array
    std::unique_ptr<int[]> pa = std::make_unique<int[]>(3);
    for (int i = 0; i < 3; ++i)
      pa[i] = i + 1;
    
  • It cannot be used to create an std::unique_ptr object with a user-defined deleter.

As we just mentioned, the great advantage of make_unique() is that it helps us avoid memory leaks in some contexts where exceptions are being thrown. make_unique() itself can throw std::bad_alloc if the allocation fails or any exception is thrown by the constructor of the object it creates. Let's consider the following example:

void some_function(std::unique_ptr<foo> p)
{ /* do something */ }
some_function(std::unique_ptr<foo>(new foo()));
some_function(std::make_unique<foo>());

Regardless of what happens with the allocation and construction of foo, there will be no memory leaks, irrespective of whether you use make_unique() or the constructor of std::unique_ptr. However, this situation changes in a slightly different version of the code:

void some_other_function(std::unique_ptr<foo> p, int const v)
{
}
int function_that_throws()
{
  throw std::runtime_error("not implemented");
}
// possible memory leak
some_other_function(std::unique_ptr<foo>(new foo),
                    function_that_throws());
// no possible memory leak
some_other_function(std::make_unique<foo>(),
                    function_that_throws());

In this example, some_other_function() has an extra parameter: an integer value. The integer argument that's passed to this function is the returned value of another function. If this function call throws an exception, using the constructor of std::unique_ptr to create the smart pointer can produce a memory leak. The reason for this is that, upon calling some_other_function(), the compiler might first call foo, then function_that_throws(), and then the constructor of std::unique_ptr. If function_that_throws() throws an error, then the allocated foo will leak. If the calling order is function_that_throws() and then new foo() and the constructor of unique_ptr, a memory leak will not happen; this is because the stack starts unwinding before the foo object is allocated. However, by using the make_unique() function, this situation is avoided. This is because the only calls made are to make_unique() and function_that_throws(). If function_that_throws() is called first, then the foo object will not be allocated at all. If make_unique() is called first, the foo object is constructed and its ownership is passed to std::unique_ptr. If a later call to function_that_throws() does throw, then std::unique_ptr will be destroyed when the stack is unwound and the foo object will be destroyed from the smart pointer's destructor.

In C++20, a new function, called std::make_unique_for_overwrite(), has been added. This is similar to make_unique() except that it default initializes the object or the array of objects. This function can be used in generic code where it's unknown whether the type template parameter is trivially copyable or not. This function expresses the intent to create a pointer to an object that may not be initialized so that it should be overwritten later.

Constant std::unique_ptr objects cannot transfer the ownership of a managed object or array to another std::unique_ptr object. On the other hand, access to the raw pointer to the managed object can be obtained with either get() or release(). The first method only returns the underlying pointer, but the latter also releases the ownership of the managed object, hence the name. After a call to release(), the std::unique_ptr object will be empty and a call to get() will return nullptr.

An std::unique_ptr that manages the object of a Derived class can be implicitly converted to an std::unique_ptr that manages an object of the class Base if Derived is derived from Base. This implicit conversion is safe only if Base has a virtual destructor (as all base classes should have); otherwise, undefined behavior is employed:

struct Base
{
  virtual ~Base()
  {
    std::cout << "~Base()" << '
';
  }
};
struct Derived : public Base
{
  virtual ~Derived()
  {
    std::cout << "~Derived()" << '
';
  }
};
std::unique_ptr<Derived> pd = std::make_unique<Derived>();
std::unique_ptr<Base> pb = std::move(pd);

std::unique_ptr can be stored in containers, such as std::vector. Because only one std::unique_ptr object can own the managed object at any point, the smart pointer cannot be copied to the container; it has to be moved. This is possible with std::move(), which performs a static_cast to an rvalue reference type. This allows the ownership of the managed object to be transferred to the std::unique_ptr object that is created in the container.

See also

  • Using shared_ptr to share a memory resource, to learn about the std::shared_ptr class, which represents a smart pointer that shares ownership of an object or array of objects allocated on the heap.

Using shared_ptr to share a memory resource

Managing dynamically allocated objects or arrays with std::unique_ptr is not possible when the object or array has to be shared. This is because an std::unique_ptr retains its sole ownership. The C++ standard provides another smart pointer, called std::shared_ptr; it is similar to std::unique_ptr in many ways, but the difference is that it can share the ownership of an object or array with other std::shared_ptr. In this recipe, we will see how std::shared_ptr works and how it differs from std::uniqueu_ptr. We will also look at std::weak_ptr, which is a non-resource-owning smart pointer that holds a reference to an object managed by an std::shared_ptr.

Getting ready

Make sure you read the previous recipe, Using unique_ptr to uniquely own a memory resource, to become familiar with how unique_ptr and make_unique() work. We will use the foo, foo_deleter, Base, and Derived classes defined in this recipe, and also make several references to it.

Both the shared_ptr and weak_ptr classes, as well as the make_shared() function template, are available in the std namespace in the <memory> header.

For simplicity and readability, we will not use the fully qualified names std::unique_ptr, std::shared_ptr, and std::weak_ptr in this recipe, but unique_ptr, shared_ptr, and weak_ptr.

How to do it...

The following is a list of the typical operations you need to be aware of when working with shared_ptr and weak_ptr:

  • Use one of the available overloaded constructors to create a shared_ptr that manages an object through a pointer. The default constructor creates an empty shared_ptr, which does not manage any object:
    std::shared_ptr<int> pnull1;
    std::shared_ptr<int> pnull2(nullptr);
    std::shared_ptr<int> pi1(new int(42));
    std::shared_ptr<int> pi2 = pi1;
    std::shared_ptr<foo> pf1(new foo());
    std::shared_ptr<foo> pf2(new foo(42, 42.0, "42"));
    
  • Alternatively, use the std::make_shared() function template, available since C++11, to create shared_ptr objects:
    std::shared_ptr<int> pi  = std::make_shared<int>(42);
    std::shared_ptr<foo> pf1 = std::make_shared<foo>();
    std::shared_ptr<foo> pf2 = std::make_shared<foo>(42, 42.0, "42");
    
  • Use the std::make_shared_for_overwrite() function template, available in C++20, to create shared_ptrs to objects or arrays of objects that are default initialized. These objects should later be overwritten with a determined value:
    std::shared_ptr<int> pi = std::make_shared_for_overwrite<int>();
    std::shared_ptr<foo[]> pa = std::make_shared_for_overwrite<foo[]>(3);
    
  • Use the overloaded constructor, which takes a custom deleter if the default delete operation is not appropriate for destroying the managed object:
    std::shared_ptr<foo> pf1(new foo(42, 42.0, "42"),
                             foo_deleter());
    std::shared_ptr<foo> pf2(
            new foo(42, 42.0, "42"),
            [](foo* p) {
              std::cout << "deleting foo from lambda..." << '
    ';
              delete p;});
    
  • Always specify a deleter when managing an array of objects. The deleter can either be a partial specialization of std::default_delete for arrays or any function that takes a pointer to the template type:
    std::shared_ptr<int> pa1(
      new int[3]{ 1, 2, 3 },
      std::default_delete<int[]>());
    std::shared_ptr<int> pa2(
      new int[3]{ 1, 2, 3 },
      [](auto p) {delete[] p; });
    
  • To access the raw pointer to the managed object, use the get() function:
    void func(int* ptr)
    {
      if (ptr != nullptr)
        std::cout << *ptr << '
    ';
      else
        std::cout << "null" << '
    ';
    }
    std::shared_ptr<int> pi;
    func(pi.get());
    pi = std::make_shared<int>(42);
    func(pi.get());
    
  • Dereference the pointer to the managed object using operator* and operator->:
    std::shared_ptr<int> pi = std::make_shared<int>(42);
    *pi = 21;
    std::shared_ptr<foo> pf = std::make_shared<foo>(42, 42.0, "42");
    pf->print();
    
  • If a shared_ptr manages an array of objects, operator[] can be used to access the individual elements of the array. This is only available in C++17:
    std::shared_ptr<int[]> pa1(
      new int[3]{ 1, 2, 3 },
      std::default_delete<int[]>());
    for (int i = 0; i < 3; ++i)
      pa1[i] *= 2;
    
  • To check whether a shared_ptr could manage an object or not, use the explicit operator bool or check whether get() != nullptr (which is what the operator bool does):
    std::shared_ptr<int> pnull;
    if (pnull) std::cout << "not null" << '
    ';
    std::shared_ptr<int> pi(new int(42));
    if (pi) std::cout << "not null" << '
    ';
    
  • shared_ptr objects can be stored in containers, such as std::vector:
    std::vector<std::shared_ptr<foo>> data;
    for (int i = 0; i < 5; i++)
      data.push_back(
        std::make_shared<foo>(i, i, std::to_string(i)));
    auto pf = std::make_shared<foo>(42, 42.0, "42");
    data.push_back(std::move(pf));
    assert(!pf);
    
  • Use weak_ptr to maintain a non-owning reference to a shared object, which can be later accessed through a shared_ptr constructed from the weak_ptr object:
    auto sp1 = std::make_shared<int>(42);
    assert(sp1.use_count() == 1);
    std::weak_ptr<int> wpi = sp1;
    assert(sp1.use_count() == 1);
    auto sp2 = wpi.lock();
    assert(sp1.use_count() == 2);
    assert(sp2.use_count() == 2);
    sp1.reset();
    assert(sp1.use_count() == 0);
    assert(sp2.use_count() == 1);
    
  • Use the std::enable_shared_from_this class template as the base class for a type when you need to create shared_ptr objects for instances that are already managed by another shared_ptr object:
    struct Apprentice;
    struct Master : std::enable_shared_from_this<Master>
    {
      ~Master() { std::cout << "~Master" << '
    '; }
      void take_apprentice(std::shared_ptr<Apprentice> a);
    private:
      std::shared_ptr<Apprentice> apprentice;
    };
    struct Apprentice
    {
      ~Apprentice() { std::cout << "~Apprentice" << '
    '; }
      void take_master(std::weak_ptr<Master> m);
    private:
      std::weak_ptr<Master> master;
    };
    void Master::take_apprentice(std::shared_ptr<Apprentice> a)
    {
      apprentice = a;
      apprentice->take_master(shared_from_this());
    }
    void Apprentice::take_master(std::weak_ptr<Master> m)
    {
      master = m;
    }
    auto m = std::make_shared<Master>();
    auto a = std::make_shared<Apprentice>();
    m->take_apprentice(a);
    

How it works...

shared_ptr is very similar to unique_ptr in many aspects; however, it serves a different purpose: sharing the ownership of an object or array. Two or more shared_ptr smart pointers can manage the same dynamically allocated object or array, which is automatically destroyed when the last smart pointer goes out of scope, is assigned a new pointer with operator=, or is reset with the method reset(). By default, the object is destroyed with operator delete; however, the user could supply a custom deleter to the constructor, something that is not possible using std::make_shared(). If shared_ptr is used to manage an array of objects, a custom deleter must be supplied. In this case, you can use std::default_delete<T[]>, which is a partial specialization of the std::default_delete class template that uses operator delete[] to delete the dynamically allocated array.

The utility function std::make_shared() (available since C++11), unlike std::make_unique(), which has only been available since C++14, should be used to create smart pointers unless you need to provide a custom deleter. The primary reason for this is the same as for make_unique(): avoiding potential memory leaks in some contexts when an exception is thrown. For more information on this, read the explanation provided on std::make_unique() in the previous recipe.

In C++20, a new function, called std::make_shared_for_overwrite(), has been added. This is similar to make_shared() except that it default initializes the object or the array of objects. This function can be used in generic code where it's unknown whether the type template parameter is trivially copyable or not. This function expresses the intent to create a pointer to an object that may not be initialized so that it should be overwritten later.

Also, as in the case of unique_ptr, a shared_ptr that manages an object of a Derived class can be implicitly converted to a shared_ptr that manages an object of the Base class. This is possible if the Derived class is derived from Base. This implicit conversion is safe only if Base has a virtual destructor (as all the base classes should have when objects are supposed to be deleted polymorphically through a pointer or reference to the base class); otherwise, undefined behavior is employed. In C++17, several new non-member functions have been added: std::static_pointer_cast(), std::dynamic_pointer_cast(), std::const_pointer_cast(), and std::reinterpret_pointer_cast(). These apply static_cast, dynamic_cast, const_cast, and reinterpret_cast to the stored pointer, returning a new shared_ptr to the designated type.

In the following example, Base and Derived are the same classes we used in the previous recipe:

std::shared_ptr<Derived> pd = std::make_shared<Derived>();
std::shared_ptr<Base> pb = pd;
std::static_pointer_cast<Derived>(pb)->print();

There are situations when you need a smart pointer for a shared object but without it contributing to the shared ownership. Suppose you model a tree structure where a node has references to its children and they are represented by shared_ptr objects. On the other hand, say a node needs to keep a reference to its parent. If this reference were also shared_ptr, then it would create circular references and no object would ever be automatically destroyed.

weak_ptr is a smart pointer that's used to break such circular dependencies. It holds a non-owning reference to an object or array managed by a shared_ptr. weak_ptr can be created from a shared_ptr object. In order to access the managed object, you need to get a temporary shared_ptr object. To do so, we need to use the lock() method. This method atomically checks whether the referred object still exists and returns either an empty shared_ptr, if the object no longer exists, or a shared_ptr that owns the object, if it still exists. Because weak_ptr is a non-owning smart pointer, the referred object can be destroyed before weak_ptr goes out of scope or when all the owning shared_ptr objects have been destroyed, reset, or assigned to other pointers. The method expired() can be used to check whether the referenced object has been destroyed or is still available.

In the How to do it... section, the preceding example models a master-apprentice relationship. There is a Master class and an Apprentice class. The Master class has a reference to an Apprentice class and a method called take_apprentice() to set the Apprentice object. The Apprentice class has a reference to a Master class and the method take_master() to set the Master object. In order to avoid circular dependencies, one of these references must be represented by a weak_ptr. In the proposed example, the Master class had a shared_ptr to own the Apprentice object, and the Apprentice class had a weak_ptr to track a reference to the Master object. This example, however, is a bit more complex because here, the Apprentice::take_master() method is called from Master::take_apprentice() and needs a weak_ptr<Master>. In order to call it from within the Master class, we must be able to create a shared_ptr<Master> in the Master class, using the this pointer. The only way to do this in a safe manner is to use std::enable_shared_from_this.

std::enable_shared_from_this is a class template that must be used as a base class for all the classes where you need to create a shared_ptr for the current object (the this pointer) when this object is already managed by another shared_ptr. Its type template parameter must be the class that derives from it, as in the curiously recurring template pattern. It has two methods: shared_from_this(), which returns a shared_ptr, which shares the ownership of the this object, and weak_from_this(), which returns a weak_ptr, which shares a non-owning reference to the this object. The latter method is only available in C++17. These methods can be called only on an object that is managed by an existing shared_ptr; otherwise, they throw an std::bad_weak_ptr exception, as of C++17. Prior to C++17, the behavior was undefined.

Not using std::enable_shared_from_this and creating a shared_ptr<T>(this) directly would lead to having multiple shared_ptr objects managing the same object independently, without knowing each other. When this happens, the object ends up being destroyed multiple times by different shared_ptr objects.

See also

  • Using unique_ptr to uniquely own a memory resource, to learn about the std::unique_ptr class, which represents a smart pointer that owns and manages another object or array of objects allocated on the heap.

Implementing move semantics

Move semantics is a key feature that drives the performance improvements of modern C++. They enable moving, rather than copying, resources or, in general, objects that are expensive to copy. However, it requires that classes implement a move constructor and assignment operator. These are provided by the compiler in some circumstances, but in practice, it is often the case that you have to explicitly write them. In this recipe, we will see how to implement the move constructor and the move assignment operator.

Getting ready

You are expected to have basic knowledge of rvalue references and the special class functions (constructors, assignment operators, and destructor). We will demonstrate how to implement a move constructor and assignment operator using the following Buffer class:

class Buffer
{
  unsigned char* ptr;
  size_t length;
public:
  Buffer(): ptr(nullptr), length(0)
  {}
  explicit Buffer(size_t const size):
    ptr(new unsigned char[size] {0}), length(size)
  {}
  ~Buffer()
  {
    delete[] ptr;
  }
  Buffer(Buffer const& other):
    ptr(new unsigned char[other.length]),
    length(other.length)
  {
    std::copy(other.ptr, other.ptr + other.length, ptr);
  }
  Buffer& operator=(Buffer const& other)
  {
    if (this != &other)
    {
      delete[] ptr;
      ptr = new unsigned char[other.length];
      length = other.length;
      std::copy(other.ptr, other.ptr + other.length, ptr);
   }
    return *this;
  }
  size_t size() const { return length;}
  unsigned char* data() const { return ptr; }
};

Let's move on to the next section, where you'll learn how to modify this class in order to benefit from move semantics.

How to do it...

To implement the move constructor for a class, do the following:

  1. Write a constructor that takes an rvalue reference to the class type:
    Buffer(Buffer&& other)
    {
    }
    
  2. Assign all the data members from the rvalue reference to the current object. This can be done either in the body of the constructor, as follows, or in the initialization list, which is the preferred way:
    ptr = other.ptr;
    length = other.length;
    
  3. Assign the data members from the rvalue reference to default values:
    other.ptr = nullptr;
    other.length = 0;
    

Put all together, the move constructor for the Buffer class looks like this:

Buffer(Buffer&& other)
{
  ptr = other.ptr;
  length = other.length;
  other.ptr = nullptr;
  other.length = 0;
}

To implement the move assignment operator for a class, do the following:

  1. Write an assignment operator that takes an rvalue reference to the class type and returns a reference to it:
    Buffer& operator=(Buffer&& other)
    {
    }
    
  2. Check that the rvalue reference does not refer to the same object as this, and if they are different, perform steps 3 to 5:
    if (this != &other)
    {
    }
    
  3. Dispose of all the resources (such as memory, handles, and so on) from the current object:
    delete[] ptr;
    
  4. Assign all the data members from the rvalue reference to the current object:
    ptr = other.ptr;
    length = other.length;
    
  5. Assign the data members from the rvalue reference to the default values:
    other.ptr = nullptr;
    other.length = 0;
    
  6. Return a reference to the current object, regardless of whether steps 3 to 5 were executed or not:
    return *this;
    

Put all together, the move assignment operator for the Buffer class looks like this:

Buffer& operator=(Buffer&& other)
{
  if (this != &other)
  {
    delete[] ptr;
    ptr = other.ptr;
    length = other.length;
    other.ptr = nullptr;
    other.length = 0;
  }
  return *this;
}

How it works...

The move constructor and move assignment operator are provided by default by the compiler unless a user-defined copy constructor, move constructor, copy assignment operator, move assignment operator, or destructor exists already. When provided by the compiler, they perform a movement in a member-wise manner. The move constructor invokes the move constructors of the class data members recursively; similarly, the move assignment operator invokes the move assignment operators of the class data members recursively.

Move, in this case, represents a performance benefit for objects that are too large to copy (such as a string or container) or for objects that are not supposed to be copied (such as the unique_ptr smart pointer). Not all classes are supposed to implement both copy and move semantics. Some classes should only be movable, while others both copyable and movable. On the other hand, it does not make much sense for a class to be copyable but not moveable, though this can be technically achieved.

Not all types benefit from move semantics. In the case of built-in types (such as bool, int, or double), arrays, or PODs, the move is actually a copy operation. On the other hand, move semantics provide a performance benefit in the context of rvalues, that is, temporary objects. An rvalue is an object that does not have a name; it lives temporarily during the evaluation of an expression and is destroyed at the next semicolon:

T a;
T b = a;
T c = a + b;

In the preceding example, a, b, and c are lvalues; they are objects that have a name that can be used to refer to the object at any point throughout its lifetime. On the other hand, when you evaluate the expression a+b, the compiler creates a temporary object (which, in this case, is assigned to c) and then destroyed (when a semicolon is encountered). These temporary objects are called rvalues because they usually appear on the right-hand side of an assignment expression. In C++11, we can refer to these objects through rvalue references, expressed with &&.

Move semantics are important in the context of rvalues. This is because they allow you to take ownership of the resources from the temporary object that is destroyed, without the client being able to use it after the move operation is completed. On the other hand, lvalues cannot be moved; they can only be copied. This is because they can be accessed after the move operation, and the client expects the object to be in the same state. For instance, in the preceding example, the expression b = a assigns a to b.

After this operation is complete, the object a, which is an lvalue, can still be used by the client and should be in the same state as it was before. On the other hand, the result of a+b is temporary, and its data can be safely moved to c.

The move constructor is different than a copy constructor because it takes an rvalue reference to the class type T(T&&), as opposed to an lvalue reference in the case of the copy constructor T(T const&). Similarly, move assignment takes an rvalue reference, namely T& operator=(T&&), as opposed to an lvalue reference for the copy assignment operator, namely T& operator=(T const &). This is true even though both return a reference to the T& class. The compiler selects the appropriate constructor or assignment operator based on the type of argument, rvalue, or lvalue.

When a move constructor/assignment operator exists, an rvalue is moved automatically. lvalues can also be moved, but this requires an explicit static cast to an rvalue reference. This can be done using the std::move() function, which basically performs a static_cast<T&&>:

std::vector<Buffer> c;
c.push_back(Buffer(100));  // move
Buffer b(200);
c.push_back(b);            // copy
c.push_back(std::move(b)); // move

After an object is moved, it must remain in a valid state. However, there is no requirement regarding what this state should be. For consistency, you should set all member fields to their default value (numerical types to 0, pointers to nullptr, Booleans to false, and so on).

The following example shows the different ways in which Buffer objects can be constructed and assigned:

Buffer b1;                // default constructor
Buffer b2(100);           // explicit constructor
Buffer b3(b2);            // copy constructor
b1 = b3;                  // assignment operator
Buffer b4(std::move(b1)); // move constructor
b3 = std::move(b4);       // move assignment

The constructor or assignment operator involved in the creation or assignment of the objects b1, b2, b3, and b4 is mentioned in the comments on each line.

There's more...

As seen with the Buffer example, implementing both the move constructor and move assignment operator involves writing similar code (the entire code of the move constructor was also present in the move assignment operator). This can actually be avoided by calling the move assignment operator in the move constructor:

Buffer(Buffer&& other) : ptr(nullptr), length(0)
{
  *this = std::move(other);
}

There are two points that must be noticed in this example:

  • Member initialization in the constructor's initialization list is necessary because these members could potentially be used in the move assignment operator later on (such as the ptr member in this example).
  • Static casting of other to an rvalue reference. Without this explicit conversion, the copy assignment operator would be called. This is because even if an rvalue is passed to this constructor as an argument, when it is assigned a name, it is bound to an lvalue. Therefore, other is actually an lvalue, and it must be converted to an rvalue reference in order to invoke the move assignment operator.

See also

  • Defaulted and deleted functions, in Chapter 3, Exploring Functions, to learn about the use of the default specifier on special member functions and how to define functions as deleted with the delete specifier.

Consistent comparison with the operator <=>

The C++ language defines six relational operators that perform comparison: ==, !=, <, <=, >, and >=. Although != can be implemented in terms of ==, and <=, >=, and > in terms of <, you still have to implement both == and != if you want your user-defined type to support equality comparison, and <, <=, >, and >= if you want it to support ordering.

That means 6 functions if you want objects of your type—let's call it T—to be comparable, 12 if you want them to be comparable with another type, U, 18 if you also want values of a U type to be comparable with your T type, and so on. The new C++20 standard reduces this number to either one or two, or multiple of these (depending on the comparison with other types) by introducing a new comparison operator, called the three-way comparison, which is designated with the symbol <=>, for which reason it is popularly known as the spaceship operator. This new operator helps us write less code, better describe the strength of relations, and avoid possible performance issues of manually implementing comparison operators in terms of others.

Getting ready

It is necessary to include the header <compare> when defining or implementing the three-way comparison operator. This new C++20 header is part of the standard general utility library and provides classes, functions, and concepts for implementing comparison.

How to do it…

To optimally implement comparison in C++20, do the following:

  • If you only want your type to support equality comparison (both == and !=), implement only the == operator and return a bool. You can default the implementation so that the compiler performs a member-wise comparison:
    class foo
    {
      int value;
    public:
      foo(int const v):value(v){}
      bool operator==(foo const&) const = default;
    };
    
  • If you want your type to support both equality and ordering and the default member-wise comparison will do, then only define the <=> operator, returning auto, and default its implementation:
    class foo
    {
      int value;
    public:
      foo(int const v) :value(v) {}
      auto operator<=>(foo const&) const = default;
    };
    
  • If you want your type to support both equality and ordering and you need to perform custom comparison, then implement both the == operator (for equality) and the <=> operator (for ordering):
    class foo
    {
      int value;
    public:
      foo(int const v) :value(v) {}
      bool operator==(foo const& other) const
      { return value == other.value; }
      auto operator<=>(foo const& other) const
      { return value <=> other.value; }
    };
    

When implementing the three-way comparison operator, follow these guidelines:

  • Only implement the three-way comparison operator but always use the two-way comparison operators <, <=, >, and >= when comparing values.
  • Implement the three-way comparison operator as a member function, even if you want the first operand of a comparison to be of a type other than your class.
  • Implement the three-way comparison operator as non-member functions only if you want implicit conversion on both arguments (that means comparing two objects, neither of which is of your class).

How it works…

The new three-way comparison operator is similar to the memcmp()/strcmp() C functions and the std::string::compare() method. These functions take two arguments and return an integer value that is smaller than zero if the first is less than the second, zero if they are equal, or greater than zero if the first argument is greater than the second. The three-way comparison operator does not return an integer but a value of a comparison category type.

This can be one of the following:

  • std::strong_ordering represents the result of a three-way comparison that supports all six relational operators, does not allow incomparable values (which means that at least one of a < b, a == b, and a > b must be true), and implies substitutability. This is a property such that if a == b and f is a function that reads only comparison-salient state (accessible via the argument's public constant members), then f(a) == f(b).
  • std::weak_ordering supports all the six relational operators, does not support incomparable values (which means that none of a < b, a == b, and a > b could be true), but also does not imply substitutability. A typical example of a type that defines weak ordering is a case-insensitive string type.
  • std::partial_ordering supports all six relational operators, but does not imply substitutability and has a value that might not be comparable (for instance, a floating point NaN cannot be compared to any other value).

The std::strong_ordering type is the strongest of all these category types. It is not implicitly convertible from any other category, but it implicitly converts to both std::weak_ordering and std::partial_ordering. std::weak_ordering is also implicitly convertible to std::partial_ordering. We've summarized all these properties in the following table:

Category

Operators

Substitutability

Comparable values

Implicit conversion

std::strong_ordering

==, !=, <, <=, >, >=

Yes

Yes

std::weak_ordering

==, !=, <, <=, >, >=

No

Yes

std::partial_ordering

==, !=, <, <=, >, >=

No

No

These comparison categories have values that are implicitly comparable with literal zero (but not with an integer variable with a value of zero). Their values are listed in the following table:

Category

Numeric values

Non-numeric values

-1

0

1

strong_ordering

less

equal

equivalent

greater

weak_ordering

less

equivalent

greater

partial_ordering

less

equivalent

greater

unordered

To better understand how this works, let's look at the following example:

class cost_unit_t
{
  // data members
public:
  std::strong_ordering operator<=>(cost_unit_t const & other) const noexcept = default;
};
class project_t : public cost_unit_t
{
  int         id;
  int         type;
  std::string name;
public:
  bool operator==(project_t const& other) const noexcept
  {
    return (cost_unit_t&)(*this) == (cost_unit_t&)other &&
           name == other.name &&
           type == other.type &&
           id == other.id;
  }
  std::strong_ordering operator<=>(project_t const & other) const noexcept
  {
    // compare the base class members
    if (auto cmp = (cost_unit_t&)(*this) <=> (cost_unit_t&)other;
        cmp != 0)
      return cmp;
    // compare this class members in custom order
    if (auto cmp = name.compare(other.name); cmp != 0)
      return cmp < 0 ? std::strong_ordering::less :
                       std::strong_ordering::greater;
    if (auto cmp = type <=> other.type; cmp != 0)
      return cmp;
    return id <=> other.id;
  }
};

Here, cost_unit_t is a base class that contains some (unspecified) data members and defines the <=> operator, although it defaults the implementation. This means that the compiler will also provide the == and != operators, not just <, <=, >, and >=. This class is derived by project_t, which contains several data fields: an identifier for the project, a type, and a name. However, for this type, we cannot default the implementation of the operators, because we do not want to compare the fields member-wise, but in a custom order: first the name, then the type, and lastly the identifier. In this case, we implement both the == operator, which returns a bool and tests the member fields for equality, and the <=> operator, which returns std::strong_ordering and uses the <=> operator itself to compare the values of its two arguments.

The following code snippet shows a type called employee_t that models employees in a company. An employee can have a manager, and an employee that is a manager has people that it manages. Conceptually, such a type could look as follows:

struct employee_t
{
  bool is_managed_by(employee_t const&) const { /* ... */ }
  bool is_manager_of(employee_t const&) const { /* ... */ }
  bool is_same(employee_t const&) const       { /* ... */ }
  bool operator==(employee_t const & other) const
  {
    return is_same(other);
  }
  std::partial_ordering operator<=>(employee_t const& other) const noexcept
  {
    if (is_same(other))
      return std::partial_ordering::equivalent;
    if (is_managed_by(other))
      return std::partial_ordering::less;
    if (is_manager_of(other))
      return std::partial_ordering::greater;
    return std::partial_ordering::unordered;
  }
};

The methods is_same(), is_manager_of(), and is_managed_by() return the relationship of two employees. However, it is possible there are employees with no relationship; for instance, employees in different teams, or the same team that are not in a manager-subordinate position. Here, we can implement equality and ordering. However, since we cannot compare all employees with each other, the <=> operator must return an std::partial_ordering value. The return value is partial_ordering::equivalent if the values represent the same employee, partial_ordering::less if the current employee is managed by the supplied one, partial_ordering::greater if the current employee is the manager of the supplied one, and partial_ordering::unorder in all other cases.

Let's see one more example to understand how the three-way comparison operator works. In the following sample, the ipv4 class models an IP version 4 address. It supports comparison with both other objects of the ipv4 type but also unsigned long values (because there is a to_unlong() method that converts the IP address into a 32-bit unsigned integral value):

struct ipv4
{
  explicit ipv4(unsigned char const a=0, unsigned char const b=0,
                unsigned char const c=0, unsigned char const d=0) noexcept :
    data{ a,b,c,d }
  {}
  unsigned long to_ulong() const noexcept
  {
    return
      (static_cast<unsigned long>(data[0]) << 24) |
      (static_cast<unsigned long>(data[1]) << 16) |
      (static_cast<unsigned long>(data[2]) << 8) |
      static_cast<unsigned long>(data[3]);
  }
  auto operator<=>(ipv4 const&) const noexcept = default;
  bool operator==(unsigned long const other) const noexcept
  {
    return to_ulong() == other;
  }
  std::strong_ordering
  operator<=>(unsigned long const other) const noexcept
  {
    return to_ulong() <=> other;
  }
private:
  std::array<unsigned char, 4> data;
};

In this example, we overloaded the <=> operator and allowed it to be default implemented. But we also explicitly implemented overloads for operator== and operator<=>, which compare an ipv4 object with an unsigned long value. Because of these operators, we can write any of the following:

ipv4 ip(127, 0, 0, 1);
if(ip == 0x7F000001) {}
if(ip != 0x7F000001) {}
if(0x7F000001 == ip) {}
if(0x7F000001 != ip) {}
if(ip < 0x7F000001)  {}
if(0x7F000001 < ip)  {}

There are two things to notice here: the first is that although we only overloaded the == operator, we can also use the != operator, and second, although we overloaded the == operator and the <=> operator to compare ipv4 values to unsigned long values, we can also compare unsigned long values to ipv4 values. This is because the compiler performs symmetrical overload resolution. That means that for an expression a@b where @ is a two-way relational operator, it performs name lookup for a@b, a<=>b, and b<=>a. The following table shows the list of all possible transformations of the relational operators:

a == b

b == a

a != b

!(a == b)

!(b == a)

a <=> b

0 <=> (b <=> a)

a < b

(a <=> b) < 0

0 > (b <=> a)

a <= b

(a <=> b) <= 0

0 >= (b <=> a)

a > b

(a <=> b) > 0

0 < (b <=> a)

a >= b

(a <=> b) >= 0

0 <= (b <=> a)

This greatly reduces the number of overloads you must explicitly provide for supporting comparison in different forms. The three-way comparison operator can be implemented either as a member or as a non-member function. In general, you should prefer the member implementation.

The non-member form should be used only when you want implicit conversion on both arguments. The following shows an example:

struct A { int i; };
struct B
{
  B(A a) : i(a.i) { }
  int i;
};
inline auto
operator<=>(B const& lhs, B const& rhs) noexcept
{
  return lhs.i <=> rhs.i;
}
assert(A{ 2 } < A{ 1 });

Although the <=> operator is defined for the type B, because it is a non-member and because A can be implicitly converted to B, we can perform comparison on objects of the A type.

See also

  • Simplifying code with class template argument deduction, in Chapter 1, Learning Modern Core Language Features, to learn how to use class templates without explicitly specifying template arguments.
  • Ensuring constant correctness for a program, to explore the benefits of constant correctness and how to achieve it.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.220.1.239