Polymorphie

Polymorphie kommt aus dem Griechischen und bedeutet Vielgestaltigkeit. Im Software Engineering ist damit gemeint, dass eine Instanz verschiedene konkrete Gestalten annehmen kann, die alle von einem identischen Teilprogramm prozessiert werden können. Wir haben bereits Beispiele kennengelernt. Unsere Funktion longest_dist_to_0 aus Kapitel 3 konnte auf verschieden gestaltige Eingaben angewendet werden.

Punkt  ・

         
Linie  ___     ->   fn longest_dist_to_0   ->   f64
       

Kreis  ⬤

Wir haben Punkte, Linien und Kreise bzgl. ihres Abstandes zum Ursprung miteinander vergleichen können, da wir den Typen als generischen Parameter verwendet haben. Zur Auffrischung folgt nochmal die Implementierung.

#![allow(unused)]
fn main() {
use std::ops::{Mul, Add, Sub};
trait Calculate: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy {}
impl<T: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Copy> Calculate for T {}
trait MeasureDistanceTo0<T: Calculate> {
   fn squared_dist_to_0(&self) -> T; 
}
fn longest_dist_to_0<T, M1, M2>(p1: M1, p2: M2) -> T
where
    T: Calculate + PartialOrd,
    M1: MeasureDistanceTo0<T>,
    M2: MeasureDistanceTo0<T>
{
    let d1 = p1.squared_dist_to_0();
    let d2 = p2.squared_dist_to_0();
    if  d1 > p2.squared_dist_to_0() {
        d1
    } else {
        d2
    }
}
}

Hierbei handelt es sich um statische Polymorphie. Wir reden von statischer Polymorphie, wenn alle Gestalten zur Kompilierzeit feststehen. Der Compiler erstellt für jede Kombination an Eingabetypen, die wir im Programm verwenden eine Kopie der Funktion und führt diese dann aus. Wenn wir beispielsweise Nur Punkte mit Punkten und Linien mit Punkten vergleichen, erstellt der Compiler zwei Versionen von longest_dist_to_0<T, Point<T>, Point<T>> und longest_dist_to_0<T, Point<T>, Line<T>> für uns. Wenn wir noch einen dritten Aufruf hinzufügen indem wir erst die Linie und dann den Punkt als Argument verwenden, erstellt der Compiler für uns eine weitere Version longest_dist_to_0<T, Line<T>, Point<T>>.

Ein weiterer prominenter Anwendungsfall polymorpher Typen ist die Sammlung seiner Instanzen in einem Container. Beispielsweise könnte unsere Funktion longest_dist_to_0 anstatt zweier Argumente den längsten Abstand über beliebig viele Formen möglicherweise unterschiedlicher Gestalt suchen. Wenn wir die Signatur

fn longest_dist_to_0<T, M1, M2>(p1: M1, p2: M2) -> T

nochmal betrachten, fällt auf, dass jeder Parameter einen separaten Typ-Parameter bekommt. Bei einem Vec<T> kann aber nicht jedes Element einen anderen Typen haben. Alle Typen sind T. Wir kennen jedoch einen Weg, unterschiedliche Gestalten in einem Vec<T> unterzubringen, der uns im folgenden zum Begriff der dynamischen Polymorphie führt.

Quiz

An dieser Stelle sei der Leser gebeten kurz innezuhalten und nachzudenken. Was könnte das sein?

Ergänzend zur statischen Polymorphie gibt es die dynamische Polymorphie, bei der erst zur Laufzeit die konkrete Gestalt der Eingabe bestimmt und verwendet wird. Die Variante dynamischer Polymorphie, die wir bereits kennengelernt haben, ist der Aufzählungstyp. Wir packen die unterschiedlichen Gestalten in die verschiedenen Varianten eines Aufzählungstypen. Für unser Beispiel könnte man dynamische Polymorphie mit einem Container anstelle zweier Parameter folgendermaßen umsetzen.

