您现在的位置是:首页 >技术教程 >【C++】C++11 新特性网站首页技术教程

【C++】C++11 新特性

野猪佩奇` 2023-05-13 00:00:03
简介C++11 新特性:列表初始化、范围 for、auto、智能指针、右值引用、可变模板参数、emplace、lambda 表达式、包装器、线程库等相关知识。

一、C++ 发展史

1982年,Bjarne Stroustrup 博士在C语言的基础上引入并扩充了面向对象的概念,发明了一种新的程序语言。为了表达该语言与C语言的渊源关系,所以将其命名为C++。简言之,C++是基于C语言而产生的,它既可以进行C语言的过程化程序设计,又可以进行以抽象数据类型为特点的基于对象的程序设计,还可以进行面向对象的程序设计。C++ 的发展史如下:

-阶段-
内容
C with classes
类及派生类、公有和私有成员、类的构造和析构、友元、内联函数、赋值运算符重载等
C++1.0
添加虚函数概念,函数和运算符重载,引用、常量等
C++2.0
更加完善支持面向对象,新增保护成员、多重继承、对象的初始化、抽象类、静态成员以及const成员函数
C++3.0
进一步完善,引入模板,解决多重继承产生的二义性问题和相应构造和析构的处理
C++98C++标准第一个版本,绝大多数编译器都支持,得到了国际标准化组织(ISO)和美国标准化协会认可,以模板方式重写C++标准库,引入了STL(标准模板库)
C++03
C++标准第二个版本,语言特性无大改变,主要:修订错误、减少多异性
C++05C++标准委员会发布了一份计数报告(Technical Report,TR1),正式更名C++0x,即:计划在本世纪第一个10年的某个时
间发布
C++11增加了许多特性,使得C++更像一种新语言,比如:正则表达式、基于范围for循环、auto关键字、新容器、列表初始化、右值引用、智能指针、标准线程库等
C++14对C++11的扩展,主要是修复C++11中漏洞以及改进,比如:泛型的lambda表达式,auto的返回值类型推导,二进制字面常量等
C++17在C++11上做了一些小幅改进,增加了19个新特性,比如:static_assert()的文本信息可选,Fold表达式用于可变的模板,if 和 switch 语句中的初始化器等
C++20自C++11以来最大的发行版,引入了许多新的特性,比如:模块(Modules)、协程(Coroutines)、范围(Ranges)、概念(Constraints) 等重大特性,还有对已有特性的更新:比如Lambda支持模板、范围for支持初始化等
C++23
制定ing

二、C++11 简介

在2003年时 C++ 标准委员会曾经提交了一份技术勘误表 (简称 TC1),使得 C++03 这个名字已经取代了 C++98 成为 C++11 之前的最新C++ 标准名称。不过由于 C++03 (TC1) 主要是对 C++98 标准中的漏洞进行修复,语言的核心部分则没有改动,因此人们习惯性的把两个标准合并称为 C++98/03 标准。

从 C++0x 到 C++11,C++ 标准十年磨一剑,第二个真正意义上的标准珊珊来迟。相比于C++98/03,C++11 则带来了数量可观的变化,其中包含了约140个新特性,以及对 C++03 标准中约600个缺陷的修正,这使得 C++11 更像是从 C++98/03 中孕育出的一种新语言。相比较而言,C++11 能更好地用于系统开发和库开发、语法更加泛华和简单化、更加稳定和安全,不仅功能更强大,而且能提升程序员的开发效率,公司实际项目开发中也用得比较多,所以我们要作为一个重点去学习。C++11 增加的语法特性非常篇幅非常多,我们这里主要讲解实际中比较实用的语法。

C++ 11 官方文档:https://en.cppreference.com/w/cpp/11

C++ 11小故事:

1998 年是 C++ 标准委员会成立的第一年,本来计划以后每5年视实际需要更新一次标准,C++ 国际标准委员会在研究 C++ 03 的下一个版本的时候,一开始计划是 2007 年发布,所以最初这个标准叫 C++ 07。但是到 06 年的时候,官方觉得 2007 肯定完不成 C++ 07,而且官方觉得 2008 年可能也完不成,所以最后干脆叫 C++ 0x。x 的意思是不知道到底能在 07 还是 08 还是 09 年完成。结果最终 2010 年的时候也没完成,最后在 2011 年终于完成了C++标准,至此才最终定名为 C++11。


三、初始化列表

1、统一使用 {} 初始化

在C++98中,标准允许使用花括号 {} 对数组或者结构体元素进行统一的列表初始值设定。比如:

struct Point {
	int _x;
	int _y;
};

int main() {
	int array1[] = { 1, 2, 3, 4, 5 };
	int array2[5] = { 0 };
	Point p = { 1, 2 };
	return 0;
}

C++11 扩大了花括号 {} 的使用范围,使其 可用于所有的内置类型和自定义类型 的初始值设定,即通过初始化列表初始化 (注意和构造函数中的初始化列表进行区分),并且在使用初始化列表初始化时,可以省略赋值符号 = 。如下:

struct Point {
	int _x;
	int _y;
};

int main() {
	//内置类型也可使用初始化列表初始化,并且可以省略赋值符号
	int x1 = 1;
	int x2{ 2 };

	//自定义类型也可以
	int array1[]{ 1, 2, 3, 4, 5 };
	int array2[5]{ 0 };
	Point p{ 1, 2 };

	// C++11中列表初始化还适用于new表达式中
	int* pa = new int[4]{ 1, 2, 3, 4 };

	return 0;
}

创建对象时也可以使用列表初始化的方式调用构造函数来初始化,即当列表中的元素类型和元素个数符合构造函数的参数要求时,可以转化为调用构造函数来完成初始化,即 A{a, b, c} <==> A(a, b, c);如下:

class Date {
public:
	Date(int year, int month, int day)
		:_year(year)
		, _month(month)
		, _day(day)
	{
		cout << "Date(int year, int month, int day)" << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main() {
	// old style
	Date d1(2022, 1, 1); 
	// C++11支持的列表初始化,这里会调用构造函数初始化
	Date d2{ 2022, 1, 2 };
	Date d3 = { 2022, 1, 3 };
    
	return 0;
}

2、initializer_list 类

initializer_list 是 C++11 中新增的一个类,其文档介绍如下:initializer_list - C++ Reference (cplusplus.com)image-20230412182532106

它可以将同一类型元素的集合即由相同元素构成的一个列表转化为一个 initializer_list 的对象;需要注意的是,initializer_list 实际上是对常量区的封装 – 将列表中的数据识别为常量区的数据,然后用类似于迭代器的 begin 和 end 指针指向并访问这些数据,其自身并不会开辟空间,所以 initializer_list 中的数据也不能修改。如下:image-20230412180154648

有了 initializer_list 类以后,我们就可以让 STL 的其他容器重载一个参数为 initializer_list 类型的构造函数和赋值函数,从而使得这些容器支持使用列表来进行初始化和赋值image-20230412180642295

image-20230412181104845

image-20230412180816270

int main()
{
	//列表初始化
	vector<int> v = { 1,2,3,4 };
	list<int> lt = { 1,2 };
	//这里 {"sort", "排序"} 会先初始化构造一个pair对象,然后再构造map
	map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };
	//使用大括号对容器赋值
	v = { 10, 20, 30 };

	return 0;
}

ps:需要注意区分列表初始化的两种不同场景

  1. 当列表中的元素类型和元素个数符合构造函数的参数要求时,会直接调用构造函数来完成初始化;
  2. 当列表中的元素个数不符合构造函数的参数要求时,会先将列表转换为 initializer_list 类,然后再调用参数为 initializer_list 的构造函数完成初始化。

总结:在 C++11 及其过后,一切即可用 {} 完成初始化,初始化时皆可以省略赋值符号。(STL 中的所有容器都重载了参数类型为 initializer_list 的构造和赋值函数,但是不包括容器适配器,因为容器适配器本身不是一个容器,其只是对容器的封装)


四、变量类型推导

1、auto

在 C++98 中 auto 是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局部的变量默认就是自动存储类型,所以auto就没什么价值了。C++11 中废弃了 auto 原来的用法,将其用于实现自动类型推导。不过我们必须对 auto 定义的变量进行显示初始化,这样才能让编译器将定义对象的类型设置为初始化值的类型。image-20230412234655095

2、decltype

C++11 还增加了 decltype 关键字,它可以将变量的类型声明为表达式指定的类型;如下:

// decltype的一些使用使用场景
template<class T1, class T2>
void F(T1 t1, T2 t2) {
	//将t1和t2乘积的类型作为ret的类型
	decltype(t1 * t2) ret;
	cout << typeid(ret).name() << endl;
}

int main()
{
	const int x = 1;
	double y = 2.2;
	//ret的类型是double
	decltype(x * y) ret;
	//p的类型是int const *
	decltype(&x) p;
	cout << typeid(ret).name() << endl;
	cout << typeid(p).name() << endl;
	F(1, 'a');
	return 0;
}

image-20230412235137329

3、nullptr

在 C++11 之前,C++ 中的 NULL 被定义成字面量 0,这样就可能会带来一些问题,因为 0 既能表示指针常量,又能表示整形常量;所以出于清晰和安全的角度考虑,C++11中新增了关键字 nullptr,用于表示空指针

#ifndef NULL
#ifdef __cplusplus
#define NULL  0
#else
#define NULL  ((void *)0)
#endif
#endif

五、范围 for 循环

范围 for 是 C++11 提供的一个语法糖,它配合 auto 可以让我们很方便的让我们取出容器中的每一个元素;但范围 for 本身并没有什么技术含量,它底层实际上是通过替换成迭代器来完成的,所以支持迭代器的容器就支持范围 for 遍历;如下:

int main() {
	//列表初始化
	vector<int> v = { 1, 2, 3, 4, 5 };
	//使用范围for遍历
	for (auto e : v) {
		cout << e << " ";
	}
	cout << endl;
	
	//等价于下面这种遍历方式
	auto it = v.begin();
	while (it != v.end()) {
		cout << *it << " ";
		++it;
	}
	cout << endl;

	stack<int> st;
	//容器适配器不支持列表初始化,也没有迭代器
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(4);
	//所以它不能使用范围for
	for (auto e : st)
		cout << e << " ";
	cout << endl;

	return 0;
}

image-20230415121813957

image-20230415121849725

image-20230415122101808


六、final 和 override

C++11 中新增了两个关键字 – final 和 override,其中 final 可以用来修饰类、函数和变量:

  • final 修饰类,表示该类不能被继承;image-20230312152152837
  • final 修饰虚函数,表示该虚函数不能被重写;image-20230312221129502
  • final 修饰变量,表示该变量的值不能被修改。image-20230312221345193

而 override 只能用来修饰子类中用于重写父类虚函数的函数,其作用是检查子类是否重写了父类的虚函数。


七、智能指针

指针指针是 C++11 中一个非常重要和实用的部分,同时它也很复杂,所以我们将它单独写为一篇博客进行学习。


八、右值引用和移动语义

和指针指针一样,右值引用同样是 C++11 中非常重要的一个知识点,我们也单独重点的对它进行学习。


九、STL 中的一些变化

C++11 对 STL 进行了更新,其中主要的变化在于增加了一些新容器和在容器中增加了一些新方法。

新增容器

下图中用橘色圈起来的是 C++11 中增加的几个新容器,其中最有用的是 unordered_map 和 unordered_set,而 bitset 和 forward_list 价值不是那么大,特别是 forward_list (单链表):image-20230415124605629

新增方法

C++11 几乎对每个容器都增加了方法,其中主要分为如下几个方面:

  • 所有支持 const 迭代器的容器都提供了 cbegin 和 cend 方法来返回 const 迭代器;image-20230415124903545

  • 所有容器的插入接口都提供了 emplace 版本,包括容器适配器 – emplace 主要是可变参数模板和右值引用:image-20230415125659038

  • 所有容器的构造函数都重载了移动构造和参数为 initializer_list 的构造 (注:容器适配器重载了移动构造,但没有重载initializer_list构造):image-20230415125958003

    image-20230415130301624

  • 所有容器的赋值重载函数都重载了移动赋值和参数为 initializer_list 的赋值,不包括容器适配器:image-20230415130511567


十、类的新功能

C++11 类的变化主要分为如下几个方面:

  • 增加了两个默认成员函数 – 移动构造和移动赋值;
  • 类成员变量允许使用缺省值进行初始化;
  • 增加 default 和 delete 关键字;
  • 增加 final 和 override 关键字;
  • 增加 emplace 插入接口。

由于上述的前三点都和右值引用和移动语义强相关,所以我直接将它们放到右值引用博客中进行讲解;而 final 和 override 在前面已经讲解,剩下的 emplace 我会在下文讲解。


十一、可变参数模板

1、可变参数模板的语法

在C语言中我们使用 … 来表示可变参数,比如 printf 和 scanf 函数,C++ 中沿用了这个用法:image-20230415205201729

可变参数模板的形式

但 C++ 也与C语言有一些不同,下面是一个基本可变参数的函数模板:

// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args...args,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void ShowList(Args... args)
{}

上面的参数 args 前面有省略号,所以它就是一个可变模版参数,我们把带省略号的参数称为 “参数包”,它里面包含了0到N(N>=0)个模版参数;

参数包中参数的个数

在可变参数的函数模板中我们可以使用 sizeof…(args) 来求得参数包中参数的个数:image-20230415210202263

2、取出参数包中的每个参数

既然可以使用 sizeof…(args) 来求得参数包中可变参数的个数,那么有的同学自然会联想到使用如下方法来依次取出参数包中的每个参数:

template <class ...Args>
void ShowList(Args... args)
{
	//求参数包中参数的个数
	cout << sizeof...(args) << endl;

	//依次取出参数包中的每个参数--error
	for (int i = 0; i < sizeof...(args); i++) {
		cout << args[i] << endl;
	}
}

image-20230415211103611

有这种想法的同学我只能说你没有参加C++11标准的制定真是太可惜了,因为上面这种方法非常好理解,但是C++11标准中并不允许以这种方式来取出参数包中的参数,而是使用另外两种非常晦涩的方式来完成,如下:

方法一:递归函数方式展开参数包,将参数包中的第一个参数赋值给 val,将剩下的 n-1 个参数以类似于递归子问题的方式逐个取出,当参数包为空时再调用最后一次,至此将参数包中的参数全部取出;

void ShowList() {
	cout << endl;
}

template <class T, class ...Args>
void ShowList(T val, Args... args)
{
	cout << val << " ";
	ShowList(args...);
}

int main() {
	ShowList(1);
	ShowList(1, 0.25);
	ShowList(1, 0.25, string("xxxxxx"));

	return 0;
}

image-20230415211955057

方法二:逗号表达式展开参数包

template <class T>
void PrintArg(T t) {
	cout << t << " ";
}

//展开函数
template <class ...Args>
void ShowList(Args... args)
{
	int arr[] = { (PrintArg(args), 0)... };
	cout << endl;
}

int main() {
	ShowList(1);
	ShowList(1, 0.25);
	ShowList(1, 0.25, string("xxxxxx"));

	return 0;
}

image-20230415213145445

如果说用递归方式展开参数包我们还能看懂,那么用逗号表达式展开参数包可能就真的完全懵逼了 – 实际上这里是利用了数组初始化的特性,我们在用0初始化数组时需要知道列表中参数的个数,而参数的个数需要通过展开参数包获得。

可以看到,C++11 提供的这两种参数包展开的方式比起 args[i] 这种方式真的是晦涩太多了,特别是逗号表达式展开,但是没办法,语言就是这么规定的;不过也不用太在这里纠结,参数包展开能看懂就行,我们并不需要去深究它的底层原理

3、STL empalce 相关接口函数

在前面我们提到 C++11 为所有容器的插入接口都新增了一个 emplace 版本,如下:image-20230415220757160

可以看到,emplace 系列的接口支持模板的可变参数和万能引用,那么相较于传统的插入接口,emplace 接口的优势在哪呢?我们分为不同类型的参数来说明:

  • 对于内置类型来说,emplace 接口和传统的插入接口在效率上是没有区别的,因为内置类型是直接插入的,不需要进行拷贝构造;

  • 对于需要进行深拷贝的自定义类型来说,如果该类实现了移动构造,则 emplace 接口会比传统插入接口少一次浅拷贝,但总体效率差不多;如果该类没有实现移动构造,则 emplace 接口的插入效率要远高于传统插入接口

    这是因为在传统的插入接口中,需要先创建一个临时对象,然后将这个对象深拷贝或者移动拷贝到容器中,而 std::emplace() 则通过使用可变参数模板、万能模板等技术,直接在容器中构造对象,避免了对象的拷贝和移动

  • 对于不需要进行深拷贝的自定义类型来说,emplace 接口也会比传统插入接口少一次浅拷贝 (拷贝构造),但总体效率也差不多;原因和上面一样,emplace 接口可以直接在容器中原地构造新的对象,避免了不必要的拷贝过程。

所以,网上有的人说,C++11 提供的 emplace 接口要比传统的插入接口高效,我们能使用 emplace 就不要使用传统插入接口,这种说法每次,但是我认为并不是绝对的;因为 STL 中的容器都支持移动构造,所以 emplace 接口仅仅是少了一次浅拷贝而已,而浅拷贝的代价并不大;所以我们在使用 STL 容器时并不需要去刻意的使用 emplace 系列接口。

特别注意:上面的传统接口的移动构造和 emplace 接口的直接在容器中构造对象都只针对右值 (将亡值),而对于左值,它都只能老实的进行深拷贝。


十二、lambda 表达式

1、lambda 表达式语法

在 C++98 中,为了替代函数指针,C++ 设计出了仿函数,也称为函数对象,仿函数实际上就是一个普通的类,只是该类重载了函数调用操作符 (),这使得该类的对象可以像函数一样去使用,如下:image-20230415133731410

虽然仿函数已经能够完全取代函数指针了,但是它在如下场景下仍然有些难用:

struct Goods {
	string _name;  // 名字
	double _price; // 价格
	int _evaluate; // 评价

	Goods(const char* str, double price, int evaluate)
		:_name(str)
		, _price(price)
		, _evaluate(evaluate)
	{}
};

struct Compare1 {
	bool operator()(const Goods& gl, const Goods& gr) {
		return gl._price < gr._price;
	}
};

struct Compare2 {
	bool operator()(const Goods& gl, const Goods& gr) {
		return gl._price > gr._price;
	}
};

int main() {
	vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } };
	sort(v.begin(), v.end(), Compare1());
	sort(v.begin(), v.end(), Compare2());
}

随着C++语法的发展,人们开始觉得上面的写法太复杂了,每次为了实现一个algorithm算法,都要重新去写一个类,如果每次比较的逻辑不一样,还要去实现多个类,特别是相同类的命名,这些都给编程者带来了极大的不便。因此,在C++11语法中出现了Lambda表达式。

lambda 表达式的格式如下

[capture-list] (parameters) mutable -> return-type { statement }

其中各部分参数的含义如下

  • [capture-list] : 捕捉列表,该列表总是出现在 lambda 函数的开始位置,编译器根据 [] 来判断接下来的代码是否为 lambda 函数,捕捉列表能够捕捉上下文中的变量供 lambda 函数使用;捕捉列表不可省略,但可以为空
  • (parameters):参数列表与普通函数的参数列表一致,如果不需要参数传递,则可以连同 () 一起省略,但不建议这样做。
  • mutable:默认情况下,lambda 函数总是一个 const 函数,mutable 可以取消其常量性;(注意:使用该修饰符时,参数列表不可省略,即使参数为空);但实际上 mutable 很少用,因为形参的改变不会影响实参。
  • ->return-type:返回值类型,用追踪返回类型形式声明函数的返回值类型,没有返回值或者返回值类型明确情况下都可以省略,由编译器对返回类型自动推导,但是写上会增加可读性,一般不写
  • {statement}:函数体和普通函数的函数体一样,不过在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。

注意:在 lambda 函数定义中,参数列表和返回值类型都是可选部分,即可以省略不写,同时捕捉列表和函数体也可以为空,因此 C++11 中最简单的 lambda 函数为:[]{}; 但该 lambda 函数无意义。


2、lambda 表达式与函数对象

lambda 表达式和仿函数一样,本质上也是一个可调用的函数对象,所以 lambda 表达式的使用方式和仿函数完全相同;但和仿函数不同的是,lambda 表达式的类型是由编译器自动生成的,并且带有随机值,所以我们无法具体写出 lambda 表达式的类型,只能使用 auto 进行推导。

struct Compare {
	bool operator()(int a, int b) {
		return a < b;
	}
};

int main() {
	int a = 1;
	int b = 2;

	//仿函数
	Compare com;
	cout << com(a, b) << endl;

	//lambda表达式
	auto func = [](int a, int b) ->bool { return a < b; };
	cout << func(a, b) << endl;

	return 0;
}

image-20230415162055293

实际上,lambda 表达式的底层实现是通过编译器生成一个匿名的函数对象,然后再通过这个函数对象来调用 operator()() 函数,从而完成调用;换句话说,lambda 表达式底层实际上是通过替换为仿函数来完成的image-20230415165511131

image-20230415165604272

3、lambda 表达式的捕捉列表

lambda 表达式最厉害的地方在于捕捉列表,捕捉列表可以捕捉父作用域中 lambda 表达式之前的所有变量,捕捉方式有如下几种:

int a = 1;
int b = 2;
  1. [var]:表示值传递方式捕捉变量var,传值捕捉到的参数默认是被 const 修饰的,所以我们不能在 lambda 表达式的函数体中修改它们;如果要修改,我们需要使用 mutable 修饰;但由于传值捕捉修改的是形参,所以一般我们也不会去修改它;image-20230415171207334

  2. [&var]:表示引用传递捕捉变量var,通过引用传递捕捉,我们就可以在 lambda 表达式函数体中修改实参的值了;image-20230415171502853

  3. [&]:表示引用传递捕捉所有父作用域中的变量 (包括this)

  4. [=]:表示值传递方式捕获所有父作用域中的变量 (包括this)image-20230415171933040

除了上面这四种捕捉方式之外,lambda 表达式的捕捉列表还支持混合捕捉,如下;image-20230415172358181

lambda 表达式有如下注意事项

  1. 父作用域是指包含 lambda 函数的语句块,捕捉列表可以捕捉父作用域中位于 lambda 函数之前定义的所有变量;
  2. 语法上捕捉列表可由多个捕捉项组成,并以逗号分割;比如:
    • [=, &a, &b]:以引用传递的方式捕捉变量a和b,值传递方式捕捉其他所有变量;
    • [&,a, this]:值传递方式捕捉变量a和this,引用方式捕捉其他变量;
  3. 捕捉列表不允许变量重复传递,否则就会导致编译错误;
  4. 在块作用域中的lambda函数仅能捕捉父作用域中局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错;
  5. lambda 表达式之间不能相互赋值,即使看起来类型相同。

最后,我们可以将最开始的排序仿函数使用 lambda 表达式的方式来实现:

struct Goods {
	string _name;  // 名字
	double _price; // 价格
	int _evaluate; // 评价

	Goods(const char* str, double price, int evaluate)
		:_name(str)
		, _price(price)
		, _evaluate(evaluate)
	{}
};

int main() {
	vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } };
	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) { return g1._price < g2._price; });
	sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) { return g1._price > g2._price; });
}

十二、包装器

1、function

我们通过下面这个例子来引入包装器:

double f(double i) {
	return i / 2;
}

struct Functor {
	double operator()(double d)
	{
		return d / 3;
	}
};

template<class F, class T>
T useF(F f, T x) {
	static int count = 0;
	cout << "count:" << ++count << endl;
	cout << "count:" << &count << endl;
	return f(x);
}

int main()
{
	// 函数指针
	cout << useF(f, 11.11) << endl;
	// 仿函数
	cout << useF(Functor(), 11.11) << endl;
	// lambda 表达式
	cout << useF([](double d)->double { return d / 4; }, 11.11) << endl;

	return 0;
}

image-20230415233713088

通过 count 变量的地址我们可以发现,尽管 T 的类型相同,但 useF 函数还是被实例化了三份,这是因为形参 F 会根据实参的不同而实例化出不同的函数,也就是说,形参 F 的类型有多个,那我们能不能让其类型变为一个,从而只实例化出一份函数呢?function 包装器可以解决这个问题 。

function 是一个可调用对象包装器,可它以将函数指针、仿函数以及 lambda 表达式、成员函数等可调用对象进行包装,使他们具有相同的类型,包装器也可以像普通的函数一样进行调用,包装器的本质还是仿函数。在 C++11 标准中引入了 std::function 模板类,其定义在 <functional> 头文件中。

function 的定义格式如下:

std::function<返回值类型(参数类型1, 参数类型2, ...)> f;

function 的使用方式类似于普通类,可以先定义一个 function 对象,然后将需要调用的函数赋值给该对象,也可以在定义 function 对象时直接使用可调用对象完成初始化,最后通过 function 对象进行函数调用;如下:

int f(int a, int b) {
	return a + b;
}

struct Functor {
public:
	int operator() (int a, int b) {
		return a + b;
	}
};

class Plus {
public:
	static int plusi(int a, int b) {
		return a + b;
	}

	int plusd(int a, int b) {
		return a + b;
	}
};

int main()
{
	// 函数名(函数指针)
	function<int(int, int)> func1(f);
	cout << func1(1, 2) << endl;

	// 函数对象
	function<int(int, int)> func2 = Functor();
	cout << func2(1, 2) << endl;

	// lambda 表达式
	function<int(int, int)> func3 = [](const int a, const int b) {return a + b; };
	cout << func3(1, 2) << endl;

	// 类的静态成员函数--本质还是函数指针
	function<int(int, int)> func4 = &Plus::plusi;
	cout << func4(1, 2) << endl;

	// 类的普通成员函数--本质还是函数指针
	std::function<int(Plus, int, int)> func5 = &Plus::plusd;
	cout << func5(Plus(), 1, 2) << endl;

	return 0;
}

需要特别注意的是:当 function 封装的是类的成员函数时,我们需要对该成员函数进行类域的声明,并且还需要在类域前面加一个取地址符,另外,成员函数又分为静态成员函数和非静态成员函数:

  • 静态成员函数没有 this 指针,所以 function 类实例化时不需要添加一个成员函数所属类的类型参数,在调用时也不需要传递一个成员函数所属类的对象;image-20230416000610438

  • 但非静态成员函数有隐藏的 this 指针,所以需要传递这两个东西;image-20230416000636549

    特别注意,这里传递的是类的类型和类的对象,有的同学可能认为它们对应的是 this 指针,所以应该像下面这样传:

    std::function<int(Plus*, int, int)> func5 = &Plus::plusd;
    cout << func5(&Plus(), 1, 2) << endl;
    

    但其实不是的,因为 this 指针并不能显式传递,同时,function 包装器也是通过类的对象来调用类中的函数的。

可以看到,经过上面 function 的包装,使得函数指针 f、仿函数 Functor、lambda 表达式以及类的静态成员函数具有了统一的类型 – function<int(int, int)>;类的普通成员函数我们也可以通过后面的绑定来让它的类型变为 function<int(int, int)>

现在,我们就可以解决开始那里模板效率低下,实例化多份的问题了:

double f(double i) {
	return i / 2;
}

struct Functor {
	double operator()(double d) {
		return d / 3;
	}
};

template<class F, class T>
T useF(F f, T x) {
	static int count = 0;
	cout << "count:" << ++count << endl;
	cout << "count:" << &count << endl;
	return f(x);
}

int main() {
	// 函数名
	std::function<double(double)> func1 = f;
	cout << useF(func1, 11.11) << endl;

	// 函数对象
	std::function<double(double)> func2 = Functor();
	cout << useF(func2, 11.11) << endl;

	// lamber表达式
	std::function<double(double)> func3 = [](double d)->double { return d /4; };
	cout << useF(func3, 11.11) << endl;

	return 0;
}

image-20230416001547717

包装器还有一些其他场景,比如下面这道OJ题:150. 逆波兰表达式求值 - 力扣(LeetCode)

这道题的传统解法是这样的:

class Solution {
public:
    int evalRPN(vector<string>& tokens) {
        stack<int> st;

        for(auto& str : tokens) {
            if(str == "+" || str == "-" || str == "*" || str == "/") {  //运算符取两个栈顶元素运算后入栈
                int right = st.top();  //右操作数
                st.pop();

                int left = st.top();  //左操作数
                st.pop();

                switch(str[0]) {
                    case '+':
                        st.push(left + right);
                        break;
                    case '-':
                        st.push(left - right);
                        break;
                    case '*':
                        st.push(left * right);
                        break;
                    case '/':
                        st.push(left / right);
                        break;
                    default:
                        break;
                }
            } else {  //数字直接入栈
                st.push(stoi(str));
            }
        }
        
        return st.top();
    }
};

可以看到,我们需要针对不同的操作符进行不同的处理,但是使用 switch case 的方式又很挫,因为一旦我们要新增一种运算符,则很多地方都要跟着修改,所以这里我们可以使用包装器,如下:

class Solution {
public:
    int evalRPN(vector<string>& tokens) {
        stack<int> st;
        //建立命令和动作的映射
        //列表初始化+包装器
        map<string, function<int(int, int)>> opFuncMap = 
        {
            { "+", [](int x, int y) { return x + y; } },
            { "-", [](int x, int y) { return x - y; } },
            { "*", [](int x, int y) { return x * y; } },
            { "/", [](int x, int y) { return x / y; } }
            //{ "%", [](int x, int y) { return x % y } }
        };

        for(auto& str : tokens) {
            //不在map里面就是操作数
            if(opFuncMap.find(str) == opFuncMap.end())
                st.push(stoi(str));
            //在就是操作符
            else 
            {
                int right = st.top();
                st.pop();
                int left = st.top();
                st.pop();
                //将运算结果入栈
                st.push(opFuncMap[str](left, right));
            }
        }
        
        return st.top();
    }
};

如上,我们将包装器定义为 map 的 value,然后使用不同的 key 和 对应的 lambda 表达式来初始化 map,这样以后我们要增加运算符只需要在 map 初始化的列表中增加一个 key 和 lambda 表达式即可。

2、bind

bind 是一个函数模板,也定义在 <functional> 头文件中,它就像一个函数包装器 (适配器),可以接受一个可调用对象(callable object),然后生成一个新的可调用对象来 “适应” 原对象的参数列表;简单来说,bind 的作用就是调整可调用对象的参数 – 包括调整参数顺序和调整参数个数

bind 的格式如下

bind(函数指针或可调用对象, 参数1, 参数2, ...)

其中,第一个参数是需要绑定的函数或函数对象的地址,后面的参数是函数或函数对象需要的参数,可以有任意多个,同时也可以使用占位符(placeholders)对参数进行占位,表示该位置的参数需要在调用时再传递进来。

placeholders 是 C++11 引入的一个命名空间域,它包含了一些占位符对象(placeholder objects),用于在使用 bind 绑定函数时,指定某个参数需要在调用时再传递进来。image-20230416011025456

bind 的使用案例如下:

int Plus(int a, int b) {
	return a + b;
}

int Div(int a, int b) {
	return a / b;
}

class Sub {
public:
	int sub(int a, int b) {
		return a - b;
	}
};

int main() {
	//表示绑定函数plus,参数分别由调用func1的第一,二个参数指定
	function<int(int, int)> func1 = bind(Plus, placeholders::_1, placeholders::_2);
	//也可以使用auto自动推导bind函数的返回值类型
	//表示绑定函数plus,并且指定参数为5和8
	auto func2 = std::bind(Plus, 5, 8);
	cout << func1(1, 2) << endl;
	cout << func2() << endl;

	return 0;
}

image-20230416011517798

bind 可以实现调整参数顺序和参数个数的功能。

bind 调整参数顺序

bind 可以通过调整占位符的顺序来调整参数的顺序,如下:

image-20230416012432742

bind 调整参数个数

bind 可以在形参列表中直接绑定具体的函数对象,这样该参数就会自动传递,而不需要我们在调用函数是显式传递,并且也不需要我们在 function 的参数包中显式声明;这样我们就可以通过绑定让我们将类的普通成员函数和类的静态成员函数以及 lambda 表达式、函数指针一样定义为统一的类型了;如下:image-20230416013710542

需要说明的是,bind 在实际开发中使用的并不多,大家作为了解内容即可


十三、线程库

和智能指针、右值引用和移动语义一样,线程库我们也作为单独的一篇博客进行学习。


风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。