API proposal for libcyphal

I apologize for the clickbait title as there’s actually no proposal here. I just want to share these two files here per @scottdixon’s suggestion:

can.hpp (27.5 KB)
generic.hpp (15.4 KB)

generic.hpp contains the transport-agnostic part and can.hpp contains the Cyphal/CAN specific part. The macros are legacy cruft for compatibility with Nunavut C; ignore them. The key idea is to use type erasure with factory methods to create publishers/subscribers/etc and to decouple the application from the transport specifics via static polymorphism — assume that a hypothetical udp.hpp would implement the same methods.

If we can tolerate dynamic memory allocation during initialization, implementing this interface should not be a problem. I think we should embrace type erasure because it allows the construction of concise API that is easy to understand and hard to misuse. In general, I would like libcyphal to somewhat approximate the API of PyCyphal: Architecture — PyCyphal 1.13.1 documentation

Pulling a chapter from a very old book, what about this idea to get type-erasure without resorting to templates:

struct IComposite
{

    virtual SerializeResult serialize(nunavut::support::bitspan out_buffer) const = 0;

    virtual SerializeResult deserialize(nunavut::support::const_bitspan in_buffer) = 0;

    virtual  HResult QueryInterface( const char* type_name, void *& out_object) noexcept = 0;
);
protected:
    ~IComposite() = default;
};

and then a helper that hides the unsafe stuff:

template<typename T>
std::optional<std::reference_wrapper<T>> dsdl_cast(IComposite& obj)
{
    void* obj_as_i;
    if(0 == obj.QueryInterface(dsdl_traits<T>::type_name(), obj_as_i))
    {
        return std::optional<std::reference_wrapper<T>>{*reinterpret_cast<T*>(obj_as_i)};
    }
    return std::optional<std::reference_wrapper<T>>{};
}

so you end up writing code like this:

void printIfFoo(IComposite& unknown)
{
    auto obj = dsdl_cast<Foo_1_0>(unknown);
    if (obj)
    {
        std::cout << "unknown was a Foo_1_0. data = " << static_cast<Foo_1_0>(*obj).data << std::endl;
    }
    else
    {
        std::cout << "unknown remains a mystery." << std::endl;
    }
}

(full, crappy, example: Compiler Explorer)

@lydiagh ? @pavel.kirienko ?

Here’s a full proposal mockup for libcyphal itself: Compiler Explorer

This should extend to DSDL types once we promote the IPolymorphicType to CETL and integrate it with Nunavut.

I think the static cast here detracts attention from the key part; if I understand correctly, it can be avoided by replacing the optional ref wrapper with a raw pointer:

-std::cout << "unknown was a Foo_1_0. data = " << static_cast<Foo_1_0>(*obj).data << std::endl;
+std::cout << "unknown was a Foo_1_0. data = " << obj->data << std::endl;

I understand that implementing this solution would require raising at least one deviation. The expectation is that a faithfully implemented ordinary type erasure would require raising more deviations which is why we’re avoiding it. Right?

I am not super happy about this design. Before we lose all hope and resort to it, I would like to talk more about the regular type erasure with metaprogramming confined to 4 (four!) critical methods within the library — the factory methods that construct the presentation-level port objects, namely Publisher, Subscriber, Client, and Server. In this case, the factory class (which would be named Presentation if we were to follow the PyCyphal design) would look roughly as follows:

/// Polymorphic base classes; definitions omitted for brevity. Implementations are Voldemort types.
class Publisher;
class Subscriber;
class Client;
class Server;

