グローバル状態とシングルトン

警告:以下の構造は、設計の悪いコードの兆候です:

  • Foo::getInstance()
  • DB::insert(...)
  • Article::setDb($db)
  • ClassName::$var または static::$var

これらの構造のいずれかがあなたのコードに存在しますか? それなら、それを改善する機会があります。これらは、さまざまなライブラリやフレームワークのサンプルソリューションでも見られる一般的な構造だと思うかもしれません。もしそうなら、それらのコードの設計は良くありません。

ここでは、学術的な純粋さについて話しているのではありません。これらの構造はすべて、共通点が1つあります:グローバル状態を利用しています。そして、それはコードの品質に破壊的な影響を与えます。クラスはその依存関係について嘘をつきます。コードは予測不可能になります。プログラマーを混乱させ、効率を低下させます。

この章では、なぜそうなるのか、そしてグローバル状態を回避する方法について説明します。

グローバル結合

理想的な世界では、オブジェクトは直接渡されたオブジェクトとのみ通信できるべきです。2つのオブジェクト AB を作成し、それらの間で参照を渡さなければ、AB も他のオブジェクトにアクセスしたり、その状態を変更したりすることはできません。これはコードの非常に望ましい特性です。バッテリーと電球を持っているようなものです。バッテリーとワイヤーで接続しない限り、電球は点灯しません。

しかし、これはグローバル(静的)変数やシングルトンには当てはまりません。オブジェクト A は、参照を渡さずに C::changeSomething() を呼び出すことで、ワイヤレスでオブジェクト C にアクセスして変更できます。オブジェクト B もグローバル C をつかむと、ABC を介して相互に影響を与えることができます。

グローバル変数の使用は、外部からは見えない新しい形式のワイヤレス結合をシステムにもたらします。コードの理解と使用を複雑にする煙幕を作り出します。開発者が依存関係を真に理解するには、ソースコードのすべての行を読む必要があります。クラスのインターフェースに精通するだけではありません。さらに、これは完全に不要な結合です。グローバル状態は、どこからでも簡単にアクセスでき、たとえばグローバル(静的)メソッド DB::insert() を介してデータベースに書き込むことができるため使用されます。しかし、これから示すように、それがもたらす利点はごくわずかであり、逆に引き起こす合併症は致命的です。

動作の観点からは、グローバル変数と静的変数に違いはありません。どちらも同じように有害です。

遠隔での不気味な作用

「遠隔での不気味な作用」 – 1935年にアルベルト・アインシュタインが、彼に鳥肌を立たせた量子物理学の現象をそう名付けました。 これは量子もつれであり、その特徴は、一方の粒子に関する情報を測定すると、たとえそれらが何百万光年も離れていても、即座に他方の粒子に影響を与えることです。 これは、光よりも速く何も伝播できないという宇宙の基本法則に明らかに違反しているように見えます。

ソフトウェアの世界では、「遠隔での不気味な作用」とは、分離されていると信じているプロセス(参照を渡さなかったため)を実行するが、システムの遠隔地で予期しない相互作用や状態の変化が発生し、それについて知らなかった状況を指すことができます。これはグローバル状態を介してのみ発生する可能性があります。

広範で成熟したコードベースを持つプロジェクトの開発チームに参加したと想像してください。新しい上司が新しい機能の実装を依頼し、あなたは適切な開発者としてテストを書くことから始めます。しかし、プロジェクトに慣れていないため、「このメソッドを呼び出すとどうなるか」のような探索的なテストをたくさん行います。そして、次のテストを書いてみます:

function testCreditCardCharge()
{
	$cc = new CreditCard('1234567890123456', 5, 2028); // あなたのカード番号
	$cc->charge(100);
}

コードを実行し、おそらく数回実行した後、しばらくして、実行するたびにクレジットカードから100ドルが引き落とされているという銀行からの通知が携帯電話に表示されることに気づきます 🤦‍♂️

一体どうしてテストが実際のお金の引き落としを引き起こしたのでしょうか? クレジットカードの操作は簡単ではありません。サードパーティのWebサービスと通信する必要があり、そのWebサービスのURLを知る必要があり、ログインする必要があり、などなど。 これらの情報はテストには含まれていません。さらに悪いことに、これらの情報がどこにあるのかさえわからないため、実行するたびに再び100ドルが引き落とされることがないように外部依存関係をモックする方法もわかりません。そして、新しい開発者として、これから行うことが100ドル貧しくなることにつながることをどうやって知ることができたのでしょうか?

これが遠隔での不気味な作用です!

プロジェクト内の結合がどのように機能するかを理解するまで、多くのソースコードを長時間掘り下げ、年上で経験豊富な同僚に尋ねるしかありません。 これは、クラス CreditCard のインターフェースを見ても、初期化する必要があるグローバル状態を特定できないためです。クラスのソースコードを見ても、どの初期化メソッドを呼び出す必要があるかはわかりません。最良の場合、アクセスされるグローバル変数を見つけて、そこから初期化方法を推測しようとすることができます。

このようなプロジェクトのクラスは病的な嘘つきです。クレジットカードは、インスタンス化して charge() メソッドを呼び出すだけで十分であるかのように装います。しかし、舞台裏では、支払いゲートウェイを表す別のクラス PaymentGateway と協力しています。そのインターフェースも、単独で初期化できると言っていますが、実際には、ある設定ファイルなどから資格情報を取得します。 このコードを書いた開発者には、CreditCardPaymentGateway を必要とすることは明らかです。彼らはこの方法でコードを書きました。しかし、プロジェクトに新しい人にとっては、それは完全な謎であり、学習を妨げます。

状況を修正するにはどうすればよいですか? 簡単です。APIに依存関係を宣言させます。

function testCreditCardCharge()
{
	$gateway = new PaymentGateway(/* ... */);
	$cc = new CreditCard('1234567890123456', 5, 2028);
	$cc->charge($gateway, 100);
}

コード内の結合が突然どのように明らかになるかに注目してください。charge() メソッドが PaymentGateway を必要とすることを宣言することで、コードがどのように結合されているかを誰かに尋ねる必要はありません。そのインスタンスを作成する必要があることを知っており、そうしようとすると、アクセスパラメータを提供する必要があることに気づきます。それらがなければ、コードを実行することさえできません。

そして最も重要なことは、これで支払いゲートウェイをモックできるため、テストを実行するたびに100ドルが請求されることはありません。

グローバル状態により、オブジェクトはAPIで宣言されていないものに密かにアクセスできるようになり、結果としてAPIを病的な嘘つきにしてしまいます。

以前はこのように考えていなかったかもしれませんが、グローバル状態を使用するたびに、秘密のワイヤレス通信チャネルを作成しています。遠隔での不気味なアクションは、開発者に潜在的な相互作用を理解するためにコードのすべての行を読むことを強制し、開発者の生産性を低下させ、新しいチームメンバーを混乱させます。 あなたがコードを作成した人なら、実際の依存関係を知っていますが、あなたの後に来る人は誰でも途方に暮れます。

グローバル状態を利用するコードを書かないでください。依存関係の受け渡しを優先してください。つまり、依存性注入です。

グローバル状態の脆弱性

グローバル状態とシングルトンを使用するコードでは、いつ誰がこの状態を変更したかが決して確実ではありません。このリスクは初期化時にすでに現れます。次のコードはデータベース接続を作成し、支払いゲートウェイを初期化することを目的としていますが、常に例外をスローし、原因を見つけるのは非常に時間がかかります:

PaymentGateway::init();
DB::init('mysql:', 'user', 'password');

PaymentGatewayオブジェクトが他のオブジェクトにワイヤレスでアクセスし、その一部がデータベース接続を必要とすることを理解するには、コードを詳細に調べる必要があります。したがって、PaymentGatewayの前にデータベースを初期化する必要があります。しかし、グローバル状態の煙幕はこれをあなたから隠します。個々のクラスのAPIが欺瞞的でなく、依存関係を宣言していたら、どれだけの時間を節約できたでしょうか?

$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);

同様の問題は、データベース接続へのグローバルアクセスを使用する場合にも発生します:

use Illuminate\Support\Facades\DB;

class Article
{
	public function save(): void
	{
		DB::insert(/* ... */);
	}
}

save()メソッドを呼び出すとき、データベース接続がすでに作成されているかどうか、そして誰がその作成を担当しているかは定かではありません。たとえば、テストのために実行時にデータベース接続を変更したい場合は、おそらくDB::reconnect(...)DB::reconnectForTest()などの追加のメソッドを作成する必要があるでしょう。

