Эта статья продолжает «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.
| Заголовок | Что даёт |
|---|---|
<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».
// 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, если значения нет.
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 — его поле. Упрощённая модель показывает, что делают эти методы:
// Упрощённая модель 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>. После проверки поля структуры читаются через ->:
// 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 нет. и при каждом обновлении экрана получает время кадра. Сколько отсчётов добавить на график, он считает по разнице с предыдущим кадром, а у первого кадра предыдущего нет:
// 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++ проверку выбирают явно.
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 в отладочной сборке сообщает, если хост прислал блок больше обещанного.
// 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:
// 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, захват по ссылке не продлевает жизнь объекта: если лямбда переживёт захваченную переменную, она обратится к уже освобождённой памяти.
В плагине лямбды связывают редактор с процессором и реагируют на события интерфейса:
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 объявляет псевдонимы для трёх таких полей:
// LfoVisualizer.h
using ReadAllLfoSamples = std::function<void(juce::AudioBuffer<float>&)>;
using GetCurrentSampleRate = std::function<double()>;
using IsBypassed = std::function<bool()>;Конструктор принимает три объекта по значению и переносит их в поля через std::move, чтобы не копировать захваченные данные ещё раз:
// 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.