C++11 新特性 学习笔记

news2024/11/25 10:36:40

C++11 新特性 | 侯捷C++11学习笔记

笔者作为侯捷C++11新特性课程的笔记进行记录,供自己查阅方便

文章目录

  • C++11 新特性 | 侯捷C++11学习笔记
    • 1.Variadic Templates
        • C++11支持函数模板的默认模板参数
        • C++11在函数模板和类模板中使用可变参数
      • 可变参数模板
          • 1) 可变参数函数模板
          • 2) 可变参数类模板
      • 参数包扩展
    • 2.Spaces in Template Expressions && nullptr && auto
      • 2.1 Spaces in Template Expressions(>>的改进)
      • 2.2 nullptr and std:nullptr_t
        • C++11 nullptr:初始化空指针
      • 2.3 auto
        • C++ auto类型推导完全攻略
        • auto 类型推导的语法和规则
        • auto 的高级用法
        • auto 的限制
        • auto 的应用(重点)
          • 使用 auto 定义迭代器
          • auto 用于[泛型编程](https://so.csdn.net/so/search?q=泛型编程&spm=1001.2101.3001.7020)
    • 3.Unifrom Lnitialization(统一初始化)
      • C++11列表初始化(统一了初始化方式)
      • 统一的初始化
    • 4.Initializer_list
    • 5. std::array容器
    • 6.explicit
    • 8.for循环
      • C++11 for循环(基于范围的循环)详解
      • C++11 for循环使用注意事项
    • 9.=default、=delete
      • 9.1 default
      • 9.2 delete
    • 10.Alias Template(using 用法)
    • 11.template template parameters(双重模版参数)
      • 解释
      • 语法
      • 例子
      • 注意
      • Q1:
      • Q2:
    • 12.Type Alias(类型别名,using用法)
    • 13.noexcept
      • noexcept的用法
      • noexcept的用途
      • 示例
    • 14.override
    • 15.final
      • 1. 用于虚函数
      • 2. 用于类定义
      • 总结
    • 16.decltype
      • C++ decltype类型推导完全攻略
          • exp 注意事项
        • decltype 推导规则
        • decltype 的实际应用
        • 汇总auto和decltype的区别
          • 语法格式的区别
    • 17.Lambda 表达式
      • C++11 lambda匿名函数用法详解
        • lambda匿名函数的定义
          • lambda匿名函数中的[外部变量]
    • 18.Rvalue references(右值引用)
      • C++左值和右值
      • C++右值引用
    • 19.移动构造函数的功能和用法
      • 深浅拷贝效率问题
      • C++移动构造函数(移动语义的具体实现)
    • 20. move()函数:将左值强制转换为右值
    • 21.Perfect Forwarding 完美转发及其实现
    • 22.array容器
    • 23.hashtable 无序容器
    • 24.tuple
      • tuple对象的创建
          • 1) 类的构造函数
          • 2) make_tuple()函数
      • tuple常用函数
    • 25.union 非受限联合体
          • 1. C++11 允许非 POD 类型
          • 2. C++11 允许联合体有静态成员
      • 非受限联合体的赋值注意事项
          • placement new 是什么?
      • 非受限联合体的匿名声明和“枚举式类”
    • 26.constexpr:验证是否为常量表达式
      • constexpr修饰普通变量
      • constexpr修饰函数
      • constexpr修饰类的构造函数
      • constexpr修饰模板函数
      • constexpr和const的区别
    • 27.long long超长整形详解
    • 28.智能指针
      • 1.shared_ptr智能指针(超级详细)
        • C++11 shared_ptr智能指针
          • 1、shared_ptr智能指针的创建
          • 2、shared_ptr模板类提供的成员方法
      • 2.unique_ptr智能指针
        • unique_ptr智能指针的创建
        • unique_ptr模板类提供的成员方法
      • 3.weak_ptr智能指针
        • weak_ptr智能指针
          • 1、weak_ptr指针的创建
          • 2) weak_ptr模板类提供的成员方法

1.Variadic Templates

C++11支持函数模板的默认模板参数

在 C++98/03 标准中,类模板可以有默认的模板参数,如下:

template <typename T, typename U = int, U N = 0>
struct Foo
{
    // ...
};

但是却不支持函数的默认模板参数:

template <typename T = int>  // error in C++98/03: default template arguments
void func()
{
    // ...
}

现在这一限制在 C++11 中被解除了。上面的 func 函数在 C++11 中可以直接使用,代码如下:

int main(void)
{
    func();   //T = int
    return 0;
}

此时模板参数 T 的类型就为默认值 int。从上面的例子中可以看出,当所有模板参数都有默认参数时,函数模板的调用如同一个普通函数。但对于类模板而言,哪怕所有参数都有默认参数,在使用时也必须在模板名后跟随<>来实例化。

除了上面提到的部分之外,函数模板的默认模板参数在使用规则上和其他的默认参数也有一些不同,它没有必须写在参数表最后的限制。甚至于,根据实际场景中函数模板被调用的情形,编译器还可以自行推导出部分模板参数的类型。

这意味着,当默认模板参数和编译器自行推导出模板参数类型的能力一起结合使用时,代码的书写将变得异常灵活。我们可以指定函数中的一部分模板参数采用默认参数,而另一部分使用自动推导,比如下面的例子:

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;
}

C++11 标准中,我们可以像 func(97) 这样调用模板函数,因为编译器可以根据实参 97 自行推导出模板参数 U 的类型为 int,并且根据返回值 val=97 推导出 R 的类型也为 int;而 func(97) 手动指定了模板参数 R 的类型为 char(默认模板参数将无效),并通过实参 97 推导出了 U = int;最后 func<double,int>(97) 手动指定的 R 和 U 的类型值,因此无需编译器自行推导。

再次强调,当默认模板参数和自行推导的模板参数同时使用时,若无法推导出函数模板参数的类型,编译器会选择使用默认模板参数;如果模板参数即无法推导出来,又未设置其默认值,则编译器直接报错。例如:

template <typename T, typename U = double>
void func(T val1 = 0, U val2 = 0)
{
    //...
}
int main()
{
    func('c'); //T=char, U=double
    func();    //编译报错
    return 0;
}

其中,func(‘c’) 的这种调用方式,编译器通过实参 ‘c’ 可以推导出 T=char,但由于未传递第 2 个实参,因此模板参数 U 使用的是默认参数 double;但 func() 的调用方式是不行的,虽然 val1 设置有默认值,但编译器无法通过该默认值推导出模板参数 T 的类型。由此不难看出,编译器的自动推导能力并没有想象的那么强大。

总的来说,C++11 支持为函数模板中的参数设置默认值,在实际使用过程中,我们可以选择使用默认值,也可以尝试由编译器自行推导得到,还可以亲自指定各个模板参数的类型。

C++11在函数模板和类模板中使用可变参数

所谓可变参数,指的是参数的个数和类型都可以是任意的。提到参数,大家会第一时间想到函数参数,除此之外 C++ 的模板(包括函数模板和类模板)也会用到参数。

对于函数参数而言,C++ 一直都支持为函数设置可变参数,最典型的代表就是 printf() 函数,它的语法格式为:

int printf ( const char * format, ... );

...就表示的是可变参数,即 printf() 函数可以接收任意个参数,且各个参数的类型可以不同,例如:

printf("%d", 10);printf("%d %c",10, 'A');printf("%d %c %f",10, 'A', 1.23);

我们通常将容纳多个参数的可变参数称为参数包。借助 format 字符串,printf() 函数可以轻松判断出参数包中的参数个数和类型。

下面的程序中,自定义了一个简单的可变参数函数:

#include <iostream>
#include <cstdarg>//可变参数的函数
void vair_fun(int count, ...)
{    
    va_list args;    
    va_start(args, count);    
    for (int i = 0; i < count; ++i)    
    {        
        int arg = va_arg(args, int);        
        std::cout << arg << " ";    
    }    
    va_end(args);
}
int main()
{    
    //可变参数有 4 个,分别为 10、20、30、40    
    vair_fun(4, 10, 20, 30,40);    
    return 0;
}

程序中的 vair_fun() 函数有 2 个参数,一个是 count,另一个就是 … 可变参数。我们可以很容易在函数内部使用 count 参数,但要想使用参数包中的参数,需要借助<cstdarg>头文件中的 va_start、va_arg 以及 va_end 这 3 个带参数的宏:

  • va_start(args, count):args 是 va_list 类型的变量,我们可以简单的将其视为 char * 类型。借助 count 参数,找到可变参数的起始位置并赋值给 args;
  • va_arg(args, int):调用 va_start 找到可变参数起始位置的前提下,通过指明参数类型为 int,va_arg 就可以将可变参数中的第一个参数返回;
  • va_end(args):不再使用 args 变量后,应及时调用 va_end 宏清理 args 变量。

注意,借助 va_arg 获取参数包中的参数时,va_arg 不具备自行终止的能力,所以程序中借助 count 参数控制 va_arg 的执行次数,继而将所有的参数读取出来。控制 va_arg 执行次数还有其他方法,比如读取到指定数据时终止。

使用 … 可变参数的过程中,需注意以下几点:

  1. … 可变参数必须作为函数的最后一个参数,且一个函数最多只能拥有 1 个可变参数。
  2. 可变参数的前面至少要有 1 个有名参数(例如上面例子中的 count 参数);
  3. 当可变参数中包含 char 类型的参数时,va_arg 宏要以 int 类型的方式读取;当可变参数中包含 short 类型的参数时,va_arg 宏要以 double 类型的方式读取。

**需要注意的是,… 可变参数的方法仅适用于函数参数,并不适用于模板参数。**C++11 标准中,提供了一种实现可变模板参数的方法。

可变参数模板

C++ 11 标准发布之前,函数模板和类模板只能设定固定数量的模板参数。C++11 标准对模板的功能进行了扩展,允许模板中包含任意数量的模板参数,这样的模板又称可变参数模板。

1) 可变参数函数模板

先讲解函数模板,如下定义了一个可变参数的函数模板:

template<typename... T>
void vair_fun(T...args) {
    //函数体
}

模板参数中, typename(或者 class)后跟 … 就表明 T 是一个可变模板参数,它可以接收多种数据类型,又称模板参数包。vair_fun() 函数中,args 参数的类型用 T… 表示,表示 args 参数可以接收任意个参数,又称函数参数包。

这也就意味着,此函数模板最终实例化出的 vair_fun() 函数可以指定任意类型、任意数量的参数。例如,我们可以这样使用这个函数模板:

vair_fun();
vair_fun(1, "abc");
vair_fun(1, "abc", 1.23);
123

使用可变参数模板的难点在于,如何在模板函数内部“解开”参数包(使用包内的数据),这里给大家介绍两种简单的方法。

【递归方式解包】
先看一个实例:

#include <iostream>
using namespace std;
//模板函数递归的出口
void vir_fun() {
}
template <typename T, typename... args>
void vir_fun(T argc, args... argv)
{
    cout << argc << endl;
    //开始递归,将第一个参数外的 argv 参数包重新传递给 vir_fun
    vir_fun(argv...);
}
int main()
{
    vir_fun(1, "http://www.biancheng.net", 2.34);
    return 0;
}

执行结果为:

1
http://www.biancheng.net
2.34

分析一个程序的执行流程:

  • 首先,main() 函数调用 vir_fun() 模板函数时,根据所传实参的值,可以很轻易地判断出模板参数 T 的类型为 int,函数参数 argc 的值为 1,剩余的模板参数和函数参数都分别位于 args 和 argv 中;
  • vir_fun() 函数中,首先输出了 argc 的值(为 1),然后重复调用自身,同时将函数参数包 argv 中的数据作为实参传递给形参 argc 和 argv;
  • 再次执行 vir_fun() 函数,此时模板参数 T 的类型为 char*,输出 argc 的值为 “http:www.biancheng.net”。再次调用自身,继续将 argv 包中的数据作为实参;
  • 再次执行 vir_fun() 函数,此时模板参数 T 的类型为 double,输出 argc 的值为 2.34。再次调用自身,将空的 argv 包作为实参;
  • 由于 argv 包没有数据,此时会调用无任何形参、函数体为空的 vir_fun() 函数,最终执行结束。

以递归方式解包,一定要设置递归结束的出口。例如本例中,无形参、函数体为空的 vir_fun() 函数就是递归结束的出口。

【非递归方法解包】
借助逗号表达式和初始化列表,也可以解开参数包。

以 vir_fun() 函数为例,下面程序演示了非递归方法解包的过程:

#include <iostream>
using namespace std;
template <typename T>
void dispaly(T t) {
    cout << t << endl;
}
template <typename... args>
void vir_fun(args... argv)
{
    //逗号表达式+初始化列表
    int arr[] = { (dispaly(argv),0)... };
}
int main()
{
    vir_fun(1, "http://www.biancheng.net", 2.34);
    return 0;
}

这里重点分析一下第 13 行代码,我们以{ }初始化列表的方式对数组 arr 进行了初始化, (display(argv),0)… 会依次展开为 (display(1),0)、(display(“http://www.biancheng.net”),0) 和 (display(2.34),0)。也就是说,第 13 行代码和如下代码是等价的:

 int arr[] = { (dispaly(1),0), (dispaly("http://www.biancheng.net"),0), (dispaly(2.34),0) };

可以看到,每个元素都是一个逗号表达式,以 (display(1), 0) 为例,它会先计算 display(1),然后将 0 作为整个表达式的值返回给数组,因此 arr 数组最终存储的都是 0。arr 数组纯粹是为了将参数包展开,没有发挥其它作用。

2) 可变参数类模板

image-20241010102752119

printf递归调用,解包,和上面一样。要注意函数1,算是一个终止条件吧。

Types可以理解为一个包类型,表示参数可以有很多类型

image-20241010204756484

image-20241010103142620

答:2比较特化(特别),3比较泛化

image-20241010103807043

tuple继承,(看右上角)一开始是int,float,string,继承为float,string,一层一层继承下去,下面的例子也是说的这个

C++11 标准中,类模板中的模板参数也可以是一个可变参数。C++ 11 标准提供的 typle 元组类就是一个典型的可变参数模板类,它的定义如下:

template <typename... Types>
class tuple;

和固定模板参数的类不同,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, "http://www.biancheng.net");

如下代码展示了一个支持可变参数的类模板:

#include <iostream>
//声明模板类demo
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, "http://www.biancheng.net");
    return 0;
}

程序中,demo 模板参数中的 Tail 就是一个参数包,解包的方式是以“递归+继承”的方式实现的。具体来讲,demo<Head, Tail…> 类实例化时,由于其继承自 demo<Tail…> 类,因此父类也会实例化,一直递归至 Tail 参数包为空,此时会调用模板参数列表为空的 demo 模板类。

程序的输出结果为:

http://www.biancheng.net
2.34
1

参数包扩展

参数包扩展是C++模板编程中的一个重要特性,它允许在模板中接受任意数量和类型的参数。参数包的扩展是在编译时由编译器自动处理的,其过程可以视为将参数包中的每个参数都作为独立的参数进行展开。

具体来说,参数包扩展的语法是在参数名后面加上省略号(…)。这个省略号表示该参数是一个参数包,可以包含零个或多个参数。在函数调用或模板实例化时,编译器会根据上下文自动将参数包中的每个参数展开为独立的参数。

以下是一些关于参数包扩展的详细解释和示例:

函数模板中的参数包扩展:
在函数模板中,参数包可以用于定义可变数量的函数参数。例如:

cpp
template<typename… Args>
void print(Args… args) {
// … 这里可以使用参数包展开来逐个打印参数
}
在这个例子中,Args… 是一个类型参数包,它表示函数可以接受任意数量和类型的参数。在函数体内,可以使用参数包展开来逐个处理这些参数。

参数包展开的方式:
参数包展开有多种方式,包括递归函数、逗号表达式、折叠表达式等。以下是几种常见的展开方式:

递归函数:通过定义一个递归函数来逐个处理参数包中的参数。这种方式需要定义一个递归终止条件,以避免无限递归。
逗号表达式:利用C++中的逗号表达式(,),它保证从左到右的顺序求值,可以在逗号表达式中逐个展开参数包。
折叠表达式(C++17引入):折叠表达式提供了一种简洁的方式来展开参数包,并可以对参数包中的元素进行折叠操作(如求和、求积等)。
示例:
以下是一个使用折叠表达式来展开参数包并打印每个参数的示例:

cpp
#include

template<typename… Args>
void print(Args… args) {
(std::cout << … << args) << std::endl; // 使用折叠表达式展开参数包
}

int main() {
print(1, 2, 3, “hello”); // 输出:123hello
return 0;
}
在这个例子中,折叠表达式 (std::cout << … << args) 将参数包 args 中的每个参数都展开为 std::cout << 操作的一部分,并依次打印出来。

需要注意的是,虽然参数包扩展提供了很大的灵活性,但也需要谨慎使用以避免编译错误或运行时错误。在使用参数包时,应确保传入的参数类型与函数模板或类模板中定义的参数包类型相匹配。

2.Spaces in Template Expressions && nullptr && auto

2.1 Spaces in Template Expressions(>>的改进)

image-20241010104301186

就是容器嵌套的时候不需要加空格了(之前害怕两个>>解析为输入)

2.2 nullptr and std:nullptr_t

  • nullptr的类型是std::nullptr_t
C++11 nullptr:初始化空指针

实际开发中,避免产生“野指针”最有效的方法,就是在定义指针的同时完成初始化操作,即便该指针的指向尚未明确,也要将其初始化为空指针。

所谓“野指针”,又称“悬挂指针”,指的是没有明确指向的指针。野指针往往指向的是那些不可用的内存区域,这就意味着像操作普通指针那样使用野指针(例如 &p),极可能导致程序发生异常。

C++98/03 标准中,将一个指针初始化为空指针的方式有 2 种:

int *p = 0;
int *p = NULL; //推荐使用

可以看到,我们可以将指针明确指向 0(0x0000 0000)这个内存空间。一方面,明确指针的指向可以避免其成为野指针;另一方面,大多数操作系统都不允许用户对地址为 0 的内存空间执行写操作,若用户在程序中尝试修改其内容,则程序运行会直接报错。

相比第一种方式,我们更习惯将指针初始化为 NULL。值得一提的是,NULL 并不是 C++ 的关键字,它是 C++ 为我们事先定义好的一个宏,并且它的值往往就是字面量 0(#define NULL 0)。

C++ 中将 NULL 定义为字面常量 0,虽然能满足大部分场景的需要,但个别情况下,它会导致程序的运行和我们的预期不符。例如:

#include <iostream>
using namespace std;
void isnull(void *c){
    cout << "void*c" << endl;
}
void isnull(int n){
    cout << "int n" << endl;
}
int main() {
    isnull(0);
    isnull(NULL);
    return 0;
}

程序执行结果为:

int n
int n

对于 isnull(0) 来说,显然它真正调用的是参数为整形的 isnull() 函数;而对于 isnull(NULL),我们期望它实际调用的是参数为 void*c 的 isnull() 函数,但观察程序的执行结果不难看出,并不符合我们的预期。

C++ 98/03 标准中,如果我们想令 isnull(NULL) 实际调用的是 isnull(void* c),就需要对 NULL(或者 0)进行强制类型转换:

isnull( (void*)NULL );
isnull( (void*)0 );

如此,才会成功调用我们预期的函数(读者可自行执行此代码,观察输出结果)。

由于 C++ 98 标准使用期间,NULL 已经得到了广泛的应用,出于兼容性的考虑,C++11 标准并没有对 NULL 的宏定义做任何修改。为了修正 C++ 存在的这一 BUG,C++ 标准委员会最终决定另其炉灶,在 C++11 标准中引入一个新关键字,即 nullptr。

在使用 nullptr 之前,读者需保证自己使用的编译器支持该关键字。以 Visual Studio 和 codeblocks 为例,前者早在 2010 版本就对 C++ 11 标准中的部分特性提供了支持,其中就包括 nullptr;如果使用后者,读者需将其 G++ 编译器版本至少升级至 4.6.1(同时开启 -std=c++0x 编译选项)。

nullptr 是 nullptr_t 类型的右值常量,专用于初始化空类型指针。nullptr_t 是 C++11 新增加的数据类型,可称为“指针空值类型”。也就是说,nullpter 仅是该类型的一个实例对象(已经定义好,可以直接使用),如果需要我们完全定义出多个同 nullptr 完全一样的实例对象。

值得一提的是,nullptr 可以被隐式转换成任意的指针类型。举个例子:

int * a1 = nullptr;
char * a2 = nullptr;
double * a3 = nullptr;

显然,不同类型的指针变量都可以使用 nullptr 来初始化,编译器分别将 nullptr 隐式转换成 int*、char* 以及 double* 指针类型。

另外,通过将指针初始化为 nullptr,可以很好地解决 NULL 遗留的问题,比如:

#include <iostream>
using namespace std;
void isnull(void *c){
    cout << "void*c" << endl;
}
void isnull(int n){
    cout << "int n" << endl;
}
int main() {
    isnull(NULL);
    isnull(nullptr);
    return 0;
}

程序执行结果为:

int n
void*c

借助执行结果不难看出,由于 nullptr 无法隐式转换为整形,而可以隐式匹配指针类型,因此执行结果和我们的预期相符。

总之在 C++11 标准下,相比 NULL 和 0,使用 nullptr 初始化空指针可以令我们编写的程序更加健壮。

2.3 auto

C++ auto类型推导完全攻略

在 C++11 之前的版本(C++98 和 C++ 03)中,定义变量或者声明变量之前都必须指明它的类型,比如 int、char 等;但是在一些比较灵活的语言中,比如 C#、JavaScript、PHP、Python 等,程序员在定义变量时可以不指明具体的类型,而是让编译器(或者解释器)自己去推导,这就让代码的编写更加方便。

C++11 为了顺应这种趋势也开始支持自动类型推导了!C++11 使用 auto 关键字来支持自动类型推导。

auto 类型推导的语法和规则

在之前的 C++ 版本中,auto 关键字用来指明变量的存储类型,它和 static 关键字是相对的。auto 表示变量是自动存储的,这也是编译器的默认规则,所以写不写都一样,一般我们也不写,这使得 auto 关键字的存在变得非常鸡肋。

C++11 赋予 auto 关键字新的含义,使用它来做自动类型推导。也就是说,使用了 auto 关键字以后,编译器会在编译期间自动推导出变量的类型,这样我们就不用手动指明变量的数据类型了。

auto 关键字基本的使用语法如下:

auto name = value;

name 是变量的名字,value 是变量的初始值。

注意:auto 仅仅是一个占位符,在编译器期间它会被真正的类型所替代。或者说,C++ 中的变量必须是有明确类型的,只是这个类型是由编译器自己推导出来的。

auto 类型推导的简单例子:

auto n = 10;
auto f = 12.8;
auto p = &n;
auto url = “http://c.biancheng.net/cplus/”;

下面我们来解释一下:

  • 第 1 行中,10 是一个整数,默认是 int 类型,所以推导出变量 n 的类型是 int。
  • 第 2 行中,12.8 是一个小数,默认是 double 类型,所以推导出变量 f 的类型是 double。
  • 第 3 行中,&n 的结果是一个 int* 类型的指针,所以推导出变量 p 的类型是 int*。
  • 第 4 行中,由双引号""包围起来的字符串是 const char* 类型,所以推导出变量 url 的类型是 const char*,也即一个常量指针。

我们也可以连续定义多个变量:

int n = 20;
auto *p = &n, m = 99;

先看前面的第一个子表达式,&n 的类型是 int*,编译器会根据 auto *p 推导出 auto 为 int。后面的 m 变量自然也为 int 类型,所以把 99 赋值给它也是正确的。

这里我们要注意,推导的时候不能有二义性。在本例中,编译器根据第一个子表达式已经推导出 auto 为 int 类型,那么后面的 m 也只能是 int 类型,如果写作m=12.5就是错误的,因为 12.5 是double 类型,这和 int 是冲突的。

还有一个值得注意的地方是:使用 auto 类型推导的变量必须马上初始化,这个很容易理解,因为 auto 在 C++11 中只是“占位符”,并非如 int 一样的真正的类型声明。

auto 的高级用法

auto 除了可以独立使用,还可以和某些具体类型混合使用,这样 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

下面我们来解释一下:

  • 第 2 行代码中,p1 为 int* 类型,也即 auto * 为 int *,所以 auto 被推导成了 int 类型。
  • 第 3 行代码中,auto 被推导为 int* 类型,前边的例子也已经演示过了。
  • 第 4 行代码中,r1 为 int & 类型,auto 被推导为 int 类型。
  • 第 5 行代码是需要重点说明的,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 类型`在这里插入代码片`

下面我们来解释一下:

  • 第 2 行代码中,n 为 const int,auto 被推导为 int。
  • 第 3 行代码中,n 为 const int 类型,但是 auto 却被推导为 int 类型,这说明当=右边的表达式带有 const 属性时, auto 不会使用 const 属性,而是直接推导出 non-const 类型。
  • 第 4 行代码中,auto 被推导为 int 类型,这个很容易理解,不再赘述。
  • 第 5 行代码中,r1 是 const int & 类型,auto 也被推导为 const int 类型,这说明当 const 和引用结合时,auto 的推导将保留表达式的 const 类型。

最后我们来简单总结一下 auto 与 const 结合的用法:

  • 当类型不为引用时,auto 的推导结果将不保留表达式的 const 属性;
  • 当类型为引用时,auto 的推导结果将保留表达式的 const 属性。
auto 的限制

前面介绍推导规则的时候我们说过,使用 auto 的时候必须对变量进行初始化,这是 auto 的限制之一。那么,除此以外,auto 还有哪些其它的限制呢?

  1. auto 不能在函数的参数中使用。

这个应该很容易理解,我们在定义函数的时候只是对参数进行了声明,指明了参数的类型,但并没有给它赋值,只有在实际调用函数的时候才会给参数赋值;而 auto 要求必须对变量进行初始化,所以这是矛盾的。

  1. auto 不能作用于类的非静态成员变量(也就是没有 static 关键字修饰的成员变量)中。
  2. auto 关键字不能定义数组,比如下面的例子就是错误的:

char url[] = “http://c.biancheng.net/”;
auto str[] = url; //arr 为数组,所以不能使用 auto

  1. auto 不能作用于模板参数,请看下面的例子:
template <typename T>
class A{
    //TODO:
};
int  main(){
    A<int> C1;
    A<auto> C2 = C1;  //错误
    return 0;
}
auto 的应用(重点)

说了那么多 auto 的推导规则和一些注意事项,那么 auto 在实际开发中到底有什么应用呢?下面我们列举两个典型的应用场景。

使用 auto 定义迭代器

auto 的一个典型应用场景是用来定义 stl 的迭代器。

我们在使用 stl 容器的时候,需要使用迭代器来遍历容器里面的元素;不同容器的迭代器有不同的类型,在定义迭代器时必须指明。而迭代器的类型有时候比较复杂,书写起来很麻烦,请看下面的例子:

#include <vector>
using namespace std;
int main(){
    vector< vector<int> > v;
    vector< vector<int> >::iterator i = v.begin();
    return 0;
}

可以看出来,定义迭代器 i 的时候,类型书写比较冗长,容易出错。然而有了 auto 类型推导,我们大可不必这样,只写一个 auto 即可。

修改上面的代码,使之变得更加简洁:

#include <vector>
using namespace std;
int main(){
    vector< vector<int> > v;
    auto i = v.begin();  //使用 auto 代替具体的类型
    return 0;
}

auto 可以根据表达式 v.begin() 的类型(begin() 函数的返回值类型)来推导出变量 i 的类型。

auto 用于泛型编程

auto 的另一个应用就是当我们不知道变量是什么类型,或者不希望指明具体类型的时候,比如泛型编程中。我们接着看例子:

#include <iostream>
using namespace std;
class A{
public:
    static int get(void){
        return 100;
    }
};
class B{
public:
    static const char* get(void){
        return "http://c.biancheng.net/cplus/";
    }
};
template <typename T>
void func(void){
    auto val = T::get();
    cout << val << endl;
}
int main(void){
    func<A>();
    func<B>();
    return 0;
}

运行结果:

100
http://c.biancheng.net/cplus/

本例中的模板函数 func() 会调用所有类的静态函数 get(),并对它的返回值做统一处理,但是 get() 的返回值类型并不一样,而且不能自动转换。这种要求在以前的 C++ 版本中实现起来非常的麻烦,需要额外增加一个模板参数,并在调用时手动给该模板参数赋值,用以指明变量 val 的类型。

但是有了 auto 类型自动推导,编译器就根据 get() 的返回值自己推导出 val 变量的类型,就不用再增加一个模板参数了。

下面的代码演示了不使用 auto 的解决办法:

#include <iostream>
using namespace std;
class A{
public:
    static int get(void){
        return 100;
    }
};
class B{
public:
    static const char* get(void){
        return "http://c.biancheng.net/cplus/";
    }
};
template <typename T1, typename T2>  //额外增加一个模板参数 T2
void func(void){
    T2 val = T1::get();
    cout << val << endl;
}
int main(void){
    //调用时也要手动给模板参数赋值
    func<A, int>();
    func<B, const char*>();
    return 0;
}

3.Unifrom Lnitialization(统一初始化)

C++11列表初始化(统一了初始化方式)

我们知道,在 C++98/03 中的对象初始化方法有很多种,请看下面的代码:

//初始化列表
int i_arr[3] = { 1, 2, 3 };  //普通数组
struct A
{
    int x;
    struct B
    {
        int i;
        int j;
    } b;
} a = { 1, { 2, 3 } };  //POD类型
//拷贝初始化(copy-initialization)
int i = 0;
class Foo
{
    public:
    Foo(int) {}
} foo = 123;  //需要拷贝构造函数
//直接初始化(direct-initialization)
int j(0);
Foo bar(123);

这些不同的初始化方法,都有各自的适用范围和作用。最关键的是,这些种类繁多的初始化方法,没有一种可以通用所有情况。

为了统一初始化方式,并且让初始化行为具有确定的效果,C++11 中提出了列表初始化(List-initialization)的概念。

POD 类型即 plain old data 类型,简单来说,是可以直接使用 memcpy 复制的对象。

统一的初始化

在上面我们已经看到了,对于普通数组和 POD 类型,C++98/03 可以使用初始化列表(initializer list)进行初始化:

int i_arr[3] = { 1, 2, 3 };
long l_arr[] = { 1, 3, 2, 4 };
struct A
{
    int x;
    int y;
} a = { 1, 2 };

但是这种初始化方式的适用性非常狭窄,只有上面提到的这两种数据类型可以使用初始化列表。

在 C++11 中,初始化列表的适用性被大大增加了。它现在可以用于任何类型对象的初始化,请看下面的代码。

【实例】通过初始化列表初始化对象。

class Foo
{
public:
    Foo(int) {}
private:
    Foo(const Foo &);
};
int main(void)
{
    Foo a1(123);
    Foo a2 = 123;  //error: 'Foo::Foo(const Foo &)' is private
    Foo a3 = { 123 };
    Foo a4 { 123 };
    int a5 = { 3 };
    int a6 { 3 };
    return 0;
}

在上例中,a3、a4 使用了新的初始化方式来初始化对象,效果如同 a1 的直接初始化。

a5、a6 则是基本数据类型的列表初始化方式。可以看到,它们的形式都是统一的。

都是用大括号进行初始化的,编译器看到{}就做出一个initializer_list<T>,它关联一个array<T,n>.

每个容器都有一个构造函数接受一个initializer_list(大括号的形式)

这里需要注意的是,a3 虽然使用了等于号,但它仍然是列表初始化,因此,私有的拷贝构造并不会影响到它。

a4 和 a6 的写法,是 C++98/03 所不具备的。在 C++11 中,可以直接在变量名后面跟上初始化列表,来进行对象的初始化。

这种变量名后面跟上初始化列表方法同样适用于普通数组和 POD 类型的初始化:

int i_arr[3] { 1, 2, 3 };  //普通数组
struct A
{
    int x;
    struct B
    {
        int i;
        int j;
    } b;
} a { 1, { 2, 3 } };  //POD类型

在初始化时,{}前面的等于号是否书写对初始化行为没有影响。

另外,如同读者所想的那样,new 操作符等可以用圆括号进行初始化的地方,也可以使用初始化列表:

int* a = new int { 123 };
double b = double { 12.12 };
int* arr = new int[3] { 1, 2, 3 };

指针 a 指向了一个 new 操作符返回的内存,通过初始化列表方式在内存初始化时指定了值为 123。

b 则是对匿名对象使用列表初始化后,再进行拷贝初始化。

这里让人眼前一亮的是 arr 的初始化方式。堆上动态分配的数组终于也可以使用初始化列表进行初始化了。

除了上面所述的内容之外,列表初始化还可以直接使用在函数的返回值上:

struct Foo
{
    Foo(int, double) {}
};
Foo func(void)
{
    return { 123, 321.0 };
}

这里的 return 语句就如同返回了一个 Foo(123, 321.0)。

由上面的这些例子可以看到,在 C++11 中使用初始化列表是非常便利的。它不仅统一了各种对象的初始化方式,而且还使代码的书写更加简单清晰。

4.Initializer_list

所有的都可以用大括号进行初始化,编译器看到{}就做出一个initializer_list<T>,它关联一个array<T,n>,背后由array支撑

每个容器都有一个构造函数接受一个initializer_list(大括号的形式)

拷贝一个initializer_list是一个浅拷贝,也就是array原来的指针和新复制的指针指向同一块内存,这个行为是危险的

cout<<max({54,16,5})<<endl;
cout<<min({54,16,5})<<endl;
//这两种也是正确的

C++11提供的新类型,定义在<initializer_list>头文件中。

template< class T >
class initializer_list;

先说它的用处吧,然后再详细介绍一下。

首先有了initializer_list之后,对于STL的container的初始化就方便多了,比如以前初始化一个vector需要这样:

int a[] = {0, 1, 2, 3};
std::vector<int> vec(a, a+sizeof(a));

或者

std::vector<int> vec;
vec.push_back(1);
vec.push_back(3);
vec.push_back(3);
vec.push_back(2);

有了initializer_list后,就可以直接像初始化数组一样:

class Test {

private:
    static std::map<string, string> const nameToBirthday = {
        {"lisi", "18841011"},
        {"zhangsan", "18850123"},
        {"wangwu", "18870908"},
        {"zhaoliu", "18810316"},
    };
}

当然啦,里面的std::map必须提供参数为initializer_list的构造函数如:

map( std::initializer_list<value_type> init,
     const Compare& comp = Compare(),
     const Allocator& alloc = Allocator() );

其实for(initializer: list)中如果list是个形如:{a, b, c…},那么其实list自动被构造成了initializer_list对象。

下面稍微介绍一下initializer_list

一个initializer_list当出现在以下两种情况的被自动构造:

  1. 当初始化的时候使用的是大括号初始化,被自动构造。包括函数调用时和赋值
  2. 当涉及到for(initializer: list),list被自动构造成initializer_list对象

也就是说initializer_list对象只能用大括号{}初始化。

拷贝一个initializer_list对象并不会拷贝里面的元素。其实只是引用而已。而且里面的元素全部都是const的。

下面一个例子可以帮助我们更好的理解如何使用initializer_list:

#include <iostream>
#include <vector>
#include <initializer_list>

using namespace std;

template <class T>
struct S {
    vector<T> v;
    S(initializer_list<T> l) : v(l){
        cout << "constructed with a " << l.size() << "-elements lists" << endl;
    }
    void append(std::initializer_list<T> l) {
        v.insert(v.end(), l.begin(), l.end());
    }

    pair<const T*, size_t> c_arr() const{
        return {&v[0], v.size()};
    }

};


template <typename T>
void templated_fn(T arg) {
    for (auto a : arg)
        cout << a << " ";
    cout << endl;
}

int main() {
    S<int> s = {1, 2, 3, 4, 5}; //automatically construct a initializer_list 
                                // object and copy it
    s.append({6, 7 , 8});         //list-initialization in function call

    cout << "The vector size is now " << s.c_arr().second << " ints:" << endl;

    for (auto n : s.v)
        cout << ' ' << n;
    cout << endl;

    cout << "range-for over brace-init-list: " << endl;
    
    for (auto x : {-1, -2, 03})    the rule for auto makes this ranged for work
        cout << x << " ";
    cout << endl;

    auto al = {10, 11, 12};  //special rule for auto

    cout << "The list bound to auto has size() = " << al.size() << endl;


    //templated_fn({1, 2, 3});   //compiler error! "{1, 2, 3}" is not an expressionit has no type, and so T cannot be duduced.

    templated_fn<initializer_list<int> > ({7, 8, 9}); //ok
    templated_fn<vector<int> >({3, 5, 7});           //also ok

    return 0;
}

5. std::array容器

array容器其实就是平常的数组,加入容器使得它可以用sort那些算法

它没有构造和析构函数

std::array 保存在栈内存中,相比堆内存中的 std::vector,我们能够灵活的访问这里面的元素,从而获得更高的性能。

std::array 会在编译时创建一个固定大小的数组,std::array 不能够被隐式的转换成指针,使用 std::array只需指定其类型和大小即可:

std::array<int, 4> arr= {1,2,3,4};

int len = 4;
std::array<int, len> arr = {1,2,3,4}; // 非法, 数组大小参数必须是常量表达式1234

当我们开始用上了 std::array 时,难免会遇到要将其兼容 C 风格的接口,这里有三种做法:

void foo(int *p, int len) {
    return;
}

std::array<int 4> arr = {1,2,3,4};

// C 风格接口传参
// foo(arr, arr.size());           // 非法, 无法隐式转换
foo(&arr[0], arr.size());
foo(arr.data(), arr.size());

// 使用 `std::sort`
std::sort(arr.begin(), arr.end());

6.explicit

explicit专用于修饰构造函数(基本只用于构造函数,其他地方很少用),表示只能显式构造,不可以被隐式转换,根据代码看explicit的作用:

struct A {
    explicit A(int value) {
        cout << "value" << endl;
    }
};

int main() {
    A a = 1; // error,不可以隐式转换
    A aa(2); // ok
    return 0;
}

image-20241010145150653

告诉编译器,你不要自动帮我隐式的转换,我自己会显式的调用

8.for循环

C++11 for循环(基于范围的循环)详解

1.C++ 11标准之前(C++ 98/03 标准),如果要用 for 循环语句遍历一个数组或者容器,只能套用如下结构:

for(表达式 1; 表达式 2; 表达式 3){
//循环体
}

eg:

#include <iostream>
#include <vector>
#include <string.h>
using namespace std;
int main() {
    char arc[] = "http://c.biancheng.net/cplus/11/";
    int i;
    //for循环遍历普通数组
    for (i = 0; i < strlen(arc); i++) {
        cout << arc[i];
    }
    cout << endl;
    vector<char>myvector(arc,arc+23);
    vector<char>::iterator iter;
    //for循环遍历 vector 容器
    for (iter = myvector.begin(); iter != myvector.end(); ++iter) {
        cout << *iter;
    }
    return 0;
}

2.而 C++ 11 标准中,除了可以沿用前面介绍的用法外,还为 for 循环添加了一种全新的语法格式,如下所示:

for (declaration : expression){
//循环体
}

其中,两个参数各自的含义如下:

  • declaration:表示此处要定义一个变量,该变量的类型为要遍历序列中存储元素的类型。需要注意的是,C++ 11 标准中,declaration参数处定义的变量类型可以用 auto 关键字表示,该关键字可以使编译器自行推导该变量的数据类型。
  • expression:表示要遍历的序列,常见的可以为事先定义好的普通数组或者容器,还可以是用 {} 大括号初始化的序列。

可以看到,同 C++ 98/03 中 for 循环的语法格式相比较,此格式并没有明确限定 for 循环的遍历范围,这是它们最大的区别,即旧格式的 for 循环可以指定循环的范围,而 C++11 标准增加的 for 循环,只会逐个遍历 expression 参数处指定序列中的每个元素。

eg:

#include <iostream>
#include <vector>
using namespace std;
int main() {
    char arc[] = "http://c.biancheng.net/cplus/11/";
    //for循环遍历普通数组
    for (char ch : arc) {
        cout << ch;
    }
    cout << '!' << endl;
    vector<char>myvector(arc, arc + 23);
    //for循环遍历 vector 容器
    for (auto ch : myvector) {
        cout << ch;
    }
    cout << '!';
    return 0;
}

这里有以下 2 点需要说明:

  1. 程序中在遍历 myvector 容器时,定义了 auto 类型的 ch 变量,当编译器编译程序时,会通过 myvector 容器中存储的元素类型自动推导出 ch 为 char 类型。注意,这里的 ch 不是迭代器类型,而表示的是 myvector 容器中存储的每个元素。
  2. 仔细观察程序的输出结果,其中第一行输出的字符串和 “!” 之间还输出有一个空格,这是因为新格式的 for 循环在遍历字符串序列时,不只是遍历到最后一个字符,还会遍历位于该字符串末尾的 ‘\0’(字符串的结束标志)。之所以第二行输出的字符串和 “!” 之间没有空格,是因为 myvector 容器中没有存储 ‘\0’。

3.除此之外,新语法格式的 for 循环还支持遍历用{ }大括号初始化的列表,比如:

#include <iostream>
using namespace std;
int main() {
    for (int num : {1, 2, 3, 4, 5}) {
        cout << num << " ";
    }
    return 0;
}
12345678

4.另外值得一提的是,在使用新语法格式的 for 循环遍历某个序列时,如果需要遍历的同时修改序列中元素的值,实现方案是在 declaration 参数处定义引用形式的变量。举个例子:

#include <iostream>
#include <vector>
using namespace std;
int main() {
    char arc[] = "abcde";
    vector<char>myvector(arc, arc + 5);
    //for循环遍历并修改容器中各个字符的值
    for (auto &ch : myvector) {
        ch++;
    }
    //for循环遍历输出容器中各个字符
    for (auto ch : myvector) {
        cout << ch;
    }
    return 0;
}

程序执行结果为:

bcdef

如果需要在遍历序列的过程中修改器内部元素的值,就必须定义引用形式的变量;反之,建议定义const &(常引用)形式的变量(避免了底层复制变量的过程,效率更高),也可以定义普通变量。

C++11 for循环使用注意事项

  1. 总的来说,基于范围的 for 循环可以遍历普通数组、string字符串、容器以及初始化列表。除此之外,for 循环冒号后还可以放置返回 string 字符串以及容器对象的函数,比如:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
string str = "http://c.biancheng.net/cplus/11/";
vector<int> myvector = { 1,2,3,4,5 };
string retStr() {
    return str;
}
vector<int> retVector() {
    return myvector;
}
int main() {
    //遍历函数返回的 string 字符串
    for (char ch : retStr()) {
        cout << ch;
    }
    cout << endl;
    //遍历函数返回的 vector 容器
    for (int num : retVector()) {
        cout << num << " ";
    }
    return 0;
}

程序执行结果为:

http://c.biancheng.net/cplus/11/
1 2 3 4 5

注意,基于范围的 for 循环不支持遍历函数返回的以指针形式表示的数组,比如:

//错误示例
#include <iostream>
using namespace std;
char str[] = "http://c.biancheng.net/cplus/11/";
char* retStr() {
    return str;
}
int main() {
    for (char ch : retStr()) //直接报错
    {
        cout << ch;
    }
    return 0;
}

原因很简单,此格式的 for 循环只能遍历有明确范围的一组数据,上面程序中 retStr() 函数返回的是指针变量,遍历范围并未明确指明,所以编译失败。

2.值得一提的是,当基于范围的 for 循环遍历的是某函数返回的 string 对象或者容器时,整个遍历过程中,函数只会执行一次。

举个例子:

#include <iostream>
#include <string>
using namespace std;
string str= "http://c.biancheng.net/cplus/11/";
string retStr() {
    cout << "retStr:" << endl;
    return str;
}
int main() {
    //遍历函数返回的 string 字符串
    for (char ch : retStr()) {
        cout << ch;
    }
    return 0;
}

程序执行结果为:

retStr:
http://c.biancheng.net/cplus/11/

借助执行结果不难分析出,整个 for 循环遍历 str 字符串对象的过程中,retStr() 函数仅在遍历开始前执行了 1 次。

3.系统学过 STL 标准库的读者应该知道,基于关联式容器(包括哈希容器)底层存储机制的限制:

  1. 不允许修改 map、unordered_map、multimap 以及 unordered_multimap 容器存储的键的值;
  2. 不允许修改 set、unordered_set、multiset 以及 unordered_multiset 容器中存储的元素的值。

因此,当使用基于范围的 for 循环遍历此类型容器时,切勿修改容器中不允许被修改的数据部分,否则会导致程序的执行出现各种 Bug。

4.另外,基于范围的 for 循环完成对容器的遍历,其底层也是借助容器的迭代器实现的(begin和end函数来实现的)。举个例子:

#include <iostream>
#include <vector>
int main(void)
{
    std::vector<int>arr = { 1, 2, 3, 4, 5 };
    for (auto val : arr)
    {
        std::cout << val << std::endl;
        arr.push_back(10); //向容器中添加元素
    }
    return 0;
}

程序执行结果可能为(输出结果不唯一):

1
-572662307
-572662307
4
5

可以看到,程序的执行结果并不是我们想要的。就是因为在 for 循环遍历 arr 容器的同时向该容器尾部添加了新的元素(对 arr 容器进行了扩增),致使遍历容器所使用的迭代器失效,整个遍历过程出现错误。

因此,在使用基于范围的 for 循环遍历容器时,应避免在循环体中修改容器存储元素的个数。

5.其实这个是一种拷贝的动作,下面这个代码还要考虑在c这个类中允不允许它的对象通过等于号进行构造,如下面

class c{
public:
	explict c(string s);
};
vector<c> v;
for (auto ch : v) {
        cout << ch;
    }

那么

c eg1;
c eg2=eg1;//这个行为是不被允许的,因为有exlicit关键字,只能显式调用,所以会报错,那就不能用for循环的这个遍历

不能用for范围循环这种的,因为它本质上还是一个赋值动作,eg2就是上面的ch

9.=default、=delete

9.1 default

如果你自己定义了一个构造函数,那编译器不会给你默认构造函数了。

如果你强制加上default,就可以重新得到编译器的默认构造函数

一般成员函数不能用这个,是给构造函数用的

image-20241010152223392

9.2 delete

C++11中,当我们定义一个类的成员函数时,如果后面使用"=delete"去修饰,那么就表示这个函数被定义为deleted,也就意味着这个成员函数不能再被调用,否则就会出错。

在C++11之前,当我们希望一个类不能被拷贝,就会把构造函数定义为private,但是在C++11里就不需要这样做了,只需要在构造函数后面加上=delete来修饰下就可以了。

构造函数成员函数析构函数都可以用

注:两者不要同时作用一个函数,会报错,编译器不知道你要干啥

~c()=delete;
~c()=default;//报错

10.Alias Template(using 用法)

Alias Template(别名模板)是一种C++编程语言中的模板技术,它允许程序员使用别名来引用已有的类型。通过别名模板,可以创建新的类型别名,使得代码更易读、更易维护。在C++11标准之后引入了别名模板的语法,使用关键字using来定义别名模板,它的语法格式如下:

template <typename AliasType>
using AliasName = OriginalType; 

在这里,AliasType 是你想要定义的新类型的占位符,AliasName 是你给这个新类型取的名字,而 OriginalType 则是你想要创建别名的原始类型。这种语法使得创建类型别名更加简洁和易于理解。

使用别名模板可以提高代码的可读性和灵活性,尤其是在模板元编程和泛型编程中。例如,当你需要使用一个复杂的模板类型时,可以通过别名模板为它创建一个更简洁的名称,使得代码更易于理解。此外,别名模板还可以用于简化模板类型的声明,提高代码的可维护性。

image-20241010155354561

用 #define 达不到我们想要的结果

typedef无法达到任意传参的效果,我们写成int就一直得是int,而我们的本意不是这种特化的模板,是想要我们传哪个类型,T就是哪个类型我们需要的其实是一个固定以 std::string 为 key 的 map,它可以映射到 int 或另一个 std::string。然而这个简单的需求仅通过 typedef 却很难办到。

因此,在 C++98/03 中往往不得不这样写:

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 的模板别名 str_map_t。比起前面使用外敷模板加 typedef 构建的 str_map,它完全就像是一个新的 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>;

可以看到,在重定义普通类型上,两种使用方法的效果是等价的,唯一不同的是定义语法。

typedef 的定义方法和变量的声明类似:像声明一个变量一样,声明一个重定义类型,之后在声明之前加上 typedef 即可。这种写法凸显了 C/C++ 中的语法一致性,但有时却会增加代码的阅读难度。比如重定义一个函数指针时:

typedef void (*func_t)(int, int);

与之相比,using 后面总是立即跟随新标识符(Identifier),之后使用类似赋值的语法,把现有的类型(type-id)赋给新类型:

using func_t = void (*)(int, int);

从上面的对比中可以发现,C++11 的 using 别名语法比 typedef 更加清晰。因为 typedef 的别名语法本质上类似一种解方程的思路。而 using 语法通过赋值来定义别名,和我们平时的思考方式一致。

下面再通过一个对比示例,看看新的 using 语法是如何定义模板别名的。

/* C++98/03 */
template <typename T>
struct func_t
{
    typedef void (*type)(T, T) type;
};
// 使用 func_t 模板
func_t<int>::type xx_1;
/* C++11 */
template <typename T>
using func_t = void (*)(T, T);
// 使用 func_t 模板
func_t<int> xx_2;

从示例中可以看出,通过 using 定义模板别名的语法,只是在普通类型别名语法的基础上增加 template 的参数列表。使用 using 可以轻松地创建一个新的模板别名,而不需要像 C++98/03 那样使用烦琐的外敷模板。

需要注意的是,using 语法和 typedef 一样,并不会创造新的类型。也就是说,上面示例中 C++11 的 using 写法只是 typedef 的等价物。虽然 using 重定义的 func_t 是一个模板,但 func_t 定义的 xx_2 并不是一个由类模板实例化后的类,而是 void(*)(int, int) 的别名。

因此,下面这样写:

void foo(void (*func_call)(int, int));
void foo(func_t func_call); // error: redefinition

同样是无法实现重载的,func_t 只是 void(*)(int, int) 类型的等价物。

细心的读者可以发现,using 重定义的 func_t 是一个模板,但它既不是类模板也不是函数模板(函数模板实例化后是一个函数),而是一种新的模板形式:模板别名(alias template)。

其实,通过 using 可以轻松定义任意类型的模板表达方式。比如下面这样:

template <typename T> 
using type_t = T;
// …
type_t<int> i;

type_t 实例化后的类型和它的模板参数类型等价。这里,type_t 将等价于 int。

11.template template parameters(双重模版参数)

在C++中,template template parameters(模板模板参数)是一种高级模板特性,它允许一个模板接受另一个模板作为参数。这种特性在编写泛型代码时非常有用,特别是当你需要定义一个模板,该模板本身需要接受另一个模板作为类型参数时。

解释

当你定义一个模板时,你可以指定它的参数类型。通常,这些参数是类型(typename)或非类型参数(如整数常量)。但是,模板模板参数允许你指定一个模板作为参数,这意味着你可以要求调用者提供一个符合特定签名的模板。

语法

模板模板参数的语法如下:

template<template<typename...> class TemplateParam>  
class MyClass;

这里,TemplateParam是一个模板模板参数,它本身接受一个或多个类型参数(在这个例子中是使用省略号...表示的可变数量)。class关键字用于指定TemplateParam是一个模板类(你也可以使用typename,但在这种情况下class更常见)。

例子

让我们通过一个例子来更好地理解模板模板参数。

假设我们想要定义一个容器类,该类可以存储任何类型的std::vector。我们可以使用模板模板参数来实现这一点:

#include <vector>  
#include <iostream>  
  
// 定义一个模板模板参数TemplateVector,它本身是一个模板,接受一个类型参数T  
template<template<typename, typename...> class TemplateVector, typename T>  
class MyContainer {  
public:  
    // 使用TemplateVector模板来定义一个成员变量,该变量接受T作为类型参数,并可能接受其他参数(如分配器)  
    TemplateVector<T> vec;  
  
    void add(const T& value) {  
        vec.push_back(value);  
    }  
  
    void print() const {  
        for (const auto& v : vec) {  
            std::cout << v << " ";  
        }  
        std::cout << std::endl;  
    }  
};  
  
int main() {  
    // 实例化MyContainer,指定TemplateVector为std::vector,T为int  
    MyContainer<std::vector, int> container;  
  
    container.add(1);  
    container.add(2);  
    container.add(3);  
  
    container.print(); // 输出: 1 2 3  
  
    return 0;  
}

在这个例子中,MyContainer是一个模板类,它接受两个参数:TemplateVectorTTemplateVector是一个模板模板参数,它本身接受一个类型参数(在这里是T)和可能的其他参数(如std::vector的分配器参数,虽然在这个例子中我们没有使用它)。

我们实例化MyContainer时,指定TemplateVectorstd::vectorTint。这样,MyContainer内部就使用了一个std::vector<int>作为其成员变量vec

注意

  • 模板模板参数必须匹配其接受的模板的签名。例如,如果TemplateVector期望一个接受两个类型参数的模板,那么你不能传递一个只接受一个类型参数的模板。
  • 模板模板参数增加了代码的灵活性,但也增加了复杂性。在使用它们之前,请确保你理解了基本的模板概念。
  • 在实际编程中,模板模板参数并不常见,但在需要高度泛型代码的情况下,它们可以非常有用。

Q1:

MyContainer<std::vector, int> container;这句话为什么不写成MyContainer<std::vector, int> container;呢?就是为什么vector后面可以不加尖括号写参数呢?

在C++模板编程中,当你看到像 MyContainer<std::vector, int> 这样的实例化表达式时,它实际上是在利用一个称为“模板模板参数”(template template parameter)的特性。这里的关键在于理解 MyContainer 的模板参数是如何定义的。

假设 MyContainer 是这样定义的:

template<template<typename...> class TemplateVector, typename T, typename... Args>  
class MyContainer {  
    // ...  
};

在这个定义中,TemplateVector 是一个模板模板参数,它接受任意数量的类型参数(由 typename... 表示)。这意味着 TemplateVector 可以是任何接受至少一个类型参数的模板,例如 std::vector

当你写 MyContainer<std::vector, int> 时,你实际上是在告诉编译器:

  • TemplateVector 应该被替换为 std::vector
  • T 应该被替换为 int
  • Args... 在这个特定实例中是空的,因为 std::vector 通常只接受一个元素类型和一个可选的分配器类型作为参数,而在这里没有提供分配器类型,所以 Args... 就不包含任何内容。

然而,由于 MyContainer 的定义允许 TemplateVector 接受任意数量的类型参数,并且你希望将 int 作为 std::vector 的元素类型,编译器会理解你的意图,并将 std::vector<int>(隐式地,加上默认的分配器类型)作为内部使用的容器类型。

但是,这里有一个微妙的点:在 MyContainer 的定义中,你实际上并没有直接实例化 std::vector。相反,你提供了 std::vector 作为模板模板参数 TemplateVector 的一个实例,并指定了 int 作为第一个(也是在这个例子中唯一的)类型参数。然后,MyContainer 内部会使用这个信息来实例化一个 std::vector<int>(或更准确地说,一个 std::vector<int, std::allocator<int>>,因为 std::vector 默认使用 std::allocator)。

如果你尝试写 MyContainer<std::vector<int>, int>,那么这将是不正确的,因为这将意味着你正在尝试将一个已经实例化的 std::vector<int> 类型作为 TemplateVector 的参数,而 TemplateVector 需要的是一个模板类型,而不是一个已经实例化的类型。

简而言之,MyContainer<std::vector, int> 是正确的写法,因为它遵循了模板模板参数的语法规则,允许 MyContainer 内部正确地实例化一个 std::vector<int>(或带有默认分配器的等价类型)。

Q2:

什么叫而 TemplateVector 需要的是一个模板类型,而不是一个已经实例化的类型?

在C++模板编程中,当我们谈论“模板类型”与“已经实例化的类型”时,我们是在区分两种不同类型的实体。

  1. 模板类型(Template Type)
    模板类型是一个蓝图或框架,它定义了如何根据提供的类型参数(以及可能的非类型参数)来构造具体的类型。例如,std::vector 是一个模板类型,它接受一个类型参数(元素的类型)和一个可选的非类型参数(通常是分配器的类型,但默认使用 std::allocator)。

    当我们说“TemplateVector 需要的是一个模板类型”时,我们是指 TemplateVector 这个参数在 MyContainer 模板的定义中预期的是一个尚未实例化的模板,比如 std::vector。这个模板可以在 MyContainer 内部根据提供的类型参数(如 int)被实例化。

  2. 已经实例化的类型(Instantiated Type)
    已经实例化的类型是指根据某个模板类型和一个或多个类型参数(以及可能的非类型参数)实际构造出来的具体类型。例如,std::vector<int> 是一个已经实例化的类型,它是根据 std::vector 模板和类型参数 int 构造出来的。

    如果我们尝试将 std::vector<int> 作为一个模板参数传递给一个期望模板类型的参数(如 TemplateVector),那么这将是不正确的,因为 std::vector<int> 已经是一个具体的类型了,而不是一个可以用来构造类型的模板。

MyContainer 的例子中,TemplateVector 被定义为接受一个或多个类型参数的模板模板参数。这意味着 MyContainer 期望一个可以作为模板使用的类型,而不是一个已经用具体类型参数实例化过的类型。因此,当我们写 MyContainer<std::vector, int> 时,我们实际上是在告诉编译器:

  • 使用 std::vector 作为模板模板参数 TemplateVector 的值。
  • MyContainer 内部,将 int 作为 std::vector 的类型参数来实例化一个 std::vector<int>(或带有默认分配器的等价类型)。

这样,MyContainer 就可以根据提供的模板类型(std::vector)和类型参数(int)来构造和使用一个具体的容器类型了。

12.Type Alias(类型别名,using用法)

type alias(类型别名)是更通用的概念,它不仅仅限于模板。类型别名可以简单地为一个现有的类型定义一个新的名称。在 C++ 中,你可以使用 typedef 关键字或 using 关键字来创建类型别名。

使用 typedef 的例子:

typedef int Integer;

使用 using 的例子(C++11 及更高版本):

using Integer = int;

这两种方式都会为 int 类型创建一个新的名称 Integer。之后,你可以在代码中使用 Integer 来代替 int。
总结

Alias Template 是 C++11 引入的特性,专门用于为模板类型定义别名。
Type Alias 是一个更通用的概念,可以用于任何类型,不仅仅限于模板类型。在 C++ 中,你可以使用 typedef 或 using 关键字来创建类型别名。

13.noexcept

在C++中,noexcept是一个说明符(specifier),用于指示某个函数或函数对象在抛出异常方面的行为。当使用noexcept时,你向编译器和其他程序员保证该函数不会抛出任何异常。如果函数违反了这一保证(即它实际上抛出了一个异常),那么程序将调用std::terminate(),这通常会导致程序异常终止。

noexcept的用法

  1. 函数声明和定义中的noexcept

你可以在函数声明或定义中的函数类型之后使用noexcept来指定该函数不会抛出异常。例如:

void myFunction() noexcept;  
  
void myFunction() noexcept {  
    // 函数体,这里不应该有throw语句,除非你想让程序异常终止。  
}

2.构造函数和析构函数中的noexcept

对于类的构造函数和析构函数来说,使用noexcept尤为重要,因为它们与对象的生命周期管理紧密相关。如果一个析构函数被声明为noexcept,那么当异常在栈展开过程中传播时,析构函数被调用时不会再次抛出异常,这有助于保证栈展开的顺利进行。

class MyClass {  
public:  
    MyClass() noexcept; // 构造函数不会抛出异常  
    ~MyClass() noexcept; // 析构函数也不会抛出异常  
};

3.lambda表达式中的noexcept

对于lambda表达式,你也可以使用noexcept来指定它不会抛出异常:

auto myLambda = []() noexcept -> void {  
    // lambda体,这里不应该有throw语句。  
};

noexcept的用途

  • 性能优化:编译器可以利用noexcept信息来生成更高效的代码。例如,在移动语义中,如果一个函数被标记为noexcept,那么编译器可能更愿意使用移动构造函数而不是复制构造函数,因为移动操作通常不会抛出异常,而复制操作可能会。
  • 异常安全性:在异常传播的过程中,如果某个函数被声明为noexcept但实际上抛出了异常,那么程序将调用std::terminate()。这可以作为一种额外的安全措施,确保在不应该抛出异常的地方不会抛出异常。
  • 标准库要求:C++标准库中的一些函数和容器要求它们的参数或成员函数是noexcept的。例如,std::vector的某些成员函数(如push_back在元素被移动而非复制时)可能要求元素的移动构造函数是noexcept的,以便在需要时优化性能。

示例

下面是一个简单的示例,展示了如何使用noexcept以及违反它的后果:

#include <iostream>  
#include <exception>  
  
void myFunction() noexcept {  
    // 这里我们故意抛出一个异常,以展示noexcept的效果。  
    throw std::runtime_error("This should not happen!");  
}  
  
int main() {  
    try {  
        myFunction();  
    } catch (const std::exception& e) {  
        std::cout << "Caught exception: " << e.what() << std::endl;  
    }  
    // 注意:由于myFunction()是noexcept的,上面的catch块实际上不会被执行。  
    // 程序将直接调用std::terminate(),导致异常终止。  
    return 0;  
}

在这个示例中,尽管我们尝试在catch块中捕获异常,但由于myFunction()被声明为noexcept,程序将在抛出异常时调用std::terminate(),而不是进入catch块。这将导致程序异常终止,并可能输出一条错误消息(这取决于你的编译器和运行时环境)。

14.override

override用于修饰派生类中的成员函数,标明该函数重写了基类函数,如果一个函数声明了override但父类却没有这个虚函数,编译报错,使用override关键字可以避免开发者在重写基类函数时无意产生的错误。

struct Base {
    virtual void func() {
        cout << "base" << endl;
    }
};

struct Derived : public Base{
    void func() override { // 确保func被重写
        cout << "derived" << endl;
    }

    void fu() override { // error,基类没有fu(),不可以被重写
        
    }
};

15.final

在C++中,final关键字有两个主要用途:一是用于类中的虚函数,表示该函数不能被任何派生类(子类)重写(override);二是用于类定义之后,表示该类不能被继承,即它是最终的基类,没有其他类可以从它派生。

1. 用于虚函数

当一个虚函数被声明为final时,这意味着在派生类中不能提供一个与该函数签名相同的新定义(即不能重写该函数)。尝试这样做将导致编译错误。

示例

class Base {  
public:  
    virtual void show() const {  
        std::cout << "Base show()" << std::endl;  
    }  
  
    // 声明为final的虚函数  
    virtual void display() const final {  
        std::cout << "Base display()" << std::endl;  
    }  
};  
  
class Derived : public Base {  
public:  
    // 试图重写final函数将导致编译错误  
    // void display() const override {  
    //     std::cout << "Derived display()" << std::endl;  
    // }  
  
    // 可以重写非final的虚函数  
    void show() const override {  
        std::cout << "Derived show()" << std::endl;  
    }  
};  
  
int main() {  
    Derived d;  
    d.show(); // 输出: Derived show()  
    // d.display(); // 如果取消注释,由于display()在Base中被声明为final,这里将编译错误  
    return 0;  
}

在这个例子中,Base类有一个虚函数show()和一个被声明为final的虚函数display()Derived类可以重写show()函数,但不能重写display()函数,因为后者在Base中被声明为final

2. 用于类定义

final关键字用于类定义之后时,它表示该类不能被继承。这有助于防止其他开发人员意外地从该类派生新的类,从而保持类的封装性和稳定性。

示例

class FinalClass final {  
public:  
    void doSomething() const {  
        std::cout << "Doing something in FinalClass" << std::endl;  
    }  
};  
  
// 试图从FinalClass派生新类将导致编译错误  
// class AnotherClass : public FinalClass {  
// };  
  
int main() {  
    FinalClass fc;  
    fc.doSomething(); // 输出: Doing something in FinalClass  
    return 0;  
}

在这个例子中,FinalClass被声明为final,这意味着不能从它派生任何新类。如果尝试这样做(如注释掉的代码所示),将导致编译错误。

总结

  • final关键字用于虚函数时,表示该函数不能被派生类重写。
  • final关键字用于类定义时,表示该类不能被继承。
  • 使用final可以提高代码的可读性和可维护性,因为它清楚地表明了哪些函数或类是不应该被修改的。

16.decltype

C++ decltype类型推导完全攻略

decltype 是 C++11 新增的一个关键字,它和 auto 的功能一样,都用来在编译时期进行自动类型推导。

decltype 是“declare type”的缩写,译为“声明类型”。

既然已经有了 auto 关键字,为什么还需要 decltype 关键字呢?因为 auto 并不适用于所有的自动类型推导场景,在某些特殊情况下 auto 用起来非常不方便,甚至压根无法使用,所以 decltype 关键字也被引入到 C++11 中。

auto 和 decltype 关键字都可以自动推导出变量的类型,但它们的用法是有区别的:

auto varname = value;
decltype(exp) varname = value;

其中,varname 表示变量名,value 表示赋给变量的值,exp 表示一个表达式。

auto 根据=右边的初始值 value 推导出变量的类型,而 decltype 根据 exp 表达式推导出变量的类型,跟=右边的 value 没有关系。

另外,auto 要求变量必须初始化,而 decltype 不要求。这很容易理解,auto 是根据变量的初始值来推导出变量类型的,如果不初始化,变量的类型也就无法推导了。decltype 可以写成下面的形式:

decltype(exp) varname;
exp 注意事项

原则上讲,exp 就是一个普通的表达式,它可以是任意复杂的形式,但是我们必须要保证 exp 的结果是有类型的,不能是 void;例如,当 exp 调用一个返回值类型为 void 的函数时,exp 的结果也是 void 类型,此时就会导致编译错误。

C++ decltype 用法举例:

int a = 0;
decltype(a) b = 1;  //b 被推导成了 int
decltype(10.8) x = 5.5;  //x 被推导成了 double
decltype(x + 100) y;  //y 被推导成了 double

可以看到,decltype 能够根据变量、字面量、带有运算符的表达式推导出变量的类型。读者请留意第 4 行,y 没有被初始化。

decltype 推导规则

上面的例子让我们初步感受了一下 decltype 的用法,但你不要认为 decltype 就这么简单,它的玩法实际上可以非常复杂。当程序员使用 decltype(exp) 获取类型时,编译器将根据以下三条规则得出结果:

  • 如果 exp 是一个不被括号( )包围的表达式,或者是一个类成员访问表达式,或者是一个单独的变量,那么 decltype(exp) 的类型就和 exp 一致,这是最普遍最常见的情况。
  • 如果 exp 是函数调用,那么 decltype(exp) 的类型就和函数返回值的类型一致。
  • 如果 exp 是一个左值,或者被括号( )包围,那么 decltype(exp) 的类型就是 exp 的引用;假设 exp 的类型为 T,那么 decltype(exp) 的类型就是 T&。

为了更好地理解 decltype 的推导规则,下面来看几个实际的例子。

【实例1】exp 是一个普通表达式:

#include <string>
using namespace std;
class Student{
public:
    static int total;
    string name;
    int age;
    float scores;
};
int Student::total = 0;
int  main(){
    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 = "http://c.biancheng.net/cplus/";  //total 为类 Student 的一个 string 类型的成员变量, url 被推导为 string 类型
    return 0;
}

这段代码很简单,按照推导规则 1,对于一般的表达式,decltype 的推导结果就和这个表达式的类型一致。

【实例2】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 中调用函数时需要带上括号和参数,但这仅仅是形式,并不会真的去执行函数代码。

【实例3】exp 是左值,或者被( )包围:

using namespace std;
class Base{
public:
    int x;
};
int main(){
    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&
    return 0;
}

这里我们需要重点说一下左值和右值:左值是指那些在表达式执行结束后依然存在的数据,也就是持久性的数据;右值是指那些在表达式执行结束后不再存在的数据,也就是临时性的数据。有一种很简单的方法来区分左值和右值,对表达式取地址,如果编译器不报错就为左值,否则为右值。

decltype 的实际应用

1.auto 的语法格式比 decltype 简单,所以在一般的类型推导中,使用 auto 比使用 decltype 更加方便

auto 只能用于类的静态成员,不能用于类的非静态成员(普通成员),如果我们想推导非静态成员的类型,这个时候就必须使用 decltype 了。下面是一个模板的定义:

#include <vector>
using namespace std;
template <typename T>
class Base {
public:
    void func(T& container) {
        m_it = container.begin();
    }
private:
    typename T::iterator m_it;  //注意这里
};
int main()
{
    const vector<int> v;
    Base<const vector<int>> obj;
    obj.func(v);
    return 0;
}	

单独看 Base 类中 m_it 成员的定义,很难看出会有什么错误,但在使用 Base 类的时候,如果传入一个 const 类型的容器,编译器马上就会弹出一大堆错误信息。原因就在于,T::iterator并不能包括所有的迭代器类型,当 T 是一个 const 容器时,应当使用 const_iterator。

要想解决这个问题,在之前的 C++98/03 版本下只能想办法把 const 类型的容器用模板特化单独处理,增加了不少工作量,看起来也非常晦涩。但是有了 C++11 的 decltype 关键字,就可以直接这样写:

template <typename T>
class Base {
public:
    void func(T& container) {
        m_it = container.begin();
    }
private:
    decltype(T().begin()) m_it;  //注意这里
};

看起来是不是很清爽?

注意,有些低版本的编译器不支持T().begin()这种写法,以上代码我在 VS2019 下测试通过,在 VS2015 下测试失败。

2.auto和decltype结合

//这样写是不行的,因为编译器找不到x和y
template<typename T1,typename T2>
decltype (x+y) add(T2 x,T2 y);
//得这样写才行
template<typename T1,typename T2>
auto add(T1 x,T2 y)->decltype(x+y);

3.decltype在C++的模板元编程中也扮演着重要角色。元编程允许程序员在编译时执行计算和操作类型,而decltype提供了一种强大的方式来推断和操纵这些类型。例如,在编写泛型代码时,你可能需要基于模板参数的类型来构造新的类型decltype可以帮助你实现这一点,因为它可以推断出任何给定表达式的类型。

4.在C++11及更高版本中,decltype还可以用于推断lambda表达式的类型。这对于需要将lambda表达式作为参数传递给函数或模板,并且希望保持其类型信息的情况非常有用。例如,你可能有一个接受函数对象作为参数的函数模板,并且希望它能够接受lambda表达式作为参数。使用decltypeauto关键字,你可以轻松地实现这一点。

image-20241010185606846

汇总auto和decltype的区别
语法格式的区别

auto 和 decltype 都是 C++11 新增的关键字,都用于自动类型推导,但是它们的语法格式是有区别的,如下所示:

auto varname = value; //auto的语法格式
decltype(exp) varname [= value]; //decltype的语法格式

其中,varname 表示变量名,value 表示赋给变量的值,exp 表示一个表达式,方括号[ ]表示可有可无。

auto 和 decltype 都会自动推导出变量 varname 的类型:

  • auto 根据=右边的初始值 value 推导出变量的类型;
  • decltype 根据 exp 表达式推导出变量的类型,跟=右边的 value 没有关系。

另外,auto 要求变量必须初始化,也就是在定义变量的同时必须给它赋值;而 decltype 不要求,初始化与否都不影响变量的类型。这很容易理解,因为 auto 是根据变量的初始值来推导出变量类型的,如果不初始化,变量的类型也就无法推导了。

auto 将变量的类型和初始值绑定在一起,而 decltype 将变量的类型和初始值分开;虽然 auto 的书写更加简洁,但 decltype 的使用更加灵活。

17.Lambda 表达式

C++11 lambda匿名函数用法详解

lambda 源自希腊字母表中第 11 位的 λ,在计算机科学领域,它则是被用来表示一种匿名函数。所谓匿名函数,简单地理解就是没有名称的函数,又常被称为 lambda 函数或者 lambda 表达式。

lambda匿名函数的定义

定义一个 lambda 匿名函数很简单,可以套用如下的语法格式:

[外部变量访问方式说明符] (参数) mutable noexcept/throw() -> 返回值类型
{
函数体;
};

其中各部分的含义分别为:

1.[外部变量方位方式说明符]
[ ] 方括号用于向编译器表明当前是一个 lambda 表达式,其不能被省略。在方括号内部,可以注明当前 lambda 函数的函数体中可以使用哪些“外部变量”。

所谓外部变量,指的是和当前 lambda 表达式位于同一作用域内的所有局部变量。

2.(参数)
和普通函数的定义一样,lambda 匿名函数也可以接收外部传递的多个参数。和普通函数不同的是,如果不需要传递参数,可以连同 () 小括号一起省略;

如果参数,关键字,返回值一个都没有,那小括号可写可不写,如果有一个那就得写小括号

3.mutable
此关键字可以省略,如果使用则之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,对于以值传递方式引入的外部变量,不允许在 lambda 表达式内部修改它们的值(可以理解为这部分变量都是 const 常量)。而如果想修改它们,就必须使用 mutable 关键字。

注意,对于以值传递方式引入的外部变量,lambda 表达式修改的是拷贝的那一份,并不会修改真正的外部变量

4.noexcept/throw()
可以省略,如果使用,在之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,lambda 函数的函数体中可以抛出任何类型的异常。而标注 noexcept 关键字,则表示函数体内不会抛出任何异常;使用 throw() 可以指定 lambda 函数内部可以抛出的异常类型。

值得一提的是,如果 lambda 函数标有 noexcept 而函数体内抛出了异常,又或者使用 throw() 限定了异常类型而函数体内抛出了非指定类型的异常,这些异常无法使用 try-catch 捕获,会导致程序执行失败(本节后续会给出实例)。

5.-> 返回值类型
指明 lambda 匿名函数的返回值类型。值得一提的是,如果 lambda 函数体内只有一个 return 语句,或者该函数返回 void,则编译器可以自行推断出返回值类型,此情况下可以直接省略-> 返回值类型

6.函数体
和普通函数一样,lambda 匿名函数包含的内部代码都放置在函数体中。该函数体内除了可以使用指定传递进来的参数之外,还可以使用指定的外部变量以及全局范围内的所有全局变量。

需要注意的是,外部变量会受到以值传递还是以引用传递方式引入的影响,而全局变量则不会。换句话说,在 lambda 表达式内可以使用任意一个全局变量,必要时还可以直接修改它们的值。

其中,红色标识的参数是定义 lambda 表达式时必须写的,而绿色标识的参数可以省略。

比如,如下就定义了一个最简单的 lambda 匿名函数:

[]{}

显然,此 lambda 匿名函数未引入任何外部变量([] 内为空),也没有传递任何参数,没有指定 mutable、noexcept 等关键字,没有返回值和函数体。所以,这是一个没有任何功能的 lambda 匿名函数

lambda匿名函数中的[外部变量]
外部变量格式功能
[]空方括号表示当前 lambda 匿名函数中不导入任何外部变量。
[=]只有一个 = 等号,表示以值传递的方式导入所有外部变量;
[&]只有一个 & 符号,表示以引用传递的方式导入所有外部变量;
[val1,val2,…]表示以值传递的方式导入 val1、val2 等指定的外部变量,同时多个变量之间没有先后次序;
[&val1,&val2,…]表示以引用传递的方式导入 val1、val2等指定的外部变量,多个变量之间没有前后次序;
[val,&val2,…]以上 2 种方式还可以混合使用,变量之间没有前后次序。
[=,&val1,…]表示除 val1 以引用传递的方式导入外,其它外部变量都以值传递的方式导入。
[this]表示以值传递的方式导入当前的 this 指针。

注意,单个外部变量不允许以相同的传递方式导入多次。例如 [=,val1] 中,val1 先后被以值传递的方式导入了 2 次,这是非法的。

调用方式举例:

//1.最常用的,如果要调用的的话就这么写
auto i=[]{
    cout<<"hello world"<<endl;
}
i();
//2.这样写不是错的,但是没必要,中就是把它看成了普通函数来调用,()就是调用符
[]{
    cout<<"hello world"<<endl;
}();
//3.在某个函数里面调用
int num[4] = {4, 2, 3, 1};
//对 a 数组中的元素进行升序排序
sort(num, num+4, [=](int x, int y) -> bool{ return x < y; } );

【例 1】lambda 匿名函数的定义和使用。

#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
    int num[4] = {4, 2, 3, 1};
    //对 a 数组中的元素进行排序
    sort(num, num+4, [=](int x, int y) -> bool{ return x < y; } );
    for(int n : num){
        cout << n << " ";
    }
    return 0;
}

程序执行结果为:

1 2 3 4

调用 sort() 函数实现了对 num 数组中元素的升序排序,其中就用到了 lambda 匿名函数。

lambda 匿名函数没有函数名称,我们仍可以为其手动设置一个名称,比如:

#include <iostream>
using namespace std;
int main()
{
    //display 即为 lambda 匿名函数的函数名
    auto display = [](int a,int b) -> void{cout << a << " " << b;};
    //调用 lambda 函数
    display(10,20);
    return 0;
}

【例 2】值传递和引用传递的区别

#include <iostream>
using namespace std;
//全局变量
int all_num = 0;
int main()
{
    //局部变量
    int num_1 = 1;
    int num_2 = 2;
    int num_3 = 3;
    cout << "lambda1:\n";
    auto lambda1 = [=]{
        //全局变量可以访问甚至修改
        all_num = 10;
        //函数体内只能使用外部变量,而无法对它们进行修改
        cout << num_1 << " "
             << num_2 << " "
             << num_3 << endl;
    };
    lambda1();
    cout << all_num <<endl;
    cout << "lambda2:\n";
    auto lambda2 = [&]{
        all_num = 100;
        num_1 = 10;
        num_2 = 20;
        num_3 = 30;
        cout << num_1 << " "
             << num_2 << " "
             << num_3 << endl;
    };
    lambda2();
    cout << all_num << endl;
    return 0;
}

