おもこん

おもこんは「思いつくままにコンピュターの話し」の省略形です

徒然Ruby(7)文字列と正規表現

文字列は最も使うオブジェクトのひとつです。 特にウェブ・アプリケーションでは、コンテンツだけでなくHTMLのタグやCSSを含めすべてが文字列です。 Rubyは文字列オブジェクトのメソッドが充実しており、またパターンマッチのための正規表現も充実しています。

文字列リテラル

文字列リテラルには

  • ダブルクォートで囲む(今まで使ってきた方法)
  • シングルクォート(')で囲む
  • %記法
  • ヒア・ドキュメント

があります。 ここでは最も使うダブルクォート、シングルクォートとヒアドキュメントを説明します。 その他の記法はRubyのドキュメントを参照してください。

ダブルクォートで囲む文字列リテラル

この記法はすでに出てきていますが、まだ説明が不十分でした。 ここでは、(1)この文字列リテラルの補足説明(2)バックスラッシュ記法(3)式展開を説明します。

ダブルクォートの文字列では

  • 空白を挟んで複数の文字列があれば、それらは結合されてひとつの文字列とみなされる
  • 文字列の途中に改行があれば、それは文字列中の改行コードになる(改行の代わりに\nがあるのと同じ)
  • 改行が文字列の途中でなければ(つまり複数の文字列の間であれば)改行前と後の2つの文字列に分かれる
a = "abc" "def" "ghi"
b = "abc
def"
c = "pqr" "stu"
"vwx"
print a, "\n"
print b, "\n"
print c, "\n"

これを実行すると

abcdefghi
abc
def
pqrstu

となります。

  • 変数aに代入された文字列は"abcdefghi"と同じ
  • 変数bに代入された文字列は"abc\ndef"と同じ
  • 変数cに代入された文字列は"pqrstu"と同じ
  • "vwx"は変数に代入されていないから、文字列として評価されるが、近いうちに消滅する(ガベージ・コレクション)

なお、ガベージ・コレクションというのは、どこからも参照されていない不用なオブジェクトを消滅させて、割り当てられていたメモリを解放することをいいます。 これをしないとどんどん不用なオブジェクトにメモリが割り当てられて、最悪の場合はメモリ不足になってしまいます。 ガベージ・コレクションはRubyが自動的に行ってくれます。

英数字やコンマ、ピリオドなどの表示できる文字以外に、改行やタブなどのコードを表すにはバックスラッシュ記法を用います。 最も使うのは改行\nです。 それ以外には次のようなものがあります(全部ではありません)

  • \t =>タブ
  • \文字 =>文字はtのような特殊な意味をもつもの(\tはタブになる)以外の文字。 その文字自身になる。 たとえば、\##自身を表す。 #は後ででてくる式展開でも使うので、#自身を表したい時にバックスラッシュをつける
  • \改行 =>改行を取り消す。複数行に文字列が渡るときに改行を抑止するために使う

なお、文字列でなくても改行直前にバックスラッシュがあるとその改行は抑止されます。 ですので、次の2つは同じ文字列を表します。

a = "abcd\
efg"
b = "abcd" \
"efg"
print a, "\n"
print b, "\n"

式展開は文字列中に#{式}の形で書き、式を文字列で表したものに置き換えます。 例えば、式の部分が整数の100であれば、それを文字列の"100"として文字列中に埋め込みます。

number = 100
print "数字は#{number}\n"

実行すると

数字は100

と表示されます。 式はもっと複雑なものでも構いません。 式展開は非常に便利で、文字列で最も良く使われる機能です。

シングルクォートで囲む文字列リテラル

シングルクォートで囲む文字列は、ダブルクォートのようなバックスラッシュ記法や式展開をしません。 シングルクォート自身を含めたい時にエスケープが必要なので、\'とバックスラッシュ記法を使います。 これにともない、バックスラッシュ自身もエスケープが必要な場合が出てくるので\\と表します。 行末のバックスラッシュはバックスラッシュ自身として解釈されます。

a = 'abc\ndef'
b = 'abc\\ndef'
c = '\abc
def'
d = 'abc\
def'
print a, "\n"
print b, "\n"
print c, "\n"
print d, "\n"

実行すると

abc\ndef
abc\ndef
\abc
def
abc\
def

となります。 シングル・クォートでは文字をその文字自身として表したい時に使いますが、バックスラッシュの扱いだけは注意が必要です。

ヒア・ドキュメント

ヒア・ドキュメントは<<EOSの次の行から行頭のEOSまでのすべての行からなる文字列です。 複数行に渡る長い文字列を表すためのものです。 EOSの代わりに任意の文字列を使っても構いません。

a = <<EOS
 春はあけぼの。やうやう白くなりゆく山ぎは、すこしあかりて、紫だちたる雲のほそくたなびきたる。
 夏は夜。月のころはさらなり。やみもなほ、蛍の多く飛びちがひたる。また、ただ一つ二つなど、ほのかにうち光りて行くもをかし。雨など降るもをかし。
 秋は夕暮れ。夕日のさして山の端いと近うなりたるに、烏の寝どころへ行くとて、三つ四つ、二つ三つなど、飛びいそぐさへあはれなり。まいて雁などのつらねたるが、いと小さく見ゆるはいとをかし。日入りはてて、風の音、虫の音など、はたいふべきにあらず。
 冬はつとめて。雪の降りたるはいふべきにもあらず、霜のいと白きも、またさらでもいと寒きに、火など急ぎおこして、炭もて渡るもいとつきづきし。昼になりて、ぬるくゆるびもていけば、火桶の火も白き灰がちになりてわろし。
EOS

print a

実行すると、枕草子の第一段が表示されます。 つまり、変数aの指す文字列は4行の長い文字列であるわけです。

EOSにダブルクォートをつけ<<"EOS"とするとダブルクォート文字列と同じように、バックスラッシュ記法と式展開を使えます。 同様にEOSにシングルクォートをつけ<<'EOS'とすると挟まれた文字列そのものになります。 このとき文中のシングルクォートにエスケープは必要ありません。 なお、クォートのない<<EOSはダブル・クォートと同じ扱いです。

インデントが可能ですがそれについてはドキュメントを参照してください。

パターンマッチ

文字列の検索には正規表現が用いられます。 正規表現の実装は一通りではありません。 現在のRuby(バージョン3.1)では「鬼雲」という正規表現ライブラリが使われています。 他の言語で正規表現を使ったことがあっても、細かい点ではRubyと異なる可能性があります。 また、Ruby正規表現は多岐にわたっているので、詳細はドキュメントを参照してください。 ここでは主な書き方に絞って説明します。

正規表現リテラル

正規表現はオブジェクトのひとつです。 正規表現は文字列の検索に用いられ、検索パターンの情報とメソッドを持っています。 また、正規表現は文字列、整数、配列と同じようにリテラルで表現することができます。 正規表現の場合はスラッシュ(/)で文字列を囲んで表します。 リテラルの規則は非常に多いので、ここですべてを解説することはできません。 詳細はRubyのドキュメントを参照してください。

例えば文字列"Hello"を検索することを考えてみます。 "Hello"という検索パターンを正規表現リテラルで表すと、

/Hello/

となります。 そして、そのパターンが文字列に含まれているかどうかを調べるには次のようにします。

a = "Hello world. Hello world.\n"
if /Hello/ =~ a
  print "マッチした\n"
else
  print "マッチしなかった\n"
end

print (/Hello/ =~ a), "\n"

=~は左辺の正規表現パターンが右辺の文字列の中に存在するとき(それを「マッチする」という)マッチした位置(インデックス)を返し、マッチしなかったときnilを返します。 このプログラムの文字列にはHello worldが2つありますが、はじめのHello worldにマッチするので、返される値は0です。 プログラムの最後の一行は0を表示します。 プログラムの実行結果は次のようになります。

マッチした
0

0はif文では真になることに注意してください。 if文で偽になるのはnilとfalseだけです。

Helloだけでなくhelloも検索対象にしたいときは

/[Hh]ello/

という正規表現を使います。 [ ]は文字クラスという仕組みで、その中にある文字のどれかに一致します。 更にすべて大文字のHELLOも入れたければ

/[Hh]ello|HELLO/

縦棒|はその左のパターンまたは右のパターンのどちらかにマッチすれば良い、という意味になります。 この他に次のようなものがあります。

  • メタ文字(正規表現で特別な意味を持つ文字)は( ) [ ] { } . ? + * | \で、これらの文字自身を表すにはバックスラッシュでエスケープする
  • #{ }で式展開できる(ダブルクォート文字列と同じ)
  • \nは改行にマッチ。その他にバックスラッシュで表す特別な文字(タブなど)がある
  • .(ドット)は「任意の1文字」にマッチ
  • [ ]で文字クラスを表す。 例えば[atc]はa、t、cのいずれにもマッチする。 [a-z](小文字全体)のようにハイフンで範囲を表せる
  • *は前の文字の0個以上の繰り返しにマッチ
  • +は前の文字の1個以上の繰り返しにマッチ
  • ^は行頭にマッチ
  • $は行末にマッチ

これ以外にも豊富なメタ文字がありますので、Rubyのドキュメントを参照してください。

Ruby版のgrep

Unixの文字列検索コマンドgrepRuby版を作ってみましょう。 名前をrubygrep.rbとすることにします。 コマンドラインにパターン、ファイル名の2つの引数を取り、パターンにマッチした行を標準出力に出力します。

$ ruby rubygrep.rb '^\#{1,6} ' 2022-9-10-Array.md

この例では2022-9-10-Array.mdという名前のファイルを検索します。 パターンは

  • ^は行頭に一致
  • \#はナンバー記号(#)。 正規表現リテラルでは式展開でこの記号(#)を使うので、バックスラッシュでエスケープしておく
  • {1,6}は1以上6以下の繰り返し
  • 半角空白は(文字通り)半角空白に一致

これらを総合すると「行頭からナンバー記号が1から6個並び、空白が続く行」となります。 すなわち、Markdownの見出しを検索することになります。

プログラムを作るために、ファイルの読み込みを説明しておきましょう。 ファイルを行ごとの配列に読み込むには

File.readlines(ファイル名)

というメソッドを使います。 rubygrep.rbは次のようになります。

r = Regexp.compile(ARGV[0])
File.readlines(ARGV[1]).each do |s|
  if r =~ s
    print s
  end
end
  • Regexp.compile(文字列)は文字列を正規表現に変換する。 コマンドラインでは正規表現リテラルとして書くことができないので、文字列としてRubyに伝わる。 Rubyではそれを正規表現オブジェクトに変換する必要がある
  • File.readlines(ARGV[1])でファイルを行ごとに読み、その配列を返す。 その配列に対してeachメソッドで各要素を取り出してパラメータsに代入しブロックを実行する
  • ブロックではsが正規表現rに一致すればsをprintで標準出力に書き出す

Ruby正規表現をサポートしているので、非常に短いプログラムで済みました。 実行してみます。

$ ruby rubygrep.rb '^\#{1,6} ' 2022-9-10-Array.md
## 配列のリテラル
## 2次元配列
## 配列のメソッド
## 配列の変更と複製
## ARGV

見出しの部分だけ表示されました。 期待通りの結果です。

なお、マッチの演算子=~は実は正規表現オブジェクトのメソッド.=~()の糖衣構文です。

r =~ s # => r.=~(s)

メソッド名は=~です。

したがって、演算子の左側は正規表現オブジェクトでなくてはならないのですが、実は左右を逆にしても使えます。

"Hello world. Hello world.\n" =~ /Hello/

これを糖衣構文でメソッドに直すと.=~()は文字列のメソッドになってしまいます。 実は文字列オブジェクトでも.=~()メソッドを定義していて、それは演算子の左右を入れ替え、正規表現.=~()メソッドの値を返すことになっています。 事情は複雑ですが、要するに正規表現と文字列を左右どちらに置いてもOKということです。

マッチデータオブジェクト

検索時あるいは検索後にマッチした文字列を取り出したいことがあります。

例えば「Markdown文書の目次を自動生成する」ことを考えてみましょう。 目次の対象になるのは見出しです。 見出しはATXタイプ(ナンバー記号が行頭に来るタイプ)だけを対象にします。

見出し検索の正規表現パターンを次のようにします。

/^\#{1,6} +(.+)$/
  • 行頭から1から6個のナンバー記号
  • 1個以上の半角空白
  • 任意の文字が行末まで

括弧( )には2つの機能があります。

  • グループを表す。数式の括弧と同じ
  • マッチした文字列のその部分を取り出す。

マッチには=~またはmatchメソッドを使います。

=~でマッチを行った場合は、その直後に前から1番目の( )でマッチした文字列が$1に、以下n番目の( )にマッチした文字列が$n$に代入されます。

a = "## 配列のリテラル"
if /^\#{1,6} +(.+)$/ =~ a
  print $1, "\n"
end

このプログラムでは( )がひとつしかありません。 if文のマッチは成立し、括弧とマッチする文字列は「配列のリテラル」ですので、それが$1に代入されています。 プログラムを実行すると、

配列のリテラル

と表示されます。 注意が必要なのは、$1は次のパターンマッチが行われると内容が変わってしまうことです。 ですから、マッチ文字列をとっておきたいときは、s = $1など、別の変数に$1の内容を代入しておくことです。 これをうっかりしたバグが結構あります。

matchメソッドを使うと、マッチが実行され、その結果がMatchDataオブジェクトとして返ってきます。 MatchDataオブジェクトはマッチの情報を保持するオブジェクトです。 $1に相当するデータは.[1]メソッドで取得できます。

m = /^\#{1,6} +(.+)$/.match("## 配列のリテラル")
print m[1], "\n"

mには正規表現で文字列に対してマッチを行った結果のMatchDataオブジェクトが代入されます。 m[1]で1番めの括弧に対応するマッチ文字列が得られますので、結果は

配列のリテラル

が表示されます。 文字列検索でマッチした文字列を得るにはmatchメソッドが便利で、私もよく使います。 このときの注意点としては

  • 文字列と正規表現の左右を入れ替えても同じMatchDataオブジェクトが得られる。 このへんの事情は=~メソッドと同様
  • 文字列が正規表現とマッチしないときはmatchメソッドはnilを返す。 このとき[ ]を使うと、nilにはそのメソッドがないのでエラーになってしまう。 そこで、.to_aメソッドで配列に変更しておくとエラーを避けることができる

2番めの注意点はもう少し説明が必要です。

  • MatchDataオブジェクトのto_aメソッドは、マッチした全体の文字列が0番目の要素、以下n番目の( )にマッチする文字列がn番目の要素となる配列を返す
  • nil.to_aは空の配列[ ]を返す

これを具体的なプログラムで説明しましょう。

m = /^\#{1,6} +(.+)$/.match("## 配列のリテラル")
print m[1], "\n"
print m.to_a[1], "\n"
m = /^\#{1,6} +(.+)$/.match("配列のリテラル")
print m.class, "\n"
print m.to_a[1]
print m[1]

これを実行すると

$ ruby example7.rb
配列のリテラル
配列のリテラル
NilClass

example7.rb:6:in `<main>': undefined method `[]' for nil:NilClass (NoMethodError)

print m[1]
       ^^^

となります。 行ごとに説明します。

  • 1: マッチが成立し、mにはMatchDataオブジェクトが代入される
  • 2: MatchDataオブジェクトmには[ ]メソッドがあり、1番目のマッチ文字列が表示される=>「配列のリテラル」が表示される
  • 3: MatchDataオブジェクトが配列["## 配列のリテラル", "配列のリテラル"]to_aメソッドで変換される。 更に[1]で配列の1番めの要素が取り出され、表示される=>「配列のリテラル」が表示される
  • 4: マッチが成立せず、mにはnilが代入される
  • 5: m.classはmのクラス名を返す=>「NilClass」が表示される
  • 6: m.to_anilから空の配列になる。 空の配列の1番めの要素が無いので、nilが返される。 printはnilを空文字列として出力する=>空行が出力される
  • 7: nilには[ ]メソッドがないのでエラーになる=>NoMethodErrorが表示される

長い説明になりましたが、まとめると、

「MatchDataオブジェクトに[ ]メソッドを使うときはその前にto_aメソッドを使おう」

ということです。

文字列のメソッド

文字列には便利なメソッドがいっぱいありますが、ここでは検索と置換に関係するメソッドを取り上げます。

subとgsub

subとgsubは置換メソッドです。 その違いは、subが最初にマッチした部分の置換だけをするのに対して、gsubは繰り返しマッチと置換を行います。 そして置換によってできた新規文字列を返します。 元の文字列オブジェクト自身は変更されません。

a = "abcdefg"
b = a.sub(/def/, "DEF")
print "a = ", a, "\n"
print "b = ", b, "\n"

実行してみます。

a = abcdefg
b = abcDEFg

メソッドsubによってdefがDEFに置換され、変数bに代入されました。 元の文字列と置換された文字列は別のオブジェクトですから、表示されたaとbは異なります。

元の文字列自身を破壊的に置換したいときはsub!メソッドを使います。 エクスクラメーションマークのついたメソッドは「破壊的」であることが多いです。 さきほどのプログラムをsub!に置き換えて実行してみましょう。

a = "abcdefg"
b = a.sub!(/def/, "DEF")
print "a = ", a, "\n"
print "b = ", b, "\n"
a = abcDEFg
b = abcDEFg

sub!はマッチしたときは元の文字列に置換をほどこし、その文字列を返します。 したがって、変数bはaと同じ文字列を指しています。 その結果aとbが両方とも置換後の文字列になって表示されます。

注意しなければならないのはsubとsub!の返り値の違いです。

  • subはパターンがマッチすればそこを置換した新しい文字列オブジェクトを返し、マッチしなければ元と同じ内容の文字列を新規に生成して返します。 いずれの場合も元の文字列オブジェクトとは別の文字列オブジェクトを返します。
  • sub!はパターンがマッチすれば、元の文字列の中で置換してそれを返し、マッチしなければnilを返します。

特にマッチしないときの振る舞いが全然違うので、返し値を利用する場合は注意が必要です。

print "abcdef\n".sub(/ddd/,"DEF").sub(/ab/,"AB")
print "abcdef\n".sub!(/ddd/,"DEF").sub!(/ab/,"AB")

これを実行すると

ABcdef
example7.rb:2:in `<main>': undefined method `sub!' for nil:NilClass (NoMethodError)

print "abcdef\\n".sub!(/ddd/,"DEF").sub!(/ab/,"AB")
                                  ^^^^^

1行目は、最初のsubメソッドでマッチが起こらなかったので元と同じ内容の新規文字列を返し、その文字列に対して2番めのsubメソッドを実行した結果が表示されます。

2行目では、最初のsub!メソッドでマッチが起こらなかったのでnilが返され、nilに対してsub!メソッドを実行しようとしましたが、nilにはそんなメソッドが無いのでエラーになります。

さて、subの2番めのパラメータには、マッチした文字列を埋め込むことができます。

  • \0はマッチした文字列全体
  • \1\2、・・・は( )の1番目、2番目・・・のマッチした文字列
  • バックスラッシュ自身を表したいときはエスケープする(\\とする)

例えば

print "abcdefg\n".sub(/d(..)g/,'\1')

これを実行すると

abcef

と表示されます。 パターンにdefgがマッチし、( )に相当する部分がefなので、置換文字列の\1にはefが代入されます。 その結果、defgがefに置換されます。

この例では文字列にシングルクォート文字列を使いました。 ダブルクォート文字列を使うこともできますが、その場合バックスラッシュはエスケープしなければなりません。 したがって、

print "abcdefg\n".sub(/d(..)g/,"\\1")

このように\\1と書かなければならないことに注意してください。 なお、シングルクォートでもバックスラッシュをエスケープ可能ですので\\1はOKです。

置き換え文字列の中に\1などを埋め込めるのは便利に思うかもしれませんが、バグを生みやすいことにも注意が必要です。 置換文字列がリテラルで与えられるなら問題はないのですが、外部から取り込んだ文字列(例えばファイルを読み込んで得られた文字列)などの場合に\1などが入っていると意図しない動作になるおそれがあります。

このような問題を避けるには、ブロック付きのsubを使う方法があります。 このsubはマッチが起こった時にブロックを実行し、その値への置換を行います。 ブロックの中では、$0$1・・・の形でマッチした文字列、( )に対応する部分文字列を参照できます。 これらは以前述べたマッチが起こった直後のグローバル変数$0$1・・・です。

a = '\1'
print "abcdefg\n".sub(/d(..)g/){ a+$1 }

ブロックの中では\1に対する置き換えはないので、

abc\1ef

と表示されます。 以上のことから、単純ではない置換や置換先の文字列が不確定の場合はブロック付きのsubが推奨されています。 なお、ブロックにはパラメータをひとつ置くことができ、パラメータはマッチした文字列全体が引数として代入されます。

以上subを見てきましたが、実用的には繰り返し置換するgsubの方がよく使われます。 gsubは「繰り返し」置換する点だけがsubとの違いなので、subでの注意点はそのままgsubにも通用します。

split

splitメソッドは、区切りを表す文字列を引数にとり、区切られた文字列の配列を返します。 例えば

print "a,b,c".split(",")
print "\n"

を実行すると

["a", "b", "c"]

と表示されます。

引数は正規表現も可能です。 例えば、コンマ区切りデータを配列に変換するには、区切りにコンマと改行の文字クラスを指定すれば良いです。

c = <<EOS
2,3,5,7,11
13,17,19,23,29
31,37,41,43,47
53,59,61,67,71
73,79,83,89,97
EOS
c.split(/[,\n]/).each do |i|
  print i, "\n"
end

このプログラムを実行すると2から97までが表示されます。

コンマ区切りデータをきちんと定義したものをCSV(Comma separated value)といいます。 RubyではCSVを扱うライブラリがあるので、そちらを使うほうがより実用的です。

scan

scanは繰り返しマッチを行い、マッチした文字列を配列にして返すメソッドです。 コンマ区切りデータから数字を取り出す例をscanで書き直してみましょう。

c = <<EOS
2,3,5,7,11
13,17,19,23,29
31,37,41,43,47
53,59,61,67,71
73,79,83,89,97
EOS
c.scan(/\d+/).each do |i|
  print i, "\n"
end

\d[0-9]と同じで、十進数クラスを表します。 scanは十進数の連続を探して取り出しています。 splitを使ったプログラムと違い、区切りは十進数以外であれば何でもOKです。

今回splitとscanはデータの取り出しに使いましたが、更に広い応用もあります。 特にドキュメントを加工、変換するようなプログラムでは活用が期待できます。