STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Ruby "enbugging" quiz の解説

STORESでフルタイムRubyコミッタをやっている遠藤(@mametter)です。

STORESは今回RubyKaigi 2024で、託児所を運営する「ナーサリースポンサー」として参加していました。この様子は後日詳報しますが、それ以外にも参加者に楽しんでもらえる企画をいろいろな形でしていました(予告記事を参照)。 この記事ではその中でも、ブースでやった「Ruby "enbugging" quiz」について、解答や出題意図などを紹介します。

概要

動作しているプログラムを、指定したエラーが出るように「エンバグ」してください、というクイズでした。

指定したエラーが出ればクリアですが、なるべく短い編集距離でエンバグできたほうがより良い得点になります。

作問は主に遠藤(mame)と、もう一人のフルタイムRubyコミッタである笹田(ko1)の二人でやりました。この記事では、各問題の想定解答や作問時に考えていたことを公開します。自力で解いてみたい人は、ぜひやってみてください。下のリンクから遊べます。

ruby-quiz-2024.storesinc.tech

クレジット

「指定したエラーが出るようにエンバグせよ」というアイデアは、遠藤独自のアイデアではありません。東京工業大学情報理工学院数理・計算科学系 プログラミング研究室のご研究が元ネタです。

prg.is.titech.ac.jp

「プログラミング初心者はエラーメッセージを読まない」とはよく言われることですが、この研究は初学者にエラーメッセージを読ませるきっかけとして、「エラー生成クイズ(enbugging quiz)」を提唱しています。多くの初学者はこのクイズをやると、エラーメッセージを読む意識が高まるそうです。詳しくは次の論文を参照ください。

エラー生成クイズという面白いアイデアを提案してくださった著者のみなさま、それをRubyKaigiのSTORESブースのイベントとして利用させていただく許可をくださった増原英彦先生に感謝いたします。

少しだけ独自の工夫として、エンバグ成功までの編集距離をスコアとしました。そしてすべての問題は、編集距離1、つまり1文字追加または1文字削除でエンバグできるように作ってあります。RubyKaigiでこれを遊ぶのは初学者だけではないので、熟練者向けのやりこみ要素として入れました。

それでは、次から各問題の解答を解説していきます。ネタバレ注意。

問1

# Change the following code
# to raise the expected error
n = 1_000
p n + 1
puts "no error"

期待エラー:undefined method '+' for nil (NoMethodError)

これは「nil#+を呼び出してしまった」というエラーなので、nil + 1 を評価すればよいです。 ちょうどp n + 1があるので、これに"i"と"l"を書き足してp nil + 1とすれば2点でクリア。

しかし、1点でクリアする方法があります。それは、n + 1$n + 1にすることです。$nは未初期化のグローバル変数なので、読みだしたらnilが得られ、同じ効果が得られます。

# Change the following code
# to raise the expected error
n = 1_000
p $n + 1
puts "no error"

ほかに、インスタンス変数@nにしてもOKです。

これがko1の想定していた解答ですが、mameが想定していたもう少し難しい別解もあります。それは、1_000の前に#を置くことです。これにより、n = p n + 1という式になるのですが、nへの代入の右辺でnを参照すると、未初期化のローカル変数の読み出しとなり、やはりnilが得られます。

# Change the following code
# to raise the expected error
n = #1_000
p n + 1
puts "no error"

さらに、参加者が発見した別の解答に、p n + 1nを消すというのもありました。Kernel#pnilを返すので、所望のエラーが出るというおもしろいハックですね。

# Change the following code
# to raise the expected error
n = 1_000
p  + 1
puts "no error"

作問:ko1

問2

# The billion dollar mistake
ary = ["NO ERROR"]
puts ary[0].downcase

期待エラー:undefined method 'downcase' for nil (NoMethodError)

これもnil.downcaseを呼び出せばOKです。Rubyの配列は、範囲外の値を読み出すとnilになるので、ary[1].downcaseなどに書き換えればOKです。

編集距離を1にするなら、ary[01].downcaseなどにすればよいでしょう。

# The billion dollar mistake
ary = ["NO ERROR"]
puts ary[01].downcase

もちろん、ary[02]でもary[10]でもかまいません。

これを2問目にした理由をよく覚えていないのですが、問題の順番を調整しているうちになんとなくそうなったのかなと思います。確かチュートリアル的に作った問題だったので、2問目にしてはちょっと気の抜けた問題になっちゃった。

ちなみに、コメントにある"The billion dollar mistake"は、nullを発明したアントニー・ホーアがnullについて言った言葉です。

作問: mame

問3

# Last of the day
def foo(x, y, z)
end

foo(1, 2, 3)

puts 'no error'

期待エラー:wrong number of arguments (given 2, expected 3) (ArgumentError)

「3引数を受け取るメソッドなのに、2引数しか渡されていない」というエラーなので、foo(1, 2, 3)の引数をひとつ消して、たとえばfoo(1, 2)にすればOKです。

1点で解くには、1文字しか消せません。どうすればよいかというと、"3"だけ消してfoo(1, 2, )にします。そう、メソッドの引数にはtrailing commaを残せるのです。まあ、1行のときにtrailing commaを書く人はいないと思いますが。

# Last of the day
def foo(x, y, z)
end

foo(1, 2, )

puts 'no error'

これはこのクイズのコンセプト段階で一番最初に作った問題で、わりとお気に入りです。当初の問1でしたが、ko1に「難しすぎる」と言われて移動しました。

作問:mame

問4

# Good morning!
n = 3
puts "I dunno error"[2 + n..]

期待エラー:nil can't be coerced into Integer (TypeError)

これは1点解答がかなりいろいろあります。2 + $n2 + @nにするとか、n = #3にするとか、"3"を消してn =にするとか。

なかでも、n = $3にするのが個人的に好きです。

# Good morning!
n = $3
puts "I dunno error"[2 + n..]

この解法はko1が発見し、社内でちょっと盛り上がりました。ちなみに、問1でアンダースコア付きで1_000と書いていたのは、この解法を封じるため($1_000は構文エラー)でした。問1が問4より難しいのはちょっと適当すぎたかも。

担当:mame

問5

# Out of bounds?
ary = [1, 2, 3]
ary[3] = "no error"
puts ary[3]

期待エラー:index -4 too small for array; minimum: -3 (IndexError)

あまり見慣れないエラーですが、ちょっと試行錯誤するとary[-4] = ...で出るエラーであるとわかります。範囲外アクセスをあまりエラーにしないRubyですが、外れすぎるとエラーになることもある。

ary[-4] =と書き換えると3点(削除1文字、追加2文字)。ちょっと工夫してary[3-7] =だと2点です。

1点で解くには、ちょっと知識が必要です。"3"を"-4"にする魔法がありまして、それはビット反転です。ということで、ary[~3] = ...にすれば正解です。

# Out of bounds?
ary = [1, 2, 3]
ary[~3] = "no error"
puts ary[3]

完全に余談ですが、~-を組み合わせることで、1を足したり引いたりできます。コードゴルフなどでたまに使うテクニックです。

-~3     #=> 4
-~-~3   #=> 5
-~-~-~3 #=> 6

~-3     #=> 2
~-~-3   #=> 1
~-~-~-3 #=> 0

担当:mame

問6

# Hint: String#*
n = 10
puts "rhino error!!"[n * -1..-3]

期待エラー:negative argument (ArgumentError)

シンプルでよくわからないエラーですが、ヒントと合わせて試すと、"something" * -1で出ることがわかります。n * -1nが文字列であればよいので、たとえばn = "10"にするとか(2点)。

1点で解くには、?n * -1とすればいいです。?nは「文字リテラル」で、意味は1文字の文字列"n"と完全に同じです。

# Hint: String#*
n = 10
puts "rhino error!!"[?n * -1..-3]

ただ、実はこれはko1が発見した別解で、遠藤の想定解ではありませんでした。というのも、問題の例外が出るのはString#*だけでなくArray#*もなのです。つまりヒントはひっかけで、nを配列にするのが遠藤の想定解でした。

1点でnを配列にするには、2つの方法があります。ひとつは、n = *10とすること。このように書くと、10を配列に変換して(つまり[10]にして)、nに代入する意味になります。

# Hint: String#*
n = *10
puts "rhino error!!"[n * -1..-3]

もうひとつは、コンマを挿入してn = 1,0にすること。これは多重代入の扱いなんですが、左辺が一つだけだと配列として代入されます。このへんの意味はトリッキーでむずかしいですね。

# Hint: String#*
n = 1,0
puts "rhino error!!"[n * -1..-3]

担当:mame

問7

# Last day!
class C
  def no = "no"
  def error = "error"

  private :no, :error

  def main
    no + " " + error
  end
end

puts C.new.main
# Thanks @joker1007

期待エラー:private method 'main' called for an instance of C (NoMethodError)

mainメソッドをprivateメソッドにする必要があります。2つの方法を思いつきます。ひとつは無引数privateにすること。引数をコメントアウトすればよいでしょう。

# Last day!
class C
  def no = "no"
  def error = "error"

  private #:no, :error

  def main
    no + " " + error
  end
end

puts C.new.main
# Thanks @joker1007

もうひとつは、private :no, :error,と、コンマを追加すること。これは問3のようなtrailing commaではなく、行継続となります。def main...endの返り値、すなわち:mainがprivateに渡され、無事に期待エラーが出ます。

# Last day!
class C
  def no = "no"
  def error = "error"

  private :no, :error,

  def main
    no + " " + error
  end
end

puts C.new.main
# Thanks @joker1007

ちなみに、この問題は次の@joker1007さんの記事を参考に(というかほぼそのまま)作りました。ありがとうございます。

https://joker1007.hatenablog.com/entry/2024/03/14/233433

作問:たしかko1?

問8

# Do you know of such
# a pedantic error?
module M
end

module M2
  include M
end

module M
  #include ? # cyclic?
  puts "no error"
end

期待エラー:cyclic include detected (ArgumentError)

(モジュールの)includeに循環が見つかったというエラーなので、循環するようなincludeをしましょう。ヒントのところでinclude M2をすればよいです。

# Do you know of such
# a pedantic error?
module M
end

module M2
  include M
end

module M
  include M2 # cyclic?
  puts "no error"
end

1点で解くには、もっと大胆な方法があります。それは、自分自身をincludeすることです。例えばmodule M2の中でinclude M2をするようにする。そのためには、ヒントを無視して上のinclude Mに"2"を書き足します。

# Do you know of such
# a pedantic error?
module M
end

module M2
  include M2
end

module M
  #include ? # cyclic?
  puts "no error"
end

逆に、module M2の"2"を消すのでも大丈夫です。

作問:mame

問9

# The last quiz
def x = 2
def y = 1
puts "no error" * (x-y)

期待エラー:wrong number of arguments (given 1, expected 0) (ArgumentError)

0引数を受け取る(つまり引数をとらない)メソッドに引数を渡してしまった、というエラーです。xyは無引数のメソッドなので、これらに引数を渡せば良さそうです。

よって、(x-y)(x(1)-y)などとすればクリアです。

1文字で解くにはどうするか。これはRubyの文法の細かい挙動を知っていないといけないのですが、答えを言うと(x -y)です。

# The last quiz
def x = 2
def y = 1
puts "no error" * (x -y)

x-yx- yx - yは、すべてx引くyとして解釈されるのですが、x -yだけはx(-y)のように解釈されます。なぜ?と思うかも知れませんが、たとえば-varの値をデバッグ出力するときには、p -varと書きたいからだと思われます。Rubyの文法は難しいですね。

作問:ko1

問A

def foo(...)
  "no error".clamp(...)
end

puts foo("a", "z")

期待エラー:cannot clamp with an exclusive range (ArgumentError)

Day 3までの9問をすべて解くと、Extraの3問がアンロックされます。余り物を詰め込みました。

先に答えを言ってしまうと、clamp(...)clamp(...1)などにすればOKです。

def foo(...)
  "no error".clamp(...1)
end

puts foo("a", "z")

もともとの...はRuby 2.7で導入された、受け取った引数をまるごと他のメソッドに渡す記法なのですが、それを片方だけbeginless range(...x)にせよという問題でした。

Comparable#clampは範囲指定としてRangeを受け取れるのですが、exclusiveなRange(x...y)だとこのエラーが出ます。 なぜこのエラーが出ることになったかわかりますか? これは、1.clamp(0.5...1)などと書いたときに返すべき値がよくわからないためです(1を返したら範囲外のものを返していることになる。1に限りなく近いFloatを返す?ちょっと微妙) そう考えると、この問題はbeginless rangeを使えばよいとわかり、わりと論理的に解けます。

