Cpp学习4 第 2 章 C++基础:函数和文件

铁名_IronName Lv5

2.1 函数简介

1
2
3
4
returnType functionName() // This is the function header (tells the compiler about the existence of the function)
{
// This is the function body (tells the compiler what the function does)
}

第一行非正式地称为函数头 ,它告诉编译器函数的存在、函数的名称以及我们将在以后的课程中介绍的其他一些信息(例如返回类型)。
花括号及其间的语句称为函数体 。决定函数具体功能的语句就放在这里。

冷知识

“foo”是一个无意义的词,通常用作函数或变量的占位符名称,用于在概念演示中名称无关紧要的情况下使用。

2.2 函数返回值(有返回值的函数)

对已有程序的改善:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

int getValueFromUser() // this function now returns an integer value
{
std::cout << "Enter an integer: ";
int input{};
std::cin >> input;

return input; // return the value the user entered back to the caller
}

int main()
{
int num { getValueFromUser() }; // initialize num with the return value of getValueFromUser()

std::cout << num << " doubled is: " << num * 2 << '\n';

return 0;
}

再探 main() 函数

应该将 main() 函数定义在代码文件的底部,位于其他函数之下,并避免显式调用它。

人们普遍误以为 main 总是第一个执行的函数。

全局变量在 main 执行之前初始化。如果此类变量的初始化器调用了一个函数,那么该函数将在 main 之前执行。我们将在 7.4 课——全局变量简介中讨论全局变量。

main() 函数的返回值有时被称为状态码(Status codes) (或较少见的退出码 ,极少数情况下称为返回码 )。状态码用于指示程序是否成功运行。

C++ 标准仅定义了 3 个状态码的含义: 0 、 EXIT_SUCCESS 和 EXIT_FAILURE 0 EXIT_SUCCESS 都表示程序执行成功。EXIT_FAILURE EXIT_FAILURE 程序执行失败。如果要最大限度地提高可移植性,则应仅使用 0 或 EXIT_SUCCESS 表示成功终止,或使用 EXIT_FAILURE 表示终止失败。
状态码会返回给操作系统。操作系统通常会将该状态码提供给启动该程序的程序。这为任何启动其他程序的程序提供了一种简单的机制,以确定被启动的程序是否成功运行。

有返回值的函数称为值返回函数 。如果函数的返回类型不是 void ,则该函数是值返回函数。有返回值的函数 必须 返回该类型的值(使用 return 语句),否则将导致未定义行为。

最佳实践

请确保所有非 void 返回类型的函数在任何情况下都返回一个值。

最佳实践

遵循 DRY 原则:“Don’t repeat yourself”。如果需要多次执行同一操作,请考虑如何修改代码以尽可能减少冗余。可以使用变量来存储需要多次使用的计算结果(这样就无需重复计算)。可以使用函数来定义需要多次执行的语句序列。循环(我们将在后面的章节中介绍)则可以用来多次执行同一条语句。
与其他最佳实践一样,DRY 原则旨在提供指导,而非绝对规则。读者 Yariv 指出 ,当代码被拆分成过小的片段时,DRY 原则反而会损害代码的整体可读性。

DRY 的(讽刺性)反义词是 WET (“Write everything twice”)

2.3 — 空函数(无返回值的函数)

不能在需要值的表达式中使用空函数。
从 void 函数返回值会导致编译错误。

2.4 — 函数参数和参数简介

函数参数是函数头中使用的变量。函数参数的工作方式几乎与函数内部定义的变量相同,但有一个区别:它们的初始值由函数的调用者提供。
当一个函数被调用时,函数的所有参数都会被创建为变量,并且每个参数的值都会被_复制_到对应的变量中(使用复制初始化)。这个过程称为按值传递 。使用按值传递的函数参数称为值参数 。

在某些情况下,你会遇到一些函数,它们的参数在函数体中并没有被使用。这些参数被称为未引用参数 。
在函数定义中,函数参数的名称是可选的。因此,如果某个函数参数需要存在,但函数体中并未使用它,则可以省略其名称。没有名称的参数称为未命名参数 。Google C++ 风格指南建议使用注释来记录未命名参数的含义:

1
2
3
void doSomething(int /*count*/)
{
}

当函数参数存在但未在函数体中使用时,不要为其命名。您可以选择在注释中添加名称。

2.5 — 局部作用域简介

在函数体内定义的变量称为局部变量 
局部变量的标识符具有局部作用域。具有局部作用域 (技术上称为块作用域 )的标识符只能从定义点到包含该标识符的最内层花括号的末尾(或者对于函数参数,到函数末尾)使用。

生命周期是运行时属性,而作用域是编译时属性,所以尽管我们在同一个程序中讨论这两个属性,但它们的执行时机却不同。

临时对象 (有时也称为匿名对象 )是一个未命名的对象,用于保存仅在短时间内需要的值。临时对象由编译器在需要时生成。临时对象会在创建它们的完整表达式执行完毕后被销毁。这意味着临时对象总会在下一条语句执行之前被销毁。

在现代 C++(尤其是自 C++17 以来)中,编译器有很多技巧来避免在以前需要的地方创建临时变量。例如,当我们使用返回值初始化变量时,通常会导致创建一个临时变量来保存返回值,然后使用该临时变量来初始化变量。然而,在现代 C++ 中,编译器通常会跳过创建临时变量的步骤,直接使用返回值初始化变量。

2.6 — 函数的作用以及如何有效地使用它们

  • 程序中多次出现的语句组通常应该被封装成函数。例如,如果我们多次以相同的方式读取用户输入,这非常适合用函数来表示。如果我们在多个地方以相同的方式输出内容,这也非常适合用函数来表示。
  • 输入输出定义明确的代码非常适合写成函数(尤其当代码比较复杂时)。例如,如果我们有一个需要排序的列表,那么即使排序操作只执行一次,这段排序代码也完全可以写成一个函数。它的输入是未排序的列表,输出是排序后的列表。另一个不错的函数示例是模拟掷六面骰子的代码。你当前的程序可能只在某一处用到它,但如果把它写成一个函数,以后在扩展程序或开发新程序时就可以重复使用了。
  • 一个函数通常应该执行一个(且仅一个)任务。
  • 当一个函数变得过长、过复杂或难以理解时,可以将其拆分成多个子函数。这称为重构 。我们将在 3.10 课——“在问题出现之前发现它们” 中详细讨论重构。

2.7 — 前置声明和定义

最佳实践

当处理程序中的编译错误或警告时,请先解决列出的第一个问题,然后再重新编译。

前置声明允许我们在实际定义标识符 之前 告知编译器该标识符的存在。
要编写函数的前向声明,我们使用函数声明语句(也称为函数原型 )。函数声明包含函数的返回类型、名称和参数类型,并以分号结尾。参数名称可以选择性地包含在内。函数体不包含在声明中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int add(int x, int y); // forward declaration of add() (using a function declaration)

int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n'; // this works because we forward declared add() above
return 0;
}

int add(int x, int y) // even though the body of add() isn't defined until here
{
return x + y;
}

值得注意的是,函数声明不需要指定参数名称(因为参数不被视为函数声明的一部分)。在上面的代码中,也可以像这样进行前向声明:

1
int add(int, int); // valid function declaration

然而,我们更倾向于给参数命名(使用与函数名称相同的名称)。这样,您只需查看函数声明即可了解各个参数的含义。

如果进行了前置声明,但函数从未被调用,程序可以正常编译和运行。但是,如果进行了前向声明并且函数被调用,但程序从未定义该函数,程序虽然可以编译,但链接器会报错,提示无法解析该函数​​调用。

定义与声明

声明告诉 编译器 某个标识符的 存在 及其关联的类型信息。
定义是一种声明,它实际实现(对于函数和类型)或实例化(对于变量)标识符。
在 C++ 中,所有定义都是声明。因此, int x; 既是定义又是声明。反之,并非所有声明都是定义。非定义的声明称为纯声明 。纯声明的类型包括函数、变量和类型的前向声明。

单一定义规则(ODR)

  1. 在一个_文件_中,给定作用域内的每个函数、变量、类型或模板只能有一个定义。出现在不同作用域中的定义(例如,在不同函数中定义的局部变量,或在不同命名空间中定义的函数)不违反此规则。
  2. 在一个_程序_中,给定作用域内的每个函数或变量只能有一个定义。这条规则的存在是因为程序可以包含多个文件(我们将在下一课中讨论这一点)。链接器不可见的函数和变量不受此规则约束(将在 7.6 课——内部链接中进一步讨论)。
  3. 类型、模板、内联函数和内联变量在不同的文件中可以有重复的定义,只要每个定义完全相同即可。
    违反 ODR 第 1 部分会导致编译器发出重定义错误。违反 ODR 第 2 部分会导致链接器发出重定义错误。违反 ODR 第 3 部分会导致未定义行为。

