函数是 C++ 的基础设施;代码包含在函数中,要执行该代码,必须调用函数。C++ 在定义和调用函数的方式上非常灵活:可以用固定数量的参数或可变数量的参数定义函数;您可以编写泛型代码,以便相同的代码可以用于不同的类型;您甚至可以编写类型数量可变的泛型代码。
在最基本的层次上,函数有参数,有操作参数的代码,并返回一个值。C++ 提供了几种方法来确定这三个方面。在下一节中,我们将从声明的左边到右边介绍 C++ 函数的那些部分。功能也可以模板化,但这将留给后面的部分。
一个函数必须只定义一次,但是通过重载,可以有许多同名的函数,它们的参数不同。使用函数的代码必须能够访问函数的名称,因此它需要能够访问函数定义(例如,函数是在源文件中较早定义的)或者函数声明(也称为函数原型)。编译器使用原型类型检查调用代码是否正在使用正确的类型调用函数。
通常,库被实现为单独的编译库文件,并且库函数的原型被提供在头文件中,以便许多源文件可以通过包含头文件来使用这些函数。但是,如果您知道函数名、参数和返回类型,您可以自己在文件中键入原型。
无论您做什么,您都只是为编译器提供信息,以对调用函数的表达式进行类型检查。链接器负责在库中定位函数,并将代码复制到可执行文件中,或者设置基础结构以使用共享库中的函数。包含库的头文件并不意味着您可以使用该库中的函数,因为在标准 C++ 中,头文件没有包含函数的库的信息。
Visual C++ 提供了一个名为comment的pragma,它可以与lib选项一起使用,作为链接器链接特定库的消息。所以头文件中的#pragma comment(lib, "mylib")会告诉链接器与mylib.lib链接。一般来说,最好使用项目管理工具,如 nmake 或 MSBuild ,以确保项目中链接了正确的库。
大多数 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 fileextern关键字是可以添加到函数声明中的许多说明符之一。例如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是您将在代码中使用的运算符。后面一节将解释如何用全局函数实现运算符。
运算符是重载的一个例子。您可以重载任何函数,即使用相同的名称,但为实现提供不同的参数类型或不同数量的参数。
函数可能没有参数,在这种情况下,函数是用一对空括号定义的。函数定义必须在括号中给出参数的类型和名称。在许多情况下,函数将有固定数量的参数,但是您可以编写具有可变数量参数的函数。您也可以为某些参数定义具有默认值的函数,实际上,提供了一个函数,该函数根据传递给该函数的参数数量来重载自身。变量参数列表和默认参数将在后面介绍。
还可以标记函数,以指示它们是否会引发异常。关于异常的更多细节将在第 7 章、诊断和调试中给出,但是有两个语法你需要注意。
C++ 的早期版本允许您以三种方式在函数上使用throw说明符:首先,您可以提供一个逗号分隔的列表,列出函数中代码可能引发的异常类型;其次,可以提供省略号(...),表示函数可能抛出任何异常;第三,您可以提供一对空括号,这意味着函数不会抛出异常。语法如下所示:
int calculate(int param) throw(overflow_error)
{
// do something which potentially may overflow
}throw说明符在 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函数。这两个函数是让迭代器访问列表所必需的,它使您能够使用带有 ranged- for语法的对象。
This is typical in the C++ Standard Library. If a container holds data in a contiguous block of memory, then pointer arithmetic can use the pointer to the first item and a pointer immediately after the last item to determine how many items are in the container. Incrementing the first pointer gives sequential access to every item, and pointer arithmetic allows random access. All containers implement a begin and end function to give access to the container iterators.
在这个例子中,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++ 开发人员来说,这也就是大家熟知的名字“mangling ”。
*如果需要通过共享库导出函数(在 Windows 中是动态链接库,必须使用其他语言可以使用的类型和名称。为此,您可以用extern "C"标记一个功能。这意味着该函数具有 C 链接,编译器不会使用 C++ 名称 mangling。显然,您应该只在将由外部代码使用的函数上使用它,而不应该在具有使用 C++ 自定义类型的返回值和参数的函数上使用它。
但是,如果这样的函数确实返回 C++ 类型,编译器只会发出警告。原因是 C 是一种灵活的语言,一个 C 程序员将能够解决如何将 C++ 类型转化为可用的东西,但是这样滥用它们是不良的做法!
The extern "C" linkage can also be used with global variables, and you can use it on a single item or (using braces) on many items.
Visual C++ 支持六种调用约定,可用于函数。__clrcall说明符意味着该函数应该作为. NET 函数调用,并允许您编写混合了本机代码和托管代码的代码。C++/CLR(微软的语言扩展,以 C++ 编写。NET 代码)超出了本书的范围。其他五个用于指示参数如何传递给函数(在堆栈上或使用中央处理器寄存器),以及谁负责维护堆栈。我们将只讨论三个:__cdecl、__stdcall和__thiscall。
你很少会明确使用__thiscall;它是用于定义为自定义类型成员的函数的调用约定,表示函数有一个隐藏的参数,该参数是指向可以通过函数中的this关键字访问的对象的指针。更多的细节将在下一章给出,但是重要的是要意识到这样的成员函数有不同的调用约定,尤其是当你需要初始化函数指针的时候。
默认情况下,C++ 全局函数将使用__cdecl调用约定。堆栈由调用代码维护,因此在调用代码中,对__cdecl函数的每次调用后面都有清理堆栈的代码。这使得每个函数调用稍微大一点,但是需要使用变量参数列表。__stdcall调用约定被大多数的 Windows SDK 函数使用,它表示被调用的函数清理堆栈,所以不需要在调用代码中生成这样的代码。显然,编译器知道某个函数使用__stdcall很重要,因为否则,它将生成代码来清理已经被该函数清理的堆栈帧。您通常会看到标有WINAPI,的窗口功能,这是__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在第二种情况下,a char是整数,所以提升为 aint,调用函数。在第三种情况下,编译器会发出一个警告,指出转换可能会导致数据丢失,但这是一个警告,因此代码将会编译。如果你想阻止这种隐式转换,你可以删除你不想让调用者使用的功能。为此,提供一个原型并使用语法= 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 参数是通过指针传递的,那么查看指针声明是很重要的。单个*表示变量是指针,两个表示它是指针的指针。以下函数通过输出参数返回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;
}任何返回内存缓冲区的函数都必须记录谁负责释放内存。在大多数情况下,它通常是调用者,如本示例代码中假设的那样。
函数通常会作用于全局数据,或者调用者传入的数据。重要的是,当函数完成时,它会使这些数据保持一致的状态。同样重要的是,函数可以在访问数据之前对数据进行假设。
函数通常会改变一些数据:传入函数的值、函数返回的数据或一些全局数据。在设计函数时,确定哪些数据将被访问和更改以及这些规则是否被记录是很重要的。
一个函数将有它将使用的数据的前提条件和假设。例如,如果一个函数被传递了一个文件名,意图是该函数将从文件中提取一些数据,那么检查文件是否存在是谁的责任?您可以让它成为函数的责任,因此前几行将检查该名称是否是文件的有效路径,并调用操作系统函数来检查该文件是否存在。但是,如果您有几个函数将对文件执行操作,那么您将在每个函数中复制这个检查代码,最好将这个责任放在调用代码上。显然,这样的操作可能很昂贵,因此避免调用代码和函数来执行检查是很重要的。
第 7 章诊断与调试,将描述如何添加调试代码,称为断言,您可以在函数中放置这些代码来检查参数值,以确保调用代码遵循您设置的前置条件规则。断言是使用条件编译定义的,因此只会出现在调试版本中(即,使用调试信息编译的 C++ 代码)。发布版本(将交付给最终用户的完整代码)将有条件地编译断言;这使得代码更快,如果您的测试足够彻底,您可以确保满足先决条件。
您还应该记录您的功能的后置条件。也就是说,关于函数返回的数据的假设(通过函数返回值、out 参数或引用传递的参数)。后置条件是调用代码将做出的假设。例如,您可以返回一个有符号的整数,其中该函数旨在返回一个正值,但负值用于指示错误。通常,如果函数失败,返回指针的函数将返回nullptr。在这两种情况下,调用代码都知道它需要检查返回值,并且只有在返回值为正或不为正时才使用它nullptr。
您应该注意记录函数如何使用函数外部的数据。如果函数的目的是更改外部数据,您应该记录函数将做什么。如果您没有明确记录函数对外部数据的作用,那么您必须确保当函数完成时,这些数据保持不变。原因是调用代码只会假设您在文档中说过的话,并且更改全局数据的副作用可能会导致问题。有时需要存储全局数据的状态,并在函数返回之前将项目返回到该状态。
这方面的一个例子,就是cout对象。cout对象对您的应用是全局的,可以通过操纵器进行更改,使其以某种方式解释数值。如果您在功能中更改它(例如,通过插入hex操纵器),那么当cout对象在功能外使用时,此更改将保持不变。
创建一个名为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。
Note that the string class defines a += operator that can be used to append a string to the end of the string in the variable and the <string> header file defines a function called to_string that is overloaded for each of the built-in types to return a string formatted with the value of the function parameter.
该函数将函数指针声明为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>。
It is important to realize that, the first time the compiler comes across a templated function, it will create a version of the function for the specified type. If you call the templated function for several different types, the compiler will create, or instantiate, a specialized function for each of these types.
此模板的定义表明将只使用一种类型,因此您只能使用两个相同类型的参数来调用它:
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;
}This is done to illustrate how templates work; it really does not make sense to define a maximum function that takes two different types.
这个版本是为两种不同的类型编写的,模板声明提到了两种类型,这两种类型用于两个参数。但是注意,函数返回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参数的maximum版本,返回一个int,所以返回值是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数组。
非类型参数在编译时必须是常量:值可以是整数(包括枚举),但不能是浮点。您可以使用整数数组,但是这些数组可以通过模板参数作为指针使用。
虽然在大多数情况下,编译器无法推导出 value 参数,但如果将该值定义为数组的大小,则可以。这可以用来让一个函数看起来可以决定一个内置数组的大小,但是当然不能,因为编译器会为每个需要的大小创建一个函数的版本。例如:
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的对象,这不是我们想要的。我们想要一个类型为T的 N 尺寸内置数组对象。这个函数是这样调用的:
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);
}这适用于大多数内置类型,但是如果您用指针调用它,您将获得指针的大小,而不是指针指向的内容。因此,对于char数组的大小,number_of_bytes("x")将返回 4(在 32 位系统上),而不是 2。您可以决定希望对使用 C 函数strlen的char*指针进行专门化,以计算字符串中的字符数,直到出现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;
}这意味着您现在可以在cout对象中插入一个point对象,并将其打印在控制台上:
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相同。第二个vector通过调用fill函数来填充。第一个参数是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函数将调用该函数对象上的operator(int,int)函数,将v1中的每一项作为第一个参数,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 表达式看起来就像是一个函数参数的函数定义:
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](int a) {return a < limit; };如果将 lambda 表达式与全局函数进行比较,捕获列表有点像标识全局函数可以访问的全局变量。
在标题列表之后,在括号中给出参数列表。同样,如果将 lambda 与函数进行比较,lambda 参数列表相当于函数参数列表。如果 lambda 表达式没有任何参数,那么您可以完全忽略括号。
lambda 的主体在一对大括号中给出。这可以包含任何可以在函数中找到的内容。lambda 体可以声明局部变量,甚至可以声明static个变量,看起来很离奇,但却是合法的:
auto incr = [] { static int i; return ++ i; };
incr();
incr();
cout << incr() << endl; // 3lambda 的返回值是从返回的项中推导出来的。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函数将为通过λ的a参数传入的范围内的每个项目调用该表达式。count_if功能记录λ返回true的次数。
本章中的示例使用您在本章中学习的技术,按照文件大小的顺序列出文件夹和子文件夹中的所有文件,并列出文件名及其大小。该示例相当于在命令行中键入以下内容:
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;
}该示例将使用窗口函数FindFirstFile和FindNextFile来获取符合文件规范的文件的信息。这些以WIN32_FIND_DATAA结构返回数据,其中包含关于文件名、文件大小和文件属性的信息。这些函数也返回关于文件夹的信息,所以这意味着我们可以测试子文件夹并递归。WIN32_FIND_DATAA结构将文件大小分为两部分,即高 32 位和低 32 位,为 64 位数字。我们将创建自己的结构来保存这些信息。在文件的顶部,在 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对象是否大于另一个。
该代码将使用窗口函数来获取关于文件的信息,特别是它们的名称和大小。该信息将存储在一个vector中,因此在文件的顶部添加这两个高亮显示的行:
#include <string>
#include <vector>
#include <tuple>需要tuple类,这样我们就可以在vector中存储一个string(文件名)和一个file_size对象作为每个项目。为了使代码更易读,在结构定义后添加以下别名:
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函数采用搜索路径,在这种情况下,我们使用以*为后缀的文件夹名称,意思是该文件夹中的所有内容。请注意,窗口函数需要一个const char*参数,所以我们在string对象上使用c_str函数。
如果函数调用成功,并且它找到了一个符合这个标准的项目,那么函数会填充引用传递的WIN32_FIND_DATAA结构,并且它还会返回一个不透明的指针,该指针将用于在这个搜索中进行后续调用(您不需要知道它指向什么)。代码检查调用是否成功,如果成功,则反复调用FindNextFileA获取下一项,直到该函数返回 0,表示没有更多项。不透明的指针被传递到FindNextFileA以便它知道正在检查哪个搜索。搜索完成后,代码调用FindClose释放窗口为搜索分配的任何资源。
搜索将返回文件和文件夹项目;为了不同地处理每一个,我们可以测试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 作为函数模板参数。该代码调用setw操纵器,以确保文件大小始终打印在 16 个字符宽的列中。要使用这个,您需要在文件的顶部为<iomanip>添加一个 include。注意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>标题中的排序功能,因此在<tuple>的 include 之后添加一个 include。在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 表达式。然而,全局函数有一个主要问题:数据与函数是分离的。这意味着函数必须通过全局数据项访问数据,或者数据必须在每次调用函数时通过参数传递给函数。在这两种情况下,数据都存在于函数之外,并且可以被与数据无关的其他函数使用。下一章将给出一个解决方案:类。A class允许您将数据封装在自定义类型中,并且您可以在该类型上定义函数,以便只有这些函数能够访问数据。**