c++在线编译工具,可快速进行实验: https://www.bejson.com/runcode/cpp920/
这段时间, 差不多把C++的基础内容重新过了一遍,后面会利用零碎的时间,再把一些C++的重要新内容给过一下, 因为随着时代的发展, C++也与时俱进, 迭代更新, 这个过程中可绕不开C++11的标准,这个可以看做是C++的一次重大升级,其实这个我还是在实习期间接触的,在之前,我对C++的认识,依然是停留在老版本上,直到实习的时候,看到自动类型推导,智能指针,Lambda表达式等, 也不禁心里一惊,“好酷炫”,C++原来还可以这样玩, 才意识到之前的C++认知有点落伍了, 而落伍的核心原因,竟然不知道C++11的存在!!!
所以呢? 想集中通过一篇文章,来特地整理C++11的新特性,内容依然是参考C语言中文网, 关于具体细节,依然是去这里看吧。 另外, 还有一点值得提一下,就是C++11的新特性, 有时候面试还会考到, 尤其是面试官看到简历上是熟悉C++的时候 😉
这篇文章依然会很长,因为我基于上面链接的知识, 摘出了重点,并对一些产生疑问的知识查缺补漏和做了一些实验,但为了方便后面查,我依然放一块,然后通过标题区分开, 所以,老规矩,各取所需即可 😉
主要内容:
Ok, let’s go!
这里得追溯到1983年, 在那时候, C++之父Bjarne Stroustrup把"带类的C"正式叫做"C++", 这时候的C++, 在C语言的基础上加入面向对象的思想,除了具备C语言的所有功能,还具有类,继承,内联函数,虚函数,引用等等。
在1998年的时候, C++标准委员会发布了第一版C++标准,就是C++98标准(C++代码编写规范), 然后C++就开始迭代更新, 直到2020年, C++发展历经3个标准:
上面3个标准, C++11是最颠覆性的, 在C++98的基础上修正了600多个C++语言存在的缺陷,增加了140多个新特性, 使得C++语言焕然一新,所以在C++98的基础上,孕育了一个全新的C++, 可以看成C++新的开始,这也是为啥C++11重要的原因。
在C++11的版本里面,定义变量或声明变量之前,必须指明类型,比如int, char等,但像一些灵活语言,比如python,在定义变量的时候,是不用指明具体类型的,而是编译器自动推导,这会非常方便。
于是乎, C++11也还是支持自动类型推导,即auto关键字。
在之前的 C++ 版本,auto 关键字用来指明变量的存储类型,它和 static 关键字是相对的。auto 表示变量是自动存储的,这也是编译器的默认规则,所以写不写都一样,一般我们也不写,这使得 auto 关键字的存在变得非常鸡肋。
C++11 赋予 auto 关键字新的含义,使用它来做自动类型推导。也就是说,使用了 auto 关键字以后,编译器会在编译期间自动推导出变量的类型,这样我们就不用手动指明变量的数据类型了。
基本语法:
auto name = value;
auto仅仅是一个占位符, 在编译期间,会被真正的类型替代, 即C++中的变量必须是有明确类型的,只是这个类型可以由编译器自己推导了, 这个要注意下。
简单例子:
auto n = 10; // 10默认int类型,所以推导出的n也是int
auto f = 12.8; // 12.8 默认是double,所以推导出的f是double
auto p = &n; // &n是一个int*类型指针,所以推导出变量p类型int*
auto url = "hello world"; // ""是一个const char*类型,所以推导出的url是const char*,即常量指针
连续定义多个变量:
int n = 20;
auto *p = &n, m = 99;
// &n是int *, 所以推导出auto是int, 后面的m变量也自然是int, 此时如果m=12.6就报错了
auto 除了可以独立使用,还可以和某些具体类型混合使用
int x = 0;
auto *p1 = &x; //p1 为 int *,auto 推导为 int
auto p2 = &x; //p2 为 int*,auto 推导为 int*
auto &r1 = x; //r1 为 int&,auto 推导为 int
auto r2 = r1; //r2 为 int,auto 推导为 int
// 最后这个要注意, r1本来是int&类型, 但auto被推到为int类型, 这说明当=右边的表达式是
// 引用类型的时候, auto会把引用抛弃,直接推导出它原始类型
另外,就是auto和const结合:
int x = 0;
const auto n = x; //n 为 const int ,auto 被推导为 int
auto f = n; //f 为 const int,auto 被推导为 int(const 属性被抛弃)
const auto &r1 = x; //r1 为 const int& 类型,auto 被推导为 int
auto &r2 = r1; //r1 为 const int& 类型,auto 被推导为 const int 类型
这里的注意点:
auto的使用时有限制的,总结如下: (重点)
使用auto的时候,必须对变量进行初始化,因为auto是"占位符", 具体类型推导,得看后面初始化的部分
auto不能在函数的参数中使用, 和上面原理一样, 函数的参数只是声明, 调用函数的时候才会给参数赋值, 但要是弄一个auto, 在编译的时候,并不会调用函数,此时编译器依然不知道怎么推导?
auto不能作用于类的非静态成员变量(没有static关键字修饰的成员变量), 这个原理其实和上面依然一样,就是始终一句话: auto只是占位符, 在编译的时候,编译器会根据他初始化的类型自动推导把auto替换成真正的类型。 那么对于类的非静态成员,其实是先有了对象之后,才会有, 那么对象是什么时候创建的呢? 之前整理第二篇文章的时候写到C++对象的创建过程, 主要是①分配内存空间 ②初始化成员变量 ③构造函数方法赋值, 而分配内存空间, 对于分配在栈区的对象,全局对象,静态对象是编译阶段完成, 而分配在堆区的对象(new)是运行阶段完成, 那么这里就发现, auto作用于非静态成员不合适了。
auto关键字不能定义数组
char url[] = "http://c.biancheng.net/";
auto str[] = url; //arr 为数组,所以不能使用 auto
auto不能用于模板参数
template <typename T>
class A{
//TODO:
};
int main(){
A<int> C1;
A<auto> C2 = C1; //错误
return 0;
}
auto在实际开发中两个典型应用场景:
auto定义迭代器
使用stl容器的时候, 需要使用迭代器遍历容器里面元素,不同容器迭代器有不同类型,在定义迭代器时候必须指明, 但有的比较复杂,书写麻烦,此时就可以使用auto
// 之前
vector< vector<int> > v;
vector< vector<int> >::iterator i = v.begin();
// 有了auto
vector< vector<int>>v;
auto i = v.begin();
auto 可以根据表达式 v.begin() 的类型(begin() 函数的返回值类型)来推导出变量 i 的类型。
auto用于泛型编程
auto 的另一个应用就是当我们不知道变量是什么类型,或者不希望指明具体类型的时候,比如泛型编程中。
class A{
public:
static int get(void){
return 100;
}
};
class B{
public:
static const char* get(void){
return "hello world";
}
};
template <typename T>
void func(void){
auto val = T::get();
cout << val << endl;
}
int main(void){
func<A>();
func<B>();
}
本例中的模板函数 func()
会调用所有类的静态函数 get()
,并对它的返回值做统一处理,但是 get()
的返回值类型并不一样,而且不能自动转换。这种要求在以前的 C++ 版本中实现起来非常的麻烦,需要额外增加一个模板参数,并在调用时手动给该模板参数赋值,用以指明变量 val 的类型。
template <typename T1, typename T2> //额外增加一个模板参数 T2
void func(void){
T2 val = T1::get();
cout << val << endl;
}
//调用时也要手动给模板参数赋值
func<A, int>();
func<B, const char*>();
但有了auto类型自动推导, 编译器根据get()的返回值自己推导出val变量的类型,不用再增加模板参数了。
decltype是C++ 11新增的关键字,和auto功能一样, 都用来在编译时期进行自动类型推导。decltype的全称"declear type"。
那么既然有了auto,为啥又搞个decltype关键字呢? 因为auto并不适用于所有自动类型推导场景,某些特殊情况下,auto用起来非常不方便,甚至无法使用
auto和decltype关键字都可以自动推导变量类型,但用法有区别:
auto varname = value;
decltype(exp) varname = value;
auto
根据=
右边的初始值value推导出变量的类型,而decltype
根据exp
表达式推导出变量的类型, 跟=
右边的value没有关系。
另外, auto要求变量必须初始化, 而declytpe不要求, 因为auto是根据变量的初始值推导变量类型,如果不初始化, 变量类型无法推导, 而decltype可以这样玩:
decltype(exp) varname;
exp是一个普通表达式, 但必须保证exp结果是有类型的,不能是void, 否则编译器无法推导类型。
int a = 0;
decltype(a) b = 1; // b推导成int
decltype(10.8) x = 5.5; // x 推导成double
decltype(x+100) y; // y被推导成double
decltype能根据变量,字面量,带有运算符表达式推导变量类型。
decltype推导规则:
如果exp是一个不被括号()
包围的表达式,或者是一个类成员访问表达式,或者是一个单独变量,那么decltype(exp)
的类型和exp一致, 这是最普遍常见的情况
int n = 0;
const int &r = n;
Student stu;
decltype(n) a = n; //n 为 int 类型,a 被推导为 int 类型
decltype(r) b = n; //r 为 const int& 类型, b 被推导为 const int& 类型
decltype(Student::total) c = 0; //total 为类 Student 的一个 int 类型的成员变量,c 被推导为 int 类型
decltype(stu.name) url = "hello world"; //total 为类 Student 的一个 string 类型的成员变量, url 被推导为 string 类型
如果exp是函数调用,那么decltype(exp)的类型和函数返回值类型一致
//函数声明
int& func_int_r(int, char); //返回值为 int&
int&& func_int_rr(void); //返回值为 int&&
int func_int(double); //返回值为 int
const int& fun_cint_r(int, int, int); //返回值为 const int&
const int&& func_cint_rr(void); //返回值为 const int&&
//decltype类型推导
int n = 100;
decltype(func_int_r(100, 'A')) a = n; //a 的类型为 int&
decltype(func_int_rr()) b = 0; //b 的类型为 int&&
decltype(func_int(10.5)) c = 0; //c 的类型为 int
decltype(fun_cint_r(1,2,3)) x = n; //x 的类型为 const int &
decltype(func_cint_rr()) y = 0; // y 的类型为 const int&&
exp调用函数时需要带上括号和参数, 但仅仅是形式,并不会真的执行函数代码。
如果exp是一个左值,或被括号()
包围,那么decltype(exp)类型就是exp的引用, 假设exp类型是T, 那么decltype(exp)类型就是T&。
const Base obj;
//带有括号的表达式
decltype(obj.x) a = 0; //obj.x 为类的成员访问表达式,符合推导规则一,a 的类型为 int
decltype((obj.x)) b = a; //obj.x 带有括号,符合推导规则三,b 的类型为 int&。
//加法表达式
int n = 0, m = 0;
decltype(n + m) c = 0; //n+m 得到一个右值,符合推导规则一,所以推导结果为 int
decltype(n = n + m) d = c; //n=n+m 得到一个左值,符号推导规则三,所以推导结果为 int&
这里的被括号包围,是decltype((exp))
, 重点理解下左值和右值: 左值是指在表达式执行结束后依然存在的数据(持久性), 而右值指在表达式执行结束后不再存在的数据(临时性), 简单区分的方法: 对表达式取地址,如果编译器不报错就是左值,否则是右值。
auto语法格式更简单, 在一般类型推导中,使用auto会更方便, 但auto使用时有个限制是只能用于类的静态成员, 不能用于类的非静态成员, 如果想推导非静态成员类型, 就必须使用decltype了。
template <typename T>
class Base {
public:
void func(T& container) {
m_it = container.begin();
}
private:
typename T::iterator m_it; //注意这里
};
// main()
const vector<int> v;
Base<const vector<int>> obj;
obj.func(v);
单独看 Base 类中 m_it 成员的定义,很难看出会有什么错误,但在使用 Base 类的时候,如果传入一个 const 类型的容器,编译器马上就会弹出一大堆错误信息。原因就在于,T::iterator
并不能包括所有的迭代器类型,当 T 是一个 const 容器时,应当使用 const_iterator。
要解决这个问题, C++98下只能想办法把const类型的容器用模板单独处理, 但decltype关键字可以完美解决:
template<typename T>
class Base {
public:
void func(T& container) {
m_it = container.begin();
}
private:
decltype(T().begin()) m_it; //注意这里
};
这俩哥们都用于自动类型推导, 语法是有格式区别:
auto varname = value; //auto的语法格式
decltype(exp) varname [= value]; //decltype的语法格式
auto和decltype都会自动推导变量varname的类型
=
右边的初始值 value 推导出变量的类型;=
右边的 value 没有关系。auto
要求变量必须初始化,也就是在定义变量的同时必须给它赋值;而 decltype
不要求,初始化与否都不影响变量的类型。
具体处理区别:
对cv限定符的处理
cv限定符是const和volatile关键字的统称, const关键字用来表示数据只读, volatile表示数据是可变,易变的,目的不让CPU将数据缓存到寄存器,而是从原始内存读取。
在推导变量类型时,auto和decltype对cv限制符处理不一样。 decltype会保留cv限定符,而auto有可能会去掉cv限定符。
//非指针非引用类型
const int n1 = 0;
auto n2 = 10;
n2 = 99; //赋值不报错
decltype(n1) n3 = 20;
n3 = 5; //赋值报错
//指针类型
const int *p1 = &n1;
auto p2 = p1;
*p2 = 66; //赋值报错
decltype(p1) p3 = p1;
*p3 = 19; //赋值报错
对引用的处理
当表达式类型为引用时, decltype会保留引用类型,而auto会抛弃引用类型,直接推导出原始类型。
int n = 10;
int &r1 = n;
auto r2 = r1; // 推导r2是int
r2 = 20;
cout << n << r1 << r2; // 10 10 20
decltype(r1) r3 = n; // 推导r3是int &
r3 = 99;
cout << n << r1 << r2; // 99 99 99
从运行结果可以发现,给 r2 赋值并没有改变 n 的值,这说明 r2 没有指向 n,而是自立门户,单独拥有了一块内存,这就证明 r 不再是引用类型,它的引用类型被 auto 抛弃了。
给 r3 赋值,n 的值也跟着改变了,这说明 r3 仍然指向 n,它的引用类型被 decltype 保留了。
使用建议:
auto书写格式比decltype简单,但推导规则优势复杂,有时候会改变表达式原始类型, 而decltype一般会保持原始表达式任何类型, 更加健壮些
但实际开发中一般还是auto用的多,毕竟使用简单,而简单才是美
vector<int> nums;
decltype(nums.begin()) it = nums.begin();
auto it = nums.begin(); // 清爽很多
泛型编程中,可能需要通过参数运算得到返回值的类型:
template<typename R, typename T, typename U>
R add(T t, U u){
return t + u;
}
int a = 1; float b = 2.0;
auto c = add<decltype(a+b)>(a,b);
我们并不关心a+b的类型,只需要通过decltype(a+b)
直接得到返回值类型即可,但上面这种使用不方便,因为外部其实并不知道参数之间如何运行,只有add函数才知道返回值应当如何推导。
那么,在 add 函数的定义上能不能直接通过 decltype 拿到返回值呢?
template <typename T, typename U>
decltype(t + u) add(T t, U u) // error: t、u尚未定义
{
return t + u;
}
这种写法编译不过去,因为t,u在参数列表中, 而C++返回值是前置语法,编译到这里的时候,参数变量还不存在。
C++11中,增加了返回值类型后置语法,将decltype和auto结合起来完成返回值类型推导
template<typename T, typename U>
auto add(T t, U u) -> decltype(t+u){
return t + u;
}
再看一个例子:
int& foo(int& i);
float foo(float& f);
template <typename T>
auto func(T& val) -> decltype(foo(val))
{
return foo(val);
}
在这个例子中,使用 decltype 结合返回值后置语法很容易推导出了 foo(val)
可能出现的返回值类型,并将其用到了 func 上。
返回值类型后置语法, 是为了解决函数返回值依赖于参数而导致难以确定返回值类型的问题, 有了这种语法, 对返回值类型的推导就可以用直接通过参数做运算方式描述出来。
在python中,这种语法还是很常见的:
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
当然在python中, 这个不叫返回值类型后置, 而是一种函数注解的写法,因为python是一种动态语言,变量以及函数的参数不区分类型, 但有时候, 定义函数的时候,为了能一眼判断出函数参数类型或者返回值类型, python3中就提供了这样的注解新特性。 感觉C++11的返回值类型后置,是不是和这个有点像?
C++中可以通过typedef重定义一个类型
typedef unsigned int uint_t;
被重定义的类型并不是一个新类型,仅仅是原有类型取了一个新名字,因此,下面是不合法的函数重载。
void func(unsigned int);
void func(uint_t); // error: redefinition
使用typedef定义模板很方便,但有时候也有限制,比如,无法重定义模板。
// 假设有这么个场景
typedef std::map<std::string, int> map_int_t;
typedef std::map<std::string, std::string> map_str_t;
// 我们想通过一个模板来实现一个, 固定的key string, 但不固定的值int或者string, 就需要这样
template<typename Val>
struct str_map
{
typedef std::map<std::string, Val> type;
}
str_map<int>::type map1;
这时候必须弄个str_map类才能实现这个功能, 但感觉非常繁琐。
C++11出现了一个可以重新定义一个模板的语法
template<typename Val>
using str_map_t = std::map<std::string, Val>
str_map_t<int> map1;
这里使用新的Using别名语法定义std::map模板别名, 比上面简洁很多。
using别名语法覆盖了typedef全部功能:
// 重定义unsigned int
typedef unsigned int uint_t;
using uint_t = unsigned int;
// 重定义std::map
typedef std::map<std::string, int> map_int_t;
using map_int_t = std::map<std::string, int>;
C++11的using是typedef的等价物, 但using语法和typedef一样,并不会创造新的类型。
C++98/03标准中, 类模板可以有默认的模板参数,比如
template<typename T, typename U=int, U N=0>
struct Foo
{
//...
};
但不支持函数默认模板参数
template<typename T=int>
void func()
{
//...
}
在C++11中这一限制解除。另外, 函数模板的默认模板参数在使用规则上和其他的默认参数也有一些不同,它没有必须写在参数表最后的限制。
我们可以指定函数中一部分模板参数采用默认参数,而另一部分使用自动推导。
template <typename R = int, typename U>
R func(U val)
{
return val;
}
int main()
{
func(97); // R=int, U=int
func<char>(97); // R=char, U=int
func<double, int>(97); // R=double, U=int
return 0;
}
可变参数,指参数的个数和类型都可以是任意的。和python里面的可变参数类似,比如def func(*numbers)
。C++11标准之前, 函数模板和类模板只能设定固定数量模板参数, C++11允许模板中欧包含任意数量的模板参数,即可变参数模板。
template<typename... T>
void vair_func(T...args){
// ...
cout << sizeof...(args) << endl; // 打印变参的个数
}
typename...
表示T是一个可变模板参数, 可以接收多种数据类型, 称为模板参数包。vair_func()函数中, args参数的类型是T...
表示,表示args可以接收任意个参数, 称函数参数包。此函数模板最终实例化出的函数可以指定任意类型,任意数量的参数:
vair_fun();
vair_fun(1, "abc")
vair_fun(1, "abc", 1.23);
使用可变参数模板的难点,在于模板函数内部的"解包"过程。即怎么在函数里面拿到包内的数据?
递归方式解包
递归函数展开参数包,需要提供一个参数包展开的函数和一个递归终止函数,递归终止函数正是用来终止递归的
#include <iostream>
using namespace std;
// 递归终止函数
void vir_func(){
}
// 展开函数
template<typename T, typename...args>
void vir_func(T argc, args... argv){
cout << argc << endl;
// 开始递归, 将第一个参数外的argv参数包重新传递给vir_func
vir_func(argv...);
}
int main()
{
vir_func(1, "hello world", 2.34);
return 0;
}
这种程序还是第一次见, 首先分析执行过程:
下面看一个可变参数求和的例子, 好知道怎么利用起每个参数来:
template<typename T>
T sum(T t)
{
return t;
}
template<typename T, typename ... Types>
T sum (T first, Types ... rest)
{
return first + sum<T>(rest...);
}
sum(1,2,3,4); //10
非递归方法解包
递归函数展开参数包是一种标准做法,也比较好理解,但也有一个缺点,就是必须要一个重载的递归终止函数,即必须要有一个同名的终止函数来终止递归,这样可能会感觉稍有不便。有没有一种更简单的方式呢?其实还有一种方法可以不通过递归方式来展开参数包,这种方式需要借助逗号表达式和初始化列表
template <class T>
void printarg(T t)
{
cout << t << endl;
}
template <class ...Args>
void expand(Args... args)
{
int arr[] = {(printarg(args), 0)...};
}
expand(1,2,3,4);
以{}初始化方式对数组arr进行初始化, (printarg(args), 0)...
会依次展开成 (printarg(1), 0), (printarg(2), 0), (printarg(3), 0), (printarg(4), 0)
。每个元素都是逗号表达式,首先会计算前面的printage(i), 成功后返回0标识。 即arr[]
都是printage返回成功的状态0。这个数组纯粹是为了将参数包展开。
C++11 标准中,类模板中的模板参数也可以是一个可变参数。C++ 11 标准提供的 tuple 元组类就是一个典型的可变参数模板类
template<typename... Types>
class tuple;
该模板类实例化时,可以接收任意数量,任意类型的模板参数
std::tuple<> tp0;
std::tuple<int> tp1=std::make_tuple(1);
std::tuple<int, double> tp2=std::make_tuple(1, 2.34);
std::tuple<int, double, string> tp3=std::make_tuple(1, 2.34, "hello world");
可变参数的类模板参数解包, 可以采用"递归+继承"的方式:
#include <iostream>
// 声明模板类demp
template<typename... Values>class demo;
// 继承式递归的出口
template<>class demo<>{};
// 继承的方式解包
template<typename Head, typename... Tail>
class demo<Head, Tail...>:private demo<Tail...>{
public:
// 子类实例化的时候,父类也会实例化, 递归发生在这里
demo(Head v, Tail... vtail):m_head(v), demo<Tail...>(vtail...){
dis_head();
}
void dis_head(){std::cout << m_head << std::endl;}
protected:
Head m_head;
};
int main()
{
demo<int, float, std::string> t(1, 2.34, "hello world");
return 0;
}
demo<Head, Tail...>
类实例化时,由于其继承自 demo<Tail...>
类,因此父类也会实例化,一直递归至 Tail 参数包为空,此时会调用模板参数列表为空的 demo 模板类。
C++11标准引入一种类模板tuple, 实例化的对象可以存储任意数量,任意类型的数据
应用场景:需要存储多个不同类型元素,可以使用tuple;当函数需要返回多个数据,可以将数据存储tuple,返回tuple对象
如果使用tuple类模板, 先引入:
#include <tuple>
using std::tuple;
实例化tuple模板类对象常用方法两种:该类的构造函数和make_tuple()函数。
构造函数方法
构造函数很多个,原型可以看上面的链接,这里简单的列几个使用:
std::tuple<int, char> first; // 1) first{}
std::tuple<int, char> second(first); // 2) second{}
std::tuple<int, char> third(std::make_tuple(20, 'b')); // 3) third{20,'b'}
std::tuple<long, char> fourth(third); // 4)的左值方式, fourth{20,'b'}
std::tuple<int, char> fifth(10, 'a'); // 5)的右值方式, fifth{10.'a'}
std::tuple<int, char> sixth(std::make_pair(30, 'c')); // 6)的右值方式, sixth{30,''c}
make_tuple函数
功能是创建一个tuple右值对象
auto first = std::make_tuple (10,'a'); // tuple < int, char >
const int a = 0; int b[3];
auto second = std::make_tuple (a,b); // tuple < int, int* >
tuple模板类提供了功能实用的成员函数,具体可以先看http://c.biancheng.net/view/8600.html, 后面具体用到的时候再进行整理。
匿名函数, 是没有名称的函数, 又称为lambda函数或者lambda表达式, 在python里面,这种函数其实非常的常见, 比如:
def sum(x,y):
return x+y
// 换成匿名函数
p = lambda x, y: x+y
p(3, 4)
这种函数使用起来就比较简洁, 并且这种定义方式离得使用地方很近, 直接就能看到函数体, 另外就是效率上也比较高, 有了lambda函数, 我们也能将类似于函数的表达式用作接受函数指针或者函数符的函数参数(使用变量接受lambda函数, 作为函数指针的效果), 这就是为啥要引入lambda函数的原因。
那么如何使用呢? 语法格式如下:
[外部变量访问方式说明符](参数列表) mutable noexcept/throw() -> 返回值类型
{
函数体;
};
说明:
[外部变量访问方式说明符]: 告诉编译器当前是一个lambda表达式, 方括号内部,注明当前lambda函数体中可以使用哪些"外部变量"(和当前lambda表达式位于同一作用域的局部变量), 不能省略, 下面总结下说明符这里的几种编写格式:
外部变量格式 | 功能 |
---|---|
[] | 当前 lambda 匿名函数中不导入任何外部变量 |
[=] | 以值传递的方式导入所有外部变量 |
[&] | 以引用传递的方式导入所有外部变量 |
[val1, val2, …] | 以值传递的方式导入 val1、val2 等指定的外部变量,多个变量之间没有先后次序 |
[&val1, &val2, …] | 以引用传递的方式导入 val1、val2 等指定的外部变量,多个变量之间没有先后次序 |
[val1, &val2, …] | 混合使用,多个变量之间没有先后次序 |
[=, &val1, …] | 除 val1 以引用传递的方式导入外,其它外部变量都以值传递的方式导入 |
[this] | 以值传递的方式导入当前的 this 指针, 类里面会用到 |
注意:单个外部变量不允许以相同的传递方式导入多次,比如[=, val1] , 这种是错误的(值传递方式导入2次)。 |
(参数列表): 匿名函数接收外部传递的参数, 如果不需要传递参数, 可以同()一起省略, 下面盘点下参数列表和普通函数的参数不一样的小地方
mutable: 默认情况下, 对于以值传递方式引入的外部变量, 不允许在lambda表达式内修改它们的值(可以理解为const), 如果想修改,就必须用mutable关键字。 可省略, 但如果使用, 前面()就必须有了。 注,对于值传递引入的外部变量,lambda表达式修改的是拷贝的那一份, 并不会修改真正外部变量
noexcept/throw(): 默认情况, lambda函数的函数体可抛出任何类型异常, 而标注noexcept关键字, 函数体内不会抛出任何异常了, 而使用throw()可以指定抛出异常类型。 可省略, 但如果使用, 前面()就不能省略了。注,如果lambda函数有noexcept而函数体抛出异常,或者用了throw()指定的异常类型和实际跑出的不一样, 那么都无法用try-catch捕获
返回值类型: 指明lambda匿名函数返回值类型, 如果函数体内只有一个return语句或者返回的是void, 可以省略->返回值类型
这块
函数体: 写实现的逻辑代码, 函数体内除了可以使用传递进来的参数,也可以使用全局变量或者指定的外部变量。 外部变量会受到以值传递还是引用传递方式的影响,但全局变量不会。
OK, 整理完了语法格式, 下面就是怎么用了, 几个例子展示下即可:
排序函数后面指定排序的方式:
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
int num[4] = {8, 9, 2, 3};
// sort排序 这个非常方便
sort(num, num+4, [=](int x, int y) -> bool{return x < y; });
for (int n: num){
cout << n << " ";
}
return 0;
}
类似函数指针的方式去设置函数名称,然后使用, 和上面python那种差不多:
auto display = [](int a, int b) -> void{cout << a << " " << b;};
display(10, 20);
值传递和引用传递的区别:
// 全局变量
int all_num = 0;
int main()
{
// 局部变量
int num1 = 1, num2 = 2, num3 = 3;
// 值传递: 函数体内部只能使用外部变量,不能修改, 但全局变量除外
auto lambda1 = [=]{
// 全局变量可以访问且修改
all_num = 10;
cout << num1 << " " << num2 << " " << num3 << endl;
// num1 = 8; // error: assignment of read-only variable ‘num1’
};
lambda1(); // 1 2 3
cout << all_num << endl; // 10
// 引用传递: 函数体内部可以修改外部变量的值,且修改的是外部变量本身
auto lambda2 = [&]{
all_num = 100;
num1 = 10;
num2 = -10;
num3 = 0;
cout << num1 << " " << num2 << " " << num3 << endl; // 10 -10 0
};
lambda2();
cout << all_num; // 100
return 0;
}
如果想在值传递的基础上修改外部变量的值, 就可以借助mutable关键字, 此时参数列表的()就不能省略了,当然可以里面不写参数。
auto lambda1 = [=]() mutable{
// 全局变量可以访问且修改
all_num = 10;
// 此时外部变量也可以进行修改了
num1 = 10;
num2 = -10;
num3 = 0;
cout << num1 << " " << num2 << " " << num3 << endl; // 10 -10 0
};
cout << all_num << endl;
cout << num1 << " " << num2 << " " << num3 << endl; // 1 2 3
但是,这个虽然在函数体内部可以修改外部变量的值, 但是修改的是拷贝的那一份, 真正的外部变量的值并不会改变。
如果想抛出异常类型:
int main()
{
auto except = []() throw(int){
throw 10;
// 当真正抛出异常与指定异常不匹配
// throw 10.2; Runtime Error (NZEC)
// terminate called after throwing an instance of 'double'
};
// 另一个错误例子
auto except2 = []() noexcept{throw 100; }; // Runtime Error
try{
except();
// except2();
}catch(int){
cout << "捕获了int异常" << endl;
}
return 0;
}
如果不使用noexcept或者throw(), lambda匿名函数函数体可以发生任何类型的异常。
C/C++中, 联合体(union)是一种构造数据类型, 在一个联合体内,可以定义多个不同类型的成员,这些成员将共享同一块内存空间。 老版C++为了和C兼容, 对联合体数据成员进行了很大程序限制, 而C++11取消了这些限制。
首先, 为了整理这一节, 我得先回顾下C语言里面的联合体是啥东西来。
C语言里面的结构体是一种构造类型, 可以包括多个类型的不同成员, 其实还有另外与结构体非常类似的语法,就是联合体或者叫共用体(union), 定义格式:
union 共用体名{
成员列表
};
那么,既然有了结构体了,又整出个这玩意有啥用呢?
结构体和共用体之间的区别: 结构体的各个成员会占用不同的内存,彼此不影响; 而共用体的所有成员, 占用同一段内存,修改一个成员会影响其余所有成员。
这也就是说结构体占用的内存大于等于所有成员占用的内存总和, 而共用体占用内存等于最长成员占用的内存。
共用体使用了内存覆盖技术, 同一时刻只能保存一个成员的值,如果对新成员赋值, 会把原来成员的值盖掉。
具体使用和结构体类似:
union data{
int n;
char ch;
double f;
};
union data a, b, c;
共用体 data 中,成员 f 占用的内存最多,为 8 个字节,所以 data 类型的变量(也就是 a、b、c)也占用 8 个字节的内存。
下面看一个例子,感受下内存共享
#include <stdio.h>
union data{
int n;
char ch;
short m;
};
int main()
{
union data a;
printf("%d, %d\n", sizeof(a), sizeof(union data)); // 4 4
a.n = 0x40;
printf("%X, %c, %hX\n", a.n, a.ch, a.m); // 40 @ 40
a.ch = '9';
printf("%X, %c, %hX\n", a.n, a.ch, a.m); // 39 9 39
a.m = 0x2059;
printf("%X, %c, %hX\n", a.n, a.ch, a.m); //2059, Y, 2059
a.n = 0x3E25AD54;
printf("%X, %c, %hX\n", a.n, a.ch, a.m); // 3E25AD54, T, AD54
return 0;
}
共用体的长度为最长成员的内存, 而修改其中一个成员,其他成员也会受到影响。 各个成员在内存中分布如下:
成员 n、ch、m 在内存中“对齐”到一头,对 ch 赋值修改的是前一个字节,对 m 赋值修改的是前两个字节,对 n 赋值修改的是全部字节。也就是说,ch、m 会影响到 n 的一部分数据,而 n 会影响到 ch、m 的全部数据。
共用体常用在单片机中, 因为单片机里面内存分布情况和上面不太一样,和pc机对其的端不一样。在普通pc机上用到的很少, 常用的一个案例是学生和教师,有姓名,编号, 性别,职业四个共有属性, 学生有分数,教师有教学科目这一个不同属性, 现要求把这些信息放在同一个表格里面,比如:
姓名 | 编号 | 性别 | 职业 | 分数/科目 |
---|---|---|---|---|
吴忠强 | 110 | 男 | 学生 | 66.6 |
张三 | 666 | 女 | 教师 | 语文 |
这时候,可以设计一个包含共用体的结构体: |
struct{
char name[20];
int num;
char sex;
char profession;
// 这个属性和职业相关,是老师,那就用course, 是学生,就用score
union{
float score;
char course[20];
} sc;
} bodys[2]; // 2表示人员总数
C++11规定, 任何非引用类型都可以成为联合体的数据成员,这种联合体叫非受限联合体。 啥意思?
class Student{
public:
Student(bool g, int a): gender(g), age(a) {}
private:
bool gender;
int age;
};
union T{
Student s; // 含有非POD类型的成员,gcc-5.1.0 版本报错
char name[10];
};
这个代码中, Student类带有自定义的构造函数,是一个非POD类型, 放到C++11之前, 这会导致报错。而C++11, 允许联合体的成员是非POD类型的了。
POD是C++中一个重要概念,全称Plain Old Data, 用来描述一个类型的属性。有下面几个特征:
- 没有用户自定义的构造函数,析构函数,拷贝构造函数和移动构造函数
- 不能包含虚函数和虚基类
- 非静态成员必须声明为public
- 类中的第一个非静态成员类型与其基类不同, 比如
class B1{}; class B2:B1{B1 b;}
,B2类就不是POD类型,因为它第一个非静态成员b的类型和基类类型一样。- 在类或者结构体继承时, 满足"派生类中有非静态成员,且只有一个仅包含静态成员的基类"或者"基类有非静态成员, 而派生类无非静态成员"这两种情况之一。比如,
class B1{static int n;}, class B2: B1{int n1;}, class B3: B2{static int n2;}
, B2中有非静态成员,且只有一个仅包含静态成员的基类B1,所有B2是POD类型, 而B3, 基类B2有非静态成员, 而B3中没有非静态成员,也是POD类型。- POD类型不能包含非POD类型的数据
- 所有兼容C语言的数据类型都是 POD 类型(struct、union 等不能违背上述规则), 到了C++11, union中允许有非POD类型数据了。
另外, C++11删除联合体不允许拥有静态成员的限制
union U {
static int func() {
int n = 3;
return n;
}
};
但静态成员变量只能在联合体内定义,却不能在联合体外面使用, 所以这个其实是个鸡肋。
另外一条规则:
C++11 规定,如果非受限联合体内有一个非 POD 的成员,而该成员拥有自定义的构造函数,那么这个非受限联合体的默认构造函数将被编译器删除;其他的特殊成员函数,例如默认拷贝构造函数、拷贝赋值操作符以及析构函数等,也将被删除。
这条规则可能导致对象构造失败:
#include <string>
using namespace std;
union U {
string s;
int n;
};
int main() {
U u; // 构造失败,因为 U 的构造函数被删除
return 0;
}
string类拥有的自定义构造函数, 所以U的构造函数被删除, 而定义U的类型变量u需要调用默认构造函数,所以u无法定义成功。 解法方法是用placement new方法。
placement new是new关键字的一种进阶用法,既可以在栈上生成对象, 也可以在堆上生成对象。而我们普通的new用法(operator new), 只能在heap上创建对象。语法格式:
new(address) ClassConstruct(...)
address表示已有内存的地址, 该内存可以在栈上,可以在堆上。 ClassConstruct(…)表示调用类的构造函数,如果构造函数没有参数,可以省略括号。
union U {
// placement new利用已经申请好的内存生成对象,不再为对象分配新的内存
// 而是将对象数据放在address指定的内存中
string s;
int n;
public:
U() { new(&s) string; }
~U() { s.~string(); }
};
这里在写U的构造函数时, 要用placement new将s构造到地址&s
上。这样, 告诉编译器在创建U的时候,要调用string类的构造函数,当然,析构的时候, 还需要调用string类的析构函数。
这样,就可以创建u了。
C++11之前, 如果for循环遍历数组或者容器,用下面结构:
for(表达式1; 表达式2; 表达式3){循环体}
比如遍历数组或者容器:
char name[] = "wuzhongqiang"
for (int i=0; i<strlen(name); i++){cout << name[i];} // wuzhognqiang
vector<char>myvector(name, name+7);
vector<char>::iterator iter;
for (iter=myvector.begin(); iter!=myvector.end(); ++iter){cout << *iter;} // wuzhong
这样写起来还是有些麻烦的,尤其是遍历容器的时候,必须用迭代器去迭代遍历。
C++11, 为for循环添加了一种全新语法格式:
for(declaration: expression){循环体}
两个参数各自含义:
基于新语法把上面的遍历写一遍:
// 这样就简洁了很多
for(char ch: name){cout << ch;}
cout << "!" << endl; //wuzhongqaing !
for (auto ch: myvector){cout << ch;}
cout << "!" << endl; // wuzhongqiang!
两点需要说明:
在使用新语法格式的 for 循环遍历某个序列时,如果需要遍历的同时修改序列中元素的值,实现方案是在 declaration 参数处定义引用形式的变量
char arc[] = "abcde";
vector<char>myvector(arc, arc + 5);
//for循环遍历并修改容器中各个字符的值
for (auto &ch : myvector) {
ch++;
}
// 如果在遍历过程中,不修改容器内部的元素值, 可以定义普通变量
// 也可以定义常引用const &, 后者形式的遍历,省去了底层复制变量的过程,效率更高
for (auto const &ch: myvector){}
这里总结使用基于范围for循环的一些注意点:
for 循环遍历序列(普通数组,容器,还是{}包裹的初始化列表), 遍历序列的变量都表示当前序列的各个元素,而不是指向各个元素的迭代器
map<string, string>mymap{ {"C++11","http://c.biancheng.net/cplus/11/"},
{"Python","http://c.biancheng.net/python/"},
{"Java","http://c.biancheng.net/java/"} };
// 这里遍历定义的直接是pair类型的变量,是因为map容器中存储的是pair类型的数据
for (pair<string,string> ch : mymap) {
cout << ch.first << " " << ch.second << endl;
}
基于范围for循环可以遍历普通数组,string字符串, 容器以及初始化列表,另外,还可以放置返回string字符串以及容器对象的函数, 但不支持遍历函数返回以指针形式表示的数组,因为此格式for循环只能遍历有明确范围的一组数据, 如果函数返回指针变量数组, 遍历范围不明确。
char str[] = "hello world";
char* retStr() {
return str;
}
for (char ch : retStr()) //直接报错
{
cout << ch;
}
使用基于范围的 for 循环遍历此类型容器时,切勿修改容器中不允许被修改的数据部分,否则会导致程序的执行出现各种 Bug。
std::vector<int>arr = { 1, 2, 3, 4, 5 };
for (auto val : arr)
{
std::cout << val << std::endl;
arr.push_back(10); //向容器中添加元素
}
for 循环遍历 arr 容器的同时向该容器尾部添加了新的元素(对 arr 容器进行了扩增),致使遍历容器所使用的迭代器失效,整个遍历过程会出现错误。
C++的常量表达式指的是由多个常量组成的表达式,常量表达式的一旦确定,值无法修改。
常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算出结果, 而常量表达式计算往往发生在程序编译阶段,这可以极大提高程序执行效率, 因为表达式只需要在编译阶段计算一次,节省每次程序运行都需要计算一次时间。
那么实际开发中,如何才能判定一个表达式是否为常量表达式,进而获得在编译阶段可执行的权力呢?
C++11标准提供了constexpr关键字,使指定的常量表达式获得在程序编译阶段计算出结果的能力,不必等到程序运行阶段。 constexpr关键字可用于修饰普通变量,函数(包括模板函数)以及类的构造函数。
修饰普通变量
声明为constexpr的变量一定是一个const变量,且必须用常量表达式初始化
constexpr int mf = 20; //20是常量表达式
constexpr int limit = mf + 1; // mf + 1是常量表达式
constexpr int sz = size(); //之后当size是一个constexpr函数时才是一条正确的声明语句
另外需要重点提出的是,当常量表达式中包含浮点数时,考虑到程序编译和运行所在的系统环境可能不同,常量表达式在编译阶段和运行阶段计算出的结果精度很可能会受到影响,因此 C++11 标准规定,浮点常量表达式在编译阶段计算的精度要至少等于(或者高于)运行阶段计算出的精度。
修饰函数
constexpr 还可以用于修饰函数的返回值,这样的函数又称为“常量表达式函数”。
注意,constexpr 并非可以修改任意函数的返回值。必须满足如下 4 个条件
整个函数体中,除了可以包含using指令, typedef语句以及static_assert断言外,只能包含一条return返回语句
constexpr int display(int x) {
//可以添加 using 执行、typedef 语句以及 static_assert 断言
return 1 + 2 + x;
}
但我在上面网站测试的时候, 这个感觉不成立了? 可以包含多条语句嘛这不是?
该函数必须有返回值,返回值类型不能是void
constexpr void display() {
//函数体
}
类似的函数无法获得一个常量。
return 返回的表达式必须是常量表达式
int num = 3;
constexpr int display(int x){
return num + x;
}
// error: the value of ‘num’ is not usable in a constant expression
常量表达式函数返回值必须是常量表达式,因为程序在编译阶段要获得某个函数返回的常量,那么该函数返回语句中就不能包含运行阶段才能确定值的变量。
修饰类的构造函数
对于 C++ 内置类型的数据,可以直接用 constexpr 修饰,但如果是自定义的数据类型(用 struct 或者 class 实现),直接用 constexpr 修饰是不行的。
//自定义类型的定义
constexpr struct myType {
const char* name;
int age;
//其它结构体成员
};
int main()
{
// error: ‘constexpr’ cannot be used for type declarations
constexpr struct myType mt { "zhangsan", 10 };
cout << mt.name << " " << mt.age << endl;
return 0;
}
如果想自定义一个可产生常量的类型, 必须在该类型内部添加一个常量构造函数。
//自定义类型的定义
struct myType {
constexpr myType(char *name,int age):name(name),age(age){};
const char* name;
int age;
//其它结构体成员
};
constexpr修饰类的构造函数时, 要求该构造函数的函数体必须是空,且采用初始化列表方式的方式给各个成员赋值的时候,必须使用常量表达式,即加constexpr修饰。
constexpr 是 C++11 引入的,一方面是为了引入更多的编译时计算能力,另一方面也是解决 C++98 的 const 的双重语义问题。
在 C 里面,const 很明确只有「只读」一个语义,不会混淆。C++ 在此基础上增加了「常量」语义,也由 const 关键字来承担,引出来一些奇怪的问题。C++11 把「常量」语义拆出来,交给新引入的 constexpr 关键字。
这里面有个词叫做"双重语义", 那么是啥子意思? 这个例子nice:
void dis_1(const int x){
//错误,x是只读的变量
array <int,x> myarr{1,2,3,4,5}; // error: ‘x’ is not a constant expression
cout << myarr[1] << endl;
}
void dis_2(){
const int x = 5; // 规范写法应该是constexpr
array <int,x> myarr{1,2,3,4,5};
cout << myarr[1] << endl;
}
这两个函数里面都有一个const int x
, 但dis_1()
函数中的x无法完成初始化array容器的任务, 而dis_2()
函数里面的x却可以。
这是因为, dis_1()函数中的
const int x
只是强调x是一个只读的变量, 其本质仍是变量, 无法初始化array容器。 而dis_2()函数中的const int x = 5
,表明x是一个只读变量的同时, x还是一个值为5的常量。 区别在于后者是定义同时进行初始化, 这时候x就成了常量, 而前者只规定read-only, 但是个变量。 C++11之前, const代表了这两种语义。
C++11中, 保留了const"只读"的含义, 把"常量"的语义划分给了新添加的constexpr关键字, 所有在C++11后, 凡是表达"只读"场景建议用const, 表达"常量"语义场景用constexpr。
"只读"和"不允许修改"之间并没有联系, "只读"是无法通过变量自身去修改自己的值, 但是可能会借助其他变量间接修改,比如:
int a = 10;
const int & con_b = a;
cout << con_b << endl; // 10
a = 20;
cout << con_b << endl; // 20 通过a把con_b值修改了
在大部分实际场景中,const 和 constexpr 是可以混用的, 比如:
const int a = 5 + 4;
constexpr int a = 5 + 4;
但某些场景,必须明确用constexpr关键字:
constexpr int sqr1(int arg){
return arg*arg;
}
const int sqr2(int arg){
return arg*arg;
}
array<int,sqr1(10)> mylist1;//可以,因为sqr1是constexpr函数
array<int,sqr2(10)> mylist1;//不可以,因为sqr2不是constexpr函数
只有常量才能初始化 array 容器, 所以上面的sqr2不能初始化array容器。
总之, const 用于为修饰的变量添加“只读”属性;而 constexpr 关键字则用于指明其后是一个常量(或者常量表达式),编译器在编译程序时可以顺带将其结果计算出来,而无需等到程序运行阶段,这样的优化极大地提高了程序的执行效率
右值引用, 是C++11新特性中最重要特性之一,指的是以引用传递(非值传递)的方式使用C++右值。
那么什么是C++右值呢?
C++中,一个表达式(字面量,变量, 对象,函数返回值等), 根据使用场景不同, 分为左值表达式和右值表达式
通常情况下, 判断某个表达式是左值还是右值, 常用以下2种方法:
可位于赋值号(=)左侧的表达式是左值, 而只能位于赋值号右侧的表达式是右值。
int a = 5;
5 = a; //错误,5 不能为左值
有名称的,可以获取到存储地址的表达式即为左值,否则为右值
比如上面的a是变量名,可以通过&a获得它们的存储地址,所以a是左值, 而5没有名称,无法获得存储地址(字面量通常存储在寄存器中或者代码存储在一起), 所以5是右值。
C++98/03标准中的引用缺陷: 正常情况下只能操作C++中的左值, 无法对右值添加引用。
int num = 10;
int &b = num; //正确
int &c = 10; //错误
这种引用称为左值引用, 虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:
int num = 10;
const int &b = num;
const int &c = 10;
我们知道, 右值往往没有名称, 因此使用它只能借助引用的方式, 这就产生一个问题,实际开发中,我们可能需要对右值修改(移动语义时需要), 这时候显然左值引用的方式行不通。
C++11标准引入了右值引用, 用&&
表示。
和声明左值引用一样, 右值引用必须立即进行初始化操作,且只能使用右值引用进行初始化
比如下面这个例子:
int num = 10;
// error: cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’
//int && a = num; //右值引用不能初始化为左值
int && a = 10;
关于左值引用和右值引用的左值,右值引用, 一图胜千言:
右值引用的主要用途是移动语义和完美转发,下面就重点看看这两个。
C++11标准之前,如果想用其他对象初始化一个同类的新对象, 只能借助类中的复制(拷贝)构造函数。拷贝构造函数实现原理就是,为新对象复制一份和其他对象一模一样的数据。
需要注意的是,当类中拥有指针类型的成员变量时,拷贝构造函数中需要以深拷贝(而非浅拷贝)的方式复制该指针成员。
这里重点看下面这个例子:
class demo{
public:
demo(): num(new int(0)){cout << "construct!" << endl;}
// 拷贝构造函数 这里是深拷贝的方式
demo(const demo &d): num(new int(*d.num)){cout << "copy construct!" << endl;}
~demo(){cout << "class desturct!" << endl;
};
private:
int *num;
};
demo get_demo(){
return demo(); // 匿名对象
// demo() 会调用默认构造函数创建对象
// return demo() 会调用拷贝构造函数, 把创建的对象再临时复制一份, 作为get_demo()函数返回值
}
int main()
{
demo a = get_demo(); // 会再次调用拷贝构造函数, 把函数返回值的对象复制给a
return 0;
}
这个例子中, demo 类自定义了一个拷贝构造函数,该函数在拷贝 d.num
指针成员时,必须采用深拷贝的方式,即拷贝该指针成员本身的同时,还要拷贝指针指向的内存资源。否则一旦多个对象中的指针成员指向同一块堆空间,这些对象析构时就会对该空间释放多次,这是不允许的。
首先get_demo函数里面的
demo()
会调用默认构造函数生成一个匿名对象, 然后return demo()
会调用拷贝构造函数复制一份前面生成的匿名对象(临时对象),并将其作为get_demo()
函数返回值, 而主函数里面会再次调用拷贝构造函数,将之前拷贝得到的临时对象复制给a。
上面利用拷贝构造函数实现对 a a a对象的初始化, 底层实际上进行了2次深拷贝操作,
如果仅申请少量堆空间的临时对象, 深拷贝的执行效率可以接受, 但如果临时对象中的指针成员申请大量的堆空间, 那么2次深拷贝操作就会影响a对象初始化的执行效率了。
那么, 为了解决类中包含指针类型的成员变量,使用其他对象来初始化同类对象过程中,由于深拷贝带来的对象初始化执行效率问题, C++11标准借助了右值引用的语法, 实现移动语义。
所谓移动语义, 指的是以移动而非深拷贝的方式初始化含有指针成员的类对象, 简单理解, 移动语义指的是将其他对象(通常是临时对象)拥有的内存资源"移为己用"。
上面那个例子里面, 使用get_demo()
函数返回的临时对象初始化a时候, 此时会重新调用拷贝构造函数进行深拷贝,即num指针指向的资源进行复制。但实际上更高效的做法是, 将临时对象的num指针直接拷贝给a.num, 然后把临时对象中num指针的指向改为NULL, 这样意味着直接将临时对象指针成员指向的内存资源移到了新对象a上面, 而无需把内存资源重新复制一般再给到a, 大大节省了运算效率。
下面是给上面的demo类修改:
class demo{
public:
demo(): num(new int(0)){cout << "construct!" << endl;}
// 拷贝构造函数
demo(const demo &d): num(new int(*d.num)){cout << "copy construct!" << endl;}
// 添加移动构造函数
demo(demo &&d): num(d.num){
d.num = NULL;
cout << "move construct!" << endl;
}
~demo(){cout << "class desturct!" << endl;
};
private:
int *num;
};
移动构造函数采用右值引用形式的参数, 在函数体里面, num指针变量采用了浅拷贝的复制方式,同时在函数内部重置了d.num, 避免了"同一块内存空间被释放多次"情况发生。 这样, 使用临时对象初始化a对象过程中产生的2次深拷贝操作,都转成了移动构造函数里面的浅拷贝。
非const右值引用只能操作右值,程序执行结果中产生的临时对象(例如函数返回值, lambda表达式等)既没有名称,也没办法获取存储地址,所以属于右值。 当类中同时包含拷贝构造函数和移动构造函数时, 如果使用临时对象初始化当前类的对象, 编译器会优先调用移动构造函数来完成操作。 只有当类中没有合适的移动构造函数时, 编译器才会调用拷贝构造函数
在实际开发中,通常在类中自定义移动构造函数的同时,会再为其自定义一个适当的拷贝构造函数,由此当用户利用右值初始化类对象时,会调用移动构造函数;使用左值(非右值)初始化类对象时,会调用拷贝构造函数。
默认情况下, 左值初始化同类对象只能通过拷贝构造函数完成, 如果想调用移动构造函数,必须使用右值初始化。 而C++11标准中为了满足能用左值初始化同类对象时也能通过移动构造函数完成的需求, 引入了std::move()
函数, 可以将左值强制转换成对应的右值,可以使用移动构造函数。
C++11 标准中借助右值引用可以为指定类添加移动构造函数,这样当使用该类的右值对象(可以理解为临时对象)初始化同类对象时,编译器会优先选择移动构造函数。
移动构造函数调用的时机: 同类的右值对象初始化新对象,右值对象一般是匿名临时对象,无法获取其存储地址
那么,当前类的左值对象(有名称,能获取其存储地址的实例对象)初始化同类对象的时候,能不能也优先选择移动构造函数呢? 那就需要move()函数的帮助了
move(arg)
: 将某个左值强制转化为右值, 常用于实现移动语义, arg表示指定的左值对象, 函数的返回是arg对象的右值形式。
看例子:
class movedemo{
public:
movedemo():num(new int(0)){cout << "construct!" << endl;}
// 拷贝构造函数
movedemo(const movedemo &d):num(new int(*d.num)){cout << "copy construct" << endl;}
// 移动构造函数
movedemo(movedemo &&d): num(d.num){
d.num = NULL;
cout << "move construct!" << endl;
}
public:
int *num;
};
int main()
{
movedemo demo; // 左值对象, 调用默认构造函数 给成员属性赋值 construct!
movedemo demo2 = demo; // 左值对象赋值,调用拷贝构造函数 copy construct
cout << *demo2.num << endl; // 0
// 用move把左值转成右值
movedemo demo3 = std::move(demo); // 此时调用移动构造函数 move construct
cout << *demo.num << endl; // 此时demo.num=NULL,所以这里会出现core dumped, 指针访问非法内存
}
左值和右值的区分对于类对象也同样适用。 左值类对象称为左值对象,右值类对象称为右值对象。
默认情况下, public修饰的成员函数,既可以被左值对象调用,也可以被右值对象调用。
class demo{
public:
demo(int num): num(num){}
int get_num(){ return this->num;}
private:
int num;
};
int main()
{
demo a(10);
cout << a.get_num() << endl; // 10
cout << move(a).get_num() << endl; // 10
return 0;
}
在某些场景下, 我们可能限制调用成员函数的对象类型(左值对象还是右值对象), 所以C++11引入了引用限定符, 所谓引用限定符,就是在成员函数后面添加&
或者&&
, 从而限制了调用者的类型。比如:
class demo{
public:
demo(int num1, int num2, int num3):num1(num1), num2(num2), num3(num3){}
int get_num1(){ return this->num1;}
// 只能被左值对象调用
int get_num2() &{return this->num2;}
// 只能被右值对象调用
int get_num3() &&{return this->num3;}
private:
int num1, num2, num3;
};
demo a(1, 2, 3);
cout << a.get_num1() << endl; // 1
cout << move(a).get_num1() << endl; // 1
cout << a.get_num2() << endl; // 2
//cout << move(a).get_num2() << endl; // rror: passing ‘std::remove_reference::type’ discards qualifiers [-fpermissive]
// cout << a.get_num3() << endl; // discards qualifiers [-fpermissive]
cout << move(a).get_num3() << endl; // 3
但注意, 引用限定符不适用于静态成员函数和友元函数。
我们知道,const 也可以用于修饰类的成员函数,我们习惯称为常成员函数
class demo{
public:
int get_num() const;
}
const 和引用限定符修饰类的成员函数时,都位于函数的末尾。C++11 标准规定,当引用限定符和 const 修饰同一个类的成员函数时,const 必须位于引用限定符前面。
需要注意的一点是,当 const && 修饰类的成员函数时,调用它的对象只能是右值对象;当 const & 修饰类的成员函数时,调用它的对象既可以是左值对象,也可以是右值对象。无论是 const && 还是 const & 限定的成员函数,内部都不允许对当前对象做修改操作。
class demo{
public:
demo(int num1, int num2, int num3):num1(num1), num2(num2), num3(num3){}
int get_num1(){ return this->num1;}
// 左值对象和右值对象都可以调用
int get_num2() const &{return this->num2;}
// 只能被右值对象调用
int get_num3() const &&{return this->num3;}
private:
int num1, num2, num3;
};
demo a(1, 2, 3);
cout << a.get_num2() << endl; // 2
cout << move(a).get_num2() << endl; // 2
// cout << a.get_num3() << endl; // discards qualifiers [-fpermissive]
cout << move(a).get_num3() << endl; // 3
完美转发指的是函数模板可以将自己的参数"完美"转发给内部调用的其他函数,所谓完美,是不仅能准确的转发参数的值, 还能保证被转发参数的左右值属性不变。
这里比较抽象了, 还是来个例子:
template <typename T>
void function(T t){
otherdef(t);
}
这个代码里面, function()
函数模板中调用了otherdef()
函数, 在此基础上, 完美转发指的是:如果function()
函数接收到的参数t是左值, 那么该函数传递给ohterdef()
的参数t也是左值, 反之,如果function()
函数接收到的参数t是右值, 那么传递给otherdef()
函数的参数t也必须是右值。
显然, 上面
function()
函数模板并没有实现完美转发,一方面,参数t是非引用类型, 意味着调用function()
函数的时候,实参将值传递给形参的过程就需要额外进行一次拷贝操作。 另一方面, 无论调用function()
函数模板时传递给参数t是左值还是右值,对于函数内部的参数t来说,它有自己的名称,也可以获取它存储地址, 那么永远是左值。所以function()
函数的定义不是"完美的"。
C++11 标准为 C++ 引入了右值引用和移动语义,因此很多场景中是否实现完美转发,直接决定了该参数的传递过程使用的是拷贝语义(调用拷贝构造函数)还是移动语义(调用移动构造函数)。
为了方便用户更快速地实现完美转发,C++ 11 标准中允许在函数模板中使用右值引用来实现完美转发。
C++11 标准中规定,通常情况下右值引用形式的参数只能接收右值,不能接收左值。但对于函数模板中使用右值引用语法定义的参数来说,它不再遵守这一规定,既可以接收右值,也可以接收左值(此时的右值引用又被称为“万能引用”)。
所以上面的函数,如果在C++11标准中实现完美转发, 只需要编写下面的模板函数:
template <typename T>
void function(T&& t) {
otherdef(t);
}
此模板函数的参数 t 既可以接收左值,也可以接收右值。但仅仅使用右值引用作为函数模板的参数是远远不够的,还有一个问题继续解决,即如果调用 function()
函数时为其传递一个左值引用或者右值引用的实参。
int n = 10;
int & num = n; // 左值引用
function(num); // T 为 int& -> function函数底层解析function(int& &&t) -> 等价于function(int &t)
int && num2 = 11; // 右值引用
function(num2); // T 为 int && -> function函数底层解析function(int&& &&t) -> 等价于function(int && t)
通过将函数模板的形参类型设置为 T&&
,我们可以很好地解决接收左、右值的问题。但除此之外,还需要解决一个问题,即无论传入的形参是左值还是右值,对于函数模板内部来说,形参既有名称又能寻址,因此它都是左值。那么如何才能将函数模板接收到的形参连同其左、右值属性,一起传递给被调用的函数呢?
C++11 标准的开发者已经帮我们想好的解决方案,该新标准还引入了一个模板函数 forword(),我们只需要调用该函数,就可以很方便地解决此问题
// 重载被调用函数, 查看完美转发的效果
void otherdef(int &t){cout << "lvalue" << endl;}
void otherdef(int &&t){cout << "rvalue" << endl;}
// 实现完美转发的函数模板
template<typename T>
void function(T&& t){otherdef(forward<T>(t));}
int main()
{
function(5); // 右值 rvalue
int x = 1; // 左值 lvalue
function(x);
return 0;
}
forword()
函数模板用于修饰被调用函数中需要维持参数左、右值属性的参数。这个function
函数才是实现完美转发的最终版本。
所以总结下:
// 实现完美转发的函数模板
template<typename T>
void function(T&& t){
otherdef(forward<T>(t));
}
定义函数模板时:
这样,就可以实现完美转发了。
实际开发中,避免产生"野指针"最有效的方法, 就是在定义指针的同时完成初始化操作,即便该指针指向未明确,也应该初始化空指针。
所谓“野指针”,又称“悬挂指针”,指的是没有明确指向的指针。野指针往往指向的是那些不可用的内存区域,这就意味着像操作普通指针那样使用野指针(例如 &p),极可能导致程序发生异常。
C++98/03标准中, 将一个指针初始化为空指针的方式:
int *p = NULL; //推荐使用
值得一提的是,NULL 并不是 C++ 的关键字,它是 C++ 为我们事先定义好的一个宏,并且它的值往往就是字面量 0(#define NULL 0
)。
0指的是0x0000 0000这个内存空间。大多数操作系统都不允许用户对地址为 0 的内存空间执行写操作,若用户在程序中尝试修改其内容,则程序运行会直接报错。
C++将NULL定义为字面常量0,虽能满足大部分场景需要, 但个别情况,会导致程序运行与与预期不符。
void isnull(void *c){
cout << "void*c" << endl;
}
void isnull(int n){
cout << "int n" << endl;
}
int main() {
isnull(0); // int n
isnull(NULL); // int n
return 0;
}
isnull(0)
来说,显然它真正调用的是参数为整型的 isnull()
函数;而对于 isnull(NULL)
,我们期望它实际调用的是参数为 void*c
的 isnull()
函数,但观察程序的执行结果不难看出,并不符合我们的预期。
如果想符合预期,需要强转
isnull( (void*)NULL );
isnull( (void*)0 );
isnull(nullptr); // void*c
为了修正 C++ 存在的这一 BUG,在 C++11 标准中引入一个新关键字,即 nullptr
。
nullptr 是 nullptr_t 类型的右值常量,专用于初始化空类型指针。nullptr_t 是 C++11 新增加的数据类型,可称为“指针空值类型”
另外, nullptr可以被隐式转换成任意的指针类型。
int *a1 = nullptr;
char *a2 = nullptr;
double *a3 = nullptr;
不同类型的指针变量都可以使用 nullptr 来初始化,编译器分别将 nullptr 隐式转换成 int*、char* 以及 double* 指针类型。
所以, 使用 nullptr 初始化空指针可以令我们编写的程序更加健壮。
实际开发中,经常会遇到程序突然崩溃,程序运行所用内存越来越多最终重启问题, 这些问题往往是内存资源管理不当造成的。比如:
Java, python等语言都有"垃圾自动回收机制"能够比较好的处理上面问题,所谓垃圾,指的是那些不再使用或者没有任何指针指向的内存空间。
C++11中, 增添了unique_ptr, shared_ptr以及weak_ptr3个智能指针实现堆内存的自动回收。
智能指针和普通指针的用法是相似的,不同之处在于,智能指针可以在适当时机自动释放分配的内存。也就是说,使用智能指针可以很好地避免“忘记释放内存而导致内存泄漏”问题出现, 所以,C++也逐渐开始支持垃圾回收机制了。
智能指针底层是引用计数方式实现的,智能指针申请堆内存空间的同时, 会为其配备一个整型值,每当有新对象使用此堆内存, 整型值+1,反之,减1, 为0时说明没有对象使用了,堆空间被释放掉
每种智能指针都是以类模板方式实现, shared_ptr<T>
(T为表示指针指向的具体数据类型)的定义位于<memory>
头文件, 并位于std命名空间。使用该类型指针,必须include进来。
#include <memory>
和 unique_ptr、weak_ptr 不同之处在于,多个 shared_ptr 智能指针可以共同使用同一块堆内存。并且,由于该类型智能指针在实现上采用的是引用计数机制,即便有一个 shared_ptr 指针放弃了堆内存的“使用权”(引用计数减 1),也不会影响其他指向同一堆内存的 shared_ptr 指针(只有引用计数为 0 时,堆内存才会被自动释放)
shared_ptr<T>
类模板中,提供了多种实用的构造函数,常用的如下:
构造shared_ptr类型的空智能指针
std::shared_ptr<int> p1; //不传入任何实参
std::shared_ptr<int> p2(nullptr); //传入空指针 nullptr
空的shared_ptr指针, 初始引用计数是0,不是1
构建shared_ptr智能指针,也可以明确指向
// 构建shared_ptr智能指针,指向一块内存有10这个int类型数据的堆内存空间
std::shared_ptr<int>p3(new int(10));
//这个也可以用std::make_shared<T>模板函数, 和上面效果一样
std::shared_ptr<int>p3 = std::make_shared<int>(10);
shared_ptr<T>
模板还提供有相应的拷贝构造函数和移动构造函数
//调用拷贝构造函数
std::shared_ptr<int> p4(p3);//或者 std::shared_ptr<int> p4 = p3;
//调用移动构造函数
std::shared_ptr<int> p5(std::move(p4)); //或者 std::shared_ptr<int> p5 = std::move(p4);
p3和p4都是shared_ptr类型的智能指针,可以用p3初始化p4, 由于p3是左值,因此会调用拷贝构造函数。 需要注意的是,如果 p3 为空智能指针,则 p4 也为空智能指针,其引用计数初始值为 0;反之,则表明 p4 和 p3 指向同一块堆内存,同时该堆空间的引用计数会加 1。
而对于 std::move(p4)
来说,该函数会强制将 p4 转换成对应的右值,因此初始化 p5 调用的是移动构造函数。另外和调用拷贝构造函数不同,用 std::move(p4)
初始化 p5,会使得 p5 拥有了 p4 的堆内存,而 p4 则变成了空智能指针。
注意, 同一普通指针不能同时为多个shared_ptr对象赋值,否则会导致程序发生异常。
int* ptr = new int;
std::shared_ptr<int> p1(ptr);
std::shared_ptr<int> p2(ptr);//错误
在初始化 shared_ptr 智能指针时,还可以自定义所指堆内存的释放规则,这样当堆内存的引用计数为 0 时,会优先调用我们自定义的释放规则。
在某些场景中,自定义释放规则是很有必要的。比如,对于申请的动态数组来说,shared_ptr 指针默认的释放规则是不支持释放数组的,只能自定义对应的释放规则,才能正确地释放申请的堆内存。
//指定 default_delete 作为释放规则 来自C++11 标准中提供的 default_delete<T> 模板类
std::shared_ptr<int> p6(new int[10], std::default_delete<int[]>());
//自定义释放规则
void deleteInt(int*p) {
delete []p;
}
//初始化智能指针,并自定义释放规则
std::shared_ptr<int> p7(new int[10], deleteInt);
// 借助lambda表达式
std::shared_ptr<int> p7(new int[10], [](int *p){delete[]p;});
shared_ptr 模板类还提供有一些实用的成员方法, 如下:
成员方法名 | 功能 |
---|---|
operator=() | 重载赋值号,使得同一类型的 shared_ptr 智能指针可以相互赋值 |
operator*() | 重载 * 号,获取当前 shared_ptr 智能指针对象指向的数据 |
operator->() | 重载 -> 号,当智能指针指向的数据类型为自定义的结构体时,通过 -> 运算符可以获取其内部的指定成员 |
swap() | 交换 2 个相同类型 shared_ptr 智能指针的内容 |
reset() | 当函数没有实参时,该函数会使当前 shared_ptr 所指堆内存的引用计数减 1,同时将当前对象重置为一个空指针;当为函数传递一个新申请的堆内存时,则调用该函数的 shared_ptr 对象会获得该存储空间的所有权,并且引用计数的初始值为 1 |
get() | 获得 shared_ptr 对象内部包含的普通指针 |
use_count() | 返回同当前 shared_ptr 对象(包括它)指向相同的所有 shared_ptr 对象的数量 |
unique() | 判断当前 shared_ptr 对象指向的堆内存,是否不再有其它 shared_ptr 对象再指向它 |
operator bool() | 判断当前 shared_ptr 对象是否为空智能指针,如果是空指针,返回 false;反之,返回 true |
应用示例:
#include <iostream>
#include <memory>
using namespace std;
int main()
{
// 构建2个智能指针
std::shared_ptr<int>p1(new int(10));
std::shared_ptr<int>p2(p1);
// 输出p2指向的数据
cout << *p2 << endl; // 10
// 重置p1
p1.reset(); // 此时p1为空,计数器减1
if (p1){cout << "p1不为空!" << endl;} else {cout << "p1为空!" << endl;}
// 此时p2不受影响
cout << *p2 << endl; // 10
// 判断和p2同时指向的智能指针个数
cout << p2.use_count() << endl; // 1
}
unique_ptr 指针也具备“在适当时机自动释放堆内存空间”的能力。和 shared_ptr 指针最大的不同之处在于,unique_ptr 指针指向的堆内存无法同其它 unique_ptr 共享,也就是说,每个 unique_ptr 指针都独自拥有对其所指堆内存空间的所有权
这也就意味着,每个 unique_ptr 指针指向的堆内存空间的引用计数,都只能为 1,一旦该 unique_ptr 指针放弃对所指堆内存空间的所有权,则该空间会被立即释放回收
unique_ptr<T>
模板类提供了多个实用的构造函数:
创建空的unique_ptr指针
std::unique_ptr<int> p1();
std::unique_ptr<int> p2(nullptr);
创建同时明确指向
std::unique_ptr<int> p3(new int);
基于 unique_ptr 类型指针不共享各自拥有的堆内存,因此 C++11 标准中的 unique_ptr 模板类没有提供拷贝构造函数,只提供了移动构造函数。例如:
std::unique_ptr<int> p4(new int);
std::unique_ptr<int> p5(p4);//错误,堆内存不共享
std::unique_ptr<int> p5(std::move(p4));//正确,调用移动构造函数
对于调用移动构造函数的 p4 和 p5 来说,p5 将获取 p4 所指堆空间的所有权,而 p4 将变成空指针(nullptr)
默认情况下,unique_ptr 指针采用 std::default_delete<T>
方法释放堆内存。当然,我们也可以自定义符合实际场景的释放规则。值得一提的是,和 shared_ptr 指针不同,为 unique_ptr 自定义释放规则,只能采用函数对象的方式
//自定义的释放规则
struct myDel
{
void operator()(int *p) {
delete p;
}
};
std::unique_ptr<int, myDel> p6(new int);
//std::unique_ptr<int, myDel> p6(new int, myDel());
unique_ptr<T>
模板类还提供有一些实用的成员方法
应用示例:
int main()
{
std::unique_ptr<int>p5(new int);
*p5 = 10;
// p接收p5释放的堆内存
int *p = p5.release();
cout << *p << endl; // 10
// 判断p5是否为空 p5此时为空
if (p5){cout << "p5不是空!" << endl;} else {cout << "p5是空" << endl;}
std::unique_ptr<int>p6;
//p6获得p的所有权
p6.reset(p);
cout << *p6 << endl; // 10
// 这里注意, p竟然不是空,上面函数描述里面是不是有问题?
if (p){cout << "p不是空!" << endl;} else {cout << "p是空" << endl;}
cout << *p << endl; // 10
*p6 = 100;
cout << *p << endl; // 100
return 0;
}
怎么感觉最后p6和普通的指针p指向了同一块内存空间了?
weak_ptr 类型指针可以视为 shared_ptr 指针的一种辅助工具,借助 weak_ptr 类型指针, 我们可以获取 shared_ptr 指针的一些状态信息,比如有多少指向相同的 shared_ptr 指针、shared_ptr 指针指向的堆内存是否已经被释放等等。
当 weak_ptr 类型指针的指向和某一 shared_ptr 指针相同时,weak_ptr 指针并不会使所指堆内存的引用计数加 1;同样,当 weak_ptr 指针被释放时,之前所指堆内存的引用计数也不会因此而减 1。也就是说,weak_ptr 类型指针并不会影响所指堆内存空间的引用计数。
weak_ptr<T>
模板类中没有重载 *
和 ->
运算符,这也就意味着,weak_ptr 类型指针只能访问所指的堆内存,而无法修改它。
weak_ptr指针的创建:
// 创建一个空weak_ptr指针
std::weak_ptr<int> wp1;
//凭借已有的weak_ptr指针, 创建新的weak_ptr指针
std::weak_ptr<int>wp2(wp1);
若 wp1 为空指针,则 wp2 也为空指针;反之,如果 wp1 指向某一 shared_ptr 指针拥有的堆内存,则 wp2 也指向该块存储空间(可以访问,但无所有权)
weak_ptr 指针更常用于指向某一 shared_ptr 指针拥有的堆内存,因为在构建 weak_ptr 指针对象时,可以利用已有的 shared_ptr 指针为其初始化
std::shared_ptr<int> sp (new int);
std::weak_ptr<int> wp3 (sp);
weak_ptr 模板类提供的成员方法:
成员方法名 | 功能 |
---|---|
operator =() | 重载了 = 赋值号,weak_ptr 指针可以直接被 weak_ptr 或者 shared_ptr 类型指针赋值 |
swap(x) | 交换当前 weak_ptr 指针和同类型的 x 指针 |
reset(p ) | 将当前 weak_ptr 指针置为空指针 |
use_count() | 查看指向和当前 weak_ptr 指针相同的 shared_ptr 指针的数量 |
expired() | 判断当前 weak_ptr 指针为否过期(指针为空,或者指向的堆内存已经被释放) |
lock() | 如果当前 weak_ptr 已经过期,则该函数会返回一个空的 shared_ptr 指针;反之,该函数返回一个和当前 weak_ptr 指向相同的 shared_ptr 指针 |
应用示例:
int main()
{
std::shared_ptr<int>sp1(new int(10));
std::shared_ptr<int>sp2(sp1);
std::weak_ptr<int>wp(sp2);
// 输出和wp同时指向的shared_ptr类型的指针数量
cout << wp.use_count() << endl; // 2
// 释放sp2
sp2.reset();
cout << wp.use_count() << endl; // 1
// 借助lock()函数, 返回一个和wp同指向的share_ptr类型的指针, 获取其存储数据
cout << *(wp.lock()) << endl; // 10
return 0;
}
这篇文章的内容有些多, C++11确实基于前面的标准修改理论很多东西,加入了更多的骚操作, 这里依然是一张导图拎起来: