2015年1月に翔泳社から「Effective Ruby」という中級者・上級者向けのRubyの本が出版されました。
とても主張が強い本です。そのため、何でも真に受ける人にはオススメできません。(初級者とか。)他の人の主張を聞いて、自分で咀嚼してよい悪いを判断できるようになってから読むべきです。
「項目5 実行時の警告に注意しよう」で警告を有効にしようと書いているなど、よいことを書いているところはあるのですが、ここでは、著者の主張が気に入らなかったところを紹介します。よいところは自分で読んで確認してください。
「項目2 オブジェクトを扱うときにはnilかもしれないことを忘れないようにしよう」で、「適切なら」と断りをいれていますが、to_sやto_iなどでnilを強制的に型変換しようと書いていることが気に入りません。不正なオブジェクトが見つかる時期が遅くなるので気に入りません。なぜか空文字がある、0がある、どうして?という状態になりやすくなります。
「項目3 Rubyの暗号めいたPerl風機能を避けよう」で次のようにifの条件式のところで代入しているのが気に入りません。しかも、一般的であると書いています。代入(=)と比較(==)は紛らわしいので条件式で代入するのはやめて欲しいです。
1 2 3 4 5 6 7 |
def extract_error (message) if m = message.match(/^ERROR:\s+(.+)$/) m[1] else "no error" end end |
「項目19 reduceを使ってコレクションを畳み込む方法を身に付けよう」で次のようなコードを書いています。selectよりもreduceの方がよいそうです。理由は効率的だからだそうです。
1 2 3 4 5 6 |
users.select {|u| u.age >= 21}.map(&:name)
users.reduce([]) do |names, user|
names << user.name if user.age >= 21
names
end
|
たしかに効率的ですが、そうまでしてreduceを使う必要はあるのでしょうか。この例ならeachで十分です。
1 2 3 4 5 |
names = [] users.each do |user| names << user.name if user.age >= 21 end names |
reduceのブロックの中で破壊的な操作をするのが気に入らないのです。次のように使うならいいです。「あるルールに従ってデータ構造内の要素数を縮小していき、最後に残った値を返す」というreduceの発想に沿った使い方だからです。ただ、効率はよくありません。
1 2 3 4 5 6 7 |
users.reduce([]) do |names, user| if user.age >= 21 names + [user.name] else names end end |
「項目33 エイリアスチェイニングで書き換えたメソッドを呼び出そう」のところは気に入らないというより著者の勘違いですが、演算子でも動きます。send(:"*_without_logging")は動くのです。
あと、些細なことですが、コーディングスタイルは気に入りませんでした。
主張の強い中級者・上級者向けのRubyの本、Effective Rubyを紹介しました。他の人の主張の良し悪しを自分で判断できるようになったら読んでみてはいかがでしょうか。ここでは気に入らないところを紹介しましたが、まっとうなことを書いているところもいろいろあります。自分で考えながら読んでみてください。
Effective Rubyを読んだ後はRubyのテスティングフレームワークの歴史(2014年版)を読みたくなることでしょう。
そういえば、最近のRubyの本はGCのことを説明することが当たり前になってきたのでしょうか*1。この本でもGCのことを説明していました。
*1 将来、RubyがJVMのように自分でいろいろチューニングしないといけなくなったらどうしましょう。。。
全文検索エンジンのGroongaでmrubyを使っているのですが、たまにCRubyと異なる挙動に遭遇することがあります。このようなときはmrubyに問題があります。特定のメソッドの挙動がおかしいときはmrubyのライブラリーの実装に問題があります。構文の使い方で挙動がおかしいときはmrubyのVMに問題があります。
mrubyの問題に遭遇するときは、なぜかmrubyのVMに問題があるケースばかり*1なのですが、久しぶりにmrubyのVMにある問題を直そうとしたらデバッグの仕方を忘れていました。忘れても後から思い出せるようにメモしておきます。
詳細は後述しますが、基本的な流れは次の通りです。
mrbc -vでスクリプトの構文木(?)を確認する。mruby XXX.rbを走らる。src/vm.cの中で問題のラベルがあるところにブレークポイントを設定する。すでに問題を見つけているはずなので、それを独立させ、できるだけ小さいコードで再現するようにします。このあいだ遭遇した問題*2は次のスクリプトで再現しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class A def x yield ensure y end def y end end # Work A.new.x do end # Not work # trace: # [2] /tmp/a.rb:5:in A.x # [0] /tmp/a.rb:15 # /tmp/a.rb:5: undefined method 'y' for main (NoMethodError) A.new.x do break end # trace: # [2] /tmp/a.rb:5:in A.call # [0] /tmp/a.rb:19 # /tmp/a.rb:5: undefined method 'y' for main (NoMethodError) lambda do A.new.x do return end end.call |
yieldで呼ばれたブロックの中からbreakまたはreturnすると、ensureの中のselfがおかしくなるという問題です。
特定のメソッドがおかしな結果を返すという類のものではないので、mruby本体の問題である可能性が高いです。
問題を再現するスクリプトはできたので次へ進みます。
mruby本体の問題である場合、mrubyをGDB上で実行し、内部状態を確認しながら原因を調べる必要があります。GDBで実行する場合、-g3オプション付きでビルドするとマクロも展開できて便利です。
以前は手動でtasks/toolchains/gcc.rakeを変更して-g3と-O0を追加していましたが、面倒になったのでpull requestを送って取り込んでもらいました。今はbuild_config.rbにenable_debugと書いておくと勝手に-g3と-O0オプションをつけてくれます。
なお、デフォルトのbuild_config.rbにはenable_debugが入っているので特に変更せずにビルドするだけでよいです。
mrbc -vでスクリプトの構文木(?)を確認GDBでどのあたりを確認すればよいかのアタリをつけるために、mrubyのVMがどのようにスクリプトを実行するかを確認します。
mrbc -v(またはmruby -v)でRubyスクリプトを実行すると構文木(?)*3が出力されます。
例えば、次のRubyスクリプトを入力にするとします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class A def x yield ensure y end def y end end A.new.x do break end |
これをmrbc -vに渡すと次のような出力になります。
mruby 1.1.0 (2014-11-19)
00001 NODE_SCOPE:
00001 NODE_BEGIN:
00001 NODE_CLASS:
00010 :A
00010 body:
00002 NODE_BEGIN:
00002 NODE_DEF:
00006 x
00006 NODE_ENSURE:
00006 body:
00003 NODE_BEGIN:
00003 NODE_YIELD:
00006 ensure:
00005 NODE_BEGIN:
00005 NODE_CALL:
00005 NODE_SELF
00005 method='y' (220)
00008 NODE_DEF:
00009 y
00009 NODE_BEGIN:
00012 NODE_CALL:
00012 NODE_CALL:
00012 NODE_CONST A
00012 method='new' (11)
00012 method='x' (219)
00014 args:
00014 block:
00012 NODE_BLOCK:
00014 body:
00013 NODE_BEGIN:
00013 NODE_BREAK:
irep 0xe7b640 nregs=3 nlocals=1 pools=0 syms=3 reps=2
file: /tmp/a.rb
1 000 OP_LOADNIL R1
1 001 OP_LOADNIL R2
1 002 OP_CLASS R1 :A
1 003 OP_EXEC R1 I(+1)
12 004 OP_GETCONST R1 :A
12 005 OP_SEND R1 :new 0
12 006 OP_LAMBDA R2 I(+2) 2
12 007 OP_SENDB R1 :x 0
12 008 OP_STOP
irep 0xe7bd70 nregs=3 nlocals=1 pools=0 syms=2 reps=2
file: /tmp/a.rb
2 000 OP_TCLASS R1
2 001 OP_LAMBDA R2 I(+1) 1
2 002 OP_METHOD R1 :x
8 003 OP_TCLASS R1
8 004 OP_LAMBDA R2 I(+2) 1
8 005 OP_METHOD R1 :y
8 006 OP_LOADSYM R1 :y
8 007 OP_RETURN R0 return
irep 0xe962d0 nregs=3 nlocals=2 pools=0 syms=1 reps=1
file: /tmp/a.rb
2 000 OP_ENTER 0:0:0:0:0:0:0
6 001 OP_EPUSH :I(+1)
3 002 OP_BLKPUSH R2 0:0:0:0
3 003 OP_SEND R2 :call 0
3 004 OP_EPOP 1
3 005 OP_RETURN R2 return
irep 0xe9c0f0 nregs=3 nlocals=1 pools=0 syms=1 reps=0
file: /tmp/a.rb
5 000 OP_LOADSELF R1
5 001 OP_SEND R1 :y 0
5 002 OP_RETURN R0 return
irep 0xe9d4d0 nregs=3 nlocals=2 pools=0 syms=0 reps=0
file: /tmp/a.rb
8 000 OP_ENTER 0:0:0:0:0:0:0
9 001 OP_LOADNIL R2
9 002 OP_RETURN R2 return
irep 0xe9d600 nregs=2 nlocals=1 pools=0 syms=0 reps=0
file: /tmp/a.rb
13 000 OP_RETURN R1 break
まずは以下のツリー状の方を確認します。
00001 NODE_SCOPE: ...
問題が発生するときに実行するコードは以下でした。
1 2 3 |
A.new.x do break end |
これに対応するサブツリーを見つけます。breakなどコードの中にあるキーワードに注目して見つけます。見つけやすいようにコードの中にリテラルを入れてもいいでしょう。
今回の場合は以下のサブツリーが対応します。
00012 NODE_CALL: 00012 NODE_CALL: 00012 NODE_CONST A 00012 method='new' (11) 00012 method='x' (219) 00014 args: 00014 block: 00012 NODE_BLOCK: 00014 body: 00013 NODE_BEGIN: 00013 NODE_BREAK:
今回はbreakが怪しいので次の部分に注目します。
00013 NODE_BEGIN: 00013 NODE_BREAK:
特に左側の13の部分に注目します。これに対応する数値を、ツリーの後に出力されている次の部分の中から見つけます。
irep 0xe7b640 nregs=3 nlocals=1 pools=0 syms=3 reps=2 ...
今回の場合は次の箇所です。
irep 0xe9d600 nregs=2 nlocals=1 pools=0 syms=0 reps=0 file: /tmp/a.rb 13 000 OP_RETURN R1 break
ここにあるOP_XXXに注目します。今回の場合はOP_RETURNです。これがわかったら次に進みます。
mruby XXX.rbを走らるGDB上で問題のあるスクリプトを実行します。
% gdb --args bin/mruby XXX.rb ... (gdb)
プログラムを実行する前にブレークポイントを設定します。
src/vm.cの中で問題のラベルがあるところにブレークポイントを設定するどこにブレークポイントを設定すればよいかというと、src/vm.cの中のOP_XXXがある場所です。
今回だと次の箇所です。
1440 CASE(OP_RETURN) {
1441 /* A B return R(A) (B=normal,in-block return/break) */
GDB上でブレークポイントを設定します。
(gdb) b vm.c:1440
ブレークポイントを設定したら実行します。
(gdb) r
ブレークポイントで止まるので後は問題があるデータを見つけて、正しいデータになるようにします。
なお、GDB上でC-x C-aを実行するとソースコード表示あり・なしを切り替えられるので必要に応じて使いわけます。出力したい内容が多い場合はソースコード表示をなしにして表示領域を広くとり、ステップ実行をする場合はソースコードを表示しながら流れを追いやすくします。
問題箇所の付近をステップ実行できるようになったので、mrb_p()でデータを確認しながら問題を見つけます。
(gdb) call mrb_p(mrb, v) Array
問題を見つけるやり方の1つは、正常なケースのスクリプトも用意して動作を比較する方法です。期待する結果がわかるので問題のあるデータを見つけやすくなります。
別のやり方は、再現スクリプトからわかった問題の値を探す方法です。今回の場合は次のようなエラーメッセージがでているため、selfがおかしくなっていることがわかります。
1 2 3 4 5 6 7 |
# trace: # [2] /tmp/a.rb:5:in A.x # [0] /tmp/a.rb:15 # /tmp/a.rb:5: undefined method 'y' for main (NoMethodError) A.new.x do break end |
問題があるデータを見つけたら後は直すだけです。
久しぶりにmrubyを直そうとするとやり方を忘れていたので後で思い出せるようにやり方をまとめました。
必要ならmruby本体の問題も修正できるので、mrubyをアプリケーションに組み込みたいので技術支援して欲しい、という場合はご相談ください。
「やりたい処理もできる」機能ではなく「やりたい処理用」の機能を使うことで、書いた人の意図が伝わるコードになるという話です。「動く」コードは書けるけど「意図が伝わる」コードはまだ書けない、という初級者向けの話です。
「動く」だけだとどんな機能を使って実装しても同じですが、「意図が伝わる」という観点では使う機能によって違いがあります。これは読む人が期待することとコードが意図することが異なるからです。
繰り返し機能が必要なケースを考えます。ここでは次のように1から5まで出力したいとします。
% ruby output-numbers.rb 1 2 3 4 5
Rubyには繰り返し機能を提供するeachというメソッド*1があります。これを使って実装すると「繰り返して処理をしたい」という「意図」が伝わるコードになります。
1 2 3 4 |
numbers = [1, 2, 3, 4, 5] numbers.each do |number| puts(number) end |
Rubyには「繰り返し」機能と「それぞれの繰り返しの結果を集める」機能を提供するmapというメソッド*2もあります。mapにも「繰り返し」機能があるので、mapを使っても「動く」コードを書けます。
1 2 3 4 |
numbers = [1, 2, 3, 4, 5] numbers.map do |number| puts(number) end |
これら2つのコードは同じ「動き」になりますが、「意図が伝わる」という点では違います。
mapには「それぞれの繰り返しの結果を集める」機能があるので、プログラムを読む人は「mapで集めた結果をどう使うのだろう」と考えながら読みます。しかし、このプログラムではmapの「それぞれの繰り返しの結果を集める」機能を使っていないので、次のコードのようにmapの結果を代入していません。
1 2 3 |
converted_numbers = numbers.map do |number| puts(number) end |
今回のコードは単に結果を捨てています。
mapのことを知っている注意深く読む人*3ならここで次のように考えます。
「それぞれの繰り返しの結果を集める」機能を提供する
mapの結果を使っていないということは…これは代入忘れのバグじゃないか?
しかし、コード全体を読んでみるとmapの「それぞれの繰り返しの結果を集める」機能は使っていなくて、単に「繰り返し」機能だけを使っていることがわかります。そしてこう思います。
なんだ、「それぞれの繰り返しの結果を集める」機能を使っていないのか。じゃあ、バグじゃないか。まぎらわしいな。。。
mapではなく「繰り返し」機能だけを提供するeachを使っていれば、読む人はまぎらわしく思わずに書いた人が何をしたかったかを理解できます。このようなコードが「意図が伝わる」コードです。
「意図が伝わる」コードは読む人が理解する時間が短くなりますし、間違って理解されにくくなります。これはコードの修正や改良に役に立ちます。多くのコードは一度書いたら終わりではなく、動くようになったあとにメンテナンスされます。そのため、修正や改良に役立つことは重要です。
「動く」コードを書けるようになったら「意図が伝わる」コードを目指してください。
eachとmapを例にして「動く」だけのコードと「意図が伝わる」コードの違いを説明しました。ちなみに、eachでよいところにmapを使っているコードはわりとよく見るコードです。いつもの癖でmapを使ったり、最初は「それぞれの繰り返しの結果を集める」機能が必要だったけど途中で必要なくなったのにmapを使い続けてしまっている、ということなのかもしれません。
「動く」コードを書けるようになったら、「動く」だけではなく「意図が伝わる」コードを目指してください。「意図が伝わる」コードは改良や修正などメンテナンスがしやすいからです。「とりあえず動くもの」の次を目指すときに参考にしてください。
あわせて読みたい:
お知らせ: