实际上是为什么重载&&和||的原因 不要短路?

运算符&&||短路行为 是程序员的一个惊人的工具。

但为什么他们在重载时会失去这种行为? 我明白操作符只是函数的语法糖,但是bool的操作符有这种行为,为什么只能限制在这种单一的types呢? 这背后有没有技术推理?

所有的devise过程都会导致相互矛盾的目标之间的妥协。 不幸的是,C ++中重载的&&操作符的devise过程产生了一个令人困惑的结果:从&& – 它的短路行为中想要的特性被省略了。

那个devise过程如何在这个不幸的地方结束的细节,我不知道。 看看后来的devise过程如何考虑这个不愉快的结果是相关的。 在C#中,重载的&&操作符短路的。 C#的devise者是如何实现这一点的?

其他答案之一就是“拉姆达升降”。 那是:

 A && B 

可以被认为是在道德上相当于:

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

第二个参数使用一些懒惰评估机制,以便在评估时产生expression式的副作用和值。 重载操作符的实现只会在必要时进行懒惰的评估。

这不是C#devise团队所做的。 (另外:虽然lambda提升我在做expression式操作符的时候所做的,这需要某些转换操作被懒惰地执行,但详细描述将是一个主要的题外话:只要说:拉姆达提升的作品,但是我们希望避免它是足够重的。)

相反,C#解决scheme将问题分解为两个单独的问题:

  • 我们应该评估右手操作数吗?
  • 如果上面的答案是“是”,那么我们如何组合这两个操作数呢?

因此,通过使&&直接超负荷是非法的。 相反,在C#中,你必须重载两个操作符,每个操作符都回答这两个问题之一。

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

(另外:实际上是三个,C#要求如果提供了运算符false ,则还必须提供运算符true ,这就回答了这个问题:这个东西是否是“真正的”?通常没有理由只提供一个这样的运算符所以C#都需要。)

考虑一下forms的陈述:

 C cresult = cleft && cright; 

编译器就像你以前编写这个伪C#一样生成代码:

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

正如你所看到的,总是评估左手边。 如果确定是“虚假”,那就是结果。 否则,评估右侧,并调用渴望的用户定义的运算符&

|| 操作符以类似的方式定义,作为操作符true和eager |的调用 运营商:

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

通过定义全部四个运算符 – truefalse&| – C#不仅可以让你说出“ cleft && cright而且还可以用于非短路的“ cleft & crightif (cleft) if (cright) ...c ? consequence : alternative c ? consequence : alternativewhile(c)等等。

现在,我说所有的devise过程都是妥协的结果。 在这里,C#语言devise者设法使&&||短路 对,但这样做需要超载四个操作员,而不是两个 ,有些人觉得混乱。 运算符true / false特征是C#中最less理解的特征之一。 拥有一种C ++用户所熟悉的明智而直接的语言的目标,是被期望短路和希望不实现拉姆达或其他forms的懒惰评估所抵制的。 我认为这是一个合理的妥协立场,但重要的是要认识到这妥协的立场。 与C ++的devise者相比,只是一个不同的妥协位置。

如果这些操作符的语言devise主题引起了您的兴趣,请考虑阅读我的系列文章,了解为什么C#没有在可空布尔值上定义这些操作符:

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

重点是(在C ++ 98的范围内),右侧操作数将作为parameter passing给重载操作符函数。 这样做, 它已经被评估operator||()operator&&()代码可以或不可以这样做,这将避免这种情况。

原来的操作符是不同的,因为它不是一个函数,而是在较低级别的语言中实现的。

额外的语言特征可能会使右操作数在语法上无法评估。 然而,他们没有打扰,因为只有less数情况下,这在语义上是有用的。 (就像? : ,根本不可用于重载。

(他们花了16年才把lambda转化为标准…)

至于语义的使用,请考虑:

 objectA && objectB 

这归结为:

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

除了调用一个转换运算符为bool ,还可以考虑在这里使用objectB(未知types)究竟做什么,以及如何将这些转化为语言定义的单词。

如果正在调用转换为布尔,那么…

 objectA && obectB 

做同样的事情,现在呢? 那么为什么超载呢?

一个function必须考虑,devise,实施,logging和运输。

现在我们想到了,让我们看看为什么现在可能很容易(而且很难做到)。 另外请记住,只有有限的资源,所以添加它可能已经切碎了别的东西(你想放弃什么?)。


从理论上讲,所有运营商都可以允许短路行为,只有一个“次要的” 附加语言特征 ,如C ++ 11(当lambdas被引入时,在1979年开始的“带类的C”的32年后,仍然是可敬的16后c ++ 98):

C ++只需要一种方式来注释一个参数,作为懒惰评估 – 一个隐藏的lambda – 避免评估,直到必要和允许(预条件满足)。


这个理论特征是什么样子的(请记住,任何新function应该广泛使用)?

一个lazy的注解,应用于一个函数参数使函数成为一个模板,期望一个函子,并使编译器将expression式打包成一个函子:

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

它会看到下面的封面:

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

需要特别注意的是,lambda保持隐藏状态,最多只会被调用一次。
除了减less共同子expression式消除的机会之外, 应该由此导致性能下降


除了实现复杂性和概念上的复杂性(每个function都增加了,除非它足够简化其他function的复杂性),让我们看看另一个重要的考虑:向后兼容性。

虽然这种语言function不会破坏任何代码,但是它会巧妙地改变任何API来利用它,这意味着在现有的库中的任何使用都将是一个沉默的重大改变。

顺便说一下:这个特性虽然更容易使用,但是比C#的分裂&&||更加强大 分成两个函数,分别定义。

追溯合理化,主要是因为

  • 为了保证短路(不需要引入新的语法),运营商将不得不被限制 结果 实际的第一个参数可转换为bool ,和

  • 在需要时短路可以用其他方式很容易地expression。


例如,如果一个类T有关联&&|| 运营商,那么expression

 auto x = a && b || c; 

其中abcT型的expression式,可以用短路表示为

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

或者也许更清楚的是

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

明显的冗余保留了操作员调用的任何副作用。


虽然lambda重写更详细,但是更好的封装允许定义这样的操作符。

我不完全确定以下所有的标准(仍然有点influensa),但它与Visual C ++ 12.0(2013)和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; } } } } 

输出:

 000  - > !!  !  || 假
 001  - >!  !  || 真正
 010  - > !!  !  || 假
 011  - > !!  !  || 真正
 100  - >!  && !!  || 假
 101  - > !!  && !!  || 真正
 110  - >!  && !! 真正
 111  - > !!  && !! 真正

这里每个!! bang-bang显示转换为bool ,即参数值检查。

由于编译器可以很容易地做到这一点,并进一步优化它,这是一个可行的实现,任何不可能的要求都必须与不可能的要求放在同一个范围内,即一般情况下的不确定性。

tl; dr :由于需求非常低(谁会使用这个特性?),而不是相当高的成本(需要特殊的语法),这是不值得的。

首先想到的是,运算符重载只是一个写函数的奇特方式,而布尔运算符||&&是buitlin的东西。 这意味着编译器可以自由地将它们短路,而带有非boolean yz的expression式x = y && z必须导致调用像X operator&& (Y, Z)这样的函数。 这意味着y && z只是一个奇怪的方法来编写operator&&(y,z) ,它只是一个奇怪命名的函数的调用,在调用函数之前必须评估两个参数(包括任何可能认为短路适用)。

然而,有人可能会认为应该可以使&&运算符的翻译更加复杂一些,就像new运算符被翻译成调用operator new的函数operator new然后构造函数调用一样。

从技术上讲,这是没有问题的,人们必须定义一个特定于语言语法的前提条件,以实现短路。 然而,短路的使用将局限于Y可传递给X ,否则必须有关于如何实际进行短路(即仅从第一个参数计算结果)的附加信息。 结果将不得不看起来像这样:

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

一个很less想要超载operator||operator&& ,因为在非布尔上下文中,编写a && b实际上很直观的情况很less。 我所知道的唯一例外是expression式模板,例如用于embedded式DSL。 只有less数几个案例会受益于短路评估。 expression式模板通常不会,因为它们用于形成稍后评估的expression式树,所以您始终需要expression式的两侧。

简而言之,无论是编译器编写者还是标准编写者,都不需要跳过这个循环,定义和实现额外的繁琐语法,仅仅因为百万计的人可能会认为在用户定义的operator&&operator|| – 只是要得出结论,这不是每手写逻辑的努力。

逻辑运算符短路是允许的,因为它是对相关真值表的评估中的“优化”。 它是逻辑本身的一个function ,并且定义了这个逻辑。

实际上是为什么重载&&||的原因 不要短路?

自定义的重载逻辑运算符没有义务遵循这些真值表的逻辑。

但为什么他们在重载时会失去这种行为?

因此,整个function需要按照正常进行评估。 编译器必须将其视为一个正常的重载运算符(或函数),它仍然可以像应用其他函数一样应用优化。

人们由于各种原因而超载逻辑运算符。 例如; 它们可能在特定的领域具有特定的含义,而不是人们习以为常的“正常”逻辑。

短路是因为“和”和“或”的真值表。 你怎么知道用户将要定义什么操作?你怎么知道你不需要评估第二个操作员?

兰巴达斯不是引进懒惰的唯一方式。 在C ++中使用expression式模板 ,懒惰的评估是相对直接的。 不需要关键字lazy ,它可以在C ++ 98中实现。 expression式树已经在上面提到。 expression模板是穷人(但聪明)的人的expression树。 诀窍是将expression式转换为Expr模板的recursion嵌套实例化树。 施工后单独评估树木。

以下代码实现短路&&|| 只要它提供了logical_andlogical_or free函数,并且它可以转换为bool ,就可以用于S类的运算符。 代码是在C ++ 14中,但是这个想法也适用于C ++ 98。 看到现场的例子

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

但布尔运算符有这种行为,为什么它应该限制在这种单一types?

我只想回答这一部分。 原因是内置的&&|| expression式不能像重载操作符那样用函数实现。

编译器对特定expression式的理解内置了短路逻辑,这很容易。 就像任何其他内置的控制stream程一样。

但是运算符重载是用函数来实现的,它有特定的规则,其中之一就是在函数调用之前所有用作参数的expression式都被计算。 显然可以定义不同的规则,但这是一个更大的工作。