Rostiges C – die wichtigsten Aspekte von Rust im Überblick

Wie Rust durch Eliminierung kritischer Funktionen Sicherheit schafft
1 Kommentar

Yoda postulierte vor vielen Jahren, dass große Macht mit großer Verantwortung einhergeht. Im Fall der Programmiersprachenfamilie C und C++ ist dies eindeutig der Fall: Es gibt kaum eine andere Sprachgruppe, in der man sich so einfach eine Kartusche in den Fuß jagen kann. Die von Mozilla entwickelte Sprache Rust – der Name bedeutet auf Englisch soviel wie Rost – möchte Entwicklern eine von kritischen Teilen befreite Alternative zu C anbieten. Dieser Artikel stellt Ihnen besonders interessante Aspekte von Rust in Kurzform vor.

Aus Sicht der Sprachdesigner ist das ersonnene Konzept einfach: Wenn man kritische Funktionen schlichtweg deaktiviert, so kann der Entwickler diese nicht zur Erstellung von unsicherem Code benutzen. C ist unter anderem deshalb so populär, weil es eine direkte Beziehung zwischen geschriebenem Code und resultierendem Maschinenverhalten gibt. Die Einführung von Garbage Collectors und anderen Niedlichkeiten würde an dieser Stelle für Unruhe sorgen – Rust verzichtet auf diese nicht unbedingt nützlichen Errungenschaften der Compilerforschung. Die Unterstellung von Luddismus ist – trotz dieses konservativen Vorgehens – unfair. Rust mag auf den ersten Blick wie C aussehen, ist aber an entscheidenden Stellen trotz allem anders. Neben diversen Kniffen zur Verkürzung des Codes bietet Rust zudem einige Änderungen, die man als Entwickler nicht unbedingt erwartet.

Erste Schritte

Wie in den vorangegangenen Ausgaben der Sprachenenzyklopädie wollen wir auch diesmal mit einer vergleichsweise einfachen Einrichtung des Entwicklungssystems befassen. Laden Sie das für Ihr Betriebssystem geeignete Archiv hier herunter und entpacken Sie es an einem für Sie angenehmen Ort.

In den folgenden Schritten wird mit Ubuntu 13.04 gearbeitet; das Betriebssystem liegt aus historischen Gründen in der 32-Bit-Variante vor. Da Rust auf einer Gruppe verschiedener Bibliotheken basiert, ist vor dem Einsatz eine Installation per Shellskript notwendig:

tamhan@ubuntu:~/Arbeitsfläche/rust-nightly-i686-unknown-linux-gnu$ sudo ./install.sh 
[sudo] password for tamhan: 
install: creating uninstall script at /usr/local/lib/rustlib/uninstall.sh 
. . .
Rust is ready to roll. 

Install.sh baut den rund 600 MB großen Interpreter tief in das Hostbetriebssystem ein. Wenn Sie Ihre Experimente mit der Sprache irgendwann beenden möchten, so sollten Sie den ausgegebenen Pfad zum Deinstallationswerkzeug notieren.

Die Ausführung eines Beispielprogramms erfolgt dann gemäß dem von gcc, g++ und Co bekannten Schema:

tamhan@ubuntu:~/Arbeitsfläche/Rust$ rustc hello.rs 
tamhan@ubuntu:~/Arbeitsfläche/Rust$ ./hello 
hello?

Von rustc erstellte Binärdateien haben normalerweise den Namen der Quelldatei, die die Main-Methode enthält. Achten Sie zudem darauf, dass das Rust-Team seinen Interpreter normalerweise in Form von als Daily bezeichneten und tagesaktuell erstellten Kompilaten ausliefert. In diesem Artikel kam folgende Version zum Einsatz:

tamhan@ubuntu:~/$ rustc -V 
rustc 1.0.0-nightly (890293655 2015-02-28) (built 2015-03-01)

Da Rust seit mehr als fünf Jahren am Markt ist, hat sich eine Vielzahl verschiedener IDEs etabliert. Neben dem universell beliebten Sublime gibt es auch Erweiterungen für einige andere populäre Produkte.

Hello Rust

Als erste Amtshandlung wollen wir ein Hello-World-Programm realisieren. Der dazu notwendige Code erinnert verdächtig an C und sieht so aus:

fn main() 
{
  println!("hello?");
}

