4
$\begingroup$

In languages with well defined object lifetimes the destructors of by-value parameters have to be called at some point. This can be the responsibility of the caller or of the callee.

GCC and Clang compiling C++ for example decide to have the caller destroy the arguments. This can can lead to slightly inefficient code if the callee will always move the value out of the argument. For example after passing a std::unique_ptr by value, the caller must always check if the value was moved out of the argument and if not call operator delete on the pointer. Taking unique_ptr by value and moving out unconditionally is a common idiom and the check in the caller is always unnecessary in this case.

Here

#include <memory>

// Will always move out of p
void foo(std::unique_ptr<int> p);

void bar() {
    foo(std::make_unique<int>());
}

is lowered to

bar():
        push    rbx
        mov     edi, 4
        sub     rsp, 16
        call    operator new(unsigned long)
        lea     rdi, [rsp+8]
        mov     DWORD PTR [rax], 0
        mov     QWORD PTR [rsp+8], rax
        call    foo(std::unique_ptr<int, std::default_delete<int> >)
        mov     rdi, QWORD PTR [rsp+8]
        test    rdi, rdi                  ; Null pointer check here!
        je      .L1
        mov     esi, 4
        call    operator delete(void*, unsigned long)
.L1:
        add     rsp, 16
        pop     rbx
        ret
        mov     rbx, rax
        jmp     .L3
bar() [clone .cold]:

Rustc on the other hand generates code where the callee destroys the arguments. So the unique_ptr (or rather Box) example does not require an extra null pointer check.

Here

#[inline(never)]
pub fn foo(p: Box<i32>) {/*...*/}

pub fn bar() {
    foo(Box::new(0));
}

is lowered to

example::bar:
        push    rax
        mov     rax, qword ptr [rip + __rust_no_alloc_shim_is_unstable@GOTPCREL]
        movzx   eax, byte ptr [rax]
        mov     edi, 4
        mov     esi, 4
        call    qword ptr [rip + __rust_alloc@GOTPCREL]
        test    rax, rax
        je      .LBB1_1
        mov     dword ptr [rax], 0
        mov     rdi, rax
        pop     rax
        jmp     qword ptr [rip + example::foo@GOTPCREL] ; Tail call to foo, no cleanup afterwards
.LBB1_1:
        mov     edi, 4
        mov     esi, 4
        call    qword ptr [rip + alloc::alloc::handle_alloc_error@GOTPCREL]
        ud2

So what are the advantages of the "caller destroys arguments" approach?

And, if there are others beside the one mentioned above, what are the advantages of Rustc's "callee destroys arguments" approach?

$\endgroup$
6
  • $\begingroup$ Why must the caller check whether a value was moved from? The moved-from object is in a valid state and so can simply be destructed in the normal way when it goes out of scope. $\endgroup$ Jan 17 at 11:35
  • 1
    $\begingroup$ @TobySpeight Yes, but in the case of std::unique_ptr and many other types the destruction involves a null pointer check. But if the compiler saw that the value was moved out it would statically know that the pointer is null and could elide the entire destructor. $\endgroup$
    – chrysante
    Jan 17 at 11:37
  • 4
    $\begingroup$ You write that “C++ for instance requires the caller to destroy the arguments,” but this seems unlikely to be true to me. I could be wrong, but I don’t think anything in the C++ specification requires this choice, and in fact I don’t think C++ is specified at a low enough level that would permit requiring this choice. Rather, it seems like C++ compilers choose to implement things this way. Is it accurate to say that your question is then why C++ compilers choose to use this calling convention? $\endgroup$
    – Alexis King
    Jan 17 at 21:19
  • $\begingroup$ @AlexisKing The initialization and destruction of each parameter occurs within the context of the full-expression ([intro.execution]) where the function call appears. If I understand this correctly the standard requires it as phrased in my question. But it doesn't really matter to the question if it is the standard or the compiler that made the decision. $\endgroup$
    – chrysante
    Jan 17 at 21:33
  • 5
    $\begingroup$ @chrysante That sentence just means that each parameter must be destructed before the end of the enclosing full-expression. It doesn’t specify where the code to do the destruction must be generated because that decision is not observable under the semantics of the C++ abstract machine. Indeed, the immediately preceding sentence, “It is implementation-defined whether the lifetime of a parameter ends when the function in which it is defined returns or at the end of the enclosing full-expression,” explicitly leaves open both possibilities. $\endgroup$
    – Alexis King
    Jan 17 at 21:42

