@ledsun blog

無味の味は佳境に入らざればすなわち知れず

デザインパターン厨よ、これが黄金の回転だ

状態管理用の変数をインスタンスに持たせるなこのタコって話 に面白い機能追加とリファクタリングの例がありました。TDDに慣れ親しんだ身からすると、

「黄金の回転のリズム」

  1. テストを書く
  2. テストが通る最低限のコードを書く
  3. リファクタリング

に比べると、リファクタリングのサイクルが大きいなと感じたので自分もやってみました。

元のクラス

シンプルなmoveメソッドで移動方向に動くPlayerクラス*1

機能追加1 二倍速

2倍速で動くようにしたい

二倍速く動くなら二回 move すればよい

サブクラスFastPlayerを追加して実現。

機能追加2 カニ

横は4倍速で動くんだけど縦は半分の速度で動く

KaniPlayerクラスを追加してとりあえず機能を実現

class KaniPlayer < Player
  def move(direction)
    case direction
    when :up
      @position[:y] -= 5
    when :down
      @position[:y] += 5
    when :left
      @position[:x] -= 40
    when :right
      @position[:x] += 40
    end
  end
end

移動倍率を持たせる

PlayerクラスとKaniPlayerクラスを見ると違うのは数字だけ。クラスごとに数字が変えられれば、switch文の分岐は共通化できる。 そのためにインスタンス変数で移動倍率を持たせます。*2

継承関係を整理

FastPlayerとKaniPlayerがサブクラスでPlayerが親クラスという継承関係に違和感があります。 移動速度が通常のNormalPlayerクラスを追加して各Playerを並列にします。

class NormalPlayer < Player
  def initialize(position)
    super
    @x_ratio = @y_ratio = 1
  end
end

Playerをクラスからモジュールへ

Playerクラスはインスタンス化することはなくなりました。JavaC#であれば抽象クラスにします。 Rubyではモジュールにします。 クラスからモジュールに変更するには次の二点を直すだけです。

  • class を module に変更
  • < Player を include Player に変更
module Player
  attr_reader :position

  def initialize(position)
    @position = position
    @x_ratio = 1
    @y_ratio = 1
  end

  def move(direction)
    case direction
    when :up
      @position[:y] -= 10 * @y_ratio
    when :down
      @position[:y] += 10 * @y_ratio
    when :left
      @position[:x] -= 10 * @x_ratio
    when :right
      @position[:x] += 10 * @x_ratio
    end
  end
end

class NormalPlayer
  include Player
  def initialize(position)
    super
    @x_ratio = @y_ratio = 1
  end
end

仕上げ

次の点が気になるので修正します。

  • Playerはモジュールっぽくない名前なのでMovableに変更します。
  • クラスを変更する際に毎回positionを取得するのは面倒なので、initializeメソッド内でpositionを取得します。
  • moduleでinitializeメソッドを実装すると、他のクラスを継承しても親クラスのinitializeメソッドを隠ぺいしてしまう。メソッド名を変更します。
  • 移動倍率の設定をメソッドにして、モジュールの実装*3を隠ぺいします。

機能追加3 寝違え

上を入力したら左に、左を入力したら下に、下を入力したら右に、右を入力したら上に行く

NechigaePlayerクラスを追加してとりあえず機能を実現

class NechigaePlayer
  include Movable
  def initialize(position)
    set_position position
    set_ratio 1, 1
  end
  
  def move(direction)
    case direction
    when :up
      @position[:x] -= 10
    when :down
      @position[:x] += 10
    when :left
      @position[:y] -= 10
    when :right
      @position[:y] += 10
    end
  end
end

NechigaePlayerはxyを入れ替えて保持

NechigaePlayerとMovableのmoveメソッドの違いはxとyが入れ替わっているだけです。 NechigaePlayerでxyを入れ替えて保持すれば、Movableのmoveメソッドをそのまま使えます。

まずMovableの@position変数を@x、@yの2変数に分けます。

module Movable
  def position
    {:x=>@x, :y=>@y}
  end

  def set_position(position)
    pos = position.is_a?(Movable) ? position.position : position
    @x = pos[:x]
    @y = pos[:y]
  end
#省略
end

NechigaePlayerはxyを入れ替えて保持

class NechigaePlayer
  include Movable
  def initialize(position)
    set_position :x=>position.position[:y], :y=>position.position[:x]
    set_ratio 1, 1
  end

  def position
    {:x=>@y, :y=>@x}
  end
end

set_positionの機能を分ける

NechigaePlayerのinitializeメソッドがごちゃっとしています。 set_positionが

  • Playerからpositionを取得する
  • @x,@yを設定する

の二つの機能を持っているからです。 二つのメソッドに分けます。

  def position(position=nil)
    return {:x=>@x, :y=>@y} unless position
    
    @x = position[:x] 
    @y = position[:y]
  end

  def set_position_or_player(position)
    position (position.is_a?(Movable) ? position.position : position)
  end
#省略
end

さらにNechigaePlayerのpositionも修正します。

class NechigaePlayer
  include Movable

  def initialize(position)
    set_position_or_player position
    set_ratio 1, 1
  end

  def position(position=nil)
    return {:x=>@y, :y=>@x} unless position
    
    @x = position[:y] 
    @y = position[:x]
  end
end

NechigaePlayerをNormalPlayerのサブクラスにする

NechigaePlayerのinitializeはNormalPlayerと全く一緒です。NormalPlayerのサブクラスにします。

class NechigaePlayer < NormalPlayer
  include Movable

  def position(position=nil)
    return {:x=>@y, :y=>@x} unless position
    
    @x = position[:y] 
    @y = position[:x]
  end
end

ディ・モールト よし! 実装方針を明確に表現している。

完成

さらにFastPlayerとKaniPlayerもNormalPlayerのサブクラスにすると、各クラスで何を上書きしてるか明確になる。

実は

当初のリファクタリング方針はこんなだった。

  1. 速度二倍の要求には初期化メソッドの引数にmoving_modeを足す。
  2. モード切り替えはインスタンス作り直し。
  3. 蟹モードは速度計算用の引数を縦と横に分ける。
  4. 使いやすくするためにFactoryモジュールを作ってもいい。

また、元記事の完成形を見た時に「moveメソッドをブロックで渡す方が良い」と思っていた。

実際書いてみると1と4は要らなかったしブロックも必要なかった。 書いてみないとわからない。 最初に完成形はわからなくてもちゃんと完成形に辿り着く。 それが黄金の回転の良いところ。

まとめ

この感覚がTDDの「黄金の回転」ッ! テスト書いて、機能を通して・・・リファクタリング----ッ!!!

というわけでツェペリさんより

「「勇気」とは「怖さ」を知ることッ! 「恐怖」を我が物とすることじゃあッ!」 ギャイイン

補足

デザインパターンをdisったので補足。

確かにデザインパターンを目指してリファクタリングすると、リファクタリングが大きくなりすぎることが多い。 しかしデザインパターンを知っていると、「あんな形にしたい!」とリファクタリングに対する強いモチベーションが生まれる。 これは大事。

そしてデザインパターンは型。 型を理解するには「守破離」が必要。 デザインパターンを使いこなすには、一度デザインパターン通りに書くことは避けられない。

追記

そういえば一番大事なことを書き忘れていたので追記。 id:nkgt_chkonk の素晴らしい機能追加の例があってこそこの記事が書けたので感謝。 このくらいの小さい規模のコードで、毎回違う性質のかつシンプルな機能追加のシナリオは滅多にないので非常に助かりました。

*1:元記事のままです

*2:移動速度を持ってもいいんだけど、要件「横は4倍、縦は半分」により素直なのは倍率

*3:インスタンス変数に値を保存していること