← Zur Übersicht

TOP 10: Rust

Rust

Wie im vorherigen Blogpost angekündigt hier nun der erste genauere Blick auf eine der TOP 10 Programmiersprachen: Rust

Das Versprechen

Rust hat sich drei Themen auf die Fahne geschrieben. Das sind:

Sicherheit und Effizienz in der Nebenläufigkeit.

Und Rust verspricht, diese Dinge wirklich gut zu können!

Sicherheit

Sicherheit meint hier Speichersicherheit. Also keine Speicherüberläufe oder hängende Zeiger. Dinge, um die man sich in C oder C++ oft selbt kümmern muss und die zum Teil viel Erfahrung erfordern, versucht Rust direkt bei der Entwicklung zu unterbinden. Hier sind vor allem das starke Typsystem, das Konzept der Ownership und der dominante Compiler Garant für diese Sicherheit. Ein interessanter Post dazu findet sich hier.

Effizienz

Nur zwei kurze Erwähnungen in diesem Blogpost:

  1. Rust kommt ohne Garbage Collector aus.
  2. Rust ist darauf ausgelegt, möglichst viele Abstraktionen der Sprache bereits durch den Compiler wieder wegzubügeln (zero-cost abstraction).

Der Compiler ist ohnehin sehr dominant und versucht der Laufzeit wenig Interpretationsspielraum zu geben. Dazu später mehr.

Let's begin

Fangen wir Vorne an, also bei der Installation. Kommt man als Windows-Benutzer noch in den Genuss einer typischen Setup-Exe (rustup-init.exe), so ist die Installation auf einem der Unix-Derivate ein wenig unorthodox über ein Shell-Script geregelt. Schafft aber auch ein mit dem AmigaOS sozialisierter Entwickler ganz gut :).

Die Installation liefert dann neben dem üblichen CLI, welches unter Rust cargo heisst, auch noch einige nützliche Tools, wie z.B. einen Formater und einen Linter mit. Setzt man als Editor auf Visual Studio Code, so ist mit dem entsprechenden Plugin (Rust language support (rls)) auch gleich die Integration dieser Tools erledigt. Daneben liefert das Plugin auch den Rust Language Server, der es der IDE ermöglicht, Code Completion und Inline Error Messages anzubieten.

Cargo ist ein regelrechtes Allround-CLI. Damit lässt sich nicht nur sehr schnell ein kleines Projekt aufsetzen (cargo new <project_name>), sondern auch ein Projekt builden, starten und testen. Als Paketmanager dient cargo ebenfalls und mit der eigenen Registry unter crates.io lässt sich schon einiges anfangen. Mittlerweile können mit cargo auch private Registries aufgesetzt und verwaltet werden.

Was bei der Entwicklung mit Rust auffällt, ist, dass die Fehlermeldungen des Compilers hervorragend und wirklich hilfreich sind. Und Fehler kann man mit den Eigenheiten von Rust am Anfang erstmal reichlich machen. Schauen wir mal rein...

Multiparadigm

Rust wird als eine sog. multiparadigmatische Sprache bezeichnet. Sie vereint also Konzepte aus mehreren Ansätzen wie z.B. funktionaler oder objektorientierter Entwicklung.

Systemnah

Darüber hinaus ist sie als eine Systemprogrammiersprache konzipiert, die also sehr maschinennah für z.B. Betriebssystemkomponenten oder direkte Hardwareprogrammierung prädestiniert ist. Das kennt man von Assembler, C sowie C++. So verwundert es auch nicht, dass die Syntax nah an der von C ausgerichtet ist. Systemnahe Programmierung erfordert ein hohes Maß an Sicherheit und Stabilität. Genau das verspricht Rust und versichert sicher, nebenläufig und praxisnah zu sein.

Typsystem

Erreicht werden sollen diese Eigenschaften durch das besondere Typesystem von Rust, das wir hier kurz beleuchten.

Immutable

Per Definition sind alle Variablen in Rust erst einmal immutable, also nicht mehr änderbar. Für Nebenläufigkeit ist das sicherlich eine gute und runde Sache. Aber natürlich gibt es auch Situationen, in denen man Variablen (so wie der Name schon sagt) auch mehrfach mit unterschiedlichen Werten beschreiben möchte. Und natürlich ist das in Rust auch möglich - nur eben nicht standardmäßig.

