sichere c++-programmierung fa. evosoft nürnberg
DESCRIPTION
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 - PowerPoint PPT PresentationTRANSCRIPT
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 dann als „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 „lazy evaluation“)• 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(Overloading and Overriding)
• 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 „most derived 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 Acquisition is Initialization (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. const char * 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