Associated Methods (draft 2)

Document #:
Date:
Project: Programming Language C++
Reply-to:

Contents

Motivation
Alternative Solutions
Proposed Solution
Conclusion
Examples
Q&A
References

Motivation

Consider the following 3 issues:

  1. We have to invent extensive machinery, like the operator | overloading, used in Ranges, in order to enable function call chaining.
    Otherwise, we don’t provide a generic implementation - currently std::optional is the only class with “monadic” operations1, where ideally these operations should be available to all optional-like types.
  2. More often then not, the Ranges workaround is not appropriate. This is the case with “utility functions”, like the “starts_with” functions, introduced in C++20 to std::string and std::string_ref. It is desirable for this type of functions to be callable as methods, yet it is also desirable for them to not be members, because they don’t need private access. Right now we must choose one or the other.
    Further, implementing starts_with and similar today, often means manually duplicating code - std::string, std::string_ref, QString, QStringRef, etc will all have the same implementation of starts_with.
  3. We have no way for an existing type to model a new concept, defined in terms of method calls. A new type must be introduced, either wrapping or subclassing the original. To alleviate that, we are forced into duplicated concept requirements, both as members and as free functions.

All these issues are due to the fact, methods can only be declared as members of a concrete new class - we can’t declare methods for multiple types and we can’t declare methods for an existing type.
This limitation is yet to be felt more strongly, because until C++20, the way we represented (a set of) multiple types, was exclusively via the abstract base class idiom. “The set” being all possible subclasses, and the methods were preexisting by the virtue of being declared in the base class.
With the introduction of Concepts, we now have the option to define “the set” as all types, matching some concept requirements. As long as the concept defines the type required interface, it acts as a more liberal form of an abstract base class.

struct DynoPoly
{
  virtual void f() = 0;
  virtual ~DynoPoly() = default;
};

void func(DynoPoly& a) 
{
  a.f();
} 
template<class T>
concept StatPoly = requires(T& i)
{
  i.f();
};

void func(StatPoly auto& a)
{
  a.f();
}

Ideally, the concept version in the above code should be much more generic, accepting any type. However, if the type does not already model StatPoly, we will need to introduce a new class, not unlike the abstract base class subclassing requirement. In most cases type introduction is not desirable (or even practically possible). This forces us to change both the interface (StatPoly) and the implementation of func to use free functions instead of methods, in an effort to move the implementation responsibility away from the type itself. This trade-off is a considerable disadvantage, even if we ignore its technical downsides, as it forces library authors to write code in specific style, complicates concepts definitions, and prevents us from directly migrating code, written in terms of dynamic polymorphism to a static one.

Alternative Solutions

0. UFCS

The “easiest” solution to the above problems is to have Uniform Function Call Syntax in the language. This was already proposed2 and rejected in few different forms and I mention it only for completeness.

1. “Extension Methods”

Another popular alternative is to have the so-called “Extension Methods”3.
The way they are always implemented however, leaves them with some of the same downsides as UFCS. In particular, because “Extension Methods” are of lower priority then class members, a library can hijack a call to a user extension method, by introducing a member of the same name to the type, receiving the extension.
Can “Extension Methods” have other semantics, preventing them from changing user code?
Not realistically. The only way to not interfere with user code is for them to have higher priority then class members. This would mean an extremely odd behavior in which both the function declarations and, by extension, the using directive are able to hide class members. Even if we “can live” with EM being special in that way, what will happen in the presence of multiple “Extension Methods” for a given type, possibly even from different libraries? Do they overload each other? If “yes”, then the user code can still change, if “no”, then function declarations (and using directives) must hide each other, which will be even more odd.
What is more, we will need wrappers for every existing function, we want to use as EM, in order to not incidentally hide a member, with the same name as a free function, already in use. This is far from ideal, even if feasible.

2. “Language Pipe” (partial solution)

With the rise of “the functional programming style”, and with the introduction of Ranges into C++, an idea for an alternative to free function calls has gained some popularity4. The idea is to have a new syntax to call free functions, one which allows chaining: x @ foo(param) @ bar(); in place of bar(foo(x, param));.
It is immediately obvious that this alternative realistically only handles the first problem case - the need for a workaround in “monadic” expressions. It does not really address the needs of “utility functions” as it is unlikely, the users will prefer the new syntax in pace of implementation as a member, or even as a free function. “Extending existing classes” to require a special syntax will not sit well with anyone, requesting such a feature.
For the 3th problem case, a class modeling a new concept, things are actually very interesting. At first glance, it seems this case is unrelated and unaffected, but upon closer inspection, it looks like, this solution might make things worse. This is because, having a new call syntax is, by definition, a new way to declare concept requirements.

template<class T>
concept NewAndShiny = requires(T& i)
{
  i @ f();
};

Adopting this solution, we will end up with 3 types of function calls: free and member, with syntax inherited from C, and a brand new, “monadic” one, with a syntax, C++ invented/stole-from-FP.
But there is the problem - we don’t have clear notion (or commitment) to “monadic” interfaces as their own entity. If we introduce a new syntax, then we must have at least some rules, when to use it, and what a concept, defined in terms of it, should mean. Considering “monadic” interfaces can be expressed as members as well, the way std::optional already does, we clearly have not made things easier for ourselves. Much like today’s member+free combo for “method” calls, we will have member+new_syntax combo for “monadic” calls.

Interestingly, current operator| workaround has the exact same problem. It opens the possibility to define a concept in term of it, with this concept meaning “range” or “monadic” usage, but such a concept will have to also support member calls, because some libraries and/or classes might have implemented their operations in that way.

3. Do we have any other option?

This paper argues, we do, by taking what works from both UFCS and “Extension Methods”.
From UFCS we take the implementation part, in the form of a standard free function or function object.
From “Extension Methods” we take the opt-in semantics, but we move the opt-in syntax away from the free function itself into a separate declaration. By doing so, we solve the two major problems of “Extension Methods”: No easy way to have “hide” semantics (for both members and previous “Extension Methods”) and the need for wrapper functions for existing code.

By examining this new option, we will have every solution evaluated:

As you can see, there is a fixed number of possible ways to introduce method-like calls into the language. This paper tries to explore the 4th option, as it was never evaluated previously.

Proposed solution

The general case

Let’s start with an example. Given Ranges take free function / function object in the form of:

namespace views {
  template<viewable_range R>
  auto take(R&& r, range_difference_t<R> n); 
}

The user associates it to one or more concepts, compatible with the one, used to constrain the function first argument:

viewable_range using views::take; //< `viewable_range` concept, from now on, will have `views::take` as an associated method.

After this declaration (“associating declaration”), the user can call views::take on every class, which models viewable_range, using the class method call syntax. The associating declaration is a user-side opt-in.
The complete example would be:

#include <iostream>

#include <range/v3/numeric/accumulate.hpp>
#include <range/v3/view/take.hpp>

int main()
{
  viewable_range using views::take;   //< `viewable_range` concept has now `views::take` as an associated method.

  const int arr[] = {1,2,3,4,5};      //< array models `viewable_range`
  const auto range = arr.take(2);     //< array instances can, for the current scope, call `views::take` as a method

  std::cout << accumulate(range, 0);  //< prints "3" (0+1+2)
}

A more complex example:

#include <iostream>

#include <range/v3/numeric/accumulate.hpp>
#include <range/v3/view/iota.hpp>
#include <range/v3/view/take.hpp>
#include <range/v3/view/transform.hpp>

int main()
{
  viewable_range using views::take, transform;     //< `viewable_range` concept has now `views::take` and `views::transform` as associated methods.
  
  int sum = accumulate(views::ints(1, unreachable) //< `ints`, returns `iota_view<int>`, which models `viewable_range`
                       .transform([](int i) { return i * i;})
                       .take(10),
                       0);
  // prints: 385
  std::cout << sum << '\n';
}

Association hides members:

#include <iostream>
#include <range/v3/view/take.hpp>

#include <QMap>       //< has `T QMap::take(const Key&)`

auto func(const QMap<int, QString>& m)
{
  viewable_range using views::take;
  
  return m.take(2);   //< calls `views::take`, because of the associating declaration above, hiding `QMap::take`
}

int main()
{
  QMap<int, QString> m = {{1, "one"}, {2, "two"}, {3, "three"}};

  m.take(2);          //< calls `QMap::take` as always
  
  for(const auto& r : func(m))
   std::cout << r;
}

Because the user has control over where the association take place, he/she can control the scope in which the hiding take place:

#include <iostream>
#include <range/v3/view/take.hpp>

#include <QMap>     //< has `T QMap::take(const Key&)`

auto func(const QMap& m)
{
  viewable_range using views::take;         //< associate `views::take` for `func` scope
  
  return m.take(2);                         //< calls `views::take`
}

viewable_range using func;                  //< associate `::func` for global scope

void print_2(const QMap<int, QString>& m)
{
  for(const auto& r : m.func())             //< calls `::func`, because of global scope association
   std::cout << r;
}

int main()
{
  QMap<int, QString> m = {{1, "one"}, {2, "two"}, {3, "three"}};

  viewable_range using print_2;            //< associate `::print_2` for `main` scope 

  m.take(2);                               //< calls `QMap::take` as always

  m.print_2();                             //< calls `::print_2`
  (void) m.func();                         //< calls `::func`, because of global scope association
}

The scope of the associating declaration is the same as the scope of the using directive - class, function, namespace and module.
Each consequent declaration, for a given function + concept combination, hides previous ones. Multiple, comma-separated function names can be listed in one declaration, as long as they are from the same scope (namespace).

The concept in the declaration controls which types receive the association. In the above example, the way we have written the association for func is a bit too forgiving, allowing us to try to call it with, for example, a vector, not just with a QMap. We can limit the usage of func as a method, by associating with a more constrained concept.

#include <iostream>
#include <range/v3/view/take.hpp>

#include <QMap>     //< has `T QMap::take(const Key&)`

auto func(const QMap<int, QString>& m)
{
  viewable_range using views::take;         
  
  return m.take(2); //< calls `views::take`
}

template<class T>
concept QMapIntToQString_like = std::is_convertable<T, QMap<int, QString>>; 

QMapIntToQString_like using func;   //< *only* convertible to QMap<int, QString> classes have the method

int main()
{
  QMap<int, QString> m = {{1, "one"},{2, "two"},{3, "three"}};

  std::vector<int> v = {1,2,3};

  m.func();                         //< calls `::func` 
  v.func();                         //< error: 'class std::vector<int>' has no member, or associated method, named 'func'
}

Note, that this is not strictly necessary. Previously, the call to func would have simply given the same error as if func(v) was called.
Having addition control however, allows us to exclude types or set of types, we don’t want associations available for. This is particularly useful if we don’t want to hide members, inside a class implementation (see discussion), or we want to implement “fallback” instead of “hide” semantics, as we can use a concept that associates only if the class doesn’t already has a member with that name. Other then excluding types, we can also use a custom concept to associate methods to multiple types with one declaration, assuming the functions already have overloads for each type.

We saw how the user takes advantage of the functionality, now let’s see how things are handled on the library side.
We’ll do that by implementing a generic version of and_then monadic operation, which currently is only available as a member to std::optional and its return is limited to an std::optional as well.

namespace std {

template<class T>
concept raw_pointer_like = requires(T t) { {*t} -> std::convertible_to<decltype(**std::addressof(t))>; };

template<class T>
concept std_pointer_like = requires(T t) { {*t} -> std::convertible_to<typename T::element_type>; };

template<class T>
concept std_optional_like = requires(T t) { {*t} -> std::convertible_to<typename T::value_type>; };

template<class T>
concept optional_like = raw_pointer_like<T> || std_pointer_like<T> || std_optional_like<T>;

template<class F, class O>
concept returning_optional_like = requires(F f, const O& o) { {std::invoke(std::forward<F>(f), *o)} -> optional_like; };

// --- the actual implementation ---

template <optional_like O, returning_optional_like<O> F>
auto and_then(const O& o, F&& f) 
{
  if (o) 
    return std::invoke(std::forward<F>(f), *o);
  else 
    return decltype(std::invoke(std::forward<F>(f), *o)){};
}

}//< ns std

The above is the entirety of the (quick-and-dirty) library-side implementation, note that most of the implementation is defining the concepts, which in general should be readily available as part of the Standard Library. The user consumes the and_then as follows:

#include <optional>
#include <QSharedPointer>

std::optional_like using std::and_then; //< association at global scope, as even if we hide, the semantics are the same

int main()
{
  int i = 1;
  int* pi = &i;

  auto res = pi.and_then([](int v){ return QSharedPointer<int>::create(++v); })
               .and_then([](int v){ return std::optional(++v); });

  static_assert(std::is_same_v<decltype(res), std::optional<int>>);
  assert(*res == 3);
}

As you can see, we have fully generic chaining - we started with a raw pointer, went through a QSharedPointer, and ended up with a std::optional. The call expressions order is the same as the order of execution, instead of the inside-out order we have with free functions. Most importantly however, the library implementation is unchanged - no special function decoration, no operator | overloading. If the associating declaration is removed and free function calls are used instead, the example will compile and run today!
What is more, in this particular example, if we are to add the Ranges operator | workaround, the code for it will be considerably more, and much more complex, compared to the code for the actual functionality, we are implementing! This means that, while and_then can be implemented by every programmer, especially assuming library-provided concepts, he/she might not go the extra step to add the workaround, making the feature:

This is not a great situation to be, and this is the status quo right now.

The case with “utility functions” like starts_with is similar, as well as the solution - the implementation is provided as a non-member, then the user is free to introduce method call access at any point and for any scope. The fact, that we think of some uses as “monadic” and others as “regular” is irrelevant. In both cases the preferred (or at least the completely acceptable) call style is method-like and that’s all it matters. Having a separate call syntax is of no real value, as we don’t need to differentiate b/w the two invocations.

At this point our solution covers 2 of the 3 problems, stated in the beginning. Let’s look into some details on how all this is going to work, before we continue with solving the last remaining issue - the ability to model a concept. The difference b/w the cases, described so far and the last one is that the latter needs to invoke associated methods from inside a template.

Lookup

Given the expression x.foo(y)

If <qualified>::foo(x, y) can’t be called with x, errors are reported as usual.

This scenario can happen, if the foo first argument requirements are more constrained then the associating declaration’s concept.

As already mentioned, associated methods hide class members, they also hide each other, meaning the last function association, for a given concept, hides previous ones:

namespace foo { void func(Integral auto); }
namespace bar { void func(SignedIntegral auto); }

SignedInteger using bar::func; 
SignedInteger using foo::func; 

2.foo();      // calls `foo::func(Integer auto)`, *not* best match

This is in contrast to free function calls, where all using directives are overloaded together, even across namespaces.

At any point in the future we can enable similar functionality by extending the associating declaration syntax. Current behavior is the conservative one - no overloading across namespaces.

Overloading inside a namespace proceeds as usual.

namespace foo { void func(Integral auto); 
                void func(SignedIntegral auto); }

SignedInteger using foo::func; 

2.func();     // calls `foo::func(SignedIntegral auto)`, best match

Also note that by the above rules, the concept itself hides previous declarations concepts:

namespace foo { void func(Integral auto); }
namespace bar { void func(SignedIntegral auto); }

SignedInteger using bar::func;
Integer using foo::func;

2.func();     // calls `func`, associated with `Integer`, *not* the previous, more constrained `SignedInteger`

These simple “first match” rules make it possible to have local reasoning even in the presence of multiple associating declarations. They also ensure stable code, as the last declaration is always respected. There is no way for changes to be introduced “behind user’s back”, by adding a new library function, be it a member or a free one, or by adding new associating declaration. This is different from free function calls today, where a new library function might overload existing user function, if there is a using namespace <lib> as well.

