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.
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.
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?
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.
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.
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.
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.
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;
}Because it'd break code. I explained in another comment here: https://news.ycombinator.com/item?id=19634423
Then that's an indictment of the decisions that led to such code being written in the first place.
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.
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.
Replacing it with defer or GC will surely break existing programs.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The better programs and libraries use them less. It remains to be seen whether this aesthetic will win out.
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.
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
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.
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).
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.
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.
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...)