具有相同标识符但参数集不同的函数也被视为不同的函数,因此这样的定义并不违反 ODR。(重载)

2.8 — 包含多个代码文件的程序

main.cpp(带有前置声明):

1
2
3
4
5
6
7
8
9
#include <iostream>

int add(int x, int y); // needed so main.cpp knows that add() is a function defined elsewhere

int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}

add.cpp(保持不变):

1
2
3
4
int add(int x, int y)
{
return x + y;
}

使用这种方法,我们可以让文件访问另一个文件中的函数。

因为编译器会单独编译每个代码文件(然后忘记它已经看到的内容),所以每个使用 std::cout 或 std::cin 的代码文件都需要 #include <iostream> 。

2.9 — 命名冲突及命名空间简介

C++ 要求所有标识符都必须是非歧义的。如果两个相同的标识符以编译器或链接器无法区分的方式引入到同一个程序中,编译器或链接器将会产生错误。这种错误通常被称为命名冲突 (或命名不匹配 )。
如果冲突的标识符出现在同一个文件中,则会导致编译器错误。如果冲突的标识符出现在属于同一程序的不同文件中,则会导致链接器错误。

命名空间提供了一种特殊的作用域区域(称为命名空间作用域 ),允许您在其中声明或定义名称,以消除歧义。在命名空间中声明的名称与其他作用域中声明的名称相互隔离,从而允许这些名称同时存在而不会发生冲突。

在 C++ 中,任何未在类、函数或命名空间中定义的名称都被视为隐式定义的命名空间的一部分,称为全局命名空间 (有时也称为全局作用域 )。

标准命名空间std

C++ 最初设计时,C++ 标准库中的所有标识符(包括 std::cin 和 std::cout)都可以直接使用,无需添加 std:: 前缀(它们属于全局命名空间)。然而,这意味着标准库中的任何标识符都可能与你自定义的标识符(同样定义在全局命名空间中)的名称发生冲突。原本可以正常运行的代码,在引入标准库的不同部分后,可能会突然出现命名冲突。更糟糕的是,在某个 C++ 版本下编译通过的代码,在下一个 C++ 版本下可能无法编译,因为标准库中新增的标识符可能与已有的代码存在命名冲突。因此,C++ 将标准库中的所有功能都移到了一个名为 std (“standard”的缩写)的命名空间中。

告诉编译器我们要使用 std 命名空间中的 cout 的最直接方法是显式地使用 std:: 前缀。 std::cout

警告

避免在程序顶部或头文件中使用 using 指令(例如 using namespace std; )。这样做违背了最初添加命名空间的初衷。

2.10 — 预处理器简介

在编译之前,每个代码文件(.cpp)都要经过预处理阶段。在这个阶段,一个名为预处理器的程序会对代码文件的文本进行各种修改。预处理器实际上并不会以任何方式修改原始代码文件——所有修改都只是暂时存储在内存中或使用临时文件。
预处理器完成代码文件处理后,得到的结果称为翻译单元 。编译器接下来要编译的就是这个翻译单元。

预处理器指令

预处理器运行时,会从上到下扫描代码文件,查找预处理器指令。 预处理器指令 (通常简称为_指令_ )是以 # 符号开头,以换行符(而非分号)结尾的指令。这些指令指示预处理器执行特定的文本操作任务。

1
2
3
4
5
6
7
#include <iostream>

int main()
{
std::cout << "Hello, world!\n";
return 0;
}

当预处理器运行此程序时,预处理器会将 #include <iostream> 替换为名为“iostream”的文件的内容,然后预处理包含的内容和文件的其余部分。

Macro defines  宏定义

#define 指令可用于创建宏。在 C++ 中, 是一条规则,用于定义如何将输入文本转换为替换后的输出文本。
宏有两种基本类型: 对象型宏函数型宏 。
类函数宏的 行为类似于函数,用途也类似。但它们的使用通常被认为是不安全的,而且几乎所有它们能做的事情都可以用普通函数完成。

宏的标识符遵循与普通标识符相同的命名规则:可以使用字母、数字和下划线,但不能以数字开头,也不能以下划线开头。按照惯例,宏名称通常全部为大写字母,并用下划线分隔。

