ダックタイピングの柔軟性と安全性

はじめに

こんにちは。ANDPAD SWEの大山です(kameholl)。 この記事はANDPADアドベントカレンダー9日目の記事になります! 普段はアンドパッドの「ANDPAD受発注」というプロダクトでバックエンドエンジニアとしてRubyを書いています!

この記事ではダックタイピングについて書いていきたいと思います。

ダックタイピング

プロを目指す人のためのRuby入門を見返していた時に下記のコードに違和感を覚えました。

module Taggable
  def price_tag
    # priceを取得するメソッドがinclude先のクラスに定義されていることを前提
    "#{price}"
  end
end

class Product
  include Taggable

  def price 
    1000
  end
end

product = Product.new
puts product.price_tag

出典:プロを目指す人のためのRuby入門 include先のメソッドを使うモジュール

上記のコードはTaggableモジュールがpriceメソッドが存在していることを前提に動作します。

これは典型的なダックタイピングの例で、Rubyは型チェックがないことが前提であり 「必要なメソッドが存在することを仮定して動作する」設計はよく見られます。

そのため、このコード自体は「Rubyらしい設計」と言えます。

しかし、ここにはいくつかの問題点が存在すると考えました。

依存関係の問題

Taggableモジュールはpriceメソッドの存在を前提としているため、priceメソッドが定義されていないクラスでこのモジュールを使用すると、実行時エラーが発生します。

実装者への負担

ダックタイピングの利点である柔軟性が、逆に実装者にとっての負担となることがあります。具体的には、モジュールをincludeする際にpriceメソッドを実装しなければならないという条件を知らずに呼び出すことができてしまうため、意図しないエラーを引き起こす可能性があります。

大規模プロジェクトでの問題

プロジェクト規模が小さい場合、ダックタイピングの柔軟性は生産性を向上させる要因となります。

しかし、規模が大きくなるにつれて、依存関係が分散し、以下のような問題が発生する可能性があります。

module DiscountPrice
  def process_item(discount)
    # クラス毎に割引をする条件は変えたいので include先に実装されていることを期待
    apply_discount(discount) 
    puts "最終価格: ¥#{price}"
  end
end

class Product
  include DiscountPrice

  def initialize(name, price)
    @name = name
    @price = price
  end

  def price
    @price
  end

  def apply_discount(discount)
    # 単純のためにプロダクトは無条件で値引き
    @price -= discount
  end
end

class Service
  include DiscountPrice

  def initialize(name, price)
    @name = name
    @price = price
  end

  def price
    @price
  end

  def apply_discount(discount)
    # もしサービスの価格が15000円以上なら値引き
    if @price >= 15000
      @price -= discount
    end
  end
end

# 使用例
product = Product.new("プロダクト", 10000)
service = Service.new("サービス", 5000)

product.process_item(10000) # プロダクトの最終価格を表示
service.process_item(20000) # サービスの最終価格を表示
  • 新しいクラスを追加するとき

新しいクラスを追加する際(例えばPaymentクラス)、payment.process_itemを呼び出すためにはprocess_itemで期待されているメソッドをPaymentクラスに実装する必要があります。

コードの規模が大きい && エンジニアが多数いた場合、エラーが発生するコードを書くことになるかもしれません。(少なくとも私はそう感じています)

module DiscountPrice
  def process_item(discount)
    apply_discount(discount)
    puts "最終価格: ¥#{price}"
  end
end

class Product
  省略
end

class Service
  省略
end

class Payment # 新しくPaymentクラスを作成
  include DiscountPrice

  def initialize(name, price)
    @name = name
    @price = price
  end
end

# 使用例
payment = Payment.new("支払い", 30000)

payment.process_item 
# →`process_item': undefined method `apply_discount' for #<Payment:0x00000001009e7090 @name="支払い", @price=30000> (NoMethodError)
  • メンテナンスの負担

どのクラスがどのメソッドを持っているのかを把握するのが難しくなり、メンテナンスの負担が増加します。

  • ドキュメンテーションの必要性

ダックタイピングを使用する場合、明示的なインターフェースがないため、ドキュメンテーションやコードのコメントが重要になりそうです。

