CBcloud Advent Calender 23日目の記事です。
普段動的型付け言語を使っていて、ダックタイピングの何が良いのかいまいちわからない、という声を聞いたのでRubyを例に解説してみます。
ダックタイピングとは
ダックタイピングについては以下のフレーズとともに語られることが多いかと思います。
"If it walks like a duck and quacks like a duck, it must be a duck"
(もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルに違いない)
これをプログラミングの用語で言い換えると、「オブジェクトがアヒルのように歩き、アヒルのように鳴くメソッドを実装しているなら、そのオブジェクトのクラスが何であれ、アヒルと同じ役割を果たせる」と言えるかと思います。
「そのオブジェクトのクラスが何であれ」という表現をしたように、ダックタイピングとは、どんな特定のクラスとも紐付かないインタフェースを定義することです。
定義されたダックタイプのインタフェースの存在は暗黙的です。下記はダックタイプDuckを定義した(と言える)コードです。
def check_health_condition(duck)
duck.quack
end
このメソッドは、引数がquackメソッドを実装していることを期待しています。言い換えると「この引数はアヒルのように振る舞うオブジェクトであれば、クラスは何でも良い」ということを伝えています。
ダックタイピングとは、どんな特定のクラスとも紐付かないインタフェースを定義することだと言った通り、このquackメソッドはどのクラスのオブジェクトが実装していても良いのです。
このcheck_health_conditionメソッドに引数を渡すために、クラスやオブジェクトを定義してみます。
ここで定義したanimal、foo、barのいずれのオブジェクトもcheck_health_conditionメソッドに渡して正常に動作することができます。
class Foo
def quack
"quack"
end
end
class Bar
def quack
"quack"
end
end
animal = Object.new
animal.define_singleton_method(:quack) { "quack" } # このオブジェクトにだけquackメソッドを特異メソッドとして定義
foo = Foo.new
bar = Bar.new
ダックタイピングの効果
上記ではダックタイプDuckのquackメソッドというインタフェースを定義しました。
これにより、コードの柔軟性が向上します。
それだけでなく、個人的に大きな効果だと思っているのが、「オブジェクト間で送受信されるメッセージに設計がフォーカスされる」ことです。
メソッドを定義するときに「引数は何クラスにすべきかな?」という考えではなく、「このメソッドの引数はどんなメソッドを持っているべきかな?」という振る舞いに着目した考えに自然となっていきます。
その視点は、オブジェクト同士がメッセージを送り合って連携するオブジェクト指向設計においては非常に重要です。
アプリケーション内のあらゆるオブジェクトが、他のオブジェクトが自身の期待する振る舞いをしてくれると信頼することができれば、設計の柔軟性は非常に高くなります。
ただし、この柔軟性はうまく使わなければ恐ろしいほど複雑な設計になってしまうことに注意しなくてはなりません。
そうならないためにも、ダックタイプのインタフェースは慎重に設計しなければなりません。
ダックタイピングの例
最初のアヒルの例はだいぶ苦しかったのでもっと具体的な例を上げて説明していきます。
ダックタイプを意識せずにコードを書いてみる
物流倉庫の業務アプリケーションを例にしてダックタイプを意識しないコードを書いてみます。
このアプリケーションでは、オペレーターが荷主から輸送の依頼を受けて荷物の輸送内容をフォームに入力します。入力された内容は、輸送するドライバーや倉庫作業者に指示として送られます。
class Transport
def prepare(preparers)
preparers.each do |p|
case p
when Driver
p.find_available_vehicle
p.move_vehicle
when WarehousePicker
p.pick_cargos
end
end
end
end
このコードでは引数の中のクラスを想像して実装していることは明白です。
この時点でprepareメソッドは下記の依存を抱え込んでいます。eachはコレクションの一般的な操作のため、カウントしませんでした。
- Driver
- find_available_vehicle
- move_vehicle
- WarehousePicker
- pick_cargos
prepareメソッドはこれらに変更があれば影響を受ける可能性があるということです。さらに今後アプリケーションが拡張され、車両の駐車スペース管理者クラスや、輸送先倉庫の担当者クラスが追加されたとき、このcase文が膨れ上がっていくことになります。当然、その分の依存を抱え込んでいくことになります。依存を抱えるほど、変更コストは高くなりますし、理解するのも困難になります。
当然のことながら、prepareメソッドにはDriverクラスかWarehousePickerクラスのインスタンスしか渡せません。
以降で、ダックタイピングを用いてprepareメソッドから依存を取り除いて柔軟性を上げていきます。
ダックタイプを使って依存を減らす
prepareメソッドの目的は、「輸送の準備をすること」です。引数はその目的を達成するためにここに渡されてきます。prepareメソッドが引数に期待するのは、「輸送に必要な準備をしてくれること」です。引数の具体的なクラスが何であるかは関心がありません。引数が期待する動作をすると信頼してコードを書いてみると下記のようになります。
class Transport
def prepare(preparers)
preparers.each do |p|
p.prepare_transport
end
end
end
非常にシンプルになりました。のちに車両の駐車スペース管理者クラスや、輸送先倉庫の担当者クラスが追加されたとしても、それらのクラスがprepare_transportを実装していればprepareメソッドを変更する必要はありません。
prepareメソッドが依存するのはprepare_transportメソッドだけです。引数の動作については信頼し切っているため、期待される動作をこなすことは引数オブジェクトの責任になります。
さて、仮にこの引数に型名をつけるとしたら何が適切でしょうか?
「輸送の準備をすること」がprepareメソッドの目的なので、「Preparer(準備者)」が良いでしょう。
このPreparerこそが隠れていたダックタイプです。このダックタイプを見つけたことで、prepareメソッドに柔軟性がもたらされ、変更に強い設計となりました。
ダックタイピングあるいは動的型付け言語の問題点
ダックタイピングや動的型付け言語でよく聞く問題について考えます。
コードが理解しにくくなる
Rubyを例にダックタイピングを説明してきましたが、「Javaのインタフェースでも同じことができるでしょ?明示的な型として見えないダックタイピングよりも明示的に型を宣言できる方がわかりやすくない?」という疑問を持つ方も多いと思います。
その疑問は全くその通りだと思います。Preparerインタフェースを定義して、prepareメソッドに渡す可能性のあるクラスにimplementsしてprepare_transportを実装すれば同じことができます。また、型が明示的であれば、引数が何のメソッドを実装していなければならないかの理解が容易です。
しかし、その調子でインタフェースを定義していくとものすごい数になるのではないでしょうか。それを続けるだけでも大きなコストになりそうです。
確かに、ダックタイピングでは抽象に依存しているため、コードを理解するのに少し時間がかかります。しかし、理解できればその柔軟性により変更コストを少なくできる場面が出てくると思います。
型に起因する実行時エラーが起きる
動的型付け言語において、引数のクラスを理解していないと正しく動かないメソッドは、新しいクラスが現れると当然失敗します(想定しない型に起因するエラーで)。そのとき、「静的型付け言語のように型のエラーを事前に教えてくれないから、編集したコードから離れたところに問題があっても実行するまで気づけなかった。だから動的型付け言語は苦手だ。新しいクラスに対応するロジックを追加しなくては...」と考えたことがある方もいるのではないでしょうか?
実行時エラーに注意を逸らされそうになりますが、本当の問題はそのメソッドが具体的なクラスに依存していることです。その依存を剥がすリファクタリングをしていくと、最終的に抽象(ダックタイプ)に依存するようになります。そのダックタイプこそが、依存すべき安定したインタフェースです。型に起因するエラーは、設計が具体的なクラスに依存していることへの警告として捉えるべきなのかも知れません。
おわりに
偉そうなことを書いてしまいましたが、オブジェクト指向設計実践ガイドに影響されまくっていますので、ぜひそちらも読んでみてください。
ダックタイピングはうまく使えば柔軟な設計が実現できますが、宣言的に見づらい部分もあるのでその問題への対策をテスト等で工夫する必要があります。その方法についても書籍では紹介されています。