Tutorial: F-Rust-freie Systemprogrammierung

Einführung in Rust: Die Programmiersprache vorgestellt

Wolfgang Ziegler

© allakuz/Shutterstock.com

Willkommen zu dieser Einführung in die Programmierung mit Rust! Das Sprichwort „Wer rastet, der rostet“ drängt sich hier auf. Denn allzu bequem sollte man es sich in unserer Branche wirklich nicht machen. Es empfiehlt sich daher, regelmäßig die eigene Komfortzone zu verlassen und Neues zu lernen, um seinen Horizont zu erweitern und nicht „einzurosten“. Legen wir also los mit unserem Tutorial – eine Beschäftigung mit Rust lohnt sich!

Rust Tutorial – eine Einführung in die Programmiersprache

In der Softwarebranche herrscht niemals Stillstand, und beinahe im Tagesrhythmus wird man mit neuen Technologien, Frameworks oder Programmiersprachen konfrontiert. Zuweilen kann das schon fast in einem Ohnmachtsgefühl resultieren, da es uns mittlerweile unmöglich geworden ist, überall und auf jedem Gebiet einigermaßen informiert, geschweige denn Spezialist zu sein. Die Herausforderung, der wir uns aktuell gegenübersehen, lautet zum einen, einen guten Überblick über Trends und Innovationen zu behalten, und zum anderen, sich von Zeit zu Zeit ein Thema herauszupicken und sich näher damit zu beschäftigen. In unserem Fall soll das die Programmiersprache Rust sein, der wir in diesem Tutorial unsere Zeit und Aufmerksamkeit schenken. Dabei gehen wir davon aus, dass bereits Erfahrungen aus ähnlichen Programmiersprachen wie C++, C#, Java oder JavaScript vorhanden sind.

Einladung zum Rust Day

Am 28. April findet im entwickler.kiosk der Rust Day statt. Sechs anerkannte Experten der populären Programmiersprache führen euch einen ganzen Tag lang in verschiedene Aspekte von Rust ein. An den Online-Sessions können Abonnenten des entwickler.kiosk gratis teilnehmen.

Aus dem Programm:

  • Why Rust? – Ryan Levick, Microsoft advocate with a passion for Rust
  • The Rust Foundation – Ashley Williams, Rust core team member and leader of the rust community team
  • Von 0 auf Rust – Arno Haase, Programmierer aus Leidenschaft und JAX Konferenz Advisory Board Member
  • Rust Package Management mit Cargo – Rainer Stropek, Autor von Fachbüchern und Artikeln, Trainer und Coach
  • Robustheit und Fehlerhandling in Rust – Florian Gilcher, Programmiersprachen-Enthusiast und erfahrener Rust-Trainer

Der Rust Day richtet sich an alle Entwickler, die einen tieferen Einblick in diese noch relativ junge Sprache erhalten möchten: von Rust-Sprachkonzepten über Tooling bis hin zu erfolgreichen Einsatzzwecken – selbstverständlich inklusive praxisnaher Code-Beispiele. Damit steht Deinem eigenen erfolgreichen Einsatz von Rust nichts mehr im Wege!

Weitere Infos zu Programm und Teilnahme: kiosk.entwickler.de/rust-day

 

Einführung in Rust: Warum eine neue Programmiersprache?

Zu den Dingen, von denen es zu wenige gibt und von denen man unbedingt mehr bräuchte, zählen Programmiersprachen zugegebenermaßen nicht unbedingt, und die Ankündigung einer neuen Vertreterin dieser Gattung löst oft nur ein entnervtes Seufzen unter uns Softwareentwicklern aus: „Ist das jetzt wirklich nötig?“, „Muss ich mir Sprache XY jetzt tatsächlich auch noch ansehen?“, „Reicht es nicht, C++ C#/Java/JavaScript … zu beherrschen?“ Diese und weitere Fragen stellt man sich anfänglich mit gutem Recht. Es existiert natürlich auch keine Patentantwort darauf, sondern bestenfalls ein pragmatischer Leitfaden. Wird die Wahl extern (also beispielsweise vom Arbeitgeber, Kunden …) getroffen, ist es relativ einfach: Hier gilt es, sich anzupassen oder neue und getrennte Wege zu gehen.

Verspürt man von sich aus den Drang nach konstanter Weiterbildung und hat Interesse an neuen Entwicklungen in unserer Branche, werden sich obige Fragen ebenfalls relativ einfach beantworten lassen. Hier liegt die größte Herausforderung wahrscheinlich darin, sich aus dem Strom an Neuerungen jene Dinge herauszupicken, von denen man am meisten profitiert. Ist man jedoch zufrieden mit seinem aktuellen Job, den täglichen Aufgaben, die mit ihm einhergehen sowie dem technischen Umfeld, in dem man arbeitet, und hat man zusätzlich kein großes Interesse an technologischen Innovationen und Trends, kann man Phänomene wie die Programmiersprache Rust vermutlich getrost ignorieren. Auch das stellt eine Antwort auf die obigen Fragen dar.

Schwierig ist nur – wie so oft – der Fall, wenn man sich nicht ganz sicher ist. Hat man vielleicht das Gefühl, technisch auf der Stelle zu treten? Plant man einen Jobwechsel, ist sich aber nicht zu hundert Prozent sicher, ob die eigenen Skills noch zeitgemäß und ausreichend sind? Oder nagt an einem vielleicht der Gedanke, dass das Erlernen einer neuen Programmiersprache bzw. eines neuen Paradigmas im bestehenden Job vielleicht doch einen Vorteil darstellen könnte? Wenn man eine oder mehrere dieser Fragen mit „ja“ beantworten kann, empfiehlt es sich aus Sicht des Autors, von Zeit zu Zeit einmal etwas Neues auszuprobieren. Ob das jetzt unbedingt die Programmiersprache Rust sein muss, sei dahingestellt und dem Leser überlassen.

Die Sprache Rust

Rust, eine von Mozilla Research [1] entwickelte Sprache, darf getrost als junge Sprache bezeichnet werden, da sie erst im Mai 2015 in Version 1.0 offiziell angekündigt wurde [2]. Das mag für den einen oder anderen beunruhigend wirken, andererseits bietet es den großen Vorteil, mit einer Programmiersprache arbeiten zu können, die keinen historischen Ballast mit sich bringt und unter zeitgemäßen technologischen Paradigmen und Anforderungen entworfen und entwickelt wurde. Folgende Aspekte zeichnen Rust auf den ersten Blick aus:

  • Systemnähe: Obwohl Rust durchaus einen hohen Abstraktionsgrad zur tatsächlichen Hardware bietet, handelt es sich um eine Systemprogrammiersprache, die ohne den Overhead eines Garbage-Collection-Mechanismus auskommt.

  • Plattformunabhängigkeit: Auch wenn man diese Eigenschaft von einer modernen Programmiersprache vermutlich ohnehin erwartet, sei sie hier dennoch erwähnt. Rust steht auf allen gängigen Betriebssystemen wie Windows, Linux, macOS und Android zur Verfügung.

  • Multiparadigmatik: Rust bedient sich verschiedener Paradigmen aus anderen Programmiersprachen mit einer leichten Tendenz zur funktionalen Programmierung. Syntaktisch erinnert es jedoch an eine klassische C-inspirierte Sprache mit Strichpunkten, geschweiften Klammern und dem typischen Kommentarstil.

  • Typsicherheit: Auch wenn ein flüchtiger Blick auf Rust Sourcecode zuweilen an JavaScript erinnern mag, handelt es sich dennoch um eine stark statisch typisierte Programmiersprache. Allerdings bedient man sich moderner Konzepte wie der Typinferenz, die es erlaubt, Typdeklarationen an solchen Stellen wegzulassen, an denen sie ohnehin eindeutig definiert sind.

  • Reduktion: Rust ist eine sehr schlanke Programmiersprache, die mit einem reduzierten Satz von Schlüsselwörtern auskommt. Auch die verwendeten Schlüsselwörter sind äußerst knapp gehalten: beispielsweise fn anstelle von function.

  • Speichersicherheit: Hierbei handelt es sich wohl um den augenscheinlichsten Unterschied zu klassischen Systemprogrammiersprachen wie C oder C++. Rust versucht mit verschiedensten Mitteln, Fehler wie falsche Speicherzugriffe, Pufferüberläufe oder Race Conditions zu verhindern. Statische Codeanalyse durch den Compiler spielt dabei eine wichtige Rolle, Konzepte wie standardmäßige Immutability und exklusiver Besitz von Speicherstellen aber auch – dazu später mehr.

Erste Schritte

Zunächst wollen wir uns aber kurz der Installation der Rust-Entwicklungswerkzeuge widmen bzw. noch davor die Sprache mit möglichst wenig Aufwand kennenlernen und ausprobieren. Dazu existiert die Website https://play.rust-lang.org, wo man Rust direkt im Browser unkompliziert und ohne jegliche Installation evaluieren kann (Abb. 1). Auch weiterführende Dinge wie Code-Linting, korrekte, d. h. Rust-Styleguide-konforme, Formatierung sowie Übersetzung in den entsprechenden Assembler-Code können hier vorgenommen werden.

Abb. 1: Rust im Webbrowser ausprobieren

Abb. 1: Rust im Webbrowser ausprobieren

Sollte unsere Neugier damit endgültig geweckt worden sein, stellt sich nun natürlich die Frage nach der Installation von Rust bzw. der geeigneten Entwicklungsumgebung oder dem geeigneten Editor. Teil eins der Frage ist einfach zu beantworten, und die aktuellste Version von Rust für die verschiedenen Betriebssysteme steht jeweils auf der Rust-Website [3] zur Verfügung (zum Verfassungszeitpunkt dieses Artikels Version 1.23.0). Auf Windows-Betriebssystemen ist zusätzlich die Installation der Visual C++ Build Tools notwendig, was mit einer Visual-Studio-Installation aber im Normalfall gegeben sein sollte. Ist das nicht der Fall, weist der Installationsprozess von Rust noch einmal explizit darauf hin (Abb. 2).

Abb. 2: Rust benötigt die Visual C++ Build Tools

Abb. 2: Rust benötigt die Visual C++ Build Tools

Die Frage nach Rust IDEs oder Editors lässt sich insofern leicht beantworten, als es für gängige Produkte (Visual Studio, Eclipse, IntelliJ, Visual Studio Code, Emacs usw.) natürlich verschiedenste Integrationen gibt. Welche davon man verwendet, sei wiederum jedem selbst überlassen. An dieser Stelle verwenden wir das Add-in Visual Rust für Visual Studio 2015; für Visual Studio 2017 steht zum aktuellen Zeitpunkt lediglich ein Nightly Build Installer zu Verfügung [4]. Besagtes Add-in ermöglicht grundlegende IDE-Features wie Syntax-Highlighting, Debugging und das Erstellen neuer Rust-Projekte. Legen wir also ein neues Projekt vom Typ „Rust Application“ in Visual Studio 2015 an (Abb. 3). Die Datei main.rs enthält den folgenden Code:

fn main() {
  println!("Hello, world!")
}

Auf der Konsole wird das vertraute „Hello, world!“ ausgegeben. Wie schon erwähnt, sind Kürze und Prägnanz des Codes hervorstechende Eigenschaften von Rust. Das Schlüsselwort fn in Kombination mit dem Funktionsnamen main markiert den Einsprungspunkt in die Applikation, die zusammen mit dem Einzeiler für die Konsolenausgabe bereits den gesamten Programmcode ergibt. Ein genauer Blick auf die println!-Anweisung lässt möglicherweise die Frage nach dem !-Zeichen am Ende dieser Anweisung aufkommen. Dieses Zeichen kennzeichnet Rust-Makros, und es handelt sich bei println! somit um keine Funktion, sondern um ein Makro. Die Verwendung von Makros an solchen Stellen ermöglicht es dem Rust-Compiler, statische Codeanalysen vorzunehmen und mögliche Formatierungsstrings typsicher aufzulösen. Wer jemals Abstürze und unerwartetes Laufzeitverhalten aufgrund inkorrekter Formatierungsstrings in C/C++-printf-Ausgaben erlebt hat, wird dieses Feature von Rust vermutlich sehr zu schätzen wissen.

Kürze und Prägnanz sind im Standardfall natürlich äußert praktisch, aber fehlt diesem Programm nicht die Möglichkeit, Argumente von der Kommandozeile entgegenzunehmen bzw. einen Rückgabewert zu setzen, um beispielsweise einen Fehlerzustand der Applikation zu signalisieren? Selbstverständlich bietet Rust auch Optionen für diese Anwendungsszenarien an, wie Listing 1 demonstriert. Zuerst lernen wir mit use ein neues Schlüsselwort kennen, das uns Zugriff auf das Modul env (Environment) [5] ermöglicht. Am ehesten lässt sich use dabei mit den Schlüsselwörtern using aus C# oder import aus Java vergleichen. Aus dem importierten Modul env verwenden wir nun zuerst die Funktion args, die uns die Kommandozeilenargumente in Form der Struktur Args zurückliefert. Über diese Struktur können wir anschließend mit dem for-Konstrukt (vergleichbar mit C# foreach oder C++ Range for) iterieren, was gleich eine weitere Besonderheit von Rust zutage bringt: Rust verwendet das Konzept sogenannter Traits, um Datentypen mit bestimmter Funktionalität auszustatten. Einer dieser Traits ist Iterator, dessen Implementierung es ermöglicht, einen Datentyp mithilfe des for-Schlüsselworts zu iterieren. Im Fall unserer Beispielanwendung sind das die Kommandozeilenargumente, von denen zumindest eines vorhanden ist (der Pfad zum ausführenden Programm) und das wir mit dem bereits kennengelernten println!-Makro ausgeben. Die geschwungenen Klammern im Formatierungsstring fungieren als Platzhalter für die Liste an Argumenten. Abschließend können wir den Rückgabewert (Fehlercode) des Programms über die exit-Funktion aus dem Modul process setzen, das wir analog zum Modul env importiert haben. Hier bestünde in beiden Fällen auch die Möglichkeit, auf die use-Anweisung zu verzichten und die jeweiligen Aufrufe voll zu qualifizieren (zum Beispiel: std::env::args()).

Schon diese kleinen Erweiterungen der Hello-World-Applikation haben den Vorhang etwas gelüftet und einen Blick hinter die Kulissen und in die Besonderheiten und Stärken der Sprache Rust ermöglicht. Bevor wir uns jedoch tiefer mit dem Funktionsumfang dieser Programmiersprache beschäftigen, drosseln wir das Tempo zunächst ein wenig und setzen uns mit grundlegenderen Themen wie Typsystem und Speicherverwaltung auseinander.

Abb. 3: Ein neues Projekt vom Typ „Rust Application“ wird erstellt

Abb. 3: Ein neues Projekt vom Typ „Rust Application“ wird erstellt

Listing 1

use std::env;
use std::process;
 
fn main() {
  println!("Hello, world!");
  for argument in env::args() {
    println!("{}", argument);
  }
  process::exit(-1);
}

Datentypen in Rust

