Netting C++
Konfiguration mit XML
Stanley B. Lippman

Inhalt
Dies ist der zweite Artikel in einer Reihe, in der die Verwendung von C++/CLI als reine Microsoft® .NET Framework-kompatible Sprache und nicht als Übergangsbrücke, um systemeigenen C++-Code in die verwaltete Umgebung zu bringen, betrachtet wird. Meine Anwendung EEK!, die im letzten Artikel beschrieben wurde, ist eine Simulation des möglichen Verhaltens einer Maus in einer experimentellen Umgebung über einen bestimmten Zeitraum. Wenn die Prämisse der Anwendung Sie beunruhigt, können Sie sich stattdessen vorstellen, es handele sich um die logische Programmierung von Nichtspielerfiguren (Non Player Characters, NPCs) in einem Onlinespiel. Die Aufgabe in diesem Artikel besteht darin, die virtuelle Welt zu initialisieren, indem eine als XML gespeicherte Weltkonfigurationsdatei eingelesen wird. Dazu wird der .NET XML-Namespace verwendet.
Nur ein Hinweis, bevor ich beginne. Im ersten
EEK!-Artikel habe ich angedeutet, dass ich versuchen würde, die tatsächliche Käfigumgebung zu simulieren, in der ich Mäuse gehalten habe, um ihr Verhalten zu beobachten. Ich musste jedoch feststellen dass die Realität eine unerwünschte Einschränkung für eine virtuelle Welt aufwirft: die Simulation war einfach nicht interessant genug. Also habe ich diese Einschränkung aus der Anwendung entfernt. Stattdessen handelt es sich bei der Umgebung einfach um ein Terrain, das nur von der eigenen Vorstellungskraft begrenzt wird. Sie muss groß und komplex genug sein, um sowohl für die Maus, die sie erforscht, als auch für mich bei der Programmierung eine Herausforderung darzustellen. Ich entschuldige mich dafür, dass ich die Spezifikation geändert habe. Doch wenn Sie ein Softwareexperte sind, sollten Sie sich mittlerweile daran gewöhnt haben.
Folgendes ist zu tun: Zunächst muss die virtuelle Welt angelegt werden, in der die Simulation ausgeführt wird. In einem Spielstudio wird dies mit einem von einer grafischen Benutzeroberfläche gesteuerten Welterstellungstool durchgeführt. Wenn der Designer auf „Speichern“ klickt, werden die Geometrie und die zugeordneten Statuswerte aller Elemente, die in der Welt platziert wurden, in einer XML-Datei festgehalten. Zweitens muss die XML-Datei gelesen und die virtuelle Welt wiederhergestellt werden können. Die Datei wird EEKWorld.xml genannt und in einem Standardverzeichnis gespeichert. Wenn ich einen meiner künstlerischer veranlagten Kollegen dazu bringen kann, können wir vielleicht sogar einen einfachen EEK!-Builder erzeugen. Für diesen Artikel gehen wir einfach davon aus, dass er vorhanden ist.
Die generierte XML-Datei wird eingelesen und innerhalb einer Hierarchie von Klassenabstraktionen gespeichert, die jede Entität bzw. jede Beziehung zwischen Entitäten innerhalb der Welt darstellen. Diese Klassen werden im nächsten Artikel erörtert. Außerdem werde ich ausführlich auf das Wesen der EEK!-Welt eingehen und darauf, wie ich ihre interne Darstellung entworfen habe. Das ist der vergnügliche Teil, doch zunächst muss der XML-Code selbst in die Anwendung eingebracht werden, bevor dies einen Sinn ergibt.
Im Rest dieses Artikels werden deshalb die beiden primären Strategien zum Analysieren einer XML-Datei besprochen: das Firehosemodell und das Dokumentobjektmodell (DOM). Firehose soll einen unidirektionalen Datenstrom suggerieren. Sie können die Daten nicht abrufen, sobald sie vorbeigeströmt sind. Das Dokumentobjektmodell ist eine Darstellung der XML-Datei im Speicher, die durchlaufen werden kann. Bei der eigentlichen Implementierung wird das Dokumentobjektmodell verwendet. Es ist jedoch wichtig, sich darüber bewusst zu sein, dass beide Modelle verfügbar sind.
XmlReader: das Firehose
Eine Möglichkeit zum Verarbeiten eines XML-Dokuments besteht darin, jeweils einen Knoten zu lesen. Anders ausgedrückt, der Knoten wird erfasst und verarbeitet, der erforderliche Zustandskontext wird beibehalten, und dann wird mit dem nächsten Knoten fortgefahren. Dies erfolgt unter .NET mit der abstrakten XmlReader-Klasse (die über mehrere nicht abstrakte abgeleitete Typen wie XmlTextReader verfügt). Dadurch können Knoten übersprungen werden, die nicht von Interesse sind. XmlReader ist innerhalb des System.xml-Namespace definiert. Abbildung 1 stellt das allgemeine Codemodul dar, das auf jeden Knoten angewendet wird.

