Klassen und Objekte

Klassen sind die grundlegendsten der C#-Typen. Eine Klasse ist eine Datenstruktur, die einen Zustand (Felder) und Aktionen (Methoden und andere Funktionsmember) in einer einzigen Einheit kombiniert. Eine Klasse stellt eine Definition für dynamisch erstellte Instanzen der Klasse, auch bekannt als Objekte bereit. Klassen unterstützen Vererbung und Polymorphie. Dies sind Mechanismen, durch die abgeleitete Klassen erweitert und Basisklassen spezialisiert werden können.

Neue Klassen werden mithilfe von Klassendeklarationen erstellt. Eine Klassendeklaration beginnt mit einem Header, der die Attribute und Modifizierer der Klasse, den Namen der Klasse, die Basisklasse (sofern vorhanden) und die von der Klasse implementierten Schnittstellen angibt. Auf den Header folgt der Klassenkörper. Dieser besteht aus einer Liste der Memberdeklarationen, die zwischen den Trennzeichen { und } eingefügt werden.

Nachfolgend sehen Sie eine Deklaration einer einfachen Klasse namens Point:

public class Point
{
    public int x, y;
    public Point(int x, int y) 
    {
        this.x = x;
        this.y = y;
    }
}

Instanzen von Klassen werden mit dem new-Operator erstellt. Dieser reserviert Speicher für eine neue Instanz, ruft einen Konstruktor zum Initialisieren der Instanz auf und gibt einen Verweis auf die Instanz zurück. Mit den folgenden Anweisungen werden zwei Point-Objekte erstellt und Verweise auf diese Objekte in zwei Variablen gespeichert:

Point p1 = new Point(0, 0);
Point p2 = new Point(10, 20);

Der von einem Objekt belegte Speicher wird automatisch wieder freigegeben, wenn das Objekt nicht mehr erreichbar ist. Es ist weder erforderlich noch möglich, die Zuweisung von Objekten in C# explizit aufzuheben.

Member

Die Member einer Klasse sind entweder statische Member oder Instanzmember. Statische Member gehören zu Klassen, Instanzmember gehören zu Objekten (Instanzen von Klassen).

Nachfolgend finden Sie einen Überblick über die Memberarten, die eine Klasse enthalten kann.

  • Konstanten
    • Konstante Werte, die der Klasse zugeordnet sind
  • Felder
    • Variablen der Klasse
  • Methoden
    • Berechnungen und Aktionen, die von der Klasse ausgeführt werden
  • Eigenschaften
    • Aktionen im Zusammenhang mit dem Lesen und Schreiben von benannten Eigenschaften der Klasse
  • Indexer
    • Aktionen im Zusammenhang mit dem Indizieren von Instanzen der Klasse, z.B. einem Array
  • Ereignisse
    • Benachrichtigungen, die von der Klasse generiert werden können
  • Operatoren
    • Operatoren für Konvertierungen und Ausdrücke, die von der Klasse unterstützt werden
  • Konstruktoren
    • Aktionen, die zum Initialisieren von Instanzen der Klasse oder der Klasse selbst benötigt werden
  • Finalizer
    • Aktionen, die ausgeführt werden, bevor Instanzen der Klasse dauerhaft verworfen werden
  • Typen
    • Geschachtelte Typen, die von der Klasse deklariert werden

Zugriff

Jeder Member einer Klasse ist mit einem Zugriff verknüpft, der die Regionen des Programmtexts steuert, die auf den Member zugreifen können. Es gibt fünf mögliche Formen des Zugriffs. Diese werden nachfolgend zusammengefasst.

  • public
    • Der Zugriff ist nicht eingeschränkt.
  • protected
    • Der Zugriff ist auf diese Klasse oder auf von dieser Klasse abgeleitete Klassen beschränkt.
  • internal
    • Der Zugriff ist auf die aktuelle Assembly beschränkt (.exe, .dll, usw.)
  • protected internal
    • Der Zugriff ist auf die enthaltende Klasse oder auf Klassen beschränkt, die von der enthaltenden Klasse abgeleitet sind.
  • private
    • Der Zugriff ist auf diese Klasse beschränkt.

Typparameter

Eine Klassendefinition kann einen Satz an Typparametern angeben, indem eine Liste der Typparameternamen in spitzen Klammern an den Klassennamen angehängt wird. Die Typparameter können dann im Körper der Klassendeklarationen zum Definieren der Klassenmember verwendet werden. Im folgenden Beispiel lauten die Typparameter von Pair TFirst und TSecond:

public class Pair<TFirst,TSecond>
{
    public TFirst First;
    public TSecond Second;
}

