Swiftの関数・クロージャ・コレクションを実行して計測する

引数ラベルと inout、変数をつかむクロージャ、コピーオンライトの可視化と実測、map/filter/reduce と for ループの -O 比較、初心者が出会う実際の swiftc エラーまで実行して確認します。

シリーズ:Swift 講座 2/6(初級)。 第1回を終えた人、または Swift を数行書いたことがある人向けです。読み終えると、呼び出し側で読みやすい関数を書き、クロージャが何をつかむかを予想でき、配列が本当にコピーされるタイミングが分かるようになります。

コードレビューをしていると、新人の書いた Swift はすぐに分かります。true が何を意味するのかコメントがないと読めない関数呼び出し、誰も予想していない値を表示するクロージャ、「念のため」と手でコピーを作って渡される配列。この記事ではその3つの癖を直します。第1回と同じく1つのファイルをターミナルで実行し、下のエラーと数値はすべて実際に動かして得たものです。

環境:Apple Silicon Mac(M1 Pro)、Swift 6.4.2。swift file.swift は既定で Swift 5 言語モードで動き、完成したコードは -swift-version 6(strict concurrency)でも型チェックしました。時間は、第1回で見たとおり最適化なしの計測は意味がないので swiftc -O で測っています。

関数:引数ラベル、デフォルト値、inout

関数は、引数(パラメータ)を受け取り、値を返すこともできる名前付きのコードのまとまりです。Swift の関数には他の言語ではあまり見ない機能があります。パラメータごとに引数ラベル、つまり呼び出し側が書く名前を、関数の中で使う名前とは別に付けられます。

func greet(_ name: String, from city: String = "Seoul", times: Int = 1) -> String {
    Array(repeating: "Hi \(name) from \(city)!", count: times).joined(separator: " ")
}
print(greet("Ana"))
print(greet("Ben", from: "Osaka"))
print(greet("Cy", times: 2))
Hi Ana from Seoul!
Hi Ben from Osaka!
Hi Cy from Seoul! Hi Cy from Seoul!
  • _ name は「ラベルなし」。呼び出しは greet("Ana")。
  • from city は、呼び出し側は from:、関数の中では city を使います。呼び出しが英文のように読めます。
  • = "Seoul" はデフォルト値で、呼び出し時に省略できます。
  • 本体が式1つだけなら、return なしでその値を返します。

ラベルは関数名の一部なので、コンパイラは厳しくチェックします。

ferr1.swift:2:13: error: missing argument label 'name:' in call
ferr2.swift:2:12: error: incorrect argument label in call (have '_:city:', expected '_:from:')

2つ目のメッセージは読み方を覚えておく価値があります。自分が書いたラベルと、関数が持っているラベルを並べて見せてくれます。

inout:関数に自分の変数を変えさせる

関数の中のパラメータは定数です。func bump(_ n: Int) { n += 1 } は left side of mutating operator isn't mutable: 'n' is a 'let' constant で失敗します。呼び出し側の変数を変える必要があるなら、パラメータに inout を付け、呼び出し側は & を書きます。

func addTax(to price: inout Double, rate: Double = 0.1) {
    price *= 1 + rate
}
var price = 1000.0
addTax(to: &price)
print(price)            // 1100.0

func swapValues(_ a: inout Int, _ b: inout Int) { (a, b) = (b, a) }
var x = 1, y = 2
swapValues(&x, &y)
print(x, y)             // 2 1

間違え方2通りの実際のメッセージ:

ferr3.swift:3:12: error: passing value of type 'Double' to an inout parameter requires explicit '&'
ferr3.swift:5:12: error: cannot pass immutable value as inout argument: 'q' is a 'let' constant

& は呼び出し箇所に付く目印で、「この呼び出しは自分の変数を書き換える」という意味です。inout は本当に必要なときだけ使い、普段は新しい値を返すほうが読みやすくなります。

クロージャ:名前のない関数

クロージャは、変数に入れたり他の関数に渡したりできるコードのブロックです。sorted(by:) は「1つ目が2つ目より前に来るべきか」を返すクロージャを受け取ります。同じ呼び出しを長い形から短い形まで並べると:

let scores = [72, 95, 58, 88]
let sorted1 = scores.sorted(by: { (a: Int, b: Int) -> Bool in return a > b })
let sorted2 = scores.sorted { a, b in a > b }      // トレイリングクロージャ、型は推論
let sorted3 = scores.sorted { $0 > $1 }            // 省略引数名
let sorted4 = scores.sorted(by: >)                 // 演算子も関数
print(sorted1, sorted2 == sorted3, sorted3 == sorted4)
// [95, 88, 72, 58] true true

トレイリングクロージャ(trailing closure)構文(sorted2)は、最後のクロージャ引数を括弧の外に出す書き方です。SwiftUI を含め、Swift のコードではどこでも使われます。$0 は短い1行だけにして、1行を超えるならパラメータに名前を付けましょう。

キャプチャ:クロージャは値ではなく変数をつかむ

クロージャは外側のコードの変数を使えます。これをキャプチャと呼び、値のコピーではなく変数そのものへの参照を保持します。

func makeCounter() -> () -> Int {
    var count = 0
    return {
        count += 1
        return count
    }
}
let c1 = makeCounter()
let c2 = makeCounter()
print(c1(), c1(), c1(), "|", c2())       // 1 2 3 | 1

var greeting = "Hello"
let byReference = { print("captured var:", greeting) }
let byValue = { [greeting] in print("capture list:", greeting) }
greeting = "Goodbye"
byReference()       // captured var: Goodbye
byValue()           // capture list: Hello

count は返したクロージャが保持しているので makeCounter() が終わっても生きており、makeCounter() を呼ぶたびに新しい count ができます。byReference は、作られた後で変わった greeting を見ます。キャプチャリスト [greeting] は、クロージャが作られた瞬間の値をコピーします。「クロージャが思った値を出さない」というバグの多くはこの違いで説明でき、第6回で扱う [weak self] もこのキャプチャリストの位置に書きます。

ループは安全です。for i in 1...3 { handlers.append { print("handler", i) } } は handler 1、2、3 を表示しました。繰り返しごとに i が別々に作られるからです。

@escaping

クロージャ引数を後で使うために保存しようとすると、コンパイラが止めます。

var saved: (() -> Void)?
func later(_ work: () -> Void) {
    saved = work
}
esc2.swift:3:11: error: assigning non-escaping parameter 'work' to an '@escaping' closure
2 | func later(_ work: () -> Void) {
  |              `- note: parameter 'work' is implicitly non-escaping

_ work: @escaping () -> Void と書きます。このキーワードは「このクロージャは関数が戻った後に実行されうる」という印で、シリーズ後半のメモリやスレッドの話で重要になります。こう直すと既定(Swift 5)モードでは動き、later { print("ran later") }; saved?() は ran later を表示しました。ただし -swift-version 6 では別の理由で失敗します:main actor-isolated var 'saved' can not be mutated from a nonisolated context。Swift 6 モードではトップレベルの var がメインアクターに属するためです。実際のコードではクロージャを型のプロパティに保存します。第3回で扱います。

Array・Dictionary・Set は値型

Swift の Array、Dictionary、Set は値セマンティクス(value semantics)を持ちます。別の変数に代入すると、丸ごとコピーしたように振る舞います。Python、Java、JavaScript、C# で2つの名前が1つのリストを指すのとは正反対です。

var a = [1, 2, 3]
var b = a          // コピーに見える
b.append(4)
print("a:", a, "b:", b)            // a: [1, 2, 3] b: [1, 2, 3, 4]

var dict = ["apple": 3]
var dict2 = dict
dict2["apple"] = 10
print(dict, dict2)                 // ["apple": 3] ["apple": 10]

var tags: Set = ["swift", "ios"]
print(tags.insert("swift"), tags.count)
// (inserted: false, memberAfterInsert: "swift") 2

配列を関数に渡すときもコピーが渡るので、inout でない限り関数があなたの配列を変えることはできません。

とはいえ代入のたびに100万要素をコピーしていたら遅すぎます。そこで Swift はコピーオンライト(copy-on-write)を使います。どちらかが書き込むまでは記憶領域を共有します。2つの配列のバッファアドレスが同じかを表示すると、目で確かめられます。

func address(_ arr: [Int]) -> String {
    arr.withUnsafeBufferPointer { String(describing: $0.baseAddress!) }
}
var big = Array(0..<5)
var copy = big
print("same buffer before write:", address(big) == address(copy))   // true
copy[0] = 99
print("same buffer after write: ", address(big) == address(copy))   // false
3段の図。1段目:var a = [1, 2, 3] が 1, 2, 3 を持つバッファ A を指す。2段目:var b = a は同じバッファ A を点線の矢印(共有、コピーなし)で指す。3段目:b.append(4) から 1, 2, 3, 4 を持つ新しいバッファ B へ矢印が伸び、「コピー」と書かれている。最初の書き込みの時点でコピーが起きる。a が使うバッファ A は 1, 2, 3 のまま。
コピーオンライト。var b = a の時点では1つのバッファを共有しているだけでコピーは起きない。b に最初に書き込んだ瞬間に b 専用のバッファが作られるので、a は変わらない。

本当のコピーはどれくらい高いのか。要素 1,000,000 個の [Int](8 MB)、1,000 ラウンド、swiftc -O、3回実行:

パターン(1,000 ラウンド) 1回目 2回目 3回目
let c = source、読むだけ 6.2 µs 4.8 µs 5.2 µs
var c = source; c[r] = -1 228 ms 233 ms 233 ms
1回コピーしてから1,000回書き込み 0.19 ms 0.22 ms 0.23 ms

共有は実質タダです。「コピーして書き込む」は毎回 8 MB を複製し、1ラウンドあたり約 0.23 ms かかりました。すでに自分だけが持っている配列への書き込みには追加コストがありません。ルール:代入は気にせずしてよいが、ループの中でコピーを作ってすぐ変更しないこと。(読むだけの行がここまで小さいのは、使われないと証明できる処理をオプティマイザが省いた影響もあります。要点は 8 MB のコピーが起きていないことです。)

map・filter・reduce と for ループ

map は要素を1つずつ変換し、filter は条件を満たすものだけを残し、reduce は全体を1つの値にまとめます。1,000万個の Int から偶数の2乗和を3通りで求めました。

// Sum of squares of even numbers, three ways. Fixed seed 42.
var seed: UInt64 = 42
var data: [Int] = []
for _ in 0..<10_000_000 {
    seed = seed &* 6364136223846793005 &+ 1442695040888963407
    data.append(Int(seed >> 54))    // 0...1023
}
let clock = ContinuousClock()

func loopVersion(_ xs: [Int]) -> Int {
    var total = 0
    for x in xs where x % 2 == 0 { total += x * x }
    return total
}
func chainVersion(_ xs: [Int]) -> Int {
    xs.filter { $0 % 2 == 0 }.map { $0 * $0 }.reduce(0, +)
}
func lazyVersion(_ xs: [Int]) -> Int {
    xs.lazy.filter { $0 % 2 == 0 }.map { $0 * $0 }.reduce(0, +)
}

for round in 1...3 {
    var r1 = 0, r2 = 0, r3 = 0
    let t1 = clock.measure { r1 = loopVersion(data) }
    let t2 = clock.measure { r2 = chainVersion(data) }
    let t3 = clock.measure { r3 = lazyVersion(data) }
    print("round \(round): for \(t1) | filter.map.reduce \(t2) | lazy \(t3) | equal: \(r1 == r2 && r2 == r3) \(r1)")
}

3ラウンドを2回実行し、すべて 1741581073024 を返しました。

方法 -O 6回の中央値 -Onone(3ラウンド)
for … where 33.4 ms 0.83〜0.86 s
.lazy.filter.map.reduce 36.5 ms 1.57〜1.65 s
.filter.map.reduce 53.4 ms 1.73〜1.90 s
棒グラフ。1,000万個の整数から偶数の2乗和を求める処理は、for ループで 33.4 ミリ秒、lazy の filter・map・reduce チェーンで 36.5 ミリ秒、中間配列を2つ作る通常の filter・map・reduce チェーンで 53.4 ミリ秒かかった。3つとも結果は 1741581073024。
1,000万個の Int(seed 42)から偶数の2乗和を求める。swiftc -O、6ラウンドの中央値、Apple Silicon Mac(M1 Pro)、Swift 6.4.2。for ループ 33.4 ms、lazy チェーン 36.5 ms、filter.map.reduce 53.4 ms。

通常のチェーンが遅いのは、filter が約500万要素の新しい配列を作り、map がもう1つ作るからです。.lazy は1要素をチェーンの最後まで通してから次の要素に進むので、-O ではループとの差が10%以内でした。まずは読みやすさで選び、大きな配列を回すホットパスなら .lazy かループにします。

辞書の参照はオプショナルを返す

辞書はキーを値に対応させます。存在しないキーを尋ねるのはよくあることなので、結果はオプショナルです。

let stock = ["apple": 3, "pear": 0]
print(stock["apple"] as Any, stock["kiwi"] as Any)    // Optional(3) nil
print(stock["kiwi", default: 0])                     // 0
if let pears = stock["pear"] { print("pears in stock:", pears) }   // pears in stock: 0

var counts: [Character: Int] = [:]
for ch in "banana" { counts[ch, default: 0] += 1 }
print(counts.sorted { $0.key < $1.key })
// [(key: "a", value: 3), (key: "b", value: 1), (key: "n", value: 2)]

pear に注目してください。キーがあり値が0なので、if let は成功します。「ない」と「0」は別物で、だから参照結果がオプショナルなのです。そのまま計算に使うと失敗します。

dict_err.swift:2:18: error: value of optional type 'Int?' must be unwrapped to a value of type 'Int'

辞書を表示するときは、上のようにソートしてからにしましょう。Dictionary の順序は決まっておらず、実行ごとに変わることがあります。

よくある間違い:Index out of range

辞書と違い、配列はオプショナルを返しません。範囲外のインデックスはクラッシュです。

let items = ["a", "b", "c"]
for i in 0...items.count {        // 0...3 は 3 を含む
    print(i, items[i])
}
$ swiftc oob.swift -o oobd && ./oobd
0 a
1 b
2 c
Swift/ContiguousArrayBuffer.swift:692: Fatal error: Index out of range
$ echo $?
133

第1回の強制アンラップと同じく、-O ビルドは 0 a、1 b、2 c を表示した後、メッセージなしで終了コード 133 で止まりました。直し方は、インデックス計算を自分で書かないことです。

for i in items.indices { print(i, items[i]) }          // 有効なインデックスだけ
for (i, v) in items.enumerated() { print(i, v) }       // インデックスと値
print(items.first ?? "-", items.last ?? "-")           // オプショナルで取得

0..<items.count も正しいです。0...items.count が典型的な1つずれ(off-by-one)です。

初心者がよく出会うエラー集(実際のメッセージ)

書いたコード swiftc のメッセージ 直し方
let arr = [1, 2]; arr.append(3) cannot use mutating member on immutable value: 'arr' is a 'let' constant var arr
let empty = [] empty collection literal requires an explicit type let empty: [Int] = []
let mixed = [1, "two", 3.0] heterogeneous collection literal could only be inferred to '[Any]'; add explicit type annotation if this is intentional 型を1つにそろえるか enum
for n in nums { n *= 2 } left side of mutating operator isn't mutable: 'n' is a 'let' constant nums = nums.map { $0 * 2 }
-> Int なのに return がない関数 missing return in global function expected to return 'Int' return s を追加
Set<Int> に s[0] no exact matches in call to subscript Set に順序はない。contains を使う

もう1つは警告ですが、かなり紛らわしいものです。map の中の複数行クロージャで、最後の行が return なしの u だけのとき:

e.swift:4:5: warning: expression of type 'String' is unused [#NoUsage]
e.swift:2:5: warning: constant 'upper' inferred to have type '[()]', which may be unexpected

値を暗黙に返すのは式1つだけのクロージャです。文が複数あるなら return u が必要で、書かないと空タプルの配列になります。警告はエラーのつもりで読みましょう。

限界とバージョンについて

  • 実行したのは Swift 6.4.2 だけです。@escaping の例を除くすべてのコードが、既定の Swift 5 モードと -swift-version 6 の両方で型チェックを通りました。Swift 6 モードではベンチマークの初稿が失敗しました。トップレベルの var seed を書き換える補助 func が main actor-isolated var 'seed' can not be mutated from a nonisolated context を出したため、最終版は配列をトップレベルのコードで埋めています。
  • バッファアドレスの確認に withUnsafeBufferPointer を使ったのは共有を見せるためだけです。バッファアドレスにプログラムのロジックを依存させないでください。
  • ContiguousArrayBuffer.swift:692 はこのバージョンの標準ライブラリの行番号です。バージョンが変われば変わります。
  • 複数文のクロージャの戻り値型の推論は、Swift のバージョンによって変わってきました。上の [()] の警告は 6.4.2 が出したものです。
  • 時間は1台・1つのツールチェーンでの値です。コピーオンライトのコストは配列の大きさに比例し、0.23 ms は 8 MB での値です。

練習問題

  1. 大文字小文字を区別せずに単語を数える func countWords(_ text: String) -> [String: Int] を、split(separator: " ") と default: 添字で書いてください。"the cat and the hat and THE bat" の結果をキー順に表示します。
  2. 次のコードが表示する2行を予想してから実行してください。

    swift var total = 10 let addFive = { total += 5 } let snapshot = { [total] in print("snapshot:", total) } addFive() addFive() snapshot() print("total:", total)

  3. クラッシュせずに nil を返す func element(at index: Int, in list: [Int]) -> Int? を書き、[10, 20, 30] に対してインデックス 1、3、-1 で試してください。

解答

1.

func countWords(_ text: String) -> [String: Int] {
    var counts: [String: Int] = [:]
    for word in text.lowercased().split(separator: " ") {
        counts[String(word), default: 0] += 1
    }
    return counts
}
let result = countWords("the cat and the hat and THE bat")
for key in result.keys.sorted() { print(key, result[key]!) }
and 2
bat 1
cat 1
hat 1
the 3

split は Substring を返すので、String(word) でキーの型に合わせます。ここでの ! は、キーが辞書自身から来ているので安全です。なお日本語の文章は空白で区切られないので、この関数はそのままでは使えません。単語分割には別の仕組みが必要です。

2. snapshot: 10 と total: 20。addFive は変数をつかんで2回変更し、snapshot のキャプチャリストはクロージャを作った時点の 10 をコピーしていました。

3.

func element(at index: Int, in list: [Int]) -> Int? {
    list.indices.contains(index) ? list[index] : nil
}
let list = [10, 20, 30]
print(element(at: 1, in: list) as Any, element(at: 3, in: list) as Any, element(at: -1, in: list) ?? 0)
// Optional(20) nil 0

前後の講座

よくある質問

Swift の配列を別の変数に代入するとコピーされますか?

論理的にはされます。var b = a の後で b を変えても a は変わりません。実際のメモリは最初の書き込みまで共有されます。b[0] = 99 の前はバッファのアドレスが同じで、後では違いました。100万要素の配列で「コピーして書き込む」を1,000回行うと -O で約0.23秒、書き込まないコピー1,000回は合計約5マイクロ秒でした。

Swift の map/filter/reduce は for ループより遅いですか?

-O では少し遅い程度です。1,000万個の Int から偶数の2乗和を求めると、for ループ 33.4 ms、lazy チェーン 36.5 ms、filter.map.reduce 53.4 ms でした(6ラウンドの中央値、M1 Pro)。通常のチェーンは中間配列を2つ作り、.lazy はそれを避けます。

Swift の辞書の参照はなぜオプショナルを返すのですか?

キーが存在しないことがあるからです。stock["kiwi"] は nil で、stock["apple"] + 1 は "value of optional type 'Int?' must be unwrapped" でコンパイルできません。if let、?? または stock[key, default: 0] を使います。

  • #Swift
  • #初級
  • #クロージャ
  • #コレクション
  • #コピーオンライト
  • #swiftc

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