1. Beziehung zwischen dem Modell und dem Code¶
Das Ziel der Übung¶
Das Ziel der Übung:
- Kennenlernen der Studenten/Studentinnen und des Übungsleiters/ins
- Klärung der Anforderungen für Übungen
- Erste Schritte mit Visual Studio und der Entwicklung von .NET-Anwendungen.
- Erstellen einer einfachen Hello World .NET-Anwendung, C#-Grundlagen
- Veranschaulichung der Beziehung zwischen UML und Code
- Anwendungstechnik der Schnittstelle und der abstrakte Basisklasse
Für Übungsleiter/in
Sicherlich gibt es einige Teilnehmer, die Visual Studio bereits in Prog2 (C++) oder aus anderen Gründen verwendet haben, aber es wird auch einige geben, die es noch nicht verwendet haben oder sich weniger daran erinnern. Das Ziel ist in diesem Fall, die Benutzeroberfläche kennenzulernen. So während der Lösung der Übungen, sollten die benutzte Dinge (z. B. Solution Explorer, Ausführen mit F5, Verwenden von Haltepunkten usw.) auch besprochen werden.
Voraussetzungen¶
Die für die Ausführung der Übung benötigten Werkzeuge:
- Visual Studio 2022
Es sollte die neueste Version von Visual Studio installiert sein. Die Versionen Community Edition, Professional und Enterprise sind ebenfalls geeignet. Die Community Edition ist kostenlos und kann von der Microsoft-Website heruntergeladen werden. Der Professional ist kostenpflichtig, steht aber auch für Studenten der Universität kostenlos zur Verfügung (auf der Website, im Rahmen des Programms Azure Dev Tools for Teaching).
Visual Studio Class Diagram support
Für einige Aufgaben in dieser Übung (und auch für die erste Hausaufgabe) werden wir die Unterstützung des Visual Studio Class Designer nutzen. Visual Studio fügt die Komponente Class Designer während der Installation nicht immer hinzu. Wenn es nicht möglich ist, ein Klassendiagramm zu Ihrem Visual Studio-Projekt hinzuzufügen (weil das Klassendiagramm nicht in der Liste des Fensters aufgeführt ist, das während des Befehls Neues Element hinzufügen angezeigt wird - mehr dazu später in diesem Handbuch), müssen Sie die Komponente Klassendiagramm später installieren:
- Starten Sie das Visual Studio-Installationsprogramm (z. B. durch Eingabe von "Visual Studio Installer" im Windows-Startmenü).
- Wählen Sie in dem nun erscheinenden Fenster die Registerkarte "Individual components"
-
Geben Sie in das Suchfeld "class designer" ein und vergewissern Sie sich, dass "Class Designer" in der gefilterten Liste angekreuzt ist.
Was Sie sich ansehen sollten:
- Die Übung beinhaltet keine Vorlesung zu diesem Thema. Gleichzeitig baut die Übung auf grundlegendem UML-Kenntnisse und den Grundlagen der Abbildung von UML-Klassendiagrammen auf Code.
Verlauf der Übung¶
Der/die Übungsleiter/in fasst die Anforderungen für die Übungen am Anfang der Übung zusammen:
- Die meisten davon finden Sie in dem Merkblatt
- Informationen zu den Hausaufgaben finden Sie auf der Website des Fachs.
Mit dem Entwicklungsumgebung Visual Studio werden wir .NET-Anwendungen in C# erstellen. C# ist ähnlich wie Java, wir lernen stufenweise die Unterschiede. Die Aufgaben werden gemeinsam unter der Leitung des Übungsleiters/ins durchgeführt.
Lösung¶
Laden Sie die fertige Lösung herunter
Es ist wichtig, dass Sie sich während des Praktikums an die Anleitung halten. Es ist verboten (und sinnlos), die fertige Lösung herunterzuladen. Allerdings kann es bei der anschließenden Selbsteinübung nützlich sein, die fertige Lösung zu überprüfen, daher stellen wir sie zur Verfügung.
Die Lösung ist auf GitHub verfügbar. Der einfachste Weg, es herunterzuladen, ist, es von der Kommandozeile aus mit dem Befehl git clone
auf Ihren Computer zu klonen:
git clone https://github.com/bmeviauab00/lab-modellkod-kiindulo -b solved
Sie müssen Git auf Ihrem Rechner installiert haben, weitere Informationen hier.
1. Aufgabe - Erstellen einer "Hello World" .NET-Konsolenanwendung¶
Die Aufgabe ist die Erstellung einer C#-Konsolenanwendung, die den Text "Hello world!" auf der Konsole ausgibt.
Die Anwendung wird in C# geschrieben. Die kompilierte Anwendung wird von der .NET-Laufzeitumgebung ausgeführt. In der ersten Vorlesung werden die theoretischen Hintergründe des Kompilierens/Ablaufens und die Grundlagen von .NET behandelt.
Die Schritte zum Erstellen einer Projektmappe und eines Projekts in Visual Studio 2022:
- Starten Sie einen neuen "Neues Projekt erstellen" Dialogfeld, was auf zwei Arten geschehen kann
- Verwendung des Startfensters
- Visual Studio starten
- In der rechten Seitenleiste des erscheinenden Startfensters Create new project
- Bereits in Visual Studio ausgeführt
- File / New-Project
- Verwendung des Startfensters
-
Wählen Sie im Dialogfeld "Neues Projekt erstellen" die Vorlage " Console app " (und NICHT die Vorlage " Console app (.NET Framework)", einschließlich der C#-Vorlage. Dass es sich um C# handelt, ist an der oberen linken Ecke des Vorlagensymbols zu erkennen. Wenn Sie es nicht in der Liste sehen, müssen Sie es suchen/filtern. Sie können danach suchen, falls Sie in der oberen Suchleiste "console" eingeben. Oder verwenden Sie die Dropdown-Felder unten: im ersten (Sprachauswahl) "C#", im dritten (Projekttypauswahl) "Console".
-
Next-Taste am unteren Rand des Dialogfeldes "Neues Projekt erstellen", auf der nächsten Seite:
- Project name: Hello World
- Location: In den Labors arbeiten wir im Ordner c:\work\<IhreName>, auf den Sie Schreibrechte haben.
- Solution name: Hello World (dies sollte bis zu unserer Ankunft hier eingeschrieben sein)
- Place solution and project in the same directory: kein Häkchen (aber nicht besonders wichtig).
-
Next-Taste am unteren Rand des Dialogfeldes "Neues Projekt erstellen", auf der nächsten Seite:
- Framework: .NET 8 (Langfristige Unterstützung).
- Aktivieren Sie das Kontrollkästchen "Do not use top level statements" (wir werden dies gleich erklären).
Das Projekt erstellt auch eine neue Projektmappe, deren Struktur im Visual Studio Solution Explorer-Fenster angezeigt werden kann. Eine Lösung kann aus mehreren Projekten bestehen, und ein Projekt kann aus mehreren Dateien bestehen. Ein Solution ist eine Zusammenfassung der gesamten Arbeitsumgebung (sie hat die Dateierweiterung .sln
), während die Ausgabe eines Projekts typischerweise eine Datei .exe
oder .dll
ist, d. h. eine Komponente einer komplexen Anwendung/eines komplexen Systems. Projektdateierweiterung für C#-Anwendungen .csproj
.
Der Inhalt unserer Datei Program.cs
ist die folgende:
namespace HelloWorld
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
Nehmen wir eine Console.ReadKey()
Zeile aus:
namespace HelloWorld
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
Console.ReadKey();
}
}
}
-
Führen wir die Anwendung aus (z. B. mit der Taste F5 ).
Die Struktur des Codes ist sehr ähnlich zu Java und C++. Unsere Klassen sind in Namespaces organisiert. Sie können einen Namespace mit dem Schlüsselwort
namespace
definieren. Wir können Namespaces mit dem Schlüsselwortusing
"ins Geltungsbereich bringen". z.B.:using System.Collections.Generic;
-
In einer C#-Konsolenanwendung wird der Eintrittspunkt der Anwendung mit einer statischen Funktion namens
Main
gegeben. Unser Klassenname kann beliebig sein, in unserem Fall hat VS eine Klasse namensProgram
erzeugt. Die Parameterliste der FunktionMain
ist gebunden: entweder werden keine Parameter angegeben, oder es wird einstring[]
angegeben, in dem die Befehlszeilenargumente zur Laufzeit angegeben werden. - in .NET wird die Klasse
Console
aus dem NamensraumSystem
verwendet, um die Standardeingabe und -ausgabe zu verarbeiten. Mit der statischen AktionWriteLine
können Sie eine Zeile drucken, mitReadKey
können Sie auf das Drücken einer Taste warten.
Top-Level-Anweisungen, implizite und statische Verwendungen und Namespaces
Bei der Projekterstellung haben wir zuvor das Kontrollkästchen "Do not use top level statements" aktiviert. Falls wir dies nicht getan hätten, hätten wir in unserer Datei Program.cs
nur eine einzige Zeile mit Inhalt gefunden:
// siehe https://aka.ms/new-console-template für weitere Informationen
Console.WriteLine("Hello World!");
Es ist funktionell äquivalent zu dem obigen Code, der die Klasse Program
und ihre Funktion Main
enthält. Schauen wir uns an, was dies möglich macht (Sie können hier mehr darüber lesen https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/top-level-statements, beide neu in C# 10):
- Top level statements. Die Idee ist, dass man Code direkt in einer einzigen Quelldatei schreiben kann, ohne dass Klassen/
Main
und andere Funktionsdefinitionen im Projekt vorhanden sind. In diesem Fall setzt der Compiler dies hinter den Kulissen in eine statischeMain
-Funktion einer Klasse, die wir nicht sehen. Die Motivation für seine Einführung war die Reduzierung von "Boilerplate"-Code für sehr einfache, "skriptartige" Anwendungen. - Implicit global usings. Je nachdem, welchen Projekttyp Sie erstellt haben, werden bestimmte Basis-Namensräume automatisch im Hintergrund in allen Quelldateien verwendet (der Compiler verwendet dazu die global using-Direktive). Der Punkt ist: Auf diese Weise müssen Entwickler bestimmte häufig verwendete Namespaces (z.B.
System.IO
,System.Collections.Generic
, etc.) nicht als Quelldateien verwenden. -
Static using. Es ist möglich, statische Klassen statt Namespaces in C# mit
using
zu verwenden, so es nicht wichtig ist, diese auszuschreiben, wenn sie verwendet werden. Ein häufiger Fall ist die Verwendung der Klasse "Console" oder "Math".using static System.Console; namensraum ConsoleApp12 { internal class Program { static void Main(string[] args) { WriteLine("Hello World!"); } } }
-
Namensräume auf Dateiebene. In C# 10 gibt es auch eine Vereinfachung bei der Deklaration von Namespaces, da es nicht mehr zwingend erforderlich ist, Klammern zu verwenden, so dass der angegebene Namespace für die ganze Datei gültig ist, z.B:
namespace HelloWorld; internal class Program { // ... }
Inconsistent visibility oder inconsistent accessibility Fehler
Während des Semesters können Sie bei der Durchführung von Programmieraufgaben auf Übersetzungsfehlermeldungen stoßen, die sich über inconsistent visibility oder inconsistent accessibility beschweren. Dieses Phänomen ist auf die Möglichkeit zurückzuführen, die Sichtbarkeit der einzelnen Typen (Klassen, Schnittstellen usw.) in einer .NET-Umgebung zu steuern:
internal
oder keine Sichtbarkeit angeben: der Typ ist nur in der angegebenen Assembly (.exe, .dll)/dem angegebenen Projekt sichtbarpublic
: der Typ ist auch für andere Assemblys/Projekte sichtbar
Der einfachste Weg, diesen Fehler zu vermeiden, ist, alle unsere Typen als öffentlich zu definieren, z.B.:
public class HardDisk
{
// ...
}
Theoretischer Überblick¶
Die Unterkapitel enthalten keine Übungen, sondern bieten den Studierenden eine mit Beispielen illustrierte Einführung in die entsprechenden theoretischen Themen.
A) Theorie der Beziehung zwischen dem UML-Klassendiagramm und dem Code [Student]*¶
Das Material ist hier verfügbar: Die Beziehung zwischen dem UML-Klassendiagramm und dem Code Dieses Thema wurde im vorangegangenen Semester in der Vorlesung Softwaretechnologien behandelt.
B) Schnittstelle und abstrakte (Basis)Klasse [Student]*¶
Das Material ist hier verfügbar: Schnittstelle und abstrakte (angestammte) Klasse.
Themen:
- Konzept und Definition abstrakter Klassen in C#
- Schnittstellenkonzepte und -definitionen in C#
- Vergleich von abstraktem Basisklasse und Schnittstelle
2. Aufgabe - Veranschaulichen der Beziehung zwischen UML und Code¶
Aufgabenbeschreibung - Equipment inventory¶
Aufgabe: Wir haben die Aufgabe bekommen, eine Computerteilregister-Anwendung zu entwickeln. Lesen Sie mehr:
- Es soll fähig sein, verschiedene Arten von Teilen zu behandeln. Anfänglich sollten die Typen
HardDisk
,SoundCard
undLedDisplay
unterstützt werden, aber das System sollte leicht auf neue Typen erweiterbar sein. - Daten der Teilen: Kaufsjahr, Alter (berechnet), Kaufspreis und aktueller Preis (berechnet), kann aber auch typspezifische Daten enthalten (z. B. Kapazität für
HardDisk
). - Der aktueller Preis hängt von der Art des Teils, dem Einkaufspreis und dem Produktionsjahr des Teils ab. Je älter das Teil ist, desto höher ist der Preisnachlass, aber der Preisnachlass hängt von der Art des Teils ab.
- Es soll fähig sein, die speicherte Teilen aufzulisten.
- Die Klasse
LedDisplay
muss von einer KlasseDisplayBase
abgeleitet sein, und der Quellcode der KlasseDisplayBase
darf nicht verändert werden. In diesem Beispiel hat dies nicht viel Sinn, aber in der Praxis treffen wir oft auf ähnliche Situationen, in denen das von uns verwendete Framework/die Plattform verlangt, dass wir von einer eingebauten Klasse ableiten. Typischerweise ist dies der Fall, wenn wir mit Fenstern, Formularen oder benutzerdefinierten Steuerelementen arbeiten: Wir müssen sie von den eingebauten Klassen des Frameworks ableiten, und wir haben den Quellcode des Frameworks nicht (oder wollen ihn zumindest nicht ändern) - z.B. Java, .NET. In unserem Beispiel simulieren wir diese Situation, indem wir eine Ableitung vonDisplayBase
verlangen.
Die Implementierung ist erheblich vereinfacht: Die Teile werden nur im Speicher abgelegt, und die Auflistung ist so einfach wie möglich, einfach die Daten der registrierten Teile werden auf die Konsole geschrieben.
Bei den ersten Gesprächen erhalten wir vom Kunden folgende Information: Ein interner Mitarbeiter hat bereits mit der Entwicklung begonnen, ist aber aus Zeitmangel nur zu einer halbfertigen Lösung gekommen. Ein Teil unserer Aufgabe besteht darin, die halbfertige Lösung zu verstehen und die Aufgabe von dort aus umzusetzen.
Klassendiagramm¶
Öffnen wir die Quellcode-Lösung unseres Kunden source code mit dem Ausführen der nachstehenden Schritte.
Klonen wir das Git-Repository des ursprünglichen Projekts, das online auf GitHub verfügbar ist, in einen eigenen Ordner innerhalb des Ordners C:\Work
: z. B.: C:\Work\NEPTUN\lab1
. Öffnen wir in diesem neuen Ordner eine Befehlszeile oder Powershell und führen wir den folgenden git-Befehl aus:
git clone https://github.com/bmeviauab00/lab-modellkod-kiindulo.git
Git und GitHub
Sie werden mehr über Git als Quellcode-Verwaltungssystem im Rahmen der ersten Hausaufgabe erfahren.
Öffnen wir die Visual Studio Solution src/EquipmentInventory.sln im geklonten Ordner.
Blicken wir die Dateien im Solution Explorer lurz über. Es wäre hilfreich, die Beziehungen zwischen den Klassen in einem Klassendiagramm darzustellen, um sie zu verstehen. Wir wollen ein Klassendiagramm in unser Projekt einfügen. Klicken wir im Solution Explorer mit der rechten Maustaste auf das Projekt (nicht auf das Solution!), und wählen wir im Popup-Menü die Option Add/New Item. Dann wählen wir in dem erscheinenden Fenster die Option Class Diagram, geben wir am unten im Fenster Main.cd als der Namen des Diagramms ein, und schließen wir das Fenster mit OK.
Fehlende Class Diagram-Vorlage
Wenn das Element Class Diagram nicht in der Liste erscheint, ist die entsprechende Komponente von VS nicht installiert. Weitere Informationen hierzu finden Sie im Abschnitt Voraussetzungen in diesem Dokument.
Die Diagrammdatei Main.cd
wird dann im Solution Explorer angezeigt. Doppelklicken wir darauf, um sie zu öffnen. Unseres Diagramm ist derzeit leer. Ziehen wir die .cs-Quelldateien aus Solution Explorer mit drag&drop auf das Diagramm. VS prüft dann, welche Klassen in diesen Quelldateien enthalten sind, und zerlegt sie in UML-Klassen. Erstellen wir das Layout wie in der folgenden Abbildung gezeigt (man kann die Mitglieder der Klassen anzeigen, falls man auf den Doppelpfeil in der oberen rechten Ecke ihres Rechtecks klickt):
Wir können auch den Quellcode der Klassen anschauen, falls wir entweder auf die entsprechende Klasse im Diagramm doppelklicken oder die .cs-Dateien im Solution Explorer öffnen. Wir werden die Folgenden erfahren:
- Die Klassen
SoundCard
,HardDisk
undLedDisplay
sind relativ gut entwickelt und verfügen über die notwendigen Attribute und Abfragefunktionen. LedDisplay
wird bei Bedarf vonDisplayBase
abgeleitet.- Obwohl
EquipmentInventory
für die Register der auf Lager befindlichen Teile verantwortlich ist, wird praktisch nichts davon umgesetzt. - Wir finden eine Schnittstelle
IEquipment
, mitGetAge
undGetPrice
Funktionen.
EquipmentInventory¶
Lassen wir uns an der Lösung arbeiten. Lassen wir uns zuerst die grundlegenden Konzepte festlegen. In der Klasse EquipmentInventory
speichern wir eine heterogene Sammlung verschiedener Teiltypen. Dies ist der Schlüssel zu einer konsistenten Teilverwaltung, so dass unsere Lösung problemlos mit neuen Teiltypen erweitert werden kann.
Wie früher erwähnt, kann eine einheitliche Verwaltung entweder durch die Implementierung einer gemeinsamen Basisklasse oder einer gemeinsamen Schnittstelle erreicht werden. In unserem Fall scheint die gemeinsame Basisklasse (z. B. EquipmentBase
) eliminiert zu werden, denn durch ihre Einführung hätte die Klasse LedDisplay
zwei Basisklassen: DisplayBase
, die obligatorisch ist, und EquipmentBase
, die wir zur einheitlichen Verwaltung einführen. Dies ist nicht möglich, in einer .NET-Umgebung kann eine Klasse nur einen Vorgänger haben. Die Lösung, DisplayBase
so zu ändern, dass es von EquipmentBase
stammt, ist nach unseren Anforderungen nicht möglich (es war eine Anforderung, dass der Quellcode nicht geändert werden durfte). Es bleibt also der schnittstellenbasierte Ansatz. Dies ist sicherlich die Schlussfolgerung des vorherigen Entwicklers der Anwendung, weshalb er die Schnittstelle IEquipment
eingeführt hat.
Fügen wir eine generische Liste von Elementen des Typs IEquipment
(keine Eigenschaft, sondern ein Feld!) zur Klasse EquipmentInventory
hinzu. Ihre Sichtbarkeit sollte - in dem Bemühen um Integration - private
sein. Der Name sollte equipment
sein (ohne "s" am Ende, im Englisch ist der Plural von equipment auch equipment). Um eine Membervariable hinzuzufügen, verwenden wir das Class Details Fenster von Visual Studio. Wenn das Fenster nicht sichtbar ist, kann es durch Auswahl von View / Other Windows / Class Details angezeigt werden.
Der Typ der Mitgliedsvariablen ist List<IEquipment>
. Der .NET-Typ List
ist ein dynamisch dehnbares generisches Array (wie ArrayList
in Java). Falls wir auf die Klasse EquipmentInventory
im Diagramm blicken, so siehen wir, dass nur der Name der Mitgliedsvariablen angezeigt wird, nicht aber der Typ. Klicken wit mit der rechten Maustaste auf den Hintergrund des Diagramms und wählen wir im Change Members Format Menü die Option Display Full Signature. Das Diagramm zeigt dann den Typ der Mitgliedsvariablen und die vollständige Signatur der Operationen.
Wenn wir auf die Klasse EquipmentInventory
doppelklicken, können wir zum Quellcode navigieren, und wie wir sehen können, erscheint sie im Code tatsächlich als Mitgliedsvariable vom Typ Liste:
class EquipmentInventory
{
private List<IEquipment> equipment;
Einerseits freuen wir uns darüber, weil Visual Studio Round-Trip-Engineering unterstützt: Änderungen am Modell spiegeln sich sofort im Code wider und umgekehrt. Andererseits haben wir bereits darüber gesprochen, dass eine Klasse, die eine Sammlung von Mitgliedern einer anderen Klasse hat, sollte in das UML-Modell als eine Assoziationsbeziehung vom Typ 1-mehr zwischen den beiden Klassen erscheinen. Dies ist noch nicht der Fall in unserem Modell. Glücklicherweise kann die VS-Modellierungsschnittstelle dazu gebracht werden, diese Art von Verbindung in dieser Form anzuzeigen. Klicken wir dazu im Diagramm mit der rechten Maustaste auf die Membervariable equipment und wählen wir im Menü die Option Show as Collection Association aus. Die Schnittstelle IEquipment
sollte dann nach rechts verschoben werden, damit im Diagramm genügend Platz für die Darstellung der Assoziationsverbindung und der Rolle der Verbindung bleibt:
Der Doppelpfeil, der auf der "Mehr"-Seite endet, entspricht nicht dem UML-Standard, aber sei man nicht zu traurig darüber, es ist nicht wichtig. Wir freuen uns darüber, dass der Name (und sogar der genaue Typ) der Mitgliedsvariablen am IEquipment
Ende der die Beziehung darstellende Pfeil in der Rolle anzeigt ist.
Navigieren wir zum Quellcode von EquipmentInventory
und schreiben wir den Konstruktor, der die Sammlung equipment
initialisiert!
public EquipmentInventory()
{
equipment = new List<IEquipment>();
}
Schreiben wir dann die Methode ListAll
, die das Alter der Elemente und ihren aktuellen Preis ausgibt:
public void ListAll()
{
foreach (IEquipment eq in equipment)
{
Console.WriteLine($"Alter: {eq.GetAge()}\tÉrtéke: {eq.GetPrice()}");
}
}
Mit dem Befehl foreach
durchlaufen wir die Elemente. Bei der Verwendung des Befehls foreach
sollte in
von einer Sammlung gefolgt werden, und in
sollte eine Variablendeklaration (in diesem Fall IEquipment eq
) vorangestellt werden, wo type der Elementtyp der Sammlung ist. Bei jeder Iteration nimmt diese Variable den Iterationswert der Sammlung an.
Der Operation Console.WriteLine
wird entweder eine einfache Zeichenfolge oder, wie in unserem Fall, eine Formatierungszeichenfolge übergeben. Die Ersetzungen werden durch String-Interpolation gelöst: Die zu ersetzenden Werte müssen zwischen {}
angegeben werden. Bei der String-Interpolation muss der String mit $
beginnen.
Schreiben wir eine Funktion mit der Bezeichnung AddEquipment
, die ein neues Gerät zu der Menge hinzufügt:
public void AddEquipment(IEquipment eq)
{
equipment.Add(eq);
}
Verwirklichern von IEquipment¶
Wir haben entschieden, die Schnittstelle IEquipment
zu verwenden, um die verschiedenen Komponententypen einheitlich zu verwalten. In unserem Fall haben sowohl die Klassen SoundCard
als auch HardDisk
die Methoden GetAge()
und GetPrice()
, aber wir können sie nicht einheitlich verwalten (z. B. in einer gemeinsamen Liste speichern). Zu diesem Zweck müssen wir beide Klassen dazu bringen, die Schnittstelle IEquipment
zu implementieren. Ändern Sie ihr Quellcode:
public class SoundCard : IEquipment
public class HardDisk : IEquipment
Dann müssen wir die Methoden der Schnittstelle IEquipment
in den Klassen SoundCard
und HardDisk
implementieren. Wir stellen fest, dass es damit nichts mehr zu tun gibt, die Funktionen GetPrice
und GetAge
sind bereits an beiden Stellen geschrieben.
Erstellen wir testweise ein Objekt EquipmentInventory
in unserer Main
Funktion in Program.cs
, füllen wir es mit den Objekten HardDisk
und SoundCard
auf, und listen wir das Objekt dann in der Konsole aus. Wenn 2021 nicht das aktuelle Jahr ist, schreiben wir in den folgenden Zeilen das Jahr 2021 auf das aktuelle Jahr und das Jahr 2020 auf eine mit eins kleinere Zahl um!
static void Main( string[] args )
{
EquipmentInventory ei = new EquipmentInventory();
ei.AddEquipment(new HardDisk(2021, 30000, 80));
ei.AddEquipment(new HardDisk(2020, 25000, 120));
ei.AddEquipment(new HardDisk(2020, 25000, 250));
ei.AddEquipment(new SoundCard(2021, 8000));
ei.AddEquipment(new SoundCard(2020, 7000));
ei.AddEquipment(new SoundCard(2020, 6000));
ei.ListAll();
}
Wenn wir die Anwendung ausführen, stellen wir fest, dass unsere Lösung zwar anfänglich ist, aber funktioniert:
Arbeiten wir weiter mit der Klasse LedDisplay
. Der Quellcode von DisplayBase
kann aufgrund der Anforderungen nicht geändert werden. Aber das ist kein Problem, unsere Klasse LedDisplay
wird die Schnittstelle IEquipment
implementieren, lassen wir uns den Code entsprechend ändern:
public class LedDisplay : DisplayBase, IEquipment
In der Klasse LedDisplay
müssen die Funktionen der Schnittstelle bereits geschrieben sein:
public double GetPrice()
{
return this.price;
}
public int GetAge()
{
return DateTime.Today.Year - this.manufacturingYear;
}
Erweitern wir unsere Main
Funktion, fügen wir zwei LedDisplay
Objekte zu unserer Liste hinzu (auch hier gilt: Wenn 2021 nicht das aktuelle Jahr ist, schreiben wir in den folgenden Zeilen das Jahr 2021 auf das aktuelle Jahr und das Jahr 2020 auf eine mit eins kleinere Zahl um!)
ei.AddEquipment(new LedDisplay(2020, 80000, 17, 16));
ei.AddEquipment(new LedDisplay (2021, 70000, 17, 12));
ei.ListAll();
Console.ReadKey();
Führen wir die Anwendung testweise aus.
3. Aufgabe - Anwendung der Schnittstelle und der abstrakten Basisklasse¶
Schnittstellenprobleme¶
Bewerten wir unsere aktuelle schnittstellenbasierte Lösung.
Eines der Hauptprobleme ist, dass unser Code mit Code-Duplikationen voll ist, die die Wartbarkeit und Erweiterbarkeit zerstören:
- Die Mitglieder
yearOfCreation
undnewPrice
gelten für alle Komponententypen (mit Ausnahme des speziellenLedDisplay
) und müssen immer mit copy-paste hinzugefügt werden, wenn ein neuer Typ eingeführt wird. - Die Implementierungsebene der Funktion
GetAge
ist für alle Komponententypen (mit Ausnahme der speziellenLedDisplay
) gleich, auch mit copy-paste wird "vermehrt". - Die Zeilen in den Konstruktoren, die die Mitglieder
yearOfCreation
undnewPrice
initialisieren, werden ebenfalls in jeder Klasse dupliziert.
Auch wenn diese Codeduplizierung im Moment noch unbedeutend zu sein scheint, wird die Situation mit der Einführung neuer Komponententypen immer schlechter, und es ist besser, künftigen Problemen rechtzeitig vorzubeugen.
Ein weiteres Problem besteht darin, dass die Auflistung der Teiledaten derzeit schmerzlich unvollständig ist, da es keine Teileart gibt (nur Alter und Preis). Um den Typ anzuzeigen, muss die Schnittstelle IEquipment erweitert werden, z. B. durch Einführung einer Operation namens GetDescription
. Fügen wir der Schnittstelle eine Funktion GetDescription
hinzu!
public interface IEquipment
{
double GetPrice();
int GetAge();
string GetDescription();
}
Dann müsste jede Klasse, die die Schnittstelle IEquipment
implementiert, diese Methode implementieren, was für viele Klassen eine Menge Arbeit bedeutet (und für eine Mehrkomponenten-Anwendung, d.h. eine Anwendung, die aus mehreren DLLs besteht, oft gar nicht machbar ist, wenn sie nicht in den Händen eines einzigen Entwicklers liegen). Führen wir den Befehl Build aus, um zu überprüfen, ob Sie nach dem Hinzufügen von GetDescription
an drei Stellen Übersetzungsfehler erhalten.
Standardimplementierung in der Schnittstelle festlegen
Es ist wichtig zu wissen, dass ab C# 8 (genauer .NET oder .NET Core Runtime ist auch nötig, es ist unter .NET Framework nicht unterstützt ) Schnittstellenoperationen eine Standardimplementierung erhalten können (default interface methods), so dass wir zur Lösung des obigen Problems keine abstrakte Klasse benötigen, aber die Schnittstelle kann keine Mitgliedsvariablen mehr haben. Weitere Informationen finden Sie hier: default interface methods.
public interface IEquipment
{
double GetPrice();
int GetAge();
string GetDescription() { return "EquipmentBase"; }
}
Abstrakte Klasse¶
Eine Lösung für beide Probleme ist die Einführung eines gemeinsamen abstrakten Vorfahres (mit Ausnahme der Klasse LedDisplay
, auf die wir noch zurückkommen werden). Wir können den Code, der allen Nachkommen gemeinsam ist, dorthin verschieben und eine Standardimplementierung für die neu eingeführte Operation GetDescription
bereitstellen. Nennen wir unsere neue abstrakte Basisklasse EquipmentBase
. Die Frage ist, ob die Schnittstelle IEquipment
noch benötigt wird oder ob sie vollständig durch die neue Klasse EquipmentBase
ersetzt werden kann. Wir müssen die Schnittstelle IEquipment
beibehalten, weil wir unsere Klasse LedDisplay nicht von EquipmentBase
ableiten können: Sie hat bereits eine obligatorische Basisklasse, DisplayBase
, deshalb bezieht sich EquipmentInventory in unserer erweiterten Lösung auf die verschiedenen Komponenten als Schnittstelle IEquipment
.
Beginnen wir mit der Umwandlung. Unser Klassendiagramm soll die aktive Registerkarte sein. Ziehen wir aus der Toolbox mit drag&drop ein Abstract Class Element auf das Diagramm und benennen wir es EquipmentBase
.
Im Folgenden müssen wir die Klassen SoundCard
und HardDisk
von EquipmentBase
ableiten ( LedDisplay
hat bereits einen anderen Vorfahren, so dass wir dies dort nicht tun können). Wählen wir dazu die Verknüpfung Inheritance in der Toolbox und ziehen wir dann eine Linie von der Kindklasse zur Basisklasse sowohl für SoundCard
als auch für HardDisk
.
Im nächsten Schritt ändern wir den Code so, dass HardDisk
und SoundCard
die Schnittstelle IEquipment
nicht separat implementieren, sondern ihr gemeinsamer Vorfahre EquipmentBase
dies tut. Ändern wir dazu die Klasse EquipmentBase
so, dass sie die Schnittstelle implementiert (entweder durch Einfügen eines inheritance Beziehung von EquipmentBase
zu IEquipment
im Diagramm oder durch Ändern des Quellcodes von EquipmentBase
). Entfernen wir die Implementierung von IEquipment
aus den Klassen HardDisk
und SoundCard
(der Vorgänger implementiert sie bereits).
Die relevanten Teile unseres Diagramms und des Quellcodes sehen dann wie folgt aus:
public abstract class EquipmentBase : IEquipment
public class HardDisk : EquipmentBase
public class SoundCard : EquipmentBase
Unser Code kann aus mehreren Gründen noch nicht kompiliert werden. EquipmentBase
implementiert die Schnittstelle IEquipment
, aber sie implementiert noch nicht die Operationen der Schnittstelle. Erzeugen wir die Methoden entweder mit Hilfe des Smarttags oder geben wir sie nach den folgenden Grundsätzen ein:
- Die Mitglieder
newPrice
undyearOfCreation
sind in den KlassenHardDisk
undSoundCard
dupliziert: verschieben (nicht kopieren!) wir sie in den gemeinsamen VorfahrenEquipmentBase
und geben wirprotected
Sichtbarkeit. - Die Operation
GetAge
wird in den KlassenHardDisk
undSoundCard
dupliziert, löschen wir die Implementierung aus diesen Klassen und verschieben wir sie in die KlasseEquipmentBase
. - Die Operation
GetPrice
wird als abstrakte Operation in den Vorgänger aufgenommen. Dies ist eine bewusste Design-Entscheidung, so dass wir nachkommende Klassen zwingen, diesen Vorgang trotzdem zu überschreiben. - Für
GetDescription
gilt das Gegenteil: Wir definieren es als virtuell (und nicht abstrakt), d. h. wir geben eine Implementierung im Vorgänger an. Auf diese Weise sind die Nachkommen nicht gezwungen, den Vorgang außer Kraft zu setzen.
Der entsprechende Code lautet:
public abstract class EquipmentBase : IEquipment
{
protected int yearOfCreation;
protected int newPrice;
public int GetAge()
{
return DateTime.Today.Year - yearOfCreation;
}
public abstract double GetPrice();
public virtual string GetDescription()
{
rückgabe "EquipmentBase";
}
}
Einige zusätzliche Gedanken zum Codefragment:
- Bei abstrakten Klassen muss das Schlüsselwort "abstrakt" vor das Wort "Klasse" geschrieben werden.
- Für abstrakte Operationen muss das Schlüsselwort
abstract
angegeben werden. - In .NET-Umgebung kann man steuern, ob ein Vorgang virtuell ist oder nicht. In dieser Hinsicht ist es ähnlich wie C++. Wenn man eine Operation virtuell machen will, muss man das Schlüsselwort
virtual
für die Operation angeben. Zur Erinnerung: Man definiert eine Operation als virtuell, wenn ihre Nachkommen sie überdefinieren. Nur dann ist gewährleistet, dass die Nachfolgeversion aufgerufen wird, wenn die angegebene Operation auf einen Vorgängerverweis angewendet wird.
Nachkommenschaft¶
Im nächsten Schritt gehen wir zu den Nachkommen von EquipmentBase
über. Wenn abstrakte und virtuelle Operationen in C# überschrieben werden, muss das Schlüsselwort override
im Nachfahren angegeben werden. Zuerst wird die Methode GetPrice
neu definiert:
public override double GetPrice()
{
return yearOfCreation < (DateTime.Today.Year - 4)
? 0
: newPrice - (DateTime.Today.Year - yearOfCreation) * 5000;
}
public override double GetPrice()
{
return yearOfCreation < (DateTime.Today.Year - 4)
? 0
: newPrice - (DateTime.Today.Year - yearOfCreation) * 2000;
}
Im nächsten Schritt werden wir die Operation GetDescription
in die Klassen HardDisk
und SoundCard
schreiben. Da wir hier die virtuelle Vorgängerfunktion umdefinieren, müssen wir auch das Schlüsselwort override
angeben:
public override string GetDescription()
{
return "Hard Disk";
}
public override string GetDescription()
{
return "Sound Card";
}
Man könnte sich fragen, warum die Entwickler der Sprache C# beschlossen haben, der Definition von Operationen ein zusätzliches Schlüsselwort hinzuzufügen, was im Fall von C++ nicht notwendig war. Der Grund dafür ist einfach: Der Code ist aussagekräftiger. Wenn man sich den Code der Nachkommen ansieht, macht das Wort override
sofort klar, dass diese Operation in einem der Vorfahren abstrakt oder virtuell ist, ohne dass man sich den Code aller Vorfahren ansehen muss.
Vorfahre von LedDisplay¶
Die Basisklasse unserer LedDisplay
Klasse ist gebunden, ihr Code kann nicht geändert werden, daher können wir sie nicht von EquipmentBase
ableiten. Wir können die Funktion GetAge
nicht löschen, diese Code-Duplizierung bleibt hier erhalten (aber nur für LedDisplay
, die nur eine Klasse unter vielen ist!).
Note
Mit ein wenig zusätzlicher Arbeit könnten wir diese Doppelung beseitigen. Dazu müsste eine statische Hilfsfunktion in eine der Klassen aufgenommen werden (z. B. EquipmentBase
) , die das Produktionsjahr ermittelt und das Alter zurückgibt. EquipmentBase.GetAge
und LedDisplay.GetAge
würden diese Hilfsfunktion für ihre Ausgabe verwenden.
In unserer Klasse LedDisplay
müssen wir noch GetDescription
schreiben:
public string GetDescription()
{
return "Led Display";
}
Beachten wir, dass das Schlüsselwort override
hier NICHT angegeben ist. Wenn eine Schnittstellenfunktion implementiert ist, muss/darf override
nicht ausgeschrieben werden.
GetDescription verwenden¶
Ändern wir die Operation EquipmentInventory.ListAll
, um auch die Beschreibung der Elemente in die Ausgabe zu schreiben:
public void ListAll()
{
foreach (IEquipment eq in equipment)
{
Console.WriteLine("$Description: {eq.GetDescription()}\t" +
$"Alter: {eq.GetAge()}\tValue: {eq.GetPrice()}");
}
}
Dies führt zu einer informativeren Ausgabe, wenn die Anwendung ausgeführt wird:
Duplizierung von Konstruktorcode¶
Ein Blick auf unseren Code zeigt, dass es eine weitere Duplikation gibt. Alle Nachfahren von EquipmentBase
(HardDisk
, SoundCard
) haben diese beiden Zeilen in ihrem Konstruktor:
this.yearOfCreation = yearOfCreation;
this.newPrice = newPrice;
Wenn wir nachdenken, werden diese yearOfCreation
und newPrice
Mitglieder im Vorfahren definiert, also sollte es seine Verantwortung sein, sie zu initialisieren. Fügen wir einen entsprechenden Konstruktor in EquipmentBase
hinzu:
public EquipmentBase(int Erstellungsjahr, int neuerPreis)
{
this.yearOfCreation = yearOfCreation;
this.newPrice = newPrice;
}
Entfernen wir die Initialisierung der beiden Mitglieder aus dem Konstruktor der Nachfahren HardDisk
und SoundCard
und rufen wir stattdessen den Konstruktor des Vorfahren auf, indem wir auf das Schlüsselwort base
verweisen:
public HardDisk(int yearOfCreation, int newPrice, int capacityGB)
: base(yearOfCreation, newPrice)
{
this.capacityGB = capacityGB;
}
public SoundCard(int yearOfCreation, int newPrice)
: base(yearOfCreation, newPrice)
{
}
Bewertung¶
Durch die Verwendung einer Kombination aus Schnittstelle und abstrakter Basisklasse ist es uns gelungen, die Lösung mit dem geringsten Kompromiss zu entwickeln:
IEquipment
als Schnittstelle können wir alle Arten von Teilen einheitlich behandeln, auch solche, bei denen die Basisklasse gebunden war (mit abstrakter Basisklasse allein hätten wir dies nicht erreichen können).- Durch die Einführung der abstrakten Basisklasse
EquipmentBase
konnten wir den Code, der in den verschiedenen Komponententypen gemeinsam ist, mit einer Ausnahme in einen gemeinsamen Basisklasse bringen und so Code-Duplikationen vermeiden. - Durch die Einführung des abstrakten Vorgängers
EquipmentBase
können wir eine Standardimplementierung für neu eingeführteIEquipment
Operationen (z.B.GetDescripton
) angeben, so dass wir nicht gezwungen sind, diese in jederIEquipment
Implementierungsklasse anzugeben.
Werfen wir abschließend noch einen Blick auf das UML-Klassendiagramm unserer Lösung:
C# 11 - Statische Schnittstellen
Die neueste Funktion von C# 11 ist die Definition von statischen Schnittstellenmitgliedern, die es Ihnen ermöglicht, von einer implementierenden Klasse zu verlangen, dass sie Mitglieder hat, die sich nicht auf die Objektinstanz beziehen, sondern die Klasse muss über ein bestimmtes statisches Mitglied verfügen. Mehr lesen
Hinweis - fakultative Hausaufgabe¶
Unsere Lösung unterstützt nicht die Anzeige von komponentenspezifischen Daten (z.B. Kapazität für HardDisk
) während der Auflistung. Zu diesem Zweck sollte das Schreiben von Komponentendaten in eine formatierte Zeichenkette von der Klasse EqipmentInventory
in die Komponentenklassen verlagert werden, und zwar nach den folgenden Grundsätzen:
- Sie können eine
GetFormattedString
Operation in dieIEquipment
Schnittstelle einführen, die ein Objekt vom Typstring
zurückgibt. Alternativ kann die OperationSystem.Object ToString()
außer Kraft gesetzt werden. In .NET sind alle Typen implizit vonSystem.Object
abgeleitet, das über eine virtuelle OperationToString()
verfügt. - In
EquipmentBase
schreiben Sie die Formatierung der gemensamen Mitglieder (Beschreibung, Preis, Alter) in Strings. - Wenn eine Komponente auch typspezifische Daten hat, dann überschreibt ihre Klasse die Funktion, die sie in eine Zeichenkette formatiert: Diese Funktion muss zuerst ihren Vorgänger aufrufen (mit dem Schlüsselwort
base
), dann ihre eigenen formatierten Daten an sie anhängen und mit dieser Zeichenkette zurückkehren.