Ein Klassentyp, der zum Akzeptieren von Typparametern deklariert wird, wird als generischer Klassentyp bezeichnet. Struktur-, Schnittstellen- und Delegattypen können auch generisch sein. Wenn die generische Klasse verwendet wird, müssen für jeden der Typparameter Typargumente angegeben werden:

Pair<int,string> pair = new Pair<int,string> { First = 1, Second = "two" };
int i = pair.First;     // TFirst is int
string s = pair.Second; // TSecond is string

Ein generischer Typ, für den Typargumente angegeben wurden (siehe Pair<int,string> oben), wird als konstruierter Typ bezeichnet.

Basisklassen

Eine Klassendeklaration kann eine Basisklasse angeben, indem ein Doppelpunkt und der Name der Basisklasse an den Klassennamen und die Typparameter angehängt wird. Das Auslassen einer Basisklassenspezifikation ist dasselbe wie eine Ableitung vom Typ object. Im folgenden Beispiel ist Point die Basisklasse von Point3D, und die Basisklasse von Point ist object:

public class Point
{
    public int x, y;
    public Point(int x, int y) 
    {
        this.x = x;
        this.y = y;
    }
}
public class Point3D: Point
{
    public int z;
    public Point3D(int x, int y, int z) : 
        base(x, y) 
    {
        this.z = z;
    }
}

Eine Klasse erbt die Member der zugehörigen Basisklasse. Vererbung bedeutet, dass eine Klasse implizit alle Member dieser Basisklasse enthält, mit Ausnahme der Instanzkonstruktoren und der statischen Konstruktoren sowie der Finalizer der Basisklasse. Eine abgeleitete Klasse kann den geerbten Membern neue Member hinzufügen, aber die Definition eines geerbten Members kann nicht entfernt werden. Im vorherigen Beispiel erbt Point3D die Felder x und y von Point, und jede Point3D-Instanz enthält drei Felder: x, y und z.

Ein Klassentyp kann implizit in einen beliebigen zugehörigen Basisklassentyp konvertiert werden. Deshalb kann eine Variable eines Klassentyps auf eine Instanz dieser Klasse oder auf eine Instanz einer beliebigen abgeleiteten Klasse verweisen. Beispielsweise kann in den vorherigen Klassendeklarationen eine Variable vom Typ Point entweder auf Point oder auf Point3D verweisen:

Point a = new Point(10, 20);
Point b = new Point3D(10, 20, 30);

Felder

Ein Feld ist eine Variable, die einer Klasse oder einer Instanz einer Klasse zugeordnet ist.

Ein Feld, das mit dem static-Modifizierer deklariert wurde, definiert ein statisches Feld. Ein statisches Feld identifiziert genau einen Speicherort. Unabhängig davon, wie viele Instanzen einer Klasse erstellt werden, gibt es nur eine Kopie eines statischen Felds.

Ein Feld, das ohne den static-Modifizierer deklariert wurde, definiert ein Instanzfeld. Jede Instanz einer Klasse enthält eine separate Kopie aller Instanzfelder dieser Klasse.

Im folgenden Beispiel weist jede Instanz der Color-Klasse eine separate Kopie der Instanzfelder r, g und b auf, aber es gibt nur eine Kopie der statischen Felder Black, White, Red, Green und Blue:

public class Color
{
    public static readonly Color Black = new Color(0, 0, 0);
    public static readonly Color White = new Color(255, 255, 255);
    public static readonly Color Red = new Color(255, 0, 0);
    public static readonly Color Green = new Color(0, 255, 0);
    public static readonly Color Blue = new Color(0, 0, 255);
    private byte r, g, b;
    public Color(byte r, byte g, byte b) 
    {
        this.r = r;
        this.g = g;
        this.b = b;
    }
}

Wie im vorherigen Beispiel gezeigt, können schreibgeschützte Felder mit einem readonly-Modifizierer deklariert werden. Einem readonly-Feld können Werte nur als Teil einer Deklaration oder in einem Konstruktor derselben Klasse zugewiesen werden.

Methoden

Eine Methode ist ein Member, das eine Berechnung oder eine Aktion implementiert, die durch ein Objekt oder eine Klasse durchgeführt werden kann. Auf statische Methoden wird über die Klasse zugegriffen. Auf Instanzmethoden wird über Instanzen der Klasse zugegriffen.

Methoden können über eine Liste von Parametern – diese repräsentieren die an die Methode übergebene Werte oder Variablenverweise – sowie über einen Rückgabetyp verfügen, der den Typ des Werts angibt, der von der Methode berechnet und zurückgegeben wird. Der Rückgabetyp einer Methode lautet void, wenn kein Wert zurückgegeben wird.

Ebenso wie Typen können Methoden einen Satz an Typparametern aufweisen, für den beim Aufruf der Methode Typargumente angegeben werden müssen. Im Gegensatz zu Typen können die Typargumente häufig aus den Argumenten eines Methodenaufrufs abgeleitet werden und müssen nicht explizit angegeben werden.

Die Signatur einer Methode muss innerhalb der Klasse eindeutig sein, in der die Methode deklariert ist. Die Signatur einer Methode besteht aus dem Namen der Methode, der Anzahl von Typparametern und der Anzahl, den Modifizierern und den Typen der zugehörigen Parameter. Die Signatur einer Methode umfasst nicht den Rückgabetyp.

Parameter

Parameter werden dazu verwendet, Werte oder Variablenverweise an Methoden zu übergeben. Die Parameter einer Methode erhalten ihre tatsächlichen Werte über Argumente, die angegeben werden, wenn die Methode aufgerufen wird. Es gibt vier Arten von Parametern: Wertparameter, Verweisparameter, Ausgabeparameter und Parameterarrays.

Ein Wertparameter wird zum Übergeben von Eingabeargumenten verwendet. Ein Wertparameter entspricht einer lokalen Variablen, die ihren Anfangswert von dem Argument erhält, das für den Parameter übergeben wurde. Änderungen an einem Wertparameter wirken sich nicht auf das Argument aus, das für den Parameter übergeben wurde.

Wertparameter können optional sein, indem ein Standardwert festgelegt wird, damit die zugehörigen Argumente weggelassen werden können.

Ein Verweisparameter wird zum Übergeben von Argumenten als Verweis verwendet. Das für einen Verweisparameter übergebene Argument muss eine Variable mit eindeutigem Wert sein, und während der Ausführung der Methode repräsentiert der Verweisparameter denselben Speicherort wie die Argumentvariable. Ein Verweisparameter wird mit dem ref-Modifizierer deklariert. Das folgende Beispiel veranschaulicht die Verwendung des ref-Parameters.

using System;
class RefExample
{
    static void Swap(ref int x, ref int y) 
    {
        int temp = x;
        x = y;
        y = temp;
    }
    public static void SwapExample() 
    {
        int i = 1, j = 2;
        Swap(ref i, ref j);
        Console.WriteLine($"{i} {j}");    // Outputs "2 1"
    }
}

Ein Ausgabeparameter wird zum Übergeben von Argumenten als Verweis verwendet. Er ist einem Verweisparameter ähnlich, außer dass er nicht erfordert, dass Sie explizit dem vom Aufrufer bereitgestellten Argument einen Wert zuweisen. Ein Ausgabeparameter wird mit dem out-Modifizierer deklariert. Das folgende Beispiel zeigt die Verwendung von out-Parametern mithilfe der in C# 7 eingeführten Syntax.

    using System;
    class OutExample
    {
        static void Divide(int x, int y, out int result, out int remainder) 
        {
            result = x / y;
            remainder = x % y;
        }
        public static void OutUsage() 
        {
            Divide(10, 3, out int res, out int rem);
            Console.WriteLine("{0} {1}", res, rem);	// Outputs "3 1"
        }
    }
}

Ein Parameterarray ermöglicht es, eine variable Anzahl von Argumenten an eine Methode zu übergeben. Ein Parameterarray wird mit dem params-Modifizierer deklariert. Nur der letzte Parameter einer Methode kann ein Parameterarray sein, und es muss sich um ein eindimensionales Parameterarray handeln. Die Write- und WriteLine-Methoden der @System.Console-Klasse sind gute Beispiele für die Verwendung eines Parameterarrays. Sie werden folgendermaßen deklariert.

public class Console
{
    public static void Write(string fmt, params object[] args) { }
    public static void WriteLine(string fmt, params object[] args) { }
    // ...
}

Innerhalb einer Methode mit einem Parameterarray verhält sich das Parameterarray wie ein regulärer Parameter des Arraytyps. Beim Aufruf einer Methode mit einem Parameterarray ist es jedoch möglich, entweder ein einzelnes Argument des Parameterarraytyps oder eine beliebige Anzahl von Argumenten des Elementtyps des Parameterarrays zu übergeben. Im letzteren Fall wird automatisch eine Arrayinstanz erstellt und mit den vorgegebenen Argumenten initialisiert. Dieses Beispiel:

Console.WriteLine("x={0} y={1} z={2}", x, y, z);

...entspricht dem folgenden Code:

string s = "x={0} y={1} z={2}";
object[] args = new object[3];
args[0] = x;
args[1] = y;
args[2] = z;
Console.WriteLine(s, args);

Methodenkörper und lokale Variablen

Der Methodenkörper gibt die Anweisungen an, die beim Aufruf der Methode ausgeführt werden sollen.

Ein Methodenkörper kann Variablen deklarieren, die für den Aufruf der Methode spezifisch sind. Diese Variable werden lokale Variablen genannt. Die Deklaration einer lokalen Variable gibt einen Typnamen, einen Variablennamen und eventuell einen Anfangswert an. Im folgenden Beispiel wird eine lokale Variable i mit einem Anfangswert von 0 und einer lokalen Variablen j ohne Anfangswert deklariert.

