Kod pisania kodu: wprowadzenie do teorii i praktyki współczesnego metaprogramowania
Opublikowany: 2022-07-22Za każdym razem, gdy myślę o najlepszym sposobie wyjaśnienia makr, przypominam sobie program w Pythonie, który napisałem, gdy po raz pierwszy zacząłem programować. Nie mogłem tego zorganizować tak, jak chciałem. Musiałem wywołać kilka nieco innych funkcji, a kod stał się niewygodny. To, czego szukałem — choć wtedy o tym nie wiedziałem — to metaprogramowanie .
metaprogramowanie (rzeczownik)
Dowolna technika, dzięki której program może traktować kod jako dane.
Możemy skonstruować przykład, który demonstruje te same problemy, z którymi miałem do czynienia w moim projekcie w Pythonie, wyobrażając sobie, że budujemy zaplecze aplikacji dla właścicieli zwierząt domowych. Korzystając z narzędzi z biblioteki pet_sdk
, piszemy Pythona, aby pomóc właścicielom zwierząt domowych w zakupie karmy dla kotów:
Po potwierdzeniu, że kod działa, przechodzimy do implementacji tej samej logiki dla dwóch kolejnych rodzajów zwierząt domowych (ptaków i psów). Dodajemy również funkcję rezerwacji wizyt weterynarza:
Dobrze byłoby skondensować powtarzalną logikę Snippet 2 w pętlę, więc postanowiliśmy przepisać kod. Szybko zdajemy sobie sprawę, że ponieważ każda funkcja ma inną nazwę, nie możemy określić, którą (np. book_bird_appointment
, book_cat_appointment
) wywołać w naszej pętli:
Wyobraźmy sobie turbodoładowaną wersję Pythona, w której możemy pisać programy, które automatycznie generują ostateczny kod, jaki chcemy — taki, w którym możemy elastycznie, łatwo i płynnie manipulować naszym programem tak, jakby był listą, danymi w pliku lub jakimkolwiek innym. inny typowy typ danych lub dane wejściowe programu:
To jest przykład makra , dostępnego w językach takich jak Rust, Julia czy C, żeby wymienić tylko kilka — ale nie w Pythonie.
Ten scenariusz jest doskonałym przykładem tego, jak przydatne może być napisanie programu, który jest w stanie modyfikować i manipulować własnym kodem. To jest właśnie rysowanie makr i jest to jedna z wielu odpowiedzi na większe pytanie: jak możemy zmusić program do introspekcji własnego kodu, traktując go jako dane, a następnie działać na tej introspekcji?
Ogólnie rzecz biorąc, wszystkie techniki, które mogą dokonać takiej introspekcji, mieszczą się pod ogólnym terminem „metaprogramowanie”. Metaprogramowanie jest bogatą subdziedziną w projektowaniu języka programowania i można ją prześledzić do jednego ważnego pojęcia: kodu jako danych.
Refleksja: W obronie Pythona
Możesz zauważyć, że chociaż Python może nie zapewniać obsługi makr, oferuje wiele innych sposobów napisania tego kodu. Na przykład tutaj używamy metody isinstance()
, aby zidentyfikować klasę, której instancją jest nasza zmienna animal
i wywołać odpowiednią funkcję:
Ten rodzaj metaprogramowania nazywamy refleksją i wrócimy do tego później. Kod Snippet 5 jest wciąż trochę kłopotliwy, ale łatwiejszy do napisania dla programisty niż Snippet 2, w którym powtórzyliśmy logikę dla każdego wymienionego zwierzęcia.
Wyzwanie
Korzystając z metody getattr
, zmodyfikuj powyższy kod, aby dynamicznie wywoływać odpowiednie funkcje order_*_food
i book_*_appointment
. Prawdopodobnie sprawia to, że kod jest mniej czytelny, ale jeśli dobrze znasz Pythona, warto pomyśleć o tym, jak użyć getattr
zamiast funkcji isinstance
i uprościć kod.
Homoikoniczność: znaczenie Lisp
Niektóre języki programowania, takie jak Lisp, przenoszą koncepcję metaprogramowania na inny poziom poprzez homoikoniczność .
homoikoniczność (rzeczownik)
Właściwość języka programowania, w której nie ma rozróżnienia między kodem a danymi, na których działa program.
Lisp, stworzony w 1958 roku, jest najstarszym językiem homoikonicznym i drugim najstarszym językiem programowania wysokiego poziomu. Otrzymując swoją nazwę od „procesora LISt”, Lisp był rewolucją w informatyce, która głęboko ukształtowała sposób używania i programowania komputerów. Trudno przecenić, jak fundamentalnie i wyraźnie Lisp wpłynął na programowanie.
Emacs jest napisany w Lispie, który jest jedynym pięknym językiem komputerowym. Neal Stephenson
Lisp powstał zaledwie rok po FORTRANIE, w epoce kart dziurkowanych i wojskowych komputerów wypełniających pokój. Jednak programiści nadal używają Lispa do pisania nowych, nowoczesnych aplikacji. Główny twórca Lispa, John McCarthy, był pionierem w dziedzinie sztucznej inteligencji. Przez wiele lat Lisp był językiem sztucznej inteligencji, a badacze cenili umiejętność dynamicznego przepisywania własnego kodu. Dzisiejsze badania nad sztuczną inteligencją koncentrują się na sieciach neuronowych i złożonych modelach statystycznych, a nie na tego rodzaju kodzie generowania logiki. Jednak badania przeprowadzone nad sztuczną inteligencją przy użyciu Lisp – zwłaszcza badania przeprowadzone w latach 60. i 70. w MIT i Stanford – stworzyły tę dziedzinę, jaką znamy, a jej ogromny wpływ trwa.
Pojawienie się Lispa po raz pierwszy odsłoniło wczesnych programistów na praktyczne możliwości obliczeniowe takich rzeczy jak rekurencja, funkcje wyższego rzędu i połączone listy. Zademonstrował również moc języka programowania opartego na ideach rachunku lambda.
Te poglądy wywołały eksplozję w projektowaniu języków programowania i, jak ujął to Edsger Dijkstra, jedno z największych nazwisk w dziedzinie informatyki, „ […] pomogły wielu naszym najbardziej utalentowanym bliźnim w myśleniu wcześniej niemożliwych myśli”.
Ten przykład pokazuje prosty program w Lispie (i jego odpowiednik w bardziej znanej składni Pythona), który definiuje funkcję „silnia”, która rekurencyjnie oblicza silnię swojego wejścia i wywołuje tę funkcję z wejściem „7”:
Seplenienie | Pyton |
---|---|
( defun factorial ( n ) ( if ( = n 1 ) 1 ( * n ( factorial ( - n 1 ))))) ( print ( factorial 7 )) | |
Kod jako dane
Pomimo tego, że jest jedną z najbardziej znaczących i konsekwentnych innowacji Lispa, homoikoniczność, w przeciwieństwie do rekurencji i wielu innych koncepcji, które Lisp zapoczątkował, nie znalazła się w większości dzisiejszych języków programowania.
W poniższej tabeli porównano funkcje homoikoniczne, które zwracają kod w Julia i Lisp. Julia jest językiem homoikonicznym, który pod wieloma względami przypomina języki wysokiego poziomu, które możesz znać (np. Python, Ruby).
Kluczowym elementem składni w każdym przykładzie jest jego cytowanie . Julia używa :
(dwukropek) do cytowania, podczas gdy Lisp używa '
(pojedynczy cytat):
Julia | Seplenienie |
---|---|
function function_that_returns_code() return :(x + 1 ) end | |
W obu przykładach cytat obok głównego wyrażenia ( (x + 1)
lub (+ x 1)
) przekształca go z kodu, który byłby oceniany bezpośrednio, w wyrażenie abstrakcyjne, którym możemy manipulować. Funkcja zwraca kod — nie ciąg znaków ani dane. Gdybyśmy mieli wywołać naszą funkcję i napisać print(function_that_returns_code())
, Julia wydrukowałaby kod z ciągiem x+1
(a odpowiednik jest prawdziwy w Lispie). I odwrotnie, bez :
(lub '
w Lisp) otrzymalibyśmy błąd, że x
nie zostało zdefiniowane.
Wróćmy do naszego przykładu Julii i rozszerzmy go:
Funkcji eval
można użyć do uruchomienia kodu, który generujemy z innego miejsca w programie. Zauważ, że wartość wydrukowana jest oparta na definicji zmiennej x
. Gdybyśmy spróbowali eval
nasz wygenerowany kod w kontekście, w którym x
nie zostało zdefiniowane, otrzymalibyśmy błąd.
Homoikoniczność to potężny rodzaj metaprogramowania, zdolny do odblokowania nowatorskich i złożonych paradygmatów programowania, w których programy mogą dostosowywać się w locie, generując kod pasujący do problemów specyficznych dla domeny lub napotykanych nowych formatów danych.
Weźmy przykład WolframAlpha, gdzie homoikoniczny język Wolfram może generować kod, aby dostosować się do niewiarygodnego zakresu problemów. Możesz zapytać WolframAlpha: „Ile wynosi PKB Nowego Jorku podzielony przez populację Andory?” i, co niezwykłe, otrzymaj logiczną odpowiedź.
Wydaje się mało prawdopodobne, aby ktokolwiek pomyślał o włączeniu tych niejasnych i bezsensownych obliczeń do bazy danych, ale Wolfram używa metaprogramowania i grafu wiedzy ontologicznej, aby napisać kod w locie, aby odpowiedzieć na to pytanie.
Ważne jest, aby zrozumieć elastyczność i moc, jaką zapewniają Lisp i inne języki homoikoniczne. Zanim przejdziemy dalej, rozważmy niektóre z dostępnych opcji metaprogramowania:
Definicja | Przykłady | Uwagi | |
---|---|---|---|
Homoikoniczność | Charakterystyka języka, w której kod jest danymi „pierwszej klasy”. Ponieważ nie ma separacji między kodem a danymi, można ich używać zamiennie. |
| Tutaj Lisp zawiera inne języki z rodziny Lisp, takie jak Scheme, Racket i Clojure. |
Makra | Instrukcja, funkcja lub wyrażenie, które pobiera kod jako dane wejściowe i zwraca kod jako dane wyjściowe. |
| (Patrz następna uwaga na temat makr w C.) |
Dyrektywy preprocesora (lub Prekompilator) | System, który przyjmuje program jako dane wejściowe i na podstawie instrukcji zawartych w kodzie zwraca zmienioną wersję programu jako dane wyjściowe. |
| Makra w języku C są zaimplementowane przy użyciu systemu preprocesorów w języku C, ale są to odrębne koncepcje. Kluczową różnicą koncepcyjną między makrami C (w których używamy dyrektywy preprocesora #define ) a innymi formami dyrektyw preprocesora C (np. #if i #ifndef ) jest to, że używamy makr do generowania kodu przy użyciu innych niż #define dyrektywy preprocesora do warunkowego kompilowania innego kodu. Oba są blisko spokrewnione w C iw niektórych innych językach, ale są to różne typy metaprogramowania. |
Odbicie | Zdolność programu do badania, modyfikowania i introspekcji własnego kodu. |
| Odbicie może wystąpić w czasie kompilacji lub w czasie wykonywania. |
Generyki | Możliwość pisania kodu, który jest prawidłowy dla wielu różnych typów lub który może być używany w wielu kontekstach, ale przechowywany w jednym miejscu. Możemy zdefiniować konteksty, w których kod jest ważny, jawnie lub niejawnie. | Ogólne w stylu szablonu:
Polimorfizm parametryczny:
| Programowanie generyczne to szerszy temat niż generyczne metaprogramowanie, a granica między nimi nie jest dobrze zdefiniowana. W opinii tego autora parametryczny system typów liczy się jako metaprogramowanie tylko wtedy, gdy jest w języku statycznie typowanym. |
Przyjrzyjmy się kilku praktycznym przykładom homoikoniczności, makr, dyrektyw preprocesora, refleksji i generyków napisanych w różnych językach programowania:
Makra (takie jak to w Snippet 11) stają się ponownie popularne w nowej generacji języków programowania. Aby skutecznie je rozwijać, musimy wziąć pod uwagę kluczowy temat: higienę.
Higieniczne i niehigieniczne makra
Co to znaczy, że kod jest „higieniczny” lub „niehigieniczny”? Aby wyjaśnić, spójrzmy na makro Rust, tworzone przez macro_rules!
funkcjonować. Jak sama nazwa wskazuje, macro_rules!
generuje kod na podstawie zdefiniowanych przez nas reguł. W tym przypadku nazwaliśmy nasze makro my_macro
, a regułą jest „Create the line of code let x = $n
”, gdzie n
to nasze dane wejściowe:
Kiedy rozszerzymy nasze makro (uruchomienie makra, aby zastąpić jego wywołanie kodem, który generuje), oczekiwalibyśmy, że otrzymamy:
Pozornie nasze makro przedefiniowało zmienną x
na 3, więc możemy rozsądnie oczekiwać, że program wypisze 3
. W rzeczywistości drukuje 5
! Zaskoczony? W Rust macro_rules!
jest higieniczny w odniesieniu do identyfikatorów, więc nie „przechwytuje” identyfikatorów poza jego zakresem. W tym przypadku identyfikatorem był x
. Gdyby został przechwycony przez makro, byłby równy 3.
higiena (rzeczownik)
Właściwość gwarantująca, że rozwinięcie makra nie przechwyci identyfikatorów ani innych stanów spoza zakresu makra. Makra i systemy makr, które nie zapewniają tej właściwości, nazywane są niehigienicznymi .
Higiena w makrach jest dość kontrowersyjnym tematem wśród programistów. Zwolennicy twierdzą, że bez higieny bardzo łatwo jest przypadkowo zmodyfikować zachowanie kodu. Wyobraź sobie makro, które jest znacznie bardziej złożone niż fragment 13 używany w złożonym kodzie z wieloma zmiennymi i innymi identyfikatorami. Co by było, gdyby to makro używało jednej z tych samych zmiennych, co Twój kod — a Ty tego nie zauważyłeś?
Nie jest niczym niezwykłym, że programista używa makra z zewnętrznej biblioteki bez przeczytania kodu źródłowego. Jest to szczególnie powszechne w nowszych językach oferujących obsługę makr (np. Rust i Julia):
To niehigieniczne makro w języku C przechwytuje website
z identyfikatorem i zmienia jej wartość. Oczywiście przechwytywanie identyfikatora nie jest złośliwe. To tylko przypadkowa konsekwencja użycia makr.
Tak więc makra higieniczne są dobre, a makra niehigieniczne są złe, prawda? Niestety nie jest to takie proste. Istnieje mocny argument, że higieniczne makra nas ograniczają. Czasami przydatne jest przechwytywanie identyfikatora. Wróćmy do Snippet 2, w którym używamy pet_sdk
do świadczenia usług dla trzech rodzajów zwierząt. Nasz oryginalny kod zaczynał się tak:
Przypomnij sobie, że Snippet 3 był próbą skondensowania powtarzalnej logiki Snippet 2 w pętlę all-inclusive. Ale co jeśli nasz kod zależy od identyfikatorów cats
and dogs
, a chcieliśmy napisać coś takiego:
Snippet 16 jest oczywiście trochę prosty, ale wyobraźmy sobie przypadek, w którym chcielibyśmy, aby makro zapisało 100% danej części kodu. W takim przypadku makra higieniczne mogą być ograniczeniem.
Chociaż debata na temat higieny kontra niehigieniczna może być złożona, dobrą wiadomością jest to, że nie jest to taka, w której trzeba zająć stanowisko. Język, którego używasz, określa, czy Twoje makra będą higieniczne, czy niehigieniczne, więc pamiętaj o tym podczas korzystania z makr.
Nowoczesne makra
Makra mają teraz chwilę. Przez długi czas, współczesne imperatywne języki programowania odchodziły od makr jako podstawowej części ich funkcjonalności, rezygnując z nich na rzecz innych typów metaprogramowania.
Języki, których nowi programiści byli uczeni w szkołach (np. Python i Java), powiedziały im, że wszystko, czego potrzebują, to refleksja i generyki.
Z biegiem czasu, gdy te nowoczesne języki stały się popularne, makra zaczęły kojarzyć się z zastraszającą składnią preprocesorów C i C++ — o ile programiści w ogóle o nich wiedzieli.
Jednak wraz z pojawieniem się Rusta i Julii trend powrócił do makr. Rust i Julia to dwa nowoczesne, dostępne i szeroko używane języki, które na nowo zdefiniowały i spopularyzowały koncepcję makr dzięki nowym i innowacyjnym pomysłom. Jest to szczególnie ekscytujące w Julii, która wygląda na gotową do zastąpienia Pythona i R jako łatwego w użyciu, uniwersalnego języka „zawierającego baterie”.
Kiedy po raz pierwszy spojrzeliśmy na pet_sdk
przez nasze okulary „TurboPython”, tak naprawdę chcieliśmy coś w stylu Julii. Przepiszmy Snippet 2 w Julii, używając jego homoikoniczności i niektórych innych narzędzi metaprogramowania, które oferuje:
Przeanalizujmy fragment 17:
- Przechodzimy przez trzy krotki. Pierwszym z nich jest
("cat", :clean_litterbox)
, więc zmiennapet
jest przypisana do"cat"
, a zmiennacare_fn
jest przypisana do symbolu w cudzysłowie:clean_litterbox
. - Używamy funkcji
Meta.parse
do konwersji ciągu znaków naExpression
, dzięki czemu możemy ocenić go jako kod. W tym przypadku chcemy użyć możliwości interpolacji ciągów, gdzie możemy wstawić jeden ciąg do drugiego, aby określić, jaką funkcję należy wywołać. - Używamy funkcji
eval
do uruchomienia generowanego kodu.@eval begin… end
to kolejny sposób na napisanieeval(...)
, aby uniknąć przepisywania kodu. Wewnątrz bloku@eval
znajduje się kod, który generujemy dynamicznie i działamy.
System metaprogramowania Julii naprawdę pozwala nam wyrażać to, czego chcemy, tak jak tego chcemy. Mogliśmy użyć kilku innych podejść, w tym refleksji (jak Python w Snippet 5). Mogliśmy również napisać funkcję makra, która jawnie generuje kod dla konkretnego zwierzęcia, lub wygenerować cały kod jako ciąg znaków i użyć Meta.parse
lub dowolnej kombinacji tych metod.
Poza Julią: inne nowoczesne systemy metaprogramowania
Julia jest być może jednym z najciekawszych i najbardziej przekonujących przykładów nowoczesnego systemu makro, ale w żadnym wypadku nie jest jedynym. Rust również odegrał kluczową rolę w ponownym udostępnieniu makr programistom.
W Ruście makra są o wiele bardziej centralne niż w Julii, chociaż nie będziemy tego w pełni omawiać tutaj. Z wielu powodów nie można napisać idiomatycznego Rusta bez użycia makr. Jednak w Julii możesz całkowicie zignorować system homoikoniczności i makro.
Bezpośrednią konsekwencją tej centralności jest to, że ekosystem Rusta naprawdę objął makra. Członkowie społeczności zbudowali niewiarygodnie fajne biblioteki, dowody koncepcji i funkcje z makrami, w tym narzędzia, które mogą serializować i deserializować dane, automatycznie generować SQL, a nawet konwertować adnotacje pozostawione w kodzie na inny język programowania, wszystkie wygenerowane w kodzie pod adresem czas kompilacji.
Podczas gdy metaprogramowanie Julii może być bardziej ekspresyjne i bezpłatne, Rust jest prawdopodobnie najlepszym przykładem współczesnego języka, który podnosi poziom metaprogramowania, ponieważ jest często opisywany w całym języku.
Oko w przyszłość
Teraz jest niesamowity czas na zainteresowanie się językami programowania. Dziś potrafię napisać aplikację w C++ i uruchomić ją w przeglądarce internetowej lub napisać aplikację w JavaScript do uruchomienia na komputerze stacjonarnym lub telefonie. Bariery wejścia nigdy nie były niższe, a nowi programiści mają informacje na wyciągnięcie ręki, jak nigdy dotąd.
W tym świecie wyboru i wolności programistów coraz częściej mamy przywilej używania bogatych, nowoczesnych języków, które wybierają cechy i koncepcje z historii informatyki i wcześniejszych języków programowania. To ekscytujące widzieć makra zbierane i odkurzane na tej fali rozwoju. Nie mogę się doczekać, aby zobaczyć, co zrobią programiści nowej generacji, gdy Rust i Julia wprowadzą ich do makr. Pamiętaj, że „koduj jako dane” to coś więcej niż tylko slogan. Jest to podstawowa ideologia, o której należy pamiętać, omawiając metaprogramowanie w dowolnej społeczności internetowej lub środowisku akademickim.
„Kod jako dane” to coś więcej niż tylko hasło.
64-letnia historia metaprogramowania była integralną częścią rozwoju programowania, jakie znamy dzisiaj. Podczas gdy odkrywane przez nas innowacje i historia są zaledwie ułamkiem sagi o metaprogramowaniu, ilustrują moc i użyteczność współczesnego metaprogramowania.