Реализация цепочек сравнения на 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, и как это делать.
Я не советовал бы использовать подобный хак в реальных проектах, поскольку, как оказалось он работает не полностью. К тому же, скорее всего это значительно снизит производительность вашей системы.