Почему пользовательский deleter входит в тип std::unique_ptr и как это влияет на совместимость разных unique_ptr?
Пользовательский deleter входит в тип std::unique_ptr, потому что указатель хранит этот объект и вызывает его при уничтожении ресурса. Поэтому std::unique_ptr с разными типами deleter — это разные типы, которые нельзя безусловно передавать или присваивать друг другу.
Это позволяет безопасно управлять ресурсами, освобождаемыми не через delete, но влияет на размер объекта, сигнатуры функций и совместимость компонентов.
Обычный std::unique_ptr<T> предполагает освобождение объекта выражением delete. Однако многие ресурсы имеют другие правила освобождения: файловый дескриптор закрывается специальной функцией, библиотечный объект может требовать вызова отдельной процедуры, а память иногда освобождается специальным аллокатором.
Механизм RAII решает эту проблему, связывая ресурс с объектом-владельцем. Пользовательский deleter делает правило освобождения частью этого владельца, сохраняя автоматическое освобождение при выходе из области видимости и при исключениях.
Если применить delete к ресурсу, полученному от внешней библиотеки, поведение может быть неопределённым или просто некорректным. Если хранить такой ресурс в сыром указателе, ответственность за своевременное освобождение легко потерять на одном из путей выхода из функции.
std::unique_ptr<T, D> устраняет эту проблему, но тип D становится частью типа владельца. Функция, принимающая std::unique_ptr<T, D1>, не обязана принимать std::unique_ptr<T, D2>, даже если оба указателя управляют объектами одного типа T.
У std::unique_ptr deleter является вторым шаблонным параметром: концептуально это std::unique_ptr<T, D>. Объект D хранится внутри unique_ptr и вызывается в деструкторе, при reset() или во время перемещения владения, когда старый владелец должен освободить прежний ресурс.
Тип deleter важен не только для выбора операции освобождения. Он может содержать состояние, например указатель на контекст библиотеки, поэтому его нельзя заменить произвольным объектом другого типа без специальных условий. Даже если два deleter делают одно и то же, std::unique_ptr<T, D1> и std::unique_ptr<T, D2> остаются разными типами.
Неявное перемещение между такими типами возможно только при выполнении ограничений стандартной библиотеки: новый deleter должен быть совместим с перемещением или копированием старого, а тип указателя также должен быть совместим. Рассчитывать на такую конверсию без проверки нельзя; обычно публичный интерфейс явно фиксирует конкретный тип владельца или использует шаблон.
Размер unique_ptr тоже может зависеть от deleter. Пустой deleter часто не увеличивает размер благодаря оптимизации пустого базового класса, а deleter с состоянием обычно занимает дополнительное место. Это важно для контейнеров, структур данных и ABI-интерфейсов.
Если конкретный тип deleter неудобен для публичного API, применяют стирание типа: например, хранят функцию или объект-обёртку с единым типом deleter. Компромисс — возможные дополнительные косвенные вызовы, состояние или расходы памяти. Для внутренних интерфейсов обычно предпочтительнее конкретный лёгкий deleter, поскольку он сохраняет статическую типизацию и не требует стирания типа.
Минимальный пример управления ресурсом с нестандартным освобождением:
Здесь File — отдельный тип, отличающийся от std::unique_ptr<std::FILE>. При уничтожении вызывается FileCloser, поэтому delete к объекту FILE не применяется. Декларация noexcept у deleter важна: освобождение ресурса не должно выбрасывать исключения из деструктора.
Сервис открывает множество файлов и передаёт их между внутренними функциями. Вариант с сырыми FILE* прост по сигнатурам, но требует вручную закрывать файл на каждом пути выхода и легко приводит к утечкам при исключениях.
Вариант с std::unique_ptr<std::FILE, FileCloser> надёжен и не требует общего базового класса, но конкретный тип нужно повторять в интерфейсах. Вариант со стиранием типа через универсальный deleter упрощает границы модулей, однако может увеличить размер владельца и скрыть стоимость освобождения.
Для внутреннего C++-кода выбран конкретный FileCloser: он не хранит состояние, не добавляет заметных расходов и явно показывает правило освобождения в типе. В результате файл закрывается автоматически при любом выходе из области видимости, а попытка передать его функции, ожидающей владельца с другим deleter, обнаруживается на этапе компиляции.
Дополнительный вопрос: Можно ли преобразовать std::unique_ptr<T, D1> в std::unique_ptr<T, D2> только потому, что D1 и D2 вызывают одну и ту же функцию?
Ответ: Нет. Совпадение поведения не делает типы совместимыми. Для преобразующего перемещения должны выполняться формальные требования к конструируемости нового deleter из старого, к доступности операции перемещения или копирования и к совместимости типов указателей. Если эти условия не выполнены, требуется явная адаптация интерфейса или передача владельца через подходящий общий тип.
Дополнительный вопрос: Может ли пользовательский deleter изменить тип указателя, который хранит std::unique_ptr?
Ответ: Да. Если у deleter есть вложенный тип pointer, std::unique_ptr может использовать его вместо T*. Это позволяет управлять не только обычными адресами, но и специальными дескрипторами или указателями с особым значением пустого ресурса. Такой deleter должен предоставить операции, необходимые unique_ptr для проверки пустого значения и обращения с указателем, поэтому подобное решение требует аккуратного проектирования.
Дополнительный вопрос: Почему нельзя бездумно использовать std::function как deleter для каждого unique_ptr?
Ответ: std::function стирает конкретный тип операции освобождения и упрощает унификацию интерфейсов, но может увеличить размер unique_ptr, добавить косвенный вызов и в некоторых случаях потребовать динамического выделения памяти для хранения состояния. Кроме того, стоимость и свойства deleter становятся менее очевидными. Для небольшого внутреннего типа обычно лучше пустой или компактный конкретный deleter, а стирание типа оправдано, когда оно действительно упрощает архитектурную границу.