Стандартная библиотека C++ в плагине: optional, контейнеры, лямбды и std::function

Какие типы стандартной библиотеки C++ использует плагин на JUCE, и где они проверяют ошибки, а где нет?

Эта статья продолжает «C++ в плагине: классы, конструкторы и время жизни» и разбирает типы стандартной библиотеки, которые встречаются в плагине (plug-in)плагин (plug-in) — Программный модуль эффекта или инструмента, который загружается в DAW или другую программу-хост и обрабатывает переданные ему аудиоблоки. тремоло (tremolo)тремоло (tremolo) — Эффект периодического изменения амплитуды аудиосигнала. Скорость пульсации задаёт частота управляющего генератора, а диапазон усиления зависит от глубины и способа её преобразования. из официального курса JUCE: std::optional, контейнеры, алгоритмы, лямбды и std::function. Фрагменты взяты из готовой версии в папке complete (JUCE 8.0.12, C++23); на полях стоят ссылки на уроки Learn C++ и справочник cppreference.

У многих типов стандартной библиотеки есть два способа получить значение: быстрый, который ничего не проверяет, и проверяющий, который при ошибке бросает исключение. Код курса выбирает между ними по месту: в аудиопотоке (audio thread)аудиопоток (audio thread) — Поток с повышенным приоритетом, который вызывает обработку звука для каждого блока. Каждый вызов должен закончиться до срока блока, поэтому в нём не выделяют память, не берут блокировки и не обращаются к диску. — быстрый способ с проверкой границ заранее, в остальном коде — тот, что удобнее читать. std::unique_ptr разобран в статье «Кто владеет объектом», std::atomic и очереди — в «C++ в аудиопотоке».

Заголовки стандартной библиотеки

Таблица 1 перечисляет стандартные заголовки, подключённые в tremolo_plugin.h.

Таблица 1. Стандартные заголовки, подключённые в tremolo_plugin.h, и то, что из них используется в плагине.
ЗаголовокЧто даёт
<vector>std::vector<T> — динамический массив, как list в Python и срез в Go. Поле lfoSamples в Tremolo.
<array>std::array<T, N> — массив фиксированного размера, заданного при компиляции. Поле lfos в Tremolo и хранилище StridedQueue.
<deque>std::deque<T> — двусторонняя очередь. Заголовок подключён, но в плагине и тестах complete не используется.
<memory>std::unique_ptr и std::shared_ptr; std::make_unique при создании параметров.
<functional>std::function<Сигнатура> — хранит любую вызываемую сущность, включая лямбду.
<algorithm>std::min, std::max, std::rotate, std::fill и другие алгоритмы; std::rotate и std::fill в StridedQueue.
<cmath>std::sin, std::abs, std::floor; triangle в Tremolo.
<ranges>std::views::iota и другие ленивые адаптеры диапазонов.
<span>std::span — вид на непрерывную память.

<optional> в этом списке нет, хотя std::optional в плагине используется: его подключает модуль juce_core в файле juce_StandardHeader.h. Цикл с std::views::iota разобран в разделе «Цикл по диапазону», std::span — в разделе «Массивы и std::span».

std::optional

std::optional<T> (C++17) хранит либо значение типа T, либо отметку, что значения нет. Он заменяет «особые» значения вроде -1 или 0, которые могли бы оказаться и настоящими данными. В Python то же выражают через None, в TypeScript — тип number | undefined, в Go — пару (value, ok). В отличие от указателя, optional не ссылается на объект в другом месте: значение и флаг лежат внутри самого объекта optional, и в куче (heap)куча (heap) — Динамическая память для объектов, созданных выражением new. Такой объект живёт до явного delete или до удаления его владельцем, например умным указателем. ничего не выделяется.

В плагине std::optional возвращают функции, у которых результата может не быть. juce::ToVar::convert превращает структуру с параметрами в дерево juce::var для JSON и возвращает std::optional<juce::var>: если какое-то поле преобразовать не удалось, результат пуст. Как JUCE доходит до этого результата, показано в разделе «Сохранение параметров: шаблон serialise».

C++
// JsonSerializer.cpp
const auto json = juce::ToVar::convert(from(parameters));

if (!json.has_value()) {
  return;
}

juce::JSON::writeToStream(output, *json,
                          juce::JSON::FormatOptions{}
                              .withSpacing(juce::JSON::Spacing::multiLine)
                              .withMaxDecimalPlaces(2));