2 Answers 2

4
$\begingroup$

TL;DR: It may well be historical.

Trip down memory lane

C++ was first standardized in 1998, though it had started its existed 15 years prior already. And only in C++11 were move semantics introduced.

This means that from 13 years (to 28 years) of its existence, moves were not. Instead, passing by value entailed copying the value -- possibly invoking a copy constructor.

In such a scheme, the caller is naturally responsible for cleaning up the value being copied -- if necessary -- and therefore well-suited to cleaning up the argument.

References & Pointers

Another possible justification for having the caller clean-up, is that whenever an argument is passed by reference or pointer, the caller will perform the clean-up already.

Thus, it seems in keeping to also have the caller be responsible for the one case where arguments are passed by value.

ABI Consideration

At the assembly level, arguments are passed either by register, or via the stack.

An arbitrarily large value cannot be passed by register -- today, I believe at most 2 registers are used to pass by value on x64 for example -- and therefore at the assembly level the caller reserves some stack space, initialize it, then passes a pointer to it.

This is symmetrical with the caller reserving some stack space and passing a pointer to it when returning large values, by the way.

It seems consistent to have the caller handle destruction, when it is already handling memory allocation.

ABI Optimization

One clever little trick used by many implementations is the so-called Red Zone.

Whenever a function is invoked which is:

  1. A leaf function: a function invoking no other function.
  2. A frugal function: a function using no more than 128 bytes on the stack.

Then this function skips setting up (and tearing down) a stack frame of its own.

This is a clever trick, as it both minimizes the size of those functions, and minimizes their execution time, even if they are not inlined.

Having the caller take care of the clean-up, rather than the callee, has the advantage of making more callees frugal, and thus eligible for Red Zone optimization. Whether this was foreseen or not, I know not.

Summary so far

Up until C++11, it is therefore "logical" for the caller to be the one cleaning up arguments passed by value.

There's a slight advantage (Red Zone) and it's otherwise in line with cleaning up all other arguments.

Moves enter the scene

When C++11 introduced moves, how to design moves was up in the air.

In the end, the design selected was user-provided move operators -- which automatically gated their introduction on existing types, to avoid introducing bugs -- and for the moved-out object to be left in an unspecified but valid state. In particular, the moved-out object has its destructor invoked.

This all very conservative, essentially mimicking the design of existing copy constructors -- including the "fake" copy-constructor that auto_ptr had at the time which transferred its pointee.

It also has the distinct advantage of being compatible with the existing ABI so far!

I know not whether anyone realized, then, that it was sub-optimal in a number of cases. Even if they did, compatibility may have trumped any such concern.

Conclusion

In the end, I am afraid that the not so satisfying answer is just "it is what it is", and that C++11 simply inherited the mechanics from C++98 (and prior). The migration to C++11 (libstdc++ std::string fiasco) was complicated enough as it is.

The Red Zone argument may still hold, and make the choice a trade-off, but with compilers nowadays inlining more, I'm not too convinced it's worth it.

$\endgroup$
2
  • $\begingroup$ Thank you, this is a great answer! I was mostly asking this to make sure I'm not getting myself into trouble if I implement argument destruction by the callee in my compiler, and this is making me not worried anymore. I do have a follow up question though: how does the compiler know if a function is a leaf or frugal? Or does the red zone optimization only work with functions whose bodies are visible to the compiler, i.e. static functions? $\endgroup$
    – chrysante
    Jan 19 at 16:23
  • 1
    $\begingroup$ @chrysante: the Red Zone optimization is entirely self-contained, as it only modifies the function prologue/epilogue within the function itself. So the compiler knows it because it's compiling the very function it applies the optimization to :) $\endgroup$ Jan 19 at 17:25
1
$\begingroup$

I think the main reason is for backwards compatibility with C varargs (<cstdarg> header).

When C stadrgs are used, the callee does not necessarily know the types of the arguments -- it is legal to pass extra arguments that the callee does not use, and they still need to be cleaned up properly. By having the caller always clean up all arguments, this becomes much less of an issue.

$\endgroup$
1
  • 2
    $\begingroup$ I have to say I'm not convinced. The question is not about who resets the stack pointer but who invokes destructors. And since you cannot pass objects with non-trivial lifetime to C style varargs functions, this should not be related unless I misunderstand your answer. $\endgroup$
    – chrysante
    Jan 18 at 12:09

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .