Реализация цепочек сравнения на Ruby, а так же немного метапрограммирования и рефакторинга

22 июля 2010 г.

Вступление

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

Теги:
рубрика Программирование
  • Похожие статьи
  • Предыдущие из рубрики