作問:どっちか忘れた

問B

r = (0..1)
0 => ^r
puts "no error"

期待エラー:0: 1 === 0 does not return true (NoMatchingPatternError)

この問題は、まず文字数を気にせずにこのエラーを出す方法を考えるのがよいです。 これは、右代入のパターンマッチの式でパターンマッチに失敗したときに出るエラーです。 いくつか試行錯誤すると、0 => 1とすればこのエラーが出ることに気づくと思います。

問題のコードには^rというパターンが書かれていて、これはrに入っている値とマッチするか判定するパターンです(この^をピン演算子と言います)。 つまりこの式は、左辺の値(0)が変数rの中身にマッチするかを判定していて、マッチしなかったときに問題のエラーが出ます。

ということで、目指すべきはrに1を代入することです。単純にはr = (0..1)r = 1に書き換えればOK。

1文字で解くには、(0..1)の1を活用することを目指します。そのためには、0..の部分を捨て去りたい。途中の式の値を捨て去る構文というと、複文です。ということで、0..1の間にセミコロンを挟みましょう。

r = (0..;1)
0 => ^r
puts "no error"

0..がendless rangeになるのがポイントでした。ここにセミコロンを挟むというアイデアをko1が出して、mameがパターンマッチに絡める形で仕上げました。この問題に悩んでいる人が一番多かったようです。

作問:ko1

問C

puts "no " + 9219755.to_s(034)

期待エラー:invalid radix 52 (ArgumentError)

この問題は解くだけなら本当に簡単で、03452にするだけです。radixは日本語で言うと基数で、n進数のnのことです。

1文字で解くのは、ひらめき一発です。8進数で52を表すには064と書く必要があり、削除1追加1で2点になってしまいます。どうするかというと、16進数として解釈しましょう。つまりxを書き足します。

puts "no " + 9219755.to_s(0x34)

逆にxを消すことで解ける問題にすることも考えましたが、8進数を知らないと解けない問題よりは、8進数を知ってもらうきっかけになる問題のほうがいいかなと思い、こうしました。

作問:mame

設計時に考えていたこと

Cookpad Code Puzzle for RubyKaigi 2022に強く影響を受けています(バレバレなので言ってしまうと、作者はどちらも自分です)。当時、スマホでは字が小さくて見にくかったという反省を踏まえ、今回はレスポンシブルなデザインにしました。モバイルファースト。

また、全面的に ruby.wasm を使いました。UIのほとんどをruby.wasmで書いています。必然的に実質vanilla JS相当ですが、まあこの程度のUIなら半日程度で実装できました。ただ、addEventListenerのコールバックがnon-asyncで呼ばれるのですが、その中でawaitする必要が生じたところだけはかなり辛かったです。それの回避に半分くらい時間使ってた気がする。相談に乗ってもらった@kateinoigakukunに感謝。

今回、別解のある問題が多いです。ちょっと悩んだのですが、そちらのほうが広く楽しんでもらえるかなと思ったので、意図的にそうしました。あまり別解チェックもしていないので、遠藤が気づいてない別解もあるかもしれません。

賞品

1日3問解いた方には、「紅いもカリカリ」という沖縄のお菓子を賞品としてお渡ししていました *1

これは STORES を利用してくださっている「美らさんぴんOKINAWA MART」さんが販売しているお菓子です。当日受け取りそこねた方や、もっと欲しくなった方はオンラインで購入できますのでぜひ。

okinawa-mart.com

まとめ

長い記事になってしまいましたが、RubyKaigi 2024のSTORESブースの企画Ruby "enbugging" quizの解説でした。

毎日100人近くの人がブースに来て、賞品をゲットしてくれました。多くの人に楽しんでいただけたようでよかったです。ご参加いただいたみなさん、ありがとうございました!

なお、6月20日に『深堀りRubyKaigi 2024』を開催します。今回はruby.wasmにフォーカスし、RubyKaigiでruby.wasm関係の発表を行ったkateinoigakukunさん、ledsunさん、remoreさんをゲストに呼んでお話を伺います。Ruby "enbugging" quizもruby.wasmを活用してつくったものなので、ちょっとくらい言及するかも。オンラインイベントなので、ラジオ感覚でお気軽にご参加ください!

hey.connpass.com

*1:STORESの巾着袋、STORESのペンも選択可でした。