Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support etl::inplace_function #1046

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

jiangyilism
Copy link

Provide similar api to std::function.

Current use case is mainly to hold a r-value lambda object, which etl::delegate is unsuitable since etl::delegate does not extend the lifetime of r-value lambda object.

Store a callable inside the inplace_function object's member field in a type-erasure way.
The member field, i.e. storage or buffer, has a compile-time fixed size.
The size is specified either by the macro ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY or a non-type template parameter.

The implementation is inspired by:

  1. SG14 inplace_function
  2. folly::Function
  3. function2

using dtor_func_t = void (*)(storage_ptr_t);

const invoke_func_t invoke_func{[](storage_ptr_t, Args&&...) -> R
{ return R(); }};
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

std::function throws std::bad_function_call if operator() is called on an empty object while etl::inplace_function safely returns a default-constructed object in this case.
That means type typename R is required to be default-constructible.
void type is fine here ( https://timsong-cpp.github.io/cppwp/std14/expr.type.conv )
But this will not compile since std::is_default_constructible_v<Type_a> is false:

  struct Type_a
  {
    Type_a(int32_t val) : val_(val) {}

    int32_t val_;
  };

etl::inplace_function<Type_a()> func0;

This deviation from std::function is by designed and also tested in test_inplace_function.cpp

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In etl::delegate an ETL error is raised.
ETL_ASSERT(is_valid(), ETL_ERROR(delegate_uninitialised));

Also in etl::delegate there are call_if and call_or member functions that handle uninitialised objects.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to make it raise etl::bad_inplace_function_call when an empty etl::inplace_function is invoked with operator() . (Similar to std::bad_function_call)
std::function does not support call_if() and call_or(). Do you suggest adding these 2 member func?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jiangyilism I suggest adding them. It avoids having to check if the object is assigned anything, and helps moving from delegate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clear() would also be nice to have for delegate compatibility.

https://github.com/ETLCPP/etl
https://www.etlcpp.com

Copyright(c) 2025 BMW AG
Copy link
Contributor

@jwellbelove jwellbelove Mar 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How close a copy is this of the implementations that 'inspired' you?
There may be copyright implications.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The closest open source implementation is SG14 inplace_function.
etl::inplace_function compiled object memory layout will be almost identical (except alignment).
The main differences:

  1. etl is compatible with c++11 while others, including SG14, typically requires c++14
  2. etl one has no member/non-member function swap(), operator==(nullptr_t), operator!=(nullptr_t) .
  3. etl does not utilize aligned storage, which will be deprecated in c++23.
  4. etl renames most of the class and variable names, except the name "inplace_function", which can be renamed too.
  5. etl simplifies some template meta programming with etl type_traits header
  6. move almost all stuffs in private namespace into private sections of classes.

typename T,
typename C = etl::decay_t<T>,
typename = etl::enable_if_t<
!private_inplace_function::is_inplace_function<C>::value && etl::is_invocable_r<R, C&, Args...>::value>>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class has limited usefulness as is restricted to STL and C++14 and above.
Many of the users of the ETL do not/cannot use the STL.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

etl::is_invocable_r depends on ETL_USING_CPP11 but not STL.
I updated to make etl::inplace_function compatible to c++11.

@jiangyilism jiangyilism force-pushed the yichiang/support_inplace_function branch 2 times, most recently from af095d7 to 064f21d Compare March 13, 2025 16:50
@jiangyilism jiangyilism requested a review from jwellbelove March 13, 2025 16:51
@denravonska
Copy link
Contributor

The missing R-value support blocks us from upgrading ETL. This will be great :)

@jiangyilism jiangyilism force-pushed the yichiang/support_inplace_function branch from 064f21d to 2a9fb5d Compare March 17, 2025 09:21
explicit ETL_CONSTEXPR vtable_t(etl::type_identity<T>) ETL_NOEXCEPT
: invoke_func{[](char* pobj, Args&&... args) -> R
{ return (*reinterpret_cast<T*>(pobj))(
etl::forward<Args&&>(args)...); }},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused about this construction. Why etl::forward<Args&&> instead of etl::forward<Args> is used here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Fixed to etl::forward<Args> .

ETL_ASSERT(vtable_ptr->invoke_func, ETL_ERROR(bad_inplace_function_call));
return vtable_ptr->invoke_func(
obj,
etl::forward<Args>(args)...);
Copy link

@BartolomeyKant BartolomeyKant Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does conversion from Args... to Args&&... is working here?

For example I want to pass some struct Foo by value into inplace_function. Would it be copied three times?

First in call to this operator(), second in invoke_func call, and third in call to actual functor inside the invoke_func lambda?

I'm not an expert I'm just wondering how this should work.

Copy link