#![allow(unused)]
fn main() {
use std::ops::{Mul, Add, Sub};
use std::cmp::Ordering;
trait Sqrt {
    fn sqrt(self) -> Self;
}
impl Sqrt for f64 
{
    fn sqrt(self) -> f64 {
        self.sqrt()
    }
}
impl Sqrt for f32 
{
    fn sqrt(self) -> f32 {
        self.sqrt()
    }
}
trait Calculate: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Sqrt
   + Copy {}
impl<T: Mul<Output=Self> 
   + Add<Output=Self> 
   + Sub<Output=Self> 
   + Sqrt
   + Copy> Calculate for T {}
struct Point<T>
where 
    T: Calculate
{
    x: T,
    y: T,
}
impl<T: Calculate> Point<T> {
    fn squared_dist_2_0(&self) -> T {
        self.x * self.x + self.y * self.y
    }
}

struct Circle<T: Calculate> {
    center: Point<T>,
    r: T
}
impl<T: Calculate> Circle<T> {
    fn squared_dist_2_0(&self) -> T {
        self.center.squared_dist_2_0().sqrt() - self.r
    }
}

enum Measurable<T: Calculate> {
    Point(Point<T>),
    Circle(Circle<T>),
}
impl<T: Calculate> Measurable<T> {
    fn squared_dist_2_0 (&self) -> T{
        match self {
            Measurable::Point(p) => p.squared_dist_2_0(),
            Measurable::Circle(c) => c.squared_dist_2_0()
        }
    }
    fn dist_to_0(&self) -> T {
        self.squared_dist_2_0().sqrt()
    }
}
fn longest_dist_to_0<T>(p: &[Measurable<T>]) -> Option<T>
where
    T: Calculate + PartialOrd,
{
    p.iter()
        .map(|pi| pi.squared_dist_2_0())
        .max_by(|a, b| match a.partial_cmp(b) {
            Some(o) => o,
            None => Ordering::Equal,
        })
}
}

Ein offensichtlicher Nachteil der enum-Lösung ist, dass sie sich nicht von außerhalb des Moduls erweitern lässt. Um weitere Gestalten hinzufügen zu können, müssen wir den Aufzählungstypen anpassen. Wir kennen also bereits zwei Wege, Polymorphie in Rust umzusetzen. Beide haben Vor- und Nachteile, die wir im folgenden zusammenfassen.

Statische Polymorphie durch generische Typen...

  • ...ist üblicherweise effizienter zur Laufzeit als dynamische Polymorphie, da die Entscheidungspfade einkompiliert sind und der Compiler noch mehr Optimierungsmöglichkeiten hat.
  • ...kann im Gegensatz zu dynamischer Polymorphie mit enums auch von außerhalb des Moduls erweitert werden. Man kann z.B. polymorphe Funktionalität in einer Bibliothek definieren und Benutzer dieser Bibliothek können neue Gestalten erfinden und die Funktionalität der Bibliothek auf diese anwenden.
  • ...führt zu größeren Kompilaten, da für jede Kombination generischer Typen ein neuer Typ oder eine Funktion vom Compiler erzeugt wird.
  • ...führt dazu, dass der Übersetzungsvorgang selbst länger dauert.
  • ...kann nur verwendet werden, wenn alle relevanten Entscheidungen zur Kompilierzeit getroffen werden können. Beispielsweise Benutzereingaben von Programmnutzern sind zur Kompilierzeit noch nicht bekannt.
  • ...kann nicht verwendet werden, um in einem Container unterschiedliche Gestalten abzulegen. Man kann zur Kompilierzeit festlegen, welche Gestalt in den Container gehört und dann ausschließlich diesen konkreten Typen zur Laufzeit verwenden. Einem Container Instanzen eines Aufzählungstypen mit unterschiedlichen Varianten hinzuzufügen ist dagegen kein Problem.
  • ...ist manchmal nicht trivial. Das ist insbesondere dann der Fall, wenn man generischen Code über ein Verhalten schreiben will, für das es noch keinen passenden Trait gibt. Die Verwendung von Aufzählungstypen ist oft simpler.

Aufgabe

Um den letzten Punkt zu verdeutlichen, sei angenommen, dass wir eine Funktion schreiben, die ein assoziatives Array verwendet. Es soll dem Benutzer aber überlassen werden, ob er eine HashMap verwendet oder z.B. die VecMap, die wir in Kapitel 3 selbst entwickelt haben. Unsere Funktion soll den Typ des Containers als generischen Typen erhalten und eine Liste an Key-Value-Paaren bekommen, die je nach Benutzereingabe entweder nach den Key oder nach den Values sortiert wurden.

Es gibt noch eine weitere Möglichkeit dynamischer Polymorphie in Rust. Die sogenannten Trait-Objekte sind das Thema der folgenden Abschnitte und entsprechen dem Konzept der Polymophie in Objekt-orientierter Programmierung. Damit ist es möglich dynamisch polymorphe Typen, die außerhalb des Moduls definiert wurden, zu verwenden. Aufzählungstypen, die Daten beinhalten, gibt es in vielen objekt-orientierten Sprachen gar nicht. Die Mengen der Probleme, die sich durch den Aufzählungstypen und durch Trait-Objekte lösen lassen, überlappen sich also, wie wir im nächsten Abschnitt sehen werden.