Nika Kurashvili
Nika Kurashvili

Reputation: 6462

Why doesn't heap memory get dropped when a value is moved?

The Rust Programming Language says:

when a variable goes out of scope, Rust automatically calls the drop function and cleans up the heap memory for that variable.

Let's look at the 2 cases.

fn dangle() -> &String { // dangle returns a reference to a String
    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} 
fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

In the first example, when s goes out of scope, s is said to be deallocated and dropped. This means that s on the stack is removed and the heap memory is deallocated/cleaned.

In the second example, s is moved. I understand that, but why doesn't the heap memory get dropped? What's the rule here that the heap memory stays alive? s went out of the scope, so heap should also be destroyed.

Upvotes: 1

Views: 1080

Answers (3)

Edmund's Echo
Edmund's Echo

Reputation: 908

The answers provided are solid.

The question conjured the following; something I keep reminding myself when working in Rust.

“variable” - in Rust how we reference a variable matters a lot.

In both, s is a reference to the memory allocated when you called String::from… but not just any reference; it’s a “smart pointer” that endows it with “ownership” rights (and a singular responsibility).

  1. Ownership encodes - “I am responsible for deallocating memory by ensuring drop gets called”

  2. Ownership can be given away by way of an instruction with a “move semantic”

Only in the second case was ownership given to the caller of the function - return s is an instruction to move ownership to the caller. In doing so, passing the responsibility of calling drop to the caller of the function.

This important delegation of responsibility does not happen in the first example - return &s.

While what I’ve said is the Rust semantic and explains the difference in both scenarios, it never really told the full story for me.

I often have to remind myself that T and &T are different types, and how that plays out (both are references to a variable, to memory that hosts the value of T; ignore the fact that T, a generic type, can host &T).

& is a function ~ T -> &T; it creates a ref::borrow (without taking ownership of T despite the pseudo code signature).

In both cases, the caller has ownership “of something”.

let i_own_something: ref::owner (&T) = dangle()
// by way of copy semantic
let i_own_something: ref::owner (T) = no_dangle()
// by way of move semantic

Each owns a ref, the hidden information is “attained ownership by way of copy or move”? This hidden information is encoded in the the Rust “smart pointers”.

The next question, if in each scenario we are taking ownership of a new ref, how does Rust enforce the ownership rule, exactly?

To level set, if both s and &s implemented the Copy trait, my question becomes moot (ignoring lifetimes, a related but separate concern). In each scenario the caller would own something by way of copy semantic.

However, s does not have a copy method (String does not implement the Copy trait).

let i_own_something: ref::owner (T) = s
// … where no_dangle() = s

How does Rust know whether it’s an instruction to move or copy?

If Rust finds that T implemented the Copy trait, it executes with “a copy semantic”. Otherwise, it executes with a “move semantic”.

To make this logic more explicit, the Rust compiler enforces that a type can implement Drop or Copy, but not both (copy Or move where move is linked to ownership, ownership linked to calling drop; thus the trait implementation constraint that makes it explicit for how Rust to “know what to do” - strictly speaking I don’t think it’s required to enforce the rule, but it makes things clearer).

All the machinery works without having to implement either of these traits. However, if you implement Copy, move never happens. Moreover, if you decide to implement the Drop trait for your type, you are actively preventing the use of a copy semantic (preventing ownership by way of copy).

So all in all, ownership of “something” does get transferred in both cases.

Ownership of that “something” occurs in one of two ways:

  • ownership of a copy by way of copy, or
  • ownership “of the original” by way of a “move”.

Rust infers which semantic to apply by looking at whether T implemented Copy (a signaling trait).

More generally, Rust encodes the design tenet of preferring “a move semantic” by not implementing Copy for custom types (no blanket implementation). However, Rust encodes a copy semantic for many types used in the std lib by implementing Copy (e.g., u32, &T :)). Contrast that of course with String and other data types that rely on using the heap for instance (where Rust implements Drop, thus making it impossible to implement Copy - amen to that guardrail).

One final note, I often conflate ownership, lifetimes and the borrow checker. Perhaps just to get a sense if this resonates with anyone:

  • While the encoding of each concept converge, the concepts are distinct.
  • the answer to this question is only about ownership.
  • lifetimes is a separate and subsequent “layer” to consider.
  • The interaction of ownership and lifetimes determined “what should compile”
  • Whether it does compile and with how many “user hints” provided to the “borrow-checker” is yet another moving, and ever-improving layer.

One final addendum: Ironically, a move semantic of memory does actually involve “copying” memory (“under the hood”), exactly like what happens in a “copy semantic”. This seemingly missed opportunity to reuse memory is a safeguard that ensures the new owner is only ever pointing to memory it fully controls. This control requirement cannot be guaranteed when reusing memory because the compile-time enforcement of the ownership rules, as good as they are, cannot guarantee that the drop method of the old owner won’t be called (UB - a use after drop error).

That said, creating this “extra” copy during a move semantic can be avoided on the heap using Box; checkout what problems the Box type solves to better understand the limits of moving ownership using T on its own.

Upvotes: 5

Masklinn
Masklinn

Reputation: 42247

In the second example, s is moved. I understand that, but why doesn't the heap memory get dropped? What's the rule here that the heap memory stays alive? s went out of the scope, so heap should also be destroyed.

It sounds like your intuition is that of C++: in C++, moves are "non-destructive" and all variables are destroyed (their destructor is called) at end of scope.

But rust follows a very different logic of destructive moves: when a variable is moved from, it is considered dead, and so there is no way to "kill" them again (for what is dead may never die), and thus dropping them doesn't make sense. So

fn f() -> String {
    let s = String::from("hello");

    s // implicit return moves the value
    // s is dead here, so it doesn't get dropped
}
fn g() -> () {
    let s = String::from("hello");
    // move value into a function
    Box::leak(Box::new(s));
    // s is dead here, so it doesn't get dropped
}
fn h() -> () {
    let s = String::from("hello");
    // borrow value
    println!("{}", s);
    // s is *live* here, it needs to be dropped
}

Upvotes: 7

rustyhu
rustyhu

Reputation: 2147

Refer to the book, the compiler remembers which values are moved already, and does not call drop on them. This is an example, the drop function would be called only once, not for moved value s:

struct MyString {
    a: String,
}

impl Drop for MyString {
    fn drop(&mut self) {
        println!("Dropping My String!");
    }
}

fn no_dangle() -> MyString {
    let s = MyString{a: String::from("hello")};
    println!("Show {1}: {0}, {1} drops after this line.", s.a, "s");
    println!("Inside no_dangle...");
    
    s
}

fn main() {
    let ss = no_dangle();
    println!("Outside no_dangle...");
    println!("Show {1}: {0}, {1} drops after this line.", ss.a, "ss");
}

Play this code.

Upvotes: 1

Related Questions