Swift にも Kotlin の「スコープ関数」のようなものが欲しい、と思って試行錯誤していたら、
いくつかのプログラミング言語に導入されている「パイプライン演算子」になりました。
困りごと
まずある処理 a を行い、次にその結果を使って処理 b を行い、更にその結果を使って処理 c を行い…というようなことがあります。
そのような場合、ローカル変数が大量になるか、
let resultOfA = a()
let resultOfB = b(resultOfA)
let resultOfC = c(resultOfB)
入れ子が深くなり、
c(b(a()))
コードが読みにくくなってしまいます。
Kotlin のスコープ関数
プログラミング言語 Kotlin には「スコープ関数」という関数群があります。
先ほどのような場合でも、これらを使うとチェーン呼び出しでローカル変数や入れ子を少なく書くことができます。
先ほどの例であれば次のように書けます。
(以下、Kotlin の関数を説明する際にも Swift 式の記法で書きます。)
a().let { b($0) }.let { c($0) }
スコープ関数はどんなオブジェクトに対しても使えます。
以下で各スコープ関数について簡単に説明します。
なお、以下では次の用語を使用します。
- レシーバー:
s.f { ... }
におけるs
。 - ブロック:
s.f { ... }
における{ ... }
。
4つのスコープ関数
スコープ関数には let
、also
、run
、apply
の4つ1があります。
let
関数
レシーバーを引数にしてブロックを呼び出し、その返値を返します。
print(
"app".let { $0 + "le" }
) // > apple
also
関数
レシーバーを引数にしてブロックを呼び出し、レシーバーを返します。
print(
"app".also {
print($0 + "le") // > apple
}
) // > app
run
関数
レシーバーを self
としてブロックを呼び出し、その返値を返します。
print(
"apple".run { dropLast(2) }
) // > app
apply
関数
レシーバーを self
としてブロックを呼び出し、レシーバーを返します。
print(
"apple".apply {
print(dropLast(2)) // > app
}
) // > apple
takeIf
と takeUnless
スコープ関数ではありませんが、似たところがある標準の関数に takeIf
と takeUnless
があります。
takeIf
関数
レシーバーを引数としてブロックを呼び出し、その返値が true
ならレシーバーを、false
なら nil
を返します。
print(
"apple".takeIf { !$0.isEmpty } ?? "(empty)"
) // > apple
print(
"".takeIf { !$0.isEmpty } ?? "(empty)"
) // > (empty)
takeUnless
関数
レシーバーを引数としてブロックを呼び出し、その返値が true
なら nil
を、false
ならレシーバーを返します。
print(
"apple".takeUnless { $0.isEmpty } ?? "(empty)"
) // > apple
print(
"".takeUnless { $0.isEmpty } ?? "(empty)"
) // > (empty)
run
と apply
は諦める
いきなりですが、run
と apply
は実現を諦めました。
前述の通り、関数 run
および apply
はレシーバーを self
としてブロックを呼び出します。
Swift でこれを実現する方法はなさそうです。(あれば教えてください)
これらはそれぞれ let
と also
で代替できます。
self.
を省略して書けたはずのところを $0.
と書けばよいだけなので、なくてもそれほど困りません。
extention(拡張)2で実装してみる
Kotlin のスコープ関数は拡張関数という仕組みを使って実装されています。
拡張関数を使うと、任意の型に対して、その型を定義したソースコードを書き換えることなく、メンバ関数を追加することができます。
Swift の extension と似ていますね。
なので extension で実装してみましょう。
protocol Scopable { }
extension Scopable {
@inline(__always) func `let`<R>(block: (Self) -> R) -> R {
block(self)
}
@inline(__always) func also(block: (Self) -> Void) -> Self {
block(self)
return self
}
}
使用例:
extension String: Scopable { }
extension Int: Scopable { }
"2".let { Int($0)! }.let { $0 + 3 }.also { print($0) } // > 5
チェーンで簡潔に書けるようになりました!
しかし Any
型に対して extension を宣言することができないため、
スコープ関数を生やしたい型それぞれに extension
を宣言しないといけないのが面倒です。
演算子オーバーロード
演算子オーバーロードを使うのはどうでしょうか。
let
関数の代わりとして仮に ||
演算子をオーバーロードしてみると次のようになります。
@inline(__always) func || <T, R>(receiver: T, block: (T) -> R) -> R {
block(receiver)
}
使用例:
"2" || { Int($0)! } || { $0 + 3 } || { print($0) } // > 5
うまくいきました!
あれ、これどこかで見たような…
ってこれ、パイプライン演算子やんけ!
パイプライン演算子
パイプライン演算子は、いくつかのプログラミング言語に導入されている二項演算子です。
最近だと JavaScript に実験的に導入されました。
ほとんどの言語では |>
で表されます。
A |> B
は B(A)
と等価です。A |> B |> C
は C( B( A ) )
と等価です。
カスタム演算子 |>
を実装する
では |>
演算子を実装しましょう。
まず、演算子の結合規則(結合方向)と結合の優先順位を決める必要があります。
A |> B |> C
が ( A |> B ) |> C
と等価になって欲しいので、左結合ですね。
優先順位については、JavaScript では ||
より低く三項演算子よりは高いとしていますので、それに倣いましょう。
precedencegroup PipelinePrecedence {
associativity: left
lowerThan: LogicalDisjunctionPrecedence
higherThan: TernaryPrecedence
}
カスタム演算子を宣言します。
infix operator |>: PipelinePrecedence
演算子をオーバーロードします。
@inline(__always) func |> <T, R>(receiver: T, block: (T) -> R) -> R {
block(receiver)
}
使用例:
print(
"2" |> { Int($0)! } |> { $0 + 3 }
) // > 5
できました!
also
なども演算子にする?
also
も同じように演算子にしましょうか。
演算子の名前には記号類しか使えません。
→Lexical Structure — The Swift Programming Language (Swift 5.3) # Operators
also
の挙動がイメージできるような名前は…。
そうだ、takeIf
や takeUntil
も欲しいですよね。
これらに対応する演算子も考えないと。
…やめましょう。
新しい演算子をいくつも作っても読みづらいだけです。
別の方法を考えましょう。3
サポート関数
"2".also { print($0) }.let { Int($0)! }.let { $0 + 3 }.also { print($0) }
↑を↓のように書けるようにするのはどうでしょうか。
"2" |> also { print($0) } |> { Int($0)! } |> { $0 + 3 } |> also { print($0) }
これなら分かりやすく、演算子名に悩むこともありません。
この方針で also
を実装するとこうなります。
@inline(__always) func also<T>(block: @escaping (T) -> Void) -> (T) -> T {
return {
block($0);
return $0;
}
}
also
関数は (T) -> T
型の関数を返します。
その関数は引数を1つ受け取り、それをそのまま返します。
また、その関数は、also
関数の引数として渡された (T) -> T
型の関数 block
を実行します。
これで先ほどの記法を実現できるようになりました。
"2"
|> also { print($0) } // > 2
|> { Int($0)! }
|> { $0 + 3 }
|> also { print($0) } // > 5
takeIf
と takeUntil
も実装しておきましょう。
@inline(__always) func takeIf<T>(predicate: @escaping (T) -> Bool) -> (T) -> T? {
return {
predicate($0) ? $0 : nil
}
}
@inline(__always) func takeUnless<T>(predicate: @escaping (T) -> Bool) -> (T) -> T? {
return {
predicate($0) ? nil : $0
}
}
使用例:
""
|> takeUnless { $0.isEmpty }
|> { $0 ?? "(empty)" }
|> also { print($0) } // > (empty)
懸案
クロージャ式を大量に書くことになるため、
パフォーマンスは少々落ちるかもしれません。4
最適化してくれるかな?(詳しい方、ご意見をいただければ幸いです)
まとめ
Swift にスコープ関数のようなものが欲しい場合、
スコープ関数 let
の代わりにパイプライン演算子 |>
を実装するとよい。
also
などは、パイプライン演算子と併せて使うサポート関数で実現する。
実装:
precedencegroup PipelinePrecedence {
associativity: left
lowerThan: LogicalDisjunctionPrecedence
higherThan: TernaryPrecedence
}
infix operator |>: PipelinePrecedence
@inline(__always) func |> <T, R>(receiver: T, block: (T) -> R) -> R {
block(receiver)
}
@inline(__always) func also<T>(block: @escaping (T) -> Void) -> (T) -> T {
return {
block($0);
return $0;
}
}
@inline(__always) func takeIf<T>(predicate: @escaping (T) -> Bool) -> (T) -> T? {
return {
predicate($0) ? $0 : nil
}
}
@inline(__always) func takeUnless<T>(predicate: @escaping (T) -> Bool) -> (T) -> T? {
return {
predicate($0) ? nil : $0
}
}
使用例:
"2"
|> also { print($0) } // > 2
|> { Int($0)! }
|> { $0 + 3 }
|> also { print($0) } // > 5
""
|> takeUnless { $0.isEmpty }
|> { $0 ?? "(empty)" }
|> also { print($0) } // > (empty)
run
や apply
のような、ブロック内の self
を書き換えるような関数は、Swift では実現できないので諦める。
パフォーマンスは少々落ちるかもしれない。
/以上