C++ 编程入门指南(三)
原文:
annas-archive.org/md5/024671a6ef06ea57693023eca62b8eea译者:飞龙
使用函数
函数是 C++的基本基础设施;代码包含在函数中,要执行该代码,你必须调用一个函数。C++在定义和调用函数的方式上非常灵活:你可以定义具有固定数量参数或可变数量参数的函数;你可以编写通用代码,以便相同的代码可以用于不同的类型;甚至可以编写具有可变数量类型的通用代码。
定义 C++函数
在最基本的层面上,函数有参数,有用于操作参数的代码,并返回一个值。C++提供了几种方法来确定这三个方面。在接下来的部分中,我们将从声明的左边到右边来介绍 C++函数的这些部分。函数也可以是模板的,但这将留到以后的部分。
声明和定义函数
函数必须被定义一次,但通过重载,你可以有许多具有相同名称但参数不同的函数。使用函数的代码必须能够访问函数的名称,因此它需要访问函数的定义(例如,函数在源文件中较早地定义)或函数的声明(也称为函数原型)。编译器使用原型来对调用代码进行类型检查,以确保使用正确的类型调用函数。
通常,库被实现为单独的编译库文件,并且库函数的原型在头文件中提供,以便许多源文件可以通过包含这些头文件来使用这些函数。然而,如果你知道函数的名称、参数和返回类型,你可以在你的文件中自己输入原型。无论你做什么,你只是提供信息给编译器来对调用函数的表达式进行类型检查。链接器负责在库中定位函数,并将代码复制到可执行文件中,或者设置基础设施以从共享库中使用函数。包含库的头文件并不意味着你可以使用该库中的函数,因为在标准 C++中,头文件并不包含包含函数的库的信息。
Visual C++提供了一个名为pragma的comment,它可以与lib选项一起使用,作为向链接器发送链接到特定库的消息。因此,在头文件中使用#pragma comment(lib, "mylib")将告诉链接器链接到mylib.lib。一般来说,最好使用项目管理工具,如nmake或MSBuild,以确保正确的库被链接到项目中。
大部分 C 运行库都是这样实现的:函数被编译为静态库或动态链接库,函数的原型在头文件中提供。你需要在链接器命令行中提供库,并且通常你会包含库的头文件,以便编译器可以使用函数的原型。只要链接器知道库的存在,你就可以在你的代码中输入原型(并将其描述为外部链接,以便编译器知道函数是在其他地方定义的)。这可以避免将一些大文件包含到你的源文件中,这些文件大多数情况下只包含你不会使用的函数的原型。
然而,C++标准库的大部分实现在头文件中,这意味着这些文件可能会非常大。你可以通过将这些头文件包含在预编译头文件中来节省编译时间,如第一章中所述,开始学习 C++。
到目前为止,在本书中,我们只使用了一个源文件,因此所有的函数都是在使用它们的同一个文件中定义的,并且我们在调用函数之前定义了函数,也就是说,函数在调用它的代码上方被定义。只要在调用函数之前定义了函数原型,就不必在使用函数之前定义函数:
int mult(int, int);
int main()
{
cout << mult(6, 7) << endl;
return 0;
}
int mult(int lhs, int rhs)
{
return lhs * rhs;
}
mult函数在main函数之后定义,但这段代码将会编译,因为在main函数之前给出了原型。这被称为前向声明。原型不必有参数名。这是因为编译器只需要知道参数的类型,而不需要它们的名称。然而,由于参数名应该是自我说明的,通常最好给出参数名,以便你可以看到函数的目的。
指定链接
在前面的例子中,函数在同一个源文件中定义,因此具有内部链接。如果函数在另一个文件中定义,原型将具有外部链接,因此原型必须这样定义:
extern int mult(int, int); // defined in another file
extern关键字是你可以添加到函数声明中的许多限定符之一,在前几章中我们已经见过其他的。例如,static限定符可以用于原型,表示函数具有内部链接,名称只能在当前源文件中使用。在前面的例子中,在原型中将函数标记为static是合适的。
static int mult(int, int); // defined in this file
你还可以将函数声明为extern "C",这会影响函数名称在目标文件中的存储方式。这对库很重要,不久将会介绍。
内联
如果函数计算的值可以在编译时计算,你可以在声明的左边使用constexpr标记它,以指示编译器可以通过在编译时计算值来优化代码。如果函数值可以在编译时计算,这意味着函数调用中的参数必须在编译时已知,因此它们必须是文字。函数还必须是单行的。如果不满足这些限制,那么编译器可以自由地忽略该限定符。
相关的是inline限定符。这可以放在函数声明的左边,作为对编译器的建议,当其他代码调用函数时,编译器不是插入一个跳转到内存中的函数(和创建一个堆栈帧),而是将实际代码的副本放在调用函数中。同样,编译器可以自由地忽略这个限定符。
确定返回类型
函数可能被编写为运行一个例程而不返回值。如果是这种情况,你必须指定函数返回void。在大多数情况下,函数会返回一个值,即使只是表示函数已经正确完成。没有要求调用函数获取返回值或对其进行任何操作。调用函数可以简单地忽略返回值。
有两种方法可以指定返回类型。第一种方法是在函数名之前给出类型。这是到目前为止大多数示例中使用的方法。第二种方法称为尾返回类型,要求在函数名之前将auto作为返回类型,并使用->语法在参数列表之后给出实际的返回类型:
inline auto mult(int lhs, int rhs) -> int
{
return lhs * rhs;
}
这个函数非常简单,因此很适合内联。左边的返回类型是auto,意味着实际的返回类型在参数列表之后指定。-> int表示返回类型是int。这种语法与在左边使用int具有相同的效果。当函数是模板化的且返回类型可能不明显时,这种语法很有用。
在这个简单的例子中,你可以完全省略返回类型,只需在函数名的左边使用auto。这种语法意味着编译器将根据实际返回的值推断返回类型。显然,编译器只能从函数体知道返回类型,因此你不能为这种函数提供原型。
最后,如果一个函数根本不返回(例如,如果它进入一个永不结束的循环来轮询某个值),你可以使用 C++11 属性[[noreturn]]标记它。编译器可以使用这个属性来编写更高效的代码,因为它知道不需要提供返回值的代码。
命名函数
一般来说,函数名的规则与变量相同:它们必须以字母或下划线开头,不能包含空格或其他标点符号。遵循自解释代码的一般原则,你应该根据函数的功能来命名函数。有一个例外,那就是用于为运算符提供重载的特殊函数(大部分是标点符号)。这些函数的名称形式为operatorx,其中x是你在代码中将使用的运算符。后面的部分将解释如何使用全局函数实现运算符。
运算符是重载的一个例子。你可以重载任何函数,也就是说,使用相同的名称但提供不同参数类型或不同数量的参数的实现。
函数参数
函数可能没有参数,这种情况下函数定义为一对空括号。函数定义必须在括号之间给出参数的类型和名称。在许多情况下,函数将有固定数量的参数,但你可以编写具有可变数量参数的函数。你还可以为一些参数定义默认值,实际上,提供了一个根据传递给函数的参数数量进行重载的函数。可变参数列表和默认参数将在后面介绍。
指定异常
函数还可以标记是否会抛出异常。关于异常的更多细节将在第十章 诊断和调试中给出,但你需要了解两种语法。
早期版本的 C++允许你以三种方式在函数上使用throw说明符:首先,你可以提供一个逗号分隔的异常类型列表,这些异常可能由函数中的代码抛出;其次,你可以提供一个省略号(...),这意味着函数可能抛出任何异常;第三,你可以提供一个空的括号对,这意味着函数不会抛出异常。语法看起来像这样:
int calculate(int param) throw(overflow_error)
{
// do something which potentially may overflow
}
throw说明符在 C++11 中已经被弃用,主要是因为指示异常类型的能力并不实用。然而,C++11 保留了指示不会抛出异常的throw版本,因为它使编译器能够通过提供不处理异常的代码基础设施来优化代码。C++11 使用noexcept说明符保留了这种行为:
// C++11 style:
int increment(int param) noexcept
{
// check the parameter and handle overflow appropriately
}
函数体
确定了返回类型、函数名和参数之后,你需要定义函数的主体。函数的代码必须出现在一对大括号({})之间。如果函数返回一个值,那么函数必须至少有一行(函数中的最后一行)带有return语句。这个语句必须返回适当的类型或者可以隐式转换为函数返回类型的类型。如前所述,如果函数声明为返回auto,那么编译器将推断返回类型。在这种情况下,所有的return语句必须返回相同的类型。
使用函数参数
当调用函数时,编译器会检查函数的所有重载,以找到与调用代码中的参数匹配的函数。如果没有完全匹配,则执行标准和用户定义的类型转换,因此调用代码提供的值可能与参数的类型不同。
默认情况下,参数是按值传递的,并且会进行复制,这意味着参数在函数中被视为局部变量。函数的编写者可以决定通过指针或 C++引用通过引用传递参数。按引用传递意味着调用代码中的变量可以被函数修改,但这可以通过使参数const来控制,这样按引用传递的原因是为了防止进行(可能昂贵的)复制。内置数组始终作为指向数组第一项的指针传递。编译器将在需要时创建临时对象。例如,当参数是const引用时,调用代码传递文字时,将创建临时对象,并且仅可用于函数中的代码:
void f(const float&);
f(1.0); // OK, temporary float created
double d = 2.0;
f(d); // OK, temporary float created
传递初始化列表
如果该列表可以转换为参数的类型,则可以将初始化列表作为参数传递。例如:
struct point { int x; int y; };
void set_point(point pt);
int main()
{
point p;
p.x = 1; p.y = 1;
set_point(p);
set_point({ 1, 1 });
return 0;
}
此代码定义了一个具有两个成员的结构。在main函数中,在堆栈上创建了一个point的新实例,并通过直接访问成员对其进行初始化。然后将该实例传递给具有point参数的函数。由于set_point的参数是按值传递的,编译器会在函数的堆栈上创建结构的副本。第二次调用set_point也是如此:编译器将在函数的堆栈上创建一个临时的point对象,并使用初始化列表中的值对其进行初始化。
使用默认参数
有时您有一个或多个参数,其值使用得如此频繁,以至于您希望它们被视为参数的默认值,同时又可以允许调用者在必要时提供不同的值。为此,您在定义的参数列表中提供默认值:
void log_message(const string& msg, bool clear_screen = false)
{
if (clear_screen) clear_the_screen();
cout << msg << endl;
}
在大多数情况下,预计该函数用于打印单个消息,但偶尔用户可能希望首先清除屏幕(例如,对于第一条消息,或在预定的行数之后)。为了适应函数的这种用法,clear_screen参数被赋予了默认值false,但调用者仍然可以选择传递一个值:
log_message("first message", true);
log_message("second message");
bool user_decision = ask_user();
log_message("third message", user_decision);
请注意,默认值出现在函数定义中,而不是在函数原型中,因此如果log_message函数在头文件中声明,则原型应为:
extern void log_message(const string& msg, bool clear_screen);
可以具有默认值的参数是最右边的参数。
您可以将具有默认值的每个参数视为函数的单独重载,因此在概念上,log_message函数应该被视为两个函数:
extern void log_message(const string& msg, bool clear_screen);
extern void log_message(const string& msg); // conceptually
如果定义了一个只有const string&参数的log_message函数,那么编译器将不知道是调用该函数还是clear_screen被赋予默认值false的版本。
参数的数量可变
具有默认参数值的函数可以被视为具有用户提供的可变数量的参数,其中您在编译时知道参数的最大数量和它们的值,如果调用者选择不提供值。C++还允许您编写函数,其中对于参数的数量以及传递给函数的值存在较少的确定性。
有三种方法可以具有可变数量的参数:初始化列表,C 风格的可变参数列表和可变模板函数。这三种方法中的后者将在本章后面讨论,一旦涵盖了模板函数。
初始化列表
到目前为止,在这本书中,初始化列表被视为一种 C++11 构造,有点像内置数组。实际上,当你使用大括号的初始化列表语法时,编译器实际上会创建一个模板化的initialize_list类的实例。如果使用初始化列表来初始化另一种类型(例如,初始化一个vector),编译器会使用大括号中给定的值创建一个initialize_list对象,并使用initialize_list迭代器初始化容器对象。这种从大括号初始化列表创建initialize_list对象的能力可以用来给函数传递可变数量的参数,尽管所有参数都必须是相同类型的:
#include <initializer_list>
int sum(initializer_list<int> values)
{
int sum = 0;
for (int i : values) sum += i;
return sum;
}
int main()
{
cout << sum({}) << endl; // 0
cout << sum({-6, -5, -4, -3, -2, -1}) << endl; // -21
cout << sum({10, 20, 30}) << endl; // 60
return 0;
}
sum函数有一个initializer_list<int>的单一参数,它只能用整数列表进行初始化。initializer_list类的函数非常少,因为它只存在于给予大括号列表中的值的访问。重要的是,它实现了一个size函数,返回列表中的项目数,以及begin和end函数,返回指向列表中第一个项目和最后一个项目后面位置的指针。这两个函数是为了给列表提供迭代器访问而需要的,并且它使你可以使用范围-for语法来使用对象。
这在 C++标准库中很典型。如果一个容器在内存中以连续的内存块保存数据,那么指针算术可以使用指向第一个项目的指针和指向最后一个项目后面的指针来确定容器中有多少项目。递增第一个指针可以顺序访问每个项目,并且指针算术允许随机访问。所有容器都实现了begin和end函数,以便访问容器的迭代器。
在这个例子中,main函数三次调用这个函数,每次都使用大括号初始化列表,并且函数将返回列表中项目的总和。
显然,这种技术意味着可变参数列表中的每个项目都必须是相同类型(或者可以转换为指定类型的类型)。如果参数是一个vector,你会得到相同的结果;不同之处在于initializer_list参数需要更少的初始化。
参数列表
C++继承了 C 的参数列表的概念。为了实现这一点,你可以使用省略号语法(...)作为最后一个参数,表示调用者可以提供零个或多个参数。编译器将检查函数的调用方式,并在堆栈上为这些额外的参数分配空间。要访问额外的参数,你的代码必须包含<cstdarg>头文件,其中包含了可以用来从堆栈中提取额外参数的宏。
这是固有的类型不安全,因为编译器无法检查函数在运行时从堆栈中获取的参数是否与调用代码放在堆栈上的参数类型相同。例如,以下是一个求和整数的函数实现:
int sum(int first, ...)
{
int sum = 0;
va_list args;
va_start(args, first);
int i = first;
while (i != -1)
{
sum += i;
i = va_arg(args, int);
}
va_end(args);
return sum;
}
函数的定义必须至少有一个参数,以便宏起作用;在这种情况下,参数被称为first。重要的是,你的代码要保持堆栈处于一致的状态,这是通过va_list类型的变量来实现的。这个变量在函数开始时通过调用va_start宏进行初始化,并在函数结束时通过调用va_end宏将堆栈恢复到其先前的状态。
这个函数中的代码简单地遍历参数列表,并维护一个总和,当参数的值为-1 时循环结束。没有宏可以提供关于堆栈上有多少参数的信息,也没有宏可以提供关于堆栈上参数的类型的指示。你的代码必须假设变量的类型,并在va_arg宏中提供所需的类型。在这个例子中,va_arg被调用,假设堆栈上的每个参数都是int。
当所有参数都从堆栈中读取完毕后,代码在返回总和之前调用va_end。函数可以这样调用:
cout << sum(-1) << endl; // 0
cout << sum(-6, -5, -4, -3, -2, -1) << endl; // -20 !!!
cout << sum(10, 20, 30, -1) << endl; // 60
由于-1用于指示列表的结尾,这意味着要对零个参数求和,你必须至少传递一个参数,即-1。此外,第二行显示了如果传递了一系列负数(在这种情况下-1不能是一个参数),那么你会遇到问题。在这个实现中,这个问题可以通过选择另一个标记值来解决。
另一种实现可以放弃使用列表末尾的标记,而是使用第一个必需的参数来给出后续参数的数量:
int sum(int count, ...)
{
int sum = 0;
va_list args;
va_start(args, count);
while(count--)
{
int i = va_arg(args, int);
sum += i;
}
va_end(args);
return sum;
}
这一次,第一个值是后面跟随的参数的数量,因此例程将从堆栈中提取这个确切数量的整数并对它们求和。代码的调用方式如下:
cout << sum(0) << endl; // 0
cout << sum(6, -6, -5, -4, -3, -2, -1) << endl; // -21
cout << sum(3, 10, 20, 30) << endl; // 60
没有约定来处理确定传递了多少参数的问题。
该例程假设堆栈上的每个项都是int,但在函数的原型中没有关于这一点的信息,因此编译器无法对调用函数时实际使用的参数进行类型检查。如果调用者提供了不同类型的参数,可能会从堆栈中读取错误数量的字节,使得对va_arg的所有其他调用的结果无效。考虑这个:
cout << sum(3, 10., 20, 30) << endl;
同时按下逗号和句号键很容易,这在输入10参数后发生了。句号意味着10是一个double,因此编译器将一个double值放在堆栈上。当函数使用va_arg宏从堆栈中读取值时,它将把 8 字节的double读取为两个 4 字节的int值,对于 Visual C++生成的代码,这将导致总和为1076101140。这说明了参数列表的类型不安全方面:你无法让编译器对传递给函数的参数进行类型检查。
如果你的函数传递了不同的类型,那么你必须实现一些机制来确定这些参数是什么类型。参数列表的一个很好的例子是 C 的printf函数:
int printf(const char *format, ...);
这个函数的必需参数是一个格式字符串,而且这个格式字符串包含一个变量参数及其类型的有序列表。格式字符串提供了通过<cstdarg>宏不可用的信息:可变参数的数量和每个参数的类型。printf函数的实现将遍历格式字符串,当它遇到一个参数的格式说明符(以%开头的字符序列)时,它将使用va_arg从堆栈中读取期望的类型。很明显,C 风格的参数列表并不像它们一开始看起来那样灵活;而且,它们可能非常危险。
函数特性
函数是应用程序或库中定义的模块化代码片段。如果一个函数是由另一个供应商编写的,重要的是你的代码以供应商预期的方式调用函数。这意味着理解所使用的调用约定以及它如何影响堆栈。
调用堆栈
当您调用函数时,编译器将为新函数调用创建一个堆栈帧,并将项目推送到堆栈上。放在堆栈上的数据取决于您的编译器以及代码是为调试还是发布构建而编译的;但是,一般来说,将有关于传递给函数的参数、返回地址(函数调用后的地址)以及函数中分配的自动变量的信息。
这意味着,在运行时进行函数调用时,将会有一个内存开销和性能开销,因为在函数运行之前创建堆栈帧,并在函数完成后进行清理,会有性能开销。如果函数是内联的,这种开销就不会发生,因为函数调用将使用当前堆栈帧而不是新的堆栈帧。显然,内联函数应该很小,无论是代码还是堆栈上使用的内存。编译器可以忽略inline说明符,并使用单独的堆栈帧调用函数。
指定调用约定
当您的代码使用自己的函数时,您不需要关注调用约定,因为编译器将确保使用适当的约定。但是,如果您正在编写可以被其他 C++编译器使用,甚至可以被其他语言使用的库代码,那么调用约定就变得重要起来。由于本书不涉及可互操作的代码,我们不会深入讨论,而是将关注两个方面:函数命名和堆栈维护。
使用 C 链接
当您给 C++函数命名时,这是您在 C++代码中调用函数时将使用的名称。但是,在底层,C++编译器将使用额外的符号为返回类型和参数修饰名称,以便重载函数都有不同的名称。对于 C++开发人员来说,这也被称为名称修饰。
如果您需要通过共享库(在 Windows 中为动态链接库)导出函数,您必须使用其他语言可以使用的类型和名称。为此,您可以使用extern "C"标记函数。这意味着函数具有 C 链接,并且编译器不会使用 C++名称修饰。显然,您应该仅在外部代码将使用的函数上使用此功能,并且不应将其用于具有返回值和使用 C++自定义类型的参数的函数。但是,如果这样的函数确实返回 C++类型,编译器将只发出警告。原因是 C 是一种灵活的语言,C 程序员将能够弄清楚如何将 C++类型转换为可用的东西,但滥用它们是不好的做法!
extern "C"链接也可以用于全局变量,并且可以在单个项目上使用它,也可以(使用大括号)在多个项目上使用它。
指定如何维护堆栈
Visual C++支持六种调用约定,您可以在函数上使用。__clrcall说明符表示该函数应该作为.NET 函数调用,并允许您编写具有混合本机代码和托管代码的代码。C++/CLR(Microsoft 的 C++语言扩展,用于编写.NET 代码)超出了本书的范围。其他五种用于指示参数如何传递给函数(在堆栈上还是使用 CPU 寄存器)以及谁负责维护堆栈。我们只会涵盖三种:__cdecl,__stdcall和__thiscall。
您很少会显式使用__thiscall;它是用于自定义类型成员函数的调用约定,并且指示函数具有一个隐藏参数,该参数是可以通过函数中的this关键字访问的对象的指针。更多细节将在下一章中给出,但重要的是要意识到这样的成员函数具有不同的调用约定,特别是当您需要初始化函数指针时。
默认情况下,C++全局函数将使用__cdecl调用约定。堆栈由调用代码维护,因此在调用代码中,对__cdecl函数的每次调用后都会有清理堆栈的代码。这使得每个函数调用都会变得稍微大一些,但这是为了能够使用可变参数列表。大多数 Windows SDK 函数使用__stdcall调用约定,它表示被调用的函数清理堆栈,因此在调用代码中不需要生成这样的代码。显然,编译器知道函数使用__stdcall是很重要的,否则它将生成代码来清理已经被函数清理的堆栈帧。通常会看到使用WINAPI标记的 Windows 函数,这是__stdcall的typedef。
使用递归
在大多数情况下,调用堆栈的内存开销并不重要。然而,当您使用递归时,可能会建立一个很长的堆栈帧链。顾名思义,递归是指一个函数调用自身。一个简单的例子是计算阶乘的函数:
int factorial(int n)
{
if (n > 1) return n ∗ factorial(n − 1);
return 1;
}
如果您为 4 调用此函数,则会进行以下调用:
factorial(4) returns 4 * factorial(3)
factorial(3) returns 3 * factorial(2)
factorial(2) returns 2 * factorial(1)
factorial(1) returns 1
重要的一点是,在递归函数中必须至少有一种方法可以在没有递归的情况下离开函数。在这种情况下,当使用参数 1 调用factorial时,函数将结束。在实践中,这样的函数应该标记为inline,以避免创建任何堆栈帧。
函数重载
您可以有几个具有相同名称的函数,但参数列表不同(参数的数量和/或参数的类型)。这就是函数重载。当调用这样的函数时,编译器将尝试找到最适合提供的参数的函数。如果没有合适的函数,编译器将尝试转换参数,看看是否存在具有这些类型的函数。编译器将从简单的转换开始(例如,数组名称到指针,类型到const类型),如果失败,编译器将尝试提升类型(例如,bool到int)。如果失败,编译器将尝试标准转换(例如,引用到类型)。如果这样的转换导致有多个可能的候选项,那么编译器将发出函数调用模糊的错误。
函数和作用域
编译器在寻找合适的函数时也会考虑函数的作用域。您不能在函数内定义函数,但是您可以在函数的作用域内提供函数原型,并且编译器将尝试(如果需要通过转换)首先调用具有这样原型的函数。考虑以下代码:
void f(int i) { /*does something*/ }
void f(double d) { /*does something*/ }
int main()
{
void f(double d);
f(1);
return 0;
}
在这段代码中,函数f被重载为一个接受int的版本和一个接受double的版本。通常,如果您调用f(1),那么编译器将调用函数的第一个版本。然而,在main中有一个接受double的版本的原型,并且int可以被转换为double而不会丢失信息。原型在与函数调用相同的作用域内,因此在这段代码中,编译器将调用接受double的版本。这种技术本质上隐藏了带有int参数的版本。
删除函数
有一种比使用作用域更正式的隐藏函数的方法。C++将尝试显式转换内置类型。例如:
void f(int i);
您可以使用int调用此函数,或者任何可以转换为int的东西:
f(1);
f('c');
f(1.0); // warning of conversion
在第二种情况下,char是一个整数,因此它被提升为int并调用函数。在第三种情况下,编译器将发出警告,表示转换可能会导致数据丢失,但这只是一个警告,所以代码将编译。如果要防止这种隐式转换,可以删除您不希望调用者使用的函数。要做到这一点,提供一个原型并使用语法= delete:
void f(double) = delete;
void g()
{
f(1); // compiles
f(1.0); // C2280: attempting to reference a deleted function
}
现在,当代码尝试使用char或double(或float,它将被隐式转换为double)调用函数时,编译器将发出错误。
按值传递和按引用传递
默认情况下,编译器将按值传递参数,即会创建一个副本。如果传递自定义类型,则会调用其复制构造函数来创建一个新对象。如果传递指向内置类型或自定义类型对象的指针,则指针将按值传递,即在函数堆栈上为参数创建一个新指针,并用传递给函数的内存地址进行初始化。这意味着在函数中,您可以更改指针指向的其他内存(如果您想对该指针进行指针算术运算,则这很有用)。指针指向的数据将通过引用传递,即数据保留在函数外部,但函数可以使用指针来更改数据。同样,如果在参数上使用引用,则表示通过引用传递对象。显然,如果在指针或引用参数上使用const,则这将影响函数是否可以更改指向或引用的数据。
在某些情况下,您可能希望从函数中返回多个值,并且您可能选择使用函数的返回值来指示函数是否正确执行。做到这一点的一种方法是将其中一个参数设置为out参数,即它是一个指针或引用,指向函数将要改变的对象或容器:
// don't allow any more than 100 items
bool get_items(int count, vector<int>& values)
{
if (count > 100) return false;
for (int i = 0; i < count; ++i)
{
values.push_back(i);
}
return true;
}
要调用此函数,必须创建一个vector对象并将其传递给函数:
vector<int> items {};
get_items(10, items);
for(int i : items) cout << i << ' ';
cout << endl
因为values参数是一个引用,这意味着当get_values调用push_back来在values容器中插入一个值时,实际上是将该值插入到items容器中。
如果通过指针传递 out 参数,则重要的是查看指针声明。一个*表示变量是一个指针,两个表示它是一个指向指针的指针。以下函数通过 out 参数返回一个int:
bool get_datum(/*out*/ int *pi);
代码的调用方式如下:
int value = 0;
if (get_datum(&value)) { cout << "value is " << value << endl; }
else { cout << "cannot get the value" << endl;}
这种返回成功值的模式经常被使用,特别是在访问跨进程或机器边界的数据的代码中。函数返回值可用于提供有关调用失败原因的详细信息(无网络访问?无效的安全凭据?等等),并指示应丢弃 out 参数中的数据。
如果 out 参数有一个双*,那么意味着返回值本身是一个指针,可以是指向单个值或数组的指针:
bool get_data(/*in/out*/ int *psize, /*out*/ int **pi);
在这种情况下,您使用第一个参数传入您想要的缓冲区的大小,并在返回时通过此参数接收缓冲区的实际大小(它是 in/out),以及第二个参数中的缓冲区的指针:
int size = 10;
int *buffer = nullptr;
if (get_data(&size, &buffer))
{
for (int i = 0; i < size; ++i)
{
cout << buffer[i] << endl;
}
delete [] buffer;
}
任何返回内存缓冲区的函数都必须记录谁有责任释放内存。在大多数情况下,通常是调用者,就像这个示例代码中假设的那样。
设计函数
通常函数将对全局数据或由调用者传入的数据进行操作。重要的是,当函数完成时,它将使这些数据处于一致的状态。同样重要的是,函数在访问数据之前可以对数据做出假设。
前置条件和后置条件
函数通常会改变一些数据:传递给函数的值,函数返回的数据,或一些全局数据。在设计函数时,确定将访问和更改的数据,并记录这些规则是很重要的。
函数将具有前置条件,即它将使用的数据的假设。例如,如果一个函数传递了一个文件名,并且意图是从文件中提取一些数据,那么谁负责检查文件是否存在?你可以让函数负责,并且前几行将检查该名称是否是文件的有效路径,并调用操作系统函数来检查文件是否存在。然而,如果你有几个函数将对文件执行操作,你将在每个函数中复制这个检查代码,把这个责任放在调用代码上可能更好。显然,这样的操作可能很昂贵,因此重要的是避免调用代码和函数执行检查。
《第十章》(5a2f36d4-7d0b-4f2d-ae7c-f9e51f5d7dc4.xhtml),诊断和调试,将描述如何添加调试代码,称为断言,你可以将其放在函数中,以检查参数的值,以确保调用代码遵循你设置的前置条件规则。断言是使用条件编译定义的,因此只会出现在调试构建中(即使用调试信息编译的 C++代码)。发布构建(将交付给最终用户的完成代码)将有条件地将断言编译掉;这使得代码更快,如果你的测试足够彻底,你可以确保前置条件得到满足。
你还应该记录函数的后置条件。也就是说,关于函数返回的数据的假设(通过函数返回值、输出参数或引用传递的参数)。后置条件是调用代码将做出的假设。例如,你可能返回一个有符号整数,而函数本来应该返回一个正值,但使用负值来表示错误。通常,返回指针的函数在失败时会返回nullptr。在这两种情况下,调用代码知道需要检查返回值,并且只有在它是正值或不是nullptr时才使用它。
使用不变量
你应该小心记录函数如何使用函数外部的数据。如果函数的意图是改变外部数据,你应该记录函数将做什么。如果你没有明确记录函数对外部数据的影响,那么你必须确保当函数完成时,这些数据保持不变。原因是调用代码只会假设你在文档中所说的,并且改变全局数据的副作用可能会导致问题。有时需要存储全局数据的状态,并在函数返回之前将项目返回到该状态。
我们已经在《第三章》(b1227194-5dda-4c73-a8e8-e8d68382abf0.xhtml)中看到了一个例子,使用了cout对象。cout对象是全局的,它可以通过操纵器进行更改,以使其以某种方式解释数值。如果你在函数中更改它(比如插入hex操纵器),那么当在函数外部使用cout对象时,这个更改将保留下来。
《第三章》(b1227194-5dda-4c73-a8e8-e8d68382abf0.xhtml),探索 C++类型,展示了如何解决这样的问题。在那一章中,你创建了一个名为read16的函数,它从文件中读取 16 个字节,并以十六进制形式和 ASCII 字符形式打印出这些值:
int read16(ifstream& stm)
{
if (stm.eof()) return -1;
int flags = cout.flags();
cout << hex;
string line;
// code that changes the line variable
cout.setf(flags);
return line.length();
}
这段代码将cout对象的状态存储在临时变量flags中。read16函数可以以任何必要的方式更改cout对象,但因为我们有存储的状态,这意味着在返回之前可以将对象恢复到其原始状态。
函数指针
当应用程序运行时,它将调用的函数将存在于内存中的某个位置。这意味着你可以获得函数的地址。C++允许你使用函数调用运算符(一对括号括住参数())通过函数指针调用函数。
记住括号!
首先,一个简单的例子,说明函数指针如何导致代码中难以注意到的错误。一个名为get_status的全局函数执行各种验证操作,以确定系统状态是否有效。该函数返回零表示系统状态有效,大于零的值表示错误代码:
// values over zero are error codes
int get_status()
{
int status = 0;
// code that checks the state of data is valid
return status;
}
可以像这样调用代码:
if (get_status > 0)
{
cout << "system state is invalid" << endl;
}
这是一个错误,因为开发人员遗漏了(),所以编译器不会将其视为函数调用。相反,它将其视为对函数的内存地址进行测试,由于函数永远不会位于内存地址为零的位置,比较将始终为true,即使系统状态有效也会打印出消息。
声明函数指针
最后一节强调了获取函数地址有多么容易:你只需使用函数的名称而不带括号:
void *pv = get_status;
指针pv只是稍微有趣;你现在知道函数存储在内存中的位置,但要打印这个地址,你仍然需要将其转换为整数。为了使指针有用,你需要能够声明一个通过该函数可以被调用的指针。为了看看如何做到这一点,让我们回到函数原型:
int get_status()
函数指针必须能够调用不带参数并期望返回整数值的函数。函数指针声明如下:
int (*fn)() = get_status;
*表示变量fn是一个指针;然而,这会绑定到左边,所以如果没有括号包围*fn,编译器会将其解释为int*指针的声明。声明的其余部分表示如何调用这个函数指针:不带参数并返回一个int。
通过函数指针调用很简单:在通常给出函数名称的地方给出指针的名称:
int error_value = fn();
再次注意括号的重要性;它们表示在函数指针fn中保存的地址处调用函数。
函数指针可能会使代码看起来相当凌乱,特别是当你使用它们指向模板函数时,因此通常代码会定义一个别名:
using pf1 = int(*)();
typedef int(*pf2)();
这两行为调用get_status函数所需的函数指针类型声明了别名。两者都是有效的,但using版本更易读,因为清楚地表明了pf1是正在定义的别名。为了理解原因,考虑这个别名:
typedef bool(*MyPtr)(MyType*, MyType*);
类型别名称为MyPtr,它是一个返回bool并接受两个MyType指针的函数。使用using更清晰:
using MyPtr = bool(*)(MyType*, MyType*);
这里的显著标志是(*),它表示类型是函数指针,因为你使用括号来打破*的关联性。然后你可以向外读取函数的原型:向左看返回类型,向右看参数列表。
一旦你声明了一个别名,你就可以创建一个指向函数的指针并调用它:
using two_ints = void (*)(int, int);
void do_something(int l, int r){/* some code */}
void caller()
{
two_ints fn = do_something;
fn(42, 99);
}
注意,因为two_ints别名声明为指针,所以在声明此类型的变量时不使用*。
使用函数指针
函数指针只是一个指针。这意味着您可以将其用作变量;您可以从函数中返回它,或将其作为参数传递。例如,您可能有一些执行一些冗长例程的代码,并且希望在例程期间提供一些反馈。为了使其灵活,您可以定义函数以接受回调指针,并在例程中定期调用该函数以指示进度:
using callback = void(*)(const string&);
void big_routine(int loop_count, const callback progress)
{
for (int i = 0; i < loop_count; ++i)
{
if (i % 100 == 0)
{
string msg("loop ");
msg += to_string(i);
progress(msg);
}
// routine
}
}
在这里,big_routine有一个名为progress的函数指针参数。该函数有一个循环,将被多次调用,每 100 次循环它调用回调函数,传递一个包含有关进度信息的string。
请注意,string类定义了+=运算符,可用于将字符串附加到变量中字符串的末尾,而<string>头文件定义了一个名为to_string的函数,该函数对每个内置类型进行了重载,以返回一个使用函数参数值格式化的string。
此函数将函数指针声明为const,只是为了让编译器知道该函数指针在此函数中不应更改为指向另一个函数的指针。代码可以这样调用:
void monitor(const string& msg)
{
cout << msg << endl;
}
int main()
{
big_routine(1000, monitor);
return 0;
}
monitor函数的原型与callback函数指针描述的原型相同(例如,如果函数参数是string&而不是const string&,那么代码将无法编译)。然后调用big_routine函数,将monitor函数的指针作为第二个参数传递。
如果将回调函数传递给库代码,必须注意函数指针的调用约定。例如,如果将函数指针传递给 Windows 函数(如EnumWindows),它必须指向使用__stdcall调用约定声明的函数。
C++标准使用另一种在运行时调用函数的技术,即函数对象。稍后将对此进行介绍。
模板函数
当编写库代码时,通常必须编写几个函数,这些函数之间的唯一区别在于传递给函数的类型;例程操作相同,只是类型已更改。C++提供了模板,允许您编写更通用的代码;您使用通用类型编写例程,并且在编译时编译器将生成具有适当类型的函数。使用template关键字和尖括号(<>)中的参数列表标记模板函数,这些参数列表为将要使用的类型提供了占位符。重要的是要理解这些模板参数是类型,并且指的是将在调用函数时用实际类型替换的参数(和函数的返回值)。它们不是函数的参数,通常在调用函数时不提供它们。
最好通过示例来解释模板函数。一个简单的maximum函数可以这样写:
int maximum(int lhs, int rhs)
{
return (lhs > rhs) ? lhs : rhs;
}
您可以使用其他整数类型调用此函数,较小的类型(如short、char、bool等)将被提升为int,较大类型(long long)的值将被截断。同样,unsigned类型的变量将被转换为signed int,这可能会导致问题。考虑以下函数调用:
unsigned int s1 = 0xffffffff, s2 = 0x7fffffff;
unsigned int result = maximum(s1, s2);
result变量的值是什么:s1还是s2?是s2。原因是两个值都转换为signed int,当转换为有符号类型时,s1将是值-1,而s2将是值2147483647。
要处理无符号类型,需要重载函数,并为有符号和无符号整数编写一个版本:
int maximum(int lhs, int rhs)
{
return (lhs > rhs) ? lhs : rhs;
}
unsigned maximum(unsigned lhs, unsigned rhs)
{
return (lhs > rhs) ? lhs : rhs;
}
例程相同,但类型已更改。还有另一个问题——如果调用者混合类型会怎样?以下表达式是否有意义:
int i = maximum(true, 100.99);
这段代码将编译,因为bool和double可以转换为int,并且将调用第一个重载。由于这样的调用是无意义的,如果编译器能捕捉到这个错误将会更好。
定义模板
回到maximum函数的两个版本,它们的例程都是一样的;改变的只是类型。如果你有一个通用类型,让我们称之为T,其中T可以是任何实现operator>的类型,那么这个例程可以用伪代码描述如下:
T maximum(T lhs, T rhs)
{
return (lhs > rhs) ? lhs : rhs;
}
这将不会编译,因为我们没有定义类型T。模板允许你告诉编译器代码使用了一个类型,并且将从传递给函数的参数中确定。以下代码将编译:
template<typename T>
T maximum(T lhs, T rhs)
{
return (lhs > rhs) ? lhs : rhs;
}
模板声明使用typename标识符指定将要使用的类型。类型T是一个占位符;你可以使用任何你喜欢的名称,只要它不是在同一作用域中的其他名称,当然,它必须在函数的参数列表中使用。你可以使用class代替typename,但意思是一样的。
你可以调用这个函数,传递任何类型的值,编译器将为该类型创建代码,调用该类型的operator>。
重要的是要意识到,当编译器第一次遇到模板函数时,它将为指定的类型创建函数的版本。如果你为几种不同的类型调用模板函数,编译器将为每种类型创建或实例化一个专门的函数。
这个模板的定义表明只会使用一个类型,所以你只能用相同类型的两个参数来调用它:
int i = maximum(1, 100);
double d = maximum(1.0, 100.0);
bool b = maximum(true, false);
所有这些都将编译,前两个将给出预期的结果。最后一行将把b赋值为true,因为bool是一个整数,true的值是1+,false的值是0。这可能不是你想要的,所以我们稍后会回到这个问题。请注意,由于模板规定两个参数必须是相同的类型,以下代码将不会编译:
int i = maximum(true, 100.99);
原因是template参数列表只给出了一个类型。如果你想要定义一个带有不同类型参数的函数,那么你将不得不为模板提供额外的参数:
template<typename T, typename U>
T maximum(T lhs, U rhs)
{
return (lhs > rhs) ? lhs : rhs;
}
这样做是为了说明模板是如何工作的;定义一个接受两种不同类型的最大函数实际上是没有意义的。
这个版本是为两种不同的类型编写的,模板声明提到了两种类型,并且这些类型用于两个参数。但请注意,函数返回T,第一个参数的类型。函数可以这样调用:
cout << maximum(false, 100.99) << endl; // 1
cout << maximum(100.99, false) << endl; // 100.99
第一行的输出是1(或者如果你使用bool alpha操作符,是true),第二行的结果是100.99。原因并不是立即显而易见。在两种情况下,比较都将从函数返回100.99,但是因为返回值的类型是T,返回值的类型将是第一个参数的类型。在第一种情况下,100.99首先被转换为bool,由于100.99不是零,返回的值是true(或者1)。在第二种情况下,第一个参数是double,所以函数返回一个double,这意味着返回100.99。如果maximum的模板版本被改为返回U(第二个参数的类型),那么前面代码返回的值将被颠倒:第一行返回100.99,第二行返回1。
注意,当你调用模板函数时,你不必给出模板参数的类型,因为编译器会推断它们。重要的是要指出,这仅适用于参数。返回类型不是由调用者分配给函数值的变量的类型决定的,因为函数可以在不使用返回值的情况下被调用。
尽管编译器将根据您调用函数的方式推断模板参数,但您可以在调用的函数中显式提供类型,以调用函数的特定版本,并(如果必要)让编译器执行隐式转换:
// call template<typename T> maximum(T,T);
int i = maximum<int>(false, 100.99);
此代码将调用具有两个int参数并返回int的maximum版本,因此返回值为100,即100.99转换为int。
使用模板参数值
到目前为止定义的模板已经将类型作为模板的参数,但您也可以提供整数值。以下是一个相当牵强的例子来说明这一点:
template<int size, typename T>
T* init(T t)
{
T* arr = new T[size];
for (int i = 0; i < size; ++i) arr[i] = t;
return arr;
}
有两个模板参数。第二个参数提供了一个类型的名称,其中T是函数参数的类型的占位符。第一个参数看起来像一个函数参数,因为它以类似的方式使用。参数size可以在函数中作为本地(只读)变量使用。函数参数是T,因此编译器可以从函数调用中推断出第二个模板参数,但无法推断出第一个参数,因此您必须在调用中提供一个值。以下是调用此模板函数的示例,T为int,size为10的值:
int *i10 = init<10>(42);
for (int i = 0; i < 10; ++i) cout << i10[i] << ' ';
cout << endl;
delete [] i10;
第一行调用函数,模板参数为10,函数参数为42。由于42是一个int,init函数将创建一个具有十个成员的int数组,每个成员的值都初始化为42。编译器推断int为第二个参数,但此代码也可以调用init<10,int>(42)函数,以明确指示您需要一个int数组。
非类型参数必须在编译时是常量:值可以是整数(包括枚举),但不能是浮点数。您可以使用整数数组,但这些将通过模板参数作为指针可用。
尽管在大多数情况下,编译器无法推断值参数,但如果该值被定义为数组的大小,则可以。这可以用来使函数似乎可以确定内置数组的大小,但当然,它不能,因为编译器将为所需的每个大小创建函数的版本。例如:
template<typename T, int N> void print_array(T (&arr)[N])
{
for (int i = 0; i < N; ++i)
{
cout << arr[i] << endl;
}
}
在这里,有两个模板参数:一个是数组的类型,另一个是数组的大小。函数的参数看起来有点奇怪,但它只是通过引用传递的内置数组。如果不使用括号,则参数为T& arr[N],即大小为 N 的引用数组,引用对象的类型为T,这不是我们想要的。我们想要一个大小为 N 的内置数组对象的类型为T。这个函数的调用如下:
int squares[] = { 1, 4, 9, 16, 25 };
print_array(squares);
前面代码的有趣之处在于编译器看到初始化列表中有五个项目。内置数组有五个项目,因此调用函数如下:
print_array<int,5>(squares);
如前所述,编译器将为代码调用的每种T和N组合实例化此函数。如果模板函数有大量代码,则可能会出现问题。解决此问题的一种方法是使用辅助函数:
template<typename T> void print_array(T* arr, int size)
{
for (int i = 0; i < size; ++i)
{
cout << arr[i] << endl;
}
}
template<typename T, int N> inline void print_array(T (&arr)[N])
{
print_array(arr, N);
}
这样做两件事。首先,有一个接受指针和指针指向的项目数的print_array版本。这意味着size参数在运行时确定,因此此函数的版本仅在编译时为所使用的数组类型实例化,而不是类型和数组大小。另一个要注意的是,使用数组大小作为模板的函数被声明为inline,并调用函数的第一个版本。尽管对于每种类型和数组大小的组合都会有一个版本,但实例化将是内联的,而不是完整的函数。
专用模板
在某些情况下,您可能有一个适用于大多数类型(并且适合模板函数的候选函数),但您可能会发现某些类型需要不同的例程。为了处理这种情况,您可以编写一个特化的模板函数,也就是说,当调用者使用符合此特化的类型时,编译器将使用此代码。例如,这是一个相当无意义的函数;它返回类型的大小:
template <typename T> int number_of_bytes(T t)
{
return sizeof(T);
}
这适用于大多数内置类型,但如果使用指针调用它,您将得到指针的大小,而不是指针指向的内容。因此,number_of_bytes("x")将返回 4(在 32 位系统上),而不是char数组的大小为 2。您可能决定为char*指针编写一个使用 C 函数strlen来计算字符串中字符数的特化版本,直到NUL字符。为此,您需要与模板函数类似的原型,将模板参数替换为实际类型,由于不需要模板参数,因此可以省略。由于此函数是为特定类型而设计的,因此需要将特定类型添加到函数名称中。
template<> int number_of_bytes<const char *>(const char *str)
{
return strlen(str) + 1;
}
现在,当您调用number_of_bytes("x")时,将调用特化版本,并返回值为 2。
之前,我们定义了一个模板函数来返回相同类型的两个参数的最大值:
template<typename T>
T maximum(T lhs, T rhs)
{
return (lhs > rhs) ? lhs : rhs;
}
使用特化,您可以为不使用>运算符进行比较的类型编写版本。由于找到两个布尔值的最大值是没有意义的,您可以删除bool的特化版本。
template<> bool maximum<bool>(bool lhs, bool rhs) = delete;
这意味着,如果代码使用bool参数调用maximum,编译器将生成错误。
可变模板
可变模板是指模板参数的数量是可变的。语法类似于函数的可变参数;您使用省略号,但是您将它们放在参数列表中参数的左侧,这声明了它为参数包:
template<typename T, typename... Arguments>
void func(T t, Arguments... args);
Arguments模板参数是零个或多个类型,它们是函数的相应数量的参数args的类型。在此示例中,函数至少有一个类型为T的参数,但您可以有任意数量的固定参数,包括没有。
在函数内部,您需要解包参数包以访问调用者传递的参数。您可以使用特殊运算符sizeof...(注意省略号是名称的一部分)确定参数包中有多少项;与sizeof运算符不同,这是项数而不是字节大小。要解包参数包,您需要在参数包名称的右侧使用省略号(例如,args...)。编译器将在此时展开参数包,用参数包的内容替换符号。
但是,您在设计时不会知道有多少参数或它们是什么类型,因此有一些策略可以解决这个问题。第一个使用递归:
template<typename T> void print(T t)
{
cout << t << endl;
}
template<typename T, typename... Arguments>
void print(T first, Arguments ... next)
{
print(first);
print(next...);
}
可变模板print函数可以使用任何可以由ostream类处理的任意类型的一个或多个参数进行调用。
print(1, 2.0, "hello", bool);
当调用此函数时,参数列表被分成两部分:第一个参数(1)在第一个参数first中,其他三个参数放在参数包next中。然后函数体调用print的第一个版本,将first参数打印到控制台。可变函数的下一行然后展开参数包调用print,也就是递归调用自身。在此调用中,first参数将是2.0,其余参数将放在参数包中。这将继续进行,直到参数包扩展到没有更多参数为止。
解包参数包的另一种方法是使用初始化列表。在这种情况下,编译器将创建一个包含每个参数的数组。
template<typename... Arguments>
void print(Arguments ... args)
{
int arr [sizeof...(args)] = { args... };
for (auto i : arr) cout << i << endl;
}
数组arr的大小与参数包的大小相同,并且使用初始化大括号的解包语法将数组填充为参数。尽管这将适用于任意数量的参数,但所有参数都必须是数组arr的相同类型。
一个技巧是使用逗号运算符:
template<typename... Arguments>
void print(Arguments ... args)
{
int dummy[sizeof...(args)] = { (print(args), 0)... };
}
这将创建一个名为dummy的虚拟数组。除了在参数包的扩展中使用之外,不使用该数组。该数组的大小与args参数包相同,并且省略号使用括号之间的表达式扩展参数包。表达式使用逗号运算符,它将返回逗号的右侧。由于这是一个整数,这意味着dummy的每个条目的值为零。有趣的部分是逗号运算符的左侧。这里使用具有单个模板化参数的print版本,该版本使用args参数包中的每个项目进行调用。
重载运算符
早些时候我们说过函数名不应包含标点符号。这并不完全正确,因为如果您正在编写运算符,只能在函数名中使用标点符号。运算符用于对一个或多个操作数进行操作的表达式。一元运算符有一个操作数,二元运算符有两个操作数,并且运算符返回操作的结果。显然,这描述了一个函数:返回类型,名称和一个或多个参数。
C++提供了关键字operator,以指示该函数不使用函数调用语法,而是使用与运算符相关的语法调用(通常,一元运算符的第一个参数位于运算符的右侧,而二元运算符的第一个参数位于左侧,第二个参数位于右侧,但也有例外)。
通常,您将提供运算符作为自定义类型的一部分(因此运算符作用于该类型的变量),但在某些情况下,您可以在全局范围内声明运算符。两者都是有效的。如果您正在编写自定义类型(如下一章中所述的类),那么将运算符的代码封装为自定义类型的一部分是有意义的。在本节中,我们将集中讨论定义运算符的另一种方法:作为全局函数。
您可以提供以下一元运算符的自定义版本:
! & + - * ++ -- ~
您还可以提供以下二元运算符的自定义版本:
!= == < <= > >= && ||
% %= + += - -= * *= / /= & &= | |= ^ ^= << <<= = >> =>>
-> ->* ,
您还可以编写函数调用运算符()、数组下标[]、转换运算符、强制转换运算符()、new和delete的版本。您不能重新定义.、.*、::、?:、#或##运算符,也不能重新定义“命名”运算符sizeof、alignof或typeid。
在定义运算符时,您编写一个函数,其中函数名为operator*x*,而*x*是运算符符号(请注意,没有空格)。例如,如果您定义了一个具有两个成员的struct,用于定义笛卡尔点,您可能希望比较两个点是否相等。可以这样定义struct:
struct point
{
int x;
int y;
};
比较两个point对象很容易。如果一个对象的x和y等于另一个对象中对应的值,则它们相同。如果定义了==运算符,则还应该使用相同的逻辑定义!=运算符,因为!=应该给出==运算符的确切相反结果。这是如何定义这些运算符的方式:
bool operator==(const point& lhs, const point& rhs)
{
return (lhs.x == rhs.x) && (lhs.y == rhs.y);
}
bool operator!=(const point& lhs, const point& rhs)
{
return !(lhs == rhs);
}
这两个参数是运算符的两个操作数。第一个是左侧的操作数,第二个参数是运算符右侧的操作数。它们作为引用传递,以便不进行复制,并且它们标记为const,因为运算符不会改变对象。一旦定义,您可以像这样使用point类型:
point p1{ 1,1 };
point p2{ 1,1 };
cout << boolalpha;
cout << (p1 == p2) << endl; // true
cout << (p1 != p2) << endl; // false
你可以定义一对名为equals和not_equals的函数,并使用这些函数:
cout << equals(p1,p2) << endl; // true
cout << not_equals(p1,p2) << endl; // false
然而,定义运算符使代码更易读,因为您可以像内置类型一样使用该类型。运算符重载通常被称为语法糖,使代码更易于阅读的语法--但这淡化了一个重要的技术。例如,智能指针是一种涉及类析构函数来管理资源生命周期的技术,仅因为您可以调用此类对象,就好像它们是指针一样。您可以这样做,因为智能指针类实现了->和*运算符。另一个例子是函数器,或函数对象,其中类实现了()运算符,以便可以像访问函数一样访问对象。
当您编写自定义类型时,应该问自己是否重载该类型的运算符是有意义的。例如,如果该类型是数值类型,例如复数或矩阵 - 那么实现算术运算符是有意义的,但实现逻辑运算符是否有意义,因为该类型没有逻辑方面?有一种诱惑,即重新定义运算符的含义以涵盖您的特定操作,但这将使您的代码不太可读。
一般来说,一元运算符被实现为接受单个参数的全局函数。后缀递增和递减运算符是一个例外,允许与前缀运算符不同的实现。前缀运算符将具有对对象的引用作为参数(运算符将递增或递减的对象),并返回对此更改后的对象的引用。然而,后缀运算符必须返回递增或递减之前对象的值。因此,运算符函数有两个参数:将被更改的对象的引用和一个整数(始终是 1 的值);它将返回原始对象的副本。
二元运算符将有两个参数并返回一个对象或对象的引用。例如,对于我们之前定义的struct,我们可以为ostream对象定义插入运算符:
struct point
{
int x;
int y;
};
ostream& operator<<(ostream& os, const point& pt)
{
os << "(" << pt.x << "," << pt.y << ")";
return os;
}
这意味着现在可以将point对象插入到cout对象中,以在控制台上打印它:
point pt{1, 1};
cout << "point object is " << pt << endl;
函数对象
函数对象,或函数器,是实现函数调用运算符(operator())的自定义类型。这意味着可以以类似函数的方式调用函数运算符。由于我们还没有涵盖类,因此在本节中,我们将仅探讨标准库提供的函数对象类型以及如何使用它们。
<functional>头文件包含各种可用作函数对象的类型。以下表列出了这些类型:
| 目的 | 类型 |
|---|---|
| 算术 | divides,minus,modulus,multiplies,negate,plus |
| 位运算 | bit_and,bit_not,bit_or,bit_xor |
| 比较 | equal_to,greater,greater_equal,less,less_equals,not_equal_to |
| 逻辑 | logical_and,logical_not,logical_or |
这些都是二元函数类,除了bit_not,logical_not和negate是一元的。二元函数对象作用于两个值并返回一个结果,一元函数对象作用于单个值并返回一个结果。例如,您可以使用以下代码计算两个数字的模:
modulus<int> fn;
cout << fn(10, 2) << endl;
这声明了一个名为fn的函数对象,将执行模运算。该对象在第二行中使用,该行调用对象上的operator()函数,带有两个参数,因此以下行等同于前一行:
cout << fn.operator()(10, 2) << endl;
结果是在控制台上打印出0的值。operator()函数仅对两个参数执行模运算,在这种情况下是10 % 2。这看起来并不太令人兴奋。<algorithm>头文件包含可以用于函数对象的函数。大多数采用谓词,即逻辑函数对象,但transform采用执行操作的函数对象:
// #include <algorithm>
// #include <functional>
vector<int> v1 { 1, 2, 3, 4, 5 };
vector<int> v2(v1.size());
fill(v2.begin(), v2.end(), 2);
vector<int> result(v1.size());
transform(v1.begin(), v1.end(), v2.begin(),
result.begin(), modulus<int>());
for (int i : result)
{
cout << i << ' ';
}
cout << endl;
这段代码将对两个向量中的值执行五次模运算。在概念上,它是这样做的:
result = v1 % v2;
也就是说,result中的每个项目都是v1和v2中对应项目的模。在代码中,第一行创建了一个具有五个值的vector。我们将用2计算这些值的模,因此第二行声明了一个空的vector,但容量与第一个vector相同。通过调用fill函数来填充这个第二个vector。第一个参数是vector中第一个项目的地址,end函数返回vector中最后一个项目之后的地址。函数调用的最后一个项目是将放置在从第一个参数指向的项目开始到第二个参数指向的项目之前(不包括)的vector中的值。
此时,第二个vector将包含五个项目,每个项目都是2。接下来,创建了一个用于结果的vector;同样,它的大小与第一个数组相同。最后,通过transform函数执行计算,再次显示如下:
transform(v1.begin(), v1.end(),
v2.begin(), result.begin(), modulus<int>());
前两个参数给出了第一个vector的迭代器,从中可以计算出项目的数量。由于所有三个vector的大小相同,因此只需要v2和result的begin迭代器。
最后一个参数是函数对象。这是一个临时对象,仅在此语句期间存在;它没有名称。这里使用的语法是对类的构造函数的显式调用;它是模板化的,因此需要给出模板参数。transform函数将对v1中的每个项目调用此函数对象的operator(int,int)函数作为第一个参数,并将v2中的相应项目作为第二参数,并将结果存储在result中的相应位置。
由于transform将任何二进制函数对象作为第二个参数,您可以传递plus<int>的实例来将v1中的每个项目加 2,或者传递multiplies<int>的实例来将v1中的每个项目乘以 2。
函数对象有用的一种情况是使用谓词进行多个比较。谓词是一个比较值并返回布尔值的函数对象。<functional>头文件包含几个类,允许您比较项目。让我们看看result容器中有多少个项目是零。为此,我们使用count_if函数。这将遍历容器,对每个项目应用谓词,并计算谓词返回true值的次数。有几种方法可以做到这一点。第一种定义了一个谓词函数:
bool equals_zero(int a)
{
return (a == 0);
}
然后可以将指向此的指针传递给count_if函数:
int zeros = count_if(
result.begin(), result.end(), equals_zero);
前两个参数指示要检查的值的范围。最后一个参数是用作谓词的函数的指针。当然,如果要检查不同的值,可以使其更通用:
template<typename T, T value>
inline bool equals(T a)
{
return a == value;
}
像这样调用它:
int zeros = count_if(
result.begin(), result.end(), equals<int, 0>);
这段代码的问题在于我们在使用操作的地方之外定义了它。equals函数可以在另一个文件中定义;然而,使用谓词时,将检查代码定义在需要谓词的代码附近更易读。
<functional>头文件还定义了可以用作函数对象的类。例如,equal_to<int>,用于比较两个值。但是,count_if函数期望一个一元函数对象,它将传递一个单个值(参见前面描述的equals_zero函数)。equal_to<int>是一个二元函数对象,用于比较两个值。我们需要提供第二个操作数,为此我们使用名为bind2nd的辅助函数:
int zeros = count_if(
result.begin(), result.end(), bind2nd(equal_to<int>(), 0));
bind2nd将参数0绑定到从equal_to<int>创建的函数对象。像这样使用函数对象将谓词的定义与将使用它的函数调用更加接近,但语法看起来相当混乱。C++11 提供了一种机制,可以让编译器确定所需的函数对象并将参数绑定到它们。这些被称为 lambda 表达式。
引入 lambda 表达式
Lambda 表达式用于在将使用函数对象的位置创建匿名函数对象。这使得您的代码更易读,因为您可以看到将执行什么。乍一看,lambda 表达式看起来像是在函数参数的地方定义的函数:
auto less_than_10 = [](int a) {return a < 10; };
bool b = less_than_10(4);
为了避免使用谓词的函数的复杂性,在此代码中,我们将一个变量分配给 lambda 表达式。这通常不是您使用它的方式,但这样可以使描述更清晰。lambda 表达式开头的方括号称为捕获列表。此表达式不捕获变量,因此方括号为空。您可以使用在 lambda 表达式外声明的变量,并且这些变量必须被捕获。捕获列表指示所有这些变量是否将被引用捕获(使用[&])还是值捕获(使用[=])。您还可以命名将被捕获的变量(如果有多个,则使用逗号分隔的列表),如果它们被值捕获,只需使用它们的名称。如果它们被引用捕获,使用它们的名称上加&。
您可以通过引入在表达式外声明的名为limit的变量,使前面的 lambda 表达式更通用:
int limit = 99;
auto less_than = limit {return a < limit; };
如果将 lambda 表达式与全局函数进行比较,捕获列表有点像标识全局函数可以访问的全局变量。
在捕获列表之后,您在括号中给出参数列表。同样,如果将 lambda 与函数进行比较,lambda 参数列表等同于函数参数列表。如果 lambda 表达式没有任何参数,则可以完全省略括号。
lambda 的主体在一对大括号中给出。这可以包含任何可以在函数中找到的内容。lambda 主体可以声明局部变量,甚至可以声明static变量,这看起来很奇怪,但是合法的:
auto incr = [] { static int i; return ++i; };
incr();
incr();
cout << incr() << endl; // 3
Lambda 的返回值是从返回的项目中推断出来的。Lambda 表达式不一定要返回一个值,如果不返回值,表达式将返回void:
auto swap = [](int& a, int& b) { int x = a; a = b; b = x; };
int i = 10, j = 20;
cout << i << " " << j << endl;
swap(i, j);
cout << i << " " << j << endl;
Lambda 表达式的强大之处在于您可以在需要函数对象或谓词的情况下使用它们:
vector<int> v { 1, 2, 3, 4, 5 };
int less_than_3 = count_if(
v.begin(), v.end(),
[](int a) { return a < 3; });
cout << "There are " << less_than_3 << " items less than 3" << endl;
在这里,我们声明一个vector并用一些值初始化它。count_if函数用于计算容器中小于 3 的项目数。因此,前两个参数用于指定要检查的项目范围,第三个参数是执行比较的 lambda 表达式。count_if函数将为传递给 lambda 的a参数的范围中的每个项目调用此表达式。count_if函数将持续计算 lambda 返回true的次数。
在 C++中使用函数
本章的示例使用了本章学到的技术,列出了文件夹和子文件夹中所有文件的文件大小,并按文件大小顺序给出文件名和它们的大小。该示例相当于在命令行中输入以下内容:
dir /b /s /os /a-d folder
这里,folder是您要列出的文件夹。/s选项递归,/a-d从列表中删除文件夹,/os按大小排序。问题在于,如果没有/b选项,我们会得到有关每个文件夹的信息,但使用它会删除列表中的文件大小。我们希望得到一个文件名(及其路径)和大小的列表,按最小的顺序排列。
首先,在Beginning_C++文件夹下创建一个新的文件夹(Chapter_05)用于本章。在 Visual C++中创建一个新的 C++源文件,并将其保存为files.cpp,保存在这个新文件夹下。该示例将使用基本的输出和字符串。它将接受一个命令行参数;如果传递了更多的命令行参数,我们将只使用第一个。将以下内容添加到files.cpp中:
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[])
{
if (argc < 2) return 1;
return 0;
}
该示例将使用 Windows 函数FindFirstFile和FindNextFile来获取符合文件规范的文件的信息。这些函数返回WIN32_FIND_DATAA结构中的数据,该结构包含有关文件名、文件大小和文件属性的信息。这些函数还返回有关文件夹的信息,因此我们可以测试子文件夹并进行递归。WIN32_FIND_DATAA结构以两部分的 64 位数字给出文件大小:高 32 位和低 32 位。我们将创建自己的结构来保存这些信息。在文件顶部,在 C++包含文件之后,添加以下内容:
using namespace std;
#include <windows.h> struct file_size { unsigned int high; unsigned int low; };
第一行是 Windows SDK 头文件,以便您可以访问 Windows 函数,该结构用于保存有关文件大小的信息。我们想要通过它们的大小来比较文件。WIN32_FIND_DATAA结构提供了两个unsigned long成员的大小(一个带有高 4 字节,另一个带有低 4 字节)。我们可以将其存储为 64 位数字,但是为了有借口编写一些操作符,我们将大小存储在我们的file_size结构中。该示例将打印文件大小并比较文件大小,因此我们将编写一个操作符将file_size对象插入输出流;由于我们想按大小对文件进行排序,我们需要一个操作符来确定一个file_size对象是否大于另一个。
该代码将使用 Windows 函数获取有关文件的信息,特别是它们的名称和大小。这些信息将存储在一个vector中,因此在文件顶部添加这两行:
#include <string>
#include <vector>
#include <tuple>
tuple类是必需的,以便我们可以将string(文件名)和file_size对象作为vector中的每个项目进行存储。为了使代码更易读,在结构定义之后添加以下别名:
using file_info = tuple<string, file_size>;
然后在main函数的上面添加获取文件夹中文件的函数的框架代码:
void files_in_folder(
const char *folderPath, vector<file_info>& files)
{
}
该函数接受一个vector的引用和一个文件夹路径。代码将遍历指定文件夹中的每个项目。如果它是一个文件,它将在vector中存储详细信息;否则,如果该项目是一个文件夹,它将调用自身以获取该子文件夹中的文件。在main函数的底部添加对该函数的调用:
vector<file_info> files;
files_in_folder(argv[1], files);
代码已经检查了至少有一个命令行参数,并将其用作要检查的文件夹。main函数应该打印文件信息,因此我们在堆栈上声明一个vector并将其通过引用传递给files_in_folder函数。到目前为止,这段代码什么也没做,但您可以编译代码以确保没有拼写错误(记得使用/EHsc参数)。
大部分工作是在files_in_folder函数中完成的。首先,在该函数中添加以下代码:
string folder(folderPath);
folder += "*";
WIN32_FIND_DATAA findfiledata {};
void* hFind = FindFirstFileA(folder.c_str(), &findfiledata);
if (hFind != INVALID_HANDLE_VALUE)
{
do
{
} while (FindNextFileA(hFind, &findfiledata));
FindClose(hFind);
}
我们将使用函数的 ASCII 版本(因此在结构和函数名称后面加上A)。FindFirstFileA函数接受搜索路径,在这种情况下,我们使用文件夹的名称后缀为*,表示此文件夹中的所有内容。请注意,Windows 函数需要const char*参数,因此我们在string对象上使用c_str函数。如果函数调用成功并找到符合此条件的项目,那么函数会填充传递的WIN32_FIND_DATAA结构的引用,并且还会返回一个不透明指针,该指针将用于对此搜索进行后续调用(您不需要知道它指向什么)。代码检查调用是否成功,如果成功,它将重复调用FindNextFileA以获取下一个项目,直到此函数返回 0,表示没有更多项目。将不透明指针传递给FindNextFileA,以便它知道正在检查哪个搜索。搜索完成后,代码调用FindClose以释放 Windows 为搜索分配的任何资源。
搜索将返回文件和文件夹项目;要分别处理每个项目,我们可以测试WIN32_FIND_DATAA结构的dwFileAttributes成员。在do循环中添加以下代码:
string findItem(folderPath);
findItem += "";
findItem += findfiledata.cFileName;
if ((findfiledata.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0)
{
// this is a folder so recurse
}
else
{
// this is a file so store information
}
WIN32_FIND_DATAA 结构仅包含文件夹中项目的相对名称,因此前几行创建了绝对路径。接下来的几行测试项目是文件夹(目录)还是文件。如果项目是文件,那么我们只需将其添加到传递给函数的向量中。将以下内容添加到else子句中:
file_size fs{};
fs.high = findfiledata.nFileSizeHigh;
fs.low = findfiledata.nFileSizeLow;
files.push_back(make_tuple(findItem, fs));
前三行初始化了一个file_size结构,并且最后一行将带有文件名和大小的tuple添加到vector中。因此,为了看到对此函数的简单调用的结果,请将以下内容添加到main函数的底部:
for (auto file : files)
{
cout << setw(16) << get<1>(file) << " "
<< get<0>(file) << endl;
}
这通过files向量中的项目进行迭代。每个项目都是一个tuple<string, file_size>对象,要获取string项目,可以使用标准库函数get,使用 0 作为函数模板参数,要获取file_size对象,可以使用 1 作为函数模板参数调用get。代码调用setw操纵符,以确保文件大小始终以 16 个字符宽的列打印。要使用此功能,需要在文件顶部添加<iomanip>的包含。请注意,get<1>将返回一个file_size对象,并将其插入cout。就目前而言,此代码将无法编译,因为没有运算符可以执行此操作。我们需要编写一个。
在结构的定义之后,添加以下代码:
ostream& operator<<(ostream& os, const file_size fs)
{
int flags = os.flags();
unsigned long long ll = fs.low +
((unsigned long long)fs.high << 32);
os << hex << ll;
os.setf(flags);
return os;
}
此运算符将更改ostream对象,因此我们在函数开始时存储初始状态,并在函数结束时将对象恢复到此状态。由于文件大小是 64 位数字,我们将file_size对象的组成部分转换为十六进制数,然后将其打印出来。
现在您可以编译并运行此应用程序。例如:
files C:windows
这将列出windows文件夹中文件的名称和大小。
还有两件事需要做-递归子文件夹和对数据进行排序。这两者都很容易实现。在files_in_folder函数中,将以下代码添加到if语句的代码块中:
// this is a folder so recurse
string folder(findfiledata.cFileName);
// ignore . and .. directories
if (folder != "." && folder != "..")
{
files_in_folder(findItem.c_str(), files);
}
搜索将返回.(当前)文件夹和..(父级)文件夹,因此我们需要检查并忽略它们。下一步是递归调用files_in_folder函数,以获取子文件夹中的文件。如果愿意,可以编译和测试应用程序,但这次最好使用Beginning_C++文件夹来测试代码,因为递归列出 Windows 文件夹将产生大量文件。
代码返回了获取的文件列表,但我们希望按文件大小顺序查看它们。为此,我们可以使用<algorithm>头文件中的sort函数,因此在<tuple>的包含之后添加一个包含。在main函数中,在调用files_in_folder之后,添加以下代码:
files_in_folder(argv[1], files);
sort(files.begin(), files.end(),
[](const file_info& lhs, const file_info& rhs) {
return get<1>(rhs) > get<1>(lhs);
} );
sort函数的前两个参数表示要检查的项目范围。第三个项目是一个谓词,函数将把vector中的两个项目传递给谓词。如果两个参数顺序正确(第一个小于第二个),则必须返回true值。
谓词由 lambda 表达式提供。没有捕获的变量,所以表达式以[]开头,然后是被sort算法比较的项目的参数列表(通过const引用传递,因为它们不会被改变)。实际的比较是在大括号之间进行的。由于我们想要按升序列出文件,我们必须确保两者中的第二个比第一个大。在这段代码中,我们使用>运算符对两个file_size对象进行比较。为了使这段代码编译通过,我们需要定义这个运算符。在插入运算符之后添加以下内容:
bool operator>(const file_size& lhs, const file_size& rhs)
{
if (lhs.high > rhs.high) return true;
if (lhs.high == rhs.high) {
if (lhs.low > rhs.low) return true;
}
return false;
}
现在您可以编译示例并运行它。您应该发现指定文件夹和子文件夹中的文件按文件大小顺序列出。
总结
函数允许您将代码分割成逻辑程序,这样可以使您的代码更易读,并且具有能够重用代码的灵活性。C++提供了丰富的选项来定义函数,包括可变参数列表、模板、函数指针和 lambda 表达式。然而,全局函数存在一个主要问题:数据与函数是分离的。这意味着函数必须通过全局数据项访问数据,或者数据必须在每次调用函数时通过参数传递给函数。在这两种情况下,数据存在于函数之外,可能被与数据无关的其他函数使用。下一章将提供解决方案:类。class允许您将数据封装在自定义类型中,并且可以在该类型上定义函数,以便只有这些函数才能访问数据。
类
C++允许您创建自己的类型。这些自定义类型可以有运算符,并且可以转换为其他类型;实际上,它们可以像内置类型一样使用,具有您定义的行为。这种功能使用一种称为类的语言特性。能够定义自己的类型的优势在于,您可以将数据封装在您选择的类型的对象中,并使用该类型来管理该数据的生命周期。您还可以定义可以对该数据执行的操作。换句话说,您可以定义具有状态和行为的自定义类型,这是面向对象编程的基础。
编写类
当您使用内置类型时,数据直接可供访问该数据的任何代码。C++提供了一种机制(const)来防止写访问,但任何代码都可以使用const_cast来取消const。您的数据可能很复杂,例如指向映射到内存中的文件的指针,意图是您的代码将更改一些字节,然后将文件写回磁盘。这样的原始指针是危险的,因为具有访问指针的其他代码可能更改不应更改的缓冲区的一部分。所需的是一种将数据封装到了解要更改哪些字节的类型中,并且只允许该类型访问数据的机制。这是类的基本思想。
审查结构
我们已经在 C++中看到了一种封装数据的机制:struct。结构允许您声明内置类型、指针或引用的数据成员。当您从该struct创建变量时,您正在创建该结构的实例,也称为对象。您可以创建引用此对象的变量或指向该对象的指针。甚至可以将对象按值传递给函数,编译器将对对象进行复制(它将调用struct的复制构造函数)。我们已经看到,对于struct,任何可以访问实例的代码(甚至通过指针或引用)都可以访问对象的成员(尽管这可能会更改)。在这种情况下,struct可以被视为包含状态的聚合类型。
struct的实例的成员可以通过直接使用点运算符或通过指向对象的指针使用->运算符进行初始化。我们还看到您可以使用初始化器列表(用大括号括起来)初始化struct的实例。这是相当受限制的,因为初始化器列表必须与struct中的数据成员匹配。在第四章中,使用内存、数组和指针,您看到可以将指针作为struct的成员,但必须明确采取适当的操作来释放指针指向的内存;如果不这样做,可能会导致内存泄漏。
struct是您可以在 C++中使用的类类型之一;另外两个是union和class。定义为struct或class的自定义类型可以具有行为和状态,C++允许您定义一些特殊函数来控制如何创建和销毁实例,复制和转换。此外,您可以在struct或class类型上定义运算符,以便您可以类似于在内置类型上使用运算符一样在实例上使用运算符。struct和class之间存在差异,我们将在后面讨论,但一般来说,本章的其余部分将涉及类,当提到class时,通常可以假定相同的内容也适用于struct。
定义类
类在一个语句中定义,并且它将在由大括号{}括起来的多个语句的块中定义其成员。因为它是一个语句,所以你必须在最后的大括号后面放一个分号。一个类可以在头文件中定义(就像许多C++标准库类一样),但你必须采取措施确保这样的文件只在源文件中包含一次。第一章,从 C++开始,描述了如何使用#pragma once、条件编译和预编译头文件来实现这一点。然而,关于类中特定项目的一些规则必须在源文件中定义,这将在后面介绍。
如果你浏览 C++标准库,你会发现类包含成员函数,并且为了将类的所有代码放入单个头文件中,这使得代码难以阅读和理解。这可能是有道理的,对于一个由专家 C++程序员组成的军团维护的库文件,但对于你自己的项目来说,可读性应该是一个关键的设计目标。因此,C++类可以在 C++头文件中声明,包括其成员函数,并且函数的实际实现可以放在源文件中。这使得头文件更容易维护和更可重用。
定义类行为
一个类可以定义只能通过类的实例调用的函数;这样的函数通常被称为方法。对象将具有状态;这是由类定义的数据成员提供的,并在创建对象时初始化。对象上的方法定义了对象的行为,通常作用于对象的状态。当你设计一个类时,你应该这样考虑方法:它们描述了对象正在做某事。
class cartesian_vector
{
public:
double x;
double y;
// other methods
double get_magnitude() { return std::sqrt((x * x) + (y * y)); }
};
这个类有两个数据成员,x和y,它们表示笛卡尔 x 和 y 方向上的二维向量的方向。public关键字意味着在此指定符号之后定义的任何成员都可以被类外定义的代码访问。默认情况下,类的所有成员都是private,除非你另有说明。这样的访问说明符将在下一章中更深入地介绍,但private意味着该成员只能被类的其他成员访问。
这是struct和class之间的区别:默认情况下,struct的成员是public,默认情况下,class的成员是private。
这个类有一个名为get_magnituide的方法,它将返回笛卡尔向量的长度。这个函数作用于类的两个数据成员并返回一个值。这是一种访问器方法;它允许访问对象的状态。这样的方法在class上很典型,但并不要求方法返回值。像函数一样,方法也可以接受参数。get_magnituide方法可以这样调用:
cartesian_vector vec { 3.0, 4.0 };
double len = vec.get_magnitude(); // returns 5.0
这里创建了一个cartesian_vector对象,并使用列表初始化语法将其初始化为表示向量(3,4)的值。这个向量的长度是 5,这是通过在对象上调用get_magnitude返回的值。
使用 this 指针
类中的方法有一个特殊的调用约定,在 Visual C++中称为__thiscall。原因是类中的每个方法都有一个名为this的隐藏参数,它是指向当前实例的类类型的指针:
class cartesian_vector
{
public:
double x;
double y;
// other methods
double get_magnitude()
{
return std::sqrt((this->x * this->x) + (this->y * this->y));
}
};
在这里,get_magnitude方法返回cartesian_vector对象的长度。对象的成员通过->运算符访问。如前所示,可以在没有this指针的情况下访问类的成员,但这确实明确了这些项目是class的成员。
你可以在cartesian_vector类型上定义一个方法,允许你改变它的状态:
class cartesian_vector
{
public:
double x;
double y;
reset(double x, double y) { this->x = x; this->y = y; }
// other methods
};
reset方法的参数与类的数据成员具有相同的名称;然而,由于我们使用了this指针,编译器知道这不会产生歧义。
你可以使用*运算符对this指针进行解引用,以访问对象。当一个成员函数必须返回对当前对象的引用时(正如我们将在后面看到的一些操作符),你可以通过返回*this来实现。类中的一个方法也可以将this指针传递给外部函数,这意味着它通过一个类型化的指针以引用的方式传递当前对象。
使用作用域解析运算符
你可以在class语句中内联定义一个方法,但你也可以分开声明和实现,所以方法在class语句中声明,但在其他地方定义。当在class语句之外定义一个方法时,你需要使用作用域解析运算符为方法提供类型的名称。例如,使用之前的cartesian_vector示例:
class cartesian_vector
{
public:
double x;
double y;
// other methods
double magnitude();
};
double cartesian_vector::magnitude()
{
return sqrt((this->x * this->x) + (this->y * this->y));
}
该方法在类定义之外定义;然而,它仍然是类方法,因此它有一个this指针,可以用来访问对象的成员。通常,类将在头文件中声明方法的原型,并且实际方法将在单独的源文件中实现。在这种情况下,使用this指针来访问类成员(方法和数据成员)在浏览源文件时是显而易见的,这些函数是类的方法。
定义类的状态
你的类可以有内置类型作为数据成员,也可以有自定义类型。这些数据成员可以在类中声明(并在类的实例构造时创建),也可以是指向在自由存储中创建的对象的指针,或者是引用在其他地方创建的对象。请记住,如果你有一个指向在自由存储中创建的项目的指针,你需要知道谁负责释放指针指向的内存。如果你有一个引用(或指针)指向在某个栈帧上创建的对象,你需要确保你的类的对象不会比该栈帧存在的时间更长。
当你将数据成员声明为public时,这意味着外部代码可以读取和写入数据成员。你可以决定只提供只读访问,这种情况下你可以将成员设为private,并通过访问器提供读取访问:
class cartesian_vector
{
double x;
double y;
public:
double get_x() { return this->x; }
double get_y() { return this->y; }
// other methods
};
当你将数据成员设为private时,意味着你不能使用初始化列表语法来初始化对象,但我们稍后会解决这个问题。你可以决定使用访问器来给数据成员提供写入访问,并使用这个来检查值。
void cartesian_vector::set_x(double d)
{
if (d > -100 && d < 100) this->x = d;
}
这是一个范围值必须在(但不包括)-100 和 100 之间的类型。
创建对象
你可以在堆栈上或自由存储中创建对象。使用前面的示例,这是如下所示的:
cartesian_vector vec { 10, 10 };
cartesian_vector *pvec = new cartesian_vector { 5, 5 };
// use pvec
delete pvec
这是对象的直接初始化,假设cartesian_vector的数据成员是public。vec对象在堆栈上创建,并用初始化列表初始化。在第二行,一个对象在自由存储中创建,并用初始化列表初始化。自由存储中的对象必须在某个时刻被释放,这是通过删除指针来实现的。new运算符将在自由存储中为类的数据成员和类需要的任何基础设施分配足够的内存(如下一章所述)。
C++11 的一个新特性是允许直接初始化以在类中提供默认值:
class point
{
public:
int x = 0;
int y = 0;
};
这意味着如果你创建一个point的实例而没有任何其他初始化值,它将被初始化,使得x和y都为零。如果数据成员是内置数组,那么你可以在类中使用初始化列表进行直接初始化:
class car
{
public:
double tire_pressures[4] { 25.0, 25.0, 25.0, 25.0 };
};
C++标准库容器可以使用初始化列表进行初始化,因此,在这个tire_pressures类中,我们可以使用vector<double>或array<double,4>来代替声明类型为double[4],并以相同的方式进行初始化。
对象的构造
C++允许您定义特殊的方法来执行对象的初始化。这些被称为构造函数。在 C++11 中,默认情况下会为您生成三个这样的函数,但如果您愿意,也可以提供自己的版本。这三个构造函数以及另外三个相关函数如下:
-
**默认构造函数:**用于创建具有默认值的对象。
-
**拷贝构造函数:**用于基于现有对象的值创建新对象。
-
**移动构造函数:**用于使用从现有对象移动的数据创建新对象。
-
**析构函数:**用于清理对象使用的资源。
-
**拷贝赋值:**将数据从一个现有对象复制到另一个现有对象。
-
**移动赋值:**将数据从一个现有对象移动到另一个现有对象。
这些函数的编译器创建的版本将被隐式设为public;但是,您可以决定通过定义自己的版本并将它们设为private,或者使用=delete语法删除它们来阻止复制或赋值。您还可以提供自己的构造函数,以便使用您决定需要初始化新对象的任何参数。
构造函数是一个与类型相同的成员函数,但不返回值,因此如果构造失败,您无法返回值,这可能意味着调用者将收到一个部分构造的对象。处理这种情况的唯一方法是抛出异常(在第十章中有解释,诊断和调试)。
定义构造函数
当创建一个没有值的对象时,将使用默认构造函数,因此对象将必须用默认值进行初始化。之前声明的point可以这样实现:
class point
{
double x; double y;
public:
point() { x = 0; y = 0; }
};
这将明确将项目初始化为零。如果要使用默认值创建实例,则不包括括号。
point p; // default constructor called
重要的是要注意这种语法,因为很容易出错写成以下形式:
point p(); // compiles, but is a function prototype!
这将编译通过,因为编译器会认为您正在提供一个函数原型作为前向声明。但是,当您尝试将符号p用作变量时,将会出现错误。您还可以使用空括号的初始化列表语法调用默认构造函数:
point p {}; // calls default constructor
虽然在这种情况下并不重要,因为数据成员是内置类型,但是在构造函数的主体中像这样初始化数据成员涉及对成员类型的赋值运算符的调用。更有效的方法是使用成员列表进行直接初始化。
以下是一个构造函数,它接受两个参数,用于说明成员列表:
point(double x, double y) : x(x), y(y) {}
括号外的标识符是类成员的名称,括号内的项目是用于初始化该成员的表达式(在本例中是构造函数参数)。此示例使用x和y作为参数名称。您不必这样做;这里只是作为一个例子,说明编译器将区分参数和数据成员。您还可以在构造函数的成员列表中使用大括号初始化语法:
point(double x, double y) : x{x}, y{y} {}
创建对象时调用此构造函数:
point p(10.0, 10.0);
您还可以创建对象数组:
point arr[4];
这将创建四个point对象,可以通过对arr数组进行索引来访问。请注意,当创建对象数组时,将调用默认构造函数;没有办法调用任何其他构造函数,因此必须分别初始化每个对象。
您还可以为构造函数参数提供默认值。在下面的代码中,car类为四个轮胎(前两个是前轮)和备用轮胎提供了值。有一个构造函数具有用于前后轮的强制值,并为备用轮胎提供了一个可选值。如果未提供备用轮胎压力的值,则将使用默认值:
class car
{
array<double, 4> tire_pressures;;
double spare;
public:
car(double front, double back, double s = 25.0)
: tire_pressures{front, front, back, back}, spare{s} {}
};
此构造函数可以使用两个值或三个值调用:
car commuter_car(25, 27);
car sports_car(26, 28, 28);
委托构造函数
构造函数可以使用相同的成员列表语法调用另一个构造函数:
class car
{
// data members
public:
car(double front, double back, double s = 25.0)
: tire_pressures{front, front, back, back}, spare{s} {}
car(double all) : car(all, all) {}
};
在这里,接受一个值的构造函数委托给接受三个参数的构造函数(在这种情况下使用备用值)。
复制构造函数
当您按值传递对象(或按值返回)或者明确基于另一个对象构造对象时,将使用复制构造函数。以下代码的最后两行都从另一个point对象创建一个point对象,并且在两种情况下都调用了复制构造函数:
point p1(10, 10);
point p2(p1);
point p3 = p1;
最后一行看起来涉及赋值运算符,但实际上调用了复制构造函数。复制构造函数可以这样实现:
class point
{
int x = 0;int y = 0;
public:
point(const point& rhs) : x(rhs.x), y(rhs.y) {}
};
初始化访问另一个对象(rhs)的private数据成员。这是可以接受的,因为构造函数参数与正在创建的对象的类型相同。复制操作可能不像这样简单。例如,如果类包含一个指针数据成员,您很可能希望复制指针指向的数据,并且这将涉及在新对象中创建一个新的内存缓冲区。
类型转换
您还可以执行转换。在数学中,您可以定义表示方向的向量,以便在两点之间绘制的线是一个向量。在我们的代码中,我们已经定义了point类和cartesian_vector类。您可以决定创建一个构造函数,该构造函数创建原点和一个点之间的向量,在这种情况下,您正在将point对象转换为cartesian_vector对象:
class cartesian_vector
{
double x; double y;
public:
cartesian_vector(const point& p) : x(p.x), y(p.y) {}
};
这里有一个问题,我们稍后会解决。转换可以这样调用:
point p(10, 10);
cartesian_vector v1(p);
cartesian_vector v2 { p };
cartesian_vector v3 = p;
建立友谊
上面的代码问题在于cartesian_vector类访问point类的private成员。由于我们编写了两个类,我们很乐意打破规则,因此我们将cartesian_vector类设置为point类的friend:
class cartesian_vector; // forward decalartion
class point
{
double x; double y;
public:
point(double x, double y) : x(x), y(y){}
friend class cartesian_point;
};
由于cartesian_vector类是在point类之后声明的,我们必须提供一个前向声明,告诉编译器名称cartesian_vector即将被使用,并且将在其他地方声明。重要的一行以friend开头。这表明整个cartesian_vector类的代码可以访问point类的私有成员(数据和方法)。
您还可以声明friend函数。例如,您可以声明一个运算符,以便point对象可以插入cout对象,因此可以将其打印到控制台。您不能更改ostream类,但可以定义全局方法:
ostream& operator<<(ostream& stm, const point& pt)
{
stm << "(" << pt.x << "," << pt.y << ")";
return stm;
}
此函数访问point的private成员,因此您必须将函数设置为point类的friend:
friend ostream& operator<<(ostream&, const point&);
这样的friend声明必须在point类中声明,但放在public或private部分都无关紧要。
将构造函数标记为显式
在某些情况下,您不希望允许将一个类型隐式转换为另一个类型的构造函数的参数。为此,您需要使用explicit限定符标记构造函数。这现在意味着调用构造函数的唯一方法是使用括号语法:显式调用构造函数。在下面的代码中,您不能将double隐式转换为mytype的对象:
class mytype
{
public:
explicit mytype(double x);
};
现在,如果要使用double参数创建对象,则必须显式调用构造函数:
mytype t1 = 10.0; // will not compile, cannot convert
mytype t2(10.0); // OK
销毁对象
当对象被销毁时,将调用一个名为析构函数的特殊方法。该方法的名称以~符号为前缀,不返回值。
如果对象是自动变量,位于堆栈上,那么当变量超出范围时,它将被销毁。当按值传递对象时,在被调用的函数堆栈上创建一个副本,并且当被调用的函数完成时,对象将被销毁。此外,函数的完成方式并不重要,无论是显式调用return还是达到最终的大括号,或者抛出异常;在所有这些情况下,都会调用析构函数。如果在函数中有多个对象,则在相同作用域中对象的构造顺序相反时调用析构函数。如果创建一个对象数组,则在声明数组的语句中为数组中的每个对象调用默认构造函数,并且当数组超出范围时,将销毁所有对象--并且调用每个对象的析构函数。
以下是一些示例,对于类mytype:
void f(mytype t) // copy created
{
// use t
} // t destroyed
void g()
{
mytype t1;
f(t1);
if (true)
{
mytype t2;
} // t2 destroyed
mytype arr[4];
} // 4 objects in arr destroyed in reverse order to creation
// t1 destroyed
当您返回一个对象时,会发生有趣的操作。以下注释是您所期望的:
mytype get_object()
{
mytype t; // default constructor creates t
return t; // copy constructor creates a temporary
} // t destroyed
void h()
{
test tt = get_object(); // copy constructor creates tt
} // temporary destroyed, tt destroyed
实际上,这个过程更加简洁。在调试版本中,编译器将看到在get_object函数返回时创建的临时对象是将用作变量tt的对象,因此在get_object函数的返回值上不会有额外的复制。函数实际上看起来是这样的:
void h()
{
mytype tt = get_object();
} // tt destroyed
然而,编译器能够进一步优化代码。在发布版本中(启用了优化),临时对象将不会被创建,调用函数中的对象tt将是在get_object中创建的实际对象t。
当您显式删除在自由存储器上分配的对象的指针时,对象将被销毁。在这种情况下,对析构函数的调用是确定性的:当您的代码调用delete时,它会被调用。同样,对于相同的类mytype,情况如下:
mytype *get_object()
{
return new mytype; // default constructor called
}
void f()
{
mytype *p = get_object();
// use p
delete p; // object destroyed
}
有时您希望使用删除对象的确定性方面(可能会忘记调用delete而存在潜在危险),有时您更希望确保对象在适当的时间被销毁(尽管可能会在以后的时间更晚)。
如果类中的数据成员是具有析构函数的自定义类型,那么当包含对象被销毁时,也会调用包含对象上的析构函数。尽管如此,请注意,这仅适用于对象是类成员的情况。如果类成员是指向自由存储器中对象的指针,则必须在包含对象的析构函数中显式删除指针。但是,您需要知道指针指向的对象在哪里,因为如果它不在自由存储器中,或者对象被其他对象使用,调用delete将会导致问题。
分配对象
当将已创建的对象分配给另一个对象的值时,将调用赋值运算符。默认情况下,您将获得一个复制赋值运算符,它将复制所有数据成员。这不一定是您想要的,特别是如果对象具有指针数据成员,那么您更有可能希望进行深层复制并复制指向的数据而不是指针的值(在后一种情况下,两个对象将指向相同的数据)。
如果定义了复制构造函数,您仍将获得默认的复制赋值运算符;然而,如果您认为编写自己的复制构造函数很重要,那么您应该提供自定义的复制赋值运算符。 (同样,如果定义了复制赋值运算符,除非定义它,否则将获得默认的复制构造函数。)
复制赋值运算符通常是类的public成员,并且它接受一个用于提供赋值值的const引用对象。赋值运算符的语义是可以链接它们,因此,例如,这段代码调用了两个对象的赋值运算符:
buffer a, b, c; // default constructors called
// do something with them
a = b = c; // make them all the same value
a.operator=(b.operator=(c)); // make them all the same value
最后两行做了同样的事情,但显然第一个更易读。为了启用这些语义,赋值运算符必须返回一个已经被赋值的对象的引用。因此,类buffer将具有以下方法:
class buffer
{
// data members
public:
buffer(const buffer&); // copy constructor
buffer& operator=(const buffer&); // copy assignment
};
尽管复制构造函数和复制赋值方法看起来做了类似的事情,但有一个关键的区别。复制构造函数创建了一个在调用之前不存在的新对象。调用代码知道,如果构造失败,那么将会引发异常。而赋值时,两个对象已经存在,所以你是将一个对象的值复制到另一个对象。这应该被视为一个原子操作,并且应该执行所有的复制;赋值在中途失败,导致一个对象是两个对象的一部分是不可接受的。此外,在构造中,只有在构造成功后对象才存在,因此复制构造不能在对象本身上发生,但是代码将对象分配给自身是完全合法的(尽管毫无意义)。复制赋值需要检查这种情况并采取适当的行动。
有各种策略可以做到这一点,一个常见的策略称为复制和交换惯用法,因为它使用标记为noexcept的标准库swap函数,并且不会引发异常。这种惯用法涉及创建赋值右侧对象的临时副本,然后交换其数据成员与左侧对象的数据成员。
移动语义
C++11 通过移动构造函数和移动赋值运算符提供了移动语义,当临时对象被用于创建另一个对象或被赋值给现有对象时,这些函数将被调用。在这两种情况下,由于临时对象不会在语句之后存在,临时对象的内容可以移动到另一个对象,使临时对象处于无效状态。编译器将通过默认操作从临时对象移动数据到新创建的(或分配给)对象来为您创建这些函数。
您可以编写自己的版本,并且为了指示移动语义,这些版本有一个右值引用的参数(&&)。
如果您希望编译器为您提供这些方法的默认版本,可以在类声明中提供带有=default后缀的原型。在大多数情况下,这是自说明的,而不是一个要求,但如果您正在编写 POD 类,您必须使用这些函数的默认版本,否则is_pod将不返回true。
如果您只想使用移动而不使用复制(例如文件句柄类),那么可以删除复制函数:
class mytype
{
int *p;
public:
mytype(const mytype&) = delete; // copy constructor
mytype& operator= (const mytype&) = delete; // copy assignment
mytype&(mytype&&); // move constructor
mytype& operator=(mytype&&); // move assignment
};
这个类有一个指针数据成员,并允许移动语义,在这种情况下,将调用移动构造函数并传递一个临时对象的引用。由于对象是临时的,它在移动构造函数调用后将不会存在。这意味着新对象可以移动临时对象的状态到自身:
mytype::mytype(mytype&& tmp)
{
this->p = tmp.p;
tmp.p = nullptr;
}
移动构造函数将临时对象的指针赋值为nullptr,以便任何为类定义的析构函数不会尝试删除指针。
声明静态成员
您可以声明类的成员——数据成员或方法——为static。在某些方面,这类似于在文件范围声明静态关键字的自动变量和函数的使用方式,但是当在类成员上使用此关键字时,有一些重要且不同的属性。
定义静态成员
当您在类成员上使用static时,这意味着该项与类相关,而不是与特定实例相关。对于数据成员来说,这意味着所有实例共享一个数据项。同样,static方法不附加到对象,它不是__thiscall,也没有this指针。
static方法是类的命名空间的一部分,因此它可以为类创建对象并访问它们的private成员。static方法默认具有__cdecl调用约定,但如果愿意,可以将其声明为__stdcall。这意味着您可以编写一个在类中使用的方法来初始化许多库使用的 C 样式指针。请注意,static函数不能调用类上的非静态方法,因为非静态方法需要this指针,但非静态方法可以调用static方法。
非静态方法通过对象调用,可以使用点运算符(对于类实例)或->运算符(对于对象指针)。static方法不需要关联对象,但可以通过对象调用。这给了调用static方法的两种方式,通过对象或通过class名称:
class mytype
{
public:
static void f(){}
void g(){ f(); }
};
在这里,类定义了一个名为f的static方法和一个名为g的非静态方法。非静态方法g可以调用static方法,但static方法f不能调用非静态方法。由于static方法f是public,类外的代码可以调用它:
mytype c;
c.g(); // call the nonstatic method
c.f(); // can also call the static method thru an object
mytype::f(); // call static method without an object
尽管可以通过对象调用static函数,但您根本不需要创建任何对象来调用它。
静态数据成员需要更多的工作,因为当您使用static时,它表示数据成员不是对象的一部分,通常在创建对象时分配数据成员。您必须在类外定义static数据成员:
class mytype
{
public:
static int i;
static void incr() { i++; }
};
// in a source file
int mytype::i = 42;
数据成员在类外部定义在文件范围内。它使用class名称命名,但请注意,它也必须使用类型进行定义。在这种情况下,数据成员使用一个值进行初始化;如果不这样做,那么在第一次使用变量时,它将具有类型的默认值(在这种情况下为零)。如果选择在头文件中声明类(这是常见的做法),则static数据成员的定义必须在源文件中。
您还可以在方法中声明一个static变量。在这种情况下,该值在所有对象的方法调用中保持不变,因此具有与static class成员相同的效果,但您不必在类外定义变量的问题。
使用静态和全局对象
全局函数中的static变量将在首次调用函数之前创建。同样,作为类成员的static对象将在首次访问之前初始化。
静态和全局对象在调用main函数之前构造,并在main函数完成后销毁。这种初始化顺序存在一些问题。C++标准规定,在源文件中定义的static和全局对象的初始化将在使用该源文件中定义的任何函数或对象之前发生,如果源文件中有几个全局对象,则它们将按照定义的顺序进行初始化。问题在于如果有几个源文件中都有static对象。无法保证这些对象的初始化顺序。如果一个static对象依赖于另一个static对象,那么就会出现问题,因为无法保证依赖对象将在其依赖对象之后创建。
命名构造函数
这是public static方法的一个应用。这个想法是,由于static方法是class的一个成员,这意味着它可以访问class实例的private成员,所以这样一个方法可以创建一个对象,执行一些额外的初始化,然后将对象返回给调用者。这是一个工厂方法。到目前为止使用的point类是使用笛卡尔坐标构建的,但我们也可以基于极坐标创建一个点,其中(x, y)笛卡尔坐标可以计算为:
x = r * cos(theta)
y = r * sin(theta)
这里r是到点的向量的长度,theta是这个向量逆时针到 x 轴的角度。point类已经有一个接受两个double值的构造函数,所以我们不能用它来传递极坐标;相反,我们可以使用一个static方法作为命名构造函数:
class point
{
double x; double y;
public:
point(double x, double y) : x(x), y(y){}
static point polar(double r, double th)
{
return point(r * cos(th), r * sin(th));
}
};
该方法可以这样调用:
const double pi = 3.141529;
const double root2 = sqrt(2);
point p11 = point::polar(root2, pi/4);
对象p11是具有笛卡尔坐标(1,1)的point。在这个例子中,polar方法调用了一个public构造函数,但它可以访问私有成员,所以同样的方法也可以写成(效率较低):
point point::polar(double r, double th)
{
point pt;
pt.x = r * cos(th);
pt.y = r * sin(th);
return pt;
}
嵌套类
你可以在一个类中定义一个类。如果嵌套类声明为public,那么你可以在容器类中创建对象并将它们返回给外部代码。然而,通常情况下,你会想要声明一个被类使用并且应该是private的类。以下声明了一个public的嵌套类:
class outer
{
public:
class inner
{
public:
void f();
};
inner g() { return inner(); }
};
void outer::inner::f()
{
// do something
}
嵌套类的名称前缀是包含类的名称。
访问 const 对象
到目前为止,你已经看到了许多使用const的例子,也许最常见的是当它作为函数参数应用于引用时,以指示编译器函数只对对象具有只读访问权限。这样的const引用用于通过引用传递对象,以避免通过值传递对象时会发生的复制开销。class上的方法可以访问对象数据成员,并且可能会改变它们,所以如果你通过const引用传递一个对象,编译器只允许引用调用不改变对象的方法。之前定义的point类有两个访问器来访问类中的数据:
class point
{
double x; double y;
public:
double get_x() { return x; }
double get_y() { return y: }
};
如果你定义一个函数,它接受一个对const的引用,并尝试调用这些访问器,你将会从编译器得到一个错误:
void print_point(const point& p)
{
cout << "(" << p.get_x() << "," << p.get_y() << ")" << endl;
}
编译器的错误有点模糊:
cannot convert 'this' pointer from 'const point' to 'point &'
这条消息是编译器抱怨对象是const,它是不可变的,并且它不知道这些方法是否会保持对象的状态。解决方法很简单--在不改变对象状态的方法中添加const关键字,就像这样:
double get_x() const { return x; }
double get_y() const { return y: }
这实际上意味着this指针是const。const关键字是函数原型的一部分,所以该方法可以在此上进行重载。你可以有一个方法,当它在一个const对象上调用时被调用,另一个方法在一个非const对象上被调用。这使你能够实现写时复制模式,例如,一个const方法会返回对数据的只读访问,而非const方法会返回可写的数据的副本。
当然,标记为const的方法不能改变数据成员,甚至是暂时的。因此,这样的方法只能调用const方法。也许有一些罕见的情况,一个数据成员被设计为通过const对象进行更改;在这种情况下,成员的声明会标记为mutable关键字。
使用指针的对象
对象可以在自由存储器上创建,并通过类型指针访问。这样做更加灵活,因为将指针传递给函数是高效的,并且你可以明确确定对象的生命周期,因为对象是通过调用new创建的,并通过调用delete销毁的。
获取对象成员的指针
如果您需要通过实例访问类数据成员的地址(假设数据成员是public),您只需使用&运算符:
struct point { double x; double y; };
point p { 10.0, 10.0 };
int *pp = &p.x;
在这种情况下,struct用于声明point,以便成员默认为public。第二行使用初始化列表构造了一个具有两个值的point对象,然后最后一行获取了一个数据成员的指针。当然,在对象被销毁后,指针不能再使用。数据成员被分配在内存中(在这种情况下是在堆栈上),因此地址运算符只是获取指向该内存的指针。
函数指针是一个不同的情况。无论创建了多少个class的实例,内存中只会有一个方法的副本,但是因为方法是使用__thiscall调用约定(带有隐藏的this参数)调用的,所以您必须有一个函数指针,可以用一个指向对象的指针来初始化,以提供this指针。考虑这个class:
class cartesian_vector
{
public:
// other items
double get_magnitude() const
{
return std::sqrt((this->x * this->x) + (this->y * this->y));
}
};
我们可以像这样定义一个指向get_magnitude方法的函数指针:
double (cartesian_vector::*fn)() const = nullptr;
fn = &cartesian_vector::get_magnitude;
第一行声明一个函数指针。这类似于 C 函数指针声明,只是指针类型中包含了class名称。这是必需的,以便编译器知道它必须在通过此指针调用时提供this指针。第二行获取方法的指针。请注意,没有涉及任何对象。您不是获取一个对象上的方法的函数指针;您是获取一个必须通过对象调用的class上的方法的指针。要通过此指针调用方法,您需要在对象上使用成员运算符.*:
cartesian_vector vec(1.0, 1.0);
double mag = (vec.*fn)();
第一行创建一个对象,第二行调用方法。成员运算符的指针表示在左侧的对象上调用右侧的函数指针。在调用方法时,左侧对象的地址用于this指针。由于这是一个方法,我们需要提供参数列表,在这种情况下为空(如果您有参数,它们将在此语句右侧的括号中)。如果您有一个对象指针,那么语法是类似的,但是您使用->*指向成员运算符:
cartesian_vector *pvec = new cartesian_vector(1.0, 1.0);
double mag = (pvec->*fn)();
delete pvec;
运算符重载
类型的一个行为是您可以应用于它的操作。C++允许您重载 C++运算符作为类的一部分,以便清楚地表明运算符是作用于该类型的。这意味着对于一元运算符,成员方法不应该有参数,对于二元运算符,您只需要一个参数,因为当前对象将位于运算符的左侧,因此方法参数是右侧的项目。以下表总结了如何实现一元和二元运算符,以及四个异常:
| 表达式 | 名称 | 成员方法 | 非成员函数 |
|---|---|---|---|
| +a/-a | 前缀一元 | 运算符() | 运算符(a) |
| a, b | 二元 | 运算符(b) | 运算符(a,b) |
| a+/a- | 后缀一元 | 运算符(0) | 运算符(a,0) |
| a=b | 赋值 | 运算符=(b) | |
| a(b) | 函数调用 | 运算符()(b) | |
| a[b] | 索引 | 运算符 | |
| a-> | 指针访问 | 运算符->() |
这里的■符号用于表示表中提到的四个运算符之外的任何可接受的一元或二元运算符。
没有严格的规则规定运算符应该返回什么,但是如果自定义类型的运算符的行为类似于内置类型的运算符,那将会有所帮助。还必须有一些一致性。如果您实现+运算符来将两个对象相加,那么+=运算符应该使用相同的加法操作。同样,您可以认为加法操作也将决定减法操作应该是什么样子,因此-和-=运算符。同样,如果您想定义<运算符,那么您应该定义<=、>、>=、==和!=。
标准库的算法(例如sort)只会期望在自定义类型上定义<运算符。
表格显示,你可以将几乎所有的运算符实现为自定义类型类的成员或全局函数(除了那四个必须是成员方法的例外)。一般来说,最好将运算符作为类的一部分实现,因为它保持了封装性:成员函数可以访问类的非公共成员。
一元运算符的一个例子是一元负运算符。这通常不会改变一个对象,而是返回一个对象的负值。对于我们的point class,这意味着使两个坐标都变为负数,这相当于在一条线y = -x上对笛卡尔点进行镜像:
// inline in point
point operator-() const
{
return point(-this->x, -this->y);
}
运算符声明为const,因为很明显运算符不会改变对象,因此可以安全地在const对象上调用。运算符可以这样调用:
point p1(-1,1);
point p2 = -p1; // p2 is (1,-1)
要理解为什么我们要这样实现运算符,请回顾一下当应用于内置类型时一元运算符会做什么。这里的第二个语句,int i, j=0; i = -j;,只会改变i,不会改变j,所以成员operator-不应该影响对象的值。
二元负运算符有不同的含义。首先,它有两个操作数,其次,在这个例子中,结果与操作数的类型不同,因为结果是一个向量,通过从一个点中减去另一个点来指示一个方向。假设cartesian_vector已经定义了一个具有两个参数的构造函数,那么我们可以写成:
cartesian_vector point::operator-(point& rhs) const
{
return cartesian_vector(this->x - rhs.x, this->y - rhs.y);
}
增量和减量运算符有特殊的语法,因为它们是可以前置或后置的一元运算符,并且会改变它们所应用的对象。两个运算符之间的主要区别在于后置运算符返回增量/减量操作之前对象的值,因此需要创建一个临时对象。因此,前置运算符几乎总是比后置运算符具有更好的性能。在类定义中,为了区分这两者,前置运算符没有参数,后置运算符有一个虚拟参数(在前面的表中给出了 0)。对于一个类mytype,如下所示:
class mytype
{
public:
mytype& operator++()
{
// do actual increment
return *this;
}
mytype operator++(int)
{
mytype tmp(*this);
operator++(); // call the prefix code
return tmp;
}
};
实际的增量代码是由前置运算符实现的,并且后置运算符通过显式调用该方法使用这个逻辑。
定义函数类
一个函数对象是一个实现了()运算符的类。这意味着你可以使用与函数相同的语法调用一个对象。考虑这个:
class factor
{
double f = 1.0;
public:
factor(double d) : f(d) {}
double operator()(double x) const { return f * x; }
};
这段代码可以这样调用:
factor threeTimes(3); // create the functor object
double ten = 10.0;
double d1 = threeTimes(ten); // calls operator(double)
double d2 = threeTimes(d1); // calls operator(double)
这段代码表明,函数对象不仅提供了一些行为(在这种情况下,在参数上执行一个动作),而且还可以有一个状态。前两行是通过对象上的operator()方法调用的:
double d2 = threeTimes.operator()(d1);
看语法。函数对象被调用,就好像它是这样声明的函数:
double multiply_by_3(double d)
{
return 3 * d;
}
想象一下,你想传递一个指向函数的指针--也许你希望函数的行为被外部代码改变。为了能够使用函数对象或方法指针,你需要重载你的函数:
void print_value(double d, factor& fn);
void print_value(double d, double(*fn)(double));
第一个接受一个函数对象的引用。第二个有一个 C 类型的函数指针(你可以传递一个指向multiply_by_3的指针),并且相当难以阅读。在两种情况下,fn参数在实现代码中以相同的方式被调用,但你需要声明两个函数,因为它们是不同的类型。现在,考虑函数模板的魔力:
template<typename Fn>
void print_value(double d, Fn& fn)
{
double ret = fn(d);
cout << ret << endl;
}
这是通用代码;Fn类型可以是一个 C 函数指针或一个函数对象class,编译器将生成适当的代码。
这段代码可以通过传递一个指向全局函数的函数指针来调用,该函数将具有__cdecl调用约定,或者传递一个函数对象,其中将调用operator()运算符,该运算符具有__thiscall调用约定。
这只是一个实现细节,但这意味着你可以编写一个通用函数,可以接受 C 风格的函数指针或函数对象作为参数。C++标准库使用了这个魔法,这意味着它提供的算法可以用全局函数、函数对象或lambda 表达式来调用。
标准库算法使用三种类型的函数类、生成器和一元和二元函数;也就是说,没有参数、一个参数或两个参数的函数。此外,标准库调用返回bool的函数对象(一元或二元)谓词。文档会告诉你是否需要谓词、一元或二元函数。旧版本的标准库需要知道函数对象的返回值和参数(如果有的话)的类型,因此,函数对象类必须基于标准类unary_function和binary_function(通过继承,在下一章中解释)。在 C++11 中,这个要求已经被移除,因此没有必要使用这些类。
在某些情况下,当需要一元函数时,你可能希望使用二元函数。例如,标准库定义了greater类,当作为函数对象使用时,它接受两个参数和一个bool来确定第一个参数是否大于第二个参数,使用两个参数的类型定义的operator>。这将用于需要二元函数的函数,因此函数将比较两个值;例如:
template<typename Fn>
int compare_vals(vector<double> d1, vector<double> d2, Fn compare)
{
if (d1.size() > d2.size()) return -1; // error
int c = 0;
for (size_t i = 0; i < d1.size(); ++i)
{
if (compare(d1[i], d2[i])) c++;
}
return c;
}
这需要两个集合,并使用作为最后一个参数传递的函数对象比较相应的项目。可以这样调用它:
vector<double> d1{ 1.0, 2.0, 3.0, 4.0 };
vector<double> d2{ 1.0, 1.0, 2.0, 5.0 };
int c = compare_vals(d1, d2, greater<double>());
greater函数对象类在<functional>头文件中定义,使用为类型定义的operator>比较两个数字。如果你想要比较容器中的项目和一个固定值;也就是说,当函数对象的operator()(double, double)方法被调用时,一个参数总是有一个固定的值?一种选择是定义一个有状态的函数对象类(如前面所示),以便固定值是函数对象的成员。另一种方法是用固定值填充另一个vector,并继续比较两个vector(对于大的vector来说,这可能会变得非常昂贵)。
另一种方法是重用函数对象类,但是绑定一个值到它的一个参数上。compare_vals函数的一个版本可以这样写,只接收一个vector:
template<typename Fn>
int compare_vals(vector<double> d, Fn compare)
{
int c = 0;
for (size_t i = 0; i < d.size(); ++i)
{
if (compare(d[i]) c++;
}
return c;
}
代码被编写为只调用函数对象参数的一个值,因为假设函数对象包含另一个要比较的值。这是通过将函数对象类绑定到参数来实现的:
using namespace::std::placeholders;
int c = compare_vals(d1, bind(greater<double>(), _1, 2.0));
bind函数是可变参数的。第一个参数是函数对象,后面是将传递给函数对象的operator()方法的参数。compare_vals函数接收一个binder对象,将函数对象绑定到值上。在compare_vals函数中,对compare(d[i])中的函数对象的调用实际上是对绑定对象的operator()方法的调用,这个方法将参数d[i]和绑定的值转发给函数对象的operator()方法。
在调用bind时,如果提供了实际值(这里是2.0),那么该值将传递给函数对象在调用函数对象时的位置(这里,2.0传递给第二个参数)。如果使用下划线前缀的符号,则是占位符。std::placeholders命名空间中定义了 20 个这样的符号(_1到_20)。占位符的意思是“使用在这个位置传递的值来调用绑定器对象的operator()方法,并将其传递给函数对象调用operator()方法中由占位符指示的位置”。因此,这个调用中的占位符意味着“将从调用绑定器中传递的第一个参数传递给greater函数对象的operator()的第一个参数”。
前面的代码将vector中的每个项目与2.0进行比较,并将大于2.0的项目计数。您可以这样调用它:
int c = compare(d1, bind(greater<double>(), 2.0, _1));
参数列表被交换,这意味着2.0与vector中的每个项目进行比较,并且函数将计算2.0大于项目的次数。
bind函数和占位符是 C++11 中的新功能。在之前的版本中,您可以使用bind1st和bind2nd函数将值绑定到函数对象的第一个或第二个参数。
定义转换运算符
我们已经看到,如果自定义类型具有接受要转换的类型的构造函数,则可以使用构造函数将另一种类型转换为自定义类型。您还可以执行另一种方向的转换:将对象转换为另一种类型。为此,您提供一个没有返回类型的带有要转换为的类型名称的运算符。在这种情况下,operator关键字和名称之间需要有一个空格:
class mytype
{
int i;
public:
mytype(int i) : i(i) {}
explicit mytype(string s) : i(s.size()) {}
operator int () const { return i; }
};
这段代码可以将int或string转换为mytype;在后一种情况下,只能通过显式提及构造函数来实现。最后一行允许将对象转换回int:
string s = "hello";
mytype t = mytype(s); // explicit conversion
int i = t; // implicit conversion
您可以将这样的转换运算符设置为explicit,这样它们只会在使用显式转换时被调用。在许多情况下,您可能希望省略此关键字,因为当您想要将资源封装在类中并使用析构函数来自动管理资源时,隐式转换是有用的。
使用转换运算符的另一个示例是从有状态的函数对象返回值。这里的想法是operator()将执行某些操作,并且结果由函数对象维护。问题是,当它们经常作为临时对象创建时,如何获取函数对象的状态?转换运算符可以提供此功能。
例如,当计算平均值时,需要分两个阶段:第一阶段是累积值,第二阶段是通过将其除以项目数来计算平均值。以下函数对象类通过将除法作为转换为double的一部分来执行此操作:
class averager
{
double total;
int count;
public:
averager() : total(0), count(0) {}
void operator()(double d) { total += d; count += 1; }
operator double() const
{
return (count != 0) ? (total / count) :
numeric_limits<double>::signaling_NaN();
}
};
可以这样调用:
vector<double> vals { 100.0, 20.0, 30.0 };
double avg = for_each(vals.begin(), vals.end(), averager());
for_each函数对vector中的每个项目调用函数对象,operator()简单地对传递给它的项目求和并保持计数。有趣的部分是,在for_each函数遍历完vector中的所有项目后,它会返回函数对象,因此会有一个隐式转换为double,这会调用计算平均值的转换运算符。
管理资源
我们已经看到一种需要仔细管理的资源:内存。您使用new分配内存,当您使用完内存后,必须使用delete释放内存。未能释放内存将导致内存泄漏。内存可能是最基本的系统资源,但大多数操作系统还有许多其他资源:文件句柄、图形对象句柄、同步对象、线程和进程。有时拥有这样的资源是独占的,并且会阻止其他代码访问通过该资源访问的资源。因此,重要的是这些资源在某个时刻被释放,通常情况下,它们应该及时释放。
类在这里有所帮助,使用了由 C++的作者 Bjarne Stroustrup 发明的一种称为资源获取即初始化(RAII)的机制。简单地说,资源在对象的构造函数中分配,并在析构函数中释放,这意味着资源的生命周期是对象的生命周期。通常,这样的包装对象是在堆栈上分配的,这意味着无论发生什么,当对象超出范围时,资源都将被释放。
因此,如果对象在循环语句(while、for)的代码块中声明,则在每次循环结束时,将调用每个对象的析构函数(按创建顺序的相反顺序),并且当循环重复时,对象将被再次创建。无论循环是因为代码块的末尾已经到达,还是因为循环通过调用continue重复。离开代码块的另一种方法是通过调用break、goto,或者如果代码调用return离开函数。如果代码引发异常(参见第十章,诊断和调试),则在对象超出范围时将调用析构函数,因此如果代码受try块保护,则在调用catch子句之前将调用块中声明的对象的析构函数。如果没有保护块,则在函数堆栈被销毁并传播异常之前将调用析构函数。
编写包装类
在编写包装资源的类时,您必须解决几个问题。构造函数将被用来获取资源,要么使用某个库函数(通常通过某种不透明句柄访问),要么将资源作为参数。这个资源将作为数据成员存储,以便类上的其他方法可以使用它。析构函数将使用库提供的函数释放资源。这是最基本的。此外,您还需要考虑对象将如何被使用。通常,如果可以将实例用作资源句柄,这样的包装类最方便。这意味着您保持相同的编程风格来访问资源,但您不必太担心释放资源。
您应该考虑是否希望能够在包装类和资源句柄之间进行转换。如果允许这样做,这意味着您可能需要考虑克隆资源,以便您不会有两个句柄的副本--一个由类管理,另一个副本可能会被外部代码释放。您还需要考虑是否允许对象被复制或分配,如果是的话,那么您将需要适当地实现复制构造函数、移动构造函数以及复制和移动赋值运算符。
使用智能指针
C++标准库提供了几个类来包装通过指针访问的资源。为了防止内存泄漏,您必须确保在某个时刻释放在自由存储器上分配的内存。智能指针的概念是您将一个实例视为指针,因此您使用*运算符进行解引用以访问它指向的对象,或者使用->运算符访问包装对象的成员。智能指针类将管理其包装的指针的生命周期,并将适当地释放资源。
标准库有三个智能指针类:unique_ptr、shared_ptr和weak_ptr。每个类以不同的方式处理资源的释放,以及是否以及如何复制指针。
管理独占所有权
unique_ptr类是使用指向它将维护的对象的指针构造的。该类提供了*运算符来访问对象,对包装的指针进行解引用。它还提供了->运算符,因此如果指针是类的指针,您可以通过包装的指针访问成员。
以下在自由存储器上分配一个对象并手动维护其生命周期:
void f1()
{
int* p = new int;
*p = 42;
cout << *p << endl;
delete p;
}
在这种情况下,您获得了一个指向为int分配的自由存储器上的内存的指针。要访问内存--无论是写入还是读取--您都必须使用*运算符对指针进行解引用。当您完成指针时,必须调用delete来释放内存并将其返回给自由存储器。现在考虑相同的代码,但使用智能指针:
void f2()
{
unique_ptr<int> p(new int);
*p = 42;
cout << *p << endl;
delete p.release();
}
两个主要区别是智能指针对象是通过调用接受用作模板参数的指针的构造函数来显式构造的。这种模式强调了资源应该只由智能指针管理的观念。
第二个变化是通过在智能指针对象上调用release方法来释放包装的指针的所有权,以便我们可以显式删除指针。
考虑release方法释放智能指针对指针的所有权。调用此方法后,智能指针不再包装资源。unique_ptr类还有一个get方法,可以访问包装的指针,但智能指针对象仍将保留所有权;不要通过这种方式删除获取的指针!
请注意,unique_ptr对象包装一个指针,只是指针。这意味着对象在内存中的大小与它包装的指针相同。到目前为止,智能指针增加了很少的东西,所以让我们看另一种释放资源的方法:
void f3()
{
unique_ptr<int> p(new int);
*p = 42;
cout << *p << endl;
p.reset();
}
这是资源的确定性释放,意味着资源在您希望发生时释放,这类似于指针的情况。这里的代码并不是释放资源本身;它允许智能指针使用删除器来执行释放。unique_ptr的默认删除器是一个名为default_delete的函数类,它在包装的指针上调用delete运算符。如果您打算使用确定性销毁,reset是首选方法。您可以通过将自定义函数类的类型作为unique_ptr模板的第二个参数传递来提供自己的删除器:
template<typename T> struct my_deleter
{
void operator()(T* ptr)
{
cout << "deleted the object!" << endl;
delete ptr;
}
};
在您的代码中,您将指定要使用自定义删除器,如下所示:
unique_ptr<int, my_deleter<int> > p(new int);
在删除指针之前,您可能需要执行额外的清理,或者指针可能是通过new之外的机制获得的,因此您可以使用自定义删除器来确保调用适当的释放函数。请注意,删除器是智能指针类的一部分,因此如果您有两个不同的智能指针以这种方式使用两个不同的删除器,那么智能指针类型是不同的,即使它们包装相同类型的资源。
当你使用自定义删除器时,unique_ptr对象的大小可能比包装的指针更大。如果删除器是一个函数对象,每个智能指针对象都将需要为此分配内存,但如果使用 lambda 表达式,则不需要额外的空间。
当然,你很可能允许智能指针为你管理资源的生命周期,为此你只需允许智能指针对象超出范围即可:
void f4()
{
unique_ptr<int> p(new int);
*p = 42;
cout << *p << endl;
} // memory is deleted
由于创建的指针是一个单一对象,这意味着你可以在适当的构造函数上调用new运算符来传递初始化参数。unique_ptr的构造函数接收一个指向已构造对象的指针,之后该类管理对象的生命周期。虽然可以直接通过调用其构造函数创建unique_ptr对象,但无法调用复制构造函数,因此无法在构造过程中使用初始化语法。相反,标准库提供了一个名为make_unique的函数。它有几种重载形式,因此这是创建基于该类的智能指针的首选方式:
void f5()
{
unique_ptr<int> p = make_unique<int>();
*p = 42;
cout << *p << endl;
} // memory is deleted
这段代码将调用包装类型(int)的默认构造函数,但你可以提供参数,这些参数将传递给类型的适当构造函数。例如,对于具有两个参数的构造函数的struct,可以使用以下内容:
void f6()
{
unique_ptr<point> p = make_unique<point>(1.0, 1.0);
p->x = 42;
cout << p->x << "," << p->y << endl;
} // memory is deleted
make_unique函数调用分配非默认值的成员的构造函数。->运算符返回一个指针,编译器将通过这个指针访问对象成员。
还有一个用于数组的unique_ptr和make_unique的特化版本。这个版本的unique_ptr的默认删除器将在指针上调用delete[],因此它将删除数组中的每个对象(并调用每个对象的析构函数)。该类实现了一个索引器运算符([]),因此可以访问数组中的每个项目。但是,请注意,没有范围检查,因此,就像内置数组变量一样,可以访问数组末尾之外的位置。没有解引用运算符(*或->),因此基于数组的unique_ptr对象只能使用数组语法访问。
make_unique函数有一个重载,允许你传递要创建的数组的大小,但你必须单独初始化每个对象:
unique_ptr<point[]> points = make_unique<point[]>(4);
points[1].x = 10.0;
points[1].y = -10.0;
这将创建一个包含四个初始设置为默认值的point对象的数组,并且以下行将第二个点初始化为(10.0, -10.0)的值。几乎总是比使用vector或array更好来管理对象数组。
早期版本的 C++标准库有一个名为auto_ptr的智能指针类。这是一个首次尝试,在大多数情况下都有效,但也有一些限制;例如,auto_ptr对象无法存储在标准库容器中。C++11 引入了右值引用和其他语言特性,如移动语义,通过这些特性,unique_ptr对象可以存储在容器中。auto_ptr类仍然可以通过<new>头文件使用,但只是为了让旧代码仍然可以编译。
unique_ptr类的重要一点是它确保指针只有一个副本。这很重要,因为类析构函数将释放资源,因此如果可以复制unique_ptr对象,这将意味着将有多个析构函数尝试释放资源。unique_ptr对象具有独占所有权;实例始终拥有其指向的内容。
您不能复制分配unique_ptr智能指针(复制分配运算符和复制构造函数已被删除),但可以通过转移所有权从源指针到目标指针来移动它们。因此,函数可以返回unique_ptr,因为所有权通过移动语义传递给被分配给函数值的变量。如果智能指针放入容器中,还有另一个移动。
共享所有权
有时您需要共享指针:您可能创建了几个对象,并将指针传递给每个对象的单个对象,以便它们可以调用此对象。通常,当一个对象有指向另一个对象的指针时,该指针代表应在包含对象销毁期间销毁的资源。如果指针被共享,这意味着当其中一个对象删除指针时,所有其他对象中的指针将无效(这称为悬空指针,因为它不再指向对象)。您需要一种机制,使得几个对象可以持有一个指针,直到所有使用该指针的对象都表示它们将不再需要使用它为止,该指针将保持有效。
C++11 提供了shared_ptr类的这个功能。该类在资源上维护引用计数,每个shared_ptr的副本都会增加引用计数。当销毁该资源的一个shared_ptr实例时,它将减少引用计数。引用计数是共享的,因此它意味着非零值表示至少存在一个shared_ptr访问该资源。当最后一个shared_ptr对象将引用计数减少到零时,可以安全释放资源。这意味着引用计数必须以原子方式进行管理,以处理多线程代码。
由于引用计数是共享的,这意味着每个shared_ptr对象都持有指向称为控制块的共享缓冲区的指针,这意味着它持有原始指针和指向控制块的指针,因此每个shared_ptr对象将持有比unique_ptr更多的数据。控制块用于不仅仅是引用计数。
可以创建一个shared_ptr对象来使用自定义删除器(作为构造函数参数传递),并且删除器存储在控制块中。这很重要,因为这意味着自定义删除器不是智能指针类型的一部分,因此封装相同资源类型但使用不同删除器的几个shared_ptr对象仍然是相同类型,并且可以放入该类型的容器中。
您可以从另一个shared_ptr对象创建一个shared_ptr对象,这将使用原始指针和指向控制块的指针初始化新对象,并增加引用计数。
point* p = new point(1.0, 1.0);
shared_ptr<point> sp1(p); // Important, do not use p after this!
shared_ptr<point> sp2(sp1);
p = nullptr;
sp2->x = 2.0;
sp1->y = 2.0;
sp1.reset(); // get rid of one shared pointer
在这里,第一个共享指针是使用原始指针创建的。这不是使用shared_ptr的推荐方式。第二个共享指针是使用第一个智能指针创建的,所以现在有两个共享指针指向相同的资源(p被赋值为nullptr以防止进一步使用)。在此之后,要么sp1要么sp2可以用来访问相同的资源。在此代码结束时,一个共享指针被重置为nullptr;这意味着sp1不再对资源有引用计数,并且不能使用它来访问资源。然而,您仍然可以使用sp2来访问资源,直到它超出范围,或者您调用reset。
在这段代码中,智能指针是从单独的原始指针创建的。由于共享指针现在已经接管了资源的生命周期管理,因此重要的是不再使用原始指针,在这种情况下将其分配为nullptr。最好避免使用原始指针,标准库通过一个名为make_shared的函数实现了这一点,可以像这样使用:
shared_ptr<point> sp1 = make_shared<point>(1.0,1.0);
该函数将使用new调用创建指定的对象,并且由于它接受可变数量的参数,你可以使用它来调用包装类上的任何构造函数。
你可以从unique_ptr对象创建一个shared_ptr对象,这意味着指针被移动到新对象,并创建了引用计数控制块。由于资源现在是共享的,这意味着不再具有资源的独占所有权,因此unique_ptr对象中的指针将被设置为nullptr。这意味着你可以有一个工厂函数,返回一个包装在unique_ptr对象中的对象的指针,调用代码可以确定是否使用unique_ptr对象来独占访问资源,还是使用shared_ptr对象来共享资源。
对于对象数组,使用shared_ptr没有太大意义;有更好的方法来存储对象的集合(vector或array)。无论如何,有一个索引运算符([]),默认删除器调用delete,而不是delete[]。
处理悬空指针
本书前面我们指出,当删除资源时,应将指针设置为nullptr,并在使用指针之前检查指针是否为nullptr。这样可以避免调用已删除对象的内存指针:悬空指针。
有时会出现悬空指针的情况。例如,父对象可能会创建具有指向父对象的后指针的子对象,以便子对象可以访问父对象。(一个例子是窗口创建子控件;子控件通常可以访问父窗口。)在这种情况下使用共享指针的问题在于,父对象将对每个子控件有一个引用计数,每个子控件对父对象也有一个引用计数,这会创建一个循环依赖。
另一个例子是,如果你有一个观察者对象的容器,希望在事件发生时通过调用每个观察者对象的方法来通知每个观察者对象。维护这个列表可能会很复杂,特别是如果观察者对象可以被删除,因此你必须提供一种方法来从容器中删除对象(其中将有一个shared_ptr引用计数),然后才能完全删除对象。如果你的代码可以简单地向容器中添加一个指向对象的指针,而不维护引用计数,但允许你在使用指针时检查指针是否悬空或指向现有对象,那就更容易了。
这样的指针称为弱指针,C++11 标准库提供了一个名为weak_ptr的类。你不能直接使用weak_ptr对象,也没有解引用运算符。相反,你可以从shared_ptr对象创建一个weak_ptr对象,当你想要访问资源时,你可以从weak_ptr对象创建一个shared_ptr对象。这意味着weak_ptr对象具有相同的原始指针,并且访问相同的控制块,但它不参与引用计数。
创建后,weak_ptr对象将使你能够测试包装指针是指向现有资源还是已被销毁的资源。有两种方法可以做到这一点:要么调用成员函数expired,要么尝试从weak_ptr创建一个shared_ptr。如果你正在维护一组weak_ptr对象,你可能决定定期遍历集合,对每个对象调用expired,如果该方法返回true,则从集合中删除该对象。由于weak_ptr对象可以访问原始shared_ptr对象创建的控制块,它可以测试引用计数是否为零。
测试weak_ptr对象是否悬空的第二种方法是从中创建一个shared_ptr对象。有两种选择。你可以通过将弱指针传递给其构造函数来创建shared_ptr对象,如果指针已经过期,构造函数将抛出一个bad_weak_ptr异常。另一种方法是在弱指针上调用lock方法,如果弱指针已经过期,那么shared_ptr对象将被分配为nullptr,你可以测试这一点。这三种方法在这里显示:
shared_ptr<point> sp1 = make_shared<point>(1.0,1.0);
weak_ptr<point> wp(sp1);
// code that may call sp1.reset() or may not
if (!wp.expired()) { /* can use the resource */}
shared_ptr<point> sp2 = wp.lock();
if (sp2 != nullptr) { /* can use the resource */}
try
{
shared_ptr<point> sp3(wp);
// use the pointer
}
catch(bad_weak_ptr& e)
{
// dangling weak pointer
}
由于弱指针不会改变资源的引用计数,这意味着你可以用它作为回指针来打破循环依赖(尽管通常更合理的是使用原始指针,因为子对象不能没有父对象而存在)。
模板
类可以被模板化,这意味着你可以编写通用代码,编译器将生成一个使用你的代码类型的类。参数可以是类型、常量整数值,或者可变版本(零个或多个参数,由使用类的代码提供)。例如:
template <int N, typename T> class simple_array
{
T data[N];
public:
const T* begin() const { return data; }
const T* end() const { return data + N; }
int size() const { return N; }
T& operator[](int idx)
{
if (idx < 0 || idx >= N)
throw range_error("Range 0 to " + to_string(N));
return data[idx];
}
};
这是一个非常简单的数组类,它定义了基本的迭代器函数和索引运算符,这样你就可以像这样调用它:
simple_array<4, int> four;
four[0] = 10; four[1] = 20; four[2] = 30; four[3] = 40;
for(int i : four) cout << i << " "; // 10 20 30 40
cout << endl;
four[4] = -99; // throws a range_error exception
如果你选择在class声明之外定义一个函数,那么你需要将模板及其参数作为class名称的一部分提供:
template<int N, typename T>
T& simple_array<N,T>::operator[](int idx)
{
if (idx < 0 || idx >= N)
throw range_error("Range 0 to " + to_string(N));
return data[idx];
}
你也可以为模板参数设置默认值:
template<int N, typename T=int> class simple_array
{
// same as before
};
如果你认为应该为模板参数提供特定的实现,那么你可以将该版本的代码作为模板的特化提供:
template<int N> class simple_array<N, char>
{
char data[N];
public:
simple_array<N, char>(const char* str)
{
strncpy(data, str, N);
}
int size() const { return N; }
char& operator[](int idx)
{
if (idx < 0 || idx >= N)
throw range_error("Range 0 to " + to_string(N));
return data[idx];
}
operator const char*() const { return data; }
};
请注意,使用特化时,你不会从完全模板化的类中获得任何代码;你必须实现你想要提供的所有方法,并且,如本例所示,特化相关但在完全模板化类上不可用的方法。这个例子是一个部分特化,意味着它只是针对一个参数(T,数据类型)进行了特化。这个类将用于声明为类型simple_array<n, char>的变量,其中n是一个整数。你可以自由地拥有一个完全特化的模板,在这种情况下,它将是一个特定大小和指定类型的特化:
template<> class simple_array<256, char>
{
char data[256];
public:
// etc
};
在这种情况下可能没有用,但是想法是对需要 256 个字符的变量有特殊的代码。
使用类
资源获取即初始化技术对于管理由其他库提供的资源非常有用,比如 C 运行时库或 Windows SDK。它简化了你的代码,因为你不必考虑资源句柄何时超出范围,并在每个点提供清理代码。如果清理代码很复杂,在 C 代码中通常会看到它放在函数的末尾,函数中的每个退出点都会有一个goto跳转到该代码。这会导致混乱的代码。在这个例子中,我们将用一个类来包装 C 文件函数,这样文件句柄的生命周期将自动维护。
C 运行时的_findfirst和_findnext函数允许你搜索与模式匹配的文件或目录(包括通配符)。_findfirst函数返回一个intptr_t,这与搜索相关,然后将其传递给_findnext函数以获取后续值。这个intptr_t是一个不透明指针,指向 C 运行时为搜索维护的资源,所以当你完成搜索时,你必须调用_findclose来清理与之相关的任何资源。为了防止内存泄漏,调用_findclose非常重要。
在Beginning_C++文件夹下,创建一个名为Chapter_06的文件夹。在 Visual C++中,创建一个新的 C++源文件,将其保存到Chapter_06文件夹中,并将其命名为search.cpp。该应用程序将使用标准库控制台和字符串,并且将使用 C 运行时文件函数,因此在文件顶部添加这些行:
#include <iostream>
#include <string>
#include <io.h>
using namespace std;
应用程序将使用文件搜索模式调用,并使用 C 函数搜索文件,因此您需要一个具有参数的main函数。在文件底部添加以下内容:
void usage()
{
cout << "usage: search pattern" << endl;
cout << "pattern is the file or folder to search for "
<< "with or without wildcards * and ?" << endl;
}
int main(int argc, char* argv[])
{
if (argc < 2)
{
usage();
return 1;
}
}
首先要创建一个包装类来管理搜索句柄的资源。在使用函数上方,添加一个名为search_handle的类:
class search_handle
{
intptr_t handle;
public:
search_handle() : handle(-1) {}
search_handle(intptr_t p) : handle(p) {}
void operator=(intptr_t p) { handle = p; }
void close()
{ if (handle != -1) _findclose(handle); handle = 0; }
~search_handle() { close(); }
};
此类有一个单独的函数来释放句柄。这样,该类的用户可以尽快释放包装资源。如果对象用于可能引发异常的代码中,则close方法不会直接调用,而是将调用析构函数。包装对象可以使用intptr_t值创建。如果此值为-1,则句柄无效,因此仅当句柄不具有此值时,close方法才会调用_findclose。
我们希望该类的对象具有句柄的独占所有权,因此通过将以下内容放入类的public部分来删除复制构造函数和复制赋值:
void operator=(intptr_t p) { handle = p; }
search_handle(search_handle& h) = delete; void operator=(search_handle& h) = delete;
如果对象被移动,那么现有对象中的任何句柄都必须被释放,因此在刚刚添加的行之后添加以下内容:
search_handle(search_handle&& h) { close(); handle = h.handle; }
void operator=(search_handle&& h) { close(); handle = h.handle; }
包装类将通过调用_findfirst分配,并将传递给调用_findnext,因此包装类需要两个运算符:一个用于转换为intptr_t,以便可以在需要intptr_t的任何地方使用此类的对象,另一个用于在需要bool时使用对象。将这些添加到类的public部分:
operator bool() const { return (handle != -1); }
operator intptr_t() const { return handle; }
将转换为bool允许您编写如下代码:
search_handle handle = /* initialize it */;
if (!handle) { /* handle is invalid */ }
如果有一个返回指针的转换运算符,那么编译器将优先调用它,而不是转换为bool。
您应该能够编译此代码(记得使用/EHsc开关)以确认没有拼写错误。
接下来,编写一个包装类来执行搜索。在search_handle类下方,添加一个file_search类:
class file_search
{
search_handle handle;
string search;
public:
file_search(const char* str) : search(str) {}
file_search(const string& str) : search(str) {}
};
该类是使用搜索条件创建的,我们可以选择传递 C 或 C++字符串。该类有一个search_handle数据成员,并且,由于默认析构函数将调用成员对象的析构函数,因此我们不需要自己提供析构函数。但是,我们将添加一个close方法,以便用户可以显式释放资源。此外,为了使该类的用户能够确定搜索路径,我们需要一个访问器。在类的底部添加以下内容:
const char* path() const { return search.c_str(); }
void close() { handle.close(); }
我们不希望复制file_search对象的实例,因为这意味着搜索句柄的两个副本。您可以删除复制构造函数和赋值运算符,但没有必要。尝试这样做:在main函数中,添加此测试代码(位置无关紧要):
file_search f1("");
file_search f2 = f1;
编译代码。您将收到一个错误和一个解释:
error C2280: 'file_search::file_search(file_search &)': attempting to reference a deleted function
note: compiler has generated 'file_search::file_search' here
没有复制构造函数,编译器将生成一个(这是第二行)。第一行有点奇怪,因为它说您正在尝试调用编译器生成的已删除方法!实际上,错误是说生成的复制构造函数正在尝试复制handle数据成员和已删除的search_handle复制构造函数。因此,您受到保护,防止复制file_search对象而无需添加任何其他代码。删除刚添加的测试行。
接下来在main函数的底部添加以下行。这将创建一个file_search对象并将信息打印到控制台。
file_search files(argv[1]);
cout << "searching for " << files.path() << endl;
然后需要添加代码来执行搜索。这里使用的模式将是一个具有输出参数并返回bool的方法。如果方法调用成功,则找到的文件将在输出参数中返回,并且该方法将返回true。如果调用失败,则输出参数将保持不变,并且该方法将返回false。在file_search类的public部分中,添加此函数:
bool next(string& ret)
{
_finddata_t find{};
if (!handle)
{
handle = _findfirst(search.c_str(), &find);
if (!handle) return false;
}
else
{
if (-1 == _findnext(handle, &find)) return false;
}
ret = find.name;
return true;
}
如果这是对该方法的第一次调用,则handle将无效,因此将调用_findfirst。这将使用搜索结果填充_finddata_t结构并返回一个intptr_t值。search_handle对象的数据成员被赋予此函数返回的值,如果_findfirst返回-1,则该方法返回false。如果调用成功,则使用_finddata_t结构中的 C 字符串指针初始化输出参数(指向string的引用)。
如果有更多与模式匹配的文件,那么可以重复调用next函数,在这些后续调用中,将调用_findnext函数以获取下一个文件。在这种情况下,search_handle对象被传递给函数,并通过类的转换运算符隐式转换为intptr_t。如果_findnext函数返回-1,这意味着搜索中没有更多文件。
在main函数的底部,添加以下行以执行搜索:
string file;
while (files.next(file))
{
cout << file << endl;
}
现在可以编译代码并使用搜索条件运行它。请记住,这受到_findfirst/_findnext函数的限制,因此您可以进行的搜索将非常简单。尝试在命令行中运行此代码,并使用参数搜索Beginning_C++文件夹中的子文件夹:
search Beginning_C++Ch*
这将给出以Ch开头的子文件夹列表。由于没有理由让search_handle成为一个单独的类,将整个类移到search_handle的private部分,在handle数据成员的声明之上。编译并运行代码。
总结
通过类,C++提供了一个强大而灵活的机制,用于封装数据和方法,以提供作用于数据的行为。您可以将此代码模板化,以便编写通用代码,并让编译器为您需要的类型生成代码。在本例中,您已经看到了类是面向对象的基础。类封装数据,使得调用者只需要了解预期的行为(在本例中是获取搜索中的下一个结果),而无需了解类如何实现这一点的细节。在下一章中,我们将进一步研究类的其他特性;特别是通过继承实现代码重用。