Du bist nicht angemeldet.

Stilllegung des Forums
Das Forum wurde am 05.06.2023 nach über 20 Jahren stillgelegt (weitere Informationen und ein kleiner Rückblick).
Registrierungen, Anmeldungen und Postings sind nicht mehr möglich. Öffentliche Inhalte sind weiterhin zugänglich.
Das Team von spieleprogrammierer.de bedankt sich bei der Community für die vielen schönen Jahre.
Wenn du eine deutschsprachige Spieleentwickler-Community suchst, schau doch mal im Discord und auf ZFX vorbei!

Werbeanzeige

1

04.12.2014, 13:38

C++11, Smartpointer, Unique Ownership .. und dann Non-Owning Pointer?

Hi, zur Unterscheidung zwischen owning- und non-owning Pointern bietet C++11 ja das Konzept um std::shared_ptr und std::weak_ptr. Möchte man allerdings unique ownership semantisch ausdrücken, bleibt gemäß Herb Sutter's Guidelines:

Zitat

Smart pointers: No delete
Always use the standard smart pointers, and non-owning raw pointers. Never use owning raw pointers and delete, except in rare cases when implementing your own low-level data structure (and even then keep that well encapsulated inside a class boundary).

nichts anderes übrig als std::unique_ptr und non-owning raw pointers als "weak_ptr-Ersatz" zu nehmen.

Damit bleibt allerdings der Vorteil von std::weak_ptr auf der Strecke: bool expired() const;. Die andere Möglichkeit wäre in der eigenen Programmlogik einzubauen, dass, wenn ein std::unique_ptr bewegt oder zerstöt wird, alle rohen non-owning pointer, die auf ihn zeigen, entsprechend resettet werden (z.B. auf nullptr). Das ganze halt ich für ziemlich aufwändig - daher habe ich mir überlegt, wie man das "wegkapseln" könnte.

Meine erste Idee: Eine Klasse dynamic_ptr schreiben und meine std::unique_ptr mit einem Customized Deleter ausstatten. Weiterhin die dynamic_ptr mit Referenzen auf die std::unique_ptr initialisieren und vom Deleter das Resetten auslösen lassen. Das klappte super - nur leider nicht mit move-Semantik auf den "beobachteten Pointern" (sprich: std::unique_ptr).

Der neue Ansatz: Damit der beobachtete Zeiger auch im Move-Fall seine Beobachter informieren kann, habe ich einen sole_ptr implementiert, der std::unique_ptr entsprechend erweitert. Im Moment habe ich noch nicht alle Operatoren des Originals gewrappt - aber den Teil, der mir bisher primär erschien. Das Ergebnis findet ihr hier:

Feedback erwünscht!

Jaja, man soll das Rad nicht neu erfinden - aber ich habe nichts gefunden was meinen Voraussetzungen entspricht - da blieb mir nur der "Ausweg" selbst implementieren. Dabei habe ich einige Testcases geschrieben und lasse sie mit assert automatisiert durchlaufen (Link

Zum Abschluss noch etwas Code, wie die beiden Zeigertypen verwenden werden können:

C-/C++-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
 // [...] Rest siehe Testcase-Code

struct Foo {
    int id;
    std::string name;

    Foo(int id, std::string const & name)
        : id{id}
        , name{name} {
        std::cout << "+" << this << "\n";
    }
    virtual ~Foo() {
        std::cout << "-" << this << "\n";
    }
};

int main() {
    // create sole ownership
    auto owner = make_sole<Foo>(42, "Anonymous");

    // create non-owning pointer
    dynamic_ptr<Foo> user{owner};

    // operate on `user`
    user->id++;
    user->name += std::to_string(user->id);
    if (!user.expired()) {
        std::cout << "Foo[" << user->id << "," << user->name << "]\n";
    } else {
        // this cannot happen in this case, because user didn't expire, yet
        std::cout << "Foo expired (unexpected!!)\n";
        return 1;
    }

    // move ownership
    auto other = std::move(owner);
    std::cout << "After moving ownership, non-owning expired() is " << user.expired() << "\n";
    
    // end ownership
    other = nullptr;
    std::cout << "After ownership ended, non-owning expired() is " << user.expired() << "\n";

}


Was haltet ihr davon? Überflüssig? Möglicherweise hilfreich? Schlecht implementiert? 8) Feedback wie gesagt gern gesehen :this:

LG Glocke

Databyte

Alter Hase

Beiträge: 1 040

Wohnort: Na zu Hause

Beruf: Student (KIT)

  • Private Nachricht senden

2

04.12.2014, 14:27

Ich sehe noch nicht ganz das Problem, was du versuchst zu lösen... Es gibt halt die drei Möglichkeiten die er beschreibt:

1. shared_ptr und weak_ptr: Benutze shared_ptr um auf ein Objekt zu zeigen, von dem du nicht weißt wer noch alles auf dieses zeigt (weak_ptr um Zyklen zu brechen)
2. unique_ptr: Hier gibt es einen eindeutigen Besitzer, der das Objekt erzeugt und nicht teilen möchte (jedenfalls mit nichts außerhalb des Objektes selber)!
Eine unique ownership mit einer Benutzung von expired zu kombinieren ergibt keinen Sinn (Jedenfalls sehe ich keinen), da du dann ja shared_ptr verwenden sollst, da du das Objekt ja offensichtlich geteilt hast.
3. non-owning Raw-Pointer: Die sollst du nur benutzen, wenn du keine Verantwortung für das Objekt hast - es also nie löschen willst. In den anderen Beiden Fällen sind die Pointer fürs Aufräumen zuständig. Im ersten Fall räumt der Letzte Pointer auf und im zweiten Fall räumt der eine Besitzer auf. raw pointer also nur, wenn du weißt, dass das Objekt auf das du zeigst länger Lebt als du selbst!

Du musst dir also überlegen, ob du in deinem Fall oben wirklich einen einzigartigen Besitzer hast. Im anderen Fall nimmst du einfach einen shared_ptr.

3

04.12.2014, 14:57

Eine unique ownership mit einer Benutzung von expired zu kombinieren ergibt keinen Sinn (Jedenfalls sehe ich keinen), da du dann ja shared_ptr verwenden sollst, da du das Objekt ja offensichtlich geteilt hast.

Die Frage ist nicht ob der Zeiger, der die Ressource besitzt ("owning"), expired ist, sondern der Zeiger, der die Ressource lediglich verwendet, aber nicht besitz (non-owning).
Unique pointer beschreiben, dass es für die Ressource genau einen Besitzer gibt. Daher teile ich nicht die Ownership, sondern den Zugriff.

Ich möchte diese explizit eindeutige Ownership nicht teilen, d.h. shared_ptr ist für mich nicht relevant. Will ich den Zugriff (non-owning!) auf die Ressource meines Unique Pointers teilen (nicht den Besitz selbst), müsste ich normalerweise entweder einen Raw Pointer oder eine Reference nehmen. Da References nicht wie Zeiger "geändert" werden können (ich meine nicht den Inhalt sondern die Referenz selber), ich das aber möchte, fallen Referenzen schon einmal weg. Übrig bleibt der Raw Pointer.

"Überlebt" der Raw Pointer nun den Unique Pointer (d.h. der Unique Pointer lässt seine Ressource frei, aber der Raw Pointer zeigt noch auf die alte Speicheradresse), so muss ich in meiner Programmlogik alle relevanten Raw Pointer explizit modifizieren (z.B. auf nullptr setzen), um ungültiges Dereferenzieren zu verhindern. Dieses "benachrichten" der Raw Pointer beabsichtige ich zu automatisieren (= das ist das eigentliche Problem).

Sorry, wenn ich mich unverständlich ausgedrückt habe :)

LG Glocke

/EDIT:
raw pointer also nur, wenn du weißt, dass das Objekt auf das du zeigst länger Lebt als du selbst

Genau die Einschränkung will ich loswerden :D

Databyte

Alter Hase

Beiträge: 1 040

Wohnort: Na zu Hause

Beruf: Student (KIT)

  • Private Nachricht senden

4

04.12.2014, 16:46

Wenn ich das richtig verstehe, willst du einen shared_ptr, den man nicht kopieren kann? In dem Fall lass einfach Niemanden an den shared_ptr rankommen.
In wirklichkeit kannst du ja immer nur einen Zugang zu einem Objekt besitzen. Das sagen ja shared_ptr und unique_ptr aus. Dazu kommt dann noch die Frage wer aufräumt.
Theoretisch kannst du ja alle unique_ptr durch shared_ptr ersetzen (nur verlierst du dann die semantische Aussage).

PS: Wenn du deinen dynamic_ptr/sole_ptr in einer Multithread-Umgebung einsetzt, kommst du in Teufels Küche ;)

Dieser Beitrag wurde bereits 1 mal editiert, zuletzt von »Databyte« (04.12.2014, 16:52)


Evrey

Treue Seele

Beiträge: 245

Beruf: Weltherrscher

  • Private Nachricht senden

5

04.12.2014, 17:03

Ich sehe den Nutzen und habe nicht selten selbst über sowas nachgedacht, namentlich owned_ptr und borrowed_ptr. Das Problem ist nun allerdings, wie man per borrowed_ptr auf das "geliehene Objekt" zugreifen will. Es muss sichergestellt werden, dass man nicht über die Lebensspanne des owned_ptrs hinaus auf das Objekt zugreifen kann. Da gäbe es nun mehrere Möglichkeiten:
  • Jede Dereferenzierung muss eine Exception werfen, wenn der Besitzer wegstirbt.

    C-/C++-Quelltext

    1
    2
    3
    4
    5
    6
    
    borrowed_ptr<int> b;
    { // scope for the owner
      owned_ptr<int> o = make_owned(0xDEADBEEF);
      b = o;
    } // owner dies, b holds a reference.
    int& x = *b; // throws


  • Analog zum weak_ptr kann der borrowed_ptr nicht direkt aufs Objekt zugreifen, sondern muss einen temporären Pointer erzeugen, der das kann, z.B. usable_borrowed_ptr. Offensichtlicher Weise kann man nicht einfach einen owned_ptr erzeugen, da wir ja nur einen Besitzer wollen. Weiterhin sollte usable_borrowed_ptr kein stumpfer Raw Pointer sein, da man ihn nicht deleten soll. (Andernfalls wäre die gesamte Überlegung hier überflüssig.)

    C-/C++-Quelltext

    1
    2
    3
    4
    5
    6
    7
    
    borrowed_ptr<int> b = o;
    do_some_stuff();
    // Now use the borrowed pointer here.
    auto p = b.lock(); // Throws if owned object died.
    // Use p without failure here:
    *p += 42;
    *p %= 7;


Aufgrund der Analogität zu shared_ptr und weak_ptr wäre der zweite Ansatz wahrscheinlich zu bevorzugen. Allerdings eröffnet sich hier ein großes Problem, das der erste Ansatz nicht hat: Was, wenn das fragliche Objekt zwischenzeitlich stirbt, während man den geliehenen Pointer verwendet? Oder in Code:

C-/C++-Quelltext

1
2
3
4
5
6
7
owned_ptr<int> X = make_owned(42);
auto t1 = std::thread(
  [](borrowed_ptr<int> x) {auto y = x.lock(); do_quite_a_lot_of_things_with(y);}
, X
);
auto t2 = std::thread([&X](){wait_a_bit(); X = nullptr;});
t1.join(); t2.join();
(Code kann Flüchtigkeitsfehler enthalten.)
In dem Fall müsste ein aktiver usable_borrowed_ptr verhindern, dass das fragliche Objekt in Thread 2 gelöscht wird. Also haben wir doch wieder Refcount-Spaß mit Atomics, also effektiv shared_ptr. Der Unterschied zum shared_ptr wäre bloß, dass determiniert ist, wer als Letzter das Objekt löscht.

