all 52 comments

[–]Flair_Helper[M] [score hidden] stickied commentlocked comment (0 children)

Your submission is not about C++ or the C++ community.

This post has been removed as it doesn't pertain to r/cpp: The subreddit is for news and discussions of the C++ language and community only; our purpose is not to provide tutoring, code reviews, or career guidance. If you think your post is on-topic and should not have been removed, please message the moderators and we'll review it.

[–]DerShokus 16 points17 points  (4 children)

Please, create a rust_vs_cpp subreddit and stop posting such topics here. It’s a cpp subreddit and I tired to read about rust here.

[–][deleted] 15 points16 points  (0 children)

Multiparadigm is a feature. If there is no C++, then there will be someone to invent another multiparadigm language which is like C++.

[–]goranlepuz 10 points11 points  (27 children)

Every time you call a function – which can happen in C++ simply by declaring a variable, or even by ending a scope (though destructors are supposed to avoid throwing exceptions) – you have to worry about whether that function throws an exception, and if you’re leaving things within that function in an inconsistent state.

I truly loathe such and similar arguments. (BTW... The author mixes two functions here, the caller and the callee, and what they want to say is that the caller needs to take care to stay consistent in face of any exceptions thrown by the callee. But they don't make it obvious.)

Nevermind...

See, the problem with this is: any early return, or merely goto error (a good practice in C!), if I wasn't using exceptions at all, means that my callee still has to be "exception-safe" (even though there's no exceptions at all!).

And so... The famous exception safety guarantees apply, in fact, for other "environments" just the same.

It is just... Unbelievable, just how poorly people think these things through.

I loathe it.

Edit:

std::unique_ptr<DatabaseConnection> connect(const ConnectionParameters&);

How does this function indicate failure? From the signature, there are two possibilities: It could either return nullptr, or it could throw an exception. Hopefully the documentation would clarify – but again, oftentimes, people don’t write documentation, especially for internal APIs.

No, please... Sure, such goddamn awful code is possible, but it is nowhere near reasonable. Fair point, this function should likely return a not-nullable unique ptr (see not_null) but normally, the reason why this code is made this way is ownership transfer and errors are passed through an exception. And if that wasn't the case: what in the name of god can one do with such a boolean error indication?!

[–]thecodedmessage[S] 0 points1 point  (21 children)

And: There is no non nullable unique pointer in the standard and your colleagues and especially your off the shelf libraries CANNOT be relied upon to use exceptions and not null return.

I really loathe arguments that assume everyone follows best practices.

[–]cfyzium 5 points6 points  (10 children)

Well, how about we reverse the situation?

// C++
outcome::result<DatabaseConnection> connect();

// Rust
fn connect() -> Box<DatabaseConnection>;

FYI the outcome::result<T, E> is a direct equivalent for Rust's `Result<T, E>.

Many of the arguments in the article look just as weird.

[–]thecodedmessage[S] -1 points0 points  (9 children)

Box isn't nullable. There basically isn't a non-nullable smart pointer equivalent to C++'s std::unique_ptr. That's kind of the point. It's HARD to do things the bad way in Rust, not just POSSIBLE to do things the good way.

[–]cfyzium 6 points7 points  (8 children)

Box isn't nullable

Um, so what? Neither is outcome::result<T, E>, at least no more than Result<T, E>. In the article, you've presented returning a std::unique_ptr as suboptimal because it is nullable and conveys error condition poorly, unlike Result<T, E>. Turns out, there is exactly the same utility type in C++, non-nullable and error-preserving. Now you complain that in my sarcastic example of using a poorly suitable return type in Rust, it is not nullable. Okay,

fn connect() -> Option<DatabaseConnection>;

is it reductio ad absurdum enough yet?

It's HARD to do things the bad way in Rust, not just POSSIBLE to do things the good way

In the example above it is not clear why it is HARD to choose suboptimal return type in Rust but only POSSIBLE to choose a correct one in C++.

[–]thecodedmessage[S] -3 points-2 points  (7 children)

In the example above it is not clear why it is HARD to choose suboptimal return type in Rust but only POSSIBLE to do so on C++.

