Кто владеет объектом: std::make_unique и время жизни в плагине JUCE

Зачем создавать объект через std::make_unique, если тут же отдаёшь его процессору, и кто потом его удаляет?

Опубликовано Дополнено 15 мин чтения

В треках:Программист, этап 7

В учебном плагине (plug-in)плагин (plug-in) — Программный модуль эффекта или инструмента, который загружается в DAW или другую программу-хост и обрабатывает переданные ему аудиоблоки. тремоло (tremolo)тремоло (tremolo) — Эффект периодического изменения амплитуды аудиосигнала. Скорость пульсации задаёт частота управляющего генератора, а диапазон усиления зависит от глубины и способа её преобразования. из официального курса JUCE по разработке аудиоплагинов (репозиторий tremolo-juce-course) параметр создаётся так: std::make_unique строит объект, а вспомогательная функция сразу отдаёт его процессору через release(). Этот код решает практическую задачу: процессор должен получить параметр в собственность, чтобы показать его хосту, сохранять вместе с проектом и в конце удалить, а код плагина — оставить себе ссылку на параметр и читать через неё значение при обработке звука. Умный указатель (smart pointer)умный указатель (smart pointer) — Объект, который хранит адрес объекта в куче и сам удаляет его в своём деструкторе. В C++ — std::unique_ptr для единственного владельца и std::shared_ptr для общего. владеет объектом только на время этой передачи, и чтобы понять, зачем он нужен, придётся разобраться, кто и когда удаляет каждый объект программы. Со стороны языка эту тему подробно разбирает учебник Learn C++ в главе 22: урок 22.5 о std::unique_ptr включает раздел о std::make_unique. Эта статья применяет те же инструменты к плагину: в какой памяти лежат его объекты, кто ими владеет, а кто только пользуется.

Фрагменты взяты из готовой версии плагина в папке complete репозитория tremolo-juce-course (JUCE 8.0.12, C++23). Функции JUCE, о которых идёт речь (addParameter, addParameterGroup, createPluginFilter, createEditor), с теми же сигнатурами есть и в JUCE 9.0.3.

Стек, поля и куча

У каждого объекта в C++ есть время жизни (object lifetime)время жизни объекта (object lifetime) — Промежуток от окончания конструктора до начала деструктора, в который к объекту можно обращаться. Зависит от того, где объект создан: в блоке, внутри другого объекта или в куче.: от конца конструктора до начала деструктора. Обращаться к объекту можно только в этих границах. Длительность зависит от того, где объект создан.

Локальная переменная функции живёт до закрывающей фигурной скобки своего блока: компилятор сам вызывает деструктор при выходе из блока, в том числе при return и при исключении. Такую память обычно называют стеком (stack). Поле класса живёт, пока живёт объект, в который оно входит: деструктор внешнего объекта уничтожит и поле. Объект, созданный выражением new, размещается в куче (heap)куча (heap) — Динамическая память для объектов, созданных выражением new. Такой объект живёт до явного delete или до удаления его владельцем, например умным указателем. и существует, пока его не удалят выражением delete: компилятор не знает, когда он больше не нужен.

В процессоре учебного плагина большая часть объектов — поля. Класс эффекта Tremolo с массивом осцилляторов (oscillator)осциллятор (oscillator) — Генератор повторяющегося колебания. Частота задаёт число повторений за секунду, а форма волны — изменение значения внутри одного периода. LFO (low-frequency oscillator, LFO)низкочастотный генератор (low-frequency oscillator, LFO) — Генератор медленно повторяющегося управляющего сигнала, которым изменяют параметры звука, например усиление, частоту или панораму. Общей строгой верхней границы частоты у LFO нет. лежит прямо внутри PluginProcessor, без кучи:

C++
// PluginProcessor.h, поля процессора
private:
  Parameters parameters{*this};
  Tremolo tremolo;
  BypassTransitionSmoother bypassTransitionSmoother;
  std::atomic<double> currentSampleRate{0.};

Поле tremolo создаётся вместе с процессором и уничтожается вместе с ним. Удалять его вручную не нужно и нельзя: у него нет отдельного времени жизни.

Параметры в куче

Параметры так хранить не получится. Базовый класс juce::AudioProcessor ведёт общий список параметров плагина, через который хост их перечисляет, автоматизирует и сохраняет. Параметры бывают разных классов: AudioParameterFloat, AudioParameterBool, AudioParameterChoice и другие. Список хранит их через общий базовый класс AudioProcessorParameter, у которого есть виртуальный деструктор. Сколько параметров и каких классов будет у конкретного плагина, базовый класс заранее не знает, поэтому полями он их сделать не может. Остаётся создать каждый параметр в куче и сохранить указатель на него. Возникает вопрос, кто потом вызовет delete.

Владелец и наблюдатель

Владение может переходить от одного владельца к другому. Хост не работает с классами JUCE напрямую: он обращается к плагину через API своего формата: VST3, AU или другого. Переводит эти вызовы в методы AudioProcessor обёртка формата (plugin wrapper) — код JUCE из модуля juce_audio_plugin_client, который собирается внутри каждого плагина отдельно для каждого формата. Автор плагина её не пишет и не меняет. В учебном плагине есть все варианты владения: обёртка формата владеет процессором, процессор — своими полями и объектами параметров, а структура Parameters только наблюдает за параметрами через ссылки. Рисунок 1 показывает эту схему.

Владельцы и наблюдатели в учебном плагинеСверху блок «обёртка формата», от него сплошная стрелка с подписью unique_ptr ведёт в большой блок PluginProcessor. Внутри него сверху вниз три блока: parameters с подписью «rate&, bypassed&, waveform&», tremolo и «AudioProcessor (база)» с вложенным блоком parameterTree. Ниже, вне процессора, три блока параметров в куче: Modulation rate, Bypass и Modulation waveform. От parameterTree к каждому идёт сплошная стрелка. От блока parameters пунктирная линия обходит процессор слева, проходит под блоками параметров и входит в каждый из них снизу. Внизу легенда: сплошная линия — владеет, пунктир — ссылается.обёртка форматаunique_ptrPluginProcessorparametersrate&, bypassed&, waveform&tremoloAudioProcessor (база)parameterTreeModulationrateBypassModulationwaveformвладеетссылается
Рис. 1. Сплошная стрелка — владение: владелец удалит объект. Пунктир — невладеющая ссылка. Обёртка формата хранит процессор в std::unique_ptr. Поля parameters и tremolo входят в объект процессора; остальные поля не показаны. Объекты трёх параметров лежат в куче, ими владеет дерево параметров базового класса AudioProcessor, а parameters только ссылается на них.

Ручные new и delete

Самый прямой способ работать с кучей — парные new и delete. Пока функция короткая, это работает. Но парность никто, кроме программиста, не проверяет, и нарушить её можно тремя способами. Если delete не выполнится, объект останется в памяти до конца программы: это утечка памяти (memory leak)утечка памяти (memory leak) — Объект в куче, который больше никто не удалит: указатель на него потерян, а delete не выполнен. Память остаётся занятой до завершения программы.. Если delete выполнится дважды для одного объекта, поведение программы не определено. Если после delete кто-то обратится к объекту через сохранённый адрес, это висячий указатель (dangling pointer)висячий указатель (dangling pointer) — Указатель или ссылка на уже удалённый объект. Обращение через него — неопределённое поведение., или висячая ссылка, и поведение тоже не определено.

C++
struct Lfo { float phase = 0.0f; };

void leak(float rate)
{
    Lfo* lfo = new Lfo{};
    if (rate <= 0.0f)
        return;               // утечка: delete не выполнится
    delete lfo;
}

void doubleDelete()
{
    Lfo* a = new Lfo{};
    Lfo* b = a;               // два указателя на один объект
    delete a;
    delete b;                 // повторное удаление
}

float dangling()
{
    Lfo* lfo = new Lfo{};
    float& phase = lfo->phase;
    delete lfo;
    return phase;             // объект уже удалён
}

Все три функции компилируются без ошибок: компилятор не следит, кто отвечает за объект. Главная трудность в том, что по типу Lfo* нельзя понять, владеет ли его хранитель объектом. Руководство C++ Core Guidelines поэтому предлагает не передавать владение через сырой указатель или ссылку (правило I.11) и не писать new и delete явно (правило R.11).

std::unique_ptr

Умный указательумный указатель (smart pointer) — Объект, который хранит адрес объекта в куче и сам удаляет его в своём деструкторе. В C++ — std::unique_ptr для единственного владельца и std::shared_ptr для общего. — объект, который хранит адрес объекта в куче и сам удаляет его в своём деструкторе. Это частный случай приёма RAII (resource acquisition is initialization)RAII (resource acquisition is initialization) — Приём C++: ресурс принадлежит объекту, а освобождение привязано к деструктору этого объекта, который вызывается при любом выходе из области видимости.: ресурс принадлежит объекту, а освобождение ресурса привязано к уничтожению этого объекта. Деструктор локальной переменной вызывается при выходе из блока, в том числе через return или исключение, поэтому забыть delete или пропустить его при раннем return становится невозможно.

std::unique_ptr<T> выражает единственного владельца. Копировать его нельзя: у двух копий было бы два права удалить один объект, а это и есть двойное удаление. Можно перемещать: std::move разрешает передать владение, после чего исходный указатель становится пустым. Наблюдателю дают обычный указатель через get() или ссылку через *p: они ничего не удаляют.

C++
void owner()
{
    std::unique_ptr<Lfo> a = std::make_unique<Lfo>();
    // std::unique_ptr<Lfo> b = a;          // ошибка компиляции: копировать нельзя
    std::unique_ptr<Lfo> b = std::move(a);  // владелец теперь b, a пуст
    Lfo* observer = b.get();                // сырой указатель на тот же объект: наблюдатель, не удаляет
    observer->phase = 0.25f;
}   // деструктор b удаляет Lfo, пустой a ничего не делает

Кроме std::move, расстаться с объектом unique_ptr позволяют методы release() и reset(). После любого из этих вызовов исходный unique_ptr пуст, а различаются они судьбой объекта. std::move передаёт объект другому unique_ptr: объект остаётся на прежнем месте в куче, меняется только владелец, и удаление по-прежнему гарантирует тип. release() снимает с unique_ptr обязанность удалить объект и возвращает его адрес как сырой указатель. Объект жив, но удалить его теперь должен тот, кто получил адрес; если он этого не сделает, будет утечка. Нужен release(), чтобы отдать объект коду, который принимает владение через сырой указатель, как addParameter ниже. reset() удаляет объект сразу, не дожидаясь деструктора unique_ptr. Метод get() ни от чего не отказывается: он только выдаёт адрес наблюдателю. Таблица 1 сводит четыре вызова вместе.

C++
void handover()
{
    auto a = std::make_unique<Lfo>();
    auto b = std::move(a);        // объект тот же, владелец теперь b, a пуст
    Lfo* raw = b.release();       // b пуст, объект жив, удалить его должен владелец raw
    std::unique_ptr<Lfo> c{raw};  // адрес снова под управлением unique_ptr: владелец c
    c.reset();                    // объект удалён сейчас, c пуст
}   // a, b и c пусты, их деструкторы ничего не удаляют
Таблица 1. Что делают вызовы для std::unique_ptr u. Только reset() удаляет объект; get() не меняет владельца.
ВызовОбъектu после вызоваКто удалит объект
v = std::move(u)жив, на прежнем местепустдеструктор v
raw = u.release()живпусттот, кто получил raw; если никто, это утечка
u.reset()удалён сразупустникто: объекта больше нет
p = u.get()живпо-прежнему владеетдеструктор u; p только наблюдает

std::make_unique

std::make_unique<T>(аргументы…) появилась в C++14. Она создаёт объект в куче, передавая аргументы конструктору T, и возвращает владеющий им std::unique_ptr<T>. Результат тот же, что у std::unique_ptr<T>(new T(аргументы…)), но запись короче:

C++
std::unique_ptr<juce::AudioParameterFloat> a{new juce::AudioParameterFloat(/* … */)};
auto b = std::make_unique<juce::AudioParameterFloat>(/* … */);

Во второй строке имя класса написано один раз, и выражения new в коде нет. Правило R.23 Core Guidelines советует именно эту запись. Кроме краткости, оно называет безопасность при исключениях, но оговаривает: в коде до C++17.

C++
void use(std::unique_ptr<Lfo> lfo, int seed);
int nextSeed();  // может бросить исключение

use(std::unique_ptr<Lfo>(new Lfo{}), nextSeed());  // до C++17 возможна утечка
use(std::make_unique<Lfo>(), nextSeed());          // утечки нет

До C++17 компилятор мог вычислять аргументы вперемешку: сначала new Lfo{}, затем nextSeed(), и только потом создать unique_ptr. Если nextSeed() бросит исключение, объект уже создан, а владельца у него ещё нет. С C++17 каждый аргумент вычисляется целиком до начала следующего, и этой утечки больше нет. В учебном плагине на C++23 эта утечка невозможна при любой записи, и от make_unique остаются две пользы: в коде нет голого new, и с первой строки видно, кто владеет объектом.

Передача владения процессору

Процессору параметр нужен, чтобы хост видел его, мог автоматизировать и сохранял значение в проекте: для этого объект должен лежать в дереве параметров процессора, и удалять его должен процессор. Коду самого плагина параметр нужен, чтобы processBlock читал значение, а редактор подключал к нему слайдер, и для этого достаточно ссылки. Отсюда порядок действий: создать объект сразу с владельцем, запомнить ссылку на него и передать владение процессору. В итоге у каждого параметра один владелец, который удалит его ровно один раз и позже всех, кто им пользуется. Структура Parameters при этом получает ссылку точного типа, например juce::AudioParameterFloat&, и обращается к параметру без поиска по идентификатору.

В учебном плагине это делают две функции из Parameters.cpp. Вторая создаёт параметр частоты (frequency)частота (frequency) — Число полных колебаний за секунду. Обозначается f и измеряется в герцах; 1 Гц означает одно колебание в секунду. LFO, первая отдаёт его процессору и возвращает ссылку на него:

C++
// Parameters.cpp, внутри безымянного пространства имён
auto& addParameterToProcessor(juce::AudioProcessor& processor, auto parameter) {
  // parameter — unique_ptr, а *parameter — сам объект по его адресу;
  // auto& делает result ссылкой на этот объект, без копии
  auto& result = *parameter;
  processor.addParameter(parameter.release());
  return result;
}

juce::AudioParameterFloat& createModulationRateParameter(
    juce::AudioProcessor& processor) {
  constexpr auto versionHint = 1;
  return addParameterToProcessor(
      processor,
      std::make_unique<juce::AudioParameterFloat>(
          juce::ParameterID{"modulation.rate", versionHint}, "Modulation rate",
          juce::NormalisableRange<float>{0.1f, 20.f, 0.01f, 0.4f}, 5.f,
          juce::AudioParameterFloatAttributes{}.withLabel("Hz")));
}

Передачу владения вынесли в отдельную функцию, потому что так же создаются параметры bypassed и waveform. Объявление auto parameter делает addParameterToProcessor шаблоном (abbreviated function template, C++20): тип параметра выводится из аргумента, и для AudioParameterBool функция вернёт AudioParameterBool&. Результат make_unique попадает в parameter без копии и без std::move: временный unique_ptr сразу становится параметром функции.

В строке auto& result = *parameter; знак & в объявлении слева говорит, что result — ссылка, то есть другое имя уже существующего объекта. * справа — разыменование: parameter хранит адрес, а *parameter — объект по этому адресу. Без * получилась бы ссылка на сам unique_ptr, локальную переменную, которая исчезнет при выходе из функции.

addParameter принимает сырой указатель AudioProcessorParameter*. Что процессор станет владельцем, сказано только в документации: «The parameter object will be managed and deleted automatically by the AudioProcessor». Внутри JUCE первым делом заворачивает указатель обратно в std::unique_ptr и добавляет его в дерево параметров parameterTree. Таблица 2 прослеживает, кто владеет объектом после каждого шага и что в это время происходит с parameter и result.

Таблица 2. Владение объектом параметра Modulation rate при вызове addParameterToProcessor. Объект создаётся один раз и не копируется; меняется только то, кто отвечает за его удаление. Ссылка result всё время указывает на этот объект и не владеет им.
ШагВладелец объектаparameterresult
make_uniqueparameterуказывает на объектещё не объявлена
auto& result = *parameterparameterуказывает на объектссылается на объект
parameter.release()никто: сырой указатель передаётся в addParameterпустссылается на объект
addParameter(…)parameterTree процессорапустссылается на объект
return resultparameterTree процессорауничтожен, ничего не удаляетвозвращена вызывающему коду; её сохранит поле Parameters::rate

Между release() и входом в addParameter объект ни у кого не числится, но там нет кода, который мог бы прервать передачу. Поэтому запись processor.addParameter(new juce::AudioParameterFloat(…)) сработала бы здесь так же. Так написан, например, пример в статье о dry/wet. Разница видна, когда функция растёт. Пусть объект сначала сохраняется в переменной, а перед addParameter появляется проверка, которая может бросить исключение или выйти через return. Тогда unique_ptr удалит объект сам, а сырой указатель из new его потеряет.

Функции, которые принимают unique_ptr

В других местах JUCE передача владения записана в типе. addParameterGroup принимает std::unique_ptr<AudioProcessorParameterGroup>, конструктор группы — std::unique_ptr на её элементы, а AudioProcessorValueTreeState::ParameterLayout собирается из std::unique_ptr на параметры. Сырого указателя в пути нет, и release() не нужен:

C++
auto rate = std::make_unique<juce::AudioParameterFloat>(/* … */);
auto& rateReference = *rate;
auto group = std::make_unique<juce::AudioProcessorParameterGroup>(
    "modulation", "Modulation", "|", std::move(rate));
processor.addParameterGroup(std::move(group));

std::move здесь обязателен: unique_ptr нельзя скопировать в параметр функции, его можно только переместить. Без std::move код не скомпилируется, поэтому передачу владения нельзя написать случайно. После вызова rate и group пусты, а объекты принадлежат процессору.

Невладеющие ссылки

Функция вернула ссылку, и её сохраняет структура Parameters. Через эти ссылки processBlock читает текущие значения параметров, а редактор подключает к ним элементы управления:

C++
// Parameters.h
struct Parameters {
  explicit Parameters(juce::AudioProcessor&);

  juce::AudioParameterFloat& rate;
  juce::AudioParameterBool& bypassed;
  juce::AudioParameterChoice& waveform;

  JUCE_DECLARE_NON_COPYABLE(Parameters)
  JUCE_DECLARE_NON_MOVEABLE(Parameters)
};

// Parameters.cpp
Parameters::Parameters(juce::AudioProcessor& processor)
    : rate{createModulationRateParameter(processor)},
      bypassed{createBypassedParameter(processor)},
      waveform{createWaveformParameter(processor)} {}

Ссылка, а не указатель, сообщает две вещи: объект точно есть (ссылка не бывает нулевой) и Parameters его не удаляет. Но ссылка верна, только пока объект жив. Почему здесь это гарантировано, объясняет порядок создания и уничтожения частей процессора.

При создании объекта класса сначала конструируется базовый класс, затем поля в порядке объявления. Уничтожение идёт в обратном порядке: тело деструктора, поля от последнего к первому, затем базовый класс (cppreference, Destructors). Для PluginProcessor это значит: база AudioProcessor с пустым деревом параметров, затем parameters, чей конструктор создаёт параметры и добавляет их в дерево базы, затем tremolo и остальные поля. При удалении сначала уходят поля, объявленные после parameters, затем само поле parameters, чьи ссылки исчезают, ничего не удаляя, и только потом деструктор базы удаляет объекты параметров. Рисунок 2 показывает эти отрезки.

Время жизни частей процессораПять горизонтальных полос на общей оси времени, ось подписана «создание» слева и «удаление» справа. Сверху вниз: «база AudioProcessor» — самая длинная полоса во всю ширину; «объекты параметров» — чуть короче с обеих сторон; «parameters: ссылки» — начинается одновременно с объектами параметров, но заканчивается раньше них; «tremolo» — внутри предыдущих; «редактор» — короткая полоса в середине.база AudioProcessorобъекты параметровparameters: ссылкиtremoloредакторсозданиеудаление
Рис. 2. Отрезки времени жизни от создания процессора (слева) до его удаления (справа). База создаётся первой и удаляется последней. Объекты параметров появляются в конструкторе parameters и удаляются в деструкторе базы. Отрезок ссылок parameters целиком лежит внутри отрезка объектов параметров, поэтому ссылки не становятся висячими. Редактор, если хост его открывает, живёт внутри всех этих отрезков.

Макросы в конце Parameters запрещают копировать и перемещать структуру. Копия хранила бы ссылки на те же объекты параметров и могла бы пережить процессор, которому они принадлежат, а такая ошибка компилятору не видна. С запретом объект Parameters существует только внутри своего процессора, и схема на рисунке 2 остаётся верной.

Граница с хостом

Поля и параметры принадлежат процессору, а сам процессор создаёт и удаляет обёртка формата. Когда хост загружает плагин, обёртка вызывает функцию, которую определяет плагин:

C++
// PluginProcessor.cpp
juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter() {
  return new tremolo::PluginProcessor();
}

Здесь new и сырой указатель допустимы, потому что так задан интерфейс JUCE: фреймворк объявляет функцию, а плагин её определяет. Как и у addParameter, владение передаётся по документации, а не по типу. Указатель сразу забирает обёртка: вспомогательная функция createPluginFilterOfType вызывает createPluginFilter() и заворачивает результат в std::unique_ptr<AudioProcessor>. Дальше обёртка формата хранит процессор у себя и удаляет его, когда хост закрывает этот экземпляр плагина.

С редактором то же самое. createEditor() возвращает AudioProcessorEditor*; обёртка получает его через createEditorIfNeeded() и кладёт в свой std::unique_ptr<AudioProcessorEditor>. Документация JUCE предупреждает, что редактор может быть удалён и создан заново в любой момент и что он всегда удаляется раньше процессора. Поэтому процессор не должен хранить указатель на редактор, а ссылка в обратную сторону безопасна. В учебном плагине createEditor() возвращает new PluginEditor(*this): редактор получает ссылку на процессор и через getParameterRefs() подключает слайдер, переключатель и список к параметрам из Parameters. Редактор здесь наблюдатель, и его ссылки не становятся висячими, потому что процессор и параметры живут дольше него.

Общее владение через std::shared_ptr, контейнеры владения JUCE вроде OwnedArray и Component::SafePointer, а также правила выделения памяти в аудиопотоке (audio thread)аудиопоток (audio thread) — Поток с повышенным приоритетом, который вызывает обработку звука для каждого блока. Каждый вызов должен закончиться до срока блока, поэтому в нём не выделяют память, не берут блокировки и не обращаются к диску. разбирает статья «C++ в аудиопотоке». Всё сказанное выше касается создания и удаления объектов при загрузке плагина, вне обработки звука.

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

Что случится, если в addParameterToProcessor заменить parameter.release() на parameter.get()?

get() возвращает адрес, не отказываясь от владения. Процессор будет считать объект своим, но при выходе из функции локальный parameter его удалит. В дереве параметров останется висячий указатель, а при удалении процессора тот же объект будет удалён второй раз.

Почему строка processor.addParameterGroup(group); не компилируется, если group — локальный std::unique_ptr?

Параметр функции имеет тип std::unique_ptr, и аргумент пришлось бы скопировать, а копирование unique_ptr запрещено. Нужно явно передать владение: processor.addParameterGroup(std::move(group));. После вызова group пуст.

Можно ли сохранить ссылку juce::AudioParameterFloat& из Parameters в глобальной переменной и читать её после выгрузки плагина?

Нет. Объект параметра удаляет деструктор базы AudioProcessor при удалении процессора. Глобальная переменная переживёт процессор, и ссылка в ней станет висячей. Ссылки в Parameters безопасны только потому, что структура удаляется раньше, чем объекты параметров.

Почему объект Tremolo хранится в процессоре по значению, а параметры — в куче?

Тип Tremolo известен заранее и нужен ровно один, поэтому его можно сделать полем: он создаётся и удаляется вместе с процессором. Параметры разных классов базовый AudioProcessor хранит в общем списке через указатель на AudioProcessorParameter, а их число и типы определяет конкретный плагин. Такие объекты создаются по одному в куче, и владение ими передаётся процессору.

Источники

Фрагменты кода взяты из папки complete репозитория tremolo-juce-course (JUCE 8.0.12, C++23); примеры с Lfo и с группой параметров проверены компиляцией (Apple clang 17, C++23).

Связи

Поиск

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