Über unsMediaKontaktImpressum
Ralph Tandetzky 19. September 2017

cow_ptr – Der Smartpointer für Copy-On-Write

Copy-On-Write bedeutet, dass beim Kopieren einer Datenstruktur X "unter der Haube" nur ein Pointer (oder eine Referenz) auf die internen Daten weitergereicht wird. Eine echte und tiefe Kopie der eigentlichen Daten wird dabei erst dann durchgeführt, wenn eine Instanz von X ihre internen Daten verändert. Auf diese Weise entsteht für den Benutzer der Datenstruktur X die Illusion, dass es sich um zwei unabhängige Instanzen der Datenstruktur handelt (s.Abb.1).

Dabei wird die Anzahl der Referenzen auf die interne Datenstruktur mitgezählt, damit der letzte Besitzer die Struktur löscht. Das hat den Vorteil, dass beim Verändern von Daten, die nur einmal referenziert werden, keine gesonderte Kopie notwendig ist und so das Kopieren erspart werden kann. Wenn man also mehrmals hintereinander eine Instanz von X verändert, so wird nicht jedes Mal eine neue Kopie der internen Daten erstellt, sondern nur dann, wenn zwischendurch eine Kopie des Objektes erstellt wird.

Ein Design-Problem

Wofür ist Copy-On-Write gut? In persönlicher Erfahrung habe ich immer wieder festgestellt, dass Copy-On-Write viele Vorteile hat. Diese Vorteile liegen aber nicht sofort auf der Hand. Zumal die Implementierung von Copy-On-Write "zu Fuß" oft mühsam und fehleranfällig sein kann. Daher wird Copy-On-Write selten eingesetzt. Copy-On-Write kann jedoch häufig die Lösung von Design-Problemen sein, wie ich es jetzt an einem Beispiel aus dem wahren Leben erklären möchte.

Nehmen wir an, wir haben ein Messgerät mit mehreren Messkanälen. Die Konfiguration des Messgeräts enthält unter anderem die Konfigurationen der einzelnen Messkanäle, die von einer gemeinsamen Basisklasse Channel ableiten (s.Abb.2).

Eine mögliche Implementierung der Klasse MeasurementConfig ohne Copy-On-Write könnte so aussehen:

class MeasurementConfig {
public:
        Channel & getChannel( size_t index );
        SiUnit getChannelUnit( size_t index );
        ⁞
private:
        std::vector<std::unique_ptr<Channel>> channels;
        ⁞
};

Der Getter für die physikalische Einheit (etwa "Volt" oder "mbar") wird gebraucht, weil diese Information nicht immer auf einem Kanal für sich genommen zu ermitteln ist. Wenn beispielsweise ein berechneter Kanal von einem analogen Eingangskanal abhängig ist, dann berechnet sich die SI-Einheit in der Regel aus dem Quellkanal. Der berechnete Kanal an sich kennt jedoch nur den Index seines Quellkanals und hat daher keinen Zugriff auf dessen physikalische SI-Einheit. Dieses Design wurde gewählt, damit die Informationen nicht mehrfach gespeichert werden müssen, weil sonst ständig Sorge dafür getragen werden müsste, dass alle Informationen konsistent vorliegen. Daher kann nur die MeasurementConfig als Ganzes die SI-Einheit eines beliebigen Kanals ermitteln, jedoch nicht die einzelnen Kanäle für sich allein.

Wenn man dieses Design genauer betrachtet, dann erkennt man, dass das Herausreichen einer Referenz auf einen Kanal problematisch ist. Der Benutzer der Klasse MeasurementConfig kann jetzt mit den internen Channels verfahren wie er möchte. Möglicherweise kann der Benutzer der Klasse dafür sorgen, dass die Konfiguration nicht mehr in sich konsistent ist, beispielsweise weil er eine zyklische Abhängigkeit zwischen Kanälen schafft – ohne dass das übergeordnete Objekt der Klasse MeasurementConfig etwas davon bemerkt.

