8.1 C++ 内联函数

常规的函数调用过程涉及一些开销:程序需要跳转到函数的内存地址,保存当前执行状态(如寄存器值),将参数复制到栈上,执行函数代码,存储返回值,恢复执行状态,然后跳转回调用点。对于非常短小且频繁调用的函数,这些开销可能会变得显著,影响程序性能。

内联函数 (Inline Function) 是 C++ 提供的一种优化机制,旨在减少这种函数调用开销。其基本思想是:建议编译器在编译时将函数的实际代码直接替换到每个调用该函数的地方,而不是执行常规的函数调用跳转。

用法

要建议编译器将一个函数视为内联函数,可以在函数定义前加上 inline 关键字。

语法:

1
2
3
inline return_type function_name(parameter_list) {
// 函数体
}

示例:

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

// 定义一个简单的内联函数
inline double square(double x) {
return x * x;
}

int main() {
double a = 5.0;
double b;

// 调用内联函数
b = square(a); // 编译器可能会将这里替换为: b = a * a;

std::cout << "a = " << a << ", a squared = " << b << std::endl;

double c = square(1.5 + 2.5); // 编译器可能会替换为: double c = (1.5 + 2.5) * (1.5 + 2.5);
std::cout << "(1.5 + 2.5) squared = " << c << std::endl;

return 0;
}

代码解释:

  • square 函数被声明为 inline
  • main 函数中调用 square(a) 时,编译器可能会直接用 a * a 的代码替换这次调用,避免了函数调用的开销。
  • 同样,square(1.5 + 2.5) 可能被替换为 (1.5 + 2.5) * (1.5 + 2.5)

inline 的特性和注意事项

  1. 建议而非命令: inline 关键字只是向编译器提出的一个建议。编译器会根据自己的优化策略来决定是否真的进行内联。如果函数体过于复杂(例如包含循环、递归、大量代码),或者编译器认为内联不会带来好处(甚至可能有害),它可能会忽略 inline 建议,仍然执行常规的函数调用。

  2. 适用于小型函数: 内联最适合那些代码量小、执行速度快且被频繁调用的函数。如果内联一个大函数,可能会导致最终生成的可执行代码体积显著增大(代码膨胀),反而降低性能(因为更大的代码可能导致更多的缓存未命中)。

  3. 定义位置: 为了让编译器能够在调用点展开函数代码,内联函数的定义(而不仅仅是原型)通常需要放在调用该函数的每个源文件中。最常见的做法是将内联函数的定义直接放在头文件中。这样,包含该头文件的所有源文件都能看到完整的函数定义,编译器就有机会进行内联。

    • 注意:将函数定义放在头文件中对于非内联函数通常是错误的(会导致链接错误,因为同一个函数会在多个编译单元中定义),但对于内联函数是允许且必要的。
  4. 类定义中的函数: 在类(classstruct)定义内部实现的成员函数默认就是内联的,不需要显式添加 inline 关键字。

    1
    2
    3
    4
    5
    6
    class MyClass {
    public:
    int getValue() const { return value; } // 默认是内联的
    private:
    int value;
    };

内联函数 vs. 宏 (#define)

在 C 语言中,有时会使用带参数的宏(#define)来模拟类似内联函数的效果,以避免函数调用开销。例如:

1
#define SQUARE(X) ((X)*(X)) // C 风格宏

然而,宏存在一些缺点:

  • 类型不安全: 宏只是简单的文本替换,不进行类型检查。
  • 意外的副作用: 如果参数带有副作用(如 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

int main() {
int rats = 101;
int& rodents = rats; // rodents 是 rats 的一个引用 (别名)

std::cout << "rats = " << rats;
std::cout << ", rodents = " << rodents << std::endl; // 输出: rats = 101, rodents = 101

// 对引用进行操作,实际上是操作原始变量
rodents++;
std::cout << "After rodents++:" << std::endl;
std::cout << "rats = " << rats; // rats 的值也变成了 102
std::cout << ", rodents = " << rodents << std::endl; // 输出: rats = 102, rodents = 102

// 查看地址,会发现它们是相同的
std::cout << "Address of rats: " << &rats << std::endl;
std::cout << "Address of rodents: " << &rodents << std::endl; // 输出相同的地址

// int& bad_ref; // 错误!引用必须在声明时初始化
// double& wrong_type = rats; // 错误!类型不匹配 (double& vs int)

return 0;
}

关键点:

  • 引用必须在声明时初始化。
  • 引用一旦初始化,就不能再指向其他变量。它终生都是其初始变量的别名。
  • 引用本身不占用独立的内存地址(或者说,它的地址就是它所引用变量的地址)。

8.2.2 将引用用作函数参数

引用最重要和最常见的用途之一是作为函数参数,这称为**按引用传递 (Pass by Reference)**。当使用引用作为函数参数时,函数接收的是原始变量的别名,而不是副本。这意味着函数可以直接访问并修改调用者作用域中的原始变量。

语法:

1
2
3
void function_name(type& ref_parameter) {
// 可以通过 ref_parameter 修改原始实参
}

示例:使用引用参数交换两个变量的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>

// 函数:使用引用参数交换两个 int 变量的值
void swap_ref(int& a, int& b) { // a 和 b 是调用时传入变量的别名
int temp = a;
a = b;
b = temp;
// 对 a 和 b 的修改直接影响原始变量
}

// 对比:使用指针参数交换 (传统 C 风格)
void swap_ptr(int* p_a, int* p_b) {
int temp = *p_a;
*p_a = *p_b;
*p_b = temp;
}

// 对比:使用值传递 (无法交换原始变量)
void swap_val(int a, int b) {
int temp = a;
a = b;
b = temp;
// 只修改了局部副本 a 和 b
}


int main() {
int wallet1 = 100;
int wallet2 = 200;

std::cout << "Original: wallet1 = " << wallet1 << ", wallet2 = " << wallet2 << std::endl;

// 尝试使用值传递交换 (失败)
swap_val(wallet1, wallet2);
std::cout << "After swap_val: wallet1 = " << wallet1 << ", wallet2 = " << wallet2 << std::endl; // 值不变

// 使用引用传递交换 (成功)
swap_ref(wallet1, wallet2);
std::cout << "After swap_ref: wallet1 = " << wallet1 << ", wallet2 = " << wallet2 << std::endl; // 值已交换

// 再次交换回来,使用指针传递 (成功)
swap_ptr(&wallet1, &wallet2); // 注意需要传递地址
std::cout << "After swap_ptr: wallet1 = " << wallet1 << ", wallet2 = " << wallet2 << std::endl; // 值再次交换回来

return 0;
}

按引用传递 vs. 按指针传递:

  • 语法: 引用传递的调用语法更简洁自然 (swap_ref(a, b)),而指针传递需要显式获取地址 (swap_ptr(&a, &b)) 并在函数内部解引用 (*p_a)。
  • 空值: 指针可以为 nullptr,需要在使用前检查。引用通常(在标准用法下)不会是“空”的,因为它必须引用一个已存在的对象。这使得引用在某些情况下更安全。
  • 目的: 两者都可以用来允许函数修改调用者的变量,以及避免大型对象的复制开销。

8.2.3 引用的属性和特别之处

  1. 必须初始化: 如前所述,引用在声明时必须绑定到一个已存在的对象。
  2. 不可重新绑定: 引用不能在初始化后更改其引用的对象。
  3. 行为像原变量: 对引用的操作(赋值、取地址等)通常表现得就像直接对原始变量操作一样。
  4. 临时变量和 const 引用: 通常,不能将引用绑定到临时变量或字面值。但有一个重要的例外:常量引用 (const type&) 可以绑定到临时变量、字面值或类型稍有不同的变量(如果可以进行隐式转换)。
    1
    2
    3
    4
    5
    6
    7
    8
    double 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <string>

struct Product {
std::string name;
double price;
int quantity;
};

// 按常量引用传递结构,避免复制且不修改
void display_product(const Product& prod) {
std::cout << "Product Name: " << prod.name << std::endl;
std::cout << "Price: $" << prod.price << std::endl;
std::cout << "Quantity: " << prod.quantity << std::endl;
// prod.price = 0.0; // 错误!不能通过 const 引用修改
}

// 按引用传递结构,允许修改
void apply_discount(Product& prod, double discount_percentage) {
if (discount_percentage > 0 && discount_percentage < 100) {
prod.price *= (1.0 - discount_percentage / 100.0);
}
}

int main() {
Product laptop = {"Laptop Pro", 1200.0, 10};

std::cout << "--- Initial Product ---" << std::endl;
display_product(laptop); // 高效传递,不复制

apply_discount(laptop, 10.0); // 传递引用以修改价格

std::cout << "\n--- Product After 10% Discount ---" << std::endl;
display_product(laptop); // 再次高效传递

return 0;
}

8.2.5 将引用用于类对象

将引用用于类对象与用于结构体完全相同。按常量引用 (const class_type&) 传递是避免复制大型对象并确保函数不修改对象状态的标准做法。如果需要修改对象,则使用普通引用 (class_type&)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <string>
#include <vector>

class Student {
public:
Student(const std::string& n) : name(n) {}
void add_grade(int g) { grades.push_back(g); }
void print_info() const { // const 成员函数
std::cout << "Student: " << name << ", Grades: ";
for (int g : grades) {
std::cout << g << " ";
}
std::cout << std::endl;
}
const std::string& get_name() const { return name; } // 返回常量引用

private:
std::string name;
std::vector<int> grades;
};

// 按常量引用传递类对象
void show_student_summary(const Student& s) {
std::cout << "Summary for " << s.get_name() << std::endl;
s.print_info(); // 可以调用 const 成员函数
// s.add_grade(100); // 错误!不能通过 const 引用调用非 const 成员函数
}

int main() {
Student alice("Alice");
alice.add_grade(95);
alice.add_grade(88);

show_student_summary(alice); // 高效传递,不复制

return 0;
}

8.2.6 对象、继承和引用

当与类继承结合使用时,基类的引用可以指向派生类的对象。这是实现多态 (Polymorphism) 的关键机制之一(与指针类似)。通过基类引用调用虚函数时,会执行派生类中相应的版本。这部分内容将在后续章节(如第 13 章)详细介绍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 概念预览 (将在后续章节详细讲解)
class Base {
public:
virtual void show() const { std::cout << "Base::show()" << std::endl; }
virtual ~Base() {} // 虚析构函数
};

class Derived : public Base {
public:
void show() const override { std::cout << "Derived::show()" << std::endl; }
};

void display(const Base& obj) { // 参数是基类的常量引用
obj.show(); // 调用虚函数,会根据 obj 实际引用的对象类型执行相应版本
}

int main() {
Base b;
Derived d;

display(b); // 输出: Base::show()
display(d); // 输出: Derived::show() (多态行为)

return 0;
}

8.2.7 何时使用引用参数

选择使用值传递、指针传递还是引用传递取决于具体需求:

  1. 按值传递 (type param):

    • 适用于小型数据类型(int, double, bool, 指针本身)。
    • 当函数需要操作数据的副本而不影响原始数据时。
    • 简单易懂。
  2. 按指针传递 (type* param):

    • 当函数需要修改调用者的原始数据时(传统 C 风格)。
    • 当需要表示“可选”参数(可以传递 nullptr)时。
    • 与 C 库或旧代码交互时。
    • 传递大型对象以避免复制开销(但通常引用更受欢迎)。
  3. 按引用传递 (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. 从右到左规则: 必须为函数参数列表从右到左依次提供默认值。如果某个参数有默认值,则其右侧的所有参数必须也有默认值。
    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);
  2. 原型 vs. 定义: 默认参数值通常在函数原型(声明)中指定,而不是在函数定义中。如果在原型中指定了默认值,则定义中不能再次指定。如果函数没有单独的原型(定义在调用之前),则可以在定义中指定默认值。将默认值放在原型中(通常在头文件里)是更好的做法,因为它向调用者清晰地展示了可以省略哪些参数。
  3. 调用时的匹配: 调用函数时,提供的实参会从左到右匹配参数。不能跳过没有默认值的参数去为有默认值的参数提供值。
    1
    2
    3
    4
    func1(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <string>

// 函数原型,指定默认参数
// level 默认为 1, prefix 默认为 "Log: "
void log_message(const std::string& message, int level = 1, const std::string& prefix = "Log: ");

int main() {
// 调用方式 1: 提供所有参数
log_message("System started.", 0, "Info: ");

// 调用方式 2: 省略最右边的 prefix 参数,使用其默认值
log_message("Processing data...", 2); // prefix 使用 "Log: "

// 调用方式 3: 省略 level 和 prefix 参数,使用它们的默认值
log_message("Operation complete."); // level 使用 1, prefix 使用 "Log: "

// log_message("Error occurred", , "Error: "); // 非法!不能跳过 level

return 0;
}

// 函数定义 (注意:这里不再重复默认值)
void log_message(const std::string& message, int level, const std::string& prefix) {
std::cout << prefix << "[Level " << level << "] " << message << std::endl;
}

输出:

1
2
3
Info: [Level 0] System started.
Log: [Level 2] Processing data...
Log: [Level 1] Operation complete.

代码解释:

  1. log_message 函数的原型为 levelprefix 参数指定了默认值。
  2. main 函数展示了不同的调用方式:
    • 第一次调用提供了所有三个参数。
    • 第二次调用只提供了 messagelevelprefix 使用了默认值 "Log: "
    • 第三次调用只提供了 messagelevel 使用了默认值 1prefix 使用了默认值 "Log: "
  3. 函数定义部分没有重复默认值,只列出了参数类型和名称。

默认参数是 C++ 中一个方便的特性,可以使函数接口更加灵活和易用,尤其是在处理具有多个配置选项或不常用参数的函数时。

8.4 函数重载

函数重载 (Function Overloading) 是 C++ 的一项特性,允许在同一个作用域内定义多个同名函数,只要它们的参数列表(也称为函数签名)不同即可。参数列表的不同可以体现在参数的数量类型顺序上。编译器会根据函数调用时提供的实参类型和数量来决定具体调用哪个重载版本。

函数签名: 函数的名称和其参数列表(参数的类型、数量和顺序)共同构成了函数签名。注意:函数的返回类型不属于函数签名的一部分,不能仅凭返回类型不同来重载函数。

目的:

  • 允许使用相同的函数名来执行概念上相似但操作于不同数据类型或参数组合的任务。
  • 提高代码的可读性和易用性,用户不必为相似操作记住多个不同的函数名。

8.4.1 重载示例

假设我们需要一个函数来打印不同类型的数据。使用函数重载,我们可以定义多个名为 print 的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <string>

// 重载版本 1: 打印 int
void print(int i) {
std::cout << "Printing int: " << i << std::endl;
}

// 重载版本 2: 打印 double
void print(double d) {
std::cout << "Printing double: " << d << std::endl;
}

// 重载版本 3: 打印字符串 (const char*)
void print(const char* s) {
std::cout << "Printing C-string: " << s << std::endl;
}

// 重载版本 4: 打印 std::string (常量引用)
void print(const std::string& s) {
std::cout << "Printing std::string: " << s << std::endl;
}

// 重载版本 5: 打印两个 int
void print(int i, int j) {
std::cout << "Printing two ints: " << i << " and " << j << std::endl;
}

// 错误示例:仅返回类型不同,无法重载
// int print(int i) {
// std::cout << "Trying to return int: " << i << std::endl;
// return i;
// }

int main() {
print(10); // 调用版本 1 (int)
print(3.14); // 调用版本 2 (double)
print("Hello"); // 调用版本 3 (const char*)
std::string msg = "World";
print(msg); // 调用版本 4 (const std::string&)
print(5, 20); // 调用版本 5 (int, int)

// print(10L); // 可能产生歧义调用,因为 long 可以转换为 int 或 double
// 编译器可能报错或选择一个最佳匹配

return 0;
}

代码解释:

  • 我们定义了五个名为 print 的函数,但它们的参数列表各不相同(类型或数量不同)。
  • main 函数中,编译器根据传递给 print 的实参类型和数量,自动选择了正确的重载版本进行调用。

名称修饰 (Name Mangling):

C++ 编译器内部通过一种称为名称修饰名称改编(Name Mangling)的技术来区分同名的重载函数。它会根据函数的签名(包括参数类型)生成一个内部唯一的名称。例如,print(int)print(double) 在编译后会变成不同的内部名称,这样链接器就能正确地将函数调用链接到对应的函数定义。

8.4.2 何时使用函数重载

函数重载是一个强大的工具,但应谨慎使用,以保持代码的清晰性。以下是一些适合使用函数重载的情况:

  1. 执行概念上相似的任务: 当多个函数执行的操作逻辑上相似,只是处理的数据类型不同时(如上例中的 print 函数,或计算不同类型数值绝对值的 abs 函数)。
  2. 提供不同参数组合: 当一个任务可以通过提供不同数量或类型的参数来完成时(例如,一个构造函数可以接受不同的初始化参数组合)。

避免使用函数重载的情况:

  1. 执行完全不同的任务: 如果函数虽然名称相同,但执行的任务在逻辑上毫不相关,那么重载可能会导致混淆。此时应使用不同的函数名。
  2. 仅参数类型可通过默认参数或模板实现: 如果函数的功能差异可以通过默认参数或函数模板(见 8.5 节)更清晰地表达,那么可能不需要重载。例如,如果只是参数数量不同,且较少参数的版本可以通过为较多参数版本提供默认值来实现,那么默认参数可能更合适。

总结:

函数重载允许我们用同一个名称定义多个功能相似但参数列表不同的函数。编译器根据调用时提供的实参来选择正确的版本。这是 C++ 实现多态性的一种方式(编译时多态),可以使代码更直观、更易用,但应确保重载的函数在逻辑上是相关的,以避免混淆。

8.5 函数模板

函数重载允许我们为不同的参数类型定义同名函数,但如果这些函数的逻辑完全相同,只是处理的数据类型不同,为每种类型都写一个重载版本会很繁琐且容易出错。例如,交换两个 int 和交换两个 double 的逻辑是一样的。

函数模板 (Function Template) 提供了一种更通用的解决方案。它允许我们编写一个与类型无关的函数定义,其中的数据类型使用模板参数(也叫类型参数)来表示。编译器会根据函数调用时使用的具体数据类型,自动生成(实例化)相应的函数版本。

目的:

  • 编写通用的、可重用的代码,适用于多种数据类型。
  • 减少代码重复。
  • 提高代码的可维护性。

语法:

1
2
3
4
template <typename T> // 或者 template <class T>
return_type function_name(parameter_list) {
// 函数体,可以使用类型参数 T
}
  • template <typename T>: 这是模板声明,告诉编译器接下来是一个模板定义。typename 是关键字(也可以用 class 关键字代替,两者在这里等价),T 是模板参数的名称(通常用大写字母,如 T, U, V,但可以是任何合法标识符)。你可以定义多个模板参数,用逗号分隔,例如 template <typename T, typename U>
  • return_type, parameter_list, function_name: 与普通函数定义类似,但可以在这些部分使用模板参数 T 来代表某种待定的数据类型。

示例:通用的交换函数模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

// 定义一个函数模板 Swap
template <typename T> // T 代表任意类型
void Swap(T& a, T& b) { // 参数是类型 T 的引用
T temp; // 声明一个类型为 T 的临时变量
temp = a;
a = b;
b = temp;
}

int main() {
int i = 10, j = 20;
std::cout << "Original ints: i = " << i << ", j = " << j << std::endl;
Swap(i, j); // 编译器自动生成 Swap<int>(int&, int&) 版本
std::cout << "Swapped ints: i = " << i << ", j = " << j << std::endl;

double x = 1.5, y = 2.8;
std::cout << "\nOriginal doubles: x = " << x << ", y = " << y << std::endl;
Swap(x, y); // 编译器自动生成 Swap<double>(double&, double&) 版本
std::cout << "Swapped doubles: x = " << x << ", y = " << y << std::endl;

char c1 = 'A', c2 = 'B';
std::cout << "\nOriginal chars: c1 = " << c1 << ", c2 = " << c2 << std::endl;
Swap(c1, c2); // 编译器自动生成 Swap<char>(char&, char&) 版本
std::cout << "Swapped chars: c1 = " << c1 << ", c2 = " << c2 << std::endl;

// Swap(i, x); // 错误!编译器无法推断出唯一的 T 类型 (int vs double)

return 0;
}

代码解释:

  1. 我们定义了一个名为 Swap 的函数模板,它使用类型参数 T
  2. main 函数中,当我们调用 Swap(i, j) 时,编译器看到两个实参都是 int 类型,于是它推断出 T 应该是 int,并自动生成(实例化)一个专门处理 intSwap 函数版本:void Swap<int>(int& a, int& b)
  3. 类似地,调用 Swap(x, y) 时,编译器生成 Swap<double> 版本;调用 Swap(c1, c2) 时,生成 Swap<char> 版本。
  4. 这个过程称为**模板实例化 (Template Instantiation)**。编译器只为程序中实际用到的类型生成函数实例。

8.5.1 重载的模板

函数模板也可以像普通函数一样被重载。你可以提供多个同名的函数模板,只要它们的模板参数列表不同,或者函数参数列表(非模板参数部分)不同即可。

示例:重载模板以处理不同数量的参数或特定类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>

// 模板版本 1: 交换两个同类型变量
template <typename T>
void Swap(T& a, T& b) {
std::cout << "Using Swap(T&, T&)" << std::endl;
T temp = a;
a = b;
b = temp;
}

// 模板版本 2: 交换两个数组的元素 (需要额外参数指定大小)
template <typename T>
void Swap(T arr1[], T arr2[], int n) {
std::cout << "Using Swap(T[], T[], int)" << std::endl;
for (int i = 0; i < n; ++i) {
T temp = arr1[i];
arr1[i] = arr2[i];
arr2[i] = temp;
}
}

int main() {
int i = 10, j = 20;
Swap(i, j); // 调用版本 1: Swap<int>(int&, int&)

int arr_a[] = {1, 2, 3};
int arr_b[] = {4, 5, 6};
int size = 3;

std::cout << "\nBefore swapping arrays:" << std::endl;
std::cout << "arr_a: " << arr_a[0] << " " << arr_a[1] << " " << arr_a[2] << std::endl;
std::cout << "arr_b: " << arr_b[0] << " " << arr_b[1] << " " << arr_b[2] << std::endl;

Swap(arr_a, arr_b, size); // 调用版本 2: Swap<int>(int[], int[], int)

std::cout << "\nAfter swapping arrays:" << std::endl;
std::cout << "arr_a: " << arr_a[0] << " " << arr_a[1] << " " << arr_a[2] << std::endl;
std::cout << "arr_b: " << arr_b[0] << " " << arr_b[1] << " " << arr_b[2] << std::endl;

return 0;
}

编译器会根据调用时提供的参数数量和类型(包括是否是数组)来选择最匹配的重载模板。

8.5.2 模板的局限性

函数模板是通用的,但并非万能。模板代码中使用的操作(如赋值 =、比较 <> 等)必须对实例化时使用的具体类型有效。如果某个类型不支持模板代码中的操作,那么实例化该类型的模板就会导致编译错误。

示例:模板可能失败的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>

template <typename T>
void CompareAndPrint(const T& a, const T& b) {
if (a < b) { // 假设类型 T 支持 < 运算符
std::cout << "a is less than b" << std::endl;
} else if (b < a) { // 假设类型 T 支持 < 运算符
std::cout << "b is less than a" << std::endl;
} else {
std::cout << "a and b are equal (or incomparable)" << std::endl;
}
}

struct Data {
int val;
// 这个结构体没有重载 < 运算符
};

int main() {
int x = 5, y = 10;
CompareAndPrint(x, y); // OK: int 支持 < 运算符

Data d1 = {10};
Data d2 = {20};
// CompareAndPrint(d1, d2); // 编译错误!Data 类型没有定义 operator<
// 编译器无法实例化 CompareAndPrint<Data>

return 0;
}

要解决这个问题,可以为 Data 结构重载 < 运算符,或者使用下一节将介绍的显式具体化

8.5.3 显式具体化 (Explicit Specialization)

有时,通用的函数模板对于某个特定类型可能不适用或效率不高,我们希望为这个特定类型提供一个专门的、非模板的实现。这就是显式具体化

语法:

1
2
3
4
template <> // 空的尖括号表示这是一个具体化
return_type function_name<specific_type>(parameter_list_with_specific_type) {
// 针对 specific_type 的特殊实现
}
  • template <>: 告诉编译器这是一个显式具体化。
  • function_name<specific_type>: 在函数名后明确指定要为哪个类型提供具体化版本。
  • 函数体包含针对 specific_type 的特殊代码。

示例:为结构体具体化 Swap 模板

假设我们有一个结构体,我们只想交换其中的某个成员,而不是整个结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>

struct Job {
char name[40];
double salary;
int floor;
};

// 通用 Swap 模板 (同上)
template <typename T>
void Swap(T& a, T& b) {
std::cout << "(Using generic Swap)" << std::endl;
T temp = a;
a = b;
b = temp;
}

// 针对 Job 类型的显式具体化
template <>
void Swap<Job>(Job& j1, Job& j2) { // 明确指定 T 为 Job
std::cout << "(Using specialized Swap for Job - swapping salary and floor only)" << std::endl;
// 只交换 salary 和 floor 成员
double temp_salary = j1.salary;
j1.salary = j2.salary;
j2.salary = temp_salary;

int temp_floor = j1.floor;
j1.floor = j2.floor;
j2.floor = temp_floor;
// name 成员保持不变
}

int main() {
int i = 10, j = 20;
Swap(i, j); // 调用通用模板 Swap<int>

Job sue = {"Susan Yaffee", 73000.60, 7};
Job sid = {"Sidney Taffee", 78060.72, 9};

std::cout << "\nOriginal Jobs:" << std::endl;
std::cout << sue.name << ": $" << sue.salary << " on floor " << sue.floor << std::endl;
std::cout << sid.name << ": $" << sid.salary << " on floor " << sid.floor << std::endl;

Swap(sue, sid); // 调用显式具体化版本 Swap<Job>

std::cout << "\nSwapped Jobs (partially):" << std::endl;
std::cout << sue.name << ": $" << sue.salary << " on floor " << sue.floor << std::endl;
std::cout << sid.name << ": $" << sid.salary << " on floor " << sid.floor << std::endl;

return 0;
}

当编译器遇到 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&);
  • 具体化 (Specialization): 程序员为某个特定类型提供一个完全不同的、非模板的函数定义,以覆盖通用的模板行为。

    • 显式具体化 (Explicit Specialization): 使用 template <> 语法为特定类型提供自定义实现(如上例中的 Swap<Job>)。

8.5.5 编译器选择使用哪个函数版本

当存在多个函数(普通函数、函数模板、模板具体化)可能匹配一个函数调用时,编译器遵循一套规则来选择最佳匹配,这个过程称为**重载解析 (Overload Resolution)**。简化规则如下:

  1. 寻找完全匹配: 编译器首先查找是否存在一个非模板函数,其参数类型与调用实参完全匹配(或只需进行不重要的转换,如数组名到指针)。
  2. 寻找模板匹配: 如果没有找到完全匹配的非模板函数,编译器会尝试查找函数模板。
    • 查找显式具体化: 检查是否存在一个显式具体化版本,其类型与实参完全匹配。
    • 尝试模板实例化: 尝试通过实参推导模板参数,看是否能从通用模板生成一个匹配的实例。
  3. 选择最佳匹配:
    • 如果只有一个匹配项(非模板函数、显式具体化或模板实例),则选择该项。
    • 如果存在多个匹配项:
      • 非模板函数优先于模板实例: 如果一个非模板函数和一个模板实例都能匹配,通常优先选择非模板函数。
      • 显式具体化优先于模板实例: 如果一个显式具体化和一个通用模板实例都能匹配,优先选择显式具体化。
      • 更具体的模板优先: 如果有多个模板实例可以匹配(可能涉及类型转换),编译器会尝试找出“最具体”的模板(即需要较少或较不复杂的类型转换就能匹配的模板)。如果无法确定哪个最具体,则调用是**歧义的 (ambiguous)**,会导致编译错误。

示例:

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

// 1. 非模板函数
void Show(int i) { std::cout << "Non-template Show(int): " << i << std::endl; }

// 2. 通用函数模板
template <typename T>
void Show(T t) { std::cout << "Template Show(T): " << t << std::endl; }

// 3. 显式具体化
template <>
void Show<double>(double d) { std::cout << "Specialized Show<double>: " << d << std::endl; }

int main() {
int a = 10;
double b = 3.14;
char c = 'X';

Show(a); // 优先匹配 1 (非模板函数)
Show(b); // 优先匹配 3 (显式具体化)
Show(c); // 匹配 2 (通用模板实例化 Show<char>)
Show<>(a); // 使用 <> 强制编译器只考虑模板版本,匹配 2 (通用模板实例化 Show<int>)

return 0;
}

8.5.6 模板函数的发展

函数模板是 C++ 泛型编程的基础。自 C++11 以来,模板功能得到了进一步增强:

  • auto 返回类型推导: 允许编译器根据 return 语句推导函数模板的返回类型。
  • 可变参数模板 (Variadic Templates): 允许定义接受任意数量、任意类型参数的模板(见 18.6 节)。
  • 别名模板 (using): 可以为模板创建别名。
  • Lambda 表达式: 可以创建匿名的函数对象,常与模板算法结合使用。
  • Concepts (C++20): 允许对模板参数施加更明确的约束,提高了编译时错误信息的可读性,并使模板意图更清晰。

函数模板是 C++ 中一个非常强大和灵活的特性,它使得编写高度通用和可重用的代码成为可能。

8.6 总结

本章深入探讨了 C++ 函数的更多高级特性,旨在提高代码的效率、灵活性和可重用性。

主要内容回顾:

  1. 内联函数 (inline):

    • 一种优化建议,请求编译器将函数代码直接替换到调用点,以减少小型、频繁调用函数的调用开销。
    • inline 只是建议,编译器可自行决定是否采纳。
    • 通常将内联函数定义放在头文件中。
    • 相比宏,内联函数具有类型安全、行为可预测等优点。
  2. 引用变量 (&):

    • 变量的别名,声明时必须初始化,之后不能再引用其他变量。
    • 按引用传递 (type&):函数参数成为原始实参的别名,允许函数修改原始数据,且避免了大型对象的复制开销。
    • 按常量引用传递 (const type&):函数参数成为原始实参的常量别名,不能通过引用修改原始数据,但同样避免了复制开销。这是传递大型对象进行只读访问的推荐方式。
    • 引用比指针在语法上更简洁,且通常不涉及空值问题。
  3. 默认参数:

    • 允许在函数声明(原型)中为参数指定默认值。
    • 调用函数时,如果省略了带有默认值的参数,则使用默认值。
    • 默认参数必须从参数列表的最右边开始指定。
    • 简化了函数调用,提高了函数的灵活性。
  4. 函数重载:

    • 允许在同一作用域内定义多个同名函数,只要它们的参数列表(数量、类型、顺序)不同。
    • 编译器根据调用时的实参来选择匹配的重载版本。
    • 返回类型不能作为区分重载函数的依据。
    • 适用于执行概念上相似但处理不同参数的任务。
  5. 函数模板 (template <typename T>):

    • 创建通用的、与类型无关的函数定义。
    • 编译器根据调用时使用的具体类型实例化相应的函数版本。
    • 重载模板: 可以定义多个同名模板,只要它们的参数列表或模板参数列表不同。
    • 显式具体化 (template <>): 为特定类型提供专门的、非模板的实现,以覆盖通用模板的行为。
    • 模板是 C++ 泛型编程的基础,极大地提高了代码的可重用性。

通过掌握这些高级函数特性,可以编写出更高效、更灵活、更易于维护的 C++ 代码。

评论