Annoyed at the tension between good software design principles that require well-defined delimitations between interface and implementations, and the requirements for critical code to run fast, which demands avoid placing runtime overheads in the critical path, I've come up with a solution that I haven't seen elsewhere.
The concept is to build a template structure that is initialized with a pointer to an abstract interface, which runs dynamic_cast
on every possible desired implementation case, and leaving the structure ready to use with a templated apply
helper that checks which implementation pointer is non-null.
The design assumptions I've made are two-fold:
- making a couple comparisons with an integer in the stack can be slightly faster sometimes that walking to a vtable, which would pay-off if the object methods are called many times
- the tradeoff of bigger stack space occupied by the extra-pointers and the extra-comparisons instead of a parametrized
Duff's device
jump is unavoidable without compiler support of variadic parameter packswitch
folds (not totally true, see final remarks)
so I would like to elicit comments on the code structure, but also if my design assumptions are correct (specially 2)
Enough talk, now to the code:
template<typename Interface, typename Impl, int Instance>
struct ImplRef
{
Impl* const m_ref;
ImplRef(Interface* ref) : m_ref(dynamic_cast<Impl*>(ref))
{}
inline int instance() const
{
if (nullptr == m_ref)
return -1;
return Instance;
}
template<typename Functor>
decltype(std::declval<Functor>()(std::declval<Impl&>())) apply(Functor f)
{
return f(*m_ref);
}
};
template <typename Interface, typename Impl0, typename...Impls>
struct OpaqueImplCollector : public ImplRef<Interface, Impl0, sizeof...(Impls)>,
public OpaqueImplCollector<Interface, Impls...>
{
using BaseImplRef = ImplRef<Interface, Impl0, sizeof...(Impls)>;
using BaseImplCollector = OpaqueImplCollector<Interface, Impls...>;
static constexpr int level = sizeof...(Impls);
const int m_idx;
//template<typename >
OpaqueImplCollector(Interface* i) : BaseImplRef(i), BaseImplCollector(i),
m_idx( (BaseImplRef(i).instance() > -1) ? level : BaseImplCollector(i).instance() )
{}
inline int instance() const
{
int base_ref = BaseImplRef::instance();
if (base_ref > -1)
return base_ref;
return BaseImplCollector::instance();
}
template<typename Functor>
decltype(std::declval<Functor>()(std::declval<Interface&>())) apply(Functor f)
{
if (m_idx > -1)
return BaseImplRef::apply(f);
return BaseImplCollector::apply(f);
}
};
template <typename Interface, typename ImplLast>
struct OpaqueImplCollector< Interface, ImplLast> : public ImplRef<Interface, ImplLast, 0>
{
using BaseImplRef = ImplRef<Interface, ImplLast, 0>;
static constexpr int level = 0;
const int m_idx;
//template<typename >
OpaqueImplCollector(Interface* i) : BaseImplRef(i),
m_idx( (BaseImplRef(i).instance() > -1) ? level : -1 )
{
//assert(m_idx > -1);
}
inline int instance() const
{
return BaseImplRef::instance();
}
template<typename Functor>
decltype(std::declval<Functor>()(std::declval<ImplLast&>())) apply(Functor f)
{
assert(m_idx > -1);
return BaseImplRef::apply(f);
}
};
That's it.
This is an example of how to use it:
struct iFace
{
virtual int meth() = 0;
};
struct Impl1 : public iFace
{
int m_a;
inline int meth() override { return m_a; }
};
struct Impl2 : public iFace
{
int m_a, m_b;
inline int meth() override { return m_a + m_b; }
};
struct PolyOp
{
//this is only required for inferring the return-type expected by the interface
int operator()(iFace&);
inline int operator()(Impl1& impl1)
{
return impl1.meth();
}
//template impl because default, specific instances become overrides
template<typename Impl>
inline int operator()(Impl& impl2)
{
return impl2.meth() - impl2.m_b;
}
};
TEST(Basic, HybridPolyContainer)
{
Impl2 impl;
std::tie(impl.m_a, impl.m_b) = std::pair{3, 2};
iFace* ref = &impl;
ImplRef< iFace, Impl1, 0 > ir1(ref);
ImplRef< iFace, Impl2, 0 > ir2(ref);
assert(ir1.instance() == -1);
assert(ir2.instance() == 0);
OpaqueImplCollector< iFace, Impl2, Impl1 > implContainer(ref);
assert(implContainer.m_idx == 1);
PolyOp polyop;
assert(implContainer.apply(polyop) == 3); // impl.m_a);
Impl1 implOne;
implOne.m_a = 7;
ref = &implOne;
OpaqueImplCollector< iFace, Impl2, Impl1 > implContainerOne(ref);
assert(implContainerOne.m_idx == 0);
// does not access template operator, access specific overload
assert(implContainerOne.apply(polyop) == 7);
};
Notice that polyop
calls can in principle be inlined by the compiler, as the implContainer
is behaving as a switch function (but not as fast as a switch, as it's not a parametrized jump, but a variable sequence of comparisons)
Also note that PolyOp
didn't need to provide a definition for void operator()(iFace&)
, just the declaration suffices so that apply
can infer a return type
Final remarks
Although lack of a switch fold expression makes life a bit harder, it's still possible to destructure several variadic specializations in order to provide switch
-based apply
implementations:
template <typename Interface, typename ImplFirst, typename ImplLast>
struct OpaqueImplCollector< Interface, ImplFirst, ImplLast> : public ImplRef<Interface, ImplLast, 0>,
public ImplRef<Interface, ImplFirst, 1>
{
using BaseImplRef0 = ImplRef<Interface, ImplLast, 0>;
using BaseImplRef1 = ImplRef<Interface, ImplFirst, 1>;
static constexpr int level = 1;
const int m_idx;
//template<typename >
OpaqueImplCollector(Interface* i) : BaseImplRef0(i), BaseImplRef1(i),
m_idx( (BaseImplRef1(i).instance() > -1) ? level : BaseImplRef0(i).instance() )
{}
inline int instance() const
{
return m_idx;
}
template<typename Functor>
decltype(std::declval<Functor>()(std::declval<Interface&>())) apply(Functor f)
{
assert(m_idx > -1);
switch( m_idx)
{
case 0:
return BaseImplRef0::apply(f);
case 1:
return BaseImplRef1::apply(f);
default:
assert(m_idx > -1);
}
}
};