Хабрахабр

Техника избежания неопределённого поведения при обращении к синглтону

В статье рассмотрены причины возникновения и способы избежания неопределённого поведения при обращении к синглтону в современном c++. Приведены примеры однопоточного кода. Ничего compiler-specific, всё в соответствии со стандартом.

Введение

Для начала рекомендую ознакомиться с другими статьями о синглтонах на Хабре:
Три возраста паттерна Singleton
Синглтоны и общие экземпляры
3 cпособа нарушить Single Responsibility Principle
Singleton — паттерн или антипаттерн?
Использование паттерна синглтон
… и наконец статья, затронувшая эта же тему, но вскольз (хотя бы потому, что не рассмотрены недостатки и ограничения):
Синглтон и время жизни объекта

Далее:

  • это не статья об архитектурных свойствах синглтона;
  • это не статья «как из страшного и ужасного синглтона сделать белый и пушистый синглтон»;
  • это не агитация за применения синглтона;
  • это не крестовый поход против синглтона;
  • это не статья с хэппи-эндом.

Это статья об одном очень важном, но всё же техническом аспекте применения синглтона в современном С++. Основное внимание в статье уделяется моменту уничтожения синглтона, т.к. в большинстве источников вопрос уничтожения раскрыт слабо. Обычно упор делается на моменте создания синглтона, а про уничтожение в лучшем случае сказано что-то типа «уничтожается в обратном порядке».

Попрошу в комментариях придерживаться рамок статьи, особенно не устраивать холивар «синглтон-паттерн vs синглтон-антипаттерн».

Итак, поехали.

Что говорит стандарт

Цитаты — из C++14 final draft N3936, т.к. доступные черновики по C++17 не отмечены как «final».
Самый важный раздел привожу целиком. Важные места выделены мной.

6. 3. Destructors (12. 3 Termination [basic.start.term]
1. 8) has begun) with static storage duration are called as a result of returning from main and as a result of calling std::exit (18. 4) for initialized objects (that is, objects whose lifetime (3. Destructors for initialized objects with thread storage duration within a given thread are called as a result of returning from the initial function of that thread and as a result of that thread calling std::exit. 5). If the completion of the constructor or dynamic initialization of an object with thread storage duration is sequenced before that of another, the completion of the destructor of the second is sequenced before the initiation of the destructor of the first. The completions of the destructors for all initialized objects with thread storage duration within that thread are sequenced before the initiation of the destructors of any object with static storage duration. —end note ] If an object is initialized statically, the object is destroyed in the same order as if the object was dynamically initialized. If the completion of the constructor or dynamic initialization of an object with static storage duration is sequenced before that of another, the completion of the destructor of the second is sequenced before the initiation of the destructor of the first. [ Note: This definition permits concurrent destruction. If the destruction of an object with static or thread storage duration exits via an exception, std::terminate is called (15. For an object of array or class type, all subobjects of that object are destroyed before any block-scope object with static storage duration initialized during the construction of the subobjects is destroyed. 1). 5.

If a function contains a block-scope object of static or thread storage duration that has been destroyed and the function is called during the destruction of an object with static or thread storage duration, the program has undefined behavior if the flow of control passes through the definition of the previously destroyed blockscope object. Likewise, the behavior is undefined if the block-scope object is used indirectly (i.e., through a pointer) after its destruction. 2.

If the completion of the initialization of an object with static storage duration is sequenced before a call to std::atexit (see «cstdlib», 18. 3. If a call to std::atexit is sequenced before the completion of the initialization of an object with static storage duration, the call to the destructor for the object is sequenced before the call to the function passed to std::atexit. 5), the call to the function passed to std::atexit is sequenced before the call to the destructor for the object. If a call to std::atexit is sequenced before another call to std::atexit, the call to the function passed to the second std::atexit call is sequenced before the call to the function passed to the first std::atexit call.

If there is a use of a standard library object or function not permitted within signal handlers (18. 4. 10) completion of destruction of objects with static storage duration and execution of std::atexit registered functions (18. 10) that does not happen before (1. [ Note: If there is a use of an object with static storage duration that does not happen before the object’s destruction, the program has undefined behavior. 5), the program has undefined behavior. These requirements permit thread managers as static-storage-duration objects. Terminating every thread before a call to std::exit or the exit from main is sufficient, but not necessary, to satisfy these requirements. Calling the function std::abort() declared in «cstdlib» terminates the program without executing any destructors and without calling the functions passed to std::atexit() or std::at_quick_exit(). —end note ]
5.

Трактовка:

  • уничтожение объектов со thread storage duration производится в порядке, обратном их созданию;
  • строго после этого уничтожаются объекты со static storage duration и производятся вызовы функций, зарегистрированных с помощью std::atexit в порядке, обратном созданию таких объектов и регистрации таких функций;
  • попытка обращения к уничтоженному объекту со thread storage duration или static storage duration содержит неопределённое поведение, повторная инициализация таких объектов не предусмотрена.

Примечание: глобальные переменные в стандарте именуются как «non-local variable with static storage duration». В итоге получается, что все глобальные переменные, все синглтоны (локальные статики) и все вызовы std::atexit попадают в единую очередь LIFO по мере их создания/регистрации.

6. Полезная для статьи информация также содержится в разделе 3. Привожу только самое важное:
2 Initialization of non-local variables [basic.start.init]
.

[...] Variables with ordered initialization defined within a single translation unit shall be initialized in the order of their definitions in the translation unit. Dynamic initialization of a non-local variable with static storage duration is either ordered or unordered.

Трактовка (с учётом полного текста раздела): глобальные переменные в пределах одной единицы трансляции инициализируются в порядке объявления.

Что будет в коде

Все примеры кода, приведённые в статье, опубликованы на гитхабе.

Код состоит из трёх слоёв, как бы написанных разными людьми:

  • синглтон;
  • утилита (класс, использующий синглтон);
  • пользователь (глобальные переменные и main).

Синглтон и утилита — это как бы сторонняя библиотека, а пользователь — он и есть пользователь.
Слой утилиты задуман для изоляции слоя пользователя от слоя синглтона. В примерах у пользователя есть возможность обращаться к синглтону, но действовать будем так, как будто это невозможно.

Починить сначала пытаемся в слое утилиты, а если не получается — то в слое синглтона.
В коде мы постоянно будем ходить вдоль края — то на светлой стороне, то на тёмной. Пользователь сначала делает всё правильно, а потом лёгким движением руки всё ломает. Чтобы проще было переходить на тёмную сторону, выбран самый сложный случай — обращение к синглтону из деструктора утилиты.

Почему случай обращения из деструктора самый сложный? Потому что деструктор утилиты может быть вызван в процессе сворачивания приложения, когда становится актуальным вопрос «уничтожен уже синглтон или ещё нет».

На практике обращения к синглтону из деструктора не нужны. Еще как нужны. Случай какой-то синтетический. Например, для логгирования уничтожения объектов.

Используются три класса синглтонов:

  • SingletonClassic — без умных указателей. На самом деле он не прямо совсем классический, но точно самый классический среди трёх рассмотренных;
  • SingletonShared — с std::shared_ptr;
  • SingletonWeak — с std::weak_ptr.

Все синглтоны являются шаблонами. Параметр шаблона используют, чтобы от него унаследоваться. В большинстве примеров параметризуются классом Payload, предоставляющим одну public-функцию по добавлению данных в std::set.

Также используется диагностический вывод в консоль из конструктора синглтона, деструктора синглтона, и instance(). Деструктор утилиты в большинстве примеров пытается набить туда сотню значений.

Обращение к уничтоженному синглтону является неопределённым поведением, но вполне может никак не проявиться внешне. Зачем так сложно? Чтобы проще было понять, что мы — на тёмной стороне. При undefined behaviour вывод в консоль может и не случиться. Набивка значений в уничтоженный std::set тоже конечно не гарантирует внешних проявлений, но более надёжного способа как бы и нет (по факту в GCC под линуксом в некорректных примерах с классическим синглтоном в уничтоженный std::set всё успешно набивается, а в MSVS под виндой — зависает). Так что в корректных примерах ждём отсутствие обращения к instance() после деструктора, а также отсутствие крэша и отсутствие зависания, а в некорректных — либо наличие такого обращения, либо крэш, либо зависание, либо всё сразу в любых комбинациях, либо всё что угодно.

Классический синглтон

Payload.h

#pragma once #include <set> class Payload
{
public: Payload() = default; ~Payload() = default; Payload(const Payload &) = delete; Payload(Payload &&) = delete; Payload& operator=(const Payload &) = delete; Payload& operator=(Payload &&) = delete; void add(int value) private: std::set<int> m_data;
};

SingletonClassic.h

#pragma once #include <iostream> template<typename T>
class SingletonClassic : public T
{
public: ~SingletonClassic() { std::cout << "~SingletonClassic()" << std::endl; } SingletonClassic(const SingletonClassic &) = delete; SingletonClassic(SingletonClassic &&) = delete; SingletonClassic& operator=(const SingletonClassic &) = delete; SingletonClassic& operator=(SingletonClassic &&) = delete; static SingletonClassic& instance() { std::cout << "instance()" << std::endl; static SingletonClassic inst; return inst; } private: SingletonClassic() { std::cout << "SingletonClassic()" << std::endl; }
};

SingletonClassic, пример 1

Classic_Example1_correct.cpp

#include "SingletonClassic.h"
#include "Payload.h" #include <memory> class ClassicSingleThreadedUtility
{
public: ClassicSingleThreadedUtility() { // To ensure that singleton will be constucted before utility SingletonClassic<Payload>::instance(); } ~ClassicSingleThreadedUtility() { auto &instance = SingletonClassic<Payload>::instance(); for ( int i = 0; i < 100; ++i ) instance.add(i); }
}; // 1. Create an empty unique_ptr
// 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>(); // This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order is correct int main()
{ return 0;
}

Вывод в консоль

instance()
SingletonClassic()
instance()
~SingletonClassic()

Утилита обращается в конструкторе к синглтону, чтобы гарантировать создание синглтона до создания утилиты.
Пользователь создаёт два std::unique_ptr: один пустой, второй — содержащий утилиту.
Порядок создания:
— пустой std::unique_ptr.
— синглтон;
— утилита.
И соответственно порядок уничтожения:
— утилита;
— синглтон;
— пустой std::unique_ptr.
Обращение из деструктора утилиты к синглтону корректно.

SingletonClassic, пример 2

Всё то же самое, но пользователь взял и одной строчкой всё испортил.

Classic_Example2_incorrect.cpp

#include "SingletonClassic.h"
#include "Payload.h" #include <memory> class ClassicSingleThreadedUtility
{
public: ClassicSingleThreadedUtility() { // To ensure that singleton will be constucted before utility SingletonClassic<Payload>::instance(); } ~ClassicSingleThreadedUtility() { auto &instance = SingletonClassic<Payload>::instance(); for ( int i = 0; i < 100; ++i ) instance.add(i); }
}; // 1. Create an empty unique_ptr
// 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>(); // This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order seems to be correct ... int main()
{ // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); // Guaranteed destruction order is still the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect return 0;
}

Вывод в консоль

instance()
SingletonClassic()
~SingletonClassic()
instance()

Порядок создания и уничтожения сохранился. Казалось бы, всё по-прежнему. Но нет. Вызовом emptyUnique.swap(utilityUnique) пользователь учинил неопределённое поведение.

Зачем пользователь сделал такую глупость? Потому что он ничего не знает о внутреннем устройстве библиотеки, предоставившей ему синглтон и утилиту.

А выпутываться придётся путём мучительного дебага, т.к. А если знать внутреннее устройство библиотеки?… то всё равно в реальном коде очень просто вляпаться. понять, что же именно произошло, будет не просто.

Ну там доки всякие понаписывать, примеры… А почему бы не сделать такую библиотеку, которую не так просто будет испортить? А почему бы не потребовать использовать библиотеку правильно?

SingletonClassic, пример 3

В ходе подготовки статьи в течение нескольких дней я считал, что в слое утилиты невозможно устранить неопределённое поведение из прошлого примера, и решение доступно только в слое синглтона. Но со временем решение всё же придумалось.

Не исключаю, что существуют решения и получше. Перед открытием спойлеров с кодом и пояснением предлагаю читателю попытаться самостоятельно найти выход из ситуации (только в слое утилиты!).

Classic_Example3_correct.cpp

#include "SingletonClassic.h"
#include "Payload.h" #include <memory>
#include <iostream> class ClassicSingleThreadedUtility
{
public: ClassicSingleThreadedUtility() { thread_local auto flag_strong = std::make_shared<char>(0); m_flag_weak = flag_strong; SingletonClassic<Payload>::instance(); } ~ClassicSingleThreadedUtility() { if ( !m_flag_weak.expired() ) { auto &instance = SingletonClassic<Payload>::instance(); for ( int i = 0; i < 100; ++i ) instance.add(i); } } private: std::weak_ptr<char> m_flag_weak;
}; // 1. Create an empty unique_ptr
// 2. Create singleton (because of modified ClassicSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<ClassicSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<ClassicSingleThreadedUtility>(); // This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order seems to be correct ... int main()
{ // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); { // To demonstrate normal processing before application ends auto utility = ClassicSingleThreadedUtility(); } // Guaranteed destruction order is still the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect ... // ... but utility uses a variable with thread storage duration to detect thread termination. return 0;
}

Вывод в консоль

instance()
SingletonClassic()
instance()
instance()
~SingletonClassic()

Пояснение

Проблема возникает только при сворачивании приложения. От неопределённого поведения можно избавиться, научив утилиту распознавать момент сворачивания приложения. Для этого использована переменная flag_strong типа std::shared_ptr, имеющая квалификатор thread storage duration (см. выдержки из стандарта выше в статье) — это как статик, но только уничтожается при завершении текущего потока до уничтожения любого из статиков, в том числе — до уничтожения синглтона. Переменная flag_strong — одна на весь поток, а каждый экземпляр утилиты хранит у себя её weak-копию.

оно опосредованное и неочевидное. В узком смысле решение можно назвать хаком, т.к. Но в широком смысле это никакой не хак, а решение с полностью определёнными стандартом свойствами — как недостатками, так и достоинствами.
Кроме того, оно предупреждает слишком рано, а иногда (в многопоточном приложении) вообще предупреждает ложно.

SingletonShared

Переходим к рассмотрению модифицированного синглтона — основанного на std::shared_ptr.

SingletonShared.h

#pragma once #include <memory>
#include <iostream> template<typename T>
class SingletonShared : public T
{
public: ~SingletonShared() { std::cout << "~SingletonShared()" << std::endl; } SingletonShared(const SingletonShared &) = delete; SingletonShared(SingletonShared &&) = delete; SingletonShared& operator=(const SingletonShared &) = delete; SingletonShared& operator=(SingletonShared &&) = delete; static std::shared_ptr<SingletonShared> instance() { std::cout << "instance()" << std::endl; // "new" and no std::make_shared because of private c-tor static auto inst = std::shared_ptr<SingletonShared>(new SingletonShared); return inst; } private: SingletonShared() { std::cout << "SingletonShared()" << std::endl; }
};

Ай-ай-ай, оператор new в современном коде использовать не следует, вместо него нужен std::make_shared! А этому мешает приватный конструктор синглтона.

Тоже мне проблема! Ха! Надо объявить std::make_shared фрэндом синглтона!… и получить разновидность антипаттерна PublicMorozov: с помощью того же самого std::make_shared можно будет насоздавать не предусмотренные архитектурой дополнительные экземпляры синглтона.

SingletonShared, примеры 1 и 2

Полностью соответствуют примерам №№1 и 2 для классического варианта. Значимые изменения внесены только в слой синглтона, утилита по сути осталась такой же. Так же, как в примерах с классическим синглтоном, пример-1 корректен, а пример-2 демонстрирует неопределённое поведение.

Shared_Example1_correct.cpp

#include "SingletonShared.h"
#include <Payload.h> #include <memory> class SharedSingleThreadedUtility
{
public: SharedSingleThreadedUtility() { // To ensure that singleton will be constucted before utility SingletonShared<Payload>::instance(); } ~SharedSingleThreadedUtility() { if ( auto instance = SingletonShared<Payload>::instance() ) for ( int i = 0; i < 100; ++i ) instance->add(i); }
}; // 1. Create an empty unique_ptr
// 2. Create singleton (because of modified SharedSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<SharedSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>(); // This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order is correct int main()
{ return 0;
}

Вывод в консоль

instance()
SingletonShared()
instance()
~SingletonShared()

Shared_Example2_incorrect.cpp

#include "SingletonShared.h"
#include "Payload.h" #include <memory> class SharedSingleThreadedUtility
{
public: SharedSingleThreadedUtility() { // To ensure that singleton will be constucted before utility SingletonShared<Payload>::instance(); } ~SharedSingleThreadedUtility() { // Sometimes this check may result as "false" even for destroyed singleton // preventing from visual effects of undefined behaviour ... //if ( auto instance = SingletonShared::instance() ) // for ( int i = 0; i < 100; ++i ) // instance->add(i); // ... so this code will demonstrate UB in colour auto instance = SingletonShared<Payload>::instance(); for ( int i = 0; i < 100; ++i ) instance->add(i); }
}; // 1. Create an empty unique_ptr
// 2. Create singleton (because of modified SharedSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<SharedSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>(); // This guarantee destruction in order:
// - utilityUnique;
// - singleton;
// - emptyUnique.
// This order seems to be correct ... int main()
{ // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); // Guaranteed destruction order is the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect return 0;
}

Вывод в консоль

instance()
SingletonShared()
~SingletonShared()
instance()

SingletonShared, пример 3

А сейчас попытаемся починить эту проблему получше, чем в примере №3 из классики.
Решение очевидно: надо всего лишь продлить жизнь синглтона, прихранив в утилите копию std::shared_ptr, возвращённого синглтоном. И это решение в комплекте с SingletonShared широко растиражировано в открытых источниках.

Shared_Example3_correct.cpp

#include "SingletonShared.h"
#include "Payload.h" #include <memory> class SharedSingleThreadedUtility
{
public: SharedSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_singleton(SingletonShared<Payload>::instance()) { } ~SharedSingleThreadedUtility() { // Sometimes this check may result as "false" even for destroyed singleton // preventing from visual effects of undefined behaviour ... //if ( m_singleton ) // for ( int i = 0; i < 100; ++i ) // m_singleton->add(i); // ... so this code will allow to demonstrate UB in colour for ( int i = 0; i < 100; ++i ) m_singleton->add(i); } private: // A copy of smart pointer, not a reference std::shared_ptr<SingletonShared<Payload>> m_singleton;
}; // 1. Create an empty unique_ptr
// 2. Create singleton (because of SharedSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<SharedSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<SharedSingleThreadedUtility>(); int main()
{ // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order is correct ... // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); // Guaranteed destruction order is the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect... // ... but utility have made a copy of shared_ptr when it was available, // so it's correct again. return 0;
}

Вывод в консоль

instance()
SingletonShared()
~SingletonShared()

А теперь, внимание, вопрос: а Вы в самом деле хотели продлевать жизнь синглтона?
Или хотели избавиться от неопределённого поведения, а продление жизни выбрали как лежащий на поверхности способ?

Теоретическая некорректности в виде подмены целей средствами ведёт к риску возникновения deadlock (или cyclic reference — называйте, как хотите).

Даже специально такое долго придётся придумывать, а уж случайно точно не сделаешь! Да нуууууу, это как так надо постараться!?

CallbackPayload.h

#pragma once #include <functional> class CallbackPayload
{
public: CallbackPayload() = default; ~CallbackPayload() = default; CallbackPayload(const CallbackPayload &) = delete; CallbackPayload(CallbackPayload &&) = delete; CallbackPayload& operator=(const CallbackPayload &) = delete; CallbackPayload& operator=(CallbackPayload &&) = delete; void setCallback(std::function<void()> &&fn) { m_callbackFn = std::move(fn); } private: std::function<void()> m_callbackFn;
};

SomethingWithVeryImportantDestructor.h

#pragma once #include <iostream> class SomethingWithVeryImportantDestructor
{
public: SomethingWithVeryImportantDestructor() { std::cout << "SomethingWithVeryImportantDestructor()" << std::endl; } ~SomethingWithVeryImportantDestructor() { std::cout << "~SomethingWithVeryImportantDestructor()" << std::endl; } SomethingWithVeryImportantDestructor(const SomethingWithVeryImportantDestructor &) = delete; SomethingWithVeryImportantDestructor(SomethingWithVeryImportantDestructor &&) = delete; SomethingWithVeryImportantDestructor& operator=(const SomethingWithVeryImportantDestructor &) = delete; SomethingWithVeryImportantDestructor& operator=(SomethingWithVeryImportantDestructor &&) = delete;
};

Shared_Example4_incorrect.cpp

#include "SingletonShared.h"
#include "CallbackPayload.h"
#include "SomethingWithVeryImportantDestructor.h" class SharedSingleThreadedUtility
{
public: SharedSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_singleton(SingletonShared<CallbackPayload>::instance()) { std::cout << "SharedSingleThreadedUtility()" << std::endl; } ~SharedSingleThreadedUtility() { std::cout << "~SharedSingleThreadedUtility()" << std::endl; } void setCallback(std::function<void()> &&fn) { if ( m_singleton ) m_singleton->setCallback(std::move(fn)); } private: // A copy of smart pointer, not a reference std::shared_ptr<SingletonShared<CallbackPayload>> m_singleton;
}; int main()
{ auto utility = std::make_shared<SharedSingleThreadedUtility>(); auto something = std::make_shared<SomethingWithVeryImportantDestructor>(); // lambda with "utility" and "something" captured utility->setCallback( [utility, something](){} ); return 0;
}

Вывод в консоль

instance()
SingletonShared()
SharedSingleThreadedUtility()
SomethingWithVeryImportantDestructor()

Был создан синглтон.

Была создана утилита.

в интернетах встречаются посты типа «ну не будет вызван деструктор синглтона, ну и что из этого, он же всё равно должен существовать всё время работы программы»). Было создано Нечто-С-Очень-Важным-Деструктором (это я для устрашения добавил, т.к.

Но ни для одного из этих объектов не был вызван деструктор!

Из-за подмены целей средствами. Из-за чего?

SingletonWeak

SingletonWeak.h

#pragma once #include <memory>
#include <iostream> template<typename T>
class SingletonWeak : public T
{
public: ~SingletonWeak() { std::cout << "~SingletonWeak()" << std::endl; } SingletonWeak(const SingletonWeak &) = delete; SingletonWeak(SingletonWeak &&) = delete; SingletonWeak& operator=(const SingletonWeak &) = delete; SingletonWeak& operator=(SingletonWeak &&) = delete; static std::weak_ptr<SingletonWeak> instance() { std::cout << "instance()" << std::endl; // "new" and no std::make_shared because of private c-tor static auto inst = std::shared_ptr<SingletonWeak>(new SingletonWeak); return inst; } private: SingletonWeak() { std::cout << "SingletonWeak()" << std::endl; }
};

Такая модификация синглтона в открытых источниках если и приводится, то точно не часто. Я встречал какие-то странные вывернутые наизнанку варианты с непонятно как применённым std::weak_ptr, которые, похоже, не предлагают утилите ничего другого, кроме как продлевать синглтону жизнь:
Предлагаемый же мной вариант при правильном применении в слоях синглтона и утилиты:

  • защищает от действий в пользовательском слое, рассмотренных в вышеприведённых примерах, в том числе предотвращает deadlock;
  • определяет момент свёртывания приложения точнее, чем применение thread_local в Classic_Example3_correct, т.е. позволяет ближе подойти к краю;
  • не страдает теоретической проблемой подмены целей средствами (я не знаю, может ли из этой теоретической проблемы появиться ещё что-нибудь осязаемое, кроме deadlock).

Однако есть и недостаток: продление жизни синглтону всё же может позволить ещё ближе подойти к краю.

SingletonWeak, пример 1

Аналогичен Shared_Example3_correct.cpp.

Weak_Example1_correct.cpp

#include "SingletonWeak.h"
#include "Payload.h" #include <memory> class WeakSingleThreadedUtility
{
public: WeakSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_weak(SingletonWeak<Payload>::instance()) { } ~WeakSingleThreadedUtility() { // Sometimes this check may result as "false" even in case of incorrect usage, // and there's no way to guarantee a demonstration of undefined behaviour in colour if ( auto strong = m_weak.lock() ) for ( int i = 0; i < 100; ++i ) strong->add(i); } private: // A weak copy of smart pointer, not a reference std::weak_ptr<SingletonWeak<Payload>> m_weak;
}; // 1. Create an empty unique_ptr
// 2. Create singleton (because of WeakSingleThreadedUtility c-tor)
// 3. Create utility
std::unique_ptr<WeakSingleThreadedUtility> emptyUnique;
auto utilityUnique = std::make_unique<WeakSingleThreadedUtility>(); int main()
{ // This guarantee destruction in order: // - utilityUnique; // - singleton; // - emptyUnique. // This order is correct ... // ... but user swaps unique_ptrs emptyUnique.swap(utilityUnique); // Guaranteed destruction order is the same: // - utilityUnique; // - singleton; // - emptyUnique, // but now utilityUnique is empty, and emptyUnique is filled, // so destruction order is incorrect... // ... but utility have made a weak copy of shared_ptr when it was available, // so it's correct again. return 0;
}

Вывод в консоль

instance()
SingletonWeak()
~SingletonWeak()

Зачем нужен SingletonWeak, ведь никто не мешает утилите использовать SingletonShared как SingletonWeak? Да, никто не мешает. И даже никто не мешает утилите использовать SingletonWeak как SingletonShared. Но использовать их по назначению чуть проще, чем использовать не по назначению.

SingletonWeak, пример 2

Аналогичен Shared_Example4_incorrect, но только deadlock в данном случае не возникает.

Weak_Example2_correct.cpp

#include "SingletonWeak.h"
#include "CallbackPayload.h"
#include "SomethingWithVeryImportantDestructor.h" class WeakSingleThreadedUtility
{
public: WeakSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_weak(SingletonWeak<CallbackPayload>::instance()) { std::cout << "WeakSingleThreadedUtility()" << std::endl; } ~WeakSingleThreadedUtility() { std::cout << "~WeakSingleThreadedUtility()" << std::endl; } void setCallback(std::function<void()> &&fn) { if ( auto strong = m_weak.lock() ) strong->setCallback(std::move(fn)); } private: // A weak copy of smart pointer, not a reference std::weak_ptr<SingletonWeak<CallbackPayload>> m_weak;
}; int main()
{ auto utility = std::make_shared<WeakSingleThreadedUtility>(); auto something = std::make_shared<SomethingWithVeryImportantDestructor>(); // lambda with "utility" and "something" captured utility->setCallback( [utility, something](){} ); return 0;
}

Вывод в консоль

instance()
SingletonWeak()
WeakSingleThreadedUtility()
SomethingWithVeryImportantDestructor()
~SingletonWeak()
~SomethingWithVeryImportantDestructor()
~WeakSingleThreadedUtility()

Вместо заключения

И что, такая модификация синглтона избавит от неопределённого поведения? Я обещал, что хэппи-энда не будет. Следующие примеры показывают, что умелые диверсионные действия в пользовательском слое смогут разрушить даже корректную продуманную библиотеку с синглтоном (но надо признать, что такое вряд ли можно сделать случайно).

Shared_Example5_incorrect.cpp

#include "SingletonShared.h"
#include "Payload.h" #include <memory>
#include <cstdlib> class SharedSingleThreadedUtility
{
public: SharedSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_singleton(SingletonShared<Payload>::instance()) { } ~SharedSingleThreadedUtility() { // Sometimes this check may result as "false" even for destroyed singleton // preventing from visual effects of undefined behaviour ... //if ( m_singleton ) // for ( int i = 0; i < 100; ++i ) // m_singleton->add(i); // ... so this code will allow to demonstrate UB in colour for ( int i = 0; i < 100; ++i ) m_singleton->add(i); } private: // A copy of smart pointer, not a reference std::shared_ptr<SingletonShared<Payload>> m_singleton;
}; void cracker()
{ SharedSingleThreadedUtility();
} // 1. Register cracker() using std::atexit
// 2. Create singleton
// 3. Create utility
auto reg = [](){ std::atexit(&cracker); return 0; }();
auto utility = SharedSingleThreadedUtility(); // This guarantee destruction in order:
// - utility;
// - singleton.
// This order is correct.
// Additionally, there's a copy of shared_ptr in the class instance...
// ... but there was std::atexit registered before singleton,
// so cracker() will be invoked after destruction of utility and singleton.
// There's second try to create a singleton - and it's incorrect. int main()
{ return 0;
}

Вывод в консоль

instance()
SingletonShared()
~SingletonShared()
instance()

Weak_Example3_incorrect.cpp

#include "SingletonWeak.h"
#include "Payload.h" #include <memory>
#include <cstdlib> class WeakSingleThreadedUtility
{
public: WeakSingleThreadedUtility() // To ensure that singleton will be constucted before utility : m_weak(SingletonWeak<Payload>::instance()) { } ~WeakSingleThreadedUtility() { // Sometimes this check may result as "false" even in case of incorrect usage, // and there's no way to guarantee a demonstration of undefined behaviour in colour if ( auto strong = m_weak.lock() ) for ( int i = 0; i < 100; ++i ) strong->add(i); } private: // A weak copy of smart pointer, not a reference std::weak_ptr<SingletonWeak<Payload>> m_weak;
}; void cracker()
{ WeakSingleThreadedUtility();
} // 1. Register cracker() using std::atexit
// 2. Create singleton
// 3. Create utility
auto reg = [](){ std::atexit(&cracker); return 0; }();
auto utility = WeakSingleThreadedUtility(); // This guarantee destruction in order:
// - utility;
// - singleton.
// This order is correct.
// Additionally, there's a copy of shared_ptr in the class instance...
// ... but there was std::atexit registered before singleton,
// so cracker() will be invoked after destruction of utility and singleton.
// There's second try to create a singleton - and it's incorrect. int main()
{ return 0;
}

Вывод в консоль

instance()
SingletonWeak()
~SingletonWeak()
instance()

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»