Beliebte Suchanfragen

Cloud Native

DevOps

IT-Security

Agile Methoden

Java

|
//

Rust – Einstieg

15.2.2021 | 20 Minuten Lesezeit

Vor ein paar Wochen war es wieder einmal so weit. Nach vielen Jahren Softwareentwicklung mit den Programmiersprachen Java und Groovy und danach hauptsächlich mit JavaScript und TypeScript sowohl im Backend als auch im Frontend hatte ich beschlossen, eine neue Programmiersprache zu erlernen. Ab und zu ist es sinnvoll, über den Tellerrand des Alltäglichen zu schauen, sich neue Konzepte anzueignen und dadurch andere Perspektiven bzw. neue Einsichten zu erlangen.

Du wirst dich jetzt sicherlich fragen, warum ich mir dafür ausgerechnet Rust ausgesucht habe. Diese Programmiersprache ist im Vergleich zu Java und JavaScript eher low-level. Es gibt keinen Garbage Collector und keine Laufzeitumgebung. Der Sourcecode wird mithilfe der LLVM Compiler Infrastructure direkt in den Maschinencode des Systems kompiliert, weshalb Rust auch als Systemprogrammiersprache bezeichnet wird. Rust ist also viel näher an C/C++ als an Java oder JavaScript. Dennoch hat Rust seit fünf Jahren in Folge den Spitzenplatz als beliebteste Programmiersprache in der jährlichen Entwicklerumfrage von Stackoverflow eingenommen. Die möglichen Gründe dafür möchte ich nun in diesem Blogartikel darstellen.

Das erste Mal wurde meine Aufmerksamkeit auf Rust gelenkt, als ich mich mit Deno beschäftigt habe. Diese neue Runtime für JavaScript und TypeScript wurde komplett in Rust entwickelt und versucht, viele der konzeptionellen Einschränkungen und Mängel von Node.js zu beheben. Als Einführung hierfür kann ich den Blogartikel meines Kollegen Felix Magnus empfehlen.

Rust ist von Mozilla

Rust wurde von Mozilla entwickelt und ist noch eine relativ junge Programmiersprache. Die erste nummerierte Pre-Alpha-Version des Rust-Compilers erschien im Januar 2012. Das erste stabile Release wurde am 15. Mai 2015 veröffentlicht. Alle sechs Wochen werden seitdem stabile Punkt-Releases ausgeliefert. Neue Features werden in Nightly Rust entwickelt und dann mit Beta-Releases getestet, die ebenfalls sechs Wochen dauern. Alle zwei bis drei Jahre wird eine neue Rust Edition produziert. Sie fasst die Features, die in den vorangegangenen Punkt-Releases ausgeliefert wurden, in einem übersichtlichen Paket zusammen, mit vollständig aktualisierter Dokumentation und Tooling. Nach der ersten Edition 2015 gab es bisher eine weitere Edition 2018. Die nächste wird 2021 erwartet.

Auch wenn letztes Jahr Mozilla wegen der langfristigen Auswirkungen der COVID-19-Pandemie 250 Mitarbeiter entlassen musste und das Rust-Team davon genauso betroffen war, ist die Zukunft dieser Programmiersprache keinesfalls gefährdet. Mittlerweile nutzt nicht nur Mozilla Rust für die Entwicklung seines Browsers Firefox. Viele bekannte Firmen implementieren heute Software mit Rust. Amazon zum Beispiel liebt Rust und hat damit etliche seiner Infrastruktur-Services implementiert (z. B. S3, Route 53, Cloud Front und andere). Google hat sein experimentelles Betriebssystem Fuchsia komplett in Rust geschrieben und auch Microsoft hat mit Rust für sichere und sicherheitskritische Softwarekomponenten experimentiert. Eine lange Liste von Firmen, die Rust in Produktion einsetzen, findest du hier .

Um die Entwicklung von Rust auch in Zukunft auf sichere Beine zu stellen, wurde im Februar 2021 die Rust Foundation gegründet. Diese Non-Profit-Organisation übernimmt das Eigentum an allen Marken und Domainnamen von Mozilla und trägt die finanzielle Verantwortung für deren Kosten. Ziel dieser Stiftung ist es, die Programmiersprache Rust und deren Ökosystem zu verwalten und weiter zu entwickeln bzw. die Maintainer des Projekts zu unterstützen. Die Gründungsmitglieder der Stiftung sind AWS, Huawei, Google, Microsoft und Mozilla. Weitere Firmen, die an der Entwicklung von Rust interessiert sind, können über die Stiftung dazu beitragen.

Rust ist ausgereift

Rust ist einfach installiert, hat eine sehr gute Dokumentation , besitzt erstklassige Werkzeuge und einen integrierten Paketmanager bzw. Build/Test-Runner namens Cargo . Die Installation bringt bereits alles mit, was man als Entwickler braucht. Danach muss man eigentlich nur noch eine IDE , wenn nicht schon vorhanden, installieren, und man kann loslegen. Zum Ausprobieren einfacher Sprachkonstrukte reicht aber auch schon der Online Playground . Die Core bzw. Standard Library von Rust ist sehr mächtig. Viele Dinge, die man für sein Projekt braucht, findet man bereits dort. Fehlt doch etwas, dann findet man es bestimmt auf crates.io . Diese zentrale Registry von Rust enthält alles, was die Community entwickelt hat. Man kann sie mit der NPM Registry in der JavaScript-Welt vergleichen.

Rust nutzt Ownership-Konzept fürs Speichermanagement

In Rust entscheidet der Entwickler, ob Daten auf dem Stack landen oder im dynamischen Speicher (Heap) gespeichert werden. Bereits zur Compile-Zeit wird bestimmt, wann Speicher nicht mehr benötigt wird und deswegen aufgeräumt werden muss. Dies ermöglicht eine effiziente Nutzung des Speichers sowie einen sehr performanten Speicherzugriff.

Man muss jetzt aber keine Angst vor einer malloc/free-Hölle wie in C haben. Über ein Ownership Konzept und den sogenannten Borrow Checker löst Rust das Problem der Speicherfreigabe auf elegante Weise. Das heißt jedoch nicht, dass Ownership und Borrow Checker trivial wären. Im Gegenteil, sie erschweren den Einstieg in Rust im Vergleich zu anderen Programmiersprachen. Hat man sie jedoch einmal richtig verstanden, muss man sich um die Speicherfreigabe ähnlich wie in Programmiersprachen mit Garbage Collector nur noch wenige Gedanken machen. Da mindestens ein weiterer Blogartikel nötig wäre, dieses zum Erlernen der Programmiersprache Rust zentrale Thema zu beschreiben, möchte ich an dieser Stelle auf die offizielle Dokumentation dafür verweisen.

Sicher und performant

Rust ist eine statisch typisierte Programmiersprache mit sehr guter Typinferenz, d. h., man muss Typen nur sehr selten explizit angeben. Meistens kann der Compiler diese selbst aus dem Kontext heraus bestimmen.

Durch sein starkes Typensystem in Kombination mit Ownership-Konzept und Borrow Checker ermöglicht Rust eine sehr hohe Speichersicherheit, die bereits zur Compile-Zeit überprüft und erzwungen wird. Wenn ein Rust-Programm kompiliert, kann man sicher sein, dass eine Variable auf ein existierendes gültiges Objekt zeigt (ganz im Gegensatz zu vielen anderen Programmiersprachen). Den Wert null bzw. undefined für eine Variable gibt es in Rust nicht. Stattdessen muss man den Typ Option verwenden. Eine Variable dieses Typs enthält entweder einen Wert vom Typ T oder None. Die Behandlung des None-Falles im Sourcecode wird vom Compiler verifiziert.

Rust kann außerdem beim Kompilieren sogenannte Data Races bzw. Race Conditions erkennen. Diese treten auf, wenn

  • zwei oder mehr Pointer zur selben Zeit auf dieselben Daten zugreifen,
  • mindestens einer der Pointer zum Schreiben auf die Daten verwendet wird und
  • kein Mechanismus verwendet wird, um den Zugriff auf die Daten zu synchronisieren.

Ein typisches Beispiel hierfür ist das Iterieren über einen Vektor und währenddessen werden Elemente zum Vektor hinzugefügt bzw. von diesem entfernt.

