8.1 C++ 内联函数
常规的函数调用过程涉及一些开销:程序需要跳转到函数的内存地址,保存当前执行状态(如寄存器值),将参数复制到栈上,执行函数代码,存储返回值,恢复执行状态,然后跳转回调用点。对于非常短小且频繁调用的函数,这些开销可能会变得显著,影响程序性能。
内联函数 (Inline Function) 是 C++ 提供的一种优化机制,旨在减少这种函数调用开销。其基本思想是:建议编译器在编译时将函数的实际代码直接替换到每个调用该函数的地方,而不是执行常规的函数调用跳转。
用法
要建议编译器将一个函数视为内联函数,可以在函数定义前加上 inline
关键字。
语法:
1 | inline return_type function_name(parameter_list) { |
示例:
1 |
|
代码解释:
-
square
函数被声明为inline
。 - 在
main
函数中调用square(a)
时,编译器可能会直接用a * a
的代码替换这次调用,避免了函数调用的开销。 - 同样,
square(1.5 + 2.5)
可能被替换为(1.5 + 2.5) * (1.5 + 2.5)
。
inline
的特性和注意事项
建议而非命令:
inline
关键字只是向编译器提出的一个建议。编译器会根据自己的优化策略来决定是否真的进行内联。如果函数体过于复杂(例如包含循环、递归、大量代码),或者编译器认为内联不会带来好处(甚至可能有害),它可能会忽略inline
建议,仍然执行常规的函数调用。适用于小型函数: 内联最适合那些代码量小、执行速度快且被频繁调用的函数。如果内联一个大函数,可能会导致最终生成的可执行代码体积显著增大(代码膨胀),反而降低性能(因为更大的代码可能导致更多的缓存未命中)。
定义位置: 为了让编译器能够在调用点展开函数代码,内联函数的定义(而不仅仅是原型)通常需要放在调用该函数的每个源文件中。最常见的做法是将内联函数的定义直接放在头文件中。这样,包含该头文件的所有源文件都能看到完整的函数定义,编译器就有机会进行内联。
- 注意:将函数定义放在头文件中对于非内联函数通常是错误的(会导致链接错误,因为同一个函数会在多个编译单元中定义),但对于内联函数是允许且必要的。
类定义中的函数: 在类(
class
或struct
)定义内部实现的成员函数默认就是内联的,不需要显式添加inline
关键字。1
2
3
4
5
6class MyClass {
public:
int getValue() const { return value; } // 默认是内联的
private:
int value;
};
内联函数 vs. 宏 (#define
)
在 C 语言中,有时会使用带参数的宏(#define
)来模拟类似内联函数的效果,以避免函数调用开销。例如:
1 |
然而,宏存在一些缺点:
- 类型不安全: 宏只是简单的文本替换,不进行类型检查。
- 意外的副作用: 如果参数带有副作用(如
SQUARE(i++)
),可能会导致意想不到的结果,因为参数会被多次求值 (((i++)*(i++))
)。 - 调试困难: 宏在预处理阶段就被替换掉了,调试器通常看不到宏的原始形式。
- 作用域问题: 宏不受 C++ 的作用域规则约束。
内联函数克服了这些缺点:
- 类型安全: 内联函数遵循正常的函数类型检查规则。
- 参数求值: 参数只会被求值一次。
- 可调试: 内联函数仍然是真正的函数,可以用调试器进行调试(尽管内联后的代码可能看起来不同)。
- 遵循作用域: 内联函数遵循 C++ 的作用域和访问规则。
因此,在 C++ 中,应优先使用内联函数而不是带参数的宏来实现简单的、需要避免调用开销的功能。
总结:
内联函数是 C++ 提供的一种性能优化建议,通过在编译时将函数代码替换到调用点来减少函数调用开销。它特别适用于短小且频繁调用的函数。inline
关键字只是一个建议,编译器有最终决定权。为了使内联成为可能,通常需要将内联函数的定义放在头文件中。相比 C 风格的宏,内联函数提供了类型安全和更可预测的行为。
8.2 引用变量
C++ 引入了一种新的复合类型——引用 (Reference)**。引用是已定义变量的别名 (alias)**。它提供了一种间接访问变量的方式,但语法比指针更简洁。一旦引用被初始化指向一个变量,它就不能再引用其他变量,并且对引用的所有操作实际上都作用于它所引用的原始变量。
8.2.1 创建引用变量
引用变量在声明时必须被初始化,并且其类型必须与它所引用的变量类型相匹配。
语法:
1 | type& reference_name = existing_variable; |
-
type
: 变量的类型。 -
&
: 引用声明符,紧跟在类型名之后。 -
reference_name
: 引用的名称。 -
existing_variable
: 引用所指向的已存在的变量。
示例:
1 |
|
关键点:
- 引用必须在声明时初始化。
- 引用一旦初始化,就不能再指向其他变量。它终生都是其初始变量的别名。
- 引用本身不占用独立的内存地址(或者说,它的地址就是它所引用变量的地址)。
8.2.2 将引用用作函数参数
引用最重要和最常见的用途之一是作为函数参数,这称为**按引用传递 (Pass by Reference)**。当使用引用作为函数参数时,函数接收的是原始变量的别名,而不是副本。这意味着函数可以直接访问并修改调用者作用域中的原始变量。
语法:
1 | void function_name(type& ref_parameter) { |
示例:使用引用参数交换两个变量的值
1 |
|
按引用传递 vs. 按指针传递:
- 语法: 引用传递的调用语法更简洁自然 (
swap_ref(a, b)
),而指针传递需要显式获取地址 (swap_ptr(&a, &b)
) 并在函数内部解引用 (*p_a
)。 - 空值: 指针可以为
nullptr
,需要在使用前检查。引用通常(在标准用法下)不会是“空”的,因为它必须引用一个已存在的对象。这使得引用在某些情况下更安全。 - 目的: 两者都可以用来允许函数修改调用者的变量,以及避免大型对象的复制开销。
8.2.3 引用的属性和特别之处
- 必须初始化: 如前所述,引用在声明时必须绑定到一个已存在的对象。
- 不可重新绑定: 引用不能在初始化后更改其引用的对象。
- 行为像原变量: 对引用的操作(赋值、取地址等)通常表现得就像直接对原始变量操作一样。
- 临时变量和
const
引用: 通常,不能将引用绑定到临时变量或字面值。但有一个重要的例外:常量引用 (const type&
) 可以绑定到临时变量、字面值或类型稍有不同的变量(如果可以进行隐式转换)。这种特性使得常量引用在函数参数中非常有用,因为它们可以接受更广泛的实参类型(包括字面值和需要类型转换的值),同时保证函数不会修改它们。1
2
3
4
5
6
7
8double value = 3.14;
// int& ref_val = value; // 错误:类型不匹配
const int& const_ref_val = value; // 合法!创建了一个临时的 int(3),const_ref_val 引用这个临时变量
const double& ref_literal = 5.0 * 2.0; // 合法!引用一个临时 double(10.0)
long num = 100L;
const int& ref_num = num; // 合法!引用一个临时的 int(100)
8.2.4 将引用用于结构
按引用传递对于结构体特别有用,因为它可以避免复制整个结构体(可能包含许多成员)的开销。如果函数不需要修改结构体,应使用常量引用 (const struct_type&
)。
1 |
|
8.2.5 将引用用于类对象
将引用用于类对象与用于结构体完全相同。按常量引用 (const class_type&
) 传递是避免复制大型对象并确保函数不修改对象状态的标准做法。如果需要修改对象,则使用普通引用 (class_type&
)。
1 |
|
8.2.6 对象、继承和引用
当与类继承结合使用时,基类的引用可以指向派生类的对象。这是实现多态 (Polymorphism) 的关键机制之一(与指针类似)。通过基类引用调用虚函数时,会执行派生类中相应的版本。这部分内容将在后续章节(如第 13 章)详细介绍。
1 | // 概念预览 (将在后续章节详细讲解) |
8.2.7 何时使用引用参数
选择使用值传递、指针传递还是引用传递取决于具体需求:
按值传递 (
type param
):- 适用于小型数据类型(
int
,double
,bool
, 指针本身)。 - 当函数需要操作数据的副本而不影响原始数据时。
- 简单易懂。
- 适用于小型数据类型(
按指针传递 (
type* param
):- 当函数需要修改调用者的原始数据时(传统 C 风格)。
- 当需要表示“可选”参数(可以传递
nullptr
)时。 - 与 C 库或旧代码交互时。
- 传递大型对象以避免复制开销(但通常引用更受欢迎)。
按引用传递 (
type& param
):- 当函数需要修改调用者的原始数据时(C++ 风格,通常比指针更简洁安全)。
- 传递大型对象(结构、类)以避免复制开销,但函数不需要修改对象时,应使用**常量引用 (
const type& param
)**。这是 C++ 中非常常见的做法,兼具效率和安全性。
经验法则:
- 对于内置类型和小型结构,优先考虑按值传递。
- 对于需要修改调用者数据的大型对象或函数,使用按引用传递 (
type&
)。 - 对于不需要修改调用者数据的大型对象,使用按常量引用传递 (
const type&
) 以提高效率和安全性。 - 在需要表示可选参数或与 C 风格代码交互时,考虑使用按指针传递 (
type*
)。
引用是 C++ 中一个强大且常用的特性,尤其是在函数参数和返回值中,它提供了指针之外的另一种处理间接访问和避免复制的方式。
8.3 默认参数
C++ 允许在函数声明(原型)或定义中为函数的参数指定默认值。如果在调用函数时没有为带有默认值的参数提供实参,那么编译器会自动使用该参数的默认值。如果提供了实参,则使用提供的实参值,覆盖默认值。
目的:
- 提高函数的灵活性,允许用户在调用时省略某些不常用的参数。
- 简化函数调用,特别是当某些参数在大多数情况下都使用相同的值时。
用法
在函数原型或定义中,通过在参数声明后使用赋值运算符 =
来指定默认值。
语法 (在原型中指定):
1 | return_type function_name(type param1, type param2 = default_value2, type param3 = default_value3); |
重要规则:
- 从右到左规则: 必须为函数参数列表从右到左依次提供默认值。如果某个参数有默认值,则其右侧的所有参数必须也有默认值。
1
2
3
4
5
6// 合法
void func1(int a, int b = 10, int c = 20);
// 合法
void func2(int a = 5, int b = 10, int c = 20);
// 非法!如果 b 有默认值,c 必须也有
// void func_error(int a, int b = 10, int c); - 原型 vs. 定义: 默认参数值通常在函数原型(声明)中指定,而不是在函数定义中。如果在原型中指定了默认值,则定义中不能再次指定。如果函数没有单独的原型(定义在调用之前),则可以在定义中指定默认值。将默认值放在原型中(通常在头文件里)是更好的做法,因为它向调用者清晰地展示了可以省略哪些参数。
- 调用时的匹配: 调用函数时,提供的实参会从左到右匹配参数。不能跳过没有默认值的参数去为有默认值的参数提供值。
1
2
3
4func1(1); // 等效于 func1(1, 10, 20)
func1(1, 50); // 等效于 func1(1, 50, 20)
func1(1, 50, 30); // 等效于 func1(1, 50, 30)
// func1(1, , 30); // 非法!不能跳过参数 b
示例
1 |
|
输出:
1 | Info: [Level 0] System started. |
代码解释:
-
log_message
函数的原型为level
和prefix
参数指定了默认值。 main
函数展示了不同的调用方式:- 第一次调用提供了所有三个参数。
- 第二次调用只提供了
message
和level
,prefix
使用了默认值"Log: "
。 - 第三次调用只提供了
message
,level
使用了默认值1
,prefix
使用了默认值"Log: "
。
- 函数定义部分没有重复默认值,只列出了参数类型和名称。
默认参数是 C++ 中一个方便的特性,可以使函数接口更加灵活和易用,尤其是在处理具有多个配置选项或不常用参数的函数时。
8.4 函数重载
函数重载 (Function Overloading) 是 C++ 的一项特性,允许在同一个作用域内定义多个同名函数,只要它们的参数列表(也称为函数签名)不同即可。参数列表的不同可以体现在参数的数量、类型或顺序上。编译器会根据函数调用时提供的实参类型和数量来决定具体调用哪个重载版本。
函数签名: 函数的名称和其参数列表(参数的类型、数量和顺序)共同构成了函数签名。注意:函数的返回类型不属于函数签名的一部分,不能仅凭返回类型不同来重载函数。
目的:
- 允许使用相同的函数名来执行概念上相似但操作于不同数据类型或参数组合的任务。
- 提高代码的可读性和易用性,用户不必为相似操作记住多个不同的函数名。
8.4.1 重载示例
假设我们需要一个函数来打印不同类型的数据。使用函数重载,我们可以定义多个名为 print
的函数:
1 |
|
代码解释:
- 我们定义了五个名为
print
的函数,但它们的参数列表各不相同(类型或数量不同)。 - 在
main
函数中,编译器根据传递给print
的实参类型和数量,自动选择了正确的重载版本进行调用。
名称修饰 (Name Mangling):
C++ 编译器内部通过一种称为名称修饰或名称改编(Name Mangling)的技术来区分同名的重载函数。它会根据函数的签名(包括参数类型)生成一个内部唯一的名称。例如,print(int)
和 print(double)
在编译后会变成不同的内部名称,这样链接器就能正确地将函数调用链接到对应的函数定义。
8.4.2 何时使用函数重载
函数重载是一个强大的工具,但应谨慎使用,以保持代码的清晰性。以下是一些适合使用函数重载的情况:
- 执行概念上相似的任务: 当多个函数执行的操作逻辑上相似,只是处理的数据类型不同时(如上例中的
print
函数,或计算不同类型数值绝对值的abs
函数)。 - 提供不同参数组合: 当一个任务可以通过提供不同数量或类型的参数来完成时(例如,一个构造函数可以接受不同的初始化参数组合)。
避免使用函数重载的情况:
- 执行完全不同的任务: 如果函数虽然名称相同,但执行的任务在逻辑上毫不相关,那么重载可能会导致混淆。此时应使用不同的函数名。
- 仅参数类型可通过默认参数或模板实现: 如果函数的功能差异可以通过默认参数或函数模板(见 8.5 节)更清晰地表达,那么可能不需要重载。例如,如果只是参数数量不同,且较少参数的版本可以通过为较多参数版本提供默认值来实现,那么默认参数可能更合适。
总结:
函数重载允许我们用同一个名称定义多个功能相似但参数列表不同的函数。编译器根据调用时提供的实参来选择正确的版本。这是 C++ 实现多态性的一种方式(编译时多态),可以使代码更直观、更易用,但应确保重载的函数在逻辑上是相关的,以避免混淆。
8.5 函数模板
函数重载允许我们为不同的参数类型定义同名函数,但如果这些函数的逻辑完全相同,只是处理的数据类型不同,为每种类型都写一个重载版本会很繁琐且容易出错。例如,交换两个 int
和交换两个 double
的逻辑是一样的。
函数模板 (Function Template) 提供了一种更通用的解决方案。它允许我们编写一个与类型无关的函数定义,其中的数据类型使用模板参数(也叫类型参数)来表示。编译器会根据函数调用时使用的具体数据类型,自动生成(实例化)相应的函数版本。
目的:
- 编写通用的、可重用的代码,适用于多种数据类型。
- 减少代码重复。
- 提高代码的可维护性。
语法:
1 | template <typename T> // 或者 template <class T> |
-
template <typename T>
: 这是模板声明,告诉编译器接下来是一个模板定义。typename
是关键字(也可以用class
关键字代替,两者在这里等价),T
是模板参数的名称(通常用大写字母,如T
,U
,V
,但可以是任何合法标识符)。你可以定义多个模板参数,用逗号分隔,例如template <typename T, typename U>
。 -
return_type
,parameter_list
,function_name
: 与普通函数定义类似,但可以在这些部分使用模板参数T
来代表某种待定的数据类型。
示例:通用的交换函数模板
1 |
|
代码解释:
- 我们定义了一个名为
Swap
的函数模板,它使用类型参数T
。 - 在
main
函数中,当我们调用Swap(i, j)
时,编译器看到两个实参都是int
类型,于是它推断出T
应该是int
,并自动生成(实例化)一个专门处理int
的Swap
函数版本:void Swap<int>(int& a, int& b)
。 - 类似地,调用
Swap(x, y)
时,编译器生成Swap<double>
版本;调用Swap(c1, c2)
时,生成Swap<char>
版本。 - 这个过程称为**模板实例化 (Template Instantiation)**。编译器只为程序中实际用到的类型生成函数实例。
8.5.1 重载的模板
函数模板也可以像普通函数一样被重载。你可以提供多个同名的函数模板,只要它们的模板参数列表不同,或者函数参数列表(非模板参数部分)不同即可。
示例:重载模板以处理不同数量的参数或特定类型
1 |
|
编译器会根据调用时提供的参数数量和类型(包括是否是数组)来选择最匹配的重载模板。
8.5.2 模板的局限性
函数模板是通用的,但并非万能。模板代码中使用的操作(如赋值 =
、比较 <
、>
等)必须对实例化时使用的具体类型有效。如果某个类型不支持模板代码中的操作,那么实例化该类型的模板就会导致编译错误。
示例:模板可能失败的情况
1 |
|
要解决这个问题,可以为 Data
结构重载 <
运算符,或者使用下一节将介绍的显式具体化。
8.5.3 显式具体化 (Explicit Specialization)
有时,通用的函数模板对于某个特定类型可能不适用或效率不高,我们希望为这个特定类型提供一个专门的、非模板的实现。这就是显式具体化。
语法:
1 | template <> // 空的尖括号表示这是一个具体化 |
-
template <>
: 告诉编译器这是一个显式具体化。 -
function_name<specific_type>
: 在函数名后明确指定要为哪个类型提供具体化版本。 - 函数体包含针对
specific_type
的特殊代码。
示例:为结构体具体化 Swap 模板
假设我们有一个结构体,我们只想交换其中的某个成员,而不是整个结构体。
1 |
|
当编译器遇到 Swap(sue, sid)
调用时,它发现存在一个专门为 Job
类型定义的显式具体化版本 Swap<Job>
,于是优先选择并调用这个特殊版本,而不是通用的模板版本。
8.5.4 实例化和具体化 (Instantiation and Specialization)
区分这两个概念很重要:
实例化 (Instantiation): 编译器根据函数模板和调用时使用的具体类型自动生成一个特定类型的函数版本。这是模板的基本工作方式。
- 隐式实例化 (Implicit Instantiation): 编译器在需要时自动进行(如
Swap(i, j)
)。 - 显式实例化 (Explicit Instantiation): 程序员可以指示编译器立即生成特定类型的函数版本,即使还没有调用它。语法:
template return_type function_name<specific_type>(parameter_list);
(注意末尾的分号)。这在某些高级场景(如将模板定义放在源文件中)可能有用。1
2// 在 .cpp 文件中显式实例化 Swap<int>
template void Swap<int>(int&, int&);
- 隐式实例化 (Implicit Instantiation): 编译器在需要时自动进行(如
具体化 (Specialization): 程序员为某个特定类型提供一个完全不同的、非模板的函数定义,以覆盖通用的模板行为。
- 显式具体化 (Explicit Specialization): 使用
template <>
语法为特定类型提供自定义实现(如上例中的Swap<Job>
)。
- 显式具体化 (Explicit Specialization): 使用
8.5.5 编译器选择使用哪个函数版本
当存在多个函数(普通函数、函数模板、模板具体化)可能匹配一个函数调用时,编译器遵循一套规则来选择最佳匹配,这个过程称为**重载解析 (Overload Resolution)**。简化规则如下:
- 寻找完全匹配: 编译器首先查找是否存在一个非模板函数,其参数类型与调用实参完全匹配(或只需进行不重要的转换,如数组名到指针)。
- 寻找模板匹配: 如果没有找到完全匹配的非模板函数,编译器会尝试查找函数模板。
- 查找显式具体化: 检查是否存在一个显式具体化版本,其类型与实参完全匹配。
- 尝试模板实例化: 尝试通过实参推导模板参数,看是否能从通用模板生成一个匹配的实例。
- 选择最佳匹配:
- 如果只有一个匹配项(非模板函数、显式具体化或模板实例),则选择该项。
- 如果存在多个匹配项:
- 非模板函数优先于模板实例: 如果一个非模板函数和一个模板实例都能匹配,通常优先选择非模板函数。
- 显式具体化优先于模板实例: 如果一个显式具体化和一个通用模板实例都能匹配,优先选择显式具体化。
- 更具体的模板优先: 如果有多个模板实例可以匹配(可能涉及类型转换),编译器会尝试找出“最具体”的模板(即需要较少或较不复杂的类型转换就能匹配的模板)。如果无法确定哪个最具体,则调用是**歧义的 (ambiguous)**,会导致编译错误。
示例:
1 |
|
8.5.6 模板函数的发展
函数模板是 C++ 泛型编程的基础。自 C++11 以来,模板功能得到了进一步增强:
-
auto
返回类型推导: 允许编译器根据return
语句推导函数模板的返回类型。 - 可变参数模板 (Variadic Templates): 允许定义接受任意数量、任意类型参数的模板(见 18.6 节)。
- 别名模板 (
using
): 可以为模板创建别名。 - Lambda 表达式: 可以创建匿名的函数对象,常与模板算法结合使用。
- Concepts (C++20): 允许对模板参数施加更明确的约束,提高了编译时错误信息的可读性,并使模板意图更清晰。
函数模板是 C++ 中一个非常强大和灵活的特性,它使得编写高度通用和可重用的代码成为可能。
8.6 总结
本章深入探讨了 C++ 函数的更多高级特性,旨在提高代码的效率、灵活性和可重用性。
主要内容回顾:
内联函数 (
inline
):- 一种优化建议,请求编译器将函数代码直接替换到调用点,以减少小型、频繁调用函数的调用开销。
-
inline
只是建议,编译器可自行决定是否采纳。 - 通常将内联函数定义放在头文件中。
- 相比宏,内联函数具有类型安全、行为可预测等优点。
引用变量 (
&
):- 变量的别名,声明时必须初始化,之后不能再引用其他变量。
- 按引用传递 (
type&
):函数参数成为原始实参的别名,允许函数修改原始数据,且避免了大型对象的复制开销。 - 按常量引用传递 (
const type&
):函数参数成为原始实参的常量别名,不能通过引用修改原始数据,但同样避免了复制开销。这是传递大型对象进行只读访问的推荐方式。 - 引用比指针在语法上更简洁,且通常不涉及空值问题。
默认参数:
- 允许在函数声明(原型)中为参数指定默认值。
- 调用函数时,如果省略了带有默认值的参数,则使用默认值。
- 默认参数必须从参数列表的最右边开始指定。
- 简化了函数调用,提高了函数的灵活性。
函数重载:
- 允许在同一作用域内定义多个同名函数,只要它们的参数列表(数量、类型、顺序)不同。
- 编译器根据调用时的实参来选择匹配的重载版本。
- 返回类型不能作为区分重载函数的依据。
- 适用于执行概念上相似但处理不同参数的任务。
函数模板 (
template <typename T>
):- 创建通用的、与类型无关的函数定义。
- 编译器根据调用时使用的具体类型实例化相应的函数版本。
- 重载模板: 可以定义多个同名模板,只要它们的参数列表或模板参数列表不同。
- 显式具体化 (
template <>
): 为特定类型提供专门的、非模板的实现,以覆盖通用模板的行为。 - 模板是 C++ 泛型编程的基础,极大地提高了代码的可重用性。
通过掌握这些高级函数特性,可以编写出更高效、更灵活、更易于维护的 C++ 代码。