9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Ruby で文字列のシマウマ処理

Last updated at Posted at 2023-04-16

与えられた文字列を,ある正規表現にマッチする部分とそれ以外の部分とに分けて,それぞれを処理したい,ということがしばしばある。

例えば,

ab1234cd5

という文字列は,\d+(数字列)という正規表現に関して

  • ab
  • 1234(マッチするところ)
  • cd
  • 5(マッチするところ)

に 4 分割される。

このそれぞれについて,マッチする部分かそうでない部分かによって異なる処理を施したい,という話だ。

文字列を一本の帯に喩えて,マッチする部分とそうでない部分を白と黒に塗り分けるとシマウマの模様になるので,このような処理を「文字列のシマウマ処理」と呼ぶことにしよう。

文字列を扱っていると,シマウマ処理はしばしば必要になる。本記事では,さまざまなシマウマ処理の考え方を紹介していきたい。

ケース 1(マッチ部分のみを変換する)

まず最初に,とても簡単なケースを検討する。それは

  • マッチする部分に何らかの変換を施す
  • そうでない部分には何もしない

という文字列加工処理だ。

たとえば,「数字列を < > で囲む」という加工を考えよう。これは String#gsub を使って,以下のように極めて簡単に実現できる。

str = "ab1234cd5"

puts str.gsub(/\d+/){ "<#{$&}>" }
# => ab<1234>cd<5>

このコードにおいて,gsub メソッドは

  • /\d+/ にマッチする部分文字列をいだすたびにブロックを評価する
  • その部分文字列をブロックの評価値で置き換える

ということを行う。
$& は見出された部分文字列を返す特殊変数だ。

上記の例ではブロックは 2 回呼び出される。

数字列ではない部分については完全に無視される,ということに注意しよう。

ケース 2

では次に,マッチする部分とそうでない部分にそれぞれ別の変換を施すことを考えよう。これが本記事の主題である。

とはいえ,本節で取り上げるのは極めてやさしい,以下のような加工処理だ。

  • 数字列は < > で囲む
  • そうでない部分は [ ] で囲む

つまり,

ab1234cd5

という文字列を

[ab]<1234>[cd]<5>

に加工したいんである。

これも String#gsub を使って以下のように書ける。

str = "ab1234cd5"

str2 = str.gsub(/\d+|\D+/) do
  if $&.match?(/\d/)
    "<#{$&}>"
  else
    "[#{$&}]"
  end
end

puts str2
# => [ab]<1234>[cd]<5>

ここでポイントとなるのが

/\d+|\D+/

という正規表現だ。
\D は「数字以外の文字」を表す文字クラスである。「非数字」と呼ぶことにしよう。
\d\D は相補的関係にある1

上記の正規表現は \d+\D+| で結ばれているため,全体として数字列と非数字列のどちらにもマッチする。
だから,見出される部分文字列は ab, 1234, cd, 5 の四つであり,ブロックが 4 回呼ばれることになる。

ここで重要なのは,この正規表現によって走査したとき,文字の取りこぼしが無いということ。
gsub はマッチする部分しか拾い上げないので,正規表現の設計をちょっとでもミスると何も処理されない部分が生じうる。こうなると当然シマウマ処理にはならないのだ。
いい変えれば,gsub によるシマウマ処理では,1 文字も取りこぼさない正規表現を与えなければならない,ということである。

さて,次にブロック内の式を見てみよう。それは

  if $&.match?(/\d/)
    "<#{$&}>"
  else
    "[#{$&}]"
  end

となっている。
gsub によって見出された部分文字列 $& が数字を含んでいるか否かによって,< > で囲むか [ ] で囲むか場合分けしている。

ここで注意したいのが,「$& が数字を含むか」の判定に String#match ではなく String#match? を使っていること。
判定自体はどちらでやっても真/偽は変わらないのだが,match のほうは $& の値を変えてしまう。match?$& に影響を与えない。
match? のほうが高速であることも知っておこう。

キャプチャーで楽をする

ここで,ちょっとした工夫を紹介したい。

$&.match?(/\d/) のような判別式はウザイと思うのだ。

いや,今回のお題が「数字列」か「非数字列」かで処理を分ける単純なものだったからこの程度の判別式で済んでいるが,もっとややこしい場合には判別も複雑になりコストもかかる。

そこで,以下のように変えてみよう:

 str = "ab1234cd5"
 
-str2 = str.gsub(/\d+|\D+/) do
-  if $&.match?(/\d/)
+str2 = str.gsub(/(\d+)|\D+/) do
+  if $1
     "<#{$&}>"
   else
     "[#{$&}]"
   end
 end
 
 puts str2
 # => [ab]<1234>[cd]<5>

