LoginSignup
7
7

More than 3 years have passed since last update.

【Swift】スコープ関数が欲しい→パイプライン演算子になった

Posted at

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つのスコープ関数

スコープ関数には letalsorunapply の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

takeIftakeUnless

スコープ関数ではありませんが、似たところがある標準の関数に takeIftakeUnless があります。

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)

runapply は諦める:see_no_evil:

いきなりですが、runapply は実現を諦めました。

前述の通り、関数 run および apply はレシーバーを self としてブロックを呼び出します。
Swift でこれを実現する方法はなさそうです。(あれば教えてください:bow:

これらはそれぞれ letalso で代替できます。
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 |> BB(A) と等価です。A |> B |> CC( 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 の挙動がイメージできるような名前は…。

そうだ、takeIftakeUntil も欲しいですよね。
これらに対応する演算子も考えないと。

…やめましょう。
新しい演算子をいくつも作っても読みづらいだけです。
別の方法を考えましょう。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

takeIftakeUntil も実装しておきましょう。

@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
最適化してくれるかな?(詳しい方、ご意見をいただければ幸いです:bow:

まとめ

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)

runapply のような、ブロック内の self を書き換えるような関数は、Swift では実現できないので諦める。

パフォーマンスは少々落ちるかもしれない。

/以上


  1. with 関数というものもありますが、ちょっと形式が異なりますし、run で書き換えられるので、省略します。 

  2. 「拡張」というと一般的な用語すぎて違和感があるので、「extension」と書くことにします。 

  3. もし「これらもカスタム演算子にしたい」という方がいれば:also|^takeIf?:takeUnless?!、はいかがでしょうか。 

  4. Kotlin の場合は、インライン関数(スコープ関数はすべてインライン関数です)の引数として書かれたクロージャ式(Kotlin ではラムダといいます)はすべてインライン化されるので、パフォーマンスの問題はありません。 

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7