-
Notifications
You must be signed in to change notification settings - Fork 0
Design Philosophy
Object-oriented programming (OOP) provides a simple and easy-to-understand way to build a hierarchy of structures which utilize a common interface but may differ in their implementation. In C++, this is typically handled via virtual functions:
class Base {
public:
virtual void foo() = 0;
};
class DerivedA : public Base {
public:
void foo() override {
std::cout << "foo() called from DerivedA!\n";
}
};
class DerivedB : public Base {
public:
void foo() override {
std::cout << "foo() called from DerivedB!\n";
};
};Because DerivedA and DerivedB are descendants of Base, we can use them interchangeably in any code that expects an object of type Base. Unfortunately, this sort of flexibility comes with a few costs:
- Because virtual functions are not known at compile time, it limits some of the optimizations that can be used, like inlining.
- There is an additional lookup operation associated with every call to a virtual function.
Though these costs seem like they should be quite small (and they typically are), a naïve polymorphic approach to computationally intensive problems is often disastrous for its performance. Furthermore, virtual functions are possible but somewhat troublesome to use on GPUs, which limits the usefulness of polymorphic behavior.
An alternative approach to traditional polymorphism is so-called "policy-based design". Consider the following example below:
class BaseA {
protected:
void foo_base() {
std::cout << "foo() called from A!\n";
}
};
class BaseB {
protected:
void foo_base() {
std::cout << "foo() called from B!\n";
}
};
template<class FooPolicy>
class Derived : public FooPolicy {
private:
using FooPolicy::foo_base;
public:
inline void foo() {
foo_base();
}
};The class Derived is now templated over the possible policies and inherits directly from the selected policy, giving it direct access to that class's member functions and variables. Because templates are resolved at compile-time, we have pseudo-polymorphic behavior that is also resolved at compile time. Admittedly, this does come with a fairly serious drawback: the base classes to Derived<BaseA> and Derived<BaseB> are different, so we lose the ability to store some generic pointer of a base class and get the correct derived behavior. In the context of PrimitiveSolver, where some GRMHD solver will need to store a copy of the PrimitiveSolver<EOSPolicy,ErrorPolicy> object, there are a few ways this can be handled. Here are two possibilities:
- Decide the template parameters at compile-time via preprocessor macros. This is the easiest route for the programmer, but it's not the most user-friendly option nor the easiest to test.
- Create an abstract wrapper class that has no knowledge of any PrimitiveSolver objects but has some high-level virtual functions. Then create a derived class which is templated over
EOSPolicyandErrorPolicywhich stores PrimitiveSolver objects and implements these high-level virtual functions (e.g., it callsConToPrimover the entire domain). One can then create some sort of factory object or function to select the correct object at runtime. This is a bit more complicated for the programmer, and it often means there's a long chain of templated objects, but it does work quite well in practice.