Arbeiten mit .NET: OOP/ Verknüpfung/ Polymorphie/ Grundlagen

Polymorphie, zu gut deutsch Vielgestaltigkeit, ist ein in der objektorientierten Programmierung häufig auftretender Begriff. Aber wie wir in den letzten Kapiteln immer wieder lesen konnten, ist der Name meistens viel komplizierter als die Sache, die dahinter steckt. Hier ist das nichts anderes. Eher ist wohl das Gegenteil der Fall: Ein besonders schwieriger und nach komplizierter Arbeit klingender Name verbirgt eine ganz einfache Sache.

Wikipedia hat einen Artikel zum Thema:

Voraussetzungen Bearbeiten

Damit wir wirklich nachvollziehen können, was Polymorphie ist, brauchen wir allerdings ein paar Grundkenntnisse der objektorientierten Programmierung. Wir sollten daher ohne zu zögern beschreiben können,

Problem Bearbeiten

Schauen wir uns ein einfaches, aber alltägliches, Problem an:
Teilproblem a: Wir möchten eine Klasse haben, die ein paar Berechnungen durchführt. Dabei soll sie eine beliebig große Menge an Zahlen übernehmen und diese - der Einfachheit halber - addieren. Allerdings können diese Zahlen sowohl Ganzzahlen, als auch Fließkommazahlen sein.
Teilproblem b: Außerdem soll diese Klasse von einer anderen mathematischen Klasse erben, die schon früher geschrieben wurde, und die noch mit dem alten Mehrwertsteuersatz (16%) statt dem aktuellen Satz von 19% rechnet. Diese Korrektur können wir nicht in die alte Klasse schreiben, weil sonst die Berechnungen bis zum 31.12.2006 fehlerhaft wären. Also muss unsere neue mathematische Klasse diese Korrektur durchführen.

Ohne Polymorphie könnten wir uns an dieser Stelle schon warm anziehen, denn wir hätten jede Menge Arbeit vor uns. Stattdessen gehen wir diese Aufgabe ganz entspannt an:

Wikipedia hat einen Artikel zum Thema:


Lösung(en) Bearbeiten

Überladen Bearbeiten

Schauen wir uns zunächst das Problem mit der Addition der Zahlen an. Die Anforderung sagt, dass wir unterschiedlich viele Parameter erhalten werden und dass unterschiedliche Datentypen übergeben werden sollen. Natürlich könnten wir jetzt spontan anfangen und Methoden schreiben, die etwa so aussehen:

public int AddiereGanzzahl(int[] i)
{
  int ergebnis = 0;

  foreach(int zahl in i)
  {
    ergebnis += zahl;
  }

  return ergebnis;
}

public float AddiereFliesskommazahl(float[] f)
{
  float ergebnis = 0f;
 
  foreach(float zahl in f)
  {
    ergebnis += zahl;
  }

  return ergebnis;   
}

Besonders sinnvoll sieht das aber nicht aus, oder? Von der Übersichtlichkeit ganz zu schweigen. Aber auch dafür gibt es eine saubere Lösung: das Überladen. Überladen nennt man den Vorgang (oder Zustand), wenn mehrere Methoden mit dem gleichen Namen erstellt werden (oder vorhanden sind). Auf unser Beispiel umgelegt, bedeutet das folgendes:

public int Addiere(int[] i)
{
  int ergebnis = 0;

  foreach(int zahl in i)
  {
    ergebnis += zahl;
  }

  return ergebnis;
}

public float Addiere(float[] f)
{
  float ergebnis = 0f;
 
  foreach(float zahl in f)
  {
    ergebnis += zahl;
  }

  return ergebnis;   
}

Jetzt haben wir zwei verschiedene Methoden, die völlig gleich heißen. Und, wichtiger noch, wir müssen uns nicht mehr um irgendwelche Details kümmern, wenn wir die Methoden später aufrufen:

int[] paramInteger = new int[] { 1, 2, 3, 4, 5, 6 };
float[] paramFloat = new float[] { 1.2f, 3.4f, 5.6f };

// Wir rufen "Addiere" auf
int ergebnisInteger = Addiere ( paramInteger );

// Wir rufen noch einmal "Addiere" auf
float ergebnisFloat = Addiere ( paramFloat );

// Wir geben die Ergebnisse aus:
Console.WriteLine("Das Ergebnis der Ganzzahl-Berechnung lautet: {0}", ergebnisInteger);
Console.WriteLine("Das Ergebnis der Fließkomma-Berechnung lautet: {0}", ergebnisFloat);

