C++入门【下】—— 预处理与内联函数关系

发布于:2022-12-04 ⋅ 阅读:(443) ⋅ 点赞:(0)


前言

在C/C++的实现中,存在两种不同的环境

  1. 翻译环境,在这个环境中源代码被转换为可执行的机器指令
  2. 运行环境,它用于实际执行代码

研究翻译环境对我们研究C++中的内联函数和函数重载尤其重要。因此我们需要先研究一下翻译环境究竟是个怎样的存在。

1️⃣ 程序的翻译环境

🔎程序的翻译环境主要分成编译和链接
在这里插入图片描述

  1. 组成程序的每个源文件通过编译转换成目标代码(object code),生成一个后缀为.obj的目标文件
  2. 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序,后缀为.exe
  3. 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人
    的程序库,将其需要的函数也链接到程序中。该过程也就是我们常用的包含头文件操作。

编译与链接

编译本身分为三个阶段,我们先创建两个文件,如下(用C语言演示)
在这里插入图片描述
在这里插入图片描述
💡我们平时写函数的时候经常会有这样的习惯,就是函数的声明和定义分离在不同文件,那么这是如何实现的呢?这和我们的编译和链接大有关系。

编译和链接的过程具体如下:
在这里插入图片描述

1️⃣在汇编阶段
在这里插入图片描述

各个文件会形成一个这样的符号表,每个符号后面都会跟上一个地址。但我们在test.c中只是对Add函数进行了声明,而没有定义,因此它不会产生地址,“?” 表示编译器会先给test.c中的Add一个没有意义的地址。

2️⃣在链接阶段

所谓符号表的合并和重定位正是函数声明定义分离的真正奥妙。
在链接阶段,各个目标文件的符号表会进行合并,然后带"?"地址的符号就会去其他目标文件的符号表中找到与它同名且已定义的符号进行重定义。

在这里插入图片描述


2️⃣ 内联函数

2.1 概念

inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
展开:指的是编译阶段展开汇编指令。

💬看如下C++代码

#include <iostream>

int Add(int x, int y)
{
	return x + y;
}

int main()
{
	int a = 10;
	int b = 20;
	int ret = Add(a, b);
	return 0;
}

⭕其汇编指令是这样的
在这里插入图片描述
在这里插入图片描述

可见,在调用Add函数时,用到了call指令,也就是需要创建栈帧调用函数。从Add函数实现部分的汇编指令我们也可以看到,包含了大量指令,这些指令都是为函数开辟栈帧做准备的。

💬再看下面C++代码,将Add改为内联函数

#include <iostream>

inline int Add(int x, int y)
{
	return x + y;
}

int main()
{
	int a = 10;
	int b = 20;
	int ret = Add(a, b);
	return 0;
}

在这里插入图片描述
在这里插入图片描述

将Add改为内联函数后,可以看到,调用Add时并没有call该函数,而是直接展开成该函数实现的汇编指令。而Add函数实现部分的汇编指令因为不需要创建栈帧,也没有多余的指令。

🔎查看方式

  1. 在release模式下,查看编译器生成的汇编代码中是否存在call Add
  2. 在debug模式下,需要对编译器进行设置,否则不会展开(因为debug模式下,编译器默认不
    会对代码进行优化,以下给出vs2013的设置方式)

在这里插入图片描述


2.2 内联函数的特性

  1. inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会
    用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运
    行效率。

⭕假设一个函数有50行指令,在一个程序内调用该函数10000次。

  • 当它不是内联函数时,每次调用开辟栈帧,则该函数影响生成了10050(10000+50)行指令
  • 当它是内联函数时,每次调用都展开,则该函数影响生成了500000(10000*50)行指令
    这种情况下使用内联函数会使可执行程序(.exe文件)的大小变得非常大。
  1. inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建
    议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、
    是递归
    、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。

  2. inline函数的声明和定义不能分离,分离会导致链接错误。因为inline函数是要被展开的,没有函数地址,链接阶段符号表重定义时会出错。

📌为什么inline函数的声明和定义不能分离?
这里就又要扯一下我们的编译与链接了。

//test.cpp

#include <iostream>
#include "add.h"

int main()
{
	int a = 10;
	int b = 20;
	int ret = Add(a, b);
	printf("%d", ret);
	return 0;
}

//add.h

#pragma once

int Add(int x, int y);

//add.cpp

inline int Add(int x, int y)
{
	return x + y;
}

🔎我们搞三个文件test.cpp、add.h、add.cpp,Add函数声明写在add.h中,定义写在add.cpp中而且是定义为内联函数。运行一下,发现报了链接错误
在这里插入图片描述

⭕这是因为在编译过程中,add.cpp中的Add内联函数是要被展开的,并不会产生地址,那么在链接时符号表重定义自然也找不到Add的地址,就会产生链接错误。


2.3 宏和内联函数

C++对C语言进行了大量的优化和补充,而内联函数就是C++中对宏的优化,基本弥补了宏了所有缺点。

宏的优点

  1. 增强代码的复用性。
  2. 提高性能

宏的缺点

  1. 没有类型安全检查
  2. 不方便调试
  3. 容易写错

在这里插入图片描述

在C++中,内联函数是对宏函数的优化。除此,C++中还建议用const和enum代替define常量定义。


3️⃣C++11中的语法糖

3.1 auto关键字

3.1.1 概念

💭在我们写代码的时候经常会遇到这样的问题。如果我们要定义一个变量,不知道它的类型、或者是类型名过长,这时候我们的代码写起来就会很麻烦,或者根本无法下手。这时候该怎么办呢?C++11标准中为auto关键字赋予了新的含义,可以用它来解决这个问题。

🌊 auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得
也就是说,auto可以自动推导变量的类型。

#include<typeinfo>
#include<iostream>

using namespace std;

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;
	return 0;
	
	//auto e; 无法通过编译,使用auto定义变量时必须对其进行初始化
}

⭕运行结果
在这里插入图片描述

使用auto定义变量必须对其进行初始化,编译器会在编译阶段需要根据初始化表达式推导auto的实际类型。因此auto并不是一种类型声明,而是一个类型声明时的占位符,编译器在编译期会将auto替换为变量实际的类型。

3.1.2 auto使用特性

  1. 这里的auto和auto*的效果是一样的。但如果要使用引用,就必须用auto&。
int main()
{
	int x = 10;

	auto a = x;
	auto b = &x;
	auto* c = &x;
	auto& d = x;

	cout << typeid(a).name() << endl;
	cout << typeid(b).name() << endl;
	cout << typeid(c).name() << endl;
	cout << typeid(d).name() << endl;

	return 0;
}

⭕运行结果
在这里插入图片描述

  1. 一行同时定义多个变量时,变量类型必须都相同,因为这种情况下编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
int main()
{
	auto a = 10, b = 20;
	auto b = 3.14, d = 2.7;
	
	return 0;
}

3.1.3 auto不能推导的场景

  1. 不能用作函数形参的类型
void Fun(auto x)//err
{}

原因:函数调用是先建立栈帧,再传参。栈帧的大小要根据参数的个数、类型等计算好。如果用auto便无法确定形参类型大小。

  1. auto不能直接用来声明数组
void TestAuto()
{
int a[] = {1,2,3};
auto b[] = {456};
}
  1. 为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法
  2. auto在实际中最常见的优势用法就是跟下面会讲到的C++11提供的新式for循环,还有
    lambda表达式等进行配合使用。

3.2 基于范围的for循环

在C++11标准中,程序员可以更加方便的使用for循环。for循环后的括号由冒号 “ : ” 分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。

int main()
{
	int array[] = { 0,1,2,3,4,5,6,7,8,9 };

	for (auto e : array)
	{
		cout << e << endl;
	}

	for (auto& e : array)//引用才能改变数组中的数据
	{
		e *= 2;
	}

	return 0;
}

PS:与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环。

⭕注意:for循环迭代的范围必须是确定的。对于数组而言,就是数组中第一个元素和最后一个元素的范围。以下代码就有问题,因为for的范围不确定

void Fun(int array[])
{
	for (auto e : array)//因为传入的不是整个数组,而只是数组的首地址,编译器无法确定范围
	{
		cout << e << endl;
	}
}

3.3 nullptr空指针

在良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针。如果一个指针没有合法的指向,我们基本都是按照如下方式对其进行初始化:

void TestPtr()
{
int* p1 = NULL;
int* p2 = 0;
// ……
}

NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码

#ifndef NULL
#ifdef __cplusplus//C++里面定义NULL为常数0
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif

可以看到,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;
}

⭕运行结果
在这里插入图片描述

  • 程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的初衷相悖。
  • 在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void*)0。

因此,C++中引入了一个新的关键字nullptr,用于代替C语言中的NULL空指针

注意:

  1. 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入
    的。
  2. 在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
  3. 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。

完。


网站公告

今日签到

点亮在社区的每一天
去签到