Swiftの関数に対する実装と抽象の分離の実現(関数のDI)
話すこと
こんちには,@arasan01です。Twitterで流れてくるポケモンのマンガがどれも良すぎて毎日つらいです,早くポケモンと暮らしたい。
今回は関数に対する実装と抽象の分離について考察します。
実装と抽象の分離を考えます,以下のような対応を考えると関数も抽象を捉えられそうです。
実装 | 抽象 |
---|---|
構造体・クラス | プロトコル |
関数 | ? |
純粋関数の場合は明確に実装と抽象が分離できます。下記の通常の関数の定義では実装はビルド時に決定されますが,分離した場合には実行時まで実装の選択が遅延されます。そのため依存性の注入が実行時に可能です。
// 通常の関数の定義
func add(first: Int, second: Int) -> Int {
first + second
}
// 分離した場合の定義
let impl: (Int, Int) -> Int = { first, second in first + second }
func add(first: Int, second: Int) -> Int {
impl(first, second)
}
高階関数
さて,このままでは少し使いにくいです。そのため関数を呼ぶと実装が返されると筋が良さそうです,これを高階関数と呼びます。またこれを実現するためには関数型にラベルがあると嬉しいですね。
下記に実装例を示します。しかしながらラベルを付与することは現在できません。Swift3よりクロージャでは型の順序のみで判別されることになるため分離した場合の定義を関数では表現できそうにないです。
// ❌
func add_maker(
impl: @escaping (Int, Int) -> Int = {
first, second in first + second
}
) -> (first: Int, second: Int) -> Int {
{ (first: Int, second: Int) -> Int in
impl(first, second)
}
}
let add = add_maker()
add(first: 1, second: 2)
// ✅
func add_maker(
impl: @escaping (Int, Int) -> Int = {
first, second in first + second
}
) -> (Int, Int) -> Int {
{ (first: Int, second: Int) -> Int in
impl(first, second)
}
}
let add = add_maker()
add(1, 2)
高階関数の手法を用いるとラベルを付与できそうにないです,別の手法を検討しましょう。
構造体の関数化
構造体を関数のように呼び出す方法が存在します。Swift5.2からcallAsFunction
のメソッドを定義することで構造体のインスタンスを関数のように扱うことができます。
これを用いて引数のラベルを表現します。実装は呼び出し箇所それぞれで定義してもよいですがextensionに通常行わせたい処理を定義することも有効です。何もしないことが意味のある関数の処理の場合は追加で定義することも良いです。
struct Sample {
let impl: (Int, Int) -> Int
func callAsFunction(first: Int, second: Int) -> Int {
return impl(first, second)
}
}
extension Sample {
static func live() -> Self {
.init { (first: Int, second: Int) -> Int in
first + second
}
}
}
let sample: Sample = .live()
sample(first: 1, second: 2)
すごく良さそうです!関数レベルでもうまく実装と抽象の分離ができました,テストコードも書きやすいそうです。
構造の共通化
さて,このコードを見たときに同じ型を3度記述していることに気づきますか?これは変更する際に大変なように感じます,そのため一度の定義でうまく表現する方法を検討しましょう。
私達は通常のプログラミングにおいて同じ値を利用するために値を変数に束縛しています,これを型レベルでも表現すると意図しているコードになります。
struct Sample {
typealias Input = (first: Int, second: Int)
typealias Output = Int
let impl: (Input) -> Output
func callAsFunction(args: Input) -> Output {
impl(args)
}
}
extension Sample {
static func live() -> Self {
.init { args in
args.first + args.second
}
}
}
let sample: Sample = .live()
sample(args: (first: 1, second: 2))
うまくタイプを1つの記述で整理できました!1つの関数ではこれ以上手を加えられる部分も少なそうです。
一歩進んで同様の関数を多く定義することを考えましょう,共通する要素が多く見えてきます。
let impl: (Input) -> Output
func callAsFunction(args: Input) -> Output { impl(args) }
また,引数がある場合を検討していますが,汎用性を考えたときに引数がない場合を考えましょう。
下記の記述ではプロトコルに同様の記述をまとめました。
入力がVoid
のときに関数はf()
のようにかけるはずです。しかし現在ではf(args: ())
のように引数が空であることを記述しなくてはならないため,条件付きのデフォルト実装を追加します。
protocol DIFunction {
associatedtype Input
associatedtype Output
var impl: (Input) -> Output { get }
func callAsFunction(args: Input) -> Output
}
extension DIFunction where Input == Void {
func callAsFunction(args: Input = ()) -> Output {
impl(args)
}
}
extension DIFunction {
func callAsFunction(args: Input) -> Output {
impl(args)
}
}
struct ASample: DIFunction {
let impl: ((first: Int, second: Int)) -> Int
}
struct BSample: DIFunction {
let impl: (Void) -> Int
}
extension ASample {
static func live() -> Self {
.init { args in
args.first + args.second
}
}
}
extension BSample {
static func live() -> Self {
.init { return 42 }
}
}
let asample: ASample = .live()
asample(args: (first: 4, second: 2)) // Output: 6
let bsample: BSample = .live()
bsample() // Output: 42
それぞれの関数として扱いたい構造体の定義が完結になりましたね!これを用いることで関数の実装と処理を分離できそうな気配が出てきました。
関数の依存関係
もしA関数がB関数に必要な場合,どこでその依存関係を解決するべきでしょうか。
B関数にとってA関数が必要な状況は,B関数を呼び出すときです。しかし,実行時ごとにA関数を生成することは避けたい場合があります。例えばJSONEncoderなどはある程度初期設定をしてしまえば使いまわしてほしいはずです。
幸運なことにこれは簡単に解決できます。実装を定義する際にstatic関数としてlive()
などを今まで定義していました。この引数に依存関係を渡すことでうまくいきます。
struct Linear: DIFunction {
let impl: ((first: Int, second: Int)) -> Int
}
struct Bias: DIFunction {
let impl: (Void) -> Int
}
extension Linear {
static func live(
bias: Bias = .live() // ✅
) -> Self {
.init { args in
return args.first * bias() + args.second
}
}
}
extension Bias {
static func live() -> Self {
.init {
return 10
}
}
static func debug() -> Self {
.init {
print("debug")
return 100
}
}
}
let linearFunctions: [Linear] = [
.live(),
.live(bias: .live()),
.live(bias: .debug()),
.live(bias: .init(impl: { 1000 })),
.init(impl: { args in
return 420_000 + args.first * 10 + args.second
})
]
おわりに
関数というかたまりをどうやって分解できる要素で構築するかについて見てきました。
すべてダミー実装に置き換えることで処理の流れをテストするなど,すべてが依存注入可能な書き方をするとできる幅が広がります。
それではまた ノシ
Twitterやってます,適当なこと話してます。
Discussion