The class concept extension point

Document #: xxx
Date: 2023-06-23
Project: Programming Language C++
SG17
Reply-to: Mihail Naydenov
<>

1 Abstract

This paper proposes a new entity, called class concept, which is equal parts a class and a concept. It will act as both an interface and an implementation for an extension point mechanism.

2 Problem

The current state of generic code has been recently explored in both “We need a language mechanism for customization points” p22791 and “Generic Programming is just Programming” p26922. This paper will not reiterate all the points for why we need a significant improvement in the way generic code is written and consumed.

3 Motivation

Our dissatisfaction with current extension points can be resolved broadly in two ways.

  • Improve what we have.
  • Introduce a new abstraction.

The first approach is taken by all currently active proposals3,4,5. Overall, that might be the right way to go in most situations, however, this paper argues, improving what we have is both insufficient and “unattractive” for the regular developer. Why it is insufficient is explored in p2279. Basically, a mere improvement can not solve the plethora of issues. Why it is “unattractive” is touched upon in p2692. Essentially, fixing what we have is making mostly the experts life easier - if someone is not using extension points, or is not using template-based generic programming altogether, he/she will not start doing so after the extension points are improved.

These two points are the motivation of this paper, which explores the second approach instead. We should fix most if not all pain-points we have, else we limit the amount of value we deliver, and if we don’t make template-based generic programming with extension points attractive to regular developers, they will simply ignore this option.

The core argument of this paper is, both of those can not be solved by an incremental improvement.

For example, say, we fix extension points as we think about them today - “free functions” that the user can override. What we would fix next to get another incremental improvement, one which is also “a natural evolution”? At some point, assuming we want to continue fixing all issues, we have to introduce an “overarching” abstraction, which combines all improvements, else we end up with very fragmented experience. On the user side, why the user should invest time learning about those “special free functions”, when what he/she already knows (abstract base class and simple templates) works and to his/her point of view is “easier and natural”? (It is “easier and natural” because both simple templates and to an extend ABC keep the original code intact to a great degree.) So, if users ignore our new customization points “in the beginning”, at what point, at what improvement level, they will become interested?

Taking the path of introducing a new overarching abstraction from the get go is extremely expensive, both design and implementation, but we have a chance to create a better proposition.

What is attractive?

This paper considers “attractive” a combination of 3 things, working in tandem:

  • familiarity
  • easiness
  • power

The success of current generic programming paradigms (templates and ABC), relies on those 3 being quite high, or at least high-enough. Abstract Base Class (ABC) has its familiarity in being a class, an extremely popular abstraction in itself, and combined with its relative easiness in implementation, has become the dominate paradigm when it comes to generic programming in general. Simple templates on the other hand might have their limitations, but this is compensated by the fact an algorithm can be left intact, just types replaced by template arguments (extreme easiness). More advanced options start to diverge however. For example class specialization of type traits is easy, gives additional power, but is by no means familiar (arguably an abuse of the original idea of specialization). Even more advanced techniques sacrifice both familiarity and easiness for even more power, lessening the attractiveness even further. This means, an attractive proposition is one that keeps the power high, does not lose familiarity and is also easy.

4 Proposal

4.1 Outline

To achieve the goals, stated in the previous section, we start with what we know - the class. This will be the base of our new abstraction, much like it was before for the Abstract Base Class. This way we are grounded in familiarity. From there, we up the ante by saying, this new class will abstract not just functions, but all static members as well - types, constants, functions. In other words all entities, that are type- and not instance-bound. Doing so we push for more power compared to both simple template (no abstraction, it’s just the type as it is) and ABC (just non-static functions). We can afford to make such a promise, because this class will be used statically and in that context type-bound items can and do change. This special class is then used as a stand-in for any concrete type, abstracting away all of the class entities, similarly how the ABC is used in place of concrete classes, abstracting their methods. To achieve maximum easiness, the stand-in usage is implicit inside a template, making it a transparent customization point - the algorithm author writes as-if this was some concrete class with all of its members (except for non-static data members).

4.2 The class concept

As mentioned in the outline, we begin with the class:

class Shape {
public:
  ...
};

Now, much like with a virtual class, we add a single keyword to redefine what this class means:

class concept Shape {
public:
  ...
};

Shape is now a class concept. All its type-level members, as well as instance-level member functions can be statically overridden. For example:

class concept Shape {
public:
  using coords_t = int;                                     //< type member with DEFAULT value. Can be overridden
  static bool is_intersection(const Shape&, const Shape&) { //< static function w/ DEFAULT implementation. Can be overridden
    ...
  }
  coords_t  width() const;                                  //< member function w/o DEFAULT. An implementation MUST be provided.
  coords_t  height() const;                                 //< member function w/o DEFAULT. An implementation MUST be provided.
  int area() const { return width() * height(); }           //< member function w/ DEFAULT implementation. Can be overridden
  ...
};

The class concept is not just a class, it is also a concept, representing the operations, available on a type.

template<Shape T>  //< function is constrained by the Shape concept
void useShape(T sh) {  
  T::coords_t;             //< use type member
  T::is_intersection(...)  //< use static function
  sh.width();              //< use member function
}

When used as a concept, we can think about the class definition as a signature-based constrain definition, in contrast to expression-based constrain definitions we have in C++20:

concept in C++20

template<class T>
concept Shape = requires(const T& t, const T& o) {
  typename T::coords_t;
  { T::is_intersection(t, o) } -> std::same_as<bool>;
  { t.width() } -> std::same_as<typename T::coords_t>;
  ...
};

concept class

class concept Shape {
public:
  using coords_t = int;                        
  static bool is_intersection(const Shape&, const Shape&);
  coords_t  width() const;   
  ...                   
};

These two however are not equivalent, just similar. In particular, standard concepts are lists of requirements for the instantiated type to fulfill in order to be a match. They must be implemented directly by the type. class concept on the other hand does not have any direct requirements. Instead, either directly (in-class) or indirectly (externally), the type must explicitly conform to the class concept, to opt-in for it. After that we again have a list of requirement, albeit with a different syntax, but the list this time:

  • may be implemented by the type directly, or
  • may be implemented indirectly (externally), or
  • may be implemented by the class concept itself (default).

In a way the class concept, or more specifically its implementation, stands b/w the algorithm template code and the instantiated type, providing both requirements and possibly fulfilling them.

The Abstract Base Class analogy can again be of help. An ABC is both an interface and an implementation (if not pure) and it stands b/w the code (function calls) and the type implementing the interface. This is done quite literally as the vtable is physically called first and does the dispatch.

Once a type conforms to a class concepts, the concept completely takes over the names, that it defines. This means, the type can not provide a “better” overload. This is different from normal concepts:

concept in C++20

template<class T>
concept A = requires(const T& t, bool val) {
  { t.f(val) };
};

class A_t {
public:
  void f(int) const {}   
};
  ...
void func(A auto a) {
  a.f(true); //< We require bool, pass bool, yet int overload is called!
}

concept class


class concept A {
   void f(bool); 
};

class A_t {
public:
  void f(int) const {}           
};
  ...
void func(A auto a) {
  a.f(true); //< FAILS: We require bool, A_t:f param is an int
}

The above is required in order for concept class to be an extension point. Otherwise one might get incidental matches. This is true even with an explicit opt-in, because hijacking (better mach overload) can happen in a class which already has fulfilled the requirements “sloppily”. This way the algorithm developer using a concept class can be sure, what he/she stated as a requirement is guaranteed to be what is finally called.

Note, the same does not apply to names not defined by the concept class:

  ...
void func(A auto a) {
  a.f(true);          //< FAILS: We require bool, A_t:f param is int
  a.something_else(); //< OK, A does not care 
}

Last but not least, a concept class can be combined with standard concepts in order to take advantage of the rich facilities, already available:

template<class T>
concept ShapeValue = Shape && std::regular<T>; //< define trivial shape

template<class T>
concept ShapeRef = Shape && std::movableT>;  //< define some non-trivial shape

template<class T>
concept ShapeF = Shape && std::floating_point<Shape::coords_t>; //< require floating point coordinates

As you can see, concept class-s by no means replace standard concepts, they complement them by adding explicit opt-in support, having nominal instead of not structural requirements, and by making writing class interface requirements easier, with greater familiarity to regular code and other popular abstractions like ABC.

The bulk of his proposal is not the requirements however, but the way we write implementations for those. Back to our Shape constraint, once defined, in order to fulfill it, the type must explicitly say so.

This can be done directly inside the class via subclassing:

class concept Shape {
public:
  using coords_t = int;                        
  static bool is_intersection(const Shape&, const Shape&) { 
    /*bounding box intersection checking*/
  }
};

class Circle : Shape {
  ...
  coords_t diameter() const;

  auto width() const { this->diameter(); }
  auto height() const { this->diameter(); }

  static bool is_intersection(const Circle& a, const Circle& b) { 
    if(Shape::is_intersection(a,b)) {
      return /*more precise and expensive circle-circle intersection checking*/
    }
    return false;
  }
};

template<Shape T>
void useShape(T sh) { 
  ...
  Shape::is_interesection(sh, otherShape()); 
}

Lets unpack what is happening. After we define the Shape concept, via a simple subclassing we not only commit to fulfill its requirements, but also take advantage of the already available implementations inside of it. In particular, we reuse the coords_t type, in the form already defined, and we also call the default implementation of is_intersection as an optimization strategy. However, inside of useShape, although we syntactically call the base implementation again, the Circle implementation will be called!

This behavior might look inconsistent, but we should again look at Abstract Base Class for reference:

void useA(A& a) {
  a.f();
}

In useA we think that we call A::f and there is not a single indication, this might not be the case! If we get a non-A::f behavior we will be confused! It is only after we learn about virtual functions, consult the definition of A, it happens to be an abstract class, then we start to get the full picture.

