名古屋で数学するプログラマ(仮)

@antimon2 が趣味兼一部本職の数学で何かするときのブログ。

「yieldのお勉強 Lv.1」解説(Ruby編) #CodeIQ

CodeIQ 出題者デビュー問題、公開終了となりました!
たくさんの挑戦、ありがとうございます。

(すでに問題の公開は終了していますので、上記 URL で問題詳細を見ることはできません)

問題文は省略します(挑戦者だけの特典♪)が、問題は、一部が未実装のプログラムを実装してテストを全て通るようにする、というもの。
以下に、問題プログラムと解答例を示します。

【2014/08/20 23:45 追記:解説補足記事 公開しました】

問題プログラムと解答例

まずは、問題プログラム。

問題プログラム:

AnswerQ1 クラスの drop メソッドが空っぽの定義になっています。
これを、テストが通るように実装する、というわけです。
テストで要求しているのは、大体次の通り:

  • each メソッドと同様に、ブロックを受け取って要素を列挙できる(ブロック付きメソッドである)。
  • each メソッドで列挙される要素のうち、先頭の n 個(第1引数)だけ除去する。
  • ブロックを指定せずに、 .first 等のメソッドチェインが出来る。

私の用意していた解答例を示します。

解答例1:

解答例2:

解答例1 は、言語依存度の少ない、シンプル・丁寧でかつパフォーマンスも十分なコード。
解答例2 は、Lv.2問題 で紹介しているEnumerator#nextメソッドを利用し、コンパクトにまとめたコード。

他にも方法はあります。例えば、解答例1の派生で、以下のようなコードを送ってくださった挑戦者さまもいらっしゃいます(drop メソッドの定義部分のみ抽出):

  # Mu様の解答(抜粋)
  def drop n
    return to_enum(:drop, n) unless block_given?
    each do |x|
      n > 0 ? (n -= 1) : (yield x)
    end
  end

仮引数 n をそのまま利用し(他の変数を使用せず)、また三項間演算子(〜?〜:〜)を用いることで非常にコンパクトにまとまっています。

また他によく見られたのが、以下のような解答(同じく drop メソッドのみ):

  # tbpgr様の解答(抜粋)
  def drop n
    return to_enum(:drop, n) unless block_given?
    each.with_index do |e, i|
      next if i < n
      yield e
    end
  end

Enumerator#with_index を利用して、要素とそのインデックス値を同時に列挙しながら処理をする、というのは Ruby でよく使われる手法です。
また next if 〜 という書き方(後置 if)も Ruby 特有の文法(他の言語にもありますが)で、全体的に Ruby らしさが見えるコードとなっています。

解説

改めて説明しますと、求めていたのは、「同じクラスで定義された each メソッドが列挙する要素のうち、最初の n 個(第1引数)を除去して残りを列挙するメソッドを定義すること」でした。

なお、Ruby に慣れ親しんでいる人の中には、同名のメソッドが既に Enumerable モジュールに用意されていることをご存じの方もいらっしゃるかもしれません。
ただしそれは、要素数が有限であることが前提で、戻り値は配列となっています。
今回はそうではなく、「each メソッドと同様に、ブロックを渡して列挙した値を受け取れるメソッド」を要求していたわけです。
ちなみに、Ruby で「ブロックを受け取るメソッド」のことを「ブロック付きメソッド」と呼びます*1

また、そのままブロックを受け取って列挙できるだけではなく、
「ブロックを指定せずにそのままメソッドチェインで Enumerable モジュール(もしくは Enumerator クラス)のメソッドを実行できるようにする」
という仕様も暗黙的に求めていました(分かりにくくてスミマセン)。
これは、Ruby でブロック付きメソッドを定義するとき、通常ですとブロックを指定せずに実行するとエラー(LocalJumpError)が発生してしまうのを、防ぐと共に便利で直感的な使い方が出来るように、という工夫の元に生まれた、ある意味の常套手段です。

ここがポイント!
解答例のコードはいずれも、以下の2つの構成要素に分かれています:

  • ブロックが渡されなかったら、そのメソッド(と引数)を to_enum メソッドに渡して Enumerator オブジェクトに変換したものを返却して終了(1行目)
  • メソッド本来の処理の定義(2行目以降)

一番のポイントは、to_enum メソッドの使い方。その定義を、Lv.2問題 の問題文で桜先生が解説しています(以下、引用):

class Object
  def to_enum method=:each, *args
    # method …`Enumerator` 化するメソッド名(Symbol)、省略時のデフォルト値は `:each`
    # args …メソッド `meth_name` に渡す引数群(配列渡しではなく`,`でそのまま列挙)
    # ※ method, args 両方省略した場合は、『each メソッドを引数なしで利用する』という意味になる
  end
end

このことを知らなかった、またLv.2の問題を未読で挑戦された方でも、この問題でも each メソッドで既に使用されているので、この to_enum についてご自分で調べられ、この仕様を的確に把握して使用されている挑戦者様もいらっしゃいました。
出題側としても、それを期待していたので、そのように回答してくださった方が多かったのは嬉しかったです(^-^)

またこの書き方にたどり着けなかった挑戦者の方々のためにも、もう一度別の角度から、この書き方の利点についてまとめておきます。
たどり着けなかった方の多くは、メソッド内で block_given? の値で条件分岐して、ブロックが与えられた場合とそうでない場合との処理を分けていました。でも。実は処理を分ける必要なんてないのです。
解答例を見てください。block_given? は最初の行だけで、後は「列挙の処理をするコード」だけが並んでいますよね?つまり。

  • ブロックを受け取った時と省略した時とで、同じコードを再利用できる(処理を分ける必要はない)。
  • 1行目に return to_enum 〜 unless block_given? を書いておけば、あとは繰り返し・列挙の仕様だけ気にしてコーディングすれば良い。

これを覚えておいてください。

考察・出題者としての反省(Ruby編)

まず、テストケース少なすぎた(>_<)
これは出題者として大いに反省すべき点です。
ま、「全然考えなしにただテストケース通ったから提出した」っていう「全くこちらの意図・想定していなかった別解」というのは全くなかったので、それだけは救われた感じです。
ただ、こちらの本来の意図したものではない、「to_enum についてちゃんと調べて、ブロックが与えられなかったら Enumerator を返すように実装されたコード」でないものは、(ある程度は覚悟していたものの想像以上に)多数寄せられてしまいました。
これに関しては今回は「テストケースが全てパスすること」を正解の最優先条件としていたため、正解という扱いにしましたが、正直、失敗(>_<)

なおそのような方のほとんどは、
「each で列挙した要素をいくつか(5個 もしくは n個 がほとんどでした)配列に格納して、ブロックが与えられなければその配列を返却し、与えられていればそれを列挙する」
というコードになっていました。
3番目のテストケースの .first を「配列の最初の要素を返すメソッド」と読み取った模様です。
ちなみに .first.take(n) 等も、配列(Array)だけでなく、Enumerable モジュールに定義されているメソッドです。

ただそのような解答を寄せていただいた挑戦者さまには漏れなく、「ではこういう場合はどうでしょう?」と追加のコード(テストコードの場合もあり)を示して、「to_enum を使うと、こういった場合にも対応出来てしかももっとコンパクトな実装に出来ますよ」とアドバイスをお送りしています。
ちなみに追加コード(テストコード)の例は、例えば以下:

    # 11番目から110番目までの(100個の)フィボナッチ数列を取得するテスト
    def test_fib_11_110
      expected = [89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040, 1346269, 2178309, 3524578, 5702887, 9227465, 14930352, 24157817, 39088169, 63245986, 102334155, 165580141, 267914296, 433494437, 701408733, 1134903170, 1836311903, 2971215073, 4807526976, 7778742049, 12586269025, 20365011074, 32951280099, 53316291173, 86267571272, 139583862445, 225851433717, 365435296162, 591286729879, 956722026041, 1548008755920, 2504730781961, 4052739537881, 6557470319842, 10610209857723, 17167680177565, 27777890035288, 44945570212853, 72723460248141, 117669030460994, 190392490709135, 308061521170129, 498454011879264, 806515533049393, 1304969544928657, 2111485077978050, 3416454622906707, 5527939700884757, 8944394323791464, 14472334024676221, 23416728348467685, 37889062373143906, 61305790721611591, 99194853094755497, 160500643816367088, 259695496911122585, 420196140727489673, 679891637638612258, 1100087778366101931, 1779979416004714189, 2880067194370816120, 4660046610375530309, 7540113804746346429, 12200160415121876738, 19740274219868223167, 31940434634990099905, 51680708854858323072, 83621143489848422977, 135301852344706746049, 218922995834555169026, 354224848179261915075, 573147844013817084101, 927372692193078999176, 1500520536206896083277, 2427893228399975082453, 3928413764606871165730, 6356306993006846248183, 10284720757613717413913, 16641027750620563662096, 26925748508234281076009, 43566776258854844738105]
      result = @fib.drop(10).take(100)
      assert_equal(expected, result)
    end

さすがにこれで「5個(n個)でだめなら100個配列に格納して返すようにしました」って再提出してきた人はいなかったので、ほっと一安心。

