C# 9.0 bringt prägnante, unveränderbare Typen

Seite 2: Init Only Properties

Inhaltsverzeichnis

In dem Listing sind die Properties nicht wie bisher in C# üblich mit get; set;, sondern get; init; deklariert. Dies sind sogenannte Init Only Properties. Es sind Properties, deren Wert sich nur bei der Objektinitialisierung (Konstruktionsphase) setzen lässt und die danach unveränderlich sind. Zur Konstruktionsphase gehören nicht nur der Konstruktor, sondern auch die Objektinitialisierung in geschweiften Klammern nach dem Konstruktoraufruf. Damit ist sowohl das Setzen von Properties erlaubt, die im Konstruktoraufruf ausgelassen wurden

Person hs = new Person(123, "Holger", "Schwichtenberg") { Status = "verheiratet" };

als auch das Setzen von Properties, die im Konstruktoraufruf bereits vorkommen:

Person hs = new Person(123, "Holger", "") { Name="Schwichtenberg", Status = "verheiratet" };

Beides ergibt hier zugegebenermaßen wenig Sinn, denn man kann ja alle Properties bereits im Konstruktor setzen. Dass das dennoch erwähnt wurde, liegt daran, dass ein Record-Typ neben dem aus Konstruktor automatisch erzeugten Properties durchaus weitere Properties und auch Methoden besitzen kann (s. Listing 2):

public enum Geschlecht
{ m, f, d }
 
public record Person(int ID, string Vorname, string Name, string Status = "unbekannt")
{
 public Geschlecht Geschlecht { get; set; }
 public int Alter { get; set; }
 
 public string GetAnrede() => Geschlecht switch
 {
  Geschlecht.f => "Sehr geehrte Frau " + Name,
  Geschlecht.m => "Sehr geehrter Herr " + Name,
  _ => "Hallo " + Name
 };
}

Listing 2: Record-Typ kann weitere Properties und Methoden besitzen

In diesem Fall kann man die Zusatz-Properties Geschlecht und Alter nicht im Konstruktor, aber via Objektinitialisierung in geschweiften Klammern befüllen:

Person hs1d = new Person(123, "Holger", "Schwichtenberg", "verheiratet") { Alter = 48, Geschlecht = Geschlecht.m };

Diese Properties sind auch später noch änderbar, weil sie als normale Properties mit get; set; deklariert sind.

Init Only Properties können C#-Entwickler nicht nur in Records, sondern auch in normalen .NET-Klassen einsetzen, die weiterhin mit class deklariert werden. Dort müssen sie ja einen Konstruktor selbst explizit schreiben, der nicht zwingend alle Properties umfasst. In diesem Fall ergibt es Sinn, einen Teil der Properties im Konstruktoraufruf und den anderen Teil in der Initialisierung in geschweiften Klammern zu setzen.

Aber zurück zu den Record-Typen. Wer sich Listing 1 genauer ansieht, findet dort neben den Init Only Properties weitere Besonderheiten:

  • die Protected-Methode PrintMembers(), die den Inhalt des Objekts in einem StringBuilder liefert (ohne dabei Reflection einzusetzen!). Es wird aber nur die oberste Ebene der öffentlichen Attribute (Field und Properties) ausgegeben (keine Unterobjekte).
  • Überschreiben von ToString(), das den Klassennamen und den Inhalt des Objekts via Aufruf von PrintMembers() liefert.
  • Impementierung der Operatorüberladung für Gleichheit (==) und Ungleichheit (!=) sowie der Methode Equals(). Es findet ein flacher Vergleich (nur die oberste Ebene) statt.
  • Implementierung einer öffentlichen Methode Clone(), die eine Inhaltskopie erstellt (auch hier flache Kopie ohne Einsatz von Reflection).
  • Es gibt eine Methode Deconstruct(), die den Zustand des Objekts in Einzelvariablen zerlegt. Das Deconstruct()-Verfahren wurde in Zusammenhang mit Tupeln in C# 7.0 eingeführt.

Diese Möglichkeiten seien nun im Praxiseinsatz gezeigt. Es ist also möglich, ein Objekt direkt mit seinen Werten auszugeben:

Console.WriteLine(hs);

Dies liefert:

Person { ID = 123, Vorname = Holger, Name = Schwichtenberg, Status = unbekannt }

Auch die Dekonstruktion in Einzelwerte ist möglich (mit dem Unterstrich übergehen Entwickler Werte, die ihn nicht interessieren):

// Nutzung von Deconstruct()
var (_, v, _, s) = hs;
Console.WriteLine("Vorname: " + v + " Status: " + s);

