2.矩形の重なりの面積

今度は矩形の重なりの面積を求める問題を解いてみましょう。

(問題)矩形Aと矩形Bを与えると、その2つの矩形の重なり部分の面積を求めるプログラムを作成しなさい。

まず最初にテストクラスを作ります。

---RectangleTest.rb----------------------
require 'test/unit'

class RectangleTest < Test::Unit::TestCase
end
--------------------------------

特定の入力に対して、期待した結果が得られることを確認するのがテストなんですから、このテストの最終行はこうなるはずです。

    def testIntersectedArea()
        ...
        assert_equal(100, intersection.area())
    end

intersectionの面積が100なのは適当です。最初はテストの形を確かめることが優先です。ここではあまり値にこだわりません。

次に、intersectionを求めるため、前に1行加えます。最初にassertを書いてしまって、その前を逆に書いていくのもテクニックの一つです(Assert First)。

    def testIntersectedArea()
        ...
        intersection = rectA.intersect(rectB)
        assert_equal(100, intersection.area())
    end

rectAとrectBを作る必要があります。

    def testIntersectedArea()
        rectA = Rectangle.new()
        rectB = Rectangle.new()
        intersection = rectA.intersect(rectB)
        assert_equal(100, intersection.area())
    end

まだRectangle生成の引数を考えていないので、とりあえず引数なしにしました。しかし、いずれ考える必要がありますから、「Rectangle生成の引数を考える」と手元の紙にメモしておきます。

それでもテストを実行して見ましょう。

Red bar
>ruby RectangleTest.rb
testIntersectedArea(RectangleTest):
NameError: uninitialized constant RectangleTest::Rectangle
    RectangleTest.rb:27:in `testIntersectedArea'

1 tests, 0 assertions, 0 failures, 1 errors

まだRectangleが定義されていないのでエラーになります。

まずRectangle.rbを作ります。

---Rectangle.rb----------------------
class Rectangle
end
--------------------------------

そしてRectangleTest.rb先頭のrequireの並びに

require "Rectangle"

を追加します。

テストを実行します。

Red bar
>ruby RectangleTest.rb
testIntersectedArea(RectangleTest):
NoMethodError: undefined method `intersect' for #
    RectangleTest.rb:28:in `testIntersectedArea'

1 tests, 0 assertions, 0 failures, 1 errors

intersectというメソッドがまだ定義されていないのでエラーです。

Rectangleにintersectメソッドを定義します。

class Rectangle
    def intersect(another)
        return nil
    end
end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
E
Finished in 0.01 seconds.

  1) Error:
testIntersectedArea(RectangleTest):
NoMethodError: undefined method `area' for nil:NilClass
    RectangleTest.rb:28:in `testIntersectedArea'

1 tests, 0 assertions, 0 failures, 1 errors

areaというメソッドがまだ定義されていないのでエラーです。

Rectangleにareaメソッドを定義しましょう。

class Rectangle
    def area()
        return 0
    end
    
    def intersect(another)
        return nil
    end
end

テストを実行します。

Red bar
>ruby RectangleTest.rb

RectangleTest#testIntersectedArea E.
Time: 0.0
FAILURES!!!
Test Results:
 Run: 1/1(0 asserts) Failures: 0 Errors: 1
Errors: 1
RectangleTest.rb:15:in `testIntersectedArea'(RectangleTest): undefined method `a
rea' for nil (NameError)
        from RectangleTest.rb:28

areaメソッドを定義したのにエラーになってしまいました。エラーメッセージを良く見るとメッセージの受け手intersectionがnilになってしまっているのが原因のようです。intersectionはその前のintersectで求めています。intersectの実装をみると無条件にnilを返しています。

intersectがRectangleを返すように修正しましょう。

class Rectangle
    def area()
        return 0
    end
    
    def intersect(another)
        return Rectangle.new()
    end
end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
F
Finished in 0.06 seconds.

  1) Failure:
testIntersectedArea(RectangleTest) [RectangleTest.rb:28]:
<100> expected but was
<0>.

1 tests, 1 assertions, 1 failures, 0 errors

areaの返値が0なのでフェイルになっています。

areaが100を返せばこのテストは成功するはずですので、100を返してしまいましょう。

class Rectangle
    def area()
        return 100
    end
    
    def intersect(another)
        return Rectangle.new()
    end
end

この実装はFake Itですので、必ず後で修正が必要です。メモしておきましょう。「RectangleのareaはFake It」

テストを実行します。

Green bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
.
Finished in 0.0 seconds.

1 tests, 1 assertions, 0 failures, 0 errors

