Альтернативная реализация std :: function для лямбда-выражений

Я пытался реализовать более быструю альтернативную реализацию std :: function. Я придумал следующий код:

template <typename R, typename ...Args>
struct Function {
    template <typename Lambda>
    Function(Lambda f) {
    static auto function = f;
    func = + [] (Args... args) -> R {
        return function(args...);
    };
    };
    Function(const Function& other) : func(other.func) {}
    inline auto operator() (Args... args) {
        return func(args...);
    }
    R (*func) (Args... args);
};

Теперь вы можете создать экземпляр функции, как показано ниже:

int y = 100;
int x = 56;
auto f1 = Function<int , int>([&] (int z) {return x + y + z});
// If you need to store a generic custom functor class:
auto functor = SomeCustomFunctorClass<int, int>(); // Assuming functor takes an int as an argument and returns an int
auto f2 = Function<int , int>([functor] (int x) {return functor(x);});

Обратите внимание, что моя реализация Function может хранить только лямбды, но сохранение общей лямбды может быть достигнуто с помощью приведенного выше примера.

Идея здесь состоит в том, чтобы стереть тип лямбды в конструкторе, сохранив его статически, что означает, что я могу использовать его как указатель на функцию func. Обратите внимание, что при каждом вызове конструктора создается экземпляр другой статической переменной, поскольку все лямбда-выражения имеют уникальный тип, поэтому каждый раз создается экземпляр новой версии конструктора с помощью другого конструктора.

Я протестировал эту реализацию, и она работает значительно быстрее, чем std :: function (с включенной оптимизацией). Он в 6-7 раз быстрее для построения, в 15 раз быстрее для копирования, а его вызов осуществляется так же быстро, как и вызов указателя на функцию (хотя и значительно медленнее, чем вызов лямбды, которая всегда встроена).

Однако это кажется слишком простым, поскольку эта реализация состоит всего из 15 строк кода. Есть ли недостатки в моей реализации по сравнению с традиционной реализацией std :: function с использованием выделения кучи и виртуальных вызовов? Возможно, статическое выделение лямбды — не лучшая идея?

1 ответ
1

Вопросы и Ответы

«Есть ли в моей реализации какие-либо недостатки по сравнению с традиционной реализацией std :: function с использованием выделения кучи и виртуальных вызовов? Возможно, статическое распределение лямбда — не лучшая идея? »

Да, определенно плохая идея с множеством недостатков.

Поскольку во время компиляции нет проверок, чтобы убедиться, что вы действительно используете лямбда, этот тип очень хрупкий и очень опасный. Сегодня я могу написать такой код:

auto x = 0;
auto y = 0;

auto func_x = [&x] { ++(*x); };
auto func_y = [&y] { ++(*y); };

// ... later:
auto function_x = Function<void>{func_x};
auto function_y = Function<void>{func_y};

Никаких проблем нет. (Ну в смысле да, проблемы, но не для этого конкретный предполагаемый вариант использования. Но мы вернемся к этому.)

Но затем, позже, в процессе разработки, кто-то преобразовывает эти две лямбды в это:

struct indirect_incrementer
{
    int* p_val = nullptr;

    auto operator()() { ++(*p_val); }
};

// then the following two lines:
//auto func_x = [&x] { ++(*x); };
//auto func_y = [&y] { ++(*y); };
// become:
auto func_x = indirect_incrementer{&x};
auto func_y = indirect_incrementer{&y};

Это невинный и совершенно логичный рефакторинг … но теперь все взрывается, потому что ваше предположение, что каждый тип объекта функции Function построен без удержания. Статическая переменная function инициализируется копией func_x, и так зовут function_y делает не вызов func_y, он звонит func_x.

Проблема не только в использовании функциональных объектов. Даже если ты как-то полностью запретили использование функциональных объектов с Function— например, с каким-то линтером или чем-то еще — и убедился на 100%, что Function является всегда вызывается с допустимым лямбда-объектом, проблема все еще существует. Потому что, несмотря на твои мысли, это является возможно, чтобы лямбда-тип не был уникальным. Это даже довольно тривиально сделать … просто вызовите функцию, содержащую лямбду, более одного раза:

auto render()
{
    // ... [snip] ...

    // somewhere in your render function, you use Function:
    auto func = Function<int, int>{[&] (int i) { return ++i; }};

    // ... [snip] ...
}

// elsewhere, in your main game loop:
while (not done)
{
    input();
    update();
    render(); // <- !!!
}

render() вызывается в цикле, но только первый цикл инициализирует статический function переменная. То есть каждый раз, когда вы используете func, это будет с висячими ссылками на местных жителей в первом render() вызов.

