Warum werden wertsteigernde Setter-Member-Funktionen in Herb Sutters CppCon 2014-Vortrag nicht empfohlen (Zurück zu den Grundlagen: Moderner C ++ - Stil)?

70

In Herb Sutters CppCon 2014-Vortrag Zurück zu den Grundlagen: Moderner C ++ - Stil verweist er auf Folie 28 ( eine Webkopie der Folien finden Sie hier ) auf dieses Muster:

class employee {
  std::string name_;
public:
  void set_name(std::string name) noexcept { name_ = std::move(name); }
};

Er sagt, dass dies problematisch ist, weil beim Aufrufen von set_name () mit einer temporären Noexcept-Ness nicht stark ist (er verwendet den Ausdruck "Noexcept-ish").

Jetzt habe ich das obige Muster in meinem aktuellen C ++ - Code ziemlich häufig verwendet, hauptsächlich, weil ich dadurch nicht jedes Mal zwei Kopien von set_name () eingeben muss - ja, ich weiß, dass dies ein bisschen ineffizient sein kann, wenn ich jedes Mal eine Kopienkonstruktion erzwinge. aber hey ich bin ein fauler typer. Herbs Satz "Dieses Noexcept ist problematisch " macht mir jedoch Sorgen, da ich das Problem hier nicht verstehe: Der Verschiebungszuweisungsoperator von std :: string ist noexcept, ebenso wie sein Destruktor. Daher scheint mir set_name () oben noexcept garantiert zu sein. Ich sehe eine mögliche Ausnahme, die der Compiler vor set_name () auslöst, wenn er den Parameter vorbereitet, aber ich habe Mühe, dies als problematisch anzusehen.

Später auf Folie 32 stellt Herb klar fest, dass das Obige ein Anti-Muster ist. Kann mir jemand erklären, warum ich nicht faul schlechten Code geschrieben habe?

Niall Douglas
quelle
5
Wenn ich mich richtig erinnere, sagt Herb, dass dies noexcepthier ein Mythos ist, weil die Zuordnung, die auf der Seite des Angerufenen geschieht, werfen kann (z. B. beim Erstellen eines std::stringaus rohen Zeichenfolgenliteral oder anderen std::string). Der Körper wirft also nicht, aber das Aufrufen dieser Funktion kann immer noch dazu führen, dass eine Ausnahme ausgelöst wird ( std::bad_alloc)
Piotr Skotnicki
7
Die Funktion ist markiert noexcept, aber das Aufrufen kann werfen. Die Tatsache, dass die Ausnahme ausgelöst wird, bevor der Funktionskörper selbst eingegeben wird, beginnt sich in das Gebiet "Wie viele Engel können auf dem Kopf einer Stecknadel tanzen" zu begeben.
Jerry Coffin
7
Ich stimme Ihnen größtenteils zu, aber ich kann Herbs Standpunkt sehen. Das noexceptsagt wirklich nur "Wenn das Aufrufen dieser Funktion nicht auslöst, verspreche ich, dass Sie keine Ausnahme bekommen", was wohl weniger nützlich ist, als es sein könnte.
Jonathan Wakely
12
IMO, was Herb in diesem Vortrag tut, ist, allgemeine Ratschläge von einem Benchmark abzuleiten, auf dem er lief std::string. Das ist dumm und macht die ganze Übung ziemlich sinnlos. Machen Sie const&im Allgemeinen keine einsamen Überlastungen. Abgesehen von der Sache noexceptsollten Herbs Argumente für einen Einzelnen const&für nichts anderes als überzeugen std::string.
R. Martinho Fernandes
5
@NiallDouglas: Am unteren Rand von Folie 23 zeigt er eine Tabelle mit "C ++ 98: Angemessene Standardempfehlung" und am oberen Rand von Folie 24 eine Tabelle mit "Modernem C ++: Angemessener Standardhinweis" und beides Tabellen sind die gleichen. Ich bin mir nicht sicher, was dort nicht klar ist.
Vaughn Cato