Diese Problematik existiert im ersten Ansatz nicht, dafür jedoch ist _jede_ Verwendung eines borrowed_ptrs eine Überraschung wert, denn jede Verwendung kann plötzlich eine Exception verursachen. Das kann ziemlich böse Denkfehler verursachen.

Bei beiden Ansätzen muss man auch wie mit shared_ptr und weak_ptr aufpassen, dass die Smart Pointer nicht wegsterben, während man Raw Pointer an z.B. C-Funktionen übergibt.

Tadaaa, jetzt haben wir zwei Ansätze, die beide ihre Vor- und Nachteile haben. Bisher konnte ich mich noch für keinen entscheiden, weshalb ich das noch nicht umgesetzt habe, aber vielleicht fällt jemandem was ein. Auf dem ersten Blick dürfte der zweite Ansatz vor allem bei Multithreading robuster sein.

C-/C++-Quelltext

1
2
3
4
int main(int _argc, char** _argv) noexcept {
  asm volatile("lock cmpxchg8b %eax");
  return 0;
} // ::main
(Dieses kleine Biest vermochte einst x86-Prozessoren lahm zu legen.)

=> Und er blogt unter Hackish.Codes D:

Beiträge: 1 223

Wohnort: Deutschland Bayern

Beruf: Schüler

  • Private Nachricht senden

6

04.12.2014, 17:09

Zitat von »Glocke«

non-owning raw pointers als "weak_ptr-Ersatz" zu nehmen.

Eigentlich ist kein "Ersatz" sondern genau das passende Gegenstück zu "std::unique_ptr". Zufälligerweise gibt es dass halt schon seit der C-Steinzeit.

Zitat von »Glocke«

Damit bleibt allerdings der Vorteil von std::weak_ptr auf der Strecke: bool expired() const;

Warum es das nicht gibt, lässt sich einfach erklären: Um diese Funktionalität zu Implementieren ist einige Zusatzlogik mit Laufzeit Overhead und zusätzlichen potentiell sehr hohen Speicherverbrauch notwendig. Woher weiß ein Pointer, dass sein Ziel zerstört ist? Beim "shares_pointer" wird es so gelöst, dass ein zusätzliches Objekt allokiert ist, dass nur speichert ob es dass Objekt noch gibt. Dieses Zusatzobjekt wird mit Referenzzählung verwaltet und kann erst freigeben werden, wenn auch alle "std::weak_ptr" freigeben bzw. zurückgesetzt wurde.

Zu dem Problem gab es kürzlich einen Thread auf Zfx. Dort wurden diverse Lösungsmöglichkeiten vorgeschlagen(, die leider alle mit Nachteilen verbunden sind), der Fragesteller entschloss sich am Ende einfach immer "std::shared_ptr" einzusetzen. Das ist eine einfache Lösung, ich bin aber nicht überzeugt das es eine Gute ist.
http://zfx.info/viewtopic.php?f=7&t=3605

Deine Methode wird dort auch diskutiert. Der Nachteil bei dieser Variante ist natürlich der Overhead durch den "std::vector" und den Doppel-Pointer.

"insert", "replace" und "remove" würde außerdem nicht Teil der öffentlichen Schnittstelle machen.
Das hat den Vorteil, dass es dadurch leichter möglich wird, in Zukunft den Ansatz zu ändern. Wenn du zum Beispiel doch den "std::weak_ptr" Ansatz gehen willst, kannst du das noch problemlos umstellen. Außerdem möchte ich darauf Hinweisen, dass es Probleme mit Exceptions geben könnte. Ein Destruktor darf keine Ausnahme werfen, "remove" kann das wegen "pop_back" aber theoretisch tun. (Sollte sich mit einem Trick vlt. beheben lassen.)
Außerdem solltest du unbedingt "virtual" vom Destruktor entfernen. Ich sehe keinen Sinn darin, außer das es einfach Ineffizient ist. Das Verdoppelt die Größe der Klasse im Prinzip sinnlos.

Multithreading ist in der Tat ein sehr großes Problem bei deiner Implementierung.
Das Thread Safe zu machen klingt für mich auf den ersten Blick nach einer sehr schweren Aufgabe bzw. einen Mutex.

7

04.12.2014, 17:24

@Databyte: Ja. Thread-Safety würde ich hier aufgrund der Komplextität mal explizit ausklammern wollen.

Warum es das nicht gibt, lässt sich einfach erklären: Um diese Funktionalität zu Implementieren ist einige Zusatzlogik mit Laufzeit Overhead und zusätzlichen potentiell sehr hohen Speicherverbrauch notwendig.


Overhead: ja, ok.
Sehr hoher Speicherverbrauch? Warum? Weil sich der Owning-Pointer seine Non-Owning-Gegenstücke merkt und umgekehrt?

[...] entschloss sich am Ende einfach immer "std::shared_ptr" einzusetzen. Das ist eine einfache Lösung, ich bin aber nicht überzeugt das es eine Gute ist.

Danke für den Link. Aber eben zum shared_ptr wechseln möchte ich nicht. Mir ist die unique ownership Semantik sehr wichtig. Und ich sehe keinen Grund wofür ich das interne Reference Couting bräuchte. Aus meiner Sicht fehlt halt zwischen unique_ptr und shared_ptr ein Hybrid mit unique ownership und shared access.

Deine Methode wird dort auch diskutiert. Der Nachteil bei dieser Variante ist natürlich der Overhead durch den "std::vector" und den Doppel-Pointer.

Ob ich mir (in der Programmlogik) merke welche non-owning raw pointer zu welchen unique_ptr gehören und diese explizit benachrichtige - oder ob ich das von einigen Smartpointern automatisieren lasse, sollte nichts am Overhead ändern. Dass er da ist, ist unstrittig. Ich denke aber, dass das "explizite benachrichtigen" ist der kritische Punkt und analog zu Raw-Pointer-vs-Smart-Pointer: Ich kann Speicher manuell freigeben - oder dies vergessen / daran durch Exceptions gehindert werden. Genauso kann ich die beobachtenden Pointer manuell benachrichtigen - oder dies vergessen / daran durch Exceptions gehindert werden.
Die Speicherverwaltungsprobleme wurden durch die bisherigen Smartpointer eingedämmt - was gut ist. Nur bleiben halt noch offene Fragen/Probleme :)

"insert", "replace" und "remove" würde außerdem nicht Teil der öffentlichen Schnittstelle machen.

Richtig, das war meiner Faulheit geschuldet. Besser private und den Gegenpart als friend class..

Außerdem möchte ich darauf Hinweisen, dass es Probleme mit Exceptions geben könnte. Ein Destruktor darf keine Ausnahme werfen, "remove" kann das wegen "pop_back" aber theoretisch tun.

Danke für den Hinweis :)

Außerdem solltest du unbedingt "virtual" vom Destruktor entfernen. Ich sehe keinen Sinn darin, außer das es einfach Ineffizient ist. Das Verdoppelt die Größe der Klasse im Prinzip sinnlos.

Gewohnheit .. ^^

Multithreading ist in der Tat ein sehr großes Problem bei deiner Implementierung.
Das Thread Safe zu machen klingt für mich auf den ersten Blick nach einer sehr schweren Aufgabe bzw. einen Mutex.

Absolut richtig - und auch absichtlich vernachlässigt. Dinge thread-safe zu machen ist komplex - und mir geht es erstmal um's Prinzip. Vllt. wird die Implementierung später ja noch thread-safe (oder bekommt ein thread-safes Pendant - ist ja immerhin zusätzlicher Overhead).