This paper suggest that the relationship b/w A& a = ...; a.f() not being a call to A::f iff dynamic dispatch is involved is no different then Shape::is_interesection() not being a call to Shape::is_interesection iff static dispatch is involved (via the aforementioned new abstraction). In both cases the code lies, and a consultation to the definition is needed.

In both cases also the difference b/w regular code and “special” code is a single keyword.

Interestingly, not just ABC, but current extension points also lie! For decades, a call to std::something meant, std::something, literally, will be called. Now this is no longer the case, because if something is an “extension point”, it might get overridden, another function might be called even though we use an absolute path to it. It is only when we see the implementation of something, we understand, this is no ordinary call.

Of course, we can track how the code is made, there is no hidden magic, but this is nothing more then implementation detail leakage. It is as if we are able step into the vtable of an ABC.

Both of the above points give us at least some reassurance, code not calling exactly the functions it says it will is not unexpected once we aware the said function is special.

Now, regarding having different calls in different contexts - in our case Shape::is_intersection resulting in different calls inside the implementation and in general use. This kind of inconsistency is less common in the existing language, but not unheard of. We for example have the situation where a call to a virtual function inside a base class is not a virtual call. In that case it is a technical necessity, here it is actually a practical one - we need to be able to call the base implementation. To that end, inside an implementation, the “magic” is turn off and regular subclassing rules apply - any call is a direct call w/o a special lookup. Otherwise we have to invent new syntax without any benefit.

There is more to say about calling contexts, but first we must cover the other way a type can fulfill a class context requirements.

External implementation

Instead of subclassing, a type can instead provide an external implementation.

class Circle {
  ...
  int diameter() const;
};

Circle : Shape {
  auto width() const { this->diameter(); }
  auto height() const { this->diameter(); }

  static bool is_intersection(const Circle& a, const Circle& b) { 
    if(Shape::is_intersection(a,b)) {
      return /*precise circle-circle intersection checking*/
    }
    return false;
  }
};

As you can see, syntactically, the external implementation is essentially identical to subclassing! All functions needed to fulfill the requirements are implemented the same way outside of the class as inside of it. External implementations allows us to provide an implementation w/o modifying the original class, which is essential for any extension point implementation (or else it can’t support fundamental types). Providing an implementation this way however, rises one important question, that we don’t have when the implementation is provided as a subclass.

What happens if both the type and the external implementation have members with the same name?

To answer this question we must talk about call contexts more thoroughly. We already encountered them where the static Shape::is_intersection does different things depending if called from normal code or not. Things get more involved when both instance member functions and external implementations enter the picture.

Call contexts

This proposal recognizes tree distinct contexts for class concept member access (not just calls):

  • implementation context
  • constrained context
  • standard context

Implementation context is the most straightforward as there the rules are the same as what we have today:

class Circle {
  ...
  int diameter() const;
};

