Wer ist schuld an dieser Bandbreite, die sich über einen Verweis auf temporäres bezieht?

15

Der folgende Code sieht auf den ersten Blick ziemlich harmlos aus. Ein Benutzer verwendet die Funktion, bar()um mit einigen Bibliotheksfunktionen zu interagieren. (Möglicherweise hat dies sogar schon lange funktioniert, nachdem bar()ein Verweis auf einen nicht temporären Wert oder Ähnliches zurückgegeben wurde.) Jetzt wird jedoch einfach eine neue Instanz von zurückgegeben B. Bhat wieder eine Funktion a(), die einen Verweis auf ein Objekt des iterierbaren Typs zurückgibt A. Der Benutzer möchte dieses Objekt abfragen, was zu einem Segfault führt, da das von zurückgegebene temporäre BObjekt bar()zerstört wird, bevor die Iteration beginnt.

Ich bin mir nicht sicher, wer (Bibliothek oder Benutzer) dafür verantwortlich ist. Alle von der Bibliothek bereitgestellten Klassen sehen für mich sauber aus und machen auf keinen Fall etwas anderes (Rückgabe von Verweisen auf Mitglieder, Rückgabe von Stack-Instanzen, ...) als so viel anderer Code da draußen. Der Benutzer scheint auch nichts falsch zu machen, er iteriert nur über ein Objekt, ohne irgendetwas in Bezug auf die Lebensdauer dieses Objekts zu tun.

(Eine verwandte Frage könnte sein: Sollte man die allgemeine Regel festlegen, dass Code nicht "bereichsbasiert für Iteration" über etwas sein sollte, das von mehr als einem verketteten Aufruf im Loop-Header abgerufen wird, da einer dieser Aufrufe u. U. a. Zurückgibt rWert?)

#include <algorithm>
#include <iostream>

// "Library code"
struct A
{
    A():
        v{0,1,2}
    {
        std::cout << "A()" << std::endl;
    }

    ~A()
    {
        std::cout << "~A()" << std::endl;
    }

    int * begin()
    {
        return &v[0];
    }

    int * end()
    {
        return &v[3];
    }

    int v[3];
};

struct B
{
    A m_a;

    A & a()
    {
        return m_a;
    }
};

B bar()
{
    return B();
}

// User code
int main()
{
    for( auto i : bar().a() )
    {
        std::cout << i << std::endl;
    }
}
hllnll
quelle
6
Wenn Sie herausgefunden haben, wer schuld ist, was wird der nächste Schritt sein? Ihn / sie anschreien?
JensG
7
Nein warum sollte ich? Eigentlich interessiert mich mehr, wo der Denkprozess bei der Entwicklung dieses "Programms" dieses Problem in Zukunft nicht vermeiden konnte.
hllnll
Dies hat nichts mit r-Werten oder bereichsbezogenen for-Schleifen zu tun, sondern damit, dass der Benutzer die Objektlebensdauer nicht richtig versteht.
James
Site-Bemerkung: Dies ist CWG 900, das als Not A Defect geschlossen wurde. Vielleicht enthält das Protokoll eine Diskussion.
typ
7
Wer ist dafür verantwortlich? In erster Linie Bjarne Stroustrup und Dennis Ritchie.
Mason Wheeler

Antworten:

14

Ich denke, das grundlegende Problem ist eine Kombination von Sprachfunktionen (oder deren Fehlen) von C ++. Sowohl der Bibliothekscode als auch der Clientcode sind vernünftig (was durch die Tatsache belegt wird, dass das Problem keineswegs offensichtlich ist). Wenn die Lebensdauer des Provisoriums Bangemessen verlängert würde (bis zum Ende der Schleife), gäbe es kein Problem.

Es ist extrem schwer, das temporäre Leben gerade lang genug und nicht länger zu machen. Nicht einmal ein eher ad-hoc "alle an der Erstellung des Sortiments für ein Live-basiertes Sortiment bis zum Ende der Schleife beteiligten Provisorien" wären ohne Nebenwirkungen. Betrachten Sie den Fall der B::a()Rückgabe eines Bereichs, der Bvom Wert des Objekts unabhängig ist. Dann kann das Provisorium Bsofort verworfen werden. Selbst wenn man genau die Fälle identifizieren könnte, in denen eine Verlängerung der Lebensdauer erforderlich ist, da diese Fälle für Programmierer nicht offensichtlich sind, wäre der Effekt (Destruktoren werden viel später aufgerufen) überraschend und möglicherweise eine ebenso subtile Fehlerquelle.

Es wäre wünschenswerter, nur solchen Unsinn zu entdecken und zu verbieten, und den Programmierer zu zwingen, explizit bar()zu einer lokalen Variablen zu wechseln . Dies ist in C ++ 11 nicht möglich und wird wahrscheinlich auch nicht möglich sein, da Anmerkungen erforderlich sind. Rust macht das, wo die Signatur von .a()wäre:

fn a<'x>(bar: &'x B) -> &'x A { bar.a }
// If we make it as explicit as possible, or
fn a(&self) -> &A { self.a }
// if we make it a method and rely on lifetime elision.

Hierbei 'xhandelt es sich um eine lebenslange Variable oder Region, bei der es sich um einen symbolischen Namen für den Zeitraum handelt, in dem eine Ressource verfügbar ist. Ehrlich gesagt sind die Lebenszeiten schwer zu erklären - oder wir haben noch nicht die beste Erklärung gefunden -, also beschränke ich mich auf das für dieses Beispiel erforderliche Minimum und verweise den geneigten Leser auf die offizielle Dokumentation .

Der Leihprüfer würde feststellen, dass das Ergebnis von bar().a()so lange leben muss, wie die Schleife läuft. Phrasiert als Einschränkung auf die Lebensdauer 'x, schreiben wir: 'loop <= 'x. Es wird auch bemerkt, dass der Empfänger des Methodenaufrufs bar()ein temporärer ist. Die beiden Zeiger sind mit derselben Lebensdauer verknüpft, daher 'x <= 'tempgibt es eine weitere Einschränkung.

Diese beiden Einschränkungen sind widersprüchlich! Wir brauchen 'loop <= 'x <= 'tempaber 'temp <= 'loop, was das Problem ziemlich genau erfasst. Aufgrund der widersprüchlichen Anforderungen wird der Fehlercode abgelehnt. Beachten Sie, dass dies eine Überprüfung zur Kompilierungszeit ist und Rust-Code in der Regel denselben Computercode ergibt wie äquivalenter C ++ - Code, sodass Sie keine Laufzeitkosten dafür bezahlen müssen.

Trotzdem ist dies eine wichtige Funktion, die einer Sprache hinzugefügt werden kann. Sie funktioniert nur, wenn der gesamte Code sie verwendet. Das Design von APIs ist ebenfalls betroffen (einige Designs, die in C ++ zu gefährlich wären, werden praktisch, andere können nicht dazu gebracht werden, mit den Lebensdauern zufrieden zu sein). Leider bedeutet dies, dass es nicht praktisch ist, C ++ (oder eine andere Sprache) rückwirkend hinzuzufügen. Zusammenfassend liegt der Fehler in der Trägheit, die erfolgreiche Sprachen haben, und in der Tatsache, dass Bjarne 1983 nicht über die Kristallkugel und die Weitsicht verfügte, um die Lehren aus den letzten 30 Jahren der Forschung und C ++ - Erfahrung einzubeziehen ;-)

Dies ist natürlich keineswegs hilfreich, um das Problem in Zukunft zu vermeiden (es sei denn, Sie wechseln zu Rust und verwenden C ++ nie wieder). Man könnte längere Ausdrücke mit mehreren verketteten Methodenaufrufen vermeiden (was ziemlich einschränkend ist und nicht einmal alle Probleme auf Lebenszeit remote behebt). Oder man könnte versuchen , eine disziplinierte Eigentumspolitik ohne Compiler Unterstützung Annahme: Dokument deutlich , dass barRenditen von Wert und das Ergebnis B::a()muss überleben nicht die Bauf dem a()aufgerufen wird. Beachten Sie, dass es sich um eine Vertragsänderung handelt, wenn Sie eine Funktion so ändern, dass sie nach Wert zurückgegeben wird, anstatt nach einer längerfristigen Referenz . Es ist immer noch fehleranfällig, kann jedoch die Ermittlung der Ursache beschleunigen.


quelle
13

Können wir dieses Problem mithilfe von C ++ - Funktionen lösen?

C ++ 11 hat Member Function Ref-Qualifiers hinzugefügt, mit denen die Wertkategorie der Klasseninstanz (Ausdruck) eingeschränkt werden kann, auf die die Member Function aufgerufen werden kann. Beispielsweise:

struct foo {
    void bar() & {} // lvalue-ref-qualified
};

foo& lvalue ();
foo  prvalue();

lvalue ().bar(); // OK
prvalue().bar(); // error

Wenn beginwir die Member-Funktion aufrufen , wissen wir, dass wir höchstwahrscheinlich auch die endMember-Funktion aufrufen müssen (oder so ähnlich size, um die Größe des Bereichs zu ermitteln). Dies erfordert, dass wir mit einem Wert arbeiten, da wir ihn zweimal ansprechen müssen. Sie können daher argumentieren, dass diese Mitgliedsfunktionen lvalue-ref-qualifiziert sein sollten.

Dies könnte jedoch das zugrunde liegende Problem nicht lösen: Aliasing. Die Funktion beginund der endMember bilden einen Alias ​​für das Objekt oder die vom Objekt verwalteten Ressourcen. Wenn wir beginund enddurch eine einzelne Funktion ersetzen range, sollten wir eine bereitstellen, die auf rvalues ​​aufgerufen werden kann:

struct foo {
    vector<int> arr;

    auto range() & // C++14 return type deduction for brevity
    { return std::make_pair(arr.begin(), arr.end()); }
};

for(auto const& e : foo().range()) // error

Dies mag ein gültiger Anwendungsfall sein, aber die obige Definition von lässt dies rangenicht zu. Da wir das temporäre Objekt nach dem Aufruf der Member-Funktion nicht ansprechen können, ist es möglicherweise sinnvoller, einen Container, dh einen Besitzerbereich, zurückzugeben:

struct foo {
    vector<int> arr;

    auto range() &
    { return std::make_pair(arr.begin(), arr.end()); }

    auto range() &&
    { return std::move(arr); }
};

for(auto const& e : foo().range()) // OK

Anwendung auf den Fall des OP und leichte Codeüberprüfung

struct B {
    A m_a;
    A & a() { return m_a; }
};

Diese Mitgliedsfunktion ändert die Wertkategorie des Ausdrucks: B()ist ein Wert, aber B().a()ein Wert. Auf der anderen Seite B().m_aist ein Wert. Beginnen wir also damit, dies konsistent zu machen. Hierfür gibt es zwei Möglichkeiten:

struct B {
    A m_a;
    A &  a() &  { return m_a; }

    A && a() && { return std::move(m_a); }
    // or
    A    a() && { return std::move(m_a); }
};

Die zweite Version behebt, wie oben erwähnt, das Problem im OP.

Zusätzlich können wir die BMitgliedsfunktionen einschränken :

struct A {
    // [...]

    int * begin() & { return &v[0]; }
    int * end  () & { return &v[3]; }

    int v[3];
};

Dies hat keine Auswirkungen auf den OP-Code, da das Ergebnis des Ausdrucks nach der :in der bereichsbasierten for-Schleife an eine Referenzvariable gebunden ist. Und diese Variable (als Ausdruck für den Zugriff auf ihre beginund endMember-Funktionen) ist ein lWert.

Die Frage ist natürlich, ob die Standardregel lautet "Aliasing von Member-Funktionen auf R-Werte sollte ein Objekt zurückgeben, das alle seine Ressourcen besitzt, es sei denn, es gibt einen guten Grund, dies nicht zu tun" . Der zurückgegebene Alias ​​kann legal verwendet werden, ist jedoch in der Art und Weise, in der Sie ihn erleben, gefährlich: Er kann nicht verwendet werden, um die Lebensdauer seines "übergeordneten" temporären Objekts zu verlängern:

// using the OP's definition of `struct B`,
// or version 1, `A && a() &&;`

A&&      a = B().a(); // bug: binds directly, dangling reference
A const& a = B().a(); // bug: same as above
A        a = B().a(); // OK

A&&      a = B().m_a; // OK: extends the lifetime of the temporary

In C ++ 2a sollten Sie dieses (oder ein ähnliches) Problem folgendermaßen umgehen:

for( B b = bar(); auto i : b.a() )

anstelle der OP's

for( auto i : bar().a() )

Die Problemumgehung gibt manuell an, dass die Lebensdauer von bden gesamten Block der For-Schleife ist.

Vorschlag, der diese Init-Anweisung einleitete

Live-Demo

dyp
quelle