В этой главе:
□ преобразование единиц измерения времени с помощью
std::ratio
;
□ преобразование между абсолютными и относительными единицами измерения времени с использованием
std::chrono
;
□ безопасное извещение о сбое с помощью
std::optional
;
□ применение функций для кортежей;
□ быстрое создание структур данных с помощью
std::tuple
;
□ замена
void*
с использованием std::any
для повышения безопасности типов;
□ хранение разных типов с применением
std::variant
;
□ автоматическое управление ресурсами с помощью
std::unique_ptr
;
□ автоматическое управление разделяемой памятью кучи с использованием
std::shared_ptr
;
□ работа со слабыми указателями на разделяемые объекты;
□ упрощение управления ресурсами устаревших API с применением умных указателей;
□ открытие доступа к разным переменным — членам одного объекта;
□ генерация случайных чисел и выбор правильного генератора случайных чисел;
□ генерация случайных чисел и создание конкретных распределений с помощью STL.
Эта глава посвящена вспомогательным классам, которые очень удобны для решения конкретных задач. Некоторые из них хороши настолько, что мы, вероятно, либо будем их очень часто встречать в любом фрагменте кода С++, либо уже видели в других главах книги.
Первые два примера посвящены измерению времени. Кроме того, мы увидим, как выполнять преобразования между разными единицами измерения времени и перепрыгивать с одного момента времени на другой.
В следующих пяти примерах мы рассмотрим типы
optional
, variant
и any
(они появились в C++14 и C++17), а также некоторые способы использования кортежей. Начиная с C++11, у нас появились сложные типы умных указателей, а именно unique_ptr
, shared_ptr
и weak_ptr
, которые очень эффективны при управлении памятью, поэтому уделим им особое внимание.
Наконец, мы кратко рассмотрим те части библиотеки STL, которые связаны с генерацией случайных чисел. Помимо изучения самых важных характеристик генераторов случайных чисел STL мы также узнаем, как обрабатывать случайные числа, чтобы получить распределения, соответствующие нашим потребностям.
Начиная с С++11 STL включает новые типы и функции для получения, измерения и отображения времени. Эта часть библиотеки существует в пространстве имен
std::chrono
.
В этом примере мы сконцентрируемся на измерении промежутков времени и преобразовании результатов между единицами измерения времени, такими как секунды, миллисекунды и микросекунды. STL поддерживает функции, которые позволяют определять собственные единицы измерения времени и выполнять преобразования между ними.
Как это делается
В данном примере мы напишем небольшую игру, приглашающую пользователя ввести конкретное слово. Время, за которое он введет его с клавиатуры, будет измерено и выведено в нескольких единицах измерения.
1. Сначала включим все необходимые заголовочные файлы. Для удобства также объявим об использовании пространства имен
std
по умолчанию:
#include
#include
#include
#include
#include
#include
using namespace std;
2. Тип
chrono::duration
используется для выражения промежутков времени, сравнимых с долями секунд. Все единицы измерения времени, представленные в STL, ссылаются на целочисленные специализации. В этом примере мы будем конкретизировать их для типа double
. В следующем больше сконцентрируемся на существующих определениях единиц времени, уже встроенных в STL.
using seconds = chrono::duration;
3. Миллисекунда — доля секунды, поэтому определяем эту единицу измерения в виде секунд. Параметр шаблона
ratio_multiply
применяет заранее определенный в STL делитель milli
к seconds::period
, что дает нужную долю секунды. Шаблон ratio_multiply
, по сути, представляет собой функцию метапрограммирования для умножения чисел на эти множители:
using milliseconds = chrono::duration<
double, ratio_multiply>;
4. То же верно и для микросекунд. Миллисекунда — миллидоля секунды, а микросекунда — микродоля секунды:
using microseconds = chrono::duration<
double, ratio_multiply>;
5. Теперь реализуем функцию, которая считает входную строку, отправленную пользователем, и определит, сколько времени ему потребовалось для ее ввода. Она не принимает никаких аргументов и возвращает строку, введенную пользователем, а также время, которое ему потребовалось на ввод, упакованные в одну пару:
static pair get_input()
{
string s;
6. Нужно получить время, в которое пользователь начал вводить строку, а также время, в которое он это делать закончил. Данная операция выглядит следующим образом:
const auto tic (chrono::steady_clock::now());
7. Сейчас мы получим данные от пользователя. Если эта операция пройдет безуспешно, то просто вернем кортеж, инициализированный значениями по умолчанию. Вызывающая сторона получит пустую строку:
if (!(cin >> s)) {
return {{}, {}};
}
8. В случае успеха продолжим работу, сделав еще один снимок текущего времени. Далее вернем входную строку и разницу между временными точками. Обратите внимание: обе временные точки выражены в абсолютном виде, но, вычислив их разность, мы получаем длительность:
const auto toc (chrono::steady_clock::now());
return {s, toc - tic};
}
9. Теперь реализуем саму программу. Запустим цикл, который будет работать до тех пор, пока пользователь не введет корректную строку. На каждом шаге цикла мы просим пользователя ввести строку
"C++17"
, а затем вызываем функцию get_input
:
int main()
{
while (true) {
cout << "Please type the word \"C++17\" as"
" fast as you can.\n> ";
const auto [user_input, diff] = get_input();
10. Далее проверим входные данные. Если они пусты, то интерпретируем это как запрос на завершение программы:
if (user_input == "") { break; }
11. Если пользователь корректно введет строку
"C++17"
, то поздравим его, а затем выведем время, которое ему потребовалось на данное действие. Метод diff.count()
возвращает количество секунд в качестве числа с плавающей точкой. Используй мы оригинальный тип duration STL seconds
, получили бы округленное целочисленное значение, а не дробное. Передавая конструктору milliseconds
или microseconds
нашу переменную diff
перед вызовом count()
, получаем то же значение, преобразованное в другую единицу измерения:
if (user_input == "C++17") {
cout << "Bravo. You did it in:\n"
<< fixed << setprecision(2)
<< setw(12) << diff.count()
<< " seconds.\n"
<< setw(12) << milliseconds(diff).count()
<< " milliseconds.\n"
<< setw(12) << microseconds(diff).count()
<< " microseconds.\n";
break;
12. Если пользователь сделал опечатку, то позволим ему повторить попытку:
} else {
cout << "Sorry, your input does not match."
" You may try again.\n";
}
}
}
13. Компиляция и запуск программы дадут следующий результат. Сначала при наличии опечаток программа попросит пользователя ввести корректное слово. После этого она отобразит время, которое потребовалось на то, чтобы ввести его, в трех единицах измерения.
$ ./ratio_conversion
Please type the word "C++17" as fast as you can.
> c+17
Sorry, your input does not match. You may try again.
Please type the word "C++17" as fast as you can.
> C++17
Bravo. You did it in:
1.48 seconds.
1480.10 milliseconds.
1480099.00 microseconds.
Как это работает
Несмотря на то что этот раздел посвящен выполнению преобразований между разными единицами измерения времени, сначала нужно выбрать один из трех доступных объектов часов. Как правило, в пространстве имен
std::chrono
можно выбрать между system_clock
, steady_clock
и high_resolution_clock
. Чем они отличаются? Взглянем на их описание (табл. 8.1).
Поскольку мы определяли продолжительность промежутка времени между двумя абсолютными точками во времени (они хранятся в переменных
tic
и toc
), нам не нужно знать, были ли эти точки искажены глобально. Даже если часы спешат или опаздывают на 112 лет 5 часов 10 минут и 1 секунду (или другое значение), это не отражается на разности между ними. Единственное, что важно, — после того, как мы сохраняем временную точку tic
, и до того, как сохраняем временную точку toc
, для часов нельзя выполнить микронастройку (что случается время от времени во многих системах), поскольку это исказит измерение. Согласно данным требованиям, оптимальным выбором является steady_clock
. Их реализация может быть основана на счетчике временных меток процессора, который всегда монотонно увеличивается с момента запуска системы.
О’кей, теперь, когда мы выбрали правильный объект
time
, можем сохранить временные точки с помощью функции chrono::steady_clock::now()
. Функция now
возвращает значение типа chrono::time_point
. Разность между двумя такими значениями (toc–tic
) является временным промежутком, или продолжительностью, имеющей тип chrono::duration
.
Поскольку данный тип является основным для текущего раздела, все немного усложняется. Рассмотрим интерфейс шаблонного типа
duration
более пристально:
template<
class Rep,
class Period = std::ratio<1>
> class duration;
Можно изменить значения параметров
Rep
и Period
. Значение параметра Rep объяснить легко: это всего лишь численный тип переменной, который используется для сохранения значения времени. Для существующих в STL единиц измерения времени таковым обычно выступает тип long long int
. В данном примере мы выбрали тип double
и благодаря этому можем сохранять по умолчанию значения в секундах, а затем преобразовывать их в милли- или микросекунды. Если у нас есть промежуток времени, равный 1.2345
секунды и имеющий тип chrono::seconds
, то значение будет округлено до одной целой секунды. Таким образом, нужно сохранить разность между переменными tic
и toc
в переменной типа chrono::microseconds
, а затем преобразовать его в менее точные единицы. Из-за выбора типа double
для Rep
можно выполнять преобразование к более и менее точным единицам и терять минимальный объем точности, что не влияет на наш пример.
Мы использовали
Rep = double
для всех единиц измерения времени, поэтому они отличаются значением параметра Period
:
using seconds = chrono::duration;
using milliseconds = chrono::duration
ratio_multiply>;
using microseconds = chrono::duration
ratio_multiply>;
Секунды — самая простая в описании единица времени, поскольку можно воспользоваться конструкцией
Period = ratio<1>
, другие же придется подстраивать. Поскольку миллисекунда — одна тысячная секунды, мы умножим seconds::period
(который представляет собой всего лишь функцию-геттер для параметра Period
) на milli
— псевдоним типа std::ratio<1, 1000>
(std::ratio
— это дробное значение a/b
). Тип ratio_multiply
, по сути, является функцией времени компиляции, которая представляет собой тип, получаемый в результате умножения одного типа ratio
на другой.
Это может показаться непонятным, так что рассмотрим пример: команда
ratio_multiply, ratio<4, 5>>
даст результат ratio<8, 15>
, поскольку (2/3) * (4/5) = 8/15
.
Полученные описания типов эквивалентны следующим описаниям:
using seconds = chrono::durationratio<1, 1>>;
using milliseconds = chrono::durationratio<1, 1000>>;
using microseconds = chrono::duration>;
После получения этих типов можно легко выполнять преобразования между ними. При наличии промежутка времени
d
с типом seconds
можно преобразовать его в тип milliseconds
, передав в конструктор другого типа — milliseconds(d)
.
Дополнительная информация
В других учебниках и книгах при преобразовании промежутков времени вы могли столкнуться с
duration_cast
. Если у нас есть промежуток времени типа chrono::milliseconds
и нужно преобразовать его к типу chrono::hours
, например, то следует написать конструкцию duration_cast(milliseconds_value)
, поскольку данные единицы измерения зависят от целочисленных типов. Преобразование точных единиц времени в менее точные приводит к потере точности, именно поэтому и нужен duration_cast
. Для продолжительностей, основанных на типах double
или float
, этого не требуется.
До C++11 было довольно сложно получить физическое время и просто вывести его на экран, поскольку C++ не имела собственной библиотеки для работы с временем. Требовалось всегда вызывать функции библиотеки С, которая выглядит очень архаично, учитывая, что такие вызовы могут быть инкапсулированы в собственные классы.
Начиная с C++11, в STL можно найти библиотеку
chrono
, она значительно упрощает решение задач, связанных с временем.
В этом примере мы возьмем местное время, выведем его на экран и поработаем с ним, добавляя разные смещения, что очень удобно делать с помощью библиотеки
std::chrono
.
Как это делается
В примере мы сохраним текущее время и выведем его на экран. Кроме того, наша программа будет добавлять разные смещения к сохраненному времени и выводить на экран полученные результаты.
1. Сначала идут типичные директивы
include
, затем мы объявляем об использовании по умолчанию пространства имен std
:
#include
#include
#include
using namespace std;
2. Выведем на экран абсолютные моменты времени. Они будут иметь форму шаблона типа
chrono::time_point
, поэтому просто перегрузим для него оператор выходного потока. Существуют разные способы вывести на экран дату и/или время для заданного момента. Мы применим только стандартное форматирование %c
. Можно было бы, конечно, также вывести только время, только дату, только год или что-то еще, приходящее на ум. Все преобразования между разными типами до того, как мы сможем использовать put_time
, будут выглядеть несколько «неаккуратно», но мы провернем это лишь однажды.
ostream& operator<<(ostream &os,
const chrono::time_point &t)
{
const auto tt (chrono::system_clock::to_time_t(t));
const auto loct (std::localtime(&tt));
return os << put_time(loct, "%c");
}
3. Для секунд, минут, часов и т.д. в STL существуют описания типов. Сейчас мы добавим тип
days
. Это делается легко; нужно лишь специализировать шаблон chrono::duration
, сославшись на часы и умножив их на 24, поскольку сутки насчитывают 24 часа.
using days = chrono::duration<
chrono::hours::rep,
ratio_multiply>>;
4. Чтобы наиболее элегантным способом выразить продолжительность длиной в несколько дней, можно определить собственный пользовательский литерал
days
. Теперь можно написать 3_days
, чтобы создать значение, которое представляет собой три дня.
constexpr days operator ""_days(unsigned long long h)
{
return days{h};
}
5. В самой программе сделаем снимок момента времени, который затем просто выведем на экран. Это очень легко и удобно, поскольку мы уже реализовали правильную версию перегруженного оператора.
int main()
{
auto now (chrono::system_clock::now());
cout << "The current date and time is " << now << '\n';
6. Сохранив текущее время в переменной
now
, можем добавить к нему произвольные продолжительности и также вывести их на экран. Добавим к текущему времени 12 часов и выведем результат на экран:
chrono::hours chrono_12h {12};
cout << "In 12 hours, it will be "
<< (now + chrono_12h)<< '\n';
7. Объявляя об использовании по умолчанию пространства имен
chrono_literals
, разблокируем все существующие литералы, описывающие продолжительность, для часов, секунд и т.д. Таким образом, можно изящно вывести на экран, какое время было 12 часов 15 минут назад или семь дней назад.
using namespace chrono_literals;
cout << "12 hours and 15 minutes ago, it was "
<< (now - 12h - 15min) << '\n'
<< "1 week ago, it was "
<< (now - 7_days) << '\n';
}
8. Компиляция и запуск программы дадут следующий результат. Поскольку мы использовали в качестве строки форматирования
%c
, получим довольно полное описание в конкретном формате. Поработав с разными строками формата, можем вывести время в любом формате, который нам нравится. Обратите внимание: здесь мы применяем 24-часовой формат.
$ ./relative_absolute_times
The current date and time is Fri May 5 13:20:38 2017
In 12 hours, it will be Sat May 6 01:20:38 2017
12 hours and 15 minutes ago, it was Fri May 5 01:05:38 2017
1 week ago, it was Fri Apr 28 13:20:38 2017
Как это работает
Мы получили текущий момент времени из
std::chrono::system_clock
. Этот класс часов STL единственный способен преобразовывать свои значения моментов времени в структуру time
, которая может быть отображена в виде понятной человеку строки описания.
Чтобы вывести на экран такие моменты времени, мы реализовали оператор
<<
для потока вывода:
ostream& operator<<(ostream &os,
const chrono::time_point &t)
{
const auto tt (chrono::system_clock::to_time_t(t));
const auto loct (std::localtime(&tt));
return os << put_time(loct, "%c");
}
Здесь мы сначала преобразуем экземпляр типа
chrono::time_point
к типу std::time_t
. Значения этого типа можно преобразовать в локальное время, что мы делаем с помощью функции std::localtime
. Она возвращает указатель на преобразованное значение (не волнуйтесь об управлении памятью, лежащей за данным указателем; это статический объект, и для него память в куче не выделяется), которое мы наконец можем вывести на экран.
Функция
std::put_time
принимает такой объект и строку формата. Строка "%c"
отображает стандартную строку даты-времени, например "Sun Mar 12 11:33:40 2017"
.
Мы также могли бы указать строку
"%m/%d/%y"
, и тогда программа вывела бы на экран дату в формате 03/12/17. Весь список существующих форматов времени слишком длинный, он хорошо задокументирован в онлайн-справочнике по С++.
Помимо вывода на экран мы добавили смещения к нашему моменту времени. Это было просто, поскольку можно выразить промежутки времени, такие как 12 часов 15 минут, в виде
12h+15min
. Пространство имен chrono_literals
предоставляет удобные литералы типов для часов (h
), минут (min
), секунд (s
), миллисекунд (ms
), микросекунд (us
) и наносекунд (ns
).
Добавление подобной продолжительности к значению момента времени создает новое значение момента времени, поскольку типы имеют соответствующие перегруженные версии операторов
+
и –
, именно поэтому так легко добавлять и отображать смещения времени.
Когда программа общается с внешним миром и полагается на значения, получаемые извне, могут происходить всевозможные сбои.
Это означает вот что: когда мы пишем функцию, которая должна возвращать значение, но также может дать сбой, это нужно отразить с помощью неких изменений в интерфейсе функции. У нас есть несколько вариантов. Посмотрим, как разработать интерфейс функции, которая возвращает строку, но способна дать сбой:
□ использовать возвращаемое значение, указывающее на успех, и выходные параметры:
bool get_string(string&);
;
□ возвращать указатель (или умный указатель), значение которого можно установить на
nullptr
в случае сбоя: string* get_string();
;
□ генерировать исключение в случае сбоя и оставить сигнатуру функции очень простой:
string get_string();
.
Все эти подходы имеют свои преимущества и недостатки. Начиная с C++17, существует новый тип, который можно применять для решения такой задачи другим способом:
std::optional
. Идея необязательных значений происходит из чисто функциональных языков программирования (там они иногда называются типами Maybe (может быть)) и позволяет писать очень изящный код.
Мы можем обернуть в тип
optional
наши собственные типы, чтобы указать на пустые или ошибочные значения. Ниже мы узнаем, как это делается.
Как это делается
В этом примере мы реализуем программу, которая считывает целые числа, поступающие от пользователя, и складывает их. Поскольку пользователь всегда может ввести случайные символы вместо чисел, мы увидим, как тип
optional
способен улучшить обработку ошибок.
1. Сначала включим все необходимые заголовочные файлы и объявим об использовании пространства имен
std
:
#include
#include
using namespace std;
2. Определим целочисленный тип, который, может быть, содержит значение. Нам идеально подойдет тип std::optional. Обернув любой тип в тип optional, задаем ему еще одно возможное состояние, которое отражает тот факт, что тип не имеет значения:
using oint = optional;
3. Определив необязательный целочисленный тип, можем выразить тот факт, что функция, которая возвращает целое число, тоже способна дать сбой. Если мы возьмем целое число из пользовательских входных данных, то существует вероятность сбоя, поскольку пользователь может ввести какой-то другой символ, несмотря на наше приглашение. Возврат целого числа, обернутого в тип
optional
, идеально решает эту проблему. Если нам удалось считать целое число, то передадим его конструктору типа optional
. В противном случае вернем экземпляр типа optional, созданный с помощью конструктора по умолчанию, что говорит о сбое или пустоте.
oint read_int()
{
int i;
if (cin >> i) { return {i}; }
return {};
}
4. Наши возможности не ограничиваются возвратом целых чисел из функций, способных дать сбой. Что если мы подсчитаем сумму двух целых чисел, которые могут не содержать значений? Мы получим реальную численную сумму только в том случае, если оба операнда содержат значения. В любом другом нужно вернуть пустую переменную типа
optional
. Эту функцию следует немного пояснить: неявно преобразуя переменные типа optionala
и b
, к булевым выражениям (с помощью конструкций !a
и !b
), мы узнаем, содержат ли они реальные значения. Если да, то можно получить к ним доступ как к указателям или итераторам, просто разыменовав их с использованием конструкций *a
и *b
:
oint operator+(oint a, oint b)
{
if (!a || !b) { return {}; }
return {*a + *b};
}
5. Сложение обычного целого числа и целого числа, которое может не содержать значений, следует той же логике:
oint operator+(oint a, int b)
{
if (!a) { return {}; }
return {*a + b};
}
6. Теперь напишем программу, совершающую некие действия с целыми числами, которые могут не содержать значений. Пригласим пользователя ввести два числа:
int main()
{
cout << "Please enter 2 integers.\n> ";
auto a {read_int()};
auto b {read_int()};
7. Сложим эти числа, а затем добавим к полученной сумме значение
10
. Поскольку a
и b
могут не иметь значений, sum
также будет необязательной целочисленной переменной:
auto sum (a + b + 10);
8. Если переменные
a
и/или b
не содержат значений, то sum не может иметь значения. Положительный момент заключается в том, что не нужно явно проверять значения переменных a
и b
. Сложение пустых экземпляров типа optional
— полностью корректное поведение, поскольку мы определили безопасный оператор +
для этих типов. Таким образом можно произвольно сложить несколько пустых необязательных экземпляров, а проверять нужно только полученный экземпляр. Если он содержит значение, то мы можем безопасно получить к нему доступ и вывести его на экран:
if (sum) {
cout << *a << " + " << *b << " + 10 = "
<< *sum << '\n';
9. Если пользователь вводит не числа, то мы сообщаем об ошибке:
} else {
cout << "sorry, the input was "
"something else than 2 numbers.\n";
}
}
10. На этом все. Компиляция и запуск программы дадут следующий результат:
$ ./optional
Please enter 2 integers.
> 1 2
1 + 2 + 10 = 13
11. Если мы запустим программу снова и введем не числа, то увидим сообщение об ошибке, подготовленное нами для таких случаев:
$ ./optional
Please enter 2 integers.
> 2 z
sorry, the input was something else than 2 numbers.
Как это работает
Работать с типом
optional
очень просто и удобно. Если мы хотим, чтобы любой тип T
имел дополнительное состояние, указывающее на возможный сбой, то можем обернуть его в тип std::optional
.
Когда мы получаем экземпляр подобного типа откуда бы ни было, нужно проверить, он пуст или же содержит значение. Здесь поможет функция
optional::has_value()
. Если она возвращает значение true
, то можно получить доступ к этому значению. Это позволяет сделать вызов T& optional::value()
.
Вместо того чтобы всегда использовать конструкции
if (x.has_value()) {...}
и x.value()
, можно применить конструкции if (x) { }
и *x
. В типе std::optional
определено неявное преобразование к типу bool
и operator*
так, что работа с типом optional
похожа на работу с указателем.
Существует еще один удобный вспомогательный оператор — это
->
. Если у нас есть тип struct Foo { int a; string b; }
и нужно получить доступ к одному из его членов с помощью переменной x
типа optional
, то можно написать конструкцию x->a
или x->b
. Конечно, сначала следует проверить, содержит ли х
значение. Если мы попробуем получить доступ к объекту типа optional
, который не содержит значения, то будет сгенерирована ошибка std::logic_error
. Таким образом, нельзя работать с большим количеством необязательных экземпляров, не проверяя их.
С помощью блока
try-catch
можно писать код в следующей форме:
cout << "Please enter 3 numbers:\n";
try {
cout << "Sum: "
<< (*read_int() + *read_int() + *read_int())
<< '\n';
} catch (const std::bad_optional_access &) {
cout << "Unfortunately you did not enter 3 numbers\n";
}
Еще одним трюком для типа
std::optional
является optional::value_or
. Это поможет, когда мы хотим взять необязательное значение и, если оно окажется пустым, откатить его к значению, заданному по умолчанию. Можно решить эту задачу с помощью одной емкой строки x = optional_var.value_or(123)
, где 123
— значение по умолчанию.
Начиная с C++11, STL предоставляет тип
std::tuple
. Он позволяет время от времени объединять несколько значений в одну переменную и получать к ним доступ. Кортежи есть во многих языках программирования, и в некоторых примерах данной книги мы уже работали с этим типом, поскольку он крайне гибок.
Однако иногда мы помещаем в кортеж значения, а затем хотим вызвать функции, передав в них его отдельные члены. Распаковывать члены по отдельности для каждого аргумента функции очень утомительно (а кроме того, могут возникнуть ошибки, если где-то вкрадется опечатка). Это выглядит так:
func(get<0>(tup)
, get<1>(tup)
, get<2>(tup), ...);
.
Ниже мы рассмотрим, как упаковывать значения в кортежи и ловко распаковывать из них, чтобы вызвать функции, которые не знают о кортежах.
Как это делается
В этом примере мы реализуем программу, которая упаковывает значения в кортежи и распаковывает из них. Затем увидим, как вызывать функции, ничего не знающие о кортежах, и передавать в них значения из кортежей.
1. Сначала включим множество заголовочных файлов и объявим об использовании пространства имен
std
:
#include
#include
#include
#include
#include
#include
using namespace std;
2. Определим функцию, которая принимает несколько параметров, описывающих студента, и выводит их на экран. Многие устаревшие интерфейсы и интерфейсы функций языка С выглядят похоже:
static void print_student(size_t id, const string &name, double gpa)
{
cout << "Student " << quoted(name)
<< ", ID: " << id
<< ", GPA: " << gpa << '\n';
}
3. В самой программе определим тип кортежа динамически и заполним его осмысленными данными о студентах:
int main()
{
using student = tuple;
student john {123, "John Doe"s, 3.7};
4. Чтобы вывести такой объект на экран, можем разбить его на отдельные члены и вызвать функцию
print_student
для этих отдельных переменных:
{
const auto &[id, name, gpa] = john;
print_student(id, name, gpa);
}
cout << "-----\n";
5. Создадим несколько студентов в виде списка инициализаторов для кортежей:
auto arguments_for_later = {
make_tuple(234, "John Doe"s, 3.7),
make_tuple(345, "Billy Foo"s, 4.0),
make_tuple(456, "Cathy Bar"s, 3.5),
};
6. Мы все еще можем относительно комфортно вывести их на экран, но, чтобы разбить кортеж на части, следует знать, сколько элементов в нем содержится. Если нужно писать подобный код, то понадобится также реструктурировать его в случае изменения интерфейса вызова функции:
for (const auto &[id, name, gpa] : arguments_for_later) {
print_student(id, name, gpa);
}
cout << "-----\n";
7. Можно сделать лучше. Даже не зная типов аргументов функции
print_student
или количества членов кортежа, описывающего студентов, можно направить содержимое кортежа непосредственно в функцию с помощью std::apply
. Она принимает указатель на функцию или объект функции и кортеж, а затем распаковывает кортеж, чтобы вызвать функцию, передав в нее в качестве параметров члены кортежа:
apply(print_student, john);
cout << " \n";
8. Конечно, все это прекрасно работает и в цикле:
for (const auto &args : arguments_for_later) {
apply(print_student, args);
}
cout << "-----\n";
}
9. Компиляция и запуск программы покажут, что работают оба подхода, как мы и предполагали:
$ ./apply_functions_on_tuples
Student "John Doe", ID: 123, GPA: 3.7
-----
Student "John Doe", ID: 234, GPA: 3.7
Student "Billy Foo", ID: 345, GPA: 4
Student "Cathy Bar", ID: 456, GPA: 3.5
-----
Student "John Doe", ID: 123, GPA: 3.7
-----
Student "John Doe", ID: 234, GPA: 3.7
Student "Billy Foo", ID: 345, GPA: 4
Student "Cathy Bar", ID: 456, GPA: 3.5
-----
Как это работает
Функция
std::apply
— это вспомогательная функция времени компиляции, которая позволяет нам работать, не имея сведений обо всех типах данных, которые появляются в нашем коде.
Допустим, у нас есть кортеж
t
со значениями (123, "abc"s, 456.0)
. Он имеет тип tuple
. Вдобавок предположим, что у нас есть функция f
с сигнатурой int f(int, string, double)
(типы также могут быть ссылками).
Затем можно написать конструкцию
x = apply(f, t)
, которая приведет к вызову функции x = f(123, "abc"s, 456.0)
. Метод apply
даже не возвращает результат работы функции f
.
Взглянем на простой пример использования кортежей, с которым мы уже сталкивались. Мы можем определить следующую структуру, чтобы просто объединить некоторые переменные в одну сущность:
struct Foo {
int a;
string b;
float c;
};
Вместо того чтобы определять структуру, как было сделано в предыдущем примере, можно также определить кортеж:
using Foo = tuple;
Получить доступ к его элементам можно по порядковому номеру типа из списка типов. Для получения доступа к первому члену кортежа
t
напишем конструкцию std::get<0>(t);
для получения доступа ко второму члену — std::get<1>
и т.д. Если порядковый номер слишком велик, то компилятор безопасно сгенерирует ошибку.
На протяжении этой книги мы уже использовали возможности декомпозиции C++17 для кортежей. Она позволяет быстро разбить кортеж на элементы, просто написав
auto [a,b,c] = some_tuple
, чтобы получить доступ к отдельным элементам.
Композиция и декомпозиция отдельных структур данных — не единственная возможность, которую предоставляют кортежи. Мы также можем конкатенировать или разбивать кортежи. В этом разделе мы поэкспериментируем с данными функциями, чтобы узнать, как они работают.
Как это делается
В этом примере мы напишем программу, которая может динамически вывести на экран любой кортеж. Вдобавок напишем функцию, способную объединять кортежи.
1. Сначала включим несколько заголовочных файлов, а затем объявим об использовании пространства имен
std
:
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
2. Поскольку мы будем работать с кортежами, было бы интересно отобразить их содержимое. Поэтому сейчас реализуем очень обобщенную функцию, которая может выводить на экран любые кортежи. Функция принимает ссылку на поток вывода
os
, которая будет использована для вывода данных на экран, и список аргументов переменной длины, содержащий все члены кортежа. Мы разбили на части все аргументы в первом элементе и поместили их в аргумент v
, а остальные поместили в наборе параметров vs...
:
template
void print_args(ostream &os, const T &v, const Ts &. vs)
{
os << v;
3. Если в наборе параметров еще есть аргументы, то они выводятся на экран, притом их разделяет символ "
,
" — используется прием с развертыванием initializer_list
. Мы говорили о нем в главе 4.
(void)initializer_list{((os << ", " << vs), 0)...};
}
4. Теперь можно выводить на экран произвольное количество аргументов с помощью конструкции
print_args(cout,1,2,"foo",3,"bar")
, например. Но мы пока не трогали кортежи. Для вывода кортежей на экран переопределим оператор потока вывода <<
, чтобы он работал с кортежами, реализовав шаблонную функцию, которая соответствует любым специализациям для кортежа:
template
ostream& operator<<(ostream &os, const tuple &t)
{
5. Сейчас все станет чуть сложнее. Сначала мы воспользуемся лямбда-выражением, которое принимает произвольно большое количество параметров. При вызове выражение добавляет аргумент
os
в начало списка данных аргументов, а затем вызывает функцию print_args
, передавая полученный новый список аргументов. Это значит, что вызов capt_tup(...некоторые параметры...)
преобразуется в вызов print_args(os, ...некоторые параметры...)
.
auto print_to_os ([&os](const auto &...xs) {
print_args(os, xs...);
});
6. Теперь можем выполнить распаковку кортежа. Для этого воспользуемся методом
std::apply
. Все значения будут извлечены из кортежа и представлены как аргументы функции для той функции, которую мы предоставили в качестве первого аргумента. Это значит, что если у нас есть кортеж t = (1,2,3)
и мы вызываем метод apply(capt_tup,t)
, то эти действия приведут к вызову функции capt_tup(1,2,3)
, что, в свою очередь, повлечет вызов print_args(os,1,2,3)
. Это именно то, что нужно. Дополнительно окружим операцию вывода скобками:
os << "(";
apply(print_to_os, t);
return os << ")";
}
7. О’кей, мы написали сложный фрагмент кода, который сделает нашу жизнь гораздо проще, если мы захотим вывести кортеж на экран. Но с помощью кортежей мы можем сделать больше. Допустим, напишем функцию, принимающую в качестве аргумента итерабельный диапазон данных, например вектор или список чисел. Эта функция проитерирует по данному диапазону и вернет сумму всех его чисел, а также минимальное, максимальное и среднее значения. Упаковывая эти четыре значения в кортеж, можем вернуть их как один объект, не определяя дополнительный тип структуры.
template
tuple
sum_min_max_avg(const T &range)
{
8. Функция
std::minmax_element
возвращает пару итераторов, указывающих на минимальный и максимальный элементы входного диапазона. Метод std::accumulate
складывает все значения в данном диапазоне. Это все нужно, чтобы вернуть четыре значения, которые позже будут помещены в кортеж!
auto min_max (minmax_element(begin(range), end(range)));
auto sum (accumulate(begin(range), end(range), 0.0));
return {sum, *min_max.first, *min_max.second,
sum / range.size()};
}
9. Перед реализацией основной программы создадим последнюю волшебную вспомогательную функцию. Я называю ее волшебной, поскольку она на первый взгляд выглядит очень сложной, но если понять принцип ее работы, она покажется очень удобным помощником. Она сгруппирует два кортежа. Это значит, что если мы передадим ей кортежи
(1,2,3)
и ('a','b','c')
, то она вернет кортеж (1,'a',2,'b',3,'c')
.
template
static auto zip(const T1 &a, const T2 &b)
{
10. Мы подобрались к самым сложным строкам данного примера. Создадим объект функции z, принимающий произвольное количество аргументов. Затем он возвращает другой объект функции, который захватывает все эти аргументы в набор параметров
xs
, а также принимает произвольное количество аргументов. Забудем об этом на минуту. С помощью упомянутого внутреннего объекта функции можно получить доступ к обоим спискам аргументов в виде наборов параметров xs
и ys
. А теперь взглянем, что именно происходит с этими наборами параметров. Вызов make_tuple(xs,ys)
. группирует наборы параметров поэлементно. Т.е. при наличии наборов xs = 1,2,3
и ys = 'a','b','c'
он вернет новый набор параметров (1,'a')
, (2,'b')
, (3,'c')
. Данный набор представляет собой разделенный запятыми список, состоящий из трех кортежей. Чтобы объединить их в один кортеж, используем функцию std::tuple_cat
, которая принимает произвольное количество кортежей и упаковывает их в один. Таким образом получим кортеж (1,'a',2,'b',3,'c')
.
auto z ([](auto ...xs) {
return [xs...](auto ...ys) {
return tuple_cat(make_tuple(xs, ys) );
};
});
11. Последний шаг состоит в том, чтобы распаковать все значения из входных кортежей
a
и b
и поместить их в z
. Вызов apply(z,a)
помещает все значения из a
в набор параметров xs
, а вызов apply(...,b)
помещает все значения b
в набор параметров ys
. Полученный кортеж представляет собой остальные сгруппированные кортежи, которые мы и возвращаем вызывающей стороне:
return apply(apply(z, a), b);
}
12. Мы написали довольно много строк кода для вспомогательных функций. Теперь воспользуемся ими. Сначала создадим произвольные кортежи.
student
содержит идентификатор, имя и средний балл студента. student_desc
включает строки, которые описывают значение этих полей, в форме, доступной человеку. std::make_tuple
— приятный помощник, поскольку определяет тип всех аргументов и создает подходящий тип кортежа:
int main()
{
auto student_desc (make_tuple("ID", "Name", "GPA"));
auto student (make_tuple(123456, "John Doe", 3.7));
13. Выведем на экран все, чем располагаем. Сделать это очень легко, поскольку мы только что реализовали соответствующую перегруженную версию оператора
<<
.
cout << student_desc << '\n'
<< student << '\n';
14. Кроме того, можем сгруппировать оба кортежа динамически с помощью
std::tuple_cat
и вывести их на экран следующим образом:
cout << tuple_cat(student_desc, student) << '\n';
15. Мы можем создать и новый сгруппированный кортеж, задействуя нашу функцию
zip
, и вывести его на экран:
auto zipped (zip(student_desc, student));
cout << zipped << '\n';
16. Не будем забывать и о функции
sum_min_max_avg
. Создадим список инициализации, который содержит некие числа, и передадим его в эту функцию. Чтобы слегка усложнить задачу, создадим еще один кортеж такого же размера, содержащий строки с описанием. Группируя эти кортежи, получим «аккуратные», чередующиеся выходные данные, которые увидим при запуске программы:
auto numbers = {0.0, 1.0, 2.0, 3.0, 4.0};
cout << zip(
make_tuple("Sum", "Minimum", "Maximum", "Average"),
sum_min_max_avg(numbers))
<< '\n';
}
17. Компиляция и запуск программы дадут следующий результат. Первые две строки представляют отдельные кортежи
student
и student_desc
. Третья строка — это комбинация кортежей, которую мы получили с помощью вызова tuple_cat
. Четвертая содержит сгруппированный кортеж для студента. В последней строке видим сумму, а также минимальное, максимальное и среднее значения для созданного нами списка чисел. Благодаря группировке очень легко понять смысл каждого значения.
$ ./tuple
(ID, Name, GPA)
(123456, John Doe, 3.7)
(ID, Name, GPA, 123456, John Doe, 3.7)
(ID, 123456, Name, John Doe, GPA, 3.7)
(Sum, 10, Minimum, 0, Maximum, 4, Average, 2)
Как это работает
Отдельные фрагменты кода, представленные в этом разделе, довольно сложны. Мы написали реализацию
operator<<
для кортежей, которая выглядит очень сложной, но зато поддерживает все виды кортежей, содержащих выводимые типы. Далее мы реализовали функцию sum_min_max_avg
, возвращающую кортеж. Еще одним сложным моментом является функция zip
.
Самой простой частью была функция
sum_min_max_avg
. Ее идея заключается в следующем: при реализации функции, которая возвращает экземпляр типа tuple f()
, можно просто написать return {foo_instance, bar_ instance, baz_instance};
внутри этой функции, чтобы создать такой кортеж. Если вам трудно понять использованные нами алгоритмы STL, то, вероятно, следует обратиться к главе 5, где мы уже рассмотрели их подробнее.
Остальной код настолько сложен, что мы посвятим конкретным вспомогательным функциям отдельные подразделы.
operator<< для кортежей
До работы с
operator<<
для потоков вывода мы реализовали функцию print_args
. Из-за своей природы он принимает произвольное количество аргументов разных типов до тех пор, пока первым из них является экземпляр типа ostream
:
template
void print_args(ostream &os, const T &v, const Ts &. vs)
{
os << v;
(void)initializer_list{((os << ", " << vs), 0)...};
}
Эта функция выводит на экран первый элемент
v
, а затем все элементы из набора параметров vs
. Мы выводим на экран первый элемент отдельно, поскольку хотим, чтобы все элементы были разделены запятыми, но не хотим, чтобы строка начиналась или заканчивалась запятой (например, "1,2,3, "
или ",1,2,3"
). Мы узнали о приеме с разворачиванием initializer_list
из примера «Вызов нескольких функций с одними и теми же входными данными» главы 4. Подготовив эту функцию, мы получили все необходимое для вывода кортежей на экран. Наша реализация оператора <<
выглядит следующим образом:
template
ostream& operator<<(ostream &os, const tuple &t)
{
auto capt_tup ([&os](const auto &...xs) {
print_args(os, xs...);
});
os << "(";
apply(capt_tup, t);
return os << ")";
}
Первое, что мы делаем, — это определяем объект функции
capt_tup
. Вызов capt_tup(foo, bar, whatever)
приводит к вызову print_args(os, foo, bar, whatever)
. Данный объект функции делает только одно: добавляет к списку аргументов объект потока вывода os
.
После этого мы воспользуемся методом
std::apply
, чтобы распаковать все элементы из кортежа t
. Если данный шаг выглядит слишком сложным, обратитесь к предыдущему примеру — он демонстрирует работу метода std::apply
.
Функция zip для кортежей
Функция
zip
принимает два кортежа, но выглядит весьма сложной, несмотря на то что имеет очень четкую реализацию:
template
auto zip(const T1 &a, const T2 &b)
{
auto z ([](auto ...xs) {
return [xs...](auto ...ys) {
return tuple_cat(make_tuple(xs, ys) ...);
};
});
return apply(apply(z, a), b);
}
Для лучшего понимания этого кода представьте, что кортеж
a
содержит значения 1
, 2
, 3
, а кортеж b
— значения 'a'
, 'b'
, 'c'
.
В данном случае вызов
apply(z, a)
приведет к вызову z(1, 2, 3)
. Он вернет объект функции, который захватит значения 1
, 2
, 3
в набор параметров xs
. В момент вызова с помощью apply(z(1,2,3),b)
этот объект получит значения 'a'
, 'b'
, 'c'
, помещенные в набор параметров ys
. По сути, действие аналогично прямому вызову z(1,2,3)('a', 'b', 'c')
.
О’кей, что произойдет теперь, когда у нас есть значения
xs = (1, 2, 3)
и ys = ('a', 'b', 'c')
? Выражение tuple_cat(make_tuple(xs, ys) ...)
сделает следующее (рис. 8.1).
Сначала элементы из наборов
xs
и ys
будут сгруппированы попарно. Это «попарное чередование» выполняется в вызове make_tuple(xs,ys)
. Сначала мы получим список кортежей переменной длины по два элемента в каждом. Чтобы получить один большой кортеж, мы применяем вызов tuple_cat
— в результате получаем большой сконкатенированный кортеж, содержащий все члены исходных кортежей, которые чередуются.
Может случиться так: нам понадобится сохранить элементы любого типа в переменной. Для такой переменной следует проверить, содержит ли она что-либо, и если да, то нужно определить, что именно. Все это надо сделать безопасно для типов.
В прошлом мы имели возможность хранить указатели на различные объекты в указателе типа
void*
. Такой указатель сам по себе не может сказать, на какой объект ссылается, поэтому нужно вручную создать некий дополнительный механизм, который сообщит, чего стоит ожидать. Данное решение приводит к созданию некрасивого и небезопасного кода.
Еще одним дополнением к STL в C++17 является тип
std::any
. Он разработан для того, чтобы хранить переменные любого вида, и предоставляет средства, которые позволяют выполнить проверку, безопасную для типов, и получить доступ к данным.
В текущем разделе мы поработаем с этим вспомогательным типом для того, чтобы несколько лучше его понять.
Как это делается
В этом примере мы реализуем функцию, которая пробует вывести на экран какие-либо данные. В качестве ее типа аргумента служит тип
std::any
.
1. Сначала включим необходимые заголовочные файлы и объявим об использовании пространства имен
std
:
#include
#include
#include
#include
#include
using namespace std;
2. Чтобы сократить объем использования угловых скобок в следующей программе, определим псевдоним для типа
list
и будем применять его впоследствии:
using int_list = list;
3. Реализуем функцию, которая утверждает, что может вывести на экран любые данные. Обещание заключается вот в чем: она выводит любые данные, предоставленные как аргумент в виде переменной
std::any
:
void print_anything(const std::any &a)
{
4. Первое, что нужно сделать, — проверить, содержит ли аргумент какие-то данные, или же это пустой экземпляр типа any. Если он пуст, то нет смысла пытаться определить, как его выводить на экран.
if (!a.has_value()) {
cout << "Nothing.\n";
5. Если он не пуст, то можно попробовать сравнивать его с разными типами до тех пор, пока не получим совпадение. Первым типом послужит тип
string
. Если это строка, то можно выполнить преобразование a
к ссылке string
с помощью std::any_cast
и просто вывести его на экран. Из соображений эстетики мы поместим строку в кавычки:
} else if (a.type() == typeid(string)) {
cout << "It's a string: "
<< quoted(any_cast(a)) << '\n';
6. Если это не
string
, то может быть int
. При совпадении данного типа можно использовать преобразование any_cast
, чтобы получить реальное значение int
:
} else if (a.type() == typeid(int)) {
cout << "It's an integer: "
<< any_cast(a) << '\n';
7.
std::any
работает не только для простых типов наподобие string
и int
. В переменную any можно поместить и ассоциативный массив, список или экземпляр любого другого сложного типа данных. Посмотрим, являются ли входные данные списком целых чисел, и если да, то можем вывести его точно так же, как и любой другой список:
} else if (a.type() == typeid(int_list)) {
const auto &l (any_cast(a));
cout << "It's a list: ";
copy(begin(l), end(l),
ostream_iterator{cout, ", "});
cout << '\n';
8. Если не подошел ни один из перечисленных типов, то у нас закончатся догадки. В таком случае просто сдадимся и скажем пользователю, что не знаем, как выводить эти данные на экран:
} else {
cout << "Can't handle this item.\n";
}
}
9. В функции
main
можем вызвать эту функцию с произвольными типами, с пустой переменной типа any
с помощью {}
или передать ей строку "abc"
или целое число. Поскольку экземпляр типа std::any
может быть создан на основе этих типов неявно, не возникает задержек, связанных с синтаксисом. Мы даже можем создать целый список и передать его в эту функцию:
int main()
{
print_anything({});
print_anything("abc"s);
print_anything(123);
print_anything(int_list{1, 2, 3});
10. Если мы будем помещать объекты, копировать которые действительно дорого, в переменную типа
any
, то можем также выполнить конструкцию «на месте» (in-place). Попробуем сделать это для нашего списочного типа. Выражение in_place_type_t{}
представляет собой пустой объект, дающий конструктору типа any достаточно информации о том, что мы собираемся создать. Второй параметр, {1,2,3}
, — просто список инициализации, который будет передан в int_list
, будучи встроенным в переменную типа any
с целью создания объекта. Это способ избежать ненужного копирования или перемещения.
print_anything(any(in_place_type_t{}, {1, 2, 3}));
}
11. Компиляция и запуск программы дадут следующие результаты, они полностью соответствуют нашим ожиданиям:
$ ./any
Nothing.
It's a string: "abc"
It's an integer: 123
It's a list: 1, 2, 3,
It's a list: 1, 2, 3,
Как это работает
Тип
std::any
в чем-то похож на тип std::optional
— он поддерживает метод has_value()
, который говорит, содержит ли экземпляр значение. Но, помимо этого, он может содержать все что угодно, вследствие чего с ним работать немного сложнее, нежели с типом optional
.
Прежде чем получать доступ к содержимому переменной типа
any
, нужно определить, какого типа хранящееся в ней значение, а затем преобразовать данные к этому типу.
Определить тип значения можно с помощью следующего сравнения:
x.type() == typeid(T)
. Если оно возвращает результат true
, то можно использовать преобразование any_cast
, чтобы получить содержимое.
Обратите внимание:
any_cast(x)
возвращает копию внутреннего значения. Если нужно получить ссылку, чтобы избежать копирования сложных объектов, то следует использовать конструкцию any_cast(x)
. Именно это мы и сделали, когда получали доступ к объектам типа string
или list
в коде данного раздела.
Если мы преобразуем экземпляр к неправильному типу, будет сгенерировано исключение
std::bad_any_cas
t.
В языке С++ для создания типов можно использовать не только примитивы
struct
и class
. Если нужно выразить, что какие-то переменные могут содержать значения типа А
либо значения типа В
(или C
, или любого другого), то на помощь придут объединения. Проблема с объединениями заключается в том, что они не могут сказать, для хранения каких типов были инициализированы.
Рассмотрим следующий код:
union U {
int a;
char *b;
float c;
};
void func(U u) { std::cout << u.b << '\n'; }
Допустим, мы вызовем функцию
func
для объединения, которое было инициализировано так, чтобы хранить в нем целое число в члене a
. Тогда ничто не помешает нам получить доступ к нему так, как если бы оно было инициализировано способом, позволяющим хранить в нем указатель на строку в члене b
. Из подобного кода могут появиться самые разнообразные ошибки. Прежде чем мы поместим в наше объединение вспомогательную переменную, которая скажет нам, для чего оно было инициализировано, можем воспользоваться типом std::variant
, появившимся в C++17.
Тип
variant
, по сути, представляет собой обновленную версию типа union
. Он не использует кучу, поэтому настолько же эффективно задействует память и время, как и решение, основанное на объединениях, так что нам нет нужды реализовывать его самостоятельно. Тип может хранить все что угодно, кроме ссылок массивов или объектов типа void
.
В этом разделе мы создадим программу, которая задействует тип
variant
.
Как это делается
В этом примере мы реализуем программу, которая уже знакома с типами
cat
и dog
и сохраняет смешанный список экземпляров обоих типов, не используя полиморфизм.
1. Сначала включим все необходимые заголовочные файлы и объявим об использовании пространства имен
std
:
#include
#include
#include
#include
#include
using namespace std;
2. Далее реализуем два класса, имеющих схожий инструментарий, но не связанных друг с другом, что отличает их от классов, которые, скажем, наследуют от одного интерфейса или похожих интерфейсов. Первый класс — это класс
cat
. Объект класса cat имеет имя и может сказать «мяу» (meow):
class cat {
string name;
public:
cat(string n) : name{n} {}
void meow() const {
cout << name << " says Meow!\n";
}
};
3. Второй класс — это класс
dog
. Объект класса dog
, конечно, может сказать не «мяу», а «гав» (woof):
class dog {
string name;
public:
dog(string n) : name{n} {}
void woof() const {
cout << name << " says Woof!\n";
}
};
4. Теперь можно определить тип
animal
, он будет представлять собой псевдоним типа std::variant
. По сути, он работает как старое доброе объединение, но имеет все дополнительные средства, предоставленные типом variant
:
using animal = variant;
5. Прежде чем писать основную программу, нужно реализовать два вспомогательных элемента. Одним из них является предикат
animal
. Вызвав is_type(...)
или is_type(...)
, можно определить, какого типа данные содержатся в экземпляре типа animal
. Реализация просто вызывает функцию holds_alternative
, которая, по сути, является обобщенной функцией-предикатом для типа variant
:
template
bool is_type(const animal &a) {
return holds_alternative(a);
}
6. Вторым вспомогательным элементом является структура, которая ведет себя как объект функции. Это двойной объект функции, поскольку он дважды реализует оператор
()
. Одна из реализаций — перегруженная версия, принимающая экземпляры типа dog
, вторая же принимает экземпляры типа cat
. Для этих типов она просто вызывает функции woof
или meow
:
struct animal_voice
{
void operator()(const dog &d) const { d.woof(); }
void operator()(const cat &c) const { c.meow(); }
};
7. Воспользуемся результатами нашего труда. Сначала определим список переменных типа
animal
и заполним его экземплярами типов cat
и dog
:
int main()
{
list l {cat{"Tuba"}, dog{"Balou"}, cat{"Bobby"}};
8. Теперь трижды выведем на экран содержимое списка, каждый раз новым способом. Один из них заключается в использовании
variant::index()
. Поскольку animal
является псевдонимом для variant
, возвращаемое значение 0
означает, что переменная хранит экземпляр типа dog
. Значение индекса 1
говорит о том, что это экземпляр типа cat
. Здесь важен порядок типов в специализации variant
. В блоке switch case
мы получаем доступ к variant
с помощью вызова get
для получения экземпляра типа cat
или dog
, хранящегося внутри:
for (const animal &a : l) {
switch (a.index()) {
case 0:
get(a).woof();
break;
case 1:
get(a).meow();
break;
}
}
cout << "-----\n";
9. Вместо того чтобы использовать численный индекс типа, можно также явно запросить каждый тип. Вызов
get_if
возвращает указатель на объект типа do
на внутренний экземпляр типа dog
. Если такого экземпляра внутри нет, то указатель равен null
. Таким образом, мы можем попробовать получать разные типы до тех пор, пока не преуспеем.
for (const animal &a : l) {
if (const auto d (get_if(&a)); d) {
d->woof();
} else if (const auto c (get_if(&a)); c) {
c->meow();
}
}
cout << "-----\n";
10. Последний — и самый элегантный вариант — это
variant::visit
. Данная функция принимает объект функции и экземпляр типа variant
. Объект функции должен реализовывать разные перегруженные версии для всех вероятных типов, которые может хранить variant
. Ранее мы реализовали структуру, имеющую необходимые перегруженные версии оператора ()
, поэтому можем использовать ее здесь:
for (const animal &a : l) {
visit(animal_voice{}, a);
}
cout << "-----\n";
11. Наконец подсчитаем количество экземпляров типов
cat
и dog
в списке. Предикат is_type
может быть специализирован для типов cat
и dog
, а затем использован в комбинации с std::count_if
, чтобы получить количество экземпляров этого типа:
cout << "There are "
<< count_if(begin(l), end(l), is_type)
<< " cats and "
<< count_if(begin(l), end(l), is_type)
<< " dogs in the list.\n";
}
12. После компиляции и запуска программы на экране будет список, выведенный три раза. Затем мы увидим, что предикаты
is_type
, объединенные с count_if
, тоже работают хорошо:
$ ./variant Tuba says Meow!
Balou says Woof!
Bobby says Meow!
-----
Tuba says Meow!
Balou says Woof!
Bobby says Meow!
-----
Tuba says Meow!
Balou says Woof!
Bobby says Meow!
-----
There are 2 cats and 1 dogs in the list.
Как это работает
Тип
std::variant
похож на тип std::any
, поскольку они оба могут содержать объекты разных типов, и нужно определять во время работы программы, что именно в них хранится, прежде чем получить доступ к их содержимому.
С другой стороны, тип
std::variant
отличается от std::any
тем, что мы должны объявлять, экземпляры каких типов он может хранить в виде списка шаблонных типов. Экземпляр типа std::variant
должен хранить один экземпляр типа A
, B
или C
. Нельзя сделать так, чтобы в экземпляре типа variant
не хранился ни один экземпляр. Это значит, что тип std::variant
не поддерживает возможность опциональности.
Экземпляр типа
variant
имитирует объединение, которое может выглядеть так:
union U {
A a;
B b;
C c;
};
Проблема с объединениями заключается в том, что нужно создавать собственные механизмы для определения того, экземпляром какого типа оно было инициализировано:
A
, B
или C
. Тип std::variant
может сделать это за нас, не прилагая особых усилий.
В коде, показанном в этом разделе, мы использовали три разных способа работы с содержимым переменной variant.
Первый способ — применение функции
index()
типа variant
. Для типа variant
она может вернуть индекс 0
, если экземпляр был инициализирован переменной типа A
, 1
для типа B
или 2
для типа C
, и т.д. для более сложных вариантов.
Следующий способ — использование функции
get_if
. Она принимает адрес объекта типа variant
и возвращает указатель типа T
на его содержимое. Если тип T
указан неправильно, то указатель станет нулевым. Кроме того, можно вызвать метод get(x)
для переменной типа variant
, чтобы получить ссылку на ее содержимое, но если это не сработает, то данная функция сгенерирует исключение (перед выполнением таких преобразований можно проверить правильность типа с помощью булева предиката holds_alternative(x)
).
Последний способ получить доступ к значению, хранящемуся в типе
variant
, — применить функцию std::visit
. Она принимает объект функции и экземпляр типа variant
. Функция проверяет, какой тип имеет содержимое экземпляра типа variant
, а затем вызывает соответствующий перегруженный оператор ()
объекта функции.
Именно для этих целей мы и реализовали тип animal_voice, поскольку он может быть использован в комбинации с
visit
и variant
:
struct animal_voice
{
void operator()(const dog &d) const { d.woof(); }
void operator()(const cat &c) const { c.meow(); }
};
Последний описанный способ получения доступа к экземплярам, хранящимся в экземплярах типа
variant
, считается самым элегантным, поскольку в разделах кода, в которых мы получаем доступ к экземпляру, не нужно жестко кодировать возможные типы. Это позволяет проще расширять код.
Утверждение о том, что тип
variant
не может не иметь значения, было не совсем верным. Добавив тип std::monostate
в список вероятных типов, можно указать, что экземпляр не будет хранить значения.
Начиная с C++11 в STL появились умные указатели, помогающие отслеживать динамическую память и ее использование. Даже до C++11 существовал класс
auto_ptr
, который мог управлять динамической памятью, но его было легко применить неправильно.
Однако с появлением умных указателей теперь редко приходится самостоятельно использовать ключевые слова
new
и delete
, и это очень хорошо. Умные указатели — отличный пример автоматического управления памятью. Поддерживая объекты, память для которых выделяется динамически с помощью unique_ptr
, мы защищены от утечек памяти, поскольку при разрушении объекта данный класс автоматически вызывает поддерживаемый им объект.
Уникальный указатель выражает принадлежность объекта, на который ссылается, и выполняет свою задачу по освобождению его памяти, если та более не используется. Этот класс может навсегда освободить нас от утечек памяти (во всяком случае вместе со своими компаньонами
shared_ptr
и weak_ptr
, но в этом примере мы концентрируемся только на unique_ptr
). Самая приятная особенность заключается в том, что он не влияет на производительность и свободное место в сравнении с кодом, содержащим необработанные указатели и предусматривающим ручное управление памятью. (О’кей, он все еще устанавливает значение внутреннего необработанного указателя на nullptr
после разрушения объекта, на который он указывает, и это нужно учитывать при оптимизации. Большая часть кода, управляющего динамической памятью и написанного вручную, делает то же самое.)
В этом разделе мы рассмотрим
unique_ptr
и способы его использования.
Как это делается
В этом примере мы напишем программу, которая покажет, как
unique_ptr
работает с памятью путем создания пользовательского типа, добавляющего некие отладочные сообщения при создании и разрушении объекта. Затем поработаем с уникальными указателями, управляя экземплярами этого типа, для которых память выделяется динамически.
1. Сначала включим необходимые заголовочные файлы и объявим об использовании пространства имен
std
:
#include
#include
using namespace std;
2. Мы реализуем небольшой класс для объекта, которым будем управлять с помощью
unique_ptr
. Его конструктор и деструктор станут выводить сообщения на консоль; это поможет узнать о том, что объект был на самом деле удален.
class Foo
{
public:
string name;
Foo(string n)
: name{move(n)}
{ cout << "CTOR " << name << '\n'; }
~Foo() { cout << "DTOR " << name << '\n'; }
};
3. Попробуем реализовать функцию, принимающую в качестве аргументов уникальные указатели, чтобы увидеть, какие ограничения она имеет. Она обрабатывает элемент типа
Foo
, выводя его название. Обратите внимание: хотя уникальные указатели являются умными, работают без лишних издержек и удобны в использовании, они все еще могут иметь значение null
. Это значит, что их все еще нужно проверять перед разыменованием.
void process_item(unique_ptr p)
{
if (!p) { return; }
cout << "Processing " << p->name << '\n';
}
4. В функции main откроем еще одну область видимости, создадим два объекта типа
Foo
в куче и будем управлять ими обоими с помощью уникальных указателей. Создадим первый объект в куче явно, используя оператор new, а затем поместим его в конструктор переменной типа unique_ptr
, p1
. Создадим уникальный указатель p2
путем вызова make_unique
с аргументами, которые в противном случае передали бы конструктору класса Foo
. Этот способ более элегантен, поскольку можно воспользоваться автоматическим определением типов и при первом обращении к объекту он уже будет управляться unique_ptr
:
int main()
{
{
unique_ptr p1 {new Foo{"foo"}};
auto p2 (make_unique("bar"));
}
5. После того как мы покинем область видимости, оба объекта будут разрушены и их память вернется в кучу. Взглянем на функцию
process_item
и узнаем, как теперь ею пользоваться вкупе с unique_ptr
. Если мы создадим новый экземпляр типа Foo
, управляемый unique_ptr
в вызове функции, то его время жизни будет ограничено областью видимости функции. Когда функция process_item
отработает, объект будет уничтожен:
process_item(make_unique("foo1"));
6. Если мы хотим вызвать
process_item
для объекта, который существовал еще до вызова, нужно передать право владения, поскольку данная функция принимает unique_ptr
по значению; это значит, что его вызов создаст копию. Но unique_ptr
нельзя скопировать, его можно только переместить. Создадим еще два объекта типа Foo
и переместим один из них в process_item
. Взглянув на консоль, мы увидим, что foo2
был уничтожен после того, как отработала функция process_item
, поскольку мы передали ей право владения. Объект foo3
продолжит существовать до тех пор, пока не отработает функция main
.
auto p1 (make_unique("foo2"));
auto p2 (make_unique("foo3"));
process_item(move(p1));
cout << "End of main()\n";
}
7. Скомпилируем и запустим программу. Сначала мы увидим вызовы конструктора и деструктора для
foo
и bar
. Они разрушаются после того, как программа покидает дополнительную область видимости. Обратите внимание: объекты разрушаются в порядке, обратном тому, в котором были созданы. Следующая строка конструктора принадлежит foo1
— мы создали этот объект во время вызова process_item
. Он уничтожается сразу после вызова функции. Затем создали объекты foo2
и foo3
. Первый из них уничтожается сразу после вызова process_item
, где мы передали право владения. Другой элемент, foo3
, разрушается после выполнения последней строки кода функции main
.
$ ./unique_ptr
CTOR foo
CTOR bar
DTOR bar
DTOR foo
CTOR foo1
Processing foo1
DTOR foo1
CTOR foo2
CTOR foo3
Processing foo2
DTOR foo2
End of main()
DTOR foo3
Как это работает
Управлять объектами кучи с помощью
std::unique_ptr
очень легко. После того как мы инициализировали уникальный указатель так, чтобы он хранил указатель на некий объект, он не может быть случайно удален в какой-то ветке кода.
Если мы присвоим какой-то новый указатель уникальному указателю, то он сначала удалит старый объект, а только затем сохранит новый указатель. Для переменной уникального указателя
x
можно также вызвать x.reset()
только затем, чтобы удалить объект, на который он указывает, не присваивая новый указатель. Еще одна эквивалентная альтернатива повторному присваиванию с помощью x = new_pointer
— это x.reset(new_pointer)
.
Существует единственный способ освободить объект от управления
unique_ptr
без удаления самого объекта. Это делает функция release
, но использовать ее в большинстве ситуаций не рекомендуется.
Поскольку указатели нужно проверять перед разыменованием, они переопределяют некоторые операторы так, чтобы те походили на необработанные указатели. Условия наподобие
if (p) {...}
и if (p != nullptr) {...}
работают так же, как если бы мы проверяли необработанный указатель.
Разыменовать уникальный указатель можно с помощью функции
get()
, возвращающей необработанный указатель на объект, или непосредственно с применением operator*
, что опять же делает их похожими на необработанные указатели.
Одна из важных характеристик
unique_ptr
заключается в том, что его экземпляры нельзя скопировать, но можно переместить из одной переменной типа unique_ptr
в другую. Именно поэтому нам пришлось перемещать существующий уникальный указатель в функцию process_item
. Если бы мы могли скопировать уникальный указатель, то это значило бы, что объектом обладали сразу два указателя. Такое положение дел противоречит идее уникальных указателей, которая гласит: он может быть единственным владельцем объекта (а позже «удалителем»).
Поскольку существуют структуры данных, такие как
unique_ptr
и shared_ ptr
, необходимость создавать объекты в куче вручную с помощью ключевых слов new
и delete
возникает редко. Используйте эти классы везде, где возможно! unique_ptr
не создает никаких лишних издержек во время выполнения.
В предыдущем примере мы узнали, как использовать
unique_ptr
. Это очень полезный и важный класс, поскольку помогает нам управлять объектами, память для которых выделяется динамически. Однако он может владеть объектом только единолично. Нельзя сделать так, чтобы несколько объектов данного класса обладали одним динамически выделенным объектом, поскольку будет непонятно, кто именно должен удалить его.
Тип указателя
shared_ptr
был разработан специально для этого случая. Общие указатели могут быть скопированы любое количество раз. Внутренний механизм подсчета ссылок отслеживает, сколько объектов все еще содержат указатель на объект. Только последний общий указатель, выходящий за пределы области видимости, может удалить объект. Таким образом, можно быть уверенными в том, что утечек памяти не возникнет, поскольку объекты удаляются автоматически после использования, как и в том, что они не будут удаляться слишком рано или слишком часто (каждый созданный объект должен быть удален всего один раз).
В этом примере вы узнаете, как использовать
shared_ptr
для автоматического управления динамическими объектами, которые имеют несколько владельцев, и увидите, чем общие указатели отличаются от unique_ptr
:.
Как это делается
В этом примере мы напишем программу, похожую на ту, которую мы писали в предыдущем примере, чтобы освоить основные принципы использования общих указателей.
1. Сначала включим необходимые заголовочные файлы и объявим об использовании пространства имен
std
:
#include
#include
using namespace std;
2. Затем определим небольшой вспомогательный класс, который поможет увидеть, когда его экземпляры будут создаваться и разрушаться. Мы станем управлять его экземплярами с помощью
shared_ptr
:
class Foo
{
public:
string name;
Foo(string n)
: name{move(n)}
{ cout << "CTOR " << name << '\n'; }
~Foo() { cout << "DTOR " << name << '\n'; }
};
3. Далее реализуем функцию, которая принимает общий указатель на экземпляр типа
Foo
по значению. Передача общих указателей по значению в качестве аргументов более интересна, чем их передача по ссылке, поскольку в этом случае их нужно скопировать, что изменит их внутренний счетчик ссылок, как мы увидим далее.
void f(shared_ptr sp)
{
cout << "f: use counter at "
<< sp.use_count() << '\n';
}
4. В функции
main
мы объявим пустой общий указатель. Вызвав его конструктор по умолчанию, мы сделаем его указателем null
:
int main()
{
shared_ptr fa;
5. Далее откроем еще одну область видимости и создадим два объекта типа
Foo
. Первый из них создадим с помощью оператора new
и передадим его в конструктор типа shared_ptr
. Далее создадим второй экземпляр, используя make_shared
, что позволяет создать экземпляр типа Foo
на основе переданных нами параметров. Этот метод более элегантен, поскольку можно воспользоваться автоматическим выведением типов, и объект уже будет управляемым, когда у нас появится первая возможность получить к нему доступ. Это очень похоже на тот код, который мы писали в примере для unique_ptr
.
{
cout << "Inner scope begin\n";
shared_ptr f1 {new Foo{"foo"}};
auto f2 (make_shared("bar"));
6. Поскольку общие указатели могут быть разделяемыми, они должны отслеживать количество сторон, владеющих ими. Это делается с помощью внутреннего счетчика ссылок или счетчика использования. Можно вывести на экран его значение, задействуя
use_count
. Сейчас его значение равно 1
, поскольку мы еще не копировали его. Копирование f1
в fa
увеличит значение счетчика использования до 2
.
cout << "f1's use counter at " << f1.use_count() << '\n';
fa = f1;
cout << "f1's use counter at " << f1.use_count() << '\n';
7. Когда мы покинем область видимости, общие указатели
f1
и f2
будут уничтожены. Счетчик ссылок переменной f1
снова уменьшится до 1
, что сделает fa
единственным владельцем экземпляра типа Foo
. При разрушении f2
его счетчик ссылок будет уменьшен до 0
. В данном случае деструктор класса shared_ptr
выполнит операцию delete
для этого объекта, который удалит его.
}
cout << "Back to outer scope\n";
cout << fa.use_count() << '\n';
8. Теперь вызовем функцию
f
для нашего общего указателя двумя разными способами. Сначала вызовем ее путем копирования fa
. Функция f
затем выведет на экран значение счетчика ссылок, которое равно 2
. Во втором вызове f
переместим указатель в функцию. Это сделает f
единственным владельцем объекта.
cout << "first f() call\n";
f(fa);
cout << "second f() call\n";
f(move(fa));
9. После того как функция
f
отработает, экземпляр Foo
будет мгновенно уничтожен, поскольку мы им больше не владеем. Поэтому все объекты подвергнутся уничтожению, когда отработает функция main
.
cout << "end of main()\n";
}
10. Компиляция и запуск программы дадут следующий результат. Сначала мы увидим, что созданы
"foo"
и "bar"
. После копирования f1
(указывает на "foo"
) его счетчик ссылок увеличился до значения 2
. При выходе из области видимости "bar"
уничтожается, поскольку общий указатель был его единственным владельцем. Одна единица на экране — счетчик ссылок fa
, который является единственным владельцем "foo"
. После этого мы дважды вызываем функцию f
. При первом вызове скопировали ее в fa
, что снова увеличило его счетчик ссылок до 2
. При втором переместили его в f
, это не изменило его счетчик ссылок. Более того, поскольку функция f
к этому моменту является единственным владельцем "foo"
, объект мгновенно разрушается после того, как f
покидает область видимости. Таким образом, другие объекты кучи не разрушаются после последнего выражения print
в функции main
.
$ ./shared_ptr
Inner scope begin
CTOR foo
CTOR bar
f1's use counter at 1
f1's use counter at 2
DTOR bar
Back to outer scope
1
first f()
call
f: use counter at 2
second f() call
f: use counter at 1
DTOR foo
end of main()
Как это работает
При создании и удалении объектов
shared_ptr
работает аналогично unique_ptr
. Создание общих указателей выглядит так же, как и создание уникальных указателей (однако существует функция make_shared
, которая создает общие объекты в дополнение к функции make_unique
для уникальных указателей unique_ptr
).
Основное отличие от
unique_ptr
заключается в том, что можно копировать экземпляры shared_ptr
, поскольку общие указатели поддерживают так называемый блок управления вместе с объектом, которым они управляют. Блок управления содержит указатель на объект и счетчик ссылок или счетчик использования. Если на объект указывают N
экземпляров shared_ptr
, то счетчик использования имеет значение N
. Когда экземпляр типа shared_ptr
разрушается, его деструктор уменьшает значение этого внутреннего счетчика использования. Последний общий указатель на такой объект при разрушении снизит значение счетчика использования до 0
. В данном случае будет вызван оператор delete
для объекта! Таким образом, мы не можем допустить утечку памяти, поскольку счетчик ссылок объекта отслеживается автоматически.
Чтобы проиллюстрировать эту идею, взглянем на рис. 8.2.
В шаге 1 у нас имеются два экземпляра типа
shared_ptr
, управляющих объектом типа Foo
. Значение счетчика использования установлено на 2
. Далее shared_ptr2
уничтожается, это снижает значение счетчика использования до 1
. Экземпляр Foo
пока не уничтожается, поскольку все еще существует второй общий указатель. В шаге 3
последний общий указатель также уничтожается. Это приводит к тому, что значение счетчика использования становится равным 0
. Шаг 4 выполняется сразу после шага 3. Блок управления и экземпляр типа Foo
уничтожаются, и занятая ими память возвращается в кучу.
С помощью
shared_ptr
и unique_ptr
можно автоматически справиться с большинством объектов, память для которых выделяется динамически, не беспокоясь об утечках памяти. Следует, однако, рассмотреть один важный подводный камень. Представьте, что у нас имеются два объекта в куче, которые содержат общие указатели друг на друга, и какой-то другой общий указатель, указывающий на один из них откуда-то еще. Если этот внешний указатель выйдет за пределы области видимости, то счетчики использования обоих объектов все еще будут иметь ненулевые значения, поскольку ссылаются друг на друга. Это приводит к утечке памяти. В подобной ситуации общие указатели применять нельзя, поскольку цепочки таких циклических ссылок не дают снизить значение счетчика использования до 0
.
Дополнительная информация
Рассмотрим следующий код. Допустим, вам сказали, что он провоцирует потенциальную утечку памяти.
void function(shared_ptr, shared_ptr, int);
// "function" определена где-то еще
// ...далее по коду:
function(new A{}, new B{}, other_function());
Кто-то спросит: «Где же утечка?» — ведь объекты
A
и B
, для которых только что выделена память, мгновенно передаются в экземпляры типа shared_ptr
, и это позволяет обезопасить нас от утечек.
Да, это так, утечки памяти нам не грозят до тех пор, пока указатели хранятся в экземплярах типа
shared_ptr
. Эту проблему решить довольно сложно.
Когда мы вызываем функцию
f(x(),y(),z())
, компилятор должен собрать код, который сначала вызывает функции x()
, y()
и z()
, чтобы он мог перенаправить результат их работы в функцию f
. Нас не устраивает то, что компилятор может выполнить вызовы функций x
, y
и z
в любом порядке.
Взглянем на пример еще раз и подумаем: какие события произойдут, если компилятор решит структурировать код так, что сначала будет вызвана функция
new A{}
, затем — other_function()
, а затем — B{}
, прежде чем результаты работы этих функций будут переданы далее? Если функция other_function()
сгенерирует исключение, то мы получим утечку памяти, поскольку у нас в куче все еще будет неуправляемый объект A
, поскольку мы не имели возможности передать его под управление shared_ptr
. Независимо от того, как мы обработаем исключение, дескриптор объекта пропадет, и мы не сможем удалить его!
Существует два простых способа обойти эту проблему:
// 1.)
function(make_shared(), make_shared(), other_function());
// 2.)
shared_ptr ap {new A{}};
shared_ptr bp {new B{}};
function(ap, bp, other_function());
Таким образом, объекты уже попадут под управление
shared_ptr
независимо от того, где позднее будет сгенерировано исключение.
Из примера, посвященного
shared_ptr
, мы узнали, какими полезными и простыми в использовании являются общие указатели. Вместе с unique_pt
r они предоставляют бесценную возможность по улучшению нашего кода, нуждающегося в управлении объектами, память для которых выделяется динамически.
Копируя
shared_ptr
, мы увеличиваем его внутренний счетчик ссылок. До тех пор, пока мы храним нашу копию общего указателя, объект, на который он указывает, не будет удален. Но если нужно что-то вроде слабого указателя, который позволит получать объект до тех пор, пока тот существует, но не мешает его удалению? Как мы определим, существует ли еще объект?
В таких ситуациях нам поможет
weak_ptr
. Использовать его чуть сложнее, чем unique_ptr
и shared_ptr
, но после прочтения этого раздела вы научитесь применять его.
Как это делается
В этом примере мы реализуем программу, которая поддерживает объекты, используя экземпляры типа
shared_ptr
, а затем добавим weak_ptr
, чтобы увидеть, как это меняет поведение при управлении памятью с помощью умного указателя.
1. Сначала включим необходимые заголовочные файлы и объявим об использовании пространства имен
std
по умолчанию:
#include
#include
#include
using namespace std;
2. Затем реализуем класс, чей деструктор выводит на экран сообщение. Таким образом, нам будет проще проверить факт уничтожения объекта.
struct Foo {
int value;
Foo(int i) : value{i} {}
~Foo() { cout << "DTOR Foo " << value << '\n'; }
};
3. Также реализуем функцию, которая выводит на экран информацию о слабом указателе, что позволит узнавать о его состоянии в разные моменты выполнения программы. Функция
expired
класса weak_ptr
скажет о том, существует ли еще объект, на который он указывает, поскольку хранение слабого указателя на объект не продлевает его время жизни! Счетчик use_count
сообщает, сколько экземпляров типа shared_ptr
в данный момент указывают на наш объект:
void weak_ptr_info(const weak_ptr &p)
{
cout << "---------" << boolalpha
<< "\nexpired: " << p.expired()
<< "\nuse_count: " << p.use_count()
<< "\ncontent: ";
4. При желании получить доступ к самому объекту нужно вызвать функцию
lock
. Она возвращает общий указатель на объект. Если объект больше не существует, то полученный общий указатель, по сути, является null
. Следует это проверять, прежде чем получать доступ к объекту.
if (const auto sp (p.lock()); sp) {
cout << sp->value << '\n';
} else {
cout << "\n";
}
}
5. Создадим пустой слабый указатель в функции
main
и выведем на экран его содержимое, оно, конечно, поначалу будет пустым:
int main()
{
weak_ptr weak_foo;
weak_ptr_info(weak_foo);
6. В новой области видимости создадим новый общий указатель, содержащий только что созданный экземпляр класса
Foo
. Затем скопируем его в слабый указатель. Обратите внимание: это не увеличит счетчик ссылок общего указателя. Счетчик ссылок будет иметь значение 1
, поскольку им владеет только один общий указатель.
{
auto shared_foo (make_shared(1337));
weak_foo = shared_foo;
7. Вызовем функцию слабого указателя прежде, чем покинем область видимости, и снова после этого. Экземпляр типа
Foo
должен быть мгновенно уничтожен, несмотря на то что на него указывает слабый указатель.
weak_ptr_info(weak_foo);
}
weak_ptr_info(weak_foo);
}
8. Компиляция и запуск программы дадут три результата работы функции
weak_ptr_info
. В первом вызове слабый указатель пуст. Во втором он уже указывает на созданный нами экземпляр типа Foo
и может разыменовать его после блокировки. Перед третьим вызовом мы покидаем внутреннюю область видимости, что заставляет сработать деструктор экземпляра типа Foo
в соответствии с нашими ожиданиями. После этого мы не можем получить содержимое экземпляра типа Foo
с помощью слабого указателя, а сам слабый указатель корректно распознает, что срок его действия истек.
$ ./weak_ptr
---------
expired: true
use_count: 0
content:
---------
use_count: 1
expired: false
content: 1337
DTOR Foo 1337
---------
use_count: 0
content:
expired: true
Как это работает
Слабые указатели предоставляют способ указать на объект, поддерживаемый общими указателями, не увеличивая его счетчик использования. Да, необработанный указатель способен сделать то же самое, но не может сказать, является ли он висящим. Слабый указатель лишен этого недостатка!
Чтобы понять, как слабые указатели работают с общими, сразу рассмотрим рис. 8.3.
Принцип работы аналогичен тому, что приведен на рис. 8.2. В шаге 1 у нас имеются два общих указателя и слабый, указывающие на объект типа
Foo
. Несмотря на то что на него указывают три объекта, его счетчик использования изменяют только общие указатели, именно поэтому его значение равно 2
. Слабый указатель изменяет только слабый счетчик блока управления. В шагах 2 и 3 экземпляры общих указателей уничтожаются, это снижает значение счетчика использования до 0
. В шаге 4 это приводит к тому, что объект Foo
удаляется, но блок управления остается. Слабому указателю все еще нужен блок управления, чтобы определить, является ли он висящим. Блок управления удаляется только в тот момент, когда последний слабый указатель, указывающий на него, тоже выходит из области видимости.
Мы также можем сказать, что срок действия висящего слабого указателя истек. Для проверки этого состояния можно опросить метод
expired
класса weak_ptr
, он вернет булево значение. Если оно равно true
, то мы не можем разыменовать слабый указатель, поскольку он не указывает на объект.
Чтобы разыменовать слабый указатель, нужно вызвать метод
lock()
. Это удобно и безопасно, поскольку данная функция возвращает общий указатель. Пока мы его храним, объект, на который он указывает, не может пропасть, поскольку мы увеличили его счетчик использования путем блокировки. Если объект удаляется вскоре после вызова lock()
, то общий указатель, по сути, является null
.
Умные указатели (
unique_ptr
, shared_ptr
и weak_ptr
) очень полезны, и можно сказать, что программист должен всегда использовать их вместо выделения и освобождения памяти вручную.
Но если для объекта нельзя выделить память с помощью оператора
new
и/или освободить память, задействуя оператор delete
? Во многих устаревших библиотеках есть собственные функции выделения/удаления памяти. Кажется, это может стать проблемой, поскольку мы узнали, что умные указатели полагаются на операторы new
и delete
. Если создание и/или разрушение конкретных типов объектов полагается на конкретные интерфейсы удаления фабричных функций, не помешает ли это получить огромное преимущество от использования умных указателей?
Вовсе нет. В этом разделе мы увидим, что нужно лишь немного изменить умные указатели, чтобы позволить им следовать определенным процедурам выделения и удаления памяти для конкретных объектов.
Как это делается
В данном примере мы определим тип, для которого нельзя непосредственно выделить память с помощью оператора new и нельзя освободить ее, прибегнув к оператору
delete
. Поскольку это помешает использовать его вместе с умными указателями, мы внесем небольшие изменения в экземпляры классов unique_ptr
и smart_ptr
.
1. Как и всегда, сначала включим необходимые заголовочные файлы и объявим об использовании пространства имен
std
по умолчанию:
#include
#include
#include
using namespace std;
2. Далее объявим класс, конструктор и деструктор которого имеют модификатор
private
. Таким образом, симулируем проблему, когда нужно получать доступ к конкретным функциям, чтобы создавать и удалять экземпляры этого класса.
class Foo
{
string name;
Foo(string n)
: name{n}
{ cout << "CTOR " << name << '\n'; }
~Foo() { cout << "DTOR " << name << '\n';}
3. Статические методы
create_foo
и destroy_foo
будут создавать и удалять экземпляры типа Fo
o. Они работают с необработанными указателями. Это симулирует ситуацию, возникающую при использовании устаревшего API языка С, который не дает задействовать их непосредственно для обычных указателей shared_p
tr:
public:
static Foo* create_foo(string s) {
return new Foo{move(s)};
}
static void destroy_foo(Foo *p) { delete p; }
};
4. Теперь сделаем так, чтобы подобными объектами можно было управлять с помощью
shared_ptr
. Конечно, можно помещать указатели, которые получаем из функции create_foo
, в конструктор общего указателя. Разрушение объекта выглядит сложнее, поскольку функция удаления класса shared_ptr
, использующаяся по умолчанию, решит проблему неправильно. Идея заключается в том, что можно задать для класса shared_ptr
пользовательскую функцию удаления. Сигнатура функции, которую следует иметь функции удаления или вызываемому объекту, должна совпадать с сигнатурой функции destroy_foo
. Если функция, которую нужно вызвать для разрушения объекта, более сложна, то можно обернуть ее в лямбда-выражение.
static shared_ptr make_shared_foo(string s)
{
return {Foo::create_foo(move(s)), Foo::destroy_foo};
}
5. Обратите внимание:
make_shared_foo
возвращает обычный экземпляр shared_ptr
, поскольку передача пользовательской функции удаления не изменяет ее тип. Это произошло потому, что shared_ptr
применяет вызовы виртуальных функций для сокрытия таких деталей. Уникальные указатели не создают наличных издержек; это не дает задействовать для них подобный прием. Нужно изменить тип unique_ptr
. В качестве второго шаблонного параметра мы передадим экземпляр типа void (*)(Foo*)
, данный тип имеет и указатель на функцию destroy_foo
:
static unique_ptr make_unique_foo(string s)
{
return {Foo::create_foo(move(s)), Foo::destroy_foo};
}
6. В функции
main
просто создаем экземпляры общего и уникального указателей. В выходных данных программы увидим, будут ли они уничтожаться корректно и автоматически.
int main()
{
auto ps (make_shared_foo("shared Foo instance"));
auto pu (make_unique_foo("unique Foo instance"));
}
7. Компиляция и запуск программы дадут ожидаемый результат:
$ ./legacy_shared_ptr
CTOR shared Foo instance
CTOR unique Foo instance
DTOR unique Foo instance
DTOR shared Foo instance
Как это работает
Обычно
unique_ptr
и shared_ptr
просто вызывают оператор delete
для внутренних указателей, когда должны уничтожить объект, который сопровождают. В этом разделе мы создали класс, для которого нельзя выделить память, используя x = new Foo{123}
, и разрушить объект непосредственно с помощью delete x
.
Функция
Foo::create_foo
просто возвращает необработанный указатель на только что созданный экземпляр типа Foo
, и это не вызывает других проблем, поскольку умные указатели работают с необработанными.
Сложность заключается в том, что нужно научить классы
unique_ptr
и shared_ptr
разрушать объект, если способ по умолчанию не подходит.
С этой точки зрения оба типа умных указателей несколько отличаются друг от друга. Чтобы определить пользовательскую функцию удаления для
unique_ptr
, нужно изменить его тип. Поскольку тип сигнатуры delete
класса Foo
— void Foo::destroy_foo(Foo*);
, типом уникального указателя, сопровождающего экземпляр типа Foo
, должен быть unique_ptr
. Теперь он может хранить указатель на функцию destroy_foo
, которую мы предоставляем в качестве второго параметра конструктора в нашей функции make_unique_foo
.
Если передача пользовательской функции удаления для класса
unique_ptr
заставила нас сменить его тип, то почему же мы смогли сделать то же самое для shared_ptr
, не изменяя его тип? Единственное, что нам пришлось сделать, — передать второй параметр для конструктора shared_ptr
. Почему это не может быть так же просто и для типа unique_ptr
?
Почему так просто передать экземпляру класса
shared_ptr
некоторый вызываемый объект delete
, не изменяя типа общего указателя? Причина кроется в природе общих указателей, поддерживающих блок управления. Блок управления общих указателей — объект, имеющий виртуальные функции. Это значит, что блок управления обычного общего указателя и блок управления общего указателя с пользовательским delete
различаются! Чтобы с помощью уникального указателя применить пользовательскую функцию удаления, нужно изменить тип этого указателя. Если мы хотим, чтобы общий указатель задействовал пользовательскую функцию удаления, то это также изменит тип внутреннего блока управления, невидимого для нас, поскольку данная разница скрыта за интерфейсом виртуальной функции.
Описанный прием можно применить и для уникальных указателей, но в таком случае он повлечет некоторые издержки во время выполнения программы. Это не то, что мы хотим, поскольку уникальные указатели не должны создавать лишних издержек.
Представим, что у нас есть общий указатель на некий сложный объект, память для которого выделяется динамически. Нужно создать новый поток, выполняющий какую-то продолжительную работу для одного из членов этого сложного объекта. Если мы хотим освободить этот общий указатель сейчас, то объект будет удален, хотя другие потоки все еще могут пытаться получить к нему доступ. Если же мы не хотим давать объекту потока указатель на весь объект, поскольку данное действие пересечется с нашим «аккуратным» интерфейсом или по каким-то другим причинам, то значит ли это, что придется управлять памятью вручную?
Нет. Вы можете использовать общие указатели, которые, с одной стороны, ссылаются на член крупного общего объекта, а с другой — выполняют автоматическое управление памятью для всего исходного объекта.
В данном разделе мы создадим подобный сценарий (без потоков, чтобы не усложнять задачу) с целью ознакомиться с этой удобной функцией типа
shared_ptr
.
Как это делается
В этом примере мы определим структуру, которая состоит из нескольких членов. Далее выделим память для экземпляра структуры в куче, ее будет сопровождать общий указатель. Из него мы получим больше общих указателей, указывающих не на сам объект, а на его члены.
1. Сначала включим необходимые заголовочные файлы, а затем объявим об использовании пространства имен
std
по умолчанию:
#include
#include
#include
using namespace std;
2. Далее определим класс, который имеет разные члены. Позволим общим указателям указывать на отдельные члены. Чтобы увидеть, когда класс создается и уничтожается, будет выводить сообщения на экран в конструкторе и деструкторе.
struct person {
string name;
size_t age;
person(string n, size_t a)
: name{move(n)}, age{a}
{ cout << "CTOR " << name << '\n'; }
~person() { cout << "DTOR " << name << '\n'; }
};
3. Определим общие указатели, которые имеют корректные типы, чтобы указывать на переменные-члены
name
и age
экземпляра класса person
:
int main()
{
shared_ptr shared_name;
shared_ptr shared_age;
4. Далее войдем в новую область видимости, создадим объект типа
person
и позволим общему указателю управлять им:
{
auto sperson (make_shared("John Doe", 30));
5. Затем позволим первым двум общим указателям указывать на его члены
name
и age
. Прием, который мы задействуем, заключается в использовании конкретного конструктора типа shared_ptr
, который принимает общий указатель и указатель на член общего объекта. Таким образом можно управлять объектом, не указывая на него самого!
shared_name = shared_ptr(sperson, &sperson->name);
shared_age = shared_ptr(sperson, &sperson->age);
}
6. После выхода из области видимости выведем на экран значения переменных
name
и age
. Это возможно только в том случае, если память для объекта все еще выделена.
cout << "name: " << *shared_name
<< "\nage: " << *shared_age << '\n';
}
7. Компиляция и запуск программы дадут следующий результат. Из сообщения деструктора мы видим, что объект все еще жив и память для него выделена, когда мы получаем доступ к значениям переменных
name
и age
с помощью указателей на члены!
$ ./shared_members
CTOR John Doe
name: John Doe
age: 30
DTOR John Doe
Как это работает
В этом разделе мы сначала создали общий указатель, управляющий объектом person, память для которого выделяется динамически. Затем создали два других умных указателя, указывающих на объект типа
person
, но не на сам объект, а на его члены name
и age
.
Чтобы подытожить созданный сценарий, взглянем на рис. 8.4.
Обратите внимание:
shared_ptr1
указывает на сам объект person
, а shared_name
и shared_age
— на члены name
и age
того же объекта. По всей видимости, они будут управлять всем жизненным циклом объекта. Это возможно потому, что указатели внутреннего блока управления все еще ссылаются на тот же блок управления, независимо от того, на какой подобъект указывают отдельные общие указатели.
В этом сценарии счетчик использования блока управления равен
3
. Таким образом, объект типа person не будет удален при уничтожении shared_ptr1
, поскольку другие общие указатели все еще владеют объектом.
При создании подобных экземпляров общих указателей, указывающих на члены общего объекта, синтаксис выглядит несколько странно. Чтобы получить экземпляр типа
shared_ptr
, который указывает на член name
общего экземпляра типа person
, нужно написать следующий код:
auto sperson (make_shared("John Doe", 30));
auto sname (shared_ptr(sperson, &sperson->name));
Чтобы получить указатель на конкретный член общего объекта, мы создаем экземпляр общего указателя, чей тип специализирован для того члена, к которому нужно получить доступ. Именно поэтому используем конструкцию
shared_ptr<string>
. Затем в конструкторе сначала предоставляем оригинальный общий указатель, сопровождающий объект типа person
, а в качестве второго аргумента — адрес объекта, которым будет пользоваться новый общий указатель при разыменовании.
Чтобы получить случайные числа, программисты С++ до появления С++11 обычно просто использовали функцию
rand()
из библиотеки C. Начиная с C++11, в нашем распоряжении целый арсенал генераторов случайных чисел, они служат для разных целей и имеют различные характеристики.
Эти генераторы не говорят сами за себя, так что в данном разделе рассмотрим их все. Мы увидим, чем они отличаются, научимся выбирать правильный и узнаем, что, скорее всего, никогда не будем ими пользоваться.
Как это делается
В этом примере мы реализуем процедуру, которая выводит на экран гистограмму, содержащую числа, создаваемые генератором случайных чисел. Затем запустим все генераторы случайных чисел, доступные в STL, и воспользуемся нашей процедурой, чтобы исследовать результаты. Данная программа содержит множество повторяющихся фрагментов, поэтому может быть полезным просто скопировать исходный код из репозитория, дополняющего эту книгу, а не писать повторяющийся код вручную.
1. Сначала включим все заголовочные файлы, а затем объявим об использовании пространства имен
std
по умолчанию:
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
2. Затем реализуем вспомогательную функцию, которая позволит сопровождать и выводить на экран статистику для каждого типа генераторов случайных чисел. Она принимает два параметра — количество сегментов и количество образцов. Мы сразу увидим, для чего они нужны. Тип генератора случайных чисел определяется с помощью шаблонного параметра
RD
. Первое, что мы сделаем в этой функции, — определим псевдоним типа для полученного численного типа чисел, возвращаемых генератором. Кроме того, убедимся, что у нас есть как минимум десять сегментов:
template
void histogram(size_t partitions, size_t samples)
{
using rand_t = typename RD::result_type;
partitions = max(partitions, 10);
3. Далее создадим экземпляр генератора типа
RD
. Затем определим переменную-делитель с именем div
. Все генераторы случайных чисел создают случайные числа в диапазоне от 0
до RD::max()
. Аргумент функции partitions
позволяет вызывающей стороне выбирать, на сколько сегментов мы разделим каждый диапазон случайных чисел. Разделив наибольшее возможное значение на количество сегментов, мы узнаем, насколько большим является каждый из них:
RD rd;
rand_t div ((double(RD::max()) + 1) / partitions);
4. Создадим вектор переменных-счетчиков. Он будет иметь размер, равный количеству сегментов. Затем получим случайные значения от генератора в количестве, равном значению переменной
samples
. Выражение rd()
получает случайное число от генератора и изменяет его внутреннее состояние так, чтобы подготовить его к выдаче следующего случайного числа. Разделив каждое случайное число на div
, мы получим номер сегмента, в который оно попадает, и можем увеличить соответствующий счетчик в векторе:
vector v (partitions);
for (size_t i {0}; i < samples; ++i) {
++v[rd() / div];
}
5. Теперь у нас есть «аккуратная» гистограмма, содержащая значения-примеры. Чтобы вывести ее на экран, нужно получить более подробную информацию о ее реальных значениях счетчика. Извлечем самое большое значение с помощью алгоритма
max_element
. Затем разделим это значение на 100
. Таким образом можно разделить все значения счетчика на max_div
и вывести множество звездочек на консоль, не выходя за значение ширины, равное 100
. Если самое крупное значение меньше 100
(это может произойти в случае применения небольшого количества образцов), то воспользуемся max для получения минимального делителя 1
:
rand_t max_elm (*max_element(begin(v), end(v)));
rand_t max_div (max(max_elm / 100, rand_t(1)));
6. Теперь выведем гистограмму на консоль. Каждый сегмент получает собственную строку на консоли. Разделив его значение счетчика на
max_div
и выведя соответствующее количество символов '*'
, получаем строки гистограммы, которые помещаются в окно консоли:
for (size_t i {0}; i < partitions; ++i) {
cout << setw(2) << i << ": "
<< string(v[i] / max_div, '*') << '\n';
}
}
7. О’кей, на этом все. Теперь перейдем к основной программе. Позволим пользователю определить, сколько сегментов и образцов следует применить:
int main(int argc, char **argv)
{
if (argc != 3) {
cout << "Usage: " << argv[0]
<< " \n";
return 1;
}
8. Затем считаем эти переменные из командной строки. Конечно, она содержит строки, которые можно преобразовать в числа с помощью функции
std::stoull
(stoull
— это аббревиатура для string to unsigned long long, строки к беззнаковым значениям типа long long
):
size_t partitions {stoull(argv[1])};
size_t samples {stoull(argv[2])};
9. Теперь вызовем нашу вспомогательную функцию, создающую гистограммы, для каждого генератора случайных чисел, предоставляемого STL. Это сделает наш пример длинным и повторяющим код. Лучше скопируйте пример из Интернета. Интересно взглянуть на результат работы данной программы. Начнем с random_device. Это устройство пытается распределить случайность поровну между всеми возможными значениями:
cout << "random_device" << '\n';
histogram(partitions, samples);
10. Следующий генератор случайных чисел — это
default_random_engine
. Тип генератора, на который ссылается данный тип, зависит от конкретной реализации. Он может оказаться одним из следующих генераторов:
cout << "ndefault_random_engine" << '\n';
histogram(partitions, samples);
11. Затем опробуем ее для всех других генераторов:
cout << "nminstd_rand0" << '\n';
histogram(partitions, samples);
cout << "nminstd_rand" << '\n';
histogram(partitions, samples);
cout << "nmt19937" << '\n';
histogram(partitions, samples);
cout << "nmt19937_64" << '\n';
histogram(partitions, samples);
cout << "nranlux24_base" << '\n';
histogram(partitions, samples);
cout << "nranlux48_base" << '\n';
histogram(partitions, samples);
cout << "nranlux24" << '\n';
histogram(partitions, samples);
cout << "nranlux48" << '\n';
histogram(partitions, samples);
cout << "nknuth_b" << '\n';
histogram(partitions, samples);
}
12. Компиляция и запуск программы дадут интересные результаты. Мы увидим длинный список, содержащий выходные данные, и узнаем, что все генераторы случайных чисел имеют разные характеристики. Сначала запустим программу, в которой количество сегментов равно
10
, а образцов всего 1000
(рис. 8.5).
13. Затем запустим эту же программу снова. В этот раз количество сегментов все еще будет равно
10
, но образцов уже 1,000,000
. Станет очевидно, что гистограммы выглядят гораздо лучше, когда мы берем для них больше образцов (рис. 8.6). Это важное наблюдение.
Как это работает
Как правило, перед использованием генератора случайных чисел нужно создать объект этого класса. Полученный объект может быть вызван как функция без параметров, поскольку он перегружает
operator()
. Тогда каждый вызов будет приводить к получению нового случайного числа. Все очень просто.
В этом разделе мы написали довольно сложную программу, чтобы чуть больше узнать о генераторах случайных чисел. Пожалуйста, поработайте с получившейся программой, запуская ее с разными аргументами командной строки, и проверьте следующее:
□ чем больше образцов мы возьмем, тем больше будут равны наши счетчики разделов;
□ неравенство счетчиков разделов значительно отличается между отдельными генераторами;
□ для большого числа образцов становится понятно, что производительность отдельных генераторов различается;
□ запустите программу с небольшим количеством образцов несколько раз. Шаблоны распределения будут выглядеть одинаково: генераторы постоянно создают одни и те же последовательности; это говорит о том, что они совсем не случайны. Такие генераторы называются детерминированными, поскольку можно предсказать получаемые значения. Единственным исключением является
std::random_device
.
Как видите, необходимо рассмотреть несколько характеристик. Для большинства стандартных приложений достаточно применить
std::default_random_engine
. Эксперты в области криптографии или в других областях, чувствительных к безопасности, будут тщательно выбирать один из доступных генераторов, но для нас, типичных программистов, это не так важно.
Из данного примера следует сделать три вывода.
1. Как правило,
std::default_random_engine
— это хороший вариант по умолчанию для типичного приложения.
2. Если действительно нужно получить недетерминированные случайные числа, то поможет
std::random_device
.
3. Можно передать конструктору любого генератора случайных чисел реальное случайное число, полученное от
std::random_device
(или, например, текущее время на системных часах), чтобы заставить его создавать разные случайные числа при каждом обращении. Это называется посевом.
Обратите внимание:
std::random_device
может откатиться к одному из детерминированных генераторов, если библиотека не поддерживает недетерминированные генераторы.
Из предыдущего примера мы узнали о генераторах случайных чисел, предоставляемых STL. Генерация случайных чисел тем или иным способом — зачастую лишь половина работы.
Возникает еще один вопрос: для чего нужны эти числа? Мы просто программно «подбрасываем монетку»? Обычно это делается с помощью конструкции
rand()%2
, что дает результаты 0
и 1
, которые можно сопоставить с орлом и решкой. Справедливо; для этого не нужна библиотека (однако эксперты в области случайных чисел знают, что использование лишь нескольких младших битов случайного числа не позволяет получить хорошую подборку случайных чисел).
Что, если мы хотим смоделировать бросок кубика? Конечно, можно написать код
(rand()%6)+1
с целью представить результат броска. Для выполнения таких простых задач не нужно использовать библиотеку.
А если мы хотим смоделировать событие, которое случается с вероятностью 66%? Окей, можно создать формулу наподобие
bool yesno = (rand()%100>66)
. (Погодите, нам следует использовать оператор >=
или правильнее будет оставить оператор >?
)
Кроме того, как смоделировать бросок нечестного кубика, грани которого могут выпасть с разной вероятностью? Как смоделировать более сложные распределения? Такие задачи могут быстро перерасти в научные. Чтобы сконцентрироваться на наших основных задачах, взглянем на инструменты, предоставляемые STL.
Библиотека содержит более дюжины алгоритмов распределения, которые могут формировать случайные числа для определенных потребностей. В этом примере мы очень кратко рассмотрим их все, а также более детально взглянем на самые полезные.
Как это делается
В этом примере мы будем генерировать случайные числа, придавать им форму и выводить на экран шаблоны распределения. Таким образом, рассмотрим их все и разберем их самые важные свойства, которые могут оказаться полезными, если потребуется смоделировать что-то конкретное.
1. Сначала включим все необходимые заголовочные файлы и объявим об использовании пространства имен
std
:
#include
#include
#include
#include
#include
#include
using namespace std;
2. Для каждого распределения, предоставляемого STL, выведем гистограмму, чтобы увидеть его характеристики, поскольку каждое из них выглядит особенным образом. Гистограмма принимает в качестве аргумента распределение и количество образцов, которые будут взяты из него. Затем создадим генератор случайных чисел по умолчанию и ассоциативный массив. В последнем будут соотнесены значения, полученные из распределения, со счетчиками, показывающими, как часто встречается то или иное значение. Мы всегда создаем экземпляр генератора случайных чисел, потому что все распределения используются только в качестве функции для формирования случайных чисел, которые все еще должны быть сгенерированы.
template
void print_distro(T distro, size_t samples)
{
default_random_engine e;
map m;
3. Возьмем столько образцов, сколько указано в переменной
samples
, и заполним ими ассоциативный массив счетчиков. Таким образом получим очередную гистограмму. Простой вызов e()
даст необработанное простое число, distro(e)
придает случайным числам форму с помощью объекта распределения:
for (size_t i {0}; i < samples; ++i) {
m[distro(e)] += 1;
}
4. Чтобы получить выходные данные, которые помещаются в окно консоли, нужно узнать самое большое значение счетчика. Функция
max_element
поможет определить такое значение путем сравнения всех связанных счетчиков в массиве и возвращения итератора, указывающего на узел, содержащий данное значение. Зная это значение, можем определить, на какое число следует разделить все значения счетчиков, чтобы уместить полученный результат в окно консоли.
size_t max_elm (max_element(begin(m), end(m),
[](const auto &a, const auto &b) {
return a.second < b.second;
})->second);
size_t max_div (max(max_elm / 100, size_t(1)));
5. Теперь пройдем по массиву в цикле и выведем полоски из символов
'*'
для всех счетчиков большого размера. Остальные значения отбросим, поскольку некоторые генераторы случайных чисел распределяют числа так широко, что это переполнит наши окна консоли.
for (const auto [randval, count] : m) {
if (count < max_elm / 200) { continue; }
cout << setw(3) << randval << " : "
<< string(count / max_div, '*') << '\n';
}
}
6. В функции
main
проверим, предоставил ли пользователь ровно один параметр, который указывает, сколько именно образцов нужно взять из каждого распределения. Если пользователь передал ноль или несколько параметров, то сгенерируем ошибку:
int main(int argc, char **argv)
{
if (argc != 2) {
cout << "Usage: " << argv[0]
<< " \n"; return 1;
}
7. Теперь преобразуем аргумент командной строки в число с помощью вызова
std::stoull
:
size_t samples {stoull(argv[1])};
8. Сначала попробуем распределения
uniform_int_distribution
и normal_distribution
. Они используются в большинстве случаев, когда нужно применить генератор случайных чисел. Все, кто когда-то изучал стохастику в университете, скорее всего, слышали о них. Равномерное распределение принимает два значения, указывая нижнюю и верхнюю границы диапазона, в котором будут распределены случайные значения. Выбрав 0
и 9
, мы получим одинаково часто встречающиеся значения между 0
и 9
(включительно). Нормальное распределение принимает в качестве аргументов математическое ожидание и среднеквадратическое отклонение.
cout << "uniform_int_distribution\n";
print_distro(uniform_int_distribution{0, 9}, samples);
cout << "normal_distribution\n";
print_distro(normal_distribution{0.0, 2.0}, samples);
9. Еще одним очень интересным распределением является
piecewise_constant_distribution
. Оно принимает в качестве аргументов два входных диапазона. Первый диапазон содержит числа, которые указывают границы интервалов. Определив их как 0
, 5
, 10
, 30
, получим три интервала, простирающиеся от 0
до 4
, от 5
до 9
и от 10
до 29
. Еще один входной диапазон определяет веса входных диапазонов. Установив значения этих весов равными 0.2
, 0.3
, 0.5
, мы укажем, что из соответствующих интервалов случайные числа будут получены с вероятностями 20, 30 и 50%. Внутри каждого из интервалов значения будут иметь одинаковую вероятность выпадения.
initializer_list intervals {0, 5, 10, 30};
initializer_list weights {0.2, 0.3, 0.5};
cout << "piecewise_constant_distribution\n";
print_distro(
piecewise_constant_distribution{
begin(intervals), end(intervals),
begin(weights)},
samples);
10. Распределение
piecewise_linear_distribution
создается аналогично, но веса работают совершенно по-другому. Для каждой граничной точки интервала существует одно значение веса. При переходе от одной границы к другой вероятность интерполируется линейно. Воспользуемся теми же интервалами, но передадим другой список весов:
cout << "piecewise_linear_distribution\n";
initializer_list weights2 {0, 1, 1, 0};
print_distro(
piecewise_linear_distribution{
begin(intervals), end(intervals), begin(weights2)},
samples);
11. Распределение Бернулли — это еще одно важное распределение, поскольку распределяет лишь значения «да/нет», «попадание/промах» или «орел/решка» с конкретной вероятностью. Его выходными значениями будут только
0
и 1
. Еще одним интересным распределением, полезным во многих случаях, является discrete_distribution
. В нашем случае инициализируем его дискретными значениями 1
, 2
, 4
, 8
. Они интерпретируются как веса для возможных выходных значений от 0
до 3
.
cout << "bernoulli_distribution\n";
print_distro(std::bernoulli_distribution{0.75}, samples);
cout << "discrete_distribution\n";
print_distro(discrete_distribution{{1, 2, 4, 8}}, samples);
12. Существует множество других генераторов распределений. Они полезны только в очень специфических ситуациях. Если вы никогда о них не слышали, то они, возможно, вам и не нужны. Однако, поскольку наша программа создает «аккуратные» гистограммы, показывающие распределение, из интереса выведем их все:
cout << "binomial_distribution\n";
print_distro(binomial_distribution{10, 0.3}, samples);
cout << "negative_binomial_distribution\n";
print_distro(
negative_binomial_distribution{10, 0.8}, samples);
cout << "geometric_distribution\n";
print_distro(geometric_distribution{0.4}, samples);
cout << "exponential_distribution\n";
print_distro(exponential_distribution{0.4}, samples);
cout << "gamma_distribution\n";
print_distro(gamma_distribution{1.5, 1.0}, samples);
cout << "weibull_distribution\n";
print_distro(weibull_distribution{1.5, 1.0}, samples);
cout << "extreme_value_distribution\n";
print_distro(
extreme_value_distribution{0.0, 1.0}, samples);
cout << "lognormal_distribution\n";
print_distro(lognormal_distribution{0.5, 0.5}, samples);
cout << "chi_squared_distribution\n";
print_distro(chi_squared_distribution{1.0}, samples);
cout << "cauchy_distribution\n";
print_distro(cauchy_distribution{0.0, 0.1}, samples);
cout << "fisher_f_distribution\n";
print_distro(fisher_f_distribution{1.0, 1.0}, samples);
cout << "student_t_distribution\n";
print_distro(student_t_distribution{1.0}, samples);
}
13. Компиляция и запуск программы дадут следующий результат. Сначала запустим программу с 1000 образцами для каждого распределения (рис. 8.7).
14. Еще один запуск, на этот раз с 1 000 000 образцов для каждого распределения, покажет, что гистограммы выглядят гораздо чище и более характерно для каждого из них. Кроме того, мы увидим, какие распределения генерируются медленно, а какие — быстро (рис. 8.8).
Как это работает
Хотя генераторы случайных чисел нас не интересуют до тех пор, пока работают быстро и создают числа максимально случайным образом, нам следует тщательно выбирать распределение в зависимости от решаемой задачи.
Чтобы использовать любое распределение, сначала нужно создать для него соответствующий объект. Мы видели, что разные распределения принимают разные аргументы конструктора. В описании примера мы кратко остановились на некоторых видах распределения, поскольку большинство из них слишком специфичны и/или сложны, чтобы рассматривать их здесь. Не волнуйтесь, все они подробно описаны в документации к C++ STL.
Однако, как только появляется экземпляр распределения, можно вызвать его как функцию, которая принимает в качестве единственного параметра объект генератора случайных чисел. Далее объект распределения получает случайное число, придает ему некую форму (которая полностью зависит от выбранного распределения), а затем возвращает его нам. Это приводит к появлению совершенно разных гистограмм, что мы видели после запуска программы.
Программа, которую мы только что написали, позволит нам получить наиболее полную информацию о разных распределениях. В дополнение к этому рассмотрим самые важные виды распределения (табл. 8.2). Для всех остальных видов распределения вы можете обратиться к документации C++ STL.