0%

2020年3月17日 下午10:56

  1. 第一部分:泛型编程,第 1~3 章,讨论了从 C 到 C++ 的泛型编程方法,并系统地总结了编程语言中的类型系统和泛型编程的本质。

  2. 第二部分:函数式编程,第 4 章和第 5 章,讲述了函数式编程用到的技术,及其思维方式,并通过 Python 和 Go 修饰器的例子,展示了函数式编程下的代码扩展能力,以及函数的相互和随意拼装带来的好处。

  3. 第三部分:面向对象编程,第 6~8 章,讲述与传统的编程思想的相反之处,面向对象设计中的每一个对象都应该能够接受数据、处理数据并将数据传达给其它对象,列举了面向对象编程的优缺点,基于原型的编程范式,以及 Go 语言的委托模式。

  4. 第四部分:编程本质和逻辑编程,第 9~11 章,先探讨了编程的本质:逻辑部分才是真正有意义的,控制部分只能影响逻辑部分的效率,然后结合 Prolog 语言介绍了逻辑编程范式,最后对程序世界里的编程范式进行了总结,对比了它们之间的不同。

    [摘抄]解释执行和编译执行的区别
    站在编程范式的角度理解STL核心模块之间的关系
    理解control、logic、data、泛型、函数式编程
    编程范式理解
    模板编程:分步骤STL如何实现泛型
    C++对C的改进
    程序语言的类型系统:优点、问题、本质

2020年3月17日 下午5:24

并发代码1

1
2
x = 0;
y = 0;

并发代码2:

1
2
x = 1;
y = 2;

并发代码3

1
2
3
4
if (y == 2) {
x = 3;
y = 4;
}

总结:

当 1 + 3 并发时—>可以解决的问题,结果是确定的

我们可以用原子操作的内存顺序来解决:

1
2
3
x = 1;
// 在 x = 1 和 y = 2 两句语句之间加入内存屏障,禁止这两句语句交换顺序
y.store(2, memory_order_release);
1
2
3
4
5
if (y.load(memory_order_acquire) == 2) {
x = 3;
//在线程 2 我们对 y 的读取应当使用获得语义,但存储只需要松散内存序即可
y.store(4, memory_order_relaxed);
}

我们可以用下图示意一下,每一边的代码都不允许重排越过黄色区域,且如果 y 上的释放早于 y 上的获取的话,释放前对内存的修改都在另一个线程的获取操作后可见

当1 + 2并发时—>不论采用哪种方式解决,结果都是不确定的

  • 你只能保证并发代码1, 并发代码2各自是禁止交换顺序
  • 你无法避免保证1 + 2并发,线程的交叉执行,所以,结果一定是不确定的!

2020年3月17日 下午5:05

互斥量

互斥量的基本语义是,一个互斥量只能被一个线程锁定,用来保护某个代码块在同一时间只能被一个线程执行。

在阅读的时候,我心里也有前面几个读者的关于锁、互斥量、原子操作的区别与联系的疑问🤔。

我尝试说一下我的理解:站在需求的角度

  1. 对单独没有逻辑联系的变量,直接使用原子量的relaxed就够了,没必要加上内存序
  2. 对于有联系的多个多线程中的变量,这时就需要考虑使用原子量的内存序
  3. 对于代码段的保护,由于原子量没有阻塞,所以必须使用互斥量和锁来解决
    Ps:互斥量+锁的操作 可取代 原子量。反之不可。

另外,还产生新的疑问:

  1. 互斥量的定义中,一个互斥量只允许在多线程中加一把锁,那么是否可以说互斥量只有和锁配合达到保护代码段的作用,互斥量还有其他单独的用法吗?
  2. 更近一步,原子量+锁,是否可以完成对代码段的保护?而吴老师也在评论区里提到:锁是由原子量构成的。

作者回复:

  • 你从需求方面理解的 1、2、3 我觉得都对,很好!
  • “互斥量只有和锁配合”这个提法我觉得很怪:互斥量是个对象,(加/解)锁是互斥量支持的动作——如果你指 lock_guard 之类的类,那是辅助的 RAII 对象,目的只是自动化互斥量上的对应操作而已。
  • 你可能是被“操作系统中锁的实现原理”这样的提法带偏了。没有作为名字的专门锁对象,只有互斥量、条件变量、原子量。我也被带偏了,我在某个评论里说“锁”的时候,指的就是互斥量加锁

作者回复:

  1. “可见”,可以理解成获得和释放操作的两个线程能观察到相同的内存修改结果。
  2. 原则上任何多线程访问的变量应该要么是原子量,要么有互斥量来保护,这样最安全。特别要考虑内存序的,当然就是有多个有逻辑相关性的共享变量了。对于单个的变量,比如检查线程是否应该退出的布尔变量,只要消除了编译器优化,不需要保证访问顺序也可以正常工作;这样原子量可以使用 relaxed 的访问方式。

