3.4. Адаптеры и представления

Адаптеры над стандартными контейнерами — это не самостоятельные контейнеры. Они используют какой-нибудь другой последовательный контейнер для хранения своих элементов, но при этом предоставляют свой набор функций для работы с ними. Представления (views) не владеют памятью, а лишь ссылаются на диапазон значений другого контейнера.

Адаптеры над стандартными контейнерами — это не самостоятельные контейнеры. Они используют какой-нибудь другой контейнер (например, дек или вектор) для хранения своих элементов, но при этом предоставляют свой набор функций для работы с ними. В стандартной библиотеке есть адаптеры std::stack, std::queue и std::priority_queue.

Представления (views) не владеют памятью, а лишь ссылаются на диапазон значений другого контейнера. Представлением можно считать пару итераторов. В С++20 появилась обширная библиотека ranges для работы с представлениями. В этом параграфе мы рассмотрим конструкцию string_view, представляющую фрагмент строки.

Адаптер std::stack

Стек — это последовательность элементов, в которую разрешается добавлять и извлекать элементы только с одной стороны. Обращение к промежуточным элементам не допускается. Говорят, что структура данных «стек» реализует идею LIFO (last in — first out).

Понятно, что эти действия можно сделать с помощью вектора или дека и операций push_back и pop_back. Адаптер std::stack является обёрткой над такими контейнерами с особым интерфейсом — функциями push, pop и top. Это позволяет более выразительно подчеркнуть, что используется не просто какой-то контейнер, а именно стек как структура данных.

C

#include <iostream>
#include <stack>

int main() {
    std::stack<int> s;
    s.push(1);
    s.push(13);
    s.pop();  // удалить элемент с вершины стека (в стеке останется 1)

    std::cout << s.top() << "\n";  // получить элемент на вершине стека, не удаляя его

    if (s.empty()) {  // проверка на пустоту
       std::cout << "Stack is empty\n";
    }
}

Функция pop ничего не возвращает. Напротив, функция top возвращает элемент, но никак не меняет состояние стека. Так было сделано для гарантий безопасности исключений. Мы поговорим об этом в параграфе «Обработка исключений».

По умолчанию стек использует контейнер std::deque для хранения элементов. При желании мы можем указать другой тип этого контейнера. Всё, что требуется от этого контейнера, — это поддержка операций push_back, pop_back, back и empty:

#include <iostream>
#include <list>
#include <stack>

int main() {
    std::stack<int, std::list<int>> s;  // используем std::list для хранения элементов в стеке
    // ...
}

В C++ есть ещё понятие стека как области памяти для локальных переменных и аргументов функций. Оно так называется потому, что переменные автоматически помещаются туда при создании и автоматически удаляются при выходе из области видимости. То есть, последняя созданная переменная удалится первой, как в обычном стеке. Не следует путать это понятие с адаптером std::stack. Подробнее про жизненный цикл объектов мы поговорим в параграфе «Жизненный цикл объекта».

Адаптер std::queue

Другой похожий адаптер — очередь (std::queue). Очередь реализует идею FIFO (first in — first out). Можно считать, что элементы встают в очередь с одного конца, а извлекаются с другого. Очередь предоставляет функции push, pop, front и back.

C

Не следует путать адаптер queue с контейнером deque.

#include <iostream>
#include <queue>

int main() {
    std::queue<int> s;
    s.push(1);
    s.push(13);

    std::cout << s.front() << "\n";  // 1
    std::cout << s.back() << "\n";  // 13

    s.pop();  // в очереди останется 13

    if (s.empty()) {
       std::cout << "Queue is empty\n";
    }
}

Так же как и в std::stack, в очереди по умолчанию используется std::deque для хранения элементов. Этот контейнер точно так же можно заменить. Для этого нужно указать тип нового контейнера во втором шаблонном параметре. Однако в отличие от стека здесь не получится использовать std::vector, так как у него нет функций push_front и pop_front.

Адаптер std::priority_queue

Рассмотрим адаптер std::priority_queueочередь с приоритетами. Эта структура данных позволяет за логарифмическое время добавлять и удалять элементы и за константное время получать максимальный элемент. Типичная реализация очереди с приоритетами основана на структуре данных «куча». Для хранения элементов используется контейнер с индексами и с возможностью добавления в конец. На эту роль подходят std::vector и std::deque, но не подходит std::list. По умолчанию используется std::vector.

C

#include <iostream>
#include <queue>  // именно queue, а не priority_queue

