CSharp 12.0 (C# 12.0)

Eintrag zuletzt aktualisiert am: 13.11.2023

Die zwölfte Sprachversion (C# 12.0) ist zusammen mit .NET 8.0 am 14.11.2023 erschienen.

Gewertete Liste der neuen Sprachfeatures in C# 12.0

Die wichtigsten Neuerungen in C# 12.0 sind:
  • Primärkonstruktoren
  • Vereinfachte Initialisierung und Zuweisung für Mengen (Collection Expressions / Collection Literals)
  • Typaliase
  • Optionale Lambda-Parameter
  • Parametermodifizierer ref readonly
  • Erweiterter Einsatz von nameof()

Support für C# 12.0

C# 12.0 wird offiziell von Microsoft erst ab .NET 8.0 unterstützt ("C# 12.0 is supported only on .NET 8 and newer versions." [https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/configure-language-version]. Man kann allerdings die meisten (aber nicht alle!) C# 12.0-Sprachfeatures auch in älteren .NET-Versionen einschließlich .NET Framework, .NET Core und Xamarin nutzen. Dazu muss man die <LangVersion> in der Projektdatei auf "12.0" erhöhen.

Notwendige Visual Studio-Version für C# 12.0 ist Visual Studio 2022 v17.8 oder höher. Eine Verwendung von C# 12.0 ist sowohl mit Visual Studio for Mac 2022 als auch einer aktuellen Version von Visual Studio Code und anderen OmniSharp-kompatiblen Editoren [https://www.omnisharp.net] möglich.

Primärkonstruktoren in C# 12.0

Die wesentliche, seit .NET 8.0 Preview 2 implementierte Neuerung in C# 12.0 sind die Primärkonstruktoren für Klassen. Alte Hasen unter den C#-Entwicklern werden sich erinnern, dass dieses Sprachfeature bereits im Jahr 2014 als Prototyp für C# 6.0 verfügbar war, dann aber doch gestrichen wurde.

Nun, sechs C#-Versionen weiter, kommt Microsoft in C# 12.0 darauf zurück, auch vor dem Hintergrund der Record-Typen, die es seit C# 9.0 mit Primärkonstruktoren gibt:
public record Person(int ID, string Name, string Website = "");

Ein Primärkonstruktor ist eine Parameterliste direkt hinter dem Typnamen. In C# 12.0 ist das auch für Klassendefinitionen möglich:
public class Person(int ID, string Name, string Website = "");

Solch eine Klasse kann ohne Inhaltsbereich (also geschweifte Klammern) existieren, ist aber wertlos. Anders als bei den in C# 9.0 eingeführten Record-Typen erstellt der Primärkonstruktor nämlich keine öffentlichen Properties in der Klasse, sondern nur private Fields. Wenn man diese Klasse mit Primärkonstruktor in einem Decompiler betrachtet, sieht man zunächst überhaupt keine Verarbeitung der Parameter im Primärkonstruktor:
public class Person
{
public Person(int ID, string Name, string Website = "") { }
}

Das liegt daran, dass die Primärkonstruktorparameter gar nicht verwendet werden. Wir müssen die Klasse z.B. um ToString() erweitern, siehe Listing.

Listing: Klasse mit Primärkonstruktor und Methode ToString(), in der alle Primärkonstruktoren verwendet werden


public class Person(int ID, string Name, string Website = "")
{
public override string ToString()
{
return $"Person #{ID}: {Name} -> {Website}";
}
}

Nun sehen wir im Decompiler, dass für jeden Primärkonstruktorparameter ein privates Field angelegt wurde inklusive Zuweisung im Konstruktor.

Listing: Decompilat des vorherigen Listings mit ILSpy


public class Person
{
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private int <ID>PCBackingField;

[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string <Name>PCBackingField;

[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string <Website>PCBackingField;

public Person(int ID, string Name, string Website = "unbekannt")
{
<ID>PCBackingField = ID;
<Name>PCBackingField = Name;
<Website>PCBackingField = Website;
base.ctor();
}

public override string ToString()
{
return $"Person #{<ID>PC_BackingField}: {<Name>PC__BackingField} -> {<Website>PC_BackingField}";
}
}

Um öffentlich auf die im Primärkonstruktor übergebenen Daten zugreifen zu können, muss man die Konstruktorparameter für Zuweisungen verwenden, siehe Name und Website im nächsten Listing. Im Listing gibt es neben der Klasse Person eine zweite, abgeleitete Klasse Autor mit Primärkonstruktor.

Listing: Primärkonstruktorbeispiel mit Zuweisung der Primärkonstruktorparameter an öffentliche Properties und Vererbung


using System.ComponentModel.DataAnnotations;
using ITVisions;

namespace NET8Konsole.CS12;

/// <summary>
/// Klasse mit Primärkonstruktor
/// </summary>
public class Person(Guid personGuid, string name)
{
public Guid PersonGuid { get; set; } = personGuid;

public string Name { get; set; } = name;

public Person() : this(Guid.Empty, "")
{
// Aufruf ohne Parameter möglich, führt aber zu einem ungültigen Objekt ;-)
}

public override string ToString()
{
return $"Person {personGuid}: {Name}";
}
}

/// <summary>
/// Abgeleitete Klasse mit Primärkonstruktor
/// </summary>
public class Autor(Guid personGuid, string name, string website) : Person(personGuid, name)
{
public string Website { get; set; } = website;

public override string ToString()
{
return $"Autor {personGuid}: {Name} -> {Website}";
}
}

internal class CS12PrimaryConstructorsDemo
{
public void Run()
{
var p = new Person();
Console.WriteLine(p.Name);
Console.WriteLine(p.ToString());

var a = new Autor(Guid.NewGuid(), "Dr. Holger Schwichtenberg", "www.IT-Visions.de");
Console.WriteLine(a.Name);
Console.WriteLine(a.Website);
Console.WriteLine(a.ToString());
}
}

Vereinfachte Initialisierung und Zuweisung für Mengen (Collection Expressions / Collection Literals)

Eine sehr schöne Neuerung in C# 12.0 ist die vereinfachte Syntax für die Initialisierung von Mengen. Microsoft nannte dieses Sprachfeature ursprünglich Collection Literals, dann Collection Expressions.
Mit dieser neuen Syntaxform kann man die bisher sehr heterogene Initialisierungsformen von Objektmengen stark vereinheitlichen im Stil von JavaScript, also mit den Werten in eckigen Klammern, getrennt durch Kommata.

Beispiele


int[] a = [1,2,3];
Span<int> b = [1,2,3];
List<int> d = [1,2,3];
Ienumerable<int> e = [1,2,3];

Die Syntax mit den eckigen Klammern ist nicht nur bei der Erstinitialisierung, sondern auch bei späteren Zuweisungen möglich:
string[] sites1, sites2, sites3 = ["angular-schulungen.de", "dotnettraining.de"];
sites1 = ["www.dotnetframework.de", "www.dotnet8.de", "dotnet-lexikon.de"];
sites2 = ["www.dotnet-doktor.de", "www.powershell-schulungen.de"];

Mit dem Spread-Operator .. kann man im Rahmen der Initialisierung Mengen in andere Mengen integrieren. Der Spread-Operator sorgt dafür, dass keine verschachtelte, sondern eine flache Liste entsteht!
// Array aus den Elementen der Arrays erstellen mit Spread Operator
string[] allSitesAsArray = [.. sites1, .. sites2, "www.IT-Visions.de", .. sites3];
// Liste aus den Elementen der Arrays erstellen mit Spread Operator
List<string> allSitesAsList = [.. sites1, .. sites2, "www.IT-Visions.de", .. sites3];
// Liste noch mal erweitern
allSitesAsList = [.. allSitesAsList, "powershell-schulungen.de"];
// Auflisten: 7 Sites sind nun in der Liste
foreach (var site in allSitesAsList)
{
Console.WriteLine(site);
}

Es entsteht eine Menge mit diesen acht Websites, denn neben den sieben in den Variables sites1, sites2 und sites3 enthaltenen Websites wurde noch eine weitere Website hinzugefügt.

Typaliase in C# 12.0

Seit C# 12.0 gibt es mit Typaliasen die Möglichkeit, für einen Typen einen alternativen Namen zu definieren. Typen können dabei C#-Typen, .NET-Basisklassen/-Strukturen oder eigene Klassen/Strukturen sein.
Einmal mehr kommt dabei das Schlüsselwort using zum Einsatz.

Beispiel 1


Wenn Sie schreiben
using Numbers = int[];
können Sie fortan Numbers anstelle von int[] bei Typdeklarationen verwenden:
Numbers numbers = new int[10];
Allerdings darf man den Alias NICHT bei der Instanziierung verwenden:
Numbers numbers = new Numbers;
Auch kann man leider keinen Alias definieren mit Hilfe eines Aliases. Das geht also auch nicht:
using DbIntList = List<DbInt>;

Zweites Beispiel: DbInt als Alias für ein int? bzw. Nullable<int>:


using DbInt = int?;
Danach ist möglich:
public DbInt LoadNumberFromDatabase()
{
try
{

}
catch (Exception)
{
return null;
}
}

DbInt n;
n = LoadNumberFromDatabase();
Console.WriteLine(n == null ? "null" : n);

Drittes Beispiel: Typalias für ein Tupel


using Measurement = (string Units, int Distance);
Danach ist möglich:
public Measurement Add(Measurement m1, Measurement m2)
{
if (m1.Units == m2.Units)
{
return (m1.Units, m1.Distance + m2.Distance);
}
else
{
throw new Exception("Units do not match!");
}
}

Measurement m1 = ("m", 100);
Console.WriteLine(m1.Distance + " " + m1.Units);

Measurement m2 = ("m", 42);
Console.WriteLine(m2.Distance + " " + m2.Units);

Measurement m3 = Add(m1, m2);
Console.WriteLine(m3);

Viertes Beispiel: Typalias für eine .NET-Klasse


using MyPerson = BO.Person;
Anders als beim Int-Array-Alias numbers ist hier eine Verwendung bei der Instanziierung gestattet:
MyPerson p = new MyPerson();
MyPerson[] pArray = new MyPerson[10];

Ein Typalias muss am Beginn einer Datei vor allen Typimplementierungen stehen. Der Typalias darf vor oder nach den using-Anweisungen für Namensraumimporte und vor oder nach der Namensraumdeklaration stehen. Ausnahme: Wenn der Typalias nicht nur für eine Datei, sondern alle Dateien im Projekt gelten soll, dann muss der Alias vor dem Namensraum stehen und zusätzlich das Schlüsselwort global besitzen. Ein Typalias kann nicht für andere Projekte exportiert werden. Er muss in jedem .NET-Projekt einmal deklariert sein, wenn er verwendet wird.
global using Measurement = (string Units, int Distance);
using BO;

namespace BL;

// Typaliase dürfen im Namensraum stehen
using Numbers = int[];
using DbInt = int?;
using MyPerson = Person;

class MeineKlasse
{

}

Optionale Lambda-Parameter in C# 12.0

Lambdas sind in den letzten Jahren an immer mehr Stellen vorgerückt, an denen zuvor Methoden geschrieben wurden. Allerdings erlaubten Lambdas bisher keine optionalen Parameter. Das hat sich in C# 12.0 geändert. Anstelle dieser Funktion mit optionalem Parameter z
public decimal Calc(decimal x, decimal y, decimal z = 1)
{
return (x + y) * z;
}
kann ein Entwickler in C# 12.0 nun auch diesen Lambda-Ausdruck schreiben:
var calc = (decimal x, decimal y, decimal z = 1) => (x + y) * z;
Das geht auch mit Statement Lambdas. Anstelle dieser Methode mit optionalem Parameter color
public void Print(object text, ConsoleColor? color = null)
{
if (color != null) Console.ForegroundColor = color.Value;
Console.WriteLine(text);
if (color != null) Console.ResetColor();
}
kann nun dieses Statement Lambda treten:
var Print = (object text, ConsoleColor? color = null) =>
{
if (color != null) Console.ForegroundColor = color.Value;
Console.WriteLine(text);
if (color != null) Console.ResetColor();
};

Parametermodifizierer ref readonly in C# 12.0

Seit C# 12.0 gibt es auch den Modifizierer ref readonly für Methodenparameter. Hierbei bekommt eine Methode einen Wert bzw. Objekt "by Reference" übergeben, darf den Wert bzw. das Objekt aber nicht ändern.

public string ParameterDemoValueTypes(int WertValue, in int WertIn, ref int WertRef, ref readonly int WertRefRO, out int WertOut)
{
WertValue++;
// nicht erlaubt, da in-Wert: WertIn++;
WertRef++;
// WertRefRO++; // nicht erlaubt, da readonly
// nicht erlaubt, da noch nicht initialisiert: WertOut++;
WertOut = 41;
return WertValue.ToString() + ";" + WertIn.ToString() + ";" + WertRef.ToString() + ";" + WertOut.ToString();
}

Hinweis: Wenn ein Referenztyp übergeben wird, kann die aufgerufene Methode immer die Daten im Objekt ändern. Die Modifizierer verhindern dann ggf. nur, dass ein anderes Objekt zugewiesen wird!

Erweiterter Einsatz von nameof() in C# 12.0

Der Operator nameof() funktionierte vor C# 12.0 in einigen Fällen nicht. Der Abruf des Namens von Instanzmitglieder von Klassenmitglieder war nicht möglich in einigen Fällen (z.B. statische Mitglieder, Annotationen) vor C# 12.0.
Beispiel: Name.Length funktioniert jetzt überall
public struct Person
{
public string Name;
// bisher schon möglich:
public string MemberName1() => nameof(Name);
// bisher schon möglich:
public string MemberName2() => nameof(Name.Length);
// bisher schon möglich:
public static string MemberName3() => nameof(Name);
// bisher Fehler CS0120:
public static string MemberName4() => nameof(Name.Length);

[Description($"{nameof(StringLength)} liefert von {nameof(Name)} die Eigenschaft {nameof(Name.Length)}")] // Name.Length nicht möglich vor C# 12.0!
public int StringLength()
{
return Name.Length;
}
}
Hier wäre vor C# 12.0 der Ausdruck nameof(Name.Length) in drei der vier Fällen nicht möglich gewesen und vom Compiler mit dem Kompilierungsfehler "error CS0120: An object reference is required for the non-static field, method, or property 'Name.Length'" quittiert worden.

Weitere Neuerungen in C# 12.0

Zwei weitere Neuerungen in C# 12.0 hat Microsoft primär für sich selbst für Optimierungen in der Entwicklung von Bibliotheken und dem Ahead-of-Timer-Compiler eingeführt:
Die Features können aber auch andere Entwickler im Rahmen der Low-Level-Programmierung verwenden. Sie sollen hier aber nicht erörtert werden, da sie wenig Relevanz für die meisten C#-Entwickler haben.