shikhalev.org

Оригинал этой статьи опубликован в журнале «Системный администратор» №12 (145) за декабрь 2014.


Добавление собственных абстракций в объектную модель — это просто. И интересно.

Авторы книги «Programming Ruby: The Pragmatic Programmers’ Guide» называют метапрограммированием расширение и изменение абстракций языка (тогда как собственно программирование пользуется теми, что есть). Конечно, можно поспорить о том, что считать такой абстракцией, а что нет, однако нельзя не заметить, что в современных динамических языках, таких как Ruby или, например, Python, легко делаются некоторые вещи, которые в классических языках находились именно на языковом уровне и жестко определялись компилятором. Тут можно вспомнить, для примера, декораторы, о которых я писал в сентябре прошлого года1. И сейчас мы рассмотрим нечто подобное. В процессе я буду делать обобщающие отступления, переходя от частного примера к общим принципам программирования в Ruby.

Формулировка задачи

В объектно-ориентированных языках, как правило, имеются такие абстракции как методы и свойства. В Ruby свойства называются атрибутами, что, впрочем, сути не меняет. Однако в Ruby мы можем и сами определить аналогичные конструкции с нужной нам специфической функциональностью. Так и поступим.

Наши свойства будут поддерживать:

  • контроль присваиваемых значений;
  • значения по умолчанию;
  • события, вызываемые при установке значения.

Определение свойств будет выглядеть примерно так:

property :alpha, filter: String, default: ''
property :beta, :gamma, filter: Integer, default: 0
property :delta, filter: Float,
    default: 1.0 do |obj, prop, value|
  p [obj, prop, value]
end

Последний вариант задает обработчик события. В случае, когда никаких именованных параметров, даже filter, не задано, property будет работать, в сущности, аналогично стандартному attr_accessor. Заметим, что атрибуты в Ruby определяются не ключевыми словами, а приватными методами класса Module. Логично будет пойти тем же путем.

Замечание: ключевые слова module и class в Ruby не формируют какое-то сакральное определение, принципиально отличающееся от остальной части программы, а просто переводят исполнение в контекст модуля (или класса, соответственно), по необходимости создавая его. Это значит, что внутри определений мы можем писать, в общем-то, произвольный код, учитывая контекст, конечно2. При этом благодаря необязательности скобок при вызове, такие методы, как attr, include, private, module_function, выглядят структурными элементами языка.

Минимальный вариант

Начнем с самого простого — реализуем аналог attr_accessor. Заявленный выше функционал добавим после. Определение выглядит так3:

class Module

  private

  def prop name
    varnm = "@#{name}"
    getnm = name
    setnm = "#{name}="
    define_method getnm do
      instance_variable_get varnm
    end
    define_method setnm do |value|
      instance_variable_set varnm, value
    end
  end

  def property *names
    names.each do |nm|
      prop nm
    end
  end

end

Собственно определение (единственного) свойства выделено в отдельный метод prop для ясности. А для проверки напишем следующее:

class Alpha
  property :alpha, :beta
  attr_accessor :gamma
end

a = Alpha.new

a.alpha = 1
a.beta = 2
a.gamma = 3

p [a.alpha, a.beta, a.gamma]
p a

И выполним:

$ ruby demo00.rb
[1, 2, 3]
♯‹Alpha:0x0000000260bbc8 @alpha=1, @beta=2, @gamma=3›

Нетрудно видеть, что нет разницы между работой нашего метода и стандартного. Теперь можно двигаться дальше, но сначала некоторые пояснения.

Свойство в Ruby — это пара методов: один возвращает значение одноименной переменной экземпляра объекта, а второй — устанавливает. Имя метода-сеттера заканчивается символом =, это необходимо и достаточно, чтобы в дальнейшем можно было использовать такой метод в левой части оператора присваивания. В принципе, привязка именно к переменной объекта необязательна, методы могут использовать любые данные.

Из приведенного кода можно видеть, что define_method (в отличие от def) работает с замыканием, таким образом мы можем использовать в нем внешние переменные. Другим вариантом могло бы быть формирование строки кода и выполнение ее через module_eval, но такой способ даст замедление за счет того, что эта строка будет разбираться и компилироваться при каждом вызове, тогда как в нашем случае разбор производится однократно, при первом проходе.

Значения по умолчанию

Если мы сейчас обратимся к определенному нами свойству, значение которого не установлено, то получим nil, так же как для стандартного атрибута. Это поведение можно переопределить. Введем два именованных параметра: default — значение по умолчанию; и default_proc — объект, приводимый к классу Proc, который будет вызываться для инициализации внутренней переменной, если она не установлена при первом вызове свойства. Если заданы оба, преимущество имеет default_proc.

Метод prop теперь будет выглядеть так:

def prop name, default: nil, default_proc: nil
  varnm = "@#{name}"
  getnm = name
  setnm = "#{name}="
  define_method getnm do
    if ! instance_variable_defined?(varnm)
      if default_proc
        instance_variable_set varnm,
              default_proc.to_proc.call(self)
      else
        instance_variable_set varnm, default
      end
    end
    instance_variable_get varnm
  end
  define_method setnm do |value|
    instance_variable_set varnm, value
  end
end

К методу property тоже добавятся соответствующие именованные параметры, которые он просто передаст в prop для каждого имени свойства. Проверим, что у нас получилось, следующим кодом:

class Alpha
  property :alpha, default: 100
  property :beta,
    default_proc: proc { |o| o.class.name }
  property :gamma, default_proc: :class
end

a = Alpha.new

p [a.alpha, a.beta, a.gamma]
p a

Вывод должен быть примерно следующий:

$ ruby demo01.rb
[100, "Alpha", Alpha]
♯‹Alpha:0x00000002252a90 @alpha=100, @beta="Alpha", @gamma=Alpha›

Стоит заметить, что для последнего свойства мы применили немного необычный фокус: оказывается, значения класса Symbol имеют метод to_proc, который формально можно записать как:

def to_proc
  proc { |obj, *args| obj.send self, *args }
end

То есть, получается вызов метода с соответствующим именем для объекта, переданного первым параметром, и с аргументами из остальных параметров, если таковые присутствуют. При этом собственного метода call объекты класса Symbol не имеют — преобразование to_proc здесь обязательно.

Вызов события

Добавим к параметрам propproperty соответственно) &block — собственно обработчик, и преобразуем слегка определение сеттера из предыдущих примеров.

define_method setnm do |value|
  instance_variable_set varnm, value
  if block
    block.call self, name, value
  end
end

Тестируем:

class Alpha
  property :alpha
  property :beta do |obj, prop, value|
    p [obj, prop, value]
  end
end

a = Alpha.new

a.alpha = 100
a.beta = 200

Получаем:

$ ruby demo02.rb
[♯‹Alpha:0x000000025d2e18 @alpha=100, @beta=200›, :beta, 200]

Проверка значений

Добавим к prop следующие именованные аргументы: filter — собственно фильтр, и on_invalid — параметр, определяющий, как будет обрабатываться попытка присвоить неподходящее значение. Вообще говоря, тут может быть множество вариантов поведения, но два основных — это проигнорировать и сгенерировать исключение, поэтому мы сделаем так: если в параметре on_invalid передан класс исключения, вызываем raise, если nil — игнорируем. А чтобы дать возможность определить какое-то произвольное поведение, будем принимать также объекты класса Proc.

Изменим определение сеттера следующим образом:

define_method setnm do |value|
  if filter && !(filter === value)
    if Class === on_invalid && on_invalid <= Exception
      raise on_invalid, "Invalid property (#{name})" +
          " value: #{value.inspect}!", caller
    elsif Proc === on_invalid
      on_invalid.call self, name, value
    end
  else
    instance_variable_set varnm, value
    if block
      block.call self, name, value
    end
  end
end

И проверим при помощи следующего кода:

class Alpha

  property :alpha, filter: Integer
  property :beta,
      filter: proc { |x| Integer === x && x > 0 },
      on_invalid: StandardError

end

a = Alpha.new

a.alpha = 11
a.beta = 2
p a

a.alpha = "String"
p a

a.beta = -1
p a

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

$ ruby demo03.rb
♯‹Alpha:0x00000001b193c8 @alpha=11, @beta=2›
♯‹Alpha:0x00000001b193c8 @alpha=11, @beta=2›
demo03.rb:24:in `‹main›': Invalid property (beta) value: -1! (StandardError)

Здесь хотелось бы отметить оператор соответствия — ===, который мы использовали для проверки условия. В отличие от, например, JavaScript, где три знака «равно» означают точное равенство, в Ruby этот оператор принято трактовать как «правая часть соответствует левой» — обычно это означает равенство, но не всегда: если в левой части класс, то выполняется проверка принадлежности классу (с учетом наследования), если диапазон — вхождения в диапазон… Так же можно проверять соответствие регулярному выражению, а значения класса Proc будут выполнены с правой частью в качестве аргумента, что и позволило нам записать условие для свойства beta в коротком и удобочитаемом виде.

Кроме того, мы можем определить этот оператор для каких-то своих классов условий, задав ему произвольное поведение. Или переопределить его для каких-либо стандартных классов, что, впрочем, может иметь непредсказуемые последствия.

Что дальше?

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

Вынесение всего, что возможно — в данном случае это проверки условий — из повторяющейся части (метода) в выполняемую однократно при его определении — азы оптимизации, и тут возможность задавать определения на ходу, доступная в динамических языках, очень нам на руку.

Практический смысл

Собственно, даже такая простая, взятая для примера, функциональность не бесполезна. А еще можно создавать свойства (поля) с внешним хранением данных, с дополнительными параметрами отображения и так далее и тому подобное. Можно привязать класс Ruby к таблице базы данных, определить поля, ключи, связи между таблицами… В общем, легко создать удобный и прозрачный ORM4. Что, кстати, и сделано в фреймворке Ruby on Rails (см. ActiveRecord и ActiveModel).

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

  1. Статья «Декораторы в Ruby». «Системный администратор» № 9 (130), сентябрь 2013. Стр. 68–71. 

  2. Подробнее о контекстах см. мою статью «Блоки и контекст в Ruby». «Системный администратор» № 1–2 (134–135), январь-февраль 2014. Стр. 111–115. 

  3. Полные тексты примеров размещены на GitHub — https://gist.github.com/shikhalev/5f19659a7ed82ce83c58

  4. Object-Relational Mapping — отображение реляционных баз данных в объектную модель.