Weiterhin ist ohne weitere Dokumentation unklar, ob die verschiedene Kanäle in verschiedenen Threads gelesen und geschrieben werden dürfen. Bei dem derzeitigen öffentlichen Interface der Klasse sollte man allerdings vorsichtshalber davon ausgehen, dass die Kanäle fester Bestandteil der MeasurementConfig-Klasse sind und nicht gleichzeitig gelesen und beschrieben werden dürfen. Die Folge können entsprechende Skalierungsprobleme sein. 

Kopieren als Alternative

Die Alternative zum Herausreichen einer Referenz ist, dass man eine Kopie eines Kanals heraus gibt und außerdem eine Setter-Funktion anbietet, mit der ein Kanal überschrieben werden kann. Eine tiefe Kopie ist jedoch oft nicht akzeptabel, weil viele Daten kopiert werden müssen, was zu Performance-Problemen führt – das war möglicherweise gerade der Grund dafür, dass wir eine "billige" Referenz herausgereicht haben.

Hier verschafft Copy-On-Write die ersehnte Rettung! Wir reichen eine Struktur mit Copy-On-Write-Semantik heraus. Diese Struktur kann dann gelesen werden, ohne dass eine unnötige Kopie erstellt wird. Es ist fast so billig, wie einen nackten Pointer zu kopieren. Falls man die Struktur dann doch verändern möchte, dann ist eine extra Kopie notwendig. In unserem Fall sind Schreibzugriffe aber zum Glück relativ selten. Schließlich will man eine Messkonfiguration nicht jede Mikrosekunde verändern.

Verwendung von cow_ptr<T>

Die Copy-On-Write-Pointer-Implementierung [1] bietet ein einfaches Wrapper-Klassen-Template, das einer kopierbaren Struktur Copy-On-Write-Semantik verleiht. Bevor wir zum gesamten public Interface der Klasse kommen, hier ein paar Beispiele, wie man die Klasse verwendet:


auto a = make_cow(); // creates a cow_ptr
auto b = a; // copy construction
cow_ptr c; // default construction
c = b; // copy assignment. Reference count == 3 now. 
b->f(); // only works, if X::f() is a const method.
b.modify( [](X*b){ b->mutate(); } ); // internal copy is made
b.modify( [](X*b){ b->mutate(); } ); // no extra copy! (ref count == 1 )
g( *a ); // ‘*a’ is a ‘const X&’

In Zeile 1 wurde eine Factory-Funktion namens make_cow() verwendet – angelehnt an std::make_unique() und std::make_shared() aus der C++-Standardbibliothek. Diese erzeugt ein cu::cow_ptr<X>-Objekt mit nur einer Speicherreservierungsoperation. Der Referenz-Count befindet sich dann direkt neben dem eigentlichen X-Objekt auf dem Heap, ähnlich wie bei einer typischen std::shared_ptr<X>-Implementierung.

In Zeile 2 wird eine Kopie des Pointers erstellt. Unter der Haube wird nur eine Kopie des internen Pointers gemacht und der atomare Referenz-Count um 1 erhöht.

Zeile 5 zeigt, dass man konstante Memberfunktionen von X direkt durch den Pfeiloperator -> aufrufen kann. Das funktioniert nicht bei Memberfunktionen, die Schreibzugriff auf X haben. Das ist absichtlich so designt, denn sonst würde zu leicht unabsichtlich eine tiefe Kopie einer Instanz von X erstellt werden.

In den Zeilen 6 und 7 sieht man, wie man das X-Objekt, auf das gezeigt wird, verändern kann. Das geschieht durch die Memberfunktion cow_ptr<X>::modify(), der man einen Funktor übergibt, welcher dann mit dem internen X-Pointer als Argument ausgeführt wird. Im dem Fall, dass es mehrere cow_ptr<X> auf dasselbe unterliegende X-Objekt gibt, wird vorher noch eine tiefe Kopie erstellt.

In Zeile 8 sieht man, dass cow_ptr<X> ganz gewöhnlich dereferenziert werden kann. Allerdings ist das Ergebnis eine konstante Referenz. Auch das ist mit Absicht so gestaltet, denn eine nicht-konstante Referenz hätte zwei Nachteile: Erstens müsste dann immer eine Kopie erstellt werden, sofern mehrere Pointer auf dasselbe Objekt zeigen, und zweitens wäre es dann zu leicht möglich, den cow_ptr<X> nach Erhalt der nicht-konstanten Referenz zu kopieren und dann über die Referenz Änderungen an beiden cow_ptr<X>-Objekten zu vollziehen. Das widerspricht dem Gedanken, dass Copy-On-Write-Semantik eine spezielle Form der Value-Semantik ist.

