Использование `destructor = delete;` в C++
Рассмотрим следующий класс:
struct S { ~S() = delete; };
Кратко, суть вопроса состоит в том, что я не могу создавать экземпляры S
, например, используя S s{};
, потому что не могу их уничтожить. Как упоминалось в комментариях, я все же могу создать экземпляр, вызвав S *s = new S;
, но не смогу его удалить. Поэтому единственное применение, которое я вижу для удаленного деструктора, это что-то вроде:
struct S {
~S() = delete;
static void f() { }
};
int main() {
S::f();
}
То есть, определение класса, который предоставляет только набор статических функций и запрещает любые попытки создать экземпляр этого класса.
Какие другие применения (если такие есть) могут иметь удаленные деструкторы?
5 ответ(ов)
Если у вас есть объект, который никогда не должен быть уничтожен с помощью delete
, находиться в памяти на стеке (автоматическое хранение) или быть частью другого объекта, использование =delete
поможет предотвратить все эти случаи.
struct Handle {
~Handle()=delete;
};
struct Data {
std::array<char, 1024> buffer;
};
struct Bundle: Handle {
Data data;
};
using bundle_storage = std::aligned_storage_t<sizeof(Bundle), alignof(Bundle)>;
std::size_t bundle_count = 0;
std::array<bundle_storage, 1000> global_bundles;
Handle* get_bundle() {
return new ((void*)global_bundles[bundle_count++]) Bundle();
}
void return_bundle(Handle* h) {
assert(h == (void*)global_bundles[bundle_count - 1]);
--bundle_count;
}
char get_char(Handle const* h, std::size_t i) {
return static_cast<Bundle*>(h)->data.buffer[i];
}
void set_char(Handle const* h, std::size_t i, char c) {
static_cast<Bundle*>(h)->data.buffer[i] = c;
}
В приведённом примере мы имеем непрозрачные объекты Handle
, которые не могут быть объявлены на стеке или созданы с помощью динамического выделения памяти. У нас есть система для получения таких объектов из известного массива.
Я считаю, что ничего из вышесказанного не ведет к неопределённому поведению; не уничтожение Bundle
приемлемо, так же как и создание нового объекта на его месте.
Интерфейс не обязательно должен раскрывать, как именно работает Bundle
. Он представляется как непрозрачный Handle
.
Такая техника может быть полезна, если другим частям кода нужно знать, что все Handle
находятся в конкретном буфере или их жизненный цикл отслеживается определённым образом. Возможно, это также могло бы быть реализовано с помощью приватных конструкторов и функций-фабрик-друзей.
Ваша идея о запрете неправильной деалокации в C++ действительно интересна. Рассмотрим два примера использования этого подхода.
Первый пример показывает, как можно предотвратить ошибочную деалокацию с помощью удаления деструктора:
#include <stdlib.h>
struct S {
~S() = delete; // Деструктор недоступен
};
int main() {
S* obj = (S*) malloc(sizeof(S)); // Выделение памяти
// корректное освобождение памяти
free(obj);
// ошибка: нельзя вызывать delete на obj, так как деструктор удален
// delete obj; // Это приведет к ошибке компиляции
return 0;
}
В этом случае, если кто-то попытается использовать delete
для объекта obj
, возникнет ошибка компиляции, что предотвращает неправильное использование оператора.
Второй пример демонстрирует более сложный подход, который является более "стилистическим" для C++:
#include <vector>
struct data {
// Реализация структуры данных
};
struct data_protected {
~data_protected() = delete; // Деструктор недоступен
data d;
};
struct data_factory {
~data_factory() {
for (data* d : data_container) {
// Это безопасно, потому что никто не может вызвать 'delete' на d
delete d;
}
}
data_protected* createData() {
data* d = new data(); // Создаем объект data
data_container.push_back(d); // Добавляем в контейнер
return (data_protected*)d; // Возвращаем указатель как data_protected
}
std::vector<data*> data_container; // Контейнер для хранения данных
};
В этом примере data_protected
запрещает вызов деструктора, тем самым гарантируя, что никто не сможет случайно удалить объект data
с использованием delete
. Вместо этого объекты data
управляются через data_factory
, что позволяет централизованно контролировать их жизненный цикл.
Эти подходы особенно полезны в случаях, когда у вас есть специальные механизмы распределения и освобождения памяти, и вы хотите избежать ошибок, связанных с неуместным использованием операторов new
и delete
.
Зачем помечать деструктор как delete
?
Чтобы предотвратить его вызов, конечно же 😉
Какие есть варианты использования?
Я вижу как минимум три различных применения:
- Класс никогда не должен быть instantiated (создан); в этом случае я бы также ожидал удалённый конструктор по умолчанию.
- Экземпляр этого класса должен быть утечен; например, экземпляр синглтона для логирования.
- Экземпляр этого класса может быть создан и уничтожен только определённым механизмом; это может иметь место, например, при использовании FFI (Foreign Function Interface).
Чтобы проиллюстрировать последний пункт, представьте себе C-интерфейс:
struct Handle { /**/ };
Handle* xyz_create();
void xyz_dispose(Handle*);
В C++ вы бы хотели обернуть это в unique_ptr
, чтобы автоматизировать освобождение, но что, если вы случайно напишете: unique_ptr<Handle>
? Это будет катастрофа во время выполнения!
Поэтому вместо этого вы можете изменить определение класса:
struct Handle { /**/ ~Handle() = delete; };
И тогда компилятор не позволит вам использовать unique_ptr<Handle>
, заставляя использовать правильный вариант unique_ptr<Handle, xyz_dispose>
вместо этого.
Есть два правдоподобных варианта использования. Во-первых (как отмечают некоторые комментарии), может быть приемлемо динамически выделять объекты, не удалять их с помощью delete
и позволить операционной системе очистить память в конце программы.
Альтернативно (и даже более странно) вы могли бы выделить буфер, создать объект внутри него, а затем удалить буфер, чтобы восстановить место, но никогда не пытаться вызвать деструктор.
#include <iostream>
struct S {
const char* mx;
const char* getx(){return mx;}
S(const char* px) : mx(px) {}
~S() = delete;
};
int main() {
char *buffer = new char[sizeof(S)];
S *s = new(buffer) S("не удаляем это..."); // Конструируем объект типа S в буфере.
// Код, использующий s...
std::cout << s->getx() << std::endl;
delete[] buffer; // Освобождаем память без вызова деструктора...
return 0;
}
Однако ни один из этих подходов не кажется хорошей идеей, кроме как в специфических обстоятельствах. Если автоматически создаваемый деструктор ничего не делает (поскольку деструкторы всех членов тривиальны), то компилятор создаст деструктор, который не оказывает эффекта.
Если автоматически создаваемый деструктор выполняет что-то нетривиальное, вы, скорее всего, нарушаете корректность вашей программы, не выполнив ее семантику.
Позволить программе выйти из main()
и позволить окружению "очистить" память — это приемлемая техника, но к ней лучше прибегать только в случае строгой необходимости. В лучшем случае это отличный способ скрыть настоящие утечки памяти!
Я подозреваю, что эта функция присутствует для полноты вместе с возможностью delete
других автоматически сгенерированных членов.
Я был бы рад увидеть реальное практическое применение этой способности.
Существует понятие статического класса (без конструкторов), и логически он не требует деструктора. Однако такие классы лучше реализовать как namespace
и у них нет (хорошего) места в современном C++, если только они не являются шаблонными.
Создание экземпляра объекта с помощью new
и отсутствие его удаления — это самый безопасный способ реализации Singleton в C++, так как он позволяет избежать проблем с порядком разрушения объектов. Типичный пример такой проблемы — это Singleton для логирования, который может быть доступен в деструкторе другого класса Singleton. Александреску посвятил целую секцию в своей классической книге "Современный C++ дизайн" способам решения проблем с порядком разрушения в реализации Singletone.
Деструктор, который удалён, является хорошим решением, поскольку даже сам класс Singleton не сможет случайно удалить экземпляр. Это также предотвращает странные случаи использования, такие как delete &SingletonClass::Instance()
(если Instance()
возвращает ссылку, как и должно; нет смысла возвращать указатель).
Тем не менее, в конечном итоге, всё это не так уж и примечательно. И, конечно, в первую очередь не стоит использовать Singleton'ы.
Когда действительно стоит использовать noexcept?
Возможно ли вывести тип переменной в стандартном C++?
Почему `std::initializer_list` не поддерживает оператор подиндексации?
Почему объект, возвращаемый по значению, имеет тот же адрес, что и объект внутри метода?
Можно ли вручную определить преобразование для класса enum?