... das war schon fast das ganze Geheimnis des Überladens.

Ein paar Sachen gilt es jedoch zu beachten. Überladene Funktionen können sich in den Typen ihrer Rückgabewerte unterscheiden. Dies reicht jedoch nicht aus, zwingend erforderlich ist es, dass sich die Typen oder die Anzahl ihrer Eingabewerte (Parameter) unterscheidet. Methoden kann man nicht ausschliesslich durch die Änderung des Rückgabetyps überladen.

Wikipedia hat einen Artikel zum Thema:

Überschreiben Bearbeiten

Das Überschreiben (engl. override; wörtlich außer Kraft setzen) ist uns im Zusammenhang mit abstrakten Methoden schon einmal begegnet. Damals haben wir gelernt, dass wir mit dem Schlüsselwort override abstrakte Methoden "außer Kraft setzen" und so unsere eigene Vorstellung von den Aktionen in den Methoden einbauen konnten. Ohne es zu merken, haben wir also schon kräftig an der Polymorphie herumgeschraubt. Das einzig wirklich Neue an dieser Stelle ist deshalb, dass man es jetzt auch physisch merkt.

Gehen wir noch einmal zu unserem Teilproblem (b) zurück: Wir haben eine Klasse, nennen wir sie MwStRechner1, die bereits eine Methode BerechneBrutto eingebaut hat. Diese Klasse können wir nicht mehr ändern. Trotzdem besitzt sie zahlreiche andere Methoden, die wir immer noch benutzen wollen. Ein Verzicht auf diese Klasse, und damit der komplette Neubau, würde viel Zeit kosten. Wie schaffen wir es also nun, das Problem zu lösen? Schauen wir uns zunächst die alte Klasse an:

class MwStRechner1
{
  virtual public decimal BerechneBrutto(decimal netto)
  {
    // Hier finden zahlreiche komplizierte Berechnungen statt... 

    // Zum Schluss berechnen wir das Brutto mit 16%
    return netto * 1.16m;
  }

  public void AndereNuetzlicheMethode() { }
  public void NochEineSinnvolleMethode() { }
}

Weise und vorausschauend, wie die Programmierer waren, haben sie die wichtige Methode BerechneBrutto damals als virtuelle Methode mit virtual deklariert. Aus dem Kapitel Zugriffsmodifizierer wissen wir ja noch, dass dieses virtual dafür steht, dass man die Methode in abgeleiteten Subklassen überschreiben darf. Diesen Umstand machen wir uns jetzt zunutze und entwickeln unsere Subklasse:

class MwStRechner2 : MwStRechner1
{
  override public decimal BerechneBrutto(decimal netto)
  {
    // Wir berechnen das Brutto.
    //
    // Weil aber die alte Methode noch andere Berechnungen durchführt
    // auf die wir nicht verzichten können,
    // lassen wir uns zunächst das Brutto-Ergebnis von ihr geben.
    
    decimal zwischenErgebnis = base.BerechneBrutto(netto); 

    // Dieses Mal müssen wir 19% kalkulieren.
    // Dazu ziehen wir zuerst die alten 16% ab,
    // und schlagen auf das - dann wieder Netto - 
    // die neue Steuer auf:

    return ( zwischenErgebnis / 1.16m ) * 1.19m;
  }
}

Das war der ganze Job. Unspektakulär, nicht wahr? Bleibt die Frage, ob es auch wirklich funktioniert:

// Wir holen uns ein Objekt der neuen MwSt-Klasse zum Testen:
MwStRechner2 rechner19 = new MwStRechner2();

// Test:
decimal brutto19 = rechner19.BerechneBrutto( 1000 );

Console.WriteLine ( brutto19 );

Okay, das hat schon mal geklappt. Aber was ist aus der alten Klasse geworden? Gemäß der Anforderungen durfte sie nicht geändert werden. Eigentlich wissen wir ja auch, dass sie nicht geändert wurde, aber es heißt schließlich nicht umsonst Probieren geht über studieren!:

// Wir holen uns je ein Objekt 
// + der alten MwSt-Klasse und 
// + der neuen MwSt-Klasse zum Testen:

MwStRechner1 rechner16 = new MwStRechner1();
MwStRechner2 rechner19 = new MwStRechner2();

// Test:

decimal brutto16 = rechner16.BerechneBrutto( 1000 );
decimal brutto19 = rechner19.BerechneBrutto( 1000 );

Console.WriteLine ( brutto16 );
Console.WriteLine ( brutto19 );

Funktioniert offensichtlich tadellos. Damit haben wir alle Anforderungen der Problemstellung erfüllt.

Ein paar Regeln Bearbeiten

In der Tat ist das Überschreiben nicht wirklich kompliziert. Und damit es so einfach werden konnte, müssen wir ein paar Regeln in Kauf nehmen, die uns darin einschränken, nun loszuziehen und alles zu überschreiben, was uns über den Weg läuft:

  • Methoden, die überschrieben werden sollen, müssen den Modifizierer abstract, virtual oder override (was im Grunde nur bedeutet, dass in irgendeiner Basisklasse schon mal abstract oder virtual auftauchte) besitzen. Alles andere lässt sich nicht überschreiben.
  • Aus der ersten Regel folgt logischerweise, dass wir keine statischen Methoden (Klassenmethoden) überschreiben können.
  • Die neue Methode muss exakt die gleiche Signatur wie die alte haben.
    Weder der Rückgabe-Datentyp, noch die Parameter - und schon gar nicht der Name der Methode - dürfen geändert werden. Allerdings wäre das auch Unfug: Wie kann man eine Methode "überschreiben", wenn man doch eigentlich eine ganz andere Methode schreibt?!
  • Als private deklarierte Methoden lassen sich nicht überschreiben.
    Wozu auch? Private Methoden gehören ausschließlich der Klasse selbst. Niemand, nicht einmal die Kinder, hat darauf Zugriff. Wie könnte man also etwas überschreiben, auf das man gar nicht zugreifen darf?!

Soweit zu den Regeln. Eigentlich alle logisch nachvollziehbar, nicht wahr?

Ausblenden Bearbeiten

Nehmen wir einmal an, die Programmierer wären damals nicht so vorausschauend gewesen, die Methode BerechneBrutto mit dem Modifizierer virtual zur Anpassung an den neuen Steuersatz zu markieren. Müssten wir dann unsere ganze Arbeit wegwerfen? ... Nein. Müssten wir nicht. Für diesen Fall gibt es den Modifizierer new, mit dem wir nahezu jede Methode "überschreiben" können. Allerdings überschreiben wir sie nicht wirklich. Vielmehr setzen wir einen "neuen Vererbungspunkt" in der Hierarchie der weiteren Erbschaftslinie der Subklassen. Wir blenden sie aus. Aber der Reihe nach...

Zunächst einmal schauen wir uns an, wie es funktioniert. Dazu nehmen wir uns wieder unsere alte Klasse her:

class MwStRechner1
{
  public decimal BerechneBrutto(decimal netto)
  {
    // Hier finden zahlreiche komplizierte Berechnungen statt... 

    // Zum Schluss berechnen wir das Brutto mit 16%
    return netto * 1.16m;
  }

  public void AndereNuetzlicheMethode() { }
  public void NochEineSinnvolleMethode() { }
}

Listigerweise steht hier kein virtual. Andere würden jetzt wohl aufgeben. Aber wir nicht. Stattdessen passen wir uns an und schreiben unsere neue Klasse eben so:

class MwStRechner2 : MwStRechner1
{
  new public decimal BerechneBrutto(decimal netto)
  {
    // Wir berechnen das Brutto.
    //
    // Weil aber die alte Methode noch andere Berechnungen durchführt
    // auf die wir nicht verzichten können,
    // lassen wir uns zunächst das Brutto-Ergebnis von ihr geben.
    
    decimal zwischenErgebnis = base.BerechneBrutto(netto); 

    // Dieses Mal müssen wir 19% kalkulieren.
    // Dazu ziehen wir zuerst die alten 16% ab,
    // und schlagen auf das - dann wieder Netto - 
    // die neue Steuer auf:

    return ( zwischenErgebnis / 1.16m ) * 1.19m;
  }
}

Wenn wir das Resultat unserer Bemühungen nun testen, müsste eigentlich alles funktionieren:

// Wir holen uns je ein Objekt 
// + der alten MwSt-Klasse und 
// + der neuen MwSt-Klasse zum Testen:

MwStRechner1 rechner16 = new MwStRechner1();
MwStRechner2 rechner19 = new MwStRechner2();

// Test:

decimal brutto16 = rechner16.BerechneBrutto( 1000 );
decimal brutto19 = rechner19.BerechneBrutto( 1000 );

Console.WriteLine ( brutto16 );
Console.WriteLine ( brutto19 );

Wozu das alles? Bearbeiten

Mit jeder der letzten beiden hier vorgestellten Methoden haben wir das selbe erreicht. Warum gibt es also verschiedene Methoden, wenn sie doch offensichtlich alle zum gleichen Ziel führen, nämlich in einer abgeleiteten Klasse die vorgegebene Funktionalität der Basisklasse zu verändern, ohne die Signatur (ihren Namen, den Typ ihres Rückgabewertes und die Eingabeparameter) der Funktion zu verändern?

Tatsächlich gibt es ein paar subtile, aber sehr wichtige Unterschiede zwischen den einzelnen Verfahren. Sie sind nicht unbedingt auf den ersten Blick zu durchschauen, werden aber schnell klar, wenn man ein wenig hinter die Kulissen schaut.

Nehmen wir an, wir haben eine Basis- und eine abgeleitete Klasse, in der die beiden Methoden override und new verwendet werden, wie unterscheidet sich das Resultat?

class MwStRechner1
{
  virtual float virtualBrutto(float Netto) {return 1.16*Netto;}
  float Brutto(float Netto) {return 1.16*Netto;}
}
class MwStRechner2 : MwStRechner1
{
  override float virtualBrutto(float Netto) {return 1.19*Netto;}
  new float Brutto(float Netto) {return 1.19*Netto;}
}

Mit override kann prinzipiell nur eine Methode überschrieben werden, die als virtual in der Basisklasse definiert wurde (dabei impliziert ein override dass diese Funktion weiterhin virtual bleibt). Mit new kann man nur eine Methode ausblenden, die eben nicht virtual ist. Das ist zwar schonmal ein Unterschied, aber er hat doch in unserem Beispiel keinerlei Konsequenzen, ausser dass man die erste dann benutzt, wenn der Programmierer der Basisklasse vorhergesehen hat, dass sich die Funktionalität in abgeleiteten Klassen ändern könnte und die zweite, wenn man eine Methode überschreiben will, ohne dass dies in der Basisklasse vorgesehen war? Man kann auch in beiden Fällen über base.Methode() aus der abgeleiteten Klasse die Methode der Basisklasse aufrufen. Also bisher immer noch kein wirklicher Unterschied in der Funktionalität und den Möglichkeiten?

Doch! Stellen wir uns jetzt einen alten Programmteil irgendeiner anderen Klasse vor, die von einem ihr übergebenen Objekt der Klasse MwStRechner1 die Methode BerechneBrutto() aufruft. Was passiert jetzt, wenn wir dieser Klasse ein Objekt unseres neuen MwStRechner2 übergeben?

class AlterKram
{
  public float Berechnung(MwStRechner1 rechner, float Netto)
  {
    return rechner.Brutto(Netto);
  }
  public float virtualBerechnung(MwStRechner1 rechner, float Netto)
  {
    return rechner.virtualBrutto(Netto);
  }
}
class NeuerKram
{
  // AlterKram ak ist irgendwo definiert und kann hier benutzt werden.
  MwStRechner2 r2 = new MwStRechner2();
  public float richtigesErgebnis(float Netto)
  {
    return ak.virtualBerechnung(r2,Netto);  // Compiler beschwert sich
  }
  public float falschesErgebnis(float Netto)
  {
    return ak.Berechnung(r2,Netto);         // Compiler beschwert sich
  }
}

Zuerst einmal wird sich der Compiler beschweren, dass unser übergebenes Objekt nicht den richtigen Typ MwStRechner1 hat, also casten wir das weg:

    return ak.virtualBerechnung((MwStRechner1)r2,Netto); // richtiges Ergebnis
    // bzw.
    return ak.Berechnung((MwStRechner1)r2,Netto);        // falsches Ergebnis

Jetzt wird der Unterschied in den beiden Methoden auf einen Schlag offenbar, wenn man sich das Ergebnis anschaut:

Wurde die ursprüngliche Funktion nicht als virtual deklariert, wird die Funktion welche davon ausgeht mit einem MwStRechner1 Objekt zu arbeiten, unser übergebenes Objekt auch als solches behandeln und den falschen MwSt-Satz berechnen. Im Gegensatz dazu "weiss" die virtuelle Funktion, dass sie zu einer abgeleiteten Klasse (MwStRechner2) gehört und ruft entsprechend die richtige, also unserer überschriebene Funktion aus MwStRechner2 auf, obwohl sie aus einer Methode heraus aufgerufen wurde, die denkt mit einem Objekt der Klasse MwStRechner1 umzugehen!

Klingt kompliziert? Ist es aber nicht.

Siehe auch Bearbeiten