-
-
Notifications
You must be signed in to change notification settings - Fork 464
Support etl::inplace_function #1046
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
base: master
Are you sure you want to change the base?
Support etl::inplace_function #1046
Conversation
using dtor_func_t = void (*)(storage_ptr_t); | ||
|
||
const invoke_func_t invoke_func{[](storage_ptr_t, Args&&...) -> R | ||
{ return R(); }}; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- etl is compatible with c++11 while others, including SG14, typically requires c++14
- etl one has no member/non-member function
swap()
,operator==(nullptr_t)
,operator!=(nullptr_t)
. - etl does not utilize aligned storage, which will be deprecated in c++23.
- etl renames most of the class and variable names, except the name "inplace_function", which can be renamed too.
- etl simplifies some template meta programming with etl type_traits header
- 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>> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
af095d7
to
064f21d
Compare
The missing R-value support blocks us from upgrading ETL. This will be great :) |
064f21d
to
2a9fb5d
Compare
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)...); }}, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)...); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
- call-by-value: then at most 2 copyings are made in
inplace_function::operator()
. - call-by-reference: then no copying is made.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
- copy ctor not explicitly defined : copy twice
- copy ctor =default : copy once
- copy ctor explicitly defined : copy once
Out of curiosity, what's using all the space? I had to bump the size to 48 bytes for an |
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. |
John, can you please enable the CI workflows for this PR? |
Actually, I accidentally compiled for host so it's 48 bytes on a 64 bit system which makes a bit more sense. |
What if ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY would be calculated based on pointer size, like |
#include "../utility.h" | ||
|
||
#ifndef ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY | ||
#define ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY 32 |
There was a problem hiding this comment.
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.
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.:
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. |
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 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. @denravonska also asked about ETL_INPLACE_FUNCTION_DEFAULT_CAPACITY based on architecture. I think solution based on |
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. |
Thanks for all the feedback. |
He also said
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. |
@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. |
2a9fb5d
to
a8c9a54
Compare
@denravonska , @BartolomeyKant |
a8c9a54
to
2362d9d
Compare
test/test_inplace_function.cpp
Outdated
{ | ||
SUITE(test_inplace_function) | ||
{ | ||
TEST(NoRedundentCopy) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NoRedundantCopy
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
There was a problem hiding this 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
.
2362d9d
to
d5726a9
Compare
std::function does not support call_if, call_or, clear . If we want all etl::delegate api then it might be better to make inplace_function and delegate into a same class.
|
I'm thinking more from the convenience perspective. I think a very common use case is going to be moving from 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. |
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. |
@rolandreichweinbmw The current 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. |
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. |
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. |
I still have some concerns with copyright issues. etl renames most of the class and variable names, except the name "inplace_function", which can be renamed too. This seems to imply that this is not a 'cleanroom implementation', but actually contains a large proportion of copy/pasted code from another copyrighted project. |
static const vtable_t default_vtable; | ||
|
||
const vtable_t* vtable_ptr{&default_vtable}; | ||
mutable char obj[Capacity]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How can you ensure that this storage is aligned correctly for the type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can adopt etl::aligned_storage
here. However, should we proceed with this PR or compare the implementation with the etl::function v2 ( https://etlcpp.slack.com/archives/C7U0HPFJA/p1742585235826159 ) first?
I'm assuming a lot of this would be "inspired" by the sg14 implementation. This implementation however somehow indicates "Copyright(c) 2025 BMW AG". Is that in itself even compatible with the ETL? |
As discuss earlier, |
Copyright can be by any contributor. So it is "compatible" be default, and license is MIT. |
Provide similar api to std::function. Store a callable inside the 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.
d5726a9
to
41517ae
Compare
@jwellbelove About the aligned_storage adoption, does
|
I'm not sure why changing the name (which to me sounds less like what it does than inplace_function, which resembles for examples std::inplace_vector) changes anything copyright related? |
No matter how it deviate from sg14, there still share some similarity. All binaries look like a sequence of zeros and ones... If the current deviation is not enough, we need to decide what need to be met to avoid copyright issue. |
IANAL, but i don't think using SG14 as a source for your version is a problem as long as you honor the original license. The original Boost license of SG14 states that:
This means (IMHO, again IANAL) you can't change copyright to BMW AG (or any other entity) if the original copyright was claimed elsewhere and you can't change the license to MIT as the original license should be included. The alternative is to create a cleanroom implementation but this can't be done by you as you already saw & used the SG14 version. Just changing some names in the implementation is not a solution at this point. |
If I understand the etl design correctly, we want to avoid introducing boost license. |
@coderabbitai review |
✅ Actions performedReview triggered.
|
WalkthroughThis update introduces a fixed-capacity, type-erased owning callable wrapper ( Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant owning_callable
participant Callable
participant Exception
User->>owning_callable: Construct with Callable
owning_callable->>Callable: Store in internal buffer
User->>owning_callable: Invoke operator()
owning_callable->>Callable: Call with arguments
Callable-->>owning_callable: Return result
owning_callable-->>User: Return result
User->>owning_callable: Invoke operator() on empty
owning_callable->>Exception: Throw bad_owning_callable_call
Exception-->>User: Exception thrown
Estimated code review effort🎯 4 (Complex) | ⏱️ ~40 minutes Poem
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.38.6)include/etl/type_traits.hNote 🔌 MCP (Model Context Protocol) integration is now available in Early Access!Pro users can now connect to remote MCP servers under the Integrations page to get reviews and chat conversations that understand additional development context. ✨ Finishing Touches
🧪 Generate unit tests
🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (7)
include/etl/type_traits.h (2)
2504-2528
: Minor clean-up – specialise once forvoid
There’s no observable benefit in duplicating the specialisation for
void
andconst void
; the latter isn’t a valid return type and the extra template instantiation bloats compile times a little. Trimming to a singlevoid
specialisation keeps things tidy.
2487-2492
: Alias template parameter name could mirror the standardThe alias template uses a generic
typename... Types
pack.
Adopting the standard’s(R, F, Args...)
improves readability and grep-ability for anyone accustomed tostd::is_invocable_r
.template <typename R, typename F, typename... Args> using is_invocable_r = std::is_invocable_r<R, F, Args...>;include/etl/owning_callable.h (1)
36-38
: Consider an#else
path for pre-C++11 buildsIf a user compiles the ETL in C++03 mode, this header quietly becomes empty.
Adding an#else #error
branch makes the limitation explicit and avoids silent ODR surprises.#if ETL_USING_CPP11 #include "private/owning_callable_cpp11.h" #else #error "etl::owning_callable requires C++11 or later" #endifinclude/etl/private/owning_callable_cpp11.h (3)
25-28
: Licence header contains stray “rhs” tokensThe MIT licence text has been mangled (
…LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR rhsWISE…
).
Please remove the “rhs” artefacts to avoid any legal ambiguity.
153-162
: Copy-assignment performs an unnecessary extra copyThe parameter is passed by value and then moved from, so the callable is copied twice.
Consider the classical copy-and-swap:- owning_callable& operator=(owning_callable rhs) ETL_NOEXCEPT + owning_callable& operator=(owning_callable rhs) ETL_NOEXCEPT { - vtable_ptr->dtor_func(obj); - vtable_ptr = rhs.vtable_ptr; - rhs.vtable_ptr = &default_vtable; - vtable_ptr->move_func(obj, rhs.obj); + rhs.swap(*this); // noexcept return *this; } + + void swap(owning_callable& other) ETL_NOEXCEPT + { + etl::swap(vtable_ptr, other.vtable_ptr); + char tmp[Capacity]; + vtable_ptr->move_func(tmp, obj); + other.vtable_ptr->move_func(obj, other.obj); + vtable_ptr->move_func(other.obj, tmp); + }This removes the superfluous copy while keeping strong exception safety.
112-118
: Cross-capacity copy only in constructor, not assignmentYou allow
owning_callable<CapA>
→owning_callable<CapB>
copy via the templated constructor but not via assignment. Providing a matching templated assignment operator would round-off the API.test/test_owning_callable.cpp (1)
341-347
: Beware silent narrowing in tests using floating literalsCalls such as
f1(7.58)
rely on implicitdouble → float
conversion followed byfloat → int32_t
truncation in the callable. The test passes today but conceals two silent truncations.For clarity (and to avoid future surprises with
-Wconversion
):CHECK_EQUAL(7, f1(7.58f)); // explicit float literal
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
include/etl/file_error_numbers.h
(1 hunks)include/etl/owning_callable.h
(1 hunks)include/etl/private/owning_callable_cpp11.h
(1 hunks)include/etl/type_traits.h
(1 hunks)test/CMakeLists.txt
(1 hunks)test/test_owning_callable.cpp
(1 hunks)test/test_type_traits.cpp
(1 hunks)
🔇 Additional comments (4)
include/etl/file_error_numbers.h (1)
110-110
: Looks good – ID sequence preservedThe new
ETL_OWNING_CALLABLE_FILE_ID
follows the existing sequential numbering scheme and doesn’t clash with any prior IDs. No action needed.include/etl/type_traits.h (1)
2495-2501
: PassR
by reference to avoid accidental copy requirements
accept(R)
takes its parameter by value; that forcesR
to be copy-constructible.
std::is_invocable_r
only requires convertibility, not copiability, so the current approach can incorrectly yieldfalse
for move-only return types.-template <typename R> -void accept(R); +template <typename R> +void accept(R&&) noexcept;Using a forwarding reference removes the unintended copy constraint while preserving the convertibility check.
Likely an incorrect or invalid review comment.
test/CMakeLists.txt (1)
220-224
: Thanks for wiring in the new tests
test_owning_callable.cpp
is added in alphabetical order, so the list stays organised. Nice one.test/test_type_traits.cpp (1)
1476-1498
: Great coverage foris_invocable_r
The new test exercises both the trait and its
_v
shorthand across positive and negative cases – exactly what we need to guard against regressions.
|
||
const vtable_t* vtable_ptr{&default_vtable}; | ||
mutable char obj[Capacity]; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Object buffer is not guaranteed to be suitably aligned
char obj[Capacity];
provides only 1-byte alignment, which is insufficient for many types (e.g. double
, long long
, SIMD types). Constructing such a type here is undefined behaviour on some targets.
A minimal fix:
- mutable char obj[Capacity];
+ alignas(std::max_align_t) mutable char obj[Capacity];
or, if available in ETL:
- mutable char obj[Capacity];
+ typename etl::aligned_storage_t<Capacity>::type obj;
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
const vtable_t* vtable_ptr{&default_vtable}; | |
mutable char obj[Capacity]; | |
const vtable_t* vtable_ptr{&default_vtable}; | |
alignas(std::max_align_t) mutable char obj[Capacity]; |
🤖 Prompt for AI Agents
In include/etl/private/owning_callable_cpp11.h around lines 226 to 229, the
object buffer obj is declared as char array which only guarantees 1-byte
alignment, causing undefined behavior for types requiring stricter alignment. To
fix this, replace the char array with a properly aligned storage type such as
std::aligned_storage or an equivalent ETL type if available, ensuring the buffer
has the correct size and alignment for any stored type.
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:
Summary by CodeRabbit
New Features
Tests
Chores