程序执行结果为:

lambda1:
1 2 3
10
lambda2:
10 20 30
100

可以看到,在创建 lambda1 和 lambda2 匿名函数的作用域中,有 num_1、num_2 和 num_3 这 3 个局部变量,另外还有 all_num 全局变量。

其中,lambda1 匿名函数是以 [=] 值传递的方式导入的局部变量,这意味着默认情况下,此函数内部无法修改这 3 个局部变量的值,但全局变量 all_num 除外。相对地,lambda2 匿名函数以 [&] 引用传递的方式导入这 3 个局部变量,因此在该函数的内部不就可以访问这 3 个局部变量,还可以任意修改它们。同样,也可以访问甚至修改全局变量。

感兴趣的读者,可自行尝试在 lambda1 匿名函数中修改 num_1、num_2 或者 num_3 的值,观察编译器的报错信息。

当然,如果我们想在 lambda1 匿名函数的基础上修改外部变量的值,可以借助 mutable 关键字,例如:

auto lambda1 = [=]() mutable{
    num_1 = 10;
    num_2 = 20;
    num_3 = 30;
    //函数体内只能使用外部变量,而无法对它们进行修改
    cout << num_1 << " "
         << num_2 << " "
         << num_3 << endl;
};

由此,就可以在 lambda1 匿名函数中修改外部变量的值。但需要注意的是,这里修改的仅是 num_1、num_2、num_3 拷贝的那一份的值,真正外部变量的值并不会发生改变。

【例 3】执行抛出异常类型

#include <iostream>
using namespace std;
int main()
{
    auto except = []()throw(int) {
        throw 10;
    };
    try {
        except();
    }
    catch (int) {
        cout << "捕获到了整形异常";
    }
    return 0;
}

程序执行结果为:

捕获到了整形异常

可以看到,except 匿名数组中指定函数体中可以抛出整形异常,因此当函数体中真正发生整形异常时,可以借助 try-catch 块成功捕获并处理。

在此基础上,在看一下反例:

#include <iostream>
using namespace std;
int main()
{
    auto except1 = []()noexcept{
        throw 100;
    };
    auto except2 = []()throw(char){
        throw 10;
    };
    try{
        except1();
        except2();
    }catch(int){
        cout << "捕获到了整形异常"<< endl;
    }
    return 0;
}

此程序运行会直接崩溃,原因很简单,except1 匿名函数指定了函数体中不发生任何异常,但函数体中却发生了整形异常;except2 匿名函数指定函数体可能会发生字符异常,但函数体中却发生了整形异常。由于指定异常类型和真正发生的异常类型不匹配,导致 try-catch 无法捕获,最终程序运行崩溃。

如果不使用 noexcept 或者 throw(),则 lambda 匿名函数的函数体中允许发生任何类型的异常。

注意:

image-20241010193201243

左边,编译器编译时,进入f的id是0而不是42

而右边传入的是引用,调用f前已经是42了

2.lambda可以和仿函数进行互换,一般都会写lambda,看着比较简洁

image-20241010193526320

18.Rvalue references(右值引用)

右值引用是一种新的 C++ 语法,理解起来有难度的是基于右值引用引申出的 2 种 C++ 编程技巧,分别为移动语义和完美转发。

C++左值和右值

右值引用可以从字面意思上理解,指的是以引用传递(而非值传递)的方式使用 C++ 右值。接下来给大家介绍什么是 C++ 右值。

在 C++ 或者 C 语言中,一个表达式(可以是字面量、变量、对象、函数的返回值等)根据其使用场景不同,分为左值表达式和右值表达式。确切的说 C++ 中左值和右值的概念是从 C 语言继承过来的。

值得一提的是,左值的英文简写为“lvalue”,右值的英文简写为“rvalue”。很多人认为它们分别是"left value"、“right value” 的缩写,其实不然。lvalue 是“loactor value”的缩写,可意为存储在内存中、有明确存储地址(可寻址)的数据,而 rvalue 译为 “read value”,指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。

通常情况下,判断某个表达式是左值还是右值,最常用的有以下 2 种方法。

1.可位于赋值号(=)左侧的表达式就是左值;反之,只能位于赋值号右侧的表达式就是右值。举个例子:

int a = 5;
5 = a; //错误,5 不能为左值

其中,变量 a 就是一个左值,而字面量 5 就是一个右值。值得一提的是,C++ 中的左值也可以当做右值使用,例如:

int b = 10; // b 是一个左值
a = b; // a、b 都是左值,只不过将 b 可以当做右值使用

2.有名称的、可以获取到存储地址的表达式即为左值;反之则是右值。

以上面定义的变量 a、b 为例,a 和 b 是变量名,且通过 &a 和 &b 可以获得他们的存储地址,因此 a 和 b 都是左值;反之,字面量 5、10,它们既没有名称,也无法获取其存储地址(字面量通常存储在寄存器中,或者和代码存储在一起),因此 5、10 都是右值。

注意,以上 2 种判定方法只适用于大部分场景。由于本节主要讲解右值引用,因此这里适可而止,不再对 C++ 左值和右值做深度剖析,感兴趣的读者可自行研究。

C++右值引用

前面提到,其实 C++98/03 标准中就有引用,使用 “&” 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:

int num = 10;
int &b = num; //正确
int &c = 10; //错误

如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用。

注意,虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:

int num = 10;
const int &b = num;
const int &c = 10;

我们知道,右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。

为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。

话说,C++标准委员会在选定右值引用符号时,既希望能选用现有 C++ 内部已有的符号,还不能与 C++ 98 /03 标准产生冲突,最终选定了 2 个 ‘&’ 表示右值引用。

需要注意的,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:

int num = 10;
//int && a = num;  //右值引用不能初始化为左值
int && a = 10;

和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:

int && a = 10;
a = 100;
cout << a << endl;

程序输出结果为 100。

另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:

int num = 10;
const int &b = num;
const int &c = 10;

但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

引用类型可以引用的值类型使用场景
非常量左值常量左值非常量右值常量右值
非常量左值引用YNNN
常量左值引用YYYY常用于类中构建拷贝构造函数
非常量右值引用NNYN移动语义、完美转发
常量右值引用NNYY无实际用途

表中,Y 表示支持,N 表示不支持。

其实,C++11 标准中对右值做了更细致的划分,分别称为纯右值(Pure value,简称 pvalue)和将亡值(eXpiring value,简称 xvalue )。其中纯右值就是 C++98/03 标准中的右值(本节中已经做了大篇幅的讲解),而将亡值则指的是和右值引用相关的表达式(比如某函数返回的 T && 类型的表达式)。对于纯右值和将亡值,都属于右值,读者知道即可,不必深究。

19.移动构造函数的功能和用法

深浅拷贝效率问题

在 C++ 11 标准之前(C++ 98/03 标准中),如果想用其它对象初始化一个同类的新对象,只能借助类中的复制(拷贝)构造函数。拷贝构造函数的实现原理很简单,就是为新对象复制一份和其它对象一模一样的数据。

需要注意的是,当类中拥有指针类型的成员变量时,拷贝构造函数中需要以深拷贝(而非浅拷贝)的方式复制该指针成员。

举个例子:

#include <iostream>
using namespace std;
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 destruct!"<<endl;
   }
private:
   int *num;
};
demo get_demo(){
    return demo();
}
int main(){
    demo a = get_demo();
    return 0;
}

如上所示,我们为 demo 类自定义了一个拷贝构造函数。该函数在拷贝 d.num 指针成员时,必须采用深拷贝的方式,即拷贝该指针成员本身的同时,还要拷贝指针指向的内存资源。否则一旦多个对象中的指针成员指向同一块堆空间,这些对象析构时就会对该空间释放多次,这是不允许的。

可以看到,程序中定义了一个可返回 demo 对象的 get_demo() 函数,用于在 main() 主函数中初始化 a 对象,其整个初始化的流程包含以下几个阶段:

  1. 执行 get_demo() 函数内部的 demo() 语句,即调用 demo 类的默认构造函数生成一个匿名对象;
  2. 执行 return demo() 语句,会调用拷贝构造函数复制一份之前生成的匿名对象,并将其作为 get_demo() 函数的返回值(函数体执行完毕之前,匿名对象会被析构销毁);
  3. 执行 a = get_demo() 语句,再调用一次拷贝构造函数,将之前拷贝得到的临时对象复制给 a(此行代码执行完毕,get_demo() 函数返回的对象会被析构);
  4. 程序执行结束前,会自行调用 demo 类的析构函数销毁 a。

注意,目前多数编译器都会对程序中发生的拷贝操作进行优化,因此如果我们使用 VS 2017、codeblocks 等这些编译器运行此程序时,看到的往往是优化后的输出结果:

construct!
class destruct!

而同样的程序,如果在 Linux 上使用g++ demo.cpp -fno-elide-constructors命令运行(其中 demo.cpp 是程序文件的名称),就可以看到完整的输出结果:

construct! <-- 执行 demo()
copy construct! <-- 执行 return demo()
class destruct! <-- 销毁 demo() 产生的匿名对象
copy construct! <-- 执行 a = get_demo()
class destruct! <-- 销毁 get_demo() 返回的临时对象
class destruct! <-- 销毁 a

如上所示,利用拷贝构造函数实现对 a 对象的初始化,底层实际上进行了 2 次拷贝(而且是深拷贝)操作。当然,对于仅申请少量堆空间的临时对象来说,深拷贝的执行效率依旧可以接受,但如果临时对象中的指针成员申请了大量的堆空间,那么 2 次深拷贝操作势必会影响 a 对象初始化的执行效率。

事实上,此问题一直存留在以 C++ 98/03 标准编写的 C++ 程序中。由于临时变量的产生、销毁以及发生的拷贝操作本身就是很隐晦的(编译器对这些过程做了专门的优化),且并不会影响程序的正确性,因此很少进入程序员的视野。

那么当类中包含指针类型的成员变量,使用其它对象来初始化同类对象时,怎样才能避免深拷贝导致的效率问题呢?C++11 标准引入了解决方案,该标准中引入了右值引用的语法,借助它可以实现移动语义。

C++移动构造函数(移动语义的具体实现)

所谓移动语义,指的就是以移动而非深拷贝的方式初始化含有指针成员的类对象。简单的理解,移动语义指的就是将其他对象(通常是临时对象)拥有的内存资源“移为已用”。

以前面程序中的 demo 类为例,该类的成员都包含一个整形的指针成员,其默认指向的是容纳一个整形变量的堆空间。当使用 get_demo() 函数返回的临时对象初始化 a 时,我们只需要将临时对象的 num 指针直接浅拷贝给 a.num,然后修改该临时对象中 num 指针的指向(通常另其指向 NULL),这样就完成了 a.num 的初始化。

事实上,对于程序执行过程中产生的临时对象,往往只用于传递数据(没有其它的用处),并且会很快会被销毁。因此在使用临时对象初始化新对象时,我们可以将其包含的指针成员指向的内存资源直接移给新对象所有,无需再新拷贝一份,这大大提高了初始化的执行效率。

例如,下面程序对 demo 类进行了修改:

#include <iostream>
using namespace std;
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 destruct!"<<endl;
    }
private:
    int *num;
};
demo get_demo(){
    return demo();
}
int main(){
    demo a = get_demo();
    return 0;
}

可以看到,在之前 demo 类的基础上,我们又手动为其添加了一个构造函数。和其它构造函数不同,此构造函数使用右值引用形式的参数,又称为移动构造函数。并且在此构造函数中,num 指针变量采用的是浅拷贝的复制方式,同时在函数内部重置了 d.num,有效避免了“同一块对空间被释放多次”情况的发生。

在 Linux 系统中使用g++ demo.cpp -o demo.exe -std=c++0x -fno-elide-constructors命令执行此程序,输出结果为:

construct!
move construct!
class destruct!
move construct!
class destruct!
class destruct!

通过执行结果我们不难得知,当为 demo 类添加移动构造函数之后,使用临时对象初始化 a 对象过程中产生的 2 次拷贝操作,都转由移动构造函数完成。

我们知道,非 const 右值引用只能操作右值,程序执行结果中产生的临时对象(例如函数返回值、lambda 表达式等)既无名称也无法获取其存储地址,所以属于右值。当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数。

在实际开发中,通常在类中自定义移动构造函数的同时,会再为其自定义一个适当的拷贝构造函数,由此当用户利用右值初始化类对象时,会调用移动构造函数;使用左值(非右值)初始化类对象时,会调用拷贝构造函数。

如果使用左值初始化同类对象,但也想调用移动构造函数完成,有没有办法可以实现呢?

默认情况下,左值初始化同类对象只能通过拷贝构造函数完成,如果想调用移动构造函数,则必须使用右值进行初始化。C++11 标准中为了满足用户使用左值初始化同类对象时也通过移动构造函数完成的需求,新引入了 std::move() 函数,它可以将左值强制转换成对应的右值,由此便可以使用移动构造函数。

20. move()函数:将左值强制转换为右值

C++11 标准中借助右值引用可以为指定类添加移动构造函数,这样当使用该类的右值对象(可以理解为临时对象)初始化同类对象时,编译器会优先选择移动构造函数。

注意,移动构造函数的调用时机是:用同类的右值对象初始化新对象。那么,用当前类的左值对象(有名称,能获取其存储地址的实例对象)初始化同类对象时,是否就无法调用移动构造函数了呢?当然不是,C++11 标准中已经给出了解决方案,即调用 move() 函数。

move 本意为 “移动”,但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。

基于 move() 函数特殊的功能,其常用于实现移动语义。

move() 函数的用法也很简单,其语法格式如下:

move( arg )

其中,arg 表示指定的左值对象。该函数会返回 arg 对象的右值形式。

【例 1】move() 函数的基础应用。

#include <iostream>
using namespace std;
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:     //这里应该是 private,使用 public 是为了更方便说明问题
    int *num;
};
int main(){
    movedemo demo;
    cout << "demo2:\n";
    movedemo demo2 = demo;
    //cout << *demo2.num << endl;   //可以执行
    cout << "demo3:\n";
    movedemo demo3 = std::move(demo);
    //此时 demo.num = NULL,因此下面代码会报运行时错误
    //cout << *demo.num << endl;
    return 0;
}

程序执行结果为:

construct!
demo2:
copy construct!
demo3:
move construct!

通过观察程序的输出结果,以及对比 demo2 和 demo3 初始化操作不难得知,demo 对象作为左值,直接用于初始化 demo2 对象,其底层调用的是拷贝构造函数;而通过调用 move() 函数可以得到 demo 对象的右值形式,用其初始化 demo3 对象,编译器会优先调用移动构造函数。

注意,调用拷贝构造函数,并不影响 demo 对象,但如果调用移动构造函数,由于函数内部会重置 demo.num 指针的指向为 NULL,所以程序中第 30 行代码会导致程序运行时发生错误。

【例 2】灵活使用 move() 函数。

#include <iostream>
using namespace std;
class first {
public:
    first() :num(new int(0)) {
        cout << "construct!" << endl;
    }
    //移动构造函数
    first(first &&d) :num(d.num) {
        d.num = NULL;
        cout << "first move construct!" << endl;
    }
public:    //这里应该是 private,使用 public 是为了更方便说明问题
    int *num;
};
class second {
public:
    second() :fir() {}
    //用 first 类的移动构造函数初始化 fir
    second(second && sec) :fir(move(sec.fir)) {
        cout << "second move construct" << endl;
    }
public:    //这里也应该是 private,使用 public 是为了更方便说明问题
    first fir;
};
int main() {
    second oth;
    second oth2 = move(oth);
    //cout << *oth.fir.num << endl;   //程序报运行时错误
    return 0;
}

程序执行结果为:

construct!
first move construct!
second move construct

程序中分别构建了 first 和 second 这 2 个类,其中 second 类中包含一个 first 类对象。如果读者仔细观察不难发现,程序中使用了 2 此 move() 函数:

  • 由于 oth 为左值,如果想调用移动构造函数为 oth2 初始化,需先利用 move() 函数生成一个 oth 的右值版本;
  • oth 对象内部还包含一个 first 类对象,对于 oth.fir 来说,其也是一个左值,所以在初始化 oth.fir 时,还需要再调用一次 move() 函数。

注意:一旦作为move的参数被使用后,下面的代码不能使用该参数,因为这样的行为是没有定义过的,是不安全的

21.Perfect Forwarding 完美转发及其实现

C++11 标准为 C++ 引入右值引用语法的同时,还解决了一个 C++ 98/03 标准长期存在的短板,即使用简单的方式即可在函数模板中实现参数的完美转发。

首先解释一下什么是完美转发,它指的是函数模板可以将自己的参数“完美”地转发给内部调用的其它函数。所谓完美,即不仅能准确地转发参数的值,还能保证被转发参数的左、右值属性不变。

举个例子:

template<typename T>
void function(T t) {
    otherdef(t);
}

如上所示,function() 函数模板中调用了 otherdef() 函数。在此基础上,完美转发指的是:如果 function() 函数接收到的参数 t 为左值,那么该函数传递给 otherdef() 的参数 t 也是左值;反之如果 function() 函数接收到的参数 t 为右值,那么传递给 otherdef() 函数的参数 t 也必须为右值。

