C++-现代编程(二)

123 阅读1小时+

C++ 现代编程(二)

原文:annas-archive.org/md5/F02528C543403FA60BC7527E0C58459D

译者:飞龙

协议:CC BY-NC-SA 4.0

使用函数

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

定义 C++函数

在最基本的层面上,函数具有参数,具有操作参数的代码,并返回一个值。C++为您提供了几种确定这三个方面的方法。在接下来的部分中,我们将从声明的左侧到右侧涵盖 C++函数的这些部分。函数也可以是模板化的,但这将留到以后的部分。

声明和定义函数

函数必须被定义一次,但通过重载,您可以有许多具有相同名称但参数不同的函数。使用函数的代码必须能够访问函数的名称,因此它需要访问函数的定义(例如,函数在源文件中较早地定义)或函数的声明(也称为函数原型)。编译器使用原型来对调用代码进行类型检查,以确保调用代码调用函数时使用了正确的类型。

通常,库被实现为单独的编译库文件,并且库函数的原型在头文件中提供,以便许多源文件可以通过包含头文件来使用这些函数。但是,如果您知道函数名称、参数和返回类型,您可以在自己的文件中输入原型。

无论您做什么,您只是为编译器提供调用函数的表达式进行类型检查的信息。链接器负责在库中定位函数,并将代码复制到可执行文件中,或者设置基础设施以从共享库中使用函数。包含库的头文件并不意味着您将能够使用该库中的函数,因为在标准 C++中,头文件没有包含函数的库的信息。

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

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 
    }

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

如果定义了一个只有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函数,返回列表中的项目数,以及beginend函数,返回列表中第一个项目的指针和最后一个项目后面的位置。这两个函数需要为列表提供迭代器访问,并且使您能够使用范围-for语法与对象一起使用。

这在 C++标准库中很典型。如果一个容器在内存中以连续的内存块保存数据,那么指针算术可以使用指向第一项的指针和指向最后一项后面的指针来确定容器中有多少项。递增第一个指针可以顺序访问每一项,指针算术允许随机访问。所有容器都实现了beginend函数,以便访问容器的迭代器

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

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

默认情况下,C++全局函数将使用__cdecl调用约定。堆栈由调用代码维护,因此在调用代码中,对__cdecl函数的每次调用都会跟随清理堆栈的代码。这使得每个函数调用都会变得稍微大一些,但这对于使用可变参数列表是必要的。大多数 Windows SDK 函数使用__stdcall调用约定,它表示被调用的函数清理堆栈,因此在调用代码中不需要生成这样的代码。显然,编译器知道函数使用__stdcall是很重要的,否则它将生成代码来清理已经被函数清理的堆栈帧。通常会看到带有WINAPI标记的 Windows 函数,这是__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类型),如果失败,编译器将尝试提升类型(例如,boolint)。如果失败,编译器将尝试标准转换(例如,引用到类型)。如果这样的转换导致有多个可能的候选项,那么编译器将发出函数调用模糊的错误。

函数和作用域

编译器在寻找合适的函数时也会考虑函数的作用域。您不能在一个函数内定义另一个函数,但是您可以在函数的作用域内提供一个函数原型,编译器将尝试(如果需要通过转换)首先调用具有这种原型的函数。考虑以下代码:

    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 
    }

现在,当代码尝试使用chardouble(或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_backvalues容器中插入一个值时,实际上是将该值插入到items容器中。

如果通过指针传递输出参数,则重要的是查看指针声明。一个*表示变量是一个指针,两个表示它是一个指向指针的指针。以下函数通过输出参数返回一个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;}

这种返回一个表示成功的值的模式经常被使用,特别是在访问跨进程或机器边界的数据的代码中。函数返回值可以用来提供关于调用失败的详细信息(没有网络访问?无效的安全凭证?等等),并且表示输出参数中的数据应该被丢弃。

如果输出参数有一个双*,那么这意味着返回值本身是一个指针,要么指向单个值,要么指向一个数组:

    bool get_data(/*in/out*/ int *psize, /*out*/ int **pi);

在这种情况下,您使用第一个参数传入要使用的缓冲区的大小,并在返回时通过该参数接收缓冲区的实际大小(它是输入/输出),以及第二个参数中的缓冲区的指针:

    int size = 10; 
    int *buffer = nullptr; 
    if (get_data(size, &buffer)) 
    { 
        for (int i = 0; i < size; ++i) 
        { 
            cout << buffer[i] << endl; 
        } 
        //delete [] buffer; 
    }

任何返回内存缓冲区的函数都必须记录谁有责任释放内存。在大多数情况下,通常是调用者,就像这个例子代码中假设的那样。

设计函数

通常函数会对全局数据或调用者传入的数据进行操作。重要的是,当函数完成时,它会使这些数据处于一致的状态。同样重要的是,函数在访问数据之前可以对数据做出假设。

前置条件和后置条件

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

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

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

您还应该记录函数的后置条件。也就是说,有关函数返回的数据的假设(通过函数返回值、输出参数或引用传递的参数)。后置条件是调用代码将做出的假设。例如,您可能返回一个有符号整数,而函数应返回一个正值,但使用负值表示错误。通常,返回指针的函数将在函数失败时返回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的函数指针参数。该函数有一个循环,将被调用多次,每一百次循环它都会调用回调函数,传递一个提供有关进度信息的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; 
    }

您可以使用其他整数类型调用此函数,并且较小的类型(shortcharbool等)将被提升为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将是值-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>

重要的是要意识到,当编译器第一次遇到模板函数时,它将为指定的类型创建函数的版本。如果你为几种不同类型调用模板函数,编译器将为每种类型创建一个专门化函数。

这个模板的定义表明只会使用一个类型,所以你只能用相同类型的两个参数来调用它:

    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参数并返回intmaximum版本,因此返回值是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,因此编译器可以从函数调用中推断出第二个模板参数,但无法推断出第一个参数,因此您必须在调用中提供一个值。以下是调用此模板函数的示例,Tintsize10的值:

    int *i10 = init<10>(42); 
    for (int i = 0; i < 10; ++i) cout << i10[i] << ' '; 
    cout << endl; 
    delete [] i10;

第一行使用10作为模板参数,42作为函数参数调用函数。由于42是一个intinit函数将创建一个具有十个成员的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);

如前所述,编译器将为代码调用的每个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); 
    }

这对大多数内置类型都适用,但如果你用指针调用它,你将得到指针的大小,而不是指针指向的内容。因此,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,以指示该函数不使用函数调用语法,而是使用与运算符相关的语法调用(通常,一元运算符的第一个参数在运算符的右侧,而二元运算符的第一个参数在左侧,第二个参数在右侧,但也有例外)。

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

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

    ! & + - * ++ -- ~

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

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

您还可以编写函数调用运算符()、数组下标[]、转换运算符、强制转换运算符()以及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

然而,定义运算符使代码更易读,因为您可以像使用内置类型一样使用该类型。运算符重载通常被称为语法糖,使代码更易于阅读-但这淡化了一个重要的技术。例如,智能指针是一种涉及类析构函数以管理资源生命周期的技术,仅因为您可以调用此类对象,就好像它们是指针一样。您可以这样做,因为智能指针类实现了->*运算符。另一个例子是函数对象,其中类实现了()运算符,以便可以访问对象,就好像它们是函数一样。

当您编写自定义类型时,应该问自己是否重载类型的运算符是有意义的。例如,如果类型是数值类型,例如复数或矩阵-那么实现算术运算符是有意义的,但是实现逻辑运算符是否有意义,因为该类型没有逻辑方面?有一种诱惑,即重新定义运算符的含义以涵盖您的特定操作,但这将使您的代码不太可读。

