Le comportement en court-circuit des opérateurs &&
et ||
est un outil étonnant pour les programmeurs.
Mais pourquoi perdent-ils ce comportement lorsqu'ils sont surchargés? Je comprends que les opérateurs ne sont que du sucre syntaxique pour les fonctions mais les opérateurs pour bool
ont ce comportement, pourquoi devrait-il être limité à ce seul type? Y a-t-il un raisonnement technique derrière cela?
Tous les processus de conception entraînent des compromis entre des objectifs mutuellement incompatibles. Malheureusement, le processus de conception de l'opérateur &&
Surchargé en C++ a produit un résultat final déroutant: la fonctionnalité même que vous voulez de &&
- son comportement de court-circuit - est omise.
Les détails de la façon dont ce processus de conception s'est terminé dans cet endroit malheureux, ceux que je ne connais pas. Il est cependant 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 court-circuité. Comment les concepteurs de C # y sont-ils parvenus?
Une des autres réponses suggère un "lifting lambda". C'est:
A && B
pourrait être réalisé comme quelque chose de moralement équivalent à:
operator_&& ( A, ()=> B )
où le deuxième argument utilise un mécanisme d'évaluation paresseuse de sorte que lorsqu'il est évalué, les effets secondaires et la valeur de l'expression soient produits. L'implémentation de l'opérateur surchargé ne ferait l'évaluation paresseuse qu'en cas de besoin.
Ce n'est pas ce que l'équipe de conception C # a fait. (À part: si la levée lambda est ce que j'ai fait quand est venu le temps de faire la représentation de l'arbre d'expression de l'opérateur ??
, qui nécessite que certaines opérations de conversion soient effectuées paresseusement. Décrire cela en détail serait cependant une digression majeure. Il suffit de dire: le levage lambda fonctionne mais est suffisamment lourd que nous le souhaitions pour l'éviter.)
Au lieu de cela, la solution C # décompose le problème en deux problèmes distincts:
Par conséquent, le problème est résolu en rendant illégal la surcharge de &&
Directement. Au contraire, en C #, vous devez 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 }
...
(À 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: est-ce que cette chose est "vraie?". Typiquement, il ne serait pas une raison pour ne fournir qu'un seul de ces opérateurs, donc C # nécessite les deux.)
Considérez une déclaration du formulaire:
C cresult = cleft && cright;
Le compilateur génère du code pour cela comme si vous aviez é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 s'agit d'un "faux-ish", c'est le résultat. Sinon, le côté droit est évalué et l'opérateur désireux défini par l'utilisateur &
Est appelé.
L'opérateur ||
Est défini de la même manière, comme une invocation de l'opérateur true et de l'opérateur |
Désireux:
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 également sans court-circuit cleft & cright
, ainsi que if (cleft) if (cright) ...
, et c ? consequence : alternative
et while(c)
, etc.
Maintenant, j'ai dit que tous les processus de conception sont le résultat d'un compromis. Ici, les concepteurs du langage C # ont réussi à court-circuiter &&
Et ||
, Mais cela nécessite une surcharge quatre au lieu de deux , ce que certains trouvent déroutant. La fonction true/false de l'opérateur est l'une des fonctionnalités les moins bien comprises en C #. Le désir de disposer d'un court-circuit et le désir de ne pas implémenter le levage lambda ou d'autres formes d'évaluation paresseuse s'opposaient à l'objectif d'avoir un langage sensé et simple, familier aux utilisateurs de C++. 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 par rapport aux concepteurs de C++.
Si le sujet de la conception de langage pour de tels opérateurs vous intéresse, pensez à lire ma série sur pourquoi C # ne définit pas ces opérateurs sur des booléens nullables:
http://ericlippert.com/2012/03/26/null-is-not-false-part-one/
Le fait est que (dans les limites de C++ 98) l'opérande de droite serait passé à la fonction d'opérateur surchargée comme argument. Ce faisant, il serait déjà évalué. Il n'y a rien que le code operator||()
ou operator&&()
pourrait ou ne pourrait 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 pourrait ont rendu la non-évaluation de l'opérande de droite syntaxiquement possible. Cependant, cela ne les a pas dérangés car il n'y a que quelques cas sélectionnés où cela serait sémantiquement utile. (Juste comme ? :
, qui n'est pas du tout disponible pour la surcharge.
(Il leur a fallu 16 ans pour mettre les lambdas dans la norme ...)
Quant à l'utilisation sémantique, considérons:
objectA && objectB
Cela se résume à:
template< typename T >
ClassA.operator&&( T const & objectB )
Pensez à ce que vous aimeriez faire exactement avec objectB (de type inconnu) ici, à part appeler un opérateur de conversion en bool
, et comment vous mettriez cela en mots pour la définition du langage.
Et si vous êtes appelez la conversion en booléen, eh bien ...
objectA && obectB
fait la même chose, maintenant? Alors pourquoi une surcharge en premier lieu?
Une fonctionnalité doit être pensée, conçue, mise en œuvre, documentée et expédiée.
Maintenant que nous y avons pensé, voyons pourquoi cela pourrait être facile maintenant (et difficile à faire alors). Gardez également à l'esprit qu'il n'y a qu'une quantité limitée de ressources, donc l'ajouter pourrait avoir coupé quelque chose d'autre (que voudriez-vous y renoncer?).
En théorie, tous les opérateurs pourraient permettre un comportement de court-circuit avec un seul "mineur" fonction de langage supplémentaire, à partir de C++ 11 (lorsque les lambdas ont été introduits, 32 ans après le début du "C avec les classes") en 1979, un 16 toujours respectable après c ++ 98):
C++ aurait juste besoin d'un moyen d'annoter un argument comme évalué paresseux - un lambda caché - pour éviter l'évaluation jusqu'à ce qu'elle soit nécessaire et autorisée (conditions préalables remplies).
À quoi ressemblerait cette fonctionnalité théorique (rappelez-vous que toute nouvelle fonctionnalité devrait être largement utilisable)?
Une annotation lazy
, qui s'appliquait à un argument de fonction, fait de la fonction un modèle attendant un foncteur, et oblige le compilateur à emballer 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);
Cela ressemblerait à la couverture comme:
template<class Func> 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;});
Notez que le lambda reste caché et sera appelé au plus une fois.
Il devrait y avoir pas de dégradation des performances à cause de cela, à part les chances réduites d'élimination de sous-expression commune.
En plus de la complexité de l'implémentation et de la complexité conceptuelle (chaque fonctionnalité augmente à la fois, à moins qu'elle ne facilite suffisamment ces complexités pour d'autres fonctionnalités), examinons une autre considération importante: la compatibilité descendante.
Bien que cette fonctionnalité de langage ne casse aucun code, elle modifierait subtilement toute API en tirant parti, ce qui signifie que toute utilisation dans les bibliothèques existantes serait un changement de rupture silencieux.
BTW: Cette fonctionnalité, bien que plus facile à utiliser, est strictement plus puissante que la solution C # de fractionnement &&
et ||
en deux fonctions chacune pour une définition distincte.
Avec une rationalisation rétrospective, principalement parce que
pour garantir un court-circuit (sans introduire de nouvelle syntaxe), les opérateurs devraient être limités à résultats premier argument réel convertible en bool
, et
les courts-circuits peuvent être facilement exprimés 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;
où 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
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 tous les effets secondaires des appels 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 entièrement sûr de la conformité standard de tous les éléments suivants (toujours un peu d'influence), mais il se compile proprement avec Visual C++ 12.0 (2013) et MinGW g ++ 4.8.2:
#include <iostream>
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 -> !! !! || false 001 -> !! !! || vrai 010 -> !! !! || false 011 -> !! !! || vrai 100 -> !! && !! || false 101 -> !! && !! || vrai 110 -> !! && !! vrai 111 -> !! && !! vrai
Ici, chaque !!
bang-bang montre une conversion en bool
, c'est-à-dire une vérification de la valeur des arguments.
Puisqu'un compilateur peut facilement faire la même chose et l'optimiser en plus, il s'agit d'une implémentation possible démontrée et toute revendication d'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 bollocks.
Le court-circuitage des opérateurs logiques est autorisé car il s'agit d'une "optimisation" dans l'évaluation des tables de vérité associées. C'est un fonction de la logique lui-même, et cette logique est définie.
Y a-t-il réellement une raison pour laquelle surchargé
&&
et||
ne court-circuite pas?
Les opérateurs logiques surchargés personnalisés sont non obligés pour suivre la logique de ces tables de vérité.
Mais pourquoi perdent-ils ce comportement lorsqu'ils sont surchargés?
Par conséquent, la fonction entière doit être évaluée comme d'habitude. Le compilateur doit le traiter comme un opérateur (ou fonction) surchargé normal et il peut toujours appliquer des optimisations comme il le ferait avec n'importe quelle 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 celui logique "normal" auquel les gens sont habitués.
tl; dr: cela ne vaut pas la peine, en raison d'une demande très faible (qui utiliserait la fonctionnalité?) par rapport à des coûts plutôt élevés (syntaxe spéciale nécessaire).
La première chose qui me vient à l'esprit est que la surcharge d'opérateurs n'est qu'un moyen sophistiqué d'écrire des fonctions, alors que la version booléenne des opérateurs ||
Et &&
Sont des choses buitlin. Cela signifie que le compilateur a la liberté de les court-circuiter, tandis que l'expression x = y && z
Avec non booléen y
et z
doit conduire à un appel à une fonction comme X operator&& (Y, Z)
. Cela signifierait que y && z
N'est qu'un moyen sophistiqué d'écrire operator&&(y,z)
qui n'est qu'un appel d'une fonction au nom étrange où les deux les paramètres doivent être évalués avant d'appeler la fonction (y compris tout ce qui pourrait être considéré comme un court-circuit approprié).
Cependant, on pourrait dire qu'il devrait être possible de rendre la traduction des opérateurs &&
Quelque peu plus sophistiquée, comme c'est le cas pour l'opérateur new
qui se traduit par l'appel de la fonction operator new
suivi d'un appel constructeur.
Techniquement, cela ne poserait aucun problème, il faudrait définir une syntaxe de langage spécifique à la condition préalable qui permet le court-circuit. Cependant, l'utilisation des courts-circuits serait limitée aux cas où Y
est convétible à X
, sinon il devait y avoir des informations supplémentaires sur la façon de faire réellement le court-circuit (c.-à-d. Calculer le résultat que du premier paramètre). 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 operator||
Et operator&&
, Car il y a rarement un cas où écrire a && b
Est réellement intuitif dans un contexte non booléen. Les seules exceptions que je connaisse sont les modèles d'expression, par exemple pour les DSL intégrés. Et seule une poignée de ces quelques cas bénéficierait d'une évaluation de court-circuit. Les modèles d'expression ne le font généralement pas, car ils sont utilisés pour former des arbres d'expression qui sont évalués plus tard, vous avez donc toujours besoin des deux côtés de l'expression.
En bref: ni les rédacteurs du compilateur ni les auteurs de normes n'ont ressenti le besoin de sauter à travers des cerceaux et de définir et d'implémenter une syntaxe supplémentaire encombrante, simplement parce qu'un sur un million pourrait avoir l'idée qu'il serait bien d'avoir un court-circuit sur défini par l'utilisateur operator&&
Et operator||
- juste pour arriver à la conclusion que ce n'est pas moins d'effort que d'écrire la logique à la main.
Le court-circuit est dû à la table de vérité de "et" et "ou". Comment sauriez-vous quelle opération l'utilisateur va définir et comment sauriez-vous que vous n'aurez pas à évaluer le deuxième opérateur?
Lambdas n'est pas le seul moyen d'introduire la paresse. L'évaluation paresseuse est relativement simple à l'aide de Modèles d'expression en C++. Il n'y a pas besoin de mot clé lazy
et il 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 de l'homme pauvres (mais intelligents). L'astuce consiste à convertir l'expression en un arbre d'instanciations récursivement imbriquées du modèle Expr
. L'arbre est évalué séparément après la construction.
Le code suivant implémente un court-circuit &&
et ||
opérateurs pour la classe S
tant qu'il fournit logical_and
et logical_or
fonctions gratuites et il 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 <iostream>
struct S
{
bool val;
explicit S(int i) : val(i) {}
explicit S(bool b) : val(b) {}
template <class Expr>
S (const Expr & expr)
: val(evaluate(expr).val)
{ }
template <class Expr>
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 <class Expr>
S evaluate(const Expr & expr)
{
return expr.eval();
}
struct And
{
template <class LExpr, class RExpr>
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 <class LExpr, class RExpr>
S operator ()(const LExpr & l, const RExpr & r) const
{
const S & temp = evaluate(l);
return temp? temp : logical_or(temp, evaluate(r));
}
};
template <class Op, class LExpr, class RExpr>
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 <class LExpr>
auto operator && (const LExpr & lhs, const S & rhs)
{
return Expr<And, LExpr, S> (lhs, rhs);
}
template <class LExpr, class Op, class L, class R>
auto operator && (const LExpr & lhs, const Expr<Op,L,R> & rhs)
{
return Expr<And, LExpr, Expr<Op,L,R>> (lhs, rhs);
}
template <class LExpr>
auto operator || (const LExpr & lhs, const S & rhs)
{
return Expr<Or, LExpr, S> (lhs, rhs);
}
template <class LExpr, class Op, class L, class R>
auto operator || (const LExpr & lhs, const Expr<Op,L,R> & rhs)
{
return Expr<Or, LExpr, Expr<Op,L,R>> (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 pour bool ont ce comportement, pourquoi devrait-il être limité à ce seul type?
Je veux juste répondre à cette seule partie. La raison en est que les expressions &&
Et ||
Intégrées ne sont pas implémentées avec des fonctions comme le sont les opérateurs surchargés.
Il est facile d'avoir la logique de court-circuit intégrée à la compréhension par le compilateur d'expressions spécifiques. C'est comme tout autre flux de contrôle intégré.
Mais la surcharge d'opérateur est implémentée à la place 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 pourraient être définies, mais c'est un travail plus important.