Swiftのextensionを利用して安全な手順で依存関係を排除する
書籍『リファクタリング』や『レガシーコード改善ガイド』に書かれているような、
- 小さな手順を積み重ね
- 常に動く状態を保つ
ようなリファクタリングは、確実で手戻りが発生しづらいので身につけておくと役立つと感じます。
テストが書かれていないレガシーコード※1を扱う際には、特に役立ちます。
※1: 書籍『レガシーコード改善ガイド』での定義
iOSなどの開発で用いられるSwiftではextension
をうまく活用することで、安全な手順でレガシーコードにおける依存関係を排除しテスト可能な状態にリファクタリングできるので、手順を紹介します。
扱う例
次のような
- ファイルからデータを読み込み
- 読み込んだデータを加工して
- 加工したデータを保存する
という例をリファクタリングしていきます。
struct Hoge {
func fuga() throws {
// 1. ファイルからデータを読み込み
let loaded = try String(contentsOfFile: path, encoding: .utf8)
// 2. 読み込んだデータを加工して
let converted = convert(loaded)
// 3. 加工したデータを保存する
try converted.write(toFile: path, atomically: true, encoding: .utf8)
}
private var path: String {
(NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first! as NSString).appendingPathComponent("saveFile")
}
private func convert(_ data: String) -> String {
return data + "+" // ここは適当
}
}
現状の問題と解決案
データを加工するconvert(_:)
をテストしたいのですが、現状はデータの読み込み・保存と結合していてテストがしづらい状態となっています。
解決案としては
- スプラウトメソッド※2:
convert(_:)
を単体の関数としてHoge
から分離しテスト可能にする - 呼び出しの抽出とオーバーライド※3: データの読み込み・保存をメソッドに抽出し、テスト時は
override
することでfuga()
を通してテストする - DI: データの読み込み・保存を
Repository
として抽象化しDIすることで、fuga()
を通してテストする
の3つくらいがあるんじゃないかと思います。
※2, 3: 書籍『レガシーコード改善ガイド』より
この例だと正直1でよくない?と記事を書いてる自分でも思うんですが、1は選択しづらく3を選択したい状況だと思ってください、お願いします。
呼び出しの抽出とオーバーライドはSwiftではあまり選択したくない
2はSwiftではあまり選択したくないと個人的に考えています。
Swiftの場合、override
するためにはアクセスレベルをinternal
以上にしなければならず、リファクタリングを完全に完了するまでの間、不必要なメソッドが外部から見える状態となってしまうからです。
定期的にprotected
があると嬉しいような気が一瞬します。
リファクタリングの手順
リファクタリングの手順は以下の7ステップです。
どのステップも小さく、常にビルドが通り動く状態を維持するものとなっています。
- 呼び出しを抽出する
-
extension
に分離する -
protocol
を作成しextension
を準拠させる - 抽出しておいた呼び出しを
protocol
に引き上げる - 抽出しておいた呼び出しに
protocol
経由でアクセスする -
extension
を新規struct
に変更する -
protocol
をプロパティ化し、コンストラクタでDIする
リファクタリング中の実際のコード
1. 呼び出しを抽出する
データの読み込み・保存をメソッドに抽出します。
XcodeのRefactor機能を使って安全に作業しましょう。
抽出後のアクセスレベルがデフォルトだとfileprivate
だったり、Refactor機能自体が時々動いてくれないことがあったりもします…が基本的には便利なのでショートカット設定しておくのをお勧めします。
struct Hoge {
fileprivate func load() throws -> String {
return try String(contentsOfFile: path, encoding: .utf8)
}
fileprivate func save(_ data: String) throws {
try data.write(toFile: path, atomically: true, encoding: .utf8)
}
func fuga() throws {
// 1. ファイルからデータを読み込み
let loaded = try load()
// 2. 読み込んだデータを加工して
let converted = convert(loaded)
// 3. 加工したデータを保存する
try save(converted)
}
private var path: String {
(NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first! as NSString).appendingPathComponent("saveFile")
}
private func convert(_ data: String) -> String {
return data + "+" // ここは適当
}
}
extension
に分離する
2. シンプルにHoge
をextension
を使って分割するだけです。
struct Hoge {
func fuga() throws {
// 1. ファイルからデータを読み込み
let loaded = try load()
// 2. 読み込んだデータを加工して
let converted = convert(loaded)
// 3. 加工したデータを保存する
try save(converted)
}
private var path: String {
(NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first! as NSString).appendingPathComponent("saveFile")
}
private func convert(_ data: String) -> String {
return data + "+" // ここは適当
}
}
extension Hoge {
fileprivate func load() throws -> String {
return try String(contentsOfFile: path, encoding: .utf8)
}
fileprivate func save(_ data: String) throws {
try data.write(toFile: path, atomically: true, encoding: .utf8)
}
}
protocol
を作成しextension
を準拠させる
3. 何もメソッドを持たないprotocol Repository
を作成し、extension Hoge
を準拠させます。
struct Hoge {
func fuga() throws {
// 1. ファイルからデータを読み込み
let loaded = try load()
// 2. 読み込んだデータを加工して
let converted = convert(loaded)
// 3. 加工したデータを保存する
try save(converted)
}
private var path: String {
(NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first! as NSString).appendingPathComponent("saveFile")
}
private func convert(_ data: String) -> String {
return data + "+" // ここは適当
}
}
extension Hoge: Repository {
fileprivate func load() throws -> String {
return try String(contentsOfFile: path, encoding: .utf8)
}
fileprivate func save(_ data: String) throws {
try data.write(toFile: path, atomically: true, encoding: .utf8)
}
}
protocol Repository {}
protocol
に引き上げる
4. 抽出しておいた呼び出しを抽出しておいたデータの読み込み・保存のアクセスレベルを上げ、protocol Repository
のメソッドに引き上げます。
struct Hoge {
func fuga() throws {
// 1. ファイルからデータを読み込み
let loaded = try load()
// 2. 読み込んだデータを加工して
let converted = convert(loaded)
// 3. 加工したデータを保存する
try save(converted)
}
private var path: String {
(NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first! as NSString).appendingPathComponent("saveFile")
}
private func convert(_ data: String) -> String {
return data + "+" // ここは適当
}
}
extension Hoge: Repository {
func load() throws -> String {
return try String(contentsOfFile: path, encoding: .utf8)
}
func save(_ data: String) throws {
try data.write(toFile: path, atomically: true, encoding: .utf8)
}
}
protocol Repository {
func load() throws -> String
func save(_ data: String) throws
}
protocol
経由でアクセスする
5. 抽出しておいた呼び出しにload()
とsave(_:)
はHoge
のメソッドなので、self
をつけてアクセスできます。
self
つまりHoge
はRepository
に準拠しているので、一時変数let repository: Repository
を経由してアクセスしても挙動は変わりません。
struct Hoge {
func fuga() throws {
let repository: Repository = self
// 1. ファイルからデータを読み込み
let loaded = try repository.load()
// 2. 読み込んだデータを加工して
let converted = convert(loaded)
// 3. 加工したデータを保存する
try repository.save(converted)
}
private var path: String {
(NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first! as NSString).appendingPathComponent("saveFile")
}
private func convert(_ data: String) -> String {
return data + "+" // ここは適当
}
}
extension Hoge: Repository {
func load() throws -> String {
return try String(contentsOfFile: path, encoding: .utf8)
}
func save(_ data: String) throws {
try data.write(toFile: path, atomically: true, encoding: .utf8)
}
}
protocol Repository {
func load() throws -> String
func save(_ data: String) throws
}
extension
を新規struct
に変更する
6. protocol Repository
に適合していたextension Hoge
を、struct FileRepository
に変更します。
合わせてlet repository: Repository
への代入をself
からFileRepository()
に変更します。
今回の例ではpath
もFileRepository
にこのタイミングで移動しました。
struct Hoge {
func fuga() throws {
let repository: Repository = FileRepository()
// 1. ファイルからデータを読み込み
let loaded = try repository.load()
// 2. 読み込んだデータを加工して
let converted = convert(loaded)
// 3. 加工したデータを保存する
try repository.save(converted)
}
private func convert(_ data: String) -> String {
return data + "+" // ここは適当
}
}
struct FileRepository: Repository {
func load() throws -> String {
return try String(contentsOfFile: path, encoding: .utf8)
}
func save(_ data: String) throws {
try data.write(toFile: path, atomically: true, encoding: .utf8)
}
private var path: String {
(NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first! as NSString).appendingPathComponent("saveFile")
}
}
protocol Repository {
func load() throws -> String
func save(_ data: String) throws
}
protocol
をプロパティ化し、コンストラクタでDIする
7. fuga()
内の変数だったlet repository
をプロパティ化し、コンストラクタでDIするように修正します。
Hoge
を呼び出していた箇所ではFileRepository
をDIしてあげましょう。
テスト時はRepository
に準拠したFakeを作るなどしてしてfuga()
経由でテストができるようになります。
struct Hoge<R: Repository> {
private let repository: R
init(repository: R) {
self.repository = repository
}
func fuga() throws {
// 1. ファイルからデータを読み込み
let loaded = try repository.load()
// 2. 読み込んだデータを加工して
let converted = convert(loaded)
// 3. 加工したデータを保存する
try repository.save(converted)
}
private func convert(_ data: String) -> String {
return data + "+" // ここは適当
}
}
struct FileRepository: Repository {
func load() throws -> String {
return try String(contentsOfFile: path, encoding: .utf8)
}
func save(_ data: String) throws {
try data.write(toFile: path, atomically: true, encoding: .utf8)
}
private var path: String {
(NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first! as NSString).appendingPathComponent("saveFile")
}
}
protocol Repository {
func load() throws -> String
func save(_ data: String) throws
}
まとめ
このようにSwiftでのリファクタリングではextension
を利用することで、既存の実装を、DIするクラスの実装へと移す作業が安全に行えます。
(上記の手順の中の
extension
を新規struct
に変更する
が一番わかりやすい箇所です。)
IDEの支援がAndroid Studioほど充実していない中でも、このような安全な手順を取ることで安全なリファクタリングを行うことができます。
『リファクタリング』、『レガシーコード改善ガイド』や『パターン指向リファクタリング入門』あたりが大好きで、この手のテクニックをあれこれ考えることが時々あるんですが、他の方のテクニックがあればぜひ教えてください。
Discussion