DEV Community

Paul J. Lucas
Paul J. Lucas

Posted on

include-tidy: A Tool to Enforce Include-What-You-Use, Part 2

Introduction

In a previous post, I described include-tidy (Tidy), a tool that enforces the include-what-you-use (IWYU) principle for C and C++ programs, namely that a source file:

  1. Directly includes every header file exactly once from which it references symbols.

  2. Does not include a header file from which it does not reference symbols.

The rules of the principle seem obvious and simple — trivial, even. As I described previously, things like complete vs. incomplete types and preprocessor macros complicated the implementation somewhat, but not crazily so.

I had done pretty the bulk of my testing on C source files including having Tidy run on itself. I (in what would turn out to be naively) thought that C++, at least in terms of referencing symbols and including the header files that declare them, would be largely the same. That is, despite the huge language differences, in C++, symbols are still symbols and headers are still headers.

Then out of the blue, someone e-mailed me a bug report for SWISH++ with a patch. I had originally written SWISH++ back in the late ’90s, but hadn’t touched it in over a decade. I decided to try Tidy on it. I naturally expected some IWYU violations, but I got several dozen. While surprising, I thought I’d just fix them. After examining a few, I started to realize that applying IWYU to C++ is a much harder problem!

When writing C++ code and you need to refer to a symbol, you just instinctively include the header that defines it. It turns out that actually codifying that instinct for C++ is hard because there are actually several “obvious” exceptions to IWYU for C++ that simply don’t exist in C.

Why IWYU for C++ is Harder

Nested Types and Inheritance

Probably the top two things that make IWYU for C++ hard are that C++ allows nested types and inheritance. For example, given:

// Base.hpp
struct Base {
  using value_type = int;
  Base( value_type );
  void b();
  // ...
};

// Derived.hpp
#include "Base.hpp"
struct Derived : Base {
  Derived( value_type );
  void d();
};

// Derived.cpp
#include "Derived.hpp"
Derived::Derived( value_type n ) : Base{ n } {
}

void Derived::d() {
  b();
}
Enter fullscreen mode Exit fullscreen mode

Any C++ programmer would look at this code and say that the #includes are exactly they way they should be: Derived.cpp implements functions for Derived so it includes Derived.hpp; Derived.hpp derives from Base so it includes Base.hpp; no more, no less. Easy, peasy.

But Derived.cpp directly references value_type, Base, and b(), all of which are declared in Base.hpp. So if you naively enforce IWYU, you’d say that Derived.cpp must include Base.hpp. Except no C++ programmer would think that because:

  • Derived.cpp includes Derived.hpp that defines Derived that inherits value_type from Base. In order to declare Derived in the first place, Derived.hpp had to include Base.hpp because Derived is-a Base. Hence, the transitive include of Base.hpp into Derived.cpp is sufficient.
  • The same is true for Base and b(): the transitive include of Base.hpp is sufficient.

In C, neither nested types nor inheritance exist, so neither do these cases.

Functions and Derived Arguments

Consider this addition to the previous example:

// Base.hpp
// ...

bool operator==( Base const&, Base const& );

// Derived.cpp
// ...

void f( Derived const &i, Derived const &j ) {
  if ( i == j )
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Similarly to before, Derived.cpp directly references operator==() that’s declared in Base.hpp. So again, if you naively enforce IWYU, you’d say that Derived.cpp must include Base.hpp. Except again, no C++ programmer would think that because:

  • Derived is-a Base and in order to declare Derived in the first place, Derived.hpp had to include Base.hpp.
  • The inheritance means that any operator (or function) that accepts a Base as an argument should also accept Derived as an argument.

Again, this means that the transitive include of Base.hpp is sufficient.

Specifically, for any function (or operator) f, you can’t decide immediately whether the header that declares it is required. Instead, you have to defer it until after you’ve examined all of its arguments. If the function:

  • Has one or more arguments; and:
  • At least one of those arguments’ type is derived from a relevant base.

then the header is not required: again, the transitive include is sufficient. Similarly in C, this case doesn’t exist: if you reference a function, then the header that declares it is required — period.

Nested Types and Aliases

Consider this example:

// int_set.hpp
#include <set>
using int_set = std::set<int>;

// Foo.cpp
#include "int_set.hpp"

void f() {
  int_set::value_type v;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Foo.cpp directly references value_type that’s declared in <set>. In this case, there is no inheritance at all, just a type alias. So again, if you naively enforce IWYU, you’d say that Foo.cpp must include <set>. Except again, no C++ programmer would think that because:

  • In order to declare int_set in the first place, int_set.hpp had to include <set>.
  • That aside, the point of using a type alias is to hide what the underlying type is, hence the fact that int_set is-a std::set should be irrelevant.

Therefore, Foo.cpp should not be required to include <set>; again, its transitive include is sufficient.

Conclusion

Implementing IWYU for C++ turned out to be a much harder problem that I originally thought several months ago.

I’ve recently released include-tidy 2.0 that has vastly improved C++ support. Even though SWISH++ now has no IWYU violations, it was written in an older version of C++. As time permits, I’ll modernize the code to a more recent version of C++ and won’t be surprised if there are other special case exceptions to IWYU for C++.

Top comments (0)