Ruby: 演算子の優先順位でハマった話(翻訳)
概要
同じ式の中に演算子(operator)が複数出現する場合、演算子がどの順序で適用されるかは演算子の優先順位(precedence)によって決定されます。演算子の優先順位はかなり初歩的な基本概念であるにもかかわらず、発見の難しいバグの温床になりがちです。本記事では、そうしたケースを手短に紹介しながら、演算子の優先順位が果たす役割を解説していきます。
はじめに
それなりに経験を積んできた開発者として、自分の頭の鈍さを思い知らされることがしばしばです。そこまでいかなくても、基本的なロジックを問われるときに戸惑う傾向があるのは確かです。最近、テストが失敗する問題を解決するのに大変な時間がかかったことがあったのですが、突き詰めてみたら演算子の優先順位というシンプルな問題でした。
演算子の優先順位とは、あるプログラミング言語(のインタプリタやコンパイラ)において、演算子を複数含む式を評価するときに演算子をどの順序で適用するかを決定するルールセットです。典型的な例としては以下のような数学の式があります。
2+10*3 # 32と評価される
上の式が32と評価される理由は、乗算演算子(*
)が加算演算子(+
)よりも優先順位が高いためです。特にこの処理順序は、私がこれまで出会ったどのプログラミング言語についても当てはまります。
丸かっこ()
を使うと、通常の処理順序を変更できます。加算を先に評価したい場合は以下のように書けます。
(2+10)*3 # 36と評価される
おさらいはこのぐらいにして、私が踏んで驚いた問題の本質を探ってみましょう。皆さんが同じ問題を踏んでブチ切れないためのヒントになるかと思います。
問題点
最初にシンプルなToken
クラスを考えてみましょう。このクラスをインスタンス化して文字列を渡すと、インスタンスのステート(@token
)の他に@error
変数もインスタンスに保持されます。この@error
変数は、インスタンスのライフタイムの間に発生したエラーを記録するためのものです。
このクラスの downcase!
メソッドはトークンを小文字に変換して@token
の内部ステートを更新します。このトークンが設定されない場合は@error
変数を更新してメソッドを早期脱出します。以下はこのクラスのシンプルな全体像です。
class Token
attr_reader :token, :error
def initialize(token)
@token = token
@error = "none"
end
def downcase!
(@error = "no_token" && return) unless token
@token = token.downcase
end
end
このクラスは以下のように利用できます。
t = Token.new("BLAH")
t.downcase!
puts t.token # 出力: 'blah'
puts t.errors # 出力: 'none'
downcase!
を呼び出してからインスタンスのステートを調べてみると、@token
変数は小文字に変換されており、@error
はデフォルトの'none'
のままです。いたって普通です。
さて、ここでnil
を渡してインスタンスを作成するとどうなるでしょうか?
t = Token.new(nil)
t.downcase!
puts t.token # 出力: なし
puts t.errors # 出力: 'none' (どういうこと??)
この場合インスタンス変数@token
はnil
のままですが、インスタンス変数@error
がno_token
に更新されていません。これは私の期待と違っています。何が起きたのでしょうか?
解説
問題が潜んでいたのは、downcase!
の以下の1行です。
(@error = "no_token" && return) unless token
ここでは、トークンが設定されない場合は@error
を設定してメソッドからreturn
したいのですが、@error
への代入が行われていないようです。
演算子の優先順位が怪しいと思った場合は、以下のような2通りの合理的なアプローチをとることが可能です。
- 演算子の優先順位のドキュメントをしっかり読む
- 期待通りに動くまで丸かっこ
()
を思いつくままに追加してみる
オプション2の方が眠たくなりにくいので、私はついこれを最初に試す傾向があります。このシンプルな例からも、私が丸かっこを愛しすぎている様子がにじみ出ていますね。
ところで、私の(@error = "no_token" && return)
では、トークンが存在しない場合の式の処理を丸かっこでグループ化していることにお気づきでしょうか?実は、後置のunless
は優先順位が低いので、この丸かっこは完全に冗長です。しかし読者がロジックを読み取りやすいよう、あえて冗長な丸かっこを残しておくことにします。
話を2つのオプションに戻します。丸かっこで優先順位を変更するということは、事実上独自の優先順位を定義していることになります。その場合、言語に内在する優先順位について何も学べません。
しかし、言語の暗黙の優先順位に不慣れでもここまでたどり着いたのですから、時にはオプション1を試してみるのもよいでしょう。
ともあれ、丸かっこを少しばかり追加してみましょう。
1つ2つ順序を変えてみると、以下のようにすれば
((@error = "no_token") && return) unless token
欲しい結果を以下のように得られることがすくわかります。
t = Token.new(nil)
t.downcase!
puts t.token # 出力: なし
puts t.errors # 出力: 'no_token'
ここでは、代入=
をグループ化するための丸かっこ()
と、&& return
を分離するための()
が必要でした。
実際に演算子の優先順位のドキュメントを見てみると、=
演算子は&&
演算子よりも優先順位が低いと書かれています。つまり、動かなかった元のバージョンは、事実上以下のように評価されていたわけです。
(@error = ("no_token" && return)) unless token
代入=
は、右辺(つまり("no_token" && return)
)が先に評価されますが、return
が実行されるので、@error
への代入は決して行われません。これで、@error
変数の値がnone
のまま変わらなかった説明がつきます。
さて、皆さんは演算子の優先順位のドキュメントを読んでいて、&&
は=
よりも優先順位が高いのに、and
は事実上優先順位が最も低いことにお気づきでしょうか?
つまり、&&
演算子をand
演算子に置き換えれば以下のように(丸かっこを減らした形で)ロジックを修正できるのです。
(@error = "no_token" and return) unless token
失敗例と完全におさらばするために、元の実装をもう一度よく見てみましょう。
(@error = "no_token" && return) unless token
実は、元の実装は別のレベルで混乱を引き起こす可能性もあるのです。それは、&&
演算子の"短絡"(short-circuit)1という振る舞いです。
たとえば @token
がない場合に、@error
に 'no_token'という値を設定するのではなく、@error
に何らかの"falsey"な値(ここではnil
)を設定するように変更するとします。
(@error = nil && return) unless token
するとどうなると思いますか?実際にはdowncase
メソッドでNoMethodError
エラーが発生します。
nil
値は&&
式で短絡評価されます。しかしreturn
には決して到達しないので、メソッドの次の行で空の@token
に対してdowncase
メソッドを実行しようとします。
まとめ
演算子の優先順位は、演算子を複数含んでいる式をプログラミング言語が評価するときの順序を決定するルールのセットです。本記事では、演算子の優先順位を正しく把握していないとコードでバグが発生しやすくなることを示しました。具体的には、Rubyのand
、&&
、=
演算子の相互作用について見てきました。
この混乱を解決するために、式の一部を丸かっこ()
でグループ化することで意図を明確にし、コードを読むときのガイダンスを提供する方法があります。しかし、丸かっこ()
で囲まなくても演算子の自然な優先順位をしっかり把握できるようになっておくべきです。
関連資料
- Rubyドキュメントの演算子の優先順位
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
なお、同ブログの別記事↓にはRuby演算子の制限時間付きクイズも掲載されていますので、興味のある方はチャレンジしてみてください。
参考: Test Yourself on Operator Precedence in Ruby