Zunächst bietet das Typsystem von Rust keine großen Überraschungen: Datentypen teilen sich in primitive (skalare) Datentypen und zusammengesetzte Typen auf. Die primitiven Datentypen unterteilen sich in Booleans, Characters, Floats und Integers, von denen die beiden Letzten in verschiedenen Größen (Bitbreiten) bzw. im Fall von Integers zusätzlich als Signed- oder Unsigned-Varianten vorliegen. Die Notation ist dabei wie Rust auch selbst knapp und präzise gehalten, wie die Auflistung der numerischen Datentypen in Tabelle 1 zeigt. Speziell sind hier lediglich die Typen isize und usize, die, abhängig von der Betriebssystemarchitektur, 32 oder 64 Bit groß sind. Das ist spätestens dann von Vorteil, wenn man Speicheradressen numerisch abbilden möchte.

Will man aber beispielsweise eine Variable vom Typ „32 Bit Signed Integer“ anlegen, geschieht das über das Schlüsselwort let, gefolgt vom Variablennamen, einem Doppelpunkt und dem Typbezeichner, hier i32: let x : i32 = 42;

Wie in der Einleitung schon erwähnt, setzt Rust allerdings stark auf Typinferenz und macht somit die explizite Notation von Datentypen optional, solange der Typ eindeutig bestimmt ist. So bewirkt nachfolgende Zuweisung an die Variable y automatisch das Anlegen einer weiteren i32-Variable, da der Typ durch x ja bereits explizit festgelegt wurde:

let y = x; // y hat den Typ i32

Zusätzlich existiert noch ein char-Datentyp, der ein einzelnes Unicode-Zeichen repräsentiert und aufgrund dessen 4 Bytes an Speicher belegt, sowie ein bool-Datentyp, der mit einem Byte auskommt.

Listing 2

fn main() {
  let a:u32 = 42;
  let b = a;
  let c:usize = 23;
  let d = 'x';
  let e = false;
 
  println!("a: {}", std::mem::size_of_val(&a));
  println!("b: {}", std::mem::size_of_val(&b));
  println!("c: {}", std::mem::size_of_val(&c));
  println!("d: {}", std::mem::size_of_val(&d));
  println!("e: {}", std::mem::size_of_val(&e));
}

Will man sich auch zur Laufzeit vergewissern, welche Größe eine Variable tatsächlich hat, steht dafür die Funktion size_of_val zur Verfügung, deren Verwendung in Listing 2 veranschaulicht wird. Die Ausgabe dieses kurzen Codeblocks lässt sich mit dem bisherigen Wissen bereits voraussagen: Die Variable a wird explizit als Typ u32 deklariert und nimmt somit 4 Bytes an Speicher ein; ebenso wie die Variable b, die durch Zuweisung von a ebenso deren Typ u32 erhält. Die Variable c wird mit dem Typ usize explizit angelegt und nimmt somit 4 oder 8 Bytes ein – je nach Betriebssystemarchitektur. Die Variable d wird implizit als char initialisiert, beansprucht somit 4 Bytes, und die Variable e erhält durch Zuweisung an das Schlüsselwort false den 1 Byte großen Datentyp bool. Insgesamt ergibt sich damit folgende Ausgabe:

a: 4

b: 4

c: 8

d: 4

e: 1

Ein wichtiges Charakteristikum von Rust, das sich dem Einsteiger wohl nicht unmittelbar erschließt, ist die standardmäßige Immutability von Variablen, die mit dem Schlüsselwort let angelegt wurden. So würde beispielsweise folgende doppelte Zuweisung an die Variable a einen Kompilierfehler verursachen:

let a = 23;
a = 42;  // Kompilierfehler!

Erst die Verwendung des Schlüsselworts mut deklariert eine Variable als veränderbar (mutable) und erlaubt eine mehrfache Zuweisung an sie:

let mut a = 23;
a = 42;  // OK!
Bits Unsigned Integer Signed Integer Float
8 u8 i8
16 u16 i16
32 u32 i32 f32
64 u64 i64 f64
OS-Architektur usize isize

Tabelle 1: Numerische Datentypen in Rust

Geborgtes

Ein weiteres interessantes Detail bei der Verwendung der Funktion size_of_val in Listing 2 ist aber vermutlich die &-Notation bei der Übergabe der Parameter. Was hier syntaktisch an den Adressoperator von C/C++ erinnert, ist im Fall von Rust die Erstellung einer konstanten Referenz. Man spricht bei diesem Vorgang von Borgen (Borrowing), da der Rust-Compiler Buch führt, wie oft und an welche Stellen eine Referenz ausgeliehen wurde. Wiederum syntaktisch ähnlich zu C/C++ kann mit dem *-Operator dereferenzierend auf eine solche Referenz zugegriffen werden, um an den eigentlich Wert zu gelangen. Erst nachdem alle erstellten Referenzen ihre Gültigkeit wieder verloren haben (z. B. am Ende der Funktion), ist die ursprünglich deklarierte Variable wieder im Alleinbesitz der entsprechenden Speicherstelle. Dieses Konzept ist spätestens bei der Verwendung von mutable-borrow-Operationen, die mit &mut eingeleitet werden, sinnvoll. Hier gehen der Besitz und somit die Änderungsrechte an einer Speicherstelle vollständig an die neue Referenz über. Erst wenn diese ihre Gültigkeit verloren hat, kann wieder auf die ursprüngliche Variable zugriffen werden. Das betrifft nicht nur schreibende, sondern auch lesende Zugriffe. Der Code in Listing 3 demonstriert dieses Konzept unter Verwendung eines zusätzlich eingeführten Scopes, in dem die Variable mut_ref_x zunächst den alleinigen Schreibzugriff auf die Variable x erhält. Erst wenn die Referenz mut_ref_x ihre Gültigkeit verliert, kann auch auf x wieder zugegriffen werden.

Für Trivialbeispiele wie die hier aufgeführten mag dieses Konzept zunächst umständlich und mühsam wirken, jedoch stellt es eine der großen Stärken von Rust dar, sobald Code komplex und nebenläufig wird. Klassische Race-Condition-Fehler können sich schlichtweg nicht mehr ergeben, wenn eine Speicherstelle immer nur einen logischen Besitzer hat.

Listing 3

let mut x = 42;
{
  let mut_ref_x = &mut x;
  x = 23;  // Kompilierfehler!
  let y = x;  // Kompilierfehler!
  *mut_ref_x = 23;  // OK
}
x = 23; // OK
let y = x; // OK

Komplexe Datentypen

Die einfachste Möglichkeit, in Rust mehrere Wert zu einem logischen Datentyp zusammenzufassen, sind Tupel. Es handelt sich dabei um namenlose Strukturen, die mittels Klammernotation deklariert werden: let tup = (10, 20, 30);

Um auf die einzelnen Elemente eines solchen Tupels zugreifen zu können, können die Punktnotation sowie der nullbasierte Index des Elements verwendet werden. Folgende Anweisung holt das zweite Element (die Zahl 30) aus dem Tupel und weist es der neuen Variable elem zu: let elem = tup.2;

Eine weitere und oft effizientere Möglichkeit, die Elemente eines Tupels zu erhalten, ist das Konzept der Destrukturierung. Dabei lässt sich in einer einzigen Zeile Code gleich eine Reihe von Variablen anlegen, die den jeweiligen Positionen im Tupel entsprechen. Folgende Codezeile legt zum Beispiel die Variablen a, b und c an, die die Werte 10, 20 und 30 aus dem oben erstellten Tupel tup zugewiesen bekommen: let (a,b,c) = tup;

Beim Destrukturieren zusammengesetzter Datentypen besteht zusätzlich auch immer die Möglichkeit, einzelne oder mehrere Felder zu ignorieren, was mit dem Zeichen _ signalisiert wird. Im folgenden Beispiel sind wir nur am ersten und letzten Element unseres Tupels interessiert: let (a,_,c) = tup;