Теперь ваша очередь мог «Исправить» эту проблему с помощью различных хаков, в основном определяя, function был ранее инициализирован, или подсчет количества раз, когда он был инициализирован, или что-то в этом роде. Но других проблем это не решит.

Другая большая проблема с использованием статической переменной для хранения копии лямбда заключается в том, что вы незаметно обманываете правила области видимости C ++. Это могло привести к неприятным сюрпризам и разочарованию. Например:

auto p_weak = std::weak_ptr<int>{};

// in some more restricted scope:
{
    auto p = std::make_shared<int>();
    p_weak = p;

    auto func = Function<void>{[p] { if (p) ++(*p); }};

    // use func somehow

    // scope is ending, so func and p are being destroyed, right?
    //
    // ... *right*?
}

if (auto p = p_weak.lock(); p)
    std::cerr << "memory leak!";

Оказывается, p фактически никогда не разрушается; лямбда принимает копию, которая затем копируется в статический function переменная, которая затем сохраняет ее … навсегда (ну, пока после main() возвращается, по крайней мере). Это не просто shared_ptr проблема; Я просто использовал shared_ptr чтобы иметь доступ к потерянной памяти. Любые значение, которое захватывает лямбда, никогда не уничтожается в течение всего времени существования программы. Другими словами, чем больше вы используете Function— даже когда каждое использование позволяет избежать других упомянутых проблем — тем больше утечки памяти.

Вероятно, поэтому вы наблюдаете такое большое увеличение скорости: объект функции копируется только один раз — даже если это неверно — и никогда не разрушается.

(Также, std::function имеет ваш очень важных и полезных других способностей и преимуществ Function не предлагает, например, возможность быть нулевым и повторно установленным. Полагаю, простое стирание типа полезно, но, вероятно, недостаточно полезно само по себе… особенно когда простые указатели на функции могут это делать (и многое другое!).

Так что да, использование статической переменной для обхода динамического распределения — не лучшая идея.

Обзор кода

template <typename Lambda>
Function(Lambda f) {
static auto function = f;
func = + [] (Args... args) -> R {
    return function(args...);
};
};

Правильный отступ определенно поможет сделать это более читаемым. Также будет выделена лишняя точка с запятой в конце.

Тот факт, что вы копируете f в function делает весь класс непригодным для использования с лямбда-типами только для перемещения:

auto p = std::make_unique<int>();

auto lambda_p = [p = std::move(p)] { if (p) { *p = 42; } };

auto func_p = Function<void>{lambda_p}; // won't compile

// (also, as mentioned previously, p won't ever be freed)

Вы также должны использовать ссылки переадресации во всех вызовах функций. Прямо сейчас каждый аргумент функции будет приниматься по значению, что будет как минимум медленным и, вероятно, не будет компилироваться во многих случаях.

Function(const Function& other) : func(other.func) {}

Это совершенно не нужно.

inline auto operator() (Args... args) {
    return func(args...);
}

В inline ключевое слово здесь ничего не делает.

Опять же, вам действительно следует использовать ссылки для пересылки.

Весь интерфейс Function также довольно неуклюжий. Кажется очевидным указывать типы возврата и аргумента, когда они очевидны и могут быть выведены из самой лямбды. Было бы намного приятнее написать:

auto f1 = Function{[&] (int z) {return x + y + z}};
// f1 is deduced as Function<int, int>

Это ситуация, в которой могут быть полезны руководства по дедукции.

Резюме

Вы не можете избежать того, чтобы обертки функциональных объектов фактически оборачивали функциональные объекты. Таким образом, это должно показаться довольно очевидным.

Обертка для удаления типов должна либо динамически выделять память для копирования объекта функции, либо использовать оптимизацию малых объектов, либо и то, и другое. Попытка скрыть обернутый объект в статической переменной — обман. Вам может сойти с рук это ненадолго… но в конечном итоге вас поймают либо из-за утечки памяти, чтения / записи висящих ссылок, либо просто чтения / записи неправильных объектов.

  • 1

    Отличный пример встречного с weak_ptr. Я бы не хотел искать утечку памяти.

    — невычислимый

  • Спасибо, что нашли время просмотреть мой код. Я никогда не думал о том, что цикл каждый раз дает неуникальную лямбду в вашем примере игрового цикла. Что касается утечек памяти через статическое распределение, как это сравнить, скажем, со строковыми литералами, которые статически выделяются в C ++?

    — Сайрус

  • Только что исправлена ​​опечатка в Function2. Function2 было оригинальным именем этой структуры (это была моя вторая попытка), но когда я скопировал вставку при проверке кода, я взял «2» из имени класса, но забыл снять его с конструктора. Извинения. Теперь должно скомпилироваться чисто.

    — Сайрус

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *