Perché l'ottimizzatore GCC 6 avanzato interrompe il pratico codice C ++?


148

GCC 6 ha una nuova funzione di ottimizzazione : presuppone che non thissia sempre nullo e si ottimizza in base a ciò.

La propagazione dell'intervallo di valori ora presuppone che questo puntatore delle funzioni membro C ++ sia non nullo. Ciò elimina i comuni controlli puntatore null ma rompe anche alcune basi di codice non conformi (come Qt-5, Chromium, KDevelop) . Come soluzione temporanea si può usare -fno-delete-null-pointer-checks. Il codice errato può essere identificato usando -fsanitize = undefined.

Il documento di modifica lo definisce chiaramente pericoloso perché rompe una quantità sorprendente di codice utilizzato di frequente.

Perché questo nuovo presupposto spezzerebbe il pratico codice C ++? Ci sono schemi particolari in cui i programmatori negligenti o disinformati si affidano a questo particolare comportamento indefinito? Non riesco a immaginare nessuno che scriva if (this == NULL)perché è così innaturale.


21
@Ben Spero che lo intendi in senso buono. Il codice con UB deve essere riscritto per non invocare UB. E 'così semplice. Cavolo, ci sono spesso domande frequenti che ti dicono come raggiungerlo. Quindi, non è un vero problema IMHO. Tutto bene.
Ripristina Monica il

49
Sono sorpreso di vedere persone che difendono dereferenziare i puntatori null nel codice. Semplicemente stupefacente.
SergeyA

19
@Ben, esplorare comportamenti indefiniti è stata la tattica di ottimizzazione molto efficace per molto tempo. Lo adoro, perché adoro le ottimizzazioni che rendono il mio codice più veloce.
Sergey,

17
Sono d'accordo con SergeyA. L'intero brouhaha è iniziato perché le persone sembrano soffermarsi sul fatto che thisviene passato come parametro implicito, quindi iniziano a usarlo come se fosse un parametro esplicito. Non è. Quando si dereferisce un valore null, si sta invocando UB proprio come se si fosse verificato un altro puntatore null. Questo è tutto. Se si desidera passare nullptrs in giro, utilizzare un parametro esplicito, DUH . Non sarà più lento, non sarà più clunkier e il codice che ha tale API è comunque profondo negli interni, quindi ha un ambito molto limitato. Fine della storia, penso.
Ripristina Monica il

41
Complimenti a GCC per aver interrotto il ciclo del codice errato -> compilatore inefficiente per supportare il codice errato -> codice più errato -> compilazione più inefficiente -> ...
MM

Risposte:


87

Immagino alla domanda a cui bisogna rispondere perché le persone ben intenzionate dovrebbero scrivere gli assegni in primo luogo.

Il caso più comune è probabilmente se si dispone di una classe che fa parte di una chiamata ricorsiva naturale.

Se tu avessi:

struct Node
{
    Node* left;
    Node* right;
};

in C, potresti scrivere:

void traverse_in_order(Node* n) {
    if(!n) return;
    traverse_in_order(n->left);
    process(n);
    traverse_in_order(n->right);
}

In C ++, è bello renderlo una funzione membro:

void Node::traverse_in_order() {
    // <--- What check should be put here?
    left->traverse_in_order();
    process();
    right->traverse_in_order();
}

All'inizio del C ++ (prima della standardizzazione), si sottolineava che le funzioni membro erano zucchero sintattico per una funzione in cui il thisparametro è implicito. Il codice è stato scritto in C ++, convertito in C equivalente e compilato. Ci sono stati anche esempi espliciti che il confronto thiscon null era significativo e anche il compilatore Cfront originale ne ha tratto vantaggio. Quindi, proveniente da uno sfondo C, la scelta ovvia per il controllo è:

if(this == nullptr) return;      

Nota: Bjarne Stroustrup menziona persino che le regole per thissono cambiate nel corso degli anni qui