Weil Rust bereits beim Kompilieren sehr viel mehr Probleme als andere Programmiersprachen entdeckt, kann das gerade für den Einsteiger sehr frustrierend sein. Fehler beim Kompilieren sind aber viel besser als nur spartanisch auftretende Probleme zur Laufzeit auf den Produktivsystemen. Die Fehlermeldungen des Compilers sind immer sehr gut und helfen dem Entwickler meistens, das Problem zu lokalisieren.

Die zwei wichtigsten Ziele bei der Entwicklung von Rust waren Robustheit und maximale Performance. Alle Abstraktionen, die die Programmiersprache anbietet, sind genauso performant wie der entsprechend handgeschriebene Code (zero-cost abstractions). Außerdem ist der sichere und effiziente Umgang mit nebenläufiger Programmierung ein weiteres wichtiges Ziel von Rust. Durch das strikte Type Checking und das Ownership-Konzept kann Rust viele Concurrency-Probleme schon zur Compile-Zeit finden.

Rust ist expression-oriented

Rust ist eine ausdrucksorientierte (expression-oriented) Sprache. Das bedeutet, die meisten Sprachkonstrukte sind Ausdrücke (expressions), die zu einem Wert evaluiert werden. Zum Beispiel ist in Rust selbst ein Block mit geschweiften Klammern zum Erstellen eines neuen Scopes ein Ausdruck:

1let x = 5;
2let y = {
3    let x = 3;
4    x + 1
5};
6println!("The value of y is: {:?}", y); // y is 4 here

Anweisungen (statements) sind im Gegensatz zu Ausdrücken Befehle, die eine Aktion ausführen, aber keinen Wert zurückgeben. Davon gibt es nur zwei Arten in Rust: declaration und expression statements. Erstere sind zum Beispiel Definitionen von Funktionen oder Variablen wie let x = 5;. Da diese Anweisung keinen Wert liefert, gibt der Compiler bei let x = (let y = 5); einen Fehler zurück. Lässt man das zweite let weg, schreibt man also let x = y = 5;, ist das zwar erlaubt aber wenig sinnvoll. Der Ausdruck y = 5 ist eine Zuweisung, die in Rust keinen Wert zurückgibt!

Ein Programm ist immer eine Folge von Anweisungen und nicht Ausdrücken. Deswegen steht in Rust am Ende jeder Programmzeile ein Semikolon, wodurch Ausdrücke in Anweisungen verwandelt und voneinander getrennt werden. Man nennt diese Anweisungen dann expression statements. Damit kann man auch erklären, warum am Ende einer Funktion bzw. in der letzten Zeile im Codeblock oben das Semikolon weggelassen wird:

1fn add_one(x: i32) -> i32 {
2    x + 1
3}

Der Ausdruck x + 1 ist der Rückgabewert der Funktion bzw. des Codeblocks weiter oben. Schreibt man noch ein Semikolon hinter diesem Ausdruck, ist es eine Anweisung. Der Compiler würde dann aber einen Fehler ausgeben, da die Funktion statt eines i32-Integer nichts zurück gibt, was in Rust ein leeres Tupel () ist (auch Unit Type genannt). Mit dem return-Ausdruck wird eine Funktion frühzeitig beendet und der Rückgabewert an die aufrufende Funktion übergeben.

1fn add_one_or_two(x: i32) -> i32 {
2    if x < 10 {
3        return x + 1;
4    } 
5    x + 2
6}

Schreibt man nach dem return-Ausdruck noch ein Semikolon, ändert sich in diesem Fall jedoch nichts. Das Expression Statement führt den return-Ausdruck aus, d. h., die Funktion wird beendet und der Rückgabewert an die aufrufende Funktion übergeben.

Weder Klassen noch Interfaces

In Rust existieren keine Klassen, die Interfaces implementieren bzw. Daten und Methoden von anderen Klassen erben. Statt class gibt es aber struct, mit dem man die Struktur eines Objektes mit seinen Daten (fields) und seinem Verhalten (methods) definiert. Structs können nicht voneinander erben. Statt Interfaces können Structs jedoch traits implementieren, die wie Interfaces für unterschiedliche Typen das gleiche Verhalten (method signatures) definieren. Ein Trait teilt also dem Rust-Compiler mit, welches Verhalten ein Type hat und mit anderen Typen teilen kann. Im Gegensatz zu Interfaces können Traits jedoch auch schon Standardimplementierungen enthalten (defaults); somit kann der gleiche Code von vielen verschiedenen Typen wiederverwendet werden. Außerdem können Traits für bereits schon existierende Typen nachträglich noch implementiert werden, zum Beispiel auch für Typen aus der Standard Library.

1trait Bytes {
2    fn to_bytes(self) -> Vec<u8>;
3}
4
5impl Bytes for String {
6    fn to_bytes(self) -> Vec<u8> {
7        self.into_bytes()
8    }
9}
10println!("{:?}", String::from("hello").to_bytes()); // [104, 101, 108, 108, 111]

Funktionale Programmierung

Das Design von Rust hat sich von vielen bestehenden Sprachen und Techniken inspirieren lassen. Ein wesentlicher Einfluss war jedoch die funktionale Programmierung, deren Prinzipien und Konzepte sich wie folgt in Rust wiederfinden:

  • Alle Variablen und Referenzen sind standardmäßig unveränderlich (immutable by default), es sei denn, sie werden explizit mit mut qualifiziert.
  • Man kann Typen durch die Anzahl der Werte, die sie haben können, klassifizieren. Zum Beispiel hat der Unit Type (void) nur einen Wert, bool hat zwei Werte, u8 hat 256 Werte usw. Die Typen, die Rust bereits mitbringt, können außerdem durch Addition und Multiplikation (in Bezug auf die Anzahl der Werte) miteinander kombiniert werden. Bei diesem Kombinieren der Datentypen gelten die Gesetze der Algebra, weswegen man sie auch Algebraische Datentypen (ADTs) nennt. In Rust nutzt man enums, um Typen zu addieren und Tupels oder Structs, um Typen zu multiplizieren. Die Rust-Standardbibliothek enthält bereits viele ADTs, wie zum Beispiel Option oder Result.
  • Ein Feature von Rust, das sehr gut mit den algebraischen Datentypen zusammen spielt, ist das sogenannte Pattern Matching. Dabei werden Muster in einer speziellen Syntax verwendet, um Daten aus der Struktur von Typen zu extrahieren bzw. destrukturieren. Im einfachsten Fall ist es dem Destructuring von JavaScript sehr ähnlich:
  • 1struct Point {
    2    x: i32,
    3    y: i32,
    4}
    5let p = Point { x: 5, y: -7 };
    6
    7let Point { x: a, y: b } = p;
    8println!("a: {:?}, b: {:?}", a, b); // a: 5, b: -7

    Die Syntax der Muster in Kombination mit dem match-Ausdruck ist jedoch viel mächtiger in Rust:

    1let point_location = match p {
    2    Point { x, y: 0 } => format!("on x axis at {:?}", x),
    3    Point { x: 0, y } => format!("on y axis at {:?}", y),
    4    Point { x: 1..=5, y } => format!("y {:?} with x between 1 and 5", y),
    5    Point { x, y } if x < 0 && y < 0 => format!("x {:?} and y {:?} are negative", x, y),
    6    _ => format!("x and y with some other value"),
    7};
    8println!("{:?}", point_location);

    Lässt man die letzte Zeile im match-Ausdruck hier weg, gibt es einen Fehler des Compilers, da match-Ausdrücke erschöpfend (exhaustive) sein müssen, d.h., es sind immer alle Möglichkeiten für den Wert zu berücksichtigen.

  • Funktionen haben einen Type und können wie alle anderen Werte auch Variablen zugewiesen bzw. von Variablen referenziert werden. Sie sind sogenannte first-class citizens.
  • 1fn add(x: i32, y: i32) -> i32 {
    2    x + y
    3}
    4let f: fn(i32, i32) -> i32 = add;
    5println!("add: {:?}", f(1,2));
  • Funktionen können auch als Argument an andere Funktionen übergeben bzw. von anderen Funktionen zurückgeben werden (higher order functions).
  • Lambda-Ausdrücke sind anonyme Funktionen, die direkt an der Stelle angegeben werden, an der sie aufgerufen bzw. als Argument an eine andere Funktion übergeben werden. Wie in anderen Sprachen werden sie auch in Rust verwendet, um ein paar Codezeilen zu kapseln und an Methoden zu übergeben. Um zum Beispiel alle Werte in einer Zahlenliste zu verdoppeln und dann zu summieren, schreibt man einfach (1..101).map(|x| x * 2).fold(0, |x, y| x + y). Der Ausdruck 1..101 ist dabei der Zahlenraum von 1 bis 100.
  • Mithilfe von Lambda-Ausdrücke können Funktionen auch partiell angewendet werden.
  • 1let add5 = |x| add(5, x);
    2println!("add5: {:?}", add5(2));
  • Anonyme Funktionen sind nicht nur einfacher zu schreiben als benannte Funktionen. Sie haben auch Zugriff auf den Scope bzw. die Umgebung, in der sie definiert sind.
  • 1let v = 10;
    2let add = |x| x + v;
    3println!("add: {:?}", add(2)) // 12

    Die anonyme Funktion in diesem Beispiel kann auf die außerhalb von ihr definierte Variable v zugreifen. Sie ist also eine sogenannte Closure die ihre Umgebung einfängt (environment capture). Der Code mag etwas konstruiert wirken aber es gibt sehr viele Anwendungsfälle für Closures. Zum Beispiel kann ein Programm im Main Thread eine Closure definieren und dann in einem neuen Thread ausführen. Da eine Closure ihre Umgebung einfängt, kann der Child-Thread Variablen verwenden, die im Parent-Thread definiert wurden.

  • Für Wertemengen stellt Rust für alle möglichen Anwendungsfälle verschiedenste Datenstrukturen, die sogenannten Collections, zur Verfügung. Im Gegensatz zu den in Rust bereits eingebauten Array- und Tupel-Typen werden die Daten, die in den Collections der Standardbibliothek verwaltet werden, im Heap abgelegt. Das bedeutet, die Datenmenge muss zur Compile-Zeit noch nicht bekannt sein. Sie kann während der Programmausführung wachsen oder schrumpfen. Typische Vertreter solcher Collections sind Vektoren (dynamische Arrays), Queues, Lists, Maps, B-Tree Sets oder die Zeichen von Strings.
  • Mithilfe von Iteratoren können die Elemente der Collections durchlaufen und Operationen auf ihnen ausgeführt werden. Wie in anderen funktionalen Programmiersprachen sind Iteratoren in Rust lazy, d. h., sie haben keine Wirkung, bis Methoden aufgerufen werden, die den Iterator tatsächlich nutzen bzw. konsumieren.
  • 1let iterator = (1..)         // infinite range of integers
    2    .filter(|x| x % 2 != 0)  // collect odd numbers
    3    .take(5)                 // only take five numbers
    4    .map(|x| x * x);         // square each number
    5
    6println!("{:?}", iterator.collect::<Vec<usize>>());     
    7// print [1, 9, 25, 49, 81]

    Der Ausdruck (1..) erstellt eine bei 1 beginnende unendliche Folge von Integer Werten. Allerdings wird diese unendliche Folge nicht wirklich erzeugt, was auch gar nicht möglich wäre. Stattdessen wird ein Range Objekt angelegt, das einen Iterator implementiert. Wird dieser benutzt, werden die drei Operationen filter, take und map der Reihe nach ausgeführt. Da take(5) die Iteration nach dem fünften erfolgreichen Filter beendet, werden nur die ersten fünf ungeraden Zahlen mit sich selber multipliziert. Zum Zeitpunkt des Aufrufs von filter, take und map werden nur Iteratoren erzeugt und miteinander verknüpft. Erst wenn ein Iterator tatsächlich konsumiert wird, werden die vorher definierten Operationen auf die Elemente der Collection ausgeführt. Im Beispiel oben passiert das erst bei Aufruf der Methode collect die den Iterator in einen Vektor umwandelt.

