Ruby
debug
新人プログラマ応援
プロを目指す人のためのRuby入門

二分探索法でコードの構文エラーの箇所を特定する

はじめに

プログラミング中に発生したエラーやバグを解決するポピュラーな手法の一つに「二分探索法」があります。

二分探索というのは、もともとソート済み配列に対する探索アルゴリズムの一つ(Wikipedia)ですが、同じ考え方をデバッグに適用することも出来ます。

すなわち、問題が起きるソースコードを真ん中で2つのグループに分け、どちらのグループで問題が起きるのかを特定したら、問題が起きている方のグループをさらに2つにわけて・・・というように繰り返して、問題が起きている箇所を絞り込むという手法です。

この記事では構文エラー(syntax error)が発生するRubyのコードを題材にして、二分探索法でデバッグする方法を紹介します。

具体的な手順

あなたはRSpec(Ruby向けのテスティングフレームワーク)でテストコードを書いていました。
ひととおりテストコードを書き終わり、「よし、じゃあテストを実行してみるか」と思い、次のコマンドを実行しました。
すると・・・。

$ bundle exec rspec

An error occurred while loading ./spec/calc_tax_sandbox/item_spec.rb.
Failure/Error: __send__(method, file)

SyntaxError:
  /Users/jnito/dev/sandbox/calc_tax_sandbox/spec/calc_tax_sandbox/item_spec.rb:73: syntax error, unexpected end-of-input, expecting keyword_end
(省略)

Finished in 0.00016 seconds (files took 2.91 seconds to load)
0 examples, 0 failures, 1 error occurred outside of examples

Oops! 上のようなエラーが発生してしまい、テストが実行できませんでした。
エラー内容を見るとitem_spec.rb:73: syntax error, unexpected end-of-input, expecting keyword_endというメッセージが見えるので、どうやら構文エラー(syntax error)が発生していることが予想されます。

ただし、item_spec.rbの73行目はこのテストコードの最終行になっています。

      end
    end
  end
end # <= 73行目

73行目付近を見ても特に問題なさそうなので、おそらく構文エラーは別の場所で起きているのだと思われます。
そこで、今から二分探索法を使って構文エラーの原因を探していきます。

修正前のコード

今回問題が起きたitem_spec.rbは次のようなコードになっています。
この中のどこかに構文エラーが隠れています。

require 'date'

RSpec.describe CalcTaxSandbox::Item do
  describe '#price_with_tax' do
    subject { item.price_with_tax(on: date, **options) }
    let(:options) { {} }
    let(:date_20190930) { Date.new(2019, 9, 30) }
    let(:date_20191001) { Date.new(2019, 10, 1) }
    context '食品でも新聞でもない場合' do
      let(:item) { CalcTaxSandbox::Item.new('プロを目指す人のためのRuby入門', 2980) }
      context '税率変更前の場合' do
        let(:date) { date_20190930 }
        it { is_expected.to eq 3218 }
      end
      context '税率変更後の場合' do
        let(:date) { date_20191001 }
        it { is_expected.to eq 3278 }
      end
    end
    context '食品だった場合' do
      context '酒類だった場合' do
        let(:item) { CalcTaxSandbox::Food.new('ビール', 300, alcohol: true) }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 324 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 330 }
        end
      end
      context '酒類でなかった場合' do
        let(:item) { CalcTaxSandbox::Food.new('ハンバーガー', 300) }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 324 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 324 }
          context '外食だった場合' do
            let(:options) { { eating_out: true } }
            it { is_expected.to eq 330 }
          end
        end
      end
    end
    context '新聞の定期購読だった場合' do
      let(:item) { CalcTaxSandbox::NewspaperSubscription.new('Ruby新聞', 5000, per_week: per_week) }
      context '週1回発行の場合' do
        let(:per_week) { 1 }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 5400 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 5500 }
      end
      context '週2回発行の場合' do
        let(:per_week) { 2 }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 5400 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 5400 }
        end
      end
    end
  end
end

この時点で「あっ、これが原因じゃん」とわかった人はいいのですが、みなさんはどうでしょうか?
とりあえず、この記事では目視では原因が特定できなかったものとして説明を続けます。

(意味のある単位で)コードを二分割して、片方をコメントアウトする

二分探索の基本は「真ん中で分割すること」ですが、ソースコードの場合は単純にど真ん中で分割するとその時点で構文エラーが発生してしまいます。
なので、「エラーが起きないことが明らかなコードの切れ目」でコードを二分割し、片方をコメントアウトします。

今回はまずcontext '新聞の定期購読だった場合' doから下(doに対応するendまで)をコメントアウトしてみます。

require 'date'

RSpec.describe CalcTaxSandbox::Item do
  describe '#price_with_tax' do
    subject { item.price_with_tax(on: date, **options) }
    let(:options) { {} }
    let(:date_20190930) { Date.new(2019, 9, 30) }
    let(:date_20191001) { Date.new(2019, 10, 1) }
    context '食品でも新聞でもない場合' do
      let(:item) { CalcTaxSandbox::Item.new('プロを目指す人のためのRuby入門', 2980) }
      context '税率変更前の場合' do
        let(:date) { date_20190930 }
        it { is_expected.to eq 3218 }
      end
      context '税率変更後の場合' do
        let(:date) { date_20191001 }
        it { is_expected.to eq 3278 }
      end
    end
    context '食品だった場合' do
      context '酒類だった場合' do
        let(:item) { CalcTaxSandbox::Food.new('ビール', 300, alcohol: true) }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 324 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 330 }
        end
      end
      context '酒類でなかった場合' do
        let(:item) { CalcTaxSandbox::Food.new('ハンバーガー', 300) }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 324 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 324 }
          context '外食だった場合' do
            let(:options) { { eating_out: true } }
            it { is_expected.to eq 330 }
          end
        end
      end
    end
    # context '新聞の定期購読だった場合' do
    #   let(:item) { CalcTaxSandbox::NewspaperSubscription.new('Ruby新聞', 5000, per_week: per_week) }
    #   context '週1回発行の場合' do
    #     let(:per_week) { 1 }
    #     context '税率変更前の場合' do
    #       let(:date) { date_20190930 }
    #       it { is_expected.to eq 5400 }
    #     end
    #     context '税率変更後の場合' do
    #       let(:date) { date_20191001 }
    #       it { is_expected.to eq 5500 }
    #   end
    #   context '週2回発行の場合' do
    #     let(:per_week) { 2 }
    #     context '税率変更前の場合' do
    #       let(:date) { date_20190930 }
    #       it { is_expected.to eq 5400 }
    #     end
    #     context '税率変更後の場合' do
    #       let(:date) { date_20191001 }
    #       it { is_expected.to eq 5400 }
    #     end
    #   end
    # end
  end
end

これでテストコードを実行してみましょう。

$ bundle exec rspec
..........

Finished in 0.01115 seconds (files took 0.15142 seconds to load)
10 examples, 0 failures

おお、ちゃんとテストが動きました!
ということは、問題は今回コメントアウトした側に隠れている可能性が高いです。

確認のため、上半分をコメントアウトして下半分だけを残してみましょう。

require 'date'

