La funzione invalida inavvertitamente il parametro di riferimento: cosa è andato storto?


54

Oggi abbiamo scoperto la causa di un brutto bug che si verificava in modo intermittente solo su determinate piattaforme. In breve, il nostro codice era simile al seguente:

class Foo {
  map<string,string> m;

  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }

  void B() {
    while (!m.empty()) {
      auto toDelete = m.begin();
      A(toDelete->first);
    }
  }
}

Il problema potrebbe sembrare ovvio in questo caso semplificato: Bpassa un riferimento alla chiave a A, che rimuove la voce della mappa prima di provare a stamparla. (Nel nostro caso, non è stato stampato, ma utilizzato in un modo più complicato) Questo è ovviamente un comportamento indefinito, poiché keyè un riferimento penzolante dopo la chiamata a erase.

Risolvere il problema è stato banale: abbiamo appena cambiato il tipo di parametro da const string&a string. La domanda è: come abbiamo potuto evitare questo errore in primo luogo? Sembra che entrambe le funzioni abbiano fatto la cosa giusta:

  • Anon ha modo di sapere che si keyriferisce alla cosa che sta per distruggere.
  • Bavrebbe potuto Acrearne una copia prima di passarla a , ma non è il compito della chiamata decidere se prendere parametri per valore o per riferimento?

C'è qualche regola che non siamo riusciti a seguire?

Risposte:


35

Anon ha modo di sapere che si keyriferisce alla cosa che sta per distruggere.

Mentre questo è vero, Aconosce le seguenti cose:

  1. Il suo scopo è quello di distruggere qualcosa .

  2. Prende un parametro che è dello stesso tipo esatto della cosa che distruggerà.

Alla luce di questi fatti, è possibile per Adistruggere il proprio parametro se si prende il parametro come un puntatore / riferimento. Questo non è l'unico posto in C ++ in cui tali considerazioni devono essere affrontate.

Questa situazione è simile al modo in cui la natura di un operator=operatore di assegnazione può comportare la necessità di essere preoccupati per l'autoassegnazione. Questa è una possibilità perché il tipo di thise il tipo del parametro di riferimento sono gli stessi.

Va notato che questo è solo problematico perché in Aseguito intende utilizzare il keyparametro dopo aver rimosso la voce. Se così non fosse, allora andrebbe bene. Certo, allora diventa facile avere tutto perfettamente funzionante, quindi qualcuno cambia Ada usare keydopo che è stato potenzialmente distrutto.

Sarebbe un buon posto per un commento.

C'è qualche regola che non siamo riusciti a seguire?

In C ++, non puoi operare partendo dal presupposto che se segui ciecamente una serie di regole, il tuo codice sarà sicuro al 100%. Non possiamo avere regole per tutto .

Considera il punto 2 sopra. Aavrebbe potuto prendere alcuni parametri di un tipo diverso dalla chiave, ma l'oggetto stesso potrebbe essere un oggetto secondario di una chiave nella mappa. In C ++ 14, findpuò assumere un tipo diverso dal tipo di chiave, purché vi sia un confronto valido tra di essi. Quindi, se lo fai m.erase(m.find(key)), puoi distruggere il parametro anche se il tipo di parametro non è il tipo di chiave.

Quindi una regola come "se il tipo di parametro e il tipo di chiave sono uguali, prendili per valore" non ti salverà. Avresti bisogno di più informazioni di questo.

In definitiva, è necessario prestare attenzione ai casi d'uso specifici e esercitare un giudizio, informato dall'esperienza.


10
Bene, potresti avere la regola "non condividere mai lo stato mutabile" o è doppio "non mutare mai lo stato condiviso", ma poi faresti fatica a scrivere identificabile c ++
Caleth,

7
@Caleth Se vuoi usare quelle regole C ++ probabilmente non è la lingua che fa per te.
user253751

3
@Caleth Stai descrivendo Rust?
Malcolm,

1
"Non possiamo avere regole per tutto". Sì possiamo. cstheory.stackexchange.com/q/4052
Ouroborus

23

Direi di sì, c'è una regola abbastanza semplice che hai infranto che ti avrebbe salvato: il principio della responsabilità singola.