Durch die cow_ptr<X>::modify()-Funktion ist es immer noch möglich, Pointer auf das enthaltene X-Objekt herauszugeben und das X-Objekt über diesen Pointer zu verändern. Etwa so:


X * px = x.modify( [](X*p){ return p; } ); // lets pointer escape
auto y = x; // copy construction
px->mutate(); // changes the guts of both x and y.

Die Folgen von solchem Vorgehen sind undurchschaubar. Daher sollte es strikt vermieden werden, nackte C-Pointer auf das unterliegende X-Objekt in irgendeiner Form herauszureichen. Das vorliegende Klassendesign sieht die Memberfunktion cow_ptr<X>::modify() als einzige Möglichkeit vor, das unterliegende X-Objekt zu verändern. Das verhindert es, versehentlich tiefe Kopien zu erstellen und erschwert es, nackte C-Pointer herauszureichen – ganz nach Scott Meyers Rat: "Make interfaces easy to use correctly and hard to use incorrectly."[2]

Weiterhin muss man vermeiden, innerhalb des modify()-Lambdas einen Pointer oder eine Referenz auf das cow_ptr<X>-Objekt zu verwenden, das gerade mit modify() verändert wird. Alles andere ist gefährlich, denn es kann zu zyklischen Referenzen führen und Value-Semantik kaputt machen.

Die öffentliche Schnittstelle der cow_ptr<T>-Templateklasse sieht folgendermaßen aus:

template <typename T> class cow_ptr {
public:
  cow_ptr() noexcept; // default construct
  cow_ptr( std::nullptr_t ) noexcept; // default construct
  cow_ptr( cow_ptr && ) noexcept; // move construct
  cow_ptr( const cow_ptr & ) noexcept; // copy construct
  template <typename U, typename D, typename C = DefaultCloner>
  cow_ptr( std::unique_ptr<U,D> p,  C cloner = DefaultCloner{} );
  cow_ptr &operator=( cow_ptr && ) noexcept; // move assign
  cow_ptr &operator=( const cow_ptr & ) noexcept; // copy assign
  void swap( cow_ptr & other ) noexcept; // nofail swap()
  template <typename U, typename … Args>
  static cow_ptr make( Args &&… ); // for make_cow()
  bool unique() const noexcept; // tell, if this is the only ref
  const T * get() const noexcept; 
  const T * operator->() const noexcept;
  const T & operator*() const noexcept;
  operator bool-type() const noexcept; // conversion
  template <typename F>
  std::result_of_t<F(T*)> modify( F && f ); // write access to pointee
};
  template <typename Base, typename Derived = Base, typename … Args>
  cow_ptr<Base> make_cow( Args && … ); // like make_shared()
  template <typename T>
  void swap( cow_ptr<T> & lhs, cow_ptr<T> & rhs ) noexcept; 
// And comparison operators …

Es handelt sich um eine kopierbare Klasse, die Move-Semantik unterstützt. Oberflächlich betrachtet sieht diese Schnittstelle einem std::shared_ptr<const T> sehr ähnlich, nur dass die modify()-Memberfunktion dazukommt. Ein genauerer Vergleich zu std::shared_ptr<T> folgt später noch. Zu bemerken ist auch, dass man einen cow_ptr<T> aus einem std::unique_ptr<U> erzeugen kann, sofern U gleich T ist oder U eine abgeleitete Klasse von T ist. Somit kann T also eine abstrakte Klasse sein. Wenn eine tiefe Kopie erstellt wird, dann wird immer das U-Objekt kopiert. Dies hilft uns sogar bei der Lösung unseres Problems.

Die Lösung unseres Design-Problems

Statt eine Referenz auf einen abstrakten Kanal herauszugeben, liefern wir einen cow_ptr<Channel>-Wert zurück. Um die Kanalkonfiguration verändern zu können, benötigen wir nun zusätzlich eine Funktion setChannel(). Die MeasurementConfig-Klasse sieht nun also so aus:

class MeasurementConfig {
public:
        Channel & getChannel( size_t index );
        SiUnit getChannelUnit( size_t index );
        ⁞
private:
        std::vector<std::unique_ptr<Channel>> channels;
        ⁞
};

Damit ist das Lesen eines Kanals fast so billig, wie bisher. Zyklische Abhängigkeiten zwischen Kanälen in der MeasurementConfig können nun ausgeschlossen werden, indem die setChannel()-Funktion ihre Eingaben überprüft. Weiterhin kann man nun einfache Threadsafety-Garantien der Klasse MeasurementConfig formulieren: Alle konstanten Memberfunktionen sind threadsafe (d. h. sie können gleichzeitig von beliebig vielen Threads aufgerufen werden) und es ist erlaubt, verschiedene MeasurementConfig-Objekte in verschiedenen Threads gleichzeitig zu lesen und schreiben, solange bei einem Aufruf einer nicht-konstanten Memberfunktion sichergestellt ist, dass kein anderer Thread irgendeine Memberfunktion auf eben diesem MeasurementConfig-Objekt gleichzeitig ausführt. Das ist eine typische Threadsafety-Garantie, wie sie im Allgemeinen auch in der C++-Standardbibliothek gilt.

Value-Semantik

Ein weiterer Vorteil ist, dass es noch nicht einmal notwendig ist, selbst einen Copy-Konstruktor von MeasurementConfig zu implementieren! Der Copy-Konstruktor des Compilers tut genau das Richtige. Er kopiert nämlich den Vektor der Kanäle. Und das auch noch relativ billig. Es ist nur eine Speicherreservierung notwendig, nämlich um für den std::vector zu kopieren. Die Kanäle selbst werden nur flach kopiert (Copy-On-Write). Um das Ganze auf die Spitze zu treiben, könnte man nun noch den std::vector selbst in einen cow_ptr kapseln. Damit hätte man dann den Typ

cow_ptr<std::vector<cow_ptr<Channel>>>

und vermeidet beim Kopieren einer MeasurementConfig jede Speicherreservierung. Erst wenn man eine MeasurementConfig verändert, werden die internen Daten kopiert, falls mehrere Referenzen darauf existieren. Das ist für unseren Anwendungsfall ideal, weil Veränderungen an einer MeasurementConfig relativ selten sind. Aber es ist sehr hilfreich, seine Daten mit Kopien der zugehörigen MeasurementConfig auszustatten, da diese ja nun dank Copy-On-Write billig sind.

In meinem konkreten Anwendungsfall habe ich die Daten blockweise vom Messgerät erhalten und ebenso blockweise verarbeitet. Pro Sekunde konnten möglicherweise tausende Blöcke generiert werden. Glücklicherweise war es nun möglich, jeden einzelnen Block mit einer MeasurementConfig auszustatten. Nach außen hin sind es natürlich Kopien ein und derselben komplexen MeasurementConfig-Struktur. Unter der Haube handelt es sich jedoch lediglich um Smart-Pointer die im ganzen Programm herumgereicht werden. Und das über Threadgrenzen hinweg auf eine sichere Art und Weise!

Das alles funktioniert, weil ein cow_ptr<T> echte Value-Semantik des Typs T erhält. D. h. nach außen hin sieht alles so aus, als würde beim Kopieren eines cow_ptr<T> das unterliegende T-Objekt mitgeklont. 

Vergleich zu std::shared_ptr<T>

Wie schon angesprochen sind cow_ptr<T> und std::shared_ptr<const T> sehr ähnlich. Nur dass ein cow_ptr<T> seine internen Daten verändern kann und garantiert nicht durch einen anderen cow_ptr<T> verändert werden kann, solange man nicht irgendwie eine nichtkonstante Referenz auf das enthaltene T-Objekt herausreicht oder auf üble Weise const wegcastet. Bei einem std::shared_ptr<const T> ist es leicht möglich, dass es sich um eine Kopie eines std::shared_ptr<T> handelt, durch den das interne T-Objekt unmerklich noch verändert werden kann. Natürlich kann man das im Einzelfall ebenfalls ausschließen, solange die Programmstruktur nicht allzu komplex ist.

