Hacker News new | ask | show | jobs
by tialaramex 1759 days ago
I feel like one of the things a "Definitive Guide" to this feature needs to make clear, and maybe even emphasise, is a key way these are different than say Rust type Traits.

Concepts, just like the SFINAE and constrexpr hacks you should discard in their favour, are about only what will compile and you, the C++ programmer, are always responsible for shouldering the burden of deciding whether that will do what you meant even if you have no insight into the types involved.

Example: In C++ the floating point type "float" matches a concept std::totally_ordered. You can, in fact, compile code that treats any container of floats as totally ordered. But of course if some of them are NaN that won't work, because NaNs in fact don't even compare equal to themselves. You, the C++ programmer were responsible for knowing not to use this with NaNs.

Whereas, Rust's floating point type f32 implements PartialOrd (saying you can try to compare these) but not Ord (a claim that all of them are totally ordered). If you know you won't use NaNs you can construct a wrapper type, and insist that is Ord and Rust will let you do that, because now you pointed the gun at your foot, and it's clearly your fault if you pull the trigger.

This is a quite deliberate choice, it's not as though C++ could have just dropped in Rust-style traits, but I think a "Definitive Guide" ought to spell this out so that programmers understand that the burden the concept seems to be taking on is in fact still resting firmly on their shoulders in C++.

The other side of this is, if you wrote a C++ type say Beachball that implements the necessary comparison operators the Beachball is std::totally_ordered in C++ 20 with no further work from you to clear up this fact. Your users might hope you'll document whether Beachballs are actually totally ordered or not though...

I think this will likely prove to be a curse, obviously its proponents think it will work out OK or even a blessing.

5 comments

> I feel like one of the things a "Definitive Guide" to this feature needs to make clear, and maybe even emphasise, is a key way these are different than say Rust type Traits.

> Concepts, just like the SFINAE and constrexpr hacks you should discard in their favour, are about only what will compile and you, the C++ programmer, are always responsible for shouldering the burden of deciding whether that will do what you meant even if you have no insight into the types involved.

To be fair, that's not what makes them different from Rust. Unless there's something in Rust that I missed, it offers no guarantees that the implementation of the trait is consistent with its semantics.

Whether it's traits or concepts, it's still about compile-time type-checking, not actual contracts.

The trick is that in Rust the implementer chooses which Traits to implement for their Class while in C++ the Concept chooses what properties it will require and then applies to any classes with matching properties whether that's desirable or not.

So in C++ the fact a Mouse is food::LovesCheese doesn't actually tell me whether the Mouse's programmer has any idea what it means to food::LovesCheese or whether the food::LovesCheese programmer knows about a Mouse. I need to carefully read the documentation, or the source code, or guess. It might be a complete accident, or at least an unlucky side effect.

In Rust the fact a Mouse has the trait food::LovesCheese always means specifically that either (1) the Mouse programmer explicitly implemented food::LovesCheese as part of a Mouse or (2) the food programmer explicitly implemented a way for Mouse to food::LovesCheese. Rust requires that nobody but them can do this, if I have a Mouse I got from somewhere but alas it doesn't food::LovesCheese and I wish it did, I need to build my own trivial wrapper type MyCheeseLovingMouse and implement the extra trait.

Either way as the user of any Mouse, you can be sure that the fact it has food::LovesCheese in Rust is on purpose and not just an unfortunate behaviour that nobody specifically intended and you need to watch out for.

Okay, now I understand what you meant. Concepts automatically "apply" to anything that satisfies them, whereas Rust traits have to be implemented deliberately.

Thank you for clarifying.

First off, we should be clear what we mean by "guarantee". When you're talking about Rust, "guarantee" means "the compiler enforces this in an ironclad way, such that the programmer cannot mess it up" (generally with the implied caveat that we assume unsafe is not used). C++ programmers generally use "guarantee" to mean "the standard says you should follow these rules", because as the language is not type-safe the compiler is in general unable to enforce much of anything that is interesting, making the Rust sense of the word "guarantee" not a particularly useful concept in that language. Confusion between these two meanings of "guarantee" (along with related concepts like "safe") has resulted in a lot of misunderstandings over the years.

Bearing this in mind, neither concepts in C++ nor traits in Rust guarantee any semantics in the Rust sense of the word. In the C++ sense of the word, C++ concepts do carry guarantees, in that the spec says what they should do. But, in this sense, so does Rust: PartialEq [1], for example, has semantic requirements spelled out in the documentation. In my mind, the difference is that Rust programmers tend to program defensively, not trusting programmers to get things right that the compiler doesn't enforce. Thus you see a lot of conversations along the lines of "what if the implementer of trait X does something weird?" in the Rust space. This may give the impression that Rust traits don't have a clear and consistent semantics associated with them. But that's not right: the implementer of PartialOrd, for example, is absolutely expected to implement a proper partial order, as explained in the documentation. A specification for Rust could specify associated semantics for those traits, just like in C++.

[1]: https://doc.rust-lang.org/std/cmp/trait.PartialEq.html

You're correct that Rust's compiler can't guarantee semantics. However because the programmer has to actually implement Rust traits, they can and they should.

That option isn't available to C++ because of how concepts work. PartialEq and Eq wouldn't be different concepts in any practical sense, they would just be a funny way to write a comment, but in Rust they are different traits because even though Eq doesn't add any syntax it does add semantics.