一般来说,一元运算符被实现为接受单个参数的全局函数。后缀递增和递减运算符是一个例外,允许与前缀运算符不同的实现。前缀运算符将具有对对象的引用作为参数(该运算符将递增或递减),并返回对此更改后的对象的引用。然而,后缀运算符必须返回递增或递减之前的对象的值。因此,运算符函数有两个参数:将被更改的对象的引用和一个整数(始终是 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_notnegate之外,它们都是一元的。二元函数对象作用于两个值并返回一个结果,一元函数对象作用于单个值并返回一个结果。例如,您可以使用以下代码计算两个数字的模数:

    modulus<int> fn; 
    cout << fn(10, 2) << endl;

这声明了一个名为fn的函数对象,它将执行模数。该对象在第二行中使用,该行调用对象上的operator()函数,并带有两个参数,因此以下行等同于前一行:

    cout << fn.operator()(10, 2) << endl;

结果是在控制台上打印了值0operator()函数仅对两个参数执行模数,在本例中为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具有相同的容量。通过调用fill函数填充了这个第二个vector。第一个参数是vector中第一个项目的地址,end函数返回vector中最后一个项目之后的地址。函数调用的最后一个项目是将在从第一个参数指向的项目开始到但不包括第二个参数指向的项目的每个项目中放置在vector中的值。

此时,第二个vector将包含五个项目,每个项目都将是2。接下来,为结果创建一个vector;再次,它与第一个数组的大小相同。最后,通过transform函数执行计算,再次显示如下:

    transform(v1.begin(), v1.end(),  
       v2.begin(), result.begin(), modulus<int>());

前两个参数给出了第一个vector的迭代器,从中可以计算出项目的数量。由于所有三个vector的大小相同,因此只需要v2resultbegin迭代器。

最后一个参数是函数对象。这是一个临时对象,仅在此语句期间存在;它没有名称。此处使用的语法是对类的构造函数的显式调用;它是模板化的,因此您需要提供模板参数。transform函数将对v1中的每个项目调用此函数对象的operator(int,int)函数作为第一个参数,并将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 {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 函数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 作为函数模板参数,要获取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>头文件中的排序函数,所以在<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++中使用的类类型之一;另外两个是unionclass。定义为structclass的自定义类型也可以具有行为和状态,C++允许您定义一些特殊函数来控制实例的创建和销毁、复制和转换。此外,您可以在structclass类型上定义运算符,以便您可以在实例上使用运算符,类似于在内置类型上使用运算符。structclass之间存在差异,我们将在后面讨论,但总的来说,本章的其余部分将涉及类,当提到class时,通常可以假定相同的内容也适用于struct

定义类

在一个语句中定义了一个类,并且将在由大括号{}括起来的多个语句块中定义其成员。由于这是一个语句,所以必须在最后一个大括号后面加上分号。一个类可以在头文件中定义(就像许多C++标准库类一样),但是你必须采取措施确保这样的文件只在源文件中包含一次。然而,关于类中必须在源文件中定义的特定项目有一些规则,这将在后面介绍。

如果你浏览 C++标准库,你会发现类包含成员函数,并且试图将类的所有代码放入单个头文件中,这使得代码难以阅读和理解。这可能是有道理的,对于由大量专业 C++程序员维护的库文件,但对于你自己的项目来说,可读性应该是一个关键的设计目标。因此,C++类可以在 C++头文件中声明,包括其成员函数,并且函数的实际实现可以放在源文件中。这使得头文件更容易维护和更可重用。

定义类的行为

一个类可以定义只能通过类的实例调用的函数;这样的函数通常被称为方法。一个对象将有状态;这是由类定义的数据成员提供的,并且在创建对象时初始化。对象上的方法定义了对象的行为,通常作用于对象的状态。当你设计一个类时,你应该这样考虑方法:它们描述了对象在做某事。

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        // other methods 
        double get_magnitude() { return std::sqrt((x * x) + (y * y)); } 
    };

这个类有两个数据成员,xy,它们表示笛卡尔 x 和 y 方向上的二维向量的方向。public关键字意味着在此指定符号之后定义的任何成员都可以被类外定义的代码访问。默认情况下,类的所有成员都是private的,除非你另有说明。private意味着该成员只能被类的其他成员访问。

这是structclass之间的区别:默认情况下,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返回的值。

使用这个指针

类中的方法有一种特殊的调用约定,在 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指针的情况下访问类的成员,但这确实明确了这些项是类的成员。

你可以在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的数据成员是publicvec对象在堆栈上创建并使用初始化列表进行初始化。在第二行,对象在自由存储中创建并使用初始化列表进行初始化。自由存储中的对象必须在某个时候被释放,这是通过删除指针来完成的。new运算符将为类的数据成员和类所需的任何基础设施在自由存储中分配足够的内存。

C++11 的一个新特性是允许直接初始化以在类中提供默认值:

    class point 
    { 
    public: 
        int x = 0; 
        int y = 0; 
    };

这意味着如果您创建一个point实例而没有任何其他初始化值,它将被初始化,以便xy都为零。如果数据成员是内置数组,则可以在类中使用初始化列表进行直接初始化:

    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) {}

