おもこん

おもこんは「思いつくままにコンピュターの話し」の省略形です

徒然Ruby(25)minitestのmockの詳細

minitestについて連続して2回書いてきました。 「minitestはドキュメントが少ない」という人がいますが、私も同感です。 例えば、モックとスタブの説明も少ないです。 そこで、今回はmock.rbのソースコードを参考に、モックの私的ドキュメントを書いてみました。 あくまで私個人の考えであり、minitest作成者の意図とは何の関係もありませんので、あらかじめご了解ください。

デリゲータ

デリゲータ(delegator)は「委任者、委任する人」ということなので「モックに処理を委任するオブジェクト」という意味ではないかと思います。 デリゲータはモックを生成するときに、newコマンドの引数として与えます。

require 'minitest/mock'

# delegator
m = Minitest::Mock.new("Hello world")
# m is a mock
p m #=> <Minitest::Mock:0x00007f809dedab50 @delegator="Hello world", @expected_calls={}, @actual_calls={}>
# Because m (mock) uses the delegator's method, m.display is the same as "Hello.world".display
m.display #=> Hello world
print "\n"
print m+"\n" #=> Hello world\n
# Because m has its own to_s method, m.to_s is NOT "Hello world".to_s
print m, "\n" #=> <Minitest::Mock:0x00007f8130db2c08>

m.expect(:size, 1000)
print m.size, "\n" #=> 1000, the size method is defined by m.expect.
print m.length, "\n" #=> 11, which is the real length of "Hello world"
p m #=> <Minitest::Mock:0x00007f264bd96e30 @delegator="Hello world", 
    # @expected_calls={:size=>[{:retval=>1000, :args=>[], :kwargs=>{}}]},
    # @actual_calls={:size=>[{:retval=>1000, :args=>[], :kwargs=>{}}]}>
p m.verify #=> true
# m.size #=> Error: No more expects available for :size

minitest/mockを取り込んでおきます。 mには文字列オブジェクト"Hello world"をデリゲータとするモックを代入します。

  • p mでモックのインスタンス変数@delegatorに"Hello world"がセットされていることがわかる
  • displayメソッドはObjectクラスのメソッドで、自分自身を標準出力に(to_sメソッドで文字列化して)出力する
  • モックはほとんどのメソッドをundef(未定義状態にする)していて、displayメソッドも持っていない
  • そのときはモックはデリゲータのメソッドとして実行する。 したがって、m.display"Hello world".displayを実行し、標準出力にHello worldが出力される
  • モックは+メソッドも持っていないので、m+"\n"はデリゲータの+メソッド("Hello world"+"\n")を実行する
  • モックはto_sメソッドを自身のメソッドとして持っているので、m.to_sはデリゲータを使わず、自身を文字列化する

ここまでで、要するにモックはデリゲータのほとんどのメソッドを引き継いでいることが分かると思います。

モックがexpectメソッドで「みせかけのメソッド」を定義するとき、そのメソッドがデリゲータのメソッドと同一名であれば、expectの定義を優先します。 後半を見ていきましょう

  • モックmにsizeメソッドが1000を返すように、expectメソッドで定義
  • m.sizeは1000を返す。 mはデリゲータのsizeメソッド(こちらは文字数の11になる)は使わず、expectの定義を優先した
  • m.lengthではモック自身はlengthメソッドを持たないので、デリゲータの"Hello world".lengthを実行し、11を返す
  • p mでモックの内容を表示すると、@expected_callsと@actual_callsの配列要素に、expectでの定義とm.sizeの実行それぞれの返り値と引数が記録されている
  • m.verifyでモックにおけるexpectされたメソッドが実行されたのでtrueが返された
  • 最後の一行はコメントされているが、仮にコメントアウトして実行するとエラー(フェイル)になる。 これはexpectが1回で、呼び出し2回目ということで、expectされていないので実行できない、というエラー

以上の機能からするとデリゲータとモックはどういう関係なのでしょうか?

モックはデリゲータをラップする。 デリゲータのメソッドのうち、テストで用いたいメソッドだけexpectでセットし、それ以外はそのまま実行させる

つまり、モックに置き換えたい元のオブジェクトがデリゲータだと考えられます。

メソッド呼び出し時のチェック

引数のチェック

expectでは3番めの引数が、定義するメソッドのパラメータです。

モック.expect(メソッド名, 返り値, パラメータの配列)

expectで定義されたメソッドはそのメソッドの呼び出し時にチェックされます。 チェックするのは

  • expectで定義された回数より多くそのメソッドが呼び出された(No more expects available for メソッド名)
  • expectで定義されたパラメータの数とメソッド呼び出し時の引数の数が一致するか
  • expectで定義されたパラメータとメソッド呼び出し時の引数のタイプが一致するか(===または==が成り立つかどうか)。 例えばexpectでStringのパラメータを定義し、呼び出し時に"abc"が引数であれば、String==="abc"はtrueになる。 ===はClassクラスで定義されていて、引数がそのクラスのインスタンスまたはサブクラスのインスタンスならばtrueになる。 StringクラスはClassクラスのインスタンスなので===が定義されている。 なお、"abc"===Stringはfalseになる。 文字列クラスのインスタンスメソッドとして===が再定義されているためで、文字列インスタンス=====は同じ
# arguments
m.expect(:concat, "Hello, folks.", [String, Integer])
print m.concat("Foo", 100), "\n" #=> Hello, folks.
m.expect(:concat, "Hello, there.", [Integer])
print m.concat("abc"), "\n" #=> Error :concat called with unexpected arguments
m.expect(:concat, "Hello, there.", [String, String])
print m.concat("abc"), "\n" #=> Error :concat expects 2 arguments
  • expectメソッドによって、concatメソッドを返り値"Hello, folks."、引数は2つでタイプはStringとIntegerと定義
  • m.concat("Foo", 100)は引数の数、タイプとも定義に合っているので、返り値"Hello, folks."`が返される
  • expectメソッドによって、concatメソッドを返り値"Hello, there."、引数は1つでタイプはIntegerと定義
  • m.concat("abc")は引数の数は1つで良いが、タイプがIntegerではないのでエラーになる
  • expectメソッドによって、concatメソッドを返り値"Hello, there."、引数は2つでタイプはStringとStringと定義
  • m.concat("abc")は引数の数が2つで定義と異なるのでエラーになる

以上のように、呼び出し時の引数が定義と異なるとエラーになります。

最後の引数のハッシュオブジェクト

一般にメソッド呼び出しの最後の引数のハッシュは{}を省略できることになっています。 expectでも同様に最後のパラメータにハッシュをつけ足すことができます。

モック.expect(メソッド名, 返り値, パラメータの配列, ハッシュ)

呼び出し時にハッシュの部分が同一でなければエラーになります。

m.expect(:concat, "Hello, folks.", [String], a:10,b:20,c:30)
print m.concat("abc", a:10, b:20, c:30), "\n" #=> Hello, folks.
m.expect(:concat, "Hello, folks.", ["efg"], a:10,b:20,c:30)
print m.concat("efg", a:10, b:20, c:30), "\n" #=> Hello, folks.
  • expectメソッドによって、concatメソッドを返り値"Hello, folks."、引数は1つでタイプはString、次にハッシュの引数{a:10,b:20,c:30}が続くよう定義
  • m.concat("abc", a:10, b:20, c:30)では定義通り文字列と(定義と同一の)ハッシュを引数としているので実行され、"Hello, folks."が返される
  • expectメソッドによって、concatメソッドを返り値"Hello, folks."、引数は1つで文字列"efg"、次にハッシュの引数{a:10,b:20,c:30}が続くよう定義
  • m.concat("efg", a:10, b:20, c:30)では定義と同一の文字列、ハッシュを引数としているので実行され、"Hello, folks."が返される

モックは、予定された引数でメソッドが呼ばれるかどうかのチェックが結構厳しいです。 テストですから当然ですが。

expectメソッドにブロックをつけるケース

expectメソッドにブロックを付けることができます。 そのときは第3、4引数(引数とハッシュ)はつけません。

モック.expect(メソッド名, 返り値){|x,y,...| x=10 && y,is_a?(String) && ....}

ブロックのパラメータにはメソッド呼び出し時の引数が代入されます。 ブロックでそのメソッドチェックをします。 メソッド呼び出し時のブロックのチェックもできます。

m.expect(:concat,"Hello, there.") {|x,y| x.is_a?(String) && y.is_a?(Integer)}
print m.concat("a", 1), "\n" #=> Hello, there.
m.expect(:concat,"Hello, there.") {|x,y,&z| x.is_a?(String) && y.is_a?(Integer) && z.call(10)==100}
print m.concat("a", 1){|x| x*x}, "\n" #=> Hello, there.
p m.verify #=> true
  • ブロックにより第1引数が文字列、第2引数が整数であること定義された
  • メソッド実行時に"a"と1が渡されるので、条件を満たしており、実行され"Hello, there."が返される
  • 上記に加えてブロック(&zパラメータ)もチェックする。ブロックは10を与えられると100を返すような動作が期待される
  • m.concat("a", 1){|x| x*x}では、文字列、整数の引数、ブロックはパラメータを2乗(したがって10を100にして返す)なので定義の条件が満たされ"Hello, there."が返される

テストで確認したいことは、対象のプログラムが期待通りにメソッドを呼び出しているかどうかです。 上記の例は極めて簡単なので、引数のタイプの確認の重要性があまり感じられません。 しかし、実際のプログラムでは、引数がいくつかの計算を経て得られることも考えられ、期待通りのオブジェクトかのチェックが重要になるかもしれません。

verify

モックのverifyメソッドは、expectで設定されたメソッドがきちんと呼び出されたかを見ます。

  • expectの設定より多く呼び出したときは、呼び出し時にフェイルになります
  • expectの設定より呼び出しが少ない(0も含め)ときには、verifyメソッドでフェイルになります

以上、モックのソースコードを見て、モックの働きの詳細を紹介しました。

残念ながらminitestの詳しい解説がなかなか見つかりません。 結局ソースコードを読むしかないのか、とちょっと残念な気持ちになります。

ところで、ここまで解説してきましたが、モックがどれくらいテスト上で重要なのでしょうか? そしてどれくらい有効に使えるのでしょうか? プログラムの下位のパーツの代わりを期待されるモックとスタブですが、テスト用のパーツを書くほうが分かりやすいような気もします。 その2つは、やろうとしていることは同じで方法が違うだけです。 こんな考えが浮かぶのは、まだまだテストということの勉強が足りないのでしょうか。

最後に他のテストツールで有名なRspecについてひとこと触れたいと思います。 RSpecは使ったことがあり、本も呼んだことがあります。 RSpecは対象のプログラムの振る舞いを記述することにかなりの重点を置いているように思います。 テストだけではなく、そのプログラムの仕様を記述する感じです。 それがspec(specification 仕様)が名前になっている理由かもしれません。

実はminitestでもspec風の書き方ができるのです。 minitestのドキュメントサイトに少しだけですが、説明があります。 また、RSpecの書き方については書籍などを参考にしてください。

徒然Ruby(24)minitest、mock、stub

今回もminitestの話です。 mockとstubに焦点をあて説明します。

単語帳プログラム

今回は単語帳プログラム「wordbook」を、テストしながら作ることにします。 このプログラムは、テストの例示に使うためのものなので、最小限の機能に絞りました。

端末からの入力に従って、単語帳を編集し、ファイル「に保存/から読み出し」できるというものです。 端末からの入力は「コマンド 英語 日本語訳」という形を原則にしています。

  • a: append(追加)。英語と日本語訳をデータに追加
  • d: delete(削除)。英語(と日本語訳)をデータから削除
  • c: change(変更)。指定された英語の日本語訳を変更
  • p: print(表示)。指定された英語のデータを表示
  • q: quit(終了)。プログラムを終了

コマンドはこの5つだけです。

wb > a add 追加 #=> 英単語「add」と日本語訳「追加」をデータに追加
wb > p add #=> 英単語「add」を表示
add 追加
wb > c add 加える #=> 「add」の訳を「加える」に変更
wb > p add
add 加える
wb > a subtract 減じる
wb > p . #=> 正規表現が可能。任意の文字にマッチ(マッチは単語の一部で良い)
add 加える
subtract 減じる
wb > d add #=> 「add」とその日本語訳を削除
wb > p .
subtract 減じる
wb > q #=> 終了

プログラム・ファイルは4つに分かれます。

  • wordbook.rb: 端末から起動するファイル。コマンドラインの解析をする
  • lib_wordbook.rb: WordBookクラスの定義。実質的なメインプログラム
  • input.rb: Inputクラスの定義。端末から一行入力する
  • db.rb: DBクラスの定義。データのファイルへの保存/読み出しをする

開発は、トップダウンで行うことにします。 トップダウンとは、メインになるプログラムから開発し、メインから呼び出される個々のパーツ・プログラムを後に回す方法です。 逆の手順はボトムアップです。 ボトムアップの利点はひとつひとつ動くパーツから組み立てるので、着実に積み上げることができることです。 ただ、メイン部分で問題が発生すると、また下位のパーツを作り直さなければならなくなるという不利な点があります。

コマンドラインとのインターフェース

コマンドラインとのインターフェースはwordbook.rbに書きます。 このプログラムは、起動時の引数の処理をします。

  • 引数なし=>データファイル名はデフォルト名(db.csv
  • 引数が-hまたは--help=>使い方メッセージを標準エラー出力に出力
  • 引数がひとつ=>引数をデータファイル名とする
  • その他=>使い方表示

プログラムは次のようになります。

#!/bin/sh
exec ruby -x "$0" "$@"
#!ruby

require_relative 'lib_wordbook.rb'

def usage
  $stderr.print "Usage: wordbook [file]\n"
  exit
end

if ARGV.size > 1 || ARGV[0] =~ /--help|-h/
  usage
end
if ARGV.size == 1
  wb = WordBook.new(ARGV[0])
else
  wb = WordBook.new
end
wb.run

$stderrは標準エラー出力のオブジェクトを表す変数でprintメソッドを持っています。 このメソッドは関数形式のprintメソッドと同じで、出力先が違うだけです。 --heloと-hは使い方を表示して終了します。 exitはプログラムを終了するメソッドです。

正しい引数で起動された場合は、WordBookクラスのインスタンスを生成し、そのオブジェクトのrunメソッドを呼び出します。 runメソッドが実質的なメインプログラムになります。

wordbook.rb のテスト

wordbook.rbはコマンドライン引数の解析をするので、テストもコマンドラインから起動して行いたいところです。 そこで、Kernelモジュールのバックティック(`)メソッドを利用して、rubyを実行し、その標準出力を入手してテストに用いることにします。 バックティック・メソッドは「徒然Ruby(11)」を参照してください。 テストプログラムのファイル名は「test_main_wordbook.rb」とします。

require 'minitest/autorun'
require 'fileutils'

# The test will be done under 'temp_test_main_wordbook' directory
class TestMainWorkbook < Minitest::Test
  include FileUtils
  def setup
    @tempd = 'temp_test_main_wordbook' 
    mkdir_p @tempd
    cp 'wordbook.rb', "#{@tempd}/wordbook.rb"
    # Put a stub of "lib_wordbook.rb" under the tepmorary directory.
    # It just prints the argument.
    File.write("#{@tempd}/lib_wordbook.rb", <<~'EOS')
    class WordBook
      def initialize(file="db.csv")
        @file = file
      end
      def run
        print @file, "\n"
      end
    end
    EOS
    cd @tempd
  end
  def teardown
    cd '..'
    remove_entry_secure @tempd
  end
  def test_main_wordbook
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb --help 2>&1`)
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb -h 2>&1`)
    assert_equal("Usage: wordbook [file]\n", `ruby wordbook.rb a.csv b.csv 2>&1`)
    assert_equal("db.csv\n", `ruby wordbook.rb`)
    assert_equal("abc.csv\n", `ruby wordbook.rb abc.csv`)
  end
end

コマンドラインからruby wordbook.rbと入力すると、wordbook.rbはlib_wordbook.rbを読み込み、WordBookクラスのインスタンスを作ろうとします。 まだ、lib_wordbook.rbは書いていませんし、またそれが書けていたとしてもテストには向きません。 ここでは、テスト用のlib_workbook.rbを使ってテストしたいので、新たにテンポラリ・ディレクトリ(一時ディレクトリ)を作り、その中でテストをすることにします。 テンポラリ・ディレクトリ名は「temp_test_main_wordbook」とします。 setupメソッドで上に述べた「下準備」をします。

  • temp_test_main_wordbookを作成する
  • wordbook.rbをそのディレクトリにコピーする
  • 仮のlib_wordbook.rbをそのディレクトリに書き込む。 その内容は、ヒアドキュメントで書いてあるとおりで、runメソッドは単に引数を標準出力に書き出す
  • テンポラリ・ディレクトリにカレントディレクトリを移動する

setupと対になるのがteardownで、これはテスト終了後の後始末をします。 teardownでは次のことを行います。

テストをするメソッドは「test_main_wordbook」です。

  • `ruby wordbook.rb --help 2>&1`でバックティック内のコマンドを実行する。 2>&1標準エラー出力の出力先を標準出力に変更する(bashのリファランス参照)。 バックティックはコマンドの標準出力を捕らえ、メソッドの返り値にする
  • assert_equalで、その返り値が文字列"Usage: wordbook [file]\n"に等しいかをテストする。 2、3番目のテストも同様
  • 4番めは引数なしで起動した場合。 そのときwordbook.rbはrunメソッドを引数"db.csv"をつけて起動する。 テスト用のlib_wordbook.rb内のrunメソッドはその引数を標準出力に書き出すので、db.csvがバックティックメソッドで返されるはずである。 それをassert_equalでチェックする。 5番目も同様

テストしてみます。

$ ruby test_main_wordbook.rb 
Run options: --seed 23981

# Running:

.

Finished in 0.391359s, 2.5552 runs/s, 12.7760 assertions/s.
1 runs, 5 assertions, 0 failures, 0 errors, 0 skips
$

トップレベルのファイルはどうしてもコマンドラインの解析があるので、テスト用のライブラリファイル(このようなものをスタブという)を作り、テスト用のテンポラリディレクトリでテストする形になります。 これは私流のやりかたですが、もし他にもっと良い方法をご存知の方がいれば、コメントで教えていただけるとありがたいです。

スタブ(stub)とモック(mock)

Minitestのstubは「Objectクラスに追加したメソッド」です。 すべてのクラスはObjectの子孫ですから、どのオブジェクトの上でもstubメソッドを呼ぶことができます。 また、「クラスも一種のオブジェクト」ということから、クラスの上でもstubメソッドを呼ぶことができます。

stubはオブジェクトの既存のメソッドの返り値を変更することができます。

オブジェクト.stub(メソッド名, 返り値){ ・・・・・}

このような形で使います。 正確には、引数にさらに付け加えられる情報があるのですが、詳細はMinitestのドキュメントをご覧ください。

stubはどんなオブジェクトに対しても使えるので、とくに入力関係のオブジェクトに使うと効果的です。 例えばFileクラスのクラスメソッドreadに対して、

File.stub(:read, "abcd\n") {・・・・・}

とすると、{}の中、すなわちブロックの中ではFile.read(ファイル名)はいつも"abcd\n"を返します。 stubによるメソッドの変更はブロックの中だけで有効です。

モックは「みせかけのもの」という意味です。 本当のオブジェクトではなく、テストのためにそれらしい振る舞いをするオブジェクトのことをいいます。 minitestのモック・オブジェクトでは、みせかけのインスタンスメソッドとその引数、返り値を定義することができます。

  • モックオブジェクトを生成し、みせかけのインスタンスメソッドを定義
  • そのインスタンスメソッドを実行
  • 期待通りの形で(引数の種類や数、呼ばれた回数)でメソッドが呼ばれたかをチェック

という手順でテストをします。

require 'minitest/autorun'
class TestFoo < Minitest::Test
  def test_foo
    @mock = Minitest::Mock.new
    @mock.expect(:read, "Hello world!")
    assert_equal("Hello world!", @mock.read)
    @mock.verify
  end
end
  • 4行目: モック・オブジェクトを生成しインスタンス変数@mockに代入
  • 5行目: モック・オブジェクトに「みせかけのメソッド」を定義。 定義はモックのexpectメソッドで行う。 1番目の引数=>みせかけのメソッド名、2番目の引数=>そのメソッドの返り値。 この例ではみせかけのメソッドに引数は設定されていないが、もし引数を設定する場合は、3番めの引数に配列で渡す
  • 6行目: @mock.readで、「みせかけのメソッド」readを呼び出す。 定義どおりに、引数なしで呼び出し。 また、その返り値は"Hello world!"になるので、assert_equalのアサーションも通過するはず
  • 7行目: モックのverifyメソッドは、定義されたメソッドが呼ばれたかをチェック。 上記のテストでは6行目で呼び出しているので、テストはパスする。 なお、expectメソッドを複数回行うことができ、そのときは「みせかけのメソッド」の呼び出しも同じ回数だけ行う。 それらの回数が等しいかどうかもverifyはチェックする。 verifyはexpectに対して呼び出しが少ないときにフェイルにするだけで、逆にexpectに対して呼び出しが多いときは呼び出し時にフェイルになる。 また、expectメソッドを複数回使うと、返り値をその回数だけ定義できる。 これらの返り値は呼び出しごとに次々に変わっていく

実際のテストでは、モックオブジェクトを本来のオブジェクトに差し替えてテストをします。 差し替えをどのように行うかは対象となるプログラムによりますが、結構難しくなる場合もあります。 対象プログラムの中身に立ち入らないのがテストの原則ですが、オブジェクトの差し替えはどうしても原則どおりには行かないことが多いと思います。 そのときは、中身に関する事柄をできるだけ少なくします。

スタブとモックを組み合わせて使うこともよくあります。 それは、スタブの2番めの引数(書き換えられたメソッドの返り値)にモックを置くことです。 そのことによって、モックをテスト対象のオブジェクトに送り込むのです。 これは、newメソッドをスタブで書き換え、newで返すオブジェクトをモックに取り替えてしまう、という方法で用いられます。

スタブのより柔軟で高度な使い方としては、2番めの引数(返り値)のところに、callメソッドを持つオブジェクトを置く方法があります。 このときスタブはcallメソッドを実行し、その値を返り値にします。 ここにはProcオブジェクトを入れるのがピッタリですが、モックを入れることも考えられます。 つまり、モックに「みせかけのメソッド」としてcallを定義するのです。 モックは複数回expectを使い、callメソッドの返り値をその回数分セットすることができます。 ということは、スタブで書き換えたメソッドに複数回分の異なる返り値をセットすることが可能になるのです。

require 'minitest/autorun'
# sample class
class A
  def initialize
    @b = B.new
  end
  def show_b
    @b.show
  end
end
class B
  def show
    "class B のオブジェクトです\n"
  end
end
class TestStubAndMock < Minitest::Test
  def test_stub_and_mock
    @a = A.new
    assert_equal("class B のオブジェクトです\n", @a.show_b)
    @mock = Minitest::Mock.new
    B.stub(:new, @mock) do
      @a = A.new
    end
    @mock.expect(:show, "ぼくはモックだよ!\n")
    @mock.expect(:show, "わたしはモックよ!\n")
    assert_equal("ぼくはモックだよ!\n", @a.show_b)
    assert_equal("わたしはモックよ!\n", @a.show_b)
    @mock.verify
  end
end

この例では、クラスAのインスタンス生成時にクラスBのインスタンスを作って@bに代入します。 クラスAのshow_bメソッドでは、@b.showによってクラスBのshowメソッドが呼ばれ"class B のオブジェクトです\n"が返されます。 ちょっと入り組んでいますが、良いでしょうか。

テストプログラムtest_stub_and_mockの最初の2行は今述べたことを実行して、@.show_bによって上述の文字列が返されたことを確認しています。 これは正しく動作し、テストはパスします。

メソッドの3行目から6行目では、モックオブジェクト@mockを生成し、stubメソッドによって、B.newの返り値を@mockにします。 本来B.newはクラスBのオブジェクトを返すのですが、モックを返すようになっているのです。 これによって、クラスAのオブジェクト@a上ではクラスBの振る舞いがモックの振る舞いに置き換わってしまいます。

次の2行はモックのshowメソッドが返す値を設定しています。 @a.show_bの中で、@b.showを実行しますが、@bにはクラスBのオブジェクトではなく、モックが入っているので返り値がモック設定のものになります。 そこで、2つのassert_equalが成功し、最後のverifyも予定通り2回呼ばれていたので成功します。 テストを実行するとすべてパスします。

この方法がクラスAで想定しているのは、initializeメソッドでB.newが呼ばれるだろうということだけです。 それがクラスAのリファクタリングで変更される可能性はごく小さいはずなので、テストはリファクタリング後も使える可能性が高いといえます。

それでは、次のセクションで単語帳プログラムの実例を見てみましょう。

lib_wordbook.rbとそのテスト

lib_wordbook.rbではWordBookクラスを定義します。 このクラスは、InputクラスとDBクラスのインスタンスを生成します(それぞれ@inputと@db)。 WordBookクラスのrunメソッドは、これらのインスタンスを使い、次のような動作をします。

  • ループの中で、@input.inputを呼び出す。 そのメソッドは標準入力(キーボード)からの入力を「コマンド、英語、日本語」の配列に変換して返す
  • コマンドに応じて、@dbのappend、delete、change、list、closeの各メソッドを呼び出す
  • コマンドがqならば、ループを抜け出すとともにrunメソッドを抜け出す

プログラムは次のようになります。

require_relative 'input.rb'
require_relative 'db.rb'

class WordBook
  def initialize(*file)
    @input = Input.new
    if file[0]
      @db = DB.new(file[0])
    else
      @db = DB.new
    end
  end

  def run
    while true
      a = @input.input #=> an array like [command, English, Japanese]
      return unless a
      case a[0]
      when 'a'
        @db.append(a[1], a[2])
      when 'd'
        @db.delete(a[1])
      when 'c'
        @db.change(a[1], a[2])
      when 'p'
        d = @db.list(a[1]).to_a
        d.each do |e,j|
          print "#{e} - #{j}\n"
        end
      when 'q'
        @db.close # save data
        break
      end
    end
  end
end

クラスから生成されるインスタンスの初期化はinitializeメソッドで行います。 このメソッドの引数が*fileとなっているのは、可変長引数を表します。 呼び出し側が、ファイルを引数にする場合と、引数なしの場合があるので、可変長にしました。 引数は配列の形でパラメータfileに代入されます。 実際には引数はあったとしてもひとつで、それはfile[0]に代入されています。 その引数があれば、それを引数にしてDBクラスのインスタンスを生成します。 引数が無ければ(f[0]==nil)、引数なしでDBクラスのインスタンスを生成します。 また、Inputクラスのインスタンスも作ります。

runメソッドはwhile trueの無限ループ内で、入力に応じた@dbのメソッドを呼ぶだけです。 pコマンドの時だけ、@dbから得たデータを標準出力に出力するのが、唯一自分自身の仕事になっています。

さて、このファイルをテストする段階で、まだinput.rbとdb.rbはできていません。 require_relativeでエラーにならないように、空のファイルを置いているだけです。 それらのファイルが定義するInputクラスとDBクラスはテストプログラムの中で定義されます。 また、それらのメソッドはモックの「みせかけのメソッド」になります。 以下はtest_lib_wordbook.rbのプログラムリストです。

require 'minitest/autorun'
require_relative 'lib_wordbook.rb'

# dummy class
class Input
end
class DB
  def initialize(*file)
  end
end

class TestLibWordbook < Minitest::Test
  def test_run
    @mock_input = Minitest::Mock.new
    @mock_db = Minitest::Mock.new
    Input.stub(:new, @mock_input) do
      DB.stub(:new, @mock_db) do
        @wordbook = WordBook.new
      end
    end

    args = []
    args << [['a', 'append', '付け足す'], :append, nil, ['append', '付け足す']]
    args << [['d', 'append'], :delete, nil, ['append']]
    args << [['c', 'append', '付け加える'], :change, nil, ['append', '付け加える']]
    args << [['p', 'app...'], :list, [['append', '付け加える']], ['app...']]

    args.each do |a|
      @mock_input.expect(:input, a[0])
      @mock_db.expect(a[1], a[2], a[3])
      @mock_input.expect(:input, ['q'])
      @mock_db.expect(:close, nil)
      if a[0][0] == 'p'
        assert_output("append - 付け加える\n") {@wordbook.run}
      else
        @wordbook.run
      end
      @mock_input.verify
      @mock_db.verify
    end
  end
end

テストプログラムについて説明します。

InputとDBクラスを定義しておきます。 これらはテスト用のダミーです。 なお、DBクラスのnewメソッド呼び出しには引数がある場合と無い場合があるので、initializeメソッドの引数にはアスタリスクを付けて可変長にします。

WordBookクラスのinitializeメソッドでInput、DBクラスのインスタンスが@inputと@dbに代入されます。 テストではそれらにモックを入れるために、stubメソッドで両クラスのnewメソッドの返り値をモックに変えてWordBook.newを実行します。 これで、runメソッドで使う@inputと@dbがモックオブジェクトを表すようになります。

test_runメソッドがテスト本体です。 まず、argの配列を作ります。 4行あるのが、それぞれ、a、d、c、pのコマンドを入力するときの諸データを配列にしたもので、それが<<メソッドでargに追加されていきます。 最初のデータがeachメソッドのループでどのように使われるかを見ていきましょう。

  • a[0]=['a', 'append', '付け足す'] 'なので、まず@wordbook.input.expect(:input, a[0])で、@inputのモックがinputメソッドに対し'['a', 'append', '付け足す']'を返すように定義をします。 これにより、@input.inputが呼ばれた時に['a', 'append', '付け足す']`が返されます。
  • a[1] = :appenda[2] = nila[3] = ['append', '付け足す']なので、@wordbook.db.expect(a[1], a[2], a[3])のところでは、@dbのモックがappendメソッドに対し、返り値nilで引数が'append', '付け足す'となるように定義をします。 返り値はrunメソッド内では使われていないので、nil以外のものでも構いません。
  • @wordbook.input.expect(:input, ['q'])で次の@input.inputメソッドの返り値を'q'にします。 これはrunメソッドの2回めのループでの呼び出しです。
  • @wordbook.db.expect(:close, nil)で、@db.closeが引数なしで呼び出されるよう定義します。
  • a[0][0]'a'でしたから、else節が実行され、@wordbook.runすなわちrunメソッドが実行されます。 このなかで@input.input、@db.append、@input.input、@db.closeがこの順で呼ばれるはずです。
  • @wordbook.input.verifyで@inputに代入されたモックが、設定されたメソッドを呼んだかをチェックします。
  • @wordbook.db.verifyで@dbに代入されたモックが、設定されたメソッドを呼んだかチェックします。

以上が1セットでこれを内容を変化させて全部で4セット行います。 実行すると、

$ ruby test_lib_wordbook.rb 
Run options: --seed 3358

# Running:

.

Finished in 0.005851s, 170.9253 runs/s, 170.9253 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

無事にテストが通過しました。 内容が複雑でしたが、大丈夫でしょうか。

モックが下位プログラムの代わりをしてくれた、ということが大事な点です。

さて、このテストプログラムではWordBook.newでInputクラスとDBクラスのインスタンス生成が行われていると仮定しました。 これが将来のリファクタリングで変更される可能性は小さいですが、もし変更されればテストプログラムの変更もしなければなりません。 それは、テストプログラムがWordBookクラスの内容に(わずかですが)立ち入っているために起こることです。 すなわち、テストは「対象の振る舞いにフォーカスする」「内部構造に立ち入らない」という原則に触れていることになります。

これを原則に忠実なテストに置き換えるには、モックを諦めなければならないと思います。 なぜならモックはインスタンスの置き換えだからです。

代案としては「クラスのスタブを作る」方法があります。 ここでいうスタブとは、代用品のことで、本物のInputクラス、DBクラスではなく、テスト用に作るものです。 スタブにはテストに必要なすべてのメソッドを持たせ、テストに適するような出力をさせます。 このプログラムは分かりやすく、単純化されます。 モックよりもずっと簡単なので、勧められる方法です。

require 'minitest/autorun'
require_relative 'lib_wordbook.rb'

# dummy class
class Input
  def initialize
    @count = -1
  end
  def input
    @count += 1
    [['a', 'append', '付け足す'], ['d', 'append'], ['c', 'append', '付け加える'], ['p', 'app...'], ['q']][@count]
  end
end
class DB
  def initialize(*file)
  end
  def append(e,j)
    print "append(#{e}, #{j})\n"
  end
  def delete(e)
    print "delete(#{e})\n"
  end
  def change(e,j)
    print "change(#{e}, #{j})\n"
  end
  def list(e)
    print "list(#{e})\n"
  end
  def close
    print "close\n"
  end
end

class TestLibWordbook < Minitest::Test
  def setup
    @wordbook = WordBook.new
  end
  def test_run
    expected_output = "append(append, 付け足す)\ndelete(append)\nchange(append, 付け加える)\nlist(app...)\nclose\n"
    assert_output(expected_output) {@wordbook.run}
  end
end

inputメソッドは、カウンタを使って呼ばれるたびに異なる値を返します。 DBの各メソッドは呼ばれるたびに、メソッド名と引数を標準出力に書き出します。 テスト本体ではrunメソッドの出力結果(上記のDBお各メソッドの出力のトータル)と期待される文字列を比較するだけです。 このテストの良いところは

  • プログラムが分かりやすい
  • テスト対象の内部構造に関わらない

ということです。

このセクションでは、モックを使ったプログラムを書きましたが、それはモックの説明をしたかったからです。 実際にはモックを使わないプログラムの方が適切なテストプログラムだと私は思います。 テストには決まった方法がありません。 いろいろな方法が可能なので、その中で最も良いものをチョイスしてください。

input.rb

入力を担当するInputクラスの書かれたファイルinput.rbは次のようになります。

require 'readline'

class Input
  def input
    while true
      buf = Readline.readline("wb > ", false)
      if buf =~ /^[ac] +[a-zA-Z]+ +\S+$|^d +[a-zA-Z]+$|^p +\S+$|^q$/
        return buf.split(' ')
      else
        $stderr.print "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"
      end
    end
  end
end

readlineライブラリをrequireし、一行入力を可能にします。

このように非常に簡単ですが、Readline.readlineの入力部分はテストする際にはスタブに置き換えて人為的に入力を作り出します。 なお、ここではモックを使うのが難しいのです。 というのは、モックはオブジェクトなのでReadlineに代入したいのですが、Readlineが定数なので再代入できないのです。 それで、モックを直接使うことはできません。 それでは、スタブを使ったテストプログラムを見ていきましょう。

require 'minitest/autorun'
require_relative 'input.rb'

class TestInput < Minitest::Test
  def test_input
    @in = Input.new
    Readline.stub(:readline, "a append 付け足す") { assert_equal(['a', 'append', '付け足す'], @in.input) }
    Readline.stub(:readline, "d append") { assert_equal(['d', 'append'], @in.input) }
    Readline.stub(:readline, "c append 付け足す") { assert_equal(['c', 'append', '付け足す'], @in.input) }
    Readline.stub(:readline, "p a..end") { assert_equal(['p', 'a..end'], @in.input) }
    Readline.stub(:readline, "q") { assert_equal(['q'], @in.input) }
    m = Minitest::Mock.new
    m.expect(:call, "abcd", ["wb > ", false])
    m.expect(:call, "q", ["wb > ", false])
    Readline.stub(:readline, m) {assert_output(nil, "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"){ @result = @in.input }}
    assert_equal(['q'], @result)
  end
end

input.rbをrequire_relativeで取り込んでおきます。 test_inputメソッドがテストプログラムです。

  • まず、Inputクラスのインスタンスを@inに代入しておく
  • Readline.readlineメソッドをスタブで置き換え、"a append 付け足す"を返すようにする
  • ブロック({}の中)で@in.inputでinputメソッドを呼び出す。 Readline.readlineの返した文字列は有効な入力なので、それを配列にして['a', 'append', '付け足す']を返すはずである。 それをassert_equalでテストする。
  • 以下同様にd、c、p、qの各コマンドをテストする
  • 次に有効でない入力があったときのテストをするが、このとき「常に無効な入力=>無限ループ」になってしまうので、最初の入力は無効で2回めの入力を有効にしたい。 スタブだけではそれを実現できないのでモックと組み合わせる

ここで、前の方に出てきたモックとスタブの組み合わせが使われています。 複雑なので、もう一度説明しましょう。

スタブの引数は、メソッド名、返り値になっています。 返り値には、Procオブジェクトなどを入れることができます。 返り値にはProcオブジェクトが返されるのではなく、Procオブジェクトのcallメソッドを実行した値が返されます。 また、このオブジェクトはProcオブジェクトでなくてもcallメソッドを持っていれば、同様にcallメソッドの実行結果を返してくれます。 そこで、モックのexpectメソッドでみせかけのメソッドcallを定義します。 すると、stubはモックのcallメソッドを呼び、expectで設定した返り値が返されます。 モックは複数回expectメソッドを使って、順に異なる返り値を設定できます。

  • モックオブジェクトを生成して変数mに代入する
  • mの最初の返り値を"abcd"(無効な入力)、2回めの返り値を"q"(有効な入力)とする
  • @in.inputを実行すると入力形式の案内が標準エラー出力に出るので、assert_outputでテストする
  • @in.inputの返り値は@resultに代入しておき、次の行でassert_equalでテストする

ここではモックを使って2回の呼び出しに対して異なる返り値を作成しました。 同じことはProcオブジェクトを使ってもできますし、むしろモックよりも複雑なことをできます。 モックで機能が足りないと思ったらProcオブジェクトを考えてみてください。

テストの実行結果は掲載しませんが、きちんとパスします。

スタブを使うのは複雑になりがちです。 それに対して、前のセクションでクラスのスタブを使ったように、Readlineのスタブを作る方法もあります。 これは、Readlineモジュールのreadlineメソッドをテスト用に再定義してしまう方法です。 こんなおそろしいことをして良いのかと思うかもしれませんが、Rubyでは珍しいことではありません。

require 'minitest/autorun'
require 'readline'
require 'stringio'
require_relative 'input.rb'

module Readline
  def self.readline(pronpt="> ", history=false)
    unless @stringio
      @stringio = StringIO.new("a append 付け足す\nd append\nc append 付け足す\np a..end\nq\nabcd\nq\n")
    end
    @stringio.readline.chomp
  end
end

class TestInput < Minitest::Test
  def test_input
    @in = Input.new
    assert_equal(['a', 'append', '付け足す'], @in.input)
    assert_equal(['d', 'append'], @in.input)
    assert_equal(['c', 'append', '付け足す'], @in.input)
    assert_equal(['p', 'a..end'], @in.input)
    assert_equal(['q'], @in.input)
    assert_output(nil, "(a|c) 英単語 日本語訳\nd 英単語\np 正規表現\nq\n"){ @result = @in.input }
    assert_equal(['q'], @result)
  end
end

Readlineモジュールの書き換えのためにrequire 'readline'が必要です。 readlineはReadlineの特異メソッドなので、def self.readlineとして再定義します。

文字列をファイルのように見立てるStringIOというクラスがあります。 このクラスにはreadlineメソッドがあり、文字列から1行ずつ返してくれます。 これがちょうどReadline.readlineの代わりに良いので、再定義の中で使います。 StringIOを使うにはrequire 'stringio'が必要です(ただ、このプログラムではminitestがrequireしているので、書かなくてもrequireされますが)。 はじめて呼ばれるときは@stringioが未定義なので、StringIOのインスタンスを代入します。 StringIO.newの引数が入力の元となる文字列です。 2度目の呼び出しではunlessのところを飛び越します。 @stringio.readlineによって、文字列から1行ずつ(つまり\nで区切られた文字列がひとつずつ)返されます。 Readline.readlineでは行末の改行が切られているので、chompメソッドで改行を落としておきます。

テスト本体ではassert_equalなどで順にInput#inputメソッド(Inputクラスのインスタンスメソッドinputをこのように書くことがあります。 これはドキュメントの中だけで、プログラム中で書くのではありません)をテストするだけです。 このプログラムではstubメソッドを使わずにReadline.readlineを書き換えています。 どちらが良いかは一概に言えませんが、今回のテストプログラムでは後者の方が分かりやすくすっきりとしています。

今回、非常に簡単なプログラムに対して難しいテストプログラムを書きましたが、これは正しい方法なのでしょうか? 私だったら、直接動かしてチェック(人手でチェック)します。 このような簡単で短いプログラムでは、その方が手っ取り早いからです。 今回はテストプログラムを書いたのは、あくまでスタブの説明のためです。

ただ、一般にはテストプログラムは必要で有効なことが多いです。

CSVクラス

CSVクラスはcsv(comma separated values)、コンマ区切りデータ形式を扱うクラスです。 IOクラスのように使え、かつコンマ区切りデータを扱えます。 コンマ区切りデータとはその名の通り、行の中でコンマで区切られたデータです。

pen,ペン
bread,パン

このように、各行には同じ数のコンマ区切りのデータがあります。 上記の例はRubyのデータ構造では次のようになります。

[["pen","ペン"], ["bread","パン"]]
  • CSVファイルの読み出しにはCSV.readを使う。 上記の例のようにCSVファイルの内容が2次元配列として返される。 1行目をヘッダ(タイトル行)とすることもできる。 その場合、引数にハッシュ{header: true}を入れる。 タイトルが無ければ{header: false}を用いる
  • 書き込みにはCSV.openと<<演算子を使う

次のプログラムは、CSVを使った読み書きの典型的な例です。

# 読み込み
array = CSV.read(CSVファイル名, headers: false)
# 書き出し
CSV.open(CSVファイル名) do |csv|
  array.each {|a| csv << a}
end

DBクラスでは単語帳のデータを2次元配列で表し、作業の開始、終了時点でCSVファイルに読み込み、書き出しをします。

db.rbとそのテスト

db.rbの内部ではデータを2次元配列インスタンス変数@dbに格納し、各メソッドで@dbにデータの付加、削除、変更、照会などをします。 プログラムは短く簡単です。

require "csv"

class DB
  def initialize(file='db.csv')
    @file = file
    if File.exist?(@file)
      @db = CSV.read(@file, headers: false)
    else
      @db = []
    end
  end
  def append(e,j)
    @db << [e,j]
  end
  def delete(e)
    i = @db.find_index{|d| e == d[0]}
    @db.delete_at(i) if i # i is nil if the search above didn't find e in @db.
  end
  def change(e,j)
    i = @db.find_index{|d| e == d[0]}
    if i
      @db[i] = [e,j]
    else
      @db <<[e,j]
    end
  end
  def list(e)
    pat = Regexp.compile(e)
    @db.select{|d| pat =~ d[0]}
  end
  def close
    CSV.open(@file, "wb") do |csv|
      @db.each {|x| csv << x}
    end
  end
