How to design good concepts and use them well (C++)

Bjarne Stroustrup published a new paper titled How to design good concepts and use them well. Most of the guidelines discussed in Chapter 5 apply almost 1:1 to (some kind of) Traits as well (traits are used for way more things than concepts). The rest is probably too C++ specific, but keeping in mind that C++ concepts are not supposed to solve the same problem as Rust traits, I still found the paper a very interesting read.

From the paper on good "final" concept design:

We often try to specify the requirements of an algorithm to be the absolute minimum. This is not what we do to design the most useful concepts. [...] In this direction lies chaos. Thus, the ideal is not “minimal requirements” but “requirements expressed in terms of fundamental and complete concepts.” This puts a burden on the designers of types (to match concepts), but that leads to better types and to more flexible code. [...] To design good concepts and to use concepts well, we must remember that an implementation isn’t a specification – someday, someone is likely to want to improve the implementation and ideally that is done without affecting the interface. Often, we cannot change an interface because doing so would break user code. To write maintainable and widely usable code we aim for semantic coherence, rather than minimalism for each concept and algorithm in isolation.

and on "incomplete concepts":

The view of concepts described here is somewhat idealistic and aimed at producing “final” concepts to be used by application builders in mature application domains. However, incomplete concepts can be very useful, especially during earlier stages of development. [...] Concepts that are too simple for general use and/or lack a clear semantics can also be used as building blocks for more complete concepts.

2 Likes

What I find interesting is the reasoning behind that quote. Some of it has to do with duck typing (e.g. strings are Addable?!), though he also brings up the tradeoff between freedom in an algorithm's implementation vs freedom in the types it accepts (a tradeoff which is very real in rust).

Other interesting things I notice:

It comes with a clean shorthand syntax out of the box; it appears these are statically dispatched:

void sort(Sortable xs);
void sort(Sortable& xs);

Also, it permits specialization in an interesting manner: (see Section 6)

void advance(Forward_iterator p, int n) { while(n--) ++p; }
void advance(Random_access_iterator p, int n) { p += n; }

void use(vector<string>& vs, list<string>& ls)
{
   auto pvs = find(vs,"foo");
   advance(pvs,2); // use fast advance

   auto pls = find(ls,"foo");
   advance(pls,2); // use slow advance
}

How does the compiler figure out how to invoke the right advance? We didn’t tell it directly.
There is no defined hierarchy of iterators and we did not define any traits to use for tag dispatch.
...
There are a few technicalities related to the exact comparison of concepts for strictness, but we
don’t need to go into those to use concept overloading. What is important is that we don’t have
to explicitly specify an “inheritance hierarchy” among concepts, define traits classes, or add tag
dispatch helper functions. The compiler computes the real hierarchies for us. This is far simpler
and more flexible.

Not only does this design choice align well with the difference in philosophies between C++ and Rust; but I'm a bit surprised that it is even possible!

No, he just decided to call his concept Addable instead of Concatenable. In C++ the binary operator +(string, string) is used to denote concatenation. If one just constrains on syntactic requirements, "strings become addable, and numbers become concatenable...". In Rust the same thing happens, e.g. if you want to use + for concatenating strings you need to implement the Add trait on your own String type,... which makes your strings "Addable". If the trait was called BinaryOpPlus everything would be fine because it is clear from its name that it's completely devoid of semantics (addition and concatenation imply semantics, like addition is commutatitve but concatenation is not).

Also, it permits specialization in an interesting manner

I think that is actually "overloading" in C++, which is more similar to implementing the same Trait for two different types in Rust and then having Trait::method(obj) call the right implementation. The way it works is that Random_access_iterator extends Forward_iterator, so the compiler trivially can know in this case which one is more "specific". There are other cases in which this is not the case, but then C++ supports arbitrary predicates on types, so you can in practice remove all candidates but one manually.

The "Strings are addable" was just alluding to Stroustroup's own example:

To be more realistic, people sometimes get into trouble defining something like this:

template<typename T>
concept bool Addable = requires(T a, T b) { { a+b } -> T; } ;

They are then often surprised to find that std::string is Addable (std::string provides a +, but that +
concatenates, rather than adding). Addable is not a suitable concept for general use, it does not
represent a fundamental user-level concept

But you're right, this specific example also occurs in Rust thanks to the operator traits. Perhaps I should have made reference to the Cowboy example instead, which is clearly specific to languages with structural polymorphism like C++ (vs rust's nominal polymorphism).

In Rust, ::shape::traits::Draw::draw is never going to accidentally resolve to ::wild::wild::west::Cowboy::draw. (though in exchange, we pay their dues in glue code!)