println ist in Rust als Makro deklariert – der Name der Methode endet aus diesem Grund mit einem Ausrufezeichen. Rust-Makros unterscheiden sich insofern von C bzw. C++, als der für ihre Ausführung zuständige Interpreter wesentlich intelligenter ist. Leider sind zu ihrem Verständnis fortgeschrittene Sprachkonstrukte notwendig, die wir in diesem Artikel nicht vorstellen können.

Typisierung: Ärger!

Ein gravierender Unterschied zwischen C und Rust offenbart sich beim Versuch der Variablenerstellung. Die Methode playSomeVars() zeigt einige Besonderheiten des Rust-Interpreters auf:

fn playSomeVars() 
{
  let var_a=22; 
  let var_b="Hallo"; 
  let an_int:int=128; 
}

Variablendeklarationen beginnen in Rust mit dem aus älteren Basic-Interpretern bekannten let-Schlüsselwort. Der Compiler kann den Typ einer Variablen in vielen Fällen anhand des übergebenen Initialisators erkennen – das dedizierte Angeben des Datentyps ist nur bei Konstanten und ähnlichen Sonderfällen notwendig. Dabei stehen die in der Tabelle 1 aufgelisteten Konstrukte zu Ihrer Verfügung.

Tabelle 1: Angabe des Datentyps

Tabelle 1: Angabe des Datentyps

rustc zwingt Entwickler von Haus aus zur Verwendung des Snake-Case-Namensschemas. playSomeVars müsste play_some_vars heißen – da wir uns hier aus didaktischen Gründen nicht an die Vorgabe halten, belohnt uns der Compiler mit der in Abbildung 1 gezeigten Fehlermeldung.

Abb. 1: Großbuchstaben sind böse!

Abb. 1: Großbuchstaben sind böse!

Wem gehört was?

Sicherheitslücken und Race Conditions entstehen oft durch unklare Besitzverhältnisse: Wenn eine Routine nicht weiß, was ihre Kollegin mit einem geteilten Datenobjekt anstellt, so entsteht Heckmeck. Die einfachste Absicherung ist in einer Abart von playSomeVars gezeigt – sie versucht, eine nicht als mutabel gekennzeichnete Variable nach der Erstellung zu beschreiben. Rust reagiert auf Kompilationsversuche mit der Anzeige einer nach dem Schema „re-assignment of immutable variable“ aufgebauten Fehlermeldung:

fn playSomeVars() 
{ 
  . . .
  let an_int:int=128; 
  an_int=25; 
}

Per let deklarierte Variablen sind unveränderbar, Änderungen am in ihnen gespeicherten Wert werden vom Compiler gnadenlos abgelehnt. Zur Umgehung dieses Problems genügt es, das Feld nach folgendem Schema als veränderbar zu markieren:

fn playSomeVars() 
{ 
  . . . 
 let mut an_int:int=128; 
an_int=25; 
}

Für die Vorführung der Eigentümerüberprüfung müssen wir ein weiteres neues Sprachelement einführen. Das folgende Statement weist Rust zur Deklaration eines Structs an, das – ganz analog zu C und C++ – aus diversen Member-Variablen besteht:

#![allow(non_snake_case)]
struct MyField{
  aNumber:i32,
  aText:char
}

Strukturen unterscheiden sich aus syntaktischer Sicht nur unwesentlich von C und Java. Neben angepassten Typnamen sticht eine Umkehrung der Reihenfolge von Variablenname und Typdeklaration ins Auge. Die Nutzung des Allow-Befehls sorgt dafür, dass der Compiler das permanente Ausspeien der weiter oben gezeigten Fehlermeldung unterlässt.

Im nächsten Schritt versuchen wir uns an der Kompilation des folgenden Codestücks (Listing 1).

fn testSomething()
{
  let mut sA;
  sA=MyField{aNumber: 22, aText:'x'};
  let sB;
  sB=&sA;
  sA.aNumber=32;
}

Der Compiler zeigt sich von diesem Snippet nicht sonderlich beeindruckt: Statt Objektcode bekommen Sie eine Fehlermeldung vom Typ „cannot assign to ’sA.aNumber’ because it is borrowed“. Dies liegt daran, dass Rust das Mater-Element eines Pointers automatisch als immutabel markiert – „nebenläufige“ Veränderungen des dort gespeicherten Werts werden dadurch erschwert.

Eine korrekte Version des Snippets trüge den Zeiger sB ab, um die Nutzung von aNumber wieder freizugeben. Dies lässt sich durch eine Closure bewerkstelligen (Listing 2).

fn testSomething()
{
  let mut sA;
  sA=MyField{aNumber: 22, aText:'x'};
  {
    let sB;
    sB=&sA;
  }
  sA.aNumber=32;
}

Rusts Eigentümerüberprüfung sorgt an anderer Stelle für weiteren Ärger. Bei per „shallow copy“ übertragenen Elementen geht das Eigentum am Inhalt beim Weitergeben eines Zeigers an den neuen Besitzer über – in folgendem Beispiel ist xs nach der Zuweisung an ys nicht mehr ansprechbar:

#enum iWas {
# . . .
# }
let mut xs = Nil;
let ys = xs

String und String

C bildet Zeichenketten durch Arrays ab. Im Laufe der Zeit entstanden diverse Formate, die Entwicklern mehr Spielraum in Sachen Handling und Allokation boten. Java und Co. führten fortgeschrittene Klassen ein, die diverse Manipulationslogik samt Overhead mitbringen.

Rust hindert Sie nicht daran, eine Zeichenkette in Form eines Arrays anzulegen. Die Sprache kennt zwei weitere Abstraktionen, die schon allein aufgrund ihrer Verwendung in anderen Programmen relevant sind.

Aus Literalen entstehende Zeichenketten sind immer vom Typ str&. Diese auch als String Slice bezeichneten Datentypen stellen einen Verweis auf den im Rahmen der Kompilation angelegten Stringspeicher dar, der – naturgemäß – nicht veränderbar ist:

let einString="Was für ein String";

Strings sind im Heap allozierte Zeichenketten, die immer im UTF-8-Format vorliegen und zum Wachstum befähigt sind. Die Grundlagen ihrer Verwendung sind in folgendem Snippet illustriert:

fn stringHaus()
{
  let einString="Was für ein String";
  let mut echterString=einString.to_string();
  echterString.push_str(" Oh Wow!");
}

Am Heap liegende Strings lassen sich über den &-Operator in stringslices umwandeln – es handelt sich dabei um eine vergleichsweise preiswerte Operation. Der Weg in die Gegenrichtung setzt eine Speicherallokation voraus und sollte aus Performancegründen nicht allzu häufig vorkommen. Weitere Informationen zu den in der String-Klasse implementierten Funktionen finden Sie in der Dokumentation hier.

Matcher, Matcher!

Es gibt nur wenige Programme, die komplett ohne Logik zur Verarbeitung von Zeichenketten auskommen. Regular Expressions sind eine große Hilfe bei der Erstellung von Matchern: Wer einmal von Hand Parser zusammengebaut hat, weiß über die Unergiebigkeit dieser Aufgabe bestens Bescheid. Rust begegnet diesem Problem durch eine Selektion, die am aus Java und C bekannten Switch-Statement angelehnt ist und auf den Namen match hört. Die an sie zu übergebenden Parameter können – nomen est omen – auf Wunsch von einem Matcher verarbeitet werden, der textuelle Vergleiche erleichtert (Listing 3).

fn main() 
{
  let wert = 13;
  let mut astring;
  match wert {
    1 => astring="Eins erkannt",
    2 | 3 | 5 | 7 | 11 => astring="Primzahl erkannt",
    13...19 => astring="Bereich erkannt",
    _ => astring="Etwas anderes",
  }
  println!("{}",astring);
}

Für von C++ oder Java umsteigende Entwickler sind hier zwei Neuerungen relevant. Erstens gibt es kein Fallthrough mehr. Zweitens werden die einleitenden Statements wie in einer If-Selektion interpretiert – es findet sich eine Vielzahl von Beispielen, die Match-Statements für diverse mögliche und unmögliche Szenarien zweckentfremden.

Das manuelle Setzen des zu retournierenden Strings ist nicht unbedingt notwendig. In Rust ist jeder Ausdruck ein zulässiger rvalue. In von Profis geschriebenem Code finden sich häufig Selektionen, die nach dem in Listing 4 dargestellten Schema zur Zurückgabe von Werten adaptiert werden.

fn main() 
{
  let wert = 13;
  let astring= match wert {
    1 => "Eins erkannt",
    2 | 3 | 5 | 7 | 11 => "Primzahl erkannt",
    13...19 => "Bereich erkannt",
    _ => "Etwas anderes",
  };
  println!("{}",astring);
}

An dieser Stelle sind mehrere Veränderungen beachtenswert. astring muss nicht mehr als mutabel deklariert werden, da der von Match zurückgegebene Wert direkt im Rahmen der Initialisierung verwendet wird. Neuerung Nummer zwei betrifft das „Absetzen“ des einzuschreibenden Strings – er bleibt einfach „stehen“. Zu guter Letzt sollten Sie darauf achten, dass der Match-Block mit einem Semikolon abgeschlossen werden muss – aus syntaktischer Sicht sieht die Zeile nämlich so aus:

let astring= ;

Enums mit Typüberprüfung

Amerikanische Softwareexperten bezeichnen Quellcode gerne als Würfel aus ballistischem Gel: eine Vergrößerung der Codemenge geht mit einer Reduktion der Wartbarkeit einher. Eine achtlos durchgeführte Erweiterung einer Enum wird vom C-Compiler nicht in die diversen Zustandsautomaten übertragen – wenn der Entwickler dies in der Hektik vergisst, so droht undefiniertes Verhalten.

Rust begegnet diesem Problem durch diverse Absicherungen, die bei Änderungen in Enum Compilerfehler auslösen. Als erstes Beispiel wollen wir uns ein Programm ansehen, das einem Enum im Rahmen eines Matchers einen dort nicht deklarierten Wert zu analysieren sucht (Listing 5).

enum MyEnum
{
  Ilyushin,
  Mikoyan,
  Tupolev
}

fn main() 
{
  let x:MyEnum=MyEnum::Ilyushin;
  match x
  {
    MyEnum::Ilyushin=> (),
    MyEnum::Mikoyan=>(),
   25=>()
  }
}

match gibt diesmal () zurück, um den Compiler darüber zu informieren, dass das Statement nur ob seiner – hier inexistenten – Nebeneffekte ausgeführt wird. Das Abfragen des nicht möglichen Werts 25 wird vom Compiler derweil mit einem Fehler der Bauart „mismatched types“ bestraft.

X muss vor der Übergabe an Match initialisiert werden: Der Compiler weist während der Kompilation auf nicht initialisierte Variablen hin.

Ein zweiter – und wesentlich bösartigerer – Bug entsteht bei der Nichtbehandlung von neu eingepflegten Cases (Listing 6).

fn main() 
{
  let x:MyEnum=MyEnum::Ilyushin;
  match x
  {
    MyEnum::Ilyushin=> (),
    MyEnum::Mikoyan=>()
  }
}

Das hier gezeigte Aufbrechen einer Enum wird in der Rust-Szene gern als „destructuring of an enum“ bezeichnet – in realem Code würde () durch Strings oder ähnliche Werte ersetzt.

Leider quittiert rustc Kompilationsversuche mit „non-exhaustive patterns: ’Tupolev’ not covered“. Auf gut Deutsch bedeutet dies, dass Enum ein Element enthält, das nicht durch einen Arm der Match-Selektion abgedeckt ist.

Leider greift dieser Schutz nur dann, wenn alle Teile von Enum namentlich vorkommen. Das Verwenden des Catchall-Operators _ blockiert die Funktion – die folgende Selektion wird prinzipbedingt nicht auf Komplettheit überprüft:

match x
{
  MyEnum::Ilyushin=> (),
  MyEnum::Mikoyan=>(),
  _=>()
}

Box und Co

Nach diesen syntaktischen Feinheiten wollen wir uns wieder in Richtung „härterer“ Themen begeben. Das weiter oben angerissene Ownership-Modell nimmt im Leben des Rust-Programmierers eine höchst wichtige Rolle ein. Fehler im Bezug auf Borrowing und Co. zählen in der Anfangsphase zu den häufigsten Stolpersteinen.

Zum Verständnis der Situation müssen wir einen kleinen Exkurs unternehmen. Speicherallokationen retournieren in C++ einen Zeiger, der auf den angeforderten Speicherbereich verweist. Er beinhaltet keine „Eigentümerschaft“ – wenn er aus dem Stack verschwindet, so bleibt der Speicher reserviert.

Rust löst dieses Problem durch Boxen. Es handelt sich dabei um eine Hilfsstruktur, die sowohl die Position als auch die Besitzerschaft an einem Speicherblock ausdrückt. Seine Abtragung sorgt dafür, dass der verbundene Speicher mitabgetragen wird.