テストが成功して一安心です。ここからは今まで棚上げにした問題を解決し、酷いごまかしや、汚い実装を整理します。

まずRectangleの生成をなんとかしましょう。

Rectangleは対向する2点を与えれば定義できるはずです。この点には前に作ったPointクラスが使えるでしょう。与える2点の組み合わせにもいろいろありますが、ここでは左上と右下をあたえることにします。

まずテストです。こんどはちょっと急ぎ足で行きます。本当はもっとステップを踏んだのですが、説明に時間がかかりすぎるので省略しました。

require "Point"

を追加し、testInitializeメソッドを書きます。

#ruby RectangleTest.rb

require 'test/unit'

require "Rectangle"
require "Point"

class RectangleTest < Test::Unit::TestCase
    def testInitialize()
        rect = Rectangle.new(Point.new(10, 5), Point.new(20, 15))
        assert_equal(Point.new(10, 5), rect.getOrigin())
        assert_equal(Point.new(20, 15), rect.getCorner())
    end
    
    def testIntersectedArea()
        rectA = Rectangle.new()
        rectB = Rectangle.new()
        intersection = rectA.intersect(rectB)
        assert_equal(100, intersection.area())
    end
end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
E.
Finished in 0.01 seconds.

  1) Error:
testInitialize(RectangleTest):
ArgumentError: wrong number of arguments(2 for 0)
    RectangleTest.rb:12:in `initialize'
    RectangleTest.rb:12:in `new'
    RectangleTest.rb:12:in `testInitialize'

2 tests, 1 assertions, 0 failures, 1 errors

Rectangleのinitializeメソッドを書きます。

    def initialize(origin, corner)
        @origin = origin
        @corner = corner
    end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
EE
Finished in 0.0 seconds.

  1) Error:
testInitialize(RectangleTest):
NoMethodError: undefined method `getOrigin' for #
    RectangleTest.rb:12:in `testInitialize'

  2) Error:
testIntersectedArea(RectangleTest):
ArgumentError: wrong number of arguments(0 for 2)
    RectangleTest.rb:18:in `initialize'
    RectangleTest.rb:18:in `new'
    RectangleTest.rb:18:in `testIntersectedArea'

2 tests, 0 assertions, 0 failures, 2 errors

getOriginが未定義です。getOriginとgetCornerを定義します。
ここでついでにsetOriginとsetCornerを実装してはいけません。テストを通すのに必要なものだけを実装します。これはXP(Extream Programming)のYAGNI(You Are NOT Gonna Need It)精神だと言えますし、Value Objectというデザインパターンでもあります。

    def getOrigin()
        return @origin
    end
    
    def getCorner()
        return @corner
    end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
.E
Finished in 0.0 seconds.

  1) Error:
testIntersectedArea(RectangleTest):
ArgumentError: wrong number of arguments(0 for 2)
    RectangleTest.rb:18:in `initialize'
    RectangleTest.rb:18:in `new'
    RectangleTest.rb:18:in `testIntersectedArea'

2 tests, 2 assertions, 0 failures, 1 errors

testInitializeは成功していますので、Rectangleの生成はうまくいきました。しかし、そのための実装がtestIntersectedAreaに影響してテストが失敗するようになってしまいました。

これがユニットテストを自動実行できる形で実装し、絶えず回帰テストをおこなうメリットです。行った修正が既存のほかの機能に影響を与えていないか即座にチェックできます。確かに完全なチェックではありません。しかし、テストがなかったときと比較するとプログラマの不安はずっと小さくなります。

testIntersectedAreaの実装をみれば、Rectangle生成に引数を渡していないのがエラーの原因です。さっそく直しましょう。rectAとrectBの座標を決め、結果は手計算で求めます。これがいいテストかどうかわかりませんが、たまたま思いついた矩形でテストを書きました。どんなテストでもないよりはましです。テストがあればそれをガイドに開発を進めることができます。

    def testIntersectedArea()
        rectA = Rectangle.new(Point.new(0, 0), Point.new(20, 30))
        rectB = Rectangle.new(Point.new(10, 10), Point.new(30, 40))
        intersection = rectA.intersect(rectB)
        assert_equal(200, intersection.area())
    end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
.E
Finished in 0.01 seconds.

  1) Error:
testIntersectedArea(RectangleTest):
ArgumentError: wrong number of arguments(0 for 2)
    ./Rectangle.rb:21:in `initialize'
    ./Rectangle.rb:21:in `new'
    ./Rectangle.rb:21:in `intersect'
    RectangleTest.rb:20:in `testIntersectedArea'

2 tests, 2 assertions, 0 failures, 1 errors

Rectangle.rbのintersectにも修正が必要でした。期待されるRectangleを手計算し、それを返してしまいます。またFake Itです。メモをわすれずに「RectangleのintersectはFake It」

    def intersect(another)
        return Rectangle.new(Point.new(10, 10), Point.new(20, 30))
    end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
.F
Finished in 0.07 seconds.

  1) Failure:
testIntersectedArea(RectangleTest) [RectangleTest.rb:21]:
<200> expected but was
<100>.

2 tests, 3 assertions, 1 failures, 0 errors

面積は200になるはずなのでareaが返す値を200に変更します。

テストを実行します。

Green bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
..
Finished in 0.01 seconds.

2 tests, 3 assertions, 0 failures, 0 errors

これで2つのテストが通るようになりましたが、Fake Itしたものが2つ残っています。 Rectangleのareaとintersectです。areaは簡単そうですのでこちらから片付けてしまいます。

まずテストを書きます。testIntersectedAreaのrectAをコピーして使っちゃいましょう。

    def testArea()
        rect = Rectangle.new(Point.new(0, 0), Point.new(20, 30))
        assert_equal(600, rect.area())
    end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
F..
Finished in 0.06 seconds.

  1) Failure:
testArea(RectangleTest) [RectangleTest.rb:18]:
<600> expected but was
<200>.

3 tests, 4 assertions, 1 failures, 0 errors

areaは無条件で200を返す実装なのでフェイルになります。テストが出来たのでareaを実装しましょう。

矩形の面積は幅かける高さなので、areaの実装はこうなります。

    def width()
        return @corner.getX() - @origin.getX()
    end
    
    def height()
        return @corner.getY() - @origin.getY()
    end
    
    def area()
        return width() * height()
    end

テストを実行します。

Green bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
...
Finished in 0.01 seconds.

3 tests, 4 assertions, 0 failures, 0 errors

testAreaは成功しましたし、areaを使っているtestIntersectedAreaも大丈夫です。これ以上シンプルな実装も思いつきません。

残るは、intersectです。testIntersectedAreaをコピーして最後の行だけ書き換えればいいでしょう。

    def testIntersect()
        rectA = Rectangle.new(Point.new(0, 0), Point.new(20, 30))
        rectB = Rectangle.new(Point.new(10, 10), Point.new(30, 40))
        intersection = rectA.intersect(rectB)
        assert_equal(Rectangle.new(Point.new(10, 10), Point.new(20, 30)), intersection)
    end

テストを実行します。

Red bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
..F.
Finished in 0.1 seconds.

  1) Failure:
testIntersect(RectangleTest) [RectangleTest.rb:25]:
<#<Rectangle:0x2be2158
 @corner=#<Point:0x2be2170 @x=20, @y=30>,
 @origin=#<Point:0x2be21d0 @x=10, @y=10>>> expected but was
<#<Rectangle:0x2be2188
 @corner=#<Point:0x2be21a0 @x=20, @y=30>,
 @origin=#<Point:0x2be21b8 @x=10, @y=10>>>.

4 tests, 5 assertions, 1 failures, 0 errors

rectangleの==の実装が必要でした。

    def ==(rect)
        return (@origin == rect.getOrigin()) && (@corner == rect.getCorner())
    end

テストを実行します。

Green bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
....
Finished in 0.01 seconds.

4 tests, 5 assertions, 0 failures, 0 errors

すべてのテストが通りました。しかし、intersectの実装はFaike Itです。このままにしておく訳にはいきません。

originとcornerを一度に実装すると間違えそうなのでまずoriginだけを実装します。

    def intersect(another)
        intersectionOrigin = Point.new(
                [@origin.getX(), another.getOrigin().getX()].max,
                [@origin.getY(), another.getOrigin().getY()].max)
        return self.class().new(intersectionOrigin, Point.new(20, 30))
    end

テストを実行してこれでいいことを確認します。

Green bar

次にcornerも実装します。

    def intersect(another)
        intersectionOrigin = Point.new(
                [@origin.getX(), another.getOrigin().getX()].max,
                [@origin.getY(), another.getOrigin().getY()].max)
        intersectionCorner = Point.new(
                [@corner.getX(), another.getCorner().getX()].min,
                [@corner.getY(), another.getCorner().getY()].min)
        return self.class().new(intersectionOrigin, intersectionCorner)
    end

テストを実行して確かめます。

Green bar

でもこの実装は複雑なのでまだ正しいかどうか自信を持てません。もっとテストを強化しましょう。色々な重なり方を考えてテストを1つずつ追加して確かめてみます。

Green bar
Green bar
Green bar
Green bar
>ruby RectangleTest.rb
Loaded suite RectangleTest
Started
........
Finished in 0.01 seconds.

8 tests, 9 assertions, 0 failures, 0 errors

大丈夫そうです。それでは実装を見直して見ましょう。

intersectが臭いですね。

        intersectionOrigin = Point.new(
                [@origin.getX(), another.getOrigin().getX()].max,
                [@origin.getY(), another.getOrigin().getY()].max)

にコメントが必要な感じです。こういうときは、だいたい別のメソッドにしたほうがわかりやすくなりますし、さらなるリファクタリングもしやすくなります。ここの部分を取り出してmaxPointというメソッドを作りましょう。この操作をメソッドの抽出(Extract Method)と呼びます。

    def maxPoint(pointA, pointB)
        return Point.new(
            [pointA.getX(), pointB.getX()].max,
            [pointA.getY(), pointB.getY()].max)
    end

テストを実行して今まで書いたテストに影響がないことを確かめます。

Green bar

次にこのmaxPointを使ってintersectを直します。

    def intersect(another)
        intersectionOrigin = maxPoint(@origin, another.getOrigin())
        intersectionCorner = Point.new(
                [@corner.getX(), another.getCorner().getX()].min,
                [@corner.getY(), another.getCorner().getY()].min)
        return self.class().new(intersectionOrigin, intersectionCorner)
    end

テストを実行して動作を確かめます。

Green bar

intersectionCornerの計算も同じように直しましょう。

    def minPoint(pointA, pointB)
        return Point.new(
            [pointA.getX(), pointB.getX()].min,
            [pointA.getY(), pointB.getY()].min)
    end

を追加してテストし、intersectを直します。

Green bar
    def intersect(another)
        intersectionOrigin = maxPoint(@origin, another.getOrigin())
        intersectionCorner = minPoint(@corner, another.getCorner())
        return self.class().new(intersectionOrigin, intersectionCorner)
    end

テストを実行して確かめます。

Green bar

これで少しシンプルになりました。これで十分シンプルかどうかもう一度、コードを見てみます。maxPointとminPointが臭いです。これらのメソッドの中ではRectangleを参照していないので、これらのメソッドがRectangleのメソッドである必要がありません。引数も返値もPointなのでこのメソッドはPointに定義すべきでしょう。maxPointとminPointをPointに移します。この操作をメソッドの移動(Move Method)と呼びます。

    def max(another)
        return self.class().new(
            [getX(), another.getX()].max,
            [getY(), another.getY()].max)
    end
    
    def min(another)
        return self.class().new(
            [getX(), another.getX()].min,
            [getY(), another.getY()].min)
    end

テストを動かしてから、intersectを書き換えます。

Green bar
    def intersect(another)
        intersectionOrigin = @origin.max(another.getOrigin())
        intersectionCorner = @corner.min(another.getCorner())
        return self.class().new(intersectionOrigin, intersectionCorner)
    end

テストで確認してから、もう必要のないmaxPointとminPointを削除し、またテストで確認します。

Green bar
Green bar

これでいいでしょうか。私ならさらに一時変数の除去(Inline Temp)をします。

    def intersect(another)
        return self.class().new(
            @origin.max(another.getOrigin()), 
            @corner.min(another.getCorner()))
    end

テストも通りました。

Green bar

一時変数があると、メソッドを分割するときにじゃまなので、リファクタリングを考慮すると一時変数は出来るだけ使わないほうがいいでしょう。

最後にRectangle.rb全体を確認しておきます。

---Rectangle.rb----------------------
class Rectangle
    def initialize(origin, corner)
        @origin = origin
        @corner = corner
    end
    
    def ==(rect)
        return (@origin == rect.getOrigin()) && (@corner == rect.getCorner())
    end
    
    def getOrigin()
        return @origin
    end
    
    def getCorner()
        return @corner
    end
    
    def width()
        return @corner.getX() - @origin.getX()
    end
    
    def height()
        return @corner.getY() - @origin.getY()
    end
    
    def area()
        return width() * height()
    end
    
    def intersect(another)
        return self.class().new(
            @origin.max(another.getOrigin()), 
            @corner.min(another.getCorner()))
    end
end
--------------------------------

以上でintersectの実装も終わりです。Red,Green,Refactorのリズムを感じてもらえたでしょうか。

リファクタリングの各種技法については、Kent Backの「テスト駆動開発」にもいくつか載っていますが、Martin Fowlerの「リファクタリング」がカタログになっていますので読んでみてください。


[上] [前] [次]