Search code examples
c++templatescontainersc++20template-specialization

How to implement heterogenous lookup aka specialize a template class minimizing code repetition


Consider a class like this (omitting details):

template<typename K, typename V>
class my_flat_map
{
    // A lot of member functions taking a const ref to key
    auto find(K const& key) { /*...*/ }
    auto contains(K const& key) { /*...*/ }
    auto insert_or_assign(K const& key, V const& val) { /*...*/ }
    auto insert_if_missing(K const& key, V const& val) { /*...*/ }
    // ...
};

When instantiating the template with a string-like key type I'd like to have all those member functions just accepting the corresponding string_view instead of a const reference to the actual key type. I know that I can obtain this with a partial specialization for each possible string class:

template<typename V>
class my_flat_map<std::u32string,V>
{
    auto find(std::u32string_view key) { /*...*/ }
    // ...
};

template<typename V>
class my_flat_map<std::u16string,V>
{
    auto find(std::u16string_view key) { /*...*/ }
    // ...
};

template<typename V>
class my_flat_map<std::u8string,V>
{
    auto find(std::u8string_view key) { /*...*/ }
    // ...
};

template<typename V>
class my_flat_map<other::esotic_string,V>
{
    auto find(other::esotic_string_view key) { /*...*/ }
    // ...
};

// ...

The bodies of all the member functions are exactly the same, the only change is the signature. Is there a mechanism to express this without repeating all the code?


Solution

  • Approach A

    You could add an alias to your class:

    #include <string>
    #include <string_view>
    
    // for all non-string types, we fall back to const T&
    template <typename T>
    struct key_view { using type = const T&; };
    
    // For any specialization of std::basic_string, we use the
    // corresponding std::basic_string_view.
    // For example, key_view<std::string>::type = std::string_view
    template <typename Char, typename Traits, typename Alloc>
    struct key_view<std::basic_string<Char, Traits, Alloc>> {
        using type = std::basic_string_view<Char, Traits>;
    };
    
    // If the key is already a std::basic_string_view, we just use that as the type.
    template <typename Char, typename Traits>
    struct key_view<std::basic_string_view<Char, Traits>> {
        using type = std::basic_string_view<Char, Traits>;
    };
    
    template<typename K, typename V>
    struct my_flat_map
    {
        // No major change to the flat_map needed, we just use this alias
        using key_view = key_view<K>::type;
    
        auto find(key_view key) { /*...*/ }
        auto contains(key_view key) { /*...*/ }
        auto insert_or_assign(key_view key, V const& val) { /*...*/ }
        auto insert_if_missing(key_view key, V const& val) { /*...*/ }
        // ...
    };
    

    Without any further changes to my_flat_map, you can add more partial specializations of key_view to handle more string-like types (perhaps Qt or boost strings).

    Further Notes

    Also note that using key_view in insert_ functions makes no sense. When inserting, the standard library usually uses two overloads for K&& and const K&. You can't insert a view anyway, so keep it:

    auto insert_or_assign(K const& key, V const& val) { /*...*/ }
    auto insert_or_assign(K && key, V const& val) { /*...*/ }
    auto insert_or_assign(K const& key, V && val) { /*...*/ }
    auto insert_or_assign(K && key, V && val) { /*...*/ }
    

    To avoid repetition, you can use a function template instead. Look into std::map::try_emplace for design inspiration:

    template <typename Key, typename... Args>
    auto try_emplace_or_assign(K && key, Args && ...args) { /*...*/ }
    

    Approach B

    You can simply turn some operations into templates to generally support heterogeneous lookup, not just in special cases like std::string_view:

    #include <concepts>
    
    template <typename T, typename U>
    concept equality_with_impl = requires (const T& a, const U& b) {
        { a == b } -> std::convertible_to<bool>;
    };
    
    template <typename T, typename U>
    concept equality_with = equality_with_impl<T, U> && equality_with_impl<U, T>;
    
    template<typename K, typename V>
    struct my_flat_map
    {
        template <equality_with<K> T, typename Equal = std::ranges::equal_to>
        auto find(const T& key, Equal equal = {}) { /*...*/ }
        // ...
    };
    

    Now, if the user calls my_flat_map<std::string, ...>::find with a const char*, std::string, or std::string_view, it would always work because std::string has a == operator for these.

    The user can also provide a custom Equal function object in case there is no appropriate ==.


    Note: if you're wondering why both equality_with and equality_with_impl are necessary, refer to: Why does same_as concept check type equality twice?