Regex-powered string processing algorithms
01 何が問題だったのか
Swift の標準ライブラリには、他のスクリプト言語と比べて文字列処理のアルゴリズムが不足していました。部分文字列を含むかどうかの判定や、指定パターンをすべて置換する、区切り文字で分割するといった「よくある処理」を素朴に行うための API が揃っておらず、多くの場合は Foundation の NSString 系 API(range(of:) や replacingOccurrences(of:with:) など)や、インデックスを自分で進めながらループを回すコードを書く必要がありました。
例えば「ある文字列中に "banana" が何回現れるか」を数えるだけでも、Swift では次のようなコードになりがちです。
let str = "A banana a day keeps the doctor away. I love bananas; banana are my favorite fruit."
var idx = str.startIndex
var ranges = [Range<String.Index>]()
while let r = str.range(of: "banana", options: [], range: idx..<str.endIndex) {
if idx != str.endIndex {
idx = str.index(after: r.lowerBound)
}
ranges.append(r)
}
print(ranges.count)
同じ処理が Python では str.count("banana") の一行で済むことを考えると、Swift 側の表現力の不足は明らかです。
さらに、現実の文字列処理ではパターンマッチだけでは足りない場面も多くあります。HTTP ヘッダの日付部分を Date として取り出したい、明細書から通貨表記を Decimal として拾いたい、といったケースでは、ロケールや会計表記、標準規格への準拠を踏まえた本格的なパーサが必要です。従来は、regex で「日付らしい部分文字列」をざっくり抜き出してから、改めて Date.ParseStrategy などで解析する、という二段構えを書く必要があり、前段の簡易パーサが本物のパーサと食い違ってバグの温床になりがちでした。
SE-0350 から始まる一連の regex 関連 Proposal は、Swift に本格的な regex とリテラル、そしてそれを組み立てる DSL(SE-0351)を持ち込みました。しかし、regex そのものがあっても、それを文字列に対して適用する標準的な API が揃っていなければ、実際のコードはきれいになりません。本 Proposal は、regex(および一般のコレクション)を用いた検索・置換・分割などのアルゴリズムを標準ライブラリに一揃い用意して、スクリプト言語並みの手触りで文字列処理を書けるようにすることを目的としています。
02 どのように解決されるのか
文字列およびコレクションに対して、regex を受け取る検索・置換・分割系のアルゴリズムを一通り追加します。あわせて、regex を使わない「部分列を直接渡す」版も汎用のコレクションに対して追加され、String 以外のコレクションでも同じ語彙で扱えます。さらに、Foundation の Date.ParseStrategy のような外部の強力なパーサを regex の一部として組み込めるようにする CustomConsumingRegexComponent プロトコルも導入されます。
追加されるアルゴリズム
文字列型(SubSequence == Substring を満たすコレクション)に対して、主に次のメソッドが追加されます。RegexComponent を受け取るため、regex リテラル・Regex インスタンス・regex builder のいずれも渡せます。
| メソッド | 概要 |
|---|---|
contains(_:) |
指定した部分列または regex にマッチする箇所を含むか |
starts(with:) |
先頭が指定した regex にマッチするか |
trimmingPrefix(_:) / trimPrefix(_:) |
先頭のマッチ部分を削除する |
firstRange(of:) |
最初のマッチの Range |
ranges(of:) |
重ならないすべてのマッチの Range |
firstMatch(of:) |
最初のマッチ(Regex.Match) |
wholeMatch(of:) |
全体が regex にマッチするか |
prefixMatch(of:) |
先頭から部分的にマッチするか |
matches(of:) |
重ならないすべてのマッチ |
split(separator:) |
regex を区切りとして分割 |
replacing(_:with:) / replace(_:with:) |
マッチ部分を置換(非破壊 / 破壊の両方) |
基本的な使い方
regex リテラルと組み合わせた典型的な例は次のようになります。
let text = "A banana a day keeps the doctor away. I love bananas; banana are my favorite fruit."
// 出現回数を数える
let count = text.ranges(of: "banana").count // 3
// 最初のマッチだけ取得
if let range = text.firstRange(of: /ban(ana|anas)/) {
print(text[range])
}
// すべてのマッチを走査
for match in text.matches(of: /\w+/) {
print(match.0) // マッチした部分文字列
}
replacing(_:with:) はマッチ情報を受け取るクロージャ版もあり、キャプチャを参照しながら置換文字列を組み立てられます。
let masked = "id: 42, id: 100".replacing(/id: (\d+)/) { match in
"id: \(String(repeating: "*", count: match.1.count))"
}
// "id: **, id: ***"
split(separator:) は Character 1 文字ではなく regex や部分文字列を区切りに取れるようになります。
let csv = "a, b ,c, d"
let parts = csv.split(separator: /\s*,\s*/)
// ["a", "b", "c", "d"]
trimPrefix(_:) は RangeReplaceableCollection なら破壊的に、そうでなくても trimmingPrefix(_:) で非破壊的に利用できます。
var line = " // comment"
line.trimPrefix(/\s+/)
line.trimPrefix("//")
// line == " comment"
コレクション一般への展開
regex 版と並んで、Element: Equatable を満たす一般のコレクションに対しても、部分列を直接引数に取るオーバーロードが追加されます。
let numbers = [1, 2, 3, 1, 2, 4, 1, 2, 3]
numbers.contains([1, 2, 3]) // true
numbers.firstRange(of: [1, 2]) // 0..<2
numbers.ranges(of: [1, 2]).count // 3
let replaced = numbers.replacing([1, 2], with: [9])
// [9, 3, 9, 4, 9, 3]
let splitted = numbers.split(separator: [1, 2])
// [[], [3], [4], [3]](omittingEmptySubsequences: true が既定で先頭の空要素は除外されます)
これにより、「文字列に対する正規表現マッチ」と「配列に対する部分列マッチ」を同じ語彙で表現できるようになります。
CustomConsumingRegexComponent による外部パーサの統合
CustomConsumingRegexComponent は、RegexComponent を継承した上で consuming(_:startingAt:in:) を要求するプロトコルです。入力文字列の指定位置からマッチを試み、「どこまで消費したか」と「その結果得られた値」を返します。これに適合すると、自前のパーサを regex の部品として扱えるようになります。
public protocol CustomConsumingRegexComponent: RegexComponent {
func consuming(
_ input: String,
startingAt index: String.Index,
in bounds: Range<String.Index>
) throws -> (upperBound: String.Index, output: RegexOutput)?
}
例えば Foundation の Date.ParseStrategy や FloatingPointFormatStyle<Decimal>.Currency が適合すれば、ロケール対応の日付や通貨をそのまま regex の Capture に組み込め、二段構えの前処理を書かずに済みます。
let header = "Date: Wed, 16 Feb 2022 23:53:19 GMT"
let dateParser = Date.ParseStrategy(
format: "\(day: .twoDigits) \(month: .abbreviated) \(year: .padded(4))"
)
let dateRegex = Regex {
Capture(dateParser)
}
let date: Date? = header.firstMatch(of: dateRegex).map(\.output.1)
let statement = """
CREDIT 04/06/2020 Paypal transfer $4.99
CREDIT 04/03/2020 Payroll $69.73
DEBIT 04/02/2020 ACH transfer ($38.25)
DEBIT 03/24/2020 IRX tax payment ($52,249.98)
"""
let currencyRegex = Regex {
Capture(.localizedCurrency(code: "USD").sign(strategy: .accounting))
}
let amounts: [Decimal] = statement.matches(of: currencyRegex).map(\.output.1)
空文字列・空マッチの扱い
firstRange(of:) や ranges(of:) に空の部分列や「空にマッチしうる regex」(例: /[a-z]*/)を渡したときの挙動は、Ruby・Python・Java・JavaScript などと同じ方針を取ります。すなわち、各インデックスの手前に空のマッチが見つかる、という扱いです。
let hello = "Hello"
hello.firstRange(of: "") // 0..<0
hello.ranges(of: "").count // 6(各位置に1つずつ)
hello.split(separator: "") // ["H", "e", "l", "l", "o"]
NSString.range(of:) が空文字列を見つけなかったのとは振る舞いが変わるので、従来コードを移行する際には注意が必要です。
Future Directions
本 Proposal のスコープには含まれませんが、将来的な拡張として次のような方向性が挙げられています。いずれも speculative で、実現を約束するものではありません。
- 末尾から検索する
lastRange(of:)や両端をトリムする API など、逆方向のアルゴリズム。前方からのranges(of:).lastと「末尾から見た最初のマッチ」が必ずしも一致しないなど、挙動の設計に詰めるべき点が残っています。 - 分割時に区切り文字自身を結果に含める
splitのバリアント。 - 重なりを許したマッチ取得や、末尾からのトリムなど、現状の API では届かない細かいユースケース。