最佳实践

宏名称应全部使用大写字母,单词之间用下划线分隔。

对象型宏 可以通过以下两种方式之一定义:

1
2
3
4
5
#define IDENTIFIER
#define IDENTIFIER substitution_text

#define USE_YEN
#define MY_NAME "Alex"

不带替换文本的_对象型宏_ :标识符的大部分后续出现将被删除,并替换为空!
在 C 语言中,使用带有替换文本的对象型宏来为字面量赋名是一种常用的方法。但现在 C++ 提供了更好的方法(内联变量),因此不再需要这种方法。带有替换文本的对象型宏现在主要用于遗留代码,我们建议尽可能避免使用它们。

Conditional compilation  条件编译

条件编译 预处理器指令允许您指定在什么条件下编译或不编译某些代码。条件编译指令有很多种,但我们只介绍几种最常用的: #ifdef 、 #ifndef 和 #endif 。

#ifdef 预处理器指令允许预处理器检查某个标识符是否已通过 #define 定义。如果已定义,则编译 #ifdef 和匹配的 #endif 之间的代码。否则,忽略该代码。

除了 #ifdef PRINT_BOB 和 #ifndef PRINT_BOB ) 之外,您还会看到 #if defined(PRINT_BOB) 和 #if !defined(PRINT_BOB) 。它们的功能相同,但语法更接近 C++ 风格。

1
2
3
4
5
6
7
8
9
#if defined (_MSVC_LANG)
return _MSVC_LANG;
#elif defined (_MSC_VER)
// If we're using an older version of Visual Studio, bail out
return -1;
#else
// __cplusplus is the intended way to query the language standard code (as defined by the language standards)
return __cplusplus;
#endif

条件编译的另一个常见用法是使用 #if 0 来排除一段代码块不被编译。这提供了一种便捷的方法来“注释掉”包含多行注释的代码。要临时重新启用被 #if 0 包裹的代码,你可以将 #if 0 更改为 #if 1

大多数情况下,当在另一个预处理器命令中使用宏标识符时,不会发生宏替换。例外:大多数形式的 #if 和 #elif 都会在预处理器命令中进行宏替换。

#defines 的作用域

