It's modern C++ so, a little of both I think. 20 years of it and I still have people saying "I should work on my fundamentals" so it's totally fine to be a bit of both.
> Unfortunately, I don't think there's getting away from just understanding value semantics to get the correct and/or performant behavior.
This is not specific to C++ though. It just so happens that C++ developers who feel this topic is important are those invested in performance optimization. For them, C++ offers them these types of tools.
Meanwhile, those who don't have a pressing need to go through great extents to optimize performance can simply fall back to the compiler generating most special member functions and then pay the performance tax of doing deep copies by default. For these cases, which I'd say corresponds to most C++ floating around, it's far more important to know the rules of when to define our own custom constructors and assignment operators.
> pay the performance tax of doing deep copies by default.
For most code that performance tax is not worth worrying about. There are almost high performance priorities. It is almost always the case that your code runs "fast enough" long before you start worrying about the few nanoseconds a deep copy of a few bytes costs.
> For most code that performance tax is not worth worrying about.
Indeed, I agree. It's possible to go a long way in terms of performance with a basic understanding of passing by reference, without even having ro bother with move semantics. So these topics end up being dominated by language lawyers and the types that enjoy debating "aktualy" topics, who also contribute to making things sound far harder than what they actually are by pretending that this sort of trivia is very important stuff.
> So these topics end up being dominated by language lawyers and the types that enjoy debating "aktualy" topics,
I'm not sure about that.
Even though most people never need it, a small minority really do, and those types have to become expert in those weird details. Well you pretty much have to be a language lawyer to get these optimizations right, but the goal really is the ultimate performance in some place where it really matters.
What is that makes NVRO so much more difficult to implement? Why couldn't they mandate that just like RVO?
Do compilers literally just special case a simple return statement of a direct construction or something?
Is going to be much harder because you don't know are compile time which is returned and so cannot construct the one you return in the correct place. That is just off the top of my head, I'm not a compiler writer, I'm sure they have figured out the simple versions of the above, but you can start to see the complex versions that they can't.
That one is not too hard either. `one` simply can't be NRVO'd as there is a runtime path in scope of it that doesn't return it. `two` can be (unless you hide some other return within ...).
The point of (N)RVO is to directly construct the return value in-place at the calling frame. Which requires knowing what object will land there.
In RVO there is no problem because you know what object is the one you need to put there.
In NRVO there is a problem because you might have one of multiple objects being returned and you need to know which one to construct at the call site; it can't be all of them on top of each other. But you don't necessarily know at the time of construction whether that object will be the one that is actually returned. Doing so requires imperfect code analysis so the standard would need to define the complicated analyses to perform.
RVO is easy to detect since it happens only in expressions in return-statements.
NRVO requires the compiler to analyze the flow, like if 2 different variables/constructions can lead to the return (what one do we take, or can we do either later?).
Also, with RVO it's easy to detect and elide destruction calling for things going out of scope whilst NRVO would require more careful management of destruction order,etc.
Basically, NRVO touches a lot of things in "inconventient" places that can easily require reworking internal compiler structures to track destinations whilst RVO was probably far easier to just "hack in".
I figured any half decent compiler already do plenty of flow and liveness analysis on everything for register allocation, dead code elimination and what not.
Maybe it's the guaranteed elision that makes it a problem, like you can't fail the analysis, but then maybe you go the rust route - fail to compile and urge the programmer to rewrite their code so it accepts it.
Make it opt in with [[must_elide]] so old code still works I guess.
Register allocation is usually on a far "lower" codegen level as is often DCE, they should be possible to compute/run on a SSA node level or similar long after destruction sequences are applied.
Now, there is far more "language level" flow analysis today apart from this as required by allowing auto type inference in more places (and things relaxed in relation to that). Reading up it seems to be suitably done in Clang on the ClangIR(MLIR extension) level, something that sits between AST and the LLVM IR.
Regardless of how it's implemented, I'm pretty sure that NRVO carried a fair bit more complexity requirements compared to RVO depending on how prepared the corebases for different compilers were to handle it.
Despite its name, NRVO isn't an optimization performed by the optimizer, it's something done by the frontend of the compiler before it generates the code for the optimizer to run on.
The frontend is extremely reluctant to do anything like flow analysis, in large part because the frontend doesn't even really have any code to do the analysis on, just the AST. More people (including far too many on the committee itself) need to understand the separation between the different parts of the compiler, and what each part can and cannot do effectively.
They probably _were_, since lower level code representations often has little notion of complex types and their semantics they could be kept clean and focused on machine code, however type inference in a language like C++ complicates such matters immensly since an assignment can be both a register move and a function call.
Template resolution solved that in the past, but C++ today allows auto in so many places that I'm uncertain that it can be done without some flow based support (if constexpr comes to mind).
I mentioned ClangIR(MLIR) in the sibling comment here, feels like it was built for stuff like this.
> Template resolution solved that in the past, but C++ today allows auto in so many places that I'm uncertain that it can be done without some flow based support (if constexpr comes to mind).
C++ requires determining the type of every expression immediately. Even auto doesn't change that: it determines the type of the variable based on the initializer, literally following the same rules as template argument deduction (there's a little bit of patching to tweak the exact expression being used for deduction, but https://eel.is/c++draft/dcl.type.auto.deduct#3 is the core rule here).
ClangIR doesn't necessarily help here, because while it does give a more abstract C abstract machine IR semantics, it's still downstream of things like NRVO decision points--it's still fundamentally past the codegen-the-AST barrier.
> Template resolution solved that in the past, but C++ today allows auto in so many places that I'm uncertain that it can be done without some flow based support (if constexpr comes to mind).
This concern is unfounded. The auto keyword in C++ acts as mere syntactic sugar. It works only when the compiler is able to tell exactly what's the type by evaluating the expression.
The auto keyword is also considered a code smell for the same reason: just because the compiler can tell exactly what the type is expected to be, that does not mean the developer can. Therefore it makes the code harder to reason about.
> The auto keyword is also considered a code smell for the same reason: just because the compiler can tell exactly what the type is expected to be, that does not mean the developer can. Therefore it makes the code harder to reason about.
This really depends though. In many cases even the programmer can tell the type of auto because its on the very same line and not using auto would mean needlessly repeating it.
In other cases (e.g. iterators) the programmer also doesn't need to care about the concrete type.
GP was possibly arguing against Herb Sutter's "almost always auto". C++ wasn't designed for auto and it shows. You can't rely on it always doing the right thing or at least a safe thing in C++, unlike in Rust - I've seen bugs due to auto. It is also often helpful to spell out the concrete type in important places such as (most/many) variable definitions.
I'm also fine with auto if it just repeats information, especially so if the concrete type takes half of your line length budget (yes it's an iterator over that container containing...).
Yeah, when auto first came out a few people started abusing it everywhere making for unreadable code. Most developers settled quickly on much better rules for using auto that do not destroy readability, and never abused it in the first place. Auto is intended for and works very well to avoid typing very long types where you often never care.
Iterators is the common example: the type name is always long, and nearly always used in a context where it is obvious. Even if you do care about the type, looking up the iterator is wrong - you always first look up the base type (ie std:vector) first to understand the iterator, only rarely do you need to dig into the iterator once you understand the base type.
Generic code (template and non-template) are the other - they type could be anything, so trying to specify more detail isn't going to gain you anything.
N/RVO works by (at the machine language level, of course) rewriting the function signature to return void and take an extra pointer parameter, which is written to before returning. If you're returning a newly-constructed object, the compiler can rewrite that into calling the constructor on the pointer, but if you're returning a named object, the class may have a non-trivial destructor that needs to run after the move, such that it's not possible to rewrite uses of the local object into uses of the pointer.
I'm not too confident on that last part, because such an implementation would mess with semantics in case of an exception, so anyone feel free to correct me on that.
NRVO does not affect the ABI of the function. It cannot affect the ABI, for whether or not it kicks in depends on the body of the function, and affecting the ABI would make it impossible to use it if only the declaration appears in a header.
The correct explanation is this:
In C++, classes with nontrivial destructors or copy/move constructors are considered nontrivial for the purposes of calls and are passed via pointers rather than via value. By passing via pointer, the class has a stable address and thus 'this' pointer. Returning such a class means the caller allocates the storage for the class on the stack before calling the function, and passes the pointer to that storage to the function as an extra parameter. This is based solely on the definition of the class itself; this happens whether or not NRVO kicks in.
Usually, when you declare a variable, the abstract machine of C++ requires you to construct a new object and call the copy/move constructors or assignment operators and the destructors at various times as appropriate. With nontrivial versions of these special functions, it is possible to observe whether or not they were called (these things still happen with trivial classes, but it's not so easy to observe). Returning a value requires constructing the storage space for that object--with all the attendant abstract machinery that involves.
What NRVO does is to say that, under certain conditions, rather than constructing storage space for a given variable that is normally required, the storage space that is allocated for the return value by the ABI is used instead. In essence, you are promoting a given variable to the return value hence the name 'Named Return Value Optimization'. What makes this annoying to implement is that you have to track at the AST level, before doing any code generation at all, whether or not a given variable is eligible for NRVO, and then use that information to control the code generation for allocating storage space.
Despite its name, NRVO is not actually an 'optimization' in the compiler. The optimizer plays no role in it, since the optimizer is fulfilling the requirements of the abstract machine. Instead, it is a set of conditions that allows the frontend to omit calls to copy constructors, etc. under specific circumstances.
> Despite its name, NRVO is not actually an 'optimization' in the compiler. The optimizer plays no role in it, since the optimizer is fulfilling the requirements of the abstract machine. Instead, it is a set of conditions that allows the frontend to omit calls to copy constructors, etc. under specific circumstances.
That's semantics. The way the compiler is structured and in which component the transformation is implemented has no bearing on whether something is an optimization.
> N/RVO works by (at the machine language level, of course) rewriting the function signature to return void and take an extra pointer parameter
This sounds wrong, are you sure? Would you mind demonstrating with an example on godbolt? Whether NRVO applies or not, the ABI should be the same, AFAIK.
Yes, it works exactly like this, this is a demo on godbolt [0]. rdi stores the pointer in both cases, makeS1() uses RVO, makeS2() takes it explicitly and constructs with placement new.
I will say before testing this i didn't realize the RVO calling convention was to return the pointer you pass in, but apparently so. If makeS2() returned void, it's just a tail call to the constructor, but makeS1() has to spill rbx and use it to save the pointer.
No, all you're showing in that example is that a pointer is passed as part of the ABI. You're not showing that RVO relates to that in any way whatsoever. If you write the same function in a manner that (N)RVO can't kick in, does the pointer no longer get passed?
The reason this should sound dubious is that you're suggesting the caller needs to know the callee's body in order to know how to call it, but it should be possible for the two to be compiled entirely independently, and in fact mutual recursions should be fine too. After all, the callee knows where the return value has to land either way, and the caller similarly knows where to expect it, regardless of when/how the object is constructed or destroyed.
Yes, of that I'm sure. This optimization is only possible if the compiler has control of both sides of a call. If the function may be callable from other translation units or modules I imagine it generates a thin wrapper that's externally callable.
I don't know why I wrote the comment about parameters earlier -- (N)RVO is about return values.
Those have a different reason for being passed behind a hidden pointer: the class type might have self-referencing pointers, so there must be an explicit move/copy constructor call whenever it changes address, to give the class an opportunity to update those pointers. This cannot work when returning in a register: the callee doesn't know the target address, and the caller doesn't the source address, so neither can call the move constructor.
Thus, all ABIs must pass a pointer (or let caller+callee agree on a memory location in some other way) for types that aren't trivially copyable.
The optimization is often possible even if the computer does not see the call, because most (all?) ABIs have always required hidden pointer parameters for class types with non-trivial destructors.
https://godbolt.org/z/9WvnEvEYh
Note how `std::unique_ptr<int>` effectively passed as a `int**`; and that the by-value unique_ptr is not destroyed at the end of the function -- destroying parameters is instead the caller's job (and commonly only happens at the end of the full expression containing the call -- though this choice is implementation-defined).
But that can only work if the caller can see the updated value of the parameter (to avoid double-free for `clear`) -> thus the need to pass the parameter by hidden pointer.
The problem is that "predictable reliable NRVO" is still a research problem. Real-world compilers do NRVO a lot but not in a way that is perfectly predictable — that is to say, not in a way that could be standardized across all compilers (or even between different releases of the same compiler).
A "perfectly predictable" algorithm was proposed in Anton Zhilin's P2025, back in the year 2021:
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p20...
but unfortunately it had some subtle corner-case problems (which I do not remember), so it was sent back for revision, and never returned with a fix. (Maybe because a fix wasn't possible; again I don't remember what the deal was exactly.)
The Right Path Forward would be for MSVC, GCC, and Clang all to try implementing P2025's algorithm in their front ends. Either something concrete breaks (reminding me what the problem was), or else all three mainstream compilers gain predictable NRVO and then we can "standardize existing practice." But the Right Path Forward requires tedious work by at least three people, which is hard.
> What is that makes NVRO so much more difficult to implement?
I recall reading that at a high level RVO is implemented by treating the return value as an external object. In simple terms (simplistic terms) RVO then works by
- first instantiating the return variable,
- passing the var by reference to the function,
- and then use return value to actually initialize the variable passed by reference.
The moment there's some funny logic on what to write to that output value, the problem gets far more complex.
- stupid question, since we are on the topic of c++, i finished reading through learncpp.com
- how do I take this from 0 to the guy that builds a play station 3 emulator?
- like seriously what kind of step by step projects or learning experiences in increasing order of difficulty do you recommend to get really really good at c++
The fun thing about C++ is that it’s really hard. The even more fun thing is that the domain knowledge behind is even harder - game engines, high performance clusters, emulators, real time simulations, rendering engines, high frequency trading - so more often than not most C++ devs commit to learning a language subset and then focus their energy on the task at hand. A PS3 emulator might be a switch statement (not really, but you get the point)
> The fun thing about C++ is that it’s really hard.
C++ definitely has a higher cognitive load than your average interpreted language. However, a big part of that cognitive load is due to support for highly optimized scenarios such as return value optimization. Most people write code that doesn't require it at all, and that's perfectly fine. The same goes for move semantics. You can spend a whole career writing code where all your classes do deep copies when passing objects by value. The compiler even graciously helps you not have to write all constructors and assignment operators to have your POCO class work out of the box. But once you feel the need to avoid those copies then you need to know what to do to nudge the compiler do that for you. That is a while subtle dance. That's when the cognitive load starts growing.
The hardest part of C++ are it's lifetime rules, which are in fact extremely difficult to grasp. There's essentially a unique(ish) ruleset for each class of object and misinterpreting one of them (no matter how obscure) can easily cause UB.
I would recommend reconsidering the "PS3" part. I had to revisit a codebase that supported the beast more than a decade ago recently, and I was in shock at how weird the PS3 bits were.
As for "really good", I would dodge that since I have only been using C++ for like 25 years =)
> like seriously what kind of step by step projects or learning experiences in increasing order of difficulty do you recommend to get really really good at c++
I'm not sure if you're personally interested in this, but the main way I learned is by writing my own game engine (Casey Muratori's Handmade Hero-style) and writing mods for games (even by reverse engineering). It actually teaches you almost everything you need to know both from a language perspective AND it forces you to understand each individual step of how a program works (input/audio/networking/graphics/logic). You can go step-by-step mastering each thing as you go.
There's no simple universal roadmap for becoming competent. You just need to grind out whatever skill you're trying to master as much as possible. 10000 hours is the common number thrown around, but plenty of programmers have been doing it for more than that and can't even tell you how an emulator is supposed to work.
So to answer your question: work longer and harder than the people who don't know how to write a PS3 emulator (especially those who think they can take a shortcut with AI)
This but replace some with more. Write more code! Programming is like any other discipline which requires constant training and exercise. Doesn't matter if its Jujutsu or C++, you must train and become well versed in all the aspects of the craft.
You don’t need to be really good at cpp to write a crappy emulator. All the “really good” stuff is about eeking out performance. If you want to build an emulator start with a qt gui and go from there. By the time you’re done you’ll be good enough
This is an easy mistake to make but it's not what happened
Programmers already knew (~20 years ago) when the C++ move feature was designed that what people want is the destructive move assignment semantic, the thing Rust has today. Other languages did have that. But C++ 98 already existed and WG21 already did not want to make it difficult to take your crusty 10+ year old C++ codebase, slap a sticker on it and say this is "Modern C++"
The C++ "move" proposal is slightly sneaky, it admits that what they're proposing is not the destructive move (again, people know they want this) but it gives the impression that if they really want destructive move they can add it later, without revealing what's really going on underneath.
In fact C++ move is roughly what Rust would call core::mem::take, we move something in the usual way (a destructive move) but then we replace it with some value of the same type, in the case of core::mem::take it's Default::default()
To enable this, C++ is full of types which look superficially familiar to a Rust programmer but have a weird "empty" state to provide that default value where none would make sense. For example std::unique_ptr<T> looks like it's Box<T> but it's not, it's actually Option<Box<T>>, even newer types often do this but they might be more embarrassed about it.
> For example std::unique_ptr<T> looks like it's Box<T> but it's not, it's actually Option<Box<T>>, even newer types often do this but they might be more embarrassed about it.
This would be the case even with destructive moves unless you also add some way for std::optional<std::unique_ptr<T>> to be no larger than a pointer by letting std::optional take advantage of the fact that a non-null std::unique_ptr has a bit representation that leaves room for sentinel values.
Also, at the language level, C++ moves are sort-destructive as the moved from object only has to guarantee to be able to run the destructor. E.g. you could still have a std::nonull_ptr where move sets the internal pointer to zero but calling anything except the destructor on such an instance throws / calls std::terminate() / is UB. It's only the stdlib types that make additional guarantees - because in most cases it can be done without additional cost.
And the most important idea: destructive moves.
Since C++ doesn't track lifetimes it has to leave the object in a "valid state" after a move and the destructor still runs which has to have a check if it should do something or not.
BTW, Rust's lifetime annotations for borrowed references are a mostly orthogonal feature.
Liveness of objects for move/drop semantics is tracked differently, without any syntax and with implicit runtime drop flags where necessary.
C++ could probably add the same deinitialized/moved-from state tracking (with an opt-in for back compat sake) purely to avoid dtor bloat, without having to add safety of borrow checking.
> C++ could probably add the same deinitialized/moved-from state (with an opt-in for back compat sake) purely to avoid dtor bloat, without having to add safety of borrow checking.
There is a lot of talk in the C++ committee about this. The details are complex in some obscure cases.
While not the common case, C++ move semantics match the situation in systems code where correctness requires decoupling logical object lifetimes and destructors. The object is logically dead but the destructor may be deferred indefinitely for safety reasons.
Most code doesn't have the shared memory setups where deferred destruction is necessary.
It did for sure, but the problem with C++ is its heritage, specifically that structures can be self-referential. For instance, the Rust's url::Url type has to use usize offsets for tracking the location of each of its components. Conversely, in C++, someone could have already created a similar Url type that would use std::string for the buffer and char pointers for the component locations. As such, you cannot simply memcpy from one struct into another and forget the former as std::string could have its own in-place storage and that would invalidate all pointers - you'll need to define a move constructor instead.
std::move is a great tool when used correctly. However used incorrectly it makes code worse: more verbose and less performant. Since I have no idea how you are using it I can't comment on your experience. My experience is people (including me!) get it wrong fairly often. Fortunately tools can detect a lot of cases where you get it wrong.
My little trick is to think of them as "oh, I accidentally made an lvalue from an actual rvalue here because a name was introduced, so I need to cast (i.e move() or forward()) back to an rvalue again", that's why i have them as macros: MOVE_CAST and FORWARD_CAST defined as static_cast (also avoids blowing up compile times).
I never think in terms of "moving this object".
Every time I run across yet another one of these “let me explain C++ move or copy semantics to you” I thank my lucky star that I am using C# in my day to day work.
Kudos to the author for explaining these concepts, but I personally find this to be a poor use of my time and mental capacity.
I dabbled in C++ programming before Rust 1.0 was released (year 2015). I have spent some time understanding things like RVO, std::move(), rvalue references, T&&, move constructors, and so on. This was the main tutorial that I read a decade ago: https://web.archive.org/web/20240108142848/http://www.thbeck...
I began programming in Rust in 2017, and it is such a breath of fresh air. It has all the power of C++ but shed all the unnecessary baggage (e.g. confusing features, duplicate features).
In relation to this article, I like Rust's clear and simple semantics about moving objects. If x and y are variables of type T which implements the Copy trait, then `x = y;` performs a simple bitwise copy. Otherwise, `x = y;` moves the object `y` to `x` and `y` is no longer allowed to be accessed ("moved away"). Whereas in C++, the article states how copy elision behavior has changed over the years:
> You get guaranteed copy elision since C++17 in the following case [implying it wasn't guaranteed before C++17]
> Then you have named return value optimization (NRVO): [...] The latter is not subject to guaranteed copy elision. You probably don't pay for a copy or move there as well.
> While this code compiles, you will get a copy construction of the return value before C++20.
> Once you switch your compiler to C++23 mode, both cases do an implicit move. No std::move required.
This is why I prefer to deal with Rust instead of C++. And this is just my commentary on specifically the behavior of moves in C++. When I pile on other issues - like copious undefined behavior everywhere (e.g. signed integer overflow, array out-of-bounds accesses), too many footguns that result in bugs and security vulnerabilities - I developed an extreme distaste for programming in C and C++.
If the author is reading: both complicated examples are the same.
Yeah, the second one is supposed to read `Apple&& Cat(Apple&& val) { return val; }` — but the return type's `&&` was omitted by accident.
I swear I was staring at these two examples for longer than I care to admit wondering if I was just blind or dumb or both.
It's modern C++ so, a little of both I think. 20 years of it and I still have people saying "I should work on my fundamentals" so it's totally fine to be a bit of both.
Sorry for that mistake! I fixed the last example. The post should make much more sense now.
You probably don't pay for a copy or move there as well.
->
You probably don't pay for a copy or move there either.
(I'm Flemish and made that mistake before).
Unfortunately, I don't think there's getting away from just understanding value semantics to get the correct and/or performant behavior.
> Unfortunately, I don't think there's getting away from just understanding value semantics to get the correct and/or performant behavior.
This is not specific to C++ though. It just so happens that C++ developers who feel this topic is important are those invested in performance optimization. For them, C++ offers them these types of tools.
Meanwhile, those who don't have a pressing need to go through great extents to optimize performance can simply fall back to the compiler generating most special member functions and then pay the performance tax of doing deep copies by default. For these cases, which I'd say corresponds to most C++ floating around, it's far more important to know the rules of when to define our own custom constructors and assignment operators.
> pay the performance tax of doing deep copies by default.
For most code that performance tax is not worth worrying about. There are almost high performance priorities. It is almost always the case that your code runs "fast enough" long before you start worrying about the few nanoseconds a deep copy of a few bytes costs.
> For most code that performance tax is not worth worrying about.
Indeed, I agree. It's possible to go a long way in terms of performance with a basic understanding of passing by reference, without even having ro bother with move semantics. So these topics end up being dominated by language lawyers and the types that enjoy debating "aktualy" topics, who also contribute to making things sound far harder than what they actually are by pretending that this sort of trivia is very important stuff.
> So these topics end up being dominated by language lawyers and the types that enjoy debating "aktualy" topics,
I'm not sure about that.
Even though most people never need it, a small minority really do, and those types have to become expert in those weird details. Well you pretty much have to be a language lawyer to get these optimizations right, but the goal really is the ultimate performance in some place where it really matters.
What is that makes NVRO so much more difficult to implement? Why couldn't they mandate that just like RVO? Do compilers literally just special case a simple return statement of a direct construction or something?
The simple cases are simple. However the complex cases get hard.
Is going to be much harder because you don't know are compile time which is returned and so cannot construct the one you return in the correct place. That is just off the top of my head, I'm not a compiler writer, I'm sure they have figured out the simple versions of the above, but you can start to see the complex versions that they can't.That one is not too hard either. `one` simply can't be NRVO'd as there is a runtime path in scope of it that doesn't return it. `two` can be (unless you hide some other return within ...).
You can structre the code differently to get around that. There is a real desire to use NRVO "one" in cases when it is returned as well, if possible.
The point of (N)RVO is to directly construct the return value in-place at the calling frame. Which requires knowing what object will land there.
In RVO there is no problem because you know what object is the one you need to put there.
In NRVO there is a problem because you might have one of multiple objects being returned and you need to know which one to construct at the call site; it can't be all of them on top of each other. But you don't necessarily know at the time of construction whether that object will be the one that is actually returned. Doing so requires imperfect code analysis so the standard would need to define the complicated analyses to perform.
RVO is easy to detect since it happens only in expressions in return-statements.
NRVO requires the compiler to analyze the flow, like if 2 different variables/constructions can lead to the return (what one do we take, or can we do either later?).
Also, with RVO it's easy to detect and elide destruction calling for things going out of scope whilst NRVO would require more careful management of destruction order,etc.
Basically, NRVO touches a lot of things in "inconventient" places that can easily require reworking internal compiler structures to track destinations whilst RVO was probably far easier to just "hack in".
I figured any half decent compiler already do plenty of flow and liveness analysis on everything for register allocation, dead code elimination and what not.
Maybe it's the guaranteed elision that makes it a problem, like you can't fail the analysis, but then maybe you go the rust route - fail to compile and urge the programmer to rewrite their code so it accepts it.
Make it opt in with [[must_elide]] so old code still works I guess.
Register allocation is usually on a far "lower" codegen level as is often DCE, they should be possible to compute/run on a SSA node level or similar long after destruction sequences are applied.
Now, there is far more "language level" flow analysis today apart from this as required by allowing auto type inference in more places (and things relaxed in relation to that). Reading up it seems to be suitably done in Clang on the ClangIR(MLIR extension) level, something that sits between AST and the LLVM IR.
Regardless of how it's implemented, I'm pretty sure that NRVO carried a fair bit more complexity requirements compared to RVO depending on how prepared the corebases for different compilers were to handle it.
Despite its name, NRVO isn't an optimization performed by the optimizer, it's something done by the frontend of the compiler before it generates the code for the optimizer to run on.
The frontend is extremely reluctant to do anything like flow analysis, in large part because the frontend doesn't even really have any code to do the analysis on, just the AST. More people (including far too many on the committee itself) need to understand the separation between the different parts of the compiler, and what each part can and cannot do effectively.
They probably _were_, since lower level code representations often has little notion of complex types and their semantics they could be kept clean and focused on machine code, however type inference in a language like C++ complicates such matters immensly since an assignment can be both a register move and a function call.
Template resolution solved that in the past, but C++ today allows auto in so many places that I'm uncertain that it can be done without some flow based support (if constexpr comes to mind).
I mentioned ClangIR(MLIR) in the sibling comment here, feels like it was built for stuff like this.
> Template resolution solved that in the past, but C++ today allows auto in so many places that I'm uncertain that it can be done without some flow based support (if constexpr comes to mind).
C++ requires determining the type of every expression immediately. Even auto doesn't change that: it determines the type of the variable based on the initializer, literally following the same rules as template argument deduction (there's a little bit of patching to tweak the exact expression being used for deduction, but https://eel.is/c++draft/dcl.type.auto.deduct#3 is the core rule here).
ClangIR doesn't necessarily help here, because while it does give a more abstract C abstract machine IR semantics, it's still downstream of things like NRVO decision points--it's still fundamentally past the codegen-the-AST barrier.
> Template resolution solved that in the past, but C++ today allows auto in so many places that I'm uncertain that it can be done without some flow based support (if constexpr comes to mind).
This concern is unfounded. The auto keyword in C++ acts as mere syntactic sugar. It works only when the compiler is able to tell exactly what's the type by evaluating the expression.
The auto keyword is also considered a code smell for the same reason: just because the compiler can tell exactly what the type is expected to be, that does not mean the developer can. Therefore it makes the code harder to reason about.
> The auto keyword is also considered a code smell for the same reason: just because the compiler can tell exactly what the type is expected to be, that does not mean the developer can. Therefore it makes the code harder to reason about.
This really depends though. In many cases even the programmer can tell the type of auto because its on the very same line and not using auto would mean needlessly repeating it.
In other cases (e.g. iterators) the programmer also doesn't need to care about the concrete type.
GP was possibly arguing against Herb Sutter's "almost always auto". C++ wasn't designed for auto and it shows. You can't rely on it always doing the right thing or at least a safe thing in C++, unlike in Rust - I've seen bugs due to auto. It is also often helpful to spell out the concrete type in important places such as (most/many) variable definitions.
I'm also fine with auto if it just repeats information, especially so if the concrete type takes half of your line length budget (yes it's an iterator over that container containing...).
Yeah, when auto first came out a few people started abusing it everywhere making for unreadable code. Most developers settled quickly on much better rules for using auto that do not destroy readability, and never abused it in the first place. Auto is intended for and works very well to avoid typing very long types where you often never care.
Iterators is the common example: the type name is always long, and nearly always used in a context where it is obvious. Even if you do care about the type, looking up the iterator is wrong - you always first look up the base type (ie std:vector) first to understand the iterator, only rarely do you need to dig into the iterator once you understand the base type.
Generic code (template and non-template) are the other - they type could be anything, so trying to specify more detail isn't going to gain you anything.
N/RVO works by (at the machine language level, of course) rewriting the function signature to return void and take an extra pointer parameter, which is written to before returning. If you're returning a newly-constructed object, the compiler can rewrite that into calling the constructor on the pointer, but if you're returning a named object, the class may have a non-trivial destructor that needs to run after the move, such that it's not possible to rewrite uses of the local object into uses of the pointer.
I'm not too confident on that last part, because such an implementation would mess with semantics in case of an exception, so anyone feel free to correct me on that.
I'm sorry, this comment is completely wrong.
NRVO does not affect the ABI of the function. It cannot affect the ABI, for whether or not it kicks in depends on the body of the function, and affecting the ABI would make it impossible to use it if only the declaration appears in a header.
The correct explanation is this:
In C++, classes with nontrivial destructors or copy/move constructors are considered nontrivial for the purposes of calls and are passed via pointers rather than via value. By passing via pointer, the class has a stable address and thus 'this' pointer. Returning such a class means the caller allocates the storage for the class on the stack before calling the function, and passes the pointer to that storage to the function as an extra parameter. This is based solely on the definition of the class itself; this happens whether or not NRVO kicks in.
Usually, when you declare a variable, the abstract machine of C++ requires you to construct a new object and call the copy/move constructors or assignment operators and the destructors at various times as appropriate. With nontrivial versions of these special functions, it is possible to observe whether or not they were called (these things still happen with trivial classes, but it's not so easy to observe). Returning a value requires constructing the storage space for that object--with all the attendant abstract machinery that involves.
What NRVO does is to say that, under certain conditions, rather than constructing storage space for a given variable that is normally required, the storage space that is allocated for the return value by the ABI is used instead. In essence, you are promoting a given variable to the return value hence the name 'Named Return Value Optimization'. What makes this annoying to implement is that you have to track at the AST level, before doing any code generation at all, whether or not a given variable is eligible for NRVO, and then use that information to control the code generation for allocating storage space.
Despite its name, NRVO is not actually an 'optimization' in the compiler. The optimizer plays no role in it, since the optimizer is fulfilling the requirements of the abstract machine. Instead, it is a set of conditions that allows the frontend to omit calls to copy constructors, etc. under specific circumstances.
> Despite its name, NRVO is not actually an 'optimization' in the compiler. The optimizer plays no role in it, since the optimizer is fulfilling the requirements of the abstract machine. Instead, it is a set of conditions that allows the frontend to omit calls to copy constructors, etc. under specific circumstances.
That's semantics. The way the compiler is structured and in which component the transformation is implemented has no bearing on whether something is an optimization.
[flagged]
> N/RVO works by (at the machine language level, of course) rewriting the function signature to return void and take an extra pointer parameter
This sounds wrong, are you sure? Would you mind demonstrating with an example on godbolt? Whether NRVO applies or not, the ABI should be the same, AFAIK.
Yes, it works exactly like this, this is a demo on godbolt [0]. rdi stores the pointer in both cases, makeS1() uses RVO, makeS2() takes it explicitly and constructs with placement new.
I will say before testing this i didn't realize the RVO calling convention was to return the pointer you pass in, but apparently so. If makeS2() returned void, it's just a tail call to the constructor, but makeS1() has to spill rbx and use it to save the pointer.
[0]: https://godbolt.org/z/ovd1n99P8
No, all you're showing in that example is that a pointer is passed as part of the ABI. You're not showing that RVO relates to that in any way whatsoever. If you write the same function in a manner that (N)RVO can't kick in, does the pointer no longer get passed?
The reason this should sound dubious is that you're suggesting the caller needs to know the callee's body in order to know how to call it, but it should be possible for the two to be compiled entirely independently, and in fact mutual recursions should be fine too. After all, the callee knows where the return value has to land either way, and the caller similarly knows where to expect it, regardless of when/how the object is constructed or destroyed.
Fair enough, I don't know the C++ ABI to this extent.
Yes, of that I'm sure. This optimization is only possible if the compiler has control of both sides of a call. If the function may be callable from other translation units or modules I imagine it generates a thin wrapper that's externally callable.
I don't know why I wrote the comment about parameters earlier -- (N)RVO is about return values. Those have a different reason for being passed behind a hidden pointer: the class type might have self-referencing pointers, so there must be an explicit move/copy constructor call whenever it changes address, to give the class an opportunity to update those pointers. This cannot work when returning in a register: the callee doesn't know the target address, and the caller doesn't the source address, so neither can call the move constructor. Thus, all ABIs must pass a pointer (or let caller+callee agree on a memory location in some other way) for types that aren't trivially copyable.
The optimization is often possible even if the computer does not see the call, because most (all?) ABIs have always required hidden pointer parameters for class types with non-trivial destructors.
https://godbolt.org/z/9WvnEvEYh Note how `std::unique_ptr<int>` effectively passed as a `int**`; and that the by-value unique_ptr is not destroyed at the end of the function -- destroying parameters is instead the caller's job (and commonly only happens at the end of the full expression containing the call -- though this choice is implementation-defined). But that can only work if the caller can see the updated value of the parameter (to avoid double-free for `clear`) -> thus the need to pass the parameter by hidden pointer.
>rewriting the function signature to return void and take an extra pointer parameter, which is written to before returning
This is completely unrelated to RVO. Every non-trivial class is returned via pointers to caller-allocated storage under the Itanium ABI. Period.
The problem is that "predictable reliable NRVO" is still a research problem. Real-world compilers do NRVO a lot but not in a way that is perfectly predictable — that is to say, not in a way that could be standardized across all compilers (or even between different releases of the same compiler).
A "perfectly predictable" algorithm was proposed in Anton Zhilin's P2025, back in the year 2021: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p20... but unfortunately it had some subtle corner-case problems (which I do not remember), so it was sent back for revision, and never returned with a fix. (Maybe because a fix wasn't possible; again I don't remember what the deal was exactly.)
The Right Path Forward would be for MSVC, GCC, and Clang all to try implementing P2025's algorithm in their front ends. Either something concrete breaks (reminding me what the problem was), or else all three mainstream compilers gain predictable NRVO and then we can "standardize existing practice." But the Right Path Forward requires tedious work by at least three people, which is hard.
> What is that makes NVRO so much more difficult to implement?
I recall reading that at a high level RVO is implemented by treating the return value as an external object. In simple terms (simplistic terms) RVO then works by
- first instantiating the return variable,
- passing the var by reference to the function,
- and then use return value to actually initialize the variable passed by reference.
The moment there's some funny logic on what to write to that output value, the problem gets far more complex.
- stupid question, since we are on the topic of c++, i finished reading through learncpp.com
- how do I take this from 0 to the guy that builds a play station 3 emulator?
- like seriously what kind of step by step projects or learning experiences in increasing order of difficulty do you recommend to get really really good at c++
The fun thing about C++ is that it’s really hard. The even more fun thing is that the domain knowledge behind is even harder - game engines, high performance clusters, emulators, real time simulations, rendering engines, high frequency trading - so more often than not most C++ devs commit to learning a language subset and then focus their energy on the task at hand. A PS3 emulator might be a switch statement (not really, but you get the point)
> The fun thing about C++ is that it’s really hard.
C++ definitely has a higher cognitive load than your average interpreted language. However, a big part of that cognitive load is due to support for highly optimized scenarios such as return value optimization. Most people write code that doesn't require it at all, and that's perfectly fine. The same goes for move semantics. You can spend a whole career writing code where all your classes do deep copies when passing objects by value. The compiler even graciously helps you not have to write all constructors and assignment operators to have your POCO class work out of the box. But once you feel the need to avoid those copies then you need to know what to do to nudge the compiler do that for you. That is a while subtle dance. That's when the cognitive load starts growing.
The hardest part of C++ are it's lifetime rules, which are in fact extremely difficult to grasp. There's essentially a unique(ish) ruleset for each class of object and misinterpreting one of them (no matter how obscure) can easily cause UB.
see https://en.cppreference.com/cpp/language/lifetime
I would recommend reconsidering the "PS3" part. I had to revisit a codebase that supported the beast more than a decade ago recently, and I was in shock at how weird the PS3 bits were. As for "really good", I would dodge that since I have only been using C++ for like 25 years =)
> like seriously what kind of step by step projects or learning experiences in increasing order of difficulty do you recommend to get really really good at c++
I'm not sure if you're personally interested in this, but the main way I learned is by writing my own game engine (Casey Muratori's Handmade Hero-style) and writing mods for games (even by reverse engineering). It actually teaches you almost everything you need to know both from a language perspective AND it forces you to understand each individual step of how a program works (input/audio/networking/graphics/logic). You can go step-by-step mastering each thing as you go.
There's no simple universal roadmap for becoming competent. You just need to grind out whatever skill you're trying to master as much as possible. 10000 hours is the common number thrown around, but plenty of programmers have been doing it for more than that and can't even tell you how an emulator is supposed to work.
So to answer your question: work longer and harder than the people who don't know how to write a PS3 emulator (especially those who think they can take a shortcut with AI)
Getting very good at C++ takes more than a decade. There are no shortcuts. You will have to start by getting in the trenches and write some code.
> ... and write some code.
This but replace some with more. Write more code! Programming is like any other discipline which requires constant training and exercise. Doesn't matter if its Jujutsu or C++, you must train and become well versed in all the aspects of the craft.
You don’t need to be really good at cpp to write a crappy emulator. All the “really good” stuff is about eeking out performance. If you want to build an emulator start with a qt gui and go from there. By the time you’re done you’ll be good enough
I'd push back slightly on move — at small scale the opposite has been true for me.
Not sure what you mean, but std::move is one of the greatest tools in C++
This is one case where Rust benefited from C++’s experience — move by default with opt-in clone/copy is IMO the better setup.
This is an easy mistake to make but it's not what happened
Programmers already knew (~20 years ago) when the C++ move feature was designed that what people want is the destructive move assignment semantic, the thing Rust has today. Other languages did have that. But C++ 98 already existed and WG21 already did not want to make it difficult to take your crusty 10+ year old C++ codebase, slap a sticker on it and say this is "Modern C++"
The C++ "move" proposal is slightly sneaky, it admits that what they're proposing is not the destructive move (again, people know they want this) but it gives the impression that if they really want destructive move they can add it later, without revealing what's really going on underneath.
In fact C++ move is roughly what Rust would call core::mem::take, we move something in the usual way (a destructive move) but then we replace it with some value of the same type, in the case of core::mem::take it's Default::default()
To enable this, C++ is full of types which look superficially familiar to a Rust programmer but have a weird "empty" state to provide that default value where none would make sense. For example std::unique_ptr<T> looks like it's Box<T> but it's not, it's actually Option<Box<T>>, even newer types often do this but they might be more embarrassed about it.
[Edited to clarify timeline]
> For example std::unique_ptr<T> looks like it's Box<T> but it's not, it's actually Option<Box<T>>, even newer types often do this but they might be more embarrassed about it.
This would be the case even with destructive moves unless you also add some way for std::optional<std::unique_ptr<T>> to be no larger than a pointer by letting std::optional take advantage of the fact that a non-null std::unique_ptr has a bit representation that leaves room for sentinel values.
Also, at the language level, C++ moves are sort-destructive as the moved from object only has to guarantee to be able to run the destructor. E.g. you could still have a std::nonull_ptr where move sets the internal pointer to zero but calling anything except the destructor on such an instance throws / calls std::terminate() / is UB. It's only the stdlib types that make additional guarantees - because in most cases it can be done without additional cost.
[dead]
And the most important idea: destructive moves. Since C++ doesn't track lifetimes it has to leave the object in a "valid state" after a move and the destructor still runs which has to have a check if it should do something or not.
BTW, Rust's lifetime annotations for borrowed references are a mostly orthogonal feature.
Liveness of objects for move/drop semantics is tracked differently, without any syntax and with implicit runtime drop flags where necessary.
C++ could probably add the same deinitialized/moved-from state tracking (with an opt-in for back compat sake) purely to avoid dtor bloat, without having to add safety of borrow checking.
> C++ could probably add the same deinitialized/moved-from state (with an opt-in for back compat sake) purely to avoid dtor bloat, without having to add safety of borrow checking.
There is a lot of talk in the C++ committee about this. The details are complex in some obscure cases.
At the very leas you'd also need to fix the caller-destructed ABI mess to get a 100% solution.
[dead]
While not the common case, C++ move semantics match the situation in systems code where correctness requires decoupling logical object lifetimes and destructors. The object is logically dead but the destructor may be deferred indefinitely for safety reasons.
Most code doesn't have the shared memory setups where deferred destruction is necessary.
But the compiler won't necessarily generate any code for the destructor:
https://godbolt.org/z/PMando5x4
It did for sure, but the problem with C++ is its heritage, specifically that structures can be self-referential. For instance, the Rust's url::Url type has to use usize offsets for tracking the location of each of its components. Conversely, in C++, someone could have already created a similar Url type that would use std::string for the buffer and char pointers for the component locations. As such, you cannot simply memcpy from one struct into another and forget the former as std::string could have its own in-place storage and that would invalidate all pointers - you'll need to define a move constructor instead.
std::move is a great tool when used correctly. However used incorrectly it makes code worse: more verbose and less performant. Since I have no idea how you are using it I can't comment on your experience. My experience is people (including me!) get it wrong fairly often. Fortunately tools can detect a lot of cases where you get it wrong.
My little trick is to think of them as "oh, I accidentally made an lvalue from an actual rvalue here because a name was introduced, so I need to cast (i.e move() or forward()) back to an rvalue again", that's why i have them as macros: MOVE_CAST and FORWARD_CAST defined as static_cast (also avoids blowing up compile times). I never think in terms of "moving this object".
Thanks Claude.
Every time I run across yet another one of these “let me explain C++ move or copy semantics to you” I thank my lucky star that I am using C# in my day to day work.
The HN title is incorrect. It should contain std::move.
Title made me think they’d just show that move is just a simple cast, nothing more
My rule is: `std::move()` is for (lvalue) arguments. We've had RVO for log enough now. Besides, my `-std` is always ≥ 20.
[dead]
Kudos to the author for explaining these concepts, but I personally find this to be a poor use of my time and mental capacity.
I dabbled in C++ programming before Rust 1.0 was released (year 2015). I have spent some time understanding things like RVO, std::move(), rvalue references, T&&, move constructors, and so on. This was the main tutorial that I read a decade ago: https://web.archive.org/web/20240108142848/http://www.thbeck...
I began programming in Rust in 2017, and it is such a breath of fresh air. It has all the power of C++ but shed all the unnecessary baggage (e.g. confusing features, duplicate features).
In relation to this article, I like Rust's clear and simple semantics about moving objects. If x and y are variables of type T which implements the Copy trait, then `x = y;` performs a simple bitwise copy. Otherwise, `x = y;` moves the object `y` to `x` and `y` is no longer allowed to be accessed ("moved away"). Whereas in C++, the article states how copy elision behavior has changed over the years:
> You get guaranteed copy elision since C++17 in the following case [implying it wasn't guaranteed before C++17]
> Then you have named return value optimization (NRVO): [...] The latter is not subject to guaranteed copy elision. You probably don't pay for a copy or move there as well.
> While this code compiles, you will get a copy construction of the return value before C++20.
> Once you switch your compiler to C++23 mode, both cases do an implicit move. No std::move required.
This is why I prefer to deal with Rust instead of C++. And this is just my commentary on specifically the behavior of moves in C++. When I pile on other issues - like copious undefined behavior everywhere (e.g. signed integer overflow, array out-of-bounds accesses), too many footguns that result in bugs and security vulnerabilities - I developed an extreme distaste for programming in C and C++.
> I failed to understand something, but it's everyone else's fault