我可以执行 x = y = z。为什么 C++ 不允许 x < y < z?

18

我是编程新手,有一个关于在单行上使用多个运算符的问题。

比如说,我有

int x = 0;
int y = 1;
int z = 2;

在这个例子中,我可以使用一系列的赋值运算符:x = y = z;

但是为什么我不能使用:x < y < z;


31
你可以使用 x<y<z。但你需要理解它的作用,可能与你预期的不同。 - Marc Glisse
值得一提的是,已经有人提出了将 x < y < z 的行为更改为更像 Python 的建议(我想到的名称是“一致的比较”)。您可以进一步了解一下,看看是否有公开的说明,解释为什么被拒绝了。 - chris
1
“(4 > y > 1)”在C++中是一个有效的语句吗?如果是,你如何评估它?为什么“(0 < 5 < 3)”返回true? - phuclv
4
这里需要做出一个任意的选择。你可以决定在语言中所有运算符之间保持规则一致,这样 x<y<z 就像 x+y+z 一样处理,所以你计算 x+y 然后将 +z 加到其结果,或者你定义语法与通常的数学表示一致,这意味着你将比较运算符与其他运算符不一致地处理,并使 x<y<z 表示为 tmp=y;x<tmp and tmp<z(我添加了 tmp 来表示只计算一次 y 表达式)。这是一个任意的选择。 - Bakuriu
@Bakuriu:如果比较运算符产生的布尔结果不能与其他比较运算符一起使用,那么可以通过使它们产生一个__ComparisonThanResult类型来扩展而不会创建歧义。该类型可转换为布尔值,但封装了比较结果和右操作数的常量引用,并重载比较运算符以接受该类型[因此{result,&rhs} < value等同于{result && (rhs < value) : &rhs} - supercat
可以使用(x < y) == (y < z)或者使用!=等。 - Jay
8个回答

22
你可以这样做,但结果可能与你期望的不同。
在某些情况下,布尔值(bool)可以隐式转换为整型(int)。此时,false 会被转换成 0,而 true 会被转换成 1
举个例子,我们有以下代码:
int x = -2;
int y = -1;
int z = 0;

表达式x < y < z将被评估为:

x < y < z
(x < y) < z
(-2 < -1) < 0
(true) < 0
1 < 0
false

运算符=不同,因为它的工作方式不同。它返回其左侧操作数(赋值操作后),因此您可以链接它:

x = y = z
x = (y = z)
//y holds the value of z now
x = (y)
//x holds the value of y now

当我尝试使用 x < y < z 时,gcc 给出了以下警告:
prog.cc:18:3: warning: comparisons like 'X<=Y<=Z' do not have their mathematical meaning [-Wparentheses]
   18 | x < y < z;
      | ~~^~~

这很容易理解。它可以工作,但并不像人们期望的那样。



注意:类可以定义自己的operator=,当链接时可能会做出意想不到的事情(没有什么比不遵循基本规则和习惯的运算符更能表达“我恨你”的意思了)。幸运的是,对于像int这样的原始类型,这是不可能的。

class A
{
public:
    A& operator= (const A& other) 
    {
        n = other.n + 1;
        return *this;
    }

    int n = 0;
};

int main()
{
    A a, b, c;
    a = b = c;
    std::cout << a.n << ' ' << b.n << ' ' << c.n; //2 1 0, these objects are not equal!
}

甚至更简单:
class A
{
public:
    void operator= (const A& other) 
    {
    }

    int n = 0;
};

int main()
{
    A a, b, c;
    a = b = c; //doesn't compile
}

3
=运算符返回的类型和值与其“提供”的类型和值不同。赋值表达式的结果是一个lvalue,指向左操作数。当左操作数不是类类型时,被赋的值是右操作数转换为左操作数类型后的值。例如,对于int yy = 3.5的值为3,而不是3.5。 - Eric Postpischil
1
@EricPostpischil 更改了措辞。 - Yksisarvinen

14

x = y = z

对于基本数据类型的内置赋值运算符=,它会返回被赋值对象的引用。这就是为什么上面的语句可以正常工作的原因。

y = z 将返回 y 的引用,然后
x = y

x < y < z

"小于"运算符<返回truefalse,导致其中一个比较不是与实际变量比较而是与truefalse比较。

x < y 将返回 truefalse,然后
truefalse < z,此时boolean会转成int型,结果为
1 or 0 < z


解决方法:

x < y < z 应写成:
x < y && y < z

如果您经常进行这种手动的二元谓词链接,或者有很多操作数,则很容易犯错误或忘记链中的某个条件。在这种情况下,您可以创建辅助函数来为您进行链接。示例:

// matching exactly two operands
template<class BinaryPredicate, class T>
inline bool chain_binary_predicate(BinaryPredicate p, const T& v1, const T& v2)
{
    return p(v1, v2);
}

// matching three or more operands
template<class BinaryPredicate, class T, class... Ts>
inline bool chain_binary_predicate(BinaryPredicate p, const T& v1, const T& v2,
                                   const Ts&... vs)
{
    return p(v1, v2) && chain_binary_predicate(p, v2, vs...);
}

下面是使用std::less的实例:

// bool r = 1<2 && 2<3 && 3<4 && 4<5 && 5<6 && 6<7 && 7<8
bool r = chain_binary_predicate(std::less<int>{}, 1, 2, 3, 4, 5, 6, 7, 8); // true

等一下。这对我来说真的很困惑。你是什么意思,operator=? - user11283726
当你在类类型中使用=给某个值赋值时,会调用成员函数T& operator=(const T&);(或类似的函数)。像int这样的基本类型已经内置了这个功能,但即使在这些情况下,也可以将赋值操作看作返回对被赋值对象的引用。我从答案中删除了operator=。回答问题不需要它。 - Ted Lyngmo
这些成员函数是否内置于C++库中,用于基本数据类型? - user11283726
核心语言本身不能被更改吗?(不是指覆盖函数) - user11283726
@JamesMiller 没错! - Ted Lyngmo
显示剩余2条评论

5
由于您将这些表达式视为“运算符链”,但C++没有这样的概念。C++将分别执行每个运算符,其顺序由它们的优先级和结合性确定(https://en.cppreference.com/w/cpp/language/operator_precedence)。
(在C Perkins的评论之后扩展)
詹姆斯,你的困惑来自于将x = y = z;视为一些特殊情况的链接运算符。实际上,它遵循与其他情况相同的规则。
此表达式的行为类似于右到左结合的赋值=,并返回其右操作数。没有特殊规则,不要期望x < y < z有特殊规则。
顺便说一下,x == y == z也不会按您的期望工作。
另请参见this answer

词语很重要。如果我们真正考虑“运算符链”意味着什么,那么每个C++ hello world程序中都存在的结构又是怎样的呢:“cout << "This " << " is a " << "chain of stream insertion operators"”。问题中已经指出了x = y = z是一个“赋值运算符链”。C++绝对有这个概念,只是它可能与其他现代语言和/或许多人的直观想法不同。 - C Perkins
C Perkins,看起来我们对“概念”的含义有不同的看法。显然,您可以有一系列相同的操作。但是C++并不关心这一点。重载的<<遵守相同的优先级和结合规则,请参见同一链接,“运算符优先级不受运算符重载的影响。”在这种情况下,链接起来是有效的,因为重载的<<返回左手参数,而不是因为某些隐藏的“链式规则”用于连续的相同运算符。 - g.kertesz
就像我说的,措辞很重要。你在评论中使用“链式法则”这个短语的解释比答案中的解释更清晰、更直接,即使在编辑后也是如此。“运算符链”仍然准确地描述了语法用法,在许多(大多数?)语言中都存在。编程语言涉及的不仅仅是技术编译器实现。C++运算符和标准库的设计是为了支持这种链接,因此尽管运算符根据优先级和结合规则进行实现,但这个概念仍然是不可或缺的。 - C Perkins
1
@CPerkins 他们说这是一系列运算符,但它不是C++中特定的概念(作为一个特殊情况)。编译器读取您的代码的方式绝非技术细节,而是语言的基本部分。如果OP能够理解表达式如何解析的基础知识,这是初学者的概念,他们将知道答案。 - Burak

5
C和C++实际上没有“链接”操作的概念。每个操作都有优先级,它们只是像数学问题一样遵循优先级使用上一个操作的结果。
注意:我提供了一个我认为有帮助的低级解释。
如果您想阅读历史解释,Davislor的答案可能对您有帮助。
我还在底部放了一个TL;DR。
例如,std::cout 实际上并没有链接:
std::cout << "Hello!" << std::endl;

实际上是利用了属性 << 从左到右进行评估并重用 *this 返回值,因此它实际上执行以下操作:
std::ostream &tmp = std::ostream::operator<<(std::cout, "Hello!");
tmp.operator<<(std::endl);

这就是为什么在输出较复杂的内容时,printf通常比std::cout更快,因为它不需要多次调用函数。你实际上可以通过生成的汇编代码(使用适当的标志)看到这一点。
#include <iostream>

int main(void)
{
    std::cout << "Hello!" << std::endl;
}

clang++ --target=x86_64-linux-gnu -Oz -fno-exceptions -fomit-frame-pointer -fno-unwind-tables -fno-PIC -masm=intel -S

这里展示的是x86_64汇编代码,但不用担心,我已经记录了每个指令的解释,所以任何人都应该能够理解。

我已经对符号进行了简化和还原。没有人想看到50次出现的std::basic_ostream<char, std::char_traits<char> >

    # Logically, read-only code data goes in the .text section. :/
    .globl main
main:
    # Align the stack by pushing a scratch register.
    # Small ABI lesson:
    # Functions must have the stack 16 byte aligned, and that
    # includes the extra 8 byte return address pushed by
    # the call instruction.
    push   rax

    # Small ABI lesson:
    # On the System-V (non-Windows) ABI, the first two
    # function parameters go in rdi and rsi. 
    # Windows uses rcx and rdx instead.
    # Return values go into rax.

    # Move the reference to std::cout into the first parameter (rdi)

    # "offset" means an offset from the current instruction,
    # but for most purposes, it is used for objects and literals
    # in the same file.
    mov    edi, offset std::cout

    # Move the pointer to our string literal into the second parameter (rsi/esi)
    mov    esi, offset .L.str

    # rax = std::operator<<(rdi /* std::cout */, rsi /* "Hello!" */);
    call   std::operator<<(std::ostream&, const char*)

    # Small ABI lesson:
    # In almost all ABIs, member function calls are actually normal
    # functions with the first argument being the 'this' pointer, so this:
    #   Foo foo;
    #   foo.bar(3);
    # is actually called like this:
    #   Foo::bar(&foo /* this */, 3);

    # Move the returned reference to the 'this' pointer parameter (rdi).
    mov     rdi, rax

    # Move the address of std::endl to the first 'real' parameter (rsi/esi).
    mov     esi, offset std::ostream& std::endl(std::ostream&)

    # rax = rdi.operator<<(rsi /* std::endl */)
    call    std::ostream::operator<<(std::ostream& (*)(std::ostream&))

    # Zero out the return value.
    # On x86, `xor dst, dst` is preferred to `mov dst, 0`.
    xor     eax, eax

    # Realign the stack by popping to a scratch register.
    pop     rcx

    # return eax
    ret

    # Bunch of generated template code from iostream

    # Logically, text goes in the .rodata section. :/
    .rodata
.L.str:
    .asciiz "Hello!"

无论如何,= 操作符是一个从右到左的操作符。
struct Foo {
    Foo();
    // Why you don't forget Foo(const Foo&);
    Foo& operator=(const Foo& other);
    int x; // avoid any cheating
};

void set3Foos(Foo& a, Foo& b, Foo& c)
{
    a = b = c;
}

void set3Foos(Foo& a, Foo& b, Foo& c)
{
    // a = (b = c)
    Foo& tmp = b.operator=(c);
    a.operator=(tmp);
}

注意:这就是为什么三五定律非常重要,以及内联它们也很重要的原因。
set3Foos(Foo&, Foo&, Foo&):
    # Align the stack *and* save a preserved register
    push    rbx
    # Backup `a` (rdi) into a preserved register.
    mov     rbx, rdi
    # Move `b` (rsi) into the first 'this' parameter (rdi)
    mov     rdi, rsi
    # Move `c` (rdx) into the second parameter (rsi)
    mov     rsi, rdx
    # rax = rdi.operator=(rsi)
    call    Foo::operator=(const Foo&)
    # Move `a` (rbx) into the first 'this' parameter (rdi)
    mov     rdi, rbx
    # Move the returned Foo reference `tmp` (rax) into the second parameter (rsi)
    mov     rsi, rax
    # rax = rdi.operator=(rsi)
    call    Foo::operator=(const Foo&)
    # Restore the preserved register
    pop     rbx
    # Return
    ret

这些函数都返回相同的类型,因此它们被称为“链式函数”。

但是<返回bool类型。

bool isInRange(int x, int y, int z)
{
    return x < y < z;
}

它从左到右进行评估:
bool isInRange(int x, int y, int z)
{
    bool tmp = x < y;
    bool ret = (tmp ? 1 : 0) < z;
    return ret;
}

isInRange(int, int, int):
    # ret = 0 (we need manual zeroing because setl doesn't zero for us)
    xor    eax, eax
    # (compare x, y)
    cmp    edi, esi
    # ret = ((x < y) ? 1 : 0);
    setl   al
    # (compare ret, z)
    cmp    eax, edx
    # ret = ((ret < z) ? 1 : 0);
    setl   al
    # return ret
    ret

简短概述:

x < y < z 没有什么用。

如果你想检查 x < y 并且 y < z,你可能需要使用 && 运算符。

bool isInRange(int x, int y, int z)
{
    return (x < y) && (y < z);
}

bool isInRange(int x, int y, int z)
{
    if (!(x < y))
        return false;
    return y < z;
}

关于链式流操作的观点是不准确的。根据标准,表达式的正确解释应该是链接函数调用:std::ostream::operator<<(std::ostream::operator<<(std::cout, "Hello!"), std::endl); - Christophe
std::ostream& std::operator<< <std::char_traits<char> >(std::ostream&, char const*)std::ostream::operator<<(std::ostream& (*)(std::ostream&)) 是汇编中调用的函数。 - EasyasPi
标准的规则是此处唯一重要的事情。任何对程序集的引用都必须特定于实现,并且仅适用于此特定编译器和版本。另一个编译器可以生成不同的代码,只要它产生与标准相同的结果。 - Christophe

2
这一历史原因是,C++继承了这些运算符自C语言,而C语言则继承了它们自一种名为B的早期语言,该语言基于BCPL,而BCPL则基于Algol。
1968年,Algol引入了“赋值”,使得赋值成为返回值的表达式。这允许一个赋值语句将其结果传递到另一个赋值语句的右侧。这允许链接赋值。为了使这种方法可行,等号(=)运算符必须从右向左解析,这与其他所有运算符相反,但自60年代以来,程序员们已经习惯了这种怪癖。所有的C族语言都继承了这个特点,而C还引入了一些其他工作方式相同的运算符。
像if (euid = 0)或a < b < c这样的严重错误之所以能够编译,是因为BCPL中进行了简化:真值和数字具有相同的类型,可以互换使用。BCPL中的B代表“基本”,它使自己变得如此简单的方法是放弃类型系统。所有表达式都是弱类型的,并且大小与机器寄存器相同。只有一组运算符&、|、^和~同时用于整数和布尔表达式,这使得语言可以消除布尔类型。因此,a < b < c将a < b转换为true或false的数值,并将其与c进行比较。为了使~既能作为位运算符又能作为逻辑非运算符工作,BCPL需要将true定义为~false,即~0。在大多数机器上,这代表-1,但在某些机器上,它可能是INT_MIN、陷阱值或-0。因此,您可以将true的“rvalue”传递给算术表达式,但它不会有意义。
C语言的前身B决定保留一般思想,但回到Algol中TRUE的值为1。这意味着~不再将TRUE更改为FALSE或反之亦然。由于B没有强类型,无法在编译时确定使用逻辑还是位运算not,因此需要创建一个单独的!运算符。它还将所有非零整数值定义为真值。它继续使用位运算&和|,尽管这些现在已经失效(1&2是false,即使两个操作数都是真值)。
C添加了&&||运算符,以允许短路优化,并次要地修复了AND的问题。 它选择不添加逻辑异或,忠于他们的哲学,让我们自己踢自己的脚,所以如果我们在一对不同的truthy数字上使用它,^就会出错。 (如果你想要一个强大的逻辑异或,!!p ^ !!q。)然后,设计师们做出了非常可疑的选择,即不添加布尔类型,即使他们已经完全消除了首次消除它的所有好处,现在没有一个布尔类型使语言更加复杂,而不是更少。 C++和C标准库都将定义bool,但那时为时已晚。 他们陷入了比他们开始多三个运算符的困境,并且当您意味着==时,他们已经使输入=成为一个致命的陷阱,这导致了许多安全漏洞。
现代编译器通过假设违反大多数编码标准的任何=<等使用可能是拼写错误来缓解这些问题,并至少警告您。 如果您确实打算这样做-一个常见的例子是if (errcode = library_call())同时检查调用是否失败并保存错误代码以防它失败-约定是额外的一对括号告诉编译器您确实打算这样做。 因此,编译器将接受if ( 0 != (errcode = library_call()) )而不会投诉。 在C++17中,您还可以编写if ( const auto errcode = library_call() )if ( const auto errcode = library_call(); errcode != 0 )。 同样,编译器将接受(foo < bar) < baz,但您可能意味着的是foo < bar && bar < baz

1
尽管看起来你在同时为多个变量赋值,但实际上这是一系列顺序赋值的链式操作。具体来说,y = z首先被评估。内置的=运算符将z的值分配给y,然后返回对y的左值引用(source)。该引用然后用于分配给x。因此,该代码基本等同于以下内容。
y = z;
x = y;

将相同的逻辑应用于比较语句,不同之处在于它从左到右进行评估(source),我们得到了等效的结果。
const bool first_comparison = x < y;
first_comparison < z;

现在,bool可以转换为int,但大多数情况下这不是你想要的。至于语言为什么不按照你的意愿运行,这是因为这些运算符仅被定义为二元运算符。链式赋值之所以有效,是因为它可以节省返回值,因此它被设计为返回引用以启用这些语义,但比较需要返回bool,因此它们不能有意义地链式化,而不引入新的可能会破坏语言的功能。

我相信记录中 x = (y = z) 是有效的,x < (y < z) 也是有效的,但后者会衰减为布尔值,因此您会在表达式中失去精度(y < z)变为0或1,但作为BOOL,但是可以做类似于 x < (y|z) 或更好的 x &= (y &= z) 这样的事情,这也适用于其他二进制操作符,并且除非覆盖运算符以进行操作,否则不会衰减。 - Jay
可以使用(x < y) == (y < z)来模拟这个过程。 - Jay

1

您可以使用x<y<z,但它并不会得到您期望的结果!

x<y<z被计算为(x<y)<z。然后x<y的结果是一个布尔值,将是truefalse。当您尝试将布尔值与整数z进行比较时,它会得到整数提升,其中false0true1(这在C++标准中明确定义)。

演示:

int x=1,y=2,z=3;
cout << "x<y:   "<< (x<y) << endl;  // 1 since 1 is smaller than 2
cout << "x<y<z: "<< (x<y<z) <<endl; // 1 since boolean (x<y) is true, which is 
                                    //   promoted to 1, which is smaller than 3

z=1; 
cout << "x<y<z: "<< (x<y<z) <<endl; // 1 since boolean (x<y) is true, which is
                                    //   promoted to 1, which is not smaler than 1 

你可以使用x=y=z,但这可能不是你预期的结果!

请注意,=是赋值运算符,而不是相等比较运算符!=从右到左工作,将右侧的值复制到左侧的“lvalue”中。所以在这里,它将z的值复制到y中,然后将y中的值复制到x中。

如果您在条件语句(ifwhile,...)中使用此表达式,则当x最终与0不同的情况下,它将为true,在所有其他情况下都为false,无论x、y和z的初始值如何。

演示:

int x=1,y=2,z=3;  

if (x=y=z) 
    cout << "Ouch! it's true and now all variables are 3" <<endl; 

z=0; 
if (x=y=z)
    cout <<"Whatever"<<end; 
else 
    cout << "Ouch! it's false and now all the variables are 0"<<endl; 

您可以使用x==y==z,但可能不是您期望的结果!

x<y<z相同,只是比较相等。因此,您最终将比较一个提升的布尔值和一个整数值,并且并不是所有值都相等!

结论

如果要以链接方式比较多个项,请逐个比较每两个项的表达式:

(x<y && y<z)     // same truth than mathematically x<y<z  
(x==y && y==z)   // true if and only if all three terms are equal

链接赋值运算符是允许的,但有些棘手。有时它被用来初始化多个变量。但不建议作为一般惯例。
int i, j; 
for (i=j=0; i<10 && j<5; j++)      // trick !! 
    j+=2;  

for (int i=0, j=0; i<10 && j<5; j++)  // comma operator is cleaner
    j+=2;  

(x < y) == (y < z) 或者带有 != 的变体也可以,还有 (x < y && y < z),这取决于具体情况,哪种更简洁、更好或更快。 - Jay

0
我可以使用x = y = z。为什么不能使用x < y < z?
你实际上在询问语法惯用性的一致性。
好吧,只需在另一个方向上保持一致性:您应该避免使用x = y = z。毕竟,它并不是断言xyz相等 - 而是两个连续的赋值;同时,因为它让人想起平等的指示 - 这种双重赋值有点令人困惑。
所以,只需写:
y = z;
x = y;

除非有非常特殊的原因需要将所有内容都推入单个语句中,否则最好不要这样做。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接