Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: 演算子の優先順位でハマった話(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

  • 英語記事: Operator Precedence Riddles in Ruby
  • 原文公開日: 2023-02-20
  • 原著者: Domhnall Murphy
  • サイト: VectorLogic -- 北アイルランドのNewryを拠点とするソフトウェア開発コンサルタント会社です。

日本語タイトルは内容に即したものにしました。

なお、同ブログの別記事↓にはRuby演算子の制限時間付きクイズも掲載されていますので、興味のある方はチャレンジしてみてください。

参考: Test Yourself on Operator Precedence in Ruby

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' (どういうこと??)

この場合インスタンス変数@tokennilのままですが、インスタンス変数@errorno_tokenに更新されていません。これは私の期待と違っています。何が起きたのでしょうか?

解説

問題が潜んでいたのは、downcase!の以下の1行です。

(@error = "no_token" && return) unless token

ここでは、トークンが設定されない場合は@errorを設定してメソッドからreturnしたいのですが、@errorへの代入が行われていないようです。
演算子の優先順位が怪しいと思った場合は、以下のような2通りの合理的なアプローチをとることが可能です。

  1. 演算子の優先順位のドキュメントをしっかり読む
  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&&=演算子の相互作用について見てきました。

この混乱を解決するために、式の一部を丸かっこ()でグループ化することで意図を明確にし、コードを読むときのガイダンスを提供する方法があります。しかし、丸かっこ()で囲まなくても演算子の自然な優先順位をしっかり把握できるようになっておくべきです。

関連資料

  1. Rubyドキュメントの演算子の優先順位

関連記事


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。