实际上,为什么&&和||重载?不短路吗?


137

运算符的短路行为&&,并||是程序员的好工具。

但是,为什么它们在过载时会丢失这种行为?我知道运算符只是函数的语法糖,但是运算符bool具有这种行为,为什么应该将其限制为单一类型呢?这背后有什么技术依据吗?


1
@PiotrS。这个问题可能是答案。我猜这个标准可以为此目的定义一个新的语法。大概喜欢 operator&&(const Foo& lhs, const Foo& rhs) : (lhs.bars == 0)
iFreilicht 2014年

1
@PiotrS .:考虑三态逻辑:{true, false, nil}。由于nil&& x == nil可能会短路。
MSalters 2014年

1
@MSalters:考虑一下std::valarray<bool> a, b, c;,您如何看待a || b || c短路?
Piotr Skotnicki 2014年

4
@PiotrS .:我认为至少存在一种非布尔类型的短路是有意义的。我并不是在说短路对于每种非布尔类型都是有意义的。
MSalters 2014年

3
还没有人提到这一点,但是还存在向后兼容性的问题。除非特别注意限制发生这种短路的情况,否则这种短路可能会破坏现有代码,从而使代码过载operator&&operator||取决于所评估的两个操作数。向现有语言添加功能时,保持向后兼容性很重要(或者应该很重要)。
David Hammen 2014年

Answers:


151

所有设计过程都会导致相互不兼容的目标之间的折衷。不幸的是,&&C ++ 中用于重载运算符的设计过程产生了一个令人困惑的最终结果:想要的功能&&(其短路行为)被省略了。

我不知道的那个设计过程的细节最终出现在这个不幸的地方。但是,重要的是要了解以后的设计过程如何考虑到这种令人不快的结果。在C#中,重载&&运算符正在短路。C#的设计师是如何实现这一目标的?

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

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

如您所见,总是评估左侧。如果确定为“假假”,则为结果。否则,将评估右侧,并调用急切的用户定义的运算符&

||操作者在类似的方式定义的,如操作者的调用真实和渴望|操作者:

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

通过定义所有四个运营商- ,,true 和- C#允许你不仅说,而且非短路,而且,和和,等等。false&|cleft && crightcleft & crightif (cleft) if (cright) ...c ? consequence : alternativewhile(c)

现在,我说所有设计过程都是妥协的结果。在这里,C#语言设计人员设法实现了短路&&||正确的选择,但这样做需要重载四个运算符,而不是两个,这会使某些人感到困惑。运算符的真/假功能是C#中鲜为人知的功能之一。C ++用户熟悉的一种简单易懂的语言的目标遭到了短路和不执行Lambda提升或其他形式的惰性求值的渴望的反对。我认为这是一个合理的妥协立场,但重要的是要意识到这一个妥协立场。只是不同 妥协的地位比C ++的设计师落地。

如果您对此类运算符的语言设计主题感兴趣,请考虑阅读我的系列文章,其中介绍C#为什么不在可空布尔值上定义这些运算符:

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


1
@Deduplicator:您可能也有兴趣阅读这个问题和答案:stackoverflow.com/questions/5965968/...
埃里克利珀

5
在这种情况下,我认为折衷是合理的。复杂的东西是什么,只是一个类库架构师必须关注,而换来的并发症,它使消费图书馆更容易,更直观。
科迪·格雷

1
@EricLippert我相信Envision表示他看到了这篇文章,以为是你...然后看到他是对的。他并不是说your post无关紧要。His noticing your distinct writing style是无关紧要的。
WernerCD 2014年

5
Microsoft团队没有获得足够的信誉,原因是:(1)在C#中做出正确的正确努力,以及(2)多次正确地做到这一点。
codenheim 2014年

2
@Voo:如果选择实现隐式转换,bool则可以使用&&||而无需实现operator true/falseoperator &/|在C#中没有问题。正是在没有转换为bool可能不需要的情况下才出现问题。
埃里克·利珀特

43

关键是(在C ++ 98的范围内)右侧操作数将作为参数传递给重载的运算符。这样做时,将对其进行评估。没有什么对operator||()operator&&()代码可以或不可以做,就可以避免这种情况。

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

附加的语言功能可能使语法上对右操作数的非评估成为可能。但是,它们没有打扰,因为在少数情况下这在语义上会有用。(就像一样? :,根本无法用于重载。

(他们花了16年的时间才能将lambda纳入标准...)

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

objectA && objectB

归结为:

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

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

而且,如果转换为bool,那么...

objectA && obectB

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


7
您的逻辑错误很可能是在当前定义的语言中推断出不同定义的语言的影响。在过去,很多新手都会这样做。“虚拟构造函数”。为了使他们摆脱这种框框式的思考,进行了过多的解释。无论如何,由于内置运算符发生短路,因此可以保证不进行参数评估。如果为用户定义的过载定义了短路,则此类保证也将存在。
干杯和健康。-Alf

1
@iFreilicht:我基本上说了与Deduplicator或Piotr相同的内容,只是用了不同的词。我对编辑后的答案进行了详细说明。这样更方便,直到最近才存在必需的语言扩展(例如lambda),而且其好处还是微不足道。早在1998年,负责人员曾几次“喜欢” 编译器构建者尚未完成的工作,但事与愿违。(请参阅export。)
DevSolar 2014年

9
@iFreilicht:bool任一类的转换运算符也可以访问所有成员变量,并且可以与内置运算符一起正常工作。除了转换为布尔之外,其他任何内容对于短路评估都没有意义!尝试从语义的角度来看,而不是语法一个办法是:什么,你会试图实现的,不是怎么你会去做。
DevSolar 2014年

1
我不得不承认我想不出一个。存在短路的唯一原因是因为它节省了布尔运算的时间,并且您可以在对所有参数求值之前就知道表达式的结果。对于其他AND操作,情况并非如此,这就是为什么&&&操作符不同。感谢您帮助我实现这一目标。
iFreilicht

8
@iFreilicht:相反,短路的目的是因为左手侧的计算可以确定右手侧的先决条件的真相if (x != NULL && x->foo)需要短路,不是为了速度,而是为了安全。
埃里克·利珀特

26

必须考虑,设计,实现,记录和交付功能。

现在我们想到了,让我们看看为什么现在可能很容易(然后很难做到)。还要记住,资源有限,因此添加资源可能会砍掉其他东西(您想为此放弃什么?)。


从理论上讲,从C ++ 11开始,所有运算符都只能使用一种“次要”的附加语言功能允许短路行为(当引入lambda时,即1979年“带有类的C”开始32年之后,仍然值得尊敬的16)在c ++ 98之后):

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#拆分解决方案要严格得多&&,它||分为两个功能,每个功能用于单独定义。


6
@iFreilicht:任何形式的问题“为什么功能X都不存在?” 有相同的答案:存在该功能必须经过考虑,认为是一个好主意,设计,指定,实施,测试,记录并交付给最终用户。如果这些事情中的任何一个没有发生,则没有任何功能。其中一项功能与您建议的功能无关。找出哪个是历史研究问题;如果您关心其中哪一项从未完成,请开始与设计委员会的成员进行交谈。
埃里克·利珀特

1
@EricLippert:并且,根据其原因,重复执行直到实施:也许有人认为它太复杂了,没有人认为需要进行重新评估。或者,重新评估由于与先前举行的拒绝原因不同而结束。(btw:添加了您的评论要点)
Deduplicator

@Deduplicator使用表达式模板,不需要lazy关键字和lambdas。
2014年

除了历史记录,请注意原始的Algol 68语言具有“过程”强制(以及反过程),这意味着在上下文需要结果类型而不是函数类型时隐式调用无参数函数。这意味着,在需要“无参数函数返回T”类型(在Algol 68中拼写为“ proc T”)的值的位置上,类型T的表达式将隐式转换为返回给定表达式的函数主体(隐式lambda)。该功能在1973年的语言修订版中被删除(与递减过程不同)。
Marc van Leeuwen 2014年

...对于C ++,类似的方法可能是声明运算符,例如&&要接受“返回T的函数的指针”类型的一个参数,以及另一种允许将T类型的参数表达式隐式转换为lambda表达式的转换规则。请注意,这不是普通的转换,因为它必须在语法级别进行:在运行时将类型T 的转换为函数将是没有用的,因为已经完成了评估。
Marc van Leeuwen 2014年

13

追溯合理化的主要原因是

  • 为了保证短路(不引入新语法),必须将运算符限制为 结果实际的第一个参数转换为bool

  • 必要时,可以用其他方式轻松表示短路。


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

auto x = a && b || c;

其中abc是类型的表达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-> !! !! || 假
001-> !! !! || 真正
010-> !! !! || 假
011-> !! !! || 真正
100-> !! && !! || 假
101-> !! && !! || 真正
110-> !! && !! 真正
111-> !! && !! 真正

在这里,每个!!爆炸都显示到的转换bool,即参数值检查。

由于编译器可以轻松地做到这一点,并对其进行额外的优化,因此,这是一种可能的实现方式,任何不可能的主张都必须与不可能的主张归为同一类,即通常是大锁。


我喜欢您的短路替代品,尤其是三元替代品,该替代品尽可能地接近您。
iFreilicht

您错过了-的短路现象,&&这需要在if (!a) { return some_false_ish_T(); }您的第一个项目符号上加上--短路是关于可转换为bool的参数,而不是结果。
Arne Mertz 2014年

@ArneMertz:您对“缺少”的评论显然毫无意义。关于它的评论,是的,我知道。转换bool是必要做的短路。
干杯和健康。-阿尔夫

@ Cheersandhth.-Alf关于缺失的评论是针对您的答案的第一次修订版,在该版本中,您短路了,||但没有短路&&。另一条评论的目的是“必须限制在只能转换为bool的结果 ”-它应该显示为“仅限于可转换为bool的参数 ” imo。
Arne Mertz 2014年

