Closed5

TCAで頻出の副作用のあるクラスをDIする方法が良かったので布教したい。

Shun UematsuShun Uematsu

どんなコードだったのか

副作用とは

Protocolで表現した場合

staticでインスタンスを提供する場合

Shun UematsuShun Uematsu

副作用とは

プログラミングにおける式の評価による作用には以下の2つがあります。

  • 主たる作用
  • それ以外の副作用(side effect)

主たる作用とは、以下のようなコードのように引数を受け取り値を返すことです。

/// 引数を2乗した結果を返す
func double(x: Int) -> Int {
  x * x
}

それ以外の作用とは、

  • 状態の変更
  • APIリクエストなどのI/O実行
    などが挙げられます。
    状態の変更とは、以下のように関数外のスコープの変数を書き換えてしまっているということです。
var total: Int = 0
func add(x: Int, y: Int) -> Int {
  let result = x + y
  // ある足し算の結果を返すという主たる作用以外に、変数の値を変更している。
  total = result
  return result
}
print(total) // 0
_ = add(x: 5, y: 3)
// addメソッドの実行前と結果がかわってしまっている
print(total) // 8

またAPIリクエストなどのI/O実行は、同じ内容でAPIリクエストを投げても常に同じ結果が返ってくるとは限りません。
つまり副作用のあるコードとは、

  • 同じ条件を与えても必ず同じ結果になるとは限らない
  • 他の機能の結果に影響を与えてしまう

言い換えると副作用のないコードとは、

  • 同じ条件を与えれば必ず同じ結果になる
  • 他のいかなる機能の結果にも影響を与えない

ということです。
このような性質を参照透過性と言います。
つまり副作用のないコードは参照透過性という性質を持っているということになります。

Shun UematsuShun Uematsu

副作用を持つAPIClientクラスを実装してみる

今回は副作用を持つコードの特徴のひとつである、「同じ条件を与えても必ず同じ結果になるとは限らない」について考えていきます。
「同じ条件を与えても必ず同じ結果になるとは限らない」を再現するクラスを実装するために、APIクライアントを想定したクラスを実装してみます。

import Combine

struct  HttpClient {
  var random: Int { Int.random(in: 0..<10) }
  func get() -> AnyPublisher<Int, HttpError> {
    Future { completion in
      completion(.success(random))
    }
    .eraseToAnyPublisher()
  }
}

final class Main {
  let client = HttpClient()
  func main() {
    client.get()
      .sink { completion in
        switch completion {
        case let .failure(error):
          print(error)
        case .finished:
          print("finished")
        }
      } receiveValue: { value in
        print(value) // 実行するたびに結果が異なる
      }
  }
}
Main().main()

上記コードのclient.get()を実行するたびに結果が異なっているので、
HttpClientは副作用を持つクラスになります。

この状態でも動作するコードですが、副作用を持つコードに付きまとう問題はテストや動作確認時の問題です。
副作用を持つクラスに依存しているクラスの動作確認を行う際、上記コードの状態だと毎回結果が変わってしまうので、特定の状態のときの動作確認を行うことができません(よくあるのがエラー発生時の動作確認)。
またテストについても、結果が同じではないので正しいテスト結果を得ることができません。

この問題を解決するためにswiftではプロトコルが用いられることが多いと思います。

Shun UematsuShun Uematsu

protocolで実装する

前述のコードにてMainクラスはHttpClientという具象クラスに依存しており、モックへの差し替えが容易ではない状態になっていました。
そこでHttpClientをprotocolにし、Mainクラスは抽象に依存するように変更します。
またHttpClientに適合したインスタンスをMainクラスにDIできるようにも変更します。

import Combine
import Foundation

struct HttpError: Error {
  var message = "failed"
}

protocol HttpClient {
  func get() -> AnyPublisher<Int, HttpError>
}

/// 常に3を返却する
struct HttpClientMock: HttpClient {
  func get() -> AnyPublisher<Int, HttpError> {
    Future { completion in
      completion(.success(3))
    }
    .eraseToAnyPublisher()
  }
}

/// 常にエラーを返却する
struct HttpClientFailure: HttpClient {
  func get() -> AnyPublisher<Int, HttpError> {
    Future { completion in
      let error = HttpError()
      completion(.failure(error))
    }
    .eraseToAnyPublisher()
  }
}

/// production用のクラス
struct HttpClientImpl: HttpClient {
  var random: Int { Int.random(in: 0..<10) }
  func get() -> AnyPublisher<Int, HttpError> {
    Future { completion in
      completion(.success(random))
    }
    .eraseToAnyPublisher()
  }
}

final class Main {
  private let client: HttpClient
  init(httpClient: HttpClient) {
    self.client = httpClient
  }

  func main() {
    client.get()
      .sink { completion in
        switch completion {
        case let .failure(error):
          print(error.message)
        case .finished:
          print("finished")
        }
      } receiveValue: { value in
        print(value)
      }
  }
}
Main(httpClient: HttpClientImpl()).main()
Main(httpClient: HttpClientMock()).main()
Main(httpClient: HttpClientFailure()).main()

こうすることでMainクラスをテストする際の依存クラスの動作をコントロールできるようになり、テストが書きやすくなります。
ただprotocolを使って実装することについて個人的には

  • protocolを用意するのが面倒
  • またprotocolを具象クラスに適合していくのが面倒

と感じました。

そして上記のような実装よりも、pointfreeさんのコードの書き方の方が良いなと感じました。

Shun UematsuShun Uematsu

staticな定数で実装する

pointfreeさんのコードでは、副作用を持つクラスのインスタンスをprotocolを使わずにstaticな定数やメソッドで提供しています。以下のような感じです。

struct HttpClient {
  // 1. クロージャーでインターフェースのように定義
  var get: () -> AnyPublisher<Int, HttpError>
}

extension HttpClient {
  static let live = Self(
    get: {
      var random: Int { Int.random(in: 0..<10) }
      return Future<Int, HttpError> { completion in
        completion(.success(random))
      }
      .eraseToAnyPublisher()
    }
  )

  static let mock = Self(
    get: {
      Future<Int, HttpError> { completion in
        completion(.success(3))
      }
      .eraseToAnyPublisher()
    }
  )

  static let failed = Self(
    get: {
      Future<Int, HttpError> { completion in
        completion(.failure(HttpError()))
      }
      .eraseToAnyPublisher()
    }
  )
}
・・・

// 2. .liveや.mockのような型省略でインスタンスを生成できる
Main(httpClient: .live).main()
Main(httpClient: .mock).main()
Main(httpClient: .failed).main()

上記のコードでは、

  • protocolに適合した構造体を作らなくて良いので、面倒くささが減る
  • Mainクラスのイニシャライザに型省略した記法(.live.mock)で渡せるので、見やすくなりどのようなインスタンスを渡しているのか(本番用なのかモックようなのか)わかりやすくなる(この点が私にとって最も良いなと思いました)

のようなメリットを感じました。

このスクラップは2021/07/19にクローズされました