Figure 1 Allgemeines Codemodul
using namespace System; // for String, Console
using namespace System::Xml; // for XML classes and enums
using namespace System::IO; // for FileStream
int main()
{
String^ fname = “EEKWorld.xml”;
FileStream^ fs = File::OpenRead( fname );
XmlReader^ xrd = XmlReader::Create( fs );
while ( xrd->Read() )
{
// process each node ...
}
xrd->Close();
fs->Close();
}
Das FileStream-Objekt liest den eigentlichen Text. XmlReader stellt die Intelligenz bereit, um alles zu verstehen und zu strukturieren. Bei jedem Aufruf von Read wird der nächste Knoten aus FileStream gelesen. Die Auswertung ergibt „false“, wenn das Ende des Datenstroms erreicht wird. Ich greife über das Reader-Objekt auf den Inhalt des Knotens zu. Einige der für den aktuellen Knoten verfügbaren Eigenschaften sind Name, NodeType, HasValue, Value, HasAttributes, AttributeCount und NamespaceURI. Abbildung 2 zeigt, wie dies programmiert werden könnte. Insbesondere wird die While-Schleife von Abbildung 1 aufgefüllt.

Figure 2 Anwendung auf die Knoten
while ( xrd->Read() )
{
Console::WriteLine( “node type is {0}”, xrd->NodeType );
if ( xrd->Name != String::Empty )
Console::WriteLine( “\tname is {0}”, xrd->Name);
else Console::WriteLine(“\tThis node has no name”);
if ( xrd->HasValue )
Console::WriteLine(“\tvalue is {0}”, xrd->Value );
if ( xrd->NodeType == XmlNodeType::Element &&
xrd->HasAttributes )
{
Console::Write( “\thas {0} attributes: “, xrd->AttributeCount );
while ( xrd->MoveToNextAttribute() )
Console::Write( “{0} = {1} “, xrd->Name, xrd->Value );
Console::WriteLine();
}
else Console::WriteLine( “\thas no attributes” );
}
Wenn Sie den Typ des aktuellen Knotens feststellen möchten, müssen Sie nur die NodeType-Eigenschaft untersuchen. Beispiel:
if ( xrd->NodeType == XmlNodeType::Whitespace ) continue;
Die XmlNodeType-Enumeration dient als eine Art von isA-Kennzeichen, mit dem der Typ des aktuellen Knotens identifiziert wird. Beispiel:
enum class XmlNodeType{ None, Element, Attribute, ... };
Beachten Sie, dass Attribute nicht als untergeordnete Elemente behandelt werden. Vielmehr muss explizit darauf zugegriffen werden. MoveToAttribute wechselt zu einem angegebenen Attribut, wobei eine der folgenden überladenen Instanzen verwendet wird:
// move to the attribute with the specified index
void MoveToAttribute( int index )
// move to the attribute with the specified name
bool MoveToAttribute( String^ name )
// move to the attribute with these characteristics
bool MoveToAttribute( String^ localName, String^ namespaceURI )
Sollen alle Attribute eines Elements durchlaufen werden, kann auch die MoveToNextAttribute-Methode verwendet werden. Wenn der aktuelle Knoten ein Elementknoten ist, wechselt diese Methode zum ersten Attribut. Jeder nachfolgende Aufruf wechselt zum jeweils nächsten Attribut, bis keine Attribute mehr vorhanden sind. Rufen Sie MoveToElement auf, um zu dem Elementknoten zurückzukehren, der die Attribute enthält. Obwohl bei diesem Verfahren das Dokument nur in Vorwärtsrichtung und ohne Zwischenspeicherung gelesen wird, bietet es eine gewisse Flexibilität.
Und obwohl es effizient ist, mag ich das Firehosemodell persönlich nicht. Es hat zwei wesentliche Nachteile. Einerseits gibt es keine Möglichkeit, zurückzugehen. Aus diesem Grund muss alles, von dem Sie meinen, dass Sie es benötigen, extrahiert und lokal gespeichert werden. Andererseits ist dieser Programmierstil des Aktivierens und Verarbeitens von Knotentypen in Bezug auf die einzelnen Knoten im Wesentlichen typenlos, alle Schritte erfolgen über das Textleserelement. Ich bevorzuge als Modell DOM, bei dem das gesamte Dokument als Hierarchie navigierbarer typisierter Klassenknoten, die die von XML unterstützten Typen darstellen, im Arbeitsspeicher abgelegt wird.
Es ist natürlich nicht immer die beste Lösung, das gesamte Dokument vor der Verarbeitung in den Speicher einzulesen, insbesondere wenn das Dokument riesig oder der verfügbare Speicher begrenzt ist. Deswegen ist das Firehosemodell manchmal die richtige Wahl.
Dokumentobjektmodell
Unter DOM verwende ich ebenfalls ein FileStream- und XmlReader-Paar, um den XML-Code zu lesen und zu strukturieren. Der Unterschied besteht darin, dass das XmlReader-Objekt nicht direkt bearbeitet wird. Vielmehr wird das XmlReader-Objekt an die Load-Methode der XmlDocument-Klasse übergeben. Load erstellt die Strukturdarstellung des Dokuments im Speicher, durch die navigiert werden kann. Die einzelnen Knotentypen werden von der XmlNode-Klassenhierarchie dargestellt, die solche abgeleiteten Klassen wie XmlElement und XmlAttribute enthält. In Abbildung 3 wird ein Beispiel für die Einrichtung der XML-Datei im Speicher gezeigt.

