programas programacion operadores logicos ejemplos cortocircuito con c++ c++11 operator-overloading logical-operators short-circuiting

c++ - programacion - ¿Existe realmente una razón por la que se sobrecargan && y || no cortocircuito?



operadores logicos en programacion (9)

El comportamiento de cortocircuito de los operadores && y || Es una herramienta increíble para programadores.

Pero, ¿por qué pierden este comportamiento cuando se sobrecargan? Entiendo que los operadores son simplemente azúcar sintáctica para funciones, pero los operadores de bool tienen este comportamiento, ¿por qué debería restringirse a este tipo único? ¿Hay algún razonamiento técnico detrás de esto?


pero los operadores de bool tienen este comportamiento, ¿por qué debería restringirse a este tipo único?

Solo quiero responder esta parte. La razón es que el && y el || las expresiones no se implementan con funciones como lo están los operadores sobrecargados.

Tener la lógica de cortocircuito integrada en la comprensión del compilador de expresiones específicas es fácil. Es como cualquier otro flujo de control incorporado.

Pero la sobrecarga del operador se implementa con funciones en su lugar, que tienen reglas particulares, una de las cuales es que todas las expresiones utilizadas como argumentos se evalúan antes de que se llame a la función. Obviamente, se podrían definir reglas diferentes, pero ese es un trabajo más grande.


Con racionalización retrospectiva, principalmente porque

  • Para garantizar un cortocircuito garantizado (sin introducir una nueva sintaxis), los operadores tendrían que estar restringidos a resultados primer argumento real convertible a bool , y

  • el cortocircuito se puede expresar fácilmente de otras formas, cuando sea necesario.

Por ejemplo, si una clase T tiene asociados && y || operadores, entonces la expresión

auto x = a && b || c;

donde a , c son expresiones de tipo T , se pueden expresar con cortocircuito como

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

o quizás más claramente como

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 aparente redundancia conserva los efectos secundarios de las invocaciones del operador.

Si bien la reescritura lambda es más detallada, su mejor encapsulación permite definir dichos operadores.

No estoy completamente seguro de la conformidad estándar de todo lo siguiente (todavía un poco de influencia), pero se compila limpiamente con Visual C ++ 12.0 (2013) y 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; } } } }

Salida:

000 -> !! !! || false 001 -> !! !! || true 010 -> !! !! || false 011 -> !! !! || true 100 -> !! && !! || false 101 -> !! && !! || true 110 -> !! && !! true 111 -> !! && !! true

Aquí cada uno !! bang-bang muestra una conversión a bool , es decir, una comprobación de valor de argumento.

Dado que un compilador puede hacer lo mismo fácilmente y, además, optimizarlo, esta es una posible implementación demostrada y cualquier reclamo de imposibilidad debe clasificarse en la misma categoría que los reclamos de imposibilidad en general, es decir, en general bollocks.


El cortocircuito se debe a la tabla de verdad de "y" y "o". ¿Cómo sabría qué operación va a definir el usuario y cómo sabe que no tendrá que evaluar al segundo operador?


El punto es que (dentro de los límites de C ++ 98) el operando de la derecha se pasaría a la función del operador sobrecargado como argumento. Al hacerlo, ya sería evaluado . No hay nada que el operator||() o el operator&&() pueda o no pueda hacer que pueda evitar esto.

El operador original es diferente, porque no es una función, sino que se implementa en un nivel inferior del lenguaje.

Las características adicionales del lenguaje podrían haber hecho que la no evaluación del operando de la derecha sea sintácticamente posible . Sin embargo, no se molestaron porque solo hay unos pocos casos selectos en los que esto sería semánticamente útil. (Justo como ? : Que no está disponible para sobrecargar en absoluto.

(Les tomó 16 años lograr que las lambdas ingresen al estándar ...)

En cuanto al uso semántico, considere:

objectA && objectB

Esto se reduce a:

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

Piense qué es exactamente lo que le gustaría hacer con el objeto B (de tipo desconocido) aquí, aparte de llamar a un operador de conversión a bool , y cómo lo pondría en palabras para la definición del lenguaje.

Y si estás llamando conversión a bool, bueno ...

objectA && obectB

hace lo mismo, ahora lo hace? Entonces, ¿por qué sobrecargar en primer lugar?


Lambdas no es la única forma de introducir la pereza. La evaluación diferida es relativamente sencilla utilizando plantillas de expresión en C ++. No hay necesidad de una palabra clave lazy y se puede implementar en C ++ 98. Los árboles de expresión ya se mencionan anteriormente. Las plantillas de expresión son árboles de expresión del hombre pobre (pero inteligente). El truco es convertir la expresión en un árbol de instancias recursivamente anidadas de la plantilla Expr . El árbol se evalúa por separado después de la construcción.

El siguiente código implementa en cortocircuito && y || operadores para la clase S siempre que proporcione logical_and logical_or libres y sea convertible a bool . El código está en C ++ 14 pero la idea también es aplicable en C ++ 98. Ver ejemplo en vivo .

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


Se permite el cortocircuito de los operadores lógicos porque es una "optimización" en la evaluación de las tablas de verdad asociadas. Es una función de la lógica misma, y ​​esta lógica está definida.

¿Existe realmente una razón por la que se sobrecargan && y || no cortocircuito?

Los operadores lógicos sobrecargados personalizados no están obligados a seguir la lógica de estas tablas de verdad.

Pero, ¿por qué pierden este comportamiento cuando se sobrecargan?

Por lo tanto, toda la función debe evaluarse según lo normal. El compilador debe tratarlo como un operador (o función) sobrecargado normal y aún puede aplicar optimizaciones como lo haría con cualquier otra función.

Las personas sobrecargan los operadores lógicos por una variedad de razones. Por ejemplo; pueden tener un significado específico en un dominio específico que no es el lógico "normal" al que la gente está acostumbrada.


Todos los procesos de diseño resultan en compromisos entre objetivos mutuamente incompatibles. Desafortunadamente, el proceso de diseño para el operador && sobrecargado en C ++ produjo un resultado final confuso: que se omite la característica que desea de && , su comportamiento de cortocircuito.

Los detalles de cómo terminó ese proceso de diseño en este desafortunado lugar, aquellos que no conozco. Sin embargo, es relevante ver cómo un proceso de diseño posterior tuvo en cuenta este resultado desagradable. En C #, el operador && sobrecargado está en cortocircuito. ¿Cómo lograron eso los diseñadores de C #?

Una de las otras respuestas sugiere "levantamiento lambda". Es decir:

A && B

podría realizarse como algo moralmente equivalente a:

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

donde el segundo argumento usa algún mecanismo para la evaluación diferida, de modo que cuando se evalúa, se producen los efectos secundarios y el valor de la expresión. La implementación del operador sobrecargado solo haría la evaluación diferida cuando sea necesario.

Esto no es lo que hizo el equipo de diseño de C #. (Aparte: aunque el levantamiento de lambda es lo que hice cuando llegó el momento de hacer una representación de árbol de expresión del operador ?? , que requiere que ciertas operaciones de conversión se realicen perezosamente. Sin embargo, describir eso en detalle sería una digresión importante. Basta decir: el levantamiento lambda funciona pero es lo suficientemente pesado como para desear evitarlo).

Más bien, la solución C # divide el problema en dos problemas separados:

  • ¿Deberíamos evaluar el operando de la derecha?
  • si la respuesta a lo anterior fue "sí", entonces, ¿cómo combinamos los dos operandos?

Por lo tanto, el problema se resuelve haciendo ilegal sobrecargar && directamente. Por el contrario, en C # debe sobrecargar dos operadores, cada uno de los cuales responde una de esas dos preguntas.

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

(Aparte: en realidad, tres. C # requiere que si se proporciona el operador true , también se debe proporcionar el operador true , lo que responde a la pregunta: ¿esto es "verdadero-ish"? Por lo general, no habría razón para proporcionar solo uno de esos operadores entonces C # requiere ambos.)

Considere una declaración de la forma:

C cresult = cleft && cright;

El compilador genera código para esto como creía que había escrito este pseudo-C #:

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

Como puede ver, el lado izquierdo siempre se evalúa. Si se determina que es "falso-ish", entonces es el resultado. De lo contrario, se evalúa el lado derecho & se invoca al operador ansioso definido por el usuario.

El || El operador se define de manera análoga, como una invocación del operador verdadero y el ansioso | operador:

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

Al definir los cuatro operadores: true , false y & | - C # le permite no solo decir cleft && cright sino también cleft & cright sin cortocircuito, y también if (cleft) if (cright) ... y c ? consequence : alternative c ? consequence : alternative y while(c) , y así sucesivamente.

Ahora, dije que todos los procesos de diseño son el resultado de un compromiso. Aquí los diseñadores del lenguaje C # lograron un cortocircuito && y || correcto, pero hacerlo requiere sobrecargar cuatro operadores en lugar de dos , lo que algunas personas encuentran confuso. La función verdadero / falso del operador es una de las características menos conocidas de C #. El objetivo de tener un lenguaje directo y sensible que sea familiar para los usuarios de C ++ se opuso a los deseos de tener un corto circuito y el deseo de no implementar el levantamiento lambda u otras formas de evaluación perezosa. Creo que fue una posición de compromiso razonable, pero es importante darse cuenta de que es una posición de compromiso. Solo una posición de compromiso diferente a la de los diseñadores de C ++.

Si el tema del diseño del lenguaje para tales operadores le interesa, considere leer mi serie sobre por qué C # no define estos operadores en booleanos que aceptan valores NULL:

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


Una característica tiene que ser pensada, diseñada, implementada, documentada y enviada.

Ahora que lo pensamos, veamos por qué podría ser fácil ahora (y difícil de hacer entonces). También tenga en cuenta que solo hay una cantidad limitada de recursos, por lo que agregarlo podría haber cortado algo más (¿qué le gustaría renunciar a él?).

En teoría, todos los operadores podrían permitir un comportamiento de cortocircuito con solo una característica de lenguaje adicional "menor", a partir de C ++ 11 (cuando se introdujeron las lambdas, 32 años después de que comenzara "C con clases" en 1979, un 16 todavía respetable después de c ++ 98):

C ++ solo necesitaría una forma de anotar un argumento como evaluado de forma diferida, una lambda oculta, para evitar la evaluación hasta que sea necesario y permitido (se cumplen las condiciones previas).

¿Cómo sería esa característica teórica (recuerde que cualquier característica nueva debería ser ampliamente utilizable)?

Una anotación lazy , que se aplica a un argumento de función convierte la función en una plantilla que espera un functor, y hace que el compilador empaquete la expresión en 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);

Se vería debajo de la cubierta como:

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

Tenga en cuenta que la lambda permanece oculta y se llamará como máximo una vez.
No debería haber degradación del rendimiento debido a esto, aparte de la reducción de las posibilidades de eliminación de subexpresiones comunes.

Además de la complejidad de la implementación y la complejidad conceptual (cada característica aumenta ambas, a menos que alivie lo suficiente esas complejidades para otras características), veamos otra consideración importante: la compatibilidad con versiones anteriores.

Si bien esta función de lenguaje no rompería ningún código, cambiaría sutilmente cualquier API aprovechándola, lo que significa que cualquier uso en bibliotecas existentes sería un cambio silencioso.

Por cierto: esta característica, aunque es más fácil de usar, es estrictamente más fuerte que la solución C # de dividir && y || en dos funciones cada una para una definición separada.


tl; dr : no vale la pena el esfuerzo, debido a la muy baja demanda (¿quién usaría la función?) en comparación con los costos bastante altos (se necesita una sintaxis especial).

Lo primero que viene a la mente es que la sobrecarga del operador es solo una forma elegante de escribir funciones, mientras que la versión booleana de los operadores || y && son cosas de buitlin. Eso significa que el compilador tiene la libertad de cortocircuitarlos, mientras que la expresión x = y && z con no booleano y y z debe conducir a una llamada a una función como el X operator&& (Y, Z) . Esto significaría que y && z es solo una forma elegante de escribir el operator&&(y,z) que es solo una llamada de una función con un nombre extraño donde ambos parámetros deben evaluarse antes de llamar a la función (incluyendo cualquier cosa que considere breve). circuito apropiado).

Sin embargo, uno podría argumentar que debería ser posible hacer que la traducción de los operadores && algo más sofisticada, como lo es para el new operador que se traduce en llamar al operator new la función operator new seguido de una llamada al constructor.

Técnicamente, esto no sería un problema, habría que definir una sintaxis de lenguaje específica para la condición previa que permita el cortocircuito. Sin embargo, el uso de cortocircuitos se restringiría a los casos en que Y sea ​​convertible a X , o de lo contrario tendría que haber información adicional sobre cómo hacer realmente el cortocircuito (es decir, calcular el resultado solo desde el primer parámetro). El resultado tendría que verse más o menos así:

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 se quiere sobrecargar al operator|| y operator&& , porque rara vez hay un caso en el que escribir a && b es realmente intuitivo en un contexto no booleano. Las únicas excepciones que conozco son las plantillas de expresión, por ejemplo, para DSL incrustadas. Y solo unos pocos de esos pocos casos se beneficiarían de la evaluación de cortocircuito. Las plantillas de expresión generalmente no lo hacen, porque se usan para formar árboles de expresión que se evalúan más adelante, por lo que siempre necesita ambos lados de la expresión.

En resumen: ni los escritores de compiladores ni los autores de estándares sintieron la necesidad de saltar a través de aros y definir e implementar una sintaxis engorrosa adicional, solo porque uno en un millón podría tener la idea de que sería bueno tener un cortocircuito en el operator&& definido por el usuario operator&& y el operator|| - solo para llegar a la conclusión de que no es menos esfuerzo que escribir la lógica a mano.