Forward-declaring STL container types
Some time ago, when looking into different ways how to reduce STL
header bloat, I discovered that libc++’s <iosfwd>
has a forward
declaration for std::vector. Turns out, there are more of them —
and not just on one particular standard library implementation.
Using forward declarations to avoid the need to #include
the whole
type definition is one of the essential techniques to counter the
ever-increasing compile times. While it’s very common to do for some libraries
(such as Qt), other libraries make it harder and some straight out impossible.
If you want to learn more about forward declarations, check out
this archived post from 2013.
The C++ standard explicitly forbids forward-declaring types from the standard
library on the user side, turning it into an undefined behavior. Moreover, the
STL namespace usually wraps another inline
namespace, which is defined
to some platform-specific string like std::__1
on libc++ or
std::__ndk1
on Android, which makes it harder (but not impossible) to
correctly match the type declaration on the other side.
The nuclear solution to this impossbility-to-forward-declare is to stop using STL altogether. Many performance-oriented projects ended up going that way, but, even though Magnum is gradually providing alternatives to more and more STL types, I don’t want to completely alienate the users but rather give them a choice and a chance to use Magnum with STL seamlessly.
A potential solution that isn’t an undefined behavior could be extending the standard library implementation itself with forward declarations for additional types. Forward declarations for stream types are in the classic <iosfwd> header, but that’s about it for what the standard guarantees. And adding forward declarations for other types isn’t as straightforward as it may seem.
The problem with templates
Most container types in the STL take optional template parameters for an allocator and other things. For example, the full set of things needed for defining the std::string type looks like this:
// <string> namespace std { template<class> class allocator; template<class> class char_traits; template<class CharT, class Traits = char_traits<CharT>, class Allocator = allocator<CharT> > class basic_string { … }; typedef basic_string<char> string; … }
As with functions, the standard mandates that the default value is specified
only once — either on a (forward) declaration or on the definition. So a way
to forward-declare these is putting the default template parameter on a forward
declaration and then have the definition without. This is by the way the main
reason Magnum provides forward declaration headers such as
Corrade/Containers/Containers.h
instead of suggesting people to write the forward declarations on their own
(it’s also much easier and less error-prone when the type is a long typedef
chain).
For the std::string case above, it would mean that the definition has to
be split into two parts — for example, with <iosfwd>
containing the
forward declaration and all related typedef
s, and <string>
just
the definition. For the actual definition we have to include the forward
declaration as well in order to get values of the default parameters.
// <iosfwd> namespace std { template<class> class allocator; template<class> class char_traits; template<class CharT, class = char_traits<CharT>, class = allocator<CharT> > class basic_string; typedef basic_string<char> string; … }
// <string> #include <iosfwd> namespace std { template<class CharT, class Traits, class Allocator > class basic_string { … }; … }
So, basically, if we’re able to find a forward declaration of a STL container type including the default arguments in some (even internal) STL header, the header is hermetic and significantly smaller than the corresponding standard header, we won. Conversely, if the type definition contains the template default parameters, then we can be sure that no forward declaration is possible.
Detecting what STL we’re on
Because we now wandered into the implementation-specific territory, we need a way to detect what STL flavor is the code being compiled on. Then, for known STL implementations with known forward declaration headers we include the particular header, and use the full type definition in the standard header otherwise. That means our forward declaration wrapper will always work, but giving us compilation time advantages in some cases.
The classic way to detect a STL vendor is to include the <ciso646>
header (which is defined to be empty on C++) and then check for either
_LIBCPP_VERSION
(defined on libc++, used by Clang mainly on macOS and
iOS), _CPPLIB_VER
(defined on MSVC STL, formerly Dinkumware) or
__GLIBCXX__
(defined on GCC’s libstdc++). One thing to note is that on
GCC before version 6.1 the <ciso646>
header doesn’t define the
_LIBCPP_VERSION
macro, so it’s needed to get it via some other means.
Beginning with C++20, there will be a new header,
<version>,
standardizing this process.
If you use Corrade and include any of its headers, you’ll get the detected library exposed through one of the CORRADE_TARGET_LIBCXX, CORRADE_TARGET_LIBSTDCXX or CORRADE_TARGET_DINKUMWARE macros.
What you can expect
The following table summarizes what common STL container types can be forward-declared on which implementation. The left column shows preprocessed line count with GNU libstdc++ and C++11 (unless said otherwise), gathered using the following command line:
echo "#include <utility>" | gcc -std=c++11 -P -E -x c++ - | wc -l
The other columns then show how many preprocessed lines is the corresponding forward declaration on a particular implementation, if applicable.
Header | Forward declaration | ||
---|---|---|---|
Size on libstdc++ 11.2 | libstdc++ 11.2 | libc++ 13.0.1 | MSVC STL 2022 |
std::pair 1<utility>
|
… 2 301 LoC |
… 4 651 LoC |
… |
std::initializer_list 1<initializer_list>
|
… 53 LoC |
… 120 LoC |
… |
std::list 2<list> , 7 433 LoC
|
✘ |
✘ |
✘ |
std::forward_list 2<forward_list> , 7 711 LoC
|
✘ |
✘ |
✘ |
std::string<string> , 12 488 LoC
|
✔ 3<bits/stringfwd.h> , 49 LoC
|
✔ 4<iosfwd> , 859 LoC
|
✘ 5 |
std::string_view 6<string_view> , 7 941 LoC
|
✘ |
✘ |
✘ |
std::array<array> , 5 640 LoC
|
✘ 7 |
✔ 8<__tuple> , 2 318 LoC
|
✔ 9<utility>
|
std::tuple<tuple> , 7 055 LoC
|
✔ 10<utility> , 2 301 LoC
|
✔ 8<__tuple> , 2 318 LoC
|
✔ 9<utility>
|
std::vector<vector> , 9 382 LoC
|
✘ 11 |
✔ 12<iosfwd> , 859 LoC
|
✘ 11 |
std::span<span> , 11 051 LoC
|
✘ |
✘ 13 |
✘ |
std::[multi]set 14<set> , 8 816 LoC
|
✘ | ✘ | ✘ |
std::[multi]map 14<map> , 10 719 LoC
|
✘ | ✘ | ✘ |
std::unordered_[multi]set 14<unordered_set> , 12 164 LoC
|
✘ | ✘ | ✘ |
std::unordered_[multi]map 14<unordered_map> , 12 200 LoC
|
✘ | ✘ | ✘ |
- 1.
- ^ Some STL implementations have a forward declaration for std::pair,
but you’ll need
<utility>
in most cases anyway for the std::move(), std::forward() and other utils there’s no point in bothering with a forward declaration — which wouldn’t be much smaller than the full definition anyway. Similarly it goes for std::initializer_list, the full definition is also very tiny. Both these types don’t have any standard default template argument so these could be theoretically safe to forward-declare however nothing prevents STL implementations from adding their own default template arguments. - 2.
- ^ Both std::list and std::forward_list have a full
definition in the standard
<list>
/<forward_list>
headers on all three implementations. The libc++ implementation has a forward declaration in the same file, but it would first need to be extracted outside to make it useful. - 3.
- ^ libstdc++ has std::string, std::wstring, std::u16string and std::u32string forward-declared in <bits/stringfwd.h>.
- 4.
- ^ libc++ has std::string and std::wstring forward-declared in the standard <iosfwd>, unfortunately the std::u16string and std::u32string typedefs are missing.
- 5.
- ^ MSVC STL has the full definition of std::basic_string including
default parameters in
<xstring>
, which makes it impossible to forward-declare. - 6.
- ^ std::basic_string_view has the full definition directly in the
<string_view>
header on both libc++ and libstdc++, MSVC 2017 has both a forward declaration and the full definition in<xstring>
— the declaration could be extracted to a separate header to make this work. - 7.
- ^ I couldn’t find any forward declaration for std::array in libstdc++. It however doesn’t mean it hasn’t any — the type has no default template parameters so it should be possible.
- 8.
- ^ libc++ has a forward declaration for std::array and std::tuple in <__tuple>.
- 9.
- ^ MSVC STL has a forward declaration for std::array and
std::tuple defined in the standard
<utility>
, next to std::pair. - 10.
- ^ libstdc++ from version 7 to 11 has a forward declaration for
std::tuple in the standard <type_traits>
header, but it got removed in GCC 12. The other forward declaration, added
in 4.6 and available also in GCC 12, is in
<bits/stl_pair.h>.
To avoid issues, include the whole
<utility>
— it isn’t that much larger than<type_traits>
. - 11.
- ^ MSVC has the full std::vector definition in
<vector>
, libstdc++ has a small-ish full definition in<bits/stl_vector.h>
but the header is not hermetic and when all needed dependencies are included as well the size is not much different from the standard header. - 12.
- ^ libc++ 3.9 and up has a forward declaration for std::vector in the standard <iosfwd> (older versions don’t).
- 13.
- ^ libc++ 7.0 has a
friend
forward declaration for std::span in the <iterator> header but that’s not enough to have the forward declaration available globally in thestd
namespace. Too bad, because the<span>
header is heavy. - 14.
- ^ All standard (unordered) (multi)map/set implementations have just the full definition with no possibility to forward-declare. Since these types are very rarely used directly as function parameters or return types, it’s not such a big problem. Besides that, they tend to be rather heavy both at compile time and at runtime due to their extreme genericity, so the less they get used the better 😉
Conclusion
While the heavier map / set types don’t have forward declarations, the existing forward declarations can already cover many use cases for libraries that want to be both fast-to-compile and STL-friendly:
- a STL-friendly function overload returning a std::string instead of a custom lightweight string type to avoid further copies when passing the string to STL-oriented APIs (used for example by Utility::Directory::read() vs Utility::Directory::readString())
- a function overload which takes a std::vector by mutable reference (instead of e.g. a reference to Containers::Array) in order to fill it, to avoid unnecessary copies when a std::vector is needed further
- functions taking and returning std::arrays / std::tuples could now reside in otherwise STL-free headers instead of being pushed out to avoid the 13k included lines
Too bad that the std::string_view and std::span types, while meant to be lightweight at runtime, are so compile-time heavy and impossible to forward-declare.
While it’s possible to make use of the forward declarations also for functions taking/returning vectors, arrays and strings by constant references, a better approach is to make use of STL compatibility in the Containers::ArrayView classes — that’ll allow more types than just std::vector, std::array or std::string to be used and the compile-time impact is clear and consistent with this type. See the article about STL-compatible array views for more information and detailed performance overview.
I also didn’t look for forward declarations of the std::unique_ptr, std::optional or std::reference_wrapper types because they’re very simple and thus easy to replace. See the Lightweight but still STL-compatible unique pointer post for more information.
Try them in your code
Corrade provides the above forward declarations in tiny
Corrade/Utility/StlForwardArray.h,
Corrade/Utility/StlForwardString.h,
Corrade/Utility/StlForwardTuple.h and
Corrade/Utility/StlForwardVector.h
headers, simply include them instead of the standard <array>
, <string>
,
<tuple>
or <vector>
headers where desired. The engine is moving to use
them as well, but since it will cause many build errors due to suddenly missing #include
s in user code, the move is done gradually to avoid suddenly
breaking everything.
The Magnum Singles repository contains these as well, as a hermeticheaders not depending on Corrade’s internals. Take them and bundle right into your project:
Library | LoC | Preprocessed LoC | Description |
---|---|---|---|
CorradeStlForwardArray.h | 67 | 245516 | See Corrade/Utility/StlForwardArray.h docs |
CorradeStlForwardString.h | 74 | 48 | See Corrade/Utility/StlForwardString.h docs |
CorradeStlForwardTuple.h | 78 | 1616 | See Corrade/Utility/StlForwardTuple.h docs |
CorradeStlForwardVector.h | 62 | 74116 | See Corrade/Utility/StlForwardVector.h docs |
- 16.
- ^ a b gathered using Clang 7.0 and libc++, since GCC 8.2’s libstdc++ doesn’t have a forward declaration for std::array / std::vector
Can we convince vendors to do this more?
While I think it’s possible to add additional forward declarations to some STL implementations, it might not be always possible to do so without breaking ABI compatibility — even in Magnum I had to break ABI compatibility a few times in the past in order to achieve that (and now I know what to avoid to make new types easily forward-declarable from the start).
The ideal way would be to have the forward declarations guaranteed by the
standard (extending <iosfwd>
further, for example) so we don’t need to
include platform-specific internal “bits”, but again this may cause an ABI
break for many vendors and thus take years to implement (like it happened with
std::string in C++11 where libstdc++ could no longer have it
copy-on-write — and the problems it caused
persist until today).
There’s also a slight chance that, due to complexities of std::allocator
and other types used in default template arguments, adding forward declarations
to <iosfwd>
would make it no longer lightweight. This really depends on how
the implementations are done and what all needs to be known to forward-declare
given type.