Programmiersprache Rust: Makros – Einführung in ein unverzichtbares Werkzeug

In Rust sind Makros ein mächtiges Werkzeug, das nicht mit den einfachen Textersetzungen in C/C++-Makros zu vergleichen ist. Ein Plädoyer für Rust-Makros.

In Pocket speichern vorlesen Druckansicht 45 Kommentare lesen
Lesezeit: 14 Min.
Von
  • Alvin Ramskogler
  • Rainer Stropek
Inhaltsverzeichnis

Wer erste Schritte in der Programmiersprache Rust macht und dabei das klassische "Hello World"-Programm generiert, sieht als erste und einzige Zeile der main-Funktion println!("Hello, world!"). Auffallend ist das Ausrufezeichen nach dem println-Befehl. Es kennzeichnet in Rust ein Makro, um genau zu sein: ein Function-like Declarative Macro. Es gibt noch weitere Formen von Makros in Rust. Dieser Beitrag konzentriert sich aber auf die funktionsähnlichen, deklarativen Makros, die man durch das Ausrufezeichen nach dem Makronamen erkennt.

Young Professionals schreiben für Young Professionals

Dieser Beitrag ist Teil einer Artikelserie, zu der die Heise-Redaktion junge Entwickler:innen einlädt – um über aktuelle Trends, Entwicklungen und persönliche Erfahrungen zu informieren. Bist du selbst ein "Young Professional" und willst einen (ersten) Artikel schreiben? Schicke deinen Vorschlag gern an die Redaktion: developer@heise.de. Wir stehen dir beim Schreiben zur Seite.

Ferris Talks – die Kolumne für Rustaceans

Die Tatsache, dass das "Hello World"-Programm in Rust bereits einen Makroaufruf enthält, ist charakteristisch für Rust. Makros sind in dieser Programmiersprache allgegenwärtig. Man findet sie in der Rust-Standardbibliothek genauso wie in praktisch allen Anwendungsframeworks. Makros nehmen Entwickler und Entwicklerinnen durch das Generieren von Code eine Menge Tipparbeit ab. Gut eingesetzt verbessern sie die Entwicklungsproduktivität und führen zu leichter lesbarem und wartbarem Code.

Entwicklerinnen und Entwickler, die Programmiererfahrung in C oder C++ haben, sind oft nicht erfreut, wenn sie hören, dass Rust intensiv auf Makros setzt. Der Grund dafür ist, dass Makros in C zwar in gewissen Fällen praktisch sein können, jedoch fehleranfällig sind. Bedenken hinsichtlich Makros sind bei Rust jedoch unbegründet. Um das zu verdeutlichen, folgt ein kurzer Blick in die Vergangenheit der Makros in C.Makros sind in C Präprozessordirektiven. Bevor der Compiler den Code übersetzt, werden Makros mit einfacher Suchen-Ersetzen-Logik expandiert, wie Listing 1 zeigt:

#define PI 3.14159265358979323846
#define CIRCLE_AREA(r) PI * r * r

int main(void) {
    int radius = 5;
    int area = CIRCLE_AREA(radius);
    printf("Radius: %d\nArea: %d\n", radius, area);
    return 0;
}

Listing 1: Erste, einfache C-Makros

Im Codebeispiel in Listing 1 wird als Erstes die Konstante PI als Makro definiert (gemeint ist hier die Zahl π). In der zweiten Zeile folgt ein Makro, das bereits etwas komplexer ist. Es verlangt einen Parameter, um mithilfe der zuvor definierten Konstante PI die Fläche eines Kreises zu berechnen. Die main-Methode enthält den Aufruf des Makros. Der C-Präprozessor wird die Makros vor dem Kompilieren durch simple Textersetzung auflösen.

Auf den ersten Blick ist kein Problem im obigen Codebeispiel ersichtlich. Was passiert aber, wenn man den Aufruf des Makros CIRCLE_AREA verändert auf double area = CIRCLE_AREA(radius + 2)? Da der C-Compiler die Makros durch Textersetzung auflöst, wird daraus double area = PI * radius + 2 * radius + 2. Jetzt wird klar, dass dadurch die Formel für die Berechnung der Kreisfläche nicht mehr richtig ist.

C-Entwickler und -Entwicklerinnen lösen das Problem, indem sie Klammern setzen. Im vorliegenden Beispiel ließe sich das Makro ändern zu: #define CIRCLE_AREA(r) PI * (r) * (r). Die Klammern werden bei der Auflösung des Makros berücksichtigt, das Ergebnis nach dem Ausführen des Präprozessors ist double area = PI * (radius + 2) * (radius + 2) – und das ist richtig. Listing 2 enthält ein weiteres Beispiel zum Veranschaulichen des Problems.

#define SUM(a, b) (a) + (b)

int main(void) {
    printf("%d\n", SUM(1, 2) * 2); // Result is 5 instead of 6
    return 0;
}

Listing 2: C-Makro führt zu fehlerhafter Berechnung

Führt man das C-Programm aus Listing 2 aus, ist man möglicherweise überrascht, dass das Ergebnis 5 lautet, obwohl man 6 erwartet hätte. Das liegt erneut an den Klammern. Die Formel SUM(1, 2) * 2 wird expandiert zu (1) + (2) * 2. Lösen lässt sich das Problem erneut durch das Hinzufügen von Klammern: #define SUM(a, b) ((a) + (b)) führt zum richtigen Ergebnis.

