根据运算符在变量的前面还是后面,可以分为前置(prefix)运算符和后置(postfix)运算符。++i、--i就是前置运算符,i++、i--就是后置运算符。
前置i,++i是先将变量 i 的值加 1,然后再使用这个新值进行计算或赋值操作;后置i,i++则是先使用变量i的原始值进行计算或者赋值,然后再将i的值加1。
在C#中,前置式和后置式++或--,似乎除了语义上的区别之外,效率上没有区别,但在C++中则不一样,《Primer C++》和 《More Effective C++》中都说明,前置式自增自减(++i、--i)的效率比后置式(i++、i--) 通常更快,且至少不慢(prefix will sometimes be faster but never slower then postfix)。
在C语言中,前置式和后置式在性能上没有区别,i++可能(potentially)会比++i慢,因为在i++中,需要先使用i的值,所以需要先存储i的值到变量temp中,然后再把i增加,最后返回temp的值即i自增之前的值,这显然会增加消耗。但现代的编译器会把这种差别优化掉。比如下面这两条语句,以编译器的智商,完全可以把第一个i++优化为第二个的++i的形式:
for (int i = 0; i < 100; i++)
{
// do something
}
for (int i = 0; i < 100; ++i)
{
//do something
}
所以在C++中,对于基本类型比如int类型,前置和后置的效率没有差别,但对于C++中自定义的类(class),前置运算符的效率至少不低于后置运算符(sometimes be faster but never slower)。在C++中前置和后置自增自减运算是通过运算符重载来实现的。
在看C++中的自增自减重载之前,先看看C#中的自增自减运算符。
C#中重载自增自减运算符
在C#中,自增自减运算符也是通过重载实现的,但自增自减操作符的重载没有前置和后置的区分,其方法签名只有一种。但如何正确在C#实现自增自减重载也有需要注意的地方,否则就会出现不符合前置后置式运算的表现。在.NET源码中可以看到int类型的自增自减实现,但我没有找到其它类类型的重载,如果找到了可以参考实现。这里先看C#中int的表现:
[TestMethod]
public void TestIncrement()
{
int c = 10;
Assert.AreEqual(c, 10);
Assert.AreEqual(c++, 10);
Assert.AreEqual(c, 11);
c = 10;
Assert.AreEqual(c, 10);
Assert.AreEqual(++c, 11);
Assert.AreEqual(c, 11);
}
上述单元测试显而易见能够通过(这里简化了单元测试的写法,应该写到多个test case中)。n++相当于以下操作:
int temp = n;
n = n + 1;
return temp;
而++n则相当于:
n = n + 1;
return n;
现在,假设我们定义一个类,来模拟int类型,我们就起名为CSharpInt,使用方法跟int一样,我们要求他表现的行为跟内置的int类型默认的前置后置自增自减的行为一样,即:
[TestMethod]
public void TestIncrement()
{
CSharpInt c = new CSharpInt(10);
Assert.AreEqual(c, 10);
Assert.AreEqual(c++, 10);
Assert.AreEqual(c, 11);
c = new CSharpInt(10);
Assert.AreEqual(c, 10);
Assert.AreEqual(++c, 11);
Assert.AreEqual(c, 11);
}
那么这个CSharpInt如何实现,下面这个模仿“后置式”的语意实现:
public class CSharpInt
{
private int number;
public CSharpInt(int _i)
{
number= _i;
}
public static CSharpInt operator ++(CSharpInt i)
{
CSharpInt temp = new CSharpInt(i.number);
++i.number;
return temp ;
}
/// <summary>
/// 隐式类型转换,方便将CsharpInt对象转换为int类型
/// </summary>
/// <param name="d"></param>
public static implicit operator int(CSharpInt d) => d.number;
public override string ToString()
{
return number.ToString();
}
}
上述CSharpInt的实现中,内部使用一个int对象来保存数字,自增的重载只有一种,并不区分前置和后置,统一的签名方法为:
public static CSharpInt operator ++(CSharpInt i)
必须是静态方法,返回类型和参数都是待重载的对象,方法前名为“operator ++”。
这里的实现模仿后置自增的方法,先创建一个新的temp对象(因为这是一个class是引用类型),用参数的值来初始化temp,然后返回该对象,接着将参数的值自增。
public static CSharpInt operator ++(CSharpInt i)
{
CSharpInt temp = new CSharpInt(i.number);
++i.number;
return temp;
}
现在运行单元测试 ,发现失败了:
上面的实现,是的i++返回的值我们期望的是i的原始值10,而不是++之后的值11,单元测试的结果显示,这显然跟我们想要的不符。
现在改用“前置式”的语义,直接修改参数,然后返回修改后的参数对象:
public static CSharpInt operator ++(CSharpInt i)
{
++i.number;
return i;
}
再次运行单元测试,会发现结果跟之前的没有任何区别。
那如何才能正确的重载自增操作符呢?正确的做法是:
- 不要修改参数的值,确保操作没有副作用(side effect),这就要求在方法内部必须深度拷贝参数对象。
- 既然没有区别前置和后置操作符,所以只需要在拷贝后的对象上,执行自增自减操作,然后返回修改后的拷贝对象即可,不需要考虑前置后置的语义区别。
- 前置后置的语意区别由编译器来完成,即i++和++i的行为由编译器来负责处理。
按照以上准则,CSharpInt自增操作符的正确做法如下:
public static CSharpInt operator ++(CSharpInt i)
{
//拷贝对象,并对该对象执行递增操作,然后返回该拷贝后的对象
CSharpInt c = new CSharpInt(i.number + 1);
return c;
}
再次运行单元测试,会发现测试通过。
额外说明一下,之所以能将CSharpInt类型和int类型直接进行比较,是因为CSharpInt对象有隐式类型转换,它的实现如下:
/// <summary>
/// 隐式类型转换,方便将CsharpInt对象转换为int类型
/// </summary>
/// <param name="d"></param>
public static implicit operator int(CSharpInt d) => d.number;
这种隐式类型转换在C++也有,后面有机会再说。
上面只重载了自增运算符,自减运算符也类似:
public static CSharpInt operator --(CSharpInt i)
{
CSharpInt c = new CSharpInt(i.Number - 1);
return c;
}
或者
public static CSharpInt operator --(CSharpInt i)
{
CSharpInt c = new CSharpInt(i.Number);
--c.Number;
return c;
}
这里我们调用了内置int类型的前置自减运算符。
C++中重载前置后置自增自减运算符
在C++中,要重载前置后置自增自减运算符就比较讲究了,在《More Effective C++》中甚至有专门的条款6来说明如何正确重载自增自减运算符。
重载方法是通过参数类型来区分彼此的,但前置和后置重载都没有参数,所以为了解决这个问题,C++只好在后置式上添加了一个int参数(对你没有看错,这种骚操作在C++能够经常看到),在被调用时,编译器默默地为该参数指定一个0值。在C++中,重载前置和后置的自增自减运算符的代码如下:
#include <iostream>
class UPInt
{
public:
friend std::ostream &operator<<(std::ostream &os, const UPInt &ui);
UPInt(uint32_t t = 0) : i(t){}; // 构造函数
UPInt &operator++(); // 前置式 ++
const UPInt operator++(int); // 后置式++
UPInt &operator--(); // 前置式 --
const UPInt operator--(int); // 后置式--
UPInt &operator+=(int); // 重载+=
UPInt &operator-=(int); // 重载-=
private:
int32_t i;
};
使用方法如下:
UPInt i(10);
++i;//调用i.operator++() 前置自增
i++;//调用i.operator++(0) 后置自增
--i;//调用i.operator--() 前置自减
i--;//调用i.operator--(0) 后置自减
除了函数参数之外,函数的返回值也有区别,前置式返回对象的引用,后置式返回一个常量对象。
自增操作符的前置式的含义是“累加然后取出”(increment and fetch),后置式的含义是“取出然后累加”(fetch and increment),这就是前置式和后置式操作符正确实现的规范。在上面的UInt对象中,自增操作的前置和后置式的实现如下:
UPInt &UPInt::operator++()
{
*this += 1; //累加 increment
return *this; //取出 fetch
}
const UPInt UPInt::operator++(int)
{
UPInt old = *this; //取出 fetch
++(*this); //累加 increment
return old; //返回先前取出的值
}
在后置式里并没有使用int参数,这个参数如前所述,其作用仅用来区分前置式和后置式而已。
为什么后置式要返回一个对象(代表旧值),原因和清楚。但为什么是const常量呢?常量表示返回的这个对象不能被修改。如果不是常量,下面的操作就是合法的。
UPInt i(10);
i++++;
后置运算符调用了两次,它相当于
i.operator++(0).operator++(0)
operator++(0)的第二个动作施加到第一个调用动作的返回值上。这里面会有两个问题:
- 它和内建的int类型的行为不相符,int不允许连续两次调用++运算符,即i++++是非法的,但++++i是合法的。
- 即使能够实施两次后置自增,它的结果也不是我们想要的,第二个operator++(0) 所改变的是第一个operator++(0)所返回的对象,而不是原对象,所以即使i++++合法,i也只是被累加一次。所以这违反直觉,容易造成混乱,所以做好的办法就是禁止这样使用。
C++禁止了int类型连续两次调用operator++(0),所以我们自己设计的类UPInt需要手动加以禁止,办法就是让后置式返回const类型的对象。所以当编译器看到i++++时,发现第一次调用operator++(0)返回的是const对象,它被用来第二次调用operator++(0),但operator++(0)是一个非常量的成员函数,所以常量对象无法调用非常量成员函数。
后置式的自增自减也有效率问题,因为它返回的是旧对象,所以必须先创建一个临时对象来作为返回值使用,临时对象也需要构造和解析,这就有性能损耗,但前置式则没有这个顾虑。所以除非就是需要使用后置式的含义(后面会介绍一个例子),否则最好使用前置式。
最后一个问题是,前置式和后置式都是对对象进行自增或自减,那么如何保证他们内部的行为一致呢?一个原则是,以前置式为基础,在后置式里面调用前置式,这样只需要维护一个前置式的实现即可。在后置式的实现中,可以看到“ ++(*this); ”这句代码,就是去调用前置式的实现。
const UPInt UPInt::operator++(int)
{
UPInt old = *this; //取出 fetch
++(*this); //累加 increment
return old; //返回先前取出的值
}
最后完整的UPInt声明级实现如下:
#include <iostream>
class UPInt
{
public:
friend std::ostream &operator<<(std::ostream &os, const UPInt &ui);
UPInt(uint32_t t = 0) : i(t){}; // 构造函数
UPInt &operator++(); // 前置式 ++
const UPInt operator++(int); // 后置式++
UPInt &operator--(); // 前置式 --
const UPInt operator--(int); // 后置式--
UPInt &operator+=(int); // 重载+=
UPInt &operator-=(int); // 重载-=
private:
int32_t i;
};
UPInt &UPInt::operator+=(int t)
{
i += t;
return *this;
}
UPInt &UPInt::operator-=(int t)
{
i -= t;
return *this;
}
std::ostream &operator<<(std::ostream &os, const UPInt &ui)
{
os << ui.i;
return os;
}
UPInt &UPInt::operator++()
{
*this += 1;
return *this;
}
const UPInt UPInt::operator++(int)
{
UPInt old = *this;
++(*this);
return old;
}
UPInt &UPInt::operator--()
{
*this -= 1;
return *this;
}
const UPInt UPInt::operator--(int)
{
UPInt old = *this;
--(*this);
return old;
}
一个需要使用后置式而不是前置式的场景
虽然大部分情况下,考虑到性能问题(内置类型性能差别不大,自定义类型前置式效率不低于后置式),应该优先使用前置式而不是后置式。但在某些情况下,必须使用后置式,在《Effective STL》中的 “第9条:慎重选择删除元素的方法” 就有这么一个集合删除的例子。
集合删除根据集合类型不同,需要使用不同的方法:
对于删除特定元素,如果集合c是一个标准的连续内存容器(vector、deque、string) 删除特定元素最好的方法是使用erase-remove方法:
std::vector<int> c{1,2,3,5};
c.erase(std::remove(c.begin(),c.end(),3),c.end());
要删除集合c中的元素3,可以使用erase-remove来完成。
如果c是list,则可以直接使用remove方法。
std::list<int> l{1,2,3,5};
l.remove(3);
如果c是关联容器(set、multiset、map、multimap),则删除元素的正确方法是调用erase。
std::set<int> s{1,2,3,5};
s.erase(3);
但是,如果要删除满足某个判别式的元素,则方法会有所不同,比如要删除集合中的奇数元素,奇数oddValue定义如下:
bool oddValue(int i)
{
return i%2==1;
}
对于标准的连续内存容器(vector、deque、string),使用erase-remove-if即可,例如如果要删除集合中的奇数(奇数的定义为oddValue),则可以使用如下方法:
std::vector<int> c1{1,2,3,5};
c1.erase(std::remove_if(c1.begin(),c1.end(),oddValue),c1.end());
对于list,可以直接使用remove_if:
std::list<int> l1{1,2,3,5};
l1.remove_if(oddValue);
对于标准关联容器(set、multiset、map、multimap),删除满足特定条件的元素没有特别直接的函数可以调用,必须我们自己写循环来实现。比较容易写的代码如下,
std::set<int> s1{1,2,3,5};
for (auto i= s1.begin();i!=s1.end();++i)
{
if (oddValue(*i))
{
s1.erase(i);
}
}
上述代码会导致不确定的行为,当元素从集合中删除时,指向该元素的所有迭代器将会失效。一旦s1.erase(i)返回,i就会变成无效值。后面在这个无效的i值上进行递增,可能会使得整个循环变得不确定。
为了修正上述问题,需要在调用erase之前,有一个元素指向集合中的下一个元素。这样做最简单的办法就是对迭代器执行后置式自增操作:
std::set<int> s1{1, 2, 3, 5};
for (auto i = s1.begin(); i != s1.end(); /*什么也不做*/)
{
if (oddValue(*i))
{
s1.erase(i++);
}
else
{
++i;
}
}
后置式递增表达式i++返回的是i的旧值,而作为副作用,i被递增,这样我们把旧i(未递增的值)传递给erase方法,但在erase开始执行前我们也递增了i,这正是我们想要的,非常完美。
参考:
Is there a performance difference between i++ and ++i in C? - Stack Overflow
C# postfix and prefix increment/decrement overloading difference - Stack Overflow
www.cnblogs.com/instance/archive/2011/05/21/2052722.html