Antworten:

40

Andere haben die noexceptobigen Überlegungen behandelt.

Herb verbrachte viel mehr Zeit im Gespräch über die Effizienzaspekte. Das Problem liegt nicht bei Zuweisungen, sondern bei unnötigen Freigaben. Wenn Sie eine kopierenstd::stringIn einem anderen Fall verwendet die Kopierroutine den zugewiesenen Speicher der Zielzeichenfolge erneut, wenn genügend Speicherplatz für die zu kopierenden Daten vorhanden ist. Bei einer Verschiebungszuweisung muss der vorhandene Speicher der Zielzeichenfolge freigegeben werden, da er den Speicher von der Quellzeichenfolge übernimmt. Die Redewendung "Kopieren und Verschieben" erzwingt, dass die Freigabe immer erfolgt, auch wenn Sie keine temporäre Zuordnung übergeben. Dies ist die Quelle der schrecklichen Leistung, die später im Vortrag demonstriert wird. Sein Rat war, stattdessen einen const ref zu nehmen und wenn Sie feststellen, dass Sie ihn benötigen, eine Überladung für R-Wert-Referenzen zu haben. Dies gibt Ihnen das Beste aus beiden Welten: Kopieren Sie es in den vorhandenen Speicher für Nicht-Provisorien, vermeiden Sie die Freigabe, und verschieben Sie es für Provisorien, in denen Sie sich befinden.

Das Obige gilt nicht für Konstruktoren, da in der Mitgliedsvariablen kein Speicher zum Freigeben vorhanden ist. Dies ist hilfreich, da Konstruktoren häufig mehr als ein Argument verwenden. Wenn Sie für jedes Argument const ref / r-value ref-Überladungen durchführen müssen, kommt es zu einer kombinatorischen Explosion von Konstruktorüberladungen.

Die Frage lautet nun: Wie viele Klassen gibt es, die beim Kopieren Speicher wie std :: string wiederverwenden? Ich vermute, dass std :: vector funktioniert, aber davon abgesehen bin ich mir nicht sicher. Ich weiß, dass ich noch nie eine Klasse geschrieben habe, die Speicher wie diesen wiederverwendet, aber ich habe viele Klassen geschrieben, die Zeichenfolgen und Vektoren enthalten. Das Befolgen von Herbs Ratschlägen schadet Ihnen nicht für Klassen, die keinen Speicher wiederverwenden. Sie kopieren zunächst mit der Kopierversion der Senkenfunktion. Wenn Sie feststellen, dass das Kopieren zu stark von der Leistung abhängt, werden Sie dies tun Machen Sie eine Überladung der R-Wert-Referenz, um die Kopie zu vermeiden (genau wie bei std :: string). Auf der anderen Seite hat die Verwendung von "Kopieren und Verschieben" einen nachgewiesenen Leistungseinbruch für std :: string und andere Typen, die Speicher wiederverwenden. und diese Typen werden wahrscheinlich im Code der meisten Leute häufig verwendet. Ich folge vorerst Herbs Rat, muss aber noch ein wenig darüber nachdenken, bevor ich das Problem für vollständig gelöst halte (es gibt wahrscheinlich einen Blog-Beitrag, für den ich keine Zeit habe, in all dem zu lauern).

Brett Hall
quelle
2
Dies ist eine großartige Antwort. Wenn Sie es leicht ändern können, um zu erwähnen, dass dieses "Anti-Pattern" -Label nur gilt, weil std :: string die Kapazität beibehält (wie von Herb erwähnt), und wenn wir keinen komplexen Typ verwenden würden, der in der Lage ist, seine vorhandene Kapazität wiederzuverwenden, und dies immer tun würde Ich muss die Freigabe vornehmen, egal was passiert. Ich werde dies als akzeptierte Antwort markieren. Übrigens, du hast mir wirklich den Nagel auf den Kopf getroffen.
Niall Douglas
Ich habe den letzten Absatz bearbeitet, um meine Vorbehalte gegen Herbs Ratschläge deutlicher zu machen. Ich war vorher ein bisschen vage über sie.
Brett Hall
11

Es wurden zwei Gründe in Betracht gezogen, warum das Übergeben von Werten besser sein könnte als das Übergeben von const-Referenzen.

  1. effizienter
  2. keine Ausnahme

Im Fall von Setzern für Mitglieder des Typs std::stringentlarvte er die Behauptung, dass das Übergeben von Werten effizienter sei, indem er zeigte, dass das Übergeben von const-Referenzen typischerweise weniger Zuweisungen (zumindest für std::string) ergab .

Er entlarvte auch die Behauptung, dass dies dem Setter erlaubt, noexceptindem er zeigte, dass die Deklaration noexcept irreführend ist, da beim Kopieren des Parameters immer noch eine Ausnahme auftreten kann.

Er kam daher zu dem Schluss, dass zumindest in diesem Fall die Übergabe der Konstantenreferenz der Übergabe des Wertes vorzuziehen sei. Er erwähnte jedoch, dass das Übergeben von Werten ein potenziell guter Ansatz für Konstrukteure sei.

Ich denke, dass das Beispiel für std::stringallein nicht ausreicht, um es auf alle Typen zu verallgemeinern, aber es stellt die Praxis in Frage, teure zu kopierende, aber billig zu verschiebende Parameter zumindest aus Effizienz- und Ausnahmegründen nach Wert zu übergeben.

Vaughn Cato
quelle
Sie haben im Grunde nur die Folien beschrieben. Wir wissen das alles (von den Folien). Und es ist falsch zu sagen, dass der Wert, der set_name () annimmt, eine Ausnahme erzeugen kann, weil dies aufgrund des noexcept nicht möglich ist.
Niall Douglas
@NiallDouglas: Ich stimme zu, dass alles in den Folien behandelt wird, aber wie beantwortet es Ihre Frage nicht?
Vaughn Cato
3
Ich finde das Effizienzargument ziemlich überzeugend, aber Sie schieben es in der Frage beiseite :) Howard mag die Copy-and-Swap-Sprache aus ähnlichen (In-) Effizienzgründen nicht. Es führt eine Zuordnung durch (und ist daher langsamer und kann werfen), selbst in Fällen, in denen name_.capacity() > name.length()Sie nur wirklich einen Memcpy benötigen. Das allein scheint genug zu sein, um es als Anti-Muster zu bezeichnen und für zwei Überladungen zu argumentieren
Jonathan Wakely,
2
@ JonathanWakely Ich finde auch das Effizienzargument überzeugend, aber nicht genug, um zwei Überladungen für jede einzelne Setterfunktion zu schreiben, die ich schreibe. Wenn das Benchmarking zeigt, dass es ein Problem ist, oder ich versuche, eine Boost-Bibliothek in der Vergangenheit zu überprüfen, störe ich, sonst nicht.
Niall Douglas
3
Das Übergeben von Werten kann sein noexcept, aber wie Herb im Vortrag betont, "nicht wirklich", da in vielen Fällen eine Kopie erstellt werden muss, um übergeben zu werden, und das Erstellen dieser Kopie kann werfen. Sicher, die Ausnahme tritt auf, bevor der Anruf getätigt wird, aber es passiert trotzdem.
Marshall Clow
6

Herb hat den Sinn, dass die In-By-Wert-Zuweisung, wenn Sie bereits Speicher zugewiesen haben, ineffizient sein und eine unnötige Zuweisung verursachen kann. Das Vorbeigehen const&ist jedoch fast so schlecht, als ob Sie eine rohe C-Zeichenfolge nehmen und an die Funktion übergeben, eine unnötige Zuordnung erfolgt.

Was Sie nehmen sollten, ist die Abstraktion des Lesens von einem String, nicht von einem String selbst, denn das ist es, was Sie brauchen.

Jetzt können Sie dies tun als template:

class employee {
  std::string name_;
public:
  template<class T>
  void set_name(T&& name) noexcept { name_ = std::forward<T>(name); }
};

das ist einigermaßen effizient. Dann fügen Sie vielleicht etwas SFINAE hinzu:

class employee {
  std::string name_;
public:
  template<class T>
  std::enable_if_t<std::is_convertible<T,std::string>::value>
  set_name(T&& name) noexcept { name_ = std::forward<T>(name); }
};

Wir bekommen also Fehler an der Schnittstelle und nicht an der Implementierung.

Dies ist nicht immer praktisch, da die Implementierung öffentlich zugänglich gemacht werden muss.

Hier string_viewkann eine Typklasse ins Spiel kommen:

template<class C>
struct string_view {
  // could be private:
  C const* b=nullptr;
  C const* e=nullptr;

  // key component:
  C const* begin() const { return b; }
  C const* end() const { return e; }

  // extra bonus utility:
  C const& front() const { return *b; }
  C const& back() const { return *std::prev(e); }

  std::size_t size() const { return e-b; }
  bool empty() const { return b==e; }

  C const& operator[](std::size_t i){return b[i];}

  // these just work:
  string_view() = default;
  string_view(string_view const&)=default;
  string_view&operator=(string_view const&)=default;

  // myriad of constructors:
  string_view(C const* s, C const* f):b(s),e(f) {}

  // known continuous memory containers:
  template<std::size_t N>
  string_view(const C(&arr)[N]):string_view(arr, arr+N){}
  template<std::size_t N>
  string_view(std::array<C, N> const& arr):string_view(arr.data(), arr.data()+N){}
  template<std::size_t N>
  string_view(std::array<C const, N> const& arr):string_view(arr.data(), arr.data()+N){}
  template<class... Ts>
  string_view(std::basic_string<C, Ts...> const& str):string_view(str.data(), str.data()+str.size()){}
  template<class... Ts>
  string_view(std::vector<C, Ts...> const& vec):string_view(vec.data(), vec.data()+vec.size()){}
  string_view(C const* str):string_view(str, str+len(str)) {}
private:
  // helper method:
  static std::size_t len(C const* str) {
    std::size_t r = 0;
    if (!str) return r;
    while (*str++) {
      ++r;
    }
    return r;
  }
};

Ein solches Objekt kann direkt aus einem std::stringoder einem erstellt werden "raw C string"und fast kostenlos speichern, was Sie wissen müssen, um daraus ein neues zu produzieren std::string.

class employee {
  std::string name_;
public:
  void set_name(string_view<char> name) noexcept { name_.assign(name.begin(),name.end()); }
};

und da unsere jetzt set_nameeine feste Schnittstelle hat (keine perfekte Vorwärtsschnittstelle), kann ihre Implementierung nicht sichtbar sein.

Die einzige Ineffizienz besteht darin, dass Sie, wenn Sie einen Zeichenfolgenzeiger im C-Stil übergeben, seine Größe unnötig zweimal überschreiten (erstmaliges Suchen nach '\0', zweites Kopieren). Auf der anderen Seite gibt dies Ihrem Ziel Informationen darüber, wie groß es sein muss, sodass es vorab zuweisen kann, anstatt es neu zuzuweisen.