Spätestens hier wird klar, dass sich in C-Makros subtile Fehlerquellen verstecken können. Ein Makro ist stets für einen bestimmten Zweck bestimmt und erfüllt diese Aufgabe auch. Wenn das Makro jedoch in einem anderen Kontext von jemandem verwendet wird, der sich über den genauen Aufbau des Makros keine Gedanken gemacht hat, kann es leicht zu falschen Ergebnissen führen. Das ist der Grund, warum Makros in C nicht den besten Ruf haben.

Die gute Nachricht in Rust ist, dass Makros fundamental anders funktionieren als in C. Der Rust-Compiler führt bei Makros keine einfache Textersetzung durch. Makros arbeiten in Rust auf der Ebene des Abstract Syntax Trees (AST). Klammerfehler wie in C gehören daher der Vergangenheit an. Die Funktionsweise von Rust-Makros geht auch weit über die von Makros in C hinaus: Die Anwendungsbereiche von Makros reichen in Rust von einfachen Hilfskonstrukten, die ein wenig Tipparbeit ersparen und den Code besser strukturieren, bis hin zu domänenspezifischen Sprachkonstrukten (Domain-specific Languages, kurz DSL), die sich durch Makros nahtlos in den Rust-Code einbauen lassen.

Listing 3 zeigt ein erstes, kleines Beispiel, um das Prinzip deklarativer Makros zu veranschaulichen. In der Rust-Dokumentation werden solche Makros auch oft als "Macros by Example" bezeichnet, da man als Makro-Entwickler oder -Entwicklerin ein Beispiel angibt, wie das Makro aufzulösen ist.

macro_rules! say_hi_to {
    ($name:expr) => {
        println!("Hi, {}!", $name);
    }
}

say_hi_to!("Rust");

Listing 3: Erstes, einfaches Rust-Makro

Die Makrodefinition beginnt mit macro_rules und der Festlegung des Makronamens say\_hi\_to. Der Identifier \$name deklariert eine Metavariable, die sich im Makro verwenden lässt. In unserem Fall wird die Metavariable \$name als Argument beim Aufruf von println! einem weiteren Makro übergeben.

Das Besondere an Rust-Makros ist der Fragment Specifier expr, der nach dem Makronamen folgt. Im Beispiel legen die Autoren fest, dass Rust als Wert für die Variable \$name eine Expression akzeptieren darf. Sie legen also den Typ des Makroparameters auf Basis von Syntax-Elementen aus dem Rust-AST fest. Hier wird deutlich, dass Rust-Makros keine Textersetzung sind, sondern eine Transformationsregel auf AST-Ebene. Der Rust-Compiler kann durch diese semantische Information die Makros besser auflösen als es bei den Präprozessordirektiven der C-Makros möglich war. Klammerfehler wie in den zuvor dargestellten C-Beispielen gibt es bei Rust-Makros nicht. Entsprechend liefert Listing 4, das eine Rust-Übersetzung des zuvor gezeigten C-Makros SUM ist, das richtige Ergebnis, ohne dass dafür Klammern einzufügen wären.

macro_rules! sum {
    ($a:expr, $b:expr) => {
        $a + $b
    };
}

println!("{}", sum!(1, 2) * 2); // Result is 6
println!("{}", sum!(2 * 2, 3 * 3) * 2); // Result is 26

Listing 4: sum-Makro in Rust

Beim oben dargestellten Beispiel nimmt das sum-Makro ähnlich wie eine Funktion zwei Parameter entgegen. Die Aufrufsignatur eines Rust-Makros kann jedoch auch Literale enthalten. Dadurch lassen sich domänenspezifische Sprachkonstrukte in Rust umsetzen. Listing 5 definiert eine Variante des sum-Makros mit einer ganz besonderen Syntax.

macro_rules! sum {
    // +---------------+-- Note literals "rechne" and "plus"
    // 
    // v               v
    (rechne $a:literal plus $b:literal) => {
        $a + $b
    };
}

//                  +--------+-- Note literals here
//                  
//                  v        v
println!("{}", sum!(rechne 1 plus 2) * 2); // Result is 6

let _x = 42;
//                     +-- Does not work as eval requires literal,
//                     |   not an identifier or an expression.
//                              v
// println!("{}", sum! { rechne _x plus 2 } * 2); // Result is 6

Listing 5: Literale in Rust-Makros

Der Rust-Lexer muss den Makroaufruf erfolgreich analysieren können, der Aufruf muss aber nicht den Regeln des Rust-Parsers entsprechen. Die Syntax ergibt sich durch die Makro-Regeln und der Rust Compiler prüft sie zur Übersetzungszeit. Syntaxfehler beim Aufruf des Makros führen also nicht zu Laufzeitfehlern. Wer neugierig geworden ist und sehen möchte, wie weit man mit Domain-specific Languages mit Rust-Makros gehen kann, kann einen Blick auf Experimente wie macro-lisp werfen, wo eine Lisp-ähnliche DSL mit Hilfe von Makros umgesetzt wird.