引:
C++是在C的基础上产生的,C++兼容了C的绝大多数特性。
本文主要是对C语法缺陷的补充,为后面知识的展开做铺垫。
正文开始@边通书
1. 关键字(C++98)
C++总计63个关键字,C语言32个关键字:
本文将主要提及——
" title="">
2. 命名冲突
引入——
" title="">
:x:报错:
是由于,我们自己定义的rand变量与库函数中rand函数发生命名冲突,编译器就不懂了,是要打印变量的值,还是rand函数的地址。
" title="">
命名冲突发生情况
- 我们自己定义的变量、函数跟库里面的重名冲突。
- 进入公司项目组后,做的项目通常比较大。多人协作,两个同事写的代码命名冲突。
对C语言而言是没有很好的解决方式的。为此cpp提出了新语法 —— 命名空间。
:key: 命名空间
引入namespace
关键字
namespace定义的是一个域。在C中我们知道在全局域和局部域中就可以分别使用相同的变量名。
#include<stdio.h>
#include<stdlib.h>
//定义了一个名为beatles的命名空间 - 域
namespace beatles
{
int rand = 0;
}
int main()
{
printf("%d\n", rand);
return 0;
}
:strawberry:这里namespace相当于Harry的隐身衣,将{ }内的内容与外界隔离开,编译器搜索不到(即谁也找不到Harry),但是本质上仍为全局域,不会改变内部变量的属性(Harry仍在)。
在此可以看到打印结果 —— 是函数的地址
" title="">
这是因为,对于变量的访问顺序,优先局部,后全局,最后,,报错。
那么如何访问命名空间的变量呢?
作用域限定符
::
接续上一段代码——
printf("%d\n", beatles::rand);//域作用限定符
这就指定了到::
前这个域去找。
打印结果——
" title="">
2.1 命名空间的定义
命名空间内可定义变量、函数、类型,因为它们都可能出现重复的情况。
namespace beatles { //1.变量 int rand = 0; //2.函数 int Add(int left, int right) { return left + right; } //3.类型 struct ListNode { struct ListNode* next; int val; }; }
访问 ——
beatles::rand = 19; beatles::Add(1, 2); struct beatles::ListNode node;// 看呐!::都是加在“名字”前的
命名空间可以嵌套使用
namespace S1 { int a; int b; int Add(int left, int right) { return left + right; } namespace S2 { int c; int d; int Sub(int left, int right) { return left - right; } } }
访问 ——
int main() { S1::Add(1, 2); S1::S2::Sub(2, 1); return 0; }
- 同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间
list.h
放声明
namespace List
{
struct ListNode
{
struct ListNode* next;
int val;
};
void ListInit();
void ListPushBack(struct ListNode* phead, int x);
}
list.c
放定义
#include"list.h"
namespace List
{
void ListInit()
{
//...
}
void ListPushBack(struct ListNode* phead, int x)
{
//...
}
}
list.h
和list.cpp
会被合并在一起。
就可以使用了 ——
struct List::ListNode node;
List::ListInit();
2.2 命名空间的使用
命名空间的成员应该怎样使用呢?
命名空间的使用有三种方式
:strawberry:1. “名字”前加 命名空间的名称::
int main()
{
printf("%d\n", beatles::a);//域作用限定符
return 0;
}
运行结果 ——
" title="">
这种做法 ——
::
前指定作用域,能做到最好的隔离效果- 使用不方便,每个前面都要加
:strawberry:2. 使用 using将命名空间中常用成员引入
#include"list.h"using List::ListNode;using List::ListInit;int main(){ struct ListNode node; ListInit(); return 0;}
- 指定展开某个,其他不展开
- 用于展开命名空间中常用的
:strawberry:3. 使用 using namespace 命名空间名称引入
//接续2.1.3的代码
#include"list.h"
using namespace List;
int main()
{
struct ListNode node;
ListInit();
return 0;
}
这种做法 ——
- 把整个命名空间全部展开,用起来方便
- 全部展开到全局,隔离失效了 —— 因此要慎用
3. C++的输入输出
讲了半天的namespace命名空间,实际上为了引入,平日里会经常看到的 ——
#include<iostream>
using namespace std;
int main()
{
cout<<"Hello world!!!"<<endl;
return 0;
}
运行结果 —— wuha!!
" title="">
说明 —— 这两行代码究竟是在干嘛?
" title="">
这样呐,也不是很好,把库中全部展开,相当于隔离失效,理论上要慎用,(其实平日练习大多写成这样也可以)。
综合上面介绍的的三种命名空间的使用方式,还可以 ——
采取2.2.1—— 在每一个"名字"前都加上 命名空间的名称+::
#include<iostream>int main(){ std::cout << "hello world" << std::endl; return 0;}
或者采取2.2.2 —— 展开某几个常用的
#include<iostream>using std::cout;using std::endl;int main(){ cout << "hello world" << endl; return 0;}
小总结:
使用cout标准输出(控制台)和cin标准输入(键盘)时,必须包含< iostream >头文件以及按命名空间方法使用std
- 在日常练习中,建议直接using namespace std即可,这样就很方便。
- 用using namespace std展开,标准库就全部暴露出来了。如果我们定义跟库重名的类型/对象/函数,就会存在冲突问题。然而这个问题在日常练习中很少出现,但是在代码较多、规模大的项目开发中就很容易出现。所以建议在项目开发中使用std::cout 或 指定命名空间 + using std::cout展开常用的库对象/类型等方式。
- cout 和 cin是全局流对象,endl是特殊的C++符号,表示换行输出,它们都包含在头文件中。
<<
是流插入运算符,>>
是流提取运算符。- 使用C++输入输出有时更方便,可以自动识别类型,不需要像printf/scanf那样手动控制格式。
如 ——
#include<iostream>
using namespace std;
int main()
{
int a = 10;
double b = 1.11;
char* str = "hello cpp!";
cout << a << " "<< b << endl;//可自动识别类型,不需要手动控制格式
printf("%d %lf\n", a, b);
cout << str << endl;//可自动识别类型,不需要手动控制格式
printf("%s\n", str);
return 0;
}
运行结果 ——
" title="">
究竟是使用C还是cpp的输入输出方式,取决于哪个更加方便。
cpp关于cin和cout有很多更复杂的用法,比如,控制浮点数输出精度,控制整型输出进制格式等等。因为C++兼容C语法,这些用的又不是很多,就不展开学习了。后续如果有需要,再配合文档学习一下。
在这里小小的对比一下 ——
#include<iostream>
using namespace std;
struct Student
{
char name[20];
int age;
};
int main()
{
struct Student s = { "小边", 19 };
//cpp
cin >> s.name >> s.age;//输入的话cpp更方便一些,且不容易出错;
cout << "姓名:" << s.name << endl;
cout << "年龄:" << s.age << endl;
//c
scanf("%s %d", s.name, &s.age);
printf("姓名:%s\n年龄:%d\n", s.name, s.age);//输出的话,c - printf更简单一些,一行轻松搞定。
}
//ps:
" title="">
- 实际上cout和cin分别是ostream和istream类型对象,
<<
和>>
也涉及运算符重载等知识,这里只是简单学习它们的使用,后续会深入学习IO流的用法及原理。
注:早期标准库将所有功能在全局域中实现,声明在.h后缀的头文件中,使用时只需包含对应头文件即可。后来将其实现在std命名空间下(这样与自己定义的不会冲突),为了和C头文件区分,也为了正确使用命名空间,规定C++头文件不带.h;旧编译器(vc 6.0)中还支持<iostream.h>格式,后续编译器已不支持,因此推荐使用+std的方式。
4. 缺省函数
4.1 缺省函数引入
缺省参数是声明或定义函数时 为函数的参数指定一个默认值。在调用该函数时,如果没有指定实参,则采用该默认值,否则使用指定的实参。
#include<iostream>
using namespace std;
void test(int a = 20)
{
cout << a << endl;
}
int main()
{
test(10);//1.显式传参时,使用指定实参
test();//2.没有传参时,直接使用缺省值做形参。在c中,没有cpp缺省函数的存在,会报错的。
return 0;
}
" title="">
4.2 缺省函数分类
:strawberry:1. 全缺省 —— 所有参数都给了缺省值
对于这种三个变量的全缺省函数有4种调用方式 ——
#include<iostream>
using namespace std;
// 全缺省
void test(int a = 10, int b = 20, int c = 30)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl << endl;
}
int main()
{
test();//第1、2、3个参数均缺省用默认值
test(1);//传给第1个参数,第2、3个参数缺省用默认值
test(1, 2);//传给前两个参数
test(1, 2, 3);//都使用指定实参
return 0;
}
运行结果 ——
" title="">
注意传值默认从左向右依次给,不然就乱了,不明确了。
:strawberry:2.部分缺省 —— 缺省部分参数
注意:
- 半缺省的参数必须从右向左依次连续给出,看下边例子就知道了,这样很合理明确
//部分缺省
void test(int a, int b = 10, int c = 20)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl << endl;
}
int main()
{
//要传的放在前边,爱传不传的放在后边
test(1);//这第一个参数必须传
test(1, 2);
test(1, 2, 3);
return 0;
}
运行结果 ——
" title="">
- 缺省函数的参数不能在声明和定义中同时出现。要么在声明中,要么在定义中,推荐写在声明中。
小应用——
#include<stdio.h>
#include<stdlib.h>
struct Stack
{
int* a;
int top;
int capacity;
};
//部分缺省
void StackInit(struct Stack* ps,int capacity = 4)
{
ps->a = (int*)malloc(sizeof(int)*capacity);
ps->top = 0;
ps->capacity = capacity;
}
int main()
{
struct Stack st;
StackInit(&st);//不知道栈最多存多少数据,就用缺省值初始化
StackInit(&st, 100);//知道栈最多存多少数据,显式传值,这样可以减少增容次数,提高效率
return 0;
}
5. 函数重载
自然语言中,一个词可以有多重含义,人们可以通过上下文来判断该词真实的含义,即该词被重载了。比如:以前有一个笑话,国有两个体育项目大家根本不用看,也不用担心。一个是乒乓球,一个是男足。前者是“谁也赢不了!”,后者是“谁也赢不了!”
5.1 函数重载概念
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或 顺序)必须不同,常用来处理实现功能类似数据类型不同的问题。
#include<iostream>
using namespace std;
//1.参数类型不同
int Add(int left, int right)
{
return left + right;
}
double Add(double left, double right)
{
return left + right;
}
//2.参数个数不同
void f()
{
cout << "f()" << endl;
}
void f(int a)
{
cout << "f(a)" << endl;
}
//3.参数顺序不同
void f(int a, char b)
{
cout << "f(int a, char b)" << endl;
}
void f(char b, int a)
{
cout << "f(char b, int a)" << endl;
}
int main()
{
//1.
Add(1, 2);
Add(1.1, 2.2);
//2.
f();
f(1);
//3.
f(1, 'A');
f('A', 1);
return 0;
}
运行结果 ——
" title="">
思考 —— 下面这两个函数构成函数重载吗?
short Add(short left, short right)
{
return left + right;
}
int Add(short left, short right)
{
return left + right;
}
不能。调用时要看参数,像这样编译器无法区分该调哪个。函数重载与返回值无关,仅返回值不同,不能函数重载。
思考 —— 下面这两个函数构成函数重载吗?
void f(int a = 0, int b = 0)
{
//...
}
void f()
{
//...
}
构成重载,我敲到这儿,聪明的编译器还没给我报错,but~
" title="">
虽然构成重载,但是存在调用歧义(二义性),那就是没有意义的。
5.2 函数重载原理 —— 名字修饰(name Mangling)
为什么C++支持函数重载,而C语言不支持函数重载?
在C/C++中,一个程序要运行起来,需要经历以下几个阶段:预处理、编译、汇编,链接。
而C++支持函数重载,而C语言不支持函数重载区别主要就发生在链接阶段。
先回忆一下这个过程 ——
" title="">
" title="">
为此,我阅读了《程序员的自我修养 —— 链接·、装载与库》第2章,和第三章的一部分,对于上述过程解释的非常清晰明了,而且真的很有意思!在此感谢作者提供文章素材。(留校最后一天,我在图书馆三楼昏暗书架的最底层找到了这本脏兮兮的书,但是那天我忽然决定回家,因为太累了回家疗伤去了哈哈)
下面这段原文,对于我们理解很有帮助,希望你读一下,真的很有意思——
" title="">
:strawberry:那么为什么C++支持函数重载,而C语言不支持函数重载?就是因为的名字修饰规则的不同。
对于C语言,为了防止类似的符号名冲突,UNIX下的C语言就规定,C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线“_”。比如一个C语言函数“foo",那么它编译后的符号名就是“_foo"。这种简单而原始的方法的确能够暂时减少多种语言目标文件之间的符号冲突的概率,但还是没有从根本上解决符号冲突的问题。C语言不支持函数重载,就是因为编译的时候,两个重载函数函数名相同,在func.o符号表中本身就存在歧义和冲突,编译器就会报错;其次链接的时候,因为他们都是直接使用函数名去标识和查找,也存在歧义和冲突。
众所周知,强大而又复杂的C++拥有类、继承、虚机制、重载、名称空间等这些特性,它们使得符号管理更为复杂,为了支持C++这些复杂的特性,人们发明了符号改编(Name Mangling〉的机制。最简单的例子,两个相同名字的函数func(int) 和 func(double),尽管函数名相同,但是有了函数名修饰规则,只要参数不同,func.o符号表里面重载的函数就不存在二义性和冲突了;链接的时候,test.o的main函数里面去调用func.o这两个重载的函数,查找地址时,也是明确。那么编译器和链接器在链接过程中就可以区分这两个函数了。
不同编译器厂商名字修饰规则可能不同, 由于Windows下vs的修饰规则过于复杂,而Linux下gcc的修饰规则简单易懂,下面我们使用了gcc演示了这个修饰后的名字。通过下面我们可以看出gcc的函数修饰后名字不变。而g++的函数修饰后变成【_Z+函数长度+函数名+类型首字母】——
" title="">
" title="">
通过这里就理解了C语言没办法支持重载,因为同名函数没办法区分。而C++是通过函数修饰规则来区分,只要参数不同,修饰出来的名字就不一样,就支持了重载。另外我们也理解了,为什么函数重载要求参数不同,而与返回值无关。
5.3 extern "C"
单拎文章写,开学前出。
6. 引用
引用的价值主要体现在 ——
:strawberry:做参数 a.提高效率 b.形参的修改改变实参(输出型参数)
:strawberry:做返回值 a.提高效率 b.改变返回对象(输出型返回值)
我们一点点来看。
6.1 引用概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。比如:李逵,在家称为"铁牛",江湖上人称"黑旋风"。
类型& 引用变量名(对象名) = 引用实体
,就像这样 ——
int main()
{
int a = 10;
int& b = a;//定义引用类型
return 0;
}
调试起来,监视一下。可以看到a,b的地址相同 ——
" title="">
:strawberry: 在语法层面(我们先不究底层编译器如何实现),这里没有开辟新空间,就是对原来的这段空间取了一个新名字叫b。
下面这段代码中,监视a,b的值,的确是同时变的——
" title="">
6.2 引用使用的小注意
:strawberry:1. 引用在定义时必须初始化
这样就会报错 ——
" title="">
:strawberry:2. 一个变量可以有多个引用
类似一个人有多个名字,多个外号。就像我,大名边通书,家里人叫我通书,高中同学叫我边哥,一些朋友甚至同学家长会叫我小边,室友叫边边,王巨龙叫我边哥或者我的老边边哈哈
int main()
{
int a = 10;
int& b = a;
int& c = a;
int& d = b;
return 0;
}
" title="">
:strawberry:3. 一旦引用了一个实体,再不能引用其他实体
我定义了谁的别名,以后就不会变成其它地址的别名了,从一而终。
就像是小王子,花园里有五千朵玫瑰,我偏爱你这一朵:rose:;世事如书:book:,我偏爱你这一句,愿做一个逗号,呆在你脚边~ 我去我好文艺!(我最近可喜欢小王子了呜呜)
这里主要是和指针做一个小对比 —— 指针,可以一会儿指这儿,一会儿指那儿,像极了渣男哈哈。
下面思考 ——
这里是让b变成c的别名呢?还是呢把c的值赋给b呢?
int main()
{
int a = 10;
int& b = a;
int c = 20;
b = c;//思考?
return 0;
}
调试起来,监视一下,很有意思 —— 显然是把c的值赋给b
" title="">
6.3 使用场景
6.3.1 引用做参数
void Swap(int& r1, int& r2)
{
int tmp = r1;
r1 = r2;
r2 = tmp;
}
我们像下面这样调用,就是在传引用 —— r1,r2就是x,y的别名
int main()
{
int x = 10;
int y = 20;
Swap(x, y);//传引用
//这里既不是传值,也不是传址。相当于把本身传了过去
printf("a = %d,b = %d\n", x, y);
return 0;
}
这能实现了与指针一样的功能。
我们以最朴素的单链表的增删查改为例。当初为了处理头结点也就是pList(类型为SLNode*
)的改变,我们可以
- 传二级指针(即类型为
SLNode**
) - 返回值然后接收
- 传引用
下面我们以SListPushBack这个函数为例传引用来改造一下 ——
这里phead就是pList的别名,phead的改变就是改变pList,如下图。
:strawberry: 这种传指针的引用的方式,可以减少对指针的使用,因为在一些情况下,指针是“复杂”的。
:strawberry: 引用做参数,还可以通过形参来改变实参,即输出型参数。
在我最近更新的题解“两个单身狗问题”中,函数接口的参数int* returnSizze
(其实就是输出型参数)容易给人误导,实际上传引用int& returnSize
有同样效果。
" title="">
6.3.2 引用做返回值
对比:传值返回,返回值是在函数栈桢销毁之前,放在临时变量中带回来的,如下图(在我的博文中,详细介绍了函数栈桢创建与销毁的整个过程)——
" title="">
- 若c比较小(4/8byte),一般是寄存器充当临时变量
- 若c比较大,临时变量存放在调用层函数的栈桢中
a.那么引用做返回值呢?
#include<iostream>
using namespace std;
int& Add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int& ret = Add(1, 2);
cout << ret << endl;
Add(3, 4);
cout << "Add(1, 2) is :" << ret << endl;
return 0;
}
解释上述代码,return c;
即是返回c的引用,ret就是c的别名(引用)。
:x:你看出来这段代码是有问题的了吗?因为引用返回的这种返回方式,并没有生成c的拷贝(引用这样减少拷贝[大对象 + 深拷贝对象],可以很好的提高性能),而是直接返回c的引用,作为ret的别名。然而Add函数栈桢已经销毁了,还回去访问c的空间,就发生了非法访问。
这种情况下,如果c空间没被覆盖,ret还能侥幸拿到所谓"正确"的值;如果清理了空间,ret拿到的就是随机值(取决于编译器)。
" title="">
:strawberry:于是我们就有这样的原则:strawberry::如果函数返回值,出了作用域,如果返回对象未还给系统,则可以引用返回;如果已经还给系统了,则必须使用传值返回,不能返回局部变量的引用。
因此,日常不建议用引用返回,但是在类和对象中用处非常大。
像全局变量就可以以引用做返回 ——
int& Count()
{
static int n = 0;
n++;
// ...
return n;
}
b.引用还可以改变返回变量
举一个别扭的例子 ——
#include<iostream>
using namespace std;
int& At(int i)
{
static int a[10];//静态数组
return a[i];
}
int main()
{
int i = 0;
//引用做返回值(返回a[i]的别名) -- 可写
for (i = 0; i < 10; i++)
{
At(i) = 10 + i;
}
//读
for (i = 0; i < 10; i++)
{
cout << At(i) << " ";
}
cout << endl;
return 0;
}
运行结果 ——
" title="">
若去掉& ——
" title="">
6.5 传值、传引用效率比较
以值作为参数或返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时拷贝,有时效率是非常低下。相比而言,指针或引用可以提高性能 ——
- 在有些场景下(大对象 + 深拷贝对象),可以提高性能
- 做输出型参数或做输出型返回值(通俗说,有些场景下,形参的改变可以改变实参;引用返回,可以改变返回对象)(后面具体聊)
下面我们来测试性能 ——
- 值和引用作为函数参数的性能比较
#include<iostream>
using namespace std;
#include <time.h>
//全局变量 —— 出了作用域不会销毁
struct A{ int a[10000]; };
void TestFunc1(A a){}//生成了a的拷贝
void TestFunc2(A& 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;
}
我们就这样调用两个函数一万次 ——
" title="">
- 值和引用作为返回值类型的参数比较
#include <time.h>
struct A{ int a[10000]; };
A a;
// 值返回
A TestFunc1() { return a; }
// 引用返回
A& TestFunc2(){ return a; }
void TestReturnByRefOrValue()
{
// 以值作为函数的返回值类型
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;
}
我们就这样调用两个函数一万次 ——
" title="">
综上,我们可以看出引用在某些场景下可以很好的提高性能。(大对象 + 深拷贝对象)
6.6 常引用
众所周知,const修饰只读,引用是读和写。
const引用 ——
- 权限扩大:x:
const int a = 10;//只读
int& b = a;//编译器报错 - 权限扩大 - int可读可写
- 权限不变:white_check_mark:
const int a = 10;
const int& b = a;//权限不变
- 权限缩小:white_check_mark:
int c = 10;
const int& d = c;//d是c的别名,但是只读 —— 权限缩小
有了上面这些,继续看 ——
int main()
{
double d = 11.11;
int i1 = d;
//int& i2 = d; //编译出错
const int& i3 = d;
return 0;
}
此时,i3就是d类型转换时,产生的临时变量的引用。
" title="">
:strawberry: 结论:const引用(const type&
)通吃,可以接收各种类型的对象。
引用常在参数和返回值中使用,假设x是一个大对象或是以后学习深度拷贝的对象,为了减少拷贝,尽量使用引用传参,这时就要注意权限的问题。
因此,如果使用若函数中不改变参数,建议使用const &
传参,like this——
void StackPrint(const struct Stack& st) {}
6.7 引用和指针的区别
面试常问,别背,理解即可 ——
- 在概念上,引用定义了一个变量的别名,没开空间;指针存储着一个变量的地址。
- 引用在定义时必须初始化;指针没有要求(虽然最好置NULL,但不初始化编译器不会报错)
- 引用在初始化时引用一个实体后,就不能再引用其他实体;指针可以在任何时候指向任何一个同类型实体
- 没有NULL引用,但有NULL指针
- 在sizeof中含义不同,引用结果为引用类型的大小;但指针始终是地址空间所占字节个数(4byte/8byte)
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
- 有多级指针,但是没有多级引用
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 引用比指针使用起来相对更安全,使用指针需要考虑空指针、野指针等问题。指针太灵活了,没有引用安全。
总之指针就是用起来更复杂一些,更容易出错一些。
:strawberry:补:了解一下即可,用的时候不要想底层实现
在语法层,指针和引用是完全不同的概念,指针是开空间,存储变量地址;引用不开空间,仅仅是对变量取别名。
但是在底层汇编代码中,我们可以看到,引用是指针实现的 —— 一模儿一样
" title="">
7. 内联函数
对于短小且频繁的函数调用,都需要建立栈桢,就要保存寄存器等等,调用结束后又恢复,这都是有消耗的,怎么优化?
在C语言中我们可以利用宏的替换机制,减少函数压栈的开销。但它也有一些缺陷,为此我们引入内联函数。
我们先回顾一下宏 ——
#define ADD(X,Y) ((X)+(Y))
int main()
{
cout << ADD(1, 2) << endl;
return 0;
}
:strawberry: 优点:增强了代码的复用性;替换机制,提高了性能
:strawberry: 缺点 ——
- 宏不方便调试(预编译阶段进行了替换)
- 导致代码的可读性差,维护性差,容易出错
就说上面的宏,我少加一层括号都不行,都和预想结果不同,示例 ——
cout << 10 * ADD(1, 2) << endl;
int a = 1;
int b = 0;
cout << ADD(a | b, a&b) << endl;//a|b+a&b,+优先级高
- 没有类型安全的检查
为此我们引入内联函数。
7.1 内联函数概念
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数压栈的开销,内联函数提升程序运行的效率。
" title="">
如果在上述函数前增加inline关键字将其改成内联函数,在编译期间编译器会用函数体替换函数的调用。
在debug模式下,需要对编译器进行设置,否则不会展开(因为debug模式下,编译器默认不会对代码进行优化,配置优化及程序数据库)
可以看到,在这儿没有call调用函数。而是直接展开 ——
" title="">
7.2 内联函数特性
:strawberry:1. inline是一种以空间换时间的做法,省去调用函数开销。所以代码很长(多少行是长?取决于编译器)或者有循环/递归的函数不适宜使用作为内联函数。
:strawberry:2. inline对于编译器而言只是一个建议,编译器会自动优化,如果定义为inline的函数体内有循环/递归等等,编译器优化时会忽略掉内联。
:strawberry:3. inline不建议声明和定义分离,分离会导致链接错误。因为inline函数是不需要地址的,都在调用函数的地方展开了,F.cpp -> F.o 符号表不会生成F函数的地址。
:strawberry:4. 在类中实现的函数(一般比较短小),默认是内联函数
// F.h
#include <iostream>
using namespace std;
inline void f(int i);
// F.cpp
#include "F.h"
void f(int i)
{
cout << i << endl;
}
// main.cpp
#include "F.h"
int main()
{
f(10);
return 0;
}
// 链接错误:main.obj : error LNK2019: 无法解析的外部符号 "void __cdecl f(int)" (?f@@YAXH@Z),该符号在函数 _main 中被引用
8. auto关键字
8.1 auto简介
在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它,因为不加auto默认定义的就是自动存储的。
C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
可以自动推导a的类型 ——
" title="">
这可以替换复杂类型,简化代码.
8.2 auto使用细则
- 用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&
" title="">
- 当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
void TestAuto()
{
auto a = 1, b = 2;
auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
8.3 auto不能推导的场景
- auto不能单独定义,必须对其进行初始化。因为在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。因此,auto不能做为函数的参数** ——
" title="">
- auto不能直接用来声明数组 ——
" title="">
auto在实际中最常见的优势用法就是跟以后会讲到的C++11提供的新式for循环,还有lambda表达式等进行配合使用。
9. 基于范围的for循环(C++11)
9.1 范围for的语法
C++98中,我们遍历一个数组,都是这样 ——
int main()
{
int arr[] = { 1, 2, 3, 4, 5 };
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
{
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
C++11为我们提供了语法糖:candy:,即范围的for循环。对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此C++11中引入了基于范围的for循环。for循环后的括号由冒号:
为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
看下面这段代码,它的意思是,自动取arr中的每个元素,赋值给e,并自动判断结束 ——
//C++11
int arr[] = { 1, 2, 3, 4, 5 };
for (auto e : arr)
{
cout << e << " ";
}
怎样给数组中的每个元素都加1呢?像这样?
int main()
{
int arr[] = { 1, 2, 3, 4, 5 };
for (auto e : arr)
{
e++;
}
for (auto e : arr)
{
cout << e << " ";
}
cout << endl;
return 0;
}
事实上,这样对e++,并不会影响数组元素,打印结果也如下 ——
" title="">
我们可以用上之前的引用,实现对数组元素的写 —— e就是arr数组中每个元素的别名(引用)——
" title="">
9.2 范围for的使用条件
- for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
注意:以下代码就有问题,因为for的范围不确定
void TestFor(int arr[])
{
for(auto& e : arr)
cout<< e <<endl;
}
范围for的:
右边,必须是数组名。但这里传参过来,已经退化成首元素地址了。
- 迭代的对象要实现++和==的操作。(关于迭代器这个问题,以后再聊)
10. 指针空值 -- nullptr(C++11)
10.1 C++98中的指针空值
在良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针。如果一个指针没有合法的指向,我们基本都是按照如下方式对其进行初始化 ——
//C++98,03
int* p1 = NULL;
int* p2 = 0;
这也没什么问题,但是在极端条件下会发生一些混淆,与我们的本意不符 ——
`" title="`">
可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量。不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的初衷相悖。
这就是因为在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void *)0
。
10.2 nullptr
因此,在C++11中就不再推荐使用NULL
——
//C++11
int* p3 = nullptr;
注:
- 在使用
nullptr
做指针空值时,不需要包含头文件,因为nullptr
是C++11作为关键字引入的 - C++中,sizeof(nullptr)和sizeof((void)0)*所占的字节数相同
本文完@边通书
后序持续更新~