@BartolomeyKant BartolomeyKant Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe std::move(args) should be used here, and then std::forward<Args>(args) in invoke_func lambda?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is copied twice under gcc/clang -O0 , -O1 and -O2 . Using etl::move/std::move still copies twice.
A test case NoRedundentCopy is added to verify that during inplace_function::operator(), if the contained callable signature is:

  1. call-by-value: then at most 2 copyings are made in inplace_function::operator().
  2. call-by-reference: then no copying is made.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example gives me one copy and one move https://godbolt.org/z/3YPMccbnz

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. And if the move constructor is not explicitly defined then copy constructor is called instead. NoRedundentCopy only checks the number of copy is less than 3 . It roughly checks that inplace_function is not copying too many times than expected. It does not guarantee the exact number of copying.

With gcc:

  1. copy ctor not explicitly defined : copy twice
  2. copy ctor =default : copy once
  3. copy ctor explicitly defined : copy once

@denravonska
Copy link
Contributor

Out of curiosity, what's using all the space? I had to bump the size to 48 bytes for an inplace_function<void(void *)> on Cortex-M33.

@rolandreichweinbmw
Copy link
Contributor

Out of curiosity, what's using all the space? I had to bump the size to 48 bytes for an inplace_function<void(void *)> on Cortex-M33.

Depends on what you save inside. If it is just a "void f1(void*){}" it maybe fits?

But if it's a lambda capturing a number of arguments by value inside, it may be much more.

@rolandreichweinbmw
Copy link
Contributor

John, can you please enable the CI workflows for this PR?

@denravonska
Copy link
Contributor

Out of curiosity, what's using all the space? I had to bump the size to 48 bytes for an inplace_function<void(void *)> on Cortex-M33.

Depends on what you save inside. If it is just a "void f1(void*){}" it maybe fits?

But if it's a lambda capturing a number of arguments by value inside, it may be much more.

Actually, I accidentally compiled for host so it's 48 bytes on a 64 bit system which makes a bit more sense.

@BartolomeyKant
Copy link

64 bit system

What if ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY would be calculated based on pointer size, like sizeof(void*)*4?

#include "../utility.h"

#ifndef ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY
#define ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY 32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be based on the target architecture? When building for 64 bit the bare minimum (void()) seems to be 40 bytes.

@rolandreichweinbmw
Copy link
Contributor

rolandreichweinbmw commented Apr 3, 2025

64 bit system

What if ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY would be calculated based on pointer size, like sizeof(void*)*4?

Similar to etl::delegate and etl::function, inplace_function has one single global size.

This one needs to store all possible closures in a whole project that uses ETL for a inplace_function template instance type. I.e. it depends.

E.g. for a simple etl::inplace_function<void(void *)> as mentioned by Denravonska, you might save different closure implementations, e.g.:

  • small: void f1(void*){}
  • big: [=]<void(void *)>(){a(b);c(d);e(f);g(h);}

I.e. needs to be adjusted if necessary for every project, to not waste memory.

I'm not sure if you can find a way to automatically calculate a global maximum size over all user locations. Finally, you can only find at link time (or after compiling all comile units) if you have captured all necessary source locations.

@BartolomeyKant
Copy link

BartolomeyKant commented Apr 3, 2025

This one needs to store all possible closures in a whole project that uses ETL

I agree with that, and it's the library user's responsibility to use only closures that fit or provide a large enough value to inplace_function Capacity.

But why did 32 was chosen for default?

For me the usual case closure captures some values by reference i.e. maximum 4 pointers for 32 byte storage on 64 bit system.
What if I had tested my application on system with 4 bytes pointers and capture 8 pointers in closure, and then moved to 8 byte sized pointer system?

@denravonska also asked about ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY based on architecture.

I think solution based on sizeof(void*) is the simplest. The smaller pointers the smaller inplace_function and vice versa.

@rolandreichweinbmw
Copy link
Contributor

Hi @BartolomeyKant and @denravonska , I think now I got your point. :-) And I agree that different defaults for e.g. 32 and 64 bit platforms can be provided.

Even though an individual ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY will typically be part of a project's etl_profile.h anyway.

The original default of 32 covered our initial use cases in a 32 bit project.

Luckily, the compiler will tell you at build time if it doesn't fit, as shown in @denravonska 's example.

@denravonska
Copy link
Contributor

Hi @BartolomeyKant and @denravonska , I think now I got your point. :-) And I agree that different defaults for e.g. 32 and 64 bit platforms can be provided.

Even though an individual ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY will typically be part of a project's etl_profile.h anyway.

The original default of 32 covered our initial use cases in a 32 bit project.

Luckily, the compiler will tell you at build time if it doesn't fit, as shown in @denravonska 's example.

I thought about this a bit more. My original concern was that the default size was valid for 32 bit but failed to compile on 64 bit, so maybe the default should be the smallest size required to fit the simplest inplace function given the target architecture.

However this only applies to the default function. As soon as you do anything fancier you need to handle the size differences in the application anyway.

@jiangyilism
Copy link
Author

Thanks for all the feedback.
@jwellbelove As you said in the slack channel discussion, your have a plan for a new fixed-storage function/delegate . Do you think I should drop this PR?

