🏡

クロージャ(Closure)を自分の言葉で説明したい。

2023/07/01に公開

はじめに

自分がSwift学習中に疑問に思った内容なので本記事はSwift文脈で書かせて頂きます。
PHPとかJavaScriptにおいてはSwiftでのクロージャ式という概念が、無名関数に相当するという認識です。

今回は公式ドキュメントの要約が中心となりますので、そちらがスッと入ってくる方にとっては何てことない内容です。

結論

公式ドキュメントを読んで示唆されていると感じた結論は下記の通り。
広義、狭義と言った点は後ほど説明する。

  • クロージャとは?

広義の意味ではある目的を持った任意の処理のまとまりをグループ化したもの。
狭義の意味では話をクロージャ式に限定することになると思われるが、任意の処理のまとまりを名前なしでグループ化したもの。

  • クロージャと関数の違いは?

広義の意味でのクロージャと関数は同じ役割を持つと言える。ただ、あえて関数とクロージャ式で分けるとしたら、前者は名前付きで宣言してコード内で呼び出すことを目的とし、後者においては、宣言と名前を省略することによって関数と同じような機能を簡潔な構文で提供することができる。

  • クロージャの特徴は?

グローバル関数、ネスト関数、クロージャ式 …
これらまとめてクロージャと呼ぶことができるそうで、ただ構文や機能に差分があるので一概には言えない。

  • スコープに値を閉じ込めるとは?

前提として、先述した3つのうちネスト関数に限定した文脈と考えて良いかと思われる。
後述で詳しく述べるが私自身は「ネスト関数が親関数のローカル変数を参照し続けることによって、そのローカル変数の値を保持する仕組み」と言う風に解釈した。

Closureの分かりづらいポイント

まず、疑問に思った際に「swiftui closureとは」って感じで調べると思う。

それで、上位に出てきた説明はこんな感じ。

  • 再利用可能なまとまった処理を定義できるデータ構造
  • 関数のスコープにある変数を自分が定義された環境に閉じ込めるためのデータ構造

こんな感じで「まとまった処理」とか「ブロック」とか「スコープ内に変数を保持(閉じ込める)」とか、そんな感じのキーワードが登場する。

語り手によって微妙に表現が使われたり、さらに話をややこしくしているのが「クロージャ」という広い概念と「クロージャの中に含まれるグローバル関数・ネスト関数・クロージャ式」という狭い概念を区別せずに語られているものが存在する点だと思う。

この点Swiftの公式ドキュメントでは一つ一つが平易な表現で丁寧に説明されており、サンプルコードも分かりやすくてエクセレントだと感じた。これまで自分は公式以外の解説記事に頼ることも多かったが、「分からないときは公式ドキュメントを読め」を体現した良い例なのではないかと思う。

▼ 参考
Closure | Documentation
Functions | Documentation

ただし、値のキャプチャ(クロージャ内に値を閉じ込めると表現した箇所)だけは公式だけで理解しづらかったので、他資料も参考にした。

クロージャとは?

Closures are self-contained blocks of functionality that can be passed around and used in your code.
クロージャは、コード内で受け渡して使用できる、ある機能の独立したブロックです。

公式ドキュメントの冒頭は上記の通り。

ブロックというのは文字通り「ひとかたまりのもの」と捉えて良さそうで、プログラミング言語によって異なるが少なくともSwiftによっては{}(中括弧)で囲った独立した機能を指すと言えそう。

関数との違い

公式のClosures(以下クロージャ)とFunctions(以下関数)に書かれてある内容を整理する。

Swiftにおけるクロージャと関数の違いについてまとめる前に、そもそも上記ドキュメント内で「関数はクロージャの特別なタイプである」と説明されており、これを字面通りに受け取ると関数とクロージャは同列に比較する概念ではないということになる。

なので、あえてクロージャと関数の違いを説明するとしたら、クロージャの下記3つの類型について説明することになるだろう。

  • クロージャ / グローバル関数
  • クロージャ / ネスト関数
  • クロージャ / クロージャ式

グローバル関数とネスト関数の共通点

グローバル関数とネスト関数は同じ関数の仲間であり、共通点としてはいずれにも名前がついている。
これは関数の役割として任意の場所から呼び出されることが期待されており、呼び出しのための名前が必要になっているためである。

func greet(person: String) -> String {
    let greeting = "Hello, " + person + "!"
    return greeting
}

上記の関数はgreetという名前がつけられており、引数にString型のpersonを、戻り値としてString型の挨拶文を返すことが定義されている。

グローバル関数とネスト関数の違い

func chooseStepFunction(backward: Bool) -> (Int) -> Int {
    func stepForward(input: Int) -> Int { return input + 1 }
    func stepBackward(input: Int) -> Int { return input - 1 }
    return backward ? stepBackward : stepForward
}

まず、ネスト関数とは上記コードのように関数の中に入れ子で作成した関数のことを指し、親関数の処理内でしか呼び出すことができず外部から利用することができない。ネスト関数以外の関数はグローバル関数に分類され、ネスト関数のことはグローバル関数に対してローカル関数と呼ばれることもある。

また、もう1つ両者の違いとして、「定数や変数をクロージャ内に閉じ込めるかどうか」の違いがある。このように値を自身のスコープに閉じ込めることを公式では、Capturing Value(値のキャプチャ)と名付けて説明している。

値のキャプチャ(Capturing Value)の特徴

値のキャプチャを自分なりに噛み砕いた結果、「ネスト関数が親関数のローカル変数を参照し続けることによって、そのローカル変数の値を保持する仕組み」という説明に落ち着いた。

公式では下記の関数が例として挙げられており、makeIncrementerという関数の中にincrementerというネスト関数が定義されていて、これを実行した結果である。。

func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
    return incrementer
}

let incrementByTen = makeIncrementer(forIncrement: 10)

incrementByTen() // 10

incrementByTen() // 20

incrementByTen() // 30

let incrementBySeven = makeIncrementer(forIncrement: 7)

incrementBySeven() //7

incrementByTen() //40

incrementByTenを実行し終わったら変数runningTotalの値は消失してしまいそうだが、結果を見ると、実行の度にrunningTotalは10加算されて結果を返している。

ここで私が感じた疑問としては

  • incrementByTen実行の度になぜvar runningTotal = 0で初期化されないのか
  • incrementByTen実行後になぜrunningTotalの値は消失せずに保持できているのか

ここは公式ドキュメントでは分かりづらい部分であったため、下記StackOverFlowの回答を参考にし、整合性があって分かりやすかった部分を抜粋。
func内の変数保持について(クロージャーの理解) - スタック・オーバーフロー

まず、下記行を実行した際にはあくまで、incrementer関数を戻り値として受け取ったmakeIncrementerのインスタンスを生成してincrementByTenに代入しただけで、incrementer関数そのものが実行されたわけではない。

let incrementByTen = makeIncrementer(forIncrement: 10)

つまり、incrementByTenはincrementer関数を参照している状態で、incrementer関数はmakeIncrementerのローカル変数であるrunningTotalとamountを参照している。

makeIncrementerのインスタンス生成=makeIncrementerの実行が終わってもrunningTotalとamoutの値が保持されるのは、incrementByTenが参照しているincrementer関数がこれら2つのローカル変数に対する参照を継続しているから、という説明である。

クロージャ式とは

クロージャ式とは見た目の話をすると下記のようにfuncキーワードと名前を宣言しない、いわば短縮形の関数と言える。

{ (<引数パラメータ>) -> <戻り値の型> in
   <処理>
}

funcキーワードつきの完全な宣言や名前を使わずに済むことによって、例えば、ある関数の引数に別の関数を渡す際に簡潔に書くことができる。

公式ではクロージャ式を使わなかった場合と使った場合を比較するために以下のサンプルコードが記載されている。

let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
func backward(_ s1: String, _ s2: String) -> Bool {
    return s1 > s2
}
var reversedNames = names.sorted(by: backward)

sortedというメソッドはクロージャを引数に取り、クロージャの処理がtrueの場合は順番の入れ替えを実行する。

上記クロージャ内では引数2つを比較して、前の引数が後の引数より大きければtrueを返すので、どういうアルゴリズムで全パターン比較しているのかは分からないが、先頭からアルファベットの降順に並ぶ。
(Playgroundで不等号を逆にしたものを実行すると今度はアルファベットの昇順に並ぶ)

ちなみに関数とメソッドは似ているが、前者は独立して実行するもので、後者はインスタンスに付随して(今回だとArrayインスタンス)実行する違いがある。

これをクロージャ式で書くと下記のようになる。見た目の違いで変わったのは、処理をinキーワードに続けて書いていること、引数と戻り値の型を{}(中括弧)内で宣言している点がある。

reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
    return s1 > s2
})

簡潔な構文を提供するためのクロージャ式の特徴

型推論による型の省略

ステートメントの文脈から型を推測することができる場合に、型を自動的に判別する型推論(Type Inference)という機能があるが、これは変数の宣言だけでなくクロージャ式にも適用することができる。

まず、型推論の軽い説明から。

Swiftは型に厳格な言語であり、変数の宣言は変数名と型指定(型アノテーション)を同時に行う必要がある。

var x: Int = 0

それが、文脈から型を推測できるケースにおいては下記のように型指定を省略することができる。
この場合は変数の宣言と同時に代入を行なっており、代入した値を行なった上でxの型指定を自動的に行う。

var x = 0 //Int型と判別

ここで先ほどのsortedメソッドを見てみよう。

let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
    return s1 > s2
})

namesは「文字列だけ入った配列([String])」であり、sortedは「配列の値を2つ引数として受け取ってBoolean型の値を返すnames配列のインスタンスメソッド」であるため、上記のnames.sortedの引数と戻り値は(String, String) -> Boolと推測される。

このケースにおいては引数と戻り値の型が両方推測されるため、引数を囲む丸括弧とアローを省略することができ、下記のように記述することができる。

let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
reversedNames = names.sorted(by: { s1, s2 in return s1 > s2 } )

func sorted() -> [Self.Element]
Returns the elements of the sequence, sorted.
Available when Element conforms to Comparable.

sortedメソッドの公式ドキュメントを見ると上記ように記載があり、配列内の要素がComparable(並べ替え可能)プロトコルに適合していれば問題ない。names配列の中身を[1,2,3,4,5]と[Int]に置き換えても正常に動作する。

これはsortedメソッドの引数に型指定が行われていることによって成り立つものであり、関数(function)にはこの省略法は存在しない。

単一式の暗黙的な戻り値

クロージャが単一式しか含まない場合はreturnキーワードを省略できる。

先述のname.sorted()の例もそうで、

reversedNames = names.sorted(by: { s1, s2 in s1 > s2 } )

とすることができる。

このケースも先述した型推論による型指定の省略と同じように、sortedメソッドの戻り値の型が指定されているかつ、in以降に続く本文の式がs1 > s2なので、上記クロージャがs1 > s2を比較した結果をBooleanで返すことは明瞭であるため。

引数の短縮名

reversedNames = names.sorted(by: { $0 > $1 } )

上記のように$と整数nを組み合わせることで引数リストのn番目を参照することができ、クロージャの引数定義を省略することができる。

また、引数の記載がなくなって本文だけになったのでinキーワードも省略する。

後置クロージャ(Trailing Closure)

倒置クロージャの存在を知らなかった私は初めて上記の構文を見たときに惑わされてしまったが、Trailing Closureという名前の構文となる。日本語ではtrailingの意味と上記構文の特徴から「後置」と訳されることが多い。

呼び出した関数やメソッドの引数にクロージャ式を渡す際に、そのクロージャが最後の引数である場合は、クロージャを引数を囲む丸括弧()の後方に置くことができる。

下記は「クロージャを引数にとるsomeFUnctionThatTakesAClosure関数」を呼び出した際に、引数のクロージャを後置クロージャなしで書いた場合と、後置クロージャを適用して書いた場合の比較である。

// 省略前の後置する前のクロージャを引数に渡した状態
someFunctionThatTakesAClosure(closure: {
    // 引数のクロージャの処理
})

// 後置したクロージャを引数に渡した状態
someFunctionThatTakesAClosure() {
    // 引数のクロージャの処理
}

また、そもそも引数がクロージャだけの場合は、後置すると引数の丸括弧が空になってしまうが、その場合はこの丸括弧も省略することができる。

someFunctionThatTakesAClosure {
    // 引数のクロージャの処理
}

Viewにおけるクロージャ

SwiftUIのViewを構成する構造体の中でも引数にクロージャを取るものについては、引数や括弧の位置が変わるように見えて困惑することがある。ただ、これも先述したクロージャのルールに則れば、考え方が分かってくると思われる。

Button構造体の公式ドキュメントを見てみよう。

struct Button<Label> where Label : View

init(action: () -> Void, label: () -> Label)

ButtonはViewプロトコルに適合した構造体の1種で、イニシャライザは第1引数として戻り値のないクロージャ式を引数として受け取り、第2引数はカスタムラベルとして使用するLabel型を返すクロージャを受け取る。

このButton構造体で引数として取るクロージャを通常の記法で記載すると、下記のようになる。

Button(action: {//ボタン押下時のアクション}, label: {
    Text("Button")
})

ここでButton構造体のインスタンス生成時に最後の引数がクロージャになっているため、先述した後置クロージャ(Trailing Closure)を使って下記のように記載することができる。

Button(action: {//ボタン押下時のアクション}) {
    Text("Button")
})

また、Button構造体はLocalizedStringKey(超平たく言うとダブルクオテーションで囲まれた文字列)を使ってラベルを作成する場合は、下記のように引数の順序が逆になる。

init(LocalizedStringKey, action: () -> Void)
// インスタンス生成
Button("Button", action: {//ボタン押下時のアクション})

この場合でもButton構造体の最後の引数がクロージャであるため、後置クロージャを使って下記のようにすっきり書くことができる。

Button("Button"){ //ボタン押下時のアクション}

以上

Discussion