Реализация цепочек сравнения на Ruby, а так же немного метапрограммирования и рефакторинга
Вступление
В Python есть довольно удобная и красивая конструкция сравнения чисел:
1 < 5 < 10 # True
1 > 5 < 10 # False
Когда я первый раз её увидел я подумал «Почему такой синтаксис не сделали в Ruby?».
Позже я осознал, что в Ruby это можно реализовать самому.
Рассуждения
В Ruby классы являются открытыми, что позволяет без труда переобределять методы уже существующих классов. Злоупотребление и неправильное использование подобного monkey patch может привести к непредсказуемости кода и кучи убитого времени на отладку. Но всё же мы пойдём этой дорогой.
Что происходит когда руби интерпретатор обрабатывает следующий код?
1 < 5
У объекта 1(единица) вызывается метод <(меньше) с параметром 5. Метод < может возвращаться true
или false
, в данном случае он возвращает true
.
Отсюда приходит на ум, что чтобы следующая и подобные ей конструкции работали:
1 < 5 < 10
В классах TrueClass и FalseClass необходимо определить методы: <, >, <=, >=.
Кроме того, необходимо чтобы объекты true и false сохраняли значения последнего операнда, так как они должны будут его использовать в сравнении со следующим числом в цепочке.
А это означает, что нам придётся так же переопределить методы <, >, <=, >=, == в классах представляющие числа (Fixnum, Float, Bignum и т.д. — все наследники класса Numeric)
Реализация
Чтобы не писать слишком много кода для примера, я буду работать только с классами Fixnum, TrueClass, FalseClass и операторами <, >:
class FalseClass attr_accessor :last_operand, :final_false def <(arg) return false if @final_false result = @last_operand.send("<", arg) result.final_false = true unless result result end def >(arg) return false if @final_false result = @last_operand.send(">", arg) result.final_false = true unless result result end end class TrueClass attr_accessor :last_operand, :final_false def <(arg) return false if @final_false result = @last_operand.send("<", arg) result.final_false = true unless result result end def >(arg) return false if @final_false result = @last_operand.send(">", arg) result.final_false = true unless result result end end class Fixnum alias original_more > alias original_less < def >(arg) result = original_more(arg) result.last_operand = arg result.final_false = true unless result result end def <(arg) result = original_less(arg) result.last_operand = arg result.final_false = true unless result result end end
Протестируем:
1 < 5 < 10 # => true
1 > 5 < 10 # => false
Работает так, как мы и ожидали.
Давайте теперь немного рассмотрим код.
Значения последнего операнда мы храним в булевых объектах в свойстве @last_operand
. Свойство @final_false
нам немобходимо, чтобы передавать дальше по цепочке вызова методов информацию о том, что в одном из предыдущих сравнений мы получили false
.
В классе Fixnum
мы создали алиасы для <, > а настоящие методы переопределили так, чтобы возвращались true
или false
с установленным значением last_operand
.
Рефакторинг
В методах, которые мы создали и переопределили очень много дублирующего кода. Именно поэтому я не стал реализовывать в предыдущем примере методы <= и >=. Благо Ruby позволяет легко избавится от однообразных повторений.
После рефакторинга наш код превращается в следующий:
module PythonComparator module Boolean attr_accessor :last_operand, :final_false new_methods = [">", "<", ">=", "<="] # динамическое определение методов: >, <, >=, <= new_methods.each do |method| define_method(method) do |arg| return false if @final_false result = last_operand.send(method, arg) result.final_false= true unless result result end end end module Numeric def self.included(clazz) # методы, которые мы хотим переопределить и имена их алиасов. original_methods = {">" => "original_more", "<" => "original_less", ">=" => "original_more_equal", "<=" => "original_less_equal", "==" => "original_equal"} clazz.class_eval do original_methods.each do |method, new_original| # создание алиаса для настоящего метода, чтобы мы смогли потом к нему обратиться alias_method(new_original, method) # переопределение метода define_method(method) do |arg| result = send(new_original, arg) result.last_operand = arg result.final_false = true unless result result end end end end end end class FalseClass include PythonComparator::Boolean end class TrueClass include PythonComparator::Boolean end # подмешиваем модуль PythonComparator::Numeric во все наследники класса Numeric # В ruby 1.9 у класса Сomplex нету методов >, < и т.д ObjectSpace.each_object(Class) do |clazz| clazz.send(:include, PythonComparator::Numeric) if clazz.ancestors.include?(Numeric) && clazz.to_s != 'Complex' end
Мы вынесли общую функциональность классов TrueClass
и FalseClass
в модуль PythonComparator::Boolean
. Поскольку все 4 метода(>, <, >=, <=) реализованы практически одинаково, целесообразно их определять с помощью define_method
, чтобы избежать лишних повторений кода. Напомню, что метод define_method
принимает имя метода, который нужно определить и блок кода. Список аргументов блока кода, будет списком аргументов нового определяемого метода.
К сожалению мы не можешь просто определить набор аналогичных методов в модуле PythonComparator::Numeric
, чтобы подмешать их в наследники Numeric
, поскольку обычные модули не переопределяют методы, которые уже есть у класса.
Для этого мы переопределили в модуле PythonComparator::Numeric
метод included
, который вызывается у модуля во время подмешивания(подобный трюк называется hook method
). Методы included
передаются объект в который модуль подмешивается(как правило класс или другой модуль).
Далее мы в контексте класса, в который подмешивается модуль создаём алиасы и переопределяем необходимые нам методы.
Тестируем:
4 >= 5 < 9 # => false
5 <= 5 < 10 >= 10 < 11 # => true
Переопределение == для true и false
Всё хорошо, но такой код у нас вернёт false
.
10 > 5 == 5
Поскольку 10 > 5
вернёт true
, то у true
будет вызван метод ==. Очевидно что true не равно 5.
Для этого нам нужно переопределить метод == так, чтобы он проверял аргумент какого типа он принял.
После нужных изменений модуль PythonComparator::Boolean
примет следующий вид:
module Boolean attr_accessor :last_operand, :final_false new_methods = [">", "<", ">=", "<="] new_methods.each do |method| define_method(method) do |arg| return false if @final_false result = last_operand.send(method, arg) result.final_false= true unless result result end end # создаём алиас class << self alias :original_included :included end def self.included(clazz) # вызываем созданый алиас, чтобы подмешать методы определённые в модуле. original_included(clazz) # в переопределяем метод == clazz.class_eval do alias :original_equal :== define_method(:==) do |arg| arg.is_a?(Numeric) ? last_operand == arg : original_equal(arg) end end end end
Теперь всё вроде работает как надо:
1 < 2 == 2 < 5 # => true
Всё же
По непонятным мне причинам при использовании конструкции
2 == 2 == 2
синтаксический анализатор Ruby возвращает ошибку.
Заключение
Данная статья является лишь примером того, что можно делать в Ruby, и как это делать.
Я не советовал бы использовать подобный хак в реальных проектах, поскольку, как оказалось он работает не полностью. К тому же, скорее всего это значительно снизит производительность вашей системы.