c++templatesargument-dependent-lookupcustomization-pointtag-invoke

customisation point for alias to std types


Let's say I am writing some generic algorithm in lib namespace that calls a customisation point my_func.

First attempt is using ADL for my_func one of the user wants to specialise my_func for his type, which is an alias to std type. Surely define it in his namespace won't work because ADL won't work for alias. Defining it in std namespace is not allowed by the standard. the only option left seems to define in the algorithm's namespace lib. But this doesn't work either if the end user includes the algorithm header before including the customisation header.

#include <iostream>
#include <array>

// my_algorithm.hpp
namespace lib{

template<typename T>
void my_algorithm(const T& t){
    my_func(t);
}

} // namespace lib

// user1.hpp
namespace user1{

struct Foo1{
    // this is working as expected (ADL)
    friend void my_func(const Foo1&){
        std::cout << "called user1's customisation\n";
    }
};

} // namespace user1

// user2.hpp
namespace user2{

using Foo2 = std::array<int,1>;

// this won't work because Foo2 is actually in std namespace
void my_func(const Foo2&){
        std::cout << "called user2's customisation\n";
}

} // namespace user2

/* surely this isn't allowed
namespace std{
void my_func(const user2::Foo2&){
        std::cout << "called user2's customisation\n";
}
} //namespace std
*/

// another attempt to costomize in the algorithm's namespace
// this won't work because my_func isn't seen before my_algorithm
namespace lib{
    void my_func(const user2::Foo2&){
        std::cout << "called user2's customisation\n";
    }
}



// main.cpp
// #include "algorithm.hpp"
// #include "user1.hpp"
// #include "user2.hpp"
int main(){
    lib::my_algorithm(user1::Foo1{});
    lib::my_algorithm(user2::Foo2{});
}

https://godbolt.org/z/bfdP8s

Second attempt is using niebloids for my_func, which has the same problem as ADL.

Third attempt is using tag_invoke, which should have same problem as ADL, i.e.,

#include <iostream>
#include <array>

// tag_invoke.hpp  overly simplified version
namespace lib_ti{

inline namespace tag_invoke_impl{

inline constexpr struct tag_invoke_fn{

template<typename CP, typename... Args>
decltype(auto) operator()(CP cp, Args&&... args) const{
    return tag_invoke(cp, static_cast<Args&&>(args)...);
}

} tag_invoke{};

} // namespace tag_invoke_impl
} // namespace lib_to


// my_algorithm.hpp

// #include "tag_invoke.hpp"
namespace lib{

inline constexpr struct my_func_fn {
    
template <typename T>
void operator()(const T& t) const{
    lib_ti::tag_invoke(*this, t);
}

} my_func{};


template<typename T>
void my_algorithm(const T& t){
    my_func(t);
}

} // namespace lib

// user1.hpp
namespace user1{

struct Foo1{
    // this is working as expected (ADL)
    friend void tag_invoke(lib::my_func_fn, const Foo1&){
        std::cout << "called user1's customisation\n";
    }
};

} // namespace user1

// user2.hpp
namespace user2{

using Foo2 = std::array<int,1>;

// this won't work because Foo2 is actually in std namespace
void tag_invoke(lib::my_func_fn, const Foo2&){
        std::cout << "called user2's customisation\n";
}

} // namespace user2

/* surely this isn't allowed
namespace std{
void tag_invoke(lib::my_func_fn, const user2::Foo2&){
        std::cout << "called user2's customisation\n";
}
} //namespace std
*/

// another attempt to customise in the algorithm's namespace
// In ADL case, this does not work. But in this case, it seems to work. why?
namespace lib{
    void tag_invoke(lib::my_func_fn, const user2::Foo2&){
        std::cout << "called user2's customisation\n";
    }
}



// main.cpp
int main(){
    lib::my_algorithm(user1::Foo1{});
    lib::my_algorithm(user2::Foo2{});
}

https://godbolt.org/z/hsKbKE

Why does this not have the same problem as the First one (raw ADL)?

Forth attempt is using template specialisation, which seems to work normally as expected

#include <iostream>
#include <array>




// my_algorithm.hpp

