Эта статья продолжает «C++ в плагине: классы, конструкторы и время жизни» и разбирает шаблоны (templates), которые встречаются в плагине (plug-in)плагин (plug-in) — Программный модуль эффекта или инструмента, который загружается в DAW или другую программу-хост и обрабатывает переданные ему аудиоблоки. тремоло (tremolo)тремоло (tremolo) — Эффект периодического изменения амплитуды аудиосигнала. Скорость пульсации задаёт частота управляющего генератора, а диапазон усиления зависит от глубины и способа её преобразования. из официального курса JUCE: шаблоны функций и классов, параметры-типы и параметры-числа, auto в параметрах, требования к типу-аргументу, вариативные шаблоны и вывод аргументов шаблона класса. Каждое понятие показано на фрагменте готовой версии из папки complete (JUCE 8.0.12, C++23); на полях стоят ссылки на уроки Learn C++ и справочник cppreference.
Второй раздел разбирает целиком один шаблон — serialise, через который плагин сохраняет параметры в JSON и читает их обратно. В нём сходятся правила первого раздела: параметр-тип, требования к типу, вариативный шаблон и пересылающие ссылки.
Шаблоны
Шаблон (template) позволяет один раз описать алгоритм или контейнер для разных типов. По назначению он близок к дженерикам других языков. juce::AudioBuffer<float> — буфер отсчётов (sample)отсчёт (sample) — Значение сигнала в один момент дискретизации, одно число. В звукозаписи его также называют сэмплом. float, AudioBuffer<double> — буфер double; логика класса общая, а тип элементов выбирается при использовании. Компилятор инстанцирует (instantiate) шаблон для каждого набора аргументов, поэтому определение шаблона должно быть видно в месте использования, и шаблонный код обычно лежит в заголовках.
Шаблон принимает и значения: в std::array<juce::dsp::Oscillator<float>, 2u> lfos второй аргумент задаёт число элементов. Плагин объявляет и собственные шаблоны. SampleFifo<SampleType> передаёт отсчёты LFO (low-frequency oscillator, LFO)низкочастотный генератор (low-frequency oscillator, LFO) — Генератор медленно повторяющегося управляющего сигнала, которым изменяют параметры звука, например усиление, частоту или панораму. Общей строгой верхней границы частоты у LFO нет. из аудиопотока (audio thread)аудиопоток (audio thread) — Поток с повышенным приоритетом, который вызывает обработку звука для каждого блока. Каждый вызов должен закончиться до срока блока, поэтому в нём не выделяют память, не берут блокировки и не обращаются к диску. в интерфейс, а StridedQueue<T, Size> хранит точки графика:
template <typename SampleType>
class SampleFifo { /* ... */ };
template <typename T, size_t Size>
class StridedQueue { /* ... */ };Для функций параметры шаблона обычно выводятся из аргументов:
template <typename Number>
Number twice (Number value)
{
return value + value;
}
auto integer = twice (3); // Number выводится как int
auto sample = twice (0.5f); // Number выводится как float
auto precise = twice<double> (3); // Number задан явно, аргумент преобразуется в doubleЯвный аргумент нужен, когда вывести тип не из чего. std::make_unique<juce::AudioParameterFloat>(...) передаёт свои аргументы конструктору, и по ним не видно, какой объект создать. juce::FromVar::convert<SerializableParameters>(parsedResult) получает juce::var, а тип нужен только для результата. Парная ей juce::ToVar::convert(from(parameters)) выводит тип из аргумента. static_cast<int>(x) только похож на шаблон: это встроенная операция языка.
Шаблон класса изнутри
SampleFifo целиком, вместе с телами методов, объявлен в заголовке SampleFifo.h. Имя SampleType внутри класса обозначает тип, который подставят при использовании. Отдельной строки template перед методами нет: методы, определённые внутри шаблона класса, пользуются его параметрами.
// SampleFifo.h, сокращено
template <typename SampleType>
class SampleFifo {
public:
void push(SampleType sample) { /* ... */ }
void popAll(juce::AudioBuffer<SampleType>& bufferToFill) { /* ... */ }
// ...
private:
static constexpr auto initialCapacity = 1024;
juce::AbstractFifo fifo{initialCapacity};
juce::AudioBuffer<SampleType> buffer{1, initialCapacity};
};Поле SampleFifo<float> lfoSampleFifo; в классе Tremolo заставляет компилятор создать класс, где SampleType заменён на float: метод push(float), поле juce::AudioBuffer<float> buffer.
Второй параметр StridedQueue — не тип, а число (non-type template parameter). Size передаётся дальше в std::array<T, Size>, поэтому число точек графика становится частью типа, а массив лежит внутри объекта, без выделения памяти в куче (heap)куча (heap) — Динамическая память для объектов, созданных выражением new. Такой объект живёт до явного delete или до удаления его владельцем, например умным указателем..
// detail/StridedQueue.h, сокращено
template <typename T, size_t Size>
class StridedQueue {
// ...
private:
std::array<T, Size> stridedElements{};
size_t elementIndex{0u};
size_t stride{1u};
};Значение такого параметра должно быть известно при компиляции. В LfoVisualizer оно объявлено как static constexpr auto pointsOnPath = 22050u; и передано в поле detail::StridedQueue<float, pointsOnPath> lfoSamplesToPlot;. Обычная переменная на этом месте не подойдёт:
auto points = 22050u;
detail::StridedQueue<float, points> queue; // ошибка: points не константа времени компиляцииКаждый набор аргументов даёт отдельный класс. Тест берёт StridedQueue<int, 5>: на пяти целых проще проверить, какие элементы остались в очереди. StridedQueue<int, 5> и StridedQueue<float, 22050> — два не связанных между собой типа, присвоить объект одного другому нельзя. В тесте testee.pushBack(std::vector{1, 0, 0, 2, ...}) компилятор выводит std::vector<int>, и вектор неявно превращается в параметр std::span<const int>. Запись T(0) в методе pushBackZeros даёт ноль нужного типа: 0.0f для float, 0 для int.
Почему detail::StridedQueue<float, points> не компилируется, если points — обычная переменная auto points = 22050u?
Size — параметр шаблона, и его значение входит в тип: от него зависит размер поля std::array<T, Size>, то есть размер всего объекта. Тип должен быть известен при компиляции, а значение обычной переменной существует только во время выполнения. Подходит константа constexpr, как pointsOnPath, или литерал.
Типы и функции в аргументах
Аргументом шаблона бывает и тип без данных, который выбирает поведение. Во втором аргументе juce::SmoothedValue<float, juce::ValueSmoothingTypes::Linear> пустая структура Linear задаёт линейное сглаживание: значение меняется на одну и ту же величину на каждом отсчёте. Multiplicative выбрала бы изменение в одно и то же число раз. BypassTransitionSmoother пишет короче, juce::LinearSmoothedValue<float>. Это шаблон-псевдоним (alias template) того же класса:
// juce_SmoothedValue.h
template <typename FloatType>
using LinearSmoothedValue = SmoothedValue <FloatType, ValueSmoothingTypes::Linear>;juce::MathConstants<float>::halfPi — статическая константа шаблона структуры: внутри записано static constexpr FloatType pi = static_cast<FloatType> (3.141592653589793238L); и так же для других констант. Аргумент float даёт π сразу в нужном типе, и в выражении с float нет перехода к double.
Аргументом бывает и тип функции:
// LfoVisualizer.h
using ReadAllLfoSamples = std::function<void(juce::AudioBuffer<float>&)>;Запись void(juce::AudioBuffer<float>&) описывает функцию, которая ничего не возвращает и принимает ссылку на буфер. Так LfoVisualizer объявляет, какой вызов ему нужен от процессора, и лямбды из PluginEditor подходят под эту сигнатуру. juce::dsp::Oscillator<float> хранит генератор формы волны так же, в std::function<float (float)>. Поэтому лямбда [](auto phase) { ... } из Tremolo.h при создании осциллятора получает параметр типа float.
auto в параметрах
Функция addParameterToProcessor из Parameters.cpp объявлена с параметром auto parameter. С C++20 это сокращённая запись шаблона функции (abbreviated function template): каждый параметр auto становится отдельным параметром шаблона. Её объявление равносильно такому:
template <typename Pointer>
auto& addParameterToProcessor (juce::AudioProcessor& processor, Pointer parameter);Три функции create…Parameter передают в неё std::unique_ptr<juce::AudioParameterFloat>, std::unique_ptr<juce::AudioParameterBool> и std::unique_ptr<juce::AudioParameterChoice>. Компилятор создаёт три версии функции, и тип результата auto& в каждой выводится из return result;: AudioParameterFloat&, AudioParameterBool&, AudioParameterChoice&. Лямбда с параметром auto (C++14) устроена так же: шаблоном становится её оператор вызова, о чём сказано в разделе «Лямбды» статьи о стандартной библиотеке.
Требования к типу
Шаблон не перечисляет, что должен уметь тип-аргумент. Требования следуют из тела: что тело вызывает у параметра, то у типа и должно быть. Компилятор проверяет это при инстанцировании, для конкретного типа:
// Шаблон требует от Meter метод getLevel(), но нигде этого не объявляет
template <typename Meter>
float readLevel (const Meter& meter)
{
return meter.getLevel();
}
struct PeakMeter
{
float getLevel() const { return peak; }
float peak = 0.5f;
};
auto level = readLevel (PeakMeter {}); // компилируется: метод есть
auto wrong = readLevel (0.5f); // ошибка: у float нет getLevel()Пока шаблон не использован, выражение meter.getLevel() полностью не проверяется: тип Meter неизвестен, и смысл выражения зависит от него (dependent name). Ошибка из последней строки указывает на return meter.getLevel(); внутри шаблона, а строки in instantiation of в сообщении компилятора ведут к вызову, который её вызвал. Это похоже на утиную типизацию Python, только несоответствие обнаруживается при сборке. В Go такие требования записывают интерфейсом. В C++20 для этого есть концепты (concepts), но код курса и сериализация JUCE их не используют. По этому правилу компилируется и archive.getVersion() в методе serialise из раздела «Сохранение параметров».
Вариативные шаблоны, CTAD и span
Функция juce::ignoreUnused, о которой шла речь в «Основах C++ для JUCE», написана как вариативный шаблон (C++11): пакет параметров typename... Types принимает любое число аргументов любых типов. Тело у неё пустое:
template <typename... Types>
void ignoreUnused(Types&&...) noexcept {}С C++17 компилятор выводит и аргументы шаблона класса (class template argument deduction, CTAD). Поэтому в LfoVisualizer.cpp записано std::span{...} без <const float>:
lfoSamplesToPlot.pushBack(std::span{
buffer.getReadPointer(0), static_cast<size_t>(buffer.getNumSamples())});std::span (C++20) — «вид» на непрерывный кусок памяти: указатель и длина, без владения данными. Это аналог среза в Go без возможности расти или memoryview в Python. Через него в pushBack передаются указатель на отсчёты канала и их число, а сами отсчёты не копируются.
Сохранение параметров: шаблон serialise
Хост сохраняет состояние плагина в проекте: вызывает getStateInformation, и PluginProcessor передаёт параметры в JsonSerializer::serialize. Тот записывает их в JSON такого вида (ожидаемый результат из теста JsonSerializerTest.cpp):
{
"__version__": 1,
"pluginName": "Tremolo",
"modulationRateHz": 10.0,
"bypassed": true,
"modulationWaveform": "Triangle"
}Преобразование в JSON и обратно выполняет JUCE, но список полей ей сообщает сам тип. В JsonSerializer.cpp для этого объявлена структура SerializableParameters:
// JsonSerializer.cpp
struct SerializableParameters {
float rate;
bool bypassed;
juce::String waveform;
static constexpr auto marshallingVersion = 1;
template <typename Archive, typename T>
static void serialise(Archive& archive, T& p) {
using namespace juce;
if (archive.getVersion() != 1) {
return;
}
std::string pluginName = TREMOLO_PLUGIN_NAME;
archive(named("pluginName", pluginName));
if (pluginName != TREMOLO_PLUGIN_NAME) {
return;
}
archive(named("modulationRateHz", p.rate), named("bypassed", p.bypassed),
named("modulationWaveform", p.waveform));
}
};Отдельная структура нужна, потому что Parameters хранит ссылки на объекты параметров процессора, а в JSON попадают значения: число, флаг и название формы волны. Функция from() копирует их в SerializableParameters, и результат передаётся в JUCE:
// JsonSerializer::serialize
const auto json = juce::ToVar::convert(from(parameters));
// JsonSerializer::deserialize
const auto parsedParameters =
juce::FromVar::convert<SerializableParameters>(parsedResult);Кто вызывает serialise
Ни одна строка плагина не вызывает serialise по имени: его вызывает JUCE внутри ToVar::convert и FromVar::convert. Документация juce::SerialisationTraits описывает договор: у типа есть константа marshallingVersion и либо один шаблон serialise (Archive&, Item&), либо пара load и save. Договор можно выполнить и снаружи типа, специализацией шаблона juce::SerialisationTraits<T>; так JUCE сама поддерживает std::vector, std::optional, std::string и juce::StringArray. Такая схема, в которой тип перечисляет свои поля, а разные архивы выполняют с ними разные операции, — паттерн «посетитель»; он разобран в статье «Паттерны проектирования в JUCE».
Наличие этих членов JUCE проверяет при компиляции. Она пробует вызвать T::serialise с пробным архивом juce::detail::DummyArchive и через if constexpr (C++17) выбирает подходящую ветку; сам вызов при этом не выполняется. Если у типа нет ни marshallingVersion, ни функций сериализации, сборка остановится на static_assert с сообщением No serialisation function found or marshallingVersion unset.
Archive и T: одна функция для записи и чтения
JUCE подставляет параметры шаблона serialise по-разному в зависимости от направления. Типы и значения ниже проверены на JUCE 8.0.12.
- Сохранение,
ToVar::convert.Archive—juce::ToVar::Visitor,T—const SerializableParameters.getVersion()возвращаетmarshallingVersion, то есть 1;archive(named(...))добавляет свойство в объект JSON. - Загрузка,
FromVar::convert<SerializableParameters>.Archive—juce::FromVar::Visitor,T—SerializableParameters.getVersion()возвращает значение"__version__"из JSON илиstd::nullopt;archive(named(...))читает свойство и записывает его в поле. - Проверка договора при компиляции.
Archive—juce::detail::DummyArchive,T—SerializableParameters; вызов не выполняется.
Visitor — закрытые вложенные классы ToVar и FromVar, в своём коде их имя не написать. Шаблону serialise имя и не нужно: он примет любой тип, у которого есть getVersion() и оператор вызова, по правилу из раздела «Требования к типу».
Параметр T нужен из-за константности. При сохранении JUCE передаёт объект по const&, ведь запись в JSON не должна его менять. При загрузке объект передаётся по обычной ссылке, чтобы архив заполнил поля. Параметр типа SerializableParameters& не принял бы константный объект, а шаблон по T принимает оба варианта, и одно тело описывает и запись, и чтение.
named — шаблон функции JUCE с двумя перегрузками. Обе возвращают Named<T>: структуру из имени std::string_view name и ссылки T& value. Значение при этом не копируется.
// juce_Serialisation.h
template <typename T> constexpr auto named (std::string_view c, T& t) { return Named<T> { c, t }; }
template <typename T> constexpr auto named (std::string_view c, const T& t) { return Named<const T> { c, t }; }При сохранении p.rate — поле константного объекта, и подходит вторая перегрузка: получается Named<const float>. При загрузке выбирается первая, Named<float>, и через ссылку архив записывает прочитанное число прямо в поле.
getVersion()
getVersion() — метод класса архива: оба Visitor объявляют std::optional<int> getVersion() const. При сохранении он возвращает версию, с которой пишется объект, то есть marshallingVersion, и ToVar записывает её в JSON как "__version__": 1. При загрузке метод возвращает значение "__version__" из JSON, а если такого свойства нет — std::nullopt.
archive.getVersion() != 1 сравнивает std::optional<int> с числом. Результат true, если значения нет или оно отличается от 1, и тогда serialise выходит, не читая полей. FromVar::convert всё равно возвращает объект: его поля остаются нулевыми, waveform — пустой строкой. deserialize отклоняет такой результат, потому что пустого названия нет в списке форм волны, и параметры плагина не меняются.
archive(a, b, c)
archive(named(...)) вызывает объект как функцию. У обоих Visitor перегружен оператор вызова operator(), и он объявлен вариативным шаблоном:
// juce_JSONSerialisation.h, ToVar::Visitor и FromVar::Visitor
template <typename... Ts>
void operator() (Ts&&... ts)
{
(visit (std::forward<Ts> (ts)), ...);
}typename... Ts— пакет параметров: вызов с одним аргументом даёт один тип, с тремя — три.Ts&&у параметра шаблона — пересылающая ссылка (forwarding reference). Она принимает и переменную, и временный объект. Результатnamed(...)временный, и обычная ссылкаTs&к нему не привязалась бы.(visit (std::forward<Ts> (ts)), ...)— выражение свёртки (fold expression, C++17) по оператору «запятая». Компилятор разворачивает его вvisit(a), visit(b), visit(c), и вызовы выполняются слева направо.std::forwardпередаёт каждый аргумент дальше в том виде, в каком он пришёл: временный остаётся временным.
visit перегружен для разных аргументов. Для Named<T> версия ToVar добавляет в объект JSON свойство с именем name и значением value. Версия FromVar ищет свойство с этим именем и записывает его в value; если свойства нет или его тип не подходит, загрузка помечается неудачной, и convert возвращает пустой std::optional. Поэтому archive(a, b, c) делает то же, что archive(a); archive(b); archive(c);, а свойства в JSON идут в порядке аргументов.
Переменная pluginName работает в обе стороны. При сохранении архив пишет в JSON её начальное значение — название плагина из макроса TREMOLO_PLUGIN_NAME, который задаёт CMake («Макросы из CMake»). При загрузке архив заменяет значение переменной прочитанным из JSON. Если состояние сохранил другой плагин, названия не совпадут, serialise выйдет до чтения параметров, и deserialize отклонит результат, как при чужой версии.
Почему serialise принимает T&, а не SerializableParameters&?
При сохранении JUCE передаёт объект как константный, и T выводится как const SerializableParameters. Ссылка SerializableParameters& к константному объекту не привязывается. Шаблон принимает и константный объект при записи, и изменяемый при чтении, поэтому одна функция описывает оба направления.
Что вернёт archive.getVersion() при загрузке JSON без свойства __version__, и что станет с параметрами плагина?
std::nullopt. Сравнение std::nullopt != 1 истинно, и serialise выходит, не читая полей. FromVar::convert вернёт объект с нулевой частотой и пустым названием формы волны, deserialize не найдёт пустое название в списке и вернёт ошибку, не меняя параметры.
Посетитель и стандартная библиотека
Схема, в которой serialise перечисляет поля, а архивы ToVar и FromVar выполняют с ними разные операции, — паттерн «посетитель»; он разобран в статье «Паттерны проектирования в JUCE». Типы стандартной библиотеки из этой статьи — std::optional, std::function и лямбды — подробно описаны в «Стандартной библиотеке C++ в плагине».
Источники
- Alex. Learn C++. Главы 11.6–11.9, 13.13–13.15, 15.5, 26.2 и 26.4. в списке литературы → — шаблоны функций и классов, их инстанцирование, параметры-значения, псевдонимы и специализация; 12.15 —
std::optional; 21.10 — оператор вызова. - cppreference: std::span и Class template argument deduction — семантика и стандарты появления.
- cppreference: Dependent names, Parameter pack, Fold expressions, Forwarding references, std::forward и сравнение std::optional со значением — правила, на которых построен вызов
archive(...)и проверка версии. - JUCE. SerialisationTraits — договор сериализации:
marshallingVersion,serialiseилиloadиsave,getVersion(); исходники 8.0.12 juce_Serialisation.h (Named,named,DummyArchive, выбор функции черезif constexpr) и juce_JSONSerialisation.h (ToVar::Visitor,FromVar::Visitor, ихoperator()иgetVersion()); juce_SmoothedValue.h —ValueSmoothingTypesиLinearSmoothedValue. Поведениеserialiseпри сохранении, загрузке и отсутствии версии проверено тестовой программой с JUCE 8.0.12. - JUCE. tremolo-juce-course — фрагменты кода из папки
complete:Tremolo.h,Parameters.cpp,PluginProcessor.cpp,JsonSerializer.cpp,SampleFifo.h,detail/StridedQueue.h,LfoVisualizer.h,LfoVisualizer.cpp,test/source/JsonSerializerTest.cppиtest/source/detail/StridedQueueTest.cpp.