Wie man eine Runtime erzeugt
Die zentrale Motivation hinter der Erstellung einer Laufzeitumgebung besteht darin, es einfacher zu machen, bestimmte Typen der Programmierung zu verwenden. Betrachten wir also das Problem einer hypothetischen Laufzeitumgebung für die Erstellung von Datenbankanwendungen. In dieser Laufzeitumgebung gibt es drei Operationen: Öffnen einer Verbindung, Schließen einer Verbindung und Ausführen einer Anweisung. Aus diesen drei Anweisungen werden drei op-codes (operation codes), die einen Satz implementierter Funktionalität ausführen. In dieser Laufzeitumgebung sind die op-codes also Vereinfachungen einer komplexen Operation.Versuchen wir jetzt, diese Laufzeitumgebung um weitere Funktionalität zu erweitern. An diesem Punkt rennen Sie gegen eine Wand, da es sich um eine Umgebung handelt, in der bestimmte Operationen vereinfacht wurden. Um einen op-code zu erweitern, müssen bestimmte andere Operationen erzeugt werden. Daraus resultiert dann einen Laufzeitumgebung, die in sich selbst geschlossen ist, das heißt man kann diese Laufzeitumgebung nutzen, um reine Runtime-Applikationen zu entwickeln. Ein Teil der Kernfunktionalität wird dabei immer nativ sein.
Was ist das Ergebnis einer solchen Laufzeitumgebung? Langsame Geschwindigkeit, da eine Infrastruktur geschaffen werden muss, die das tut, was einfacher auf einer niedrigeren Ebene erledigt werden könnte. Ein konkretes Beispiel wäre das Erstellen von Klassen: In dieser Art Umgebung würden Klassen unter Verwendung bestimmter op-codes für Erstellung und Management von Klassen erstellt und verwaltet werden.
Wenden wir uns jetzt der .NET Laufzeitumgebung zu. Ich habe gesagt, dass es sich dabei um nichts anderes als eine Makro-Sprache für COM handelt. In einer Laufzeitumgebung vom .NET-Typ basiert diese auf Objekten und nicht auf Vereinfachungen spezifischer Operationen. Wenn wir das Laufzeitumgebungsbeispiel mit den Datenbank-op-codes mit einer .NET Runtime vergleichen, gäbe es dort keine op-codes für die Datenbank, sondern man hätte ein Objekt namens database, das drei verschiedene Operationen enthielte. Das ist ein dramatischer Unterschied, da es bedeutet, dass man die Laufzeitumgebung für wirklich schwierige Dinge wie z.B. Garbage Collection nutzt. Und man erzeugt keine op-codes, um Objekte zu erzeugen, die Objekte verwalten.
Der Hauptunterschied liegt in der zugrunde liegenden Sprache: Im ersten Beispiel wäre es typischerweise C, wohingegen es in .NET C++ ist. Wenn also eine Anweisung zur Erstellung einer Klasse angetroffen wird, wird direkt eine C++-Klasse erzeugt. Innerhalb des C-Ansatzes würde der op-code zur Erzeugung der Klasse eine Subroutine ausführen, die eine Struktur erzeugt, welche die Runtime als Klasse interpretiert. Die Klassenerzeugung in C++ ist sehr viel schneller und besser wartbar.
In der .NET-Laufzeitumgebung erbt ein Vererbungskontext in MSIL-Code von einem C++-Objekt. Warum also nicht gleich reines C++ verwenden und die MSIL überspringen? Der Grund ist, dass - wie sich bei COM gezeigt hat - sehr viel Beinarbeit nötig ist, um eine verwendbare C++-COM-Komponente zu erstellen. Und Sie müssen auch beachten, dass MSIL MSIL heißt und nicht Assembler. MSIL ist eine Zwischensprache oberhalb von C++. Wenn die .NET Laufzeitumgebung eine Klasse mit überladenen Klassenmembern sieht, sieht sie nicht eine spezielle Laufzeitklasse, sondern eine C++-Klasse.
Warum sollte Sie das alles interessieren? Die Antwort hat damit zu tun, wie man Anwendungen programmiert. .NET-Anwendungen sind nicht wie Java-Anwendungen, sondern eher wie Anwendungen mit dem guten alten VB - wenn auch einer optimierten und korrekt designten Version von Visual Basic. Wenn Sie sich die Rotor-Sourcen anschauen, werden Sie feststellen, dass es tatsächlich eine ganze Reihe von Lower-Level-Klassen gibt, die alles managen. Und Sie werden feststellen, dass die .NET Base-Level-Klassen eine ganze Reihe von Aufrufen an native Klassen absetzen. Als Ergebnis dieses Ansatzes ist .NET konsistent, gut engineert und trotzdem performanceorientiert. Diese Tatsache hat mich - vor allen anderen - überrascht.
Einen Array erzeugen
Um die Dynamik von C# und .NET zu zeigen, wollen wir mit etwas C#-Code herumspielen. Im Rotor-Quellcode gibt es eine Klasse Array. Das heißt, dass man, anstatt die []-Notation zu verwenden, Arrays manuell manipulieren könnte. Betrachten Sie den folgenden Quellcode:class TestClass{public void TestArray(){Array arr = new Array();}}
Cannot create an instance of the abstract class or interface 'System.Array'
class TestClass : Array{public void TestArray(){}}
'TestClass' cannot inherit from special class 'System.Array'
Hoppla, wurde das Objekt collectet?
Die .NET Runtime verwaltet per Default Objekte für den Programmierer. Wenn also ein Objekt nicht mehr verwendet wird, wird dieses Objekt vom Garbage Collector aufgesammelt. Es gibt aber auch einen Mechanismus, um Schwache Referenzen auf ein Objekt zu legen. Eine schwache Referenz bedeutet, dass ein Objekt fast aufgegeben wurde, aber - wenn nötig - noch referenziert werden kann. Ein Einsatzfall für die Schwache Referenzierung wäre, wenn Sie ein globales Objekt haben wollen, dieses globale Objekt aber nicht mitführen wollen, wenn dies das System verlangsamen würde, weil zu viele Ressourcen verwendet werden. Der folgende Quellcode zeigt ein Beispiel für Schwache Referenzierung:BaseObj obj = new BaseObj("WeakRef");WeakReference wr = new WeakReference(obj, trackResurrection);
Betrachten wir die Schwache Referenz nun hinsichtlich der Implementierung. Auf den ersten Blick könnte der Entwickler versucht sein, die WeakReference-Klasse als eine reine .NET-Klasse zu begreifen. Und innerhalb dieser reinen .NET-Klasse gäbe es dann einen raffinierten Algorithmus für die Verwaltung der Referenz auf ein Objekt. Wenn man sich jedoch die Rotor-Sourcen anschaut, sieht man, dass WeakReference in Wahrheit keine reine .NET-Klasse ist.
In der .NET-Implementierung bedient sich WeakReference des COM Interoperability Layers. Um zu verstehen, warum dies so ist, müssen Sie sich den Fall der Interoperation mit traditionellen COM-Objekten vorstellen. Diese COM-Objekte müssen von der .NET Runtime gemanagt werden, weshalb es Hilfsmittel gibt, die dies automatisch erledigen. Diese werden verwendet, um Objekte auf einer gepinnten oder ungepinnten Basis zu verwalten. Listing 1
FCIMPL2(LPVOID, GCHandleInternalAlloc, Object *obj, int type){OBJECTREF or(obj);OBJECTHANDLE hnd;HELPER_METHOD_FRAME_BEGIN_RET_NOPOLL();THROWSCOMPLUSEXCEPTION();// If it is a pinned handle, check the object type.if (type == HNDTYPE_PINNED) GCHandleValidatePinnedObject(or);// Create the handle.if((hnd = GetAppDomain()->CreateTypedHandle(or, type)) == NULL)COMPlusThrowOM();HELPER_METHOD_FRAME_END_POLL();return (LPVOID) hnd;}FCIMPLEND
Welche Lektion haben wir nun gelernt? Weak References sind sehr hilfreich, wenn man einen globalen Status verwalten will, ohne ständig allozieren und deallozieren zu müssen. Schwache Referenzen erreichen dasselbe wie Allokation und Deallokation - aber eben nur wenn nötig, etwa in Fällen, in denen Ressourcen knapp sind. Und während wir gerne immer nur reines .NET für alle unsere Programmieraufgaben verwenden würden, ist es manchmal doch besser, nativen Code zu schreiben und bereits bestehende .NET-Möglichkeiten zu verwenden. Im Weak References-Beispiel wird etwa der Garbage Collector verwendet, um ein Problem zu lösen, bei dem es ursprünglich um Interoperabilität ging.
Hast Du noch 'nen Buffer?
Eine der am häufigsten verwendeten Klassen in .NET und jeder anderen Programmierumgebung ist die String-Klasse. Im Kontext der Java Virtual Machine ist ein String eine reine Java-Klasse. Es stellt sich die Frage, ob dies im Kontext der .NET-Umgebung genauso ist. Die Antwort ist Nein. Und bei genauerer Betrachtung der String-Klasse ergeben sich viele interessante Aspekte: Warum, zum Beispiel, ist es möglich, einem String eine Zahl hinzuzufügen und einen verketteten String-Buffer zu erhalten. Die Antwort liegt in der Implementierung der String-Klasse begründet. Das Interessante an der String-Klasse ist die Tatsache, dass diese viele Konstrukte implementiert, die wir in unseren täglichen Programmieraufgaben verwenden.Eine String-Klasse in .NET ist keine reine MSIL-Klasse. Ein String ist ein .NET-befähigter Wrapper der zugrunde liegenden COMString-Klasse der .NET Laufzeitumgebung. COMString verwaltet die Stringdaten in einer Serie von Buffer-Manipulationen. Dabei ist es interessant zu beobachten, wie eine generische C++-Klasse in .NET unter Verwendungen von .NET Best Practices zugänglich gemacht wird.
Der folgende Code zeigt, wie es möglich ist, über eine Array-Notation auf einzelne Elemente des Buffers in .NET zuzugreifen:
String myString = "true";Console.WriteLine( "Hello" + myString[ 1]);
[System.Runtime.CompilerServices.IndexerName("Chars")]public char this[int index] {get { return InternalGetChar(index); }}
FCIMPL3(Object*, COMString::Insert, StringObject* thisRefUNSAFE, INT32 startIndex, StringObject* valueUNSAFE){STRINGREF refRetVal = NULL;STRINGREF thisRef = (STRINGREF) thisRefUNSAFE;STRINGREF value = (STRINGREF) valueUNSAFE;//Allocate a new String.valueLength = value->GetStringLength();newLength = thisLength + valueLength;refRetVal = NewString(newLength);//Get the buffers to access the characters directly.newChars = refRetVal->GetBuffer();thisChars = thisRef->GetBuffer();valueChars = value->GetBuffer();//Copy all of the characters to the appropriate locations.memcpyNoGCRefs(newChars, thisChars, (startIndex*sizeof(WCHAR)));newChars+=startIndex;memcpyNoGCRefs(newChars, valueChars, valueLength*sizeof(WCHAR));newChars+=valueLength;memcpyNoGCRefs(newChars, thisChars+startIndex, (thisLength - startIndex)*sizeof(WCHAR));//Set the String length and return;//We'll count on the fact that Strings are 0 initialized to set the terminating null.refRetVal->SetStringLength(newLength);return OBJECTREFToObject(refRetVal);}FCIMPLEND
Welche Auswirkung hat dies nun für Ihre tägliche Programmierarbeit? Betrachten Sie den folgenden Code:
String result = "hello" + firstString + " " + secondString;
- "hello" erzeugt einen Buffer.
- " " erzeugt einen Buffer.
- Das Verketten von "hello" mit firstString erzeugt einen Buffer.
- Das Verketten von " " mit secondString erzeugt einen Buffer.
- Das Verketten der anderen beiden Buffer erzeugt einen weiteren Buffer.
IL_000c: ldstr "hello"IL_0011: ldloc.0IL_0012: ldstr " "IL_0017: ldloc.1IL_0018: call string [mscorlib]System.String::Concat(string,string,string,string)
Objekte sortierbar machen
Wenn ein Objekt Teil eines Arrays oder einer Collection ist, ist es möglich, das Objekt zu sortieren. Um die die Sortier-Fähigkeiten zu aktivieren, muss man die Schnittstelle IComparable implementieren. Es gibt nur eine Methode IComparable.CompareTo, die wie folgt implementiert wird:public int CompareTo(Object value) {if (value == null) {return 1;}if (!(value is String)) {throw new ArgumentException(Environment.GetResourceString("Arg_MustBeString"));}return String.Compare(this,(String)value);}
Ein Objekt klonen
Innerhalb der .NET Laufzeitumgebung ist es möglich, unter Verwendung der ICloneable-Schnittstelle Objekte zu kopieren. Das Interface ICloneable stellt eine Methode, Clone, zur Verfügung, die folgendermaßen implementiert werden kann:class MyClass : ICloneable{public MyClass(){}public virtual Object Clone(){return MemberwiseClone();}}
[MethodImplAttribute(MethodImplOptions.InternalCall)]protected extern Object MemberwiseClone();
Ein Fall für Ausnahmen
Wenn man sich in den Rotor-Sourcen umschaut, wir schnell offensichtlich, dass eine riesige Menge des Codes safe kodiert wurde. Früher wurde Code geschrieben und dann getestet, um zu gewährleisten, dass bestimmte Grenzen nicht überschritten wurden. Der Test auf diese Grenzen beinhaltete die Hinzufügung von Assert-Code. Das Problem bei Assert-Codes liegt darin, dass diese typischerweise nur in Debug-Builds funktionieren.In Rotor wurde eine riesige Menge Code getestet, um die Einhaltung bestimmter Grenzen zu sichern. Betrachten Sie folgenden Ausschnitt:
public virtual IEnumerator GetEnumerator(int index, int count) {if (index < 0 || count < 0)throw new ArgumentOutOfRangeException((index<0 ? "index" : "count"), Environment.GetResourceString("ArgumentOutOfRange_NeedNonNegNum"));if (_size - index < count)throw new ArgumentException(Environment.GetResourceString("Argument_InvalidOffLen"));return new ArrayListEnumerator(this, index, count);}}
Das ist Safe Coding, da die Tests vor jeder Ausführung durchgeführt werden. Auch in Ihrer Programmiertätigkeit sollte Code so geschrieben werden. Bedeutet das, dass es überall try- und catch-Blöcke geben sollte. Nein. Fehler sollten in zentralen Bereichen abgefangen und behandelt werden.
Buffer Management in .NET
Manchmal kann man es nicht vermeiden, seinen eigenen Buffer in .NET zu managen. Die einfachste Lösung besteht darin, den Buffer als ein Array von Bytes zu betrachten und dann entweder diese Bytes zu verwalten oder die Klasse System.IO.MemoryStream zu verwenden. Dieser Ansatz funktioniert in den meisten Fällen. In den Rotor-Sourcen gibt es jedoch im Namespace System.IO eine interne Klasse namens __UnmanagedMemoryStream. __UnmanagedMemoryStream ist intern und versiegelt, sodass sie ausschließlich in diesem spezifischen Kontext verwendet werden kann. Wenn man jedoch die Kommentare liest, wird schnell deutlich, dass diese Klasse dafür bestimmt ist, auch in anderen Kontexten eingesetzt zu werden. Und da diese Klasse von .NET zur Verfügung gestellt wird, wäre es sinnvoll, sie für die Erstellung eigener Memory Buffer-Klassen zu verwenden.Das erste, was zu beachten ist, ist die korrekte Deklaration der Klasse:
[CLSCompliant(false)]internal sealed class __UnmanagedMemoryStream : Stream
Eine weitere gute Angewohnheit beim Managen von Buffern ist die Vermeidung von Overflow-Situationen, indem man die Länge des Buffers deklariert:
private const long MemStreamMaxLength = Int32.MaxValue;
Geschoss im Anmarsch!
Bill Gates hat Secure Computing zur Priorität erklärt und die Veröffentlichung der .NET-Quellen war dabei ein sehr guter erster Schritt. Dabei habe ich etwas entdeckt, das mich nur den Kopf schütteln ließ - und wofür der Programmierer, der den ursprünglichen Code geschrieben hat, eigentlich ausgepeitscht gehört. Es sei angemerkt, dass der entsprechende Code in Listing 3 Teil der Quellcode-Distribution von .NET Beta 2 ist. Listing 3FCIMPL1(INT32, COMString::GetHashCode, StringObject* str) {VALIDATEOBJECTREF(str);if (str == NULL) {FCThrow(kNullReferenceException);}WCHAR *thisChars;int thisLength;_ASSERTE(str);//Get our values;RefInterpretGetStringValuesDangerousForGC(str, &thisChars, &thisLength);// HashString looks for a terminating null. We've generally said all strings// will be null terminated. Enforce that._ASSERTE(thisChars[thisLength] == L'\0' && "String should have been null-terminated. This one was created incorrectly");INT32 ret = (INT32) HashString(thisChars);FC_GC_POLL_RET();return(ret);}FCIMPLEND
Schluss
Die Inspektion der Rotor-Quellen ist sehr aufschlussreich. Rotor zeigt, welche Fortschritte das Programmieren gemacht bzw. manchmal nicht gemacht hat. Was mir beim Betrachten der Sourcen gefallen hat, war zu sehen, wie Microsoft seinen Code schreibt. Bei den ganzen Basisklassen wurde ich immer wieder daran erinnert, dass hier versucht wurde, den Code so effizient wie möglich und so sicher wie möglich zu schreiben. Microsoft hat versucht, Tabula rasa zu machen und eine neue Art, Code zu schreiben, zu finden.Ich musste mich fragen, ob dies nicht letztlich der Heilsberg-Effekt sei - analog zum Schumi-Effekt (die Formel 1- und Michael Schumacher-Fans unter Ihnen werden diese Anspielung verstehen): Vor Schumi war Ferrari am Ende und er baute über die Jahre durch sein Talent ein Team auf, dass ständig gewinnt.
Wenn Sie an weiteren Informationen interessiert sind, erreichen Sie Christian Gross per eMail unter contact@devspace.com oder besuchen Sie seine Website www.devspace.com.


