Skip to content

Latest commit

 

History

History
230 lines (180 loc) · 13.7 KB

std_function_const.md

File metadata and controls

230 lines (180 loc) · 13.7 KB

std::function

С++11 предоставил разработчикам очень удобный класс-шаблон для описания абстрактных вызываемых объектов:

std::function<R(Args)> f = /* все что угочно, 
                            что можно вызвать как f(args) 
                            и результатом будет R */ 

Благодаря технике type-erasure (стирание типа), std::function может хранить в себе что угодно. У этого, конечно, есть цена — посредственная производительность: конкретный вызываемый объет должен быть перемещен в кучу, выделение памяти, динамическая диспетчеризация вызова... Если мы не пишем чего-то высоконагруженного, то цена не очень высока.

Однако, благодаря тому же самомуму стиранию типов и тому, как оно реализовано, std::function обладает еще некоторыми потрясающими спецэффектами!

Спецэффект 1. Вариантность

С этим понятием знакомы далеко не все разработчики, так что начнем с примера.

Пожалуй, я не позволю себе использовать забитый пример с классами Animal и Dog. Вместо этого у меня будет InputDevice и Keyboard. Вопрос: если Keyboard это подтип InputDevice, то является ли Containter<Keyboard> подтипом Container<InputDevice>?

С точки зрения абстрактной достаточно высокоуровневой иерархии в системе типов — вполне себе. Коробка клавиатур это коробка устройств ввода. И так, например, будет в языках Java, Kotlin или Rust

// https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=679a17722f69cf5178fb751f42c8a543
trait InputDevice {
    fn input(&self) -> char;
}

trait Keyboard: InputDevice {
    fn lock_keys(&self);
}

struct MBox<T>(T);

// Коробка клавиатур это коробка устройств ввода!
// Keyboard       is subtype of InputDevice
// MBox<Keyboard> is subtype of MBox<InputDevice>
fn relabel(b: MBox<impl Keyboard>) -> MBox<impl InputDevice> {
    b
}

Теоретики скажут, что коробка ковариантна типу содержимого! Типы контейнеров и типы содержимого вложены согласованно. Поэтому ковариантна.

А бывает и обратная ситуация. Например, работник, который умеет чинить клавиатуры, совсем не факт что может чинить произвольные устройства ввода. А вот если наоборот — то без проблем. Он и клавиатуру вам починит.

// https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=ef93662f6bf210b61483a06ac56b4b4a
trait Senior {
    fn repair(&self, _: impl InputDevice);
}
trait Junior {
    fn repair(&self, _: impl Keyboard);
}
impl <M: Senior> Junior for M {
    fn repair(&self, k: impl Keyboard) {
        Senior::repair(self, k)
    }
}

// Keyboard                     is subtype of InputDevice
// Senior (repairs InputDevice) is subtype of Junior (repairs only keyboards)
fn demote(m: impl Senior) -> impl Junior {
    m
}

Вложенность типов работников противоположна вложенности типов объектов, с которыми они работают. Они контравариантны.

Шаблоны в C++ инвариантны: отношения между типами-контейнерами никак не зависят от типов элементов... Так по крайней мере должно быть и так и задумывалось. Если бы нe внезапное исключение в виде std::function!

// https://godbolt.org/z/oecYYcfvj
class InputDevice {
public:
    virtual ~InputDevice() = default;
    virtual char input() = 0; 
};

class Keyboard : public InputDevice {
public:
    char input() override {
        return '*';
    }
};

// мы можем сделать так
Keyboard* keyboard = ...;
// ведь клавиатура это устройство ввода
InputDevice* device = keyboard;

std::vector<Keyboard*> box_of_keyboards { keyboard, keyboard, keyboard };
// Compilation error. Шаблоны инвариантны. Такого оператора присваивания нет
std::vector<InputDevice*> box_of_input_devs = box_of_keyboard; 


// Но!
std::function<Keyboard* ()> keyboard_maker = ...;
// Отлично компилируется! std::function ковариантен по возвращаемому значению
std::function<InputDevice* ()> input_device_maker = keyboard_maker;
std::function<void(InputDevice*)> senior = ...;
// Тоже компилируется! std::function контравариантен по принимаемым параметрам
std::function<void(Keyboard*)> junion = senior;

С одной стороны выглядит очень даже здорово и правильно. А с другой стороны оно, разумеется, работает не потому что std::function на самом деле поддерживает вариантность. Так происходит из-за неявного приведения типов аргументов и возвращаемого значения, а также повторного стирания типов (со всеми накладными расходами).

std::function<void(InputDevice*)> senior = [](auto){}; // Аллокация и перемещение лямбды на кучу! Стерли тип лямбды внутри
std::function<void(Keyboard*)> junion = std::move(senior); // Типы разные. Шаблоны инвариантны. Еще одна аллокация! и перемещенине исходной std::function на кучу. Стираем ее тип.

А если цепочки передачи таких функций с изменением типов будут более длинными — становится уже не так здорово.

И проблему можно усугубить тем, что такая "вариантность" работает не только лишь с указателями/ссылками на наследуемые классы в сигнатуре функции. Как было замечено ранее: оно работает из-за неявного приведения типов. А значит мы можем сделать так

// https://godbolt.org/z/cjfTz5dEW
std::function<void(std::string_view)> by_view = [](std::string_view v){
    std::cout << v;
};
std::function<void(const std::string&)> by_str_ref = by_view;
std::function<void(std::string)> by_str_val = by_str_ref;
std::function<void(const char*)> by_char_ptr = by_str_val;
by_char_ptr("hello");

И если туда попадет nullptr... Мы помним что будет:

Program returned: 139
Program stderr
terminate called after throwing an instance of 'std::logic_error'
  what():  basic_string: construction from null is not valid
Program terminated with signal: SIGSEGV

И разумеется при наличии таких цепочек сохранять переданные в аргументах ссылки становится особенно сомнительным занятием.

Проблему с переизбытком аллокаций C++26 пердлагает решать с помощью std::function_ref — невладеющих ссылок на вызаваемый объект. Создайте ваш объект один раз и храните, а дальше передавайте на него ссылку с удобным интерфейсом. Вариантность остается в комплекте с возможностью получить dangling reference на другой std::function_ref в цепочке присваиваний.

Спецэффект 2. Отломанный const

Усердно стирая типы, std::function перестарался и подтер пробрасывание const.

int main() {
    const auto counter = [count = 0]() mutable {
        return count++;
    };
    // cannot call: mutable lambda requires mutable access to operator()
    // counter();
    const std::function<int()> f = counter;
    f(); // this is fine! const is not propagated!
}

Ну подтер и подтер... Ничего ж страшного. Код компилируется и вызывается — это ж главное! А некорректный код, который тоже компилируется из-за этого, просто не нужно писать...

О важности правильного пробрасывания const, а особенно с приходом C++23 и deducing this, можно вспомнить один забавный факт о популярном фреймворке Qt: в нем свои свобственные контейнеры с Copy-on-Write поведением по умолчанию. И это приводит к тому, что совершенно очевидная read-only итерация по контейнеру внезапно вызывает копирование всего контейнера! А также к инвалидациям ссылок и другим занятным последствиям и порчей памяти.

// Бах! range based for вызывает begin()/end() методы, 
// которые в не const версии вызывают копирование!
for (const auto& x: qlist) {}; 
// Вот так надежнее и корректнее
for (const auto& x: std::as_const(qlist)) {}; 

Если мы теперь вернемся обратно к std::function и будем передавать в нее вызываемый объект с существенно разными const и non-const перегрузками operator() мы можем получить довольно неприятные результаты:

// https://godbolt.org/z/KcWxoYqPo
struct Proxy {
    int& operator()(int index) {
        return data[index];
    }

    int operator()(int index) const {
        return data.at(index);
    }

    std::map<int, int>& data;
};

int main() {
    std::map<int, int> data = { {42, 42}};
    const std::function<int(int)> f = Proxy{data};
    f(43); // expect throw?
    for (auto [k, v]: data){
        std::cout << k << " " <<  v << "\n";
    }
}

Но const был потерян, так что результатом будет:

42 42
43 0

В C++26 проблему решили: используйте, пожалуйста std::copyable_function.

// const -- часть сигнатуры! 
// также можно дописывать noexcept -- это еще одно улучшение
std::copyable_function<int(int) const> f = Proxy{data};

Чинить старый std::function не стали по соображениям обратной совместимости со старым кодом, который

  1. может полагаться на ее странное поведение с проглатыванием const.
  2. рисково может использовать std::function в C++ ABI — и изменение в сигнатуре operator() могут его сломать (хотя и так гарантий по нему не дается)

Читатель может заинтересоваться, а почему для исправленной версии выбрано такое странное название. А это из-за еще одной проблемы std::function

Спецэффект 3. move-only не поддерживается

std::function<int(int)> f = [data = std::make_unique<int>(42)](int x) { return *data + x; };
/opt/compiler-explorer/gcc-14.2.0/include/c++/14.2.0/bits/std_function.h:439:69: error: static assertion failed: std::function target must be copy-constructible
  439 |           static_assert(is_copy_constructible<__decay_t<_Functor>>::value,
      |                                                                     ^~~~~

Для решения этой проблемы C++26 ввел std::move_only_function. А std::copyable_function добавили уже на замену старого std::function, который поддерживает только копируемые типы.

Возможно, в C++29 std::function будет помечен как устаревший и deprecated.

Полезные ссылки

  1. Copyable Function proposal
  2. Variance
  3. Type Erasure