シングルトンが邪悪な理由
概要
- Singletonが多用されたコードベースをメンテナンスする機会があり、非常に残念な思いをしたので書きます。
- 海外ではSingletons Are EvilとしてWikiWikiWebでも議論されていた。20年以上経った今でもこの問題は解決されていない。
- Singletonパターンはデザパタの中でも理解しやすいので、「デザパタ使ってる俺かっこいい」みたいなノリで多用されがち。初心者が最初に覚えて、最初に乱用するパターン。
何が問題なのか
1. グローバル変数と何が違うの?
Singletonパターンはシステム上で1つだけのインスタンスを提供する。よって、サービス内でオブジェクトの参照を持ち回る必要がなくなる。
で、それってグローバル変数と何が違うの?
// Singleton経由のアクセス。グローバル変数と本質的に同じ。class OrderService { public function process(Order $order): void { $db = Database::getInstance(); // どこからでもアクセス可能 $logger = Logger::getInstance(); // 隠れた依存関係 $cache = Cache::getInstance(); // クラスの外から見えない
$db->save($order); $logger->info("Order processed"); $cache->invalidate("orders"); }}このOrderServiceのコンストラクタやメソッドシグネチャを見ても、Database、Logger、Cacheに依存していることがわからない。コードを読まないと依存関係が見えない。
参照を持ち回らなくて良くなる代わりに、微妙なシステムデザインになってしまう。システムデザインを深く考えると、ほとんどの場合、グローバル変数を使わなくて済む。
2. 単一責任原則に違反する
Singletonを使うと、1つのクラスに「ビジネスロジック」と「インスタンスの生成管理」という2つの責務を課してしまう。
// BAD: クラスが自分のインスタンス管理まで担当してしまうclass Database { private static ?Database $instance = null;
private function __construct() { /* ... */ }
// ビジネス要件とは無関係な責務 public static function getInstance(): Database { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; }
public function query(string $sql): array { /* ... */ }}クラス自身は、Singletonかどうかを気にする必要は無い。クラスはビジネス要件のみを考えるべき。
生成方法を制限したければ、FactoryパターンやDIコンテナでオブジェクトの生成をカプセル化し、そこで制約を設ければよい。
3. テストが地獄になる
一番つらいのがこれ。 Singletonのせいでユニットテストが非常に困難になる。
// テストでDBをモックに差し替えたいが、Singletonだと不可能class OrderServiceTest extends TestCase { public function testProcess(): void { // Database::getInstance() が実際のDBを返してしまう。 // テストが本番DBに依存してしまう。。。 $service = new OrderService(); $service->process(new Order()); // DBの状態が前のテストから引き継がれてしまう。。。 }}テストは1つ1つが独立していなければならない。Singletonは状態を持ち続けるので、テスト間で状態がリークする。テストの実行順序によって結果が変わるとか、もう最悪。
Singletonを使わないことは、テストドリブンな開発の基本。
4. 結合度が上がってモック不可能になる
Singletonを使うと、クラス間の結合度が高くなり、ポリモーフィズムやモックへの差し替えが難しくなる。
結合度が高いコードは変更に弱い。Singletonの実装を変えたら、それを使っている全クラスに影響が波及する。
代わりにどうすればいいのか
Dependency Injection (DI) を使う。 これだけ。
// GOOD: 依存関係がコンストラクタで明示されるclass OrderService { public function __construct( private DatabaseInterface $db, private LoggerInterface $logger, private CacheInterface $cache, ) {}
public function process(Order $order): void { $this->db->save($order); $this->logger->info("Order processed"); $this->cache->invalidate("orders"); }}
// テストでモックに差し替え可能class OrderServiceTest extends TestCase { public function testProcess(): void { $mockDb = $this->createMock(DatabaseInterface::class); $mockDb->expects($this->once()) ->method('save');
$service = new OrderService( $mockDb, new NullLogger(), new ArrayCache(), ); $service->process(new Order()); }}| 比較項目 | Singleton | DI |
|---|---|---|
| 依存関係の可視性 | 隠蔽される | コンストラクタで明示 |
| テスト容易性 | モック困難 | モック容易 |
| 結合度 | 高い(具象クラス直結) | 低い(インターフェース経由) |
| 単一責任原則 | 違反(生成+ロジック) | 準拠(生成は外部で) |
| 状態管理 | グローバルで状態保持 | スコープで制御可能 |
インスタンスを1つに制限したければ、DIコンテナ側で制御すればよい。クラス自身がSingletonである必要はない。
引用元
所感
- ミドルウェアレベルでSingletonが多用されたコードをDevOpsする仕事がありましたが、テストコードが書けなくて苦しかった。書けることは書けるけど、コンテクストを意識したテストになり、正当性を機械的にチェック出来ない。
- 理想のオブジェクト指向で書かれたアプリケーションは、利用者が
newを呼ぶことで初めてオブジェクトが生成されることが理想。Singletonだと誰がいつ作ったかよくわからないオブジェクトが漂っている状態になる。 - メモリ節約の利点はあるが、それ以上にデメリットが大きすぎる。Pro/Conを整理して判断すべきだけど、ほとんどの場合はDIで代替できる。
- Singletonで書かれたコードを見つけたら
git blameして、作者にこの記事のURLを送りつけるなどして頂ければと思います。