Circle : Shape { //< Circle IMPLEMENTATION of Shape requirements
  auto width() const { this->diameter(); }        //< calls MEMBER Circle::diameter()
  auto height() const { this->width(); }          //< calls IMPLEMENTATION of width()
// auto height() const { this->Shape::width(); }  //< calls DEFAULT of width()
// auto height() const { this->Circle::width(); } //< Error: no member Circle::width()

  static bool is_intersection(const Circle& a, const Circle& b) { 
    if(Shape::is_intersection(a,b)) { //< calls Shape DEFAULT of is_intersection()
    ...
//    is_intersection(a, b)           //< calls IMPLEMENTATION of is_intersection(), resulting in recursion
//    Circle::is_intersection(a, b)   //< Error: no member Circle::is_intersection()
  }
};

As said, compared to normal subclassing nothing changed. Both Circle and Shape have two distinct implementations, each of which addressed the way it is done with normal subclassing. A good way of thinking is, inside an implementation the extension point is not “active”; call to Shape:: is not yet “remapped” to anything different then the default implementation.

Constrained context is code using an extension point via a constrained template parameter. In this context all calls are to the implementation.

template<Shape T>
void useShape(T sh) { 
  ...
  T::is_interesection(sh, otherShape()); //< calls IMPLEMENTATION of is_intersection
  sh.width();                            //< calls IMPLEMENTATION of width()
  ...
}

Please take note, the call is always to the implementation. An external implementation, if present, hides in-class implementations of the same name. If the implementation is not external, the in-class one is used, if there is no in-class one, the default is used, if there is no default there will be a compilation failure. In other words, when the type is constrained, there is an active fallback for all names, defined by the class concept and the root of the lookup is the implementation, not the type (unless the implementation is internal).

Standard context is a context where we don’t have a constrained by a class concept type . In this context, an external implementation takes a back seat. It does not hide type members, in fact implementation is not accessible without an explicit call:

int main() {
  Circle c;

  c.width();         //< Error: no member Circle::width()
  c.Shape::width();  //< calls IMPLEMENTATION of width()

  return 0;
}

Having three different contexts creates inconsistency, which we trade for simplicity, because in each context we have the desired behavior:

When we create an implementation of a class concept, we have only two well-defined entities - the class and the default, nothing more. As noted it is the implementation which takes care of having fallback and is the startling point of the call lookup. No reason to expect for it to “work”, while it is being defined! When we use an implementation via a constrained type, we code against the constrain, not the type. We expect all calls to the implementation to be implicit. When we write regular code, the type is free, unconstrained, its interface is not overtaken by the constrain. If it fulfills the requirements, the implementation is available, but access is explicit.

As you can see, the three contexts give us the desired behavior always. To have an accessible “base” when implementing (in contrast to specializations), to have safe use of classes in normal code (no unexpected interface overrides) and finally, to have advanced generic code with similar look, feel and complexity to simple templates!

extension point

Defined as above, class concept checks all boxes, listed by p2279 and p2692.

  • “Interface visible in code” Yes, the class concept itself is the interface.
  • “Providing default implementations” Yes, usable “base” implementation.
  • “Explicit opt-in” Yes, via an explicit internal or external implementation
  • “Diagnose incorrect opt-in” Yes, implementations are checked at definition
  • “Easily invoke the customization” Yes, in constrained context (implicit), OK-ish in normal context (verbose call)
  • “Verify implementation” Yes, an implementation, even a formal one, must be present
  • “Atomic grouping of functionality” Yes, this is what classes are for!
  • “Non-intrusive” Yes, via external implementations
  • “Associated Types” Yes, as well as static constants and functions
  • “Non-obtrusive” Yes, the programming experience is mostly the same as “naive” templates

From this list is missing “Forwarding Customizations”, see Appendix A for why this is. Other then that, all core points are covered.

4.3 Details

A class concept differs from normal classes in at least one major way - it can not have non-static data. This is because class concept-s can not change the size and layout of a type. Not having data has multiple side effects, like no need for constructors, destructors, or even instantiation. Instead, class concept-s always access non-static data from an instance of the type they constrain. This means, the this pointer is effectively a template argument:

template<class I>
class Shape {
public:
  auto width(this const I&);
  auto height(this const I&);
  ...
};

I however here is not the constrained type itself, but the implementation for that type. This is also the reason why the template parameter is implicit - it can not be supplied directly by the user. External implementations also can not have data on their own, they have a concrete, non-template this pointer, however it is implicitly convertible to both the concrete type and the class concept the implementation is for. For internal implementations this is already the case:

class Circle : Shape<Circle> {
 ...
};

Circle itself is the implementation. It has access to itself and Shape. External implementations must behave the same way. Arguably, this is best described as multiple inheritance by a third dummy class:

class __impl_Circle_Shape : Circle, Shape<__impl_Circle_Shape> {
  // all __impl_Circle_Shape members, as well as those from both Circle and Shape available
};

The above model closely represents the desired behavior of an external implementation. The implementation is not instantiated on its own, but its members are accessed when a type is constrained by the class concept, using an instance of the constrained type:

template<class T> //< NOT constrained
void func(T t) {
  t.width(); //< access Circle::width()
}

template<Shape T> //< constrained
void func(T c) {
  c.width();         //< access __impl_Circle_Shape::width() instead
  c.T::width();      //< same
  c.Circle::width(); //< same (when instantiated it's already the same as T::width. No reason to act differently)

  c.diameter();      //< access Circle::diameter() (not part of the constrain)
}

func(Circle());

Note, the actual type the function is instantiated with does not change! It is still func<Circle> and decltype(c), sizeof(c) etc. return the same as if no constraint was in place. The difference is not in the type, but in the fact __impl_Circle_Shape members are looked up first, as-if __impl_Circle_Shape was a subclass of both Circle and Shape, with the exception that an elaborate path can’t access “the bases” or the constrained class members specifically.

One way of thinking about this is, a call to c.with() is actually magic_cast<__impl_Circle_Shape>(c).width() and c is used as a __impl_Circle_Shape (has access to self, Circle and Shape). See Appendix B for a naive implementation.

Member access is altered not just for instance functions like width, but for all members, defined in the constrain. This allows us to have associated types where T::coords_t resolve to __impl_Circle_Shape::coords_t and in turn to either Circle or Shape. Note, however, that Shape::coords_t can not possibly give us anything different then the default type - there is simply not enough information when making the call. In that regard T, as a constrained type, is special because a call to T::coords_t knows about both the concrete type and the abstraction, making a full lookup possible.

Having said that, as already mentioned, calls to static functions in the form Shape::is_intersection(c, other), must however work. This is because we expect explicit calls to the concept class to act as extension points, with the same behavior in and outside templates! Shape alone does not cary any other information besides the defaults. We need access to the implementation of Shape for a given type. “Luckily” is_intersection arguments are implementations of Shape. From the call Shape::is_intersection(c, other) we can get the type of c, Circle, and look up the implementation of Shape for Circle, resulting in the desired call of __impl_Circle_Shape::is_intersection. If the static function did not had such arguments we are in the same scenario as with member types - we don’t have enough information by using the only the constrain Shape. In that case, only a call via the constrained type (T) would have worked. Outside constrained context the simplest solution is to just use Circle::is_intersection directly, ignoring any extension point magic. Alternatively, we can introduce an elaborate syntax in the form Circle::Shape::is_intersection which guaranties the correct implementation is called.

Another possible future direction could be to have constrained types outside templates, but this is out of scope for this paper.

class concept and templates

A class concept can be a template.

template<class T>
class concept C {
  using type = T;
  auto f(type);
};

class A {...};

A : C<int> {...};

template<C<int> T> 
void f();

The template arguments come after the first implicit template argument that is the implementation for a given type.

However, perhaps more interesting are member templates:

class concept Worker {
public:
    template<class T>
    void process(T);
};

class WorkerClass : public Worker {
public:
    template<class T>
    void process(T) {
      // handle multiple types
    } 
};

template<Worker W> 
void doWork(W& w) {
    w.process(12);
    w.process("string")
}

int main() {
  WorkerClass s;
  doWork(s);
  return 0;
}

The above can be viewed as the so called “function template override”6 and allows us to call methods in sub-classes, using the base class, even if those are templates.

Finally, implementations themselves can be templates, allowing us to have ones that work on many types.

class concept Stringable {
public:
  using result = std::to_chars_result;
  result to_chars(char*, char*);
  result to_chars(char*, char*, int);
};

template<std::floating_point F>
F : Stringable {
  auto to_chars(char*, char*) { return std::to_chars(begin, end, *this); }
};

template<std::integral I>
I : Stringable {
  auto to_chars(char*, char*, int base=10) { return std::to_chars(begin, end, *this, base); }
};

The code adds “to_chars” conversion method to all numeric types using implementation templates.

This example is also interesting as both implementations are partial. They do not cover the entire interface of Stringable. If one tries to call an unimplemented function, there will be a compilation error.

void work(Stringable auto s) {
  s.to_chars(a, b, 6); //< use integer overload
}

int main() {
  work(3.14); //< ERROR, double, does not fully implement the Stringable concept. 
              // You need to implement, the to_chars(char*, char*, int) overload. 
  return 0;
}

Partial implementations could be seen as controversial, after all a type is either a Stringable or not. Yet, such strictness is impractical as it will lead to either proliferation of mini-concepts with only the methods, used for a particular algorithm, sometimes with even one method, or a lot of useless implementations in the form of std::cout << "unimplemented!" and/or defaults, which only report an error. Worse, such a solution will make the code fragile, as any new member of a concept without a default, will break existing code.

explicit implementation

In ABC, to be explicit in function overriding, we use the override keyword. We might want similar functionality in class concept as well. To serve this purpose, proposed is to allow fully-qualified names to class concept entities, inside of a class and external implementations:

struct concept C {
  void start();
  ...
};

struct A : C {
  void C::start() { ... } //< explicit override for C::start
};

This syntax also allows for multiple class concept-s to have the same names for entities and still be implemented by a single implementation:

namespace NS {
struct concept C {
  void start();
  ...
};
}

struct A : C, NS::C {
  void C::start();     { ... } //< explicit override for C::start
  void NS::C::start(); { ... } //< explicit override for NS::C::start
};

Explicit overrides are not strictly required - we already committed to the interface by subclassing (internal or not), but will serve as disambiguation, including for partial implementations, highlighting the implemented entity.

4.4 Prior Art

4.4.1 The Circle Language

Current proposal is inspired by Circle Interfaces7 (in turn inspired by Rust Traits8) and views them as a proof of concept for the core aspects of this paper. However, at the time of writing, there are many differences to “interfaces”, most of which being limitations that might be addressed. Here is a list in no particular order:

  • No static functions, no types and constants, only member functions support.
  • No possibility to implement the interface as a subclass.
  • No way to call (reuse) the base implementations.
  • No integration with C++20 concepts.

There are also features present in Circle, but not in the current proposal.

As you can see, those are significant differences and the main reason why this paper is not a proposal to just add Circle’s interfaces to C++. Still, as noted Circle can be seen as proof of concept because the overlap is also considerable:

  • Templates based. (no new generic programming machinery like Carbon9)
  • External implementations.
  • Implementations hide members, defined by the interface and only them. (No “better overload”, non-interface calls still allowed)
  • Implicit invocation in a constrained context, explicit in regular code.

4.4.2 C++0x Concepts

This paper has seemingly great similarity to parts of the “full concepts” proposal10, before it turned into “concepts light”. In particular, class concept implementations can be seen as concept_map-s. This is true, but we must remember, “full concepts” are in some aspects much closer to today’s concepts, then to class concept-s. This is because “full concepts” also follow the same syntax checking logic, even if they were written in function signature form instead as expressions. For example:

concept F<typename T> {
  void f(const T&);
}

will call either f(const SomeType&) or f(SomeType&&) overload, depending if, in a constrained context, f is called with l- or rvalue. In other words, the concept F, although written in terms of a signature, ultimately represents all valid expressions for calling f. class concepts are more conservative and there is no “better match” - a requirement to be an extension point.
The other, arguably more important difference is that class concept-s have no support for free functions, which are an integral part of the original Concepts, to the point all examples are written in terms of them! Having free functions has significant consequences. For example, atomicity (“Atomic grouping of functionality”) becomes a bit fuzzy because one function can belong to multiple concepts, the side-effects of which can be confusing11. All this again comes to the fact, the original Concepts, much like current, care exclusively about valid syntax. Entities defined in a concept (“full” or “light”) do not belong to it! Things are completely opposite for a class concept - all members belong to it the same way class members do. They are addressable and usable as stand-alone expressions, with the limitations already discussed in the paper. Further, although today’s concepts and the C++0x ones are very similar, the same can not be said about the constrained functions themselves. In C++0x constrained functions are completely checked at definition time, in C++20 they are not. class concept do not propose new model in that regard, compared to C++20, so they differ from C++0x Concepts also. That being said, because class concept requirements are no longer just expressions, some definition checking should be possible. To the very least, hopefully we can avoid the need for typename and template for disambiguation!

In a way class concept-s can be seen as C++0x Concepts archetypes, providing additional information about the interface of a type.

C++0x Concepts wording12 is also vague how member functions would be implemented in default and concept_map implementations. It is not clear for example if/how one can access non-static data members from inside such implementations, making comparison to class concept quite hard.

As you can see, to contrast concept_map-s and class concept implementations we must acknowledge, they have different requirements to fullfil. That being said, concept_map on their own differ from implementations in few specific ways (ignoring the syntax). concept_map-s are more closely related to the concept, then to the type they provide implementation for. In a way, the are something like a “sidecar” to the concept. For example, a concept_map for auto concepts is automatically generated inside the namespace of the concept to aid requirement fulfillment of that concept. Also, there can be multiple concept_map-s, for the same type, in different namespaces. class concept implementations differ in both those aspects. They are always in the same namespace as the type they are for and as a result, there are no multiple implementations. class concept implementations are intimately related to the type (to the point they can be a base class). They are an explicit extensions of the type, not a syntax helper for a given concept. As such, those extensions are a callable even outside a constrained context also. This behavior stems from a class concept serving the purpose of an extension point - customization of a type is done in its namespace. Consistency b/w internal and external implementations is also naturally preserved in this way. One open question however is how to customize primitive types, assuming, we refrain from using the global scope. A possible option is to have a special, dedicated namespace for that purpose, or maybe simply reuse std and “make it just work”.

Lastly, a probably less important difference is that C++0x Concepts do not support static constants, only functions and types (and templates thereof). class concept-s aim to support all class-defined entities, including static constants and enums, again serving to make “natural” extension points, where template code is not much different then the regular one.

4.4.3 Examples

Example std::swap

namespace std {
struct concept object {
  static void swap(object& a, object& b) {
    auto tmp = std::move(a);
    a =  std::move(b);
    b =  std::move(tmp);
  }
  
  // object might have other fundamental implementations
};
} // std

class MyObject {
  ...
  void swap();
};

MyObject : std::object {
  static void swap(MyObject& a,  MyObject& b) { a.swap(b); }
};

// --- Usage

MyObject a,b;
std::object::swap(a, b); //< will use  MyObject  IMPLEMENTATION (with fallback)

template<class T>  //< *note*, does not NEED to say object T! 
void func(T& a, T& b) {
  std::object::swap(a,b); //< will use T IMPLEMENTATION (with fallback)
}

This is a standard implementation, requiring full explicitness from the types.

Alternatively, we could provide an external implementation for all types, which already have a swap member function, similar to the behavior we have (or expect to have) today.

namespace std {
struct concept object {
  static void swap(object& a, object& b) {
    ...
  }
};

// provide impl for class with a swap method

template<HasSwap T> requires requires(T& a, T& b) {a.swap(b);}
T : object {
  static void swap(T& a,  T& b) { a.swap(b); }
};

} // std

This way a type with a swap does not have to provide an implementation. We traded nominal requirements for the established and expect behavior.
One further step we can do is introduce a function object to lessen the verbosity one extra bit:

namespace std {
inline const auto swap = [](const object auto& a, const object auto& b) { std::object::swap(a, b); };
} // std

template<class T> 
void func(T& a, T& b) {
  std::swap(a,b); //< direct use, no need for `using` to call type-specific override
}

int main() {
  std::string a, b;

  std::swap(a, b); //< less verbose then std::object::swap

  return 0;
}

Having done so, we restore the use of the shorter and well-known std::swap and at the same time improving it.

Similar treatment could receive other functions with more or less unambiguous use (nominal requirements are overkill).

Example hash

namespace std {
struct concept object {
  size_t hash() const; //< no default implementation
  
  // object might have other fundamental implementations
};

// provide impl for integers

template<std::integral T>
T : object {
  size_t hash() const {
    ...
  }
  // other object functions if needed, or leave to default
};

} // std

struct MyIndex {
    int row;
    int col;
};

MyIndex : std::object {
  size_t hash() const {
    std::size_t h1 = row.std::object::hash(); //< will call DEFAULT impl
    std::size_t h2 = col.std::object::hash(); //  

    return h1 ^ (h2 << 1);
  }
};

template<std::object T> //< We require std::object::hash impl and not any other hash()!
void func(T o) {
  o.hash();
}

int main() {
  MyIndex index{12, 13};

  func(index);

  return 0;
}

Alternatively, we could have just subclassed std::object. The reason this was not done is to show, the template argument constrain to func is required in order to make the call: MyIndex does not have hash on its own.

Example fmt

Usage (Current)

template <typename T, typename Formatter>
void format_custom_arg(void* arg,
                              parse_context_type& parse_ctx,
                              Context& ctx) {
  auto f = Formatter();
  parse_ctx.advance_to(f.parse(parse_ctx));
  ctx.advance_to(f.format(*static_cast<T*>(arg), ctx));
}
...
custom.format = format_custom_arg<T, typename Context::template
   formatter<T>>; //< get formatter

Usage (Proposed)

template <typename T, FormatterC Formatter> //< Formatter Concept
void format_custom_arg(void* arg,
                              parse_context_type& parse_ctx,
                              Context& ctx) {
  auto f = Formatter(); 
  parse_ctx.advance_to(f.parse(parse_ctx));
  ctx.advance_to(f.format(*static_cast<T*>(arg), ctx));
}
...
custom.format = format_custom_arg<T, typename Context::template 
  T::formatter>; //< get formatter

Possible Default Formatter (Current)

template <typename T, typename Char = char>
struct formatter {};

Possible Default Formatter (Proposed)

template <typename CharT = char>
concept struct formatter {
  basic_format_parse_context<CharT>::iterator 
  parse(basic_format_parse_context<CharT>& c);

  using format_context = std::buffer_context<CharT>; //< Default format context

  template <typename ValueT>
  format_context::iterator 
  format(const ValueT& val, format_context& ctx) const;
};

// Get the concrete formatter type as formatter is no longer a standard class
template<formatter F>
concept struct formattable {
  using formatter = F; 
};

User Formatter (Current)

struct point {
  double x, y;
};

template <> struct formatter<point> {
  // Presentation format: 'f' - fixed, 'e' - exponential.
  char presentation = 'f';

  // Parses format specifications of the form ['f' | 'e'].
  constexpr format_parse_context::iterator parse(format_parse_context& ctx) {
    // Parse the presentation format and store it in the formatter:
    auto it = ctx.begin(), end = ctx.end();
    if (it != end && (*it == 'f' || *it == 'e')) presentation = *it++;

    // Check if reached the end of the range:
    if (it != end && *it != '}') throw_format_error("invalid format");

    // Return an iterator past the end of the parsed range:
    return it;
  }

  // Formats the point p using the parsed format specification (presentation)
  // stored in this formatter.

  format_context::iterator format(const point& p, format_context& ctx) const {
    // ctx.out() is an output iterator to write to.
    return presentation == 'f'
              ? std::format_to(ctx.out(), "({:.1f}, {:.1f})", p.x, p.y)
              : std::format_to(ctx.out(), "({:.1e}, {:.1e})", p.x, p.y);
  }
};

User Formatter (Proposed)

struct point {
  double x, y;
};

struct pointFormatter : formatter<> {
  // Presentation format: 'f' - fixed, 'e' - exponential.
  char presentation = 'f';
  
  // Parses format specifications of the form ['f' | 'e'].
  constexpr format_parse_context::iterator parse(format_parse_context& ctx) {
    // Parse the presentation format and store it in the formatter:
    auto it = ctx.begin(), end = ctx.end();
    if (it != end && (*it == 'f' || *it == 'e')) presentation = *it++;

    // Check if reached the end of the range:
    if (it != end && *it != '}') throw_format_error("invalid format");

    // Return an iterator past the end of the parsed range:
    return it;
  }

  // Formats the point p using the parsed format specification (presentation)
  // stored in this formatter.
  template<>
  format_context::iterator format<point>(const point& p, format_context& ctx) const {
    // ctx.out() is an output iterator to write to.
    return presentation == 'f'
              ? std::format_to(ctx.out(), "({:.1f}, {:.1f})", p.x, p.y)
              : std::format_to(ctx.out(), "({:.1e}, {:.1e})", p.x, p.y);
  }
};

// point uses pointFormatter
point : formattable<pointFormatter> {
};

This example is both very similar and very different from what fmt does. It is similar, because the formatter itself is almost exactly the same. However, there is a significant difference in semantics and technicalities.
Semantically, point is not a formatter! Where format specializes a concrete formatter for point, we can’t provide implementation of the formatter requirements for point, even on a semantic level. Further, internally formatter is explicitly created as a regular class. This can not be done for an implementation and/or a class concept. Being a regular class, formatter also has a state, again not something an implementation of class concept can have. Lastly, the type that uses the formatter, the formatted type, and the type which is-a formatter (fulfills formatter requirements) are two completely different classes.
All those intricacies require us to have one extra layer, which maps from the formatted type to the formatter. This mapping is done via an extra class concept, formattable, giving us a formatter for formatted type. Arguably this model is best suited for when we already have an existing formatter class. In that case, we back to purely interface requirement fulfillment.

4.5 Appendix A: Forwarding

p2279r0 has somewhat of bonus requirement for extension points to be “forwardable”. This means, given some type B being a “stand-in” for some type A, B should be able to handle some of the extension points of A and forward all others to A itself (see the paper for details). Current proposal does not try to provide a solution for this case because the goals are not typical and are heavily influenced by the non-atomic nature of the current extension points. In particular, B does not know which extension points A handles outside the ones it cares about, so that it could upfront commit to the same “interface” as A. Not only that, A and B are often assumed to be unrelated classes, not part of an inheritance hierarchy where this issue can be solved naturally by subclassing.
Lastly, the forwarding requirement comes entirely from one source only - the std::execution proposal - which itself is a work in progress.

4.6 Appendix B: Proxy implementation

Here is a near-implementation of this paper proposed entities with what is possible using C++20.

class concept approximation, I stands for “implementation”:

template<class I>
struct Shape {
  using coords_t = int;
  static const bool constant = false;
  static bool is_intersection(const I&, const I&) { std::cout << "Shape::intersect, constant is " << I::constant; return false; }

  auto width(this const I&) { return I::coords_t(0); }
  auto height(this const I&) { return I::coords_t(0); }

  auto area(this const I& t) { return t.width() * t.height(); } 
};

As you can see, we have to do some tricks to force lookup not on the current class, but on the template argument. In particular I::coords_t will not work as a return type (will fail to compile upon instantiation). Instead we have to do a cast inside the function and use auto as a return type. Other then that, our approximation is more or less feature-complete!

An implementation as a subclass lacks surprises:

struct S : Shape<S> {
  using coords_t = double;
};

S s;
std::cout << s.area(); //< prints 0

We added, width, height and area to S, they return 0, but of the correct type, double, meaning we have “overridden” the base!

Interestingly, implementation outside the class is much the same, but using a hidden dummy class instead:

struct S {
  using coords_t = double;
};

struct _implShapeForS : Shape<_implShapeForS>
{
  ...
};

With the above, inside a constrained type context, a call to S will be replaced with _implShapeForS:

template<Shape S>
void useShape(S& s) {
  ...
  if(S::is_intersection(s, other)) //< becomes _implShapeForS::is_intersection
}

S s;
useShape(s); //< S will be used as _implShapeForS in there

This way we can either use the default implementation (Shape) or override any member inside the external implementation (_implShapeForS) if we want.

To have access to the type S as well, and be able to use its implementations, we “semantically” need multiple inheritance (struct _implShapeForS : S, Shape<_implShapeForS>). This will not work smoothly for few technical reasons, but let’s say it does. Then we can approximate the implementation as follows:

struct S {
  using coords_t = double;
  coords_t w;
};

struct _implShapeForS : S, Shape<_implShapeForS> //< subclass both S and Shape
{
  ...
  auto width() { return s.w; }  
  auto height() { return s.w; } 
};

After that, on the call site, the member calls become equivalent of:

template<Shape S>
void useShape(S& s) {
  ...
  static_cast<_implShapeForS&>(s).area(); //< UB, but safe as _implShapeForS has no data
}

S s;
useShape(s); //< S will be used as _implShapeForS from here

Again, going into the details, multiple inheritance will not work straightaway, but it is very, very close. Overall an dummy class, together with some modified form of multiple inheritance seems like a viable implementation strategy for external implementations.


  1. We need a language mechanism for customization points: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p2279r0.html↩︎

  2. Generic Programming is just Programming (P2692R0): https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2692r0.html↩︎

  3. Suggested Design for Customization Points. (N4381): https://wg21.link/n4381↩︎

  4. Customization Point Functions. (P1292R0): https://wg21.link/p1292r0↩︎

  5. tag_invoke: A general pattern for supporting customisable functions. (P1895R0): https://wg21.link/p1895r0↩︎

  6. override-template-member-in-interface: https://stackoverflow.com/questions/22422845/override-template-member-in-interface↩︎

  7. Circle Interfaces: https://github.com/seanbaxter/circle/blob/master/new-circle/README.md#interface↩︎

  8. Rust Traits: https://doc.rust-lang.org/book/ch10-02-traits.html↩︎

  9. Carbon Interfaces: https://github.com/carbon-language/carbon-lang/blob/trunk/docs/design/generics/overview.md#interfaces↩︎

  10. Concepts: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2617.pdf↩︎

  11. Simplifying the use of concepts:https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2009/n2906.pdf↩︎

  12. Proposed Wording for Concepts:https://www.open-std.org/Jtc1/sc22/wg21/docs/papers/2008/n2773.pdf↩︎