There is one further trick I wasn't going to mention but you've sort of brought it up. Rust's unsafe is partly about taking responsibility for the safe and correct operation of your code. That's a pretty alien thought in C++ although I argue it shouldn't have been, it's clearly too late now. As a result Rust has the idea of unsafe traits. If you get PartialEq wrong, the programmer using your type curses and probably discards it as hopelessly broken, but their program mustn't have Undefined Behaviour as a result by Rust's definition.

However if you implement an unsafe trait like Send wrong, maybe the program now has Undefined Behaviour, as a result (signalled by the unsafe keyword you'll need to use to implement it) you are responsible for making sure your implementation is in fact semantically correct.

This distinction wouldn't mean anything in C++ where not only are your implementations of concepts never required to obey the semantics, but the language doesn't even allow you express that your implementation doesn't obey the semantics, the fact it's a syntactic match means it gets recruited by the concept and too bad.

Are you sure about this? In my tests floating points are always considered partially ordered, not totally ordered. This page [0] even mentions this in the notes towards the bottom.

[0]: https://en.cppreference.com/w/cpp/utility/compare/partial_or...

Note that std::totally_ordered is a concept (this topic is about "C++ 20 Concepts: The Definitive Guide") whereas you're talking about std::partial_ordering which is a class, also introduced in C++ 20.

Specifically these ordering classes are the result of the spaceship operator and the concept doesn't care whether you have a spaceship operator.

<=> spaceship operator: Good article on the C++20 three-way comparison operator: https://devblogs.microsoft.com/cppblog/simplify-your-code-wi...
> I feel like one of the things a "Definitive Guide" to this feature needs to make clear, and maybe even emphasise, is a key way these are different than say Rust type Traits.

What use would that be to a C++ developer who doesn't know rust?

Ah, sorry, I didn't intend that it should address this as "Here's why it's different to Rust" but rather, "Here's a surprising thing about what it does/ doesn't do in your program" and Rust offers a contrast to show this isn't just "But that's how computers work".

As you see from the standard library concepts the emphasis is on semantic claims like "fully ordered" but this feature does not actually provide semantics and I think that's a trap programmers would be likely to fall into.

Rust traits are opt-in, C++ concepts are opt-out; you can think of C++ concepts as having a "blanket" implementation for all types, so you'd need to opt out using negative reasoning. For example, in C++, you can declare a trait:

    template <typename T>
    struct totally_ordered : std::false_type {};
and opt-in implement it for some types, but not for floats.

Then you can define a TotallyOrdered concept that requires the trait.

The C++ standard library didn't do this, so if you happen to accidentally implement a type with an API that conforms to TotallyOrdered, then it becomes "accidentally" TotallyOrdered, which is a big footgun.

I think this might have been a better way to explain it than my attempt (since it looks like that confused some readers). However, you say C++ concepts are "opt-out", how does a Class opt out ?

If your Delicious concept mistakenly applies to my Desert, how do I as the author of the Desert tell C++ "No, no, when people ask if a Desert is Delicious tell them it isn't?".

Either the writer of the concept "opts-in to making the concept opt-in" (e.g. using the approach above), and that way, classes must opt in, _or_ classes opt-out by not implementing the concept API.

There is no way for a class to both implement the concept API and opt-out. The only way to support it is for the C++ concept writer to make their concept opt-in.

How can floats be totally ordered. This isn’t even a matter of NaNs or not. A set of floats where two or more floats compare equal does not permit a total ordering.
I think that's the parent comment's point. Floats are not totally ordered but C++'s type system is weak enough that it appears they are.
IEEE754 provides a total ordering algorithm actually, but it's not used when you do double a, b; ... a < b
What do you mean? I'm pretty sure no two distinct floats compare as equal.
-0 and 0 do.
-0.0 and +0.0?
Are those floats actually distinct? I mean you could represent 3.0 + 4.0 = 7.0 as well. Are "-0.0" and "0.0" actually different?

(This is a serious question I honestly don't know.)

As other users mentioned, the most relevant part is when you divide by 0, you get different infinities depending on if you're dividing by +0.0 or -0.0. But even beyond that: the binary representation of floating point numbers [1] necessarily means that there are two values for zero with distinct bit patterns.

Floating point numbers have a dedicated sign bit that specifies the sign, which means that you can flip the sign of any floating point number and get a float with a different bit pattern (and opposite sign). That means that you get necessarily get both +0.0 and -0.0, and they have different internal representation in bits.

This is one of the major advantages of two's complement notation for representing integers: it doesn't have a dedicated sign bit in the same way, so you only get one representation for 0. You can still check the sign by looking at the top bit, but if you flip it for the number 0, you don't get -0 (which doesn't really exist), you get -128 (for 8 bit signed integers).

[1]: https://en.wikipedia.org/wiki/Double-precision_floating-poin...

They are distinct values but they compare equal. For almost all uses they are effectively equal, except where you are producing infinities.
I hadn't thought about the infinities part. If they had the same behavior in all operations involving other numbers, then I could see how they could nonetheless be the same from a C++ type perspective, but given they behave differently, C++ certainly couldn't consider them the same.

Thanks for clearing that up!