↑Programmieren in Rust
Betrachten wir einmal das folgende Programm.
fn main() { let s0: &str = "Zeichenkette 0"; { let s1: &str = s0; println!("{}", s1); } }
Das Programm ist kompilierbar, denn s0
existiert
länger als s1
.
Man definiert die folgende Relation zwischen Lebenszeiten:
'b: 'a
bedeute, 'b
dauert mindestens so lang an wie 'a
,
kurz
'b: 'a
bedeute, 'b
überlebt 'a
.
Man kann sich das auch als 'lang: 'kurz
merken.
Das aufgezeigte Programm wird nun mit Lebenszeiten annotiert.
fn main() { let s0: &'0 str = "Zeichenkette 0"; { let s1: &'1 str = s0; println!("{}", s1); } }
Gemäß der Definition gilt '0: '1
. Damit aber die
Zeile
let s1: &'1 str = s0;
korrekt ist, muss der Typ &'0 str
in
&'1 str
überführt werden können. Die Beziehung
zwischen diesen Typen ist eine Subtyp-Beziehung
&'0 str ≤: &'1 str.
Es stellt sich nämlich heraus, dass ein Typ mit langer Lebenszeit überall dort eingesetzt werden darf, wo der entsprechende Typ kurzer Lebenszeit erwartet wird, womit das Liskov-Prinzip erfüllt ist.
Im nächsten Schritt werden die Lebenszeiten ebenfalls als Typen
aufgefasst. Allerdings handelt es sich dabei um eine andere Art
von Typ, weil eine Lebenszeit keinesfalls an einer Stelle eingesetzt
werden darf, die einen gewöhnlichen Typ verlangt. Wir schreiben nun
'b ≤: 'a
anstelle von
'b: 'a
. Das heißt, wir betrachten
eine längere Lebenszeit als Subtyp einer kürzeren. Unter dieser
Sichtweise findet man,
'b ≤: 'a
impliziert &'b T ≤: &'a T
für jeden Typ T
. Demnach ist &'a T
kovariant bezüglich 'a
. Genau genommen ist Kovarianz
eine Eigenschaft eines Typkonstruktors. Gemeint ist also, dass sich der
durch
type Ref<'a, T> = &'a T;
definierte Typkonstruktor Ref
kovariant bezüglich
des Lebenszeit-Parameters verhält.
Ein Typkonstruktor kann sich bezüglich einer Subtyp-Relation unterschiedlich verhalten. Man muss hier drei Fälle unterscheiden:
Besitzt der Typkonstruktor mehr als ein Argument, kann die Varianz je Argument unterschiedlich ausfallen.
Eine Erklärung, warum Varianz wichtig ist, liefert die folgende Diskussion. Zunächst einmal seien die beiden Enumerationen
enum E {V0, V1, V2} enum S {V0, V1}
definiert. Wir erweitern das Typsystem nun dergestalt, dass
S
ein Subtyp von E
wird, wobei
E::V0 == S::V0
und E::V1 == S::V1
gelten soll.
Man darf einen Wert vom Typ S
nun überall dort
einsetzen, wo ein Wert vom Typ E
erwartet wird.
Liegt beispielsweise eine Funktion fn f(x: E) {}
vor,
ist der Aufruf f(S::V0)
ein gültiger Term.
Man kann außerdem sagen, dass bezüglich
fn f(x: &E) {}
der Aufruf f(&S::V0)
ein gültiger Term ist. Dass man mit solchen Aussagen aber vorsichtig
sein muss, zeigt das folgende pathologische Beispiel.
fn f(x: &mut E) { *x = E::V2; } let mut s: S = S::V0; f(&mut s);
Dieses Programm schreibt den Wert E::V2
in eine Variable
des Typs S
, was für diesen Typ jedoch ein ungültiger
Wert ist. Um dieses Missverhältnis auszuschließen, muss der als
type RefMut<'a, T> = &'a mut T;
definierte Typkonstruktor RefMut
im zweiten Argument
invariant sein.
Des Weiteren verhalten sich Funktionentypen in bestimmter Weise bezüglich Subtypen. Betrachen wir dazu:
fn f(cb: fn(S)) {cb(S::V0)} fn cb0(x: E) {} f(cb0)
Dieses Programm darf man als gültig befinden, denn es stellt kein
Problem dar, wenn cb0
ein Argument vom
Typ S
bekommt. Indes besitzt cb0
den
Typ fn(E)
. Wie es scheint, führt
S
≤: E
zu
fn(E)
≤: fn(S)
.
Andererseits darf man das Programm
fn f(cb: fn() -> E) -> E {cb()} fn cb0() -> S {S::V0} f(cb0)
als gültig befinden. Demzufolge sollte
S
≤: E
zu
fn()->S
≤: fn()->E
führen. Wir kommen insgesamt zum Schluss, dass der gemäß
type Fun<X, Y> = fn(X) -> Y;
definierte Typkonstruktor Fun
kontravariant im ersten
Argument und kovariant im zweiten Argument ist. Oder sein darf.
Die bisweilen einzige uns bekannte Subtyp-Relation betrifft die Lebenszeiten. Das bedeutet allerdings mitnichten, dass die gemachten Erwägungen lediglich auf Lebenszeit-Parameter zutreffen würden. Der Wert eines Typkonstruktors ist nämlich ein gewöhnlicher Typ, wodurch sich die Subtyp-Relation auf gewöhnliche Typen fortsetzt.
Ein Typkonstruktor, der Lebenszeiten auf gewöhnliche Typen abbildet, wird als Hilfsmittel dienen:
struct P<'a>(&'a u8);
Es ist nun ein Kontext herzustellen, wo zwei Variablen
unterschiedliche Lebenszeiten aufweisen. Dies wäre durch Bildung
eines kürzeren Scopes erreichbar. Es geht aber auch
explizit mit einer wobei-Klausel. Dem Programm darf
#![allow(unused)]
vorangestellt werden, um
pedantische Warnungen abzustellen, die für die nachfolgenden
Betrachtungen nebensächlich sind.
fn context<'a, 'b>(mut a: P<'a>, mut b: P<'b>) where 'b: 'a { a = b; }
Das dargelegte Programm ist kompilierbar, obwohl a
und b
unterschiedlichen Typ besitzen. Die Zuweisung
a = b
ist generell nur dann formulierbar, wenn der Typ von
b
ein Subtyp des Typs von a
ist, oder wenn
eine koerzitive Typumwandlung zwischen den Typen existiert.
Der Compiler ist sich also über die Subtyp-Relation und die Kovarianz
von P
bewusst. Ebenso ist
fn context<'a, 'b, 'c>(mut a: &'c P<'a>, mut b: &'c P<'b>) where 'b: 'a { a = b; }
kompilierbar, weil &'c T
bezüglich
T
kovariant ist. Das Programm
fn context<'a, 'b, 'c>(mut a: &'c mut P<'a>, mut b: &'c mut P<'b>) where 'b: 'a { a = b; }
stellt hingegen kein kompilierbares dar, denn
&'c mut T
ist invariant bezüglich T
.
Aufgrund der besagten Kontravarianz schlägt zudem die Kompilierung von
fn context<'a, 'b>(mut a: fn(P<'a>), mut b: fn(P<'b>)) where 'b: 'a { a = b; }
fehl. In der umgedrehten Reihenfolge, also b = a
,
ist der Compiler aber wie erwartet zufrieden. Im Übrigen ist der
Compiler mit
fn context<'a, 'b>(mut a: fn() -> P<'a>, mut b: fn() -> P<'b>) where 'b: 'a { a = b; }
zufrieden.
Wir sehen also, dass die gemachten theoretischen Erwägungen allesamt zutreffen.