文章目录

  • 1.C++发展史
  • 2.C++面向对象的思想
  • 3.RAII:离不开构造函数
  • 4.使用 {} 和 () 调用构造函数,有什么区别?
  • 5.编译器默认生成的构造函数:无参数(POD 陷阱!)
  • 6.编译器默认生成的构造函数:拷贝构造函数
  • 7.编译器默认生成的特殊函数:拷贝赋值函数
  • 8.编译器自动生成的函数
  • 9.编写我们自己的 vector 类
  • 10.C++11区分拷贝和移动?
  • (1)触发“移动”的情况
  • (2)移动构造函数:缺省实现
  • 13.智能指针
  • (1)RAII 解决内存管理的问题:unique_ptr
  • (2)更智能的指针:shared_ptr
  • (3)智能指针:作为类的成员变量
  • (4)那是不是只要 shared_ptr 就行,不用 unique_ptr 了?
  • 14.三五法则:什么时候需要担心
  • (1)类型安全vs类型不安全
  • (2)成员都是安全的类型:五大函数,一个也不用声明
  • (3)函数参数类型优化规则:按引用还是按值?
  • (4)如何避免不经意的隐式拷贝
  • 15.扩展
  • 19.为什么很多面向对象语言,比如 Java,都没有构造函数全家桶这些概念?

1.C++发展史

  • 问题:求一个列表中所有数的和:
  • eg:course/02/01
a = [4, 3, 2, 1]
print(sum(a))
  • 古代C语言
#include <stdlib.h>
#include <stdio.h>

int main() {
    size_t nv = 4;
    int *v = (int *)malloc(nv * sizeof(int));
    v[0] = 4;
    v[1] = 3;
    v[2] = 2;
    v[3] = 1;

    int sum = 0;
    for (size_t i = 0; i < nv; i++) {
        sum += v[i];
    }

    printf("%d\n", sum);

    free(v);
    return 0;
}
  • 近代:C++98 引入 STL 容器库
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v(4);
    v[0] = 4;
    v[1] = 3;
    v[2] = 2;
    v[3] = 1;

    int sum = 0;
    for (size_t i = 0; i < v.size(); i++) {
        sum += v[i];
    }

    std::cout << sum << std::endl;
    return 0;
}
  • 近现代:C++11 引入了 {} 初始化表达式
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {4, 3, 2, 1};

    int sum = 0;
    for (size_t i = 0; i < v.size(); i++) {
        sum += v[i];
    }

    std::cout << sum << std::endl;
    return 0;
}
  • 近现代:C++11 引入了 range-based for-loop
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {4, 3, 2, 1};

    int sum = 0;
    for (int vi: v) {
        sum += vi;
    }

    std::cout << sum << std::endl;
    return 0;
}
  • 使用 for_each 这个算法模板
#include <vector>
#include <iostream>
#include <algorithm>

int sum = 0;

void func(int vi) {
    sum += vi;
}

int main() {
    std::vector<int> v = {4, 3, 2, 1};

    std::for_each(v.begin(), v.end(), func);

    std::cout << sum << std::endl;
    return 0;
}
  • 近现代:C++11 引入了 lambda 表达式
#include <vector>
#include <iostream>
#include <algorithm>

int main() {
    std::vector<int> v = {4, 3, 2, 1};

    int sum = 0;
    std::for_each(v.begin(), v.end(), [&] (auto vi) {
        sum += vi;
    });

    std::cout << sum << std::endl;
    return 0;
}
  • 现代:C++14 的 lambda 允许用 auto 自动推断类型
#include <vector>
#include <iostream>
#include <algorithm>

int main() {
    std::vector v = {4, 3, 2, 1};

    int sum = 0;
    std::for_each(v.begin(), v.end(), [&] (auto vi) {
        sum += vi;
    });

    std::cout << sum << std::endl;
    return 0;
}
  • 当代:C++17 CTAD / compile-time argument deduction / 编译期参数推断
#include <vector>
#include <iostream>
#include <algorithm>

int main() {
	//这里没有<int>
    std::vector v = {4, 3, 2, 1};

    int sum = 0;
    std::for_each(v.begin(), v.end(), [&] (auto vi) {
        sum += vi;
    });

    std::cout << sum << std::endl;
    return 0;
}

对应的CmakeLists.txt

cmake_minimum_required(VERSION 3.12)
set(CMAKE_CXX_STANDARD 17)
project(hellocpp LANGUAGES CXX)

add_executable(cpptest 7.cpp)
  • 当代:C++17 引入常用数值算法
#include <vector>
#include <iostream>
#include <numeric>

int main() {
    std::vector v = {4, 3, 2, 1};

    auto sum = std::reduce(v.begin(), v.end());
    auto sum = std::reduce(v.begin(), v.end(), std::plus{});
    int sum = std::reduce(v.begin(), v.end(), 0, [] (int x, int y) {
        return x + y;
    });

    std::cout << sum << std::endl;
    return 0;
}
  • 未来:C++20 引入区间(ranges)
#include <vector>
#include <iostream>
#include <numeric>
#include <ranges>
#include <cmath>

int main() {
    std::vector v = {4, 3, 2, 1, 0, -1, -2};

    for (auto &&vi: v
         | std::views::filter([] (auto &&x) { return x >= 0; })
         | std::views::transform([] (auto &&x) { return sqrtf(x); })
         ) {
        std::cout << vi << std::endl;
    }

    return 0;
}
  • 未来:C++20 引入模块(module)
import <vector>;
import <iostream>;
import <numeric>;
import <ranges>;
import <cmath>;

int main() {
    std::vector v = {4, 3, 2, 1, 0, -1, -2};

    for (auto &&vi: v
         | std::views::filter([] (auto &&x) { return x >= 0; })
         | std::views::transform([] (auto &&x) { return sqrtf(x); })
         ) {
        std::cout << vi << std::endl;
    }

    return 0;
}
  • 未来:C++20 允许函数参数为自动推断(auto)
import <vector>;
import <iostream>;
import <numeric>;
import <ranges>;
import <cmath>;

void myfunc(auto &&v) {
    for (auto &&vi: v
         | std::views::filter([] (auto &&x) { return x >= 0; })
         | std::views::transform([] (auto &&x) { return sqrtf(x); })
         ) {
        std::cout << vi << std::endl;
    }
}

int main() {
    std::vector v = {4, 3, 2, 1, 0, -1, -2};
    myfunc(v);
    return 0;
}
  • 未来:C++20 引入协程(coroutine)和生成器(generator)
import <vector>;
import <iostream>;
import <numeric>;
import <ranges>;
import <cmath>;
import <generator>;
import <format>;

std::generator<int> myfunc(auto &&v) {
    for (auto &&vi: v
         | std::views::filter([] (auto &&x) { return x >= 0; })
         | std::views::transform([] (auto &&x) { return sqrtf(x); })
         ) {
        co_yield vi;
    }
}

int main() {
    std::vector v = {4, 3, 2, 1, 0, -1, -2};
    for (auto &&vi: myfunc(v)) {
        std::format_to(std::cout, "number is {}\n", vi);
    }
    return 0;
}
  • 未来:C++20 标准库加入 format 支持
import <vector>;
import <iostream>;
import <numeric>;
import <ranges>;
import <cmath>;
import <generator>;
import <format>;

std::generator<int> myfunc(auto &&v) {
    for (auto &&vi: v
         | std::views::filter([] (auto &&x) { return x >= 0; })
         | std::views::transform([] (auto &&x) { return sqrtf(x); })
         ) {
        co_yield vi;
    }
}

