How to cast std::tuple to std::any?

Viewed 469

Here's what I'm trying:

auto fwd_args = std::forward_as_tuple(std::forward<Args>(args)...);
auto key = std::make_pair(std::type_index(typeid(T)), std::any(fwd_args));

The error is:

error C2440: '': cannot convert from 'std::tuple<const char (&)[78],_Ty &&>' to 'std::any'

Which traces back to here:

factory->get<Font>(R"(C:\Fonts\myfont.ttf)", 24)

Where the Font c'tor is:

explicit Font(const std::string& filename, float fontSize=32) {

My questions:

  1. Can I cast an arbitrary args to an std::any?
  2. If not, how can I use arbitrary args as a key in a map?

Full code below:

class SingletonFactory {
  public:
    template<typename T, typename... Args>
    const T &get(Args &&... args) {
        auto fwd_args = std::forward_as_tuple(std::forward<Args>(args)...);
        auto key = std::make_pair(std::type_index(typeid(T)), std::any(fwd_args));

        auto it = _cache.find(key);

        if(it != _cache.end()) {
            return std::any_cast<T>(it->second);
        }

        return std::any_cast<T>(_cache.emplace(std::piecewise_construct, std::forward_as_tuple(key), fwd_args).first);
    }

  private:
    std::map<std::pair<std::type_index, std::any>, std::any> _cache{};
};

Instead of std::pair<std::type_index, std::any> we can try using a struct which implements all the necessary methods...

Example on Godbolt


What I'm trying to do:

I'm trying to build a cache for some assets/resources for my game. e.g. if I want to use the same font in two different places, I don't want to have load it twice (which involves reading it from disk and converting it into a texture and uploading it to the GPU). And because the resources have handles, it's important that their destructors are called deterministically (e.g. when unloading a level I will destroy the factory).

I can do this all manually of course, but I don't want to have to keep track of where I use a 24px FontA vs a 32px FontB and manually pipe those objects around my game. I just want general cache that I can dump everything into. Then if I've used that specific asset before, great, it'll be re-used, if not, it can make a new one. If I later decide to scrap that level or asset or what have you, I just delete the get<> and it's gone, I don't have to backtrack and find every place I piped it through.

3 Answers

Remember that type erasure facilities like std::any and std::function only exposes the interface they promise and nothing more: std::any encapsulates copy-ability but nothing more, thus you cannot compare equality / hash a std::any object. Using an additional std::function just to store the operator< is cumbersome (that's essentially using two vtables for every type), you're better off using hand-rolled type erasure.

Also, inferred from your requirements, you have to special-case const char* or const char(&)[N] parameters, since you want them stored as std::string, and also for their comparison operators. This also solves your "storing a std::tuple with reference members in std::any" problem. (See edit note #2 for more discussions.)

The code in your godbolt link was incorrect in some places, especially that you were passing the arguments for T's constructor to construct a std::any (missing the std::in_place_type<T> at the front, that is).

The following implementation uses C++20 for convenience, but it can be made working under older standard with some modification.

Edit #1: Fixed uninitialized initial hash value, really a noob error on me.

Edit #2: Yes, the trick to special-case const char* isn't great, and it prevents c'tors that take const char* from working. You could just rewrite it as "just decay every parameter and don't take any special action for const char* or const char(&)[N]", and that will work for all the c'tors. But this also will only work if you pass in string literals, or else you might have a dangling pointer stored in your hash map. Maybe this approach is OK if you specify every location where you really want to pass the reference by std::string (e.g. by using UDL like "hello"s or explicitly constructing a std::string).
AFAIK you cannot get the parameter types of c'tors since C++ explicitly disallows taking the address of c'tors, and if you cannot form the pointer to member function you cannot do template tricks on it. Also, overload resolution might be another barrier to achieving this.

Edit #3: I didn't notice that there would be non-copyable objects to cache. In this case, std::any is of no use, since it can only store copyable objects. Using similar type erasure technique non-copyable objects could also be stored. My implementation just uses std::unique_ptr to store the erased keys and values, forcing them to be stored on the heap. This simple method even supports non-copyable and non-movable types. If SBO is needed, more sophisticated ways to store the type-erased objects must be used.

#include <iostream>
#include <unordered_map>
#include <type_traits>

// Algorithm taken from boost
template <typename T>
void hash_combine(std::size_t& seed, const T& value)
{
    static constexpr std::size_t golden_ratio = []
    {
        if constexpr (sizeof(std::size_t) == 4)
            return 0x9e3779b9u;
        else if constexpr (sizeof(std::size_t) == 8)
            return 0x9e3779b97f4a7c15ull;
    }();
    seed ^= std::hash<T>{}(value) + golden_ratio +
        std::rotl(seed, 6) + std::rotr(seed, 2);
}

class Factory
{
public:
    template <typename T, typename... Args>
    const T& get(Args&&... args)
    {
        Key key = construct_key<T, Args...>(static_cast<Args&&>(args)...);
        if (const auto iter = cache_.find(key); iter != cache_.end())
            return static_cast<ValueImpl<T>&>(*iter->second).value;
        Value value = key->construct();
        const auto [iter, emplaced] = cache_.emplace(
            std::piecewise_construct,
            // Move the key, or it would be forwarded as an lvalue reference in the tuple
            std::forward_as_tuple(std::move(key)),
            // Also the value, remember that this tuple constructs a std::any, not a T
            std::forward_as_tuple(std::move(value))
        );
        return static_cast<ValueImpl<T>&>(*iter->second).value;
    }

private:
    struct ValueModel
    {
        virtual ~ValueModel() noexcept = default;
    };

    template <typename T>
    struct ValueImpl final : ValueModel
    {
        T value;

        template <typename... Args>
        explicit ValueImpl(Args&&... args): value(static_cast<Args&&>(args)...) {}
    };
    
    using Value = std::unique_ptr<ValueModel>;

    struct KeyModel
    {
        virtual ~KeyModel() noexcept = default;
        virtual std::size_t hash() const = 0;
        virtual bool equal(const KeyModel& other) const = 0;
        virtual Value construct() const = 0;
    };

    template <typename T, typename... Args>
    class KeyImpl final : public KeyModel
    {
    public:
        template <typename... Ts>
        explicit KeyImpl(Ts&&... args): args_(static_cast<Ts&&>(args)...) {}

        // Use hash_combine to get a hash
        std::size_t hash() const override
        {
            std::size_t seed{};
            std::apply([&](auto&&... args)
            {
                (hash_combine(seed, args), ...);
            }, args_);
            return seed;
        }

        bool equal(const KeyModel& other) const override
        {
            const auto* ptr = dynamic_cast<const KeyImpl*>(&other);
            if (!ptr) return false; // object types or parameter types don't match
            return args_ == ptr->args_;
        }

        Value construct() const override
        {
            return std::apply([](const Args&... args)
            {
                return std::make_unique<ValueImpl<T>>(args...);
            }, args_);
        }

    private:
        std::tuple<Args...> args_;
    };

    using Key = std::unique_ptr<KeyModel>;
    using Hasher = decltype([](const Key& key) { return key->hash(); });
    using KeyEqual = decltype([](const Key& lhs, const Key& rhs) { return lhs->equal(*rhs); });

    std::unordered_map<Key, Value, Hasher, KeyEqual> cache_;

    template <typename T, typename... Args>
    static Key construct_key(Args&&... args)
    {
        constexpr auto decay_or_string = []<typename U>(U&& arg)
        {
            // convert to std::string if U decays to const char*
            if constexpr (std::is_same_v<std::decay_t<U>, const char*>)
                return std::string(arg);
                // Or just decay the parameter otherwise
            else
                return std::decay_t<U>(arg);
        };
        using KeyImplType = KeyImpl<T, decltype(decay_or_string(static_cast<Args&&>(args)))...>;
        return std::make_unique<KeyImplType>(decay_or_string(static_cast<Args&&>(args))...);
    }
};

struct IntRes
{
    int id;
    explicit IntRes(const int id): id(id) {}
};

struct StringRes
{
    std::string id;
    explicit StringRes(std::string id): id(std::move(id)) {}
};

int main()
{
    Factory factory;
    std::cout << factory.get<IntRes>(42).id << std::endl;
    std::cout << factory.get<StringRes>("hello").id << std::endl;
}

The problem, as noted in the comments, is not the std::tuple template. It's specifically this part in your code:

const T &get(Args &&... args) {
    auto fwd_args = std::forward_as_tuple(std::forward<Args>(args)...);

That's obviously a tuple of references. You can't even put one reference in a std::any, let alone a tuple of them. If you can live with copies, just drop the references here. There's no rule in C++ that Template Argument Packs must be forwarded as tuples of references.

As for Q2, "If not, how can I use arbitrary args as a key in a map?" - you can't. Map keys must have a partial ordering. Even the float fontSize is already a bit problematic, if someone would pass a NaN.

Ignoring the code you provided, and reading "What I'm trying to do".
Here is all I have:

#include <map>
#include <any>
#include <string_view>

class LevelCache {
  std::map<const std::string_view, std::any> self_;

public:
  template <class T> auto get(std::string_view key) const -> T const& {
    // If it's not there what on earth do I return? Throw. You handle it.
    return std::any_cast<T const&>(self_.at(key));
  }

  auto operator [](std::string_view key) -> std::any& {
    return self_[key]; // not const. Used for insert
  }
};

#include <iostream>
auto main() -> int {
  auto lvl = LevelCache();
  lvl["Some resource"] = 1;
  lvl["a string"] = "some string"; // Not sure if UB, or not. 

  std::cout << "lvl[\"Some resource\"] -> " << lvl.get<int>("Some resource") << '\n'
            << "lvl[\"a string\"] -> " << lvl.get<char const*>("a string");
}

I couldn't come up with anything better of the top of my head. Does that solve (part of) the problem you're facing? Because I'm having a hard time understanding the exact concern.
Compiler Explorer

Related