C++11现代化编程基础

C++11中引入了右值引用, 移动语义, 新增了许多标准库内容, 这里主要介绍一些基于C++11的现代类构建, 内存资源管理等基础内容, 也算是自己的一次复习, 文中部分内容来自书本<C++ primer 5th>

重点:

  • RAII: 智能指针
  • Functional: lambda
  • STL: vector, map, unordered_map

语言基础

基本编译与运行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# g++编译源码,输出二进制可执行文件
# 若省略-o prog1,编译器默认输出a.out
# 通过-std=c++0x,指定C++11支持
g++ -o prog1 prog1.cc

# 运行程序
./prog1

# 查看程序返回值
echo $?

测试-1返回值

1
2
3
int main() {
  return -1;
}
1
2
3
g++ -o prog1 -std=c++0x prog1.cc
./prog1
echo $? # 255

输入不定数量的数字

1
2
3
4
5
6
7
8
#include <iostream>
int main() {
  int num = 0, sum = 0;
  while (std::cin >> num) { // 持续读取直到遇见EOF(end-of-file), bash中可通过ctrl+d输入EOF
    sum += num;
  }
  std::cout << sum << std::endl;
}

内联函数和constexpr函数

内联机制用于优化规模较小、流程直接、频繁调用的函数, inline关键字也只是给编译器的建议, 如果函数过长(超过10行), 或者其中包含分支/循环/递归, 实际上编译器不支持内联展开

constexpr函数约定: 函数的返回类型及所有形参的类型都得是字面值类型, 而且函数体中必须有且只有一条return语句

constexpr函数的返回值由编译器在编译时确定, constexpr函数被隐式地指定为内 联函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
constexpr int new_sz() { return 42; }
constexpr int foo = new_sz();
constexpr size_t scale(size_t cnt) {
  return new_sz() * cnt;
}

int main() {
  std::cout << scale(2) << std::endl;
  return 0;
}

函数指针与返回指针的函数

1
2
bool (*pf)(const std::string&, const std::string&); // 函数指针
bool *pf(const std::string&, const std::string&); // 返回指针的函数

class基本

struct和class没有很大区别,主要是默认访问权限不同,struct默认为public,class默认为private

The only difference between struct and class is the default access level.

If we use the struct keyword, the members defined before the first access specifier are public; if we use class, then the members are private.

sale_data.h

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <string>
#include <cstdint>

struct SaleData {
  // costructors
  SaleData() = default; // 使用自动生成的默认构造
  SaleData(const std::string& s, uint32_t n, double p) :
    book_no(s), units_sold(n), revenue(p) {} // 构造函数初始值列表, 若成员是const或者引用, 必须在此时初始化
  SaleData(const std::string& s) : SaleData(s, 0, 0.0) {} // 委托构造
  SaleData(SaleData&) = delete; // 禁用自动生成的复制构造
  // operations
  std::string isbn() const { return book_no; }
  SaleData& combine(const SaleData&);
  double avg_price() const;
  // data members
  std::string book_no;
  uint32_t units_sold = 0;
  double revenue = 0.0;
};
// nonmember SaleData interfaces functions
std::ostream& operator<<(std::ostream&, const SaleData&);
std::istream& operator>>(std::istream&, SaleData&);

sale_data.cc

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "item.h"

double SaleData::avg_price() const {
  if (units_sold > 0) {
    return revenue / units_sold;
  } else {
    return 0.0;
  }
}

SaleData& SaleData::combine(const SaleData& rhs) {
  units_sold += rhs.units_sold;
  revenue += rhs.revenue;
  return *this;
}

std::ostream& operator<<(std::ostream& os, const SaleData& data) {
  os << "ISBN: " << data.isbn() << "; Sold: " << data.units_sold
    << "; Avg price: " << data.avg_price() << std::endl;
  return os;
}

std::istream& operator>>(std::istream& is, SaleData& data) {
  double price = 0.0;
  is >> data.book_no >> data.units_sold >> price;
  data.revenue = price * data.units_sold;
  return is;
}

main.cc

1
2
3
4
5
6
7
8
9
#include <iostream>
#include "item.h"
int main() {
  SaleData d1, d2;
  std::cin >> d1 >> d2;
  d1.combine(d2);
  std::cout << "Combine: " << d1 << std::endl;
  return 0;
}

隐式类类型转换

如果构造函数只接受一个实参, 则它实际上定义了转换为此类类型的隐式转换机制, 有时我们把这种构造函数称作转换构造函数

  • 只有接收一个参数的构造函数才会用于隐式转换, explicit关键字也只能修饰只有一个参数的构造函数
  • 隐式转换不能连续转换两次, 比如 const char*​ -> std::string​ -> MyClass
  • explicit修饰构造函数可阻止隐式转换发生
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyClass {
public:
  MyClass(const std::string& n) : name(n) {}
  explicit MyClass(int n) : num(n), name("default") {}
  std::string to_string() const {
    return std::string("{ MyClass: ") + name + " * " + std::to_string(num) + " }";
  }
private:
  std::string name;
  int32_t num = 0;
};

void test(const MyClass& t) {
  std::cout << t.to_string() << std::endl;
}

int main() {
  test(std::string("cpp"));
  MyClass c = std::string("cpp");
  // test("cpp"); // Error 隐式转换不能连续转换两次 const char* -> std::string -> MyClass
  // test(99); // Error 使用explicit声明的构造函数不可以用作隐式转换
  return 0;
}

Friends

友元: private成员只允许类成员函数访问, 若希望允许在外部类/函数中访问private成员, 可使用友元声明

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class SaleData {
// friends declarations
friend std::ostream& operator<<(std::ostream&, const SaleData&);
friend std::istream& operator>>(std::istream&, SaleData&);
// friend class TestClass;

public:
  // costructors
  SaleData() = default;
  SaleData(const std::string& s) : book_no(s) {}
  SaleData(const std::string& s, uint32_t n, double p) :
    book_no(s), units_sold(n), revenue(p) {}
  SaleData(SaleData&) = delete;
  // operations
  std::string isbn() const { return book_no; }
  SaleData& combine(const SaleData&);
  double avg_price() const;
private:
  // data members
  std::string book_no;
  uint32_t units_sold = 0;
  double revenue = 0.0;
};
// nonmember SaleData interfaces functions
std::ostream& operator<<(std::ostream&, const SaleData&);
std::istream& operator>>(std::istream&, SaleData&);

类型别名

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 可在类外定义, typedef 与 using 等价, using更直观
typedef std::string::size_type pos;
using spos = std::string::size_type;

// 也可以在类中定义, 作为类成员, 受到访问权限控制
class MyClass {
public:
  typedef std::string::size_type pos;
  using spos = std::string::size_type;
};

int main() {
  MyClass::pos p = 0;
  return 0;
}

const对象与const成员

声明为 const 的对象只能访问类中声明为 const 的成员函数, 不能调用其它成员函数

const成员函数

1
2
3
4
class Test {
public:
  void print(int i) const;
};
  1. const 是函数类型的一个组成部分, 因此在实现部分也要带 const 关键字
  2. 常成员函数不能更新对象的数据成员, 也不能调用该类中没有用 const 修饰的成员函数

使用const的建议

  1. 要大胆的使用 const, 这将给你带来无尽的益处, 但前提是你必须搞清楚原委
  2. 要避免最一般的赋值操作错误, 如将 const 变量赋值, 具体可见思考题
  3. 在参数中使用 const 应该使用引用或指针, 而不是一般的对象实例, 原因同上
  4. const 在成员函数中的三种用法(参数、返回值、函数)要很好的使用
  5. 不要轻易的将函数的返回值类型定为 const
  6. 除了重载操作符外一般不要将返回值类型定为对某个对象的 const 引用