要するに,まず正規表現の \d+ の部分を ( ) で囲った。
これにより,

  • 見出した部分文字列が \d+ にマッチするものだった場合,$1 にその文字列が入る
  • そうでない場合,$1 の値は nil

となる。

Ruby では,真偽値として

  • 文字列オブジェクトは真
  • nil は偽

なので,条件式を単に $1 と書くことができるわけだ。

ちょっと振り返り

シマウマ処理は,

  • 正規表現が与えられる(これを R と書くことにしよう)
  • 文字列が与えられる(これを S と書こう)
  • S を頭から探索して R にマッチする部分文字列を見出していく
  • マッチする部分とそれ以外の部分それぞれに別の何かをする

ということであった。

「何かをする」というのはいろいろであって,ここまでに出てきた例では,文字列の置換であった。これは gsub で実現した。
しかし,一般には,何か別のデータを組み立てるとかでもいいし,何かを表示するとかでもいい。

さて,ここが重要なのだが,前節のような例では,与えられた正規表現 R をそのまま使うことはできず,R を部分パターンとして含むような少し複雑な正規表現を考案する必要があった。
R にマッチする部分だけに何かをするのであれば,String#gsub なり String#scan なりに R を与えれば簡単なのだが,マッチしない部分にも何かをするためには,こういう面倒なことを考えなければならないのだ。

ケース 3

ここでは,文字列置換ではない例を。
数字列,非数字列それぞれに対して「数字列」「非数字列」と表示するだけ。

置換ではないので gsub でなく scan を使えばよい。

str = "ab1234cd5"

str.scan(/(\d+)|\D+/) do
  puts $1 ? "数字列" : "非数字列"
end

# => 非数字列
#    数字列
#    非数字列
#    数字列

とくに面白くもない。

ケース 4

くどいようだが,文字列のシマウマ処理を gsubscan で行うとき,与えられた正規表現 R から,「R にマッチする部分以外をも拾い上げる正規表現」を構築するのが面倒なのだった。

ここではケース 2 よりももう少し複雑な例を考える。

ケース 4 の解説は長くてダルいので,途中で嫌になっちゃった人は飛ばして次節「別のアプローチ」を読んでほしい。

お題

処理対象の文字列は,アルファベット(大文字,小文字)の列のところどころが < > で囲まれたものであるとする。
たとえば

  • aBC<dEf>G
  • <Abc>d<E>fgH
  • f<>j<HGsa><Lq>

のようなもの。
(最後の例のように < > の中身は空文字列もありうるとしよう)

