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.