Selbstverständlich bietet auch Rust die Möglichkeit, eigene Datenstrukturen zu definieren, was mit dem Schlüsselwort struct geschieht (im Unterschied zu C#, C++ usw. gibt es in Rust kein class-Schlüsselwort). Im einfachsten Fall besteht eine solche Struktur nur aus einem Namen und einer Liste von Datentypen, die den einzelnen Feldern entsprechen: struct Vec2(i32,i32);

Möchte man nun eine Instanz dieser Struktur erstellen, lässt sich das ebenso mit einer Zeile Code bewerkstelligen: let v = Vec2(10, 10);

Um auf einzelne Felder dieser Struktur zuzugreifen, können, wie auch bei Tupeln, Punktnotation und nullbasierter Index des Felds verwendet werden: let x = v.0;

Diese Form der Notation wird für größere Datentypen natürlich schnell unleserlich und fehleranfällig. Glücklicherweise sieht auch Rust eine Variante zur Strukturenerstellung vor, die auf der Zuordnung von Namen zu Feldern basiert:

struct Vec2 {
  x:i32,
  y:i32
}

Analog erfolgt hier die Instanziierung ebenfalls mit geschwungenen Klammern: let v2 = Vec2 { x: 10, y: 11 };

Logik und Traits

Will man einen solchen neu erstellten Datentyp nun auch mit Logik ausstatten, bietet Rust hierfür das Schlüsselwort impl an. Man stellt sozusagen die Implementierung von Funktionalität für eine Struktur zur Verfügung. Die Deklaration der Struktur und deren Implementierung teilen sich in zwei separate Codeblöcke auf. So lassen sich auch Funktionen zu bestehenden Strukturen hinzufügen, ähnlich wie in C# mit dem Konzept von Extension Methods. Listing 4 zeigt, wie man die zuvor erstellte Struktur Vec2 um die Funktion len zur Berechnung der Länge dieses Vektors erweitern kann. Auf das Schlüsselwort impl folgt der Name der Struktur, für die man Funktionalität implementieren möchte, und wiederum darauf eine Reihe von Funktionen. Auffällig ist das Schlüsselwort self als erster Parameter einer solchen Funktion. Dieses Schlüsselwort entspricht im Wesentlichen dem bekannten this aus anderen Programmiersprachen und erlaubt den Zugriff auf die Instanz selbst. Das Weglassen dieses Parameters bewirkt die Deklaration einer statischen Funktion. Mit der Pfeilnotation, gefolgt von einem Datentyp (hier f64), wird der Rückgabetyp der Funktion festgelegt. Der eigentliche Rückgabewert ergibt sich aus dem Ausdruck in der Funktion und kennzeichnet sich lediglich dadurch, dass er nicht von einem Semikolon abgeschlossen wird. Ein return-Schlüsselwort sucht man vergeblich.

Seine vollständige Stärke kann das Konzept der getrennten Deklaration und Implementierung jedoch erst im Zusammenspiel mit Traits entfalten. Solche Traits lassen sich am ehesten mit Interfaces aus objektorientierten Sprachen bzw. mit Aspekten vergleichen, sofern man jemals mit aspektorientierter Programmierung zu tun hatte. In unserem Beispiel ließe sich ein Aspekt Vec mit der Funktion len definieren, um einen Vektor zu repräsentieren, der die Berechnung seiner Länge anbietet. Auf diese Weise ließe sich nicht nur Vec2, sondern auch eine mögliche neue dreidimensionale Vektorstruktur mit diesem Trait versehen und überall dort verwenden, wo auch bisher mit diesem Trait gearbeitet wurde (Listing 5). Das Schlüsselwort trait leitet eine Liste von Funktionen ein, die zu einem Trait zusammengefasst werden. In unserem Fall enthält der Trait Vec lediglich die Funktion len. Die Implementierung von Vec2 muss jetzt nur noch um die Klausel impl Vec for erweitert werden, um den Trait Vec mit der Struktur zu verbinden. Ab jetzt implementiert die Struktur Vec2 diesen Trait, und ihre Instanzen können an all den Stellen verwendet werden, wo der Trait erwartet wird. Im Beispiel in Listing 5 ist das die Funktion printLen. Traits werden in Rust beispielsweise verwendet, um Iteratoren zu implementieren.

Listing 4

struct Vec2 {
  x:i32,
  y:i32
}
impl Vec2 {
  fn len(&self) -> f64 {
    ((self.x * self.x + self.y * self.y) as f64).sqrt()
  }
}

Listing 5

trait Vec {
  fn len(&self) -> f64;
}
 
impl Vec for Vec2 {
  fn len(&self) -> f64 {    
    ((self.x * self.x + self.y * self.y) as f64).sqrt()
  }
}
 
fn printLen(v: &Vec) {
  println!("len: {}", v.len());
}

Pattern Matching

Beinahe schon ein Markenzeichen vieler funktionaler Programmiersprachen sind deren Pattern-Matching-Fähigkeiten. Auch Rust steht diesem Phänomen in nichts nach und bietet mit dem Schlüsselwort match Möglichkeiten, die man in traditionellen prozeduralen und objektorientierten Programmiersprachen wie C++ oder Java vergeblich suchen würde; lediglich für C# wurde in letzter Zeit stark in dieses Feature investiert und das switch-Konstrukt damit deutlich aufgewertet [6]. Die Verwendung von match erinnert zunächst stark an klassische switch/case-Konstruktionen, allerdings lassen sich damit wesentlich komplexere Szenarien abbilden. Der Code in Listing 6 veranschaulicht dies unter Zuhilfenahme der vorher definierten Struktur Vec2.

Die ersten beiden Fälle decken den Zustand ab, dass beide Komponenten des Vektors den Wert 0 bzw. 1 haben, und der Text „Nullvektor“ oder „Einheitsvektor“ wird auf der Konsole ausgegeben. Formuliert wird die Bedingung dabei genauso wie bei der Instanziierung der Struktur. Dabei müssen aber nicht unbedingt alle Felder angeführt werden, wie der dritte Fall zeigt. Hier interessieren wir uns lediglich für horizontale Vektoren, also jene Instanzen der Klasse Vec2, bei denen die y-Komponente den Wert 0 hat. Felder, deren Wert uns nicht näher interessiert, können durch die Zeichenfolge .. ignoriert werden. Ähnlich gelagert ist der vierte Fall, der vertikale Vektoren – also solche mit dem Wert 0 in ihrer x-Komponente – abdeckt. Hier ignorieren wir das Feld für die y-Komponente jedoch nicht, sondern binden es an eine damit neu eingeführte Variable y1, die wir anschließend in der Ausgabeanweisung verwenden können. Fall fünf bindet beide Felder der Struktur an lokale Variablen – diese heißen identisch zu den Feldern der Struktur x und –, schränkt aber den Fall aufgrund der folgenden if-Bedingung weiter ein. So lassen sich sehr feingranulare Bedingungen für eine match-Konstruktion formulieren. In unserem Fall müssen eben die x– und y-Komponenten des Vektors identisch sein. Abschließend steht noch der Fall _ zur Verfügung, der am ehesten dem default-Fall aus klassischen switch-Konstruktionen entspricht. Ein wesentlicher Unterschied ist hier jedoch, dass die Reihenfolge der Bedingungen relevant ist. Würde man den default-Fall _ an die erste Stelle verschieben, so würde er immer herangezogen, da er in jedem Fall zutrifft. Die Einhaltung der Reihenfolge garantiert in unserem Beispiel somit erst, dass Nullvektoren und Einheitsvektoren korrekt klassifiziert werden und nicht etwa erst im fünften Fall zutreffen. Grundsätzlich folgt daraus also, dass man bei der Formulierung seiner match-Bedingungen immer vom speziellsten zum allgemeinen Fall gehen sollte.

Listing 6

match v // v ist vom Typ Vec2
{
  Vec2 { x: 0, y: 0 } => println!("Nullvektor"),
  Vec2 { x: 1, y: 1 } => println!("Einheitsvektor"),
  Vec2 { y: 0, ..}  => println!("Horizontaler Vektor"),
  Vec2 { x: 0, y: y1}  => println!("Vertikaler Vektor (y = {})", y1),
  Vec2 { x: x, y: y } if (x == y) => println!("Vektor mit 45 Grad Steigung"),
  _ => println!("irgendein Vektor")
}

Die Programmiersprache Rust in der Praxis

Nachdem wir uns mit den Grundprinzipien von Rust vertraut gemacht haben, betrachten wir die Sprache und ihre Werkzeuge nun aus einer etwas abstrakteren Perspektive: der Architektur von Rust-Projekten. Welche Möglichkeiten der Modularisierung und Strukturierung stehen uns dabei zur Verfügung, wie ist es um die Testbarkeit und Dokumentation dieser Projekte bestellt und wie erfolgt die Integration mit bestehendem Code?

Rust-ige Entwicklungstools

Im ersten Teil dieser Einführung  haben wir uns mit der Programmiersprache Rust vertraut gemacht und ihre grundlegenden Konzepte und Ideen kennengelernt. Als Entwicklungsumgebung haben wir dabei Visual Studio 2015 mit dem Add-in Visual Rust verwendet, ohne uns über die darunterliegende Toolchain großartig Gedanken zu machen. Nun wollen wir jedoch diese oberste Abstraktionsschicht abtragen, um ein besseres Verständnis für die einzelnen Rust-Werkzeuge zu gewinnen. Durch die Entkopplung von einer konkreten Entwicklungsumgebung schaffen wir es auch gleichzeitig, Rust im Sinne seiner Erfinder (nämlich plattformübergreifend) zu verstehen und zu verwenden.

Die Toolchain selbst lässt sich am schnellsten über die offizielle Rust-Website [7] installieren, sofern sie nicht ohnehin schon seit dem vorangegangenem Artikel vorhanden ist. Als Texteditor kommt dieses Mal Visual Studio Code [8] zum Einsatz, was sich in erster Linie aus persönlichen Präferenzen ergibt. Visual Studio Code bietet sich insofern an, als es ebenfalls plattformübergreifend (Windows, Linux, macOS) zur Verfügung steht und in Form eines Add-ins recht passablen Support für Rust-Projekte bietet; andere Editoren sollten aber zu den gleichen Ergebnissen führen. Am einfachsten erhält man das Add-in rls (Rust language support), indem man eine Rust-Datei (.rs) in Visual Studio Code öffnet oder anlegt. Das entsprechende Add-in wird dann automatisch zur Installation vorgeschlagen (Abb. 4).

Abb. 1: Das Rust-Add-in rls für Visual Studio Code

Abb. 4: Das Rust-Add-in rls für Visual Studio Code

Handarbeit

Wie bereits angekündigt, wollen wir uns jedoch weniger auf die Features von Entwicklungsumgebungen, Add-ins und Texteditoren verlassen, sondern „eigenhändig“ mit der Rust Toolchain zu experimentieren beginnen. Zu diesem Zweck erstellen wir erneut eine „Hello World!“-Anwendung, indem wir den Text aus Listing 7 in die Datei main.rs kopieren. Anschließend rufen wir den Rust-Compiler rustc in der Kommandozeile auf:

> rustc main.rs

Als Ergebnis sollten wir die Datei main.exe erhalten, deren Aufruf auch die gewünschte Ausgabe liefert. So weit, so einfach:

> main.exe
Hello, World!

Auch für die folgenden kurzen Codebeispiele wird es ausreichend sein (sofern nicht explizit anders erwähnt), die Datei main.rs händisch zu kompilieren und main.exe anschließend auszuführen.

Modularisierung

Bisher fanden sämtliche unserer Gehversuche in Rust in einer einzigen Datei statt. Da das erklärte Ziel aber lautet, Rust unter etwas praxisrelevanteren Aspekten kennenzulernen, beschäftigen wir uns nun mit dem Thema Modularisierung. Das Modulkonzept von Rust war bereits im ersten Teil dieser Einführung ein Thema, dort jedoch ausschließlich unter dem Aspekt der Verwendung von Modulen. Erfreulicherweise ist aber auch die Bereitstellung eines Moduls keine große Herausforderung und beschränkt sich im einfachsten Fall auf eine zusätzliche Datei, die diese Funktionalität bereitstellt. In unserem Fall legen wir dafür eine Datei mit dem Namen util.rs an, die die Funktion say implementiert (Listing 8). Interessant und für uns ist hier lediglich das Schlüsselwort pub, das die Funktion als public markiert. Würden wir dieses Schlüsselwort weglassen, erhielte die Funktion die standardmäßige Sichtbarkeit private und könnte somit nicht außerhalb des Moduls verwendet werden. Listing 9 demonstriert, wie die exportierte Funktion say nun verwendet werden kann. Das Schlüsselwort mod importiert das Modul util und anhand des vollqualifizierten Namens util::say kann auf diese Funktion zugegriffen werden. Alternativ könnte mit der Anweisung use util::say die Funktion lokal bekanntgemacht werden, sodass sie allein durch ihren Funktionsnamen say verwendet werden kann, was bei längeren oder stärker verschachtelten Modulnamen praktischer ist. Unabhängig von der Variante, die wir hier verwenden, sollte die Ausgabe auf der Kommandozeile folgendermaßen aussehen:

> main.exe
Hello, Rust!

Wie gerade angedeutet, können Module auch weiter verschachtelt und strukturiert werden. Das kann entweder deklarativ im Code (durch mehrere mod-Blöcke) oder implizit durch Verzeichnishierarchien geschehen. Beginnen wir damit, einen expliziten Modulnamen zu vergeben. Der Name util, mit dem das Modul bisher angesprochen wurde, ermittelte sich ja implizit durch den Dateinamen. Listing 10 zeigt die notwendige Änderung in Form der Deklaration pub mod greeter, die notwendig ist, um explizit ein Modul mit dem Namen greeter zu erzeugen. Auch hier ist das Schlüsselwort pub wieder von entscheidender Bedeutung, da ansonsten das Modul von außen nicht verwendet werden könnte. Der Aufruf der exportierten Funktion ändert sich ebenfalls minimal, da der vollqualifizierte Name nun util::greeter::say lautet.

Angenommen, wir wollten unsere util-Sammlung um weitere Funktionalität in Form eines zusätzlichen Moduls erweitern: Dieses Modul soll den Namen text tragen und eine Funktion namens lorem erhalten, die ein Fragment des bekannten Blindtexts zurückliefert (Listing 11). Um dieses neue Modul verwenden zu können, wäre es ausreichend, den Code aus Listing 11 einfach zum bestehenden Modul greeter in der Datei util.rs hinzuzufügen. Geht man aber davon aus, dass diese Sammlung von Helfermodulen und deren Funktionalitäten kontinuierlich weiter wächst, wird sehr schnell klar, dass die Vorgehensweise, bei der alles in einer Datei gehalten wird, nicht gut skaliert. Selbstverständlich bietet Rust hierfür einen transparenten Strukturierungsmechanismus an, der auf Verzeichnisnamen basiert. Zuerst erstellen wir einen Ordner mit demselben Namen wie die Datei, die die Modulsammlung enthält – in unserem Fall also util. Die eigentliche Datei util.rs wird in mod.rs umbenannt (dabei handelt es sich um eine Rust-Namenskonvention) und in den neu erstellten Ordner verschoben. Kompilieren wir die Anwendung nun erneut, funktioniert jedoch alles wie gehabt, und für den Konsumenten der Module ändert sich nichts. Die Umstellung auf diese neue, verzeichnisbasierte Modulstruktur erlaubt es uns nun aber, den Code besser zu strukturieren und in logische Einheiten zu gliedern. Dazu ändern wir den Code in der Datei mod.rs ab, sodass er nur noch die Deklaration der einzelnen Module enthält, wie in Listing 12 zu sehen ist. Die eigentliche Implementierung der beiden Module verschieben wir in die Dateien greeter.rs und text.rs, die sich in demselben Verzeichnis befinden (Abb. 5). Jetzt muss in den beiden Dateien noch die eigentliche Moduldeklaration (pub mod) entfernt werden, da ansonsten eine zusätzliche gleichnamige Ebene in der Modulhierarchie angelegt würde. Vereinfacht gesagt bleiben in den beiden Dateien lediglich die Funktionsdeklarationen übrig. Der Rest der Anwendung lässt sich erneut ohne weitere Änderungen kompilieren, doch von nun an sind die Implementierungen der beiden Module sauber in separate Dateien aufgeteilt.

Abb. 2: Modularisierung in Rust

Abb. 5: Modularisierung in Rust

Ein saubere Modularisierung ist das Rückgrat jeder guten Software, und entsprechend haben wir diesem Thema auch die nötige Aufmerksamkeit und Beachtung geschenkt. Mit dem bisher gesammelten Wissen ist es uns nun möglich, ein neu erstelltes Rust-Projekt ordentlich zu strukturieren und in Moduldateien zu gliedern. Ob und wie man diese Module aber in verschiedenen Projekten bzw. binären Komponenten verwalten kann, sehen wir uns in weiterer Folge an.

Listing 7

// main.rs
fn main() {
  println!("Hello, World!");
}

Listing 8

// util.rs
pub fn say(name : &str) {
  println!("Hello, {}!", name);
}

Listing 9

// main.rs: verwendet Funktion
// "say" aus dem Modul "util"
mod util;
 
fn main() {
  util::say("Rust");
}

Listing 10

// util.rs: exportiert Modul "greeter"
pub mod greeter {
  pub fn say(name : &str) {
    println!("Hello, {}!", name);
  }
}

Listing 11

// util.rs: neues Modul "text"
. . .
pub mod text {
  pub fn lorem() -> &'static str {
    "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam"
  }
}

Listing 12

// mod.rs: Implementierung der Module in greeter.rs/text.rs
pub mod greeter;
pub mod text;

Komponentisierung

Wir verwenden den Rust-Compiler, um aus den zuvor entwickelten Modulen eine Bibliothek zu erzeugen, die wir anschließend in das Hauptprogramm einbinden und darin verwenden können. Rust verwendet für diese Art Komponente den Begriff „Crate“. Standardmäßig erstellt der Rust-Compiler Crates vom Typ bin, also ausführbare Dateien (.exe-Dateien unter Windows). Um stattdessen eine Bibliothek zu erhalten, muss der Compiler lediglich angewiesen werden, eine Crate vom Typ lib zu erstellen. Zu diesem Zweck kompilieren wir das zuvor erstellte Modul util folgendermaßen:

> rustc util\mod.rs --crate-type lib --crate-name util

Der Parameter –crate-type lib sorgt dafür, dass anstelle einer ausführbaren Datei eine Bibliothek entsteht; mit –crate-name util können wir dieser einen beliebigen Namen geben. Standardmäßig wird dafür der Name der kompilierten Datei herangezogen, aber der Name mod ist für diesen Zweck etwas zu generisch. Als Ergebnis erhalten wir die Datei libutil.rlib, die wir nun in der Hauptanwendung main.rs verwenden wollen. Dies geschieht mithilfe der Schlüsselwörter extern crate und deren Namen (util), über den anschließend auch auf die Funktionalität der Bibliothek zugegriffen werden kann. Listing 13 enthält den dafür notwendigen Code. Beim Kompilieren von main.rs muss die zuvor erstellte Crate nun angegeben werden, damit der Compiler die entsprechenden Funktionsimports und -exports auflösen kann. Am einfachsten lässt sich das mit der Kommandozeilenoption -L <Verzeichnisname> bewerkstelligen, die den Compiler veranlasst, im entsprechenden Verzeichnis nach einer passenden Crate zu suchen:

> rustc -L . main.rs

Möchte man die Crate jedoch lieber explizit spezifizieren, geht das natürlich auch. Zusätzlich hat diese Variante den Vorteil, dass hier ein beliebiger Name für die Crate vergeben werden kann (sofern dieser auch beim Importieren wiederverwendet wird):

> rustc main.rs --extern util=libutil.rlib

In beiden Fällen erhalten wir wieder eine ausführbare Datei, die uns mit den Worten „Hello, Crate!“ begrüßt.

Nachdem wir das Prinzip und die Funktionsweise von Rusts Crates verstanden haben, lohnt sich ein Blick auf die Website www.crates.io (Abb. 6). Diese Seite bietet ähnlich wie www.npmjs.com für Node.js oder www.nuget.org für .NET eine zentrale Anlaufstelle zur Bereitstellung und zum Download von Rusts Crates. Wie man Crates aus dieser Onlinesammlung verwenden kann, sehen wir uns im folgenden Abschnitt an, wenn wir das Rust-Werkzeug Cargo kennenlernen.

Listing 13

// main.rs: Verwendung der
// crate "util"
extern crate util;
 
fn main() {
  util::greeter::say("Crate");
  println!("{}", util::text::lorem())
}
Abb. 3: Rusts zentrales Paketlager

Abb. 6: Rusts zentrales Paketlager

Rust-Paketverwaltung mit Cargo

Das CLI-Tool Cargo lässt sich am besten mit anderen Werkzeugen aus dieser Kategorie wie npm (Node.js) oder dotnet (.NET Core) vergleichen und bietet eine breite Auswahl an Funktionalitäten:

  • Mit dem Aufruf von cargo new lässt sich zum Beispiel eine neue Projektstruktur erstellen, die automatisch den gängigen Konventionen in Rust entspricht.

  • Dieses neu erstellte Projekt kann sogleich mit cargo build kompiliert werden.

  • Der Befehl cargo run führt die Anwendung aus und kompiliert sie zur Not auch, wenn man den vorherigen Schritt vergessen oder ausgelassen hat.

  • Aber auch das Ausführen von Unit-Tests (cargo test) oder das Erstellen einer Dokumentation (cargo doc) ist damit möglich. Dazu jedoch später noch mehr.

  • Eine Übersicht über alle Funktionen, die Cargo bietet, erhält man am einfachsten durch den Aufruf von cargo ohne weitere Argumente.

Dieser kurze Überblick über die Funktionalitäten von Cargo zeigt schon, dass uns dieses Werkzeug viele Arbeitsschritte abnehmen kann, die wir gerade noch händisch selbst erledigt hatten. Um diese Erkenntnis reicher, verwenden wir Cargo nun zum Erstellen und Kompilieren unserer Testprojekte. Zunächst erzeugen wir wieder eine Crate namens util, die erneut die Module greeter und text beinhalten wird. Folgender Kommandozeilenaufruf erledigt das für uns:

> cargo new util
Created library `util` project

Die Konsolenausgabe bestätigt auch gleich, dass standardmäßig eine Bibliothek erzeugt wurde, was genau unserem Vorhaben entspricht. Zusätzlich legt cargo new auch gleich ein Git Repository für das neu erstellte Projekt an; das ließe sich über den Kommandozeilenparameter –vcs none verhindern, wenn man das nicht wünscht. Im Ordner src befinden sich – wenig überraschend – die Quellcodedateien, und im Order target entstehen später beim Kompilieren des Projekts die entsprechenden Binärdateien. Zusätzlich erhalten wir die Datei Cargo.toml, bei der es sich um eine Manifest-Datei für unsere Crate handelt. Bei TOML (Tom‘s Obvious, Minimal Language) [9] handelt es sich um eine einfache Beschreibungssprache, mit der die Metadaten eines Projekts (Name, Autor, Version usw.), aber auch Abhängigkeiten zu anderen Crates definiert werden können (Listing 14). Als Nächstes kopieren wir die zuvor erstellten Dateien greeter.rs und text.rs in den Ordner src und deklarieren diese beiden Module in der generierten Datei lib.rs.