预处理器并不理解 C++ 中函数之类的概念。因此,这个程序的行为与 #define MY_NAME “Alex” 定义在函数 foo 之前或之后完全相同。为了避免混淆,通常应该在函数外部定义标识符。
预处理器完成后,该文件中所有已定义的标识符都会被丢弃。这意味着预处理器指令仅在定义点到定义它的文件末尾范围内有效。在一个文件中定义的指令不会影响其他文件(除非它们被 #include 到另一个文件中)。

2.11 — 头文件

C++ 代码文件(扩展名为 .cpp)并不是 C++ 程序中常见的唯一文件类型。另一种文件类型称为头文件 。头文件通常以 .h 为扩展名,但有时也会看到以 .hpp 为扩展名或没有扩展名的头文件。
头文件允许我们将代码声明集中在一个地方,然后在需要的地方导入它们。这可以节省多文件程序中的大量输入工作。

最佳实践

命名头文件时,最好使用 .h 后缀(除非你的项目已经遵循其他约定)。
这是 C++ 头文件的长期惯例,大多数 IDE 仍然默认使用 .h 而不是其他选项。

头文件通常与代码文件配对使用,头文件为相应的代码文件提供前向声明。由于我们的头文件将包含 add.cpp 中定义的函数的前向声明,因此我们将新的头文件命名为 add.h。

最佳实践

如果一个头文件与一个代码文件配对(例如 add.h 与 add.cpp),则它们都应该具有相同的基本名称(add)。

避免在头文件中定义函数或变量。这样做通常会导致违反单一定义规则(ODR),尤其是在头文件被多个源文件包含的情况下。

当编译 add.cpp 时,前向声明 int add(int x, int y) 会被复制到 #include 指令所在的位置。当编译器执行到定义 double add(int x, int y) 时,会发现前向声明和定义的返回类型不匹配。由于函数不能仅凭返回类型就有所不同,编译器会报错并立即中止编译。在大型项目中,这可以节省大量时间,并有助于快速定位问题所在。

最佳实践

源文件应包含其对应的头文件(如果存在)。

最佳实践

避免包含 .cpp 文件。

尖括号与双引号

使用尖括号时,我们是在告诉预处理器这是一个我们自己没有编写的头文件。预处理器只会在 include directories 指定的目录中查找该头文件。 include directories 配置方式是在您的项目/IDE/编译器设置中,通常默认为包含编译器和/或操作系统自带头文件的目录。预处理器不会在项目源代码目录中查找该头文件。

当我们使用双引号时,我们是在告诉预处理器这是一个我们编写的头文件。预处理器首先会在当前目录中查找该头文件。如果找不到匹配的头文件,它还会继续在 include directories 中查找。

最佳实践

使用标准库头文件时,请勿添加 .h 扩展名。用户自定义头文件仍应使用 .h 扩展名。

包含来自其他目录的头文件

更好的方法是告诉编译器或 IDE,你在其他位置有一堆头文件,这样当它在当前目录下找不到这些头文件时,就会去那里查找。通常可以通过在 IDE 项目设置中设置 包含路径搜索目录 来实现这一点。

Visual Studio:在_解决方案资源管理器_中右键单击您的项目,选择 “属性” ,然后选择 “VC++ 目录”选项 卡。在这里,您会看到一个名为 “包含目录”的 行。添加您希望编译器在其中查找其他头文件的目录。

最佳实践

每个文件都应该显式地包含所有编译所需的头文件。不要依赖其他头文件间接包含的头文件。

最佳实践

为了最大限度地提高编译器标记缺失头文件的几率,请按如下顺序排列 #includes 指令(跳过任何不相关的指令):

  • 此代码文件对应的头文件(例如 add.cpp 应该 #include "add.h" )
  • 同一项目中的其他头文件(例如 #include "mymath.h" )
  • 第三方库头文件(例如 #include <boost/tuple/tuple.hpp> )
  • 标准库头文件(例如 #include <iostream> )

每个分组的标题应按字母顺序排序(除非第三方库的文档另有指示)。

建议将功能说明或使用方法放在头文件中,这样更容易被看到。而描述工作原理的文档则应该保留在源文件中。

2.12 — 头文件保护

如果一个程序多次定义同一个变量标识符,就会导致编译错误。虽然这些程序很容易修复(删除重复的定义),但对于头文件来说,很容易出现头文件中的定义被多次包含的情况。这种情况通常发生在头文件使用 #include 指令包含另一个头文件时。
好消息是,我们可以通过一种称为头文件保护 (也称包含保护 )的机制来避免上述问题。头文件保护是条件编译指令。
所有头文件都应该添加头文件保护符。按照惯例,它被设置为头文件的完整文件名,全部大写,空格或标点符号用下划线表示。例如, _square.h 的_头文件保护符为:

1
2
3
4
5
6
7
8
9
#ifndef SQUARE_H
#define SQUARE_H

int getSquareSides()
{
return 4;
}

#endif

#pragma

现代编译器支持使用 #pragma 预处理器指令来实现更简单、更简洁的头文件保护形式:

1
2
3
#pragma once

// your code here

#pragma once 作用与头文件保护机制相同:防止同一个头文件被多次包含。传统的头文件保护机制需要开发者自行保护头文件(通过预处理器指令 #ifndef 、 #define 和 #endif )。而使用 #pragma once ,我们则是请求编译器来保护头文件。至于编译器如何做到这一点,则取决于具体的实现细节。
有一种情况下, #pragma once 通常会失败。如果一个头文件被复制,导致它在文件系统中存在于多个位置,并且这两个副本都被包含了,那么头文件保护机制会成功地对相同的头文件进行去重,但 #pragma once 不会(因为编译器无法识别它们的内容实际上相同)。

2.13 — 如何设计你的第一个程序

编写程序时的一些建议

一开始,尽量让你的程序简单易懂 。
逐步添加功能 。
一次只专注于一个领域 。
边写边测试代码 。
不要过早地在代码完善上投入过多精力。
优化应着眼于可维护性,而非性能 。“过早优化是万恶之源。”

关键

事先花点时间思考如何构建程序结构,将有助于编写出更好的代码,并减少查找和修复错误所花费的时间。

2.x — 第二章总结和测验
pass.

  • 标题: Cpp学习4 第 2 章 C++基础:函数和文件
  • 作者: 铁名_IronName
  • 创建于 : 2026-08-06 18:11:52
  • 更新于 : 2026-08-08 09:45:32
  • 链接: https://blog.ironname.top/2026/Cpp/Cpp学习4/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论