Wenn man eine Objektzuweisung vornimmt, wird wie bei Klassen eine Referenzkopie erzeugt:

Person hs_ref = hs;

Um eine Wertkopie anzulegen, gibt es in der Record-Klasse die Methode Clone(). Man kann eine solche Kopie aber nicht anlegen, indem man Clone() direkt aufruft. Denn die Methode existiert zur Entwicklungszeit noch nicht; sie wird erst vom Compiler erzeugt. Hier hat Microsoft dem Schlüsselwort with eine weitere Bedeutung verpasst. Bei der Verwendung von with nach einer Zuweisung zwischen Objektreferenzen wird Clone() aufgerufen. Bei der Angabe leerer geschweifte Klammern erhält man eine 1:1-Kopie, deren Vergleich mit dem Original immer noch "true" liefert.

Person hs_Klon1 = hs with { };

Alternativ kann man nach dem Schlüsselwort with zu verändernde Properties angeben, sodass dann ein Vergleich mit dem Original "false" liefert.

Person hs_Klon2 = hs with { Status = "geklont wegen hoher Nachfrage" };

Listing 3 zeigt das an Beispielen und beweist auch mit dem in .NET enthaltenen ObjectIDGenerator, das hs_ref eine Objektreferenz auf hs ist, hs_Klon1 und hs_Klon2 aber Wertkopien.

// Kopie der Objektreferenz
Person hs_ref = hs;
Console.WriteLine(hs_ref);
Console.WriteLine(hs == hs_ref); // true
 
// Objektkopie mit Veränderung via Clone()
Person hs_Klon1 = hs with { };
Console.WriteLine(hs_Klon1);
Console.WriteLine(hs == hs_Klon1); // true
 
// Objektkopie mit Veränderung via Clone()
Person hs_Klon2 = hs with { Status = "geklont wegen hoher Nachfrage" };
Console.WriteLine(hs_Klon1);
Console.WriteLine(hs == hs_Klon2); // false
 
var oidg = new System.Runtime.Serialization.ObjectIDGenerator();
Console.WriteLine("Objekt-IDs hs=" + oidg.GetId(hs, out _) + " hs_ref=" + oidg.GetId(hs_ref, out _) + " hs_Klon2=" + oidg.GetId(hs_Klon1, out _) + " hs_Klon3=" + oidg.GetId(hs_Klon2, out _));

Listing 3: Referenzkopie vs. Wertkopie bei Record-Typen

Zwei Punkte in Bezug auf Record-Typen sind noch zu erwähnen:

1. Vererbung von anderen Record-Typen ist möglich (aber nicht von normalen Klassen!). Die Vererbung lässt sich mit sealed unterbinden. Die erbende Record-Klasse nimmt nach dem Doppelpunkt Bezug auf den Konstruktor des gewünschten Basis-Record-Typs.

public record Dozent(int ID, string Vorname, string Name, string Status = "unbekannt", List<string> Themen = null) : Person(ID, Vorname, Name, Status);Man kann auch Record-Typen explizit ausformulieren, wie Listing 3 zeigt. In diesem Fall ist man frei im Aufbau des Konstruktors, muss dafür aber auch wieder alle Properties explizit deklarieren und ggf. im Konstruktor zuweisen. Hier entsteht durch den Compiler der in Listing 1 gezeigte Code für PrintMembers(), ToString(), Equals() und die Operatoren für Gleichheit und Ungleichheit. Es fehlt dann aber die Deconstruct()-Methode.

2. Man kann auch Record-Typen explizit ausformulieren, wie Listing 4 zeigt. In diesem Fall ist man frei im Aufbau des Konstruktors, muss dafür aber auch wieder alle Properties explizit deklarieren und gegebenenfalls im Konstruktor zuweisen. Hier entsteht durch den Compiler der in Listing 1 gezeigte Code für PrintMembers(), ToString(), Equals() und die Operatoren für Gleichheit und Ungleichheit. Es fehlt dann aber die Deconstruct()-Methode.

record Person
 {
  public int ID { get; init; }
  public string Vorname { get; set; }
  public string Name { get; set; }
  public string Status = "unbekannt";
  public Person()
  {
 
  }
  public Person(int id, string vorname, string name)
  {
   this.ID = id;
   this.Vorname = vorname;
   this.Name = name;
  }
 }
 
 record Dozent : Person
 {
  public List<string> Themen { get; set; } = new();
  public Dozent(int id, string vorname, string name) : base(id, vorname, name) { }
 }

Listing 4: Explizit ausformulierte Record-Typen