E questo ha funzionato su molti compilatori per molti anni. Quando è avvenuta la standardizzazione, questo è cambiato. E più recentemente, i compilatori hanno iniziato a trarre vantaggio dal chiamare una funzione membro in cui l' thisessere nullptrè un comportamento indefinito, il che significa che questa condizione è sempre falsee il compilatore è libero di ometterla.

Ciò significa che per eseguire qualsiasi attraversamento di questo albero, è necessario:

  • Fai tutti i controlli prima di chiamare traverse_in_order

    void Node::traverse_in_order() {
        if(left) left->traverse_in_order();
        process();
        if(right) right->traverse_in_order();
    }

    Questo significa anche controllare su OGNI sito di chiamata se si potrebbe avere una radice null.

  • Non utilizzare una funzione membro

    Ciò significa che stai scrivendo il vecchio codice di stile C (forse come metodo statico) e lo chiami esplicitamente con l'oggetto come parametro. per esempio. sei tornato a scrivere Node::traverse_in_order(node);piuttosto che node->traverse_in_order();sul sito di chiamata.

  • Credo che il modo più semplice / accurato per risolvere questo particolare esempio in modo conforme agli standard sia quello di utilizzare effettivamente un nodo sentinella anziché un nullptr.

    // static class, or global variable
    Node sentinel;
    
    void Node::traverse_in_order() {
        if(this == &sentinel) return;
        ...
    }

Nessuna delle prime due opzioni sembra così allettante, e mentre il codice potrebbe cavarsela, hanno scritto codice cattivo con this == nullptr invece di usare una correzione corretta.

Immagino che sia così che alcune di queste basi di codice si sono evolute per avere this == nullptrcontrolli in esse.


6
Come può 1 == 0essere un comportamento indefinito? È semplicemente false.
Johannes Schaub - litb

11
Il controllo stesso non è un comportamento indefinito. È sempre sempre falso e quindi eliminato dal compilatore.
Sergey,

15
Hmm .. this == nullptridioma è un comportamento indefinito perché prima hai chiamato una funzione membro su un oggetto nullptr, che non è definito. E il compilatore è libero di omettere il controllo
jtlim,

6
@Joshua, il primo standard è stato pubblicato nel 1998. Qualunque cosa accadesse prima era qualunque cosa desiderasse ogni implementazione. Anni oscuri.
SergeyA

26
Heh, wow, non posso credere che qualcuno abbia mai scritto codice che si basava sul chiamare funzioni di istanza ... senza un'istanza . Avrei istintivamente usato il brano "Fai tutti i controlli prima di chiamare traverse_in_order", senza nemmeno pensare di thisessere mai nulla . Immagino che forse questo sia il vantaggio di imparare il C ++ in un'epoca in cui esiste SO per radicare i pericoli di UB nel mio cervello e dissuadermi dal fare bizzarri hack come questo.
underscore_d

65

Lo fa perché il codice "pratico" è stato violato e all'inizio ha comportato un comportamento indefinito. Non c'è motivo di usare un null this, se non come una micro-ottimizzazione, di solito molto prematura.

È una pratica pericolosa, poiché la regolazione dei puntatori a causa del passaggio della gerarchia di classi può trasformare un valore null thisin uno non null. Quindi, per lo meno, la classe i cui metodi dovrebbero funzionare con un null thisdeve essere una classe finale senza classe base: non può derivare da nulla e non può essere derivata. Ci stiamo rapidamente allontanando dalla terra pratica a quella brutta .

In termini pratici, il codice non deve essere brutto:

struct Node
{
  Node* left;
  Node* right;
  void process();
  void traverse_in_order() {
    traverse_in_order_impl(this);
  }
private:
  static void traverse_in_order_impl(Node * n)
    if (!n) return;
    traverse_in_order_impl(n->left);
    n->process();
    traverse_in_order_impl(n->right);
  }
};

Se avevi un albero vuoto (es. Root è nullptr), questa soluzione si basa ancora su un comportamento indefinito chiamando traverse_in_order con nullptr.

Se l'albero è vuoto, noto anche come null Node* root, non dovresti chiamare alcun metodo non statico su di esso. Periodo. Va benissimo avere un codice ad albero di tipo C che accetta un puntatore di istanza da un parametro esplicito.

L'argomento qui sembra ridursi in qualche modo alla necessità di scrivere metodi non statici su oggetti che potrebbero essere chiamati da un puntatore a istanza null. Non è necessario. Il modo C-with-objects di scrivere tale codice è ancora molto più bello nel mondo C ++, perché può essere almeno sicuro. Fondamentalmente, il null thisè una tale micro-ottimizzazione, con un campo di applicazione così ristretto, che non consentirlo è perfettamente corretto. Nessuna API pubblica dovrebbe dipendere da un null this.


18
@Ben, chiunque abbia scritto questo codice ha sbagliato in primo luogo. È divertente che tu stia nominando progetti terribilmente falliti come MFC, Qt e Chromium. Buon viaggio con loro.
Sergey,

19
@Ben, i terribili stili di codifica in Google mi sono ben noti. Il codice di Google (almeno disponibile pubblicamente) è spesso scritto male, nonostante più persone credano che il codice di Google sia l'esempio brillante. Può essere questo li indurrà a rivisitare i loro stili di codifica (e le linee guida mentre ci sono).
Sergey,

18
@Ben Nessuno sostituisce retroattivamente Chromium su questi dispositivi con Chromium compilato usando gcc 6. Prima che Chromium venga compilato usando gcc 6 e altri compilatori moderni, dovrà essere riparato. Non è nemmeno un compito enorme; i thiscontrolli sono scelti da vari analizzatori di codici statici, quindi non è come se qualcuno dovesse cacciarli manualmente. La patch sarebbe probabilmente un paio di centinaia di righe di cambiamenti banali.
Ripristina Monica il

8
@Ben In termini pratici, una thisdereferenza nulla è un crash immediato. Questi problemi verranno individuati molto rapidamente anche se a nessuno interessa eseguire un analizzatore statico sul codice. C / C ++ segue il mantra "paga solo per le funzionalità che usi". Se vuoi i controlli, devi essere esplicito su di loro e questo significa non farli this, quando è troppo tardi, poiché il compilatore presume che thisnon sia nullo. Altrimenti dovrebbe controllare this, e per il 99,9999% di codice là fuori tali controlli sono una perdita di tempo.
Ripristina Monica il

10
il mio consiglio per chiunque pensi che lo standard sia rotto: usa una lingua diversa. Non mancano i linguaggi simili al C ++ che non hanno la possibilità di comportamenti indefiniti.
MM

35

Il documento di modifica lo definisce chiaramente pericoloso perché rompe una quantità sorprendente di codice utilizzato di frequente.

Il documento non lo definisce pericoloso. Né afferma che rompe una quantità sorprendente di codice . Sottolinea semplicemente alcune basi di codice popolari che afferma di essere noto per fare affidamento su questo comportamento indefinito e che si romperanno a causa della modifica a meno che non venga utilizzata l'opzione alternativa.

Perché questo nuovo presupposto spezzerebbe il pratico codice C ++?

Se il pratico codice c ++ si basa su un comportamento indefinito, le modifiche a quel comportamento indefinito possono interromperlo. Questo è il motivo per cui UB deve essere evitato, anche quando un programma che fa affidamento su di esso sembra funzionare come previsto.

Ci sono schemi particolari in cui i programmatori negligenti o disinformati si affidano a questo particolare comportamento indefinito?

Non so se è un anti- pattern molto diffuso , ma un programmatore non informato potrebbe pensare di poter riparare il loro programma dall'arresto anomalo facendo:

if (this)
    member_variable = 42;

Quando il vero bug sta dereferenziando un puntatore null altrove.

Sono sicuro che se il programmatore è abbastanza disinformato, sarà in grado di escogitare modelli più avanzati (anti) che si basano su questo UB.

Non riesco a immaginare nessuno che scriva if (this == NULL)perché è così innaturale.