Der Hauptunterschied liegt darin, dass sich cow_ptr<T> semantisch so verhalten wie ein T-Wert. Ein std::shared_ptr<T> verhält sich semantisch so wie ein echter T-Pointer. Abb.3 veranschaulicht das.

Unter der Haube sehen zwei cow_ptr<T>-Objekte, die auf ein gemeinsames T-Objekt zeigen, zwei std::shared_ptr<T>-Objekten, die ebenfalls auf ein gemeinsames T-Objekt zeigen, sehr ähnlich. Bei einem Schreibvorgang auf das Objekt, auf das der cow_ptr<T> zeigt, wird jedoch zunächst eine Kopie erstellt. Dadurch wird immer auf getrennte Objekte geschrieben. Zeigen zwei std::shared_ptr<T> auf dasselbe Objekt, so sind Schreibvorgänge durch alle std::shared_ptr<T> auf dieses Objekt "global" sichtbar. Dadurch entsteht zwischen zwei Kopien ein und desselben shared_ptr<T> eine starke Kopplung.
Eine weitere interessante Beobachtung ist, dass cow_ptr<T> keine zyklischen Referenzen bilden, sofern man sich an die Regel hält, dass man keine Pointer oder Referenzen auf das enthaltene T-Objekt heraus gibt und sich an einfache Regeln der Const Correctness hält. Das will ich jetzt an einem Beispiel begründen: Nehmen wir einmal an, wir haben eine Struktur, die einen cow_ptr<T> auf sich selbst enthalten soll, etwa

struct X { cow_ptr<X> p; };

Hat man nun einen cow_ptr<X> und möchte die innere Zuweisung durchführen, dann würde man das wohl ungefähr so versuchen:

cow_ptr<X> p = make_cow<X>();
p.modify( [q=p]( X * p ){ p->p = q; } );

Das funktioniert jedoch nicht, denn bevor die modify()-Funktion aufgerufen wird, wird bereits eine Kopie namens q von p innerhalb des Lambdas erstellt. Dadurch führt modify() zunächst eine tiefe Kopie. Zur Ausführungszeit des Lambdas zeigen also p und q bereits auf verschiedene Objekte und es entsteht keine zyklische Referenz. Daher sollte man innerhalb des Lambdas nicht versuchen, auf p per Referenz zuzugreifen, denn dann können zyklische Referenzen entstehen. In der Praxis ist diese Regel sehr leicht einzuhalten.

Bei shared_ptr<T> kann es deutlich leichter passieren, dass unbeabsichtigte zyklische Referenzen entstehen, die dann zu Memory-Leaks führen. Beim cow_ptr<T> braucht man sich darum normalerweise keine Sorgen zu machen. 

Objektbäume

Man kann die Sache mit Copy-On-Write auch noch etwas weiter treiben, nämlich indem man komplexe Datenstrukturen in verschiedenen Ebenen in cow_ptr<T> kapselt. Dies wird in Abb.4 veranschaulicht.

In unserem konkreten Fall enthält die MeasurementConfig verschiedene Kanalkonfigurationen, die in cow_ptr<T>-Objekten gekapselt sind. Diese Kanalkonfigurationen können wiederum ihre Daten in cow_ptr<T>-Objekte kapseln. Auf diese Weise können ganze Datenhierarchien aufgebaut und billig kopiert werden. Selbst eine tiefe Kopie auf einer Ebene ist nun relativ billig, weil die darunterliegenden Ebenen mit Copy-On-Write-Semantik versehen sind.

Dieses Vorgehen ermöglicht es beispielsweise, einer Anwendung Undo-/Redo-Operationen anzubieten, da die umfangreichen Konfigurationen zu verschiedenen Zeitpunkten ohne großen Speicheraufwand kopiert und angepasst werden können. Intern teilen sich diese Konfigurationen nämlich den Großteil ihres Speichers. Nur die Stellen, wo sie sich unterscheiden, werden doppelt im Speicher gehalten.

Ein klassischer Fall für Copy-On-Write

