I am working with a project of using modern C++ on the Game Boy Advance (repository is here, code under the MIT license). Being a fairly limited platform (288 KB of RAM maximum, no memory controller), I try to avoid as best as I can the use of dynamic memory allocation (malloc
and friends). Since now I could get away with "simple" classes and such, but now I felt I would need to store lambdas and pass them around. For that, I created the following container, that stores the function object inside it. I would like some serious criticism on it, please.
//--------------------------------------------------------------------------------
// StaticFunction.hpp
//--------------------------------------------------------------------------------
// Provides space to store statically a function object with a defined number
// of bytes, saving on allocations
//--------------------------------------------------------------------------------
#pragma once
#include <cstddef>
#include <new>
#include <type_traits>
#include <functional>
template <std::size_t Size, typename Sig>
class StaticFunction;
// A function holder of Size bytes and signature R(Args...)
// based on the following answer: https://stackoverflow.com/a/38478032/
template <std::size_t Size, typename R, typename... Args>
class StaticFunction<Size, R(Args...)>
{
// Define the important function pointers here
typedef R(*Invoker)(std::byte*, Args...);
typedef void(*Replacer)(std::byte*, const std::byte*);
template <typename Functor>
static R genericInvoker(std::byte* f, Args... args)
{
static_assert(std::is_invocable_r_v<R, Functor, Args...>,
"Functor must be callable with the appropriate signature!");
return std::invoke(*std::launder(reinterpret_cast<Functor*>(f)),
std::forward<Args>(args)...);
}
template <typename Functor>
static void genericReplacer(std::byte* newObj, const std::byte* oldObj)
{
if (oldObj) new (newObj) Functor(*std::launder(reinterpret_cast<const Functor*>(oldObj)));
else std::launder(reinterpret_cast<Functor*>(newObj))->~Functor();
}
static R fptrInvoker(std::byte* f, Args... args)
{
auto fptr = reinterpret_cast<R(**)(Args...)>(f);
return (*fptr)(args...);
}
static void fptrReplacer(std::byte* newObj, const std::byte* oldObj)
{
*reinterpret_cast<R(**)(Args...)>(newObj) =
*reinterpret_cast<R(* const*)(Args...)>(oldObj);
}
// Now define the pointers
Invoker invoker;
Replacer replacer;
// And finally the storage
std::byte storage[Size];
public:
// A trivial default constructor
StaticFunction() = default;
// A constructor for function pointers
StaticFunction(R (*f)(Args...)) : invoker(fptrInvoker), replacer(fptrReplacer)
{
// Copy the function pointer
replacer(storage, reinterpret_cast<const std::byte*>(&f));
}
// A templated constructor for any callable object
template <typename Functor>
StaticFunction(const Functor& f) : invoker(genericInvoker<Functor>), replacer(genericReplacer<Functor>)
{
static_assert(std::is_invocable_r_v<R, Functor, Args...>,
"Functor must be callable with the appropriate signature!");
static_assert(sizeof(Functor) <= Size,
"The required function object is too big to be stored!");
// Copy the functor
replacer(storage, reinterpret_cast<const std::byte*>(&f));
}
// Copy constructor
StaticFunction(const StaticFunction& other) : invoker(other.invoker), replacer(other.replacer)
{
// Replace this one storage with the other
if (replacer) replacer(storage, other.storage);
}
// Copy assignment operator
StaticFunction& operator=(const StaticFunction& other)
{
// Destroy the object here first
if (replacer) replacer(storage, nullptr);
invoker = other.invoker;
replacer = other.replacer;
replacer(storage, other.storage);
return *this;
}
// Call operator
R operator()(Args... args)
{
// Calling an empty StaticFunction would trigger UB
return invoker(storage, std::forward<Args>(args)...);
}
// Destructor
~StaticFunction()
{
replacer(storage, nullptr);
}
};
The idea is that you have a StaticFunction<N, Sig>
which behaves as a std::function<Sig>
, but instead storing the object in an internal pool of N
bytes. Some of the uses of it would be like this:
#include <cstdio>
int f(int v) { return v << 3; }
int add(int a, int b) { return a+b; }
struct C
{
int v;
int example(int x) const { return v-x; }
};
int main()
{
int v;
StaticFunction<12, int(int)> container = [&v] (int x) { return v+x; };
printf("%d\n", container(14));
container = f;
printf("%d\n", container(11));
StaticFunction<12, int(const C&,int)> container2 = &C::example;
C x = { 12 };
printf("%d\b", container2(x, 12));
container = std::bind(add, std::placeholders::_1, 24);
printf("%d\n", container(-3));
}
Unfortunately, I have not battle tested it thoroughly. I tried to use perfect forwarding (replacing Args...
by Args&&...
), but suddenly, my assembly is permeated with &&
, even where it's not needed (int&&
, having my int hidden behind a pointer indirection). A version of the function and the example can be found here.
What could be some "attack vectors"? Where could I improve this code?
1 Answer 1
typedef R(*Invoker)(std::byte*, Args...); typedef void(*Replacer)(std::byte*, const std::byte*);
Alias-declarations are easier to understand IMO:
using Invoker = R (*)(std::byte*, Args...);
using Replacer = void (*)(std::byte*, const std::byte*);
template <typename Functor> static R genericInvoker(std::byte* f, Args... args) { static_assert(std::is_invocable_r_v<R, Functor, Args...>, "Functor must be callable with the appropriate signature!"); return std::invoke(*std::launder(reinterpret_cast<Functor*>(f)), std::forward<Args>(args)...); }
The *std::launder(reinterpret_cast<Functor*>(f))
part comes up quite a lot. Have you considered a helper function?
// somewhere
template <typename F>
F* as(void* storage)
{
return *std::launder(std::reinterpret_cast<F*>(storage));
}
then
using Invoker = R (*)(void*, Args...);
Invoker invoker;
template <typename F>
static R invoke(void* storage, Args... args)
{
std::invoke(*as<F>(storage), std::forward<Args>(args)...);
}
template <typename Functor> static void genericReplacer(std::byte* newObj, const std::byte* oldObj) { if (oldObj) new (newObj) Functor(*std::launder(reinterpret_cast<const Functor*>(oldObj))); else std::launder(reinterpret_cast<Functor*>(newObj))->~Functor(); }
Don't put everything on one line — it requires horizontal scrolling and is hard to read. newObj
and oldObj
are a bit confusing. The logic may also be clearer if you separate the destruction part:
using Replacer = void (*)(void*, void*);
using Destroyer = void (*)(void*);
Replacer replacer;
Destroyer destroyer;
template <typename F>
static void replace(void* storage, void* f)
{
::new (storage) F(*as<F>(f));
}
template <typename F>
static void destroy(void* storage)
{
std::destroy_at(as<F>(storage));
}
static R fptrInvoker(std::byte* f, Args... args) { auto fptr = reinterpret_cast<R(**)(Args...)>(f); return (*fptr)(args...); } static void fptrReplacer(std::byte* newObj, const std::byte* oldObj) { *reinterpret_cast<R(**)(Args...)>(newObj) = *reinterpret_cast<R(* const*)(Args...)>(oldObj); }
I don't think these are necessary — the generic version is fine.
std::byte storage[Size];
Using std::byte
s to store the function object disregards alignment, which may cause performance degradation or even undefined behavior. Use std::aligned_storage
instead:
std::aligned_storage_t<Size> storage;
// A trivial default constructor StaticFunction() = default;
This is questionable. Throwing an exception when an empty StaticFunction
is called may be better:
// special-case nullptr
Invoker invoker{};
Replacer replacer{};
Destroyer destroyer{};
template <typename Functor> StaticFunction(const Functor& f) : invoker(genericInvoker<Functor>), replacer(genericReplacer<Functor>) { static_assert(std::is_invocable_r_v<R, Functor, Args...>, "Functor must be callable with the appropriate signature!"); static_assert(sizeof(Functor) <= Size, "The required function object is too big to be stored!"); // Copy the functor replacer(storage, reinterpret_cast<const std::byte*>(&f)); }
Instead of making static_assert
s, why not SFINAE? Taking the function object by value allows move semantics:
template <typename F,
typename = std::enable_if_t<std::is_invocable_r_v<R, F, Args...> &&
sizeof(F) <= Size>>
StaticFunction(F f)
: invoker{invoke<F>}
, replacer{replace<F>}
, destroyer{destroy<F>}
{
::new (&storage) F(std::move(f));
}
// Copy constructor StaticFunction(const StaticFunction& other) : invoker(other.invoker), replacer(other.replacer) { // Replace this one storage with the other if (replacer) replacer(storage, other.storage); } // Copy assignment operator StaticFunction& operator=(const StaticFunction& other) { // Destroy the object here first if (replacer) replacer(storage, nullptr); invoker = other.invoker; replacer = other.replacer; replacer(storage, other.storage); return *this; }
Consider supporting move semantics (maybe move_replacer
).
R operator()(Args... args) { // Calling an empty StaticFunction would trigger UB return invoker(storage, std::forward<Args>(args)...); }
Maybe this:
R operator()(Args... args) const
{
if (invoker) {
return invoker(&storage, std::forward<Args>(args)...);
} else {
throw std::bad_function_call{};
}
}
~StaticFunction() { replacer(storage, nullptr); }
Yeah, with destroyer
it becomes
~StaticFunction()
{
destroyer(&storage);
}
-
1\$\begingroup\$ There are some design decisions that have been made thinking on the limitations of the platform. The
fptr
versions were provided because (for some reason)std::launder
is deleted for function pointers. The decision of combining a replacer and a destroyer into a single function was to save a function pointer in space (288 KB RAM only). About exceptions, I'm running the entire project with exceptions disabled and, in that case, an empty slot will trigger an assertion (and lock up the program). Move semantics are something to consider, but that would mean yet another function pointer. \$\endgroup\$JoaoBapt– JoaoBapt2020年04月01日 16:13:39 +00:00Commented Apr 1, 2020 at 16:13 -
\$\begingroup\$ But so far thank you for the time you took for commenting and suggesting improvements. I'll keep looking for improvements! :) \$\endgroup\$JoaoBapt– JoaoBapt2020年04月01日 16:14:41 +00:00Commented Apr 1, 2020 at 16:14
-
\$\begingroup\$ @JoaoBapt Yeah, I understand the compromises you have to make. Saving space is indeed a legit point to reduce the number of function pointers. Anyway, we are
std::launder
ing pointers to function pointers (we store function pointers in thestorage
), so it being deleted for function pointers shouldn't be a problem. \$\endgroup\$L. F.– L. F.2020年04月02日 00:55:11 +00:00Commented Apr 2, 2020 at 0:55 -
\$\begingroup\$ Actually, testing with Godbolt, I figured out that using a lambda with this class would be faster (less pointer indirections) than a function pointer, because the lambda's body is inlined inside the
genericInvoker
, but with a function pointer it would have to go through thefptrInvoker
then to the pointer stored in thestorage
. I wonder if there is a way to "rearrange" the code so function pointers could be theoretically stored in theinvoker
so we don't have to do this second hop. \$\endgroup\$JoaoBapt– JoaoBapt2020年04月02日 01:01:22 +00:00Commented Apr 2, 2020 at 1:01 -
\$\begingroup\$ @JoaoBapt Well, lambdas are generally better than function pointers because they can be inlined and don't require indirection. I don't really think what you are describing is possible - functions aren't objects, so they have to be decayed to function pointers before they can be stored, introducing indirection. That's the same reason why standard algorithms are faster with function objects than with function pointers. \$\endgroup\$L. F.– L. F.2020年04月02日 02:15:20 +00:00Commented Apr 2, 2020 at 2:15
std::function
also does not useArgs&&
. This is a bit tricky, but you've done both the argument passing part (Args...
) and invocation part (std::forward<Args>(args)
) correct. \$\endgroup\$std::function<>
? \$\endgroup\$