350 likes | 430 Views
Sichere C++-Programmierung Fa. Evosoft Nürnberg. Zusammenfassung der vermittelten Programmierrichtlinien. Const-Qualifizierung. Nutzen Sie die const-Qualifizierung für Variablen, deren Wert allein durch die Initialisierung festgelegt wird und sich anschließend nicht mehr ändert
E N D
Sichere C++-ProgrammierungFa. Evosoft Nürnberg Zusammenfassung der vermittelten Programmierrichtlinien
Const-Qualifizierung • Nutzen Sie die const-Qualifizierung • für Variablen, deren Wert allein durch die Initialisierung festgelegt wird und sich anschließend nicht mehr ändert • zur Unterscheidung von „in“- und „inout“-Parametern wenn Zeiger oder Referenzen übergeben wird • um Methoden zu markieren, welche für const-qualifizierte Objekt-Instanzen aufrufbar sein sollen
Zeiger vs. Referenzen • Nutzen Sie Referenzen, • wenn dadurch immer ein Objekt referenziert wird, • und es sich während der Lebensdauer der Referenz stets ein und dasselbe Objekt handelt • Nutzen Sie Zeiger • wenn auch der Sonderfall „kein Objekt“ (= Null-Zeiger) darstellbar sein muss • oder während der Lebensdauer des Zeigers unterschiedliche Objekte referenziert werden
Explizite Typumwandlung • Nutzen Sie static_cast für Umwandlungen • zwischen arithmetischen Datentypen, wenn der Zieltyp einen kleineren Wertebereich hat und mit dem Cast eine Warnung des Compilers vermieden wird • von Ganzzahlen in Gleitpunktzahlen, wenn ein Quotient mittels Gleitpunkt-Division berechnet werden soll • nur dannals „Down-Cast“ in einer Vererbungslinie, wenn es sich um extrem zeitkritischen Code handelt und die zusätzliche Sicherheit eines dynamic_cast als absolut verzichtbar erscheint
Explizite Typumwandlungen • Nutzen Sie dynamic_cast • um Down-Casts in einer Vererbungslinie abzusichern • mit der Zeiger-Syntax, wenn sie den Fehlerfall mit explizitem Code behandelt wollen • in der Referenzsyntax, wenn Sie im Fehlerfall eine Exception auslösen möchten • Einschränkung: • dynamic_cast funktioniert nur für Objekte von Klassen mit mindestens einer virtuellen Methode • machen Sie notfalls den Destruktor virtuell
Explizite Typumwandlungen • Sofern Ihr Klassen-Design nicht ohne Verwendung von const_cast auskommt • überprüfen Sie das Design auf mögliche Alternativen • verwenden Sie ggf. mutable (z.B. bei redundanten Attributen mit „lazyevaluation“) • Die Notwendigkeit zur Verwendung von reinterpret_cast • sollte sich auf hardware-nahen Code beschränken (z.B. Programmierung vom Embedded Devices oder Treibern) • kann in sehr generischem Code oft durch die Verwendung von Templates reduziert werden
Klassenspezifisch definierte Typumwandlungen • Konstruktoren mit genau einem Argument vom Typ T • werden ggf. automatisch zur Umwandlung des Typs T in die betreffende Klasse angewendet • um diese automatische Anwendung zu vermeiden können solche Konstruktoren als explicit markiert werden • Sogenannte „Type-Cast“-Methoden in der Syntax operator T() • werden ggf. automatisch zur Umwandlung der betreffenden Klasse in den Typ T angewendet • um diese automatische Anwendung zu vermeiden sind stattdessen Methoden der Art T to_T() zu verwenden
Vererbung und Komposition • Bei Vererbung wie bei Komposition • sind die Datenelemente einer Klasse als Teil in einer anderen Klasse enthalten • kann die „enthaltene“ Klasse als „Basis-Klasse“ angegeben werden • Bei Vererbung • muss die Basis-Klasse public sein • gilt das Liskov‘sche Ersetzungsprinzip • Bei Komposition • kann die Basisklasse private oder protected sein • kann statt einer Basisklasse auch ein Attribut entsprechenden Typs verwendet werden
Interfaces • Können als „Bündel von Funktionszeigern“ verstanden werden • Bei der Definition von Interfaces • gibt es (anders als in Java) kein spezielles Konstrukt • sind Klassen mit ausschließlich rein virtuellen Methoden zu verwenden • Bei der Implementierung von Interfaces • werden diese als public-Basisklassen verwendet • gilt (genau wie in Java), dass eine einzelne Klasse auch mehrere Interfaces auf einmal implementieren kann
LSP – Liskov Substituion Principle • Barbara Liskov formulierte folgendes Ersetzungsprinzip: • Ein Objekt einer abgeleiteten Klasse muss überall dort akzeptabel sein, wo eine seiner Basisklassen erwartet wird. • In C++ ist das LSP i.d.R. zur Laufzeit ein „No-Op“, da die Attribute der Basisklasse am Anfang des Datenbereichs der abgeleiteten Klasse liegen … • … d.h. der this-Zeiger gilt unverändert für beide Objekte. • Das LSP gilt nicht in umgekehrter Richtung • d.h. Basisklassen werden niemals (automatisch) dort akzeptiert, wo eine abgeleitete Klasse erwartet wird … • … sondern erfordern ggf. stets eine explizite Typumwandlung (Down-Cast) • Auch dieser Down-Cast kann zur Laufzeit ein „No-Op“ sein … • … außer im Fall von Mehrfachvererbung
Vererbung und Überschreiben von Methoden in abgeleiteten Klassen • Vererbung kann als „Erweiterung“ verstanden werden, denn eine abgeleitete Klasse kann • ihrer Basis-Klasse weitere Attribute hinzufügen • ihrer Basis-Klasse weitere Methoden hinzufügen • einer geerbten Methode weitere Anweisungen hinzufügen • Letzteres geht allerdings nur durch Überschreiben („overriding“) • d.h. die abgeleitete Klasse „ersetzt“ die geerbte Methode durch eine neue ... • … ruft dort jedoch die Methode der Basisklasse auf und … • … kann jetzt davor und dahinter Anweisungen hinzufügen
LSP-Problematikbei Zeigern und Arrays • C++ hat von C den engen Zusammenhang zwischen Zeigern und Arrays übernommen: • Zeiger auf Array-Elemente können inkrementiert werden … • … und zeigen dann auf das nächste Element • Es entspricht zumindest in C der üblichen Praxis, eine Schleife über alle Elemente eines Arrays mit Zeigern zu implementieren • Durch das LSP • kann ein Basisklassen-Zeiger jederzeit auf ein Element in einem Array von abgeleiteten Klassen verweisen • wird aber falsch inkrementiert, wenn die abgeleitete Klasse gegenüber der Basisklasse mehr Speicherplatz benötigt • Das Problem tritt oft etwas verschleiert in Erscheinung, • wenn ein Array als Parameter an eine Funktion übergeben wird • wobei – technisch gesehen – lediglich Zeiger verwendet werden
Vor- und Nachbedingungen(Pre- und Post-Conditions) • Beim Überschreiben von Methoden ist das LSP zu beachten: • Vorbedingungen dürfen niemals strenger gefasst sein als die der überschriebenen Methode • Nachbedingungen dürfen niemals schwächer gefasst sein als die der überschriebenen Methode • Andernfalls würde Code. der für die Basisklasse „korrekt“ ist, mit der abgeleiteten Klasse nicht mehr funktionieren • Vor- und Nachbedingungen • sollten daher für Methoden einer als Basisklasse entworfenen Klasse ausdrücklich spezifiziert sein … • … ansonsten ist beim Überschreiben von Methoden nicht erkennbar, ob das LSP evtl. verletzt wurde (möglicherweise unbeabsichtigt)
Überladen und Überschreiben(OverloadingandOverriding) • Von Überladen spricht man wenn • mehrere Methoden (oder globale Funktionen) mit identischem Namen aber unterschiedlicher Anzahl bzw. unterschiedlichem Typ von Argumenten existieren • die beim Aufruf angegebenen Argumente bestimmen, welche Methode aufgerufen wird • Von Überschreiben spricht man wenn • eine abgeleitete Klasse eine Methode ihrer Basisklasse durch eine gleichnamige Methode ersetzt • hierdurch werden zugleich alle überladenen Methoden der Basisklasse verdeckt • die abgeleitete Klasse sollte daher ggf. alle überladenen Methoden überschreiben
„inline“ vs. normale Methoden • Methoden (Member-Funktionen von Klassen) • entsprechen üblicherweise Unterprogrammen • mit einem zusätzlich (versteckt) übergebenen Argument (this-Zeiger) • Bei Verwendung von „inline“ • wird der Methoden-Inhalt (Body) an der Aufrufstelle direkt eingesetzt • im Unterschied zu Präprozessor Makros erfolgt dies „semantisch korrekt“ • Normalerweise ergibt sich mit „inline“ • eine etwas bessere Ausführungsgeschwindigkeit • aber mehr Bedarf an Speicherplatz (im Code) • der konkret von der Zahl der Methoden-Aufrufstellen abhängt • Im Fall sehr kleiner Methoden kann „inline“ • deutlich schnelleren Code erzeugen (da bessere „Lokalität“) • der im Gesamtumfang sogar kleiner ist
Compilezeit-Typ und Laufzeit-Typ • Der Compilezeit-Typ einer Variablen • ist der aus der Deklaration/Definition ersichtliche Typ • bestimmt bei Objekten, welche Methoden aufgerufen werden können • Der Laufzeit-Typ einer Variablen • kann bei einem Zeiger oder einer Referenz auch eine vom Compilezeit-Typ abgeleitete Klasse sein (LSP!) • stimmt ansonsten mit dem Compilezeit-Typ überein • legt im Falle virtueller Methoden fest, welche Methode tatsächlich aufgerufen wird • kann bei Bedarf mittels RTTI (Runtime-Type-Information) ermittelt werden
Virtuelle Methoden • Ein großer Teil der Flexibilität Objektorientierter Programmierung resultiert aus der Verwendung virtueller Methoden • Sie verschieben „externe Fallunterscheidungsketten“ in die Klassenhierarchie selbst und … • … führen damit zu besserer Wartbarkeit und Erweiterbarkeit • Virtuelle Methoden haben grundsätzlich einen geringfügigen Overhead • der – relativ betrachtet – um so mehr ins Gewicht fällt, je weniger Code die Methode enthält • bei sehr kleinen Methoden ist daher der Vorteil der flexiblen Erweiterbarkeit gegenüber dem Geschwindigkeits-Nachteil abzuwägen
Virtuelle und Methoden und „inline“ • Der Aufrufmechanismus für virtuelle Methoden • erlaubt die Auswahl gemäß dem Laufzeit-Typ … • … setzt aber den Weg über eine Einsprungtabelle voraus • insofern muss immer ein Unterprogramm-Sprung erfolgen • Da sich Compilezeit- und Laufzeit-Typ aber nur bei Bezugnahme über Zeiger und Referenzen unterscheiden können • ist der Weg über die Sprungtabelle nicht erforderlich, wenn das Objekt direkt angesprochen wird • entfaltet „inline“ in diesem Fall auch bei virtuellen Methoden seine Wirkung
Mehrfachvererbung undVirtuelle Basisklassen • Mehrfachvererbung • bezeichnet den Fall, dass eine Klasse mehr als eine Basisklasse hat • ist so lange unproblematisch, wie die Vererbungslinien nicht wieder in einer gemeinsamen Basisklasse zusammentreffen • Ist letzteres doch der Fall, wird die gemeinsame Basisklasse per Default mehrfach enthalten sein („disjoint“) • weshalb das LSP nicht mehr für diese gemeinsame Basisklasse greift • Virtuelle Basisklassen • sind die Lösung für den Fall, dass eine gemeinsame Basisklasse bei Mehrfachvererbung nur einmal enthalten sein soll („overlapping“) • bedingen Overhead durch einen zusätzliche Zeiger (pro Objekt) in den direkt abgeleiteten Klassen und eine Indirektionsstufe (bei Zugriff auf Attribute der virtuellen Basisklasse) • sind in besondere Weise in Initialisierungs-Listen zu berücksichtigen (Initialisierung muss von der „mostderived class“ ausgehen)
Runtime-Type-Information (RTTI) • Mittels dynamic_cast kann ermittelt werden, • ob der Laufzeit-Typ ggf. wie der in der Cast-Operation vorgegebene Typ verwendbar wäre • also ob er exakt diesem Typ entspricht … • … oder dem einer davon abgeleiteten Klasse • Die Anwendung ist nur im Zusammenhang mit Klassen möglich, die wenigstens eine virtuelle Methode haben • Mittels typeid kann ermittelt werden, • ob der Laufzeit-Typ exakt einem bestimmten Typ entspricht • können einige weitere Informationen zum betreffenden Typ gewonnen werden (z.B. eine Text-Darstellung) • Die Anwendung ist auch auf die in C++ enthaltenen Grundtypen und Klassen ohne virtuelle Methoden möglich … • … bezieht sich dann allerdings auf den Compilezeit-Typ!
Entwurfsmuster: Template Method • Im Sinne des „Open-Close“-Principles • wird hier ein fest vorgegebener Ablauf (= close) • … an vorher festgelegten Stellen mit variabel zu füllenden Erweiterungspunkten ausgestattet (= open) • Die klassische Implementierung der letzeren • erfolgt mit Hilfe virtueller Methode • die von abgeleiteten Klassen nach Bedarf implementiert werden • Alternativ kann dieses Muster auch • auf C++-Templates zurückgreifen und • Erweiterungspunkte in einer bei der späteren Template-Instanziierung anzugebenden Basisklasse implementieren
Ressource-Management • Konstruktoren • sind verantwortlich für die Bereitstellung von Ressourcen, die ein Objekt privat (für sich allein) benötigt • werden bei der Definition des Objekts automatisch aufgerufen (können also nicht vergessen werden) • Destruktoren • sind verantwortlich für die Freigabe von Ressourcen, die ein Objekt privat (für sich allein) benötigt • werden am Ende der Lebensdauer des Objekts automatisch aufgerufen (können also nicht vergessen werden) • Bereitstellung und Freigabe privater Ressourcen außerhalb von Konstruktoren / Destruktoren ist fehlerträchtiger und nur in seltenen Fällen sinnvoll.
Ressource-Leaks (1) • Hierunter versteht man u.a. den schleichenden Verlust an verfügbarem Hauptspeicher, • wenn ein Zeigers zwar mit new initialisiert wird, • das referenzierte Objekt aber nicht vor Ende der Lebensdauer des Zeigers mit delete wieder freigegeben wird • Um Ressource-Leaks im Fall von Exceptions vorzubeugen • ist sicherzustellen, dass die Freigabe einer bereits erfolgreich belegten Ressource in jedem Fall geschieht, • z.B. indem alle Operationen, die möglicherweise (direkt oder indirekt) ein throw auslösen), in einen try-Block eingeschlossen werden, • sodass ein nachfolgender catch-Block die Freigabe vornehmen kann • Ist eine Gruppe von Ressourcen zu belegen • kann die Anforderung nur „eine nach der anderen“ geschehen, • womit sich (ohne RAII) verschachtelte try-Blöcke ergeben
Ressource-Leaks (2) • Ein sehr bekanntes Problem, das zu Ressource-Leaks führen kann, wenn keine Vorkehrung dagegen getroffen werden, • sind Klassen, die im Konstruktor Speicherplatz mit new anfordern, • in einem lokalen (Member-) Attribut halten • bis dieser im Destruktor wieder freigegeben wird. • Solche Klassen müssen zugleich • den per Default erzeugten Kopier-Konstruktor und Zuweisungs-Operator vermeiden • indem entweder entsprechende eigene Methoden definiert • oder zumindest deklariert und nicht implementiert werden • C++0x erlaubt darüberhinaus das „Sperren“ der per Default erzeugten Kopier- und Zuweisungs-Operationen mittels einer speziellen, neuen Syntax
Ressource-Leaks (3) • Scheitert die Anforderung einer Ressource in einem Konstruktor • muss das Problem lokal gelöst werden, • da der Destruktor für ein Objekt erst dann „freigeschaltet“ wird, wenn der Konstruktor vollständig und fehlerfrei sein Ende erreicht hat • Die Behandlung von Problemen bei der Anforderungen im Konstruktor • führt oft zu geschachtelten try-Blöcken, • die sich u.U. auch über die MI-Liste erstrecken müssen • Eine ebenso wirksame aber deutlich elegantere Lösung bieten Ressource-Wrapper (RAII)
Vorsichtsmaßnahmen bei der Verwendung von Auto-Pointern • Bei der Initialisierung ist sicherzustellen, • dass ein Zeiger auf „frischen“ (= mit new angeforderten) Heap-Speicherplatz verwendet wird • der Zeiger darf nicht von new[] geliefert worden sein • der Zeiger darf nicht mit dem Adress-Operator bestimmt worden sein • der Zeiger darf nicht von einem anderen Auto-Pointer mit get ermittelt worden sein • Zur Übergabe eines Auto-Pointers als Argument an eine Funktion ist meist eine Referenz sinnvoll • Bei der Wert-Übergabe wird • die Eigentümerschaft auf den Parameter übertragen • das referenzierte Objekt mit Ende der Funktion gelöscht und • der als aktuelles Argument verwendete Auto-Pointer zum Nullzeiger • Die Rückgabe eines Auto-Pointer in einer return-Anweisung ist OK und sinnvoll (z.B. aus Factory-Funktionen/-Methoden)
Lebensdauer von Objekten • Globale Objekte und Klassen-Attribute (static Member) werden • vor dem Start der main-Funktion initialisiert und • nach dem Ende von main-Funktion aufgeräumt • Block-lokale static Objekte werden • direkt vor der ersten Verwendung initialisiert und • nach dem Ende der main-Funktion aufgeräumt • Block-lokale automatische Objekte werden • wenn der Kontrollfluss ihre Definitionsstelle erreicht initialisiert und • wenn der Kontrollfluss den enthaltenden Block verlässt aufgeräumt • Auf dem Heap angelegte Objekte • werden im Rahmen der new-Anweisung initialisiert und • im Rahmen der delete-Anweisung aufgeräumt • Sie werden jedoch nicht aufgeräumt, wenn lediglich die Lebensdauer des auf sie verweisende Zeigers endet.
Klasse std::auto_ptr • Auto-Pointer bieten einen „leichtgewichtigen“ Ersatz für Zeiger • Sie gehen davon aus, dass sie „Eigentümer“ des über sie erreichbaren Speicherplatzes sind • ein Konstruktor sorgt für dessen Initialisierung • ein Destruktor räumt am Ende der Lebensdauer des Auto-Pointer das dadurch referenzierte Objekt weg • Damit sichergestellt ist, dass immer nur genau ein Auto-Pointer ein bestimmtes Objekt bezeichnet, wird • im Kopierkonstruktor der zur Initialisierung verwendete Auto-Pointer zum Null-Pointer gemacht • im Zuweisungsoperator der auf der rechten Seite stehende Auto-Pointer zum Null-Pointer gemacht
Gemischte Verwendung von auto_ptr<T> und T* • Die get-Methode eines Auto-Pointer • gibt die Adresse des referenzierten Objekts zurück … • … aber der Auto-Pointer ist weiterhin der Eigentümer, wird also zum Ende seiner Lebensdauer das referenzierte Objekt löschen • Sinnvoll, um einem Dritten Zugriff auf das referenzierte Objekt zu geben • Dieser darf den erhaltenen Zeiger nur nicht in einer „langlebigen Variablen“ speichern • Die release-Methode eines Auto-Pointer • gibt die Adresse des referenzierten Objekts zurück … • … macht den Auto-Pointer in diesem Fall aber zum Nullzeiger • Sinnvoll, um einem Dritten die Eigentümerschaft des Objekts zu übertragen • Dieser darf nur nicht vergessen, den über den erhaltenen Zeiger erreichbaren Speicherplatz irgendwann mittels delete freizugeben
Ressource AcquisitionisInitialization (RAII) • Ein u.a. von Bjarne Stroustrup favorisiertes Idiom, gemäß dem • für Ressourcen mit expliziten Anforderungs- und -Freigabe-Operation ein Objekt angelegt werden sollte (Ressource-Wrapper) • das in seinem Konstruktor die Anforderungs-Operation und • in seinem Destruktor die Freigabe-Operation durchführt. • Vorteile eines solchen Ressource-Wrappers sind, dass die Anforderung/Freigabe einfach und risikolos • an einen Code-Block gebunden werden kann, indem dort ein lokales Wrapper-Objekt angelegt wird • an die Lebensdauer eines Objekts gebunden werden kann, indem dort ein Wrapper-Objekt als Attribut angelegt wird
Verwendung von Exceptions • Die Verwendung der throw-Anweisung im Fehlerfall entspricht einem „go-to“ auf einen passenden catch-Block • Es kommen nur catch-Blöcke in Betracht, deren vorangehender try-Block „noch aktiv“ ist … • … der Kontrollfluss verzweigt somit grundsätzlich „zurück in Richtung auf main“ • „Passend“ bedeutet, dass • der Typ des formalen Parameters im catch-Block mit dem Typ des Ausdrucks nach throw übereinstimmt … • … oder letzterer in ersteren umwandelbar ist, und zwar nach den selben Regeln wie bei einem Funktions-Aufruf • Folgen ein und demselben try-Block sowohl catch-Blöcke für Basisklassen wie auch davon abgeleiteten Klassen • sind letztere weiter vorne anzuordnen • sonst werden sie niemals ausgeführt
Typ des Exception-Objekts • C++ macht keine Einschränkungen hinsichtlich des Typs, der als Exception geworfen wird • Grundtypen (z.B. int oder enum als Fehler-Code) funktionieren ebenso • wie Zeiger (z.B. constchar *oder std::string als Fehlermeldung) • und Klassen (Standardklassen oder selbst definierte) • Dennoch ist es ist es empfehlenswert, eigene Exception-Klassen von der Standard-Klassenhierarchie für Exceptions abzuleiten, z.B. • std::logic_error – wenn das Problem durch einen Programmierfehler verursacht wurde und zur Beseitigung der Programm-Quelltext geändert und neu kompiliert werden muss • std::runtime_error– wenn das Problem eine äußere Ursache hat, die zu seiner Beseitigung zu beheben ist • std::exception– Mindestanforderung, damit ein zentraler catch-Block den what-Text nicht behandelter Fehler ausgeben kann
Lebensdauer des Exception-Objekts • Der bei der throw-Anweisung angegebene Ausdruck • wird (zumindest formal) kopiert, • in einen Speicherbereich der auch bei der Ausführung des catch-Blocks noch zur Verfügung steht • Um ein nochmaliges Kopieren zu vermeiden • sollte das „Argument“ des catch-Blocks eine Referenz sein, und • falls der catch-Block die Exception weiterreichen muss, lediglich die Anweisung throw; (ohne nachfolgenden Ausdruck) benutzt werden • Die Verwendung von Zeigern als Exception-Objekte ist • nicht nur überflüssig sonder auch • unnötig fehlerträchtig
Performance von Exceptions • Die Implementierung von Exceptions ist im ISO/ANSI-Standard von C++ nicht exakt vorgegeben • typischerweise ist der Code für den „Normalfall“ (= kein throw) ähnlich schnell wie ein return … • … beim tatsächlichen Auslösen einer Exception aber sehr viel langsamer