見出し画像

Retro-gaming and so on

Racketと正規表現

正規表現はややこしい。
ぶっちゃけた話、プログラミング言語にあっても無くても良い機能、って言えば良い機能である。
事実、古典的なC言語やPascalには正規表現を扱う機能は搭載されてないし、それで充分と言えば充分なのだ。
実際、正規表現はプログラミング言語が持つべき機能、と言うよりはOSが持つべき機能である。
と言うのも、平たく言うと、正規表現と言うのは文書検索に絶大な能力を発揮する。ハードディスクに眠ってる文書類に対して、特定の文字列を含んだ文章をリストアップせよ、なんつー命題を考えれば「なるほど正規表現はあった方が良い」って話になるだろう。
ところが、黎明期のパソコンを初めとして、90年代に至るまで、正規表現はOSの機能としてもそこまで馴染みがなかったのだ。
理由としては、パソコンの与える環境が今と比べると非常に貧弱だった事が挙げられる。CPUは遅いしメモリも少ないし、ハードディスクがあったとしても容量は今のそれに比べると遥かに小さかった。一々特定の文字列を含んだ文書を正規表現で探すより、文書ファイルを実際開いて目で確認した方が早かったのである。どうせ大した量は保存出来ないんだし。
一方、1970年代中期に中古ミニコンで利用するためのOS、UNIXが登場する。UNIXはマルチユーザー・マルチタスクを基本とするOSで、アメリカの大学のメーカー保証期間が終わった中古ミニコンを中心に普及する。ミニコンは廃棄処分するにはバカデカいし、新しいミニコンを買うには金が無いし・・・と言う大学側のニーズに適合して広まったわけだ。しかもタダだった、ってのが大きかった(※1)。
当初のUNIXはワープロなんか持ってなかったので、マトモなオフコン(※2)を駆動させるシリアスなOSだとは受け取ってもらえなかった。
一方、ワープロが無かったUNIXではあるが、テキストファイルや文字列に関する機能は正規表現を中心として豊富だったのだ。UNIXを組み立てているC言語には文字列、なんぞ無かったが、UNIX標準搭載のAWKsedと言ったスクリプト言語は文書対象に正規表現を駆使したスクリプト(※3)を書くように設計されている(※4)。
そして、1987年にUNIX上で、AWKやsed、そして他のプログラミング言語の特徴を混ぜたようなプログラミング言語、Perlが登場する(※5)。
そしてその後、90年代に入ってインターネットが市井に登場する。そしてそれに伴ってPerlが、BASIC以来の勢いで一般人を巻き込んで普及するのである(※6)。
何故か。レンタル掲示板が無かった当時、誰も彼もが自分が作ったWebサイトに掲示板を付けたがった。「掲示板記述用言語」としてPerlは旋風を巻き起こしたのである。
そして、Webは文字列の嵐である。あっちを見てもこっちを見ても文字列だらけ、で、これはそれまでのパソコンの、例えばゲームなんかのソフトウェアとは大違いだったのだ。そんな中、「掲示板記述用言語」Perlは、内包した強力な「文字列処理機能」で八面六臂の大活躍を見せるのである。その人気の爆発度合いは現時点でのPythonの比ではない(※7)。
そして一般人はここで「正規表現」に邂逅するのである。
結果、「新しくプログラミング言語を作るにはライブラリとして正規表現がないと」と言うような段階に突入したのである。
全てはPerlが招いた事態、と言って過言ではない。

さて、正規表現は、単純に言うと文字列のパターンを記述して、適合する文字列を返す機能である。こういうのを一般にパターンマッチングと呼ぶ。
これだけ聞くとさして難しくないように思える。
ところが、だ。
正規表現がややこしいのは、こいつは繰り返しや条件分岐を内包してる辺りなのである。
そう、こう言えば分かるだろう。正規表現「自体」がプログラミング言語の一種となっている。言わば文字列相手のDSL(Domain Specific Language: ドメイン特化言語)になってるのが正規表現なのだ。
つまり、プログラミング言語内プログラミング言語として「浮いている」。外部の文法法則と全く違う「治外法権」。
それが正規表現の学習を難しくしている原因なのだ。

