Omni Function
omni::function
is an ABI safe, DLL boundary safe polymorphic function intended to be used in Omniverse Native Interfaces that provides the
functionality of std::function
.
An omni::function
satisfies std::is_standard_layout
Standard usage is identical to std::function
:
// Basic usage of binding to a lambda expression:
omni::function<int(int)> f = [](int x) { return x + 1; };
assert(f(1) == 2);
// Or bind to a free function:
int double_it(int x)
{
return x * 2;
};
f = double_it;
assert(f(5) == 10);
// Closures are automatically captured:
auto& output = std::cout;
omni::function<void(omni::string)> print =
[&output](omni::string s)
{
output << s << std::endl;
};
print("A message");
void (*printfn)(char const*) =
[](char const* msg)
{
output << msg << std::endl;
};
// use of std::bind will also work:
print = std::bind(printfn, "Hello again!");
print();
omni::function
Features
Relocatable
An omni::function
is trivially relocatable.
This is similar to trivially move constructible, in
that std::memcpy
is an acceptable substitute for actual move-construction, but with the qualification that the
moved-from location does not get the destructor called.
Note
The examples in this section are demonstration only.
You should prefer using the language-specific bindings for working with omni::function
, like operator()
, the
copy and move constructors and assignment operators, and the destructor in C++.
using function_type = omni::function<void()>;
using function_buf = alignas(function_type) char[sizeof(function_type)];
function_buf buf1, buf2;
new static_cast<void*>(buf1) function_type([] { std::println("Hello world\n"); });
// Relocate through memcpy is safe
std::memcpy(buf2, buf1, sizeof buf2);
// buf2 now acts as a function_type
(*reinterpret_cast<function_type*>(buf2))();
// Destroy through the relocated-to position, not the relocated-from
reinterpret_cast<function_type*>(buf2)->~function_type();
This concept is useful when interacting with C, where concepts like copy and move construction do not exist.
When shared with C, an omni::function
can be used with a byte-for-byte copy; no wrapper indirection is needed.
This allows for natural usage.
/* signature: omni_function omni_foo(void); */
omni_function fn = omni_foo();
/* signature: void my_own_code(omni_function f);
The backing structure is copied into the target, which is safe. */
my_own_code(fn);
omni_function_free(fn);
Interaction with std::function
and std::bind
Despite the lack of ABI safety for std::function
and std::bind
, omni::function``s created using these
facilities are ABI safe.
Because the Standard Library implementations are inlined, the ``omni::function
will call code which is compatible with
the compilation parameters of the binding site.
Function binding requires that certain targets create an unbound function wrappers.
For example, a function pointer which is null (omni::function<void()>{static_cast<void(*)()>(nullptr)}
) or a
function wrapper which is unbound (omni::function<void()>{omni::function<void()>{nullptr}}
) both yield unbound
omni::function``s.
The implementation also understands ``std::function``s, so binding an ``omni::function
to an unbound std::function
works as you would expect.
std::function<void()> std_fn; // <- unbound
// The omni::function will be created unbound
omni::function<void()> omni_fn = std_fn;
if (!omni_fn)
printf("It works!\n");
This property is not commutative.
Since std::function
does not understand omni::function
, an std::function
binding to an unbound
omni::function
will successfully bind to a target that can never be successfully called.
omni::function<void()> omni_fn; // <- unbound
// The std::function will be bound to call the unbound omni::function
std::function<void()> std_fn = omni_fn;
if (std_fn)
std_fn(); // <- calls the unbound omni_fn
If conversion from omni::function
to std::function
is required, the omni::function
should be checked to see
if it is bound first.
Note
This conversion can not be done implicitly because of the std::function
constructor which accepts a universal
reference to bind to (C++ Standard 2020, §22.10.17.3.2/8).
Conversion operators on omni::function
which target a std::function<UReturn(UArgs...)>
will be ambiguous
with the candidate constructor accepting template F&&
.
Missing Features from std::function
While omni::function
is generally compatible with std::function
, there are a few features that are not supported.
Allocators
The constructors for std::function
support std::allocator_arg_t
-accepting overloads which allows for a
user-supplied allocator to be used when the binding needs to allocate.
This was inconsistently implemented and removed from the C++ Standard in the 2017 edition.
Because of this, omni::function
does not support these allocator-accepting overloads.
It will always use the carbReallocate()
function for allocation on bindings.
Target Access
The member functions target_type()
and target()
allow a caller to access the type information and value of the
functor the omni::function
is bound to.
These are problematic because they require run-time type information, which Carbonite does not require and many builds
disable.
Even when RTTI is enabled, the std::type_info
is not ABI safe, so attempting to use the types returned from these
functions would not be safe, either.
These facilities are unhelpful in Omniverse at large, since the target of an omni::function
might be implemented in
another language; getting PythonFunctionWrapper
or RustTrampoline
is not all that useful.
Developers are encouraged to find a safer facility instead of attempting to access the target function through the
polymorphic wrapper.
For example, you can keep the part you need to access in a shared_ptr
and bind the function to access that:
struct Foo
{
int value{};
int operator()(int x) const
{
return x + value;
}
};
int main()
{
// An std::function supports accessing the bound target:
std::function<int(int)> with_target{Foo{}};
with_target.target<Foo>()->value = 5;
assert(with_target(4) == 9);
// Instead, you can track Foo through a shared_ptr
auto foo_ptr = std::make_shared<Foo>();
omni::function<int(int)> omni_style = std::bind(&Foo::operator(), foo_ptr);
foo_ptr->value = 5;
assert(omni_style(4) == 9);
}
Why not std::function
?
There are two major reasons to have omni::function
instead of relying on std::function
.
The primary reason is std::function
is not guaranteed to be ABI stable, even across different compilation flags with
the same compiler.
Even if std::function
guaranteed ABI stability, some binding targets require allocations, which need to go through
the Omniverse carbReallocate()
to support construction in one module and destruction in another.
Details
Data Layout
The core functionality of omni::function
lives in omni::detail::FunctionData
, a non-templated structure.
A omni::function
consists only of this FunctionData
, the templated type only providing structural sugar for
function calls and lifetime management in C++.
Core data is designed to be used from other languages, with lifetime management and call syntax being left to language
specific facilities.
struct FunctionCharacteristics;
constexpr std::size_t kFUNCTION_BUFFER_SIZE = 16U;
constexpr std::size_t kFUNCTION_BUFFER_ALIGN = alignof(std::max_align_t);
union alignas(kFUNCTION_BUFFER_ALIGN) FunctionBuffer
{
char raw[kFUNCTION_BUFFER_SIZE];
void* pointer;
// other alternatives excluded
};
struct FunctionData
{
FunctionBuffer buffer;
void* trampoline;
FunctionCharacteristics const* characteristics;
};
The trampoline
value is a pointer to a function with the signature TReturn (*)(FunctionBuffer const*, TArgs...)
.
It exists to restore context from the FunctionBuffer
, which are user-defined additional details needed to perform
the function call, covered in the next paragraph.
If trampoline
is nullptr
, then the function is not active, regardless of any other value.
Note
Care must be taken to ensure the FunctionData
for a function with one signature is not mixed with the signature
of another.
Type safety for this is maintained by language specific bindings (e.g.: in C++, a
omni::function<TReturn(TArgs...)>
cannot be mixed with a omni::function<UReturn(UArgs...)>
).
The buffer
field stores extra information for actually performing a function call.
Exact structure is determined by the type omni::function
is bound to.
In the simplest case, a omni::function<TReturn(TArgs...)>
might be bound to a TReturn(*)(TArgs...)
, so the
buffer would contain only this pointer.
A more complex binding might be to a class type with an operator()
overload, in which case the buffer will contain
an object instance or pointer to a heap-allocated one.
The characteristics
is meant for the management level operations of copying and destroying the function buffer
.
If this value is nullptr
, then the function buffer is assumed to be trivially copyable and trivially destructible.
It is discussed in detail in the Function Characteristics section.
Bringing this all together, the actual function call is performed by (error handling removed for brevity):
template <typename TReturn, typename... TArgs>
TReturn function<TReturn(TArgs...)>::operator()(TArgs... args) const
{
auto real = reinterpret_cast<TReturn(*)(FunctionBuffer const*, TArgs...)>(this->trampoline);
return omni::invoke_r<TReturn>(&this->buffer, std::forward<TArgs>(args)...);
}
Function Characteristics
struct FunctionCharacteristics
{
size_t size;
void (*destroy)(FunctionBuffer* self);
omni::Result (*clone)(FunctionBuffer* target, FunctionBuffer const* source);
};
The FunctionCharacteristics
structure is responsible for performing the special operations of destruction and
copying of binding targets through the destroy
and clone
member functions.
Either of these functions can be nullptr
, which indicates the operation is trivial.
A trivial destroy
does nothing; a trivial clone
means that memcpy
is a valid replacement for copy.
If both of these are trivial operations, then FunctionData::characteristics
can be left as nullptr
.
The instance must exist for the lifetime of the omni::function
, but these live in the library-static space of where
the function was created.
A function
instance does not make guarantees that the DLL/SO will remain loaded while it is callable.