Сначала has_value() проверяет, есть ли значение, и при пустом результате функция выходит. Только после этого *json достаёт сам juce::var и передаёт его в writeToStream.

Проверить и достать значение

Пустой optional получается конструктором по умолчанию или присваиванием std::nullopt. Есть ли значение, сообщают has_value() и оператор преобразования к bool. Он объявлен explicit, поэтому работает в условии if (json), но не даёт случайно сложить optional с числом. Достать значение можно так:

  • *opt возвращает ссылку на значение, opt->member — обращается к полю значения. Ни один из операторов не проверяет, есть ли значение.
  • opt.value() возвращает ссылку на значение, а у пустого optional бросает исключение std::bad_optional_access.
  • opt.value_or (x) возвращает значение или запасное x, если значения нет.
C++
std::optional<double> lastTimestampSeconds;                  // пусто: std::nullopt

const auto empty = lastTimestampSeconds.has_value();         // false
const auto fallback = lastTimestampSeconds.value_or (0.0);   // 0.0

lastTimestampSeconds = 0.016;                                // теперь значение есть
const auto seconds = *lastTimestampSeconds;                  // 0.016

if (lastTimestampSeconds)                                    // explicit operator bool
    lastTimestampSeconds = std::nullopt;                     // снова пусто

Присваивание lastTimestampSeconds = 0.016 кладёт значение в optional, а std::nullopt снова делает его пустым. Сравнение optional с обычным числом, например archive.getVersion() != 1, разобрано в разделе «getVersion()»: пустой optional не равен никакому значению.

Операторы * и -> без проверки

* и -> у optional — методы operator* и operator->, которые класс перегружает; со встроенными операторами указателя их роднит только запись. Синтаксис скопирован у указателя, чтобы optional читался так же: *json — само значение, parsedParameters->rate — его поле. Упрощённая модель показывает, что делают эти методы:

C++
// Упрощённая модель optional: значение и флаг лежат внутри объекта
template <typename Value>
class SimpleOptional
{
public:
    SimpleOptional() = default;
    SimpleOptional (Value newValue) : storage { newValue }, engaged { true } {}

    bool has_value() const noexcept { return engaged; }

    Value& operator*() noexcept { return storage; }    // флаг не проверяется
    Value* operator->() noexcept { return &storage; }  // флаг не проверяется

    Value& value()
    {
        if (! engaged)
            throw std::bad_optional_access {};

        return storage;
    }

private:
    Value storage {};  // в std::optional — память под Value без вызова конструктора
    bool engaged = false;
};

operator* возвращает ссылку на хранилище, operator-> — указатель на него, и ни один не смотрит на флаг engaged. Проверка есть только в value(). Настоящий std::optional устроен так же: оба оператора объявлены noexcept и не бросают исключений. Их проверка ложится на вызывающий код, а value() делает её сам ценой сравнения и возможного исключения. Методы has_value и value названы, как в стандартной библиотеке, а не по стилю примеров сайта.

Разыменование пустого optional — неопределённое поведение (undefined behavior): стандарт не говорит, что произойдёт, и компилятор вправе считать, что этого не бывает. Программа необязательно упадёт. При проверке с Apple clang 17 и libc++ без дополнительных флагов * у пустого std::optional<double> вернул 0.0, и программа продолжила работу с этим числом.

Ошибку ловят проверочные режимы стандартной библиотеки. В libc++ их включает макрос _LIBCPP_HARDENING_MODE: в режиме _LIBCPP_HARDENING_MODE_FAST та же программа аварийно останавливается, в режиме _LIBCPP_HARDENING_MODE_DEBUG ещё и печатает optional operator* called on a disengaged value. По умолчанию режим none, то есть проверок нет, если поставщик библиотеки не выбрал другой. В libstdc++ (GCC) операторы optional проверяет макрос _GLIBCXX_ASSERTIONS. C++26 вводит в стандарт понятие усиленной реализации (hardened implementation): если она включена, пустой optional у * и -> вызывает нарушение контракта и остановку программы, без неё поведение по-прежнему не определено. Включается ли такой режим, решает реализация.

optional в коде курса

Курс везде пишет одну пару строк: сначала has_value(), потом * или ->. Пустой результат здесь ожидаем: JSON может прийти от другой версии плагина, а у первого кадра ещё нет предыдущего. Для обычной ситуации исключение не нужно, достаточно ветки if. value() нужен там, где пустой optional означает ошибку в программе и её надо остановить. В функции noexcept, а в курсе так помечены методы обработки звука вроде Tremolo::process, исключение из value() до обработчика не дойдёт: программа завершится через std::terminate.

