-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Clarify that RFC1520 does not permit the compiler to replace calls to Clone::clone
with a memcpy
#3197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Are built-in derives count as a compiler or as a (proc macro) library here? |
9f26c96
to
dde2b71
Compare
Clone::clone
with a memcpy
Are you referring to the implementation of I have nevertheless reworded to refer to users instead of libraries, which is probably more correct anyway; and I think that should also cover (any other) built-in derive macros that rely on some |
dde2b71
to
363213c
Compare
With respect, I do not understand how Ralf's claim can be simultaneously true and not also affect whether it is valid for a user to use memcpy on a To overload this to reductio ad absurdum: If |
This is the subject of the very first page of the Rustonomicon. Excerpting: No matter what, Safe Rust can't cause Undefined Behavior. The design of the safe/unsafe split means that there is an asymmetric trust relationship between Safe and Unsafe Rust. Safe Rust inherently has to trust that any Unsafe Rust it touches has been written correctly. On the other hand, Unsafe Rust cannot trust Safe Rust without care. As an example, Rust has the PartialOrd and Ord traits to differentiate between types which can "just" be compared, and those that provide a "total" ordering (which basically means that comparison behaves reasonably). BTreeMap doesn't really make sense for partially-ordered types, and so it requires that its keys implement Ord. However, BTreeMap has Unsafe Rust code inside of its implementation. Because it would be unacceptable for a sloppy Ord implementation (which is Safe to write) to cause Undefined Behavior, the Unsafe code in BTreeMap must be written to be robust against Ord implementations which aren't actually total — even though that's the whole point of requiring Ord. The Unsafe Rust code just can't trust the Safe Rust code to be written correctly. That said, BTreeMap will still behave completely erratically if you feed in values that don't have a total ordering. It just won't ever cause Undefined Behavior. One may wonder, if BTreeMap cannot trust Ord because it's Safe, why can it trust any Safe code? For instance BTreeMap relies on integers and slices to be implemented correctly. Those are safe too, right? The difference is one of scope. When BTreeMap relies on integers and slices, it's relying on one very specific implementation. This is a measured risk that can be weighed against the benefit. In this case there's basically zero risk; if integers and slices are broken, everyone is broken. Also, they're maintained by the same people who maintain BTreeMap, so it's easy to keep tabs on them. On the other hand, BTreeMap's key type is generic. Trusting its Ord implementation means trusting every Ord implementation in the past, present, and future. Here the risk is high: someone somewhere is going to make a mistake and mess up their Ord implementation, or even just straight up lie about providing a total ordering because "it seems to work". When that happens, BTreeMap needs to be prepared. The same logic applies to trusting a closure that's passed to you to behave correctly. This problem of unbounded generic trust is the problem that unsafe traits exist to resolve. ..... The decision of whether to mark a trait unsafe is an API design choice. A safe trait is easier to implement, but any unsafe code that relies on it must defend against incorrect behavior. Marking a trait unsafe shifts this responsibility to the implementor. Rust has traditionally avoided marking traits unsafe because it makes Unsafe Rust pervasive, which isn't desirable. Send and Sync are marked unsafe because thread safety is a fundamental property that unsafe code can't possibly hope to defend against in the way it could defend against a buggy Ord implementation. Similarly, GlobalAllocator is keeping accounts of all the memory in the program and other things like Box or Vec build on top of it. If it does something weird (giving the same chunk of memory to another request when it is still in use), there's no chance to detect that and do anything about it. The decision of whether to mark your own traits unsafe depends on the same sort of consideration. If unsafe code can't reasonably expect to defend against a broken implementation of the trait, then marking the trait unsafe is a reasonable choice. As an aside, while Send and Sync are unsafe traits, they are also automatically implemented for types when such derivations are provably safe to do. Send is automatically derived for all types composed only of values whose types also implement Send. Sync is automatically derived for all types composed only of values whose types also implement Sync. This minimizes the pervasive unsafety of making these two traits unsafe. And not many people are going to implement memory allocators (or use them directly, for that matter). This is the balance between Safe and Unsafe Rust. The separation is designed to make using Safe Rust as ergonomic as possible, but requires extra effort and care when writing Unsafe Rust. The rest of this book is largely a discussion of the sort of care that must be taken, and what contracts Unsafe Rust must uphold. |
Now, Copy is a genuinely interesting trait in this regard. In some sense, you could argue that it should be an unsafe trait, for much the same reason Send and Sync should be: it is making a hard guarantee, and an incorrect implementation obviously could cause Undefined Behaviour. For instance you could mark Vec as Copy and that would be unsound. But I would argue that the situation is a bit different. Send and Sync are marked unsafe and automatically derived when possible. When the compiler detects that your type cannot be automatically Send (e.g. because you contain a non-Send type) you have the option to override that determination and manually Copy on the other hand must be manually derived, and the compiler will refuse to allow you to implement Copy when it detects a dubious situation:
So you can't compose a bunch of safe code and toss in an So what is an unsound impl of Copy? It would be a type that doesn't have a destructor but which otherwise has properties that are trusted by unsafe code, and which would be violated by duplicating without any additional book-keeping. For instance, a phantom token to validate a sequence of events:
If step2 contains unsafe code that requires step1 to have been run beforehand, Copying Step1Token would be unsound... But so would Cloneing it. So would a Default implementation. So would This is the exact same situation as unsafe code in Vec needing to rely on field privacy for correctness: An unsafe code author exposing a safe interface must wholistically design that interface for correctness. If you make the fields of Vec Similarly, if you make a key type in your design |
Mmm, I understand that but I am not as confident about that specifically applying here because:
That may sound deranged, but given we're functionally asking the compiler to look at our code and rewrite it to a better implementation, we need to ask that question... especially if we (or, say, LLVM, GCC, or Cranelift, or any other codegen backend) are also implementing optimizations based on what reasoning we can normally apply to Rust. "We have met the compiler, and it is us." |
The reason probably is because it has been allowed thus far to have custom Clone impls, so it would be surprising if the compiler suddenly wouldn't start calling them anymore. But is this really something that needs to be kept? Maybe in Rust 2024 or so we could just have Copy prevent a manual Clone impl and always allow the compiler to optimize the call into a memcpy. Unless there's a good reason to allow manual Clone impls, but I currently don't see it. |
So I agree that if you write a type with a non-trivial Clone that is still Copy, you are doing something Very Dubious and Probably Unsound. But you could certainly contrive situations where it's "basically fine". For instance you could add logging statements to your Clone impl to try to get debug something, while keeping the Clone otherwise trivial. In this case it may be impossible to remove the Copy implementation since random pieces of code are relying on it, but you "know" it will be Actually Clone'd in some place you're interested in. Now it would be frustating but understandable if e.g. Vec::Clone used specialization to skip your logging Clones -- you told it that it could. But it would be a whole other level of cursed if it was literally impossible to execute your logging clones because the compiler kept removing them. So I think this is less of a soundness thing and more of a "this will be really cursed, we refuse to do this" thing. (You could contrive some example where Copying is fine but you're relying on a non-trivial Clone Definitely Happening in a specific place, but, that's playing with a lot of Fire and even I'm not willing to endorse that level of evil.) |
Yeah I was actually about to bring up logging too... but yeah it's not even guaranteed that anyone even explicitly calls .clone() on your type, so you are missing like half the logging anyway, so you are much better off with a non-Copy newtype wrapper that does the logging consistently instead. So not only does this prevent optimizations, but it also is just a big footgun if you actually do implement Clone, that honestly at least clippy should lint against it. So I'm still under the opinion that there's no good use case for this and at least Rust 2024 should just not allow manual Clone impls at all (and always optimize). |
I would hesitate to block manually implementing Clone where Copy is implemented simply because that would interfere with cases which have very nuanced where-clause bounds on the impl. |
I mean the compiler could just "auto implement it" and the trait resolver will realize there's 2 conflicting implementations and then fail compiling. |
Also, if your So the only place this makes a difference is when the |
My thinking behind this PR was that it cannot be satisfactory for the compiler to be free to perform this optimisation but make no guarantee over whether it will: surely its behaviour needs to be certain one way or the other? Yet to guarantee that it will perform this optimisation is a significant change that I imagine requires the approval of at very least the lang team, and possibly also the compiler team? It would also be a breaking change that I presume would not be admitted into stable until a new edition. My conclusion was therefore that, for the time being, the compiler surely guarantees that it won't perform this optimisation? This PR merely clarifies that status quo in (what I considered to be) an otherwise ambiguous RFC. I guess the question of whether a future edition should handle things differently can be deferred (and indeed I see that Niko historically blogged various thoughts around how specialisation could enable libcore to provide a blanket |
This is a core safety contract, akin to the contract of There is absolutely no logical connection between a type being Put differently: the idea that IOW, the following function is not sound (and AFAIK there is widespread consensus for that): fn test_eq<T: PartialEq>(x: &T, y: &T) {
if (x == y) != (y == x) {
unsafe { unreachable_unchecked(); }
}
} For the same reason, it is not sound for the compiler to replace fn test_eq<T: PartialEq>(x: &T, y: &T) {
let eq = (x == y);
if eq != eq {
unsafe { unreachable_unchecked(); }
}
} If the compiler could assume that |
I think the answer is that it should always be possible for somebody to unambiguously call an Now, you may consider that this property, at least for the specific case of
What way to go?
Those are the facts. Imho, the caveats of "having to use |
If only we had decided a long time ago that the compiler was entitled to remove clone/drop pairs. |
I think that would have been a mistake -- I think the basic property that when you call a function, that function actually gets called, is too important to give up on. (This would be in a sense 'worse' than C++ copy elision, where -- to my knowledge -- there is a guarantee that the function [the copy constructor] will not be called in certain syntactic forms. In a sense that is just "weird syntax". But here we'd be talking about the compiler deciding to call the function or not, depending on arbitrary factors that can change with every rebuild.) |
Your expectations of C++ are too high ;) It actually has both mandatory and non-mandatory copy elision in different situations. (Don’t be fooled by that page describing the latter as an “optimization”; it’s not just talking about standard compiler optimizations that might happen to remove a copy if it has no observable side effects. It really is a rule that the compiler decides whether to call the function or not.) |
Then maybe the mistake was allowing a manual
If, back then, manual |
@dlight I don't think there's actually a problem here - this issue is about the compiler being able to elide explicit calls to As for unsafe code: unsafe code is allowed to do anything safe code can do (obviously) so if unsafe code knows that a type is |
8e76b0e
to
50571f8
Compare
50571f8
to
9aeaf42
Compare
I think my questions have been satisfied. My understanding is now:
|
Related: A while back, I experimented with a transformation that replaced a 'transitive
I think it would be useful to have a warn-by-default lint against such impls, to catch cases where the user may not have intended that combination (e.g. a type starts out with a custom |
Following discussion on Zulip referencing a thread on the Rust subreddit and a previous discussion on IRLO, this PR clarifies that RFC1520 only affects the assumptions that users may make about implementations of
Clone
forCopy
types and does not affect the assumptions that the compiler will make when compiling calls toClone::clone
. In particular, the complier is not permitted by this RFC to replace calls toClone::clone
with amemcpy
.I believe that this was always the intention of the RFC from the outset, but the wording left some doubt.