C++ 避坑指南(二)

赋值语句的返回值 自增 自减 解指针

C++ 避坑指南(二)

赋值语句的返回值

C/C++的赋值语句自带返回值,这一定算得上一大缺陷,在 C 中赋值语句返回值,在 C++中赋值语句返回左值引用。

这件事造成的最大影响就在===这两个符号上,比如:

int a1, a2;   
bool b = a1 = a2;

这里原本想写b = a1 == a2,但是错把==写成了=,但编译是可以完全通过的,因为a1 = a2本身返回了 a1 的引用,再触发一次隐式类型转换,把 bool 转化为 int(这里详见后面非布尔类型的布尔意义章节)。

更有可能的是写在 if 表达式中:

if (a = 1) { 
}

可以看到,a = 1执行后 a 的值变为 1,返回的 a 的值就是 1,所以这里的if变成了恒为真。

C++为了兼容这一特性,又不得不要求自定义类型要定义赋值函数

class Test {
 public:
  Test &operator =(const Test &); // 拷贝赋值函数
  Test &operator =(Test &&); // 移动赋值函数
  Test &operator =(int a); // 其他的赋值函数
};

这里赋值函数的返回值强制要求定义为当前类型的左值引用,一来会让人觉得有些无厘头,记不住这里的写法,二来在发生继承关系的时候非常容易忘记处理父类的赋值。

class Base {
 public:
  Base &operator =(const Base &);
};

class Ch : public Base {
 public:
  Ch &opeartor =(const Ch &ch) {
    this->Base::operator =(ch);
    // 或者写成 *static_cast<Base *>(this) = ch;
    // ...
    return *this;
  }
};

其他语言的赋值语句

古老一些的 C 系扩展语言基本还是保留了赋值语句的返回值(例如 java、OC),但一些新兴语言(例如 Go、Swift)则是直接取消了赋值语句的返回值,比如说在 swift 中:

let a = 5
var b: Int
var c: Int
c = (b = a) // ERR

b = a会返回Void,所以再赋值给 c 时会报错

非布尔类型的布尔意义

在原始 C 当中,其实并没有“布尔”类型,所有表示是非都是用 int 来做的。所以,int 类型就赋予了布尔意义,0 表示 false,非 0 都表示 true,由此也诞生了很多“野路子”的编程技巧:

int *p;
if (!p) {} // 指针→bool

while (1) {} // int→bool

int n;
while (~scanf("%d", &n)) {} // int→bool

所有表示判断逻辑的语法,都可以用非布尔类型的值传入,这样的写法其实是很反人类直觉的,更严重的问题就是与 true 常量比较的问题。

int judge = 2; // 用了int表示了判断逻辑
if (judge == true) {} // 但这里的条件其实是false,因为true会转为1,2 == 1是false

正是由于非布尔类型具有了布尔意义,才会造成一些非常反直觉的事情,比如说:

true + true != true
!!2 == 1
(2 == true) == false

其他语言的布尔类型

基本上除了 C++和一些弱类型脚本语言(比如 js)以外,其他语言都取消了非布尔类型的布尔意义,要想转换为布尔值,一定要通过布尔运算才可以,例如在 Go 中:

func Demo() {
  a := 1 // int类型
  if (a) { // ERR,if表达式要求布尔类型
  }
  if (a != 0) { // OK,通过逻辑运算得到布尔类型
  }
}

这样其实更符合直觉,也可以一定程度上避免出现写成类似于if (a = 1)出现的问题。C++中正是由于“赋值语句有返回值”和“非布尔类型有布尔意义”同时生效,才会在这里出现问题。

解指针类型

关于 C/C++到底是强类型语言还是弱类型语言,业界一直争论不休。有人认为,变量的类型从定义后就不能改变,并且每个变量都有固定的类型,所以 C/C++应该是强类型语言。

但有人持相反意见,是因为这个类型,仅仅是“表面上”不可变,但其实是可变的,比如说看下面例程:

int a = 300;
uint8_t *p = reinterpret_cast<uint8_t *>(&a);
*p = 1; // 这里其实就是把a变成了uint8_t类型

根源就在于,指针的解类型是可以改变的,原本int类型的变量,我们只要把它的首地址保存下来,然后按照另一种类型来解,那么就可以做到“改变 a 的类型”的目的。

这也就意味着,指针类型是不安全的,因为你不一定能保证现在解指针的类型和指针指向数据的真实类型是匹配的。

还有更野一点的操作,比如:

struct S1 {
  short a, b;
};

struct S2 {
  int a;
};

void demo() {
  S2 s2;
  S1 *p = reinterpret_cast<S1 *>(&s2);
  p->a = 2;
  p->b = 1;

  std::cout << s2.a; // 猜猜这里会输出多少?
}

这里的指针类型问题和前面章节提到的指针偏移问题,综合起来就是说 C/C++的指针操作的自由度过高,提升了语言的灵活度,同时也增加了其复杂度。

后置自增/自减

如果仅仅在 C 的角度上,后置自增/自减语法并没有带来太多的副作用,有时候在程序中作为一些小技巧反而可以让程序更加精简,比如说:

void AttrCnt() {
  static int count = 0;
  std::cout << count++ << std::endl;
}

但这个特性继承到 C++后问题就会被放大,比如说下面的例子:

for (auto iter = ve.begin(); iter != ve.end(); iter++) {
}

这段代码看似特别正常,但仔细想想,iter 作为一个对象类型,如果后置++,一定会发生复制。后置++原本的目的就是在表达式的位置先返回原值,表达式执行完后再进行自增。但如果放在类类型来说,就必须要临时保存一份原本的值。例如:

class Element {
 public:
  // 前置++
  Element &operator ++() {
   ele++;
   return *this;
  }
  // 后置++
  Element operator ++(int) {
    // 为了最终返回原值,所以必需保存一份快照用于返回
    Element tmp = *this;
    ele++;
    return tmp;
  }
 private:
  int ele;
};

这也从侧面解释了,为什么前置++要求返回引用,而后置++则是返回非引用,因为这里需要复制一份快照用于返回。

那么,写在 for 循环中的后置++就会平白无故发生一次复制,又因为返回值没有接收,再被析构。

C++保留的++--的语义,也是因为它和+=1-=1语义并不完全等价。我们可以用顺序迭代器来解释。对于顺序迭代器(比如说链表的迭代器),++表示取下一个节点,--表示取上一个节点。而+n或者-n则表示偏移了,这种语义更适合随机访问(所以说随机迭代器支持+=-=,但顺序迭代器只支持++--)。

其他语言的自增/自减

其他语言的做法基本分两种,一种就是保留自增/自减语法,但不再提供返回值,也就不用区分前置和后置,例如 Go:

a := 3
a++ // OK
b := a++ // ERR,自增语句没有返回值

另一种就是干脆删除自增/自减语法,只提供普通的操作赋值语句,例如 Swift:

var a = 3
a++ // ERR,没有这种语法
a += 1 // OK,只能用这种方式自增

推荐阅读

C++避坑指南(一)

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/1274

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2022年11月2日 下午12:25
下一篇 2022年11月3日 下午3:00

相关推荐