Основы C++ для JUCE: сборка, типы, функции и указатели

Что нужно знать о синтаксисе C++, чтобы читать код плагина на JUCE?

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

Фрагменты взяты из готовой версии плагина в папке complete репозитория tremolo-juce-course (JUCE 8.0.12). Они приведены в оформлении репозитория, а собственные примеры сайта — в стиле JUCE. Короткие учебные примеры без JUCE проверены компиляцией: Apple clang 17, режим C++23.

C++ развивается стандартами: C++98, C++11, C++14, C++17, C++20, C++23. Рядом с возможностями, которые появились после 1998 года, в скобках указан стандарт. Плагин собирается в режиме C++23, поэтому доступно всё перечисленное.

Классы, конструкторы, наследование и время жизни объектов (object lifetime)время жизни объекта (object lifetime) — Промежуток от окончания конструктора до начала деструктора, в который к объекту можно обращаться. Зависит от того, где объект создан: в блоке, внутри другого объекта или в куче. разобраны во второй части, «C++ в плагине: классы, конструкторы и время жизни», шаблоны — в «C++ в плагине: шаблоны и сериализация параметров». Владение объектами (ownership)владение объектом (ownership) — Ответственность за удаление объекта. Владелец удаляет объект, наблюдатели только пользуются им через указатель или ссылку и должны перестать обращаться к нему до удаления. в куче (heap)куча (heap) — Динамическая память для объектов, созданных выражением new. Такой объект живёт до явного delete или до удаления его владельцем, например умным указателем. — в отдельной статье «Кто владеет объектом», а правила для аудиопотока (audio thread)аудиопоток (audio thread) — Поток с повышенным приоритетом, который вызывает обработку звука для каждого блока. Каждый вызов должен закончиться до срока блока, поэтому в нём не выделяют память, не берут блокировки и не обращаются к диску. — в «C++ в аудиопотоке».

Как код попадает в компилятор

Перед компиляцией каждый файл проходит через препроцессор (preprocessor). Он обрабатывает строки, которые начинаются с #, и работает с текстом: типов и областей видимости он не знает. Директива #include вставляет содержимое другого файла на место директивы, как копирование текста. Импорт модулей, как в Python или Go, появился только в C++20, и код плагина его не использует.

C++
#include <juce_core/juce_core.h>   // угловые скобки: системные пути и пути библиотек
#include "tremolo_plugin.h"        // кавычки: сначала рядом с текущим файлом

Файлы стандартной библиотеки подключаются без расширения: <vector>, <memory>, <ranges>, <cmath>. Заголовок, который может попасть в одну единицу компиляции дважды, начинается с #pragma once. Строка гарантирует, что файл будет вставлен один раз. Раньше для этого писали «стражи включения» #ifndef, #define, #endif; #pragma once в стандарт не входит, но поддерживается всеми основными компиляторами.

Объявления («что существует») принято писать в заголовок .h, определения («как работает») — в файл реализации .cpp. В PluginProcessor.h объявлен метод getName(), а его тело находится в PluginProcessor.cpp. Каждый .cpp вместе со всем, что он подключил, компилируется отдельно и называется единицей трансляции (translation unit). Затем компоновщик (linker) собирает единицы в один бинарный файл.

Модуль JUCE и «unity build»

Заголовки из include/Tremolo/ не подключают JUCE, а файлы из source/ не подключают свои заголовки. Это работает благодаря формату модуля JUCE. Заголовок tremolo_plugin.h подключает JUCE, стандартную библиотеку и все заголовки проекта в нужном порядке:

C++
// tremolo_plugin.h
#include <juce_dsp/juce_dsp.h>
#include <vector>
#include <memory>
// ...
#include "include/Tremolo/Parameters.h"
#include "include/Tremolo/Tremolo.h"
#include "include/Tremolo/PluginProcessor.h"

А tremolo_plugin.cpp подключает этот заголовок и затем вставляет через #include все файлы реализации:

C++
// tremolo_plugin.cpp
#include "tremolo_plugin.h"
#include <TremoloPluginAssets.h>
#include "source/LfoVisualizer.cpp"
#include "source/Parameters.cpp"
#include "source/PluginProcessor.cpp"

В итоге весь плагин компилируется как одна единица трансляции. Такой приём называют unity build. Для него важен порядок #include в tremolo_plugin.h: класс должен быть объявлен выше места, где его используют. Например, Tremolo.h стоит раньше PluginProcessor.h, который хранит поле типа Tremolo. А новый заголовок или .cpp нужно добавить в tremolo_plugin.h или tremolo_plugin.cpp, иначе компилятор его не увидит.

Unity build применяют ради скорости полной сборки: тяжёлые заголовки вроде juce_dsp.h компилятор разбирает один раз, а не в каждом .cpp. Модулю JUCE приём нужен ещё и для подключения: модуль добавляется в проект одним .cpp-файлом, который внутри вставляет остальные, и системе сборки не нужен список его исходников. Правка любого файла перекомпилирует всю единицу трансляции, поэтому после мелкого изменения сборка идёт дольше, чем при раздельной компиляции. Имена с внутренней связью и макросы из одного файла видны в следующих (см. анонимное пространство имён). Файл может компилироваться только потому, что нужный ему заголовок подключён выше в общей единице, и вне её он перестанет собираться.

Обычная схема — раздельная компиляция: каждый .cpp сам подключает нужные заголовки, и правка одного файла пересобирает только его. Повторный разбор общих заголовков в такой схеме сокращают предкомпилированные заголовки (precompiled headers), в CMake их задаёт команда target_precompile_headers (CMake 3.16). Склеивать исходники CMake тоже умеет сам: свойство цели UNITY_BUILD (CMake 3.16) объединяет файлы в пакеты во время сборки, а исходный код остаётся пригодным для раздельной компиляции. Модули C++20 (import вместо #include) решают ту же задачу средствами языка: интерфейс модуля компилируется один раз, а макросы импортирующего файла на него не влияют.

Макросы из CMake

C++
const juce::String PluginProcessor::getName() const {
  return TREMOLO_PLUGIN_NAME;
}

TREMOLO_PLUGIN_NAME нигде в C++ не определён. Его передаёт компилятору CMake: в CMakeLists.txt проекта стоит TREMOLO_PLUGIN_NAME="${TREMOLO_PLUGIN_NAME}" в target_compile_definitions, что равносильно флагу -DTREMOLO_PLUGIN_NAME="Tremolo". Препроцессор заменит имя строкой "Tremolo".

Комментарии

C++
// однострочный комментарий

/* многострочный
   комментарий */

/** Комментарий документации (Doxygen).
 *  @code ... @endcode — пример кода внутри документации
 */

Стандарт языка знает только // и /* */. Форма /** */ обычный комментарий для компилятора, но генератор документации Doxygen и среды разработки считают её описанием следующего объявления. Команды @param, @return, @code относятся к разметке Doxygen. В этой форме написано описание класса BypassTransitionSmoother. Комментарий после закрывающей скобки, например } // namespace tremolo, подсказывает, какой блок закрыт; это соглашение стиля, и clang-format умеет его проверять.

Типы и литералы

Таблица 1 показывает встроенные типы, которые чаще всего встречаются в коде плагина. Отсчёты (sample)отсчёт (sample) — Значение сигнала в один момент дискретизации, одно число. В звукозаписи его также называют сэмплом. аудио хранятся во float: для звука хватает точности, а данных вдвое меньше, чем с double. Частота дискретизации (sample rate)частота дискретизации (sample rate) — Число отсчётов в каждом канале за одну секунду, то же число кадров аудиоданных в секунду. Измеряется в герцах: 44 100 Гц означает 44 100 отсчётов на канал в секунду. и время — в double. Подробнее о представлении отсчётов — в статье «Аудиоданные».

Таблица 1. Встроенные типы в коде плагина. Размеры int, float и double указаны для типичных платформ: стандарт задаёт только минимальные диапазоны.
ТипЧто этоПример из кода
intцелое со знаком, обычно 32 битаint expectedMaxFramesPerBlock
floatчисло с плавающей точкой, обычно 32 битаjuce::AudioBuffer<float>
doubleчисло с плавающей точкой, обычно 64 битаdouble sampleRate
booltrue или falsebool acceptsMidi() const
voidзначения нетvoid releaseResources()

std::size_t

size_t не встроенный тип и не ключевое слово. Это имя беззнакового целого типа, выбранного реализацией. Полное имя std::size_t объявлено в заголовке <cstddef>. Тип предназначен для размеров объектов и индексов контейнеров стандартной библиотеки. В плагине он нужен на границе с JUCE: библиотека возвращает количество отсчётов как int, а std::vector и std::span ждут size_t.

C++
lfoSamples.resize(4u * static_cast<size_t>(expectedMaxFramesPerBlock));

Преобразование само знак не проверяет: отрицательное число, превращённое в size_t, станет огромным положительным. Поэтому оно записано явно, о чём подробнее в разделе про static_cast.

Литералы

Тип числового литерала задаёт его запись: 10 — int, 1.0 — double, 1.0f и 0.4f — float, 4u — unsigned int, 1uz (C++23) — size_t; последний встречается в StridedQueue.h. Суффикс f важен: без него литерал 0.4 имеет тип double, и при передаче в float компилятор предупредит о сужении. Его пишут даже у нуля: 0.f — то же, что 0.0f.

Строка в кавычках, например "Tremolo", не объект-строка, как в Python, а массив char с нулевым байтом на конце. Объекты строк — это классы std::string и juce::String; из литерала они создаются неявно. Сырой литерал R"(...)" (C++11) отключает экранирование и сохраняет переносы строк, с префиксом u8 — в кодировке UTF-8. Так в тестах плагина записан ожидаемый JSON:

C++
const juce::String expectedOutput =
    u8R"({
  "__version__": 1,
  "pluginName": "Tremolo",
  "modulationRateHz": 10.0,
  "bypassed": true,
  "modulationWaveform": "Triangle"
})";

Нулевой указатель обозначает nullptr (C++11). Раньше писали NULL или 0, но это целое число, и оно путалось с перегрузками функций. У nullptr собственный тип, и приводится он только к указателям: setLookAndFeel(nullptr) в деструкторе редактора.

auto, const и constexpr

auto (C++11) просит компилятор вывести тип переменной из выражения справа. Это не динамическая типизация: тип остаётся статическим и фиксируется при компиляции. Особенность в том, что auto отбрасывает ссылку и const верхнего уровня. Чтобы получить ссылку, пишут auto&; без амперсанда будет копия.

C++
for (auto& lfo : lfos) {   // ссылка на каждый осциллятор в массиве
  lfo.reset();
}

Если в этом цикле написать auto lfo, reset() сработает на копии осциллятора (oscillator)осциллятор (oscillator) — Генератор повторяющегося колебания. Частота задаёт число повторений за секунду, а форма волны — изменение значения внутри одного периода., а исходный не изменится.

const запрещает менять объект после инициализации. В коде плагина почти всё неизменяемое помечено const:

C++
const auto inputSample = buffer.getSample(channelIndex, frameIndex);
const auto outputSample = modulationValue * inputSample;

С указателями const читают справа налево. const auto* const name = "text"; означает: name — константный указатель (второй const) на константные символы (первый). Первый const запрещает менять символы через указатель, второй — перенаправлять сам указатель.

constexpr (C++11) требует, чтобы значение было известно во время компиляции. Его можно использовать там, где нужна константа: размер массива, параметр шаблона. constexpr подразумевает const. Так записаны, например, static constexpr auto modulationDepth = 0.4f; в Tremolo и constexpr auto versionHint = 1; при создании параметров.

Операторы и приведение типов

Условный оператор условие ? значение_если_да : значение_если_нет — выражение, а не инструкция. Через него выбирают целевое значение и подпись кнопки:

C++
const auto target = bypass ? 1.0f : 0.0f;
bypassButton.setButtonText(bypassButton.getToggleState() ? "Bypassed" : "Bypass");

Логические операторы && («и»), || («или») и ! («не») вычисляются лениво: если результат ясен по левой части, правая не выполняется. В isBusesLayoutSupported оператор != сравнивает два объекта juce::AudioChannelSet; он работает потому, что класс сам определил этот оператор (см. перегрузку операторов).

static_cast

static_cast<T>(x) — ключевое слово C++: явное преобразование выражения x к типу T. Угловые скобки похожи на шаблон, но это встроенная операция языка, и подключать для неё ничего не нужно. Компилятор проверяет, что такой переход между типами разрешён: static_cast<int>(3.7) допустим и даёт 3, а превратить указатель в несвязанный тип указателя нельзя. Сохранение значения при этом не гарантируется: возможна потеря дробной части, сужение или превращение отрицательного числа в беззнаковое.

C++
dryBuffer.setSize(static_cast<int>(spec.numChannels),
                  static_cast<int>(spec.maximumBlockSize));

В коде плагина static_cast встречается часто, потому что строгие предупреждения (см. раздел о предупреждениях) запрещают молча смешивать знаковые и беззнаковые числа. JUCE хранит размеры как int, а стандартная библиотека ждёт size_t. Приведение в стиле C, (int)x, допускает более широкий набор преобразований и хуже показывает намерение, поэтому в современном коде его избегают. Другие виды приведений в плагине не используются: const_cast снимает const, reinterpret_cast переинтерпретирует биты, dynamic_cast проверяет тип в иерархии классов во время выполнения.

Пространства имён

Пространство имён (namespace) группирует имена, чтобы они не конфликтовали с чужими. Код плагина лежит в tremolo, JUCE — в juce, стандартная библиотека — в std. Оператор разрешения области :: отделяет имя от области, где оно объявлено. В отличие от пакетов Go, пространство имён не связано с файлами: его можно открывать в любом числе файлов, содержимое объединяется.

C++
juce::AudioBuffer<float>          // класс AudioBuffer из пространства juce
juce::dsp::AudioBlock<float>      // вложенное пространство juce::dsp
std::views::iota (0, n)           // функция из std::views
Tremolo::LfoWaveform::sine        // значение перечисления внутри класса
PluginProcessor::getName()        // метод класса в определении вне класса

Пространство имён можно вложить в другое: juce::dsp — часть juce. Запись namespace tremolo::assets { ... } (C++17) равна двум вложенным блокам. Поиск имени идёт изнутри наружу: в PluginEditor.cpp написано assets::Background_png, хотя сгенерированные данные лежат в tremolo::assets. Код редактора находится внутри namespace tremolo, поэтому assets находится относительно него.

Анонимное пространство имён

Блок namespace { ... } без имени прячет объявления внутри единицы трансляции. Так устроены вспомогательные функции в Parameters.cpp: addParameterToProcessor и три функции create…Parameter нужны только этому файлу. У таких имён внутренняя связь (internal linkage): другой .cpp может объявить собственную функцию с тем же именем, и компоновщик их не спутает.

В этом проекте нужна оговорка. Из-за unity build все файлы source/*.cpp составляют одну единицу трансляции, поэтому анонимные пространства из разных файлов объединяются. Две вспомогательные функции с одинаковым именем в разных файлах всё равно будут конфликтовать. В заголовке такое пространство создаёт отдельные копии в каждой единице, поэтому для общего интерфейса оно не подходит.

Функции

Тип результата пишется перед именем. В определении вне класса имя метода уточняют именем класса. Компилятор должен увидеть объявление функции до её вызова, поэтому и нужны заголовки:

C++
// PluginProcessor.h — объявление (сигнатура)
bool acceptsMidi() const override;

// PluginProcessor.cpp — определение (тело)
bool PluginProcessor::acceptsMidi() const {
  return false;
}

В объявлении имена параметров можно опустить, важны только типы: void processBlock(juce::AudioBuffer<float>&, juce::MidiBuffer&);. Если параметр в теле не нужен, имя можно опустить и в определении. Если тело функции ещё не написано, JUCE предлагает вызвать juce::ignoreUnused(index);: пустая функция «использует» аргумент, и компилятор не предупреждает о неиспользуемой переменной. Стандартная альтернатива (C++17) — атрибут [[maybe_unused]] int index.

Параметр может иметь значение по умолчанию: explicit BypassTransitionSmoother(double crossfadeLengthSecondsValue = 0.01). Если аргумент не передан, подставится 0.01. Выражение по умолчанию вычисляется заново при каждом вызове без аргумента. В Python иначе: значение по умолчанию создаётся один раз при определении функции, поэтому список из def f(items=[]) общий для всех вызовов и накапливает элементы между ними. Задают значение по умолчанию только в объявлении.

Перегрузка

В C++ несколько функций могут иметь одно имя и разные списки параметров; компилятор выбирает подходящую по типам аргументов. У каждой перегрузки своё тело. У juce::AudioProcessor два метода processBlock: для AudioBuffer<float>& и для AudioBuffer<double>&.

C++
int twice (int value) { return value * 2; }
double twice (double value) { return value * 2.0; }

// twice (3) вызовет первое тело, twice (3.5) — второе.

В TypeScript перегрузка сигнатур устроена иначе: несколько записей описывают допустимые вызовы, а реализация одна и разветвляется внутри. В C++ ветвление выполняет компилятор при выборе функции.

inline и цепочки вызовов

В тестах плагина функция getFileOutputPath определена в заголовке TestUtils.h со словом inline. Заголовок подключается в несколько .cpp, и без inline в каждой единице трансляции появилось бы своё определение: компоновщик выдал бы ошибку «multiple definition». inline разрешает одинаковые определения в разных единицах и велит оставить одно. Исторически слово означало совет «встроить тело в место вызова», но сейчас главный смысл именно такой. Методы, тело которых написано прямо в классе, как в Tremolo.h, неявно inline.

Та же функция показывает цепочку вызовов: каждый метод возвращает объект, у которого вызывается следующий.

C++
return juce::File::getSpecialLocation(
           juce::File::SpecialLocationType::currentExecutableFile)
    .getParentDirectory()
    .getChildFile(fileName)
    .getFullPathName()
    .toStdString();

Так же построены BusesProperties().withInput(...).withOutput(...) в конструкторе процессора. Шаблон называют «строитель» (builder).

Значение, ссылка и указатель

Это главное отличие C++ от Python, Go и TypeScript: автор кода выбирает, как передаётся объект. В Python и TypeScript объект всегда передаётся по ссылке на него. В Go структура по умолчанию копируется целиком. В C++ копируется любой объект, даже большой класс, если не сказано иное.

Таблица 2 сводит способы передачи с примерами из кода плагина. Обычно маленький тип (int, double, bool) передают по значению, входной объект — как const T&, а объект, который функция меняет, — как T&.

Таблица 2. Способы передачи параметров в коде плагина. Копия большого объекта, например аудиобуфера, расходует время и память, поэтому буфер передают по ссылке.
СпособЧто получает функцияПример из кода
по значениюкопию аргументаvoid setBypass(bool bypass) noexcept
T&другое имя того же объекта; функция может менять объект через это имяvoid process(juce::AudioBuffer<float>& buffer) noexcept
const T&другое имя того же объекта; менять объект через это имя функция не можетvoid prepare(const juce::dsp::ProcessSpec& spec)
T*адрес объекта, может быть пустымjuce::AudioProcessorEditor* createEditor() override

Ссылка T&

Ссылка — другое имя существующего объекта, изменения через неё меняют оригинал. В отличие от указателя, ссылка обязана быть инициализирована при объявлении, не бывает пустой, не может быть перенаправлена на другой объект и используется как обычная переменная: buffer.getNumSamples(), а не buffer->getNumSamples(). Структура Parameters хранит параметры как ссылки: juce::AudioParameterFloat& rate;.

Ссылку можно и вернуть из функции. Например, процессор плагина хранит параметры в поле parameters, а редактору отдаёт их методом getParameterRefs(). Рядом объявлен метод getBypassParameter(), который возвращает указатель:

C++
// PluginProcessor.h
[[nodiscard]] Parameters& getParameterRefs() noexcept;
juce::AudioProcessorParameter* getBypassParameter() const noexcept override;
// ...
Parameters parameters{*this};  // поле: сам объект, не ссылка и не указатель

// PluginProcessor.cpp
Parameters& PluginProcessor::getParameterRefs() noexcept {
  return parameters;
}

juce::AudioProcessorParameter* PluginProcessor::getBypassParameter() const noexcept {
  return &parameters.bypassed;
}

Поле parameters — объект типа Parameters. Ссылкой результат делает тип возвращаемого значения Parameters& в объявлении функции. return parameters; называет сам объект, и возвращаемая ссылка привязывается к нему: вызывающий код получает другое имя поля процессора, копия не создаётся. return &parameters; здесь не скомпилируется: выражение &parameters берёт адрес и имеет тип Parameters*, а ссылку к указателю привязать нельзя. Чтобы вернуть адрес, меняют тип результата: getBypassParameter() объявлена с juce::AudioProcessorParameter* и поэтому пишет return &parameters.bypassed;. Возвращать ссылку можно, пока жив объект, к которому она привязана. Поле живёт вместе с процессором, а редактор удаляется раньше процессора. Ссылку на локальную переменную функции возвращать нельзя: переменная исчезнет при выходе из функции, и ссылка станет висячей.

Копию Parameters вернуть и не удалось бы: структура запрещает копирование и перемещение макросами JUCE_DECLARE_NON_COPYABLE и JUCE_DECLARE_NON_MOVEABLE (см. запрет копирования). По той же причине вызывающий код сохраняет результат в ссылку: auto& params = p.getParameterRefs();. Запись auto params = ... отбрасывает ссылку и пытается скопировать объект, и компилятор сообщит о вызове удалённого конструктора копирования. Редактор обходится без переменной: p.getParameterRefs().rate сразу обращается к полю rate объекта процессора.

Ссылочный параметр устроен так же, только ссылка привязывается при вызове. Например, класс JsonSerializer сохраняет параметры в JSON. Его метод объявлен так: static void serialize(const Parameters&, juce::OutputStream&);. Тип const Parameters& говорит, что функция принимает ссылку на Parameters и не меняет объект через неё. Имя параметра в объявлении можно опустить; в определении в JsonSerializer.cpp он назван parameters.

C++
void PluginProcessor::getStateInformation(juce::MemoryBlock& destData) {
  juce::MemoryOutputStream outputStream{destData, true};
  JsonSerializer::serialize(parameters, outputStream);
}

Аргумент parameters — существующее поле процессора. При вызове ссылка из объявления связывается с этим объектом, и внутри serialize имя параметра обозначает само поле, без копии. Поэтому в месте вызова передают объект: serialize(&parameters, outputStream) не скомпилируется, потому что &parameters — указатель Parameters*. Второй аргумент передаётся так же: outputStream имеет тип juce::MemoryOutputStream, и ссылка на базовый класс juce::OutputStream& привязывается к нему (см. наследование).

Амперсанд ссылки пишут там, где объявляется тип: у параметра, возвращаемого значения, поля или переменной. В вызове функции и после return стоят выражения, и & в них берёт адрес. Подробнее о двух значениях символа — во врезке ниже.

Указатель T*

Указатель хранит адрес объекта. Он может быть nullptr и может указывать на другой объект после переприсваивания. Доступ к члену через указатель записывают ->, разыменование — *p. Тип const void* — указатель на байты неизвестного типа, которые нельзя менять. Так JUCE передаёт плагину сохранённое состояние: void setStateInformation(const void* data, int sizeInBytes). Объекта «массив с длиной» здесь нет, поэтому размер идёт отдельным параметром.

В аудиокоде указатель обычно показывает на начало блока отсчётов. Метод buffer.getReadPointer(0) возвращает const float* — адрес первого отсчёта канала 0. Следующие отсчёты лежат в памяти подряд, и p[i] читает i-й из них. По типу float* не видно, указывает он на одно число или на массив и сколько в массиве элементов, поэтому длину передают отдельно, как buffer.getNumSamples().

Кто отвечает за удаление объекта, на который указывает указатель, — отдельный вопрос. Он разобран в статье «Кто владеет объектом».

const в типе указателя

В типе указателя участвуют два объекта: сам указатель и значение, на которое он показывает, и каждый из них может быть константным. Правило одно: const левее * относится к значению, правее * — к указателю. Поэтому const float* и float const* — один и тот же тип, порядок const и float до звёздочки не важен. Длинный тип удобно читать справа налево: const float* const p — «p — константный указатель на константный float». Таблица 3 сводит варианты.

Таблица 3. Варианты const в типе указателя на отсчёты. Ошибку при попытке изменить защищённое выдаёт компилятор.
ТипКак читатьЧто нельзя менятьГде встречается
float*указатель на floatвсё можно менятьbuffer.getWritePointer(0)
const float*указатель на константный floatотсчётыbuffer.getReadPointer(0)
float const*то же самое: const левее *отсчётыта же запись в другом стиле
float* constконстантный указатель на floatсам указатель: его нельзя перенаправитьпараметр функции, который тело не должно перенаправлять
const float* constконстантный указатель на константный floatни отсчёты, ни указательто же для блока, который функция только читает
float* const*указатель на константный указатель на floatуказатели каналов в таблицеbuffer.getArrayOfWritePointers()
const float* const*указатель на константный указатель на константный floatуказатели каналов и отсчётыbuffer.getArrayOfReadPointers()

Две звёздочки появляются, когда каналов несколько. juce::AudioBuffer хранит таблицу указателей на каналы, а getArrayOfReadPointers() возвращает адрес этой таблицы с типом const float* const*. const перед первой звёздочкой защищает отсчёты, const между звёздочками — указатели каналов в таблице. Сам возвращённый указатель обычный: его можно перенаправить, таблицу и отсчёты это не затронет. getArrayOfWritePointers() возвращает float* const*: отсчёты менять можно, подменить канал нельзя. Те же типы получает колбэк аудиоустройства JUCE audioDeviceIOCallbackWithContext: входные каналы — const float* const*, выходные — float* const*.

C++
const float* const* inputs = buffer.getArrayOfReadPointers();
float* const* outputs = buffer.getArrayOfWritePointers();

const auto x = inputs[1][10];  // канал 1, отсчёт 10: читать можно
outputs[1][10] = 0.5f * x;     // писать отсчёт через float* const* можно
// inputs[1][10] = 0.0f;       // ошибка: отсчёты константны
// outputs[1] = nullptr;       // ошибка: указатели каналов константны

У среднего const есть и второе назначение: float** неявно превращается в const float* const*, но не в const float**. Иначе через const float** можно было бы записать в таблицу адрес константного числа, а потом изменить это число через исходный float*.

Массивы и std::span

Пару «указатель и длина» компилятор не проверяет: длину легко перепутать, а вызов process(nullptr, 512) скомпилируется. Стандартная библиотека хранит длину вместе с данными. std::array<float, N> (C++11) — массив, длина N которого известна при компиляции; элементы лежат внутри самого объекта. В учебном плагине из курса JUCE так объявлены два осциллятора LFO в классе Tremolo: std::array<juce::dsp::Oscillator<float>, 2u> lfos. std::vector<float> — массив, длина которого задаётся при выполнении; память он берёт в куче и освобождает в деструкторе. Поле lfoSamples того же класса — вектор: его размер один раз задаёт resize в методе prepare, который вызывается из prepareToPlay, а process только пишет в готовую память. Метод data() у обоих контейнеров возвращает указатель на первый элемент, так lfoSamples.data() передаётся в функции JUCE, которые принимают указатель и число отсчётов.

Функции не обязательно знать, в каком контейнере лежат отсчёты. std::span<float> (C++20) хранит указатель и длину в одном параметре и не владеет данными. Функция с таким параметром принимает std::array, std::vector и указатель с длиной из AudioBuffer, отсчёты при этом не копируются. std::span<const float> даёт доступ только для чтения, как const float*.

C++
void applyGain (std::span<float> samples, float gain) noexcept
{
    for (auto& sample : samples)
        sample *= gain;
}

std::array<float, 4> window { 0.0f, 0.5f, 1.0f, 0.5f }; // длина известна при компиляции
std::vector<float> block (512);                         // длина задаётся при выполнении

applyGain (window, 0.5f);
applyGain (block, 0.5f);
applyGain ({ buffer.getWritePointer (0),                 // указатель и длина из AudioBuffer
             static_cast<std::size_t> (buffer.getNumSamples()) }, 0.5f);
applyGain (std::span { block }.first (64), 0.0f);        // первые 64 отсчёта

Цикл по диапазону

Цикл for (элемент : коллекция) (C++11) похож на for x in xs в Python и for _, x := range xs в Go. Он работает с любым объектом, у которого есть begin() и end(). Запись const auto x копирует каждый элемент, и для чисел это верно. Для больших объектов пишут const auto& x, чтобы не копировать, и auto& x, чтобы менять элементы на месте.

Вместо классического for (int i = 0; i < n; ++i) код плагина использует std::views::iota (C++20, заголовок <ranges>): ленивую последовательность целых чисел в полуоткрытом интервале [начало, конец), как range(a, b) в Python. Массив в памяти не создаётся, а индекс можно объявить const.

C++
for (const auto frameIndex : std::views::iota(0, buffer.getNumSamples())) {
  for (const auto channelIndex : std::views::iota(0, buffer.getNumChannels())) {
    const auto inputSample = buffer.getSample(channelIndex, frameIndex);
    buffer.setSample(channelIndex, frameIndex, modulationValue * inputSample);
  }
}

Оба аргумента iota должны быть одного типа. getNumSamples() возвращает int, поэтому начало записано как 0, а не 0u.

noexcept и [[nodiscard]]

noexcept (C++11) обещает, что функция не выбрасывает исключений. Если исключение всё же вылетит, программа завершится через std::terminate. Компилятор может сгенерировать более компактный код, а стандартные контейнеры учитывают noexcept при выборе между копированием и перемещением. В аудиокоде это ещё и документация: методы, которые вызываются из аудиопотока (process, setBypass, reset), не должны бросать исключения, как и выделять память или блокироваться.

C++
void setBypass(bool bypass) noexcept { /* ... */ }
[[nodiscard]] bool isTransitioning() const noexcept { /* ... */ }