end
  • initializeでCSVデータを読み込み、closeで書き出しをする
  • append、delete、change、listは2次元配列への追加、削除、変更、照会をする。
  • listメソッドでは引数を正規表現オブジェクトに変えてから(Regexp.compileメソッド)、それに一致するデータの配列を返す

このプログラムのテストは、2つに分かれます。

  • CSVファイルの入出力は、CSVクラスの仕事なので、それをテストの対象から除いた部分のテストをする
  • CSVファイルの入出力が上手くコントロールされているかの部分のテストをする

本来のテストは1番めだけで良いと思いますが、ここでは2番めもテストします。

require 'minitest/autorun'
require_relative 'db.rb'

class TestDB < Minitest::Test
  def test_db
    File.stub(:exist?, true) do
      CSV.stub(:read, [["pen","ペン"],["pencil","鉛筆"]]) do
        @db = DB.new
      end
    end
    assert_equal([["pen","ペン"]], @db.list("^pen$"))
    assert_equal([["pen","ペン"],["pencil","鉛筆"]], @db.list("pen"))
    @db.append("circle","")
    assert_equal([["circle",""]], @db.list("cir"))
    @db.change("circle","円周")
    assert_equal([["circle","円周"]], @db.list("cir"))
    @db.delete("pen")
    assert_equal([["pencil","鉛筆"], ["circle","円周"]], @db.list("."))
  end
  def test_csv
    File.write("test.csv",<<~CSV)
    pen,ペン
    pencil,鉛筆
    CSV
    @db = DB.new("test.csv")
    @db.append("circle","")
    @db.change("circle","円周")
    @db.delete("pen")
    @db.close
    assert_equal("pencil,鉛筆\ncircle,円周\n",File.read("test.csv"))
    File.delete("test.csv")
  end
end
  • test_dbメソッドでは、stubメソッドをネストして使い、ファイル入力の結果が[["pen","ペン"],["pencil","鉛筆"]]になるとしている
  • test_dbメソッドではlistメソッドで内容照会をし、assert_equalでテストする方法をとっている 他のappend、delete、changeについては、その実行後にlistメソッドを使い、正しく実行されているかをテストしている
  • test_csvメソッドではテスト用のCSVファイル「test.csv」を作り、append、delete、change、closeの後に「test.csv」がその作業を反映しているかどうかをテストする

実際にテストを実行してみると、すべてパスします。

wordtest.rbの実行

テストはすべて通ったので、wordtest.rbを実行してみました。 いくつか英単語と日本語訳を入力して、作成されたCSVファイルを見てみると、正しく反映されていました。 小さいプログラムですが、動くと嬉しいものです。 プログラムの今後の発展方向としては

  • 例文、備考などのフィールドを追加する
  • 単語テストのコマンドを作る(英=>日と日=>英の両方向のテスト)
  • CSVでなく、データベースを使う

などが考えられます。 ただ単語帳ソフトが本当に役立つプログラムなのかは疑問が残ります。 どうでしょうか? この問に対する答えは英語教育の専門家でなければ出せないでしょう。 一般に、プログラムが有用かどうかは開発者には分からないことが多いです。 その分野の専門家とソフト開発者の協力はとても大切なことです。

今回は実用には程遠い単語帳プログラムではありますが、開発とテストの実例として見てきました。 実際の開発はもっと規模が大きいですが、同様の手順、すなわちユニットごとに作成とテストを繰り返すことになります。 そのときには、minitestを有効に活用して開発を進めてください。

最後にminitestについて述べます。

minitestは高速です。 大きな開発で使うとそれがよく分かります。 なぜかというと複数のテストをマルチメソッドで並行して行うからです。 逆にこのことはテスト相互が独立していないとコンフリクトを起こす可能性があることを示唆しています。 プログラムの上から下へテストするのではなく、各メソッドは同時並行で非同期に進みます。

minitestはウェブ開発フレームワークRuby on Railsにおける標準のテストシステムになっています。 Railsでは、railsに合うようにminitestの機能を拡張しています。 詳しくはRails Guideを参照してください。 日本語訳もあります。

大きなプログラムのテストでは、Rakeを使ってテストを自動化することができます。 これについては、「はじめてのRake」に説明があります。

今回のテストをするためのRakefile

require "rake/testtask"

FileList['test*.rb'].each do |file|
  Rake::TestTask.new do |t|
    t.test_files = [file]
    t.verbose = false
  end
end

です。 コマンドラインから

$ rake test

とすると、すべてのテストが実行されます。 rakeに引数testが必要なことに注意してください(通常は引数なしでrakeを起動することが多いので)。

徒然Ruby(23)minitest、ripper

アプリ作成の記事でminitestを使いました。 今回はminitestについて、また一般にテストについて、私の考えを書こうと思います。

テストとは

一般にテストはプログラムのバグを除くために広範に使われている手法です。 開発に従事している方は既に良くご存知のことと思います。 私は「仕事として」つまり給料をもらって開発をしたことがありません。 おそらくプロの開発の方は、私よりもっとテストの経験も知識もお持ちだと思います。 そういう方は、この記事を読む必要はないかもしれません。

また、テストには「テスト駆動開発」とか「振る舞い駆動開発」などというのもあって、ひとつの開発手法にもなっています。

  • テスト駆動開発=>Test Driven Development=>TDD
  • 振る舞い駆動開発=>Behavior Driven Development=>BDD

そのような手法では、まずテストを書いてからアプリを書く、といわれます。 ですが、私はどちらが先でも良いのではないかと思っています。

テストを書くときは、あまりプログラムの細かい部分に立ち入るよりも、その振る舞い(behavior)が期待通りかをテストするようにします。 そうすることで、そのプログラムの内部構造を変更、改良したのちも同じテストプログラムでテストすることができます。 つまり、テストは内部構造まで立ち入らず、振る舞いのみにフォーカスすべきです。

また、テストではその規模も問題になります。

  • ユニットテスト: クラスあるいはメソッドなどのプログラムを構成する単位に対して行うテスト。 比較的簡単に作成、テストができる
  • 統合テスト: ユニットテストの対象よりも大きなプログラムの部分、あるいは全体について行うテスト。 プログラムの構成によってその実際は変わるが、例えばユニットテスト済みの単体同士の連携などをテストする。 このような大きなテストでは入出力、ネットワーク、データベースなどを扱うために、特別な方法が必要になることが多い。

この記事ではユニットテストを扱います。

自作テストプログラム

minitestはテスト用のフレームワークですが、それを使わずに自分でテストプログラムを作ることもできます。

例えば、あるメソッドが数字を表す文字列を引数に取り、その数値を返す、というものだとしましょう。 メソッド名をto_numberとしておきましょう。 今回は先にto_numberを作ってから、そのテストプログラムを作ることにします。

def to_number(s)
  s.to_i
end

引数の文字列をto_iメソッドで整数にして返す、というメソッドです。 おそらく、皆さんはすぐにこのプログラムの欠点を見つけたことでしょう。 問題ありありのプログラムですが、テストの説明には適しています。

テストプログラムを考える上でのポイントは「メソッドの振る舞い」をテストするということです。 この場合、「引数が与えられたら、その引数の表す数値を返す」というのが振る舞いですが、もう少し詳しく考えないといけません。 引数と返し値の組み合わせを「引数=>返し値」で表すことにします。

  • 数字を表す文字列=>対応する数(ただし、整数と浮動小数点数の2種類の数がある。ここでは複素数、有理想は想定しない)
  • 数字ではない文字列=>??
  • 文字列以外のオブジェクト=>??

??と書いたところは仕様が決められていなかったので、そう書きました。 テストを考えただけで、仕様の不十分さも分かりました。 これもテストを導入するメリットの1つです。 つまり、振る舞いに対するテストを書くためには、振る舞いをより完全に定義しなければならなくなります。

今回は??のところはnilを返して、エラー処理は呼び出し側に任せることにしましょう。

テストはこんな感じです。

def to_number(s)
  s.to_i
end

# テストプログラム
def test_to_number
  a = to_number("0")
  print "0 にならずに #{a} が返された\n" unless a == 0
  a = to_number("-0.1")
  print "-0.1にならずに #{a} が返された\n" unless a == -0.1
  a = to_number("1.23e10")
  print "1.23e10にならずに #{a} が返された\n" unless a == 1.23e10
  a = to_number("abc")
  print "nilにならずに #{a} が返された\n" unless a == nil
  a = to_number(100)
  print "nilにならずに #{a} が返された\n" unless a == nil
end

test_to_number
#=> -0.1にならずに 0 が返された
#=> 1.23e10にならずに 1 が返された
#=> nilにならずに 0 が返された
#=> nilにならずに 100 が返された

最初のテストだけ通って、残りの4つは通りませんでした。 テストを通ることを「成功(サクセス)」通らないことを「失敗(フェイル)」といいます。 またテストを通ることを「パスする」ともいいます。

to_numberは引数について場合分けして、それぞれに対して適切な値を返さなければなりません。

  • 整数を表す文字列=>その整数を返す
  • 浮動小数点数を表す文字列=>その浮動小数点数を返す
  • それ以外の文字列=>nilを返す
  • 文字列でないオブジェクト=>nilを返す

メソッドto_numberを書き直してみましょう。

def to_number(s)
  return nil unless s.is_a?(String)
  case s
  when /\A-?\d+\Z/
    s.to_i
  when /\A-?\d+\.\d+([Ee]\d+)?\Z/
    s.to_f
  else
    nil
  end    
end

正規表現を使って、文字列が数字にマッチする場合に数値に変換しています。 結構複雑なことをしなければならないのですね。 これでテストは通るようになります。

Ripper

ちょっと脇道にそれますが、このように文字列が数値を表すかどうかをチェックするのは一種の字句解析に当たります。 rubyはプログラムを読む時に「字句解析」をして、その文字列をキーワード、識別子、区切り記号、数値などに分解したのちに構文解析、実行に移ります。 そこで、rubyの字句解析を使って数値を取り出すこともひとつの方法として考えられます。 ripperという標準添付ライブラリはRuby プログラムを解析するためのライブラリです。 メソッドRipper.lexは字句解析をし、字句(トークン)の配列を返します。

require 'ripper'
p Ripper.lex("123 -1.2e5")
#=> [[[1, 0], :on_int, "123", END], [[1, 3], :on_sp, " ", END], [[1, 4], :on_op, "-", BEG], [[1, 5], :on_float, "1.2e5", END]]

配列の中身の詳細はRubyのドキュメントを参照してください。 注目すべきは、各配列の2番目のシンボルです。

これを利用すると、正規表現を使わずに「整数」「浮動小数点数」「その他」に文字列を分類できます。 to_numberをRipper.lexを使って書き直してみましょう。

require 'ripper'

# メソッドの再々定義
def to_number(s)
  return nil unless s.is_a?(String)
  a = Ripper.lex(s)
  unless a.size == 1 || (a.size == 2 && a[0][1] == :on_op && a[0][2] == "-")
    return nil
  end
  case a[-1][1]
  when :on_int
    s.to_i
  when :on_float
    s.to_f
  else
    nil
  end    
end

Ripperを使うことはあまりないでしょうが、Ruby同様の字句解析をしたいときはRipperが便利です。 本題とはあまり関係ないことを書きましたが、Ripperも面白いと思っていたので、この機会に紹介しました。

minitest

to_numberメソッドをテストするminitestのプログラムを作ってみましょう。 次のプログラムはto_numberに対して8つのテストをします。 to_numberメソッドはこのプログラムの前に挿入しておくこととします。 (require_relativeメソッドで取り込んでも良いです)。

require 'minitest/autorun'
class TestToNumber < Minitest::Test
  def setup
  end
  def teardown
  end

  def test_to_number
    [["0",0],["12",12],["-5",-5],["12.34",12.34],["2.27421e5",2.27421e5],["-23.56",-23.56],["abc",nil], [123,nil]].each do |s,v|
      if v == nil
        assert_nil to_number(s)
      else
        assert_equal v, to_number(s)
      end
    end
  end
end