Natürlich kann die cow_ptr<T>-Templateklasse auch für klassisches Copy-On-Write, beispielsweise bei großen Datenstrukturen, wie Bildern oder Matrizen angewendet werden. Hier ein Beispiel einer einfachen Matrix-Klasse:

template <typename T> 
class Matrix {
public:
        ⁞
private:
        size_t width = 0, height = 0;
        cow_ptr<std::vector<T>> data;
};

Hat man nun drei Matrix-Objekte

Matrix<float> a, b, c;

dann könnte die Berechnung von

Matrix<float> x = a * b * c;

durch Expression-Templates erfolgen. Der Ausdruck a*b*c ist also ein Expression-Template und wird erst ausgewertet, wenn er einer Matrix <float> zugewiesen wird ("lazy evaluation"). Die Expression-Template-Struktur kann dabei Kopien von a, b und c enthalten, da diese ja dank Copy-On-Write billig sind.

Andere Implementierungen, die kein Copy-On-Write benutzen, führen entweder teure Kopien aus oder benutzen Referenz-Semantik, was manchmal zu Überraschungen führen kann, weil sich plötzlich Ausdrücke verändern, von denen man es gar nicht erwartet. 

Pimpln mit cow_ptr<T>

Weiterhin ist cow_ptr<T> hervorragend als Pimpl-Pointer für komplexe Klassen mit Value-Semantik geeignet. Das Pimpl-Pattern wird auch manchmal als Opaque Pointer Idiom oder als Compile-Time Firewall bezeichnet. Die Idee ist, dass man die privaten Daten einer Klasse X hinter einem Pointer versteckt, der auf eine deklarierte aber nicht definierte Datenstruktur zeigt. Dadurch vermeidet man, dass in dem Headerfile, in dem die Klasse X definiert wird, nicht etliche andere Headerfiles eingebunden werden müssen, in denen die Definitionen stehen, die für die privaten Datenmember von X gebraucht würden [3].

In unserem Fall könnte eine Implementierung der MeasurementConfig-Klasse beispielsweise so aussehen:

// measurement_config.hpp
#include <cpp_utils/cow_ptr.hpp>
class Channel; // forward declaration
class SiUnit;  // forward declaration
class MeasurementConfig {
public:
    cow_ptr<Channel> getChannel( size_t index ) const;
    void setChannel( size_t index, cow_ptr<Channel> channel );
    SiUnit getChannelUnit( size_t index ) const;
    ⁞
private:
  struct Impl; // Forward declaration. Definition in cpp file.
  cow_ptr<Impl> impl;
};
 
// measurement_config.cpp
#include <measurement_config.hpp>
#include <channel.hpp>
#include <si_unit.hpp>
struct MeasurementConfig::Impl
{
    std::vector<cow_ptr<Channel>> channels;
    ⁞
};
  ⁞

Alle privaten Mitglieder der MeasurementConfig-Klasse werden einfach in die MeasurementConfig::Impl-Struktur verschoben. Wenn man nach dieser Änderung die Datei measurement_config.hpp einbindet, dann bindet man nicht automatisch auch noch die channel.hpp und si_unit.hpp ein. Dadurch werden Abhängigkeiten reduziert.

Natürlich kann man diese Technik auch mit std::unique_ptr<Impl> oder std::shared_ptr<Impl> durchführen. In beiden Fällen muss man jedoch mindestens den Copy-Konstruktor und den Copy-Assignment-Operator selbst implementieren. Beim std::unique_ptr<Impl> meckert der Compiler, wenn man versuchen würde zu kopieren, ohne den Copy-Konstruktor selbst zu implementieren. Beim std::shared_ptr<Impl> ist die Situation schlimmer: Der std::shared_ptr<Impl> wird vom compilergenerierten Copy-Konstruktor kopiert. Die kopierte Struktur zeigt also nun für immer auf dieselben internen Daten! Wird also eine der beiden MeasurementConfigs verändert, so sind diese Änderungen bei der anderen MeasurementConfig sichtbar.

Im Fall von cow_ptr<Impl> tut der compilergenerierte Copy-Konstruktor genau das, was er soll, allerdings mit dem zusätzlichen Vorteil billiger Kopien (solange nicht jede Kopie modifiziert wird).

