Make the compiler optimize away function call indirection with lambda in type erasure

Viewed 128

I am using type erasure to obtain for any class full with a member function void work(char&) a type erased handle with the class erased.

// erase.hxx
#pragma once
#include <memory>

struct erased
{
private:
    using fn_t = void(*)(void*, char&);
    void* self;
    fn_t  fn;

public:
    template<typename F>
    explicit
    erased(F& full) noexcept
        : self(std::addressof(full)),
          fn([](void* self, char& c) { static_cast<F*>(self)->work(c); })
    {}

    void work(char& c) { fn(self, c); }
};

// full.hxx
#pragma once

struct full
{
    void work(char&);
};

// full.cxx
#include "full.hxx"
#include <cstdio>

// Implemented here to prevent inlining.
void full::work(char&) { puts("Working hard!"); }

// main.cxx
#include "erased.hxx"
#include "full.hxx"

template erased::erased(full&);

int main()
{
    char c;
    auto x  = full{};
    auto ex = erased{x};
    ex.work(c);
}

My question now arises when looking at the generated assembly (GCC 10.2.0 and Clang 11.1.0 at -O3):

0000000000001190 <erased::erased<full>(full&)>:
    1190:   48 89 37                mov    QWORD PTR [rdi],rsi
    1193:   48 8d 05 06 00 00 00    lea    rax,[rip+0x6]        # 11a0 <erased::erased<full>(full&)::{lambda(void*, char&)#1}::__invoke(void*, char&)>
    119a:   48 89 47 08             mov    QWORD PTR [rdi+0x8],rax
    119e:   c3                      ret    
    119f:   90                      nop

00000000000011a0 <erased::erased<full>(full&)::{lambda(void*, char&)#1}::__invoke(void*, char&)>:
    11a0:   e9 0b 00 00 00          jmp    11b0 <full::work(char&)>
    11a5:   66 2e 0f 1f 84 00 00    cs nop WORD PTR [rax+rax*1+0x0]
    11ac:   00 00 00 
    11af:   90                      nop

The field erased::fn is made to point at the lambda created in erased::construct, and the body of this lambda does nothing but immediately yield control to full::work.

Since the lambda and full::work appear to be binary compatible I would have liked for the compiler to have done away with the lambda and to have directly stored the address of full::work in erased::fn, eliminating an unnecessary indirection.

So my question is:

  • Why has the compiler not done this? And more importantly,
  • How can I tell the compiler to do it?

Edit

I changed the implementation of struct erase to make it impossible to ever call erased::fn with anything that is not a pointer to the same type F that was used in the static_cast<F*> in the lambda.

Even without that I suspect it would be fine to assume that the void* self passed to the lambda is always a pointer to F because F::work is invoked on it and this:

Calling a function through an expression whose function type is different from the function type of the called function's definition results in undefined behavior.

Furthermore I made the example a little more realistic by changing the function type to using fn_t = void(*)(void*,char&): it now takes an argument in addition to the this pointer.

This is to illustrate that even though in the example above the optimization I am asking about should be possible, it will not be possible when F::work has the signature void work(char): a copy of c would have to be made: the body of the lambda would no longer consist of only jmp.

I would prefer solutions where both cases work and the compiler decides whether the optimization is possible.

Otherwise, I know that I could force an exact match of the argument type with this:

template<typename M, typename... Args>
struct method_with_args : std::false_type {};

template<typename F, typename R, typename... Args>
struct method_with_args<R(F::*)(Args...), Args...>
    : std::true_type{};
1 Answers

(Maybe I'm not right here, but) "the lambda and full::work appear to be binary compatible" is not really true. A member function has a hidden first parameter: a pointer to the member object (here: F* this).

The function pointer to F::work would therefore be void(F::*)(void). That's not the same as void(*)(void*), thus cannot be replaced as such.

Maybe this FAQ on isocpp will give some insight.

Related