LoginSignup
7
4

More than 5 years have passed since last update.

String#gsub の第二引数にハッシュが渡せるんだった

Last updated at Posted at 2017-03-04

動機

HTML エスケープするメソッドが作りたいとする。たとえば Q & A という文字列なら Q & A に変換する。

話を簡単にするため,&, <, > をそれぞれ &amp;, &lt;, &gt; に変換すればいいということにする。

実装

難しくはない。

ENTITIES = {
  '&' => '&amp;',
  '<' => '&lt;',
  '>' => '&gt;'
}

def html_escape(str)
  str.gsub(/[&<>]/){ENTITIES[$&]}
end

puts html_escape("<Q&A>") #=> "&lt;Q&amp;A&gt;"

ちなみに,

str.gsub(/&/, "&amp;").gsub(/</, "&lt;").gsub(/>/, "&gt;")

みたいなやり方は効率が悪い。

発見

なんかのライブラリーのコードを見てたら,ちょうど HTML エスケープするメソッドが含まれてて,String#gsub の第二引数にハッシュを渡してた。

え? そんなことできんの?

で,できるみたい:
https://docs.ruby-lang.org/ja/2.4.0/method/String/i/gsub.html

Ruby 1.9 からなのかあ! 知らんかった orz

もしかしたら,見たけどすぐに忘れたんかも。

改良

これでいいわけだ。

ENTITIES = {
  '&' => '&amp;',
  '<' => '&lt;',
  '>' => '&gt;'
}

def html_escape(str)
  str.gsub(/[&<>]/, ENTITIES)
end

puts html_escape("<Q&A>") #=> "&lt;Q&amp;A&gt;"

簡潔になった。

速度

ベンチマークをとってみよう。

require "benchmark"

ENTITIES = {
  '&' => '&amp;',
  '<' => '&lt;',
  '>' => '&gt;'
}

def html_escape_block(str)
  str.gsub(/[&<>]/){ENTITIES[$&]}
end

def html_escape_hash(str)
  str.gsub(/[&<>]/, ENTITIES)
end

iter = 200_000

string = "honyara<b>ah&oge</b>hogefugafuga"

Benchmark.bm 8 do |r|
  r.report "block" do
    iter.times{html_escape_block string}
  end

  r.report "hash" do
    iter.times{html_escape_hash string}
  end
end
               user     system      total        real
block      1.500000   0.010000   1.510000 (  1.523939)
hash       1.240000   0.010000   1.250000 (  1.268519)

スピードも第二引数にハッシュを渡すほうが少し速いようだ。

ただし,対象の文字列に置換すべきものがあまりないと差が出ない。まあ当たり前か。

余談

HTML エスケープみたいに,何か文字列の置き換えリストみたいのがあって,それに従って置換を行いたいとする。

例えば,置き換えリストが

replace_table = {
  "なんたら" => "かんたら",
  "どうたら" => "こうたら",
  # ...
}

のようなハッシュになっているとする。

うっかり次のようなコードを書いてしまいそうだ。

re = Regexp.new(replace_table.keys.join("|"))

some_string.gsub(re, replace_table)

これは,replace_table の内容によっては,次の二つの点でマズい。

まず,ハッシュのキーのほうにメタ文字と解釈されちゃう文字があったらダメ。だから,Regexp.escape
を使って

re = Regexp.new(replace_table.keys.map{|s| Regexp.escape(s)}.join("|"))

のように個々の文字列をエスケープしてやらなくてはならない。

もう一つの問題は,キーの中に「cat」と「catch」のように,一方が他方の先頭の一部分になっているようなペアがあったとき,出現順序に左右されるということ。

str = "catch a cat"

puts str.gsub(/cat|catch/, "*") #=> "*ch a *"
puts str.gsub(/catch|cat/, "*") #=> "* a *"

ようするに,Ruby の正規表現エンジン鬼雲では,他の多くのエンジンと同じく,/cat|catch/"catch" を拾うことはないってこと。

よって,長いほうを前に出さなければならない。

だから,ちょっとめんどうだけど

keys = replace_table.keys.sort_by{|s| -s.length}
re = Regexp.new(keys.map{|s| Regexp.escape(s)}.join("|"))

とする必要がある。

追記(2017-09-13)

@taram さんのコメントにあるように,文字列の配列を与えて,それらのいずれかにマッチするという正規表現オブジェクトを得るには Regexp.union が使えます。

エスケープまでやってくれます。

たとえば,

Regexp.union(["a+b", "a.b"]) #=> /a\+b|a\.b/

というように。

ただし,長いもの順に並べてくれたりはしないので,ソーティングはやってやる必要があります。

つまり,最後に書いた

keys = replace_table.keys.sort_by{|s| -s.length}
re = Regexp.new(keys.map{|s| Regexp.escape(s)}.join("|"))

keys = replace_table.keys.sort_by{|s| -s.length}
re = Regexp.union(keys)

と書くべきでした。わー,すっきり!

7
4
2

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
7
4