Esiste davvero una ragione per cui overload && e || non cortocircuitare?

Il comportamento di cortocircuito degli operatori && e || è uno strumento straordinario per i programmatori.

Ma perché perdono questo comportamento quando sono sovraccarichi? Capisco che gli operatori sono semplicemente zucchero sintattico per le funzioni, ma gli operatori di bool hanno questo comportamento, perché dovrebbe essere limitato a questo singolo tipo? C’è qualche ragionamento tecnico dietro questo?

Tutti i processi di progettazione portano a compromessi tra obiettivi reciprocamente incompatibili. Sfortunatamente, il processo di progettazione per l’operatore && sovraccarico in C ++ ha prodotto un risultato finale confuso: che la funzione che si desidera da && – il suo comportamento di cortocircuito – viene omessa.

I dettagli di come quel processo di progettazione è finito in questo posto sfortunato, quelli che non conosco. È tuttavia importante vedere come una successiva progettazione abbia preso in considerazione questo risultato spiacevole. In C #, l’operatore && sovraccarico sta cortocircuitando. Come hanno fatto i progettisti di C # a farlo?

Una delle altre risposte suggerisce “lifting lambda”. Questo è:

 A && B 

potrebbe essere realizzato come qualcosa di moralmente equivalente a:

 operator_&& ( A, ()=> B ) 

dove il secondo argomento utilizza un meccanismo per la valutazione lazy in modo che, una volta valutati, vengano prodotti gli effetti collaterali e il valore dell’espressione. L’implementazione dell’operatore sovraccarico farebbe solo la valutazione pigra quando necessario.

Questo non è ciò che ha fatto il team di progettazione C #. (A parte: anche se il sollevamento lambda è quello che ho fatto quando è arrivato il momento di fare la rappresentazione dell’albero dell’espressione dell’operatore ?? , che richiede alcune operazioni di conversione da eseguire pigramente. Descrivere che in dettaglio sarebbe comunque una grande digressione. lavori di sollevamento di lambda ma sono sufficientemente pesanti che volevamo evitarlo).

Piuttosto, la soluzione C # risolve il problema in due problemi separati:

  • dovremmo valutare l’operando di destra?
  • se la risposta a quanto sopra fosse “sì”, allora come combineremo i due operandi?

Pertanto il problema viene risolto rendendo illegale sovraccaricare && direttamente. Piuttosto, in C # è necessario sovraccaricare due operatori, ognuno dei quali risponde a una di queste due domande.

 class C { // Is this thing "false-ish"? If yes, we can skip computing the right // hand size of an && public static bool operator false (C c) { whatever } // If we didn't skip the RHS, how do we combine them? public static C operator & (C left, C right) { whatever } ... 

(A parte: in realtà, tre C # richiede che se sia fornito l’operatore false allora deve essere fornito anche l’operatore true , che risponde alla domanda: è questa cosa “true-ish?” In genere non ci sarebbe motivo di fornire un solo operatore di questo tipo quindi C # richiede entrambi).

Considera una dichiarazione del modulo:

 C cresult = cleft && cright; 

Il compilatore genera il codice per questo come se avessi scritto questo pseudo-C #:

 C cresult; C tempLeft = cleft; cresult = C.false(tempLeft) ? tempLeft : C.&(tempLeft, cright); 

Come puoi vedere, il lato sinistro viene sempre valutato. Se è determinato a essere “falso-ish”, allora è il risultato. In caso contrario, viene valutata la parte destra e l’operatore definito dall’utente desideroso & viene richiamato.

|| l’operatore è definito in modo analogo, come un’invocazione di operatore vero e desideroso | operatore:

 cresult = C.true(tempLeft) ? tempLeft : C.|(tempLeft , cright); 

Definendo tutti e quattro gli operatori: true , false , & e | – C # ti permette di dire non solo cleft && cright ma anche di non-cortocircuitare cleft & cright , e anche if (cleft) if (cright) ... , c ? consequence : alternative c ? consequence : alternative e while(c) , e così via.

Ora, ho detto che tutti i processi di progettazione sono il risultato di un compromesso. Qui i progettisti di linguaggio C # sono riusciti a ottenere cortocircuiti && e || giusto, ma farlo richiede di sovraccaricare quattro operatori invece di due , cosa che alcune persone trovano confusa. La funzione vero / falso dell’operatore è una delle funzioni meno comprese in C #. L’objective di avere un linguaggio sensibile e diretto che sia familiare agli utenti del C ++ è stato contrastato dal desiderio di avere cortocircuiti e dal desiderio di non implementare il sollevamento di lambda o altre forms di valutazione pigra. Penso che fosse una posizione di compromesso ragionevole, ma è importante rendersi conto che si tratta di una posizione di compromesso. Solo una diversa posizione di compromesso rispetto a quella dei progettisti di C ++.

Se l’argomento della progettazione della lingua per tali operatori ti interessa, considera di leggere le mie serie sul perché C # non definisce questi operatori su valori booleani nullable:

http://ericlippert.com/2012/03/26/null-is-not-false-part-one/

Il punto è che (entro i limiti di C ++ 98) l’operando di destra sarebbe passato alla funzione dell’operatore sovraccarico come argomento. In tal modo, sarebbe già stato valutato . Non c’è nulla che il codice operator||() o operator&&() potrebbe o non potrebbe fare per evitarlo.

L’operatore originale è diverso, perché non è una funzione, ma implementato a un livello inferiore della lingua.

Ulteriori caratteristiche linguistiche avrebbero potuto rendere sintatticamente imansible la valutazione dell’operando della mano destra. Tuttavia, non hanno infastidito perché ci sono solo pochi casi in cui ciò sarebbe semanticamente utile. (Proprio come ? : , Che non è disponibile per il sovraccarico.

(Ci sono voluti 16 anni per ottenere lambda nello standard …)

Per quanto riguarda l’uso semantico, considera:

 objectA && objectB 

Questo si riduce a:

 template< typename T > ClassA.operator&&( T const & objectB ) 

Pensa a cosa ti piacerebbe fare esattamente con l’object B (di tipo sconosciuto) qui, oltre a chiamare un operatore di conversione al bool e come lo inseriresti in parole per la definizione della lingua.

E se chiami la conversione in bool, beh …

 objectA && obectB 

fa la stessa cosa, ora lo fa? Quindi perché sovraccaricare in primo luogo?

Una caratteristica deve essere pensata, progettata, implementata, documentata e spedita.

Ora ci abbiamo pensato, vediamo perché potrebbe essere facile ora (e difficile da fare allora). Ricorda anche che c’è solo una quantità limitata di risorse, quindi aggiungerla potrebbe aver tagliato qualcos’altro (cosa ti piacerebbe rinunciare per questo?).


In teoria, tutti gli operatori potevano consentire un comportamento di cortocircuito con una sola caratteristica linguistica aggiuntiva “minore”, come in C ++ 11 (quando furono introdotti i lambda, 32 anni dopo “C con le classi” iniziata nel 1979, un 16 rispettabile. dopo c ++ 98):

C ++ avrebbe solo bisogno di un modo per annotare un argomento come lazy-valutato – un lambda nascosto – per evitare la valutazione fino a quando necessario e permesso (pre-condizioni soddisfatte).


Che aspetto avrebbe questa caratteristica teorica (ricordate che qualsiasi nuova funzionalità dovrebbe essere ampiamente utilizzabile)?

Un’annotazione lazy , che si applica a un argomento di funzione, rende la funzione un modello che si aspetta un funtore e fa in modo che il compilatore comprenda l’espressione in un functor:

 A operator&&(B b, __lazy C c) {return c;} // And be called like exp_b && exp_c; // or operator&&(exp_b, exp_c); 

Guarderebbe sotto la copertura come:

 template A operator&&(B b, Func& f) {auto&& c = f(); return c;} // With `f` restricted to no-argument functors returning a `C`. // And the call: operator&&(exp_b, [&]{return exp_c;}); 

Prendete nota speciale che la lambda rimane nascosta e verrà chiamata al massimo una volta.
Non ci dovrebbe essere alcun degrado delle prestazioni a causa di questo, a parte le ridotte possibilità di eliminazione della sottoespressione comune.


Oltre all’implementazione, complessità e complessità concettuale (ogni funzionalità aumenta entrambe, a meno che non riduca sufficientemente tali complessità per alcune altre funzionalità), diamo un’occhiata a un’altra considerazione importante: la compatibilità con le versioni precedenti.

Anche se questa caratteristica linguistica non infrange alcun codice, cambierebbe in modo subitaneo qualsiasi API che ne approfitti, il che significa che qualsiasi utilizzo nelle librerie esistenti sarebbe un cambiamento silenzioso.

BTW: Questa caratteristica, sebbene più facile da usare, è rigorosamente più forte della soluzione C # di splitting && e || in due funzioni ciascuna per definizione separata.

Con razionalizzazione retrospettiva, principalmente perché

  • per garantire un cortocircuito (senza introdurre nuova syntax), gli operatori dovrebbero essere limitati a risultati primo argomento effettivo convertibile in bool , e

  • il cortocircuito può essere facilmente express in altri modi, quando necessario.


Ad esempio, se una class T ha associato && e || operatori, quindi l’espressione

 auto x = a && b || c; 

dove a , c sono espressioni di tipo T , possono essere espressi con cortocircuito come

 auto&& and_arg = a; auto&& and_result = (and_arg? and_arg && b : and_arg); auto x = (and_result? and_result : and_result || c); 

o forse più chiaramente come

 auto x = [&]() -> T_op_result { auto&& and_arg = a; auto&& and_result = (and_arg? and_arg && b : and_arg); if( and_result ) { return and_result; } else { return and_result || b; } }(); 

L’apparente ridondanza preserva qualsiasi effetto collaterale dalle chiamate dell’operatore.


Mentre la riscrittura lambda è più prolissa, il suo migliore incapsulamento consente di definire tali operatori.

Non sono completamente sicuro della conformità standard di tutti i seguenti (ancora un po ‘di influensa), ma si compila in modo pulito con Visual C ++ 12.0 (2013) e MinGW g ++ 4.8.2:

 #include  using namespace std; void say( char const* s ) { cout << s; } struct S { using Op_result = S; bool value; auto is_true() const -> bool { say( "!! " ); return value; } friend auto operator&&( S const a, S const b ) -> S { say( "&& " ); return a.value? b : a; } friend auto operator||( S const a, S const b ) -> S { say( "|| " ); return a.value? a : b; } friend auto operator<<( ostream& stream, S const o ) -> ostream& { return stream << o.value; } }; template< class T > auto is_true( T const& x ) -> bool { return !!x; } template<> auto is_true( S const& x ) -> bool { return x.is_true(); } #define SHORTED_AND( a, b ) \ [&]() \ { \ auto&& and_arg = (a); \ return (is_true( and_arg )? and_arg && (b) : and_arg); \ }() #define SHORTED_OR( a, b ) \ [&]() \ { \ auto&& or_arg = (a); \ return (is_true( or_arg )? or_arg : or_arg || (b)); \ }() auto main() -> int { cout << boolalpha; for( int a = 0; a <= 1; ++a ) { for( int b = 0; b <= 1; ++b ) { for( int c = 0; c <= 1; ++c ) { S oa{!!a}, ob{!!b}, oc{!!c}; cout << a << b << c << " -> "; auto x = SHORTED_OR( SHORTED_AND( oa, ob ), oc ); cout << x << endl; } } } } 

Produzione:

 000 -> !!  !!  ||  falso
 001 -> !!  !!  ||  vero
 010 -> !!  !!  ||  falso
 011 -> !!  !!  ||  vero
 100 -> !!  && !!  ||  falso
 101 -> !!  && !!  ||  vero
 110 -> !!  && !!  vero
 111 -> !!  && !!  vero

Qui ognuno !! bang-bang mostra una conversione in bool , cioè un controllo del valore di argomento.

Poiché un compilatore può facilmente fare lo stesso e, in aggiunta, ottimizzarlo, si tratta di un'attuazione ansible dimostrata e qualsiasi rivendicazione di impossibilità deve essere inserita nella stessa categoria delle affermazioni sull'impossibilità in generale, vale a dire, in generale, bollock.

tl; dr : non vale la pena, a causa della domanda molto bassa (chi userebbe la funzione?) rispetto ai costi piuttosto elevati (necessaria una syntax speciale).

La prima cosa che viene in mente è che l’overloading dell’operatore è solo un modo elegante per scrivere funzioni, mentre la versione booleana degli operatori || e && sono cose Buitlin. Ciò significa che il compilatore ha la libertà di cortocircuitarli, mentre l’espressione x = y && z con nonboolean y e z deve condurre a una chiamata a una funzione come X operator&& (Y, Z) . Ciò significherebbe che y && z è solo un modo ingegnoso per scrivere l’ operator&&(y,z) che è solo una chiamata di una funzione con un nome strano dove entrambi i parametri devono essere valutati prima di chiamare la funzione (incluso qualsiasi cosa che ritenga un breve circuiting appropiate).

Tuttavia, si potrebbe sostenere che dovrebbe essere ansible rendere la traduzione degli operatori && un po ‘più sofisticata, come è per il new operatore che viene tradotto chiamando l’ operator new della funzione operator new seguito da una chiamata del costruttore.

Tecnicamente questo non sarebbe un problema, bisognerebbe definire una syntax del linguaggio specifica per la precondizione che abilita il cortocircuito. Tuttavia, l’uso di cortocircuiti sarebbe limitato ai casi in cui Y è convalidabile a X , oppure dovevano esserci ulteriori informazioni su come effettivamente effettuare il cortocircuito (cioè calcolare il risultato solo dal primo parametro). Il risultato dovrebbe apparire un po ‘come questo:

 X operator&&(Y const& y, Z const& z) { if (shortcircuitCondition(y)) return shortcircuitEvaluation(y); <"Syntax for an evaluation-Point for z here"> return actualImplementation(y,z); } 

Raramente si vuole sovraccaricare l’ operator|| e operator&& , perché raramente c’è un caso in cui la scrittura di a && b è in realtà intuitiva in un contesto nonbooleano. Le uniche eccezioni che conosco sono i modelli di espressione, ad esempio per i DSL incorporati. E solo una manciata di quei pochi casi trarrebbe beneficio dalla valutazione del cortocircuito. I modelli di espressioni di solito no, perché sono usati per formare alberi di espressioni che vengono valutati in seguito, quindi hai sempre bisogno di entrambi i lati dell’espressione.

In breve: né gli scrittori di compilatori né gli autori degli standard hanno sentito il bisogno di saltare i cerchi e definire e implementare un’ulteriore syntax macchinosa, solo perché uno su un milione potrebbe avere l’idea che sarebbe bello avere un cortocircuito sull’operatore definito dall’utente e sull’operatore operator|| – solo per arrivare alla conclusione che non è meno sforzo di scrivere la logica per mano.

Il cortocircuito degli operatori logici è consentito perché è un “ottimizzazione” nella valutazione delle tabelle di verità associate. È una funzione della logica stessa e questa logica è definita.

Esiste davvero una ragione per cui overload && e || non cortocircuitare?

Gli operatori logici personalizzati in sovraccarico non sono obbligati a seguire la logica di queste tabelle di verità.

Ma perché perdono questo comportamento quando sono sovraccarichi?

Quindi l’intera funzione deve essere valutata come normale. Il compilatore deve trattarlo come un normale operatore (o funzione) sovraccarico e può ancora applicare le ottimizzazioni come con qualsiasi altra funzione.

Le persone sovraccaricano gli operatori logici per una serie di motivi. Per esempio; possono avere un significato specifico in un dominio specifico che non è quello logico “normale” a cui le persone sono abituate.

Il cortocircuito è dovuto alla tabella di verità di “e” e “o”. Come faresti a sapere quale operazione l’utente definirà e come sapresti che non dovrai valutare il secondo operatore?

Lambdas non è l’unico modo per introdurre la pigrizia. La valutazione pigra è relativamente semplice usando Expression Templates in C ++. Non è necessaria la parola chiave lazy e può essere implementata in C ++ 98. Gli alberi delle espressioni sono già menzionati sopra. I modelli di espressione sono alberi di espressione dell’uomo poveri (ma intelligenti). Il trucco è convertire l’espressione in un albero di istanze ricorsivamente annidate del modello Expr . L’albero viene valutato separatamente dopo la costruzione.

Il codice seguente implementa && e || operatori per la class S purché fornisca funzioni logical_and e logical_or e gratuite e sia convertibile in bool . Il codice è in C ++ 14 ma l’idea è applicabile anche in C ++ 98. Guarda l’esempio dal vivo .

 #include  struct S { bool val; explicit S(int i) : val(i) {} explicit S(bool b) : val(b) {} template  S (const Expr & expr) : val(evaluate(expr).val) { } template  S & operator = (const Expr & expr) { val = evaluate(expr).val; return *this; } explicit operator bool () const { return val; } }; S logical_and (const S & lhs, const S & rhs) { std::cout << "&& "; return S{lhs.val && rhs.val}; } S logical_or (const S & lhs, const S & rhs) { std::cout << "|| "; return S{lhs.val || rhs.val}; } const S & evaluate(const S &s) { return s; } template  S evaluate(const Expr & expr) { return expr.eval(); } struct And { template  S operator ()(const LExpr & l, const RExpr & r) const { const S & temp = evaluate(l); return temp? logical_and(temp, evaluate(r)) : temp; } }; struct Or { template  S operator ()(const LExpr & l, const RExpr & r) const { const S & temp = evaluate(l); return temp? temp : logical_or(temp, evaluate(r)); } }; template  struct Expr { Op op; const LExpr &lhs; const RExpr &rhs; Expr(const LExpr& l, const RExpr & r) : lhs(l), rhs(r) {} S eval() const { return op(lhs, rhs); } }; template  auto operator && (const LExpr & lhs, const S & rhs) { return Expr (lhs, rhs); } template  auto operator && (const LExpr & lhs, const Expr & rhs) { return Expr> (lhs, rhs); } template  auto operator || (const LExpr & lhs, const S & rhs) { return Expr (lhs, rhs); } template  auto operator || (const LExpr & lhs, const Expr & rhs) { return Expr> (lhs, rhs); } std::ostream & operator << (std::ostream & o, const S & s) { o << s.val; return o; } S and_result(S s1, S s2, S s3) { return s1 && s2 && s3; } S or_result(S s1, S s2, S s3) { return s1 || s2 || s3; } int main(void) { for(int i=0; i<= 1; ++i) for(int j=0; j<= 1; ++j) for(int k=0; k<= 1; ++k) std::cout << and_result(S{i}, S{j}, S{k}) << std::endl; for(int i=0; i<= 1; ++i) for(int j=0; j<= 1; ++j) for(int k=0; k<= 1; ++k) std::cout << or_result(S{i}, S{j}, S{k}) << std::endl; return 0; } 

ma gli operatori di bool hanno questo comportamento, perché dovrebbe essere limitato a questo singolo tipo?

Voglio solo rispondere a questa parte. Il motivo è che il built-in && e || le espressioni non sono implementate con funzioni come gli operatori sovraccaricati.

Avere la logica di cortocircuito integrata nella comprensione del compilatore di espressioni specifiche è facile. È proprio come qualsiasi altro stream di controllo incorporato.

Ma l’overloading dell’operatore è implementato con le funzioni, che hanno regole particolari, una delle quali è che tutte le espressioni usate come argomenti vengono valutate prima che la funzione venga chiamata. Ovviamente si potrebbero definire regole diverse, ma questo è un lavoro più grande.