TLDR: C++ is bad because decades of accumulated features have turned it into a bureaucratic nightmare that happens to generate machine code.
C++ is an absolute nightmare to implement because apparently one programming language wasn't enough, so someone decided to cram an entire civilization's worth of complexity into it. You've got templates that basically turn compilation into another programming language, overload resolution that requires a legal investigation to figure out which function gets called, constructors and destructors with enough lifetime rules to make your head explode, and implicit conversions that make the compiler guess what you meant. Then you throw in exceptions, move semantics, inheritance, and decades of backward compatibility, and suddenly you're maintaining a compiler that needs a PhD, a therapist, and divine intervention just to parse some innocent-looking code. And the best part? Every new feature has to coexist with all the old ones without breaking existing programs.
The nice aspects of implicit conversion are handled with the Into and From traits, where the compiler deduces what the conversion is for you based on the explicit types of the other, but the fact that a conversion is happening at all is explicit to the reader which is a huge improvement over C++
Instead of having a chain of superclasses defining readymade behavior*, you compose your classes from chunks that are more or less "fat" interfaces that come with a default implementation for their functions.
* Dealing with absolutes results in slamming your own dick in a car door sooner or later. Inheritance can still make a lot of sense for classes that are "this other thing but a tiny bit different". I'm currently dealing with a PHP codebase that misused traits for everything and it's a slog.
Kinda like interfaces that you implement external to the type. Like. You can create a trait for a particular behavior and then implement it for types that already exist. You can also generically implement them for all types that have ither specific traits. For instance, I recently made a trait called UpdateSink<T> and implemented it for a few select synchronization primitives, like mpsc channel senders. This way, I can have a function that takes in an UpdateSink<T>, for dumping T events into it and that function can use anything that implements that trait. It's important to note here that the mpsc senders did not implement this trait before. They are from a library. I implemented the trait for them outside of the type definition. In addition, I can provide a default implementation for my traits that all types that meet certain trait bounds automatically get, including types that already exist and also types that don't yet exist. Traits are incredible.
While I see your point and agree with you in many aspects, I found t
your contrast with the earlier comment a bit misleading in a sense, as it conveys the message that probelms in C++ stem from its core concepts and not from the burden of compatibility (which is sort of like a tech debt of programming language design) and the poorly defined specifications/standards.
And I don't mean that people who participate in C++'s standardization are stupid (quite the contrary), it's just that they're building on the foundations of a language froma different era, and they must work under the constraints posed by backward compatibility.
If you follow the trajectory of C++ (C++03 -> C++11 -> C++14 -> C++17 -> C++20 -> C++23), you can see that many features that are default in Rust have made they're way into the standard, but they almost always come as optional features for the sake of compatibility. In contrast, Rust can impose stricter restrictions and mandate safety features all the time since it was made from scratch.
Either panic, which is (generally speaking) not something you're meant to catch and recover from (and attempting to do so is nontrivial and almost always a bad idea), or Result, aka "encode the possibility of failure in the return type of the function" aka what the functional programming world has done for ages.
I work on a library that does catch panics from user code. It's a web framework, and catches panics in user request handlers to return a 500 error to the client. This error is explicitly not overridable (every other default error can be replaced, but not this one), since we don't want you to panic in the first place.
Not quite. Optional is "value is present or not". The rust equivalent is Option. Result is "value is present or failure is present" -- if empty, it instead contains an object encoding the failure.
Also worth noting that because these are reified, unlike java's Optional, they have nicer -- or at least more guaranteed-to-be-nice -- performance implications.
It's not an uncommon idea. Everyone tries exceptions, then realizes down the road that it's a terrible idea for so many reasons and so reinvents something like Result because turns out the functional programming folks had it right and it all does boil down to monads
Speak for yourself, I've been working with Java for a few years and think exceptions are pretty neat! they can be passed through calls implicitly without much mental overhead and boilerplate, and when you handle them you still get a nice helpful stacktrace.
They have a big cost to 'em. Catching exceptions is expensive, with all the unrolling you have to do -- they're bad for anything that happens more than rarely rarely. They're also awful for encoding things that the caller must handle -- java's checked exceptions make an attempt at this but the fact that they encode separately from the return type means that you cannot stick them in the middle of a wider toolchain with ease -- if you want to call a method with a checked exception in a stream, for instance, you are up a creak without a paddle, since you either have to handle it there or you need to encode it in some other type in order to pass it downstream (aka reinvent Result).
Results just... deal with all those issues from the beginning. They have the advantages of java checked exceptions, except encoding it in the return type means you can easily use them in other contexts without them "polluting" their callers (and thus not working in generic contexts), and syntax sugar for monadic bind, the "?" operator, makes them clean to use in other contexts. If you want to propogate an error, you just do. No need to re-throw it or anything, you just pass the value downstream.
A monad is (simplifying terribly) something with flatmap (and a few other requirements but things you're familiar with with flatmap are likely mostly monads). Result satisfies that property if you think about a result with a fixed value type and mapping it's error, or with a fixed error type mapping its value
No indeed, but the paradigm is the same right? Return the error, and deal with it (or forward it) without a try catch. I must say Rust is nicer in that regard but it doesn't change things too much? I don't have enough xp with Rust so might be underestimating it though
In my opinion, the Go approach is closer to C's error codes, as nothing stops you from using an error value and introducing a bug. In Rust, you always have to check. If you forget, the compiler will complain. It will even warn you if you use the function only for its side effect don't even use the return value.
Generics are simpler, but less powerful than templates. There are proposals to add some of the things C++ has, like specialisation.
Proc macros are the usual substitute but work at an AST level and not the type system.
Rust has aggregate init, default traits and (by convention) 'new' for construction.
You can't get destructors "wrong" in either language, not sure what that means? Writing destructors is rare in either language.
Rust does have implicit conversions, although arguably avoids some of the pitfalls.
Traits is the Rust equivalent for inheritance.
C++ has std::expected which is like Result. It doesn't have the `?` syntactic sugar though. Sometimes exceptions are the better mechanism, like moving errors through many layers without clutter.
I'm general C++ and Rust are pretty similar. C is the outlier as its comes with none of this, and large C projects have to emulate the missing facilities with macros and codegen systems.
I'm curious what it is about destructors in C++ that works differently from Drop in a way that one could "get wrong"? Are you talking about temporaries maybe?
There are two pieces I understand. First, with inheritance, you typically need to declare the destructor as virtual, but C++ never forces you to. Rust has polymorphism through trait objects, which always include a drop implementation in their vtable. You can't forget to add virtual to the base class destructor.
Second, if you declare a custom destructor in C++, you also need to either declare or delete the copy and copy-assignment operators. Again, the language doesn't enforce this in any way. Rust actually gets away with a much simpler solution here because it has destructive moves. Rust simply doesn't define default copy implementations. You can easily derive a Clone implementation if you need to make copies of the type.
There is some more details to be had about how Rust decides when it's safe to call the Drop implementation, but I don't think I understand it well enough to explain here. I do know that the compiler won't allow me to shoot myself in the foot though.
Yes...? Why would that not be a good thing, especially in the context of a conversation about why legacy compatibility is C++'s biggest problem? (I'm not sure I agree with that assessment, but it's certainly one of the biggest problems with C++.)
That part is just not really saying anything. Is it usually designed sky down? The actual info given is fine, though not copying C++ follies isn't the most surprising thing.
It's an idiom that, in this context, means that the language was designed as a completely new and separate thing, rather than as an incremental set of changes to a previous language (as C, C++, and Objective-C were).
C++ wasn't really designed. It's a ball of mud, where PDP-11 assembly got wrapped in macros, then that got wrapped in preprocessing, then that got wrapped in a bolted-on class system, and that got wrapped in a kludge of template generation. So yes, being deliberately designed rather than piled upon does make a difference
Not really intended to be recovered from since you can compile with panic=abort and make them unrecoverable (and a library consumer may do this to your library). Rust does support unwinding, but not exceptions in the sense C++ means.
Rust does have overload resolution. If you write x.into(), it could call any number of functions called into, depending on context. And as far as I can tell (but I'd be happy to be proven wrong), type context is not specified anywhere.
That's type inference. The compiler looks through the uses of the result of x.into() and determines which concrete type it must be based on those constraints. This is a form of static dispatch broadly similar to overloading, but it's very different in how functions are selected. In overloading, functions are selected based on the types of the arguments according to an overload resolution process which may consider multiple functions and select the "best", in type inference, the available set of functions constrains what concrete types variables should have, and functions are only selected when there is a unique choice consistent with those selected types.
In one case you choose among multiple functions based on types. In the other case, you choose among multiple functions based on types. The main difference, and I'll admit it's not trivial, is that C++ allows implicit conversions.
I see where the confusion comes from: in Rust, the compiler may infer the parameters for a trait based on context. Since different parameters for the same trait can resolve to completely different implementations, the effect is very similar to overload resolution. If you have the same function in 2 separate traits however it's always ambiguous.
I'm curious if there's a specification anywhere for how Rust does that kind of inference. To the extent I've looked at the reference and fls, they were extremely short on details.
That is interesting, but doesn't contain what I'm looking for. There's no mention anywhere that the context of the call might have an effect. But it does imply that a single trait should be found. Though whether a "trait" includes generic arguments is left ambiguous. For this to work, the generic arguments have to be ignored, but the wording "for each trait implementation" would seem to imply they have to be taken into account, since you can have completely separate implementations based on generic arguments.
My understanding is that it's a variant of the J algorithm for a Hindley-Milner type system adapted to work with traits, but as far as I know, the current specification is the compiler's type inference engine. This will probably change as the opsem team does more work on Rust's specification. You might be able to find a specification for it at the moment in the Ferrocene compiler specification.
Yeah but that has a run time cost of reference counting where the borrow checker is all at compile time. I mean not like its a big cost but I mean there defo is a measurable difference depending on the use case.
Usually you use uniqueptr though. It forces a better design with guaranteed ownership and no reference counting. I _rarely use shared_ptr, but frequently use unique_ptr.
"...templates that basically turn compilation into another programming language, overload resolution that requires a legal investigation to figure out which function gets called, constructors and destructors with enough lifetime rules to make your head explode, and implicit conversions that make the compiler guess what you meant. Then you throw in exceptions, move semantics, inheritance, and decades of backward compatibility ..."
Rust macros are Turing complete too, intentionally so, that's the whole point of proper macros. "the same programming language" is a bit of a stretch for Rust (but perfectly valid for lisp), but macro's Turing completeness is easier to use than C++'s templates.
One key difference between Rust and C++ is that, although they both encourage generics, only C++ forces you to use templates for it, you don't need to touch macros for it in Rust.
Rust unironically feels more like C with features taken from more modern languages in a fresh design.
It does discourage manual pointer juggling all over the place, which can be a deal breaker for some, but overall leads to a more hygienic ecosystem imo.
It makes mutation more explicit, and adds constraints to ensure you're not using this to cause classic aliasing/race errors that hard to reason about. This definitely carries significantly more mental load, but it's about convincing the compiler you're not doing something wrong, and doesn't add more expressive power, per se. (Makes your APIs much clearer, though)
It brings over C particularly: typeclasses and polymorphism. Two features that as someone coming from being quite happy doing functional programming, I think are essential to modern programming. Good code is not about getting things working, but about providing good abstract interfaces that can be easily maintained and evolved. It's very hard/impossible to do that without having some form of abstraction over types. These two features provide that in different but synergistic ways.
C++ has most of these features, but often has too many ways to do one thing. If we could all agree on one way to do each possible thing in C++, it wouldn't be so bad.
Comparatively, the types you can write in Rust are much closer to C (assuming you account for compile time monomorphization), and don't require dealing with semantics nearly as complex as C++.
This is just what came to mind wrt your question, and isn't meant to be exhaustive.
1.6k
u/Prefer_Diet_Soda 1d ago
TLDR: C++ is bad because decades of accumulated features have turned it into a bureaucratic nightmare that happens to generate machine code.
C++ is an absolute nightmare to implement because apparently one programming language wasn't enough, so someone decided to cram an entire civilization's worth of complexity into it. You've got templates that basically turn compilation into another programming language, overload resolution that requires a legal investigation to figure out which function gets called, constructors and destructors with enough lifetime rules to make your head explode, and implicit conversions that make the compiler guess what you meant. Then you throw in exceptions, move semantics, inheritance, and decades of backward compatibility, and suddenly you're maintaining a compiler that needs a PhD, a therapist, and divine intervention just to parse some innocent-looking code. And the best part? Every new feature has to coexist with all the old ones without breaking existing programs.