Chapter 3

Interfaces

Images

Cippi assembles components.

An interface is a contract between a service provider and a service user. Interfaces are, according to the C++ Core Guidelines, “probably the most important single aspect of code organization.” The section on interfaces has about twenty rules. Four of the rules are related to contracts, which didn’t make it into the C++20 standard.

A few rules related to interfaces involve contracts, which may be part of C++23. A contract specifies preconditions, postconditions, and invariants for functions that can be checked at run time. Due to the uncertainty of the future, I ignore these rules. The appendix provides a short introduction to contracts.

Let me end this introduction with my favorite quote from Scott Meyers:

Make interfaces easy to use correctly and hard to use incorrectly.

I.2

Avoid non-const global variables

Of course, you should avoid non-const global variables. But why? Why is a global variable, in particular when it is non-constant, bad? A global injects a hidden dependency into the function, which is not part of the interface. The following code snippet makes my point:

int glob{2011};
 
int multiply(int fac) {
   glob *= glob;
   return glob * fac;
}

The execution of the function multiply changes, as a side effect, the value of the global variable glob. Therefore, you cannot test the function or reason about the function in isolation. When more threads use multiply concurrently, you have to protect the variable glob. There are more drawbacks to non-const global variables. If the function multiply had no side effects, you could have stored the previous result and reused the cached value for performance reasons.

The curse of non-const global variables

Using non-const globals has many drawbacks. First and foremost, non-const globals break encapsulation. This breaking of encapsulation makes it impossible to think about your functions/classes (entities) in isolation. The following bullet points enumerate the main drawbacks of non-const global variables.

  • Testability: You cannot test your entities in isolation. There are no units, and therefore, there is no unit testing. You can only perform system testing. The effect of your entities depends on the state of the entire system.

  • Refactoring: It is quite challenging to refactor your code because you cannot reason about your code in isolation.

  • Optimization: You cannot easily rearrange the function invocations or perform the function invocations on different threads because there may be hidden dependencies. It’s also extremely dangerous to cache previous results of function calls.

  • Concurrency: The necessary condition for having a data race is a shared, mutable state. Non-const global variables are shared and mutable.

I.3

Avoid singletons

Sometimes, global variables are very well disguised.

// singleton.cpp
   
class MySingleton {
   
   public:
      MySingleton(const MySingleton&)= delete;
      MySingleton& operator = (const MySingleton&)= delete;
   
      static MySingleton* getInstance() {
         if ( !instance ){
            instance= new MySingleton();
         }
         return instance;
      }
   
   private:
      static MySingleton* instance;
      MySingleton()= default;
      ~MySingleton()= default;
};
   
MySingleton* MySingleton::instance= nullptr;
   
   
int main() {
   
   std::cout << MySingleton::getInstance() << "\n";
   std::cout << MySingleton::getInstance() << "\n";

}

A singleton is just a global, and you should, therefore, avoid singletons, if possible. A singleton gives the straightforward guarantee that only one instance of a class exists. As a global, a singleton injects a dependency, which ignores the interface of a function. This is due to the fact that singletons as static variables are typically invoked directly: Singleton::getInstance() as shown in the two lines of the main function. The direct invocation of the singleton has a few serious consequences. You cannot unit test a function having a singleton because there is no unit. Additionally, you cannot fake your singleton and replace it during run time because the singleton is not part of the function interface. To make it short: Singletons break the testability of your code.

Implementing a singleton seems like a piece of cake but is not. You are faced with a few challenges:

  • Who is responsible for destroying the singleton?

  • Should it be possible to derive from the singleton?

  • How can you initialize a singleton in a thread-safe way?

  • In which sequence are singletons initialized when they depend on each other and are in different translation units? This is to scare you. This challenge is called the static initialization order problem.

The bad reputation of the singleton is, in particular, due to an additional fact. Singletons were heavily overused. I see programs that consist entirely of singletons. There are no objects because the developer wants to prove that they apply design patterns.

Dependency injection as a cure

When an object uses a singleton, it injects a hidden dependency into the object. Thanks to dependency injection, this dependency is part of the interface, and the service is injected from the outside. Consequently, there is no dependency between the client and the injected service. Typical ways to inject dependencies are constructors, setter members, or template parameters.

The following program shows how you can replace a logger using dependency injection.

// dependencyInjection.cpp
   
#include <chrono>
#include <iostream>
#include <memory>
   
class Logger {
public:
   virtual void write(const std::string&) = 0;
   virtual ~Logger() = default;
};
   
class SimpleLogger: public Logger {
   void write(const std::string& mess) override {
      std::cout << mess << std::endl;
   }
};
   
class TimeLogger: public Logger {
   using MySecondTick = std::chrono::duration<long double>;
   long double timeSinceEpoch() {
      auto timeNow = std::chrono::system_clock::now();
      auto duration = timeNow.time_since_epoch();
      MySecondTick sec(duration);
      return sec.count();
   }
   void write(const std::string& mess) override {
      std::cout << std::fixed;
      std::cout << "Time since epoch: " << timeSinceEpoch() 
   }
   
};
   
class Client {
public:
   Client(std::shared_ptr<Logger> log): logger(log) {}
   void doSomething() {
      logger->write("Message");
   }
   void setLogger(std::shared_ptr<Logger> log) {
      logger = log;
   }

private:
   std::shared_ptr<Logger> logger;
};
   

int main() {
   
   std::cout << '\n';
   
   Client cl(std::make_shared<SimpleLogger>());    // (1)
   cl.doSomething();
   cl.setLogger(std::make_shared<TimeLogger>());   // (2)
   cl.doSomething();
   cl.doSomething();
   
   std::cout << '\n';
   
}

The client cl supports the constructor (1) and the member function setLogger (2) to inject the logger service. In contrast to the SimpleLogger, the TimeLogger includes the time since epoch in its message (see Figure 3.1).

Images

Figure 3.1 Dependency injection

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.

I.13

Do not pass an array as a single pointer

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

I.27

For stable library ABI, consider the Pimpl idiom

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.

Related rules

I present the rule “I.10: Use exceptions to signal a failure to perform a required task” in Chapter 11, Error Handling, the rule “I.11: Never transfer ownership by a raw pointer (T*) or reference (T&)” in Chapter 4, Functions, the rule “I.22: Avoid complex initialization of global objects” in Chapter 8, Expressions and Statements, and the rule “I.25: Prefer abstract classes as interfaces to class hierarchies” in Chapter 5, Classes and Class Hierarchies.