Yakk - Adam Nevraumont
quelle
In Bezug auf Ihren Kommentar zur Ineffizienz bei der Übergabe eines nullterminierten Zeichenfolgenzeigers über string_view: Selbst wenn Sie diesen Zeiger direkt übergeben würden std::string::assign, würde die Implementierung mit ziemlicher Sicherheit zwei Durchgänge benötigen, um die Länge zu scannen und trotzdem zu kopieren. Jedenfalls für Zeichenfolgen, die größer als die SSO-Größe sind.
Casey
@Casey Stellen Sie sich eine push_backbasierte Implementierung vor, die das Ziel löscht, dann jedes Zeichen pusht und '\0'vor dem Stoppen nach prüft . Dies liest nur einmal. Die Unfähigkeit, den Zielpuffer vorab zuzuweisen, ist mit Kosten verbunden. Wenn Sie jedoch ständig Zeichenfolgen ähnlicher Länge zuweisen, kann eine solche Implementierung schneller sein. A string_view<char>kann das nicht duplizieren: Eine templateImplementierung könnte. In der Tat könnte eine std::basic_string<char>::operator=Überladung für char const*das eingegebene Zeichen wahrscheinlich nach \0Kopieren suchen , bis es nicht mehr im Raum ist, und dann nach Größe suchen und möglicherweise die Größe ändern.
Yakk - Adam Nevraumont
Man kann in der Tat Trampolintypen verwenden, um beim Schreiben von zwei Überladungen für jede Mitgliedsfunktion zu sparen, die einen kapazitätsfähigen Parameter verwendet. Es fühlt sich ein bisschen so an, als würde man fragen, wie viele Engel zu diesem Zeitpunkt auf dem Kopf einer Stecknadel tanzen können ...
Niall Douglas,
1
@NiallDouglas Allerdings ist die range_view, array_viewund string_viewKlassen eine Menge Nutzen außerhalb dieses Problems haben: die Fähigkeit , nicht-Kopie Abschnitte von Bereichen zu nehmen, sind zusammenhängende Bereiche oder Strings sehr mächtig. Dies ist nur eine weitere Verwendung von ihnen.
Yakk - Adam Nevraumont
2

Sie haben zwei Möglichkeiten, diese Methoden aufzurufen.

  • Mit rvalueparameter move constructorgibt es kein Problem ( solange höchstwahrscheinlich kein Parameter std::stringvorliegt), solange der Parametertyp noexcept ist. Verwenden Sie in jedem Fall besser ein bedingtes noexcept (um sicherzugehen, dass die Parameter noexcept sind).
  • Mit lvalueparameter würde in diesem Fall der copy constructorvom Parametertyp aufgerufen und es ist fast sicher, dass eine Zuordnung erforderlich ist (die werfen könnte).

In solchen Fällen, in denen die Verwendung möglicherweise nicht verwendet wird, ist es besser, sie zu vermeiden. Der Client des geht classdavon aus, dass keine Ausnahme wie angegeben ausgelöst wird, aber in gültiger, kompilierbarer, nicht verdächtiger Form ausgelöst werden kann C++11.

NetVipeC
quelle
Der Verschiebungszuweisungsoperator und der Destruktor von std :: string sind garantiert keine Ausnahme. Und sein Verschiebungskonstruktor, wenn auch seltsamerweise nicht, wenn dieser Verschiebungskonstruktor auf einen Allokator verweist.
Niall Douglas
Dies ist ein Fehler: cplusplus.github.io/LWG/lwg-active.html#2063 Die Zuweisung von Zeichenfolgenverschiebungen sollte nicht unbedingt ausgeschlossen sein, da die Zuweiser möglicherweise ungleich sind und sich nicht verbreiten und eine Neuzuweisung erfordern. Bei der Bewegungskonstruktion breitet sich der Allokator immer aus, sodass der Speicher gestohlen werden kann. Für den Allokator-erweiterten Verschiebungskonstruktor müssen Sie eine Neuzuweisung vornehmen, wenn der bereitgestellte Allokator nicht mit dem in der rWert-Zeichenfolge übereinstimmt.
Jonathan Wakely
Ach, wieder verdammte Allokatoren. Ja, ich habe genau das gleiche Problem mit meiner vorgeschlagenen boost :: concurrent_unordered_map gesehen, das sehr viel Sinn macht. Danke Jonathan.
Niall Douglas
2
Verdammte Allokatoren in der Tat! Obwohl ich sehe, dass Sie gesagt haben std::string, und da std::allocatordies propagate_on_container_move_assignmentzutreffend ist, ist es wahr, dass die Verschiebungszuweisung von std :: string in der Praxis niemals geworfen wird, selbst wenn die noexceptGarantie in einem zukünftigen Standard entfernt wird. Dies gilt jedoch std::basic_stringim Allgemeinen nicht.
Jonathan Wakely