2024 год. C++20 уже как 4 года готов (не совсем) к использованию в серьезной production разработке. По крайней мере мне недавно сообщили, что компиляторы наконец-то обновлены и мы уже можем...
С++20 принес 4 крупные фичи. 2 из них готовы к употреблению в вашем коде сразу, а еще 2 -- не очень. Здесь мы будем говорить о первых двух.
Революция в методе работы с последовательностями элементов в C++! Последний раз такое было, когда в C++11 range-based-for сделали. И вот теперь опять.
Забудьте о паре итераторов begin/end и мучениях с тем, чтоб лихо и красиво выбросить все нечетные числа, а все четные возвести в квадрат, как это можно сделать в других высокоуровневых языках:
let v : Vec<_> = ints.iter()
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.collect();
List<int> v = Stream.of(ints)
.filter(x -> x % 2 == 0)
.map(x -> x * x)
.collect(Collectors.toList());
var v = ints.Where(x => x % 2 == 0)
.Select(x => x * 2)
.ToList();
// до C++20
std::vector<int> v;
std::copy_if(ints.begin(), ints.end(), std::back_inserter(v), [](int x) { return x % 2 == 0;});
std::transform(v.begin(), v.end(), v.begin(), [](int x){return x * x;});
// после С++20
std::vector<int> v;
std::ranges::copy(
ints | std::views::filter([](int x){ return x % 2 == 0;})
| std::views::transform([](int x) { return x * x;})
std::back_inserter(v)
);
// после C++23
auto v =
ints | std::views::filter([](int x){ return x % 2 == 0;})
| std::views::transform([](int x) { return x * x;})
| std::ranges::to<std::vector>();
Красота! Только компилируется оно долго, омтимизируется не всегда хорошо, но ничего страшного...
Концепты были нужны и как самостоятельная фича: уж дюже SFINAE в современном C++ необходим то здесь, то там (особенно библиотекописателям), а техника эта тяжелая и для чтения и для написания. И ошибки компиляции чудовищные... Концепты как именованные ограничения должны были улучшить ситуацию.
Так что теперь мы, например, можем написать максимально правильную и, вероятно, понятную generic функцию для сложения целых чисел. И только целых. И это будет явно видно в ее сигнатуре без всякой магии enable_if
std::integral auto sum(std::integral auto a
std::integral auto b) {
return a + b;
}
И для ranges они были страшно необходимы: вся библиотека ranges это невероятная гора шаблонов с ограничениями, без синтаксического сахара концептов прочитать сигнатуры функций было бы невероятно сложно, а в попытках разрешить ошибки компиляции можно было бы провести вечность.
Один разработчик написал в общий C++ чат, что он работает над добавлением тестов к какой-то новой фиче в библиотеке, которая так здорово реализована с помощью C++20 и ranges, да вот только что-то странное происходит: тесты падают, valgrind что-то совершенно невразумительное говорит...
Код фичи был таков
struct MetricsRecord {
std::string tag;
// ...
};
struct Metrics {
std::vector<MetricsRecord> records;
std::ranges::range auto by_tag(const std::string& tag) const;
// ...
};
// ... много-много кода
std::ranges::range auto Metrics::by_tag(const std::string& tag) const {
return records | std::ranges::views::filter([&](auto&& r) { return r.tag == tag; });
}
Ничего страшного, вроде все нормально. Никаких проблем не видно.
int main() {
auto m = Metrics {
{
{"hello"}, {"world"}, {"loooooooooooooooongtag"}
}
};
{
// печатает found
auto found = m.by_tag("hello");
for (const auto& f: found) {
std::cout << std::format("found {}\n", f.tag);
}
}
{
// не печатает... странно
auto found = m.by_tag("loooooooooooooooongtag");
for (const auto& f: found) {
std::cout << std::format("found {}\n", f.tag);
}
}
{
// а так работает
std::string tag = "loooooooooooooooongtag";
auto found = m.by_tag(tag);
for (const auto& f: found) {
std::cout << std::format("found {}\n", f.tag);
}
}
}
Искушенный читатель уже понял, что проблема во временной переменной
auto found = m.by_tag("loooooooooooooooongtag"); // неявное создание временной переменной std::string в аргументе!
и в том что предикат фильтрации захватывет переменную по ссылке
std::ranges::range auto Metrics::by_tag(const std::string& tag) const {
return records | std::ranges::views::filter([&](auto&& r) { return r.tag == tag; });
}
а еще в том, что разработчик долгое время писал на JavaScript, а там Array.prototype.filter
сразу же создает новый массив.
const words = ['spray', 'elite', 'exuberant', 'destruction', 'present'];
const result = words.filter((word) => {
console.log(word);
return word.length > 6
});
// сразу же будут напечатаны все элементы
И разработчик просто не понял сразу, что метод ленивый (и что все std::ranges ленивые).
Всё так просто! Вам нужно просто правильно использовать C++ и проблем не будет! Или будут?..
std::ranges::range auto Metrics::by_tag(const std::string& tag) const
Можно ли по сигнатуре метода догадаться, что он ленивый? Вряд ли. std::ranges::range auto
не дает об этом никакой информации.
Это должно быть написано в документирующем комментарии. Но его не было.
Либо должен был быть использован концет std::ranges::view
.
Ах, как обычно, вот если бы все было сделано правильно...
Но зато valgrind поймал ошибку! Да. В тестах к библиотеке. Кто знает, если тесты у пользователей этой библиотеки...
Пусть пишут тесты! Пусть используют статический анализ! Ага. Возможно, они помогут. На момент написания этого текста (апрель 2024 года): ни clang-tidy, ни PVS-studio не могут диагностировать эту ошибку.
Ну хорошо. Все теперь будут заранее читать документацию и будут знать, что std::viewes
ленивые. А значит захватывать ссылки с ними нужно осторожно. Закрываем вопрос...
Секция ниже во многом вдохновлена выступлением Nicolai Josuttis на Keynote Meeting C++ 2022
Подождите. std::ranges
не просто ленивые. А невероятно ленивые! Им иногда не просто лень итерироваться по контейнеру, им иногда даже лень у контейнера begin()
и end()
лишний раз вызвать. Причина такой лени -- требования стандарта обеспечить, в среднем, константное время выполнения методов begin()
и end()
:
Given an expression t such that decltype((t)) is T&, T models range only if
(2.1)
[ranges::begin(t), ranges::end(t)) denotes a range ([iterator.requirements.general]),
(2.2)
both ranges::begin(t) and ranges::end(t) are amortized constant time and non-modifying, and
Поэтому некоторые views
- Откладывают вызов begin/end у контейнера при конструировании
- Кэшируют свои begin/end после их первого вычисления.
И получаются интересные спецэффекты:
// https://godbolt.org/z/n8Paf6svj
void print_range(std::ranges::range auto&& r) {
for (auto&& x: r) {
std::cout << x << " ";
}
std::cout << "\n";
}
void test_drop_print() {
std::list<int> ints = {1, 2, 3 ,4, 5};
auto v = ints | std::views::drop(2); // пропустить первые два
ints.push_front(-5);
print_range(v); // -5 и 1 пропущены. drop вызвал begin и end только сейчас
}
void test_drop_print_before_after() {
std::list<int> ints = {1, 2, 3 ,4, 5};
auto v = ints | std::views::drop(2);
print_range(v); // 1, 2 пропущены
ints.push_front(-5);
print_range(v); // 1, 2 пропущены! drop не вызывает begin и end еще раз
}
void test_take_print() {
std::list<int> ints = {1, 2, 3 ,4, 5};
auto v = ints | std::views::take(2);
ints.push_front(-5);
print_range(v); // -5, 1 выведены
}
void test_take_print_before_after() {
std::list<int> ints = {1, 2, 3 ,4, 5};
auto v = ints | std::views::take(2);
print_range(v); // 1, 2 выведены
ints.push_front(-5);
print_range(v); // -5, 1 выведены! take вызывает begin и end каждый раз
}
Здорово, совершенно естественно, а главное -- предсказуемо! Нет никакой магии, если знать как оно работает... Главное не ошибиться при использовании на практике.
Просто не надо брать и модифицировать контейнер, когда на него взят ranges::view. Это же так просто.
Кстати, если мы сделаем одно крохотное изменение
// https://godbolt.org/z/svb91qn8s
void print_range(std::ranges::range auto r) // by value теперь
{
for (auto&& x: r) {
std::cout << x << " ";
}
std::cout << "\n";
}
void test_drop_print_before_after() {
std::list<int> ints = {1, 2, 3 ,4, 5};
auto v = ints | std::views::drop(2);
print_range(v); // 1, 2 пропущены
ints.push_front(-5);
print_range(v); // -5, 1 пропущены! мы же теперь сделали копию view и копия снова вызвала begin() и end()
}
Следующим вытекующим спецэффектом такого ленивого и иногда кэшурующего поведения является то, что в функцию, принимающую const std::range::range&
, абы какой view
подставить нельзя
// https://godbolt.org/z/hznzEqPEh
void print_range(const std::ranges::range auto& r) {
for (auto&& x: r) {
std::cout << x << " ";
}
std::cout << "\n";
}
void test_drop_print() {
std::list<int> ints = {1, 2, 3 ,4, 5};
auto v = ints | std::views::drop(2);
print_range(v); // не компилируется! drop от std::list должен быть мутабельным
/*
<source>: In instantiation of 'void print_range(const auto:42&) [with auto:42 = std::ranges::drop_view<std::ranges::ref_view<std::__cxx11::list<int> > >]':
<source>:19:16: required from here
19 | print_range(v);
| ~~~~~~~~~~~^~~
<source>:10:5: error: passing 'const std::ranges::drop_view<std::ranges::ref_view<std::__cxx11::list<int> > >' as 'this' argument discards qualifiers [-fpermissive]
10 | for (auto&& x: r) {
*/
}
void test_drop_print_vector() {
std::vector<int> ints = {1, 2, 3 ,4, 5};
auto v = ints | std::views::drop(2);
print_range(v); // все ок
}
Соответсвенно один и тот же абстрактный view
ни в коем случае нельзя напрямую использовать по ссылке в нескольких потоках. Нужно требовать константности или синхронизировать доступ. Также писатели generic кода должны возложить на себя дополнительную когнитивную нагрузку и правильно указывать концепты-ограничения.
Для начала вот эти четыре
std::ranges::range
- слишком абстрактный: только begin и endstd::ranges::view
- тоже range, но ему удовлетворяют только viewsstd::ranges::borrowed_range
- тоже слишком абстрактный, но его итераторы безопасно возвращать из функцийstd::ranges::constant_range
(С++23) - тоже абстрактный, но итераторы дают только read-only доступ
А потом еще и оставшиеся подключатся.
Последним выдающимся спецэффектом ленивого кэширования является следующий курьез:
enum State {
Stopped,
Working,
...
};
struct Unit {
State state;
....
};
...
std::vector<Unit> units;
...
// stop all working units
for (auto& unit: units | std::views::filter{[](auto& unit){ return unit.state == Working; }}) {
...
unit.state = State::Stopped; // UB!
// https://eel.is/c++draft/range.filter#iterator-1
/*
Modification of the element a filter_view::iterator denotes is permitted, but results in undefined behavior if the resulting value does not satisfy the filter predicate.
*/
}
Стандарт явно запрещает изменять
элементы найденные с помощью std::views::filter
так, чтоб результат предиката менялся! Все из-за предположения, что вы, возможно, еще раз будете итерироваться по тому же самому view. И чтобы не делать работу дважды, нужно закэшировать begin()
Cамое чудовищное в том, что такое поведение закреплено стандартом. Это не implementation defined:
https://eel.is/c++draft/range.filter#view-5
Remarks: In order to provide the amortized constant time complexity required by the range concept when filter_view models forward_range, this function caches the result within the filter_view for use on subsequent calls.