Io posso.


11
"Se il codice c ++ pratico si basa su un comportamento indefinito, le modifiche a quel comportamento indefinito possono interromperlo. Ecco perché UB deve essere evitato" this * 1000
underscore_d

if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere(); Come un bel registro di facile lettura di una sequenza di eventi di cui un debugger non può parlarti facilmente. Divertiti a eseguire il debug ora senza passare ore a effettuare controlli ovunque quando c'è un improvviso nullo casuale in un set di dati di grandi dimensioni, nel codice che non hai scritto ... E la regola UB su questo è stata fatta in seguito, dopo la creazione di C ++. Era valido.
Stephane Hockenhull

@StephaneHockenhull Ecco a cosa -fsanitize=nullserve.
Eerorika,

@ user2079303 Problemi: questo rallenterà il codice di produzione al punto in cui non è possibile lasciare il check-in durante l'esecuzione, costando un sacco di soldi all'azienda? Aumenterà le dimensioni e non si adatta al flash? Funziona su tutte le piattaforme target, incluso Atmel? È possibile -fsanitize=nullregistrare gli errori sulla scheda SD / MMC sui pin # 5,6,10,11 utilizzando SPI? Questa non è una soluzione universale. Alcuni hanno sostenuto che è contrario ai principi orientati agli oggetti accedere a un oggetto null, ma alcuni linguaggi OOP hanno un oggetto null su cui può essere operato, quindi non è una regola universale di OOP. 1/2
Stephane Hockenhull

1
... un'espressione regolare che corrisponde a tali file? Dire che, ad esempio, se si accede a un lvalue due volte, un compilatore può consolidare gli accessi a meno che il codice tra loro non compia una delle varie cose specifiche sarebbe molto più semplice che tentare di definire le situazioni precise in cui il codice può accedere all'archiviazione.
supercat

25

Alcuni dei codici "pratici" (modo divertente di scrivere "buggy") che erano rotti apparivano così:

void foo(X* p) {
  p->bar()->baz();
}

e ha dimenticato di rendere conto del fatto che a p->bar()volte restituisce un puntatore nullo, il che significa che il dereferenziare che chiama baz()non è definito.

Non tutto il codice che era rotto conteneva espliciti if (this == nullptr)o if (!p) return;controlli. Alcuni casi erano semplicemente funzioni che non accedevano a nessuna variabile membro e quindi sembravano funzionare correttamente. Per esempio:

struct DummyImpl {
  bool valid() const { return false; }
  int m_data;
};
struct RealImpl {
  bool valid() const { return m_valid; }
  bool m_valid;
  int m_data;
};

template<typename T>
void do_something_else(T* p) {
  if (p) {
    use(p->m_data);
  }
}

template<typename T>
void func(T* p) {
  if (p->valid())
    do_something(p);
  else 
    do_something_else(p);
}

In questo codice quando si chiama func<DummyImpl*>(DummyImpl*)con un puntatore nullo c'è una dereferenza "concettuale" del puntatore da chiamare p->DummyImpl::valid(), ma in effetti quella funzione membro ritorna semplicemente falsesenza accedere *this. Ciò return falsepuò essere integrato e quindi in pratica non è necessario accedere al puntatore. Quindi con alcuni compilatori sembra funzionare bene: non c'è segfault per il dereferenziamento null, p->valid()è falso, quindi il codice chiama do_something_else(p), che controlla i puntatori null e quindi non fa nulla. Non si osservano arresti anomali o comportamenti imprevisti.

Con GCC 6 ricevi ancora la chiamata p->valid(), ma il compilatore ora deduce da quell'espressione che pdeve essere non nulla (altrimenti p->valid()sarebbe un comportamento indefinito) e prende nota di tali informazioni. Le informazioni dedotte vengono utilizzate do_something_else(p)dall'ottimizzatore in modo tale che se la chiamata a viene allineata, il if (p)controllo viene ora considerato ridondante, poiché il compilatore ricorda che non è nullo e quindi incorpora il codice in:

template<typename T>
void func(T* p) {
  if (p->valid())
    do_something(p);
  else {
    // inlined body of do_something_else(p) with value propagation
    // optimization performed to remove null check.
    use(p->m_data);
  }
}

Questo ora dereferenzia davvero un puntatore nullo, e quindi il codice che in precedenza sembrava funzionare smette di funzionare.

In questo esempio è presente il bug func, che dovrebbe aver prima verificato la presenza di null (oppure i chiamanti non avrebbero mai dovuto chiamarlo con null):

template<typename T>
void func(T* p) {
  if (p && p->valid())
    do_something(p);
  else 
    do_something_else(p);
}

Un punto importante da ricordare è che la maggior parte delle ottimizzazioni come questa non è un caso del compilatore che dice "ah, il programmatore ha testato questo puntatore contro null, lo rimuoverò solo per essere fastidioso". Quello che succede è che varie ottimizzazioni run-of-the-mill come inline e propagazione dell'intervallo di valori si combinano per rendere quei controlli ridondanti, perché arrivano dopo un controllo precedente o una dereference. Se il compilatore sa che un puntatore non è nullo nel punto A in una funzione e il puntatore non è cambiato prima di un successivo punto B nella stessa funzione, allora sa che è anche non nullo in B. Quando si verifica l'allineamento i punti A e B potrebbero in realtà essere parti di codice che erano originariamente in funzioni separate, ma ora sono combinate in una parte di codice e il compilatore è in grado di applicare la propria conoscenza del fatto che il puntatore non è nullo in più punti.


È possibile strumentare GCC 6 per emettere avvisi in fase di compilazione quando incontra tali usi this?
jotik,


3
@jotik, ^^^ cosa ha detto TC. Sarebbe possibile, ma riceveresti questo avviso PER TUTTO IL CODICE, PER TUTTO IL TEMPO . La propagazione dell'intervallo di valori è una delle ottimizzazioni più comuni, che interessa quasi tutto il codice, ovunque. Gli ottimizzatori vedono solo il codice, che può essere semplificato. Non vedono "un pezzo di codice scritto da un idiota che vuole essere avvisato se il loro stupido UB viene ottimizzato". Non è facile per il compilatore dire la differenza tra "controllo ridondante che il programmatore vuole essere ottimizzato" e "controllo ridondante che il programmatore pensa possa aiutare, ma è ridondante".
Jonathan Wakely,

1
Se vuoi strumentare il tuo codice per dare errori di runtime per vari tipi di UB, inclusi gli usi non validi di this, usa semplicemente-fsanitize=undefined
Jonathan Wakely,


-25

Lo standard C ++ è rotto in modi importanti. Sfortunatamente, anziché proteggere gli utenti da questi problemi, gli sviluppatori di GCC hanno scelto di utilizzare comportamenti indefiniti come scusa per implementare ottimizzazioni marginali, anche quando è stato chiaramente spiegato loro quanto sia dannoso.

Qui una persona molto più intelligente di quanto spieghi in dettaglio. (Sta parlando di C ma la situazione è la stessa lì).

Perché è dannoso?

La semplice ricompilazione del codice sicuro funzionante in precedenza con una versione più recente del compilatore può introdurre vulnerabilità della sicurezza . Mentre il nuovo comportamento può essere disabilitato con un flag, ovviamente i makefile esistenti non hanno quel flag impostato. E poiché non viene prodotto alcun avviso, per lo sviluppatore non è ovvio che il comportamento precedentemente ragionevole sia cambiato.

In questo esempio, lo sviluppatore ha incluso un controllo per l'overflow di numeri interi, utilizzando assert, che terminerà il programma se viene fornita una lunghezza non valida. Il team GCC ha rimosso il controllo sulla base del fatto che l'overflow di numeri interi non è definito, pertanto è possibile rimuovere il controllo. Ciò ha comportato la ri-vulnerabilità delle istanze reali di questa base di codice dopo che il problema è stato risolto.