Calls inside a template

Using associated methods inside a template is an interesting case. This is because, the template author and the user (the one supplying the template arguments), both share authorship over the final function implementation, and “compete” for the templated arguments interface.

namespace lib {
template<class T>
concept any_type = true;

template<class T>
void print(T& t) { ... }

any_type using print;   //< associate for our `lib` namespace

template<class T>
void func(T& t)
{
  t.print();
}

} //< ns lib

In the above example we associate print to any type. The question is - should calling print inside func hide a t class member?
If t was not a templated argument, but a concrete type like std::string, the answer is yes - our namespace-wide association hides any class members. However, in that case, std::string is a preexisting type, the library author is the user of that class and is free to extend it as he/she sees fit, using associations, subclassing and what not.
This is not the case with a template argument. The template argument is not preexisting when the function is defined. The user is yet to supply the type, finishing the function implementation himself. In that case, the end user will be quite surprised, his/her print was not called! Remember, the user will be able to see func implementation and its requirements, but will almost certainly will not be able to see the associating declaration, placed somewhere in our library, that ultimately hides the call.
How can we solve this issue without giving up on associated methods inside a template?
There seems to be two options - one simple and one advanced.

A: The simple solution

Limit lookup inside template to the scope of the function. In order for the library author to use associated methods, he/she has to declare them inside the function.

namespace lib {
template<class T>
concept any_type = true;

template<class T>
void print(T& t) { ... }

template<class T>
void func(T& t)
{
  any_type using print;

  t.print();   //< always calls lib::print   (because of local hiding)
}

} //< ns lib

This way, the user will be aware, there is an explicit override and his/her method will not be called.

B: The elaborate solution

Thus far our template arguments were unconstrained. It is reasonable (and recommended) to have function requirements, which effectively declare the templated type interface.

namespace lib {
...

template<class T> requires requires(T t){ t.foo(); };
void func(T& t)
{
  t.foo();
}

} //< ns lib

Given the above definition of func, it makes sense to have methods, defined as part of the templated argument interface (foo in this case), to not be hidden by associating declarations. After all, why declare these as a requirement and hide them afterwards? Makes no sense.
The opposite is true as well - methods, not part of the interface, like print, can be hidden, because even if they are present on the object, they are considered unrelated/unneeded to the implementation (because they are not required). The user has little to no right to assume, his/her methods should be called in that case - they become an implementation detail of the function.
Let’s see a more detailed example:

namespace lib {
template<class T>
concept any_type = true;

template<class T>
void print(T& t) { ... }
template<class T>
void foo(T& t) { ... }
template<class T>
void bar(T& t) { ... }

any_type using print, foo;

template<class T> requires requires(T t){ t.foo(); t.bar(); };
void func(T& t)
{
  any_type using bar;

  t.print(); //< always calls lib::print (because of namespace-level hiding + no requirement on T)
  t.foo();   //< always calls T::foo     (because of requirement on T)
  t.bar();   //< always calls lib::bar   (because of local hiding, though hiding required method makes little sense)
}

} //< ns lib

Note that in all cases, the code does not change if the object acquires a member! The library code can never change from user actions, outside the predefined agreement in the form of function requirements. If T does not have foo there is compilation error, as usual, not a fallback to lib::foo.

This more complex solution gives complete freedom and stability for the library authors to use associated methods on templated arguments as if these were concrete types. If a fallback is desired, it can trivially be implemented by having the associating concept filter out classes, that already have the method (thus not enabling association for that type).

It might seem, that templates are a lot of trouble and probably other solutions, like UFCS and Extension Methods, will work better in this situation. The truth is, the other solutions are mainly concerned with “the general case”, and for the template code they assume s.func() is always a member call.
Overall, other solutions do not envision the library author himself might want to use member calls to library functions on templated arguments. This is a limitation, because templated arguments are placed at a disadvantage, compered to regular ones. If the library still decides to make a member call to a library free function, because the templated type members have priority, the library code will change, depending on user choice of concrete types. All these issues are addressed and solved by the current proposal, granted with added complexity. What is more, because the calls to associated methods are always fully qualified, calling library free functions this way is safer even then the free function invocations, we have today.

Modeling a concept

Finally, we take a look into the 3th problem, we set ourselves to solve in the beginning - not being possible for a type to model a concept, defined in terms of member functions, without introducing a new type.
In code our problem looks like this:

template<class T>
concept stringEx_like = requires(const T& s) { {s.split()} -> ...  }; //< "extended string" concept, which has additional members

template<stringEx_like S>
void func(const S& s)     //< function, using an "extended string"
{
  s.split();
}

QString qs = "hello";
func(qs);                 //< succeeds, `QString` has `split` 

std::string s = "world";
func(s);                  //< fails, `std::string` has no member called `split`

We want the above code to work for std::string as well, without converting one string to another (QString::fromStdString) or inventing another string type (wrapping/subclassing std::string).
At this point, the user is able to call split on a std::string (and alike), by providing split implementation via association:

template<class T>
concept std_string_like = std::convertible_to<T, std::string_view> || std::convertible_to<T, std::string>;  

auto split(const std::string& s) { ... }       //< implementation, returning list of strings
auto split(const std::string_view& s) { ... }  //< implementation, returning list of strings_view

std_string_like using split;

std::string s = "hello";
std::string_view sv = "world";

for(const auto& s : s.split())
  std::cout << s << ',';

for(const auto& s : sv.split())
  std::cout << s << ',';

If the user can call split directly as member, he/she will expect a function template to be able to do so as well - func from the previous example must work. The question is how.
Let’s first make an important observation. After we have a std_string_like using split declaration, the compiler has all the information needed to make the call, and the key word here is “after”, before it, the call to func can not be done:

...

void some_func(const std::string& s)
{
  func(s)                              //< *fails*
  std_string_like using split;
  func(s)                              //< should work
}

This is quite different from how types currently work, or how concept_map worked5, as the ability to model a concept can be turned “on” and “off” at any point and it is not universally available for a type. Not only that, but how the concept is modeled can change as well:

...
concept std_string_like = ...;  

namespace my {
  auto split(const std::string& s) { ... }    
}  
namespace other {
  auto split(const std::string& s) { ... }    
} 

void some_func(const std::string& s)
{
  func(s);                            //< *fails*
  std_string_like using my::split;
  func(s);                            //< should work, internally calling `my::split`
  std_string_like using other::split;
  func(s);                            //< should work, internally calling `other::split`
}

Ultimately, func must have different implementations at different points, before and after each associating declaration.
To achieve this, it seems natural to simply instantiate foo with a different type for every association. As said, the compiler has all the prerequisites to make the calls, required by the concept - s.split() is a valid expression and testing func requirements succeeds. The compiler just needs a way to “pass” these methods “into” func itself. Because func does not care what is the concrete type of the stringEx_like argument, any type will do, including a compiler-invented one.

To feed-in a new type, the compiler has broadly two options.

A: Tag type

One option is to create “tag” type, which will instantiate func with the correct function calls.

template<class T>
concept stringEx_like = requires(const T& s) { {s.split()} -> ...  };

template<stringEx_like S>
void func(const S& s) 
{
  s.split();
}

concept std_string_like = ...;  

namespace my {
  auto split(const std::string& s) { ... }    
}  
namespace other {
  auto split(const std::string& s) { ... }    
} 

void some_func(const std::string& s)
{
  func(s);                            //< *fails*
  std_string_like using my::split;

  template<std_string_like T>
  struct __tag1_std_string_like : T {}; 

  func(s);                            //< called as-if `func(static_cast<__tag1_std_string_like<std::string>&>(s))`
  
  std_string_like using other::split;

  template<std_string_like T>
  struct __tag2_std_string_like : T {};

  func(s);                            //< called as-if `func(static_cast<__tag2_std_string_like<std::string>&>(s))`
}

The actual instantiations of foo are as follows:

template<>
void func<__tag1_std_string_like<std::string>>(const __tag1_std_string_like<std::string>& s) 
{
  my::split(s);    //< `s` implicitly converted to `std::string`
}
template<>
void func<__tag2_std_string_like<std::string>>(const __tag2_std_string_like<std::string>& s) 
{
  other::split(s);  //< `s` implicitly converted to `std::string`
}

The above is compiler-transformation of the original func body to use the associated free functions directly. Notice that the tag type is used only to force the compiler to create the required instantiation of func and instances of that type are never used as such.

B: Redirecting type

An alternative is to have a concrete type, implemented by the compiler.

template<class T>
concept stringEx_like = requires(const T& s) { {s.split()} -> ...  };

template<stringEx_like S>
void func(const S& s) 
{
  s.split();
}

concept std_string_like = ...;  

namespace my {
  auto split(const std::string& s) { ... }    
}  
namespace other {
  auto split(const std::string& s) { ... }    
} 

void some_func(const std::string& s)
{
  func(s);                            //< *fails*
  std_string_like using my::split;

  template<std_string_like T>
  struct __rdrct1_std_string_like : T { auto split() const { return my::split(*this); } }; 

  func(s);                            //< called as `func(static_cast<__rdrct1_std_string_like<std::string>&>(s))`
  
  std_string_like using other::split;

  template<std_string_like T>
  struct __rdrct2_std_string_like : T { auto split() const { return other::split(*this); };

  func(s);                            //< called as `func(static_cast<__rdrct2_std_string_like<std::string>&>(s))`
}

One advantage of this second option is that the func implementation does not need to be transformed - it is exactly the same as if the class was user-supplied and natively supported split. The disadvantage is the potential code-bloat for the redirect member functions.

Note, that both options are just sketches, better solutions might be possible.

Other then how the associated functions are called, both solutions have a lot in common.
For example, in both cases, inside the function template instantiation, the calls are no longer associated methods calls - they are either regular free function calls or regular member calls.
More importantly, in both cases, it is impossible to have explicit instantiations. The call to func succeeds only because the compiler, after checking func requirements, steps-in and creates the concrete type. This type is hidden, probably even implementation defined, and the user can not directly supply it.

...
void some_func(const std::string& s)
{
  func(s);                            //< *fails*, `std::string` has no member named `split`
  std_string_like using my::split;
  func<std::string>(s);               //< *fails*, `std::string` has no member named `split` 
  func(s);                            //< succeeds, type is compiler-invented
}

The reason for this limitation is already hinted - there is no one single instantiation of func-for-std::string, but potentially many. We can’t “reuse” the func<std::string> specialization and make it suddenly valid.

With both options, the created type can be reused for all calls with the same requirements.

...

std_string_like using my::split;

void some_func(const std::string& s)
{
  func(s);                            //< succeeds, type is compiler-invented
}
void some_other_func(const std::string& s)
{
  func(s);                            //< succeeds, compiler-type reused
}

The reuse is possible, because the associating declarations + the base types create a closed set of all concrete types that can be generated, independent of the number of concepts, that need to be modeled. In fact, the compiler is even free to create one type, which serves many unrelated concepts:

template<class T>
concept stringEx_like = requires(const T& s) { {s.split()} -> ...  };

template<stringEx_like S>
void func(const S& s) 
{
  s.split();
}

template<class T>
concept printable = requires(const T& s) { s.print(); };

template<printable S>
void func2(const S& s) 
{
  s.print();
}

namespace my {
  auto split(const std::string& s) { ... }    
}  
namespace other {
  void print(const std::string& s) { ... }    
} 

std_string_like using my::split;
std_string_like using other::print;

void some_func(const std::string& s)
{
  func(s);       //< succeeds, the compiler is free to use _one_ invented type for both calls
  func2(s);      //<
}

This all goes back to the fact, a function template does not care about the actual type, as long as the requirements are met. The user does not care either, else he/she would have created a concrete type. Because of this, the compiler is free to “bridge the gap” and make the call possible, with whatever type it sees fit. The type itself is not important, only the actual function calls, and they are guaranteed to not change as long as the requirements of the functions don’t change.

Modeling a concept can happen inside a template as well.

template<class T>
concept stringEx_like = requires(const T& s) { {s.split()} -> ...  };

template<class T>
concept printable = requires(const T& s) { s.print(); };

template<printable S>
void func2(const S& s) 
{
  s.print();
}

namespace other {
  void print(const std::string& s) { ... }    
} 

std_string_like using other::print;

template<stringEx_like S> requires printable<S>
void some_func(const S& s)
{
  func2(s);      //< succeeds, the compiler will (always) use S::print to model printable
}
template<stringEx_like S>
void some_func(const S& s)
{
  func2(s);      //< succeeds, the compiler will invent a type and (always) use other::print to model printable
}

(The above example assumes “the elaborate solution” to enable calls inside a template, else the association must be inside some_func)

With modeling a concept covered, we have a solution for all problems, defined in the beginning.

Conclusion

Presented here was a model, which could enable scenarios, previously not possible.
Code, currently required to be done in its original library, can now be done by the users themselves - we don’t need to spend committee time on starts_with and alike. The desire to “extend existing classes” goes well back (15+ years!), but while before this desire was limited to a single class, now, because of concepts, we recognize the power of “extending” a set of types. This changes the picture significantly. And not just user-side extensions.
Writing the Standard Library itself in a completely generic way has always been of paramount importance, if not a core design feature. Yet, we still add methods and entire sub-libraries, which only work on std types alone, and trying to do otherwise requires non-trivial workarounds. This should not be the case - the Standard Library should, as much as possible, be just as as useful with user types as it is with its own types, without requiring a library of its own to accomplish that.
Lastly, being able to model a concept, defined in terms of methods, frees library developers from worrying, such requirements are “a burden” or a limitation. This allows them to develop against the interface, they feel is natural to the problem - image.width() should be just that - without the round-trips we need today in order to support “modeling for existing classes”.

Examples

Ranges

Library code6 before:

namespace detail {

template <typename>
inline constexpr bool is_raco = false;

template <typename R, typename C,
          typename = std::enable_if_t<...>>
constexpr auto operator|(R&& lhs, C&& rhs)
    -> decltype(std::forward<C>(rhs)(std::forward<R>(lhs)))
{
    return std::forward<C>(rhs)(std::forward<R>(lhs));
}

template <typename LHS, typename RHS>
struct raco_pipe {
private:
    LHS lhs_;
    RHS rhs_;

public:
    constexpr raco_pipe(LHS&& lhs, RHS&& rhs)
        : lhs_(std::move(lhs)),
          rhs_(std::move(rhs))
    {}

    // FIXME: Do I need to do ref-qualified overloads of these too?

    template <typename R, std::enable_if_t<viewable_range<R>, int> = 0>
    constexpr auto operator()(R&& r)
        -> decltype(rhs_(lhs_(std::forward<R>(r))))
    {
        return rhs_(lhs_(std::forward<R>(r)));
    }


    template <typename R, std::enable_if_t<viewable_range<R>, int> = 0>
    constexpr auto operator()(R&& r) const
        -> decltype(rhs_(lhs_(std::forward<R>(r))))
    {
        return rhs_(lhs_(std::forward<R>(r)));
    }
};

template <typename LHS, typename RHS>
inline constexpr bool is_raco<raco_pipe<LHS, RHS>> = true;

template <typename LHS, typename RHS>
constexpr auto operator|(LHS&& lhs, RHS&& rhs)
    -> std::enable_if_t<...>
{
    return raco_pipe<LHS, RHS>{std::forward<LHS>(lhs), std::forward<RHS>(rhs)};
}

template <typename Lambda>
struct rao_proxy : Lambda {
    constexpr explicit rao_proxy(Lambda&& l) : Lambda(std::move(l)) {}
};

template <typename L>
inline constexpr bool is_raco<rao_proxy<L>> = true;

} // namespace detail

Must be enabled on the view as well:

...    
template <typename C>
constexpr auto operator()(C c) const
{
  return detail::rao_proxy{[c = std::move(c)](auto&& r) mutable
    -> decltype(...)
  {
    return take_view{std::forward<decltype(r)>(r), std::move(c)};
  }};
}

Library code after:

(none)

User code before

#include <iostream>
#include <range/v3/view/take.hpp>

int main()
{


  const int arr[] = {1,2,3,4,5};     
  const auto range = arr | take(2);  
  // `take_view` created via proxy, via overload on the view
}

User code after

#include <iostream>
#include <range/v3/view/take.hpp>

int main()
{
  viewable_range using views::take; 

  const int arr[] = {1,2,3,4,5};    
  const auto range = arr.take(2);   
  // `take_view` created via direct call to `take`
}

Extension points as members

Ranges have “smart” extension points functions, which always call the correct function, even if fully qualified.
We can use these as methods also.

#include <ranges>

template<viewable_range C>
void func(const C& c)
{
  ranges::viewable_range using ranges::begin;

  c.begin(); //< calls _either_ member `c.begin()` or `begin(c)` or `std::begin(c)`
}

Executors7 will have similar extension points in the form of set_value, set_done, set_error.

#include <execution>

template<execution::receiver R>
void func(const R& r)
{
  execution::receiver using execution::set_value,set_done,set_error;

  r.set_value(v); //< all these will find the correct function, similar to `ranges::begin` above
  r.set_done();   //<
  r.set_error();  //<
}

As you can see, the syntax lands itself much better as a member, then a free function.

Optional and_then

Oversimplification warning!
Library code

namespace std {

template<class T>
concept raw_pointer_like = requires(T t) { {*t} -> std::convertible_to<decltype(**std::addressof(t))>; };

template<class T>
concept std_pointer_like = requires(T t) { {*t} -> std::convertible_to<typename T::element_type>; };

template<class T>
concept std_optional_like = requires(T t) { {*t} -> std::convertible_to<typename T::value_type>; };

}//< ns std

Before

namespace std {

template <class T>
template <class F> requires 
  requires(F&& f, const optional<T>& o) 
    { {f(*o)} -> std_optional_like; }
auto optional<T>::and_then(F&& f)
{ 
  if (*this) 
    return f(**this);
  else 
    return decltype(f(**this)){};
}
}//< ns std

After

namespace std {


template <optional_like O, class F> requires 
  requires(F&& f, const O& o) 
    { {f(*o)} -> optional_like; }
auto and_then(const O&, F&& f)
{ 
  if (o) 
    return f(*o);
  else 
    return decltype(f(*o)){};
}
}//< ns std

User code

#include <optional>
#include <QSharedPointer>



int main()
{
  int i = 1;
  int* pi = &i;

//  auto res = pi.and_then([](int v){ return QSharedPointer<int>::create(++v); })
//               .and_then([](int v){ return std::optional(++v); });
 
  // *fails*, neither the raw pointer, nor QSharedPointer are std::optional
}
#include <optional>
#include <QSharedPointer>

std::optional_like using std::and_then; 

int main()
{
  int i = 1;
  int* pi = &i;

  auto res = pi.and_then([](int v){ return QSharedPointer<int>::create(++v); })
               .and_then([](int v){ return std::optional(++v); });

  // succeeds, raw pointer and QSharedPointer are optional-like
}

Generic starts_with

Oversimplification warning!
Library code

namespace std {

template <class T>
auto basic_string<T>::starts_with(T v) const
{ 
  return ! empty() && *cbegin() == v;
}
template <class T>
auto basic_string_view<T>::starts_with(T v) const 
{ 
  return ! empty() && *cbegin() == v;
}
}//< ns std
namespace std {

template<class C, class V> requires 
  requires(const C& c, const V& v) 
  {  
    {*ranges::cbegin(c) == v} -> std::convertible_to<bool>;
  }
bool starts_with(const C& c, const V& v)
{ 
  return ranges::cbegin(c) != ranges::cend(c) 
         && *ranges::cbegin(c) == v;
}
}//< ns std

User code

#include <string>
#include <QString>
#include <QMap>

int main()
{


  std::string s = "hello";
  std::string_view sv = "hello";
  const char cs[] = "hello";
  QString qs = "hello";
  QMap<int, QChar> qm = {{0, 'h'}, {1, 'e'}, {2, 'l'}, {3, 'l'}};

  s.starts_with('h');     // succeeds
  sv.starts_with('h');    // succeeds
//  cs.starts_with('h');  // *fails*
//  qs.starts_with('h');  // *fails*
//  qm.starts_with('h');  // *fails*
}
#include <string>
#include <QString>
#include <QMap>

int main()
{
  ranges::viewable_range using std::starts_with;

  std::string s = "hello";
  std::string_view sv = "hello";
  const char cs[] = "hello";
  QString qs = "hello";
  QMap<int, QChar> qm = {{0, 'h'}, {1, 'e'}, {2, 'l'}, {3, 'l'}};

  s.starts_with('h');     // succeeds
  sv.starts_with('h');    // succeeds
  cs.starts_with('h');    // succeeds
  qs.starts_with('h');    // succeeds
  qm.starts_with('h');    // succeeds
}

Modeling a concept

namespace lib {
template<class T>
concept image_like = requires(const I& img) 
{
  {img.width()} -> std::convertible_to<size_t>;
  {img.height()} -> std::convertible_to<size_t>;
  {img.pitch()} -> std::convertible_to<size_t>;
  ...
};

template<image_like I>
void mirror(I& img) {...} 
}

User code

#include <QImage>
#include "FreeImage.h"
#include "lib.h"

auto pitch(const QImage& img) { return img.bytesPerLine(); }

auto width(const FIBITMAP& img) { return FreeImage_GetWidth(const_cast<FIBITMAP*>(&img)); }
auto height(const FIBITMAP& img) { return FreeImage_GetHeight(const_cast<FIBITMAP*>(&img)); }
auto pitch(const FIBITMAP& img) { return FreeImage_GetPitch(const_cast<FIBITMAP*>(&img)); }

template<class T>
concept my_image_types = std::same_as<T, FIBITMAP> || std::convertible_to<QImage>;

my_image_types using width, height, pitch; 

int main()
{
  const auto* dib = FreeImage_Load(...);
  const auto qimg = QImage::load(...);

  lib::mirror(*dib);    //< calling `lib::mirror`, using compiler-invented type for `FIBITMAP`
  lib::mirror(qimg);    //< calling `lib::mirror`, using compiler-invented type for `QImage`
  
  my_image_types using lib::mirror; 

  dib->mirror();        //< calling `lib::mirror`, using compiler-invented type for `FIBITMAP`
  qimg.mirror();        //< calling `lib::mirror`, using compiler-invented type for `QImage`
}

Notice, we used just one associating declaration for both image types. This is another plus of having control over which classes receive the methods.

Q&A

Q: Do associated methods also hide class member variables?

Yes, the rules are the same as with subclassing.

Q: If associated method hides a class member, can that member still be called?

Yes, via a qualified call, the same as with subclassing.

Q: Is the call performed using ADL (Argument Dependent Lookup)?

No, the call is made with the fully qualified name from the association declaration.

Q: Is there a using namespace option?

No. using namespace is inherently unsafe, especially with associating methods, which have hide semantics. This would mean, introducing a new function in a library namespace to be able to hide class member function in user code.

Q: Can I shoot myself in the foot?

Yes, by using associating declaration in headers and not being careful about order of inclusion. Luckily, we move away from headers.

Q: How do associated methods work with raw pointers?

If an associated method is called, using ., on a raw pointer, and there is a pointer overload for the function, it will be called.
If an associated method is called, using ->, on a raw pointer, and there is a reference overload for the function, it will be called.

void f(const Integral auto*);

void f(const SignedIntegral auto&);
void f(const UnsignedIntegral auto&);

void func(int* pi, unsigned* pu)
{
  Integral using f;

  pi.f();     //< calls void f(const Integral auto*);
  f(pi);      //<

  pi->f();    //< calls void f(const SignedIntegral auto&);
  (*pi).f();  //<
  f(*pi);     //< 
 
  pu.f();     //< calls void f(const Integral auto*);
  f(pu);      //<

  pu->f();    //< calls void f(const UnsignedIntegral auto&);
  (*pu).f();  //<
  f(*pu);     //< 
}

Note that, although int is a different type from int*, and the latter is not an Integral, the concept association still works, making it possible to call methods on either a reference or pointer, depending on the available overloads. This is done purely for convenience and is a special rule for raw pointers only as they don’t have members that might be hidden. In other words, if pointer. does not find associations specifically for a pointer type, it will try associations for the type the pointer points to.

As said, this is purely for convenience and is not mandatory in any way. This saves the user from inventing a new concept, just to accommodate pointer overloads: template<class T> concept Integral_or_PIntegral = Integral || requires(T t){{*t}->Integral;};
Integral_or_PIntegral using f;

An interesting side-effect of having a pointer overload is that we can have safe member call chaining on a pointer.

int* a(const int* p) { if(!p) return {}; ... }
int* b(const int* p) { if(!p) return {}; ... }
int* c(const int* p) { if(!p) return {}; ... }

Integral using a,b,c;

int* p = {};
p.a().b().c();     //< safe!

Q: Does the this pointer invoke associated methods?

Open Question. Consider:

namespace lib { 
  bool contains(const string_like auto&); 
}

class MyString
{
  string_like using lib::contains;

  bool contains() const;

  void something() const
  {
    contains();                  //< calls ?
    this->contains();            //< calls ?

    (*this).contains();          //< calls ?
    contains(*this);             //< calls lib::contains;

    lib::contains();             //< ?
    this->lib::contains();       //< calls lib::contains; 
 
    MyString::contains();        //< calls MyString::contains
    this->MyString::contains();  //< calls MyString::contains 

    const auto* self = this;
    self->contains();            //< calls lib::contains; 
  }
};

We have two options.

References


  1. std::optional “monadic” interface: http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2019/p0798r3.html↩︎

  2. UFCS: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2004/n1585.pdf,
    http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4474.pdf and others.↩︎

  3. “Extension Methods”: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/p0079r0.pdf.↩︎

  4. “Language Pipe”: Eliminating the Static Overhead of Ranges,
    P1282R0 workflow operator.↩︎

  5. Linguistic Support for Generic Programming in C++: http://www.stroustrup.com/oopsla06.pdf↩︎

  6. Example from NanoRange as it has one of the simplest and cleanest implementation of the operator | machinery.↩︎

  7. A Unified Executors Proposal for C++: http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2019/p0443r11.html↩︎