int main() {
    std::vector v = {4, 3, 2, 1, 0, -1, -2};
    for (auto &&vi: myfunc(v)) {
        std::format_to(std::cout, "number is {}\n", vi);
    }
    return 0;
}

总结

  • 基于 C++17 标准, C++20 作为扩展。

2.C++面向对象的思想

(1)C++思想:封装

  • 将多个逻辑上相关的变量包装成一个类
  • eg:比如要表达一个数组,需要:起始地址指针v,数组大小nv,因此 C++ 的 vector 将他俩打包起来,避免程序员犯错

(2)封装:不变性

  • 比如当我要设置数组大小为 4 时,不能只 nv = 4,还要重新分配数组内存,从而修改数组起始地址 v
  • 常遇到:当需要修改一个成员时,其他也成员需要被修改,否则出错,这种情况出现时,就意味着你需要把成员变量的读写封装为成员函数

(3)不变性:请勿滥用封装

  • 仅当出现“修改一个成员时,其他也成员要被修改,否则出错”的现象时,才需要getter/setter 封装。
  • 各个成员之间相互正交,比如数学矢量类 Vec3,就没必要去搞封装,只会让程序员变得痛苦,同时还有一定性能损失:特别是如果 getter/setter 函数分离了声明和定义,实现在另一个文件时!

(4)C++思想:RAII(Resource Acquisition Is Initialization)

  • 资源获取视为初始化,反之,资源释放视为销毁
  • C++ 除了用于初始化的构造函数(constructor)
    还包括了用于销毁的解构函数(destructor)

RAII的作用:避免犯错误

  • 如果没有解构函数,则每个带有返回的分支都要手动释放所有之前的资源:
  • RAII内存管理_#include

  • 与 Java,Python 等垃圾回收语言不同,C++ 的解构函数是显式的,离开作用域自动销毁,毫不含糊(有好处也有坏处,对高性能计算而言利大于弊)
  • RAII内存管理_Pig_02

RAII作用:异常安全(exception-safe)

  • C++ 标准保证当异常发生时,会调用已创建对象的解构函数。因此 C++ 中没有(也不需要) finally 语句。
  • eg:如果此处不关闭,则可等待稍后垃圾回收时关闭。
    虽然最后还是关了,但如果对时序有要求或对性能有要求就不能依靠 GC。
    比如 mutex 忘记 unlock 造成死锁等等……
  • eg:产生异常的同时,文件也会被关闭,所以文件内容正常写出来了,所以cpp没有finally

3.RAII:离不开构造函数

(1)自定义构造函数:无参数

  • eg:course/02/03a/0.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;

    Pig()
    {
        m_name = "佩奇";
        m_weight = 80;
    }
};

int main() {
    Pig pig;

    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;

    return 0;
}

(2)自定义构造函数:无参数(使用初始化表达式

  • 为什么需要初始化表达式
1. 假如类成员为 const 和引用
2. 假如类成员没有无参构造函数
3. 避免重复初始化,更高效
  • eg:course/02/03a/1.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;

    Pig() : m_name("佩奇"), m_weight(80)
    {}
};

int main() {
    Pig pig;

    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;

    return 0;
}

(3)自定义构造函数:多个参数

  • eg:course/02/03a/2.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;

    Pig(std::string name, int weight) : m_name(name), m_weight(weight)
    {}
};

int main() {
    Pig pig("佩奇", 80);

    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;

    return 0;
}

(4)自定义构造函数:单个参数

  • eg:course/02/03a/3.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;

    Pig(int weight)
        : m_name("一只重达" + std::to_string(weight) + "kg的猪")
        , m_weight(weight)
    {}
};

int main() {
    Pig pig(80);

    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;

    return 0;
}

自定义构造函数:单个参数(陷阱)

  • eg:course/02/03a/4.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;

    Pig(int weight)
        : m_name("一只重达" + std::to_string(weight) + "kg的猪")
        , m_weight(weight)
    {}
};

int main() {
    Pig pig = 80;

    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;

    return 0;
}

自定义构造函数:单个参数(避免陷阱)

  • eg:course/02/03a/5.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;

    explicit Pig(int weight)
        : m_name("一只重达" + std::to_string(weight) + "kg的猪")
        , m_weight(weight)
    {}
};

int main() {
    // Pig pig = 80;  // 编译错误
    Pig pig(80);      // 编译通过

    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;

    return 0;
}
  • 测试:
  • eg:my_course/course/02/03a/6.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;

    explicit Pig(int weight)
        : m_name("一只重达" + std::to_string(weight) + "kg的猪")
        , m_weight(weight)
    {}
};

void show(Pig pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    // show(80);    // 编译错误
    show(Pig(80));  // 编译通过

    return 0;
}

避免陷阱体现在哪里?

  • 加了 explicit 表示必须用 () 强制转换(就是强制初始化)。否则 show(80) 也能编译通过!
  • 所以,如果你不希望这种隐式转换(就是隐式构造,也是一个构造函数,虽然等价于Pig pig(80),但是编译器认为类型不一样,看上面截图):Pig pig = 80;,请给单参数的构造函数加上explicit。
    比如 std::vector 的构造函数 vector(size_t n) 也是 explicit 的。
std::vector<int> a=100//error
std::vector<int> a{100};//ok
std::vector<int> a(100);//ok

explicit 对多个参数也起作用!

  • 多个参数时,explicit 的作用体现在禁止从一个 {} 表达式初始化。={}也是隐式构造
  • Pig pig2{“佩奇”,80}; Pig pig3(“佩奇”,80);都是显式构造
  • 如果你希望在一个返回 Pig 的函数里用:
return {“佩奇”, 80};的话,就不要加 explicit。
顺便一提,上一个例子中 show(80) 和 show({80}) 等价。调用时等价于:Pig pig=80;Pig pig={80};

RAII内存管理_c++_03

4.使用 {} 和 () 调用构造函数,有什么区别?

  • int(3.14f) 不会出错,但是 int{3.14f} 会出错,因为 {} 是非强制转换。(防止变窄转换)
  • Pig(“佩奇”, 3.14f) 不会出错,但是 Pig{“佩奇”, 3.14f} 会出错,原因同上,更安全。
  • 可读性:Pig(1, 2) 则 Pig 有可能是个函数,Pig{1, 2} 看起来更明确。

其实谷歌在其 Code Style 中也明确提出别再通过 () 调用构造函数,需要类型转换时应该用:

static_cast<int>(3.14f) 而不是 int(3.14f)
reinterpret_cast<void *>(0xb8000) 而不是 (void *)0xb8000。将地址直接转换为指针
更加明确用的哪一种类型转换(cast),从而避免一些像是 static_cast<int>(ptr) 的错误。

5.编译器默认生成的构造函数:无参数(POD 陷阱!)

除了我们自定义的构造函数外,编译器还会自动生成一些构造函数。

  • 当一个类没有定义任何构造函数,且所有成员都有无参构造函数时,编译器会自动生成一个无参构造函数 Pig(),他会调用每个成员的无参构造函数。
  • 但是请注意,这些类型不会被初始化为 0:
1)int, float, double 等基础类型
2)void *, Object * 等指针类型
3)完全由这些类型组成的类
  • 这些类型被称为 POD(plain-old-data)。
    POD 的存在是出于兼容性和性能的考虑。
Pig pig;会随机初始化
  • eg:course/02/03a/8.cpp,weight:<< 取决于内存的随机值
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;
};

void show(Pig pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig;

    show(pig);
    return 0;
}
  • 测试:

编译器默认生成的构造函数:无参数(POD 陷阱解决方案)

  • 不过我们可以手动指定初始化 weight 为0。
  • 方式1:通过 {} 语法指定的初始化值,会在编译器自动生成的构造函数里执行。
  • eg:my_course/course/02/03a/9.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight{0};
};

void show(Pig pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig;

    show(pig);
    return 0;
}
  • 方式2:通过 {} 语法指定的初始化值,不仅会在编译器自动生成的构造函数里执行,也会用户自定义构造函数里执行!
  • eg:my_course/course/02/03a/a.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight{0};

    Pig(std::string name) : m_name(name)
    {}
};

void show(Pig pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig("佩奇");

    show(pig);
    return 0;
}

编译器默认生成的构造函数:无参数(类成员初始化很方便)

  • 类成员的 {} 中还可以有多个参数,甚至能用 =。
  • 除了不能用 () 之外,和函数局部变量的定义方式基本等价。
顺便一提:
int x{};
void *p{};
与
int x{0};
void *p{nullptr};
等价,都会零初始化。但是你不写那个空括号就会变成内存中随机的值。
  • 再比如:std::cout << int{}; 会打印出 0
  • eg:my_course/course/02/03a/b.cpp
#include <iostream>
#include <string>

struct Demo {
    explicit Demo(std::string a, std::string b) {
        std::cout << "Demo(" << a << ',' << b << ')' << std::endl;
    }
};

struct Pig {
    std::string m_name{"佩奇"};
    int m_weight = 80;
    Demo m_demo{"Hello", "world"};        // 编译通过
    // Demo m_demo = {"Hello", "world"};  // 编译出错
};

void show(Pig pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig;

    show(pig);
    return 0;
}
  • 测试:

编译器默认生成的构造函数:初始化列表(感谢 C++11)

  • 当一个类(和他的基类)没有定义任何构造函数,这时编译器会自动生成一个参数个数和成员一样的构造函数。
  • 他会将 {} 内的内容,会按顺序赋值给对象的每一个成员。
  • 目的是为了方便程序员不必手写冗长的构造函数一个个赋值给成员。
  • 不过初始化列表的构造函数只支持通过 {} 或 = {} 来构造,不支持通过 () 构造。其实是为了向下兼容 C++98
  • eg:my_course/course/02/03a/c.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight;
};

void show(Pig pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig1 = {"佩奇", 80};   // 编译通过
    Pig pig2{"佩奇", 80};      // 编译通过
    Pig pig3("佩奇", 80);   // 编译错误!

    show(pig1);
    return 0;
}
  • 测试:

编译器默认生成的构造函数:初始化列表(初始化一部分,剩余的为默认值)

  • 这个编译器自动生成的初始化列表构造函数,除了可以指定全部成员来构造以外,还可以指定部分的成员,剩余没指定的保持默认。
  • 不过你得保证那个没指定的有在类成员定义里写明 {} 初始化,否则有可能会变成内存里的随机值。
  • 顺便一提,C++20中还可以通过指定名称来跳顺序:
  • eg:my_course/course/02/03a/d.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight{0};
};

void show(Pig pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig{"佩奇"};  // m_weight 没指定,使用 {0} 指定的默认值

    show(pig);
    return 0;
}
  • 测试:

编译器默认生成的构造函数:初始化列表(妙用,解决函数多返回值)

  • 没有explicit
  • 典型的例子包括,图形学某知名应用中,可以简化函数具有多个返回值的处理。
  • 和 std::tuple 相比,最大的好处是每个属性都有名字,不容易搞错。举个例子:
    auto [hit, pos, …] = intersect(…)
  • 每增加一个属性都要全部改一次代码。
  • 更加fancy的写法:

编译器默认生成的构造函数:初始化列表(妙用,处理函数的复杂类型参数)

  • 还有,函数的参数,如果是很复杂的类型,你不想把类型名重复写一遍,也可以利用 {} 初始化列表来简化:

有自定义构造函数时仍想用默认构造函数:= default

  • 一旦我们定义了自己的构造函数,编译器就不会再生成默认的无参构造函数。
  • 有自定义构造函数时仍想用默认构造函数:= default(续)

6.编译器默认生成的构造函数:拷贝构造函数

  • 除了无参和初始化列表构造函数外,编译器默认还会生成这样一个特殊的构造函数:
Pig(Pig const &other);
可见他的参数是一个 Pig 类型,他的功能就是拷贝 Pig 对象,故称为拷贝构造函数。
  • 调用方式如下:

拷贝构造函数:用户自定义

  • 除了编译器可以自动生成拷贝构造函数外,如果有需要,用户也可以自定义拷贝构造函数。
  • 比如:

不想要编译器自动生成拷贝构造函数怎么办:= delete

  • 如果想要让编译器不要自动生成拷贝构造函数,可以用 = delete 语法删除:
  • 注:= delete 和 = default 是一对。如果你不确定某个函数有没有被编译器默认生成,可以都用这两个显式地声明一下。

7.编译器默认生成的特殊函数:拷贝赋值函数

除了拷贝构造函数外,编译器默认还会生成这样一个重载’=’这个运算符的函数:

Pig &operator=(Pig const &other);
  • 拷贝构造函数的作用是在Pig尚未初始化时,将另一个Pig拷贝进来,以初始化当前Pig。
Pig pig = pig2;  // 拷贝构造
  • 拷贝赋值函数的作用是在Pig已经初始化时,将当前Pig销毁,同时将另一个Pig拷贝进来。
Pig pig;             // 无参构造
pig = pig2;        // 拷贝赋值
  • 追求性能时推荐用拷贝构造,因为可以避免一次无参构造,拷贝赋值是出于需要临时修改对象的灵活性需要。
    这个函数同样可以由 = delete 和 = default 控制是否默认生成。
  • 注:return *this 是为了支持连等号 a = b = c;

8.编译器自动生成的函数

除了拷贝构造和拷贝赋值,编译器会自动生成的特殊函数还有这些:

  • 这在其他面向对象语言中是看不到的
  • RAII内存管理_c++_04

  • eg:my_course/course/02/03a/g.cpp
#include <iostream>
#include <string>

struct Pig {
    std::string m_name;
    int m_weight{0};

    Pig(std::string name, int weight)
        : m_name(name), m_weight(weight)
    {}

    Pig()
    {}

    Pig(Pig const &other)
        : m_name(other.m_name)
        , m_weight(other.m_weight)
    {}

    Pig &operator=(Pig const &other) {
        m_name = other.m_name;
        m_weight = other.m_weight;
        return *this;
    }

    Pig(Pig &&other)
        : m_name(std::move(other.m_name))
        , m_weight(std::move(other.m_weight))
    {}

    Pig &operator=(Pig &&other) {
        m_name = std::move(other.m_name);
        m_weight = std::move(other.m_weight);
        return *this;
    }

    ~Pig() {}
};

void show(Pig pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig;

    show(pig);
    return 0;
}

解释:

  • 顺便一提,如果其中一个成员(比如m_name)不支持拷贝构造函数,那么 Pig 类的拷贝构造函数将不会被编译器自动生成
    其他函数同理。
  • 拷贝就把每个属性拷贝一遍;
  • 赋值就把每个属性赋值一遍;
  • 移动就把每个属性移动一遍;
  • C++除了智能指针,其他都是深拷贝,所以有的类删除拷贝构造函数,让智能指针管理这个类,so这类的拷贝就变成智能指针的浅拷贝了,目的是避免拷贝所需的时间

9.编写我们自己的 vector 类

  • eg:这个Vector类有哪些问题?
    my_course/course/02/04

三五法则:规则类怪谈

  • 如果一个类定义了解构函数,那么您必须同时定义或删除拷贝构造函数和拷贝赋值函数,否则出错。
  • 如果一个类定义了拷贝构造函数,那么您必须同时定义或删除拷贝赋值函数,否则出错,删除可导致低效。
  • 如果一个类定义了移动构造函数,那么您必须同时定义或删除移动赋值函数,否则出错,删除可导致低效。
  • 如果一个类定义了拷贝构造函数或拷贝赋值函数,那么您必须最好同时定义移动构造函数或移动赋值函数,否则低效。

(1)三五法则:拷贝构造函数

  • 在 = 时,默认是会拷贝的(编译器默认生成的是浅拷贝的,成员带指针的一定要注意!!)。
  • 比如右边这样,这样对我们当前 Vector 的实现造成一个很大的问题。其 m_data 指针是按地址值浅拷贝的,而不深拷贝其指向的数组!
  • 这就是说,在退出 main 函数作用域的时候,v1.m_data 会被释放两次!更危险的则是 v1 被解构而 v2 仍在被使用的情况。
  • 这就是为什么“如果一个类定义了解构函数,那么您必须同时定义或删除拷贝构造函数和拷贝赋值函数,否则出错。”
  • eg:my_course/course/02/05
  • RAII内存管理_c++_06


  • RAII内存管理_c++_07

  • 解决方案:要么删除
    最简单的办法是,直接禁止用户拷贝这个类的对象,在 C++11 中可以用 = delete 表示这个函数被删除,让编译器不要自动生成一个默认的(会导致指针浅拷贝的)拷贝构造函数了。
    这样就可以在编译期提前发现错误:
  • RAII内存管理_c++_08


  • RAII内存管理_Pig_09

解决方案:要么定义

  • 如果需要允许用户拷贝你的 Vector 类对象,我们还是需要实现一下的。
  • 发现了吗?其实不管是 size/resize 这样的 get/set 模式也好;自定义的拷贝构造函数也好;RAII 保证异常安全也好;都是在为面向对象思想的“封装:不变性”服务。
    即:保证任何单个操作前后,对象都是处于正确的状态,从而避免程序读到错误数据(如空悬指针)的情况。
  • eg:my_course/course/02/07

(2)三五法则:拷贝赋值函数

  • 编译器默认生成的拷贝赋值函数也是浅拷贝!
  • 区分两种拷贝可以提高性能。
int x = 1;   // 拷贝构造函数
x = 2;        // 拷贝赋值函数
  • 拷贝赋值函数≈解构函数+拷贝构造函数
    拷贝构造:直接未初始化的内存上构造 2
    拷贝赋值:先销毁现有的 1,再重新构造 2

    因此若对提高性能不感兴趣,可以这样写:my_course/course/02/08/main.cpp
  • RAII内存管理_Pig_10

  • 拷贝赋值函数:提高性能
    区分两种拷贝可以提高性能。
    内存的销毁重新分配可以通过realloc,从而就地利用当前现有的m_data,避免重新分配。
    因此拷贝赋值函数还是自定义下比较好:
    这解释了“如果一个类定义或删除了拷贝构造函数,那么您必须同时定义或删除拷贝赋值函数,否则出错。”
    eg:my_course/course/02/09/main.cpp
  • RAII内存管理_ios_11

10.C++11区分拷贝和移动?

有时候,我们需要把一个对象 v2 移动到 v1 上。而不需要涉及实际数据的拷贝。

  • 时间复杂度:移动是 O(1),拷贝是 O(n)。

拷贝赋值

  • v1=v2;是深拷贝,指向了不同的内存地址,v1里面的指针独立于v2,只是数据从v2跑到v1了,析构时是析构了2个不同的内存块,也没有双重free问题

我们可以用 std::move 实现移动。

  • v2 被移动到 v1 后,原来的 v2 会被清空,因此仅当 v2 再也用不到时才用移动
    (把v2指针移动到v1里,然后将v2指针清零,这样的话还是只有一个对象,就不会出现双重free了)
  • 拷贝应该是深的(浅拷贝会导致双重free),移动应该都是浅的;
  • eg:v2 的内容被移走,所以只剩0个元素了,my_course/course/02/10/main.cpp

移动进阶:交换两者的值

  • 除了 std::move 可以把 v2 移动到 v1 外,
  • 还可以通过 std::swap 交换 v1 和 v2。
  • swap 在高性能计算中可以用来实现双缓存(ping-pong buffer)。
  • eg:my_course/course/02/10/main.cpp
    swap 可能是这样实现的:

小结:

  • 善用移动和交换能让复杂度贬低

(1)触发“移动”的情况

  • 这些情况下编译器会调用移动
return v2                                                    // v2 作返回值,返回值优化(rvo优化)始终是移动语义
v1 = std::vector<int>(200)                         // 就地构造的 v2,当场创建的vector对象
v1 = std::move(v2)                                    // 显式地移动
  • 这些情况下编译器会调用拷贝
return std::as_const(v2)                            // 显式地拷贝,希望返回时调用拷贝
v1 = v2                                                      // 默认拷贝

注意,以下语句没有任何作用:

  • 这两个函数只是负责转换类型,实际产生移动/拷贝效果的是在类的构造/赋值函数里。
std::move(v2)                                            // 不会清空 v2,需要清空可以用 v2 = {} (调用默认构造函数和移动赋值函数)或 v2.clear()
std::as_const(v2)                                      // 不会拷贝 v2,需要拷贝可以用 { auto _ = v2; }
std::move(t)       相当于 (T &&)t
std::as_const(t) 相当于 (T const &)t

(2)移动构造函数:缺省实现

同样,如果对降低时间复杂度不感兴趣:

  • 移动构造≈拷贝构造+他解构+他默认构造
  • 移动赋值≈拷贝赋值+他解构+他默认构造
  • 只要不定义移动构造和移动赋值,编译器会自动这样做。虽然低效,但至少可以保证不出错。
    移动构造相当于我拷贝了了它,然后把它清零;
    如果不定义移动构造,编译器也会这么做;
    移动时,只有一个对象,所以可以使用浅移动
  • Eg:下图移动是浅移动,不用调用malloc,直接把大小和指针一过来就行,移过来后,把other的对象给清零

若自定义了移动构造,对提高性能不感兴趣:

  • 移动赋值≈解构+移动构造
  • eg:my_course/course/02/11/main.cpp
  • RAII内存管理_#include_12

  • 注:
    降低时间复杂度:O(n) >>> O(1)
    提高性能: O(1) >>> O(0.1)

小技巧:如果有移动赋值函数,可以删除拷贝赋值函数

  • 其实:如果你的类已经实现了移动赋值函数,那么为了省力你可以删除拷贝赋值函数。
这样当用户调用:
v2 = v1;时,因为拷贝赋值被删除,编译器会尝试:v2 = List(v1)

从而先调用拷贝构造函数,然后因为 List(v1) 相当于就地构造的对象,从而变成了移动语义,从而进一步调用移动赋值函数。

如果拷贝构造是explicit,则必须使用: v2 = List(v1)

RAII内存管理_#include_13

构造函数全家桶

  • eg:my_course/course/02/11/zongjie.cpp

13.智能指针

C++98:令人头疼的内存管理

  • 在没有智能指针的 C++ 中,我们只能手动去 new 和 delete 指针。这非常容易出错,一旦马虎的程序员忘记释放指针,就会导致内存泄露等情况,更可能被黑客利用空悬指针篡改系统内存从而盗取重要数据等。
  • eg:my_course/course/02/12/main.cpp
#include <cstdio>
#include <cstdlib>

struct C {
    C() {
        printf("分配内存!\n");
    }

    ~C() {
        printf("释放内存!\n");
    }
};

int main() {
    C *p = new C;

    if (rand() != 0) {
        printf("出了点小状况……\n");
        // delete p;   // 程序员粗心忘记释放指针
        return 1;
    }

    delete p;
    return 0;
}
  • 测试:
  • 编译
    my_course/course/02/12/run.sh
set -e

cmake -B build
cmake --build build
build/cpptest

my_course/course/02/12/CMakeLists.txt

cmake_minimum_required(VERSION 3.12)
set(CMAKE_CXX_STANDARD 17)
project(hellocpp LANGUAGES CXX)

add_executable(cpptest main.cpp)

(1)RAII 解决内存管理的问题:unique_ptr

  • 因此,C++11 引入了 unique_ptr 容器,他的解构函数中会调用 delete p,因此不会有马虎犯错的问题。
  • 这里 make_unique(…) 可以理解为和之前的 new C(…) 等价,括号里也可以有其他构造函数的参数。
  • eg:my_course/course/02/13/main.cpp
#include <cstdio>
#include <memory>

struct C {
    C() {
        printf("分配内存!\n");
    }

    ~C() {
        printf("释放内存!\n");
    }
};

int main() {
    std::unique_ptr<C> p = std::make_unique<C>();

    if (1 + 1 == 2) {
        printf("出了点小状况……\n");
        return 1;  // 自动释放 p
    }

    return 0;  // 自动释放 p
}

RAII内存管理_#include_14

unique_ptr:封装的智慧

  • 在旧时代 C++ 里,常常听到这样的说法:
“释放一个指针后,必须把这个指针设为 NULL,防止空悬指针!”
delete p;
p = nullptr;
  • unique_ptr 则把他们封装成一个操作:只需要
p = nullptr;      // 等价于:p.reset()
也不会保留着一个空悬指针,体现了面向对象“封装:不变性”的思想。
  • eg:my_course/course/02/14/main.cpp
#include <cstdio>
#include <memory>

struct C {
    C() {
        printf("分配内存!\n");
    }

    ~C() {
        printf("释放内存!\n");
    }
};

int main() {
    std::unique_ptr<C> p = std::make_unique<C>();

    printf("提前释放……\n");
    p = nullptr;
    printf("……释放成功\n");

    return 0;  // p 不会再释放一遍
}
  • 测试:

unique_ptr:禁止拷贝

  • unique_ptr 删除了拷贝构造函数。为什么他要删除拷贝构造函数?
  • 原因还是三五法则,如果拷贝了指针,那么就会出现之前 Vector 那样重复释放(double free)的问题。
  • 因为unique_ptr 自定义拷贝函数是delete的,所以它必须删除拷贝函数,否则拷贝就是一个浅拷贝,浅拷贝容易出现double free问题
  • eg:my_course/course/02/15/main.cpp
#include <cstdio>
#include <memory>

struct C {
    C() {
        printf("分配内存!\n");
    }

    ~C() {
        printf("释放内存!\n");
    }

    void do_something() {
        printf("成员函数!\n");
    }
};

void func(std::unique_ptr<C> p) {
    p->do_something();
}

int main() {
    std::unique_ptr<C> p = std::make_unique<C>();
    func(p);  // 出错
    return 0;
}
  • 测试:
  • RAII内存管理_ios_15

  • 解决方案1:获取原始指针(C * 这种类型的指针)
解决这个问题需要分两种情况讨论。
第一种是,你的 func() 实际上并不需要“夺走”资源的占有权(ownership)。
比如刚才这个例子,func() 只是调用了 p 的某个成员函数而已,并没有接过掌管对象生命周期的大权。
  • eg:my_course/course/02/16/main.cpp
#include <cstdio>
#include <memory>

struct C {
    C() {
        printf("分配内存!\n");
    }

    ~C() {
        printf("释放内存!\n");
    }

    void do_something() {
        printf("成员函数!\n");
    }
};

void func(C *p) {
    p->do_something();
}

int main() {
    std::unique_ptr<C> p = std::make_unique<C>();
    func(p.get());
    return 0;
}
  • 测试:
  • RAII内存管理_ios_16

  • 解决方案2:unique_ptr 不能拷贝,但可以移动
第二种是,你的 func() 需要“夺走”资源的占有权。
比如右边这个例子,func 把指针放到一个全局的列表里,
p 的生命周期将会变得和 objlist 一样长。因此需要接过掌管对象生命周期的大权。

请根据你的具体情况,决定要选用哪一种解决方案。
  • eg:my_course/course/02/17/main.cpp
#include <cstdio>
#include <memory>
#include <vector>

struct C {
    C() {
        printf("分配内存!\n");
    }

    ~C() {
        printf("释放内存!\n");
    }
};

std::vector<std::unique_ptr<C>> objlist;

void func(std::unique_ptr<C> p) {
    objlist.push_back(std::move(p));  // 进一步移动到 objlist
}

int main() {
    std::unique_ptr<C> p = std::make_unique<C>();
    printf("移交前:%p\n", p.get());  // 不为 null
    func(std::move(p));    // 通过移动构造函数,转移指针控制权
    printf("移交后:%p\n", p.get());  // null,因为移动会清除原对象
    return 0;
}
  • 测试:

移交控制权后仍希望访问到 p 指向的对象

  • 解决方案2中,有时候我们会遇到移交控制权后,仍希望访问到对象的需求。
  • 如果还是用 p 去访问的话,因为被移动构造函数转移了,p 已经变成空指针,从而出错。
  • eg:my_course/course/02/18/main.cpp
#include <cstdio>
#include <memory>
#include <vector>

struct C {
    int m_number;

    C() {
        printf("分配内存!\n");
        m_number = 42;
    }

    ~C() {
        printf("释放内存!\n");
        m_number = -2333333;
    }

    void do_something() {
        printf("我的数字是 %d!\n", m_number);
    }
};

std::vector<std::unique_ptr<C>> objlist;

void func(std::unique_ptr<C> p) {
    objlist.push_back(std::move(p));  // 进一步移动到 objlist
}

int main() {
    std::unique_ptr<C> p = std::make_unique<C>();
    func(std::move(p));

    p->do_something();  // 出错,p 已经为空了!

    return 0;
}
  • 测试:
  • RAII内存管理_ios_17

  • 解决方案:提前获取原始指针
    最简单的办法是,在移交控制权给 func 前,提前通过 p.get() 获取原始指针:
  • eg:my_course/course/02/19/main.cpp
#include <cstdio>
#include <memory>
#include <vector>

struct C {
    int m_number;

    C() {
        printf("分配内存!\n");
        m_number = 42;
    }

    ~C() {
        printf("释放内存!\n");
        m_number = -2333333;
    }

    void do_something() {
        printf("我的数字是 %d!\n", m_number);
    }
};

std::vector<std::unique_ptr<C>> objlist;

void func(std::unique_ptr<C> p) {
    objlist.push_back(std::move(p));  // 进一步移动到 objlist
}

int main() {
    std::unique_ptr<C> p = std::make_unique<C>();

    C *raw_p = p.get();
    func(std::move(p));

    raw_p->do_something();  // 正常执行,raw_p 保留了转移前的指针

    return 0;
}
  • 测试:
  • 不过你得保证 raw_p 的存在时间不超过 p 的生命周期,否则会出现危险的空悬指针。比如右边这样:
  • eg:my_course/course/02/20/main.cpp
#include <cstdio>
#include <memory>
#include <vector>

struct C {
    int m_number;

    C() {
        printf("分配内存!\n");
        m_number = 42;
    }

    ~C() {
        printf("释放内存!\n");
        m_number = -2333333;
    }

    void do_something() {
        printf("我的数字是 %d!\n", m_number);
    }
};

std::vector<std::unique_ptr<C>> objlist;

void func(std::unique_ptr<C> p) {
    objlist.push_back(std::move(p));  // 进一步移动到 objlist
}

int main() {
    std::unique_ptr<C> p = std::make_unique<C>();

    C *raw_p = p.get();
    func(std::move(p));

    raw_p->do_something();  // 正常执行,raw_p 保留了转移前的指针

    objlist.clear();        // 刚刚 p 移交给 func 的生命周期结束了!

    raw_p->do_something();  // 错误!raw_p 指向的对象已经被释放!

    return 0;
}
  • 测试:

(2)更智能的指针:shared_ptr

  • 使用起来很困难的原因,在于 unique_ptr 解决重复释放的方式是禁止拷贝,这样虽然有效率高的优势,但导致使用困难,容易犯错等。
  • 相比之下, 牺牲效率换来自由度的 shared_ptr 则允许拷贝,他解决重复释放的方式是通过引用计数:
当一个 shared_ptr 初始化时,将计数器设为1。
当一个 shared_ptr 被拷贝时,计数器加1。
当一个 shared_ptr 被解构时,计数器减1。减到0时,则自动销毁他指向的对象。
  • 从而可以保证,只要还有存在哪怕一个指针指向该对象,就不会被解构。
  • eg:my_course/course/02/21/main.cpp
#include <cstdio>
#include <memory>
#include <vector>
#include <iostream>

struct C {
    int m_number;

    C() {
        printf("分配内存!\n");
        m_number = 42;
    }

    ~C() {
        printf("释放内存!\n");
        m_number = -2333333;
    }

    void do_something() {
        printf("我的数字是 %d!\n", m_number);
    }
};

std::vector<std::shared_ptr<C>> objlist;

void func(std::shared_ptr<C> p) {
    objlist.push_back(std::move(p));  // 这里用移动可以更高效,但不必须
}

int main() {
    std::shared_ptr<C> p = std::make_shared<C>(); // 引用计数初始化为1

    func(p);  // shared_ptr 允许拷贝!和当前指针共享所有权,引用计数加1
    func(p);  // 多次也没问题~ 多个 shared_ptr 会共享所有权,引用计数加1

    p->do_something();  // 正常执行,p 指向的地址本来就没有改变

    objlist.clear();    // 刚刚 p 移交给 func 的生命周期结束了!引用计数减2

    std::cout<<"p use_count="<<p.use_count()<<std::endl;
    p->do_something();  // 正常执行,因为引用计数还剩1,不会被释放

    return 0;  // 到这里最后一个引用 p 也被释放,p 指向的对象才终于释放
}
  • 测试:
  • RAII内存管理_c++_18

  • 我们可以使用 p.use_count() 来获取当前指针的引用计数,看看他是不是在智能地增减引用计数器。
  • 注意 p.func() 是 shared_ptr 类型本身的成员函数,而 p->func() 是 p 指向对象(也就是 C)的成员函数,不要混淆。
  • eg:my_course/course/02/22/main.cpp
#include <cstdio>
#include <memory>
#include <vector>

struct C {
    int m_number;

    C() {
        printf("分配内存!\n");
        m_number = 42;
    }

    ~C() {
        printf("释放内存!\n");
        m_number = -2333333;
    }

    void do_something() {
        printf("我的数字是 %d!\n", m_number);
    }
};

std::vector<std::shared_ptr<C>> objlist;

void func(std::shared_ptr<C> p) {
    objlist.push_back(std::move(p));  // 这里用移动可以更高效,但不必须
}

int main() {
    std::shared_ptr<C> p = std::make_shared<C>(); // 引用计数初始化为1

    printf("use count = %ld\n", p.use_count());   // 1

    func(p);  // shared_ptr 允许拷贝!和当前指针共享所有权,引用计数加1

    printf("use count = %ld\n", p.use_count());   // 2

    func(p);  // 多次也没问题~ 多个 shared_ptr 会共享所有权,引用计数加1

    printf("use count = %ld\n", p.use_count());   // 3

    p->do_something();  // 正常执行,p 指向的地址本来就没有改变

    objlist.clear();    // 刚刚 p 移交给 func 的生命周期结束了!引用计数减2

    printf("use count = %ld\n", p.use_count());   // 1

    p->do_something();  // 正常执行,因为引用计数还剩1,不会被释放

    return 0;  // 到这里最后一个引用 p 也被释放,p 指向的对象才终于释放
}
  • 测试:

不影响 shared_ptr 计数:弱引用 weak_ptr

  • 有时候我们希望维护一个 shared_ptr 的弱引用 weak_ptr,即:弱引用的拷贝与解构不影响其引用计数器。
  • 之后有需要时,可以通过 lock() 随时产生一个新的 shared_ptr 作为强引用。但不 lock 的时候不影响计数。
  • 如果失效(计数器归零)则 expired() 会返回 false,且 lock() 也会返回 nullptr。
  • 可以把 C * 理解为 unique_ptr 的弱引用。weak_ptr 理解为 shared_ptr 的弱引用。但 weak_ptr 能提供失效检测,更安全。
  • eg:my_course/course/02/23/main.cpp
#include <cstdio>
#include <memory>
#include <vector>

struct C {
    int m_number;

    C() {
        printf("分配内存!\n");
        m_number = 42;
    }

    ~C() {
        printf("释放内存!\n");
        m_number = -2333333;
    }

    void do_something() {
        printf("我的数字是 %d!\n", m_number);
    }
};

std::vector<std::shared_ptr<C>> objlist;

void func(std::shared_ptr<C> p) {
    objlist.push_back(std::move(p));  // 这里用移动可以更高效,但不必须
}

int main() {
    std::shared_ptr<C> p = std::make_shared<C>(); // 引用计数初始化为1

    printf("use count = %ld\n", p.use_count());   // 1

    std::weak_ptr<C> weak_p = p;        // 创建一个不影响计数器的弱引用

    printf("use count = %ld\n", p.use_count());   // 1

    func(std::move(p));  // 控制权转移,p 变为 null,引用计数加不变

    if (weak_p.expired())
        printf("错误:弱引用已失效!");
    else
        weak_p.lock()->do_something();  // 正常执行,p 的生命周期仍被 objlist 延续着

    objlist.clear();    // 刚刚 p 移交给 func 的生命周期结束了!引用计数减1,变成0了

    if (weak_p.expired())              // 因为 shared_ptr 指向的对象已释放,弱引用会失效
        printf("错误:弱引用已失效!");
    else
        weak_p.lock()->do_something();  // 不会执行

    return 0;  // 到这里最后一个弱引用 weak_p 也被释放,他指向的“管理块”被释放
}
  • 测试:

(3)智能指针:作为类的成员变量

  • 可以在类中使用智能指针作为成员变量。需要根据实际情况(主要是看所有权),判断要用哪一种智能指针:

类有智能指针的话,那么这个类很可能就变成浅拷贝的类了,

  • 类中的unique_ptr是禁止拷贝的,那么这个类也会是禁止拷贝的了, 但是移动还是有的
  • (1)unique_ptr:当该对象仅仅属于我时。比如:父窗口中指向子窗口的指针。即:父窗口拥有子窗口
  • (2)原始指针:当该对象不属于我,但他释放前我必然被释放时。有一定风险。比如:子窗口中指向父窗口的指针。
  • (3)shared_ptr:当该对象由多个对象共享时,或虽然该对象仅仅属于我,但有使用 weak_ptr 的需要。
  • (4)weak_ptr:当该对象不属于我,且他释放后我仍可能不被释放时。比如:指向窗口中上一次被点击的元素。
  • 初学者可以多用 shared_ptr 和 weak_ptr 的组合,更安全。但是有性能损耗,因为引用计数是原子的
  • 原始指针没有失效检测的,waek_ptr是有失效检测的;1和2是一对,3和4是一对

shared_ptr 管理的对象生命周期,取决于所有引用中,最长寿的那一个。
unique_ptr 管理的对象生命周期长度,取决于他所属的唯一一个引用的寿命。

  • eg:my_course/course/02/24/pseudo.cpp
struct C {
    // 当一个类具有 unique_ptr 作为成员变量时:
    std::unique_ptr<D> m_pD;

    // 拷贝构造/赋值函数会被隐式地删除:
    // C(C const &) = delete;
    // C &operator=(C const &) = delete;

    // 移动构造/赋值函数不受影响:
    // C(C &&) = default;
    // C &operator=(C &&) = default;
};

(4)那是不是只要 shared_ptr 就行,不用 unique_ptr 了?

可以适当使用减轻初学者的压力,因为他的行为和 Python 等 GC 语言的引用计数机制很像。但从长远来看是不行的,因为:

  • shared_ptr 需要维护一个 atomic 的引用计数器,效率低,需要额外的一块管理内存,访问实际对象需要二级指针,而且 deleter 使用了类型擦除技术。
  • 全部用 shared_ptr,可能出现循环引用之类的问题,导致内存泄露,依然需要使用不影响计数的原始指针或者 weak_ptr 来避免。比如右边这个例子:
  • C是一个窗口类,m_child指向它的子窗口,m_parent指向它的父窗口
  • eg:my_course/course/02/24/0.cpp
#include <memory>
#include <cstdio>
struct C
{
    std::shared_ptr<C> m_child;
    std::shared_ptr<C> m_parent;
};

int main()
{
    auto parent = std::make_shared<C>();
    auto child = std::make_shared<C>();

    // 建立相互引用:
    parent->m_child = child;
    child->m_parent = parent;
    printf("parent usecout :%ld\n", parent.use_count());
    printf("child usecout :%ld\n", child.use_count());
    parent = nullptr; // parent 不会被释放!因为 child 还指向他!
    child = nullptr;  // child 也不会被释放!因为 parent 还指向他!

    // 必须手动置为nullptr
    printf("manuaul set nullptr\n");
    printf("parent usecout :%ld\n", parent.use_count());
    printf("child usecout :%ld\n", child.use_count());
    return 0;
}

RAII内存管理_#include_19

循环引用:解决方案1

  • 如何解决?只需要把其中逻辑上“不具有所属权”的那一个改成 weak_ptr 即可:
    因为父窗口“拥有”子窗口是天经地义的,而子窗口并不“拥有”父窗口。
    其实主要是一个父窗口可以有多个子窗口,只有规定子窗口从属于父窗口才能解决引用计数的问题……
  • 让孩子指向父的这个指针为弱指针,

child->m_parent = parent;
孩子的释放与否不影响父类指针的引用计数的,所以parent已经变成0了

  • eg:my_course/course/02/24/1.cpp
#include <memory>
#include <cstdio>

struct C
{
    std::shared_ptr<C> m_child;
    std::weak_ptr<C> m_parent;
};

int main()
{
    auto parent = std::make_shared<C>();
    auto child = std::make_shared<C>();

    // 建立相互引用:
    parent->m_child = child;
    child->m_parent = parent;

    parent = nullptr; // parent 会被释放。因为 child 指向他的是 **弱引用**
    printf("parent usecout :%ld\n", parent.use_count());
    printf("child usecout :%ld\n", child.use_count());
    // child = nullptr; // child 会被释放。因为指向 child 的 parent 已经释放了
    printf("parent usecout :%ld\n", parent.use_count());
    printf("child usecout :%ld\n", child.use_count());
    return 0;
}

RAII内存管理_ios_20

循环引用:解决方案2

  • 还有一种更适应“父子窗口”这一场景的解决方案。
  • 刚才提到原始指针的应用场景是“当该对象不属于我,但他释放前我必然被释放时”。
    这里我们可以发现父窗口的释放必然导致子窗口的释放。因此我们完全可以把 m_parent 变成原始指针。
  • 这样也不需要 weak_ptr 判断是否 expired() 了。
  • eg:my_course/course/02/24/2.cpp
#include <memory>

struct C
{
    std::shared_ptr<C> m_child;
    C *m_parent;
};

int main()
{
    auto parent = std::make_shared<C>();
    auto child = std::make_shared<C>();

    // 建立相互引用:
    parent->m_child = child;
    child->m_parent = parent.get();

    parent = nullptr; // parent 会被释放。因为 child 指向他的是原始指针
    child = nullptr;  // child 会被释放。因为指向 child 的 parent 已经释放了

    return 0;
}

循环引用:解决方案3

  • 还可以更好!刚才提到 unique_ptr 的应用场景是“当该对象仅仅属于我时”。
  • 既然都用了原始指针(假定他释放前我必然被释放)。因为因此我们完全可以把 m_child 变成一个标志这“完全所有权”的 unique_ptr。
  • 这样也不需要 shared_ptr 维护一个原子计数器的开销了。
  • 用unique_ptr没有副本了,那么用原始指针就比较安全
  • eg:my_course/course/02/24/3.cpp
#include <memory>

struct C {
    std::unique_ptr<C> m_child;
    C *m_parent;
};

int main() {
    auto parent = std::make_unique<C>();
    auto child = std::make_unique<C>();

    // 建立相互引用:
    child->m_parent = parent.get();
    parent->m_child = std::move(child);  // 移交 child 的所属权给 parent

    parent = nullptr;  // parent 会被释放。因为 child 指向他的是原始指针
    // 此时 child 也已经被释放了,因为 child 完全隶属于 parent

    return 0;
}

总结:

  • 接下来你会发现,在智能指针的管理下,某些类型的对象并不是总是需要用到拷贝和移动。
  • C++中所有的对象都是深拷贝,只有shared_ptr和weak_ptr是浅拷贝,unique_ptr是禁止拷贝

14.三五法则:什么时候需要担心

(1)类型安全vs类型不安全

一般来说,可以认为符合三五法则的类型是安全的。
以下类型是安全的(不会double free):

int id;                                        // 基础类型
std::vector<int> arr;                  // STL 容器
std::shared_ptr<Object> child; // 智能指针
Object *parent;                         // 原始指针,如果是从智能指针里 .get() 出来的

以下对象是不安全的(会double free):

char *ptr;                                   // 原始指针,如果是通过 malloc/free 或 new/delete 分配的
GLint tex;                                  // 是基础类型 int,但是对应着某种资源
std::vector<Object *> objs;       // STL 容器,但存了不安全的对象
  • 解决办法:弄成深拷贝或者禁止拷贝

(2)成员都是安全的类型:五大函数,一个也不用声明

如果你的类所有成员,都是安全的类型,那么五大函数都无需声明(或声明为 = default),你的类自动就是安全的。

  • 最好的判断方式是:如果你不需要自定义的解构函数,那么这个类就不需要担心。
    因为如果用到了自定义解构函数,往往意味着你的类成员中,包含有不安全的类型。
  • 一般无外乎两种情况:
    你的类管理着资源。你的类是数据结构。
  • eg:

管理着资源:删除拷贝函数,然后统一用智能指针管理

  • 这个类管理着某种资源,资源往往不能被“复制”。比如一个 OpenGL 的着色器,或是一个 Qt 的窗口。
  • 如果你允许 GLShader 拷贝,就相当于把 glCreateShader 返回的 int 拷贝两遍,解构时就会出现重复释放 (double free) 错误。
  • 你会想“那我是不是可以在 GLShader 里加一个引用计数器呢,这样就可以算拷贝次数避免重复释放了!”
  • 可以,但是既然标准库已经提供了 shared_ptr,还不如用 shared_ptr 来管理,省的每个类实现一遍原子引用计数器。
    这样就可以实现任意没有拷贝函数的对象的浅拷贝了

是数据结构:如果可以,定义拷贝和移动

  • 这个类是你精心设计的数据结构,包括我们刚刚发明的 Vector,还有链表,红黑树等。如果这些数据结构是可以支持拷贝的(比如 Vector 就可以),你可能需要自己一个个定义。如果不支持,那就删除(= delete)。

函数参数:如何避免不必要的拷贝

  • 注意到,如果函数的参数声明为值类型,很可能会造成一次不必要的拷贝。
  • eg:02/25/main.cpp
#include <vector>
#include <iostream>

struct Pig {
    std::string m_name;
    int m_weight;

    Pig(std::string name, int weight)
        : m_name(name)
        , m_weight(weight)
    {}

    Pig(Pig const &other)
        : m_name(other.m_name)
        , m_weight(other.m_weight)
    {
        std::cout << "拷贝了一只猪!" << std::endl;
    }
};

void show(Pig const &pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig{"佩奇", 80};
    show(pig);
    return 0;
}
#include <vector>
#include <iostream>

struct Pig {
    std::string m_name;
    int m_weight;

    Pig(std::string name, int weight)
        : m_name(name)
        , m_weight(weight)
    {}

    Pig(Pig const &other)
        : m_name(other.m_name)
        , m_weight(other.m_weight)
    {
        std::cout << "拷贝了一只猪!" << std::endl;
    }
};

// void show(Pig const &pig) {
//     std::cout << "name: " << pig.m_name << std::endl;
//     std::cout << "weight: " << pig.m_weight << std::endl;
// }

void show(Pig pig)
{
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

int main() {
    Pig pig{"佩奇", 80};
    show(pig);
    return 0;
}

RAII内存管理_Pig_21

如何避免不必要的拷贝:常引用

  • 因此,可以把函数的参数类型声明为:
    Pig const &pig
  • 这样传递的就是 pig 对象的常引用,从而实际只传递了一个指针,避免了拷贝。
  • 常见的有 std::vector const &arr 等。
    注:有的教材喜欢这样:const Pig &pig,仅仅是个人喜好不同,没有实际区别。
  • eg:my_course/course/02/25/main.cpp
#include <vector>
#include <iostream>

struct Pig {
    std::string m_name;
    int m_weight;

    Pig(std::string name, int weight)
        : m_name(name)
        , m_weight(weight)
    {}

    Pig(Pig const &other)
        : m_name(other.m_name)
        , m_weight(other.m_weight)
    {
        std::cout << "拷贝了一只猪!" << std::endl;
    }
};

void show(Pig const &pig) {
    std::cout << "name: " << pig.m_name << std::endl;
    std::cout << "weight: " << pig.m_weight << std::endl;
}

// void show(Pig pig)
// {
//     std::cout << "name: " << pig.m_name << std::endl;
//     std::cout << "weight: " << pig.m_weight << std::endl;
// }

int main() {
    Pig pig{"佩奇", 80};
    show(pig);
    return 0;
}

RAII内存管理_#include_22

(3)函数参数类型优化规则:按引用还是按值?

如果是基础类型(比如 int,float)则按值传递:
float squareRoot(float val);

如果是原始指针(比如 int *,Object *)则按值传递:
void doSomethingWith(Object *ptr);

如果是数据容器类型(比如 vector,string)则按常引用传递:
int sumArray(std::vector<int> const &arr);

如果数据容器不大(比如 tuple<int, int>),则其实可以按值传递:
glm::vec3 calculateGravityAt(glm::vec3 pos);

如果是智能指针(比如 shared_ptr),且需要生命周期控制权,则按值传递:
void addObject(std::shared_ptr<Object> obj);

如果是智能指针,但不需要生命周期,则通过 .get() 获取原始指针后,按值传递:
void modifyObject(Object *obj);

(4)如何避免不经意的隐式拷贝

  • 我们可以将拷贝构造函数声明为 explicit 的,这样隐式的拷贝就会出错,从而发现因为疏忽大意造成的不必要拷贝。
  • 而当的确需要拷贝时,也可以改成 Pig(pig) 语法来强制拷贝(即:显式拷贝)。
  • eg:my_course/course/02/26/main.cpp

15.扩展

P-IMPL 模式:可以分离声明和定义,改变类里面的属性,其外面也不会重新编译
虚函数与纯虚函数
拷贝如何作为虚函数,拷贝构造函数是构造函数,构造函数不能为虚的,父类定义一个返回值为shared_ptr的虚函数,那么子类就可以进行拷贝了
std::unique_ptr::release(),获取原始指针而不删除
std::enable_shared_from_this,this变成了一个shared_ptr,限制在于:这个类必须通过make_shared去构建,自己不能去new或者去栈上分配了
dynamic_cast,把具有虚函数的类进行类型转换
std::dynamic_pointer_cast,智能指针版本的dynamic_cast,
运算符重载
右值引用 &&
std::shared_ptr<void>和  std::any
可以接收任何一个指针,也能智能的管理它,和C语言的void*一样;std:any是一个深拷贝版本的shared_ptr<void>,可以接收任何类型的对象

19.为什么很多面向对象语言,比如 Java,都没有构造函数全家桶这些概念?

因为他们的业务需求大多是:打开数据库,增删改查学生数据,打开一个窗口,写入一个文件,正则匹配是不是电邮地址,应答 HTTP 请求等。

这些业务往往都是在和资源打交道,从而基本都是刚刚说的要删除拷贝函数的那一类,解决这种需求,几乎总是在用 shared_ptr 的模式,于是 Java 和 Python 干脆简化:一切非基础类型的对象都是浅拷贝,引用计数由垃圾回收机制自动管理。

因此,以系统级编程、算法数据结构、高性能计算为主要业务的 C++,才发展出了这些思想,并将拷贝/移动/指针/可变性/多线程等概念作为语言基本元素存在。这些在我们的业务里面是非常重要的,所以不可替代。