Changing the Rules of Rust
without.boats
without.boats
1. Who should read this? Who should care? Why?
2. Help me situate this relative to other Rust commentary, ideas, and recommendations. Where does this sit?
3. What's been happening in the Rust world that may have motivated boats to write this blog post now?
Context: For many years, I've enjoyed and written a lot of Rust, but I don't necessarily keep up with the 'inner sanctum' conversations.
This blog post discusses one potential technical direction for addressing a subset of the problems with async Rust, and in particular why the need to preserve backwards compatibility with earlier design decisions, made before async Rust was designed, makes it harder than it might otherwise have been.
If you don't use async Rust, you can mostly ignore this. If you do use async Rust, and are interested in how it might (backwards-compatibly!) evolve in the coming years, then the report of the Async Foundations Working Group is probably the best starting point to understand what's going on: https://rust-lang.github.io/wg-async/welcome.html
Asynchronous systems code is usually done completely stackless, using state machines and queues of objects, and batched functions that run to completion to transition objects to the next state. This gives you maximum cache-friendliness, and since good performance fundamentally comes from limiting your options, making unbounded state growth hard is a pretty good way to get it. Async functions with stacks are already a performance compromise in comparison. You can do this type of programming in Rust by just not using async.
Unfortunately, some fraction of Rust maintainers are pulling back towards Rust being a web backend language (see the earlier comment about Javascript), and that really needs a concept of stackful async, but really doesn't need the same level of performance as a kernel or a filesystem. Rust green threads would even have worked really well for this use case, despite being a huge mismatch with traditional systems programming. In addition, those async functions need some sort of runtime anyway, so it's not even really a standalone language feature distinct from the packages that enable it.
So here we are, in a situation around async that makes nobody happy. C++ coroutines, despite the hate they get, and Go green threads, despite the performance, actually seem to be closer to the mark.
Rust will undergo a similar journey where the compiler will continually make improvements to ease of use, arriving at a very elegant solution that's just as easy to use.
In practice it means allocating a stack for your function of the maximum size you would need.
Rust has stackless coroutines. Go has stackful coroutines. The literal distinction is that stackless coroutines exist independently of a stack: you have a state machine object that, when it's resumed into, is put onto the stack and calls normal functions through the run-time stack. Stackful coroutines are essentially lightweight threads (hence "green threads" or "virtual threads"): the coroutine comes with a miniature run-time stack.
In practice, stackless coroutines are more space efficient and have lower context-switch costs. A big factor for Rust specifically is interoperating with C. Stackful coroutines make writing "straight-line" code simpler (no function coloring) but introduce more overhead.
C++ coroutines perform the same transform, but they dynamically allocate that state machine for every coroutine (unless the optimizer can eliminate the allocation). In contrast, Rust inlines every coroutine into a single state machine until you explicitly tell it to allocate it. I don't know why C++ chose a less "zero cost" solution; I suspect the fact that they don't have memory safety and so letting you own a coroutine's state is very footgunny in C++ played a part in their choice.
The story that gets told -- and for some reason, I tend to believe it, even though I'm much less idealistic than 10 years ago -- is that folks in the Rust community (and not just them of course, also C++ people and lots of others) tend to really push hard against abstractions that aren't both ergonomic and zero-cost. It is frankly kind of amazing to see the solutions that emerge after months and years of what somewhere resembles either head-butting or crazy experimentation. The path to getting there is messy, and it seems like maybe that's where we are now.
1. If you don't read and comment on Rust RFCs, this post is probably not relevant to you.
2. This post is not even a suggestion for Rust to do anything, it's about how if you want to make a certain kind of change, the barrier to do so backward compatibly is very high.
3. A lot of Rust contributors have recently talked about how they think Rust should have linear types (which involves adding a trait like "Leak"). Some of the commentary suggests people don't realize how big of a hurdle there is to doing this. This is a detailed explanation of how difficult it is.
I don't know why my blog posts show up on the front page of Hacker News all the time!
Other big-names play word games and rely on the "I'm popular with the community" shtick to back up their weak ideas. Your take on Rust feels strong and honest by comparison. I particularly appreciate the awareness of the importance of "non-technical" work in shepherding an OSS project.
The problem with not having Leak is that it forces all futures to be ‘static, which effectively disables the borrow checker whenever you cross an async spawn boundary.
Arc is the standard solution, but, for multi-threaded code, it can be expensive (though also more predictable, in fairness) than having a GC.
The only other solution (which I prefer) is to use an unsafe version of spawn that assumes the future it returns will not be leaked.
This certainly breaks the rust promise of supporting safe, zero cost abstractions.
And there is tokio::spawn_local() that doesn't require Send
Do you have measurements to back that up? I would expect it to be a lot cheaper than GC because GCed language such as Java/Go/etc. GC every heap allocation (and Java in particular doesn't have unboxed objects, so there are a lot of heap allocations!) where Arc<RequestState> would bundle a bunch of things together.
That said, I'm not in love with the Arc solution either. It's not just the performance impact. The programming model of scoped concurrency is just much more pleasant. Giving up that and then also having to deal with putting everything into an Arc, and likely also then wrapping it in a MutexLock, even when it should really be one thread at a time dealing with the value in question, just doesn't do anything good for readability and writability of code...
GC is not run on every allocation.
This is more a Java problem than a GC problem; C# supports values just fine, and some heroic JVM-targetting languages also manage it.
> RC languages do need to free memory used to hold the values later
It also has arena allocator libraries, so you can group allocations that will be freed at a similar time. You don't need to walk through these to find them all and individually free them. Any Drop impls (destructors/finalizers) get handled via a lifo; then the arena's relatively few, large blocks get freed.
I've used this in C++ to squeeze some more performance out of a high-request-rate server. I haven't done it in Rust, but it's the same idea (but safer of course).
btw, little confused by this:
> [RC languages?] have to run expensive compaction and locking steps
Was this meant to refer to GC languages? I've never seen a non-GC language that does compaction (we're talking about the same thing? moving all the heap allocations around to reduce fragmentation?) That's more of a GC language thing that e.g. Java does; it would require the language/runtime to be able to precisely spot pointers and be able to throw in a trap when one it's changing is dereferenced or to entirely stop the world first, and I don't know why you'd take on that complexity and expense if you're not doing GC...
Well, there is boehm if you do want to take on that complexity. But no, I am referring to the book keeping operations that are done internal to the malloc and free calls. They are largely hidden from you as the user of the API, but you still have to pay for them in runtime.
I thought rust didn’t have any safe arena allocators, but that some people make them by tricking the system with indexes instead and such, though word was they might come soon https://news.ycombinator.com/item?id=33403324
Under modern garbage collection, there's very little need to coordinate across cores, just some barriers every now and then to mark things safe.
If you're talking about Arc<GlobalStuff>, I'd probably use Box::leak instead. There's also a few crates that do epoch gc. I haven't really written super high request rate multicore stuff in Rust, but I have in C++, and there for global-ish occasionally updated config stuff, I used a epoch gc implementation on top of Linux rseq to totally eliminate the effect you're describing.
Arc makes otherwise read-only activity write.
In this case, the Linux rseq-based epoch GC worked perfectly. It is technically a form of garbage collection, but it's not all-or-nothing like Java-style or Bohm GC; it's just a library you use for a few highly used, infrequently updated data structures.
btw, Arc<Config> doesn't really seem relevant to the discussion of scoped concurrency. Scoped concurrency can often replace or reduce the need for Arc<RequestState> but not Arc<Config>.
This can't be implemented robustly, because there's no way to enforce the outer scope (the one lending its stack) won't return while the futures are still running. It needs a blocking mechanism that does "wait, your async code hasn't finished yet!", but implementing that via guards and destructors can be bypassed by leaking them.
https://without.boats/blog/the-scoped-task-trilemma/
----
However, running futures on the same thread, with a simple plain .await, does allow them to borrow from the caller's stack safely without restriction. So the first poster in this thread may be doing something unusual, perhaps just over-using multi-threaded spawn() where join_all or streams would suffice.
If this is mostly about scoped async task and the ability to reference data from the parent task, then there also had been a different proposal being discussed which provides the same guarantee: A new Future type (e.g. CompletionFuture), which by represents an async function that needs to be driven to completion. See https://github.com/Matthias247/rfcs/pull/1 for details. However since this propsal is already 3 years old and hasn't gotten and interest by the Rust async WG, it's probably also not going forward.
I know this is highly unpopular opinion, but I don't consider permanent backwards compatibility a good thing.
I think the current version, with its minor warts, is a very pleasant and consistent experience for the most part -- very much unlike C++. So I'm not sure what I'd change to such a drastic degree that it'd require such breaking changes.
Now, it may be something that still can be fixed and improved upon, but a Rust 2.0 with nicer async experience would be a big win.
And if I want to risk going too far, I'd also advocate for refactoring the type system to be slightly less generic in favor of chasing faster compile times.
Also, ideally the lead designer of SafeLang2.0 would be much more heavy on engineering experience than PL theory. Rust made a lot of tradeoffs that were beautiful in PL theory but are annoying to use on a day to day basis.
I honestly think rust is equal parts amazing ideas and mediocre ideas, and I think a slighky less ambitious language would still be almost as safe (world class compared to any other language) but 10x more fun to use.
Similarly, I’m curious as to what the details of a “slightly less generic” type system entails.
Changing Iterator to make use of Generic Associated Types.
Change std::env::set_var to be unsafe. Because using it in multithreaded program causes undefined behavior for any c code that reads the environment (which includes quite a few libc and posix functions).
Change channels to have a better API that is mpmc and closer to crossbeam.
And probably others I can't think of at the moment.
About `std::env::set_var()`, there was a proposal for a `#[deprecated_safe]` attribute that triggers a warning if the function is used without an unsafe block, and eventually make that a hard error in future edition.
I'd love to see programming languages and frameworks add a "gamma" phase to their initial releases. Alphas can be for the internal development, betas can be for stuff that's being tested for bugs and functionality on a public level, and gamma can be a state of "the code can be used out in production, but the API and functionality may change in the future once we see how the tool is used and can identify and resolve operational bottlenecks". Just a handy state that says "feel free to use this, but keep in mind that the interface will likely change for the better but in a non-backwards compatible way based on your experiences using it".
I'm pretty sick and tired of almost every project needing a version 2 because version 1 was essentially publicly released proof of concept that didn't know better.
Like pearl and parrot, as I understand it.
It completely avoids the chance that "python" will become ambiguous regarding which set of rules it refers to: version 2 or version 3.
And the downside is minimal, because anyone can find the most current name online.
Except Fortran, Fortran is forever.
And if anybody brings up breaking changes in C++ I will point to how after any of those it sometimes takes almost a decade for everyone to update.
And that was for breaking changes designed to have minimal impact. See how Python 2 is still used in many places 15 years later for what happens if you change how strings work for example.
C++ already changed the definition of std::string, that's why your Linux distro went through painful C++ ABI changes many years ago. They went from one terrible string design to a different terrible string design, ruling out some optimisations GNU can chosen, but mandating optimisations other groups had chosen.
Don't worry though, they didn't standardize an actual string slice type (Rust's &str) until much later, as std::string_view in C++ 17, so most software which didn't actually care about string allocation needed to be thrashed around anyway because there was no vocabulary type for this most obvious thing aside from the hopeless C-style "nul terminated char *".
It's actually hard to over-estimate how terrible they are at this (language standardization). Is it really just that there are too many of them? I find it hard to imagine that just large numbers of people can explain it, plenty of people co-operate to create Rust and the results aren't anywhere close to as awful. Maybe it's the ISO process? I have never participated in ISO standardization of something this complicated, maybe the process is somehow poisonous.
But I think the best explanation is the culture. C++ has developed a culture which prizes arcane nonsense.
GNU didn't do any optimizations that are acceptable in a language with move semantics. The new `std::string` is _strictly_ better than the old one. Although there do exist very many superior string containers outside of the standard library. libstdc++ still supports the old ABI, by the way, as `std::__cxx11::string`.
There are people who make a lot of very short strings and need them to go fast, but not so many to justify making that a core language feature, if not for the fact that C++ empty string needs a zero byte for C compatibility.
That's why Rust got away without it, their empty string has length zero.
Everyone uses GCC C++ or MSVC C++ or Clang C++.
This includes the committee that makes the standard and requires a working battle tested implementation before inclusion.
So you get a Frankenstein result because that is the input.
And while yes in theory the standards define a kind of C++ that works on every compiler but the reality is most compilers don't fully support that for literal years best case.
So everyone uses what is available and writes abstractions to handle the harsh edges if they support multiple compilers.
This "requirement" is a mirage, as you can tell by simply installing any of the three compilers from say the week C++ 20 was published and trying to use the headline "modules" feature described in the standard. There are no working implementations.
If you've worked at a large dysfunctional organisation you'll recognise the pattern. Nobody wants to say "No" and make a decision themselves which may go badly - so instead they look for excuses and you're listing one of the excuses. But when they want to do it none of the excuses apply.
For example, I've been using Rust in production since just after 1.0, so about 8 years now. I maintain 10-20 libraries and tools, some with hundreds of dependencies. If I update a project with 200 dependencies that hasn't been touched in 3 years, that's 3x200 = 600 "dependency-years" worth of old code being run on a new compiler. And usually it either works flawlessly, or it can be fixed in under 5 minutes. Very occasionally I'm forced to update my code to use a new library version that requires some code changes on my end.
I've also maintained long-lived C, C++, Ruby and Python projects. Those projects tend to have far more problems with new compilers or runtimes than Rust, in my experience. Updating a large C++ program to newest version of Visual C++, for example, can be a mess.
However, Rust obviously does not support running new code on old compilers. And because stable Rust is almost always a safe upgrade, many popular libraries don't worry too much about supporting 3-year-old compilers. (This is super irritating for distros like Debian.) Which if you're working on a regulated embedded system that requires you to use a specific old compiler, well, it's a problem. Realistically, in that case you'll want to vendor and freeze all your dependencies and back-port specific security fixes as needed. If you're not allowed to update your compiler, you probably shouldn't be mass-updating your dependencies, either.
Basically, Rust got so exceptionally good at running old code on new compilers that the library ecosystem developed the habit of dropping support for old compilers too quickly for some downstream LTS maintainers. And as a library maintainer, I'm absolutely part of the problem—I don't actually care about supporting 5 year old compilers for free. On some level, I probably should, but...
For languages with more frequent releases, that just isn't feasible. The let-else construct [0] was stabilized in 1.65 (Nov 2022), default values for const generics were stabilized in 1.59 (Feb 2022) [1], and the const evaluation timeout [2] was removed in 1.72 (Aug 2023). These are all reasonable changes, released on a reasonable timescale. However, when writing a `struct Position<const Dim: usize = 3>`, I don't check whether my library already uses language features that post-date 1.59, and I don't consider it a backwards-compatibility breakage to do so.
Incrementing the major version for a change that is source-compatible on most compiler versions (e.g. the first use of let-else in a project) just isn't justified.
[0] https://doc.rust-lang.org/rust-by-example/flow_control/let_e...
[1] https://blog.rust-lang.org/2022/02/24/Rust-1.59.0.html#const...
[2] https://blog.rust-lang.org/2023/08/24/Rust-1.72.0.html#const...
That's an MSVC problem. MSVC ignored the C++ specification for decades. Now it does follow the spec. So a lot of non-standard code broke.
The transition was pretty ugly, as I'm pretty sure you've figured out. The permissive mode never gave warnings for non-standard code, so you couldn't just fix warnings as they came up. You had to do a fix the world update, which are a dickpain in large codebases. The transition was relatively fast; VS2017 introduced the standard compliant parser, and C++20 requires it.
Honestly MSVC is such a mess. I have a hunch that before this decade is out, Microsoft will just replace it with Clang a la Internet Explorer/Edge/Chromium. That's why the switch to the standard compliant parser was so rushed; they're trying to force everyone to write standard compliant code so that Clang can compile it.
I have configured cargo-deny for nearly all our projects to just ban OpenSSL from ever appearing as a dependency.
It does mention that dealing with the pain is the only potential solve.
In next-edition:
- 2021 crates that turned Leak feature uses it just as a next-edition crate would. Only things explicitly marked with Leak in the crate can leak.
- 2021 crate that didn't opt-in to Leak feature, automatically marks everything in the crate with Leak.
Your refinement seems like a nice addition to the author's suggestion, if it is possible and doesn't effect compile times too much. Hopefully somebody smarter than me takes a run at the problem.
Right now longjmp()ing over rust code is not a great idea for a couple reasons, but because leaks are currently always allowed in rust, it arguably follows the rules at least in some cases (doing so could cause problems, of course).
If the Leak trait were introduced, then skipping destructors would be clearly the wrong thing to do if there are types on the stack that don't implement Leak. That would clearly require some way to either prevent longjmp() from ever jumping over rust code (e.g. always catch it in C code and turn it into a special return code), or find some way to catch the longjmp and turn it into an unwind in rust (e.g. a panic?) and then turn it back into a longjmp to go back into C.
"If replacing std::longjmp with throw and setjmp with catch would invoke a non-trivial destructor for any automatic object, the behavior of such std::longjmp is undefined."[1]
[1]: https://en.cppreference.com/w/cpp/utility/program/longjmp
And I don't think it's impossible -- it probably requires some special wrappers (perhaps bindgen could generate them?) that would call setjmp before entering the C code. setjmp has its own set of problems, but I don't believe those are impossible to solve, either.
In the general case, skipping destructors of objects you don't own (through longjmp(), killing a single thread, etc.) has never been allowed in Rust: at any particular point in the program, you're only allowed to skip destructors of objects that you currently own, or can otherwise gain ownership of.
For instance, the thread::scope() API, after running the spawning thread's closure, uses a destructor* to join all the created threads. However, if a program could longjmp() past that destructor, then the spawning thread could access its variables again while the created threads are still using them, resulting in a data race. (This is the same issue that the original thread::scoped() API had.)
let mut x = 0;
let mut jmp_buf = JmpBuf::default();
if setjmp(&mut jmp_buf) == 0 {
std::thread::scope(|s| {
s.spawn(|| loop { x += 1; });
longjmp(&jmp_buf, 1);
});
} else {
loop { println!("{x}"); } /* the created thread is still changing the value of x! */
}
That is to say, longjmp() has always been a very unsafe operation, which can only be sound if you're in control of every single destructor which it skips. A Leak trait wouldn't change anything in that regard.* Or rather, a catch_unwind() followed by a resume_unwind(), which is mostly equivalent to a destructor in this context.
Couldn't this be done just by changing how 2021 code is compiled, such that:
All types impl Leak
All generic type parameters have a Leak constraint added
All trait objects are changed to include "+ Leak"
In other words, treat pre 2024 code as if it was the equivalent code in the presense of the Leak trait, requiring that all types are Leak.
You'd just treat all code the same, as supporting Leak, and process trait bounds as usual.
EDIT: https://without.boats/blog/follow-up-to-changing-the-rules-o...
fn foo<T>(input: T)
would actually parse as if: fn foo<T: Leak>(input: T)
An implicit Leak bound would be inserted into AST of every trait bound in the 2021 edition. Then all code from all editions would be interpreted the same.This way, the 2021 code wouldn't compile, because the 2024 trait requires `<T>` bound, and the 2021 code can only express `<T: Leak>` and nothing less.
2021 code could not work at all with any 2024 code that allows !Leak. libstd would have to add `+ Leak` to all existing APIs. Upgrading to 2024 edition would need a migration to change the bounds.
The unfortunate part is that means that upgrading foo to the 2024 edition means you either have to break backwards compatibility (at least for 2021 code) or add Leak bounds to type parameters and trait objects. There could be a cargo fix rule to do that update for you.
It is really unfortunate that existing APIs can't be made more general without breaking compatibility though.
I hope they would think twice about the effects on new learners before even experimenting
I don't agree, but even if it were true, C++ has so much accidental complexity (because of its legacy) that you cannot meaningfully compare it with Rust, which by and large is very well thought out.
In general, if something is complex in Rust that is because it's in the nature of the problem and not because of some quirkiness in the solution.
Borrowing is the prime example. People find it complex, but just because the C++ compiler doesn't check the rules for you, doesn't mean you have follow them in C++ as well (which is tedious and error-prone) or know exactly why you don't have to follow them (which is even harder).
I admit though, that the Leakpocalypse (which the article talks about, even if it doesn't call it like that) is an area where accidental complexity lurks.
But I agree, in general things in rust are a lot more thought out and experiments are relegated to unstable which is fine with breaking changes.
I do wish though that they had a better versioning story in the language for making backward incompatible changes more a matter of course. It’s great already for “user space” code via editions, but as the author notes making certain kinds of changes brings up risk and uncertainty because there’s not a well trodden path to support changing core semantics in the language and having it work gracefully without needing a migration en masse.
That is easy to say, and I don't disagree with the sentiment, but there are valid reasons for setting a 1.0 release date and cutting corners to get there. If they had not done that, there probably was a real risk of running an infinite loop of "let's wait for Move to be implemented", "great, now let's wait for Leak to be implemented", "great, now let's wait for..." and a 1.0 never gets put out. Rust may not be perfect, but at least it's stable and people are actually using it.
Rust can and does move things around as it feels the need. So you need something like Pin if what you've built can't move.
It's confusing if you're coming from a C++ background because in C++ something only "moves" if you explicitly std::move it. In Rust its effectively the reverse. A crude way of thinking about Rust's call pattern is that it's basically the opposite: as if every call was a std::move operation.
So Pin is basically just saying: you can't move this, don't try. The memory can't be moved. And it's mostly used in corner cases where some kind of explicit memory management is involved.
Most Rust users should not have to worry about it. It's one of those things that becomes more of a concern if you're writing library code for low level datastructures, or interfacing with C/C++ libraries.
Don’t all Rust users writing async code have to worry about Pin?
People writing application code in Rust should not encounter it often.
For instance, the async_stream crate would be much nicer if you didn’t need to pun_mut! the stream instances it provides:
I think in many ways Rust is doing a better job of tying down the tricky details than C++ has.
There is no effort to explain how pointer provenance works in C++ at all. The state of the art is if you write a provenance bug, someone from a compiler vendor will gesture at DR/260 which says provenance exists but WG14 (so C, not even C++) declines to explain how it works, and that's all you get. In contrast Aria's "Experiment" (https://doc.rust-lang.org/std/ptr/index.html#strict-provenan...) has gone rather well. The vast majority of people who want to do acrobatics with pointers can do what Aria says you have to do to get coherent behaviour, and by following her rules they can make code which works in Rust.
In contrast, Aria went from "We should see whether strict rules are workable" to the current set of Rust nightly features relatively quickly.
If I want to attempt pointer stunts in C, N3005 is probably worth reading first, but it isn't actionable from what I can tell. Contrast Aria's changes, I can go try that (in nightly Rust) and see it works or why I can't / mustn't do what I wanted under Strict Provenance and who I ought to talk to about that.
I wish you luck for C 29 (?) but that's a distant future, and we're contrasting C++ here, so as with #embed you might take this obvious must-have for C29 and find that somehow WG21 are still so far behind they can't do the same for C++ 29.
N3005 explains four provenance variants, but settles on specifically PNVI-ue-udi for the future of C. But Aria's experiment focuses on roughly what you'd call PNVI-plain, reasoning that Rust largely does not need the extra headache of exposure.
N3005 doesn't provide an implementation, so Aria can't have merely "translated" that. It doesn't provide an API design (so not that). Which leaves basically the fact that you've extensively discussed the semantics of these variants.
Let's try specifics: Take a common feature of Rust's design, ptr::map_addr. Where is equivalent functionality described for C in N3005 ? In Rust it's perfectly natural to write a lambda here, in C perhaps you'd spell this very differently, but N3005 doesn't seem to offer such a thing.
It's an issue that N3005 is still a draft because it goes to how long this wound has festered in C comparatively and will still be there. If this was merely a theoretical problem I wouldn't be bothered, but the whole reason DR260 was raised is that it's not a theoretical problem.
For LLVM the effect of Rust has been to drag some of these issues into the sunlight, as it did for aliasing problems previously. https://github.com/llvm/llvm-project/issues/45725
Once the GCC work is closer to finished, I expect we'll see the same there.
I disagree that you need to solve the C problem first to solve the compiler problem, and I think it was misguided to start there. You seem to have focused on the fact that exposure is even an option in Aria's implementation, but let me quote: "The goal of the Strict Provenance experiment is to determine whether it is possible to use Rust without expose_addr and from_exposed_addr". Setting PNVI-ae-udi as "the" provenance rule is your end goal for C, but there's a reason it's called the "Strict Provenance" experiment in Rust, the goal is something like what you call PNVI-plain.
APIs like map are key to that goal, Rust has them and N3005 does not.
So like I said, rather than just being a "translation" I think the most that can be said is you've got the same problem albeit in a very different context, and your solutions are related in the way we'd expect when competent people attack similar problems.
If you think that "map_addr" etc. is an important API, then I agree that this is an innovation on top of what we did. But I personally do not quite see the importance of this API. Yes, it allows some things in the scope of strict provenance which in C would now require PVNI-ae-udi. We envisioned future extensions that prevent exposure of pointers for certain operations, but somehow this seems more academic at this point in time. If you are not using hardware such as CHERI, this does not matter. On the other hand, PVNI-ae-udi makes most existing C code follow a precise provenance model, which I think is a huge step forward.
This juxtaposition kind of makes it seem like that new C++ versions did require changes to your code, did they?
My understanding is that C++ maintains backwards compatibility.
My code went from 2015 to 2018 and 2021 by simply changing Cargo.toml. No code changes were necessary and it was still completely idiomatic code. Nothing was deprecated or even outdated.
An idiomatic C++11 solution is not likely to be the idiomatic C++17 solution however. Every version of C++ makes deep changes to the actual language and adds a ton of features. Whereas Rust versions and editions tend to make the language simpler as they generally remove limitations.
It's true that there are usually new features which arguably make older code no longer idiomatic, but modernizing code is always optional, so what's really the issue there?