In questo momento, Aviene passato un parametro che utilizza sia per rimuovere un elemento da una mappa, sia per fare altri elaborati (stampa come mostrato sopra, apparentemente qualcos'altro nel codice reale). La combinazione di tali responsabilità mi sembra molto all'origine del problema.

Se abbiamo una funzione che che solo cancella il valore dalla mappa, e un altro che solo si occupa di elaborazione di un valore dalla mappa, avremmo dovuto chiamare ciascuno dal codice di livello superiore, in modo che saremmo finiti con qualcosa di simile :

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

Certo, i nomi che ho usato rendono senza dubbio il problema più ovvio dei nomi reali, ma se i nomi sono significativi, sono quasi certi di chiarire che stiamo cercando di continuare a utilizzare il riferimento dopo che è stato invalidato. Il semplice cambio di contesto rende il problema molto più evidente.


3
Bene, questa è un'osservazione valida, si applica solo molto strettamente a questo caso. Ci sono molti esempi in cui l'SRP è rispettato e ci sono ancora problemi con la funzione che potrebbe invalidare il proprio parametro.
Ben Voigt,

5
@BenVoigt: il solo invalidare il suo parametro non causa problemi. Continua a utilizzare il parametro dopo che è stato invalidato e ciò causa problemi. Ma alla fine sì, hai ragione: anche se in questo caso lo avrebbe salvato, ci sono senza dubbio casi in cui è insufficiente.
Jerry Coffin,

3
Quando si scrive un esempio semplificato, è necessario omettere alcuni dettagli e, a volte, risulta che uno di questi dettagli era importante. Nel nostro caso, in Arealtà cercato keyin due diverse mappe e, se trovato, rimosso le voci più un po 'di pulizia in più. Quindi non è chiaro che il nostro ASRP violato. Mi chiedo se dovrei aggiornare la domanda a questo punto.
Nikolai,

2
Espandere sul punto di @BenVoigt: nell'esempio di Nicolai m.erase(key)ha la prima responsabilità e cout << "Erased: " << keyha la seconda responsabilità, quindi la struttura del codice mostrato in questa risposta non è in realtà diversa dalla struttura del codice nell'esempio, ma in il mondo reale il problema è stato trascurato. Il principio della singola responsabilità non fa nulla per garantire, o addirittura rendere più probabile, che sequenze contraddittorie di singole azioni appariranno in prossimità del codice del mondo reale.
sdenham,

10

C'è qualche regola che non siamo riusciti a seguire?

Sì, non sei riuscito a documentare la funzione .

Senza una descrizione del contratto che passa parametri (in particolare la parte relativa alla validità del parametro - è all'inizio della chiamata di funzione o in tutto) è impossibile dire se l'errore si trova nell'implementazione (se il contratto di chiamata è che il parametro è valido all'avvio della chiamata, la funzione deve effettuare una copia prima di eseguire qualsiasi azione che potrebbe invalidare il parametro) o nel chiamante (se il contratto di chiamata prevede che il parametro deve rimanere valido durante la chiamata, il chiamante non può passare un riferimento ai dati all'interno della raccolta da modificare).

Ad esempio, lo standard C ++ stesso specifica che:

Se un argomento di una funzione ha un valore non valido (come un valore esterno al dominio della funzione o un puntatore non valido per l'uso previsto), il comportamento non è definito.

ma non riesce a specificare se ciò si applica solo all'istante in cui viene effettuata la chiamata o durante l'esecuzione della funzione. Tuttavia, in molti casi è chiaro che solo quest'ultimo è persino possibile, vale a dire quando l'argomento non può essere mantenuto valido facendo una copia.

Ci sono alcuni casi nel mondo reale in cui questa distinzione entra in gioco. Ad esempio, aggiungendo std::vector<T>a se stesso


"non riesce a specificare se ciò si applica solo all'istante in cui viene effettuata la chiamata o durante l'esecuzione della funzione." In pratica, i compilatori fanno praticamente tutto ciò che vogliono durante la funzione una volta invocato UB. Questo può portare a comportamenti davvero strani se il programmatore non rileva l'UB.

@snowman, sebbene interessante, il riordino di UB è completamente estraneo a ciò di cui discuto in questa risposta, che è la responsabilità di garantire la validità (in modo che UB non accada mai).
Ben Voigt,

che è esattamente il mio punto: la persona che scrive il codice deve essere responsabile di evitare UB per evitare un'intera tana di coniglio piena di problemi.

@Snowman: non esiste "una persona" che scriva tutto il codice in un progetto. Questo è uno dei motivi per cui la documentazione dell'interfaccia è così importante. Un altro è che interfacce ben definite riducono la quantità di codice che deve essere ragionato contemporaneamente - per qualsiasi progetto non banale, non è possibile che qualcuno sia "responsabile" per pensare alla correttezza di ogni affermazione.
Ben Voigt,

Non ho mai detto che una persona scriva tutto il codice. A un certo punto, un programmatore potrebbe guardare una funzione o scrivere un codice. Tutto quello che sto cercando di dire è che chiunque stia osservando il codice deve fare attenzione perché in pratica UB è contagioso e si diffonde da una riga di codice su ambiti più ampi una volta coinvolto il compilatore. Questo risale al tuo punto sulla violazione del contratto di una funzione: sono d'accordo con te, ma affermando che può diventare un problema ancora più grande.

2

C'è qualche regola che non siamo riusciti a seguire?

Sì, non sei riuscito a testarlo correttamente. Non sei solo e sei nel posto giusto per imparare :)


C ++ ha un sacco di comportamento indefinito, il comportamento indefinito si manifesta in modi sottili e fastidiosi.

Probabilmente non puoi mai scrivere un codice C ++ sicuro al 100%, ma puoi certamente ridurre la probabilità di introdurre accidentalmente un comportamento indefinito nella tua base di codice utilizzando una serie di strumenti.

  1. Avvertenze del compilatore
  2. Analisi statica (versione estesa degli avvisi)
  3. Binari di prova strumentati
  4. Binari di produzione temprati

Nel tuo caso, dubito che (1) e (2) avrebbero aiutato molto, anche se in generale consiglio di usarli. Per ora concentriamoci sulle altre due.

Sia gcc che Clang presentano un -fsanitizeflag che indica i programmi che compili per verificare una varietà di problemi. -fsanitize=undefinedper esempio catturerà underflow / overflow di numeri interi con segno, spostando di una quantità troppo alta, ecc ... Nel tuo caso specifico, -fsanitize=addresse -fsanitize=memorysarebbe probabile che si risolvesse sul problema ... purché tu abbia un test che chiama la funzione. Per completezza, -fsanitize=threadvale la pena utilizzarlo se si dispone di una base di codice multi-thread. Se non puoi implementare il binario (ad esempio, hai librerie di terze parti senza il loro sorgente), puoi anche usare valgrindsebbene sia più lento in generale.

Recenti compilatori presentano anche possibilità di indurimento ricchezza . La differenza principale con i binari strumentati è che i controlli di tempra sono progettati per avere un basso impatto sulle prestazioni (<1%), rendendoli adatti al codice di produzione in generale. I più noti sono i controlli CFI (Control Flow Integrity) che sono progettati per sventare attacchi di stack-smashing e hi-jack del puntatore virtuale tra l'altro per sovvertire il flusso di controllo.

Il punto di entrambi (3) e (4) è trasformare un fallimento intermittente in un certo fallimento : entrambi seguono il principio del fallimento rapido . Ciò significa che:

  • fallisce sempre quando calpesti la mina
  • fallisce immediatamente , indicandoti l'errore anziché corrompere casualmente la memoria, ecc ...

La combinazione (3) con una buona copertura di prova dovrebbe rilevare la maggior parte dei problemi prima che colpiscano la produzione. L'uso di (4) in produzione può fare la differenza tra un bug fastidioso e un exploit.


0

@note: questo post aggiunge solo altri argomenti in aggiunta alla risposta di Ben Voigt .

La domanda è: come abbiamo potuto evitare questo errore in primo luogo? Sembra che entrambe le funzioni abbiano fatto la cosa giusta:

  • A non ha modo di sapere che la chiave si riferisce alla cosa che sta per distruggere.
  • B avrebbe potuto crearne una copia prima di passarla ad A, ma non è compito della chiamata decidere se prendere i parametri in base al valore o al riferimento?

Entrambe le funzioni hanno fatto la cosa giusta.

Il problema è nel codice client, che non ha tenuto conto degli effetti collaterali della chiamata A.

Il C ++ non ha un modo diretto per specificare gli effetti collaterali nella lingua.

Ciò significa che spetta a te (e al tuo team) assicurarsi che cose come gli effetti collaterali siano visibili nel codice (come documentazione) e mantenute con il codice (probabilmente dovresti considerare di documentare pre-condizioni, post-condizioni e invarianti anche per motivi di visibilità).

Cambio codice:

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

Da questo punto in poi hai qualcosa in più sull'API che ti dice che dovresti avere un test unitario per esso; Ti dice anche come usare (e non usare) l'API.


-4

come abbiamo potuto evitare questo errore in primo luogo?

C'è solo un modo per evitare i bug: smettere di scrivere codice. Tutto il resto è fallito in qualche modo.

Tuttavia, testare il codice a vari livelli (unit test, test funzionali, test di integrazione, test di accettazione, ecc.) Non solo migliorerà la qualità del codice, ma ridurrà anche il numero di bug.


1
Questa è una totale assurdità. Non v'è , non solo un modo per evitare errori. Mentre è banalmente vero che l'unico modo per evitare completamente l'esistenza di bug è non scrivere mai codice, è anche vero (e molto più utile) che ci sono varie procedure di ingegneria del software che puoi seguire, sia quando scrivi inizialmente codice che durante il test, ciò può ridurre significativamente la presenza di bug. Tutti conoscono la fase di test, ma l'impatto maggiore può essere spesso ottenuto al minor costo seguendo pratiche progettuali e modi di dire responsabili mentre si scrive il codice in primo luogo.
Cody Gray il
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.