シングルトンパターンの使用

はじめに


多くはすでにシングルトンなどの用語に精通しています。 要するに、これは単一のインスタンスを持つオブジェクトを記述するパターンです。 そのようなインスタンスを作成するには多くの方法があります。 しかし、今ではそれについてではありません。 また、マルチスレッドに関連する問題も省略しますが、このパターンを使用する場合、これは非常に興味深い重要な質問です。 シングルトンの正しい使い方についてお話したいと思います。

このトピックに関する文献を読むと、このアプローチに対するさまざまな批判に遭遇する可能性があります。 欠点のリストは次のとおりです[1]
  1. シングルトンはSRP(Single Responsibility Principle)に違反します-シングルトンクラスは、その即時の責任を果たすことに加えて、コピー数の制御にも関与します。
  2. シングルトンに対する通常のクラスの依存関係は、パブリッククラスコントラクトには表示されません。 通常、シングルトンインスタンスはメソッドパラメーターでは渡されず、getInstance()から直接取得されるため、シングルトンへのクラスの依存関係を調べるには、各メソッドの本体に入る必要があります。オブジェクトのパブリックコントラクトを見るだけでは十分ではありません。 結果として、その後のシングルトンを複数のインスタンスを含むオブジェクトに置き換える際のリファクタリングの複雑さ。
  3. グローバルステータス。 誰もがすでにグローバル変数の危険性を知っているようですが、これは同じ問題です。 クラスのインスタンスにアクセスするとき、このクラスの現在の状態、誰が、いつ変更したかがわからず、この状態は期待どおりにならない場合があります。 言い換えると、シングルトンの操作の正確さは、シングルトンの呼び出しの順序に依存します。これにより、サブシステムが相互に暗黙的に依存し、その結果、開発が大幅に複雑になります。
  4. シングルトンが存在すると、アプリケーション全体および特にシングルトンを使用するクラスのテスト容易性が低下します。 第一に、シングルトンの代わりに、モックオブジェクトをプッシュできません。第二に、シングルトンに状態を変更するためのインターフェースがある場合、テストは互いに依存し始めます。

したがって、これらの問題が存在する場合、多くはこのパターンの使用を避けるべきであると結論付けます。 一般に、上記の問題には同意しますが、これらの問題に基づいて、シングルトーンを使用する価値がないと結論付けることはできません。 シングルトンを使用している場合でも、私が意味することと、これらの問題を回避する方法を詳しく見てみましょう。

実装


最初に注意したいのは、シングルトンはインターフェイスではなく実装です。 これはどういう意味ですか? つまり、クラスは、可能な場合は何らかのインターフェイスを使用する必要があります。そうでない場合は、シングルトンが存在するかどうかにかかわらず、クラスは認識されないため、知らないはずです。 シングルトンを明示的に使用すると、これらの問題が発生します。 一見、見た目が良いので、人生でどのように見えるか見てみましょう。

このアイデアを実装するために、依存性注入と呼ばれる強力なアプローチを使用します。 その本質は、インターフェイスを使用しているクラスが誰がいつそれを行うかを気にせずに、何らかの方法で実装をクラスに埋めることです。 これらの質問は彼にはまったく興味がありません。 彼が知る必要があるのは、提供された機能を適切に使用する方法だけです。 この場合の機能インターフェースは、抽象インターフェースまたは特定のクラスのいずれかです。 私たちの特定のケースでは、それは重要ではありません。

アイデアがあります。C++で実装しましょう。 ここでは、テンプレートとその特殊化の可能性が役立ちます。 まず、必要なインスタンスへのポインターを含むクラスを定義します。
template<typename T> struct An { An() { clear(); } T* operator->() { return get0(); } const T* operator->() const { return get0(); } void operator=(T* t) { data = t; } bool isEmpty() const { return data == 0; } void clear() { data = 0; } void init() { if (isEmpty()) reinit(); } void reinit() { anFill(*this); } private: T* get0() const { const_cast<An*>(this)->init(); return data; } T* data; }; 

説明されたクラスはいくつかの問題を解決します。 まず、クラスの必要なインスタンスへのポインターを格納します。 次に、インスタンスが存在しない場合、anFill関数が呼び出されます。この関数は、インスタンスが存在しない場合に目的のインスタンスで埋められます(reinitメソッド)。 クラスにアクセスすると、インスタンスは自動的に初期化され、呼び出されます。 anFill関数の実装を見てみましょう。
 template<typename T> void anFill(An<T>& a) { throw std::runtime_error(std::string("Cannot find implementation for interface: ") + typeid(T).name()); } 

したがって、デフォルトでは、この関数は宣言されていない関数の使用を防ぐために例外をスローします。

使用例


次に、クラスがあると仮定します。
 struct X { X() : counter(0) {} void action() { std::cout << ++ counter << ": in action" << std::endl; } int counter; }; 

さまざまなコンテキストで使用するシングルトンにしたいと考えています。 これを行うには、クラスXのanFill関数を特化します。
 template<> void anFill<X>(An<X>& a) { static X x; a = &x; } 

この場合、最も単純なシングルトンを使用したため、具体的な実装は重要ではありません。 この実装はスレッドセーフではないことに注意してください(マルチスレッドの問題については別の記事で説明します)。 これで、クラスXを次のように使用できます。
 An<X> x; x->action(); 

または簡単:
 An<X>()->action(); 

表示されるもの:
 1: in action 

アクションが再び呼び出されると、以下が表示されます。
 2: in action 

これは、まだ状態があり、クラスXのインスタンスが正確に1であることを意味します。 次に、例を少し複雑にしましょう。 これを行うには、クラスXの使用を含む新しいクラスYを作成します。
 struct Y { An<X> x; void doAction() { x->action(); } }; 