namespace lib{

template<typename T, typename = void>
struct my_func_impl{
    //void static apply(const T&) = delete;
};

inline constexpr struct my_func_fn {
    
template <typename T>
void operator()(const T& t) const{
    using impl = my_func_impl<std::decay_t<T>>;
    impl::apply(t);
}

} my_func{};


template<typename T>
void my_algorithm(const T& t){
    my_func(t);
}

} // namespace lib

// user1.hpp
namespace user1{

struct Foo1{};

} // namespace user1

namespace lib{

template<>
struct my_func_impl<user1::Foo1>{
    void static apply(const user1::Foo1&){
        std::cout << "called user1's customisation\n";
    }
};

} //namespace lib



// user2.hpp
namespace user2{

using Foo2 = std::array<int,1>;

} // namespace user2

namespace lib{

template<>
struct my_func_impl<user2::Foo2>{
    void static apply(const user2::Foo2&){
        std::cout << "called user2's customisation\n";
    }
};

}



// main.cpp
int main(){
    lib::my_algorithm(user1::Foo1{});
    lib::my_algorithm(user2::Foo2{});
}

https://godbolt.org/z/r71x6c


What is the best way to write generic algorithms and customisation points and allow clients to customise for aliases for std types?


Solution

  • one of the user wants to specialise my_func for his type, which is an alias to std type

    This is the original sin, which is causing you all the pain. Type aliases in C++ are just aliases; they're not new types. You have a generic algorithm that uses a customization point, something like

    // stringify_pair is my generic algorithm; operator<< is my customization point
    template<class T>
    std::string stringify_pair(K key, V value) {
        std::ostringstream oss;
        oss << key << ':' << value;
        return std::move(oss).str();
    }
    

    Your user wants to call this generic algorithm with a standard type, like

    std::string mykey = "abc";
    std::optional<int> myvalue = 42;
    std::cout << stringify_pair(mykey, myvalue);
    

    This doesn't work because std::optional<int> doesn't provide an operator<<. It can't possibly be made to work, because your user doesn't own the std::optional<int> type and therefore can't add operations to it. (They can certainly try, physically speaking; but it doesn't work from a philosophical point of view, which is why you keep running into roadblocks every time you get (physically) close.)

    The simplest way for the user to make their code work is for them to "take legal ownership" of the type definition, instead of relying on somebody else's type.

    struct OptionalInt {
        std::optional<int> data_;
        OptionalInt(int x) : data_(x) {}
        friend std::ostream& operator<<(std::ostream&, const OptionalInt&);
    };
    OptionalInt myvalue = 42;  // no problem now
    

    You ask why tag_invoke doesn't have the same problem as raw ADL. I believe the answer is that when you call lib::my_func(t), which calls lib_ti::tag_invoke(*this, t), which does an ADL call to tag_invoke(lib::my_func, t), it's doing ADL with an argument list that includes both your t (which doesn't really matter) and that first argument of type lib::my_func_fn (which means lib is an associated namespace for this call). That's why it finds the tag_invoke overload you put into namespace lib.

    In the raw ADL case, namespace lib is not an associated namespace of the call to my_func(t). The my_func overload you put into namespace lib is not found, because it isn't found by ADL (not in an associated namespace) and it isn't found by regular unqualified lookup either (because waves hands vaguely two-phase lookup).


    What is the best way to write generic algorithms and customisation points and allow clients to customise for aliases for std types?

    Don't. The "interface" of a type — what operations it supports, what you're allowed to do with it — is under the control of the author of the type. If you're not the author of the type, don't add operations to it; instead, create your own type (possibly by inheritance, preferably by composition) and give it whatever operations you want.

    In the worst case, you end up with two different users in different parts of the program, one doing

    using IntSet = std::set<int>;
    template<> struct std::hash<IntSet> {
        size_t operator()(const IntSet& s) const { return s.size(); }
    };
    

    and the other one doing

    using IntSet = std::set<int>;
    template<> struct std::hash<IntSet> {
        size_t operator()(const IntSet& s, size_t h = 0) const {
            for (int i : s) h += std::hash<int>()(i);
            return h;
        }
    };
    

    and then both of them try to use std::unordered_set<IntSet>, and then boom, ODR violation and undefined behavior at runtime when you pass a std::unordered_set<IntSet> from one object file to another and they agree on the name of std::hash<std::set<int>> but disagree on its meaning. It's just a huge can of worms. Don't open it.