std::function

This commit is contained in:
Nekrolm
2024-09-21 02:25:53 +01:00
parent 6471992b4a
commit fa7794864f
2 changed files with 231 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
# std::function
С++11 предоставил разработчикам очень удобный класс-шаблон для описания абстрактных вызываемых объектов:
```C++
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
```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
}
```
Теоретики скажут, что коробка ковариантна типу содержимого! Типы контейнеров и типы содержимого вложены согласованно. Поэтому **ко**вариантна.
А бывает и обратная ситуация. Например, работник, который умеет чинить клавиатуры, совсем не факт что может чинить произвольные устройства ввода. А вот если наоборот — то без проблем. Он и клавиатуру вам починит.
```Rust
// 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`!
```C++
// 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` на самом деле поддерживает вариантность. Так происходит из-за неявного приведения типов аргументов и возвращаемого значения, а также повторного стирания типов (со всеми накладными расходами).
```C++
std::function<void(InputDevice*)> senior = [](auto){}; // Аллокация и перемещение лямбды на кучу! Стерли тип лямбды внутри
std::function<void(Keyboard*)> junion = std::move(senior); // Типы разные. Шаблоны инвариантны. Еще одна аллокация! и перемещенине исходной std::function на кучу. Стираем ее тип.
```
А если цепочки передачи таких функций с изменением типов будут более длинными — становится уже не так здорово.
И проблему можно усугубить тем, что такая "вариантность" работает не только лишь с указателями/ссылками на наследуемые классы в сигнатуре функции. Как было замечено ранее: оно работает из-за неявного приведения типов. А значит мы можем сделать так
```C++
// 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`.
```C++
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 итерация по контейнеру внезапно [вызывает копирование всего контейнера!](https://doc.qt.io/qt-6/containers.html#implicit-sharing-iterator-problem) А также к инвалидациям ссылок и другим занятным последствиям и порчей памяти.
```C++
// Бах! 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()` мы можем получить довольно неприятные результаты:
```C++
// 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`.
```C++
// 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 не поддерживается
```C++
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](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p2548r6.pdf)
2. [Variance](https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science))
3. [Type Erasure](https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Type_Erasure)