文章目录
什么是C++
C 语言是结构化和模块化的语言,适合处理较小规模的程序。对于复杂的问题,规模较大的程序,需要高度的抽象和建模时,C 语言则不合适。
为了解决软件危机, 20 世纪 80 年代, 计算机界提出了 OOP(object oriented programming:面向对象)思想,支持面向对象的程序设计语言应运而生。
1982 年,Bjarne Stroustrup 博士在 C 语言的基础上引入并扩充了面向对象的概念,发明了一种新的程序语言。为了表达该语言与 C 语言的渊源关系,命名为 C++。
因此:C++ 是基于 C 语言而产生的,它既可以进行 C 语言的过程化程序设计,又可以进行以抽象数据类型为特点的基于对象的程序设计,还可以进行面向对象的程序设计。
C++的发展史
1979 年,贝尔实验室的本贾尼等人试图分析 unix 内核的时候,试图将内核模块化,于是在 C 语言的基础上进行扩展,增加了类的机制,完成了一个可以运行的预处理程序,称之为 C with classes。
语言的发展就像是练功打怪升级一样,也是逐步递进,由浅入深的过程。
我们先来看下 C++ 的历史版本(如下图所示👇)。
目前,C++ 还在不断的向后发展中。
1. C++关键字
C 语言有 32 个关键字,而 C++ 有 63 个关键字(如下图所示👇)。
当然是不是看到很多眼熟的 “朋友” 呢?没错,下面圈起来的这些关键字,就是在 C 语言中出现的(如下图所示)。
2. 命名空间
在 C/C++ 中,变量、函数和后面要学到的类都是大量存在的,这些变量、函数和类的名称将都存在于全局作用域中,可能会导致很多冲突。
使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或名字污染,namespace 关键字的出现就是针对这种问题的。
🍑 命名空间的定义
定义命名空间,需要使用到 namespace 关键字,后面跟命名空间的名字,然后接一对{ }
即可,{ }
中即为命名空间的成员。
(1)命名空间的普通定义
📝 代码示例
// 普通的命名空间
namespace N1 // N1为命名空间的名称
{
// 命名空间中的内容,既可以定义变量,也可以定义函数
int a;
int Add(int x, int y) {
return x + y;
}
}
(2)命名空间的嵌套定义
📝 代码示例
// 命名空间可以嵌套
namespace N1 // 定义一个名为N1的命名空间
{
int a;
int b;
namespace N2 // 嵌套定义另一个名为N2的命名空间
{
int c;
int d;
}
}
(3)命名空间的相同定义
同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。
📝 代码示例
// 定义一个N1
namespace N1
{
int a;
int Add(int x, int y) {
return x + y;
}
}
// 再定义一个N1
namespace N1
{
int Mul(int left, int right)
{
return left * right;
}
}
注意:一个命名空间就定义了一个新的作用域,命名空间中的所有内容都局限于该命名空间中
🍑 命名空间的使用
我们已经知道了如何定义命名空间,那么我们应该如何使用命名空间中的成员呢?
命名空间的使用一共有三种方式,我们一起来看看吧!
(1)加命名空间名称及作用域限定符
符号 ::
在 C++ 中叫做作用域限定符。
我们通过 命名空间名称::命名空间成员
便可以访问到命名空间中相应的成员。
📝 代码示例
#include <stdio.h>
// 加命名空间名称及作用域限定符
namespace N
{
int a;
float b;
}
int main()
{
N::a = 10; // 将命名空间中的成员a赋值为10
N::b = 5.55; // 将成员b赋值为5.55
printf("%d\n", N::a); // 打印a
printf("%.2f\n", N::b); // 打印b
return 0;
}
运行结果
(2)使用 using 将命名空间中成员引入
我们还可以通过 using 命名空间名称::命名空间成员
的方式将命名空间中指定的成员引入。
这样语句之后的代码中就可以直接使用引入的成员变量了。
📝 代码示例
#include <stdio.h>
// 使用using将命名空间中的成员引入
namespace N
{
int a;
float b;
}
using N::a; // 将命名空间中的成员a引入
using N::b; // 再将b引入
int main()
{
a = 10; // 将命名空间中的成员a赋值为10
b = 5.55; // 将b赋值为5.55
printf("%d\n", a); // 打印成员a
printf("%.2f\n", b); // 打印成员b
return 0;
}
运行结果
(3)使用 using namespace 命名空间名称引入
最后一种方式就是通 using namespace 命名空间名称
将命名空间中的全部成员引入。
这样语句之后的代码中就可以直接使用该命名空间内的全部成员了。
📝 代码示例
#include <stdio.h>
// 使用 using namespace 命名空间名称引入
namespace N
{
int a;
float b;
}
using namespace N; // 将命名空间N的所有成员引入
int main()
{
a = 10; // 将命名空间中的成员a赋值为10
printf("%d\n", a); // 打印命名空间中的成员a
return 0;
}
运行结果
3. C++的输入和输出
在学习任何语言的时候,我们首先会向 世界问好!也就是会在屏幕上打印 hello world!
那么用 C++ 如何打印呢?很简单。
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
cout << "hello world!" << endl;
return 0;
}
运行结果
代码解释:
- C 语言中的标准输入输出函数为:scanf 和 printf。
- 而在 C++ 中,cin 是流提取(键盘),cout 流插入(控制台)。
- 当我们使用 cin 和 cout 时,需要包含头文件
<iostream>
以及 std 标准命名空间。
我们在 C 语言中,输入输出数据时,需要加数据格式控制比如:整形为 %d
,字符为 %c
。
而 C++ 的输入输出更方便,不需增加数据格式控制。
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
int a;
float b;
char c;
cin >> a; // 输入一个整型
cin >> b; // 输入一个浮点型
cin >> c; // 输入一个字符型
cout << endl; // 换行
cout << a << endl; // 打印整型a
cout << b << endl; // 打印浮点型b
cout << c << endl; // 打印字符型c
return 0;
}
运行结果
注意:endl 表示 换行,相当于 C 语言中的 \n。
4. 缺省参数
在 C 语言中,函数没有指定参数列表,默认可以接收任意多个参数,但在 C++ 中,因为严格的参数类型检测,没有参数列表的函数,默认为 void,不接收任何参数。
🍑 缺省参数概念
缺省参数是声明或定义函数时为函数的参数指定一个默认值。
在调用该函数时,如果没有指定实参则采用该默认值,否则使用指定的实参。
📝 代码示例
#include <iostream>
using namespace std;
void Test(int a = 0) {
cout << a << endl;
}
int main()
{
Test(); // 没有指定实参,使用参数的默认值(打印0)
Test(10); // 指定了实参,使用指定的实参(打印10)
return 0;
}
运行结果
在第一个 Test 函数中,输出的结果是 0,第二个 Test 函数输出结果是 10。
🍑 缺省参数分类
缺省参数是分为两类的,一类是 全缺省,一类是 半缺省。
(1)全缺省参数
-
全缺省参数所有参数都有默认值,如果没有手动传参,那么编译器会使用默认参数列表中的参数。
-
但是这里值得注意的是,如果传参的时候只传了部分参数,那么该值会被 从左至右 匹配。
📝 代码示例
#include <iostream>
using namespace std;
void Test(int a = 1, int b = 2, int c = 3)
{
cout << a << " " << b << " " << c << endl;
}
int main()
{
Test();
Test(10);
Test(10, 20);
Test(10, 20, 30);
return 0;
}
运行结果
(2)半缺省参数
- 半缺省参数,即函数的参数不全是缺省参数。
📝 代码示例
void Test1(int a ,int b = 2, int c = 3)
{
cout << a << " " << b << " " << c << endl;
}
void Test2(int a, int b, int c = 3)
{
cout << a << " " << b << " " << c << endl;
}
其中 Test 1 函数至少传一个参数,Test 2 函数至少传两个参数,函数才可以正常运行。
🍑 注意事项
(1)半缺省参数必须从右往左依次来缺省,不能间隔着给。
📝 代码示例
// 错误示例
void Test(int a, int b = 20, int c)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
// 正确示例
void Test(int a, int b = 20, int c = 30)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
(2) 缺省参数不能在函数声明和定义中同时出现
📝 代码示例
错误示例:
// Test.h(函数声明)
void Test(int a, int b, int c = 30);
// Test.c(函数定义)
void TestFunc(int a, int b, int c = 30) {
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
//-------------------------------------------
正确示例:
// Test.h(函数声明)
void Test(int a, int b, int c = 30);
// Test.c(函数定义)
void TestFunc(int a, int b, int c) {
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
如果 声明 与 定义 位置同时出现,恰巧两个位置提供的值不同,那编译器就无法确定到底该用那个缺省值。
也就是说,缺省最好在函数的 声明 中给定,因为一般函数的声明都是定义在头文件中的,它会在 cpp 文件去展开。
(3) 缺省值必须是常量或者全局变量
📝 代码示例
// 正确示例
int x = 30; //全局变量
void Test(int a, int b = 20, int c = x)
{
cout << a << endl;
cout << b << endl;
cout << c << endl;
}
5. 函数重载
自然语言中,一个词可以有多重含义,人们可以通过上下文来判断该词真实的含义,即该词被 重(chong)载 了。
🍑 函数重载概念
函数重载是指 在同一作用域内,可以有一组具有相同函数名,不同参数列表的函数,这组函数被称为重载函数。
重载函数通常用来命名一组功能相似的函数,这样做减少了函数名的数量,避免了名字空间的污染,对于程序的可读性有很大的好处。
例如函数 Test(int a, float b)
的参数列表是 (int, float)
,它与函数 Test(float a, int b)
参数列表 (float, int)
不同,这就是函数重载。
函数重载是编译时多态。
📝 代码示例
#include <iostream>
using namespace std;
int Test(int a, int b) {
return a + b;
}
double Test(double a, double b) {
return a + b;
}
double Test(int a, double b) {
return a + b;
}
int main()
{
cout << Test(10, 20) << endl;
cout << Test(5.5, 5.5) << endl;
cout << Test(10, 5.5) << endl;
return 0;
}
运行结果
注意:
- 函数重载支持同名参数,要求函数名相同,但是形参列表的参数不同。
- 形参列表不同是指 参数个数、参数类型 或者 参数顺序 不同,若仅仅是返回类型不同,则不能构成重载。
🍑 函数重载原理
为什么 C++ 支持函数重载,而 C 语言不支持函数重载呢?
首先,我们知道在 C/C++ 中,一个程序要运行起来,需要经历以下几个阶段:预处理、编译、汇编、链接。
如下图所示:
上图解释:
- 编译阶段会将程序中的每个源文件的全局范围的变量符号分别进行汇总。
- 在汇编阶段会给每个源文件汇总出来的符号分配一个地址(若符号只是一个声明,则给其分配一个无意义的地址),然后分别生成一个符号表。
- 最后在链接期间会将每个源文件的符号表进行合并,若不同源文件的符号表中出现了相同的符号,则取合法的地址为合并后的地址(重定位)。
🍅 C/CPP的符号汇总是不同的
我们可以看一下在 Linux 平台下,C 和 CPP 的指令集符号的标识
在 C 语言中,汇编阶段进行符号汇总时,一个函数汇总后的符号就是其函数名,所以当汇总时发现多个相同的函数符号时,编译器便会报错。
而 C++ 在进行符号汇总时,对函数的名字修饰做了改动,函数汇总出的符号不再单单是函数的函数名,而是通过其 参数的类型 和 个数 以及 顺序 等信息汇总出一个符号。
这样一来,就算是函数名相同的函数,只要其参数的类型或参数的个数或参数的顺序不同,那么汇总出来的符号也就不同了。
总结:
- C 语言不能支持重载,是因为同名函数没办法区分;而 C++ 是通过函数修饰规则来区分的,只要函数的形参列表不同,修饰出来的名字就不一样,也就支持了重载。
- 另外我们也理解了,为什么函数重载要求参数不同,根返回值没关系。
🍑 extern “C”
在 C 工程中,我们可以去调用 C 自己的静态库或者动态库;
在 CPP 工程中,我们也可以去调用 CPP 自己的静态库或者动态库;
那么如何实现 C 和 CPP 互相调用对方的库呢?
🍅 CPP 调用 C
有时候在 CPP 工程中可能需要将某些函数按照 C 的风格来编译,在函数声明的前面加上 extern "C"
。
意思是 告诉编译器,将该函数按照 C 语言规则来编译。
如下,我在 CPP 工程中,去调用 C 的静态库:
🍅 C 调用 CPP
假设我现在有个 CPP 的静态库,那么如何在 C 工程中去调用呢?
除了需要在 C 工程引上 CPP 静态库的头文件之外,还需要在 CPP 文件中,把 extern "C"
加在函数声明的前面:
或者,如果不加的话,也可以用括号把多个函数声明括起来:
比如,GooGle 有个开源项目叫做 tcmalloc,这个项目是用来替代 C 语言当中的 malloc,它认为 tcmalloc 在多线程下的效率比 malloc 高,它是用 C++ 来实现的。
如果在 CPP 工程中,我使用这个项目完全没问题,但是如果在 C 的工程中想去用它呢?那么就需要使用 extern "C"
来解决。
比如:tcmalloc 是 google 用 C++ 实现的一个项目,他提供 tcmallc()
和 tcfree()
两个接口来使用,但如果是 C 项目就没办法使用,那么他就使用 extern "C"
来解决。
下面是 tcmalloc 项目的源码,可以看到,都是加了 extern "C"
的,这样 C 和 CPP 工程都能够使用!
总结:
- C++ 项目可以调用 C++ 库,也可以调用 C 的库,C++ 是直接兼容 C 的。
- C 项目可以调用 C 库,也可以使用
extern "C"
调用 C++ 库 (C++ 提供的函数加上extern "C"
)
6. 引用
🍑 引用的概念
引用 不是新定义一个变量,而 是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量 共用同一块内存空间。
使用的基本形式为:类型& 引用变量名(对象名) = 引用实体
。
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
int a = 10;
int& b = a; //给变量a取了一个别名,叫b
cout << "a = " << a << endl; // a打印结果为10
cout << "b = " << b << endl; // b打印结果也是10
cout << " " << endl;
b = 20; // 改变b也就是改变了a
cout << "a = " << a << endl; // a打印结果为20
cout << "b = " << b << endl; // b打印结果也是为20
return 0;
}
运行结果
注意:引用类型必须和引用实体是同种类型的。
🍑 引用的特性
(1)引用在定义时必须初始化
错误用法:
int a = 10;
int& b; // 定义b时,没有进行初始化
b = a;
正确用法:
int a = 10;
int& b = a; // 定义时必须初始化
(2)一个变量可以有多个引用
int a = 10;
int& b = a;
int& c = a;
int& d = a;
此时,b、c、d 都是变量 a 的引用。
(3)引用一旦引用一个实体,再不能引用其他实体
创建一个 变量 a,再创建一个 变量 b,那么 b 是 a 的引用。
int a = 10;
int& b = a;
那么我再创建一个变量 c,想让 b 成为 c 的引用。
int a = 10;
int& b = a;
int c = 20;
b = c;
注意:此时,b 已经是 a 的引用了,b 不能再引用其他实体,它是意思是,将 b 引用的实体赋值为 c,也就是将变量 a 的内容改成了 20。
运行结果
🍑 常引用
引用类型必须和引用实体是同种类型的。
但是仅仅是同种类型,还不能保证能够引用成功,我们若用一个普通引用类型去引用一个被 const 所修饰的类型,那么引用将不会成功。
我们对于引用取别名的原则是:对原引用变量,权限只能缩小,不能放大。
(1)权限不变
下面代码中,a 变量的内容是 可读可写 的,也就是说此时权限是最大的,我们可以通过别名 b,随意对 a 进行修改:
int main()
{
int a = 10;
int& b = a;
//打印b(读权限)
cout << b << endl;
//修改b(写权限)
b = 20;
cout << b << endl;
cout << a << endl;
return 0;
}
运行结果
(2)权限缩小
下面代码中,a 变量的内容是 可读可写 的,也就是说此时权限是最大的,那么我们可以控制 b 的权限,让它变成只读:
int main()
{
int a = 10; //可读可写
const int& b = a; //只读权限
//打印b(读权限)
cout << b << endl;
//这里如果再对b进行修改,会报错
b = 20;
return 0;
}
可以看到,如果我们对 b 进行赋值,编译会报错:
(3)权限放大
下面代码中,a 变量用 const 修饰,说明变量不能被修改,是只读的,那么我们定义别名的时候,也必须拿 const 修饰:
int main()
{
const int a = 10; //变量a是只读的
//这里的b属于权限的放大,编译会报错
int& b = a; //b是a的别名,但b是可读可写的
const int& c = a; //c是a的别名,和a一样,c是只读的
return 0;
}
可以看到,编译会报错:
(4)临时变量
还有一种情况也必须用 const,下面代码中,d 是 double 类型,而 a 和 b 都是 int 类型,但是如果想要为 d 取一个别名,必须加上引用,因为把 double 类型赋值给 int 类型会涉及到隐式类型转换,那么 d 在此时就是一个临时变量,临时变量具有常性,所以要加 const
int main()
{
double d = 2.2;
int& a = d; //编译不通过,会报错
const int& b = d;
return 0;
}
可以看到,编译时会有报错:
总结:
- const 引用的好处是保护实参,避免被误改,且它可以传普通对象也可以传 const 对象。
- 函数传参如果想减少拷贝使用引用传参,如果函数中不改变这个参数最好使用 const 引用传参。
🍑 使用场景
(1)做参数
在 C 语言中,我们学习过 交换函数,当时深入剖析了 传值 和 传址。
现在我们学习了引用,可以不用 传址 了。
📝 代码示例
#include <iostream>
using namespace std;
void Swap1(int* p1, int* p2) {
int temp = *p1;
*p1 = *p2;
*p2 = temp;
}
void Swap2(int& rx, int& ry) {
int temp = rx;
rx = ry;
ry = temp;
}
int main()
{
int x = 3, y = 5;
Swap1(&x, &y); // C传参
Swap2(x, y); // C++传参
return 0;
}
因为这里 rx 和 ry 是传入实参的引用,我们将 x 和 y 的值交换,就相当于将传入的两个实参交换了。
(2)做返回值
思考一下为什么需要使用 引用 来做返回值呢?
下面这段代码中,我在 Cout 函数里面定义了一个静态变量,那么出了作用域以后,n 不会被销毁,如果我们使用传值返回的话,会产生一个临时变量,意味着会有一个临时拷贝,那么程序的效率也就降低了。
int Cout() {
static int n = 0;
n++;
return n;
}
int main()
{
int ret = Cout();
cout << ret << endl;
ret = Cout();
cout << ret << endl;
return 0;
}
所以我们可以给 Cout 函数的返回值加上一个引用,那么当我们返回 n 时,主函数里面使用 ret 来接收,那么这个 ret 就是 n 的别名,也就是说,传引用返回以后,就没有生成一个临时拷贝了,函数返回的直接就是返回变量的别名。
代码如下:
int& Cout() {
static int n = 0;
n++;
return n;
}
int main()
{
int ret = Cout();
cout << ret << endl;
ret = Cout();
cout << ret << endl;
return 0;
}
但是要特别注意,我们返回的数据不能是函数内部创建的普通局部变量,因为在函数内部定义的普通的局部变量会随着函数调用的结束而被销毁。
我们返回的数据必须是被 static 修饰,或者是 动态开辟 的,再或者是 全局变量 等一些不会随着函数调用的结束而被销毁的数据。
总结:
- 如果函数返回时,出了函数作用域,返回对象还未还给系统,则可以使用引用返回;
- 如果已经还给系统了,则必须使用传值返回。
🍑 传值、传引用效率比较
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝。
我这里写了个程序,可以用来测量 传值 和 传引用 的效率。
📝 代码示例
#include <iostream>
#include <time.h>
using namespace std;
struct A {
int a[10000];
};
A a;
void TestFunc1(A a) {}
void TestFunc2(A& a) {}
void TestRefAndValue()
{
A a;
// 以值作为函数参数
size_t begin1 = clock();
for (size_t i = 0; i < 10000; ++i)
TestFunc1(a);
size_t end1 = clock();
// 以引用作为函数参数
size_t begin2 = clock();
for (size_t i = 0; i < 10000; ++i)
TestFunc2(a);
size_t end2 = clock();
// 分别计算两个函数运行结束后的时间
cout << "TestFunc1(A)-time:" << end1 - begin1 << endl;
cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}
int main()
{
//传值、传引用效率比较
TestRefAndValue();
return 0;
}
运行结果
可以看到,用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
🍑 值和引用的作为返回值类型的性能比较
我们再来比较一下值和引用的作为返回值类型的性能。
📝 代码示例
#include <iostream>
#include <time.h>
using namespace std;
struct A {
int a[10000];
};
A a;
A TestFunc3() {
return a;
}
A& TestFunc4() {
return a;
}
void TestReturnByRefOrValue()
{
// 以值作为函数的返回值类型
size_t begin1 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc3();
size_t end1 = clock();
// 以引用作为函数的返回值类型
size_t begin2 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc4();
size_t end2 = clock();
// 计算两个函数运算完成之后的时间
cout << "A TestFunc3 time:" << end1 - begin1 << endl;
cout << "A& TestFunc4 time:" << end2 - begin2 << endl;
}
int main()
{
//值和引用作为返回值类型的性能比较
TestReturnByRefOrValue();
return 0;
}
运行结果
很明显,引用 的性能更优于 传值。
总结: 可以发现发现 传值 和 指针 在作为 传参 以及 返回值类型 上效率相差很大。
🍑 引用和指针的区别
在语法概念上,引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。
而指针变量是开辟一块空间,存储变量的地址。
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
int a = 10;
int& ra = a;
cout << "&a = " << &a << endl;
cout << "&ra = " << &ra << endl;
return 0;
}
运行结果
可以看到 a 和它的引用 b 地址是一样的。
但是,在底层实现上实际是有空间的,因为引用是按照指针方式来实现的。
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
int a = 10;
// 语法上,给a这块空间取了一个别名,没有新开空间
int& ra = a;
ra = 20;
// 在语法上,这里定义了一个pa指针,开辟了4个字节的空间,用于存储a的地址
int* pa = &a;
*pa = 20;
cout << "&a = " << &a << endl;
cout << "&ra = " << &ra << endl;
cout << "&pa = " << &pa << endl;
return 0;
}
我们来看下引用和指针的 汇编代码 对比,可以看到从底层的角度来说,它们都是一样的实现方式:
🍑 引用和指针的区别
(1) 引用在定义时必须初始化,指针没有要求。
(2) 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体。
(3)没有 NULL 引用,但有 NULL 指针。
(4)在 sizeof 中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32 位平台下占 4 个字节)。
(5)引用自加即引用的实体增加 1,指针自加即指针向后偏移一个类型的大小。
(6) 有多级指针,但是没有多级引用。
(7)访问实体方式不同,指针需要显式解引用,引用编译器自己处理。
(8)引用比指针使用起来相对更安全。
7. 内联函数
在程序中,大量重复的建立函数栈帧 (如 swap 等函数) 会造成很大的性能开销。
在 C 语言可以用宏来代替函数,使之不会开辟栈帧,虽然宏的优点多,但也有不少的缺点,这时 内联函数 就可以针对这种场景解决问题 (内联函数对标宏函数)。
🍑 内敛函数的概念
以 inline 修饰的函数叫做内联函数,编译时,C++ 编译器会在调用内联函数的地方展开,没有函数压栈的开销,内联函数提升程序运行的效率。
📝 代码示例
#include <iostream>
using namespace std;
int Add(int x, int y)
{
return x + y;
}
int main()
{
int ret = 0;
ret = Add(1, 2);
return 0;
}
这就是一个简单的 加法 函数,我们可以转到 反汇编,然后能看到调用栈帧的过程(call 指令用于调用其他函数)。
如果在上述函数前增加 inline 关键字将其改成内联函数,在编译期间编译器会用函数体替换函数的调用。
📝 代码示例
#include <iostream>
using namespace std;
inline int Add(int x, int y)
{
return x + y;
}
int main()
{
int ret = 0;
ret = Add(1, 2);
return 0;
}
此时,我们在 release 模式下查看编译器生成的汇编代码,可以发现不存在 call Add
这个指令:
从汇编代码中可以看出,内联函数调用时并没有调用函数这个过程的汇编指令。
🍑 内敛函数的特性
(1)inline 是一种以空间换时间的做法,省去调用函数额开销。所以 代码很长 或者 有循环 或者 有递归 的函数不适宜使用作为内联函数。
(2) inline 对于编译器而言只是一个建议,编译器会自动优化,如果定义为 inline 的函数体内 有循环 或者 有递归 等等,编译器优化时会忽略掉内联。
(3)inline 不建议声明和定义分离,分离会导致链接错误。因为 inline 被展开,就没有函数地址了,链接就会找不到。
📝 代码示例
// F.h
#include <iostream>
using namespace std;
inline void f(int i);
// F.cpp
#include "F.h"
void f(int i)
{
cout << i << endl;
}
// Test.cpp
#include "F.h"
int main()
{
f(10);
return 0;
}
可以看到,编译时会报错:
8. auto关键字
auto 是 C++11 中的关键字。
🍑 auto简介
在早期 C/C++ 中 auto 的含义是:使用 auto 修饰的变量,是具有自动存储器的局部变量。
但遗憾的是一直没有人去使用它,大家可思考下为什么?
C++11 中,标准委员会赋予了 auto 全新的含义即:auto 不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto 声明的变量必须由编译器在编译时期推导而得。
📝 代码示例
#include <iostream>
using namespace std;
double Test() {
return 3.14;
}
int main()
{
int a = 10;
auto b = a;
auto c = 'A';
auto d = Test();
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
return 0;
}
运行结果
注意:
- 使用 auto 定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导 auto 的实际类型。
- 因此 auto 并非是一种 “类型” 的声明,而是一个类型声明时的 “占位符”,编译器在编译期会将 auto 替换为变量实际的类型。
🍑 auto的使用细则
(1)auto 与指针和引用结合起来使用
用 auto 声明指针类型时,用 auto
和 auto*
没有任何区别,但用 auto 声明引用类型时则必须加 &
。
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
int a = 10;
auto b = &a; // 自动推导出b的类型为int*
auto* c = &a; // 自动推导出c的类型为int*
auto& d = a; // d是a的引用,自动推导出d的类型为int
cout << typeid(b).name() << endl; // 打印结果为int*
cout << typeid(c).name() << endl; // 打印结果为int*
cout << typeid(d).name() << endl; // 打印结果为int
return 0;
}
运行结果
注意:用 auto 声明引用时 必须加 &,否则创建的只是与实体类型相同的普通变量。
(2)在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
auto a = 1, b = 2;
auto c = 3, d = 3.14; // 编译器报错:“auto”必须始终推导为同一类型
return 0;
}
🍑 auto不能推导的场景
(1)auto 不能作为函数的参数
// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导
void TestAuto(auto a) {
;
}
(2)auto 不能直接用来声明数组
void TestAuto() {
int a[] = {1,2,3};
auto b[] = {4,5,6}; // 此处编译失败,错误写法
}
(3) 为了避免与 C++98 中的 auto 发生混淆,C++11 只保留了 auto 作为类型指示符的用法
(4)auto 在实际中最常见的优势用法就是跟 C++11 提供的新式 for 循环,还有 lambda 表达式等进行配合使用。
9. 基于范围的for循环
这也是 C++11 中的特性。
🍑 范围for的语法
在 C++98 中如果要遍历一个数组,可以按照以下方式进行:
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
int arr[] = { 1,2,3,4,5 };
// 将数组元素值全部乘以2
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
arr[i] *= 2;
}
// 打印数组中的所有元素
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
运行结果
以上方式是我们 C 语言中所用的遍历数组的方式,对于一个有范围的集合而言,循环的范围是多余的,有时候还会容易犯错误。
因此 C++11 中引入了基于范围的 for 循环。
for 循环后的括号由冒号 :
分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
📝 代码示例
#include <iostream>
using namespace std;
int main()
{
int arr[] = { 1,2,3,4,5 };
// 将数组元素值全部乘以2
for (auto& e : arr) {
e *= 2;
}
// 打印数组中的所有元素
for (auto e : arr) {
cout << e << " ";
}
cout << endl;
return 0;
}
运行结果
注意:与普通循环类似,可以用 continue 来结束本次循环,也可以用 break 来跳出整个循环。
🍑 范围for的使用条件
(1)for 循环迭代的范围必须是确定的
- 对于数组而言,就是数组中第一个元素和最后一个元素的范围;
- 对于类而言,应该提供 begin 和 end 的方法,begin 和 end 就是 for 循环迭代的范围。
下述代码就有问题,因为 for 的范围不确定,也就是此时 array 在形参部分是一个指针,而不再是数组。
void TestFor(int array[]) {
for(auto& e : array)
cout<< e <<endl;
}
(2)迭代的对象要实现 ++ 和 == 的操作。
这是关于迭代器的问题,后续文章会讲。
10. 指针空值nullptr
这也是 C++11 中的特性
🍑 C++98中的指针空值
在良好的 C/C++ 编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针。
如果一个指针没有合法的指向,我们基本都是按照如下方式对其进行初始化。
void TestPtr() {
int* p1 = NULL;
int* p2 = 0;
}
NULL 其实是一个宏,在传统的 C 头文件 (stddef.h)
中可以看到如下代码:
/* Define NULL pointer value */
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else /* __cplusplus */
#define NULL ((void *)0)
#endif /* __cplusplus */
#endif /* NULL */
可以看到,NULL 可能被定义为字面常量 0,或者被定义为无类型指针 (void*)
的常量。
不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,比如:
#include <iostream>
using namespace std;
void f(int) {
cout << "f(int)" << endl;
}
void f(int*) {
cout << "f(int*)" << endl;
}
int main()
{
f(0); //调用f(int)
f(NULL); //也是调用f(int)
return 0;
}
程序本意是想通过 f(NULL)
调用指针版本的 f(int*)
函数,但是由于 NULL 被定义成 0,所以 f(NULL)
最终调用的是 f(int)
函数。
注意:
- 在 C++98 中,字面常量 0 既可以是一个整形数字,也可以是无类型的指针
(void*)
常量。 - 但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转
(void *)0
。
🍑 C++11中的指针空值
对于 C++98 存在的问题,C++11 引入了关键字 nullptr。
但是,还得注意:
(1)在使用 nullptr 表示指针空值时,不需要包含头文件,因为 nullptr 是 C++11 作为新关键字引入的。
(2)在 C++11 中,sizeof(nullptr)
与 sizeof((void*)0)
所占的字节数相同。
(3)为了提高代码的健壮性,在后续表示指针空值时建议最好使用 nullptr。
总结
这是 C++ 入门的第一篇,学习 C++ 语言就像学一门活跃使用中的外语,你不要期望能够掌握所有的单词和语法规则—那对于世界上 99.999999% 的人来说是不可能的。但语言是服务于人的,语法规则也是服务于人的,是为了让人们能够更好地沟通和表达。
虽然C++的每一个新标准都是让语言从定义和规则的角度变得更复杂,但从用法上来说,新标准允许人们能够更简单地表达自己的计算意图。跟学外语一样,我们需要的是多看多写,掌握合适的“语感”,而不是记住所有的规则。