C++从零开始(五):务实基础(下)函数
🌟《C++从零开始》 系列,开始更新中…
三、函数
3.1 基本概念
怎么定义函数?
以前,我比较倾向将函数定义为顺序执行的语句集合。现在我认为更恰当的说法应该是:函数是一个可重用的语句序列,旨在完成特定的工作。
C++ 中函数一般形式如下:
1 | return_type function_name( parameter_list ) |
上面包含一个函数的所有组成部分:
- 返回类型(return_type):一个函数可以返回一个值,return_type 是函数返回的值的数据类型。不需要返回值,return_type 是关键字 void。
- 函数名称(function_name):函数的实际名称,函数名和参数列表一起构成了函数签名。
- 所谓”签名“则意味着这可以唯一标识一个函数。
- 参数列表(parameter_list):参数就像是占位符。当函数被调用时,可向参数传递一个值,这个值被称为实际参数,参数列表包括函数参数的类型、顺序、数量。
- 参数列表的顺序、类型、数量不完全一致的话,形成不同函数签名,即是不同函数。
- 函数主体(boby):函数主体包含一组定义函数执行任务的语句。
特别的,C++是不支持嵌套函数的。
1 |
|
3.1.1 前向声明🌟
前向声明初识
在很多IDE中,如果你尝试在main函数使用未在之前定义的函数,会出现编译错误:
1 |
|
这是因为max函数定义在main函数之后,顺序编译到代码第6行max(1,2)时找不到max的定义,所以main不知道max是什么(是的怎么这么笨?)。
解决这个问题,可以:
-
将max函数整体定义在main函数前;
-
前向声明。
前向声明告诉编译器标识符在实际定义之前就已经存在,这样编译器会在链接时进行符号链接寻找标识符的定义。
感觉和extern关键字很像?请见下节对比。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using namespace std;
int max(int num1, int num2) ; // 前向声明
int main()
{
max(1,2); // 编译正确
return 0;
}
// 函数返回两个数中较大的那个数
int max(int num1, int num2)
{
return num1>num2?num1:num2;
}如果max定义在其它文件(max.cpp)中:
1
2
3
4
5/*max.cpp*/
int max(int num1, int num2)
{
return num1>num2?num1:num2;
}你在main中(定义在main.cpp)中,可以不通过include “main.cpp”而使用前置声明:
1
2
3
4
5
6
7
8
9
10/*main.cpp*/
using namespace std;
int max(int num1, int num2) ; // 前向声明,会在链接的时候寻找max的定义,编译器时不报错
int main()
{
max(1,2); // 编译正确
return 0;
}编译(一切正常):
1
2g++ main.cpp max.cpp -o main.out
./main.out
但经过我的实践,在很多编译器中进行了优化。同一个文件的函数即使不进行前向声明,上面的代码也不会报错。
而且,在Google code style 也明确指出尽量不要使用前置声明:
尽可能地避免使用前置声明。使用
#include
包含需要的头文件即可。
那么,前向声明还存在的意义是什么?
- 减少编译时间。使用include也可以使用别的的文件中定义的变量,但是也会把头文件其它不需要的变量引入。如果只是需要使用很少的外部变量,使用前向声明能减少编译的头文件展开。
- 打破循环引用。
循环引用
前置声明现在用来解决多文件中循环引用的问题。
以类循环引用为例。
想象这么一个情况:A.h定义了Class A,它需要引用B.h定义的Class B,也就是要写入#include “B.h” ;类似的情况,B.h也需要写入#include “A.h”。
这就造成了两个类互相引用,但由于C++蛋疼顺序编译的规则。如果是先编译A.h,引用的类B则找不到实现的定义;如果是先编译B.h,则找不到类A的定义。形成“先有鸡还有先有蛋”的世纪难题。
我们举一个具体的例子。
1 | /* A.h */ |
尝试编译一下:
1 | g++ test.cpp -o test.out |
显示在B.h
中找不到A
的定义:
现在我们从底层编译过程探讨下这个循环引用出现的原因和解决方案。
我们知道编译四大流程为:预编译→编译→汇编→链接,先看看预编译源文件test.cpp产生的test.i。
1 | g++ -E test.cpp -o test.i |
预编译后内容如下:
下面是test.i
中具体内容。
1 | class B |
可以看到,test.i中第6行代码A* a
,在类B中引用了A
,但是前面没有关于A
的定义。这为后面编译出错埋下了伏笔。
预编译后完成后,我们进行第2个阶段:编译。
1 | g++ -S test.cpp -o test.s |
Error!出现了最开始的“未定义”错误:
也就是说这个错误在编译阶段就产生了:当C++按顺序编译到 A *a
这行时,编译器进行语法检查,发现在前面找不到A
相关的定义,于是报错。
怎么解决这个问题?
我们可以在B.h
中进行前向声明,解决这个错误。
- 类似于全局变量,这样
A
作用范围就到本文件结尾:即编译器还会在test.i
中其它位置寻找A
的定义; - 不仅如此,即使
test.i
中不存在,编译器还会在链接时符号解析时寻找A的定义,而不是在编译时就报错。
这样编译器就不会在编译阶段报错了。
1 |
|
新的预编译文件test.i
文件也在相应位置多了一行Class A;
(其余无变化):
1 | class A; |
如果不使用#ifndef 、#define 和#endif?
#ifndef 、#define 和#endif,即条件编译:只有满足要求代码才参与编译,否则不参与编译。基本用法为:
1 |
|
在本节中,对B.h和A.h都使用了条件编译:防止头文件被重复定义,在链接时出现大量重定义错误。
1 |
|
以A.h
为例:
- 如果
A.h
被第一个文件引入时,会定义标识符A_H
,这个时候A.h
其后的代码会被预编译插入到文件中; - 如果其它文件存在代码
#include “A.h”
,尝试预编译替换为A.h
中内容; - 因为
A_H
已被定义,直接跳转到#endif
结束,避免了头文件重复引入。
在本节中,如果对B.h和A.h都不使用条件编译,预编译时不但会出现重定义错误还会出现无限嵌套:
- main.cpp中:#include “A.h” 被替换为A.h中具体内容;
- A.h中: 由于在头文件引入了 #include “B.h” ,所以 #include “B.h”也会被替换为B.h中具体内容;
- B.h中: 重复引入了 #include “A.h”,重复替换头文件A.h,跳转到步骤2发生无限嵌套。
extern和前向声明
extern和前向声明作用非常相似:都可以用来声明函数/结构体/类等是在外部定义的,这样在编译时不出错,在链接时会在其它.o
寻找相关定义。
但是extern关键字和前向声明也有些不同:
- extern可以作用于变量,前向声明无法声明变量(变量会默认初始化),只能声明函数/结构体/类等;
- extern还有extern “C”相关用法;
- extern是一个关键字,前向声明是种声明方式(使用前声明)。
最佳实践:extern/前向声明/include
-
如果只是少量地要使用别的文件中定义的变量/函数/结构体/类等:请使用extern关键字,它可以减少编译时间;
虽然函数/结构体/类等使用前向声明也可以但不推荐,使用extern更好。
-
打破循环引用。前向声明。
-
其余情况使用include更好,代码逻辑更清晰。
3.1.2 最佳实践:什么时候使用函数
作为曾经稚嫩的(现在不那么稚嫩的)程序员,什么时候使用函数是一个挺纠结的问题。参考learncpp 中建议:
- 多次出现的语句应该组成一个函数。例如,如果我们以相同的方式多次读取用户的输入,那么这是一个很好的函数候选。
- 具有明确定义的输入和输出目标的代码。例如,如果我们有一个要排序的项目列表,那么进行排序的代码将是一个很好的功能,即使它只完成了一次。输入是未排序的列表,输出是排序的列表。
- 一个函数应该执行有且只有一项任务。
- 当一个函数变得太长、太复杂或难以理解时。可以将其拆分为多个子函数,也就是重构。
3.2 函数重载
在前面我们提到:函数通过函数签名来唯一确定一个函数,而函数签名由 函数名&参数列表 组成。
比如,两个函数函数名相同而参数列表不同,这个时候是同一函数吗?
显然不是,因为函数签名中的参数列表不同,因此是两个函数,这也就是函数重载。
3.2.1 为什么需要函数重载?
重载函数通常用来命名一组功能相似的函数。这样做的好处:
- 减少了函数名的数量;
- 避免了名字空间的污染,对于程序的可读性有很大的好处。
请看下例。
我们有两个函数:一个返回两个整数相加的值,一个返回两个浮点数相加的值。我们定义了多个名称但极其相似(功能、名字etc.)的函数。它们核心功能虽然都是add,但却对应多个不同的函数名,增加了记忆负担。
1 | int addInteger(int x, int y) |
优雅的做法应该是:每个函数同名,编译器靠参数类型、数量、顺序来自动匹配调用。
1 | int add(int x, int y) |
3.2.2 二义性匹配
前面介绍的都是传递的参数和定义函数参数完全匹配的简单情况。但实际上,还会出现函数调用中的参数类型与任何重载函数中的参数类型不完全匹配 。
这会发生什么?
1 |
|
输出:
1 | print int : 97 |
发生了什么?print('a')
匹配了 print(int)
? 97
又是什么?
这是因为编译器如果找不到完全匹配的函数,会自动进行隐式转换将某些窄整数和浮点类型自动提升为更宽类型。所有这里的char('a')
自动提升为int
,匹配到了print(int)
, 97
是a
的ASCII编码。
特别的,由于匹配到了print(int)
,便不会自动继续提升类型去匹配print(double)
。
关于类型转换 会在第十章统一总结,这里先简单了解下即可。
特别的,如果上述过程(数字转换找不到)未找到匹配项,编译器将尝试通过任何用户定义的转换找到匹配项。
这涉及到类型重载,会在下篇文章面对对象 进行讲解。这里只要了解这个例子即可。
1 | class X |
在这里例子中(也是一般函数参数匹配流程总结):
- 编译器寻找是否存在
print(X)
,不存在转至第2步; - 编译器检查
x
是否可以类型提升,不能进行第3步; - 编译器将查找任何用户定义的转换,存在,进行转换。
3.3 函数模板
在前面函数重载中,我们通过将两个功能相似仅参数列表不同的函数,改为同名函数让编译器通过函数签名来区分,减少了思维负担。
但是考虑一种更特殊的情况,两个函数不仅功能极其相似,连参数列表的个数都相同。
1 |
|
总感觉哪里不对,似乎造成了很多代码重复?它们只是参数类型不同啊,函数体、名字什么都一样?
在C++中提供了函数模板,用来优雅地应对这种情况。
1 |
|
不错,一切顺眼了很多。
3.3.1 模板函数是如何工作的?
在前文我们介绍了函数模板,如下:
1 | template <typename T> |
但函数模板实际上并不是函数——它们的代码不是直接编译或执行的。函数模板只有一个功能:为每个具有一组唯一参数类型的函数,调用创建(并编译)一个函数。
让我们看一个简单的例子,展示了一个不同以往的模板函数调用方式funName<actual_type>(arg1, arg2)
:
1 |
|
但相比funName(arg1, arg2)
这种方式,上面的才是更接近本质的调用方式。
因为print(1,2)
,本质就是编译器克隆了函数模板void print(T x, T y)
,将模板类型T
替换为我们指定的实际类型<int>
,最终生成指定类型的函数void print<int>(int x, int y)
。
亲眼看一看:实例化完成后编译的内容。
由于直接使用 g++ -S
生成的是汇编代码,不太直观,我们借助https://cppinsights.io/ 观察模板实例化后的代码。
1 | print<int>(1,2); |
实例化后,编译器确实生成了两个函数:void print<int>(int x, int y)
、void print<double>(double x, double y)
供我们调用。
上述从函数模板创建指定类型的函数的过程,称为函数模板实例化。如果这个过程是因为函数调用而产生的,则称为隐式实例化。
-
实例化函数每次调用都会发生吗?仅在第一次函数调用时实例化,对该函数的进一步调用将路由(指向)到已实例化的函数。
-
最佳实践:优先使用普通函数调用方式,即
print(1,2)
。
3.3.2 多个函数模板类型
多个函数模板类型适用于函数拥有多个不同类型参数的情况。
请看下例:
1 |
|
继续往下阅读前,请思考:为什么编译会出错?有什么好的解决办法吗?
-
为什么编译出错?
- 根据调用的函数
max(2, 3.5)
,编译器会尝试寻找匹配的函数,没找到转下一步; - 编译器尝试根据函数模板
max(T,T)
,生成函数max(int,double)
。显然这是不可能生成不同类型的参数。 - 函数调用解析出错。
当然你可能还会问:为什么编译器不会生成函数
max(double,double)
,这调用时max(int,double)
中int
可以隐式转换为double
呢?因此,类型转换仅在解析函数重载时进行,而不是在执行模板参数推导时进行。
- 根据调用的函数
-
有什么好的解决办法吗?
当然,最简单办法是传参时便进行类型转换:
1
cout << max(static_cast<double>(2), 3.5) << '\n';
这样函数模板就可以生成函数
max(double,double)
,从而正确被调用了。但这很不cool。或者你又想到了,我们不是新学会一种调用函数模板的方法吗,它可以显示指定转换类型,编译器就不用自己去推导了:
1
cout << max<double>(2, 3.5) << '\n';
这也是ok的。
但最佳的解决办法,还是从根源解决:既然是调用参数类型有多个,而模板函数参数类型只有一个导致出现上面的问题。
那为什么不直接定义模板函数时也定义为多个类型呢?
-
多个模板类型参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using namespace std;
template <typename T,typename U>
T max(T x, U y)
{
return (x > y) ? x : y;
}
int main()
{
cout << max(2, 3.5) << '\n'; // compile error
return 0;
}好了,一切看起来挺不错,让我们输出一下:
1
3
啊?大惊失色。为什么输出结果是3而不是3.5?
因为函数内部结果
3.5
为double
类型,返回的是T
类型,已经被编译器自动推断(根据传的参数2
)替换为int
类型。因此将3.5强制转换为3。那我们直接指定返回类型
T
变为U
不就行了吗?很可惜,不行。因为T、U的实际类型都是根据调用时传的参数进行推断的,而调用时传递参数的位置可以随意换动。1
2// cout << max(2, 3.5) << '\n';
cout << max(3.5, 2) << '\n';此时
U
被推断为int
类型。更好的做法是使用auto
关键字,自动推导函数的返回类型。1
2
3
4
5template <typename T,typename U>
auto max(T x, U y)
{
return (x > y) ? x : y;
}输出:
1
3.5
好了,一切都好起来了。关于
auto
关键字还会在后面做更详细的总结,希望你有了个初步的认识。
3.4 函数参数🌟
⚠️ 本节知识设计到较多指针和引用相关内容,此前无基础建议先阅读:第二章:指针和引用。
在正式探讨函数参数前,我们先了解下函数中的两种参数:
- 形式参数:函数声明时表示的变量,函数调用时才分配内存。
- 实际参数:调用函数数实际传递的参数值,必须是确定的值。
1 | void foo(int x, int y) // x,y 是形参 |
它们更多的区别会在3.4.6节中进行对比。
本节核心:按值传递、按引用传递和按指针传递,这 3 种向函数传递参数的主要方式。我们一起了解下吧。
3.4.1 按值传递
-
什么是按值传递?
一般来说,C++ 中的非指针参数都是按值传递:实参的值被复制到相应函数形参中。
按值传递既然是复制,即只是将实参的副本传递给函数,那么函数修改副本(形参)的值是不能影响到实参值。请看下例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void foo(int y)
{
y = 6;
}
int main()
{
int x{ 5 };
cout << "x = " << x << '\n';
foo(x); // 实参x复制一份给y
cout << "x = " << x << '\n';
return 0;
}输出:
1
2x = 5
x = 5x
的值并没有被改变,虽然foo
函数内容修改了传递过来的值,但那只是x
的副本。上图对这一过程进行形象说明,注意到实参x、形参y对应的是不同内存区域(地址都不一样)。
-
什么时候用值传递?
按值传递通常用于需要传递的参数不希望被修改的时候,实参可以是:变量(例如 x)、数字(例如 6)、表达式(例如 x+1)、结构和类以及枚举数。
但是按值传递也有明显的缺点,应该避免以下几种情况使用:
- 复制结构和类。复制结构和类开销过大,导致明显性能损失;
- 希望参数被改变;
- 返回多个值。
其它情况优先考虑值传递。
3.4.2 按引用传递
-
为什么需要引用传递?
在前面我们说到值传递的几个缺点:复制结构和类开销大、无法改变参数、只能返回一个值。
对应,如果你不希望以上发生,请使用引用传递。
引用就相当于变量别名,操作引用等价于直接操作本体变量。请看下例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using namespace std;
void getAdd(int x_add_1, int& y_add_2,int& z_add_3)
{
x_add_1 += 1;
y_add_2 += 2;
z_add_3 += 3;
}
int main()
{
// print(1.2,2);
int x = 0 ,y = 0 , z =0;
getAdd(x,y,z);
cout << x << endl;
cout << y << endl;
cout << z << endl;
return 0;
}输出:
1
2
30
2
3上述过程简略分析。
- 引用传递变量
y
、z
均被修改(x
值传递未被修改)。
类似值传递。例如,引用传递中的
y_add_2
也是被调函数栈上的一个局部变量,它保存了y
的地址。对于引用参数的任何处理都会通过间接寻址,等价直接操作实参y
本体。特别的,引用还有几个特点(2.2节):
- 引用必须使用右值进行初始化,除非是执行常量的引用;
- 引用传递值必须被初始化,所以不必担心空值。
- 引用传递变量
-
这么棒了你后面还讲指针传递干嘛?
一般来说,我们确实推荐尽量使用引用传递参数,但是地址传递参数还是有它的用武之地。
比如,引用传递值可以使得函数“返回”多个值:
1
void getAdd(int x_add_1, int& y_add_2,int& z_add_3);
但是观察这个表达式,输入参数
x_add_1
,和输出参数(返回值)y_add_2
、z_add_3
放在一块,无法很好的区分哪些是要被修改的(输出参数)。毕竟,它们连调用都是这么相似:1
2int x = 0 ,y = 0 , z =0;
getAdd(x,y,z);你能区分哪些是
x、y、z
哪些输出参数吗?
3.4.3 按指针传递
-
为什么要需要指针传递值?
前面说到,引用传值用来“返回”多个参数时,很难区分哪些参数是输出参数,连调用时都过分相似(多胞胎搞人心态是吧?)。
上面代码修改为指针传值后,函数形参由
&
变为*
:1
2
3
4
5
6void getAdd(int x_add_1, int* y_add_2,int* z_add_3)
{
x_add_1 += 1;
*y_add_2 += 2; // y_add_2存的是y的地址,使用*操作符取值
*z_add_3 += 3;
}调用时(y、z可以很好的认出是输出参数):
1
2int x = 0 ,y = 0 , z =0;
getAdd(x,&y,&z); // &--取地址运算符输出:
1
2
30
2
3上面,
y_add_2
获取了形参&y
保存的值,即变量y
的内存地址。通过取值运算符*y_add_2
,可以获取变量y
的内存区域操作权,即可以修改y
的值了(+2)。因此变量y
的值被修,z
同理。 -
惊讶!指针其实值传递?
这就是说我们像值传递一样将实参的值(一般是某个变量的地址)复制了一份给形参,形参也作为局部变量在栈中开辟了内存空间。
换句话说,指针是按值传递的!当然,这其实没多大惊讶(除非你和作者一样是个惊讶怪),因为我们早在2.1节分析过,指针本质就是一个变量,它有自己的值(其它变量的地址)和自己的地址。
请看下例,试分析:为什么
pfive
值没有被修改成功?1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using namespace std;
// 变量 ptr保存了p_five的值(five的地址)
void setNull(int* ptr)
{
ptr = NULL;
}
int main()
{
int five = 5;
cout<<&five<<endl;
int* pfive = &five; // p_five = &five = 0x7ffdb136b8e4
cout<<&pfive<<endl;
setNull(pfive); // 将p_five指向null
cout<<pfive<<endl; // 输出此时p_five内存地址
return 0;
}输出:
1
2
3
40x7ffd037a0ecc
0x7ffd037a0ec0
0x7ffd037a0ea8
0x7ffd037a0ecc显然
pfive
没有修改成功(NULL)。整个过程如图所示:
-
局部变量
pfive
保存了&five
的值,即five
的地址; -
随后
pfive
作为实参,其值(five
地址)复制给形参ptr
,编译器给形参ptr
开辟了空间专门保存five
地址; -
随后
ptr=NULL
,但是影响不到pfive
,二者是不同的变量拥有各自独立的空间。
这验证了我们之前的结论:指针传值只是复制了实参值给形参,只不过这个实参值一般是某个变量的地址。
那如果我们想在指针传值时修改形参就可以影响实参?
显然这就是引用的做法,对形参的任何修改直接等价操作实参本体。不过为了更好的讲解,我们先总结一下前面引用、指针传值的用法。
我们知道引用相当于变量的别名,使用引用时可以认为就是在使用变量本身。
1 | int& a_ref = a; // 使用a_ref和使用a没什么区别 |
又如之前的引用传值:
1 | void func(int& var) |
此时形参是int& var
,实参tmp
,实参值初始化形参var
,就相当于:
1 | int& var = tmp; |
也可以推广到函数其余参数传递情况:
-
值传递
1
2
3
4
5
6
7
8
9
10
11void func(int var)
{
// var = ...;
}
int main()
{
int tmp = 0;
func(tmp);
return 0;
}实参值初始化形参
var
等价于:1
int var = tmp;
-
指针传递
1
2
3
4
5
6
7
8
9
10
11void func(int* var)
{
// *var = ...;
}
int main()
{
int tmp = 0;
func(&tmp);
return 0;
}实参值初始化形参
var
等价于:1
int* var = &tmp;
好了,接受了上面的概念,我们再来说说怎么修改形参ptr
等价于修改实参pfive
?
1 |
|
显然,引用可以做到这点:将ptr
视为pfive
的别名:
1 | int*& ptr = pfive; |
只需将第5行修改为:
1 | void setNull(int*& ptr) |
我们再尝试输出:
1 | 0 // 表示指针指向NULL |
3.4.4 最佳实践
传引用快还是传指针快?
虽然前面的分析,你对引用和指针传递有一定的了解、区分。
但如果要你回答这么一个问题:是传引用快还是传指针快?
先上结论:一样快。
因为引用就是特殊的指针,它底层实现和指针是一致的。
准备一段地址传参代码:
1 | void func(int* y) |
main
对应汇编代码如下:
1 | main: |
可以看到main
中最终将实参&x
(变量x
的地址)保存在寄存器rdi
中 。
接着我们开始重头戏func
函数:指针通过保存变量地址到寄存器中,实现对变量所在内存区域进行修改,因此修改指针就是直接影响变量。
1 | _Z4funcPi: |
现在我们将上述代码从地址传递改为引用传递:
1 | void func(int& y) |
查看其汇编代码:不能说毫不相干,只能说和之前指针传值的汇编代码完全一模一样。
也就是说,引用本质和指针一样,都是通过保存变量对应内存区域地址,来实现操作变量。对引用的任何操作,都会通过间接寻址直接操作变量本身,只不过相比指针隐藏了一些细节(编译器对使用引用会自动加上*
,2.2.1节)。
参数传递选择规则
有引选引。
优先选用引用传递(引用:拜托了),除非:
- 希望参数不被修改,选择按值传递,否则转下一步;
- 需要返回空指针、or返回局部变量内存、OR数组,选择按指针传递,否则转下一步。
数组怎么使用引用传参?
这是个很有意思的问题。
先看看数组的引用:
1 | int array[] = {1,2,3,4,5}; |
此时数组的类型可以认为是int [5]
,&arr
便是声明一个array
的别名。
因此,我们如此使用数组的引用作为参数:
1 |
|
输出:
1 | 0 |
可以看到,确实被成功修改了。但int (&arr)[5]
编译器要检查数组实参和形参的大小,扩展性太差!
为此,我们使用模板进行改进(其余不变):
1 | template<typename T,int N> |
完美!
3.4.5 参数传递总结
一些面试常考题对前面所学进行总结和验证。
-
形参和实参的区别?
- 何时分配内存:形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元(这一部分内容还会在8.6函数返回值 详细举例);实参在调用前就已经分配了内存。
- 参数类型: 实参可以是常量、变量、表达式、函数等,在进行函数调用时,它们都必须具有确定的值;实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。
- 单向传递。只能把实参的值传送给形参,而不能把形参的值反向地传送给实参。
-
值传递、指针传递、引用传递的区别?
请看下表。
值传递 引用传递 指针(地址)传递 拷贝内容 实参的副本(数组例外,会退化为指针) 给实参起个别名 指针(4字节或8字节) 效率 低,特别是拷贝结构体或类对象时 高(推荐),起个别名即可 高,拷贝指针即可 是否修改 是 是 不能修改为其它对象的引用 初始化 不必要 一定要 不必要 何时使用 参数不希望被修改时 优先选用引用、传递结构或类对象、希望参数被修改 返回多个值、需传递空指针(引传递用不允许空值)、返回局部变量内存(3.5中详述) -
指针传递、引用传递底层区别?
-
指针传递本质是值传递。值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。
所以形参指针(内容)变了(保存了其它变量地址),实参指针不会变。
-
引出传递本质是间接寻址。引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,通过栈中存放的地址访问主调函数中的实参变量。
-
符号表不同。程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同,编译器直接处理为操作引用对象)。
-
3.4.6 特殊参数
在这一小节将来认识下比较特殊的两类参数。
-
命令行参数
如果你运行过一些开源代码/库,经常会要求你输入指定参数:
1
$ program arg1 arg2
这为我们提供了一个可以向其他函数输入参数的方法,特别是你无法修改源码或程序需要用户提供参数时。当然,还可以通过配置文件实现,这里暂不表。
下面形式的main函数可以接受命令行参数:
1
2int main(int argc, char* argv[])
int main(int argc, char** argv)其中:
argc
:传递的参数量个数,至少为1,因为至少存在argv[0]
指向函数本身名称;argv
: C风格二维数组,存储参数。例如,argv[0][0]
指向第一个数组第一个字符,argv[0]
是第一个数组第一个字符的地址。
一个简单的程序,打印用户输入的姓名和年龄:
1
2
3
4
5
6
7
8
9
10
using namespace std;
int main(int argc , char* argv[])
{
cout<<"the count of paramters: "<< argc<<endl;
cout<<"the name of program: "<< argv[0] <<endl;
cout<<"your name: "<< argv[1]<<endl;
cout<<"your age: "<< argv[2]<<endl;
}特别的,操作系统对如何处理特殊字符(如双引号和反斜杠)有特殊的规则。
-
双引号:以双引号传递的字符串被认为是同一字符串的一部分(即使它们之间存在空格);
1
./test.out "royhuang cqu" 25
-
斜杠:如果要包含文字双引号,则必须反斜杠双引号。
1
./test.out \"royhuang\" 25
-
省略号(可变参数)
到目前为止,在我们看到的所有函数中,函数将采用的参数数量必须事先知道。但是,在某些情况下,能够将可变数量的参数传递给函数会很有用。
1
return_type function_name(argument_list, ...);
例如:我们要编写一个函数来计算一组整数的平均值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using namespace std;
double findAverage(int count, ...)
{
double sum{ 0 };
// 通过预定义宏va_list获取参数列表
va_list list;
// 通过宏va_start对va_list初始化
va_start(list, count);
// 求和
for (int arg{ 0 }; arg < count; ++arg)
{
// 通过宏va_arg获取具体参数
sum += va_arg(list, int);
}
// 使用完后通过宏va_end清理va_list
va_end(list);
return sum / count;
}
int main()
{
cout << findAverage(5, 1, 2, 3, 4, 5) << '\n';
cout << findAverage(6, 1, 2, 3, 4, 5, 6) << '\n';
}上述结果输出:
1
23
3.5看起来这很棒。但是我们并不推荐使用省略号:
-
省略号很危险:无法判断传递的参数个数是正确
假设你只传递了5个参数,而实际要求是6个:
1
findAverage(6, 1, 2, 3, 4, 5)
在作者的机器上,这产生了奇怪的结果:
1
699773
va_arg(list, int)
返回的前5个值是我们传入的值。它返回的第 6 个值(没有报错)是一个垃圾值堆栈。结果,我们得到了一个垃圾答案。 -
省略号很危险:类型检查被暂停
如果你尝试传递一个浮点数(
1.0
)作为参数:1
findAverage(6, 1.0, 2, 3, 4, 5, 6)
结果一定让你大吃一惊(这是什么?这么大的数字?)
1
1.78782e+008
这是因为
va_arg(list, int)
指定预期类型是int
,但是我们又传递double类型参数。这导致:- va_arg 的第一次调用将只读取 double 的前 4 个字节(产生垃圾结果);
- va_arg 的第二次调用将读取 double 的后 4 个字节(产生另一个垃圾结果);
- 因此,我们的总体结果是垃圾。
-
限定省略号:参数数量和类型
幸运的是,我们可以人为的传递一个“解码器字符串”,它告诉程序如何解释参数(限定了数量和类型)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using namespace std;
double findAverage(std::string decoder, ...)
{
double sum{ 0 };
va_list list;
va_start(list, decoder);
int count = 0;
while (true)
{
char codetype{ decoder[count] };
switch (codetype)
{
default:
case '\0':
va_end(list);
return sum / count;
case 'i':
sum += va_arg(list, int);
++count;
break;
case 'd':
sum += va_arg(list, double);
++count;
break;
}
}
}
int main()
{
cout << findAverage("iiiii", 1, 2, 3, 4, 5) << '\n';
cout << findAverage("iiiiii", 1, 2, 3, 4, 5, 6) << '\n';
cout << findAverage("iiddi", 1, 2, 3.5, 4.5, 5) << '\n';
}看起来很好,但是一般情况我们完全有其它合理的解决方案。比如:为什么不将
...
换成一个数组? -
换成其它方案:动态数组
一般情况,我们都要避免使用省略号,选择其它的方案。比如这里我们完全可以传递一个数组作为参数。
1
double findAverage(int len, int* nums);
-
3.5 函数返回值
在前面我们学习了按值、引用和地址向函数传递参数,如果作为函数返回值会有什么不同呢?
- 按值返回的是
value
的副本? - 按指针返回的是
value
的地址?局部变量在退出函数被销毁时,它的地址不是没有了吗? - 按引用返回的是
value
的别名?局部变量在退出函数被销毁时,别名还有用吗?
请看下文分解。
3.5.1 按值返回
和按值传参一样,按值返回很安全,因为它只返回value
的副本,不用担心返回之后value
发生什么变化。
1 | int doubleValue(int x) |
当然,它的缺点也和按值传参一致,返回大型结构或类时很慢。一般希望值不被修改或者返回局部变量时使用值传递。
返回局部变量?函数调用结束时局部变量不就被销毁了,为什么还能返回?
在函数调用过程中是局部变量被压到栈中,当函数退出时,临时变量出栈,确实已经被销毁。
但局部变量作为返回值时在函数调用时,有些特别的变化。
-
C语言里规定:16bit程序中,返回值保存在ax寄存器中,32bit程序中,返回值保持在eax寄存器中,如果是64bit返回值,edx寄存器保存高32bit,eax寄存器保存低32bit。
-
由此可见,函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。
下图的汇编代码也表明了这一点:
这也就是为啥:上述代码value虽然作为返回值但也是局部变量,函数调用结束时,依旧正确返回了其值。
现在让我们来特别关注一下按地址/引用返回局部变量的情况。
3.5.2 按指针返回
和按指针传递参数类似,按指针返回的只是将value
的地址复制一份返回,所以速度很快。
-
危险:返回局部变量地址
局部变量在函数退出时就会被销毁,如果尝试返回局部变量的地址,这种行为非常的危险(地址对应的内存可能已被释放):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using namespace std;
int* doubleValue(int i)
{
int d_value = i*2;
return &d_value; // 返回局部变量地址
}
int main()
{
int value = 3;
int* p = doubleValue(value);
cout<<*p<<endl;
return 0;
}输出:
编译器(VSCode)给出了警告,虽然输出值很幸运是正确的。但这是因为局部变量
d_value
对应栈空间还存在没有被重新分配使用,通过地址获取到了正确的值。很显然这种做法很危险,你并不知道什么时候就返回的是一个垃圾值,因此不建议你去尝试。
-
那按指针返回还有什么用途吗?
按指针返回常用于将动态内存返回给调用者,因为动态分配的内存不会在函数退出时被销毁。
1
2
3
4
5
6
7
8
9
10
11int* allocateArray(int size)
{
return new int[size]; // 动态分配空间
}
int main()
{
int* array{ allocateArray(25) };
delete[] array;
return 0;
}不过,分配空间(new[])和删除空间(delete[])在代码不同的函数中,使得谁负责删除资源和是否需要删除资源变得有点难以理解。
这里更好的做法是使用智能指针,第二章有相关介绍。
另外一个用途就是返回按地址传递的参数。
1
2
3
4
5
6
7
8
9
10
11
12
13int* doubleValue(int* p_i)
{
*p_i = (*p_i)*2;
return p_i; // 返回指针参数p_i
}
int main()
{
int value = 3;
int* p = doubleValue(&value);
cout<<p<<endl;
return 0;
} -
不说说返回地址其它用途吗?
既然提到返回地址很快,那用来返回结构体、类不应该很好吗?然而并不是。道理同参数传递中尽量建议使用引用一样:
- 引用更安全。引用一定会被初始化,不能为空。
- 引用效率更好。比如它不用管理指针析构释放之类的问题。
3.5.3 按引用返回
与按指针返回类似,按引用返回的值不能是局部变量。
-
危险:不要返回局部引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using namespace std;
int& doubleValue(int i)
{
int d_value = i*2;
return d_value; // 返回局部引用
}
int main()
{
int value = 3;
int& ref = doubleValue(value);
cout<<ref<<endl;
return 0;
}道理同上按指针返回,除非你想哪天收到一个对垃圾的引用。
-
什么时候按引用返回
除了上述说的按值返回、按指针返回的情况,其它时候一般都建议按引用返回。
比如返回大型数据结构、类等,以及返回按引用传递参数时。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using namespace std;
int& doubleValue(int& i)
{
i = i*2;
return i; // 返回按引用传递的参数i
}
int main()
{
int value = 3;
int& ref = doubleValue(value);
cout<<ref<<endl;
return 0;
}
3.5.4 小结
编码时选择何种方式返回参数?
和选择何种方式传递参数很像:
- 不想修改
value
就是想返回value
一个副本,or返回局部变量(见下节述)用值传递,否则转下一步; - 需要动态内存分配时、返回按地址传递的参数,选择地址传递,否则转下一步;
- 其它情况,一般选用引用传递(返大型结构体、类、按引用传递的参数)。
3.5.5 返回多个值
在3.4.3 介绍了使用地址传递参数,达到类似返回多个参数的效果。但是这种做法比较别扭,也不够优雅。C++有两种比较好的方式:
-
使用结构体
将要返回的多个参数定义为一个结构体,最后直接返回结构体。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using namespace std;
struct S
{
int m_x;
double m_y;
};
S add()
{
S s;
s.m_x++;
s.m_y++;
return s; // 值传递,复制一个s的副本返回
}
int main()
{
S s = add();
cout << s.m_x << ' ' << s.m_y << '\n';
return 0;
}相比返回数组,结构体允许定义更多类型的值,显然更灵活。元组
std::tuple
也可以定义不同类型元素序列。 -
使用元组
见下例,使用元组返回多个不同类型的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using namespace std;
tuple<int, double> returnTuple()
{
return { 5, 6.7 };
}
int main()
{
tuple s{ returnTuple() };
cout << get<0>(s) << ' ' << get<1>(s) << '\n';
return 0;
}
3.6 内联函数
函数给我们提供了非常多有用的功能:
- 代码可以重复使用;
- 提供类型检查,确保参数类型匹配(类似函数的宏不会);
- 便于阅读、调试;
- …
3.6.1 那函数有什么缺点吗?
函数的一个主要缺点是每次调用函数时,都会发生一定量的性能开销(中断等),比如:
- CPU 必须存储它正在执行的当前指令的地址(因此它知道稍后返回到哪里)以及其他寄存器;
- 必须创建所有函数参数并赋值,并且程序必须跳转到新位置;
- …
当然,对于大型复杂的函数,函数调用时间相比函数运行时间微不足道。但是对于比较轻巧的函数,若是频繁调用,函数调用的时间便很可观了。
此时,我们希望这些轻巧又被经常调用的函数,最好不好进行这些复杂调用、返回操作。
如何做到这一点?类似于预编译头文件的替换,直接把函数体嵌入到每一个调用了它的地方,重复地嵌入。
请看下例:
1 | inline int min(int x, int y) |
min(int,int)
被声明为内联函数。在编译时,相当直接在相应调用位置替换为实际min(int,int)
函数体:
1 | int main() |
小问题:内联函数这么棒,那把所有的函数写成内联函数?
内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。
- 只适合比较简单的函数。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;
- 占用内存空间多。另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间。
典型的空间换时间策略。
3.6.2 内联函数什么时候被替换?
在前面我们知道,宏会由预处理器对宏进行替代(预编译阶段)。而内联函数也会通过编译器来实现展开替换(编译阶段)。
为了更好地验证所学,我们通过反汇编来对比加上
inline
前后代码的不同之处对比。
准备一个更简单的代码:
1 | inline int add(int y) |
生成可执行文件后进行反汇编:
1 | g++ -save-temps -fverbose-asm -g test.cpp -o test.out |
关键性代码截图如下:
可以看到main
函数体内被直接插入了add
函数的代码(绿色框)。
但是如果add
函数是非内联函数:
1 | // 防止编译器自己优化,强制声明为非内联函数 |
反汇编结果如图所示,add
函数代码并没有插入到main
函数体内。
3.6.3 内联函数和宏对比
一个常考的面试题,加深下印象。
- 替换时机。宏在预编译时被替换,内联函数是在运行时(至少不是预编译时);
- 调试。内联函数在运行时可调试,而宏定义不可以;
- 安全。编译器会对内联函数的参数类型做安全检查或自动类型转换(同普通函数),而宏定义则不会;
- 访问。内联函数可以访问类的成员变量,宏定义则不能。
3.7 函数指针
3.7.1 函数和指针
在前一节,通过反汇编,你也看到了(在main
函数中)调用(add
)函数是通过一个地址,例如:
1 | callq 40052d<_Z3addi> |
其中 40052d
便是函数add
的地址。让我们尝试打印一下函数地址:
1 |
|
输出:
1 | 0x4007ad |
可以看到,就像指针一样,func
保存的也是一个地址,只不过它保存的是函数地址。有函数地址就可以调用函数,此时可将函数压入栈(3.8节详述)。
像指针一样?那么可以像下面这样,使用int*
指针保存函数指针吗?
1 | int* p_func = func; |
很不幸报错了:
主要原因是因为:虽然func
和p_func
都是指针,但它们类型是不一样的。函数func
类型是int(*)()
,而我们给出的指针p_func
类型是int*
,无法赋值。就像你不能将string*
类型指针赋值给int*
类型指针。
所以,我们至少还得让他们参数类型一致:
1 | int (*p_func)() = func; |
依旧报错了:
这是因为函数指针p_func
的类型参数和函数func
不一致,编译器类型检查时出错。
题外话:为什么是在运行时报错,而不在编译代码时报错?
因为函数指针是在运行时才会进行解析。
我们还应该指定其参数类型:
1 | int (*p_func)(int) = func; |
一切到此就好起来了。p_func
此时获得了函数的地址,就可以像func
一样使用了。
特别的下面这种方式也是正确的:
1 | int (*p_func)(int) = &func; // 函数名多了个&,func和&func打印出来其实是一样的,都是函数地址 |
3.7.2 为什么需要函数指针:回调函数
虽然,前面我们了解了怎么定义和使用函数指针。但不禁还是有疑惑:使用函数指针p_func
调用函数func
不是多此一举?直接使用func
不就好了?
想象这么一种情况:
- 你有一个函数假定为
funcA
, 但你的功能需要外部自定义一些规则,这些规则用户自定义的; - 所以你需要一个“参数”来保存这些特定的规则,而这个规则显然是一个逻辑集合——换句话,它应该是个函数。
那么这个“参数”是不是应该是函数类型?某个函数如果作为参数传递给另一个函数,就是回调函数。
我们举一个更具体的例子:
- 我们定义一个排序函数,将数字进行排序:但排序的规则由用户自定义,它可能是从大到小排列,也可能是从小到大或者其它——总之它取决于用户怎么定义“规则”。
我们给定一个排序函数如下,它有一个参数bool (*comparisonFcn)(int, int)
用来定义排序规则:
1 |
|
输出:
1 | 5 |
显然这种方式,优雅且灵活,除了定义的函数指针实在过于丑陋。当然在上述myCompare
函数中,你甚至可以定义一些奇怪的规则:
1 | bool myCompare2(int a, int b) |
你可以尝试输出试一试。
3.7.3 更优雅地使用函数指针
前面我们提到,函数指针的声明实在过于丑陋:
1 | bool (*compare)(int,int); |
好消息你现在有两种方式让它看起来顺眼很多:
1. 类型别名
1 | // 类型别名 |
2. function
std::function是标准库 <functional>
头文件的一部分。
1 |
我们将之前的排序函数,重新定义为:
1 | void bubbleSort(int* arr, int len, std::function<bool(int,int)>) |
在主函数中,如此调用:
1 | // 其余函数代码同前,略 |
输出:
1 | 5 |
3. function是什么?
有一个很有意思的事情,函数定义必须要和主函数中传递的参数类型一致(都是std::function
),不能定义为指针:
1 | void bubbleSort(int* arr, int len, bool(*)(int,int) compare) |
如果你这个时候这样调用函数:
1 | // 在main中 |
会报错:error: cannot convert ‘std::function<bool(int, int)>’ to ‘bool (*)(int, int)’
。
因为std::function
本质是一种类模板,不是函数,是对通用、多态的函数封装。
- 通过
std::function
对C++中各种可调用实体(普通函数、Lambda表达式、函数指针、以及其它函数对象等)的封装,形成一个std::function对象。让我们不再纠结那么多的可调用实体,一切变的简单粗暴。 - 换句话说,
std::function
就是函数的容器,它自己是个类对象。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。
我们来看看它的原型:
1 | template< class R, class... Args > |
类为function,R是返回值类型,Args是函数的参数类型。再回想我们之前的调用:
1 | std::function<bool(int,int)> compare = myCompare; |
很显然,此时compare
本质是一个function类对象,只不过重载了其函数调用操作符(),所以使用的时候可以直接像函数一样调用。
特别的,function类还重载了赋值操作符=,这样可以将可调用的函数实体赋值给它。其函数调用操作符重载函数里间接的调用赋值时传进来的调用实体。
4. 使用auto
使用auto关键字自动推断类型,可更简便地使用函数指针:
1 | auto compare = myCompare; |
但是这个时候compare
隐藏了类型具体定义,如果希望读者更了解这段代码意义,使用typedef
应该是个更好的做法。
3.7.4 CPU眼中的函数指针:变量
去繁归真,我们来看看CPU眼中的函数指针是什么?
答案可能会让你惊奇:它在CPU眼中不过是一个过度包装的变量而已,就像指针一样也只是变量。
参考:CPU眼里的“函数指针”:过度包装的“变量” - 阿布的视频 - 知乎
3.7.5 小结
-
什么是函数指针?
函数指针是指向特殊的数据类型(即函数)的指针变量。
- 函数的名字也可视为是函数的指针;
- 使用重载后的
()
即函数名()
便是调用一个函数。
它在CPU眼中不过是过度包装的“变量”。
-
函数指针的声明&赋值方式?
以下为例。
1
2int (*p_func)(int) = func
int (*p_func)(int) = &func -
函数指针的用途?
函数指针还允许将函数作为参数传递给其他函数,即回调函数。
3.8 栈和堆🌟
在这一节中,我们来了解程序运行时,函数调用更底层的过程。不过在这之前,我们先了解下内存布局。
3.8.1 内存布局
内存布局初识
位系统4GB()。
下图展示了一个虚拟进程(程序)内存空间运行时分布布局,注意到此时还多了堆&栈用来给程序运行时进行空间分配。
-
一个程序(比如hello.out)本质是由数据段、代码段、.bss段(图中和数据段合并了)三个组成的。
-
另外,高地址的1GB(Windows默认2GB)空间分配给内核,也称为内核空间;剩下的3GB分给用户,也称用户空间(程序使用的)。
作为程序员,我们更关注的是用户空间中的内容,也就是:
-
栈(Stack):存储代码中调用函数、定义局部变量(但不包含static修饰的变量)、保存的上下文等;
-
特点:存放的数据从栈顶(低地址)压入,也是从栈顶(低地址)弹出,所以有人说栈是向下生长的。函数退出时,所有数据会自动释放内存(出栈)。
-
-
文件映射区域 : 栈和堆中间那个空白区域。动态库、共享内存等映射物理空间的内存,一般是
mmap
函数所分配的虚拟地址空间。 -
堆(Heap):存储那些生存期与函数调用无关的数据,如动态分配的内存。堆(动态)分配的接口通常有malloc()、calloc()、realloc()、new等。
- 特点:相对于栈,堆是向上生长的;堆空间需要主动释放,否则会依然存在。
-
.bss段:全称Block Started by Symbol,也就是未被初始化的全局变量、静态变量的内容的一块内存区域。比如:
1
2
3
4
5static int a; // a保存.bss段
int b; // b保存在.bss段
int main()
{
} -
数据段(.data):保存全局变量、常量、静态变量的内容的一块内存区域,区别.bss段在于变量已经被初始化。比如:
1
2
3
4
5
6
7static int a = 1; // a保存在.data段中静态区,1是文字常量在代码区
int b = 2; // b保存在.data段中全局区,2是文字常量在代码区
char* str = "royhuang"; // str保存在栈上,"royhuang"是字符常量,保存在常量区
const int c = 3; // c保存在.data段中常量区,3是文字常量在代码区
int main()
{
} -
代码段(.text & .init):
.text
用于存放整个程序中的代码,.init
用于存放系统中用来初始化启动你的程序的一段代码 。
一个程序本质其实都是由.bss段、数据段、代码段三个组成的。
静态存储区探讨
前面我们认识到C++内存布局分为:
- 堆:存放动态分配的内存;
- 栈:存放临时分配局部变量;
- 代码段:存储程序指令,也可能包含只读的常量(如文字常量);
- .bss段和数据段:分别存储初始化和未初始化的
全局变量
和静态变量
,数据段还可以存储常量。
.bss段和数据段主要区别在于是否初始化,这里我们不做区分,统称为静态存储区。
根据.bss段和数据段存储的数据类型,静态存储区又可分为:
- 静态变量区,存储静态变量
- 全局变量区,存取全局变量
- 常量区,存储字符串常量和全局常量(局部常量存储在栈中),只读
示例代码:
1 | // a保存在静态变量区,1是文字常量在代码区,此时a值为1 |
对应汇编代码也验证了这一点(静态变量区内容汇编代码未展示):
还应注意到:
- 存储在静态存储区的变量的值,在编译期间就确定了;
- 而堆、栈上变量的值是运行时动态分配的。
继续分析下面这个问题:
1 | int main() |
char str[] = "royhuang"
,str
保存在栈上,运行时将常量区的字符串常量“royhuang”,赋值给了数组str
,修改str
数组成员是合法的;char* pstr = "royhuang"
,pstr
保存在栈上,但运行时只是将字符串常量“royhuang”的地址,赋值给了指针pstr
,尝试修改常量区的成员是非法的!
3.8.2 函数调用过程
在这一小节我们来深入探讨下函数调用时的原理和过程。
栈帧
前面我们说到,栈是位于进程的高地址位置,且栈顶是向下增长的。在函数调用时,栈会专门使用一个独立的栈帧保存函数调用需要的所有信息。这对后面理解函数执行过程很关键。
一个典型的栈帧如下:
- 栈帧保存的内容:每一次函数调用需要的函数返回地址、参数、临时变量、保存的上下文等;
esp
和ebp
:非常重要的两个寄存器,记录了当前栈帧的栈顶位置和栈底位置(对于X86-64
平台上来说,对应的寄存器则为rsp
及rbp
) 。- 可以看到在上图压栈(push)会使得esp向下移动(地址变小)。
汇编分析
我们准备的验证代码:
1 |
|
反汇编的代码及分析如下。
-
main函数汇编代码
1
2
3
4
5
6
7
8
9
10
11main:
pushq %rbp # 寄存器rbp保存上一个栈帧栈底位置
movq %rsp, %rbp # 寄存器rsp指向栈顶位置,用rsp内容初始化寄存器rbq
subq $16, %rsp
movl $2, %esi # 立即数寻址,将foo第二个参数存入寄存器esi中
movl $1, %edi # 将foo的第一个参数存入寄存器edi中
call _Z3fooii # 调用foo,转到分析foo函数汇编代码,此时call还将下一条指令当做返回地址压入到栈中
movl %eax, -4(%rbp) # 返回函数结果给res,它的地址是-4(%rbp)
movl $0, %eax # eax被置为0,主函数退出
leave
ret -
foo函数汇编代码
1
2
3
4
5
6
7
8
9
10
11
12_Z3fooii:
pushq %rbp # 保存main的栈底地址rbp,用于返回
movq %rsp, %rbp # 重置foo函数的栈帧
movl %edi, -20(%rbp) # 参数1入栈
movl %esi, -24(%rbp) # 参数2入栈
movl -20(%rbp), %edx # 参数1存入edx
movl -24(%rbp), %eax # 参数2存入eax
addl %edx, %eax # 加法运算,结果保存在eax中
movl %eax, -4(%rbp) # 将运算结果赋值给result,它的位置为rbp-4
movl -4(%rbp), %eax # 将result赋值给eax,eax为函数返回值
popq %rbp # 跳转到main函数(栈底),然后还需要跳到返回地址
ret
栈压入顺序:当前栈帧状态信息(如rbp)—> 当前函数的参数(逆序)—>当前函数的局部变量等—>…—>退出。
- 在调用函数(main)函数中
- 保存上一个栈帧的rbp,重置main栈帧的rbp;
- 按照与被调函数foo的形参顺序相反的顺序压入栈中(在本例是直接存入寄存器中,因为直接传入常量foo(1,2));
- call指令调用: 调用者函数(main)使用call指令调用被调函数(foo),为foo函数分配栈帧空间,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作是隐含的)
- 在被调用函数(foo)函数中
- 保存main栈帧的rbp,重置foo栈帧的rbp;
- 被调参数sp1、sp2压入栈中;
- 执行foo函数体中代码,返回值res入栈,然后保存在寄存器eax中 ;
- 执行完毕,栈帧所有数据出栈;
popq %rbp 和 ret
跳转main中call指令的下一条指令地址继续执行。
- 回到调用函数(main)函数中
- 返回值res(寄存器eax中内容)压入栈;
- 继续main函数中后续代码执行;
- main函数退出。
3.8.3 malloc/free 原理
从静态分配说起
我们之前接触数据通常保存在:
- 栈,比如函数内部局部变量;
- 数据段,静态区、全局区、常量区;
- .bss段,未初始化的数据。
上面的数据有两个共同点:
- 变量/数组的大小必须在编译时知道。
- 内存分配和释放自动发生(当变量被实例化/销毁时)。
大多数时候,这很好。那什么时候在堆上分配内存?
很多时候,我们需要在堆上动态申请/释放内存。
- 不知道分配对象的大小。比如我们想创建声明一个数组,但是事先并不知道数组的大小(稍后才能知道)。这个时候无法使用静态数组分配,因为它必须指定数组的大小;
- 分配的对象太大。栈等空间不够。
在C中,我们常常使用malloc/free在堆上分配和释放内存:
1 | // 分配:大小为4的int数组 |
malloc和free函数底层是如何去实现的,如何在堆上分配内存的?
从堆块说起
什么是堆?
C++使用动态内存分配器(下简称分配器)进行动态内存分配,它维护一个进程的虚拟内存区域,也就是堆。
分配器眼里的堆是什么?如何进行管理?
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。
chunk结构图:
- 正在使用的chunk标识意义
prev_size
: 表示前一个 chunk 的 size,程序可以使用这个值来找到前一个 chunk 的开始地址(P=0
才有效);size
: 表示当前chunk的size大小;A
:为1表示属于主分配区(已分配),否则属于非主分配区(未分配);M
:为 1 表示该 chunk 是从 mmap 映射区域分配的,否则是从 heap 区域分配的;P
:为1表示前一个chunk在使用中,为0表示空闲。
- 空闲chunk标识意义
fd、bk
:分别指向前、后的空闲chunk,通过这种方式将将大小相近的chunk连成一个双向链表;
具体来说,分配器通过bins+空闲链表(非常类似哈希表),对堆块进行管理。
一个典型的堆整体结构:
可以看到,内存分成了大小不同的chunk,然后通过bins来组织起来;相似大小的chunk用双向链表链接起来,一个链表被称为bin。
- unsorted bin:即bin[1](bin[0]没有被使用),用于维护free释放的chunk;
- small_bins:即bins[2,63),用于维护<512字节的内存块,其中每个元素对应的链表中的chunk大小相同,均为
index*8
; - large_bins:即bins[64,127),用于维护>512字节的内存块,每个元素对应的链表中的chunk大小不同,index越大,链表中chunk的内存大小相差越大;
在堆中,还有一个很特殊的top chunk。
- 产生时机:程序第一次进行 malloc 的时候,heap 会被分为两块,一块给用户,剩下的那块就是 top chunk,所以它不属于任何一个 bin;
- 作用:在于当所有的 bin 都无法满足用户请求的大小时,就使用top chunk进行分配,并将剩下的部分作为新的 top chunk。堆顶指针brk位于top chunk的顶部,移动brk指针,即可扩充top chunk的大小。
现在我们再来探讨malloc分配的过程。
malloc原理
glibc在内存池中查找合适的chunk时,采用了最佳适应的伙伴算法。
第一次分配内存时会进行堆初始化:
- 一开始时,brk和start_brk是相等的,这时实际heap大小为0;
- 如果第一次用户请求的内存大小< mmap分配阈值,则(通过移动brk指针扩展堆大小)malloc会申请(chunk_size+128kb) align 4kb大小的空间作为初始的heap。
往后会按照顺序:bins查找分配–>brk扩展堆分配—>mmap分配:
-
如果分配内存<max_fast=默认64B,在fast bins 中查找相应的空闲块;
-
如果分配内存<512字节,则通过内存大小定位到smallbins对应的index上:
- 如果smallbins[index]为空,进入步骤3;
- 如果smallbins[index]非空,直接返回第一个chunk。
-
如果分配内存>512字节,定位到largebins对应的index上:
- 如果largebins[index]为空,进入步骤3;
- 如果largebins[index]非空,扫描链表,找到第一个大小最合适的chunk,如size=12.5K,则使用chunk B,剩下的0.5k释放并放入unsorted_list中。
-
遍历unsorted_list:
- 查找合适size的chunk,如果找到则返回;
- 否则,将这些chunk都归类放到smallbins和largebins里面。
-
在bin更大的链表寻找:从index++更大的链表继续寻找:
- 查找合适size的chunk,如果找到则返回,同时还会将chunk拆分,并将剩余的加入到unsorted_list中。
- 找不到则通过top chunk。
-
通过top chunk分配:默认top chunk分配的最大大小为128K(也是mmap分配的阈值):
-
top chunk大小>分配的内存,则返回合适大小chunk,剩余的作为新的 top chunk;
-
top chunk大小<分配的内存<128K,移动brk指针扩展top chunk大小,满足分配;
-
分配的内存>128K,通过mmap分配内存。
-
-
通过mmap分配。在进程的虚拟地址空间中(堆和栈中间的文件映射区域)找一块空闲的虚拟内存返回地址。
总结以上,内存分配(malloc)主要由两种系统调用完成:brk和mmap(不考虑共享内存)。
- brk:分配内存 < DEFAULT_MMAP_THRESHOLD,走
__brk
,将数据段(.data)的最高地址指针_edata
,往高地址推; - mmap:分配内存 > DEFAULT_MMAP_THRESHOLD,走
__mmap
,直接调用mmap系统调用。在进程的虚拟地址空间中(堆和栈中间的文件映射区域)找一块空闲的虚拟内存。
其中,DEFAULT_MMAP_THRESHOLD是mmap分配阈值,默认为128K。
值得注意的是,这两种方式分配的都是虚拟内存,没有分配物理内存。只有实际调用发生缺页中断时,才会建立虚拟内存和物理内存之间的映射关系。
不过这里有个小问题,既然
brk、mmap
提供了内存分配的功能,直接使用brk、mmap
进行内存管理不是更简单吗,为什么需要glibc呢?
因为系统调用本身会产生软中断,导致程序从用户态陷入内核态,比较消耗资源。试想,如果频繁分配回收小块内存区,那么将有很大的性能耗费在系统调用中。
因此,为了减少系统调用带来的性能损耗,glibc采用了内存池的设计,增加了一个代理层,每次内存分配,都优先从内存池中寻找,如果内存池中无法提供,再向操作系统申请。
free原理
前面我们说到,C++将对视为堆视为一个个chunk(block)的集合,每个chunk的结构如下:
malloc分配好内存,返回的是User data的起始地址,header则保存了当前chunk的一些信息。
但free并非真的直接将相应内存区域返回操作系统:
-
如果
free
释放mmap
分配内存,free
可以很顺利就释放掉其相关的虚拟和物理内存,返回操作系统; -
如果
free
释放brk
分配的内存,free
只是标记chunk可被重新分配并加入空闲链表(A=0),但没有真的删除任何数据!1
2
3
4
5
6int* a = new int(1);
cout<<"a = "<<*a<<endl; // 1
free(a);
cout<<"a = "<<*a<<endl; // 0,数据被清除,但没被操作系统回收
*a=2;
cout<<"a = "<<*a<<endl; // 2,依据可以被使用,虽然这很危险具体加入到空闲链表何处,也分三种情况考虑:
- 如果该chunk和top chunk相邻,则将其和top chunk合并;
- 特别的,如果top chunk>128K,会执行内存紧缩(trim)操作移动brk指针。
- 如果free的chunk的大小在0x20~0x80之间(fastbin的范围),那么该chunk直接放到fast bin上;
- 如果free的chunk的大小大于0x80(大于fast bin的范围),此时不会直接放到small bin或者large bin上面,而是放到unsorted bin上面;
- 如果该chunk和top chunk相邻,则将其和top chunk合并;
-
最佳实践:删除一个指针时,请将指针设置为 nullptr。
否则的话,使用一个指向的内存已经被释放的一种指针,是使用悬空指针。
(区分野指针是指使用时还没被初始化)
1
2
3cout<<*a<<endl; // 危,此时a是悬空指针,已经被free(a)
a = nullptr; // 最佳实践:将已经释放的指针置空
cout<<*a<<endl; // 此时编译器会报错,ok
可以看到,如果free的chunk没有和top chunk相邻被合并,其又太小的话,可能永远不会被使用——这就产生了内存碎片。
如下图:
free(A)后,由于chunk A无法和top chunk合并,在堆中便产生了内存空洞,也就是内存碎片。幸运的是,它还足够大(40K),有很大概率其空间是能够被利用的。
小细节:free如何知道释放多大空间?
在前面地址传递我们知道:向一个函数传递指针的时候,它的长度信息往往会被截断(如果是数组名),无法标示数组的长度。因此,在函数中我们也无法获取它的长度信息,除非显示传递一个长度参数。
那free如何知道释放多大的空间?
原因很简单,free根本不用关系chunk大小,malloc分配的时候就是分配一个合适大小chunk。chunk包含header和user data。free只是将header中的相关标记为可用,加入空闲链表中即可。
3.8.4 new /delete 原理
在3.8.3节介绍了C语言中的动态内存分配方式:malloc,malloc函数分配失败返回指针空值,成功返回首地址。
现在我们来认识一下C++中的动态内存管理方式:new和delete。
准备好一个公共类Stu
:
1 |
|
new原理
无论是new还是delete,它们在内置类型/自定义类型上使用有些不同。
-
new底层实现(内置类型)
对于内置类型来说,new和malloc功能基本一致:
- 分配内存空间;
- 返回内存空间地址。
不过new还可以进行初始化。
1
2
3
4// 初始化单个:等同于int* a = (int*)malloc(sizeof(int));
int* a = new int;
// 初始化单个:同时进行初始化
int* c = new int(10);new在C++中被定义为一个运算符,它会在底层调用全局函数operator new。
我们可以通过查看
int* array = new int;
对应汇编源码验证:1
2
3
4
5
6# 首先在寄存器edi中存入数组大小(4,一个int大小)
movl $4, %edi
# 调用operator new分配空间
call operator new(unsigned long)
# 寄存器rax保存new返回的首地址给a(对应地址 -16(%rbp))
movq %rax, -16(%rbp)operator new只是malloc的一层封装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25void* __CRTDECL operator new(size_t const size)
{
for (;;)
{
// malloc
if (void* const block = malloc(size))
{
return block;
}
if (_callnewh(size) == 0)
{
if (size == SIZE_MAX)
{
__scrt_throw_std_bad_array_new_length();
}
else
{
__scrt_throw_std_bad_alloc();
}
}
// The new handler was successful; try to allocate again...
}
}-
执行流程:
-
当malloc申请空间成功时直接返回
return (p)
; -
申请空间失败,尝试执行空间不足应对措施(malloc中失败直接返回空指针);
-
如果改应对措施用户设置了,则继续申请,否则抛异常。
-
-
从源码中能看出的是operator new在底层是利用malloc分配内存,因此可以说new只是malloc一层封装。
-
new底层实现(自定义类型)
但对于自定义类型,new还会调用构造函数初始化:
- 调用operator new为对象分配内存;
- 调用对象的构造函数对对象进行初始化;
- 返回分配的内存空间地址。
分析下例:
1
2
3
4
5
6
7
8
9int main()
{
// malloc分配对象
cout << "malloc:" << endl;
Stu* a = (Stu*)malloc(sizeof(Stu));
// new分配对象
cout << "new:" << endl;
Stu* b = new Stu(1, "张三");
}输出:
1
2
3malloc:
new:
自定义构造函数初始化可以看到malloc分配的方式不会调用构造函数,但是new还会调用构造函数。
因此也可以理解为malloc分配出来的只不过是一个和类一样大小的空间(在前面我们称为chunk),并不能称作是一个对象,而new和delete分配出来的才能被成为对象。
-
new与new[]
new[]和new有些细微区别:
-
new[]是调用operator new[]对多个对象进行分配,operator new[]本质还是多次调用operator new;
但operator new[]还会多申请4个字节的空间保存此次对象个数。
为什么要这么做?
使用delete[] 释放空间时可以知道被释放的对象个数。
-
当然,对于自定义类型,类似new,还会调用N次构造函数对N个对象进行初始化。
举例说明。
1
2
3
4
5
6// malloc分配对象
cout << "malloc:" << endl;
Stu* a = (Stu*)malloc(3*sizeof(Stu));
// new分配对象
cout << "new:" << endl;
Stu* b = new Stu[3];输出:
1
2
3
4
5malloc:
new:
默认构造函数初始化
默认构造函数初始化
默认构造函数初始化
-
delete原理
delete也分两种情况讨论:
- 对于内置类型:底层调用free实现,和free无多大区别(也没有异常处理);
- 对于自定义类型:除了调用free释放对象空间,在此之前还会调用对象的析构函数。
通过具体例子来看看。
-
delete底层实现(内置类型)
先看一个简单的例子:
1
2int* a = new int;
delete a;查看对应的汇编源码,核心是调用operator delete:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17/*int* a = new int;*/
# 首先在寄存器edi中存入数组大小(4,一个int大小)
movl $4, %edi
# 调用operator new分配空间
call operator new(unsigned long)
# 寄存器rax保存new返回的首地址给a(对应地址 -16(%rbp))
movq %rax, -16(%rbp)
/*delete a*/
movq -16(%rbp), %rax
testq %rax, %rax
je .L2
movl $4, %esi
movq %rax, %rdi
# 核心是调用operator delete
# 前面在干什么?暂不清楚。
call operator delete(void*, unsigned long)operator delete也只是free的一层封装(没有异常处理):
1
2
3
4
5
6
7
8void __CRTDECL operator delete(void* const block) noexcept
{
_free_dbg(block, _UNKNOWN_BLOCK);
free(block);
} -
delete底层实现(自定义类型)
但对于自定义类型,delete还会调用析构函数:
- 调用对象的析构函数清理对象;
- 调用operator delete清理内存;
一个小例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14int main()
{
// malloc分配对象
cout << "malloc:" << endl;
Stu* a = (Stu*)malloc(sizeof(Stu));
cout << "free:" << endl;
free(a);
// new分配对象
cout << "new:" << endl;
Stu* b = new Stu;
cout << "delete:" << endl;
delete b;
return 0;
}输出:
1
2
3
4
5
6malloc:
free:
new:
默认构造函数初始化
delete:
析构销毁可见free不会调用析构函数,但delete会。
-
delete与delte[]
delete[]用来删除多个对象(和new[]成对出现),本质是对每个对象调用delete清理。
大致流程如下:
-
首先根据对象数组前4个字节,获取对象个数N;
-
对后续每个对象使用delete删除。
同样的,对于自定义类型还会调用对象的析构函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16int main()
{
// malloc分配多个对象
cout << "malloc:" << endl;
Stu* a = (Stu*)malloc(3*sizeof(Stu));
cout << "free:" << endl;
// free释放内存
free(a);
// new[]分配对象
cout << "new:" << endl;
Stu* b = new Stu[3];
cout << "delete:" << endl;
// delete[] 释放内存
delete[] b;
return 0;
}输出:
1
2
3
4
5
6
7
8
9
10malloc:
free:
new:
默认构造函数初始化
默认构造函数初始化
默认构造函数初始化
delete:
析构销毁
析构销毁
析构销毁这里有两个有意思的小细节:
-
malloc
出的对象不会调用析构函数; -
我们只用了一次
free(a)
释放了分配对象数组a
,而对象数组b
实际通过delete[]
调用了多次delete
。
这个细节也验证了我们之前结论:
malloc
分配出来的只不过是一个我们需要的大小(这里是3个stu
)的内存空间,它并没有真的分配了3个stu
对象,所以free
一次就可以释放这块内存。- 但
new[]
是真正分配了3个stu
对象内存空间,所以要delete
多次且会执行析构函数。
-
plain/nothrow/placement new
在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new。
plain new
言下之意就是普通的new,就是我们常用的new,在C++中定义如下:
1 | // plain new原型 |
可见plain new
在空间分配失败的情况下,抛出异常std::bad_alloc
而不是返回NULL。
因此通过判断返回值是否为NULL是徒劳的,请使用try-catch
捕获异常:
1 |
|
输出:
1 | bad allocation |
nothrow new
nothrow new在空间分配失败的情况下不抛出异常,而是返回NULL,定义如下:
1 | // nothrow new原型 |
举个例子:
1 |
|
输出:
1 | alloc failed |
placement new
placement new
允许在一块已经分配成功的内存上重新构造对象或对象数组。
placement new
不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:
1 | // placement new原型 |
- 主要用途:反复使用一块较大的动态分配的内存,来构造不同类型的对象或者他们的数组;
- 使用析构函数删除而不是delete:placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存)。这是因为placement new构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏或者之后释放内存时出现运行时错误。
举个例子:
1 |
|
输出:
1 | ADT construct i=10,j=100 |
小结
- new和delete分别是malloc和free的一层封装,对于自定义类型还会分别调用构造函数初始化/析构函数清理内存。不过new相比malloc还会有一层申请空间失败应对措施,以及可以初始化。
- new[]/delete[],是分别用来分配对象数组/清理对象数组的,本质是多次调用了new/free。值得注意的是,new[]分配的对象数组,还会多分配4个字节标识对象的个数。
- 在C++中有plain new,nothrow new和placement new。plain new就是最普通的new,分配错误返回bad alloc异常;nothrow new分配失败返回NULL异常;placement new不分配内存,请只调用析构函数进行释放,否则可能会导致double free。
3.8.5 问答测验
本节是八股重灾区,因为特地准备一些常见面试问题来巩固所学。
-
堆栈的区别?
主要的区别如下:
-
申请方式:栈由系统自动分配,比如函数中的局部变量
int a
,调用函数时会自动为a
开辟空间;堆是程序员申请并负责释放,并指明大小,例如int* array= new int[LEN]{1,2,3};
,但指针array
本身是存于栈中。 -
存储内容:栈存放和函数相关的数据,如定义局部变量(但不包含static修饰的变量)、保存的上下文等;堆存放函数无关的数据,如动态分配的内存。
-
大小限制:栈空间默认是4M,连续的 ;堆是不连续的内存区域,一般是 1G - 4G,大小受限于计算机系统中有效的虚拟内存。
-
申请效率:栈由系统自动分配,计算机在底层对栈提供支持,速度快;堆是由程序员new分配的内存,一般速度比较慢,但更灵活。
-
存储效率:栈的存取效率更高。请看下例:
1
2
3
4
5
6
7
8
9int main()
{
int arr1[] = {1,2,3};
int* arr2 = new int[3]{1,2,3};
int a = arr1[0];
int b = arr2[0];
return 0;
}转成汇编语言分析:
-
-
new / malloc 的异同?
都可用于内存的动态申请,返回用户分配使用空间的首地址。
-
本质:new是关键字,malloc是函数,所以malloc还需要引入库文件,new不用;
-
返回值类型:malloc返回的是void类型指针(必须进行类型转换),new返回的是具体类型指针;
-
空间计算:new会自动分配空间大小,编译器会根据类型信息自行计算,malloc需要手工计算;
-
类型安全:new是类型安全的,malloc不是;
1
2int *p = new float[2]; // 编译错误
int *p = (int*)malloc(2 * sizeof(double));// 编译无错误 -
构造函数: new调用名为operator new的标准库函数分配足够空间,如果是自定义类型还会调用相关对象的构造函数,malloc则不会;
-
分配失败措施:new是malloc的一层封装,如果分配失败还会有相应措施执行,抛出bac_alloc异常;malloc返回null。
-
-
free / delete 的异同?
都可用于内存的动态释放。
- 本质:delete 是关键字,free 是函数,所以free 还需要引入库文件,delete 不用;
- 返回值类型:free返回的是void类型指针(必须进行类型转换),delete 返回的是具体类型指针;
- 析构函数: delete 调用名为operator delete的标准库函数分配足够空间,如果是自定义类型还会调用相关对象的析构函数,free 则不会。
-
new实现原理?delete实现原理?
- new原理:operator new分配内存 (底层是malloc实现)—> (自定义类型)分配的内存上调用构造函数初始化—>返回指向该对象的指针;
- delete原理:operator delete清理内存 (底层是free实现)—> (自定义类型)删除内存前还会调用析构函数;
-
malloc/free底层原理?
参考前文。这里只简略说明:
- malloc:brk初始化分配–>后续分配通过内存池:bins+双向链表实现—>太大则top chunk 和mmap分配;
- free:将chunk标记可使用,并加入空闲链表。在上一个步骤free的时候,发现最高地址空闲内存超过128K,还会内存紧缩。
-
被free回收的内存是立即返还给操作系统吗?
如前,free回收只是标识这块内存空闲,同时会加入空闲链表中等待下一次分配。
-
delete和free可以混用吗?
我们知道delete也只是free一种封装,只有自定义类型时delete会调用析构函数。new和malloc同理。
所以在以下情况是可以free/delete混用:
-
对象是基本类型时。
free掉new申请的内存。
1
2
3int* a = new int(1);
// delete a;
free(a);delete掉malloc申请的内存。
1
2int* a = (int*)malloc(sizeof(int));
delete a;上面也适用于new/malloc混用。
-
自定义的类型,但没有显示定义析构函数。
delete和free混用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class test
{
private:
int a;
public:
test(){a = 0;};
};
int main()
{
test* t1 = (test*)malloc(sizeof(test));
delete(t1);
test* t2 = new test();
free(t2);
}
如果是自定义类型,malloc混用new需要显示调用构造函数的逻辑实现(或者该类不会有构造函数作用)。
-
-
malloc、realloc、calloc的区别?
-
malloc
malloc申请20个int类型的空间。
1
2// void* malloc(unsigned int num_size);
int *p = malloc(20*sizeof(int)); -
calloc
malloc申请的空间的值是随机初始化的,calloc申请的空间的值是初始化为0的;
1
2// void* calloc(size_t n,size_t size);
int *p = calloc(20, sizeof(int)); -
realloc
给动态分配的空间分配额外的空间,用于扩充容量。
1
// void realloc(void *p, size_t new_size);
-
-
为什么C++没有垃圾回收机制?这点跟Java不太一样。
- 资源消耗。实现一个垃圾回收器会带来额外的空间和时间开销;
- 你需要开辟一定的空间保存指针的引用计数和对他们进行标记mark;
- 然后需要单独开辟一个线程在空闲的时候进行free操作。
- C++本身原因。垃圾回收会使得C++不适合进行很多底层的操作。
- 资源消耗。实现一个垃圾回收器会带来额外的空间和时间开销;
3.9 Lambda表达式
很多时候我们常常需要定义一个简单、甚至只被使用一次的函数,额外地定义了一个函数调用既浪费了空间,甚至未必有简化成一行代码好理解。
另外,前面的学习我们了解到C++是不允许函数嵌套的,但lambda可以被函数嵌套(lambda并不是函数,下文细说)。
lambda表达式就为我们做了这么一件事:将一个简单函数简化为一行,还允许我们在一个函数进行嵌套lambda。
3.9.1 lambda初识
第一个lambda
C++中lambda表达式格式如下:
1 | [ captureClause ] ( parameters ) -> returnType |
captureClause
:允许我们使用lambda外部的变量,这一点在3.9.5中详述,这里先略过;parameters
:传递的参数,可空;returnType
:返回类型,编译器会自动推断,可空;Statement
:代码体,一般就一行,当然多行也行。
一个最简单的lambda表达式可定义为:
1 | [](){} |
现在举例说明用法。
我们之前写过一个冒泡函数bubbleSort
:它有一个参数,允许我们传递定义排序规则,即myCompare
函数来实现排序。
1 |
|
我们将这个myCompare
函数改为更简洁lambda
表达式:
1 | bool myCompare(int a, int b) |
改为:
1 | [](int a, int b){return a > b? false: true;} |
这样我们如此调用bubbleSort函数:代码更加的紧凑,上下文更清晰,一眼明白我们排序的规则。
1 | // bubbleSort(array,len,myCompare); |
输出:
1 | 1 |
lambda本质探讨🌟
虽然3.9.2节(下节)的名字叫做“lambda与函数指针”,但值得提前声明的一点是:lambda并不是函数,它是一种特殊的对象,称为函数对象(也称为函数子)。函数不能嵌套,但lambda可以。
怎么理解函数对象?
C++函数对象实质上是实现了对()操作符的重载。C++函数对象不是函数指针,但它的调用方式与函数指针一样,后面加个括号就可以了。
1 | class Add |
函数对象相比普通函数有什么好处吗?
-
函数对象带有状态:函数对象相对于普通函数是“智能函数”,这就如同智能指针相较于传统指针。因为函数对象除了提供函数调用符方法,还可以拥有其他方法和数据成员。所以函数对象有状态。即使同一个类实例化的不同的函数对象其状态也不相同,这是普通函数所无法做到的。而且函数对象是可以在运行时创建。
-
每个函数对象有自己的类型:对于普通函数来说,只要签名一致,其类型就是相同的。但是这并不适用于函数对象,因为函数对象的类型是其类的类型。这样,函数对象有自己的类型,这意味着函数对象可以用于模板参数,这对泛型编程有很大提升。
-
函数对象一般快于普通函数:因为函数对象一般用于模板参数,模板一般会在编译时会做一些优化。
回到lambda表达式。
我们看看一个lambda表达式是如何被转换成函数对象。
准备代码:
1 | int main() |
依旧是https://cppinsights.io/,观察编译过程变化:
编译器如何将我们写的lambda表达式转换为函数对象便一目了然:
- 首先为我们生成类
__lambda_3_16
(①处),它有一个内联函数(②处),重载了符号()
; - 代码30行处,为我们生成了lambda函数对象
add
,也就是我们定义的lambda表达式名; - 随后调用了函数对象
add
(③处),完成整个转换过程。
存储lambda
虽然一般来说我们不需要像函数一样给lambda表达式一个名字,但是有时候也需要能将lambda表达式(作为右值)存储起来,供以后使用。
在C++中提供三种方式:
- 使用函数指针方式存储:只有在
capture clause
为空时; - 使用function方式存储:
capture clause
不为空时,也可以使用;
先来看看函数指针方式。
函数指针指向不仅限于函数,只要是函数对象都可以指向。lambda作为函数对象,自然是可以被其存储的。
1 | bool (*myCompare)(int, int) = [](int a, int b) {return a > b? false: true;}; |
用 auto
关键字可简化函数指针类型书写:
1 | auto myCompare = [](int a, int b) {return a > b? false: true;}; |
再来关注下std::function
方式。
前面我们提到过:std::function
本质就是类模板,也就是函数容器,可以将函数指针/函数转换成(函数)对象进行处理。
1 | function<bool(int,int)> myCompare = [](int a, int b) {return a > b? false: true;}; |
lambda参数
Lambda表达式的参数和普通函数的参数相比有些限制(C++11):
-
参数列表中不能有默认参数;
-
不支持可变参数(比如使用auto关键字);
1
2// C++11:编译出错,参数类型无法使用auto
auto myCompare = [](auto a, auto b) {return a > b? false: true;}; -
所有参数必须有参数名。
关于第2点,从C++14
开始,lambda
表达式支持泛型:其参数可以使用自动推断类型的功能,而不需要显示地声明具体类型。
具体介绍参考3.9.2节。
3.9.2 泛型lambda(C++14)
什么是泛型lambda?
从 C++14 开始,我们被允许auto
用于参数。当 lambda 具有一个或多个auto
参数时,编译器将从对 lambda 的调用中推断出需要哪些参数类型。
这使得具有一个或多个auto
参数的lambda可适用于多种类型,它们也被称为泛型 lambda。
看一个小例子:
1 | int main() |
是不是非常像模板函数?
1 | template <typename T> |
add(2, 3)
、add(2.5, 3.4)
分别让函数模板生成了函数实例 add(int, int)
、 add(double, double)。
auto关键字自动推断lambda参数类型,让lambda表达式起到了和模板函数相同的效果。前面我们说到,模板函数会为每种不同类型生成实例函数:那泛型lambda也会为不同类型生成一个lambda表达式吗?
答案是肯定的。
完整代码如下:
1 | int main() |
在上面代码中:
-
编译器自动生成了lambda类
__lambda_3_16
-
类
__lambda_3_16
,根据40、41行函数调用的不同参数类型,分别生成了两个成员函数。1
2
3
4
5template<>
inline /*constexpr */ int operator()(int x, int y) const
template<>
inline /*constexpr */ double operator()(double x, double y) const注意到,两个函数都重载了操作符
()
。 -
代码39行,生成了类
__lambda_3_16
对象add
,add
就是我们定义的泛型lambda名,显然此时add
也是函数对象,可以用名字()
方式调用。1
__lambda_3_16 add = __lambda_3_16{};
-
代码40、41行调用了不同的类
__lambda_3_16
不同重载函数。1
2int x = add.operator()(2, 3);
double y = add.operator()(2.5, 3.3999999999999999);
更好地理解泛型闭包行为🌟
前面已经说到,泛型lambda会为每种不同类型生成成员函数 。
同时,我们也了解局部静态变量同样具有全局的生命周期,下面这个例子展示了这种特性:
1 |
|
输出:
1 | the type of your input:i |
count
虽然是局部变量,但是由于是静态变量函数存储在全局数据段,和堆栈无关,退出也不会销毁。所以被myCount函数共享,可进行计数。
但是如果我们myCount
替换成泛型函数,即允许参数类型为auto,一切开始变得不一样了。
1 |
|
输出:
1 | the type of your input:i |
此时静态变量count
没有被myCount
函数共享?这是因为此时myCount
函数已经是泛型函数。
- 执行代码第13行,推断出
value
为int
类型,编译器生成函数int myCount<int>(int value)
,它拥有自己作用范围的静态变量count
; - 执行代码第14行,类似的,无法匹配上一次生成函数
int myCount<int>(int value)
。所以编译器还需生成函数int myCount<double>(double value)
,它也拥有自己作用范围的静态变量count
。
两个myCount
是重载函数,但它们并不一样,自然无法共享各自的局部静态变量count
。
类似的结果也出现在泛型lambda、函数模板中。
下面以泛型lambda为例进行展示:
1 |
|
输出:
1 | the type of your input:i |
查看编译转换后的代码验证:
3.9.3 使用标准库函数
和array配合时,lambda表达式会接受数组两个元素,定义它们的比较规则
lambda表达式常配合标准库函数使用,简洁优雅。
下面展示了几个排序用例。
-
例1:
std::sort
和lambda配合用在std::array
上1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main()
{
std::array<int,6> arr = { 13, 90, 99, 5, 40, 80 };
// 此时lambda参数接受数组两个元素,返回它们的比较规则(bool类型)
// 可以结合之前我们写的冒泡排序自定义规则理解,此时是在双重for循环中接受数组相邻两个元素进行比较
std::sort(arr.begin(), arr.end(), [](int a,int b ){return a>b;});
for (int i : arr)
{
std::cout << i << ' ';
}
std::cout << '\n';
return 0;
}输出:
1
99 90 80 40 13 5
对于常见的操作(例如加法、求反或比较),标准库附带了许多可直接使用的函数对象。
比如这个例子可用
std::greater
替代我们写的lambda表达式。1
std::sort(arr.begin(), arr.end(), std::greater);
-
例2:排序对象是结构数组、类数组、RDD等
struct Student
用来存储学生姓名和分数的,将下面Student
数组按成绩进行排序。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct Student
{
std::string name{};
int points{};
// 需要初始化函数
Student(const char my_name[],const int my_points)
{
name = std::string(my_name);
points = my_points;
}
};
std::array<Student, 8> arr
{
{
{ "Albert", 3 },
{ "Ben", 5 },
{ "Christine", 2 },
{ "Dan", 8 },
{ "Enchilada", 4 },
{ "Francis", 1 },
{ "Greg", 3 },
{ "Hagrid", 5 }
}
};实现流程如下:
- 明确lambda接受的参数是数组两个元素,这里元素是结构体
Student
对象; - 假设两个
Student
对象分别是stu1、stu2,它们比较的规则应该是: 。
1
2
3
4
5
6
7
8int main()
{
// 排序
std::sort(arr.begin(),arr.end(),[](Student s1,Student s2){return s1.points>s2.points;});
// 输出
std::for_each(arr.begin(),arr.end(),[](Student s){std::cout<<s.name<<std::endl;});
return 0;
} - 明确lambda接受的参数是数组两个元素,这里元素是结构体
3.9.4 捕获
为什么需要捕获
前面我们说过lambda表达式原理:
- 每定一个lambda表达式,会生成一个匿名类,这个类重载了
()
运算符; - 使用lambda表达式,其实就是返回一个闭包类实例。
既然是闭包,当然无法使用外部的变量,请看下例:
1 |
|
通过捕获可以让我们使用外部变量a
。
按值捕获
下面展示了按值捕获方式。
1 |
|
按值捕获只是将变量a
复制一份,而且由于lambda匿名生成了重载函数()
被const
修饰,所以不能在lambda表达式内部修改数据成员a
的值。
要想修改a
的值,需要声明为mutable
:
1 | [a]()mutable |
此时重载函数的const
关键字不会被声明。
但mutable
这种方式,我们依旧无法修改lambda外部变量a
的值,毕竟lambda内部变量a
只是值复制了一份。
1 |
|
输出:
1 | 1 |
要想能修改lambda外部的变量,有两种方式:
-
声明外部变量为
a
为static:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using namespace std;
int main()
{
static int a = 1;
auto print = []()
{
cout<<a++<<endl;
};
print(); // 1
cout<<a<<endl; // 2
return 0;
}注意到此时,不用mutable也可以捕获外部静态变量
a
,因为此时a
具有全局生命周期,可被隐式捕获(下个小节详细列出)。 -
用引用捕获的方式。
按引用捕获
引用捕获方式:
1 |
|
输出:
1 | 1 |
原因很简单,修改引用时会通过间接寻址到本体外部的变量a
,然后进行修改的。
混合捕获
上面的例子,要么是值捕获,要么是引用捕获,Lambda表达式还支持混合的方式捕获外部变量,这种方式主要是以上几种捕获方式的组合使用。
捕获形式 | 说明 |
---|---|
[] | 不捕获任何外部变量 |
[变量名, …] | 默认以值得形式捕获指定的多个外部变量(用逗号分隔),如果引用捕获,需要显示声明(使用&说明符) |
[this] | 以值的形式捕获this指针 |
[=] | 以值的形式捕获所有外部变量 |
[&] | 以引用形式捕获所有外部变量 |
[=, &x] | 变量x以引用形式捕获,其余变量以传值形式捕获 |
[&, x] | 变量x以值的形式捕获,其余变量以引用形式捕获 |
隐式捕获
前面介绍的都是显示捕获,在C++中,如果变量被声明为static或者const,其实是可以隐式捕获使用的。
请回答:下面变量哪些可以在main
不显式捕获它们的情况下使用?
1 | int i{}; |
除了a、f,其余都是被static或者const修饰,可以隐式被lambda捕获。
3.X 再谈 auto
前面我们或多或少了使用了auto关键字,它的作用也很明显:自动类型推断。
1 | auto a = 10; |
但像这种简单类型变量声明不建议使用auto关键字,直接写出变量的类型更加清晰易懂。
现在进行常见场景下auto总结&使用。
auto用法总结
-
代替冗长复杂、变量使用范围专一的变量声明
比如:存储函数指针或lambda表达式、模板对象声明等。
STL标准库将在后续进行总结。
想象一下在没有auto的时候,我们操作标准库时经常需要这样(难受啊兄弟):
1
2
3
4
5
6
7
8
9
10
int main()
{
std::vector<std::string> vs;
for (std::vector<std::string>::iterator i = vs.begin(); i != vs.end(); i++)
{
//...
}
}使用auto能简化代码(舒服了):
1
2
3
4
5
6
7
8
9
10
int main()
{
std::vector<std::string> vs;
for (auto i = vs.begin(); i != vs.end(); i++)
{
//..
}
}又如函数指针声明比较复杂,存储时不好书写:
1
2
3
4
5
6
7
8
9
10
11
12
13
using namespace std;
int func(int a)
{
cout<<a<<endl;
}
int main()
{
int (*p_func)(int,int) = func;
return 0;
}如果有lambda表达式,类似:
1
bool (*myCompare)(int, int) = [](int a, int b) {return a > b? false: true;};
auto优化后:
1
auto (*myCompare)(int, int) = [](int a, int b) {return a > b? false: true;};
-
在定义模板函数时,用于声明依赖模板参数的变量类型
若不使用auto变量来声明
v
,则无法进行定义,因为其类型无法确定。1
2
3
4
5
6template <typename _Tx,typename _Ty>
void Multiply(_Tx x, _Ty y)
{
auto v = x*y;
std::cout << v;
}在C++11中如果
v
作为返回值,还需要在尾部指定返回类型(否则会发出警告):1
2
3
4
5template <typename _Tx, typename _Ty>
auto multiply(_Tx x, _Ty y)->decltype(x*y)
{
return x*y;
}decltype用于计算出表达式
x*y
的类型。
注意事项
-
auto关键字必须初始化
需要在声明时auto就可以推断出变量类型。
1
2auto a = 10; // ok
auto b; // error -
auto会自动去除右值的引用、const语义,需要显示声明
右值引用语义被去除。
1
2
3
4int a = 10;
int& b = a;
auto c = b; // 此时c的类型是int不是int&需要显示声明为引用:
1
auto& c = b; // 此时c的类型才是int&
auto还会去除const语义。
1
2
3
4const int a = 10;
auto b = a; // 此时b为int类型,而不是const int
b = 100; // b可以被修改需要显示声明为const常量:
1
2const auto b = a;
b = 100; // 非法,无法被修改有个有意思的问题:如果auto& 还会去除const语义吗?理论上来说,此时引用是某种意义上等价本体的,自然类型也应该是一致的。
答案是不会的。
1
2
3
4const int a = 10;
auto& b = a; // 此时b为const int,const语义未被去除
b = 100; // 非法 -
auto关键字不去除指针的语义
请看下例,auto正确推导除了变量的类型为指针:
1
2
3
4
5
6
7
8
9int main()
{
int a = 10;
auto b = &a; // b是int*类型
cout<<*b<<endl; // 10
return 0;
} -
auto不能作为函数形参
这样做的话,声明的函数实际上就是个函数模板了。
1
2
3
4auto func(auto a)
{
...
}但这种做法在C++20中已被支持。
更新记录
- 3.8.1节增加了静态存储区相关描述
- 第一次更新
参考资料
- 1.C++中引用传递与指针传递的区别:https://blog.csdn.net/u013130743/article/details/80806179 ↩
- 2.C/C++ 程序的内存布局:https://blog.csdn.net/m0_45047300/article/details/118389444 ↩
- 3.内存分配的原理--molloc/brk/mmap:http://abcdxyzk.github.io/blog/2015/08/05/kernel-mm-malloc/ ↩
- 4.C++ lambda表达式与函数对象:https://www.jianshu.com/p/d686ad9de817 ↩
- 5.C++ auto 关键字的使用:https://cloud.tencent.com/developer/article/1660750 ↩