括号外的标识符是类成员的名称,括号内的项目是用来初始化该成员的表达式(在这种情况下是构造函数参数)。这个例子使用xy作为参数名。你不必这样做;这里只是给出一个例子,说明编译器将区分参数和数据成员。你也可以在构造函数的成员列表中使用大括号初始化语法:

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

此函数访问pointprivate成员,因此您必须将该函数声明为point类的friend

    friend ostream& operator<<(ostream&, const point&);

这样的friend声明必须在point类中声明,但放在publicprivate部分都无关紧要。

将构造函数标记为显式

在某些情况下,您不希望允许将一个类型隐式转换为另一个类型的构造函数的参数。为此,您需要使用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 通过移动构造函数和移动赋值运算符提供了移动语义,当临时对象被用来创建另一个对象或被赋值给现有对象时,这些函数将被调用。在这两种情况下,因为临时对象不会在语句之后存在,临时对象的内容可以被移动到另一个对象,使得临时对象处于无效状态。编译器将通过默认操作从临时对象中移动数据到新创建的(或被赋值的)对象中创建这些函数。

你可以编写自己的版本,并且为了指示移动语义,这些版本有一个 rvalue 引用(&&)作为参数。

如果你希望编译器为你提供这些方法的默认版本,你可以在类声明中提供带有=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指针。

静态方法是类的命名空间的一部分,因此它可以为类创建对象并访问它们的private成员。静态方法默认具有__cdecl调用约定,但如果需要,可以声明为__stdcall。这意味着,可以编写一个在类中用于初始化许多库使用的 C 样式指针的方法。请注意,静态函数不能调用类的非静态方法,因为非静态方法需要this指针,但非静态方法可以调用静态方法。

非静态方法通过对象调用,可以使用点运算符(对于类实例)或->运算符(对于对象指针)。静态方法不需要关联对象,但可以通过对象调用。

这提供了两种调用静态方法的方式,通过对象或通过class名称:

    class mytype 
    { 
    public: 
        static void f(){} 
        void g(){ f(); } 
    };

在这里,类定义了一个名为f的静态方法和一个名为g的非静态方法。非静态方法g可以调用静态方法,但静态方法f不能调用非静态方法。由于静态方法fpublic的,类外部的代码可以调用它。

    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数据成员:

    class mytype 
    { 
    public: 
        static int i; 
        static void incr() { i++; } 
    }; 

    // in a source file 
    int mytype::i = 42;

数据成员在类外部的文件作用域中定义。它使用class名称命名,但请注意,它也必须使用类型进行定义。在这种情况下,数据成员使用一个值进行初始化;如果不这样做,那么在首次使用变量时,它将具有类型的默认值(在这种情况下为零)。如果选择在头文件中声明类(这是常见的做法),则static数据成员的定义必须在源文件中。

还可以在方法中声明一个static变量。在这种情况下,该值在所有对象的方法调用之间保持不变,因此它具有与static class成员相同的效果,但不需要在类外部定义变量的问题。

使用静态和全局对象

全局函数中的静态变量将在该函数首次调用之前的某个时刻创建。同样,作为类成员的静态对象将在首次访问之前的某个时刻初始化。

静态和全局对象在调用main函数之前构造,并在main函数结束后销毁。这种初始化的顺序存在一些问题。C++标准规定,源文件中定义的静态和全局对象的初始化将在使用该源文件中的任何函数或对象之前发生,如果一个源文件中有多个全局对象,则它们将按照它们被定义的顺序进行初始化。问题在于如果有几个源文件中都有静态对象。在这种情况下,无法保证这些对象的初始化顺序。如果一个静态对象依赖于另一个静态对象,那么就会出现问题,因为无法保证依赖对象在所依赖的对象之后创建。

命名构造函数

这是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指针是constconst关键字是函数原型的一部分,因此该方法可以在此上进行重载。您可以有一个方法,当它在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前缀一元operator()operator(a)
a, b二元operator(b)operator(a,b)
a+/a-后缀一元operator(0)operator(a,0)
a=b赋值operator=(b)
a(b)函数调用operator()(b)
a[b]索引operator
a->指针访问operator->()

这里的■符号用于表示除表中提到的四个运算符之外的任何可接受的一元或二元运算符。

对于运算符应该返回什么并没有严格的规定,但是如果自定义类型的运算符的行为类似于内置类型的运算符,那么会有所帮助。还必须有一些一致性。如果你实现了+运算符来将两个对象相加,那么+=运算符应该使用相同的加法操作。同样,你可以说加法操作也将决定减法操作应该是什么样的,因此--=运算符。同样,如果你想定义<运算符,那么你应该也定义<=>>===!=

标准库的算法(例如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_functionbinary_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.0vector中的每个项目进行比较,并且函数将计数2.0大于项目的次数。

bind函数和占位符是 C++11 中的新功能。在之前的版本中,你可以使用bind1stbind2nd函数来将一个值绑定到函数对象的第一个或第二个参数上。

定义转换运算符

我们已经看到,如果你的自定义类型有一个接受你要转换的类型的构造函数,那么构造函数可以用来将另一种类型转换为你的自定义类型。你也可以进行另一种方向的转换:将对象转换为另一种类型。为此,你提供一个没有返回类型的操作符,其名称为要转换为的类型。在这种情况下,你需要在operator关键字和名称之间加一个空格。

    class mytype 
    { 
        int i; 
    public: 
        mytype(int i) : i(i) {} 
        explicit mytype(string s) : i(s.size()) {} 
        operator int () const { return i; } 
    };

这段代码可以将intstring转换为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释放内存。未能释放内存将导致内存泄漏。内存可能是最基本的系统资源,但大多数操作系统还有许多其他资源:文件句柄、图形对象句柄、同步对象、线程和进程。有时,对这种资源的拥有是独占的,并且会阻止其他代码访问通过该资源访问的资源。因此,重要的是在某个时刻释放这些资源,并且通常及时释放这些资源。

类在这里有所帮助,使用了 Bjarne Stroustrup 发明的一种称为资源获取即初始化(RAII)的机制,他是 C++的作者。简单地说,资源在对象的构造函数中分配,并在析构函数中释放,这意味着资源的生命周期与对象的生命周期相同。通常,这种包装对象是在堆栈上分配的,这意味着无论发生什么情况,当对象超出范围时,资源都将被释放。

因此,如果对象在循环语句(whilefor)的代码块中声明,那么在每次循环结束时,将调用每个对象的析构函数(按创建顺序的相反顺序),并且当循环重复时,对象将再次创建。无论循环是因为已经到达代码块的末尾而重复,还是通过调用continue而重复。离开代码块的另一种方法是通过调用breakgoto,或者如果代码调用return来离开函数。如果代码引发异常(参见第七章,诊断和调试),则在对象超出范围时将调用析构函数,因此如果代码受try块保护,则将在调用catch子句之前调用块中声明的对象的析构函数。如果没有保护块,则在函数堆栈被销毁并传播异常之前将调用析构函数。

编写包装类

在编写包装资源的类时,您必须解决几个问题。构造函数将被用于获取资源,要么使用某种库函数(通常通过某种不透明句柄访问)获取资源,要么将资源作为参数传递。这个资源将作为数据成员存储,以便类上的其他方法可以使用它。析构函数将使用库提供的函数释放资源。这是最低限度。此外,您还需要考虑对象将如何使用。

通常,如果您可以将实例用作资源句柄,这样的包装类会更加方便。这意味着您可以保持相同的编程风格来访问资源,但您不必太担心释放资源。

您应该考虑是否希望能够在包装类和资源句柄之间进行转换。如果允许这样做,这意味着您可能需要考虑克隆资源,以便您不会有两个句柄的副本--一个由类管理,另一个副本可能会被外部代码释放。您还需要考虑是否允许对象被复制或分配,如果允许,那么您将需要适当地实现复制构造函数、移动构造函数以及复制和移动赋值运算符。

使用智能指针

C++标准库提供了几个类来包装通过指针访问的资源。为了防止内存泄漏,您必须确保在某个时候释放在自由存储器上分配的内存。智能指针的想法是您将实例视为指针,因此您使用*运算符进行解引用以访问它指向的对象,或者使用->运算符访问包装对象的成员。智能指针类将管理其包装的指针的生命周期,并将适当释放资源。

标准库有三个智能指针类:unique_ptrshared_ptrweak_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_ptrmake_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)的值。几乎总是比使用unique_ptr来管理对象数组更好使用vectorarray

