1
\$\begingroup\$

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:

  1. 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
  2. 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 pack switch 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);
        }
    }
};
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Use std::variant and std::visit() instead

Basically, you want to turn a base pointer into a derived pointer at runtime, and you want to store that somehow in a variable. Since C++17, there's std::variant, which solves the latter problem. So what if you made a function that looks like this:

template<typename Interface, typename... Impls>
auto make_impl_variant(Interface* i) {
    std::variant<Impls*...> result;   
    ([&]{if (Impls* i = dynamic_cast<Impls*>(base)) result = i;}(), ...);
    return result;
}

There is no apply() member function in std::variant, instead you use std::visit(), like so:

Impl2 impl;
iFace* ref = &impl;
auto implContainer = make_impl_variant<iFace, Impl1, Impl2>(ref);
PolyOp polyop;
assert(std::visit(polyop, implContainer) == 3);

One issue that doesn't make it a drop-in replacement for your version is that you can't easily store references inside a variant, so either you need to modify PolyOp::operator() in your code to take pointers instead of references, or create a helper function that converts the pointer to a reference before applying it to the desired functor while visiting, like so:

std::visit([&](auto* i){return polyop(*i);}, implContainer);

You can of course still wrap all this into a class OpaqueImplContainer if that is easier. If you can't use C++17 yet, then perhaps you can implement your own versions of std::variant and std::visit, or use an existing library implementation like Boost::Variant.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Huh! I completely forgot about variant visitation, despite that I was looking into this stuff as far back as 2011: stackoverflow.com/q/7867555/170521 I was hesitant initially as I had the (obsolete) impression that visitation was also implemented under the hood with virtual invocation and compiled into similar or slower comparisons, but looking right now at the compiled binary, it seems the compiler is turning this into parametrized jumps. \$\endgroup\$
    – lurscher
    Commented Dec 9, 2021 at 21:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.