显然,function() 函数模板并没有实现完美转发。一方面,参数 t 为非引用类型,这意味着在调用 function() 函数时,实参将值传递给形参的过程就需要额外进行一次拷贝操作;另一方面,无论调用 function() 函数模板时传递给参数 t 的是左值还是右值,对于函数内部的参数 t 来说,它有自己的名称,也可以获取它的存储地址,因此它永远都是左值,也就是说,传递给 otherdef() 函数的参数 t 永远都是左值。总之,无论从那个角度看,function() 函数的定义都不“完美”。

读者可能会问,完美转发这样严苛的参数传递机制,很常用吗?C++98/03 标准中几乎不会用到,但 C++11 标准为 C++ 引入了右值引用和移动语义,因此很多场景中是否实现完美转发,直接决定了该参数的传递过程使用的是拷贝语义(调用拷贝构造函数)还是移动语义(调用移动构造函数)。

事实上,C++98/03 标准下的 C++ 也可以实现完美转发,只是实现方式比较笨拙。通过前面的学习我们知道,C++ 98/03 标准中只有左值引用,并且可以细分为非 const 引用和 const 引用。其中,使用非 const 引用作为函数模板参数时,只能接收左值,无法接收右值;而 const 左值引用既可以接收左值,也可以接收右值,但考虑到其 const 属性,除非被调用函数的参数也是 const 属性,否则将无法直接传递。

这也就意味着,单独使用任何一种引用形式,可以实现转发,但无法保证完美。因此如果使用 C++ 98/03 标准下的 C++ 语言,我们可以采用函数模板重载的方式实现完美转发,例如:

#include <iostream>
using namespace std;
//重载被调用函数,查看完美转发的效果
void otherdef(int & t) {
    cout << "lvalue\n";
}
void otherdef(const int & t) {
    cout << "rvalue\n";
}
//重载函数模板,分别接收左值和右值
//接收右值参数
template <typename T>
void function(const T& t) {
    otherdef(t);
}
//接收左值参数
template <typename T>
void function(T& t) {
    otherdef(t);
}
int main()
{
    function(5);//5 是右值
    int  x = 1;
    function(x);//x 是左值
    return 0;
}

程序执行结果为:

rvalue
lvalue

从输出结果中可以看到,对于右值 5 来说,它实际调用的参数类型为 const T& 的函数模板,由于 t 为 const 类型,所以 otherdef() 函数实际调用的也是参数用 const 修饰的函数,所以输出“rvalue”;对于左值 x 来说,2 个重载模板函数都适用,C++编译器会选择最适合的参数类型为 T& 的函数模板,进而 therdef() 函数实际调用的是参数类型为非 const 的函数,输出“lvalue”。

显然,使用重载的模板函数实现完美转发也是有弊端的,此实现方式仅适用于模板函数仅有少量参数的情况,否则就需要编写大量的重载函数模板,造成代码的冗余。为了方便用户更快速地实现完美转发,C++ 11 标准中允许在函数模板中使用右值引用来实现完美转发。

C++11 标准中规定,通常情况下右值引用形式的参数只能接收右值,不能接收左值。但对于函数模板中使用右值引用语法定义的参数来说,它不再遵守这一规定,既可以接收右值,也可以接收左值(此时的右值引用又被称为“万能引用”)。

仍以 function() 函数为例,在 C++11 标准中实现完美转发,只需要编写如下一个模板函数即可:

template <typename T>
void function(T&& t) {
    otherdef(t);
}

此模板函数的参数 t 既可以接收左值,也可以接收右值。但仅仅使用右值引用作为函数模板的参数是远远不够的,还有一个问题继续解决,即如果调用 function() 函数时为其传递一个左值引用或者右值引用的实参,如下所示:

int n = 10;
int & num = n;
function(num); // T 为 int&
int && num2 = 11;
function(num2); // T 为 int &&

其中,由 function(num) 实例化的函数底层就变成了 function(int & & t),同样由 function(num2) 实例化的函数底层则变成了 function(int && && t)。要知道,C++98/03 标准是不支持这种用法的,而 C++ 11标准为了更好地实现完美转发,特意为其指定了新的类型匹配规则,又称为引用折叠规则(假设用 A 表示实际传递参数的类型):

  • 当实参为左值或者左值引用(A&)时,函数模板中 T&& 将转变为 A&(A& && = A&);
  • 当实参为右值或者右值引用(A&&)时,函数模板中 T&& 将转变为 A&&(A&& && = A&&)。

我们只需要知道,在实现完美转发时,只要函数模板的参数类型为 T&&,则 C++ 可以自行准确地判定出实际传入的实参是左值还是右值。

通过将函数模板的形参类型设置为 T&&,我们可以很好地解决接收左、右值的问题。但除此之外,还需要解决一个问题,即无论传入的形参是左值还是右值,对于函数模板内部来说,形参既有名称又能寻址,因此它都是左值。那么如何才能将函数模板接收到的形参连同其左、右值属性,一起传递给被调用的函数呢?

C++11 标准的开发者已经帮我们想好的解决方案,该新标准还引入了一个模板函数 forword(),我们只需要调用该函数,就可以很方便地解决此问题。仍以 function 模板函数为例,如下演示了该函数模板的用法:

#include <iostream>
using namespace std;
//重载被调用函数,查看完美转发的效果
void otherdef(int & t) {
    cout << "lvalue\n";
}
void otherdef(const int & t) {
    cout << "rvalue\n";
}
//实现完美转发的函数模板
template <typename T>
void function(T&& t) {
    otherdef(forward<T>(t));
}
int main()
{
    function(5);
    int  x = 1;
    function(x);
    return 0;
}

程序执行结果为:

rvalue
lvalue

注意程序中第 12~16 行,此 function() 模板函数才是实现完美转发的最终版本。可以看到,forword() 函数模板用于修饰被调用函数中需要维持参数左、右值属性的参数。

总的来说,在定义模板函数时,我们采用右值引用的语法格式定义参数类型,由此该函数既可以接收外界传入的左值,也可以接收右值;其次,还需要使用 C++11 标准库提供的 forword() 模板函数修饰被调用函数中需要维持左、右值属性的参数。由此即可轻松实现函数模板中参数的完美转发。

22.array容器

同 5 .

23.hashtable 无序容器

C++11 引入了两组无序容器:

std::unordered_set/std::unordered_multiset

std::unordered_map/std::unordered_multimap

无序容器中的元素是不进行排序的,内部通过 Hash 表实现,插入和搜索元素的平均复杂度为 O(constant)。

背后底层是哈希表

24.tuple

C++11 标准新引入了一种类模板,命名为 tuple(中文可直译为元组)。tuple 最大的特点是:实例化的对象可以存储任意数量、任意类型的数据。

tuple 的应用场景很广泛,例如当需要存储多个不同类型的元素时,可以使用 tuple;当函数需要返回多个数据时,可以将这些数据存储在 tuple 中,函数只需返回一个 tuple 对象即可。

tuple对象的创建

tuple 本质是一个以可变模板参数定义的类模板,它定义在 头文件并位于 std 命名空间中。因此要想使用 tuple 类模板,程序中需要首先引入以下代码:

#include <tuple>
using std::tuple;

实例化 tuple 模板类对象常用的方法有两种,一种是借助该类的构造函数,另一种是借助 make_tuple() 函数。

1) 类的构造函数

tuple 模板类提供有很多构造函数,包括:

1) 默认构造函数
constexpr tuple();
2) 拷贝构造函数
tuple (const tuple& tpl);
3) 移动构造函数
tuple (tuple&& tpl);
4) 隐式类型转换构造函数
template <class... UTypes>
    tuple (const tuple<UTypes...>& tpl); //左值方式
template <class... UTypes>
    tuple (tuple<UTypes...>&& tpl);      //右值方式
5) 支持初始化列表的构造函数
explicit tuple (const Types&... elems);  //左值方式
template <class... UTypes>
    explicit tuple (UTypes&&... elems);  //右值方式
6) 将pair对象转换为tuple对象
template <class U1, class U2>
    tuple (const pair<U1,U2>& pr);       //左值方式
template <class U1, class U2>
    tuple (pair<U1,U2>&& pr);            //右值方式

举个例子:

#include <iostream>     // std::cout
#include <tuple>        // std::tuple
using std::tuple;
int main()
{
    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}
    return 0;
}
2) make_tuple()函数

上面程序中,我们已经用到了 make_tuple() 函数,它以模板的形式定义在 头文件中,功能是创建一个 tuple 右值对象(或者临时对象)。

对于 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* >

程序中分别创建了 first 和 second 两个 tuple 对象,它们的类型可以直接用 auto 表示。

tuple常用函数

tupe 模板类提供了一个功能实用的成员函数, 头文件中也提供了一些和操作 tuple 对象相关的函数模板和类模板,如表 1 所示。

函数或类模板描 述
tup1.swap(tup2) swap(tup1, tup2)tup1 和 tup2 表示类型相同的两个 tuple 对象,tuple 模板类中定义有一个 swap() 成员函数, 头文件还提供了一个同名的 swap() 全局函数。 swap() 函数的功能是交换两个 tuple 对象存储的内容。
get(tup)tup 表示某个 tuple 对象,num 是一个整数,get() 是 头文件提供的全局函数,功能是返回 tup 对象中第 num+1 个元素。
tuple_size::valuetuple_size 是定义在 头文件的类模板,它只有一个成员变量 value,功能是获取某个 tuple 对象中元素的个数,type 为该tuple 对象的类型。
tuple_element<I, type>::typetuple_element 是定义在 头文件的类模板,它只有一个成员变量 type,功能是获取某个 tuple 对象第 I+1 个元素的类型。
forward_as_tuple<args…>args… 表示 tuple 对象存储的多个元素,该函数的功能是创建一个 tuple 对象,内部存储的 args… 元素都是右值引用形式的。
tie(args…) = tuptup 表示某个 tuple 对象,tie() 是 头文件提供的,功能是将 tup 内存储的元素逐一赋值给 args… 指定的左值变量。
tuple_cat(args…)args… 表示多个 tuple 对象,该函数是 头文件提供的,功能是创建一个 tuple 对象,此对象包含 args… 指定的所有 tuple 对象内的元素。

tuple 模板类对赋值运算符 = 进行了重载,使得同类型的 tuple 对象可以直接赋值。此外,tuple 模板类还重载了 ==、!=、<、>、>=、<= 这几个比较运算符,同类型的 tuple 对象可以相互比较(逐个比较各个元素)。

下面的程序给您演示了表 1 中一部分函数模板和类模板的功能:

#include <iostream>
#include <tuple>
int main()
{
    int size;
    //创建一个 tuple 对象存储 10 和 'x'
    std::tuple<int, char> mytuple(10, 'x');
    //计算 mytuple 存储元素的个数
    size = std::tuple_size<decltype(mytuple)>::value;
    //输出 mytuple 中存储的元素
    std::cout << std::get<0>(mytuple) << " " << std::get<1>(mytuple) << std::endl;
    //修改指定的元素
    std::get<0>(mytuple) = 100;
    std::cout << std::get<0>(mytuple) << std::endl;
    //使用 makde_tuple() 创建一个 tuple 对象
    auto bar = std::make_tuple("test", 3.1, 14);
    //拆解 bar 对象,分别赋值给 mystr、mydou、myint
    const char* mystr = nullptr;
    double mydou;
    int myint;
    //使用 tie() 时,如果不想接受某个元素的值,实参可以用 std::ignore 代替
    std::tie(mystr, mydou, myint) = bar;
    //std::tie(std::ignore, std::ignore, myint) = bar;  //只接收第 3 个整形值
    //将 mytuple 和 bar 中的元素整合到 1 个 tuple 对象中
    auto mycat = std::tuple_cat(mytuple, bar);
    size = std::tuple_size<decltype(mycat)>::value;
    std::cout << size << std::endl;
    return 0;
}

程序执行结果为:

10 x
100
5

25.union 非受限联合体

在 C/C++ 中,联合体(Union)是一种构造数据类型。在一个联合体内,我们可以定义多个不同类型的成员,这些成员将会共享同一块内存空间。老版本的 C++ 为了和C语言保持兼容,对联合体的数据成员的类型进行了很大程度的限制,这些限制在今天看来并没有必要,因此 C++11 取消了这些限制。

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];
};
int main(){
    return 0;
}

上面的代码中,因为 Student 类带有自定义的构造函数,所以是一个非 POD 类型的,这导致编译器报错。这种规定只是 C++ 为了兼容C语言而制定,然而在长期的编程实践中发现,这种规定是没有必要的。

接下来,我们具体看一下 C++11 对 C++98 的改进。

1. C++11 允许非 POD 类型

C++98 不允许联合体的成员是非 POD 类型,但是 C++1 1 取消了这种限制。

POD 是 C++ 中一个比较重要的概念,在这里我们做一个简单介绍。POD 是英文 Plain Old Data 的缩写,用来描述一个类型的属性。

POD 类型一般具有以下几种特征(包括 class、union 和 struct等):

  1. 没有用户自定义的构造函数、析构函数、拷贝构造函数和移动构造函数。
  2. 不能包含虚函数和虚基类。
  3. 非静态成员必须声明为 public。
  4. 类中的第一个非静态成员的类型与其基类不同,例如:
class B1{};
class B2 : B1 { B1 b; };

class B2 的第一个非静态成员 b 是基类类型,所以它不是 POD 类型。

  1. 在类或者结构体继承时,满足以下两种情况之一:
  • 派生类中有非静态成员,且只有一个仅包含静态成员的基类;
  • 基类有非静态成员,而派生类没有非静态成员。

我们来看具体的例子:

class B1 { static int n; };
class B2 : B1 { int n1; };
class B3 : B2 { static int n2; };

对于 B2,派生类 B2 中有非静态成员,且只有一个仅包含静态成员的基类 B1,所以它是 POD 类型。对于 B3,基类 B2 有非静态成员,而派生类 B3 没有非静态成员,所以它也是 POD 类型。

  1. 所有非静态数据成员均和其基类也符合上述规则(递归定义),也就是说 POD 类型不能包含非 POD 类型的数据。
  2. 此外,所有兼容C语言的数据类型都是 POD 类型(struct、union 等不能违背上述规则)。
2. C++11 允许联合体有静态成员

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(稍后会讲解这个概念),代码如下:

#include <string>
using namespace std;
union U {
    string s;
    int n;
public:
    U() { new(&s) string; }
    ~U() { s.~string(); }
};
int main() {
    U u;
    return 0;
}

构造时,采用 placement new 将 s 构造在其地址 &s 上,这里 placement new 的唯一作用只是调用了一下 string 类的构造函数。注意,在析构时还需要调用 string 类的析构函数。

placement new 是什么?

placement new 是 new 关键字的一种进阶用法,既可以在栈(stack)上生成对象,也可以在堆(heap)上生成对象。相对应地,我们把常见的 new 的用法称为 operator new,它只能在 heap 上生成对象。

placement new 的语法格式如下:

new(address) ClassConstruct(…)

address 表示已有内存的地址,该内存可以在栈上,也可以在堆上;ClassConstruct(…) 表示调用类的构造函数,如果构造函数没有参数,也可以省略括号。

placement new 利用已经申请好的内存来生成对象,它不再为对象分配新的内存,而是将对象数据放在 address 指定的内存中。在本例中,placement new 使用的是 s 的内存空间。

非受限联合体的匿名声明和“枚举式类”

匿名联合体是指不具名的联合体(也即没有名字的联合体),一般定义如下:

union U{
union { int x; }; //此联合体为匿名联合体
};

可以看到,联合体 U 内定义了一个不具名的联合体,该联合体包含一个 int 类型的成员变量,我们称这个联合体为匿名联合体。

同样的,非受限联合体也可以匿名,而当非受限的匿名联合体运用于类的声明时,这样的类被称为“枚举式类”。示例如下:

#include <cstring>
using namespace std;
class Student{
public:
    Student(bool g, int a): gender(g), age(a){}
    bool gender;
    int age;
};
class Singer {
public:
    enum Type { STUDENT, NATIVE, FOREIGENR };
    Singer(bool g, int a) : s(g, a) { t = STUDENT; }
    Singer(int i) : id(i) { t = NATIVE; }
    Singer(const char* n, int s) {
        int size = (s > 9) ? 9 : s;
        memcpy(name , n, size);
        name[s] = '\0';
        t = FOREIGENR;
    }
    ~Singer(){}
private:
    Type t;
    union {
        Student s;
        int id;
        char name[10];
    };
};
int main() {
    Singer(true, 13);
    Singer(310217);
    Singer("J Michael", 9);
    return 0;
}

上面的代码中使用了一个匿名非受限联合体,它作为类 Singer 的“变长成员”来使用,这样的变长成员给类的编写带来了更大的灵活性,这是 C++98 标准中无法达到的(编译器会报member 'Student Singer::<anonymous union>::s' with constructor not allowed in union错误)。

26.constexpr:验证是否为常量表达式

constexpr 是 C++ 11 标准新引入的关键字,不过在讲解其具体用法和功能之前,读者需要先搞清楚 C++ 常量表达式的含义。

所谓常量表达式,指的就是由多个(≥1)常量组成的表达式。换句话说,如果表达式中的成员都是常量,那么该表达式就是一个常量表达式。这也意味着,常量表达式一旦确定,其值将无法修改。

实际开发中,我们经常会用到常量表达式。以定义数组为例,数组的长度就必须是一个常量表达式:

// 1)
int url[10];//正确
// 2)
int url[6 + 4];//正确
// 3)
int length = 6;
int url[length];//错误,length是变量

上述代码演示了 3 种定义 url 数组的方式,其中第 1、2 种定义 url 数组时,长度分别为 10 和 6+4,显然它们都是常量表达式,可以用于表示数组的长度;第 3 种 url 数组的长度为 length,它是变量而非常量,因此不是一个常量表达式,无法用于表示数组的长度。

常量表达式的应用场景还有很多,比如匿名枚举、switch-case 结构中的 case 表达式等,感兴趣的读者可自行编码测试,这里不再过多举例。

我们知道,C++ 程序的执行过程大致要经历编译、链接、运行这 3 个阶段。值得一提的是,常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算出结果;而常量表达式的计算往往发生在程序的编译阶段,这可以极大提高程序的执行效率,因为表达式只需要在编译阶段计算一次,节省了每次程序运行时都需要计算一次的时间。

对于用 C++ 编写的程序,性能往往是永恒的追求。那么在实际开发中,如何才能判定一个表达式是否为常量表达式,进而获得在编译阶段即可执行的“特权”呢?除了人为判定外,C++11 标准还提供有 constexpr 关键字。

constexpr 关键字的功能是使指定的常量表达式获得在程序编译阶段计算出结果的能力,而不必等到程序运行阶段。C++ 11 标准中,constexpr 可用于修饰普通变量、函数(包括模板函数)以及类的构造函数。

注意,获得在编译阶段计算出结果的能力,并不代表 constexpr 修饰的表达式一定会在程序编译阶段被执行,具体的计算时机还是编译器说了算。

