Programmieren in Rust

Die natürlichen Zahlen

Inhaltsverzeichnis

  1. Darstellung der natürlichen Zahlen
  2. Die natürlichen Zahlen als Typen
  3. Anwendung: Einheiten
  4. Gleichheit
  5. Literatur

Darstellung der natürlichen Zahlen

Den Mathematikern Richard Dedekind und Giuseppe Peano gelang Ende des 19. Jahrhunderts erstmals eine strenge Definition der natürlichen Zahlen. Ihr Vorgehen baut auf dem Gedankengang auf, dass jede natürliche Zahl einen Nachfolger besitzt. Sie nutzen die Nachfolgerabbildung, um die arithmetischen Operationen rekursiv zu definieren. Die wesentliche Schwierigkeit, die sich dabei stellt, ist das Prinzip der Induktion, das die Grundlage für das fundamentale Beweisverfahren vollständige Induktion bildet. Das Prinzip wird durch das Induktionsaxiom abgesichert, das letzte und komplizierteste Axiom in Peanos System.

Dieses Kapitel beschäftigt sich mit der Darstellung der natürlichen Zahlen als Typen. Zur Vorbereitung wollen wir die Zahlen aber erst einmal als Werte darstellen. Bei beiden Unterfangen wird die Vorgehensweise von Dedekind und Peano genutzt.

Es bezeichne Zero die Zahl null und Succ bzw. succ als Abkürzung von successor die Nachfolgerabbildung. Man stellt die natürlichen Zahlen als Enumeration dar:

#[derive(Clone, PartialEq, Eq, Debug)]
enum Nat {
    Zero,
    Succ(Box<Nat>)
}

use Nat::{Zero, Succ};

fn succ(a: Nat) -> Nat {Succ(Box::new(a))}

Die Addition ist rekursiv definiert als

0 + b := b,
succ(a) + b := succ(a + b).

Man kann sie direkt so implementieren:

fn add(a: Nat, b: Nat) -> Nat {
    match a {
        Zero => b,
        Succ(a) => succ(add(*a, b))
    }
}

Nun lässt sich berechnen, ob 1 + 1 = 2 gilt:

fn main() {
    let one = succ(Zero);
    let two = succ(one.clone());
    assert_eq!(add(one.clone(), one), two);
}

Eine weniger umständliche Formulierung ergibt sich durch Kodierung der natürlichen Zahlen als Werte des Typs u32. Bei dieser entfällt leider die Durchführbarkeit, auf das Muster Succ(a) abgleichen zu können. Wir überlegen uns dazu, dass dies bisher geklappt hat, weil Succ eine injektive Abbildung ist. Allgemein gehört zu jeder Enum-Variante eine einbettende Injektion. Daher können wir die Umkehrabbildung mit Ausnahme der Null für alle natürlichen Zahlen definieren und mit dieser arbeiten. Sie sei mit pred als Abkürzung für predecessor bezeichnet.

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Nat(u32);

fn succ(a: Nat) -> Nat {Nat(a.0 + 1)}
fn pred(a: Nat) -> Nat {
    if a.0 == 0 {unreachable!()} else {Nat(a.0 - 1)}
}

fn add(a: Nat, b: Nat) -> Nat {
    match a {
        Nat(0) => b,
        a => succ(add(pred(a), b))
    }
}

fn main() {
    assert_eq!(add(Nat(1), Nat(1)), Nat(2));
}

Die natürliche Zahlen als Typen

Wir wollen nun dazu übergehen, die natürlichen Zahlen als Typen darzustellen. Die Nachfolgerabbildung Succ wird hierbei zu einen Typkonstruktor, der dem Typ N dessen Nachfolger Succ<N> zuordnet. Es muss außerdem der Typ Zero vorhanden sein, der die Null darstellt.

Zudem möchten wir ausdrücken können, dass ein Typparameter zu den natürlichen Zahlen gehören soll. Wir definieren daher einen Trait Nat und implementieren diesen für alle natürlichen Zahlen. Obwohl nicht unbedingt notwendig, ist es praktisch, außerdem eine assoziierte Konstante VALUE zum Trait hinzuzufügen, mit der sich der Typ zur Kompilierzeit in dessen Entsprechung als Zahl umwandeln lässt.

struct Zero;
struct Succ<N>(N);

trait Nat {const VALUE: u32;}
impl Nat for Zero {const VALUE: u32 = 0;}
impl<N: Nat> Nat for Succ<N> {
    const VALUE: u32 = N::VALUE + 1;
}

Alternativ lässt sich Zero als leere Enumeration enum Zero {} definieren, die die Erstellung von Werten unmöglich macht. Absurderweise kann man Werte dennoch mittels PhantomData erstellen. Für das erzeugte Maschinenprogramm ergibt sich dabei kein Unterschied, weil Zero bezüglich struct Zero; bereits keine Information trägt, also kein Artefakt im Maschinenprogramm hinterlässt.

Kurze Rekapitulation. Die Addition ist rekursiv definiert als

0 + b := b,
succ(a) + b := succ(a + b).

Um diese Rekursion implementieren zu können, definiert man als Hilfsmittel einen trait Plus mit dem rechten Summand als Parameter und assoziiertem Typ Value als Wert der Addition.

trait Plus<B> {type Value;}
type Add<A, B> = <A as Plus<B>>::Value;

impl<B: Nat> Plus<B> for Zero {
    type Value = B;
}

impl<A: Nat + Plus<B>, B: Nat> Plus<B> for Succ<A> {
    type Value = Succ<Add<A, B>>;
}

Die Multiplikation ist rekursiv definiert als

0⋅b := 0,
succ(a)⋅b := ab + b.

Die Potenzierung ist rekursiv definiert als

a0 := 1,
asucc(n) := aan.

Die Rekursion wird analog zur Addition implementiert.

trait Times<B> {type Value;}
type Mul<A, B> = <A as Times<B>>::Value;

impl<B: Nat> Times<B> for Zero {
    type Value = Zero;
}

impl<A: Nat + Times<B>, B: Nat> Times<B> for Succ<A>
where Mul<A, B>: Plus<B>
{
    type Value = Add<Mul<A, B>, B>;
}

trait ExpBase<A> {type Value;}
type Pow<A, N> = <N as ExpBase<A>>::Value;

impl<A: Nat> ExpBase<A> for Zero {
    type Value = Succ<Zero>;
}

impl<A: Nat, N: Nat + ExpBase<A>> ExpBase<A> for Succ<N>
where A: Times<Pow<A, N>>
{
    type Value = Mul<A, Pow<A, N>>;
}

Mit den drei Operationen Add, Mul, Pow lässt sich nun, wenngleich ein wenig umständlich, jede natürliche Zahl im Stellenwertsystem darstellen. Das folgende Beispiel zeigt die Darstellung der Zahl 360 im Dezimalsystem.

type P0 = Zero;
type P1 = Succ<P0>;
type P2 = Succ<P1>;
type P3 = Succ<P2>;
type P4 = Succ<P3>;
type P5 = Succ<P4>;
type P6 = Succ<P5>;
type P7 = Succ<P6>;
type P8 = Succ<P7>;
type P9 = Succ<P8>;
type P10 = Succ<P9>;

fn main() {
    type P360 = Add<Mul<P3, Pow<P10, P2>>, Mul<P6, P10>>;
    println!("{}", P360::VALUE);
}

Das P soll hierbei für plus stehen. Fügt man später negative Zahlen hinzu, kann man sie dann M1M2 usw. für −1, −2 usw. nennen.

Anwendung: Einheiten

Mit den natürlichen Zahlen lassen sich Größen durch die Dimension parametrisieren, ohne Konstanten als Typparameter heranziehen zu müssen.

Die Definition von Nat sollte zu Nat: Copy modifiziert werden, damit die Größen Copy sein können. Der Einfachheit halber wird der Typ Quantity lediglich durch Dimensionen zur Basis Länge parametrisiert.

mod quantities {
    use super::{Nat, Plus, P1, P2};
    use std::ops::{Add, Mul};
    use std::marker::PhantomData as Ph;
    use std::fmt;

    #[derive(Clone, Copy)]
    pub(super) struct Quantity<L: Nat> {
        value: f64, dim: Ph<L>
    }

    impl<L: Nat> Add<Quantity<L>> for Quantity<L> {
        type Output = Quantity<L>;
        fn add(self, y: Quantity<L>) -> Self::Output {
            Quantity {value: self.value + y.value, dim: Ph}
        }
    }

    impl<L: Nat> Mul<Quantity<L>> for f64 {
        type Output = Quantity<L>;
        fn mul(self, y: Quantity<L>) -> Self::Output {
            Quantity {value: self*y.value, dim: Ph}
        }
    }
    
    impl<L1: Nat, L2: Nat> Mul<Quantity<L2>> for Quantity<L1>
    where L1: Plus<L2>, super::Add<L1, L2>: Nat
    {
        type Output = Quantity<super::Add<L1, L2>>;
        fn mul(self, y: Quantity<L2>) -> Self::Output {
            Quantity {value: self.value*y.value, dim: Ph}
        }
    }

    impl<N: Nat> fmt::Display for Quantity<N> {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "{} m^{}", self.value, N::VALUE)
        }
    }

    pub(super) type Length = Quantity<P1>;
    pub(super) type Area = Quantity<P2>;
    
    pub(super) struct Meter;
    impl Mul<Meter> for f64 {
        type Output = Length;
        fn mul(self, _rhs: Meter) -> Self::Output {
            Quantity {value: self, dim: Ph}
        }
    }
}

