Skip to content

Latest commit

 

History

History
1403 lines (991 loc) · 83.5 KB

File metadata and controls

1403 lines (991 loc) · 83.5 KB

五、使用函数

函数是 C++ 的基本基础设施;代码包含在函数中,要执行该代码,您必须调用函数。 C++ 在定义和调用函数的方式上非常灵活:您可以使用固定数量的参数或可变数量的参数定义函数;您可以编写泛型代码,以便相同的代码可以用于不同的类型;您甚至可以编写数量可变的类型的泛型代码。

定义 C++ 函数

在最基本的级别上,函数有参数,有操作参数的代码,并返回值。 C++ 为您提供了几种确定这三个方面的方法。 在下一节中,我们将从声明的左侧到右侧介绍 C++ 函数的这些部分。 函数也可以模板化,但这将留待后面的小节讨论。

声明和定义函数

一个函数必须只定义一次,但通过重载,您可以拥有许多名称相同但参数不同的函数。 使用函数的代码必须有权访问函数的名称,因此它需要有权访问函数定义(例如,函数在源文件中早先定义)或函数的声明(也称为函数原型)。 编译器使用原型来类型检查调用代码是否使用正确的类型调用函数。

通常,库被实现为单独的编译后的库文件,并且库函数的原型在头文件中提供,以便许多源文件可以通过包括头文件来使用函数。 但是,如果您知道函数名、参数和返回类型,则可以自己在文件中键入原型。 无论执行哪种操作,您都只是为编译器提供信息,以便对调用函数的表达式进行类型检查。 链接器负责定位库中的函数,并将代码复制到可执行文件中,或者设置基础结构以使用共享库中的函数。 包含库的头文件并不意味着您将能够使用该库中的函数,因为在标准 C++ 中,头文件不包含有关包含函数的库的信息。

Visual C++ 提供了一个名为commentpragma,它可以与lib选项一起用作链接器的消息,以链接到特定库。 因此,头文件中的#pragma comment(lib, "mylib")将告诉链接器与mylib.lib链接。 通常,最好使用项目管理工具,如nmakeMSBuild,以确保项目中链接了正确的库。

大多数 C 运行时库是这样实现的:函数在静态库或动态链接库中编译,函数原型在头文件中提供。 您可以在链接器命令行中提供库,并且通常会包括库的头文件,以便编译器可以使用函数原型。 只要链接器知道该库,您就可以在代码中键入原型(并将其描述为外部链接,以便编译器知道函数是在其他地方定义的)。 这可以使您避免在源文件中包含一些大文件,这些文件大多具有您不会使用的功能原型。

但是,大部分 C++ 标准库都是在头文件中实现的,这意味着这些文件可能非常大。 您可以通过将这些头文件包含在预编译头文件中来节省编译时间,如第 1 章、*中从 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标记该值,以指示编译器可以通过在编译时计算该值来优化代码。 如果函数值可以在编译时计算,这意味着函数调用中的参数在编译时必须是已知的,因此它们必须是文字。 该函数也必须为单行。 如果不满足这些限制,则编译器可以自由忽略该说明符。

Related 是inline说明符。 这可以放在函数声明的左侧,作为对编译器的建议:当其他代码调用该函数时,编译器应该将实际代码的副本放入调用函数中,而不是由编译器在内存中插入指向该函数的跳转(以及创建堆栈帧)。 同样,编译器可以随意忽略此说明符。

确定返回类型

可以编写函数来运行例程,而不返回值。 如果是这种情况,则必须指定该函数返回void。 在大多数情况下,函数将返回值,即使只是为了指示函数已正确完成。 不要求调用函数获取返回值或对其执行任何操作。 调用函数可以简单地忽略返回值。

有两种方法可以指定返回类型。 第一种方法是在函数名之前指定类型。 这是迄今为止大多数示例中使用的方法。 第二种方法称为尾随返回类型,要求您将auto作为返回类型放在函数名之前,并使用->语法在参数列表之后提供实际的返回类型:

    inline auto mult(int lhs, int rhs) -> int 
    { 
        return lhs * rhs; 
    }

这个函数非常简单,很适合内联。 左边的返回类型是auto,这意味着实际的返回类型是在参数列表之后指定的。 -> int表示返回类型为int。 此语法与在左侧使用int具有相同的效果。 当函数模板化并且返回类型可能不明显时,此语法非常有用。