Rust unterstützt Polymorphismus – auch ohne Klassen

Mit Generics können in Rust Datentypen (wie Structs, Funktionen, Methoden und Enums) parametrisiert werden. Dadurch wird Code-Duplikation reduziert, ohne dabei die Typsicherheit aufzugeben. Zur Compile-Zeit wird der generische Code umgewandelt, indem die Parameter durch die konkreten Typen, die der Compiler im Sourcecode vorfindet, ersetzt werden. Type Erasure wie in Java gibt es nicht.

Mit Traits kann definiert werden, dass der Parameter eines generischen Typs ein bestimmtes Verhalten besitzt, also bestimmte Methoden mit definierter Signatur implementieren muss. Die so verwendeten Traits sind somit Constraints bzw. Bounds für die Parameter von Generics und ermöglichen es, über verschiedene Typen zu abstrahieren. Dadurch können Objekte auch zur Laufzeit gegeneinander ausgetauscht werden, wenn sie die durch die Traits definierten Eigenschaften besitzen. Rust implementiert damit Polymorphismus (bounded parametric polymorphism).

Das folgende Codebeispiel zeigt, wie der Trait Summary für die drei Typen Point, Vec, und LinkedList implementiert wird. Der Type der Elemente von Vec und LinkedList ist ein generischer Parameter, der den Trait ToString aus der Standardbibliothek implementieren muß. Die in diesem Trait definierte Methode to_string wird in summary benutzt, um die Elemente des Vektors bzw. der Liste in einen String zu konvertieren.