Атрибут [[nodiscard]] (C++17) просит компилятор предупредить, если результат функции проигнорирован: вызывать isTransitioning(); и не использовать ответ бессмысленно. Двойные квадратные скобки — стандартный синтаксис атрибутов (C++11), отличный от [] индексирования и от захвата лямбды. Атрибут nodiscard — не ключевое слово вроде const, а имя атрибута. Другие атрибуты: [[maybe_unused]], [[fallthrough]], [[likely]]. Правила описаны в стандарте.

Точка входа плагина

У плагина нет функции main(). Вместо неё JUCE ищет функцию с фиксированным именем, которая создаёт процессор:

C++
}  // namespace tremolo

// This creates new instances of the plugin.
// This function definition must be in the global namespace.
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() {
  return new tremolo::PluginProcessor();
}

Функция определена вне namespace tremolo, в глобальном пространстве: внутри пространства её полное имя стало бы tremolo::createPluginFilter, и JUCE её бы не нашёл. Макрос JUCE_CALLTYPE задаёт соглашение о вызове, то есть порядок передачи аргументов. Оно нужно, чтобы плагин и хост одинаково понимали вызов; на Windows это __stdcall, на других платформах макрос пуст.

Выражение new T(...) создаёт объект в куче и возвращает указатель на него. Владение переходит обёртке плагина JUCE, и она позже вызовет delete. Возвращается указатель на базовый класс juce::AudioProcessor*, хотя создан tremolo::PluginProcessor: объект наследника можно использовать везде, где ждут базовый класс (см. наследование). В собственном коде голые new и delete обычно не пишут: для этого есть std::make_unique, подробнее — в «Кто владеет объектом».

