Programmieren in Rust

Typsystem und Typentheorie

Inhaltsverzeichnis

  1. Produkte von Typen
  2. Summen von Typen
  3. Typisierung von Funktionen
  4. Polymorphe Typen
  5. Existentielle Typen
  6. Rekursive Typen
  7. Literatur

Dieses Kapitel erläutert Beziehungen zwischen dem Typsystem von Rust und der Typentheorie. Das soll der tiefergehenden Einordnung dienen, ist aber für das Verständnis der Programmiersprache und der nachfolgenden Kapitel nicht essentiell.

Produkte von Typen

Typen von Tupeln

Zu zwei Typen XY lässt sich der Typ X×Y bilden, auch (X,Y) geschrieben. Dies ist der Datentyp der Paare (x,y) mit x: X und y: Y.

Die Schlussregel zur Einführung von Paaren ist eigentlich straightforward:

Γ |- x: X,  Γ |- y: Y
───────────────────── (Prod.)
  Γ |- (x,y): (X,Y)

Hinzu kommen Regeln zur Projektion:

Γ |- t: (X,Y)
───────────── (Proj. 0)
 Γ |- t.0: X
Γ |- t: (X,Y)
───────────── (Proj. 1)
 Γ |- t.1: Y

Die Regeln für Produkte von mehr als zwei Faktoren sind analog. Zu beachten ist, dass es einen Unterschied zwischen (X,Y,Z) und (X,(Y,Z)) gibt.

Typen von Strukturen

Strukturen, auch Records genannt, sind Tupel mit frei wählbaren Bezeichnern zur Indizierung. Die Regel zur Einführung einer Struktur mit zwei Einträgen lautet:

    Γ |- x: X,    Γ |- y: Y
───────────────────────────────
Γ |- {a: x, b: y}: {a: X, b: Y}

Entsprechend gibt es wieder Regeln zur Projektion:

Γ |- s: {a: X, b: Y}
────────────────────
    Γ |- s.a: X

Die Bezeichner ab gehören zum Typ dazu. Infolge bedeuten andere Bezeichner einen anderen Typ.

Die Regeln für Strukturen mit mehr als zwei Einträgen sind analog zu den genannten.

Summen von Typen

Für eine Enumeration

enum E{V(T), ...}

gibt es zur jeweiligen Variante E::V die Regel:

   Γ |- x: T
───────────────
Γ |- E::V(x): E

Zur Zerlegung von Enumerationen bedarf es einer weiteren Regel:

             Γ |- t: E
  Γ ∪ {xi: Ti} |- ti: T für jedes i
──────────────────────────────────────
Γ |- match t {E::V0(x0) => t0, ...}: T

In Worten: Der Term t sei vom Typ E. Außerdem sei angenommen, dass wenn jeweils die Variable xi vom Typ Ti ist, dann der zugehörige Term ti vom Typ T. Weil daher jeder der Terme vom gleichen Typ T ist, ist auch der Musterabgleich von diesem Typ.

Typisierung von Funktionen

Zu zwei Typen XY gibt es den Typ X → Y, der alle Funktionen mit Argument vom Typ X und Wert vom Typ Y beschreibt. Z. B. besitzt die Funktion

fn f(x: u32) -> bool {x%2 == 0}

die Typisierung

f: u32 → bool.

Für Funktionen gilt die folgende Schlussregel:

Γ |- f: X → Y,  Γ |- x: X
───────────────────────── (App.)
      Γ |- f(x): Y

Diese Schlussregel nennt man die Regel der Applikation, kurz App. geschrieben. Für den Kontext

Γ := {n: u32, f: u32 → bool}

gilt z. B. die Schlussfolgerung:

1. Γ |- f: u32 → bool
2. Γ |- n: u32
3. Γ |- f(n): bool  (App. 1., 2.)

Polymorphe Typen

Polymorphe Typisierung erkennt man an Typvariablen. Polymorphe Typen sind solche, die über Typvariablen allquantifiziert sind.