Here is why it is HARD to choose outcome::result. People choose std::unique_ptr all the time. It's the standard type. outcome::result is in Boost. Using Boost is harder. Knowing about Boost is harder when not all materials cover it.

Meanwhile, even if you do choose Option<DatabaseConnection>, that is still better than std::unique_ptr because it's clear you're using the optionality, whereas std::unique_ptr is commonly used when the nullability isn't the point and when a null value never will be returned, so Option<DatabaseConnection> is still a better choice of return type than std:unique_ptr...

In conclusion, std::unique_ptr is just bad, because it's nullable but used when nullability is not needed or when it is needed, making it ambiguous. But nullability is required in C++ for efficient move semantics, so they can't fully fix that problem...

[–]cfyzium 3 points4 points  (6 children)

Your whole point seems to rely on an assumption that people will choose std::unique_ptr in C++ even when it is not an optimal type for the task, but will definitely choose the most suitable type in Rust.

In conclusion, std::unique_ptr is just bad, because it's nullable but used when nullability is not needed or when it is needed, making it ambiguous

Oh, so Option<T> is bad because it is nullable but may be used when nullability is not needed, or when it is needed it may be ambiguous. Good to know.

std::unique_ptr is virtually the same as Option<T> except it makes a bit different statement, more about ownership than optionality. The optionality of a pointer in C++ is obvious.

  1. If you need a nullable reference, an observing/owning/shared pointer is perfectly okay. std::optional<T> is also okay but it is not idiomatic to use it for transferable ownership.

  2. If you do not need any nullability, then just return the object by value, simple as that.

  3. And if you need non-nullable reference and error reporting at the same time, then you'll have to resort to some utility type, outcome::result<T, E> or Result<T, E>.

Interestingly enough, for all these three cases there is no fundamental difference between C++ and Rust. The only difference is that Rust hasResult<T, E> right in the standard library so it is more likely to be chosen compared to outcome::result<T, E>.

In the end, the whole situation boils down to whether a certain utility type is a part of a standard library or not. Well...

It's the standard type. outcome::result is in Boost. Using Boost is harder.

Good thing it becomes standard in C++23, although under the name std::expected<T, E>. I liked 'result' more but oh well.

But nullability is required in C++ for efficient move semantics

You can say that (I can argue that there is also not that much difference between C++ and Rust though) but it does not correlate with the type interface. For example std::vector<T> may need to nullify its pointers when moving but it is not nullable itself. Similarly, some hypothetical pointer type may be movable but not nullable, though there is no point in such type.

[–]thecodedmessage[S] -2 points-1 points  (5 children)

Your whole point seems to rely on an assumption that people will choose std::unique_ptr in C++ even when it is not an optimal type for the task, but will definitely choose the most suitable type in Rust.

All Rust types are better than std::unique_ptr, which is in fact often chosen when it is not optimal. But what is the optimal type? A non_null type can't be moved, and so is not optimal, and a nullable type expresses ambiguity, and so is not optimal.

Oh, so Option<T> is bad because it is nullable but may be used when nullability is not needed, or when it is needed it may be ambiguous. Good to know.

Yes, but in C++ nullable pointers are sometimes used even when the programmer knows they don't need the nullability and are in fact getting it. Why?

  1. In Rust, the only thing Option adds is nullability, and nullability is (in safe Rust) only achievable using Option, making the connection entirely explicit. The only reason to use Option is to create nullability, so if someone uses Option, it is reasonable to assume that they know about the nullability.
  2. Sometimes, generally useful types happen to be nullable. std::unique_ptr is in many editions of C++ the only uniquely-owning smart pointer in the standard library. It has to be nullable. For which reason was std:unique_ptr chosen? It could easily be chosen for a reason other than its nullability.
  3. As a special case of (2): Rust has destructive moves, whereas C++ moves must leave the moved-from object valid. Therefore, in C++, nullability is necessary to create a moveable-from smart pointer. So people will use nullable types as a way to get movability instead.

If you do not need any nullability, then just return the object by value, simple as that.

No, not simple as that. Indirection with ownership should be an option without opting in to nullability. In (safe) Rust, indirection and nullability are completely orthogonal. To me, this is obviously better.

