動機
HTML エスケープするメソッドが作りたいとする。たとえば Q & A
という文字列なら Q & A
に変換する。
話を簡単にするため,&
, <
, >
をそれぞれ &
, <
, >
に変換すればいいということにする。
実装
難しくはない。
ENTITIES = {
'&' => '&',
'<' => '<',
'>' => '>'
}
def html_escape(str)
str.gsub(/[&<>]/){ENTITIES[$&]}
end
puts html_escape("<Q&A>") #=> "<Q&A>"
ちなみに,
str.gsub(/&/, "&").gsub(/</, "<").gsub(/>/, ">")
みたいなやり方は効率が悪い。
発見
なんかのライブラリーのコードを見てたら,ちょうど HTML エスケープするメソッドが含まれてて,String#gsub
の第二引数にハッシュを渡してた。
え? そんなことできんの?
で,できるみたい:
https://docs.ruby-lang.org/ja/2.4.0/method/String/i/gsub.html
Ruby 1.9 からなのかあ! 知らんかった orz
もしかしたら,見たけどすぐに忘れたんかも。
改良
これでいいわけだ。
ENTITIES = {
'&' => '&',
'<' => '<',
'>' => '>'
}
def html_escape(str)
str.gsub(/[&<>]/, ENTITIES)
end
puts html_escape("<Q&A>") #=> "<Q&A>"
簡潔になった。
速度
ベンチマークをとってみよう。
require "benchmark"
ENTITIES = {
'&' => '&',
'<' => '<',
'>' => '>'
}
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)
と書くべきでした。わー,すっきり!