attr_magic: Инструменты для реализации атрибутов-вычислителей
👉Страница проекта на GitHub
Домашняя страница данного проекта находится на GitHub: dadooda/attr_magic. Все ссылки ведут туда же.
Введение
Атрибут-вычислитель (lazy attribute) — это метод-accessor, который производит некое вычисление при первом вызове, мемоизирует результат в instance-переменную и возвращает результат. Например:
class Person
  # @return [String]
  attr_accessor :first_name, :last_name
  attr_writer :full_name, :is_admin
  def initialize(attrs = {})
    attrs.each { |k, v| public_send("#{k}=", v) }
  end
  # @return [String]
  def full_name
    @full_name ||= begin
      [first_name, last_name].compact.join(" ").strip
    end
  end
  # @return [Boolean]
  def is_admin
    return @is_admin if defined? @is_admin
    @is_admin = !!full_name.match(/admin/i)
  end
end
В примере выше #full_name и #is_admin — атрибуты-вычислители.
Что даёт AttrMagic?
Классам, содержащим атрибуты-вычислители, AttrMagic даёт:
- #igetsetи- #igetwriteдля простой мемоизации любых значений, включая- falseи- nil.
- #require_attrдля валидации атрибутов, от которых зависит результат данного вычисления.
Установка
Добавляем в Gemfile нашего проекта:
gem "attr_magic"
#gem "attr_magic", git: "https://github.com/dadooda/attr_magic"
Пример использования
Чтобы использовать фичу, загружаем её в класс:
class Person
  AttrMagic.load(self)
  …
end
Методы AttrMagic теперь доступны в классе Person.
Теперь рассмотрим, какие инструменты стали нам доступны.
  #igetset
  
    
  
В примере выше метод #full_name мемоизирует результат оператором ||=.
Это вполне приемлемо, ведь результат вычисления — строка.
А вот реализация #is_admin гораздо более громоздка, ведь результат
может быть вычислен как false, стало быть ||= не подойдёт.
Оба метода можно переписать с использованием #igetset:
class Person
  …
  def full_name
    igetset(__method__) { [first_name, last_name].compact.join(" ").strip }
  end
  def is_admin
    igetset(__method__) { !!full_name.match(/admin/i) }
  end
end
Методы приобрели короткий, единообразный, легко читаемый вид.
Теперь вычисление в #is_admin отчётливо видно, тогда как ранее оно тонуло в повторах
is_admin внутри метода, который и так назван этим словом.
  #require_attr
  
    
  
В примере выше метод #first_name возвращает пустую строку, даже если атрибуты
first_name и last_name не присвоены или пусты.
С точки зрения внятности это поведение «на грани фола».
Ведь, скорее всего, результат #full_name будет выводиться в блоке информации о персоне или при обращении к персоне.
Пустая строка, даже правомерно вычисленная, вызовет в этой ситуации, как минимум, непонимание.
Конечно, экземпляр Person не виноват, что перед вызовом #full_name в нём не присвоили first_name и last_name.
Как говорится, garbage in — garbage out.
Однако, чем тупо «вредничать», возвращая невнятную пустоту, #full_name мог бы
помочь разработчику выявить эту ситуацию, чётко о ней просигналив.
Предположим, мы решили, что для корректного вычисления #full_name нам необходим,
как минимум, непустой first_name. Реализация может выглядеть так:
class Person
  …
  def full_name
    igetset(__method__) do
      require_attr :first_name
      [first_name, last_name].compact.join(" ").strip
    end
  end
end
Теперь посмотрим, как это работает:
Person.new.full_name
# RuntimeError: Attribute `first_name` must not be nil: nil
Вроде неплохо. Вместо пустоты мы получили исключение с указанием на причину отказа:
не присвоен first_name. Смущают, однако, слова про nil, хотя речь идёт о строковом атрибуте.
А вдруг в first_name пустая строка, что тогда? Пробуем:
Person.new(first_name: "").full_name
# => ""
Person.new(first_name: " ").full_name
# => ""
На пустую строку наш require_attr пока не реагирует, хотя суть требования была в том,
чтобы first_name был именно не пустой. Чуть-чуть доработаем код:
# Нужно для `Object#present?`.
require "active_support/core_ext/object/blank"
class Person
  …
  def full_name
    igetset(__method__) do
      require_attr :first_name, :present?
      #require_attr :first_name, :not_blank?   # Можно так.
      [first_name, last_name].compact.join(" ").strip
    end
  end
end
Снова пробуем вызвать:
Person.new.full_name
# RuntimeError: Attribute `first_name` must be present: nil
Person.new(first_name: " ").full_name
# RuntimeError: Attribute `first_name` must be present: " "
Person.new(first_name: "Joe").full_name
# => "Joe"
Теперь и сообщение чётче, и требование выполнено.
Мы узнали, что #require_attr даёт возможность выполнить тривиальную валидацию
соседнего атрибута, значение которого нужно в данном вычислении.
  #igetwrite
  
    
  
Описанный в примере выше, метод #igetset взаимодействует с instance-переменными напрямую:
проверяет наличие, читает, записывает. В большинстве случаев этого достаточно.
Бывает, однако, что мы добавляем наш метод-вычислитель в класс, требующий записи в свои атрибуты
строго через write-аксессоры вроде #name=.
В таких случаях помогает #igetwrite.
Выполнив вычисление, он записывает значение в объект через write accessor.
Copyright
Продукт распространяется свободно на условиях лицензии MIT.
— © 2017-2023 Алексей Фортуна