Generyczny singleton w C++

Generyczny singleton w C++

I. Co/Dlaczego?

„Kolejna implementacja singletona w C++? A po co to komu?”. Jeśli taka była Twoja, pierwsza myśl po przeczytaniu tytułu tego artykułu, to najprawdopodobniej jesteś już znużony mnogością implementacji tego wzorca projektowego w C++, bądź masz już swojego faworyta w tym temacie (pomijam sytuację, gdy po prostu uważasz singleton za antywzorzec i kategorycznie odrzucasz jego używanie). Zdecydowanie C++ cierpi na nadmiar przeróżnych implementacji singletona. Począwszy od implementacji naiwnych, przez double-check locking, po warianty z zmienno-argumentową metodą pobierania/inicjalizacji instancji, czy wreszcie fabryki singletonów. Co więcej większość z tych implementacji zupełnie inaczej definiuje sposób inicjalizacji singletona, czy też sposób jego pozyskiwania.
Warto w tym miejscu zastanowić się, czym taki singleton powinien się charakteryzować. Zgodnie z bandą czworga można stwierdzić, że singleton powinien gwarantować istnienie tylko jednej instancji danej klasy oraz jej globalną dostępność. Ponadto pożądanymi cechami takiego singletona mogłyby być lazy initialization oraz thread safety. W dalszej kolejności singleton mógłby oferować zmienno-argumentowy sposób inicjalizacji instancji. Jeśli weźmiemy pod uwagę wszystkie powyższe wymagania to na placu boju zostaniem nam właściwie tylko implementacja z zmienno-argumentowym sposobem inicjalizacji instancji, podobna do tej:

template <typename T>
class singleton
{
public:
  
  template <typename... Args>
  static void init(Args&&... args)
  {
    std::call_once(once,
      [&args...]() { instance.reset(new T(std::forward<Args>(args)...));}
    );
  }
  
  static T& get_instance()
  {
    return *instance.get();
  }

private:
  
  static std::unique_ptr<T> instance;
  static std::once_flag once;
};

template <typename T>
std::unique_ptr<T> singleton<T>::instance = nullptr;

template <typename T>
std::once_flag  singleton<T>::once;

class foo : public singleton<foo>
{
  friend class singleton<foo>;

private:

    template <typename... Args>
    foo(Args&&...) {}
};

int main()
{
  foo::init("asdasd", 12.f, 1231);
  foo& f = foo::get_instance();
  return 0;
}

Głównym problemem tej implementacji jest konieczność wywoływania metody init nawet wtedy, gdy klasa będąca singletonem posiada tylko konstruktor domyślny. Jeszcze gorszym rozwiązaniem byłoby usunięcie metody init i przeniesienie czynności inicjalizacji instancji do metody get_instance. Tego typu rozwiązanie łamie zasadę pojedynczej odpowiedzialności każdej z metod,
a ponadto wprowadza zamęt w sposobie użycia singletona (raz wołamy get_instance z parametrami, a później już bez). Problemy nie kończą się jednak tylko na tym, musimy za każdym razem zaprzyjaźniać singleton w klasie pochodnej, nie możemy użyć std::make_unique, gdyż konstruktor klasy pochodnej jest prywatny, itd.

Wszystkie, powyższe przesłanki skłoniły mnie do napisania jeszcze jednej wersji singletona w C++. Moja implementacja stanowi próbę rozwiązania wspomnianych powyżej problemów, tym niemniej daleki jestem od stwierdzenia, że moje rozwiązanie jest złotym środkiem na wszelkie bolączki. Proszę traktować zaproponowane rozwiązanie raczej, jako proof-of-concept niż gotową metodę.

W niniejszym artykule spróbuję przedstawić drogę od najbardziej naiwnej implementacji singletona w C++ do ostatecznego rozwiązania, które spełnia (a przynajmniej mam taką nadzieję :D) przedstawione powyżej warunki.

Kod finalnej wersji singletona omówionego w tym artykule można znaleźć tutaj: https://github.com/Satirev/generic-singleton

Uwaga – zakładam, że czytelnik zna podstawy języka C++ oraz ma chociaż pobieżną wiedzę na temat standardów C++11/14.

II. Pierwsze kroki

Zacznijmy od samego początku. Chcielibyśmy napisać taki singleton, który gwarantowałby istnienie tylko jednej instancji danej klasy oraz zapewniał globalny dostęp do tej instancji. Zastanówmy się jak zagwarantować istnienie tylko jednej instancji. Pierwsze co przychodzi do głowy (przynajmniej mi ;P) to zmienna statyczna. Użycie takiej zmiennej dodatkowo zdeterminuje lazy initialization. Zatem upieczemy dwie pieczenie na jednym ogniu. Co więcej, to nie koniec zalet! Przez wzgląd na to, że w niniejszym artykule używam standardu C++14, zatem wybór zmiennej statycznej na instancję singletona implikuje także, to że dopóki konstruktor klasy będącej singletonem będzie thread safe dopóty nasza inicjalizacja będzie thread safe! Brzmi cudownie. Spójrzmy zatem na naszą implementację:

template <typename T>
class singleton : T
{ 
public:
    
  static T& get_instance()
  {
    static T&& instance = singleton<T>{};
    return instance;
  }

private:    
  
  singleton() = default;
    
  singleton(const singleton&) = delete;
  
  singleton& operator= (const singleton&) = delete;
};

class foo
{     
protected:
  
  foo() = default;
  virtual ~foo() = default;
};

using foo_singleton = singleton<foo>;

int main()
{
  foo& f = foo_singleton::get_instance();
  return 0;
}   

Omówienie kodu rozpoczniemy od statycznej metody get_instance. Tworzymy tam statyczną referencję do r-wartości (która sama jest l-wartością) i zwracamy referencję do tej zmiennej. Ponadto uniemożliwiliśmy kreowanie innych instancji klasy foo poprzez zdefiniowanie konstruktora tej klasy w sekcji chronionej. Nie ma także problemów z globalną dostępnością takiego singletona. Niestety na tym koniec zalet takiej implementacji. Powyższe rozwiązanie pozwala tylko tworzyć obiekty, które mają domyślny konstruktor. W związku z tym spróbujmy zastanowić się nad umożliwieniem tworzenia instancji na podstawie zadanych parametrów.

III. Drugie podejście

Wraz z rozpatrywaniem argumentów potrzebnych do stworzenia singletona wprowadzimy do naszej implementacji nową metodę init, odpowiedzialną za tworzenie instancji. Natomiast metoda get_instance zacznie pełnić tylko rolę gettera. Przez wzgląd na ten podział musimy zrezygnować z lokalnej, statycznej instancji. Zamienimy ją na std::unique_ptr. Chcąc nadal utrzymać thread safety użyjemy funkcji std::call_once przy kreowaniu instancji. Zobaczmy, jak prezentuje się nasz kod po wprowadzeniu tych zmian:

template <typename T>
class singleton : T
{ 
public:

  template <typename... Args>
  static void init(Args&&... args)
  {
      std::call_once(once,
        [&args...](){ instance.reset(new singleton<T>(std::forward<Args>(args)...)); }
      );
  }

  static T& get_instance()
  {
    assert(instance && "Instance does not exist!");  
    return *instance.get();
  }

private:    
  
  template <typename... Args>
  singleton(Args&&... args)
    : T(std::forward<Args>(args)...)
  {}
    
  singleton(const singleton&) = delete;
  
  singleton& operator= (const singleton&) = delete;
  
  static std::unique_ptr<T> instance;
  static std::once_flag once;
};

template <typename T>
std::unique_ptr<T> singleton<T>::instance = nullptr;

template <typename T>
std::once_flag singleton<T>::once;

class foo
{ 
public:

  virtual ~foo() = default;
  
protected:
  
  template <typename... Args>
  foo(Args&&...)
  {}
};

using foo_singleton = singleton<foo>;

int main()
{
  foo_singleton::init("asdasd", 1231, 35.f);    
  foo& f = foo_singleton::get_instance();
  return 0;
}

Pierwsze co rzuca się w oczy to fakt, że utraciliśmy lazy initialization. Na tę chwilę musimy expilicit wywołać metodę init w celu utworzenia instancji. Wprowadziło to kłopot przy tworzeniu instancji typów posiadających konstruktor domyślny. Wartym odnotowania jest także przeniesienie destruktora klasy pochodnej do sekcji publicznej przez wzgląd na użycie std::unique_ptr w zamian lokalnej, zmiennej statycznej.

IV. Już prawie...

Spróbujmy rozwiązać problem z koniecznością wywoływania metody init dla typów posiadających konstruktor domyślny. Musielibyśmy w jakiś, magiczny sposób wykrywać, czy klasa posiada konstruktor domyślny, czy też nie. W zależności od tego warunku moglibyśmy zezwolić na lazy initialization wtedy, gdy klasa posiada konstruktor domyślny oraz wymusisz jawną inicjalizację w przeciwnym przypadku. Na nasze szczęście biblioteka standardowa udostępnia trait std::is_default_constructible. Trait ten wymaga żeby konstruktory badanej klasy były dostępne publicznie. W związku z tym musimy napisać lekko zmodyfikowaną wersję tego traitu, pozwalającą badać także klasy, które mają zdefiniowane konstruktory w sekcji protected. Tak prezentuje się nasz trait:

namespace std
{

  template <typename T>
  struct is_default_or_protected_constructible
  {
    struct X : T {};
    static const auto value = std::is_default_constructible<X>::value;
  };

}

Potrzebujemy teraz mechanizmu, który na podstawie zdefiniowanego traitu wykonywałby odpowiednią akcję. Użyjemy w tym celu tag dispatching. Spójrzmy jak przedstawiają się nasze dwa scenariusze:

template <typename T>
class singleton;

template <bool = false>
struct get_instance_selector
{
  template <typename T>
  static T& get_instance()
  {
    assert(singleton<T>::instance && "Instance does not exist!");
    return *singleton<T>::instance.get();
  }
};

template <>
struct get_instance_selector<true>
{
  template <typename T>
  static T& get_instance()
  {
    std::call_once(singleton<T>::once,
      []()
      { 
          singleton<T>::instance.reset(new singleton<T>{}); 
      }
    );
    return *singleton<T>::instance.get();
  }
};

Wydaje mi się, że kod jest na tyle czytelny, że nie wymaga dodatkowego komentarza. Spójrzmy teraz jak prezentuje się nasz singleton:

template <typename T>
class singleton : T
{
public:
    
  template <typename... Args>
  static void init(Args&&... args)
  {	
    std::call_once(once,
      [&args...](){ instance.reset(new singleton<T>(std::forward<Args>(args)...)); }
    );
  } 

  static T& get_instance()
  {
    return get_instance_selector<
        std::is_default_or_protected_constructible<T>::value
      >::template get_instance<T>();
  }

private:

  singleton(const singleton&) = delete;

  singleton& operator=(const singleton&) = delete;  
  
  template <typename... Args>
  singleton(Args&&... args)
    : T(std::forward<Args>(args)...)
  {}
  
  friend struct get_instance_selector<false>;
  friend struct get_instance_selector<true>;

  static std::once_flag once;
  static std::unique_ptr<T> instance;    
};

template <typename T>
std::once_flag singleton<T>::once;

template <typename T>
std::unique_ptr<T> singleton<T>::instance = nullptr;

Dzięki wprowadzonym zmianom w czasie kompilacji rozstrzygana jest kwestia inicjalizacji singletona. Zatem tam gdzie trzeba uzyskamy lazy initialization, a także wymusimy jawną inicjalizację w pozostałych przypadkach. Całość zaczyna wyglądać tak, jak byśmy tego oczekiwali ;)

V. Nareszcie koniec!

Wprowadźmy na koniec drobną poprawkę, tj. zamieńmy new na std::make_unique. Nie będzie to jednak, aż tak trywialne jak mogłoby się zdawać. Musimy przenieść definicję konstruktora klasy singleton do sekcji publicznej, tak by std::make_unique miał do niego dostęp. Ta zmiana wprowadza poważną lukę w naszej implementacji. Po tej zmianie można bez problemu utworzyć nową instancję singletona w dowolnym miejscu programu. Nie o taki singleton walczyliśmy ;( Na szczęście rozwiązać ten problem można na kilka, bardzo prostych sposobów. Ja zdecydowałem się na dodanie wewnętrznej, prywatnej struktury, która zostanie użyta do inicjalizacji singletona. Dzięki temu tylko singleton i klasy z nim zaprzyjaźnione będą mogły utworzyć jego instancję.

Po zaaplikowaniu zmian kod finalnej wersji singletona wygląda następująco:

template <typename T>
class singleton : public T
{

  struct secret {};

public:
    
  template <typename... Args>
  static void init(Args&&... args)
  {	
    std::call_once(once,
      [&args...]() 
      { 
        instance = std::unique_ptr<T>{std::make_unique<singleton<T>>(secret{}, std::forward<Args>(args)...)};
      }
    );
  } 

  static T& get_instance()
  {
    return get_instance_selector<
        std::is_default_or_protected_constructible<T>::value
      >::template get_instance<T>();
  }

  singleton(const singleton&) = delete;

  singleton& operator=(const singleton&) = delete;  
  
  template <typename... Args>
  singleton(const secret&, Args&&... args)
    : T(std::forward<Args>(args)...) {}

private:

  friend struct get_instance_selector<false>;
  friend struct get_instance_selector<true>;

  static std::once_flag once;
  static std::unique_ptr<T> instance;    
};

template <typename T>
std::once_flag singleton<T>::once;

template <typename T>
std::unique_ptr<T> singleton<T>::instance = nullptr;

}

5 komentarzy

Faktycznie, nie spojrzałem, że konstruktor jest prywatny/"niewołalny" z zewnątrz i musimy używać init() : P

@Criss: nie nadpisze, bo std::once_flag będzie już ustawiony (via wcześniejszy call singleton<T>::init(...)). Btw, ten kod to raczej taka ciekawostka, dodatkowo już trochę czasowa (2,5 letnia). Dzisiaj napisałbym to raczej zupełnie inaczej, a tak po prawdzie to w ogóle bym nie używał singletona ; p

A co w przypadku, gdy T jest "default_or_protected_constructible", ale wewnętrzna instancja została utworzona za pomocą innego konstruktora (poprzez szablon konstruktora singletona)? Czy wtedy zawołanie get_instance nie nadpisze nam wcześniej utworzonego obiektu, nowym utworzonym poprzez konstruktor domyślny?

@alagner, sprawa jest prosta. Singleton ma usunięty konstruktor kopiujący więc nie możemy zdefiniować statycznej instancji w taki sposób static T instance = singleton<T>{};. Możemy jednak pobrać referencję do r-value tej instancji i przekazać referencję do l-value wskazującej na tę r-value. Prawda, że to proste? ;)

static T& get_instance()
{
static T&& instance = singleton<T>{};
return instance;
}

Co daje nam ta referencja do rvalue, bo nie do końca łapię tego ideę? Chodzi o thread safety przez zwracanie "tymczasowego" obiektu?