Метапрограммирование Ruby даже круче, чем кажется
Опубликовано: 2022-03-11Вы часто слышите, что метапрограммирование — это то, что используют только ниндзя Ruby, и что оно просто не для простых смертных. Но правда в том, что метапрограммирование совсем не страшно. Этот пост в блоге призван бросить вызов этому типу мышления и приблизить метапрограммирование к среднему разработчику Ruby, чтобы они также могли воспользоваться его преимуществами.
Следует отметить, что метапрограммирование может означать очень многое, и его часто можно очень неправильно использовать и доходить до крайности, когда дело доходит до использования, поэтому я постараюсь привести несколько реальных примеров, которые каждый мог бы использовать в повседневном программировании.
Метапрограммирование
Метапрограммирование — это метод, с помощью которого вы можете написать код, который динамически пишет код сам по себе во время выполнения. Это означает, что вы можете определять методы и классы во время выполнения. Сумасшедший, верно? Короче говоря, используя метапрограммирование, вы можете повторно открывать и изменять классы, перехватывать несуществующие методы и создавать их на лету, создавать СУХОЙ код, избегая повторений, и многое другое.
Основы
Прежде чем мы погрузимся в серьезное метапрограммирование, мы должны изучить основы. И лучший способ сделать это — личный пример. Давайте начнем с одного и шаг за шагом разберем метапрограммирование Ruby. Вы, наверное, догадались, что делает этот код:
class Developer def self.backend "I am backend developer" end def frontend "I am frontend developer" end end
Мы определили класс с двумя методами. Первый метод в этом классе — это метод класса, а второй — метод экземпляра. Это базовые вещи в Ruby, но за этим кодом скрывается гораздо больше, что нам нужно понять, прежде чем мы продолжим. Стоит отметить, что сам класс Developer
на самом деле является объектом. В Ruby все является объектом, включая классы. Поскольку Developer
является экземпляром, он является экземпляром класса Class
. Вот как выглядит объектная модель Ruby:
p Developer.class # Class p Class.superclass # Module p Module.superclass # Object p Object.superclass # BasicObject
Одна важная вещь, которую нужно понять здесь, это значение self
. frontend
метод — это обычный метод, доступный в экземплярах класса Developer
, но почему backend
метод является методом класса? Каждый фрагмент кода, выполняемый в Ruby, выполняется для определенного self . Когда интерпретатор Ruby выполняет какой-либо код, он всегда отслеживает значение self
для любой заданной строки. self
всегда ссылается на какой-то объект, но этот объект может измениться в зависимости от выполняемого кода. Например, внутри определения класса self
ссылается на сам класс, который является экземпляром класса Class
.
class Developer p self end # Developer
Внутри методов экземпляра self
ссылается на экземпляр класса.
class Developer def frontend self end end p Developer.new.frontend # #<Developer:0x2c8a148>
Внутри методов класса self
ссылается на сам класс определенным образом (более подробно это будет обсуждаться далее в этой статье):
class Developer def self.backend self end end p Developer.backend # Developer
Это хорошо, но что такое метод класса? Прежде чем ответить на этот вопрос, мы должны упомянуть о существовании чего-то, что называется метаклассом, также известным как одноэлементный класс и собственный класс. Внешний frontend
метода класса, который мы определили ранее, представляет собой не что иное, как метод экземпляра, определенный в метаклассе для объекта Developer
! Метакласс — это, по сути, класс, который Ruby создает и вставляет в иерархию наследования для хранения методов класса, не мешая, таким образом, экземплярам, созданным из класса.
Метаклассы
У каждого объекта в Ruby есть собственный метакласс. Он почему-то невидим для разработчика, но он есть, и вы можете очень легко его использовать. Поскольку наш класс Developer
по сути является объектом, у него есть собственный метакласс. В качестве примера создадим объект класса String
и будем манипулировать его метаклассом:
example = "I'm a string object" def example.something self.upcase end p example.something # I'M A STRING OBJECT
Что мы сделали здесь, так something
добавили одноэлементный метод к объекту. Разница между методами класса и одноэлементными методами заключается в том, что методы класса доступны для всех экземпляров объекта класса, а одноэлементные методы доступны только для этого единственного экземпляра. Методы класса широко используются, а одноэлементные методы не так часто, но оба типа методов добавляются в метакласс этого объекта.
Предыдущий пример можно было бы переписать так:
example = "I'm a string object" class << example def something self.upcase end end
Синтаксис отличается, но он эффективно делает то же самое. Теперь давайте вернемся к предыдущему примеру, где мы создали класс Developer
, и рассмотрим некоторые другие синтаксисы для определения метода класса:
class Developer def self.backend "I am backend developer" end end
Это основное определение, которое используют почти все.
def Developer.backend "I am backend developer" end
Это то же самое, мы определяем метод backend
-класса для Developer
. Мы не использовали self
, но определение такого метода фактически делает его методом класса.
class Developer class << self def backend "I am backend developer" end end end
Опять же, мы определяем метод класса, но с использованием синтаксиса, аналогичного тому, который мы использовали для определения одноэлементного метода для объекта String
. Вы можете заметить, что здесь мы использовали self
, который относится к самому объекту Developer
. Сначала мы открыли класс Developer
, сделав себя равным классу Developer
. Затем мы делаем class << self
, делая self равным метаклассу Developer
. Затем мы определяем backend
метода в метаклассе Developer
.
class << Developer def backend "I am backend developer" end end
Определяя такой блок, мы устанавливаем для self
метакласс Developer
на время действия блока. В результате в метакласс Developer
добавляется backend
метод, а не сам класс.
Посмотрим, как этот метакласс ведет себя в дереве наследования:
Как вы видели в предыдущих примерах, нет никаких реальных доказательств того, что метакласс вообще существует. Но мы можем использовать небольшой хак, который может показать нам существование этого невидимого класса:
class Object def metaclass_example class << self self end end end
Если мы определим метод экземпляра в классе Object
(да, мы можем повторно открыть любой класс в любое время, это еще одна прелесть метапрограммирования), у нас будет self
, ссылающийся на Object
внутри него. Затем мы можем использовать синтаксис class << self
, чтобы изменить текущий self так , чтобы он указывал на метакласс текущего объекта. Поскольку текущий объект сам является классом Object
, это будет метакласс экземпляра. Метод возвращает self
, который на данный момент сам является метаклассом. Таким образом, вызывая этот метод экземпляра для любого объекта, мы можем получить метакласс этого объекта. Давайте снова определим наш класс Developer
и начнем немного исследовать:

class Developer def frontend p "inside instance method, self is: " + self.to_s end class << self def backend p "inside class method, self is: " + self.to_s end end end developer = Developer.new developer.frontend # "inside instance method, self is: #<Developer:0x2ced3b8>" Developer.backend # "inside class method, self is: Developer" p "inside metaclass, self is: " + developer.metaclass_example.to_s # "inside metaclass, self is: #<Class:#<Developer:0x2ced3b8>>"
И для крещендо, давайте посмотрим на доказательство того, что frontend
— это метод экземпляра класса, а backend
— метод экземпляра метакласса:
p developer.class.instance_methods false # [:frontend] p developer.class.metaclass_example.instance_methods false # [:backend]
Хотя, чтобы получить метакласс, вам не нужно заново открывать Object
и добавлять этот хак. Вы можете использовать singleton_class
, который предоставляет Ruby. Это то же самое, что и metaclass_example
, который мы добавили, но с помощью этого хака вы действительно можете увидеть, как Ruby работает под капотом:
p developer.class.singleton_class.instance_methods false # [:backend]
Определение методов с использованием «class_eval» и «instance_eval»
Есть еще один способ создать метод класса — использовать instance_eval
:
class Developer end Developer.instance_eval do p "instance_eval - self is: " + self.to_s def backend p "inside a method self is: " + self.to_s end end # "instance_eval - self is: Developer" Developer.backend # "inside a method self is: Developer"
Этот фрагмент кода интерпретатор Ruby оценивает в контексте экземпляра, который в данном случае является объектом Developer
. И когда вы определяете метод для объекта, вы создаете либо метод класса, либо метод singleton. В данном случае это метод класса — точнее, методы класса — это одноэлементные методы, но одноэлементные методы класса, а остальные — одноэлементные методы объекта.
С другой стороны, class_eval
оценивает код в контексте класса, а не экземпляра. Это практически заново открывает класс. Вот как можно использовать class_eval
для создания метода экземпляра:
Developer.class_eval do p "class_eval - self is: " + self.to_s def frontend p "inside a method self is: " + self.to_s end end # "class_eval - self is: Developer" p developer = Developer.new # #<Developer:0x2c5d640> developer.frontend # "inside a method self is: #<Developer:0x2c5d640>"
Подводя итог, когда вы вызываете метод class_eval
, вы меняете self
, чтобы ссылаться на исходный класс, а когда вы вызываете instance_eval
, self
изменяется, чтобы ссылаться на метакласс исходного класса.
Определение отсутствующих методов на лету
Еще одна часть головоломки метапрограммирования — method_missing
. Когда вы вызываете метод объекта, Ruby сначала входит в класс и просматривает его методы экземпляра. Если он не находит там метод, он продолжает поиск по цепочке предков. Если Ruby по-прежнему не находит метод, он вызывает другой метод с именем method_missing
, который является методом экземпляра Kernel
, наследуемым каждым объектом. Поскольку мы уверены, что Ruby в конечном итоге вызовет этот метод для отсутствующих методов, мы можем использовать его для реализации некоторых трюков.
define_method
— это метод, определенный в классе Module
, который можно использовать для динамического создания методов. Чтобы использовать define_method
, вы вызываете его с именем нового метода и блока, в котором параметры блока становятся параметрами нового метода. В чем разница между использованием def
для создания метода и define_method
? Большой разницы нет, за исключением того, что вы можете использовать define_method
в сочетании с method_missing
для написания СУХОГО кода. Точнее, вы можете использовать define_method
вместо def
для управления областями при определении класса, но это совсем другая история. Давайте рассмотрим простой пример:
class Developer define_method :frontend do |*my_arg| my_arg.inject(1, :*) end class << self def create_backend singleton_class.send(:define_method, "backend") do "Born from the ashes!" end end end end developer = Developer.new p developer.frontend(2, 5, 10) # => 100 p Developer.backend # undefined method 'backend' for Developer:Class (NoMethodError) Developer.create_backend p Developer.backend # "Born from the ashes!"
Это показывает, как define_method
использовался для создания метода экземпляра без использования def
. Однако мы можем сделать с ними гораздо больше. Давайте посмотрим на этот фрагмент кода:
class Developer def coding_frontend p "writing frontend" end def coding_backend p "writing backend" end end developer = Developer.new developer.coding_frontend # "writing frontend" developer.coding_backend # "writing backend"
Этот код не является СУХИМ, но с помощью define_method
мы можем сделать его СУХИМ:
class Developer ["frontend", "backend"].each do |method| define_method "coding_#{method}" do p "writing " + method.to_s end end end developer = Developer.new developer.coding_frontend # "writing frontend" developer.coding_backend # "writing backend"
Это намного лучше, но все еще не идеально. Почему? Например, если мы хотим добавить новый метод coding_debug
, нам нужно поместить этот "debug"
в массив. Но с помощью method_missing
мы можем это исправить:
class Developer def method_missing method, *args, &block return super method, *args, &block unless method.to_s =~ /^coding_\w+/ self.class.send(:define_method, method) do p "writing " + method.to_s.gsub(/^coding_/, '').to_s end self.send method, *args, &block end end developer = Developer.new developer.coding_frontend developer.coding_backend developer.coding_debug
Этот фрагмент кода немного сложен, поэтому давайте разберем его. Вызов несуществующего метода вызовет method_missing
. Здесь мы хотим создать новый метод только тогда, когда имя метода начинается с "coding_"
. В противном случае мы просто вызываем super, чтобы сообщить об отсутствующем методе. И мы просто используем define_method
для создания этого нового метода. Вот и все! С помощью этого фрагмента кода мы можем создать буквально тысячи новых методов, начинающихся с "coding_"
, и именно этот факт делает наш код СУХИМ. Поскольку define_method
является приватным для Module
, нам нужно использовать send
для его вызова.
Подведение итогов
Это только вершина айсберга. Чтобы стать рубиновым джедаем, это отправная точка. После того, как вы освоите эти строительные блоки метапрограммирования и по-настоящему поймете его суть, вы можете приступить к чему-то более сложному, например, к созданию своего собственного предметно-ориентированного языка (DSL). DSL — это отдельная тема, но эти основные понятия необходимы для понимания сложных тем. Некоторые из наиболее часто используемых драгоценных камней в Rails были построены таким образом, и вы, вероятно, использовали его DSL, даже не подозревая об этом, например, RSpec и ActiveRecord.
Надеюсь, эта статья поможет вам на шаг приблизиться к пониманию метапрограммирования и, возможно, даже к созданию собственного DSL, который вы сможете использовать для более эффективного написания кода.