Y a-t-il réellement une raison pour laquelle && et || ne pas court-circuiter?

Le comportement de court-circuit des opérateurs && et || est un outil incroyable pour les programmeurs.

Mais pourquoi perdent-ils ce comportement en cas de surcharge? Je comprends que les opérateurs ne sont que du sucre syntaxique pour les fonctions, mais les opérateurs de bool ont ce comportement, pourquoi devrait-il être limité à ce type unique? Y a-t-il un raisonnement technique derrière cela?

Tous les processus de conception entraînent des compromis entre des objectives incompatibles entre eux. Malheureusement, le processus de conception de l’opérateur surchargé && en C ++ a produit un résultat final déroutant: la fonctionnalité que vous souhaitez utiliser dans && – son comportement de court-circuit – est omise.

Les détails de la façon dont ce processus de conception a abouti dans ce lieu malheureux, ceux que je ne connais pas. Il est toutefois pertinent de voir comment un processus de conception ultérieur a pris en compte ce résultat désagréable. En C #, l’opérateur surchargé && est en court-circuit. Comment les concepteurs de C # ont-ils réalisé cela?

Une des autres réponses suggère “lambda lifting”. C’est:

 A && B 

pourrait être réalisé comme quelque chose de moralement équivalent à:

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

où le second argument utilise un mécanisme d’évaluation paresseuse pour que, une fois évalués, les effets secondaires et la valeur de l’expression soient produits. L’implémentation de l’opérateur surchargé ne ferait l’évaluation paresseuse que si nécessaire.

Ce n’est pas ce que l’équipe de conception de C # a fait. (À part: bien que la levée lambda soit ce que j’ai fait quand est venu le temps de faire une représentation d’arbre d’expression de l’opérateur ?? , ce qui nécessite certaines opérations de conversion paresseusement. Décrire cela en détail serait cependant une digression majeure. lambda lifting fonctionne mais est suffisamment lourd que nous voulions l’éviter.)

La solution C # divise plutôt le problème en deux problèmes distincts:

  • devrions-nous évaluer l’opérande de droite?
  • Si la réponse à ce qui précède était “oui”, alors comment pouvons-nous combiner les deux opérandes?

Par conséquent, le problème est résolu en rendant illégal de surcharger directement && . En C #, vous devez plutôt surcharger deux opérateurs, chacun répondant à l’une de ces deux questions.

 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 } ... 

(Mis à part: en fait, trois. C # exige que si l’opérateur false est fourni, l’opérateur true doit également être fourni, ce qui répond à la question: cette chose est-elle vraie?) donc C # nécessite les deux.)

Considérons une déclaration de la forme:

 C cresult = cleft && cright; 

Le compilateur génère du code pour cela, car vous avez écrit ce pseudo-C #:

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

Comme vous pouvez le voir, le côté gauche est toujours évalué. S’il est déterminé qu’il est “faux-ish”, alors c’est le résultat. Sinon, le côté droit est évalué, et l’opérateur défini par l’utilisateur désirant & est appelé.

Le || L’opérateur est défini de manière analogue, comme invocation de l’opérateur true et du désir | opérateur:

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

En définissant les quatre opérateurs – true , false , & et | – C # vous permet non seulement de dire cleft && cright mais aussi non-court-circuitant cleft & cright , et aussi if (cleft) if (cright) ... , et c ? consequence : alternative c ? consequence : alternative et while(c) , et ainsi de suite.

Maintenant, j’ai dit que tous les processus de conception sont le résultat d’un compromis. Ici, les concepteurs de langage C # ont réussi à court-circuiter && et || droit, mais cela nécessite de surcharger quatre opérateurs au lieu de deux , ce que certaines personnes trouvent déroutant. L’opérateur true / false est l’une des fonctionnalités les moins bien comsockets dans C #. L’objective de disposer d’un langage simple et intuitif familier aux utilisateurs de C ++ était contrarié par le désir de court-circuiter et le désir de ne pas implémenter le lambda lifting ou d’autres formes d’évaluation paresseuse. Je pense que c’était une position de compromis raisonnable, mais il est important de réaliser que c’est une position de compromis. Juste une position de compromis différente de celle des concepteurs de C ++.

Si le sujet de la conception du langage pour de tels opérateurs vous intéresse, envisagez de lire mes séries sur les raisons pour lesquelles C # ne définit pas ces opérateurs sur les booléens nullables:

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

Le point est que (dans les limites de C ++ 98), l’opérande de droite serait passé en argument à la fonction de l’opérateur surchargé. Ce faisant, il serait déjà évalué . Il n’y a rien que l’ operator||() ou l’ operator&&() puisse ou ne puisse pas faire pour éviter cela.

L’opérateur d’origine est différent, car ce n’est pas une fonction, mais implémenté à un niveau inférieur du langage.

Des fonctionnalités de langage supplémentaires pourraient rendre la non-évaluation de l’opérande droit possible sur le plan syntaxique. Cependant, ils ne se sont pas souciés du fait qu’il n’y a que quelques rares cas où cela serait sémantiquement utile. (Juste comme ? : , Qui n’est pas disponible pour surcharger du tout.

(Il leur a fallu 16 ans pour faire entrer les lambdas dans la norme …)

Pour l’utilisation sémantique, considérez:

 objectA && objectB 

Cela se résume à:

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

Pensez à ce que vous voulez faire exactement avec objectB (de type inconnu) ici, en dehors de l’appel d’un opérateur de conversion à bool , et de la façon dont vous mettez cela en mots pour la définition du langage.

Et si vous appelez conversion en bool, eh bien …

 objectA && obectB 

fait la même chose, maintenant le fait? Alors pourquoi surcharger en premier lieu?

Une fonctionnalité doit être pensée, conçue, implémentée, documentée et expédiée.

Maintenant, nous y avons pensé, voyons pourquoi cela pourrait être facile maintenant (et difficile à faire). Gardez également à l’esprit qu’il n’ya qu’une quantité limitée de ressources, donc l’append pourrait avoir coupé quelque chose d’autre (que voudriez-vous renoncer à cela?).


En théorie, tous les opérateurs pourraient autoriser un comportement de court-circuit avec une seule fonctionnalité de langue supplémentaire “mineure”, à partir de C ++ 11 (lorsque les lambdas ont été introduits, 32 ans après le “C avec les classes” après c ++ 98):

C ++ aurait juste besoin d’un moyen d’annoter un argument évalué paresseux – un lambda caché – pour éviter l’évaluation jusqu’à ce que cela soit nécessaire et autorisé (pré-conditions remplies).


À quoi ressemblerait cette caractéristique théorique (Rappelez-vous que toute nouvelle fonctionnalité devrait être largement utilisable)?

Une annotation lazy , appliquée à un argument de fonction, fait de la fonction un modèle qui attend un foncteur et fait que le compilateur emballe l’expression dans un foncteur:

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

Il semblerait sous la couverture comme:

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

Prenez note que le lambda rest caché et sera appelé au plus une fois.
Il ne devrait y avoir aucune dégradation des performances en raison de la réduction des chances de sous-expression-élimination commune.


Outre la complexité de l’implémentation et la complexité conceptuelle (chaque fonctionnalité augmente à la fois, sauf si elle simplifie suffisamment ces complexités pour d’autres fonctionnalités), examinons une autre considération importante: la compatibilité ascendante.

Bien que cette fonctionnalité de langage ne brise aucun code, elle modifierait subtilement toute API en tirant parti, ce qui signifie que toute utilisation dans les bibliothèques existantes serait une modification silencieuse.

BTW: Cette fonctionnalité, bien que plus facile à utiliser, est ssortingctement plus puissante que la solution C # de fractionnement && et || en deux fonctions chacune pour une définition séparée.

Avec la rationalisation rétrospective, principalement parce que

  • pour avoir un court-circuit garanti (sans introduire de nouvelle syntaxe), les opérateurs devraient être limités à résultats premier argument réel convertible en bool , et

  • le court-circuit peut être facilement exprimé d’autres manières, si nécessaire.


Par exemple, si une classe T a associé && et || opérateurs, puis l’expression

 auto x = a && b || c; 

a , b et c sont des expressions de type T , peuvent être exprimées avec un court-circuit comme

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

ou peut-être plus clairement que

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

La redondance apparente préserve les effets secondaires des invocations de l’opérateur.


Alors que la réécriture lambda est plus verbeuse, sa meilleure encapsulation permet de définir de tels opérateurs.

Je ne suis pas tout à fait sûr de la conformité standard de tous les éléments suivants (encore un peu d’influence), mais il comstack proprement avec Visual C ++ 12.0 (2013) et 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; } } } } 

Sortie:

 000 -> !!  !!  ||  faux
 001 -> !!  !!  ||  vrai
 010 -> !!  !!  ||  faux
 011 -> !!  !!  ||  vrai
 100 -> !!  && !!  ||  faux
 101 -> !!  && !!  ||  vrai
 110 -> !!  && !!  vrai
 111 -> !!  && !!  vrai

Ici chacun !! bang-bang montre une conversion en bool , c'est-à-dire une vérification de valeur d'argument.

Comme un compilateur peut facilement faire la même chose et l’optimiser en plus, il s’agit d’une implémentation démontrée et toute impossibilité doit être classée dans la même catégorie que les revendications d’impossibilité en général, à savoir généralement les bornes.

tl; dr : cela ne vaut pas la peine, en raison de la très faible demande (qui utiliserait la fonctionnalité?) par rapport à des coûts plutôt élevés (syntaxe spéciale nécessaire).

La première chose qui vient à l’esprit est que la surcharge d’opérateurs n’est qu’une façon élégante d’écrire des fonctions, alors que la version booléenne des opérateurs || et && sont des trucs de buitlin. Cela signifie que le compilateur a la liberté de les court-circuiter, tandis que l’expression x = y && z avec y et z booléen doit conduire à un appel à une fonction telle que l’ X operator&& (Y, Z) . Cela voudrait dire que y && z est juste un moyen compliqué d’écrire l’ operator&&(y,z) qui est juste un appel d’une fonction nommée de façon étrange où les deux parameters doivent être évalués avant d’appeler la fonction (y compris circuit approprié).

Cependant, on pourrait faire valoir qu’il devrait être possible de rendre la traduction des opérateurs && un peu plus sophistiquée, comme c’est le cas pour le new opérateur qui est traduit en appelant l’ operator new fonction operator new suivi d’un appel de constructeur.

Techniquement, cela ne poserait aucun problème, il faudrait définir une syntaxe de langage spécifique à la condition préalable qui permet la mise en court-circuit. Cependant, l’utilisation de courts-circuits serait restreinte aux cas où Y est convetible à X , ou alors il devrait y avoir des informations supplémentaires sur la manière de réaliser le court-circuit (c.-à-d. Calculer le résultat à partir du premier paramètre uniquement). Le résultat devrait ressembler à ceci:

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

On veut rarement surcharger l’ operator|| et operator&& , car il est rare que l’écriture d’ a && b soit intuitive dans un contexte non booléen. Les seules exceptions que je connaisse sont les modèles d’expression, par exemple pour les DSL incorporés. Et seule une poignée de ces rares cas bénéficierait d’une évaluation en court-circuit. Les modèles d’expression ne le font généralement pas, car ils sont utilisés pour former des arborescences d’expression évaluées ultérieurement. Vous avez donc toujours besoin des deux côtés de l’expression.

En bref: ni les auteurs de compilateurs ni les auteurs de normes n’ont ressenti le besoin de sauter et de définir et d’implémenter une syntaxe compliquée, simplement parce qu’un million pourrait avoir l’idée de court-circuiter les operator&& et les operator|| – juste pour arriver à la conclusion que ce n’est pas moins d’effort que d’écrire la logique par main.

La mise en court-circuit des opérateurs logiques est autorisée car il s’agit d’une “optimisation” dans l’évaluation des tables de vérité associées. C’est une fonction de la logique elle-même et cette logique est définie.

Y a-t-il réellement une raison pour laquelle && et || ne pas court-circuiter?

Les opérateurs logiques surchargés personnalisés ne sont pas obligés de suivre la logique de ces tables de vérité.

Mais pourquoi perdent-ils ce comportement en cas de surcharge?

Par conséquent, la fonction entière doit être évaluée selon la norme. Le compilateur doit le traiter comme un opérateur (ou une fonction) surchargé normal et il peut toujours appliquer des optimisations comme avec toute autre fonction.

Les utilisateurs surchargent les opérateurs logiques pour diverses raisons. Par exemple; ils peuvent avoir une signification spécifique dans un domaine spécifique qui n’est pas la logique normale à laquelle les gens sont habitués.

Le court-circuit est dû à la table de vérité de “et” et “ou”. Comment savez-vous quelle opération l’utilisateur va définir et comment savez-vous que vous ne devez pas évaluer le deuxième opérateur?

Lambdas n’est pas la seule façon d’introduire la paresse. L’évaluation différée est relativement simple en utilisant des modèles d’expression en C ++. Le mot clé lazy n’est pas nécessaire et peut être implémenté en C ++ 98. Les arbres d’expression sont déjà mentionnés ci-dessus. Les modèles d’expression sont des arbres d’expression pauvres (mais intelligents) de l’homme. L’astuce consiste à convertir l’expression en une arborescence d’instanciations récursivement nestedes du modèle Expr . L’arbre est évalué séparément après la construction.

Le code suivant implémente && et || opérateurs pour la classe S tant qu’elle fournit des fonctions logical_and et logical_or free et qu’elle est convertible en bool . Le code est en C ++ 14 mais l’idée est également applicable en C ++ 98. Voir exemple en direct .

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

mais les opérateurs de bool ont ce comportement, pourquoi devrait-il être restreint à ce type unique?

Je veux juste répondre à cette partie. La raison en est que le && et || les expressions ne sont pas implémentées avec des fonctions comme le sont les opérateurs surchargés.

Avoir la logique de court-circuit intégrée à la compréhension des expressions spécifiques par le compilateur est facile. C’est comme n’importe quel autre stream de contrôle intégré.

Mais la surcharge d’opérateur est implémentée avec des fonctions qui ont des règles particulières, dont l’une est que toutes les expressions utilisées comme arguments sont évaluées avant l’appel de la fonction. De toute évidence, des règles différentes peuvent être définies, mais c’est un travail plus important.