要点を次に記します。

  • minitest/autorunをrequireメソッドで取り込む。 なお、minitestは標準ライブラリなのでインストール不要
  • MiniTest::TesTのサブクラスとして、テスト用のクラスを定義する。 クラス内には、setup、teardown、テスト用のメソッドを記述する
  • setupはテスト前に行う準備を記述する。 良くあるのは、インスタンスの生成など
  • teardownはテスト後の後始末をする。 上記のプログラムではsetupとteardownは何もしていないので、記述しなくても良い
  • テストのメソッドはその名前の先頭にtest_をつける。 ここではto_numberメソッドのテストなのでtest_to_numberとした。 ここでは、2重配列を使い、その8つの要素に対してeachメソッドを用い、テストする。 配列のそれぞれの要素は`["0",0]のように、数字の文字列(=>引数)と数字(返されるオブジェクト)となっている。 最初の例では"0"を引数にしてメソッドを呼び出したとき、0が返されることが期待されている。 期待通りならテストは通過(パス)し、そうでなければ失敗(フェイル)となる。
  • assert_nilは実行結果とnilを比較する。 つまり、期待される結果がnilで実行結果がassert_nilの引数になる。
  • assert_equalはminitestが提供するテスト用メソッドで、2つの引数をとる。 第1引数には期待されるオブジェクト、第2引数には実際にテストで得られたオブジェクトを書く。 両者が一致すればパス、一致しなければフェイルになる。 なお、期待されるオブジェクトがnilのときは、assert_equal nil, (テストで得られたオブジェクト)とすることもできるが、これは古い書き方で現在は推奨されていない。 assert_nilが推奨される書き方になっている。 テスト通過の場合は何も表示されず、失敗のときは期待された(expected)オブジェクトと実際の(actual)オブジェクトが表示される。

実行してみます。

$ ruby example23.rb
Run options: --seed 7828

# Running:

.

Finished in 0.005369s, 186.2654 runs/s, 1490.1234 assertions/s.
1 runs, 8 assertions, 0 failures, 0 errors, 0 skips

テストは成功したので、フェイルの表示が出ません。 ドットはテストするメソッド(この場合はtest_to_number)の個数分だけ表示されます。 一番下の行の「8 assertions」は8つのassert_・・・のテスト(アサーションと呼ぶ)が行われ、失敗0エラー0だったことを示しています。 なお、失敗は「期待した値と実行結果の値が異なること」、エラーは「プログラムのエラー(間違い)」で、違うものを表します。

このテストはトップレベルのメソッドを対象としました。 もしこのメソッドがトップレベルで定義されたインスタンス変数を使っているときは、その定義をsetupで行わなければなりません。 (トップレベルで行う定義をそっくりコピペすれば良い)。 トップレベルのインスタンス変数はmain(トップレベルのオブジェクト)のインスタンス変数になります。 これに対して、minitestのテストはMinitest::Testクラスのサブクラスに定義されたメソッドで行われるので、そこで参照されるインスタンス変数は「そのサブクラスのインスタンスの変数」になります。 したがって、mainのインスタンス変数は参照できないことに注意してください。 これを解決するには、さきほど述べたように、setupメソッドでインスタンス変数を定義しておきます。

標準出力のテスト

次のプログラムはテストするまでもない簡単なものですが、標準出力のテスト例として取り上げます。

@n = 0
def inc
  @n += 1
  p @n
end

inc
inc
inc

メソッドincはインスタンス変数@nを1つ大きくし、標準出力に出力します。 トップレベルでincを3回呼ぶと、

1
2
3

と、呼び出すごとに1つずつ大きくなって表示されます。

このテストは次のようになります。

class TestInc < Minitest::Test
  def setup
    @n=0
  end
  def teardown
  end

  def test_inc
    assert_output("1\n") {inc}
    assert_output("2\n") {inc}
    assert_output("3\n") {inc}
  end
end

大事なことはsetupでインスタンス変数@nの初期化をしなければなりません。 前項で述べたように、トップレベルのインスタンスがmainであるのに対し、test_incメソッドのインスタンスはTestIncクラスのインスタンスです。 したがって、トップレベルのインスタンス変数を参照することはできません。 setupでの@nの初期化は必須で、これがないとincの@n += 1を実行するときに、@nが未定義で参照されるためにnilになり、nilには+メソッドがないのでエラーになります。 これはエラーであってフェイルではありません。

このテストではassert_outputメソッドを使っています。 引数が期待される結果、それをブロックの実行の標準出力と比較して異なるとフェイル・メッセージが表示されます。

自作のテストプログラムで標準出力をテストするには、出力を捕まえる(出力先を変更する)必要があり、大変ですが、minitestを使えばassert_outputを使うだけですみます。

minitestのアサーションは豊富です。 minitestのドキュメントに詳細がありますので、参考にして下さい。

徒然Ruby(22)public、private、protected

今回はメソッドの呼び出し制限ついて説明します。 呼び出し制限にはpublic、private、protectedの3つがあります。

メソッドの公開と非公開

英語のpublicとprivateは「公的な」「私的な」という形容詞ですが、同時に「公開の」「非公開の」という意味も持っています。 プログラム上では後者の意味でよく使われる単語です。 Rubyではprivateを「メソッドをクラス定義の外に非公開にする」ために良く用います。

# プライベートメソッド(外から参照できないメソッド)の例
class Statistic
  def initialize array
    @array = array
  end
  def show
    print "合計は #{@array.sum}\n"
    print "平均は #{average(@array)}\n"
    print "標準偏差は #{stdev(@array)}\n"
  end
  private
  def average(array)
    (array.sum.to_f/array.size).round(1)
  end
  def stdev(array)
    mean = average(array)
    v = array.inject(0){|a,b| a+(b-mean)*(b-mean)}/array.size
    Math.sqrt(v).round(1)
  end
end

d = Statistic.new([5,6,7,8])
d.show
#=> 合計は 26
#=> 平均は 6.5
#=> 標準偏差は 1.1
p d.average([5,6,7,8]) #=> エラー、Privateメソッドを非関数形式で呼び出したため
p average([5,6,7,8]) #=> エラー、averageはトップレベルで未定義なため

この例ではStatisticクラスを定義しています。 このクラスは配列を引数にインスタンスを生成、初期化し、showメソッドで配列の統計量(合計、平均、標準偏差)を表示します。 クラスには3つのインスタンスメソッドshow、average、stdevが定義されていますが、クラス外部で使えるのはshowのみになっています。 最後の2行で、外部からaverageを呼び出していますが(片方はドットつき、他方はドットなし)いずれもエラーになります。 これはaverageがクラス定義の外部では呼び出せないことを示しています。 stdevも同様に外部からは呼び出せません。

これはクラス定義の中ほどにあるprivateメソッドによって、以下の行で定義されたクラス定義内のメソッドが「呼び出し制限がprivateであるメソッド」になるためです。 「呼び出し制限がprivaeteであるメソッド」を短く「privateメソッド」といいますが、「呼び出し制限を設定する方のprivateメソッド」と混乱しないように注意してください。 これは文脈から判断することになります。 最初のshowメソッドはprivateメソッドの前なのでpublicメソッドになります。 クラス定義におけるデフォルトはpublicです。

averageやstdevのようにクラス定義内では使うが、外からは使うことのないメソッドは、プログラム上で良く発生します。 そのようなメソッドを外部から呼び出すと問題が発生することが予想される場合は、privateメソッドにしておくべきです。 (上記の例ではそのような問題発生はありませんが、privateメソッドの例示のために設定しました)。

さて、privateの良くある使い方を説明しましたが、実はprivateの本当の意味は「非公開」ではなく、「関数形式でしか呼び出せない」なのです。 そこで、以下では呼び出し制限の定義とメソッド振る舞いについて解説します。

メソッドとレシーバ

まず、話の前提として「メソッドはレシーバとセットで呼び出される」ということを確認しておきたいと思います。 次の例を見てください

class A
  def intro
    print "私はクラスAのインスタンスです\n"
  end
end
a = A.new
a.intro #=> 私はクラスAのインスタンスです

メソッドintroがクラスAのインスタンス・メソッドとして定義されました。 そのメソッドを呼び出すには

  • クラスAのインスタンスを作成し、変数aに代入する
  • インスタンス、ドット、メソッド名」の形、この例ではa.introの形でメソッドを呼び出す

このときのインスタンスを「メソッドのレシーバ」と呼びます。

もうひとつ大事なことは「レシーバのクラスでそのメソッドを定義済みである」ということがメソッド呼び出しの前提です。 上記の例ではクラスAでintroを定義しているのでa(の指すインスタンス)をレシーバとしてintroを呼び出せるわけです。

※ 以下では「a(の指すインスタンス)」を単に「a」と書くことにします。

もし、定義されてなければエラーになります。

class A
  # introを定義しない
end
a = A.new
a.intro #=> エラー(NoMethodError)

Aのサブクラス(直接の子でなくても孫から先でも良い)はインスタンス・メソッドを受け継ぐので、メソッド呼び出しが可能です。

class A
  def intro
    print "私はクラスAのインスタンスです\n"
  end
end

class B < A
end

b = B.new
b.intro #=> 私はクラスAのインスタンスです

以上をまとめると

  • メソッドはレシーバとセットで呼び出される
  • レシーバのクラスでメソッドが定義されている、またはスーパークラスでメソッドが定義されていることが、メソッド呼び出しの前提である

ということになります。

呼び出し制限

呼び出し制限にはpublic、private、protectedの3つがあり、メソッドはこの3つのいずれかを持っています。

publicメソッド

publicメソッドは制限なしに呼び出せます。 つまり、プログラムのどこでもそのメソッドを呼び出せます。 また、クラスでメソッドを定義するとき、そのメソッドの呼び出し制限のデフォルトはpublicです。 最初の例をもう一度見てみましょう。

class A
  def intro
    print "私はクラスAのインスタンスです\n"
  end
end
a = A.new
a.intro #=> 私はクラスAのインスタンスです

メソッドintroの呼び出し制限はデフォルトのpublicです。 introはレシーバaとセットで、クラス定義の外で呼び出すことができます。 また、クラス定義の内部で呼び出すことも可能です。

class C
  def calc(x)
    print "合計は#{sum(x)}、平均は#{average(x)}\n"
  end
  def sum(x)
    x.sum
  end
  def average(x)
    x.sum.to_f/x.size
  end
end
c = C.new
c.calc([1,2,3,4]) #=> 合計は10、平均は2.5
c2 = C.new
c2.calc([1,2,3,4]) #=> 合計は10、平均は2.5

クラスCの内部では3つのメソッドcalc、sum、averageが定義されています。 そして、メソッドcalcの中では、sumとaverageの2つのメソッドが呼ばれています。 ここで注意したいのは、これらのメソッドは「インスタンス、ドット」の部分が無いということです。 つまりレシーバが指定されていません。 このように、レシーバが省略されている呼び出し形式を「関数形式」といいます。 関数形式の呼び出しでは、レシーバはあるのですが、それが書かれていないだけなのです。 そのレシーバを「デフォルトのレシーバ」と、ここでは言うことにします。

では、デフォルトのレシーバは何かと言うと、それは(外部から)calcを呼び出したときのレシーバです。 c=C.newでクラスCのインスタンスが変数cに代入され、c.calc([1,2,3,4])でそのインスタンスをレシーバとしてcalcが呼ばれた時点でレシーバが確定します。 つまり、レシーバは変数cの指すインスタンスです。 同様に、最終行のc2.calc([1,2,3,4])ではc2の指すインスタンスがレシーバです。

calcメソッドを実行中にsumとaverageを関数形式で呼び出すとき、そのレシーバはcalcのレシーバを用います。

  • c.calcを実行中=>関数形式の呼び出しは、c.sum、c.averageと同じ=>cがデフォルトのレシーバ
  • c2.calcを実行中=>関数形式の呼び出しは、c2.sum、c2.averageと同じ=>c2がデフォルトのレシーバ

メソッド定義の中では「self」という疑似変数がデフォルトのレシーバを指します。 selfの指すインスタンスは、calcが呼ばれた時点で確定します。

プログラムの3行目は

print "合計は#{self.sum(x)}、平均は#{self.average(x)}\n"

とレシーバselfを明示して書いても同じことになります。 ですが、特に何か意図するところがなければ、selfは省略するのが普通です。

ここでの説明をまとめると

  • 関数形式のメソッド呼び出しではレシーバが省略される
  • レシーバが省略されたときは、selfがレシーバになる
  • selfが確定するのは、selfを含むメソッドが呼ばれたとき。 selfにはそのメソッドのレシーバが代入される

ということになります。

self

今までのselfの説明はメソッド定義の中に限った話でした。 selfは他の場所では何を指しているのでしょうか? 一般にselfは次のようになっています。

  • トップレベルでは、オブジェクトmain(Objectクラスのインスタンス
  • クラス定義またはモジュール定義の中ではクラス(またはモジュール)自身

となります。

※ mainについてはRubyのドキュメントに記載があります。

注意が必要なのはメソッド定義の部分です。 「メソッドの定義」と「メソッドの呼び出し(または実行)」は別のことだと理解してください。 「メソッドを定義」しているときのselfと「メソッドを実行」しているときのselfは別物です。 メソッド定義には、メソッドを実行するときの振る舞いを書くのですから、selfも実行時の振る舞いが想定されなければなりません。 すなわち「selfには定義中のメソッドが実行されたときのレシーバが代入される」ということを想定してメソッド定義を書かなければなりません。

次の例で、selfが何を指すか確認してください。 表中央のselfはRubyがその行を実行中のselfです。 (「実行」には「クラス定義を実行」「メソッド定義を実行」などもあることに注意してください)。

プログラム self メソッド実行中のself
class A
def intro A
print "私はクラスAのインスタンスです\n" A introを呼び出したときのレシーバ
end A
end
a = A.new main
a.intro #=> 私はクラスAのインスタンスです main

※のところはselfはmainだと思われるが未確認。

トップレベルとクラス、モジュール定義はselfが分かりやすいですけれども、メソッド定義の中のselfを考えるのは面倒な場合があります。 次の例を考えてみましょう。

def average2(array)
  (array.sum.to_f/array.size).round(1)
end
def stdev2(array)
  mean = average2(array)
  v = array.inject(0){|a,b| a+(b-mean)*(b-mean)}/array.size
  Math.sqrt(v).round(1)
end

class Statistic2
  def initialize array
    @array = array
  end
  def show
    print "合計は #{@array.sum}\n"
    print "平均は #{average2(@array)}\n"
    print "標準偏差は #{stdev2(@array)}\n"
  end
end

d = Statistic2.new([2,4,3,9])
d.show
#=> 合計は 18
#=> 平均は 4.5
#=> 標準偏差は 2.7
  • average2とstdev2は引数の配列のデータの平均、標準偏差を返す。これらはトップレベルで定義されている
  • Statistic2クラスは、@arrayをインスタンス変数に持つ
  • インスタンスを作る時に、newメソッドに配列を引数として渡し、それを@arrayに代入する
  • showメソッドで合計、平均、標準偏差を表示する
  • showメソッドの中でaverage2とstdev2メソッドが呼ばれている。

さて、このとき、selfはそれぞれの場所で何を表しているでしょうか?

  • トップレベルでは、selfは「mainというオブジェクト」を指す。
  • average2とstdev2の定義の内部のselfは、メソッド実行時にはそれが呼ばれたときのレシーバが代入される
  • class〜endのshowメソッド定義のselfは、そのメソッド実行時にはそれが呼ばれたときのレシーバが代入される
  • Statistic2クラスのインスタンスdが生成される。
  • dをレシーバとしてshowメソッドが呼ばれる。 実行中のshowメソッドのselfはd
  • showの中でaverage2とstdev2が関数形式で呼ばれる。 関数形式のときはselfをレシーバとして呼ぶので、ここではdがレシーバになる
  • stdev2の中でaverage2が関数形式で呼ばれている。 関数形式なのでレシーバはself。 このときのselfはdである。
  • dはStatistic2クラスのオブジェクトである。 Statistic2クラスではaverage2もstdev2も定義されていないが、dはaverage2とstdev2のレシーバになりうるのだろうか?

細かい分析と検討の結果、最後に?のついた疑問が出てきました。

この疑問に答えるには、トップクラスで定義されたメソッドの扱いを知る必要があります。 Rubyのドキュメントには次のように書かれています。

トップレベルで定義したメソッドは Object の private インスタンスメソッドとして定義されます。

このことから、average2とstdev2はObjectクラスのメソッドです。

  • average2とstdev2はObjectクラスのインスタンス・メソッド
  • Statistic2クラスはスーパークラスの指定なく定義されているが、それでもObjectはStatistic2のスーパークラスになる。 (Objectはデフォルトですべての定義されるクラスのスーパークラス
  • スーパークラスインスタンス・メソッドはサブクラスに継承される
  • したがって、average2とstdev2はStatistic2クラスにも継承されている
  • 変数dの指すオブジェクトをレシーバとしてaverage2とstdev2を呼ぶことが可能である

このように、selfがどのインスタンスを指していようと、そのインスタンスのクラスはObjectのサブクラスなので、トップレベルで定義されたメソッドを継承しています。 よって、selfはそのメソッドのレシーバになれるので、関数形式でそのメソッドを呼ぶことができるのです。

長い考察でしたが、簡潔にいうと

「トップレベルで定義したメソッドは、プログラムのどこでも関数形式で呼び出すことができる」

ということになります。

privateメソッド

privateメソッドは関数形式(メソッド名だけでの呼び出し)でのみ呼び出せます(ただし「self.メソッド名」ならば呼び出すことが可能)。 ということは、privateメソッドは「selfをレシーバとしてのみ呼び出せる」ということです。

また、一般にメソッド呼び出しにおけるレシーバは次の条件を満たしていなければなりません。

  • レシーバのクラスでそのメソッドが定義されている
  • または、レシーバのクラスのスーパークラスがそのメソッドを定義している。 (逆に言えば、レシーバが「メソッドを定義したクラスのサブクラスのインスタンス」である)

この2つを組み合わせると、privateメソッドを呼び出すにはselfの指すオブジェクトのクラスが上記の2条件のいずれかを満たすことが必要です。 説明が複雑になるので、例をあげて説明します。

  • privateメソッドprvがクラスCで定義されている
  • publicメソッドpubがクラスCで定義されている
  • Cのインスタンスをcとする

トップクラスではselfはmainを指していて、mailはObjectクラスのインスタンスで、Objectではprvメソッドが定義されていないので、prvを関数形式で(つまりselfであるmainをレシーバとして)呼ぶことはできません。 同様にトップクラスでpubも関数形式では呼べませんが、pubは「インスタンス、ドット、メソッド名」の形式でも呼び出せるので、c.pubとして呼び出すことができます。

c.pubが実行されている間は常にselfとcは同じオブジェクトを指しています。 ですから、この実行の最中はprvを関数形式で呼び出すことが可能です。

それはどのようなときでしょうか? それはpubの定義の中で直接または間接にprvを呼び出すときです。

以上の考察をまとめると

  • privateメソッドは、そのクラス定義のメソッド定義(これはそのメソッド自身でも他のメソッドでも良い)の中で直接または間接に呼び出すことができる
  • それ以外はprivateメソッドを呼び出すことができない。

ということになります。 「直接または間接」というのは後ほど説明します。

ただし、ひとつ注意が必要で、メソッドが定義されたクラスのサブクラスはメソッドを継承できるということから、サブクラス中のメソッド定義でもprivateメソッドを呼び出せます。

privateの本来の意味は「呼び出し形式が関数形式に限る」ということなのですが、その結果として「クラスとサブクラスの定義外では(通常は)呼び出せない」ということになります。 それがprivate(非公開)という名前になっている理由だと思われます。

実際問題、クラス定義の中だけで使われる関数は良くでてきます。 そのような関数にはprivateの設定をしましょう。

さて、クラス定義の中のメソッドはデフォルトでpublicでした。 privateにするためにはprivateメソッドを使います。 このことは冒頭の例でも示しました。 privateメソッドを引数なしで呼び出すとその後のメソッドはすべてprivateメソッドになります。

また、privateメソッドを引数つきで使うと、その引数のメソッドがprivateメソッドになります。 このときはprivateメソッドに先立って対象となるメソッドを定義しておかなければなりません。 引数のメソッド名にはシンボルまたは文字列を使いますが、シンボルを使う方が普通です。

private :average

これにより、averageメソッドだけがprivateメソッドになります。 引数なしのprivateメソッドと異なり、引数付きのprivateメソッドでは以後のメソッドは引き続きデフォルトがpublicであるとして定義されます。

privateメソッドに対応するpublicメソッドとprotectedメソッドがあります。 これらはメソッド名を引数にとり、そのメソッドの呼び出し制限を設定します。 また、引数なしで実行されると、それ以後に定義されるメソッドすべての呼び出し制限を設定します。

最後に「直接または間接」を詳しく説明しましょう。 「直接呼び出す」の意味は明らかですから、「間接的に呼び出す」例を示します。

class D
  def a
    print "privateメソッドaです\n"
  end
  def b
    c #トップレベルのメソッドcを呼び出す
  end
  private :a
end
def c
  a # privateメソッドaを呼び出す
end
d = D.new
d.b #=> privateメソッドaです
  • このプログラムではprivate :aによって、メソッドaの呼び出し制限がprivateになってる
  • メソッドcはトップレベルのメソッドで、aを関数形式で呼び出している
  • クラスDのインスタンスを生成し変数dに代入。d.bでpublicメソッドbを呼び出す
  • メソッドbのselfはdである。bの中でトップレベルのメソッドcを呼ぶ メソッドcはObjectのメソッドなのでサブクラスDで継承されている。 したがって、インスタンスdはメソッドcのレシーバになりうる。 よってメソッドcの呼び出しはエラーにならない
  • cが呼ばれたときselfはdである。 dはaのレシーバになれるので、関数形式のaの呼び出しはエラーにならずに行われる
  • aが呼び出されたので「privateメソッドaです」が表示される

この例ではprivateメソッドaがクラス定義の外で正常に呼び出されました。 それは、メソッドcがDのメソッドbから呼び出されたため、selfがdのままの状態でメソッドcが実行できたからです。 この場合privateメソッドaはDのメソッドbによって、cを通して間接に呼ばれています。 これが「間接的な呼び出し」の意味です。

bとは独立にcがaを呼び出すことはできません。

これは、privateメソッドがクラス外で呼び出せる例外です。 通常はprivateメソッドはクラス外から呼び出せないと考えても問題ありません。

protectedメソッド

protectedメソッドはprivateメソッドに似た使い方ができますが、定義は違います。 protectedメソッドは「そのメソッドを持つオブジェクトが selfであるコンテキストでのみ」呼び出せます。

ここで難しいのが「コンテキスト」だと思います。 私も正確な定義は分かっていないのですが、実行時の「状態」をデータとしてまとめたものを一般にコンテキストというので、Rubyでも同様だと思います。 protectedの理解自体にはコンテキスト全体の理解までは必要ありません。 selfの状態だけを理解するだけで十分です。

さて、実行状態において、そのメソッドを持つオブジェクトがselfというのはどういうときでしょうか。 privateの最後の例では、メソッドがb=>c=>aと呼ばれる間ずっとselfとdは同じオブジェクトを指していました。 この状態が「そのメソッドを持つオブジェクトがself」です。 そのときにはprotectedメソッドは(関数形式でも関数形式でなくても)呼び出すことができます。

class E
  def initialize(name)
    @name = name
  end
  def a
    print "protectedメソッドaです\n"
    print "レシーバは#{@name}です\n"
  end
  def b(e)
    e.a # selfはdになっているのでprotectedメソッドを使える
  end
  protected :a
end
d = E.new("d")
e = E.new("e")
d.b(e) #=> privateメソッドaです レシーバはeです

この例ではpublicメソッドbを呼び出した時に、selfはdと同じものを指しています。 「そのメソッドを持つオブジェクト(d)が selfであるコンテキスト」になっています。 そこで、protectedメソッドaが使えますが、ここではeをレシーバにして呼び出しています。 このように、関数形式ではない形でも呼び出せるのはprivateとの違いです。

まとめ

  • publicメソッドは制限なしに呼び出せる
  • privateメソッドは関数形式(メソッド名のみでの呼び出し)でのみ呼び出せる(ただし「self.メソッド名」ならば呼び出すことができる)
  • protectedメソッドは、そのメソッドを持つオブジェクトが selfであるコンテキストでのみ呼び出せる

この呼び出し制限から、privateメソッドとprotectedメソッドはクラス定義外では(一部例外を除いて)呼び出すことができません。 メソッドを「非公開」にするにはprivateでもprotectedでも同様の効果が期待できますが、privateを使っている例がほとんどです。 privateという名前が「非公開」をイメージしやすいからでしょうか。

徒然Ruby(21)特異メソッド、名前空間、モジュール関数

今回は特異メソッド、特異クラス定義、名前空間、モジュール関数について説明します。

特異メソッド

特異メソッドは英語ではsingleton method(一人っ子のメソッド)といいます。 英語の方が意味をとりやすいと思います。 特異メソッドは個々のオブジェクト(インスタンス)だけがレシーバになるメソッドのことを言います。

次の例はクラスAのインスタンスを2つ作り、そのうちの一方に特異メソッドを作ったときの実行結果を示すものです。 オブジェクトaに特異メソッドを定義するにはdef a.introのように「オブジェクト、ドット、メソッド名」のようにします。

class A
end

a = A.new
b = A.new

def a.intro
  print "私はaです\n"
end

a.intro #=> 私はaです
b.intro #=> エラー (NoMethodError)

実行します。

私はaです
example21.rb:12:in `<main>': undefined method `intro' for #<A:0x00007f6f3d737bd0> (NoMethodError)

