Objektorientierte Konstrukte von .NET-Objekten

Veröffentlicht: 15. Dez 2001 | Aktualisiert: 19. Jun 2004

Von Jeffrey Richter

Im Gegensatz zu den CoClasses in COM gibt es in .NET Konstruktoren für Typen. Konstruktoren, Properties und Indexers fördern das objektorientierte Design und können dem Programmierer das Leben erleichtern.

Auf dieser Seite

Typkonstruktoren
Properties
Indexproperties
Fazit

Diesen Artikel können Sie hier lesen dank freundlicher Unterstützung der Zeitschrift:

Bild01

Diese Folge meiner "Typenlehre" wird einige spezielle Funktionen beschreiben, die ein Typ definieren kann. Diese Funktionen fördern ein gutes objektorientiertes Design und vereinfachen zudem die Syntax für die Bearbeitung eines Typs und seiner Objektinstanzen.

Typkonstruktoren

Wahrscheinlich brauche ich Ihnen nicht zu erzählen, was Konstruktoren sind. Sie sind dafür zuständig, eine Objektinstanz in ihren Anfangszustand zu versetzen. Neben den Instanzkonstruktoren bietet die CLR, die allen Sprachen gemeinsame Laufzeitschicht vom .NET (common language runtime), nun Typkonstruktoren an, auch bekannt als statische Konstruktoren, Klassenkonstruktoren oder Typinitialisierer. Einen Typkonstruktor kann es für Schnittstellen geben, für Klassen und für Werttypen. Er ermöglicht es dem Typ, die erforderlichen Initialisierungen durchzuführen, bevor der Zugriff auf irgendwelche Datenelemente erfolgt, die im Typ deklariert werden. Typkonstruktoren haben keine Parameter und auch keine formalen Ergebnisse (also den Ergebnis- oder Rückgabetyp void). Ein Typkonstruktor hat nur Zugriff auf die statischen Felder eines Typs und seine übliche Aufgabe besteht darin, eben diese Felder zu initialisieren. Der Typkonstruktor wird unter Garantie aufgerufen, noch bevor irgendeine Instanz dieses Typs angelegt wird und bevor irgendwelche statischen Felder oder Methoden des Typs benutzt werden.

