3

Consider this simple linked list implementation:

enum Cons<T> {
    Empty,
    Cons(T, Box<Cons<T>>),
}

fn main() {
    let mut list = Cons::Empty;
    for i in 1..1_000_000 {
        list = Cons::Cons(i, Box::new(list));
    }
    println!("Hello, world!");
}

Playground

Why does it overflow the stack?

8
  • Hint: add impl<T> Drop for Cons<T>{ fn drop(&mut self) println!("Dropping"); } } Commented Oct 9, 2023 at 16:22
  • 1
    You should check out Learning Rust With Entirely Too Many Linked Lists, 2.7 Drop Commented Oct 9, 2023 at 16:27
  • I'm inferring from your hints that rust implements drop recursively? Can you just say that if it's true? Commented Oct 9, 2023 at 16:31
  • 1
    Not always, but what drop glue do you think should be generated for Cons<T> in the variant Cons? I.e. if your datastructure is recursive, how would you in general generate the drop glue? Commented Oct 9, 2023 at 16:33
  • 1
    You could have a flat list of objects and then define the relationships using weak references. You'll need to manage dropping expired references, but it avoids issues with the recursive drop logic and a lot of ownership issues introduced by cycles. Commented Oct 9, 2023 at 17:00

1 Answer 1

4

It overflows the stack on trying to drop list. The drop glue generated by Rust simply drops all fields in order, so it's going to look something like this (pseudo-code):

match self {
    Self::Empty => {},
    Self::Cons(a, b) => {
        Drop::drop(&mut a);
        Drop::drop(&mut b);
    }
}

The call Drop::drop(&mut b); is going to recurse to this same drop code (indirectly through Box's drop code), and it will continue to recurse 1,000,000 times in order to drop the entire list. Though, note that it will actually create 2,000,000 stack frames as Cons's drop glue and Box's drop implementation are going to call each other. This overflows the stack. (Note that tailcall optimization can't even be performed, because b is a box -- the box needs to be destroyed after the inner Cons is dropped.)

Fixing this seems straightforward but requires a few hacks since you can't move out of values that implement Drop.

The basic approach is to implement Drop manually and recursively drop the structure using iteration instead of recursive function calls. To help with this, we can create a method that will "steal" an existing Cons<T> leaving it Empty. We can use this to pull the Cons<T> value out of the box without actually moving it out as far as Rust is concerned.

impl<T> Cons<T> {
    pub fn take(&mut self) -> Cons<T> {
        std::mem::replace(self, Self::Empty)
    }
}

Easy enough. Now we can implement Drop around this method.

Notably, we have to check for the non-recursive cases first (self is Empty or is a Cons whose box is also Empty) to avoid accidental infinite recursion1. Once we know we have at least one level of recursion, we can simply loop forward in the structure, take()-ing the next item into our iteration variable:

impl<T> Drop for Cons<T> {
    fn drop(&mut self) {
        match self {
            Self::Empty => return,
            Self::Cons(_, boxed) if matches!(**boxed, Self::Empty) => return,
            _ => {}
        };

        let mut v = self.take();

        while let Self::Cons(_, boxed) = &mut v {
            v = boxed.take();
        }
    }
}

Now, whenever Rust's generated drop glue gets ahold of a Cons value, it will always either be Empty or be a Cons whose box is Empty.


1 The line let mut v = self.take(); will cause infinite recursion of our drop() implementation if we don't first check for the terminating cases. It will unconditionally create a new Cons<T> value, which must be dropped, but drop() will then unconditionally create another Cons<T> when it tries to drop that one. The match block detects the terminating cases to avoid creating a new Cons<T> when Rust's own drop glue will correctly handle the value without recursing more than once.

Sign up to request clarification or add additional context in comments.

8 Comments

How does this work memory wise? Does each object go out of scope and get dropped after each while loop iteration, or does this end up creating N values of Cons::Empty which are all dropped together at the end of the function?
@flakes v = boxed.take(); takes the Cons value out of boxed, drops v, then stores the taken value in v. So values are dropped during iteration. (If they were all dropped at the end, there would have to be some kind of hidden Vec storing them all, which Rust isn't going to implicitly create.)
I was reading this explanation yesterday that values with custom Drop methods only get dropped after they go out of scope stackoverflow.com/a/77241556/3280538 . This case is kind of tricky because v is created initially in a higher level scope, and it's being re-assigned with a new value. I guess in this case, re-assigning the variable counts as it leaving scope immediately?
@flakes Correct. Values are dropped when the scope ends, but they can be dropped earlier for multiple reasons. One is replacing the value in an existing binding; the prior value in that binding is dropped.
@Test I've amended my answer to include an explanation of why the stack overflow happens.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.