グローバルステートとシングルトン
警告以下の構文は、コードの設計が不十分な場合に見られる症状です:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
またはstatic::$var
あなたのコードにこのような構文はありませんか?もしそうなら、改善するチャンスです。これらはよくある構成で、さまざまなライブラリやフレームワークのサンプル・ソリューションでよく見かけるものだと思うかもしれません。もしそうなら、そのコード設計には欠陥があります。
ここでは学術的な純度の話をしているのではない。これらのコンストラクトに共通しているのは、グローバル・ステートを利用しているということだ。そしてこれは、コードの品質に破壊的な影響を与える。クラスは依存関係をごまかす。コードは予測不可能になる。開発者を混乱させ、効率を低下させる。
この章では、なぜそうなるのか、そしてグローバル・ステートを回避する方法を説明します。
グローバルインターリンキング
理想的な世界では、オブジェクトは直接渡されたオブジェクトとしか通信できないはずだ。もし私が2つのオブジェクトA
とB
を作成し、その間に決して参照を渡さないとすると、A
もB
も、もう一方の状態にアクセスしたり変更したりすることはできない。これはコードにとって非常に望ましい性質である。これは、電池と電球があるようなもので、電池をワイヤーでつなぐまで電球は点灯しない。
しかし、これはグローバル(静的)変数やシングルトンには当てはまらない。オブジェクトA
は、C::changeSomething()
を呼び出すことで、ワイヤレスでオブジェクトC
にアクセスし、参照渡しをすることなくそれを変更することができる。オブジェクトB
がグローバル変数C
にもアクセスする場合、A
とB
は、C
を介して互いに影響を与え合うことができます。
グローバル変数の使用は、外部からは見えない新しい形のワイヤレスカップリングを導入する。これは、コードの理解と使用を複雑にする煙幕を作ります。依存関係を真に理解するためには、開発者はクラス・インターフェイスに精通するだけでなく、ソースコードのすべての行を読まなければなりません。しかも、このもつれ合いはまったく不要なものだ。グローバル・ステートが使われるのは、どこからでも簡単にアクセスでき、例えばグローバル(静的)メソッドDB::insert()
を使ってデータベースに書き込むことができるからです。しかし、後述するように、グローバル・ステートがもたらす利点はごくわずかであり、その一方で、グローバル・ステートがもたらす複雑さは深刻です。
動作の面では、グローバル変数と静的変数の間に違いはありません。どちらも同じように有害です。
遠距離の不気味な作用
1935年、アルベルト・アインシュタインは、量子物理学のある現象を「Spooky action at a distance」(距離による不気味な作用)と名付けた。 量子もつれとは、ある粒子に関する情報を測定すると、たとえそれが何百万光年も離れていても、すぐに別の粒子に影響を与えるという特殊性のことである。 これは、「光より速く移動するものはない」という宇宙の基本法則を一見破っているように見える。
ソフトウェアの世界では、(参照を渡していないので)孤立していると思われるプロセスを実行しても、オブジェクトに伝えていないシステムの遠い場所で予期せぬ相互作用や状態変化が起こる状況を「spooky action at a distance」と呼ぶことができます。これはグローバルな状態を通してのみ起こりうることです。
大規模で成熟したコードベースを持つプロジェクト開発チームに参加することを想像してください。新しいリーダーから新機能の実装を依頼されたあなたは、優秀な開発者らしく、テストを書くことから始めます。しかし、あなたはプロジェクトに参加したばかりなので、「このメソッドを呼び出したらどうなるか」という探索的なテストをたくさん行います。そして、次のようなテストを書こうとします。
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // your card number
$cc->charge(100);
}
あなたはコードを実行し、おそらく数回実行しました。しばらくして、銀行からあなたの携帯電話に、実行するたびに100ドルがあなたのクレジットカードに請求されたという通知に気づきます 🤦♂️
一体どうやってテストで実際の請求が発生するのでしょうか?クレジットカードで操作するのは簡単ではありません。サードパーティのウェブサービスとやりとりしなければならない、そのウェブサービスのURLを知っていなければならない、ログインしなければならない、などなど。 これらの情報は、テストには一切含まれていません。さらに悪いことに、この情報がどこに存在するのか、したがって、実行のたびに100ドルが再び請求されることがないように、外部の依存関係をどのように模擬すればよいのかさえもわからないのです。そして新米開発者であるあなたは、これからやろうとしていることが100ドル貧乏になることにつながると、どうやって知ることになるのでしょうか?
遠目で見ると不気味な動作ですね!?
プロジェクト内の接続の仕組みが理解できるまで、先輩や経験者に聞きながら、たくさんのソースコードを掘り下げるしかないのです。
これは、CreditCard
クラスのインターフェイスを見ても、初期化が必要なグローバル状態を判断できないことに起因しています。クラスのソースコードを見ても、どの初期化メソッドを呼び出せばいいのかがわからないのです。せいぜい、アクセスされているグローバル変数を見つけ、そこから初期化方法を推測するくらいです。
このようなプロジェクトのクラスは病的な嘘つきである。ペイメントカードは、インスタンス化してcharge()
メソッドを呼び出すだけでよいように装っています。しかし、それは密かに別のクラス、PaymentGateway
と相互作用している。そのインターフェースでさえ、独立して初期化できると言っているが、実際には、ある設定ファイルからクレデンシャルを引き出したりするのである。
このコードを書いた開発者には、CreditCard
がPaymentGateway
を必要とすることは明らかです。彼らはこのようにコードを書きました。しかし、このプロジェクトに初めて参加する人にとっては、これは完全な謎であり、学習の妨げになります。
どうすればこの状況を解決できるのか?簡単です。Let the API declare dependencies.(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;
}
// and other methods that perform the functions of the class
}
残念ながら、シングルトンはアプリケーションにグローバルな状態を導入することになります。そして、上で示したように、グローバルな状態は望ましくありません。これが、シングルトンがアンチパターンと言われる所以です。
コードにシングルトンを使わず、他のメカニズムに置き換えてください。シングルトンは本当に必要ない。しかし、アプリケーション全体に対して、あるクラスの単一のインスタンスの存在を保証する必要がある場合は、DIコンテナに任せます。
したがって、アプリケーションシングルトン(サービス)を作成します。これにより、クラスは独自のユニークさを持たなくなり(つまり、getInstance()
メソッドや静的変数を持たなくなり)、その機能のみを実行するようになります。したがって、単一責任の原則に違反することはなくなる。
グローバル・ステート・バーズ・テスト
テストを書くとき、各テストは孤立したユニットであり、外部の状態が入り込むことはないと仮定します。また、テストから離れる状態もない。テストが完了すると、テストに関連する状態は、ガベージコレクタによって自動的に削除されるはずです。これにより、テストは孤立したものになります。したがって、テストを任意の順序で実行することができます。
しかし、グローバルな状態やシングルトンが存在する場合、これらの素敵な仮定はすべて崩れてしまいます。状態はテストに入り、テストから出ることができる。突然、テストの順番が問題になることがある。
シングルトンをテストするために、開発者はしばしば、インスタンスを別のものに置き換えるなどして、その特性を緩和しなければなりません。このような解決策は、せいぜいハック程度で、維持と理解が困難なコードを生成します。グローバルな状態に影響を与えるテストやメソッド(tearDown()
)は、それらの変更を元に戻さなければなりません。
グローバルステートは、ユニットテストにおける最大の頭痛の種です
どうすればこの状況を解決できるのか?簡単です。シングルトンを使うようなコードを書かず、依存関係を渡すことを優先する。つまり、依存性注入です。
グローバル定数
グローバルステートは、シングルトンや静的変数の使用に限らず、グローバル定数にも適用可能です。
定数の値が、新しい情報(M_PI
)や有用な情報(PREG_BACKTRACK_LIMIT_ERROR
)を提供しない定数は、明らかにOKです。
逆に、コード内部で情報をワイヤレスで受け渡す方法として機能する定数は、隠れた依存関係以外の何物でもありません。次の例のLOG_FILE
のようなものです。 FILE_APPEND
定数を使用することは完全に正しいです。
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
この場合、Foo
クラスのコンストラクタでパラメータを宣言し、API
の一部とする必要があります。
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::fromCallable
,strlen()
などの決定論的な静的メソッドや関数の使用は、依存性注入と完全に一致します。これらの関数は、常に同じ入力パラメータから同じ結果を返すので、予測可能です。また、グローバルな状態を使用することもありません。
しかし、PHPには決定論的でない関数があります。例えば、htmlspecialchars()
関数がそうです。その第3パラメータである$encoding
は、指定しない場合、デフォルトで設定オプションini_get('default_charset')
の値になります。したがって、関数の予測不可能な動作を避けるために、このパラメータを常に指定することが推奨されます。Netteでは一貫してこれを採用しています。
strtolower()
,strtoupper()
などの一部の関数は、最近になって非決定的な動作をするようになり、setlocale()
の設定に依存するようになりました。このため、多くの複雑な問題が発生し、その多くはトルコ語を扱うときに発生しました。
というのも、トルコ語はドットのある大文字と小文字I
を区別しているからです。そのため、strtolower('I')
はı
の文字を返し、strtoupper('i')
はİ
の文字を返します。このため、アプリケーションは多くの謎のエラーを引き起こすことになりました。
しかし、この問題はPHPバージョン8.2で修正され、関数はロケールに依存しなくなりました。
これは、グローバルステートが世界中の何千人もの開発者を悩ませてきたことを示すいい例です。その解決策は、依存性注入に置き換えることでした。
グローバルステートの使用はどのような場合に可能か?
グローバルステートを使用することが可能な特定の状況があります。例えば、コードをデバッグする際に、変数の値をダンプしたり、プログラムの特定の部分の時間を測定したりする必要がある場合です。このような場合、後でコードから削除される一時的な動作に関するものであれば、グローバルに利用可能なダンパやストップウォッチを使用することが正当です。これらのツールは、コード設計の一部ではありません。
もう一つの例は、正規表現を扱うための関数preg_*
で、コンパイルされた正規表現を内部的にメモリ上の静的キャッシュに保存します。コードの異なる部分で同じ正規表現を複数回呼び出しても、コンパイルされるのは1回だけです。キャッシュは性能を節約し、またユーザーには全く見えないので、このような使い方は正当なものだと考えることができます。
概要
なぜそれが理にかなっているのかを示しました
- コードからすべての静的変数を削除する
- 依存関係を宣言する
- そして依存性注入を使う
コード設計を考えるとき、static $foo
のそれぞれが問題を表していることに留意してください。あなたのコードがDIを尊重する環境になるためには、グローバルステートを完全に根絶し、依存性注入に置き換えることが必要不可欠です。
この過程で、クラスが複数の責任を持つため、クラスを分割する必要があることがわかるかもしれません。そのようなことは気にせず、1つの責任という原則を貫くようにしましょう。
*本章は、Miško Hevery氏の「Flaw: Brittle Global State & Singletons」等の論文に基づくものです。