Viele Sprachen (einschließ;lich C#) generieren automatisch für alle Typen, die Sie definieren, die erforderlichen Typkonstruktoren. Manche Sprachen machen aber die explizite Implementierung des Typkonstruktors erforderlich. Schauen Sie sich zum besseren Verständnis der Typkonstruktoren einmal den folgenden Typ an, der hier in C# definiert wird:

class AType { 
   static int x = 5; 
}

Wenn dieser Code übersetzt wird, generiert der Compiler für AType automatisch einen Typkonstruktor. Dieser Konstruktor ist dafür verantwortlich, dass das statische Feld x mit dem Wert 5 initialisiert wird. Mit dem ILDasm lassen sich die Typkonstruktoren leicht am Namen erkennen, da sie .cctor heiß;en (für Klassenkonstruktor).

In C# können Sie Typkonstruktoren selbst definieren, indem Sie in Ihrem Typ eine statische Konstruktormethode definieren. Durch das Schlüsselwort static wird der Konstruktor zum Typkonstruktor, ist also kein Instanzenkonstruktor mehr. Hier ein sehr einfaches Beispiel:

class AType { 
   static int x; 
   static AType() { 
      x = 5; 
   } 
}

Diese Typdefinition ist mit der vorherigen identisch. Ein Typkonstruktor darf aber nie versuchen, eine Instanz seines eigenen Typs anzulegen. Auß;erdem darf der Typkonstruktor nicht auf die nicht-statischen Datenelemente des Typs zugreifen.

Wenn Sie dem C#-Compiler schließ;lich den folgenden Code geben, wird er eine einzelne Typkonstruktormethode generieren.

class AType { 
   static int x = 5; 
   static AType() { 
      x = 10; 
   } 
}

Dieser Konstruktor initialisiert zuerst x mit dem Wert 5 und setzt anschließ;end x auf 10. Anders gesagt, der resultierende Typkonstruktor, den der Compiler generiert, beginnt mit dem Initialisierungscode für das statische Feld, gefolgt von dem Code aus der Typkonstruktormethode.

Properties

Viele Typen definieren Attribute, die sich abfragen oder ändern lassen. Diese Attribute werden relativ oft als Felder des Typs implementiert. So weist die folgende Typdefinition zum Beispiel zwei Felder auf:

class Employee { 
   public String Name; 
   public Int32 Age; 
}

Wenn Sie eine Instanz dieses Typs anlegen sollten, könnten Sie dessen Attribute leicht mit Zeilen wie den folgenden setzen oder ändern:

Employee e = new Employee(); 
e.Name = "Jeffrey Richter"; // Setze das Namensattribut 
e.Age = 36;                 // Setze das Altersattribut 
Console.WriteLine(e.Name);  // zeigt "Jeffrey Richter" 

Diese Art des Umgangs mit Attributen ist sehr verbreitet. Allerdings hätte man den gezeigten Code niemals in dieser Form implementieren sollen. Einer wichtiger Punkt im objektorientierten Design und der entsprechenden Programmierung ist nämlich die Datenabstraktion. Diese Datenabstraktion hat zur Folge, dass die Felder des Typs niemals öffentlich zugänglich (public) gemacht werden sollten, weil es dann nämlich viel zu leicht möglich ist, die Felder falsch zu benutzen und das Objekt in irgendwelche undefinierten oder unerwünschten Zustände zu bringen. So könnte jemand zum Beispiel ein Employee-Objekt mit Code wie dem folgenden unbrauchbar machen:

e.Age = -5; // Wie kann man -5 Jahre alt sein?

Wenn Sie also einen Typ entwerfen, kann ich nur dringend empfehlen, sämtliche Felder als private Felder zu deklarieren oder zumindest als geschützte Felder (protected). Niemals als öffentliche Felder. Damit die Benutzer Ihres Typs den Wert eines Attributs auslesen oder ändern können, implementieren Sie entsprechende Methoden. Methoden, die den Zugriff auf ein Feld erlauben, nennt man dementsprechend Zugriffsmethoden (accessor methods). Sie können zusätzlich noch die gewünschten Gesundheitstests an den Daten durchführen und dafür sorgen, dass das Objekt niemals in einen undefinierten Zustand gerät. Als Beispiel habe ich die oben gezeigte Employee-Klasse in diesem Sinne überarbeitet (Listing L1). Es ist zwar nur ein einfaches Beispiel, aber es zeigt den groß;en Vorteil, der in der Abstraktion der Datenfelder liegt. Auß;erdem können Sie sehen, wie einfach es ist, den Schreib- oder Lesezugriff auf bestimmte Properties zu sperren, indem man einfach die entsprechenden Zugriffsmethoden nicht implementiert.

L1 Abstraktion der Datenfelder

class Employee { 
   private String Name;  // Das Feld ist nun privat 
   private Int32 Age;    // Das Feld ist nun privat 
   public String GetName() { 
      return(Name); 
   } 
   public void SetName(String value) { 
      Name = value; 
   } 
   public Int32 GetAge() { 
      return(Age); 
   } 
   public void SetAge(Int32 value) { 
      if (value <= 0) 
         throw(new ArgumentException("Age must be greater than 0"); 
      Age = value; 
   } 
}

Allerdings hat die Abstraktion der Daten wie in Listing L1 auch zwei Nachteile. Erstens ist mehr Code zu schreiben, weil man zusätzliche Funktionen implementieren muss. Zweitens können die Anwender nicht mehr direkt auf die Felder zugreifen, sondern müssen Funktionen aufrufen:

e.SetAge(36);    // Aktualisiert die Altersangabe 
e.SetAge(-5);    // Führt zur Meldung einer Ausnahme

Vermutlich werden Sie alle mit mir darin übereinstimmen, dass diese Nachteile aber vernachlässigbar sind. Trotzdem bietet die Laufzeitschicht einen zusätzlichen Mechanismus namens "Properties" (Eigenschaften) an, der den ersten Nachteil etwas abschwächt und den zweiten völlig aufhebt.

L2 Der Zugriff auf Properties erfolgt über get- und set-Funktionen

class Employee { 
   private String _Name; // Stelle '_' voran, um Namenskonflikt  
   private Int32 _Age;   //   zu vermeiden. 
   public String Name { 
      get { return(_Name); }a 
      set { _Name = value; } // 'value' meint immer den neuen Wert 
   } 
   public Int32 Age { 
      get { return(_Age); } 
      set { 
         if (value <= 0)    // 'value' meint immer den neuen Wert 
            throw(new ArgumentException("Age must be greater than 0"); 
         _Age = value; 
      } 
   } 
}

Die in Listing L2 gezeigte Klasse benutzt Properties und ist von ihrer Funktionalität her mit der Klasse aus L1 identisch. Wie Sie sehen, vereinfachen Properties den Code etwas. Viel wichtiger ist aber, dass sie Formulierungen wie die folgenden erlauben:

e.Age = 36;    // Aktualisiere die Altersangabe 
e.Age = -5;    // Führt zur Meldung einer Ausnahme

Der Ergebniswert der get-Zugriffsfunktion und der Parameter der set-Funktion sind vom selben Typ. Die set-Funktionen haben keinen Ergebnistyp (void) und die get-Funktionen haben keine Parameter. Properties können static sein, virtual, abstract, internal, private, protected oder public. Auß;erdem lassen sich Properties in einer Schnittstelle definieren. Aber darauf komme ich später noch zurück.

Vielleicht sollte ich noch darauf hinweisen, dass Properties nicht mit einem Feld verknüpft werden müssen. Der Typ System.IO.FileStream zum Beispiel definiert ein Length-Property, das die Zahl der Bytes liefert, die sich im Datenstrom befinden. Diese Länge wird nicht in einem Feld verwaltet. Statt dessen ermittelt die get-Funktion den aktuellen Wert dynamisch. Nach ihrem Aufruf fordert sie den aktuellen Wert durch den Aufruf einer weiteren Funktion beim Betriebssystem an.

Wenn Sie ein Property definieren, generiert der Compiler spezielle get_PropName und/oder set_PropName-Zugriffsfunktionen, wobei PropName für den Namen des Properties steht. Die meisten Compiler verstehen diese speziellen Methoden und erlauben es den Entwicklern, mit der speziellen Propertysyntax auf diese Methoden zuzugreifen. Allerdings braucht ein Compiler, der sich an die Common Language Specification (CLS) hält, gar nicht die volle Property-Unterstützung anzubieten. Von ihm wird nur verlangt, dass er den Aufruf der speziellen Zugriffsfunktionen ermöglicht. Auß;erdem kann es durchaus möglich sein, dass Compiler trotz der vollen Property-Unterstützung eine etwas andere Syntax für die Definition von Properties und für deren Einsatz vorschreiben. So setzt C++ mit den "verwalteten Erweiterungen" (managed extensions) zum Beispiel die Angabe des Schlüsselworts __property voraus.

L3 Hier werden Indexproperties eingesetzt

class BitArray { 
    private Byte[] byteArray; 
    public BitArray(int numBits) { 
        if (numBits <= 0) 
            throw new ArgumentException("numBits must be > 0"); 
        byteArray = new Byte[(numBits + 7) / 8]; 
    } 
    public Boolean this[Int32 bitPosition] { 
        get { 
            if ((bitPosition < 0) || 
             (bitPosition > byteArray.Length * 8 - 1)) 
                throw new IndexOutOfRangeException(); 
            return((byteArray[bitPosition / 8] & 
                   (1 << (bitPosition % 8))) != 0); 
        } 
        set { 
            if ((bitPosition < 0) || 
             (bitPosition > byteArray.Length * 8 - 1)) 
                throw new IndexOutOfRangeException(); 
            if (value) { 
                byteArray[bitPosition / 8] = (Byte) 
                   (byteArray[bitPosition / 8] | 
                   (1 << (bitPosition % 8))); 
            } else { 
                byteArray[bitPosition / 8] = (Byte) 
                   (byteArray[bitPosition / 8] & 
                   ~(1 << (bitPosition % 8))); 
            } 
        } 
    } 
}

Indexproperties

Manche Typen wie System.Collections.SortedList bieten eine logische Liste mit mehreren Elementen an. Zur Vereinfachung des Zugriffs auf solche Elemente können Sie ein Indexproperty definieren (auch als "Indexer" bekannt). Listing L3 zeigt ein Beispiel für solch ein Indexproperty. Die Anwendung des Indexers ist ziemlich einfach:

BitArray ba = new BitArray(14); 
for (int x = 0; x < 14; x++) { 
   // Schalte alle geraden Bits ein 
   ba[x] = (x % 2 == 0); 
   Console.WriteLine("Bit " + x + " is " + (ba[x] ? "On" : "Off")); 
}

Im BitArray-Beispiel aus Listing L3 hat der Indexer einen Int32-Parameter, nämlich bitPosition. Nun müssen alle Indexer zwar mindestens einen Parameter haben, aber sie können auch zwei oder mehr Parameter aufweisen. Diese Parameter können (wie auch der Ergebniswert) einen beliebigen Typ haben. So ist es zum Beispiel möglich, einen Indexer mit einem String als Parameter zu definieren, der für den Zugriff auf die Werte in einem assoziativen Array dient. Ein Typ kann auch mehrere überladene Indexer anbieten, solange sich die Indexer an ihren Prototypen unterscheiden lassen.

Wie die set-Funktion eines Properties hat auch die set-Zugriffsfunktion für einen Indexer einen verborgenen Parameter, nämlich value, der beim Aufruf der Zugriffsfunktion den übergebenen neuen Wert bezeichnet. Die set-Funktion von BitArray zeigt, wie sich der value-Parameter benutzen lässt.

Ein gut konzipierter Indexer sollte beides haben, get- und set-Funktionen. Man kann sich zwar auf die Implementierung einer get-Funktion (für den reinen Lesezugriff) oder einer set-Funktion (für den reinen Schreibzugriff) beschränken, aber für Indexer wird empfohlen, beide Zugriffsarten zu ermöglichen. Mit der simplen Begründung, dass der Benutzer eines Indexers wohl kaum erwartet, dass ein Indexer nur die halbe Funktionalität hat. So wird sich wohl jeder Programmierer überrumpelt fühlen, wenn er wegen der beiden folgenden Zeilen vom Compiler eine Fehlermeldung kassiert:

String s = SomeObj[5];  // Ist in Ordnung, wenn die get-Funktion  
                        //   vorhanden ist 
SomeObj[5] = s;         // Compiler meldet Fehler, wenn die set- 
                        //   Funktion fehlt

Indexer wirken immer auf eine bestimmte Instanz von einem Typ und können nicht static deklariert werden. Allerdings lassen sich Indexer als public kennzeichnen, als private, protected oder internal.

Wenn Sie ein Indexproperty definieren, generiert der Compiler spezielle get_Item und/oder set_Item-Zugriffsfunktionen. Die meisten Compiler werden diese speziellen Funktionen verstehen und es dem Entwickler erlauben, mit der speziellen Indexproperty-Syntax zu arbeiten.

Allerdings wird von einem CLS-konformen Compiler nicht verlangt, dass er die Indexproperties vollständig unterstützt. Er muss nur den Aufruf der speziellen Zugriffsfunktionen ermöglichen. Auß;erdem kann es durchaus möglich sein, dass Compiler trotz der vollen Unterstützung der Indexproperties eine etwas andere Syntax für die Definition und den Einsatz der Indexproperties erwarten. So setzt C++ mit den verwalteten Erweiterungen zum Beispiel die Angabe des Schlüsselworts __property voraus.

Fazit

Die in diesem Artikel besprochenen Konzepte sind für alle Programmierer sehr wichtig, die Anwendungen für das .NET entwickeln. Die speziellen Funktionen der Typen, von denen hier die Rede war, machen eine Komponente in der allen Sprachen gemeinsamen Laufzeitschicht zum Bürger erster Klasse. Anders gesagt, moderne Komponenten bieten auch Properties an.

In meinem nächsten Artikel wird von Ereignissen (Events) die Rede sein und - etwas mysteriös - von "Delegierten". Auch sie stellen einen integralen Bestandteil der Anwendungsentwicklung auf Komponentenbasis dar.