ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
Swift Regex: 上級編
Swift Regexで文字列処理のベーシックを越えましょう。Regexの概要や仕組みをはじめ、Foundationの豊富なデータパーサ、自前のパーサの統合、キャプチャの詳細について解説します。また、文字列の照合やRegexベースのアルゴリズムを活用するベストプラクティスも紹介します。
リソース
- SE-0350: Regex type and overview
- SE-0351: Regex builder DSL
- SE-0354: Regex literals
- SE-0355: Regex syntax
- SE-0357: Regex-powered algorithms
関連ビデオ
WWDC22
WWDC21
-
ダウンロード
(音楽)
Richardです Swift Standard Library チームの エンジニアです 今日は Swift Regex の基本を 越えた分野を見ていきましょう Swift 5.7 には力強い文字列処理の 新たな機能が加わっています まず “Regex”型です これは Swift Standard Library の新しい型です 言語組み込みの Regex リテラル構文で この強力で馴染みのある概念を さらに一流のものにします そして最後に RegexBuilder という リザルトビルダー API です これはドメイン固有言語 または DSL と呼ばれ リザルトビルダーのシンタックスの単純さや 構成可能性を上手く利用して Regex の可読性を全く新しい レベルにまで引き上げます Swift Regexが 文字列処理を容易にする理由は 同僚Michaelによるセッション 「Swift Regexの紹介」をご覧ください Swift Regex の非常に単純な例を 見ていきましょう 私が一連のデータを持っていて そこからユーザーIDをマッチさせ 抽出したいとしましょう NSRegularExpressionで行うように テキストから正規表現を 作成することができます マッチするのは user_id コロン その後にゼロまたは複数の空白が続き その後に1桁または数桁が入ります 今回 違う点は Regex型の値を作成していることです Swift Standard Library の新しい型です その後文字列の“firstMatch” アルゴリズムを使って Regex の定義するパターンで 最初にマッチしたものを見つけ 全てマッチするものをプリントします Regex 文字列はコンパイル時に既知なので Regex リテラルに変更して コンパイラにシンタックスエラーを チェックさせたり Xcode に シンタックスをハイライトさせたりできます ですが さらなる可読性や カスタマイズのためには RegexBuilder DSL が使えます RegexBuilder を使えば Regex コンテンツの読解は ネイティブの Swift API を 読むのと同じくらい容易です 本セッションではRegex の使い方と ワークフローへの適用方法をお見せします Regex はベースとなる Regex エンジンで 実行されるプログラムです Regex の実行時には そのエンジンが入力文字列を受け取り 文字列の最初から最後まで照合を行います 非常に単純な Regex を見ていきましょう この Regex がマッチする文字列は 1つ以上の“a”で始まり その後1桁以上続くものです 僕が使う照合アルゴリズムは“wholeMatch”で “aaa12”の入力にマッチさせます Regex エンジンは入力の 最初の文字から始めます 最初は1つかそれ以上のa の文字の照合です ここで “1”の文字に到達しますが これを“a”の文字に対して マッチさせようとしますが マッチしません すると Regex エンジンは Regex の次のパターンに移り 1桁または数桁をマッチさせようとします 文字列の最後に到達した時点で照合成功です 本セッションの残り時間で この実行モデルについてもう少し説明します Regex が Regex エンジンを使って 構築されている事で RegexBuilder DSL や そのアルゴリズムが Regex の パワーと表現力を広げます
Regex によるアルゴリズムは コレクションベースの API で 代表的なオペレーションとして “firstMatch”は文字列で最初にマッチした Regex を見つけ “wholeMatch”は文字列全体が マッチするか照合し “prefixMatch”は文字列の プレフィックスの照合を行います 照合の他にもSwift standard library は Regex ベースの予測や置換 トリミングやスプリッティングの API を加えました また Swift のパターンマッチングの case 文で Rengexが使えるようになり switch文での文字列の扱いが 非常に簡単になっています 最後に RegexビルダーとRegexを 利用したアルゴリズムの上に 今年 Foundation は Regexビルダーとシームレスに動作する 独自のRegexサポートを導入しました この Regex サポートは DateやNumberのような すでに使用している フォーマッタやパーサに他なりません APIについての詳細はWWDC21の 「Foundationの新機能」でどうぞ 今年はURLのフォーマットや パースのサポートも加えました Foundation におけるRegex サポートで Foundation パーサを直接 RegexBuilder に埋め込めます 例えばこのような銀行の取引明細のパースには Foundation の日付のパーサを カスタマイズしたものと 地域固有の通貨パーサを使う事ができます この最大の利点は厄介なケースを処理できて ローカライズをサポートする 既存の実戦的なパーサから Regex Builder DSL の表現力で Regex を構成できるという事なのです Swift Regex のワークフローへの 適用方法について 実際の例を見てみましょう XCTestを使ったユニットテストの 実行結果のログを パースするスクリプトを書きました テストログはテストスイートの ステータスで始まり終わります XCTest は全てのテストケースを実行し そのステータスを報告します 今日はログの最初と最後の行を パースしましょう テストスイートの情報です まず RegexBuilder をインポートします Swift Standard Library の新モジュールで RegexBuilder DSL を提供します Regex はそのボディである トレイリングクロージャで 初期化することができます 例のログメッセージを見てみましょう 注目すべき3つの変数部分の文字列は テストスイートの名前とそのステータス つまり started または passed か failed です そしてタイムスタンプです この3つのパースパターンを考えながら 行の他の部分は逐語パースしていきます ログメッセージの冒頭は“Test Suite”で スペースと一重引用符が続きます 次にテストスイート名をパースします 名前は識別子であり 小文字や大文字や数字を含むことができますが 最初の文字に数字は使えません
なので最初の文字にマッチする文字クラスと そして0かそれ以上の長さの 文字か 0から9の数字を照合します 非常にクリアで読みやすいですが やや面倒ですね テキストの Regex 構文を使いましょう RegexBuilder では簡潔なRegex リテラルを 直接ボディに埋め込めます Regexリテラルの最初と最後は / です Swift は正しい型を推測します 例えばここでは “Hello, WWDC!” つまり出力の型はサブストリングです ですが Regex リテラルの 本当の素晴らしさは 強く型付けされたキャプチャグループです 例えば年を表す2桁にマッチする キャプチャグループを記述します そしてその名前を“Year”とします すると出力タイプに 別のサブストリングが現れます 文字列からの情報抽出に キャプチャを使う方法を 後ほどお見せします 標準の Regexリテラルの他にも Swift は #/ で始まり /# で終わる 拡張リテラルをサポートしており 意味を持たない連続スペースを許します このモードではパターンを 複数の行に分割できます Regex リテラルをRegexBuilder に埋め込めば クリーンで見慣れた形になります テスト名をパースした後 一重引用符と空白をパースします そしてテストステータスです started failed passed のステータスがあります その1つにマッチさせるため “ChoiceOf”を使います 1つのサブパターンにマッチするので まさに狙い通りです 次にパースするのはステータスの直後のもので スペース “at” スペース になります 文字列の残りはタイムスタンプです これは1つ以上のどんな文字でも構いません ですが他の例を見ると ピリオドで終わるログメッセージもあります それに対応するのが“Optionally”です
Regex に対する入力の照合に 提供されているアルゴリズムの1つを使います 文字列全体を照合する “wholeMatch”を使いましょう これでログメッセージを1つずつ照合し マッチしたコンテンツをプリントします マッチしました! しかしマッチしたかどうかだけでは不十分です 気になる情報も抽出したい テスト名や ステータスやタイムスタンプです Regex のクールな機能を使いましょう キャプチャです! 照合中に一部の入力を保存するものです RegexBuilderでは“Capture”で Regex Syntax では丸括弧で挟みます マッチしたサブストリングを 出力のタプル型に加えるものです 出力タプル型はRegex全体にマッチした 全サブストリングで始まり 初めにマッチしたキャプチャ 2番目のキャプチャと続きます 照合アルゴリズムが Regex Match を返し そこから出力タプルが得られます 全体照合 ファーストキャプチャ そしてセカンドキャプチャです
テストスイートログの Regex で キャプチャを使ってみましょう キャプチャしたのは テストスイート名とステータス そしてタイムスタンプです 今度はこの Regex を入力に対して実行して キャプチャした3項目をプリントしましょう 照合成功のようです! 名前とステータスと タイムスタンプをプリントしました
ですがよく見ると日時に少しズレがあります キャプチャの一部にピリオドが入っています Regex の間違いをチェックしに戻りましょう タイムスタンプの問題に注目します そこで気づくのが OneOrMore(.any) が タイムスタンプの最初の数字から文末まで 全てを含めてしまう事です その下の Optionally(“.") には マッチしませんでした
ここの“OneOrMore”を “reluctant”にすれば直せます "Reluctant "は繰り返し動作の指定です OneOrMore ZeroOrMore Optionally Repeat を Swift Regex では反復と呼びます 反復はデフォルトが eager で できるだけ多くマッチさせようとします 前出の例を使いましょう Regex エンジンがあらゆる文字の OneOrMore を照合する時 最初の文字から始めて 入力の最後までの全ての文字を受け入れます そして“Optionally”のピリオドへと移ります ピリオドは残っていませんが オプションなので成功です “WholeMatch”アルゴリズムを実行しており 入力も Regex パターンも終わりに到達したので 照合は成功です 照合は成功したもののOneOrMore の一部として ピリオドが予想外にキャプチャされています
反復行動を“reluctant”に変えることで Regex エンジンの反復の 照合の仕方が少し変わります できるだけ少ない文字をマッチさせるのです 今回の Regex エンジンの入力文字列の照合では 反復が発生する前に 常に Regex の残りを 先に照合してから 注意深く前に進んでいくわけです Regex の残り部分がマッチしない場合 エンジンは反復の所まで引き返して 反復して照合を行います 最後の文字 ピリオドまで早送りしましょう eager のビヘイビアと違い Regex エンジンはピリオドを OneOrMore の一部としてマッチさせず Optionally (".") での照合を試みました これでマッチし Regex エンジンは パターンの最後に到達します 照合は成功し ピリオドが最後に付かない 正しいキャプチャができました
デフォルトのビヘイビアは eager なので 反復を使って Regex を作る際は 意図する照合の動きを考えましょう 追加の引数で反復の動作を指定するか repetitionBehavior 修飾子を使用して 動作を指定していないすべての反復を 上書きすることもできます タイムスタンプに対する反復を reluctant に変更したので ピリオドを含まない 正しいタイムスタンプを 取り出せるようになりました
Regex に戻りましょう キャプチャを使って入力から テストステータスを抽出するのに サブストリングの型を使います ですが望ましくはサブストリングをもっと プログラミングに適したものに変えたいですね 例えばカスタムデータストラクチャです それにはトランスフォーム キャプチャが使えます クロージャで変換します Regex エンジンは照合の際 マッチしたサブストリングの トランスフォームクロージャを呼び出して その結果 目的の型を生成します 適合するRegexの出力の型が クロージャの戻り型になります ここでは String から Int のイニシャライザで キャプチャを変換することで 出力タプルの型にオプショナルの Intを取得しています オプショナルでない出力を得るためには TryCapture が有効です これはオプショナルを返す変換を容認する キャプチャのバリアントで 出力型のオプショナルを削除します 照合時に nil を返すことで Regex エンジンは後退して 別のパスを試みます TryCaptureは 失敗する可能性のある イニシャライザを持つキャプチャを 変換する場合にも有効です キャプチャされたテストの状態を保存するには 列挙型 enum が適しています やってみましょう TestStatus の enum を 3つのケースで定義しました started passed failed です raw string valueがこの enum を 文字列から初期化可能にします
Regex は transform 付きの TryCapture に変更 トランスフォームクロージャで TestStatus イニシャライザを呼び出し マッチしたサブストリングを TestStatus 値に変換します これで一致する出力型は TestStatus になりました このようなカスタムデータ構造を使うことで Regex の照合出力がタイプセーフになります Regex に戻りましょう さらに改善できると思う点を1つ挙げましょう タイムスタンプの照合に使う ワイルドカードのパターンは サブストリングを生成します つまり Appがタイムスタンプを理解するには サブストリングを別のデータ構造に パースしなければなりません 前出のようにFoundation は現在 Swift Regex をサポートし 強力なパーサ Regexes を提供しています なので日時をサブストリングとして パースする代わりに Foundation の ISO 8601日付パーサに切り替え タイムスタンプを日付として パースできるわけです 推測された型が示すように Regex は Date を出力します
入力で wholeMatch を実行すると 日付文字列は Foundation の Date 値に パースされました Foundation の日付パーサのような実績のある パーサにアクセスできるのは 日々の文字列処理において驚くほど便利です 次にお話するのは応用的機能で 他で定義された既存のパーサを Swift Regex で再利用する方法です テストケースの継続時間を パースする例を見ましょう 継続時間とは 0.001 のような浮動小数点数です 本来ならローカライゼーションの フルサポートが受けられる Foundation の浮動小数点数パーサを使います 今回は説明のため 既存のパーサをRegex エンジンにつないで 継続時間の浮動小数点数を パースしてみます “strtod”は標準Cライブラリの関数です 文字列ポインタを受け取り 文字列をパースし マッチの終端アドレスを endのポインタに設定します 継続時間をC言語でパースしてみましょう そのためには自分でパーサ型を決定し CustomConsumingRegexComponent プロトコルに準拠します
私は CDoubleParser という ストラクチャを定義しました その RegexOutput はDouble で 倍精度数としてパースします "consuming"メソッドで 標準Cライブラリから Doubleのパーサを呼び出し 文字列のポインタを渡し 数字が返ります メソッド本体ではwithCString メソッドを使い startAddress を取得します そして strtod C言語関数を呼び startAddress と 結果を受け取る endAddress のポインタを渡します そしてエラーチェックです パースが成功すると endAddress は startAddress より大きくなります でなければパースは失敗で nil を返します C API によるポインタから マッチした終端を計算します 最後に マッチの終端と数字の出力を返します Regex に戻りそこで直接 CDoubleParser を使えます 出力型は Double だと推測されています wholeMatch を呼び出し パースされた数をプリントすると 出力は予想通り 0.001です まとめると 本日はSwift Regex の一般的および 上級の使用法をお話ししました Swift 5.7 の新機能で Appでの文字列処理能力が統合できます Swift Regex を使う際には簡潔さと可読性の バランスが大切で RegexBuilder DSL と Regexリテラル併用時は特にそうです 日付や URL などの一般的パターンの場合 パースはカスタムコードでは エラーが起こりやすいので Foundation 提供の強力なパーサがお薦めです
Swift Regex の詳細は Swift Evolution の宣言型文字列処理の プロポーザルご覧ください Swift での文字列処理をお楽しみください ご清聴ありがとうございました
-
-
0:39 - Regex matching "Hi, WWDC22!"
Regex { "Hi, WWDC" Repeat(.digit, count: 2) "!" }
-
1:06 - Simple Regex from a string
let input = "name: John Appleseed, user_id: 100" let regex = try Regex(#"user_id:\s*(\d+)"#) if let match = input.firstMatch(of: regex) { print("Matched: \(match[0])") print("User ID: \(match[1])") }
-
1:56 - Simple Regex from a literal
let input = "name: John Appleseed, user_id: 100" let regex = /user_id:\s*(\d+)/ if let match = input.firstMatch(of: regex) { print("Matched: \(match.0)") print("User ID: \(match.1)") }
-
2:08 - Simple regex builder
import RegexBuilder let input = "name: John Appleseed, user_id: 100" let regex = Regex { "user_id:" OneOrMore(.whitespace) Capture(.localizedInteger) } if let match = input.firstMatch(of: regex) { print("Matched: \(match.0)") print("User ID: \(match.1)") }
-
2:38 - A trivial Regex interpreted by the Regex engine
let regex = Regex { OneOrMore("a") OneOrMore(.digit) } let match = "aaa12".wholeMatch(of: regex)
-
3:49 - Regex-powered algorithms
let input = "name: John Appleseed, user_id: 100" let regex = /user_id:\s*(\d+)/ input.firstMatch(of: regex) // Regex.Match<(Substring, Substring)> input.wholeMatch(of: regex) // nil input.prefixMatch(of: regex) // nil input.starts(with: regex) // false input.replacing(regex, with: "456") // "name: John Appleseed, 456" input.trimmingPrefix(regex) // "name: John Appleseed, user_id: 100" input.split(separator: /\s*,\s*/) // ["name: John Appleseed", "user_id: 100"] switch "abc" { case /\w+/: print("It's a word!") }
-
5:14 - Use Foundation parsers in regex builder
let statement = """ DSLIP 04/06/20 Paypal $3,020.85 CREDIT 04/03/20 Payroll $69.73 DEBIT 04/02/20 Rent ($38.25) DEBIT 03/31/20 Grocery ($27.44) DEBIT 03/24/20 IRS ($52,249.98) """ let regex = Regex { Capture(.date(format: "\(month: .twoDigits)/\(day: .twoDigits)/\(year: .twoDigits)")) OneOrMore(.whitespace) OneOrMore(.word) OneOrMore(.whitespace) Capture(.currency(code: "USD").sign(strategy: .accounting)) }
-
6:24 - XCTest log regex (version 1)
import RegexBuilder let regex = Regex { "Test Suite '" /[a-zA-Z][a-zA-Z0-9]*/ "' " ChoiceOf { "started" "passed" "failed" } " at " OneOrMore(.any) Optionally(".") }
-
6:25 - Test our Regex against some inputs
let testSuiteTestInputs = [ "Test Suite 'RegexDSLTests' started at 2022-06-06 09:41:00.001", "Test Suite 'RegexDSLTests' failed at 2022-06-06 09:41:00.001.", "Test Suite 'RegexDSLTests' passed at 2022-06-06 09:41:00.001." ] for line in testSuiteTestInputs { if let match = line.wholeMatch(of: regex) { print("Matched: \(match.output)") } }
-
10:28 - Example of capture
let regex = Regex { "a" Capture("b") "c" /d(e)f/ } if let match = "abcdef".wholeMatch(of: regex) { let (wholeMatch, b, e) = match.output }
-
11:10 - XCTest log regex (version 2, with captures)
import RegexBuilder let regex = Regex { "Test Suite '" Capture(/[a-zA-Z][a-zA-Z0-9]*/) "' " Capture { ChoiceOf { "started" "passed" "failed" } } " at " Capture(OneOrMore(.any)) Optionally(".") }
-
11:21 - Test our Regex (version 2) against some inputs
let testSuiteTestInputs = [ "Test Suite 'RegexDSLTests' started at 2022-06-06 09:41:00.001", "Test Suite 'RegexDSLTests' failed at 2022-06-06 09:41:00.001.", "Test Suite 'RegexDSLTests' passed at 2022-06-06 09:41:00.001." ] for line in testSuiteTestInputs { if let (whole, name, status, dateTime) = line.wholeMatch(of: regex)?.output { print("Matched: \"\(name)\", \"\(status)\", \"\(dateTime)\"") } }
-
11:51 - XCTest log regex (version 3, with reluctant repetition)
import RegexBuilder let regex = Regex { "Test Suite '" Capture(/[a-zA-Z][a-zA-Z0-9]*/) "' " Capture { ChoiceOf { "started" "passed" "failed" } } " at " Capture(OneOrMore(.any, .reluctant)) Optionally(".") }
-
15:20 - Example of transforming capture
Regex { Capture { OneOrMore(.digit) } transform: { Int($0) // Int.init?(_: some StringProtocol) } } // Regex<(Substring, Int?)>
-
15:55 - Example of transforming capture and removing optionality
Regex { TryCapture { OneOrMore(.digit) } transform: { Int($0) // Int.init?(_: some StringProtocol) } } // Regex<(Substring, Int)>
-
16:21 - XCTest log regex (version 4, with transforming capture)
enum TestStatus: String { case started = "started" case passed = "passed" case failed = "failed" } let regex = Regex { "Test Suite '" Capture(/[a-zA-Z][a-zA-Z0-9]*/) "' " TryCapture { ChoiceOf { "started" "passed" "failed" } } transform: { TestStatus(rawValue: String($0)) } " at " Capture(OneOrMore(.any, .reluctant)) Optionally(".") } // Regex<(Substring, Substring, TestStatus, Substring)>
-
17:23 - XCTest log regex (version 5, with Foundation ISO 8601 date parser)
let regex = Regex { "Test Suite '" Capture(/[a-zA-Z][a-zA-Z0-9]*/) "' " TryCapture { ChoiceOf { "started" "passed" "failed" } } transform: { TestStatus(rawValue: String($0)) } " at " Capture(.iso8601( timeZone: .current, includingFractionalSeconds: true, dateTimeSeparator: .space)) Optionally(".") } // Regex<(Substring, Substring, TestStatus, Date)>
-
18:19 - XCTest log duration parser
let input = "Test Case '-[RegexDSLTests testCharacterClass]' passed (0.001 seconds)." let regex = Regex { "Test Case " OneOrMore(.any, .reluctant) "(" Capture { .localizedDouble } " seconds)." } if let match = input.wholeMatch(of: regex) { print("Time: \(match.output)") }
-
19:16 - CDoubleParser definition
import Darwin struct CDoubleParser: CustomConsumingRegexComponent { typealias RegexOutput = Double func consuming( _ input: String, startingAt index: String.Index, in bounds: Range<String.Index> ) throws -> (upperBound: String.Index, output: Double)? { input[index...].withCString { startAddress in var endAddress: UnsafeMutablePointer<CChar>! let output = strtod(startAddress, &endAddress) guard endAddress > startAddress else { return nil } let parsedLength = startAddress.distance(to: endAddress) let upperBound = input.utf8.index(index, offsetBy: parsedLength) return (upperBound, output) } } }
-
20:13 - Use CDoubleParser in regex builder
let input = "Test Case '-[RegexDSLTests testCharacterClass]' passed (0.001 seconds)." let regex = Regex { "Test Case " OneOrMore(.any, .reluctant) "(" Capture { CDoubleParser() } " seconds)." } // Regex<(Substring, Double)> if let match = input.wholeMatch(of: regex) { print("Time: \(match.1)") }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。