Code schreiben Code: Eine Einführung in die Theorie und Praxis der modernen Metaprogrammierung
Veröffentlicht: 2022-07-22Immer wenn ich darüber nachdenke, wie man Makros am besten erklärt, erinnere ich mich an ein Python-Programm, das ich geschrieben habe, als ich anfing zu programmieren. Ich konnte es nicht so organisieren, wie ich wollte. Ich musste eine Reihe leicht unterschiedlicher Funktionen aufrufen, und der Code wurde umständlich. Wonach ich suchte – obwohl ich es damals noch nicht wusste – war Metaprogrammierung .
Metaprogrammierung (Substantiv)
Jede Technik, mit der ein Programm Code als Daten behandeln kann.
Wir können ein Beispiel konstruieren, das die gleichen Probleme zeigt, mit denen ich bei meinem Python-Projekt konfrontiert war, indem wir uns vorstellen, dass wir das Back-End einer App für Haustierbesitzer erstellen. Mit den Tools in einer Bibliothek, pet_sdk
, schreiben wir Python, um den Tierbesitzern beim Kauf von Katzenfutter zu helfen:
Nachdem wir bestätigt haben, dass der Code funktioniert, implementieren wir dieselbe Logik für zwei weitere Arten von Haustieren (Vögel und Hunde). Wir fügen auch eine Funktion hinzu, um Tierarzttermine zu buchen:
Es wäre gut, die sich wiederholende Logik von Snippet 2 in einer Schleife zusammenzufassen, also machten wir uns daran, den Code neu zu schreiben. Da jede Funktion anders benannt ist, stellen wir schnell fest, dass wir nicht bestimmen können, welche (z. B. book_bird_appointment
, book_cat_appointment
) in unserer Schleife aufgerufen werden soll:
Stellen wir uns eine aufgeladene Version von Python vor, in der wir Programme schreiben können, die automatisch den endgültigen Code generieren, den wir wollen – eines, in dem wir unser Programm flexibel, einfach und flüssig manipulieren können, als wäre es eine Liste, Daten in einer Datei oder irgendetwas anderes andere übliche Datentypen oder Programmeingaben:
Dies ist ein Beispiel für ein Makro , das in Sprachen wie Rust, Julia oder C verfügbar ist, um nur einige zu nennen – aber nicht in Python.
Dieses Szenario ist ein großartiges Beispiel dafür, wie es nützlich sein könnte, ein Programm zu schreiben, das in der Lage ist, seinen eigenen Code zu ändern und zu manipulieren. Genau das ist der Reiz von Makros, und es ist eine von vielen Antworten auf eine größere Frage: Wie können wir ein Programm dazu bringen, seinen eigenen Code zu prüfen, ihn als Daten zu behandeln und dann auf dieser Grundlage zu handeln?
Im Großen und Ganzen fallen alle Techniken, die eine solche Selbstbeobachtung durchführen können, unter den Oberbegriff „Metaprogrammierung“. Metaprogrammierung ist ein reichhaltiges Teilgebiet des Programmiersprachendesigns und lässt sich auf ein wichtiges Konzept zurückführen: Code als Daten.
Reflexion: Zur Verteidigung von Python
Sie könnten darauf hinweisen, dass Python zwar keine Makrounterstützung bietet, aber viele andere Möglichkeiten zum Schreiben dieses Codes bietet. Hier verwenden wir zum Beispiel die Methode isinstance()
, um die Klasse zu identifizieren, von der unsere animal
-Variable eine Instanz ist, und rufen die entsprechende Funktion auf:
Wir nennen diese Art der Metaprogrammierungsreflexion und kommen später darauf zurück. Der Code von Snippet 5 ist immer noch etwas umständlich, aber für einen Programmierer einfacher zu schreiben als der von Snippet 2, in dem wir die Logik für jedes aufgelistete Tier wiederholt haben.
Herausforderung
Ändern Sie mithilfe der Methode getattr
den vorangehenden Code, um die entsprechenden Funktionen order_*_food
und book_*_appointment
dynamisch aufzurufen. Dies macht den Code wohl weniger lesbar, aber wenn Sie Python gut kennen, sollten Sie darüber nachdenken, wie Sie getattr
anstelle der Funktion isinstance
verwenden und den Code vereinfachen können.
Homoikonizität: Die Bedeutung von Lisp
Einige Programmiersprachen, wie Lisp, bringen das Konzept der Metaprogrammierung durch Homoikonizität auf eine andere Ebene.
Homoikonizität (Substantiv)
Die Eigenschaft einer Programmiersprache, bei der es keinen Unterschied zwischen Code und den Daten gibt, auf denen ein Programm operiert.
Lisp wurde 1958 entwickelt und ist die älteste homoikonische Sprache und die zweitälteste höhere Programmiersprache. Lisp, das seinen Namen von „LISt Processor“ erhielt, war eine Revolution in der Computertechnik, die die Verwendung und Programmierung von Computern tiefgreifend geprägt hat. Es ist schwer zu übertreiben, wie grundlegend und unverwechselbar Lisp die Programmierung beeinflusst hat.
Emacs ist in Lisp geschrieben, der einzigen schönen Computersprache. Neal Stephenson
Lisp entstand nur ein Jahr nach FORTRAN, in der Ära der Lochkarten und raumfüllenden Militärcomputer. Dennoch verwenden Programmierer Lisp auch heute noch, um neue, moderne Anwendungen zu schreiben. Der Hauptschöpfer von Lisp, John McCarthy, war ein Pionier auf dem Gebiet der KI. Viele Jahre lang war Lisp die Sprache der KI, wobei Forscher die Fähigkeit schätzten, ihren eigenen Code dynamisch umzuschreiben. Die heutige KI-Forschung konzentriert sich eher auf neuronale Netze und komplexe statistische Modelle als auf diese Art von logischem Generierungscode. Die Forschung zur KI mit Lisp – insbesondere die in den 60er und 70er Jahren am MIT und in Stanford durchgeführte Forschung – hat jedoch das Gebiet, wie wir es kennen, geschaffen, und sein massiver Einfluss hält an.
Das Aufkommen von Lisp machte frühe Programmierer zum ersten Mal mit den praktischen Rechenmöglichkeiten von Dingen wie Rekursion, Funktionen höherer Ordnung und verknüpften Listen vertraut. Es demonstrierte auch die Leistungsfähigkeit einer Programmiersprache, die auf den Ideen des Lambda-Kalküls aufbaut.
Diese Ideen lösten eine Explosion im Design von Programmiersprachen aus und, wie Edsger Dijkstra, einer der größten Namen in der Informatik, es ausdrückte, „ [...] halfen einer Reihe unserer begabtesten Mitmenschen, zuvor unmögliche Gedanken zu denken.“
Dieses Beispiel zeigt ein einfaches Lisp-Programm (und sein Äquivalent in der bekannteren Python-Syntax), das eine Funktion „Fakultät“ definiert, die die Fakultät ihrer Eingabe rekursiv berechnet und diese Funktion mit der Eingabe „7“ aufruft:
Lispeln | Python |
---|---|
( defun factorial ( n ) ( if ( = n 1 ) 1 ( * n ( factorial ( - n 1 ))))) ( print ( factorial 7 )) | |
Code als Daten
Obwohl es sich um eine der wirkungsvollsten und folgenreichsten Innovationen von Lisp handelt, hat es Homoikonizität im Gegensatz zu Rekursion und vielen anderen Konzepten, für die Lisp Pionierarbeit geleistet hat, nicht in die meisten heutigen Programmiersprachen geschafft.
Die folgende Tabelle vergleicht homoikonische Funktionen, die sowohl in Julia als auch in Lisp Code zurückgeben. Julia ist eine homoikonische Sprache, die in vielerlei Hinsicht den Hochsprachen ähnelt, mit denen Sie vielleicht vertraut sind (z. B. Python, Ruby).
Das Schlüsselstück der Syntax in jedem Beispiel ist sein Anführungszeichen . Julia verwendet ein :
(Doppelpunkt), um zu zitieren, während Lisp ein '
(einfaches Anführungszeichen) verwendet:
Julia | Lispeln |
---|---|
function function_that_returns_code() return :(x + 1 ) end | |
In beiden Beispielen wandelt das Anführungszeichen neben dem Hauptausdruck ( (x + 1)
oder (+ x 1)
) diesen aus Code, der direkt ausgewertet worden wäre, in einen abstrakten Ausdruck um, den wir manipulieren können. Die Funktion gibt Code zurück – keine Zeichenkette oder Daten. Wenn wir unsere Funktion aufrufen und print(function_that_returns_code())
schreiben würden, würde Julia den als x+1
gestringten Code ausgeben (und das Äquivalent gilt für Lisp). Umgekehrt würden wir ohne :
(oder '
in Lisp) einen Fehler erhalten, dass x
nicht definiert wurde.
Kehren wir zu unserem Julia-Beispiel zurück und erweitern es:
Die eval
-Funktion kann verwendet werden, um den Code auszuführen, den wir an anderer Stelle im Programm generieren. Beachten Sie, dass der ausgedruckte Wert auf der Definition der x
-Variablen basiert. Wenn wir versuchten, eval
generierten Code in einem Kontext auszuwerten, in dem x
nicht definiert war, erhielten wir einen Fehler.
Homoikonizität ist eine leistungsstarke Art der Metaprogrammierung, die in der Lage ist, neuartige und komplexe Programmierparadigmen freizuschalten, in denen sich Programme spontan anpassen und Code generieren können, um domänenspezifischen Problemen oder neu aufgetretenen Datenformaten gerecht zu werden.
Nehmen wir den Fall von WolframAlpha, wo die homoikonische Wolfram Language Code generieren kann, um sich an eine unglaubliche Bandbreite von Problemen anzupassen. Sie können WolframAlpha fragen: „Was ist das BIP von New York City geteilt durch die Bevölkerung von Andorra?“ und bemerkenswerterweise eine logische Antwort erhalten.
Es scheint unwahrscheinlich, dass irgendjemand jemals daran denken würde, diese obskure und sinnlose Berechnung in eine Datenbank aufzunehmen, aber Wolfram verwendet Metaprogrammierung und einen ontologischen Wissensgraphen, um On-the-Fly-Code zu schreiben, um diese Frage zu beantworten.
Es ist wichtig, die Flexibilität und Leistungsfähigkeit zu verstehen, die Lisp und andere homoikonische Sprachen bieten. Bevor wir weiter tauchen, lassen Sie uns einige der Metaprogrammierungsoptionen betrachten, die Ihnen zur Verfügung stehen:
Definition | Beispiele | Anmerkungen | |
---|---|---|---|
Homoikonität | Ein Sprachmerkmal, bei dem Code „erstklassige“ Daten sind. Da es keine Trennung zwischen Code und Daten gibt, können die beiden austauschbar verwendet werden. |
| Hier umfasst Lisp andere Sprachen in der Lisp-Familie, wie Scheme, Racket und Clojure. |
Makros | Eine Anweisung, Funktion oder ein Ausdruck, der Code als Eingabe akzeptiert und Code als Ausgabe zurückgibt. |
| (Siehe die nächste Anmerkung zu den Makros von C.) |
Präprozessordirektiven (oder Precompiler) | Ein System, das ein Programm als Eingabe verwendet und basierend auf im Code enthaltenen Anweisungen eine geänderte Version des Programms als Ausgabe zurückgibt. |
| Die Makros von C werden mit dem Präprozessorsystem von C implementiert, aber die beiden sind getrennte Konzepte. Der wesentliche konzeptionelle Unterschied zwischen C-Makros (in denen wir die Präprozessordirektive #define verwenden) und anderen Formen von C-Präprozessordirektiven (z. B. #if und #ifndef ) besteht darin, dass wir die Makros verwenden, um Code zu generieren, während wir andere Nicht-# #define verwenden Präprozessordirektiven, um anderen Code bedingt zu kompilieren. Die beiden sind in C und einigen anderen Sprachen eng verwandt, aber sie sind unterschiedliche Arten der Metaprogrammierung. |
Betrachtung | Die Fähigkeit eines Programms, seinen eigenen Code zu untersuchen, zu modifizieren und zu prüfen. |
| Die Reflektion kann zur Kompilierzeit oder zur Laufzeit erfolgen. |
Generika | Die Fähigkeit, Code zu schreiben, der für eine Reihe verschiedener Typen gültig ist oder der in mehreren Kontexten verwendet, aber an einem Ort gespeichert werden kann. Wir können die Kontexte, in denen der Code gültig ist, entweder explizit oder implizit definieren. | Generika im Template-Stil:
Parametrischer Polymorphismus:
| Generische Programmierung ist ein umfassenderes Thema als generische Metaprogrammierung, und die Grenze zwischen beiden ist nicht klar definiert. Nach Ansicht des Autors zählt ein parametrisches Typsystem nur dann als Metaprogrammierung, wenn es sich um eine statisch typisierte Sprache handelt. |
Schauen wir uns einige praktische Beispiele für Homoikonizität, Makros, Präprozessordirektiven, Reflektion und Generika an, die in verschiedenen Programmiersprachen geschrieben wurden:
Makros (wie das in Snippet 11) werden in einer neuen Generation von Programmiersprachen wieder populär. Um diese erfolgreich zu entwickeln, müssen wir ein zentrales Thema berücksichtigen: Hygiene.
Hygienische und unhygienische Makros
Was bedeutet es, wenn Code „hygienisch“ oder „unhygienisch“ ist? Schauen wir uns zur Verdeutlichung ein Rust-Makro an, das von den macro_rules!
Funktion. Wie der Name schon sagt, macro_rules!
generiert Code basierend auf Regeln, die wir definieren. In diesem Fall haben wir unser Makro my_macro
, und die Regel lautet „Erstelle die Codezeile let x = $n
“, wobei n
unsere Eingabe ist:
Wenn wir unser Makro erweitern (ein Makro ausführen, um seinen Aufruf durch den von ihm generierten Code zu ersetzen), würden wir Folgendes erwarten:
Anscheinend hat unser Makro die Variable x
auf 3 umdefiniert, sodass wir vernünftigerweise erwarten können, dass das Programm 3
ausgibt. Tatsächlich druckt es 5
! Überrascht? In Rust, macro_rules!
ist hygienisch in Bezug auf Identifikatoren, so dass es keine Identifikatoren außerhalb seines Geltungsbereichs „erfassen“ würde. In diesem Fall war der Bezeichner x
. Wäre es vom Makro erfasst worden, wäre es gleich 3 gewesen.
Hygiene (Substantiv)
Eine Eigenschaft, die garantiert, dass die Erweiterung eines Makros keine Bezeichner oder andere Zustände erfasst, die außerhalb des Bereichs des Makros liegen. Makros und Makrosysteme, die diese Eigenschaft nicht bieten, werden als unhygienisch bezeichnet.
Hygiene in Makros ist ein etwas umstrittenes Thema unter Entwicklern. Befürworter bestehen darauf, dass es ohne Hygiene allzu leicht ist, das Verhalten Ihres Codes versehentlich zu ändern. Stellen Sie sich ein Makro vor, das wesentlich komplexer ist als Snippet 13, das in komplexem Code mit vielen Variablen und anderen Bezeichnern verwendet wird. Was wäre, wenn dieses Makro eine der gleichen Variablen wie Ihr Code verwendet – und Sie es nicht bemerkt hätten?
Es ist nicht ungewöhnlich, dass ein Entwickler ein Makro aus einer externen Bibliothek verwendet, ohne den Quellcode gelesen zu haben. Dies ist besonders häufig in neueren Sprachen, die Makrounterstützung bieten (z. B. Rust und Julia):
Dieses unhygienische Makro in C erfasst die Kennung der website
und ändert ihren Wert. Natürlich ist die Erfassung von Kennungen nicht böswillig. Es ist lediglich eine zufällige Folge der Verwendung von Makros.
Hygienische Makros sind also gut und unhygienische Makros sind schlecht, oder? Leider ist es nicht so einfach. Es gibt starke Argumente dafür, dass hygienische Makros uns einschränken. Manchmal ist die Erfassung von Kennungen nützlich. Sehen wir uns Snippet 2 noch einmal an, wo wir pet_sdk
verwenden, um Dienste für drei Arten von Haustieren bereitzustellen. Unser ursprünglicher Code begann folgendermaßen:
Sie werden sich erinnern, dass Snippet 3 ein Versuch war, die sich wiederholende Logik von Snippet 2 zu einer allumfassenden Schleife zusammenzufassen. Aber was ist, wenn unser Code von den Bezeichnern cats
und dogs
abhängt und wir so etwas wie das Folgende schreiben wollten:
Snippet 16 ist natürlich etwas einfach, aber stellen Sie sich einen Fall vor, in dem wir möchten, dass ein Makro 100 % eines bestimmten Codeabschnitts schreibt. Hygienische Makros könnten in einem solchen Fall einschränkend sein.
Während die Makro-Debatte hygienisch versus unhygienisch komplex sein kann, ist die gute Nachricht, dass es keine ist, in der Sie eine Haltung einnehmen müssen. Die Sprache, die Sie verwenden, bestimmt, ob Ihre Makros hygienisch oder unhygienisch sind, also denken Sie daran, wenn Sie Makros verwenden.
Moderne Makros
Makros haben jetzt einen Moment Zeit. Lange Zeit verlagerte sich der Fokus moderner imperativer Programmiersprachen weg von Makros als Kernbestandteil ihrer Funktionalität und vermied sie zugunsten anderer Arten der Metaprogrammierung.
Die Sprachen, die neuen Programmierern in den Schulen beigebracht wurden (z. B. Python und Java), sagten ihnen, dass alles, was sie brauchten, Reflexion und Generika seien.
Im Laufe der Zeit, als diese modernen Sprachen populär wurden, wurden Makros mit der einschüchternden C- und C++-Präprozessorsyntax in Verbindung gebracht – falls Programmierer sich ihrer überhaupt bewusst waren.
Mit dem Aufkommen von Rust und Julia hat sich der Trend jedoch wieder zu Makros verlagert. Rust und Julia sind zwei moderne, zugängliche und weit verbreitete Sprachen, die das Konzept von Makros mit einigen neuen und innovativen Ideen neu definiert und populär gemacht haben. Dies ist besonders spannend in Julia, die bereit zu sein scheint, Python und R als einfach zu verwendende, vielseitige Sprache mit „Batterien inklusive“ zu ersetzen.
Als wir pet_sdk
zum ersten Mal durch unsere „TurboPython“-Brille betrachteten, wollten wir eigentlich so etwas wie Julia. Lassen Sie uns Snippet 2 in Julia umschreiben, indem wir seine Homoikonizität und einige der anderen Metaprogrammierungswerkzeuge verwenden, die es bietet:
Lassen Sie uns Snippet 17 aufschlüsseln:
- Wir iterieren durch drei Tupel. Die erste davon ist
("cat", :clean_litterbox)
, also wird die Variablepet
"cat"
zugewiesen und die Variablecare_fn
wird dem Symbol in:clean_litterbox
. - Wir verwenden die Funktion
Meta.parse
, um einen String in einenExpression
umzuwandeln, damit wir ihn als Code auswerten können. In diesem Fall möchten wir die Leistung der Zeichenfolgeninterpolation nutzen, bei der wir eine Zeichenfolge in eine andere einfügen können, um zu definieren, welche Funktion aufgerufen werden soll. - Wir verwenden die
eval
-Funktion, um den Code auszuführen, den wir generieren.@eval begin… end
ist eine andere Art,eval(...)
zu schreiben, um das erneute Eingeben von Code zu vermeiden. Innerhalb des@eval
Blocks befindet sich Code, den wir dynamisch generieren und ausführen.
Julias Metaprogrammierungssystem gibt uns wirklich die Freiheit, auszudrücken, was wir wollen, wie wir es wollen. Wir hätten mehrere andere Ansätze verwenden können, einschließlich Reflektion (wie Python in Snippet 5). Wir hätten auch eine Makrofunktion schreiben können, die explizit den Code für ein bestimmtes Tier generiert, oder wir hätten den gesamten Code als String generieren und Meta.parse
oder eine beliebige Kombination dieser Methoden verwenden können.
Jenseits von Julia: Andere moderne Metaprogrammierungssysteme
Julia ist vielleicht eines der interessantesten und überzeugendsten Beispiele für ein modernes Makrosystem, aber keineswegs das einzige. Auch Rust war maßgeblich daran beteiligt, Makros wieder vor die Programmierer zu bringen.
In Rust spielen Makros viel zentraler als in Julia, obwohl wir das hier nicht vollständig untersuchen werden. Aus einer Reihe von Gründen können Sie kein idiomatisches Rust schreiben, ohne Makros zu verwenden. In Julia könnten Sie sich jedoch dafür entscheiden, die Homoikonizität und das Makrosystem vollständig zu ignorieren.
Als direkte Folge dieser Zentralität hat das Rust-Ökosystem Makros wirklich angenommen. Mitglieder der Community haben einige unglaublich coole Bibliotheken, Proofs of Concept und Funktionen mit Makros erstellt, darunter Tools, die Daten serialisieren und deserialisieren, automatisch SQL generieren oder sogar im Code hinterlassene Anmerkungen in eine andere Programmiersprache konvertieren können, die alle im Code unter generiert werden Kompilierzeit.
Während Julias Metaprogrammierung ausdrucksstärker und freier sein mag, ist Rust wahrscheinlich das beste Beispiel für eine moderne Sprache, die Metaprogrammierung erhöht, da sie in der gesamten Sprache stark vertreten ist.
Ein Blick in die Zukunft
Jetzt ist eine unglaubliche Zeit, um sich für Programmiersprachen zu interessieren. Heute kann ich eine Anwendung in C++ schreiben und in einem Webbrowser ausführen oder eine Anwendung in JavaScript schreiben, die auf einem Desktop oder Telefon ausgeführt wird. Die Eintrittsbarrieren waren noch nie so niedrig, und neuen Programmierern stehen Informationen wie nie zuvor zur Verfügung.
In dieser Welt der Programmiererwahl und -freiheit haben wir zunehmend das Privileg, reichhaltige, moderne Sprachen zu verwenden, die Merkmale und Konzepte aus der Geschichte der Informatik und früheren Programmiersprachen herauspicken. Es ist aufregend zu sehen, wie Makros in dieser Welle der Entwicklung aufgegriffen und abgestaubt werden. Ich kann es kaum erwarten zu sehen, was die Entwickler einer neuen Generation tun werden, wenn Rust und Julia sie in Makros einführen. Denken Sie daran, dass „Code as Data“ mehr als nur ein Schlagwort ist. Es ist eine Kernideologie, die man im Hinterkopf behalten sollte, wenn man in einer Online-Community oder einem akademischen Umfeld über Metaprogrammierung diskutiert.
„Code as Data“ ist mehr als nur ein Schlagwort.
Die 64-jährige Geschichte der Metaprogrammierung war ein wesentlicher Bestandteil der Entwicklung der Programmierung, wie wir sie heute kennen. Während die Innovationen und die Geschichte, die wir untersucht haben, nur ein Teil der Metaprogrammierungs-Saga sind, veranschaulichen sie die robuste Kraft und Nützlichkeit der modernen Metaprogrammierung.