every Tech Blog

株式会社エブリーのTech Blogです。

シングルトンパターンの問題点と改善方法 - 保守性とテスタビリティの向上を目指して -

はじめに

DELISH KITCHENのiOSアプリ開発を担当している池田です。iOSチームでは継続的な開発のために日々リファクタリングを行っております。 リファクタリングを進める中で、特に厄介な存在として浮かび上がってきたのがシングルトンパターンです。シングルトンは便利な機能に見えますが、アプリケーションの保守性やテスタビリティを低下させる要因となっています。 本記事では、シングルトンパターンの問題点を解説し、より良い設計への改善方法を提案します。

シングルトンとは

アプリ内に存在するクラスのインスタンスをひとつに制限させる設計パターンで、静的なインスタンスフィールドからグローバルにアクセス可能です。

単一のリソースに対してアクセスするクラスは、複数のインスタンスがあると並列アクセス等のバグを生みやすくなります。そのようなクラスの場合、インスタンスの存在をひとつに強制するためにシングルトンにすることがあります。

以下にシングルトンのサンプルコードをSwiftで示します。(ここではSwift6のConcurrency Checkingは考慮していません。)

final class DatabaseManager {
    static let shared = DatabaseManager()
    private init() {}

    func save(key: String, value: Int) { /* 保存処理 */ }
    func delete(key: String) { /* 削除処理 */ }
    func fetch(key: String) -> Int { /* 取得処理 */ }
}

// 使用例
DatabaseManager.shared.save(key: "hoge", value: 1)
let data = DatabaseManager.shared.fetch()

シングルトンの問題点

現在では次のような理由からシングルトンはアンチパターンとして避けられることが多くなっています。

シングルトンとの密結合

ひとつめの問題点は、シングルトンを利用するクラスがシングルトンと密結合してしまうことです。

final class UseCaseA {
    func doSomething() {
        DatabaseManager.shared.save(key: "hoge", value: 100)
        let value = DatabaseManager.shared.fetch(key: "hoge")
        // 処理
    }
}

この実装には次のような問題があります。

  • シングルトンを直接参照しているため、差し替えが困難

この問題は特にテストを行う場合が顕著で、モックを使いたい場合でも置き換えることができません。また将来的に実装を変更したい場合にも、すべての参照箇所を修正する必要が出てきてしまいます。

シングルトンを介したクラス間の密結合

ふたつめの問題点は、シングルトンを介して複数のクラスが密結合してしまうことです。

final class UseCaseA {
    func doSomething() {
        DatabaseManager.shared.save(key: "hoge", value: 100)
        let value = DatabaseManager.shared.fetch(key: "hoge")
        // 処理
    }
}

final class UseCaseB {
    func doSomething() {
        let value = DatabaseManager.shared.fetch(key: "hoge")
        DatabaseManager.shared.save(key: "hoge", value: value + 200)
        // 処理
    }
}

この実装には次のような問題があります。

  • ふたつのUseCaseは一見独立しているが、シングルトンインスタンスを介して結合している。
  • 意図せず他方のクラスに影響を与える可能性がある。

例えば、UseCaseBの開発者がUseCaseAの実装を知らないまま同じkeyを使用してしまうと、意図せずデータを上書きしてしまう可能性があります。また、一方のUseCaseの変更が他方に影響を与える可能性があり、変更の影響範囲を把握することが困難になります。

改善策

このような問題を解決するために、インターフェースの定義と依存性の注入(DI)を行います。

protocol DatabaseManager {
    func save(key: String, value: Int)
    func delete(key: String)
    func fetch(key: String) -> Int
}

final class DatabaseManagerImpl: DatabaseManager {
    static let shared = DatabaseManagerImpl()
    private init() {}

    func save(key: String, value: Int) { /* 保存処理 */ }
    func delete(key: String) { /* 削除処理 */ }
    func fetch(key: String) -> Int { /* 取得処理 */ }
}

final class UseCaseA {
    private let databaseManager: DatabaseManager
    
    init(databaseManager: DatabaseManager) {
        self.databaseManager = databaseManager
    }

    func doSomething() {
        databaseManager.save(key: "hoge", value: 100)
        let value = databaseManager.fetch(key: "hoge")
        // 処理
    }
}

final class UseCaseB { /* 省略 */ }

// 使用例
let databaseManager = DatabaseManagerImpl.shared
let useCaseA = UseCaseA(databaseManager: databaseManager)
let useCaseB = UseCaseB(databaseManager: databaseManager)
useCaseA.doSomething()
useCaseB.doSomething()

このようになるとシングルトンである必要はなく、エントリポイントで共通のインスタンスを注入するだけで良くなります。

protocol DatabaseManager {
    func save(key: String, value: Int) 
    func delete(key: String)
    func fetch(key: String) -> Int
}

final class DatabaseManagerImpl: DatabaseManager {
    init() {}

    func save(key: String, value: Int) { /* 保存処理 */ }
    func delete(key: String) { /* 削除処理 */ }
    func fetch(key: String) -> Int { /* 取得処理 */ }
}

// 使用例
let databaseManager = DatabaseManagerImpl() // シングルトンの必要はない
let useCaseA = UseCaseA(databaseManager: databaseManager)
let useCaseB = UseCaseB(databaseManager: databaseManager)
useCaseA.doSomething()
useCaseB.doSomething()

よくある誤用

DELISH KITCHENのコードを確認したところ多くのシングルトンが実装されていました。しかしその中にはシングルトンのグローバルにアクセスが可能という部分のみを利用した実装がありました。

final class ConfigManager {
    static let shared = ConfigManager()
    private init() {}

    var hogeConfig: HogeConfig = .init()
}

この実装の問題点は、シングルトンの本来の目的である「インスタンスの一意性を保証する」という点が活かされていない点です。単にグローバルな変数として使用されているだけで、むしろこのような用途であれば、設定値は依存性注入で渡すか、より適切な形でのデータ管理を検討すべきです。たとえば以下のような方法が考えられます。

struct AppConfig {
    let hogeConfig: HogeConfig
}

final class UseCase {
    private let config: AppConfig

    init(config: AppConfig) {
        self.config = config
    }

    func doSomething() {
        // configを使用した処理
    }
}

このように修正することで、設定値の管理がより明示的になり、テストも容易になります。

まとめ

シングルトンパターンは、クラスのインスタンスをグローバルに一つだけ存在させる設計パターンです。しかし、現在ではアンチパターンとして認識されることが多くなっています。これは、シングルトンを使用するクラスとの密結合や、シングルトンを介した複数クラス間の密結合といった問題を引き起こすためです。 シングルトンを使わずともインターフェースを定義し、依存性の注入を活用することで、シングルトンと同様の機能を実現できることが多いです。 シングルトンは最終手段として考え、まずは代替手段を考えることをおすすめします。

この記事が、これから同様の課題に取り組む開発者の方々の参考になれば幸いです。

余談

Swiftにおいては、1つのインスタンスを複数処理で共有する場合、Swift 5.9で実装された ~Copyable を使うことでより安全なコードを書ける可能性があるので、こちらも合わせて検討すると良いと思います。