use quantities::{Length, Area, Meter};
use std::f64::consts::PI;

fn circle_area(radius: Length) -> Area {
    PI*radius*radius
}

fn main() {
    println!("{}", circle_area(2.0*Meter));
}

Dieses Beispiel zeigt bereits auf, dass die gemachten Konstruktionen nicht nur eine »akademische Spielerei« darstellen, sondern tatsächliche Bedeutung für die Typsicherheit haben können. Allerdings macht die allgemeine Verfügbarkeit von Konstanten als Typparameter die Konstruktion von Nat für diesen Zweck entbehrlich.

Gleichheit

Um den Compiler prüfen zu lassen, ob zwei natürliche Zahlen oder allgemeiner zwei Typen gleich sind, bietet sich der folgende Trick an, den wir in ein Makro verpacken.

macro_rules! assert_type_eq {
    ($t1:ty, $t2:ty) => {
        const _: Option<$t1> = Option::<$t2>::None;
    }
}

assert_type_eq!(P3, Add<P1, P2>);

Dabei wurde zunutze gemacht, dass Option::None keine Daten trägt. Insofern kann man ebensogut PhantomData heranziehen.

use std::marker::PhantomData as Ph;

macro_rules! assert_type_eq {
    ($t1:ty, $t2:ty) => {
        const _: Ph<$t1> = Ph::<$t2>;
    }
}

Auf diese Weise sind aber lediglich konkrete Typen vergleichbar. Mathematisch interessante Fragen wie die nach der die allgemeinen Gleichheit von Add<A, B> und Add<B, A> lassen sich so nicht beantworten.

Für einige Spezialfälle wie

n ∈ ℕ: 0 + n = n

findet sich unschwer ein prüfendes Programm. Es genügt dafür, einen Kontext zu erstellen, der die allquantifizierte Variable enthält. Eine mögliche Formulierung ist:

fn context<N: Nat>(n: N) -> Add<Zero, N> {n}

oder:

fn context<N: Nat>() {
    let _: Option<Add<Zero, N>> = Option::<N>::None;
}
oder auch:
trait Context<N: Nat> {
    const C: Option<Add<Zero, N>> = Option::<N>::None;
}

Diese Methodik schlägt allerdings bereits bei einer unscheinbaren Aussage wie

n ∈ ℕ: n + 0 = n

fehl. Der Beweis dieser Aussage ist nämlich induktiv zu führen, wofür das Induktionsaxiom benötigt wird. Der Induktionsschritt ist eigentlich ein Kinderspiel, nämlich

succ(n) + 0 = succ(n + 0) = succ(n),

wobei die erste Gleichung gemäß der Definition der Addition und die zweite Gleichung gemäß der Induktionsvoraussetzung gilt. Der Induktionsanfang ist mit 0 + 0 = 0 noch banaler. Das Induktionsaxiom sichert nun ab, dass die Aussage somit bereits für alle natürlichen Zahlen gilt, oder die Kette der Dominosteine ohne Ausnahmen purzelt, wie man sich das auch immer veranschaulichen mag.

Damit das Induktionsaxiom dies schafft, muss es die Existenz von Parallelstrukturen verhindern, das wären Dominosteine neben der Kette, die nicht von der Kettenreaktion betroffen sind. Bei uns ist das Axiom jedoch abhanden. Tatsächlich können wir eine solche Parallelstruktur herstellen und damit Schabernack treiben. Es genügt ein einziger Stein bzw. ein einziger Typ dafür:

struct A0;

impl Nat for A0 {}

impl<B: Nat> Plus<B> for A0 {
    type Value = B;
}

Mit dieser Definition gilt Add<A0, Zero> = Zero, womit die Setzung N:=A0 ein Gegenbeispiel zu Add<N, Zero> = N erbringt.

Literatur

  1. Stefan Müller-Stach: »Richard Dedekind: Was sind und was sollen die Zahlen? Stetigkeit und Irrationale Zahlen«. Springer, 2017.
  2. Richard Dedekind: »Was sind und was sollen die Zahlen?«. Vieweg, Braunschweig 1888, 10. Auflage 1965.
  3. Giuseppe Peano: »Arithmetices principia: nova methodo«. Fratres Bocca, Turin 1889. Englische Übersetzung »The principles of arithmetic, presented by a new method« in: Jan van Heijenoort: »From Frege to Goedel«. Harvard University Press, 1967.
  4. Philip Wadler, Wen Kokke, Jeremy G. Siek: »Programming Language Foundations in Agda«. Kapitel »Naturals: Natural numbers«.