Programmieren in Rust

Regionale Typen

Inhaltsverzeichnis

  1. Subtyp-Beziehung zwischen Regionen
  2. Varianz
  3. Untersuchungen zur Varianz

Subtyp-Beziehung zwischen Regionen

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.

Varianz

Ein Typkonstruktor kann sich bezüglich einer Subtyp-Relation unterschiedlich verhalten. Man muss hier drei Fälle unterscheiden:

  1. Bleibt die Subtyp-Relation bei der beidseitigen Anwendung des Typkonstruktors F erhalten, will heißen, S ≤: T impliziert FS⟩ ≤: FT⟩, bezeichnet man diesen als kovariant.
  2. Dreht sich die Subtyp-Relation bei der beidseitigen Anwendung des Typkonstruktors F um, will heißen, S ≤: T impliziert FT⟩ ≤: FS⟩, bezeichnet man diesen als kontravariant.
  3. Geht die Subtyp-Relation bei der beidseitigen Anwendung des Typkonstruktors verloren, bezeichnet man diesen als invariant oder nonvariant.

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.

Untersuchungen zur Varianz

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.