© Ray Lischner 2020
R. LischnerExploring C++20https://doi.org/10.1007/978-1-4842-5961-0_43

43. Old-Fashioned “Modules”

Ray Lischner1 
(1)
Ellicott City, MD, USA
 

Modules are the way of the future, but until the future arrives, we are stuck with #include files. Billions of lines of C++ code currently use #include files, so you need to know how they work. With a little bit of discipline, you can still separate interface from implementation and achieve much of what modules offer.

Interfaces As Headers

The basic principle is that you can define any function or global object in any source file. The compiler does not care which file contains what. As long as it has a declaration for every name it needs, it can compile a source file to an object file. (In this unfortunate case of convergent terminology, object files are unrelated to objects in a C++ program.) To create the final program, you have to link all the object files together. The linker doesn’t care which file contains which definition; it simply has to find a definition for every name reference that the compiler generates.

The previous Exploration presented the rational class as an interface (Listing 42-5) and an implementation (Listing 42-6). Let’s rewrite the program to put the rational class interface in one file called rational.hpp and the implementation in a another called rational.cpp. Listing 43-1 shows the rational.hpp file.
#ifndef RATIONAL_HPP_
#define RATIONAL_HPP_
#include <iosfwd>
class rational
{
public:
  inline rational(int num) : numerator_{num}, denominator_{1} {}
  inline rational(rational const&) = default;
  inline rational(int num, int den)
  : numerator_{num}, denominator_{den}
  {
    reduce();
  }
  void assign(int num, int den);
  inline int numerator() const           { return numerator_; }
  inline int denominator() const         { return denominator_; }
  rational& operator=(int num);
private:
  void reduce();
  int numerator_;
  int denominator_;
};
std::ostream& operator<<(std::ostream&, rational const&);
#endif // RATIONAL_HPP_
Listing 43-1.

The Interface Header for rational in rational.hpp

Listing 43-2 shows rational.cpp.
#include "rational.hpp"
#include <cassert>
#include <numeric>
#include <ostream>
void rational::assign(int num, int den)
{
  numerator_ = num;
  denominator_ = den;
  reduce();
}
void rational::reduce()
{
  assert(denominator_ != 0);
  if (denominator_ < 0)
  {
    denominator_ = -denominator_;
    numerator_ = -numerator_;
  }
  int div{std::gcd(numerator_, denominator_)};
  numerator_ = numerator_ / div;
  denominator_ = denominator_ / div;
}
rational& rational::operator=(int num)
{
  numerator_ = num;
  denominator_ = 1;
  return *this;
}
std::ostream& operator<<(std::ostream& stream, rational const& r)
{
    return stream << r.numerator() << '/' << r.denominator();
}
Listing 43-2.

The rational Implementation in rational.cpp

To use the rational class, you must #include "rational.hpp", as demonstrated in Listing 43-3.
#include <iostream>
#include "rational.hpp"
int main()
{
  rational pi{3927, 1250};
  std::cout << "pi approximately equals " << pi << ' ';
}
Listing 43-3.

The main Function Using the rational Class in the main.cpp File

Now compile main.cpp and rational.cpp, then link them together to produce a working C++ program. An IDE takes care of the details for you, provided both source files are part of the same project. If you are using command-line tools, you can invoke the same compiler, but instead of listing source file names on the command line, list only the object file names. Alternatively, you can compile and link at the same time, by listing all the source file names in one compilation.

That’s the basic idea, but the details, of course, are a little trickier. For the remainder of this Exploration, we’ll take a closer look at those details.

Inline or Not Inline

Because Listing 42-1 is not a module, functions defined inside the class declaration are implicitly inline. But I declared them with the inline keyword anyway. This is a good practice to remind the human reader that these functions are inline. It also promotes a hybrid style of programming.

As much fun as it would be to leap forward into the future and embrace modules 100%, we must maintain existing code and write new code. We want to move forward and not lock our code in the past, so the ideal would be write code that can live in both worlds. It turns out this is easy to do with modules and headers. The first step is to be explicit with the inline keyword. Now the class definition works in a module and in a header. Then create a module from the header as shown in Listing 43-4.
export module rat;
export {
    #include "rational.hpp"
}
Listing 43-4.

Creating a Module from a Header

