Я влюбился в DelegateClass

в 9:55, , рубрики: delegator, ruby, ruby on rails, ооп, рефакторинг

Если ваш класс разросся настолько, что начинает нарушать принцип единственной обязанности, вы без труда сможете разбить его на несколько более связных классов. Поможет вам в этом предоставляемая Ruby конструкция DelegateClass.

Допустим, у вас есть класс Person. Пользователи в системе могут продавать что-то и/или публиковать статьи. Подклассы здесь использовать не получится, потому что пользователь может одновременно быть и автором, и продавцом. Проведем рефакторинг.

Первоначально ваш класс выглядит как-то так:

class Person < ActiveRecord::Base
  has_many :articles                        # статьи
  has_many :comments, :through => :articles # комментарии
  has_many :items                           # товары
  has_many :transactions                    # продажи

  # продавец?
  def is_seller?
    items.present?
  end

  # сколько ему должны
  def amount_owed
    # => хитрые вычисления
  end

  # автор?
  def is_author?
    articles.present?
  end

  # может постить на главную страницу?
  def can_post_article_to_homepage?
    # => сложная система разрешений на публикацию статей
  end
end

Вроде бы, выглядит все неплохо. «Класс Person должен знать, сколько вещей продал пользователь и сколько статей опубликовал», скажете вы. «Чушь несусветная», отвечу я.

Поступает новая задача: пользователи могут быть как продавцами/авторами, так и покупателями. Чтобы выполнить эту задачу, вам нужно изменить свой класс, например вот так:

class Person < ActiveRecord::Base
  #  ...
  has_many :purchased_items        # купленные вещи
  has_many :purchased_transactions # оплата покупок

  # покупатель?
  def is_buyer?
    purchased_items.present?
  end

  # ...
end

Во-первых, вы нарушили принцип открытости/закрытости (открытости для расширения, но закрытости для изменений), ведь вы изменили класс. Во-вторых, при осуществлении продаж/покупок имена классов будут неочевидны («пользователь продает пользователю», было бы круче, если бы «продавец продавал покупателю»). И наконец, код нарушает принцип разделения ответственности.

Теперь представим, что поступила новая задача. Пользователи должны храниться не в реляционной, а, скажем, в NoSQL базе данных, или получаться с веб-сервиса через XML. Вы лишаетесь удобств ActiveRecord, все эти has_many больше не работают. По сути, вам нужно переписать класс с нуля, разработка нового функционала откладывается.

Знакомьтесь: DelegateClass

Вместо того, чтобы изменять класс Person, его функциональность можно расширить с помощью классов-делегаторов:

# пользователь
class Person < ActiveRecord::Base
end

# продавец
class Seller < DelegateClass(Person)
  delegate :id, :to => :__getobj__

  # товары
  def items
    Item.for_seller_id(id)
  end

  # продажи
  def transactions
    Transaction.for_seller_id(id)
  end

  # продавец? 
  def is_seller?
    items.present?
  end

  # сколько ему должны
  def amount_owed
    # => хитрые вычисления
  end
end

# автор
class Author < DelegateClass(Person)
  delegate :id, :to => :__getobj__

  # статьи
  def articles
    Article.for_author_id(id)
  end

  # комментарии
  def comments
    Comment.for_author_id(id)
  end

  # автор
  def is_author?
    articles.present?
  end

  # может постить на главную страницу?
  def can_post_article_to_homepage?
    # => сложная система разрешений на публикацию статей
  end
end

Для использования этих классов придется написать чуть больше кода. Вместо

person = Person.find(1)
person.items

используйте такой код:

person = Person.find(1)
seller = Seller.new(person)
seller.items
seller.first_name # => вызывается person.first_name

Теперь совсем несложно сделать пользователей еще и покупателями:

# покупатель
class Buyer < DelegateClass(Person)
  delegate :id, :to => :__getobj__

  # купленные товары
  def purchased_items
    Item.for_buyer_id(id)
  end

  # покупатель?
  def is_buyer?
    purchased_items.present?
  end
end

Теперь если вам придется переехать с ActiveRecord на Mongoid, вам не нужно ничего менять в классах-делегаторах.

Разумеется, классы-делегаторы — не серебряная пуля. Нужно какое-то время, чтобы привыкнуть к не всегда очевидному поведению некоторых методов, например, #reload:

person = Person.find(1)
seller = Seller.new(person)
seller.class # => Seller
seller.reload.class # => Person

Еще один подводный камень состоит в том, что по умолчанию метод #id не делегируется. Чтобы получать именно AcitveRecord#id, добавьте эту строчку в класс-делегатор:

delegate :id, :to => :__getobj__

Несмотря на это, классы-делегаторы — отличный инструмент для повышения гибкости кода.


От переводчика: Сергей Потапов указывает на еще одну неочевидную особенность DelegateClass:
require 'delegate'

class Animal
end

class Dog < DelegateClass(Animal)
end

animal = Animal.new
dog = Dog.new(animal)

dog.eql?(dog)  # => false, WTF? O_o

Происходит это из-за того, что #eql? вызывается для базового объекта (в данном случае, animal):

dog.eql?(animal)  # => true

С другой стороны, #equal? не делегируется по умолчанию:

dog.equal?(dog)     # => true
dog.equal?(animal)  # => false

Автор: kaluzhanin

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js