参考的代码库是boot-camp

References and Move Semantics


主要讲了引用和移动操作。
引用就是数据的别名,和该数据的变量指向同一块地址。重头戏是这里的移动(Move)

LValue、RValue

在说移动之前先解释一下左值引用(Lvalue)右值引用(Rvalue)。左值引用是一个变量,可以有具体的指针,是一个变量,可以被具体存储。右值引用是暂时变量的引用,它代表的是数据本身。
它俩的区别在于,如果一个左值对另一个左值赋值,一般的效果是浅拷贝。但是一个左值对另一个右值赋值,就相当于把这个右值的数据的所有权给了这个右值。
从结果上看,浅拷贝就是一般的浅拷贝,而给所有权是如果这个右值原来有左值,那么原先的左值就不再指向这个右值数据(因为它的所有权给了新的左值)

Move

上述我们提到了所有权的转移,这就是移动语义。也就是说,我们不再是复制,而是把数据的所有权移动到了新的左值。然后有一个move方法,需要在<utility>库里引入,作用是将左值强制转变成右值引用。

没有了,大概?

我们可以说明一下这里文件里给的示例。对于赋值除了一般的浅拷贝的复制赋值,还有一种的移动赋值。上述的给左值赋右值,调用的是移动赋值。
深入下去就是Class &operator =(Class && myclass),对于一个自定义类,会有默认的赋值操作(复制、移动),而如何重载就是参数类型的区别,一个是传入左值,复制赋值;另一个是传入右值引用,移动赋值。
然后对于传入右值类型的function。虽然传入右值,但是在函数里这个右值会被当成左值使用(但是编译器知道它是作为右值传入),想要在函数里将这个右值赋给左值还需要用move来切换。
这里传入右值引用是为了告诉编译器我这个资源是右值引用,它很可能会在函数里被移动。如果你传入的是左值,那就几乎可以确认这个左值不会被影响。而传入右值就意味着是想移动它。

C++ Templates


主要讲的是模板。模板这个东西其实是函数和类通用的,两者用法差不多,这里就直接讲概念就好
讲了三种模板的使用:

  • 一般的模板使用,比如单个模板:
1
2
3
4
5
6
7
8
template<typename T>
void print(T a){
cout<<a<<endl;
}
int main(){
print<int>(10);
return 0;
}

也可以是多个模板:

1
2
3
4
5
6
7
8
template<typename T,typename U>
void print2(T a,U b){
cout<<a<<" "<<b<<endl;
}
int main(){
print<int,double>(10,2.5);
return 0;
}
  • 空模板,也叫偏特化,可以对使用了模板的类或者函数,加上一个对某个特殊类型的模板的特化版本,就相当于是以模板类型为区分的一种重载:
1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void print(T a){
cout<<a<<endl;
}
template<>
void print<float>(float a){
cout<<"this is float version:"<<a<<endl;
}
int main(){
print<int>(10);
print<float>(3.34f);
return 0;
}
  • 还有就是将模板作为参数,可以传入常值模板,用于构建函数本身:
1
2
3
4
5
6
7
8
9
template<bool T>
void print(int a){
if (T)cout<<"this is true:"<<a<<endl;
else cout<<"this is false:"<<a<<endl;
}
int main(){
print<true>(10);
print<false>(10);
}

这里的要点是,在你生成函数实例时,模板参数会参与构建函数基本框架,然后再将参数补到函数中,换句话说,模板参数会在编译时就被使用。
这里还有一个很有趣的实现,就是比如:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T,int Size>void print(T (&arr)[Size]){
cout<<Size<<endl;
}
template<int Size>void print(int (&arr)[Size]){
cout<<"this is int:"<<Size<<endl;
}
int main(){
int arr[5]={1,2,3,5,6};
print(arr);
print<int>(arr);
return 0;
}

这个示例有几个要点:

  • 注意这个Size,它是非类型模板参数,在这个示例里它可以自动获取数组的长度(因为你在传递参数时如此使用了它),那么它就可以作为参数使用,并且存储了数组的长度,非常方便
  • 注意这两个函数的写法,本意是想作为偏特化的实例,但实际上,你如果将第二个函数写成template<int Size>void print<int>(int (&arr)[Size])的话,会报错。原因在于,这里的模板参数非空,所以没办法显式指定特化的模板参数。只有空置模板参数,编译器才会通过显式指定参数来做偏特化。这个也是下面我要说明的情况的原因
  • 注意主函数里这两个函数的使用。理论上都应该走特化版本的输出,对吧?但实际上,第一个确实是调用了“特化”版本,但第二个却调用了第一个版本。原因就在于第二个版本并不是第一个版本的特化,它相当于一个函数重载,指定了输入函数是int数组。所以你不指定版本,编译器会使用第二种这样直接规定的函数。但是如果你指定了模板参数的种类,编译器就会去找相应的模板函数,只能找到第一个版本
  • 所以,对于这种对部分模板参数做特化的操作,要想统一起来的话最好使用std::enable_if来限定模板范围,具体使用有点长,这里就不说了

Misc


讲了wrapper_class(包装类)、iterator(迭代器)和namespace(命名空间)
wrapper_class没什么特别好说的,实际上是通过move实现了这样一个类,它只支持移动赋值和移动拷贝函数。一般的根据值的构造函数可以使用(相当于是存储数据),但是复制赋值和拷贝函数都无法使用。相当于是避免了同一份数据被两个实例存储,不仅快捷遍历,也不会出现double deletion的问题

至于iterator,其实看了它的实现demo,就能更好的理解它的使用。迭代器本质上是指针实现,支持自增运算,它的自增逻辑上就是把它所对应的指针向下一位自增。我们需要强调一点,迭代器本身是一种数据类型,它的内部可能是基于指针来设计,但是它本身并不是指针,只是它提供的API让它具有类似的能力。
然后迭代器也支持解码运算,可以获取迭代器所指位置的值,因为迭代器需要基于一个数据列,然后它指向的是在这个数据列中的数据位置,所以它类似一种列表中的指针。因此它也支持解码获取数据。
同时它还支持相等和不等运算,这个倒也正常

至于namespace,含义很简单,其实就是给函数或者类一个空间,或者一个分类。我们正常所使用的函数一般都是定义在std空间,就像我们经常使用的using namespace std,就是默认整个文件里的函数的命名空间是std。想要调用某个函数是需要指定命名空间的,就算你引入了某个头文件,你没有写using namespace std或者加上std::的作用域指定,编译器也不知道你用的是那个函数,所以系统会报错。
它的作用就是创建一个自己的空间,主要解决的是函数重名问题。不同的命名空间可以有完全一样的函数,编译器会优先根据你调用时的命名空间来寻找这个函数或者数据类型

C++ Standard Library (STL) Containers


这次顾名思义,学习了一些容器,具体来说是vetcorsetsunordered_map。然后又学习了一个方便的类型定义关键字auto
vector就是老朋友了,因为前面学了迭代器所以这里也就没有什么特别难的知识点。有一个要说的就是它的插入操作:push_back()emplace_back()。这两个效果上都是在向量后面加上一个对象,但是机制和传递的参数不一样。为明显区分我们伪定义一个数据类型Point,构造函数是Point(int,int)。那么对于一个vector<Point>VPpush_back()的机制是将你传递的对象或临时对象拷贝或者移动到向量尾端,所以应该写成类似VP.push_back(Point(1,1));但emplace_back()的机制是调用向量内类型的构造函数,你需要传递的是构造函数需要的参数,然后这个方法会在向量尾创建一个相应的对象,所以你要写成VP.emplace_back(1,1)。因为后者是直接创建,而前者则是先创建后放置,所以emplace_back会稍微快一点
sets名字叫集合,但实际上是有序集。它的构造比较简单,插入的语法也就是insert()函数,直接把value传进去就行。同样的,你需要给集合的存储的数据类型。它的insert看起来叫插入,实际上不仅插入,而且排序,而且去重。也就是你可以乱序插入,结果上都是有序集。同样,它也有上面向量那样类似的插入方法emplacesets不支持下标索引,其他和向量差不多。
unordered_map,用Python来理解就是字典,需要指定键值对的类型,比如unordered_map<string,int>UM,前者是键的类型,后者是值的类型。插入也是insert,插入的对象是一个键值对。你可以直接插入一个二元对(Pair) ,比如UM.insert({"makise",1}),也可以插入一个Pair类型。你需要引入utility.h头文件来获取这个数据类型。它的效果相当于一个键值对,你可以创建一个Pair对象通过pair<string,int>p("makise",1)或者pair<string,int>p={"makise",1}或者使用make_pair来创建:pair<string,int> p=make_pair("makise",1)

  • 觉得麻烦否?我们也可以使用今天学到的auto来减少代码量:auto p=make_pair("makise",1)
    然后把这个pair类型作为参数传进map里。

重点讲一下remove_if和lambda表达式。
remove_if接受三个参数:容器的初始位置迭代器、结束位置迭代器、以及一个谓词。这个谓词具体来说是一个function,用于判断是否删除的条件,并且必须返回bool类型。它的实际机制是,找到不满足删除条件的元素,按顺序复制到容器前(依次往后复制,每次复制接在上一次复制的位置的后面),然后遍历完整个容器后,返回目前复制到的位置的后一个位置的迭代器。也就是说,简化一下,我想对{1,2,3,4,5,6}做remove_if,删除偶数,那么因为有1,3,5不满足,所以依次向前复制,最后这个数组会变成{1,3,5,4,5,6},并且返回现在4在的位置的迭代器。然后真要删除就从给出的迭代器位置一直删到end()就行。
至于lambda表达式,它的形式是[capture list](parameter list)->return type{function body},举个例子就是[x,y](int var1,char var2)->int{...}

  • capture list:捕获列表,就是如果函数体中需要使用一些外部变量,需要将要用到的外部变量写在这个捕获列表里,表示从外部捕获到的变量
  • parameter list:参数列表,因为lambda表达式本质上还是一个匿名函数,就像Javascript里的箭头函数一样,所以也需要有传入的参数定义
  • return type:返回类型,这个也和一般的函数一样,但是似乎是可选,不一定要指定。但如果是返回值类型受外部接口固定的话建议还是指定一下。
  • function body:函数体,就是定义逻辑的地方,可以是多行。
    一般来讲lambda表达式可以用来定义匿名函数,或者作为函数进行传递,比如remove_if里的谓词,就可以传递一个lambda表达式进去

C++ Standard Library (STL) Memory


这里主要是讲智能指针,两种智能指针unique_ptrshared_ptr
这两个指针要放在一起讲,首先,两者都有自动管理内存的机制。但是,unique_str是独占所有权。换句话说,它不存在复制赋值和复制构造(也不能这么操作,否则编译器会报错)但是它支持移动赋值,相当于一个wrapper_class,所以它的内存被删除只有在它的生命周期结束时;而shared_ptr则是允许数据所有权的共享。所以它可以正常复制赋值。它内置一个引用计数器,可以使用use_count来获取引用次数,它只有在所有引用了这个数据的指针的生命周期都结束之后,才会清理内存。
关于unique_str,由于它只能移动赋值,所以要传递这个类型的参数时只能传递引用。似乎也没有很特别的内容了

C++ Standard Library (STL) Synch Primitives

应该是收官了,最后一个模块。
如题,讲的是同步相关的。其实更准确的,是异步操作里的同步。
讲所有的东西之前需要先讲一下thread。这是C++的一个库,用于创建线程、做并行处理用的。thread t(function)即可创建一个线程。这里的function可以直接用函数名(如果函数没有额外参数),也可以thread t(function,x,y)直接传递参数、使用bind函数:thread t(bind(function,x,y))、或者传递一个Lambda表达式。如果参数需要一个引用类型,可以用ref将数据作为引用传递。
一个线程一旦被创建就会开始执行。你可以连着创建一堆线程,然后这些线程都会开始执行(这就是并行)你可以使用t.join()或者t.detach()来决定对这些子线程的结果处理。如果使用join(),那么主线程会在该子线程执行完毕之前阻塞在这里。如果使用detach(),那么该子进程就会脱离主进程,不会被阻塞,但很有可能会因为主进程结束然后被直接杀掉,进而造成可能的bug。
知道多线程之后,我们想到,如果两个线程同时对一个变量做修改,是不是会出问题?谁知道谁先谁后?所以,对于这种共享资源,我们使用mutex来锁住这个资源。举个例子:

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


int count=0;
std::mutex m;
void addCount(){
m.lock();
count++;
m.unlock();
return;
}

int main(){
std::thread t1(addCount);
std::thread t2(addCount);

t1.join();
t2.join();
std::cout<<count;
}

在上面的例子中,mutex包裹住的代码段里,mutex锁住了内部共享资源count的访问权。只有拿到了这个锁的权限的线程可以访问这个资源,其他线程只能等待这个线程解锁,然后另一个线程拿到锁的权限,然后执行相关代码,以此类推。
当然,假设当mutex包裹的区域在执行时出现了问题,没能正确到解锁的代码,就会出现该资源一直被锁住的情况,也就是死锁。所以,我们一般使用lock_guardscoped_lockunique_lockshared_lock等来进行加锁。这些方法的共同点是,都支持自动解锁。当上锁器结束作用范围时,便会自动解锁。但它们的用法和含义都有一些区别:

  • lock_guradlock_guard<mutex> lock(m),作用就是单纯的上锁,而且是对单的上锁
  • scoped_lockscoped_lock<mutex> lock(m1,m2),作用也是单纯的上锁,但是它支持对群。而且它还可以自动调整不同线程获得锁的顺序,使得所有的进程都会优先等待获取一个锁,然后再获取另一个锁,避免两个进程获取了不同的锁导致死锁
  • unique_lockunique_lock<mutex> lock(m),也是对单的单纯上锁,但是它支持自定义位置解锁和重复上锁。比如前面讲的上锁方式都是从上锁位置一直锁到作用域。但是unique_lock出来的lock可以手动lock()unlock(),没锁上也能自动解锁,非常方便
  • shared_lockshared_lock<shared_mutex> lock(m),是对写入操作的单纯上锁,但是允许其他线程读取这个共享资源的值。注意,这个方法只对shared_mutex类有效,因为一般的mutex默认锁住读写,无论是读写都需要和mutex锁申请,但是shared_mutex类就可以在获取读的权限时不需要再申请
    除此之外还有lock方法,语法是lock(m1,m2),可以同时加锁多个锁,相当于scoped_lock一半的功能,还有一半可以加上lock_guard<mutex> lg1(m1,adopt_lock)来实现自动解锁。后者的语法里有一个adopt_lock,效果是告诉lock_guard不用再上锁了,直接管理解锁就行
    除此之外又讲了一个叫condition_variable的东西。创建一个condition_variable这么写:condition_variable cv。它的作用是卡住某个线程,只有在满足一些条件时该进程才会开始执行。比如:
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
#include<condition_variable>
#include<iostream>
#include<thread>
#include<mutex>
int count=0;
std::condition_variable cv;
std::mutex mu;
void addCount(){
std::lock_guard<std::mutex> lg(mu);
count++;
if(count==2){
cv.notify_all();
}
return;
}
void printCount(){
std::unique_lock<std::mutex> ul(mu);
cv.wait(ul,[]{return count==2});
std::cout<<"the number of count is:"<<count<<std::endl;
return;
}
int main(){
std::thread t1(addCount);
std::thread t2(printCount);
std::thread t3(addCount);

t1.join();
t2.join();
t3.join();
return 0;
}

在上述这个例子里,理论上,如果无视condition_variable的话,应该是什么值都有可能输出来(大概率是1)
但实际上,在有condition_variable的条件下,只会输出2
原因在于,condition_variable有这样一个方法wait,它的使用是cv.wait(lock,function),一个是某个锁的变量(要求是unique_lock),另一个则是需要输出布尔的函数(也可以传lambda表达式)。它的作用是,判定当前条件下function的输出是不是true。如果是true就正常执行线程;如果是false,就会挂起当前线程并且释放掉传入的那个锁。但是这个线程已经被挂起了,所以一般情况下,即使条件满足了,function能输出true了,这个线程也不会执行(因为这个线程没有执行,它也不知道现在的条件是否满足)
所以我们需要另一个方法:notify_onenotify_all,它们的效果就是唤起一个或者全部的已挂起的线程,让它们重新跑起来。但是如果这个线程里的wait条件还是不满足,那么该线程会再次被挂起,直到下一次被唤醒再做判断