wake-up-neo.com

Gibt es eigentlich einen Grund warum && und || überladen sind nicht kurzschließen?

Das Kurzschlussverhalten der Betreiber && und || ist ein erstaunliches Werkzeug für Programmierer.

Aber warum verlieren sie dieses Verhalten bei Überlastung? Ich verstehe, dass Operatoren nur syntaktische Zucker für Funktionen sind, aber die Operatoren für bool haben dieses Verhalten. Warum sollte es auf diesen einen Typ beschränkt sein? Gibt es technische Gründe dafür?

134
iFreilicht

Alle Entwurfsprozesse führen zu Kompromissen zwischen inkompatiblen Zielen. Leider führte der Entwurfsprozess für den überladenen Operator && In C++ zu einem verwirrenden Endergebnis: Das von && Gewünschte Leistungsmerkmal - sein Kurzschlussverhalten - wurde weggelassen.

Die Details, wie dieser Entwurfsprozess an diesem unglücklichen Ort endete, kenne ich nicht. Es ist jedoch wichtig zu sehen, wie ein späterer Entwurfsprozess dieses unangenehme Ergebnis berücksichtigt. In C # ist der überladene Operator &&ist ​​ein Kurzschluss. Wie haben die Designer von C # das erreicht?

Eine der anderen Antworten schlägt "Lambda-Heben" vor. Das ist:

A && B

könnte als etwas moralisch Äquivalent zu realisiert werden:

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

das zweite Argument verwendet einen Mechanismus für die verzögerte Auswertung, sodass bei der Auswertung die Nebenwirkungen und der Wert des Ausdrucks hervorgerufen werden. Die Implementierung des überlasteten Operators würde die verzögerte Auswertung nur dann durchführen, wenn dies erforderlich ist.

Dies hat das C # -Designteam nicht getan. (Abgesehen davon: obwohl Lambda-Heben ist ​​was ich getan habe, als es Zeit wurde zu tun Ausdrucksbaumdarstellung des Operators ??, Für den bestimmte Konvertierungsoperationen erforderlich sind träge durchgeführt. Beschreiben, dass im Detail wäre jedoch ein großer Exkurs. Es genügt zu sagen: Lambda-Heben funktioniert, ist aber schwer genug, dass wir es vermeiden wollten.)

Die C # -Lösung unterteilt das Problem vielmehr in zwei separate Probleme:

  • sollen wir den rechten Operanden auswerten?
  • wenn die Antwort auf das oben Gesagte "Ja" war, wie kombinieren wir dann die beiden Operanden?

Daher wird das Problem gelöst, indem es illegal gemacht wird, && Direkt zu überladen. Vielmehr müssen Sie in C # zwei - Operatoren überladen, von denen jeder eine dieser beiden Fragen beantwortet.

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

(Abgesehen davon: Tatsächlich erfordert C #, dass, wenn der Operator false angegeben wird, auch der Operator true angegeben werden muss, der die Frage beantwortet: Ist das Ding "wahr?". In der Regel vorhanden wäre kein Grund, nur einen solchen Operator anzugeben, daher erfordert C # beide.)

Betrachten Sie eine Aussage des Formulars:

C cresult = cleft && cright;

Der Compiler generiert dafür Code, als ob Sie dieses Pseudo-C # geschrieben hätten:

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

Wie Sie sehen, wird die linke Seite immer ausgewertet. Wenn festgestellt wird, dass es "falsch" ist, ist es das Ergebnis. Andernfalls wird die rechte Seite ausgewertet und der benutzerdefinierte Operator eager& Aufgerufen.

Der Operator || Wird auf analoge Weise als Aufruf von operator true und dem eifrigen Operator | Definiert:

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

Durch die Definition aller vier Operatoren - true, false, & Und | - können Sie in C # nicht nur cleft && cright, Sondern auch Folgendes sagen auch nicht kurzschließen von cleft & cright und auch if (cleft) if (cright) ... und c ? consequence : alternative und while(c) und so weiter.

Nun sagte ich, dass alle Designprozesse das Ergebnis von Kompromissen sind. Hier ist es den C # -Sprachen-Designern gelungen, && Und || Richtig kurzzuschließen, dies erfordert jedoch eine Überladung von vier Operatoren anstelle von zwei , was manche Leute verwirrend finden. Das Operator-True/False-Feature ist eines der am wenigsten verstandenen Features in C #. Dem Ziel einer vernünftigen und einfachen Sprache, die C++ - Benutzern vertraut ist, standen die Wünsche nach Kurzschlüssen und der Wunsch entgegen, kein Lambda-Heben oder andere Formen der verzögerten Auswertung zu implementieren. Ich denke, das war eine vernünftige Kompromissposition, aber es ist wichtig zu erkennen, dass es ist ​​eine Kompromissposition. Nur eine andere Kompromissposition, auf der die Designer von C++ gelandet sind.

Wenn Sie das Thema Sprachgestaltung für solche Operatoren interessiert, lesen Sie in meiner Reihe, warum C # diese Operatoren nicht für nullfähige Boolesche Werte definiert:

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

150
Eric Lippert

Der Punkt ist, dass (innerhalb der Grenzen von C++ 98) der rechte Operand als Argument an die überladene Operatorfunktion übergeben wird. Dabei würde bereits ausgewertet. Es gibt nichts, was der Code operator||() oder operator&&() tun könnte oder nicht, was dies verhindern würde.

Der ursprüngliche Operator ist anders, da er keine Funktion ist, sondern auf einer niedrigeren Sprachstufe implementiert wird.

Zusätzliche Sprachmerkmale könnten haben die syntaktische Nichtbewertung des rechten Operanden möglich bewirkt. Sie haben sich jedoch nicht darum gekümmert, weil es nur einige wenige Fälle gibt, in denen dies semantisch nützlich wäre. (So ​​wie ? :, das überhaupt nicht zum Überladen zur Verfügung steht.

(Sie brauchten 16 Jahre, um Lambdas in den Standard zu bringen ...)

Beachten Sie für die semantische Verwendung Folgendes:

objectA && objectB

Dies läuft auf Folgendes hinaus:

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

Überlegen Sie sich, was genau Sie hier mit objectB (eines unbekannten Typs) tun möchten, außer einen Konvertierungsoperator für bool aufzurufen, und wie Sie dies für die Sprachdefinition in Worte fassen würden.

Und if Sie are rufen die Konvertierung nach bool auf, na ja ...

objectA && obectB

macht das Gleiche, jetzt? Warum also überhaupt überladen?

43
DevSolar

Eine Funktion muss gedacht, entworfen, implementiert, dokumentiert und ausgeliefert werden.

Jetzt haben wir darüber nachgedacht, mal sehen, warum es jetzt einfach sein könnte (und dann schwer zu tun ist). Denken Sie auch daran, dass es nur eine begrenzte Menge an Ressourcen gibt. Wenn Sie also etwas hinzufügen, hat dies möglicherweise etwas anderes zerschnitten (auf was möchten Sie verzichten?).


Theoretisch könnten alle Operatoren ein Kurzschlussverhalten mit nur einem "kleinen" zusätzliches Sprachmerkmal zulassen, ab C++ 11 (als Lambdas eingeführt wurden, 32 Jahre nach dem Start von "C mit Klassen") 1979 noch beachtliche 16 nach c ++ 98):

C++ würde nur eine Möglichkeit benötigen, ein Argument als faul bewertet zu kommentieren - ein verstecktes Lambda -, um die Bewertung zu vermeiden, bis sie erforderlich und zulässig ist (Voraussetzungen erfüllt).


Wie würde dieses theoretische Feature aussehen? (Denken Sie daran, dass neue Features weitgehend verwendbar sein sollten.)

Eine Annotation lazy, die auf ein Funktionsargument angewendet wird, macht die Funktion zu einer Vorlage, die einen Funktor erwartet, und veranlasst den Compiler, den Ausdruck in einen Funktor zu packen:

A operator&&(B b, __lazy C c) {return c;}

// And be called like
exp_b && exp_c;
// or
operator&&(exp_b, exp_c);

Es würde unter der Decke so aussehen:

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

Beachten Sie besonders, dass das Lambda verborgen bleibt und höchstens einmal aufgerufen wird.
Aus diesem Grund sollte kein Leistungsabfall auftreten, abgesehen von einer verringerten Wahrscheinlichkeit der Eliminierung von gemeinsamen Subausdrücken.


Lassen Sie uns neben der Implementierungskomplexität und der konzeptionellen Komplexität (jede Funktion erhöht beide, sofern diese Komplexität nicht für einige andere Funktionen ausreichend verringert wird) einen weiteren wichtigen Aspekt betrachten: Abwärtskompatibilität.

Während dieses Sprachfeature keinen Code beschädigen würde, würde es jede API, die es ausnutzt, subtil verändern, was bedeutet, dass jede Verwendung in vorhandenen Bibliotheken a wäre lautlose Veränderung.

Übrigens: Diese Funktion ist zwar einfacher zu bedienen, aber strikt stärker als die C # -Lösung zum Teilen von && und || in zwei Funktionen zur getrennten Definition.

26
Deduplicator

Mit nachträglicher Rationalisierung, hauptsächlich weil

  • um einen Kurzschluss zu gewährleisten (ohne neue Syntax einzuführen), müssten die Operatoren auf beschränkt werden ergebnisse aktuelles erstes Argument konvertierbar zu bool und

  • kurzschlüsse können bei Bedarf leicht auf andere Weise ausgedrückt werden.


Wenn zum Beispiel eine Klasse T&& und || Operatoren, dann der Ausdruck

auto x = a && b || c;

wobei a, b und c Ausdrücke vom Typ T sind, die kurzgeschlossen ausgedrückt werden können als

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

oder vielleicht deutlicher als

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

Die offensichtliche Redundanz bewahrt alle Nebenwirkungen der Bedieneraufrufe.


Während das Lambda-Rewrite ausführlicher ist, ermöglicht seine bessere Kapselung das Definieren solcher Operatoren .

Ich bin mir nicht ganz sicher, ob alle folgenden Standards (noch ein bisschen Influensa) eingehalten werden können, aber sie lassen sich problemlos mit Visual C++ 12.0 (2013) und MinGW g ++ 4.8.2 kompilieren:

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

Ausgabe:

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

Hier jeder !!bang-bang zeigt eine Umwandlung in bool, d. h. eine Argumentwertprüfung.

Da ein Compiler dies auf einfache Weise tun und zusätzlich optimieren kann, ist dies eine nachgewiesene mögliche Implementierung, und jeder Unmöglichkeitsanspruch muss in dieselbe Kategorie wie Unmöglichkeitsansprüche im Allgemeinen eingeordnet werden, und zwar im Allgemeinen in Blöcken.

13

Das Kurzschließen der logischen Operatoren ist zulässig, da es sich um eine "Optimierung" bei der Auswertung der zugehörigen Wahrheitstabellen handelt. Es ist eine Funktion der Logik selbst, und diese Logik ist definiert.

Gibt es eigentlich einen Grund, warum && und || nicht kurzschließen?

Benutzerdefinierte überladene logische Operatoren sind nicht verpflichtet , der Logik dieser Wahrheitstabellen zu folgen.

Aber warum verlieren sie dieses Verhalten bei Überlastung?

Daher muss die gesamte Funktion normal ausgewertet werden. Der Compiler muss es als einen normalen überladenen Operator (oder eine normale überladene Funktion) behandeln und kann weiterhin Optimierungen anwenden, wie dies bei jeder anderen Funktion der Fall wäre.

Die logischen Operatoren werden aus verschiedenen Gründen überlastet. Beispielsweise; Sie können in einer bestimmten Domäne eine bestimmte Bedeutung haben, die nicht die "normale" logische Bedeutung hat, an die Menschen gewöhnt sind.

5
Niall

tl; dr: Der Aufwand lohnt sich nicht, da die Nachfrage (wer würde das Feature nutzen?) im Vergleich zu den hohen Kosten (spezielle Syntax erforderlich) sehr gering ist.

Das erste, was mir in den Sinn kommt, ist, dass das Überladen von Operatoren nur eine ausgefallene Art ist, Funktionen zu schreiben, während die Booleschen Versionen der Operatoren || Und && Buitlin sind. Das bedeutet, dass der Compiler die Freiheit hat, sie kurzzuschließen, während der Ausdruck x = y && z Mit nicht-booleschen y und z zu einem Aufruf einer Funktion wie X operator&& (Y, Z). Dies würde bedeuten, dass y && z Nur eine ausgefallene Möglichkeit ist, operator&&(y,z) zu schreiben, was nur ein Aufruf einer seltsam benannten Funktion ist, bei der beide Parameter ausgewertet werden müssen vor dem Aufrufen der Funktion (einschließlich allem, was als Kurzschluss geeignet erachtet wird).

Man könnte jedoch argumentieren, dass es möglich sein sollte, die Übersetzung von && - Operatoren etwas komplexer zu gestalten, wie es für den new -Operator der Fall ist, der in den Aufruf der Funktion operator new gefolgt von einem Konstruktoraufruf.

Technisch wäre dies kein Problem, man müsste eine für die Vorbedingung spezifische Sprachsyntax definieren, die das Kurzschließen ermöglicht. Die Verwendung von Kurzschlüssen würde sich jedoch auf Fälle beschränken, in denen Y zu X konvertierbar ist, oder es müsste zusätzliche Informationen darüber geben, wie der Kurzschluss tatsächlich ausgeführt werden soll (dh wie der berechnet wird) ergeben sich nur aus dem ersten Parameter). Das Ergebnis müsste ungefähr so ​​aussehen:

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

Man möchte selten operator|| Und operator&& Überladen, weil es selten einen Fall gibt, in dem das Schreiben von a && b In einem nicht-booleschen Kontext tatsächlich intuitiv ist. Die einzigen mir bekannten Ausnahmen sind Ausdrucksvorlagen, z. für eingebettete DSLs. Und nur eine Handvoll dieser wenigen Fälle würde von einer Kurzschlussbewertung profitieren. Ausdrucksvorlagen tun dies normalerweise nicht, da sie zum Bilden von Ausdrucksbäumen verwendet werden, die später ausgewertet werden. Sie benötigen daher immer beide Seiten des Ausdrucks.

Kurz gesagt: Weder Compilerautoren noch Standardautoren hatten das Bedürfnis, durch die Rahmen zu springen und zusätzliche umständliche Syntax zu definieren und zu implementieren, nur weil einer von einer Million vielleicht auf die Idee kommt, dass es schön wäre, benutzerdefinierte operator&& Und operator|| - nur um zu dem Schluss zu kommen, dass es nicht weniger Aufwand ist, als die Logik per Hand zu schreiben.

5
Arne Mertz

Der Kurzschluss liegt an der Wahrheitstabelle von "und" und "oder". Wie würden Sie wissen, welche Operation der Benutzer definieren wird und wie würden Sie wissen, dass Sie den zweiten Operator nicht bewerten müssen?

4
nj-ath

Lambdas ist nicht der einzige Weg, um Faulheit einzuführen. Die verzögerte Auswertung ist mit Ausdrucksvorlagen in C++ relativ unkompliziert. Das Schlüsselwort lazy ist nicht erforderlich und kann in C++ 98 implementiert werden. Ausdrucksbäume sind oben bereits erwähnt. Ausdrucksvorlagen sind Ausdrucksbäume armer (aber kluger) Menschen. Der Trick besteht darin, den Ausdruck in einen Baum rekursiv verschachtelter Instanzen der Vorlage Expr zu konvertieren. Der Baum wird nach der Erstellung separat ausgewertet.

Der folgende Code implementiert kurzgeschlossenes && und || Operatoren für die Klasse S, solange sie logical_and und logical_or freie Funktionen und es ist konvertierbar zu bool. Der Code ist in C++ 14, aber die Idee ist auch in C++ 98 anwendbar. Siehe Live-Beispiel.

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

aber die Operatoren für Bool haben dieses Verhalten. Warum sollte es auf diesen einen Typ beschränkt sein?

Ich möchte nur diesen einen Teil beantworten. Der Grund ist, dass die eingebaute && und || Ausdrücke werden nicht mit Funktionen implementiert, wie dies bei überladenen Operatoren der Fall ist.

Es ist einfach, die Kurzschlusslogik in das Verständnis des Compilers für bestimmte Ausdrücke zu integrieren. Es ist wie jeder andere integrierte Kontrollfluss.

Das Überladen von Operatoren wird jedoch stattdessen mit Funktionen implementiert, die bestimmte Regeln haben. Eine davon ist, dass alle als Argumente verwendeten Ausdrücke ausgewertet werden, bevor die Funktion aufgerufen wird. Offensichtlich könnten andere Regeln definiert werden, aber das ist eine größere Aufgabe.

3
bames53