目录
(本图像复制于C++ - Wikipedia)
0.前言
在C++入门(一)中,我们已经探讨了C++的基础知识,包括关键字、命名空间以及输入输出的处理。这些都是构建我们编程基础的重要步骤。今天,我们将进入C++的下一个阶段——C++入门(二)。在这一部分,我们将探讨一些更高级的主题,如函数重载、引用、内联函数、auto关键字、范围for循环以及C++11标准引入的指针空值nullptr。这些主题不仅会加深我们对语言的理解,而且也是提高我们编程技巧的关键步骤。
1.函数重载
1.1概念
函数重载(Function Overloading)是C++中一个允许多个同名函数共存,但它们的参数类型或者参数个数不同的特性。这意味着,在同一作用域内,可以声明几个具有相同名字的函数,只要它们的参数列表不同即可。函数重载是多态性的一种表现形式,使得函数调用更加灵活,增强了程序的可读性和易用性。
当调用一个重载的函数时,编译器通过检查传递给函数的参数数量和类型,来决定调用哪个版本的函数。如果没有找到匹配的函数,或者找到多个同样适合的函数,编译器将报错。
例如,你可能需要一个函数来输出信息,但输出的信息可能是整数、浮点数或字符串。通过函数重载,你可以创建三个名为 print
的函数,每个函数接受不同类型的参数:
void print(int i) {
std::cout << "整数:" << i << std::endl;
}
void print(double f) {
std::cout << "浮点数:" << f << std::endl;
}
void print(const std::string& s) {
std::cout << "字符串:" << s << std::endl;
}
在这个例子中,print
函数被重载了三次:一次接受整数类型的参数,一次接受浮点类型的参数,还有一次接受字符串类型的参数。根据调用print
函数时提供的参数类型,编译器决定调用哪个版本的print
函数。这就是函数重载的基本概念。
1.2应用
函数重载在C++中的应用非常广泛,主要体现在能够用同一个函数名处理不同类型、不同数量的参数,甚至是参数顺序不同的情况,从而使程序的可读性和易用性大大增强。以下是一些典型应用场景和相关的重载函数代码示例:
1.2.1参数类型不同
函数重载可以根据参数的类型来选择合适的函数版本,使得同一操作可以适用于不同类型的数据。
void display(int i) {
std::cout << "Displaying int: " << i << std::endl;
}
void display(double d) {
std::cout << "Displaying double: " << d << std::endl;
}
void display(std::string s) {
std::cout << "Displaying string: " << s << std::endl;
}
在这个例子中,display
函数被重载三次,分别处理整数、浮点数和字符串类型的参数。这样就可以用同一个函数名来显示不同类型的数据。
1.2.2参数数量不同
重载函数也可以根据传递给函数的参数数量的不同来选择不同的函数版本。
void printValues(int i) {
std::cout << "Value: " << i << std::endl;
}
void printValues(int i, int j) {
std::cout << "Values: " << i << ", " << j << std::endl;
}
这里,printValues
函数有两个版本:一个接受单个整数参数,另一个接受两个整数参数。根据调用时提供的参数数量,编译器将决定调用哪个版本。
1.2.3参数顺序不同
即便参数数量和类型相同,只要参数的顺序不同,也可以进行函数重载。
void display(int i, double d) {
std::cout << "Int: " << i << ", Double: " << d << std::endl;
}
void display(double d, int i) {
std::cout << "Double: " << d << ", Int: " << i << std::endl;
}
在这个例子中,虽然两个display
函数都接受一个整数和一个浮点数作为参数,但它们的参数顺序不同,这也构成了重载。
1.3C++支持函数重载的原理
C++支持函数重载的原理基于两个关键的编译器技术:名称修饰(Name Mangling)和函数签名。
1.3.1名称修饰(Name Mangling)
名称修饰是指编译器内部将函数名和其参数类型的信息组合起来,生成一个独一无二的名称的过程。这一过程允许编译器区分不同的函数重载版本,即使它们的原始名称相同。名称修饰确保了每个函数重载版本在编译后的二进制代码中都有一个唯一的标识符。
例如,如果你有一个名为print
的函数重载,一个接受int
参数,另一个接受double
参数,编译器可能会将它们内部名称修饰为print_int
和print_double
(实际的修饰名称依赖于特定的编译器实现)。这样,当函数被调用时,编译器可以根据调用的参数类型来确定应该调用哪一个函数。
下面截图展示了Visual Studio2022的名称修饰规则:(截图自修饰名 | Microsoft Learn)
1.3.2函数签名
函数签名是指确定函数重载唯一性的参数列表,包括参数的类型、数量以及顺序。函数返回类型不被认为是函数签名的一部分,因此不能仅通过返回类型来重载函数。
编译器使用函数签名来区分不同的重载函数。在函数调用时,编译器会查看提供给函数的参数,并与已定义的函数签名进行匹配,以确定调用哪个版本的函数。如果找不到匹配的签名,或者有多个函数签名同样匹配调用参数(这种情况称为“歧义”),编译器将报错。
C++支持函数重载的原理允许开发者使用相同的函数名定义不同的操作,只要它们的参数列表(函数签名)不同。通过名称修饰和函数签名的机制,编译器能够在编译时解析函数调用,确定调用哪个重载版本。这种机制提高了代码的可读性和灵活性,允许更加自然地实现多态性。尽管函数重载增加了编程的便利性,但它也要求程序员更加细心地管理和设计函数接口,以避免歧义和混淆。
1.4注意事项
1.4.1 返回类型不构成重载
在C++中,仅仅通过改变函数的返回类型不能构成函数的重载。重载的函数必须至少在参数类型、数量或顺序上有所不同。
int func();
double func(); // 错误:不能仅通过返回类型来重载函数
1.4.2 默认参数可能导致歧义
使用重载函数时,默认参数可能导致调用歧义,因为它可能使得多个函数版本对于特定的函数调用都成为候选。
void display(int a, int b = 0);
void display(int a); // 调用display(10)时,这里会产生歧义
2.引用
2.1概念
在C++中,引用是一种复合类型,它是对象的一个别名。通过使用引用,可以创建一个新的名字(即引用),这个新名字引用(指向)已经存在的对象。引用在定义时必须被初始化,并且一旦被初始化后,就不能改变引用的目标。引用的语法和行为让它在某些情况下比指针更安全和更易于使用。
引用的基本语法如下:
Type& refName = existingVariable;
这里,Type
是被引用的变量的类型,refName
是引用的名称,而existingVariable
是已经存在的变量,refName
将成为existingVariable
的一个别名。
例如:
int x = 10; int& y = x;
在这个例子中,y
是对x
的引用。通过y
对x
的任何修改都会直接影响到x
,因为y
和x
指向同一内存位置。
2.2特性
C++中的引用有几个关键特性,这些特性定义了引用的行为和它们如何与变量、指针以及程序的其它部分交互。理解这些特性对于有效地使用引用至关重要。
2.2.1 引用作为别名
- 别名性:引用被创建为一个已存在变量的别名。对引用的任何操作实际上是直接作用于它所引用的变量。
int original = 5;
int& ref = original;
ref = 10; // original现在是10
2.2.2 必须初始化
- 初始化要求:在声明引用时,必须将其初始化为一个已存在变量的引用。一旦被初始化,引用就不能改变为另一个变量的引用。
int x = 5;
int& y = x; // 正确:引用被初始化
// int& z; // 错误:引用必须在声明时初始化
2.2.3 不可为空
- 非空性:引用必须连接到一个合法的存储位置;不像指针,它不能为
nullptr
或未定义。
2.2.4 不能重新绑定
- 不变性:引用被初始化后,就不能被重新指向另一个对象。引用的这个属性与常量指针类似。
2.2.5 不存在引用的引用
- 单层引用:C++不允许引用的引用、指向引用的指针或引用数组。
2.3常引用
常引用(const reference)是一种特殊类型的引用,它不允许通过引用去修改它所绑定的对象。常引用的使用场景广泛,特别是在需要保护数据不被修改的情况下,或者当函数参数为大型对象且不想在函数调用时进行拷贝,同时又需要保证函数内部不会修改这个对象时。
常引用的定义方式如下:
const Type& refName = variable;
这里,Type
是变量的类型,refName
是常引用的名称,而variable
是已经存在的变量。通过这种方式声明的引用,不允许通过引用来修改它所指向的数据。
使用常引用时,需要注意以下三点:
- 不可修改:通过常引用,无法修改它所绑定的对象,即使原对象本身不是
const
。 - 多用途:常引用对于提高函数参数传递的效率特别有用,特别是在处理大型数据或类对象时,同时它也是实现只读访问的有效方式。
- 临时对象绑定:将临时对象绑定到常引用上是合法的,这在某些特定场景下可以被用来优化性能和资源使用。
2.4使用场景
引用在C++中的使用非常广泛,提供了代码简化和性能优化的可能。理解引用的不同使用场景可以帮助大家更有效地利用这一特性。以下是引用在C++中的一些主要使用场景:
2.4.1 函数参数传递
-
避免对象拷贝的开销:当函数参数是大型对象时,通过值传递会导致整个对象的拷贝,这可能会带来显著的性能开销。使用引用传递可以避免这种拷贝,因为它只传递对象的引用而不是整个对象。
-
允许函数修改参数:通过引用传递,函数可以直接修改传入的参数,这在需要通过函数改变外部变量状态时非常有用。
void reset(int& x) {
x = 0; // 直接修改传入的参数
}
2.4.2 函数返回值
-
返回函数内部创建的静态对象:如果需要从函数返回局部静态对象的引用,使用引用可以避免对象拷贝,同时保证了返回的对象在函数调用后依然有效。
-
链式调用:通过返回对象的引用,可以实现函数或操作符的链式调用。
MyClass& MyClass::setX(int val) {
x = val;
return *this; // 允许链式调用
}
2.5传值和传引用的效率比较
在C++中,函数参数可以通过值传递(Pass by Value)或引用传递(Pass by Reference),这两种方式各有优缺点,特别是在效率方面。理解它们之间的区别对于编写高效的C++程序至关重要。
2.5.1传值
当使用传值方式时,函数会创建参数的一个副本。这意味着对函数参数的任何修改都不会影响到原始数据。这种方式在处理基础数据类型(如int
、char
等)时效率较高,因为这些类型的数据大小通常较小,且副本的创建成本不高。
优点:
- 简单安全:由于操作的是副本,函数内的修改不会影响到实际参数,降低了代码间的耦合。
- 自动内存管理:参数副本在函数调用结束时自动销毁,无需手动管理内存。
缺点:
- 效率低下:对于大型对象(如大数组、容器、用户定义的大型类等),复制整个对象会消耗较多的时间和内存。
- 仅适用于小型数据:对大型数据的处理效率低,可能导致性能瓶颈。
2.5.2传引用
传引用方式通过传递引用(或指针)来避免复制整个对象。函数接收的是引用,所以对参数的任何修改都会影响到原始数据。这种方式在处理大型对象或需要在函数内部修改实际参数的场景下更为高效。
优点:
- 高效:避免了大型对象的复制开销,特别是对于大型类、结构体或数组,可以显著提高程序的性能。
- 允许修改原始数据:通过引用传递,函数可以直接修改实际参数的值,提供了更大的灵活性。
缺点:
- 安全性问题:由于可以修改实际参数,如果不小心处理,可能导致数据被意外修改,增加了程序的复杂度。
- 生命周期问题:引用的对象必须确保在引用使用期间保持有效,否则可能会引发运行时错误。
让我们用下面这段代码来真实感受一下传值和传引用的效率差异:
#include <iostream>
#include <vector>
#include <ctime>
struct LargeStruct {
std::vector<int> largeVector;
// 构造函数,初始化一个含有1000000个元素的vector
LargeStruct() : largeVector(1000000, 42) {}
};
// 通过值传递的函数
void processByValue(LargeStruct ls) {
// 假设这里有一些处理逻辑,但实际不进行操作
}
// 通过引用传递的函数
void processByReference(const LargeStruct& ls) {
// 假设这里有一些处理逻辑,但实际不进行操作
}
int main() {
LargeStruct ls;
// 测量通过值传递的时间
std::clock_t startValue = std::clock();
for (int i = 0; i < 10000; ++i) {
processByValue(ls);
}
std::clock_t endValue = std::clock();
double elapsedValue = double(endValue - startValue) / CLOCKS_PER_SEC;
std::cout << "Pass by Value: " << elapsedValue << " seconds\n";
// 测量通过引用传递的时间
std::clock_t startReference = std::clock();
for (int i = 0; i < 10000; ++i) {
processByReference(ls);
}
std::clock_t endReference = std::clock();
double elapsedReference = double(endReference - startReference) / CLOCKS_PER_SEC;
std::cout << "Pass by Reference: " << elapsedReference << " seconds\n";
return 0;
}
在编译器版本为GCC11.4.0 64-bit的release模式下,运行结果如下图:(0表示运行时间小于1ms)
2.6引用 VS 指针
在C++中,引用和指针都允许以不同的方式间接访问数据,但它们之间存在一些关键的区别。理解这些区别有助于选择在特定情况下更适合的一种。
2.6.1引用
引用被设计为对另一个变量的别名,它必须在声明时被初始化,并且一旦绑定到一个变量,就不能再绑定到另一个变量。
2.6.1.1特性
- 别名性:引用作为一个已存在变量的别名存在,对引用的操作实际上是对该变量的操作。
- 不可为空:引用必须绑定到一个合法的存储位置,不能为
nullptr
。 - 不可重新绑定:一旦引用被初始化为某个变量的引用,就不能改变为引用另一个变量。
- 无需解引用:直接使用引用名操作数据,不需要特殊语法。
2.6.1.2用途
- 用于函数参数和返回值,特别是当你想要通过函数修改变量的值或者避免大型对象的拷贝时。
2.6.2指针
指针是一个变量,其值为另一个变量的地址,通过指针可以间接访问和修改该变量的值。
2.6.2.1特性
- 可变性:指针可以在其生命周期内指向不同的变量。
- 可为空:指针可以为
nullptr
,表示它不指向任何变量。 - 需要解引用:使用
*
操作符来解引用指针,访问或修改它所指向的变量的值。 - 指针算术:可以对指针进行算术运算,如递增(
++
)、递减(--
),或者计算两个指针之间的距离。
2.6.2.2用途
- 用于动态内存管理(如使用
new
和delete
操作符)。 - 实现复杂的数据结构,如链表、树、图等。
- 支持多态和动态分派(通过基类指针访问派生类对象)。
2.6.3引用 vs 指针
- 安全性:引用更安全,因为它们必须被初始化且不能为
nullptr
,而指针可以指向任何地方,增加了错误的可能性。 - 易用性:引用提供了更简洁的语法,不需要解引用操作,使得代码更易读写。
- 灵活性:指针提供了更多的灵活性,比如可以通过指针算术操作遍历数组,或者可以改变指向的对象。
- 用途差异:引用通常用于函数参数和返回值,以实现传递大型对象、避免对象拷贝、或者允许函数修改调用者的数据。指针则广泛用于动态内存管理、实现复杂数据结构、以及支持多态。
3.内联函数
3.1概念
内联函数(Inline Functions)是C++中一种提高函数执行效率的机制。当函数被声明为内联时,编译器在编译过程中会尝试将函数调用处替换为函数本身的代码。这意味着程序在执行到函数调用时,不会进行常规的函数调用(如压栈、跳转到函数代码位置、执行函数代码、返回和弹栈等),而是直接执行函数体中的代码。这个过程类似于宏替换,但内联函数比宏提供了类型检查等面向对象的优点:
- 提高效率:对于小型函数,使用内联可以消除函数调用的开销,尤其是在循环或频繁调用的场合。
- 代码组织:允许将小函数的定义放在头文件中,使得代码组织更为紧凑,便于理解和维护。
内联函数通过在函数声明前加上inline
关键字来指定:
inline int max(int a, int b)
{
return a > b ? a : b;
}
值得注意的是,inline
仅是对编译器的一个建议,而非强制要求。编译器会根据函数的复杂性、调用频率以及特定的编译策略来决定是否真正将函数内联。例如,对于递归函数或大型函数,编译器可能会忽略内联请求。
使用内联函数时,我们需要注意:
- 代码膨胀:过度使用内联函数可能会导致编译后的程序体积增大,因为每个内联函数调用点都会插入一份函数体的副本。
- 调试困难:内联函数的代码直接嵌入到调用处,可能会使得调试更为困难。
- 适用场景:通常,只有当函数体小、调用频繁且执行时间短于函数调用开销时,将函数声明为内联才是有意义的。
3.2特性
3.2.1 编译器优化
- 编译时决定:内联是在编译时进行的,编译器会根据函数的定义直接在每个调用点替换为函数体的代码。这是一种编译时优化。
- 非强制性:
inline
关键字对编译器而言是一个建议而非强制命令。编译器会根据函数的复杂度、调用上下文以及其他优化策略来决定是否进行内联。
3.2.2 减少函数调用开销
- 消除调用成本:内联函数通过替换调用点来消除函数调用的开销,包括参数传递、栈操作等。
- 提升执行效率:对于小型且频繁调用的函数,内联能显著提高程序的执行效率。
3.2.3 适用场景的局限性
- 小型函数优先:适合内联的典型场景是那些执行路径短、逻辑简单的小型函数。
- 递归函数限制:递归函数通常不适合内联,因为内联递归函数可能导致无限展开,编译器会忽略这种函数的内联请求。
3.2.4 调试难度
- 调试挑战:内联函数的调试可能比非内联函数更为复杂,因为调用点没有明显的函数调用过程,可能会使得跟踪程序执行流程更加困难。
3.2.5可见性要求
- 定义的可见性:为了让编译器在调用点内联函数,函数的定义必须对调用点可见,这通常意味着内联函数的定义需要在头文件中。
4.auto关键字——C++11
4.1简介
自C++11起,auto
关键字在C++中的含义被扩展,用于启用自动类型推导。在变量声明时使用auto
,编译器会根据变量的初始化表达式自动推导出变量的类型。这样做不仅可以减少编码时需要键入的类型名称的长度,还可以使代码更加清晰易读,特别是在处理复杂类型或模板类型时。
auto x = 5; // x 被推导为 int
auto y = 1.5; // y 被推导为 double
4.2使用细则
- 必须初始化:使用
auto
时,变量必须在声明时初始化,以便编译器能推导出其类型。
auto a = 42; // 正确
// auto b; // 错误,b没有初始化
- 统一的初始化表达式:当使用
auto
声明多个变量时,只能使用统一的初始化表达式,确保所有变量都是同一类型。
auto c = 0, *d = &c; // 正确,c是int,d是int*。
// auto e = 3.14, f = 42; // 错误,因为3.14和42的类型不同。
- 数组和函数自动推导:当用
auto
声明数组或函数类型时,会自动推导为指针。
int arr[] = {1, 2, 3};
auto arrPtr = arr; // arrPtr是int*类型。
int func(int);
auto funcPtr = func; // funcPtr是int(*)(int)类型,即指向函数的指针。
4.3注意事项
-
避免过度使用:虽然
auto
使得类型声明更简洁,但过度使用可能会降低代码的可读性,特别是在代码意图不明显的情况下。 -
不可用于函数参数:
auto
不能用于函数参数的类型声明,这种情况下应使用模板或具体的类型名称。 -
推导的是值类型而非引用:当用
auto
对引用类型进行初始化时,推导出的是被引用对象的值类型,而非引用类型。若需要保持引用性质,应使用auto&
或auto&&
。
5.范围for循环——C++11
5.1语法
C++11引入了范围for
循环(也称为基于范围的for
循环),它提供了一种更简洁、更安全的方式来遍历容器或数组中的所有元素。范围for
循环自动迭代容器或数组的开始到结束,无需手动管理迭代器或索引。
基本语法如下:
for (declaration : range) {
// 循环体
}
- declaration:用于每次迭代时存储当前元素值的变量声明。这个变量可以是一个值类型(将会拷贝容器中的元素)或者引用类型(直接引用容器中的元素)。
- range:要迭代的范围,可以是数组、容器或任何支持
begin()
和end()
成员函数的对象。
示例
std::vector<int> vec = {1, 2, 3, 4, 5};
// 使用自动类型推导遍历vector
for (auto v : vec) {
std::cout << v << " ";
}
// 使用引用遍历并修改vector中的元素
for (auto& v : vec) {
v *= 2; // 将每个元素翻倍
}
5.2使用条件
范围for
循环可以用于任何提供了begin()
和end()
迭代器成员函数的类型。这包括了所有的STL容器(如vector
、list
、map
等),字符串(std::string
)以及静态数组。它为C++程序员提供了一种更现代、更安全的方式来遍历数据结构。
使用范围for
循环的条件:
- 迭代容器:当需要遍历STL容器中的所有元素时,范围
for
循环是最简洁的选择。 - 操作数组:对于静态数组,范围
for
循环提供了一种简单的迭代方式,无需关心数组的大小。 - 只读访问:当仅需要读取元素值时,使用范围
for
循环与自动类型推导(auto
)结合能够提供简洁的代码。 - 修改元素:当需要修改容器或数组中的元素时,应该使用引用(
auto&
)作为循环变量的类型。
至于什么是STL,迭代器等等,读者可以自行查阅,我在后续的博客中也会介绍。
6.指针空值nullptr——C++11
在C++11之前,C++程序员通常使用宏NULL
来表示空指针。NULL
实际上是0
的宏定义,用来初始化空指针。然而,使用NULL
存在类型安全性问题和一些模糊的场景,特别是在函数重载和模板编程中。为了解决这些问题,C++11引入了一个新的关键字nullptr
,专门用来表示空指针。
6.1 概念
nullptr
是一个字面量,代表指针类型的空值。它的类型是nullptr_t
,可以自动转换为任何原始指针类型,但不能转换为整型,这提高了类型安全。
6.2 使用细则
- 类型安全:与
NULL
相比,nullptr
提供了更好的类型安全。nullptr
可以被明确地区分为非整型,避免了与整数之间的混淆。 - 函数重载:在涉及到函数重载的情况下,
nullptr
的引入解决了使用NULL
可能引起的歧义问题。编译器可以准确地识别出指向nullptr
的重载版本。
void func(char *ptr) {
// 处理字符指针
}
void func(int i) {
// 处理整数
}
// 使用nullptr
func(nullptr); // 明确调用 void func(char *ptr)
- 与布尔值的兼容:
nullptr
可以在需要布尔值的上下文中隐式转换为false
,在逻辑表达式中使用时非常方便。
if (ptr != nullptr) {
// 如果ptr不是空指针,则执行
}
6.3 注意事项
- 不可与整型直接比较:尽管
nullptr
可以在逻辑判断中隐式转换为false
,但它不能直接与整型进行比较,这有助于避免类型混淆和潜在的错误。 - 广泛支持:几乎所有现代C++编译器都支持
nullptr
,建议在C++11及更高版本的代码中使用nullptr
来代替NULL
或裸0
。 - 兼容性:为了与C++11之前的代码库保持兼容,
nullptr
设计为可以自动转换为任何原始指针类型,但最好在新的代码中统一使用nullptr
。
7.结语
在"C++入门(一)"和"C++入门(二)"中,我们探索了C++语言的基础和一些进阶特性。从最初的关键字、命名空间,到函数重载、引用、内联函数,再到现代C++中的auto
关键字、范围for
循环以及nullptr
的使用,每一步都是为了让我们更加深入地理解C++的强大能力和灵活性。这些知识点不仅构成了C++编程的基石,也为进一步的学习和实践打下了坚实的基础。希望这两部分的内容能够帮助大家建立起坚实的C++基础,为探索更多C++的高级特性和最佳实践做好准备。