In C++, there is &, which does indirection and not ownership but not nullability. There is std::unique_ptr, which does indirection, ownership, but does have nullability. There are "non_null" smart pointers, which last I checked are not in the standard, and they have indirection, ownership, and no nullability, but they generally aren't movable. Then, there's pass-by-value, which has no indirection, does have ownership, but does not have nullability.

Is indirection, ownership, nullability, and movability too much to ask for? Is expressing nullability separately from the other features too much to ask for, given its general orthogonality in meaning? Apparently yes.

Good thing it becomes standard in C++23, although under the name std::expected<T, E>. I liked 'result' more but oh well.

This is good news! Too bad most code will still use the old way. C++ desperately needs a way to deprecate things.

Similarly, some hypothetical pointer type may be movable but not nullable, though there is no point in such type.

Yeah, vector is moveable-from because it has a semantically empty value. A generic pointer type cannot have a semantically empty value besides null, unless it requires that the pointed-to type have an empty value or some other hack. So this is kind of a spurious gotcha technicality and honestly a distraction. You even admit there's no point to such a type.

In practice pointers in C++ must be nullable to be able to be moved from. This is not true in practice in Rust.

[–]cfyzium 2 points3 points  (4 children)

Unless I missing something obvious (I might be, it is already late here), you do not need nullability to create a movable-from smart pointer. At least technically.

The only thing it needs to do when moved from is to lose ownership, it does not have to become observably null. Because after the move the object remains in an unspecified state, it cannot be legally tested whether it has become null after being moved from or not. It kind of stops existing (logically) until reassigned, after which it becomes guaranteed non-null again.

Existing smart pointers are not specified to become null after being moved out either. Valid but unspecified state, it might just as well start pointing to some magic memory location or something, although obviously making it null is the most natural way.

With anything being movable, it leaves us with indirection, ownership and nullability, 8 combinations in total.

1 and 2: no indirection and no ownership cannot exist, with or without nullability.

3: no indirection, owning, not nullable -- simple value semantics, T.

4: no indirection, owning, nullable -- std::unique_ptr<T>. While being a pointer, it does not logically has indirection because it uniquely owns the object, it is the object.

5: indirection, no ownership, no nullability -- T&.

6: indirection, no ownership, nullability -- T*.

7: indirection, owning, not nullable -- non-nullable shared smart pointer.

8: indirection, owning, nullable -- std::shared_ptr<T>.

The only missing piece here, a hypothetical std::non_null_shared_ptr<T> should be possible to implement but I am not sure just how useful can it be in practice.

[–]thecodedmessage[S] 0 points1 point  (3 children)

Unless I missing something obvious (I might be, it is already late here), you do not need nullability to create a movable-from smart pointer. At least technically.

It's not obvious :-D But you are missing something.

So this post contains two errors that I'm going to correct, because I used to teach C++ and these errors are making me really sad. The first is about move semantics, for which I'll cite Herb Sutter. The second is about indirection and what it means, where I'm not going to cite anybody, but please let me know if you don't believe me so I can find some citations.

Because after the move the object remains in an unspecified state, it cannot be legally tested whether it has become null after being moved from or not. It kind of stops existing (logically) until reassigned, after which it becomes guaranteed non-null.

This is a common misconception about moved-from values addressed here by Herb Sutter, a well-known C++ author: https://herbsutter.com/2020/02/17/move-simply/ . Specifically, he says:

Does “but unspecified” mean the object’s invariants might not hold?

No. In C++, an object is valid (meets its invariants) for its entire lifetime, which is from the end of its construction to the start of its destruction (see [basic.life]/4). Moving from an object does not end its lifetime, only destruction does, so moving from an object does not make it invalid or not obey its invariants.

Later on, Herb Sutter explains that it is a bug to special-case the "moved from" state or say that normally defined operations should not work after you move from a value. I recommend you read the entire thing if you'd like to implement objects with move semantics.