LG Glocke

Evrey

Treue Seele

Beiträge: 245

Beruf: Weltherrscher

  • Private Nachricht senden

8

04.12.2014, 17:34

Den vector halte ich für überflüssig. Es genügt, zu prüfen, ob ein borrowed_ptr noch gültig ist, oder eben nicht. Ref Counts sind da wesentlich kompakter. Der Overhead dadurch wäre lediglich sizeof(T*)+2*sizeof(std::atomic<size_t>), was im Idealfall 3*sizeof(void*) ist. Der vector ist in den meisten Implementierungen ebenfalls 3*sizeof(void*) groß, allokiert jedoch zusätzlich irgendwo irgendwelche Arrays und bietet unsäglich mehr Quellen für Exceptions. Weiterhin braucht der Destruktor O(n) für n borrowed_ptr, um diese per vector zu leeren, im Gegensatz zu O(1) mit dem weak_ptr-Design. Allerdings blockiert der Destruktor, solange usable_borrowed_ptrs aktiv sind. Das sollte allerdings nicht lange dauern.

C-/C++-Quelltext

1
2
3
4
int main(int _argc, char** _argv) noexcept {
  asm volatile("lock cmpxchg8b %eax");
  return 0;
} // ::main
(Dieses kleine Biest vermochte einst x86-Prozessoren lahm zu legen.)

=> Und er blogt unter Hackish.Codes D:

9

04.12.2014, 17:46

Den vector halte ich für überflüssig. Es genügt, zu prüfen, ob ein borrowed_ptr noch gültig ist, oder eben nicht.

Wenn der borrowed_ptr aber nach dem move des owned_ptr noch aktuell sein soll, ist er aber notwendig - zumindest für das oben beschriebene Design. Wenn ich RefCount verwenden wollen würde, bestünde mein Problem gar nicht :D Aber wie gesagt: unique ownership ist mir grundlegendst an dieser Stelle.

Evrey

Treue Seele

Beiträge: 245

Beruf: Weltherrscher

  • Private Nachricht senden

10

04.12.2014, 17:57

Nein, ein move würde die borrowed_ptr in meinem Design nicht ungültig machen! Der Grund lässt sich mit folgendem simplifizierten Code verdeutlichen:

C-/C++-Quelltext

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>struct ref_data {
  T* obj_ptr;
  std::atomic<size_t> borrowed;
  std::atomic<size_t> used;
};

owned_ptr::owned_ptr(owned_ptr&& p) noexcept : obj_ptr_{nullptr}, ref_data_{nullptr} {
  std::swap(obj_ptr_,  p.obj_ptr_ );
  std::swap(ref_data_, p.ref_data_);
}

borrowed_ptr::borrowed_ptr(const owned_ptr& o) noexcept : ref_data_{o.ref_data_} {}


Stirbt ein owned_ptr, oder wird er zurückgesetzt, wird geprüft, ob ref_data_->used Null ist. Falls nicht, blockiert der Destruktor, bis dem so ist. Danach wird ref_data_->obj_ptr auf nullptr gesetzt und das Objekt dahinter gelöscht. ref_data_ wird vom letzten Smart Pointer gelöscht, der es benutzt. Das kann auch ein borrowed_ptr oder ein usable_borrowed_ptr sein, denn damit stellen sie ja fest, ob das Objekt noch gültig ist, auf das sie verweisen.

Du kommst nicht um Ref Counts herum, wenn du ein robustes Design willst, das möglichst mit O(1) arbeitet.

C-/C++-Quelltext

1
2
3
4
int main(int _argc, char** _argv) noexcept {
  asm volatile("lock cmpxchg8b %eax");
  return 0;
} // ::main
(Dieses kleine Biest vermochte einst x86-Prozessoren lahm zu legen.)

=> Und er blogt unter Hackish.Codes D:

Werbeanzeige