Once again, working with modules makes coding easy. The export keyword can apply to all declarations in a brace-enclosed block. In this case, the #included header contains a single class declaration, but it can include much, much more.

Quotes and Brackets

All of the standard library’s #include directives used angle brackets, such as <iostream>, but #include "rational.hpp" uses double quotes. The difference is that you should use angle brackets only for the standard library and system headers, although some third-party libraries recommend the use of angle brackets too. Use double quotes for everything else. The C++ standard is deliberately vague and recommends that angle brackets be used for headers that are provided with the system and quotes be used for other headers. Vendors of add-on libraries have all taken different approaches concerning naming their library files and whether they require angle brackets or double quotes.

For your own files, the important aspect is that the compiler must be able to find all your #include files. The easiest way to do that is to keep them in the same directory or folder as your source files. As your projects become larger and more complex, you probably will want to move all the #include files to a separate area. In this case, you have to consult your compiler documentation, to learn how to inform the compiler about that separate area. Users of g++ and other UNIX and UNIX-like command-line tools typically use the -I (capital letter I) option. Microsoft’s command-line compiler uses /I. IDEs have a project option with which you can add a directory or folder to the list of places to search for #include files.

For many compilers, the only difference between angle brackets and quotes is where it looks for the file. A few compilers have additional differences that are specific to that compiler.

In a source file, I like to list all the standard headers together, in alphabetical order, and list them first, followed by the #include files that are specific to the program (also in alphabetical order). This organization makes it easy for me to determine whether a source file #includes a particular header and helps me add or remove #include directives as needed.

Include Guards

One very important difference between modules and #include files is that a module can be imported multiple times with no ill effect. But #include-ing the same file more than once might repeat all the declarations in that file, which is not allowed. Listing 43-1 protects against this possible mistake with #ifndef RATIONAL_HPP_. The directive #ifndef is short for “if not defined,” so the first line tests whether RATIONAL_HPP_ is not defined, which is isn’t. The second line goes about defining it. An #endif closes the conditional at the end of the file. If the same file is #included again, RATIONAL_HPP_ is now defined, so the #ifndef is false, and the entire file is skipped, down to #endif. This include guard, as it is known, is a common idiom in header files. It is not needed in a module, but is harmless. (And remember from Exploration 34 not to begin the guard name with an underscore. I use a trailing underscore to ensure the name does not conflict with any real name that the header might declare.)

Forward Declarations

The <istream> header contains the full declaration of std::istream and other, related declarations, and <ostream> declares std::ostream. These are large classes in large headers. Sometimes, you don’t need the full class declarations. For example, declaring an input or output function in an interface requires informing the compiler that std::istream and std::ostream are classes, but the compiler needs to know the full class definitions only in the implementation file.

The header <iosfwd> is a small header that declares the names std::istream, std::ostream, and so on, without providing the complete class declarations. Thus, you can reduce compilation time for any file that includes a header by changing <istream> and <ostream> to <iosfwd>.

You can do the same for your own classes by declaring the class name after the class keyword, with nothing else describing the class:
class rational;

This is known as a forward declaration. You can use a forward declaration when the compiler has to know a name is a class but doesn’t have to know the size of the class or any of the class’s members. A common case is using a class solely as a reference function parameter.

If your header uses <iosfwd> or other forward declarations, be sure to include the full class declarations (e.g., <iostream>) in the .cpp source file.

extern Variables

Global variables are usually a bad idea, but global constants can be extremely helpful. If you define a constexpr constant, you can put that in a header and not worry about it again. But not all constant objects can be constexpr. If you need to define a global constant and cannot make it constexpr, you need to declare it in a header and define it in a separate source file, which you link with the rest of your program. Use the extern keyword to declare the constant in the header. Another reason to separate the declaration and definition of a global constant is when you may need to change the value of the constant but do not want to recompile the entire program.

For example, suppose you need to define some global constants for use in a larger program. The program name and global version number will not change often or will change when the program is rebuilt anyway, so they can be made constexpr and declared in globals.hpp. But you also want to declare a string called credits, which contains citations and credits for the entire project. You don’t want to rebuild your component just because someone else added a credit to the string. So the definition of credits goes into a separate globals.cpp file. Start by writing globals.hpp, with include guards and using constexpr for globals with values and extern for globals without values. Compare your file with Listing 43-5.
#ifndef GLOBALS_HPP_
#define GLOBALS_HPP_
#include <string_view>
constexpr std::string_view program_name{ "The Ultimate Program" };
constexpr std::string_view program_version{ "1.0" };
extern const std::string_view program_credits;
#endif
Listing 43-5.