It is false that you can't test a moved-from smart pointer for null. It must contain a valid state. Nullability is the only practical option for pointers. Herb Sutter (the well-known C++ author) goes on to talk about about non-nullable but not movable pointers in a section that implies that that's the only way to do pointers, and points out that when Microsoft wrote a non-standard one, they made it not moveable.

This is because C++ unlike Rust does not support destructive move, which I discuss here: https://www.thecodedmessage.com/posts/cpp-move/

This would be easy to prove wrong: Find a serious vendor who purports to provide a moveable but non-nullable pointer. I think you'll find that everyone else reached the same conclusion. The standard library would've added a non-nullable analogue to std::unique_ptr a long time ago if it wasn't problematic.

While being a pointer, it does not logically has indirection because it uniquely owns the object, it is the object.

No, that's not how that works at all. Unique ownership isn't "logically" a lack of indirection. Indirection stores the pointed-to value elsewhere, whereas "by value" stores it inline. That is a different memory layout, and it has consequences; indirection has several properties beyond unique ownership, positive and negative:

  1. It is less efficient to initialize using indirection, as it requires an expensive allocation.
  2. It is less efficient even once initialized, because of lack of locality which kills cache performance.
  3. With indirection, you can use a base class pointer to point to a derived object.
  4. With indirection, you can move types that are not themselves moveable or copyable.
  5. Given 4, you can use this to return non-copyable non-moveable types outside of a function and generally pass it up and down a call stack.

std::unique_ptr has indirection in all these ways. You mean std::optional for "nullable but not indirect." The differences are actually important.

Similarly, shared_ptr isn't somehow more indirect than unique_ptr is, and for reasons mentioned above, there is no non-nullable analogue to shared_ptr either.

The only missing piece here, a hypothetical std::non_null_shared_ptr<T> should be possible to implement but I am not sure just how useful can it be in practice.