例を考えてみましょう:

$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();

$article->save()を呼び出すときに、テストデータベースが実際に使用されているという確信はどこにありますか? Foo::doSomething()メソッドがグローバルデータベース接続を変更した場合はどうなりますか? これを確認するには、クラスFooのソースコード、そしておそらく他の多くのクラスを調べる必要があります。しかし、このアプローチは短期的な答えしか提供せず、状況は将来変わる可能性があります。

そして、データベース接続をクラスArticle内の静的変数に移動したらどうなりますか?

class Article
{
	private static DB $db;

	public static function setDb(DB $db): void
	{
		self::$db = $db;
	}

	public function save(): void
	{
		self::$db->insert(/* ... */);
	}
}

これは何も変わりません。問題はグローバル状態であり、どのクラスに隠されているかはまったく関係ありません。この場合、前のケースと同様に、$article->save()メソッドを呼び出すときに、どのデータベースに書き込まれるかについての手がかりはありません。アプリケーションの反対側の誰かが、いつでもArticle::setDb()を使用してデータベースを変更できた可能性があります。私たちの手の下で。

グローバル状態は、アプリケーションを非常に脆弱にします。

しかし、この問題に対処する簡単な方法があります。APIに依存関係を宣言させるだけで、正しい機能が保証されます。

class Article
{
	public function __construct(
		private DB $db,
	) {
	}

	public function save(): void
	{
		$this->db->insert(/* ... */);
	}
}

$article = new Article($db);
// ...
Foo::doSomething();
$article->save();

このアプローチのおかげで、データベース接続の隠れた予期しない変更を心配する必要はなくなります。これで、記事がどこに保存されるかが確実になり、他の無関係なクラス内のコードの変更が状況を変えることはできなくなります。コードはもはや脆弱ではなく、安定しています。

グローバル状態を利用するコードを書かないでください。依存関係の受け渡しを優先してください。つまり、依存性注入です。

シングルトン

シングルトンは、有名なGang of Fourの出版物からの定義 によると、クラスを単一のインスタンスに制限し、それにグローバルアクセスを提供する設計パターンです。このパターンの実装は通常、次のコードに似ています:

class Singleton
{
	private static self $instance;

	public static function getInstance(): self
	{
		self::$instance ??= new self;
		return self::$instance;
	}

	// クラスの機能を実行する他のメソッド
}

残念ながら、シングルトンはアプリケーションにグローバル状態を導入します。そして、上で示したように、グローバル状態は望ましくありません。したがって、シングルトンはアンチパターンと見なされます。

コードでシングルトンを使用せず、他のメカニズムに置き換えてください。シングルトンは本当に必要ありません。ただし、アプリケーション全体でクラスの単一インスタンスの存在を保証する必要がある場合は、DIコンテナに任せてください。 これにより、アプリケーションシングルトン、つまりサービスが作成されます。これにより、クラスは自身の独自性を保証すること(つまり、getInstance()メソッドと静的変数を持たないこと)をやめ、その機能のみを実行します。したがって、単一責任の原則に違反しなくなります。

グローバル状態 対 テスト

テストを作成するとき、各テストは分離されたユニットであり、外部状態が入力されないことを前提としています。そして、テストから状態は出力されません。テストが完了すると、テストに関連するすべての状態はガベージコレクタによって自動的に削除されるはずです。これにより、テストは分離されます。したがって、テストは任意の順序で実行できます。

ただし、グローバル状態/シングルトンが存在する場合、これらの快適な前提はすべて崩壊します。状態はテストに入力および出力できます。突然、テストの順序が重要になる可能性があります。

シングルトンをテストできるようにするために、開発者はしばしば、インスタンスを別のインスタンスに置き換えることを許可するなどして、そのプロパティを緩和する必要があります。このようなソリューションは、せいぜいハックであり、保守や理解が困難なコードを作成します。グローバル状態に影響を与える各テストまたはtearDown()メソッドは、これらの変更を元に戻す必要があります。

グローバル状態は、ユニットテストにおける最大の頭痛の種です!

状況を修正するにはどうすればよいですか? 簡単です。シングルトンを利用するコードを書かないでください。依存関係の受け渡しを優先してください。つまり、依存性注入です。

グローバル定数