@ArneMertz:好的,重新版本控制,抱歉,我的编辑缓慢。重新限制,不是必须要限制的运算符结果,因为必须将其转换bool为检查表达式中其他运算符的短循环。就像,a && b必须将的结果转换为bool以检查逻辑OR​​中的短路a && b || c
干杯和健康。-Alf 2014年

5

tl; dr:不值得付出努力,因为与相当高的成本(需要特殊语法)相比,需求非常低(谁会使用该功能?)。

首先想到的是,运算符重载只是编写函数的一种奇特方式,而运算符的布尔版本||&&buitlin则是这样。这意味着编译器可以自由地将它们短路,而表达式x = y && z为非布尔值y并且z必须导致调用像这样的函数X operator&& (Y, Z)。这意味着这y && z只是一种奇特的编写方式,operator&&(y,z)而这只是对一个名字奇怪的函数的调用,在调用该函数之前,必须对两个参数进行评估(包括任何认为短路的原因)。

但是,有人可能会认为,应该有可能使&&运算符的翻译更加复杂,就像new运算符被翻译成先调用函数再operator new进行构造函数调用一样。

从技术上讲这不是问题,必须定义一种特定的语言语法,以实现短路的前提条件。然而,使用短路将被限制到案件Y是convetible来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||and operator&&,因为很少有一种情况a && b是在非布尔上下文中实际上是直观的。我知道的唯一例外是表达式模板,例如,用于嵌入式DSL的表达式模板。仅有少数几个案例会从短路评估中受益。表达式模板通常不使用,因为它们用于形成表达式树,稍后再对它们进行求值,因此您始终需要表达式的两面。

简而言之:无论是编译器作者还是标准作者,都没有必要跳过箍并定义和实现其他繁琐的语法,仅因为百万分之一的人可能会想到,对用户定义进行短路将是一件好事,operator&&并且operator||-得出的结论是,与编写每一个逻辑逻辑相比,它的工作量不少。


成本真的那么高吗?D编程语言允许声明参数,lazy从而将作为参数给出的表达式隐式转换为匿名函数。这使被调用函数可以选择是否调用该参数。因此,如果该语言已经具有lambda,则所需的额外语法非常小。“伪代码”:X和(A a,懒惰B b){如果(cond(a)){返回short(a);}其他{实际(a,b()); }}
BlackJack 2014年

@BlackJack可以通过接受来实现惰性参数std::function<B()>,这会产生一定的开销。或者,如果您愿意将其内联 template <class F> X and(A a, F&& f){ ... actual(a,F()) ...}。并且可能用“ normal” B参数重载它,以便调用者可以决定选择哪个版本。该lazy语法可能更方便,但具有一定的性能折衷。
Arne Mertz 2014年

1
std::functionvs 的问题之一lazy是第一个可以被多次评估。foo用作的惰性参数foo+foo仍然只评估一次。
MSalters 2014年

“短路的使用仅限于Y可以让X接受的情况” ...不,它仅限于X可以单独计算的情况Y。非常不一样。 std::ostream& operator||(char* a, lazy char*b) {if (a) return std::cout<<a;return std::cout<<b;}。除非您非常随意地使用“转换”。
Mooing Duck

1
@Sumant,他们可以。但是您也可以operator&&手动写出短路定制的逻辑。问题不在于是否可行,而是为什么没有一种便捷的方法。
Arne Mertz 2014年

5

Lambdas不是引入懒惰的唯一方法。使用C ++中的Expression Templates,惰性评估相对简单。不需要关键字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;
}

5

允许短路逻辑运算符,因为这是对关联的真值表的评估中的“优化”。它是逻辑本身的函数,并且已定义此逻辑。

过载&&||不短路的确有原因吗?

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

但是,为什么它们在过载时会丢失这种行为?

因此,整个功能需要按照常规进行评估。编译器必须将其视为普通的重载运算符(或函数),并且仍然可以像使用其他任何函数一样应用优化。

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


4

短路是因为“和”和“或”的真值表。您将如何知道用户将要定义的操作以及如何不必评估第二个运算符?


如评论和@Deduplicators答案中所述,附加的语言功能将是可能的。我知道它现在不起作用。我的问题是没有该功能的原因是什么。
iFreilicht

考虑到我们必须冒险猜测用户的定义,这肯定是一个复杂的功能!
nj-ath 2014年

什么: (<condition>)符声明指定要在哪,第二个参数是不求值的条件之后?
iFreilicht

@iFreilicht:您仍然需要一个替代的一元函数主体。
MSalters 2014年

3

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

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

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

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


1
我不知道任何考虑了问题是否中过载&&||,应该被允许?C ++没有机制允许重载除函数调用之外的任何行为,这一事实说明了为什么这些函数的重载不能执行其他任何操作,但并不能解释为什么这些运算符首先是可重载的。我怀疑真正的原因仅仅是因为他们没有经过深思熟虑就被扔进了操作员列表中。
supercat
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.