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;
}