Swift Digest
SE-0228 | Swift Evolution

Fix ExpressibleByStringInterpolation

Proposal
SE-0228
Authors
Becca Royal-Gordon, Michael Ilseman
Review Manager
Doug Gregor
Status
Implemented (Swift 5.0)

01 何が問題だったのか

Swift の文字列リテラルは \(...) で式を埋め込める補間機能(string interpolation)を備えていて、補間の振る舞いは ExpressibleByStringInterpolation プロトコルによって抽象化されています。しかしこのプロトコルには Swift 3 時点から問題が知られていて、長らく deprecated のまま放置されていました。

既存設計の仕組み

旧来のコンパイラは文字列リテラルを literal segment(リテラル部分)と interpolated segment(補間部分)に分解し、各セグメントを init(stringInterpolationSegment:) で一度 Self に変換してから、すべてをまとめて init(stringInterpolation:) に可変長引数として渡していました。"hello \(name)!" はおおむね次のように展開されます。

String(stringInterpolation:
  String(stringInterpolationSegment: "hello "),
  String(stringInterpolationSegment: name),
  String(stringInterpolationSegment: "!"))

非効率性

この設計ではセグメントごとに Self の一時インスタンスを作ってから連結するため、セグメントの数や大きさによってはヒープ確保と ARC のオーバーヘッドが毎回発生します。さらに、コンパイラはリテラル部分の総文字数や補間の個数を知っているのに、その情報が適合型(conformer)には一切伝わらないため、最終サイズを見積もって事前にバッファを確保することもできませんでした。

柔軟性の不足

init(stringInterpolationSegment:) は制約のないジェネリック引数を取るため、補間値の種類を型で制限することもできませんでした。たとえば SQL 文のビルダーのように「整数と文字列しか補間させたくない」という用途には不向きです。

また、補間に追加のパラメータやオプションを添えたいというニーズにもまったく応えられません。"\(cost, format: "%.2f")" のように書式指定を渡したり、"\(public: tagName)" のようにラベルで意味を区別したり、属性付き文字列や SQL 用のエスケープ済み文字列のような型を安全に組み立てたりする、といった発展がすべて閉ざされていました。

加えて、補間値のデフォルト型推論がリテラル型ではなく String に固定されていたり、セグメントがリテラル由来か式由来かを適合側が判別するにはコンパイラの内部仕様(必ずリテラル segment から始まり literal と interpolated が交互に並ぶ)に依存するしかなかったり、といった問題もありました。

ABI 安定化までに決着させる必要

ExpressibleByStringInterpolation は Swift 5 で ABI 安定化されるため、deprecated のまま固定してしまう前にプロトコルを作り直す必要がありました。

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

ExpressibleByStringInterpolation を全面的に作り直し、セグメントを一つずつ append していくビルダー方式 に変更します。リテラル部分の総容量と補間回数がビルダーの初期化時に渡されるため事前確保が可能になり、補間ごとに appendInterpolation のオーバーロードが選ばれるため、引数の型や個数・ラベルを適合側が自由に設計できます。

新しいプロトコル

新しい ExpressibleByStringInterpolationStringInterpolation という関連型を持ち、そちらが補間の組み立てを担当します。

public protocol ExpressibleByStringInterpolation
  : ExpressibleByStringLiteral {

  associatedtype StringInterpolation: StringInterpolationProtocol
    = String.StringInterpolation
    where StringInterpolation.StringLiteralType == StringLiteralType

  init(stringInterpolation: StringInterpolation)
}

public protocol StringInterpolationProtocol {
  associatedtype StringLiteralType: _ExpressibleByBuiltinStringLiteral

  init(literalCapacity: Int, interpolationCount: Int)

  mutating func appendLiteral(_ literal: StringLiteralType)

  // 非形式要件: mutating func appendInterpolation(...)
}

補間のコード展開

コンパイラは "hello \(name)!" をおおよそ次のように展開します。リテラル総容量と補間回数は静的にわかるのでそのまま渡され、各セグメントは個別の文として追加されます。

String(stringInterpolation: {
  var temp = String.StringInterpolation(
    literalCapacity: 7, interpolationCount: 1)
  temp.appendLiteral("hello ")
  temp.appendInterpolation(name)
  temp.appendLiteral("!")
  return temp
}())

これによって、適合型は事前にバッファを確保でき、一時インスタンスを作ることなく補間値のデータをそのまま書き込めるようになります。

appendInterpolation は非形式要件

appendInterpolation はプロトコルの形式要件ではなく、適合側が 好きなシグネチャで自由にオーバーロードできる ad hoc な要件です。引数のラベル、個数、デフォルト値、可変長引数、throws のいずれもサポートされ、\(x, with: y) のような補間は appendInterpolation(x, with: y) の呼び出しに対応します。throws する appendInterpolation を使う文字列リテラルは try / try? / try! でカバーする必要があります。

コンパイラはプロトコルとしての形式要件ではないものの、適合型を宣言する際に「型と同じ可視性を持ち、戻り値を持たない(または @discardableResult)、非 staticappendInterpolation が少なくとも 1 つあること」を検査し、欠けている場合はエラーにします。

DefaultStringInterpolation と既定実装

標準ライブラリは String / SubstringStringProtocol)が共有する補間型 DefaultStringInterpolation を提供します。用途ごとに次の 2 種類の既定実装が用意されます。

  • StringInterpolationDefaultStringInterpolation にする型には init(stringInterpolation:) の既定実装が与えられ、補間結果の Stringinit(stringLiteral:) に渡します。既に ExpressibleByStringLiteral に適合していて String をリテラル型として使っている型は、適合を ExpressibleByStringInterpolation に変えるだけで補間を受け取れるようになります。
  • 独自の StringInterpolation 型を用意する型には init(stringLiteral:) の既定実装が与えられ、内部で補間型を経由して init(stringInterpolation:) に委譲します。

既存型に補間機能を後付けする

DefaultStringInterpolation は拡張可能で、String などに独自の補間フォーマットを追加できます。次の例では \(escaped:) 形式の補間を足しています。

extension DefaultStringInterpolation {
  fileprivate mutating func appendInterpolation(
    escaped value: String, asASCII forceASCII: Bool = false
  ) {
    for char in value.unicodeScalars {
      appendInterpolation(char.escaped(asASCII: forceASCII))
    }
  }
}

print("Escaped string: \(escaped: string)")

DefaultStringInterpolation への拡張は mutating なメンバのみを足し、self をコピーしたりエスケープするクロージャに閉じ込めたりしないよう注意します。

独自型での活用例

新しい設計では、型ごとに意味のある補間を静的に制限できます。たとえばログ用の型でプライバシーレベルを強制するなら、appendInterpolation を次のように用意します。

struct LogMessage: ExpressibleByStringInterpolation {
  struct StringInterpolation: StringInterpolationProtocol {
    var parts: [String] = []

    init(literalCapacity: Int, interpolationCount: Int) {
      parts.reserveCapacity(interpolationCount * 2 + 1)
    }

    mutating func appendLiteral(_ literal: String) {
      parts.append(literal)
    }

    mutating func appendInterpolation(public value: String) {
      parts.append(value)
    }

    mutating func appendInterpolation(private value: String) {
      parts.append("<redacted>")
    }
  }

  var text: String
  init(stringLiteral value: String) { self.text = value }
  init(stringInterpolation: StringInterpolation) {
    self.text = stringInterpolation.parts.joined()
  }
}

let msg: LogMessage = "Processing \(public: tagName) tag containing \(private: contents)"

この方針で、書式付き数値、属性付き文字列、ローカライズ用のフォーマット文字列、SQL 文の安全な組み立てなど、従来は補間の外でやるしかなかった処理を型安全に書けるようになります。

補間構文のパース

補間の \(...) 内は引数リストとしてパースされるようになり、ラベルや複数引数が書けます(trailing closure は不可)。これに伴って、従来タプルとして解釈されていた \(x, y)\(foo: x) は Swift 5 以降エラーになります。タプルを補間したい場合は \((x, y)) のように括弧を 1 段追加します。

付随する標準ライブラリの変更

  • StringProtocol、そして SubstringExpressibleByStringInterpolation に適合するようになり、Substring もリテラル補間で作れます。
  • Float / Double / Float80TextOutputStreamable 適合が追加され、浮動小数点の補間パフォーマンスが改善されます。