Die einfachste Nutzung einer Box beginnt mit dem Aufruf der new-Methode. Sie übernimmt einen Initialisierungswert, der den Inhalt des Heap-Speicherbereichs festlegt. In unserem Fall handelt es sich um eine Box, die eine MyEnum enthält. Wir könnten stattdessen auch ein Struct oder eine float-Allokation anlegen:

fn main() 
{
  let x=Box::new(MyEnum::Ilyushin);
}

Boxen lassen sich an Funktionen übergeben. Die dafür notwendige Syntax ist nicht besonders kompliziert – workWithX könnte die in main() erstellte Box entgegennehmen:

fn workWithX(mut num:Box)
{

}

Bei der praktischen Nutzung derartiger Methoden tritt ein logisches Problem auf. Dies zeigt sich in folgendem Snippet, das den Inhalt der Box durch die setTupolev-Methode in eine Tupolev umwandelt (Listing 7).

fn setTupolev(mut num:Box)
{
  *num=MyEnum::Tupolev;
}

fn main() 
{
  let x=Box::new(MyEnum::Ilyushin);
  setTupolev(x);
  match *x
  {
    MyEnum::Tupolev=>println!("Tu erkannt"),
    _=> ()
  }
}

Boxen geben ihren Inhalt durch Nutzung des *-Operators frei – unser Programmbeispiel sollte den Typ von x an unsere Bedürfnisse anpassen. Leider funktioniert dies nur in der Theorie, da rustc die Kompilation mit dem Fehler „use of moved value: ’*x’ abbricht.

Zum Verständnis der Situation müssen wir uns in die Rolle der Laufzeitumgebung hineinversetzen. Im Rahmen des Aufrufs von setTupolev wird die in x enthaltene Referenz in den Stackframe der Methode geschrieben. Nach dem Abtragen des Frames steht der Garbage Collector vor einer unlösbaren Aufgabe: Gehört die Box der abzutragenden Funktion oder ihrem Aufrufer?

Java und Co. lösen dieses Problem durch die Analyse des Speichers: ein Feature, das trotz seiner immensen Nützlichkeit negative Auswirkungen auf Performance und Vorhersehbarkeit entfaltet. Die Rust-Entwickler bieten hier eine andere Abstraktion an. Im Rahmen des Kopierens wechselt das Eigentum am Speicherbereich zum Empfänger, weshalb der alte Zeiger als ungültig deklariert werden muss.

Ein einfacher Weg zur Behebung ist das Zurückgeben der Box. Die neue Version von setTupolev schreibt die Boxreferenz in den Rückgabespeicher. Im Rahmen der Abtragung ihres Stackframes trifft der Garbage Collector nur auf den num-Pointer, der die Boxdaten nicht mehr enthält (Listing 8).

fn setTupolev(mut num:Box) -> Box
{
  *num=MyEnum::Tupolev;
  num
}

fn main() 
{
  let mut x=Box::new(MyEnum::Ilyushin);
  x=setTupolev(x);
  . . .
}

Das Hin- und Herwerfen von Boxen verfettet den Code – die Methode setTupolev wächst um 50 Prozent und wird zudem um die Möglichkeit des Zurückgebens eines anderen Rückgabewerts beschnitten.

Rust löst dieses Problem durch Borrowing. Ein per Borrowing übergebener Parameter geht kurzfristig in das Eigentum des Aufgerufenen über, um nach dessen Ableben wieder zum Aufrufer zurückzukehren. setTupolev sähe dann so wie in Listing 9 aus.

fn setTupolev(num: &mut MyEnum)
{
  *num=MyEnum::Tupolev;
}

fn main() 
{
  let mut x=Box::new(MyEnum::Ilyushin);
  setTupolev(&mut x);
  . . . 
}

An dieser Stelle findet sich eine kleine Besonderheit. Die Erstellung eines mutablen „Zeigers“ erfolgt durch das etwas umständlich aussehende Kommando &mut.

Boxen sind nicht nur als Basis für Pointer wichtig. Auf sich selbst verweisende Elemente lassen sich nur über den Umweg einer Box erstellen. In C oder Java würde die folgende Struktur eine hervorragende Basis für eine Linked List abgeben – rustc verweigert ihre Ausführung mit der Fehlermeldung „illegal recursive struct type“:

struct MyElement
{
  isEnd:bool,
  aNumber:i32,
  aNextValue:

}

Die Rust-kompatible Version verpackt die Selbstreferenz in eine Box, was zu folgendem Code führt:

struct MyElement
{
  aNumber:i32,
  aNextValue:Option<Box>

}

Neben der für die Abstraktion zuständigen Box muss aNextValue zudem in eine Option eingefasst werden. Es handelt sich dabei um eine nullbare Klasse, die im Rahmen der Initialisierung unberührt bleiben kann. Wäre aNextValue eine normale Box, so könnte man MyElement nie instanzieren. Zur Erstellung ist ein Initialwert notwendig, der sich in Ermangelung eines Konstruktors aber nirgendwo beschaffen lässt.

In komplexen Programmen kann es hilfreich sein, die Lebensdauer von Objekten von der ihrer Referenzen aufzutrennen. Weitere Informationen dazu finden sich hier im Abschnitt Lifetimes.

Von Charakterzügen

Im Rahmen der Erklärung der bisherigen Beispiele findet sich eine kleine Ungenauigkeit: Rust kennt keine „echten Klassen“. Fachautoren streiten seit Jahr und Tag darüber, ob die Sprache funktional oder objektorientiert ist – nach der Meinung des Autors haben wir es mit einem Hybriden zu tun.

Wir wollen die OOP-Fähigkeiten der Sprache anhand eines kleinen Beispiels evaluieren. Im ersten Schritt erstellen wir eine Struktur, die ein geometrisches Rechteck durch seine vier Parameter Höhe, Breite und Ursprung abbildet:

struct Rechteck{
  x: f64,
  y: f64,
  h: f64,
  w: f64
}

Die vier Koordinaten erlauben die Berechnung der Fläche des Objekts. Dazu müssen wir der Struktur eine Methode einschreiben – der dazu notwendige Code sieht so aus:

impl Rechteck {
  fn area(&self) -> f64 {
    self.h*self.w
  }
}

impl-Blöcke ergänzen Elemente um Untermethoden. In unserem Fall wird das Rechteck-Struct um eine Methode erweitert, die die Fläche durch einfache Multiplikation ermittelt.

Der Methodenkörper der einzuschreibenden Funktion kann einen von drei Parametertypen entgegennehmen. &self ist die am weitesten verbreitete Variante: &mut self übergibt ein Mutabel, während self einen Wert vom Stack entgegennimmt.

Rust-Entwickler setzen in komplexen Projekten gerne auf Method Chaining: Eine Funktion, die keinen Rückgabewert anliefern muss, sollte sich im Idealfall nach dem Schema objekt.a().b().c() verketten lassen. Dies lässt sich durch Retournieren von self bewerkstelligen – geben Sie den angelieferten Wert einfach an den Aufrufer zurück.

Die Sprache kennt auch statische Methoden. Sie werden gern zur Realisierung von Konstruktoren eingesetzt. Wir erweitern unser Rechteck um eine Methode, die die Koordinaten von zwei Ecken zur Ermittlung der im Objekt zu speichernden Elemente verwendet und die passende Instanz zurückgibt:

impl Rechteck{
  fn new(x:f64, y:f64, x2:f64, y2:f64) -> Rechteck
  {
    Rechteck{
      x:x,
      y:y,
      w:x2-x,
      h:y2-y
    }
  }
}

Rechecke lassen sich dann nach folgendem Schema errichten:

fn main() 
{
  let c=Rechteck::new(0.0,0.2,2.2,2.2);
}

Interfaces herbei

Im Bereich der objektorientierten Programmierung sind Interfaces von eminentester Bedeutung. Sie erlauben die Realisierung von Methoden, die Objekte verschiedener Typen entgegennehmen können.

Wir wollen für die folgenden Schritte davon ausgehen, dass alle Körper eine „Oberkante“ aufweisen. Diese soll von einer oberkante-Funktion bereitgestellt werden, die in allen Körpern zu implementieren ist.

Rust bildet diese Situation durch traits ab. Auf Deutsch bedeutet der Begriff „trait“ soviel wie Eigenschaft oder Charakterzug; es handelt sich dabei aber um ein mehr oder weniger normales Interface:

trait IstKoerper{
  fn oberkante(&self)->f64
  ;
}

Traits können einen oder mehrere Funktionen enthalten, wir beschränken uns an dieser Stelle aus Bequemlichkeitsgründen auf eine einzige Methode. Sie lässt sich danach gemäß folgendem Schema implementieren:

impl IstKoerper for Rechteck{
  fn oberkante(&self)->f64{
    self.x
  }
}

In besonders komplexen Programmen ist es hilfreich, Traits mit Default-Logik zu versehen. Dies erfolgt nach folgendem Schema – die Implementierung von bar ist hier verpflichtend, während eine nicht implementierte baz-Funktion bei Bedarf durch die im Trait angebotenen Methode ersetzt wird:

trait Foo {
  fn bar(&self);
  fn baz(&self) { println!("We called baz."); }
}

Im Rust-Reddit gibt es einen hoch interessanten Thread, der diverse Abstraktionen und Design Patterns vorstellt.

Paralellisiere mich

Race Conditions sind die häufigste Fehlerquelle in parallelisierten Programmen. Rust behebt dieses Problem auf eine radikale Weise: Die weiter oben beschriebenen Speicherschutzregeln gelten auch für Threads in vollem Umfang. Datentypen können sich als versendbar bezeichnen, was den Compiler darüber informiert, dass sie beliebig zwischen Threads hin- und hergeschoben werden.

Die Kommunikation zwischen Threads erfolgt durch als Channel bezeichnete Synchronisationsprimitiva, die wie eine Art Socket funktionieren. Weitere Informationen hierzu finden sich – unter anderem – hier.

Vom Cargo-Kult

Kleine Projekte lassen sich ohne großen Aufwand von Hand kompilieren. Ab einer gewissen Programmgröße ist es sinnvoller, Kompilation und Verwaltung an ein dediziertes Managementsystem zu übertragen.

In der Rust-Community hat sich ein als Cargo bezeichnetes Werkzeug als Standard etabliert. Das Programm nimmt in TOML gehaltene Dateien entgegen, die den Umfang und die Art des Projekts beschreiben – die eigentliche Kompilation erfolgt dann durch passende Kommandozeilenbefehle.

Mehr lernen

Wie schon in den letzten Ausgaben dieser Sprachenenzyklopädie ist es uns auch diesmal nicht möglich, die Programmiersprache Rust im Rahmen eines Artikels komplett abzubilden. Interessante Features wie die immens leistungsfähigen Makros konnten hier nicht zur Sprache kommen, während andere Funktionen aus Platzgründen nur oberflächlich behandelt sind.

Das einsehbare Book of Rust stellt alle Features in kompakter Form vor – leider gibt es im Moment keine einfache Möglichkeit, um die Webseite in Form eines PDFs herunterzuladen. Die Website http://rustbyexample.com/ orientiert sich nur grob an der Struktur des Book of Rust, enthält aber eine Vielzahl illustrativer Codesnippets.

Der bei der für die Entwicklung von Rust zuständigen Mozilla Foundation angestellte Tim Chevalier bietet ein PDF an, das auf die Unterschiede zwischen Rust und C++ eingeht.

Fazit

Rust steht zu C wie ein militärischer zu einem zivilen Quadrocopter. Während ersterer seinem Besitzer waghalsigste Flugmanöver unter feindlichem Beschuss ermöglicht, bietet zweiterer seiner Kundschaft ein sicheres und eingeschränktes Flugerlebnis.

Die praktische Erfahrung lehrt, dass auch sehr erfahrene und perfekt ausgebildete Entwickler zu diversen Fehlern neigen. Rust schafft an dieser Stelle schon allein insofern Abhilfe, als es die Realisierung von kritischem Code erschwert. Bug- und Race-Condition-freie Routinen gelingen dank Compilerunterstützung auch dem Greenhorn.

Entwickler Magazin

Entwickler Magazin abonnierenDieser Artikel ist im Entwickler Magazin erschienen.

Natürlich können Sie das Entwickler Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.

Aufmacherbild: metal rust background via Shutterstock / Urheberrecht: komkrit Preechachanwate

Unsere Redaktion empfiehlt:

Relevante Beiträge

Abonnieren
Benachrichtige mich bei
guest
1 Kommentar
Inline Feedbacks
View all comments
trackback

[…] Lesen Sie auch unsere Einführung zu Rust: Wie Rust durch Eliminierung kritischer Funktionen Sicherheit schafft […]

X
- Gib Deinen Standort ein -
- or -