Does C++ need runtime duck typing?

TLDR; With reflection as proposed in P2996, we could achieve pythonic duck-typing in C++ and have an object with a runtime-dispatch member / member-function lookup.

Background

A discussion at the ISO C++ committee meeting in Tokyo made me think, “what can we do to save C++ and prevent it from being a language only meant for advanced usages?” Someone might simply argue why we would even want that, but I’d say if C++ could be made simpler and more accessible, while the more advanced features are left for the library authors and advanced users, it wouldn’t be a bad thing. It would mean more people can contribute to existing C++ codebases, instead of writing brand new codebases in languages that have at present lent themselves to some subset of things that C++ has lent itself to.

Another disussion at C++Now in Aspen led to this thought: Once the new and shiny languages get to a point where they’ve added enough features to bring over highly performance sensitive C++ users, they might find themselves in the same trap as C++. Albeit with better package managers and build tools, unmarred by C++’s storied past having integrated with systems packages.

Finally, the culmination of this line of thinking was a quote by Gasper at NYC++ during his talk “C++ is a multi-paradigm programming language; what paradigms do we have? ALL OF THEM!”

virtual_any

Today I’d like to muse about why we should consider adding dynamic, runtime, duck typing to C++. No caveats, no compile time magic tricks that look cute but have other trade-offs. I’d like to have unapologetic duck typed code that dispatches the method at runtime based on the string method name you were trying to call, or returns a member of a class based on the string name you used to access it. I want python like behavior. I want python like syntax. I want it in C++ for the 70% of my code which doesn’t care about performance:

  1. For init time code that loads config files and does some operation on them.
  2. For the script-like code that is a part of my binaries sometimes.
  3. For dependency injection when I don’t want every factory class to be in a visible header. I’d like to be able to inject dependencies from a remote part of my code, without having to create virtual interfaces manually.
  4. For create module boundaries where an ABI breakage causes a segfault currently, a duck typed ABI boundary could throw clean exceptions instead. The same way python module imports do.
  5. For the code where it’s just simply annoying to keep typing things.
  6. For the code where we end up using variants only to represent inherently unstructured data.

I want to borrow the good parts of python and make a variant of virtually anything! I want it to behave like a virtual interface, but generated automatically.

I want std::virtual_any (I’ll settle for boost::virtual_any).

Sadly, it will use reflection, as described in https://wg21.link/p2996, and which hasn’t yet landed but the optimist in me believes it will be in C++26 with a high probability. I spoke about it in my talk at C++Now this year too, trying to hype it up and make sure it lands. We also have two (yes, two!) working compiler implementations of it, one is EDG and the other is Clang. Both are available on Godbolt, and I have been able to compile the clang fork locally and experiment with it.

So, lengthy intro aside, let’s get started.

The desired API

I have dreamed about this syntax for a while:

class MyDuck {
  public:
    std::string name;
    int repeat_count;
    std::string quack() {
        std::stringstream ss;
        int reps = repeat_count;
        while (reps--) {
            ss << "quack ";
        }
        return ss.str();
    }
    friend std::ostream& operator<<(std::ostream&, const MyDuck&);
};

std::ostream& operator<<(std::ostream& os, const MyDuck& duck) {
    return os << "MyDuck[" << duck.name << "]";
}

int main() {
    auto myvar = std::make_virtual_any<int>(2);
    std::cout << "myvar is " << a << std::endl;

    myvar = make_virtual_any(MyDuck(3));
    std::cout << "myvar is " << myvar << " with repeat_count "
              << myvar.attr("repeat_count")
              << " and quack output is " << myvar.attr("quack")()
              << std::endl;
}

The myvar object can be assigned any virtual_any instance, and the instance has a dynamic-dispatch-like member function called attr which lets you access members or member functions in a completely untyped manner. We might also expect the .attr function to throw an exception if the attribute was not found.

A somewhat similar API was proposed in boost::python::object as follows:

object f(object x, object y) {
     if (y == "foo")
         x.slice(3,7) = "bar";
     else
         x.attr("items") += y(3, x);
     return x;
}

Of course, this was implemented using python’s machinery and doesn’t interop nicely with existing C++ classes. So we need to look into reflection to make this happen.

The code

I have a working code here.

class virtual_any_interface;
class virtual_any {
    std::shared_ptr<virtual_any_interface> _impl;
    bool valid = false;

   public:
    virtual_any() = default;
    virtual_any(std::shared_ptr<virtual_any_interface> elem) : _impl(elem) { valid = true; }
    virtual_any(const virtual_any& other) = default;
    virtual_any(virtual_any&& other) = default;
    virtual_any& operator=(const virtual_any& other) = default;
    virtual_any& operator=(virtual_any&& other) = default;

    virtual_any attr(const std::string& name);
    virtual_any operator()();
    friend std::ostream& operator<<(std::ostream& os, const virtual_any& self);
};

template <typename T>
virtual_any make_virtual_any(T value);

class virtual_any_interface {
   public:
    virtual ~virtual_any_interface() = default;
    virtual virtual_any attr(const std::string& name) = 0;
    virtual virtual_any call() = 0;
    virtual std::ostream& stream(std::ostream& os) const = 0;
};

template <typename T>
class virtual_any_impl : public virtual_any_interface {
    T _value;

    // All members and methods
    std::vector<std::pair<std::string, virtual_any>> get_attrs();

   public:
    virtual_any_impl(T& value) : _value(std::forward<T>(value)) {}
    virtual virtual_any attr(const std::string& name) override;
    virtual virtual_any call() override;
    virtual std::ostream& stream(std::ostream& os) const override;
};

template <typename T>
std::vector<std::pair<std::string, virtual_any>> virtual_any_impl<T>::get_attrs() {
    using T2 = std::remove_cvref_t<T>;
    if constexpr(!meta::test_type (^std::is_class_v, ^T)) {
        return {};
    } else if constexpr(std::is_same_v<T2, std::string> ||
                        std::is_same_v<T2, std::function<virtual_any(void)>>) {
        return {};
    } else {
        // Actual logic
        std::vector<std::pair<std::string, virtual_any>> attrs;
        [:expand(meta::members_of (^T)):] >> [&]<auto e> {
            if constexpr(!meta::is_public(e)) {
                return;
            } else {
                auto name = std::string(std::meta::name_of(e));
                if constexpr(meta::is_nonstatic_data_member(e)) {
                    attrs.push_back({name, make_virtual_any(_value.[:e:])});
                } else if constexpr(meta::is_function(e) && !meta::is_constructor(e) &&
                                    !meta::is_destructor(e)) {
                    if constexpr(!meta::is_special_member(e)) {
                        std::function<virtual_any(void)> l = [this]() -> virtual_any {
                            return make_virtual_any(_value.[:e:]());
                        };
                        attrs.push_back({name, make_virtual_any(l)});
                    }
                }
            }
        };
        return attrs;
    }
}

template <typename T>
virtual_any virtual_any_impl<T>::attr(const std::string& name) {
    auto attrs = get_attrs();
    if (attrs.size() == 0) {
        throw std::runtime_error("No attributes found");
    }
    for (const auto & [ name2, e ] : attrs) {
        if (name2 == name) {
            return e;
        }
    }
    std::stringstream ss;
    ss << "Attribute " << name << " not found in object of type " << std::meta::name_of (^T);
    throw std::runtime_error(ss.str());
}

template <typename T>
virtual_any virtual_any_impl<T>::call() {
    if constexpr(std::is_same_v<T, std::function<virtual_any(void)>>) {
        return _value();
    } else {
        std::stringstream ss;
        ss << "Object of type " << std::meta::name_of (^T) << " is not callable";
        throw std::runtime_error(ss.str());
    }
}

template <typename T>
std::ostream& virtual_any_impl<T>::stream(std::ostream& os) const {
    if constexpr(is_streamable<std::stringstream, T>::value) {
        os << _value;
    } else {
        os << "(non-streamable object of type " << std::meta::name_of (^T) << ")";
    }
    return os;
}

virtual_any virtual_any::attr(const std::string& name) {
    return _impl->attr(name);
}

virtual_any virtual_any::operator()() {
    return _impl->call();
}

std::ostream& operator<<(std::ostream& os, const virtual_any& self) {
    return self._impl->stream(os);
}

template <typename T>
virtual_any make_virtual_any(T value) {
    std::shared_ptr<virtual_any_interface> elem = std::make_shared<virtual_any_impl<T>>(value);
    return virtual_any{elem};
}