あと、1行目を return to_enum 〜 unless block_given? にする方法で、なんでうまくいくのかまだよく分からない、という方もけっこういらっしゃいました。
そういう方は、やはり「メソッドチェインするとき(=ブロックを指定しなかったとき)は、配列のようなものが返ってきている」となんとなく想像している方がほとんどのようです。

(to_enum で生成される)Enumerator オブジェクトは、配列のような側面もありますが、「配列とは全然別物」と考えていただいた方が良いです。
より正確に言うと、Enumerator オブジェクトというのは、
「値を列挙する方法だけを定義して、それを元に必要な値をその都度算出して列挙するもの」
です。
to_enum は、その「列挙する方法」として、メソッド(と引数)を設定して Enumerator オブジェクトを生成しています。
ポイントは、ここで生成した時点では「具体的な要素はまだ算出されていない」という点です。
そして、Enumerator オブジェクトの each メソッド(や next メソッド等)が呼ばれた時点で、初めて元のメソッドが呼び出されて、必要な値だけを算出して列挙するのです。
だから、無限のリストのようになっていても、その場ではエラーにはなりません*2

また、終了条件もメソッド内に書く必要はありません。
「その Enumerator を利用する側」が「もうこれ以上要らない!」と思ったときに中断すれば良いのです。
.first メソッドで最初だけ取得したり、.take(n) メソッドで最初の n 個だけ取得したり、といったことがこれに当てはまります。
それだけで、あとは Ruby が内部で適切に処理を中断してくれるのです。

Lv.1 なのに、けっこう高度な技を要求されていた、ような気がしてきましたか?
でも、簡単な記述で実は割と高度なことができちゃう、この書き方を覚えておけば、けっこう世界が拡がると思います(^-^)

コメント紹介(抜粋)

お寄せいただいたコメントを一部紹介、およびこの場を借りて返信いたします。

(tbpgr様)
出題デビューおめでとうございます!楽しみが増えました。

⇒ありがとうございます(^-^)
 これからもo(^▽^)oります。

(maehrm様)
Rubyの学習を始めたときから,ブロック, yieldについては,なんとなく苦手というか…。
《中略》
今回,問題に挑戦してみて,少しだけですが苦手意識が和らいだ気がします。

⇒そう言っていただけるとウレシイです(^-^)

(にくも様)
Rubyは自由度が高い印象がありましたが、自由度高いなりの制約があるのかな?と思いました。

⇒これは、Python と比べて、ということなのでしょうか?
 一言で言えば、「PythonyieldRubyyield は別物です」。
 詳しくは…この記事でも書きたかったのですが長くなるので日を改めて記事に起こしますm(_ _)m

(福田摂津守様)
最初は問題の意味すら理解できませんでした。

(yakipote様)
テストケースの説明がないので、はじめなにを求められているのかわからなかった

⇒ちょっと不親切な部分がありました。ゴメンナサイ。

(noriok様)
楽しい問題をありがとうございました。Rubyでもっとプログラムを書きたくなりました。

(beforelpkj様)
楽しかったです!

⇒楽しんでいただけて何よりです(^-^)こちらこそ、ありがとうございます(^-^)

(suppy193様)
テスト駆動開発の演習はしたことはありますが、実践は初めてでした。
テストが通ったときは爽快ですね。

⇒今回、何気に unittest の使い方の練習にもなった、という方が他にもいらっしゃいました。
 こういった単機能のメソッドのテストには手軽で有効だと思います(^-^)
 どんどん活用してみてください。

(beforelpkj様)
丁寧なコメントを下さりありがとうございました!!自分のコードにコメントをいただくのはこれが初めてでして大変感激しております。とても勉強になりました。

(suppy193様)
頂いたフィードバックを読んで、すぐに解答が見つかりました。
適切な内容をありがとうございます。

⇒いずれも、2回目の再挑戦時のコメントです。
 こちらこそ、丁寧なお返事、ありがとうございます(^-^)励みになりますo(^▽^)o

(pocari様)
普段eachやdropは使ってばかりなので、自分で実装してみると面白かったです。

(alluser様)
めっちゃ勉強になります!

(ryosy383様)
RubyはいつもRailsで使用しているので、yieldはRailsじゃない場合にこういう動作をするんだと改めて知って勉強になりました!

(oda1979様)
yieldを使ったことがなかったので勉強になりました。
ありがとうございました。

(angelhalo様)
Enumeratorの勉強になりました!

⇒o(^▽^)o

*1:古くは「イテレータ」とも呼んでいましたが、必ずしも「繰り返す」ことが目的ではないので、正式名称としては現在はイテレータとは言いません

*2:このように「値が必要になってから算出して列挙する構造」のことを「遅延リスト」とか「ストリーム」などと呼びます。