Chi è la colpa di questo intervallo basato su un riferimento temporaneo?

14

Il seguente codice sembra piuttosto innocuo a prima vista. Un utente usa la funzione bar() per interagire con alcune funzionalità della libreria. (Potrebbe anche funzionare per un lungo periodo da quando bar() ha restituito un riferimento a un valore non temporaneo o simile.) Ora tuttavia si tratta semplicemente di restituire una nuova istanza di B . B ha di nuovo una funzione a() che restituisce un riferimento a un oggetto del tipo iterabile di A . L'utente desidera interrogare questo oggetto che conduce a un segfault poiché l'oggetto B temporaneo restituito da bar() viene distrutto prima dell'inizio dell'iterazione.

Sono indeciso a chi (biblioteca o utente) è da biasimare per questo. Tutte le classi fornite dalle librerie mi sembrano pulite e certamente non stanno facendo nulla di diverso (restituendo riferimenti ai membri, restituendo istanze di stack, ...) di tanto altro codice là fuori. Anche l'utente non sembra fare nulla di sbagliato, sta semplicemente iterando su qualche oggetto senza fare nulla riguardo alla durata di questi oggetti.

(Una domanda correlata potrebbe essere: si dovrebbe 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 loop poiché una di queste chiamate potrebbe restituire un valore?)

#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;
    }
}
    
posta hllnll 09.11.2014 - 14:55
fonte

2 risposte

13

Penso che il problema fondamentale sia una combinazione di caratteristiche linguistiche (o la loro mancanza) di C ++. Sia il codice della libreria sia il codice cliente sono ragionevoli (come dimostra il fatto che il problema è tutt'altro che scontato). Se la durata del temporaneo B era adatta estesa (fino alla fine del ciclo) non ci sarebbero problemi.

Rendere la vita dei provvisori solo abbastanza a lungo, e non più, è estremamente difficile. Nemmeno un piuttosto "ad hoc" tutti i provvisori coinvolti nella creazione della gamma per una serie basata sul live fino alla fine del ciclo "sarebbe senza effetti collaterali. Considera il caso di B::a() che restituisce un intervallo indipendente dal B oggetto per valore. Quindi il% provvisorioB può essere scartato immediatamente. Anche se è possibile identificare con precisione i casi in cui è necessaria un'estensione permanente, poiché questi casi non sono ovvi per i programmatori, l'effetto (i distruttori chiamati molto più tardi) sarebbe sorprendente e forse anche una fonte di bug altrettanto sottile.

Sarebbe più desiderabile solo rilevare e proibire tali sciocchezze, costringendo il programmatore a elevare esplicitamente bar() a una variabile locale. Questo non è possibile in C ++ 11 e probabilmente non sarà mai possibile 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.

Qui 'x è una variabile a vita o una regione, 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 inclinato a documentazione ufficiale .

Il controllore del prestito noterebbe che il risultato di bar().a() deve vivere fino a quando il ciclo viene eseguito. Inserito come vincolo sulla durata 'x , scriviamo: 'loop <= 'x . Noterebbe anche che il destinatario della chiamata al metodo, bar() , è un temporaneo. I due puntatori sono associati alla stessa durata, quindi 'x <= 'temp è un altro vincolo.

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

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

Ovviamente, non è affatto utile per evitare il problema in futuro (a meno che non si passi a Rust e non si usi mai più il C ++). Si potrebbero evitare espressioni più lunghe con più chiamate a metodi concatenati (il che è piuttosto limitante e non risolve neanche in remoto tutti i problemi di durata). Oppure si potrebbe provare ad adottare una politica di proprietà più disciplinata senza l'assistenza del compilatore: Documentare chiaramente che bar restituisce per valore e che il risultato di B::a() non deve sopravvivere al B su cui viene invocato a() . Quando si modifica una funzione in base al valore anziché a un riferimento più longevo, sii consapevole che si tratta di un cambio di contratto . Ancora soggetto a errori, ma potrebbe velocizzare il processo di identificazione della causa quando si verifica.

    
risposta data 09.11.2014 - 17:41
fonte
7

Possiamo risolvere questo problema usando le funzionalità di C ++?

C ++ 11 ha aggiunto qualificatori di ref della funzione membro, che consente di limitare la categoria di valore dell'istanza di classe (espressione) su cui è possibile richiamare la funzione membro. Ad esempio:

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

foo& lvalue ();
foo  prvalue();

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

Quando chiami la funzione membro begin , sappiamo che molto probabilmente dovremo anche chiamare la funzione membro end (o qualcosa come size , per ottenere la dimensione dell'intervallo). Ciò richiede che operiamo su un lvalue, dal momento che dobbiamo affrontarlo due volte. Puoi quindi sostenere che queste funzioni membro dovrebbero essere lvalue-ref-qualified.

Tuttavia, questo potrebbe non risolvere il problema sottostante: aliasing. La funzione membro begin e end alias l'oggetto o le risorse gestite dall'oggetto. Se sostituiamo begin e end con una singola funzione range , dovremmo fornirne uno che può essere richiamato su rvalues:

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 precedente definizione di range non lo consente. Poiché non possiamo indirizzare il codice 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 dell'OP e leggera revisione del codice

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

Questa funzione membro modifica la categoria valore dell'espressione: B() è un valore di prval, ma B().a() è un valore di lvalue. D'altra parte, B().m_a è un valore. Quindi iniziamo rendendo questo coerente. Ci sono 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 nell'OP.

Inoltre, possiamo limitare le funzioni membro di B :

struct A {
    // [...]

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

    int v[3];
};

Questo non avrà alcun impatto sul codice dell'OP, poiché il risultato dell'espressione dopo che : nel ciclo basato su intervallo è associato a una variabile di riferimento. E questa variabile (come espressione utilizzata per accedere alle sue funzioni membro begin e end ) è un lvalue.

Ovviamente, la domanda è se la regola predefinita debba essere "le funzioni membro aliasing su rvalue dovrebbero restituire un oggetto che possiede tutte le sue risorse, a meno che non ci sia una buona ragione per non" . L'alias restituito può essere utilizzato legalmente, ma è pericoloso nel modo in cui lo stai sperimentando: non può essere utilizzato per estendere 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
    
risposta data 09.11.2014 - 22:45
fonte

Leggi altre domande sui tag