What do we have?
A software system we are working on needs to exchange lots of data between components. The data is structured in what we call trees of variables. This data essentially is the interface between components. The C++ code representing a specific interface is automatically generated from interface descriptions. There are different underlying implementations for doing the actual data exchange, like OPC/UA, but most of the code is shielded from that. The important node types are those that store values and array of values, both of which can be instantiated for almost any types.
class node { /* whatever all nodes have in common */ };
class value_node : public node { /* polymorphic access to value */ };
template<typename T>
class typed_value_node : public value_node { /* type-safe access to value */ };
// imagine pretty much the same for array_node and typed_array_node
Consequently, the visitor base class used to traverse the nodes in those trees has functions to accept all integer types (signed and unsigned), all floating types, booleans, and strings, both for const and non-const nodes. (We currently plan to map enum types to int/string pairs, but nothing has been set in stone regarding this.) All of those overloads exists for values and for array.
At the moment, that's around 70 overloads:
class visitor {
public:
virtual ~visitor() = default;
virtual void accept( typed_value_node< char >&) = 0;
virtual void accept(const typed_value_node< char >&) = 0;
virtual void accept( typed_value_node< signed char >&) = 0;
virtual void accept(const typed_value_node< signed char >&) = 0;
...
virtual void accept( typed_value_node< signed long long>&) = 0;
virtual void accept(const typed_value_node< signed long long>&) = 0;
virtual void accept( typed_value_node<unsigned char >&) = 0;
virtual void accept(const typed_value_node<unsigned char >&) = 0;
...
virtual void accept( typed_value_node<unsigned long long>&) = 0;
virtual void accept(const typed_value_node<unsigned long long>&) = 0;
virtual void accept( typed_value_node<bool >&) = 0;
virtual void accept(const typed_value_node<bool >&) = 0;
...
// repeat for typed_array_node
};
In order to be able to actually work with this, we employ CRTP to make a visitor implementation call function templates of a derived class:
template<typename Derived>
class visitor_impl : public visitor {
public:
void accept( typed_value_node<char>& node) override
{static_cast<Derived*>(this)->do_visit(node);}
void accept(const typed_value_node<char>& node) override
{static_cast<Derived*>(this)->do_visit(node);}
// etc.
};
That makes dealing with just some kind of nodes bearable:
class my_value_node_visitor : public visitor_impl<my_value_node_visitor> {
public:
template<typename T>
void accept(const typed_value_node<T>&) {/* I wanna see these*/}
template<typename T>
void accept(const T&) {/* I don't care about those */}
};
What do we want?
Beginning a new C++ software component in an application domain heavy with eletrical engineering, we have decided to employ a compile-time-checking unit library (aka "dimensional analysis library"). Unit libraries are great in that they employ the type system to check your code for correctness at compile-time. They do this by creating an almost unbound number of types, which encode not only the underlying built-in type (int, double,...), but also the physical unit (mass, energy), the scale (milli-, mega-), and some tag (active/reactive/apparent power, degree Kelvin/Celsius).
What's the problem?
Tree nodes with the correct physical units can easily be generated from the interface description. But if values of unit types are stored in a tree node, this will make our visitor base class need thousands of overloads for accepting all the different node types used, prompting developers to add new ones whenever a node needs a unit not used before, or in a scale not used before. We could come up with some clever abuse of templates to generate all those virtual functions at compile time from type lists. However, when the question came up if the resulting virtual table size could become a problem, I started to wonder if our current approach really is still the best one to tackle the problem.
Common wisdom says that, if you have many node types and few algorithms to traverse them, you should use virtual functions in the nodes themselves. If, OTOH, you have many algorithms and few node types, you should use the visitor pattern. Common wisdom also says that, if you have many of both, you're screwed.
My feeling is that, so far, we barely fit into the few-node-types/many-algorithms drawer. With the type proliferation that comes with compile-time units, my guts say we become a pretty good fit for the many-of-either drawer instead. In short: we might be screwed.
What do we do now?
Having been firmly tied to C++03 until the end of last year (the embedded world moves slowly), we certainly are not too familiar with a lot of the tools other C++ programmers have been using for almost a decade. So I am hoping we're missing an obvious solution here.
Or maybe we're missing something not so obvious?