一、前言
C++是一种计算机高级程序设计语言,它在C语言的基础上进行了进一步的扩充和完善,并增加了许多有用的库,是一种面向对象的程序设计语言。
所以,C++是兼容C语言语法的。
我打算把所有C++入门需要学习的知识整合成一个全集,方便各位,也能方便自己复习。
本文主要讲解C++相对C语言的查漏补缺和优化部分,也为后续学习类和对象打基础。
二、C++关键字
C语言有32个关键字,而C++有63个关键字,不过这些关键字无法一次学完,我们这里先混个脸熟
asm | do | if | return | try | continue |
auto | double | inline | short | typedef | for |
bool | dynamic_cast | int | signed | typeid | public |
break | else | long | sizeof | typename | throw |
case | enum | mutable | static | union | wchar_t |
catch | explicit | namespace | static_cast | unsigned | default |
char | export | new | struct | using | friend |
class | extern | operator | switch | virtual | register |
const | false | private | template | void | true |
const_cast | float | protected | this | volatile | while |
delete | goto | reinterpret_cast |
三、命名空间
在C语言中,命名冲突问题时常存在,例如我们无法定义一个名为rand的变量,因为在stdlib.h中已经有函数取名为rand了
如果我们将所有的变量名、函数名和类名都存放在全局作用域中,就可能导致命名冲突,所以C++中出现了命名空间这一概念。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或名字污染。
3.1 命名空间的定义
要定义一个命名空间,需要使用到关键字namespace,然后给命名空间取个名,再用大括号括起来,大括号中即为命名空间的成员
namespace test
{
int rand = 10;
}
除了变量,命名空间中还可以定义函数或者结构体类型
另外,命名空间还可以嵌套,如:
namespace test1
{
namespace test2
{
int rand = 10;
}
}
如果命名空间也同名了怎么办呢?同一个工程的不同文件中可能出现多个同名的命名空间,此时编译器会将它们合并为同一个命名空间。
3.2 命名空间的使用
当我们学会在命名空间中定义变量、函数和类型后,该如何使用它们呢?
(1)使用作用域限定符
所谓作用域限定符就是两个冒号 "::"
像这样,就可以访问到两个不同命名空间的变量a了
(2)使用using引入命名空间成员
变量b是命名空间test1的成员,我们可以通过using引入它,所以下面main函数中我们访问变量b就不需要使用作用域限定符了
(3)使用using namespace
像这样,我们使用using namespace引入命名空间test1,所以没有使用作用域限定符的a就访问到了test1中的a,而下面使用了作用域限定符就会访问test2中的a
在后面学习C++的输入和输出中,我们会学到cout和cin,二者的定义是放在名为std的一个C++标准库的命名空间中的,C++将标准库的定义实现都放在这个命名空间中。
如果不使用using namespace,每次使用的时候就要用到作用域限定符,也就是变成std::cout和std::cin,十分的麻烦。
所以我们在使用它们的时候一般会先输入“using namespace std;”,避免每次要输入和输出的时候都使用作用域限定符。
四、C++的输入和输出
听说每一个程序员的第一个程序都是hello world,我们来使用C++实现一下
#include <iostream>
using namespace std;
int main()
{
cout << "Hello world" << endl;
return 0;
}
从这一段简短的代码中,我们可以总结如下信息:
- cout和cin是全局的流对象,endl是特殊的C++符号,表示换行,它们都包含在<iostream>头文件中
- <<是流插入运算符,>>是流提取运算符
实际上,cout和cin分别是ostream和istream类型的对象,而<<和>>在C语言中原本是位运算符,在C++却变了,这里也涉及到运算符重载的知识,在后续我们还会对其进行深入的学习
注意:早期的标准库将所有功能都在全局域中实现,并声明在以.h为后缀的头文件中,使用时也只需要包含对应头文件即可。而后来则将其实现在std命名空间下,为了和头文件区分,规定C++的头文件不带.h。一些旧编译器可能还支持<iostream.h>的格式,不过还是推荐使用<iostream>+std的方式
使用cout和cin的好处在于更方便,不需要像printf和scanf一样手动控制格式
缺点在于打印一串数据的情况会比较繁杂
所以我们根据实际情况选择更优的方式
在日常练习中,我们当然可以使用using namespace std,怎么方便怎么来。
但是这样做,整个标准库就全部暴露出来了,此时如果我们定义了和库中重名的类型/对象/函数,就会出现冲突问题。所以在项目开发中不推荐使用全局展开,我们可以指定命名空间访问,例如using std::cout和using std::cin来展开常用的命名空间成员
五、缺省参数
缺省参数就是我们声明或定义函数时可以为函数的参数指定一个值,在调用函数的时候没有指定的实参,就使用这个预先设定的默认值
缺省参数又分为全缺省参数和半缺省参数
关于缺省参数,我们需要注意几点:
(1)半缺省参数必须从右到左依次给出,不能间隔着给
(2)缺省参数不能同时出现在函数的定义和声明中
像这样,函数的定义和声明中同时出现缺省参数,而两个位置设置的值不同,编译器就无法确定该使用哪个缺省值
(3)缺省值必须是常量或者是全局变量
六、函数重载
C++允许在同一作用域中声明几个功能类似的同名函数,前提是这些同名函数的形参个数/类型/类型顺序不同。
例如我们以前在C语言中想实现Add函数,但是int和double类型的数据不能用同一个函数处理,每处理一种类型的数据就要写一个函数,函数间还不能同名
但是在C++中针对这个问题进行了优化,编译器会有一套自己的函数名修饰规则来修饰不同形参个数/类型/类型顺序的同名函数
#include <iostream>
using namespace std;
//参数类型不同
int Add(int x, int y)
{
return x + y;
}
double Add(double x, double y)
{
return x + y;
}
//参数个数不同
void f(int a)
{
cout << "void f(int a)" << endl;
}
void f(int a, int b)
{
cout << "void f(int a,int b)" << endl;
}
//参数类型的顺序不同
void f(int a, char b)
{
cout << "void f(int a, char b)" << endl;
}
void f(char a, int b)
{
cout << "void f(char a, int b)" << endl;
}
int main()
{
Add(1, 2);
Add(1.1, 2.2);
f(1);
f(1, 2);
f(10, 'a');
f('a', 10);
return 0;
}
C++支持函数重载的原理就是——名字修饰(name Mangling)
名字修饰是一种在编译过程中,将函数、变量的名称重新改编的机制。简单来说就是编译器为了区分多个同名函数,规定了一个新的规则来对原本的名字进行修饰
为什么C语言不支持函数重载,是因为它的名字修饰规则过于简单,只是在函数名前面添加了下划线
拓展:在C++的函数前加上extern "C" 就可以让函数按照C语言的风格编译
这里可以看到,Add函数按照C语言的风格编译后名字变为了_Add
而在C++中,修饰规则得到了完善,所以可以支持函数重载
不过不同的编译器有自己的函数名修饰规则,上面的就是在Windows下vs的修饰规则,有点过于复杂了,有兴趣的同学可以自行深入学习。
接下来我们展示g++的修饰规则,它会比前者更加的简单易懂
#include <iostream>
using namespace std;
int Add(int x, int y)
{
return x + y;
}
double Add(double x,double y)
{
return x + y;
}
int main()
{
Add(1, 2);
Add(1.1, 2.2);
return 0;
}
上面的是源文件代码,我们在终端中输入"g++ -S test.cpp -o test.s"来查看其汇编代码
可以看到,这两个就是上面int类型的Add函数和double类型的Add函数重载后的名字了。
其中_Z后跟着的数字就是原函数名的长度,后面的 "ii" 和 "dd" 就是参数的类型。
通过这些,我们就能理解为什么C语言不支持函数重载,而C++通过函数名的修饰规则可以区分同名函数了,只要参数个数/类型/类型顺序不同,修饰后的名字就不同,也就可以区分了。
需要注意的是,如果两个函数的函数名和参数个数/类型/类型顺序都相同,也是无法区分的。
七、引用
引用就是给已经存在的变量取一个别名,编译器不会为引用变量开辟内存空间,而是和它引用的对象共用同一块内存空间,引用的符号和取地址符号&一样。
需要注意的是,引用变量必须和被引用的对象是同一类型的
引用变量也可以作为被引用的对象
引用还可以代替指针的传址调用,例如以前我们要实现Swap函数,需要指针传地址才能成功交换,现在可以换成引用,此时形参是实参的别名。
当然,指针也可以进行引用
7.1 引用的特性
(1)引用在定义时必须初始化
(2)一个变量可以有多个引用,也就是取多个别名
(3)一旦引用变量已经引用过了某个对象,就再不能引用其他对象
可以看出,引用和指针还是有区别的,像在链表这些地方我们还是选择使用指针,而在一些输出型参数,也就是形参的改变要影响实参的地方我们可以选择使用引用
7.2 常引用
(1)如果被引用的对象被const修饰,而引用变量没有被const修饰会报错
(2)引用变量如果没有被const修饰,不能将常量作为引用对象
(3)引用变量没有被const修饰且和被引用对象不是同一类型时会报错
7.3 引用的使用场景
(1)引用作为函数参数,就是上面提到过的Swap
(2)引用作为函数返回值
int& Add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int& ret = Add(1, 2);
Add(3, 4);
cout << "Add(1, 2) is :" << ret << endl;
cout << "Add(1, 2) is :" << ret << endl;
return 0;
}
上面的代码会输出什么结果?
实际上,结果是未定义的。
第一次调用Add函数的时候,函数栈帧创建完毕,局部变量c(此时值为3)保存在Add的栈帧中。函数运行结束后,栈帧销毁,内存空间被系统回收,此时变量c已经没有意义了,所以ret引用了一块已经被释放的空间。
第二次调用Add函数和第一次一样,只不过局部变量c的值变为了7。
需要注意的是,虽然空间被回收,里面的东西却都还在。就像去住酒店,退房后里面的东西在没打扫前都是一直保持原样的,而不是说退房后里面的东西就都没了。
所以,当我们第一次输出ret的值是7,第二次就变为了随机值
因此,如果出了函数作用域后返回对象没有销毁(static,malloc等),则可以使用引用返回,否则必须使用传值返回
另外的,引用返回还可以修改返回的对象
#include <iostream>
using namespace std;
int& Func(int* a, int i)
{
return a[i];
}
int main()
{
int a[10] = { 1,2,3,4,5,6,7,8,9,10 };
for(int i = 0 ;i<10;i++)
{
Func(a, i) = i * 10;
cout << a[i] << " ";
}
return 0;
}
7.4 传值和引用返回的效率比较
以值作为函数参数或返回值类型的时候,函数并不会直接传递实参或者将变量本身直接返回,而是会创建一个临时变量作为“中间商”,因此会影响效率,我们以传值返回为例测试一下
#include <iostream>
#include <time.h>
using namespace std;
struct A
{
int a[10000];
};
A a;
// 值返回
A TestFunc1()
{
return a;
}
// 引用返回
A& TestFunc2()
{
return a;
}
int main()
{
// 以值作为函数的返回值类型
size_t begin1 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc1();
size_t end1 = clock();
// 以引用作为函数的返回值类型
size_t begin2 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc2();
size_t end2 = clock();
// 计算两个函数运算完成之后的时间
cout << "TestFunc1 time:" << end1 - begin1 << endl;
cout << "TestFunc2 time:" << end2 - begin2 << endl;
return 0;
}
可以看到,传值返回比引用返回效率低了许多
7.5 引用和指针的区别
从底层来看,引用还是按照指针的方式来实现的,我们可以观察一下汇编代码
二者的不同点在于:
- 从概念上来说,引用是定义了一个变量的别名,指针是存储了变量的地址
- 引用在定义时必须初始化,指针可以不用
- 引用在初始化时引用了一个对象后,就不能再引用其他对象,而指针可以随时改变指向
- 没有空引用,但是有空指针
- 从sizeof来说:引用的sizeof结果是引用类型的大小,但指针始终是地址所占字节个数
- 引用的++是被引用的对象+1,而指针++是指针向后偏移一个类型的大小
- 多级指针之间的含义不同,但多级引用指向的是同一块空间
- 访问对象的方式不同,指针需要解引用,而引用是由编译器自己处理
- 引用比指针使用起来更安全
八、内联函数
在C++中,为了解决一些频繁调用的小函数大量消耗栈内存的问题,引入了inline修饰符。
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开函数,不用调用函数建立栈帧。
其作用很像宏函数,不过相比宏函数的缺点,它可以调试,有类型的检查,不容易出错。
inline int Add(int x, int y)
{
return x + y;
}
inline是一种比宏更简单更好的方式,并且不降低效率。
如果不用inline修饰,在汇编代码中我们会看到用call指令去调用函数的操作
但是在Add函数前增加inline将其修改为内联函数,在编译期间编译器会用函数体替换函数的调用。
我们可以在release模式下查看汇编代码中是否存在call Add的指令
如果想在debug模式下查看,需要进行一些设置,因为在debug模式中默认情况下编译器不会对代码进行优化
在项目中点击属性
在属性中修改这两项
然后我们在debug模式下查看内联函数的汇编代码
可以看到,已经没有call指令了。
inline是一种以空间换时间的做法,这里的空间指编译出来的可执行文件的大小,内联函数会直接在程序中展开,越长的函数调用越多次就会使文件大小暴增。
inline只是向编译器发出的一个请求,编译器可以选择忽略该请求。
假如有一个1000行的函数,我们要在程序中调用1000次,如果不用inline修饰的话就是1000+1000行代码,如果用inline修饰的话就是1000*1000行代码,换做是你你觉得哪个更好?
可以看到,太长的函数尽管用了inline修饰,编译器也会忽略掉,选择call调用函数
一般建议将函数规模较小、非递归且调用次数较多的函数使用inline修饰
需要注意的是,inline不建议声明和定义分离,会导致链接错误,对于内联函数最好直接在头文件中定义
此时在test.cpp中,编译时展开了Func.h的内容,而里面只有Func函数的声明,只能等链接的时候在符号表中寻找对应函数,也就是通过call指令调用函数,而内联函数是不会生成call指令的
所以我们直接在头文件中定义内联函数即可
九、auto关键字(C++11)
随着我们不断深入学习C++,程序越来越复杂,使用的类型也会越来越复杂,不仅难于拼写,还容易出错。
除了typedef,我们还有另一个选择就是auto,它可以帮助我们自动推导类型。
需要注意的是,使用auto定义变量时必须对变量进行初始化,因为在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。
因此,auto并非是一种类型的声明,而是一个占位符,编译器在编译时会将auto替换为变量实际的类型
9.1 使用auto的注意事项
(1)如果auto后加上*就限定了赋值的对象必须是指针
(2)我们使用auto时,也可以在同一行定义多个变量,前提是这些变量必须是相同的类型
(3)用auto声明引用类型时还是要加上&的
#include <iostream>
using namespace std;
int main()
{
int a = 10;
auto& b = a;
return 0;
}
9.2 不能使用auto的场景
(1)auto不能在函数的参数中使用
(2)auto不能用于声明数组
十、范围for(C++11)
现代C++倾向于让各种繁杂的操作变得简洁,因此诞生了许多语法糖,范围for算是其中的典型。
在C++98/03中,不同的容器和数组遍历的方式有很多,不够统一,也不够简洁。
而C++11出现了基于范围的for循环,可以更简洁的去遍历容器和数组,也更方便我们使用了。
以前我们遍历数组的方式如下:
int main()
{
int array[] = { 1,2,3,4,5 };
for (int i = 0; i < sizeof(array) / sizeof(array[0]); i++)
{
cout << array[i] << " ";
}
cout << endl;
return 0;
}
对于一个有范围的集合而言,由程序员来声明循环的范围未免太多余,还容易出错。接下来我们来使用范围for遍历数组:
for循环的括号中由冒号":"分为两部分,左边是范围内用于迭代的变量,右边表示被迭代的范围
这里也用到了前面的auto关键字,如果我们想对范围内的元素进行修改,还可以用到引用&
和普通循环一样,范围for中也可以使用continue和break。
需要注意的是,范围for迭代的范围必须是确定的。对于数组而言,就是数组第一个元素到最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
void Func(int array[])
{
for (auto x : array)
cout << x << endl;
}
像这种情况就不能使用范围for,因为传参到函数中时传递的不是一整个数组而是数组指针,此时for的范围不确定。
十一、指针空值nullptr(C++11)
在过去,我们给一个没有指向的指针进行初始化的时候会使用NULL,而NULL实际上是一个宏。
我们在C语言中使用NULL没有问题,但是在C++中就会出现问题,为什么呢?
在传统的C头文件stddef.h中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到,在C++中NULL被定义为0,这样会造成什么麻烦呢?
可以看到,就算传递的参数为NULL,程序还是会调用int类型的Func,而不是int*的Func,这违背了我们的目的。
因此出现了指针空值nullptr来填补这个bug,使用nullptr时不需要包含头文件,因为它是C++11作为新关键字引入的。为了提高代码的健壮性,我们后续表示指针空值时最好都使用nullptr。
有人会想,为什么不直接把这个bug修改了呢?因为语言有一个向前兼容的原则,也就是已经出现的东西即使有问题也不能修改,如果贸然去修改了可能会导致以前的代码无法运行,可能会造成巨大的损失。
到这里,我们就算对C++有了一个简单的了解了,下一篇的C++入门我们就会开始学习类和对象。
如果觉得本文对你有帮助就点个赞吧
完.