内联函数
C++中函数的使用我们已经比较清楚了,与C语言中函数的使用大多相同,主要是增加了重载的特性,对C语言的函数的一些缺陷做了一些补充。
那么对于一些比较简单却又经常使用的功能,我们在C语言中常常使用宏来替换,宏呢与函数相比没有栈帧的开辟,类型的检查,没有传参,仅仅是做一个替换,非常适合功能简单却使用频繁的应用场景,但是宏正因为如此,也就具有了不安全、无法调试的缺陷,那么C++中如何处理这样地缺陷呢?
内联函数应运而生它既继承了宏的优点也继承了函数的优点,即既没有开辟栈帧的开销,又可以去调试,并且有类型的检查。
内联函数和宏很类似,而区别在于,宏是由预处理器对宏进行替代,而内联函数是通过编译器控制来实现的。而且内联函数是真正的函数,只是在需要用到的时候,内联函数像宏一样的展开,所以取消了函数的参数压栈,减少了调用的开销。你可以像调用函数一样来调用内联函数,而不必担心会产生 处理宏的一些问题。
概念
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数压栈的开销,内联函数提升程序运行的效率。
函数是一个可以重复使用的代码块,CPU 会一条一条地挨着执行其中的代码。CPU 在执行主调函数代码时如果遇到了被调函数,主调函数就会暂停,CPU 转而执行被调函数的代码;被调函数执行完毕后再返回到主调函数,主调函数根据刚才的状态继续往下执行。
我们得明白,函数调用是有时间和空间开销的。程序在执行一个函数之前需要做一些准备工作,要将实参、局部变量、返回地址以及若干寄存器都压入栈中,然后才能执行函数体中的代码;函数体中的代码执行完毕后还要清理现场,将之前压入栈中的数据都出栈,才能接着执行函数调用位置以后的代码。
如果函数体代码比较多,需要较长的执行时间,那么函数调用机制占用的时间可以忽略;如果函数只有一两条语句,那么大部分的时间都会花费在函数调用机制上,这种时间开销就就不容忽视,要尽可能处理函数调用机制所用时间占比大的这种情况。
为了消除函数调用的时空开销,C++ 提供一种提高效率的方法,即在编译时将函数调用处用函数体替换,类似于C语言中的宏展开。这种在函数调用处直接嵌入函数体的函数称为内联函数(Inline Function),又称内嵌函数或者内置函数。
先来看看普通函数的调用过程:
调用函数时是使用call指令,去调用某地址上的函数。(注意:普通函数都是有地址的,可以用以区分内联函数)
如果在上述函数的前面加上inline关键字将其改为内联函数,在编译期间编译器会用函数体替换函数的调用。
不过我们通常在Debug模式下默认函数不会被当做内联,即使你加上了inline,都会被编译器忽略,只有在release模式下,inline才有可能会被采纳,至于为什么是有可能,编译器只会把你的inline关键字当做一个建议,至于编译器是否按照你所要求的去做,这就不一定了,因为这仅仅是一个建议,编译器会结合具体情况比如函数体指令的多少来判断到底是否当做内联函数。
所以我们如何去观察一个函数是否被当做内联函数呢?
在release模式下
-
查看编译生成的汇编代码中是否存在call Add
-
监视器窗口查看Add函数是否有地址;
在debug模式下需要对编译器进行设置,否则不会展开,因为在debug模式下,编译器默认不会对代码进行优化,内联函数其实算一种优化方式。
- 在项目—>属性中找到 C/C++选项—>常规
将调试信息格式改为程序数据库(/Zi)
-
在C/C++选项中找到优化
将内联函数扩展选择—>只适用于__inline(Ob1)
- 重新生成可执行文件即可
完成后,我们便可以在debug模式下查看到内联函数的展开
这里并没有call Add函数,而是函数体的展开(当然不仅仅是简单的展开,还会涉及一些其他指令,不做深入讨论)。
特性
- inline是一种空间换时间的做法 ,节省了开辟栈帧的时间开销;
与调用普通函数相比不需要去开辟栈帧空间,节省了时间,相当于inline函数体所有指令都在当前栈内被执行;
- inline对于编译器仅仅是一个建议,编译器会自动优化,如果定义为inline的函数体内有循环/递归等,编译器优化会自动忽略内联。
不仅是以上两种情况,函数体内的指令一旦较多,编译器就会自动忽略,如下:
函数体指令较复杂:
函数体指令较简单:
- inline函数不建议声明和定义分离,分离会导致链接错误。因为inlinn函数被展开,也就不会有函数地址,自然不用提去链接了。
//func.h文件
#pragma once
#include<iostream>
using namespace std;
inline void f(int i);
//func.cpp文件
#include"test.h"
void f(int i)
{
cout << "func" << endl;
}
//main.cpp文件
#include"test.h"
int main()
{
f(1);
return 0;
}
报错:error LNK2019: 无法解析的外部符号 "void __cdecl f(int)" (?f@@YAXH@Z),函数 main 中引用了该符号。
如果想要一个函数成为内联,但是类的定义和类实例化的地方在不同的源文件(声明定义分离),那么最好是将此函数定义在类中。
对于内联函数,其工作原理是:
对于任何内联函数,编译器在符号表里放入函数的声明(包括名字、参数类型、返回值类型)。如果编译器没有发现内联函数存在错误,那么该函数的代码也被放入符号表里。在调用一个内联函数时,编译器首先检查调用是否正确(进行类型安全检查,或者进行自动类型转换,当然对所有的函数都一样)。如果正确,内联函数的代码就会直接替换函数调用,于是省去了函数调用的开销。
各个文件是分离编译的,在func.c中由于声明了f函数是内联的,并且函数体也很简短,因此编译器遵循了我们的建议,使其成为一个内联函数,由于没有函数地址,自然无法被除本源文件以外的地方调用;也可以说内联函数在符号表不会有合并这一步操作,仅仅存在于本源文件中。
内联函数的缺点
难道内联函数就没有缺点吗,当然有!不然还要函数做什么?内联函数随着一次次的调用展开,会造成代码膨胀的问题,通俗讲就是生成的可执行文件会变大,这是我们不愿意看到的(有谁愿意看着自己的电脑硬盘被榨干呢?)
可以大致从几个方面看:
- 编译后的程序会存在多份相同的函数指令拷贝,这些函数拷贝编译后都是二进制的指令,如果被声明为内联函数的函数体非常大,那么编译后的程序体积也将会变得很大。
- 可执行程序在运行前要先载入内存,程序的执行就是一步步去内存取指令然后交给CPU执行的过程,可以试想可执行文件大,那么其指令也就越多,载入内存后消耗的空间也越大。
很好理解,普通的函数都有一个地址,每当我们需要使用这个函数时,直接通过函数名访问地址,然后就是建立栈帧的过程,在新栈帧中执行相应函数指令。
内联函数没有它的地址,我们需要调用这个函数时,只能临时拷贝一份,再执行相应指令。
举一个例子就是,普通函数就是一个坚信好 “记性不如烂笔头的乖学生”,老师讲一个重要的、多次使用的知识点时,他就记在笔记本上,需要了就拿出来看看就会了。内联函数也是一个爱记笔记的学生,不过它丢三落四的,刚记下笔记笔记本就丢了,每次需要时,就只能又去问老师再记下来,慢慢的他写过的笔记本就很多了,不过他自己还浑然不知。
他们两个同学的笔记本都是一个作用,就是记录下这个知识,但是随着使用次数的增加,这位有收拾的同学只需要一个笔记本就能终生受用,而这位丢三落四的同学则会随着记了又丢,丢了又记的过程产生很多个笔记本,内联函数也是同样的道理。
那么它们的过程实际区别如下:
在程序载入过程中,两个函数体内容相同的普通函数和内联函数,普通函数的函数指令只向内存中载入了一次,之后每次调用此函数都只需要一条指令,直接访问其函数地址并取指令。
内联函数不会载入内存,没有函数地址。在编译后这些调用内联函数的语句都会被展开为内联函数的指令(做了一些特殊处理,并不是完全复制的函数体指令),由于编译后内联函数展开部分就只是一条条的指令,这些指令都会被载入内存,可以看到这里调用了多少次,内联就展开了多少次,展开的指令都会被载入内存,可以等效于调用了几次就将内联函数的指令载入了几次到内存。
那么也就得出差异,普通函数只需要载入内存一次,而内敛函数是调用了几次就会载入内存几次。
- 如果内联函数调用次数很多,调用结束后由于调用所产生的内存消耗并不会被释放(普通函数调用结束后栈帧会销毁)
如图:
总体来说,如果除去开辟栈帧的花销,内联函数和普通函数的所执行的指令数、时间几乎是相同的,重点在于如何把控执行一个函数时,它开辟的栈帧的消耗占整个函数调用的比重,如何把控这个比重,决定了我们是否建议一个函数为内联。
我们来写个程序验证一下,并从指令的角度来看:
inline void func()
{
cout << "func" << endl;
}
int main()
{
func();
func();
func();
func();
return 0;
}
来看看内联函数的汇编指令:
func();
00007FF6AA4D1522 lea rdx,[string "func" (07FF6AA4DAE64h)]
00007FF6AA4D1529 mov rcx,qword ptr [__imp_std::cout (07FF6AA4E0198h)]
00007FF6AA4D1530 call std::operator<<<std::char_traits<char> > (07FF6AA4D1046h)
00007FF6AA4D1535 lea rdx,[std::endl<char,std::char_traits<char> > (07FF6AA4D1014h)]
00007FF6AA4D153C mov rcx,rax
00007FF6AA4D153F call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF6AA4E01B0h)]
func();
00007FF6AA4D1545 lea rdx,[string "func" (07FF6AA4DAE64h)]
00007FF6AA4D154C mov rcx,qword ptr [__imp_std::cout (07FF6AA4E0198h)]
00007FF6AA4D1553 call std::operator<<<std::char_traits<char> > (07FF6AA4D1046h)
00007FF6AA4D1558 lea rdx,[std::endl<char,std::char_traits<char> > (07FF6AA4D1014h)]
00007FF6AA4D155F mov rcx,rax
00007FF6AA4D1562 call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF6AA4E01B0h)]
func();
00007FF6AA4D1568 lea rdx,[string "func" (07FF6AA4DAE64h)]
00007FF6AA4D156F mov rcx,qword ptr [__imp_std::cout (07FF6AA4E0198h)]
00007FF6AA4D1576 call std::operator<<<std::char_traits<char> > (07FF6AA4D1046h)
00007FF6AA4D157B lea rdx,[std::endl<char,std::char_traits<char> > (07FF6AA4D1014h)]
00007FF6AA4D1582 mov rcx,rax
00007FF6AA4D1585 call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF6AA4E01B0h)]
func();
00007FF6AA4D158B lea rdx,[string "func" (07FF6AA4DAE64h)]
00007FF6AA4D1592 mov rcx,qword ptr [__imp_std::cout (07FF6AA4E0198h)]
00007FF6AA4D1599 call std::operator<<<std::char_traits<char> > (07FF6AA4D1046h)
00007FF6AA4D159E lea rdx,[std::endl<char,std::char_traits<char> > (07FF6AA4D1014h)]
00007FF6AA4D15A5 mov rcx,rax
00007FF6AA4D15A8 call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF6AA4E01B0h)]
接着我们取消内联再来看看:
func();
00007FF679261572 call func (07FF679261285h)
func();
00007FF679261577 call func (07FF679261285h)
func();
00007FF67926157C call func (07FF679261285h)
func();
00007FF679261581 call func (07FF679261285h)
差别还是蛮大的,这些指令都会在运行时载入内存,造成代码膨胀。
一些其他不足
- 通常,编译器比程序设计者更清楚对于一个特定的函数是否合适进行内联扩展;一些情况下,对于程序员指定的某些内联函数,编译器可能更倾向于不使用内联甚至根本无法完成内联。
- 对于一些开发中的函数,它们可能从原来的不适合内联扩展变得适合或者倒过来。尽管内联函数或者非内联函数的转换易于宏的转换,但增加的维护开支还是使得它的优点显得更不突出了。
- 对于基于C的编译系统,内联函数的使用可能大大增加编译时间,因为每个调用该函数的地方都需要替换成函数体,代码量的增加也同时带来了潜在的编译时间的增加。
判断是否设置为内联:一般只将那些短小的、频繁调用的函数声明为内联函数。
最后需要说明的是,对函数作 inline 声明只是程序员对编译器提出的一个建议,而不是强制性的,并非一经指定为 inline 编译器就必须这样做。编译器有自己的判断能力,它会根据具体情况决定是否这样做。
auto关键字
auto为自动的意思,C语言中貌似也有提到过(自动变量什么的,记不清了几乎没使用过),那么在C++中auto有什么作用、应用于哪些场景呢?
auto简介
在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它,大家可思考下为什么?
C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
int main()
{
auto a = 1;
auto b = 2.0;
auto c = 2.0f;
auto d = 'w';
cout << typeid(a).name() << endl;
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
return 0;
}
//auto e;无法通过编译。
既然是编译期间自动推导类型,那么就说明一定得初始化咯。
使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
详细使用规则
既然是占位符则说明auto可以是任何类型,而不一定是对其进行初始化数据的类型。
- auto和指针结合起来使用
用auto声明指针类型时,用auto和auto*没有任何区别,但是auto对于引用的声明必须加上&;
int main()
{
int a = 10;
auto p1 = &a;//auto在编译时会被替换为int*
auto* p2 = &a;//auto在编译时会被替换为int
int& ref = a;
auto ref1 = ref;//ref是a的别名,因此推导出是int类型
ref1 = 12;//a并不会改变
cout << a << endl;
cout << typeid(a).name() << endl;
cout << typeid(ref).name() << endl;
cout << typeid(p1).name() << endl;
cout << typeid(p2).name() << endl;
cout << typeid(ref1).name() << endl;
return 0;
}
- 同一行定义多个变量
在同一行定义多个变量时,这些变量的类型必须相同,否则编译器会报错,因为编译器实际只对第一个类型进行推导,然后替换为auto,并用该类型定义其他变量。
int main()
{
auto a = 1, b = 1.0;
return 0;
}
在声明符列表中,“auto”必须始终推导为同一类型
这样看auto好像还挺万能的,那么auto能适用于所以场景吗?
auto不能推导类型的场景
- 不能作为函数的形参
// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a和b的实际类型进行推导
int Add(auto a, auto b)
{
return a + b;
}
error C3533: 参数不能为包含“auto”的类型
函数在编译时是需要形参类型来确定修饰后函数名的,所以形参类型要先确定。
这种显然是不可行的,如果可以直接使用auto推导,那么就没后面的模板什么事了。
注:返回值是可以用auto的。
- 不能用来直接声明数组
int main()
{
auto arr[] = { 1,2,3,4,5 };
return 0;
}
error C3318: “auto []”: 数组不能具有其中包含“auto”的元素类型
error C3535: 无法推导“auto []”的类型(依据“initializer list”)
- 为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法
- auto为了在实际中最常见的优势用法就是跟以后会讲到的C++11提供的新式for循环,还有lambda表达式等
进行配合使用
基于范围的for循环
for循环是我们非常熟悉的,用法也比较单一,通常在知道循环次数的情况下使用,那么什么是范围for呢?
范围for的语法
C++98中如果我们要遍历一个数组通常是下面这种方式:
void TestFor()
{
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;
}
对于一个范围已知的集合而言,由我们来说明循环的范围显然是多余的,有时候不注意还会出错。因此C++11中引入了基于范围的for循环。for循环后的括号由冒号“:”分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
for (auto& e : array)
e *= 2;
for (auto e : array)
cout << e << " ";
}
循环体内就和普通的循环一样了,我们可以使用break跳出循环,也可以使用continue结束本次循环,只不过是编译器帮助我们确定迭代的范围而已。
范围for的使用条件
- for循环迭代的范围是确定的
对于数组而言,迭代的范围就是从第一个元素到最后一个元素;
对于类而言,应该提供begin以及end的方法,循环的范围就是从begin到end;
也就是说我么给for的应该是一组数据集合,例如数组,链表等等…
看看一下代码是否有问题:
void TestFor(int array[])
{
for (auto& e : array)
cout << e << endl;
}
“begin”: 未找到匹配的重载函数
显然是有问题的,数组名作为形参,其本质上是一个指针,指针是一组数据的集合吗??显然不是。
- 迭代的对象的迭代器要实现++和==的操作。(关于迭代器这个问题,以后会讲,现在大家了解一下就可以了)
可以先简单说说迭代器,迭代器就是一个封装后的指针,通过这个封装后的指针我们可以通过地址找到数据的存储位置。为什么要封装呢?因为对于原生指针我们执行++操作,它是在相邻的位置移动,这样对于链表等数据结构是无法正确访问的,可如果我们对++操作符进行重载,就可以让它根据他的存储特性去移动指针了!
新的指针空值nullptr C++11
从学习C语言之初,我们就说要养成一个好习惯——声明一个变量时给一个合适的初值,否则可能会出现不可预料的错误,比如未初始化的指针。如果当前指针没有明确的指向,那么可以给其赋值为空;
int main()
{
int* p = NULL;
int* p1 = 0;
return 0;
}
这两者其实是一样的,NULL其实是一个宏,即是0值;
在传统C头文件里(stddef.h)中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量。不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,比如:
void func(int)
{
cout << "f(int)" << endl;
}
void func(int*)
{
cout << "f(int*)" << endl;
}
int main()
{
func(0);
func(NULL);
func((int*)NULL);
return 0;
}
我们的本意是让func(NULL)
去调用void func(int*)
的,但却出现了问题,由于NULL被定义成0,而0默认被当做一个整型,因此与程序的初衷相悖。
在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void *)0。
因此在C++程序中我们更倾向使用nullptr而不是NULL,来看看nullptr是什么。
int main()
{
int* p = NULL;
int* p1 = 0;
cout << " " << typeid(NULL).name() << endl;
cout << " " << typeid(nullptr).name() << endl;
return 0;
}
注意:
-
在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入的。
-
在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
-
为了提高代码的健壮性(适用于更多场景),在后续表示指针空值时建议最好使用nullptr。