1.c++原始字面量
1.1 什么是字面量
在编程语言中,“字面量”(Literal)是指在源代码中直接表示某一固定值的符号。字面量是编程语言中最基本的数据表示方式之一,它们是编译时已知的常量值,不需要通过变量或其他表达式计算得到。以下是一些常见的字面量类型:
数值字面量:直接表示数字的字面量,如整数、浮点数等。
- 整数:
123
,0xFF
(十六进制),0b1010
(二进制)等。 - 浮点数:
3.14
,2.5e-3
(科学记数法)等。
- 整数:
字符字面量:用单引号括起来的单个字符,如
'A'
、'5'
、'\n'
(换行符)等。字符串字面量:用双引号括起来的字符序列,如
"Hello, World!"
。布尔字面量:表示逻辑值的字面量,如
true
和false
。空字面量:表示空值的字面量,如 C++11 中的
nullptr
。复合字面量:某些语言支持复合字面量,如数组或结构体的初始化。
- 数组:
int arr[] = {1, 2, 3};
- 结构体:
Point p = {10, 20};
- 数组:
字面量在编程中非常重要,因为它们提供了一种简洁的方式来表示和使用常量值。在编译过程中,字面量会被替换为它们对应的值,这有助于提高代码的可读性和效率。
1.2 原始字面量的必要性
字符串"h\t"
会产生歧义,因为\
在c++中是转义字符,\t
可以表示制表符,所以输出可能不是你想的预期结果
- 反斜杠可以将原始字符转成有特殊含义的字符,
\r
,\n
,\t
分别表示回车,换行以及制表符 - 反斜杠同样可以将有特殊含义的字符转成原始字符,例如
\\t
在输出的时候将会被看作一个普通的t来输出
所以,使用原始字面量就很有必要了,在c++11及以后的版本中,可以使用R"xxx(你想输出的原始字符串的内容)xxx"
来输出不会被转义的字符串常量,其中xxx是对原始字面量的描述,在编译时会被省略.
#include <iostream>
using namespace std;
int main()
{
string s1 = "hello \
world"; // 使用连接符连接多个字符串
string s2 = R"(hello
world)"; // 使用原始字符串字面量的时候可以不用加连接符,语法不会错
std::cout << R"(h\ello world)" << std::endl; // 原始字面量
std::cout << R"hello(h\ello world)hello" << std::endl; // 描述内容必须一样,并且不能为中文
cout << "h\t" << endl; // 不使用原始字面量
cout << "h\\t" << endl; // 使用两个反斜杠转义
return 0;
}
2.指针空值类型nullptr
NULL
是一个在 C 和 C++ 编程语言中广泛使用的宏定义,它表示一个空指针常量,即一个不指向任何对象或函数的指针。NULL
的值通常是 0
(在大多数平台上),但这个定义允许编译器和运行时系统识别它是一个空指针,而不是一个普通的整数。
在 C 语言中,NULL
被定义在 <stddef.h>
或 <stdlib.h>
头文件中,而在 C++ 中,它被定义在 <cstddef>
或 <cstdlib>
头文件中。NULL
的定义如下:
#define NULL ((void *)0)
或者在 C++ 中:
/* A null pointer constant. */
#if defined (_STDDEF_H) || defined (__need_NULL)
#undef NULL /* in case <stdio.h> has defined it. */
#if defined(__GNUG__) && __GNUG__ >= 3
#define NULL __null
#else /* G++ */
#ifndef __cplusplus
#define NULL ((void *)0)
#else /* C++ */
#ifndef _WIN64
#define NULL 0
#else
#define NULL 0LL
#endif /* W64 */
#endif /* C++ */
#endif /* G++ */
#endif /* NULL not defined and <stddef.h> or need NULL. */
#undef __need_NULL
在 C++11 及以后的版本中,NULL
被 nullptr
取代,因为 nullptr
是一个类型安全的关键字,它只能被用来表示空指针。nullptr
的类型是 std::nullptr_t
,它是一个特殊的指针类型,可以被隐式转换为任何指针类型或引用类型,但不能转换为其他类型。
在给int*
变量初始化的时候会被自动转成int*
类型
#include <iostream>
using namespace std;
void func(int p)
{
cout << "func(int p) called" << endl;
}
void func(char *p)
{
cout << "func(char* p) called" << endl;
}
int main(int argc, char const *argv[])
{
int *ptr1 = NULL; // 空指针,会被隐式转换
int *ptr1 = (int *)((void *)0);
cout << ptr1 << endl; // 输出 0
double *ptr2 = NULL;
char *ptr3 = NULL;
void *ptr4 = NULL;
// void * ptr4 = (void *)0;
int *ptr = nullptr; // 自动隐式转换成 "int *" 类型
// int *ptr5 = ptr4; //"void *" 类型的值不能用于初始化 "int *" 类型的实体C/C++,这在C++11中是不允许的
int *ptr5 = (int *)ptr4; // 显式类型转换
func(12); // 调用func(int p)函数
// func(NULL); // 还是使用 func(int p)函数,因为NULL是其实是一个(void *)0
// 所以想调用func(char* p)函数,需要使用nullptr关键字
func(nullptr); // 调用func(char* p)函数
return 0;
}
使用 NULL
或 nullptr
而不是直接使用 0
作为空指针的理由是:
- 类型安全:
NULL
和nullptr
提供了类型安全的空指针表示,它们明确地告诉编译器这是一个指针值,而不是一个整数。 - 可移植性:不同的系统和编译器可能有不同的空指针表示,使用
NULL
或nullptr
可以确保代码的可移植性。 - 清晰性:在代码中使用
NULL
或nullptr
可以提高代码的可读性,让其他开发者更容易理解代码的意图。 - 避免歧义:在某些上下文中,
0
可能被解释为整数,而NULL
或nullptr
明确表示这是一个空指针。
2.1 隐式转换和显式转换的必要
#include <iostream>
#include <stdio.h>
#include <stddef.h>
int main()
{
#ifdef NULL
std::cout << "NULL is defined as: " << NULL << std::endl; // NULL is defined as: 0
#endif
// void *ptr = NULL;
void *ptr = (void *)0; // ptr就是把0转化为指向void类型的指针,类似于内存0???
int *ptr2 = (int *)ptr; // ptr2就是指向0的int指针
// int *ptr5 = ptr; // 不合法, 因为ptr5的类型是int*,而ptr的类型是void*,需要进行类型转换
std::cout << "ptr: " << ptr << std::endl;
std::cout << "ptr2: " << ptr2 << std::endl;
// std::cout << "ptr5: " << ptr5 << std::endl;
int *ptr_i = NULL; // NULL相当于0
int *ptr_i_ = (int *)(void *)0;
#ifdef NULL
std::cout << "NULL is defined as: " << NULL << std::endl;
#endif
return 0;
}
3.constexpr新关键字
3.1 const关键字
在C++11之前只有const关键字
,从功能上来说这个关键字有双重语义:变量只读
,修饰常量
,举一个简单的例子:
void func(const int num)
{
const int count = 24;
int array[num]; // 报错,num是一个只读变量,不是常量 因为const修饰参数
int array1[count]; // ok,count是一个常量
int a1 = 520;
int a2 = 250;
int array2[a1]; // 报错, 定义数组不能使用变量
const int& b = a1;
b = a2; // 报错
a1 = 1314;
cout << "b: " << b << endl; // 输出结果为1314
}
- 函数
void func(const int num)
的参数num
表示这个变量是只读的,但不是常量,因此使用int array[num];
这种方式定义一个数组,编译器是会报错的,提示num不可用作为常量来使用。
const int count = 24;
中的count
却是一个常量,因此可以使用这个常量来定义一个静态数组。
另外,变量只读并不等价于常量
,二者是两个概念不能混为一谈,分析一下这句测试代码const int& b = a1;
- b是一个常量的引用,所以
b
引用的变量是不能被修改的,也就是说b = a2;
这句代码语法是错误的。 - 语句的
const
对于变量a1
是没有任何约束的,a1 的值变了 b 的值也就变了 - 引用b是
只读的
,但是并不能保证它的值是不可改变的,也就是说它不是常量。
3.2 constexpr常量表达式
在C++11中添加了一个新的关键字 constexpr
,这个关键字是用来修饰 常量表达式
的。所谓常量表达式,指的就是由多个(≥1)常量(值不会改变)组成并且在编译过程中就得到计算结果的表达式
。
在介绍gcc/g++工作流程
的时候说过,C++ 程序从编写完毕到执行分为四个阶段:预处理
、 编译
、汇编
和链接
4个阶段,得到可执行程序之后就可以运行了。需要额外强调的是,常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算出结果,但是常量表达式的计算往往发生在程序的编译阶段,这可以极大提高程序的执行效率,因为表达式只需要在编译阶段计算一次,节省了每次程序运行时都需要计算一次的时间。
那么问题来了,编译器如何识别
表达式是不是常量表达式呢?在C++11中添加了 constexpr
关键字之后就可以在程序中使用它来修饰常量表达式,用来提高程序的执行效率。在使用中建议将 const
和 constexpr
的功能区分开,即凡是表达“只读”语义
的场景都使用 const
,表达“常量”语义
的场景都使用 constexpr
。
在定义常量的时候,使用const
和constexpr
都可以:
const int num = 10; // 常量表达式
const int doubled = num * 2; // 常量表达式
const int result = func();
constexpr int a = 10; // 常量表达式
constexpr int b = a * 2; // 常量表达式
constexpr int c = func(); // 报错 => 不能作为常量表达式,因为func()在运行时才会返回值,所以不能在编译时确定值
对于C++内置类型的数据,可以直接使用constexpr
修饰,但是对于自定义数据类型
,例如 struct
和 class
定义的结构体和类,直接使用 constexpr
是不行的
/* 这样会报错: constexpr无效 */
constexpr struct Person
{
int age;
const char *name;
};
如果要定义一个结构体/类常量对象
,可以这样写:
struct Test
{
int id;
int num;
};
int main()
{
constexpr Test t{ 1, 2 };
constexpr int id = t.id;
constexpr int num = t.num;
// 报错,不能修改常量
t.num += 100;
cout << "id: " << id << ", num: " << num << endl;
return 0;
}
在第13行的代码中t.num += 100;
的操作是错误的,对象t是一个常量,因此它的成员也是常量,常量是不能被修改的
。
3.3 常量表达式函数
为了提高C++程序的执行效率,我们可以将程序中值不需要发生变化的变量定义为常量,也可以使用constexpr
修饰某些特定函数的返回值,这种函数被称作常量表达式函数
,这些函数主要包括以下几种:普通函数/类成员函数
、类的构造函数
、模板函数
。
a. 修饰普通函数
constexpr
并不能任意函数的返回值,使这些函数成为常量表达式函数
,必须满足以下条件:
函数必须要有返回值
,并且return返回的表达式必须是常量表达式
// error,不是常量表达式函数
constexpr void func1()
{
int a = 100;
cout << "a: " << a << endl;
}
// error,不是常量表达式函数
constexpr int func1()
{
int a = 100;
return a;
}
- 函数func1()没有返回值,不满足常量表达式函数要求
- 函数func2()返回值不是常量表达式,不满足常量表达式函数要求
由此可见在更新的C++标准
里边放宽了对 constexpr
的语法限制。
函数在使用之前,必须有与之相对应的定义
#include <iostream>
using namespace std;
constexpr int func1();
int main()
{
constexpr int num = func1(); // error
return 0;
}
constexpr int func1()
{
constexpr int a = 100;
return a;
}
在测试程序constexpr int num = func1();
中,还没有定义func1()
就直接调用了,应该将func1()
函数的定义放到main()
函数的上边。
- 整个函数的函数体中,
不
能出现非常量表达式
之外的语句(using 指令、typedef 语句以及 static_assert 断言、return语句除外
)。
// error
constexpr int func1()
{
constexpr int a = 100;
constexpr int b = 10;
for (int i = 0; i < b; ++i)
{
cout << "i: " << i << endl;
}
return a + b;
}
// ok
constexpr int func2()
{
using mytype = int;
constexpr mytype a = 100;
constexpr mytype b = 10;
constexpr mytype c = a * b;
return c - (a + b);
}
在C++中,constexpr
函数要求函数体中所有的操作都必须是常量表达式。这意味着函数体中不能包含任何运行时的操作,比如I/O操作(如cout
),以及任何依赖于非常量值的操作。
func1()
函数中包含了一个for
循环,并且在循环体内使用了cout
进行输出。cout
是一个运行时的I/O操作,不是常量表达式,因此会导致constexpr
函数的定义失败。即使for
循环本身可能是基于常量表达式的,但由于包含了非constexpr
的操作(cout
),整个函数都不能被标记为constexpr
。func2()
函数中没有包含任何运行时的操作,所有的计算都是在编译时完成的,因此它可以被标记为constexpr
。
以上三条规则不仅对于普通的函数生效,并且对于类的成员函数同样生效
b. 修饰成员函数
class Test
{
public:
constexpr int func()
{
constexpr int var = 100;
return 5 * var;
}
};
int main()
{
Test t;
constexpr int num = t.func();
cout << "num: " << num << endl;
return 0;
}
c. 修饰模板函数
在C++中,”模板函数”指的是一种允许函数在编译时根据给定的参数类型生成特定实现的机制。这种机制使得同一个函数可以用于不同的数据类型,而不需要为每种类型编写不同的函数代码。
函数模板的基本语法如下:
template <typename T>
void myFunction(T param) {
// 函数体,使用T作为参数类型
}
这里的template <typename T>
声明了一个模板,其中T
是一个类型参数,可以在调用函数时指定。typename
关键字用于指定T
是一个类型。你也可以使用class
关键字代替typename
,但在现代C++中,typename
是首选。
函数模板的使用示例:
template <typename T>
T max(T a, T b) {
return (a > b) ? a : b;
}
// 使用int类型调用
int maxInt = max(3, 5);
// 使用double类型调用
double maxDouble = max(3.14, 2.71);
在这个例子中,max
函数模板可以根据传入的参数类型自动实例化出int
和double
版本的max
函数。
函数模板也可以接受多个类型参数:
template <typename T1, typename T2>
void printPair(const T1& a, const T2& b) {
std::cout << a << " and " << b << std::endl;
}
// 使用
printPair(10, 3.14);
这里,printPair
函数模板接受两个参数,这两个参数可以是不同的类型。
函数模板也可以使用非类型模板参数,这些参数在编译时必须是常量:
template <int N>
void printArray(const int (&arr)[N]) {
for (int i = 0; i < N; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
}
// 使用
int myArray[] = {1, 2, 3};
printArray(myArray);
在这个例子中,printArray
函数模板接受一个数组,并使用非类型模板参数N
来指定数组的大小。
C++11 语法中,
constexpr
可以修饰函数模板,但由于模板中类型的不确定性,因此函数模板实例化后的模板函数是否符合常量表达式函数的要求也是不确定的。如果 constexpr 修饰的模板函数实例化结果不满足常量表达式函数的要求,则 constexpr 会被自动忽略,即该函数就等同于一个普通函数。
#include <iostream>
using namespace std;
struct Person {
const char* name;
int age;
};
// 定义函数模板
template<typename T>
constexpr T dispaly(T t) {
return t;
}
int main()
{
struct Person p { "luffy", 19 };
//普通函数
struct Person ret = dispaly(p);
cout << "luffy's name: " << ret.name << ", age: " << ret.age << endl;
//常量表达式函数
constexpr int ret1 = dispaly(250);
cout << ret1 << endl;
constexpr struct Person p1 { "luffy", 19 }; // 使用constexpr修饰, p1是一个常量表达式
constexpr struct Person p2 = dispaly(p1);
cout << "luffy's name: " << p2.name << ", age: " << p2.age << endl;
return 0;
}
在上面示例程序中定义了一个函数模板 display()
,但由于其返回值类型未定,因此在实例化之前无法判断其是否符合常量表达式函数的要求:
struct Person ret = dispaly(p);
由于参数p
是变量,所以实例化后的函数不是常量表达式函数,此时constexpr
是无效的constexpr int ret1 = dispaly(250);
参数是常量,符合常量表达式函数的要求,此时constexpr
是有效的constexpr struct Person p2 = dispaly(p1);
参数是常量,符合常量表达式函数的要求,此时constexpr
是有效的
d. 修饰构造函数
在C++中,constexpr
构造函数允许在编译时初始化对象,这意味着构造函数创建的对象可以被用作常量表达式的一部分。这对于创建在编译时就需要确定值的常量对象
非常有用。
- 构造函数的参数必须是常量表达式:
constexpr
构造函数的参数应当是可以在编译时求值的常量。例如,基本数据类型的常量、常量变量和字面量等。
- 函数体必须包含允许的操作:
constexpr
构造函数的函数体必须仅包含可以在编译时执行的表达式。这意味着不能使用诸如动态内存分配、输入输出等运行时操作。
- 以常量对象的形式使用:
- 构造函数的结果必须是用于创建一个常量对象,可以是
constexpr
变量或用constexpr
表达式初始化的对象。
- 构造函数的结果必须是用于创建一个常量对象,可以是
- 返回类型:
constexpr
构造函数的返回类型必须是该类类型,即生成的对象类型
#include <iostream>
class Point {
public:
// 常量表达式构造函数
constexpr Point(int x, int y) : x_(x), y_(y) {}
// 获取 x 和 y 的常量成员函数
constexpr int getX() const { return x_; }
constexpr int getY() const { return y_; }
private:
int x_;
int y_;
};
int main() {
// 在编译时创建常量对象
constexpr Point p(1, 2);
// 输出常量对象的值
std::cout << "Point: (" << p.getX() << ", " << p.getY() << ")" << std::endl;
return 0;
}
4.auto类型推导
在C++11中,可以使用auto
来自动推测变量的类型,还可以结合使用decltype
关键字来推测函数或者表达式的返回值。允许程序员编写更灵活和通用的代码,尤其是在模板编程中。通过decltype
,我们可以避免显式指定类型,从而减少代码的复杂性,并提高代码的可读性和可维护性。
4.1 auto的推导规则
在C++11之前
,auto
关键字用于声明自动存储期
的变量,但由于非static
的局部变量默认就是自动存储期的,所以这个关键字的使用变得不那么常见。
C++11引入了新的auto
关键字用法,它主要用于自动类型推导
,使得程序员可以不必显式声明变量的类型,而让编译器根据变量的初始值
自动推导出类型。
a. 自动存储期
在C++中,自动存储期(Automatic Storage Duration)
是指变量的生命周期,它描述了变量在程序执行过程中存在的时间。具有自动存储期的变量通常是在函数或代码块内部定义的局部变量,它们在定义它们的代码块被执行时创建,在代码块执行结束时销毁。
以下是自动存储期的一些特点:
局部作用域:
- 自动存储期的变量具有局部作用域,它们只能在定义它们的函数或代码块内部被访问。
栈内存分配:
- 这些变量通常在栈(stack)上分配内存。当函数被调用时,局部变量的内存被分配;当函数返回时,这些内存被释放。
生命周期:
- 自动存储期的变量的生命周期仅限于它们被定义的代码块。一旦代码块执行结束,这些变量就会被销毁,它们的内存空间会被回收。
初始化:
- 在C++中,局部变量(具有自动存储期的变量)不会自动初始化。如果它们没有被显式初始化,它们的值是未定义的。
与静态存储期的区别:
- 与自动存储期相对的是静态存储期(Static Storage Duration),具有静态存储期的变量在程序的整个运行期间都存在,它们通常在全局区域或在函数外部定义。
例子:
void function() { int localVariable; // 自动存储期的局部变量,未初始化,其值是未定义的 localVariable = 10; // 初始化后,可以安全使用 }
在这个例子中,
localVariable
是一个局部变量,它具有自动存储期。它只在function
函数内部有效,并且在函数结束时被销毁。
b. auto的推导规则
C++11 中 auto 并不代表一种实际的数据类型,只是一个类型声明的 “占位符”,auto并不是万能的在任意场景下都能够推导出变量的实际类型,使用auto声明的变量必须要进行初始化,以让编译器推导出它的实际类型,在编译时将auto占位符替换为真正的类型
。使用语法如下:
auto 变量名 = 变量值;
auto x = 10; // x会被推导为int类型
auto y = 3.14; // y会被推导为double类型
auto z = "hello"; // z会被推导为const char[]类型
不仅如此,auto还可以和指针、引用结合起来使用也可以带上const
、volatile限定符
,在不同的场景下有对应的推导规则,规则内容如下:
- 当变量不是指针或者引用类型时,推导的结果中不会保留
const
、volatile
关键字 - 当变量是指针或者引用类型时,推导的结果中会保留
const
、volatile
关键字 volatile
关键字用于告诉编译器该变量可能会被程序之外的因素(如硬件或其他线程)改变,因此编译器需要在每次访问该变量时从内存中重新读取其值,而不是使用寄存器中的值。
int temp = 150;
auto *a = &temp; // 变量a的数据类型为 int*,因此auto关键字被推导为 int类型
auto b = &temp; // 变量b的数据类型为 int*,因此auto关键字被推导为 int*类型
auto &c = temp; // 变量c的数据类型为 int&,因此auto关键字被推导为 int类型
auto d = temp; // 变量d的数据类型为 int,因此auto关键字被推导为 int类型
int tmp = 200;
const auto a1 = tmp;
auto a2 = a1;
const auto &a3 = tmp;
auto &a4 = a3;
- 变量a1的数据类型为
const int
,因此auto关键字被推导为int类型
- 变量a2的数据类型为
int
,但是a2没有声明为指针或引用
因此 const属性被去掉, auto被推导为int
- 变量a3的数据类型为
const int&
,a3被声明为引用
因此 const属性被保留,auto关键字被推导为int类型
- 变量a4的数据类型为
const int&
,a4被声明为引用
因此 const属性被保留,auto关键字被推导为const int类型
4.2 auto的限制
auto
的类型推导并不是万能的,对于在编译时无法完成类型推导的情况
,不能使用自动推导
auto
不能作为函数的参数使用,因为C++中,函数参数的类型必须在编译时已知。auto
是一个类型推导关键字,它依赖于初始化表达式来确定具体的类型,这意味着auto
需要在变量声明时立即跟随一个初始化表达式,以便编译器可以推导出类型。
因为只有在函数调用的时候才给函数参数传递实参,auto 要求必须要给修饰的变量赋值,因此二者矛盾
int func(auto a, auto b){ // 错误
return a + b;
}
auto
不能用于非静态成员
的初始化,这是因为非静态成员
是属于类的实例对象的,而对象需要在程序运行时才会被创建,所以不能使用自动类型推导
class Test{
auto age = 18; // 错误
static auto name = "Tom"; // 错误,类的静态非常量成员不允许在类内部直接初始化
static const auto sex = 0; // 正确,
}
- 不能使用
auto
关键字定义数组
int func()
{
int array[] = {1, 2, 3, 4, 5}; // 定义数组
auto t1 = array; // ok, t1被推导为 int* 类型
auto t2[] = array; // error, auto无法定义数组
auto t3[] = {1, 2, 3, 4, 5}; // error, auto无法定义数组
}
- 不能用于
模板参数的推导
,在C++中,auto
关键字用于自动类型推导,但它不能用于模板参数的推导。这是因为模板参数需要在编译时就确定,而auto
的类型推导依赖于初始化表达式的类型,这通常在模板实例化时已经太晚了。
template <typename T>
struct Test{}
int func()
{
Test<double> t;
Test<auto> t1 = t; // error, 无法推导出模板类型
return 0;
}
4.3 auto的应用
auto
关键字在 C++ 中主要用于自动类型推导,它可以在多种场景下简化代码和提高代码的可读性。以下是一些常见的应用场景:
遍历容器
当遍历标准库容器(如 std::vector
、std::list
、std::map
等)时,auto
可以用来简化迭代器的类型。
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto it = vec.begin(); it != vec.end(); ++it) {
std::cout << *it << " ";
}
// 输出:1 2 3 4 5
简化复杂类型
对于复杂的类型,如嵌套的模板或自定义类型的嵌套定义,auto
可以减少代码的复杂性。
std::map<std::string, std::list<int>> myMap;
for (auto& pair : myMap) {
std::cout << pair.first << ": ";
for (auto num : pair.second) {
std::cout << num << " ";
}
std::cout << std::endl;
}
函数返回值
当函数返回复杂类型时,auto
可以用来简化函数调用。
std::vector<int> getVector() {
return std::vector<int>{1, 2, 3};
}
auto vec = getVector(); // vec 的类型被推导为 std::vector<int>
Lambda 表达式
在 Lambda 表达式中,auto
可以用来推导捕获的变量类型。
int x = 10;
auto lambda = [x]() { return x; };
初始化列表
使用花括号初始化列表时,auto
可以用来推导数组或 std::vector
的类型。
auto arr = {1, 2, 3}; // arr 的类型被推导为 std::initializer_list<int>
auto vec = std::vector<int>{1, 2, 3}; // vec 的类型被推导为 std::vector<int>
智能指针
当使用智能指针如 std::unique_ptr
或 std::shared_ptr
时,auto
可以简化类型声明。
auto ptr = std::make_unique<int>(10); // ptr 的类型被推导为 std::unique_ptr<int>
泛型编程
在模板编程中,auto
可以用来推导模板实例化的具体类型。在使用模板的时候,很多情况下我们不知道变量应该定义为什么类型
#include <iostream>
#include <string>
using namespace std;
class T1
{
public:
static int get()
{
return 10;
}
};
class T2
{
public:
static string get()
{
return "hello, world";
}
};
template <class A>
void func(void)
{
auto val = A::get();
cout << "val: " << val << endl;
}
int main()
{
func<T1>();
func<T2>();
return 0;
}
错误处理
在处理异常和错误代码时,auto
可以用来简化错误类型的处理。
auto error = someFunctionThatReturnsErrorCode();
if (error != 0) {
std::cerr << "Error occurred: " << error << std::endl;
}
4.4 decltype关键字
decltype
的作用是在不需要或者不能进行表达式的计算
的时候,希望能够得到表达式的结果类型
,这个时候就可以运用到decltype关键字
了,语法如下:
decltype (表达式)
decltype
是declare type
声明类型的缩写,其推导是在编译期
完成的,它只用于表达式类型的推导,不会在内部进行计算表达式的值。
int main()
{
auto a = 10;
decltype(a) b = 20; // int
decltype(a) c = a + b; // int
decltype(b * 10.25 + 10) d = 100; // double
// 输出类型
std::cout << "Type of a: " << typeid(a).name() << std::endl;
std::cout << "Type of b: " << typeid(b).name() << std::endl;
std::cout << "Type of c: " << typeid(c).name() << std::endl;
std::cout << "Type of d: " << typeid(d).name() << std::endl;
return 0;
}
4.5 decltype的推导规则
- 表达式为
普通变量
或者普通表达式
或者类表达式(有类的限定的或者对象参与的)
,在这种情况下,使用decltype推导出的类型和表达式的类型是一致的。
#include <iostream>
#include <string>
using namespace std;
class Test
{
public:
string text;
static const int value = 110;
};
int main()
{
int x = 99;
const int &y = x;
decltype(x) a = x;
decltype(y) b = x;
decltype(Test::value) c = 0;
Test t;
decltype(t.text) d = "hello, world";
return 0;
}
- 变量
a
被推导为int
类型 - 变量
b
被推导为const int &
类型 - 变量
c
被推导为const int
类型 - 变量
d
被推导为string
类型
- 表达式是
函数调用
,使用 decltype 推导出的类型和函数返回值一致。(有一个特殊,纯右值)
class Test{...};
//函数声明
int func_int(); // 返回值为 int
int& func_int_r(); // 返回值为 int&
int&& func_int_rr(); // 返回值为 int&&
const int func_cint(); // 返回值为 const int
const int& func_cint_r(); // 返回值为 const int&
const int&& func_cint_rr(); // 返回值为 const int&&
const Test func_ctest(); // 返回值为 const Test
//decltype类型推导
int n = 100;
decltype(func_int()) a = 0;
decltype(func_int_r()) b = n;
decltype(func_int_rr()) c = 0;
decltype(func_cint()) d = 0;
decltype(func_cint_r()) e = n;
decltype(func_cint_rr()) f = 0;
decltype(func_ctest()) g = Test();
- 变量
a
被推导为int
类型 - 变量
b
被推导为int&
类型 - 变量
c
被推导为int&&
类型 - 变量
d
被推导为int
类型 - 变量
e
被推导为const int &
类型 - 变量
f
被推导为const int &&
类型 - 变量
g
被推导为const Test
类型
函数 func_cint()
返回的是一个纯右值
(在表达式执行结束后不再存在的数据,也就是临时性的数据,或者例如字面量),对于纯右值而言,只有类类型可以携带const
、volatile限定符
,除此之外需要忽略掉这两个限定符,因此推导出的变量 d
的类型为 int
而不是 const int
。
表达式是一个左值,或者被括号( )包围
,使用decltype
推导出的是表达式类型的引用(如果有const、volatile限定符
不能忽略)。
#include <iostream>
#include <vector>
using namespace std;
class Test
{
public:
int num;
};
int main() {
const Test obj;
//带有括号的表达式
// a : int
decltype(obj.num) a = 0;
// b : const int&
decltype((obj.num)) b = a;
//加法表达式
int n = 0, m = 0;
// c : int
decltype(n + m) c = 0;
// n+m最后保存在n中,n是可以取地址的左值,所以 d : int&
decltype(n = n + m) d = n;
return 0;
}
4.6 decltype在泛型编程的应用
关于decltype的应用多出现在泛型编程
中。比如我们编写一个类模板,在里边添加遍历容器的函数,操作如下:
#include <list>
using namespace std;
template <class T>
class Container
{
public:
void func(T& c)
{
for (m_it = c.begin(); m_it != c.end(); ++m_it)
{
cout << *m_it << " ";
}
cout << endl;
}
private:
??? m_it; // 这里不能确定迭代器类型
};
int main()
{
const list<int> lst;
Container<const list<int>> obj;
obj.func(lst);
return 0;
}
在程序的第17行出了问题,关于迭代器变量一共有两种类型:只读(T::const_iterator)
和读写(T::iterator)
,有了decltype
就可以完美的解决这个问题了,当 T 是一个 非 const 容器得到一个 T::iterator,当 T 是一个 const 容器时就会得到一个T::const_iterator
。
#include <list>
#include <iostream>
using namespace std;
template <class T>
class Container
{
public:
void func(T& c)
{
for (m_it = c.begin(); m_it != c.end(); ++m_it)
{
cout << *m_it << " ";
}
cout << endl;
}
private:
decltype(T().begin()) m_it; // 这里不能确定迭代器类型
};
int main()
{
const list<int> lst{ 1,2,3,4,5,6,7,8,9 };
Container<const list<int>> obj;
obj.func(lst);
return 0;
}
decltype(*T*().begin()) iter; // 推导出对应的容器的迭代器类型
4.7 返回类型的后置(声明返回类型?)
在泛型编程中,可能需要通过参数的运算
来得到返回值的类型
,比如下面这个场景:
#include <iostream>
using namespace std;
// R->返回值类型, T->参数1类型, U->参数2类型
template <typename R, typename T, typename U>
R add(T t, U u)
{
return t + u;
}
int main()
{
int x = 520;
double y = 13.14;
// auto z = add<decltype(x + y), int, double>(x, y);
auto z = add<decltype(x + y)>(x, y); // 简化之后的写法
cout << "z: " << z << endl;
return 0;
}
关于返回值,从上面的代码可以推断出和表达式 t+u
的结果类型是一样的,因此可以通过通过decltype
进行推导,关于模板函数的参数t
和u
可以通过实参自动推导出来,因此在程序中就也可以不写。虽然通过上述方式问题被解决了,但是解决方案有点过于理想化,因为对于调用者来说,是不知道函数内部执行了什么样的处理动作的。(如例子中的 t + u
,自然也不能通过decltype
来声明表达式类型)
因此如果要想解决这个问题就得直接在 add()
函数身上做文章,自然而然地想到在函数声明
的时候使用decltype来推导返回值类型
template <typename T, typename U>
decltype(t+u) add(T t, U u)
{
return t + u;
}
但这样显然是错误的,因为decltype中的 t 和 u 都是函数参数,直接这样写相当于变量还没有定义就直接用上了,这时候变量还不存在,有点心急了。
在C++11中增加了返回类型后置语法
,说明白一点就是将decltype和auto结合起来完成返回类型的推导
。其语法格式如下:
// 符号 -> 后边跟随的是函数返回值的类型
auto func(参数1, 参数2, ...) -> decltype(参数表达式)
通过对上述返回类型后置语法代码的分析,得到结论:auto 会追踪 decltype() 推导出的类型
,因此上边的add()函数
可以做如下的修改:
#include <iostream>
using namespace std;
template <typename T, typename U>
// 返回类型后置语法
auto add(T t, U u) -> decltype(t+u) // 声明函数时就设定返回的类型由t+u推导出来
{
return t + u;
}
int main()
{
int x = 520;
double y = 13.14;
// auto z = add<int, double>(x, y);
auto z = add(x, y); // 简化之后的写法
cout << "z: " << z << endl;
return 0;
}
为了进一步说明这个语法,我们再看一个例子:
#include <iostream>
using namespace std;
int& test(int &i)
{
return i;
}
double test(double &d)
{
d = d + 100;
return d;
}
template <typename T>
// 返回类型后置语法
auto myFunc(T& t) -> decltype(test(t))
{
return test(t);
}
int main()
{
int x = 520;
double y = 13.14;
// auto z = myFunc<int>(x);
auto z = myFunc(x); // 简化之后的写法
cout << "z: " << z << endl;
// auto z = myFunc<double>(y);
auto z1 = myFunc(y); // 简化之后的写法
cout << "z1: " << z1 << endl;
return 0;
}
在这个例子中,通过decltype结合返回值后置
语法很容易推导出来, test(t)
函数可能出现的返回值类型,并将其作用到了函数myFunc()
上。
// 输出结果
z: 520
z1: 113.14
类模板的实例化(生成对应类型的具体代码)和
decltype类型推导
都是在编译期完成的,所以decltype和模板能够一起使用+------------------+ | 调用 myFunc(x) | +------------------+ | +---------v---------+ | 模板实例化 myFunc<int> | +---------+---------+ | +---------v---------+ | decltype(test(t)) 推导为 int& | +---------+---------+ | +---------v---------+ | 调用 test(int&) 返回 x 的引用 | +------------------+ +------------------+ | 调用 myFunc(y) | +------------------+ | +---------v---------+ | 模板实例化 myFunc<double> | +---------+---------+ | +---------v---------+ | decltype(test(t)) 推导为 double | +---------+---------+ | +---------v---------+ | 调用 test(double&) 修改 y 并返回副本 | +------------------+
5.final和override
C++ 中增加了 final 关键字
来 限制某个类不能被继承
,或者某个虚函数不能被重写
,和 Java
的 final 关键字的功能是类似的。
如果使用 final 修饰函数,只能修饰虚函数
,并且 要把 final关键字放到类或者函数的后面。
5.1 final 修饰函数
如果使用final
修饰函数,只能修饰虚函数
,这样就能阻止子类重写父类的这个函数了:
class Base
{
public:
virtual void test()
{
cout << "Base class...";
}
};
class Child : public Base
{
public:
void test() final
{
cout << "Child class...";
}
};
class GrandChild : public Child
{
public:
// 语法错误, 不允许重写
void test()
{
cout << "GrandChild class...";
}
};
5.2 final 修饰类
使用final关键字
修饰过的类是不允许被继承
的,也就是说这个类不能有派生类。
#include <iostream>
using namespace std;
class Person
{
public:
virtual void run() = 0; // 定义纯虚函数
virtual void finalRun() final { cout << "Person::finalRun()" << endl; } // 定义final修饰的虚函数
};
class SportsMan final : public Person
{
public:
void run() override { cout << "SportsMan::run()" << endl; }
void finalRun() override { cout << "SportsMan::finalRun()" << endl; } // 无法重写final修饰的虚函数
};
class FinalAthlete : public SportsMan // 不允许继承final修饰的类
{
};
5.3 overide
override关键字
确保在派生类中声明的重写函数与基类的虚函数有相同的签名,同时也明确表明
将会重写基类的虚函数,这样就可以保证重写的虚函数的正确性,也提高了代码的可读性,和final
一样这个关键字要写到方法的后面。使用方法如下:
#include <iostream>
using namespace std;
class Object_My
{
public:
virtual void print() = 0;
virtual void print_a()
{
std::cout << "Object_My::print_a()" << std::endl;
}
};
class ClassA : public Object_My
{
void print() override
{
std::cout << "ClassA::print()" << std::endl;
}
};
int main()
{
Object_My *obj = new ClassA(); // 将子类对象指针指向父类对象,用来实现多态
obj->print(); // 子类虚函数表寻址,调用子类的print()函数
obj->print_a(); // 调用父类的print_a()函数,因为子类没有重写该函数
return 0;
}
6.模板的优化
6.1 模板嵌套右尖括号的优化
在泛型编程中,模板实例化有一个非常繁琐的地方,那就是连续的两个右尖括号(>>)
会被编译器解析成右移操作符
,而不是模板参数表的结束。我们先来看一段关于容器遍历的代码,在创建的类模板Base
中提供了遍历容器的操作函数traversal()
:
// test.cpp
#include <iostream>
#include <vector>
using namespace std;
template <typename T>
class Base
{
public:
void traversal(T& t)
{
auto it = t.begin();
for (; it != t.end(); ++it)
{
cout << *it << " ";
}
cout << endl;
}
};
int main()
{
vector<int> v{ 1,2,3,4,5,6,7,8,9 };
Base<vector<int>> b;
b.traversal(v);
return 0;
}
如果使用C++98/03
标准来编译上边的这段代码,就会得到如下的错误提示:
test.cpp:25:20: error: '>>' should be '> >' within a nested template argument list
Base<vector<int>> b;
根据错误提示中描述模板的两个右尖括之间需要添加空格,这样写起来就非常的麻烦,C++11
改进了编译器的解析规则,尽可能地将多个右尖括号(>)解析成模板参数结束符
,方便我们编写模板相关的代码。
上面的这段代码,在支持C++11的编译器中编译是没有任何问题的,如果使用g++直接编译需要加参数-std=c++11
:
g++ test.cpp -std=c++11 -o app
6.2 默认模板参数
在C++98/03
标准中,类模板
可以有默认的模板参数:
#include <iostream>
using namespace std;
template <typename T=int, T t=520>
class Test
{
public:
void print()
{
cout << "current value: " << t << endl;
}
};
int main()
{
Test<> t;
t.print();
Test<int, 1024> t1;
t1.print();
return 0;
}
但是不支持函数的默认模板参数,在C++11中添加了对函数模板
默认参数的支持:
#include <iostream>
using namespace std;
template <typename T=int> // C++98/03不支持这种写法, C++11中支持这种写法
void func(T t)
{
cout << "current value: " << t << endl;
}
int main()
{
func(100);
return 0;
}
通过上面的例子可以得到如下结论:当所有模板参数都有默认参数时,函数模板的调用如同一个普通函数
。但对于类模板
而言,哪怕所有参数都有默认参数,在使用时也必须在模板名后跟随< >
来实例化。
另外:函数模板的默认模板参数在使用规则上和其他的默认参数也有一些不同,它没有必须写在参数表最后的限制
。这样当默认模板参数和模板参数自动推导
结合起来时,书写就显得非常灵活了。我们可以指定函数模板中的一部分模板参数使用默认参数,另一部分使用自动推导, 像下面的例子:
#include <iostream>
using namespace std;
template <typename T = int, T t = 520>
class Test
{
public:
void print()
{
cout << "current value: " << t << endl;
}
};
template <typename T = long, typename U = int>
void myTest(T t = 'A', U u = 'B')
{
std::cout << "t = " << t << ", u = " << u << std::endl; // 默认会将'A'和'B'转换为long, int类型
}
int func(int x, double y = 10.0) // 要么全部指定参数,要么将默认参数放在后面
{
return x + y;
}
int main()
{
Test<> t2;
t2.print();
Test<int, 1024> t1;
t1.print();
std::cout << "==========\n";
myTest(); // 使用默认模板参数和默认函数参数
myTest(123L); // 使用默认的 U 类型和自定义的 T 类型(long)
myTest<>('C'); // 使用自定义的 T 类型(char)和默认的 U 类型(int)
myTest<int, float>(42, 3.14f); // 完全指定模板参数和默认函数参数
std::cout << "==========\n";
std::cout << func(10) << std::endl; // 使用默认参数
std::cout << func(20, 25.0) << std::endl; // 自定义参数
return 0;
}
对于上述调用,输出将是:
current value: 520
current value: 1024
==========
t = 65, u = 66 // 'A' 和 'B' 的 ASCII 值
t = 123, u = 66 // 123 是 long 类型,'B' 是 int 类型
t = 67, u = 66 // 'C' 是 char 类型,'B' 是 int 类型
t = 42, u = 3.14 // 42 是 int 类型,3.14 是 float 类型
==========
20
45
当默认模板参数
和模板参数自动推导
同时使用时(优先级从高到低
):
如果可以推导出参数类型则使用推导出的类型
如果函数模板无法推导出参数类型,那么编译器会使用默认模板参数
如果无法推导出模板参数类型并且没有设置默认模板参数,编译器就会报错。
- 当有多个模板类型却只指定少于实际的模板类型,从左到右依次赋值
再看下面的例子:
#include <iostream>
#include <string>
using namespace std;
// 函数模板定义
template <typename T, typename U = char>
void func(T arg1 = 100, U arg2 = 100)
{
cout << "arg1: " << arg1 << ", arg2: " << arg2 << endl;
}
int main()
{
// 模板函数调用
func('a');
func(97, 'a');
// func(); //编译报错,默认的模板参数不全,默认的函数参数不能用于类型推导
return 0;
}
7.using关键字
在
C++11
之前,using关键字
一般有两种用法:
- 引入
命名空间
,这样可以使用命名空间里的函数和变量,防止命名冲突- 派生类使用基类的成员或者函数
#include <iostream>
// using namespace std; // using声明命名空间后可以省略命名空间,直接使用cout
using std::cout; // 也可以只使用using声明命名空间中的一个对象
class Base
{
public:
int a;
void print(){ cout << "Base::print()" << std::endl; }
};
class Child : public Base
{
public:
using Base::a; // 继承父类的a成员
using Base::print; // 继承父类的print成员
};
int main()
{
cout << "Hello, world!\n";
Child c;
c.print(); // 输出Child::print()
return 0;
}
7.1 给类定义别名
在c++中,可以用 typedef
重定义一个类型,语法如下:
typedef 旧的类型名 新的类型名;
// 使用举例
typedef unsigned int uint_t;
unsigned int a = 10;
uint_t b = 20;
typedef
重定义的类型并不是一个新的类型,仅仅只是原有的类型取了一个新的名字。和以前的声明语句一样,这里的声明符也可以包含类型修饰(例如const
,volatile
),从而也能由基本数据类型构造出**复合类型(指针或者数组)**来。C++11中规定了一种新的方法,使用别名声明(alias declaration)来定义类型的别名,即使用 using
。
在使用的时候,关键字 using
作为别名声明的开始,其后紧跟别名和等号,其作用是把等号左侧的名字规定成等号右侧类型的别名。类型别名和类型的名字等价,只要是类型的名字能出现的地方,就能使用类型别名。使用typedef定义的别名和使用using定义的别名在语义上是等效的。
使用using定义别名的语法格式是这样的:
using 新的类型 = 旧的类型;
// 使用举例
using uint_t = int;
通过using
和typedef
的语法格式可以看到二者的使用没有太大的区别,假设我们定义一个函数指针,using
的优势就能凸显出来了,看一下下面的例子:
// 使用typedef定义函数指针
typedef int(*func_ptr)(int, double);
// 使用using定义函数指针
using func_ptr1 = int(*)(int, double);
#include <iostream>
using namespace std;
// using定义别名
using u_int = unsigned int;
u_int a = 64;
int add(int a, int b)
{
cout << "add(" << a << ", " << b << ")=" << a + b << endl;
return a + b;
}
// 函数指针类型定义, 这两种方法同效果
typedef int (*add_ptr)(int, int);
using add_ptr_ = int (*)(int, int);
int main()
{
add_ptr func = add;
// add_ptr_ func = add;
int (*add_func)(int, int) = add;
add(30, 'c'); // 不使用别名
func(10, 20);
(*func)(10, 20);
return 0;
}
7.2 给模板定义别名
使用typedef重定义类
时很方便,但是它有一点限制,比如无法重定义一个模板
,比如我们需要一个固定以int类型为key的map
,它可以和很多类型的value
值进行映射,如果使用typedef
这样直接定义就非常麻烦:
typedef map<int, string> map_s;
typedef map<int, int> map_i;
typedef map<int, double> map_d;
在这种情况下我们就不自觉的想到了模板:
template <typename T>
typedef map<int, T> type; // error, 语法错误
使用typename不支持给模板定义别名,这个简单的需求仅通过typedef
很难办到,需要添加一个外敷类
:
#include <iostream>
#include <functional>
#include <map>
using namespace std;
template <typename T>
// 定义外敷类
struct MyMap
{
typedef map<int, T> type;
};
int main(void)
{
MyMap<string>::type m;
m.insert(make_pair(1, "luffy"));
m.insert(make_pair(2, "ace"));
MyMap<int>::type m1;
m1.insert(1, 100);
m1.insert(2, 200);
return 0;
}
通过上边的例子可以直观的感觉到,需求简单但是实现起来并不容易。在C++11
中,新增了一个特性就是可以通过使用using来为一个模板定义别名
,对于上面的需求可以写成这样:
/* 使用using为模板定义别名 */
template <typename T>
using MyMap = map<T, int>;
完整的实例代码如下:
#include <iostream>
#include <map>
using namespace std;
template <typename T>
struct MyContainer
{
void print(T &t)
{
for (auto iter = t.begin(); iter != t.end(); iter++)
{
cout << iter->first << " " << iter->second << " ";
}
}
};
/* 使用using为模板定义别名 */
template <typename T>
using MyMap = map<T, int>;
int main()
{
MyMap<string> map; // ok
map.insert(pair<string, int>("a", 1));
map.insert(pair<string, int>("b", 2));
map.insert(pair<string, int>("c", 3));
MyContainer<MyMap<string>> c; // ok
c.print(map); // ok
return 0;
}
上面的例子中通过使用using给模板指定别名
,就可以基于别名非常方便的给value
指定相应的类型,这样使编写的程序变得更加灵活,看起来也更加简洁一些。
最后在强调一点:using
语法和typedef
一样,并不会创建出新的类型,它们只是给某些类型定义了新的别名。using相较于typedef的优势在于定义函数指针别名时看起来更加直观,并且可以给模板定义别名。
8.委托构造和继承构造
8.1 委托构造函数
在一个类中,常常会重载多个构造函数
以适应不同的需求,在C++11之前,即便是同一个类的构造函数之间也不能互相调用,而在C++11之后,提出了委托构造函数的概念
: 委托构造函数允许使用同一个类中的一个构造函数调用其它的构造函数,从而简化相关变量的初始化。下面举例说明:
enum Gender
{
Male,
Female
};
class Person
{
public:
int i_age;
string s_name;
Gender s_gender;
Person() {}
Person(int age)
{
i_age = age;
}
Person(int age, string name)
{
i_age = age; // 冗余代码
s_name = name;
}
Person(int age, string name, Gender gender)
{
i_age = age; // 冗余代码
s_name = name; // 冗余代码
s_gender = gender;
}
};
在上面的程序中有三个构造函数,但是这三个函数中都有重复的代码,在C++11之前构造函数是不能调用构造函数的,加入了委托构造之后,我们就可以轻松地完成代码的优化了:
#include <iostream>
using namespace std;
enum Gender
{
Male,
Female
};
class Person
{
public:
int i_age;
string s_name;
Gender s_gender = Gender::Male;
Person() {}
Person(int age)
{
i_age = age;
}
Person(int age, string name) : Person(age)
{
s_name = name;
}
Person(int age, string name, Gender gender) : Person(age, name)
{
s_gender = gender;
}
void print()
{
string genderStr = (s_gender == Male) ? "Male" : "Female";
cout << "age: " << i_age << " name: " << s_name << " gender: " << genderStr << endl;
}
};
int main()
{
Person p1(20, "luffy", Male);
Person p2(25, "zoro1");
Person p3(18, "sanji", Female);
p1.print();
p2.print();
p3.print();
}
注意事项:
这种链式的构造函数调用不能形成一个闭环(死循环),否则会在运行期抛异常。
如果要进行多层构造函数的链式调用,建议将构造函数的调用的写在初始列表中而不是函数体内部,否则编译器会提示形参的重复定义。
在初始化列表中调用了代理构造函数初始化某个类成员变量之后,就不能在初始化列表中再次初始化这个变量了。
// 错误, 使用了委托构造函数就不能再次m_max初始化了
Person(int age_, string name) : Person(age), age(age_)
{
this->name = name;
}
8.2 继承构造函数
C++11中提供的继承构造函数
可以让派生类直接使用基类的构造函数
,而无需自己再写构造函数,尤其是在基类有很多构造函数的情况下,可以极大地简化派生类构造函数的编写。先来看没有继承构造函数之前的处理方式:
#include <iostream>
#include <string>
using namespace std;
class Base
{
public:
Base(int i) :m_i(i) {}
Base(int i, double j) :m_i(i), m_j(j) {}
Base(int i, double j, string k) :m_i(i), m_j(j), m_k(k) {}
int m_i;
double m_j;
string m_k;
};
class Child : public Base
{
public:
Child(int i) :Base(i) {}
Child(int i, double j) :Base(i, j) {}
Child(int i, double j, string k) :Base(i, j, k) {}
};
int main()
{
Child c(520, 13.14, "i love you");
cout << "int: " << c.m_i << ", double: "
<< c.m_j << ", string: " << c.m_k << endl;
return 0;
}
可以看出,即便是子类中只需要使用和基类一样的函数体也需要重新声明一遍子类的构造函数
,这是非常繁琐的,C++11中通过添加继承构造函数这个新特性完美的解决了这个问题,使得代码更加精简。
继承构造函数的使用方法是这样的:通过使用using 类名::构造函数名
(其实类名和构造函数名是一样的)来声明使用基类的构造函数
,这样子类中就可以不定义相同的构造函数了,直接使用基类的构造函数来构造派生类对象。
#include <iostream>
#include <string>
using namespace std;
class Base
{
public:
Base(int i) : m_i(i) {}
Base(int i, double j) : m_i(i), m_j(j) {}
Base(int i, double j, string k) : m_i(i), m_j(j), m_k(k) {}
int m_i;
double m_j;
string m_k;
};
class Child : public Base
{
public:
using Base::Base;
};
int main()
{
Child c(520, 13.14, "i love you");
cout << "int: " << c.m_i << ", double: "
<< c.m_j << ", string: " << c.m_k << endl;
return 0;
}
在修改之后的子类中,没有添加任何构造函数,而是添加了using Base::Base;
这样就可以在子类中直接继承父类的所有的构造函数,通过他们去构造子类对象了。
另外如果在子类中隐藏了父类中的同名函数,也可以通过using
的方式在子类中使用基类中的这些父类函数:
#include <iostream>
#include <string>
using namespace std;
class Base
{
public:
Base(int i) :m_i(i) {}
Base(int i, double j) :m_i(i), m_j(j) {}
Base(int i, double j, string k) :m_i(i), m_j(j), m_k(k) {}
void func(int i)
{
cout << "base class: i = " << i << endl;
}
void func(int i, string str)
{
cout << "base class: i = " << i << ", str = " << str << endl;
}
int m_i;
double m_j;
string m_k;
};
class Child : public Base
{
public:
using Base::Base;
using Base::func;
void func()
{
cout << "child class: i'am luffy!!!" << endl;
}
};
int main()
{
Child c(250);
c.func();
c.func(19);
c.func(19, "luffy");
return 0;
}
上述示例代码输出的结果为:
child class: i'am luffy!!!
base class: i = 19
base class: i = 19, str = luffy
子类中的func()函数
隐藏了基类中的两个func()
,因此默认情况下通过子类对象只能调用无参的func(),在上面的子类代码中添加了using Base::func;
之后,就可以通过子类对象直接调用父类中被隐藏的带参func()函数了。
9.初始化列表
关于C++中的变量,数组,对象
等都有不同的初始化方法
,在这些繁琐的初始化方法中没有任何一种方式适用于所有的情况。为了统一初始化方式,并且让初始化行为具有确定的效果,在C++11中提出了列表初始化
的概念。
9.1 统一的初始化方式
在C++98/03中,对应普通数组和可以直接进行内存拷贝(memcpy())
的对象是可以使用列表初始化来初始化数据的
// 数组的初始化
int array[] = { 1,3,5,7,9 };
double array1[3] = { 1.2, 1.3, 1.4 };
// 对象的初始化
struct Person
{
int id;
double salary;
}zhang3{ 1, 3000 };
在C++11
后,变得更加多样和灵活了,看一下下面的例子:
#include <iostream>
using namespace std;
class Test
{
public:
Test(int) {}
private:
Test(const Test &);
};
int main(void)
{
Test t1(520); // 调用默认构造函数
Test t2 = 520; // 520会被编译器自动转换为int类型,然后调用默认构造函数构建(临时)匿名对象,然后通过拷贝构造函数赋值给t2
Test t3 = {520}; // c++11新特性:列表初始化
Test t4{520}; // c++11新特性:列表初始化
int a1 = {1314};
int a2{1314};
int arr1[] = {1, 2, 3};
int arr2[]{1, 2, 3};
return 0;
}
既然列表初始化
可以对普通类型和对象进行直接初始化,那么自然也可以对使用new关键字
动态分配的对象进行列表初始化:
int * p = new int{520};
double b = double{52.134};
int * array = new int[3]{1,2,3};
Test *test = new Test{50};
不仅如此,还可以用在函数的返回值
上,返回一个匿名对象
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person(int id, string name)
{
cout << "id: " << id << ", name: " << name << endl;
}
};
Person func()
{
return { 9527, "华安" };
}
int main(void)
{
Person p = func();
return 0;
}
代码中的return { 9527, "华安" };
就相当于return (9527, "华安" );
,直接返回了一个匿名对象
。通过上面的几个例子可以看出在C++11使用列表初始化是非常便利的,它统一了各种对象的初始化方式,而且还让代码的书写更加简单清晰。
9.2 列表初始化细节
在C++11中,只要是聚合类型都能使用列表初始化
进行直接的初始化方式。(例如数组)
普通数组本身可以看作一个聚合类型
int x[] = {1,2,3,4,5,6};
double y[3][3] = {
{1.23, 2.34, 3.45},
{4.56, 5.67, 6.78},
{7.89, 8.91, 9.99},
};
char carry[] = {'a', 'b', 'c', 'd', 'e', 'f'};
std::string sarry[] = {"hello", "world", "nihao", "shijie"};
a. 聚合体
在C++中,聚合体(Aggregate)
是指一种特殊的类类型,它满足以下条件:
- 没有
用户定义的构造函数(即没有自定义的构造函数,包括默认构造函数)
。 - 没有
私有(private)或保护(protected)的非静态数据成员
。 - 没有
基类(即不是派生类)
。 - 没有
虚(virtual)函数
。
聚合体的特点是可以使用列表初始化来初始化其成员,例如:
#include <iostream>
using namespace std;
struct Test // 聚合体类型
{
int x;
int y;
};
struct Test2 // 非聚合体类型
{
int x;
int y;
Test2(int a, int b) : x(10), y(20) {};
};
int main()
{
Test t1 = {1, 2}; // x=1, y=2
Test2 t2 = {1, 2}; // x=10, y=20
}
Test2类的对象t2
使用初始化列表进行初始化,你会发现x,y的值并没有按照初始化列表中的来,这是因为Test2类
中定义了一个自定义的构造函数(不在把它当作一个简单的类型的聚合),所以系统在使用初始化列表的时候,将会调用它定义的构造函数对象t1是对一个自定义的聚合类型进行初始化,它将以拷贝的形式使用初始化列表中的数据来初始化T1结构体中的成员。
聚合体类型可以使用初始化列表进行初始化,但还有一种情况是不支持的(在c++11)
- 类中不能有使用
{}
和=
直接初始化的非静态数据成员
(从c++14开始就支持了)
#include <iostream>
#include <string>
using namespace std;
struct T2
{
int x;
long y;
protected:
static int z;
}t1{ 1, 100 }; // ok
// 静态成员的初始化
int T2::z = 2;
struct T3
{
int x;
double y = 1.34;
int z[3]{1,2,3};
};
int main(void)
{
T3 t{520, 13.14, {6,7,8}}; // error, c++11不支持,从c++14开始就支持了
return 0;
}
b. 非聚合体
对于聚合类型的类可以直接使用列表初始化进行对象的初始化,如果不满足聚合条件还想使用列表初始化其实也是可以的,需要在类的内部自定义一个构造函数, 在构造函数中使用初始化列表对类成员变量进行初始化
:
#include <iostream>
#include <string>
using namespace std;
struct T1
{
int x;
double y;
// 在构造函数中使用初始化列表初始化类成员
T1(int a, double b, int c) : x(a), y(b), z(c){}
virtual void print()
{
cout << "x: " << x << ", y: " << y << ", z: " << z << endl;
}
private:
int z;
};
int main(void)
{
T1 t{ 520, 13.14, 1314 }; // ok, 基于构造函数使用初始化列表初始化类成员
t.print();
return 0;
}
另外,需要额外注意的是聚合类型的定义并非递归的
,也就是说当一个类的非静态成员是非聚合类型时,这个类也可能是聚合类型
,比如下面的这个例子:
#include <iostream>
#include <string>
using namespace std;
struct T1
{
int x;
double y;
private:
int z;
};
struct T2
{
T1 t1;
long x1;
double y1;
};
int main(void)
{
T2 t2{ {}, 520, 13.14 };
return 0;
}
可以看到,T1并非一个聚合类型,因为它有一个private的非静态成员
。但是尽管T2有一个非聚合类型的非静态成员t1,T2依然是一个聚合类型,可以直接使用列表初始化的方式进行初始化。
最后强调一下t2对象的初始化过程,对于非聚合类型的成员t1做初始化的时候,可以直接写一对空的大括号{}
,这相当于调用是T1的无参构造函数
。
对于一个聚合类型,使用
列表初始化相当于对其中的每个元素分别赋值
,而对于非聚合类型,则需要先自定义一个合适的构造函数,此时使用列表初始化将会调用它对应的构造函数
。
9.3 std::initializer_list
在C++的STL容器中,可以进行任意长度的相同类型数据的初始化
,使用初始化列表也只能进行固定参数的初始化,如果想要做到和STL一样有任意长度初始化的能力,可以使用std::initializer_list这个轻量级的类模板来实现。
vector<int> v1{1, 2, 3, 4, 5};
vector<int> v2{1, 2, 3};
先来介绍一下这个类模板
的一些特点:
- 它是一个
轻量级的容器类型
,内部定义了迭代器iterator等容器必须的概念,遍历时得到的迭代器是只读的。 - 对于
std::initializer_list<T>
而言,它可以接收任意长度的初始化列表,但是要求元素必须是同种类型T - 在
std::initializer_list
内部有三个成员接口:size()
,begin()
,end()
。 std::initializer_list
对象只能被整体初始化或者赋值。
- 私有构造函数
initializer_list
类有一个私有构造函数
,它接受两个参数:一个指向数组的指针和数组的长度。这个构造函数由编译器调用,用于初始化initializer_list
对象。这个构造函数的定义如下:
constexpr initializer_list(const_iterator __a, size_type __l) : _M_array(__a), _M_len(__l) { }
其中,
_M_array
是指向列表中第一个元素的指针,_M_len
是列表中元素的数量。
- 编译器的隐式调用
当你使用初始化列表语法
{1, 2, 3}
来创建initializer_list
对象时,编译器会自动调用这个私有构造函数。编译器会生成一个临时数组,将{1, 2, 3}
中的元素存储在这个数组中,然后将这个数组的指针和长度传递给私有构造函数。例如:
initializer_list<int> l = {1, 2, 3};
编译器会生成类似以下的代码:
int __tmp[] = {1, 2, 3}; initializer_list<int> l(__tmp, 3);
a. 作为普通函数参数
如果想要自定义一个函数并且接收任意个数的参数(变参函数)
,只需要将函数参数指定为std::initializer_list
,使用初始化列表{ }
作为实参进行数据传递即可。
#include <iostream>
#include <string>
using namespace std;
void traversal(std::initializer_list<int> a)
{
for (auto it = a.begin(); it != a.end(); ++it)
{
cout << *it << " ";
}
cout << endl;
}
int main(void)
{
initializer_list<int> list;
cout << "current list size: " << list.size() << endl;
traversal(list);
list = { 1,2,3,4,5,6,7,8,9,0 };
cout << "current list size: " << list.size() << endl;
traversal(list);
cout << endl;
list = { 1,3,5,7,9 };
cout << "current list size: " << list.size() << endl;
traversal(list);
cout << endl;
////////////////////////////////////////////////////
////////////// 直接通过初始化列表传递数据 //////////////
////////////////////////////////////////////////////
traversal({ 2, 4, 6, 8, 0 });
cout << endl;
traversal({ 11,12,13,14,15,16 });
cout << endl;
return 0;
}
std::initializer_list
拥有一个无参构造函数
,因此,它可以直接定义实例,此时将得到一个空的std::initializer_list
,因为在遍历这种类型的容器的时候得到的是一个只读的迭代器
,因此我们不能修改里边的数据。
在效率方面也无需担心,std::initializer_list
的效率是非常高的,它的内部并不负责保存初始化列表中元素的拷贝,仅仅存储了初始化列表中元素的引用。
b. 作为构造函数参数
自定义的类如果在构造对象的时候想要接收任意个数的实参,可以给构造函数指定为std::initializer_list
类型,在自定义类的内部还是使用容器来存储接收的多个实参。
#include <iostream>
#include <string>
#include <vector>
using namespace std;
class Test
{
public:
Test(std::initializer_list<string> list)
{
for (auto it = list.begin(); it != list.end(); ++it)
{
cout << *it << " ";
m_names.push_back(*it);
}
cout << endl;
}
private:
vector<string> m_names;
};
int main(void)
{
Test t({ "jack", "lucy", "tom" });
Test t1({ "hello", "world", "nihao", "shijie" });
return 0;
}
10.基于范围的for循环
在C++98/03之前,不同的数组和容器有不同的遍历方式
,写法不统一,也不够简洁,而C++11基于范围的for循环可以以简洁、统一的方式来遍历容器和数组,用起来也更方便了。
10.1 for循环的新语法
在介绍新的for循环语法之前,我们先来看传统的for循环的语法结构:
for(表达式 1; 表达式 2; 表达式 3)
{
// 循环体
}
在介绍新语法之前,先来看一个使用迭代器遍历容器的例子:
#include <iostream>
#include <vector>
using namespace std;
int main()
{
vector<int> t{ 1,2,3,4,5,6 };
for (auto it = t.begin(); it != t.end(); ++it)
{
cout << *it << " ";
}
cout << endl;
return 0;
}
我们在遍历的过程中需要给出容器的两端:开头(begin)和结尾(end),因为这种遍历方式不是基于范围来设计的。在基于范围的for循环中,不需要再传递容器的两端,循环会自动以容器为范围展开,并且循环中也屏蔽掉了迭代器的遍历细节,直接抽取容器中的元素进行运算,使用这种方式进行循环遍历会让编码和维护变得更加简便。
C++11基于范围的for循环,语法格式:
for (declaration : expression)
{
// 循环体
}
在上面的语法格式中declaration
表示遍历声明,在遍历过程中,当前被遍历到的元素会被存储到声明的变量中。expression
是要遍历的对象,它可以是表达式、容器、数组、初始化列表
等。
使用基于范围的for循环遍历容器,示例代码如下:
#include <iostream>
#include <vector>
using namespace std;
int main()
{
vector<int> v = {1, 2, 3, 4, 5};
for (auto i : v)
{
cout << i << " ";
}
return 0;
}
在上面的例子中,是将容器中遍历的当前元素拷贝到了声明的变量value中
,因此无法对容器中的元素进行写操作,如果需要在遍历过程中修改元素的值,需要使用引用。
#include <iostream>
#include <vector>
#include <windows.h>
using namespace std;
int main(void)
{
// 设置控制台的输出代码页为 UTF-8
SetConsoleOutputCP(CP_UTF8);
vector<int> t{1, 2, 3, 4, 5, 6};
cout << "遍历修改之前的容器: ";
for (auto &value : t)
{
cout << value++ << " ";
}
cout << endl
<< "遍历修改之后的容器: ";
for (auto &value : t)
{
cout << value << " ";
}
cout << endl;
return 0;
}
代码输出的结果:
遍历修改之前的容器: 1 2 3 4 5 6
遍历修改之后的容器: 2 3 4 5 6 7
对容器的遍历过程中,如果只是读数据
,不允许修改元素的值,可以使用const
定义保存元素数据的变量,在定义的时候建议使用const auto &
,这样相对于const auto
效率要更高一些。
#include <iostream>
#include <vector>
using namespace std;
int main(void)
{
vector<int> t{ 1,2,3,4,5,6 };
for (const auto& value : t)
{
cout << value << " ";
}
return 0;
}
10.2 使用细节
a. 关系型容器
使用基于范围的for循环有一些需要注意的细节,先来看一下对关系型容器map
的遍历:
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main(void)
{
map<int, string> m{
{1, "lucy"},{2, "lily"},{3, "tom"}
};
// 基于范围的for循环方式
for (auto& it : m)
{
cout << "id: " << it.first << ", name: " << it.second << endl;
}
// 普通的for循环方式
for (auto it = m.begin(); it != m.end(); ++it)
{
cout << "id: " << it->first << ", name: " << it->second << endl;
}
return 0;
}
在上面的例子中使用两种方式对map进行了遍历,通过对比有两点需要注意的事项:
使用普通的for循环方式(基于迭代器)遍历关联性容器, auto自动推导出的是一个迭代器类型,需要使用迭代器的方式取出元素中的键值对(和指针的操作方法相同)
:
- it->first
- it->second
使用基于范围的for循环遍历关联性容器,auto自动推导出的类型是容器中的value_type,相当于一个对组(std::pair)对象,提取键值对的方式如下:
- it.first
- it.second
b. 元素只读
通过对基于范围的for循环语法的介绍可以得知,在for循环内部声明一个变量的引用就可以修改遍历的表达式中的元素的值,但是这并不适用于所有的情况,对应set容器
来说,内部元素都是只读的
,这是由容器的特性决定的,因此在for循环中auto&
会被视为const auto &
。
#include <iostream>
#include <set>
using namespace std;
int main(void)
{
set<int> st{ 1,2,3,4,5,6 };
for (auto &item : st)
{
cout << item++ << endl; // error, 不能给常量赋值
// 元素的值用于确定其在集合中的位置,并且集合要求元素是唯一的。如果允许修改元素的值,可能会破坏set的有序性和唯一性。
}
return 0;
}
除此之外,在遍历关联型容器时也会出现同样的问题,基于范围的for循环中,虽然可以得到一个std::pair引用
,但是我们是不能修改里边的first
值的,也就是key值。
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main(void)
{
map<int, string> m{
{1, "lucy"},{2, "lily"},{3, "tom"}
};
for (auto& item : m)
{
// item.first 是一个常量
cout << "id: " << item.first++ << ", name: " << item.second << endl; // error
}
return 0;
}
c. 访问次数
基于范围的for循环遍历的对象可以是一个表达式或者容器/数组
等。假设我们对一个容器进行遍历,在遍历过程中for循环对这个容器的访问频率
是一次还是多次呢?我们通过下面的例子验证一下:
#include <iostream>
#include <vector>
using namespace std;
vector<int> v{ 1,2,3,4,5,6 };
vector<int>& getRange()
{
cout << "get vector range..." << endl;
return v;
}
int main(void)
{
for (auto val : getRange())
{
cout << val << " ";
}
cout << endl;
return 0;
}
输出的结果如下:
get vector range...
1 2 3 4 5 6
从上面的结果中可以看到,不论基于范围的for循环迭代了多少次,函数getRange()
只在第一次迭代之前被调用,得到这个容器对象之后就不会再去重新获取这个对象了。
对应基于范围的for循环来说,冒号后边的表达式只会被执行一次。在得到遍历对象之后会先确定好迭代的范围,基于这个范围直接进行遍历。如果是普通的for循环,在每次迭代的时候都需要判断是否已经到了结束边界。
11.可调用对象包装器、绑定器
11.1 可调用对象
可调用对象(Callable) 是指可以像函数一样调用某些实体的对象。
- 是一个
函数对象
void Func()
{
cout << "函数被调用" << endl;
}
函数指针
,本质也是函数
int print(int a, double b)
{
cout << a << b << endl;
return 0;
}
// 定义函数指针
int (*func)(int, double) = &print;
仿函数
- 重载了()
操作符的类的对象
class Test
{
public:
void operator()()
{
cout << "hello world";
}
};
// 3. 可调用对象--仿函数
Test t1;
t1();
是一个可被转换为函数指针的类对象 类型转换运算符
- 能够将类对象转换为函数指针类型
类型转换运算符(Conversion Operator) 是一种可用于类的特殊成员函数,用于定义类到其他类型的隐式或显式转换。类型转换运算符的语法如下:
operator 类型() const;
- 定义了如何将类对象转换为指定类型。
- 例如,可以将一个自定义类转换为内置数据类型或其他类类型。
示例:定义类型转换运算符
假设有一个表示货币金额的类
Money
,我们希望将其转换为double
类型:>#include <iostream> >class Money { >private: double amount; >public: Money(double amt) : amount(amt) {} // 定义类型转换运算符,将 Money 转换为 double operator double() const { return amount; } >}; >int main() { Money m(100.5); // 使用类型转换运算符 double amount = m; // 调用 operator double() std::cout << "Amount: " << amount << std::endl; return 0; >}
解释:
- 类
Money
中定义了operator double()
,允许将Money
对象隐式转换为double
。- 在
main
函数中,double amount = m;
触发了类型转换运算符,将Money
对象m
转换为double
。隐式与显式转换
- 默认情况下,类型转换运算符允许隐式转换。
- 如果希望防止隐式转换,可以使用
explicit
关键字:>explicit operator double() const { return amount; >}
这样,需要显式转换(如
double amount = static_cast<double>(m);
)才能进行转换。
#include <iostream>
#include <string>
#include <vector>
using namespace std;
using func_ptr = void(*)(int, string);
struct Test
{
static void print(int a, string b)
{
cout << "name: " << b << ", age: " << a << endl;
}
// 将类对象转换为函数指针
operator func_ptr()
{
// 返回的要是 类的静态成员Test::print
return print;
}
};
int main(void)
{
Test t;
// 对象转换为函数指针, 并调用
t(19, "Monkey D. Luffy");
return 0;
}
是一个类成员函数指针或者类成员指针
#include <iostream>
#include <string>
#include <vector>
using namespace std;
struct Test
{
void print(int a, string b)
{
cout << "name: " << b << ", age: " << a << endl;
}
int m_num;
};
int main(void)
{
// 定义类成员函数指针指向类成员函数
void (Test::*func_ptr)(int, string) = &Test::print;
// 类成员指针指向类成员变量
int Test::*obj_ptr = &Test::m_num;
Test t;
// 通过类成员函数指针调用类成员函数
(t.*func_ptr)(19, "Monkey D. Luffy");
// 通过类成员指针初始化类成员变量
t.*obj_ptr = 1;
cout << "number is: " << t.m_num << endl;
return 0;
}
在上面的例子中满足条件的这些可调用对象对应的类型被统称为可调用类型
。
C++中的可调用类型虽然具有比较统一的操作形式,但定义方式五花八门,这样在我们试图使用统一的方式保存,或者传递一个可调用对象时会十分繁琐。现在,C++11通过提供 std::function 和 std::bind 统一了可调用对象的各种操作
。
11.2 可调用对象包装器
C++11的std::function源码解析_std::function 源码-CSDN博客
libcxx 的 std::function 源码分析 - 知乎
std::function
是可调用对象的包装器
。它是一个类模板
,可以容纳除了类(非静态)成员(函数)指针之外
的所有可调用对象。通过指定它的模板参数,它可以用统一的方式处理函数、函数对象、函数指针,并允许保存和延迟执行它们。
基本用法
使用std::function
需要添加头文件#include <functional>
,可调用对象包装器使用语法如下:
#include <functional>
std::function<返回值类型(参数类型列表)> diy_name = 可调用对象;
下面的实例代码中演示了可调用对象包装器的基本使用方法:
#include <iostream>
#include <algorithm>
#include <functional>
using namespace std;
using f_ptr = void (*)(string);
void Func()
{
cout << "函数被调用" << endl;
}
class Test
{
public:
int num;
void operator()()
{
cout << "hello world" << endl;
}
// 类型转换运算符 -- 返回值必须是 静态成员函数指针 这样才能在没有实例的情况下调用
operator f_ptr()
{
return static_print;
}
static void static_print(string msg)
{
cout << "我是静态成员函数" << msg << endl;
}
void normal_print()
{
cout << "我是非静态成员函数" << endl;
}
};
int main(int argc, char const *argv[])
{
system("chcp 65001");
// 1. 可调用对象--函数
Func();
// 2. 可调用对象--函数指针
using func_ptr = void (*)();
func_ptr ptr1 = Func;
(ptr1)();
(*ptr1)();
// 3. 可调用对象--仿函数
Test t1;
t1();
// 4. 类型转换运算符 -> 能够将`类对象转换为函数指针类型`
string msg = "hhh";
t1(msg);
// 5. 类成员函数指针 / 类成员指针
using class_func_ptr = void (Test::*)();
class_func_ptr ptr2 = &Test::normal_print;
int Test::*class_member_ptr = &Test::num;
Test t2;
(t2.*ptr2)();
// 使用可调用对象包装器
// 1. 函数指针
function<void()> f1 = Func;
// 2. 仿函数
Test tt;
function<void()> f2 = tt;
// 3. 类型转换运算符
function<void(string)> f3 = tt;
// 4. 类静态函数
function<void(string)> f4 = &Test::static_print;
// 调用
f1();
f2();
f3("hhh");
f4("hhh");
return 0;
}
通过测试代码可以得到结论:std::function可以将可调用对象进行包装,得到一个统一的格式,包装完成得到的对象相当于一个函数指针
,和函数指针的使用方式相同,通过包装器对象就可以完成对包装的函数的调用了。
作为回调函数使用
因为回调函数本身就是通过函数指针实现的,使用对象包装器可以取代函数指针的作用
,来看一下下面的例子:
#include <functional>
#include <iostream>
using namespace std;
class Test
{
public:
// 构造函数传入一个function包装器对象 并保存到成员变量中
Test(const function<void(string)> &f) : callback(f)
{
}
// 执行回调函数
void notify()
{
callback("Hello World");
}
private:
function<void(string)> callback;
};
class TestB
{
public:
// 重载()运算符
void
operator()(string msg)
{
cout << msg << endl;
}
};
int main()
{
TestB testB;
// 重载了()运算符,所以对象当作函数使用
testB("Hello World"); // 仿函数通过包装器对象进行包装
Test test(testB); // 隐式转换为function包装器对象
return 0;
}
通过上面的例子可以看出,使用对象包装器std::function可以非常方便的将仿函数转换为一个函数指针,通过进行函数指针的传递,在其他函数的合适的位置就可以调用这个包装好的仿函数了。
11.3 bind绑定器
std::bind用来将可调用对象与其参数一起进行绑定。绑定后的结果可以使用std::function进行保存,并延迟调用到任何我们需要的时候。
通俗来讲,它主要有两大作用:
- 将
可调用对象与其参数一起绑定成一个仿函数
。 将多元(参数个数为n,n>1)可调用对象转换为一元或者(n-1)元可调用对象,即只绑定部分参数
。
绑定器函数使用语法格式如下:
// 绑定非类成员函数/变量
auto f = std::bind(可调用对象地址, 绑定的参数/占位符);
// 绑定类成员函/变量
auto f = std::bind(类函数/成员地址, 类实例对象地址, 绑定的参数/占位符);
下面来看一个关于绑定器的实际使用的例子:
#include <iostream>
#include <functional>
using namespace std;
void callFunc(int x, const function<void(int)>& f)
{
if (x % 2 == 0)
{
f(x);
}
}
void output(int x)
{
cout << x << " ";
}
void output_add(int x)
{
cout << x + 10 << " ";
}
int main(void)
{
// 使用绑定器绑定可调用对象和参数
auto f1 = bind(output, placeholders::_1);
for (int i = 0; i < 10; ++i)
{
callFunc(i, f1);
}
cout << endl;
auto f2 = bind(output_add, placeholders::_1);
for (int i = 0; i < 10; ++i)
{
callFunc(i, f2);
}
cout << endl;
return 0;
}
测试代码输出的结果:
0 2 4 6 8
10 12 14 16 18
在上面的程序中,使用了std::bind绑定器,在函数外部通过绑定不同的函数,控制了最后执行的结果。std::bind绑定器返回的是一个仿函数类型,得到的返回值可以直接赋值给一个std::function,在使用的时候我们并不需要关心绑定器的返回值类型,使用auto进行自动类型推导就可以了。
placeholders::_1
是一个占位符,代表这个位置将在bind返回的仿0函数调用时被传入的第一个参数所替代。
同样还有其他的占位符placeholders::_2、placeholders::_3、placeholders::_4、placeholders::_5等……
有了占位符的概念之后,使得std::bind的使用变得非常灵活:
#include <iostream>
#include <functional>
using namespace std;
void output(int x, int y)
{
cout << x << " " << y << endl;
}
int main(void)
{
// 使用绑定器绑定可调用对象和参数, 并调用得到的仿函数
bind(output, 1, 2)();
bind(output, placeholders::_1, 2)(10);
bind(output, 2, placeholders::_1)(10);
// error, 调用时没有第二个参数
// bind(output, 2, placeholders::_2)(10);
// 调用时第一个参数10被吞掉了,没有被使用
bind(output, 2, placeholders::_2)(10, 20);
bind(output, placeholders::_1, placeholders::_2)(10, 20);
bind(output, placeholders::_2, placeholders::_1)(10, 20);
return 0;
}
示例代码执行的结果:
1 2 // bind(output, 1, 2)();
10 2 // bind(output, placeholders::_1, 2)(10);
2 10 // bind(output, 2, placeholders::_1)(10);
2 20 // bind(output, 2, placeholders::_2)(10, 20);
10 20 // bind(output, placeholders::_1, placeholders::_2)(10, 20);
20 10 // bind(output, placeholders::_2, placeholders::_1)(10, 20);
通过测试可以看到,std::bind可以直接绑定函数的所有参数,也可以仅绑定部分参数。在绑定部分参数的时候,通过使用std::placeholders来决定空位参数将会属于调用发生时的第几个参数。
可调用对象包装器std::function是不能实现对类成员函数指针或者类成员指针的包装的
,但是通过绑定器std::bind
的配合之后,就可以完美的解决这个问题了,再来看一个例子,然后再解释里边的细节:
#include <iostream>
#include <functional>
using namespace std;
class Test
{
public:
void output(int x, int y)
{
cout << "x: " << x << ", y: " << y << endl;
}
int m_number = 100;
};
int main(void)
{
Test t;
// 绑定类成员函数
function<void(int, int)> f1 =
bind(&Test::output, &t, placeholders::_1, placeholders::_2);
// 绑定类成员变量(公共)
function<int&(void)> f2 = bind(&Test::m_number, &t);
// 调用
f1(520, 1314);
f2() = 2333;
cout << "t.m_number: " << t.m_number << endl;
return 0;
}
示例代码输出的结果:
x: 520, y: 1314
t.m_number: 2333
在用绑定器绑定类成员函数或者成员变量的时候需要将它们所属的实例对象一并传递到绑定器函数内部。f1的类型是function<void(int, int)>,通过使用std::bind将Test的成员函数output的地址和对象t绑定,并转化为一个仿函数并存储到对象f1中
。
使用绑定器绑定的类成员变量m_number得到的仿函数被存储到了类型为function<int&(void)>的包装器对象f2中,并且可以在需要的时候修改这个成员。其中int是绑定的类成员的类型,并且允许修改绑定的变量,因此需要指定为变量的引用,由于没有参数因此参数列表指定为void。
示例程序中是使用function包装器保存了bind返回的仿函数,如果不知道包装器的模板类型如何指定,可以直接使用auto进行类型的自动推导,这样使用起来会更容易一些。
12.lambda表达式
12.1 基本用法
lambda表达式
是C++11最重要也是最常用的特性之一,这是现代编程语言的一个特点,lambda表达式有如下的一些优点:
- 声明式的编程风格:就地匿名定义目标函数或函数对象,不需要额外写一个命名函数或函数对象。
- 简洁:避免了代码膨胀和功能分散,让开发更加高效。
- 在需要的时间和地点实现功能闭包,使程序更加灵活。
lambda表达式定义了一个匿名函数,并且可以捕获一定范围内的变量。lambda表达式的语法形式简单归纳如下:
[capture](params) opt -> ret {body;};
其中capture
是捕获列表,params
是参数列表,opt
是函数选项,ret
是返回值类型,body
是函数体。
捕获列表[]:
捕获一定范围内的变量参数列表():
和普通函数的参数列表一样,如果没有参数参数列表可以省略不写。opt 选项
, 不需要可以省略mutable
: 可以修改按值传递进来的拷贝(注意是能修改拷贝,而不是值本身)exception
: 指定函数抛出的异常,如抛出整数类型的异常,可以使用throw();
返回值类型
:在C++11中,lambda表达式的返回值是通过返回值后置语法来定义的。函数体
:函数的实现,这部分不能省略,但函数体可以为空。
注意匿名函数需要加
()
才能调用
// 只定义lambda匿名函数
[]()
{
cout << "lambda-1" << endl;
};
// 定义lambda匿名函数并调用
[]() -> void
{
cout << "lambda-2" << endl;
}();
12.2 捕获列表
lambda表达式的捕获列表可以捕获一定范围内的变量,具体使用方式如下:
[]
- 不捕捉任何变量[&]
- 捕获外部作用域中所有变量, 并作为引用
在函数体内使用 (按引用捕获
)[=]
- 捕获外部作用域中所有变量, 并作为副本
在函数体内使用 (按值捕获
)拷贝的副本在匿名函数体内部是只读的
[=, &foo]
- 按值捕获外部作用域中所有变量, 并按照引用捕获外部变量 foo[bar]
- 按值捕获 bar 变量, 同时不捕获其他变量[&bar]
- 按引用捕获 bar 变量, 同时不捕获其他变量[this]
- 捕获当前类中的this指针
让lambda表达式拥有和当前类成员函数同样的访问权限
如果已经使用了 & 或者 =, 默认添加此选项
使用
= 值捕获的
变量是在函数体中创建了一个只读的同名副本
(不同内存空间),添加mutable
选项也只是让这个副本
可写,想要修改外部作用域的变量,需要使用引用捕获
#include <iostream>
using namespace std;
#define cout_line cout << "--------------------" << endl;
class A
{
public:
int i_member = 0;
void func(int x, int y)
{
int a = 10;
int b = 20;
// 只定义lambda匿名函数
[]()
{
cout << "lambda-1" << endl;
};
cout_line;
// 定义lambda匿名函数并调用
[]() -> void
{
cout << "lambda-2" << endl;
}();
cout_line;
// 捕获方式 --> = 值捕获
[=]() -> void
{
int c = a;
int d = x;
// a++; // error 捕获值捕获的变量不能被修改
};
[a]() -> void
{
int c = a;
// int d = x; // error 只值捕获了外部作用域的a
};
// 捕获方式 --> & 引用捕获
[&]() -> void
{
int c = a;
int d = x;
a++; // ok 引用捕获的变量可以修改
}();
cout << "修改后的a=" << a << endl;
cout_line;
// 捕获方式 --> this 捕获当前类this指针
[this]() -> void
{
cout << "this->i_member=" << this->i_member << endl;
int c = this->i_member;
i_member++;
}();
cout << "修改后的i_member=" << i_member << endl;
cout_line;
// 选项 --> mutable
[=]() mutable -> void
{
int c = a;
int d = x;
a++; // ok 添加mutable后,捕获值捕获的变量可以修改,但是也还是副本
cout << "lambda函数内 a++ 后 a=" << a << endl; // 12
}();
cout << "外部变量 a=" << a << endl; // 11
}
};
int main()
{
system("chcp 65001");
A a;
a.func(1, 2);
}
12.3 返回值
很多时候,lambda表达式的返回值是非常明显的,因此在C++11中允许省略lambda表达式的返回值
。
// 完整的lambda表达式定义
auto f = [](int a) -> int
{
return a+10;
};
// 忽略返回值的lambda表达式定义
auto f = [](int a)
{
return a+10;
};
一般情况下,不指定lambda表达式的返回值,编译器会根据return语句自动推导返回值的类型,但需要注意的是labmda表达式不能通过列表初始化自动推导出返回值类型。
// ok,可以自动推导出返回值类型
auto f = [](int i)
{
return i;
}
// error,不能推导出返回值类型
auto f1 = []()
{
return {1, 2}; // 基于列表初始化推导返回值,错误
}
12.4 函数本质
使用lambda表达式捕获列表捕获外部变量,如果希望去修改按值捕获的外部变量,那么应该如何处理呢?这就需要使用mutable选项,被mutable修改是lambda表达式就算没有参数也要写明参数列表,并且可以去掉按值捕获的外部变量的只读(const)属性。
int a = 0;
auto f1 = [=] {return a++; }; // error, 按值捕获外部变量, a是只读的
auto f2 = [=]()mutable {return a++; }; // ok
最后再剖析一下为什么通过值拷贝的方式捕获的外部变量是只读的:
lambda表达式的类型在C++11中会被看做是一个带operator()的类,即仿函数。
按照C++标准,lambda表达式的operator()默认是const的,一个const成员函数是无法修改成员变量值的。
mutable选项的作用就在于取消operator()的const属性
因为lambda表达式在C++中会被看做是一个仿函数,因此可以使用std::function和std::bind来存储和操作lambda表达式
:
#include <iostream>
#include <functional>
using namespace std;
int main(void)
{
// 包装可调用函数
std::function<int(int)> f1 = [](int a) {return a; };
// 绑定可调用函数
std::function<int(int)> f2 = bind([](int a) {return a; }, placeholders::_1);
// 函数调用
cout << f1(100) << endl;
cout << f2(200) << endl;
return 0;
}
对于没有捕获任何变量的lambda表达式,还可以转换成一个普通的函数指针:
using func_ptr = int(*)(int);
// 没有捕获任何外部变量的匿名函数
func_ptr f = [](int a)
{
return a;
};
// 函数调用
f(1314);
13.左值引用和右值引用
13.1 右值引用
右值
C++11 增加了一个新的类型,称为右值引用( R-value reference
),标记为 &&
。在介绍右值引用类型之前先要了解什么是左值和右值:
lvalue
是loactor value
的缩写,rvalue
是read value
的缩写左值是指
存储在内存中、有明确存储地址(可取地址)的数据;
右值是指
可以提供数据值的数据(不可取地址);
通过描述可以看出,区分左值与右值的便捷方法是:可以对表达式取地址(&)就是左值,否则为右值 。所有有名字的变量或对象都是左值,而右值是匿名的。
int a = 520;
int b = 1314;
a = b;
一般情况下,位于=
前的表达式为左值,位于=
后边的表达式为右值。也就是说例子中的a, b
为左值,520,1314
为右值。
a=b
是一种特殊情况,在这个表达式中a, b都是左值,因为变量b是可以被取地址的,不能视为右值。
C++11 中右值可以分为两种:一个是将亡值( xvalue, expiring value),另一个则是纯右值( prvalue, PureRvalue):
纯右值
:非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和 lambda 表达式等
将亡值
:即将被移动/资源被转移的对象,与右值引用相关的表达式,比如,T&&类型函数的返回值、 std::move 的返回值等。
纯右值 (prvalue) | 将亡值 (xvalue) | |
---|---|---|
资源状态 | 新创建的临时对象 | 已有对象但即将消亡 |
典型操作 | 初始化/拷贝(可优化) | 移动语义(资源转移) |
内存地址 | 通常无 | 有(但即将失效) |
右值引用
右值引用就是对一个右值进行引用的类型。因为右值是匿名的,所以我们只能通过引用的方式找到它。无论声明左值引用还是右值引用都必须立即进行初始 化,因为引用类型本身并不拥有所绑定对象的内存,只是该对象的一个别名。通过右值引用的声明,该右值又“重获新生”,其生命周期与右值引用类型变量 的生命周期一样,只要该变量还活着,该右值临时量将会一直存活下去。
左值引用的使用场景:
- 一个函数传入(指针或者对象)都会创建对应类型的副本,但是假如传递的是一个左值引用(别名),就不会消耗内存空间用于存储副本(能在一定程度上提升效率)
右值引用的使用场景:
- 假设Test是一个非常大的类,一个类实例对象要消耗很大的内存,一个函数返回值为实例对象,那么在接收函数返回值的时候,就会产生临时对象,并调用拷贝构造函数,这对于资源是一个很大的消耗。
- 直接使用右值引用,将临时对象的生命周期延长,直接使用该临时对象的数据
#include <iostream>
using namespace std;
int&& value = 520;
class Test
{
public:
Test()
{
cout << "construct: my name is jerry" << endl;
}
Test(const Test& a)
{
cout << "copy construct: my name is tom" << endl;
}
};
Test getObj()
{
return Test();
}
int main()
{
int a1;
int &&a2 = a1; // error 左值不能赋值给右值引用
Test& t = getObj(); // error 非常量引用的初始值必须为左值 给一个临时对象起别名是不允许的
Test && t = getObj();
const Test& t = getObj();
return 0;
}
- 常量左值引用绑定右值:
- 安全保证:
const
确保不会修改临时对象- 生命周期扩展:
C++标准规定常量左值引用可将临时对象生命周期延长至引用作用域结束
在上面的例子中
int&& value = 520;
里面520
是纯右值
,value是对字面量520这个右值的引用。
在
int &&a2 = a1;
中a1
虽然写在了=
右边,但是它仍然是一个左值,使用左值初始化一个右值引用类型是不合法
的。在
Test& t = getObj()
这句代码中语法是错误的,右值不能给普通的左值引用赋值。在
Test && t = getObj();
中getObj()
返回的临时对象被称之为将亡值
,t是这个将亡值的右值引用。
const Test& t = getObj()这句代码的语法是正确的,常量左值引用是一个万能引用类型,它可以接受左值、右值、常量左值和常量右值。
在 C++ 中,
int&& a = 10; const int& b = a;
的代码是合法的,但涉及引用类型转换的核心概念。以下是关键分析:
int&& a = 10;
- 行为:将右值引用
a
绑定到字面量10
(右值)- 生命周期:
- 字面量
10
会隐式生成临时int
对象- 右值引用
a
会延长该临时对象的生命周期至a
的作用域结束
const int& b = a;
行为:将常量左值引用
b
绑定到a
(此时a
是左值)合法性:
a
是具名
的右值引用变量 → 被编译器视为左值
const
左值引用允许绑定到左值带名字的变量都是左值
>int &&a = 10; // a是别名 >cout << "a: " << a << endl; // 10 >cout << "&a: " << &a << endl; // 引用的变量地址 >const int &b = a; // 别名就相当于是一个变量名, a是int字面量的别名,所以可以被绑定到const int &b
13.2 性能优化
在C++中在进行对象赋值操作的时候,很多情况下会发生对象之间的深拷贝,如果堆内存很大,这个拷贝的代价也就非常大,在某些情况下,如果想要避免对象的深拷贝,就可以使用右值引用进行性能的优化。
再来修改一下上面的实例代码:
#include <iostream>
using namespace std;
class Test
{
public:
Test() : m_num(new int(100))
{
cout << "construct: my name is jerry" << endl;
}
Test(const Test& a) : m_num(new int(*a.m_num))
{
cout << "copy construct: my name is tom" << endl;
}
~Test()
{
delete m_num;
}
int* m_num;
};
Test getObj()
{
Test t;
return t;
}
int main()
{
Test t = getObj();
cout << "t.m_num: " << *t.m_num << endl;
return 0;
};
测试代码执行的结果为(当时使用的vs版本为2019,vs2022已无法看到相同的输出,代码被优化了)
:
construct: my name is jerry
copy construct: my name is tom
t.m_num: 100
通过输出的结果可以看到调用Test t = getObj();
的时候调用拷贝构造函数对返回的临时对象进行了深拷贝得到了对象t
,在getObj()函数
中创建的对象虽然进行了内存的申请操作,但是没有使用就释放掉了。如果能够使用临时对象已经申请的资源,既能节省资源,还能节省资源申请和释放的时间,如果要执行这样的操作就需要使用右值引用了,右值引用具有移动语义,移动语义可以将资源(堆、系统对象等)通过浅拷贝从一个对象转移到另一个对象这样就能减少不必要的临时对象的创建、拷贝以及销毁,可以大幅提高C++应用程序的性能。
#include <iostream>
using namespace std;
class Test
{
public:
Test() : m_num(new int(100))
{
cout << "construct: my name is jerry" << endl;
}
Test(const Test& a) : m_num(new int(*a.m_num))
{
cout << "copy construct: my name is tom" << endl;
}
// 添加移动构造函数
Test(Test&& a) : m_num(a.m_num)
{
// m_num = a.m_num; // 将临时对象的堆内存成员变量的指针赋值给当前对象,提高效率(这样不用创建新指针,赋值,释放临时对象的变量指针)
a.m_num = nullptr;
cout << "move construct: my name is sunny" << endl;
}
~Test()
{
delete m_num;
cout << "destruct Test class ..." << endl;
}
int* m_num;
};
Test getObj()
{
Test t;
return t; // 返回临时对象
}
int main()
{
Test t = getObj(); // 此时调用的是移动构造函数 直接接管临时对象的堆内存指针
cout << "t.m_num: " << *t.m_num << endl;
return 0;
};
测试代码执行的结果如下(当时使用的vs版本为2019,vs2022已无法看到相同的输出,代码被优化了)
:
construct: my name is jerry
move construct: my name is sunny
destruct Test class ...
t.m_num: 100
destruct Test class ...
通过修改,在上面的代码给Test
类添加了移动构造函数(参数为右值引用类型)
,这样在进行Test t = getObj();
操作的时候并没有调用拷贝构造函数进行深拷贝,而是调用了移动构造函数,在这个函数中只是进行了浅拷贝,没有对临时对象进行深拷贝,提高了性能。
在测试程序中getObj()的返回值就是一个将亡值
,也就是说是一个右值
,在进行赋值操作的时候如果=
右边是一个右值,那么移动构造函数就会被调用。
移动构造中使用了右值引用,会将临时对象中的堆内存地址的所有权转移给对象t
,这块内存被成功续命,因此在t对象中还可以继续使用这块内存。
对于需要动态申请大量资源的类,
应该设计移动构造函数,以提高程序效率
。需要注意的是,我们一般在提供移动构造函数的同时,也会提供常量左值引用的拷贝构造函数,以保证移动不成还可以使用拷贝构造函数。
13.3 && 的特性
在C++中,并不是所有情况下 &&
都代表是一个右值引用,具体的场景体现在模板和自动类型推导中,如果是模板参数需要指定为T&&
,如果是自动类型推导需要指定为auto &&
,在这两种场景下&&被称作未定的引用类型
。另外还有一点需要额外注意const T&&表示一个右值引用,不是未定引用类型。
先来看第一个例子,在函数模板中使用&&:
template<typename T>
void f(T&& param);
void f1(const T&& param);
f(10);
int x = 10;
f(x);
f1(x); // error, x是左值
f1(10); // ok, 10是右值
在上面的例子中函数模板进行了自动类型推导,需要通过传入的实参来确定参数param的实际类型。
- 第4行中,对于
f(10)
来说传入的实参10是右值,因此T&&
表示右值引用 - 第6行中,对于
f(x)
来说传入的实参是x是左值,因此T&&
表示左值引用 - 第7行中,
f1(x)
的参数是const T&&
不是未定引用类型,不需要推导,本身就表示一个右值引用
再来看第二个例子:
int main()
{
int x = 520, y = 1314;
auto&& v1 = x;
auto&& v2 = 250;
decltype(x)&& v3 = y; // error
cout << "v1: " << v1 << ", v2: " << v2 << endl;
return 0;
};
- 第4行中
auto&&
表示一个整形的左值引用 - 第5行中
auto&&
表示一个整形的右值引用 - 第6行中
decltype(x)&&
等价于int&&
是一个右值引用不是未定引用类型,y是一个左值,不能使用左值初始化一个右值引用类型。
由于上述代码中存在T&&
或者auto&&
这种未定引用类型,当它作为参数时,有可能被一个右值引用初始化,也有可能被一个左值引用初始化
,在进行类型推导时右值引用类型(&&)会发生变化,这种变化被称为引用折叠
。在C++11中引用折叠的规则如下:
通过右值推导 T&& 或者 auto&& 得到的是一个右值引用类型
通过非右值(右值引用、左值、左值引用、常量右值引用、常量左值引用)推导 T&& 或者 auto&& 得到的是一个左值引用类型
int&& a1 = 5;
auto&& bb = a1;
auto&& bb1 = 5;
int a2 = 5;
int &a3 = a2;
auto&& cc = a3;
auto&& cc1 = a2;
const int& s1 = 100;
const int&& s2 = 100;
auto&& dd = s1;
auto&& ee = s2;
const auto&& x = 5;
- 第2行:
a1
为右值引用,推导出的bb
为左值引用
类型 - 第3行:
5
为右值,推导出的bb1
为右值引用
类型 - 第7行:
a3
为左值引用,推导出的cc
为左值引用
类型 - 第8行:
a2
为左值,推导出的cc1
为左值引用
类型 - 第12行:
s1
为常量左值引用,推导出的dd
为常量左值引用
类型 - 第13行:
s2
为常量右值引用,推导出的ee
为常量左值引用
类型 - 第15行:
x
为右值引用,不需要推导,只能通过右值初始化
再看最后一个例子,代码如下:
#include <iostream>
using namespace std;
void printValue(int &i)
{
cout << "l-value: " << i << endl;
}
void printValue(int &&i)
{
cout << "r-value: " << i << endl;
}
void forward(int &&k)
{
printValue(k);
}
int main()
{
int i = 520;
printValue(i);
printValue(1314);
forward(250);
return 0;
};
测试代码输出的结果如下:
l-value: 520
r-value: 1314
l-value: 250
根据测试代码可以得知,编译器会根据传入的参数的类型(左值还是右值)调用对应的重置函数(printValue)
,函数forward()
接收的是一个右值,但是在这个函数中调用函数printValue()
时,参数k变成了一个命名对象,编译器会将其当做左值来处理
。
最后总结一下关于&&
的使用:
左值和右值是独立于他们的类型的,右值引用类型可能是左值也可能是右值。
编译器会将已命名的右值引用视为左值,将未命名的右值引用视为右值。
auto&&或者函数参数类型自动推导的T&&是一个未定的引用类型,它可能是左值引用也可能是右值引用类型,这取决于初始化的值类型(上面有例子)。
通过右值推导 T&& 或者 auto&& 得到的是一个右值引用类型,其余都是左值引用类型。
14.转移和完美转发
14.1 move转移
在C++11添加了右值引用,并且不能使用左值初始化右值引用,如果想要使用左值初始化一个右值引用需要借助std::move()函数,使用std::move方法可以将左值转换为右值。使用这个函数并不能移动任何东西,而是和移动构造函数一样都具有移动语义,将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存拷贝。
从实现上讲,std::move
基本等同于一个类型转换:static_cast<T&&>(lvalue);
,函数原型如下:
template<class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) _NOEXCEPT
{ // forward _Arg as movable
return (static_cast<remove_reference_t<_Ty>&&>(_Arg));
}
使用方法如下:
class Test
{
public:
Test(){}
......
}
int main()
{
Test t;
Test && v1 = t; // error
Test && v2 = move(t); // ok move()函数本身返回一个右值引用临时表达式(其本身是右值),但是被具名化v2后,v2就变成一个左值
return 0;
}
- 在第4行中,使用
左值初始化右值引用,因此语法是错误的
- 在第5行中,使用
move()函数
将左值转换为了右值,这样就可以初始化右值引用了。
假设一个临时容器很大,并且需要将这个容器赋值给另一个容器,就可以执行如下操作:
#include <iostream>
#include <list>
using namespace std;
int main()
{
// l1是左值(list对象),内部指针指向字符串链表
list<string> l1 = {"Hello", "World", "Good", "Morning"};
cout << "&l1\t" << &l1 << endl;
// move函数将左值标为右值引用并返回,使用右值引用初始化后 rref指向的还是l1对象
list<string> &&rref = std::move(l1); // rref是右值引用类型的变量
cout << "&rref\t" << &rref << endl; // 但此时 rref 是具名变量 → 属于左值(可以取地址)
// 并不会直接执行移动操作,真正的移动发生在构造/赋值时,list的移动构造函数被调用,转移资源所有权,返回一个新的对象(但是新对象内指向的数据还是之前那坨)
list<string> l2 = move(l1); // move()返回一个右值引用(因为是临时的,使用本身是右值)
cout << "&l2\t" << &l2 << endl;
return 0;
}
输出如下:
&l1 0x7ffc6d3e5d20 // 原始对象的地址
&rref 0x7ffc6d3e5d20 // 右值引用与原对象地址相同(引用即别名)
&l2 0x7ffc6d3e5d60 // 新对象的地址(通过移动构造创建)
+---------+ heap内存
| l1 | ------> [原链表数据] (移动后变为nullptr)
| (0x5d20)|
+---------+
↑
|
+---------+
| rref |
| (0x5d20)| --(始终指向l1,但移动后数据已转移)
+---------+
+---------+ heap内存
| l2 | ------> [原l1的链表数据]
| (0x5d60)|
+---------+
如果不使用std::move,拷贝的代价很大,性能较低。使用move几乎没有任何代价,只是转换了资源的所有权。如果一个对象内部有较大的堆内存或者动态数组时,使用move()就可以非常方便的进行数据所有权的转移。另外,我们也可以给类编写相应的移动构造函数(T::T(T&& another)
)和和具有移动语义的赋值函数(T&& T::operator=(T&& rhs)
),在构造对象和赋值的时候尽可能的进行资源的重复利用,因为它们都是接收一个右值引用参数。
14.2 forward完美转发
右值引用类型是独立于值的,一个右值引用作为函数参数的形参时,在函数内部转发该参数给内部其他函数时,它就变成一个左值,并不是原来的类型了。如果需要按照参数原来的类型转发到另一个函数,可以使用C++11提供的std::forward()函数
,该函数实现的功能称之为完美转发。
// 函数原型
template <class T> T&& forward (typename remove_reference<T>::type& t) noexcept;
template <class T> T&& forward (typename remove_reference<T>::type&& t) noexcept;
// 精简之后的样子
std::forward<T>(t);
当T为左值引用类型时,t将被转换为T类型的左值
当T不是左值引用类型时,t将被转换为T类型的右值
下面通过一个例子演示一下关于forward的使用:
#include <iostream>
using namespace std;
template<typename T>
void printValue(T& t)
{
cout << "l-value: " << t << endl;
}
template<typename T>
void printValue(T&& t)
{
cout << "r-value: " << t << endl;
}
template<typename T>
void testForward(T && v)
{
printValue(v);
printValue(move(v));
printValue(forward<T>(v));
cout << endl;
}
int main()
{
testForward(520);
int num = 1314;
testForward(num);
testForward(forward<int>(num));
testForward(forward<int&>(num));
testForward(forward<int&&>(num));
return 0;
}
测试代码打印的结果如下:
l-value: 520
r-value: 520
r-value: 520
l-value: 1314
r-value: 1314
l-value: 1314
l-value: 1314
r-value: 1314
r-value: 1314
l-value: 1314
r-value: 1314
l-value: 1314
l-value: 1314
r-value: 1314
r-value: 1314
testForward(520);
函数的形参为未定引用类型T&&
,实参为右值,初始化后被推导为一个右值引用printValue(v);
已命名的右值v
,编译器会视为左值处理,实参为 左值printValue(move(v));
已命名的右值编译器会视为左值处理,通过move
又将其转换为右值,实参为 右值printValue(forward<T>(v));forward
的模板参数为右值引用,最终得到一个右值,实参为 右值
testForward(num);
函数的形参为未定引用类型T&&
,实参为左值,初始化后被推导为一个左值引用printValue(v);
实参为 左值printValue(move(v));
通过move
将左值转换为右值,实参为 右值printValue(forward<T>(v));
forward
的模板参数为左值引用,最终得到一个左值引用,实参为 左值
testForward(forward<int>(num));
forward的模板类型为int,最终会得到一个右值,函数的形参为未定引用类型T&&
被右值初始化后得到一个右值引用类型printValue(v);
已命名的右值v,编译器会视为左值处理,实参为左值printValue(move(v));
已命名的右值编译器会视为左值处理,通过move又将其转换为右值,实参为右值printValue(forward<T>(v));
forward的模板参数为右值引用,最终得到一个右值,实参为右值
testForward(forward<int&>(num));
forward的模板类型为int&,最终会得到一个左值,函数的形参为未定引用类型T&&
被左值初始化后得到一个左值引用类型printValue(v);
实参为左值printValue(move(v));
通过move将左值转换为右值,实参为右值printValue(forward<T>(v));
forward的模板参数为左值引用,最终得到一个左值,实参为左值
testForward(forward<int&&>(num));
forward的模板类型为int&&,最终会得到一个右值,函数的形参为未定引用类型T&&
被右值初始化后得到一个右值引用类型printValue(v);
已命名的右值v,编译器会视为左值处理,实参为左值printValue(move(v));
已命名的右值编译器会视为左值处理,通过move又将其转换为右值,实参为右值printValue(forward<T>(v));
forward的模板参数为右值引用,最终得到一个右值,实参为右值
15.共享智能指针
在C++中没有垃圾回收机制,必须自己释放分配的内存,否则就会造成内存泄露。解决这个问题最有效的方法是使用智能指针(smart pointer)。智能指针是存储指向动态分配(堆)对象指针的类,用于生存期的控制,能够确保在离开指针所在作用域时,自动地销毁动态分配的对象,防止内存泄露。智能指针的核心实现技术是引用计数,每使用它(引用堆对象)一次,(控制块)内部引用计数加1,每析构一次内部的引用计数减1,减为0时,删除所指向的堆内存。
- 存储位置
- 控制块(Control Block):引用计数与相关元数据(如弱引用计数、删除器等)被封装在动态分配的控制块中,该控制块独立于被管理对象。
- 堆分配:控制块由智能指针在构造时通过
new
(或std::make_shared
)动态分配在堆上,确保其生命周期与所有关联的智能指针实例同步。
- 共享机制
- 共享控制块:当多个
std::shared_ptr
指向同一对象时,它们通过内部指针共享同一个控制块,引用计数通过原子操作保证线程安全。- 隐式关联:用户不直接操作控制块,智能指针的拷贝构造函数、赋值运算符等自动管理引用计数的增减。
C++11中提供了三种智能指针,使用这些智能指针时需要引用头文件<memory>
:
std::shared_ptr
:共享的智能指针std::unique_ptr
:独占的智能指针std::weak_ptr
:弱引用的智能指针,它不共享指针,不能操作资源,是用来监视shared_ptr的。
15.1 shared_ptr的初始化
共享智能指针是指多个智能指针可以同时管理同一块有效的内存,共享智能指针shared_ptr 是一个模板类
,如果要进行初始化有三种方式:通过构造函数
、std::make_shared辅助函数
以及reset方法
。共享智能指针对象初始化完毕之后就指向了要管理的那块堆内存,如果想要查看当前有多少个智能指针同时管理着这块内存可以使用共享智能指针提供的一个成员函数use_count
,函数原型如下:
// 管理当前对象的 shared_ptr 实例数量,或若无被管理对象则为 0。
long use_count() const noexcept;
a. 通过构造函数初始化
// shared_ptr<T> 类模板中,提供了多种实用的构造函数, 语法格式如下:
std::shared_ptr<T> 智能指针名字(创建堆内存);
测试代码如下:
#include <iostream>
#include <memory>
using namespace std;
int main()
{
// 使用智能指针管理一块 int 型的堆内存
shared_ptr<int> ptr1(new int(520));
cout << "ptr1管理的内存引用计数: " << ptr1.use_count() << endl;
// 使用智能指针管理一块字符数组对应的堆内存
shared_ptr<char> ptr2(new char[12]);
cout << "ptr2管理的内存引用计数: " << ptr2.use_count() << endl;
// 创建智能指针对象, 不管理任何内存
shared_ptr<int> ptr3;
cout << "ptr3管理的内存引用计数: " << ptr3.use_count() << endl;
// 创建智能指针对象, 初始化为空
shared_ptr<int> ptr4(nullptr);
cout << "ptr4管理的内存引用计数: " << ptr4.use_count() << endl;
return 0;
}
测试代码输出的结果如下:
ptr1管理的内存引用计数: 1
ptr2管理的内存引用计数: 1
ptr3管理的内存引用计数: 0
ptr4管理的内存引用计数: 0
如果智能指针被初始化了一块有效内存,那么这块内存的引用计数+1,如果智能指针没有被初始化或者被初始化为 nullptr空指针,引用计数不会+1。另外,不要使用一个原始指针初始化多个 shared_ptr。
- 控制块独立性:每个
shared_ptr
构造调用会生成新控制块(若参数是裸指针)。- 重复释放风险:多个控制块导致同一内存被多次释放。
- 安全实践:优先用
make_shared
或从已有shared_ptr
拷贝构造。
int *p = new int;
shared_ptr<int> p1(p);
shared_ptr<int> p2(p); // error, 编译不会报错, 运行会出错
b. 通过拷贝和移动构造函数初始化
当一个智能指针被初始化之后,就可以通过这个智能指针初始化其他新对象。在创建新对象的时候,对应的拷贝构造函数或者移动构造函数就被自动调用了。
#include <iostream>
#include <memory>
using namespace std;
int main()
{
// 使用智能指针管理一块 int 型的堆内存, 内部引用计数为 1
shared_ptr<int> ptr1(new int(520));
cout << "ptr1管理的内存引用计数: " << ptr1.use_count() << endl;
//调用拷贝构造函数
shared_ptr<int> ptr2(ptr1);
cout << "ptr2管理的内存引用计数: " << ptr2.use_count() << endl;
shared_ptr<int> ptr3 = ptr1;
cout << "ptr3管理的内存引用计数: " << ptr3.use_count() << endl;
//调用移动构造函数
shared_ptr<int> ptr4(std::move(ptr1));
cout << "ptr4管理的内存引用计数: " << ptr4.use_count() << endl;
std::shared_ptr<int> ptr5 = std::move(ptr2);
cout << "ptr5管理的内存引用计数: " << ptr5.use_count() << endl;
return 0;
}
测试程序输入的结果:
ptr1管理的内存引用计数: 1
ptr2管理的内存引用计数: 2
ptr3管理的内存引用计数: 3
ptr4管理的内存引用计数: 3
ptr5管理的内存引用计数: 3
如果使用拷贝的方式初始化共享智能指针对象,这两个对象会同时管理同一块堆内存,堆内存对应的引用计数也会增加;如果使用移动的方式初始智能指针对象,只是转让了内存的所有权,管理内存的对象并不会增加,因此内存的引用计数不会变化。
c. 通过 std::make_shared 初始化
通过C++提供的 std::make_shared()
就可以完成内存对象的创建并将其初始化给智能指针,函数原型如下:
template< class T, class... Args >
shared_ptr<T> make_shared( Args&&... args );
T:模板参数的数据类型
Args&&... args :要初始化的数据,如果是通过make_shared创建对象,需按照构造函数的参数列表指定
测试代码如下:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
class Test
{
public:
Test()
{
cout << "construct Test..." << endl;
}
Test(int x)
{
cout << "construct Test, x = " << x << endl;
}
Test(string str)
{
cout << "construct Test, str = " << str << endl;
}
~Test()
{
cout << "destruct Test ..." << endl;
}
};
int main()
{
// 使用智能指针管理一块 int 型的堆内存, 内部引用计数为 1
shared_ptr<int> ptr1 = make_shared<int>(520);
cout << "ptr1管理的内存引用计数: " << ptr1.use_count() << endl;
shared_ptr<Test> ptr2 = make_shared<Test>();
cout << "ptr2管理的内存引用计数: " << ptr2.use_count() << endl;
shared_ptr<Test> ptr3 = make_shared<Test>(520);
cout << "ptr3管理的内存引用计数: " << ptr3.use_count() << endl;
shared_ptr<Test> ptr4 = make_shared<Test>("我是要成为海贼王的男人!!!");
cout << "ptr4管理的内存引用计数: " << ptr4.use_count() << endl;
return 0;
}
使用
std::make_shared()模板函数
可以完成内存地址的创建,并将最终得到的内存地址传递给共享智能指针对象管理。如果要创建一个类对象,函数的()内部需要指定构造对象需要的参数,也就是类构造函数的参数。
d. 通过 reset方法初始化
共享智能指针类提供的std::shared_ptr::reset方法函数原型如下:
void reset() noexcept;
template< class Y >
void reset( Y* ptr );
template< class Y, class Deleter >
void reset( Y* ptr, Deleter d );
template< class Y, class Deleter, class Alloc >
void reset( Y* ptr, Deleter d, Alloc alloc );
ptr:指向要取得所有权的对象的指针
d:指向要取得所有权的对象的指针
aloc:内部存储所用的分配器
测试代码如下:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
int main()
{
// 使用智能指针管理一块 int 型的堆内存, 内部引用计数为 1
shared_ptr<int> ptr1 = make_shared<int>(520);
shared_ptr<int> ptr2 = ptr1;
shared_ptr<int> ptr3 = ptr1;
shared_ptr<int> ptr4 = ptr1;
cout << "ptr1管理的内存引用计数: " << ptr1.use_count() << endl;
cout << "ptr2管理的内存引用计数: " << ptr2.use_count() << endl;
cout << "ptr3管理的内存引用计数: " << ptr3.use_count() << endl;
cout << "ptr4管理的内存引用计数: " << ptr4.use_count() << endl;
ptr4.reset();
cout << "ptr1管理的内存引用计数: " << ptr1.use_count() << endl;
cout << "ptr2管理的内存引用计数: " << ptr2.use_count() << endl;
cout << "ptr3管理的内存引用计数: " << ptr3.use_count() << endl;
cout << "ptr4管理的内存引用计数: " << ptr4.use_count() << endl;
shared_ptr<int> ptr5;
ptr5.reset(new int(250));
cout << "ptr5管理的内存引用计数: " << ptr5.use_count() << endl;
return 0;
}
测试代码输入的结果:
ptr1管理的内存引用计数: 4
ptr2管理的内存引用计数: 4
ptr3管理的内存引用计数: 4
ptr4管理的内存引用计数: 4
ptr1管理的内存引用计数: 3
ptr2管理的内存引用计数: 3
ptr3管理的内存引用计数: 3
ptr4管理的内存引用计数: 0
ptr5管理的内存引用计数: 1
对于一个未初始化的共享智能指针,可以通过reset方法来初始化,当智能指针中有值的时候,调用reset会使引用计数减1。
e. 获取原始指针
通过智能指针可以管理一个普通变量或者对象的地址,此时原始地址就不可见了。当我们想要修改变量或者对象中的值的时候,就需要从智能指针对象中先取出数据的原始内存的地址再操作,解决方案是调用共享智能指针类提供的get()
方法,其函数原型如下:
T* get() const noexcept;
测试代码如下:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
int main()
{
int len = 128;
shared_ptr<char> ptr(new char[len]);
// 得到指针的原始地址
char* add = ptr.get();
memset(add, 0, len);
strcpy(add, "我是要成为海贼王的男人!!!");
cout << "string: " << add << endl;
shared_ptr<int> p(new int);
*p = 100;
cout << *p.get() << " " << *p << endl;
return 0;
}
综合案例:
#include <iostream>
#include <memory>
using namespace std;
class Test
{
public:
Test()
{
cout << "空参构造函数" << endl;
}
Test(int x)
{
cout << "int型构造函数 x = " << x << endl;
}
Test(string str)
{
cout << "string型构造函数 str = " << str << endl;
}
~Test()
{
cout << "Test 析构函数被调用" << endl;
}
int nember = 100;
};
int main()
{
system("chcp 65001");
// 1. 构造函数初始化
shared_ptr<int> ptr1(new int(5));
cout << "ptr1 use_count " << ptr1.use_count() << endl;
shared_ptr<Test> ptr2(new Test(5));
shared_ptr<Test> ptr3(new Test("hello world"));
// 2. 移动构造和拷贝构造函数初始化
shared_ptr<int> ptr4(move(ptr1)); // 移动
cout << "ptr1 use_count " << ptr1.use_count() << endl;
cout << "ptr4 use_count " << ptr4.use_count() << endl;
shared_ptr<Test> ptr5(ptr2); // 拷贝
cout << "ptr2 use_count " << ptr2.use_count() << endl;
cout << "ptr5 use_count " << ptr5.use_count() << endl;
// 3. 通过工厂函数std::make_shared 初始化
// 假如要创建共享指针的对象是类实例,在make_shared函数参数中填类构造函数参数
shared_ptr ptr6 = make_shared<Test>(10);
shared_ptr ptr7 = make_shared<Test>("args");
// 4. reset函数
ptr4.reset(); // 重置当前共享指针,引用计数-1,若只有该指针引用,则释放内存
cout << "ptr4 use_cout " << ptr4.use_count() << endl;
// 5. get函数
shared_ptr<Test> ptr8(new Test(10));
cout << "ptr8 use_cout " << ptr8.use_count() << endl;
// shared_ptr内部重载了 << 运算符调用get()
cout << "ptr8 = " << ptr8 << "\nptr8.get() = " << ptr8.get() << endl;
cout << ptr8.get()->nember << endl;
}
15.2 指定删除器
当智能指针管理的内存对应的引用计数变为0的时候,这块内存就会被智能指针析构掉了。另外,我们在初始化智能指针的时候也可以自己指定删除动作,这个删除操作对应的函数被称之为删除器,这个删除器函数本质是一个回调函数,我们只需要进行实现,其调用是由智能指针完成的。
#include <iostream>
#include <memory>
using namespace std;
// 自定义删除器函数,释放int型内存
void deleteIntPtr(int* p)
{
delete p;
cout << "int 型内存被释放了...";
}
int main()
{
shared_ptr<int> ptr(new int(250), deleteIntPtr);
return 0;
}
删除器函数也可以是lambda表达式
,因此代码也可以写成下面这样:
int main()
{
shared_ptr<int> ptr(new int(250), [](int* p) {delete p; });
return 0;
}
在上面的代码中,lambda表达式的参数就是智能指针管理的内存的地址(智能指针在调用删除器时传入的),有了这个地址之后函数体内部就可以完成删除操作了。
在C++11
中使用shared_ptr
管理动态数组时,需要指定删除器,因为std::shared_ptr的默认删除器不支持数组对象
(C++11以后支持),具体的处理代码如下:
int main()
{
shared_ptr<int> ptr(new int[10], [](int* p) {delete[]p; });
return 0;
}
在删除数组内存时,除了自己编写删除器,也可以使用C++提供的std::default_delete<T>()
函数作为删除器,这个函数内部的删除功能也是通过调用delete来实现的,要释放什么类型的内存就将模板类型T指定为什么类型即可。具体处理代码如下:
int main()
{
shared_ptr<int> ptr(new int[10], default_delete<int[]>());
return 0;
}
另外,我们还可以自己封装一个make_shared_array方法来让shared_ptr支持数组,代码如下:
#include <iostream>
#include <memory>
using namespace std;
template <typename T>
shared_ptr<T> make_share_array(size_t size)
{
// 返回匿名对象
return shared_ptr<T>(new T[size], default_delete<T[]>());
}
int main()
{
shared_ptr<int> ptr1 = make_share_array<int>(10);
cout << ptr1.use_count() << endl;
shared_ptr<char> ptr2 = make_share_array<char>(128);
cout << ptr2.use_count() << endl;
return 0;
}
16.独占的智能指针
16.1 unique_ptr的初始化
std::unique_ptr是一个独占型的智能指针
,它不允许其他的智能指针共享其内部的指针,可以通过它的构造函数初始化一个独占智能指针对象,但是不允许通过赋值将一个unique_ptr赋值给另一个unique_ptr。
// 通过构造函数初始化对象
unique_ptr<int> ptr1(new int(10));
// error, 不允许将一个unique_ptr赋值给另一个unique_ptr
unique_ptr<int> ptr2 = ptr1;
std::unique_ptr不允许复制,但是可以通过函数返回给其他的std::unique_ptr,还可以通过std::move来转移给其他的std::unique_ptr
,这样原始指针的所有权就被转移了,这个原始指针还是被独占的。
#include <iostream>
#include <memory>
using namespace std;
unique_ptr<int> func()
{
return unique_ptr<int>(new int(520));
}
int main()
{
// 通过构造函数初始化
unique_ptr<int> ptr1(new int(10));
// 通过转移所有权的方式初始化
unique_ptr<int> ptr2 = move(ptr1);
unique_ptr<int> ptr3 = func();
return 0;
}
unique_ptr
的移动构造函数签名:unique_ptr(unique_ptr&&)
unique_ptr独占智能指针类也有一个reset方法,函数原型如下:
void reset( pointer ptr = pointer() ) noexcept;
使用reset方法可以让unique_ptr解除对原始内存的管理,也可以用来初始化一个独占的智能指针。
#include <iostream>
#include <memory>
using namespace std;
class Test
{
public:
int member = 0;
Test()
{
cout << "constructed" << endl;
}
Test(int x):member(x)
{
cout << "constructed" << endl;
}
~Test()
{
cout << "destroy member= " << this->member << endl;
}
};
unique_ptr<int> func()
{
return unique_ptr<int>(new int(520));
}
int main()
{
unique_ptr<int> ptr1(new int(10));
// unique_ptr<int> ptr2 = ptr1; // error 独享资源
unique_ptr<int> ptr3(move(ptr1)); // 转移资源
unique_ptr<int> ptr4 = func(); // 将亡值 移动构造被调用
unique_ptr<Test> ptr5(new Test());
unique_ptr<Test> ptr6(new Test(10));
ptr5.reset();
ptr6.reset(new Test(20));
return 0;
}
ptr1.reset();解除对原始内存的管理
ptr2.reset(new int(250));重新指定智能指针管理的原始内存
输出结果为:
constructed
constructed
destroy member= 0
constructed
destroy member= 10
destroy member= 20
如果想要获取独占智能指针管理的原始地址,可以调用get()方法,函数原型如下:
pointer get() const noexcept;
16.2 删除器
unique_ptr指定删除器和shared_ptr指定删除器是有区别的,unique_ptr指定删除器的时候需要确定删除器的模板类型,所以不能像shared_ptr那样直接指定删除器,举例说明:
shared_ptr<int> ptr1(new int(10), [](int*p) {delete p; }); // ok
unique_ptr<int> ptr1(new int(10), [](int*p) {delete p; }); // error
int main()
{
using func_ptr = void(*)(int*);
unique_ptr<int, func_ptr> ptr1(new int(10), [](int*p) {delete p; });
return 0;
}
在上面的代码中第7行,func_ptr
的类型和lambda表达式
的类型是一致的。在lambda表达式没有捕获任何变量的情况下是正确的,如果捕获了变量,编译时则会报错:
int main()
{
using func_ptr = void(*)(int*);
unique_ptr<int, func_ptr> ptr1(new int(10), [&](int*p) {delete p; }); // error
return 0;
}
上面的代码中错误原因是这样的,在lambda表达式没有捕获任何外部变量时,可以直接转换为函数指针,一旦捕获了就无法转换了,如果想要让编译器成功通过编译,那么需要使用可调用对象包装器来处理声明的函数指针
:
int main()
{
using func_ptr = void(*)(int*);
unique_ptr<int, function<void(int*)>> ptr1(new int(10), [&](int*p) {delete p; });
return 0;
}
using func_ptr = void(*)(Test*);
// 不捕获时,直接定义函数指针
unique_ptr<Test, func_ptr> ptr7(new Test(5), [](Test *t){
cout << "自定义删除器" << endl;
delete t;
});
// 捕获外部变量时 需要使用可调用对象包装器来处理声明的函数指针
int x = 100;
unique_ptr<Test, function<void(Test*)>> ptr8(new Test(5), [x](Test *t){
cout << "自定义删除器 x" << x << endl;
delete t;
});
// unique_ptr可以使用数组初始化
unique_ptr<Test[]> ptr9(new Test[5]);
ptr9.reset();
17.weak_ptr
17.1 基本使用方法
弱引用智能指针std::weak_ptr
可以看做是shared_ptr的助手
,它不管理shared_ptr
内部的指针。std::weak_ptr
没有重载操作符*
和->
,因为它不共享指针,不能操作资源,所以它的构造不会增加引用计数,析构也不会减少引用计数,它的主要作用就是作为一个旁观者监视shared_ptr
中管理的资源是否存在。
a. 初始化
// 默认构造函数
constexpr weak_ptr() noexcept;
// 拷贝构造
weak_ptr (const weak_ptr& x) noexcept;
template <class U> weak_ptr (const weak_ptr<U>& x) noexcept;
// 通过shared_ptr对象构造
template <class U> weak_ptr (const shared_ptr<U>& x) noexcept;
在C++11中,weak_ptr
的初始化可以通过以上提供的构造函数来完成初始化,具体使用方法如下:
#include <iostream>
#include <memory>
using namespace std;
int main()
{
shared_ptr<int> sp(new int);
weak_ptr<int> wp1;
weak_ptr<int> wp2(wp1);
weak_ptr<int> wp3(sp);
weak_ptr<int> wp4;
wp4 = sp;
weak_ptr<int> wp5;
wp5 = wp3;
return 0;
}
weak_ptr<int> wp1;
构造了一个空weak_ptr
对象weak_ptr<int> wp2(wp1);
通过一个空weak_ptr
对象构造了另一个空weak_ptr
对象weak_ptr<int> wp3(sp);
通过一个shared_ptr对象构造了一个可用的weak_ptr实例对象wp4 = sp;
通过一个shared_ptr
对象构造了一个可用的weak_ptr
实例对象(这是一个隐式类型转换)wp5 = wp3;
通过一个weak_ptr
对象构造了一个可用的weak_ptr
实例对象
17.2 其他常用方法
a. use_count()
通过调用std::weak_ptr类提供的use_count()
方法可以获得当前所观测资源的引用计数
,函数原型如下:
// 函数返回所监测的资源的引用计数
long int use_count() const noexcept;
修改一下上面的测试程序,添加打印资源引用计数的代码:
#include <iostream>
#include <memory>
using namespace std;
int main()
{
shared_ptr<int> sp(new int);
weak_ptr<int> wp1;
weak_ptr<int> wp2(wp1);
weak_ptr<int> wp3(sp);
weak_ptr<int> wp4;
wp4 = sp;
weak_ptr<int> wp5;
wp5 = wp3;
cout << "use_count: " << endl;
cout << "wp1: " << wp1.use_count() << endl;
cout << "wp2: " << wp2.use_count() << endl;
cout << "wp3: " << wp3.use_count() << endl;
cout << "wp4: " << wp4.use_count() << endl;
cout << "wp5: " << wp5.use_count() << endl;
return 0;
}
测试程序输出的结果为:
use_count:
wp1: 0
wp2: 0
wp3: 1
wp4: 1
wp5: 1
通过打印的结果可以知道,虽然弱引用智能指针wp3
、wp4
、wp5
监测的资源是同一个,但是它的引用计数并没有发生任何的变化,也进一步证明了weak_ptr只是监测资源,并不管理资源。
b. expired()
通过调用std::weak_ptr类提供的expired()
方法来判断观测的资源是否已经被释放,函数原型如下:
// 返回true表示资源已经被释放, 返回false表示资源没有被释放
bool expired() const noexcept;
函数的使用方法如下:
#include <iostream>
#include <memory>
using namespace std;
int main()
{
shared_ptr<int> shared(new int(10));
weak_ptr<int> weak(shared);
cout << "1. weak " << (weak.expired() ? "is" : "is not") << " expired" << endl;
shared.reset();
cout << "2. weak " << (weak.expired() ? "is" : "is not") << " expired" << endl;
return 0;
}
测试代码输出的结果:
1. weak is not expired
2. weak is expired
weak_ptr
监测的就是shared_ptr
管理的资源,当共享智能指针调用shared.reset();
之后管理的资源被释放,因此weak.expired()
函数的结果返回true
,表示监测的资源已经不存在了。
c. lock()
通过调用std::weak_ptr
类提供的lock()
方法来获取管理所监测资源的shared_ptr对象,函数原型如下:
不在于
weak_ptr的构造函数
的参数shared_ptr
实例是否还存在,只要管理的资源的还在,就会创建一个新的shared_ptr实例
shared_ptr<element_type> lock() const noexcept;
函数的使用方法如下:
#include <iostream>
#include <memory>
using namespace std;
int main()
{
shared_ptr<int> sp1, sp2;
weak_ptr<int> wp;
sp1 = std::make_shared<int>(520);
wp = sp1;
sp2 = wp.lock();
cout << "use_count: " << wp.use_count() << endl;
sp1.reset();
cout << "use_count: " << wp.use_count() << endl;
sp1 = wp.lock();
cout << "use_count: " << wp.use_count() << endl;
cout << "*sp1: " << *sp1 << endl;
cout << "*sp2: " << *sp2 << endl;
return 0;
}
测试代码输出的结果为:
use_count: 2
use_count: 1
use_count: 2
*sp1: 520
*sp2: 520
sp2 = wp.lock();
通过调用lock()
方法得到一个用于管理weak_ptr
对象所监测的资源的共享智能指针对象,使用这个对象初始化sp2,此时所监测资源的引用计数为2sp1.reset();
共享智能指针sp1
被重置,weak_ptr
对象所监测的资源的引用计数减1sp1 = wp.lock(); sp1
重新被初始化,并且管理的还是weak_ptr
对象所监测的资源,因此引用计数加1- 共享智能指针对象
sp1和sp2
管理的是同一块内存,因此最终打印的内存中的结果是相同的,都是520
d. reset()
通过调用std::weak_ptr
类提供的reset()
方法来清空对象,使其不监测任何资源,函数原型如下:
void reset() noexcept;
函数的使用是非常简单的,示例代码如下:
#include <iostream>
#include <memory>
using namespace std;
int main()
{
shared_ptr<int> sp1, sp2;
weak_ptr<int> wp;
sp1 = std::make_shared<int>(520);
wp = sp1; // weak_ptr内部重载了=运算符,使用wp监视sp1管理的内存
sp2 = wp.lock(); // 返回一个指向管理内存的shraed_ptr
cout << "use_count: " << wp.use_count() << endl; // 所以现在有 sp1, sp2指向同一个内存
sp1.reset(); // 重置sp1
cout << "use_count: " << wp.use_count() << endl; // 1 : sp2
sp1 = wp.lock();
cout << "use_count: " << wp.use_count() << endl; // 获得一个新的指向管理内存的shraed_ptr并赋值
cout << "*sp1: " << *sp1 << endl;
cout << "*sp2: " << *sp2 << endl;
return 0;
}
测试代码输出的结果为:
1. wp is not expired
2. wp is expired
17.3 返回管理this的shared_ptr
如果在一个类中编写了一个函数,通过这个得到管理当前对象的共享智能指针,我们可能会写出如下代码:
#include <iostream>
#include <memory>
using namespace std;
struct Test
{
shared_ptr<Test> getSharedPtr()
{
return shared_ptr<Test>(this);
}
~Test()
{
cout << "class Test is disstruct ..." << endl;
}
};
int main()
{
shared_ptr<Test> sp1(new Test);
cout << "use_count: " << sp1.use_count() << endl;
shared_ptr<Test> sp2 = sp1->getSharedPtr(); // this指向sp1管理的同一个Test对象
cout << "use_count: " << sp1.use_count() << endl;
return 0;
}
执行上面的测试代码,运行中会出现异常,在终端还是能看到对应的日志输出:
use_count: 1
use_count: 1
class Test is disstruct ...
class Test is disstruct ...
通过输出的结果可以看到一个对象被析构了两次
,其原因是这样的:在这个例子中使用同一个指针this
构造了两个智能指针对象sp1
和sp2
,这二者之间是相互独立的(有各自的控制块)
,因为sp2并不是通过sp1初始化得到的实例对象。在离开作用域之后this将被构造的两个智能指针各自析构
,导致重复析构的错误。
- 双重控制块问题
ptr1
通过new Test
创建,生成第一个控制块,指向new出来的对象。ptr2
通过ptr1的getSharedPtr()
用this
创建,因为函数返回的是ptr1所管理的对象的裸指针,生成第二个独立的控制块。- 两个
shared_ptr
互相不知道对方的存在
enable_shared_from_this
核心实现原理1. 内部结构
enable_shared_from_this
基类模板中维护一个weak_ptr
成员:>template<class T> >class enable_shared_from_this { >private: mutable weak_ptr<T> __weak_this_; // 关键成员 >public: shared_ptr<T> shared_from_this() { return shared_ptr<T>(__weak_this_); // 通过 weak_ptr 构造 } >};
2. 控制块绑定流程
当首次通过
shared_ptr
管理对象时:>shared_ptr<Test> ptr1(new Test); // Test 继承 enable_shared_f
- 步骤 1:创建
shared_ptr
时,构造控制块(包含强/弱引用计数)- 步骤 2:通过
shared_ptr
构造函数初始化基类的__weak_this_:
>// shared_ptr 构造函数伪代码 >template<typename T> >shared_ptr(T* ptr) { // 创建控制块 ctrl_block = new ControlBlock(ptr); // 如果 T 继承 enable_shared_from_this if constexpr (继承自 enable_shared_from_this<T>) { ptr->__weak_this_ = *this; // 将 weak_ptr 绑定到控制块 } >}
3. shared_from_this() 的工作机制
>shared_ptr<Test> Test::getSharedPtr() { return shared_from_this(); >}
- 步骤 1:通过
__weak_this_.lock()
尝试提升为shared_ptr
- 步骤 2:若成功(对象未被释放),返回与原
shared_ptr
共享控制块的新shared_ptr
- 步骤 3:强引用计数 +1,确保对象生命周期正确延长
这个问题可以通过weak_ptr
来解决,通过weak_ptr
返回管理this资源的共享智能指针对象shared_ptr
。C++11中为我们提供了一个模板类叫做std::enable_shared_from_this<T>
,这个类中有一个方法叫做shared_from_this()
,通过这个方法可以返回一个共享智能指针,在函数的内部就是使用weak_ptr来监测this对象,并通过调用weak_ptr
的lock()
方法返回一个shared_ptr对象。
修改之后的代码为:
#include <iostream>
#include <memory>
using namespace std;
struct Test : public enable_shared_from_this<Test>
{
shared_ptr<Test> getSharedPtr()
{
return shared_from_this();
}
~Test()
{
cout << "class Test is disstruct ..." << endl;
}
};
int main()
{
shared_ptr<Test> sp1(new Test);
cout << "use_count: " << sp1.use_count() << endl;
shared_ptr<Test> sp2 = sp1->getSharedPtr();
cout << "use_count: " << sp1.use_count() << endl;
return 0;
}
测试代码输出的结果为:
use_count: 1
use_count: 2
class Test is disstruct ...
最后需要强调一个细节:在调用
enable_shared_from_this
类的shared_from_this()
方法之前,必须要先初始化函数内部weak_ptr对象
,否则该函数无法返回一个有效的shared_ptr对象(具体处理方法可以参考上面的示例代码第19行)。
17.4 解决循环引用问题
智能指针如果循环引用会导致内存泄露,比如下面的例子:
#include <iostream>
#include <memory>
using namespace std;
struct TA;
struct TB;
struct TA
{
shared_ptr<TB> bptr;
~TA()
{
cout << "class TA is disstruct ..." << endl;
}
};
struct TB
{
shared_ptr<TA> aptr;
~TB()
{
cout << "class TB is disstruct ..." << endl;
}
};
void testPtr()
{
shared_ptr<TA> ap(new TA);
shared_ptr<TB> bp(new TB);
cout << "TA object use_count: " << ap.use_count() << endl; // 1
cout << "TB object use_count: " << bp.use_count() << endl; // 1
ap->bptr = bp; // 发生了拷贝
bp->aptr = ap;
cout << "TA object use_count: " << ap.use_count() << endl; // 2
cout << "TB object use_count: " << bp.use_count() << endl; // 2
}
// bp离开作用域后,bp指向的(TB)引用计数-1 = 1
// ap离开作用域
int main()
{
testPtr();
return 0;
}
测试程序输出的结果如下:
TA object use_count: 1
TB object use_count: 1
TA object use_count: 2
TB object use_count: 2
在测试程序中,共享智能指针ap
、bp
对TA
、TB
实例对象的引用计数变为2,在共享智能指针离开作用域之后引用计数只能减为1
,这种情况下不会去删除智能指针管理的内存,导致类TA
、TB
的实例对象不能被析构,最终造成内存泄露。通过使用weak_ptr
可以解决这个问题,只要将类TA
或者TB
的任意一个成员改为weak_ptr
,修改之后的代码如下:
#include <iostream>
#include <memory>
using namespace std;
struct TA;
struct TB;
struct TA
{
weak_ptr<TB> bptr;
~TA()
{
cout << "class TA is disstruct ..." << endl;
}
};
struct TB
{
shared_ptr<TA> aptr;
~TB()
{
cout << "class TB is disstruct ..." << endl;
}
};
void testPtr()
{
shared_ptr<TA> ap(new TA);
shared_ptr<TB> bp(new TB);
cout << "TA object use_count: " << ap.use_count() << endl;
cout << "TB object use_count: " << bp.use_count() << endl;
ap->bptr = bp;
bp->aptr = ap;
cout << "TA object use_count: " << ap.use_count() << endl;
cout << "TB object use_count: " << bp.use_count() << endl;
}
int main()
{
testPtr();
return 0;
}
程序输出的结果:
TA object use_count: 1
TB object use_count: 1
TA object use_count: 2
TB object use_count: 1
class TB is disstruct ...
class TA is disstruct ...
通过输出的结果可以看到类TA
或者TB
的对象被成功析构了。
上面程序中,在对类TA成员赋值时ap->bptr = bp;
由于bptr
是weak_ptr类型,这个赋值操作并不会增加引用计数,所以bp的引用计数仍然为1,在离开作用域之后bp的引用计数减为0,类TB的实例对象被析构。
在类TB的实例对象被析构的时候,内部的aptr
也被析构,其对TA
对象的管理解除,内存的引用计数减为1,当共享智能指针ap离开作用域之后,对TA对象的管理也解除了,内存的引用计数减为0,类TA的实例对象被析构。
18.final和override
18.1 final关键字
C++中增加了final关键字来限制某个类不能被继承,或者某个虚函数不能被重写
,和Java的final关键字的功能是类似的。如果使用final修饰函数
,只能修饰虚函数,并且要把final关键字放到类或者函数的后面。
如果使用final修饰函数,只能修饰虚函数,这样就能阻止子类重写父类的这个函数了
使用final关键字修饰过的类是不允许被继承的,也就是说这个类不能有派生类。
#include <iostream>
using namespace std;
class Father
{
public:
virtual void Print() = 0;
virtual void override_import();
};
class Son : public Father
{
public:
virtual void Print() final // ① 继承抽象类的派生类必须重写方法,否则继承抽象类属性
{
cout << "Son print" << endl;
}
void override_import_worng() override
{
cout << "重写了方法" << endl;
}
void override_import() override{
cout << "重写了方法" << endl;
}
};
class GrandChild final : public Son // 类使用final关键字修饰 -> 不允许被继承
{
// virtual void Print(){} // 无法重写final函数
};
// class Test : public GrandChild{}; // error
int main()
{
// Father father; // ② 抽象类(有纯虚函数的类)不允许创建实例
Son son;
son.Print();
return 0;
}
18.2 override关键字
override
关键字确保在派生类中声明的重写函数与基类的虚函数有相同的签名
,同时也明确表明将会重写基类的虚函数,这样就可以保证重写的虚函数的正确性,也提高了代码的可读性,和final一样这个关键字要写到方法的后面
。使用方法如下:
class Base
{
public:
virtual void test()
{
cout << "Base class...";
}
};
class Child : public Base
{
public:
void test() override
{
cout << "Child class...";
}
};
class GrandChild : public Child
{
public:
void test() override
{
cout << "Child class...";
}
};
上述代码中第13行和第22行就是显示指定了要重写父类的test()方法
,使用了override
关键字之后,假设在重写过程中因为误操作,写错了函数名或者函数参数或者返回值编译器都会提示语法错误,提高了程序的正确性,降低了出错的概率。
19.类成员的快速初始化
19.1 c++98的类成员初始化
在C++98中,支持了在**类声明中使用等号 = 加初始值
** 的方式,来初始化类中**静态成员常量
** 。这种声明方式我们也称之为”就地”
声明。而非静态成员变量的初始化则必须在构造函数中进行。
下面通过一段代码举例说明:
struct Base
{
Base() : a(250) {}
Base(int num) : a(num) {}
int a;
int b = 1;
static int c = 0;
static const double d = 3.14;
static const char* const e = "i am luffy";
const static int f = 0;
};
如果按照 c++98 标准来解读上面这段代码
,其中有这么几行语法是错误的:
第7行:类的
非静态成员,必须在构造函数中进行初始化
第8行:类的
静态成员,必须在类的外部进行初始化
第9行:类的静态常量成员,
但不是整形或者枚举,无法通过编译
第10行:类的静态常量成员,
但不是整形或者枚举,无法通过编译
第8、9、10行的变量初始化方式是一样的,都是在类的外部
int Base::c = 110;
const double Base::d = 3.14;
const char* const Base::e = "i am luffy";
19.2 c++11标准的类成员快速初始化
a. 初始化类的非静态成员
在进行类成员变量初始化的时候,C++11标准对于C++98做了补充,允许在定义类的时候在类内部直接对非静态成员变量进行初始化,在初始化的时候可以使用等号 = 也可以使用花括号 {} 。
class Test
{
private:
int a = 9;
int b = {5};
int c{12};
double array[4] = { 3.14, 3.15, 3.16, 3.17};
double array1[4] { 3.14, 3.15, 3.16, 3.17 };
string s1("hello"); // error
string s2{ "hello, world" };
};
可以看到如果使用花括号 {}的方式对类的非静态成员进行初始化,等号是可以省略不写的。
- 第9行:
错误,不能使用小括号() 初始化对象,应该使用花括号{}
b. 构造函数内部初始化和初始化列表
在C++11之前对于非静态的类成员变量我们除了在构造函数内部进行赋值,也可以在类的初始化列表中进行初始化(这种方式比在构造函数内部赋值效率高)。那么,如果同时在类内部对非静态成员变量就地初始化和在初始化列表中进行初始化会怎么样呢?下面来测试一下:
#include <iostream>
using namespace std;
struct Test
{
int x = 1;
int y = 2;
Test() : x(3), y(4){}
};
int main(int argc, char const *argv[])
{
Test t;
cout << "x=" << t.x << ", y=" << t.y << endl;
return 0;
}
最后输出结果:
x=3, y=4
我们可以从函数的打印输出中看到,在类内部就地初始化和初始化列表并不冲突(程序可以正常运行)
。程序员可以为同一成员变量既在类内部就地初始化,又在初始化列表中进行初始化,只不过初始化列表总是看起来后作用于非静态成员。也就是说,通过初始化列表指定的值会覆盖就地初始化时指定的值。
而构造函数体的赋值又会覆盖初始化列表
struct Test
{
int x = 1;
int y = 2;
Test() : x(3), y(4){
this->x = 5;
this->y = 6;
}
};
最后输出结果是:
x=5, y=6
20.超长整形longlong
相比于C++98标准,C++11整型的最大改变就是多了long long
。但事实上,long long 整型本来就离C++标准很近,早在1995年,long long 就被提议写入 C++98标准,却被C++标准委员会拒绝了。而后来,long long类型却进入了C99标准,而且也事实上也被很多编译器支持。于是辗转地,C++标准委员会又掉头决定将 long long纳入 C++11标准。
20.1 longlong类型
C++11 标准要求 long long 整型可以在不同平台上有不同的长度,但至少有64位
。long long 整型有两种∶
long long
- 对应类型的数值可以使用 LL (大写) 或者 ll (小写) 后缀
long long num1 = 123456789LL;
long long num2 = 123456789ll;
unsigned long long
- 对应类型的数值可以使用 ULL (大写) 或者 ull (小写) 或者 Ull、uLL (等大小写混合)后缀
unsigned long long num1 = 123456789ULL;
unsigned long long num2 = 123456789ull;
unsigned long long num3 = 123456789uLL;
unsigned long long num4 = 123456789Ull;
事实上在C++11中还有一些类型与以上两种类型是等价的:
对于有符号类型的 long long和以下三种类型等价
long long int
signed long long
signed long long int
对于无符号类型的unsigned long long 和unsigned long long int是等价的
同其他的整型一样,要了解平台上 long long大小的方法就是查看<climits>
(或<limits. h>
)中的宏与long long整 型相关的一共有3个:
LLONG_MIN
- 最小的long long值LLONG_MAX
- 最大的long long 值ULLONG MAX
- 最大的 unsigned long long 值
测试代码如下:
#include <iostream>
using namespace std;
int main()
{
long long max = LLONG_MAX;
long long min = LLONG_MIN;
unsigned long long ullMax = ULLONG_MAX;
cout << "Max Long Long value: " << max << endl
<< "Min Long Long value: " << min << endl
<< "Max unsigned Long Long value: " << ullMax << endl;
return 0;
}
程序输出结果:
Max Long Long value: 9223372036854775807
Min Long Long value: -9223372036854775808
Max unsigned Long Long value: 18446744073709551615
20.2 扩展的整形
在C++11中一共只定义了以下5种标准的有符号整型:
signed char
short int
int
long int
long long int
标准同时规定,每一种有符号整型都有一种对应的无符号整数版本,且有符号整型与其对应的无符号整型具有相同的存储空间大小。比如与 signed int对应的无符号版本的整型是 unsigned int。
当我们在C++中处理数据的时候,如果参与运算的数据或者传递的参数类型不匹配,整型间会发生隐式的转换
,这种过程通常被称为整型的提升
。比如如下表达式∶
(int)num1 + (long long)num2
关于这种整形提升的隐式转换遵循如下原则
:
- 长度越大的整型等级越高,比如 long long int 的等级会高于int。
- 长度相同的情况下,标准整型的等级高于扩展类型,比如 long long int 和 int64 如果 都是64 位长度,则long long int类型的等级更高。
- 相同大小的有符号类型和无符号类型的等级相同,long long int 和unsigned longlong int的等级就相同。
- 转换过程中,低等级整型需要转换为高等级整型,有符号的需要转换为无符号整形。
21.数值类型和字符串之间的转换
21.1 数值转成字符串
使用to_string()
方法可以非常方便地将各种数值类型转换为字符串类型,这是一个重载函,函数声明位于头文件<string>
中,函数原型如下:
// 头文件 <string>
string to_string (int val);
string to_string (long val);
string to_string (long long val);
string to_string (unsigned val);
string to_string (unsigned long val);
string to_string (unsigned long long val);
string to_string (float val);
string to_string (double val);
string to_string (long double val);
关于函数的使用是非常简单的,示例代码如下:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string pi = "pi is " + to_string(3.1415926);
string love = "love is " + to_string(5.20 + 13.14);
cout << pi << endl;
cout << love << endl;
return 0;
}
21.2 字符串转换成数值
由于C++中的数值类型包括整形
和浮点型
,因此针对于不同的类型提供了不同的函数,通过调用这些函数可以将字符串类型转换为对应的数值类型。
// 定义于头文件 <string>
int stoi( const std::string& str, std::size_t* pos = 0, int base = 10 );
long stol( const std::string& str, std::size_t* pos = 0, int base = 10 );
long long stoll( const std::string& str, std::size_t* pos = 0, int base = 10 );
unsigned long stoul( const std::string& str, std::size_t* pos = 0, int base = 10 );
unsigned long long stoull( const std::string& str, std::size_t* pos = 0, int base = 10 );
float stof( const std::string& str, std::size_t* pos = 0 );
double stod( const std::string& str, std::size_t* pos = 0 );
long double stold( const std::string& str, std::size_t* pos = 0 );
str
:要转换的字符串pos
:传出参数, 记录从哪个字符开始无法继续进行解析, 比如:123abc
, 传出的位置为3
base
:若 base 为0
,则自动检测数值进制:若前缀为0
,则为八进制,若前缀为0x 或 0X
,则为十六进制,否则为十进制。
这些函数虽然都有多个参数,但是除去第一个参数外其他都有默认值,一般情况下使用默认值就能满足需求。关于函数的使用也给大家提供了一个例子,示例代码如下:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string str1 = "45";
string str2 = "3.14159";
string str3 = "9527 with words";
string str4 = "words and 2";
int myint1 = std::stoi(str1);
float myint2 = std::stof(str2);
int myint3 = std::stoi(str3);
// 错误: 'std::invalid_argument'
// int myint4 = std::stoi(str4);
cout << "std::stoi(\"" << str1 << "\") is " << myint1 << endl;
cout << "std::stof(\"" << str2 << "\") is " << myint2 << endl;
cout << "std::stoi(\"" << str3 << "\") is " << myint3 << endl;
// cout << "std::stoi(\"" << str4 << "\") is " << myint4 << endl;
}
示例代码输入的结果如下:
std::stoi("45") is 45
std::stof("3.14159") is 3.14159
std::stoi("9527 with words") is 9527
从上述测试程序可以得出这样的结论,在C++11提供的这些转换函数将字符串转换为数值的过程中:
如果字符串中所有字符都是数值类型,整个字符串会被转换为对应的数值,并通过返回值返回
如果字符串的前半部分字符是数值类型,后半部不是,那么前半部分会被转换为对应的数值,并通过返回值返回
如果字符第一个字符不是数值类型转换失败
22.静态断言
22.1 断言
断言(assertion)是一种编程中常用的手段。
在通常情况下,断言就是将一个返回值总是需要为真的判断表达式放在语句中,用于排除在设计的逻辑上不应该产生的情况。
比如:一个函数总需要输入在一定的范围内的参数,那么程序员就可以对该参数使用断言,以迫使在该参数发生异常的时候程序退出,从而避免程序陷入逻辑的混乱。
从一些意义上讲,断言并不是正常程序所必需的,不过对于程序调试来说,通常断言能够帮助程序开发者快速定位那些违反了某些前提条件的程序错误。
如果我们要在C++程序中使用断言,需要在程序中包含头文件<cassert>
或<assert.h>
,头文件中为我们提供了 assert 宏,用于在运行时进行断言。举例说明:
#include <iostream>
#include <cassert>
using namespace std;
// 创建一个指定大小的 char 类型数组
char* createArray(int size)
{
// 通过断言判断数组大小是否大于0
assert(size > 0); // 必须大于0, 否则程序中断
char* array = new char[size];
return array;
}
int main()
{
char* buf = createArray(0);
// 此处使用的是vs提供的安全函数, 也可以使用 strcpy
strcpy_s(buf, 16, "hello, world!");
cout << "buf = " << buf << endl;
delete[]buf;
return 0;
}
在程序的第9行,使用了断言assert(expression)
,这是一个宏,它的参数是一个表达式
,这个表达式通常返回一个布尔类型的值,并且要求表达式必须为 true 程序才能继续向下执行,否则会直接中断。
- 如果 createArray参数大于0,程序在16行正常运行直到结束
- 如果 createArray参数小于等于0,程序运行到16行直接退出,会看到如下的提示信息:
Assertion failed: size > 0, file .\32_断言.cpp, line 7
22.2 静态断言
在上面的例子中我们使用了断言 assert。但assert是一个运行时断言,也就是说它只有在程序运行时才能起作用
。这意味着不运行程序我们将无法得知某些条件是否是成立的。
比如:我们想知道当前是32位还是64位平台,对于这个需求我们应该是在程序运行之前就应该得到结果,如果使用断言显然是无法做到的,对于这种情况我们就需要使用C++11提供的静态断言了。
静态断言 static_assert
,所谓静态就是在编译时就能够进行检查的断言
,使用时不需要引用头文件。静态断言的另一个好处是,可以自定义违反断言时的错误提示信息。静态断言使用起来非常简单,它接收两个参数:
- 参数1:
断言表达式,这个表达式通常需要返回一个 bool值
- 参数2:
警告信息,它通常就是一段字符串,在违反断言(表达式为false)时提示该信息
由于基于VS计算的字节大小和理论值有出入,下面程序基于64位Linux进行测试,使用静态断言验证当前操作系统是否是32位:
// assert.cpp
#include <iostream>
using namespace std;
int main()
{
// 字体原因看起来是一个=, 其实这是两个=
static_assert(sizeof(long) == 4, "错误, 不是32位平台...");
cout << "64bit Linux 指针大小: " << sizeof(char*) << endl;
cout << "64bit Linux long 大小: " << sizeof(long) <<endl;
return 0;
}
通过g++编译程序:
$ g++ assert.cpp -std=c++11
assert.cpp: In function ‘int main()’:
assert.cpp:6:5: error: static assertion failed: 错误, 不是32位平台...
static_assert(sizeof(long) == 4, "错误, 不是32位平台...");
由于使用的Linux是64位的,因此在编译阶段静态断言检测条件失败,提示的错误信息就是我们给静态断言指定的第二个参数对应的那个字符串。
注意事项:
由于静态断言的表达式是在编译阶段进行检测,所以在它的表达式中不能出现变量,也就是说这个表达式必须是常量表达式。
22.3 不同系统间数据类型对比
数据类型 | 说明 | 32位字节数 | 64位字节数 | 取值范围 |
---|---|---|---|---|
bool | 布尔型 | 1 | 1 | true, false |
char | 字符型 | 1 | 1 | -128 ~ 127 |
unsigned char | 无符号字符型 | 1 | 1 | 0 ~ 255 |
short | 短整型 | 2 | 2 | -32768 ~ 32767 |
unsigned short | 无符号短整型 | 2 | 2 | 0 ~ 65535 |
int | 整型 | 4 | 4 | -2147483648 ~ 2147483647 |
unsigned int | 无符号整型 | 4 | 4 | 0 ~ 4294967295 |
long | 长整型 | 4 | 8 | - |
unsigned long | 无符号长整型 | 4 | 8 | - |
unsigned long long | 无符号超长整型 | 至少8 | 至少8 | 8字节取值范围: 0 ~ 2^64 |
float | 单精度浮点数 | 4 | 4 | 范围-2^128 ~ 2^128 精度为6~7位有效数字 |
double | 双精度浮点数 | 8 | 8 | 范围-2^1024 ~ 2^1024 精度为15~16位 |
long double | 扩展精度浮点数 | 8 | 8 | 范围-2^1024 ~ 2^1024 精度为15~16位 |
* | 地址(指针) | 4 | 8 | - |
23.noexcept
23.1 异常
c++和其他很多编程语言一样,都有异常处理设计。异常通常用于处理逻辑上可能发生的错误
,在C++98中为我们提供了一套完善的异常处理机制,我们可以直接在程序中将各种类型的异常抛出,从而强制终止程序的运行。
基本语法
关于异常的基本语法如下:
#include <iostream>
#include <stdexcept> // 包含标准异常类
using namespace std;
// 自定义除0异常类
class DivisionByZeroException : public runtime_error {
public:
DivisionByZeroException() : runtime_error("Division by zero") {}
};
int main() {
try {
int numerator, denominator;
cout << "输入分子: ";
cin >> numerator;
cout << "输入分母: ";
cin >> denominator;
if (denominator == 0) {
throw DivisionByZeroException(); // 抛出自定义异常
}
double result = static_cast<double>(numerator) / denominator;
cout << "Result: " << result << endl;
}
catch (const DivisionByZeroException& e) {
cerr << "Error: " << e.what() << endl;
}
catch (const invalid_argument& e) {
cerr << "Invalid argument: " << e.what() << endl;
}
catch (const exception& e) {
cerr << "General error: " << e.what() << endl;
}
return 0;
}
异常被抛出后,从进入try块起,到异常被抛掷前,这期间在栈上构造的所有对象,都会被自动析构。析构的顺序与构造的顺序相反。这一过程称为
栈的解旋。
异常接口声明
为了加强程序的可读性,可以在函数声明中列出可能抛出的所有异常类型,常用的有如下三种书写方式:
显示指定可以抛出的异常类型
:在throw()
中列举可能抛出的异常类型
struct MyException
{
MyException(string s) :msg(s) {}
string msg;
};
double divisionMethod(int a, int b) throw(MyException, int)
{
if (b == 0)
{
throw MyException("division by zero!!!");
// throw 100;
}
return a / b;
}
int main()
{
try
{
double v = divisionMethod(100, 0);
cout << "value: " << v << endl;
}
catch (int e)
{
cout << "catch except: " << e << endl;
}
catch (MyException e)
{
cout << "catch except: " << e.msg << endl;
}
return 0;
}
第7行代码在divisionMethod
函数后添加了throw异常接口声明
,其参数表示可以抛出的异常类型,分别为 int
和 MyException
类型。
- 抛出任意异常类型
struct MyException
{
MyException(string s) :msg(s) {}
string msg;
};
double divisionMethod(int a, int b)
{
if (b == 0)
{
throw MyException("division by zero!!!");
// throw 100;
}
return a / b;
}
第7行代码在 divisionMethod
没有添加异常接口声明,表示在该函数中可以抛出任意类型的异常
- 不抛出任何异常
struct MyException
{
MyException(string s) :msg(s) {}
string msg;
};
double divisionMethod(int a, int b) throw()
{
if (b == 0)
{
cout << "division by zero!!!" << endl;
}
return a / b;
}
第7行代码在 divisionMethod
函数后添加了throw
异常接口声明,其参数列表为空,表示该函数不允许抛出异常。
温馨提示:以上程序在VS上的测试结果和在Linux上基于G++的测试结果是不同的,如果违反了规则VS只会给出警告,而G++则会直接终止程序的运行。(PS:VS使用的不是G++编译器)
23.2 noexcept
上面的例子中,在 divisionMethod
函数声明之后,我们定义了一个动态异常声明 throw(MyException, int)
,该声明指出了divisionMethod
可能抛出的异常的类型。事实上,该特性很少被使用,因此在C++11中被弃用了 ,而表示函数不会抛出异常的动态异常声明 throw()
也被新的 noexcept
异常声明所取代。
noexcept 形如其名,表示其修饰的函数不会抛出异常 。不过与 throw()
动态异常声明不同的是,在 C++11 中如果 noexcept 修饰的函数抛出了异常,编译器可以选择直接调用 std::terminate() 函数来终止程序的运行,这比基于异常机制的 throw() 在效率上会高一些
。这是因为异常机制会带来一些额外开销,比如函数抛出异常,会导致函数栈被依次地展开(栈解旋),并自动调用析构函数释放栈上的所有对象。
因此对于不会抛出异常的函数我们可以这样写:
double divisionMethod(int a, int b) noexcept
{
if (b == 0)
{
cout << "division by zero!!!" << endl;
return -1;
}
return a / b;
}
从语法上讲,noexcept
修饰符有两种形式:
简单地在函数声明后加上 noexcept 关键字
可以接受一个常量表达式作为参数,如下所示∶
double divisionMethod(int a, int b) noexcept(常量表达式);
- 常量表达式的结果会被转换成一个bool类型的值:
- 值为 true,表示函数不会抛出异常
- 值为 false,表示有可能抛出异常这里
- 不带常量表达式的noexcept相当于声明了
noexcept(true)
,即不会抛出异常。
24.POD类型
要理解什么是POD
,推荐以下链接
普通、标准布局、POD 和文本类型 | Microsoft Learn
24.1 POD类型
POD是英文中 Plain Old Data
的缩写,翻译过来就是普通的旧数据
。POD在C++中是非常重要的一个概念,通常用于说明一个类型的属性,尤其是用户自定义类型的属性。
POD属性在 C++11 中往往又是构建其他 C++ 概念的基础,事实上,在 C++11 标准中,POD出现的概率相当高。因此学习C++,尤其是在 C++11中,了解POD的概念是非常必要的。
- Plain :表示是个普通的类型
- Old :体现了其与C的兼容性,支持标准C函数
在C++11中将POD划分为两个基本概念的合集,即∶平凡的(trivial)
和标准布局的(standard layout)
。
24.2 “平凡”类型
一个平凡的类或者结构体应该符合以下几点要求:
- 拥有平凡的
默认构造函数(trivial constructor)和析构函数(trivial destructor)
。
通常情况下,不定义类的构造函数
,编译器就会为我们生成一个平凡的默认构造函数
。
// 使用默认的构造函数
class Test {};
一旦定义了构造函数
,即使构造函数不包含参数,函数体里也没有任何的代码,那么该构造函数也不再是"平凡"的
。
class Test1
{
Test1(); // 自定义的构造函数, 非默认构造
};
关于析构函数也和上面列举的构造函数类似,一旦被定义就不平凡了。但是这也并非无药可救,使用**=default
**关键字可以显式地声明默认的构造函数,从而使得类型恢复“平凡化”。
拥有平凡的
拷贝构造函数(trivial copy constructor)
和移动构造函数(trivial move constructor)
。平凡的拷贝构造函数基本上等同于使用
memcpy
进行类型的构造。同平凡的默认构造函数一样,不声明拷贝构造函数的话,编译器会帮程序员自动地生成。
可以显式地使用
=default
声明默认拷贝构造函数。而平凡移动构造函数跟平凡的拷贝构造函数类似,只不过是用于移动语义。
拥有平凡的
拷贝赋值运算符(trivial assignment operator)
和移动赋值运算符(trivial move operator)
。不包含虚函数以及虚基类。
- 类中使用
virtual
关键字修饰的函数叫做虚函数
class Base
{
public:
Base() {}
virtual void print() {}
};
- 虚基类是在
创建子类的时候在继承的基类
前加virtual
关键字修饰
class Base
{
public:
Base() {}
};
// 子类Child,虚基类:Base
class Child : virtual public Base
{
Child() {}
};
24.3 标准布局类型
标准布局类型主要主要指的是类
或者结构体
的结构或者组合方式。
标准布局类型的类应该符合以下五点定义,最重要的为前两条
:
- 所有非静态成员有相同 的访问权限
(public,private,protected)
。
class Base
{
public:
Base() {}
int a;
int b;
int c;
};
- 在类或者结构体继承时,满足以下两种情况之一∶
派生类中有非静态成员,基类中包含静态成员(或基类没有变量)。
基类有非静态成员,而派生类没有非静态成员。
struct Base { static int a;};
struct Child: public Base{ int b;}; // ok
struct Base1 { int a;};
struct Child1: public Base1{ static int c;}; // ok
struct Child2:public Base, public Base1 { static int d;); // ok
struct Child3:public Base1{ int d;}; // error
struct Child4:public Base1, public Child // error
{
static int num;
};
- 非静态成员只要同时出现在派生类和基类间,即不属于标准布局。
- 对于多重继承,一旦非静态成员出现在多个基类中,即使派生类中没有非静态成员变量,派生类也不属于标准布局。
子类中第一个非静态成员的类型与其基类不同。
此处基于G++编译器讲解,如果使用VS的编译器和G++编译器得到的结果是不一样的。
struct Parent{};
struct Child : public Parent
{
Parent p; // 子类的第一个非静态成员
int foo;
};
上面的例子中Child
不是一个标准布局类型,因为它的第一个非静态成员变量p和父类的类型相同,改成下面这样子类就变成了一个标准布局类型:
struct Parent{};
struct Child1 : public Parent
{
int foo; // 子类的第一个非静态成员
Parent p;
};
这条规则对于我们来说是比较特别的,这样规定的目的主要是是节约内存,提高数据的读取效率。对于上面的两个子类Child
和Child1
来说它们的内存结构是不一样的,在基类没有成员的情况下:
注意要求
- C++标准允许
标准布局类型(Child1)
派生类的第一个成员foo与空基类共享地址
,此时基类并没有占据任何的实际空间(可以节省一点数据) - 对于
子类Child
而言,如果子类的第一个成员仍然是基类类型,C++标准要求类型相同的对象它们的地址必须不同(基类地址不能和子类中的变量 p 类型相同
)
没有虚函数和虚基类。
所有非静态数据成员均符合标准布局类型,其基类也符合标准布局,这是一个递归的定义。
24.4 对 POD 类型的判断
如果我们想要判断某个数据类型是不是属于 POD 类型,可以使用C++11给我们提供的相关函数:
a. 对平凡类型判断
C++11提供的类模板叫做 is_trivial
,其定义如下:
template <class T> struct std::is_trivial;
std::is_trivial
的成员 value
可以用于判断T的类型是否是一个平凡的类型(value 函数返回值为布尔类型
)。除了类和结构体外,is_trivial
还可以对内置的标准类型数据(比如int、float都属于平凡类型)及数组类型(元素是平凡类型的数组总是平凡的)进行判断。
关于类型的判断,示例程序如下:
#include <iostream>
#include <type_traits>
using namespace std;
class A {};
class B { B() {} };
class C : B {};
class D { virtual void fn() {} };
class E : virtual public A { };
int main()
{
cout << std::boolalpha;
cout << "is_trivial:" << std::endl;
cout << "int: " << is_trivial<int>::value << endl;
cout << "A: " << is_trivial<A>::value << endl;
cout << "B: " << is_trivial<B>::value << endl;
cout << "C: " << is_trivial<C>::value << endl;
cout << "D: " << is_trivial<D>::value << endl;
cout << "E: " << is_trivial<E>::value << endl;
return 0;
}
输出的结果:
is_trivial:
int: true
A: true
B: false
C: false
D: false
E: false
- int :内置标准数据类型,属于 trivial 类型
- A :拥有默认的构造和析构函数,属于 trivial 类型
- B :自定义了构造函数,因此不属于 trivial 类型
- C :基类中自定义了构造函数,因此不属于 trivial 类型
- D :类成员函数中有虚函数,因此不属于 trivial 类型
- E :继承关系中有虚基类,因此不属于 trivial 类型
b. 对标准布局类型的判断
同样,在C++11中,我们可以使用模板类来帮助判断类型是否是一个标准布局的类型,其定义如下:
template <typename T> struct std::is_standard_layout;
通过 is_standard_layout
模板类的成员 value(is_standard_layout<T>∶∶value)
,我们可以在代码中打印出类型的标准布局属性,函数返回值为布尔类型。
关于类型的判断,示例程序如下:
// pod.cpp
#include <iostream>
#include <type_traits>
using namespace std;
struct A { };
struct B : A { int j; };
struct C
{
public:
int a;
private:
int c;
};
struct D1 { static int i; };
struct D2 { int i; };
struct E1 { static int i; };
struct E2 { int i; };
struct D : public D1, public E1 { int a; };
struct E : public D1, public E2 { int a; };
struct F : public D2, public E2 { static int a; };
struct G : public A
{
int foo;
A a;
};
struct H : public A
{
A a;
int foo;
};
int main()
{
cout << std::boolalpha;
cout << "is_standard_layout:" << std::endl;
cout << "A: " << is_standard_layout<A>::value << endl;
cout << "B: " << is_standard_layout<B>::value << endl;
cout << "C: " << is_standard_layout<C>::value << endl;
cout << "D: " << is_standard_layout<D>::value << endl;
cout << "D1: " << is_standard_layout<D1>::value << endl;
cout << "E: " << is_standard_layout<E>::value << endl;
cout << "F: " << is_standard_layout<F>::value << endl;
cout << "G: " << is_standard_layout<G>::value << endl;
cout << "H: " << is_standard_layout<H>::value << endl;
return 0;
}
VS2019输出的结果:
is_standard_layout:
A: true
B: true
C: false
D: true
D1: true
E: false
F: false
G: false
H: false
G++ 编译输出的结果:
is_standard_layout:
A: true
B: true
C: false
D: true
D1: true
E: false
F: false
G: true
H: false
关于输出的结果
- A :没有虚基类和虚函数,属于 standard_layout 类型
- B :没有虚基类和虚函数,属于 standard_layout 类型
- C :所有非静态成员访问权限不一致,不属于 standard_layout 类型
- D :基类和子类没有同时出现非静态成员变量,属于 standard_layout 类型
- D1 :没有虚基类和虚函数,属于 standard_layout 类型
- E :基类和子类中同时出现了非静态成员变量,不属于 standard_layout 类型
- F :多重继承中在基类里同时出现了非静态成员变量,不属于 standard_layout 类型
- G :使用的编译器不同,得到的结果也不同。
- H :子类中第一个非静态成员的类型与其基类类型不能相同,不属于 standard_layout 类型
24.5 总结
POD类型的数据能够预测到其内存布局
使用POD类型有如下好处:
- 字节赋值,代码中我们可以安全地使用
memset()
和memcpy()
对 POD类型进行初始化和拷贝等操作。 010101直接复制过来就能使用 - 提供对C内存布局兼容。C++程序可以与 C函数进行相互操作,因为POD类型的数据在C与C++间的操作总是安全的。
- 保证了静态初始化的安全有效。静态初始化在很多时候能够提高程序的性能,而POD类型的对象初始化往往更加简单。
25.显式默认设置的函数和已删除的函数
显式默认设置的函数和已删除的函数 | Microsoft Learn
25.1 类与默认函数
在C++中声明自定义的类,编译器会默认帮助程序员生成一些他们未自定义的成员函数。这样的函数版本被称为默认函数(特殊成员函数)
。这样的函数一共有六个,我们一起来看一下分别的名称和功能:
无参构造函数
:创建类对象拷贝构造函数
:拷贝类对象移动构造函数
:拷贝类对象(转移资源)拷贝赋值函数
:类对象赋值移动赋值函数
:类对象赋值析构函数
:销毁类对象
C++语法规则中,一旦程序员实现了这些函数的自定义版本,则编译器不会再为该类自动生成默认版本。
有时程序员会忘记上面提到的规则,最常见的是声明了带参数的构造,如果还需要无参构造函数,这时候必须定义出不带参数的版本。不过通过编译器的提示,这样的问题通常会得到更正。但更为严重的问题是,一旦声明了自定义版本的构造函数,则有可能导致我们定义的类型不再是POD类型,我们便不再能够享受POD类型为我们带来的便利。
对于上面提到的这些,我们无需过度担心,因为C++11非常贴心地为我们提供了解决方案,就是使用=default
。
25.2 =default
可以默认设置任何特殊成员函数 — 以显式声明特殊成员函数使用默认实现
、定义具有非公共访问限定符的特殊成员函数或恢复其他情况下被阻止其自动生成的特殊成员函数。通俗点来讲就是让编译器将特殊成员函数恢复默认实现。
使用 =defaut 指定的默认函数和类提供的默认函数是等价的
struct widget
{
widget()=default;
inline widget& operator=(const widget&);
};
inline widget& widget::operator=(const widget&)=default;
既可以在内部使用=default,也可以在外部指定
25.3 =delete
=delete
表示显示删除,显式删除可以避免用户使用一些不应该使用的类的成员函数
,使用这种方式可以有效的防止某些类型之间自动进行隐式类型转换产生的错误。可以删除特殊成员函数和普通成员函数以及非成员函数,以阻止定义或调用它们。 通过删除特殊成员函数,可以更简洁地阻止编译器生成不需要的特殊成员函数。 必须在声明函数时将其删除;
禁止使用默认生成的函数
class Base
{
public:
Base() = default;
Base(const Base& obj) = delete;
Base& operator= (const Base& obj) = delete;
};
int main()
{
Base b;
Base tmp1(b); // error 不允许使用默认的拷贝构造
Base tmp = b; // error 不允许使用默认的拷贝赋值
return 0;
}
禁止使用自定义函数
class Base
{
public:
Base(int num) : m_num(num) {}
Base(char c) = delete;
void print(char c) = delete;
void print()
{
cout << "num: " << m_num << endl;
}
void print(int num)
{
cout << "num: " << num << endl;
}
private:
int m_num;
};
int main()
{
Base b(97); // 'a' 对应的 acscii 值为97
Base b1('a'); // error
b.print();
b.print(97);
b.print('a'); // error
return 0;
}
- 第5行:禁用带 char类型参数的构造函数,
防止隐式类型转换(char转int)
- 第6行:禁止使用带char类型的自定义函数,
防止隐式类型转换(char转int)
- 第22行:对应的构造函数被禁用,因此无法使用该构造函数构造对象
- 第25行:对应的打印函数被禁用,因此无法给函数传递char类型参数
26.拓展的friend语法
friend关键字
在C++中是一个比较特别的存在。因为在大多数编程语言中是没有提供friend
关键字的,比如Java。friend关键字用于声明类的友元,友元可以无视类中成员的属性( public、protected 或是 private ),友元类或友元函数都可以访问,这就完全破坏了面向对象编程中封装性的概念。
但有的时候,friend关键字确实会让程序猿少写很多代码,因此 friend 还是在很多程序中被使用到。
26.1 语法改进
在 C++11 标准中对 friend关键字进行了一些改进,以保证其更加好用:
#include <iostream>
using namespace std;
// 类声明
class Tom;
// 定义别名
using Honey = Tom;
// 定义两个测试类
class Jack
{
// 声明友元
// friend class Tom; // C++98 标准语法
friend Tom; // C++11 标准语法
string name = "jack"; // 默认私有
void print() // 默认私有
{
cout << "my name is " << name << endl;
}
};
class Lucy
{
protected:
// 声明友元
// friend class Tom; // C++98 标准语法
friend Honey; // C++11 标准语法
string name = "lucy";
void print()
{
cout << "my name is " << name << endl;
}
};
class Tom
{
public:
void print()
{
// 通过类成员对象访问其私有成员
cout << "invoke Jack private member: " << jObj.name << endl;
cout << "invoke Jack private function: " << endl;
jObj.print();
cout << "invoke Lucy private member: " << lObj.name << endl;
cout << "invoke Lucy private function: " << endl;
lObj.print();
}
private:
string name = "tom";
Jack jObj;
Lucy lObj;
};
int main()
{
Tom t;
t.print();
return 0;
}
在上面的例子中Tom类
分别作为了Jack类和Lucy类的友元类
,然后在Tom类中定义了Jack类和Lucy类的对象jObj
和lObj
,这样我们就可以在Tom类中通过这两个类对象直接访问它们各自的私有或者受保护的成员变量或者成员函数了。
26.2 为类模板声明友元
虽然在C++11标准中对友元的改进不大,却会带来应用的变化 ——> 程序员可以为类模板声明友元
了,这在C++98中是无法做到的。使用方法如下:
class Tom;
template<typename T>
class Person
{
friend T;
};
int main()
{
Person<Tom> p;
Person<int> pp;
return 0;
}
- 第11行:Tom类是Person类的友元
- 第12行:
对于int类型的模板参数,友元声明被忽略(第6行)
27.枚举类型
27.1 枚举
枚举的基本使用
枚举类型是C及C++中一个基本的内置类型,不过也是一个有点奇怪
的类型。从枚举的本意上来讲,就是要定义一个类别,并穷举同一类别下的个体以供代码中使用。由于枚举来源于C,所以出于设计上的简单的目的,枚举值常常是对应到整型数值的一些名字,比如:
// 匿名枚举
enum {Red, Green, Blue};
// 有名枚举
enum Colors{Red, Green, Blue};
在枚举类型中的枚举值编译器会默认从0开始赋值,而后依次向下递增,也就是说Red=0,Green=1,Blue=2
。
枚举的缺陷
C/C++的enum有个很奇怪的设定,就是具名(有名字)的enum类型的名字,以及 enum 的成员的名字都是全局可见的。这与 C++中具名的 namespace、class/struct 及 union 必须通过名字::成员名的
方式访问相比是格格不入的,编码过程中一不小心程序员就容易遇到问题。比如∶
enum China {Shanghai, Dongjing, Beijing, Nanjing};
enum Japan {Dongjing, Daban, Hengbin, Fudao};
上面定义的两个枚举在编译的时候,编译器会报错,具体信息如下:
error C2365: “Dongjing”: 重定义;以前的定义是“枚举数”
错误的原因上面也提到了,在这两个具名的枚举中Dongjing是全局可见的,所有编译器就会提示其重定义了
。
另外,由于C中枚举被设计为常量数值的”别名”的本性,所以枚举的成员总是可以被隐式地转换为整型
,但是很多时候我们并不想这样。
27.2 强枚举类型
针对枚举的缺陷,C++11标准引入了一种新的枚举类型,即枚举类,又称强类型枚举(strong-typed enum)
。
声明强类型枚举非常简单,只需要在 enum 后加上关键字 class。比如∶
// 定义强类型枚举
enum class Colors{Red, Green, Blue};
强类型枚举具有以下几点优势∶
- 强作用域,强类型枚举成员的名称不会被输出到其父作用域空间。
- 强类型枚举只能是有名枚举,如果是匿名枚举会导致枚举值无法使用(因为没有作用域名称)。
- 转换限制,强类型枚举成员的值不可以与整型隐式地相互转换。
- 可以指定底层类型。
强类型枚举默认的底层类型为 int
,但也可以显式地指定底层类型, 具体方法为在枚举名称后面加上∶type
,其中 type 可以是除wchar_t
以外的任何整型
。
enum class Food : char
{
Apple,
Banana,
Pear
};
wchar_t 是什么?
双字节类型,或宽字符类型,是C/C++的一种扩展的存储方式,一般为16位或32位,所能表示的字符数远超char型。
主要用在国际化程序的实现中,但它不等同于 unicode 编码。unicode 编码的字符一般以wchar_t类型存储。
了解了强类型枚举的优势之后,我们再看一段程序:
enum class China { Shanghai, Dongjing, Beijing, Nanjing, };
enum class Japan:char { Dongjing, Daban, Hengbin, Fudao };
int main()
{
int m = Shanghai; // error
int n = China::Shanghai; // error
if ((int)China::Beijing >= 2)
{
cout << "ok!" << endl;
}
cout << "size1: " << sizeof(China::Dongjing) << endl;
cout << "size2: " << sizeof(Japan::Dongjing) << endl;
return 0;
}
- 第5行:该行的代码有两处错误
- 强类型枚举属于
强作用域类型
,不能直接使用,枚举值前必须加枚举类型 强类型枚举不会进行隐式类型转换
,因此枚举值不能直接给int行变量赋值(虽然强类型枚举的枚举值默认就是整形,但其不能作为整形使用)。
- 强类型枚举属于
- 第6行:语法错误,将强类型枚举值作为整形使用,此处不会进行隐式类型转换
- 第7行:语法正确,
强类型枚举值在和整数比较之前做了强制类型转换
。 - 第11行:打印的结果为4,强类型枚举底层类型值默认为
int
,因此占用的内存是4个字节
- 第12行:打印的结果为1,显示指定了强类型枚举值的类型为
char
,因此占用的内存大小为1个字节
,这样我们就可以节省更多的内存空间了。
对原有枚举类型的优化
相比于原来的枚举,强类型枚举更像是一个属于C++的枚举。但为了配合新的枚举类型,C++11还对原有枚举类型进行了扩展:
- 原有枚举类型的底层类型在默认情况下,仍然由编译器来具体指定实现。
但也可以跟强类型枚举类一样,显式地由程序员来指定。其指定的方式跟强类型枚举一样,都是枚举名称后面加上∶type,其中type 可以是除 wchar_t 以外的任何整型。
- 关于作用域,在C++11中,枚举成员的名字除了会自动输出到父作用域,也可以在枚举类型定义的作用域内有效。比如:
enum Colors : char { Red, Green, Blue };
int main()
{
Colors c1 = Green; // C++11以前的用法
Colors c2 = Colors::Green; // C++11的扩展语法
return 0;
}
C++11中对原有枚举类型的这两个扩展都保留了向后兼容性
,也方便了程序员在代码中同时操作两种枚举类型。此外,我们在声明强类型枚举的时候,也可以使用关键字enum struct
。实际上 enum struct
和 enum class
在语法上没有任何区别(enum class 的成员没有公有私有之分,也不会使用模板来支持泛化的声明 )。
28.非受限联合体
28.1 联合体
联合体又叫共用体,我将其称之为union
,它的使用方式和结构体类似,程序猿可以在联合体内部定义多种不同类型的数据成员,但是这些数据会共享同一块内存空间(也就是如果对多个数据成员同时赋值会发生数据的覆盖
)。在某些特定的场景下,通过这种特殊的数据结构我们就可以实现内存的复用,从而达到节省内存空间的目的。
union 排列在内存中(从概念上讲)
,如下图所示:
在C++11之前我们使用的联合体是有局限性的,主要有以下三点:
- 不允许联合体拥有
非POD类型
的成员 - 不允许联合体拥有静态成员
- 不允许联合体拥有引用类型的成员
在新的C++11标准中,取消了关于联合体对于数据成员类型的限定
,规定任何非引用
类型都可以成为联合体的数据成员,这样的联合体称之为非受限联合体(Unrestricted Union)
28.2 非受限联合体
静态类型的成员
对于非受限联合体来说,静态成员有两种分别是静态成员变量
和静态成员函数
,我们来看一下下面的代码:
union Test
{
int age;
long id;
// int& tmp = age; // error
static char c;
static int print()
{
cout << "c value: " << c << endl;
return 0;
}
};
char Test::c;
// char Test::c = 'a';
int main()
{
Test t;
Test t1;
t.c = 'b';
t1.c = 'c';
t1.age = 666;
cout << "t.c: " << t.c << endl;
cout << "t1.c: " << t1.c << endl;
cout << "t1.age: " << t1.age << endl;
cout << "t1.id: " << t1.id << endl;
t.print();
Test::print();
return 0;
}
- 第5行:语法错误,非受限联合体中不允许出现引用类型
- 第6行:非受限联合体中的静态成员变量
- 需要在
非受限联合体外部声明(第13行)或者初始化(第14行)之后才能使用
- 通过打印的结果可以发现18、19行的
t
和t1
对象共享这个静态成员变量。
- 需要在
- 第7行:非受限联合体中的静态成员函数
- 在静态函数print()只能
访问非受限联合体Test中的静态变量
,对于非静态成员变量(age、id)是无法访问的。 - 调用这个静态方法可以通过对象(第27行)也可以通过类名(第28行)实现。
- 在静态函数print()只能
- 第24、25、26行:通过打印的结果可以得出结论在
非受限联合体中静态成员变量和非静态成员变量使用的不是同一块内存。
非POD类型成员
在 C++11标准中会默认删除一些非受限联合体的默认函数。比如,非受限联合体有一个非 POD 的成员,而该非 POD成员类型拥有非平凡的构造函数
,那么非受限联合体的默认构造函数将被编译器删除
。其他的特殊成员函数,例如默认拷贝构造函数、拷贝赋值操作符以及析构函数等,也将遵从此规则。下面来举例说明:
union Student
{
int id;
string name;
};
int main()
{
Student s;
return 0;
}
编译程序会看到如下的错误提示:
warning C4624: “Student”: 已将析构函数隐式定义为“已删除”
error C2280: “Student::Student(void)”: 尝试引用已删除的函数
上面代码中的非受限联合体Student
中拥有一个非PDO类型
的成员string name
,string 类中有非平凡构造函数
,因此Student的构造函数被删除(通过警告信息可以得知它的析构函数也被删除了)导致对象无法被成功创建出来。解决这个问题的办法就是由程序猿自己为非受限联合体定义构造函数
,在定义构造函数的时候我们需要用到定位放置new操作
。
28.3 placement new
一般情况下,使用new申请空间时,是从系统的堆(heap)中分配空间
,申请所得的空间的位置是根据当时的内存的实际使用情况决定的。但是,在某些特殊情况下,可能需要在已分配的特定内存创建对象
,这种操作就叫做placement new 即定位放置 new
。
ClassName* ptr = new (定位的内存地址)ClassName;
我们看下面的示例程序:
#include <iostream>
using namespace std;
class Base
{
public:
Base() {}
~Base() {}
void print()
{
cout << "number value: " << number << endl;
}
private:
int number;
};
int main()
{
int n = 100;
Base* b = new (&n)Base;
b->print();
return 0;
}
程序运行输出的结果为:
number value: 100
在程序的第20行,使用定位放置的方式为指针b申请了一块内存
,也就是说此时指针 b 指向的内存地址和变量 n 对应的内存地址是同一块(栈内存)
,而在Base类中成员变量number的起始地址和Base对象的起始地址是相同的
,所以打印出 number 的值为100也就是整形变量 n 的值。
最后,给大家总结一下关于placement new的一些细节:
- 使用定位放置new操作,既可以在
栈(stack)
上生成对象,也可以在堆(heap)
上生成对象,这取决于定位时指定的内存地址是在堆还是在栈上。 - 从表面上看,定位放置new操作是申请空间,其
本质是利用已经申请好的空间,真正的申请空间的工作是在此之前完成的
。 - 使用定位放置 new 创建对象时会自动调用对应类的构造函数,但是由于对象的空间不会自动释放,如果需要
释放堆内存必须显式调用类的析构函数
。 - 使用定位放置new操作,我们可以反复动态申请到同一块堆内存,这样可以避免内存的重复创建销毁,从而提高程序的执行效率(比如网络通信中数据的接收和发送)。
自定义非受限联合体构造函数
掌握了placement new
的使用,我们通过一段程序来演示一下如果在非受限联合体中自定义构造函数:
class Base
{
public:
void setText(string str)
{
notes = str;
}
void print()
{
cout << "Base notes: " << notes << endl;
}
private:
string notes;
};
union Student
{
Student()
{
new (&name)string;
}
~Student() {}
int id;
Base tmp;
string name;
};
int main()
{
Student s;
s.name = "蒙奇·D·路飞";
s.tmp.setText("我是要成为海贼王的男人!");
s.tmp.print();
cout << "Student name: " << s.name << endl;
return 0;
}
程序打印的结果如下:
Base notes: 我是要成为海贼王的男人!
Student name: 我是要成为海贼王的男人!
我们在上面的程序里边给非受限制联合体显示的指定了构造函数和析构函数
,在程序的第31行需要创建一个非受限联合体对象,这时便调用了联合体内部的构造函数,在构造函数的第20行通过定位放置new的方式将构造出的对象地址定位到了联合体的成员string name的地址上了(但是构造出的是string对象,没看懂,可能是为了正确初始化非POD类型string?)
,这样联合体内部其他非静态成员也就可以访问这块地址了(通过输出的结果可以看到对联合体内的tmp对象赋值,会覆盖name对象中的数据)。
- 由于
tmp
和name
共享同一块内存,tmp
的notes
值覆盖了name
的值。
28.4 匿名的非受限联合体
一般情况下我们使用的非受限联合体都是具名的(有名字),但是我们也可以定义匿名的非受限联合体,一个比较实用的场景就是配合着类的定义使用。我们来设定一个场景:
木叶村要进行第99次人口普查,人员的登记方式如下:
- 学生只需要登记所在学校的编号
- 本村学生以外的人员需要登记其身份证号码
- 本村外来人员需要登记户口所在地 + 联系方式
// 外来人口信息
struct Foreigner
{
Foreigner(string s, string ph) : addr(s), phone(ph) {}
string addr;
string phone;
};
// 登记人口信息
class Person
{
public:
enum class Category : char {Student, Local, Foreign};
Person(int num) : number(num), type(Category::Student) {}
Person(string id) : idNum(id), type(Category::Local) {}
Person(string addr, string phone) : foreign(addr, phone), type(Category::Foreign) {}
~Person() {}
void print()
{
cout << "Person category: " << (int)type << endl;
switch (type)
{
case Category::Student:
cout << "Student school number: " << number << endl;
break;
case Category::Local:
cout << "Local people ID number: " << idNum << endl;
break;
case Category::Foreign:
cout << "Foreigner address: " << foreign.addr
<< ", phone: " << foreign.phone << endl;
break;
default:
break;
}
}
private:
Category type;
union
{
int number;
string idNum;
Foreigner foreign;
};
};
int main()
{
Person p1(9527);
Person p2("1101122022X");
Person p3("砂隐村村北", "1301810001");
p1.print();
p2.print();
p3.print();
return 0;
}
程序输出的结果:
Person category: 0
Student school number: 9527
Person category: 1
Local people ID number: 1101122022X
Person category: 2
Foreigner address: 砂隐村村北, phone: 1301810001
根据需求我们将木叶村的人口分为了三类并通过枚举记录了下来,在Person
类中添加了一个匿名的非受限联合体用来存储人口信息,仔细分析之后就会发现这种处理方式的优势非常明显:尽可能地节省了内存空间。
Person类可以直接访问匿名非受限联合体内部的数据成员。
- 不使用匿名非受限联合体申请的内存空间等于
number
、idNum
、foreign
三者内存之和。 - 使用匿名非受限联合体之后
number
、idNum
、foreign
三者共用同一块内存。
宏
宏(Macro)是 C 和 C++ 等编程语言中预处理器的一种功能,用于在编译之前对代码进行文本替换。宏可以用来定义常量、简化重复代码或者实现一些条件编译功能。宏通常使用 #define
指令来定义。
以下是一些宏的基本特点和使用方式:
常量宏:
常量宏可以用于定义一些常量值。例如:#define PI 3.14159
这种定义可以在代码中随处使用
PI
,编译器在编译时会将PI
替换为3.14159
。函数宏:
函数宏可以接受参数并进行替换,起到类似函数的效果。例如:#define SQUARE(x) ((x) * (x))
使用时,如果你写
SQUARE(5)
,编译器会将其替换为((5) * (5))
。条件编译:
宏还可以用于条件编译。通过#ifdef
、#ifndef
、#if
等指令,可以根据条件编译不同的代码块。例如:#ifdef DEBUG #define LOG(x) cout << x << endl; #else #define LOG(x) #endif
这里,只有在定义了
DEBUG
的情况下,LOG(x)
才会输出日志信息。优势和劣势:
- 优势:宏可以减少代码重复,提高可维护性,支持条件编译等功能。
- 劣势:宏在替换时没有类型检查,可能导致代码难以调试。此外,过度使用宏会使代码不易阅读和理解。
左值引用和右值引用
在C++中,左值引用(lvalue reference)
和右值引用(rvalue reference)
是两种不同的引用类型,用于处理不同类型的数据和场景。
左值引用(lvalue reference)
定义:
左值引用是C++中传统的引用类型,用法是 T&
,其中 T
是被引用的类型。
特点:
- 绑定左值:左值引用只能绑定到左值。左值是具有持久存储的表达式,可以出现在赋值操作符的左侧(可以取地址的变量)。
- 修改性:除非引用本身被声明为
const
,否则可以通过左值引用来修改被引用的对象。 - 语法:使用
&
符号声明。
示例:
int main() {
int a = 10;
int& ref_a = a; // 左值引用绑定到左值 a
ref_a = 20; // 修改 a 的值
std::cout << "a: " << a << std::endl; // 输出 20
return 0;
}
右值引用(rvalue reference)
定义:
右值引用是C++11引入的一种新的引用类型,用法是 T&&
,其中 T
是被引用的类型。
特点:
- 绑定右值:右值引用可以绑定到右值。右值是
临时对象或字面量
,不能出现在赋值操作符的左侧。 - 移动语义:右值引用的主要目的是实现移动语义,允许资源从一个对象转移到另一个对象,从而提高性能和减少不必要的复制。
- 语法:使用
&&
符号声明。
示例:
#include <iostream>
#include <utility>
int main() {
int&& ref_a = 10; // 右值引用绑定到右值 10(字面量)
std::cout << "ref_a: " << ref_a << std::endl; // 输出 10
int b = 20;
// 下面的代码会报错,因为右值引用不能绑定到左值
// int&& ref_b = b;
// 使用 std::move 将左值转换为右值
int&& ref_c = std::move(b);
std::cout << "ref_c: " << ref_c << std::endl; // 输出 20
std::cout << "b: " << b << std::endl; // 输出 20,但 b 的值可能被移动后变为未定义
return 0;
}
区别总结
特性 | 左值引用 (T& ) |
右值引用 (T&& ) |
---|---|---|
绑定对象 | 左值(持久存储的表达式) | 右值(临时对象或字面量) |
语法 | T& |
T&& |
移动语义 | 不支持 | 支持,用于资源转移 |
修改性 | 可以修改(除非是 const ) |
可以修改(除非是 const ) |
示例 | int& ref_a = a; |
int&& ref_b = 10; |
进一步理解
- 左值:可以出现在赋值操作符左侧的表达式。例如,变量
a
是左值。 - 右值:不能出现在赋值操作符左侧的表达式。例如,字面量
10
是右值。 - **
std::move
**:这是一个标准库函数,用于将左值转换为右值引用,从而允许资源的移动。
通过理解左值引用和右值引用,你可以更有效地利用C++11的特性来编写高性能的代码。
进程和线程的区别
- 进程:是指程序的一次执行过程,是操作系统进行资源分配的基本单位。每个进程都有独立的地址空间、内存、数据栈和其他辅助数据结构等资源
- 线程:是进程内的一个执行单元,
是 CPU 调度和分派的基本单位
。线程是比进程更小的能独立运行的基本单位
。一个进程可以包含一个或多个线程,这些线程共享进程的资源
进程和线程是操作系统中的两个核心概念,它们的主要区别如下:
1. 定义
- 进程:是指程序的一次执行过程,是操作系统进行资源分配的基本单位。每个进程都有独立的地址空间、内存、数据栈和其他辅助数据结构(如进程控制块)来跟踪其执行状态。
- 线程:是进程内的一个执行单元,是 CPU 调度和分派的基本单位。线程是比进程更小的能独立运行的基本单位。一个进程可以包含一个或多个线程,这些线程共享进程的资源。
2. 资源分配
- 进程:每个进程都有独立的内存空间和系统资源(如文件描述符、信号量、内核对象等)。这种隔离性确保一个进程的崩溃不会直接影响到其他进程,提升了系统的稳定性和安全性。
- 线程:线程共享同一进程的内存空间和资源,使得它们能够高效地交换数据和信息。但这也带来了风险:一个线程的错误可能导致整个进程崩溃,影响其他线程的正常运行。
3. 上下文切换
- 进程上下文切换:进程切换涉及到保存和恢复大量的上下文信息,包括内存管理信息(如页表)、CPU寄存器和其他资源信息。这种切换开销较大,导致进程间的切换效率较低。
- 线程上下文切换:线程切换相对轻便,因为它们共享同一进程的内存空间,只需保存和恢复少量信息(如寄存器状态和栈指针)。这种低开销使得线程在需要频繁切换执行上下文的高并发场景下更加高效。
4. 通信方式
- 进程间通信(IPC):由于进程具有独立的内存空间,进程间的通信需要通过特定的IPC机制,如管道、消息队列、共享内存等。
- 线程间通信:由于线程共享同一进程的内存,线程间的通信可以通过共享变量来实现,效率更高。但这也引入了并发访问的问题,需要通过同步机制(如互斥锁、信号量、条件变量等)来保证数据的一致性和正确性。
5. 应用场景
- 多进程:适用于需要高可靠性和隔离性的场景。例如,服务器的每个请求可以使用独立的进程,这样一个进程的崩溃不会影响到其他进程,增强了系统的稳定性和安全性。
- 多线程:适用于需要快速响应和高效资源利用的场景。例如,图形用户界面应用程序通常使用多线程来处理用户输入、后台任务和界面更新,以提高用户体验和系统响应能力。
6. 性能考虑
- 进程:由于进程的隔离性,虽然可以提供更好的安全性和稳定性,但也会引入较大的性能开销,特别是在需要频繁创建和销毁进程的场景。
- 线程:线程在性能上更具优势,适合于高并发和高响应要求的应用场景。然而,线程间的共享资源管理和同步问题也可能导致复杂性和性能下降,尤其是在锁争用和上下文切换频繁的情况下。.