pub mod greeter;
pub mod text;

Mit dem Aufruf von cargo build sollte sich die neu erstellte Bibliothek nun ohne Fehler oder Warnungen kompilieren lassen. Um die Funktionalität der Bibliothek wieder zu testen, erstellen wir eine weitere Crate mit folgendem Aufruf:

cargo new main --bin

Der Parameter –bin sorgt dafür, dass wir dieses Mal eine ausführbare Datei erhalten. Dies können wir mit dem Aufruf von cargo run auch gleich überprüfen, und die Ausgabe auf der Konsole sollte auch hier zunächst „Hello, World!“ lauten. Wenn wir nun aber die Funktionalität der Module in der crate util nutzen möchten, müssen wir sie wieder in geeigneter Form referenzieren. Am bequemsten lässt sich das nun mit dem schon erwähnten Abhängigkeitsmechanismus in der Datei Cargo.toml erreichen. Das ist zum Beispiel mit der Angabe eines relativen Pfads möglich (Listing 14).

Doch dieser Mechanismus kann wesentlich mehr, als nur lokale Referenzen aufzulösen. Die dependencies-Einträge in der TOML-Datei können ebenso Crates referenzieren, die im bereits vorgestellten zentralen Repository, zu finden unter www.crates. io, vorhanden sind. Die Suche nach entsprechenden Bibliotheken ist natürlich auf dieser Website möglich, lässt sich aber auch direkt über Cargo abwickeln. Das ist praktisch, wenn man ungefähr weiß, wonach man sucht oder den Namen einer Crate nicht hundertprozentig abrufbereit hat. Sucht man beispielsweise nach einer Crate, die HTTP-Funktionalität bietet, lässt sich das mit folgendem Aufruf erledigen:

cargo search http

Als Antwort erhält man eine Liste von möglichen Kandidaten (Abb. 7). Wenn wir uns für eine passende (z. B. http) entschieden haben, können wir diese in der gewünschten Version direkt in der Datei Cargo.toml eintragen.

Abb. 4: Cargo hilft bei der Suche nach passenden Crates

Abb. 7: Cargo hilft bei der Suche nach passenden Crates

[dependencies]
http = "0.1.5"

Ein Aufruf von cargo install kümmert sich um den Rest, und Listing 15 zeigt, wie sich die neu hinzugefügte Funktionalität direkt verwenden lässt. Dieses kurze Beispiel lässt uns bereits erahnen, dass Cargo ein äußerst mächtiges und nützliches Hilfsmittel ist, das Rust-Standardaufgaben wie das Erstellen, Kompilieren und Ausführen von Projekten stark vereinfacht. Doch selbst über die Verwaltung von Paketabhängigkeiten hinaus stellt Cargo noch die eine oder andere zusätzliche Funktionalität zur Verfügung, wie wir in weiterer Folge sehen werden.

Listing 14

// Cargo.toml
[package]
name = "main"
version = "0.1.0"
authors = ["Wolfgang Ziegler"]
 
[dependencies]
util = { path = "../util" }

Listing 15

// main.rs
extern crate http;
 
use http::Request;
 
fn main() {
  let request = Request::builder()
    .uri("https://www.rust-lang.org/")
    .body(())
    .unwrap();
}

Tests First!

Ein Thema, das in professionellen Softwareentwicklungsprozessen in jedem Fall behandelt werden muss, ist das Testen. Zum Glück ist diese Denkweise auch in den Köpfen der Rust-Entwickler fest verankert, und aus diesem Grund enthält Rust ein eingebautes Unit-Test-Framework. Spezielle Attribute auf Modulen und Funktionen kennzeichnen diese als „testrelevant“, und ein Aufruf von cargo test kümmert sich automatisch um ihre Ausführung und um die Erstellung eines Testreports. Doch sehen wir uns am besten direkt an, wie sich das für die Crate util aus unserem Beispiel umsetzen lässt und erstellen Tests für das Modul greeter. Zunächst erweitern wir dafür die Datei lib.rs um die Definition eines weiteren Moduls greeter_test.rs, das den eigentlichen Testcode implementieren wird:

#[cfg(test)]
mod greeter_test;

Das Attribut cfg sorgt dafür, dass das neue Modul nur im Fall eines Testlaufs kompiliert werden muss. Das ist äußerst praktisch, da man auf diese Weise Tests direkt in den entsprechenden Modulen implementieren kann, diese das Endprodukt jedoch nicht unnötig aufblähen. Den Testcode selbst entwickeln wir, wie jedes andere Modul auch, in einer eigenen Rust-Datei – in diesem Fall greeter_test.rs. Um die Funktionalität des Moduls greeter auch in einem Unit-Test abdecken zu können, ist zuvor ein kleines Refactoring nötig, da die Funktion say direkt auf die Kommandozeile schreibt, was sich in einem Test nur schwer verifizieren lässt. Wir spendieren dem Modul aus diesem Grund eine zusätzliche Funktion make, die den String entsprechend vorbereitet und von say nurmehr verwendet werden muss (Listing 16). Für diese Funktion lässt sich nun auch ganz einfach ein Unit-Test bereitstellen, wie Listing 17 zeigt. Das Attribut test kennzeichnet die Funktion dabei als Test, und das Makro assert_eq! nimmt die eigentliche Verifikation vor.

Abb. 5: Unit Testing mit Rust und Cargo

Abb. 8: Unit Testing mit Rust und Cargo

Listing 16

// greeter.rs: Refactoring fuer bessere Testbarkeit
pub fn say(name : &str) {
  println!("{}", make(name))
}
 
pub fn make(name : &str) -> String {
  format!("Hello, {}!", name)
}

Listing 17

// greeter_test.rs: Unit test fuer greeter.rs
use super::greeter;
 
#[test]
fn greets() {
  let s = greeter::make("Test");
  assert_eq!("Hello, Test!", s)
}

Rust bietet noch eine Reihe weiterer nützlicher Attribute und Makros zum Erstellen und Verwalten von Unit-Tests:

  • Das Makro assert! verifiziert einen einfachen Wahrheitswert. Der Ausdruck assert!(false) würde somit einen Test immer fehlschlagen lassen.

  • Das Attribut should_panic zeigt an, dass ein Test fehlschlagen soll. Es wird ein negatives Ergebnis von assert_eq! oder assert! erwartet. Der logische Ausgang eines Tests lässt sich somit umkehren.

  • Mit dem Attribut ignore kann ein Test ignoriert werden, wenn er noch nicht vollständig implementiert oder instabil ist. Der Test scheint dann im Testreport auf, wird jedoch nicht ausgeführt.

  • Der Aufruf von cargo test kümmert sich um das Kompilieren, Ausführen und Erstellen eines schön formatierten Testreports (Abb. 8).

RTFM!

Ein weiteres zentrales Thema beim Erstellen produktionsreifer Software ist die Dokumentation. Was in anderen Sprachen oft nur mühsam oder gar nicht vorgesehen ist und nur durch den Einsatz externer Tools bewerkstelligt werden kann, ist in Rust ebenfalls direkt eingebaut. Der Aufruf von cargo doc liefert bereits eine sehr ansprechende HTML-Dokumentation unserer Crate, ohne dass wir spezielle Vorkehrungen dafür hätten treffen müssen (Abb. 9). Sofern man sich aber zusätzlich noch an einige wenige Konventionen hält, können Kommentare im Code die standardmäßige Dokumentation noch deutlich aufwerten. Kommentare auf Modulebene werden beispielsweise mit der Sequenz „//!“ eingeleitet, Funktionskommentare mit „///“. Wirklich interessant dabei ist aber, dass Rust-Kommentare Markdown-Sequenzen enthalten dürfen [10], was ansprechende Formatierungen inklusive Codeblöcken erlaubt. Das Besondere an solchen Codeblöcken ist aber darüber hinaus, dass sie beim Ausführen von cargo test auch tatsächlich kompiliert werden und somit ausführbar sein müssen. So wird automatisch sichergestellt, dass die technische Seite der Dokumentation korrekt ist und Code und Dokumentation nicht voneinander abweichen. Listing 18 zeigt, wie die Moduldokumentation für greeter.rs aussehen könnte und wie sich auf diese Art und Weise sogar Unit-Tests in die Dokumentation einbauen lassen.

