- •Ооп: Лекция 7. Стандартная библиотека шаблонов ч.1
- •Адаптеры stl-контейнеров
- •Категории итераторов
- •Итераторы stl-контейнеров
- •Реверс-итератор можно преобразовать в обычный итератор через метод base().
- •Примеры алгоритмов и итераторов
- •Недействительные итераторы
- •При удалении все элементы, стоящие правее, сдвигаются на одну ячейку влево. При этом, итератор, по-прежнему указывает на ту же самую позицию:
- •Затем, итератор инкрементируется, указывая уже на следующую позицию:
- •При работе с другими контейнерами также следует соблюдать осторожность, и задумываться о ситуациях, при которых могут появляться недействительные итераторы:
- •Алгоритмы стандартной библиотеки
- •Пример использования изученных элементов stl
- •Полные примеры из лекции
Ооп: Лекция 7. Стандартная библиотека шаблонов ч.1
Версия 3.01 22 августа 2016г.
(С) 2013-2016, Зайченко Сергей Александрович, к.т.н, ХНУРЭ, доцент кафедры АПВТ
std::vector
В ходе изучения обеспечивающей дисциплины «Структуры и алгоритмы обработки данных» было рассмотрено понятие динамически растущего массива (вектора). Вектор при необходимости автоматически удваивает размер управляемого им массива элементов, переносит ранее накопленные данные в новые блоки памяти и освобождает старые. При этом память расходуется рационально, поскольку все элементы хранятся цельным непрерывным (contiguous) блоком памяти. Исходя из структуры, вектор очевидно эффективен в операциях произвольного доступа, а также вставки и удаления элементов с конца. В то же время, операции вставки и удаления в произвольной позиции, в частности, в начале вектора, приводят к линейному сдвигу элементов влево или вправо, что отрицательно сказывается на производительности структуры.
Основное достоинство вектора - простота. Реализовать такую структуру данных можно всего лишь при помощи 3 указателей:
адрес начала блока данных (pStart);
адрес элемента, следующего за последним заполненным (pFinish);
адрес ячейки памяти, следующей за границей выделения текущего блока (pEndOfStorage):
Расстояние между указателями pFinish и pStart представляет собой текущий размер (size) вектора, т.е. количество хранимых элементов. Расстояние между указателями pEndOfStorage и pStart называется емкостью (capacity) вектора.
В стандартной библиотеке языка С++ имеется готовая отлаженная реализация этой структуры, представленная шаблоном класса std::vector. Реализация интенсивно использует подробно рассмотренный в предыдущей лекции механизм шаблонов, благодаря которому функциональность вектора можно применить к практически любому желаемому типу хранимых элементов. В качестве элементов могут выступать как встроенные типы, так и собственные классы программистов, при условии, что объекты интересующего класса могут свободно копироваться либо перемещаться.
Для подключения к программе векторов из стандартной библиотеки, необходим заголовочный файл:
#include <vector>
Далее можно создать объект-вектор для данных любого типа, передав этот тип в специальных угловых скобках, например:
// Вектор целых чисел
std::vector< int > v1;
// Вектор действительных чисел
std::vector< double > v2;
// Вектор объектов-дат
std::vector< Date > v3;
Класс std::vector предоставляет программисту широкий набор функциональных возможностей через методы и перегруженные операторы. При этом, реализация полностью берет на себя ответственность за необходимые низкоуровневые действия по управлению динамической памятью.
Существует большое число способов конструирования объекта-вектора:
Конструктор по умолчанию: std::vector< int > v; Cоздает вектор нулевого размера и, возможно, но не обязательно, с некоторой небольшой ненулевой емкостью. Количество выделяемой сразу памяти не оговаривается стандартами и зависит от конкретной реализации языка.
Конструктор с изначально заданным размером: std::vector< int > v( 10 ); Создает вектор указанного размера (10) и заполняет все элементы значениями по умолчанию. Для встроенных числовых типов такие значения являются нулями, для пользовательских типов - вызываются конструкторы по умолчанию.
Конструктор с изначально заданным размером и значением для заполнения: std::vector< int > v( 10, 1 ); Создает вектор указанного размера (10) и заполняет все элементы указанными вторым аргументом значениями (1).
Конструктор по набору, заданному интервалом итераторов: std::vector< int > v1( 10, 1 ); std::vector< int > v2( v1.begin(), v1.end() ); Создает вектор на основе значений в переданном интервале итераторов. Интервал может быть задан итераторами другого вектора, итераторами другой структуры данных или даже указателями на начало и конец интересующего сегмента обыкновенного массива.
Конструктор копий: std::vector< int > v1( 10, 1 ); std::vector< int > v2 = v1; Создает вектор на основе копирования значений другого вектора. Типы элементов двух векторов могут не совпадать, если между ними возможно преобразование типа.
Конструктор перемещения: std::vector< int > v1( 10, 1 ); std::vector< int > v2 = std::move( v1 ); Создает вектор на основе перемещения внутреннего содержимого. Типы элементов двух векторов обязательно должны совпадать. После перемещения оригинальный вектор либо не должен использоваться, либо должен быть переприсвоен.
Конструктор по списку инициализаторов: std::vector< int > v{ 1, 2, 3, 4, 5 }; Формирует вектор из элементов переданного списка инициализаторов. Типы элементов в списке и в векторе могут не совпадать, если между ними возможно преобразование типа.
Объект std::vector может быть присвоен как при помощи оператора копирования, так и при помощи перемещения. Дополнительно предоставляется перегруженный метод assign с аргументами, подобными ряду конструкторов.
Два вектора могут полностью обменяться внутренним содержимым при помощи метода swap:
std::vector< int > v1{ 1, 2, 3, 4, 5 };
std::vector< int > v2;
assert( v1.size() == 5 );
assert( v2.empty() );
v2.swap( v1 );
assert( v1.empty() );
assert( v2.size() == 5 );
К содержимому вектора также можно обращаться несколькими способами:
Произвольный доступ оператором индексной выборки []: std::vector< int > v{ 1, 2, 3, 4, 5 }; std::cout << v[ 2 ]; // выведет число 3 v[ 1 ] = 7; // заменит элемент 2 элементом 7 Указанный индекс никак не проверяется на корректность. Если индекс выходит за допустимые границы слева или справа, могут быть прочитаны либо повреждены соседствующие с вектором ячейки памяти, что является крайне небезопасным. В то же время, это самый быстрый способ произвольного доступа. Такой способ следует применять только при полной уверенности программиста в корректности передаваемого индекса.
Произвольный доступ при помощи метода at: std::vector< int > v{ 1, 2, 3, 4, 5 }; std::cout << v.at( 2 ); // выведет число 3 v.at( 1 ) = 7; // заменит элемент 2 элементом 7 Данный способ обращения выполняет проверку индекса на корректность. Если проверка выявляет выход за допустимую границу, выбрасывается исключение. Такой способ обращения безопасен, но уступает индексной выборке по производительности. Если нет уверенности в корректности передаваемого индекса, следует воспользоваться именно этим способом.
Методы front() и back() - возвращают ссылки на начальный и конечный элемент вектора соответственно. Поведение не определено, если вектор в данный момент пуст.
Метод data() - возвращает прямой указатель на начало блока данных.
Вектор также предоставляет большое разнообразие различных видов итераторов, что будет рассмотрено подробнее позднее. Простейшая пара итераторов, очевидно, возвращается парой методов begin() / end(). Из этого следует возможность использования вектора в интервальном цикле for напрямую: std::vector< int > v{ 1, 2, 3, 4, 5 }; for ( int x : v ) std::cout << x << ‘ ‘;
Содержимое векторов можно сравнивать при помощи перегруженных операторов ==, !=, <, <=, >, >=. Реализуется, так называемое, лексикографическое сравнение. Сначала сравниваются нулевые элементы двух векторов. При необходимости, сравнение продолжается на первых, на вторых элементах и т.д. Для равенства векторов все элементы должны быть равны, для неравенства - достаточно обнаружить разницу хотя бы в одном из элементов. Сравнение по < однозначно при неравенстве элементов, и продвигается далее только при равенстве. Остальные операторы работают по аналогии, и, зачастую, реализуются друг через друга.
Чтобы выяснить текущий размер вектора следует воспользоваться методом size(), а текущую емкость можно получить методом capacity().
В большинстве случаев реализация вектора прекрасно справляется с управлением собственной емкостью. Реализация оптимизирована для подавляющего большинства сценариев. Однако, могут возникать ситуации, когда программист может знать заранее о предстоящем размещении конкретного числа элементов. Повторное выделение памяти является дорогостоящей операцией, и для целей оптимизации производительности вектор предоставляет ряд дополнительных методов управления:
Метод resize: v.resize( 10 ); v.resize( 10, 5 ); Вызов метода изменяет размер вектора как в сторону увеличения, так и в сторону уменьшения. Емкость увеличивается только в том случае, если это необходимо. Емкость никогда не уменьшается от вызова resize(). Первый вариант метода заполняет новые элементы значениями по умолчанию, второй - указанными значениями.
Метод reserve: v.reserve( 100 ); Вызов метода подсказывает вектору, что необходимо заранее зарезервировать емкость для последующего роста до указанного числа элементов. Это позволяет избежать избыточных повторных выделений памяти, если целевой размер известен. Резервирование никогда не приводит к уменьшению емкости вектора, только к увеличению.
Метод shrink_to_fit: v.shrink_to_fit(); Вызов метода подсказывает вектору, что необходимо сузить емкость до текущего используемого размера.
Не рекомендуется пользоваться данными методами бездумно. Решение о их применении должно быть следствием измерений и экспериментов по оптимизации конкретных проблемных мест.
Вектор можно полностью очистить при помощи метода clear(). Очистка обнуляет размер, но не емкость. Если вектор пуст (нулевой размер, независимо от емкости), метод empty() вернет значение true, и false в противном случае. Многие начинающие программисты путают методы clear() и empty(). Метод empty() является запросом, а не действием, и помечен модификатором const.
Вставка одиночных элементов в конец вектора осуществляется методом push_back:
v.push_back( 1 );
При передаче значения либо константной ссылки элемент копируется в вектор, а при передаче r-value ссылки (например, с использованием std::move) - перемещается в вектор.
Существует также особая форма вставки в конец при помощи метода emplace_back:
std::vector< std::string > v;
v.emplace_back( “Hello” );
Фактически, хранимый объект таким способом конструируется непосредственно внутри контейнера, а ему передается список аргументов для конструктора. Это позволяет экономить на избыточных копированиях и даже перемещениях при вставке элементов.
Вставка в произвольную позицию вектора осуществляется при помощи метода insert. Помимо вставляемого значения или ссылки на него, передается итератор, обозначающий позицию, перед которой необходимо вставить новый элемент. Например, для вставки в начало вектора следует воспользоваться таким вызовом:
std::vector< std::string > v{ “World” };
v.insert( v.begin(), “Hello” ); // Строка “Hello” окажется раньше строки “World”
Аналогично push_back, допускается копирующая и перемещающая версии. Также, имеется метод emplace для вставки в произвольную позицию с конструированием элемента на месте.
Особая форма метода insert - интервальная. Если необходимо вставить несколько элементов подряд, интервальная форма является предпочтительной, поскольку уменьшает общее число линейных сдвигов внутри вектора:
std::vector< int > v1{ 1, 2, 5, 6, 7 };
std::vector< int > v2{ 3, 4 };
v1.insert( v1.begin() + 2, v2.begin(), v2.end() );
Для удаления элемента или нескольких элементов имеется перегруженный набор методов erase, а для частной задачи - удаления элемента в конце вектора - предоставлен метод pop_back().
Столь подробное изучение возможностей библиотечной реализации std::vector не случайно, поскольку остальные структуры данных стандартной библиотеки во многом похожи на std::vector по набору открытых функций, что значительно упрощает изучение и использование библиотеки.
STL-контейнеры для последовательностей
Вектор является одним из контейнеров-последовательностей в стандартной библиотеке шаблонов С++ (STL - Standard Template Library). Контейнерами в STL называются реализации классических структур данных, предназначенные для хранения, перебора, поиска и прочих манипуляций с наборами элементов. Подобные мощные утилитарные классы для классических структур имеются во всех современных языках программирования, и С++ не является исключением.
Контейнеры-последовательности реализуют структуры данных, для которых порядок хранения и перебора элементов определяется пользователем. Такие контейнеры никогда не изменяют заданный порядок. Помимо последовательностей в STL также существуют ассоциативные контейнеры, которые будут подробно рассмотрены позднее. Ассоциативные контейнеры управляют порядком элементов самостоятельно исходя из принципов своей внутренней организации.
STL предлагает набор контейнеров-последовательностей на выбор с пересекающимися возможностями, и принятие решения об наилучшем контейнере для конкретной ситуации может вызвать ряд трудностей у начинающих. В целом, если нет явных противопоказаний, очевидных из условия задачи, вектор может служить типичным начальным вариантом, рассматриваемым для применения в решении. Вектор довольно хорошо оптимизирован для простых ситуаций, в связи с чем он используется наиболее часто. Остальные контейнеры применяют, в первую очередь, для оптимизации производительности конкретной задачи, когда недостатки вектора очевидны.
Сигнатуры методов различных контейнеров STL, хотя и не идентичны на 100%, но имеют большое пересечение. Существенная часть операций называется одинаково во всех контейнерах и принимает идентичные формальные аргументы. Такой стиль не является случайностью и помогает программистам минимальными затратами изменять используемый алгоритмом контейнер на другой, а также разрабатывать обобщенные от конкретного типа контейнера алгоритмы на основе шаблонов. Таким образом, изначальная ошибка в выборе контейнера для хранения данных не слишком критична, и почти всегда может быть относительно быстро исправлена.
Типовая альтернатива векторам - связные списки. Основное преимущество связных списков состоит в одинаковой производительности вставки и удаления элементов в любой позиции. Особенно ярко по сравнению с вектором выигрыш проявляется при манипуляциях в начальных позициях. Еще один удачный сценарий для применения связного списка - необходимость разорвать хранимую последовательность на 2 части для последующего отдельного использования (операция splice). Однако, связные списки значительно проигрывают векторам по занимаемой памяти (отдельное выделение динамической памяти для каждого узла), а также неэффективны при обращении к элементу по индексу и элементарном вычислении текущего размера.
Основной вид связных списков, предлагаемый стандартной библиотекой - это двусвязный список, реализуемый шаблоном класса std::list (заголовочный файл <list>):
Набор операций std::list похож на операции с векторами, с некоторыми исключениями:
в списках нет операторов индексной выборки [] и функции at (неэффективно для списков);
в списках нет специфических для вектора методов, касающихся резервирования места для будущих элементов (reserve, capacity, shrink_to_fit);
в списках есть функции push_front, emplace_front и pop_front для вставки и удаления элементов в начальной позиции, которые не эффективны и не предоставлены для векторов;
Также в списках std::list есть ряд уникальных методов, не повторяющихся ни в одном контейнере, реализующих часто используемые алгоритмы:
splice - разделение списка на два независимых по выбранной позиции;
sort - метод сортировки списка (обычно используется сортировка слиянием);
merge - метод слияния двух отсортированных списков (как в сортировке слиянием);
reverse - метод обращения порядка элементов списка на противоположный;
remove, remove_if - методы удаления узлов, соответствующих интересующему условию;
unique - метод удаления повторяющихся элементов.
Алгоритмы STL будут рассмотрены в данной лекции позднее, однако можно сразу заявить, что связные списки далеко не всегда удачно вписываются в ряд типичных решений для других структур данных, и, в связи с этим, для них предоставляется специальная реализация в виде методов. Наиболее яркий пример - сортировка. Другие структуры данных чаще всего сортируют при помощи алгоритма быстрой сортировки, и только списки - сортировкой слиянием.
Также, начиная с С++11, в стандартной библиотеке появился еще один вариант связных списков - односвязные списки std::forward_list (заголовочный файл <forward_list>):
Разница между двумя вариантами состоит в небольшой экономии памяти в односвязном варианте, достигаемого за счет отказа от операций push_back, emplace_back и pop_back. Экономию памяти нельзя назвать существенной, т.к. служебных данных для обеспечения отдельный выделений динамической памяти для каждого узла расходуется значительно больше, чем для непосредственного хранения узлов. В принципе, реализацию односвязного списка можно упростить до хранения только указателя на начальный узел, поскольку операции с последним узлом не проводятся. Еще одно отличие односвязных списков от std::list и std::vector - отсутствие методов insert, erase, emplace. Поскольку односвязный список не может эффективно перебирать элементы в обратном направлении, то эти методы заменены на более эффективные insert_after, erase_after, emplace_after, работающие с элементом после позиции, а не до нее.
Типичной ошибкой программистов при использовании std::list и std::forward_list является использование метода size() для выяснения пустоты контейнера. Например:
std::list< int > l;
// ...
if ( ! l.size() )
std::cout << “Empty list”;
Такой код выполнит задачу, однако он крайне неэффективен для длинных списков. В отличие от вектора, метод size() в связном списке характеризуется линейной вычислительной сложностью, т.к. для подсчета количества хранимых элементов требует полного прохода от начала до конца списка. Для определения пустоты следует воспользоваться методом empty(), который принимает решение мгновенно исходя из наличия или отсутствия внутренних узлов:
std::list< int > l;
// ...
if ( l.empty() )
std::cout << “Empty list”;
Помимо связных списков, STL предлагает промежуточный вариант с точки зрения выделения памяти - дек (deque), или очередь с двумя концами. Это более сложная структура данных, иногда называемая сегментированным массивом, состоящая из блока-директории и нескольких блоков данных одинакового размера. Директория содержит указатели на индивидуальные блоки данных, и может расти в любую сторону. В такую структуру одинаково эффективно добавлять новые элементы как в начальную, так и в конечную позицию. Такие изменения задевают только крайние блоки. Если при добавлении очередного элемента соответствующий блок оказывается заполненным полностью, выделяется еще один блок, а его адрес записывается в директорию. Однако, вставка или удаление элемента в середине дека еще более неэффективна, чем в векторе, поскольку вызывает сложный каскад сдвигов данных, в том числе из блока в блок, а не только в его рамках.
С точки зрения методов, дек очень похож на вектор, в частности, содержит оператор индексной выборки [] и функцию at для доступа к произвольной ячейке. Однако, следует понимать, что в такой структуре, хотя и сохраняется константная вычислительная сложность доступа, все же в абсолютных показателях времени это более дорогая операция, чем в векторе, поскольку сначала необходимо вычислить номер блока, номер ячейки в блоке, извлечь адрес блока из директории и только потом обратиться к элементу массива. В отличие от вектора, дек, также как и связный список, имеет операции push_front, emplace_front, pop_front. В деке отсутствуют методы reserve и capacity, однако имеется собственный вариант метода shrink_to_fit, освобождающий избыточные блоки памяти. В целом, данный контейнер применяется значительно реже, чем вектора и связные списки, и решение о его применении должно быть выверенным, оправданным с точки зрения быстродействия и расхода памяти.
В тех редких случаях, когда количество элементов фиксированное и известно из требований к задаче, имеет смысл пользоваться массивами, а не контейнерами. Чтобы упростить работу по созданию массивов, можно воспользоваться оберткой std::array из стандартной библиотеки:
std::array< int, 10 > a;
По сравнению с непосредственным применением обычных массивов, такая обертка предоставляет совместимые со всеми стандартными алгоритмами объекты-итераторы, функциональность по проверке корректности индекса при доступе (метод at() как в векторе), и одно из самых полезных средств, так не хватающих во встроенных массивах - метод size(). Однако такой вариант с оберткой std::array подходит только для тех случаев, когда размер массива известен заранее, а не определяется каким-либо условием во время выполнения. Здесь применен ранее рассмотренный механизм с константами в качестве аргументов шаблона, что позволяет полностью избежать динамического выделения памяти для хранения элементов массива.
Интересно, что среди методов STL-контейнеров специально исключен ряд однозначно неэффективных возможностей использования структур данных. Например, std::vector не содержит метода push_front, поскольку эффективно реализовать его не удастся. Если программисту крайне необходимо нарушить эту рекомендацию и все же вставить начальный элемент в вектор, для этой задачи служит более многословный универсальный метод вставки insert, позволяющий вставлять новые элементы в любую позицию, в том числе, в начальную:
std::vector< int > v{ 5, 6, 7 };
v.insert( v.begin(), 4 );
Аналогично, std::list не перегружает оператор индексной выборки, а std::forward_list не имеет операций push_back/emplace_back/pop_back. Это довольно яркая иллюстрация особенностей технической идеологии, заложенной в С++. Программист защищен от случайного совершения неэффективного действия, просто за счет исключения ненужной возможности из открытой части класса. Мысль гениально проста: если нет неэффективного метода - не может быть и ошибки. Однако при явно выраженном осознанном намерении можно выполнить любое необходимое для решения задачи действие, пусть и написав немного больше кода. Иначе говоря, С++ не пытается быть умнее программиста, и в руках опытного мастера делает возможными любые трюки.
