Rubyでnil.to_s
と叩くといわゆる空文字(""
)が返却されます。
しかし、Ruby 2.6以前と2.7以降では微妙に扱いが違い、2.7以降ではfrozen stringが返るようになりました。
これはRuby2.7のリリースノートにも書かれています。
Module#name, true.to_s, false.to_s nil.to_s は常にfrozenな文字列を返すようになりました。返された文字列は常に同じオブジェクトとなります。 [Experimental] [Feature #16150]
なので、下記のようなコードの場合、変数s
はnilが入るとエラーになります。
def gsub_hoge_fuga(s)
s.to_s.gsub!('hoge', 'fuga') # s = nilだと can't modify frozen String: "" (FrozenError)
end
では、どのようにしてエラーを回避するかを上記の例をもとに考えてみましょう。
そもそもnilのときに関数を呼ばないようにする
これは単純ですね。
replaced_string = gsub_hoge_fuga(s) unless s.nil? # ActiveSupportが入ってるならblank?をとかempty?とかでもいいかも
def gsub_hoge_fuga(s)
s.to_s.gsub!('hoge', 'fuga') # s = nilだと can't modify frozen String: "" (FrozenError)
end
nilの時エラーが起きるとわかっているなら、nil入れなければいいじゃない
というストロングスタイルです。
自分も一人で趣味開発してる時は、何も考えずにこうしてしまうかもしれません。
書いているコードの用途にもよりますが、他の人がこの関数を使おうとした時にnilが混入するリスクを減らすために、yardを書いてあげるなどの動きをしておくともっと良いかもしれません。
dupを使う
def gsub_hoge_fuga(s)
s = s.to_s.dup
s.gsub!('hoge', 'fuga')
end
dupはオブジェクトの参照値をコピーして新しくオブジェクトを作成するメソッドですが、dupで生成されたオブジェクトはfreezeされていません。
なのでdupで生成されたオブジェクトには心置きなくgsub!をすることができます。
irb(main):025:0> s = nil.to_s
=> ""
irb(main):026:0> s.frozen?
=> true
irb(main):027:0> t = s.dup
=> ""
irb(main):028:0> t.frozen?
=> false
そもそもgsub!ではなくgsubを使う
def gsub_hoge_fuga(s)
s.to_s.gsub('hoge', 'fuga')
end
そもそも今回のケースだと、置換して終わりなので破壊的メソッドであるgsub!を無理に使わなくてもgsubで事足りる場合も多いでしょう。
gsubとgsub!の違いを理解して使いわけをするようにしましょう。
early returnする
def gsub_hoge_fuga(s)
return '' if s.nil?
s.to_s.gsub!('hoge', 'fuga')
end
このケースだとnilだけ例外処理を挟んでしまうのもいいでしょう。gsubしても置換は起きないので、''
を返すことでRuby2.6以前の動作になります。
+@を使う
def gsub_hoge_fuga(s)
(+s.to_s).gsub!('hoge', 'fuga')
end
+以降に記載されたオブジェクト(上記の例だとs.to_s
)がfreezeされているかを検査し、freezeされている場合、複製を返してくれます。
カッコがないと、s.to_s.gsub!
まで実行されてからfreezeかどうかを判定する、という処理になりエラーになるので注意しましょう。
まとめ
この記事を書いたきっかけはruboty-slack_rtmというgemに、同様のエラーでひっかかってパッチを送ったことでした。
一つのエラーを解決するためにサッと並べただけでもこれだけの解決方法があるあたり、Rubyは相当柔軟な言語だなと感じました。
また、上にあげた方法以外にもあると思うので、その時その時でベストエフォートを考えるようにしたいです。