Стиль кода в примерах

Компилятор не требует определённых отступов, места скобок или формы имён. Каждый проект выбирает свои правила и закрепляет их форматтером, например clang-format. Учебный плагин tremolo-juce-course отформатирован в стиле Chromium: отступ 2 пробела, открывающая скобка на строке объявления, литерал 0.f. Фрагменты курса приведены на сайте так же, чтобы их было легко найти в репозитории.

Собственные примеры сайта следуют JUCE Coding Standards — правилам оформления самого фреймворка. В них отступ занимает 4 пробела, фигурные скобки стоят на отдельной строке, перед открывающей скобкой вызова с аргументами ставится пробел, литерал пишется как 0.0f, а параметр шаблона называется словом, а не T. Одна и та же функция в двух стилях:

C++
// Оформление учебного плагина (Chromium)
void applyGain(std::span<float> samples, float gain) noexcept {
  for (auto& sample : samples)
    sample *= gain;
}

// Оформление примеров сайта (JUCE)
void applyGain (std::span<float> samples, float gain) noexcept
{
    for (auto& sample : samples)
        sample *= gain;
}

Примеры собираются в режиме C++23. У величины с единицей измерения единица видна в имени или в комментарии: delaySeconds, latencySamples, double cutoff = 1000.0; // Гц. Объектами владеют поля и std::unique_ptr; голые new и delete встречаются только там, где статья разбирает ручное владение или API JUCE принимает сырой указатель. Вместо массивов C — std::array, std::vector и std::span, вместо приведения (int) x — static_cast<int> (x). Возможности новее C++11 при первом упоминании помечены стандартом в скобках, как std::span (C++20) в этой статье.

