🌟《C++从零开始》 系列,开始更新中…

三、函数

3.1 基本概念

怎么定义函数?

以前,我比较倾向将函数定义为顺序执行的语句集合。现在我认为更恰当的说法应该是:函数是一个可重用的语句序列,旨在完成特定的工作
C++ 中函数一般形式如下:

1
2
3
4
return_type function_name( parameter_list )
{
// body of the function
}

上面包含一个函数的所有组成部分:

  • 返回类型(return_type):一个函数可以返回一个值,return_type 是函数返回的值的数据类型。不需要返回值,return_type 是关键字 void
  • 函数名称(function_name):函数的实际名称,函数名和参数列表一起构成了函数签名
    • 所谓”签名“则意味着这可以唯一标识一个函数。
  • 参数列表(parameter_list):参数就像是占位符。当函数被调用时,可向参数传递一个值,这个值被称为实际参数,参数列表包括函数参数的类型、顺序、数量
    • 参数列表的顺序、类型、数量不完全一致的话,形成不同函数签名,即是不同函数。
  • 函数主体(boby):函数主体包含一组定义函数执行任务的语句。

特别的,C++是不支持嵌套函数的。

1
2
3
4
5
6
7
8
9
10
11
#include<iostream>
using namespace std;

int main
{
// 编译出错
int max1(int num1=0, int num2)
{
return num1>num2?num1:num2;
}
}

3.1.1 前向声明🌟

前向声明初识

在很多IDE中,如果你尝试在main函数使用未在之前定义的函数,会出现编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<iostream>
using namespace std;

int main
{
max(1,2); // 编译错误
return 0;
}

// 函数返回两个数中较大的那个数
int max(int num1, int num2)
{
return num1>num2?num1:num2;
}

这是因为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
    #include<iostream>
    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*/
    #include<iostream>
    using namespace std;

    int max(int num1, int num2) ; // 前向声明,会在链接的时候寻找max的定义,编译器时不报错
    int main()
    {
    max(1,2); // 编译正确
    return 0;
    }

    编译(一切正常):

    1
    2
    g++ main.cpp max.cpp -o main.out
    ./main.out

但经过我的实践,在很多编译器中进行了优化。同一个文件的函数即使不进行前向声明,上面的代码也不会报错。

而且,在Google code style 也明确指出尽量不要使用前置声明:

尽可能地避免使用前置声明。使用 #include 包含需要的头文件即可。

image-20220116163129065

那么,前向声明还存在的意义是什么

  1. 减少编译时间。使用include也可以使用别的的文件中定义的变量,但是也会把头文件其它不需要的变量引入。如果只是需要使用很少的外部变量,使用前向声明能减少编译的头文件展开。
  2. 打破循环引用。
循环引用

前置声明现在用来解决多文件中循环引用的问题

以类循环引用为例。

想象这么一个情况:A.h定义了Class A,它需要引用B.h定义的Class B,也就是要写入#include “B.h” ;类似的情况,B.h也需要写入#include “A.h”

这就造成了两个类互相引用,但由于C++蛋疼顺序编译的规则。如果是先编译A.h,引用的类B则找不到实现的定义;如果是先编译B.h,则找不到类A的定义。形成“先有鸡还有先有蛋”的世纪难题。

我们举一个具体的例子。

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
/* A.h */
#ifndef A_H
#define A_H
#include "B.h"
class A
{
private:
B *b;
};
#endif


/* B.h */
#ifndef B_H
#define B_H
#include "A.h"
class B
{
private:
A *a;
};
#endif

/*test.cpp*/
#include "A.h"
#include "B.h"

int main()
{
A a;
B b;
return 0;
}

尝试编译一下:

1
g++  test.cpp -o test.out

显示在B.h中找不到A的定义:image-20211227163519959

现在我们从底层编译过程探讨下这个循环引用出现的原因和解决方案

我们知道编译四大流程为:预编译→编译→汇编→链接,先看看预编译源文件test.cpp产生的test.i。

1
g++ -E  test.cpp  -o test.i

预编译后内容如下:

image-20211227171957061

下面是test.i中具体内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class B
{
private:
A *a; // 前面没有A的定义
};
# 4 "A.h" 2

class A
{
private:
B *b;
};
# 10 "test.cpp" 2

int main()
{
A a;
B b;

return 0;
}

可以看到,test.i中第6行代码A* a,在类B中引用了A ,但是前面没有关于A 的定义。这为后面编译出错埋下了伏笔。

预编译后完成后,我们进行第2个阶段:编译。

1
g++ -S test.cpp -o  test.s

Error!出现了最开始的“未定义”错误:

image-20211227210145942

也就是说这个错误在编译阶段就产生了:当C++按顺序编译A *a 这行时,编译器进行语法检查,发现在前面找不到A相关的定义,于是报错。

怎么解决这个问题

我们可以在B.h 中进行前向声明,解决这个错误。

  • 类似于全局变量,这样A作用范围就到本文件结尾:即编译器还会在test.i 中其它位置寻找A的定义
  • 不仅如此,即使test.i中不存在,编译器还会在链接时符号解析时寻找A的定义,而不是在编译时就报错

这样编译器就不会在编译阶段报错了。

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef B_H
#define B_H
#include "A.h"

class A; // 前向声明,
class B
{
private:
A *a;
};

#endif

新的预编译文件test.i文件也在相应位置多了一行Class A;(其余无变化):

1
2
3
4
5
6
class A;
class B
{
...
}
...

如果不使用#ifndef 、#define 和#endif

#ifndef 、#define 和#endif,即条件编译:只有满足要求代码才参与编译,否则不参与编译。基本用法为:

1
2
3
4
5
#ifndef 标识符  
程序段1
#else
程序段2
#endif

在本节中,对B.h和A.h都使用了条件编译:防止头文件被重复定义,在链接时出现大量重定义错误

1
2
3
4
#ifndef A_H // 或B_H
#define A_H // 或B_H
// A_H或B_H中的代码
#endif

A.h为例:

  1. 如果A.h第一个文件引入时,会定义标识符A_H ,这个时候A.h其后的代码会被预编译插入到文件中;
  2. 如果其它文件存在代码#include “A.h” ,尝试预编译替换为A.h中内容;
  3. 因为A_H 已被定义,直接跳转到#endif结束,避免了头文件重复引入

在本节中,如果对B.h和A.h都不使用条件编译,预编译时不但会出现重定义错误还会出现无限嵌套

  1. main.cpp中:#include “A.h” 被替换为A.h中具体内容;
  2. A.h中: 由于在头文件引入了 #include “B.h” ,所以 #include “B.h”也会被替换为B.h中具体内容;
  3. B.h中: 重复引入了 #include “A.h”,重复替换头文件A.h,跳转到步骤2发生无限嵌套。
extern和前向声明

extern和前向声明作用非常相似:都可以用来声明函数/结构体/类等是在外部定义的,这样在编译时不出错,在链接时会在其它.o寻找相关定义

但是extern关键字和前向声明也有些不同:

  1. extern可以作用于变量,前向声明无法声明变量(变量会默认初始化),只能声明函数/结构体/类等;
  2. extern还有extern “C”相关用法
  3. extern是一个关键字,前向声明是种声明方式(使用前声明)。
最佳实践:extern/前向声明/include
  • 如果只是少量地要使用别的文件中定义的变量/函数/结构体/类等:请使用extern关键字,它可以减少编译时间;

    虽然函数/结构体/类等使用前向声明也可以但不推荐,使用extern更好。

  • 打破循环引用。前向声明。

  • 其余情况使用include更好,代码逻辑更清晰。

3.1.2 最佳实践:什么时候使用函数

作为曾经稚嫩的(现在不那么稚嫩的)程序员,什么时候使用函数是一个挺纠结的问题。参考learncpp 中建议:

  • 多次出现的语句应该组成一个函数。例如,如果我们以相同的方式多次读取用户的输入,那么这是一个很好的函数候选。
  • 具有明确定义的输入和输出目标的代码。例如,如果我们有一个要排序的项目列表,那么进行排序的代码将是一个很好的功能,即使它只完成了一次。输入是未排序的列表,输出是排序的列表。
  • 一个函数应该执行有且只有一项任务
  • 当一个函数变得太长、太复杂或难以理解时。可以将其拆分为多个子函数,也就是重构。

3.2 函数重载

在前面我们提到:函数通过函数签名来唯一确定一个函数,而函数签名由 函数名&参数列表 组成。

比如,两个函数函数名相同而参数列表不同,这个时候是同一函数吗

显然不是,因为函数签名中的参数列表不同,因此是两个函数,这也就是函数重载

3.2.1 为什么需要函数重载?

重载函数通常用来命名一组功能相似的函数。这样做的好处:

  1. 减少了函数名的数量;
  2. 避免了名字空间的污染,对于程序的可读性有很大的好处。

请看下例。

我们有两个函数:一个返回两个整数相加的值,一个返回两个浮点数相加的值。我们定义了多个名称但极其相似(功能、名字etc.)的函数。它们核心功能虽然都是add,但却对应多个不同的函数名,增加了记忆负担。

1
2
3
4
5
6
7
8
9
int addInteger(int x, int y)
{
return x + y;
}

double addDouble(double x, double y)
{
return x + y;
}

优雅的做法应该是:每个函数同名,编译器靠参数类型数量顺序来自动匹配调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int add(int x, int y)
{
return x + y;
}
int add(int x, int y,int z)
{
return x + y;
}

double add(double x, int y)
{
return x + y;
}

int main()
{
add(1,2); // 匹配第一个add
add(1,2,3); // 匹配第二个add
add(3.4,5); // 匹配第三个add
return 0;
}

3.2.2 二义性匹配

前面介绍的都是传递的参数和定义函数参数完全匹配的简单情况。但实际上,还会出现函数调用中的参数类型与任何重载函数中的参数类型不完全匹配

