Оригинал этой статьи опубликован в журнале «Системный администратор» №12 (145) за декабрь 2014.
Добавление собственных абстракций в объектную модель — это просто. И интересно.
Авторы книги «Programming Ruby: The Pragmatic Programmers’ Guide» называют метапрограммированием расширение и изменение абстракций языка (тогда как собственно программирование пользуется теми, что есть). Конечно, можно поспорить о том, что считать такой абстракцией, а что нет, однако нельзя не заметить, что в современных динамических языках, таких как Ruby или, например, Python, легко делаются некоторые вещи, которые в классических языках находились именно на языковом уровне и жестко определялись компилятором. Тут можно вспомнить, для примера, декораторы, о которых я писал в сентябре прошлого года1. И сейчас мы рассмотрим нечто подобное. В процессе я буду делать обобщающие отступления, переходя от частного примера к общим принципам программирования в Ruby.
Формулировка задачи
В объектно-ориентированных языках, как правило, имеются такие абстракции как методы и свойства. В Ruby свойства называются атрибутами, что, впрочем, сути не меняет. Однако в Ruby мы можем и сами определить аналогичные конструкции с нужной нам специфической функциональностью. Так и поступим.
Наши свойства будут поддерживать:
- контроль присваиваемых значений;
- значения по умолчанию;
- события, вызываемые при установке значения.
Определение свойств будет выглядеть примерно так:
Последний вариант задает обработчик события. В случае, когда никаких именованных параметров, даже filter
, не задано, property
будет работать, в сущности, аналогично стандартному attr_accessor
. Заметим, что атрибуты в Ruby определяются не ключевыми словами,
а приватными методами класса Module
. Логично будет пойти тем же путем.
Замечание: ключевые слова module
и class
в Ruby не формируют какое-то сакральное определение, принципиально отличающееся
от остальной части программы, а просто переводят исполнение в контекст модуля (или класса, соответственно), по необходимости
создавая его. Это значит, что внутри определений мы можем писать, в общем-то, произвольный код, учитывая контекст, конечно2.
При этом благодаря необязательности скобок при вызове, такие методы, как attr
, include
, private
, module_function
, выглядят
структурными элементами языка.
Минимальный вариант
Начнем с самого простого — реализуем аналог attr_accessor
. Заявленный выше функционал добавим после. Определение выглядит так3:
Собственно определение (единственного) свойства выделено в отдельный метод prop для ясности. А для проверки напишем следующее:
И выполним:
Нетрудно видеть, что нет разницы между работой нашего метода и стандартного. Теперь можно двигаться дальше, но сначала некоторые пояснения.
Свойство в Ruby — это пара методов: один возвращает значение одноименной переменной экземпляра объекта, а второй — устанавливает.
Имя метода-сеттера заканчивается символом =
, это необходимо и достаточно, чтобы в дальнейшем можно было использовать такой метод
в левой части оператора присваивания. В принципе, привязка именно к переменной объекта необязательна, методы могут использовать
любые данные.
Из приведенного кода можно видеть, что define_method
(в отличие от def
) работает с замыканием, таким образом мы можем использовать
в нем внешние переменные. Другим вариантом могло бы быть формирование строки кода и выполнение ее через module_eval
, но такой способ
даст замедление за счет того, что эта строка будет разбираться и компилироваться при каждом вызове, тогда как в нашем случае разбор
производится однократно, при первом проходе.
Значения по умолчанию
Если мы сейчас обратимся к определенному нами свойству, значение которого не установлено, то получим nil
, так же как для стандартного
атрибута. Это поведение можно переопределить. Введем два именованных параметра: default
— значение по умолчанию; и default_proc
— объект,
приводимый к классу Proc
, который будет вызываться для инициализации внутренней переменной, если она не установлена при первом вызове
свойства. Если заданы оба, преимущество имеет default_proc
.
Метод prop
теперь будет выглядеть так:
К методу property
тоже добавятся соответствующие именованные параметры, которые он просто передаст в prop для каждого имени свойства.
Проверим, что у нас получилось, следующим кодом:
Вывод должен быть примерно следующий:
Стоит заметить, что для последнего свойства мы применили немного необычный фокус: оказывается, значения класса Symbol
имеют метод
to_proc
, который формально можно записать как:
То есть, получается вызов метода с соответствующим именем для объекта, переданного первым параметром, и с аргументами из остальных
параметров, если таковые присутствуют. При этом собственного метода call
объекты класса Symbol
не имеют — преобразование to_proc
здесь обязательно.
Вызов события
Добавим к параметрам prop
(и property
соответственно) &block
— собственно обработчик, и преобразуем слегка определение сеттера
из предыдущих примеров.
Тестируем:
Получаем:
Проверка значений
Добавим к prop
следующие именованные аргументы: filter
— собственно фильтр, и on_invalid
— параметр, определяющий,
как будет обрабатываться попытка присвоить неподходящее значение. Вообще говоря, тут может быть множество вариантов поведения,
но два основных — это проигнорировать и сгенерировать исключение, поэтому мы сделаем так: если в параметре on_invalid
передан класс исключения, вызываем raise
, если nil
— игнорируем. А чтобы дать возможность определить какое-то произвольное
поведение, будем принимать также объекты класса Proc
.
Изменим определение сеттера следующим образом:
И проверим при помощи следующего кода:
Результат должен получиться примерно такой:
Здесь хотелось бы отметить оператор соответствия — ===
, который мы использовали для проверки условия. В отличие от, например, JavaScript,
где три знака «равно» означают точное равенство, в Ruby этот оператор принято трактовать как «правая часть соответствует левой» — обычно это
означает равенство, но не всегда: если в левой части класс, то выполняется проверка принадлежности классу (с учетом наследования), если диапазон —
вхождения в диапазон… Так же можно проверять соответствие регулярному выражению, а значения класса Proc
будут выполнены с правой частью
в качестве аргумента, что и позволило нам записать условие для свойства beta
в коротком и удобочитаемом виде.
Кроме того, мы можем определить этот оператор для каких-то своих классов условий, задав ему произвольное поведение. Или переопределить его для каких-либо стандартных классов, что, впрочем, может иметь непредсказуемые последствия.
Что дальше?
В общем-то, задача, поставленная в начале статьи, решена. Замечу только, что совершенно необязательно формировать один большой
метод с несколькими условными ветвлениями — можно, и даже нужно с точки зрения оптимизации, проверять filter
снаружи, и уже
в зависимости от его значения определять метод-сеттер: простой без проверок, игнорирующий, с исключением, или же с обработкой.
Вынесение всего, что возможно — в данном случае это проверки условий — из повторяющейся части (метода) в выполняемую однократно при его определении — азы оптимизации, и тут возможность задавать определения на ходу, доступная в динамических языках, очень нам на руку.
Практический смысл
Собственно, даже такая простая, взятая для примера, функциональность не бесполезна. А еще можно создавать свойства (поля)
с внешним хранением данных, с дополнительными параметрами отображения и так далее и тому подобное. Можно привязать класс Ruby
к таблице базы данных, определить поля, ключи, связи между таблицами… В общем, легко создать удобный и прозрачный ORM4.
Что, кстати, и сделано в фреймворке Ruby on Rails (см. ActiveRecord
и ActiveModel
).
Естественно, базами данных область применения не ограничивается. Важно помнить, что внутри определений классов и модулей мы можем использовать все средства языка и, соответственно, запрограммировать какие-то сложные вещи в короткие «однострочные» конструкции. Это напоминает макросы в некоторых компилируемых языках, но, поскольку определения доступны во время выполнения, дает гораздо более широкие возможности.
-
Статья «Декораторы в Ruby». «Системный администратор» № 9 (130), сентябрь 2013. Стр. 68–71. ↩
-
Подробнее о контекстах см. мою статью «Блоки и контекст в Ruby». «Системный администратор» № 1–2 (134–135), январь-февраль 2014. Стр. 111–115. ↩
-
Полные тексты примеров размещены на GitHub — https://gist.github.com/shikhalev/5f19659a7ed82ce83c58. ↩
-
Object-Relational Mapping — отображение реляционных баз данных в объектную модель. ↩