2020年3月17日 下午2:03

理解函数对象:

  • 老师教的是:函数对象,在这我给他加上了一个“类”字。
  • 主要原因: 想突出函数对象其实是一个类型
  • 是类型的话,就可以和模板操作完美的联系在一起了

当定义好函数对象之后,能如何进行使用

  • 当做类型,与模板一起使用
  • 当做函数,进行函数式编程
  • 当做参数,可以用来传递、使用、返回,这样的函数是高阶函数

函数对象类定义

  • 函数对象是一个可以被当作函数来用的对象
  • 从文字理解:
    • 首先他就是对象。一个对象可以包含各种方法、属性,他也可以。
    • 函数,两个字说明了他的使用方式的不同。
      • 一般,对象都是object.function()来调用方法
      • 函数对象,调用方法是object(),其实就是利用运算符重载,实现了operator() 而已,任何一个对象都可以成为函数对象
  • lambda表达:匿名函数对象类,没有类名,或者说是没有函数名

为什么光用对象、或者函数不行

  • 光用函数:无法实现object()()这种函数式的连续操作
  • 光用对象:函数对象依然还是对象!这个说法不存在。

2020年3月17日

即使加锁,也不一定唯一初始化

在多线程可能对同一个单件进行初始化的情况下,有一个双重检查锁定的技巧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 头文件
class singleton {
public:
static singleton* instance();

private:
static singleton* inst_ptr_;
};

// 实现文件
singleton* singleton::inst_ptr_ =
nullptr;

singleton* singleton::instance()
{
if (inst_ptr_ == nullptr) {
lock_guard lock; // 加锁
if (inst_ptr_ == nullptr) {
inst_ptr_ = new singleton();
}
}
return inst_ptr_;
}
  • 原本的意图是:
    • 如果 inst_ptr_ 没有被初始化,执行才会进入加锁的路径,防止单件被构造多次
  • 这里面有两个需求:
    • 单一初始化inst_ptr_。方法:写成下面即可
      1
      2
      3
      4
      5
      6
      7
      8
      singleton* singleton::instance()
      {
      lock_guard lock; // 加锁
      if (inst_ptr_ == nullptr) {
      inst_ptr_ = new singleton();
      }
      return inst_ptr_;
      }
    • 为什么还要再加一个if (inst_ptr_ == nullptr)
      • 效率的角度,别直接上来就锁。如果inst_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
// 头文件
class singleton {
public:
static singleton* instance();

private:
static mutex lock_;
static atomic<singleton*>
inst_ptr_;
};

// 实现文件
mutex singleton::lock_;
atomic<singleton*>
singleton::inst_ptr_;

singleton* singleton::instance()
{
singleton* ptr = inst_ptr_.load(
memory_order_acquire);
if (ptr == nullptr) {
lock_guard<mutex> guard{lock_};
ptr = inst_ptr_.load(
memory_order_relaxed);
if (ptr == nullptr) {
ptr = new singleton();
inst_ptr_.store(
ptr, memory_order_release);
}
}
return inst_ptr_;
}

2020年3月16日 下午9:18

模板的理解

模板最出发点,其实可以从模板两个字上看出,其实就是代码复用,只不过后来有发展出来了编译期计算等复杂的功能

其实就是一个知识:

模板、泛型编程和静态多态

代码复用有几种方式

  • 函数:最原始的
  • 多态:面向对象中的
  • 模板:编译期的

编译期功能的组成部分:

  • 编译期常量:用constexpr声明
  • 编译期计算:类型推导来计算
  • 编译期潜规则:模板匹配规则SFINAE

编译期可以实现哪些功能:

  • 计算:已经证明是图灵完全的
  • 类型检查:对自己定义的类、容器类进行类型检查,是否包含指定函数
    • 这个是运行期无法完成的!这能在编译期完成
  • 代码的复用:
    • 利用模板,完成编译期多态,也就是静态多态
    • eg:虽然 C++ 的标准容器没有对象继承关系,但彼此之间有着很多的同构性。这些同构性很难用继承体系来表达,也完全不必要用继承来表达。C++ 的模板,已经足够表达这些鸭子类型。

函数模板:杂交出来的!或者说是分工合作

  • 编译期会处理一部分,运行期也会处理一部分
  • 编译期主要处理类型,运行期主要处理计算
  • 不杂交不行:因为运行期无法处理类型

函数与模板的合作方式:

  • 函数模板
    • 编译期会处理一部分,运行期也会处理一部分
    • 编译期主要处理类型,运行期主要处理计算
    • 不杂交不行:因为运行期无法处理类型
  • 函数对象、lambda表达式 与 一些C++自带模板进行组合使用

函数与struct模板的区别

  • 执行期不同:
    • 函数在运行期执行
    • 模板在编译器运行
  • 处理对象不同:
    • 函数的处理对象:也就是函数的输入,只能是数据,包括以包含数据的容器、普通类型遍历
    • 模板的处理对象:类型(容器类型,自定义类型) + static const 常量。不是数据
  • body内容不同:
    • 函数体内:定义的是数据运算规则
      • 函数的编译过程会报错
    • struct模板体内:定义的是类型推导规则
      • 模板的编译过程由于有SFINAE不会报错
  • 将编译过程分为两个步骤:先模板编译,后函数编译

泛型的好处:

  1. 可以统一数据类型,便于操作。
  2. 将运行时的异常提前到了编译时,提高了效率。
  3. 避免了强制类型转换
  4. 实现代码的模板化,把数据类型当作参数传递,提高了可重用性。

While< Sum<2>::type >::type::value 实例化(instantiation)过程

  • 对于模板,就是要在脑子里或纸上、电脑上把它展开……☺️
  • 把计算转变成类型推导:在类型推导的时候,可以进行计算,类似于传递参数的时候+1 这样的操作。
  • 模板元编程,其本质是把计算过程用编译期的类型推导和类型匹配表达出来。
    • 计算功能由类型推导完成
    • 类型匹配是模板的天生能力,其实就是多态,只不过是静态多态
  • 在展开的过程中,甚至可以变成递归,这一点一定要理解。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    —> While< SumLoop<0, 2> >::type::value
    —> WhileLoop<SumLoop<0, 2>::cond_value, SumLoop<0, 2>>::type::value
    --> WhileLoop<true, SumLoop<0, 2>>::type::value

    --> WhileLoop<SumLoop<0, 2>::cond_value, SumLoop<0, 2>::next_type>::type::value
    --> WhileLoop<true, SumLoop<2, 1>>::type::value

    --> WhileLoop<SumLoop<2, 1>::cond_value, SumLoop<2, 1>::next_type>::type::value
    --> WhileLoop<true, SumLoop<3, 0>>::type::value

    --> WhileLoop<SumLoop<3, 0>::cond_value, SumLoop<3, 0>::next_type>::type::value
    --> WhileLoop<false, SumLoop<3, -1>>::type::value

    --> SumLoop<3, -1>::res_type::value

    -->integral_constant<int, 3>::value
    -->3

2020年3月16日 下午7:53

auto减少类型的声明语句

auto的限制:

  • 通常情况下,能写 auto 来声明变量肯定是件比较轻松的事。但这儿有个限制,你需要在写下 auto 时就决定你写下的是个引用类型还是值类型
  • ps:我觉得auto在将来的C++标准中会被慢慢取消,因为我觉得既然在很多清下可以将类型用auto代替,在编译的时候编译器能自动的判断出类型,那么是否可以:让编译器判断出那个位置需要声明变量类型,那么,auto也可以省了

减少初始化的难度

  • 列表初始化
  • 统一初始化
  • 类数据成员默认初始化

其他:

  • 字面量:就是一些和数字在一起的有意义的字符 3.14f,这里的f就是
    • 自定义字面量的方式就是:
      • 重载运算符 operator””
      • 要在自己的类里支持字面量也相当容易,唯一的限制是非标准的字面量后缀必须以下划线 _
  • 静态断言
  • 成员函数说明符

2020年3月16日 下午7:25

异常相关文字概念:

错误处理的两种方式:

  • 返回错误码
  • 异常处理

异常处理

异常处理并不意味着需要写显式的 try 和 catch。异常安全的代码,可以没有任何 try 和 catch

异常安全定义

  • 异常安全,并不是指的不会发生异常,而是发生异常之后也是安全的。这里的安全指:不会发生资源泄漏 + 不会处于一个不一致的状态
  • 异常安全是指当异常发生时,既不会发生资源泄漏,系统也不会处于一个不一致的状态

可能会出现错误 / 异常的地方

  • 首先是内存分配。
    • 如果 new 出错,按照 C++ 的规则,一般会得到异常 bad_alloc,对象的构造也就失败了。这种情况下,在 catch 捕捉到这个异常之前,所有的栈上对象会全部被析构,资源全部被自动清理。
  • 如果是矩阵的长宽不合适不能做乘法呢?
    • 我们同样会得到一个异常,这样,在使用乘法的地方,对象 c 根本不会被构造出来。
  • 如果在乘法函数里内存分配失败呢?
    • 一样,result 对象根本没有构造出来,也就没有 c 对象了。还是一切正常。
  • 如果 a、b 是本地变量,然后乘法失败了呢?
    • 析构函数会自动释放其空间,我们同样不会有任何资源泄漏

异常相关的语法定义:C++自带的功能

noexcept

  • 从 C++17 开始,C++ 甚至完全禁止了以往的动态异常规约,你不再能在函数声明里写你可能会抛出某某异常。你唯一能声明的,就是某函数不会抛出异常——noexcept、noexcept(true) 或 throw()。这也是 C++ 的运行时唯一会检查的东西了。
  • 如果一个函数声明了不会抛出异常、结果却抛出了异常,C++ 运行时会调用 std::terminate 来终止应用程序。不管是程序员的声明,还是编译器的检查,都不会告诉你哪些函数会抛出哪些异常。

标准容器的强异常保证:

  • C++ 的标准容器在大部分情况下提供了强异常保证,即,一旦异常发生,现场会恢复到调用函数之前的状态,容器的内容不会发生改变,也没有任何资源泄漏
  • 前面提到过,vector 会在元素类型没有提供保证不抛异常的移动构造函数的情况下,在移动元素时会使用拷贝构造函数。这是因为一旦某个操作发生了异常,被移动的元素已经被破坏,处于只能析构的状态,异常安全性就不能得到保证了。

2020年3月16日 下午6:00

补充:

  1. 什么是右值引用,跟左值又有什么区别?
    1. 右值引用是C++11中引入的新特性 , 它实现了转移语义和精确传递。它的主要目的有两个方面:
    2. 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。
    3. 能够更简洁明确地定义泛型函数。
  2. 左值和右值的概念
    • 左值:能对表达式取地址、或具名对象/变量。一般指表达式结束后依然存在的持久对象。
    • 右值:不能对表达式取地址,或匿名对象。一般指表达式结束就不再存在的临时对象。
  3. 右值引用和左值引用的区别:
    1. 左值可以寻址,而右值不可以。
    2. 左值可以被赋值,右值不可以被赋值,可以用来给左值赋值。
    3. 左值可变,右值不可变(仅对基础类型适用,用户自定义类型右值引用可以通过成员函数改变)。

小总结:

  1. 我认为左值和右值,就是C++设计者给数据、对象设计的一个标签。
  2. 你作为程序员,你可以将一个数据、对象打上合适的标签,甚至可以给他们更换标签,但是这个过程对于内存上真实存在的数据来说,其实是无感的,他依然存在内存中的原有位置,一动不动。
  3. 当数据、对象有了这个标签属性之后,在加上C++内在的对不同标签的数据处理机制(其实就是一个if-else),来区分对象移动、对象拷贝。
  4. C++设计者是为了给C++添加对象移动这一新功能,而设计了这组新的数据标签

注:为了节约时间,我仅仅将重要的理解方法记录在这里,具体的知识点的讲解,还是去看原文。

宏观角度理解

  • 宏观的一句话总结:值类别、值类型、生命周期、表达式类型、移动构造等等知识点,其实就是从效率的角度进行思考,思考如何在完成相同功能的前提下,能够从逻辑上使用更少的对象、更少的步骤,从实际上如何更少的使用内存空间。
  • 我觉得这不仅仅是学习移动构造系列知识的角度,学习任何关于编程的知识都要站在这个角度进行思考。这样你才能理解的舒服,弄懂背后的逻辑。最后达到不死记知识点,而是靠理解来进行记忆(耗叔的话)。

具体的理解

  1. 拷贝构造就会生成临时对象,造成新的开销,那么我们的目的就是减少拷贝构造的次数,减少临时对象的数量。
  2. 解决方法最重要的有一条就是:用移动构造,替换掉拷贝构造
  3. 对于 smart_ptr,我们使用右值引用的目的是实现移动,而实现移动的意义是减少运行的开销,其实就是生成临时对象的开销。这里的移动指的是移动构造,移动构造和拷贝构造是。

几个零散的知识点:

  1. “值类别”(value category)和“值类型”(value type)
    • 是两个看似相似、却毫不相干的术语。
    • 前者指的是上面这些左值、右值相关的概念,
    • 后者则是与引用类型(reference type)相对而言,表明一个变量是代表实际数值,还是引用另外一个数值。
    • 在 C++ 里,所有的原生类型、枚举、结构、联合、类都代表值类型,只有引用(&)和指针(*)才是引用类型。
    • 在 Java 里,数字等原生类型是值类型,类则属于引用类型。在 Python 里,一切类型都是引用类型
  2. 临时对象的生命周期:
    • C++ 的规则是:一个临时对象会在包含这个临时对象的完整表达式估值完成后、按生成顺序的逆序被销毁,除非有生命周期延长发生
    • 注意:这里说的是临时对象
  3. 为了方便对临时对象的使用,C++ 对临时对象有特殊的生命周期延长规则
    • 如果一个 prvalue 被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长