Daily bit(e) of C++ | Learn Modern C++ 7/N
Daily bit(e) of C++ #167, A Modern-only C++ course (including C++23), part 7 of N: User types
Welcome to the seventh lesson in the Learn Modern C++ series. Today we are revisiting user types.
If you missed the previous lesson, you can find it here:
With this topic, we are also approaching the end of the beginner arc of the course. There will be one more lesson after this. Then the course will take a break, restarting with the intermediate/advanced content.
User types
So far, the only user types we have talked about are aggregates. Aggregates offer simplicity and delegate the definitions of operations, such as initialization or assignment, to the compiler.
This is very convenient, but naturally, also very limiting. We cannot maintain an internal invariant as the state can be changed externally.
To address this problem, we can mark our members as private, disallowing external access. Consequently, we lose access to aggregate initialization and must provide our constructor.
Let’s look at a concrete example. Let’s implement a numerical type for representing rational numbers; for now, without an invariant.
Access specifiers
Access specifiers define who can access that section's members (or member functions). Public members can be accessed by anyone; private members can only be accessed by members of this type or friends.
I mentioned that friends could also access private members, so let’s return to our rational number type. In a previous lesson, we discussed stream insertion and extraction operators and how to overload them.
However, if we tried to apply that to our rational number type, we would have a problem since these operator overloads need access to the private members. This is where friend declarations come in.
Until now, we have been diligently using the keyword struct for user types. However, you might have come across the keyword class. The only difference is that class defaults to private access, whereas struct defaults to public access.
The choice of whether to use struct or class is purely stylistic. However, some style guides use struct and class for different purposes (e.g., only use struct for aggregate types).
Constructors
Another reason to switch away from aggregates is to customize the initialization process.
For an aggregate, the default initialization will default initialize all members; with a custom constructor, we can initialize each member as desired.
Note that we used the friend keyword even for the SimpleLabel. This allows us to declare the overload inside the SimpleLabel definition, making it obvious to anyone looking at the code that the stream insertion operation is supported.
In the above example, we also included the full definition inline; that is very much up to style.
Initializer list
In both Label and our previous example with Rational, we have relied on the initializer list to set up the state of our members. The initializer lists exist to prevent double-initialization. When we enter the body of a constructor, all members already have to be properly initialized. This means that if we do not list a member in the initializer list, it will be default initialized.
The initializer list allows us to control how each member is initialized. However, there is one thing to keep in mind when doing so. The order of initialization is fixed to the order listed in our type. Fortunately, most compilers will warn you about this.
Parametrized constructors
When we provide a parametrized constructor, we are signalling to the compiler that our type requires input to be initialized. Consequently, the compiler will not emit code for a default constructor.
We can bring the default constructor back by either implementing it or declaring it with = default, which will instruct the compiler to generate a default one.
You might have noticed the keyword explicit that has cropped up in the previous examples. This prevents implicit conversions and should always be used for single-argument parametrized constructors. Implicit conversions can lead to hard-to-track-down bugs.
Error handling
We started this topic with the premise that we might want to maintain an internal invariant. Let’s revisit our Rational number example and adjust the code so we do not permit zero in the denominator.
One way a denominator can become zero is through initialization. A reasonable way to prevent that is to throw an exception when our object is initialized with a zero as the denominator. The only other option we have would be to maintain a notion of an invalid state for our object, which would effectively mean supporting NaN.
When an exception is thrown, it interrupts the normal flow of the program, and the exception will propagate up until a handling try {} catch () {} block is encountered.
In this case, when we try to initialize y, the initialization of y doesn’t finish. Instead, we propagate the thrown exception, which is immediately caught with the catch block, which handles any standard exception in this case.
Note that the std::domain_error and std::exception do not match. The relation is the same as we encountered with streams (i.e. std::cin behaving as std::istream and std::cout behaving as std::ostream). We will come back to this later in this lesson.
A second way to introduce a zero in the denominator is in the stream extraction overload.
We have jumped up in complexity here. The main reason is that we are providing a strong exception guarantee.
Exception guarantees
When implementing code, we have three main options regarding exceptions.
We can provide a strong exception guarantee, where the code completes to execute or when an exception is thrown, no changes will be made (essentially a transactional guarantee).
A weak exception guarantee guarantees that the internal invariants will be maintained and permits a partial change. For example, the sorted data structures std::set and std::map provide weak guarantees when inserting multiple elements. This means that some elements may be inserted even when an exception is thrown; however, the internal invariant of the data structure being sorted will not be invalidated.
The final useful guarantee is a guarantee of no exceptions, which can even be annotated in code.
std::expected
Exceptions are fairly cumbersome to handle properly, and if an error is not “exceptional”, we can run into performance issues since exceptions are optimized for the happy path.
In many situations, an error is either a frequent or transient occurrence. We might want to rely on a different mechanism in such a case. The std::expected is a type that either holds the expected value or an error.
Note that for simplicity, I’m highjacking the system error codes. In practice, you would define your own.
Dynamic interfaces with inheritance
I have promised to finally explain why we can overload stream insertion and extraction with std::ostream and std::istream respectively, and have these overloads work with std::cin and std::cout or even std::fstream.
In the following example, we introduce a new “interface” (C++ doesn’t have an official concept of an interface) DuckInterface. This interface has two pure virtual member functions, meaning virtual functions without any implementation. This forces any type inherited from DuckInterface to implement these methods or defer that responsibility to their children. In any case, a type containing unimplemented pure virtual members cannot be instantiated.
Despite the lack of an official interface term, types with pure virtual members can only serve as interfaces, like the std::istream and std::ostream in our stream extraction and insertion operator overloads or like DuckInterface in the is_it_a_duck below.
If you were paying close attention to the previous lessons, you might wonder whether a template function wouldn’t work here.
The critical difference between the two approaches is that in the case of a dynamic interface, we have one function that can handle any potential type. If we release a library with such a function, users can implement their types without recompiling our function. As a downside, we pay for this feature with runtime performance through virtual dispatch.
With a template, the compiler needs to generate a function for each concrete type, which will cost us compilation time and the size of the generated binary. As a benefit, we no longer have the runtime overhead of virtual dispatch.
Homework
As usual, to practice the content of this lesson, you have a homework assignment.
The template repository with homework for this lesson is here: https://github.com/HappyCerberus/daily-bite-course-07.
As with all homework, you will need VSCode and Docker installed on your machine and follow the instructions from the first lesson.
The goal is to make all tests pass as described in the readme file.
I liked this std::expected with std::errc example
Was this the last one posted?