函数和头文件
1. 函数介绍
我们自己编写的函数称为用户定义函数(user-defined functions)
用户定义函数示例:
1 | returnType functionName() // 函数头 (告诉编译器函数的存在、函数的名称以及返回类型) |
一个定义和调用用户定义函数的示例:
1 |
|
输出:
1 | Starting main() |
函数里面可以调用另一个函数,但是C++不支持嵌套函数,即一个函数不能定义在另一个函数的内部,如下示例:
1 |
|
main()
函数是不允许被显示调用的,如下示例:
1 | void foo() |
C 确实允许显式调用
main()
,因此出于兼容性原因,某些 C++ 编译器将允许这样做。
2. 函数返回值
在编写用户定义的函数时,我们可以确定函数是否会将值返回给调用者。首先,函数必须指示将返回什么类型的值。这是通过设置函数的返回类型来完成的,该类型是在函数名称之前定义的类型。其次,在将返回值的函数中,我们使用 return 语句来指示返回给调用者的特定值。return 语句由 return
关键字组成,后跟一个表达式(有时称为 return 表达式),以分号结尾。一个示例:
1 |
|
该程序会打印:
1 | 5 |
在C++中,main()
有两个特殊要求:
-
main()
需要返回int
。 -
不允许对
main()
进行显式函数调用。
两个错误的示例:
1 | void foo() |
main()
函数的返回值习惯称为状态代码,用来指示程序是否成功,如果程序正常运行,应该返回0。
非零状态代码通常用于指示某种故障,C++ 标准仅定义了 3 个状态码的含义: 0
、 EXIT_SUCCESS
和EXIT_FAILURE
。 0
和EXIT_SUCCESS
都表示程序执行成功。 EXIT_FAILURE
表示程序未成功执行。示例:
1 |
|
如果没有提供return
语句,main()
函数将隐式返回0
有返回值的函数称为返回值函数。如果返回类型不是void
则函数是有值返回的。返回值函数必须返回该类型的值(使用 return 语句),否则将导致未定义的行为。下面是一个产生未定义行为的函数示例:
1 |
|
返回值函数每次调用时只能将单个值返回给调用者,但是有多种方法可以解决函数只能返回单个值的限制。
3. Void函数
void 函数不需要 return
语句。在void函数放置空return
语句是多余的,如下示例:
1 | void printHi() |
void 函数不能用于需要值的语句中,示例:
1 |
|
4. 函数参数和参数
函数参数是函数头中使用的变量。它们是使用函数调用者提供的值进行初始化的。多个参数用逗号分隔。当调用函数时,函数的所有参数都被创建为变量,并且每个参数的值都被复制到匹配的参数中(使用复制初始化)。这个过程称为按值传递。利用值传递的函数参数称为值参数。
函数的参数未在函数体中使用的情况称为未引用参数。编译器可能会警告变量已定义但未使用。
在函数定义中,函数参数的名称是可选的。因此,当函数参数需要存在但函数体中没有使用时,可以简单地省略名称。没有名称的参数称为未命名参数:
1 | void doSomething(int) // ok: unnamed parameter will not generate warning |
通过使用参数和返回值,我们可以创建将数据作为输入的函数,使用数据进行一些计算,然后将值返回给调用者。下面是一个非常简单的函数示例,该函数将两个数字相加并将结果返回给调用者:
1 |
|
Google C++ 风格指南建议使用注释来记录未命名参数的内容:
1 | void doSomething(int /*count*/) |
5. 局部作用域
在函数体内定义的变量称为局部变量,函数参数通常也被认为是局部变量。
5.1 局部变量的生存周期
局部变量的生存周期:函数参数在进入函数时创建并初始化,函数体内的变量在定义时创建并初始化。局部变量在定义它的花括号组末尾(或者对于函数参数,在函数末尾)以与创建相反的顺序销毁。下面这个程序演示了名为 x
的变量的生存周期:
1 |
|
对象被销毁后的任何使用都会导致未定义的行为。
5.2 局部作用域(块作用域)
局部变量的标识符具有局部作用域(local scope)。具有局部作用域(技术上称为块作用域(block scope))的标识符从定义点开始到包含该标识符的内层大括号对结束(对于函数参数,是在函数结束时)都是可用的。这确保了局部变量不能在定义点之前(即使编译器选择在此之前创建它们)或在它们被销毁之后使用。在一个函数中定义的局部变量在被调用的其他函数中也不在作用域内。
下面程序演示了一个名为 x
的变量的作用域:
1 |
|
生命周期是一个运行时属性,而作用域是一个编译时属性。
5.3 局部变量定义的位置
在现代 C++ 中,最佳实践是函数体内的局部变量应尽可能合理地定义为接近其首次使用:
1 |
|
由于旧的、更原始的编译器的限制,C 语言过去要求所有局部变量都在函数的顶部定义:
1 |
|
C99 语言标准中取消了此限制。
5.4 临时对象
临时对象(有时也称为匿名对象)是一个未命名的对象,用于保存仅在短时间内需要的值。编译器会在需要时生成临时对象。示例:
1 |
|
在上面的程序中,函数 getValueFromUser()
将存储在局部变量 input
中的值返回给调用者。由于 input
将在函数结束时销毁,因此调用方会收到该值的副本,因此即使在 input
被销毁后,它也可以有一个可以使用的值。这个返回值存储在临时对象中。然后,这个临时对象被传递给 std::cout
进行打印。
6. 前向声明和定义
6.1 前向声明
前向声明(forward declarations)允许我们在实际定义标识符之前告诉编译器标识符的存在。要为函数编写前向声明,我们使用函数声明语句(也称为函数原型)。函数声明由函数的返回类型、名称和参数类型组成,以分号结尾。可以选择包含参数的名称。函数体不包含在声明中。示例:
1 |
|
函数声明不需要指定参数的名称(因为它们不被视为函数声明的一部分)。在上面的代码中,你也可以像这样前向声明:
1 | int add(int, int); // valid function declaration |
但是最佳的实践是吧参数名称保留在前向声明中。
为什么要使用前向声明:
-
大多数情况下,前向声明用于告诉编译器存在已在不同代码文件中定义的某些函数。在这种情况下,无法重新排序,因为调用方和被调用方位于完全不同的文件中
-
前向声明也可以用于以与顺序无关的方式定义我们的函数。这允许我们以任何顺序定义函数,以最大限度地提高组织性
-
有时我们有两个函数相互调用。在这种情况下,也无法重新排序,因为无法对函数进行重新排序,使每个函数都位于另一个函数之前。前向声明为我们提供了一种解决此类循环依赖关系的方法
6.2 声明和定义
声明(declaration) 告诉编译器标识符的存在及其关联的类型信息。以下是一些声明示例:
1 | int add(int x, int y); // tells the compiler about a function named "add" that takes two int parameters and returns an int. No body! |
定义(definition) 是实际实现(对于函数和类型)或实例化(对于变量)标识符的声明,示例:
1 | // because this function has a body, it is an implementation of function add() |
在 C++ 中,所有定义都是声明。相反,并非所有声明都是定义。不是定义的声明称为纯声明。纯声明的类型包括函数、变量和类型的前向声明。
6.3 单一定义规则(ODR)
单一定义规则(ODR):
-
在一个文件中,给定范围内的每个函数、变量、类型或模板只能有一个定义
-
在程序中,给定范围内的每个函数或变量只能有一个定义
-
类型、模板、内联函数和内联变量允许在不同文件中具有重复定义,只要每个定义相同即可
7. 具有多个代码文件的程序
一个简单示例:main.cpp
内容:
1 |
|
add.cpp
内容:
1 | int add(int x, int y) |
8. 命名冲突和命名空间
8.1 命名冲突
C++ 要求所有标识符都必须是明确的。如果两个相同的标识符以编译器或链接器无法区分的方式引入同一程序,则编译器或链接器将产生错误。这种错误一般称为命名冲突(naming collision)。如果将冲突标识符引入同一文件,则结果将是编译器错误。如果将冲突标识符引入属于同一程序的单独文件中,则结果将是链接器错误。
示例:a.cpp
内容:
1 |
|
main.cpp
内容:
1 |
|
8.2 命名空间
命名空间(namespaces)提供了另一种类型的作用域区域(称为命名空间作用域(namespaces scope)),它允许我们在其中声明名称以消除歧义。命名空间内声明的任何名称都不会被误认为是其他作用域中的相同名称。只有声明和定义可以出现在命名空间的范围中,在不同的命名空间内定义两个相同名称的函数,并且不会发生命名冲突。
在 C++ 中,任何未在类、函数或命名空间内定义的名称都被视为隐式定义的命名空间的一部分,称为全局命名空间(有时也称为全局作用域)。虽然变量可以在全局命名空间中定义,但这通常应该避免。
当 C++ 最初设计时,C++ 标准库中的所有标识符(包括 std::cin
和 std::cout
)都可以在没有std::
前缀的情况下使用(它们是全局命名空间的一部分)。然而,这意味着标准库中的任何标识符都可能与您为自己的标识符选择的任何名称(也在全局命名空间中定义)发生冲突。当您包含标准库的不同部分时,曾经有效的代码可能会突然出现命名冲突。或者更糟糕的是,在一个 C++ 版本下编译的代码可能无法在下一版本的 C++ 下编译,因为引入标准库的新标识符可能与已编写的代码发生命名冲突。因此,C++ 将标准库中的所有功能移至名为std
(“standard”的缩写)的命名空间中。
::
符号是一个称为范围解析运算符的运算符。 ::
符号左侧的标识符标识,::
符号右侧的名称所在的命名空间。如果::
符号左侧没有提供标识符,则假定为全局命名空间。
using 指令允许我们访问命名空间中的名称,而无需使用命名空间前缀。示例:
1 |
|
避免在程序顶部或头文件中使用 using 指令(例如
using namespace std;
)。它们违反了最初添加名称空间的原因。
9. 预处理器
在编译之前,每个代码 (.cpp) 文件都会经历一个预处理阶段。在此阶段,称为预处理器的程序对代码文件的文本进行各种更改。当预处理器完成对代码文件的处理时,结果称为翻译单元。该翻译单元随后由编译器编译。预处理、编译、链接的整个过程称为翻译。
当预处理器运行时,它会扫描代码文件(从上到下),查找预处理器指令。预处理器指令(通常简称为指令)是以#
符号开头并以换行符(不是分号)结尾的指令。这些指令告诉预处理器执行某些文本操作任务。
当#include
文件时,预处理器会将 #include
指令替换为所包含文件的内容。然后对包含的内容进行预处理(这可能会导致递归地预处理额外的#include
),然后对文件的其余部分进行预处理。
#define
指令可用于创建宏。在 C++ 中,宏是定义如何将输入文本转换为替换输出文本的规则。宏有两种基本类型:类对象宏_和_类函数宏。类函数宏的行为类似于函数,并且具有相似的用途。类似对象的宏可以通过以下两种方式之一定义:
1 |
按照惯例,宏名称通常全部大写,并用下划线分隔。当预处理器遇到此指令时,宏标识符和替换文本之间会建立关联。所有进一步出现的宏标识符(在其他预处理器命令中使用之外)都将被替换文本替换。
[!note]
尽量避免使用带有替换文本的宏,因为C++有更好的方法,这是C遗留的方式
条件编译预处理器指令允许指定在什么条件下将或不会编译某些内容。#ifdef
预处理器指令允许预处理器检查先前是否已通过#define
定义了标识符。如果是这样,则编译#ifdef
和匹配的#endif
之间的代码。如果不是,则忽略该代码。示例:
1 |
|
条件编译的一种更常见的用法是使用#if 0
来排除代码块的编译(就好像它位于注释块内一样):
1 |
|
1 |
|
10. 头文件
按照惯例,头文件用于将一堆相关的前向声明传播到代码文件中。应该避免将函数或变量定义放在头文件中。如果头文件包含在多个源文件中,这样做通常会导致违反单一定义规则 (ODR)。
在 C++ 中,代码文件的最佳做法是 #include
其配对的头文件(如果存在)。例如add.cpp
包含add.h
通常不应该
#include
.cpp
文件
当我们使用尖括号时,我们告诉预处理器这是一个不是我们自己编写的头文件。预处理器将仅在include directories
指定的目录中搜索标头。 include directories
被配置为项目/IDE 设置/编译器设置的一部分,通常默认为包含编译器和/或操作系统附带的头文件的目录。预处理器不会在项目的源代码目录中搜索头文件。当我们使用双引号时,我们告诉预处理器这是我们编写的头文件。预处理器首先会在当前目录中查找头文件。如果在那里找不到匹配的标头,它将搜索include directories
。
iostream.h
是与iostream
不同的头文件。在进行语言标准化时,由于需要将标准库中所使用的所有名称迁移至std命名空间,这样会导致所有的旧程序不再工作。为了解决此问题,C++ 引入了缺少.h
扩展名的新头文件。这些新的头文件声明了std命名空间内的所有名称。这样,包含#include <iostream.h>
的旧程序不需要重写,而新程序可以#include <iostream>
。
现代 C++ 现在包含 4 组头文件:
标头类型 | 命名约定 | 例子 | 放置在命名空间中的标识符 |
---|---|---|---|
C++ specific (new) | <xxx> | iostream | std namespace |
C compatibility (new) | <cxxx> | cstddef | std namespace (required) global namespace (optional) |
C++ specific (old) | <xxx.h> | iostream.h | Global namespace |
C compatibility (old) | <xxx.h> | stddef.h | Global namespace (required) std namespace (optional) |
为了最大限度地提高编译器标记缺少的 include 的几率,建议按以下顺序 #includes
头文件:
-
此代码文件的配对头文件(例如,
add.cpp
应#include “add.h”)
-
来自同一项目的其他头文件(例如
#include “mymath.h”)
-
第三方库头文件(例如
#include <boost/tuple/tuple.hpp>
) -
标准库头文件(例如
#include <iostream>
)
11. 头文件保护
我们可以通过一种称为头文件保护(header guard)的机制(也称为包含保护(include guard))来避免引入重复包含头文件。头文件保护是条件编译指令,其形式如下:
1 |
|
一个示例:
square.h
1 |
|
wave.h
1 |
main.cpp
1 |
|
现代编译器使用#pragma
预处理器指令支持更简单的替代形式的头文件保护:
1 |
|
由于#pragma once
不是由 C++ 标准定义的,因此某些编译器可能不会实现它。