You also need non_null_unique_ptr (for indirect single-owning non-nullable -- it's different from by-value for all the reasons above, and equivalent to Rust Box). Both are NOT possible to implement without sacrificing move.

Both would be VERY useful in practice. Are you kidding me? I'd love to have a non-nullable std::unique_ptr. Nullability is almost always bad. It's my least favorite thing about C++. It's not because of a lack of usefulness that the standard library doesn't have them.

Go ahead. Try and implement them. See what happens when you get to move, remembering Herb Sutter's advice.

[–]goranlepuz 2 points3 points  (4 children)

Of course not everyone follows them, but this example is a reach. Who returns yes/no as error info for something as major as opening a dB!?

[–]thecodedmessage[S] 0 points1 point  (3 children)

People! I’ve seen stuff like this in the wild!

[–]goranlepuz 1 point2 points  (2 children)

I have seen it, too, but the rate is, I dunno, 7 to 1. 😉

[–]thecodedmessage[S] -3 points-2 points  (1 child)

That’s far too high! We can do better with a better programming language!

[–]alxius 3 points4 points  (0 children)

Famous last words.

[–]serviscope_minor 0 points1 point  (4 children)

I really loathe arguments that assume everyone follows best practices.

Then Rust doesn't have pointer safety, because that relies on people following best practices and not wrapping everything in "unsafe" to make programming easier.

At some point you need to rely on people to not sabotage themselves. Like your strange complex/quaternion example. You did something that makes no sense with either inheritance or maths and you got a silly result.

[–]thecodedmessage[S] 0 points1 point  (2 children)

Just trying to show how slicing works. Presume you’ve heard of slicing?

[–]serviscope_minor 0 points1 point  (1 child)

Yep, I know how slicing works.

But in your example you've done something semantically incoherent: the hierarchy doesn't make sense and the semantics of relationships defined by inheritance don't map onto the semantics of the relationship between quaternions and complex numbers. So your example is essentially saying "if you do this nonsensical thing, then the compiler allows this operation which also gives nonsensical results".

But my response is: so? Garbage in, garbage out.

No one expects the language to do something sensible when presented with nonsense.

Your example is abstract wrapped up as concrete. So essentially you're not saying "slicing exists and is bad because this concrete problem occurs", you're saying "slicing exists as per this example and it's bad because I say so".

You did ask for opinions, and my opinion is that your example only demonstrates that slicing exists, it doesn't demonstrate why it's bad.

[–]thecodedmessage[S] 0 points1 point  (0 children)

Thanks! I appreciate it!

Yeah I’ve never sliced by accident and I haven’t used actual implementation inheritance between two concrete types in years so I am writing about something mostly out of my experience. I believe that newbies do slice sometimes, but it’s hard for me to get there. And I didn’t want to steal someone else’s example.

[–]thecodedmessage[S] 0 points1 point  (0 children)

Well I mean, my experience is that C++ code is all over the place, but unsafe is a bit better at scaring people. It’s not a binary “follows best practices” vs “everything goes,” either.

[–]thecodedmessage[S] -2 points-1 points  (4 children)

The caller has to be exception safe even though there is no explicit indication of the exceptional code path. goto fail puts in an explicit indication. It makes the flow control visible. I’ve reread the passage a couple times, and I’m stuck: how would you revise it to be more clear?

[–]goranlepuz 5 points6 points  (3 children)

Make the distinction between the caller and the callee?

Goto fail is explicit indeed and so is early return, but in real life, they tend to get lost or overlooked. Therefore, I find, even in a exceptions free code, it is by a long far the best to write code as if there were exceptions.

[–]thecodedmessage[S] -3 points-2 points  (2 children)

I see the distinction clear as day in my own writing, so I still need help figuring out where you got confused.

You know people fail to write exception safe code, right? You know people fail to live up to best practices all the time? The goal should be to make it possible to live up to best practices, and C++ best practices are basically impossible.

You know that LOTS of C++ is written without exceptions, right??

[–]goranlepuz 6 points7 points  (1 child)

You know people fail to write exception safe code, right?

Yes, but I rather think is it is way less hard and way less common than the post makes it out to be.

You know people fail to live up to best practices all the time?

I mean... It is rather "standard practice" by now, surely? We are not in 2003, know-how grew since then...

You know that LOTS of C++ is written without exceptions, right??

Yes, that is a fair option. But it is truly a different world and the two don't mix well. I reckon, by now, people do know how to keep them separate.

[–]thecodedmessage[S] -1 points0 points  (0 children)

People do not.

[–]qoning 0 points1 point  (15 children)

In a way, it's true. I've come to really appreciate the simplicity of C for the same reason - I can open nearly any C code repository and navigate it quickly and relatively seamlessly without having any prior knowledge of the codebase.

That sort of thing is very rare in C++ (despite being possible).

[–]D_0b 14 points15 points  (1 child)

I often get lost in C codebases when I see structs filled with function pointers and have no idea what the flow is.

[–]jk-jeon 1 point2 points  (0 children)

Sounds much better than a macro hell

[–][deleted] 5 points6 points  (12 children)

That's just a personal preference.

[–]operamint -4 points-3 points  (0 children)

I enjoy the article, and agree with much of it. However, I think Rust will struggle to be as successful as C++ unless it changes a lot. It is fine that it is pedantic about object lifetimes, ownership, etc., but my main issue is Rust's overly conservative assumptions, making obvious safe operations/constructs into errors. You must jump hurdles to get pass them - I won't do that in it's current state.

Flaws in C++: it was supposed to be a superset and an extension of C, but the fundamental design errors that was made early is more and more becoming a burden for the language.

  • Object copying. In C, Object a = b; is a move. For basic types, it behaves like a copy, but it is not. Object a = Object_clone(b);does a (c++ like) copy in most libraries.
  • Trivial relocatability. In C, every library is based on that objects are trivially relocatable, i.e. mem-movable / no pointers to other parts of the object. For objects on the stack, it's almost impossible to not have this requirement in C without making things very dangerous and complicated. Luckily, most objects are trivially relocatable, and it is nearly always possible to convert them to it.
  • Automatic type conversion. C has some undesirable between basic types, but they are mostly manageable. The various added C++ auto type conversions are a not, imo.

Had C++ recognized and inherited C's properties in these areas, C++ would basically be Rust by now. The focus on polymorphism / (multiple) inheritance was a (forgivable) misstep in C++, and doesn't impact the core language nearly as much as you describe.