これが不十分だと、他の開発者がコードを理解するのが難しくなることが起こりそうな気がします。

Rubyではダックタイピングをうまく利用しているモジュールがある

そうは言ってもダックタイピングは相応のメリットがあるから使われているはずです。

実際include先のメソッドをうまく利用したモジュールがいくつか存在します。

(プロを目指す人のためのRuby入門ではEnumerableとComparableが紹介されていました)

RubyのEnumerableモジュールは、まさにダックタイピングの威力を活かした良い例だったので少し掘り下げてみます。

Enumerableモジュールをincludeするだけで、mapやselect、reduceなどの多くの便利なメソッドを自動的に使えるようになります。しかし、その条件として1つだけ必要なものがあります。それは「eachメソッドを実装すること」です。

docs.ruby-lang.org

なぜeachをモジュール内に定義しないのか?

ここで一つの疑問が浮かびました。

「Enumerableモジュールの中にeachを定義してしまえば、そもそもeachをクラスごとに実装する必要がなくなるのでは?」という考えです。 eachがEnumerable内にあれば、冒頭でも触れたデメリットが改善できそうです。

依存関係の緩和

クラスにeachがないとエラーになる、という問題を防げます。

実装者の負担軽減

モジュールをincludeする際に「eachを実装しなければいけない」という条件を知らなくても済みます。

しかし実際には問題がありました。

Enumerableモジュールにeachを定義してしまうと、次のような課題が生じます。

具体的な実装が制約される

データ構造ごとに繰り返し方は異なります。

例えば、Arrayのeachは順番に要素を返しますが、Hashではキーと値のペアを返します。これらを1つの共通eachで処理するのは難しそうです。(データ構造毎に分岐させたりする必要があったり、そのせいでeach関数のパフォーマンスも悪くなりそう && 可読性も悪そう)

設計の柔軟性が損なわれる

開発者が独自のeachを実装する自由がなくなり、結果的に拡張性が制限されてしまいます。

クラスごとの実装と共通処理のダックタイピングの効果

以上の理由から、Enumerableモジュールにはeachメソッドを定義せず、各データ構造が独自にeachを実装することが求められているのだろうと考えました。

各クラスは自分のデータ構造に合ったeachを定義しつつ、mapやselectなどの汎用的なメソッドを簡単に利用できます。

この設計は、データ構造ごとの繰り返し処理の違いを尊重しつつ、型に縛られない柔軟なコードを可能にしていると感じました。

RubyのEnumerableモジュールは、ダックタイピングを活用して柔軟性と拡張性を実現した設計になっており、「include先のメソッドをうまく利用したモジュール」であることがわかりました。

ダックタイピングを安全に活用するための工夫

  • respond_to? を使用して事前チェック

クラスに必要なメソッドが存在するかを事前に確認し、不足している場合はエラーを明示します。

  • モジュール側でデフォルト実装を提供する

Taggable モジュール側でデフォルトの price メソッドを定義することで、メソッドが未定義のクラスに対して安全策を講じます。

module Taggable
  def price
    raise NotImplementedError, "include先でpriceメソッドをちゃんと実装してくれ!"
  end

  def price_tag
    "#{price}"
  end
end

class Hoge
  include Taggable
end

puts Hoge.new.price_tag
# price': include先でpriceメソッドをちゃんと実装してくれ! (NotImplementedError)
  • テストで依存関係を保証する

必要なメソッドが実装されているかどうかをテストコードで明示的に検証します。

まとめ

ここまで、Rubyにおけるダックタイピングの特徴と、その活用方法について見てきました。

今回の記事を書くにあたってEnumerableモジュールの設計の綺麗さ、大規模開発における注意点について考える機会となりました。

ダックタイピングは、使い方次第でコードの柔軟性と保守性のバランスを大きく左右する機能です。適切に使用すればEnumerableモジュールのような優れた設計が可能になる一方で、安易な使用は開発者の混乱を招く可能性があります。

これからも、より良いコードを書けるよう、ダックタイピングの利点を活かしつつ、デメリットに対する適切な対策を講じながら、日々の開発を続けていきたいと思います。

最後に

アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を募集しています。

ご興味を持たれた方は、下記のサイトをぜひご覧ください。

engineer.andpad.co.jp