class Presentation final
{
public:
    /// This factory method requires raising a deviation!
    /// The returned type can be used as-is or converted to the Publisher base and used with IComposite.
    template <typename T>
    auto* makePublisher(const std::uint16_t port_id)
    {
        class VoldemortPublisher final : public Publisher
        {
        public:
            VoldemortPublisher(...) {...}

            bool publish(const T& msg)
            {
                std::array<std::byte, T::SerializationBufferSize> buf;
                serialize(msg, buf);
                return iface.publish(port_id_, buf);  // This call is made up but you get the point.
            }

            bool publish(const IComposite& msg) override  // Compatibility with the IComposite interface.
            {
                if (auto obj = dsdl_cast<T>(msg))
                {
                    publish(*obj);
                    return true;
                }
                return false;
            }

        private:
            const std::uint16_t port_id_;
        };
        return allocator_.construct<VoldemortPublisher>(port_id, ...);
    }
};

The advantage of this approach is that many (most?) applications will be able to rely strictly on the concrete types rather than using templates or IComposite because the DSDL type is specialized in the type of object returned by the factory. The disadvantage is that it requires a few more deviations.

it seems to me there are two problem domains:

  1. DSDL types and runtime introspection
  2. Concrete object lifecycle in libcyphal

This post only discusses item (2) and a technique for erasing platform-specific, concrete types from objects vended through libcyphal.

I’m working on the first version of libcyphal that we’ll ask for an in-depth design review on and I have the following idea for shielding the transport layer from types defined for a specific platform.

And so I give you … the Voldemort Pointer?

/// TODO integrate this functionality into cetl::pmr::Factory
struct VoldemortPointer final
{
    template <typename T>
    using allocator_t = cetl::pmr::polymorphic_allocator<T>;

    template <typename T>
    using unique_ptr_t = std::unique_ptr<T, cetl::pmr::PolymorphicDeleter<allocator_t<T>>>;

    /// Construct a new concrete type but return a unique_ptr to an interface type for the concrete object.
    /// This becomes a polymorphic "voldemort" of sorts where only RTTI provides a safe way to refer to the
    /// concrete type after construction.
    template <typename InterfaceType, typename ConcreteType, typename... Args>
    static unique_ptr_t<InterfaceType> make_unique(allocator_t<ConcreteType>& concrete_allocator, Args&&... args)
    {
        ConcreteType* s = concrete_allocator.allocate(1);
        if (s)
        {
#if __cpp_exceptions
            try
            {
                concrete_allocator.construct(s, std::forward<Args>(args)...);
            } catch (...)
            {
                concrete_allocator.deallocate(s, 1);
                throw;
            }
#else
            concrete_allocator.construct(s, std::forward<Args>(args)...);
#endif
        }

        typename unique_ptr_t<InterfaceType>::deleter_type td{allocator_t<InterfaceType>{concrete_allocator.resource()},
                                                              1};

        return unique_ptr_t<InterfaceType>{s, td};
    }
    VoldemortPointer() = delete;
};

for example:

#include "CatToy.hpp"
#include "VoldemortPointer.hpp"

struct ICat
{
    virtual void meow() const noexcept = 0;

protected:
    // Yes, this is a wart. You could make the dtor public but then anyone could delete it.
    // This, at least, prevents a casual mistake and has the added benefit of describing
    // the one place in the system to look for who is going to delete a cat (soul-less bastards!)
    friend class cetl::pmr::PolymorphicDeleter<cetl::pf17::pmr::polymorphic_allocator<ICat>>;
    ~ICat() = default;
};
...

using CatPointer = VoldemortPointer::unique_ptr_t<ICat>;

extern CatPointer make_cat(const CatToy&);

… then, elsewhere we implement make_cat

class TomMarvoloRiddle: public ICat
{
public:
    TomMarvoloRiddle(const CatToy& toy) : toy_{toy} {}
    virtual ~TomMarvoloRiddle() = default;

    void meow() const noexcept override
    {
        // implement meow.
    }
private:
    CatToy toy_;
};


cetl::pf17::pmr::polymorphic_allocator<TomMarvoloRiddle> voldemort_allocator_{cetl::pf17::pmr::new_delete_resource()};

CatPointer make_cat(const CatToy& toy)
{
    VoldemortPointer::make_unique<ICat, TomMarvoloRiddle>(voldemort_allocator_, toy);
}

