back

by lerno·4y ago·view on hn ↗
I don't mind a sane defer in C, that is something which follows scope in the same way stack allocated destructors are invoked. This follows the behaviour in other languages like Swift.

Why "on function exit" style defers - already known to be a bad idea from Go - is beyond me. Such a solution more or less requires storage of potentially unbounded allocations.

    Foo *f;
    for (int i = 0; i < very_big_value; i++) {
      f = get_foo(i);
      // BOOM
      defer return_foo(f);
    }
I would sort of understand if this was proposed for a high level language garbage collection where actual memory usage is secondary.
3 comments
> Why "on function exit" style defers - already known to be a bad idea from Go - is beyond me

Is there something you can point me to about this? I write Go professionally and from a readability and utility standpoint I really like it in common scenarios. I hadn't heard its a know bad idea and am just curious. Thanks.

I think parent means that there are languages with scope-based clean-up (e.g. in rust/c++ a value will be cleaned up at the end of the scope that contained it, so one can even create a separate block inside a function) which is a better choice than forcing people to do clean up at the end of the function.
Note that Rust isn't dropping things "at the end of the scope" but at the end of their lifetime, it's just that if you declare local variables their lifetime ends when they fall out of scope and so this often (but not always, so it's worth remembering to care about lifetimes not scopes) coincides for values in those variables.

Making things more confusing, Rust is inferring scopes you never explicitly wrote, for example Rust brings a new scope into existence whenever you declare a variable with a let statement:

  let good = something.iter().filter(is_good).count(); // good is a usize

  let good = format!("{} of them were good", good); // a String
This is fairly idiomatic Rust, whereas it would sound alarm bells in a lot of languages because their shadowing is dangerous (if you hate shadowing you can tell Rust's linter to forbid this, but may find some other people's Rust hard to read so I suggest trying to see if you can live with it instead).

Obviously that first variable named "good" is gone by the time there's a new variable named good, and so that usize was dropped (but, dropping a usize doesn't do anything interesting, beyond making life harder for a debugger on optimised code since this "variable" may never really exist in the machine code). On the other hand the String in that second variable named "good" has a potentially long lifetime, if it gets out of this local variable before the variable's scope ends.

Because Rust is tracking ownership, it will know whether the String is still in good when that scope ends (so the String gets dropped), or whether it was moved somewhere else (e.g. a big HashMap that lives long after this stack frame). Because it tracks borrowing, it will also notice if in the former case (where the String is to be dropped) there are outstanding references to that String alive anywhere. That's prohibited, the lifetime of the String ends here, so those references are erroneous, your program has an error which will be explained with perhaps a suggestion for how to fix it.

NieDzejkob is correct: In Rust, shadowing a variable has no effect on when the destructor of the previous value runs. Thus there's no problem with retaining a reference to the previous value:

    let a_string = String::from("foo");
    let retained_reference = &a_string;
    let a_string = String::from("bar");
    dbg!(retained_reference);
    dbg!(a_string);
Prints:

    [src/main.rs:5] retained_reference = "foo"
    [src/main.rs:6] a_string = "bar"
Similarly, "non-lexical lifetimes" have no effect on when a destructor runs. The compiler will infer short lifetimes for values that don't need to be destructed (don't implement Drop), but adding a Drop implementation to a type will force every instance's lifetime to extend to end of scope. (Though as in C++, temporaries are still destroyed at the end of the statement that created them, if they're not bound to a local variable.)

The only exception to this rule that I'm aware of is what you mentioned about move semantics: Moving a value means that its destructor will never run. That's the big difference from C++. Everything else to do with destructors is very similar, as far as I know.

To my mind, move semantics being "the only exception" is a pretty bad joke. Unlike C++ Rust's assignment semantics are moves. So, you're not opting in to anything here as with C++ move, this is just how everything works.

For example, if you were to make the second a_string mutable, and then on the next line re-assign it to yet a third string containing "quux", the "bar" string gets dropped immediately, as a consequence of move semantics again.

In C++ you'd have to go write a bunch of code to arrange that, although I believe the standard library did that work for you on the standard strings - but in Rust that's just how the language works, you assigned a new value to a_string so the previous value gets dropped.

> In C++ you'd have to go write a bunch of code to arrange that

I don't think it's quite that bad. If you define a new struct or class that follows the "Rule of Zero (or 3 or 5)", the copy-assignment and move-assignment operators will have reasonable defaults. For example, the following Rust and C++ programs make the same two allocations and two frees.

Rust:

    struct Foo {
        m: String,
    }

    fn main() {
        let mut x = Foo {
            m: "abcdefghijklmnopqrstuvwxyz".into(),
        };
        x = Foo {
            m: "ABCDEFGHIJKLMNOPQRSTUVWXYZ".into(),
        };
    }
C++:

    struct Foo {
      string m;
    };

    int main() {
      auto x = Foo{"abcdefghijklmnopqrstuvwxyz"};
      // Foo's default move-assignment operator is invoked on the temporary.
      x = Foo{"ABCDEFGHIJKLMNOPQRSTUVWXYZ"};
    }
The high-level "you assigned a new value so the previous value gets dropped" behavior is indeed what's happening, and it's automatic in most cases. But when we do voilate the Rule of Zero and override the default constructors/operators, things get quite complicated, and it's easy to make mistakes. (Also in general we often get more copies than we intended, when we're not dealing with temporaries.)

The "moves are implcit and destructive, and everything is movable" behavior in Rust is substantially simpler and often more efficient, and personally I strongly prefer it. But I'll admit that trying to contend with destructive moves without the borrow checker would probably be painful.

That is a common misconception. The destructor runs at the end of the scope. Try putting a println in a drop impl.
If you do this with C++ destructors, they will sure enough fire at the end of the scope. Even if your String is long gone, the destructor fires anyway, destroying... a hollowed out String left behind to satisfy the destructor.

But go ahead and try it in Rust, your print doesn't happen because nothing was actually dropped. The String was moved, and so there isn't anything to drop.

https://gist.github.com/rust-play/4bbcc2a4efb641e578e84a1962...

In this case you wrap the loop in an anonymous function if you need it to be cleaned up within the scope of the loop. Or move the functionality to another function.
Adding a statement that would require a dynamic allocation for every iteration of a loop is kind of insane for C.

It doesn't matter what the defer does, if it's allocated in a for statement and doesn't go off until the surrounding function exits, then it's going to have to stuff tons of function pointers and parameters somewhere, presumably alloca'd onto the stack over and over.

That's not C. That shouldn't be C.

There are a dozen languages for doing clever dynamic magical things in code. C is still fairly straightforward. Use one of the other languages instead of bagging down C with complexity until it turns into another difficult C++ variant over time.

It seems like you could just ban that. Defers aren't typically used within loops, so why support it?
Sure they are, if there is something that is being acquired at the start of the loop and needs to get released at the end...
...of the function? You could append to a slice, after setting up a defer to free everything in the slice.

...of the loop? Call a function.

...or just make defer run at the end of the scope.