为什么过载和不短路?

操作员 &&||的短路行为对于程序员来说是一个神奇的工具。

但是为什么它们在超载时会失去这种行为呢?我理解操作符只是函数的语法糖,但是 bool的操作符有这种行为,为什么要限制在这个单一类型?这背后有什么技术原因吗?

13275 次浏览

关键是(在 C + + 98的范围内)右边的操作数将作为参数传递给重载的操作符函数。这样做,它已经被评估过了。没有什么 operator||()或者 operator&&()代码可以或者不可以避免这种情况。

原始运算符不同,因为它不是函数,而是在语言的较低级别实现的。

附加的语言特性 可以使右边的操作数在语法上不能求值 有可能。但是,他们没有麻烦,因为只有少数情况下,这将是 语义上的有用的。(就像 ? :一样,根本不可能超载。

(他们花了16年的时间才把 Lambdas 打入标准...)

至于语义使用,请考虑:

objectA && objectB

这可以归结为:

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

考虑一下除了调用 bool的转换运算符之外,在这里您到底想对 objectB (未知类型)做什么,以及如何将其转换为语言定义中的单词。

如果呼叫转换到布尔,好..。

objectA && obectB

那么为什么一开始会超载呢?

短路是因为“和”和“或”的真值表。如何知道用户将定义什么操作,如何知道不必计算第二个操作符?

一个特性必须被思考、设计、实现、记录和发布。

现在我们想到了这一点,让我们看看为什么它现在可能很容易(当时很难做到)。还要记住,资源的数量是有限的,所以添加它可能会砍掉其他东西(你想放弃什么?).


理论上,从 C + + 11开始,所有的运算符都可以只允许一个“次要”附加语言功能的短路行为(当 lambdas 被引入时,在1979年“ C with class”开始后32年,在 c + + 98之后仍然是可观的16) :

C + + 只需要一种方法来将参数注释为惰性求值-一个隐藏的 lambda-以避免求值,直到必要和允许(前提条件满足)。


理论上的特性是什么样子的(记住,任何新特性都应该是可以广泛使用的) ?

应用于函数参数的注释 lazy使函数成为期望函数的模板,并使编译器将表达式打包到函数中:

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 保持隐藏,并将被调用最多一次。
除了减少共同子表达式消除的机会之外,由于这个原因应该有 没有性能下降


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

虽然这个 语言特征语言特征不会破坏任何代码,但它会利用它微妙地改变任何 API,这意味着在现有库中的任何使用都将是无声的突破性改变。

顺便说一句: 这个特性虽然更容易使用,但是比 C # 解决方案(将 &&||分成两个函数,每个函数用于单独的定义)要强大得多。

追溯合理化,主要是因为

  • 为了保证短路(没有引入新的语法) ,操作符必须限制在 结果实际的第一个参数可转换为 bool,以及

  • 当需要时,短路可以很容易地以其他方式表示。


例如,如果类 T具有关联的 &&||运算符,则表达式

auto x = a && b || c;

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

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 重写更加冗长,但是它更好的封装允许使用 定义这样的操作符。

I & rsquo; m 不完全确定下面所有代码的标准一致性(仍然有点流感) ,但是它使用 Visual C + + 12.0(2013)和 MinGW g + + 4.8编译得很干净:

#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 -> !! !! || false
001 -> !! !! || true
010 -> !! !! || false
011 -> !! !! || true
100 -> !! && !! || false
101 -> !! && !! || true
110 -> !! && !! true
111 -> !! && !! true

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

由于编译器可以很容易地做同样的事情,而且还可以对其进行优化,因此这是一个经过演示的可能实现,任何不可能的声明都必须与一般的不可能声明放在同一类别中,即一般的胡说八道。

Dr : 由于需求非常低(谁会使用该特性?) ,因此不值得这样做与相当高的成本相比(需要特殊的语法)。

我首先想到的是,运算符重载只是编写函数的一种花哨的方式,而操作符 ||&&的布尔版本是 buitlin 的东西。这意味着编译器可以自由地将它们短路,而具有非布尔 yz的表达式 x = y && z必须调用类似于 X operator&& (Y, Z)的函数。这意味着 y && z只是编写 operator&&(y,z)的一种花哨的方式,operator&&(y,z)只是一个名称奇怪的函数的调用,在调用该函数之前,必须对 都有参数进行求值(包括任何认为适合短路的东西)。

然而,有人可能会争辩说,&&操作符的转换应该更复杂一些,就像 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);
}

很少有人希望超载 operator||operator&&,因为很少有情况下,在非布尔上下文中编写 a && b实际上是直观的。我所知道的唯一例外是表达式模板,例如用于嵌入式 DSL。而且只有少数病例能从短路评估中获益。表达式模板通常不需要,因为它们用于形成稍后计算的表达式树,所以总是需要表达式的两边。

简而言之: 无论是编译器作者还是标准作者都不觉得有必要跳过这些环节,定义并实现额外的繁琐语法,仅仅因为百万分之一的人可能会认为,在用户定义的 operator&&operator||上进行短路是件好事——只是为了得出结论,这并不比每手写逻辑少。

所有的设计过程都会导致相互不兼容的目标之间的妥协。不幸的是,在 C + + 中为重载的 &&操作符进行的设计过程产生了一个令人困惑的最终结果: 您希望从 &&获得的特性——它的短路行为——被忽略了。

设计过程的细节如何在这个不幸的地方结束,那些我不知道。然而,看看后来的设计过程是如何将这种不愉快的结果考虑在内是相关的。在 C # 中,超载的 &&操作员 短路。C # 的设计者是如何做到这一点的?

另一个答案是“ Lambda 举重”,即:

A && B

在道德上等同于:

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

第二个参数使用一些延迟计算机制,以便在计算时产生表达式的副作用和值。重载运算符的实现只在必要时执行延迟计算。

这不是 C # 设计团队所做的。(旁白: 虽然 lambda 提升 时,我做了什么时候做的 ??操作符的 表达式树表示法,这需要某些转换操作被懒惰地执行。然而,详细描述这一点将是一个主要的题外话。简而言之: Lambda 起重机工作,但足够重量,我们希望避免它。)

相反,C # 解决方案将问题分解为两个独立的问题:

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

因此,通过使 &&直接超载为非法,问题得到了解决。相反,在 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 # 需要两个操作符。)

考虑下面这种形式的陈述:

C cresult = cleft && cright;

编译器为此生成代码,就像您编写的伪 C # 一样:

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

如您所见,总是计算左侧。如果它被确定为“虚假的”,那么它就是结果。否则,将计算右侧,并调用 渴望用户定义操作符 &

||运算符以类似的方式定义,作为运算符 true 和热切的 |运算符的调用:

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

通过定义所有四个操作符—— truefalse&|—— C # 不仅允许说 cleft && cright,还允许说非短路 cleft & cright,以及 if (cleft) if (cright) ...c ? consequence : alternativewhile(c),等等。

我说过所有的设计过程都是妥协的结果。在这里,C # 语言设计人员设法使 &&||短路,但是这样做需要重载 操作符而不是 操作符,有些人会感到困惑。操作符 true/false 特性是 C # 中最难理解的特性之一。拥有一个 C + + 用户熟悉的明智而直接的语言的目标,与希望拥有短路的愿望和不实现 lambda 提升或其他形式的惰性求值的愿望相反。我认为这是一个合理的妥协立场,但重要的是要认识到,这是 的一个妥协立场。只是 与众不同的折衷立场比 C + + 的设计者落到了地步。

如果你对这些操作符的语言设计感兴趣,可以阅读我的关于为什么 C # 不在可空布尔值上定义这些操作符的系列文章:

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

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

超负荷 &&||不短路的原因是什么?

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

但是为什么它们在超载时会失去这种行为呢?

因此,整个函数需要按照正常值进行计算。编译器必须将其视为一个正常的重载操作符(或函数) ,并且它仍然可以像对待其他任何函数一样应用优化。

由于各种原因,人们使逻辑运算符超载。例如,它们可能在一个特定的领域有特定的含义,而这个领域并不是人们所习惯的“正常”逻辑。

但是 bool 的运算符有这种行为,为什么要限制在这个单一类型呢?

我只想回答这一部分。原因是内置的 &&||表达式不像重载运算符那样使用函数实现。

将短路逻辑内置到编译器对特定表达式的理解中很容易。它就像任何其他内置的控制流一样。

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

Lambdas 并不是引入懒惰的唯一途径。在 C + + 中使用 表达式模板进行惰性评估是相对简单的。不需要关键字 lazy,它可以在 C + + 98中实现。上面已经提到了表达式树。表达式模板是可怜(但聪明)的人的表达式树。诀窍是将表达式转换为 Expr模板的递归嵌套实例树。构造完成后分别对树进行评价。

下面的代码实现了 S类的短路 &&||运算符,只要它提供 logical_andlogical_or自由函数,并且可以转换为 bool。代码是在 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;
}