Windows mit C++

C++ und die Windows-API

Kenny Kerr

Kenny KerrDie Windows-API stellt für C++-Entwickler eine Herausforderung dar. Die verschiedenen Bibliotheken, aus denen die API besteht, werden größtenteils entweder als Funktionen und Handles im C-Stil oder als Schnittstellen im COM-Stil verfügbar gemacht. Beides ist nicht sehr entwicklerfreundlich und erfordert einen gewissen Grad an Kapselung oder Dereferenzierung.

Die Herausforderung für C++-Entwickler besteht darin, den Kapselungsgrad zu bestimmen. Entwickler, die mit Bibliotheken wie MFC und ATL groß geworden sind, neigen vielleicht dazu, per Wrapper alles in Klassen und Memberfunktionen einzuschließen, weil dies das seit Langem vertraute Muster der C++-Bibliotheken ist. Andere Entwickler machen sich vielleicht über jede Art von Kapselung lustig und verwenden die rohen Funktionen, Handles und Schnittstellen nur direkt. Dazu lässt sich einwenden, dass diese anderen Entwickler keine richtigen C++-Entwickler sind, sondern einfach C-Entwickler mit Identitätsproblemen. Meiner Meinung nach gibt es für heutige C++-Entwickler jedoch einen natürlicheren Mittelweg.

Indem ich meine Kolumne im MSDN-Magazin wiederaufnehme, zeige ich Ihnen, wie Sie C++0x (bzw. C++ 2011, wie es wohl eher heißen wird) in Verbindung mit der Windows-API nutzen können, um die Kunst der systemeigenen Windows-Softwareentwicklung wieder interessant zu machen. Über die nächsten Monate hinweg nehme ich Sie auf eine umfassende Reise durch die Windows-Threadpool-API mit. Wenn Sie mich dabei begleiten, erfahren Sie etwas über das Schreiben von überraschend skalierbaren Anwendungen, ohne dass dafür ausgefallene neue Sprachen und komplizierte oder teure Laufzeiten erforderlich sind. Sie benötigen lediglich den hervorragenden Visual C++-Compiler, die Windows-API und die Motivation zur Erweiterung Ihrer Kenntnisse.

Wie bei allen guten Projekten, müssen einige Grundvoraussetzungen erfüllt sein, um eine gute Ausgangsbasis zu haben. Wie soll das "Wrappen" der Windows-API also erfolgen? Anstatt mich in jeder Kolumne in Einzelheiten dieser Art zu verlieren, werde ich meinen empfohlenen Ansatz in diesem Artikel darstellen und in den nächsten Artikeln einfach darauf aufbauen. Das Problem der Schnittstellen im COM-Stil lasse ich erst einmal beiseite, da dies für die nächsten Artikel nicht relevant ist.

Die Windows-API besteht aus vielen Bibliotheken, die einen Satz von Funktionen im C-Stil sowie einen oder mehrere nicht transparente Zeiger (Handles) verfügbar machen. Diese Handles stehen in der Regel für eine Bibliothek oder Systemressource. Funktionen werden bereitgestellt, um die Ressourcen mithilfe von Handles zu erstellen, zu manipulieren und freizugeben. Mit der "CreateEvent"-Funktion wird z. B. ein Ereignisobjekt erstellt und ein Handle an das Ereignisobjekt zurückgegeben. Um das Handle freizugeben und dem System mitzuteilen, dass Sie mit der Verwendung des Ereignisobjekts fertig sind, übergeben Sie das Handle einfach an die "CloseHandle"-Funktion. Falls keine anderen ausstehenden Handles für dasselbe Ereignisobjekt vorhanden sind, wird es vom System zerstört:

auto h = CreateEvent( ... );
CloseHandle(h);

Neu bei C++

Falls Sie neu bei C++ 2011 sind, sollte ich darauf hinweisen, dass das Schlüsselwort "auto" den Compiler anweist, den Typ der Variablen aus dem Initialisierungsausdruck abzuleiten. Dies ist nützlich, falls Ihnen der Typ eines Ausdrucks nicht bekannt ist, was bei der Metaprogrammierung häufig vorkommt, oder falls Sie nur einige Tastenanschläge sparen möchten.

