Инструменты для реализации атрибутов-вычислителей
Введение
An English version of this text is also available: README.md.
Атрибут-вычислитель (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 Алексей Фортуна