LoginSignup
27
11

More than 1 year has passed since last update.

【Swift】シングルトンは悪くない、sharedを直接触るのが悪いんだ

Last updated at Posted at 2022-12-12

シングルトン

古来からあるデザインパターンの一種で

  • A: クラスのインスタンスをアプリ内で1つのみ存在させる
  • B: static変数のsharedでどこからでもアクセスできる

という効果があります。
「単一のリソースに対してアクセスするクラスは、複数生成すると並列アクセス等でバグが出るからシングルトンにしようね」なんて言われてたりします。例としては以下のようなクラスです。

  • ログイン状態を管理するクラス
  • DBを操作するクラス
  • デバイスの機能 (Location, Bluetooth) を使うクラス
final class Auth {
    
    // static変数で自分自身を保持
    static let shared: Auth = .init()

    var isSignedIn: Bool { ... }
    func signIn() async throws { ... }

    // initをprivateにすることで、クラス外でインスタンスを生成できなくする
    // =上のshared以外のインスタンスは存在できないことを保証
    private init() {}
}

シングルトン良くない論

さんざん言われてるものから個人的に特に良くないと感じる点を抽出

理由1: 蜜結合で単体テストできない

ユニットテストを難しくする。外部から渡せないオブジェクトはモックにする事が難しい。

struct AuthRepository {
    var isSignedIn: Bool { 
        Auth.shared.isSignedIn // これ
    }

    func signIn() async throws { 
        try await Auth.shared.signIn() // これ
    }
}

このAuthRepositoryを単体テストする際、Authをスタブ/モックに切り替えたくても、これでは切り替えれません。

理由2: 依存関係が見えなくなる

依存関係を見えにくくし、コードが読みづらくなる。

let authRepository = AuthRepository() // 何も引数がない

AuthRepository を利用する側から見ると、initに何も引数がないので中でAuthが使われていることが分かりません。オブジェクトが内部で何に依存しているかは、外から見えるようにしたほうが良いと考えています。

特に、シングルトンに依存や環境変数が必要な場合
自動で初期化はできないため、sharedをIUOにしてsetupで依存を受け取り初期化する場合があります。

final class Auth {
    
    static let shared: Auth!

    let accessGroup: String
    
    private init(accessGroup: String) {
        self.accessGroup = accessGroup
    }

    static func setup(accessGroup: String) {
        Self.shared = Auth(accessGroup: accessGroup)
    }
}

この場合、setupをせずにsharedにアクセスするとクラッシュしてしまいますが
先ほどの例のようにAuthへの依存が外から見えていないと、「AuthRepositoryを使う前にAuthsetupを呼ばなくてはいけない」と認知することすらできません。

テストする場合なんかは特に地獄で、「このクラス、中で何に依存してたっけな〜」っていうエスパーをしながらsetupしていくことになります。

// AuthRepositoryはAuth使ってるからinitしなきゃ...
Auth.setup(accessGroup: "")

let authRepository = AuthRepository()

// UserRepositoryはAPIとDBと...
API.setup(...)
DB.setup(...)
let userRepository = UserRepository()

解決策: DIする

つまり、シングルトンしていたクラス: Authをprotocolで抽象化し、かつ利用側: AuthRepositoryではinitでインスタンスを受け取るようにしようということです。

struct AuthRepository {

    let auth: AuthProtocol

    init(auth: AuthProtocol) {
        self.auth = auth
    }

    var isSignedIn: Bool { 
        auth.isSignedIn
    }

    func signIn() async throws { 
        try await auth.signIn()
    }
}

これで、 理由1: 蜜結合で単体テストできない, 理由2: 依存関係が見えなくなる の両方を解決できます。

final class AuthStub: AuthProtocol {
    var isSignedIn: Bool { 
        true
    }

    func signIn() async throws { }
}

// Stubの注入もできるし、依存も見える
let authRepository = AuthRepository(auth: AuthStub())

シングルトンはやめなくてもいいんじゃない?

この記事の本題です。
上記で触れた シングルトンは悪い点があるので、DIを利用するべき というのは前提として
「別にDIはシングルトンと矛盾しないんじゃないか?」 「シングルトン自体をやめなくてもいいんじゃない?」 という話です。

実際、限られたリソースにアクセスするクラスや、initで重い処理を行うようなクラスのインスタンスはアプリ内で一個だけにしておきたいケースがあります。(少なくとも自分はありました)

悪いのはsharedを直接触っていたこと

理由1: 蜜結合で単体テストできない, 理由2: 依存関係が見えなくなるの2点に関しては
シングルトンのA: クラスのインスタンスをアプリ内で1つのみ存在させるに対する問題というよりかは、B: static変数のsharedでどこからでもアクセスできるに対する問題だと思います。

解決策2: sharedをDIする

実際、DIとシングルトンは共存できます。以下のようにsharedをDIすれば、B: static変数のsharedでどこからでもアクセスできるの問題を解決しつつシングルトンのA: クラスのインスタンスをアプリ内で1つのみ存在させるの利点だけを利用できます。

let authRepository = AuthRepository(auth: Auth.shared)

解決策3: これもシングルトン?

とはいう自分も、実はsharedのスタイルのシングルトンは使っていません。
ただ、以下のようにアプリで使う依存を管理するクラスを定義し、各画面(やRouter等)で依存解決に使っています。(AppDependency自体はバケツリレー)

これだとシングルトンに依存がある場合でもIUOにする必要がないですし、簡単にシングルトン/ファクトリーを切り替えることができます。

この仕様はAndroidのDIライブラリのkoinのmoduleの定義を参考にしました

protocol AppDependencyProtocol {
    func inject() -> AuthProtocol
    func inject() -> AuthRepositoryProtocol
}

final class AppDependency: AppDependencyProtocol {
    let auth = Auth(accessGroup: "")

    func inject() -> AuthProtocol {
        // 変数を返せばシングルトン
        auth
    }

    func inject() -> AuthRepositoryProtocol {
        // その場で生成すればファクトリー
        AuthRepository(auth: inject())
    }
}

「アプリ内でクラスのインスタンスを共有している」だけなので、デザインパターンの「シングルトン」とは違うかもしれませんが、シングルトンの目的の一つを達成しているので、広義では「シングルトン」なのかもしれません。

まとめ

シングルトンには主に二つの恩恵があり

  • A: クラスのインスタンスをアプリ内で1つのみ存在させる
  • B: static変数のsharedでどこからでもアクセスできる

Bは問題が多いため、シングルトンをやめてDIする意見(解決策1) が見られるが、sharedをDIしたり(解決策2)sharedを使わないシングルトン(?)(解決策3) を利用することでAだけの恩恵を利用できるのでこれは良いのではないかと思います。

ただ、A自体もデメリットだという意見も見られるので、Aがデメリットだと感じている場合はこの記事では解決しないかもしれません。

もっと良い方法・改善点があればコメントください!!

27
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
11