这会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

void print(int x)
{
cout << "print int : "<< x <<endl;
}

void print(double d)
{
cout << "print double : "<< d <<endl;
}

int main()
{
print('a');
}

输出:

1
print int : 97

发生了什么?print('a') 匹配了 print(int)? 97又是什么?

这是因为编译器如果找不到完全匹配的函数,会自动进行隐式转换将某些窄整数和浮点类型自动提升为更宽类型。所有这里的char('a') 自动提升为int ,匹配到了print(int) , 97a 的ASCII编码。

特别的,由于匹配到了print(int) ,便不会自动继续提升类型去匹配print(double)

关于类型转换 会在第十章统一总结,这里先简单了解下即可。

特别的,如果上述过程(数字转换找不到)未找到匹配项,编译器将尝试通过任何用户定义的转换找到匹配项

这涉及到类型重载,会在下篇文章面对对象 进行讲解。这里只要了解这个例子即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class X 
{
public:
operator int() { return 0; } // 用户自定义从X转换int
};

void print(int)
{
}

void print(double)
{
}

int main()
{
X x;
print(x); // 匹配print(int)

return 0;
}

在这里例子中(也是一般函数参数匹配流程总结):

  1. 编译器寻找是否存在print(X) ,不存在转至第2步;
  2. 编译器检查x是否可以类型提升,不能进行第3步;
  3. 编译器将查找任何用户定义的转换,存在,进行转换。

3.3 函数模板

在前面函数重载中,我们通过将两个功能相似仅参数列表不同的函数,改为同名函数让编译器通过函数签名来区分,减少了思维负担。

但是考虑一种更特殊的情况,两个函数不仅功能极其相似,连参数列表的个数都相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;

void print(int x, int y)
{
cout << "print result : "<< x+y <<endl;
}

void print(double x, double y)
{
cout << "print result : "<< x+y <<endl;
}

int main()
{
print(1,2);
print(3.0,4.0);
}

总感觉哪里不对,似乎造成了很多代码重复?它们只是参数类型不同啊,函数体、名字什么都一样?

在C++中提供了函数模板,用来优雅地应对这种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

template <typename T> // template和typename是关键字,T是类型名
void print(T x, T y)
{
cout << "print result : "<< x+y <<endl;
}

int main()
{
print(1,2);
print(3.0,4.0);
}

不错,一切顺眼了很多。

3.3.1 模板函数是如何工作的?

在前文我们介绍了函数模板,如下:

1
2
3
4
5
template <typename T>    
void print(T x, T y)
{
cout << "print result : "<< x+y <<endl;
}

但函数模板实际上并不是函数——它们的代码不是直接编译或执行的。函数模板只有一个功能:为每个具有一组唯一参数类型的函数,调用创建(并编译)一个函数

让我们看一个简单的例子,展示了一个不同以往的模板函数调用方式funName<actual_type>(arg1, arg2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

template <typename T>
void print(T x, T y)
{
cout << "print result : "<< x+y <<endl;
}

int main()
{
print<int>(1,2);
// print(1,2);
return 0;
}

但相比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
2
print<int>(1,2);
print<double>(1,2);

image-20220111224749061

实例化后,编译器确实生成了两个函数:void print<int>(int x, int y)void print<double>(double x, double y)供我们调用。

上述从函数模板创建指定类型的函数的过程,称为函数模板实例化。如果这个过程是因为函数调用而产生的,则称为隐式实例化

  • 实例化函数每次调用都会发生吗?仅在第一次函数调用时实例化,对该函数的进一步调用将路由(指向)到已实例化的函数。

  • 最佳实践:优先使用普通函数调用方式,即print(1,2)

3.3.2 多个函数模板类型

多个函数模板类型适用于函数拥有多个不同类型参数的情况。

请看下例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

template <typename T>
T max(T x, T y)
{
return (x > y) ? x : y;
}

int main()
{
cout << max(2, 3.5) << '\n'; // compile error
return 0;
}

继续往下阅读前,请思考:为什么编译会出错?有什么好的解决办法吗?

  • 为什么编译出错

    1. 根据调用的函数max(2, 3.5) ,编译器会尝试寻找匹配的函数,没找到转下一步;
    2. 编译器尝试根据函数模板max(T,T),生成函数max(int,double) 。显然这是不可能生成不同类型的参数。
    3. 函数调用解析出错。

    当然你可能还会问:为什么编译器不会生成函数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
    #include <iostream>
    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.5double 类型,返回的是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
    5
    template <typename T,typename U>
    auto max(T x, U y)
    {
    return (x > y) ? x : y;
    }

    输出:

    1
    3.5

    好了,一切都好起来了。关于auto关键字还会在后面做更详细的总结,希望你有了个初步的认识。

3.4 函数参数🌟

⚠️ 本节知识设计到较多指针和引用相关内容,此前无基础建议先阅读:第二章:指针和引用

在正式探讨函数参数前,我们先了解下函数中的两种参数:

  • 形式参数:函数声明时表示的变量,函数调用时才分配内存。
  • 实际参数:调用函数数实际传递的参数值,必须是确定的值。
1
2
3
4
5
void foo(int x, int y) // x,y 是形参
{
}

foo(6, 7); // 6,7 是实参,被赋值给形参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
    #include <iostream>

    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
    2
    x = 5
    x = 5

    x的值并没有被改变,虽然foo 函数内容修改了传递过来的值,但那只是x的副本。

    image-20211231000603042

    上图对这一过程进行形象说明,注意到实参x、形参y对应的是不同内存区域(地址都不一样)。

  • 什么时候用值传递

    按值传递通常用于需要传递的参数不希望被修改的时候,实参可以是:变量(例如 x)、数字(例如 6)、表达式(例如 x+1)、结构和类以及枚举数

    但是按值传递也有明显的缺点,应该避免以下几种情况使用:

    1. 复制结构和类。复制结构和类开销过大,导致明显性能损失;
    2. 希望参数被改变;
    3. 返回多个值。

    其它情况优先考虑值传递。

3.4.2 按引用传递

  • 为什么需要引用传递

    在前面我们说到值传递的几个缺点:复制结构和类开销大、无法改变参数、只能返回一个值。

    对应,如果你不希望以上发生,请使用引用传递。

    引用就相当于变量别名,操作引用等价于直接操作本体变量。请看下例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #include <iostream>
    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
    3
    0
    2
    3

    上述过程简略分析。

    image-20211231160958522
    • 引用传递变量 yz 均被修改(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_2z_add_3 放在一块,无法很好的区分哪些是要被修改的(输出参数)。毕竟,它们连调用都是这么相似

    1
    2
      int x = 0 ,y = 0 , z =0;
    getAdd(x,y,z);

    你能区分哪些是x、y、z哪些输出参数吗?

3.4.3 按指针传递

  • 为什么要需要指针传递值?

    前面说到,引用传值用来“返回”多个参数时,很难区分哪些参数是输出参数,连调用时都过分相似(多胞胎搞人心态是吧?)。

    上面代码修改为指针传值后,函数形参由&变为*

    1
    2
    3
    4
    5
    6
    void 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
    2
    int x = 0 ,y = 0 , z =0;
    getAdd(x,&y,&z); // &--取地址运算符

    输出:

    1
    2
    3
    0
    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
    #include <iostream>
    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
    4
    0x7ffd037a0ecc
    0x7ffd037a0ec0
    0x7ffd037a0ea8
    0x7ffd037a0ecc

    显然pfive没有修改成功(NULL)。整个过程如图所示:

    image-20220109145535610

  1. 局部变量pfive 保存了&five的值,即five 的地址;

  2. 随后pfive作为实参,其值(five地址)复制给形参ptr,编译器给形参ptr开辟了空间专门保存five地址;

  3. 随后ptr=NULL,但是影响不到pfive,二者是不同的变量拥有各自独立的空间。

这验证了我们之前的结论:指针传值只是复制了实参值给形参,只不过这个实参值一般是某个变量的地址

那如果我们想在指针传值时修改形参就可以影响实参

显然这就是引用的做法,对形参的任何修改直接等价操作实参本体。不过为了更好的讲解,我们先总结一下前面引用、指针传值的用法。

我们知道引用相当于变量的别名,使用引用时可以认为就是在使用变量本身。

1
int& a_ref = a;  // 使用a_ref和使用a没什么区别

又如之前的引用传值:

1
2
3
4
5
6
7
8
9
10
11
void func(int& var)
{
// var = ...;
}

int main()
{
int tmp = 0;
func(tmp);
return 0;
}

此时形参是int& var ,实参tmp ,实参值初始化形参var,就相当于:

1
int& var = tmp;

也可以推广到函数其余参数传递情况:

  • 值传递

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void 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
    11
    void func(int* var)
    {
    // *var = ...;
    }

    int main()
    {
    int tmp = 0;
    func(&tmp);
    return 0;
    }

    实参值初始化形参var等价于:

    1
    int* var = &tmp;

好了,接受了上面的概念,我们再来说说怎么修改形参ptr 等价于修改实参pfive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

// 变量 ptr保存了p_five的值(five的地址)
void setNull(int* ptr)
{
ptr = NULL;
}

int main()
{
int five = 5;
int* pfive = &five;
setNull(pfive); // 将p_five指向null
cout<<p_five<<endl; // 输出此时p_five内存地址
return 0;
}

显然,引用可以做到这点:将ptr 视为pfive的别名:

1
int*& ptr = pfive;

只需将第5行修改为:

1
void setNull(int*& ptr)

我们再尝试输出:

1
0 // 表示指针指向NULL

3.4.4 最佳实践

传引用快还是传指针快?

虽然前面的分析,你对引用和指针传递有一定的了解、区分。

但如果要你回答这么一个问题:是传引用快还是传指针快

先上结论:一样快。

因为引用就是特殊的指针,它底层实现和指针是一致的

准备一段地址传参代码:

1
2
3
4
5
6
7
8
9
10
11
void func(int* y)
{
*y = 2;
}

int main()
{
int x = 0 ;
func(&x);
return 0;
}

main对应汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
main:
# 保存main栈帧信息
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
# 用0初始化x(对应地址为-4(%rbp),并将x压栈
movl $0, -4(%rbp)
# 将x地址保存到寄存器rax中,然后保存在rdi中
leaq -4(%rbp), %rax
movq %rax, %rdi
# 调用函数func
call _Z4funcPi
movl $0, %eax
leave
ret

可以看到main中最终将实参&x(变量x的地址)保存在寄存器rdi中 。

接着我们开始重头戏func函数:指针通过保存变量地址到寄存器中,实现对变量所在内存区域进行修改,因此修改指针就是直接影响变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
_Z4funcPi:
# 保存func栈帧相关信息
pushq %rbp
movq %rsp, %rbp
# 进行地址复制:将寄存器rdi的值(实参&x)复制给形参y(对应地址-8(%rbp)),并将y压栈
movq %rdi, -8(%rbp)
# 进行赋值运算:先用寄存器rax保存形参y的的值(x的地址),然后将2复制给x
movq -8(%rbp), %rax
movl $2, (%rax)
nop
popq %rbp
# 返回main
ret

现在我们将上述代码从地址传递改为引用传递:

1
2
3
4
5
6
7
8
9
10
11
void func(int& y)
{
y = 2;
}

int main()
{
int x = 0 ;
func(x);
return 0;
}

查看其汇编代码:不能说毫不相干,只能说和之前指针传值的汇编代码完全一模一样

image-20220109193419428

也就是说,引用本质和指针一样,都是通过保存变量对应内存区域地址,来实现操作变量。对引用的任何操作,都会通过间接寻址直接操作变量本身,只不过相比指针隐藏了一些细节(编译器对使用引用会自动加上*2.2.1节)。

参数传递选择规则

有引选引。

优先选用引用传递(引用:拜托了),除非:

  1. 希望参数不被修改,选择按值传递,否则转下一步;
  2. 需要返回空指针、or返回局部变量内存、OR数组,选择按指针传递,否则转下一步。
数组怎么使用引用传参?

这是个很有意思的问题。

先看看数组的引用:

1
2
int array[] = {1,2,3,4,5};
int (&arr)[5] = array;

此时数组的类型可以认为是int [5]&arr 便是声明一个array的别名。

因此,我们如此使用数组的引用作为参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

void modify_array(int (&arr)[5])
{
arr[0] = 0;
}

int main()
{
int array[] = {1,2,3,4,5};
modify_array(array);
cout<<array[0]<<endl;
return 0;
}

输出:

1
0

可以看到,确实被成功修改了。但int (&arr)[5] 编译器要检查数组实参和形参的大小,扩展性太差!

为此,我们使用模板进行改进(其余不变):

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T,int N>
void modify_array(T (&arr)[N])
{
arr[0] = 0;
}

int main()
{
int array[] = {1,2,3,4,5};
modify_array(array);
cout<<array[0]<<endl;
return 0;
}

完美!

3.4.5 参数传递总结

一些面试常考题对前面所学进行总结和验证。

  • 形参和实参的区别

    • 何时分配内存形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元(这一部分内容还会在8.6函数返回值 详细举例);实参在调用前就已经分配了内存。
    • 参数类型: 实参可以是常量、变量、表达式、函数等,在进行函数调用时,它们都必须具有确定的值;实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。
    • 单向传递。只能把实参的值传送给形参,而不能把形参的值反向地传送给实参。
  • 值传递、指针传递、引用传递的区别

    请看下表。

    值传递 引用传递 指针(地址)传递
    拷贝内容 实参的副本(数组例外,会退化为指针) 给实参起个别名 指针(4字节或8字节)
    效率 低,特别是拷贝结构体或类对象时 高(推荐),起个别名即可 高,拷贝指针即可
    是否修改 不能修改为其它对象的引用
    初始化 不必要 一定要 不必要
    何时使用 参数不希望被修改时 优先选用引用、传递结构或类对象、希望参数被修改 返回多个值、需传递空指针(引传递用不允许空值)、返回局部变量内存(3.5中详述)
  • 指针传递、引用传递底层区别

    • 指针传递本质是值传递。值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。

      所以形参指针(内容)变了(保存了其它变量地址),实参指针不会变。

    • 引出传递本质是间接寻址。引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,通过栈中存放的地址访问主调函数中的实参变量。

    • 符号表不同。程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值指针变量的地址值,而引用在符号表上对应的地址值引用对象的地址值(与实参名字不同,地址相同,编译器直接处理为操作引用对象)。

3.4.6 特殊参数

在这一小节将来认识下比较特殊的两类参数。

  • 命令行参数

    如果你运行过一些开源代码/库,经常会要求你输入指定参数:

    1
    $ program arg1 arg2

    这为我们提供了一个可以向其他函数输入参数的方法,特别是你无法修改源码或程序需要用户提供参数时。当然,还可以通过配置文件实现,这里暂不表。

    下面形式的main函数可以接受命令行参数:

    1
    2
    int 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
    #include <iostream>
    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;
    }

    image-20220102165327119

    特别的,操作系统对如何处理特殊字符(如双引号和反斜杠)有特殊的规则。

    • 双引号:以双引号传递的字符串被认为是同一字符串的一部分(即使它们之间存在空格);

      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
    #include <iostream>
    #include <cstdarg> // needed to use ellipsis
    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
    2
    3
    3.5

    看起来这很棒。但是我们并不推荐使用省略号:

    1. 省略号很危险:无法判断传递的参数个数是正确

      假设你只传递了5个参数,而实际要求是6个:

      1
      findAverage(6, 1, 2, 3, 4, 5)

      在作者的机器上,这产生了奇怪的结果:

      1
      699773

      va_arg(list, int) 返回的前5个值是我们传入的值。它返回的第 6 个值(没有报错)是一个垃圾值堆栈。结果,我们得到了一个垃圾答案。

    2. 省略号很危险:类型检查被暂停

      如果你尝试传递一个浮点数(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 个字节(产生另一个垃圾结果);
      • 因此,我们的总体结果是垃圾。
    3. 限定省略号:参数数量和类型

      幸运的是,我们可以人为的传递一个“解码器字符串”,它告诉程序如何解释参数(限定了数量和类型)。

      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
      #include <iostream>
      #include <string>
      #include <cstdarg> // needed to use ellipsis
      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';
      }

      看起来很好,但是一般情况我们完全有其它合理的解决方案。比如:为什么不将... 换成一个数组

    4. 换成其它方案:动态数组

      一般情况,我们都要避免使用省略号,选择其它的方案。比如这里我们完全可以传递一个数组作为参数。

      1
      double findAverage(int len, int* nums);

3.5 函数返回值

在前面我们学习了按值、引用和地址向函数传递参数,如果作为函数返回值会有什么不同呢?

  • 按值返回的是value的副本?
  • 按指针返回的是value的地址?局部变量在退出函数被销毁时,它的地址不是没有了吗?
  • 按引用返回的是value的别名?局部变量在退出函数被销毁时,别名还有用吗?

请看下文分解。

3.5.1 按值返回

和按值传参一样,按值返回很安全,因为它只返回value的副本,不用担心返回之后value发生什么变化

1
2
3
4
5
6
7
8
9
10
11
int doubleValue(int x)
{
int value{ x * 2 };
return value;
}

int main()
{
int res = doubleValue(1);
return 0;
}

当然,它的缺点也和按值传参一致,返回大型结构或类时很慢。一般希望值不被修改或者返回局部变量时使用值传递。

返回局部变量?函数调用结束时局部变量不就被销毁了,为什么还能返回

在函数调用过程中是局部变量被压到栈中,当函数退出时,临时变量出栈,确实已经被销毁。

但局部变量作为返回值时在函数调用时,有些特别的变化

  • C语言里规定:16bit程序中,返回值保存在ax寄存器中,32bit程序中,返回值保持在eax寄存器中,如果是64bit返回值,edx寄存器保存高32bit,eax寄存器保存低32bit。

  • 由此可见,函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。

    下图的汇编代码也表明了这一点:

    image-20220112002905629

这也就是为啥:上述代码value虽然作为返回值但也是局部变量,函数调用结束时,依旧正确返回了其值。

现在让我们来特别关注一下按地址/引用返回局部变量的情况。

3.5.2 按指针返回

和按指针传递参数类似,按指针返回的只是value 的地址复制一份返回,所以速度很快。

  • 危险:返回局部变量地址

    局部变量在函数退出时就会被销毁,如果尝试返回局部变量的地址,这种行为非常的危险(地址对应的内存可能已被释放):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <iostream>
    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;
    }

    输出:

    image-20220103143317023

    编译器(VSCode)给出了警告,虽然输出值很幸运是正确的。但这是因为局部变量d_value对应栈空间还存在没有被重新分配使用,通过地址获取到了正确的值。

    很显然这种做法很危险,你并不知道什么时候就返回的是一个垃圾值,因此不建议你去尝试。

  • 那按指针返回还有什么用途吗

    按指针返回常用于将动态内存返回给调用者,因为动态分配的内存不会在函数退出时被销毁。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    int* 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
    13
    int* 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;
    }
  • 不说说返回地址其它用途吗

    既然提到返回地址很快,那用来返回结构体、类不应该很好吗?然而并不是。道理同参数传递中尽量建议使用引用一样:

    1. 引用更安全。引用一定会被初始化,不能为空。
    2. 引用效率更好。比如它不用管理指针析构释放之类的问题。

3.5.3 按引用返回

与按指针返回类似,按引用返回的值不能是局部变量。

  • 危险:不要返回局部引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    #include <iostream>

    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
    #include <iostream>

    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 小结

编码时选择何种方式返回参数

和选择何种方式传递参数很像:

  1. 不想修改value就是想返回value一个副本,or返回局部变量(见下节述)用值传递,否则转下一步;
  2. 需要动态内存分配时、返回按地址传递的参数,选择地址传递,否则转下一步;
  3. 其它情况,一般选用引用传递(返大型结构体、类、按引用传递的参数)。

3.5.5 返回多个值

3.4.3 介绍了使用地址传递参数,达到类似返回多个参数的效果。但是这种做法比较别扭,也不够优雅。C++有两种比较好的方式:

  1. 使用结构体

    将要返回的多个参数定义为一个结构体,最后直接返回结构体。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <iostream>
    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 也可以定义不同类型元素序列。

  2. 使用元组

    见下例,使用元组返回多个不同类型的值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <tuple>
    #include <iostream>
    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 那函数有什么缺点吗?

函数的一个主要缺点是每次调用函数时,都会发生一定量的性能开销(中断等),比如:

  1. CPU 必须存储它正在执行的当前指令的地址(因此它知道稍后返回到哪里)以及其他寄存器;
  2. 必须创建所有函数参数并赋值,并且程序必须跳转到新位置;

当然,对于大型复杂的函数,函数调用时间相比函数运行时间微不足道。但是对于比较轻巧的函数,若是频繁调用,函数调用的时间便很可观了

此时,我们希望这些轻巧又被经常调用的函数,最好不好进行这些复杂调用、返回操作。

如何做到这一点?类似于预编译头文件的替换,直接把函数体嵌入到每一个调用了它的地方,重复地嵌入

请看下例:

1
2
3
4
5
6
7
8
9
10
11
inline int min(int x, int y)
{
return x > y ? y : x;
}

int main()
{
min(5, 6) ;
min(3, 2) ;
return 0;
}

min(int,int) 被声明为内联函数。在编译时,相当直接在相应调用位置替换为实际min(int,int)函数体:

1
2
3
4
5
6
int main()
{
return 5 > 6 ? 6 : 5 ;
return 3 > 2 ? 2 : 3 ;
return 0;
}

小问题:内联函数这么棒,那把所有的函数写成内联函数?

内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。

  • 只适合比较简单的函数。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;
  • 占用内存空间多。另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间。

典型的空间换时间策略。

3.6.2 内联函数什么时候被替换?

在前面我们知道,宏会由预处理器对宏进行替代(预编译阶段)。而内联函数也会通过编译器来实现展开替换(编译阶段)。

为了更好地验证所学,我们通过反汇编来对比加上inline前后代码的不同之处对比。

准备一个更简单的代码:

1
2
3
4
5
6
7
8
9
10
11
inline int  add(int y)
{
y++;
}

int main()
{
int x = 0;
add(x);
return 0;
}

生成可执行文件后进行反汇编:

1
2
g++ -save-temps -fverbose-asm -g  test.cpp -o test.out
objdump -S --disassemble test.out

关键性代码截图如下:

image-20220103183107580

可以看到main 函数体内被直接插入了add函数的代码(绿色框)。

但是如果add函数是非内联函数:

1
2
// 防止编译器自己优化,强制声明为非内联函数
int __attribute__ ((noinline)) add(int y)

反汇编结果如图所示,add函数代码并没有插入到main函数体内。

image-20220103183933062

3.6.3 内联函数和宏对比

一个常考的面试题,加深下印象。

  1. 替换时机。宏在预编译时被替换,内联函数是在运行时(至少不是预编译时);
  2. 调试。内联函数在运行时可调试,而宏定义不可以;
  3. 安全。编译器会对内联函数的参数类型做安全检查或自动类型转换(同普通函数),而宏定义则不会;
  4. 访问。内联函数可以访问类的成员变量,宏定义则不能。

3.7 函数指针

3.7.1 函数和指针

在前一节,通过反汇编,你也看到了(在main函数中)调用(add)函数是通过一个地址,例如:

1
callq 40052d<_Z3addi>

其中 40052d 便是函数add 的地址。让我们尝试打印一下函数地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<iostream>
using namespace std;

int func(int a)
{
cout<<a<<endl;
}

int main()
{
// 如果你的编译器打印出来的地址是1
// 需要转换为空指针,强制编译器打印出地址:(void*)func
cout<<func<<endl;
return 0;
}

输出:

1
0x4007ad

可以看到,就像指针一样,func 保存的也是一个地址,只不过它保存的是函数地址。有函数地址就可以调用函数,此时可将函数压入栈(3.8节详述)。

像指针一样?那么可以像下面这样,使用int*指针保存函数指针吗?

1
2
int* p_func = func;
p_func(3);

很不幸报错了:

image-20220104223733205

主要原因是因为:虽然funcp_func都是指针,但它们类型是不一样的。函数func 类型是int(*)() ,而我们给出的指针p_func类型是int* ,无法赋值。就像你不能将string* 类型指针赋值给int* 类型指针。

所以,我们至少还得让他们参数类型一致:

1
2
int (*p_func)() = func;
p_func(3);

依旧报错了:

image-20220104224700403

这是因为函数指针p_func的类型参数和函数func不一致,编译器类型检查时出错。

题外话:为什么是在运行时报错,而不在编译代码时报错?

因为函数指针是在运行时才会进行解析。

我们还应该指定其参数类型:

1
2
int (*p_func)(int) = func;
p_func(3);

一切到此就好起来了。p_func此时获得了函数的地址,就可以像func一样使用了。

特别的下面这种方式也是正确的:

1
2
int (*p_func)(int) = &func; // 函数名多了个&,func和&func打印出来其实是一样的,都是函数地址
p_func(3);

3.7.2 为什么需要函数指针:回调函数

虽然,前面我们了解了怎么定义和使用函数指针。但不禁还是有疑惑:使用函数指针p_func 调用函数func不是多此一举?直接使用func不就好了

想象这么一种情况:

  • 你有一个函数假定为funcA , 但你的功能需要外部自定义一些规则,这些规则用户自定义的;
  • 所以你需要一个“参数”来保存这些特定的规则,而这个规则显然是一个逻辑集合——换句话,它应该是个函数。

那么这个“参数”是不是应该是函数类型?某个函数如果作为参数传递给另一个函数,就是回调函数

我们举一个更具体的例子:

  • 我们定义一个排序函数,将数字进行排序:但排序的规则由用户自定义,它可能是从大到小排列,也可能是从小到大或者其它——总之它取决于用户怎么定义“规则”。

我们给定一个排序函数如下,它有一个参数bool (*comparisonFcn)(int, int)用来定义排序规则:

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
#include<iostream>
#include <functional>
using namespace std;

// 交换数组值
void swap(int* arr,int idx_i,int idx_j)
{
int tmp = arr[idx_i];
arr[idx_i] = arr[idx_j];
arr[idx_j] = tmp;
}

// 自定义排序规则
bool myCompare(int a, int b)
{
return a > b? false: true;
}

// 冒泡排序
void bubbleSort(int* arr, int len, bool (*compare)(int,int))
{
for(int i = 0; i < len; i++) // 外轮=n-1
// * j=0,每次都是从头开始比较
for (int j=0; j< len-i-1; j++ ) // 内轮=余下乱序数-1
{
if (compare(arr[j],arr[j+1]))
swap(arr,j,j+1);
}
}

int main()
{
int array[] = {5,4,1,3,2};
int len = sizeof(array)/sizeof(array[0]);
bubbleSort(array,len,myCompare);
for(int i=0; i<len ;i++)
cout<<array[i]<<endl;
return 0;
}

输出:

1
2
3
4
5
5
4
3
2
1

显然这种方式,优雅且灵活,除了定义的函数指针实在过于丑陋。当然在上述myCompare 函数中,你甚至可以定义一些奇怪的规则:

1
2
3
4
5
6
7
bool myCompare2(int a, int b)
{
if ((a % 2 == 0) && !(b % 2 == 0))
return false;
else
return true;
}

你可以尝试输出试一试。

3.7.3 更优雅地使用函数指针

前面我们提到,函数指针的声明实在过于丑陋:

1
2
bool (*compare)(int,int);
bool (*)(int,int) compare;

好消息你现在有两种方式让它看起来顺眼很多:

1. 类型别名
1
2
3
4
5
6
7
8
// 类型别名
using bool_compare = bool(*)(int,int);

// 函数中使用:就行使用普通的类型一样
void bubbleSort(int* arr, int len, bool_compare compare)
{
// ...
}
2. function

std::function是标准库 <functional> 头文件的一部分。

1
#include <functional>

我们将之前的排序函数,重新定义为:

1
void bubbleSort(int* arr, int len, std::function<bool(int,int)>)

在主函数中,如此调用:

1
2
3
4
5
6
7
8
9
10
11
12
// 其余函数代码同前,略

int main()
{
int array[] = {5,4,0,1,3,2};
int len = sizeof(array)/sizeof(array[0]);
std::function<bool(int,int)> compare = myCompare;
bubbleSort(array,len,compare);
for(int i=0; i<len ;i++)
cout<<array[i]<<endl;
return 0;
}

输出:

1
2
3
4
5
6
5
4
3
2
1
0
3. function是什么?

有一个很有意思的事情,函数定义必须要和主函数中传递的参数类型一致(都是std::function),不能定义为指针:

1
void bubbleSort(int* arr, int len, bool(*)(int,int) compare)

如果你这个时候这样调用函数:

1
2
3
// 在main中
std::function<bool(int,int)> compare = myCompare;
bubbleSort(array,len,compare);

会报错:error: cannot convert ‘std::function<bool(int, int)>’ to ‘bool (*)(int, int)’

因为std::function本质是一种类模板,不是函数,是对通用、多态的函数封装。

  • 通过std::function对C++中各种可调用实体(普通函数、Lambda表达式、函数指针、以及其它函数对象等)的封装,形成一个std::function对象。让我们不再纠结那么多的可调用实体,一切变的简单粗暴。
  • 换句话说,std::function就是函数的容器,它自己是个类对象。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理

我们来看看它的原型:

1
2
template< class R, class... Args >
class function<R(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
    2
    int (*p_func)(int) = func 
    int (*p_func)(int) = &func
  • 函数指针的用途

    函数指针还允许将函数作为参数传递给其他函数,即回调函数。

3.8 栈和堆🌟

在这一节中,我们来了解程序运行时,函数调用更底层的过程。不过在这之前,我们先了解下内存布局。

3.8.1 内存布局

内存布局初识

3232位系统4GB(232=4GB2^{32}=4GB)。

下图展示了一个虚拟进程(程序)内存空间运行时分布布局,注意到此时还多了堆&栈用来给程序运行时进行空间分配

  • 一个程序(比如hello.out)本质是由数据段、代码段、.bss段(图中和数据段合并了)三个组成的。

  • 另外,高地址的1GB(Windows默认2GB)空间分配给内核,也称为内核空间;剩下的3GB分给用户,也称用户空间(程序使用的)。

你确定你理解内存分配吗?

作为程序员,我们更关注的是用户空间中的内容,也就是:

  • 栈(Stack):存储代码中调用函数、定义局部变量(但不包含static修饰的变量)、保存的上下文等;

    • 特点:存放的数据从栈顶(低地址)压入,也是从栈顶(低地址)弹出,所以有人说栈是向下生长的。函数退出时,所有数据会自动释放内存(出栈)。

      img
  • 文件映射区域 : 栈和堆中间那个空白区域。动态库、共享内存等映射物理空间的内存,一般是 mmap 函数所分配的虚拟地址空间。

  • 堆(Heap):存储那些生存期与函数调用无关的数据,如动态分配的内存。堆(动态)分配的接口通常有malloc()、calloc()、realloc()、new等。

    • 特点:相对于栈,堆是向上生长的;堆空间需要主动释放,否则会依然存在。
  • .bss段:全称Block Started by Symbol,也就是未被初始化的全局变量、静态变量的内容的一块内存区域。比如:

    1
    2
    3
    4
    5
    static int a;  // a保存.bss段
    int b; // b保存在.bss段
    int main()
    {
    }
  • 数据段(.data):保存全局变量、常量静态变量的内容的一块内存区域,区别.bss段在于变量已经被初始化。比如:

    1
    2
    3
    4
    5
    6
    7
    static 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// a保存在静态变量区,1是文字常量在代码区,此时a值为1
static int a = 1;
// b保存在全局变量区(此时值为2),2是文字常量在代码区,此时b值为2
int b = 2;
// c保存在常量区,3是文字常量在代码区,此时c的值为3
const int c = 3;
// str保存在全局区,"royhuang"是字符常量,保存在常量区,此时str只是保存"royhuang"地址
char* str1 = "royhuang";
// str是数组,保存在全局区,此时str复制了字符串常量"hwh",所以值为"hwh"
char str2[] = "hwh";
int main()
{
int d = 4; // d在栈上,4是文字常量在代码区,d的值在【运行时】被赋值为4
const int f = 5; // 区分c,此时f是局部变量保存在栈上,f在【运行时】被赋值为5
static int g = 6; // 同a
char* str3 = "roy"; // 同str1,不过此时str3保存在栈上,"roy"在常量区
}

对应汇编代码也验证了这一点(静态变量区内容汇编代码未展示):

image-20220208134212195

还应注意到:

  • 存储在静态存储区的变量的值,在编译期间就确定了;
  • 而堆、栈上变量的值是运行时动态分配的。

继续分析下面这个问题:

1
2
3
4
5
6
7
int main()
{
char str[] = "royhuang";
str[0] = 'h'; // ok
char* pstr = "royhuang";
pstr[0] = 'h'; // error,为什么?
}
  • char str[] = "royhuang"str 保存在栈上,运行时将常量区的字符串常量“royhuang”,赋值给了数组str ,修改str数组成员是合法的;
  • char* pstr = "royhuang"pstr 保存在栈上,但运行时只是将字符串常量“royhuang”的地址,赋值给了指针pstr尝试修改常量区的成员是非法的

3.8.2 函数调用过程

在这一小节我们来深入探讨下函数调用时的原理和过程。

栈帧

前面我们说到,栈是位于进程的高地址位置,且栈顶是向下增长的。在函数调用时,栈会专门使用一个独立的栈帧保存函数调用需要的所有信息。这对后面理解函数执行过程很关键。

一个典型的栈帧如下:

img

  • 栈帧保存的内容:每一次函数调用需要的函数返回地址、参数、临时变量、保存的上下文等;
  • espebp :非常重要的两个寄存器,记录了当前栈帧栈顶位置和栈底位置(对于X86-64平台上来说,对应的寄存器则为rsprbp) 。
    • 可以看到在上图压栈(push)会使得esp向下移动(地址变小)。
汇编分析

我们准备的验证代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

int foo(int sp1, int sp2)
{
int res = sp1 + sp2;
return res;
}

int main()
{
int res = foo(1, 2);
return 0;
}

反汇编的代码及分析如下。

image-20220109134057556

  • main函数汇编代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    main:
    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)函数中
    1. 保存上一个栈帧的rbp,重置main栈帧的rbp
    2. 按照与被调函数foo的形参顺序相反的顺序压入栈中(在本例是直接存入寄存器中,因为直接传入常量foo(1,2));
    3. call指令调用: 调用者函数(main)使用call指令调用被调函数(foo),为foo函数分配栈帧空间,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作是隐含的)
  • 在被调用函数(foo)函数中
    1. 保存main栈帧的rbp,重置foo栈帧的rbp;
    2. 被调参数sp1、sp2压入栈中;
    3. 执行foo函数体中代码,返回值res入栈,然后保存在寄存器eax中
    4. 执行完毕,栈帧所有数据出栈;
    5. popq %rbp 和 ret 跳转main中call指令的下一条指令地址继续执行。
  • 回到调用函数(main)函数中
    1. 返回值res(寄存器eax中内容)压入栈;
    2. 继续main函数中后续代码执行;
    3. main函数退出。

3.8.3 malloc/free 原理

从静态分配说起

我们之前接触数据通常保存在:

  1. ,比如函数内部局部变量;
  2. 数据段,静态区、全局区、常量区;
  3. .bss段,未初始化的数据。

上面的数据有两个共同点:

  • 变量/数组的大小必须在编译时知道
  • 内存分配和释放自动发生(当变量被实例化/销毁时)。

大多数时候,这很好。那什么时候在上分配内存?

很多时候,我们需要在堆上动态申请/释放内存

  1. 不知道分配对象的大小。比如我们想创建声明一个数组,但是事先并不知道数组的大小(稍后才能知道)。这个时候无法使用静态数组分配,因为它必须指定数组的大小;
  2. 分配的对象太大。栈等空间不够。

在C中,我们常常使用malloc/free在堆上分配和释放内存:

1
2
3
4
5
// 分配:大小为4的int数组
int* array ;
array = (int*)malloc(4*sizeof(int));
// 释放
free(array);

malloc和free函数底层是如何去实现的,如何在堆上分配内存的

从堆块说起

主要参考:malloc 的实现原理 内存池 mmap sbrk 链表

什么是堆

C++使用动态内存分配器(下简称分配器)进行动态内存分配,它维护一个进程的虚拟内存区域,也就是堆。

分配器眼里的堆是什么?如何进行管理

分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。

chunk结构图:

img

  • 正在使用的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+空闲链表(非常类似哈希表),对堆块进行管理

一个典型的堆整体结构:

img

可以看到,内存分成了大小不同的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时,采用了最佳适应的伙伴算法。

第一次分配内存时会进行堆初始化

  1. 一开始时,brk和start_brk是相等的,这时实际heap大小为0;
  2. 如果第一次用户请求的内存大小< mmap分配阈值,则(通过移动brk指针扩展堆大小)malloc会申请(chunk_size+128kb) align 4kb大小的空间作为初始的heap。

往后会按照顺序:bins查找分配–>brk扩展堆分配—>mmap分配

  1. 如果分配内存<max_fast=默认64B,在fast bins 中查找相应的空闲块;

  2. 如果分配内存<512字节,则通过内存大小定位到smallbins对应的index上:

    • 如果smallbins[index]为空,进入步骤3;
    • 如果smallbins[index]非空,直接返回第一个chunk。
  3. 如果分配内存>512字节,定位到largebins对应的index上:

    • 如果largebins[index]为空,进入步骤3;
    • 如果largebins[index]非空,扫描链表,找到第一个大小最合适的chunk,如size=12.5K,则使用chunk B,剩下的0.5k释放并放入unsorted_list中
  4. 遍历unsorted_list

    • 查找合适size的chunk,如果找到则返回;
    • 否则,将这些chunk都归类放到smallbins和largebins里面。
  5. 在bin更大的链表寻找:从index++更大的链表继续寻找:

    • 查找合适size的chunk,如果找到则返回,同时还会将chunk拆分,并将剩余的加入到unsorted_list中
    • 找不到则通过top chunk。
  6. 通过top chunk分配:默认top chunk分配的最大大小为128K(也是mmap分配的阈值):

    • top chunk大小>分配的内存,则返回合适大小chunk,剩余的作为新的 top chunk;

    • top chunk大小<分配的内存<128K,移动brk指针扩展top chunk大小,满足分配;

    • 分配的内存>128K,通过mmap分配内存。

  7. 通过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的结构如下:

img

malloc分配好内存,返回的是User data的起始地址header则保存了当前chunk的一些信息。