1use std::collections::LinkedList;
2use std::iter::FromIterator;
3
4trait Summary {
5    fn summary(&self) -> String;
6}
7
8struct Point {
9    x: i32,
10    y: i32,
11}
12
13impl Summary for Point {
14    fn summary(&self) -> String {
15        format!("Point({}, {})", self.x, self.y)
16    }
17}
18impl<T: ToString> Summary for Vec<T> {
19    fn summary(&self) -> String {
20        format!("Vec({}): {}", self.len(), self.iter().map(|e| e.to_string()).collect::<String>())
21    }
22}
23impl<T: ToString> Summary for LinkedList<T> {
24    fn summary(&self) -> String {
25        format!("LinkedList({}): {}", self.len(), self.iter().map(|e| e.to_string()).collect::<String>())
26    }
27}
28
29let point = Point { x: 5, y: -7 };
30let vec = vec![1, 2, 3, 4, 5];
31let list = LinkedList::from_iter(["a","b","c"].iter());
32
33let summaries: Vec<&dyn Summary> = vec![&point, &vec, &list];
34summaries.iter().for_each(|e| println!("{:?}", e.summary()));

Der Vektor summaries enthält Referenzen auf Objekte, die den Trait Summary implementieren müssen (sogenannte trait objects). Durch das Schlüsselwort dyn weiß der Rust-Compiler, dass für den Aufruf der Methode summary dynamisches (Laufzeit-)Dispatching (dynamic dispatch ) verwendet werden muss weil die Objekte im Vektor von unterschiedlichen Typen sein können (Point, Vec, und LinkedList). Rust muss dafür Pointer auf virtuelle Methodentabellen halten (vtables) und dann die Methodenaufrufe basierend auf dem Laufzeittyp des Objekts durchführen. Nur dadurch wird in der letzten Zeile beim Iterieren der Vektorelemente die richtige Methode summary aufgerufen.

Metaprogrammierung

Ein weiteres, sehr mächtiges Feature von Rust sind Makros. Deren Syntax ist jedoch gerade für Anfänger ungewohnt bzw. überwältigend. Mit Makros kann man Code schreiben, der zur Compile-Zeit Rust-Code erzeugt. Aufgrund dieser Indirektion sind Makrodefinitionen im Allgemeinen schwieriger zu lesen, zu verstehen und zu pflegen als Funktionsdefinitionen. Die Generierung von Code durch Code wird oft auch als Metaprogrammierung bezeichnet.

Im Gegensatz zum C-Präprozessor sind Makros in Rust aber keine einfachen Textersetzungen, sondern Teil des normalen Kompilierprozesses. Das heißt, sie verhalten sich eher wie Funktionen, die inline in den Code eingefügt werden, bevor dieser dann zu Binärcode kompiliert wird. Der Code wird aber nicht als Text eingefügt, sondern direkt in den Abstract Syntax Tree (AST). Dadurch wird eine bessere Typsicherheit erreicht, die unerwartetes Verhalten minimiert.

In Rust gibt es zwei sehr unterschiedliche Arten von Makros:

  • Deklarative Makros nutzen ein dem Match-Ausdruck ähnliches Konstrukt, um damit sich wiederholenden Code zu generieren bzw. DSLs (domain specific languages) zu definieren.
  • Prozedurale Makros ermöglichen es, auf dem AST des Rust-Codes zu operieren, der dem Makro übergeben wird. Im Wesentlichen ist es eine Funktion von einem TokenStream zu einem anderen TokenStream, wobei der Output den Makroaufruf ersetzt. Prozedurale Makros sind viel mächtiger als deklarative Makros, aber auch komplexer.

Ähnlich wie Funktionen können Makros die Menge an Codezeilen reduzieren, die man schreiben muss. Zum Beispiel generiert das Makro vec! beim Kompilieren ungefähr folgenden Code, um einen Vektor zu erzeugen und zu initialisieren:

1// the macro invocation...
2let sample_vec = vec![1, 2, 3];
3
4// ...expands to somethig similar to (simplified for demonstration):
5{
6    let mut sample_vec = Vec::new();
7    sample_vec.push(1);
8    sample_vec.push(2);
9    sample_vec.push(3);
10    sample_vec
11}

Um den Aufruf eines Makros von einem Funktionsaufruf unterscheiden zu können, wird dem Makronamen ein ! angehangen.

Makros haben auch einige Fähigkeiten, die Funktionen nicht haben. Zum Beispiel kann man einem Makro, im Gegensatz zu Funktionen, eine variable Anzahl von Parametern übergeben. In den Codebeispielen dieses Artikels sieht man das an mehreren Stellen. Dort werden die Makros println! und format! in Abhängigkeit vom ersten Parameter (dem Formatstring) mit einer unterschiedlichen Anzahl von Argumenten aufgerufen.

Da der Code von Makros generiert wird, bevor der Compiler die Bedeutung dieses Codes interpretiert, kann ein Makro einen Trait für einen Type implementieren. Eine Funktion kann das nicht, weil sie erst zur Laufzeit aufgerufen wird und der Trait aber bereits zur Kompilierzeit existieren muss. Will man zum Beispiel ein Objekt mit dem println!-Makro in der Konsole ausgeben (z. B. zum Debugging), muss dieses Objekt den Trait Debug implementieren‘. Den dafür notwendigen Boilerplate-Code kann man sich jedoch sparen. Mit dem Debug-Makro wird der Debug Trait automatisch erzeugt, wie in diesem Beispiel zu sehen ist:

1#[derive(Debug)] // implements Debug trait for Point
2struct Point {
3    x: i32,
4    y: i32,
5}
6let p = Point { x: 1, y: 21 };
7println!("{:?}", p);

Makros sind ein großes und wichtiges Thema in Rust. Einen guten Einstieg dafür findest du hier .

Rust ist sehr vielseitig

Der Rust-Compiler erzeugt für die jeweilige Plattform ein lauffähiges Executable ohne weitere Abhängigkeiten. Eine Runtime (wie z. B. JDK oder Node.js) wird nicht benötigt. Dadurch ist ein Docker Image, in dem die Rust-Anwendung läuft, um ein Vielfaches kleiner als eine entsprechende Java- oder Node.js-Anwendung. Ein Alpine Docker Image mit einem einfachen Node.js-Webservice ist schnell mehr als 200 MB groß. Ein entsprechender Webservice, geschrieben in Rust, verpackt im selben Docker Image, ist jedoch nicht mal 20 MB groß. Damit eignet sich Rust sehr gut für Services in der Cloud bzw. in einem Kubernetes Cluster.

Man kann mit Rust auch sehr ressourcenschonende (low footprint) Anwendungen implementieren, man hat ähnlich wie in C/C++ vollen Zugriff auf die Hardware (aber nur wenn das wirklich notwendig ist) und man hat eine exzellente Plattform-Unterstützung . Damit eignet sich Rust besonders gut für embedded Software wie zum Beispiel Microcontroller-Anwendungen oder Sensoren auf IoT Devices, aber auch für Agenten, die Systeme überwachen. Da außerdem kein Garbage Collector die Anwendung kurz einfriert, um Speicher freizugeben, kann man mit Rust auch sehr gut Realtime-Anwendungen implementieren bzw. sogar OS Kernels entwickeln.

Rust-Sourcecode kann nicht nur in Maschinencode der unterstützten Plattformen kompiliert werden. Es ist auch möglich, ihn in Web­Assembly zu übersetzen, das als binäres Ladezeit-effizientes Befehlsformat mittlerweile in allen modernen Webbrowser ausgeführt werden kann. Web­Assembly verspricht, performancekritische Teile einer Webanwendung, wie zum Beispiel Animationen und Simulationen, mit nahezu nativer Leistung im Webbrowser parallel zu JavaScript auszuführen. Damit ist Rust auch für Entwickler interessant, die zum Beispiel Spiele für den Browser implementieren wollen. Auch andere Programmiersprachen, wie zum Beispiel Go oder C#, können in Web­Assembly herunterkompiliert werden. Allerdings erzeugen sie im Vergleich zu Rust größere WebAssembly-Binärdateien, da zumindest Teile ihrer Laufzeitumgebung mit in die Datei geschrieben werden müssen. Rust ist da im Vorteil, da es keine eigene Laufzeitumgebung benötigt. Ähnlich gute Ergebnisse erzielt man derzeit wahrscheinlich nur noch mit C++.

Das Buch Rust and WebAssembly erklärt alles, was du zur Kompilierung von Rust zu WebAssembly wissen musst. Voraussetzung ist aber, dass du schon einige Rust-Kenntnisse hast und mit JavaScript, HTML und CSS vertraut bist. Du musst aber kein Experte in einem dieser Bereiche sein. Im Buch implementierst du Schritt für Schritt Conway’s Game of Life als Browser App, wobei performancekritische Teile in Rust implementiert und zu WebAssembly kompiliert werden. Mir hat es viel Spaß gemacht, das Tutorial durchzuarbeiten und ich kann es nur empfehlen, es auch zu tun. Hast du jedoch gerade wenig Zeit und möchtest trotzdem einen Blick auf die Implementierung werfen, dann findest du in diesem GitHub Repo meine Lösung mit einigen weiteren kleineren Verbesserungen. In der Readme vom Repo findest du alles was du wissen musst, um die App im Browser zu starten.

Deine nächsten Schritte

Wenn ich dein Interesse an Rust geweckt habe, solltest du vielleicht als nächstes den Blogartikel von Elisabeth Schulz lesen. Dieser vergleicht Rust mit Java, beschreibt Besonderheiten der Sprache und erklärt einige Konzepte, wie die bereits schon mehrmals erwähnte Ownership. Willst du richtig tief einsteigen, dann empfehle ich The Rust Programming Language , liebevoll „the book“ genannt. Es gibt einen Überblick über die Sprache, erklärt Konzepte und Prinzipien und lässt dich auf deinem Weg zum einem tieferen Verständnis der Sprache einige Beispielprojekte bauen. Wenn es nicht dein Ding ist, mehrere hundert Seiten über eine Programmiersprache zu lesen, dann ist wahrscheinlich Rust By Example genau das Richtige für dich. Es enthält sehr viele Codebeispiele und auch ein paar Übungen.

Zum Schluss möchte ich noch auf zwei weitere Websites hinweisen, die für deinen Einstieg und der Arbeit mit Rust hilfreich sein könnten. Unter cheat.rs findest du einen sehr guten und umfangreichen Spickzettel, der dir beim Programmieren mit Rust helfen wird. Möchtest du jedoch erst mal einige Programming-Idiome deiner derzeitigen Lieblingssprache mit Rust vergleichen, dann solltest du einen Blick auf programming-idioms.org werfen. Viel Spaß beim Coding in Rust!

|

Beitrag teilen

Gefällt mir

7

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.