Efficient&Elegant:Java程序员入门Cpp
最近项目急需C++ 的知识结构,虽说我有过快速学习很多新语言的经验,但对于C++ 老特工我还需保持敬畏(内容太多),本文会从一个Java程序员的角度,制定高效学习路线快速入门C++ 。
Java是为了就业,C++ 是信仰。(C++ 是教学、信仰、商业这三个原本互斥的概念(这三个概念也是三个阶段,正好可以陪我们一起成长)的偏偏集合体)
关键字:C++ ,基本语法,C++ 与Java对比,环境搭建,helloworld,C++ 工具,C++ 类库,抽象机制,并发
热身
基本思想
这一章是高屋建瓴,为学习C++ 定下基调。下面通过斯特鲁普(C++发明者)对Java程序员的字字珠玑的建议,再加上我的理解和总结,列出几点“中心思想”。
- 不要试图用C++ 来编写Java程序。
- 不能依赖垃圾收集器了。
- 同为面向对象语言,但要采用C++ 自己的抽象机制【类和模板】。
- 要理解C++ 与C语言是各个方面都不同的程序设计语言(虽然最早C++ 是作为“带类的C”出现的),不要因为虚假的熟悉感而将代码写成C。
- C++ 标准库很重要很高效,要非常熟悉。
- C++ 程序设计强调富类型、轻量级抽象,希望能细细体会。
- C++ 特别适合资源受限的应用,也是为数不多可以开发出高质量软件的程序设计语言。
- C++ 的成长速度很快,要与时俱进。
- 一定要有单元测试和错误处理模型。
- C++ 将内置操作和内置类型都直接映射到硬件,从而提供高效内存使用和底层操作。
- C++ 有着灵活且低开销的抽象机制【核心掌握】(可能的话以库的形式呈现),而不是简单的如Java一样上来就给所有类创造一个唯一的基类。
- 尽量不使用引用和指针变量,作为替代,使用局部变量和成员变量。
- 使用限定作用域的资源管理。
- 对象释放时使用析构函数,而不是模仿finally。:
- 避免使用单纯的new和delete,应该使用容器(例如vector,string和map)以及句柄类,(例如lock和unique_ptr)
- 使用独立函数来最小化耦合,使用命名空间来限制独立函数的作用域。
- 不要使用异常规范。
- C++ 嵌套类对外围类没有访问权限。
- C++ 提供最小化的运行时反射:dynamic_cast和type_id,应更多依靠编译时特性。
- 零开销原则,必须不浪费哪怕一个字节或是一个处理器时钟周期(C++ 是信仰)
与Java的差别
C++ 是系统程序设计语言(例如驱动程序、通信协议栈、虚拟机、操作系统、标准库、编程环境等高大上有技术深度的系统),而Java是业务开发语言(例如XXX管理系统,电商网站,微信服务号等基于B/S架构的上层UED相关的应用),高下立判(鄙视链是有道理的)。
关于细节的学习
学习C++ 最重要的就是重视基本概念(例如类型安全、资源管理以及不变式)和程序设计技术(例如使用限定作用域的对象进行资源管理以及在算法中使用迭代器),但要注意不要迷失在语言技术性细节中。
学习C++ 一定要避免深入到细节特性中去浪费掉大量时间,
了解最生僻的语言特性或是使用到更多数量的特性并不是什么值得炫耀的事情,学习C++ 细节知识的真正目的是:在良好设计所提供的语境中,有能力组合使用语言特性和库特性来支持好的程序设计风格。
所以,使用库来简化程序设计任务,提高系统质量是非常必要的,学习标准库是学习C++ 不可分割的一部分。(遇到问题先找库,这一点我想每个Java程序员骨子里都是这么想的,不会钻到细节中去。)
领悟编程和设计技术比了解所有细节重要的多。而细节问题不要过分担心,通过时间的积累,不断的练习自然就会掌握。
支持库和工具集
C++ 除了标准库以外,有大量的标准库和工具集,现在有数以千计的C++ 库,跟上所有这些库的变化是不可能的,因此还是上面那些话,要通过组合使用个语言特性以及库特性来支持好的程序设计风格,所以熟悉这些库的领域(不必钻进去一一研究)以及领悟编程设计技术才是核心点。
C++ 基础
本章开始正式入门学习C++ ,会从基础知识、抽象机制、容器和算法、并发以及实用工具这几个方面进行学习。
基础知识
C++ 是一种编译型语言,要想运行一段C++ 程序,需要首先用编译器把源文件(通常包括许多)编译为对象文件,然后再用链接器把这些对象文件组合生成可执行程序。
C++ 的跨平台体现在源文件的跨平台,而不是可执行文件的跨平台,意思就是根据不同平台(例如Windows、Linux等)的编译器可以生成支持不同平台的可执行文件。
开发环境
我们这里使用的IDE仍旧是来自jetbrain家族的CLion。
Helloworld
New Project,创建一个新的C++ 项目,CLion会自动为你生成一个HelloWorld的基本项目。这个项目是基于CMake编译的,如果要连接git库,我推荐将所有编译相关的CMake文件都设置为ignore。下面来看一下C++ helloworld代码main.cpp:
#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}
下面分析一下这段代码:
- 首行通过“#include
” 指示编译器把iostream库include(包含)到本源文件中来。 - int main(),每个C++ 程序中有且只有一个名为main()的全局函数,在执行一个程序时首先执行该函数。
- std::cout,引用自iostream库的标准输出流。
- <<,将后面的字符串字面值写入到前面的标准输出流中,字符串字面值是一对双引号当中的字符序列。
编译执行
CLion采用CMake编译,需要一个CMakeLists.txt编译文件。
cmake_minimum_required(VERSION 3.10)
project(banner)
set(CMAKE_CXX_STANDARD 11)
add_executable(banner codeset/simplecal.cpp)
分析一下这个编译文件:
- 第一行是cmake的最低版本要求
- 第二行指定了项目名称,可以是别名
- 第三行是指定了编译版本,这里是C++ 11
- 第四行是加入执行器,需要两个参数,第一个参数必须是正确的项目名称,第二个参数是main函数所在位置,也就是执行器入口。
都设置好以后,开始执行,打出正确日志:
/home/liuwenbin/work/CLionProjects/github.com/banner/cmake-build-debug/banner
Hello, World!
Process finished with exit code 0
基本语法
隐藏std
std:: 是用来指定cout所在的命名空间,如果在代码中涉及大量操作会很麻烦,所以可以通过语法来隐藏掉,我们新建一个cpp源文件(注意默认CLion会直接创建.cpp和.h两个文件,这是C++ 源文件和头文件,也可以选择C的.c和.h。我们这里只保留cpp文件即可,头文件的使用在后续会应用到,这里可以删掉),键入代码如下:
//
// Created by liuwenbin on 18-4-14.
//
#include <iostream>
using namespace std;
double square(double x) {
return x * x;
}
void print_square(double x) {
cout << "the square of " << x << " is " << square(x) << "\n";
}
int main() {
print_square(12);
}
在函数print_square中,我们直接使用了cout而没有像上方一样std::cout,省略掉了std,执行结果:
the square of 12 is 144
初始化器
int main() {
int a{1}; // 使用初始化器,可以有效避免因为类型转换而被强制削掉的数据信息。例如int a {1.2}无法通过编译,而int a=1.2会直接削掉小数部分,a最后等于1
int b = 2.2;
cout << a << " " << b;
}
输出:
1 2
类型识别
初始化器可以通过=号直接赋值,变量的类型会通过初始化器推断得到。
int main() {
auto c = 1.2;
auto d = 'x';
auto e = true;
cout << c << " " << d << " " << e;
};
输出:
1.2 x 1
初始化器列表构造函数
std::initializer_list<double>,使用一个列表进行初始化,下面来看具体使用:
Vector2::Vector2(std::initializer_list<double> lst) : elem{new double[lst.size()]}, sz{lst.size()} {
copy(lst.begin(), lst.end(), elem);
}
↑↓初始化器列表,使用单冒号加空花括号(有点匿名函数的意思)的方式。
TODO: 使用初始化器列表的时候,会报错 narrowing conversion of XXX。stackoverflow上写需要在template中定义构造函数,这与当前研究内容走远了,所以放在后面研究。先不使用初始化器列表。
Vector2::Vector2(std::initializer_list<double> lst) {
elem{new double[lst.size()]}, sz{lst.size()};
copy(lst.begin(), lst.end(), elem);
}
通过一个标准库的initializer_list
常量
int main() {
const int kim = 38;// const“我承诺这个变量一旦赋值不会再改变”,编译器负责确认并执行const的承诺
constexpr double max = square(kim); // 编译时求值,参数必须是const类型,方法也必须是静态表达式,本行报错error: call to non-constexpr function
}
标准输入
上面讲了标准输出是std::cout,那么标准输入是什么呢?
bool accept() {
cout << "Do you want to accept?(y or n)\n";
char answer = 0;
cin >> answer;
if (answer == 'y')return true;
return false;
}
int main() {
bool a = accept();
cout << a;
}
执行,
Do you want to accept?(y or n)
y(这是我手动输入的)
1
注意:accept方法必须在main函数的上方,因为cpp源文件编译是顺序的,如果先编译main函数,就会发生找不到还未编译的accept方法。
数组
遍历一个数组:
int main() {
int v[8] = {0, 1, 2, 3, 4, 5, 6, 7};// 越界会报错
int t[] = {0, 1, 2, 3, 4, 5};// 没有设定边界,自动边界
for (auto i:v) {
cout << "-" << i;
}
}
输出:
-0-1-2-3-4-5-6-7
这个写法与其他语言很相似。
指针
指针变量中存放着一个相应类型对象的地址。
引用类似于指针,唯一的区别是我们无须使用前置运算符*访问所引用的值。换句话说就是引用是直接引用了地址的值,而指针只是指向地址。
引用指的是指针位置的值,指针指的是变量所在的位置,一个变量包括位置(指针)值(引用),赋值时可以修改自身(通过引用),拷贝一份(裸变量名)
一个变量存着值,它的指针是这个值所在的位置,它的引用就是这个位置的这个值本身。
接着来讲,一个变量本身存的就是一个变量的位置,那么它的指针就是这个位置内存存储的值,它的引用就是这个位置的字符串。
结构体
struct Vector {
int a;
double *b;
};
// Vector 初始化方法
void vector_init(Vector &v, int s) {//注意这里要用引用,否则v在后面的操作会在内存中复制一份变量而不是修改引用本身(这与java是不同的)
v.a = s;
v.b = new double[s];// new运算符是从一个自由存储,又称作动态内存或堆中分配内存。
}
// Vector 应用:从cin中读取s个整数,返回Vector
Vector read_and_sum(int s) {
Vector v;
vector_init(v, s);
for (int i = 0; i != s; ++i) {
cin >> v.b[i];
}
return v;
}
输出:
1(手动输入)
2(手动输入)
3(手动输入)
3 0x18bec20
Vector是一个struct,所有成员并没有任何要求,都是公开的,相当于一个不加限制的任意类型。
类
上面的自定义类型的结构体特性很好,但是有时候我们希望能够对内部数据进行访问限制,例如外部不允许直接访问Vector的属性a,那么类结构是很好的解决方式。
class Vector2 {// 在源文件中定义一个类
public: //公开的方法,通过方法与属性进行交互
Vector2(int s) : elem{new double[s]}, sz{s} {}//定义了一个构造函数,通过匿名内部类的形式
double &operator[](int i) { return elem[i]; }//自定义运算符“[]”,根据下标获取elem对应的元素值
int size() { return sz; }//获取elem的大小
private: //不可以直接访问属性
double *elem;
int sz;
};
Vector2是一个类,它包含两个成员,一个是elem的指针,一个是整型数据,所以Vector2的对象其实是一个“句柄”,并且它本身的大小永远保持不变,因为成员中一个固定大小的句柄指向“别处”内存的一个位置,无论这个位置通过new在自由存储中分配了多少空间,对于Vector2对象来说,它只存储一个句柄,这个数据的大小可以稳定的。
不变式
以上struct和类的最大区别就在于类的不变式,与自由的struct不同的是,类的不变式约定了类成员数据的一种限制条件,它是类合理性的体现,类承担了维护不变式的责任。如果每个数据成员都可以被赋以任何值,那么它就不是类,只是个结构,使用struct就好了。
注意Java程序员的恶习,如果一个类的所有成员都是私有的,然后它提供了或仅提供了这些成员的get,set方法,这在C++ 中是没意义的,直接使用struct吧。
枚举
enum class Color {// 作用域
red, blue, green
};
enum class traffic_light {
green, yellow, red
};
traffic_light &operator++(traffic_light &t) {// 枚举属于自定义类型,那么也可以自定义运算符++
switch (t) {
case traffic_light::green:
return t = traffic_light::yellow;
case traffic_light::yellow:
return t = traffic_light::red;
case traffic_light::red:
return t = traffic_light::green;
}
}
int main() {
Color col = Color::red;
traffic_light light = traffic_light::red;
traffic_light a = ++light;
}
枚举类型是自定义类型,它不是基本类型,red是它的一个对象,它的运算需要通过自定义运算符操作。
模块化
我们在写以上内容的时候,其实一直都有一种困扰:如何在函数、用户自定义类型、类以及模板之间进行交互?或者说复用?
分离编译
用户代码只能看见所用类型和函数的声明,它们的定义则放置在分离的源文件里,并被分别编译。这个结构是:
头文件定义接口,相同名称的cpp文件进行实现,然后其他cpp文件使用的时候引入头文件即可。
注意:虽然cpp文件实现头文件接口的机制与java很像,但C++ 是非常灵活的语言,它没有固定范式,所以一定要保持警惕,这并不是头文件唯一能做的事情,接口和所谓实现也不像java那样严格要求。
我们可以把上面对类Vector2的声明定义放到一个头文件Vector2.h中去。用户需要将该头文件include进程序才可访问接口。
Vector2.h
class Vector2 {// 头文件中只放置接口的描述声明,不写实现(相当于Java中的一个接口)
public: //公开的方法,通过方法与属性进行交互
Vector2(int s);
double &operator[](int i);
int size();
private://不可以直接访问属性
double *elem;
int sz;
};
Vector2.cpp
#include "Vector2.h"//头文件声明(接口),cpp文件实现,名称要一致。
Vector2::Vector2(int s)
: elem{new double[s]}, sz{s} {
}
double &Vector2::operator[](int i) {
return elem[i];
}
int Vector2::size() {//Vector2::命名空间的语法
return sz;
}
user.cpp
//
// Created by liuwenbin on 18-4-16.
//
#include "Vector2.h"
#include <cmath>
#include <iostream>
using namespace std;
double sqrt_sum(Vector2 &v) {
double sum = 0;
for (int i = 0; i != v.size(); ++i) {
sum += sqrt(v[i]);
}
return sum;
}
// CMakeLists.txt中add_executable只要加入实现类即可,换句话说不必加入.h文件
int main() {
Vector2 v(8);
v[0] = 1;
v[2] = 1;
v[10] = 1;//没有越界处理(见下方《错误处理》)
cout << sqrt_sum(v);
}
注意,CMakeLists.txt要修改,
add_executable(banner codeset/user.cpp codeset/Vector2.cpp)
配置完成以后,运行user.cpp的main函数,输出为:2
命名空间 namespace
作用:
- 表达某些声明是属于一个整体的
- 表明他们的名字不会与其他命名空间中的名字冲突
namespace Mine {
class complex {
};
complex sqrt(complex);
int main();
}
int Mine::main() {
//complex z{1, 2};// 由于没有实现complex类,所以这部分初始化器会静态报错。
auto z2 = sqrt(z);
}
int main() { // 全局命名空间,真正的main函数,执行器入口
return Mine::main();// 调用命名空间Mine下的main函数。
}
上面Vector2的命名空间的语法我们介绍了,这里再次加深理解命名空间的含义。
上面代码中也经常出现了,要想获取标准库的命名空间中的内容访问权,要使用using。
using namespace std;
命名空间主要用于组织较大规模的程序组件(架构经常使用),最典型的例子是库。使用命名空间,我们就可以很容易地把若干独立开发的部件组织成一个程序。
一个程序的组织包括:命名空间+执行器(要包含所有相关源文件cpp即可,以及基于全局命名空间的main入口函数)
错误处理
通常的应用程序在构建时,大部分都要依靠新类型(例如string,map和regex)以及算法(例如sort(),find_if()和draw_all()),这些高层次的结构简化了程序设计,减少了产生错误的机会。C++ 的设计思想一定是建立在优雅高效的抽象机制。模块化、抽象机制、库、命名空间都是C++ 程序架构的体现。
上面我们留下了一个锚,关于数组越界的问题,下面我们写一个错误处理。
首先加到自定义运算符[]的函数内,加入错误判断,并且抛出异常
double &Vector2::operator[](int i) {
if (i >= size())throw std::out_of_range("Vector2::operator[]");// std是标准库的意思,上面包含了<stdexcept>库,这里统一使用std作为命名空间。
return elem[i];
}
然后在使用该运算符的位置,利用try catch对来捕捉异常并做出异常处理
int main() {
Vector2 v(8);
v[0] = 1;
v[2] = 1;
try {
v[10] = 1;//没有越界处理(不是工程代码,还有很多待完善)
} catch (out_of_range) {
cout << "out_of_range error";
return 0;// 跳出程序
}
cout << sqrt_sum(v);
}
输出:
out_of_range error
从上面可以总结出,错误处理分三步:
- 错误判断
- 抛异常
- 错误处理
上面的错误判断以及抛异常放在类的构造函数中就是类的不定式的概念,用于检查构造函数传入的实参是否有效。
编译时错误检查:静态断言
int main() {
Vector2 v(4000);// 传入的整数为double数组的大小,但是由于Vector2中存储的只是“句柄”,这在上面已经提过了,Vector2对象的大小是永远不变的,是16。
cout << sizeof(v);
static_assert(4 <= sizeof(v), "size is too small!");
}
输出为16,当我们将静态断言的判断条件改为32时,执行以后报错,报错日志截取一部分:
/home/liuwenbin/work/CLionProjects/github.com/banner/codeset/user.cpp:32:5: error: static assertion failed: size is too small!
static_assert(32 <= sizeof(v), "size is too small!");
^
sizeof() 返回的是实参所占的字节数,例如一个char是1个字节,即sizeof(char)=1,整型int是4个字节,double是8个字节。
注意:静态断言的前置条件必须是与一个常量进行比较,比如上面就是与4还有32进行比较,如果是变量来代替确定数字的话,那么该变量必须是const类型的,不可改变的,否则会报错。
抽象机制
上面反复提到了C++ 的高效优雅的抽象机制。本章将重点介绍这部分内容,主要包括类和模板。
类
类包含具体类,抽象类,类层次(暂理解为继承实现等)中的类。
具体类型
具体类型的成员变量就是表现形式的概念
成员变量可以是一个或几个指向保存在别处的数据的指针(例如上面的Vector2 elem成员的定义是double *elem),这种成员变量也会存在于具体类的每一个对象中。
通过使用类的成员变量,它允许我们:
- 把具体类型的对象至于栈、静态分配的内存或者其他对象中。
- 直接引用对象(而非仅仅通过指针或引用)
- 创建对象后立即进行完整的初始化
- 拷贝对象
类的成员变量可以被限定为private,只能通过public的成员函数访问。
成员变量一旦发生任何改变都要重新编译,如果想提高灵活性,具体类型可以将其成员变量的主要部分放置在自由存储(动态内存、堆)中,然后通过存储在类对象内部的另一部分访问他们。
一个完整的例子,实现复数complex(简单)
#include <iostream>
namespace Mine {
class complex {
double re, im;//复数包含两个双精度浮点数。一个是实部,一个是虚部
public:
//定义三个构造函数,分别是两个实参、一个实参以及无参
complex(double r, double i) : re{r}, im{i} {
}
complex(double r) : re{r}, im{0} {
}
complex() : re{0}, im{0} {// 无参的构造函数是默认构造函数
}
// getter setter
double real() const {// 返回实部的值,const标识这个函数不会修改所调用的对象。
return re;
}
void real(double d) {// 设置实部的值
re = d;
}
double imag() const {// 返回虚部的值,const标识这个函数不会修改所调用的对象。
return im;
}
void imag(double d) {// 设置虚部的值
im = d;
}
// 定义运算符
complex &operator+(complex z) {
re += z.re;
im += z.im;
return *this;
}
complex &operator-(complex z) {
re -= z.re;
im -= z.im;
return *this;
}
// 接口,只描述方法,实现在外部的某处进行。
complex &operator*=(complex);
complex &operator/=(complex);
};
complex test();
}
Mine::complex Mine::test() {
complex z1{1, 2};
complex z2{3, 4};
return z1 + z2;
}
using namespace std;
int main() {
//complex a{1,2}; // 静态报错,这里complex的作用域是全局,而不是上面Mine中定义的那个。
cout << Mine::test().imag();
}
输出:
6
析构函数
上面我们定义的Vector2类,有一个致命缺陷(java程序员可能意识不到)就是它使用了new分配元素但却没有释放这些元素的机制。这是个糟糕的设计,所以这一节我们要引入析构函数来保证构造函数分配的内存一定会被销毁。
我们在Vector2.h头文件的类声明中:
// 加入析构函数
~Vector2() {
delete[] elem;
}
在容器类Vector2加入析构函数以后,外部的使用者无需干预,就想使用普通内置变量那样使用Vector2即可,而Vector2对象会在作用域结束处(例如右花括号)自动delete销毁对象。
几个概念。
数据句柄模型:构造函数负责分配元素空间以及初始化成员,析构函数负责释放空间,这种模型被称作数据句柄模型。
RAII,在构造函数中请求资源,析构函数释放资源,这种技术被称作资源获取即初始化,英文Resource Acquisition Is Initialization,简称RAII。
所以我们的程序设计一定要基于数据句柄模型,采用RAII技术,换句话来说,就是避免在普通代码中分配内存或释放内存,而是要把分配和释放隐藏在好的抽象的实现内部。
抽象类型
抽象类可以做真正的接口类,因为它分离接口和实现并且放弃了纯局部变量。
class Container {
public:
virtual double &operator[](int) = 0;//纯虚函数,
virtual int size() const = 0;// 常量成员
virtual ~Container() {};//析构函数
};
几个概念。
- 虚函数:有关键字virtual的函数被称为虚函数。
- 纯虚函数:虚函数还等于0的被称为纯虚函数。
- 抽象类:存在纯虚函数的类被称为抽象类。
使用Container,
void use(Container &c) {// 方法体内部完全使用了Container的方法,但是要知道目前这些方法还没有类来实现。
const int sz = c.size();
for (int i = 0; i != sz; i++) {
std::cout << c[i] << '\n';
}
}
如果一个类专门为了其他一些类来定义接口,那么我们把这个类称为多态类型,所以Container类是多态类型。
下面写一个实现类Vector3.cpp
#include "Container.h"
#include "Vector2.h"
class Vector_container : public Container {// 派生自(derived)Container,或者实现了Container接口
Vector2 v;
public:
Vector_container(int s) : v(s) {}
~Vector_container() {}
double &operator[](int i) {
return v[i];
}
int size() const {
return v.size();
}
};
几个概念,其实和其他OO语言差不多,
- Vector_container是Container的子类或派生类
- Container是Vector_container的基类或超类。
- 他们的关系就是继承。
虚函数
我们首先对Vector2.h头文件进行改造:
#include <initializer_list>
#include <algorithm>
#include <stdexcept>
class Vector2 {// 头文件中只放置类相关内容,复杂成员方法可不实现,但它与完全的抽象类作为多态类型的接口不同
private://不可以直接访问属性
double *elem;
int sz;
public: //公开的方法,通过方法与属性进行交互
Vector2(int s) : elem{new double[s]}, sz{s} {// 构造函数的实现可以写在头文件中,属于简单公用方法
}
// Vector2(std::initializer_list<double> lst) : elem{new double[lst.size()]},
// sz{lst.size()} {// 构造函数的实现可以写在头文件中,属于简单公用方法
// std::copy(lst.begin(), lst.end(), elem);
// }
double &operator[](int i) {
if (i >= size())throw std::out_of_range("Vector2::operator[]");// std是标准库的意思,上面包含了<stdexcept>库,这里统一使用std作为命名空间。
return elem[i];
}
int size() const {
return sz;
}
~Vector2() {// 加入析构函数,有实现,属于公用方法,实现方式都是一样的。
delete[] elem;
}
};
因为本章学习的方向,我们将Vector2.h头文件中对Vector2类的成员方法全部实现了。而没有使用Vector2.cpp,
总结一点:一般来讲永远都是在程序中引入别的类的头文件进行使用,而没有引用cpp文件的,,这一节知识与Vector2.cpp无关,因此这里我们对Vector2.h头文件进行丰富的道理也在这。
Vector2.h中构造函数——初始化器列表被注释掉,原因在上面的《初始化器列表》小节中有专门讲述。
然后,重新写Vector3.cpp,
//
// Created by liuwenbin on 18-4-16.
//
#include "Container.h"
#include "Vector2.h"
#include <list>
/**
* Container接口有两个实现类:Vector_container以及List_container
*/
// Vector_container类的定义
class Vector_container : public Container {// 派生自(derived)Container,或者实现了Container接口
Vector2 v;
public: // 成员方法都重用了Vector2的具体实现方法。
Vector_container(int s) : v(s) {}
~Vector_container() {}// 覆盖了基类的析构函数~Container()
double &operator[](int i) {
return v[i];
}
int size() const {
return v.size();
}
};
// List_container类的定义
class List_container : public Container {
std::list<double> ld;// 与Vector_container的成员是我们自定义的Vector2不同的是,这里的成员是采用的标准库的list。
public:
List_container() {}
// 由于标准库的list的初始化器列表实现更加高可用,所以这里可以采用初始化器列表,更加方便
List_container(std::initializer_list<double> il) : ld{il} {}
~List_container() {}
double &operator[](int i);// 没有花括号的方法体,说明这个方法在类声明期间并没有实现
int size() const { return ld.size(); }
};
// 实现操作符[]
double &List_container::operator[](int i) {
for (auto &x:ld) {
if (i == 0)return x;
--i;
}
throw std::out_of_range("List container");
}
// 接收Container接口类型对象为实参,不考虑其实现类的实现细节的通用方法。
void use(Container &c) {// 方法体内部完全使用了Container的方法,但是要知道目前这些方法还没有类来实现。
const int sz = c.size();
for (int i = 0; i != sz; i++) {
std::cout << c[i] << '\n';
}
}
void g() {
// Vector_container vc{1, 2, 3, 4, 5, 6};
Vector_container vc(3);// 使用了Vector_container
vc[1] = 1;
vc[2] = 3;
use(vc);
}
void h() {
List_container lc = {1, 2, 3};// 使用了List_container,采用初始化器列表的方式构造函数,十分方便。
use(lc);
}
// 入口函数,分别调用以上方法测试。
int main() {
g();
h();
}
输出:
0
1
3
1
2
3
具体实现细节请看代码注释,这里不再赘述。下面总结几点心得:
- 我们要完成可与标准库的list媲美的自定义类型(例如Vector2),需要很多工作要做。
- 注意保持类定义的简洁性,可将复杂抑或有个性化可能的方法实现留给派生方法去做,而在类定义中只保留公用唯一简单方法的实现。
- 派生类的很多成员方法都可以通过成员变量(例如list)的内部方法来实现。
- use方法中可以根据传入的不同Container的实现类的真实对象,来调用真实对象本身的实现方法,这是基于一个虚函数表(vtbl),每个含有虚函数的类都有它自己的vtbl用于辨识虚函数。
- 类的虚函数(接口方法)的空间开销包括:一个额外的指针,每个类都需要一个vtbl。
类层次
类层次就是通过派生创建的一组类,在框架中有序排列,比如上面的Vector3.cpp源文件中的Container基类与Vector_container以及List_container组成的一组类就形成了类层次。类层次共有两种便利:
- 接口继承,派生类对象可以用在任何需要基类对象的地方。也就是说,基类看起来像是派生类的接口一样。Container就是这样的一个例子。
- 实现继承,基类负责提供可简化派生类实现的函数和数据(例如成员属性以及已实现的构造函数)。
类层次中的成员数据有所区别,我们倾向于通过new在自由存储中为其分配空间,然后通过指针或引用访问它们。
函数返回一个指向自由存储上的对象的指针是非常危险的,因为该对象很可能消失,这个指针参与的工作就会发生错误。
千万不要滥用类继承体系,如果两个类没有任何关系(例如工具类),那么将他们独立开来,我们在使用的时候可以自由组合,而不必因为共同继承或实现了一个基类而焦头烂额。
拷贝和移动
当我们设计一个类时,必须仔细考虑对象是否会被拷贝以及如何拷贝的问题。
逐成员的复制,意思就是遍历类的成员按顺序复制的方法。这种方法在简单的具体类型中会更符合拷贝操作的本来语义。但是在复杂具体类型以及抽象类型中,逐成员复制常常是不正确的。
原因是涉及得到指针的成员的类,在拷贝操作中,很可能复制出来的只是对真实数据的指针或引用,而并没有对真实数据进行拷贝一份副本。这就是问题所在。
拷贝容器
资源句柄(resource handle),当一个类负责通过指针访问一个对象时,这个类就是作为资源句柄的存在。
我们在Vector2.h头文件中先声明一个执行拷贝操作的构造函数
Vector2(const Vector2 &a);// 拷贝操作
然后在Vector3.cpp中实现以上操作:
// 实现Vector2.h头文件中拷贝操作
// 先用初始化器按照实参原对象的大小将内存空间分配出来。
Vector2::Vector2(const Vector2 &a) : elem{new double[sz]}, sz{a.sz} {
// 执行数据的复制操作
for (int i = 0; i != sz; i++)
elem[i] = a.elem[i];
}
移动容器
移动构造函数,执行从函数中移出返回值的任务。我们继续在Vector2.h头文件中先声明一个执行移动操作的构造函数
Vector2(Vector2 &&a);// 移动操作
然后在Vector3.cpp中实现以上操作:
// 实现Vector2.h头文件中的移动构造函数
// 先用初始化器按照实参原对象的大小将数据移到新对象中。
Vector2::Vector2(Vector2 &&a) : elem{a.elem}, sz{a.sz} {
// 清除a的数据
a.elem = nullptr;
a.sz = 0;
}
&:引用
&&:右值引用,我们可以给该引用绑定一个右值,大致意思是我们无法为其赋值的值,比如函数调用返回一个整数。
换句话讲,右值引用的含义就是引用了一个别人无法赋值的内容。
几点注意:
- 该移动构造函数不接受const实参,毕竟移动构造函数最终要删除掉它实参的值。
- 我们也可以根据这个思想构建移动赋值运算符。
- 移动操作完成以后,源对象所进入的状态应该能允许运行析构函数。通常,我们也应该允许为一个移动操作后的源对象赋值。
以值的方式返回容器(依赖于移动操作以提高效率)。
资源管理
通过以上的构造函数、拷贝操作、移动操作以及析构函数,程序员就能对受控资源的全生命周期进行管理。例如标准库的thread和含有百万个double的Vector,前者不能执行拷贝操作,后者我们不希望拷贝它(成本太高)。在很多情况下,用资源句柄比用指针效果好,就像替换掉程序中的new和delete一样,我们也可以把指针转化为资源句柄。在这两种情况下,都将得到简单也更易维护的代码,而且没有额外的开销。特别是我们能实现强资源安全(strong resource safety)不要泄露任何你认为是资源的东西,换句话说,对于一般概念上的资源,这种方法都可以消除资源泄露。
抑制操作
对于层次类来讲,使用默认的拷贝和移动构造函数都意味着风险:因为只给出一个基类的指针,我们无法了解派生类有什么样的成员,当然也不知道该如何操作他们。因此,最好的做法是删除掉默认的拷贝和移动操作,也就是说,我们应该尽量避免使用这两个操作的默认定义。
模板
一个模板就是一个类或一个函数,但需要我们用一组类型或值对其进行参数化。我们使用模板表示那些通用的概念,然后通过指定实参(比如指定元素的类型为double)生成特定的类型或函数。(C++ 中一个高大上的知识)
参数化类型
#include <initializer_list>
#include <algorithm>
#include <stdexcept>
template<typename T>
class VecTemp {// 头文件中只放置类相关内容,复杂成员方法可不实现,但它与完全的抽象类作为多态类型的接口不同
private://不可以直接访问属性
T *elem;
int sz;
public: //公开的方法,通过方法与属性进行交互
VecTemp(int s) : elem{new T[s]}, sz{s} {// 构造函数的实现可以写在头文件中,属于简单公用方法
}
double &operator[](int i) {
if (i >= size())throw std::out_of_range("VecTemp::operator[]");// std是标准库的意思,上面包含了<stdexcept>库,这里统一使用std作为命名空间。
return elem[i];
}
int size() const {
return sz;
}
~VecTemp() {// 加入析构函数,有实现,属于公用方法,实现方式都是一样的。
delete[] elem;
}
VecTemp(const VecTemp &a);// 拷贝操作
VecTemp(VecTemp &&a);// 移动构造函数
};
我新建一个类VecTemp,将Vector2的内容复制了进来,同时修改了所有类名为统一的VecTemp,接着在类声明之上加入了template关键字,同时加入以单尖括号的形式的typename T,最后修改类代码中的所有具体类型的double为T。我们对Vector2的类型参数化改造就完成了,这就是一个模板,我们在外部不仅可以传入double类型的,任何其他内置类型甚至自定义类型都可被支持。这个理念与java中的泛型是一致的,感兴趣的朋友可以参考一下我的另一篇博文《大师的小玩具——泛型精解》
使用容器保存同类型值的集合,将其定义为资源管理模板。
函数模板
上面我们用T来泛型了所有的数据类型,下面我们也可以使用基类或者超类来定义整个其派生类均适用的函数。
template<typename Container, typename Value>
Value sum(const Container &c, Value v) {
for (auto x:c)
v += x;
return v;
};
以上这个程序可以计算任意容器中的元素的和。
使用函数模板来表示通用的算法。
函数对象
模板的一个特殊用途是函数对象,有时也称为函子functor。我们可以像调用函数一样调用对象。下面定义一个模板,可以自动比较大小
template<typename T>
class Less_than {
const T val;
public:
Less_than(const T &v) : val(v) {}
bool operator()(const T &x) const {
return x < val;
}
};
下面是该模板的使用:
int main() {
Less_than<int> lti{19};
Less_than<std::string> lts{"hello"};
std::cout << lti(2);
std::cout << lti(50);
std::cout << lts("world");
}
输出:100
说明2比19小为true,输出1,50比19小为flase,输出0。
C++ 的布尔值true为1,false为0。
函数对象val,精妙之处在于他们随身携带着准备与之比较的值,我们无须为每个值(或每种类型)单独编写函数,更不必把值保存在让人厌倦的全局变量中。同时,像Less_than这样的简单函数对象很容易内联,因此调用Less_than比间接调用更有效率。正是因为函数对象具有可携带数据和高效这两个特性,我们经常用其作为算法的实参。
lambda表达式
[&](int a){return a<x;} 这种语法被称为Lambda表达式,它生成一个函数对象,就像less_than
- 如果我们希望只“捕获”x,则可以写成[&x];
- 如果希望给生成的函数对象传递一个x的拷贝,则写成[=x];
- 什么也不捕获,是[];
- 捕获所有通过引用访问的局部名字是[&];
- 捕获所有以值访问的局部名字是[=];
使用Lambda虽然简单便捷,但也有可能稍显晦涩难懂。对于复杂的操作(不是简单的一条表达式),我们更愿意给该操作起个名字,以便更加清晰地表述他们的目的并且在程序中随处使用它。
使用函数对象表示策略和动作。
可变参数模板
定义模板时可以令其接受任意数量任意类型的实参,这样的模板称为可变参数模板。
template<typename T, typename... Tail>
void f(T head, Tail... tail) {
//对head做事
f(tail...);
};
void f() {}
省略号... 表示列表的“剩余部分”
别名
很多情况下,我们应该为类型或模板引入一个同义词,例如标准库头文件<cstddef>包含别名size_t的定义:
using size_t = unsigned int;
其中,size_t的实际类型依赖于具体实现,使用size_t,程序员可以写出易于移植的代码。
template<typename Key, typename Value>
class Map {
//...
};
template<typename Value>
using String_map = Map<std::string, Value>;// 使用别名String_map
String_map<int> m;//m是一个Map<string,int>
事实上,每个标准库容器都提供了value_type作为其值类型的名字(别名),这样我们编写的代码就能在任何一个服从这种规范的容器上工作了。
使用类型别名和模板别名为相似类型或可能在实现中变化的类型提供统一的符号。
容器与算法
字符串
//
// Created by liuwenbin on 18-4-18.
//
#include <string>
#include <iostream>
using namespace std;
string name = "Tracy Mcgrady";
void m3() {
string s = name.substr(6, 7);
cout << s << "\n";
cout << name << "\n";
name.replace(0, 5, "Hashs");
cout << name << "\n";
name[0] = tolower(name[0]);
cout << name << "\n";
};
int main() {
m3();
}
输出:
Mcgrady
Tracy Mcgrady
Hashs Mcgrady
hashs Mcgrady
以上代码练习了简单的字符串相关的操作。
IO流
输入
istream库,cin关键字。上面介绍过。iostream具有类型敏感、类型安全和可扩展等特点。
输出
ostream库,一般来讲,我们会直接引入iostream,输入输出都包含了,省事。cout关键字,上面介绍过。
自定义IO
//
// Created by liuwenbin on 18-4-18.
//
#include <string>
#include <iostream>
using namespace std;
struct Entry {
string name;
int number;
};
// 输出比较好实现,就是相当于拼串
ostream &operator<<(ostream &os, const Entry &e) {
return os << "{\"" << e.name << "\"," << e.number << "}";
}
//
//// 输入要检查很多格式,所以比较复杂
//istream &operator>>(istream &is, Entry &e) {
// char c, c2;
// if (is >> c && c == '{') {// 以一个{开始,
// string name;// 收集name的信息
// while (is.get(c) && c != ',') {
// name += c;
// }
// if (is >> c && c == ',') {// 以,间隔,开始收集number的信息
// int number = 0;
// if (is >> number >> c && c == '}') {// 直到遇到}结束
// e = {name, number};
// return is;
// }
// }
// }
// is.setf((ios_base::fmtflags) ios_base::failbit);
// return is;
//}
int main() {
Entry ee{"John Holdwhere", 3421};
cout << ee << "\n";
}
输出:
{"John Holdwhere",3421}
我们定义了一个结构Entry,格式是{"John Holdwhere",3421}这种。然后我们定义了输出操作符<<,内部实现就是针对Entry的两个元素进行拼串(相当于Java中的toString())。重写输入操作符有点问题,这里不展开讨论了。
容器
如果一个类的主要目的是保存一些对象,那么我们统一称之为容器(注意与普通类以及结构区分)。注意,上面讲到的模板泛型T[]数组,不如使用vector<T>,map<K,T>,unordered_map<K,T>。
vector
一个vector就是一个给定类型元素的序列(vector是带边界检查的,可以自动处理越界问题),元素在内存中是连续存储的。我们在上面已经实现了基于泛型的vector<T>容器,该容器可以存储不同类型的对象的集合。以后可以直接使用标准库的vector即可。
vector<int> v1{1, 2, 3, 4};
vector<Entry> en2{{"John Holdwhere", 3421},
{"John Holdwhere", 3421},
{"John Holdwhere", 3421}};
cout << v1[2];
cout << en2[2].number;
输出:33421
list
我们还是直接使用标准库的list,这是一个双向链表。
如果我们希望在一个序列中添加和删除元素的同时无须移动其他元素,则应该使用list。换句话说,对于有大量添加删除操作的需求,采用list容器比较合适。
list<int> i2{1, 2, 3, 4};
//list<int>::iterator p; //会中止SIGSEGV,不报错
//i2.insert(p, 2);
for (auto x:i2)
cout << x;
TODO: SIGSEGV中止信号
上面的例子都可以用vector来代替,除非你有更充分的理由,否则就应该使用vector。vector无论是遍历(如find()和count())性能还是排序和搜索(如sort()和binary_search())性能都优于list。
map
当出现大量特定结构{Key,Value}的数据时,我们希望通过Key来查找Value,以上容器都是很低效的实现。因此有了map,它通过key来高速查找value是基于搜索树(红黑树)【Knowledge_SPA——精研查找算法】。map有时也被称为关联数组或字典,map通常用平衡二叉树来实现。
map<string, int> m1{{"John Holdwhere", 3421},
{"AKA", 991},
{"FYke", 0110}};
cout << m1.size() << endl;
cout << m1.at("AKA") << endl;
cout << m1["FYke"] << endl;
输出:
3
991
72
map的应用与其他语言差不多,注意要使用标准库的而不是自己实现一套即可。
unordered_map
map的搜索时间复杂度是O(log(n)),它是必须遍历一遍所有的元素才能找到指定Key的值。试想如果样本扩大到100万,我们只想20次通过比较或者间接寻址的方式查出需要的元素,这就是基于哈希查找,(恶补一下吧【Knowledge_SPA——精研查找算法】),而不是通过比较操作。标准库的unordered_map容器就是无序容器。它的操作与map基本一致,但根据特定场景,性能突出很明显。
TODO: unordered_map使用场景,性能测试。
以上介绍了一些常见容器,除此之外,标准库还有deque<T>,set<T>,multiset<T>,multimap<K,V>,unordered_multimap<K,V>,unordered_set<K,V>,unordered_multiset<T>。注意所有带unordered的都是无序容器,他们都针对搜索进行了优化,是通过哈希表来实现的。
一些针对所有容器的基本操作:
- begin(),end()获取首位元素和尾元素
- push_back()可用来高效地向vector、list及其他容器的末尾添加元素。
- size()返回元素数目。
- 下标操作,get元素,at等待
最后总结,推荐标准库vector作为存储元素序列的默认类型,只有当你的理由足够充分时,再考虑其他容器。
算法
针对容器的操作,除了上面列举的一些简单操作,还会有排序、打印、抽取子集、删除元素以及搜索等更复杂的操作,因此,标准库除了提供容器以外,还为这些容器提供了算法。
迭代器
标准库算法find在一个序列中查找一个值,返回的结果是指向找到的元素的迭代器。
bool has_c(const string &s, char c) {
auto p = find(s.begin(), s.end(), c);
if (p != s.end()) {// 如果找不到,返回的是end();
return true;
} else {
return false;
}
}
调用以上函数
string name = "YANSUI";
cout << has_c(name, 'Y') << endl;
cout << has_c(name, 'O') << endl;
输出:
1
0
包含Y为true输出1,不包含O为false,输出0。has_c函数的短版写法:
bool has_c(const string &s, char c) { return find(s.begin(), s.end(), c) != s.end();}
下面在字符串中查找一个字符出现的所有位置。我们返回一个string迭代器的vector。
vector<string::iterator> find_all(string &s, char c) {
vector<string::iterator> res;
for (auto p = s.begin(); p != s.end(); ++p) {
if (*p == c) {// 找到位置相同的元素了
res.push_back(p);
}
}
return res;
}
void findall_test() {
string m{"Mary had a little lamb"};
for (auto p:find_all(m, 'a')) {
if (*p != 'x') {
cerr << "a bug" << endl;// 如果是'a bug'会自动转为char,是个很大的整数值。
}
}
}
int main() {
string name = "YANSUYI";
// cout << has_c(name, 'Y') << endl;
// cout << has_c(name, 'O') << endl;
for (auto p:find_all(name, 'Y')) {
cout << &p << endl;
}
// cout << find_all(name, 'O').size() << endl;
findall_test();
}
输出:
a bug
0x7ffd98991f90
a bug
0x7ffd98991f90
a bug
a bug
以上算法都可以引入模板(即泛型)设计成不计数据特定类型的通用方法。
注意:上面的main函数中的迭代器遍历输出时,我们改成这样:
cout << *p << endl;//cout标准输出默认是通过<< 传入一个值的位置(指针),然后输出这个值的内容。
cout << &p << endl;//这里的p是一个对象,它本身是存着一个值的位置,使用引用以后,打印的是这个位置本身的值。
输出结果为:
Y
0x7ffec22899f0
Y
0x7ffec22899f0
引用是变量位置本身的值,指针是变量位置。标准输出是通过运算符<<传入一个位置,输出它的值。由于p本身是位置,所以输出p的引用就直接打印除了内存位置字符串,而输出p的指针就是打印出来这个位置存的值。
predicate
查找满足特定要求的元素的问题,可以通过predicate方法来解决。
struct Greater_than {
int val;
Greater_than(int v) : val{v} {};
// 通过pair来配对map的搜索,pair是专门用来做predicate操作而存在的。
bool operator()(const pair<string, int> &r) { return r.second > val; };
};
void f(map<string, int> &m) {
auto p = find_if(m.begin(), m.end(), Greater_than{42});
//...
}
神奇的Lambda来写是这样:
map<string, int> m1{{"YANSUI", 55},
{"AKA", 991},
{"FYke", 0110}};
int cxx = count_if(m1.begin(), m1.end(), [](const pair<string, int> &r) { return r.second > 42; });// Lambda表达式,完美替代以上Greater_than和void f()方法。
cout << cxx;
输出:3
容器算法
算法的一般性定义:
算法就是一个对元素序列进行操作的函数模板。
标准库提供了很多算法,它们都在头文件<algorithm>中且属于命名空间std。下面与容器一样,我们列举一下其他常见的算法:find,find_if,count,count_if,replace,replace_if,copy,copy_if,unique_copy,sort,equal_range,merge。以后慢慢熟悉。
排序算法
vector<int> v23{122, 8, 42};
sort(v23.begin(), v23.end());
for (auto &x:v23) {
cout << x << endl;
}
输出:
8
42
122
并发
再次重申标准库关注的是所有需求的交集而不是并集。此外,标准库也在一些特别重要的应用领域(如数学计算和文本操作等)提供支持。
并发:
- 提高吞吐率(用多个处理器共同完成单个运算)
- 提高相应速度(允许程序的一部分在等待响应时,另一部分继续执行)
C++提供了一个适合的内存模型和一套原子操作用来支持并发。下面要提及的有thread,mutex,lock(),packaged_task,future,这些特性直接建立在操作系统并发机制之上,与系统原始机制相比,这些特性并不会带来额外的性能开销,当然也不保证性能有显著提升(巧妇难为无米之炊)。
资源管理
资源是指程序中符合先获取后释放(显式或隐式)规律的东西,比如内存、锁、套接字、线程句柄和文件句柄等。
资源管理就是对以上资源的及时释放的处理。这部分内容我们在容器章节已有所领悟,一个标准库的组件不会出现资源泄露的问题,因为设计者使用成对的构造函数和析构函数等基本语言特性来管理资源,确保资源依存于其所属的对象,而不会超过对象的生命周期。这里再次提及RAII(使用资源句柄管理资源)。
智能指针:unique_ptr与shared_ptr
- unique_ptr:对应所有权唯一的情况,用它来访问多态类型对象(可以直接垃圾管理到原始位置,不会造成资源泄露的情况)。
- shared_ptr:对应所有权共享的情况。
这些智能指针最基本的作用是防止由于编程疏忽而造成的内存泄露。
void f(int i, int j) {
vector<int> *p = new vector<int>;// new是分配内存空间,所以要用指针类型变量来接收
unique_ptr<vector<int>> sp{new vector<int>};
// p和sp的区别就是,如果在下面操作中发生异常中止或者直接返回,p会执行不了delete,而因为sp是unique_ptr指针分配的,所以会保证在程序中止时释放掉sp的资源。
if (i < 77) {
return;
}
// 释放普通指针变量的资源。
delete p;
}
int main() {
f(1, 1);
// 其实我们完全不必要使用new,以及指针和引用,因为很容易不小心就变成了滥用。
vector<int> p;
p = {1, 2};
cout << p[1];
}
所以,
我们的目标是,尽量不适用new,不适用指针和引用,多使用标准库来写高可用代码,如果实在需要使用指针,那么更够保证自己释放资源的unique_ptr。
不要有错觉,unique_ptr指针并不比普通指针消耗时空代价更大。
通过unique_ptr,我们还可以把自由存储上申请的对象传递给函数或者从函数中传出来。
就像vector是对象序列的句柄一样(本身大小是固定的,因为它只是一个指向真正存储空间的地址数据),unique_ptr是一个独立对象或数组的句柄。他们都是以RAII机制控制其他对象的生命周期,并且都通过移动操作使得return语句简单高效。
shared_ptr
shared_ptr在很多方面与unique_ptr非常相似。唯一的区别是shared_ptrd的对象使用拷贝操作而非移动操作。什么意思?shared_ptr是共享的意思,所以不能操作源文件,必须通过复制多份分发出来多个shared_ptr共享该对象的所有权。
只有在最后一个shared_ptr被销毁时才会销毁源对象。而unique_ptr是唯一所有权,它销毁了原对象也会跟着销毁。
而这句话的另一个意思就是shared_ptr的垃圾回收机制很不稳妥,因为程序可能对多份的shared_ptr管理失控,就会造成原对象永远不被销毁的情况,所以与析构函数相比,shared_ptr的垃圾回收需要慎重使用。除非你确实需要共享所有权,否则不用轻易使用shared_ptr。
而且,shared_ptr还有一个问题就是它本身并没有指定任何规则用以指明共享指针的哪个拥有者有权读写对象。因此尽管在一定程度上解决了资源管理问题,但是数据竞争和其他形式的数据混淆依然存在(这是很可怕的)。
任务和线程
几个概念:
- 任务,task,是指那些可以与其他计算并行执行的计算。
- 线程,thread,是任务在程序中的系统级标表示。
//
// Created by liuwenbin on 18-4-19.
//
#include <thread>
#include <iostream>
#include <string>
using namespace std;
// The function we want to execute on the new thread.
void task1(string msg) {
cout << "task1: " << msg << endl;
}
void task2(int i) {
cout << " task2:" << i << " Hello" << endl;
};
struct F {
void operator()() {
cout << " F():" << "Parallel World!" << endl;
};// 调用运算符()
};
int main() {
thread t1(task1, "Hello");// 函数作为task
thread t2(task2, 2);
thread t3{F()};//函数对象task
cout << "Concurrency has started!" << endl;
t1.join();// join=等待线程结束
t2.join();
t3.join();
cout << "Concurrency completed!" << endl;
}
到这还需要配置一下CMakeList.txt,才能继续执行:
set(CMAKE_CXX_FLAGS -pthread)
加入线程支持以后,以上程序才可以正常运行了。下面是第一次输出:
task1: HelloConcurrency has started!
F():Parallel World!
task2:2 Hello
Concurrency completed!
第二次输出:
task1: F():Hello task2:2 Hello
Parallel World!
Concurrency has started!
Concurrency completed!
可以看出,cout输出的内容是不可控的,这正是多线程自然执行的结果。
一个程序中所有线程共享单一地址空间。在这一点上线程与进程不同,进程间通常不直接共享数据。由于共享单一地址空间,因此线程间可通过共享对象相互通信。通常通过锁或其他防止数据竞争(对变量的不受控制的并发访问)的机制来控制线程间通信。
任务本身应该是完全隔离的,自主利用资源执行,但是任务间的通信应该以一种简单而明显的方式进行。
思考一个并发任务的最简单的方式是:
把它看做一个可以与调用者并发执行的函数。
为此,我们只需要传递实参、获取结果并保证两者不会同时使用共享数据(不存在数据竞争)即可。
传递参数
//
// Created by liuwenbin on 18-4-19.
//
#include <vector>
#include <thread>
using namespace std;
void f(vector<double> &v){};//声明一个函数f
struct FF {
vector<double> &v;// 以成员的方式保存了一个向量(vector是指向一个参数,变量都使用引用&)
FF(vector<double> &vv) : v{vv} {}// 这里的单冒号是指使用成员。
void operator()(){};
};
int main() {
vector<double> some_vec{1, 2, 3, 4, 5, 6};
vector<double> vec2{10, 11, 12, 13, 14, 15};
thread t1{f, ref(some_vec)};// thread的可变参数模板构造函数。using the reference wraper with thread
thread t2{FF{vec2}};// 以值传递的方式可保证其他线程不会访问vec2【因为值传递是复制一份值传递过去而不会修改vec2本身。】
t1.join();
t2.join();
}
成功执行。
注意:undefined reference to 'XXX' 错误都是因为使用了一个没有被实现的接口。所以将上面的方法声明都加入花括号空实现也可以。
编译器检查第一个参数(函数或函数对象)是否可用后续的参数来调用,如果检查通过,就构造一个必要的函数对象并传递给线程。因此FF和f执行相同的算法,任务的处理大致相同:
他们都为thread构造了一个函数对象来执行任务。
返回结果
在上面的例子中,是通过一个非const引用向线程中传递参数。只有当希望任务有权修改引用所引的数据时,才会这么做。而正规的一般的做法是:
将输入数据以const引用的方式传递,并将保存结果的内存地址作为第二个参数传递给线程。
//
// Created by liuwenbin on 18-4-19.
//
#include <vector>
#include <iostream>
#include <thread>
using namespace std;
void f(const vector<double> &v, double *res) {};// 从v获取输入,将结果放入*res
class F {
public:
F(const vector<double> &vv, double *p) : v{vv}, res{p} {}
void operator()() {};// 将结果放入*res
private:
const vector<double> &v;// 输入源
double *res;//输出目标
};
int main() {
vector<double> some_vec;
vector<double> vec2;
double res1;
double res2;
thread t1{f, some_vec, &res1};// f(some_vec,&res1)在一个独立线程中执行
thread t2{F{vec2, &res2}};// 相当于F{vec2,&res2}(),在一个独立线程中执行
t1.join();
t2.join();
cout << res1 << ' ' << res2 << '\n';
}
输出:
3.12431e-317 2.07441e-317
通过参数返回结果也不一定是很优雅的方法,所以C++ 的魅力就在于实现不定式(虽然C++ 的类有不变式的概念,呵啊呵)。
共享数据
在多个任务中,同时访问数据是很常见的同步需求,然而如果数据是不变的,所有任务来查看这是没问题的,除此之外,我们要确保在同一时刻至多有且有一个任务可以访问给定的对象。
我们会采用互斥对象mutex来解决这个问题。thread使用lock()操作来获取一个互斥对象:
#include <mutex>
using namespace std;
mutex m;//控制共享数据访问的mutex
int sh;// 共享的数据(模拟)
void f() {
unique_lock<mutex> lck{m};//获取mutex
sh += 7;// 处理共享数据(修改数据,不仅仅是查看)
}// 隐式释放mutex
流程:
- unique_lock的构造函数获取了互斥对象(通过调用m.lock())。
- 一个线程已经获取了互斥对象,则其他线程会等待(进入阻塞状态)。
- 当前线程完成对共享数据的访问,unique_lock会释放mutex(通过调用m.unlock())。
死锁
当thread1获取了mutex1然后试图获取mutex2,而同时thread2获取了mutex2然后试图获取mutex1。(两个线程掐上了正好)这就是死锁。
思考
这一节基于mutex上锁解锁的共享数据机制,实际上并不比参数拷贝和结果返回好,现代计算机的效率已经很高,可能的话,使用后者吧而不是轻易上锁。
condition_variable
通过外部事件实现线程间通信的基本方法是使用condition_variable,它提供了一种机制:
允许一个thread等待另一个threa。特别是,它允许一个thread等待某个条件(condition,通常称为一个事件,event)发生,这种条件通常是其他thread完成工作产生的结果。
//
// Created by liuwenbin on 18-4-19.
//
#include <queue>
#include <condition_variable>
#include <mutex>
#include <thread>
#include <chrono>
using namespace std;
class Message {// 通信对象
};
queue<Message> mqueue;// 消息队列
condition_variable mcond;//通信用的条件变量
mutex mmutex;// 锁机制
/**
* 消费者
*/
void consumer() {
while (true) {//无限循环
unique_lock<mutex> lck{mmutex};//获取mmutex锁:上锁
while (mcond.wait_for(lck, chrono::milliseconds{20}, true)) {//释放lck并等待20毫秒
}
// 被唤醒后重新获取lck
auto m = mqueue.front();//获取消息
mqueue.pop();// 从队列中弹出消息
lck.unlock();// 释放lck
}
}
/*
* 生产者
*/
void producer() {
while (true) {
Message m;
//...处理
unique_lock<mutex> lck{mmutex};//保护队列上的操作:上锁
mqueue.push(m);// 队列压入信息对象
mcond.notify_one(); // 通知
// 释放锁(在作用域结束)
}
}
生产者和消费者是并发线程,消费者若先获得锁,然后通过condition_variable解锁,释放对共享对象mqueue的所有权,停留在阻塞状态。同时,生成者会获得该所有权然后生产数据存入对象mqueue,同时通过与消费者同一个condition_variable对象的通知notify函数,告诉消费者阻塞位置“我已生产好了,你用吧!”,消费者会被唤醒,重新获得锁,然后可以对共享对象mqueue进行访问处理。
不过还是那句话,共享数据一定要用么?大部分时间我们直接使用拷贝参数和结果返回的形式了。除非很必要的必须维护同一个数据对象的时候,那就可以考虑condition_variable来做。
任务通信
上面一直在将线程通信的范畴,我们讨论了共享数据的方式,多线程并发的模型,上锁解锁的机制等。然而标准库其实提供了一些特性,允许程序员在抽象的任务层(工作并发执行)进行操作,而不是在底层的线程和锁的层次直接进行操作。有三种机制:future和promise,packaged_task, async()。
future和promise
用来从一个独立线程上创建出的任务返回结果。他们允许在两个任务间传输值,而无须显式使用锁,高效地实现多线程间传输。
基本思路: 当一个任务需要向另一个任务传输某个值时,它把值放入promise中。具体的C++ 实现以自己的方式令这个值出现在对应的future中,然后就可以从其中读到这个值了。
通过这个图,可以有效地理解future-promise流程。
packaged_task
消费者的future和生产者的promise,如何引入?
packaged_task 就是标准库提供用来简化future和promise设置的:
- 它提供了一层包装代码,负责把某个任务的返回值或异常放入一个promise中。
- 如果get_future()向一个packaged_task发出请求,它会返回给你对应的promise和future。
//
// Created by liuwenbin on 18-4-19.
//
#include <vector>
#include <numeric>
#include <functional>
#include <iostream>
#include <future>
using namespace std;
double accum(double *beg, double *end, double init) {
// 注意:accumulate在库<numeric>中。
return accumulate(beg, end, init);// 计算(beg:end)中元素的和,结果的原始值是init,如果init为10,那么无论beg,end啥样,结果要先加10。
}
void comp2() {
vector<double> v{33, 10, 123, 1, 3};
// using 别名关键字。可以全局命名空间,也可以定义一个结构的别名:任何作用域内提到别名的时候就可以用它的定义代替。
using Task_type = double(double *, double *, double);
packaged_task<Task_type> pt0{accum};// 封装了promise和future,通过别名的结构打包任务
packaged_task<Task_type> pt1{accum};
future<double> f0{pt0.get_future()};// 获取pt0的future,现在我们有一个future对象f0了。
future<double> f1{pt1.get_future()};
double *first = &v[0];// 找到起始位置。
// 为pt0启动一个线程
thread t1{move(pt0), first, first + v.size() / 2, 0};//packaged_task不能被拷贝,所以要使用移动move()操作。
// 为pt1启动一个线程
thread t2{move(pt1), first + v.size() / 2, first + v.size(), 0};
cout << f0.get() << endl;
cout << f1.get() << endl;
// 别忘记加join,等待执行完毕在关闭总程序。
t1.join();
t2.join();
}
int main() {
comp2();
}
输出:
43
127
具体操作看注释,总结一下,packaged_task让我们不必显式地调用锁,同时有可以在线程间进行数据通信。
以上代码的意思:5个数,算他们的和,为了更高效率,我们用两个线程,第一个算前两个数的和,第二个线程算后三个数的和,这两个线程并发,然后通过join等待他们并发结束,主线程再针对两个线程返回的结果相加获得最终的结果。这就通过多线程机制让工作变得更加高效,也提升了对系统资源的利用率。
async()
整个这一章并发,多线程的实现思路是:将任务当做可以与其他任务并发执行的函数来处理。这是简单又强大的。
如果需要启动可异步运行的任务,可使用async()。
void comp3(vector<double> &v) {
double *first = &v[0];// 找到起始位置。
auto v0 = &v[0];
auto sz = v.size();
auto f0 = async(accum, v0, v0 + sz / 2, 0);// 先算前一半
auto f1 = async(accum, v0 + sz / 2, v0 + sz, 0);// 再算后一半
//通过async异步执行,不必再操心线程和锁,只考虑可能异步执行的任务拆分即可。
cout << f0.get() << endl;
cout << f1.get() << endl;
}
int main() {
vector<double> v{33, 10, 123, 1, 3};
comp3(v);
}
输出:
43
127
限制:不要试图对共享资源且需要用锁机制的任务使用async(),实际上thread全都是封装在async内部,是由async来决定的,它根据它所了解的调用发生时,系统可用资源量来确定使用多少个thread。例如async会先检查有多少可用核(处理器)再确定启动多少个thread。
请注意,async异步强调的是不占用主线程,可以另外开启一个线程异步操作,而让主程序继续执行。
这一部分我们彻底放下了锁机制和线程的易碎体质,使用了标准库提供的更好的多线程实现工具。所以在程序设计时要从并发执行任务的角度,而不是直接从thread的角度思考。
实用工具
以上基本把C++ 所有的知识大概捋了一遍,而除了上面介绍到的标准库组件,标准库还提供了一些不太显眼但应用非常广泛的小工具组件:
- clock和duration,属于
库。通过统计程序运行时间,是获得性能表现最好的指标。 - iterator_traits和is_arithmetic类型函数,用于获取关于类型的信息。(类型函数,指在编译时求值的函数,它接受一个类型作为实参或者返回一个类型作为结果。)
- pair和tuple,用于标识规模较小且由异构数据组成的集合。
库定义了正则表达式相关的支持内容,用来简化模式匹配的任务。关于正则,请看《正则表达式》 - 还有就是上面提到过的一些专门领域的支持,例如数学运算:复数、随机数,算法、向量算术等内容,注意我们不要重复造轮子,优先使用这些库而不是语言本身去自创。
- 可以使用numeric_limits访问数值类型的属性,例如float的最高阶,int所占的字节数等。
总结
本文长篇大论,实际上都是C++ 最入门的知识,我们可以直接去查标准库或其他优秀库boost等,但若要真的掌握一门语言,在开始查找以前,从头到尾了解清楚这门语言是什么,它的设计思想,它都涵盖了哪些内容,这是非常重要的。所以本文从C++ 的设计思想开始,总结了Java程序员学习C++ 应该发扬和规避的一些问题,然后具体介绍了C++ 的基础知识,抽象机制,容器,算法,并发以及其他一些实用工具。其中涉及到的代码演练部分,都经过本地编译环境的测试,语言本身的关键字也都有所介绍。最后,想真的掌握C++,要去理解语言本身的设计思想而不必硬扣细节特性。之后,我们会进入C++ 优秀项目的源码学习,在这个阶段,我们将丢下身上的书生气,切实地应用工业级代码规范,去熟悉更多优秀库的使用。
参考资料
The C++ Programming Language, Fourth Edition. Bjarne Stroustrup.
源码请参照玉如意的github
更多文章请转到醒者呆的博客园。
我的博客即将搬运同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=7591a70cxj4z