Предупреждения компилятора

Файл cmake/CompilerWarnings.cmake включает строгий набор предупреждений, и он объясняет несколько особенностей стиля кода. Таблица 4 связывает флаги с тем, как они видны в коде.

Таблица 4. Флаги предупреждений GCC и Clang из CompilerWarnings.cmake и приёмы кода, которые они вызвали.
ФлагЧто проверяетКак это видно в коде
-Wunusedнеиспользуемые переменные и параметрыjuce::ignoreUnused(index)
-Wconversion, -Wsign-conversion, -Wshorten-64-to-32неявные преобразования со знаком и сужениемstatic_cast<size_t>(...), static_cast<int>(...)
-Woverloaded-virtualвиртуальная функция базы скрыта перегрузкойusing AudioProcessor::processBlock;
-Wreorderпорядок списка инициализации не совпадает с порядком полейпорядок полей в BypassTransitionSmoother
-Wshadowлокальное имя скрывает внешнеепараметр конструктора назван crossfadeLengthSecondsValue, а не crossfadeLengthSeconds
-Wfloat-equalсравнение вещественных чисел через ==в тестах EXPECT_NEAR и EXPECT_FLOAT_EQ
-Wzero-as-null-pointer-constant0 вместо nullptrsetLookAndFeel(nullptr)
-Wno-ignored-qualifiersотключено: бессмысленный const у возвращаемого значенияconst juce::String getName() const override

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

