During development of libraries (mainly for use in internal projects) I have come across the "problem" of how to design them in a generic way. I am going to demonstrate with an example library that I have implemented and use in code.
But first, here is a summary of the ways I know to "distribute" a library in C++:
- Statically linked
- Dynamically linked
- Header only
- Direct inclusion into source tree
In (1), the library code is compiled into a set of object files which is later linked into the final project at compile time. The set of object files and include headers are included in the library "package." (2) is similar to (1) except that the "linking" occurs at run-time using O.S. facilities.
(3) is quite common in C++ with libraries that use templates (e.g., the C++ Standard Template Library). All the code is contained within header files distributed to and included by the project. This code is either all inline or templated (which requires the complete definition at the time of use).
In (4) the complete source tree (headers and sources) are "included" into the project's tree and compiled along-side the project as if they were part of it. For example, this is often seen when using git submodules to "link to" code.
If I am missing any options, please enlighten me.
Now, this is where my real problem comes into play...
Ideally, I design a library in such a way that it is generic enough to be used with any project (within limits). This often means that the library needs to be "configured" in differently to be compatible with the specifics of the project. Here are the methods I know of for providing this configurability:
- Dependency injection (Dynamic/Run-time polymorphism)
- Templates/CRTP (Static polymorphism)
- Preprocessor conditionals
- "Tweaks" (configuration headers)
(5) is (in my opinion) the simplest to work with. Pieces of code are separated to not directly depend on each either via abstract interfaces. The library depends only upon the abstract interface which is passed in (i.e., injected) as a parameter (e.g., to a function or constructor). In this way, the library may be compiled as its own unit (i.e., as a static or dynamic library) without having any other dependency than the interface header and the compiler.
(6) allows a library to be generic for any changes in type or usage at compile time via templates. This restricts the library to only being distributed only via (3) or (4). It also can lead to too much code having to be a template (and thus header-only).
(7) is very common in C/C++ but it causes may problems. Preprocessor defines are use to change the way the library compiles and perhaps even the interfaces. The problem with this solution is it does not play well with "distribution" methods (1) and (2) (static and dynamic libraries) because the project and library must be compiled with exactly the same preprocessor definitions. If not, at best a link-time error is encountered. At worst, the project is linked but some unexpected things happen at runtime.
I learned about (8) from https://vector-of-bool.github.io/2020/10/04/lib-configuration.html. The concept is similar to (7) in that it uses the preprocessor, however the compile-time configuration is contained with in a single "tweak" file. Again, the restricts the library to being distributed via (3) and (4).
Time for a real example. I wanted to write a logging library for C++ that met requirements I couldn't find in any other library. But, I wanted to be able to use this library both on embedded systems and a standard PC. The latter is freer to use the standard library datatypes, strings, standard IO, etc... The former must be restricted to more deterministic constructs.
For example, I wanted an AbstractEvent to "stream" a string representation of itself (i.e., to be printed to some output).
class AbstractEvent
{
public:
virtual OStream& stream(OStream& os) const = 0;
friend OStream& operator<<(OStream& os, const AbstractEvent& v)
{
return v.stream(os);
}
};
OStream
is a configurable type depending on the use case that much meet a set of named requirements (same as how the standard template library works). However, I opted not to use templates because I didn't want the complexity of everything becoming a template. I would like to, for example, compile my logging library for embedded systems where OStream
is defined to be a "light" stream. I want to compile the library on a PC where OStream
is defined to be std::ostream
. The functionality of the library remains the same because both OStream
s expose the same interface.
My solution for my library was to use a combination of (8) and (4) (via submodules).
//In AbstractEvent.hpp
#include "OStream.hpp"
//In OStream.hpp
#if __has_include(<logging/OStream.tweaks.hpp>)
# include <logging/OStream.tweaks.hpp>
#endif
#ifndef LOGGING_OSTREAM
# define LOGGING_OSTREAM std::ostream
#endif
#ifndef LOGGING_OSTREAM_INCLUDE
# define LOGGING_OSTREAM_INCLUDE <iostream>
#endif
#include LOGGING_OSTREAM_INCLUDE
namespace logging
{
using OStream = LOGGING_OSTREAM;
} //namespace logging
//For embedded systems
//In logging/OStream.tweaks.hpp
#define LOGGING_OSTREAM_INCLUDE <etl/string_stream.h> //ETL is a STL-ish library for embedded systems
#define LOGGING_OSTREAM etl::string_stream
//For PC
//Use defaults (i.e., don't define a tweaks header)
I then add the library to my project via a git submodule.
My question is, have I missed any other design alternatives in my lists above? Are "configurable types" an indication of bad design?