Figure 3 Einrichten der XML-Datei im Speicher
using namespace System;
using namespace System::Xml;
using namespace System::IO;
int main()
{
String^ fname = “EEKWorld.xml”;
FileStream^ fs = File::OpenRead( fname );
XmlReader^ xrd = XmlReader::Create( fs );
// build the in-memory representation
XmlDocument^ xd = gcnew XmlDocument;
xd->Load( xrd );
ProcessDom(xd); // work with the DOM
xrd->Close();
fs->Close();
}
Die XmlDocument-Klasse stellt einen Satz von Eigenschaften bereit, die das Abfragen der im Speicher abgelegten Darstellung des Dokuments unterstützen. Abbildung 4 zeigt beispielsweise, wie die ProcessDom-Funktion von der Hauptfunktion in Abbildung 3 aufgerufen wird. Die inneren Knoten des Dokuments werden jeweils durch ein einzelnes Objekt dargestellt, die alle in der abstrakten XmlNode-Basisklasse ihren Ursprung haben. Tatsächlich erbt die XmlDocument-Klasse selbst von XmlNode.

Figure 4 ProcessDom
void ProcessDom( XmlDocument^ xd )
{
Console::WriteLine( “XmlDocument {0} :: {1} “, xd->Name, xd->Value );
XmlAttributeCollection^ xac = xd->Attributes;
if ( xac->Count != 0 )
{
// process attributes ...
}
Console::WriteLine(“Retrieving the {0} XmlDocument Children\n”,
xd->ChildNodes->Count );
XmlNodeList^ children = xd->ChildNodes;
for each ( XmlNode^ node in children )
{
Console::WriteLine( “Child node: {0} of type {1}”,
node->Name, node->NodeType );
Console::WriteLine( “Child has children? {0} :: Node’s parent:
{1}”, node->HasChildNodes, node->ParentNode->Name );
}
}
Jeder der Knotentypen wird von einer Klasse dargestellt, die von der abstrakten XmlNode-Basisklasse abgeleitet ist. XmlElement stellt ein Element dar, XmlAttribute ein Attribut, XmlComment einen Kommentar usw. Die ChildNodes-Eigenschaft gibt die untergeordneten Knoten zurück, die in einem XmlNodeList-Objekt verwaltet werden.
Mit der GetElementsByTagName-Methode können alle untergeordneten Elemente abgerufen werden, die dem Tagnamen entsprechen. In dem Code in Abbildung 5 werden zum Beispiel durch den Aufruf alle Kachelelemente innerhalb der Weltkonfigurationsdatei in der Reihenfolge abgerufen, in der sie innerhalb des XML-Dokuments auftreten.

Figure 5 Verarbeiten von Kachelelementen
// root node of the document
XmlElement^ xelem = xd->DocumentElement;
// returns a list of all elements with a tag name of tile
XmlNodeList^ tiles = xelem->GetElementsByTagName( “tiles” );
Console::WriteLine( “there are {0} tiles to process: “, tiles->Count );
// now let’s process each node ...
for each ( XmlNode^ tile in tiles )
ProcessTile( static_cast<XmlElement^>( tile ));
C++/CLI ist die einzige .NET-kompatible Sprache, die durch einen static_cast-Operator zur Kompilierzeit eine Typumwandlung ermöglicht. Alle anderen Typumwandlungen einer Basisklasse werden zur Laufzeit dynamisch durchgeführt. In C++/CLI umfasst dies die Umwandlung im C-Stil, safe_cast und dynamic_cast einer Basisklasse. safe_cast ist der bevorzugte Umwandlungsoperator für C++/CLI. Er führt die erforderlichen Schritte durch und vermeidet nach Möglichkeit eine Umwandlung zur Laufzeit. Um jedoch eine dynamische Umwandlung von der abgeleiteten Klasse in die Basisklasse zu unterdrücken, müssen Sie explizit den static_cast-Operator verwenden.
Warum sollte eine Umwandlung zur Laufzeit vermieden werden? Ist es nicht gefährlich, dies zu unterlassen? Sie tun es für eine bessere Leistung, wenn Sie von der Semantik des Programms her absolut sicher sind, dass die Basisklasse, die Sie bearbeiten, definitiv der abgeleitete Typ ist, den Sie bearbeiten müssen. In diesem Fall habe ich eine XmlNode-Klasse und benötige eine XmlElement-Klasse, und ich weiß, dass die XmlNode-Klasse in Wirklichkeit eine XmlElement-Klasse ist.
Wieso habe ich stattdessen eine XmlNode-Klasse? Ich nehme an, dass dies daran liegt, dass die Designer des XML-Namespace keine XmlElementList-Klasse, keine XmlDocumentList-Klasse usw. bereitstellen wollten und es damals keine Generika gab. Da also all diese unterschiedlichen XML-Typen von XmlNode abgeleitet werden, können sie alle in einer Liste von XmlNodes enthalten sein. Dies scheint der einzige Grund dafür zu sein, dass ich einen ungeeigneten Knotentyp erhalte und eine Typumwandlung durchführen muss, bevor ich auf die richtige Schnittstelle zugreifen kann. Ich sehe dies als Preis für die Verwendung eines objektorientierten Entwurfs an.
Um diese unnötigen Leistungseinbußen auszugleichen, haben wir innerhalb der Gruppe, die an C++/CLI arbeitet (und das war eine umfassende gemeinsame Anstrengung), beschlossen, einen Kompilierzeithook bereitzustellen: den static_cast-Operator. Wir alle waren der Ansicht, dass dieser beim Schreiben von realem Code erforderlich war. Es erscheint naiv zu denken, dass diese Art von unnötigem Laufzeitaufwand sich nicht ungünstig auf die Leistung auswirkt. Das Gegenargument besteht darin, dass ich eine systemgebundene Denkweise zeige, wenn ich dies glaube, und dass dies innerhalb einer verwalteten Umgebung kein bedeutender Aufwand ist. Das ist das Argument derjenigen, die meinen, wir hätten uns beim Bereitstellen eines Umwandlungsoperators für die Kompilierzeit geirrt. Letzten Endes wissen wir nicht wirklich, ob es die richtige Entscheidung war, da sie im Widerspruch zur .NET-Philosophie zu stehen scheint. Wir haben es jedoch getan, und der Operator ist verfügbar. Und gerade weil er verfügbar ist, werde ich ihn auch nutzen.
Im nächsten Artikel werde ich die Transformation der XML-Datei erläutern, die in eine Auflistung von C++-/CLI-Klassen geparst wird, die die Welt der Simulation darstellt. So komme ich dann zum Kernstück der Anwendung.
Senden Sie Fragen und Kommentare für Stanley B. Lippman (in englischer Sprache) an purecpp@microsoft.com.
Stanley B. Lippman arbeitet derzeit für Perpetual Entertainment, ein Unternehmen, das sich auf die Entwicklung von Massively-Multiplayer-Onlinespielen und die Infrastrukturtechnologie für deren Entwicklung und Unterstützung spezialisiert hat. Stan ist vor allem für seine Arbeit am ursprünglichen C++-cfront-Compiler bekannt, zusammen mit dessen Schöpfer Bjarne Stroustrup. Seine Lieblingsstelle in seiner bisherigen Karriere war die Arbeit als Software Technical Director für den Firebird-Geschäftsbereich der Fantasia 2000. Er arbeitete zudem mit dem Visual C++-Team von Microsoft bei der Entwicklung von C++/CLI zusammen.