首先我们先要知道,C++是在C的基础之上,容纳进去了面向对象编程思想,并增加了许多有用的库。本章将会带大家了解,C++是补充C语言语法的不足,以及C++是如何对C语言设计不合理的地方进行优化的。
一、命名空间
1. namespace
在C/C++中,变量、函数等等都是大量存在的,这些变量、函数和类的名称将都存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突,namespace 关键字的出现就是针对这种问题的。
例如,我们想定义一个变量 sqrt ,直接定义在全局变量然后编译是可以通过的,例如下图:
但是,我们知道 sqrt 其实是一个库函数,它包含在 math.h 的头文件中,假设我们加上 math.h 的头文件,还能编译过吗?答案是不能,因为它们重名了,如果包含了 math.h 的头文件,编译不会通过,会报下图中的错误:
那么有没有好的解决方案呢,答案是有的,C++中就增加了 namespace 这样的关键字解决这样的问题。例如我们可以将我们需要定义的变量放入 namespace 的命名空间中,然后在使用让编译器在指定的命名空间中寻找;如果不指定编译器,编译器优先会在全局域中寻找变量;namespace 的使用:
#include <stdio.h> #include <math.h> // 命名空间的名字 namespace Young { int sqrt = 10; } int main() { printf("%d\n", Young::sqrt); return 0; }
上述代码的使用就是让编译器在指定的命名空间 Young 中去寻找变量 sqrt 然后使用这个变量,这样就不会与库函数中的 sqrt 函数有命名冲突了;Young 是一个可以自己命名的命名空间的名字,可以取任意名字,不一定是 Young.
像 printf("%d\n", Young::sqrt);
中,sqrt 前面的 :: 符号,叫做域作用限定符,意思是让编译器使用域作用限定符前面的命名空间中定义的东西。
2. namespace 的使用场景
除了上面我们使用 namespace 在命名空间中定义变量外,还可以定义函数、结构体等;除此之外,还可以嵌套使用。例如以下代码:
namespace Young { //变量 int sqrt = 10; // 函数 int Add(int a, int b) { return a + b; } // 结构体 struct ListNode { int data; struct ListNode* next; }; // 嵌套使用 namespace Y { int a = 10; } } int main() { int ret = Young::Add(1, 2); printf("%d\n", ret); struct Young::ListNode node; printf("%d\n", Young::Y::a); return 0; }
上述代码中主函数部分,结构体中的域作用限定符是要在 ListNode 前使用,而不是在 struct 前使用;嵌套使用 namespace 是从右往左看,到指定的命名空间中去寻找;
虽然这种方法可以有效避免命名冲突问题,但是每次用的时候都要在前面加上域作用限定符,是不是很麻烦呢?确实是,但是还有一种方法可以解决,将命名空间展开;以上面的命名空间为例,例如以下代码:
// 将命名空间展开 using namespace Young; using namespace Y; int main() { int ret = Add(1, 2); printf("%d\n", ret); struct ListNode node; printf("%d\n",a); return 0; }
上面的代码就将 Young 和 Y 两个命名空间中的内容展开,就不用再使用域作用限定符了;除此之外,我们还可以展开部分命名空间中的内容,例如,我只展开 Add 函数出来:
// 展开部分 using Young::Add; int main() { int ret = Add(1, 2); printf("%d\n", ret); struct Young::ListNode node; printf("%d\n", Young::Y::a); return 0; }
以上就是展开部分的命名空间,通常在做项目的时候,我们都不会将命名空间展开,因为展开就会变得不安全;但是在平常我们在写代码练习的时候,可以将命名空间展开,更有利于我们练习。
二、了解 C++ 中的输入和输出
首先我们先要知道,C++中引入了不同于C语言的输入和输出,在C语言中我们使用 scanf 和 printf 作为输入和输出,但是在C++中了 cout 标准输出对象(控制台)和 cin 标准输入对象(键盘);我们先看看它们的使用:
我们可以了解到,上述代码中的 cout 和 cin 分别叫做流插入运算符和流提取运算符,关于这两个更多的我们在以后的学习中再介绍;其中 cout 和 cin 必须包含< iostream >头文件以及按命名空间使用方法使用 std ,其中 std 是C++标准库的命名空间名,C++将标准库的定义实现都放到这个命名空间中。所以我们可以展开 std 的命名空间:
#include <iostream> using namespace std; int main() { int input; double d; // 自动识别类型 cin >> input >> d; cout << input << endl << d << endl; return 0; }
除此之外,cin 和 cout 还可以自动识别变量的类型,如上述代码,它的输出如下图:
三、缺省参数
缺省参数是声明或定义函数时为函数的参数指定一个缺省值。在调用该函数时,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参。先看看缺省参数的使用:
在上面的使用中,Add 函数就是用了缺省参数,在 Add 函数定义中,它指定了 a = 100,b = 200,意思就是,当调用 Add 函数时,如果没有参数传进来,就使用它自己定义的变量;传参时,就使用指定的实参,如下图:
当然也可以只传一部分参数,但是当出现多个参数时,参数必须从右往左依次来给出,不能间隔着给;例如:
#include <iostream> using namespace std; int Add(int a = 100, int b = 200, int c = 300) { return a + b + c; } int main() { int a = 10, b = 20, c = 30; int ret = Add(a); cout << ret << endl; return 0; }
以上这段的代码输出结果就是 510 ,那么例如 int ret = Add(a,,c);
这种传参是不允许的。
那么我们可以给缺省参数分类,像上面代码中,Add()
这种什么都不传的就叫做全缺省参数;像Add(a)
或者Add(a,b)
这种只传一部分的就叫做半缺省参数。
最后,我们要注意缺省参数不能在函数声明和定义中同时出现,如果在函数声明和函数中同时出现,我们只需要在声明中给缺省值即可。
四、函数重载
1. 函数重载的概念
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或 类型顺序)不同,常用来处理实现功能类似数据类型不同的问题。我们先看使用:
#include <iostream> using namespace std; void Add(int a ,double b) { // 打印数据方便观察 cout << "void Add(int a ,double b)" << endl; } void Add(double a, int b) { // 打印数据方便观察 cout << "void Add(double a, int b)" << endl; } int main() { Add(3, 3.14); Add(3.14, 3); return 0; }
运行的结果如下:
以上代码中,我们在函数中打印数据,是为了说明编译器调用了这个函数;我们定义了两个同名的函数,但是它们的参数类型不一样,而我们在使用这两个函数的时候,传的参数也不一样,所以它们会调用各自对应的函数;
2. C++支持函数重载的原理
C++支持函数重载的原理是因为C++有自己的函数名修饰规则。
我们知道,.cpp文件或者.c文件在生成可执行程序之前,要经过预处理,编译,汇编,链接的过程,具体回顾往期博客:预处理和程序环境;
其中,C语言在编译过程中,符号汇总将所有.c文件的函数名汇总在一起,注意,是函数名,所以在C语言中,重名的函数名在编译过程中会有冲突,编译不通过;
但是,在C++中的函数名修饰规则中,C++不是用函数名汇总在一起,而是有它自己的修饰规则,具体的修饰规则在不同的编译器有不同的修饰规则,例如:
void func(int i, double d) {} void func(double d, int i) {}
这两个函数,在 g++ 编译器的函数修饰后变成【_Z+函数长度+函数名+类型首字母】,如图:
所以它们在编译汇总的时候是可以区分开来的。
五、引用
1. 引用的概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。先看一个简单的例子:
#include <iostream> using namespace std; int main() { int a = 10; int& b = a; return 0; }
以上代码中int& b = a;
就是在定义引用类型,b 就是 a 的别名,a 和 b 实际上都是指向同一个空间,a 的改变会影响 b ,b 的改变也会影响 a.
2. 引用特性
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 引用一旦引用一个实体,再不能引用其他实体
void Test() { int a = 10; // int& ra; // 该语句编译时会出错 int& ra = a; int& rra = a; }
int& ra;
会编译出错是因为在定义时没有初始化;上述代码中,rra 是 ra 的别名,也是 a 的别名,这三个变量用的都是同一个空间,它们之间的互相改变都会影响彼此。
3. 常引用
我们在使用引用时要遵守一条规则,就是在引用的过程中,权限可以平移,权限也可以缩小,但是权限不能放大。例如:
int main() { const int a = 0; // 权限的放大,不允许 //int& b = a; // 不算权限的放大,因为这里是赋值拷贝,b修改不影响a //int b = a; // 权限的平移,允许 const int& c = a; // 权限的缩小,允许 int x = 0; const int& y = x; return 0; }
上述代码中,权限的放大是指,const int a = 0;
const修饰的 a 变量具有常性,不可修改,是只读,但是int& b = a;
代表 b 的值可修改,并且 b 的值修改会影响 a ,b 是可读可写的,但是 a 只有只读,所以这里是权限的放大;但是int b = a;
不算权限的放大,因为这里是赋值拷贝,b 的修改不影响 a.
权限的平移是指,大家都具有一样的权限,例如上述代码中的const int& c = a;
此处的 c 和 a 都被 const 修饰了,大家都具有常性,所以是权限的平移,是可以的。
权限的缩小在上述代码中,int x = 0; const int& y = x;
是指 x 是可读可写的,但 y 被 const 修饰了,只有只读,但是从可读可写转变成只读是允许的,这种就叫做权限的缩小。
那么我们看一下以下的语句属于什么呢?
void test() { int i = 0; double& d = i; }
首先我们应该了解清楚,如果是int i = 0; double d = i;
也是可以的,因为它们之间会发生整型提升;那么我们要清楚,这个整型提升的过程中,会发生拷贝的过程,d 取的是 i 的临时拷贝,如下图,而这个临时拷贝具有常性,不可被修改,所以这里是权限的放大,是不允许的。
所以正确的语句应该如下:
void test() { int i = 0; const double& d = i; }
将 d 的属性也变成不可修改,那么它们之间就是权限的平移关系了。
4. 引用的使用场景
(1)做参数(传引用传参)
我们常见的传引用传参就是交换函数了,写一个我们常用的交换函数如下:
#include <iostream> using namespace std; void Swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } int main() { int a = 10, b = 20; Swap(&a, &b); return 0; }
在这个交换函数中,我们需要传 a 的地址和 b 的地址过去,才能改变 a 和 b 的值;在C++中,我们可以使用引用完成同样的交换,代码如下:
void Swap(int& p1, int& p2) { int tmp = p1; p1 = p2; p2 = tmp; } int main() { int a = 10, b = 20; Swap(a, b); return 0; }
使用了引用后,代码整体看起来就很舒服,不用像指针那样传地址和解引用;同时传引用传参还能提高传参的效率,因为每一次传址或者传值都是一次拷贝,每传一次就要多拷贝一次,效率很低;而引用则不需要拷贝,因为形参是实参的别名,就不用进行拷贝。
除此之外,传引用传参最舒服的地方还是在我们以前学过的单链表中,如往期博客 单链表 中,无论是头插还是尾插等等操作,都需要传二级指针才能改变链表的整体结构,而C++引入了引用之后,就不需要传二级指针了,如下代码:
void SLTPushBack(SLTNode*& phead, SLTDateType x) { // ... if (phead == NULL) { phead = newnode; } else { //... } } int main() { SLTNode* plist = NULL; SLTPushBack(plist, 1); SLTPushBack(plist, 2); SLTPushBack(plist, 3); return 0; }
(2)做返回值(传引用返回)
在使用传引用返回时需要注意,不像传引用传参一样,传引用返回如果出了函数作用域对象还在的话才可以用,如果出了函数作用域对象不在就不能用;如以下代码:
int& func() { int n = 0; n = 10; return n; } int main() { int ret = func(); return 0; }
在这段代码中,函数 func 内定义了一个变量 n,但是它的生命周期只在这个函数内,出了函数作用域它的空间就会被销毁,画图更好地理解:
如上图,func 销毁后,n 随之也会销毁,将空间归还给操作系统,但是在 main 函数中,ret 实际上是相当于访问已经销毁的 n ,这严格来说相当于野指针问题了,也就是越界访问。
但是在不同的编译器中,得出的结果却不一样,在 vs2019 中,是可以得到 n 的值,如下图:
而在 gcc/g++ 的编译器中,却报错了,如下图:
原因是因为,这取决于栈帧销毁之后,编译器是否会对已经销毁的空间初始化,如果对已经销毁的空间进行初始化,而继续对它进行访问,就是越界,像 gcc/g++ 这样的编译器,很明显在空间回收时会对空间进行初始化,所以造成越界;而 vs2019 则没有严格的检查。
拓展:那如果将代码改成如下,还能编译通过吗?
int& func() { int n = 0; n = 10; return n; } int main() { int& ret = func(); cout << ret << endl; cout << ret << endl; return 0; }
这里将 ret 的接收改成了引用,也就是说,ret 是返回的 n 的别名,我们看执行结果:
第二次执行是随机值,为什么呢?原因是因为 ret 是 n 的别名,它们公用同一个空间,在执行 cout 语句时,也会发生一系列函数栈帧的创建,所以新的空间会覆盖之前的 func 所在的空间,也就是说,n 的空间被覆盖了,也就是 ret 的空间被覆盖了,所以 n 的值也就变成了随机值;第一次是 10 的原因是原来的空间并没有被覆盖。
所以就引入了另一个话题,如果 n 的空间没有被覆盖,它是不是还是 10 呢?那么我们将代码修改成以下代码:
int& func() { int a[1000]; int n = 0; n = 10; return n; } int main() { int& ret = func(); cout << ret << endl; cout << ret << endl; return 0; }
在 func 函数内,我们增加了一个长度为 1000 的数组,我们先看运行结果:
这个时候又变成了 10 ,这是因为函数的栈帧中空间是向下创建的,所以在 func 函数内,先创建 1000 个空间,然后再为 n 创建空间,n 这个时候的位置是处于下方的;如果 func 销毁后,如果有新的空间覆盖,这要取决于这个空间是否比原来 func 的空间要大,如果这个空间很大,覆盖了 n ,那么 n 就会变成随机值,否则,n 还是原来的值。
那么传引用返回有什么应用场景呢?我们常见的传引用返回可以用作修改返回对象,例如在单链表中,查找函数和修改函数可以合并在一起写,使用传引用返回,这样就既可以查找到想要查找的数据,又能修改想要修改的值。例如以下代码:
int& SLFindOrModify(struct SeqList& ps, int i) { assert(i < ps.size); // ... return (ps.a[i]); } int main() { // 定义对象 struct SeqList s; // 查找 10 这个数据,并将它修改成 20 SLFindOrModify(s, 10) = 20; return 0; }
(3)引用和指针的区别
现在我们都学过指针和引用了,我们可以发现,其实引用和指针很相似,在很多用法上指针可以代替引用,引用也可以代替指针,那么它们之间又有什么区别呢?我们一一分析:
引用和指针的不同点:
- 引用概念上定义一个变量的别名,指针存储一个变量地址。
- 引用在定义时必须初始化,指针没有要求
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何
一个同类型实体 - 没有NULL引用,但有NULL指针
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32
位平台下占4个字节) - 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
- 有多级指针,但是没有多级引用
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 引用比指针使用起来相对更安全
六、内联函数
1. #define定义宏
我们以前学过 #define定义宏,如往期博客 #define定义宏 中,宏给我们带来很多好处,如针对频繁调用的小函数,不需要建立栈帧,提高了效率;如以下代码:
#define ADD(a,b) ((a)+(b)) int main() { int ret = ADD(10, 20); cout << ret << endl; return 0; }
以上的宏定义了两个数的相加,注意,这里宏定义的((a)+(b))
不能写成(a+b)
,因为考虑到运算符优先级问题,如ADD(1 | 2 + 1 & 2)
这种表达式,加号优先级更高,会先执行加的操作,再执行 | 和 & ,并不是我们想要的结果。
上面的宏定义在预处理阶段是直接展开替换,所以没有建立栈帧,很好地提高了效率。
但是宏给我们带来好处的同时,必然会带来不便,如使用宏定义会容易出错,就如上面两数相加的宏,少一个括号都不行,所以宏的语法坑很多。
最后总结一下宏的优缺点:
优点:
- 没有类型的严格限制。
- 没有函数栈帧的建立,提高效率。
缺点:
- 不方便调试宏。(因为预编译阶段进行了替换)
- 导致代码可读性差。
- 没有类型安全的检查 。
- 容易出错,语法坑多。
2. 内联函数的概念
所以C++引入了内联函数,以 inline 修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
例如以下的两数相加的内联函数:
inline int Add(int a, int b) { return a + b; } int main() { int ret = Add(10, 20); cout << ret << endl; return 0; }
以上代码中,两数相加的内联函数既没有建立函数栈帧,性能有很好的体现,也没有因为运算符问题需要添加很多括号,所以内联函数是综合了宏和函数的优缺点来设计的。
2. 内联函数的特性
(1) inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率。
(2) inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。
也就是说,假设你使用了 inline,编译器也不一定会视这个函数为内联函数,因为如果这个函数的规模很大,代码量大,会造成代码膨胀,所以综合性能方面考虑,我们如果使用内联函数,尽量要简化代码。
(3) inline 不建议声明和定义分离,分离会导致链接错误。因为 inline 被展开,就没有函数地址了,链接就会找不到。
例如我定义了一个 Test.h
的头文件,里面包含 Add 函数的声明:
inline int Add(int a, int b);
再定义一个 Test.cpp
文件,里面包含 Add 函数的实现:
#include "Test.h" int Add(int a, int b) { return a + b; }
然后在 main.cpp
函数中调用 Add 函数:
#include "Test.h" int main() { int ret = Add(10, 20); cout << ret << endl; return 0; }
最后编译出错了,如下图:
这是因为什么呢?原因是因为头文件 #include "Test.h"
会在预处理阶段在 main.cpp 文件中展开,展开之后会有函数 Add 的声明,而 Add 函数前加了内联 inline,编译器会认为它就是一个内联函数,认为它就会直接展开,所以在编译阶段没有给它一个有效的地址,也就没有进入符号表;而在 main 函数中调用了 Add 函数,它在符号表中并没有找到自己对应函数的地址,所以会出现链接错误。
七、auto关键字
在 C++11 中,auto 的含义是,auto 声明的变量必须由编译器在编译时期推导而得。也就是说,auto 是一个根据变量自动推导类型的关键字。
例如:
八、基于范围的for循环(C++11)
当我们需要遍历一个数组时,通常使用以下方式:
int main() { int arr[] = { 1,2,3,4,5,6,7,8 }; for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) { cout << arr[i] << " "; } return 0; }
对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此 C++11 中引入了基于范围的 for 循环。for 循环后的括号由冒号“ :”分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。使用范围 for 我们可以结合上面所学的 auto 关键字结合使用,例如以下代码:
如果我们需要改变数组中的值,是否像以下代码那样使用呢?
很明显,答案是不可以的,因为 e 只是数组中的数据的临时拷贝,改变临时拷贝的值不影响数组中原来的值,所以我们要加上引用:
int main() { int arr[] = { 1,2,3,4,5,6,7,8 }; for (auto& e : arr) { e *= 2; } for (auto e : arr) { cout << e << " "; } return 0; }
加上引用后,e 就是数组中的数据的别名,改变 e 也就是改变数组中的内容。
九、指针空值 nullptr
在早期设计 NULL 空指针时,NULL 实际上就是 0,所以导致有些地方使用 NULL 会造成不明确的函数调用,例如:
在以上代码中,func 构成函数重载,我们期望的 NULL 是调用 void func(int*)
函数,但是它却调用了另外一个,所以这造成了不明确的函数调用。
所以在 C++11 中,引入了 nullptr,它的类型是无类型指针(void*),这很好地避免了以上的情况,例如下图,nullptr 是调用了具有指针类型的函数:
最后,C++ 入门的全部内容已经全部分享完啦,感觉对自己有帮助的小伙伴赶紧点赞收藏吧~感谢支持!