Hello,
I was wondering why can a value where any of its parts have a drop() trait not implement the copy() trait?
I cannot think of a good explanation.
Hello,
I was wondering why can a value where any of its parts have a drop() trait not implement the copy() trait?
I cannot think of a good explanation.
It's trivial, really. If you bitwise copy a value that manages a resource, then multiple instances will attempt to clean up the same resource, and/or the copy will access the now-dangling resource cleaned up by the original. This is a serous error even in the simplest case, memory allocation, where it manifests as a double-free and/or use-after-free.
okay thanks. but if you have such a value say this struct - if it could implement copy and there was a an implementation for drop() for s1 then it would mean that the value s1 in two different structs of test would be cleaned up by drop(). If they are bitwise copied why is there a double free?
struct test{
s1: DropStruct,
a: i32
}
I don't think it's trivial. It's because Rust has decided that Copy types never have destructors, and that's a reasonable choice, but it's not like you can't imagine a Rust where you could have a Copy that has a destructor.
Consider this code:
#[derive(Clone)]
struct PrintOnDrop;
impl Drop for PrintOnDrop {
fn drop(&mut self) {
println!("Dropped!");
}
}
fn main() {
let a = PrintOnDrop;
let b = a.clone();
}
Dropped!
Dropped!
This prints "Dropped!" twice. Now consider a version where we perform a move instead of a clone:
fn main() {
let a = PrintOnDrop;
let b = a;
}
Dropped!
This only prints "Dropped!" once.
But if we make PrintOnDrop
copy, then let b = a
becomes a copy instead of a move. This means that if this was allowed:
#[derive(Clone, Copy)]
struct PrintOnDrop;
impl Drop for PrintOnDrop {
fn drop(&mut self) {
println!("Dropped!");
}
}
fn main() {
let a = PrintOnDrop;
let b = a;
}
then it would print "Dropped!" twice because both a
and b
are still valid since b
is a copy.
Now, the Rust language designers decided that adding #[derive(Copy)]
to a struct should not change the behavior of programs. But clearly, as demonstrated by the above example, it would change the behavior of programs if the type has a destructor.
So to avoid this kind of situation where adding #[derive(Copy)]
changes the behavior of a program, they made it so that Copy
types can't have destructors.
There may not be a problem in that toy example, but in general it's unsound. As I already mentioned, memory allocation, the most common example, would immediately suffer from this kind of error (Raw pointers are Copy
, so you could make a Copy
struct that frees the same pointer twice or more, precisely because the pointer is copied bitwise), but in basically all non-toy cases, not pairing dropping with the appropriate resource initialization is at least a logical error.
While it's true that a field implementing Drop
will prevent your type from implementing Copy
, it is not the root cause. The real reason is that your type cannot implement Copy
if any of its fields don't implement Copy
. As @paramagnetic this is required because some types manage resources, and those cannot be duplicated (which would happen if those types would be Copy
, or if they were part of a type that implements Copy
).
How does this relate to Drop
then? As @alice showed, a type implementing Drop
cannot also implement Copy
, and thus will prevent your type from implementing Copy
(but this is still because it doesn't implement Copy
!). This is an artificial limitation, however note that even if this restriction didn't exist, most types implementing Drop
would still not implement Copy
due to managing a resource, and thus would prevent you from implementing Copy
.
Note as evidence for this that most C++ guides recommend that you implement a copy constructor (== Rust Clone
) and not rely on the default copy constructor (== Rust Copy
) if you're implementing a non-trivial destructor.
You can force a copy, and if you do, it can create vulnerabilities and crash the program:
struct StructWithDropMember {
data: String,
}
fn main() {
let unique_owner = StructWithDropMember {
data: String::from("hello"),
};
unsafe {
let no_longer_unique = std::ptr::read(&unique_owner);
}
}
This causes Drop
to be called twice, freeing the same data twice, which is never allowed, and can corrupt memory:
free(): double free detected in tcache 2
If copying of Drop
types was allowed, then they could not rely on being the exclusive owner of their data, and would have to keep track of all the copies in existence, which is basically what a garbage collector does. Rust specifically chose not to have a garbage collector.
I’d say that examples such as the one @kornel posted are sufficiently covered by the rule that a Copy
struct must not have any non-Copy
members. This is not necessarily the question OP was asking though. Well, maybe it is. I cannot really know for sure.
Anyways, this is really the only condition necessary for soundness. The Copy
trait marks types that can safely be duplicated via bit-wise copy. And of course you can only do that with your own custom structs, if all your fields support it, too. (And very commonly the reason a particular struct does not support this is because it would result in a double-free; though in Rust, commonly it's also the case that a struct does not support Copy
because it would result in mutable aliasing.)
The rule that also exists is that a Copy
struct must not implement Drop
. This rule isn't really a rule about any “parts [that] have a drop() trait,” but really only about the whole struct; the rule about “parts” (i.e. fields) really only needs to be about implementations of Copy
(but they cannot implement both Copy
and Drop
at the same time, anyways, so as a consequence any Drop
implementations in fields also—indirectly—preclude Copy
implementation of the whole struct).
As for why that rule exists, @alice has answered that; the TL;DR of which could be “you should still be able to ‘move’ your Copy
structs.” You can really (in most cases[1]) only sort-of ‘pretend’ to move struct that implements Copy
by copying it and no longer using the original, but[2] doing this is always equivalent to a ‘true’ move anyways, and the rule of no Drop implementation enforces this equivalence.
for example, in a generic function with some <T>
parameter and no declared T: Copy
constraint, a value x: T
can be ‘truly’ moved even if you later instantiate that function with Copy
types such as T = i32
↩︎
for almost all intends and purposes; I think I've read about some minor differences somewhere, that when passing a value to a function by-value, a Copy
type can not profit from a specific compiler optimization that could elide the bitwise copying for a non-Copy
type ↩︎
This would only mean that types that are allocating memory couldn't have Drop
.
But allowing Drop
for SafeInt
which just zeroes-out storage on drop
would work perfectly fine.
I think Rust creators just decided that this need is obscure enough that it's not worth complications that it would bring to the language: it's not clear how much would you need to add to compiler to make it truly secure (== guarantee that data is not leaking out of SafeInt
) without such guarantees there are no much interest in having it.
Yes, some other idempotent or optional variant of drop could exist.
But I think it'd expose another problem: copies would become an observable behavior, and currently the number of copies made can vary with optimization levels and details of compiler implementation.
If the number of copies was guaranteed, it'd remove a lot of flexibility from code generation and optimizations, and probably be pretty expensive (e.g. C++ has guaranteed Return Value Optimizations, but it needs to be used carefully).
If it wasn't guaranteed, it'd be difficult for users to use it correctly. It's easy to say "just don't rely on the number of copies and drop runs", but harder to enforce that this is bug-free across the ecosystem.
thanks all of you for your answers!
That's not necessary though. One can easily imagine a variant of Rust where simple moves are guaranteed to be simple moves, regardless of Copy
impls, and actual copies would be inserted only when the borrow checker would complain otherwise (e.g. when trying to move out of a reference).
The issue with that design is that it would be impossible, from just looking at source code, to understand where the copies are. If Copy
types could implement Drop
, then copies would be observable behaviour, because each copy would separately invoke Drop
as in @alice 's example. But the actual locations of copies would be invisible, leading to hard to debug errors. This issue is exactly why cloning types with Drop
requires an explicit Clone::clone()
method call, while in C++ clones can be implicitly invoked by the copy constructor/assignment on any assignment, function parameter pass or value return, and some other places. Worse, there are copy elision rules which force some copies to be eliminated, and move constructors/assignments, meaning that even a simple heuristic like "every mention of variable is a copy" doesn't describe the actual locations of copies. Getting those locations requires a proper standards compliant compiler implementation, and is not something one can reliably do in their head.
That's generally not how the borrow checker works. It checks (a whole function at a time) and says "good" or "no good". It doesn't generate any detailed information of "this and that needs to change to make it work",[1] and that's very much by design.
One thing this allows is future improvements to the borrow checker. Something like polonius can be integrated into Rust in the future precisely because it's okay for the borrow checker to change in ways that makes it accepts (strictly) more programs. Any logic that goes "do this alternative behavior if borrow checking would fail" means that such future updates to the borrow checker become breaking changes.
For example this (admittedly contrived) code example
#[derive(Clone)]
struct S;
fn foo() {
let s1 = S;
let mut r1 = &s1;
let s2 = S;
let r2 = &s2;
if false {
outlives(r2, &mut r1);
} else {
let s3 = s2;
r1;
}
}
fn outlives<'a: 'b, 'b>(_: &'a S, _: &mut &'b S) {}
currently fails to compile
error[E0505]: cannot move out of `s2` because it is borrowed
--> src/lib.rs:12:18
|
7 | let s2 = S;
| -- binding `s2` declared here
8 | let r2 = &s2;
| --- borrow of `s2` occurs here
...
12 | let s3 = s2;
| ^^ move out of `s2` occurs here
13 | r1;
| -- borrow later used here
but if S
is marked Copy
or let s3 = s2;
becomes let s3 = s2.clone();
, the code compiles. However, the code also compiles fine with the move in place, when -Z polonius
is used on nightly.
any error help message that currently does this is usually quite heuristical, and often does arbitrarily choose a suggestion out of multiple possible fixes that could make borrow checking pass ↩︎
All of that is not particularly relevant since we're describing an alternative reality Rust rather than the one we have. Nothing fundamentally prevents implementing the borrow checker in a different way, and perhaps it should work differently. This would help with the soundness issues of specialization, for example.
In any case, you don't need to run a borrow checker in order to know that moving out of a &T
or &mut T
requires a copy or explicit clone. That can be implemented purely as an AST lowering (at least for explicit derefs, rather than method calls, which need type inference).
This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.