Es muss auch kein leerer Destruktor implementiert werden, wie es der Fall ist, wenn man std::unique_ptr<Impl> gebraucht. Versucht man nämlich, std::unique_ptr<Impl> zu benutzen und den Destruktor vom Compiler zu generieren, dann beschwert sich der Compiler, denn der automatisch generierte Destruktor ist "inline" und daher muss std::unique_ptr<Impl> zerstört werden können. Dies ist jedoch nicht möglich, weil der Destruktor von Impl zu dieser Zeit gar nicht sichtbar ist. Definiert man den Destruktor der Klasse MeasurementConfig im cpp-File, dann ist der Destruktor von MeasurementConfig::Impl sichtbar, sofern die MeasurementConfig::Impl-Klasse weiter oben im Sourcecode definiert ist.

Der Destruktor von cow_ptr<Impl> hingegen braucht den Destruktor von Impl nicht zu sehen. Das hängt damit zusammen, dass der Deleter im Konstruktor des cow_ptr<Impl> übergeben wird und dieser wird dann im Destruktor dynamisch aufgerufen. Daher braucht nur der Konstruktor oder die verwendete make_cow()-Funktion die Definition von Impl verfügbar zu haben. 

Zusammenfassung

Oft kommt es vor, dass man Daten in verschiedenen Teilen eines Programms braucht. Der erste Reflex eines Softwareentwicklers ist dann meist, dass er Pointer oder Referenzen auf diese Daten an die verschiedenen Softwarekomponenten verteilt, die diesen Zugriff benötigen. Dieser Ansatz ist normal und legitim, aber er bringt auch negative Effekte mit sich, insbesondere dann, wenn die Daten im Laufe der Zeit verändert werden:

  • Alle Softwarekomponenten, die eine Referenz (oder einen Pointer) auf die Daten besitzen, sind indirekt voneinander abhängig.
  • Wenn die Daten beschrieben werden, dann müssen die verschiedenen Programmkomponenten oft darüber informiert werden, um selbst in einem konsistenten Zustand zu bleiben.
  • Wenn viele Softwarekomponenten Lese- und Schreibrechte auf dieselben Daten haben, dann wird es oft schwierig, sichere Schlussfolgerungen über ihr Verhalten und ihren gegenseitigen Einfluss zu ziehen.
  • Wenn die Softwarekomponenten auf verschiedene Threads verteilt sind, dann muss sichergestellt sein, dass der Zugriff auf die Daten threadsafe ist. Oft ist das unpraktikabel und man ist gezwungen, alle betroffenen Softwarekomponenten im selben Thread zu halten.
  • Die Softwarekomponenten können nicht mehr so einfach getestet werden.

Mit anderen Worten: Die Software wird komplizierter und schwieriger wartbar. Aus diesen und anderen Gründen versucht man häufig solche Designs zu vermeiden.
Die genannten Probleme können vermieden werden (oder zumindest abgemildert werden), indem man die Daten kopiert, statt Referenzen auf sie zu verteilen. Tiefe Kopien sind jedoch oft zu teuer. Dort kommt der Copy-On-Write ins Spiel, der diese Probleme bezwingt. cow_ptr<T> bietet eine generische Implementierung von Copy-On-Write und bringt folgende Vorteile mit sich

  • Er spart Speicherplatz.
  • Er spart Kopieroperationen.
  • Er verringert Abhängigkeiten.
  • Er ist geeignet für Multithreading.
  • Klonen leicht gemacht. Container müssen nicht elementeweise geklont werden, stattdessen reicht eine einfache Kopie des Containers.
  • Er ist hervorragend als Pimpl-Pointer für Value-Klassen geeignet.

Eine Implementierung von cow_ptr<T> findet man in meinem GitHub-Repository unter einer offenen Open Source-Lizenz [1].

Autor

Ralph Tandetzky

Ralph Tandetzky hat professionelle Erfahrung in den Bereichen Bildverarbeitung, Mustererkennung, physikalische Simulationen, Optik und arbeitet im Bereich Fahrassistenzsysteme.
>> Weiterlesen
Kommentare (0)

Neuen Kommentar schreiben