Swift Digest
SE-0183 | Swift Evolution

Substring performance affordances

Proposal
SE-0183
Authors
Ben Cohen
Review Manager
Chris Lattner
Status
Implemented (Swift 4.0)

01 何が問題だったのか

Swift 4 では、String をスライスしたときの戻り値の型が String から Substring に変わりました。それ以前は String 自身がスライス型を兼ねていましたが、その方式だと、小さな部分文字列だけを保持しているつもりでも、元の大きな文字列のバッファが解放されずに残り続けてしまう問題がありました。かといってスライス時に毎回コピーを取る作りにすると、Collection のスライスは定数時間で取れるという要件と衝突しますし、性能面でも不利です。そこで Swift 4 では、スライス専用の型として Substring を導入し、元バッファへの参照を共有しつつ、String が必要になった時点で改めて String(substring) としてコピーを取る(= スライス時に回避したコピーを「遅延させる」)という設計が採られました。

この設計は方針としては自然ですが、標準ライブラリ側の API が String 前提のままだと、実務では次のような場面で毎回コピーが必要になり、せっかくの設計が活かしきれません。

  • Substring を数値に変換したいとき。Int.init(_:)Double.init(_:)String しか受け取らないため、Int(String(sub)) のように書く必要がある
  • Substring の列を連結したいとき。joined(separator:)Sequence where Element == String にしか生えておらず、[Substring] に対して直接呼べない
  • Substring に対して filter を呼んだとき。Collection のデフォルト実装が効いて戻り値が [Character] になってしまう

とくに数値変換と連結は、「文字列を split して得た Substring をループで処理する」といった典型的なコードで繰り返し現れるため、Substring のまま扱えないと不要なコピーが多発します。

また、SE-0163 では「Substring に対して新しい文字列を作り出す操作(uppercased() など)は String を返すべき」という方針が示されていました。Substring.filter はまだこの方針に沿っておらず、戻り値の型が揃っていない状態でした。

02 どのように解決されるのか

StringSubstring の共通プロトコルである StringProtocol を活用して、標準ライブラリの一部 API を一般化します。変更は次の 4 点です。

数値型のイニシャライザを StringProtocol に一般化

FixedWidthIntegerInt / UInt / Int32 などの整数型)と Float / Double / Float80 のイニシャライザが、String ではなく StringProtocol を受け取るようになります。

extension FixedWidthInteger {
    public init?<S : StringProtocol>(_ text: S, radix: Int = 10)
}

extension Float/Double/Float80 {
    public init?<S : StringProtocol>(_ text: S, radix: Int = 10)
}

これにより、Substring から直接数値を作れるようになります。たとえば CSV 風の文字列をパースするときに、いちいち String に変換し直す必要がありません。

let line = "1,2,3,4,5"
let numbers = line.split(separator: ",").compactMap { Int($0) }
// split の戻り値は [Substring] だが、Int($0) のまま渡せる
// numbers == [1, 2, 3, 4, 5]

joined(separator:)StringProtocol 要素の Sequence に拡張

従来は Sequence where Element == String にしか生えていなかった joined(separator:) が、Element: StringProtocol へと広がります。

extension Sequence where Element: StringProtocol {
    public func joined(separator: String = "") -> String
}

これにより、[Substring] を要素ごとに String へコピーしてから連結する、といった手間が不要になります。

let csv = "a,b,c,d"
let joined = csv.split(separator: ",").joined(separator: "-")
// joined == "a-b-c-d"

戻り値は String です。Substring の列を連結しても、得られる連結結果は独立した String になります。

Substring.filterString を返すように変更

Substring 上の filter が、Collection のデフォルト実装(戻り値 [Character])を上書きして String を返すようになります。

extension Substring {
    public func filter(_ isIncluded: (Element) throws -> Bool) rethrows -> String
}

これは SE-0163 で示された「Substring に対して新しい文字列を作り出す操作は String を返す」という方針に沿った変更です。uppercased() など他のメソッドと戻り値の型が揃い、Substring を起点にした文字列処理の結果を自然に String として受け取れます。

let sub = "Hello, World!"[...]  // Substring
let letters = sub.filter { $0.isLetter }
// letters は String 型で "HelloWorld"

スコープについて

この提案は、Substring を使う頻度が特に高い場所に的を絞って API を整えるものです。「String を要求する任意の API に Substring をそのまま渡せるようにする」といった、より一般的な相互運用の仕組み(暗黙変換など)は対象外で、将来の検討課題として残されています。当面は、ここで一般化された API 群と StringProtocol を手掛かりに、Substring のまま扱える範囲を少しずつ広げていく、という位置づけの変更です。