顶层const与底层const

如果能弄明白以下所有的情况, 那么恭喜你迈出了可喜的一步

1
2
3
4
5
  int a = 0;
  const int * b1 = &a; // 1
  int const * b2 = &a; // 2
  int * const b3 = &a; // 3
  const int * const b4 = &a; // 4

上面的示例中, 1和2情况相同, const位于星号左侧, 都是指针指向的内存不可变, 但指针的值可以修改; 3表示指针本身值不可变, 但指向的内存可变; 4则是两者都不可变

  • 顶层const: 指针本身为常量, 指针的值(地址)不可变, 但它指向的内存是可修改的
  • 底层const: 指针的值(地址)可修改, 但它指向的内存是不可变的
  • 顶层const+底层const: 指针值和指向的内存都是不可变的

拷贝与移动控制

左值与右值

理解C++11中拷贝与移动的前提是了解左值右值, 以及对应的引用

  • 左值 (Lvalue): 指向明确内存地址, 有名字的对象. 可以取地址. 例如: 变量名, 指针成员, 返回左值引用的函数.
  • 右值 (Rvalue): 临时对象, 表达式产生的中间结果, 没有持久的身份. 无法取地址. 例如: 字面量 (除字符串外), 返回非引用类型的函数结果, 表达式 a + b.

引用类型

  • 左值引用 (T&​): 只能绑定到左值, 长久存在, 而且可修改
  • 右值引用 (T&&​): 只能绑定到右值, 其核心意义在于: 这个右值对象是临时的, 可以安全的移动它的资源, 不用担心造成其他影响
  • 常量左值引用 (const T&): 可以绑定到任何值(左值, 右值), 但只读不能修改

C++11中专门引入右值引用用于解决两大痛点: 移动语义完美转发

1
2
3
4
5
6
int a = 10;           // a 是左值, 10 是右值
int& lref = a;        // 左值引用绑定到左值
// int& lref2 = 10;   // 错误: 左值引用不能绑定右值!!
int&& rref = 10;      // 右值引用 (&&) 绑定到右值
// int&& rref2 = a;   // 错误: 右值引用不能绑定左值!!
const int& cref = 10; // 常量左值引用可绑定到右值

右值引用变量具备名称, 可以被取地址, 所以右值引用的变量本身是一个左值, 右值引用的变量用于函数匹配时会匹配到左值的版本! 重要

并且前文提到, 左值引用只能绑定到左值, 右值引用只能绑定到右值

函数形参匹配时同样遵循该规则: 形参为左值引用时无法传入右值; 形参为右值引用时无法传入左值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
void work(int&) { std::cout << "Left value" << std::endl; }
void work(int&&) { std::cout << "Right value" << std::endl; }
int main() {
  int n = 0;
  int& r1 = n;
  int&& r2 = 10; // 其实在此语句之后, r2和普通的int变量(左值)已经没有什么区别
  work(r1); // 传入左值, 匹配左值引用版本
  work(r2); // 传入左值, 匹配左值引用版本, 记住: 右值引用的变量本身 还是左值 (有名字, 可取地址)
  work(std::move(n)); // 传入右值, 匹配右值引用版本
  work(20); // 传入右值, 匹配右值引用版本
  return 0;
}

// 输出
// Left value
// Left value
// Right value
// Right value

右值是中间结果, 临时对象, 即将消亡的对象, 而右值引用可以延长临时对象的声明周期

C++ 标准明确规定: 当右值引用绑定到临时对象时, 临时对象的生命周期会被延长至与右值引用的生命周期一致

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 延长字面值的生命周期
// 字面值可能在源码指令中,没有内存地址,也可能存储在只读数据段.rodata,不可修改
// 右值引用无法绑定到只读的字面值,发生绑定时,编译器会在栈上创建一个临时int变量并初始化
// 右值引用绑定到临时变量,延长临时变量生命周期与自身一致
int&& rref = 20; // 栈上临时变量,右值引用绑定在临时变量上
rref = 999; // 右值引用为左值,可修改
cout << rref; // 999


std::vector<int> get_big_data() { return {1, 2, 3, 4, 5}; }
// get_big_data() 产生的临时 vector 在这行结束后本该销毁
// 但 data 接住了它, 延长了它的寿命, 且没有发生深拷贝
// 左值引用无法引用右值, 无法承接返回的数据, 若不使用右值引用, 可能会发生不必要的数据拷贝
std::vector<int>&& data = get_big_data();

类的"五法则"

一个类若要管理资源(动态内存/句柄等), 通常需要实现"五法则", 包括以下成员

  1. 析构函数 (~T())

  2. 拷贝构造函数 (T(const T&)​) 参数必须是const, 避免无限递归

  3. 拷贝赋值运算符 (T& operator=(const T&))

    实现自身成员赋值, 与旧资源释放

  4. 移动构造函数 (T(T&&)) – C++11新增

  5. 移动赋值运算符 (T& operator=(T&&)) – C++11新增

拷贝构造函数和拷贝赋值运算符一般来说要实现类资源的深拷贝, 而不是简单的指针值拷贝

而移动构造函数和移动赋值运算符需要实现类资源的"移动", 避免不必要的拷贝, 提升性能, 减少资源浪费

std::move​本质上是一个强制类型转换(static_cast<T&&>), 它本身不移动任何内容, 只是告诉编译器将对象当作右值引用看待, 用于触发移动语义, 至于是否发生移动, 取决于对应的类是否实现了移动构造函数/移动赋值运算符

以一个类的实现举例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <algorithm>

class ResourceManager {
private:
  int* data; // 方便示例, 这里没使用智能指针
  size_t size;
public:
  // 构造函数
  ResourceManager(size_t s): size(s), data(new int[s]) {}
  ResourceManager(): ResourceManager(10) {}
  // 析构函数
  ~ResourceManager() {
    delete[] data;
  }
  // 拷贝构造函数 (深拷贝)
  ResourceManager(const ResourceManager& other): size(other.size), data(new int[other.size]) {
    std::copy(other.data, other.data + other.size, data);
    std::cout << "Copy constructor" << std::endl;
  }
  // 拷贝赋值运算符: 自身成员赋值与旧资源释放
  ResourceManager& operator=(const ResourceManager& other) {
    if (this != &other) { // 防止自我赋值
      int* new_data = new int[other.size];
      std::copy(other.data, other.data + other.size, new_data);
      delete[] data; // 释放旧资源
      data = new_data;
      size = other.size;
    }
    std::cout << "Copy operator =" << std::endl;
    return *this;
  }
  // 移动构造函数: 转移资源所有权, 标记为noexcept优化性能
  ResourceManager(ResourceManager&& other) noexcept: size(other.size), data(other.data) {
    other.data = nullptr; // 将原对象置于可析构的默认状态
    other.size = 0;
    std::cout << "Move constructor" << std::endl;
  }
  // 移动赋值运算符
  ResourceManager& operator=(ResourceManager&& other) noexcept {
    if (this != &other) {
      delete[] data; // 释放旧资源
      data = other.data; // 转移资源所有权
      size = other.size;
      other.data = nullptr;
      other.size = 0;
    }
    std::cout << "Move operator =" << std::endl;
    return *this;
  }
};

移动语义

为什么要移动? 拷贝涉及内存分配和数据复制 (昂贵), 而移动只需修改指针指向 (廉价).