using System;
class Squares
{
    public static void WriteSquares() 
    {
        int i = 0;
        int j;
        while (i < 10) 
        {
            j = i * i;
            Console.WriteLine($"{i} x {i} = {j}");
            i = i + 1;
        }
    }
}

In C# muss eine lokale Variable definitiv zugewiesen sein, bevor ihr Wert abgerufen werden kann. Wenn beispielsweise die vorherige Deklaration von i keinen Anfangswert enthielte, würde der Compiler bei der nachfolgenden Verwendung von i einen Fehler melden, weil i zu diesen Zeitpunkten im Programm nicht definitiv zugewiesen wäre.

Eine Methode kann return-Anweisungen verwenden, um die Steuerung an den zugehörigen Aufrufer zurückzugeben. In einer Methode, die void zurückgibt, können return-Anweisungen keinen Ausdruck angeben. In einer Methode, die nicht „void“ zurückgibt, müssenreturn-Anweisungen einen Ausdruck enthalten, der den Rückgabewert berechnet.

Statische Methoden und Instanzmethoden

Eine mit einem statischen Modifizierer deklarierte Methode ist eine statische Methode. Eine statische Methode führt keine Vorgänge für eine spezifische Instanz aus und kann nur direkt auf statische Member zugreifen.

Eine ohne einen statischen Modifizierer deklarierte Methode ist eine Instanzmethode. Eine Instanzmethode führt Vorgänge für eine spezifische Instanz aus und kann sowohl auf statische Member als auch auf Instanzmember zugreifen. Auf die Instanz, für die eine Instanzmethode aufgerufen wurde, kann explizit als this zugegriffen werden. Es ist ein Fehler, in einer statischen Methode auf this zu verweisen.

Die folgende Entity-Klasse umfasst sowohl statische Member als auch Instanzmember.

class Entity
{
    static int nextSerialNo;
    int serialNo;
    public Entity() 
    {
        serialNo = nextSerialNo++;
    }
    public int GetSerialNo() 
    {
        return serialNo;
    }
    public static int GetNextSerialNo() 
    {
        return nextSerialNo;
    }
    public static void SetNextSerialNo(int value) 
    {
        nextSerialNo = value;
    }
}

Jede Entity-Instanz enthält eine Seriennummer (und vermutlich weitere Informationen, die hier nicht angezeigt werden). Der Entity-Konstruktor (der einer Instanzmethode ähnelt) initialisiert die neue Instanz mit der nächsten verfügbaren Seriennummer. Da der Konstruktor ein Instanzmember ist, kann er sowohl auf das serialNo-Instanzfeld als auch auf das statische nextSerialNo-Feld zugreifen.

Die statischen Methoden GetNextSerialNo und SetNextSerialNo können auf das statische Feld nextSerialNo zugreifen, aber es wäre ein Fehler, über diese Methoden direkt auf das Instanzfeld serialNo zuzugreifen.

Das folgende Beispiel zeigt die Verwendung der Entity-Klasse.

using System;
class EntityExample
{
    public static void Usage() 
    {
        Entity.SetNextSerialNo(1000);
        Entity e1 = new Entity();
        Entity e2 = new Entity();
        Console.WriteLine(e1.GetSerialNo());            // Outputs "1000"
        Console.WriteLine(e2.GetSerialNo());            // Outputs "1001"
        Console.WriteLine(Entity.GetNextSerialNo());    // Outputs "1002"
    }
}

Beachten Sie, dass die statischen Methoden SetNextSerialNo und GetNextSerialNo für die Klasse aufgerufen werden, während die GetSerialNo-Instanzmethode für Instanzen der Klasse aufgerufen wird.

Virtuelle, überschriebene und abstrakte Methoden

Wenn die Deklaration einer Instanzmethode einen virtual-Modifizierer enthält, wird die Methode als virtuelle Methode bezeichnet. Ist kein virtual-Modifizierer vorhanden, spricht man von einer nicht virtuellen Methode.

Beim Aufruf einer virtuellen Methode bestimmt der Laufzeittyp der Instanz, für die der Aufruf erfolgt, die tatsächlich aufzurufende Methodenimplementierung. Beim Aufruf einer nicht virtuellen Methode ist der Kompilierzeittyp der bestimmende Faktor.

Eine virtuelle Methode kann in einer abgeleiteten Klasse nicht überschrieben werden. Wenn eine Instanzmethodendeklaration einen override-Modifizierer enthält, überschreibt die Methode eine geerbte virtuelle Methode mit derselben Signatur. Während eine Deklaration einer virtuellen Methode eine neue Methode einführt, spezialisiert eine Deklaration einer überschriebenen Methode eine vorhandene geerbte virtuelle Methode, indem eine neue Implementierung dieser Methode bereitgestellt wird.

Eine abstrakte Methode ist eine virtuelle Methode ohne Implementierung. Eine abstrakte Methode wird mit dem abstract-Modifizierer deklariert und ist nur in einer Klasse erlaubt, die auch als abstrakt deklariert wurde. Eine abstrakte Methode muss in jeder nicht abstrakten abgeleiteten Klasse überschrieben werden.

Im folgenden Beispiel wird die abstrakte Klasse Expression deklariert, die einen Ausdrucksbaumstrukturknoten sowie drei abgeleitete Klassen repräsentiert: Constant, VariableReference und Operation. Diese implementieren Ausdrucksbaumstrukturknoten für Konstanten, variable Verweise und arithmetische Operationen. (Dies ähnelt den Ausdrucksbaumstrukturtypen, sollte aber mit diesen nicht verwechselt werden.)

using System;
using System.Collections.Generic;
public abstract class Expression
{
    public abstract double Evaluate(Dictionary<string,object> vars);
}
public class Constant: Expression
{
    double value;
    public Constant(double value) 
    {
        this.value = value;
    }
    public override double Evaluate(Dictionary<string,object> vars) 
    {
        return value;
    }
}
public class VariableReference: Expression
{
    string name;
    public VariableReference(string name) 
    {
        this.name = name;
    }
    public override double Evaluate(Dictionary<string,object> vars) 
    {
        object value = vars[name];
        if (value == null) 
        {
            throw new Exception("Unknown variable: " + name);
        }
        return Convert.ToDouble(value);
    }
}
public class Operation: Expression
{
    Expression left;
    char op;
    Expression right;
    public Operation(Expression left, char op, Expression right) 
    {
        this.left = left;
        this.op = op;
        this.right = right;
    }
    public override double Evaluate(Dictionary<string,object> vars) 
    {
        double x = left.Evaluate(vars);
        double y = right.Evaluate(vars);
        switch (op) {
            case '+': return x + y;
            case '-': return x - y;
            case '*': return x * y;
            case '/': return x / y;
        }
        throw new Exception("Unknown operator");
    }
}

Die vorherigen vier Klassen können zum Modellieren arithmetischer Ausdrücke verwendet werden. Beispielsweise kann mithilfe von Instanzen dieser Klassen der Ausdruck x + 3 folgendermaßen dargestellt werden.

Expression e = new Operation(
    new VariableReference("x"),
    '+',
    new Constant(3));

Die Evaluate-Methode einer Expression-Instanz wird aufgerufen, um den vorgegebenen Ausdruck auszuwerten und einen double-Wert zu generieren. Die Methode verwendet ein Dictionary-Argument, das Variablennamen (als Schlüssel der Einträge) und Werte (als Werte der Einträge) enthält. Da Evaluate eine abstrakte Methode ist, müssen nicht-abstrakte Klassen, die von Expression abgeleitet sind, Evaluate außer Kraft setzen.

Eine Implementierung von Constant für Evaluate gibt lediglich die gespeicherte Konstante zurück. Eine Implementierung von VariableReference sucht im Wörterbuch nach dem Variablennamen und gibt den Ergebniswert zurück. Eine Implementierung von Operation wertet zunächst (durch einen rekursiven Aufruf der zugehörigen Evaluate-Methoden) den linken und rechten Operanden aus und führt dann die vorgegebene arithmetische Operation aus.

Das folgende Programm verwendet die Expression-Klassen zum Auswerten des Ausdrucks x * (y + 2) für verschiedene Werte von x und y.

using System;
using System.Collections.Generic;
class InheritanceExample
{
    public static void ExampleUsage() 
    {
        Expression e = new Operation(
            new VariableReference("x"),
            '*',
            new Operation(
                new VariableReference("y"),
                '+',
                new Constant(2)
            )
        );
        Dictionary<string,object> vars = new Dictionary<string, object>();
        vars["x"] = 3;
        vars["y"] = 5;
        Console.WriteLine(e.Evaluate(vars));		// Outputs "21"
        vars["x"] = 1.5;
        vars["y"] = 9;
        Console.WriteLine(e.Evaluate(vars));		// Outputs "16.5"
    }
}   

Methodenüberladung

Das Überladen von Methoden macht es möglich, dass mehrere Methoden in derselben Klasse denselben Namen verwenden, solange sie eindeutige Signaturen aufweisen. Beim Kompilieren des Aufrufs einer überladenen Methode verwendet der Compiler die Überladungsauflösung, um die spezifische Methode zu ermitteln, die aufgerufen werden soll. Mithilfe der Überladungsauflösung wird die Methode ermittelt, die den Argumenten am besten entspricht, bzw. es wird ein Fehler ausgegeben, wenn keine passende Methode gefunden wird. Das folgende Beispiel zeigt die Verwendung der Überladungsauflösung. Der Kommentar für jeden Aufruf in der Main-Methode zeigt, welche Methode tatsächlich aufgerufen wird.