constexpr修饰普通变量

C++11 标准中,定义变量时可以用 constexpr 修饰,从而使该变量获得在编译阶段即可计算出结果的能力。

值得一提的是,使用 constexpr 修改普通变量时,变量必须经过初始化且初始值必须是一个常量表达式。举个例子:

#include <iostream>
using namespace std;
int main()
{
    constexpr int num = 1 + 2 + 3;
    int url[num] = {1,2,3,4,5,6};
    couts<< url[1] << endl;
    return 0;
}

程序执行结果为:

2

读者可尝试将 constexpr 删除,此时编译器会提示“url[num] 定义中 num 不可用作常量”。

可以看到,程序第 6 行使用 constexpr 修饰 num 变量,同时将 “1+2+3” 这个常量表达式赋值给 num。由此,编译器就可以在编译时期对 num 这个表达式进行计算,因为 num 可以作为定义数组时的长度。

有读者可能发现,将此示例程序中的 constexpr 用 const 关键字替换也可以正常执行,这是因为 num 的定义同时满足“num 是 const 常量且使用常量表达式为其初始化”这 2 个条件,由此编译器会认定 num 是一个常量表达式。

注意,const 和 constexpr 并不相同,关于它们的区别,我们会在下一节做详细讲解。

另外需要重点提出的是,当常量表达式中包含浮点数时,考虑到程序编译和运行所在的系统环境可能不同,常量表达式在编译阶段和运行阶段计算出的结果精度很可能会受到影响,因此 C++11 标准规定,浮点常量表达式在编译阶段计算的精度要至少等于(或者高于)运行阶段计算出的精度。

constexpr修饰函数

constexpr 还可以用于修饰函数的返回值,这样的函数又称为“常量表达式函数”。

注意,constexpr 并非可以修改任意函数的返回值。换句话说,一个函数要想成为常量表达式函数,必须满足如下 4 个条件。

  1. 整个函数的函数体中,除了可以包含 using 指令、typedef 语句以及 static_assert 断言外,只能包含一条 return 返回语句。

举个例子:

constexpr int display(int x) {
    int ret = 1 + 2 + x;
    return ret;
}

注意,这个函数是无法通过编译的,因为该函数的返回值用 constexpr 修饰,但函数内部包含多条语句。

如下是正确的定义 display() 常量表达式函数的写法:

constexpr int display(int x) {
    //可以添加 using 执行、typedef 语句以及 static_assert 断言
    return 1 + 2 + x;
}

可以看到,display() 函数的返回值是用 constexpr 修饰的 int 类型值,且该函数的函数体中只包含一个 return 语句。

  1. 该函数必须有返回值,即函数的返回值类型不能是 void。

举个例子:

constexpr void display() {
    //函数体
}

像上面这样定义的返回值类型为 void 的函数,不属于常量表达式函数。原因很简单,因为通过类似的函数根本无法获得一个常量。

  1. 函数在使用之前,必须有对应的定义语句。我们知道,函数的使用分为“声明”和“定义”两部分,普通的函数调用只需要提前写好该函数的声明部分即可(函数的定义部分可以放在调用位置之后甚至其它文件中),但常量表达式函数在使用前,必须要有该函数的定义。

举个例子:

#include <iostream>
using namespace std;
//普通函数的声明
int noconst_dis(int x);
//常量表达式函数的声明
constexpr int display(int x);
//常量表达式函数的定义
constexpr int display(int x){
    return 1 + 2 + x;
}
int main()
{
    //调用常量表达式函数
    int a[display(3)] = { 1,2,3,4 };
    cout << a[2] << endl;
    //调用普通函数
    cout << noconst_dis(3) << endl;
    return 0;
}
//普通函数的定义
int noconst_dis(int x) {
    return 1 + 2 + x;
}

程序执行结果为:

3
6

读者可自行将 display() 常量表达式函数的定义调整到 main() 函数之后,查看编译器的报错信息。

可以看到,普通函数在调用时,只需要保证调用位置之前有相应的声明即可;而常量表达式函数则不同,调用位置之前必须要有该函数的定义,否则会导致程序编译失败。

  1. return 返回的表达式必须是常量表达式,举个例子:
#include <iostream>
using namespace std;
int num = 3;
constexpr int display(int x){
    return num + x;
}
int main()
{
    //调用常量表达式函数
    int a[display(3)] = { 1,2,3,4 };
    return 0;
}

该程序无法通过编译,编译器报“display(3) 的结果不是常量”的异常。

常量表达式函数的返回值必须是常量表达式的原因很简单,如果想在程序编译阶段获得某个函数返回的常量,则该函数的 return 语句中就不能包含程序运行阶段才能确定值的变量。

注意,在常量表达式函数的 return 语句中,不能包含赋值的操作(例如 return x=1 在常量表达式函数中不允许的)。另外,用 constexpr 修改函数时,函数本身也是支持递归的,感兴趣的读者可自行尝试编码测试。

constexpr修饰类的构造函数

对于 C++ 内置类型的数据,可以直接用 constexpr 修饰,但如果是自定义的数据类型(用 struct 或者 class 实现),直接用 constexpr 修饰是不行的。

举个例子:

#include <iostream>
using namespace std;
//自定义类型的定义
constexpr struct myType {
    const char* name;
    int age;
    //其它结构体成员
};
int main()
{
    constexpr struct myType mt { "zhangsan", 10 };
    cout << mt.name << " " << mt.age << endl;
    return 0;
}

此程序是无法通过编译的,编译器会抛出“constexpr不能修饰自定义类型”的异常。

当我们想自定义一个可产生常量的类型时,正确的做法是在该类型的内部添加一个常量构造函数。例如,修改上面的错误示例如下:

#include <iostream>
using namespace std;
//自定义类型的定义
struct myType {
    constexpr myType(char *name,int age):name(name),age(age){};
    const char* name;
    int age;
    //其它结构体成员
};
int main()
{
    constexpr struct myType mt { "zhangsan", 10 };
    cout << mt.name << " " << mt.age << endl;
    return 0;
}

程序执行结果为:

zhangsan 10

可以看到,在 myType 结构体中自定义有一个构造函数,借助此函数,用 constexpr 修饰的 myType 类型的 my 常量即可通过编译。

注意,constexpr 修饰类的构造函数时,要求该构造函数的函数体必须为空,且采用初始化列表的方式为各个成员赋值时,必须使用常量表达式。

前面提到,constexpr 可用于修饰函数,而类中的成员方法完全可以看做是“位于类这个命名空间中的函数”,所以 constexpr 也可以修饰类中的成员函数,只不过此函数必须满足前面提到的 4 个条件。

举个例子:

#include <iostream>
using namespace std;
//自定义类型的定义
class myType {
public:
    constexpr myType(const char *name,int age):name(name),age(age){};
    constexpr const char * getname(){
        return name;
    }
    constexpr int getage(){
        return age;
    }
private:
    const char* name;
    int age;
    //其它结构体成员
};
int main()
{
    constexpr struct myType mt { "zhangsan", 10 };
    constexpr const char * name = mt.getname();
    constexpr int age = mt.getage();
    cout << name << " " << age << endl;
    return 0;
}

程序执行结果为:

zhangsan 10

注意,C++11 标准中,不支持用 constexpr 修饰带有 virtual 的成员方法。

constexpr修饰模板函数

C++11 语法中,constexpr 可以修饰模板函数,但由于模板中类型的不确定性,因此模板函数实例化后的函数是否符合常量表达式函数的要求也是不确定的。

针对这种情况下,C++11 标准规定,如果 constexpr 修饰的模板函数实例化结果不满足常量表达式函数的要求,则 constexpr 会被自动忽略,即该函数就等同于一个普通函数。

举个例子:

#include <iostream>
using namespace std;
//自定义类型的定义
struct myType {
    const char* name;
    int age;
    //其它结构体成员
};
//模板函数
template<typename T>
constexpr T dispaly(T t){
    return t;
}
int main()
{
    struct myType stu{"zhangsan",10};
    //普通函数
    struct myType ret = dispaly(stu);
    cout << ret.name << " " << ret.age << endl;
    //常量表达式函数
    constexpr int ret1 = dispaly(10);
    cout << ret1 << endl;
    return 0;
}

程序执行结果为:

zhangsan 10
10

可以看到,示例程序中定义了一个模板函数 display(),但由于其返回值类型未定,因此在实例化之前无法判断其是否符合常量表达式函数的要求:

  • 第 20 行代码处,当模板函数中以自定义结构体 myType 类型进行实例化时,由于该结构体中没有定义常量表达式构造函数,所以实例化后的函数不是常量表达式函数,此时 constexpr 是无效的;
  • 第 23 行代码处,模板函数的类型 T 为 int 类型,实例化后的函数符合常量表达式函数的要求,所以该函数的返回值就是一个常量表达式。

constexpr和const的区别

我们知道,constexpr 是 C++ 11 标准新添加的关键字,在此之前(C++ 98/03标准)只有 const 关键字,其在实际使用中经常会表现出两种不同的语义。举个例子:

#include <iostream>
#include <array>
using namespace std;
void dis_1(const int x){
    //错误,x是只读的变量
    array <int,x> myarr{1,2,3,4,5};
    cout << myarr[1] << endl;
}
void dis_2(){
    const int x = 5;
    array <int,x> myarr{1,2,3,4,5};
    cout << myarr[1] << endl;
}
int main()
{
   dis_1(5);
   dis_2();
}

可以看到,dis_1() 和 dis_2() 函数中都包含一个 const int x,但 dis_1() 函数中的 x 无法完成初始化 array 容器的任务,而 dis_2() 函数中的 x 却可以。

这是因为,dis_1() 函数中的“const int x”只是想强调 x 是一个只读的变量,其本质仍为变量,无法用来初始化 array 容器;而 dis_2() 函数中的“const int x”,表明 x 是一个只读变量的同时,x 还是一个值为 5 的常量,所以可以用来初始化 array 容器。

C++ 11标准中,为了解决 const 关键字的双重语义问题,保留了 const 表示“只读”的语义,而将“常量”的语义划分给了新添加的 constexpr 关键字。因此 C++11 标准中,建议将 const 和 constexpr 的功能区分开,即凡是表达“只读”语义的场景都使用 const,表达“常量”语义的场景都使用 constexpr。

在上面的实例程序中,dis_2() 函数中使用 const int x 是不规范的,应使用 constexpr 关键字。

有读者可能会问,“只读”不就意味着其不能被修改吗?答案是否定的,“只读”和“不允许被修改”之间并没有必然的联系,举个例子:

#include <iostream>
using namespace std;
int main()
{
    int a = 10;
    const int & con_b = a;
    cout << con_b << endl;
    a = 20;
    cout << con_b << endl;
}

程序执行结果为:

10
20

可以看到,程序中用 const 修饰了 con_b 变量,表示该变量“只读”,即无法通过变量自身去修改自己的值。但这并不意味着 con_b 的值不能借助其它变量间接改变,通过改变 a 的值就可以使 con_b 的值发生变化。

在大部分实际场景中,const 和 constexpr 是可以混用的,例如:

const int a = 5 + 4;
constexpr int a = 5 + 4;
12

它们是完全等价的,都可以在程序的编译阶段计算出结果。但在某些场景中,必须明确使用 constexpr,例如:

#include <iostream>
#include <array>
using namespace std;
constexpr int sqr1(int arg){
    return arg*arg;
}
const int sqr2(int arg){
    return arg*arg;
}
int main()
{
    array<int,sqr1(10)> mylist1;//可以,因为sqr1时constexpr函数
    array<int,sqr2(10)> mylist1;//不可以,因为sqr2不是constexpr函数
    return 0;
}

其中,因为 sqr2() 函数的返回值仅有 const 修饰,而没有用更明确的 constexpr 修饰,导致其无法用于初始化 array 容器(只有常量才能初始化 array 容器)。

总的来说在 C++ 11 标准中,const 用于为修饰的变量添加“只读”属性;而 constexpr 关键字则用于指明其后是一个常量(或者常量表达式),编译器在编译程序时可以顺带将其结果计算出来,而无需等到程序运行阶段,这样的优化极大地提高了程序的执行效率。

27.long long超长整形详解

C++ 11 标准中,基于整数大小的考虑,共提供了如表 1 所示的这些数据类型。与此同时,标准中还明确限定了各个数据类型最少占用的位数。

整数类型等价类型C++11标准规定占用最少位数
shortshort int(有符号短整型)至少 16 位(2 个字节)
signed short
signed short int
unsigned shortunsigned short int(无符号短整型)
unsigned short int
intint(有符号整形)至少 16 位(2 个字节)
signed
signed int
unsignedunsigned int(无符号整形)
unsigned int
longlong int(有符号长整形)至少 32 位(4 个字节)
long int
signed long
signed long int
unsigned longunsigned long int(无符号长整形)
unsigned long int
long long(C++11)long long int(有符号超长整形)至少 64 位(8 个字节)
long long int(C++11)
signed long long(C++11)
signed long long int(C++11)
unsigned long long(C++11)unsigned long long int(无符号超长整型)
unsigned long long int(C++11)

C++11 标准规定,每种整数类型必须同时具备有符号(signed)和无符号(unsigned)两种类型,且每种具体的有符号整形和无符号整形所占用的存储空间(也就是位数)必须相同。注意,C++11 标准中只限定了每种类型最少占用多少存储空间,不同的平台可以占用不同的存储空间。

在表 1 罗列的这些数据类型中,long long 超长整型是 C++ 11 标准新添加的,接下来就对该整数类型做具体的介绍。

说道 C++ 标准委员会将 long long 整形写入 C++ 11 标准中,其实早在 1995 年,就有人提议将 long long 整形写入 C++ 98 标准,但被委员会拒绝了。而后 long long 整形被 C99 标准(C语言标准之一)采纳,并逐渐被很多编译器支持,于是 C++ 标准委员会重新决定将 long long 整形写入 C++ 11 标准中。

如同 long 类型整数需明确标注 “L” 或者 “l” 后缀一样,要使用 long long 类型的整数,也必须标注对应的后缀:

  • 对于有符号 long long 整形,后缀用 “LL” 或者 “ll” 标识。例如,“10LL” 就表示有符号超长整数 10;
  • 对于无符号 long long 整形,后缀用 “ULL”、“ull”、“Ull” 或者 “uLL” 标识。例如,“10ULL” 就表示无符号超长整数 10;

如果不添加任何标识,则所有的整数都会默认为 int 类型。

对于任意一种数据类型,读者可能更关心的是此类型的取值范围。对于 long long 类型来说,如果想了解当前平台上 long long 整形的取值范围,可以使用<climits>头文件中与 long long 整形相关的 3 个宏,分别为 LLONG_MIN、LLONG_MAX 和 ULLONG_MIN:

  1. LLONG_MIN:代表当前平台上最小的 long long 类型整数;
  2. LLONG_MAX:代表当前平台上最大的 long long 类型整数;
  3. ULLONG_MIN:代表当前平台上最大的 unsigned long long 类型整数(无符号超长整型的最小值为 0);

举个例子:

#include <iostream>
#include <iomanip>
#include <climits>
using namespace std;
int main()
{
    cout <<"long long最大值:" << LLONG_MIN <<" "<< hex << LLONG_MIN <<"\n";
    cout << dec <<"long long最小值:" << LLONG_MAX << " " << hex << LLONG_MAX << "\n";
    cout << dec << "unsigned long long最大值:" << ULLONG_MAX << " " << hex << ULLONG_MAX;
    return 0;
}

程序执行结果为(不唯一):

long long最大值:-9223372036854775808 8000000000000000
long long最小值:9223372036854775807 7fffffffffffffff
unsigned long long最大值:18446744073709551615 ffffffffffffffff

此程序中,输出了各最大值和最小值对应的十六进制,显然在当前平台(Windows10 64位操作系统)上,long long 超长整型占用 64 位(也就是 16 个字节)的存储空间。读者可自行在自己的机器上运行此段代码,即可轻松得知 long long 类型在自己机器上所占用的字节数。

28.智能指针

1.shared_ptr智能指针(超级详细)

在实际的 C++ 开发中,我们经常会遇到诸如程序运行中突然崩溃、程序运行所用内存越来越多最终不得不重启等问题,这些问题往往都是内存资源管理不当造成的。比如:

  • 有些内存资源已经被释放,但指向它的指针并没有改变指向(成为了野指针),并且后续还在使用;
  • 有些内存资源已经被释放,后期又试图再释放一次(重复释放同一块内存会导致程序运行崩溃);
  • 没有及时释放不再使用的内存资源,造成内存泄漏,程序占用的内存资源越来越多。

针对以上这些情况,很多程序员认为 C++ 语言应该提供更友好的内存管理机制,这样就可以将精力集中于开发项目的各个功能上。

事实上,显示内存管理的替代方案很早就有了,早在 1959 年前后,就有人提出了“垃圾自动回收”机制。所谓垃圾,指的是那些不再使用或者没有任何指针指向的内存空间,而“回收”则指的是将这些“垃圾”收集起来以便再次利用。

如今,垃圾回收机制已经大行其道,得到了诸多编程语言的支持,例如 Java、Python、C#、PHP 等。而 C++ 虽然从来没有公开得支持过垃圾回收机制,但 C++98/03 标准中,支持使用 auto_ptr 智能指针来实现堆内存的自动回收;C++11 新标准在废弃 auto_ptr 的同时,增添了 unique_ptr、shared_ptr 以及 weak_ptr 这 3 个智能指针来实现堆内存的自动回收。

所谓智能指针,可以从字面上理解为“智能”的指针。具体来讲,智能指针和普通指针的用法是相似的,不同之处在于,智能指针可以在适当时机自动释放分配的内存。也就是说,使用智能指针可以很好地避免“忘记释放内存而导致内存泄漏”问题出现。由此可见,C++ 也逐渐开始支持垃圾回收机制了,尽管目前支持程度还有限。

C++ 智能指针底层是采用引用计数的方式实现的。简单的理解,智能指针在申请堆内存空间的同时,会为其配备一个整形值(初始值为 1),每当有新对象使用此堆内存时,该整形值 +1;反之,每当使用此堆内存的对象被释放时,该整形值减 1。当堆空间对应的整形值为 0 时,即表明不再有对象使用它,该堆空间就会被释放掉。

接下来,我们将分别对 shared_ptr、unique_ptr 以及 weak_ptr 这 3 个智能指针的特性和用法做详细的讲解,本节先介绍 shared_ptr 智能指针。

C++11 shared_ptr智能指针

实际上,每种智能指针都是以类模板的方式实现的,shared_ptr 也不例外。shared_ptr(其中 T 表示指针指向的具体数据类型)的定义位于<memory>头文件,并位于 std 命名空间中,因此在使用该类型指针时,程序中应包含如下 2 行代码:

#include <memory>
using namespace std;

注意,第 2 行代码并不是必须的,也可以不添加,则后续在使用 shared_ptr 智能指针时,就需要明确指明std::