ただし,

  • 入れ子(例:a<b<c>d>e
  • 閉じ過ぎ(例:a<b>c>
  • 開き過ぎ(例:a<b>c<d

は無いとする。

この文字列に対して,

  • < > で囲まれた部分は <> を取り除いたうえで大文字化
  • 囲まれていない部分は小文字化

という変換を施して繋げた文字列が得たい,とする。
たとえば

  • aBC<dEf>GabcDEFg
  • <Abc>d<E>fgHABCdEfgh
  • fj<HGsa><Lq>fjHGSALQ

という具合。

まず R を考える

まず最初に,「< > で囲まれた部分(<> も含む)」という正規表現を考えよう。
正規表現に慣れていない方は

/<.*>/

と考えてしまいがちだが,これはダメ。
なぜかというと,量指定子 * には最長一致の原則があるため,

a<b>c<d>e

という文字列に対して,<b>c<d> を拾ってしまうのだ。
本当は <b><d> を拾って欲しいのに。

正しいやり方として,次の二つが考えられる。

  • /<.*?>/
  • /<[^>]*>/

前者は,量指定子の * を最短一致版の *? に変えたもの。

後者は,<> の間に > が入らないようにしている。

前者のほうがやや簡素で見やすいので,ここでは前者を採用しよう。
パフォーマンスは後者のほうが優るようだが,軽くベンチマークテストをしてみた限りではあまり違わなかった。

さて,これで R にあたる正規表現は得られたが,R にマッチする部分以外をも拾うにはどうすればいいだろうか。

「それ以外」をも拾う正規表現

「R にマッチする部分」ではない部分文字列は,<> を含まない文字列だ。だから

/[^<>]*|<.*?>/

でよさそうな気がする。
本当だろうか?

試行

さきほどの正規表現を使って(少し改変して)処理を書いてみよう。
改変といっても,[^<>]* の部分を ( ) で囲むだけである。

str = "aB<cD>"

str2 = str.gsub(/([^<>]*)|<.*?>/) do
  $1 ? $&.downcase : $&[1..-2].upcase
end
puts str2
# => ab<cd>

えっ? うまくいっていない?
すべてが小文字になってしまったし,<> が削除されてもいない。

これはどういうことなのか?

失敗の原因を探るため,/([^<>]*)|<.*?>/ でどんな部分文字列が拾い上げられるのかを以下のコードで調べてみよう:

str = "aB<cD>"

str.scan(/([^<>]*)|<.*?>/) do
  p $&
end
# => "aB"
#    ""
#    "cD"
#    ""
#    ""

意外な結果ではないだろうか。なぜこうなったかを解説しよう。

上記の scan は,まず文字列先頭において [^<>]* を探索するのだが,首尾よく aB を見出す。

次に B の直後から探索を開始するのだが,[^<>]* は空文字列にもマッチするため,空文字列を見出す。

そして,ここが重要なのだが,空文字列を見出した次の探索は 1 文字先から始めるのだ。
だって,そうしないと同じ場所で永遠に空文字列を拾い続けるから!
これは正規表現検索のオキテなのだ。

というわけで,次は < の直後から探索を始める。よって,cD が見出される。<cD> ではなく!

そのあとは D の直後から探索を再開するが,まずそこにある空文字列を拾う。

空文字列を拾った後は 1 文字飛ばすので,今度は > の直後から探索を始める。

そこはもう文字列末尾なのだが,文字列末尾にも空文字列が存在するので(!),そいつを拾う。

ともかく,失敗の原因は,R1|R2 という形の正規表現において,R1 が空文字列にマッチすることであった。この場合,R1 はどこにでもマッチしてしまうので,いつまで経っても R2 の出番はやってこないのだ。
なお,正規表現エンジンのタイプによっては話が違ってくるのだが,そこに触れるのはやめておこう。

改修

要は | の前後を入れ替えればよい。つまり正規表現を

/<.*?>|([^<>]*)/

とする。
こうすると,「今いる地点」の直後に <.*?> にマッチする文字列が存在しないときに限って [^<>]* のほうがマッチする,ということになる。

やってみよう:

str = "aB<cD>"

str2 = str.gsub(/<.*?>|([^<>]*)/) do
  $1 ? $&.downcase : $&[1..-2].upcase
end
puts str2
# => abCD

うむ,確かにうまくいっているようだ。

しかし,この程度の確かめ方で「合ってる」などと思ってはいけない。絶対ダメ。
くどいようだが,この手の処理は正規表現の設計をほんのちょっと間違えただけで「多くの場合に期待どおり動作するが,特殊なケースで失敗する」ということがある。

面倒くさがらずにテストコードを書き,考えうるあらゆるパターンの文字列を食わせて動作確認しなければならない。

本記事でテストコードの書き方も紹介したかったが,記事が既に長くなり過ぎたし,くたびれたので割愛する。

別のアプローチ

ケース 4 までのお題は,どれも単純な部類に入る。
もう少し複雑なことがやりたくなってくると,与えられた正規表現 R から,gsubscan に食わせる正規表現を構築するのが大変になってくるし,ミスも起こしやすい。

また,さきほど見たように,構築された正規表現が空文字列にマッチする場合,空文字列を拾うことがあるので,そのケアをしなければならないこともある。
ケース 4 ではとくに意識しなくてよかったが,見出した文字列を ( ) で囲むといった場合,空文字列を無視するような場合分け処理が必要になるだろう。

もっと機械的にできて素直なアプローチは無いのだろうか?
ある。

方針

頭をあまりひねらずにすませてバグを生みにくくするには,与えられた正規表現 R をそのまま使うのがよい。

しかし,R が拾わない部分はどうすればいいのか?

String#match の返り値を使えばいい。

このメソッドの返り値は,単に真偽値としても使えるが,(nil ではない場合)多くの情報を含んだ MatchData オブジェクトだ。

MatchData オブジェクトは,見出した部分文字列の位置(どこからどこまで)の情報を MatchData#offset が提供する。

この位置情報を使えば,「拾わなかった箇所の位置」が算出できるので,そこを部分文字列として取り出せばよい。

もう一つ押さえておきたいのは,String#match で検索開始位置が指定できる,ということだ。

これで大方針は決まった。
あとは以下のように考えよう。

  • ロジックの中核を汎用性のあるメソッドにする
  • String クラスのインスタンスメソッドとする
  • ただし,String を直接いじらず,refinement で
  • メソッド名は zebra_each
  • 使い方は str.zebra_each(re){ |s| 云々 }
  • ブロックパラメーター s
    • re にマッチする部分の場合,MatchData オブジェクト
    • そうでない部分の場合 String オブジェクト

実装

まずコードを示す。
(このコードは投稿後の 2023-04-19 に改変した)

module AddZebraEachToString
  refine String do
    def zebra_each(regexp)
      unless block_given?
        return Enumerator.new{ |y| zebra_each(regexp){ y << _1 } }
      end

      pos = 0
      search_from = 0
      while (search_from <= length) && (match_data = regexp.match(self, search_from))
        b, e = match_data.offset(0)
        yield self[pos...b] if b > pos
        pos = search_from = e
        search_from += 1 if b == e
        yield match_data
      end
      yield self[pos..] if pos < length
    end
  end
end

これを定義しておけば,zebra_each メソッドを使いたい箇所で

using AddZebraEachToString

するだけで使えるようになる。

くたびれたので解説は略すけど,分かりにくいところがあったら遠慮なく質問してね。

試用

ではこれを使ってみよう。
以下のコード提示では,AddZebraEachToString モジュールの定義は略す。

using AddZebraEachToString

"ab1234cd5".zebra_each(/\d+/) do |s|
  p s
end
# => "ab"
#    #<MatchData "1234">
#    "cd"
#    #<MatchData "5">

いい感じだね。\d+ にマッチしたところは MathData オブジェクトが,そうでないところは String オブジェクトが与えられている。
だからブロック内の処理はクラスで分ければいいわけだ。

では,これを使ってケース 2〜4 をやってみよう。

ケース 2

using AddZebraEachToString

str = "ab1234cd5"

str2 = +""
str.zebra_each(/\d+/) do |s|
  str2 << (s.is_a?(MatchData) ? "<#{s}>" : "[#{s}]")
end

puts str2
# => [ab]<1234>[cd]<5>

コード量もコードの複雑さも,最初のコードと大して変わらないが,正規表現が \d+ でよい,というのは精神衛生上よろしい。

少し解説しておこう。
s が MatchData オブジェクトだった場合,"<#{s}>" が評価されるわけだが,式展開の中では,文字列でないものは to_s によって文字列化される。
MatchData#to_s はマッチした文字列全体を返すことになっているから,これでいいわけだ。

なお,str2 を初期化するとき,空文字列を "" ではなく +"" と書いているのは,frozen_string_literal: true でも大丈夫なように,という配慮だ2

ケース 3

using AddZebraEachToString

str = "ab1234cd5"

str.zebra_each(/\d+/) do |s|
  puts s.is_a?(MatchData) ? "数字列" : "非数字列"
end

# => 非数字列
#    数字列
#    非数字列
#    数字列

こちらも同様。

ケース 4

using AddZebraEachToString

str = "aB<cD>"

str2 = +""
str.zebra_each(/<(.*?)>/) do |s|
  str2 << (s.is_a?(String) ? s.downcase : s[1].upcase)
end
puts str2
# => abCD

この例ではキャプチャーを使った。
MatchData オブジェクトは [] でキャプチャー文字列を取り出すことができる。

ブロックなし用法(2023-04-19 追記)

投稿後の 2023-04-19 に,zebra_each メソッドの定義の冒頭に以下を書き加えた。

unless block_given?
  return Enumerator.new{ |y| zebra_each(regexp){ y << _1 } }
end

これにより,ブロックを与えずにメソッドを呼び出した場合に Enumerator オブジェクトを返すようになる。

mapto_a, with_index, count といったものにつなげることができ,使い方が広がる。
Ruby の Enumerator はいいね!

では,「ブロックなし用法」を使って,ケース 2, 4 を書き換えてみよう。

ケース 2

using AddZebraEachToString

str = "ab1234cd5"

str2 = str.zebra_each(/\d+/).map do |s|
  s.is_a?(MatchData) ? "<#{s}>" : "[#{s}]"
end.join

puts str2

やや簡素になった。

ケース 4

using AddZebraEachToString

str = "aB<cD>"

str2 = str.zebra_each(/<(.*?)>/).map do |s|
  s.is_a?(String) ? s.downcase : s[1].upcase
end.join

puts str2
# => abCD

こちらも同様。

おわりに

String#zebra_each はテキスト処理に頻出するシマウマ処理をシンプルに解決する。
String クラスに組み込んでほしいが,私には説得力のある提案をする力はない。

なお,「シマウマ処理」などというのは私が勝手に考えた用語である。

はー,疲れた。ツッコミでも質問でもご意見でも何でもどうぞ。

訂正(2023-04-18)

「試行」節の str.scan(/([^<>]*)|<.*?>/) の解説が一部間違っていたので訂正した。

同時に,他の箇所の表現を若干修正した。

修正(2023-04-19)

zebra_each の定義に少し書き加え,ブロックなし用法に対応させた。

  1. どんな文字でも,\d\D のどちらか一方のみにマッチするということ。

  2. スクリプト先頭に #frozen_string_literal: true と書いておくと,文字列リテラルによって生成される String オブジェクトが frozen(凍結された状態)になる。

9
0
11

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?