- The curse of non-const global variables
- Dependency injection as a cure
- Making good interfaces
- Related rules
Making good interfaces
Functions should not communicate via global variables but through interfaces. Now we are in the core of this chapter. According to the C++ Core Guidelines, here are the recommendations for interfaces. Interfaces should follow these rules:
Make interfaces explicit (I.1).
Make interfaces precise and strongly typed (I.4).
Keep the number of function arguments low (I.23).
Avoid adjacent unrelated parameters of the same type (I.24).
The first function showRectangle breaks all mentioned rules for interfaces:
void showRectangle(double a, double b, double c, double d) { a = floor(a); b = ceil(b); ... } void showRectangle(Point top_left, Point bottom_right);
Although the first function showRectangle should show only a rectangle, it modifies its arguments. Essentially, it has two purposes and has, as a consequence, a misleading name (I.1). Additionally, the function signature does not provide any information about what the arguments should be, nor in which sequence the arguments must be given (I.23 and I.24). Furthermore, the arguments are doubles without a constraint value range. This constraint must, therefore, be established in the function body (I.4). In contrast, the second function showRectangle takes two concrete points. Checking to see if a Point has valid value is the job of the constructor of Point. This responsibility should not be the job of the function.
I want to elaborate more on the rules I.23 and I.24 and the function std::transform_reduce from the Standard Template Library (STL). First, I need to define the term callable. A callable is something that behaves like a function. This can be a function but also a function object, or a lambda expression. If a callable accepts one argument, it is called a unary callable; if it takes two arguments, it is called a binary callable.
std::transform_reduce first applies a unary callable to one range or a binary callable to two ranges and then a binary callable to the resulting range. When you use std::transform_reduce with a unary lambda expression, the call is easy to use correctly:
std::vector<std::string> strVec{"Only", "for", "testing", "purpose"}; std::size_t res = std::transform_reduce( std::execution::par, strVec.begin(), strVec.end(), 0, [](std::size_t a, std::size_t b) { return a + b; }, [](std::string s) { return s.size(); } );
The function std::transform_reduce transforms each string onto its length ([](const std::string s) { return s.size(); }) and applies the binary callable ([](std::size_t a, std::size_t b) { return a + b; }) to the resulting range. The initial value for the summation is 0. The whole calculation is performed in parallel: std::execution::par.
When you use the overload, which accepts two binary callables, the declaration of the function becomes quite complicated and error prone. Consequently, it breaks the rules I.23 and I.24.
template<class ExecutionPolicy, class ForwardIt1, class ForwardIt2, class T, class BinaryOp1, class BinaryOp2> T transform_reduce(ExecutionPolicy&& policy, ForwardIt1 first1, ForwardIt1 last1, ForwardIt2 first2, T init, BinaryOp1 binary_op1, BinaryOp2 binary_op2);
Calling this overload would require six template arguments and seven function arguments. Using the binary callables in the correct sequence may also be a challenge.
transform | reduce
The main reason for the complicated function std::transform_reduce is that two functions are combined into one. Defining two separate functions transform and reduce and supporting function composition via the pipe operator would be a better choice: transform | reduce.
The guideline that you should not pass an array as a single pointer is special. I can tell you from experience that this rule is a common cause of undefined behavior. For instance, the function copy_n is quite error prone.
template <typename T> void copy_n(const T* p, T* q, int n); // copy from [p:p+n) to [q:q+n) ... int a[100] = {0, }; int b[100] = {0, }; copy_n(a, b, 101);
Maybe you had an exhausting day and you miscounted by one. The result is an off-by-one error and, therefore, undefined behavior. The cure is simple. Use a container from the STL such as std::vector and check the size of the container in the function body. C++20 offers std::span, which solves this issue more elegantly. A std::span is an object that can refer to a contiguous sequence of objects. A std::span is never an owner. This contiguous memory can be an array, a pointer with a size, or a std::vector.
template <typename T> void copy(std::span<const T> src, std::span<T> des); int arr1[] = {1, 2, 3}; int arr2[] = {3, 4, 5}; ... copy(arr1, arr2);
copy doesn’t need the number of elements. Hence, a common cause of errors is eliminated with std::span<T>.
An application binary interface (ABI) is the interface between two binary programs.
Thanks to the PImpl idiom, you can isolate the users of a class from its implementation and, therefore, avoid recompilation. PImpl stands for pointer to implementation and is a programming technique in C++ that removes implementation details from a class by placing them in a separate class. This separate class is accessed by a pointer. This is done because private data members participate in class layout and private member functions participate in overload resolution. These dependencies mean that changes to those implementation details require recompilation of all users of a class. A class holding a pointer to implementation (PImpl) can isolate the users of a class from changes in its implementation at the cost of an indirection.
The C++ Core Guidelines show a typical implementation.
Interface: Widget.h
class Widget { class impl; std::unique_ptr<impl> pimpl; public: void draw(); // public API that will be forwarded // to the implementation Widget(int); // defined in the implementation file ~Widget(); // defined in the implementation file, // where impl is a complete type Widget(Widget&&) = default; Widget(const Widget&) = delete; Widget& operator = (Widget&&); // defined in the // implementation file Widget& operator = (const Widget&) = delete; };
Implementation: Widget.cpp
class Widget::impl { int n; // private data public: void draw(const Widget& w) { /* ... */ } impl(int n) : n(n) {} }; void Widget::draw() { pimpl->draw(*this); } Widget::Widget(int n) : pimpl{std::make_unique<impl>(n)} {} Widget::~Widget() = default; Widget& Widget::operator = (Widget&&) = default;
cppreference.com provides more information about the PImpl idiom. Additionally, the rule “C.129: When designing a class hierarchy, distinguish between implementation inheritance and interface inheritance” shows how to apply the PImpl idiom to dual inheritance.