ここではRacketでの正規表現の概要を説明する。

1. 並び

まずは一番簡単な「並び」から。
例えば

red

と言う正規表現を考えるが、これは単に文字r、文字e、文字dが並んでる事を意味する。見たまんま、だ。
つまり、redと言う並びはredletteredcredibleと言うそれを含む単語と全部一致する。
実際確かめてみよう。

> (regexp-match #rx"red" "red")
'("red")
> (regexp-match #rx"red" "lettered")
'("red")
> (regexp-match #rx"red" "credible")
'("red")
>

Racketでは「#fじゃなければ全部真」なので、データとして一致部分を含むリストを返却してても別に不思議ではないだろう。
こういう「文字列の一部分が指定した文字列パターンを含んでるか」調べる関数がregexp-matchである。
またこの場合、パターンを指定するのには#rx"パターン"を用いる。
まずはこの一番簡単な正規表現によるパターンマッチを色々と試してみて欲しい。

次にメタキャラ、とかメタ文字、と言われるものを紹介する。
これはプログラミング言語で言うと関数みたいなモノである。メタ文字の使用によって、検索すべき「パターン」をもうちょい絞り込む事が可能となる。


例えば上の表にあげたような4つの例文

  1. red ribbons are good
  2. I love red
  3. it's redirected by post
  4. you covered it already
があったとして、それぞれのメタ文字の指定により「パターンがマッチする」ケースと「パターンがマッチしない」ケースがある、と言う事だ。

;;; 行の先頭チェック
> (regexp-match #rx"^red" "red ribbons are good") ;; 行の先頭なので一致
'("red")
> (regexp-match #rx"^red" "I love red") ;; 行の先頭じゃないので以下は一致せず
#f
> (regexp-match #rx"^red" "it's redirected by post")
#f
> (regexp-match #rx"^red" "you covered it already")
#f

;;; 行の末尾チェック
> (regexp-match #rx"red$" "red ribbons are good") ;; 行の末尾じゃないので一致せず
#f
> (regexp-match #rx"red$" "I love red") ;; 行の末尾なので一致
'("red")
> (regexp-match #rx"red$" "it's redirected by post") ;; 行の末尾じゃないので以下は一致せず
#f
> (regexp-match #rx"red$" "you covered it already")
#f
>

;;; 単語の先頭チェック

> (regexp-match #px"\\bred" "red ribbons are good") ;; 単語の先頭なので一致
'("red")
> (regexp-match #px"\\bred" "I love red") ;; 単語の先頭なので一致
'("red")
> (regexp-match #px"\\bred" "it's redirected by post") ;; 単語の先頭なので一致
'("red")
> (regexp-match #px"\\bred" "you covered it already") ;; 単語の先頭じゃないので一致しない
#f

;; 単語の末尾チェック

> (regexp-match #px"red\\b" "red ribbons are good") ;; 単語の末尾なんで一致
'("red")
> (regexp-match #px"red\\b" "I love red") ;; 単語の末尾なんで一致
'("red")
> (regexp-match #px"red\\b" "it's redirected by post") ;; 単語の末尾じゃないので一致しない
#f
> (regexp-match #px"red\\b" "you covered it already") ;; 単語の末尾なので一致
'("red")

Racketの#rx#pxの違いは、基本は二つとも同じだが、#pxの方が機能が多く、つまりより多くの正規表現のパターン記述に使える、と言う事だ。
だから慣れないうちは、とにかく#pxを使う、と言うのもテではある。
次に覚えるメタキャラクタにワイルドカード、と言うものがある。
ワイルドカードはトランプのジョーカーのように「あらゆる文字の代替」、従ってどんな文字とも一致する事を意味する。
Racketの正規表現ではピリオド(.)がワイルドカードとなる。

;; 3文字目は何でも良い、と言うパターン
> (regexp-match #rx"be.t" "best")
'("best")
;; 一方、3文字目は何でも良くても4文字目はちげーよ、と言うパターンは当然ながら失敗する
> (regexp-match #rx"be.t" "bess")
#f
>

次は範囲、とかセットと呼ばれる記述法である。いくつか候補の文字があり、その中の一つと一致してれば良し、と言うパターンだ。
これは、任意の数文字を[]で囲む事で表す。

> (regexp-match #rx"s[pwl]am" "spam")
'("spam")
>

要するに、これは言外では、単語はspamでもswamでもslamでも良い、っつってるのである。
しかし二文字目以外は全部同じだ。従って二文字目の候補を[]で囲い、候補の文字をツッコんでおけば、勝手に正規表現が判断してくれる。
逆に、「この文字だけはいただけない」場合、同じような表現方法に否定を意味する^を用いる(※8)。

;; 単語の1文字目はf以外、と指定したいケース
> (regexp-match #rx"[^f]ool" "cool")
'("cool")
> (regexp-match #rx"[^f]ool" "pool")
'("pool")

> (regexp-match #rx"[^f]ool" "fool")
#f

これがRacketによる基本的な正規表現の使い方、となる。
この基礎を把握したら次へと進もう。

2. 繰り返し

先にも書いたが、正規表現には「繰り返し」の機能がある。
ただし、通常のプログラミング言語のようにfor〜があるわけではない。
例によってメタキャラクタで繰り返しを表現する。
主な繰り返し用メタキャラクタは、Racketでは次のようになる。



例えばこのようにして使う。

;; lが無い(0個の)racketyに一致する
> (regexp-match #rx"racketl?y" "rackety")
'("rackety")
;; lが1個のracketlyに一致する
> (regexp-match #rx"racketl?y" "racketly")
'("racketly")
;; * では直前のlが(0個を含み)何個あっても構わない
> (regexp-match #rx"racketl*y" "rackety")
'("rackety")
> (regexp-match #rx"racketl*y" "racketly")
'("racketly")
> (regexp-match #rx"racketl*y" "racketlly")
'("racketlly")
> (regexp-match #rx"racketl*y" "racketllly")
'("racketllly")
;; *と+は似てるが+はlが1個以上じゃないとマッチしない。
> (regexp-match #rx"racketl+y" "rackety")
#f
> (regexp-match #rx"racketl+y" "racketly")
'("racketly")
> (regexp-match #rx"racketl+y" "racketlly")
'("racketlly")
> (regexp-match #rx"racketl+y" "racketllly")
'("racketllly")
;; {n,m}では#rxの代わりに#pxを使う
;; またコンマとmの間にスペースを入れてはいけない
> (regexp-match #px"fo{1,2}" "fo")
'("fo")
> (regexp-match #px"fo{1,2}" "foo")
'("foo")
;; oが連続するため、繰り返し上限数が機能しないパターン
> (regexp-match #px"fo{1,2}" "fooo")
'("foo")
;; 文字の連続を制限するにはこのように書く
> (regexp-match #px"fo{1,2}[^o]" "fooo")
#f
;; あるパターンをグループ化して「直前」を認識させるには()を使う
;; 「ワイルドカードan」を1回から2回繰り返してsを付け足したモノはcansに一致する。
> (regexp-match #px"(.an){1,2}s" "cans")
'("cans" "can")
;; 「ワイルドカードan」を1回から2回繰り返してsを付け足したモノはcancansに一致する
> (regexp-match #px"(.an){1,2}s" "cancans")
'("cancans" "can")
;; 「ワイルドカードan」を1回から2回繰り返してsを付け足したモノはcanpansに一致する
> (regexp-match #px"(.an){1,2}s" "canpans")
'("canpans" "pan")
;; 「ワイルドカードan」を1回から2回繰り返してsを付け足したモノはbananasとは一致しない
> (regexp-match #px"(.an){1,2}s" "bananas")
#f
;; aから最後のbまで、の長い部分を一致させるパターン
> (regexp-match #rx"a.*b" "abracadabra")
'("abracadab")
;; aから次にbが出てくるまで、を一致させるパターン
> (regexp-match #rx"a.*?b" "abracadabra")
'("ab")

これが基本的な「繰り返し」の使い方だ。
何度か練習してみよう。

3. 条件分岐

さて、ここまで来ると後は簡単だ。
と言うか、やっぱり繰り返しがメンドくせぇんだよ。
そして人間は繰り返しが嫌いだ、ってのは何度も言っている。
それに比べれば条件分岐はヘナチョコである。
まずは次のパターンを考えてみよう。

> (regexp-match #rx"computer?d?" "computer")
'("computer")
> (regexp-match #rx"computer?d?" "computed")
'("computed")

これはcomputercomputedに一致させたい、と言うので書いたモノだ。この二つに一致するパターンを書きたい、と。
ところがこいつは次の間違った英単語にも一致してしまう。

> (regexp-match #rx"computer?d?" "computerd")
'("computerd")

こういう場合は、既出の、任意要素を含めた「範囲」を使用して、凌ぐ。
実は「範囲」は条件分岐その1、である。

;; 文末がrあるいはdであるように$で指定する
> (regexp-match #rx"compute[rd]$" "computer")
'("computer")
> (regexp-match #rx"compute[rd]$" "computed")
'("computed")
> (regexp-match #rx"compute[rd]$" "computerd")
#f
>

もう一つの条件分岐は選択表現、である。要するに「または」を利用する。
Racketの正規表現では次のように書く。

;; preに続く部分がmatureかvenriveかいずれかの場合を表現
> (regexp-match #rx"pre(mature|ventive)" "premature")
'("premature" "mature")
> (regexp-match #rx"pre(mature|ventive)" "preventive")
'("preventive" "ventive")
> (regexp-match #rx"pre(mature|ventive)" "prelude")
#f

ご覧のように、Racketの正規表現では「または」は|で表現する。

4. Racketの正規表現の代表的関数

ここまではregexp-match一本槍だったが、Racketで良く使うだろう正規表現用関数を紹介する。

  • regexp-match: 文字列中で正規表現と一致したなるべく最初の要素を返そうとする。
  • regexp-match*: 文字列中に正規表現と一致した要素全部をリストにして返す。
  • regexp-split: 正規表現で指定した文字を区切り文字として文字列を分割する。
  • regexp-replace: 文字列の正規表現で見つかった部分を別の文字で置き換えた文字列を返す。
  • regexp: 単なる文字列を正規表現#rx"文字列"に変換する。
  • pregexp: 単なる文字列を正規表現#px"文字列"に変換する。
なお、#rx#pxの代わりに#rx##px#を使うと速度的には有利になる。

ここにRacketの正規表現を使ったプログラム例をあげておく。
このサンプルプログラムは、HTMLファイルからimgタグを探し出し、alt属性が含まれてない場合、コメントとしてalt属性を付けろ、と促すスクリプトである。

なお、この記事は全般的にこのサイトの記述を参考にした。
Python用の記事だが、正規表現の導入部分は良く書かれていると思う。

※1: オリジナルのUNIXの開発元は日本でのNTTにあたる電話会社、AT&Tであるが、巨大企業である。
巨大企業であるAT&Tが新たにコンピュータのOSの販売に踏み切る、と言うのはアメリカの独占禁止法に抵触するのが明らかだったので、AT&TはOSの販売を行う事は出来なかったのだ。
それ故、著作権はAT&Tに属しながらもAT&Tは全米の大学から要望されればソースコードとしてUNIXを配布せざるを得なかったのである。
言い換えると、偶発的に起きたこの状況により、「オープンソースの雛形」としてUNIXは広まっていくわけである。
繰り返すが、AT&Tは実はそれを望んでいたわけではなく、「そうせざるを得なかった」のだ。

※2: オフィスコンピュータ。平たく言うとミニコンの一種だが、会社で業務用に使われる、と言う名目で販売されていた。
このテのオフコンで著名だったOSがCP/Mと言うOSで、これのクローンがいわゆるMS-DOSである。
つまり、黎明期のPCでMS-DOSが広範囲に広まった最大の理由は、オフコンを使ってる層がパソコンのアーリーアダプターとなり、彼らが慣れてるOSがCP/MだったのでMS-DOSを使うのに迷いがなかった、と言う事である。
当然、オフコンで有名だったワードプロセッサ、WordstarがMS-DOSでも問題なく動いてた、と言う事が大きい。
言い換えると、黎明期の8bitパソコンを除き、MS-DOSが大勝した理由は

  1. PCのアーリーアダプター(オフコンのユーザー)が慣れてる環境を提供していた事
  2. オフコンで使ってたソフトウェア(Wordstar等)をそのまま使えた
と言う2つによる。
比すると、同時期に現れた16bit PCは、GUIを持っててもソフト資産が最初からMS-DOSに敵わなかったわけだ(全ソフトウェアを1から作らなければならなかった)。

※3: 本来スクリプトやスクリプト言語とは、OS備え付けの「自動化」ユーティリティ、そしてその「自動化ユーティリティ」を記述する為のプログラミング言語の事を指していた。
よって狭義ではVBSはWindowsの「自動化」ユーティリティ(スクリプト)を書く為の言語であり、Apple Scriptは旧Macintosh用の備え付けのスクリプト言語である。
他にもCommodore Amigaには、AREXXと言うIBMが自社のミニコン用OSに搭載したスクリプト言語、REXXの亜種が搭載されていた。
なお、Pythonは最初にMacで人気が出た為、Macで開発された、と言う嘘が一部ではまかり通ってるが、事実はそうではなく、Mac OS 9用のスクリプト言語としてPythonが採用された、と言った方が正しい。

※4: 意外だが、C言語はUNIXで生まれてるが、UNIXユーザーに対しては「C言語でプログラムを書くな」と示唆している。一方、UNIXユーザーに対しては「AWKやsed、そしてコマンドを駆使したプログラムを書くように」と助言している。
実はC言語はUNIX上でも飛び道具な言語なのである。
言い換えると、C言語は、当時の(UNIXが走る事が出来る)ミニコンに遥かに劣る環境じゃないと受け入れられなかった言語だった、と言う側面があるのだ。

※5: UNIXでは「一つの機能は一つの事柄しか出来ない」事を善、とする思想がある。つまり、何か大きな事柄を成すには、既存の「小さな事柄を組み合わせて」行う、と言う思想がある為、元々「大きなプログラムをいきなり作る」文化ではないのだ。
一方、Perlは「色々混ぜ合わせてる」為、根本的にUNIXのアプローチとは違う。こういうアプローチを「キッチンシンクアプローチ」と呼び、どっちかと言うとUNIX文化と対極にあるMITの文化である。
余談だが、良くGNU Emacsは「UNIXのテキストエディタとして有名」と紹介をされるが、ここまで読めば分かると思うが、「何でも出来る」テキストエディタはUNIX文化圏のモノではない、キッチンシンクアプローチのモノである。
そして、Emacsは確かにUNIXの出自ではないのだ。

※6: いつぞやにも書いたが、有史以来(って程の歴史は無いが・笑)、自発的にプログラムを書きたい、と言う人々を増やしたのはBASICとPerlしかない。
C言語もJavaも「学校の授業で教えるから」「就職に有利だから」と言う枕詞無しでは誰も学ぼうとしない、ってのが厳然たる事実なのだ。
一方、BASICとPerlだけは「あれをやりたい」「これをやりたい」と言う明確な目標を提示してプログラミング人口を増やした稀有な例である。

※7: 似たような言語としてPerl、Python、Rubyが一緒くたとして挙げられるが、実はこの3つの中ではPythonが一番人気が無かった。
Perlの提供する優れた環境としてCPANと言うモノが挙げられる。これはPerlユーザー有志が作ったライブラリをコマンドラインでインストール出来るモノで、これが「一々全部を自分でプログラミングしたくない」と言う層に大ウケしたのだ。
Rubyも極早い段階からRubyGemsを用意し、Perlに追随する。
一方、Pythonでpipが整備されたのは割に最近になってから、である。それまで色々と試行錯誤はあったのだが、実を結んだのはかなり後になってから、である。
と同時に、PerlもRubyもWebアプリケーションフレームワークで成功してるが、Pythonでの成功例、ってのは無かった(最近は盛り返してるみたいだが)。
加えて、日本では、Pythonの日本語処理がイマイチ信頼がおけず、結果長い間Rubyの後塵を拝していたのである。

※8: ここが正規表現のクソなトコで、^は行の先頭を意味すると同時に否定を表すのだ。結果^は文脈で役割を変える。引っかからないように。
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

最近の「プログラミング」カテゴリーもっと見る

最近の記事
バックナンバー
人気記事