RSpec.describe CalcTaxSandbox::Item do
  describe '#price_with_tax' do
    # subject { item.price_with_tax(on: date, **options) }
    # let(:options) { {} }
    # let(:date_20190930) { Date.new(2019, 9, 30) }
    # let(:date_20191001) { Date.new(2019, 10, 1) }
    # context '食品でも新聞でもない場合' do
    #   let(:item) { CalcTaxSandbox::Item.new('プロを目指す人のためのRuby入門', 2980) }
    #   context '税率変更前の場合' do
    #     let(:date) { date_20190930 }
    #     it { is_expected.to eq 3218 }
    #   end
    #   context '税率変更後の場合' do
    #     let(:date) { date_20191001 }
    #     it { is_expected.to eq 3278 }
    #   end
    # end
    # context '食品だった場合' do
    #   context '酒類だった場合' do
    #     let(:item) { CalcTaxSandbox::Food.new('ビール', 300, alcohol: true) }
    #     context '税率変更前の場合' do
    #       let(:date) { date_20190930 }
    #       it { is_expected.to eq 324 }
    #     end
    #     context '税率変更後の場合' do
    #       let(:date) { date_20191001 }
    #       it { is_expected.to eq 330 }
    #     end
    #   end
    #   context '酒類でなかった場合' do
    #     let(:item) { CalcTaxSandbox::Food.new('ハンバーガー', 300) }
    #     context '税率変更前の場合' do
    #       let(:date) { date_20190930 }
    #       it { is_expected.to eq 324 }
    #     end
    #     context '税率変更後の場合' do
    #       let(:date) { date_20191001 }
    #       it { is_expected.to eq 324 }
    #       context '外食だった場合' do
    #         let(:options) { { eating_out: true } }
    #         it { is_expected.to eq 330 }
    #       end
    #     end
    #   end
    # end
    context '新聞の定期購読だった場合' do
      let(:item) { CalcTaxSandbox::NewspaperSubscription.new('Ruby新聞', 5000, per_week: per_week) }
      context '週1回発行の場合' do
        let(:per_week) { 1 }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 5400 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 5500 }
      end
      context '週2回発行の場合' do
        let(:per_week) { 2 }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 5400 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 5400 }
        end
      end
    end
  end
end

この状態でテストを実行するとこうなります。

$ bundle exec rspec

An error occurred while loading ./spec/calc_tax_sandbox/item_spec.rb.
Failure/Error: __send__(method, file)

SyntaxError:
  /Users/jnito/dev/sandbox/calc_tax_sandbox/spec/calc_tax_sandbox/item_spec.rb:73: syntax error, unexpected end-of-input, expecting keyword_end
(省略)

Finished in 0.00017 seconds (files took 0.24959 seconds to load)
0 examples, 0 failures, 1 error occurred outside of examples

うむ、やはりエラーが発生しますね🤔
ということはつまり、構文エラーがcontext '新聞の定期購読だった場合' doから下の部分に隠れているわけです。

さらに分割して実行してみる

あとは同じ作業の繰り返しになるのですが、念のため説明しておきましょう。
次はcontext '新聞の定期購読だった場合' doの中を半分に分けて上半分をコメントアウトします。

require 'date'

RSpec.describe CalcTaxSandbox::Item do
  describe '#price_with_tax' do
    # subject { item.price_with_tax(on: date, **options) }
    # let(:options) { {} }
    # let(:date_20190930) { Date.new(2019, 9, 30) }
    # let(:date_20191001) { Date.new(2019, 10, 1) }
    # context '食品でも新聞でもない場合' do
    # (省略)
    # end
    context '新聞の定期購読だった場合' do
      # let(:item) { CalcTaxSandbox::NewspaperSubscription.new('Ruby新聞', 5000, per_week: per_week) }
      # context '週1回発行の場合' do
      #   let(:per_week) { 1 }
      #   context '税率変更前の場合' do
      #     let(:date) { date_20190930 }
      #     it { is_expected.to eq 5400 }
      #   end
      #   context '税率変更後の場合' do
      #     let(:date) { date_20191001 }
      #     it { is_expected.to eq 5500 }
      # end
      context '週2回発行の場合' do
        let(:per_week) { 2 }
        context '税率変更前の場合' do
          let(:date) { date_20190930 }
          it { is_expected.to eq 5400 }
        end
        context '税率変更後の場合' do
          let(:date) { date_20191001 }
          it { is_expected.to eq 5400 }
        end
      end
    end
  end
end

この状態で実行するとこうなります。

$ bundle exec rspec
FF...

Failures:

  1) CalcTaxSandbox::Item#price_with_tax 新聞の定期購読だった場合 週2回発行の場合 税率変更前の場合 
     Failure/Error:
       def initialize(name, price)
         @name = name
         @price = price
       end

     ArgumentError:
       wrong number of arguments (given 0, expected 2)
     # ./lib/calc_tax_sandbox/item.rb:7:in `initialize'
     # ./spec/calc_tax_sandbox/item_spec.rb:64:in `block (6 levels) in <top (required)>'

  2) CalcTaxSandbox::Item#price_with_tax 新聞の定期購読だった場合 週2回発行の場合 税率変更後の場合 
     Failure/Error:
       def initialize(name, price)
         @name = name
         @price = price
       end

     ArgumentError:
       wrong number of arguments (given 0, expected 2)
     # ./lib/calc_tax_sandbox/item.rb:7:in `initialize'
     # ./spec/calc_tax_sandbox/item_spec.rb:68:in `block (6 levels) in <top (required)>'

Finished in 0.0047 seconds (files took 0.14964 seconds to load)
5 examples, 2 failures

Failed examples:

rspec ./spec/calc_tax_sandbox/item_spec.rb:64 # CalcTaxSandbox::Item#price_with_tax 新聞の定期購読だった場合 週2回発行の場合 税率変更前の場合 
rspec ./spec/calc_tax_sandbox/item_spec.rb:68 # CalcTaxSandbox::Item#price_with_tax 新聞の定期購読だった場合 週2回発行の場合 税率変更後の場合 

テストは失敗するのですが、エラーメッセージが変わっていますね。
これは「テストは実行できたが、その実行結果がエラーになった」ということを意味しています。
つまり、構文エラーは発生していません。

かなり絞り込めたので、そろそろ原因を特定する

というわけで、構文エラーが発生しているのは先ほどコメントアウトした部分に隠れているはずです。
コメントアウトしたのはこんなコードでした。

let(:item) { CalcTaxSandbox::NewspaperSubscription.new('Ruby新聞', 5000, per_week: per_week) }
context '週1回発行の場合' do
  let(:per_week) { 1 }
  context '税率変更前の場合' do
    let(:date) { date_20190930 }
    it { is_expected.to eq 5400 }
  end
  context '税率変更後の場合' do
    let(:date) { date_20191001 }
    it { is_expected.to eq 5500 }
end

どうですか?上のコードをじ〜〜〜っ 👀 と見ていると・・・あっ!

context '税率変更後の場合' doのブロックを終了するendが抜けていますね!!(下から2行目)

let(:item) { CalcTaxSandbox::NewspaperSubscription.new('Ruby新聞', 5000, per_week: per_week) }
context '週1回発行の場合' do
  let(:per_week) { 1 }
  context '税率変更前の場合' do
    let(:date) { date_20190930 }
    it { is_expected.to eq 5400 }
  end
  context '税率変更後の場合' do
    let(:date) { date_20191001 }
    it { is_expected.to eq 5500 }
  end # <= このendが抜けていた!!
end

きっとこれが構文エラーの原因だったに違いありません。

コードを修正して問題が解消されたことを確認する

というわけで、構文エラーの原因となっていたendをちゃんと入力して再度テストコードを実行してみます。

$ bundle exec rspec
..............

Finished in 0.0081 seconds (files took 0.15567 seconds to load)
14 examples, 0 failures

期待どおりちゃんと実行できました!
よって、これにてデバッグは完了です!
めでたしめでたし😄

まとめ

今回の記事では構文エラーを対象にしましたが、二分探索法の考え方はデバッグのさまざまな場面で活用することができます。

プログラミング初心者の方はぜひ二分探索法をご自身の「デバッグの道具箱」に入れておきましょう👍

あわせて読みたい

こちらの記事でもデバッグの基本を紹介しています。

プログラミング初心者歓迎!「エラーが出ました。どうすればいいですか?」から卒業するための基本と極意(解説動画付き) - Qiita

拙著「プロを目指す人のためのRuby入門」でも第11章でRubyのデバッグ技法を紹介しています。

プロを目指す人のためのRuby入門 言語仕様からテスト駆動開発・デバッグ技法まで:書籍案内|技術評論社

第11章 Rubyのデバッグ技法を身につける
11.1 イントロダクション
11.2 バックトレースの読み方
11.3 よく発生する例外クラスとその原因
11.4 プログラムの途中経過を確認する
11.5 汎用的なトラブルシューティング方法
11.6 この章のまとめ

今回使用したサンプルコードについて

この記事で使用したitem_spec.rbは、消費税の軽減税率制度の計算ルールをシミュレートした以下のサンプルコードを流用しました。

https://github.com/JunichiIto/calc_tax_sandbox