以上面的类进行以下测试, 观察显式对象移动的触发, 完成资源所有权转移

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int main() {
  ResourceManager r1; // 普通构造
  ResourceManager r2(r1); // 拷贝构造
  ResourceManager r3(std::move(r1)); // 移动构造 (r1的资源被转移所有权, 转移之后不应该再使用r1)
  ResourceManager r4;
  r4 = r2; // 拷贝赋值运算
  ResourceManager r5;
  r5 = std::move(r3); // 移动赋值运算 (r3的资源所有权被转移)
  ResourceManager r6 = r4; // 依然是 拷贝构造, 这里要注意, 定义时的初始化不同于赋值运算
  return 0;
}

输出如下

1
2
3
4
5
Copy constructor
Move constructor
Copy operator =
Move operator =
Copy constructor

默认生成的函数与=default

一般来说, 当我们定义一个类但不声明任何构造和赋值运算符时, 编译器会生成默认的无参构造函数以及拷贝构造函数和拷贝赋值运算符

当我们定义一个带参构造函数时, 编译器又不会自动生成默认的无参构造函数

我不喜欢去猜一个函数有没有自动生成, 我希望类的内容和行为是明确清晰的

C++11允许我们更精确的控制自动生成的函数, 掌握类的行为, 让内存/资源的拷贝或转移都按照我们预想的执行

  • =default 显式声明需要默认生成的构造函数或者赋值运算符
  • =delete 显式声明禁止生成默认的构造函数或者赋值运算符

如果没有明确声明"五法则"的每个函数, 一个类到底包含哪些构造函数和赋值运算, 其行为是怎样的, 完全不可控, 很难一眼看清

这里用一个错误举例说明一下未定义和不可控的危害, 下面的Test类没有显式定义任何构造/赋值运算, 只有一个简单的析构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Test {
public:
  size_t size = 0;
  int* data = nullptr;
public:
  ~Test() { delete[] data; }
};

int main() {
  Test t1;
  t1.size = 10;
  t1.data = new int[10];
  Test t2(t1);
  Test t3;
  t3 = t1;
  std::cout << t1.size << " " << t1.data << std::endl;
  std::cout << t2.size << " " << t2.data << std::endl;
  std::cout << t3.size << " " << t3.data << std::endl;
  return 0;
}

以上代码运行后会发现t1, t2, t3的data指针值一样, 并且同一个内存位置会被多次释放, 内存管理混乱

这是因为编译器帮我们生成了不符合规范的默认拷贝构造和

1
2
3
4
10 0x60bfb23b6eb0
10 0x60bfb23b6eb0
10 0x60bfb23b6eb0
free(): double free detected in tcache 2

默认生成函数的行为

  • 默认无参构造函数: 按照类成员顺序逐个初始化

    • 如果是类类型成员, 则调用其默认构造函数初始化
    • 如果有类内初始值, 则按照顺序使用给定的初始值初始化
    • 内置类型(int, double等), 如果没有类内初始值, 也没有在构造函数中处理, 将被默认初始化 (可能是无意义的垃圾值)
  • 默认的复制构造: 按照类成员顺序逐个拷贝, 如果是类类型成员, 则尝试拷贝构造

    1
    2
    3
    4
    5
    6
    
    // 编译器生成的拷贝构造函数大致等同于:
        // DataBatch(const DataBatch& other) :
        //     id(other.id),
        //     name(other.name),
        //     scores(other.scores) {}
        DataBatch(const DataBatch&) = default;
    
  • 默认的拷贝赋值运算: 按照类成员顺序逐个进行赋值运算 (拷贝赋值)

    1
    2
    3
    4
    5
    6
    7
    8
    
    // 编译器生成的拷贝赋值运算符大致等同于:
        // DataBatch& operator=(const DataBatch& other) noexcept {
        //     id = other.id;
        //     name = other.name;
        //     scores = other.scores;
        //     return *this;
        // }
        DataBatch& operator=(const DataBatch&) = default;
    
  • 默认的移动构造: 按照类成员顺序逐个进行移动构造

    1
    2
    3
    4
    5
    6
    
    // 编译器生成的移动构造函数大致等同于:
        // DataBatch(const DataBatch& other) :
        //     id(std::move(other.id)),
        //     name(std::move(other.name)),
        //     scores(std::move(other.scores)) {}
        DataBatch(DataBatch&&) = default;
    
  • 默认的移动赋值运算: 按照类成员顺序逐个进行移动赋值运算

    1
    2
    3
    4
    5
    6
    7
    8
    
    // 编译器生成的移动赋值运算符大致等同于:
        // DataBatch& operator=(DataBatch&& other) noexcept {
        //     id = std::move(other.id);
        //     name = std::move(other.name);
        //     scores = std::move(other.scores);
        //     return *this;
        // }
        DataBatch& operator=(DataBatch&&) = default;
    

还是以上面的ResourceManager类来复用举例, 我们构建一个Test类并包含一个ResourceManager成员, “五法则"除了析构函数全用默认生成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Test {
private:
  ResourceManager m;
public:
  Test() = default;
  Test(const Test& t) = default;
  Test(Test&& t) = default;
  Test& operator=(const Test& t) = default;
  Test& operator=(Test&& t) = default;
};

int main() {
  Test t1;
  Test t2(t1); // 逐个成员拷贝构造
  Test t3(std::move(t1)); // 逐个成员移动构造
  Test t4;
  t4 = t2; // 逐个成员拷贝赋值运算
  Test t5;
  t5 = std::move(t3); // 逐个成员移动赋值运算
  return 0;
}

得到如下输出, 在我们预期之中

1
2
3
4
Copy constructor
Move constructor
Copy operator =
Move operator =

=default​默认函数与{}实现的区别

在自己明确知道=default​干了什么的前提下, 可以尽量使用, 因为=default​不仅显式标明了”该函数存在, 由编译器默认生成“的信息, 还可能有一些性能优化, 并且可自动推导noexcept

A. 琐碎性 (Triviality)

如果一个类所有的成员都有 “琐碎” 的构造函数 (比如全是基本类型 int​, double​ 或指针), 那么 =default​ 生成的构造函数也是 Trivial (琐碎的)

  • 琐碎函数: 编译器可以将其优化为极其高效的内存操作 (如 memcpy 或直接清零)
  • 用户定义函数 {}: 一旦你写了 {}, 即使里面什么都没写, 这个函数也不再是 “琐碎” 的, 编译器必须像调用普通函数一样去执行它, 无法进行内存级别的压榨优化

B. 异常规格 (Exception Specification)

=default​ 函数会自动推导异常规格. 如果类的所有成员在拷贝时都不抛出异常, 编译器生成的函数会自动带上 noexcept​. 而手动编写 {}​ 则需要你手动管理 noexcept, 否则可能导致容器性能下降

使用=default​与=delete定制类的行为

如果一个类的资源(所有权)只允许被移动, 不允许被拷贝, 类似std::unique_ptr的行为, 可以按照以下示例处理

如果不小心进行了拷贝, 编译器会报错, 阻止不允许的操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Test {
private:
  ResourceManager m;
public:
  Test() = default;
  Test(const Test& t) = delete; // 阻止拷贝构造
  Test(Test&& t) = default;
  Test& operator=(const Test& t) = delete; // 阻止拷贝赋值运算
  Test& operator=(Test&& t) = default;
};

int main() {
  Test t1;
  // Test t2(t1); // 逐个成员拷贝构造 // 编译报错, 删除的操作
  Test t3(std::move(t1)); // 逐个成员移动构造
  Test t4;
  // t4 = t2; // 逐个成员拷贝赋值运算 // 编译报错, 删除的操作
  Test t5;
  t5 = std::move(t3); // 逐个成员移动赋值运算
  return 0;
}

同理, 我们可以自定义控制类的行为, 比如出只允许拷贝不允许移动的类

移动(move)和完美转发(forward)和引用折叠

std::move是无条件的右值转换

之前提过, std::move​ 并不移动任何数据, 它的唯一作用是: 无条件地将输入参数转换为右值引用

相当于告诉编译器, 这个对象我不再使用了, 可以随便转移对象中资源的所有权, 通常会触发目标的 移动构造函数移动赋值运算符

注意: 除非你确定该对象后续不再被使用, 否则不要随便 std::move. 移动后的对象处于 “有效但未定义” 的状态.

std::forward是有条件的完美转发

std::forward​和引用折叠是紧密相关的概念, std::forward​​ 必须配合 万能引用( T&&) 使用

作用是: 只有当原始参数是右值时, 才将其转换为右值; 否则保留其左值属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
template<typename T>
void wrapper(T&& arg) { // T&& 在这里是万能引用
    // 如果不加 forward, arg 永远以左值形式传递 (无论arg类型是左值还是右值)
    // 加上 forward, 依据引用折叠规则, 当arg是左值时调用左值引用函数; 当arg是右值时调用右值引用函数
    real_work(std::forward<T>(arg));
}

void real_work(int& x) { std::cout << "Left value" << std::endl; }
void real_work(int&& x) { std::cout << "Right value" << std::endl; }

int main() {
    int a = 10;
    wrapper(a);  // 传入左值, forward 转发为左值
    wrapper(20); // 传入右值, forward 转发为右值
}

引用折叠

为什么说T&&是"万能引用”? 这涉及到C++11中的引用折叠规则

在C++中我们不能直接定义"引用的引用", 比如int& &&, 这种写法会导致编译错误

然而, 在模板参数推导, typedef​, using​别名, 以及decltype等场景下可能会间接出现这种情况

为了处理这些嵌套的引用, 编译器会应用"引用折叠"规则

原始定义类型 传入/合成T的类型 折叠后的最终类型
T& (左值引用) & (左值) T&
T& (左值引用) && (右值) T&
T&& (右值引用) & (左值) T&
T&& (右值引用) && (右值) T&&

示例1: typedef别名

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
typedef int&  Lref;
typedef int&& Rref;

int main() {
  int n = 0;
  Lref& r1 = n; // r1: Lref & -> int& & -> int&
  Lref&& r2 = n; // r2: Lref && -> int& && -> int&
  Rref& r3 = n; // r3: Rref & -> int&& & -> int&
  Rref&& r4 = std::move(n); // r4: Rref && -> int&& && -> int&&
  return 0;
}

注意: 这里的r4变量本身是左值, 再当作参数传递还是会匹配为左值引用参数

函数返回类对象

当函数返回一个局部对象时, 编译器会按照一定优先级顺序来优化这个过程

当你写 return obj; 时, 编译器并非直接选择拷贝, 而是遵循以下优先级:

  1. 返回值优化 (RVO/NRVO): 这是最高优先级. 编译器直接在调用者的内存栈上构造对象, 既不发生拷贝, 也不发生移动. 这是性能最优的方案.
  2. 隐式移动 (Implicit Move): 如果由于某些原因 (如复杂的条件分支) 无法进行 RVO, 编译器会自动将返回的局部变量视为 ​右值​, 尝试调用该类的 ​移动构造函数.
  3. 拷贝构造: 如果类没有定义移动构造函数, 编译器才会回退 (Fallback) 到传统的 拷贝构造.

性能最大化: 移动语义的开销远小于深拷贝. 这种自动尝试移动的机制让开发者无需手动编写 return std::move(obj);​ (事实上, 显式写 std::move​​ 反而会破坏 RVO 优化, 导致性能下降).

如果没有上述优化, 看看C++98/03时代的内存处理过程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class BigObject {
    // ... 包含拷贝构造函数和析构函数 ...
};

BigObject createObj() {
    BigObject temp; // 1. 构造局部对象
    return temp;    // 2. 拷贝构造到临时对象 (return slot)
                    // 3. 析构局部对象 temp
}

int main() {
    BigObject obj = createObj(); // 4. 拷贝构造到 obj
                                 // 5. 析构临时对象
}

理论上的开销:

  1. 构造局部对象.
  2. 拷贝构造临时对象(用于传出返回值).
  3. 析构局部对象.
  4. 拷贝构造最终对象 obj.
  5. 析构临时对象.
  6. (最终) 析构 obj.

这涉及了两次拷贝两次额外的析构, 对于重量级对象(如大容器), 性能损耗巨大

RVO 通常特指 Unnamed Return Value Optimization (未命名返回值优化). 当函数直接返回一个临时对象(匿名对象)时触发

NRVONamed Return Value Optimization (具名返回值优化). 当函数返回一个具名的局部变量时触发

C++11 引入了移动语义 (Move Semantics). 编译器处理返回值的优先级变成了:

  1. Tier 1: RVO/NRVO (最佳, 零拷贝).
  2. Tier 2: Move Constructor (次佳, 资源转移).
  3. Tier 3: Copy Constructor (最差, 深拷贝).

现代C++11函数返回局部对象示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Test {
private:
  int index = 0;
public:
  Test(int i) : index(i) { cout << "Test (" << index << "): Constructor" << endl; }
  Test() : Test(0) {}
  ~Test() { cout << "Test (" << index << "): Deconstructor" << endl; }
  Test(const Test&) { cout << "Test (" << index << "): Copy Constructor" << endl; }
  Test(Test&&) noexcept { cout << "Test (" << index << "): Move Constructor" << endl; }
  Test& operator=(const Test&) { cout << "Test (" << index << "): Copy operator=" << endl; return *this; }
  Test& operator=(Test&&) noexcept { cout << "Test (" << index << "): Move operator=" << endl; return *this; }
};

Test factory1() {
  return Test(111);
}

Test factory2() {
  Test t(222);
  return t;
}

Test factory3() {
  int seed = 333;
  if (rand() % 100 < 50) {
    Test a(seed);
    return a;
  } else {
    Test b(-seed);
    return b;
  }
}

int main() {
  srand(time(nullptr));
  {
    cout << "Case 1" << string(10, '=') << endl;
    Test s1 = factory1();
  }
  {
    cout << "Case 2" << string(10, '=') << endl;
    Test s2 = factory2();
  }
  {
    cout << "Case 3" << string(10, '=') << endl;
    Test s3 = factory3();
  }
  return 0;
}

源码输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Case 1==========
Test (111): Constructor
Test (111): Deconstructor
Case 2==========
Test (222): Constructor
Test (222): Deconstructor
Case 3==========
Test (-333): Constructor
Test (0): Move Constructor
Test (-333): Deconstructor
Test (0): Deconstructor

用例说明

  • Case1: RVO优化, 直接原位构造, 仅构造一次, 零拷贝

    机制: 编译器意识到该函数通过值返回, 它会直接将 main​ 函数中 s1​ 的内存地址隐式传递给 factory1()​. factory1()​ 不再在自己的栈帧上创建临时对象, 而是直接在 s1的地址上调用构造函数.

  • Case2: NRVO优化, 同样直接原位构造, 更复杂, 也是仅构造一次, 零拷贝

    机制: 相比 RVO, NRVO 更难实现, 因为 t​ 作为一个局部变量, 在其生命周期内必须可见且可用. 编译器必须确保直接在调用者(main​ 中的 s2​)的内存空间中构造 t, 并确保函数体内的任何操作都是直接针对该外部内存进行的.

  • Case3: 由于条件分支导致编译器无法进行RVO/NRVO, 编译器会自动将返回的局部变量视为 右值, 尝试触发该类的 移动构造函数

可以看到, 在没有任何显式处理的情况下, 编译器已经依据情况做了最佳优化, 不必手动return std::move(t);, 仅当RVO/NRVO没有触发, 并且类没有实现移动构造时, 编译器才会不得已选择拷贝

noexcept

在之前的示例中我们已经多次见到noexcept关键字

noexcept​ 是 C++11 引入的一个重要关键字, 它本质上是一个 “性能契约” . 它告诉编译器和程序员: “这个函数保证不会抛出任何异常”.

1. noexcept 的基本含义与语法

noexcept​ 既是一个 ​说明符 (Specifier) ​, 也是一个 ​运算符 (Operator) .

  • 作为说明符: 紧跟在函数参数列表后, 表示该函数是否会抛出异常.
  • 作为运算符: 用于在编译期检查一个表达式是否可能抛出异常.
1
2
3
4
5
void fast_func() noexcept;          // 保证不抛出异常
void slow_func() noexcept(false);   // 可能会抛出异常 (等同于不写)

// 运算符用法
bool is_safe = noexcept(fast_func()); // 返回 true

2. 核心作用: 性能优化的 “绿灯”

noexcept​ 最主要的作用不是为了排版好看, 而是为了 ​性能.

A. 编译器优化

在普通函数中, 编译器必须生成大量的额外代码来支持 ​栈回溯 (Stack Unwinding) ​. 万一异常发生, 程序需要知道如何按顺序销毁已构造的局部对象. 如果标注了 noexcept, 编译器就可以省去这些复杂的开销, 生成更小, 更快的机器码.

B. 容器搬运的效率 (以 std::vector 为例)

这是 noexcept​ 最具实战价值的场景. 当 std::vector 扩容需要将旧元素迁移到新内存时:

  • 如果元素的 移动构造函数noexcept​ 的, vector​ 会选择 ​移动 (Move) , 速度极快.
  • 如果移动构造函数 没有 标注 noexcept​, vector​ 为了保证 “强异常安全保证” (即移动一半出错了不能把原数据弄丢), 会被迫选择 拷贝 (Copy) .

3. 关键问题: 如果在 noexcept 函数中抛出了异常会怎样?

这是一个典型的 “破釜沉舟” 行为. 如果你承诺了不抛异常, 结果却在运行时从 noexcept 函数中甩出了一个异常:

  1. 立即调用 std::terminate(): 程序不会去寻找匹配的 catch 块.
  2. 不保证栈回溯: 在调用 std::terminate()​ 之前, 编译器 不保证 会销毁当前的局部对象. 这意味着你的析构函数可能不会执行, 资源可能泄露.
  3. 直接崩溃: 程序会直接挂掉, 没有任何商量的余地.

4. 什么时候该用 noexcept ?

虽然它能提速, 但也不要滥用. 以下是最佳实践:

  • 必须加上: 析构函数 (默认就是 noexcept​​), 移动构造函数, 移动赋值运算符, 交换函数 (swap​​).
  • 建议加上: 逻辑简单的叶子函数 (不调用别人, 且自己不 throw​).
  • 不要加上: 可能会向下调用第三方库或涉及 IO 操作, 内存申请 (可能抛出 std::bad_alloc) 的函数.

动态内存与智能指针

使用动态内存最安全的方式是用make_shared/make_unique方法

shared_ptr使用

shared_ptr 使用引用计数自动管理内存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <initializer_list>
#include <iostream>
#include <stdexcept>
#include <string>
#include <cstdint>
#include <memory>
#include <vector>


class StrBlob {
public:
  typedef std::vector<std::string>::size_type size_type;
  StrBlob();
  StrBlob(std::initializer_list<std::string> il);
  size_type size() const { return data->size(); }
  bool empty() const { return data->empty(); }
  // 增删元素
  void push_back(const std::string& t) { data->push_back(t); }
  void pop_back();
  // 元素访问
  std::string& front();
  std::string& back();
  // const访问器
  const std::string& front() const;
  const std::string& back() const;
private:
  std::shared_ptr<std::vector<std::string>> data;
  // 下标检查
  void check(size_type i, const std::string& msg) const;
};

StrBlob::StrBlob(): data(std::make_shared<std::vector<std::string>>()) {}
StrBlob::StrBlob(std::initializer_list<std::string> il):
  data(std::make_shared<std::vector<std::string>>(il)) {}
void StrBlob::check(size_type i, const std::string& msg) const {
  if (i >= data->size()) {
    throw std::out_of_range(msg);
  }
}
std::string& StrBlob::front() {
  check(0, "front on empty StrBlob");
  return data->front();
}
std::string& StrBlob::back() {
  check(0, "back on empty StrBlob");
  return data->back();
}
const std::string& StrBlob::front() const {
  check(0, "front on empty StrBlob");
  return data->front();
}
const std::string& StrBlob::back() const {
  check(0, "back on empty StrBlob");
  return data->back();
}
void StrBlob::pop_back() {
  check(0, "pop_back on empty StrBlob");
  data->pop_back();
}

int main() {
  int i = { 42 };
  std::cout << i << std::endl;
  StrBlob b1 = { "a", "b", "c" };
  {
    StrBlob b2 = b1;
    b2.push_back("d");
    std::cout << "b2 size: " << b2.size() << std::endl;
  }
  std::cout << "b1 size: " << b1.size() << std::endl;

  std::shared_ptr<std::vector<std::string>> pv =
    std::make_shared<std::vector<std::string>>(std::initializer_list<std::string>{"a", "b", "c"});
  pv.reset(); // 释放内存, 智能指针成为空指针
  // new和智能指针尽量不要一起用, 避免创建出指向同一内存区域的两个独立智能指针
  int* p = new int(22);
  {
    std::shared_ptr<int> p1(p);
    std::shared_ptr<int> p2(p); // 二次释放, 行为未定义
  }

  int* pn = new int(33);
  std::shared_ptr<int> p1(pn);
  p1.reset(new int(35)); // pn指向的内存被自动释放, p1指向位置变更
  std::shared_ptr<int> p2 = p1; // 正确, 指针拷贝, 引用计数+1
  // reset也常与unique一起使用
  std::string* name = new std::string("unique name");
  std::shared_ptr<std::string> ps(name);
  if (!ps.unique()) {
    ps.reset(new std::string(*ps)); // 不是唯一用户, 分配新的拷贝
  }
  return 0;
}

unique_ptr

与shared_ptr不同的是, unique_ptr拥有它指向的对象, 不支持普通的拷贝或者赋值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
unique_ptr<int> p1(new int(12));
// unique_ptr<int> p2(p1); // 错误, 不支持拷贝
// unique_ptr<int> p3 = p2; // 错误, 不支持赋值

p1 = nullptr; // 释放对象, 指针置空
p1.reset(); // 释放对象, 指针置空
p1.reset(nullptr)
p1.reset(p5); // 释放原有对象, 指向新内存
p1.release(); // 释放控制权, 返回指针, 指针本身置空 (内存还在, 只是不再由智能指针管理)

// 移交所有权
unique_ptr<int> p2(p1.release());
unique_ptr<int> p3(new int());
p3.reset(p2.release()); // p3原本内存释放, p2将内存所有权移交到p3

不能拷贝unique_ptr的规则有一个例外:我们可以拷贝或赋值一个 将要被销毁的unique_ptr (右值引用)

1
2
3
4
5
6
7
8
unique_ptr<int> clone() {
  return unique_ptr<int>(new int(6));
}

unique_ptr<int> clone(int i) {
  unique_ptr<int> ret(new int(i));
  return ret;
}

自定义删除器

shared_ptr与unique_ptr默认使用delete释放对象内存, 也可以在泛型参数第二个参数中传入自定义的删除器(指定类型的可调用对象)

1
2
3
4
5
void f(destination& d) {
  connection c = connect(&d);
  unique_ptr<connection, decltype(end_connection)*> p(&c, end_connection);
  // 当f退出时, 无论是否抛出异常, connection都会正确关闭
}

weak_ptr

weak_ptr不控制对象生命周期, 它指向一个shared_ptr管理的对象, weak_ptr绑定到shared_ptr不改变引用计数

1
2
3
4
5
6
7
8
weak_ptr<T> w;
w = p; // p可以是shared_ptr, 可以是weak_ptr, 赋值后共享对象
w.reset(); // w置空
w.use_count(); // 与w共享对象的shared_ptr数量
w.expired(); // 值为: w.use_count() == 0
w.lock(); // 若w.expired()为true则返回一个空的shared_ptr, 否则返回一个指向w对象的shared_ptr

if (shared_ptr<T> np = w.lock()) { ... } // 安全, 仅当返回指针非空时才进入语句体

new 与 delete

一般来说不直接使用new和delete, 也不要混合使用new/delete与智能指针, 也尽量不要使用智能指针的get方法访问原始指针, 最好全部使用智能指针和make_shared/make_unique管理动态内存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int *pi1 = new int; // 值未定义
int *pi2 = new int(); // 值初始化为0
int *pi3 = new int(1024); // 初始化为1024
string *ps1 = new string; // 默认初始化为空字符串
string *ps2 = new string(); // 值初始化为空字符串
string *ps3 = new string(10, '9'); // 初始化为"9999999999"
vector<int> *pv = new vector<int>{0, 1, 2, 3}; // 列表初始化

auto p1 = new auto(obj); // p1指向与obj类型相同的对象, 并且该对象使用obj进行初始化
// delete 省略
1
2
3
4
5
int *pi1 = nullptr;
double *pd1 = new double(33);
delete pi1; // 正确, 对空指针调用delete不会有问题
delete pd1; // 正确
delete pd1; // 未定义, 释放已经释放过的内存

数组

delete释放动态数组时必须加上方括号, 如果在释放数组时忘记加上方括号, 其行为是未定义的; 同理, 在普通动态对象delete时如果加上方括号, 其行为是未定义的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int* pia = new int[dynamic_size]; // 大小可以不是常量, 但必须是整数
typedef int arrT[42]; // arrT表示42个int的数组类型
int* p = new arrT; // 等价于 int* p = new int[42];
int* pia = new int[10]; // 10个未初始化的int
int* pia = new int[10](); // 10个值为0的int
string* psa = new string[10]; // 10个空字符串, 等价于 new string[10]();
int* pia = new int[10]{0, 1, 2, 3}; // 前4个列表初始化, 后面的为0
int* psa = new string[10]{"a", "the", string(10, 'x')};

char arr[0]; // 错误: 不能定义长度为0的数组
char* cp = new char[0]; // 正确: 非空指针, 但cp指向的位置无效

delete[] pia; // 释放动态数组时必须加上方括号, 如果在释放数组时忘记加上方括号, 其行为是未定义的

智能指针管理动态数组

unique_ptr可以管理动态数组, 但是模板类型必须为带方括号的数组类型

1
2
3
4
5
6
unique_ptr<int[]> up(new int[10]()); // up指向10个值为0的int
for (size_t i = 0; i < 10; ++i) {
  up[i] = i + 1;
}
up.reset(); // 自动调用delete[]释放数组, up置空
// *(up + 3) = 9; // 错误: 智能指针不支持指针算术运算

shared_ptr与unique_ptr不同, 它不直接支持动态数组管理, 若希望shared_ptr管理动态数组, 必须提供自定义的删除器

1
2
3
// shared_ptr管理动态数组必须提供自定义删除器
shared_ptr<int> sp(new int[10](), [](int* p) { delete[] p; });
sp.reset(); // 使用我们提供的lambda释放数组, 使用delete[]

shared_ptr不直接支持动态数组, 也未定义下标运算符, 也不支持指针算术运算

若需要动态访问数组内容, 可以使用get临时获得原始指针访问

内存耗尽

1
2
int *p1 = new int; // 若分配失败, new抛出std::bad_alloc
int *p2 = new (nothrow) int; // 若分配失败, new返回空指针

第二种形式的new被称为定位new, 定位new表达式允许传递额外的参数, nothrow是一个由标准库定义的对象, bad_alloc和nothrow都定义在头文件new中

allocator 更灵活的内存管理

new/delete与allocator

new: 内存分配 + 对象构造, 组合在一起

delete: 对象析构 + 内存释放, 组合在一起

分配单个对象时, new/delete比较方便, 但是分配一大块内存, 并需要按需构造对象时, 不方便

而且没有默认构造函数的类就不能new动态分配数组

allocator类

allocator是定义在memory头文件中的模板类, 它帮助我们将内存分配和对象构造分离开

1
2
3
4
5
6
7
8
allocator<T> a; // 定义一个allocator对象, 可以为类型为T的对象分配内存
a.allocate(n); // 分配一段原始的,未构造的内存, 用于保存n个T类型的对象
a.construct(p, args); // p必须是一个T*指针, 指向一块原始内存, args被传递给T的构造函数, 在p指向的内存上构造对象
a.destroy(p); // p为T*指针, 此算法为p所指向的对象执行析构函数

// 释放从T*指针p开始的内存, 这块内存包含n个T类型对象; p必须指向allocator分配的内存,n必须是创建内存时指定的大小
// 若p指向的内存上的对象已经构造过, 那么必须对应调用destroy进行析构后才可以释放内存
a.deallocate(p, n);

使用未初始化的内存是未定义行为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
allocator<string> alloc;
auto const p = alloc.allocate(n);
auto q = p; // 指向最后构造的元素之后的位置
alloc.construct(q++); // 构造空字符串
alloc.construct(q++, 10, 'c'); // 构造10个c组成的字符串
alloc.construct(q++, "hi"); // 构造指定初始化的字符串

cout << *p << endl; // 正确
// cout << *q << endl; // 错误: 使用未初始化的内存是未定义的

while (q != p) {
  alloc.destroy(--q); // 析构真正构造的string
}

// 析构完成后可以释放内存
alloc.deallocate(p, n);

memory头文件中提供了allocator的两个伴随算法, 可在未初始化内存中创建对象

1
2
3
4
5
6
7
8
uninitialized_copy(b, e, p); // 从迭代器b和e指出的输入范围中,拷贝元素到p指定的原始内存中,内存容量需要足够大
uninitialized_copy_n(b, n, p); // 从迭代器b开始拷贝n个元素到p指定的原始内存中
uninitialized_fill(b, e, t); // 在迭代器b和e指出的原始内存范围内创建对象,对象值均为t的拷贝
uninitialized_fill_n(b, n, t); // 从迭代器b指向的原始内存开始创建n个对象,对象值均为t的拷贝

auto p = alloc.allocate(vi.size() * 2); // 分配比vi大一倍的内存空间
auto q = unitialized_copy(vi.begin(), vi.end(), p); // 从p开始将vi中的对象拷贝到内存中,q指向下一块未初始化的内存
uninitialized_fill_n(q, vi.size(), 42); // 将剩余一半元素初始化为42

IO

头文件istream, ostream, iostream, sstream

  • cin 一个istream对象, 从标准输入读取数据
  • cout 一个ostream对象, 向标准输出写入数据
  • cerr 一个ostream对象, 用于输出程序错误消息, 写入到标 准错误
  • getline 函数, 从一个istream对象读取一行数据, 存入指定的string对象中
  • stringstream 内存流

IO对象无拷贝无赋值, 不支持对应操作

条件状态

1
2
3
4
5
6
7
8
9
s.eof(); // 若流s的eofbit置位,返回true,表示达到文件结束
s.fail(); // 若流s的failbit或badbit置位,返回true,表示有IO操作失败或者流已崩溃
s.bad(); // 若流s的badbit置位,返回true,表示流已崩溃
s.good(); // 若流s处于有效状态,返回true
s.clear(); // 将s中所有条件状态复位,流状态设置为有效

s.clear(flags); // 复位对应标志位,flas类型为strm::iostate,strm为IO类型
s.setstate(flags); // 设置对应标志位
s.rdstate(); // 返回s当前状态位,类型为strm::iostate

不同条件位含义

1
2
3
4
strm::badbit // 系统级错误,不可恢复的错误,一般badbit置位后流无法再使用了
strm::failbit // 通常可修正,比如期望数字却读取到字母,发生读取错误,但流还可以继续使用
strm::eofbit // 若到达文件结束位置,eofbit和failbit都会被置位
strm::goodbit // 值为0表示流未发生错误,若badbit/failbit/eofbit任意一个被置位,则状态条件失败

输出缓冲

每个输出流都有一个缓冲区, 用于保存程序写入的数据, 操作系统可以通过缓冲机制将程序多个输出操作组合成单一的系统级写操作, 提升性能

我们可以通过操纵符比如endl来显式刷新缓冲区

1
2
3
4
5
6
cout << "hi" << endl; // 输出hi和换行符,刷新缓冲区
cout << "hi" << flush; // 输出hi,刷新缓冲区
cout << "hi" << ends; // 输出hi和一个空字符,刷新缓冲区

cout << unitbuf; // 所有输出操作后立即刷新,无缓冲
cout << nounitbuf; // 恢复正常缓冲模式

当缓冲打开时, 若程序崩溃, 缓冲区不会被刷新, 输出可能丢失

文件输入输出

ifstream/ofstream基本用法

1
2
3
4
5
6
7
8
9
ifstream in(ifile); // 构建一个ifstream并打开指定文件
ofstream out; // 文件流未与任何文件关联
out.open(ifile + ".copy"); // 打开文件

if (out) { // open可能失败,失败时failbit会置位,检查open是否成功是好习惯
}

in.close(); // 关闭文件,关闭关联文件后才可以打开新文件
in.open(iflle + "2"); // 打开新文件,一个流只能关联到一个打开的文件,若尝试重复打开,则failbit置位,不再可用

文件模式(file mode)

1
2
3
4
5
6
in // 以读方式打开
out // 以写方式打开
app // 每次写操作前均定位到文件末尾
ate // 打开文件后立即定位到文件末尾
trunc // 截断文件,写文件时每次重新创建文件,不保留原本内容
binary // 以二进制进行IO

文件模式指定有以下限制

  • 只可以对ofstream或fstream设定out模式
  • 只可以对ifstream或fstream设定in模式
  • 只有out模式被设定时才可以设定trunc模式
  • 只要trunc模式没有设定, 就可以设定app模式, 在app模式下即使没有显式设定out模式, 文件也总是以输出模式打开
  • 默认情况下, 即使没设定trunc, 以out模式打开的文件也会被截断, 为保留文件内容, 必须同时指定app
  • ate和binary可用于任何类型文件流, 可与其他任意模式组合使用

不同类型文件流都有默认模式, 未显式指定时, 则使用默认模式

  • ifstream关联文件默认以in模式打开
  • ofstream关联文件默认以out模式打开, 默认会丢弃已有数据
  • fstream关联文件默认以in和out模式打开
1
2
3
4
5
6
7
// file1都会被截断
ofstream file1("file1"); // 隐含out模式,并截断
ofstream file1("file1", ofstream::out); // 隐含截断
ofstream file1("file1", ofstream::out | ofstream::trunc); // 显式截断
// file2都不会截断
ofstream file2("file2", ofstream::app); // 隐含out模式
ofstream file2("file2", ofstream::out | ofstream::app);

string流

istringstream从string读数据, ostringstream向string写数据, stringstream可以从string读写数据

1
2
3
4
5
6
7
sstream strm; // sstream为某一种string流类型,构造一个未绑定的string流
sstream strm(s); // strm保存string s的拷贝,此构造函数为explicit
strm.str(); // 返回strm所保存的string拷贝
strm.str(s); // 将string s拷贝到strm中

strm >> persion.name; // 流输出
strm << " " << persion.age; // 流写入

一次读取所有文件内容

针对小文件一次读取所有文件内容

方法1: 文件缓冲流读取

1
2
3
4
5
6
std::ifstream ifile(path);
if (ifile.is_open()) {
  std::stringstream ss;
  ss << ifile.rdbuf(); // 不断从缓冲流中读取数据输入ss流, 直到遇到EOF
  std::cout << ss.str() << std::endl;
}

方法2: 流迭代器 (非常现代C++的方式)

1
2
std::ifstream iflie(path);
std::string content(std::istreambuf_iterator<char>(ifile), std::istreambuf_iterator<char>());

泛型算法

lambda表达式

1
2
3
4
5
auto f1 = [](const string& msg) {
  cout << msg << endl;
};
// 获取lambda表达式类型
decltype(f1)

lambda的捕获

捕获主要决定了 Lambda 内部如何访问外部作用域中的变量

变量捕获是在lambda对象创建时就完成的, 如果是值捕获, 那么其拷贝的副本就是创建lambda对象时捕获的值

  1. 值捕获: [v]​ 或 [=]​(全捕获)外部变量的值会被拷贝一份到 Lambda 对象中

    lambda默认不能修改捕获的副本, 除非加上mutable关键字; Lambda 内部修改变量不会影响外部, 但是会影响下次次的lambda调用

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
      int val = 30;
      auto f = [val]() mutable {
        std::cout << "lambda: " << val << std::endl;
        val = 99;
        std::cout << "lambda: " << val << std::endl;
      };
      std::cout << "main: " << val << std::endl; // 30
      f(); // 30 99
      val = 111;
      std::cout << "main: " << val << std::endl; // 111
      f(); // 99 99
      std::cout << "main: " << val << std::endl; // 111
    
  2. 引用捕获: [&v]​ 或 [&](全捕获)外部变量改变时, Lambda 内部看到的是最新值;内部修改也会直接反映到外部

    使用引用捕获时一定要注意: lambda对象的生命周期必须不超过捕获的局部变量

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
      int val = 30;
      auto f = [&val]() mutable {
        std::cout << "lambda: " << val << std::endl;
        val += 1;
        std::cout << "lambda: " << val << std::endl;
      };
      std::cout << "main: " << val << std::endl; // 30
      f(); // 30 31
      std::cout << "main: " << val << std::endl; // 31
      val = 111;
      std::cout << "main: " << val << std::endl; // 111
      f(); // 111 112
    
  3. 混合捕获: 指定默认的全捕获方式, 对特例变量特别处理

    1
    2
    3
    4
    5
    
    int a = 1, b = 2;
    // 默认引用捕获, 但 a 使用值捕获
    auto lambda = [&, a]() {
        // b 可以修改, a 不可以
    };
    
  4. this捕获: [this] ​捕获当前对象的指针 在类成员函数中使用 Lambda 时, 需要捕获 this 指针才能访问成员变量或成员函数

  5. 初始化捕获(C++14): 允许在捕获列表中定义新变量, 甚至可以使用 std::move​, 对于只能移动的对象(std::unique_ptr)非常有用

    1
    2
    3
    4
    5
    
    auto ptr = std::make_unique<int>(100);
    // 将外部 ptr 移动到 Lambda 内部的新变量 p 中
    auto lambda = [p = std::move(ptr)]() {
        std::cout << *p;
    };
    

algorithm & numeric

大多数算法定义在algorithm头文件中, numeric头文件中还定义了一些数值泛型算法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// find 查找std::vector<int>中的值
auto result = find(vec.cbegin(), vec.cend(), 42);
if (result == vec.cend()) // 未找到

// string list查找
auto result = find(lst.cbegin(), lst.cend(), std::string("a value"));

// 数组查找
int ia[] = {27, 210, 12, 47, 109, 83};
int val = 83;
int* result = find(begin(ia), end(ia), val); // 使用标准库中的begin和end获得数组的首元素指针和尾后指针(迭代器)
int* result = find(ia + 1, ia + 4, val); // 在ia[1]到ia[4]之间查找, 包括ia[1], 不包括ia[4]

只读算法

accumulate

1
2
3
4
5
6
7
// numeric中提供accumulate,用于求和
int sum = accumulate(vec.cbegin(), vec.cend(), 0); // 求和,初始值为0
string sum = accumulate(v.cbegin(), vec.cend(), string("")); // 第三个参数必须为string, 不能是const char*, 因为const char*没有对应的+运算符

// 只要可以用==来比较元素即可,roster1可以是vector<string>,roster2可以是list<const char*>
// equal假定第二个序列至少与第一个序列一样长,否测产生错误
bool result = equal(roster1.cbegin(), roster1.cend(), roster2.cbegin());

写容器元素

fill

1
2
3
fill(vec.begin(), vec.end(), 0); // 将vec所有元素填充为0
fill(vec.begin(), vec.begin() + vec.size() / 2, 10); // 将容器一个子序列填充为10
fill_n(vec.begin(), n, val); // 从vec.begin()开始,将n个元素设置为val,fill_n假定写入的元素是安全的

千万不要在空容器上调用fill_n, 函数会假定空间是安全的, 否则是未定义行为

可以使用insert iterator, 比如back_inserter, 通过迭代器向元素赋值, 元素将被添加到容器中

1
2
3
4
5
6
7
vector<int> vec; // 空容器
auto it = back_inserter(vec);
*it = 42; // 元素将被插入到容器中
*it = 33; // 元素继续插入到容器

vector<int> vec; // 空容器
fill_n(back_inserter(vec), 10, 0); // 添加10个元素到vec

copy

1
2
3
4
int a1[] = {0, 1, 2, 3, 4, 5};
int a2[sizeof(a1)/sizeof(*a1)]; // a2大小与a1一致
// ret指向拷贝到a2尾元素之后的位置
auto ret = copy(begin(a1), end(a1), a2); // 把a1的内容拷贝给a2

replace

1
2
replace(ilst.begin(), ilst.end(), 0, 33); //将ilst中所有的0元素修改为33
replace_copy(ilst.cbegin(), ilst.cend(), back_inserter(ivec), 0, 33); // ilst元素保持不变,替换后的序列填充到ivec

重排容器元素

1
2
3
4
5
6
sort(words.begin(), words.end()); // 按字典序排序words中的单词, 以便查找重复单词
// unique 重排输入范围,使得每个单词只出现一次,调用unique之前需要保证有序
// 不重复范围在前,返回指向不重复区域之后一个位置的迭代器
auto end_unique = unique(words.begin(), words.end());
// 删除重复单词
words.erase(end_unique, words.end());

算法在比较元素时默认使用<​和==​运算符, sort使用元素类型默认的<运算符, 比较运算可自定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
bool is_shorter(const string& s1, const string& s2) {
  return s1.size() < s2.size();
}

// 按照字符串长度由短到长排序
sort(words.begin(), words.end(), is_shorter);

// 按照长度由短到长排序,长度相同的字符串保持原有顺序
stable_sort(words.begin(), words.end(), is_shorter);

// 也可以使用lambda表达式
stable_sort(words.begin(), words.end(), [](const string& s1, const string& s2) { return a.size() < b.size() });

查找与遍历

find_if

1
auto wc = find_if(words.begin(), words.end(), [sz](const string& a) { return a.size() >= sz; });

可变参数模板

可变参数函数模板

1
2
3
4
5
6
7
8
// Args是一个模板参数包; rest是一个函数参数包
// Args表示0个或者多个模板类型参数
// rest表示0个或者多个函数参数
template<typename T, typename... Args>
void foo(const T& t, const Args& ... rest);

foo("hi"); // 空包
foo(s, 42, "hi"); // 包中包含2个参数

编译器从函数的实参推断模板参数类型和数量, 之后为模板函数实例化多个不同的版本 (不同参数类型数量组合)

sizeof...运算符获取包中的元素个数

1
2
3
4
template<typename ... Args> void g(Args ... args) {
  cout << sizeof...(Args) << endl; // 类型参数数目
  cout << sizeof...(args) << endl; // 函数参数数目
}

可变参函数的递归

可变参函数通常是递归的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 用来终止递归并打印最后一个元素
// 此函数必须在可变参数版本之前定义
template<typename T>
ostream& print(ostream& os, const T& t) {
  return os << t << endl;
}
// 包中除了最后一个元素外的其他元素都会调用可变参数版本
template<typename T, typename ... Args>
ostream& print(ostream& os, const T& t, const Args& ... rest) {
  os << t << ", "; // 打印第一个实参
  return print(os, rest...); // 递归调用, 打印其他实参
}

print(std::cout, 1, 2, 3, "hi");

编译器会自动实现print的多个变种版本

包扩展

1
2
3
4
5
6
7
8
9
template<typename ... Args>
ostream& error_msg(ostream& os, const Args& ... rest) {
  // 相当于对包中每个参数调用debug_rep之后作为新的参数传入print中
  // print(os, debug_rep(rest1), debug_rep(rest2), debug_rep(rest3));
  return print(os, debug_rep(rest)...);
}

// 如果写成 debug_rep(rest...)
// 则相当于调用 debug_rep(rest1, rest2, rest3)

包参数转发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class StrVec {
public:
  template<typename ... Args> void emplace_back(Args&&...);
};

template<typename ... Args>
void StrVec::emplace_back(Args&& ... args) {
  // 使用allocator处理内存空间分配
  alloc.construct(first_free++, std::forward<Args>(args)...);
}

这里的传入参数是&&​万能引用, 为了保证右值向下传递时保持右值, 我们使用std::forward进行转发

std::forward<Args>(args)...会同时对Args和args进行扩展, 得到类似如下的形式

1
alloc.construct(first_free++, std::forward<int>(10), std::forward<std::string>(s));

总结

本文梳理了C++11现代化编程的部分内容, 这是我自己觉得重要的基础内容, 弄懂了上面这些内容再去查看STL中的容器, 或者了解智能指针的行为, 就相对简单很多了

关于容器, 智能指针, 泛型算法等, 文中没有全面说明的内容, 都可以去查阅对应的文档 (https://cppreference.com)

C++11还新增了线程std::thread, 时间std::chrono, 正则std::regex, 迭代器, 随机数等内容, 这篇文章尚未提及, 都是非常有用的工具

Licensed under CC BY-NC-SA 4.0
hugo + stack 构建
使用 Hugo 构建
主题 StackJimmy 设计