- •И. А. Андрианов, д. В. Кочкин, с. Ю. Ржеуцкая
- •Учебное пособие
- •Оглавление
- •1. Основы языка 8
- •1.2.2 Простые типы данных 13
- •6. Паттерны проектирования 159
- •7. Антипаттерны 211
- •2. Работа с памятью 73
- •3. Основы объектно-ориентированного программирования 87
- •4.Обработка исключений 114
- •5. Шаблонные функции и классы. Библиотека стандартных шаблонов 130
- •9. Методы отладки и оптимизации кода 242
- •1. Основы языка
- •1.1.2 Понятие проекта
- •1.2 Простые типы данных
- •1.2.1 Понятие типа
- •1.2.2 Простые типы данных
- •1.2.3 Внутреннее представление простых типов
- •1.2.4 Ключевое слово typedef. Тип size_t
- •1.3 Константы и переменные
- •1.3.1 Литералы
- •1. Числовые константы:
- •2. Символьные константы:
- •1.3.2 Переменные
- •1.3.3 Описание переменных
- •1.4. Выражения. Преобразование типов
- •1.4.1 Операнды и операции
- •1.4.2 Приоритет операций
- •1.4.3 Преобразование типов
- •1.5 Ветвления и циклы
- •1.5.2 Циклы
- •1.6 Массивы, строки
- •1.6.1 Основные понятия
- •1.6.2 Встроенные массивы
- •1.6.3 Cтроки. Обработка строк с завершающим нулём
- •1.7 Указатели и ссылки. Связь указателей и массивов. Библиотека cstring
- •1.7.1 Понятия указателя и ссылки
- •1.7.2 Связь между массивами и указателями
- •1.7.3 Библиотека cstring
- •1.8 Использование типов vector и string
- •1.8.1 Шаблонный класс vector
- •1.8.2 Класс string
- •1.9 Структуры и объединения. Битовые поля
- •1.10.1 Понятие функции
- •1.10.2 Описание функции и прототип функции
- •1.11 Параметры функции. Способы передачи параметров
- •1.11.1 Параметры функции и глобальные переменные
- •1.11.2 Способы передачи параметров в функцию
- •1.11.3 Передача массивов в функцию
- •1.11.4 Параметры-константы
- •1.11.5 Значения параметров по умолчанию
- •1.12.1 Указатель на функцию
- •1.12.2 Функции с переменным числом параметров
- •1.12.3 Перегрузка функций
- •1.12.4 Встроенные (inline) функции
- •1.13 Рекурсивные функции
- •1.14 Пространства имён
- •1.15 Директивы препроцессора. Макросы
- •2. Работа с памятью
- •2.1 Управление выделением и освобождением памяти
- •2.1.1 Статическое и динамическое выделение памяти
- •2.1.2 Способы динамического выделения и освобождения памяти
- •2.2 Динамические структуры данных
- •2.2.1 Основные понятия
- •2.2.2 Примеры реализации динамических структур на основе указателей
- •3. Основы объектно-ориентированного программирования
- •3.1 Основные понятия ооп
- •3.2.1 Описание класса
- •3.2.2 Область видимости элементов класса. Инкапсуляция
- •3.2.3 Первые примеры
- •3.3. Конструкторы и деструкторы.
- •3.4 Указатель this
- •3.5 Перегрузка операций
- •3.6 Дружественные функции и классы
- •3.7 Статические элементы класса
- •3.8 Наследование и полиморфизм
- •3.8.1. Основные понятия
- •3.8.2 Одиночное наследование
- •3.8.3 Множественное наследование
- •3.8.4 Конструкторы и деструкторы классов-потомков
- •3.9. Полиморфизм при наследовании классов
- •3.9.1 Механизмы раннего и позднего связывания
- •3.9.2 Абстрактные классы
- •4.Обработка исключений
- •4.1 Основные понятия
- •4.2 Перехват исключений
- •4.3 Поиск обработчика исключений. Раскрутка стека.
- •4.4 Повторное возбуждение исключений
- •4.5 "Аппаратные" и "программные" исключения
- •4.6 Стандартные классы исключений
- •4.7 Спецификация исключений, возбуждаемых функцией
- •4.8 Исключения в конструкторах при наследовании
- •4.9. Исключения в деструкторах
- •5. Шаблонные функции и классы. Библиотека стандартных шаблонов
- •5.1 Шаблонные функции
- •5.2 Шаблонные классы
- •5.3 Специализация шаблонов
- •5.4 Шаблонные параметры шаблонов
- •5.6 Использование шаблонов для вычислений на этапе компиляции
- •5.5 Разработка шаблонных классов с настраиваемой функциональностью
- •5.7 Библиотека стандартных шаблонов (stl) – основные понятия
- •5.8 Последовательные контейнеры. Итераторы
- •5.9. Адаптеры контейнеров
- •5.10 Ассоциативные контейнеры
- •5.11 Алгоритмы
- •6. Паттерны проектирования
- •6.1 Порождающие шаблоны
- •6.2 Структурные шаблоны
- •6.3 Шаблоны поведения
- •6.4 Шаблон "фабричный метод" (Factory method)
- •6.5 Шаблон "одиночка" (Singleton)
- •6.6 Шаблон "итератор" (Iterator)
- •6.7 Шаблон "наблюдатель" (Observer)
- •6.8 Шаблон "пул объектов" (Object pool)
- •6.9 Шаблон "команда" (Command)
- •6. 10 Шаблон "посетитель" (Visitor)
- •6.11 Дополнительные задания
- •6.11.1 Шаблон Iterator
- •6.11.2 Шаблон Observer
- •6.11.3 Шаблоны Command и Observer
- •6.11.5 Шаблон Visitor
- •6.11.5 Разработка класса − контейнера
- •6.11.6 Оценка производительности кода
- •7. Антипаттерны
- •7.1 Программирование методом копирования и вставки (Copy-Paste Programming)
- •7.2 Спагетти-код (Spaghetti code)
- •7.3 Магические числа (Magic numbers)
- •7.4 Бездумное комментирование
- •7.5 Жесткое кодирование (Hard code)
- •7.6 Мягкое кодирование (Soft code)
- •7.7 Золотой молоток (Golden hammer)
- •7.8 Слепая вера (Blind faith)
- •7.9 Ненужная сложность (Accidental complexity)
- •7.10 Божественный объект (God Object)
- •7.11 Лодочный якорь (Boat anchor)
- •7.12 Поток лавы (Lava flow)
- •7.13 Изобретение велосипеда (Reinventing the wheel)
- •7.14 Программирование перебором (Programming by permutation)
- •8.1 Выведение типов
- •8.2 Списки инициализации
- •8.3 Улучшение процесса инициализации объектов
- •8.4 Цикл for по коллекции
- •8.5 Лямбда-функции
- •8.6 Константа нулевого указателя nullptr
- •8.7 "Умные" указатели
- •9. Методы отладки и оптимизации кода
- •9.1 Отладка кода
- •9.1.1 Основные этапы отладки
- •9.1.2 Инструменты и приёмы отладки
- •9.2 Оптимизация кода
- •9.2.1 Рекомендации по выполнению оптимизации
- •9.2.2 Методики оптимизации кода
- •Заключение
- •Библиографический список
5.7 Библиотека стандартных шаблонов (stl) – основные понятия
Библиотека стандартных шаблонов (STL, Standard Template Library) является частью стандартной библиотеки языка C++. STL предоставляет программистам готовые реализации наиболее востребованных на практике структур данных и алгоритмов, что позволяет программистам не "изобретать велосипед" и повышает эффективность разработки надёжных и переносимых программ.
В библиотеке можно выделить следующие основные компоненты:
1. Контейнеры – шаблонные классы для хранения объектов в памяти. Контейнеры можно разделить на 4 вида: последовательные, ассоциативные, контейнеры-адаптеры и псевдоконтейнеры.
2. Итераторы – специальные шаблонные классы, предназначенные для доступа к элементам внутри контейнера.
3. Алгоритмы – шаблонные функции, реализующие различные алгоритмы над данными в контейнерах – например, сортировку, поиск и др.
4. Функциональные объекты (функторы) – вспомогательные шаблонные классы, в которых определён operator(). Основное их предназначение – изменение поведения алгоритмов STL (например, вместо сортировки по возрастанию выполнять сортировку по убыванию).
5. Некоторые вспомогательные шаблонные классы. Например, класс std::pair предназначен для хранения произвольных пар элементов и содержит два поля: first − первый элемент, second − второй элемент.
Заметим, что библиотека STL весьма обширна. В данной главе мы лишь познакомимся с основами её использования.
5.8 Последовательные контейнеры. Итераторы
К последовательным контейнерам относятся шаблонные классы std::vector, std::deque и std::list. На практике класс std::vector используется крайне широко, тогда как два оставшихся класса применяются довольно редко.
Класс std::vector можно рассматривать как альтернативу обычным динамическим массивам. При использовании векторов значительно снижается риск утечек памяти, поскольку освобождение памяти происходит автоматически в деструкторе данного класса (который вызовется даже при возникновении исключения).
Также большим плюсом вектора является возможность автоматически увеличивать свой размер при вставке элементов. Рассмотрим подробнее, как это происходит. Внутри вектора имеется указатель на блок памяти (динамический массив), в котором реально хранятся данные. Для того, чтобы можно было эффективно добавлять новые элементы в конец вектора, размер этого блока памяти обычно берётся с некоторым запасом. Для демонстрации этого приведём следующий код:
std::vector<int> a(4);
a.push_back(5);
std::cout << a.size() << " " << a.capacity() << std::endl;
Функция size возвращает количество элементов в векторе, а функция capacity − размер динамического массива, выделенного для хранения данных. Для компилятора, которым пользовался автор, на экран будет выведено "5 8" − то есть реально в массиве хранится пять элементов, но память выделена под хранение восьми. Это означает, что если мы продолжим добавление в вектор, то следующие три вызова функции push_back выполнятся максимально эффективно (с константной временной сложностью).
Что произойдёт при попытке выполнить вставку в вектор в четвёртый раз? Поскольку место для хранения элементов закончится, то будет выделен новый блок памяти большего размера, все текущие элементы скопируются из старого блока памяти в новый, а старая память будет освобождена. Таким образом, четвёртый вызов функции push_back выполнится существенно медленней, чем предыдущие − с линейной временной сложностью. При этом размер зарезервированной памяти снова увеличился.
Примечание. Насколько именно увеличивается размер зарезервированной памяти, стандартом не регламентируется, поэтому в разных компиляторах может отличаться (обычно в полтора-два раза).
Заметим, что хотя отдельные вызовы push_back могут выполняться с линейной сложностью, но средняя (амортизационная) вычислительная сложность данной функции является константной.
Для обращения к элементам вектора имеются два основных способа. Во-первых, вектор поддерживает эффективное обращение по индексу, используя операцию [] или функцию at, например:
a[2] = 5;
a.at(2) = 5;
Отличие между этими вариантами состоит в том, что в функции at проверяется выход индекса за границы, а в операции [] в целях производительности такая проверка не делается (точнее, проверка может выполняться, если проект компилируется в конфигурации Debug, но в конфигурации Release она отключается).
Во-вторых, как и для других контейнеров, для обращения к элементам вектора можно использовать итераторы.
Итератор − специальный объект, ссылающийся на элемент внутри контейнера. По своему использованию итератор похож на обычный указатель. Например, операция ++ перемещает итератор на следующий элемент контейнера, операция -- − на предыдущий элемент (поддерживается для двунаправленных итераторов). Операция разыменования * возвращает ссылку на текущий элемент. Итераторы произвольного доступа (для векторов и деков) поддерживают также операции прибавления и вычитания целого числа. При этом итератор смещается вперёд или назад на соответствующее число элементов.
Любой контейнер содержит методы begin и end, где функция begin возвращает итератор на первый элемент, а функция end − на воображаемый несуществующий элемент, следующий за последним. Для примера напишем вывод на консоль всех элементов вектора с использованием итератора:
std::vector<int> a = {1, 2, 3, 4, 5};
for (std::vector<int>::iterator it=a.begin(); it!=a.end(); it++){
std::cout << *it << " ";
}
Используя итераторы, важно понимать, что при изменении контейнера некоторые итераторы могут стать недействительными. Например, пусть у нас есть итератор, ссылающийся на какой-то элемент вектора. Вставим в вектор несколько новых элементов. Если в процессе вставки в векторе закончится свободное место, будет выполнено перераспределение памяти. При этом все предыдущие итераторы станут недействительными, а попытка их использования приведёт к неопределённому поведению программы:
std::vector<int> a;
a.push_back(1);
std::vector<int>::iterator it = a.begin();
a.push_back(2); a.push_back(3); a.push_back(4);
*it = 8; // ошибка - неопределённое поведение
Рекомендуется с осторожностью применять итераторы при изменении контейнера, обязательно убедившись по документации, что итераторы, которые вы используете, не могут стать недействительными.
Перечислим кратко некоторые полезные методы, которые имеются в классе std::vector.
• assign − устанавливает новое содержимое для вектора, заменяя его старое содержимое. Пример: a.assign(10, 1); − вектор a будет содержать 10 целых чисел, каждое из которых равно 1.
• resize − изменяет размер вектора. Если размер увеличивается, то новые элементы заполняются значением по умолчанию либо указанным значением. Пример: a.resize(10, 1); устанавливает размер вектора равным 10 элементам. Предыдущие 10 значений вектора сохраняются. Если в векторе было меньше десяти элементов, то новые элементы будут равны единице.
• empty − логическая функция, которая возвращает true, если вектор пуст.
• size − возвращает размер вектора (то есть количество элементов в нём).
• capacity − возвращает ёмкость вектора (количество элементов, которое может быть в нём сохранено перед тем как потребуется перераспределить память).
• reserve − требует установить ёмкость не меньше, чем указанное значение. Пример: a.reserve(100); означает, что если изначально вектор был пуст, то как минимум следующие сто вызовов push_back не вызовут перераспределения памяти. Может использоваться для небольшого повышения производительности, если заранее известно, сколько элементов будет добавляться.
• clear − очистка вектора.
• pop_back − удаляет последний элемент. При этом ёмкость вектора не уменьшается.
• shrink_to_fit − уменьшает ёмкость (capacity) вектора, устанавливая её равной размеру вектора. Данную функцию удобно использовать после большого количества удалений из вектора для экономии памяти, поскольку ёмкость вектора автоматически не уменьшается никогда.
• insert − выполняет вставку в заданную позицию. Пример: a.insert(a.begin(), 8); − число 8 будет вставлено в начало вектора, остальные элементы сдвинутся вправо. Для вектора данная операция имеет линейную сложность.
• erase − выполняет удаление из заданной позиции одного элемента или указанный диапазон элементов. Пример: a.erase(a.begin()); − удалить начальный элемент; a.erase(a.begin() + 1, a.begin() + 3); − удалить элементы с позиции 1 по позицию 3. Последующие элементы при этом сдвинутся влево. Для вектора данная операция имеет линейную сложность.
Заметим, что многие из перечисленных методов имеются не только в классе std::vector, но и в других контейнерах.
В качестве более сложного примера на применение векторов решим следующую задачу. Имеется N городов, пронумерованных от 1 до N. Некоторые пары городов соединены двухсторонними дорогами (всего дорог M). Требуется найти город, из которого выходит больше всего дорог (если таких несколько, то первый из них), и вывести его, а также города, с которыми он соединён.
Для решения этой задачи будем для каждого города запоминать, какие дороги из него выходят. Для этого создадим вектор, элементами которого будут вектора из целых чисел. Такая структура данных часто используется для представления графов в памяти.
// Пример 5.12 - поиск города с максимальным числом дорог
#include <stdio.h>
#include <vector>
int main() {
int n, m;
scanf("%d %d", &n, &m);
std::vector<std::vector<int> > g(n + 1);
for (int i = 1; i <= m; i++) {
int v1, v2;
scanf("%d %d", &v1, &v2);
g[v1].push_back(v2);
g[v2].push_back(v1);
}
int ans = 1;
for (int i = 2; i <= n; i++)
if (g[i].size() > g[ans].size())
ans = i;
printf("%d\n", ans);
for (int to : g[ans])
printf("%d ", to);
}
В заключение данного подраздела поговорим немного о классах std::list и std::deque.
Класс std::list представляет собой двухсвязный список. Основное его преимущество по сравнению с вектором − высокая эффективность выполнения операций insert и erase для текущей позиции списка. Однако, для их использования нужно иметь итератор, указывающий на текущую позицию. На практике данный класс используется довольно редко.
Класс std::deque реализует структуру данных "дек" − двухстороннюю очередь, где возможны эффективная вставка и удаление элементов с обоих концов. Кроме методов push_back и pop_back, в данном классе имеются методы push_front и pop_front, которые работают тоже с константной (в среднем) алгоритмической сложностью.
Как и в векторе, в деке реализована операция индексирования [], которая тоже выполняется с константной алгоритмической сложностью. Однако, на практике она работает несколько медленней, чем в векторе, из-за более сложного внутреннего устройства дека (данные внутри дека хранятся не в одном непрерывном блоке памяти, как в векторе, а в нескольких блоках, связанных между собой).