Hat ein Term den Typ ∀T.A, bedeutet dies, der Term ist vom Typ A[T:=U] für jeden beliebigen Typ U. Das heißt, der Term hat nicht nur einen Typ, sondern unendliche viele, die durch die Substitution der Typvariable T durch beliebige Typen entstehen. Diese Substitution bezeichnet man als Spezialisierung.

In Rust stehen die Typvariablen in spitzen Klammern. Betrachten wir dazu einmal die identische Funktion, das ist:

fn id<T>(x: T) -> T {x}

Diese Funktion hat die Typisierung

id: ∀T. T → T.

Sagen wir mal, wir haben den Kontext

Γ := {n: u32, id: ∀T. T → T}.

Dann kann man schlussfolgern:

1. Γ |- id: ∀T. T → T
2. Γ |- id: u32 → u32  (1. [T:=u32])
3. Γ |- n: u32          
4. Γ |- id(n): u32  (App. 2., 3.)

Bei Vorhandensein von Trait-Bounds wird das nun ein wenig komplizierter. Betrachten wir dazu:

fn dup<T: Clone>(x: T) -> (T,T) {(x.clone(),x)}

Diese Funktion hat die Typisierung

dup: ∀T∈Clone. T → (T,T).

Eine Substitution T:=U ist nur noch dann durchführbar, wenn U in Clone liegt, das heißt wenn Clone für U implementiert wurde.

Existentielle Typen

Existentielle Typisierung erlaubt es, Wissen über die genaue Struktur eines Wertes zu Vergessen, womit sich abstrakte Schnittstellen erstellen lassen. Dies bildet die theoretische Grundlage für Module und abstrakte Datentypen.

Regel zur Einführung existentiell quantifizierter Typen:

Γ |- t: X[T:=U]
──────────────── (Pack)
Γ |- (U,t): ∃T.X

In Worten: Vorausgesetzt sei, der Term t ist vom Typ X nachdem in X jedes Vorkommen der Typvariable T gegen den Typ U ersetzt wurde. Dann ist das Paar (U,t) vom existentiellen Typ ∃T.X.

Zu bemerken ist dazu noch, dass der Term (U,t) mehrere unterschiedliche existentielle Typen haben kann. Zur eindeutigen Zuordnung schreibt man explizit (U,t) as ∃T.X.

Nun muss man Terme mit existentiell typisierten Variablen auch auswerten können, wofür es an weiteren Regeln bedarf. Wir definieren

unpack (T,x) = (U,t) in s

als s[x:=t][T:=U]. In Worten: Enthält der Term s eine Variable x und ist ein Wert (U,t) gegeben, und möchte man x=t haben, kann man s auswerten, indem jedes Vorkommen von x in s durch t ersetzt wird.

Damit das einen Sinn ergibt, darf für x natürlich kein beliebiger Term ersetzt werden. Zusätzliche Forderung ist x: X. Das führt zu der folgenden Schlussregel:

Γ |- e: ∃T.X,  Γ∪{x: X} |- s: Y,  T ∉ FV(Y)
─────────────────────────────────────────── (Unpack)
      Γ |- (unpack (T,x) = e in s): Y

Der Typ X kodiert die abstrakte Schnittstelle und x: X sagt aus, dass man auf x lediglich Operationen dieser Schnittstelle anwenden darf.

Betrachten wir als Beispiel den Struktur-Typ

X := {a: T; to_string: T → String}.

Ohne die Forderung, dass T nicht in den freien Variablen FV(Y) vorkommen darf, würde die Abstraktion kaputt brechen, womit interne Struktur von x in den Wert von s entweichen würde. Z. B. kann man nicht einfach s:=x.a setzen. Dann wäre ja s: T, also Y=T und daher T ∈ FV(Y).

In Rust ist ∃X.T für die genannte Struktur X als opaker Typ impl ToString modellierbar. Wir betrachten die bereits in der Standardbibliothek definierte Schnittstelle:

trait ToString {
    fn to_string(&self) -> String;
}

Man braucht nun lediglich self := a zu setzen. Beispielprogramm:

fn f(a: i32) -> impl ToString {a}

fn main() {
    let x = f(0);
    println!("{}", x.to_string());
}

Die Formulierung ist äquivalent zu der existenziell quantifizierten Struktur, weil man erstens an den Typ von a nicht mehr herankommt, und zweitens die Funktion to_string trotzdem auf a anwendbar bleibt. Darin besteht genau der springende Punkt, den wir haben wollen.

Man darf sich das so vorstellen, als würde die Struktur während der Optimierung durch den Compiler entfernt, weil kein Bedarf am dynamischen Dispatch besteht. Nämlich kann der Compiler schon zur Kompilierzeit in Erfahrung bringen, dass to_string auf einen Wert des Typs i32 angewendet wird.

Im Gegensatz zu impl Trait ist beim opaken Typ Box<dyn Trait> auch dynamischer Dispatch zulässig. Dieser Typ entspricht dem Zeigerpaar

∃T.(Box<T>, &{to_string: &T → String}).

Das äquivalente Beispielprogramm:

fn f(a: i32) -> Box<dyn ToString> {Box::new(a)}

fn main() {
    let x = f(0);
    println!("{}", x.to_string());
}

Eine andere Modellierung von existentiell quantifizierten Typen sind Module mit privaten Variablen und Funktionen. Das äquivalente Beispielprogramm:

mod mx {
    pub struct X {a: i32}
    impl X {
        pub fn to_string(&self) -> String {
            format!("{}", &self.a)
        }
    }
    pub fn f(a: i32) -> X {X {a}}
}

fn main() {
    let x = mx::f(0);
    println!("{}", x.to_string());
}

Wieder ist erreicht, dass man an a nicht mehr herankommt, die Funktion to_string aber auf a anwendbar bleibt.

Auch Closures bieten die Möglichkeit zur Auftrennung zwischen Schnittstellen und privaten Daten. Das ist ein Grund dafür, warum Closures opake Typen haben müssen. Nochmals das äquivalente Beispielprogramm:

fn f(a: i32) -> impl Fn() -> String {
    move || format!("{}", a)
}

fn main() {
    let to_string = f(0);
    println!("{}", to_string());
}

Rekursive Typen

Bei verketteten Listen und Bäumen enthalten die Knoten Einträge von der Form der Gesamtstruktur. Zur Typisierung solcher Strukturen sind rekursive Typen erforderlich.

Rekursive Typen lassen sich mit den bisherigen Mitteln nicht darstellen. Man führt für sie die Notation μT.Term ein, wobei T eine Typvariable ist. Diese Typvariable kommt im Term Term vor, und steht obendrein für den Term selbst.

Das klassische Beispiel für einen rekursiven Typ ist die Darstellung der natürlichen Zahlen als

Nat = μT.enum {Zero, Succ(T)},

oder in abstrakter Form äquivalent als

Nat = μT.Either<(), T> = μT.1+T.

In Rust sind rekursive Typen in der Gestalt nominaler Typen darstellbar, wobei die Notwendigkeit der speziellen Notation mit Typvariable entfällt. Die Entsprechung zu Nat kann man als

enum Nat {Zero, Succ(Rc<Nat>)}

oder

enum Either<X, Y> {Left(X), Right(Y)}
struct Nat(Either<(), Rc<Nat>>);

gestalten. Ein ähnliches Beispiel bieten die bereits in der Form

enum List<T> {
    Nil, Node {data: T, next: Box<List<T>>}
}

beschriebenen einfach verketteten Listen. Wir können Nat an und für sich sogar als äquivalent zu List<()> betrachten.

Literatur

  1. Benjamin C. Pierce: »Types and Programming Languages«. The MIT Press, Cambridge, Massachusetts & London, England.
  2. Herbert Klaeren: »Konzepte höherer Programmiersprachen«. Mathematisch-Naturwissenschaftliche Fakultät, Eberhard Karls Universität Tübingen, 2013. Kapitel 7: »Typen und Typsysteme«.