运算符&&
和||
的短路行为对于程序员来说是一个了不起的工具。
但为什么他们在超载时会失去这种行为?我理解运算符只是函数的语法糖,但bool
的运算符有这种行为,为什么它应该限制在这种单一类型?这背后有任何技术推理吗?
答案 0 :(得分:148)
所有设计流程都会导致互不兼容的目标之间的妥协。不幸的是,C ++中重载&&
运算符的设计过程产生了令人困惑的最终结果:省略了&&
所需的特性 - 它的短路行为 -
这个设计过程如何在这个不幸的地方结束的细节,我不知道的。然而,有必要了解后来的设计过程如何将这种不愉快的结果考虑在内。在C#中,重载的&&
运算符 短路。 C#的设计师是如何实现这一目标的?
其他一个答案表明“lambda lift”。那就是:
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);
如您所见,左侧始终被评估。如果确定它是“假的”,那么结果就是这样。否则,将评估右侧,并调用 eager 用户定义的运算符&
。
||
运算符以类似的方式定义,作为operator true和急切|
运算符的调用:
cresult = C.true(tempLeft) ? tempLeft : C.|(tempLeft , cright);
通过定义所有四个运算符 - true
,false
,&
和|
- C#不仅可以说cleft && cright
,还可以说非cleft & cright
- 短路if (cleft) if (cright) ...
,以及c ? consequence : alternative
,while(c)
和&&
,等等。
现在,我说所有设计过程都是妥协的结果。在这里,C#语言设计人员设法使||
和{{1}}短路,但这样做需要重载四个运算符而不是两个,有些人觉得很困惑。运算符true / false功能是C#中最不被理解的功能之一。拥有C ++用户熟悉的合理且直截了当的语言的目标是反对短路的愿望以及不实施lambda提升或其他形式的懒惰评估的愿望。我认为这是一个合理的妥协立场,但重要的是要认识到 是一个妥协的立场。只是一个不同的妥协位置,而不是C ++的设计者。
如果这些运算符的语言设计主题让您感兴趣,请考虑阅读我的系列文章,了解为什么C#没有在可空的布尔值上定义这些运算符:
http://ericlippert.com/2012/03/26/null-is-not-false-part-one/
答案 1 :(得分:43)
关键是(在C ++ 98的范围内)右侧操作数将作为参数传递给重载的操作符函数。在这样做时,它已经被评估。 operator||()
或operator&&()
代码无法做或不可以避免这种情况。
原始运算符是不同的,因为它不是一个函数,而是在较低级别的语言中实现。
其他语言功能可以在语法上可能对右手操作数进行非评估。但是,他们并不烦恼,因为只有极少数情况下语义上有用。 (就像? :
一样,根本无法进行重载。
(他们花了16年时间才将lambdas纳入标准......)
至于语义用法,请考虑:
objectA && objectB
归结为:
template< typename T >
ClassA.operator&&( T const & objectB )
在这里考虑一下你对objectB(未知类型)的确切要求,除了将转换运算符调用到bool
,以及如何将其转换为单词语言定义。
如果你 调用转换为bool,那么......
objectA && obectB
做同样的事情,现在做到了吗?那么为什么首先要超负荷?
答案 2 :(得分:26)
必须考虑,设计,实施,记录和发布功能。
现在我们想到了它,让我们看看为什么现在可能很容易(而且很难做到)。另外请记住,只有有限的资源,所以添加它可能会削减其他东西(你想放弃什么呢?)。
理论上,所有运营商都可以允许短路行为只有一个&#34;次要&#34; 其他语言功能,从C ++ 11开始(当lambdas被引入时,32年后&#34; C与类&#34;开始于1979年,在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#解决方案要强得多,每个函数都用于单独的定义。
答案 3 :(得分:12)
回顾性合理化,主要是因为
为了保证短路(不引入新语法),运算符必须限制为结果实际可以转换为bool
的第一个参数,并且
在需要时,可以通过其他方式轻松表达短路。
例如,如果某个类T
已关联&&
和||
运算符,那么表达式
auto x = a && b || c;
其中a
,b
和c
是T
类型的表达式,可以用短路表示为
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重写更详细,但其更好的封装允许人们定义这样的运算符。
我不完全确定以下所有内容的标准一致性(仍然有点影响),但它使用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 -> !! !! || false 001 -> !! !! || true 010 -> !! !! || false 011 -> !! !! || true 100 -> !! && !! || false 101 -> !! && !! || true 110 -> !! && !! true 111 -> !! && !! true
此处每个!!
bang-bang显示转换为bool
,即参数值检查。
由于编制者可以轻松地做同样的事情,并且另外对其进行优化,因此这是一种已证明的可能的实施方式,任何不可能性的要求都必须与一般的不可能性要求(即一般性的枷锁)属于同一类别。
答案 4 :(得分:5)
tl; dr :由于需求非常低(谁会使用该功能?)而不是相当高的成本(需要特殊语法),这是不值得的。
首先想到的是,运算符重载只是编写函数的一种奇特方式,而运算符||
和&&
的布尔版本是buitlin。这意味着编译器可以自由地将它们短路,而带有非布符x = y && z
和y
的表达式z
必须导致对X operator&& (Y, Z)
之类的函数的调用。 。这意味着y && z
只是编写operator&&(y,z)
的一种奇特方式,它只是一个奇怪命名函数的调用,其中两个参数必须在调用函数之前进行评估(包括任何会认为适合短路的东西。)
然而,有人可能会争辩说,应该可以使&&
运算符的翻译更加复杂,就像new
运算符被转换为调用函数{{1}一样。然后是构造函数调用。
从技术上讲,这没有问题,人们必须定义特定于启用短路的前提条件的语言语法。但是,短路的使用仅限于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&&
实际上是直观的情况。我所知道的唯一例外是表达模板,例如用于嵌入式DSL。只有少数几个案例可以从短路评估中受益。表达式模板通常不会,因为它们用于形成稍后评估的表达式树,因此您始终需要表达式的两侧。
简而言之:编译器编写者和标准作者都没有觉得需要跳过箍并定义和实现额外繁琐的语法,因为百万分之一的人可能会认为在用户定义上进行短路会很好a && b
和operator&&
- 只是为了得出这样的结论:与每手写逻辑相比,它的努力并不少。
答案 5 :(得分:5)
允许短路逻辑运算符,因为它是评估相关真值表时的“优化”。它是逻辑本身的功能,并且定义了这个逻辑。
实际上是否存在过载
&&
和||
不会短路的原因?
自定义重载逻辑运算符没有义务遵循这些真值表的逻辑。
但为什么他们在超载时会失去这种行为?
因此需要按照正常情况评估整个功能。编译器必须将其视为普通的重载操作符(或函数),它仍然可以像对待任何其他函数一样应用优化。
由于各种原因,人们会使逻辑运算符超载。例如;它们可能在特定领域具有特定含义,而不是人们习以为常的“正常”逻辑。
答案 6 :(得分:4)
短路是因为“和”和“或”的真值表。您如何知道用户将要定义的操作以及您如何知道您不必评估第二个操作符?
答案 7 :(得分:4)
Lambda不是引入懒惰的唯一方法。使用C ++中的Expression Templates,延迟评估相对简单。不需要关键字lazy
,它可以在C ++ 98中实现。表达树已经在上面提到了。表达模板很差(但很聪明)人的表达树。诀窍是将表达式转换为Expr
模板的递归嵌套实例化树。树在施工后单独评估。
以下代码为类&&
实现了短路||
和S
运算符,只要它提供logical_and
和logical_or
个免费函数,它就是可转换为bool
。代码在C ++ 14中,但这个想法也适用于C ++ 98。请参阅 live example 。
#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;
}
答案 8 :(得分:3)
但是bool的运算符有这种行为,为什么要限制这种类型?
我只想回答这一部分。原因是内置的&&
和||
表达式没有使用函数实现,因为重载运算符是。
内置编译器对特定表达式的理解的短路逻辑很容易。它就像任何其他内置控制流一样。
但是运算符重载是用函数实现的,它们具有特定的规则,其中之一就是在调用函数之前对所有用作参数的表达式进行求值。显然可以定义不同的规则,但这是一项更大的工作。