欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

C++灵活易错特性-01

程序员文章站 2022-06-24 12:56:14
C++的语法无疑是庞杂的,存在许多灵活的语法和奇怪的定义。自己写代码的时候,有一些潘多拉魔盒的特性就不要用了,不要自己给自己找麻烦,人生苦短。以下是精心挑选的,最容易混淆的语言特性。 ......

C++笔记系列:灵活易错的特性 - 01

C++的语法无疑是庞杂的,存在许多灵活的语法和奇怪的定义。自己写代码的时候,有一些潘多拉魔盒的特性就不要用了,不要自己给自己找麻烦,人生苦短。以下是精心挑选的,最容易混淆的语言特性。

关键词:

  • 引用
  • 常量(const)
  • 常量表达式(constexpr)
  • 静态(static)
  • 外部(extern)

引用

C语言让人特别头疼的就是指针,功能强大,却很难把控,稍不注意,便埋下了bug。引用,相对于指针,更加安全,推荐使用。

要明确一点,引用是变量的别名,它并不独立占据内存空间,这和指针有明显的差别。C语言的指针可以理解为是一种用于保存地址(一串数字)的数据类型。

引用变量

引用只是别名,不占空间,不能单独存在,必须在创建时初始化。

int x = 3;
int& x_ref = x;

赋值之后,x_ref就是x的另一个名称。使用x_ref就是使用x的当前值。

警告:创建引用必须要初始化。对于类而言,需要在构造函数初始化器中初始化引用数据成员。

1.修改引用

引用一旦创建,无法修改指向的变量。声明的时候,用一个变量“赋值”,这个引用会指向这个变量。但是,初始化后,再用变量对引用赋值,被引用的变量的值变为被赋值变量的值。引用不会更新去指向这个变量。

这就会出现no zuo no die的尝试,比如说试图赋值的时候,用y的地址,绕过限制,然而这是没用的。

int x = 3, y = 4;
int& x_ref = x;
// 编译不通过:y的地址是指针,x_ref是int的引用,而不是指针的引用。
x_ref = &y; 

以下操作也是没用的,

int x = 3, z = 5;
int& x_ref = x;
int& z_ref = z;
// 只改变值,不改变引用
z_ref = x_ref;    // 只是把z的值设为3,因为x_ref指向x,x的值是3

警告:初始化引用之后无法改变引用所指向的变量;而只能改变该变量的值。

2.指针的引用和指向引用的指针

可以创建任何类型的引用,包括指针。不过要注意,无法声明引用的引用,或者指向引用的指针。引用的引用怎么都说不过去,别名的别名……引用没有实际的内存空间,也就没有实际的物理地址,自然不存在指向引用的指针。

int* p_int;
int*& ptr_ref = p_int;
ptr_ref = new int;
*ptr_ref = 5;

用别名去理解就很简单了,没什么难的。同样,用别名来理解对引用取地址的结果与被引用变量取地址的结果相同。说白了,同一事物的不同叫法。

int x = 3;
int& x_ref = 3;
int* p_x = &x_ref;
*p_x = 100;

引用数据成员

前面也提到了,类的数据成员可以是引用。但是,引用不能单独存在,必须指向变量。因此,必须在构造函数初始化器(constructor initializer)中初始化数据成员,而不是构造函数体内。

class RefClass {
 public:
  RefClass(int& ref) : my_ref_(ref) {}
 private:
  int& my_ref_;
};

引用参数

谁都不会没事去用引用变量或者引用数据成员。引用经常是被用来作为函数或者方法的参数。函数的默认参数传递机制是按值传递,需要进行拷贝。修改这些拷贝的副本时,原始的传入参数并不会受到影响。

在很多情况下,我们都需要改变传入的参数,例如算法中的交换操作、利用修改传入参数达到多返回值等等。这种情况下,在C语言中,就需要指针作为参数。

在C++中,可以用引用参数来向函数传递参数,也是C++更为推崇的做法。只有在参数是简单的内建类型(例如int或者double),且不需要修改参数的情况下,才应该使用按值传递,其它所有情况都应该使用按引用传递。

以交换两个int值为例:

// C++引用风格swap
void Swap(int& first, int& second) {
    int temp = first;
    first = second;
    second = temp;
}

注意:不能将常量作为参数传递给按引用传递参数的函数。

Swap(3,4); // 编译不通过!

对比一下指针版本的swap,我们就可以发现指针的丑陋,进而可以讨论一下指针与引用了。

// C指针风格swap
void Swap(int* first, int* second) {
    int temp = *first;
    *first = *second;
    *second = temp;
}

在这之前,顺便提一句吧。引用可以作为函数的返回值,这样做主要原因是提高效率。

警告:确保函数终止后仍然存在的情况下才能使用这一技巧。对于堆栈中自动分配的变量,绝不能返回这个变量的引用。

使用引用还是指针

在C++,引用可以说是多余的,几乎所有使用引用的地方都可以改成指针。但是,我们也看到了指针语法的丑陋,利用引用可以使程序简洁且容易理解。

此外,引用比指针安全,不可能存在无效引用,众所周知,使用空悬指针是C语言很容易出现的bug。引用也不需要显式地解除引用。

大多数情况下,应该使用引用。只有在需要改变所指向的地址时,才需要使用指针,因为无法改变引用所指的对象。例如,动态分配内存,应该将结果存储在指针而不是引用中。需要使用指针的第二种情况是可选参数,指针可以带默认值的nullptr(c++11)的可选参数,但是引用参数不能这样定义。

还有一种方法判断使用指针还是引用作为参数和返回类型:考虑谁拥有内存。如果接受变量的代码不需要释放内存,应该使用引用。代码需要管理释放内存,必须使用对象的指针,最好是智能指针。

右值引用(C++11)

左值(lvalue)是可以获取其地址的一个量,例如有名称的变量。通常出现在赋值语句的左边,因此称其为左值。

不是左值的量,都是右值(rvalue),例如常量值、临时对象或者临时值。同理,右值通常位于赋值运算符的右边。

右值引用是一个对右值的引用。右值引用的目的是提供在涉及临时对象时可以选定的特定方法。由于临时对象会被销毁,通过右值引用,某些涉及复制大量值的操作可以通过简单地复制指向这些值的指针实现。

#include <iostream>

// 左值引用版本
void Incr(int& value) {
    std::cout << "增加左值" << std::endl;
    ++value;
}

// 重载的右值版本
void Incr(int&& value) {
    std::cout << "增加右值" << std::endl;
    ++value;
}

int main(int argc, char const *argv[])
{
    using std::cout;
    using std::endl;
    int a = 10, b = 20;
    // 增加左值
    Incr(a);
    // 11
    cout << a << endl;
    // 增加右值,结果被弃
    Incr(a+b);
    return 0;
}

如果删除接受左值引用的Incr()函数,使用左值调用Incr(),如Incr(b),会导致编译错误。右值引用参数(int&& value)不会与左值(b)绑定。可以使用std::move()将左值转换为右值,强迫编译器使用Incr()的右值引用版本。

Incr(std::move(b)); // 将调用Incr(int&& value)

右值引用并不局限于函数的参数。可以声明右值引用类型的变量,并对其赋值,尽管这一用法很少见。

考虑下面的代码,在C++中,这是不合法的。

// 在C++中是不合法的
int& i = 2;
int a = 2, b = 3;
int& j = a + b;

改用右值引用,下面代码完全合法。

int&& i = 2;
int a = 2, b = 3;
int&& j = a + b;

不过,很少有人这么写代码的,单独使用右值引用的情况很少见。

移动语义

对象移动语义(Move Semantics)需要实现移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。

如果源对象是在复制或者赋值结束后被销毁的临时对象,编译器就会使用这两个方法。移动构造函数和移动赋值运算符将成员变量从源对象复制/移动到新对象,然后将源对象的变量设置为空值。

说白一点,就是发现对象复制完就被销毁了,那就干脆不要复制,直接给我就行了,然后你假装被销毁。这样实际上将内存的所有权进行了转移,只对成员变量进行浅复制(shallow copy),然后转换已分配内存的所有权,从而阻止悬挂指针和内存泄漏。

注意,移动构造函数和移动赋值运算符应使用noexcept限定符标记,告诉编译器,不会抛出异常。这对于标准库兼容非常重要。

#include <iostream>
#include <vector>

// 移动语义探究
class MoveSemExplore {
 public:
  MoveSemExplore(int width, int height) : width_(width), height_(height) {
    std::cout << "普通构造函数" << std::endl;
  }
  // 移动构造函数
  MoveSemExplore(MoveSemExplore&& src) noexcept;
  // 重载赋值运算符
  MoveSemExplore& operator=(const MoveSemExplore& rhs);
  // 移动赋值运算符
  MoveSemExplore& operator=(MoveSemExplore&& rhs) noexcept;
 private:
  int width_;
  int height_;
};

// 移动构造函数实现
MoveSemExplore::MoveSemExplore(MoveSemExplore&& src) noexcept {
    // 测试输出
    std::cout << "移动构造函数" << std::endl;
    // 浅复制
    width_ = src.width_;
    height_ = src.height_;
    // 重置源对象,转移所有权
    src.width_ = 0;
    src.height_ = 0;
}

// 移动赋值操作符重载
MoveSemExplore& MoveSemExplore::operator=(MoveSemExplore&& rhs) noexcept {
    // 测试输出
    std::cout << "移动赋值运算符" << std::endl;
    // 检查自赋值
    if (this == &rhs) {
        return *this;
    }
    // 浅复制
    width_ = rhs.width_;
    height_ = rhs.height_;
    // 重置源对象,转移所有权
    rhs.width_ = 0;
    rhs.height_ = 0;
    return *this;
}

MoveSemExplore& MoveSemExplore::operator=(const MoveSemExplore& rhs) {
    std::cout << "普通赋值运算符" << std::endl;
    // 检查自赋值
    if (this == &rhs) {
        return *this;
    }
    width_ = rhs.width_;
    height_ = rhs.height_;
    return *this;
}
// 工厂方法
MoveSemExplore CreateObject() {
    return MoveSemExplore(3, 2);
}

int main(int argc, char const *argv[])
{
    std::vector<MoveSemExplore> vec;
    for (int i = 0; i < 2; ++i) {
        std::cout << "插入" << i << std::endl;
        vec.push_back(MoveSemExplore(100, 100)); 
    }
    std::cout << "===========" << std::endl;
    MoveSemExplore m(2, 3);
    m = CreateObject();
    MoveSemExplore m2(5, 6);
    m2 = m;
    return 0;
}

-----输出-----
插入0
普通构造函数
移动构造函数
插入1
普通构造函数
移动构造函数
移动构造函数
===========
普通构造函数
普通构造函数
移动赋值运算符
普通构造函数
普通赋值运算符

还是很好理解的,唯一可能会让人困惑的就是为什么插入1那会有两次移动构造函数。这个问题就牵涉到了vector容器的实现原理。

vector的底层还是一个数组,元素数量上溢时,会分配一块更大的内存(数组),然后将原有的数组复制进去。倘若频繁的复制,开销无疑是巨大的,因此一次分配多少个元素就是个问题。经过对复杂度的分析,计算机科学家表示应该按照1、2、4、8、16、32……以2的幂次扩容。这样均摊到每一个元素的复制成本是O(1)。

所以,插入第2个元素的时候,vector就需要分配两个元素的空间,然后把原来的第1个元素复制进新空间,这就是为什么会多出一次移动构造函数的操作。如果你怀疑,还能把插入的数量作调整,就会看到,插入第3个元素(分配4个元素的内存)的时候会出现3次移动构造函数,插入第4个元素的时候,只需要1次。

在这个例子中,移动语义其实没什么作用,但是如果涉及大量的数据,移动语义节省的开销是非常可观的。

再次考虑swap函数,如果我们通过模板实现泛型,可以使用移动语义提高性能。

template<typename T>
void SwapCopy(T& a, T& b) {
    T temp(a);
    a = b;
    b = temp;
}

这个实现将a复制到temp,然后把b复制到a,最后将temp复制到b。如果类型T的复制开销很大,这个交换就会严重影响性能。

使用移动语义的SwapMove()函数可以避免所有的复制。

template<typename T>
void SwapMove(T& a, T& b) {
    T temp(std::move(a));
    a = std::move(b);
    b = std::move(temp);
}

显然,只有知道源对象会被销毁的情况下,移动语义才有意义。

const和static的疑问

C++中的两个关键字conststatic有很多令人困惑的地方,有很多很微妙的地方。

const关键字

const是constant的缩写,指保持不变的量。编译器会执行这一个要求,任何尝试改变常量的行为都会被当作错误。此外,启用优化后,编译器能根据const信息生成更好的代码。

1.const变量和参数

替换#define定义常量,这是const关键字的重要用法

const double PI = 3.14159265358;

可以将任何变量标记为const,包括全局变量和类数据成员。

const指针
const int *ip;
ip = new int[10];
// 不能编译
ip[4] = 5;

这种写法等价于:

int const *ip;
ip = new int[10];
// 不能编译
ip[4] = 5

const是左结合的const int是因为左边没有修饰内容,才向右修饰了int,只能修改指针指向,不能修改地址所包含的数值。或者你可以按照const位于*前后划分。

如果将const放到*后面,这就变成修饰指针

int* const ip = nullptr;
// 试图修改指针指向,无法通过编译
ip = new int[5];

这个时候,只能修改地址内保存的值,不能修改地址。

还可以将指针和所指的值全都标记为const,这样值和指针都改不了。

int const * const ip = nullptr;
// 等价的写法
const int * const ip = nullptr;

有点混乱,但实际上很简单,记住const是直接作用于它左边的内容就行了。

const引用

const引用通常就比const作用指针简单,原因有两个

  1. 引用已经默认const,无法改变引用所指的对象。
  2. 无法创建一个引用到的引用,所以引用通常只有一层间接取值。

因此,提到“const引用”,含义如下:

int z;
const int& z_ref = z;
// 无法通过编译
z_ref = 4;

const经常用作参数,这非常有用。如果为了提高效率,想按引用传递某个值,但不想修改这个值,可以标记为const引用。

将对象作为参数传递时,默认选择是const引用,只有明确需要修改对象,才能忽略const

const方法

没什么好说的,可以将类方法标记为const,禁止修改类的任何非可变(non-mutable)数据成员。

2.constexpr关键字

数组的大小必须是常量表达式,下面的代码在C++中无效:

const int GetArraySize() { return 32; }
int main() {
   // 无效
   int my_array[GetArraySize()];
   return 0;
}

使用constexpr重新定义GetArraySize()函数,把它变成常量表达式。常量表达式会在编译的时候计算。

constexpr int GetArraySize() { return 32; }
int main() {
   int my_array[GetArraySize()];    // 可以
   return 0;
}

甚至,还能int my_array[GetArraySize() + 1];

将函数声明为constexpr,会有一定的限制,因为编译器必须在编译期间对constexpr函数求值。只要是运行的时候才能确定的,都不能写进该函数中。只要清楚这一点,那些条条框框的细则就没必要列出了。

constexpr 特性,方才实现了 C++ 在接口上打造真正常量机制的可能。好好用 constexpr 来定义真・常量以及支持常量的函数。避免复杂的函数定义,以使其能够与constexpr一起使用。 千万别痴心妄想地想靠 constexpr 来强制代码「内联」。

static关键字

在C++中,static有多种用法,这些用法之间好像并没有关系。“重载”这个关键字的部分原因是避免引入新的关键字。

1.静态数据成员和方法

类层次(而不是对象层次),它不是对象的一部分。

静态数据成员只有一个副本,存在于类的任何对象之外,可以用来统计有多少个实例化的对象。

静态方法类似,不会在某个特定对象中执行。

2.静态链接(static link)

C++每个源文件都是单独编译的,编译得到的目标文件会彼此链接。C++源文件中的每个名称,包括函数和全局变量,都有一个内部或者外部的链接。

外部的链接,意味着这个名称在其他的源文件中也有效,内部链接(也称作静态链接),意味着在其他源文件中无效。

默认情况下,函数和全局变量都拥有外部链接。可以使用static指定内部(或者静态)链接。

如果在应该使用外部链接的时候使用了内部(静态)链接,可以成功编译,但是链接会失败。

内部链接的另一种方式是使用匿名名称空间(anonymous namespaces)。可以将变量或者函数封装到一个没有名字的名称空间。

#include <iostream>
namespace {
    void f();
    void f() {
        std::cout << "f\n";
    }
} // namespace

可以在匿名名称空间之后的任何位置访问名称空间中的项,但不能在其他源文件中访问。

extern关键字

extern将后面的名称指定为外部链接。例如,consttypedef默认情况下是内部链接,可以使用extern变为外部链接。

然而,extern有一点复杂。指定某个名称为extern,编译器将其作为声明,而不是定义。对于变量而言,这意味着编译器不会为这个变量分配空间。必须为这个变量单独提供不使用extern关键字的定义行。

extern int x;
int x = 3;

也可以在extern行初始化x

extern int x = 3

这种情况并不是很有用,因为本来x就具有外部链接。当另一个源文件使用了x时,才会真正用到extern

#include <iostream>
extern int x;
int main() {
   std::cout << x << std::endl;
}

如果没有声明时没有用extern,编译器会认为这是一个定义,分配空间,导致最后链接失败(有两个全局作用域的x变量)。

不过,建议不要使用全局变量,尽可能地缩小变量的作用域。很难跟踪这些全局变量在哪里以及如何使用它们。代码可能不经意地改变了全局变量,虽然原本只是想要使用全局变量。为了获取类似的功能,可以使用类的静态数据成员和静态方法。

3.函数中的静态变量

C++中的static关键字的最终目的是创建离开和进入定义域时都可以保留值的局部变量。函数中的静态变量就像只能在函数内部访问的全局变量。静态变量最常见的用法是“记住”某个函数是否执行了特定的初始化。

// 该函数不是线程安全的,包含一个竟态条件。
// 多线程环境中需要用同步机制同步多个线程
void PerformTask() {
    static bool initialized = false;
    if (!initialized) {
        std::cout << "Initializing\n";
        initialized = true;
    }
}

不过静态变量很容易令人迷惑,在构建代码时通常有更好的方法,以避免使用静态变量。在这种情况下,可以写一个类,用构造函数执行所需要的初始化。

非局部变量的初始化顺序

程序中所有的全局变量和静态数据成员都会在main()开始之前初始化。给定源文件中的变量以在源文件中出现的顺序初始化。例如,在下面文件中,Demo::x一定会在y之前初始化。

class Demo {
 public:
  static int x;
};
int Demo::x = 3;
int y = 4;

然而,C++并没有规定不同源文件初始化非全局变量的顺序。如果在某种源文件中有一个全局变量x,在另一个文件中有一个全局变量y,无法知道哪个变量先初始化。通常,不用理会这个问题,但是如果某个全局变量或者静态变量依赖于另一个变量,就可能引发问题。

非局部变量的销毁顺序

非局部变量按照其初始化的逆序进行销毁。不同源文件中非局部变量的初始化顺序是不确定的,所以其销毁顺序也是不确定的。