Ruby

Rubyの文字列連結に関して知っておくべきこと

Rubyだと文字列連結は足し算するだけでいいのでとってもお気楽。

でも、その裏の動きを意識しないと効率の悪いコードになってしまうかもしれない。


文字列連結の方法3つ


String#+

一番ポピュラーなやつ。

str = 'abc' + 'def'

# => "abcdef"


String#<< / String#concat

これでも同じ動きをする…かのように見える。

str = 'abc' << 'def'

# => "abcdef"
str = 'abc'.concat('def')
# => "abcdef"


Array#join

連結したい文字列を配列で持っておく必要があるけど、これでも連結できる。

str = %w(abc def).join

# => "abcdef"


たくさん繋げてみると大きな差が

文字列を2つ3つ、ちょろっと連結するだけなら好きな方法をとればいいと思う。

でもたくさん繋げるとなると、大きな違いが出てくる。

start = Time.now

str = ""
(0...10000).each do
str += "test"
str += "test"
str += "test"
str += "test"
str += "test"
str += "test"
str += "test"
str += "test"
str += "test"
str += "test"
end
puts "String#+ : #{Time.now - start}[sec]"

start = Time.now
str = ""
(0...10000).each do
str << "test"
str << "test"
str << "test"
str << "test"
str << "test"
str << "test"
str << "test"
str << "test"
str << "test"
str << "test"
end
puts "String#<< : #{Time.now - start}[sec]"

start = Time.now
ary = []
(0...10000).each do
ary << "test"
ary << "test"
ary << "test"
ary << "test"
ary << "test"
ary << "test"
ary << "test"
ary << "test"
ary << "test"
ary << "test"
end
str = ary.join
puts "Array#join : #{Time.now - start}[sec]"

ループの中で同じ文をたくさん書いてるのは、計測時間に占めるループ処理時間の割合を下げるための悪あがき。

こんなことしなくても差は歴然だけれども。

String#+   : 9.117918[sec]

String#<< : 0.013012[sec]
Array#join : 0.021162[sec]

String#+String#<<とで700倍ぐらい差がでた。


なぜこんなに差がでたのか

String#+に関しては、例えば

str = 'abc' + 'def'

と書くと、次のような処理になる。

1. 'abc''def'の文字列長を確認して、結合後の文字列の長さを求める

2. 結合後の文字列が入るサイズのメモリを確保したオブジェクトstrを作成

3. 'abc''def'strが持つメモリにコピー

先ほど処理時間を計測したコードだと、10万回のメモリ確保が行われたことになる。

メモリ確保に時間もかかるし、さらに確保したメモリをすぐに捨ててまたメモリ確保してを繰り返すので、わりとフラグメンテーションが起きているかもしれない。

そして、文字列のコピーもざっと200MB分ぐらいやってるはず。

最後の方なんてstrに40万文字の文字列が入ってるので、1回文字列結合するだけで40KB分も文字列コピーされちゃう><

それに対してString#<<はというと、

str = 'abc' << 'def'



  1. 'abc''def'の文字列長を確認して、結合後の文字列の長さを求める


  2. 'abc'のオブジェクトが持つメモリサイズを見て、'def'を追加できないか確認


    • 追加してもメモリが足りるようだったら、そのまま追加

    • 足りないなら、'abc'の持つメモリサイズの倍(それでも足りなければ更に倍、倍、と繰り返す)のサイズのメモリを持つオブジェクトを用意して、そこに両方の文字列をコピー。



ということで、毎回メモリを確保せず、できるだけ追加しようとしてくれる。

追加が無理でも、先を見越して倍のサイズのメモリを確保してくれるので、またしばらく安泰という寸法。

なので、処理時間計測コードで見ると、メモリ確保20回程度、文字列のコピーも全部で500KB程度なはず。

場合によっては無駄に多くメモリが確保されることもあるけど、今の時代気にしなくていい場合がほとんどじゃないかと思う。

それから、メモリに追加していく関係上、String#<<は破壊的メソッドになるので注意。

Array#joinは、最終的な文字列の長さが一度でわかるので、そのサイズのメモリを一度確保してやるだけで大丈夫。あとはそのメモリにどんどん文字列を書き込んでいくだけ。

どちらかというとjoinするための配列を作るのに時間がかかってる。


舞台裏を垣間見る

本当にそうなのか?

String#+String#<<の挙動を確認するために以下のコードを動かしてみた。

オブジェクトIDと、使用しているメモリサイズ(大体の値らしい)を出力している。

require 'objspace'

p "String#+"
str1 = ""
(0...10).each do
str1 += "012345678901234567890123456789"
puts "obj_id : #{str1.object_id} / memsize : #{ObjectSpace.memsize_of(str1)}"
end

p "String#<<"
str2 = ""
(0...10).each do
str2 << "012345678901234567890123456789"
puts "obj_id : #{str2.object_id} / memsize : #{ObjectSpace.memsize_of(str2)}"
end

String#+

obj_id : 70116696586100 / memsize : 31
obj_id : 70116696585960 / memsize : 61
obj_id : 70116696585840 / memsize : 91
obj_id : 70116696585720 / memsize : 121
obj_id : 70116696585600 / memsize : 151
obj_id : 70116696585480 / memsize : 181
obj_id : 70116696585360 / memsize : 211
obj_id : 70116696585240 / memsize : 241
obj_id : 70116696585120 / memsize : 271
obj_id : 70116696585000 / memsize : 301
String#<<
obj_id : 70116696584900 / memsize : 49
obj_id : 70116696584900 / memsize : 99
obj_id : 70116696584900 / memsize : 99
obj_id : 70116696584900 / memsize : 199
obj_id : 70116696584900 / memsize : 199
obj_id : 70116696584900 / memsize : 199
obj_id : 70116696584900 / memsize : 399
obj_id : 70116696584900 / memsize : 399
obj_id : 70116696584900 / memsize : 399
obj_id : 70116696584900 / memsize : 399



  • String#+では、


    • 毎回新しくオブジェクトが作られる

    • 最低限必要なサイズのメモリしか確保しない




  • String#<<では、


    • オブジェクトの中身を変更していく(破壊的メソッド)

    • メモリが足りなくなったら倍のサイズを確保する



ということが分かる。


まとめ

アレコレ書いたけど、文字列をちょっと繋げるだけなら、そんなに神経質にならなくてもいいと思う。

出した例はわりと極端だけど、もし文字列をガンガン繋げる場面に出くわしたら気をつけてもらえれば。