Hacker News new | past | comments | ask | show | jobs | submit login

Excellent explanation. Too bad it's necessary. && and std::move are the biggest warts in C++ (that's saying a lot), representing the need for programmers to be constantly aware of the less-than-obvious ways that a compiler might put an expression in one category or another. It's the programmer helping the compiler, instead of the other way around as it should be. In general, if I need an rvalue and it's legal to convert the lvalue I have into an rvalue, the compiler should do it automatically. Having to make it explicit should be the exception, not the rule. The compiler does many other kinds of inference and automatic promotion/conversion, some of them far more difficult (and dangerous). Why not this one? I suspect the reason has less to do with the actual ergonomics of producing correct programs than with the the interests and self-image of those creating the standards.



It's not what what you're asking is unreasonable in concept, but that it goes against how RAII is used in C++; it'd make more sense in a new language. Specifically the issue is that despite its name, RAII isn't merely used for resource acquisition, but also to define dynamic scopes. That means destroying an object too early (which can happen if an object is automatically moved) can cause a subsequent statement that doesn't syntactically depend on the variable holding that object to see the wrong program state. So if your compiler moved objects automatically, that'd break code that uses RAII as a dynamic scoping mechanism.

That said though, it might be cool to have an [[attribute]] for classes (maybe [[automove]]? or [[resource]]? or [[functional]] or [[dataflow]]??) that lets you declare that that a class's construction and destruction (or maybe all members... ) can be assumed to have no side-effects for clients to depend on, or something like that... if they can work it out that'd be cool. Ideally it'd mean that an automatic variable of that type can be destroyed following its last dependency in any given scope.


> it goes against how RAII is used in C++

That's an excellent point. To be honest, I think it's a problem with how RAII is used, conflating execution scope and object lifetime in a way that makes "move" a bit of a mess. That's why I prefer explicit "defer" like some languages have. Nonetheless, it's a common and useful enough idiom that breaking it is not something to be taken lightly. Maybe it's not really a solvable problem, given the paths that C++ took years ago, but I'd say "too bad it's necessary" is even more applicable if that's the case.


> I think it's a problem with how RAII is used, conflating execution scope and object lifetime in a way that makes "move" a bit of a mess.

It seems to me that there's a subset that is just fine. Use RIAA to, for example, take a lock and automatically release it when you leave a scope. That's not a problem, in itself. And go ahead and use move semantics, too. That's also fine.

But don't use move on an object that's doing RIAA to take and release a lock. That, in my view, is not much of a restriction, because if you think about it, that's not really a reasonable thing to want to do. That RIAA lock-taker is not the kind of object that you want to move, or even copy. Explicitly disable those actions (private copy constructor and operator, private move), and you should be good.

Or have I missed something?


> if you think about it, that's not really a reasonable thing to want to do. That RIAA lock-taker is not the kind of object that you want to move, or even copy.

It actually is (at least re: moving). Tying lock acquisition to object lifetime is a Good Thing (TM), and if you wanted to extend or shrink the lifetime of that lock you should be able to do that by moving the RAII object, the same as how you could do it with a shared_ptr or whatever.


> don't use move on an object that's doing RIAA to take and release a lock.

Absolutely. If the question is "what would I do in C++ today" then my answer is that I would use RAII subject to common-sense rules like this.

> Explicitly disable those actions (private copy constructor and operator, private move)

That's a decent enough solution within the context of C++ as an unchangeable thing, but I want people to look beyond that context. C++ has never been an unchangeable thing. Programmers shouldn't have to take such extra steps, so closely tied to how the language works that year, to get a conceptually simple result. That's how bugs creep in, and how even correct code becomes drudgery. We have no choice but to accept it now, but we shouldn't accept it permanently.


Automatic moves don't imply that you can't use RAII to define dynamic scopes. In fact it's the reverse- moves give RAII much more power and flexibility around scopes. The problem, instead, is that C++ has no way to determine whether later statements depend on moved-from variables.

To a first approximation, this could be solved using the same analysis that powers "use of uninitialized variable warnings." In the general case you need something more powerful, like the C++ Core Guidelines lifetime checker (https://herbsutter.com/2018/09/20/lifetime-profile-v1-0-post...). This means move operations end scopes just like `}`s do, preserving both RAII and the compiler's ability to help the programmer.


No, lack of good code analysis is not the problem. If the compiler was to only do a move in cases where it could prove variable independence then it would be operating under the as-if rule -- which it is already allowed to do -- and hence your code wouldn't behave any differently than if it hadn't done that. Automatic moves OTOH change the semantics of the code, which is kind of the point -- the compiler can't do an automatic move without permission unless the language specification says so, and unlike with the case of copy elision, it doesn't permit the compiler to change the semantics like this, as RAII has multiple use cases as I mentioned above that would break if this was permitted.


Yes, we're discussing an alternative design here. An automatic move should change the semantics of the program, rendering later uses invalid.

This does not break RAII- it merely changes the defaults. It should be copies that are marked in the program source, not moves- this is unfortunately not backwards compatible, but it's also not a problem for how RAII gets used.


Dynamic scopes are not available in C++. I suppose them as in languages like Emacs Lisp. I get the point, though.


> In general, if I need an rvalue and it's legal to convert the lvalue I have into an rvalue, the compiler should do it automatically.

This is already done in some places. Example:

    std::unique_ptr<int> get_int() {
        auto p = std::make_unique<int>(1);

        // `p` is an lvalue but treated as an rvalue in the return statement.
        // (This would not compile otherwise because `p` is not copyable.)
        return p;
    }


Yes RVO is one case where the compiler will do it automatically.

I think copy initialization of an object will have the same will apply copy elision as well to result in the same performance, but I'm not entirely sure.


That is the right approach IMO. What I'm basically saying is "more of this". It's clearly possible for C++ compilers to do this in many more cases. They already do most of the hard parts just to produce some of the error messages that they do. Why not use that knowledge more often to help the programmer instead of burdening them?


Because the compiler can't always know when moving a value is acceptable.


> It's clearly possible for C++ compilers to do this in many more cases. [...] Why not use that knowledge more often to help the programmer instead of burdening them?

Because it'd break code. I explained in another comment here: https://news.ycombinator.com/item?id=19634423


> Because it'd break code.

Then that's an indictment of the decisions that led to such code being written in the first place.


Only if you're quick to judge without understanding why such code is and will continue to be written.


You're pretty quick to assume I don't. Believe me, I understand. I just don't agree that it's a good idea to mix up object lifetimes and execution context by using destructors to "magically" release locks etc. It never was. The mistakes were made years ago.

I'm not quick to judge (in this case). I'm judging after careful consideration, because I know the difference between good and bad patterns. The ones being hasty are those who mistake their own comfort level with something (often because they know nothing else) for actual merit.


RAII is one of the core language feature of C++.

Replacing it with defer or GC will surely break existing programs.

It would make sense ONLY if you want to have a new language.


> It would make sense ONLY if you want to have a new language.

Are you seriously suggesting that C++ with defer is a new language? Unlike, say, C++ with lambdas? That's just silly.


C++ without deterministic destructors to manage lifetime would be a (fundamentally) different language.

Furthermore, also disagree with you that it would be a better language. C++ has many warts but object lifetime and RAII is amongst its strong suits (unarguably, I thought — yes, resource lifetime is complex, but it’s inherently so; C++ just makes the complexity explicit and handles it in a good way). Handling resource lifetime in languages with nondeterministic GCs can be such a pain that it has fundamental, detrimental impact on the architecture. Just look at .NET’s handling of `Dispose`. I’ve written a ton of .NET GUI code, and handling resource lifetime (in particular GDI+) is an absolute pain point, which is uniquely caused by the lack of deterministic object lifetime.


> C++ without deterministic destructors to manage lifetime would be a (fundamentally) different language.

The problem is that it's not usefully deterministic once you add in exceptions, shared pointers, move semantics, lambda captures, etc. Not to mention every perverse combination of those things. Yes, it's deterministic in the tautological sense that almost everything is deterministic given enough information, but I'm not sure that's enough even for the people who write the compilers. Even they have bugs related to misunderstanding this "deterministic" system. I certainly wouldn't want to make it less deterministic, and defer certainly doesn't make it so.

> resource lifetime is complex, but it’s inherently so

A certain amount of complexity is natural, a certain amount is spurious and self-inflicted. See above for some of the causes of that spurious complexity.

> C++ just makes the complexity explicit

Being explicit about memory management isn't a goal. C is even more explicit about these things. Does that make it a better language? Even in C++, new/delete is more explicit than most of the current idioms, and every book on modern C++ recommends against them. Explicit memory management should be a last resort, for the cases not handled cleanly by the language's other constructs, and those should be kept to a minimum. C++ has notably failed at that. Literally every other popular language except for C does better.

> Handling resource lifetime in languages with nondeterministic GCs can be such a pain

Do you really see no alternatives between dumping all memory-management complexity on the programmer and a full tracing GC? The authors of Objective C's automatic reference counting or Rust's borrow checker might take issue with that, as would the people who developed the memory-lifetime rules and infrastructure for all of the bigger older C codebases I mentioned in another comment. It is in fact possible for object lifetimes to be far more deterministic than in C++ as it exists today, and I for one think that would be a good thing.


> The problem is that it's not usefully deterministic once you add in exceptions, shared pointers, move semantics, lambda captures, etc.

No, it really is, even in the presence of the things you mentioned. That’s the whole point.

> Being explicit about memory management isn't a goal. […]

I get the impression that you’re confusing explicit and manual memory management. They’re not the same.

> Do you really see no alternatives between dumping all memory-management complexity on the programmer and a full tracing GC?

Of course I do, but the alternatives aren’t without their own problems. I’m curious how Rust will fare but Objective C’s ARC, while attractively simple, has performance implications, and then there’s the problem with cycles.


Rust has ARC and RC, which I gather are badly overused by refugees from other languages dependent on GC.

The better programs and libraries use them less. It remains to be seen whether this aesthetic will win out.


> The problem is that it's not usefully deterministic once you add in exceptions, shared pointers, move semantics, lambda captures, etc. Not to mention every perverse combination of those things.

It really is though, and I can't stress that enough. I feel you just throwing out keywords to make it sound more complex than it is.


> I can't stress that enough.

You can stress an untrue statement all you like. Shout it to the heavens. It will still be untrue.

Thought for you to consider: the behavior might seem predictable to you but that's not a relevant standard. A chess opening, a volleyball play, a snowboard run might all seem straightforward to me, but that doesn't mean they'd suit everyone. It doesn't mean they're the best. It just means I've invested my time in learning to do those things those ways. It's too easy to say everyone should have to memorize the same lists of rules, to retreat into "we don't care about the blubs" arrogance, ignoring the fact that it just doesn't have to be that way. It is in no way necessary, for any purpose, to make every single programmer in a language spend so much of their time looking over their shoulder to make sure the compiler is doing the right thing. It's a waste no matter how good those programmers are.

> I feel you just throwing out keywords

And I feel that you're just not even trying to understand their relevance because you've already decided on a conclusion. The lengths to which people in this thread go to rationalize the time they've already wasted is astonishing. People who use C++ should be the first to demand its improvement, but I guess not all humans are rational.


You might have picked the wrong hill to die on.

C++ destructors really are deterministic, even in the face of exceptions, lambdas, and moves: everything constructed gets destroyed. Modern wrinkles where the compiler is allowed to skip a construction and a destruction do not contradict that. You have to fool with heap memory or evoke UB to escape that law.


To be useful, determinism has to mean not only that something happens but that it happens at a predictable time. Otherwise you're into that tautological "everything is deterministic" territory, and you step into that even deeper when you acknowledge the "modern wrinkles". "Compiler is allowed" (but not required) is practically the definition of non-determinism.


"Compiler is allowed" not to destroy what it never constructed.

This is no different from the myriad other elisions modern compilers do -- and that CPU cores do.

Deterministically, destructors run exactly when the constructed object goes out of scope, after objects constructed later, before those constructed earlier. More determinism than that is something you will get from nobody.

You don't have to write any constructors at all. Write ordinary functions, and call them whenever you like. Been there, had enough of that for several lifetimes. Destructors are better.


The behavior is predictable to anyone versed in the language. C++ is a complex language to learn so that is inherently harder than most alternatives, but the concepts here are not.

I am first in line to acknowledge the flaws of C++ as well as sheering on more modern approaches. But I do find your criticisms shallow and a quite oddly chosen.


> The behavior is predictable to anyone versed in the language.

Ahh, there's that "don't care about the blubs" arrogance again. Never mind that your interlocutor is about 99.9% likely to be less of a blub than you are. Whether behavior is predictable given arbitrary amounts of information and effort is not the point. What matters is how much it distracts the programmer from the non-language-specific problem they're really trying to solve, and the answer for C++ remains way too damn much even when the programmer is highly skilled and well versed in the language.


You know you reek of it yourself.

C++ and even C has tons of subtle edges that are impossible to keep track of. You need to be experienced to have a fighting chance, I'm not defending that. But it is a fact of life and in a large part of that are relics from the past.

You can't just take a concept that is somewhat unique to C++ and proclaim that it is bad just because C++ has a lot of warts. Your criticism doesn't even register on the weirdness scale in my opinion. It works as intended, is easy to reason about and solves practical problems. Object lifetimes is something I almost always miss when I don't use C++. A garbage collector is hell to work with in comparison.

What is unfortunate is that we don't really have any alternatives. Rust shows promise but we've had to suffer through decades to even get to this point, which also implies that we have decades left. And that assumes that rust continues in the pace it has and preferably also that we get more alternatives to chose from.


Your proposed better pattern to follow when one needs a finally block in C++ is...?


I already mentioned "defer" as in Go or Zig. It's explicitly tied to scope exit, not object lifetime, so it avoids all of the problems inherent in confusing the two.


I didn't ask what language you would use. I asked what you would do in C++, given you're criticizing people for writing such code in the language. If you're put so much thought into this problem like you claim then surely you must have a better alternative in mind.


I don't care what people "would do" in C++ today, because my whole point is that C++ started down this bad path years ago. I'm not criticizing people for writing such code. I'm criticizing the standards-makers who made such hacks (seem) necessary. It's not about having put thought into it either. Lots of people had put plenty of thought into it when the various versions of C++ were standardized. This is about making the right choices from among the alternatives available, and that was not done.

Demanding a solution that is both applicable to C++ as it exists today and yet not in C++ today is demanding two contradictory things. It's demanding that the same thing both did and didn't happen. It's dishonest. You want a suggestion? Adopt "defer" for the next version of C++. It's the best we can do. We can't change the past, but we can learn from it if we don't get stuck trying to excuse past mistakes and attack those who point them out.


suppose you have a shared ptr and you pass it to a function with overloads for move and value. what should the compiler deduce? it can't tell if it's legal. it can't tell if it's illegal

I agree this is a nasty thing to rely on, but id rather be verbose and know what's happening than have more deduction guides/similar


If the compiler can't deduce what to do or whether it would be safe then sure, it should require the programmer to disambiguate. The keywords and underlying mechanisms should exist, just as with type casts and such. However, I see a lot of cases where what the intent is obvious and the result obviously safe. Often the compiler even manages to identify the one reasonable answer, but instead of applying it or at least presenting it to the user in readily applicable form it throws an error with the answer converted into standards-ese and surrounded with a dozen lines of irrelevant context. That's not helpful.

I've worked on a lot of large old C codebases, mostly kernels and filesystems. Every one has developed its own way to handle issues of object lifetime, ownership, etc. I've seen dozens of implementations of automatic reference counting, borrowing, weak references, and so on. Some of them have been widely and rightly regarded as awful and a pain to use, but none of them were as bad as the mess that C++ imposes on a much larger audience. I see a lot of my colleagues submit to Stockholm Syndrome and accept it as the proper way of the world, but I'm not inclined to follow their lead without registering my objections first.


In general, if I need an rvalue and it's legal to convert the lvalue I have into an rvalue, the compiler should do it automatically. Having to make it explicit should be the exception, not the rule. The compiler does many other kinds of inference and automatic promotion/conversion, some of them far more difficult (and dangerous). Why not this one?

How do you even tell that it's legal to stick std::move around a variable? Here's a really simple example.

void test() { std::string s = ...; foo(s); bar(s); }

Can I change the last line to bar(std::move(s))? I can write a well-formed program that will misbehave, perhaps something like

char* global; void foo(const std::string& s) { global = s.data(); }

void bar(const std::string& s) { assert(global == s.data()); }

(Note that s.data() is not necessarily unchanged after std::move'ing the object, due to small-string optimization.)

This is contrived, but my point is that this is really tricky to do automatically.

Note also that when adding rvalue references the language committee had the additional constraint of not breaking existing programs. This makes things like automatic inference of temporariness even trickier.

The compiler does many other kinds of inference and automatic promotion/conversion, some of them far more difficult (and dangerous).

The C++ compiler does exceptionally few transformations that can change the behavior of a well-formed program. In fact, the only one that comes to mind is copy elision (aka RVO).


> The C++ compiler does exceptionally few transformations that can change the behavior of a well-formed program.

So all of those conversions between numeric, pointer, or string-ish types are just figments of my imagination? The rules for which constructor to use, which template or overload to apply, aren't a form of inference? "Auto" doesn't exist? Maybe there's some class of transformations and some definition of "well formed" for which your statement is true, but I can't imagine what those are.


Those are all things you asked to have. The topic was things you coded, but that the compiler did not generate code to do.

To be precise: by the object model, a constructor and destructor were supposed to be called. The compiler, by special dispensation, avoided calling them.

You may complain that the object model is whatever the standard says the compiler must do, in which case the statement would be vacuous: no transformations.


This is simply not acceptable because it would silently break too much code. Parameter passing in C++ used to mean copy, not move.

Perhaps a more important rule is that id-expressions should mean lvalues, not xvalues. Note that even expressions of rvalue references are not xvalues in such contexts. So, making something to-be-moved visually different from others is quite intentional.

Alternatively, to prevent use-after-move cases by additional syntaxes with typechecking rules (like Rust) can be a good idea, but it also does not work here. And C++ still lacks destructive move. (Note this has been considered at the very beginning of the design. Sadly it does not easily interact well with other features. See http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n137...)




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: