Metaprogramowanie Ruby jest jeszcze fajniejsze niż się wydaje

Opublikowany: 2022-03-11

Czę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.

Metaprogramowanie Rubiego: Kod pisania kodu
Ćwierkać

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:

Rubinowy model obiektowy

 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:

Metaklasa 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.