七. Lambda表达式
如下, 先进行一个简单的使用
struct Cars { int carnum; int price; string name; }; struct cmp { bool operator()(Cars& c1, Cars& c2) { return c1.price < c2.price; } }; bool cmp2(const Cars& c1, const Cars& c2) { return c1.price < c2.price; } int main() { auto fun = [] {}; //这个是最为简单的lambda表达式啥都不干 fun(); //调用, 使用方式像极了无参仿函数调用 auto add = [](double a, double b)->double { return a + b; }; cout << add(2.7, 3.7) << endl; //然后是常用方式: 代替 仿函数使用 Cars cars[] = { {100, 150000, "长城"} , {55, 20000, "宝马摩托"} , {455, 1000, "小电瓶"} , {1000, 500, "自行车"}}; //形式1: sort(cars, cars + sizeof(cars) / sizeof(Cars) , [](Cars& c1, Cars& c2)->bool {return c1.price < c2.price; }); //形式2: sort(cars, cars + sizeof(cars) / sizeof(Cars), cmp()); //传入匿名可调用对象 //形式3: sort(cars, cars + sizeof(cars) / sizeof(Cars), cmp2); //传入函数指针 return 0; }
上述我们利用了 lambda 表达式来代替了仿函数的使用, 这个也是lambda表达式的常用形式之一, 在很多时候都可以见到上述的这种使用场景... 但是有没有思考过为什么吗?
可调用对象(仿函数). 函数指针, lambda表达式 底层处理是否是类似 或者说甚至一样的???
其实 lambda表达式的底层处理就是完全处理成了仿函数的.
- 其实本质上 lambda的底层只是有很多的修饰, 如果把修饰看成是类名 本质就完全是类的 operator() 的重载 仿函数的底层处理形式了
侯杰老师在讲解 lambda底层的时候也曾阐述 lambda 的底层处理就是按照仿函数, 当作类来进行处理的
针对对于自定义对象的 sort 还有一点点小小的技巧, 可以在我们需要 sort 的自定义类中去重载一下operator < 函数, 直接不需要在自己传入排序规则了.......
why??? 上述 重载一下 operator < 就可以达到重建排序规则 () 的效果
- 因为默认调用的其实就是 less<>{} 说白了就是 operator < 验证如下代码: 可以达到同上述一毛一样的效果.
struct Cars { int carnum; int price; string name; bool operator<(Cars& c) const { return price < c.price; } }; int main() { Cars cars[] = { {100, 150000, "长城"} , {55, 20000, "宝马摩托"} , {455, 1000, "小电瓶"} , {1000, 500, "自行车"} }; sort(cars, cars + sizeof(cars) / sizeof(Cars)); return 0; }
八. 包装器 (适配器) (function包装器)
function 是 C++中的类模板, 也是一个包装器.
说到包装器, 首先就要思考 函数指针, 仿函数, Lambda表达式
上章就提到了 三者底层可能差不大多, 使用的情景也是各有雷同, 包装器 其实就可以算是将上述三者进行一个统一, 适配成一个东西 如下 : function 包装器可以实现对三者的统一包装
//函数指针 int add1(int a, int b) { return a + b; } //仿函数 struct Add { int operator()(int a, int b) { return a + b; } int a, b; }; int main() { auto add2 = [](int a, int b){ return a + b; }; //当然可以在()->指定后置返回类型 //auto add2 = [](int a, int b)->int { return a + b; }; function<int(int, int) > func1 = add1; //函数名 function<int(int, int) > func2 = Add(); //函数对象 function<int(int, int) > func3 = add2; //lambda表达式 std::cout << func1(3, 5) << std::endl; std::cout << func2(3, 5) << std::endl; std::cout << func3(3, 5) << std::endl; while (1); return 0; }
思考一个问题, 为什么需要 function 这个包装器, 直接使用三者不可以吗???
包装器的好处???? 统一了可调用对象的类型, 并且指定了参数和返回值类型
1. 简化了函数指针这样的复杂指针的使用, 函数指针复杂难以理解
2. 方便了作为参数时候的传入
3. 仿函数是一个类名没有指定参数和返回值需要知道就需要去看这个operator () 重载获取
4. lambda 在语法层, 看不到类型, 底层存在类型, 但是也是lambda_uuid, 也很难看
我觉得function 出现的 最最重要的原因就是有了一个确切的类型, 使用简单方便,
解决函数指针使用复杂的问题, 解决仿函数不能指定参数类型的问题, 要知道参数类型还要跑去看哪个 operator() 以及解决 lambda没有具体类型的问题.
实际案例:
150. 逆波兰表达式求值
根据 逆波兰表示法,求表达式的值。
有效的算符包括 +、-、*、/ 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。
未使用 function代码:
class Solution { public: int evalRPN(vector<string>& tokens) { stack<int> st; for(int i=0;i<tokens.size();i++){ if(tokens[i]=="+"||tokens[i]=="-"||tokens[i]=="*"||tokens[i]=="/"){ int rhs=st.top(); st.pop(); int lhs=st.top(); st.pop(); switch(tokens[i][0]){ case '+':st.push(lhs+rhs);break; case '-':st.push(lhs-rhs);break; case '*':st.push(lhs*rhs);break; case '/':st.push(lhs/rhs);break; } continue; } st.push(stoi(tokens[i])); } //然后就是最后的结果了 return st.top(); } };
使用 function 的代码:
class Solution { //使用包装器进行复用??? 如何利用包装器?? //需要的是 function 和 对应的 op 对应起来... //如何对应 使用的就是map 对应 map<string, function<int(int, int) > > opmap //逆波兰表达式: 左 右 op //遇到 op 的时候 说明前面的就是 l + r //每一个运算结果需要重新入栈 public: int evalRPN(vector<string>& tokens) { stack<int > numst; map<string, function<int(int, int) > > opmap = { {"+", [](int a, int b)->int{ return a + b;}} , {"-", [](int a, int b)->int{ return a - b;}} , {"*", [](int a, int b)->int{ return a * b;}} , {"/", [](int a, int b)->int{ return a / b;}} }; for (auto& e : tokens) { if (e == "+" || e == "-" || e == "*" || e == "/") { int r = numst.top(); numst.pop(); int l = numst.top(); numst.pop(); //先提取的是r 后 l numst.push(opmap[e](l, r)); } else { numst.push(stoi(e)); } } return numst.top(); } };
九. 线程库
简单的用起来
int main() { size_t n = 100; thread t1([n]{ for (size_t i = 0; i < n; i += 2) { cout << i << endl; } }); cout << t1.get_id() << endl; //线程id thread t2([n]{ for (size_t i = 1; i < n; i += 2) { cout << i << endl; } }); cout << t2.get_id() << endl; //线程id t1.join(); t2.join(); //主线程阻塞等待子线程的死亡 while (1); //等待他们结束 return 0; }
- . 线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的 状态。
- 当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。 线程函数一般情况下可按照以下三种方式提供: 函数指针 lambda表达式 函数对象
void TFun() { cout << "函数指针" << endl; } struct TF { void operator()() { cout << "函数对象" << endl; } }; int main() { thread t1(TFun); //传入函数指针 TF tf; thread t2(tf); //可调用对象(仿函数) thread t3([]() {cout << "Lambda" << endl; }); t1.join(); t2.join(); t3.join(); //join 主线程挂起等待三个线程结束返回 return 0; }
thread类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个 线程对象关联线程的状态转移给其他线程对象,转移期间不意向线程的执行。
可以通过jionable()函数判断线程是否是有效的,如果是以下任意情况,则线程无效
采用无参构造函数构造的线程对象
线程对象的状态已经转移给其他线程对象
线程已经调用jion或者detach结束
线程函数参数
线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在 线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。 (线程函数参数传入是以值拷贝的形式拷贝到栈空间中的, 所以既是是引用类型, 在线程中修改后外部实参也是无法修改的) 如何处理这个问题, 如下代码解释
std::ref(); 使用这个函数转换之后传入的线程函数参数才是真正的引用, 线程中改变, 外面也会改变
class Fun { public: void operator()() { cout << "operator()" << endl; } }; void ThreadFunc1(int& x) { x += 10; } void ThreadFunc2(int* x) { *x += 10; } int main() { //测试一波: int a = 10; thread t1(ThreadFunc1, a); //传入a cout << a << endl; //?? a 是否改变? //上述发现 a 没有改变 //如何可以使得传入的数据不需要进行拷贝, 而是原有数据? 如果想要通过形参改变外部实参时,必须借助std::ref()函数 thread t3(ThreadFunc1, std::ref(a));//才不会传入拷贝本 cout << a << endl; thread t2(ThreadFunc2, &a); //这样看一看??? cout << a << endl; //a改变了, 因为这个传入的是地址进去 t1.join(); t2.join(); return 0; }
十. 原子操作
多线程最主要的问题是共享数据带来的问题(即线程安全)。如果共享数据都是只读的,那么没问题,因为只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。但是,当一个或多个线程要修改共享数据时,就会产生很多潜在的麻烦。
//多线程对于共享数据的写操作带来的问题...
unsigned long num = 0L; //先定义全局的数据 void tf(size_t n) { for (size_t i = 0; i < n; ++i) { num += 1; } } int main() { thread t1(tf, 10000000); thread t2(tf, 10000000); t1.join(); t2.join(); cout << num << endl; return 0; }
- 发现一个大问题, 上述根本无法获取我们想要的结果甚至, 每一次运行结果都是不一样的
解决上述问题的方式1: 在 C++98 中采取的是加锁的方式实现避免函数的重入问题,
lock();
操作临界资源 (写入操作)
unlock();
unsigned long num = 0L; //先定义全局的数据 mutex mtx; void tf(size_t n) { for (size_t i = 0; i < n; ++i) { mtx.lock(); num += 1; mtx.unlock(); } } int main() { thread t1(tf, 10000000); thread t2(tf, 10000000); t1.join(); t2.join(); cout << num << endl; return 0; }
加锁确实是可以解决上述的问题, 但是不停的解锁解锁, 效率会变得特别低, 时间消耗也会大大增加, 不停的加锁解锁, 虽然也解决了问题, 保护了临界资源.. 但是程序运行时延性大大增加, 而且对于锁控制不好还会死锁, 于是C++11 搞出来一个原子操作。所谓原子操作:即不可被中断的一个或一系列操作,C++11引入 的原子操作类型,使得线程间数据的同步变得非常高效。
atomic_long num{ 0 };//定义全局的原子操作数据 void tf(size_t n) { for (size_t i = 0; i < n; ++i) { num += 1; } } int main() { thread t1(tf, 10000000); thread t2(tf, 10000000); t1.join(); t2.join(); cout << num << endl; return 0; }
有了原子操作数据, 确实针对这些数据的操作不再需要加锁保护了, 但是如果是一段代码段的原子操作, 就还是不得不使用锁来实现, 但是只要设计到锁就可能发生死锁, C++11为了预防死锁, C++11采用RAII的方式对锁进行了封装,即lock_guard和unique_lock。
- unique_lock 和 lock_guard 都是对于锁的一种封装模板类, 实现对于锁的管理,
- 然后接下来是一个简单的实现
// RAII namespace tyj { template<class Lock> class lock_guard { public: lock_guard(Lock& lock) :_lock(lock) { _lock.lock(); cout << "加锁" << endl; } /*void lock() { _lock.lock(); } void unlock() { _lock.unlock(); }*/ //对于lock_guard是没有上述操作的, 它仅仅只是做垃圾回收 //出作用域自动回收锁, 调用析构解锁 ~lock_guard() { _lock.unlock(); cout << "解锁" << endl; } lock_guard(const lock_guard<Lock>& lock) = delete; private: Lock& _lock; }; }
只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数 成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁 问题。
向比较 lock_guard 而言, unique_lock 提供了更多的操作
上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock
修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有 权)、释放(release:返回它所管理的互斥量对象的指针,并释放所有权)
条件变量引入以及条件变量对象和互斥对象配合实现一个案例
要求 : 支持两个线程交替打印,一个打印奇数,一个打印偶数
int main() { mutex mtx; //定义锁 为后面的完成要求做准备 bool flag = 1;//flag = 1 打印偶数 flag = 0 打印奇数 配合 condion_variable使用 condition_variable _cond; //定义条件变量为后序相互耦合关联式打印埋伏笔 thread t1([&](){ unique_lock<mutex> _lock(mtx); int i = 0; while (i < 100) { while (!flag) _cond.wait(_lock);//不满足flag 一直等 //说明满足了 cout << "i: " << i << endl; flag = 0; //修改让t2去打印 _cond.notify_one();//唤醒t2打印奇数了 i += 2; } }); thread t2([&](){ unique_lock<mutex> _lock(mtx); int j = 1; while (j < 100) { while (flag) _cond.wait(_lock);//满足flag 说明这个时候在打印偶数 //说明满足了 cout << "j: " << j << endl; flag = 1; //修改让t2去打印 _cond.notify_one();//唤醒t2打印奇数了 j += 2; } }); t1.join(); t2. join(); return 0; }
十一. 总结本章
首先本章介绍了初始化参数列表{} 进行统一的初始化
{} 的本质是一个类型 叫做 initializer_list , 支持使用{} 构造的本质是支持传入initializer_list做参数的构造函数
然后引入了右值引用, 可以取地址的是左值, 右值是不可以去地址的值, 一旦给右值取别名, 右值就会退化, 就会分配空间 + 地址 退化为左值
然后通过右值引用引出移动构造, 移动构造相比拷贝构造好处体现在深拷贝上面, 他和深拷贝不同的是不需要重新开底层的存储空间 + 转移数据, 直接窃取右值的底层空间
模板右值引用: 万能引用, 引用接收之后所有的右值会退化为左值, 想要保持住右值属性不退化, 需要进行 std::forward<>()完美转发, 保持右值属性
然后是 lambda表达式的引出, [捕获列表](参数列表)->后置返回类型{函数体} 且lambda表达式的底层处理就是 类的可调用对象 operator() 运算符重载
function 包装器 对于 函数指针 仿函数 lambda表达式的统一封装.... 包装: 好处, 使用起来更加方便, 指定好了参数和返回值类型, 作为参数传入也更加方便灵活...
thread 线程类库, C++11 支持的线程库, 参数的传入以值拷贝形式, 要想传入的是真正的引用 必须进行 std::ref()处理
原子操作: 创建了一套原子操作数据类型 atomic_long 等等以atomic开头的支持原子操作的数据类型, 相比 使用mutex 更加高效, 且不会死锁
但是由于对于代码段的原子操作, 原子操作的数据类型 无能为力, 只能使用 mutex, 使用锁为了避免死锁, C++11 产生了 锁的管理模板类 unique_lock 和 lock_guard 进行管理锁, 在 对象结束的时候调用析构解锁, 不至于一直死锁
因为一直使用锁, 效率极低, 所以 可以使用 condition_variable 配合锁使用完成一些特殊的要求, 以及提高效率, 不至于让系统一直不停的加锁解锁, 因为加锁解锁 耗费CPU资源