🗂

Swiftのextensionを利用して安全な手順で依存関係を排除する

2023/10/23に公開

書籍『リファクタリング』や『レガシーコード改善ガイド』に書かれているような、

  • 小さな手順を積み重ね
  • 常に動く状態を保つ

ようなリファクタリングは、確実で手戻りが発生しづらいので身につけておくと役立つと感じます。
テストが書かれていないレガシーコード※1を扱う際には、特に役立ちます。
※1: 書籍『レガシーコード改善ガイド』での定義

iOSなどの開発で用いられるSwiftではextensionをうまく活用することで、安全な手順でレガシーコードにおける依存関係を排除しテスト可能な状態にリファクタリングできるので、手順を紹介します。

扱う例

次のような

  1. ファイルからデータを読み込み
  2. 読み込んだデータを加工して
  3. 加工したデータを保存する

という例をリファクタリングしていきます。

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(_:)をテストしたいのですが、現状はデータの読み込み・保存と結合していてテストがしづらい状態となっています。
解決案としては

  1. スプラウトメソッド※2: convert(_:)を単体の関数としてHogeから分離しテスト可能にする
  2. 呼び出しの抽出とオーバーライド※3: データの読み込み・保存をメソッドに抽出し、テスト時はoverrideすることでfuga()を通してテストする
  3. DI: データの読み込み・保存をRepositoryとして抽象化しDIすることで、fuga()を通してテストする

の3つくらいがあるんじゃないかと思います。
※2, 3: 書籍『レガシーコード改善ガイド』より

この例だと正直1でよくない?と記事を書いてる自分でも思うんですが、1は選択しづらく3を選択したい状況だと思ってください、お願いします。

呼び出しの抽出とオーバーライドはSwiftではあまり選択したくない

2はSwiftではあまり選択したくないと個人的に考えています。
Swiftの場合、overrideするためにはアクセスレベルをinternal以上にしなければならず、リファクタリングを完全に完了するまでの間、不必要なメソッドが外部から見える状態となってしまうからです。
定期的にprotectedがあると嬉しいような気が一瞬します。

リファクタリングの手順

リファクタリングの手順は以下の7ステップです。
どのステップも小さく、常にビルドが通り動く状態を維持するものとなっています。

  1. 呼び出しを抽出する
  2. extensionに分離する
  3. protocolを作成しextensionを準拠させる
  4. 抽出しておいた呼び出しをprotocolに引き上げる
  5. 抽出しておいた呼び出しにprotocol経由でアクセスする
  6. extensionを新規structに変更する
  7. 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 + "+"   // ここは適当
    }
}

2. extensionに分離する

シンプルにHogeextensionを使って分割するだけです。

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)
    }
}

3. protocolを作成しextensionを準拠させる

何もメソッドを持たない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 {}

4. 抽出しておいた呼び出しをprotocolに引き上げる

抽出しておいたデータの読み込み・保存のアクセスレベルを上げ、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
}

5. 抽出しておいた呼び出しにprotocol経由でアクセスする

load()save(_:)Hogeのメソッドなので、selfをつけてアクセスできます。
selfつまりHogeRepositoryに準拠しているので、一時変数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
}

6. extensionを新規structに変更する

protocol Repositoryに適合していたextension Hogeを、struct FileRepositoryに変更します。
合わせてlet repository: Repositoryへの代入をselfからFileRepository()に変更します。

今回の例ではpathFileRepositoryにこのタイミングで移動しました。

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
}

7. protocolをプロパティ化し、コンストラクタでDIする

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するクラスの実装へと移す作業が安全に行えます。
(上記の手順の中の

  1. extensionを新規structに変更する

が一番わかりやすい箇所です。)

IDEの支援がAndroid Studioほど充実していない中でも、このような安全な手順を取ることで安全なリファクタリングを行うことができます。
『リファクタリング』、『レガシーコード改善ガイド』や『パターン指向リファクタリング入門』あたりが大好きで、この手のテクニックをあれこれ考えることが時々あるんですが、他の方のテクニックがあればぜひ教えてください。

Discussion