Какой тип у литерала 0.4 без суффикса и почему в коде пишут 0.4f?

Литерал 0.4 имеет тип double. При передаче в параметр типа float Clang с флагом -Wconversion предупредит о сужении double → float. Суффикс f делает литерал float сразу, и преобразования нет.

Что случится, если в цикле for (auto lfo : lfos) вызвать lfo.reset()?

auto отбрасывает ссылку, поэтому lfo — копия элемента массива. reset() сработает на копии, а осцилляторы в lfos не изменятся. Чтобы менять элементы на месте, нужна запись for (auto& lfo : lfos).

Почему getParameterRefs() возвращает Parameters&, хотя в теле написано return parameters, а не return &parameters?

Ссылкой результат делает тип Parameters& в объявлении, а return parameters; называет объект, к которому ссылка привяжется. &parameters в выражении берёт адрес и даёт Parameters*; такой return подходит функции, объявленной с типом результата Parameters*, а с Parameters& не скомпилируется.

Функция получает параметр float* const* channels. Какие строки скомпилируются: channels[0][5] = 0.0f; channels[0] = nullptr; channels = nullptr;?

Первая и третья. float в конце цепочки не константен, поэтому отсчёт менять можно. const между звёздочками запрещает подменять указатели каналов в таблице, и вторая строка — ошибка. Сам параметр channels не константен: его можно перенаправить, это изменит только локальную копию указателя в функции.

Зачем в tremolo_plugin.h порядок директив #include важен, а в обычном проекте с отдельными .cpp-файлами это не так?

Плагин собирается как одна единица трансляции: все .cpp вставляются в tremolo_plugin.cpp после общего заголовка. Компилятор читает текст сверху вниз и должен увидеть объявление класса раньше его использования, поэтому Tremolo.h идёт раньше PluginProcessor.h. В проекте, где каждый .cpp подключает нужные заголовки сам, порядок между файлами задаёт не общий заголовок, а директивы каждого файла.

Почему createPluginFilter определена вне namespace tremolo?

JUCE находит функцию по фиксированному имени createPluginFilter в глобальном пространстве. Внутри namespace tremolo её полное имя было бы tremolo::createPluginFilter, и хост-обёртка JUCE её не нашла бы.

Источники

  • Alex. Learn C++. Главы 2 «C++ Basics: Functions and Files» (уроки 2.7, 2.8, 2.10, 2.11, 2.12), 4, 5, 7, 10, 11, 12, 16 и 17. в списке литературы → — препроцессор, заголовки, типы, литералы, пространства имён, приведение типов, перегрузка, ссылки, возврат ссылки, указатели и const, std::vector, std::array и циклы.
  • Wilczek J. C++ Pointers Explained For Audio Programming. Разделы о массивах, двойных указателях, const и std::span. в списке литературы → — указатель как адрес блока отсчётов, std::array и std::vector вместо массивов C, таблица каналов float** в колбэке аудиоустройства, чтение const справа налево, std::span вместо пары «указатель и длина».
  • cppreference. Pointer declaration — раздел Constness: const до и после *, равенство const T* и T const*; std::span — невладеющий вид на непрерывную память.
  • cppreference. static_cast — допустимые преобразования; std::size_t — определение и заголовок <cstddef>; noexcept specifier и nodiscard — семантика и стандарт появления.
  • Проект стандарта C++ (eel.is/c++draft): support.types.layout — size_t, namespace.unnamed — анонимное пространство имён, dcl.attr.grammar — синтаксис атрибутов.
  • JUCE. JUCE Module Format — модуль из одного .cpp, который вставляет остальные исходники, и блок BEGIN_JUCE_MODULE_DECLARATION.
  • CMake. UNITY_BUILD и target_precompile_headers — автоматический unity build и предкомпилированные заголовки (CMake 3.16).
  • cppreference. Modules — модули C++20 и отличие import от #include.
  • Doxygen. Documenting the code — формы комментариев документации.
  • JUCE. Coding Standards — оформление и соглашения исходников JUCE, на которых основан стиль примеров сайта.
  • Clang. ClangFormatStyleOptions — параметр BasedOnStyle и встроенные стили, в том числе Chromium.
  • JUCE. tremolo-juce-course — фрагменты кода из папки complete: tremolo_plugin.h, tremolo_plugin.cpp, PluginProcessor.h, PluginProcessor.cpp, Parameters.cpp, Tremolo.h, BypassTransitionSmoother.h, cmake/CompilerWarnings.cmake, CMakeLists.txt, test/source/TestUtils.h, Parameters.h, JsonSerializer.h и JsonSerializer.cpp.
  • JUCE 8.0.12: juce_AudioSampleBuffer.h — типы getReadPointer, getWritePointer, getArrayOfReadPointers и getArrayOfWritePointers; juce_AudioIODevice.h — параметры audioDeviceIOCallbackWithContext.

Связи

Связи списком

Ссылаются сюда

Открыть в общем графе

Поиск

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