值得一提的是,和 unique_ptr、weak_ptr 不同之处在于,多个 shared_ptr 智能指针可以共同使用同一块堆内存。并且,由于该类型智能指针在实现上采用的是引用计数机制,即便有一个 shared_ptr 指针放弃了堆内存的“使用权”(引用计数减 1),也不会影响其他指向同一堆内存的 shared_ptr 指针(只有引用计数为 0 时,堆内存才会被自动释放)。

1、shared_ptr智能指针的创建

shared_ptr 类模板中,提供了多种实用的构造函数,这里给读者列举了几个常用的构造函数(以构建指向 int 类型数据的智能指针为例)。

  1. 通过如下 2 种方式,可以构造出 shared_ptr 类型的空智能指针:
std::shared_ptr<int> p1;             //不传入任何实参std::shared_ptr<int> p2(nullptr);    //传入空指针 nullptr

注意,空的 shared_ptr 指针,其初始引用计数为 0,而不是 1。

  1. 在构建 shared_ptr 智能指针,也可以明确其指向。例如:
std::shared_ptr<int> p3(new int(10));

由此,我们就成功构建了一个 shared_ptr 智能指针,其指向一块存有 10 这个 int 类型数据的堆内存空间。

同时,C++11 标准中还提供了 std::make_shared 模板函数,其可以用于初始化 shared_ptr 智能指针,例如:

std::shared_ptr<int> p3 = std::make_shared<int>(10);

以上 2 种方式创建的 p3 是完全相同。

  1. 除此之外,shared_ptr 模板还提供有相应的拷贝构造函数和移动构造函数,例如:
//调用拷贝构造函数
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);//错误
  1. 在初始化 shared_ptr 智能指针时,还可以自定义所指堆内存的释放规则,这样当堆内存的引用计数为 0 时,会优先调用我们自定义的释放规则。

在某些场景中,自定义释放规则是很有必要的。比如,对于申请的动态数组来说,shared_ptr 指针默认的释放规则是不支持释放数组的,只能自定义对应的释放规则,才能正确地释放申请的堆内存。

对于申请的动态数组,释放规则可以使用 C++11 标准中提供的 default_delete 模板类,我们也可以自定义释放规则:

//指定 default_delete 作为释放规则
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 表达式,我们还可以像如下这样初始化 p7,它们是完全相同的:

std::shared_ptr<int> p7(new int[10], [](int* p) {delete[]p; });
2、shared_ptr模板类提供的成员方法

为了方便用户使用 shared_ptr 智能指针,shared_ptr 模板类还提供有一些实用的成员方法,它们各自的功能如表 1 所示。

成员方法名功 能
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。

除此之外,C++11 标准还支持同一类型的 shared_ptr 对象,或者 shared_ptr 和 nullptr 之间,进行 ==,!=,<,<=,>,>= 运算。

下面程序给大家演示了 shared_ptr 智能指针的基本用法,以及该模板类提供了一些成员方法的用法:

#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;
    p1.reset();//引用计数减 1,p1为空指针
    if (p1) {
        cout << "p1 不为空" << endl;
    }
    else {
        cout << "p1 为空" << endl;
    }
    //以上操作,并不会影响 p2
    cout << *p2 << endl;
    //判断当前和 p2 同指向的智能指针有多少个
    cout << p2.use_count() << endl;
    return 0;
}

程序执行结果为:

10
p1 为空
10
1

2.unique_ptr智能指针

作为智能指针的一种,unique_ptr 指针自然也具备“在适当时机自动释放堆内存空间”的能力。和 shared_ptr 指针最大的不同之处在于,unique_ptr 指针指向的堆内存无法同其它 unique_ptr 共享,也就是说,每个 unique_ptr 指针都独自拥有对其所指堆内存空间的所有权。

这也就意味着,每个 unique_ptr 指针指向的堆内存空间的引用计数,都只能为 1,一旦该 unique_ptr 指针放弃对所指堆内存空间的所有权,则该空间会被立即释放回收。

unique_ptr 智能指针是以模板类的形式提供的,unique_ptr(T 为指针所指数据的类型)定义在<memory>头文件,并位于 std 命名空间中。因此,要想使用 unique_ptr 类型指针,程序中应首先包含如下 2 条语句:

#include <memory>
using namespace std;

第 2 句并不是必须的,可以不添加,则后续在使用 unique_ptr 指针时,必须标注std::

unique_ptr智能指针的创建

考虑到不同实际场景的需要,unique_ptr 模板类提供了多个实用的构造函数,这里给读者列举了几种常用的构造 unique_ptr 智能指针的方式。

  1. 通过以下 2 种方式,可以创建出空的 unique_ptr 指针:
std::unique_ptr<int> p1();
std::unique_ptr<int> p2(nullptr);
  1. 创建 unique_ptr 指针的同时,也可以明确其指向。例如:
std::unique_ptr<int> p3(new int);

由此就创建出了一个 p3 智能指针,其指向的是可容纳 1 个整数的堆存储空间。

和可以用 make_shared() 模板函数初始化 shared_ptr 指针不同,C++11 标准中并没有为 unique_ptr 类型指针添加类似的模板函数。

  1. 基于 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)。

  1. 默认情况下,unique_ptr 指针采用 std::default_delete 方法释放堆内存。当然,我们也可以自定义符合实际场景的释放规则。值得一提的是,和 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模板类提供的成员方法

为了方便用户使用 unique_ptr 智能指针,unique_ptr 模板类还提供有一些实用的成员方法,它们各自的功能如表 1 所示。

成员函数名功 能
operator*()获取当前 unique_ptr 指针指向的数据。
operator->()重载 -> 号,当智能指针指向的数据类型为自定义的结构体时,通过 -> 运算符可以获取其内部的指定成员。
operator =()重载了 = 赋值号,从而可以将 nullptr 或者一个右值 unique_ptr 指针直接赋值给当前同类型的 unique_ptr 指针。
operator重载了 [] 运算符,当 unique_ptr 指针指向一个数组时,可以直接通过 [] 获取指定下标位置处的数据。
get()获取当前 unique_ptr 指针内部包含的普通指针。
get_deleter()获取当前 unique_ptr 指针释放堆内存空间所用的规则。
operator bool()unique_ptr 指针可直接作为 if 语句的判断条件,以判断该指针是否为空,如果为空,则为 false;反之为 true。
release()释放当前 unique_ptr 指针对所指堆内存的所有权,但该存储空间并不会被销毁。
reset§其中 p 表示一个普通指针,如果 p 为 nullptr,则当前 unique_ptr 也变成空指针;反之,则该函数会释放当前 unique_ptr 指针指向的堆内存(如果有),然后获取 p 所指堆内存的所有权(p 为 nullptr)。
swap(x)交换当前 unique_ptr 指针和同类型的 x 指针。

除此之外,C++11标准还支持同类型的 unique_ptr 指针之间,以及 unique_ptr 和 nullptr 之间,做 ==,!=,<,<=,>,>= 运算。

下面程序给大家演示了 unique_ptr 智能指针的基本用法,以及该模板类提供了一些成员方法的用法:

#include <iostream>
#include <memory>
using namespace std;
int main()
{
    std::unique_ptr<int> p5(new int);
    *p5 = 10;
    // p 接收 p5 释放的堆内存
    int * p = p5.release();
    cout << *p << endl;
    //判断 p5 是否为空指针
    if (p5) {
        cout << "p5 is not nullptr" << endl;
    }
    else {
        cout << "p5 is nullptr" << endl;
    }
    std::unique_ptr<int> p6;
    //p6 获取 p 的所有权
    p6.reset(p);
    cout << *p6 << endl;;
    return 0;
}

程序执行结果为:

10
p5 is nullptr
10

3.weak_ptr智能指针

在 C++98/03 的基础上,C++11 标准新引入了 shared_ptr、unique_ptr 以及 weak_ptr 这 3 个智能指针。其中,shared_ptr 和 unique_ptr 已经在前面章节做了详细地介绍,本节重点讲解 weak_ptr 智能指针的特性和用法。

weak_ptr智能指针

和 shared_ptr、unique_ptr 类型指针一样,weak_ptr 智能指针也是以模板类的方式实现的。weak_ptr( T 为指针所指数据的类型)定义在<memory>头文件,并位于 std 命名空间中。因此,要想使用 weak_ptr 类型指针,程序中应首先包含如下 2 条语句:

#include <memory>
using namespace std;

第 2 句并不是必须的,可以不添加,则后续在使用 unique_ptr 指针时,必须标注std::

需要注意的是,C++11标准虽然将 weak_ptr 定位为智能指针的一种,但该类型指针通常不单独使用(没有实际用处),只能和 shared_ptr 类型指针搭配使用。甚至于,我们可以将 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 模板类中没有重载 * 和 -> 运算符,这也就意味着,weak_ptr 类型指针只能访问所指的堆内存,而无法修改它。

1、weak_ptr指针的创建

创建一个 weak_ptr 指针,有以下 3 种方式:

  1. 可以创建一个空 weak_ptr 指针,例如:
std::weak_ptr<int> wp1;
  1. 凭借已有的 weak_ptr 指针,可以创建一个新的 weak_ptr 指针,例如:
std::weak_ptr<int> wp2 (wp1);

若 wp1 为空指针,则 wp2 也为空指针;反之,如果 wp1 指向某一 shared_ptr 指针拥有的堆内存,则 wp2 也指向该块存储空间(可以访问,但无所有权)。

  1. weak_ptr 指针更常用于指向某一 shared_ptr 指针拥有的堆内存,因为在构建 weak_ptr 指针对象时,可以利用已有的 shared_ptr 指针为其初始化。例如:
std::shared_ptr<int> sp (new int);
std::weak_ptr<int> wp3 (sp);

由此,wp3 指针和 sp 指针有相同的指针。再次强调,weak_ptr 类型指针不会导致堆内存空间的引用计数增加或减少。

2) weak_ptr模板类提供的成员方法

和 shared_ptr、unique_ptr 相比,weak_ptr 模板类提供的成员方法不多,表 1 罗列了常用的成员方法及各自的功能。

成员方法功 能
operator=()重载 = 赋值运算符,是的 weak_ptr 指针可以直接被 weak_ptr 或者 shared_ptr 类型指针赋值。
swap(x)其中 x 表示一个同类型的 weak_ptr 类型指针,该函数可以互换 2 个同类型 weak_ptr 指针的内容。
reset()将当前 weak_ptr 指针置为空指针。
use_count()查看指向和当前 weak_ptr 指针相同的 shared_ptr 指针的数量。
expired()判断当前 weak_ptr 指针为否过期(指针为空,或者指向的堆内存已经被释放)。
lock()如果当前 weak_ptr 已经过期,则该函数会返回一个空的 shared_ptr 指针;反之,该函数返回一个和当前 weak_ptr 指向相同的 shared_ptr 指针。

再次强调,weak_ptr 模板类没有重载 * 和 -> 运算符,因此 weak_ptr 类型指针只能访问某一 shared_ptr 指针指向的堆内存空间,无法对其进行修改。

下面的样例演示了 weak_ptr 指针以及表 1 中部分成员方法的基本用法:

#include <iostream>
#include <memory>
using namespace std;
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;
    //释放 sp2
    sp2.reset();
    cout << wp.use_count() << endl;
    //借助 lock() 函数,返回一个和 wp 同指向的 shared_ptr 类型指针,获取其存储的数据
    cout << *(wp.lock()) << endl;
    return 0;
}

程序执行结果为:

2
1
10

有关表 1 中其它成员函数的用法,感兴趣的读者可直接查看 weak_ptr 官网。

注:其他博客地址

C++11、C++14、C++17、C++20新特性总结(5万字详解)_c++11和c++17的区别-CSDN博客

C++11 新特性总结 - fengMisaka - 博客园 (cnblogs.com)

C++11新特性(全详解) - 知乎 (zhihu.com)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2208784.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Spring Boot 项目中 Redis 与数据库性能对比实战:从缓存配置到时间分析,详解最佳实践

一、前言&#xff1a; 在现代应用中&#xff0c;随着数据量的增大和访问频率的提高&#xff0c;如何提高数据存取的性能变得尤为重要。缓存技术作为一种常见的优化手段&#xff0c;被广泛应用于减少数据库访问压力、提升系统响应速度。Redis 作为一种高效的内存缓存数据库&…

DBeaver连接mysql 9报错:Public Key Retrieval is not allowed

DBeaver连接mysql 9报错&#xff1a;Public Key Retrieval is not allowed 如图&#xff1a; 解决方案 编辑连接属性&#xff1a; 修改 allowPublicKeyRetrieval 的值为 true DBeaver连接mysql数据库执行.sql脚本&#xff0c;Windows_dbeaver执行sql脚本.sql文件-CSDN博客文章…

【Java】 —— 数据结构与集合源码:Vector、LinkedList在JDK8中的源码剖析

目录 7.2.4 Vector部分源码分析 7.3 链表LinkedList 7.3.1 链表与动态数组的区别 7.3.2 LinkedList源码分析 启示与开发建议 7.2.4 Vector部分源码分析 jdk1.8.0_271中&#xff1a; //属性 protected Object[] elementData; protected int elementCount;//构造器 public …

2024ccna考试时间?新手小白看这些就够了

2024年想要考取ccna证书的新手小白们&#xff0c;是不是正在为考试时间而烦恼呀&#xff0c;其实ccna的考试时间其实非常灵活&#xff0c;并不需要像其他考试那样死记硬背固定的日期。那么小编马上就给大家说说2024ccna考试时间&#xff0c;并且附带一些考试内容&#xff0c;让…

LeetCode[简单] 70. 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢&#xff1f; 思路 利用滚动数组 public class Solution {public int ClimbStairs(int n) { //滚动数组int f0 0, f1 0, f2 1;for(int i 1; i < n; i){…

拱坝与重力坝:结构特性与应用差异的深度解析

在水利工程领域&#xff0c;坝体结构的选择对于工程的稳定性、安全性以及经济效益具有至关重要的影响。拱坝与重力坝作为两种主要的坝型&#xff0c;各自具有独特的结构特性和应用场景。本文旨在深入探讨拱坝与重力坝的区别&#xff0c;从工程特点、坝体结构型式、材料选用、水…

国内如何下载谷歌浏览器(chrome浏览器)历史版本和chromedriver驱动,长期更新,建议收藏

众所周知&#xff0c;google是一直被国内屏蔽的&#xff0c;有时候想要下载个chrome浏览器都要去外网&#xff0c;或者到处去搜索才能下载到。因为下载chrome浏览器的这个网址&#xff1a;google.com/chrome/ 在国内是一直被屏蔽掉的。 今天主要讲解的是国内ChromeDriver 的下…

ET实现游戏中的红点提示系统(服务端)

目录 ☝&#x1f913;前言 ☝&#x1f913;一、实现思路 ☝&#x1f913;二、实现 &#x1f920;2.1 定义红点组件 &#x1f920;2.2 定义Proto消息体 &#x1f920;2.3 RedPointComponentSystem ☝&#x1f913;难点 ☝&#x1f913;前言 当我们闲来无事时打开农药想消…

数据结构-5.3.二叉树的定义和基本术语

一.二叉树的基本概念&#xff1a; 树是一种递归定义的数据结构&#xff0c;因此二叉树是递归定义的数据结构。 二.二叉树的五种状态&#xff1a; 三.几个特殊的二叉树&#xff1a; 1.满二叉树&#xff1a;结点总数就是通过等比数列公式求出来的&#xff0c;首项为1即根节点&a…

【网络协议】TCP协议常用机制——延迟应答、捎带应答、面向字节流、异常处理,保姆级详解,建议收藏

&#x1f490;个人主页&#xff1a;初晴~ &#x1f4da;相关专栏&#xff1a;计算机网络那些事 前几篇文章&#xff0c;博主带大家梳理了一下TCP协议的几个核心机制&#xff0c;比如保证可靠性的 确认应答、超时重传 机制&#xff0c;和提高传输效率的 滑动窗口及其相关优化机…

C++ Builder XE12关于KonopkaControls与TMS VCL UI Pack组件的安装

1、先打开open project&#xff0c;选中安装的组件工程&#xff0c;并打开。 2、在option中设置 3、点击编译并进行安装install

洞察AI趋势:智享AI直播,打造专属你的数字化直播AIGC系统!

洞察AI趋势&#xff1a;智享AI直播&#xff0c;打造专属你的数字化直播AIGC系统&#xff01; 在当今这个日新月异的数字时代&#xff0c;人工智能&#xff08;AI&#xff09;已不再是遥不可及的未来科技&#xff0c;而是正深刻改变着我们生活、工作的每一个角落。其中&#xf…

幽默视频下载网站推荐

在快节奏的生活中&#xff0c;搞笑视频无疑是缓解压力的良药&#xff0c;不论是自制搞笑视频还是寻找素材来增添创作的趣味性&#xff0c;找到合适的视频素材至关重要。幸运的是&#xff0c;网络上有许多优秀的网站能够满足这一需求。以下是8个适合下载幽默搞笑视频素材的网站&…

华为 静态路由和bfd 侦测的实验

实验要求 sw1 上业务地址192.168.1.1/24 SW3 业务地址192.168.2.1/24 正常情况下走主链路&#xff0c;不正常的情况下走备份链路 2 配置 这是基本地址配置 开启了bfd 本端地址为 10.1.1.1 对端地址是10.1.1.2 关键是discrimination 分辨参数 …

静态路由和nqa 联动实验

nqa 配置 1 test 断端口 很明显是切换到备机上了

Word中如何删除表格下一页的空白页

Reference&#xff1a; [1] Word空白页怎么都删除不掉&#xff1f;用这6个方法随便删&#xff01; - 知乎 (zhihu.com)

前端埋点学习

前端埋点 前端数据埋点是在前端页面中通过代码的方式手机用户行为数据和页面性能的过程&#xff0c;通过在页面中插入指定的代码&#xff0c;实现实时监控用户在页面上的操作行为。 通常包括一下事件 定义事件: 定义需要手机的数据事件&#xff0c;如点击&#xff0c;浏览等添…

基于Docker的FRP内网穿透部署

服务器搭建&#xff08;FRPS&#xff09; 创建配置文件 # 创建存放目录 sudo mkdir /etc/frp # 创建frps.ini文件 nano /etc/frp/frps.ini frps.ini内容如下&#xff1a; [common] # 监听端口 bind_port 7000 # 面板端口 dashboard_port 7500 # 登录面板账号设置 dashboa…

【进阶OpenCV】 (9)--摄像头操作--->答题卡识别改分项目

文章目录 项目&#xff1a;答题卡识别改分1. 图片预处理2. 描绘轮廓3. 轮廓近似4. 透视变换5. 阈值处理6. 找每一个圆圈轮廓7. 将每一个圆圈轮廓排序8. 找寻所填答案&#xff0c;比对正确答案8.1 思路8.2 图解8.3 代码体现 9. 计算正确率 总结 项目&#xff1a;答题卡识别改分 …

数论与同余 - 离散数学系列(七)

目录 1. 整数的性质 整除与因数 最大公约数与最小公倍数 2. 欧几里得算法 算法步骤 3. 模运算与同余 模运算 同余关系 同余的性质 4. 数论在密码学中的应用 RSA 加密算法 5. 实际应用场景 1. 数字签名 2. 哈希函数与数据完整性 3. 密钥交换 6. 例题与练习 例题…