Rust 1.33 erschienen plus Einführung in const fn

Rostige Zahnräder (Anlehnung an Rust)

Rust ist in einer neuen Version erschienen. Version 1.33 bringt einige Änderungen insbesondere in Bezug auf sog. const fn-Funktionen mit. In diesem Blogartikel möchte ich ein wenig hierauf eingehen.

Verbesserungen bei const fn

const fn erhielt die Möglichkeit, let-Bindings (auch mutable) zu beinhalten sowie Wertzuweisungen auszuführen. Um ein wenig die Bedeutung von const fn zu erläutern, hier ein kleiner Exkurs:

const fn wurde mit Rust 2018 (v1.31) eingeführt kann immer dann eingesetzt werden, wenn Funktionen bereits zur Kompilierungszeit (compile time) ausgewertet werden sollen. Aber was bedeutet das? Schauen wir uns ein kleines Beispiel aus C an, um ein Gefühl dafür zu bekommen:

Kleiner C-Exkurs

#include <stdio.h>

int main()
{
	/* Define variable by precise value */
	int a = 1;
	int b = 9;

	/* Define variable by expression */
	int c = a + b;

	/* print to stdout */
	printf("%d", c);

	/* return with successful return code */
	return 0;
}

Diesen Quelltext werden wir nun ohne jegliche Optimierungen (-O0) kompilieren, aber den Zwischenschritt vor der Assemblierung genauer anschauen (konkret: dort stoppen; -S):

gcc -S -O0 demo.c

In der demo.s befindet sich jetzt der Assembler-Source. Relevant sind hier die folgenden Zeilen:

movl	$1, -12(%rbp)
movl	$9, -8(%rbp)
movl	-12(%rbp), %edx
movl	-8(%rbp), %eax
addl	%edx, %eax
movl	%eax, -4(%rbp)
movl	-4(%rbp), %eax
movl	%eax, %esi
leaq	.LC0(%rip), %rdi
movl	$0, %eax
call	printf@PLT

Hieran lässt sich eindrucksvoll zeigen, dass bei Programmausführung wirklich erst die Zahlen 1 und 9 in die entsprechenden Register geladen und dann addiert werden. Jetzt kann die Frage auftauchen, warum der Compiler nicht selber erkennen kann, dass er doch sowieso nur mit der Variable c arbeiten muss, die ja in diesen Beispiel offensichtlich 10 ergibt und so Rechenschritte bei der Ausführung sparen könnte. Ja, genau das macht die Optimierung, die wir gerade noch abgeschaltetet haben. Mit

gcc -S -O3 demo.c

sieht die entsprechende Stelle ganz anders aus:

movl	$10, %esi
leaq	.LC0(%rip), %rdi
xorl	%eax, %eax
call	printf@PLT

Hier wird der Code sauber erkannt und kann bereits so optimiert werden, dass die Zwischenschritte der Addition entfallen. Voraussetzung ist natürlich, dass die Werte zur compile time feststehen. Interaktive Nutzereingaben machen solch eine Optimierung natürlich unmöglich.

Was hier als Optimierungsschritt erscheint, lässt sich auch mit System betrachten: Funktionen, die bereits während der Kompilierung zu einem Ausgabewert zerfallen können, verbrauchen während der Ausführungszeit weniger Ressourcen, es entfallen Aufrufe und Rücksprünge. Genau das ist einer der Einsatzszenarien für const fn. Unter C++ ist ein ähnliches Konstrukt als constexpr seit C++11 bekannt.

const fn-Funktionen kommen neben der Performanceoptimierung auch dann zum Einsatz, wenn ihre Ausgabe schon während der Kompilierung bekannt sein muss, z.B. bei der Berechnung der Länge von Arrays. Wer öfter mit dem Compiler zu tun hatte, wird merken, dass aufgrund der statischen Typisierung i.d.R. die Größe von Ausgaben während der Kompilierung bereits bekannt und konstant sein muss (d.h. der Ausgabetyp muss das Sized-Trait implementieren). Wenn das nicht möglich ist, muss als Workaround wenigstens der Pointer auf den (langsameren) Heap eine fixe Länge haben (s. Box).

Dass diese const fn-Funktionen 4 Monate nach ihrer Einführung stetig erweitert werden, sehe ich als sehr sinnvoll an.

Pinning

Ein von mir heiß erwartetes Feature ist endlich stable geworden: Pinning. Hierzu muss man im Hinterkopf haben, dass Rust im Gegensatz zu C(++) den Einsatz von manuellen Pointern konzeptionell massiv zurückdrängt. Das Management von Pointern soll Rust übergeben werden, wodurch Boilerplate-Code vermieden werden soll. C++-Entwickler werden dies mitunter von unique_ptr kennen. Der Nachteil bei Rust: einige Aufgaben wie komplexerere Datenstrukturen sind oftmals mit C schneller umgesetzt oder könnten in Rust nur mit direktem Zugriff auf die Pointer über die unsafe-Blöcke umgesetzt werden.

Als einfaches Beispiel dienen selbstreferenzierende structs, die durch die Eigenreferenz im Speicher verschoben (move) und invalidiert werden. Hier bestand lange Zeit der Wunsch, solche Speicherbereiche zu fixieren, also anpinnen zu können, was durch das Pin module nun auch im Stable-Code ermöglicht wird. Dies wird die Arbeit vieler Autoren der Bibliotheken erleichtern, da hier solche Szenarien öfter vorkommen. Mir ist so ein Szenario in letzter Zeit beim Threading bzw. der Arbeit mit tokio-rs untergekommen und hier wird die Reise auch hingehen, denn dieses Feature ist ein Meilenstein bei der Entwicklung von async und await für Rust, die unter areweasyncyet.rs verfolgt werden kann.

Underscore imports

Rust-Einsteiger werden merken, dass unter Rust ein etwas anderes Verständnis von Objektorientierung vorherrscht. So werden die Daten (struct) von deren Methoden (impl) optisch wie auch semantisch getrennt und nicht in einer Einheit wie z.B. class unter Python mit Eigenschaften/Properties und Methoden vereint.

Gemeinsame Methoden werden in traits (Fähigkeiten) deklariert und können für ein bestimmtes struct implementiert werden. Ergo wird die dahinterliegende Datenstruktur bei der Interaktion für einen Entwickler irrelevant und unsichtbar, er beschränkt sich nur auf die Übergabestellen. Aus diesem Grund werden die Java-Entwickler diese Technik auch unter dem Namen interfaces kennen. Zusammenfassend lässt sich der in Rust vorliegende Ansatz als Composition over inheritance bzw. Komposition an Stelle von Vererbung beschreiben.

Der Entwicklungsalltag ist somit von traits geprägt. Soll mit einem bestimmten struct gearbeitet und auf eine für dieses implementierte trait-Funktion aus einem anderen Modul zurückgegriffen werden, muss das trait über ein use-Kommando in-scope gerufen werden. Das klingt sehr ungewöhnlich, ist aber beim Programmieren logisch. In nebenstehender Grafik habe ich ein wenig das Szenario versucht, grafisch zu umreißen.

Fakt ist: bisher kann dieses Verfahren zu "naming clashes" führen, wenn z.B. die Funktionalität des Read-traits aus der Standard-Lib genutzt, aber gleichzeitig ein eigenes Read-trait im Code definiert werden soll. Ab sofort gibt es hierfür Abhilfe: mit z.B.

use std::io::Read as _;

lässt sich die mit dem Trait verbundene und durch das genutzte struct implementierte Read-Funktionalität nutzen; trotzdem kann nun zusätzlich ein eigenes Read-Trait im Module definiert werden, da durch den underscore import das Trait im Module nicht namentlich bekannt gemacht wird.

Weitere Änderungen

  • Duration kann nun im Stable in kleineren Einheiten als Sekunde ausgeben, benötigt dafür z.B. unsigned 128-bit integer
  • Cargo erkennt und rekompiliert nun, sofern sich während der Kompilierung Dateien ändern
  • viele Libraries wurden weiter stabilisiert

Das Changelog sowie der Blogartikel zum neuen Release sind vom Rust-Team bereits zur Verfügung gestellt worden.

Keine Kommentare

Kommentar verfassen