using System;
class OverloadingExample
{
    static void F() 
    {
        Console.WriteLine("F()");
    }
    static void F(object x) 
    {
        Console.WriteLine("F(object)");
    }
    static void F(int x) 
    {
        Console.WriteLine("F(int)");
    }
    static void F(double x) 
    {
        Console.WriteLine("F(double)");
    }
    static void F<T>(T x) 
    {
        Console.WriteLine("F<T>(T)");
    }
    static void F(double x, double y) 
    {
        Console.WriteLine("F(double, double)");
    }
    public static void UsageExample()
    {
        F();            // Invokes F()
        F(1);           // Invokes F(int)
        F(1.0);         // Invokes F(double)
        F("abc");       // Invokes F<string>(string)
        F((double)1);   // Invokes F(double)
        F((object)1);   // Invokes F(object)
        F<int>(1);      // Invokes F<int>(int)
        F(1, 1);        // Invokes F(double, double)
    }
}

Wie im Beispiel gezeigt, kann eine bestimmte Methode immer ausgewählt werden, indem die Argumente explizit in die passenden Parametertypen konvertiert und/oder explizit Typargumente angegeben werden.

Andere Funktionsmember

Member, die ausführbaren Code enthalten, werden als Funktionsmember einer Klasse bezeichnet. In den vorangegangenen Abschnitten wurden die Methoden beschrieben, die wichtigste Form der Funktionsmember. In diesem Abschnitt werden die weiteren Funktionsmember behandelt, die C# unterstützt: Konstruktoren, Eigenschaften, Indexer, Ereignisse, Operatoren und Finalizer.

Das folgende Beispiel zeigt eine generische Klasse namens List, die eine wachsende Liste von Objekten implementiert. Die Klasse enthält verschiedene Beispiele der gängigsten Arten von Funktionsmembern.

public class List<T>
{
    // Constant
    const int defaultCapacity = 4;

    // Fields
    T[] items;
    int count;

    // Constructor
    public List(int capacity = defaultCapacity) 
    {
        items = new T[capacity];
    }

    // Properties
    public int Count => count; 

    public int Capacity 
    {
        get { return items.Length; }
        set 
        {
            if (value < count) value = count;
            if (value != items.Length) 
            {
                T[] newItems = new T[value];
                Array.Copy(items, 0, newItems, 0, count);
                items = newItems;
            }
        }
    }

    // Indexer
    public T this[int index] 
    {
        get 
        {
            return items[index];
        }
        set 
        {
            items[index] = value;
            OnChanged();
        }
    }
    
    // Methods
    public void Add(T item) 
    {
        if (count == Capacity) Capacity = count * 2;
        items[count] = item;
        count++;
        OnChanged();
    }
    protected virtual void OnChanged() =>
        Changed?.Invoke(this, EventArgs.Empty);

    public override bool Equals(object other) =>
        Equals(this, other as List<T>);

    static bool Equals(List<T> a, List<T> b) 
    {
        if (Object.ReferenceEquals(a, null)) return Object.ReferenceEquals(b, null);
        if (Object.ReferenceEquals(b, null) || a.count != b.count)
            return false;
        for (int i = 0; i < a.count; i++) 
        {
            if (!object.Equals(a.items[i], b.items[i])) 
            {
                return false;
            }
        }
    return true;
    }

    // Event
    public event EventHandler Changed;

    // Operators
    public static bool operator ==(List<T> a, List<T> b) => 
        Equals(a, b);

    public static bool operator !=(List<T> a, List<T> b) => 
        !Equals(a, b);
}

Konstruktoren

C# unterstützt sowohl Instanzkonstruktoren als auch statische Konstruktoren. Ein Instanzkonstruktor ist ein Member, der die erforderlichen Aktionen zum Initialisieren einer Instanz einer Klasse implementiert. Ein statischer Konstruktor ist ein Member, der die zum Initialisieren einer Klasse erforderlichen Aktionen implementiert, um die Klasse beim ersten Laden selbst zu initialisieren.

Ein Konstruktor wird wie eine Methode ohne Rückgabetyp und mit demselben Namen wie die enthaltende Klasse deklariert. Wenn eine Konstruktordeklaration einen static-Modifizierer enthält, deklariert sie einen statischen Konstruktor. Andernfalls wird ein Instanzkonstruktor deklariert.