在这个简单的示例中,您可以完全省略返回类型,只需在函数名左侧使用auto即可。 此语法意味着编译器将从返回的实际值推断返回类型。 显然,编译器只知道函数体的返回类型,因此不能提供此类函数的原型。

最后,如果一个函数根本没有返回(例如,如果它进入一个永无止境的循环来轮询某个值),您可以用 C++ 11 属性[[noreturn]]来标记它。 编译器可以使用此属性编写更高效的代码,因为它知道不需要提供返回值的代码。

命名函数

通常,函数名对变量有相同的规则:它们必须以字母或下划线开头,并且不能包含空格或其他标点符号。 遵循自文档化代码的一般原则,您应该根据函数的功能来命名函数。 有一个例外,它们是用于为操作符(大多数是标点符号)提供重载的特殊函数。 这些函数的名称形式为operatorx,其中x是您将在代码中使用的运算符。 后面的部分将解释如何实现具有全局函数的运算符。

运算符是重载的一个示例。 您可以重载任何函数,即使用相同的名称,但提供不同参数类型或不同数量的参数的实现。

函数参数

函数可以没有参数,在这种情况下,函数是用一对空括号定义的。 函数定义必须在圆括号内给出参数的类型和名称。 在许多情况下,函数将具有固定数量的参数,但您可以使用可变数量的参数编写函数。 您还可以使用某些参数的默认值定义函数,实际上,提供了一个根据传递给函数的参数数量重载自身的函数。 变量参数列表和默认参数将在后面介绍。

指定例外情况

还可以标记函数以指示它们是否会引发异常。 有关异常的更多详细信息将在第 10 章诊断和调试中提供,但您需要了解两种语法。

C++ 的早期版本允许您以三种方式在函数上使用throw说明符:第一,您可以提供函数中代码可能引发的异常类型的逗号分隔列表;第二,您可以提供省略号(...),这意味着函数可能抛出任何异常;第三,您可以提供一对空圆括号,这意味着函数不会抛出异常。 语法如下所示:

    int calculate(int param) throw(overflow_error) 
    { 
        // do something which potentially may overflow 
    }

在 C++ 11 中,throw说明符已被弃用,很大程度上是因为指示异常类型的功能没有用处。 然而,表示不会抛出异常的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

如果您定义的log_message函数只有一个const string&参数,那么编译器将不知道是调用该函数还是调用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函数,以及返回指向列表中第一个项目和最后一个项目之后位置的指针的beginend函数。 这两个函数是提供对列表的迭代器访问所必需的,它使您能够将对象与 range-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宏中提供所需的类型。 在本例中,假设堆栈上的每个参数都是int,则调用va_arg

一旦从堆栈中读取了所有参数,代码就会在返回总和之前调用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参数之后发生的。 句点表示10double,因此编译器将double值放入堆栈。 当函数使用va_arg宏从堆栈读取值时,它会将 8 字节的double读取为两个 4 字节的int值,对于 Visual C++ 生成的代码,这将导致总和为1076101140。 这说明了参数列表的类型不安全方面:无法让编译器对传递给函数的参数进行类型检查。

如果您的函数传递了不同的类型,那么您必须实现某种机制来确定这些参数是什么。 参数列表的一个很好的例子是 Cprintf函数:

    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++ 类型转换为有用的东西,但这样滥用它们是糟糕的做法!

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(Microsoft 对 C++ 的语言扩展以编写.NET 代码)超出了本书的范围。 其他五个用于指示如何将参数传递给函数(在堆栈上或使用 CPU 寄存器),以及谁负责维护堆栈。 我们只介绍三个:__cdecl__stdcall__thiscall

您很少显式使用__thiscall;它是用于定义为自定义类型成员的函数的调用约定,并指示该函数有一个隐藏参数,该参数是指向可通过函数中的this关键字访问的对象的指针。 下一章将给出更多细节,但重要的是要认识到,此类成员函数具有不同的调用约定,特别是当您需要初始化函数指针时。

默认情况下,C++ 全局函数将使用__cdecl调用约定。 堆栈由调用代码维护,因此在调用代码中,每个对__cdecl函数的调用后面都跟有清理堆栈的代码。 这会使每个函数调用稍大一些,但使用变量参数列表时需要这样做。 大多数 Windows SDK 函数都使用__stdcall调用约定,它表明被调用的函数清理了堆栈,因此不需要在调用代码中生成这样的代码。 显然,编译器知道函数使用__stdcall是很重要的,因为否则,它将生成代码来清理已经被函数清理的堆栈框架。 您通常会看到 Windows 函数标有WINAPI,,这是__stdcalltypedef

