写在前面
书接上文:初识C++(上)。本篇笔记作为C++的开篇笔记,主要是讲解C++关键字(C++98)连带一点点(C++11)的知识。掌握的C++新语法新特性,当然C++是兼容C的,我们学习C的那套在C++中也是受用。
文章目录
- 写在前面
- 六、引用
- 6.1、引用权限的细节
- 6.2、引用的使用场景
- 6.3、引用和指针的区别
- 七、内联函数
- 八、auto关键字(C++11)
- 8.1、auto的使用细节与注意事项
- 1. 一般`auto`与指针和引用结合起来使用
- 2. 在同一行定义多个变量
- 3. `auto`不能作为函数的参数
- 4. `auto`不能直接用来声明数组
- 5. 为了避免与C++98中的`auto`发生混淆,`C++11`只保留了`auto`作为类型指示符的用法
- 6. auto在实际中最常见的优势用法就是C++11提供的新式for循环,还有跟以后会讲到的lambda表达式等进行配合使用。
- 九、 基于范围的for循环(C++11)
- 十、指针空值nullptr(C++11)
六、引用
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
引用的定义:类型& 引用变量名(对象名) = 引用实体;
给一个普通变量设置一个引用:
int main()
{int a = 10;int& ra = a;//<====定义引用类型printf("%p\n", &a);printf("%p\n", &ra);return 0;
}
程序运行结果:
- 在上面程序中,我们就成功把变量
ra
设置为了a
变量的别名 - 程序运行结果中,也可以看出两个变量的地址都是同一空间
注意:引用类型必须和引用实体是同种类型的
引用的使用细节:
- 引用一旦引用一个实体,再不能引用其他实体
- 引用在定义时必须初始化
- 一个变量可以有多个引用
例子1:不进行初始化赋值。
int main()
{int a = 10;int& b;int& ra = a;int& num = a;printf("%p\n", &num);printf("%p\n", &ra);return 0;
}
程序运行结果:
例子2:更改引用变量,查看引用变量是否会改变为引用其他实体。
int main()
{int a = 10;int b = 30;int& ra = a;ra = b;//尝试更改引用变量raprintf("%p\n", &ra);printf("%p\n", &a);printf("%p\n", &b);printf("\n%d\n", a);printf("%d\n", ra);return 0;
}
程序运行结果
- 在上运行结果图中,可以看出
ra = b
执行的是赋值,把b
变量的值,赋值给ra
,ra
是a
的别名,实际上就是更改a
变量的值。 说明:引用一旦引用一个实体,再不能引用其他实体 - 由这个例子说明了,别名只能在初始化的时候进行赋值,过了初始化后,其他情况下
=
都都是进行赋值操作,不再进行初始化操作。
6.1、引用权限的细节
引用变量也是变量,也支持变量中的权限设置,如:把变量设置为常变量的const
、设为静态变量的static
。在引用中,有着严格的权限管理,引用的权限可以缩小和平移,但绝对不能放大。
例子1: 引用的缩小权限
int main() {double d = 12.34;const double& rd = d;rd = 2.5;//errd = 2.5;return 0;
}
程序运行结果:
- 在上图中我们把
d
的设置了引用rd
,但是rd
是被const
收索了权限,把rd
设置为了一个常变量,这时我们使用rd
进行更改变量值是,就会报错,但是rd
收缩权限并不会影响变量d
。
我们注释rd
变量后打印结果,如下如:
例子2:权限的平移
int main() {const int a = 10;const int& ra = a;cout << a << " and " << ra << endl;return 0;
}
程序运行结果:
例子3:尝试权限放大
int main() {const int a = 10;int& ra = a;ra = 20;cout << a << " and " << ra << endl;return 0;
}
程序运行结果:
通过上面的三个例子,就很好理解引用权限细节。
6.2、引用的使用场景
1.做函数的形参 (一般用于输出型参数,减少拷贝提高效率)
在C语言时期,我们要写一个交换函数,函数的形参就必须是指针,在函数中的编码也是相当的麻烦:如下程序
void Swap(int* a, int* b) { int num = *a;*a = *b;*b = num;
}
在程序中,我们只要涉及到形参变量的交换或赋值,就需要解引用,这样我们编码的复杂度提高了,这时候我们使用引用,为传递过来的实参取别名,这样我们不需要解引用就可以直接操作到实参。如下程序:
void swap(int& ra, int& rb) {int num = ra;ra = rb;rb = num;
}int main() {int a = 10, b = 20;swap(a, b);cout << "a = " << a << " b = " << b << endl;return 0;
}
程序运行结果:
这样我们编写程序时候,就无需每次使用形参都进行解引用操作,大大增加了编码的效率
2.做函数的返回值(提高效率)
在不使用引用作为返回值的时候,我们的返回值一般都是使用传值返回。如下程序
int add(int a, int b) {int sum = a + b;return sum;
}int main() {int a = 10, b = 20;int sum = add(a, b);cout << "a + b = " << sum << endl;return 0;
}
add
函数的返回值会传递给寄存器。之后,再把寄存器的值赋值给变量sum
。(如下图)
在需要有返回的函数中一定会进行生成寄存器,在返回值比较小的时候才使用寄存器,如果但返回值大的时候,就使用临时变量。在传值返回时,免不了把需要返回的内容拷贝给寄存器/临时变量,这样效率会有损耗,如果说返回类型是个巨无霸,那么所消耗的时间也是非常恐怖。所以我们可以使用引用返回来提高效率。
int& add(int a, int b) {//引用返回static int sum = a + b;return sum;
}int main() {int a = 10, b = 20;int num = add(a, b);cout << "a + b = " << num << endl;return 0;
}
在上面函数中,我们设置了引用返回,那么改函数返回的就是sum
的别名(引用)。此时就没有再进行中间的赋值。这样就节省下了时间
注意: 使用引用返回返回的变量一定不能是局部变量,如果返回的是局部变量,那么该变量会随着函数的生命周期结束而结束。那么得到的引用值是随机值(类似于野指针)。(如下图)
所以如果函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),则可以使用
引用返回,如果已经还给系统了,则必须使用传值返回。
3.做函数的返回值(修改返回值+获取返回值)
在C++中把struct
自定义类型升级为类,在类中可以声明变量,也可以定义函数。在C的结构体中如果我们定义了一个数组,那数组的使用会非常繁琐,如下程序
struct test {int arr[500];};int main() {struct test t1;t1.arr[0] = 0;t1.arr[0]++;return 0;
}
每次使用结构体中的数组,都需要结构体变量名.结构体变量
的编写,才可以调用到对应的数组,但是在C++ 中,使用函数封装后,再使用传引用返回,如下程序:
struct stu {
public:int _name[1000];int& at(int pos) {if (pos >= 0 && pos < 1000) {return _name[pos];}}
};int main() {stu test1;int& arr1 = test1.at(10);arr1 = 0; return 0;
}
使用传引用返回后,我们也用引用来接收,这样arr1
就是_name[pos]
的别名,这时我们就可以用arr1
来轻松修改返回值,从而改变对应的 “结构体变量”。
int& arr1 = test1.at(10);
:此时test1.at(10)
返回的是_name[10]
的别名,我们用引用变量arr1
来接收,这么arr1
也是_name[10]
,所以可以直接使用arr1
来修改。
更C++的写法是:C++类里面可以进行运算符重载(不才后面笔记会补充这个内容),在重载后就不需要再和C语言和引用接收一样需要复杂的编写了。如下程序:
struct stu {
public:int _name[1000];int& operator[] (int pos) { // 重载下标引用操作符[]if (pos >= 0 && pos < 1000) {return _name[pos];}}};int main() {stu test1;test1[2] = 0;test1[2]++;}
在重载下标引用操作符[]
后我们就可以直接使用使用类变量+操作符
就可以直接访问到,对应的 “结构体变量”,因为在重载下标引用操作符函数中,我们使用的是传引用返回,返回的数组对应下标的别名,即 []
实际是_name[pos]
。
6.3、引用和指针的区别
在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。但是在实际底层实现上实际是有空间的,因为引用类似指针方式来实现的。如下图汇编代码
在上图的汇编代码中。我们清晰地看到。引用和指针的汇编代码都是一样的。说明引用起始也是由指针实现的,由编译器封装实现了引用的结构。但是不能说引用就是指针。
int main() {double a = 0;double& ra = a;double* pa = &a;cout << sizeof(ra) << " " << sizeof(pa) << endl;return 0;
}
在32位环境下运行的结果:
- 我们知道在32位环境下,指针占用空间大小是四个字节。
double
类型占用八个字节。 - 在程序运行结果中,我们也可以看到。我们计算引用的大小其实是计算的是变量类型占用空间的大小。
- 计算指针变量的大小,计算的是指针类型的占用空间的大小
引用和指针的不同点:
- 在使用中的不同点:
- 引用概念上定义一个变量的别名,指针存储一个变量地址。
- 引用在定义时必须初始化,指针没有要求
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
- 没有NULL引用,但有NULL指针
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节,64位平台下占用8个字节)
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
- 有多级指针,但是没有多级引用
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 引用比指针使用起来相对更安全
- 在内存上的不同点:
- 在语法层面上,应用不占用内存空间,指针占用4/8个字节空间
七、内联函数
以
inline
修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
在C语言中,如果我们频繁的调用函数时,在内存中就会不断的栈帧与销毁,这样会导致效率下滑。如下代码:
int add(int a, int b) {return a + b;
}int main() {int a = 10, b = 20;for (int i = 0; i < 100000; i++) {arr(a, b);cout << i << endl;}return 0;
}
- 在循环中,我们循环的栈帧与销毁十万次只为了将两个变量相加,效率极其低下。
- 我们只能更改为使用宏函数,这样避免十万次栈帧与销毁的开销。
#define ADD(x,y) ((x) + (y))//int add(int a, int b) {
// return a + b;
//}int main() {int a = 10, b = 20;for (int i = 0; i < 100000; i++) {ADD(a, b);cout << i << endl;}return 0;
}
- 使用宏函数虽然可以避免了十万次的栈帧与销毁开销,但是使用宏函数还有明显的缺点的
关于宏函数:
- 优点: 不需要建立栈帧,提高效率
- 缺点:复杂,容易出错,可读性差,不能调试
所以在C++中就推出了内联函数,只需要在普通函数前,增加一个inline
关键字即可。
inline int add(int a, int b) {return a + b;
}int main() {int a = 10, b = 20;for (int i = 0; i < 100000; i++) {cout << add(a, b) << endl;}return 0;
}
- 这样就把
add
函数更改为内联函数
我们查看汇编代码中可以清晰看出:编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
非内联函数:
内联函数:
但是inline
对于编译器而已也只是建议,最终决定该函数是否在调用内联函数的地方展开的决定权在编译器,就算我们把每个函数都设置为内联函数,也不确保把每个函数都时内联函数。
因为在函数内容过多的情况下,每个调用内联函数的地方都展开的话,会造成代码膨胀。
一个函数func
,里面由100行代码,在整个工程中,由10000处调用了该函数。
此时若func
函数它不是一个内联函数时,整个工程在编译后的func
占用代码量只是:10000+50行代码。
若此时func
函数时一个内联函数,而且每次都会在调用的地方展开,那么整个工程在编译后func
占用的代码量就是:10000*50行代码。
最终结果就导致可执行程序变大,使得最终程序安装包也变大。
我们实际测试一下:
inline int add(int a, int b) {cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;cout << "Bucai_不才" << endl;return a + b;
}int main() {int a = 10, b = 20;for (int i = 0; i < 100000; i++) {cout << add(a, b) << endl;}return 0;
}
程序运行结果:
总结:内联函数只用于短小而且频繁调用的函数,对于行数较长的函数与递归函数不能使用内联,当然编译器也会自己决定。
ps:在vs默认Debug环境下,inline
不会起作用,因为这样就不方便调试了。
inline
不建议声明和定义分离,分离会导致链接错误。因为inline编译器默认展开的,不会进入符号表,就没有函数地址了,链接就会找不到。
//test.h
inline int add(int a, int b);//add.c
int add(int a, int b) {return a + b;
}//main.c
int main() {int a = 10, b = 20;add(a, b);return 0;
}
运行结果:
- 上图报的是链接错误。我们画图详细分析。
在main.c
中:
-
在编译阶段,发现
add
函数只有声明,那么就创建一个符号表记录add
函数,待链接时候再把定义的地址链接在一起。 -
在
.h
文件中,add
函数声明的是inline
内联函数,在编译阶段编译器不会把add
函数生成符号表,因为编译器认为该函数在预编译时候就已经展开了。 -
到最后链接阶段却发现,
main
函数的符号表中的add
函数,没有对应的定义函数的地址,所以报错。
若我们修改成一下代码,内联函数就会成功运行:
//test.h文件
inline int add(int a, int b);
int testadd(int a, int b);//add.c
int add(int a, int b) {return a + b;
}int testadd(int a, int b) {int sum = add(a, b);return sum;
}
//main.c
int main() {int a = 10, b = 20;cout << testadd(a, b) << endl;return 0;
}
- 我们在
add.c
文件内调用add
函数,在预编译期间,把头文件内容拷贝到add.c
文件中,可以得到下内存图 - 此时在
add.c
文件内,内联函数add
有声明和定义,所以在testadd
函数中可以直接展开。
总结:不才认为内联函数是不能声明和分开定义的,上面的例子中我们可以感受出,内联函数声明和定义分开并没有意义,在同一声明和定义在文件下可以起到内联作用,但是没必要分开,在不同文件下,无法成功调用函数的定义。
八、auto关键字(C++11)
随着程序越来越复杂,程序中用到的类型也越来越复杂,经常体现在:
- 类型难于拼写
- 含义不明确导致容易出错
#include <string>
#include <map>
int main()
{std::map<std::string, std::string> m{ { "apple", "苹果" }, { "orange","橙子" },{"pear","梨"} };std::map<std::string, std::string>::iterator it = m.begin();while (it != m.end()){//....}return 0;
}
- 在上代码中
std::map<std::string, std::string>::iterator
是一个类型,但是该类型太长了,特别容易写错。
我们可以使用typedef
重命名类型,如下:
#include <string>
#include <map>
typedef std::map<std::string, std::string>::iterator Map;
int main()
{std::map<std::string, std::string> m{ { "apple", "苹果" }, { "orange","橙子" },{"pear","梨"} };Map it = m.begin();while (it != m.end()){//....}return 0;
}
但是使用typedef
也会有有一个问题,就是在以const
修饰指针缩小权限的时候,会出现我们意料之外的结果。如下代码
typedef char* pstring;
int main()
{const pstring p1; // 编译成功还是失败?const pstring* p2; // 编译成功还是失败?return 0;
}
程序运行结果:
const pstring p1
:编译器会把const
修饰到变量p1
上,缩小指针变量的权限,使得p1
变为一个常变量,即使pstring
是char*
。const pstring* p2
:在p2
上,const
修饰的就是*p2
,缩小的是*p2
的权限,并不会把p2
变量改变为一个常变量。
在编程时,常常需要把表达式的值赋值给变量,这就要求在声明变量的时候清楚地知道表达式的类型。然而有时候要做到这点并非那么容易,因此C++11给auto
赋予了新的含义。
C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
int TestAuto()
{return 10;
}
int main()
{int a = 10;auto b = a;auto c = 'a';auto d = TestAuto();cout << typeid(b).name() << endl;cout << typeid(c).name() << endl;cout << typeid(d).name() << endl;//auto e; 无法通过编译,使用auto定义变量时必须对其进行初始化return 0;
}
- 编译器会获取赋值的类型得知变量的正确类型。
- 使用
auto
定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto
的实际类型。 - 因此
auto
并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto
替换为变量实际的类型
这样,我们再次使用定义std::map<std::string, std::string>::iterator
类型时,就不需要再手敲,就可以直接使用auto
来充当类型。如下:
int main()
{std::map<std::string, std::string> m{ { "apple", "苹果" }, { "orange","橙子" },{"pear","梨"} };auto it = m.begin();while (it != m.end()){//....}return 0;
}
8.1、auto的使用细节与注意事项
1. 一般auto
与指针和引用结合起来使用
用
auto
声明指针类型时,用auto
和auto*
没有任何区别,但用auto
声明引用类型
时则必须
加&
int main()
{int x = 10;auto a = &x;auto* b = &x;auto& c = x;cout << typeid(a).name() << endl;cout << typeid(b).name() << endl;cout << typeid(c).name() << endl;*a = 20;*b = 30;c = 40;return 0;
}
2. 在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
void TestAuto()
{auto a = 1, b = 2;auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
3. auto
不能作为函数的参数
在使用
auto
作为函数的形参时候,编译器无法在编译期间对形参的实际类型进行推导。
void test(auto a) {printf("%d\n", a);
}
int main() {test(10);auto a = 10;return 0;
}
程序运行结果:
我们尝试加上缺省值:
- 即使我们使用了缺省参数的形式来使用
auto
也是报错。
4. auto
不能直接用来声明数组
5. 为了避免与C++98中的auto
发生混淆,C++11
只保留了auto
作为类型指示符的用法
6. auto在实际中最常见的优势用法就是C++11提供的新式for循环,还有跟以后会讲到的lambda表达式等进行配合使用。
九、 基于范围的for循环(C++11)
在C++98和C语言中如果要遍历一个数组,可以按照以下方式进行:
int main() {int array[] = { 1, 2, 3, 4, 5 };for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)array[i] *= 2;for (int* p = array; p < array + sizeof(array) / sizeof(array[0]); ++p)cout << *p << endl;return 0;
}
但这样的方法对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此C++11中引入了基于范围的for循环。for循环后的括号由冒号:
分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
int main()
{int arr1[] = { 1, 2, 3, 4, 5 };char arr2[] = { 'a','b','c','d' };for (auto& e : arr1) {e *= 2;}for (auto e : arr1) {cout << e << " " ;}cout << endl;for (auto e : arr2) {cout << e << " ";}return 0;
}
程序运行结果:
- 左边变量我们可以设置为
auto
类型,这样可以用来接收所有类型的值。 - 右边则是有范围的集合,会由编译器逐个赋值给左边接收变量
- 上面范围
for
解析(以arr1
为例):范围for
依次取arr1
数组中数据赋值给e
,自动迭代结束。 - 需要注意的是,如果我们想修改数组里面的内容时,我们就需要把接收变量设置为引用
注意: 与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环
范围for的注意事项:
- for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin
和end
的方法,begin
和end
就是for
循环迭代的范围。
举个错误例子:
void TestFor(int array[])
{for (auto& e : array)cout << e << endl;
}
运行结果:
- 在函数中,我们知道
array
变量已经不是函数,而是指针,指针没有范围所有不能作为范围for
的迭代的范围。
- 迭代的对象要实现++和==的操作。
十、指针空值nullptr(C++11)
在C语言中,NULL
在库中是被宏重命名的数字0
,如下图:
可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)
的常量。 不论采取何
种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,比如:
void f(int)
{cout << "f(int)" << endl;
}
void f(int*)
{cout << "f(int*)" << endl;
}
int main()
{f(0);f(NULL);f((int*)NULL);return 0;
}
程序运行结果:
在运行结果中,我们可以观察到,我们使用NULL
调用的重载函数居然是int
类型的函数,程序本意是想通过f(NULL)
调用指针版本的f(int*)
函数,但是由于NULL
被定义成0
,因此与程序的初衷相悖。
在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)
常量,但是编译器默认情况下将NULL
看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void*)0
。
如下图:(在64位环境下,指针变量占用8个字节)
NULL
计算的是int
类型占用4
个字节的大小- 只有强制类型转换
(void*)NULL
时,才是指针变量的大小。
为了避免这样的情况出现,在C++11中就新增了 nullptr
作为空指针,使用nullptr
代表的就只是指针,不再回出现看作整形常量的情况。
以上就是本章所有内容。若有勘误请私信不才。万分感激💖💖 如果对大家有用的话,就请多多为我点赞收藏吧~~~💖💖
ps:表情包来自网络,侵删🌹