Instanzkonstruktoren können überladen werden und optionale Parameter verwenden. Beispielsweise deklariert die List<T>-Klasse zwei Instanzkonstruktoren, einen ohne Parameter und einen weiteren mit einem int-Parameter. Instanzkonstruktoren werden über den new-Operator aufgerufen. Die folgenden Anweisungen weisen zwei Instanzen von List<string> unter Verwendung des Konstruktors der List-Klasse zu, mit dem optionalen Argument und ohne das optionale Argument.

List<string> list1 = new List<string>();
List<string> list2 = new List<string>(10);

Im Gegensatz zu anderen Members werden Instanzkonstruktoren nicht geerbt, und eine Klasse weist keine anderen Instanzkonstruktoren auf als diejenigen, die tatsächlich in der Klasse deklariert wurden. Wenn kein Instanzkonstruktor für eine Klasse angegeben ist, wird automatisch ein leerer Instanzkonstruktor ohne Parameter bereitgestellt.

Eigenschaften

Eigenschaften sind eine natürliche Erweiterung der Felder. Beide sind benannte Member mit zugeordneten Typen, und für den Zugriff auf Felder und Eigenschaften wird dieselbe Syntax verwendet. Im Gegensatz zu Feldern bezeichnen Eigenschaften jedoch keine Speicherorte. Stattdessen verfügen Eigenschaften über Accessors zum Angeben der Anweisungen, die beim Lesen oder Schreiben ihrer Werte ausgeführt werden sollen.

Eine Eigenschaft wird wie ein Feld deklariert, abgesehen davon, dass die Deklaration nicht auf ein Semikolon, sondern auf einen get- und/oder einen set-Accessor endet, der von den Trennzeichen { und } umschlossen wird. Eine Eigenschaft, die sowohl einen get- als auch einen set-Accessor aufweist, ist eine Eigenschaft mit Lese- und Schreibzugriff. Eine Eigenschaft, die nur einen get-Accessor aufweist, ist schreibgeschützt, eine Eigenschaft, die nur einen set-Accessor aufweist, ist lesegeschützt.

Ein get-Accessor entspricht einer Methode ohne Parameter mit einem Rückgabewert des Eigenschaftstyps. Wenn eine Eigenschaft kein Ziel einer Zuweisung ist, sondern in einem Ausdruck referenziert wird, wird der get-Accessor der Eigenschaft aufgerufen, um ihren Wert zu berechnen.

Ein set-Accessor entspricht einer Methode mit einem einzigen Parameter namens „value“ ohne Rückgabetyp. Wenn auf eine Eigenschaft als Ziel einer Zuweisung der als Operand ++ oder -- verwiesen wird, erfolgt der Aufruf des set-Accessors mit einem Argument, das den neuen Wert bereitstellt.

Die List<T>-Klasse deklariert die zwei Eigenschaften „Count“ und „Capacity“, von denen die eine schreibgeschützt ist und die andere Lese- und Schreibzugriff besitzt. Es folgt ein Beispiel zur Verwendung dieser Eigenschaften.

List<string> names = new List<string>();
names.Capacity = 100;   // Invokes set accessor
int i = names.Count;    // Invokes get accessor
int j = names.Capacity; // Invokes get accessor

Ähnlich wie bei Feldern und Methoden unterstützt C# sowohl Instanzeigenschaften als auch statische Eigenschaften. Statische Eigenschaften werden mit dem static-Modifizierer, Instanzeigenschaften werden ohne static-Modifizierer deklariert.

Die Accessors einer Eigenschaft können virtuell sein. Wenn eine Eigenschaftendeklaration einen virtual-, abstract- oder override-Modifizierer enthält, wird dieser auf den Accessor der Eigenschaft angewendet.

Indexer

Ein Indexer ist ein Member, mit dem Objekte wie ein Array indiziert werden können. Ein Indexer wird wie eine Eigenschaft deklariert, abgesehen davon, dass an den Membernamen eine in die Trennzeichen [ und ] eingefügte Parameterliste angehängt wird. Die Parameter stehen im Accessor des Indexers zur Verfügung. Ähnlich wie Eigenschaften können Indexer Lese-/Schreibzugriff besitzen, schreibgeschützt und lesegeschützt sein und virtuelle Accessors verwenden.

Die List-Klasse deklariert einen einzigen Indexer mit Lese-/Schreibzugriff, der einen int-Parameter akzeptiert. Der Indexer ermöglicht es, Instanzen von List mit int-Werten zu indizieren. Beispiel:

List<string> names = new List<string>();
names.Add("Liz");
names.Add("Martha");
names.Add("Beth");
for (int i = 0; i < names.Count; i++) 
{
    string s = names[i];
    names[i] = s.ToUpper();
}

Indexer können überladen werden, d.h. eine Klasse kann mehrere Indexer deklarieren, solange sich die Anzahl oder Typen ihrer Parameter unterscheiden.

Ereignisse

Ein Ereignis ist ein Member, der es einer Klasse oder einem Objekt ermöglicht, Benachrichtigungen bereitzustellen. Ein Ereignis wird wie ein Feld deklariert, abgesehen davon, dass es ein event-Schlüsselwort enthält und einen Delegattyp aufweisen muss.

Innerhalb einer Klasse, die einen Ereignismember deklariert, verhält sich das Ereignis wie ein Feld des Delegattyps (vorausgesetzt, das Ereignis ist nicht abstrakt und deklariert keine Accessors). Das Feld speichert einen Verweis auf einen Delegaten, der die Ereignishandler repräsentiert, die dem Ereignis hinzugefügt wurden. Wenn keine Ereignishandler vorhanden sind, ist das Feld null.

Die List<T>-Klasse deklariert einen einzigen Ereignismember namens Changed, der angibt, dass der Liste ein neues Element hinzugefügt wurde. Das Changed-Ereignis wird durch die virtuelle Methode OnChanged ausgelöst, die zunächst prüft, ob das Ereignis null ist (d.h. nicht über Handler verfügt). Das Auslösen eines Ereignisses entspricht exakt dem Aufrufen des Delegaten, der durch das Ereignis repräsentiert wird, es gibt deshalb keine besonderen Sprachkonstrukte zum Auslösen von Ereignissen.

Clients reagieren über Ereignishandler auf Ereignisse. Ereignishandler werden unter Verwendung des +=-Operators angefügt und mit dem -=-Operator entfernt. Im folgenden Beispiel wird dem Changed-Ereignis von List<string> ein Ereignishandler hinzugefügt.

class EventExample
{
    static int changeCount;
    static void ListChanged(object sender, EventArgs e) 
    {
        changeCount++;
    }
    public static void Usage() 
    {
        List<string> names = new List<string>();
        names.Changed += new EventHandler(ListChanged);
        names.Add("Liz");
        names.Add("Martha");
        names.Add("Beth");
        Console.WriteLine(changeCount);		// Outputs "3"
    }
}

In komplexeren Szenarien, in denen die zugrunde liegende Speicherung eines Ereignisses gesteuert werden soll, können in einer Ereignisdeklaration explizit die add- und remove-Accessors bereitgestellt werden. Diese ähneln in gewisser Weise dem set-Accessor einer Eigenschaft.

Operatoren

Ein Operator ist ein Member, der die Bedeutung der Anwendung eines bestimmten Ausdrucksoperators auf Instanzen einer Klasse definiert. Es können drei Arten von Operatoren definiert werden: unäre Operatoren, binäre Operatoren und Konvertierungsoperatoren. Alle Operatoren müssen als public und static deklariert werden.

Die List<T>-Klasse deklariert zwei Operatoren, operator == und operator !=, und verleiht so Ausdrücken, die diese Operatoren auf List-Instanzen anwenden, eine neue Bedeutung. Insbesondere die Operatoren definieren die Gleichheit für zwei Instanzen von List<T>, indem alle enthaltenen Objekte mithilfe ihrer Equals-Methoden verglichen werden. Im folgenden Beispiel wird der ==-Operator verwendet, um zwei Instanzen von List<int> zu vergleichen.

List<int> a = new List<int>();
a.Add(1);
a.Add(2);
List<int> b = new List<int>();
b.Add(1);
b.Add(2);
Console.WriteLine(a == b);  // Outputs "True" 
b.Add(3);
Console.WriteLine(a == b);  // Outputs "False"

Die erste Methode Console.WriteLine gibt True aus, weil die zwei Listen dieselbe Anzahl von Objekten mit denselben Werten in derselben Reihenfolge enthalten. Wenn List<T> nicht operator == definieren würde, würde die Ausgabe der ersten Console.WriteLine-Methode False lauten, weil a und b auf unterschiedliche List<int>-Instanzen verweisen.

Finalizer

Ein Finalizer ist ein Member, der die erforderlichen Aktionen zum Bereinigen einer Instanz einer Klasse implementiert. Finalizer können weder Parameter noch Zugriffsmodifizierer aufweisen und können nicht explizit aufgerufen werden. Der Finalizer für eine Instanz wird bei der Garbagecollection automatisch aufgerufen.

Der Garbage Collector kann weitestgehend selbst über den Zeitpunkt der Objektbereinigung und die Ausführung der Finalizer entscheiden. Insbesondere der Zeitpunkt für den Aufruf der Finalizer ist nicht festgelegt, und Finalizer können für beliebige Threads ausgeführt werden. Aus diesen und weiteren Gründen sollten Klassen Finalizer nur dann implementieren, wenn keine andere Lösung möglich ist.

Die using-Anweisung bietet einen besseren Ansatz für die Objektzerstörung.