5 ms·
std::unique_ptr still suffers from The RAII Syndrome, where freeing memory, instead of being a constant-time operation, becomes linear, when one std::unique_ptr
by yakubin 5y ago
std::unique_ptr still suffers from The RAII Syndrome, where freeing memory, instead of being a constant-time operation, becomes linear, when one std::unique_ptr owns a struct, which owns other std::unique_ptrs and so on. Another problem with The RAII Syndrome is encouraging large numbers of microallocations, over allocating memory in bulk, increasing the number of places in your program where something may fail.
And although not strictly RAII, a related issue with the C++ model of constructors, destructors and whatnot (The Rule of N, where N goes to inf as the version of C++ goes to inf), is that when you use std::vector::push_back, and the std::vector needs to be resized, instead of executing a simple memcpy (or equivalent), move constructors for every element need to be called.
- tialaramex 5y ago> instead of executing a simple memcpy (or equivalent), move constructors for every element need to be called. But does that actually result in different machine code? If the compiler ends up concluding that you want to move all this contiguous data and by the as-if rule it does memmove() then the fact that notionally a bunch of other C++ code ran doesn't matter. I agree it's theoretically cleaner to be able to just say "You can move these, it's fine" about a type and know that optimisation falls out automatically, but the end result machine code may be identical.
- yakubin 5y agoFor it to produce the same machine code, a couple of things need to happen: 1. The move constructors need to be equivalent to a simple memcpy. This is often not the case. E.g. std::unique_ptr, which clears the source std::unique_ptr to null. 2. The compiler needs to either inline the move constructor to notice the optimization, or it needs to do inter-procedural optimization. Either way, it's unreliable. Sometimes it will happen, sometimes it won't. Sometimes it will happen in your current compiler version, but an update will come and it's not going to happen anymore. Generally, blindly relying on the compiler to optimize that is another case Sufficiently Smart Compiler[1]. Even if it happens, it's just not reliable. Chandler Carruth held a talk at CppCon once, where he showed how std::vector::push_back is optimized by Clang to a simple assignment and size increment, when it's preceded by an appropriate call to std::vector::reserve. The only problem was that when there were more than three calls to std::vector::push_back the optimization was turned off, so now not only did you pay the price of a function call (doesn't matter all that much), but also paid the price of increasing the number of branches that the CPU goes through (not that nice), because now on each std::vector::push_back, it needed to check whether it has enough capacity for the new size. Optimizations like that are very unstable. And I'm sure this is going to receive mixed reactions among HN readers (not Rust again!), but Rust, although still suffers from The RAII Syndrome, when it comes to its standard library, it doesn't suffer from the problem with the move constructors, because it doesn't have move constructors. It's move-by-default, as opposed to copy-by-default in C++, and the moves are provided by the compiler, they are all the same; they can't be customized on a type-by-type basis. Which means that a Vec::push in Rust doesn't have the issue I described that std::vector::push_back has. It will always compile to some variant of memcpy. [1]: <http://wiki.c2.com/?SufficientlySmartCompiler http://wiki.c2.com/?SufficientlySmartCompiler>
- ncmncm 5y agoIt is always irresistably tempting to claim as proof of virtue the products of what is really just good luck. Rust had the good luck to come along behind C++, but not too close behind, and so get benefit of hindsight to choose nicer defaults. I have seen people (even on HN!) earnestly assert that Rust originated the notion of language recognition for object ownership and lifetime, and move semantics—and even insist C++ copied those from Rust, badly. Rust's custodians are equally as bound by their unfortunate early choices as C++'s; sufficiently-aware readers will be able to list numerous cases where early choices are already regretted. Rust benefits from much more than hindsight: it had nothing to be backward-compatible with, beyond its C ABI subset, where C++ was bound by almost all of C's early-'70s choices. (Function prototypes (along with implicit struct copy, "//" comments, void, and const) were successfully back-ported from C++ to C, freeing C++ from need to parse K&R function declarations.) To a working programmer, history is void. Rust coders can luxuriate in its lucky defaults while sneering at C++ coders saddled with clumsy evolved syntax and backward-compatibilities. In their turn, old Rust coders will, if they are lucky enough, be sneered at by users of future languages that lucked into lessons Rust has taught by unfortunate example.
- tialaramex 5y agoThe existence of people who mistakenly believe that Rust is innovating is not at all remarkable, this happens all the time for technologies, and if you're setting HN as a high bar you are certain to be disappointed. Of course one thing you certainly can learn from other people's experience is that you're going to learn from experience :) Once upon a time Rust didn't reserve keywords for async and await. Once upon a time Rust's arrays weren't IntoIterator. But when students in 2022 pick up Rust it will seem as though Rust always had async and await and its arrays always implemented IntoIterator. Only if those students dig through old code will they discover that these things were once not true. Contrast C++ where the C++ beginner soon learns that there is a built-in array but it's awful and they mustn't use it. They might reasonably wonder why C++ hasn't fixed that. There are two related things going on here. First and most obvious Rust Editions, the Editions get to change the standard prelude (a set of standard library components which are exported to your program by default without action, such as std::result::Result::Ok which can thus refer to in your program as just Ok); add new keywords (which may not yet have a definition when added); tweak the syntax of the language and even make syntax that previously gave a warning into a hard error in the new edition. Secondly, and enabling Editions to be more powerful, the Rust language syntax was chosen to reserve room to improve and expand. Implementations are not licensed to just carve off potential future syntax for their own purposes. In particular many types of Rust token can grow to accommodate future needs. Once upon a time Rust's identifiers didn't have a raw syntax, which would have made new reserved words painful - so they grew one. This is furthered by the choice to have token-oriented macro processing. Even scary proc macros (which could in principle cause absolute havoc) in practice mostly just manipulate tokens, meaning they don't blow up mysteriously as a result of new library features. A fundamental change to token parsing is invisible to a macro that only wanted tokens anyway, while tweaks to C++ grammar will upset your pre-processor macros because those don't process tokens they just mangle raw text. I have no doubt that Rust will run into unforeseen (and perhaps unforeseeable) obstacles that render it less suited for a future some day than it is today. But I believe it is much better equipped to adapt than C++ was, and as we can see C++ managed to stick around anyway for decades.
- spacechild1 5y agoHow is that even relevant? What is the alternative? 'new' and 'delete'?
- symmetricsaurus 5y agoUsually std::unique_ptr or in a container like std::vector. Being too afraid of std::shared_ptr can lead to using references or raw pointers in the wrong places. That way leads to the usual memory safety issues.
- spacechild1 5y agoI was arguing for unique_ptr, not against. Parent was complaining about it.
- yakubin 5y ago> How is that even relevant? What kind of a question is that? It's relevant, if you care about performance, reliability and code simplicity. > What is the alternative? 'new' and 'delete'? new and delete are part of the constructor-destructor business. "delete [] array" is a linear operation, when you have non-trivial destructors. Of course you may always just stick to trivial destructors, but the casual use of new and delete puts you into a mindset that it is entirely normal and nothing to wonder about day-to-day, and then you pay the price without being conscious of it. There are multiple solutions: 1. Modern (concurrent?) garbage collectors are one solution. 2. Another solution is using arenas. Recently, in the compiler I'm writing in Rust I'm using the bumpalo crate, which provides an arena allocator. This allocator is not going to execute destructors of the objects stored in the arenas, when they are freed. I basically write code which doesn't rely on destructors of little objects. There is the destructor of the arena itself, but it's already a completely different picture than doing microallocations, with an unpredicted number of destructors. I can list off the top of my head places where in my code a destructor will be executed (and it's always just a single destructor, not N). I group objects according to the time at which they should be freed. So there is one arena which holds things like source code of different files (I can't mmap because of functionality provided) and arrays of tokens. This is going to be freed when the frontend finishes its job, because at that point I'm not going to quote the source code or tokens to the user (all the error messages doing that would be generated earlier). String literals have a longer lifetime. They're going to be there throughout the whole compilation process, so they go to a separate arena. I'm not making separate allocations for each separate string literal. Similarly, I'm not making separate allocations for each token array in the frontend. It also has the benefit that code is simpler, because I don't need to deal with different forms of the same data, only differing in "ownership". I have no issues like expecting in one place a String, but what I have is a &str. Or in C++ e.g. expecting a std::shared_ptr, when I have a normal inline value (whether it's on stack or part of another struct). Everything is a reference annotated with the lifetime of the arena it resides in. It's both faster and conceptually simple. Another case: at work recently I needed to write an in-process sampling profiler to profile a subset of our code. Modern C++ would mandate that I should use a std::vector<StackTrace> type to store the collected stack traces and the StackTrace type would have a bunch of constructors defined and a destructor. What I did instead is I calculated the size of the buffer that I needed based on the sampling frequency, maximum stack trace depth, size of addresses on the target platform and the time the profiler was supposed to run for. Then I just allocated the whole thing in one go with mmap(2) with appropriate flags. Then I just appended subsequent stack traces in the buffer, holding a pointer to the end, and incrementing it each time an appropriate amount, terminating each stack trace with the 0 address, so that I know where one stack trace begins and another ends. No resizing, no destructors firing up out of nowhere, no microallocations for separate little objects, just one allocation in the beginning and one deallocation in the end. Only two points of failure, instead of N. The "modern C++" way would: be slower, consist of more code (The Rule of N), be slower to compile, have an incomparably bigger number of points of failure. Oh, using std::vector::push_back in a signal handler is also a terrible idea (dynamic allocation in a signal handler? good luck). Another issue with destructors is that they are used for more things than just deallocation. E.g. they are used for sockets... So now you don't know whether your last packets reached the other end in TCP. For files, you don't know if an I/O error occurred. And, unlike the defer mechanism in various languages, they are invisible, so looking at some code it's not immediately obvious that some code that you do not see is going to be executed at a particular time. Allocating things in large portions and forgoing that invisible destructor business brings a lot of benefits: performance, reliability, code simplicity. Also, a big reason why manual memory management is so tricky and why people are so afraid of it is exactly because they were taught to allocate each little object separately, each little object having its separate lifetime. When your code is sprinkled with malloc(3) and free(3) all over the place, then no wonder you lose track of things. In my compiler, although it's written in Rust, I can easily point to places where memory allocation and deallocation happen. I could replace it with manual calls, because the objects are grouped together into larger buckets. It also helps avoid fighting the borrow-checker, when your lifetimes are so simple. UPDATE: This also translates into efficient use of a garbage collector. If you allocate and free a lot of little objects frequently, then the garbage collector is going to be spending a lot of time on-CPU. And the developers are going to say "it's not me, it's the garbage collector!", but the truth is they are the ones that made all that garbage.