但free并非真的直接将相应内存区域返回操作系统:

  • 如果free 释放mmap 分配内存free可以很顺利就释放掉其相关的虚拟和物理内存,返回操作系统;

  • 如果free 释放 brk分配的内存free只是标记chunk可被重新分配并加入空闲链表(A=0),但没有真的删除任何数据

    1
    2
    3
    4
    5
    6
    int* 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上面
  • 最佳实践:删除一个指针时,请将指针设置为 nullptr

    否则的话,使用一个指向的内存已经被释放的一种指针,是使用悬空指针

    (区分野指针是指使用时还没被初始化)

    1
    2
    3
    cout<<*a<<endl; // 危,此时a是悬空指针,已经被free(a)
    a = nullptr; // 最佳实践:将已经释放的指针置空
    cout<<*a<<endl; // 此时编译器会报错,ok

可以看到,如果free的chunk没有和top chunk相邻被合并,其又太小的话,可能永远不会被使用——这就产生了内存碎片。

如下图:

img

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>                           
#include <stdlib.h>
using namespace std;
class Stu
{
public:
Stu()
{
cout << "默认构造函数初始化" << endl;
};
Stu(int num, string name):_num(num), _name(name)
{
cout << "自定义构造函数初始化" << endl;
}
~Stu()
{
cout << "析构销毁" << endl;
}
private:
int _num;
string _name;
};
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
    25
    void* __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...
    }
    }
    • 执行流程:

      1. 当malloc申请空间成功时直接返回return (p)

      2. 申请空间失败,尝试执行空间不足应对措施(malloc中失败直接返回空指针);

      3. 如果改应对措施用户设置了,则继续申请,否则抛异常。

    • 从源码中能看出的是operator new在底层是利用malloc分配内存,因此可以说new只是malloc一层封装

  • new底层实现(自定义类型

    但对于自定义类型,new还会调用构造函数初始化:

    1. 调用operator new为对象分配内存;
    2. 调用对象的构造函数对对象进行初始化
    3. 返回分配的内存空间地址。

    分析下例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int main()     
    {
    // malloc分配对象
    cout << "malloc:" << endl;
    Stu* a = (Stu*)malloc(sizeof(Stu));
    // new分配对象
    cout << "new:" << endl;
    Stu* b = new Stu(1, "张三");
    }

    输出:

    1
    2
    3
    malloc:
    new:
    自定义构造函数初始化

    可以看到malloc分配的方式不会调用构造函数,但是new还会调用构造函数。

    因此也可以理解为malloc分配出来的只不过是一个和类一样大小的空间(在前面我们称为chunk),并不能称作是一个对象,而new和delete分配出来的才能被成为对象。

  • new与new[]

    new[]和new有些细微区别:

    • new[]是调用operator new[]对多个对象进行分配,operator new[]本质还是多次调用operator new

      但operator new[]还会多申请4个字节的空间保存此次对象个数。

      image-20220110001617014

      为什么要这么做

      使用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
      5
      malloc:
      new:
      默认构造函数初始化
      默认构造函数初始化
      默认构造函数初始化
delete原理

delete也分两种情况讨论:

  • 对于内置类型:底层调用free实现,和free无多大区别(也没有异常处理);
  • 对于自定义类型:除了调用free释放对象空间,在此之前还会调用对象的析构函数。

通过具体例子来看看。

  • delete底层实现(内置类型

    先看一个简单的例子:

    1
    2
    int* 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
    8
    void __CRTDECL operator delete(void* const block) noexcept
    {
    #ifdef _DEBUG
    _free_dbg(block, _UNKNOWN_BLOCK);
    #else
    free(block);
    #endif
    }
  • delete底层实现(自定义类型

    但对于自定义类型,delete还会调用析构函数:

    1. 调用对象的析构函数清理对象
    2. 调用operator delete清理内存;

    一个小例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int 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
    6
    malloc:
    free:
    new:
    默认构造函数初始化
    delete:
    析构销毁

    可见free不会调用析构函数,但delete会。

  • delete与delte[]

    delete[]用来删除多个对象(和new[]成对出现),本质是对每个对象调用delete清理。

    大致流程如下:

    1. 首先根据对象数组前4个字节,获取对象个数N;

      image-20220110001617014

    2. 对后续每个对象使用delete删除。

    同样的,对于自定义类型还会调用对象的析构函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    int 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
    10
    malloc:
    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
2
3
4
// plain new原型
void* operator new(std::size_t) throw(std::bad_alloc);
// plain new对应delete
void operator delete(void *) throw();

可见plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL。

因此通过判断返回值是否为NULL是徒劳的,请使用try-catch捕获异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <string>
using namespace std;
int main()
{
try
{
char *p = new char[10e11];
delete p;
}
catch (const std::bad_alloc &ex)
{
cout << ex.what() << endl;
}
return 0;
}

输出:

1
bad allocation

nothrow new

nothrow new在空间分配失败的情况下不抛出异常,而是返回NULL,定义如下:

1
2
3
4
// nothrow new原型
void * operator new(std::size_t,const std::nothrow_t&) throw();
// 对应delete原型
void operator delete(void*) throw();

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <string>
using namespace std;

int main()
{
char *p = new(nothrow) char[10e11];
if (p == NULL)
{
cout << "alloc failed" << endl;
}
delete p;
return 0;
}

输出:

1
alloc failed

placement new

placement new允许在一块已经分配成功的内存上重新构造对象或对象数组。

placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:

1
2
3
4
// placement new原型
void* operator new(size_t,void*);
// 对应delete
void operator delete(void*,void*);
  • 主要用途:反复使用一块较大的动态分配的内存,来构造不同类型的对象或者他们的数组;
  • 使用析构函数删除而不是delete:placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存)。这是因为placement new构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏或者之后释放内存时出现运行时错误。

举个例子:

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
#include <iostream>
#include <string>
using namespace std;
class ADT
{
int i;
int j;
public:
ADT()
{
i = 10;
j = 100;
cout << "ADT construct i=" << i << ",j="<<j <<endl;
}
~ADT()
{
cout << "ADT destruct" << endl;
}
};
int main()
{
char *p = new(nothrow) char[sizeof ADT + 1];
if (p == NULL)
{
cout << "alloc failed" << endl;
}
// 使用指针p申请的内存来构建类ADT对象adt
// placement new:不必担心失败,只要p所指对象的的空间足够ADT创建即可
ADT *adt = new(p) ADT;
/*
delete adt; // error,不能在此处调用delete
*/
// 调用析构函数删除adt
adt->ADT::~ADT();
// delete删除p
delete[] p;
return 0;
}

输出:

1
2
ADT construct i=10,j=100
ADT destruct
小结
  1. new和delete分别是malloc和free的一层封装,对于自定义类型还会分别调用构造函数初始化/析构函数清理内存。不过new相比malloc还会有一层申请空间失败应对措施,以及可以初始化
  2. new[]/delete[],是分别用来分配对象数组/清理对象数组的,本质是多次调用了new/free。值得注意的是,new[]分配的对象数组,还会多分配4个字节标识对象的个数
  3. 在C++中有plain new,nothrow new和placement new。plain new就是最普通的new,分配错误返回bad alloc异常;nothrow new分配失败返回NULL异常;placement new不分配内存,请只调用析构函数进行释放,否则可能会导致double free。

3.8.5 问答测验

本节是八股重灾区,因为特地准备一些常见面试问题来巩固所学。

  1. 堆栈的区别

    主要的区别如下:

    • 申请方式栈由系统自动分配,比如函数中的局部变量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
      9
      int main()
      {
      int arr1[] = {1,2,3};
      int* arr2 = new int[3]{1,2,3};

      int a = arr1[0];
      int b = arr2[0];
      return 0;
      }

      转成汇编语言分析:

      image-20220108202348644

  2. new / malloc 的异同

    都可用于内存的动态申请,返回用户分配使用空间的首地址。

    • 本质:new是关键字,malloc是函数,所以malloc还需要引入库文件,new不用;

    • 返回值类型:malloc返回的是void类型指针(必须进行类型转换),new返回的是具体类型指针;

    • 空间计算:new会自动分配空间大小,编译器会根据类型信息自行计算,malloc需要手工计算;

    • 类型安全:new是类型安全的,malloc不是;

      1
      2
      int *p = new float[2]; // 编译错误
      int *p = (int*)malloc(2 * sizeof(double));// 编译无错误
    • 构造函数: new调用名为operator new的标准库函数分配足够空间,如果是自定义类型还会调用相关对象的构造函数,malloc则不会;

    • 分配失败措施:new是malloc的一层封装,如果分配失败还会有相应措施执行,抛出bac_alloc异常;malloc返回null。

  3. free / delete 的异同

    都可用于内存的动态释放。

    • 本质:delete 是关键字,free 是函数,所以free 还需要引入库文件,delete 不用;
    • 返回值类型:free返回的是void类型指针(必须进行类型转换),delete 返回的是具体类型指针;
    • 析构函数: delete 调用名为operator delete的标准库函数分配足够空间,如果是自定义类型还会调用相关对象的析构函数,free 则不会。
  4. new实现原理?delete实现原理

    • new原理:operator new分配内存 (底层是malloc实现)—> (自定义类型)分配的内存上调用构造函数初始化—>返回指向该对象的指针;
    • delete原理:operator delete清理内存 (底层是free实现)—> (自定义类型)删除内存前还会调用析构函数;
  5. malloc/free底层原理

    参考前文。这里只简略说明:

    • malloc:brk初始化分配–>后续分配通过内存池:bins+双向链表实现—>太大则top chunk 和mmap分配;
    • free:将chunk标记可使用,并加入空闲链表。在上一个步骤free的时候,发现最高地址空闲内存超过128K,还会内存紧缩。
  6. 被free回收的内存是立即返还给操作系统吗

    如前,free回收只是标识这块内存空闲,同时会加入空闲链表中等待下一次分配。

  7. delete和free可以混用吗?

    我们知道delete也只是free一种封装,只有自定义类型时delete会调用析构函数。new和malloc同理。

    所以在以下情况是可以free/delete混用

    1. 对象是基本类型时

      free掉new申请的内存。

      1
      2
      3
      int* a  = new int(1);
      // delete a;
      free(a);

      delete掉malloc申请的内存。

      1
      2
       int* a  = (int*)malloc(sizeof(int));
      delete a;

      上面也适用于new/malloc混用。

    2. 自定义的类型,但没有显示定义析构函数

      delete和free混用。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      #include <stdlib.h>  

      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需要显示调用构造函数的逻辑实现(或者该类不会有构造函数作用)

  8. 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);
  9. 为什么C++没有垃圾回收机制?这点跟Java不太一样

    • 资源消耗。实现一个垃圾回收器会带来额外的空间和时间开销;
      • 你需要开辟一定的空间保存指针的引用计数和对他们进行标记mark;
      • 然后需要单独开辟一个线程在空闲的时候进行free操作。
    • C++本身原因。垃圾回收会使得C++不适合进行很多底层的操作。

3.9 Lambda表达式

很多时候我们常常需要定义一个简单、甚至只被使用一次的函数,额外地定义了一个函数调用既浪费了空间,甚至未必有简化成一行代码好理解。

另外,前面的学习我们了解到C++是不允许函数嵌套的,但lambda可以被函数嵌套(lambda并不是函数,下文细说)。

lambda表达式就为我们做了这么一件事:将一个简单函数简化为一行,还允许我们在一个函数进行嵌套lambda

3.9.1 lambda初识

第一个lambda

C++中lambda表达式格式如下:

1
2
3
4
[ captureClause ] ( parameters ) -> returnType
{
Statement;
}
  • captureClause:允许我们使用lambda外部的变量,这一点在3.9.5中详述,这里先略过;
  • parameters:传递的参数,可空;
  • returnType:返回类型,编译器会自动推断,可空;
  • Statement:代码体,一般就一行,当然多行也行。

一个最简单的lambda表达式可定义为:

1
[](){}

现在举例说明用法。

我们之前写过一个冒泡函数bubbleSort:它有一个参数,允许我们传递定义排序规则,即myCompare函数来实现排序。

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
#include<iostream>
using namespace std;

// 交换数组值
void swap(int* arr,int idx_i,int idx_j)
{
int tmp = arr[idx_i];
arr[idx_i] = arr[idx_j];
arr[idx_j] = tmp;
}

// 自定义排序规则
bool myCompare(int a, int b)
{
return a > b? false: true;
}

// 冒泡排序
void bubbleSort(int* arr, int len, bool (*compare)(int,int))
{
for(int i = 0; i < len; i++) // 外轮=n-1
// * j=0,每次都是从头开始比较
for (int j=0; j< len-i-1; j++ ) // 内轮=余下乱序数-1
{
if (compare(arr[j],arr[j+1]))
swap(arr,j,j+1);
}
}

int main()
{
int array[] = {5,4,1,3,2};
int len = sizeof(array)/sizeof(array[0]);
bubbleSort(array,len,myCompare);
for(int i=0; i<len ;i++)
cout<<array[i]<<endl;
return 0;
}

我们将这个myCompare函数改为更简洁lambda表达式:

1
2
3
4
bool myCompare(int a, int b)
{
return a > b? false: true;
}

改为:

1
[](int a, int b){return a > b? false: true;}

这样我们如此调用bubbleSort函数:代码更加的紧凑,上下文更清晰,一眼明白我们排序的规则。

1
2
// bubbleSort(array,len,myCompare);
bubbleSort(array,len,[](int a, int b){return a > b? false: true;});

输出:

1
2
3
4
5
1
2
3
4
5
lambda本质探讨🌟

虽然3.9.2节(下节)的名字叫做“lambda与函数指针”,但值得提前声明的一点是:lambda并不是函数,它是一种特殊的对象,称为函数对象(也称为函数子)。函数不能嵌套,但lambda可以

怎么理解函数对象

C++函数对象实质上是实现了对()操作符的重载。C++函数对象不是函数指针,但它的调用方式与函数指针一样,后面加个括号就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Add
{
public:
const int operator()(const int a,const int b)
{
return a+b;
}
};

int main()
{
// addFunction的Add类对象,此也是函数对象
Add addFunction;
// 像函数(指针)一样调用
cout<<addFunction(2,3)<<endl; // 5
cout<<addFunction.operator()(2,3)<<endl; // 5
cout<<Add()(2,3)<<endl; // 5
return 0;
}

函数对象相比普通函数有什么好处吗

  • 函数对象带有状态:函数对象相对于普通函数是“智能函数”,这就如同智能指针相较于传统指针。因为函数对象除了提供函数调用符方法,还可以拥有其他方法和数据成员。所以函数对象有状态。即使同一个类实例化的不同的函数对象其状态也不相同,这是普通函数所无法做到的。而且函数对象是可以在运行时创建。

  • 每个函数对象有自己的类型:对于普通函数来说,只要签名一致,其类型就是相同的。但是这并不适用于函数对象,因为函数对象的类型是其类的类型。这样,函数对象有自己的类型,这意味着函数对象可以用于模板参数,这对泛型编程有很大提升。

  • 函数对象一般快于普通函数:因为函数对象一般用于模板参数,模板一般会在编译时会做一些优化。

回到lambda表达式。

我们看看一个lambda表达式是如何被转换成函数对象

准备代码:

1
2
3
4
5
int main()
{
auto add = [](auto x, auto y) { return x + y; };
int x = add(2, 3); // 5
}

依旧是https://cppinsights.io/,观察编译过程变化:

image-20220112200056709

编译器如何将我们写的lambda表达式转换为函数对象便一目了然:

  1. 首先为我们生成类__lambda_3_16 (①处),它有一个内联函数(②处),重载了符号()
  2. 代码30行处,为我们生成了lambda函数对象add,也就是我们定义的lambda表达式名;
  3. 随后调用了函数对象add (③处),完成整个转换过程。
存储lambda

虽然一般来说我们不需要像函数一样给lambda表达式一个名字,但是有时候也需要能将lambda表达式(作为右值)存储起来,供以后使用。

在C++中提供三种方式:

  1. 使用函数指针方式存储:只有在capture clause为空时;
  2. 使用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):

  1. 参数列表中不能有默认参数;

  2. 不支持可变参数(比如使用auto关键字);

    1
    2
    // C++11:编译出错,参数类型无法使用auto
    auto myCompare = [](auto a, auto b) {return a > b? false: true;};
  3. 所有参数必须有参数名。

关于第2点,从C++14开始,lambda表达式支持泛型:其参数可以使用自动推断类型的功能,而不需要显示地声明具体类型。

具体介绍参考3.9.2节。

3.9.2 泛型lambda(C++14)

什么是泛型lambda?

从 C++14 开始,我们被允许auto用于参数。当 lambda 具有一个或多个auto参数时,编译器将从对 lambda 的调用中推断出需要哪些参数类型。

这使得具有一个或多个auto参数的lambda可适用于多种类型,它们也被称为泛型 lambda

看一个小例子:

1
2
3
4
5
6
int main()
{
auto add = [](auto x, auto y) { return x + y; };
int x = add(2, 3); // 5
double y = add(2.5, 3.4); // 5.9
}

是不是非常像模板函数?

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
auto add(T x, T y)
{
return x + y;
}

int main()
{
int x = add(2, 3); // 5
double y = add(2.5, 3.4); // 5.9
}

add(2, 3)add(2.5, 3.4) 分别让函数模板生成了函数实例 add(int, int) add(double, double)。

image-20220112193845733

auto关键字自动推断lambda参数类型,让lambda表达式起到了和模板函数相同的效果。前面我们说到,模板函数会为每种不同类型生成实例函数:那泛型lambda也会为不同类型生成一个lambda表达式吗

答案是肯定的。

image-20220112194112575

完整代码如下:

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
int main()
{

class __lambda_3_16
{
public:
template<class type_parameter_0_0, class type_parameter_0_1>
inline /*constexpr */ auto operator()(type_parameter_0_0 x, type_parameter_0_1 y) const
{
return x + y;
}

#ifdef INSIGHTS_USE_TEMPLATE
template<>
inline /*constexpr */ int operator()(int x, int y) const
{
return x + y;
}
#endif


#ifdef INSIGHTS_USE_TEMPLATE
template<>
inline /*constexpr */ double operator()(double x, double y) const
{
return x + y;
}
#endif

private:
template<class type_parameter_0_0, class type_parameter_0_1>
static inline auto __invoke(type_parameter_0_0 x, type_parameter_0_1 y)
{
return x + y;
}

};

__lambda_3_16 add = __lambda_3_16{};
int x = add.operator()(2, 3);
double y = add.operator()(2.5, 3.3999999999999999);
}

在上面代码中:

  1. 编译器自动生成了lambda类__lambda_3_16

  2. __lambda_3_16 ,根据40、41行函数调用的不同参数类型,分别生成了两个成员函数。

    1
    2
    3
    4
    5
    template<>
    inline /*constexpr */ int operator()(int x, int y) const

    template<>
    inline /*constexpr */ double operator()(double x, double y) const

    注意到,两个函数都重载了操作符()

  3. 代码39行,生成了类__lambda_3_16 对象addadd就是我们定义的泛型lambda名,显然此时add也是函数对象,可以用名字()方式调用。

    1
    __lambda_3_16 add = __lambda_3_16{};
  4. 代码40、41行调用了不同的类__lambda_3_16 不同重载函数。

    1
    2
    int x = add.operator()(2, 3);
    double y = add.operator()(2.5, 3.3999999999999999);
更好地理解泛型闭包行为🌟

前面已经说到,泛型lambda会为每种不同类型生成成员函数 。

同时,我们也了解局部静态变量同样具有全局的生命周期,下面这个例子展示了这种特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream>
#include <typeinfo>
using namespace std;

