Книга «С++17 STL. Стандартная библиотека шаблонов»

в 11:33, , рубрики: c++, Блог компании Издательский дом «Питер», книги, Профессиональная литература

image В книге описана работа с контейнерами, алгоритмами, вспомогательными классами, лямбда-выражениями и другими интересными инструментами, которыми богат современный С++. Освоив материал, вы сможете коренным образом пересмотреть привычный подход к программированию. Преимущество издания — в подробном описании стандартной библиотеки шаблонов С++, STL. Ее свежая версия была выпущена в 2017 году. В книге вы найдете более 90 максимально реалистичных примеров, которые демонстрируют всю мощь STL. Многие из них станут базовыми кирпичиками для решения более универсальных задач. Вооружившись этой книгой, вы сможете эффективно использовать С++17 для создания высококачественного и высокопроизводительного ПО, применимого в различных отраслях.

Далее представлен отрывок «Лямбда-выражения».

Одной из важных новых функций C++11 были лямбда-выражения. В C++14 и C++17 они получили новые возможности, и это сделало их еще мощнее. Но что же такое лямбда-выражение?

Лямбда-выражения или лямбда-функции создают замыкания. Замыкание — очень обобщенный термин для безымянных объектов, которые можно вызывать как функции. Чтобы предоставить подобную возможность в С++, такой объект должен реализовывать оператор вызова функции (), с параметрами или без. Создание аналогичного объекта без лямбда-выражений до появления С++11 выглядело бы так:

#include <iostream>
#include <string>

int main() {
     struct name_greeter {
           std::string name;
           void operator()() {
                 std::cout << "Hello, " << name << 'n';
           }
      };
      name_greeter greet_john_doe {"John Doe"};
      greet_john_doe();
}

Экземпляры структуры name_greeter, очевидно, содержат строку. Обратите внимание: тип этой структуры и объект не являются безымянными, в отличие от лямбда-выражений. С точки зрения замыканий можно утверждать, что они захватывают строку. Когда экземпляр-пример вызывается как функция без параметров, на экран выводится строка «Hello, John Doe», поскольку мы указали строку с таким именем.

Начиная с С++11 создавать подобные замыкания стало проще:

#include <iostream>
int main() {

     auto greet_john_doe ([] {
            std::cout << "Hello, John Doen";
     });

     greet_john_doe();
}

На этом все. Целая структура name_greeter заменяется небольшой конструкцией [] { /* сделать что-то */ }, которая на первый взгляд выглядит странно, но уже в следующем разделе мы рассмотрим все возможные случаи ее применения.

Лямбда-выражения помогают поддерживать код обобщенным и чистым. Они могут применяться как параметры для обобщенных алгоритмов, чтобы уточнить их при обработке конкретных типов, определенных пользователем. Они также могут служить для оборачивания рабочих пакетов и данных, чтобы их можно было запускать в потоках или просто сохранять работу и откладывать само выполнение пакетов. После появления С++11 было создано множество библиотек, работающих с лямбда-выражениями, поскольку они стали естественной частью языка С++. Еще одним вариантом использования лямбда-выражений является метапрограммирование, поскольку они могут быть оценены во время выполнения программы. Однако мы не будем рассматривать этот вопрос, так как он не относится к теме данной книги.

В текущей главе мы в значительной мере будем опираться на отдельные шаблоны функционального программирования, которые могут показаться странными для новичков и даже опытных программистов, еще не работавших с такими шаблонами. Если в последующих примерах вы увидите лямбда-выражения, возвращающие лямбда-выражения, которые опять-таки возвращают лямбда-выражения, то, пожалуйста, не теряйтесь. Мы несколько выходим за рамки привычного стиля программирования, чтобы подготовиться к работе с современным языком С++, в котором шаблоны функционального программирования встречаются все чаще и чаще. Если код какого-то примера выглядит слишком сложным, то уделите время тому, чтобы подробнее разобрать его. Как только вы с этим разберетесь, сложные лямбда-выражения в реальных проектах больше не будут вас смущать.

Динамическое определение функций с помощью лямбда-выражений

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

Синтаксис лямбда-выражений выглядел новым в С++11, и к С++17 он несколько изменился. В этом разделе мы увидим, как сейчас выглядят лямбда-выражения и что они означают.

Как это делается

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

1. Для работы с лямбда-выражениями не нужна поддержка библиотек, но мы будем выводить сообщения на консоль и использовать строки, поэтому понадобятся соответствующие заголовочные файлы:

#include <iostream>
#include <string>

2. В данном примере все действие происходит в функции main. Мы определим два объекта функций, которые не принимают параметры, и вернем целочисленные константы со значениями 1 и 2. Обратите внимание: выражение return окружено фигурными скобками {}, как это делается в обычных функциях, а круглые скобки (), указывающие на функцию без параметров, являются необязательными, мы не указываем их во втором лямбда-выражении. Но квадратные скобки [] должны присутствовать:

int main()
{
     auto just_one ( [](){ return 1; } );
     auto just_two ( [] { return 2; } );

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

std::cout << just_one() << ", " << just_two() << 'n';

Забудем о них и определим еще один объект функции, который называется plus, — он принимает два параметра и возвращает их сумму:

auto plus ( [](auto l, auto r) { return l + r; } );

5. Использовать такой объект довольно просто, в этом плане он похож на любую другую бинарную функцию. Мы указали, что его параметры имеют тип auto, вследствие чего объект будет работать со всеми типами данных, для которых определен оператор +, например со строками.

std::cout << plus(1, 2) << 'n';
std::cout << plus(std::string{"a"}, "b") << 'n';

6. Не нужно сохранять лямбда-выражение в переменной, чтобы использовать его. Мы также можем определить его в том месте, где это необходимо, а затем разместить параметры для данного выражения в круглых скобках сразу после него (1, 2):

std::cout
   << [](auto l, auto r){ return l + r; }(1, 2)
   << 'n';

7. Далее определим замыкание, которое содержит целочисленный счетчик. При каждом вызове значение этого счетчика будет увеличиваться на 1 и возвращать новое значение. Для указания на то, что замыкание содержит внутренний счетчик, разместим в скобках выражение count = 0 — оно указывает, что переменная count инициализирована целочисленным значением 0. Чтобы позволить ему изменять собственные переменные, мы используем ключевое слово mutable, поскольку в противном случае компилятор не разрешит это сделать:

auto counter (
      [count = 0] () mutable { return ++count; }
);

8. Теперь вызовем объект функции пять раз и выведем возвращаемые им значения с целью увидеть, что значение счетчика увеличивается:

for (size_t i {0}; i < 5; ++i) {
     std::cout << counter() << ", ";
}
std::cout << 'n';

9. Мы также можем взять существующие переменные и захватить их по ссылке вместо того, чтобы создавать копию значения для замыкания. Таким образом, значение переменной станет увеличиваться в замыкании и при этом будет доступно за его пределами. Для этого мы поместим в скобках конструкцию &a, где символ & означает, что мы сохраняем ссылку на переменную, но не копию:

int a {0};
auto incrementer ( [&a] { ++a; } );

10. Если это работает, то можно вызвать данный объект функции несколько раз, а затем пронаблюдать, действительно ли меняется значение переменной a:

incrementer();
incrementer();
incrementer();

std::cout
<< "Value of 'a' after 3 incrementer() calls: "
<< a << 'n';

11. Последний пример демонстрирует каррирование. Оно означает, что мы берем функцию, принимающую некоторые параметры, а затем сохраняем ее в другом объекте функции, принимающем меньше параметров. В этом случае мы сохраняем функцию plus и принимаем только один параметр, который будет передан в функцию plus. Другой параметр имеет значение 10; его мы сохраняем в объекте функции. Таким образом, мы получаем функцию и назовем ее plus_ten, поскольку она может добавить значение 10 к единственному принимаемому ею параметру.

auto plus_ten ( [=] (int x) { return plus(10, x); } );
std::cout << plus_ten(5) << 'n';
}

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

1, 2
3
ab
3
1, 2, 3, 4, 5,
Value of a after 3 incrementer() calls: 3
15

Как это работает

То, что мы сейчас сделали, выглядит не слишком сложно: сложили числа, а затем инкрементировали их и вывели на экран. Мы даже выполнили конкатенацию строк с помощью объекта функций, который был реализован для сложения чисел. Но для тех, кто еще незнаком с синтаксисом лямбда-выражений, это может показаться запутанным.

Итак, сначала рассмотрим все особенности, связанные с лямбда-выражениями (рис. 4.1).

image

Как правило, можно опустить большую часть этих параметров, чтобы сэкономить немного времени. Самым коротким лямбда-выражением является выражение []{}. Оно не принимает никаких параметров, ничего не захватывает и, по сути, ничего не делает. Что же значит остальная часть?

Список для захвата

Определяет, что именно мы захватываем и выполняем ли захват вообще. Есть несколько способов сделать это. Рассмотрим два «ленивых» варианта.

1. Если мы напишем [=] () {...}, то захватим каждую внешнюю переменную, на которую ссылается замыкание, по значению; то есть эти значения будут скопированы.

2. Запись [&] () {...} означает следующее: все внешние объекты, на которые ссылается замыкание, захватываются только по ссылке, что не приводит к копированию.

Конечно, можно установить настройки захвата для каждой переменной отдельно. Запись [a, &b] () {...} означает, что переменную a мы захватываем по значению, а переменную b — по ссылке. Для этого потребуется напечатать больше текста, но, как правило, данный способ безопаснее, поскольку мы не можем случайно захватить что-то ненужное из-за пределов замыкания.

В текущем примере мы определили лямбда-выражение следующим образом: [count=0] () {...}. В этом особом случае мы не захватываем никаких переменных из-за пределов замыкания, только определили новую переменную с именем count. Тип данной переменной определяется на основе значения, которым мы ее инициализировали, а именно 0, так что она имеет тип int.

Кроме того, можно захватить одни переменные по значению, а другие — по ссылке, например:

• [a, &b] () {...} — копируем a и берем ссылку на b;
• [&, a] () {...} — копируем a и применяем ссылку на любую другую переданную переменную;
• [=, &b, i{22}, this] () {...} — получаем ссылку на b, копируем значение this, инициализируем новую переменную i значением 22 и копируем любую другую использованную переменную.

mutable (необязательный)
Если объект функции должен иметь возможность модифицировать получаемые им переменные путем копирования ([=]), то его следует определить как mutable. Это же касается вызова неконстантных методов захваченных объектов.

constexpr (необязательный)
Если мы явно пометим лямбда-выражение с помощью ключевого слова constexpr, то компилятор сгенерирует ошибку, когда это выражение не будет соответствовать критериям функции constexpr. Преимущество использования функций constexpr и лямбда-выражений заключается в том, что компилятор может оценить их результат во время компиляции, если они вызываются с параметрами, постоянными на протяжении данного процесса. Это приведет к тому, что позднее в бинарном файле будет меньше кода.

Если мы не указываем явно, что лямбда-выражения являются constexpr, но эти выражения соответствуют всем требуемым критериям, то они все равно будут считаться constexpr, только неявно. Если нужно, чтобы лямбда-выражение было constexpr, то лучше явно задавать его таковым, поскольку иначе в случае наших неверных действий компилятор начнет генерировать ошибки.

exception attr (необязательный)
Здесь определяется, может ли объект функции генерировать исключения, если при вызове столкнется с ошибкой.

return type (необязательный)
При необходимости иметь полный контроль над возвращаемым типом, вероятно, не нужно, чтобы компилятор определял его автоматически. В таких случаях можно просто использовать конструкцию [] () -> Foo {}, которая укажет компилятору, что мы всегда будем возвращать объекты типа Foo.

Добавляем полиморфизм путем оборачивания лямбда-выражений в std::function

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

Для реализации задачи можно поместить несколько объектов функции-наблюдателя в вектор, все они будут принимать в качестве параметра переменную типа int, которая представляет наблюдаемое значение. Мы не знаем, что именно станут делать данные функции при вызове, но нам это и неинтересно.

Какой тип будут иметь объекты функций, помещенные в вектор? Нам подойдет тип std::vector<void (*)(int)>, если мы захватываем указатели на функции, имеющие сигнатуры наподобие void f(int);. Данный тип сработает с любым лямбда-выражением, которое захватывает нечто, имеющее совершенно другой тип в сравнении с обычной функцией, поскольку это не просто указатель на функцию, а объект, объединяющий некий объем данных с функцией! Подумайте о временах до появления С++11, когда лямбда-выражений не существовало. Классы и структуры были естественным способом связывания данных с функциями, и при изменении типов членов класса получится совершенно другой класс. Это естественно, что вектор не может хранить значения разных типов, используя одно имя типа.

Не стоит указывать пользователю, что он может сохранить объекты функции наблюдателя, которые ничего не захватывают, поскольку это ограничивает варианты применения. Как же позволить ему сохранять любые объекты функций, ограничивая лишь интерфейс вызова, принимающий конкретный диапазон параметров в виде наблюдаемых значений?

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

Как это делается

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

1. Сначала включим необходимые заголовочные файлы:

#include <iostream>
#include <deque>
#include <list>
#include <vector>
#include <functional>

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

static auto consumer (auto &container){
      return [&] (auto value) {
            container.push_back(value);
      };
}

3. Еще одна небольшая вспомогательная функция выведет на экран содержимое экземпляра контейнера, который мы предоставим в качестве параметра:

static void print (const auto &c)
{
      for (auto i : c) {
           std::cout << i << ", ";
      }
      std::cout << 'n';
}

4. В функции main мы создадим объекты классов deque, list и vector, каждый из которых будет хранить целые числа:

int main()
{
     std::deque<int> d;
     std::list<int> l;
     std::vector<int> v;

5. Сейчас воспользуемся функцией consumer для работы с нашими экземплярами контейнеров d, l и v: создадим для них объекты-потребители функций и поместим их в экземпляр vector. Эти объекты функций будут захватывать ссылку на один из объектов контейнера. Последние имеют разные типы, как и объекты функций. Тем не менее вектор хранит экземпляры типа std::function<void(int)>. Все объекты функций неявно оборачиваются в объекты типа std::function, которые затем сохраняются в векторе:

const std::vector<std::function<void(int)>> consumers
      {consumer(d), consumer(l), consumer(v)};

6. Теперь поместим десять целочисленных значений во все структуры данных, проходя по значениям в цикле, а затем пройдем в цикле по объектам функций-потребителей, которые вызовем с записанными значениями:

for (size_t i {0}; i < 10; ++i) {
     for (auto &&consume : consumers) {
          consume(i);
     }
}

7. Все три контейнера теперь должны содержать одинаковые десять чисел. Выведем на экран их содержимое:

   print(d);
   print(l);
   print(v);
}

8. Компиляция и запуск программы дадут следующий результат, который выглядит именно так, как мы и ожидали:

$ ./std_function
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,

» Более подробно с книгой можно ознакомиться на сайте издательства
» Оглавление
» Отрывок

Для Хаброжителей скидка 25% по купону — С++17 STL

Автор: ph_piter

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js