@denravonska
Copy link
Contributor

Thanks for all the feedback. @jwellbelove As you said in the slack channel discussion, your have a plan for a new fixed-storage function/delegate . Do you think I should drop this PR?

He also said

I may decided to go with the etl::inplace_function instead.

I have no major quarrel with the implementation. The only request I have so far is a default size that works for both 32- and 64 bit systems.

@jwellbelove
Copy link
Contributor

@jiangyilism No, keep it active for the time being. I'm in Greece at the moment, so I'll look at this functionality again when I get back next week.

@jiangyilism jiangyilism force-pushed the yichiang/support_inplace_function branch from 2a9fb5d to a8c9a54 Compare April 7, 2025 06:09
@jiangyilism
Copy link
Author

@denravonska , @BartolomeyKant
ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY is changed to 8 * sizeof(void*) .

@jiangyilism jiangyilism force-pushed the yichiang/support_inplace_function branch from a8c9a54 to 2362d9d Compare April 7, 2025 06:15
{
SUITE(test_inplace_function)
{
TEST(NoRedundentCopy)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NoRedundantCopy

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Copy link
Contributor

@denravonska denravonska left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be a quality of life change with .call_if(). Consider

for(auto cb : callbacks) {
   if(cb) {
      cb();
   }
}

vs

for(auto cb : callbacks) {
   cb.call_if();
}

That, .call_or and a .clear would make it a lot more compatible with delegate.

Provide similar api to std::function.
Store a callable inside the inplace_function object's member field in a
type-erasure way. The member field, i.e. storage or buffer, has a
compile-time fixed size. The size is specify either by the macro
ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY or a non-type template parameter.

The implementation is inspired by:
1. SG14 inplace_function
2. folly::Function
3. function2
@jiangyilism jiangyilism force-pushed the yichiang/support_inplace_function branch from 2362d9d to d5726a9 Compare April 7, 2025 12:59
@jiangyilism
Copy link
Author

call_if

std::function does not support call_if, call_or, clear .
std::function and etl::inplace_function clear its content with operator=(nullptr_t) noexcept, which is now tested in HasValue test case.

If we want all etl::delegate api then it might be better to make inplace_function and delegate into a same class.
We need @jwellbelove 's feedback to continue with one of the directions:

  1. extend inplace_function with api compatible with std::function. We can rename inplace_function though.
  2. merge inplace_function into delegate
  3. merge inplace_function (and delegate?) into etl::function
  4. something else?

@denravonska
Copy link
Contributor

denravonska commented Apr 7, 2025

call_if

std::function does not support call_if, call_or, clear . std::function and etl::inplace_function clear its content with operator=(nullptr_t) noexcept, which is now tested in HasValue test case.

If we want all etl::delegate api then it might be better to make inplace_function and delegate into a same class. We need @jwellbelove 's feedback to continue with one of the directions:

  1. extend inplace_function with api compatible with std::function. We can rename inplace_function though.
  2. merge inplace_function into delegate
  3. merge inplace_function (and delegate?) into etl::function
  4. something else?

I'm thinking more from the convenience perspective. I think a very common use case is going to be moving from delegate to inplace_function once you realize the limitations of the former and are willing to pay the cost of the latter. delegate has the luxury of not having a real counter part in STL so it can be a bit more free in its API.

I guess it all boils down to how true to the standard ETL wants to be in terms of adding functionality that's outside of the specification.

@rolandreichweinbmw
Copy link
Contributor

I'd propose @jiangyilism 's option 1. in this way: Be close to std::function to minimize confusion, and if possible rename etl::inplace_function to etl::function. The latter of course only if @jwellbelove decides to abandon the old (already deprecated) etl::function.

@jwellbelove
Copy link
Contributor

jwellbelove commented Apr 7, 2025

@rolandreichweinbmw The current etl::function (based on virtual functions) is indeed deprecated.
I have a complete std::function clone in a private branch, although it currently has the same limitation as etl::delegate when it comes to lambdas, which is why I 'parked' it for the time being. Its only real advantage over etl::delegate was that it had the same API as std::function (apart from lambdas) and could accept runtime function pointers.
It's possible I may be able to add runtime function pointers to etl::delegate without affecting constexpr compatibility.

I don't have any problem with adding functions that enhance the usability of an ETL class; making an exact clone of the STL was never my intention.

@jwellbelove
Copy link
Contributor

std::function does not support call_if, call_or, clear .\nstd::function and etl::inplace_function clear its content with operator=(nullptr_t) noexcept

I've always treated the STL as inspiration for classes in the ETL, not as a rigid specification for what can and can't be included.

@jwellbelove
Copy link
Contributor

I have limited options to review this PR at the moment, as I'm on holiday in Kos. I'm not quite sure when I will get back, as the Greek air traffic controllers are threatening a strike on the day we are supposed to fly home, and the postponed flights may be backed up for a day or two.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In progress
Development

Successfully merging this pull request may close these issues.

5 participants