int main() {
    std::priority_queue<int> pq;
    for (int x : {3, 14, 15, 92, 6, 0, 1, 10}) {
        pq.push(x);
    }
    while (!pq.empty()) {
        std::cout << pq.top() << "\n";
        pq.pop();
    }
    // Будет напечатано 92, 15, 14, 10, 6, 3, 1, 0
}

Для сравнения элементов по умолчанию используется оператор <. Чтобы воспользоваться оператором > и, соответственно, извлекать из очереди минимальные элементы вместо максимальных, можно передать в третьем шаблонном параметре специальную обёртку std::greater<int>.

#include <iostream>
#include <functional>
#include <queue>
#include <vector>

int main() {
    std::priority_queue<
        int,
        std::vector<int>,  // тип внутреннего контейнера; его приходится указывать, если нам нужен третий параметр
        std::greater<int>  // вспомогательная обёртка над оператором > (а не <)
    > pq;

    for (int x : {3, 14, 15, 92, 6}) {
        pq.push(x);
    }

    while (!pq.empty()) {
        std::cout << pq.top() << "\n";
        pq.pop();
    }
    // Будет напечатано 3 6 14 15 92
}

Представление std::string_view

Конструкция std::string_view не является адаптером. Это не самостоятельная строка, а отсылка к подстроке какой-то другой строки. Объекты такого класса не выделяют память для размещения символов строки и не копируют подстроки. Упрощённо их можно представлять себе просто как такие структуры:

struct string_view {
    const char* start;  // указатель на начало подстроки в чужой памяти
    size_t len;   // длина этой подстроки
};

C

Давайте взглянем на следующий код:

#include <iostream>
#include <string>

int main() {
    std::string s = "Hello, world! How do you do?";
    auto sub = s.substr(7, 5);  // тип sub — std::string
    sub[0] = 'W';
    std::cout << sub << "\n";  // World
    // В исходной строке всё осталось по-прежнему:
    std::cout << s << "\n";  // Hello, world! How do you do?
}

Здесь имеется исходная строка s, у которой мы выделяем подстрочку sub. Эта подстрока является самостоятельной копией фрагмента исходной строки. С одной стороны, это позволяет её независимо изменять. С другой стороны, если такие изменения не требуются, на такое копирование тратится время и память.

Перепишем теперь это через string_view:

#include <iostream>
#include <string>
#include <string_view>

int main() {
    std::string s = "Hello, world! How do you do?";
    std::string_view sv = s;  // смотрим через string_view на строку s
    auto sub = sv.substr(7, 5);  // теперь тип sub — std::string_view
    // sub[0] = 'W';  // изменять через string_view символы строки нельзя
    std::cout << sub << "\n";  // world
    std::cout << s << "\n";  // Hello, world! How do you do?
}

Обёртка string_view рассматривает исходную строчку как константную: она не позволяет её изменять. В то же время через string_view можно читать символы фрагмента исходной строки, а также можно брать новые подстроки и сужать длину подстроки:

#include <iostream>
#include <string>
#include <string_view>

int main() {
    std::string s = "Hello, world! How do you do?";
    std::string_view sv = s;
    sv.remove_prefix(7);  // убрали из sv первые 7 символов
    sv.remove_suffix(16);  // убрали последние 16 символов
    std::cout << sv << "\n";  // world
    std::cout << s << "\n";  // строка s при этом никак не изменилась
}

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

#include <iostream>
#include <string>
#include <string_view>
#include <vector>

int main() {
    std::vector<std::string_view> lines;
    for (int i = 0; i < 5; ++i) {
        std::string line;
        std::getline(std::cin, line);
        lines.push_back(line);  // string_view в векторе ссылается на память строки line
    }
    for (auto item : lines) {
        std::cout << item << "\n";  // Ошибка! Все эти строки уже невалидны!
    }
}

Программа компилируется, но при запуске попадает в неопределённое поведение, так как исходные строки line выходят из своей области видимости после каждой итерации цикла, и обращаться к их памяти больше нельзя.

По аналогии со string_view, std::span является оберткой над произвольной непрерывной последовательностью элементов в памяти. Можно сказать, что span — это аналог string_view для массивов или векторов.

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.3. Алгоритмы

Алгоритмы стандартной библиотеки представляют из себя шаблонные функции для обработки последовательностей: подсчёта, поиска, сортировки и т.д. Такие функции, как правило, принимают на вход два итератора, которые ограничивают рассматриваемый диапазон.

Следующий параграф3.5. Разбор задач к главе «Стандартная библиотека C++»

В этом параграфе мы разберём задачи к главе «Стандартная библиотека C++».