Di chi è la colpa per questo intervallo basato su un riferimento temporaneo?


15

Il seguente codice sembra piuttosto innocuo a prima vista. Un utente utilizza la funzione bar()per interagire con alcune funzionalità della libreria. (Questo potrebbe anche aver funzionato per molto tempo da quando ha bar()restituito un riferimento a un valore non temporaneo o simile.) Ora tuttavia sta semplicemente restituendo una nuova istanza di B. Bha di nuovo una funzione a()che restituisce un riferimento a un oggetto di tipo iterabile A. L'utente desidera interrogare questo oggetto che porta a un segfault poiché l' Boggetto temporaneo restituito da bar()viene distrutto prima dell'inizio dell'iterazione.

Sono indeciso su chi (la biblioteca o l'utente) sia la colpa di questo. Tutte le classi fornite dalla libreria mi sembrano pulite e certamente non stanno facendo nulla di diverso (restituendo riferimenti ai membri, restituendo istanze di stack, ...) rispetto a così tanto altro codice là fuori. L'utente non sembra fare nulla di male, sta solo iterando su un oggetto senza fare nulla riguardo alla vita di quegli oggetti.

(Una domanda correlata potrebbe essere: se si dovesse stabilire la regola generale che il codice non dovrebbe "range-based-for-iterate" su qualcosa che viene recuperato da più di una chiamata concatenata nell'intestazione del ciclo poiché una di queste chiamate potrebbe restituire un rvalue?)

#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;
    }
}

6
Quando hai capito chi dare la colpa, quale sarà il prossimo passo? Urlando a lui / lei?
JensG,

7
No, perché dovrei? In realtà sono più interessato a sapere dove il processo di pensiero di sviluppo di questo "programma" non è riuscito a evitare questo problema in futuro.
hllnll,

Ciò non ha nulla a che fare con i valori, o basati su intervallo per i loop, ma con l'utente che non comprende correttamente la durata dell'oggetto.
James,

Osservazione del sito: questo è CWG 900 che è stato chiuso come Not A Defect. Forse i minuti contengono qualche discussione.
dyp,

8
Di chi è la colpa? Bjarne Stroustrup e Dennis Ritchie, innanzitutto.
Mason Wheeler,

Risposte:


14

Penso che il problema fondamentale sia una combinazione di caratteristiche del linguaggio (o mancanza di esse) del C ++. Sia il codice della libreria che il codice client sono ragionevoli (come evidenziato dal fatto che il problema è tutt'altro che ovvio). Se la durata del temporaneo Bfosse adatta estesa (fino alla fine del ciclo) non ci sarebbero problemi.

Rendere la vita temporanea abbastanza a lungo, e non più, è estremamente difficile. Nemmeno un "ad-hoc" ad hoc "tutti i soggetti coinvolti nella creazione dell'intervallo per un intervallo basato su live fino alla fine del ciclo" sarebbe privo di effetti collaterali. Considera il caso di B::a()restituire un intervallo indipendente Bdall'oggetto per valore. Quindi il temporaneo Bpuò essere scartato immediatamente. Anche se si potrebbero identificare con precisione i casi in cui è necessaria un'estensione a vita, poiché questi casi non sono ovvi per i programmatori, l'effetto (i distruttori chiamati molto più tardi) sarebbe sorprendente e forse una fonte altrettanto sottile di bug.

Sarebbe più desiderabile rilevare e vietare tali assurdità, costringendo il programmatore a elevarsi esplicitamente bar()a una variabile locale. Questo non è possibile in C ++ 11 e probabilmente non lo sarà mai perché richiede annotazioni. Rust fa questo, dove la firma di .a()sarebbe:

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.

Ecco 'xuna variabile o regione di durata, che è un nome simbolico per il periodo di tempo in cui una risorsa è disponibile. Francamente, le vite sono difficili da spiegare - o non abbiamo ancora capito la migliore spiegazione - quindi mi limiterò al minimo necessario per questo esempio e rimanderò il lettore propenso alla documentazione ufficiale .

Il controllore del prestito si accorgerebbe che il risultato dei bar().a()bisogni deve vivere finché il ciclo è in esecuzione. Formulata come un vincolo sulla durata 'x, scriviamo: 'loop <= 'x. Si noti inoltre che il destinatario della chiamata del metodo bar()è temporaneo. I due puntatori sono associati alla stessa durata, quindi 'x <= 'tempc'è un altro vincolo.

Questi due vincoli sono contraddittori! Abbiamo bisogno di , 'loop <= 'x <= 'tempma 'temp <= 'loopche cattura il problema in modo abbastanza preciso. A causa dei requisiti contrastanti, il codice buggy viene rifiutato. Si noti che questo è un controllo in fase di compilazione e il codice Rust di solito risulta nello stesso codice macchina del codice C ++ equivalente, quindi non è necessario pagare un costo di runtime per esso.

Tuttavia questa è una grande funzionalità da aggiungere a una lingua e funziona solo se tutto il codice la utilizza. anche il design delle API è interessato (alcuni progetti che sarebbero troppo pericolosi in C ++ diventano pratici, altri non possono essere fatti per giocare bene con le vite). Purtroppo, ciò significa che non è pratico aggiungere retroattivamente al C ++ (o qualsiasi altra lingua). In sintesi, la colpa è dell'inerzia delle lingue di successo e del fatto che Bjarne nel 1983 non aveva la sfera di cristallo e la lungimiranza per incorporare le lezioni degli ultimi 30 anni di ricerca ed esperienza in C ++ ;-)

Naturalmente, ciò non è affatto utile per evitare il problema in futuro (a meno che non passi a Rust e non usi mai più C ++). Si potrebbero evitare espressioni più lunghe con più chiamate a metodi concatenati (il che è piuttosto limitante e non risolve in remoto tutti i problemi della vita). Oppure si potrebbe provare ad adottare una politica di proprietà più disciplinata senza l'assistenza del compilatore: documentare chiaramente che barrestituisce per valore e che il risultato di B::a()non deve sopravvivere a quello Bsu cui a()viene invocato. Quando si modifica una funzione per restituire un valore anziché un riferimento di lunga durata, tenere presente che si tratta di un cambiamento di contratto . Ancora soggetto a errori, ma può accelerare il processo di identificazione della causa quando si verifica.


14

Possiamo risolvere questo problema utilizzando le funzionalità C ++?

C ++ 11 ha aggiunto ref-qualificatori di funzioni membro, che consente di limitare la categoria di valori dell'istanza di classe (espressione) su cui è possibile chiamare la funzione membro. Per esempio:

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

foo& lvalue ();
foo  prvalue();

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

Quando chiamiamo la beginfunzione membro, sappiamo che molto probabilmente dovremo anche chiamare la endfunzione membro (o qualcosa del genere size, per ottenere le dimensioni dell'intervallo). Ciò richiede che operiamo su un valore, poiché dobbiamo affrontarlo due volte. Si può quindi sostenere che queste funzioni del membro devono essere qualificate come refvalore.

Tuttavia, ciò potrebbe non risolvere il problema di fondo: aliasing. La funzione begine endmember alias l'oggetto o le risorse gestite dall'oggetto. Se sostituiamo begine endcon una singola funzione range, dovremmo fornirne una che può essere chiamata sui valori:

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

Questo potrebbe essere un caso d'uso valido, ma la definizione di cui sopra rangenon lo consente. Poiché non è possibile risolvere il problema temporaneo dopo la chiamata della funzione membro, potrebbe essere più ragionevole restituire un contenitore, ovvero un intervallo proprietario:

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

Applicando questo al caso del PO e una leggera revisione del codice

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

Questa funzione membro modifica la categoria di valori dell'espressione: B()è un valore, ma B().a()è un valore. D'altra parte, B().m_aè un valore. Quindi iniziamo a renderlo coerente. Esistono due modi per farlo:

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); }
};

La seconda versione, come detto sopra, risolverà il problema nel PO.

Inoltre, possiamo limitare Ble funzioni dei membri:

struct A {
    // [...]

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

    int v[3];
};

Ciò non avrà alcun impatto sul codice dell'OP, poiché il risultato dell'espressione dopo il :ciclo for basato su intervallo è associato a una variabile di riferimento. E questa variabile (come espressione usata per accedere alle sue funzioni begine ai endmembri) è un valore.

Naturalmente, la domanda è se la regola predefinita debba essere "aliasing Le funzioni membro sui valori dovrebbero restituire un oggetto che possiede tutte le sue risorse, a meno che non ci sia una buona ragione per non farlo" . L'alias che restituisce può essere legalmente utilizzato, ma è pericoloso nel modo in cui lo stai vivendo: non può essere utilizzato per prolungare la durata del suo "genitore" temporaneo:

// 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, penso che dovresti aggirare questo (o un problema simile) come segue:

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

invece degli OP

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

La soluzione alternativa specifica che la durata di bè l'intero blocco del for-loop.

Proposta che ha introdotto questa dichiarazione di apertura

Dimostrazione dal vivo


Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.