Метапрограммирование 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, который вы сможете использовать для более эффективного написания кода.