LoginSignup
0
1

More than 1 year has passed since last update.

Fat Singletonを救いたい【Swiftでデザインパターン攻略 #2】

Last updated at Posted at 2021-12-03

はじめに

今回は「FatSingletonを救いたい」と題して、Singletonパターンについて書きました。
自分は、Singletonパターンを結構使うので、かなり馴染み深いデザインパターンです。

Singletonパターンは便利なのですが、気をつけて使わないと、何でもありコードになってしまうので、そのあたりを記事にできたらと思い書いてみました。

この記事で学べること

  • SwiftのSingletonパターンの書き方
  • Singletonのメリット、デメリット
  • FatSingletonにならないようにするためのリファクタリング <- Point
  • UnitTestに都合の良いSingletonクラスにしておく <- Point

Singletonパターンとは

概要

特定のクラスのインスタンスがアプリケーションの中で1つしか生成されないことを保証するデザインパターンのことです。
image.png
Wikipediaより

SwiftにおけるSingletonパターン

iOSアプリ開発においては、アプリ内の共通利用データを保持させるために用いられることが多いパターンです。
複数のクラスから参照が必要なものの、同じクラスの複数のインスタンスが乱立するとデータ整合性が保ちづらくなり、バグが発生することを防ぎたいときに使うことが多いです。

SessionManagerLoginManagerがあるあるです。

メリット
アプリ全体から利用できるため、アプリ内で共通利用したいデータをメモリに持たせたいときに活用することができます。
また、インスタンスが1つしか生成されないので、データの整合性が保証されます。

デメリット
メリットの性質がそのまま仇となるのですが、「どこからでも参照可能」という便利がゆえに、本来の役割を超えたプロパティやメソッドが書かれてしまい、FatViewControllerならぬFatSingletonが完成されてしまいます。

また、Singletonクラスに色々なプロパティやメソッドを追加しすぎたあまり、知らない間に密結合なコードになり、UnitTestがままならなくなります。(←体験談)

Singletonパターンの雛形を実装する

// 1. 継承を防ぐためにfinalをつける
final class FatDataStore {
    // 2. 外部から参照するためにstatic変数でインスタンスを返すプロパティを用意する
    static let shared = FatDataStore()
    // 3. 外部からインスタンス化されないためにinitをprivateにする
    private init() {}

    // 4. 必要に応じて変数を追加する(この2つの値はAPIなどから取得した値をSetするものとする)
    var realMoney = 0
    var electricMoney = 0

    // 5. 必要に応じてメソッドを追加する
    func calcTotalMoney() -> Int {
        return realMoney + electricMoney
    }

    func calcRealMoneyRatio() -> Int {
        return realMoney / calcTotalMoney() * 100
    }

    func calcBalance(payMoney: Int) -> Int {
        return calcTotalMoney() - payMoney
    }
}

// 利用する時
print(FatDataStore.shared.realMoney)   // 0
FatDataStore.shared.realMoney = 1000
print(FatDataStore.shared.realMoney)   // 1000

1〜3は、Singletonクラスを作る上で定番のパターンで、とりあえずこのようにしておけばSigletonパターンとして使えると言って問題ないです。

"Fat" Singleton問題

結論から言うと、上記のSingletonクラスはFatSingletonになる危険性があります。
なぜなら、上記のSingletonクラスはあくまでDataStoreとしての位置づけですが、自身のプロパティを用いた計算、アクセス元から渡された引数を用いた計算もSingletonクラスで行われています。つまりこのクラスは、DataStoreとしての責務を超えて「なんでも実行クラス」になってしまっていると言えます。

このように、Singletonはアプリ内で共有する利用頻度の高いプロパティを持つことから、それに関するメソッドがSingletonクラス内に乱立してしまうという危険性があるということです。

というわけで、計算に関するメソッドはちゃんと計算クラスを定義することでFatSingletonを防ぐことができます。

class Calcrator {

    let dataStore: FatDataStore

    // Singletonクラスのプロパティを利用したければ初期化時にセットする
    init(dataStore: FatDataStore) {
        self.dataStore = dataStore
    }

    func calcTotalMoney() -> Int {
        return dataStore.realMoney + dataStore.electricMoney
    }

    func calcRealMoneyRatio() -> Int {
        return dataStore.realMoney / calcTotalMoney() * 100
    }

    func calcBalance(payMoney: Int) -> Int {
        return calcTotalMoney() - payMoney
    }
}

UnitTestを考慮してもう一歩踏み込んでみる

FatSingleton問題以外のもう1つのデメリットであるこれも解消してしまいましょう。

また、Singletonクラスに色々なプロパティやメソッドを追加しすぎたあまり、知らない間に密結合なコードになり、UnitTestがままならなくなります。(←体験談)

サンプルコードのrealMoneyelectricMoneyがAPIから取得した値をセットするなどという仕様の場合、UnitTest時には、本来期待される値が入っていないことが多いです。

全財産の合計計算、残高計算のテストをしたいのに、わざわざAPIを投げてサーバーから値を受け取ってテストするというのは、正しい単体テストのあり方ではありません。

そんな問題点を解決するためのコードがこれです。

// DataStoreProtocolを準拠させる
final class SlimDataStore: DataStoreProtocol {
    static let shared = SlimDataStore()
    private init() {}

    var realMoney: Int = 0
    var electricMoney: Int = 0
}

class Calcrator {

    let dataStore: DataStoreProtocol

    // 引数で受け取る型を「DataStoreProtocol」に変更する
    init(dataStore: DataStoreProtocol) {
        self.dataStore = dataStore
    }

    func calcTotalMoney() -> Int {
        return dataStore.realMoney + dataStore.electricMoney
    }

    func calcRealMoneyRatio() -> Int {
        return dataStore.realMoney / calcTotalMoney() * 100
    }

    func calcBalance(payMoney: Int) -> Int {
        return calcTotalMoney() - payMoney
    }
}

まず、DataStoreProtocolを定義して、Singletonクラスに必ず必要なプロパティやメソッドを確定させます。
そのプロトコルをSlimDataStoreというSingletonへ準拠させます。

最後に、Singletonを利用したいクラスでは、SlimDataStoreを直接受け取る形ではなく、DataStoreProtocolを初期化時の引数として受け取るようなロジックへ変更します。

こうすることで、UnitTestの時は、DataStoreProtocolを準拠したTestDataStoreクラスに差し替えるだけでいいので、計算テストが単体テストとして実行できるようになります。

class TestDataStore: DataStoreProtocol {
    var realMoney: Int = 11111
    var electricMoney: Int = 88888
}

let calcTest = Calcrator(dataStore: TestDataStore())
print(calcTest.calcTotalMoney())    // 99999

こんな感じです。

おわりに

Singletonパターンはデザインパターンについて知る前から、プロジェクトでよく使われていました。
なんで、わりと思い入れのあるデザインパターンで長くなってしまいましたが、皆さんの参考になれば幸いです。

参考にさせていただいた記事

0
1
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
0
1