Abb. 6: Dokumentation out of the Box

Abb. 9: Dokumentation out of the Box

Abb. 7: Dokumentation auf Basis von Codekommentaren und Markdown

Abb. 10: Dokumentation auf Basis von Codekommentaren und Markdown

Will man lediglich die Dokumentation erzeugen, ohne den Code auf Ausführbarkeit zu prüfen, so geht das mit dem Aufruf von cargo doc. Das Ergebnis kann sich aber in jedem Fall sehen lassen, wie Abbildung 10 beweist.

Listing 18

//! Dieses Modul liefert einen "Hello, World!" Begrüßungstext.
//!
//! # Beispiel
//!
//! ```
//! use util::greeter;
//! assert_eq!("Hello, Test!", greeter::make("Test"));
//! ```
 
/// Gibt den Begrüßungstext direkt auf der Konsole aus.
pub fn say(name : &str) {
  println!("{}", make(name))
}
 
/// Formatiert den Begrüßungstext.
///
/// ```
/// use util::greeter;
/// assert_eq!("Hello, Test!", greeter::make("Test"));
/// ```
pub fn make(name : &str) -> String {
  format!("Hello, {}!", name)
}

Interop

Ein nicht zu unterschätzender Faktor bei der Adoption neuer Sprachen und Frameworks ist meist auch die Notwendigkeit, diese in bereits bestehende Systeme einzubauen. Nur in den wenigsten Fällen ist ein Start von der grünen Wiese möglich; viel öfter gilt es, neue Software in eine bestehende Infrastruktur so nahtlos wie möglich zu integrieren. In Zeiten von verteilten Applikationen und Microservices ist das Problem oft weniger ein technisches, sondern dreht sich vielmehr um die Frage, wie viele verschiedene Technologien man letztendlich unter seinem Dach anhäufen möchte. All die verschiedenen Technologien müssen schlussendlich auch weiterentwickelt und gewartet werden, was entsprechendes Know-how voraussetzt.

Noch schwieriger wird dieses Thema aber, wenn es um engere Kopplung und Zusammenarbeit von Softwarekomponenten auf der Ebene von Binärkompatibilität geht. Glücklicherweise hat Rust aber auch für diese Anwendungsfälle Lösungen vorgesehen, und es gibt sowohl die Möglichkeit, bestehende Komponenten in einer Rust-Applikation zu verwenden, als auch den umgekehrten Fall. In beiden Varianten erfolgt die Kommunikation über die gute alte C-Schnittstelle.

Will man die Funktionen einer bestehenden Komponente (Windows DLL) von Rust aus aufrufen, findet das über Rusts FFI (Foreign Function Interface) [11] statt. Die Funktionsweise ist ähnlich zu PInvoke in .NET und verfolgt im Wesentlichen den Ansatz, die benötigten externen Funktionen in einer für Rust aufrufbaren Weise zu deklarieren. Als Beispiel versuchen wir, die Win32-API-Funktion MessageBoxA aufzurufen, um eine Nachricht in einem Fenster auf dem Bildschirm anzuzeigen. Der Code in Listing 19 zeigt, wie sich das in relativ wenigen Zeilen Rust-Code umsetzen lässt. Ein extern-Block gibt an, welche Funktionen aus der gewünschten Bibliothek man importieren möchte, das link-Attribut auf dem Block importiert die Bibliothek selbst. In diesem Fall ist das die erwähnte Funktion MessageBoxA, deren Parameterliste mit Rust-Typen möglichst passend abgebildet wird. Da die Funktion nullterminierte Strings erwartet, bedienen wir uns der Hilfsklasse CString. Der Aufruf selbst erfolgt in einem mit unsafe markierten Block, da wir hier die sicheren Gefilde von Rust verlassen müssen. Das Ergebnis stellt sich aber wunschgemäß ein, wie Abbildung 11 zeigt.

Abb. 8: Win32-API-Interop mit Rust

Abb. 11: Win32-API-Interop mit Rust

Will man den umgekehrten Weg gehen und die Funktion einer Crate über eine C-Schnittstelle aufrufbar machen, ist das auch nicht weiter schwierig. Zuerst muss der Typ dem Crate-Typ angepasst werden, was am einfachsten in der Datei Cargo.toml geschieht:

crate-type = ["dylib"]

Anschließend muss die zu exportierende Funktion noch als extern deklariert und mit dem no_mangle-Attribut versehen werden, damit Rusts Linker diesen Namen auch tatsächlich für die C-Schnittstelle heranzieht:

#[no_mangle]
pub extern fn hello() {
  println!("Hello from Rust!");
}

Zu guter Letzt kann die DLL, die auf diese Weise entstanden ist, direkt aus C/C++ oder z. B aus .NET mit PInvoke verwendet werden.

Natürlich gibt es beim Thema Interop noch jede Menge Fallstricke und Randfälle, die wir hier keineswegs abdecken können, und wahrscheinlich ließe sich allein zu diesem Thema ein ganzes Buch schreiben. Wichtig ist jedoch, mit den Grundprinzipien vertraut zu sein und zu wissen, welche Anwendungsfälle sich wie abdecken lassen.

Listing 19

use std::ffi::CString;
 
#[link(name = "user32")]
extern {
  fn MessageBoxA(hwnd: i32, text : *const i8, caption : *const i8, _type : i32) -> i32;
}
 
fn message_box(msg: &str) {
  let cstr = CString::new(msg).unwrap();
  unsafe {
    MessageBoxA(0, cstr.as_ptr(), cstr.as_ptr(), 0);
  }
}
 
fn main() {
  message_box("Hello from Rust!");
}

Einführung in Rust – Fazit

Hiermit sind wir am Ende dieser Einführung zum Thema Rust angelangt, und ich hoffe, dass Sie sich ein Bild von dieser Sprache und ihrem Ökosystem machen konnten. Während der Fokus zunächst auf der Syntax und Idiomatik von Rust selbst sowie den Prinzipien, die diese Sprache ausmachen, lag, konzentrierten wir uns im zweiten Teil eher auf das Big Picture. Wie erstellt und strukturiert man neue Projekte und welche Möglichkeiten bieten die Werkzeuge rund um Rust? Wie funktionieren Tests und Dokumentation, bzw. wie kann die Integration in bereits bestehende Softwaresysteme gelingen? Nun haben Sie hoffentlich die nötigen Antworten parat, um Rust erfolgreich in eigenen Projekten einzusetzen bzw. dafür entsprechende Überzeugungsarbeit zu leisten. Unterstützende Codebeispiele stehen im GitHub Repository rust-playground [12] zur Verfügung.

Verwandte Themen:

Geschrieben von
Wolfgang Ziegler

Wolfgang Ziegler ist Softwareentwickler, Blogger und Autor. Er ist .NET-Enthusiast der ersten Stunde und seine aktuellen Schwerpunkte liegen auf Webtechnologien sowie Windows-Phone-Entwicklung. Für Fragen, Trainings oder Vorträge steht er jederzeit gerne zur Verfügung. Web: www.wolfgang-ziegler.com Twitter: @z1c0

Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: