目录
0.认识C++
相信大家在初次看到C++这几个字眼的时候,不禁会发问,什么是C++?为什么要有C++?C++和C语言有什么关系?今天笔者我就简单聊一聊。
在编程语言的发展过程中,人们从机器语言进入汇编语言,随着时间的推移,人们从汇编语言进入高级编程语言,C语言凭借其自身简单,高效,易用等优点,一举成为当时最流行的高级编程语言,这也就意味着有很多的人使用C语言。但是,人们进入高级编程语言阶段之后,编程语言的发展并没有停止,使用的人多了,使用的时间久了,其缺点自然就浮现出来了。
此时,人们已经不满足于面向过程编程,试图解决面向过程编程的缺点,于是,编程语言迎来了进一步的发展。
1979年,贝尔实验室的本贾尼等人试图分析unix内核的时候,试图将内核模块化,于是在C语言的基础上进行扩展,增加了类的机制,完成了一个可以运行的预处理程序,称之为C with classes(带类的C),这其实就是C++的雏形;1982年,Bjarne Stroustrup 博士在C语言的基础上引入并扩充了面向对象的概念,发明了一种新的程序语言,为了表达该语言与C语言的渊源关系,命名为C++。
所以,我们应该能够回答上面几个问题了:
- 什么是C++?C++是一门在C语言的基础上发展而来的新的编程语言。
- 为什么要有C++?解决C语言的不足和缺陷。
- C语言和C++之间的关系?C语言是C++的根基,C++是对C语言的扩充。
注意:C++是在C语言的基础上发展而来的,所以,C++需要兼容C语言,所以,写C++代码的时候,C语言的东西是可以直接拿来用的,本文主要介绍C++入门的一些小知识点。
1.命名空间
为什么要有命名空间
C++和C语言的一个不同的地方就是C++中有命名空间的概念。那为什么C++中要有命名空间呢?
我们来看一段代码:
#include <stdio.h>
#include <stdlib.h>
int rand = 10;
int main()
{
printf("%d ", rand);
return 0;
}
运行结果如下:
- 这是因为,rand这个变量在<stdlib.h>这个头文件中被定义成了函数,我们使用了和库里面名称相同的变量名,造成重定义错误,也就是命名冲突。
那这就意味着,在写代码的时候,我们每次包含库之后,都需要注意不能和库中的变量名冲突。这岂不难受?好在祖师爷本贾尼比我们先难受,于是发明了命名空间来解决命名冲突问题。
如何定义命名空间
定义命名空间需要使用 namespace 关键字,后面跟上命名空间的名字(命名空间的名字是可以随便取的),再加上一对大括号{}即可,大括号中的内容就是定义的命名空间中的内容。
示例:
- 命名空间中可以定义变量、函数、类型
namespace wall
{
// 在命名空间中定义变量
int rand = 10;
// 在命名空间中定义函数
int add(int num1, int num2)
{
return num1+num2;
}
// 在命名空间中定义类型
struct Node
{
int val;
struct Node* next;
};
}
- 命名空间可以嵌套
namespace wall
{
int rand = 10;
std::string name;
namespace sub_wall
{
int rand = 0;
std::string name;
}
}
上面演示的都是在同一个文件中定义命名空间,但是在实际工程中,我们可能会在不同的文件中定义相同名称的命名空间,这个时候会不会造成命名空间自己本身的冲突呢?
- 答案是不会的,因为,对于相同名称的命名空间,编译器会合成到同一个命名空间中去。
命名空间的使用
从上面我们可以感受到,命名空间就好像定义了一个新的作用域,就好像是修了一堵墙,把我们自己定义的变量、函数、类型包围起来了,当我们需要使用的时候,就需要指定地去这堵墙里面找,那么命名空间中的内容如何找呢?
使用命名空间中的内容有三种形式:我们以使用上述命名空间wall中的变量rand为例。
1、方式一:加命名空间名称 及 作用域限定符(::)
int main()
{
cout << wall::rand << endl;
return 0;
}
- 这种指定方式相当于 “指哪打哪”,指定哪个命名空间中的哪个变量就使用哪个变量。
2、方式二:使用using将命名空间中的某个成员引入
using wall::rand;
int main()
{
cout << rand << endl;
return 0;
}
- 如果并不存在冲突的变量名时,我们频繁大量地使用上面那种方式引入命名空间中的内容,就会显得麻烦且多余,所以,我们可以通过这种展开命名空间中的指定成员来减少代码的书写。
3、方式三:使用 using namespace 命名空间名字引入
using namespace wall;
int main()
{
cout << rand << endl;
return 0;
}
- 在我们平时写C++代码时,我们并不会引入太多的头文件或者库,也就不会产生命名冲突,所以,没必要指定命名空间中的成员,此时,我们可以使用这种方式将整个命名空间都展开,那么,命名空间中的所有内容都可以直接使用了。
在我们日常写代码时,使用方式三即可,但是,在实际项目中,最好是使用方式一。
2.C++的输入输出
我们每学习一门新的语言,编写的第一段代码无疑是大名鼎鼎的 “hello world”,学习C++也不例外,那么如何使用C++输出 “hello world” 呢?代码如下所示:
注意:为了和C头文件区分,也为了正确使用命名空间,规定C++头文件不带.h
#include <iostream>
using namespace std;
int main()
{
int year = 0;
// 输入
cin >> year;
// 输出
cout << "hello world " << year << endl;
return 0;
}
- 使用C++进行输入时,需要使用 cin 和 流提取运算符( >> ) ;使用C++进行输出时,需要使用 cout 和 流插入运算符 ( << )。cin 相当于C语言中的scanf() 函数,用于输入数据,cout 就相当于C语言中的printf() 函数,用于打印。
- cin 和 cout 都是全局的对象,endl是特殊的C++符号,表示换行;他们都包含在<iostream>头文件中,所以需要包含该头文件;并且,他们都包含在std命名空间中(std是C++标准库的命名空间名),所以需要指定命名空间,这里我们使用的是方式三来展开命名空间中的全部内容。
- C++中的输入和输出可以自动识别类型,不需要像C语言的输入输出那样指定格式。
3.缺省参数
缺省参数就是在声明或者定义函数的时候,为函数指定一个值,在调用该函数的时候,如果没有指定实参,则采用该值作为形参。
缺省参数分为全缺省和半缺省:
- 全缺省:所有的参数都给一个缺省值就是全缺省
#include <iostream>
using namespace std;
//全缺省
void func(int a = 10, int b = 20, int c = 30)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl << endl;
}
int main()
{
func(); // 输出10、20、30
func(1); // 输出1、20、30
func(1, 2); // 输出1、2、30
func(1, 2, 3); // 输出1、2、3
return 0;
}
- 半缺省:只有部分参数有缺省值就是半缺省,半缺省的缺省参数只能从右往左给。
// 半缺省(缺省值只能从右往左给,必须是连续给)
void func(int a, int b = 20, int c = 30)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl << endl;
}
int main()
{
func(1); // 输出1、20、30
func(1, 2); // 输出1、2、30
func(1, 2, 3); // 输出1、2、3
return 0;
}
需要注意的是:缺省值不能在声明和定义同时给。如果声明和定义可以同时给定缺省值的话,防不住写代码时,声明和定义给定的缺省值不一致,此时编译器就不知道该使用哪个缺省值了。
//test.h
void func(int num = 10);
//test.cpp
void func(int num = 20)
{
cout << num;
}
总结:缺省参数不能在声明和定义同时给,只能在声明或者定义给,建议在声明给。
4.函数重载
什么是函数重载
我们在写C语言代码的时候,当我们想实现两个数字相加,这两个数字可能是两个整形、两个浮点型、一个整形一个浮点型,这时候,我们需要写三个函数名不同的函数,在调用的时候,明明是相同的功能却要调用不同的函数,使用起来略有不便,于是C++中引入函数重载解决这个问题。
函数重载就是在同一作用域内,允许出现函数名相同,参数不同的函数。参数不同可以是参数个数不同、参数类型不同、参数顺序不同。
- 参数类型不同构成重载
// 1、参数类型不同
int add(int num1, int num2)
{
cout << "int add(int num1, int num2)" << endl;
return num1 + num2;
}
double add(double num1, double num2)
{
cout << "double add(double num1, double num2)" << endl;
return num1 + num2;
}
- 参数个数不同构成重载
void func()
{
cout << "hello" << endl;
}
void func(int val)
{
cout << val << endl;
}
- 参数顺序不同构成重载
void func(int num, char ch)
{
cout << "func(int num, char ch)" << endl;
}
void func(char ch, int num)
{
cout << "func(char ch, int num)" << endl;
}
这样一来,当我们在参数不同的情况下,想要使用相同功能的函数时,就不需要记忆太多的函数名,方便代码的书写。
为什么C语言不支持函数重载而C++支持呢
对于这个问题,我们以下面程序为例进行讲解:
// Func.h
void func(int num, char ch);
// Func.cpp
void func(int num, char ch)
{
cout << "func(int num, char ch)" << endl;
}
// Test.cpp
int main()
{
func(1, 'a');
return 0;
}
在C/C++程序中,一个程序要运行起来,要经历 预处理、编译、汇编、链接 这四个阶段,每个阶段都会将原来的文件处理成新的文件。
- 在预处理之后,会形成 .i文件;Func.i中只包含函数声明和定义,Test.i中只包含函数声明和调用。
- 在编译阶段,编译器会去检查Func.i和Test.i文件中的语法是否正确,并生成汇编代码,在检查Test.i文件的时候,Test.i中只有被调用函数的声明和调用,这里的声明是告诉编译器,这个函数我有,你先编译通过,到底有没有还需要等到链接的时候才知道。编译之后,会形成以.s结尾的汇编代码,分别是Func.s和Test.s。
- .s文件经过汇编之后,会形成一张符号表,表中有函数名和函数地址的映射关系(是否支持重载就体现在这),当然,还会形成以.o为结尾的二进制的机器码。
- 在链接阶段,会去符号表中找函数的地址进行链接,链接的主要是一些没有确定的函数地址。
我们在Linux环境下分别使用gcc和g++编译器对同一段代码进行编译,结果如下:
- 在C语言中,形成的符号表中 函数名 和 函数地址 的映射是原生函数名和函数地址的映射,下图是在Linux环境下使用gcc编译器编译之后的结果
- 在C++中,形成的符号表中 函数名 和 函数地址 的映射是修饰之后的函数名和函数地址的映射,下图是在Linux环境下使用g++编译器编译之后的结果:
可以看出,C语言的编译器直接使用原生的函数名作为函数和函数地址的映射,这也就决定了符号表中不会出现函数名相同参数不同的函数,同名函数只能出现一个,也就无法进行函数重载。C++的编译器会对函数名进行修饰,Linux下修饰规则如下:_Z+函数长度+函数名+类型首字母。这也就意味着函数名相同,参数不同的函数经过函数名修饰规则修饰之后的函数名是不同的,符号表中可以出现函数名相同参数不同函数,并且符号表中修饰之后的函数名对应不同的函数地址,所以,也就支持函数重载了。
总结一下:C语言没办法支持重载,是因为同名函数没办法区分。而C++是通过函数名修饰规则来区分同名函数,只要参数不同,修饰出来的名字就不一样,就支持了重载。
函数重载为什么和返回值无关呢
这是因为,函数调用的时候,我们并不写出返回值,只写出函数名和参数,此时的函数名和参数也就能通过函数名修饰规则修饰出一个新的函数名,编译器再根据这个新的函数名去符号表中找函数地址,整个过程不涉及函数的返回值,所以,函数重载和函数返回值无关。
void func(int num, char ch)
{
cout << "func(int num, char ch)" << endl;
}
void func(char ch, int num)
{
cout << "func(char ch, int num)" << endl;
}
int main()
{
func(1, 'a'); // 编译器根据函数名和参数修饰出新的函数名
return 0;
}
5.引用
引用的简单认识和使用
在我们使用C语言交换两个变量的值的时候,我们通常这样写代码:
void swap(int *p1, int *p2)
{
int t = *p1;
*p1 = *p2;
*p2 = t;
}
改变变量的时候需要使用一级指针,改变一级指针需要使用二级指针…… 这样用起来比较麻烦且难度大,于是C++引入引用,那什么是引用呢?
C++中的引用就是给变量取别名,编译器不会为引用变量开辟内存空间,它和它所引用的变量共用同一块内存空间。 举个例子:孙悟空又叫齐天大圣,齐天大圣就是孙悟空的别名。
引用的定义格式为:类型& 引用变量名(对象名) = 引用实体;
- 引用类型必须和引用实体是同种类型。
int main()
{
int a = 10;
int& ra = a; // 给变量a取别名叫ra
cout << "a = " << a << endl;
cout << "ra = " << ra << endl;
return 0;
}
有了引用之后,交换两个变量的值的代码就可以这样写了:
void swap(int& p1, int &p2)
{
int t = p1;
p1 = p2;
p2 = t;
}
- 使用引用可以让代码逻辑更加清晰明了,避免了指针操作的复杂性。
引用的特性
1. 引用在定义时必须初始化。引用是取别名,必须要有用来取别名的实体,否则引用是没有意义的。
int main()
{
int a = 10;
int& r; // 没有意义
int& ra = a;
return 0;
}
2. 一个变量可以有多个引用,甚至可以给别名再取别名。这也很好理解,这就相当于一个人可以有多个别名是一样的,齐天大圣是孙悟空,弼马温也是孙悟空。
int main()
{
int a = 10;
int& ra = a; // 给a取别名
int& rra = a; // 给a再取一个别名
int& rrra = rra // 给rra取别名
return 0;
}
3. 引用一旦引用一个实体,再不能引用其他实体。就好像在下面这段代码中,ra引用了a,把变量b赋值给ra的时候,ra并没有变成b的引用,而是将b的值赋值给了ra。
int main()
{
int a = 10;
int b = 0;
int& ra = a;
ra = b;
return 0;
}
常引用
引用常量或者常变量就是常引用,常引用是只读的,常变量和常量也是只读的。
- 可读可写的引用不能引用只读的变量(也就是说不能放大变量的权限)。
int main()
{
// 权限不能放大
const int a = 10; // 变量a是只读的
// b是可读可写的,引用只读变量会造成变量a权限的放大,会报错
// int& b = a;
// const修饰时候,b也是只读的,可以引用变量a
const int& b = a;
return 0;
}
- 只读的常引用可以引用可读可写的变量(也就是说可以缩小变量的权限)。
int main()
{
// 权限可以缩小
int c = 20; // 变量c是可读可写的
const int& d = c; // 只读的d可以引用变量c
// 使用常引用引用常量
const int& e = 10;
return 0;
}
- 相近类型之间的引用。
int 和 double 都是数值型的数据类型,他们之间是可以互相转换的。当我们使用double类型的rj去引用i的时候,是会报错的,当我们加了const之后又不会报错了,这是为什么呢?
这是因为不同类型的变量互相转换时,会产生一个具有常性的临时变量,不加const就会造成权限的放大,所以加上const就没事了。
int main()
{
int i = 1;
// 非法的
// double& rj = i;
// 可行的
const double& rj = i;
return 0;
}
引用的使用场景
引用做参数
对与传值传参,形参是实参的一份临时拷贝,如果实参比较大的话,拷贝起来会比较耗时。所以,在C语言中,我们通常使用传址传参来提高效率,但是传址传参也需要拷贝,并且使用起来比较麻烦,毕竟,指针嘛…… 于是,引用的价值便出来了。
引用做参数是对实参的引用,此时形参就是实参,不需要进行拷贝,可以大大提高传参的效率。
void Swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
引用做返回值
我们先看这段有意思的代码:
#include <iostream>
using namespace std;
int& add(int num1, int num2)
{
int result = num1 + num2;
return result;
}
int main()
{
int& ret = add(1, 2);
cout << ret << endl;
add(3, 4);
cout << ret << endl;
return 0;
}
运行结果如下:
当我们第二次调用add函数的时候,并没有使用ret接收返回值,但是ret的值却改变了,这是为什么呢?
这是因为:在add函数中,我们返回的是result这个局部变量的引用,也就是这个局部变量result,ret引用的是result变量所在的空间,当add函数执行完毕之后,result变量销毁,但是ret引用的还是这块空间,再次执行add函数的时候,好巧不巧,result又被分配到了原来的那块空间,也就是ret引用的那块空间,此时add函数会修改这块空间的值,也就修改了ret的值。
这里我想说的就是,引用做返回值,返回的是返回值的引用。
对比传值返回,返回的是返回值的拷贝,所以,引用做返回值,可以避免拷贝,提高效率,这一点和引用做参数是一样的。
需要注意的是:如果函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。
值得一提的是,引用做返回值还可以修改返回对象:
引用和指针的关系
引用在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间,但是指针是有独立空间的。
引用在底层实现上是有空间的,因为引用是按照指针的方式来实现的。
引用和指针的不同点
- 引用在概念上定义一个变量的别名,指针存储一个变量地址。
- 引用在定义时必须初始化,指针没有要求。
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体。
- 没有NULL引用,但有NULL指针。
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数。(32位平台下占4个字节)
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小。
- 有多级指针,但是没有多级引用。
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理。
- 引用比指针使用起来相对更安全。
6.内联函数
认识内联函数
在学习C语言的时候,相信你应该学习过宏这个东西,宏这玩意不好用,缺点有如下几个:
- 容易出错,语法细节多。
- 不能调试。
- 没有类型安全的检查。
在《effective C++》这本书中,有一个条款就是,尽量使用enum、const、inline代替宏。
- enum、const用来替换宏常量。
- inline用来替换宏函数。
相信大家对enum和const并不陌生,这个inline是什么呢?在C++中,inline是用来修饰函数的,用inline修饰的函数叫做内联函数。
// 定义一个内联函数
inline int add(int num1, int num2)
{
return num1+num2;
}
内联函数的特性分析
内联函数的调用和普通函数不一样,普通函数在调用的时候,编译器会建立函数栈帧,将函数的参数、变量等压栈,并且是通过函数的入口地址去调用函数的。内联函数在调用的时候,早在编译阶段,内联函数就会在调用的地方展开,不会有调用函数建立栈帧的开销,提高了程序运行的效率。
内联函数在调用的地方展开,这不就和宏是一样的吗?是的,内联函数的出现就是为了替代宏函数,内联函数具有语法简单、效率高、可以调试等优点,很好的解决了宏函数的缺点。
但是,函数展开就一定好吗?不见得,如果一个函数有100行,被调用了1000次,展开之后一共有100 * 1000 == 10 0000行,如果不展开就是100 + 1000 == 1100行,所以,内联函数会导致目标文件变大,可见,内联函数是一种用空间换时间的方法。
其实,内联函数inline对于编译器来说只是一个建议,既然是建议,那就可以听也可以不听,不同编译器对于inline的实现机制不同,当函数上了一定规模之后,即使添加inline关键字也不会将其设置为内联函数,一般来说,将函数规模较小,不是递归且频繁调用的函数设置为inline较为合理。
需要注意的是:内联函数的声明和定义不能分离,否则会出现链接错误。
- 这是因为,内联函数是在调用的地方展开,内联函数的函数地址也就不会进入符号表,当链接的时候,去符号表中找内联函数的地址肯定找不到,也就发生链接错误了。
7.auto关键字
在C++中往往有一些类型比较长,例如:std::vector<int>::iterator,这个时候,我们就可以使用auto关键字来自动推到类型,这也是auto关键字最常用的场景。
#include <iostream>
using namespace std;
int add(int num1, int num2)
{
return num1+num2;
}
int main()
{
auto ret = func(1,2);
return 0;
}
注意:使用auto定义变量的时候,必须要对其进行初始化。这是因为在编译器编译阶段需要根据初始化表达式来推到auto的类型, 因此,auto并不是类型的声明,更像是一个类型的占位符,编译器在编译之后会将auto替换成变量实际的类型。
auto使用细则
auto可以与指针和引用结合使用:
- 用auto声明指针类型时,用auto和auto*是一样的。
int main()
{
int num = 1;
auto p1 = #
auto* p2 = #
cout << typeid(p1).name() << endl;
cout << typeid(p2).name() << endl;
return 0;
}
- 用auto声明引用必须加&。
int main()
{
int num = 1;
auto& r = num;
cout << typeid(r).name() << endl;
return 0;
}
在同一行定义多个变量:
在同一行定义多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个变量进行推到,然后用推导出来的类型定义其他变量。
void TestAuto()
{
auto a = 1, b = 2;
auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
需要注意的是:auto不能用来做函数的返回值和参数,也不能用来声明数组。
8.基于范围的for循环
我们在使用C语言进行遍历数组操作的时候,需要自己指明范围,但是对于有范围的集合来说,这是多余的,有时候还容易犯错误,所以C++提供不需要指定范围的遍历方式 —— 范围for。
范围for的书写格式为:for(用于迭代的变量 : 被迭代的范围) { }。
- 范围for和普通循环类似,可以使用 continue 和 break 跳出循环。
int main()
{
int nums[10] = {0,1,2,3,4,5,6,7,8,9};
for(auto e : nums)
{
cout << e << ' ';
}
return 0
}
9.指针空值nullptr
在使用C语言表示空指针的时候,我们往往使用NULL表示,NULL实际上是一个宏,在C语言的头文件<stddef.h>中我们可以看到如下定义:
所以,当我们写出如下程序的时候就会出现歧义:func(NULL)的本意是调用第二个函数,但是由于宏替换的原因调用了第一个函数,要想调用第二个函数的话,需要进行强转。
void func(int num)
{
cout << "func(int num)" << endl;
}
void func(int* p)
{
cout << "func(int* p)" << endl;
}
int main()
{
func(0); // 调用第一个
func(NULL); // 调用第一个
func((int*)NULL); // 调用第二个
return 0;
}
为了避免这种歧义,C++引入nullptr表示空指针。