早期版本的 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以防止其进一步使用)。在此之后,sp1sp2可以用于访问相同资源。在此代码结束时,一个共享指针被重置为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没有太大意义;有更好的方法来存储对象的集合(vectorarray)。无论如何,有一个索引运算符([]),默认删除器调用delete,而不是delete[]

处理悬空指针

在本书的前面,我们指出,当您删除资源时,应将指针设置为nullptr,并且在使用指针之前应检查指针是否为nullptr。这样可以避免调用已被删除的对象的内存指针:悬空指针。

有时悬空指针可能是有意设计的。例如,对象可能创建具有反向指针到父对象的对象,以便子对象可以访问父对象。(一个例子是窗口创建子控件;子控件通常可以访问父窗口是很有用的。)在这种情况下使用共享指针的问题在于父对象将对每个子控件有一个引用计数,而每个子控件对父对象也有一个引用计数,这会创建一个循环依赖。

另一个例子是,如果您有一个观察者对象的容器,并且希望能够在事件发生时通过调用每个观察者对象的方法来通知每个观察者对象。维护此列表可能会很复杂,特别是如果观察者对象可以被删除,因此您必须提供一种从容器中删除对象的方法(其中将存在shared_ptr引用计数),然后才能完全删除对象。如果您的代码可以简单地以一种不维护引用计数但允许您在使用指针时检查指针是否悬空或指向现有对象的方式将对象指针添加到容器中,那将变得更容易。

这样的指针被称为弱指针,C++11 标准库提供了一个名为weak_ptr的类。您不能直接使用weak_ptr对象,也没有解引用运算符。

相反,您可以从shared_ptr对象创建一个weak_ptr对象,当您想要访问资源时,您可以从weak_ptr对象创建一个shared_ptr对象。这意味着weak_ptr对象具有相同的原始指针,并且访问相同的控制块作为shared_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

我们希望这个类的对象拥有句柄的独占权,因此通过将以下内容放入类的公共部分来删除复制构造函数和复制赋值:

    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。如果调用成功,则输出参数(指向string的引用)将使用_finddata_t结构中的 C 字符串指针进行初始化。

如果有更多与模式匹配的文件,那么可以重复调用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_handleprivate部分,在handle数据成员的声明之上。编译并运行代码。

总结

使用类,C++提供了一个强大而灵活的机制来封装数据和方法,以提供对数据进行操作的行为。您可以将此代码模板化,以便编写通用代码并让编译器为您需要的类型生成代码。在本例中,您已经看到类是面向对象的基础。类封装数据,使得调用者只需要了解预期的行为(在本例中是获取搜索中的下一个结果),而无需了解类如何实现这一点的细节。