При загрузке состояния juce::FromVar::convert<SerializableParameters> возвращает std::optional<SerializableParameters>. После проверки поля структуры читаются через ->:

C++
// JsonSerializer.cpp
const auto parsedParameters =
    juce::FromVar::convert<SerializableParameters>(parsedResult);

if (!parsedParameters.has_value()) {
  return juce::Result::fail(
      "failed to parse parameters from JSON representation");
}

// ...
parameters.rate = parsedParameters->rate;
parameters.bypassed = parsedParameters->bypassed;

parsedParameters->rate значит то же, что (*parsedParameters).rate: operator-> возвращает указатель на хранимую структуру, а к нему компилятор применяет обычный ->.

Третий пример — поле std::optional<double> lastTimestampSeconds в LfoVisualizer. Компонент рисует кривую LFO (low-frequency oscillator, LFO)низкочастотный генератор (low-frequency oscillator, LFO) — Генератор медленно повторяющегося управляющего сигнала, которым изменяют параметры звука, например усиление, частоту или панораму. Общей строгой верхней границы частоты у LFO нет. и при каждом обновлении экрана получает время кадра. Сколько отсчётов добавить на график, он считает по разнице с предыдущим кадром, а у первого кадра предыдущего нет:

C++
// LfoVisualizer.cpp
void LfoVisualizer::updateSamplesQueue(double timestampSeconds) {
  if (!lastTimestampSeconds.has_value()) {
    lastTimestampSeconds = timestampSeconds;
    return;
  }

  // ...
  if (isBypassed()) {
    const auto secondsPassed = timestampSeconds - *lastTimestampSeconds;
    // ...
  }

  lastTimestampSeconds = timestampSeconds;
}

Поле объявлено без инициализатора, поэтому до первого кадра оно пусто. Первый вызов только запоминает время и выходит, следующие вычитают из нового времени предыдущее. Значение 0.0 вместо пустого optional здесь не подошло бы. Документация juce::VBlankAttachment обещает только, что время кадра в секундах монотонно растёт, но не говорит, от какого момента его отсчитывают. Разница с нулём могла бы оказаться любой, например временем с запуска системы.

Контейнеры: индекс с проверкой и без

Чем std::array отличается от std::vector и как передать их в функцию через std::span, разобрано в разделе «Массивы и std::span». У обоих контейнеров доступ к элементу устроен так же, как у optional. v[i] не проверяет индекс: обращение за пределами size() — неопределённое поведение, как выход за границу массива в C. v.at (i) сравнивает индекс с размером и при ошибке бросает std::out_of_range. В Python и Go проверяет любой индекс, в C++ проверку выбирают явно.

C++
std::array<float, 4> window { 0.0f, 0.5f, 1.0f, 0.5f };

window[4];      // неопределённое поведение: элемента 4 нет
window.at (4);  // бросает std::out_of_range

В плагине встречаются оба способа. Tremolo::processChannelwise вызывается из processBlock, объявлена noexcept и пишет в lfoSamples[i] без проверки. Границу она обеспечивает заранее: число отсчётов ограничено размером вектора через std::min, а jassert в отладочной сборке сообщает, если хост прислал блок больше обещанного.

C++
// Tremolo.h
const auto samplesToProcess = std::min(
    lfoSamples.size(), static_cast<size_t>(buffer.getNumSamples()));

// detect if the host is misbehaving; if this fails, then many more frames
// have been given for processing than declared in prepare()
jassert(samplesToProcess <= lfoSamples.size());

// generate LFO signal
for (const auto i : std::views::iota(0u, samplesToProcess)) {
  lfoSamples[i] = getNextLfoValue();
  lfoSampleFifo.push(lfoSamples[i]);
}

LfoVisualizer::samplesToPath строит кривую в потоке сообщений (message thread)поток сообщений (message thread) — Главный поток программы на JUCE: обрабатывает ввод, перерисовывает окна и выполняет таймеры. Срока блока у него нет, поэтому здесь создают и удаляют объекты и читают файлы. и читает точки через lfoSamplesToPlot.at(i). Метод at класса StridedQueue передаёт вызов std::array::at, поэтому неверный индекс там бросит std::out_of_range, и программа не прочитает чужую память.

Размер и ёмкость вектора

У вектора есть длина size() — сколько элементов в нём лежит, и ёмкость capacity() — под сколько элементов выделена память. resize (n) меняет длину, reserve (n) — только ёмкость. Если push_back или resize не помещаются в ёмкость, вектор выделяет новый блок в куче, переносит туда элементы и освобождает старый. После такого перевыделения указатель из data(), ссылки на элементы и итераторы указывают на освобождённую память. Поэтому Tremolo::prepare один раз задаёт размер lfoSamples с запасом, lfoSamples.resize(4u * static_cast<size_t>(expectedMaxFramesPerBlock)), а processChannelwise только пишет в готовые элементы и передаёт lfoSamples.data() в функции JUCE.

Алгоритмы

Функции из <algorithm> работают с диапазоном, заданным парой итераторов begin() и end(), и не зависят от вида контейнера. StridedQueue хранит последние точки графика LFO в std::array и при поступлении новых отсчётов сдвигает старые к началу вызовом std::rotate:

C++
// detail/StridedQueue.h
if (availableStridedCount < stridedElements.size()) {
  // rotate when not overwritten completely
  std::rotate(stridedElements.begin(),
              stridedElements.begin() + availableStridedCount,
              stridedElements.end());
}

std::rotate (first, middle, last) циклически сдвигает элементы: тот, на который указывает middle, становится первым, а элементы от first до middle переезжают в конец. Сдвиг выполняется обменом элементов внутри того же массива, новой памяти не нужно. Освободившиеся места в конце затем заполняются новыми точками, а pushBackZeros заполняет их нулями через std::fill. std::min и std::max возвращают меньшее и большее из двух значений одного типа, поэтому в pushBackZeros размеры сначала приводятся к int: std::max(0, static_cast<int>(stridedElements.size()) - static_cast<int>(availableStridedCount)).

Лямбды и std::function

Лямбда создаёт вызываемый объект прямо в месте использования, а std::function позволяет хранить такой объект в поле класса и вызывать позже. В редакторе плагина они работают вместе: редактор передаёт лямбды в LfoVisualizer, а тот хранит их в полях типа std::function.

Лямбды

Лямбда-выражение (lambda expression, C++11) — анонимная функция вида [захват](параметры) { тело }. В квадратных скобках перечислено, что она берёт из окружения: [this] — указатель на текущий объект, [x] — копию x, [&x] — ссылку на x, [=] и [&] — всё по значению или по ссылке. В отличие от замыканий Python и Go, захват по ссылке не продлевает жизнь объекта: если лямбда переживёт захваченную переменную, она обратится к уже освобождённой памяти.

В плагине лямбды связывают редактор с процессором и реагируют на события интерфейса:

C++
bypassButton.onClick = [this]() {
  bypassButton.setButtonText(bypassButton.getToggleState() ? "Bypassed" : "Off");
};

lfoVisualizer{
    [&p](juce::AudioBuffer<float>& b) { p.readAllLfoSamples(b); },
    [&p] { return p.getSampleRateThreadSafe(); },
    [&p] { return p.getParameterRefs().bypassed.get(); }},

Захват [&p] безопасен, потому что редактор живёт не дольше процессора: по правилам JUCE редактор удаляется раньше процессора. Лямбда [](auto phase) { ... } в Tremolo.h принимает параметр типа auto (C++14), то есть работает как шаблон: тип выводится при вызове. Такие параметры разобраны в разделе «auto в параметрах».

std::function

У каждой лямбды свой безымянный тип, поэтому поле класса нельзя объявить «типом этой лямбды». std::function<Сигнатура> (C++11) хранит любой вызываемый объект с подходящей сигнатурой: лямбду, обычную функцию или объект с operator(). Сигнатура записывается как тип функции: bool() — без параметров, возвращает bool. Как тип функции стоит в аргументе шаблона, показано в разделе «Типы и функции в аргументах». LfoVisualizer объявляет псевдонимы для трёх таких полей:

C++
// LfoVisualizer.h
using ReadAllLfoSamples = std::function<void(juce::AudioBuffer<float>&)>;
using GetCurrentSampleRate = std::function<double()>;
using IsBypassed = std::function<bool()>;

Конструктор принимает три объекта по значению и переносит их в поля через std::move, чтобы не копировать захваченные данные ещё раз:

C++
// LfoVisualizer.cpp
LfoVisualizer::LfoVisualizer(ReadAllLfoSamples readSamples,
                             GetCurrentSampleRate getRate,
                             IsBypassed getIsBypassed)
    : readAllLfoSamples{std::move(readSamples)},
      getCurrentSampleRate{std::move(getRate)},
      isBypassed{std::move(getIsBypassed)} {

Дальше поля вызываются как обычные функции: getCurrentSampleRate(), isBypassed(), readAllLfoSamples(buffer). LfoVisualizer не знает ни класса процессора, ни того, как устроены лямбды: ему достаточно сигнатур, и вместо процессора ему можно передать любые функции с теми же сигнатурами. Тот же приём использует сама JUCE: конструктор juce::VBlankAttachment принимает std::function<void (double)>, и LfoVisualizer передаёт в него лямбду [this](double timestampSeconds) { update(timestampSeconds); }. В отличие от optional, пустой std::function при вызове проверяет себя сам и бросает std::bad_function_call. Присваивание лямбды с большим захватом может выделить память в куче, поэтому std::function задают заранее, а не в аудиопотоке: этот случай есть в таблице «Скрытые выделения».

Самопроверка

Что будет, если убрать из updateSamplesQueue проверку has_value(), а в первом кадре окажется включён bypass?

Ветка isBypassed() вычислит *lastTimestampSeconds у пустого optional. Это неопределённое поведение: без проверочного режима библиотеки программа, скорее всего, возьмёт какое-то число, например 0.0, получит огромный промежуток времени и передаст в pushBackZeros огромное число нулевых отсчётов. В режиме проверок libc++ программа аварийно остановится на этой строке.

Чем parsedParameters->rate отличается от parsedParameters.value().rate?

Если значение есть, ничем: оба читают поле rate хранимой структуры. У пустого optional первый вариант даёт неопределённое поведение, второй бросает std::bad_optional_access. В deserialize пустой результат уже отсеян проверкой has_value(), поэтому повторная проверка внутри value() не нужна.

Почему processChannelwise пишет lfoSamples[i], а не lfoSamples.at(i)?

Метод объявлен noexcept и работает в аудиопотоке. Если at бросит std::out_of_range, исключение не сможет покинуть метод, и программа завершится через std::terminate. Вместо проверки на каждом отсчёте метод один раз ограничивает число отсчётов через std::min, после чего каждый индекс заведомо меньше lfoSamples.size().

Что станет с указателем lfoSamples.data(), если в processChannelwise вызвать lfoSamples.push_back(0.0f)?

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

Источники

  • Alex. Learn C++. Уроки 12.15 «std::optional», 16.3, 16.10, 18.3, 20.1, 20.6 и 20.7. в списке литературы → — std::optional и неопределённое поведение при разыменовании пустого, value() и value_or(); operator[] без проверки границ и at() с std::out_of_range; длина, ёмкость и перевыделение вектора; алгоритмы; std::function в разделе Using std::function; лямбды и захват.
  • cppreference: std::optional, operator*, operator-> (неопределённое поведение до C++26, нарушение контракта в усиленной реализации C++26, noexcept по LWG 2762), value, value_or, std::vector::operator[], push_back (инвалидация при перевыделении), std::rotate, std::function::operator() и Standard library hardening.
  • LLVM. libc++ Hardening Modes — режимы none, fast, extensive, debug, макрос _LIBCPP_HARDENING_MODE, режим none по умолчанию, std::optional как контейнер не больше чем из одного элемента. Поведение пустого optional без флагов и в режимах fast и debug проверено программой с Apple clang 17.
  • GCC. libstdc++ Macros — _GLIBCXX_ASSERTIONS; исходник optional GCC 14: operator* и operator-> вызывают _M_get() с __glibcxx_assert.
  • JUCE 8.0.12. juce_JSONSerialisation.h — ToVar::convert возвращает std::optional<var>, FromVar::convert — std::optional<T>; juce_StandardHeader.h — подключение <optional>; juce_VBlankAttachment.h — конструктор с std::function<void (double)> и монотонно растущее время кадра в секундах.
  • JUCE. tremolo-juce-course — фрагменты кода из папки complete: tremolo_plugin.h, JsonSerializer.cpp, LfoVisualizer.h, LfoVisualizer.cpp, Tremolo.h, detail/StridedQueue.h, PluginEditor.h и PluginEditor.cpp.

Связи

Поиск

Ищем по названиям, тексту статей и английским терминам. Например: «частота дискретизации», «Nyquist», «аналоговый сигнал».