auto myCount(int value)
{
cout<<"the type of your input:"<<typeid(value).name()<<endl;
static int count = 1;
return count++;
}

int main()
{
cout<<myCount(0)<<endl;
cout<<myCount(0)<<endl;
return 0;
}

输出:

1
2
3
4
the type of your input:i
1
the type of your input:d
2

count 虽然是局部变量,但是由于是静态变量函数存储在全局数据段,和堆栈无关,退出也不会销毁。所以被myCount函数共享,可进行计数

但是如果我们myCount替换成泛型函数,即允许参数类型为auto,一切开始变得不一样了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<iostream>
#include <typeinfo>
using namespace std;

// C++20支持参数类型为auto
auto myCount(auto value)
{
cout<<"the type of your input:"<<typeid(value).name()<<endl;
static int count = 1;
return count++;
}

int main()
{
cout<<myCount(1)<<endl;
cout<<myCount(1.2)<<endl;
return 0;
}

输出:

1
2
3
4
the type of your input:i
1
the type of your input:d
1

此时静态变量count没有被myCount函数共享?这是因为此时myCount函数已经是泛型函数。

  • 执行代码第13行,推断出valueint类型,编译器生成函数int myCount<int>(int value),它拥有自己作用范围的静态变量count
  • 执行代码第14行,类似的,无法匹配上一次生成函数int myCount<int>(int value)。所以编译器还需生成函数int myCount<double>(double value),它也拥有自己作用范围的静态变量count

两个myCount 是重载函数,但它们并不一样,自然无法共享各自的局部静态变量count

image-20220113000047727

类似的结果也出现在泛型lambda、函数模板中

下面以泛型lambda为例进行展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <typeinfo>
using namespace std;

auto myCount
{
[](auto value)
{
cout<<"the type of your input:"<<typeid(value).name()<<endl;
static int count = 1;
return count++;
}
};

int main()
{
cout<<myCount(1)<<endl;
cout<<myCount(1.2)<<endl;
return 0;
}

输出:

1
2
3
4
the type of your input:i
1
the type of your input:d
1

查看编译转换后的代码验证:

image-20220113000753408

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
    #include <algorithm>
    #include <array>
    #include <iostream>
    #include <functional> // for std::greater

    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
    #include <algorithm>
    #include <array>
    #include <iostream>
    #include <string>

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

    实现流程如下:

    1. 明确lambda接受的参数是数组两个元素,这里元素是结构体Student对象;
    2. 假设两个Student对象分别是stu1、stu2,它们比较的规则应该是:stu1.points>stu2.pointsstu1.points>stu2.points
    1
    2
    3
    4
    5
    6
    7
    8
    int 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;
    }

3.9.4 捕获

为什么需要捕获

前面我们说过lambda表达式原理:

  1. 每定一个lambda表达式,会生成一个匿名类,这个类重载了()运算符;
  2. 使用lambda表达式,其实就是返回一个闭包类实例。

既然是闭包,当然无法使用外部的变量,请看下例:

1
2
3
4
5
6
7
8
9
10
11
12
#include<iostream>
using namespace std;

int main()
{
int a = 1;
[]()
{
cout<<a<<endl; // 无法使用外部变量a
};
return 0;
}

通过捕获可以让我们使用外部变量a

按值捕获

下面展示了按值捕获方式。

1
2
3
4
5
6
7
8
9
10
11
12
#include<iostream>
using namespace std;

int main()
{
int a = 1;
[a]()
{
cout<<a<<endl;
};
return 0;
}

按值捕获只是将变量a 复制一份,而且由于lambda匿名生成了重载函数()const修饰,所以不能在lambda表达式内部修改数据成员a的值。

image-20220113145733566

要想修改a的值,需要声明为mutable

1
2
3
4
[a]()mutable
{
cout<<a++<<endl;
};

此时重载函数的const关键字不会被声明。

image-20220113150219048

mutable这种方式,我们依旧无法修改lambda外部变量a的值,毕竟lambda内部变量a只是值复制了一份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<iostream>
using namespace std;

int main()
{
int a = 1;
auto print = [a]() mutable
{
cout<<a++<<endl;
};

print();
cout<<a<<endl;
return 0;
}

输出:

1
2
1
1

要想能修改lambda外部的变量,有两种方式:

  1. 声明外部变量为a为static:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include<iostream>
    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具有全局生命周期,可被隐式捕获(下个小节详细列出)。

  2. 用引用捕获的方式。

按引用捕获

引用捕获方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<iostream>
using namespace std;

int main()
{
int a = 1;
auto print = [&a]() // 引用捕获
{
cout<<a++<<endl;
};

print();
cout<<a<<endl;
return 0;
}

输出:

1
2
1
2

原因很简单,修改引用时会通过间接寻址到本体外部的变量a,然后进行修改的。

混合捕获

上面的例子,要么是值捕获,要么是引用捕获,Lambda表达式还支持混合的方式捕获外部变量,这种方式主要是以上几种捕获方式的组合使用。

捕获形式 说明
[] 不捕获任何外部变量
[变量名, …] 默认以值得形式捕获指定的多个外部变量(用逗号分隔),如果引用捕获,需要显示声明(使用&说明符)
[this] 以值的形式捕获this指针
[=] 以值的形式捕获所有外部变量
[&] 以引用形式捕获所有外部变量
[=, &x] 变量x以引用形式捕获,其余变量以传值形式捕获
[&, x] 变量x以值的形式捕获,其余变量以引用形式捕获
隐式捕获

前面介绍的都是显示捕获,在C++中,如果变量被声明为static或者const,其实是可以隐式捕获使用的。

请回答:下面变量哪些可以在main不显式捕获它们的情况下使用?

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
int i{};
static int j{};

int getValue()
{
return 0;
}

int main()
{
int a{};
constexpr int b{};
static int c{};
static constexpr int d{};
const int e{};
const int f{ getValue() };
static const int g{};
static const int h{ getValue() };

[]()
{
a;
b;
c;
d;
e;
f;
g;
h;
i;
j;
}();

return 0;
}

除了a、f,其余都是被static或者const修饰,可以隐式被lambda捕获。

3.X 再谈 auto

前面我们或多或少了使用了auto关键字,它的作用也很明显:自动类型推断

1
2
auto a = 10;
typeid(a).name() // i

但像这种简单类型变量声明不建议使用auto关键字,直接写出变量的类型更加清晰易懂。

现在进行常见场景下auto总结&使用。

auto用法总结

  1. 代替冗长复杂、变量使用范围专一的变量声明

    比如:存储函数指针或lambda表达式、模板对象声明等。

    STL标准库将在后续进行总结。

    想象一下在没有auto的时候,我们操作标准库时经常需要这样(难受啊兄弟):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #include<string>
    #include<vector>
    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
    #include<string>
    #include<vector>
    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
    #include<iostream>
    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;};
  2. 在定义模板函数时,用于声明依赖模板参数的变量类型

    若不使用auto变量来声明v,则无法进行定义,因为其类型无法确定。

    1
    2
    3
    4
    5
    6
    template <typename _Tx,typename _Ty>
    void Multiply(_Tx x, _Ty y)
    {
    auto v = x*y;
    std::cout << v;
    }

    在C++11中如果v作为返回值,还需要在尾部指定返回类型(否则会发出警告):

    1
    2
    3
    4
    5
    template <typename _Tx, typename _Ty>
    auto multiply(_Tx x, _Ty y)->decltype(x*y)
    {
    return x*y;
    }

    decltype用于计算出表达式x*y的类型。

注意事项

  1. auto关键字必须初始化

    需要在声明时auto就可以推断出变量类型。

    1
    2
    auto a = 10; // ok
    auto b; // error
  2. auto会自动去除右值的引用、const语义,需要显示声明

    右值引用语义被去除。

    1
    2
    3
    4
    int a = 10;
    int& b = a;

    auto c = b; // 此时c的类型是int不是int&

    需要显示声明为引用:

    1
    auto& c = b; // 此时c的类型才是int&

    auto还会去除const语义。

    1
    2
    3
    4
    const int a = 10;

    auto b = a; // 此时b为int类型,而不是const int
    b = 100; // b可以被修改

    需要显示声明为const常量:

    1
    2
    const auto b = a;
    b = 100; // 非法,无法被修改

    有个有意思的问题:如果auto& 还会去除const语义吗?理论上来说,此时引用是某种意义上等价本体的,自然类型也应该是一致的。

    答案是不会的。

    1
    2
    3
    4
    const int a = 10;

    auto& b = a; // 此时b为const int,const语义未被去除
    b = 100; // 非法
  3. auto关键字不去除指针的语义

    请看下例,auto正确推导除了变量的类型为指针:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int main()
    {
    int a = 10;
    auto b = &a; // b是int*类型

    cout<<*b<<endl; // 10

    return 0;
    }
  4. auto不能作为函数形参

    这样做的话,声明的函数实际上就是个函数模板了。

    1
    2
    3
    4
    auto func(auto a)
    {
    ...
    }

    但这种做法在C++20中已被支持。

更新记录

2021-02-8:更新笔记

  1. 3.8.1节增加了静态存储区相关描述

2021-01-16 :更新笔记

  1. 第一次更新

参考资料


  1. 1.C++中引用传递与指针传递的区别:https://blog.csdn.net/u013130743/article/details/80806179
  2. 2.C/C++ 程序的内存布局:https://blog.csdn.net/m0_45047300/article/details/118389444
  3. 3.内存分配的原理--molloc/brk/mmap:http://abcdxyzk.github.io/blog/2015/08/05/kernel-mm-malloc/
  4. 4.C++ lambda表达式与函数对象:https://www.jianshu.com/p/d686ad9de817
  5. 5.C++ auto 关键字的使用:https://cloud.tencent.com/developer/article/1660750