Leggi tutto. È abbastanza per farti piangere.

OK, ma per quanto riguarda questo?

Molto tempo fa, c'era un idioma abbastanza comune che era simile a questo:

 OPAQUEHANDLE ObjectType::GetHandle(){
    if(this==NULL)return DEFAULTHANDLE;
    return mHandle;

 }

 void DoThing(ObjectType* pObj){
     osfunction(pObj->GetHandle(), "BLAH");
 }

Quindi il linguaggio è: se pObjnon è null, si utilizza l'handle che contiene, altrimenti si utilizza un handle predefinito. Questo è incapsulato nella GetHandlefunzione.

Il trucco è che chiamare una funzione non virtuale in realtà non fa alcun uso del thispuntatore, quindi non vi è alcuna violazione di accesso.

Ancora non capisco

Esiste molto codice scritto in questo modo. Se qualcuno semplicemente lo ricompila, senza cambiare linea, ogni chiamata a DoThing(NULL)è un bug che si arresta in modo anomalo - se sei fortunato.

Se non sei fortunato, le chiamate a bug che si arrestano in modo anomalo diventano vulnerabilità di esecuzione remota.

Questo può accadere anche automaticamente. Hai un sistema di compilazione automatizzato, giusto? L'aggiornamento all'ultimo compilatore è innocuo, giusto? Ma ora non lo è - non se il tuo compilatore è GCC.

OK, diglielo!

Gli è stato detto. Lo stanno facendo nella piena consapevolezza delle conseguenze.

ma perché?

Chi puo 'dirlo? Forse:

  • Apprezzano la purezza ideale del linguaggio C ++ rispetto al codice reale
  • Credono che le persone dovrebbero essere punite per non aver seguito lo standard
  • Non hanno comprensione della realtà del mondo
  • Stanno ... introducendo dei bug di proposito. Forse per un governo straniero. Dove vivi? Tutti i governi sono stranieri nella maggior parte del mondo e la maggior parte sono ostili ad alcuni del mondo.

O forse qualcos'altro. Chi puo 'dirlo?


32
Non sono d'accordo con ogni singola riga della risposta. Gli stessi commenti sono stati fatti per rigide ottimizzazioni di aliasing, e si spera che ora vengano respinti. La soluzione è educare gli sviluppatori, non prevenire ottimizzazioni basate su cattive abitudini di sviluppo.
Sergey,

30
Sono andato a leggere tutto come hai detto tu, e in effetti ho pianto, ma principalmente per la stupidità di Felix che non credo fosse quello che stavi cercando di superare ...
Mike Vine,

33
Downvoted per l'inutile sfogo. "Stanno ... introducendo dei bug di proposito. Forse per un governo straniero." Veramente? Questa non è / r / cospirazione.
isanae,

31
I programmatori decenti ripetono ripetutamente il mantra non invocano comportamenti indefiniti , eppure questi nonk sono andati avanti e lo hanno fatto comunque. E guarda cosa è successo. Non ho alcuna simpatia. Questa è colpa degli sviluppatori, così semplice. Devono assumersi la responsabilità. Ricordati che? Responsabilità personale? Le persone si affidano al tuo mantra "ma in pratica !" è esattamente come questa situazione è nata in primo luogo. Evitare assurdità come questa è precisamente il motivo per cui gli standard esistono in primo luogo. Codifica secondo gli standard e non avrai problemi. Periodo.
Razze di leggerezza in orbita

18
"La semplice ricompilazione del codice di sicurezza funzionante in precedenza con una versione più recente del compilatore può introdurre vulnerabilità della sicurezza": ciò accade sempre . A meno che tu non voglia imporre che una versione di un compilatore sia l'unico compilatore che sarà consentito per il resto dell'eternità. Ti ricordi quando il kernel di Linux poteva essere compilato solo con esattamente gcc 2.7.2.1? Il progetto gcc è stato addirittura biforcuto perché le persone erano stufate di bullcrap. Ci è voluto molto tempo per superarlo.
MM
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.