b.intro
 ^^^^^^

aにはintroメソッドを定義したので「a.intro」の結果「私はaです」が出力されましたが、bには定義されていないので、「b.intro」はエラーになりました。

通常のメソッドは同じクラスから生成したインスタンスすべてで可能ですが、特異メソッドはそれが定義された個別のインスタンスでのみ可能です。 特異メソッドは様々なケースで使えると思いますが、例えば次のようなケースが考えられます。

  • クラスUserはあるシステムを使用するユーザを定義する
  • 個々のユーザはUserクラスのインスタンスとして生成する
  • ユーザの中には一人だけ管理者ユーザがいる。つまりUserクラスのインスタンスの中にひとつだけ管理者ユーザ・インスタンスがある
  • 管理者ユーザ・インスタンスには特別のメソッドを付与する(特異メソッドになる)

クラスメソッド

Rubyでは個々のクラス(例えばIntegerやString)はClassクラスのインスタンスです。 話がややこしくなりますが、クラスはクラスであると同時にインスタンスでもあるわけです。

クラス名は大文字で始まりますが、この文字は定数を表しています。 クラスを定義すると、そのクラスオブジェクト(Classクラスのインスタンス)が生成され、定数に代入されるのです。 つまり大文字で始まる名前は、クラスを表す名前であると同時に定数の名前でもあります。

クラスはインスタンスですから、変数に代入することもできます。

class A
end

c = A
p c  #=> A
p c.class #=> Class

変数cにはクラスA(のオブジェクト)が代入され、「p c」でクラス名Aが、「p c.class」でクラスAのクラスであるClassが表示されます。

このようにクラスを変数に代入することはできますが、通常はそういうことはしません。 それはプログラムを分かりにくくなることに繋がります。 定数を参照すれば、それがクラスだと直感的に分かります。 定数だけをクラスに用いるのが良い習慣です。

さて、クラスはオブジェクトですから、特異メソッドを定義することができます。 クラスの特異メソッドをクラスメソッドといいます。

class A
end

def A.intro
  print "私はクラスAです\n"
end

A.intro #=> 私はクラスAです
selfを使った定義

クラスメソッドの定義はクラスの内側でもできます。

class A
  def A.intro
    print "私はクラスAです\n"
  end
end

A.intro #=> 私はクラスAです

この方法では疑似変数selfを使うことができます。 「class A〜end」では、selfは「ClassクラスのインスタンスであるクラスA」を指します。 したがって、この区間では定数Aとselfは同じオブジェクトを指します。 ですので、

class A
  def self.intro
    print "私はクラスAです\n"
  end
end

A.intro #=> 私はクラスAです

のように、selfを使ってクラスメソッドを定義することができるわけです。

クラスメソッドの継承

クラスメソッドはそのサブクラスにも継承されます。 上記のプログラムに続き、以下を追加するとBにもAのクラスメソッドintroが継承されます。

class B < A
end
B.intro #=>私はクラスAです
モジュールの特異メソッド

特異メソッドは個々のオブジェクトにメソッドを定義するものでした。 クラスメソッドはクラス(クラスはオブジェクトでもある)の特異メソッドです。 ではモジュールについてはどうでしょうか? モジュールはクラス同様にオブジェクトなのでしょうか? 答えはイエスです。 モジュールはModuleクラスのインスタンスです。 したがって、モジュールの特異メソッドを作ることができます。

module C
end

def C.intro
  print "私はモジュールCです\n"
end

C.intro #=> 私はモジュールCです

モジュール定義の中ではselfを使った特異メソッド定義が可能です(クラスの場合と同じ)。

モジュールの場合はクラスと違い、サブモジュールを作ることができませんから、特異メソッドの継承はありません。 また、モジュールをincludeしても特異メソッドの継承はできません。 インクルードは通常のメソッドと定数を引き継ぐだけです。

名前空間

トップレベルに同じ名前のメソッドを2つ作ることはできません。 それをすると、2番めの定義が1番めの定義の「再定義」になり、以後2番めの定義だけが有効になります。 しかし、異なる動作をする2つのメソッドに同じ名前をつけたいときもあります。 例えば、あるプログラムはMarkdownファイルをHTMLにもPDFにも変換できるとします。 「変換する」メソッドは、convertという名前が良いでしょう。 HTMLに変換するのもconvert、PDFに変換するのもconvertにしたいのです。

このとき、convertにプリフィックスをつけて区別することが可能です。

def html_convert
... ... ...
end
def pdf_convert
... ... ...
end

このようなプリフィックス名により、区別することを「名前空間」を使うといいます。 HTMLに関するメソッドにはすべてhtml_をつけ、PDFに関するメソッドについてはpdf_をつけるわけです。

同様のことはmoduleの特異メソッドでも実現できます。

module HTML
end
module PDF
end

def HTML.convert
... ... ...
end
def PDF.convert
... ... ...
end

このようにモジュールの特異メソッドは名前空間を付与することと同一の効果があります。 同じことをクラスでもできますが、クラスには継承があるため、サブクラスを作ると別の名前空間で同じ内容のものが作られてしまいます。 したがって、名前空間としてはモジュールの利用が適しています。

しかし、クラスの特異メソッドも名前空間に似た効果があります。 例えば、IOクラスにはreadという特異メソッドがあります。

s = IO.read(ファイル名)

このメソッドはファイルを読み込み、その文字列を変数sに代入します。