Code sollten Sie in den allermeisten Fällen jedoch nicht auf diese Weise schreiben. Zweifellos ist die wertvollste Funktion von C++ die Klasse. Vorlagen sind cool, die Standardvorlagenbibliothek (Standard Template Library, STL) ist magisch, aber ohne die Klasse ergibt alles andere in C++ keinen Sinn. Die Klasse macht C++-Programme kompakt und zuverlässig. Ich rede nicht von virtuellen Funktionen und Vererbung und anderen ausgefallenen Funktionen. Ich rede lediglich von einem Konstruktor und einem Destruktor. Meist ist das alles, was Sie benötigen. Und wissen Sie was? Es kostet Sie nichts. In der Praxis müssen Sie sich über den Mehraufwand aufgrund der Ausnahmebehandlung bewusst sein, worauf ich am Ende dieses Artikels eingehen werde.

Um die Windows-API zu zähmen und für moderne C++-Entwickler zugänglich zu machen, ist eine Klasse erforderlich, die ein Handle kapselt. Es kann sein, dass Ihre bevorzugte C++-Bibliothek bereits über einen Wrapper für Handles verfügt, aber wurde er auch von Grund auf für C++ 2011 entworfen? Können Sie diese Handles auf zuverlässige Weise in einem STL-Container speichern und in Ihrem Programm mehrfach übergeben, ohne dabei den Überblick zu verlieren, wer jeweils der Besitzer ist?

Die C++-Klasse ist die perfekte Abstraktion für Handles. Beachten Sie, dass ich nicht von "Objekten" rede. Denken Sie daran, dass das Handle das Objekt innerhalb Ihres Programms repräsentiert und meist nicht das Objekt selbst ist. Das Handle muss gehütet werden, nicht das Objekt. Es kann in bestimmten Fällen von Vorteil sein, eine 1:1-Beziehung zwischen einem Windows-API-Objekt und einer C++-Klasse zu haben, aber das ist ein anderes Thema.

Obwohl Handles normalerweise nicht transparent sind, gibt es doch unterschiedliche Typen von Handles und häufig auch geringe semantische Unterschiede, die eine Klassenvorlage erfordern, um Handles auf allgemeine Weise adäquat zu "wrappen". Vorlagenparameter werden benötigt, um den Handletyp und die speziellen Merkmale oder Eigenschaften (traits) des Handles anzugeben.

In C++ wird häufig eine "traits"-Klasse verwendet, um Informationen zu einem bestimmten Typ zu liefern. Auf diese Weise kann ich eine einzelne Klassenvorlage für Handles schreiben und unterschiedliche "traits"-Klassen für die unterschiedlichen Typen von Handles in der Windows-API bereitstellen. Die "traits"-Klasse eines Handles muss auch definieren, wie ein Handle freigegeben wird, damit die Handleklassenvorlage es bei Bedarf automatisch freigeben kann. Dies ist eine "traits"-Klasse für Ereignishandles:

struct handle_traits
{
  static HANDLE invalid() throw()
  {
    return nullptr;
  }

  static void close(HANDLE value) throw()
  {
    CloseHandle(value);
  }
};

Da viele Bibliotheken in der Windows-API diese Semantik aufweisen, können sie nicht nur für Ereignisobjekte, sondern auch darüber hinaus verwendet werden. Wie Sie sehen, besteht die "traits"-Klasse nicht nur aus statischen Memberfunktionen. Das Ergebnis ist, dass der Compiler den Code leicht zu Inlinecode machen kann und kein Mehraufwand entsteht, während gleichzeitig ein hohes Maß an Flexibilität für die Metaprogrammierung vorhanden ist.

Die "invalid"-Funktion gibt den Wert eines ungültigen Handles zurück. Dies ist normalerweise ein nullptr, ein neues Schlüsselwort in C++ 2011, das für einen Nullzeigerwert steht. Im Gegensatz zu herkömmlichen Alternativen ist "nullptr" stark typisiert und funktioniert daher gut in Verbindung mit Vorlagen und der Funktionsüberladung. Es gibt Fälle, in denen ein ungültiges Handle als etwas anderes als "nullptr" definiert ist. Dafür gibt es die Einbindung der "invalid"-Funktion in die "traits"-Klasse. Mit der "close"-Funktion wird der Mechanismus gekapselt, mit dem das Handle geschlossen oder freigegeben wird.

Aufgrund der Gliederung der "traits"-Klasse kann ich mit dem Definieren der Handleklassenvorlage beginnen. Dies ist in Abbildung 1 dargestellt.

Abbildung 1: Handleklassenvorlage

template <typename Type, typename Traits>
class unique_handle
{
  unique_handle(unique_handle const &);
  unique_handle & operator=(unique_handle const &);

  void close() throw()
  {
    if (*this)
    {
      Traits::close(m_value);
    }
  }

  Type m_value;

public:

  explicit unique_handle(Type value = Traits::invalid()) throw() :
    m_value(value)
  {
  }

  ~unique_handle() throw()
  {
    close();
  }

Ich habe den Namen "unique_handle" gewählt, weil er der standardmäßigen "unique_ptr"-Klassenvorlage ähnelt. Viele Bibliotheken verwenden auch identische Handletypen und Semantiken, also ist es sinnvoll, ein "typedef"-Element für den am häufigsten verwendeten Fall bereitzustellen, das einfach den Namen "handle" hat:

typedef unique_handle<HANDLE, handle_traits> handle;

Ich kann jetzt ein Ereignisobjekt erstellen und wie folgt "handeln":

handle h(CreateEvent( ... ));

Ich habe den Kopierkonstruktor und Kopierzuweisungsoperator als privat deklariert und unimplementiert gelassen. Dadurch wird verhindert, dass der Compiler diese Elemente automatisch generiert, da sie nur in seltenen Fällen für Handles geeignet sind. Die Windows-API lässt das Kopieren bestimmter Arten von Handles zu, aber dies ist ein völlig anderes Konzept als die C++-Kopiersemantik.

Der Parameter "value" des Konstruktors benötigt die "traits"-Klasse zum Bereitstellen eines Standardwerts. Der Destruktor ruft die private "close"-Memberfunktion auf, für die wiederum die "traits"-Klasse erforderlich ist, was das Schließen des Handles im Bedarfsfall angeht. Auf diese Weise erhalte ich ein stapelfreundliches und vor Ausnahmen geschütztes Handle.

Ich bin aber noch nicht fertig. Die "close"-Memberfunktion erfordert das Vorhandensein einer booleschen Konvertierung, um zu bestimmen, ob das Handle geschlossen werden muss. Obwohl mit C++ 2011 explizite Konvertierungsfunktionen eingeführt werden, ist dies in Visual C++ noch nicht verfügbar. Also verwende ich in Bezug auf die boolesche Konvertierung eine gängige Vorgehensweise, um die gefürchteten impliziten Konvertierungen zu vermeiden, die der Compiler andernfalls zulässt:

private:

  struct boolean_struct { int member; };
  typedef int boolean_struct::* boolean_type;

  bool operator==(unique_handle const &);
  bool operator!=(unique_handle const &);

public:

  operator boolean_type() const throw()
  {
    return Traits::invalid() != m_value ? &boolean_struct::member : nullptr;
  }

Dies bedeutet, dass ich jetzt einfach testen kann, ob ich über ein gültiges Handle verfüge, jedoch ohne das Risiko einzugehen, dass unbemerkt gefährliche Konvertierungen ablaufen:

unique_handle<SOCKET, socket_traits> socket;
unique_handle<HANDLE, handle_traits> event;

if (socket && event) {} // Are both valid?

if (!event) {} // Is event invalid?

int i = socket; // Compiler error!

if (socket == event) {} // Compiler error!

Bei Verwendung des offensichtlicheren Operators "bool" hätte es sein können, dass die beiden letzten Fehler unbemerkt bleiben. Dabei ist es jedoch möglich, einen Socket mit einem anderen zu vergleichen. Daher ist es erforderlich, die Gleichheitsoperatoren entweder explizit zu implementieren oder als privat zu deklarieren und unimplementiert zu lassen.

Die Art und Weise, wie ein "unique_handle"-Element ein Handle besitzt, ist analog zur Art und Weise, wie die standardmäßige "unique_ptr"-Klassenvorlage ein Objekt besitzt und dieses Objekt über einen Zeiger verwaltet. Dann ist es sinnvoll, die vertrauten Memberfunktionen "get", "reset" und "release" bereitzustellen, um das zugrunde liegende Handle zu verwalten. Die "get"-Funktion ist einfach:

Type get() const throw()
{
  return m_value;
}

Die "reset"-Funktion ist etwas aufwändiger, baut jedoch auf den bisher behandelten Informationen auf:

bool reset(Type value = Traits::invalid()) throw()
{
  if (m_value != value)
  {
    close();
    m_value = value;
  }

  return *this;
}

Ich habe mir erlaubt, die "reset"-Funktion in Bezug auf das von "unique_ptr" vorgegebene Muster leicht zu ändern, indem ich einen "bool"-Wert zurückgebe. Mit dem Wert wird angegeben, ob das Objekt mit einem gültigen Handle zurückgesetzt wurde. Dies ist bei der Fehlerbehandlung nützlich, auf die ich gleich zurückkomme. Die "release"-Funktion sollte jetzt offensichtlich sein:

Type release() throw()
{
  auto value = m_value;
  m_value = Traits::invalid();
  return value;
}

Vergleich von Kopieren und Verschieben

Der letzte Schritt ist der Vergleich der Semantik zum Kopieren und zum Verschieben. Da ich die Kopiersemantik für Handles bereits ausgeschlossen habe, ergibt es Sinn, die Verschiebungssemantik zuzulassen. Dies wird wichtig, wenn Sie Handles in STL-Containern speichern möchten. Für diese Container wurde bisher immer die Kopiersemantik verwendet, aber mit der Einführung von C++ 2011 wird auch die Verschiebungssemantik unterstützt.

Ohne die Verschiebungssemantik und R-Wert-Verweise ausführlich zu beschreiben, lautet die Grundidee wie folgt: Es wird zugelassen, dass der Wert eines Objekts so von einem Objekt zum anderen übergeben wird, dass der Vorgang für den Entwickler vorhersagbar und für Bibliotheksautoren und Compiler kohärent ist.

Vor C++ 2011 mussten Entwickler alle mögliche Arten von komplizierten Tricks anwenden, um die außerordentliche Vorliebe der Sprache – und somit indirekt auch der STL – für das Kopieren von Objekten zu umgehen. Häufig hat der Compiler eine Kopie eines Objekts erstellt und das Original dann sofort zerstört. Bei der Verschiebungssemantik kann der Entwickler deklarieren, dass ein Objekt nicht mehr verwendet wird und sein Wert an einen anderen Ort verschoben wird, und das häufig nur per Zeigertausch.

In einigen Fällen muss der Entwickler explizit sein und dies angeben. Häufig kann der Compiler jedoch verschiebungsfähige Objekte verwenden und hoch effiziente Optimierungen durchführen, die vorher nicht möglich waren. Die gute Nachricht ist, dass die Aktivierung der Verschiebungssemantik für Ihre eigenen Klassen unkompliziert ist. So wie beim Kopieren ein Kopierkonstruktor und ein Kopierzuweisungsoperator verwendet werden, gibt es bei der Verschiebungssemantik einen Verschiebungskonstruktor und einen Verschiebungszuweisungsoperator:

unique_handle(unique_handle && other) throw() :
  m_value(other.release())
{
}

unique_handle & operator=(unique_handle && other) throw()
{
  reset(other.release());
  return *this;
}

R-Wert-Verweis

Mit C++ 2011 wird eine neue Art von Verweis eingeführt, und zwar der so genannte R-Wert-Verweis. Er wird mithilfe von "&&" deklariert, so wie in den "unique_handle"-Membern im obigen Code. Obwohl dies älteren Verweisen ähnelt, die jetzt als L-Wert-Verweise bezeichnet werden, verfügen die neuen R-Wert-Verweise über etwas andere Regeln, was die Initialisierung und die Überladungsauflösung betrifft. Jetzt möchte ich es erst einmal dabei belassen (und dann später auf dieses Thema zurückkommen). Der Hauptvorteil eines Handles mit Verschiebungssemantik in dieser Phase ist, dass Sie Handles korrekt und effizient in STL-Containern speichern können.

Fehlerbehandlung

Mit der "unique_handle"-Klassenvorlage sind wir damit fertig. Das letzte Thema in diesem Monat – und die Vorbereitung auf die weiteren Artikel – ist die Fehlerbehandlung. Man kann endlose Debatten über die Vor- und Nachteile der Ausnahme im Vergleich mit Fehlercodes führen, aber wenn Sie die standardmäßigen C++-Bibliotheken nutzen möchten, müssen Sie sich einfach an Ausnahmen gewöhnen. In der Windows-API werden natürlich Fehlercodes verwendet, also ist ein Kompromiss erforderlich.

Mein Ansatz in Bezug auf die Fehlerbehandlung ist, so wenig wie möglich zu tun und ausnahmesicheren Code zu schreiben, dabei aber das Abfangen von Ausnahmen zu vermeiden. Falls keine Ausnahmehandler vorhanden sind, generiert Windows automatisch einen Fehlerbericht, der einen Minidump des Absturzes enthält, den Sie im Nachhinein debuggen können. Lösen Sie Ausnahmen nur aus, wenn unerwartete Laufzeitfehler auftreten, und lösen Sie alle anderen Fälle mit Fehlercodes. Wenn eine Ausnahme ausgelöst wird, können Sie dann sicher sein, dass Ihr Code einen Bug enthält oder dass es sich um einen schwerwiegenden Fehler handelt, der den Computer befallen hat.

Als Beispiel möchte ich das Zugreifen auf die Windows-Registrierung verwenden. Wenn beim Schreiben eines Wert in die Registrierung ein Fehler auftritt, ist dies gewöhnlich ein Zeichen für ein größeres Problem, das Sie in Ihrem Programm auf vernünftige Weise nur schwer beheben können. Dies sollte zu einer Ausnahme führen. Das Auftreten eines Fehlers beim Auslesen eines Werts aus der Registrierung sollte jedoch antizipiert und angemessen behandelt werden. Es sollte nicht zu einer Ausnahme führen, sondern es sollte ein "bool"- oder "enum"-Wert zurückgegeben werden, der angibt, ob bzw. warum der Wert nicht gelesen werden konnte.

Die Windows-API geht bei der Fehlerbehandlung nicht besonders einheitlich vor. Das ist das Ergebnis einer API, die über viele Jahre hinweg weiterentwickelt wurde. Meist werden die Fehler entweder als BOOL- oder HRESULT-Werte zurückgegeben. Es gibt noch einige andere, die ich in der Regel explizit behandle, indem ich den Rückgabewert mit dokumentierten Werten vergleiche.

Wenn ich entscheide, dass ein bestimmter Funktionsaufruf erfolgreich sein muss, damit mein Programm weiter zuverlässig funktioniert, verwende ich eine der in Abbildung 2 aufgeführten Funktionen, um den Rückgabewert zu überprüfen.

Abbildung 2: Überprüfen des Rückgabewerts

inline void check_bool(BOOL result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_bool(bool result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_hr(HRESULT result)
{
  if (S_OK != result)
  {
    throw check_failed(result);
  }
}

template <typename T>
void check(T expected, T actual)
{
  if (expected != actual)
  {
    throw check_failed(0);
  }
}

Zwei Dinge sind in Bezug auf diese Funktionen erwähnenswert. Erstens: Die "check_bool"-Funktion ist überladen, sodass Sie auch die Gültigkeit eines Handleobjekts prüfen können, wobei zu Recht keine implizite Konvertierung in BOOL zulässig ist. Zweitens: Die "check_hr"-Funktion, die einen expliziten Vergleich mit "S_OK" durchführt, anstatt das gängigere SUCCEEDED-Makro zu verwenden. Dadurch wird das stillschweigende Akzeptieren anderer zweifelhafter Erfolgscodes wie "S_FALSE" vermieden, was von Entwicklern in den meisten Fällen auch nicht erwartet wird.

Mein erster Versuch, diese Prüffunktionen zu schreiben, war ein Satz von Überladungen. Nachdem ich sie in verschiedenen Projekten verwendet hatte, ist mir aufgefallen, dass die Windows-API zu viele Ergebnistypen und Makros definiert. Das Erstellen eines Satzes von Überladungen, der für alle Eventualitäten gilt, ist daher einfach nicht möglich. Daher die ergänzten Funktionsnamen. Ich bin auf ein paar Fälle gestoßen, in denen Fehler aufgrund einer unerwarteten Überladungsauflösung nicht abgefangen wurden. Der ausgelöste "check_failed"-Typ ist relativ einfach:

struct check_failed
{
  explicit check_failed(long result) :
    error(result)
  {
  }

  long error;
};

Ich könnte dies durch verschiedene Arten ausgefallener Funktionen ergänzen, z. B. Unterstützung für Fehlermeldungen, aber wozu soll das gut sein? Ich binde den Fehlerwert ein, damit ich ihn leicht herauspicken kann, wenn ich für eine abgestürzte Anwendung eine Autopsie durchführe. Alles darüber hinaus ist nur im Wege.

Bei Verwendung dieser Prüffunktionen kann ich ein Ereignisobjekt erstellen und mit einem Signal versehen, sodass eine Ausnahme ausgelöst wird, wenn etwas schiefgeht:

handle h(CreateEvent( ... ));

check_bool(h);

check_bool(SetEvent(h.get()));

Ausnahmebehandlung

Das andere Problem bei der Ausnahmebehandlung betrifft die Effizienz. Entwickler sind auch hierbei geteilter Meinung, aber häufig basiert dies auf Annahmen, die der Wirklichkeit nicht standhalten.

Der Aufwand für die Ausnahmebehandlung fällt in zwei Bereichen an. Der erste Bereich ist das Auslösen von Ausnahmen. Dies ist oft langsamer als die Verwendung von Fehlercodes und einer der Gründe dafür, warum Sie Ausnahmen nur bei Auftreten eines schwerwiegenden Fehlers auslösen sollten. Wenn alles gut geht, fällt dieser Aufwand niemals an.

Die zweite und häufigere Ursache für Leistungsprobleme hat mit dem Laufzeitaufwand zu tun, bei dem sichergestellt werden muss, dass in dem unwahrscheinlichen Fall der Auslösung einer Ausnahme die richtigen Destruktoren aufgerufen werden. Es ist Code erforderlich, um zu verfolgen, welche Destruktoren ausgeführt werden müssen. Dies erhöht natürlich auch die Größe des Stapels, was bei großen Codebasen eine erhebliche Auswirkung auf die Leistung haben kann. Beachten Sie, dass dieser Aufwand unabhängig davon anfällt, ob wirklich eine Ausnahme ausgelöst wird. Die Minimierung in diesem Bereich ist für eine gute Leistung also wichtig.

Aus diesem Grund muss sichergestellt werden, dass der Compiler gut darüber informiert ist, welche Funktionen Ausnahmen auslösen könnten. Wenn der Compiler über den Nachweis verfügt, dass von bestimmten Funktionen keine Ausnahmen ausgelöst werden, kann er den Code optimieren, der zum Definieren und Verwalten des Stapels generiert wird. Aus diesem Grund habe ich die gesamte Handleklassenvorlage und die Memberfunktionen der "traits"-Klasse um die Ausnahmenspezifikation ergänzt. Obwohl dies in C++ 2011 veraltet ist, handelt es sich um eine wichtige plattformspezifische Optimierung.

Das ist alles für diesen Monat. Sie verfügen jetzt über eines der entscheidenden Instrumente zum Schreiben zuverlässiger Programme mithilfe der Windows-API. Seien Sie auch nächsten Monat wieder dabei, wenn ich mit der Untersuchung der Windows-Threadpool-API beginne.        

Kenny Kerr ist Softwarespezialist mit einer Vorliebe für die systemeigene Windows-Entwicklung. Sie erreichen ihn unter kennykerr.ca.