使用递归

在大多数情况下,调用堆栈的内存开销并不重要。 但是,当您使用递归时,可能会构建一长串堆栈帧。 顾名思义,递归是指函数调用自身。 一个简单的例子是计算阶乘的函数:

    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

在第二种情况下,achar是一个整数,因此它被提升为int并调用该函数。 在第三种情况下,编译器将发出转换可能导致数据丢失的警告,但这是一个警告,因此代码将进行编译。 如果你想阻止这种隐式转换,你可以删除你不想让调用者使用的函数。 为此,请提供一个原型并使用语法= delete

    void f(double) = delete; 

    void g() 
    { 
        f(1);   // compiles 
        f(1.0); // C2280: attempting to reference a deleted function 
    }

现在,当代码尝试使用chardouble(或将隐式转换为doublefloat)调用函数时,编译器将发出错误。

按值传递和按引用传递

默认情况下,编译器将按值传递参数,即创建一个副本。 如果传递自定义类型,则会调用其复制构造函数来创建新对象。 如果将指针传递给内置类型或自定义类型的对象,则将通过值传递指针,即在函数堆栈上为参数创建一个新指针,并使用传递给函数的内存地址对其进行初始化。 这意味着,在函数中,您可以将指针更改为指向其他内存(如果要对该指针使用指针算法,这将非常有用)。 指针指向的数据将通过引用传递,也就是说,数据保留在函数外部的位置,但函数可以使用指针更改数据。 同样,如果在参数上使用引用,则意味着该对象由该引用传递。 显然,如果在指针或引用参数上使用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_backvalues容器中插入一个值时,它实际上是在将该值插入到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; 
    }

任何返回内存缓冲区的函数都必须记录谁负责释放内存。 在大多数情况下,通常是调用方,如本示例代码所假定的那样。

设计功能

通常,函数将作用于全局数据或调用方传入的数据。 重要的是,当函数完成时,它会使此数据保持一致状态。 同样重要的是,函数可以在访问数据之前对其进行假设。

前置条件和后置条件

函数通常会更改某些数据:传递给函数的值、函数返回的数据或某些全局数据。 在设计函数时,确定要访问和更改的数据,并记录这些规则,这一点很重要。

函数将对它将使用的数据有前提条件和假设。 例如,如果向某个函数传递了一个文件名,目的是让该函数从该文件中提取一些数据,那么谁负责检查该文件是否存在呢? 您可以让它由函数负责,因此前几行将检查名称是否为文件的有效路径,并调用操作系统函数来检查该文件是否存在。 但是,如果您有几个函数将对文件执行操作,那么您将在每个函数中复制此检查代码,并且最好将该责任放在调用代码上。 显然,这样的操作可能代价很高,因此避免调用代码和函数来执行检查非常重要。

第 10 章诊断和调试将介绍如何添加调试代码(称为Asserts),您可以将这些代码放在函数中以检查参数值,以确保调用代码遵循您设置的前提规则。 断言是使用条件编译定义的,因此只会出现在调试版本中(即,使用调试信息编译的 C++ 代码)。 发布版本(将交付给最终用户的已完成代码)将有条件地编译断言;这会使代码更快,如果您的测试足够彻底,则可以确保满足前提条件。

您还应该记录您的函数的后置条件。 也就是说,关于函数返回的数据的假设(通过函数返回值、输出参数或引用传递的参数)。 后置条件是调用代码将做出的假设。 例如,您可以返回带符号整数,其中函数返回正值,但负值用于指示错误。 如果函数失败,返回指针的函数通常会返回nullptr。 在这两种情况下,调用代码都知道它需要检查返回值,并且仅在返回值为正或不为nullptr时才使用它。

使用不变量

您应该小心记录函数如何使用函数外部的数据。 如果该函数的目的是更改外部数据,则应记录该函数将执行的操作。 如果您没有显式地记录函数对外部数据做了什么,那么您必须确保函数完成这些数据时保持不变。 原因是调用代码只假定您在文档中所说的内容,更改全局数据的副作用可能会导致问题。 有时需要存储全局数据的状态,并在函数返回之前将项返回到该状态。

我们已经在第 3 章探索 C++ 类型中看到了一个使用cout对象的例子。 cout对象对于您的应用是全局的,可以通过操纵器对其进行更改,使其以特定方式解释数字值。 如果在函数中对其进行更改(例如,通过插入hex操纵器),则在函数外部使用cout对象时,此更改将保持不变。