単にreadという名前のメソッドでは名前の衝突が不安になりますが、IO.がついているので、これはIOクラスの特異メソッドだと分かります。 これは名前空間に似た効果です。 また、IOのサブクラスにFileがあります。 特異メソッドはサブクラスに引き継がれるので

s = File.read(ファイル名)

でも同じreadメソッドが呼ばれます。 これはファイルクラスの特異メソッドと見えますが、実はIOの特異メソッドの継承です。

特異メソッドは一般のオブジェクト(通常のクラスから生成したインスタンス)に定義するよりも、モジュールやクラスに定義する場合が多いように思います。 実際Rubyのドキュメントを見ると、クラスメソッドは数多く定義されています。 最もよく使うのはインスタンスを生成するnewメソッドでしょう。 これはほとんどのクラスの祖先であるObjectクラスの特異メソッドです。 各クラスはそれを受け継いでいるので、newが使えるのですね。

特異クラス定義

特異クラス定義とは、次のようにclass <<(式)〜endという構文のことをいいます。 ここで定義されるメソッドは(式)の指すオブジェクトの特異メソッドになります。

class A
end
a = A.new
b = A.new
class << a
  def intro
    print "私はaです\n"
  end
end
a.intro #=> 私はaです
b.intro #=> エラー(NoMethodError)

classに<< aをつけると、aの指すオブジェクトについて定義をすることになります。 そこで定義されるメソッドはすべてaの指すオブジェクトの特異メソッドです。 この記法の良いところは「多くの特異メソッドを少ないタイプ量で記述できる」ことです。

また、selfをこの記法に使い、クラスメソッドを定義することができます。

class A
  class << self
    def intro
      print "私はクラスAです\n"
    end
  end
end
A.intro #=> 私はクラスAです

これにより、class A〜endの中で、普通のメソッドもクラスメソッドも定義することができ、非常に効率的になります。 この記法が最も良いかもしれません。 モジュールにはこのタイプの記法がないので、個別に作ることになります。 ですが、次に述べるモジュール関数を作る方法を適用すると簡単に特異メソッドと通常のメソッドの2つができます。

モジュール関数

ビルトイン・オブジェクトの中にMathモジュールがあります。 数学関数を提供するモジュールです。 例えば正弦関数はMath.sin(角度)として呼ぶことができます。 また、モジュールをインクルードしておけば、sin(角度)とモジュール名を省略することができます。

p Math.sin(Math::PI/3) #=> 0.8660254037844386
include Math
p sin(PI/3) #=> 0.8660254037844386
  • Math::PIはモジュールMathの中で定義されている定数。 定数の場合はドットではなく::を使ってモジュール下の定数であることを示す。 クラス定義の中で定義された定数も「クラス名::定数」となる。 これはMathモジュール外部からの参照で用いられる
  • includeによって、モジュール内の定数やメソッドが取り込まれる。 PIにMath::を付けなくて良い。 また、sinメソッドはMathモジュールの通常のメソッドである

ここで注意が必要なのは、Math.sinとsinメソッドの違いです

  • Math.sin=>Mathモジュールの特異メソッド
  • sin=>Mathモジュールの通常のメソッドがincludeで取り込まれたもの

この2つがセットになっているので、インクルードを使う/使わないの選択が可能になります。 これは便利なことなので、モジュールの提供するメソッドに名前空間をつける目的であれば、両方のメソッドの定義をするべきです。 (Math.がつかないsinはインクルードが必要なので、その時点で名前の衝突を避けることができる)。 このようなメソッドを「モジュール関数」といいます。

以前紹介したモジュールのミックス・インは通常のメソッドを複数のクラスに提供する仕組みでした。 ミックス・インのメソッドは、特異メソッドを含まないので、モジュール関数ではありません。

モジュール関数は通常のメソッドと特異メソッドの2つを定義しなければならないのですが、それは非効率です。 それを解決するメソッドがmodule_functionメソッドです。

  • 通常のメソッドを定義する
  • module_function メソッド名によって、そのメソッドがモジュール関数になる。 メソッド名はシンボルで表す
module X
  def intro
    print "私はモジュールXのモジュール関数です\n"
  end
  module_function :intro
end
X.intro #=> 私はモジュールXのモジュール関数です
include X
intro #=> 私はモジュールXのモジュール関数です

有用な関数をプログラム全体で使えるように提供したいときには、このようなモジュール関数として提供するのが良い方法です。 特にライブラリの作成においては、モジュール関数にするのがベストな選択です。 (ライブラリでなければ、トップレベルのメソッドでも十分です)。

定数の名前空間

前項で述べたように、モジュール内で定義された定数をモジュール外から参照するには「モジュール名::定数名」としなければなりませんでした。 これは「モジュールで、定数にも名前空間をつけることができる」ということを示しています。

定数は変数と同様にオブジェクトを指しますが、変数と違い再代入ができません。 その定数が生きている間はずっと同じオブジェクトを指します。 ユーザが定義する定数は数字や文字列(特にフリーズした文字列)が多いと思いますが、その他にクラス名やモジュール名も定数です。 このことから「クラスやモジュールにも名前空間を適用することができる」ということができます。

module ABC
  class A
    def intro
      print "モジュールABCのAです\n"
    end
  end
end
module EFG
  class A
    def intro
      print "モジュールEFGのAです\n"
    end
  end
end

a = ABC::A.new
b = EFG::A.new
a.intro #=> モジュールABCのAです
b.intro #=> モジュールEFGのAです

プログラムが大きくなり、クラス名の名前の衝突が心配であれば(特にライブラリでは)モジュールを使った名前空間の提供が解決手段となるかもしれません。

徒然Ruby(20)アプリ制作、インストール、テスト

2023/11/4追記

この記事の内容は古くなり、不適当な部分があります。 それはGitHubにあるCalcレポジトリが大幅に更新されたことによります。 そのため、下記の記事の代わりとして、GitHubページの徒然なるままにRuby -- アプリ制作、インストール、テストを参照してください。 ご迷惑をおかけしますが、よろしくお願いいたします。

追記終わり

だいぶRubyの説明は進みましたから、このあたりでアプリを作る上でのポイントを述べたいと思います。 そのために、簡単な電卓プログラムを作り、GitHubにアップロードしたので、参考にしてください。

ファイル名で起動する方法

Rubyプログラムは

$ ruby ファイル名

で起動できるのですが、「ruby」と入力するのは煩わしいものです。 アプリ作成中は仕方がないとしても、完成したアプリはファイル名だけで起動したいですね。 それを実現するには、次の3行をファイル名の先頭に付けます。

#!/bin/sh
exec ruby -x "$0" "$@"
#!ruby

お呪いのようなものだと考えて、コピペしても構いません。 一応説明すると、まず1行目の#!はシバン(shebang)と呼ばれ、Unix系のOSではスクリプトを実行するプログラムを指定します。 この場合は「/bin/shによってこのファイルを実行する」のですから、シェルスクリプトとしての実行になります。 (/bin/bashではなく/bin/shとなっているのは、システムによってはbash以外のシェルが使われているかもしれないからです。 /bin/shはいろいろなシェル共通の呼び出しを提供します)。

2行目はシェルのコマンドで、execはそのシェルのプロセスで(新規プロセスを生成せずに)コマンドを実行する、というものです。 $0は起動されたファイル名(スクリプトファイルのファイル名)、$@は引数すべてを表します。 これから、もしこのスクリプトファイルのファイル名がcopyコマンドラインから次のように呼ばれたとすると、

copy file1 file2

まず、`/bin/sh/が起動され、2行目が実行されます。 2行目は

ruby -x copy file1 file2

コマンドラインから入力するのとほぼ同じことになります。

-xrubyのオプションで、「スクリプトを読み込む時に、`#!'で始まり, "ruby"という文字列を含む行までを読み飛ばす」というものです。 このことにより、最初の3行(#!/bin/sh〜#!ruby)が読み飛ばされ、4行目からrubyプログラムが実行されます。

Unix系OSのシバングは指定されたファイルを実行するので、

#!/usr/bin/ruby

でも良いように思われますが、これだとまずい場合もあります。 最もありそうなケースはrbenvでインストールしたrubyです。 rubyは$HOME/.rbenv以下に保存されるので、/usr/bin/rubyでは呼び出せません。 ($HOMEはユーザのホームディレクトリで、シェルからは~でも参照できます)。 このrubyはシェルから呼ぶことにより起動できるので、いったんシェルを起動してからrubyを起動する、という面倒なやり方が必要なのです。

コマンドへの引数の処理

Unix系OSではコマンドラインの構成が

コマンド 引数1 引数2 ・・・・

となっています。 Rubyでは、引数はARGVという配列に代入されます。 例えば

$ ruby_echo Hello world

とコマンドruby_echoが呼ばれたとき、

  • ARGV[0]には文字列"Hello"が代入されている
  • ARGV[1]には文字列"world"が代入されている

となります。 コマンドラインでは半角空白が引数の区切り文字になります。 区切り文字はARGVの中には入りません。 もし、空白も入れたいというときには、シングルクォートを用います。

$ruby_echo 'Hello world'

この場合はARGV[0]に文字列"Hello world"が代入されます。 引数は1個ということになります。

引数が何個あるかは配列の要素の数を返すメソッドsizeを使います。 ARGV.sizeが引数の数です。

ruby_echoのプログラムは簡単です。

#!/bin/sh
exec ruby -x "$0" "$@"
#!ruby

print ARGV.join(' '), "\n"

配列ARGVの各要素をjoinメソッドで繋げて文字列にします。 そのとき要素の区切りには、引数' '(半角空白)が用いられます。

インストーラ

作成例として、電卓プログラムcalc.rbを考えてみましょう。 このプログラムをコマンド名calcでインストールしたいとします。 その場合は$HOME/bin以下にファイルを置き、実行属性をつければよいのです。 FileUtilsモジュールを使うのが便利です。

require 'fileutils'
include FileUtils

def install
  cp "calc.rb", "#{Dir.home}/bin/calc"
  chmod 0755, "#{Dir.home}/bin/calc"
end
  • Dir.homeはユーザのホームディレクトリ($HOMEと同じ)を返すメソッド
  • cpはFileUtilsモジュールのメソッドで、ファイルをコピーする。
  • chmodはFileUtilsモジュールのメソッドでファイルの属性を指定する。 0755は8進整数を表す。このファイル属性は
    • 所有者は読み、書き、実行可
    • グループメンバーは読み、実行可で、書き込不可
    • その他ユーザは読み、実行可で、書き込不可 となる

実行属性をつけないと、ファイル名での起動はできません。 以上のようにインストールするとコマンドラインから

$ calc

で起動できるようになります。

アプリケーションを作るときには、インストーラも作っておきましょう。 また、アンインストーラインストーラに含め、オプションで切り替えても良い)も入れておくと良いです。 GitHubのCalcにはインストーラinstall.rbが添付されているので参考にしてください。

上記ではユーザ領域へのインストールでしたが、他のLinuxユーザにも使えるようにするには/usr/local/binにインストールします。 このときは管理者権限が必要なので、Ubuntuなどではsuコマンドを使います。 例えば

$ su ruby install.rb

のように起動します。

しかし、システム領域にインストールする必要はほとんどないと思います。

テスト

Rubyの標準のテスト・スートはminitestです。 名前はミニですが、結構大きいプログラムで、ドキュメントの量もあります。 minitestは別の記事で詳しく述べようと思いますが、ここではポイントを絞って書きたいと思います。

テストプログラムはRubyで書きます。 calcのテストは次のような感じになります。

require 'minitest/autorun'
require_relative 'lib_calc.rb'

class TestCalc < Minitest::Test
  def setup
    @calc = Calc.new
  end
  def teardown
  end

  def test_lex
    assert_equal [[func.to_sym,nil],[:'(',nil],[:id,"x"],[:')',nil]], @calc.lex("#{func}(x)")
... ... ...
... ... ...
  end

  def test_parse
    assert_output("100\n"){@calc.run("10*10")}
... ... ...
... ... ...
  end
end
  • minitesti/autorunをrequireで取り込む
  • テストしたいファイルlib_calc.rbはテストプログラムと同一ディレクトリにある。 取り込みにはrequire_relativeを使う
  • テスト用のクラス(ここではTestCalcという名前になっている)をMinitest::Testのサブクラスとして定義する
  • クラス内にはsetup、teardown、各テスト用のメソッドがある。
    • setup=>各テストの前に準備作業をするためのメソッド
    • teardown=>各テスト後の後始末をするためのメソッド
  • テスト用のメソッドにはtest_というプレフィックスをつける
  • assert_equial A, B は「Aが正常に機能したときの結果のオブジェクト」「Bが実行結果のオブジェクト」で、それらが一致すればテストを通過したことになり、一致しないとメッセージが出力される。 前者をexpected(期待される結果)、後者をactual(実際に行った結果)としてメッセージに書き込まれる
  • assert_output(A){ B }はAが標準出力への期待される出力、ブロックは実行メソッド。 メソッド(B)を実行した出力とAが一致すればテスト通過、一致しなければメッセージが出力される

テストがすべて通ると次のように出力されます。

$ ruby test.rb
Run options: --seed 43869

Running:

..

Finished in 0.006940s, 288.1909 runs/s, 8501.6308 assertions/s.
2 runs, 59 assertions, 0 failures, 0 errors, 0 skips
$ 

ドットはテスト項目、つまりTestCalcの各メソッドを表しています。 エラーがあると次のようなメッセージが出力されます。

Run options: --seed 34278

Running:

.F

Failure:
TestCalc#test_parse [test.rb:24]:
In stdout.
--- expected
+++ actual
@@ -1,2 +1,2 @@
-"100.0
+"100
 "



rails test test.rb:23



Finished in 0.010876s, 183.8891 runs/s, 4505.2836 assertions/s.
2 runs, 49 assertions, 1 failures, 0 errors, 0 skips

Failureはテストで失敗したことを示しています。 この他にErrorが出ることがありますが、それはプログラムを実行した時にエラーがあったことを意味しており、テストの結果ではありません。 上記ではexpectedがマイナスでactualがプラスで表されているので、「100.0」になることを期待してテストしたが、実際は「100」になったということを表しています。

すべての場合をテストするのは無理なので、典型的な例をテストすることになります。 プログラムのエラーは境界で起こりやすいです。 例えば正負が問題になるプログラムでは0が境界です。 「(変数)>= 0」を使わなければいけないのに「(変数)> 0」を使うといったバグは0以外ではフェイル(失敗)が起こりません。 ですから「境界をテストする」ことは非常に重要です。

GitHubのcalcにはtest.rbというテストプログラムがついているので参考にしてください。

なお、minitestを使うことを考えると、トップレベルだけでプログラムを作るのは得策ではありません。 仮にインスタンス変数をトップレベルで使うと、minitestからは参照できなくなります。 それは、テストのためのメソッドがTestCalcクラスで定義されているので、テスト時のインスタンスはトップレベルではないためです。 ですから、上の例のように、アプリの中でクラスを定義し、それをsetupメソッドでインスタンス生成して使うのが良い方法です。

Readme.md

簡単なドキュメントは付けておくべきです。 仮に公開しなくても、将来自分自身が見直す時に役に立ちます。 2週間別の仕事をすると、元の仕事内容を思い出すのに結構な時間がかかります。 そのときにドキュメントは役に立つでしょう。

GitHubに公開する場合はReadme.mdのようなファイル名をつけることになっています。 拡張子のmdはMarkdown形式を表します。 Markdownはhtmlと比べ格段に見やすく、書きやすいので勧められる形式です。 Markdownの説明は、次の記事を参考にしてください。

ただし、はてな記法などのはてな独自の記法はGitHubでは使えません(GitHubMarkdownはGFM)。

GitとGitHub

プログラムを公開するならばGitHubは無料で、機能が充実していて、有力な選択肢です。 GitHubとGitについては「はじめてのJekyll+GitHub Pages」の中に書かれていますので、以下を参考にしてください。

  • 第3章 GitHub pagesクイックスタート
  • 第7章 Gitの使い方
  • 第10章 GitをSSHで使う方法

が参考になります。 このうち第10章のSSHで使う方法は知らなくても大丈夫です。

今回はアプリ開発の実際を見てきましたが、いかがだったでしょうか。 簡単なアプリで良いのでぜひ作ってGitHubにあげてみてください。 作れば作るほどプログラミングのレベルは上がります。

徒然Ruby(19)Case文

if〜elsif〜・・・〜else〜endは皆さん良く使うでしょうか? これは場合分けで良く使われる方法です。 これと同様の制御構造にcase文があります。 Cのswitch文に似ていますが、より強力な機能を持っています。 if-else-endよりも高い能力があるといえます。

case文の使い方

case文は次のような構造で使います。

case [式]
[when 式 [, 式] ...[, `*' 式] [then]
  式..]..
[when `*' 式 [then]
  式..]..
[else
  式..]
end
  • caseの次にある「式」に対してwhenの次の「式」(またはその並びのうちのどれか)が一致したときthen以下が実行される。 最初に一致したwhen節があれば、残りのwhen節の比較、実行は行われず、case文全体の次(endの先)に実行が進む
  • whenの式との比較は===メソッドを使う(==ではない)。 ===メソッドのレシーバはwhen節の式の値となるオブジェクトである。 case文の後の式は===メソッドの引数となる
  • どのwhen節にも一致しなければelse節が(あれば)実行される
  • then後の式を次行以下に書く場合はthenを省略できる

これより、次のcase文とif-else文はほぼ同等です。

case x
when 1 then p "x is 1."
when 2 then p "x is 2."
else p "x is not 1 or 2."
end

if 1 === x then p "x is 1."
elsif 2 === x then p "x is 2."
else p "x is not 1 or 2."
end

なお、when節の式の前にアスタリスク*)を付けると、式は展開されます。

when *[1,2,3] => when 1,2,3 となる

case文のような構造を条件分岐ともいいます。 条件分岐はプログラムで最も良く現れる構造です。

==と===の違い

=====はオブジェクトごとにメソッドとして定義されています。 ほとんどのメソッドの祖先であるObjectクラスでは、=====の別名になっていますので、その子孫クラスで===を独自に定義していなければ=====は同じ動作をします。 =====と違うのはビルトイン・クラスでは次のものです。

  • Methodクラス。 ==はオブジェクトとして等しいかどうかを返す。 ===は右辺を引数にメソッドオブジェクトを実行した結果を返す
  • Moduleクラス。 ==は同じモジュールかどうかを返す。 ===は右辺がそのモジュールのサブクラス(子だけでなく子孫すべて)のインスタンスであるときtrueを返す。 すなわち、A===bb.kind_of?(A)は同じ値を返す。 なお、ClassクラスはModuleのサブクラスで===メソッドを継承しており、同様に使うことができる RefinementクラスもModuleのサブクラスで===メソッドを継承しているが、このクラスを使うのはライブラリなどに限ると思われる
  • Procクラス。 ==はオブジェクトとして等しいかどうかを返す。 同じ手続きを表すProcオブジェクトでも異なるインスタンスはfalseになる。 p proc{|x| x*x} == proc{|x| x*x} #=> false。 実際問題として==はほとんど役に立たない。 ===は右辺を引数にProcオブジェクトを実行した結果を返す。 Methodクラスの実装と考え方は全く同じ
  • Rangeクラス。 ==は同じ範囲を表していればtrueを返す。 すなわち、それぞれの端が同じ(==)であり、端の含み方が同じ(..なのか...なのか)ときにtrue。 ===は引数がRangeオブジェクトの範囲の中にあればtrue。
  • Regexpクラス。 ==正規表現として同じであればtrueを返す。 ===は引数の文字列またはシンボルがマッチすればtrueを返す。 if文では良く=~が用いられるが、=~===はほとんど同じ(ただ返し値は異なる)。

===はcase文専用です。 ですので、完全なイコールでなく、マッチに近いメソッドになっています。

特にProcは非常に柔軟なマッチを可能にしています。 Methodも同様ですが、Procの方がよく使われるのではないかと思います。

引数のチェック(クラスのwhen節)

Rubyの変数は任意のオブジェクトを代入できます。 これは変数自体には型がないということです。 そのため、コンパイル時にメソッドの引数の型をチェックすることができません。

※ Rubyはソースを中間コードにコンパイルしてから実行しています

C言語は変数に型があり、パラメータに対しても型を指定できます。

int square (int x) {
  return x*x
}

このCの関数のパラメータは整数型です。 コンパイル時にこの関数を整数型以外の引数で呼び出している文があれば、エラーになります。

Rubyでは、このようなチェックは実行時に行うしかありません。 メソッドの最初に引数のオブジェクトがどのようなクラスのオブジェクトかを調べます。 次の例は引数を2乗して返すメソッドです。

# 引数を2乗して返すメソッド
def square x
  case x
  when Numeric
    x*x
  else # impossible to calculate, return nil
    return nil
  end
end

p square("abc") #=> nil (文字列は数字でない)
p square(2) #=> 4
p square(1.2) #=> 1.44
p square(Complex("1+2i")) #=> (-3+4i) 複素数も計算できる
p square(Rational("2/3")) #=> (4/9) 分数(有理数)も計算できる

数字のクラスInteger(整数)、Float(浮動小数点数)、Complex(複素数)、Rational(有理数)はすべてNumeric(数)のサブクラスです。 when Numericの節では、上記の4つのオブジェクトであるかをひとつのチェックで済ましています。 else(それ以外)のときはnilを返すことにします。 これは積極的なエラー対策ではなく、呼び出し側にエラー処理を任せるやり方です。

squareを呼び出したとき、文字列ではnilが返り、整数、浮動小数点数複素数有理数では2乗された値が返されています。

このような引数のクラスチェックは特にライブラリでは重要です。 ライブラリの作成者と使用者は異なるのが普通であり、作成者に予想外の使われ方をするかもしれません。 そのためこのようなチェックは非常に重要になります。

引数のチェック(Procオブジェクトのwhen節)

Procオブジェクトを使うと複雑な条件を作ることができます。 次の例は配列の各要素を2乗するメソッドsquare_elementsです。

各要素を2乗するだけなら、配列のmapメソッドで簡単に実現できますが、要素に数以外のものが入っているとエラーが起こります。 そこで、配列の要素がすべてNumericのサブクラスのインスタンスかどうかを調べます。 そのためにProcオブジェクトを使います。

@is_array_of_Numeric = lambda do |a|
  return false unless a.instance_of? Array # lambdaではreturnで手続きオブジェクトを抜けることができる
  b = a.map{|e| e.kind_of?(Numeric)}.uniq #要素のクラスの配列を作り、重複を除く
  case b.size
  when 0 then false # 空の配列だった
  when 1 then b[0] # true(Numericクラス)かfalse(そうでない)を返す
  else        false # Numericとそうでない要素が混じっていた
  end
end

def square_elements a
  case a
  when @is_array_of_Numeric
    a.map{|x| x*x}
  else
    a
  end
end

p square_elements([1,2,3]) #=> [1, 4, 9]
p square_elements([1.0, 2,Complex("2+3i"),Rational("5/7")]) #=> [1.0, 4, (-5+12i), (25/49)]
p square_elements(["a","b","c"]) #=> ["a", "b", "c"]
p square_elements([[1,2],[3,4],[5,6]]) #=>[[1, 2], [3, 4], [5, 6]]
p square_elements([[1,"2",3.0]]) #=> [[1, "2", 3.0]]

インスタンス変数(@つき変数)をProcオブジェクト名に使っているのは、メソッド定義の中で参照できるようにするためです。 型チェックはかなり複雑です

  • 引数が配列でない=>false
  • 引数が空の配列=>false
  • 引数の要素がすべてNumericのサブクラスのインスタンス=>true
  • 引数の要素にNumericのサブクラス以外が混ざっている=>false

これをメソッドの中で書くこともできますし、例のようにメソッドの外でProcオブジェクトにすることもできます。 もちろん、メソッドの中でProcオブジェクトを生成しても良いのですが、メソッドがコールされるたびにProcオブジェクトが生成され、効率が悪くなります。 このチェックが複数のメソッドで必要ならば、このようにメソッド外でProcオブジェクトを作るのが有効なやり方です。

字句解析(正規表現のwhen節)

字句解析は、主にプログラミング言語の処理系で行われます。 例えば、次のようなRubyプログラムをrubyが処理することを考えてみましょう。

ab = 10 * cd

このとき、これを次のように解析します。

文字列 タイプ
ab 識別子
= 等号
10 整数
* 掛け算
cd 識別子

この表はruby処理系の実際の動作を説明するものではありません。 あくまでも、字句解析の例として提供するものです。

「識別子」は定数名、変数名やメソッド名などに用いられる文字列です。

字句解析は頭から順にその言語の要素になるもの(トークンという)を取り出し、そのタイプと内容を返していくものです。

ここでは、簡単な電卓プログラムの字句解析を考えてみましょう。 電卓には、変数、整数、四則、代入、表示ができるとします。

トーク タイプ 内容
英文字列 :id 変数名
数字の文字列 :num 整数
+ :'+' 加算記号
- :'-' 減算記号
* :'*' 乗算記号
/ :'/' 除算記号
( :'(' 左括弧
) :')' 右括弧
= :'=' 代入
print :print 表示命令

全部で10種類のタイプのトークンがあります。 入力文字列を分析し、トークンの列の配列resultを返すメソッド「lex」を作ってみます。

例えば、入力が

abc = (2+3)*6
print abc

であったとき(つまり文字列"abc = (2+3)*6\nprint abc\n"であったとき)、lexの出力は

[[:id, "abc"], [:"=", nil], [:"(", nil], [:num, 2], [:+, nil], [:num, 3], [:")", nil], [:*, nil], [:num, 6], [:print, nil], [:id, "abc"]]

になります。 字句解析の流れは、入力の最初の文字に対するcase文の条件分岐が主になります。

def lex(s)
  result = []
  while true
    break if s == ""
    case s[0]
    when /[[:alpha:]]/
      m = /\A([[:alpha:]]+)(.*)\Z/m.match(s)
      if m[1] == "print"
        result << [:print, nil]
      else
        result << [:id, m[1]]
      end
      s = m[2]
    when /[[:digit:]]/
      m = /\A([[:digit:]]+)(.*)\Z/m.match(s)
      result << [:num, m[1].to_i]
      s = m[2]
    when /[+\-*\/()=]/
      result << [s[0].to_sym, nil]
      s = s[1..-1]
    when /\s/
      s = s[1..-1] 
    else
      raise "Unexpected character."
    end
  end
  result
end

p lex("abc = (2+3)*6\nprint abc\n")
  • 入力(sに代入される引数の文字列)の1文字目によって場合分けをする
  • /[[:alpha:]]/は英字(大文字小文字の両方可)にマッチする正規表現
  • 正規表現/\A([[:alpha:]]+)(.*)\Z/mにおいて、\A\Zはそれぞれ文字列の先頭と末尾にマッチ。 [[:alpha:]]+は1文字以上の英字の繰り返しにマッチ。 .*は任意の文字の0個以上の繰り返しにマッチ。 .はデフォルトでは改行にマッチしないが、正規表現の最後にmオプションがつくときは、改行にもマッチする。 ()が2箇所にあるので、MatchDataオブジェクトの[1][2]でマッチした部分文字列を参照できる
  • 文字列m[1]がprintであれば、予約語なので[:print, nil]を配列resultに加える。 printでなければ、変数なので[:id, m[1]]、すなわち識別子タイプを表す:idと変数名の文字列をセットにしてresultに加える。 s = m[2]でsにはマッチした文字列を除いた残りを代入する
  • /[[:digit:]]/は数字(0-9)にマッチ。 変数のときと同様に数字の並びを取り出し、:numタイプと整数値をセットにしてresultに加える
  • /[+\-*\/()=]/は四則、括弧とイコールのどれかにマッチする。 それぞれの記号を表すシンボルをタイプとし、対応する内容が無いのでnilとセットにし、resultに追加する。 `s[1..-1]は先頭の1文字を削除し、次の文字から最後の文字までを返す
  • /\s/は空白文字にマッチ。 空白文字にはタブや改行も含まれる。 この文字は区切りとしての意味しかない。 resultに付け加えるものは何もない
  • それ以外は、電卓で使う文字ではないので、raiseメソッドで例外を発生させ、プログラムを停止する

この例では、正規表現をcase文に使いました。 ===正規表現のマッチを意味するので、このようにwhen節に使うことができます。

以上case文を見てきましたが、if-else-end文よりも条件のチェック部分に工夫があります。 これは具体的には===メソッドで実現されています。 もし、新たにクラスを作成する時に、そのオブジェクトがwhen節で使える可能性があれば、かならず===メソッドを定義してください。