はじめに
今回は「FatSingletonを救いたい」と題して、Singletonパターンについて書きました。
自分は、Singletonパターンを結構使うので、かなり馴染み深いデザインパターンです。
Singletonパターンは便利なのですが、気をつけて使わないと、何でもありコードになってしまうので、そのあたりを記事にできたらと思い書いてみました。
この記事で学べること
- SwiftのSingletonパターンの書き方
- Singletonのメリット、デメリット
-
FatSingleton
にならないようにするためのリファクタリング <- Point - UnitTestに都合の良いSingletonクラスにしておく <- Point
Singletonパターンとは
概要
特定のクラスのインスタンスがアプリケーションの中で1つしか生成されないことを保証するデザインパターンのことです。
Wikipediaより
SwiftにおけるSingletonパターン
iOSアプリ開発においては、アプリ内の共通利用データを保持させるために用いられることが多いパターンです。
複数のクラスから参照が必要なものの、同じクラスの複数のインスタンスが乱立するとデータ整合性が保ちづらくなり、バグが発生することを防ぎたいときに使うことが多いです。
SessionManager
やLoginManager
があるあるです。
メリット
アプリ全体から利用できるため、アプリ内で共通利用したいデータをメモリに持たせたいときに活用することができます。
また、インスタンスが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がままならなくなります。(←体験談)
サンプルコードのrealMoney
やelectricMoney
が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パターンはデザインパターンについて知る前から、プロジェクトでよく使われていました。
なんで、わりと思い入れのあるデザインパターンで長くなってしまいましたが、皆さんの参考になれば幸いです。