Data Types

Rust kennt die üblichen skalaren Typen wie integer, float, boolean und character.

Ebenso sind zusammengesetzte Typen wie Tupel ein fester Bestandteil des Typsystems. Die Elemente eines Tupels können verschiedene Typen aufnehmen und eine Deklaration kann z.B. so aussehen.

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

Das soll hier an Ort und Stelle zum Thema Tupel reichen. Dasselbe gilt für die verschiedenen Collection-Typen, die die Standard-Bibliothek von Rust bereithält. Die meisten sind uns zumindest vom Konzept her aus anderen Programmiersprachen geläufig.

Viel interessanter sind die beiden Typen struct und enum, von denen wir hier den Blick auf struct richten.

Struct

Ein struct ist der Umsetzung in C sehr ähnlich. Aus Pascal kennt man sie noch als record.

struct Person {
    vorname: String,
    nachname: String,
    alter: u8,
}

Nur in Rust lassen sich neben Datenfeldern auch Methoden an die Struktur hängen.

impl Person {
    fn print_diplay_name(&self) {
        println!(
            "Person: {} {}, {} Jahre.",
            self.vorname, self.nachname, self.alter
        );
    }
}

Somit bilden sie das OOP-Konzept der Klasse nach. Zumindest in Teilen, denn eine Vererbung gibt es nicht. Auch fühlt es sich etwas fremd an, da die Methoden nicht innerhalb des Body eines structs definiert werden, sondern als eigenständige Blöcke, auch in anderen Modulen oder Dateien. Das ähnelt sehr den Extension-Methods aus C#.

Polymorphie mittels Generics und Traits

Generische Datentypen sind in vielen Programmiersprachen geläufig und bieten uns eine gute Möglichkeit der Wiederverwendung von Code.

Etwas weniger bekannt sind traits. Mit ihnen wird das Konzept der Interfaces durch Standardimplementierungen ergänzt. Das eröffnet eine neue Sichtweise auf die viel diskutierte Mehrfachvererbung. Immer mehr Programmiersprachen, darunter auch Java und C#, bieten mittlerweile eine Umsetzung von traits an. Rust macht von traits in seiner Standardbibliothek regen Gebrauch. Hier mal ein einfaches Beispiel:

trait SalesDiscount {
    fn discount(&self) -> u8;
}

...

impl SalesDiscount for Person {
    fn discount(&self) -> u8 {
        5
    }
}

Natürlich geht das Konzept der traits weiter, als das in diesem Blogpost erörtert werden soll. Wer die Grundidee verstehen möchte, sollte sich z.B. dieses Paper mal genauer anschauen.

Functions

Funktionen sind das Elixir von Rust und bilden das Grundgerüst jeder Applikation. Auch hier keine Vertiefung an Ort und Stelle aber der Hinweis, dass Funktionen kein explizites return-Statement benötigen, sondern die letzte Expression im Kontrollfluss automatisch zurückgegeben wird. Wer möchte darf aber auch return benutzen.

Kontrollfluss

Apropos Kontrollfluss. Den gibt es in Rust mit bedingten Anweisungen, Verzweigungen und Schleifen natürlich auch. Eine Besonderheit ist sicherlich die Tatsache, dass if-Statements ebenfalls eine Expression darstellen und somit auch an Funktionen übergeben werden können.

Sicheres Speichermanagement

Ownership

Nun wird es interessant. Es kommt ein Konzept, das Rust ziemlich einzigartig macht - Ownership!

Jedes Programm muss den Speicher verwalten, den es während der Ausführung nutzt. Einige Sprachen verfügen über eine Garbage Collection, die während der Programmausführung ständig nach nicht mehr genutztem Speicher sucht (C#, Go), in anderen Sprachen muss der Programmierer den Speicher explizit zuweisen und freigeben (C/C++). Rust verwendet einen dritten Ansatz: Der Speicher wird durch ein System von Regeln verwaltet, die der Compiler zur Kompilierzeit überprüft. Dieses System von Regeln nennt sich Ownership. Und das sind die Regeln:

  • Jeder Wert in Rust hat eine Variable, die als sein Eigentümer bezeichnet wird.
  • Es kann jeweils nur einen Eigentümer geben.
  • Wenn der Eigentümer seinen Scope verlässt, erlischt der Wert.

Was bedeutet das nun in der Praxis? Schauen wir uns ein kleines Beispiel an:

let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);

Sowas kennen wir aus unserer täglichen Arbeit. Wir definieren eine Variable und weisen ihr einen Wert zu. Im weiteren Verlauf definieren wir eine neue Variable und weisen ihr den Wert der Ersten zu. Wir sind es gewohnt, dass uns dann beide Variablen zur Verfügung stehen. Nicht so in Rust! Der Compiler sagt uns sehr deutlich, was er davon hält:

error[E0382]: borrow of moved value: `s1`
  --> src/main.rs:17:28
   |
14 |     let s1 = String::from("hello");
   |         -- move occurs because `s1` has type `std::string::String`, which does not implement the `Copy` trait
15 |     let s2 = s1;
   |              -- value moved here
16 | 
17 |     println!("{}, world!", s1);
   |                            ^^ value borrowed here after move

Nun, was ist passiert? Der Compiler wendet die zweite Regel an, die besagt, dass es nur einen Eigentümer für einen Wert geben darf. Und in dem Beispiel haben wir mit der Initialisierung von s2 durch s1 die Ownership ebenfalls übergeben. s2 ist nun der alleinige Besitzer des Literals "hello".

Das ist definitiv anders. Daran muss man sich erstmal gewöhnen.

Copy

Der aufmerksame Leser wird gesehen haben, dass in der Fehlermeldung vom "Copy trait" die Rede ist. In der Tat ist es so, dass man das Verhalten des Besitzübergangs durch die Implementierung dieses traits umgehen kann. Typen, die direkt auf dem Stack landen, z.B. Interger, haben das bereits und nutzen standardmäßig Copy.

Das macht es in der ersten Auseinandersetzung mit Rust natürlich nicht einfacher, aber auch hier gilt: Übung macht den Meister.

Borrowing

Nicht in jedem Fall wollen wir die Ownership an jemand anderen übertragen. In diesem Fall gibt uns Rust die Option, Referenzen zu nutzen. Syntaktisch werden Referenzen über & eingeleitet, wie das folgende Beispiel aus der Originaldokumentation zeigt:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Was passiert hier? Nun, nach dem vorhin eingeführten Konzept der Ownership hätten wir erwartet, dass der Compiler den Zugriff aus s1 in der Sequence println!("The length of '{}' is {}.", s1, len); nicht mehr erlaubt, weil die Ownership von s1 in der Sequence let len = calculate_length(&s1); an die Funktion übertragen wird.

Der Unterschied ist nun der vorhin erwähnte Ampersand (&), der dafür sorgt, dass lediglich eine Referenz von s1 an die Funktion übergeben wird. Dabei wird die Ownership nicht übertragen, sondern verbleibt bei s1. Es ist darauf zu achten, dass der Ampersand auch in der Signatur der Funktion eingesetzt werden muss, ansonsten funktioniert der Mechanismus nicht.

Dieses Prinzip nennt sich Borrowing - Ausleihe.

Dieses Prinzip funktioniert für immutable Types, wie auch für mutable Types. Nur mutable Types haben eine generelle Einschränkung: sie dürfen nur eine Referenz besitzen. Genauer: sobald eine mutable Referenz eines Wertes existiert, darf es überhaupt keine weitere Referenz dieses Wertes geben - auch keine immutable.

Auch das ist neu in Rust und in den meisten anderen Programmiersprachen sind fast beliebig viele Referenzen auf Werte erlaubt. Rust möchte eben die aus diesen Sprachen bekannten Fehlerquellen, wie z.B. Race Conditions oder Dangling References vermeiden. Und das gelingt Rust, sogar schon zur Kompilierzeit.

Lifetime-Parameter

Den dritten Baustein des sicheren Speichermanagements stellen die Lifetimes bzw. Lifetime-Parameter dar. Erstmal sind Lifetimes genau das, was sie aussagen: die Zeitspanne, in der eine Variable in ihrem Scope existiert. Für Variablen auf dem Stack ist es sehr einfach diese Zeitspanne zu bestimmen. Der Compiler geht dabei nicht anders vor als ein Entwickler, der sich den Code genau anschaut und die Scopes analysiert. Bei Werten auf dem Heap ist die simple Methode mitunter nicht so einfach anzuwenden. Genau hier setzt das Konzept der Lifetimes in Rust an. Heap bedeutet Referenzen. Und Referenzen sind in diesem Fall die einzigen Dinge, die dem Compiler Kopfzerbrechen bereiten können. Wie gesagt: oft kann der Compiler sehr genau bestimmen, in welchem Scope eine Referenz gültig ist. Kann er das aber nicht, benötigt er etwas Hilfe. Für diesen Zweck geben wir der entsprechenden Struktur (kann fast alles sein: Function, Method, Struct, Trait, ...) eine Lifetime von außen mit. Das definiert man genau wie einen generischen Typen, dem ein Apostroph vorangestellt ist.

fn will_fail<'a>() {
    let _z = 12;
    let _fail: &'a i32 = &_z;
}

Wenn man das Beispiel kompiliert bekommt man den folgenden Fehler:

error[E0597]: `_z` does not live long enough
  --> src/main.rs:41:26
   |
39 | fn will_fail<'a>() {
   |              -- lifetime `'a` defined here
40 |     let _z = 12;
41 |     let _fail: &'a i32 = &_z;
   |                -------   ^^^ borrowed value does not live long enough
   |                |
   |                type annotation requires that `_z` is borrowed for `'a`
42 | }
   | - `_z` dropped here while still borrowed

In der Tat veranschaulicht dieser Fehler das Prinzip besser als ein funktionierendes Beispiel. Der Lifetime-Paramter 'a (per Konvention werden die Lifetime-Parameter tatsächlich mit a, b oder c benannt) wird von einem Aufrufer implizit übergeben. Innerhalb der Methode definieren wir die lokale Variable _z, die definitiv nach der } nicht mehr existiert. Nun definieren wir eine weitere Variable, die wir versuchen, in die Lifetime 'a zu überführen. Wie wir gleich sehen werden, kann man das auch machen. Das Problem ist nur, dass wir ihr die lokale Variable _zzuweisen wollen. Das quittiert der Compiler mit der o.g. Fehlermeldung. Ist nachvollziehbar. Korrekt wäre die, mehr oder weniger sinnfreie, Funktion so:

fn will_not_fail_anymore<'a>() {
    let _z: &'a i32 = &12;
    let _fail: &'a i32 = &_z;
}

Generell ist das ein Thema, welches erprobt sein will. Implizit sind die Lifetime-Paramter übrigens immer vorhanden. Bei für den Compiler trivialen Konstellationen werden wir nicht dazu genötigt sie anzugeben. Falls doch, der Chef wird es uns schon sagen.

Weitere Themen

Rust hat natürlich noch viel mehr zu bieten, als so ein Blogpost zum Ausdruck bringen kann. Daher sollen sie hier nur kurz erwähnt werden.

Nebenläufigkeit

Nebenläufigkeit ist ein sehr wichtiges, aber auch sehr großes Thema. Vielleicht gibt es dazu ja mal einen weiteren Blog-Post. Es sei nur erwähnt, dass die hier beschriebene Speichersicherheit hinreichend für das Konzept der Nebenläufigkeit in Rust ist.

Metaprogrammierung mit Macros

Metaprogrammierung ist eine Technik, bei der man Code schreibt, der die Fähigkeit besitzt neuen Code zu generieren. Dies ist ein mächtiges Werkzeug, um eine Sprache zu erweitern. Generell gibt es zwei Ausprägungen: Runtime oder Compile Time Metaprogrammierung. Dynamische Sprachen wie Python, Javascript oder Lisp unterstützen Runtime Metaprogrammierung. Rust hingegen unterstützt die Compile Time Metaprogrammierung, wie auch C oder Elixir.

Was bleibt?

Wow, das waren jetzt doch viele Themen auf einmal. Und einige davon - vor allem die schwergewichtigen Themen wie Ownership oder auch Nebenläufigkeit - sind zu groß, um sie in einem Blogartikel zu würdigen. Ich hoffe allerdings, dass dieser Artikel das Interesse an der Sprache Rust geweckt hat und der ein oder andere Leser sich inspiriert genug fühlt, um sich das mal genauer anzuschauen. Ich jedenfalls habe in Rust die Sprache gefunden, die ich mir in diesem Jahr genauer anschauen werde.

In diesem Sinne, viel Spaß beim Ausprobieren.