【问题标题】:Why are pre- and post- inc/decrement operators implemented separately?为什么前后递增/递减运算符分开实现?
【发布时间】:2013-11-16 13:39:27
【问题描述】:

我试图找出让递增和递减运算符的重新版本和后期版本分别可重载的理由。
在我看来,在我所见过的任何类型的类的这些运算符的每个实现中,它们都是相同的运算符(= 做同样的事情),只是在调用时有所不同。
对我来说,C++ 的设计者应该有 one ++ 运算符似乎更合乎逻辑,编译器会根据需要在读取值之前或之后调用它(或者,更有可能,在上一个或下一个序列点,我认为是等效的)

所以,问题是:有没有人有可能以相同方式实现的案例/类的示例?或者有没有人知道/猜测这种设计选择背后的基本原理?


对于那些更喜欢看代码而不是阅读问题文本的人,这里是摘要:

对于什么类型的T(代表你想要的任何东西的用户定义类)是否有意义,以下两行具有相同的副作用:

T v;

v++;
++v;

编辑
引用下面@Simple 的评论,我希望能澄清这个问题:

为什么编译器会在语言中使用后增量(重载) 可以自己做一个副本并做预增量


编辑 2
由于许多人显然不清楚这个问题,这里有另一种解释:

考虑以下两行:

b = a++;
b = ++a;

如果是一个操作符(为了论证,我称操作符为+a+),第一行会被编译器翻译成

b = a;
+a+;

第二个进入

+a+;
b = a;

【问题讨论】:

  • 考虑到他们有点做两件不同的事情,即使他们做同样的事情......
  • 我不知道你为什么说它们是一样的,实现通常是不同的
  • T x=v++;T x=++v; 怎么样?
  • 后增量总是可以按照前增量来实现。他在问,如果编译器可以自己复制并进行预增量,为什么语言中会出现后增量(重载)。
  • 问题可以扩展为:为什么不能把所有的关系运算符都实现为operator<;为什么operator!= 不能仅仅按照operator== 来实现;为什么不能以operator@= 等形式实现所有算术运算符。答案是,真的,只是因为有人/某些人认为,如果这些算术运算符可以在 1998 年做不同的事情是个好主意。

标签: c++ operator-overloading


【解决方案1】:

也许在延迟实现的线程环境中有些东西?对于 ++a 您希望它阻塞直到它更新 a 以便您获取更新的值,但对于 a++ 您只需发送发出信号并继续做事。

【讨论】:

    【解决方案2】:

    它们是两个独立的运算符,因为它们执行两种不同(尽管相关)的事情。

    预递增/递减将递增/递减变量并返回值。

    int i = 0;
    int j = ++i; // j is now 1
    

    后递增/递减将递增/递减变量并返回值。

    int i = 0;
    int j = i++; // j is now 0
    

    一般来说,这些运算符的实现如下所示(对于某些类型T):

    T& T::operator++() // prefix overload
    {
        *this = *this + 1;
        return *this;
    }
    
    T T::operator++(int) // postfix overload
    {
        T prev = *this;
        ++(*this); // call prefix overload
        return prev;
    }
    

    如您所见,前缀重载不需要类型的额外副本,而后缀版本则需要。

    由于大部分 cmets 都围绕着为什么会这样的问题:

    简短的回答是:因为 C 标准是这样说的(而 C++ 从 C 继承了它)。

    更长的答案是:

    ++aa++ 只是调用特定函数的简写符号。 ++a(对于给定类型T)映射到T& T::operator++()T& operator++(T&)a++ 映射到T T::operator++(int)T operator(T&, int)。与所有运算符一样,您(作为程序员)可以将它们定义为针对相应类型执行 anything(注意:通常认为重载运算符以执行某些操作是不好的做法 奇怪,但标准并没有阻止你这样做)。通常,如果您要定义一个类型(例如迭代器),您可以通过提供类似的接口(即重载适当的运算符)使其与内置类型(例如指针)的行为相匹配。但是,您可以决定让operator++() 执行二次公式,而operator++(int) 进行傅里叶变换。因为它们是 2 个独立的功能,所以这是允许的。如果允许编译器根据operator++() 定义的前提来推断operator++(int),则它们将绑定在一起。

    C++ 中的运算符只不过是函数调用的简写符号。虽然根据其他运算符来实现多个运算符是很常见的,但标准并不要求这样做,因此编译器无法做出这样的假设。如果标准要求它,那么就会有很多假设的行为需要跟踪。

    此外,++aa++ 的行为是从 C 中继承而来的。存在很多代码利用其中一个或另一个的行为,并且不断变化在 C++ 标准中会破坏与 C 的兼容性(除非您也在 C 标准中进行了更改)。由于有 大量 现有代码利用了这些运算符的行为,因此您可能会做出重大的重大更改。

    虽然在前增量方面实现后增量是很常见的,但您确实应该将这两个函数视为不同的函数(与您对 operator==operator!=operator< 的看法非常相似, operator> 等。仅仅因为某些东西很常见并不意味着该标准将,甚至应该将其作为要求。

    【讨论】:

    • 但是x++ 成为一个单独的运算符什么时候有意义,如果几乎每个理智的后增量重载都与您在这里的重载基本相同?为什么不能让编译器生成完全符合您对后缀重载所做的代码?比如说,把 T i; T j = i++; 变成 T i; T j = i; ++i; ?在这一点上,即使++ii++ 之间的性能差异可能会消失,直到您将结果分配到某个地方。
    • 编译器对你的类的目的一无所知;它只知道你告诉它什么。有时您不想要后增量并希望仅使用前增量完成所有操作(反之亦然)。让编译器对事物做出假设是你最终得到不可预测代码的方式。
    • 你的课程的目的不应该真正涉及强制语法。但是,如果您想禁止后增量,您所要做的就是使该类不可复制分配或复制构造。如果您允许其中任何一个,人们已经有了一个微不足道的解决方法,因为您拒绝允许后增量,并且缺少操作员似乎是疏忽而不是设计决定。
    • 使一个类不可复制有很多其他副作用。例如,如果您正在编写一个迭代器类并且只想允许预增量,那么使整个类不可复制只是这样做会使您的整个迭代器类不可用。您将语法与它的实际含义混淆了:接口。 ++a 是调用operator++() 的简写,而a++ 是调用operator++(int) 的简写。它们是 2 个独立的函数。
    • 重点(实际上也是问题的标题!)是,为什么它们是两个独立的函数?什么时候他们做同样的事情没有意义,唯一的区别是返回旧值还是新值(编译器可以处理的差异)?你还没有真正回答这个问题;您正在继续讨论非法语法,但忽略使这种任意决定有用所需的更改,因为这些更改本身会阻止后增量工作。
    【解决方案3】:

    看下面的例子

    int i = 5;
    int x = i++;
    cout << i << " " << x;
    

    这将打印出来

    6 5

    int i = 5;
    int x = ++i;
    cout << i << " " << x;
    

    这将打印出来

    6 6

    那么我们可以推断出什么?
    在后缀中,i 的值首先分配给 x,然后 i 递增
    在prefix中,i的值先递增后赋值给x

    【讨论】:

    • The precedence of post-fix &gt; precedence of assignment operator &gt; precedence of pre-fix
    【解决方案4】:

    这种区别在复杂类型的迭代器中变得很重要。表达式

    *it++
    

    给我迭代器当前指向的对象,并递增迭代器。如果迭代器前进后数据通常不会保存在内存中,则返回前一个对象变得困难。有两种方法:

    1. 在后增量中保留一份副本
    2. 延迟后提前

    前一个方法仍然必须返回一些行为类似于迭代器的东西(至少对于operator*operator-&gt;,但不能是指针,因为它还必须保留对象副本的所有权,所以返回一个代理:

    struct iterator {
        value_type value;
    
        struct proxy {
            value_type value;
            value_type &operator*() { return value; }
            value_type *operator->() { return &value; }
        };
    
        value_type &operator*() { return value; }
        value_type *operator->() { return &value; }
        iterator &operator++(); // actual increment code
        proxy operator++(int) { proxy ret = { value }; ++*this; return ret; }
    };
    

    如果创建副本也很昂贵并且应该避免,您也可以延迟增量:

    struct iterator {
        value_type value;
        bool needs_increment;
    
        value_type &operator*() { if(needs_increment) ++*this; return value; }
        value_type *operator->() { if(needs_increment) ++*this; return &value; }
        iterator &operator++(); // actual increment code, resets needs_increment
        value_type *operator++(int) { needs_increment = true; return &value; }
    };
    

    【讨论】:

    • 最后,一个实际的答案。
    【解决方案5】:

    我认为问题与(子)表达式的评估顺序和应用副作用的时间有关。例如,在 C# 中,(子)表达式的求值顺序是确定性的,并且会立即应用副作用。例如考虑以下 C# 代码

    int x = 0;
    int y = x++ + ++x;
    

    此代码在 C# 中定义了行为。所以你可以只实现一个增量运算符,编译器会以适当的方式使用它。

    C++ 没有这种可能性。 (子)表达式的求值顺序未指定,并且不会立即应用副作用。

    【讨论】:

      【解决方案6】:

      您将如何实现后增量的通用版本?

      我猜:T operator++(int) { T tmp(*this); ++*this; return tmp; }

      如果我的类型不可复制或复制成本高怎么办?

      好吧,我更喜欢:

      Proxy operator++(int) { return Proxy(++*this, 1); }
      

      然后有类似的东西:

      bool operator==(Proxy const& left, T const& right) {
          return left.value - 1 == right.value;
      }
      

      如果编译器可以自己进行复制并进行预增量,为什么语言中有后增量(重载)?

      因为您认为编译器可以进行复制的假设是错误的,即使它持有也可能成本太高。

      【讨论】:

        【解决方案7】:

        Pre increment 在语句的其余部分之前递增变量,例如

        x = 2;
        y = ++x;
        
        y == 3;
        x == 3;
        

        而 post increment 在语句的其余部分之后进行增量,

        x = 2;
        y = x++;
        
        y == 2;
        x == 3;
        

        预增量稍微快一些,所以应该是首选。需要注意的是,当两个运算符都在一个语句中使用时,行为是未定义的,所以像

        x = 5;
        x = x++ + ++x;
        

        会在不同的语言中给出不同的结果。

        【讨论】:

        • 我知道这一点。问题是为什么是这 2 个运算符,而不是在不同点调用的 1 个运算符
        • 嘘。如果没有两个单独的运算符,编译器可以轻松优化差异。 (事实上​​,它负责使x++ 正常工作,而不是相信代码这样做了。)问题是为什么有两个独立的运算符——也就是说,在哪里除了在赋值之前或之后获取值之外,让他们做任何事情是否有意义。
        • 应该注意的是,如果您使用后缀而不是前缀(例如,在编写 for 循环时),几乎所有现代编译器都会优化掉临时,因此性能问题不再是问题.
        【解决方案8】:

        因为这些运算符对于内置类型的不同语义。表达式的值在前后递增/递减之间是不同的,即使两者都改变​​了操作数。

        int a = 1;
        (a++) == 1;
        a = 1;
        (++a) == 2;
        

        允许单独重载它们允许为返回值创建类似的语义。

        【讨论】:

        • 这不能回答问题。我知道这个。但是,如果它们是一个运算符,这仍然可以通过在使用它的指令之前或之后调用它来实现。
        • @baruch,“在使用它的指令之前或之后调用它”是什么意思?
        • 如果它是一个运算符(为了论证,我将调用运算符+a+),您的第一行将由编译器翻译成a==1; +a+;,第二行翻译成+a+; a==2;
        • @baruch 他们不是一个操作员,所以你要等一会儿。无论如何,一般评论中的问题更有趣。 IE。为什么编译器不能使用预增量实现生成正确的临时值后增量,从而允许您只有一个operator++()(假设您不提供自己的后增量覆盖)。如果这是真正的问题,那么它是一个有趣的问题,否则这会变得很快。
        • @baruch 和我对此的评论可能是原因,但我没有花时间查找它。我猜测(与标准有关)后增量绝不是标准定义为与前增量等效(反之亦然)正是因为他们是两个不同的运营商。这就像将operator -(val) 定义为等同于operator +(-val)。该标准竭尽全力独立定义运算符,以获得最大的灵活性和直接结论性的实现。 (再次强调一下,但这似乎是合理的)。
        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2011-07-21
        • 2023-01-05
        • 1970-01-01
        • 2011-02-16
        • 2011-04-09
        相关资源
        最近更新 更多