グローバル状態は、シングルトンや静的変数の使用に限定されず、グローバル定数にも関係する可能性があります。

値が新しい(M_PI)または有用な(PREG_BACKTRACK_LIMIT_ERROR)情報をもたらさない定数は、明らかに問題ありません。 逆に、情報をコード内にワイヤレスで渡す方法として機能する定数は、隠れた依存関係にすぎません。次の例のLOG_FILEのように。 定数FILE_APPENDの使用は完全に正しいです。

const LOG_FILE = '...';

class Foo
{
	public function doSomething()
	{
		// ...
		file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
		// ...
	}
}

この場合、APIの一部となるように、クラスFooのコンストラクタでパラメータを宣言する必要があります:

class Foo
{
	public function __construct(
		private string $logFile,
	) {
	}

	public function doSomething()
	{
		// ...
		file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
		// ...
	}
}

これで、ロギング用のファイルパスに関する情報を渡し、必要に応じて簡単に変更できるため、コードのテストと保守が容易になります。

グローバル関数と静的メソッド

静的メソッドとグローバル関数自体の使用が問題ではないことを強調したいと思います。DB::insert()や同様のメソッドの使用が不適切である理由を説明しましたが、それは常に、ある静的変数に格納されているグローバル状態の問題にすぎませんでした。DB::insert()メソッドは、データベース接続が格納されているため、静的変数の存在を必要とします。この変数がなければ、メソッドを実装することは不可能です。

DateTime::createFromFormat()Closure::fromCallablestrlen()、その他多くの決定論的な静的メソッドと関数の使用は、依存性注入と完全に一致しています。これらの関数は、同じ入力パラメータから常に同じ結果を返し、したがって予測可能です。グローバル状態は使用しません。

ただし、PHPには決定論的でない関数もあります。これらには、たとえば関数htmlspecialchars()が含まれます。その3番目のパラメータ$encodingが指定されていない場合、デフォルト値は設定オプションini_get('default_charset')の値になります。したがって、このパラメータを常に指定し、関数の予期しない動作の可能性を防ぐことをお勧めします。Netteはこれを一貫して行っています。

strtolower()strtoupper()などの一部の関数は、最近まで非決定論的に動作し、setlocale()の設定に依存していました。これは多くの合併症を引き起こし、最も一般的にはトルコ語を扱うときに発生しました。トルコ語では、ドット付きとドットなしの小文字と大文字のIを区別します。したがって、strtolower('I')は文字ıを返し、strtoupper('i')は文字İを返し、これによりアプリケーションが一連の不可解なエラーを引き起こし始めました。 しかし、この問題はPHPバージョン8.2で修正され、関数はもはやロケールに依存しません。

これは、グローバル状態が世界中の何千人もの開発者をどのように悩ませたかの良い例です。解決策は、それを依存性注入に置き換えることでした。

グローバル状態を使用できる場合はいつですか?

グローバル状態を利用できる特定の状況があります。たとえば、コードのデバッグ中に、変数の値を出力したり、プログラムの特定の部分の期間を測定したりする必要がある場合です。このような場合、後でコードから削除される一時的なアクションに関する場合、グローバルに利用可能なダンパーまたはストップウォッチを正当に利用できます。これらのツールはコードの設計の一部ではありません。

別の例は、正規表現を扱う関数preg_*であり、コンパイルされた正規表現をメモリ内の静的キャッシュに内部的に格納します。したがって、コードの異なる場所で同じ正規表現を複数回呼び出すと、一度だけコンパイルされます。キャッシュはパフォーマンスを節約し、同時にユーザーには完全に表示されないため、このような使用は正当と見なすことができます。

まとめ

私たちは、なぜ意味があるのか​​を議論しました:

  1. コードからすべての静的変数を削除する
  2. 依存関係を宣言する
  3. そして依存性注入を使用する

コードの設計を考えるとき、すべてのstatic $fooが問題であることを念頭に置いてください。コードがDIを尊重する環境であるためには、グローバル状態を完全に根絶し、依存性注入に置き換えることが不可欠です。

このプロセス中に、クラスが複数の責任を持っているため、分割する必要があることに気づくかもしれません。恐れないでください。単一責任の原則を目指してください。

この章の基礎となったFlaw: Brittle Global State & Singletonsなどの記事を提供してくれたMiško Hevery氏に感謝します。

バージョン: 3.x