2. Sprachliche Mittel¶
Das Ziel der Übung¶
Während der Übung lernen die Studenten die wichtigsten modernen Sprachelementen kennen, die auch in der .NET-Umgebung verfügbar sind. Es wird vorausgesetzt, dass der/die Student/in den objektorientierten Ansatz in seinem/ihrem bisherigen Studium beherrscht und mit den grundlegenden Konzepten der Objektorientierung vertraut ist. In dieser Übung werden wir uns auf die Sprachelemente in .NET konzentrieren, die über den allgemeinen objektorientierten Ansatz hinausgehen, aber wesentlich zur Erstellung von transparentem und wartbarem Code beitragen. Diese sind:
- Eigenschaft (property)
- Delegat (delegate, Methodenreferenz)
- Ereignis (event)
- Attribut (attribute)
- Lambda-Ausdruck (lambda expression)
- Generischer Typ (generic type)
- Einige zusätzliche Sprachkonstruktionen
Zugehörige Vorlesungen: Vorlesung 2 und Anfang der Vorlesung 3 - Sprachliche Mittel.
Voraussetzungen¶
Die für die Durchführung der Übung benötigten Werkzeuge:
- Visual Studio 2022
Übung unter Linux oder macOS
Das Übungsmaterial ist grundsätzlich für Windows und Visual Studio gedacht, kann aber auch auf anderen Betriebssystemen mit anderen Entwicklungswerkzeugen (z.B. VS Code, Rider, Visual Studio für Mac) oder sogar mit einem Texteditor und CLI (Kommandozeilen)-Tools durchgeführt werden. Dies wird dadurch ermöglicht, dass die Beispiele im Kontext einer einfachen Konsolenanwendung präsentiert werden (keine Windows-spezifischen Elemente) und das .NET SDK auf Linux und macOS unterstützt wird. Hello World unter Linuxon
Einführung¶
Ausblick
Dieser Leitfaden enthält an mehreren Stellen zusätzliche Informationen und Erklärungen, die in derselben Farbe wie dieser Hinweis und mit demselben Symbol umrahmt sind. Dies sind nützliche Erkenntnisse, die jedoch nicht Teil des Kernlehrmaterial sind.
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 [hier] verfügbar (https://github.com/bmeviauab00/lab-nyelvieszkozok-megoldas). 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-nyelvieszkozok-megoldas
Sie müssen Git auf Ihrem Computer installiert haben, weitere Informationen hier.
0. Aufgabe - Schlüsselwort var - Implizit typisierte lokale Variablen (implicitly typed local variables)¶
Wir beginnen mit einer einfachen Aufwärmübung. Im folgenden Beispiel erstellen wir eine Klasse namens Person
, die eine Person darstellt.
- Erstellen wir eine neue C#-Konsolenanwendung. .NET-Basis (d.h. nicht.NET Framework):
- Ein Beispiel dafür haben wir in der ersten Übung gesehen, die im Leitfaden beschrieben wird.
- Das Kontrollkästchen "Do not use top level statements" ist bei der Projekterstellung aktiviert.
- Fügen wir eine neue Klasse mit dem Namen
Person
zu unserer Anwendung hinzu. (Um eine neue Klasse im Solution Explorer hinzuzufügen, klicken wir mit der rechten Maustaste auf die Projektdatei und wählen wir Add / Class. Ändern wir den Namen der zu erstellenden Datei im erscheinenden Fenster aufPerson.cs
und klicken wir auf Add.) -
Lassen wir uns die Klasse öffentlich machen. Dazu müssen wir das Schlüsselwort
public
vor dem Klassennamen eingeben. Diese Änderung wäre hier eigentlich nicht nötig, aber eine spätere Aufgabe wird eine öffentliche Klasse erfordern.public class Person { }
-
Ergänzen wir die Funktion
Main
in der DateiProgram.cs
, um unsere neue Klasse zu testen.static void Main(string[] args) { Person p = new Person(); }
-
Anstatt den Typ der lokalen Variablen explizit anzugeben, können wir das Schlüsselwort
var
verwenden:static void Main(string[] args) { var p = new Person(); }
Dies wird als implicitly typed local variables bezeichnet, auf Deutsch implizit typisierte lokale Variablen genannt. In diesem Fall versucht der Compiler, den Typ der Variablen aus dem Kontext, aus der rechten Seite des Gleichheitszeichens zu erkennen. In diesem Fall ist es
Person
. Es ist wichtig anzumerken, dass die Sprache dadurch statisch typisiert bleibt (es funktioniert also nicht wie das JavaScript-Schlüsselwortvar
), da der Typ derp
-Variable später nicht mehr geändert werden kann. Es ist nur ein einfaches syntaktisches Bonbon, um die Definition lokaler Variablen kompakter zu machen (keine Notwendigkeit, den Typ "zweimal" anzugeben, auf der linken und auf der rechten Seite von=
).Target-typed
new
expressionsEin weiterer Ansatz könnte die Target-typed
new
expressions in C# 9 sein, wo der Typ für den neuen Operator weggelassen werden kann, wenn er vom Compiler aus dem Kontext erkannt werden kann (z.B.: linke Seite eines Wertes, Typ eines Parameters, etc.). Unser obigerPerson
-Konstruktor würde wie folgt aussehen:Person p = new();
Der Vorteil dieses Ansatzes gegenüber
var
ist, dass er auch für Membervariablen verwendet werden kann.
1. Aufgabe - Eigenschaft (property)¶
Eigenschaften erlauben uns typischerweise (aber nicht ausschließlich, wie wir noch sehen werden) den Zugriff auf Membervariablen von Klassen auf eine syntaktisch ähnliche Weise wie den Zugriff auf eine traditionelle Membervariable. Beim Zugriff haben wir jedoch die Möglichkeit, anstelle einer einfachen Wertabfrage oder Einstellung eine methodenähnliche Art des Zugriffs auf die Variable zu implementieren, und wir können sogar die Sichtbarkeit der Abfrage und der Einstellung separat definieren.
Syntax von Eigenschaften¶
Im folgenden Beispiel erstellen wir eine Klasse namens Person
, die eine Person darstellt. Sie hat zwei Mitgliedsvariablen, name
und age
. Auf Mitgliedsvariablen kann nicht direkt zugegriffen werden (da sie privat sind), sie können nur über die öffentlichen Eigenschaften Name
und Age
verwaltet werden. Das Beispiel veranschaulicht, dass die .NET-Eigenschaften eindeutig den aus C++ und Java bekannten Methoden SetX(…)
und GetX()
entsprechen, aber die sind auf einheitlichere Weise, auf Sprachebene unterstützt.
-
Erstellen wir in der Klasse
Person
, die in der vorherigen Aufgabe erstellt war, eine Membervariable des Typsint
mit dem Namenage
und eine EigenschaftAge
, die sie verfügbar macht.public class Person { private int age; public int Age { get { return age; } set { age = value; } } }
Visual Studio Snippets
Obwohl wir die gesamte Eigenschaft im Labor zu Übungszwecken manuell eingegeben haben, stellt Visual Studio Code Snippets zur Verfügung, um häufig vorkommende Codeteile zu erstellen, mit denen wir allgemeine Sprachkonstrukte als Vorlagen verwenden können. Der obige Eigenschaftscodeschnipsel kann mit dem Schnipsel
propfull
abgerufen werden. Geben Sie den Namen des Schnipsels ein (propfull
) und drücken Sie dann die Tab -Taste, bis der Schnipsel aktiviert ist (normalerweise 2x).Weitere erwähnenswerte Schnipseln sind unter anderem:
ctor
: Konstruktorfor
: für Zyklusforeach
: foreach-Schleifeprop
: automatische Eigenschaft (siehe später)switch
: Schaltbefehlcw
: Console.WriteLine
Wir können solche Schnipseln herstellen.
-
Ergnänzen wir die Funktion
Main
in der DateiProgram.cs
, um unsere neue Eigenschaft zu testen.static void Main(string[] args) { var p = new Person(); p.Age = 17; p.Age++; Console.WriteLine(p.Age); }
-
Führen wir unseren Programm aus (F5)
Wir sehen, dass die Eigenschaft auf ähnliche Weise wie die Mitgliedsvariablen verwendet werden kann. Wenn die Eigenschaft abgefragt wird, wird der in der Eigenschaft definierte Teil
get
ausgeführt und der Wert der Eigenschaft ist der durch return zurückgegebene Wert. Wenn die Eigenschaft gesetzt ist, wird der in der Eigenschaft definierte Abschnittset
ausgeführt, und der Wert der speziellen Variablenvalue
in diesem Abschnitt entspricht dem als Eigenschaftswert angegebenen Ausdruck.Beachten wir in der obigen Lösung, wie elegant wir ein Jahr zum Alter einer Person hinzufügen können. In Java- oder C++-Code hätte ein ähnlicher Vorgang in der Form
p.setAge(p.getAge() + 1)
geschrieben werden können, was eine wesentlich umständlichere und schwieriger zu lesende Syntax ist als die Obige. Der Hauptvorteil der Verwendung von Eigenschaften besteht darin, dass unser Code syntaktisch sauberer ist und Wertzuweisungen/-abfragen in den meisten Fällen gut von tatsächlichen Funktionsaufrufen getrennt sind. -
Überprüfen wir, dass unser Programm wirklich
get
undset
aufruft. Dazu setzen wir Haltepunkte (breakpoints) innerhalb der Getter- und Setter-Blöcke, dazu klicken wir auf den grauen Balken am linken Rand des Code-Editors. -
Führen wir das Programm Schritt für Schritt aus. Starten wir dazu das Programm mit F11 statt F5, und drücken wir dann erneut F11, um es Zeile für Zeile ablaufen zu lassen.
Wir sehen, dass unser Programm tatsächlich jedes Mal den Getter aufruft, wenn ein Wert abgefragt wird, und den Setter, wenn ein Wert gesetzt wird.
-
Ein wichtiges Merkmal von Setter-Funktionen ist, dass sie die Möglichkeit der Wertüberprüfung bieten. Fügen wir in diesem Sinne dem Setter der Eigenschaft
Age
etwas hinzu.public int Age { get { return age; } set { if (value < 0) throw new ArgumentException("Ungültiges Alter!"); age = value; } }
Beachten wir, dass bei einfachen Gettern und Settern die Abfrage bzw. das Setzen von Werten in einer Zeile erfolgt, während sie bei komplexeren Stammdaten auf mehrere Zeilen aufgeteilt wird.
-
Um die Anwendung zu testen, ordnen wir dem Alter einen negativen Wert in der Funktion
Main
der KlasseProgram
zu.p.Age = -2;
-
Führen wir das Programm aus, um es zu testen, ob die Prüfung korrekt funktioniert, und korrigieren wir dann den Fehler, ändern wir das eingestellte Alter auf positiv.
p.Age = 2;
Auto-implementierte Eigenschaft (auto-implemented property)¶
In unserer täglichen Arbeit begegnen wir auch einer viel kompakteren Syntax von Eigenschaften. Diese Syntax kann verwendet werden, wenn wir eine Eigenschaft erstellen möchten, in der:
- wir wollen keine zusätzliche Logik zu den Getter- und Setter-Methoden hinzufügen,
- müssen wir nicht direkt auf die private Mitgliedsvariable zugreifen.
Nachfolgend ein Beispiel dafür.
-
Fügen wir eine solche automatisch implementierte Eigenschaft (auto-implemented property) zu unserer Klasse
Person
hinzu. Erstellen wir eine Eigenschaft vom Typstring
mit dem NamenName
.public string Name { get; set; }
Der syntaktische Unterschied zu den vorherigen ist, dass weder der get- noch der set-Zweig implementiert wurden (keine Klammern). Im Falle einer automatisch implementierten Eigenschaft erzeugt der Compiler eine versteckte Variable in der Klasse, auf die vom Code aus nicht zugegriffen werden kann und die zum Speichern des aktuellen Werts der Eigenschaft verwendet wird. Es sollte betont werden, dass dies nicht die zuvor eingeführte
name
Mitgliedsvariable (die gelöscht werden könnte) anhält und abfragt, sondern auf eine versteckte, neue Variable wirkt! -
Überprüfen wir nun ihre Funktionalität, und ergänzen wir die Funktion
Main
.static void Main(string[] args) { // ... p.Name = "Lukas"; // ... Console.WriteLine(p.Name); }
Standardwert (default value)¶
Für automatisch implementierte Eigenschaften können wir bei der Deklaration auch deren Anfangswert angeben.
-
Geben wir der Eigenschaft
Name
einen Anfangswert.public string Name { get; set; } = "anonymous";
Sichtbarkeit von Eigenschaften¶
Ein großer Vorteil der Eigenschaften, neben der völlig freien Implementierung, ist, dass die Sichtbarkeit des Getters und des Setters getrennt eingestellt werden kann.
-
Setzen wir die Sichtbarkeit des Setters der Eigenschaft
Name
auf privat.public string Name { get; private set; }
In diesem Fall wird ein Übersetzungsfehler in der Klasse
Program
für die Richtliniep.Name = "Luke";
zurückgegeben. Die Grundregel ist, dass Getter und Setter die Sichtbarkeit der Eigenschaft erben, die weiter eingeschränkt, aber nicht gelockert werden kann. Die Sichtbarkeitskontrolle kann sowohl für autoimplementierte als auch für nicht autoimplementierte Eigenschaften verwendet werden. -
Stellen wir die Sichtbarkeit wieder her (entfernen wir das Schlüsselwort
private
aus dem Property SetterName
), um den Übersetzungsfehler zu vermeiden.
Nur-Lese-Eigenschaft (readonly property)¶
Der Setter kann weggelassen werden, um eine schreibgeschützte Eigenschaft zu erhalten. Für eine automatisch implementierte Eigenschaft kann auch ein Anfangswert angegeben werden: Dies ist nur in einem Konstruktor oder durch Angabe eines Standardwerts (siehe oben) möglich, im Gegensatz zu Eigenschaften mit einem privaten Setter, deren Setter von jeder Mitgliedsfunktion der Klasse aufgerufen werden kann.
Die Definition einer schreibgeschützten Eigenschaft wird in den folgenden Codeschnipseln veranschaulicht (implementieren wir sie NICHT in unserem Code):
a) Autoimplementierter Fall
public string Name { get; }
b) Nicht automatisch implementierter Fall
private string name;
...
public string Name { get {return name; } }
Berechneter Wert (calculated value)¶
Eigenschaften mit nur Getter haben eine andere Verwendung. Sie kann auch verwendet werden, um einen berechneten Wert zu ermitteln, der immer einen Wert auf der Grundlage einer bestimmten Logik berechnet, aber im Gegensatz zur "Nur-Lese-Eigenschaft" verfügt sie nicht über ein Datenelement direkt hinter ihr. Dies wird im folgenden Codeschnipsel veranschaulicht (übernehmen wir ihn NICHT in unserem Code):
public int AgeInDogYear { get { return Age * 7; } }
2. Aufgabe - Delegat (delegate, Methodenreferenz)¶
Stellen wir sicher, dass der Code kompilierbar ist!
Die folgenden Übungen bauen auf den Ergebnissen der vorherigen Übungen auf. Wenn Ihr Programm nicht abstürzt oder nicht richtig funktioniert, melden Sie dies Ihrem/er Übungsleiter/in am Ende der Aufgaben, damit er/sie Ihnen bei der Behebung des Problems helfen kann.
Delegate sind Methodenreferenzen in .NET, das moderne Äquivalent zu C/C++-Funktionszeigern. Ein Delegat ist eine Möglichkeit, einen Variablentyp zu definieren, der verwendet werden kann, um auf Methoden zu verweisen. Nicht irgendein Zeiger, sondern - ähnlich wie bei C++-Funktionszeigern - solche, deren Typ (Parameterliste und Rückgabewert) dem Typ des Delegaten entspricht. Durch das "Aufrufen" der Delegatvariable wird die als Wert angegebene (registrierte) Methode automatisch aufgerufen. Ein Vorteil der Verwendung von Delegaten ist, dass wir zur Laufzeit entscheiden können, welche von mehreren Methoden wir aufrufen möchten.
Einige Beispiele für den Einsatz von Delegaten:
- die Funktion, die die Elemente vergleicht, als Parameter an eine universelle Ordnungsfunktion übergeben,
- ist die Implementierung einer universellen Filterlogik für eine allgemeine Sammlung, bei der eine Funktion als Delegat in einem Parameter übergeben wird, um zu entscheiden, ob ein Element in die gefilterte Liste aufgenommen werden soll,
- Implementierung des Publish-Subscribe-Musters, bei dem bestimmte Objekte andere Objekte über sich selbst betreffender Ereignisse informieren.
Im folgenden Beispiel werden wir Objekten der zuvor erstellten Klasse Person
erlauben, Objekte anderer Klassen frei zu benachrichtigen, wenn sich das Alter einer Person geändert hat. Zu diesem Zweck führen wir einen Delegatentyp (AgeChangingDelegate
) ein, der den aktuellen und neuen Wert des Alters der Person in seiner Parameterliste übergeben kann. Als Nächstes erstellen wir eine öffentliche Mitgliedsvariable des Typs AgeChangingDelegate
in der Klasse Person
, die es einer externen Partei ermöglicht, die Funktion anzugeben, über die sie die Benachrichtigung über Änderungen an der Instanz Person
anfordern wird.
-
Erstellen wir einen neuen Delegatentyp, der auf solche Funktionen verweisen kann, die
void
zurückgeben und zweiint
Parameter annehmen. Überprüfen wir, dass der neue Typ vor der KlassePerson
definiert ist, direkt im Gültigkeitsbereich des Namespaces!namespace PropertyDemo { public delegate void AgeChangingDelegate(int oldAge, int newAge); public class Person { // ...
AgeChangingDelegate
ist ein Typ (man beachte auch die VS-Färbung), der überall dort verwendet werden kann, wo ein Typ gesetzt werden kann (z.B. kann man eine Membervariable, eine lokale Variable, einen Funktionsparameter, etc. auf dieser Basis erstellen). -
Ermöglichen wir Objekten in
Person
, auf jede Funktion zu zeigen, die der obigen Signatur entspricht. Erstellen wir dazu eine Membervariable vom TypAgeChangingDelegate
in der KlassePerson
!public class Person { public AgeChangingDelegate AgeChanging;
Wie objektorientiert ist das?
Die Methodenreferenz, die als öffentliche Membervariable erstellt wurde, verstößt (vorerst) gegen die Grundsätze der objektorientierten Einheitsbegrenzung/Informationsverschleierung. Wir werden später darauf zurückkommen.
-
Rufen wir die Funktion jedes Mal auf, wenn sich das Alter unseres Person ändert. Dazu fügen wir dem Setter der Eigenschaft
Age
Folgendes hinzu.public int Age { get { return age; } set { if (value < 0) throw new ArgumentException("Ungültiges Alter!"); if (AgeChanging != null) AgeChanging(age, value); age = value; } }
Die obige Codezeile veranschaulichen mehrere wichtige Regeln:
- Die Validierungslogik geht in der Regel der Meldungslogik voraus.
- Es hängt von der Art der Meldelogik ab, ob sie vor oder nach der Auswertung ausgeführt wird (in diesem Fall, da sich das Wort "changing" auf etwas in Arbeit befindliches bezieht, geht die Meldung der Auswertung voraus, das Vorkommen wird durch die Vergangenheitsform angezeigt: "changed")
- Beachten wir, dass noch niemand der Mitgliedsvariablen vom Typ Delegat einen Wert zugewiesen hat (kein Abonnent/Teilnehmer). In solchen Fällen würde der Aufruf zu einer Ausnahme führen. Überprüfen wir daher immer, ob die Mitgliedsvariable
null
ist, bevor wir sie aufrufen. - Wenn das Ereignis ausgelöst wird, können wir auch die Überprüfung von
null
und die Auslösung des Ereignisses auf elegantere, kompaktere und thread-sichere Weise mit dem "?.
" Null-Bedingungs-Operator durchführen (C# 6 und höher):
statt
if (AgeChanging != null) AgeChanging(age, value);
können wir
AgeChanging?.Invoke(age, value);
schreiben.
Das Ereignis wird nur ausgelöst, wenn es nicht
null
ist, ansonsten geschieht nichts. -
Genauer gesehen, sollte das Ereignis nur ausgelöst werden, wenn sich das Alter tatsächlich ändert, d. h. die Verzweigung der Eigenschaft set sollte prüfen, ob der neue Wert mit dem alten übereinstimmt. Eine Lösung könnte darin bestehen, in der ersten Zeile des Setters sofort zurückzukehren, wenn der neue Wert mit dem alten übereinstimmt:
if (age == value) return; …
-
Wir sind fertig mit dem Code für die Klasse
Person
. Kommen wir zum Abonnenten! Als erstes müssen wir der KlasseProgram
eine neue Funktion hinzufügen.class Program { // ... private static void PersonAgeChanging(int oldAge, int newAge) { Console.WriteLine(oldAge + " => " + newAge); } }
Tipp
Überprüfen Sie, dass die neue Funktion im richtigen Bereich platziert ist! Während der Delegatentyp außerhalb der Klasse (aber innerhalb des Namespace) platziert ist, befindet sich die Funktion innerhalb der Klasse!
-
Melden wir uns schließlich für die Änderungsverfolgung in der Funktion
Main
an!static void Main(string[] args) { Person p = new Person(); p.AgeChanging = new AgeChangingDelegate(PersonAgeChanging); // ...
-
Starten wir das Programm!
Wenn wir z. B. einen Haltepunkt in der Zeile
AgeChanging?.Invoke(age, value);
setzen, die Anwendung debuggen und den Code schrittweise ausführem, können wir feststellen, dass das Ereignis bei jedem Setter-Durchlauf ausgeführt wird, sowohl bei der ersten Wertzuweisung als auch beim Inkrement. -
Fügen wir der Funktion
Main
mehrere Abonnenten hinzu (mit dem Operator+=
können wir neue Abonnenten zu den bereits vorhandenen hinzufügen) und führen wir das Programm dann aus.p.AgeChanging = new AgeChangingDelegate(PersonAgeChanging); p.AgeChanging += new AgeChangingDelegate(PersonAgeChanging); p.AgeChanging += PersonAgeChanging; // Kompaktere Syntax
Es ist zu erkennen, dass alle drei registrierten/"abonnierten" Funktionen bei jeder Wertänderung ausgeführt werden. Dies ist möglich, weil die Mitgliedsvariablen des Delegatentyps nicht nur eine Funktionsreferenz, sondern eine Funktionsreferenzliste enthalten (und pflegen).
Beachten wir in der dritten Zeile oben, dass wir Funktionsreferenzen mit einer kompakteren Syntax schreiben können, als wir sie beim ersten Mal gesehen haben: Geben wir einfach den Namen der Funktion nach dem
+=
Operator an, ohne dasnew AgeChangingDelegate(...)
. Unabhängig davon wird einAgeChangingDelegate
-Objekt diePersonAgeChanging
-Funktionen hinter den Kulissen umhüllen. In der Praxis verwenden wir diese kompaktere Syntax. -
Versuchen wir auch, uns abzumelden (an einem Punkt unserer Wahl) und starten wir dann das Programm.
p.AgeChanging -= PersonAgeChanging;
3. Aufgabe - Ereignis (event)¶
So wie Eigenschaften eine syntaktisch schlankere Alternative zu Getter- und Setter-Methoden sind, bietet der oben beschriebene Delegat-Mechanismus eine schlankere Alternative zu den aus Java bekannten Event Listenern. Allerdings verstößt unsere obige Lösung immer noch erheblich gegen einige OO-Prinzipien (Einheiteneinschränkung, Verbergen von Informationen). Wir können dies anhand der folgenden zwei Beispiele veranschaulichen.
-
Das Ereignis kann auch von außen ausgelöst werden (durch die Operationen anderer Klassen). Das ist unglücklich, denn so kann das Ereignis fälschlicherweise ausgelöst werden, auch wenn es in Wirklichkeit nicht eingetreten ist, und alle Teilnehmer werden getäuscht. Um dies zu demonstrieren, fügen wir die folgende Zeile am Ende der Funktion
Main
ein.p.AgeChanging(67, 12);
Hier haben wir ein gefälschtes Altersänderungsereignis für das Objekt
p
Person
ausgelöst und damit alle Abonnenten getäuscht. Eine gute Lösung wäre, wenn das Ereignis nur durch Aktionen der KlassePerson
ausgelöst werden könnte. -
Ein weiteres Problem ist das folgende. Während
+=
und-=
andere Funktionen, die die Liste abonniert haben, respektieren, können wir die Abonnements anderer jederzeit mit dem Operator=
überschreiben (löschen). Versuchen wir dies, indem wir die folgende Zeile einfügen (direkt nach den An- und Abmeldungen).p.AgeChanging = null;
-
Fügen wir das Schlüsselwort
event
zurAgeChanging
Member-VariablePerson.cs
hinzu!Person.cspublic event AgeChangingDelegate AgeChanging;
Das Schlüsselwort
event
ist eigentlich dazu gedacht, unser Programm zurück auf den objektorientierten Weg zu zwingen und die beiden oben genannten Probleme auszuschließen. -
Lassen wir uns versuchen, das Programm zu übersetzen. wir werden sehen, dass der Übersetzer unsere früheren Übertretungen jetzt als Übersetzungsfehler behandelt.
-
Entfernen wir die drei fehlerhaften Codezeilen (beachten wir, dass die erste direkte Wertzuweisung bereits ein Fehler ist), kompilieren wir dann und führen wir unsere Anwendung aus!
4. Aufgabe - Attribute¶
Anpassen der Serialisierung nach Attribut¶
Attribute sind ein deklarativer Weg, um Metadaten für Ihren Quellcode bereitzustellen. Ein Attribut ist eigentlich eine Klasse, die an ein bestimmtes Element des Programms (Typ, Klasse, Schnittstelle, Methode usw.) angehängt ist. Diese Metainformationen können von jedem (auch von uns selbst) gelesen werden, während das Programm läuft, und zwar über einen Mechanismus, der Reflection genannt wird. Die Attribute können auch als das .NET-Äquivalent zu den Java-Annotationen betrachtet werden.
property vs. attribute vs. static
Es stellt sich die Frage, welche Klasseneigenschaften in properties und welche in attributes einer Klasse untergebracht werden sollten. Eigenschaften beziehen sich auf die Objektinstanz selbst, während sich ein Attribut auf die Klasse (oder ein Mitglied der Klasse) bezieht, die das Objekt beschreibt.
In dieser Hinsicht sind Attribute näher an statischen Eigenschaften, aber es lohnt sich immer noch eine Überlegung, ob man ein bestimmtes Datenelement als statisches Mitglied oder als Attribut definiert. Mit einem Attribut ist die Beschreibung deklarativer, und wir verschmutzen den Code nicht mit Details, die nicht in der öffentlichen Schnittstelle der Klasse erscheinen sollten.
.NET definiert viele eingebaute Attribute, die eine große Vielfalt an Funktionen haben können. Die im folgenden Beispiel verwendeten Attribute kommunizieren beispielsweise verschiedene Metainformationen mit dem XML-Serialisierer.
-
Fügen wir den folgenden Zeilen am Ende der Funktion
Main
ein und führen wir dann unser Programm aus!var serializer = new XmlSerializer(typeof(Person)); var stream = new FileStream("person.txt", FileMode.Create); serializer.Serialize(stream, p); stream.Close(); Process.Start(new ProcessStartInfo { FileName = "person.txt", UseShellExecute = true, });
Der letzte Funktionsaufruf
Process.Start
im obigen Beispiel ist nicht Teil der Serialisierungslogik, sondern lediglich sondern nur eine kluge Methode, um die resultierende Datendatei mit dem Windows-Standardtextdateibetrachter zu öffnen. Wir können dies versuchen, aber es hängt davon ab, welche .NET-Laufzeitumgebung wir verwenden und ob diese von unserem Betriebssystem unterstützt wird. Ist dies nicht der Fall, erhalten wir bei der Ausführung eine Fehlermeldung. In diesem Fall lassen wir es unkommentiert und öffnen wir die Dateiperson.txt
manuell im Dateisystem (sie befindet sich in unserem Visual Studio Ordner unter \bin\Debug\neben unserer .exe Anwendung). -
Schauen wir uns die Struktur der resultierenden Datei an. Beachten wir, dass jede Eigenschaft auf das XML-Element abgebildet wird, das ihrem Namen entspricht.
-
.NET-Attribute ermöglichen es uns, unsere Klasse
Person
mit Metadaten zu versehen, die das Verhalten der Serialisierung direkt verändern. Das AttributXmlRoot
bietet die Möglichkeit, das Wurzelelement umzubenennen. Platzieren wir es über der KlassePerson
![XmlRoot("deutsche Person")] public class Person { // ... }
-
Das
XmlAttribute
-Attribut zeigt dem Serialisier an, dass die markierte Eigenschaft auf ein xml-Attribut und nicht auf ein xml-Element abgebildet werden soll. Machen wir daraus die EigenschaftAge
(und nicht die Member-Variable!)![XmlAttribute("Alter")] public int Age
-
Das Attribut
XmlIgnore
zeigt dem Serialiser an, dass die markierte Eigenschaft vollständig aus dem Ergebnis ausgelassen werden soll. Versuchen wir es über die EigenschaftName
.[XmlIgnore] public string Name { get; set; }
-
Führen wir unsere App aus! Vergleichen wir die Ergebnisse mit den vorherigen Ergebnissen.
5. Aufgabe - Delegaten 2.¶
In den Aufgaben 2 und 3 haben wir ereignisbasierte Nachrichtenübermittlung mit Delegaten implementiert. Als einer anderen typischen Verwendung von Delegaten ist ihre Verwendung als Funktionsreferenzen, um eine Implementierung eines undefinierten Schritts an einen Algorithmus oder eine komplexere Operation zu übergeben.
Zum Beispiel kann die eingebaute generische Listenklasse (List<T>
) mit der Funktion FindAll
eine neue Liste mit allen Elementen zurückgeben, die eine bestimmte Bedingung erfüllen. Die spezifische Filterbedingung kann als Funktion angegeben werden, genauer gesagt als Delegate-Parameter (dies ruft FindAll
für jedes Element auf), der für jedes Element, das wir in der Ergebnisliste sehen wollen, true zurückgibt. Der Typ des Funktionsparameters ist der folgende vordefinierte Delegatentyp (er muss nicht eingegeben/erstellt werden, er existiert bereits):
public delegate bool Predicate<T>(T obj)
Note
Um die vollständige Definition oben anzuzeigen, geben Sie einfach Predicate
irgendwo ein, z. B. am Ende der Funktion Main
, klicken Sie mit der Maus darauf, und verwenden Sie F12, um zur Definition zu navigieren.
Das heißt, sie nimmt als Eingabe eine Variable des gleichen Typs wie der Typ des Listenelements und als Ausgabe einen logischen (booleschen) Wert. Um dies zu veranschaulichen, fügen wir unserem vorherigen Programm einen Filter hinzu, der nur die ungeraden Einträge in der Liste behält.
-
Stellen wir in unserer Anwendung eine Filterfunktion bereit, die ungerade Zahlen zurückgibt:
private static bool MyFilter(int n) { return n % 2 == 1; }
-
Vervollständigen wir den Code, den wir zuvor geschrieben haben, mit unserer Filterfunktion:
var list = new List<int>(); list.Add(1); list.Add(2); list.Add(3); list = list.FindAll(MyFilter); foreach (int n in list) { Console.WriteLine($"Wert: {n}"); }
-
Führen wir die Anwendung aus. Beachten wir, dass in der Konsole nur ungerade Zahlen angezeigt werden.
- Als Kuriosität können wir einen Haltepunkt innerhalb unserer Funktion
MyFilter
setzen und beobachten, dass die Funktion tatsächlich für jedes Listenelement einzeln aufgerufen wird.
Collection initializer syntax
Für alle Klassen (typischerweise Sammlungen) mit der Methode Add
, die die Schnittstelle IEnumerable
implementieren, lautet die Syntax für die Sammlungsinitialisierung wie folgt:
var list = new List<int>() { 1, 2, 3 };
Ab C# 12 kann eine noch einfachere Syntax (sogenannte collection expression) verwendet werden, um eine Sammlung zu initialisieren, wenn der Compiler aus dem Typ der Variablen schließen kann, dass es sich um eine Sammlung handelt. Z.B.:
List<int> list = [1, 2, 3];
6. Aufgabe - Lambda-Begriffe¶
Die entsprechenden Themen werden in dem Vorlesungsmaterial ausführlich behandelt, sie werden hier nicht wiederholt. Siehe das Kapitel "Lambda-Ausdruck" im Dokument "Vorlesung 02 - Sprachwerkzeuge.pdf". Das Schlüsselelement ist =>
(Lambda-Operator), das die Definition von Lambda-Ausdrücken, d. h. anonymen Funktionen, ermöglicht.
Action und Func
Die in .NET eingebauten generischen Delegatentypen Func
und Action
werden hier aus Zeitgründen nicht behandelt. Sie sind immer noch Teil des grundlegende Kenntnisse!
Die vorherige Aufgabe 5 wird wie folgt gelöst: Geben wir keine separate Filterfunktion an, sondern spezifizieren wir die Filterlogik in Form eines Lambda-Ausdrucks für die Operation FindAll
.
Wir brauchen nur eine Zeile zu ändern:
list = list.FindAll((int n) => { return n % 2 == 1; });
Eine unbenannte Funktion wird definiert und an die Funtkion FindAll
übergeben:
- dies ist ein Lambda-Term,
- auf der linken Seite von
=>
haben wir die Parameter der Operation angegeben (hier gab es nur einen), - auf der rechten Seite von
=>
haben wir der Stamm der Operation angegeben (die gleiche wie der Stamm der vorherigenMyFilter
).
Die obige Zeile kann in einer viel einfacheren und klareren Form geschrieben werden:
list = list.FindAll(n => n % 2 == 1);
Es wurden die folgenden Vereinfachungen vorgenommen:
- wird der Typ des Parameters nicht geschrieben: der Compiler kann ihn aus dem Typ des Delegatenparameters von
FindAll
ableiten, derPredicate
ist. - die Klammern um den Parameter können weggelassen werden (da es nur einen Parameter gibt)
- auf der rechten Seite von
=>
könnten wir die Klammern undreturn
weglassen (weil es nur einen Ausdruck im Funktionsrumpf gab, der von der Funktion zurückgegeben wird).
7. Andere Sprachkonstruktionen¶
Im Folgenden werfen wir einen Blick auf einige der C#-Sprachelemente, die bei alltäglichen Programmieraufgaben immer häufiger verwendet werden. Während der Übung kann es sein, dass keine Zeit bleibt, diese zu überprüfen.
Ausdruckskörpermember (Expression-bodied members)¶
Manchmal schreiben wir kurze Funktionen oder, im Falle von Eigenschaften, sehr oft kurze get/set/init-Definitionen, die aus einem einzigen Ausdruck bestehen. In diesem Fall kann der get/set/init-Stamm einer Funktion oder Eigenschaft unter Verwendung der Syntax für sogenannten Ausdruckskörpermember (expression-bodied members) angegeben werden, unter =>
. Dies kann unabhängig davon geschehen, ob es im Kontext einen Rückgabewert (Return-Anweisung) gibt oder nicht.
In den Beispielen werden wir sehen, dass die Verwendung von Ausdrucks-Tags nichts weiter als eine kleine syntaktische "Wendung" ist, um die Notwendigkeit zu minimieren, so viel umgebenden Code wie möglich in solch einfachen Fällen zu schreiben.
Schauen wir uns zunächst ein Funktionsbeispiel an (angenommen, die Klasse hat eine Mitgliedsvariable oder eine Eigenschaft Age
):
public int GetAgeInDogYear() => Age * 7;
public void DisplayName() => Console.WriteLine(ToString());
return
entfernt, so dass die Syntax kompakter ist.
Wichtig
Obwohl hier das Token =>
verwendet wird, hat dies nichts mit den zuvor besprochenen Lambda-Ausdrücken zu tun: Es ist einfach so, dass dasselbe =>
Token (Symbolpaar) von C# für zwei völlig unterschiedliche Dinge verwendet wird.
Beispiel für die Angabe eines Property Getters:
public int AgeInDogYear { get => Age * 7; }
Wenn wir nur einen Getter für die Eigenschaft haben, können wir sogar das Schlüsselwort get
und die Klammern weglassen.
public int AgeInDogYear => Age * 7;
Der Unterschied zur ähnlichen Syntax der bisherigen Funktionen ist, dass wir die geschweifte Klammern nicht ausgeschrieben haben.
Objektinitialisierer (Object initializer)¶
Die Initialisierung von öffentlichen Eigenschaften/Mitgliedsvariablen und der Aufruf des Konstruktors können mit einer Syntax kombiniert werden, die als Objektinitialisierung bezeichnet wird. Dazu wird nach dem Konstruktoraufruf ein Block mit geschweifte Klammern geöffnet, in dem der Wert der öffentlichen Eigenschaften/Mitgliedsvariablen unter Verwendung der folgenden Syntax angegeben werden kann.
var p = new Person()
{
Age = 17,
Name = "Lukas",
};
Eigenschaften/Mitglieder werden initialisiert, nachdem der Konstruktor ausgeführt wurde (wenn die Klasse einen Konstruktor hat). Diese Syntax ist auch deshalb vorteilhaft, weil sie als ein Ausdruck zählt (im Gegensatz zu drei Ausdrücken, wenn wir ein nicht initialisiertes Objekt Person
erstellen und dann in zwei weiteren Schritten Werte an Age
und Name
übergeben). Auf diese Weise können wir ein initialisiertes Objekt direkt als Parameter für einen Funktionsaufruf übergeben, ohne eine separate Variable deklarieren zu müssen.
void Foo(Person p)
{
// etwas mit p machen
}
Foo(new Person() { Age = 17, Name = "Lukas" });
Die Syntax ist auch zum Kopieren und Einfügen geeignet, denn wie wir in den obigen Beispielen sehen können, spielt es keine Rolle, ob nach der letzten Eigenschaft ein Komma steht oder nicht.
Eigenschaften - Init only setter¶
Die Syntax für die Objektinitialisierung im vorigen Abschnitt ist sehr praktisch, erfordert aber, dass die Eigenschaft öffentlich ist. Wenn wir möchten, dass eine Eigenschaft nur bei der Erstellung des Objekts auf einen Wert gesetzt wird, müssen wir einen Konstruktorparameter einführen und ihn auf eine Nur-Lesbare-Eigenschaft (Getter-Only) setzen. Eine einfachere Lösung für dieses Problem ist die so genannte Init only setter-Syntax, bei der wir mit dem Schlüsselwort init
einen "Setter" erstellen können, der nur im Konstruktor und in der im vorigen Kapitel beschriebenen Syntax für die Objektinitialisierung gesetzt werden darf, nicht aber danach.
public string Name { get; init; }
var p = new Person()
{
Age = 17,
Name = "Lukas",
};
p.Name = "Test"; // Erstellungsfehler, kann nicht nachträglich geändert werden
Wir können auch den init only setter als obligatorisch festlegen, indem wir das Schlüsselwort required
für die Eigenschaft verwenden. In diesem Fall muss der Wert der Eigenschaft in der Syntax der Objektinitialisierung angegeben werden, da sonst ein Übersetzungsfehler auftritt.
public required string Name { get; init; }
Dies ist auch deshalb nützlich, weil wir die obligatorischen Konstruktorparameter speichern können, wenn wir die Eigenschaften der Klasse ohnehin veröffentlichen und die Syntax der Objektinitialisierung unterstützen wollen.
8. Aufgabe - Generische Klassen¶
Hinweis: Die Zeit für diese Übung reicht wahrscheinlich nicht aus. In diesem Fall ist es ratsam, die Übung zu Hause zu machen.
Generische Klassen in .NET ähneln den Template-Klassen in C++, sind aber näher an den bereits bekannten generischen Klassen in Java. Sie können verwendet werden, um generische (Multi-Typ), aber typsichere Klassen zu erstellen. Wenn wir ohne generische Klassen ein Problem allgemein behandeln wollen, verwenden wir Daten des Typs object
(da in .NET alle Klassen von der Klasse object
abgeleitet sind). Dies ist z. B. bei ArrayList
der Fall, einer Allzwecksammlung zum Speichern beliebiger Elemente des Typs object
. Schauen wir uns ein Beispiel für die Verwendung von ArrayList
an:
var list = new ArrayList();
list.Add(1);
list.Add(2);
list.Add(3);
for (int n = 0; n < list.Count; n++)
{
//cast ist nötig, sonder es kann nicht kompiliert werden
int i = (int)list[n];
Console.WriteLine($"Wert: {i}");
}
Bei der obigen Lösung ergeben sich folgende Probleme:
ArrayList
speichert jedes Element alsobject
.- Wenn wir auf ein Element in der Liste zugreifen wollen, müssen wir es immer in den richtigen Typ umwandeln.
- Nicht typsicher. Im obigen Beispiel hindert wir nichts (und keine Fehlermeldung) daran, ein Objekt eines anderen Typs in die Liste neben dem Typ
int
einzufügen. In diesem Fall würden wir nur dann einen Fehler erhalten, wenn wir versuchen, den Typ, der nichtint
ist, aufint
zu übertragen. Bei der Verwendung generischer Sammlungen werden solche Fehler während der Übersetzung erkannt. - Bei der Speicherung von Daten des Typs "Wert" ist die Liste langsamer, da der Typ "Wert" zunächst in eine Box eingeschlossen werden muss, um als
object
(d. h. als Referenztyp) gespeichert werden zu können.
Die Lösung des obigen Problems unter Verwendung einer allgemeinen Liste sieht wie folgt aus (in der Übung wird nur die hervorgehobene Zeile im zuvor eingegebenen Beispiel geändert):
var list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
for (int n = 0; n < list.Count; n++)
{
int i = list[n]; // Kein cast erforderlich
Console.WriteLine($"Wert: {i}");
}