第 3 章探索 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提供有关进度的信息。

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; 
    }

您可以使用其他整数类型调用此函数,较小类型(shortcharbool等)将提升为int,较大类型(long long)的值将被截断。 同样,unsigned类型的变量将转换为可能导致问题的signed int类型。 请考虑下面的函数调用:

    unsigned int s1 = 0xffffffff, s2 = 0x7fffffff; 
    unsigned int result = maximum(s1, s2);

result变量的值是什么:s1s2? 它是s2。 原因是这两个值都转换为signed int,当转换为有符号类型时,s1将是值-1s2将是值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);

此代码将进行编译,因为可以将booldouble转换为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,因此编译器可以从函数调用中推导出第二个模板参数,但不能推导出第一个参数,因此您必须在调用中提供一个值。 以下是为Tintsize的值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函数将创建一个有 10 个成员的int数组,并且每个成员都被初始化为值42。 编译器推导出int作为第二个参数,但是这段代码可以用init<10,int>(42)调用函数来显式指示您需要一个int数组。

非类型参数在编译时必须是常量:该值可以是整数(包括枚举),但不能是浮点数。 您可以使用整数数组,但可以通过 Template 参数将其用作指针。

虽然在大多数情况下,编译器无法推导出 Value 参数,但如果将该值定义为数组的大小,则可以推导出 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],即对类型为T的对象的 N 个大小的内置引用数组,这不是我们想要的。 我们需要一个类型为T的 N 大小的内置数组对象。 此函数的调用方式如下:

    int squares[] = { 1, 4, 9, 16, 25 }; 
    print_array(squares);

前面代码的有趣之处在于,编译器看到初始化式列表中有五个项目。 内置数组有五个项目,因此调用如下函数:

    print_array<int,5>(squares);

如前所述,编译器将为代码调用的每个TN组合实例化此函数。 如果模板函数有大量代码,那么这可能是个问题。 解决此问题的一种方法是使用帮助器函数:

    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 函数strlenchar*指针进行专门化,以计算字符串中直到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);

调用此函数时,参数列表被分成两部分:第一个参数first,中的第一个参数(1)和其他三个参数放入参数包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,是用参数包的大小创建的,与初始值设定项大括号一起使用的 Unpack 语法将用参数填充数组。 尽管这适用于任意数量的参数,但所有参数都必须是相同类型的数组arr

其中一个技巧是使用逗号运算符:

    template<typename... Arguments>  
    void print(Arguments ... args) 
    { 
        int dummy[sizeof...(args)] = { (print(args), 0)... }; 
    }

这将创建一个名为dummy的虚拟数组。 除了在参数包的扩展中以外,不使用该数组。 该数组以args参数包的大小创建,省略号使用括号内的表达式展开参数包。 表达式使用逗号运算符,它将返回逗号的右侧。 由于这是一个整数,这意味着dummy的每个条目都有零值。 有趣的部分是逗号操作符的左侧。 这里,使用args参数包中的每一项调用带有单个模板化参数的print版本。

重载运算符

前面我们说过函数名不应该包含标点符号。 严格来说并非如此,因为如果要编写运算符,则在函数名中使用标点符号。 运算符在作用于一个或多个操作数的表达式中使用。 一元运算符有一个操作数,二元运算符有两个操作数,运算符返回运算结果。 显然,这描述了一个函数:返回类型、名称和一个或多个参数。

C++ 提供关键字operator来指示函数没有与函数调用语法一起使用,而是使用与运算符相关的语法来调用(通常,一元运算符,第一个参数在运算符的右侧,而对于二元运算符,第一个参数在左边,第二个参数在右边,但也有例外)。

通常,您将提供运算符作为自定义类型的一部分(因此运算符作用于该类型的变量),但在某些情况下,您可以在全局范围内声明运算符。 两者都是有效的。 如果您正在编写自定义类型(类,如下一章所述),则将运算符的代码封装为自定义类型的一部分是有意义的。 在本节中,我们将重点介绍定义运算符的另一种方式:将其定义为全局函数。

您可以提供以下一元运算符的您自己的版本:

    ! & + - * ++ -- ~

您还可以提供以下二元运算符的您自己的版本:

    != == < <= > >= && ||
    % %= + += - -= * *= / /= & &= | |= ^ ^= << <<= = >> =>>
    -> ->* ,

您还可以编写函数调用运算符()、数组下标[]、转换运算符、强制转换运算符(),newdelete的版本。 不能重新定义..*::?:###运算符,也不能重新定义“命名”运算符sizeofalignoftypeid

定义运算符时,编写一个函数,其中函数名为operator*x*,并且*x*是运算符符号(请注意,没有空格)。 例如,如果定义的struct具有定义笛卡尔点的两个成员,则可能需要比较两个点是否相等。 可以这样定义struct

    struct point 
    { 
        int x; 
        int y; 
    };

比较两个point对象很容易。 如果一个对象的xy等于另一个对象中的相应值,则它们相同。 如果定义==运算符,则还应该使用相同的逻辑定义!=运算符,因为!=应该给出与==运算符完全相反的结果。 以下是这些运算符的定义方式:

    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

您可以定义一对名为equalsnot_equals的函数,并改为使用以下函数:

    cout << equals(p1,p2) << endl;     // true 
    cout << not_equals(p1,p2) << endl; // false

但是,定义运算符可以提高代码的可读性,因为您可以像使用内置类型一样使用该类型。 运算符重载通常被称为语法糖,这是一种使代码更容易阅读的语法--但这使一项重要的技术变得微不足道。 例如,智能指针是一种涉及类析构函数来管理资源生存期的技术,它之所以有用,是因为您可以像调用指针一样调用这些类的对象。 之所以可以这样做,是因为智能指针类实现了->*运算符。 另一个例子是Functors或 Function Objects,其中类实现了()运算符,因此可以像访问函数一样访问对象。

在编写自定义类型时,您应该问问自己,重载类型的运算符是否有意义。 如果类型是数值类型,例如复数或矩阵,那么实现算术运算符是有意义的,但是由于类型没有逻辑方面,实现逻辑运算符有意义吗? 很容易重新定义运算符的表示,以涵盖您的特定操作,但这会降低代码的可读性。

通常,一元运算符被实现为接受单个参数的全局函数。 后缀递增和递减运算符是一个例外,允许实现与前缀运算符不同的实现。 前缀操作符将引用对象作为参数(操作符将递增或递减),并返回对更改后的对象的引用。 但是,后缀运算符必须在递增或递减之前返回对象的值。 因此,操作符函数有两个参数:一个是对要更改的对象的引用,另一个是整数(值始终为 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>头文件包含可用作函数对象的各种类型。 下表列出了这些内容:

| 目的 | 类型 | | 算术 / 计算 / 可用数字表示的某种情况 | dividesminusmodulusmultipliesnegateplus | | 逐位,按位 | bit_andbit_notbit_orbit_xor | | 比较 / 对照 / 类比 / 比喻 | equal_togreatergreater_equallessless_equalsnot_equal_to | | 逻辑的 / 符合逻辑的 / 自然而然的 / 逻辑学的 | logical_andlogical_notlogical_or |

这些都是二元函数类,除了bit_notlogical_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中的每一项都是v1v2中相应项的模数。 在代码中,第一行创建具有五个值的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的大小相同,因此只需要v2resultbegin迭代器。

最后一个参数是函数对象。 这是一个临时对象,仅在此语句期间存在;它没有名称。 这里使用的语法是对类的构造函数的显式调用;它是模板化的,因此需要给出模板参数。 transform函数将对此函数对象调用operator(int,int)函数,将v1中的每个项目作为第一个参数,v2中的相应项目作为第二个参数,并将结果存储在result中的相应位置。

由于transform接受任何二元函数对象作为第二个参数,因此您可以传递plus<int>的实例以将值 2 加到v1中的每一项,或者传递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](int a) {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参数传入的范围内的每一项调用此表达式。 函数的作用是:记录 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 函数FindFirstFileFindNextFile来获取有关符合文件规范的文件的信息。 它们以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 作为函数模板参数,并使用 1 作为函数模板参数调用get来获得file_size对象。 代码调用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 表达式。 但是,全局函数有一个主要问题:数据与函数是分开的。 这意味着函数必须通过全局数据项访问数据,或者每次调用函数时都必须通过参数将数据传递给函数。 在这两种情况下,数据都存在于函数之外,并且可以由其他与数据无关的函数使用。 下一章将给出这个问题的解决方案:类。 class允许您将数据封装在自定义类型中,并且您可以在该类型上定义函数,以便只有这些函数才能访问数据。