Metaprogramowanie Ruby jest jeszcze fajniejsze niż się wydaje
Opublikowany: 2022-03-11Często słyszysz, że metaprogramowanie jest czymś, z czego korzystają tylko ninja Ruby i że po prostu nie jest dla zwykłych śmiertelników. Ale prawda jest taka, że metaprogramowanie wcale nie jest czymś przerażającym. Ten wpis na blogu posłuży do zakwestionowania tego typu myślenia i przybliżenia metaprogramowania przeciętnemu programiście Ruby, aby mogli również czerpać z niego korzyści.
Należy zauważyć, że metaprogramowanie może wiele znaczyć i często może być bardzo niewłaściwie używane i posuwać się do skrajności, jeśli chodzi o użycie, więc postaram się podać kilka przykładów z prawdziwego świata, które każdy mógłby wykorzystać w codziennym programowaniu.
Metaprogramowanie
Metaprogramowanie to technika, dzięki której można pisać kod, który sam zapisuje kod w sposób dynamiczny w czasie wykonywania. Oznacza to, że możesz definiować metody i klasy w czasie wykonywania. Szalony, prawda? Krótko mówiąc, za pomocą metaprogramowania możesz ponownie otwierać i modyfikować klasy, przechwytywać metody, które nie istnieją i tworzyć je w locie, tworzyć kod, który jest DRY, unikając powtórzeń, i wiele więcej.
Podstawy
Zanim zagłębimy się w poważne metaprogramowanie, musimy poznać podstawy. A najlepszym sposobem na to jest podanie przykładu. Zacznijmy od jednego i zrozum metaprogramowanie Rubiego krok po kroku. Prawdopodobnie możesz się domyślić, co robi ten kod:
class Developer def self.backend "I am backend developer" end def frontend "I am frontend developer" end end
Zdefiniowaliśmy klasę za pomocą dwóch metod. Pierwsza metoda w tej klasie to metoda klasowa, a druga to metoda instancji. To podstawowe rzeczy w Rubim, ale za tym kodem dzieje się znacznie więcej, co musimy zrozumieć, zanim przejdziemy dalej. Warto zaznaczyć, że sama klasa Developer
jest tak naprawdę obiektem. W Rubim wszystko jest obiektem, łącznie z klasami. Ponieważ Developer
jest instancją, jest to instancja klasy Class
. Oto jak wygląda model obiektowy Ruby:
p Developer.class # Class p Class.superclass # Module p Module.superclass # Object p Object.superclass # BasicObject
Jedną ważną rzeczą do zrozumienia jest tutaj znaczenie self
. Metoda frontend
jest zwykłą metodą dostępną w instancjach klasy Developer
, ale dlaczego metoda backend
jest metodą klasy? Każdy fragment kodu wykonywany w Rubim jest wykonywany na określonym ja . Kiedy interpreter Ruby wykonuje dowolny kod, zawsze śledzi wartość self
dla dowolnej linii. self
zawsze odnosi się do jakiegoś obiektu, ale ten obiekt może się zmienić na podstawie wykonanego kodu. Na przykład w definicji klasy self
odnosi się do samej klasy, która jest instancją klasy Class
.
class Developer p self end # Developer
Wewnątrz metod instancji self
odnosi się do instancji klasy.
class Developer def frontend self end end p Developer.new.frontend # #<Developer:0x2c8a148>
Wewnątrz metod klas self
odnosi się do samej klasy w sposób (który zostanie omówiony bardziej szczegółowo w dalszej części artykułu):
class Developer def self.backend self end end p Developer.backend # Developer
To jest w porządku, ale czym w końcu jest metoda klasowa? Zanim odpowiemy na to pytanie, musimy wspomnieć o istnieniu czegoś, co nazywa się metaklasą, znaną również jako klasa singletona i klasa własna. frontend
na metodę klasy, którą zdefiniowaliśmy wcześniej, to nic innego jak metoda instancji zdefiniowana w metaklasie dla obiektu Developer
! Metaklasa jest zasadniczo klasą, którą Ruby tworzy i wstawia do hierarchii dziedziczenia, aby przechowywać metody klas, w ten sposób nie zakłócając wystąpień utworzonych z tej klasy.
Metaklasy
Każdy obiekt w Ruby ma swoją własną metaklasę. Jest w jakiś sposób niewidoczny dla programisty, ale jest tam i można z niego bardzo łatwo korzystać. Ponieważ nasz Developer
klas jest zasadniczo obiektem, ma swoją własną metaklasę. Jako przykład stwórzmy obiekt klasy String
i manipulujmy jej metaklasą:
example = "I'm a string object" def example.something self.upcase end p example.something # I'M A STRING OBJECT
To, co tutaj zrobiliśmy, something
dodanie metody singletonowej do obiektu. Różnica między metodami klas a metodami singleton polega na tym, że metody klasy są dostępne dla wszystkich instancji obiektu klasy, podczas gdy metody singleton są dostępne tylko dla tej pojedynczej instancji. Metody klasowe są szeroko stosowane, podczas gdy metody singletonowe nie, ale oba typy metod są dodawane do metaklasy tego obiektu.
Poprzedni przykład można by przepisać w ten sposób:
example = "I'm a string object" class << example def something self.upcase end end
Składnia jest inna, ale skutecznie robi to samo. Wróćmy teraz do poprzedniego przykładu, w którym stworzyliśmy klasę Developer
i zbadajmy kilka innych składni w celu zdefiniowania metody klasy:
class Developer def self.backend "I am backend developer" end end
To podstawowa definicja, z której korzysta prawie każdy.
def Developer.backend "I am backend developer" end
To samo, definiujemy metodę klasy backend
dla Developer
. Nie używaliśmy self
ale zdefiniowanie takiej metody skutecznie czyni ją metodą klasy.
class Developer class << self def backend "I am backend developer" end end end
Ponownie definiujemy metodę klasy, ale używając składni podobnej do tej, której używaliśmy do definiowania metody singleton dla obiektu String
. Możesz zauważyć, że użyliśmy tutaj self
, które odnosi się do samego obiektu Developer
. Najpierw otworzyliśmy klasę Developer
, czyniąc siebie równym klasie Developer
. Następnie wykonujemy class << self
, czyniąc self równym metaklasie Developer
. Następnie definiujemy backend
metody w metaklasie Developer
.
class << Developer def backend "I am backend developer" end end
Definiując taki blok, ustawiamy się na self
Developer
na czas trwania bloku. W rezultacie metoda backend
jest dodawana do metaklasy Developer
, a nie do samej klasy.
Zobaczmy, jak ta metaklasa zachowuje się w drzewie dziedziczenia:
Jak widzieliście w poprzednich przykładach, nie ma prawdziwego dowodu, że metaklasa w ogóle istnieje. Ale możemy użyć małego hacka, który może pokazać nam istnienie tej niewidzialnej klasy:
class Object def metaclass_example class << self self end end end
Jeśli zdefiniujemy metodę instancji w klasie Object
(tak, w każdej chwili możemy ponownie otworzyć dowolną klasę, to kolejne piękno metaprogramowania), będziemy mieli self
odwołujące się do znajdującego się w nim Object
. Następnie możemy użyć składni class << self
, aby zmienić bieżące self tak, aby wskazywało na metaklasę bieżącego obiektu. Ponieważ bieżący obiekt jest samą klasą Object
, będzie to metaklasa instancji. Metoda zwraca self
, które w tym momencie jest samą metaklasą. Więc wywołując tę metodę instancji na dowolnym obiekcie, możemy uzyskać metaklasę tego obiektu. Zdefiniujmy ponownie naszą klasę Developer
i zacznijmy trochę eksplorować:

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>>"
A na crescendo zobaczmy dowód, że frontend
jest metodą instancji klasy, a backend
jest metodą instancji metaklasy:
p developer.class.instance_methods false # [:frontend] p developer.class.metaclass_example.instance_methods false # [:backend]
Chociaż, aby uzyskać metaklasę, nie musisz ponownie otwierać Object
i dodawać tego hacka. Możesz użyć singleton_class
, który zapewnia Ruby. Jest to to samo, co dodaliśmy metaclass_example
, ale dzięki temu hackowi możesz zobaczyć, jak Ruby działa pod maską:
p developer.class.singleton_class.instance_methods false # [:backend]
Definiowanie metod za pomocą „class_eval” i „instance_eval”
Jest jeszcze jeden sposób na utworzenie metody klasy, a jest nim użycie 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"
Ten fragment kodu interpreter Ruby ocenia w kontekście instancji, która w tym przypadku jest obiektem Developer
. A kiedy definiujesz metodę na obiekcie, tworzysz metodę klasy lub metodę singleton. W tym przypadku jest to metoda klasowa - a dokładniej metody klasowe to metody singletonowe, ale metody singletonowe klasy, podczas gdy pozostałe są metodami singletonowymi obiektu.
Z drugiej strony class_eval
ocenia kod w kontekście klasy, a nie instancji. To praktycznie ponownie otwiera klasę. Oto jak class_eval
może być użyty do stworzenia metody instancji:
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>"
Podsumowując, kiedy wywołujesz metodę class_eval
, zmieniasz self
, aby odwoływać się do oryginalnej klasy, a kiedy wywołujesz instance_eval
, self
zmienia się, aby odnosić się do metaklasy oryginalnej klasy.
Definiowanie brakujących metod w locie
Jeszcze jeden element układanki metaprogramowania to method_missing
. Kiedy wywołujesz metodę na obiekcie, Ruby najpierw przechodzi do klasy i przegląda jej metody instancji. Jeśli nie znajdzie tam metody, kontynuuje wyszukiwanie w łańcuchu przodków. Jeśli Ruby nadal nie znajduje metody, wywołuje inną metodę o nazwie method_missing
, która jest metodą instancji Kernel
, którą dziedziczy każdy obiekt. Ponieważ jesteśmy pewni, że Ruby w końcu wywoła tę metodę w przypadku brakujących metod, możemy to wykorzystać do zaimplementowania kilku sztuczek.
define_method
to metoda zdefiniowana w klasie Module
, której możesz użyć do dynamicznego tworzenia metod. Aby użyć define_method
, wywołaj ją z nazwą nowej metody i bloku, w którym parametry bloku stają się parametrami nowej metody. Jaka jest różnica między używaniem def
do tworzenia metody a define_method
? Nie ma dużej różnicy, z wyjątkiem tego, że możesz użyć define_method
w połączeniu z method_missing
do napisania kodu DRY. Aby być dokładnym, możesz użyć define_method
zamiast def
, aby manipulować zasięgami podczas definiowania klasy, ale to już zupełnie inna historia. Spójrzmy na prosty przykład:
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!"
To pokazuje, jak użyto metody define_method
do utworzenia metody instancji bez użycia def
. Możemy jednak z nimi zrobić znacznie więcej. Rzućmy okiem na ten fragment kodu:
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"
Ten kod nie jest DRY, ale za pomocą metody define_method
możemy go wysuszyć:
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"
Tak jest znacznie lepiej, ale wciąż nie idealnie. Czemu? Jeśli chcemy na przykład dodać nową metodę coding_debug
, musimy umieścić ten "debug"
w tablicy. Ale używając method_missing
możemy to naprawić:
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
Ten fragment kodu jest trochę skomplikowany, więc podzielmy go. Wywołanie metody, która nie istnieje, uruchomi method_missing
. Tutaj chcemy utworzyć nową metodę tylko wtedy, gdy nazwa metody zaczyna się od "coding_"
. W przeciwnym razie po prostu wywołujemy super, aby wykonać pracę polegającą na zgłoszeniu brakującej metody. A my po prostu używamy metody define_method
do stworzenia tej nowej metody. Otóż to! Dzięki temu fragmentowi kodu możemy stworzyć dosłownie tysiące nowych metod, zaczynając od "coding_"
i to właśnie sprawia, że nasz kod jest DRY. Ponieważ define_method
jest prywatna dla Module
, musimy użyć send
do jej wywołania.
Zawijanie
To tylko wierzchołek góry lodowej. Aby zostać Rubinowym Jedi, to jest punkt wyjścia. Po opanowaniu tych elementów składowych metaprogramowania i prawdziwym zrozumieniu jego istoty, możesz przejść do czegoś bardziej złożonego, na przykład stworzyć własny język specyficzny dla domeny (DSL). DSL jest tematem samym w sobie, ale te podstawowe pojęcia są warunkiem wstępnym zrozumienia zaawansowanych tematów. Niektóre z najczęściej używanych klejnotów w Railsach zostały zbudowane w ten sposób i prawdopodobnie używałeś ich DSL nawet o tym nie wiedząc, takich jak RSpec i ActiveRecord.
Mam nadzieję, że ten artykuł przybliży Cię o krok do zrozumienia metaprogramowania, a może nawet do zbudowania własnego łącza DSL, które można wykorzystać do wydajniejszego kodowania.