ソフトウェア設計

SwiftのSingleton Pattern、sharedがアンチパターンと呼ばれる本当の理由

Swiftで開発していると、static let sharedの1行で作られたシングルトンを本当によく見かけます。

読了 5 分
SwiftのSingleton Pattern、sharedがアンチパターンと呼ばれる本当の理由のカバー画像

Swiftで開発していると、static let sharedの1行で作られたシングルトンを本当によく見かけます。

初めてiOSアプリを作るときは、ネットワークマネージャー、ユーザーセッション、キャッシュ管理まで、すべてをシングルトンで埋め尽くしがちです。

便利だからです。どこからでもManager.sharedを呼べばよいのですから。

しかしプロジェクトが大きくなると、奇妙なことが起こり始めます。テストができません。画面を1つ直しただけなのに、無関係な場所でバグが発生します。

シングルトンがアンチパターンと呼ばれるのには、理由があります。

この記事では、SwiftのSingleton Patternとは何か、なぜ多用するとアンチパターンと見なされるのか、それでも使ってよい場面はいつかを、実務の観点から整理します。

結論から言えば、シングルトン自体が悪いのではありません。「どこからでもグローバルに状態を変更できるよう開いておく方式」が問題です。そのためsharedを無分別に多用すると、テストが難しくなり、依存性が隠れ、並行処理の問題まで発生します。

SwiftのSingleton Patternとは、いったい何ですか?

シングルトンとは、アプリ全体でインスタンスが必ず1つだけ存在することを保証するデザインパターンです。

Swiftでは非常に短く作れます。これが魅力であり、落とし穴でもあります。

final class NetworkManager {
    static let shared = NetworkManager()  // アプリに1つだけ
    private init() {}                     // 外部からの生成を禁止
    func request(_ url: URL) { /* ... */ }
}

static letはSwiftによってスレッドセーフに一度だけ初期化されます。そのため、別途lockがなくてもインスタンス生成自体は安全です。

private init()で外部からの生成を防ぐことも重要です。これがなければ、単なるグローバルオブジェクトであってシングルトンではありません。

ここまではとてもきれいです。問題は、どこからでも呼び出せることから始まります。


sharedインスタンスがアンチパターンと呼ばれるのはなぜ?

よく指摘される3つの理由を、私が経験した順に説明します。

1つ目は、テストが地獄になることです。

コード内でNetworkManager.sharedを直接呼ぶと、テスト時にモックオブジェクトへ差し替える方法がありません。

実際のサーバーにリクエストが送られたり、テスト同士で状態を共有したりするため、順番が変わるだけで結果が変わります。

2つ目は、依存性が隠れてしまうことです。

ある関数が内部でこっそりUserSession.sharedを使っていると、関数シグネチャだけでは何を必要としているのかわかりません。

協業時には本当に危険です。コードをすべて開いて初めて依存関係が見えるからです。

3つ目は、グローバルな可変状態による並行処理の問題です。

シングルトンがプロパティを持ち、複数のスレッドが同時に読み書きすると、データ競合が発生します。

インスタンス生成が安全でも、その内部の状態変更まで安全とは限りません。この2つを混同するとクラッシュに遭遇します。

sharedで埋め尽くしていた頃の私のコード、今見るとぞっとします
sharedで埋め尽くしていた頃の私のコード、今見るとぞっとします

まとめると、こうです。

シングルトンが批判される本当の理由は、「1つしか存在しないから」ではなく、「どこからでも取り出して、どこからでも変更できるから」です。


では、シングルトンは絶対に使ってはいけないのでしょうか?

いいえ。今でも特定の状況では使います。

そもそも1つだけ存在するのが自然なものもあります。たとえばUserDefaults.standardFileManager.defaultURLSession.sharedのように、Appleも標準ライブラリでシングルトンを使っています。

基準は次のように考えると便利です。

  • 状態をほとんど変更せず、読み取りが中心 → シングルトンでよい
  • アプリ全体で本当に1つでなければならない → シングルトンを検討
  • テストでは異なる動作が必要 → 依存性注入を推奨

重要なのは、「コード内でsharedを直接呼ばず、外部から注入する」ことです。

sharedをデフォルトにし、テストには偽物を注入する構成
sharedをデフォルトにし、テストには偽物を注入する構成

同じシングルトンを使っていても、このように変えるだけでテスト容易性は大きく向上します。

protocol Networking { func request(_ url: URL) }
extension NetworkManager: Networking {}

final class FeedViewModel {
    private let network: Networking
    init(network: Networking = .shared) {  // デフォルトはシングルトン、テストでは mock を注入
        self.network = network
    }
}

通常はデフォルトのシングルトンを使えて便利で、テスト時には偽物のオブジェクトを入れられるため柔軟です。

シングルトンを捨てたのではなく、呼び出し方だけを変えたのです。


実務で守っている3つのルール

最後に、実務で自分に課している基準を共有します。

  1. 可変状態を複数箇所から変更するシングルトンは作らない。状態が必要なら、所有者を明確にする。
  2. sharedを関数内で直接呼び出さず、イニシャライザやパラメータから注入する。
  3. 同時アクセスがあるシングルトンはactorにするか、直列キューで保護する。

この3つを守るだけで、「シングルトンのせいでプロジェクトが腐る」状況はほぼ防げます。

シングルトンは間違った道具ではなく、簡単すぎるため多用されやすい道具です。

便利だからといってsharedをどこにでもばらまく前に、「これ、あとでテストできる?」と一度だけ自問してください。その1つの問いが、6か月後の自分を救います。

あわせて読みたい