Simple Header for Global Constants

One source file in the project must define program_credits. Name the file globals.cpp. Write globals.cpp. Compare your file with Listing 43-6.
#include "globals.hpp"
std::string_view const program_credits{
    "Ray Lischner "
    "Jane Doe "
    "A. Nony Mouse "
};
Listing 43-6.

Definitions of Global Constants

Invent a program to test the globals. Link your main.cpp with globals.cpp to create the program. Listing 43-7 shows an example of such a program.
#include <iostream>
#include "globals.hpp"
int main()
{
  std::cout << "Welcome to " << program_name << ' ' << program_version << ' ';
  std::cout << program_credits;
}
Listing 43-7.

A Trivial Demonstration of globals.hpp

One-Definition Rule

The compiler enforces the rule that permits one definition of a class, function, or object per source file. Another rule is that you can have only one definition of a function or global object in the entire program. You can define a class in multiple source files, provided the definition is the same in all source files.

Inline functions follow different rules than ordinary functions. You can define an inline function in multiple source files. Each source file must have no more than one definition of the inline function, and every definition in the program must be the same.

These rules are collectively known as the One-Definition Rule (ODR).

The compiler enforces the ODR within a single source file. However, the standard does not require a compiler or linker to detect any ODR violations that span multiple source files. If you make such a mistake, the problem is all yours to find and fix.

Imagine that you are maintaining a program, and part of the program is the header file shown in Listing 43-8.
#ifndef POINT_HPP_
#define POINT_HPP_
class point
{
public:
  point() : point{0, 0} {}
  point(int x, int y) : x_{x}, y_{y} {}
  int x() const { return x_; }
  int y() const { return y_; }
private:
  int y_, x_;
};
#endif // POINT_HPP_
Listing 43-8.

The Original point.hpp File

The program works just fine. One day, however, you upgrade compiler versions, and when recompiling the program, the new compiler issues a warning, as follows, that you’ve never seen before:
point.hpp: In constructor 'point::point()':
point.hpp:13: warning: 'point::x_' will be initialized after
point.hpp:13: warning:   'int point::y_'
point.hpp:8: warning:   when initialized here

The problem is that the order of the data member declarations is different from the order of the data members in the constructors’ initializer lists. It’s a minor error, but one that can lead to confusion, or worse, in more complicated classes. It’s a good idea to ensure the orders are the same. Suppose you decide to fix the problem by reordering the data members.

Then you recompile the program, but the program fails in mysterious ways. Some of your regression tests pass and some fail, including trivial tests that have never failed in the past.

What went wrong?
  • _____________________________________________________________

  • _____________________________________________________________

With such limited information, you can’t determine for certain what went wrong, but the most likely scenario is that the recompilation failed to capture all the source files. Some part of the program (not necessarily the part that is failing) is still using the old definition of the point class, and other parts of the program use the new definition. The program fails to adhere to the ODR, resulting in undefined behavior. Specifically, when the program passes a point object from one part of the program to another, one part of the program stores a value in x_, and another part reads the same data member as y_.

This is only one small example of how ODR violations can be both subtle and terrible at the same time. By ensuring that all class definitions are in their respective header files, and that any time you modify a header file you recompile all dependent source files, you can avoid most accidental ODR violations.

Modules do not make ODR problems vanish, but they greatly reduce the likelihood that you will run into one. Because modules are distinct entities that the compiler knows about and have semantics of their own, the compiler can do more error-checking than it can with #include headers, which are just files and carry no additional semantic information. So when using modules, the compiler might be able to tell you that the implementation was compiled with a different version of the interface or that the interface for a particular class changed since the last time you compiled the implementation.

Tip

If you can write your own modules, I recommend that you do so, even if your tools do not fully support standard library modules yet. Judging from prereleases, it looks like importing standard library modules, such as import <iostream>, may be the last aspect of modules to be implemented. Just don’t let that stop you from writing your own.

Now that you have the tools needed to start writing some serious programs, it’s time to embark on some more advanced techniques. The next Exploration introduces function objects—a powerful technique for using the standard algorithms.

..................Content has been hidden....................

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