This technique takes advantage of the polymorphic part of the polymorphic_allocator in that the deleter is able to use polymorphism to delete the concrete type without a need to refer to the concrete type (i.e. the concrete type is erased in the pointer). This technique also avoids auto or hiding anything other than the object’s vtbl which is the sole source of dark magic in this configuration.

Okay, true, this isn’t very novel (and the Voldemort reference is contrived) but it’s subtly different from just passing up pointers and deleting through base class types in that the concrete allocator doesn’t need to be much of an allocator at all. In fact, our TomMarvoloRiddle object may be an item in a static array where we are using a monotonic_buffer_resource or could even be a singleton where we provide a no-op deleter. This pattern completely separates memory management of the concrete type from the APIs that vend references to its interfaces. Furthermore, ownership is transferred to the caller of make_unique and the factory context does not maintain any access to the concrete type.

This post only discusses item (2) and a technique for erasing platform-specific, concrete types from objects vended through libcyphal.

This is a slight detour from the topic of my post. I wanted to discuss the erasure of the DSDL type from port objects, such as where a concrete ConcreteSubscriber<T> for some DSDL type T is represented via some non-template base Subscriber. I mean, it is still type erasure, but applied to a different problem.

This technique takes advantage of the polymorphic part of the polymorphic_allocator in that the deleter is able to use polymorphism to delete the concrete type without a need to refer to the concrete type (i.e. the concrete type is erased in the pointer).
<…>
our TomMarvoloRiddle object may be an item in a static array where we are using a monotonic_buffer_resource or could even be a singleton where we provide a no-op deleter. This pattern completely separates memory management of the concrete type from the APIs that vend references to its interfaces. Furthermore, ownership is transferred to the caller of make_unique and the factory context does not maintain any access to the concrete type.

Do I understand correctly that the advantage of this approach is that it allows one to customize the lifecycle management of the allocated object (such as making it static or using some special allocation strategy)? I am trying to assess the implications of having or not having this ability. We do know that the operator delete is always provided with the true size of the concrete object, even if it is being deleted through a base pointer, so that addresses at least part of the problem.

the Voldemort reference is contrived

Indeed it is. I think it is appropriate to call your pointer a Horcrux, to be more technically correct.

Correct. These “Horcrux” pointers have a deleter that can abstract the “delete” action from the user of the pointer. The deleter can call delete, it can call deallocate on a PMR allocator, it can decrement the reference count on a shared buffer, or it could even set a bit in a hardware register to indicate that a given DMA buffer was no longer in use, etc. But this is a property one gets with any unique_ptr. The subtle difference in this pattern is that, like the Voldemort type, only the creator of the pointer is using the concrete type and once the pointer is created and ownership is transferred the owner of the pointer cannot know (i.e. cannot name) the concrete type. This concrete type does live on in the deleter but that too is opaque to the owner of the pointer. Finally, using the friend declaration, any attempt by the owner (or other objects the owner lends a raw pointer to) to delete this pointer will fail since only the deleter is allowed to use the protected destructor.

class SomeDumbObject final
{
public:
    SomeDumbObject(ICat* a_cat) : cat_{a_cat} {}
    ~SomeDumObject()
    {
        delete cat_; //< nope. ICat::~ICat is protected and SomeDumbObject is not
                     //  a friend.
    }
private:
    ICat* cat_;
};

CatPointer tabby = make_cat(some_toy);
SomeDumbObject foo{tabby.get()}; //< nope (see above)

That’s neat, indeed. Can we settle on some kind of plan regarding the design of the port factory methods? Are we generally okay with using Voldemort types, perhaps vended via your Horcrux pointers to allow for deep customization of the object lifecycle?

As long as we agree that we need an IComposite to allow generic serialization and deserialization in libcyphal

As long as the ability to use the concrete type of a DSDL object remains an option, I see no harm in that.