6

I am trying to write a function, make_foo, that will "unwrap" a std::optional< foo >, returning the contained value. The function assumes that the optional is engaged so does not perform any runtime checks on the optional.

My implementation of this is below, along with the compiled assembly for reference. I have a couple of questions about the compiler output:

  1. Why does this result in branching code? optional::operator* gives unchecked access to the contained value, so I would not expect to see any branching.

  2. Why does foo's destructor get called? Note the call to on_destroy() in the assembly. How do we move the contained value out of the optional without calling the destructor?

Godbolt link

C++17 source

#include <optional>

extern void on_destroy();

class foo {
  public:
    ~foo() { on_destroy(); }
};

extern std::optional< foo > foo_factory();

// Pre-condition: Call to foo_factory() will not return nullopt
foo make_foo() {
    return *foo_factory();
}

Optimized compiler output (Clang 11)

make_foo():                           # @make_foo()
        push    rbx
        sub     rsp, 16
        mov     rbx, rdi
        lea     rdi, [rsp + 8]
        call    foo_factory()
        cmp     byte ptr [rsp + 9], 0
        je      .LBB0_2
        mov     byte ptr [rsp + 9], 0
        call    on_destroy()
.LBB0_2:
        mov     rax, rbx
        add     rsp, 16
        pop     rbx
        ret
jonnybolton
  • 487
  • 4
  • 7
  • 6
    You can't avoid the destruction. `std::optional< foo >` has a `foo` in it. Even if that `foo` gets moved, the optional still has to destroy the stub that is left. – NathanOliver Mar 22 '21 at 14:04
  • 3
    A moved-from instance is still an instance. It will get destroyed when the `optional` is destroyed, even if that destruction has nothing to clean up. Your destructor should check for a moved-from instance. If your type supports move semantics, it would be very suspicious for that destructor to *always* do something meaningful. – François Andrieux Mar 22 '21 at 14:05
  • 1
    and the check is because the pre-condition is unknown from compiler, and it need to know that to "select" correct destructor. – Jarod42 Mar 22 '21 at 14:11
  • This might help you understand moving a little bit better. Lets pretend we a cardboard box that has things inside it (this models a `vector`). When you move that box, you don't actually move the box. What you do is open a new empty box, scoop out the contents of the old box, and put them into the new box. When you are done, the old box is still there, it's just empty. This is what moving does. After the move is complete, you need to get rid of that empty box, and that happens via a call to the destructor of the box. – NathanOliver Mar 22 '21 at 14:20
  • Thanks for the comments. That has helped clarify what is going on. @FrançoisAndrieux, in my actaul code the class is an RAII-wrapper around two C API functions (initialize library in constructor, clean up library in destructor). From what you have suggested, I think the solution for me will be to add a `bool` member variable to `foo` which indicates whether the cleanup in the destructor is required or not. – jonnybolton Mar 22 '21 at 14:20
  • 1
    To make this experiment more interesting, write a more realistic test class with move semantics and an optional call to `on_destroy` that only happens if the object was not moved from. Now the optimizer challenge is to detect a move in `make_foo`, track that state to the dtor, and eliminate the call to `on_destroy` there. – MSalters Mar 22 '21 at 14:21
  • @jonnybolton That should work. But typically, C API handle types have specific values to indicate "no object" such as `NULL` for pointer-type handles or something like Windows API's `INVALID_HANDLE` for integer-type handles. Check the C API you are using for such constants. – François Andrieux Mar 22 '21 at 14:22
  • If you swap `std::optional` with a `std::unique_ptr` the instance is not destroyed. However, this will add dynamic allocation... Anyhow, if "Pre-condition: Call to foo_factory() will not return nullopt", why use `std::optional` at all? – JHBonarius Mar 22 '21 at 15:02
  • 1
    You can't get rid of the destructor call because moved-from objects are still objects, and the `on_destroy` call is opaque to the compiler in this context and can't be inlined -- but you _can_ get rid of the branch by hinting to the compiler that the branch is always a specific case using `__builtin_unreachable`. ([godbolt link](https://godbolt.org/z/scsaK3GxG)) – Human-Compiler Mar 22 '21 at 15:28

2 Answers2

3

Your method is more or less this:

foo make_foo() {
   auto x = foo_factory(); 
   return *x;
}

Where x is the optional returned from the factory (unnamed temporary in your code). When x is destroyed, it either calls the destructor of the contained object (when there is one). Or it does not destroy the contained object (when there is none). In short: The foo you moved from still needs to be destroyed and even though you know that the optional does contain it, the compiler cannot, hence the branch.

How to move a value out of a std:optional without calling the destructor?

You can't. Even a moved from object needs to be destroyed eventually.

463035818_is_not_an_ai
  • 109,796
  • 11
  • 89
  • 185
3

How to move a value out of a std:optional without calling the destructor?

Like you did in the example. The destructor is not called by the move. The destructor of foo is called by the destructor of the std::optional which is called by the destruction of the temporary std::optional object that you created.

You can only prevent an object from being destroyed by leaking it, or by avoiding creation (and thus also the move) of the object in the first place.

Why does this result in branching code?

There is a branch in the destructor of std::optional. The destructor of the contained object is called only if the std::optional is not empty.

optional::operator* gives unchecked access to the contained value, so I would not expect to see any branching.

In theory, if the optimiser was smart enough, it might use this knowledge to call the destructor unconditionally, since it might know that behaviour of the program is undefined if the function returned an empty std::optional. It seems to not have been smart enough to make such optimisation.

eerorika
  • 232,697
  • 12
  • 197
  • 326