デフォルトのインスタンスを使用したい場合は、次を実行するだけです。
 Y y; y.doAction(); 

前の呼び出しの後に表示されるもの:
 3: in action 

ここで、クラスの別のインスタンスを使用したいとします。 これは非常に簡単です。
 X x; yx = &x; y.doAction(); 

つまり クラスYに(既知の)インスタンスを設定し、対応する関数を呼び出します。 画面上に表示されます:
 1: in action 

次に、抽象化インターフェースのケースを調べてみましょう。 抽象基本クラスを作成します。
 struct I { virtual ~I() {} virtual void action() = 0; }; 

このインターフェイスの2つの異なる実装を定義します。
 struct Impl1 : I { virtual void action() { std::cout << "in Impl1" << std::endl; } }; struct Impl2 : I { virtual void action() { std::cout << "in Impl2" << std::endl; } }; 

デフォルトでは、Impl1の最初の実装を使用して入力します。
 template<> void anFill<I>(An<I>& a) { static Impl1 i; a = &i; } 

したがって、次のコード:
 An<I> i; i->action(); 

結論を出します:
 in Impl1 

インターフェイスを使用してクラスを作成します。
 struct Z { An<I> i; void doAction() { i->action(); } }; 

次に、実装を変更します。 次に、以下を実行します。
 Z z; Impl2 i; zi = &i; z.doAction(); 

結果:
 in Impl2 


アイデア開発


一般的に、これは終了する可能性があります。 ただし、使いやすくするために便利なマクロを追加する価値があります。
 #define PROTO_IFACE(D_iface) \ template<> void anFill<D_iface>(An<D_iface>& a) #define DECLARE_IMPL(D_iface) \ PROTO_IFACE(D_iface); #define BIND_TO_IMPL_SINGLE(D_iface, D_impl) \ PROTO_IFACE(D_iface) { a = &single<D_impl>(); } #define BIND_TO_SELF_SINGLE(D_impl) \ BIND_TO_IMPL_SINGLE(D_impl, D_impl) 

多くの人はマクロは悪だと言うかもしれません。 私はこの事実に精通していると責任を持って宣言します。 それにもかかわらず、それは言語の一部であり、私が教義と偏見の対象ではないことに加えて、それを使用することができます。

DECLARE_IMPLマクロは、デフォルトのパディング以外のパディングを宣言します。 実際、この行は、このクラスでは明示的な初期化が行われない場合、特定の値が自動的に入力されることを示しています。 マクロBIND_TO_IMPL_SINGLEは、実装のためにCPPファイルで使用されます。 シングルトンインスタンスを返す単一の関数を使用します。
 template<typename T> T& single() { static T t; return t; } 

マクロBIND_TO_SELF_SINGLEを使用すると、そのインスタンスがクラスに使用されます。 明らかに、抽象クラスの場合、このマクロは適用されず、クラス実装の仕様でBIND_TO_IMPL_SINGLEを使用する必要があります。 この実装は、CPPファイルでのみ非表示および宣言できます。

ここで、構成などの特定の例を使用することを検討してください。
 // IConfiguration.hpp struct IConfiguration { virtual ~IConfiguration() {} virtual int getConnectionsLimit() = 0; virtual void setConnectionLimit(int limit) = 0; virtual std::string getUserName() = 0; virtual void setUserName(const std::string& name) = 0; }; DECLARE_IMPL(IConfiguration) // Configuration.cpp struct Configuration : IConfiguration { Configuration() : m_connectionLimit(0) {} virtual int getConnectionsLimit() { return m_connectionLimit; } virtual void setConnectionLimit(int limit) { m_connectionLimit = limit; } virtual std::string getUserName() { return m_userName; } virtual void setUserName(const std::string& name) { m_userName = name; } private: int m_connectionLimit; std::string m_userName; }; BIND_TO_IMPL_SINGLE(IConfiguration, Configuration); 

さらに、他のクラスでも使用できます。
 struct ConnectionManager { An<IConfiguration> conf; void connect() { if (m_connectionCount == conf->getConnectionsLimit()) throw std::runtime_error("Number of connections exceeds the limit"); ... } private: int m_connectionCount; }; 


結論


その結果、次の点に注意します。
  1. インターフェイスの依存関係の明示的な定義:依存関係を探す必要はなくなりました。依存関係はすべてクラス宣言に記述されており、これはインターフェイスの一部です。
  2. シングルトンインスタンスおよびクラスインターフェイスへのアクセスの提供は、異なるオブジェクトに分離されます。 したがって、誰もが彼の問題を解決し、それによってSRPを保存します。
  3. 複数の構成がある場合、必要なインスタンスを問題なくConnectionManagerクラスに簡単に入力できます。
  4. クラスのテスト容易性:モックオブジェクトを作成し、たとえば、connectメソッドを呼び出したときに条件が正しく機能することを確認できます。
     struct MockConfiguration : IConfiguration { virtual int getConnectionsLimit() { return 10; } virtual void setConnectionLimit(int limit) { throw std::runtime_error("not implemented in mock"); } virtual std::string getUserName() { throw std::runtime_error("not implemented in mock"); } virtual void setUserName(const std::string& name) { throw std::runtime_error("not implemented in mock"); } }; void test() { // preparing ConnectionManager manager; MockConfiguration mock; manager.conf = &mock; // testing try { manager.connect(); } catch(std::runtime_error& e) { //... } } 


したがって、説明したアプローチにより、この記事の冒頭で示した問題が解消されます。 後続の記事では、ライフタイムとマルチスレッドに関連する重要な問題に対処したいと思います。

文学


[1] RSDNフォーラム:シングルトンの欠陥リスト
[2] ウィキペディア:シングルトン
[3] C ++内部:シングルトン

Source: https://habr.com/ru/post/J116577/


All Articles