C++ Annotations Version 12.5.0 学习(1)

C++’s history

C++ 的首次实现是在 1980 年代由 AT&T 贝尔实验室开发的,当时 Unix 操作系统也在这里创建。C++ 最初是一个“预编译器”,类似于 C 的预处理器,将源代码中的特殊结构转换为普通的 C 代码。当时,这些代码由标准 C 编译器编译。C++ 预编译器读取的“预代码”通常保存在扩展名为 .cc、.C 或 .cpp 的文件中。这些文件会被转换为扩展名为 .c 的 C 源文件,然后进行编译和链接。
C++ 的首次实现是在 1980 年代由 AT&T 贝尔实验室开发的,当时 Unix 操作系统也在这里创建。C++ 最初是一个“预编译器”,类似于 C 的预处理器,将源代码中的特殊结构转换为普通的 C 代码。当时,这些代码由标准 C 编译器编译。C++ 预编译器读取的“预代码”通常保存在扩展名为 .cc、.C 或 .cpp 的文件中。这些文件会被转换为扩展名为 .c 的 C 源文件,然后进行编译和链接。
C++ 曾经被编译成 C 代码的事实也体现在 C++ 是 C 的超集:C++ 提供了完整的 C 语法并支持所有 C 库函数,同时增加了自己的特性。这使得从 C 过渡到 C++ 相对容易。熟悉 C 的程序员可以通过使用扩展名为 .cc 或 .cpp 的源文件来开始“用 C++ 编程”,然后舒适地适应 C++ 提供的所有功能,无需突然改变习惯。

编译 C 程序使用 C++ 编译器

未来的 C++ 程序员应认识到 C++ 并不是 C 的完美超集。在将文件扩展名重命名为 .cc 并通过 C++ 编译器编译时,可能会遇到一些差异:

  • 在 C 中,sizeof('c') 等于 sizeof(int),其中 'c' 是任何 ASCII 字符。其基本哲学可能是字符在传递给函数作为参数时,无论如何都会被作为整数传递。此外,C 编译器将字符常量如 'c' 视为整数常量。因此,在 C 中,函数调用

    putchar(10);
    

    putchar('\n');
    

    是等效的。

    相比之下,在 C++ 中,sizeof('c') 始终是 1(但见 3.4.2 节)。不过,int 仍然是 int。正如我们稍后会看到的(2.5.4 节),这两个函数调用

    somefunc(10);
    

    somefunc('\n');
    

    可能会被不同的函数处理:C++ 不仅通过函数名,还通过参数类型来区分函数,这两种调用的参数类型不同。前者使用 int 参数,后者使用 char 参数。

  • C++ 对外部函数的原型要求非常严格。例如,在 C 中,一个像

    void func();
    

    的原型意味着存在一个函数 func(),返回值为空。这个声明没有指定函数接受哪些参数(如果有的话)。

    然而,在 C++ 中,上述声明意味着函数 func() 完全不接受任何参数。传递给它的任何参数都会导致编译时错误。请注意,声明函数时不需要 extern 关键字。函数定义通过将函数体替换为分号就变成了函数声明。然而,声明变量时需要 extern 关键字。

在 MS-Windows 下的 C++

对于 MS-Windows,Cygwin(http://cygwin.com)或 MinGW(http://mingw-w64.org/doku.php)提供了安装 Windows 版本 GNU g++ 编译器的基础(另见 https://docs.microsoft.com/en-us/windows/wsl/about)。

GNU g++ 编译器的官方网站是 http://gcc.gnu.org,网站上也包含有关如何在 MS-Windows 系统中安装编译器的信息。

编译 C++ 源代码

通常可以使用以下命令来编译 C++ 源文件 source.cc

g++ source.cc

这将生成一个二进制程序(a.outa.exe)。如果默认名称不合适,可以使用 -o 标志指定可执行文件的名称(这里生成名为 source 的程序):

g++ -o source source.cc

如果仅需要编译而不进行链接,可以使用 -c 标志:

g++ -c source.cc

这将生成文件 source.o,该文件可以在后续与其他模块进行链接。

C++ 程序很快会变得过于复杂,无法手动维护。对于所有严肃的编程项目,通常会使用程序维护工具。通常使用标准的 make 程序来维护 C++ 程序,但也存在一些很好的替代工具,如 icmakeccbuild 程序维护工具。

强烈建议在学习 C++ 的早期就开始使用维护工具。

C++ 的优点和主张

经常有人说,使用 C++ 编程会导致“更好的”程序。C++ 的一些声称的优点包括:

  • 新程序的开发时间会更短,因为可以重用旧代码。
  • 创建和使用新数据类型会比在 C 中更容易。
  • C++ 下的内存管理会更简单和透明。
  • 程序的错误发生率较低,因为 C++ 使用更严格的语法和类型检查。
  • “数据隐藏”,即一个程序部分使用数据而其他程序部分不能访问这些数据,会更容易在 C++ 中实现。

这些主张中哪些是正确的呢?最初,我们的印象是 C++ 语言被有些高估了;整个面向对象编程(OOP)方法也一样。对 C++ 语言的热情类似于曾经对人工智能(AI)语言如 Lisp 和 Prolog 的主张:这些语言被认为几乎可以“毫不费力地”解决最困难的 AI 问题。新的语言常常被过度销售:最终,每个问题都可以用任何编程语言(比如 BASIC 或汇编语言)来编码。编程语言的优缺点不在于“你可以用它们做什么”,而在于“语言提供了哪些工具来实现一个高效且易于理解的解决方案”。这些工具通常以语法限制的形式出现,强制或促进某些构造,或者通过应用或“拥抱”这些语法形式来暗示意图。我们不再使用一长串的汇编指令,而是使用流程控制语句、函数、对象,甚至(在 C++ 中)所谓的模板来结构化和组织代码,并用自己选择的语言“优雅地”表达自己。

关于上述 C++ 的主张,我们支持以下几点:

  • 使用现有代码来开发新程序的能力也可以通过 C 来实现,例如使用函数库。函数可以被收集到库中,无需在每个新程序中重新发明。C++ 提供了除了函数库之外的特定语法来进行代码重用(详见第 13 和第 21 章)。
  • 在 C 中,创建和使用新数据类型是可能的;例如,通过使用 structstypedefs 等。从这些类型中可以派生出其他类型,从而导致嵌套的 structs。在 C++ 中,这些功能得到了扩展,可以定义完全“自我支持”的数据类型,例如自动处理内存管理(无需像 Java 中那样使用独立的内存管理系统)。
  • 在 C++ 中,内存管理原则上可以像 C 中一样简单或复杂。特别是当使用如 xmallocxrealloc 这样的专用 C 函数时(在内存池耗尽时分配内存或中止程序)。然而,使用 malloc 时容易出错。C 程序中的许多错误可以追溯到使用 malloc 时的计算错误。相比之下,C++ 提供了更安全的内存分配方法,使用 new 操作符。
  • 关于“错误发生率”,C++ 确实使用比 C 更严格的类型检查。然而,大多数现代 C 编译器实现了“警告级别”;此时,程序员可以选择忽略或消除这些警告。在 C++ 中,许多这样的警告会变成致命错误(编译停止)。
  • 在数据隐藏方面,C 确实提供了一些工具。例如,在可能的情况下,可以使用局部或静态变量,并通过专用函数操作特定的数据类型如 structs。即使在 C 中也可以实现数据隐藏,尽管必须承认,C++ 提供了特殊的语法结构,使得在 C++ 中实现“数据隐藏”(更普遍地说:“封装”)比在 C 中要容易得多。

C++(尤其是面向对象编程)显然不是所有编程问题的解决方案。然而,这种语言确实提供了各种新颖而优雅的功能,值得探索。另一方面,与 C 相比,C++ 的语法复杂性显著增加。这可能被认为是语言的一个严重缺点。虽然我们逐渐习惯了这种复杂性,但过渡并不快速也不轻松。

通过《C++ 注解》,我们希望帮助读者在从 C 过渡到 C++ 时,专注于 C++ 相对于 C 的新增功能,而省略 C 的内容。我们希望您喜欢这份文档,并能从中受益。

祝您在学习 C++ 的旅程中一切顺利!

什么是面向对象编程?

面向对象(和基于对象)编程提供了一种不同于通常在 C 程序中使用的编程方法。在 C 编程中,通常使用“过程化方法”来解决编程问题:将问题分解成子问题,然后重复这一过程,直到子任务可以被编码。这种方法产生了一组函数,这些函数通过参数和变量进行通信,参数和变量可以是全局的、本地的或静态的。

与此不同(或者说,更确切地说,作为补充),基于对象的方法会识别问题陈述中的关键字。这些关键字随后被描绘在一个图中,图中的箭头用于表示这些关键字之间的内部层次结构。这些关键字成为实现中的对象,而层次结构定义了这些对象之间的关系。在这里,术语“对象”用来描述一个有限且定义明确的结构,包含关于实体的所有信息:数据类型以及操作数据的函数。以下是面向对象方法的一个示例:

汽车销售和修理公司中的员工和老板的薪酬方式如下:
在这里插入图片描述

首先,车库中的技工每个月领取固定的薪水。其次,公司的老板每个月领取固定的薪水。第三,销售汽车的销售员在展厅工作,每个月领取固定薪水,并根据销售的汽车数量获得奖金。最后,公司雇佣的二手车采购员在外地出差,他们除了领取每月的薪水外,还会根据购买的汽车数量获得奖金,并报销差旅费用。

在表示上述薪酬管理时,关键字可以是技工、老板、销售员和采购员。这些单位的属性包括:每月薪水,有时还有按购买或销售数量计算的奖金,以及有时还有差旅费用的报销。分析这个问题后,我们得到如下表示:

  • 老板和技工可以用相同类型来表示,他们每月领取固定薪水。对于这种类型来说,相关信息包括月薪。此外,这个对象还可以包含姓名、地址和社会安全号码等数据。
  • 在展厅工作的汽车销售员可以被表示为与上述相同类型,但具有一些额外功能:交易次数(销售数量)和每次交易的奖金。在对象层次结构中,我们通过让汽车销售员从老板和技工派生来定义这两者之间的依赖关系。
  • 最后是二手车采购员,他们共享销售员的功能,但需要处理差旅费用。因此,这种类型会增加支出功能,并且从销售员类型派生而来。

这些识别对象的层次结构在图2.1中进一步说明。

定义这种层次结构的整体过程从描述最简单的类型开始。传统上(并且在一些流行的面向对象语言中仍然存在),更复杂的类型从基本类型派生,每个派生的类型增加一些新的功能。从这些派生类型中,可以再派生出更复杂的类型,直到可以表示整个问题为止。

随着时间的推移,这种方法在C++中变得不那么流行,因为它通常导致类型之间的紧密耦合,这反而降低了对复杂程序的理解、维护性和可测试性。耦合的术语指的是软件组件之间的独立性程度:紧密耦合意味着强依赖关系,这在C++中是不受欢迎的。在C++中,面向对象程序越来越倾向于小而易于理解的层次结构,有限的耦合,以及一个以设计模式(参见Gamma等人(1995))为中心的开发过程。

在C++中,类经常用于定义对象的特征。类包含执行有用操作所需的功能。类通常不向其他类的对象提供它们的全部功能(通常没有任何数据)。正如我们将看到的,类倾向于以隐藏其属性的方式进行操作,使得外界不能直接修改这些属性。相反,使用专门的函数来访问或修改对象的属性。因此,类类型的对象能够保持自身的完整性。核心概念是封装,其中数据隐藏只是一个例子。这些概念将在第7章中进一步详细讨论。

C 和 C++ 的区别

在这一部分,将展示一些 C++ 代码示例,并强调 C 和 C++ 之间的一些区别。

main 函数

在 C++ 中,main 函数只有两种变体:

  • int main()
  • int main(int argc, char **argv)

注意事项:

  • main 函数的返回类型是 int,而不是 void
  • main 函数不能被重载(除上述两种签名外)。
  • main 函数末尾不一定需要显式的 return 语句。如果省略,main 将返回 0。
  • argv[argc] 的值等于 0。
  • 第三个 char **envp 参数 在 C++ 标准中并未定义,应避免使用。相反,应该声明全局变量 extern char **environ 来访问程序的环境变量,其最终元素的值为 0。
  • C++ 程序在 main 函数返回时正常结束。使用函数 try 块(参见第 10.11 节)也是 C++ 程序正常结束的一种方式。当 C++ 程序正常结束时,全局定义的对象的析构函数(参见第 9.2 节)会被激活。像 exit(3) 这样的函数通常不会使 C++ 程序正常结束,因此使用这些函数是不推荐的。

行尾注释

根据 ANSI/ISO 标准,C++ 中实现了“行尾注释”语法。这种注释以 // 开头,并在行尾结束。标准 C 注释,由 /**/ 定义,仍然可以在 C++ 中使用:

int main()
{
    // 这是行尾注释
    // 每行一个注释

    /*
     * 这是标准 C 注释,覆盖多行
     */
}

尽管如此,建议不要在 C++ 函数体内使用 C 类型的注释。有时候,现有代码可能需要暂时被抑制,例如为了测试目的。在这种情况下,能够使用标准 C 注释非常实用。然而,如果被抑制的代码中本身包含注释,就会导致嵌套注释行,从而导致编译器错误。因此,通常的做法是在 C++ 函数体内避免使用 C 类型的注释(当然,也可以使用 #if 0#endif 预处理指令对)。

严格的类型检查

C++ 使用非常严格的类型检查。每个函数在被调用之前必须知道其原型,并且调用必须与原型匹配。程序

int main()
{
    printf("Hello World\n");
}

在 C 语言中,通常能够编译成功,尽管会警告 printf() 是一个未知函数。但在 C++ 中,编译器应该会因这种情况而无法生成代码。错误的原因当然是缺少了 #include <stdio.h>(在 C++ 中更常用的是 #include <cstdio> 指令)。

顺便提一下:在 C++ 中,main 函数总是使用 int 返回值。虽然可以定义 int main() 而不显式定义返回语句,但在 main 中,使用没有显式 int 表达式的返回语句是不可能的。例如:

int main() // 编译失败:期望一个 int 表达式,例如
{
    return; // return 1;
}

void * 到非 void 指针的隐式转换是不允许的。例如,以下代码在 C++ 中是不被接受的:

void *none()
{
    return 0;
}

int main()
{
    int *empty = none();
}

函数重载

在 C++ 中,可以定义具有相同名称但执行不同操作的函数。这些函数必须在参数列表(和/或常量属性)上有所不同。以下是一个示例:

#include <stdio.h>

void show(int val)
{
    printf("Integer: %d\n", val);
}

void show(double val)
{
    printf("Double: %lf\n", val);
}

void show(char const *val)
{
    printf("String: %s\n", val);
}

int main()
{
    show(12);
    show(3.1415);
    show("Hello World!\n");
}

在上述程序中,定义了三个 show 函数,它们仅在参数列表(分别期望一个 intdoublechar *)上有所不同。这些函数具有相同的名称。具有相同名称但不同参数列表的函数称为重载函数。定义这些函数的行为称为“函数重载”。

C++ 编译器以相当简单的方式实现函数重载。尽管函数共享相同的名称(在此例中为 show),编译器(及链接器)使用的是完全不同的名称。这种从源文件中的名称到内部使用名称的转换被称为“名称修饰”。例如,C++ 编译器可能将原型 void show(int) 转换为内部名称 VshowI,而类似的具有 char * 参数的函数可能会被称为 VshowCP。实际使用的内部名称依赖于编译器,对于程序员来说并不重要,除非这些名称出现在库内容的列表中。

关于函数重载的一些附加说明:

  • 不要将函数重载用于执行概念上不同任务的函数。在上述示例中,show 函数仍然有一定的关联(它们将信息打印到屏幕上)。然而,也可以定义两个 lookup 函数,一个用于在列表中查找名称,另一个用于确定视频模式。在这种情况下,这两个函数的行为没有任何共同之处。因此,使用更能提示其操作的名称会更实际,例如 findNamevideoMode

  • C++ 不允许仅在返回值上有所不同的函数同名,因为程序员可以选择使用或忽略函数的返回值。例如,片段 printf("Hello World!\n"); 并未提供有关 printf 函数返回值的信息。因此,仅在返回类型上有所不同的两个 printf 函数将无法被编译器区分。

  • 在第 7 章中介绍了 const 成员函数的概念(参见第 7.7 节)。这里仅提到类通常具有所谓的成员函数(例如,第 5 章中对该概念的非正式介绍)。除了使用不同参数列表来重载成员函数外,还可以通过其 const 属性来重载成员函数。在这种情况下,类可能会有一对相同名称、参数列表相同的成员函数。这些函数通过其 const 属性进行重载。在这种情况下,其中只有一个函数必须具有 const 属性。

默认函数参数

在 C++ 中,可以在定义函数时提供“默认参数”。这些参数在程序员未指定时由编译器提供。例如:

#include <stdio.h>

void showstring(char *str = "Hello World!\n");

int main()
{
    showstring("Here's an explicit argument.\n");
    showstring();
}

上述代码中,showstring 函数有一个默认参数。如果调用 showstring 时没有提供参数,编译器将使用默认值 "Hello World!\n"

默认参数的使用只是一个方便的特性:当未显式指定参数时,编译器会提供缺失的参数。使用默认参数并不会使代码更简洁或更高效。

函数可以定义多个默认参数:

void two_ints(int a = 1, int b = 4);

int main()
{
    two_ints();        // arguments: 1, 4
    two_ints(20);     // arguments: 20, 4
    two_ints(20, 5);  // arguments: 20, 5
}

当调用 two_ints 函数时,编译器会在必要时提供一个或两个参数。然而,像 two_ints(,6) 这样的语句是不允许的:当省略参数时,必须从右侧开始省略。

默认参数必须在编译时已知,因为在编译时参数会被提供给函数。因此,默认参数必须在函数的声明中提及,而不是在其实现中:

// 示例头文件
void two_ints(int a = 1, int b = 4);

// 函数实现文件,比如 two.cc
void two_ints(int a, int b)
{
    // 函数实现
}

在函数定义和函数声明中都提供默认参数是错误的。当需要默认参数时,应该在函数声明中提供:当函数被其他源文件使用时,编译器通常会读取头文件,而不是函数定义本身。因此,编译器无法确定默认参数的值,如果这些参数仅在函数定义中提供。

NULL 指针 vs. 0 指针和 nullptr

在 C++ 中,所有零值都用 0 表示。在 C 语言中,NULL 通常用于指针的上下文中。这个差异只是风格上的不同,但被广泛采用。在 C++ 中,应该避免使用 NULL(因为它是一个宏,宏在 C++ 中应当避免使用,见第 8.1.4 节)。相反,几乎总是可以使用 0。

虽然几乎总是如此,但不是总能使用 0。由于 C++ 支持函数重载(见第 2.5.4 节),程序员可能会遇到意外的函数选择,如第 2.5.4 节所示:

#include <stdio.h>

void show(int val)
{
    printf("Integer: %d\n", val);
}

void show(double val)
{
    printf("Double: %lf\n", val);
}

void show(char const *val)
{
    printf("String: %s\n", val);
}

int main()
{
    show(12);
    show(3.1415);
    show("Hello World!\n");
}

在这种情况下,程序员如果意图调用 show(char const *) 可能会写成 show(0)。但这不起作用,因为 0 被解释为 int,从而调用了 show(int)。但调用 show(NULL) 也不起作用,因为 C++ 通常将 NULL 定义为 0,而不是 ((void *)0)。因此,再次调用了 show(int)。为了解决这些问题,新的 C++ 标准引入了关键字 nullptr,表示 0 指针。在当前的示例中,程序员应该调用 show(nullptr) 以避免选择错误的函数。nullptr 值也可以用来初始化指针变量。例如:

int *ip = nullptr;
int value = nullptr; // 错误:value 不是指针

void 参数列表

在 C 语言中,使用空参数列表的函数原型,如 void func();,表示声明的函数的参数列表没有被原型化:对于使用这种原型的函数,编译器不会对调用 func 时的任何参数集发出警告。在 C 语言中,void 关键字用于明确声明一个没有任何参数的函数,如:

void func(void);

由于 C++ 强制执行严格的类型检查,在 C++ 中,空参数列表表示完全没有参数。因此,void 关键字被省略。

#define __cplusplus

每个符合 ANSI/ISO 标准的 C++ 编译器都会定义符号 __cplusplus:这就像是每个源文件前面都加上了预处理指令 #define __cplusplus。我们将在接下来的章节中看到这个符号的使用示例。

使用标准 C 函数

标准 C 函数,例如那些被编译并收集在运行时库中的函数,也可以在 C++ 程序中使用。然而,这些函数必须被声明为 C 函数。

例如,以下代码片段将函数 xmalloc 声明为 C 函数:

extern "C" void *xmalloc(int size);

这个声明与 C 中的声明类似,只是前缀加上了 extern "C"

另一种声明 C 函数的方式如下:

extern "C"
{
    // C 声明放在这里
}

还可以在声明的位置使用预处理指令。例如,一个声明了 C 函数的 C 头文件 myheader.h 可以在 C++ 源文件中这样包含:

extern "C"
{
    #include <myheader.h>
}

虽然这两种方法可以使用,但在 C++ 源文件中实际上较少见。更常用的方法会在下一节中介绍。

适用于 C 和 C++ 的头文件

结合预定义符号 __cplusplusextern "C" 函数的使用,可以创建同时适用于 C 和 C++ 的头文件。这样的头文件可以声明一组既可用于 C 程序也可用于 C++ 程序的函数。

这样的头文件的设置如下:

#ifdef __cplusplus
extern "C"
{
#endif

/* 在这里插入 C 数据和函数的声明。例如: */
void *xmalloc(int size);

#ifdef __cplusplus
}
#endif

使用这种设置,普通的 C 头文件会被 extern "C" { 包围,这部分位于文件的顶部,} 位于文件的底部。#ifdef 指令用于检查编译类型:C 还是 C++。‘标准’ C 头文件,如 stdio.h,就是以这种方式构建的,因此既适用于 C 也适用于 C++。

此外,C++ 头文件应该支持包含保护。通常在 C++ 中,避免在同一源文件中多次包含相同的头文件是很重要的。可以通过在头文件中包含 #ifndef 指令来轻松避免这种多次包含。例如:

#ifndef MYHEADER_H_
#define MYHEADER_H_

// 在这里插入头文件的声明,使用 #ifdef __cplusplus 等指令

#endif

当这个文件第一次被预处理器扫描时,符号 MYHEADER_H_ 尚未定义。#ifndef 条件成功,所有声明会被扫描。此外,符号 MYHEADER_H_ 会被定义。

当同一源文件再次编译时,符号 MYHEADER_H_ 已经被定义,因此所有在 #ifndef#endif 指令之间的信息会被编译器跳过。
在这个上下文中,符号名称 MYHEADER_H_ 仅用于识别目的。例如,可以使用头文件的名称(以大写字母表示)和下划线字符代替点号作为符号名称。

此外,习惯上 C 头文件使用 .h 扩展名,而 C++ 头文件不使用扩展名。例如,标准的 iostreams cincoutcerr 通过包含头文件 iostream 进行访问,而不是 iostream.h。在《注释》中,这种约定用于标准 C++ 头文件,但并不一定适用于其他所有情况。

关于头文件还有更多内容需要讨论。第 7.11 节提供了对 C++ 头文件首选组织的详细讨论。此外,从 C++23 标准开始,模块(modules)可用,这提供了一种比传统头文件更高效的处理声明的方法。

当前,C++ 注释仅简要介绍了模块(参见第 7.11.2 节)。

定义局部变量

虽然在 C 编程语言中已经可以使用局部变量,但应该仅在需要时定义它们。虽然这样做需要一点适应,但最终往往会产生更可读、可维护且更高效的代码,而不是在复合语句的开头定义变量。我们建议在定义局部变量时遵循以下规则:

  • 局部变量应在“直观合适”的位置创建,例如在以下示例中。这不仅仅涉及 for 语句,还包括变量仅在函数中途需要的所有情况。
  • 更一般地,变量应该以使其作用域尽可能有限和局部的方式进行定义。避免在函数的开头定义局部变量,而是应在首次使用时定义。
  • 被认为是好的实践是避免使用全局变量。很容易丧失跟踪哪个全局变量用于什么目的。在 C++ 中,很少需要全局变量,通过局部化变量可以轻松避免将同一变量用于多个目的(从而使变量的单独目的无效)的风险。

如果认为合适,可以使用嵌套块来局部化辅助变量。然而,确实存在在嵌套语句内定义局部变量的情况。例如,上述 for 语句就是一个例子,但局部变量也可以在 if-else 语句的条件子句、switch 语句的选择子句以及 while 语句的条件子句内定义。这样定义的变量对整个语句及其嵌套语句是可用的。例如,考虑以下 switch 语句:

#include <stdio.h>
int main()
{
    switch (int c = getchar())
    {
        case 'a':
        case 'e':
        case 'i':
        case 'o':
        case 'u':
            printf("Saw vowel %c\n", c);
            break;
        case EOF:
            printf("Saw EOF\n");
            break;
        case '0' ... '9':
            printf("Saw number character %c\n", c);
            break;
        default:
            printf("Saw other character, hex value 0x%2x\n", c);
    }
}

注意字符 c 的定义位置:它在 switch 语句的表达式部分中定义。这意味着 c 仅对 switch 语句本身及其嵌套(子)语句可用,而在 switch 语句的作用域外不可用。

相同的方法也可以用于 ifwhile 语句:在 ifwhile 语句的条件子句中定义的变量在其嵌套语句中也是可用的。不过,有一些注意事项:

  • 在条件子句中定义的变量必须初始化为数字或逻辑值;
  • 变量定义不能嵌套(例如,使用括号)在更复杂的表达式中。

第一个注意点不会令人感到意外:为了能够评估 ifwhile 语句的逻辑条件,变量的值必须可以解释为零(假)或非零(真)。通常这不是问题,但在 C++ 中,对象(如 std::string 类型的对象)通常由函数返回。这些对象可能可以或不能被解释为数字值。如果不能(例如 std::string 对象),那么这样的变量不能在条件或表达式子句中定义。以下示例因此不会编译:

if (std::string myString = getString())  // 假设 getString 返回一个 std::string 值
{
    // 处理 myString
}

上述示例需要进一步说明。通常,变量可以有益地被赋予局部作用域,但初始化后立即需要额外的检查。初始化和测试不能在一个表达式中同时完成。相反,需要两个嵌套的语句。因此,以下示例也不会编译:

if ((int c = getchar()) && strchr("aeiou", c))
    printf("Saw a vowel\n");

如果遇到这种情况,可以使用两个嵌套的 if 语句,或通过嵌套的复合语句来局部化 int c 的定义:

if (int c = getchar())  // 嵌套的 if 语句
    if (strchr("aeiou", c)) printf("Saw a vowel\n");
{  // 嵌套的复合语句
    int c = getchar();
    if (c && strchr("aeiou", c)) printf("Saw a vowel\n");
}

关键字 typedef

关键字 typedef 在 C++ 中仍然被使用,但在定义联合体(union)、结构体(struct)或枚举(enum)时已经不再是必需的。以下示例说明了这一点:

struct SomeStruct
{
    int a;
    double d;
    char string[80];
};

在定义结构体、联合体或其他复合类型时,该类型的标签可以作为类型名称使用(在上面的示例中是 SomeStruct):

SomeStruct what;
what.d = 3.1415;

在 C++ 中,可以直接使用定义的结构体、联合体或枚举的标签作为类型名,无需额外的 typedef

函数作为结构体的一部分

在 C++ 中,我们可以将函数定义为结构体的成员。在这里,我们首次遇到一个具体的对象示例:如前所述(参见第 2.4 节),对象是包含数据的结构体,同时存在专门的函数来操作这些数据。

下面的代码片段定义了一个 struct Point。在这个结构体中,声明了两个 int 数据字段和一个 draw 函数。

struct Point {  // 定义一个屏幕上的点
    int x;      // x 坐标
    int y;      // y 坐标
    void draw();  // 绘图函数
};

类似的结构体可以是绘图程序的一部分,例如表示一个像素。关于这个结构体,应注意以下几点:

  • 结构体定义中的 draw 函数只是一个声明。实际的函数代码在其他地方定义(关于结构体内函数的概念将在第 3.2 节进一步讨论)。
  • 结构体 Point 的大小等于其两个 int 的大小。结构体内声明的函数不影响结构体的大小。编译器通过允许 draw 函数仅在 Point 的上下文中可用来实现这种行为。

Point 结构体的使用示例如下:

Point a;  // 屏幕上的第一个点
Point b;  // 屏幕上的第二个点
a.x = 0;  // 定义第一个点的 x 坐标
a.y = 10;  // 定义第一个点的 y 坐标
a.draw();  // 绘制第一个点
b = a;  // 将第一个点复制到第二个点
b.y = 20;  // 重新定义第二个点的 y 坐标
b.draw();  // 绘制第二个点

在上面的示例中,结构体的一部分函数可以通过点(.)操作符选择(当使用对象的指针时,则使用箭头(->)操作符)。这与选择结构体的数据字段的方式是相同的。

这种语法结构的背后思想是,多个类型可以包含具有相同名称的函数。例如,表示圆形的结构体可能包含三个 int 值:两个用于圆心坐标的值和一个用于半径的值。与 Point 结构体类似,Circle 也可以有一个 draw 函数来绘制圆形。

操作数的求值顺序

传统上,除了布尔运算符 andor,二元运算符的操作数的求值顺序在 C 语言中并未定义。C++ 对此进行了改进,对于后缀表达式、赋值表达式(包括复合赋值)和位移运算符,求值顺序如下:

  • 使用后缀运算符的表达式(如索引运算符和成员选择符)从左到右进行求值(不要与后缀递增或递减运算符混淆,后缀递增或递减运算符不能连用,例如 variable++++ 不会编译)。
  • 赋值表达式从右到左进行求值;
  • 位移运算符的操作数从左到右进行求值。

在以下示例中,无论是单个变量、带括号的表达式还是函数调用,first 会在 second 之前被求值,second 会在 third 之前被求值,依此类推:

first.second
fourth += third = second += first
first << second << third << fourth
first >> second >> third >> fourth

此外,在重载运算符时,重载运算符的实现函数会像它重载的内建运算符一样被求值,而不是像一般函数调用那样被排序。

显著的 C++ 与 C 的区别

在我们继续探讨真正的面向对象编程之前,首先介绍一些 C++ 与 C 编程语言之间的显著区别:这些不仅仅是 C 和 C++ 之间的差异,还包括 C++ 中存在但 C 中没有,或者在 C 中用法不同的重要语法构造和关键字。

使用 const 关键字

尽管 const 关键字在 C 语言中也存在,但在 C++ 中的使用比 C 语言中更为重要、常见且严格。

const 关键字是一个修饰符,用于声明一个变量或参数的值不能被修改。以下示例中尝试改变变量 ival 的值会失败:

int main() {
    int const ival = 3;  // 常量整数
    ival = 4;            // 错误:试图修改常量整数
}

这个例子展示了如何在定义时初始化 ival 为某个值,之后尝试修改该值会导致错误。

在 C++ 中,与 C 相比,const 关键字还有以下几个应用:

  1. 数组大小:在 C++ 中,声明 const 变量可以用来指定数组的大小,例如:

    int const size = 20;
    char buf[size]; // 声明一个大小为 20 的字符数组
    
  2. 指针声明const 关键字用于指针声明时,可以有不同的含义:

    • char const *buf; 表示 buf 是一个指向 const 字符的指针,指针所指向的字符不可修改,但指针本身可以修改。
    • char *const buf; 表示 buf 是一个 const 指针,指针本身不可修改,但指针所指向的字符可以修改。
    • char const *const buf; 表示 buf 是一个 const 指针,指针本身和指针所指向的字符都不可修改。

const 关键字的使用规则:关键字 const 放在声明的左侧的部分不可被修改。例如:

  • const int a = 1;
  • int const b = 2;

这两个声明都是合法的,const 的位置仅仅是风格问题。虽然 const 关键字的放置位置在不同场景下可以影响代码的可读性,但在 C++ 中,更重要的是理解 const 的含义。

对于更复杂的声明,读取声明的步骤可以如下:

  1. 从变量名开始阅读。
  2. 尽可能往后阅读,直到遇到声明的结束或一个未匹配的右括号。
  3. 返回到开始的地方,向后阅读直到遇到声明的开始或一个匹配的左括号。
  4. 如果遇到左括号,继续步骤 2,直到括号外部。

例如,对于以下复杂的声明:

char const *(* const (*(*ip)())[])[];

可以按照上述步骤解析出 ip 是一个指向一个函数的指针,该函数不接受参数,返回一个指向数组的指针,该数组包含 const 指针,指向包含 const 字符的数组。

我们已经看到一个例子,其中 const 关键字的“前置”规则产生了意外的(即不希望的)结果。此外,“前置”用法也与 const 函数的概念相冲突,后者将 const 关键字放在函数名之后,而不是之前。

定义或声明(无论是否包含 const)应始终从变量或函数标识符读取回类型标识符:
“Buf 是一个指向 const 字符的 const 指针”。

这个经验法则在可能引起混淆的情况下特别有用。在其他地方发布的 C++ 代码示例中,经常会遇到相反的情况:const 放在应该保持不变的部分之前。以下示例中显示了这可能导致代码不准确的问题:

int main() {
    char const *buf = "hello";
    ++buf;  // 编译器接受此操作,buf 现在指向 "ello"
    *buf = 'u';  // 编译器拒绝此操作,不能修改常量字符串
}

在上面的例子中,编译失败是因为 *buf = 'u'; 尝试修改 const 字符,而 ++buf; 是允许的,因为它只是改变了指针的位置。

Marshall Cline 的 C++ FAQ 中提供了类似的规则(第 18.5 段):
[18.5] “const Fred* p”、“Fred* const p”和“const Fred* const p”之间有什么区别?

你必须从右到左阅读指针声明。

Marshall Cline 的建议可以改进。以下是一个简单的解析复杂声明的步骤:

  1. 从变量名开始阅读。
  2. 尽可能往后阅读,直到遇到声明的结束或一个未匹配的右括号。
  3. 返回到开始的地方,向后阅读直到遇到声明的开始或一个匹配的左括号。
  4. 如果遇到左括号,继续步骤 2,直到括号外部。

让我们应用这个方法来解析以下复杂的声明:

char const *(* const (*(*ip)())[])[]

小箭头指示了每一步的阅读范围和方向:

  • 从变量名开始:ip
  • 遇到右括号,回到 *ip
  • 继续阅读直到匹配的左括号,说明这是一个函数指针:(*ip)()
  • 读取右括号后,发现这是一个数组:(*(*ip)())[]
  • 返回到前面,发现 const 修饰的是指针:const (*(*ip)())[]
  • 继续到最开始的部分,最终解析为 char const *(* const (*(*ip)())[])[]ip 是一个指向函数的指针,该函数返回一个指向 const 指针数组的指针,这些指针指向 const 字符的数组。
(*ip)  
<-
找到配对的左括号:
‘一个指针’
(*ip)())
    -->
下一个未匹配的右括号:
‘一个函数(不接受参数)’

(*(*ip)())
<-
找到配对的左括号:
‘返回一个指针’

(*(*ip)())[])
          -->
下一个右括号:
‘一个数组’

(* const (*(*ip)())[])
<--------
找到配对的左括号:
‘const 指针’
(* const (*(*ip)())[])[]  
                      ->
读到结尾:
‘一个数组的’

char const *(* const (*(*ip)())[])[]  
<-----------
向后读取剩下的部分:
‘指向 const 字符的指针’

综合所有部分,我们得到 char const *(* const (*(*ip)())[])[] 的含义是:ip 是一个指向不接受参数的函数的指针,该函数返回一个指向 const 指针数组的指针,这些指针指向 const 字符的数组。这个方法可以用来解析你遇到的任何声明。

命名空间

C++ 引入了命名空间的概念:所有符号都在一个更大的上下文中定义,称为命名空间。命名空间用于避免名称冲突,例如当程序员希望定义一个处理度数的 sin 函数时,但不想失去使用处理弧度的标准 sin 函数的能力。

命名空间在第4章中有详细介绍。现在需要注意的是,大多数编译器要求显式声明标准命名空间 std。因此,除非另有说明,Annotations 中的所有示例都隐式使用了 using namespace std; 声明。因此,如果你打算编译 C++ Annotations 中给出的示例,确保源代码以上述使用声明开始。

作用域解析运算符 ::

C++ 引入了几种新的运算符,其中包括作用域解析运算符 ::。这个运算符可以在存在与局部变量同名的全局变量的情况下使用,例如:

#include <stdio.h>
double counter = 50;  // 全局变量
int main() {
    for (int counter = 1;  // 这里指的是局部变量
         counter != 10;    // 这里指的是局部变量
         ++counter) {
        printf("%d\n", ::counter      // 这里指的是全局变量
                           /          // 除以
                           counter);  // 这里指的是局部变量
    }
}

在上述程序中,作用域解析运算符用于引用全局变量,而不是与之同名的局部变量。在 C++ 中,作用域运算符的使用非常广泛,但它很少用于访问被同名局部变量遮蔽的全局变量。它的主要用途将在第7章中详细介绍。

coutcincerr

类似于 C 语言,C++ 定义了标准输入和输出流,这些流在程序执行时可用。这些流包括:

  • cout,类似于 stdout
  • cin,类似于 stdin
  • cerr,类似于 stderr

在语法上,这些流并不像函数那样使用;而是使用运算符 <<(称为插入运算符)和 >>(称为提取运算符)来向流中写入数据或从中读取数据。以下是一个示例:

#include <iostream>
using namespace std;

int main() {
    int ival;
    char sval[30];
    
    cout << "Enter a number:\n";
    cin >> ival;
    cout << "And now a string:\n";
    cin >> sval;
    cout << "The number is: " << ival << "\n"
         << "And the string is: " << sval << '\n';
}

这个程序从 cin 流(通常是键盘)读取一个数字和一个字符串,并将这些数据打印到 cout 上。关于流,需要注意以下几点:

  • 标准流在头文件 iostream 中声明。在 C++ 注释中,这个头文件通常没有明确提到。然而,当使用这些流时,必须包含这个头文件(直接或间接)。与使用 using namespace std; 子句类似,读者在使用标准流的示例中需要 #include <iostream>
  • coutcincerr 是所谓的类类型的变量。这些变量通常称为对象。类将在第7章详细讨论,并在 C++ 中被广泛使用。
  • cin 通过提取运算符(两个连续的 > 字符:>>)从流中提取数据并将提取的信息复制到变量(例如上述示例中的 ival)。稍后在注释中,我们将描述 C++ 中运算符可以执行的动作与其语言定义的动作有所不同的情况。函数重载已经被提到。在 C++ 中,运算符也可以有多重定义,这称为运算符重载。
  • 操作 cincoutcerr 的运算符(即 >><<)也可以操作不同类型的变量。在上述示例中,cout << ival 结果是打印一个整数值,而 cout << "Enter a number" 结果是打印一个字符串。因此,这些运算符的行为依赖于提供的变量类型。
  • 提取运算符 (>>) 通过“提取”文本流中的值到变量中执行类型安全的赋值。通常,提取运算符会跳过所有在值之前的空白字符。
  • 特殊的符号常量用于特殊情况。通常,一行通过插入 "\n"'\n' 来终止。但是,当插入 endl 符号时,行会被终止,并且流的内部缓冲区会被刷新。因此,通常可以避免使用 endl,而使用 '\n' 结果会使代码更高效。

流对象 cincoutcerr 并不是 C++ 语法的一部分。这些流是头文件 iostream 中定义的一部分。这类似于 printf 这样的函数,它们并不是 C 语法的一部分,但最初是由认为这些函数重要的人编写并收集在运行时库中的。

程序仍然可以使用旧式函数如 printfscanf,而不是使用新式流。两种风格甚至可以混合使用。但流提供了几个明显的优势,并在许多 C++ 程序中完全替代了旧式 C 函数。一些使用流的优点包括:

  • 使用插入和提取运算符是类型安全的。printfscanf 使用的格式字符串可能为其参数定义错误的格式说明符,编译器有时无法发出警告。相比之下,cincoutcerr 的参数检查由编译器执行。因此,不可能因提供错误的参数而出错。使用流时没有格式字符串。
  • printfscanf(以及其他使用格式字符串的函数)实际上实现了一个在运行时解释的迷你语言。相比之下,使用流时 C++ 编译器完全了解根据所用参数执行的输入或输出操作。没有迷你语言。
  • 此外,插入和提取运算符的功能可以扩展,允许将当流最初设计时不存在的类对象插入到流中或从流中提取。printf 使用的迷你语言不能扩展。
  • 在流上下文中使用左移和右移运算符展示了 C++ 的另一个功能:运算符重载,允许我们在某些上下文中重新定义运算符执行的操作。运算符重载需要一些适应,但很快这些重载运算符会让人感到相当舒适。
  • 流独立于它们操作的介质。这种(在此阶段有些抽象的)概念意味着相同的代码可以在没有任何修改的情况下用于接口到任何类型的设备。使用流的代码可以用于磁盘上的文件;互联网连接;数字相机;DVD 设备;卫星链路;以及更多设备:随你选择。流允许你的代码与要操作的设备解耦(独立),这简化了维护,并允许在新情况中重用相同的代码。

iostream 库提供的功能远不止 cincoutcerr。第6章将更详细地介绍 iostream。尽管 printf 和相关函数仍然可以在 C++ 程序中使用,但流实际上已经完全取代了旧式 C 输入输出函数。如果你认为仍需要使用 printf 和相关函数,再想想吧:在这种情况下,你可能还没有完全理解流对象的功能。

结构体中的函数

之前我们提到过,函数可以是结构体的一部分(见第2.5.13节)。这些函数称为成员函数。本节简要讨论如何定义这样的函数。

下面的代码片段显示了一个包含个人姓名和地址数据字段的结构体。在结构体的定义中包含了一个 print 函数:

struct Person
{
    char name[80];
    char address[80];
    void print();
};

在定义成员函数 print 时,使用结构体的名称(Person)和作用域解析运算符(::):

void Person::print()
{
    cout << "Name: " << name << "\n"
         << "Address: " << address << '\n';
}

Person::print 的实现展示了如何访问结构体的字段,而无需使用结构体的类型名称。在这里,Person::print 打印了一个变量 name。由于 Person::print 本身是结构体 Person 的一部分,变量 name 隐式地指代相同类型的数据。

这个结构体 Person 可以如下使用:

Person person;
strcpy(person.name, "Karel");
strcpy(person.address, "Marskramerstraat 33");
person.print();

成员函数的优点在于被调用的函数自动访问调用它的结构体的数据字段。在 person.print() 语句中,对象 person 是“基底”:在 print 函数的代码中使用的变量 nameaddress 指的是存储在 person 对象中的数据。

数据隐藏:public、private 和 class

如之前所提到的(见第2.3节),C++包含了实现数据隐藏的专用语法。数据隐藏是指程序的某些部分可以隐藏其数据,以防止其他部分访问。这可以产生非常清晰的数据定义,并允许这些部分强制执行数据的完整性。

C++有三个与数据隐藏相关的关键字:privateprotectedpublic。这些关键字可以用于结构体的定义中。public 关键字允许所有后续字段被所有代码访问;private 关键字则只允许结构体内部的代码访问后续字段。protected 关键字在第13章讨论,这里不在当前讨论范围内。

在结构体中,所有字段默认都是 public,除非明确声明为其他访问权限。利用这一点,我们可以扩展结构体 Person 如下:

struct Person
{
private:
    char d_name[80];
    char d_address[80];
public:
    void setName(char const *n);
    void setAddress(char const *a);
    void print();
    char const *name();
    char const *address();
};

由于数据字段 d_named_addressprivate 区域,因此只有在结构体内部定义的成员函数(如 setNamesetAddress 等)可以访问这些字段。例如,下面的代码演示了这种访问权限:

Person fbb;
fbb.setName("Frank"); // 正确,setName 是 public
strcpy(fbb.d_name, "Knarf"); // 错误,d_name 是 private

数据完整性通过以下方式实现:结构体 Person 的实际数据在结构体定义中提及。这些数据通过结构体的特殊函数(成员函数)进行访问,这些函数也是结构体定义的一部分。这些成员函数控制数据字段和程序其他部分之间的所有交互,因此也称为“接口”函数。这样实现的数据隐藏如图3.1所示。成员函数 setNamesetAddress 使用 char const * 参数。这表明这些函数不会修改其参数中提供的字符串。类似地,成员函数 nameaddress 返回 char const *,编译器会防止调用者修改通过这些成员函数返回的值。以下是 Person 结构体的两个成员函数的示例:

void Person::setName(char const *n)
{
    strncpy(d_name, n, 79);
    d_name[79] = 0;
}

char const *Person::name()
{
    return d_name;
}

成员函数和数据隐藏的强大之处在于,成员函数可以执行特殊任务,例如检查数据的有效性。在上述示例中,setName 只从其参数中复制最多79个字符到数据成员 name,从而避免了缓冲区溢出。

数据隐藏的另一个示例是,除了内存中的数据,库还可以开发成员函数将数据存储在文件中。将存储 Person 结构体的程序从内存存储转换为磁盘存储,只需重新编译并将程序链接到新的库中即可。这种示例展示了比数据隐藏更广泛的概念;它展示了封装。数据隐藏是封装的一种形式。一般来说,封装减少了程序不同部分之间的耦合,从而大大增强了生成软件的可重用性和可维护性。通过让结构体封装实际存储介质,使用该结构体的程序变得独立于实际使用的存储介质。

尽管数据隐藏可以通过结构体实现,但更常见(几乎总是)的是使用类。类是一种结构体,只是类默认使用 private 访问权限,而结构体默认使用 public 访问权限。因此,Person 类的定义与上面显示的结构体定义完全相同,只是 struct 关键字被 class 替换,初始的 private: 部分可以省略。我们的排版建议是类名(以及其他程序员定义的类型名)以大写字母开头,后续部分使用小写字母(例如,Person)。

C中的结构体与C++中的结构体

在本节中,我们将讨论C和C++中结构体及其(成员)函数之间的重要区别。在C中,通常会定义多个函数来处理结构体,这些函数需要一个指向结构体的指针作为其中一个参数。下面是一个虚构的C头文件,展示了这一概念:

/* 结构体 PERSON 的定义 */
typedef struct
{
    char name[80];
    char address[80];
} PERSON;

/* 操作 PERSON 结构体的一些函数 */
/* 使用名称和地址初始化字段 */
void initialize(PERSON *p, char const *nm, char const *adr);

/* 打印信息 */
void print(PERSON const *p);

/* 等等... */

在C++中,相关函数的声明被放置在结构体或类的定义内部。函数的参数中不再需要表示结构体的标识符。C++ 中的定义如下:

class Person
{
    char d_name[80];
    char d_address[80];
public:
    void initialize(char const *nm, char const *adr);
    void print();
    // 等等...
};

在C++中,不再使用结构体参数。一个C函数调用,如下所示:

PERSON x;
initialize(&x, "some name", "some address");

在C++中,变成了:

Person x;
x.initialize("some name", "some address");

引用

除了常见的变量定义方式(普通变量或指针)之外,C++ 引入了引用,它定义了变量的同义词。对变量的引用就像是一个别名;变量和引用都可以在涉及该变量的语句中使用:

int int_value;
int &ref = int_value;

在上面的例子中,定义了一个变量 int_value。随后定义了一个引用 ref,它(由于初始化)引用了与 int_value 相同的内存位置。在 ref 的定义中,引用操作符 & 表示 ref 本身不是一个 int,而是一个对 int 的引用。两个语句:

++int_value;
++ref;

具有相同的效果:它们都递增 int_value 的值。无论这个位置被称作 int_value 还是 ref 都无关紧要。

引用在 C++ 中起着重要的作用,作为将可修改的参数传递给函数的一种方式。例如,在标准 C 中,一个增加参数值 5 并且不返回任何值的函数需要一个指针参数:

void increase(int *valp)  // 期望一个指向 int 的指针
{
    *valp += 5;  // 修改指针指向的 int 的值
}

int main() {
    int x;
    increase(&x);  // 传递 x 的地址
}

这种构造方式在 C++ 中也可以使用,但可以通过引用实现相同的效果:

void increase(int &valr) {  // 期望一个对 int 的引用
    valr += 5;              // 直接修改引用的值
}

int main() {
    int x;
    increase(x);  // 传递 x 的引用
}

关于是否应优先使用如上代码的方式还是 C 的方法,存在一定争议。increase(x) 的语句暗示着传递的是 x 的副本。然而,由于 increase() 的定义,x 的值会发生变化。然而,引用也可以用于传递仅用于检查的对象(不需要复制或使用 const *),或传递那些其修改是预期的副作用的对象。在这些情况下,使用引用通常比复制值或传递指针的现有替代方案更为推荐。

在幕后,引用是通过指针实现的。因此,就编译器而言,C++ 中的引用实际上就是常量指针。然而,使用引用时,程序员无需关注或处理间接层级。一个重要的区别在于,指针和引用之间的显著区别是,引用没有间接层级。例如:

extern int *ip;
extern int &ir;

ip = 0;  // 重新分配 ip,现为零指针
ir = 0;  // ir 未改变,所引用的 int 变量现在值为 0

为了避免混淆,我们建议遵循以下准则:

  • 在函数不会修改内置类型或指针类型参数的值时,使用值参数:

    void some_func(int val) {
        cout << val << '\n';
    }
    
    int main() {
        int x;
        some_func(x); // 传递的是 x 的副本
    }
    
  • 当函数显式地需要更改其参数的值时,优先使用指针参数。通常,这些指针参数应作为函数的初始参数,这称为“通过参数返回”:

    void by_pointer(int *valp) {
        *valp += 5; // 修改指针指向的值
    }
    
  • 当函数不更改类或结构体类型参数的值,或者修改参数是微不足道的副作用(例如,参数是流)时,可以使用引用。如果函数不修改参数,应该使用 const 引用:

    void by_reference(string const &str) {
        cout << str; // str 不被修改
    }
    
    int main() {
        int x = 7;
        by_pointer(&x);  // 传递指针
        // x 可能会被修改
        string str("hello");
        by_reference(str);  // str 不会被改变
    }
    

引用在参数不被函数改变,但不希望复制参数初始化参数的情况中起着重要作用。例如,当传递大型对象作为参数,或者对象由函数返回时,复制操作可能成为一个重要因素,因为整个对象必须被复制。在这些情况下,优先使用引用。

如果函数不会修改参数,或者调用者不应修改返回的信息,应使用 const 关键字。考虑以下示例:

struct Person {  // 一些大的结构体
    char name[80];
    char address[90];
    double salary;
};

Person person[50];  // 个人数据库

// printperson 期望一个结构体的引用
// 但不会改变它
void printperson(Person const &subject) {
    cout << "Name: " << subject.name << '\n'
         << "Address: " << subject.address << '\n';
}

// 根据索引值获取一个人
Person const &personIdx(int index) {
    return person[index];  // 返回一个引用
}

int main() {
    // 不是 person[index] 的副本
    Person boss;
    printperson(boss);
    // 没有传递指针,
    // 所以 `boss` 不会被函数改变
    printperson(personIdx(5));
    // 这里传递的是引用,不是副本
}

此外,还要注意使用引用作为函数参数的另一个原因。传递对象的引用时,可以避免激活所谓的复制构造函数。复制构造函数在第9章中介绍。

引用可能会导致非常“丑陋”的代码。例如,一个函数可以返回对一个变量的引用,如下例所示:

int &func() {
    static int value;
    return value;
}

func() = 20;
func() += func();

可能不需要指出,这种构造通常不应该使用。然而,在某些情况下,返回引用是有用的。实际上,我们在之前讨论流时已经见过这种现象。在像 cout << "Hello" << '\n'; 的语句中,插入操作符返回对 cout 的引用。因此,在这个语句中,首先将 “Hello” 插入到 cout,产生一个对 cout 的引用。通过这个引用,再将 '\n' 插入 cout 对象,再次产生一个对 cout 的引用,然后被忽略。

以下是指针和引用之间的几个区别:

  • 引用不能独立存在,即没有引用的对象。例如,int &ref; 是不允许的;那 ref 应该引用什么?
  • 引用可以被声明为外部的。这些引用在其他地方初始化。
  • 引用可以作为函数的参数存在:它们在函数调用时被初始化。
  • 引用可以用作函数的返回类型。在这些情况下,函数决定返回值引用的内容。
  • 引用可以用作类的数据成员。稍后会讨论这种用法。
  • 指针本身是变量。它们指向某个具体的东西或“什么也不指向”。
  • 引用是其他变量的别名,不能重新别名到另一个变量。一旦定义了引用,它就引用了特定的变量。
  • 指针(除了 const 指针外)可以重新分配以指向不同的变量。
  • 当与引用一起使用地址运算符 & 时,表达式返回引用所应用的变量的地址。相比之下,普通指针本身是变量,因此指针变量的地址与指针所指向变量的地址无关。

右值引用

在C++中,临时值(右值)与 const & 类型是不可区分的。C++ 引入了一种新的引用类型,称为右值引用,定义为 typename &&

右值引用这个名字源自赋值语句,其中赋值操作符左侧的变量称为左值(lvalue),右侧的表达式称为右值(rvalue)。右值通常是临时的、匿名的值,比如函数返回的值。

在这个术语中,C++ 引用应该被视为左值引用(使用符号 typename &)。与之对比的是右值引用(使用符号 typename &&)。

理解右值引用的关键在于匿名变量的概念。匿名变量没有名称,这使得编译器在有选择的情况下可以自动将其与右值引用关联。首先让我们看看一些使用左值引用的标准情况。以下函数返回一个临时(匿名)值:

int intVal() {
    return 5;
}

虽然 intVal 的返回值可以被赋值给一个 int 变量,但这需要复制,这在函数返回一个大型对象时可能变得不可接受。引用或指针也不能用来收集匿名返回值,因为返回值不会在此之后存活。所以以下的做法是不合法的(编译器会指出):

int& ref = intVal();  // 错误,无法绑定到临时值
int &ir = intVal(); // 失败:尝试引用一个临时值
int const &ic = intVal(); // 正确:对临时值的引用是不可变的
int *ip = &intVal(); // 失败:没有左值可用

显然,不可能修改由 intVal 返回的临时值。但现在考虑以下这些函数:

void receive(int &value) {
    // 注意:左值引用
    cout << "int value parameter\n";
}

void receive(int &&value) {
    // 注意:右值引用
    cout << "int R-value parameter\n";
}

让我们从 main 函数中调用这些函数:

int main() {
    receive(18);
    int value = 5;
    receive(value);
    receive(intVal());
}

这个程序产生了以下输出:

int R-value parameter
int value parameter
int R-value parameter

程序的输出表明,编译器在所有接收到匿名 int 作为参数的情况下选择了 receive(int &&value)。请注意,这包括 receive(18):值 18 没有名称,因此调用了 receive(int &&value)。实际上,它内部使用一个临时变量来存储 18,如下例所示:

void receive(int &&value) {
    ++value;
    cout << "int R-value parameter, now: " << value << '\n';
    // 分别显示 19 和 6
}

对比 receive(int &value)receive(int &&value) 并不是因为 int &value 不是常量引用。如果使用 receive(int const &value),结果也将相同。关键点是:编译器在函数接收到匿名值时选择了带有右值引用的重载函数。

然而,如果将 void receive(int &value) 替换为 void receive(int value),编译器会遇到问题。当面对值参数和引用参数(无论是左值还是右值)之间的选择时,编译器无法做出决定,并报告了一个歧义。在实际上下文中,这不是问题。右值引用被添加到语言中,以便能够区分两种引用形式:命名值(使用左值引用)和匿名值(使用右值引用)。

这种区分使得实现移动语义和完美转发成为可能。此时,移动语义的概念尚未完全讨论(但请参见第9.7节以获得更详细的讨论),但可以很好地说明其基本思想。

考虑一个函数返回一个 Data 结构体,结构体包含一个指向动态分配的 NTBS 的指针。我们同意 Data 对象仅在初始化后使用,为此提供了两个初始化函数。顺便提一下,当 Data 对象不再需要时,指向的内存必须再次返回给操作系统;假设这个任务得到了妥善处理。

struct Data {
    char *text;
    void init(char const *txt);  // 从 txt 初始化 text
    void init(Data const &other) { text = strdup(other.text); }
};

这里还有一个有趣的函数:Data dataFactory(char const *text);

其实现无关紧要,但它返回一个用 text 初始化的(临时)Data 对象。这种临时对象在创建它们的语句结束后就会消失。现在我们来使用 Data

int main() {
    Data d1;
    d1.init(dataFactory("object"));
}

在这里,init 函数复制存储在临时对象中的 NTBS。紧接着,临时对象消失。如果仔细考虑一下,你会意识到这有点多余了:

  • dataFactory 函数使用 init 初始化其临时 Data 对象的 text 变量。为此,它使用了 strdup
  • 然后 d1.init 函数也使用 strdup 初始化 d1.text
  • 语句结束后,临时对象消失。结果是进行了两次 strdup 调用,但临时 Data 对象之后再也不会被使用了。

为了解决这种情况,引入了右值引用。我们向 struct Data 添加以下函数:

void init(Data &&tmp) {
    text = tmp.text;  // (1)
    tmp.text = 0;     // (2)
}

现在,当编译器翻译 d1.init(dataFactory("object")) 时,它注意到 dataFactory 返回一个(临时)对象,因此使用了 init(Data &&tmp) 函数。我们知道 tmp 对象在执行完语句后就会消失,所以 d1 对象在步骤 (1) 中获取了临时对象的 text 值,然后在步骤 (2) 中将 tmp.text 赋值为 0,以确保临时对象的 free(text) 操作不会产生影响。

因此,struct Data 突然变得能够感知移动,并实现了移动语义,消除了前一种方法的(额外复制)缺点,而是简单地将指针值转移给了新对象所有者。

Lvalues, rvalues and more

虽然这一节包含对第5、7和16章的前瞻引用,但它的主题最适合当前这一章。这一节可以跳过而不影响连贯性,您可以在熟悉了这些未来章节的内容后再回来看这一节。

历史上,C 语言区分了左值(lvalues)和右值(rvalues)。这种术语基于赋值表达式,在赋值操作符左侧的表达式接收一个值(例如,它指向一个内存位置,可以将值写入其中,比如变量),而赋值操作符右侧的表达式只需要表示一个值(它可以是一个临时变量、一个常量值或存储在变量中的值):

lvalue = rvalue;

C++在这一基本区分的基础上,增加了几种新的表达方式:

  • lvalue:C++中的左值与C语言中的含义相同。它指向一个可以存储值的位置,比如变量、对变量的引用或解引用的指针。
  • xvalue:xvalue表示一个即将到期的值。即将到期的值指的是一个对象(参考第7章)在其生命周期即将结束之前。此类对象通常需要确保它们拥有的资源(如动态分配的内存)也随之消失,但这些资源可能会在对象的生命周期结束之前被移动到另一个位置,从而防止它们被销毁。
  • glvalue:glvalue是广义的左值。广义的左值指的是任何可以接收值的东西。它可以是一个左值或xvalue。
  • prvalue:prvalue是纯右值:一个字面值(如1.2e3)或一个不可变对象(例如,返回一个常量std::string的函数返回的值,参考第5章)。

一个表达式的值是xvalue,如果它是:

  • 一个返回对象的rvalue引用的函数返回的值;
  • 一个对象被转换为rvalue引用;
  • 一个访问非静态类数据成员的表达式,其对象是xvalue,或使用指向成员的表达式(参考第16章),其中左操作数是xvalue,而右操作数是指向数据成员的指针。

这一规则的效果是,命名的rvalue引用被视为lvalues,而对对象的匿名rvalue引用被视为xvalues。
无论是否匿名,函数的rvalue引用都被视为lvalues。

以下是一个小例子。考虑这个简单的结构体:

struct Demo {
    int d_value;
};

另外,我们还有这些函数声明和定义:

Demo &&operator+(Demo const &lhs, Demo const &rhs);
Demo &&factory();
Demo demo;
Demo &&rref = static_cast<Demo &&>(demo);

像下面这些表达式:

factory();
factory().d_value;
static_cast<Demo &&>(demo);
demo + demo;

是xvalues。然而,表达式rref;是一个lvalue。

在很多情况下,了解使用的到底是何种左值或右值并不是特别重要。在C++注释中,术语lhs(左侧)经常用来表示写在二元操作符左侧的操作数,而术语rhs(右侧)经常用来表示写在二元操作符右侧的操作数。lhs和rhs操作数可以是gvalues(例如,当表示普通变量时),但它们也可以是prvalues(例如,使用加法运算符相加的数值)。lhs和rhs操作数是gvalues还是lvalues,可以根据它们使用的上下文来确定。

强类型枚举(Strongly Typed Enumerations)

enum class SafeEnum {
    NOT_OK,  // 隐含为 0
    OK = 10,
    MAYBE_OK  // 隐含为 11
};

Enum类默认使用int类型的值,但可以通过使用:类型标注来轻松改变所用的值类型,例如:

enum class CharEnum : unsigned char {
    NOT_OK,
    OK
};

要使用Enum类中定义的值,必须同时提供其枚举名称。例如,OK 是未定义的,而 CharEnum::OK 是已定义的。

通过数据类型说明(默认情况下为int),可以使用Enum类的前向声明。例如:

enum Enum1;  // 非法:未提供大小
enum Enum2 : unsigned int;  // 合法:明确声明了类型
enum class Enum3;  // 合法:使用默认的int类型
enum class Enum4 : char;  // 合法:明确声明了类型

switch语句中,也可以使用省略号语法来指示强类型枚举的符号序列,如下例所示:

SafeEnum enumValue();
switch (enumValue()) {
    case SafeEnum::NOT_OK...SafeEnum::OK:
        cout << "Status is known\n";
        break;
    default:
        cout << "Status unknown\n";
        break;
}

初始化列表

C语言定义了初始化列表为用花括号括起来的一组值列表,这些值列表本身可能也包含初始化列表。在C语言中,这些初始化列表通常用于初始化数组和结构体。

C++扩展了这一概念,引入了initializer_list<Type>类型,其中Type被相应的值的类型名所替代。C++中的初始化列表与C语言中的类似,都是递归的,因此也可以用于多维数组、结构体和类。

在使用initializer_list之前,必须包含<initializer_list>头文件。

和C语言一样,初始化列表由用花括号括起来的值列表组成。但与C不同,C++的函数可以定义初始化列表参数。例如:

void values(std::initializer_list<int> iniValues) {
}

像这样的函数可以如下调用:

values({2, 3, 5, 7, 11, 13});

初始化列表作为一个用花括号括起来的值列表出现在参数中。由于初始化列表的递归特性,还可以传递二维的值系列,如下例所示:

void values2(std::initializer_list<std::initializer_list<int>> iniValues) {
}
values2({{1, 2}, {2, 3}, {3, 5}, {4, 7}, {5, 11}, {6, 13}});

初始化列表是常量表达式,不能被修改。然而,它们的大小和值可以通过sizebeginend成员函数来获取,如下所示:

void values(std::initializer_list<int> iniValues) {
    cout << "Initializer list having " << iniValues.size() << " values\n";
    for (auto it = iniValues.begin(); it != iniValues.end(); ++it) {
        cout << "Value: " << *it << '\n';
    }
}

初始化列表还可以用于初始化类对象(参见第7.5节,该节还总结了初始化列表的功能)。

当指定初始化列表的值时,不允许隐式转换(也称为缩窄转换)。缩窄转换发生在使用的值类型范围大于初始化列表中定义的类型时。例如:

  • int类型的初始化列表指定floatdouble值;
  • float类型的初始化列表指定超出float范围的整数值;
  • int类型的初始化列表指定比该类型范围更宽的整数类型的值,除非指定的值在初始化列表整数类型的范围内。

以下是一些例子:

std::initializer_list<int> ii{1.2};  // 1.2不是int类型的值
std::initializer_list<unsigned> iu{~0ULL};  // unsigned long long类型的值超出了unsigned的范围

指定初始化

C++与C语言一样,也支持指定初始化。然而,由于C++要求数据成员的析构顺序与其构造顺序相反,因此在使用指定初始化时,必须按照类或结构体中声明的顺序初始化成员。例如:

struct Data {
    int d_first;
    double d_second;
    std::string d_third;
};

Data data{.d_first = 1, .d_third = "hello"};

在这个例子中,d_firstd_third被显式初始化,而d_second则被隐式初始化为其默认值(即0.0)。

在C++中,不允许在指定初始化列表中重新排序成员的初始化。因此:

Data data{.d_third = "hello", .d_first = 1}; // 错误

而下面的代码是允许的:

Data data{.d_third = "hello"}; // 合法

因为在后一个例子中没有发生顺序冲突(这也会将d_firstd_second初始化为0)。

同样,联合体(union)也可以使用指定初始化进行初始化,如下面的例子所示:

union Data {
    int d_first;
    double d_second;
    std::string *d_third;
};

// 初始化联合体的d_third字段:
Data data{.d_third = new std::string{"hello"}};

位域的初始化

位域用于在整数类型中指定一系列的位。例如,在处理IPv4数据包的网络软件中,IPv4数据包的第一个uint32_t值包含以下内容:

  • 版本号(4位)
  • 头长度(4位)
  • 服务类型(8位)
  • 总长度(16位)

与其使用复杂的位操作和移位操作,可以使用位域来指定这些整数值中的字段。例如:

struct FirstIP4word {
    uint32_t version : 4;
    uint32_t header : 4;
    uint32_t tos : 8;
    uint32_t length : 16;
};

FirstIP4word对象的总大小为32位,即四个字节。要显示FirstIP4word对象的版本号,可以简单地这样做:

cout << first.version << '\n';

要将其头长度设置为10,可以这样做:

first.header = 10;

位域在C语言中已经可用。C++23标准允许通过在定义中使用初始化表达式来对它们进行默认初始化。例如:

struct FirstIP4word {
    uint32_t version : 4 = 1;  // 版本号默认初始化为1
    uint32_t header : 4 = 10;  // TCP头长度默认初始化为10
    uint32_t tos : 8;
    uint32_t length : 16;
};

这些初始化表达式在使用位域的对象定义时被求值。此外,当变量用于初始化位域时,该变量至少在包含位域的结构体定义时已经声明。例如:

extern int value;

struct FirstIP4word {
    ...
    uint32_t length : 16 = value;  // 合法:value已被声明
};

FirstIP4word 对象的总大小为 32 位,即四字节。要显示 FirstIP4word 对象 first 的版本信息,可以直接使用:

cout << first.version << '\n';

而要将其头部长度设置为 10,可以简单地写:

first.header = 10;

位域在 C 语言中已存在。C++23 标准允许通过在定义中使用初始化表达式来为位域进行默认初始化。例如:

struct FirstIP4word {
    uint32_t version : 4 = 1;  // version 默认为 1
    uint32_t header : 4 = 10;  // TCP 头部长度默认为 10
    uint32_t tos : 8;
    uint32_t length : 16;
};

初始化表达式在定义使用位域的对象时被评估。此外,当变量用于初始化位域时,该变量必须在定义包含位域的结构时已被声明。例如:

extern int value;
struct FirstIP4word {
    ...
    uint32_t length : 16 = value; // OK: value 已被声明
};

类型推断使用 auto

关键字 auto 可用于简化变量的类型定义和函数的返回类型,只要编译器能够确定这些变量或函数的正确类型。

在 C++ 中,auto 作为存储类说明符的用法已经不再支持。例如,像 auto int var 这样的变量定义会导致编译错误。

auto 关键字用于那些难以确定变量类型的情况。例如,在模板的上下文中(参见第18至23章),或者当已知类型非常长但编译器可以自动推断时。此时,程序员可以使用 auto 避免输入冗长的类型定义。

以下是一些简单的示例。在定义和初始化变量时,如 int variable = 5,初始化表达式的类型是已知的:它是 int,除非程序员的意图不同,否则可以用这种类型定义变量的类型(尽管在这种情况下,使用 auto 会降低代码的清晰度):

auto variable = 5;

但是,使用 auto 还是很有吸引力的。在第5章中引入了迭代器的概念(参见第12章和第18章)。迭代器的类型定义通常很长,例如:

std::vector<std::string>::const_reverse_iterator

函数可能返回具有这些类型的对象。由于编译器了解这些类型,我们可以利用这些知识,使用 auto。假设有一个声明为 std::vector<std::string>::const_reverse_iterator begin() 的函数:

与其写一个冗长的变量定义(如第1处所示),不如使用更短的定义(如第2处所示):

std::vector<std::string>::const_reverse_iterator iter = begin();  // 1
auto iter = begin();                                              // 2

同样,可以定义和初始化其他变量。当初始化这些变量时,可以使用 iter 来初始化这些变量,auto 会自动推断它们的类型:

auto start = iter;

当使用 auto 定义变量时,变量的类型是从变量的初始化表达式中推断出来的。普通类型和指针类型按原样使用,但当初始化表达式是引用类型时,则使用引用的基本类型(不包括引用本身,省略 constvolatile 说明符)。

如果需要引用类型,则可以使用 auto &auto &&。同样,const 和/或指针说明符也可以与 auto 关键字一起使用。以下是一些示例:

int value;
auto another = value;  // 定义了 'int another'

string const &text();
auto str = text();
// text 的基本类型是 string,所以
// 定义了 string str,而不是 string const str

str += "...";  // 所以这是合法的

int *ip = &value;
auto ip2 = ip;  // 定义了 int *ip2

int *const &ptr = ip;
auto ip3 = ptr;         // 定义了 int *ip3,省略了 const &
auto const &ip4 = ptr;  // 定义了 int *const &ip4

在倒数第二个 auto 规格说明中,从右到左读取的标记(即引用到基本类型的部分)被省略了:这里 const & 被附加到 ptr 的基本类型 int *。因此,定义了 int *ip2

在最后一个 auto 规格说明中,auto 也生成了 int *,但在类型定义中,const & 被添加到由 auto 生成的类型中,因此定义了 int *const &ip4

auto 关键字还可以用于推迟函数返回类型的定义。一个返回指向 10 个 int 数组的指针的函数声明如下:

int (*intArrPtr())[10];

这样一个声明相当复杂。例如,除了其他复杂性外,它还需要使用括号来“保护”指针与函数的参数列表。在这种情况下,返回类型的规格说明可以使用 auto 推迟定义,接着在函数的任何其他规格说明(例如,作为 const 成员(见第 7.7 节)或跟随 noexcept 规格说明(见第 23.8 节))之后指定函数的返回类型。

使用 auto 声明上述函数,声明变成了:

auto intArrPtr() -> int (*)[10];

使用 auto 的返回类型规格说明被称为“晚指定返回类型”。

从 C++14 标准开始,对于返回 auto 的函数,不再需要晚指定返回类型。这些函数现在可以简单地声明如下:

auto autoReturnFunction();

在这种情况下,函数定义和声明都有一些限制:

  • 如果函数定义中使用了多个 return 语句,它们必须返回相同类型的值;
  • 仅仅返回 auto 的函数在编译器看到其定义之前不能使用,因此它们不能在仅有声明之后使用;
  • 当返回 auto 的函数实现为递归函数时,至少在递归调用之前必须看到一个 return 语句。例如:
auto fibonacci(size_t n)
{
    if (n <= 1)
        return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

结构化绑定声明

通常,函数返回单一值,如 doubleintstring 等。当函数需要返回多个值时,通常会使用按引用返回的方式,通过将外部变量的地址传递给函数,使得函数能够为这些变量赋予新值。

当函数需要返回多个值时,可以使用 structpair(参见第12.2节)或 tuple(参见第22.6节)。以下是一个简单的例子,函数 fun 返回一个包含两个数据字段的 struct

struct Return {
    int first;
    double second;
};

Return fun() {
    return Return{ 1, 12.5 };
}

(简要地参考第12.2节和第22.6节:如果 fun 返回的是 pairtuple,可以完全省略 struct 定义。在这些情况下,以下代码仍然有效。)

传统上,调用 fun 的函数会定义一个与 fun 的返回类型相同的变量,然后使用该变量的字段来访问 firstsecond。如果你不喜欢这种类型定义,也可以使用 auto

int main() {
    auto r1 = fun();
    cout << r1.first;
}

除了引用返回的 structpairtuple 的元素外,还可以使用结构化绑定声明。这里,auto 后面跟着一个用方括号括起来的以逗号分隔的变量列表,其中每个变量都被定义,并接收调用函数返回值中对应字段或元素的值。因此,上面的 main 函数也可以写成这样:

int main() {
    auto [one, two] = fun();
    cout << one;  // one 和 two 现在已定义
}

仅指定 auto 会导致 fun 的返回值被复制,结构化绑定变量将引用复制的值。但是,结构化绑定声明也可以与(左值/右值)返回值结合使用。以下确保 ronertwo 引用 fun 的匿名返回值的元素:

int main() {
    auto &&[rone, rtwo] = fun();
}

如果调用的函数返回一个在函数调用本身之后仍然存在的值,则结构化绑定声明可以使用左值引用。例如:

Return &fun2() {
    static Return ret{ 4, 5 };
    return ret;
}

int main() {
    auto &[lone, ltwo] = fun2(); // OK:引用 ret 的字段
}

使用结构化绑定声明不一定要通过函数调用。提供数据的对象也可以匿名定义:

int main() {
    auto const &[lone, ltwo] = Return{ 4, 5 };
    // 或:
    auto &&[lone, ltwo] = Return{ 4, 5 };
}

对象的成员数据甚至不需要公开。有关使用结构化绑定而不必引用数据成员的内容,请参见第TUPLES节。

另一个应用场景是当 for 或选择语句的嵌套语句从使用各种类型的局部变量中受益时。这些变量可以通过结构化绑定声明,从匿名的 structpairtuple 中轻松定义。以下是一个说明这一点的例子:

// 定义一个结构体:
struct Three {
    size_t year;
    double firstAmount;
    double interest;
};

// 定义一个 Three 对象的数组,并逐个处理:
Three array[10];
fill(array);  // 这里没有实现

for (auto &[year, amount, interest] : array)
    cout << "Year " << year << ": amount = " << amount << '\n';

在使用结构化绑定时,结构化绑定声明必须指定所有可用的元素。因此,如果一个结构体有四个数据成员,结构化绑定声明必须定义四个元素。为了避免未使用变量的警告,至少必须使用结构化绑定声明中的一个变量。

typedef 和 using 声明用于定义类型别名

在 C++ 中,typedef 通常用于定义复杂类型的简写。假设我们要定义一个简写,用于表示“一个接受 doubleint 并返回 unsigned long long int 的函数的指针”。这样的函数可以是:

unsigned long long int compute(double, int);

这样的函数指针形式如下:

unsigned long long int (*pf)(double, int);

如果这种指针经常使用,可以考虑使用 typedef 来定义它。只需在前面加上 typedef,指针的名称将变成类型的名称。可以将其大写以使其更显著地作为类型名称:

typedef unsigned long long int (*PF)(double, int);

定义了这种类型后,可以用它来声明或定义这样的指针:

PF pf = compute;  // 将指针初始化为指向类似 'compute' 的函数

函数 fun 也可以使用这个定义来接受一个函数指针作为参数:

void fun(PF pf);  // fun 期望一个类似 'compute' 的函数指针

然而,将指针包含在 typedef 中可能不是一个很好的主意,因为这样会掩盖 pf 实际上是一个指针的事实。毕竟,PF pf 看起来更像是 int x 而不是 int *x。为了明确 pf 实际上是一个指针,可以稍微修改 typedef

typedef unsigned long long int FUN(double, int);
FUN *pf = compute;  // 现在 pf 明确是一个指针

typedef 的作用范围限制在编译单元内。因此,typedef 通常被嵌入到头文件中,这些头文件随后被多个源文件包含,以便在这些源文件中使用 typedef

除了 typedef,C++ 还提供了 using 关键字来关联一个类型和一个标识符。在实践中,typedefusing 可以互换使用。using 关键字通常会产生更可读的类型定义。考虑以下三种等效的定义:

  • 传统的 C 风格的类型定义,将类型名称嵌入到定义中(将变量名称变为类型名称):

    typedef unsigned long long int FUN(double, int);
    
  • 使用 using 关键字来提高类型名称的可读性,将类型名称移到定义的前面:

    using FUN = unsigned long long int (double, int);
    
  • 使用晚指定返回类型的另一种构造方法:

    using FUN = auto (double, int) -> unsigned long long int;
    

基于范围for 循环

C++ 的 for 语句与 C 的 for 语句相同:

for (init; cond; inc)
    statement

通常,初始化、条件和增量部分比较明显,例如在需要处理数组或向量的所有元素时。许多语言提供了 foreach 语句来处理这种情况,而 C++ 提供了 std::for_each 泛型算法(参见第 19.1.18 节)。

除了传统的语法,C++ 还为 for 语句增加了新的语法:基于范围 for 循环。这种新语法可以用来依次处理范围内的所有元素。范围有三种类型:

  • 普通数组(例如 int array[10]);
  • 初始化列表;
  • 标准容器(或类似容器)(参见第 12 章);
  • 任何其他提供 begin()end() 函数并返回所谓迭代器的类型(参见第 18.2 节)。

以下是可用的附加 for 语句语法:

// 假设 int array[30]
for (auto &element : array)
    statement

冒号左边的部分称为 for 范围声明。声明的变量(element)是一个形式名称;可以使用任何标识符。该变量仅在嵌套语句中可用,它引用(或是)范围内每个元素,从第一个元素到最后一个元素。

没有正式要求使用 auto,但在许多情况下使用 auto 是非常有用的。不仅在范围引用一些复杂类型的元素时,甚至在你知道你可以对范围中的元素执行什么操作但不关心它们的确切类型名称时也是如此。在上述示例中,也可以使用 int

引用符号(&)在以下情况下非常重要:

  • 如果你想在嵌套语句中修改元素;
  • 如果元素本身是结构体(或类,参见第 7 章)。

当省略引用符号时,变量将是范围内每个后续元素的副本。对于原始类型的变量,这通常没问题,但如果你有一个 BigStruct 元素的数组,则效率不高,尤其是在处理大结构体时:

struct BigStruct
{
    double array[100];
    int last;
};

效率低下,因为你不需要复制数组元素。相反,应该使用对元素的引用:

BigStruct data[100];  // 假设已在其他地方正确初始化
int countUsed() {
    int sum = 0;
    // const &: 元素未被修改
    for (auto const &element : data) sum += element.last;
    return sum;
}

基于范围 for 循环也可以从结构化绑定中受益。如果 struct Element 包含 int keydouble value,并且需要将所有正键的值相加,那么以下代码片段可以完成这个任务:

Element elems[100];
double sum = 0;
// 以某种方式初始化
for (auto const &[key, value] : elems) {
    if (key > 0)
        sum += value;
}

C++23 标准还支持基于范围 for 循环的可选初始化部分(类似于已有的 if 和 switch 语句)。假设需要将数组的元素插入到 cout 中,但在每个元素之前我们想要显示元素的索引。索引变量在 for 语句外部不会使用,C++23 标准提供的扩展允许我们将索引变量局部化。以下是一个示例:

// 局部化 idx: 仅在 for 语句中可见
for (size_t idx = 0; auto const &element : data)
    cout << idx++ << ": " << element << '\n';

原始字符串字面量

标准的 ASCII 字符串(即 C 字符串)由双引号限定,支持转义序列如 \n\\\",并以 0 字节结尾。这类 ASCII 字符串通常被称为以 null 终止的字节字符串(单数:NTBS,复数:NTBSs)。C 的 NTBS 是大量代码构建的基础。

在某些情况下,能够避免使用转义序列是很有吸引力的(例如,在 XML 的上下文中)。C++ 允许通过使用原始字符串字面量来实现这一点。

原始字符串字面量以 R 开头,后跟双引号,后面可选地跟一个标签(标签是一个任意非空白字符的序列,后跟 ())。原始字符串在关闭的括号 ) 结束,后面跟上标签(如果在开始原始字符串字面量时指定了),然后是一个双引号。以下是一些示例:

R"(A Raw \ "String")"
R"delimiter(Another \ Raw "(String))delimiter"

在第一个例子中,"( 和 )" 之间的所有内容都是字符串的一部分。转义序列不被支持,因此第一个原始字符串字面量中的 \ " 定义了三个字符:反斜杠、空白字符和双引号。第二个示例显示了在标记 "delimiter()delimiter" 之间定义的原始字符串。

原始字符串字面量在处理长且复杂的 ASCII 字符串序列(例如,使用信息或长 HTML 序列)时非常有用。最终,它们仅仅是长的 NTBS。为了保持使用代码的可读性,这些长的原始字符串字面量应与使用它们的代码分开。

例如:bisonc++ 解析器生成器支持一个选项 +NOTRANS(-{}-{})prompt。当指定时,bisonc++ 生成的代码在调试时插入提示代码。将原始字符串字面量直接插入处理提示代码的函数中,会导致代码非常难以阅读:

void prompt(ostream &out) {
    if (d_genDebug)
        out << (d_options.prompt() ? R"(
        if (d_debug__)
        {
            s_out__ << "\n================\n"
            "? " << dflush__;
            std::string s;
            getline(std::cin, s);
        })"
        :
        R"(if (d_debug__)
            s_out__ << '\n';)")
            << '\n';
}

通过在源文件的匿名命名空间中定义原始字符串字面量作为命名的 NTBS,可以大大提高可读性:

namespace {
char const noPrompt[] =
    R"(
        if (d_debug__)
        s_out__ << '\n';
    )";
char const doPrompt[] =
R"(
    if (d_debug__)
    {
        s_out__ << "\n================\n"
        "? " << dflush__;
        std::string s;
        getline(std::cin, s);
    }
)";
}  // 匿名命名空间

void prompt(ostream &out) {
    if (d_genDebug) out << (d_options.prompt() ? doPrompt : noPrompt) << '\n';
}

二进制常量

除了十六进制整数常量(以 0x 开头)、八进制整数常量(以 0 开头)和十进制整数常量(以 1 至 9 的某个数字开头),二进制整数常量可以使用前缀 0b0B 来定义。例如,要表示十进制值 5,可以使用表示为 0b101 的二进制表示法。

在处理位标志等情况时,二进制常量非常有用,因为它可以直接显示哪些位字段被设置,而其他表示法则不够直观。

带初始化的选择语句

标准的重复语句(如 for 循环)允许使用可选的初始化子句。初始化子句使我们能够将变量的作用域限制在 for 语句内。初始化子句也可以用于选择语句中。

考虑以下情况:如果从标准输入流中读取的下一行等于 “go!”,则应执行某个操作。传统上,在函数内部使用时,为了尽可能将字符串的作用域限制在包含下一行内容的范围内,需要使用如下构造:

void function()
{
    // ... 其他语句
    {
        string line;
        // 限制 line 的作用域
        if (getline(cin, line))
            action();
    }
    // ... 其他语句
}

由于初始化子句也可以用于选择语句(ifswitch 语句)(请注意,对于选择语句,分号是初始化子句的一部分,这与 for 语句中可选的初始化子句(无分号)不同),我们可以将上述示例重新表述为:

void function()
{
    // ... 其他语句
    if (string line; getline(cin, line))
        action();
    // ... 其他语句
}

请注意,变量仍然可以在实际的条件子句中定义。这对于扩展的 ifswitch 语句都是适用的。然而,在使用条件子句之前,可以使用初始化子句来定义额外的变量(可以包含用逗号分隔的变量列表,类似于 for 语句的语法)。

属性(Attributes)

属性是插入到源文件中的编译器指令,用于通知编译器某些代码(变量或函数)的特殊性。属性用于告知编译器代码的某些情况是有意的,从而防止编译器发出警告。

以下是一些已识别的属性:

  • [[carries_dependency]]
    目前这个属性在 C++ 注释中尚未覆盖。在 C++ 注释中,它可以被安全地忽略。

  • [[deprecated]]
    从 C++14 标准开始,这个属性(及其替代形式 [[deprecated(“reason”)]])可用。它表示使用带有此属性的名称或实体是允许的,但不推荐使用。该属性可以用于类、typedef 名称、变量、非静态数据成员、函数、枚举和模板特化。当编译器遇到 [[deprecated]] 属性时,会生成一个警告。例如:

    demo.cc:12:24: warning: 'void deprecatedFunction()' is deprecated
    [-Wdeprecated-declarations] deprecatedFunction();
    demo.cc:5:21: note: declared here
    [[deprecated]] void deprecatedFunction()
    

    使用替代形式时(例如 [[deprecated(“do not use”)]] void fun()),编译器会生成一个显示双引号之间文本的警告,例如:

    demo.cc:12:24: warning: 'void deprecatedFunction()' is deprecated:
    do not use [-Wdeprecated-declarations]
    deprecatedFunction();
    demo.cc:5:38: note: declared here
    [[deprecated("do not use")]] void deprecatedFunction()
    
  • [[fallthrough]]
    当 switch 语句中的 case 条件下的语句继续执行到后续的 case 或 default 条目时,编译器会发出“falling through”警告。如果这种“fall through”是有意的,应使用属性 [[fallthrough]],并且后面必须跟一个分号。示例:

    void function(int selector)
    {
        switch (selector) {
            case 1:
            case 2:
                // 没有“fall through”,只是合并的入口点
                cout << "cases 1 and 2\n";
                [[fallthrough]];
            // 没有警告:有意的“fall through”
            case 3:
                cout << "case 3\n";
            case 4:
                // 警告:没有宣布的“fall through”
                cout << "case 4\n";
                [[fallthrough]];
            // 错误:之后没有任何内容
        }
    }
    
  • [[maybe_unused]]
    该属性可以应用于类、typedef 名称、变量、参数、非静态数据成员、函数、枚举或枚举成员。当将此属性应用于一个实体时,当该实体未被使用时不会生成警告。示例:

    void fun([[maybe_unused]] size_t argument)
    {
        // argument 没有使用,但不会发出警告
    }
    
  • [[nodiscard]]
    该属性可以在声明函数、类或枚举时指定。如果一个函数被声明为 [[nodiscard]] 或一个函数返回的实体之前被声明为 [[nodiscard]],那么该函数的返回值只能被显式地强制转换为 void,否则当返回值未被使用时,会发出警告。示例:

    int [[nodiscard]] importantInt();
    struct [[nodiscard]] ImportantStruct { ... };
    ImportantStruct factory();
    int main()
    {
        importantInt();  // 发出警告
        factory(); // 发出警告
    }
    
  • [[noreturn]]
    [[noreturn]] 表示函数不会返回。 如果声明了此属性的函数实际上会返回,则其行为是未定义的。

以下标准函数具有此属性:

  • std::_Exit
  • std::abort
  • std::exit
  • std::quick_exit
  • std::unexpected
  • std::terminate
  • std::rethrow_exception
  • std::throw_with_nested
  • std::nested_exception::rethrow_nested

以下是使用 [[noreturn]] 属性的函数声明和定义示例:

[[noreturn]] void doesntReturn();

[[noreturn]] void doesntReturn()
{
    exit(0);
}

三路比较 (<=>)

C++23 标准为 C++ 添加了三路比较运算符 <=>,也称为飞船运算符。在 C++ 中,运算符可以为类类型定义,其中包括相等和比较运算符(熟悉的 ==!=<<=>>= 运算符)。为了为类提供所有比较运算符,只需定义相等运算符和三路比较运算符。

三路比较运算符的优先级低于位移运算符 <<>> 的优先级,但高于排序运算符 <<=>>= 的优先级。

有关三路比较运算符的构造,请参见第 11.7.2 节。

新的语言定义数据类型

在 C 语言中,以下内建数据类型可用:voidcharshortintlongfloatdouble。C++ 在这些内建类型的基础上扩展了几个附加的内建类型:boolwchar_tlong longlong double(参见 ANSI/ISO 草案(1995)第 27.6.2.4.1 节了解这些非常长的类型的示例)。long long 类型实际上是一个双长的 long 类型。long double 类型实际上是一个双长的 double 类型。这些内建类型以及指针变量被称为 C++ 注释中的原始类型。

在将为 32 位架构开发的应用程序转换为 64 位架构时,需要注意一个微妙的问题。在将 32 位程序转换为 64 位程序时,只有 long 类型和指针类型的大小从 32 位变为 64 位;int 类型的大小保持为 32 位。这可能会导致在将指针或 long 类型赋值给 int 类型时发生数据截断。此外,将比 int 类型大小短的类型的表达式赋值给 unsigned long 或指针时,可能会出现符号扩展的问题。

除了这些内建类型外,string 类类型可用于处理字符字符串。boolwchar_t 数据类型在接下来的章节中会介绍,string 数据类型在第 5 章中会介绍。请注意,最近版本的 C 语言也可能采用了一些这些较新的数据类型(特别是 boolwchar_t)。然而,传统上 C 语言不支持它们,因此在此提及。

现在介绍了这些新类型后,我们来回顾一下可以用于各种类型的文字常量的字母。它们包括:

  • bB:除了作为十六进制值使用外,它也可以用于定义二进制常量。例如,0b101 等于十进制值 5。从 C++14 标准开始,可以使用 0b 前缀来指定二进制常量。
  • Ee:浮点数文字值中的指数符号。例如:1.23E+3。在这里,E 应该被读作(并解释为):乘以 10 的幂。因此,1.23E+3 代表值 1230。
  • F:可以作为后缀用于非整数数字常量,以指示浮点类型 float,而不是默认的 double。例如:12.F(点将 12 转换为浮点值);1.23E+3F(参见上面的例子。1.23E+3double 值,而 1.23E+3Ffloat 值)。
  • L:可以作为前缀来指示字符字符串,其元素为 wchar_t 类型的字符。例如:L"hello world"
  • L:可以作为后缀用于整数值,以指示 long 类型的值,而不是默认的 int 类型。请注意,没有表示 short 类型的字母。对于 short 类型,必须使用 static_cast<short>()
  • p:用于指定十六进制浮点数中的幂。例如 0x10p4。指数部分以十进制常量的形式读取,因此不能以 0x 开头。指数部分被解释为 2 的幂。因此,0x10p2 在十进制中等于 64:16 * 2^2
  • U:可以作为后缀用于整数值,以指示无符号值,而不是 int。它也可以与后缀 L 结合使用,以生成无符号长整型值。
  • 当然:xaf 字符可以用于指定十六进制常量(可选地使用大写字母)。

数据类型 bool

类型 bool 表示布尔(逻辑)值,可以使用现在保留的常量 truefalse。除了这些保留值外,整型值也可以被赋值给 bool 类型的变量,这些整型值会根据以下转换规则被隐式转换为 truefalse(假设 intValue 是一个 int 类型变量,boolValue 是一个 bool 类型变量):

// 从 int 到 bool:
bool Value = intValue ? true : false;

// 从 bool 到 int:
int Value = boolValue ? 1 : 0;

此外,当 bool 值被插入到流中时,true 代表 1,false 代表 0。考虑以下示例:

cout << "A true value: " << true << "\n"
     << "A false value: " << false << '\n';

bool 数据类型在其他编程语言中也存在。Pascal 有其 Boolean 类型;Java 有 boolean 类型。与这些语言不同的是,C++ 的 bool 类型像一种整数类型。它主要是一个改善文档的类型,只有两个值 truefalse。实际上,这些值可以被解释为 1 和 0 的枚举值。这样做虽然忽略了 bool 数据类型的哲学,但赋值 trueint 变量不会产生警告或错误。

使用 bool 类型通常比使用 int 更清晰。考虑以下原型:

bool exists(char const *fileName); // (1)
int exists(char const *fileName); // (2)

在第一个原型中,读者会期望函数在给定的文件名存在时返回 true。然而,在第二个原型中,会出现一些模糊性:直观上,返回值 1 是有吸引力的,因为它允许类似这样的构造:

if (exists("myfile"))
    cout << "myfile exists";

另一方面,许多系统函数(如 accessstat 和许多其他函数)返回 0 来表示成功操作,保留其他值来表示各种类型的错误。

作为一个经验法则,我建议如下:如果函数应该向调用者报告其任务的成功或失败,让函数返回一个 bool 值。如果函数应该返回成功或各种类型的错误,让函数返回枚举值,通过其各种符号常量来记录情况。只有当函数返回一个概念上有意义的整型值(如两个 int 值的和)时,才让函数返回 int 值。

数据类型 wchar_t

wchar_t 类型是 char 内置类型的扩展,用于处理宽字符值(但也请参阅下一节)。g++ 编译器报告 sizeof(wchar_t) 为 4,这足以容纳所有 65,536 种不同的 Unicode 字符值。

需要注意的是,Java 的 char 数据类型在某种程度上与 C++ 的 wchar_t 类型类似。然而,Java 的 char 类型宽度为 2 字节。另一方面,Java 的 byte 数据类型与 C++ 的 char 类型类似:都是一个字节。这会让人感到困惑吗?

Unicode 编码

在 C++ 中,字符串字面量可以定义为 NTBS。通过在 NTBS 前面添加 L(例如 L"hello"),可以定义 wchar_t 字符串字面量。

C++ 还支持 8 位、16 位和 32 位的 Unicode 编码字符串。此外,新增了两个数据类型:char16_tchar32_t,分别用于存储 UTF-16 和 UTF-32 的 Unicode 值。

char 类型的值适合存储 UTF-8 编码的 Unicode 值。对于字符集超过 256 个不同值的情况,应该使用更宽的类型(如 char16_tchar32_t)。

不同类型 Unicode 编码的字符串字面量(以及相关变量)可以按如下方式定义:

char utf_8[] = u8"This is UTF-8 encoded.";
char16_t utf16[] = u"This is UTF-16 encoded.";
char32_t utf32[] = U"This is UTF-32 encoded.";

另外,Unicode 常量可以使用 \u 转义序列定义,后跟一个十六进制值。根据 Unicode 变量(或常量)的类型,使用 UTF-8、UTF-16 或 UTF-32 的值。例如:

char utf_8[] = u8"\u2018";
char16_t utf16[] = u"\u2018";
char32_t utf32[] = U"\u2018";

Unicode 字符串可以用双引号分隔,但也可以使用原始字符串字面量。

long long int

C++ 还支持 long long int 类型。在 32 位系统上,它至少有 64 位可用。

size_t数据类型

size_t 类型实际上不是内建的基本数据类型,而是 POSIX 规定的一个类型,用于表示非负整数值,如“多少”或“多少个”的问题。在这种情况下,应该使用 size_t 而不是 unsigned int。它不是 C++ 特有的类型,也在 C 语言中可用。通常在包含任何系统头文件时,size_t 会被隐式定义。在 C++ 中,size_t 由头文件 cstddef 正式定义。

使用 size_t 的好处在于它是一个概念性的类型,而不是通过修饰符修改的标准类型。这样可以提高源代码的自文档化价值。

可以使用多个后缀显式指定整型常量的表示方式,例如 42UL 定义 42 为 unsigned long int。类似地,后缀 uzzu 可以用于指定整型常量表示为 size_t,如:cout << 42uz

有时函数明确要求使用 unsigned int。例如,在 AMD 架构上,X-Windows 函数 XQueryPointer 明确要求其参数之一是指向 unsigned int 变量的指针。在这种情况下,不能使用指向 size_t 变量的指针,必须提供 unsigned int 的地址。这类情况虽然特殊,但存在。

其他有用的位表示类型也存在。例如,uint32_t 保证可以存储 32 位无符号值。类似地,int32_t 存储 32 位有符号值。对应的类型也存在用于 8 位、16 位和 64 位的值。这些类型在头文件 cstdint 中定义,当需要指定或使用固定大小的整型值时,它们非常有用。

std::byte 数据类型

在许多情况下,需要使用 8 位变量,通常用于访问内存位置。传统上,char 类型被用于此目的,但 char 是一个有符号类型,并且当将 char 变量插入到流中时,会使用字符的表示而不是其值。更重要的是,当仅使用 char 类型变量的(无符号)值时,char 的使用容易引起混淆,因为 char 类型文档表示的是文本而非仅仅是 8 位值,这通常用于最小的可寻址内存位置。

不同于 char 类型,std::byte 类型旨在仅表示 8 位值。要使用 std::byte,必须包含 <cstddef> 头文件。std::byte 被定义为一个强类型枚举,简单地嵌入了 unsigned char

enum class byte: unsigned char {};

由于 byte 是没有预定义值的枚举,普通赋值只能在 byte 类型值之间进行。byte 变量可以通过花括号初始化,花括号内可以是现有的 byte 值或最多 8 位的固定值(见示例中的 #1)。如果指定的值不适合 8 位(见示例中的 #2),或者指定的值既不是 byte 也不是 unsigned char 类型变量(见示例中的 #3),编译器会报告错误。

使用括号初始化的右值也是被接受的,只要这些值不适合 8 位的 byte 类型(见示例中的 #4 和 #5)。在这些情况下,指定的值会被截断为其最低的 8 位。示例如下:

byte value{0x23};  // #1
// byte error{ 0x123 }; // #2
char ch = 0xfb;  // byte error{ ch }; // #3
byte b1 = byte(ch);   // #4
value = byte(0x123);  // #5

byte 类型支持所有位运算,但位运算符的右操作数也必须是 byte 类型。例如:

value &= byte(0xf0);

byte 类型的值也可以进行顺序比较和等值比较。

不幸的是,std::byte 不支持其他操作。例如,byte 不能进行加法,也不能插入到或从流中提取,这使得 std::byte 比普通类型(如 unsigned intuint16_t)的用途更有限。当需要这些操作时,可以使用类型转换(在第 3.5 节中讨论),但通常建议尽可能避免使用类型转换。然而,C++ 允许我们定义一个表现得像普通数值类型的 byte 类型,包括将其值插入到流中或从流中提取。第 11.4 节中开发了这样一个类型。

数字分隔符

为了提高大型数字的可读性,可以对整数和浮点文字使用数字分隔符。数字分隔符是一个单引号,可以插入在这些文字的数字之间,以增强人类的可读性。可以使用多个数字分隔符,但在连续的数字之间只能插入一个分隔符。例如:

1'000'000
3.141'592'653'589'793'238'5

多个单引号或多个分隔符之间的插入是无效的,例如:

''123  // 编译错误
1''23   // 编译错误

新的类型转换语法

传统上,C语言提供了以下类型转换语法:

(typename)expression

这里 typename 是有效类型的名称,而 expression 是一个表达式。

C风格的类型转换现在已被弃用。C++程序应仅使用新的C++风格的类型转换,因为它们提供了编译器检查类型转换合理性的功能,而这些功能是传统的C风格类型转换所不具备的。

类型转换不应与常用的构造函数语法混淆:

typename(expression)

构造函数语法不是类型转换,而是请求编译器从 expression 构造一个(匿名)类型为 typename 的变量。

如果确实需要进行类型转换,应使用以下几种新风格的类型转换之一。这些新风格的类型转换将在接下来的章节中介绍。

static_cast 操作符

static_cast<type>(expression) 用于将“概念上可比较或相关的类型”相互转换。在这里,type 是目标类型,expression 是要转换的表达式的类型。

以下是 static_cast 可以(或应该)使用的一些情况:

  • int 转换为 double
    例如,当需要计算两个 int 值的商时,必须保留除法的分数部分。以下代码片段中的 sqrt 函数返回值为 2:

    int x = 19;
    int y = 4;
    sqrt(x / y);
    

    而当使用 static_cast 时,返回值为 2.179,如下所示:

    sqrt(static_cast<double>(x) / y);
    

    这里需要注意的重要一点是,static_cast 允许将表达式的表示形式更改为目标类型使用的表示形式。还要注意,除法操作在转换表达式之外进行。如果除法操作在 static_cast 表达式内部(如 static_cast<double>(x / y)),在转换之前已经进行了整数除法。

  • enum 值转换为 int 值(或反向转换):
    这里这两种类型使用相同的表示形式,但具有不同的语义。将普通的 enum 值赋给 int 不需要强制转换,但当 enum 是强类型枚举时,则需要进行强制转换。相反,当将 int 值赋给某个 enum 类型的变量时,也需要使用 static_cast。以下是一个示例:

      enum class Enum
      {
      };
      VALUE
      cout << static_cast<int>(Enum::VALUE);
    
  • 当转换相关的指针类型时:
    static_cast 用于类继承的上下文中,将指向所谓的“派生类”的指针转换为指向其“基类”的指针。它不能用于将不相关的类型之间的指针进行转换(例如,static_cast 不能将指向 short 的指针转换为指向 int 的指针)。

  • void * 是一个通用指针。它常常被 C 库中的函数(例如 memcpy(3))使用。由于它是通用指针,因此它与任何其他指针类型相关联,使用 static_castvoid * 转换为目标指针是合适的。这是一种遗留的做法,应该仅在这种情况下使用。例如:
    C 库中的 qsort 函数期望一个具有两个 void const * 参数的(比较)函数。实际上,这些参数指向待排序数组的数据元素,因此比较函数必须将 void const * 参数转换为指向待排序数组元素的指针。因此,如果数组是 int array[],并且比较函数的参数是 void const *p1void const *p2,那么比较函数通过以下方式获得 p1 指向的 int 的地址:

    static_cast<int const *>(p1);
    
  • 当撤销或引入 int 类型变量的符号修饰符时(记住 static_cast 允许改变表达式的表示!)。例如:
    C 函数 tolower 需要一个表示 unsigned char 值的 int。但 char 默认是一个有符号类型。为了使用 tolower,可以用如下方式:

    tolower(static_cast<unsigned char>(ch));
    

const_cast 运算符

const 关键字在类型转换中有一个特殊的地位。通常,任何 const 类型的存在都是有原因的。然而,在某些特殊情况下,可能会遇到可以忽略 const 的情况。对于这些特殊情况,应该使用 const_cast。其语法为:

const_cast<type>(expression)

const_cast<type>(expression) 用于取消(或添加)一个指针类型的 const 属性。

使用 const_cast 的需求可能会出现在标准 C 库的函数中,这些函数传统上并不总是像应有的那样处理 const。例如,一个函数 strfun(char *s) 可能会存在,它对其 char *s 参数执行某些操作,但实际上并不修改 s 指向的字符。将 char const hello[] = "hello"; 传递给 strfun 会产生警告:

passing `const char *' as argument 1 of `fun(char *)' discards const

使用 const_cast 是防止该警告的合适方法:

strfun(const_cast<char *>(hello));

reinterpret_cast 运算符

第三种新式类型转换是 reinterpret_cast,用于改变信息的解释方式。它有点类似于 static_cast,但 reinterpret_cast 仅在确切知道信息可以被解释为完全不同的东西时使用。其语法为:

reinterpret_cast<pointer type>(pointer expression)

可以将 reinterpret_cast 视为一种贫民版的联合体:相同的内存位置可以以完全不同的方式进行解释。

reinterpret_cast 例如在与用于流的 write 函数结合使用时非常有用。在 C++ 中,流是与磁盘文件等交互的首选接口。标准流如 std::cinstd::cout 也是流对象。

用于写入的流(例如 cout)提供了 write 成员函数,其原型为:

write(char const *buffer, int length)

为了以未解释的二进制形式将 double 变量中的值写入流,使用流的 write 成员。然而,由于 double *char * 指向的变量使用不同且不相关的表示,不能使用 static_cast。在这种情况下,需要使用 reinterpret_cast。要将变量 double 的原始字节写入 cout,可以使用:

cout.write(reinterpret_cast<char const *>(&value), sizeof(double));

所有类型转换都可能是危险的,但 reinterpret_cast 是其中最危险的。实际上,我们告诉编译器:别管了,我们知道我们在做什么,所以不要再纠缠了。在这种情况下,一切都不确定,我们最好知道自己在做什么。例如考虑以下代码:

int value = 0x12345678;
// 假设是 32 位 int
cout << "Value's first byte has value: " << hex <<
static_cast<int>(
    *reinterpret_cast<unsigned char *>(&value)
);

上述代码在小端和大端计算机上会产生不同的结果。小端计算机显示值 78,大端计算机显示值 12。还要注意,小端和大端计算机使用的不同表示使得前面的示例(cout.write(...))在不同架构的计算机上不可移植。

作为一个经验法则:如果出现需要使用类型转换的情况,请在代码中清晰地记录使用这些转换的原因,并确保转换不会最终导致程序出现异常。同时:除非必须,否则应避免使用 reinterpret_cast

dynamic_cast 运算符

最后,有一种新式类型转换运算符用于与多态性结合使用(见第14章)。其语法为:

dynamic_cast<type>(expression)

static_cast 的行为完全由编译时确定不同,dynamic_cast 的操作在运行时决定,用于将某个类(例如 Base)的对象指针转换为另一个类(例如 Derived)的对象指针,这个类在所谓的类层次结构中进一步向下(这也称为向下转换)。

在此注释中,dynamic_cast 还不能被详细讨论,但我们将在第14.6.1节中回到这个话题。

shared_ptr 对象的类型转换

本节可以安全地跳过而不会影响内容的连续性。

shared_ptr 类的上下文中(第18.4节中讨论),有几种新的类型转换可用。实际的这些专门化的类型转换将推迟到第18.4.5节进行详细讨论。这些专门化的类型转换包括:

  • static_pointer_cast:返回指向派生类对象的基类部分的 shared_ptr
  • const_pointer_cast:从指向常量对象的 shared_ptr 返回指向非 const 对象的 shared_ptr
  • dynamic_pointer_cast:从指向基类对象的 shared_ptr 返回指向派生类对象的 shared_ptr

C++ 中的关键字和保留名称

C++ 的关键字是 C 关键字的超集。以下是语言中所有关键字的列表:

  • alignas char16_t double long reinterpret_cast true
  • alignof char32_t dynamic_cast module requires try
  • and class else mutable return typedef
  • and_eq co_await enum namespace short typeid
  • asm co_return explicit new signed typename
  • atomic_cancel co_yield export noexcept sizeof union
  • atomic_commit compl extern not static unsigned
  • atomic_noexcept concept false not_eq static_assert using
  • auto const float nullptr static_cast virtual
  • bitand const_cast for operator struct void
  • bitor constexpr friend or switch volatile
  • bool continue goto or_eq synchronized wchar_t
  • break decltype if private template while
  • case default import protected this xor
  • catch char delete do inline int
  • public thread_local register throw xor_eq

备注:

  • 自 C++17 标准以来,register 关键字不再使用,但它仍然是一个保留标识符。换句话说,像 register int index; 这样的定义会导致编译错误。register 也不再被视为存储类说明符(存储类说明符有 externthread_localmutablestatic)。
  • operator 关键字:
    • andand_eqbitandbitorcomplnotnot_eqoror_eqxorxor_eq&&&=&|~!!=|||=^^= 的符号替代。
  • C++ 还识别特殊标识符 finaloverridetransaction_safetransaction_safe_override。这些标识符在声明类或多态函数时具有特殊含义。第14.4节提供了更多细节。

关键字只能用于其预定的目的,不能用作其他实体(例如变量、函数、类名等)的名称。此外,以下划线开头并位于全局命名空间(即不使用任何显式命名空间或仅使用 :: 命名空间说明)的标识符或位于 std 命名空间中的标识符也是保留的,其使用是实现者的特权。

命名空间

假设有一位数学老师希望开发一个交互式数学程序。对于这个程序,需要使用类似 cossintan 等函数,这些函数接受以度为单位的参数,而不是弧度。不幸的是,函数名称 cos 已经被使用,而且这个函数接受的是弧度参数。

通常,这类问题的解决方案是定义另一个名称,例如,定义一个函数名称为 cosDegrees。C++ 提供了一种替代解决方案,即通过命名空间。命名空间可以被看作是在代码中定义标识符的区域或范围。在命名空间中定义的标识符通常不会与其他地方(即命名空间外部)已经定义的名称发生冲突。因此,可以在一个名为 Degrees 的命名空间中定义一个 cos 函数,该函数接受度数作为参数。当在 Degrees 命名空间内调用 cos 时,你将调用接受度数的 cos 函数,而不是接受弧度的标准 cos 函数。

定义命名空间

命名空间的定义语法如下:

namespace identifier
{
    // 声明或定义的实体
    // (声明区域)
}

在定义命名空间时使用的标识符是一个标准的 C++ 标识符。

在上述代码示例中引入的声明区域内,可以定义或声明函数、变量、结构体、类以及(嵌套的)命名空间。命名空间不能在函数体内定义。然而,可以使用多个命名空间声明来定义一个命名空间。命名空间是“开放的”,这意味着命名空间 CppAnnotations 可以在文件 file1.ccfile2.cc 中分别定义。然后,在文件 file1.ccfile2.cc 中定义的 CppAnnotations 命名空间中的实体会合并到一个 CppAnnotations 命名空间区域中。例如:

// 在 file1.cc 中
namespace CppAnnotations
{
    double cos(double argInDegrees)
    {
        // 实现
    }
}
// 在 file2.cc 中
namespace CppAnnotations
{
    double sin(double argInDegrees)
    {
        // 实现
    }
}

现在 sincos 都定义在同一个 CppAnnotations 命名空间中。

命名空间实体可以在它们的命名空间之外定义。这一主题在第4.1.4.1节中进行了讨论。

在命名空间中声明实体

除了在命名空间中定义实体外,还可以在命名空间中声明实体。这允许我们将所有声明放在一个头文件中,然后在源文件中包含该头文件,以使用在命名空间中定义的实体。这样的头文件可以包含,例如:

namespace CppAnnotations
{
    double cos(double degrees);
    double sin(double degrees);
}

闭合命名空间

命名空间可以不带名称定义。这种匿名命名空间限制了定义的实体的可见性,使其仅在定义了匿名命名空间的源文件中可见。

在匿名命名空间中定义的实体类似于 C 语言中的 static 函数和变量。在 C++ 中,static 关键字仍然可以使用,但其首选用法是在类定义中(见第7章)。在 C++ 中,类似于 C 语言中使用 static 变量或函数的情况,应该使用匿名命名空间。

匿名命名空间是一个闭合的命名空间:无法使用不同的源文件向同一个匿名命名空间中添加实体。

引用实体

给定一个命名空间及其实体,可以使用作用域解析运算符来引用这些实体。例如,可以这样使用 CppAnnotations 命名空间中定义的 cos() 函数:

// 假设 CppAnnotations 命名空间在以下头文件中声明:
#include <cppannotations>

int main()
{
    cout << "The cosine of 60 degrees is: " <<
    CppAnnotations::cos(60) << '\n';
}

这种方式引用 CppAnnotations 命名空间中的 cos() 函数相对繁琐,尤其是在函数被频繁使用的情况下。在这种情况下,可以在指定了 using 声明之后使用简写形式。如下所示:

using CppAnnotations::cos; // 注意:只需要实体名称,不需要函数原型

这样调用 cos 时,会调用 CppAnnotations 命名空间中定义的 cos 函数。这意味着标准的 cos 函数(接受弧度)不会被自动调用。要调用后者的 cos 函数,可以使用作用域解析运算符:

int main() {
    using CppAnnotations::cos;
    ... 
    cout << cos(60)      // 调用 CppAnnotations::cos()
         << ::cos(1.5)  // 调用标准的 cos() 函数
         << '\n';
}

using 声明可以具有受限的作用域。它可以在一个代码块内部使用。using 声明会防止定义与 using 声明中使用的名称相同的实体。无法在某个命名空间中为变量 value 指定 using 声明,然后在同一个代码块中定义(或声明)一个具有相同名称的对象。示例如下:

int main()
{
    using CppAnnotations::value;
    ...
    cout << value << '\n'; // 使用 CppAnnotations::value
    int value;// 错误:value 已经被声明。
}

using 指令

using 声明的一个更通用的替代方案是 using 指令:

using namespace CppAnnotations;

使用这个指令后,CppAnnotations 命名空间中定义的所有实体将被当作是通过 using 声明引入的。

虽然 using 指令是一种快速导入命名空间中所有名称的方法(前提是该命名空间已经被声明或定义),但同时它也有些不清晰,因为在特定的代码块中不容易确定实际使用的是哪个实体。例如,如果 cosCppAnnotations 命名空间中定义,则调用 cos 时将使用 CppAnnotations::cos。但是,如果 cosCppAnnotations 命名空间中未定义,则将使用标准的 cos 函数。using 指令没有像 using 声明那样清晰地记录实际使用的实体。因此,使用 using 指令时需要小心。

命名空间声明是上下文敏感的:当在一个复合语句内指定 using namespace 声明时,该声明在复合语句的闭合大括号遇到之前是有效的。在下例中,first 在没有明确指定 std::string 的情况下定义,但是一旦复合语句结束,using namespace std 声明的作用域也随之结束,因此在定义 second 时需要再次使用 std::

#include <string>
int main()
{
    {
        // ...
    }
    using namespace std;
    string first;
    std::string second;
}

using namespace 指令不能在类或枚举类型的声明块内使用。例如,以下示例将无法编译:

struct Namespace
{
    using namespace std; // 编译错误
};

‘Koenig lookup’

如果“Koenig 查找”被称为“Koenig 原则”,它可能会成为一部新的 Ludlum 小说的标题。然而,它并没有被这样称呼。相反,它指的是 C++ 的一个技术细节。

“Koenig 查找”指的是当调用一个函数而没有明确指定其命名空间时,函数调用会使用其参数类型的命名空间来确定函数的命名空间。如果参数类型定义所在的命名空间中包含这样的函数,则会使用该函数。这一过程称为“Koenig 查找”。

例如,考虑以下示例。函数 FBB::fun(FBB::Value v) 定义在 FBB 命名空间中。它可以在不明确提及其命名空间的情况下被调用:

#include <iostream>

namespace FBB {
    enum Value { FIRST };  // 定义了 FBB::Value

    void fun(Value x) { std::cout << "fun called for " << x << '\n'; }
}  // 命名空间 FBB

int main() {
    fun(FBB::FIRST);  // Koenig 查找:未指定 fun() 的命名空间
}

输出结果:

fun called for 0

编译器在处理命名空间时非常聪明。如果在 FBB 命名空间中 Value 被定义为 using Value = int,那么 FBB::Value 将被识别为 int,从而导致 Koenig 查找失败。

作为另一个示例,考虑以下程序。这里涉及两个命名空间,每个命名空间都定义了自己的 fun 函数。由于参数决定了命名空间,因此没有歧义,调用了 FBB::fun

#include <iostream>

namespace FBB {
    enum Value {
        FIRST  // 定义了 FBB::Value
    };

    void fun(Value x) { std::cout << "FBB::fun() called for " << x << '\n'; }
}

namespace ES {
    void fun(FBB::Value x) {}
}  // 命名空间 ES

int main() {
    fun(FBB::FIRST);  // 无歧义:参数决定了命名空间
}

输出结果:

FBB::fun() called for 0

在下面的示例中存在歧义:fun 有两个参数,每个命名空间中都有一个。必须由程序员解决这种歧义:

#include <iostream>

namespace ES {
    enum Value {
        FIRST  // 定义了 ES::Value
    };
}

namespace FBB {
    enum Value {
        FIRST  // 定义了 FBB::Value
    };

    void fun(Value x, ES::Value y) { std::cout << "FBB::fun() called\n"; }
}  // 命名空间 FBB

namespace ES {
    void fun(FBB::Value x, Value y) { std::cout << "ES::fun() called\n"; }
}  // 命名空间 ES

int main() {
    // fun(FBB::FIRST, ES::FIRST); 歧义:通过显式提及命名空间解决
    ES::fun(FBB::FIRST, ES::FIRST);
}

输出结果:

ES::fun() called

命名空间的一个有趣的细微之处是,一个命名空间中的定义可能会破坏在另一个命名空间中定义的代码。这表明命名空间可能会相互影响,如果我们不了解它们的特殊性,命名空间可能会带来意外的后果。考虑以下示例:

namespace FBB {
    struct Value {};
    void fun(int x);
    void gun(Value x);
}

namespace ES {
    void fun(int x) {
        fun(x);
    }
    
    void gun(FBB::Value x) {
        gun(x);
    }
}

无论发生什么,程序员最好不要使用 ES 命名空间中定义的任何函数,因为这会导致无限递归。然而,这并不是重点。重点是程序员甚至没有机会调用 ES::fun,因为编译失败了。为什么 ES::fun 编译通过而 ES::gun 编译失败呢?在 ES::fun 中,调用了 fun(x)。由于 x 的类型未在命名空间中定义,Koenig 查找不适用,fun 函数递归调用自身,导致无限递归。

ES::gun 中,参数的类型是在 FBB 命名空间中定义的。因此,FBB::gun 函数是一个可能的调用候选者。但是,ES::gun 自身也可能作为候选,因为 ES::gun 的原型完全匹配 gun(x) 的调用。

考虑一下如果 FBB::gun 尚未声明的情况。在这种情况下,当然没有歧义。负责 ES 命名空间的程序员可能会高枕无忧。然而,当负责 FBB 命名空间的程序员决定增加一个 gun(Value x) 函数时,突然间,ES 命名空间中的代码会因为在完全不同的命名空间(FBB)中的增加而破坏。显然,命名空间并不是完全独立的,我们应该注意这些细微之处。在 C++ 注释(第 11 章)中,我们会回到这个问题。

Koenig 查找仅在命名空间的上下文中使用。如果一个函数定义在命名空间外部,定义一个类型为在命名空间内定义的类型的参数,并且该命名空间也定义了一个具有相同签名的函数,则编译器在调用该函数时会报告歧义。以下是一个示例,假设上述的 FBB 命名空间也可用:

void gun(FBB::Value x);

int main(int argc, char **argv) {
    gun(FBB::Value{});  // 歧义:`FBB::gun` 和 `::gun` 都可以被调用。
}

标准命名空间

std 命名空间是 C++ 保留的。标准定义了许多属于运行时可用软件的实体(例如,coutcincerr);标准模板库(见第 18 章)中定义的模板;以及泛型算法(见第 19 章),这些都定义在 std 命名空间中。

关于前一节讨论的内容,using 声明可用于引用 std 命名空间中的实体。例如,为了使用 std::cout 流,可以如下声明这个对象:

#include <iostream>
using std::cout;

然而,通常情况下,可以不经多思考地接受 std 命名空间中定义的标识符。因此,程序员经常使用 using 指令,允许省略命名空间前缀来引用指定命名空间中的任何实体。与使用声明相比,以下 using 指令的构造是常见的:

#include <iostream>
using namespace std;

那么,应该使用 using 指令还是 using 声明?作为经验法则,可以决定坚持使用 using 声明,直到列表变得不切实际长,这时可以考虑使用 using 指令。

using 指令和声明有两个限制:

  • 程序员不应在 std 命名空间内声明或定义任何东西。这不是由编译器强制执行的,而是由标准对用户代码施加的;
  • 不应对第三方编写的代码强加 using 声明和指令。

实际上,这意味着 using 指令和声明应从头文件中禁止,仅应在源文件中使用(见第 7.11.1 节)。

嵌套命名空间和命名空间别名

命名空间可以嵌套。以下是一个示例:

namespace CppAnnotations {
    int value;
    namespace Virtual {
        void *pointer;
    }
}

变量 value 定义在 CppAnnotations 命名空间内。在 CppAnnotations 命名空间内部嵌套了另一个命名空间 Virtual,在这个命名空间内定义了变量 pointer。要引用这些变量,有以下几种选项:

  • 使用完全限定名
    完全限定名是一个包含所有命名空间的列表,直到到达实体的定义。命名空间和实体通过作用域解析运算符连接起来:

    int main() {
        CppAnnotations::value = 0;
        CppAnnotations::Virtual::pointer = 0;
    }
    
  • 使用 using namespace CppAnnotations 指令
    现在 value 可以直接使用,但 pointer 仍需使用 Virtual:: 前缀:

    using namespace CppAnnotations;
    int main() {
        value = 0;
        Virtual::pointer = 0;
    }
    
  • 使用完整命名空间链的 using namespace 指令
    现在 value 需要其 CppAnnotations 前缀,但 pointer 不再需要前缀:

    using namespace CppAnnotations::Virtual;
    int main() {
        CppAnnotations::value = 0;
        pointer = 0;
    }
    
  • 使用两个独立的 using namespace 指令
    不再需要任何命名空间前缀:

    using namespace CppAnnotations;
    using namespace Virtual;
    int main() {
        value = 0;
        pointer = 0;
    }
    
  • 通过具体的 using 声明来实现相同效果
    即使不使用任何命名空间前缀,也可以通过提供具体的 using 声明:

    using CppAnnotations::value;
    using CppAnnotations::Virtual::pointer;
    int main() {
        value = 0;
        pointer = 0;
    }
    
  • 使用 using namespace 指令和 using 声明的组合
    例如,可以使用 using namespace 指令来引用 CppAnnotations::Virtual 命名空间,并使用 using 声明来引用 CppAnnotations::value 变量:

    using namespace CppAnnotations::Virtual;
    using CppAnnotations::value;
    int main() {
        value = 0;
        pointer = 0;
    }
    

在使用 using namespace 指令之后,所有该命名空间的实体都可以在没有任何进一步前缀的情况下使用。如果只使用单一的 using namespace 指令来引用嵌套的命名空间,那么所有该嵌套命名空间的实体都可以在没有任何进一步前缀的情况下使用。然而,定义在更浅层命名空间中的实体仍需保留浅层命名空间的名称。只有在提供具体的 using namespace 指令或 using 声明后,命名空间限定符才能被省略。

当完全限定名被认为太长时(例如 CppAnnotations::Virtual::pointer),可以使用命名空间别名:

namespace CV = CppAnnotations::Virtual;

这将 CV 定义为完全名称的别名。变量 pointer 现在可以使用别名访问:

CV::pointer = 0;

命名空间别名也可以在 using namespace 指令或 using 声明中使用:

namespace CV = CppAnnotations::Virtual;
using namespace CV;

嵌套命名空间定义

从 C++17 标准开始,嵌套的命名空间可以直接使用作用域解析运算符进行引用。例如:

namespace Outer::Middle::Inner
{
    // 在这里定义/声明的实体都在 Inner 命名空间中,该命名空间定义在 Middle 命名空间中,Middle 命名空间定义在 Outer 命名空间中
}

在其命名空间之外定义实体

在命名空间区域之外定义命名空间成员并不是严格必要的。但在实体在命名空间之外定义之前,它必须已在其命名空间内声明。

要在其命名空间之外定义实体,必须使用完全限定名,通过命名空间前缀来引用成员。定义可以在全局级别或嵌套命名空间的中间级别进行。这允许我们在命名空间 A 的区域内定义属于命名空间 A::B 的实体。

假设类型 int INT8[8] 定义在 CppAnnotations::Virtual 命名空间中。进一步假设我们打算在 CppAnnotations::Virtual 命名空间中定义一个函数 squares,该函数返回一个指向 CppAnnotations::Virtual::INT8 的指针。

CppAnnotations::Virtual 命名空间中定义的先决条件如下:

namespace CppAnnotations {
namespace Virtual {
void *pointer;
using INT8 = int[8];
INT8 *squares() {
    INT8 *ip = new INT8[1];
    for (size_t idx = 0; idx != sizeof(INT8) / sizeof(int); ++idx)
        (*ip)[idx] = (idx + 1) * (idx + 1);
    return ip;
}
}  // namespace Virtual
}  // namespace CppAnnotations

Squares 函数定义了一个 INT8 向量的数组,并在初始化向量后返回其地址,初始化内容为前八个自然数的平方。现在可以在 CppAnnotations::Virtual 命名空间之外定义 squares 函数:

namespace CppAnnotations {
namespace Virtual {
void *pointer;
using INT8 = int[8];
INT8 *squares();
}  // namespace Virtual
}

CppAnnotations::Virtual::INT8 *CppAnnotations::Virtual::squares() {
    INT8 *ip = new INT8[1];
    for (size_t idx = 0; idx != sizeof(INT8) / sizeof(int); ++idx)
        (*ip)[idx] = (idx + 1) * (idx + 1);
    return ip;
}

在上述代码片段中注意以下几点:

  • squaresCppAnnotations::Virtual 命名空间内声明。
  • 在命名空间外部的定义需要使用函数及其返回类型的完全限定名。
  • squares 函数体内,我们在 CppAnnotations::Virtual 命名空间中,因此在函数内部不再需要完全限定名(例如,对于 INT8)。

最后,请注意,该函数也可以在 CppAnnotations 区域中定义。在这种情况下,定义 squares() 和指定其返回类型时需要使用 Virtual 命名空间,而函数内部的内容保持不变:

namespace CppAnnotations {
namespace Virtual {
void *pointer;
using INT8 = int[8];
INT8 *squares();
}

Virtual::INT8 *Virtual::squares() {
    INT8 *ip = new INT8[1];
    for (size_t idx = 0; idx != sizeof(INT8) / sizeof(int); ++idx)
        (*ip)[idx] = (idx + 1) * (idx + 1);
    return ip;
}
}  // namespace CppAnnotations

std::chrono 命名空间(时间处理)

C 语言提供了如 sleep(3)select(2) 等工具来暂停程序执行一段时间。当然,还有 time(3) 函数家族用于设置和显示时间。sleepselect 可以用于等待,但由于它们是在多线程还未普及的时代设计的,当在多线程程序中使用时,其效果有限。多线程已成为 C++ 的一部分(详细内容见第 20 章),并且在 std::filesystem 命名空间中还提供了额外的时间相关功能,这些功能将在本章后续部分介绍。

在多线程程序中,线程经常被挂起,尽管通常时间非常短。例如,当一个线程想要访问一个变量,而该变量当前正在被另一个线程更新时,前者线程应该等待,直到后者线程完成更新。更新变量通常不会花费太多时间,但如果花费时间意外地长,前者线程可能希望得到通知,以便在后者线程忙于更新变量时可以做其他事情。像 sleepselect 这样的函数无法实现线程间的这种交互。

std::chrono 命名空间弥合了传统时间相关函数和多线程以及 std::filesystem 命名空间的时间需求之间的差距。除了与 std::filesystem 相关的特定时间功能,所有功能都可以在包含 <chrono> 头文件后使用。包含 <filesystem> 头文件后,可以使用 std::filesystem 的功能。

时间可以以各种分辨率进行测量:在奥运会上,几百分之一秒的差异可能决定金银奖的归属,而在规划假期时,我们可能谈论的是几个月。时间分辨率通过 std::ratio 类的对象来指定(除了包含 <chrono> 头文件外,还需要包含 <ratio> 头文件)。

不同的事件通常持续不同的时间(给定特定的时间分辨率)。时间长度通过 std::chrono::duration 类的对象来指定。事件也可以通过其时间点来表征:例如,格林威治时间 1970 年 1 月 1 日午夜是一个时间点,2010 年 12 月 5 日 19:00 也是一个时间点。时间点通过 std::chrono::time_point 类的对象来指定。

不仅是分辨率、事件的持续时间和事件的时间点可能不同,而且用于指定时间的设备(时钟)也各不相同。在过去,沙漏曾被使用(有时在煮鸡蛋时仍然使用),但在需要非常精确的测量时,我们可能会使用原子钟。提供四种不同类型的时钟。常用的时钟是 std::chrono::system_clock,但在文件系统上下文中,还有一个(隐式定义的) filesystem::__file_clock

接下来的部分将详细介绍 std::chrono 命名空间的特性。首先,我们将探讨时间分辨率的特点。接下来将介绍如何处理给定分辨率的时间长度。下一部分描述了定义和处理时间点的设施。最后,将讲解这些类型与各种时钟类型之间的关系。

在本章中,通常会省略 std::chrono:: 规范(实际上,通常使用 using namespace std 之后再加上 using namespace chrono 来避免歧义),偶尔也会遇到对后续章节的前瞻性引用,例如对多线程章节的引用。这些是难以避免的,但可以在不影响连贯性的情况下推迟学习这些章节。

时间分辨率:std::ratio

时间分辨率(或时间单位)是时间规格的基本组成部分。时间分辨率通过 std::ratio 类的对象来定义。在使用 std::ratio 类之前,必须包含 <ratio> 头文件。也可以包含 <chrono> 头文件,因为 std::ratio 需要两个模板参数,这些参数是用尖括号括起来的正整数,分别定义分数的分子和分母(默认为 1)。

例如:

  • ratio<1> 代表 1
  • ratio<60> 代表 60
  • ratio<1, 1000> 代表 1/1000

std::ratio 定义了两个直接可访问的静态数据成员:num 表示分子,den 表示分母。单独定义 ratio 只是定义了一个特定的量。例如,当执行以下程序时:

#include <iostream>
#include <ratio>
using namespace std;

int main() {
    cout << ratio<5, 1000>::num << ',' << ratio<5, 1000>::den << '\n'
         << milli::num << ',' << milli::den << '\n';
}

将显示 1, 200,这是 ratio<5, 1000> 代表的量;ratio 会尽可能简化分数。

存在许多预定义的 ratio 类型,它们与 ratio 类似,定义在标准命名空间中,可以使用它们代替较繁琐的 ratio<x>ratio<x, y> 规格:


名称指数
yocto10^−24
zepto10^−21
atto10^−18
femto10^−15
pico10^−12
nano10^−9
micro10^−6
milli10^−3
centi10^−2
deci10^−1
deca10^1
hecto10^2
kilo10^3
mega10^6
giga10^9
tera10^12
peta10^15
exa10^18
zetta10^21
yotta10^24

(注意:类型 yoctozeptozettayotta 的定义使用了超过 64 位的整数常量。虽然这些常量在 C++ 中是定义的,但在 64 位或更小的体系结构上不可用。)

时间相关的比率可以很好地解释为秒的分数或倍数,其中 ratio<1, 1> 代表一秒的分辨率。

以下是一个示例,展示了这些缩写如何使用:

cout << milli::num << ',' << milli::den << '\n' <<
        kilo::num << ',' << kilo::den << '\n';

这段代码将输出 millikilo 的分子和分母。例如,milli::nummilli::den 表示毫秒的分数,而 kilo::numkilo::den 表示千秒的分数。

时间量:std::chrono::duration

时间量通过 std::chrono::duration 类的对象来指定。在使用 duration 类之前,必须包含 <chrono> 头文件。

ratio 类似,duration 类需要两个模板参数。一个是数值类型(通常使用 int64_t),定义保存时间量的类型;另一个是时间分辨率(称为其分辨率),通常通过 std::ratio 类型来指定(常常使用其中的一个时间缩写)。

使用预定义的 std::deca 比例,表示 10 秒的单位,可以定义如下 30 分钟的时间间隔:

duration<int64_t, std::deca> halfHr(180);

这里,halfHr 代表 180 个 deca-秒的时间间隔,即 1800 秒。与预定义的比例类似,也有预定义的时间量类型:

  • nanosecondsduration<int64_t, nano>
  • microsecondsduration<int64_t, micro>
  • millisecondsduration<int64_t, milli>
  • secondsduration<int64_t>
  • minutesduration<int64_t, ratio<60>>
  • hoursduration<int64_t, ratio<3600>>

使用这些类型,现在可以简单地定义 30 分钟的时间量为:

minutes halfHour(30);

定义 duration<Type, Resolution> 时指定的两个类型可以分别通过以下方式检索:

  • rep,它等同于数值类型(如 int64_t)。例如,seconds::rep 等同于 int64_t
  • period,它等同于比例类型(如 kilo),因此 duration<int, kilo>::period::num 等于 1000。

可以通过指定其数值类型的参数来构造时间量对象:

  • duration(Type const &value):一个特定的时间量值。Type 指的是时间量的数值类型(例如,int64_t)。因此,当定义:

    minutes halfHour(30);
    

    时,参数 30 被存储在其 int64_t 数据成员中。

duration 支持拷贝构造函数和移动构造函数(参见第 9 章),其默认构造函数将 int64_t 数据成员初始化为零。

可以通过加法或减法两个 duration 对象,或通过乘法、除法、或计算数据成员的模值来修改存储在 duration 对象中的时间量。数值乘法操作数可以用作左操作数或右操作数;与其他乘法运算符结合时,数值操作数必须用作右操作数。复合赋值运算符也可用。

minutes fullHour = minutes{30} + halfHour;
fullHour = 2 * halfHour;
halfHour = fullHour / 2;
fullHour = halfHour + halfHour;
halfHour /= 2;
halfHour *= 2;

此外,duration 提供了以下成员(第一个成员是一个普通成员函数,需要一个 duration 对象):

  • 其他三个是静态成员(参见第 8 章),可以在不需要对象的情况下使用(如零代码片段所示):

    • count() const:返回存储在 duration 对象的数据成员中的值。例如,halfHour 返回 30,而不是 1800。
    • duration<Type, Resolution>::zero():这是一个不可变的 duration 对象,其 count 成员返回 0。例如:seconds::zero().count(); 结果为 int64_t 0
    • duration<Type, Resolution>::min():一个不可变的 duration 对象,其 count 成员返回其 Type 的最小值(即 std::numeric_limits<Type>::min())(参见第 21.11 节)。
    • duration<Type, Resolution>::max():一个不可变的 duration 对象,其 count 成员返回其 Type 的最大值(即 std::numeric_limits<Type>::max())。

    使用不同分辨率的 duration 对象可以组合,只要没有精度丢失。当组合使用不同分辨率的 duration 对象时,结果的分辨率是两者中较细的一个。当使用复合二进制运算符时,接收对象的分辨率必须是更细的分辨率,否则编译将失败。

    minutes halfHour{30};
    hours oneHour{1};
    cout << (oneHour + halfHour).count();  // 显示: 90
    halfHour += oneHour;                   // 可以
    // oneHour += halfHour;               // 编译失败
    

    后缀 hminsmsusns 可以用于整数值,创建相应的 duration 时间间隔。例如,minutes min = 1h 将 60 存储在 min 中。

时钟测量时间

时钟用于测量时间。C++ 提供了几种预定义的时钟类型,除了 std::filesystem::__file_clock 之外,所有的预定义时钟类型都定义在 std::chrono 命名空间中。使用时钟类型之前,必须包含 <chrono> 头文件。

我们需要时钟类型来定义时间点(见下节)。所有预定义的时钟类型都定义了以下类型:

  • 时钟的持续时间类型Clock::duration(预定义的时钟类型使用纳秒)。例如:

    system_clock::duration oneDay{ 24h };
    
  • 时钟的分辨率类型Clock::period(预定义的时钟类型使用纳秒)。例如:

    cout << system_clock::period::den << '\n';
    
  • 存储时间量的时钟类型Clock::rep(预定义的时钟类型使用 int64_t)。例如:

    system_clock::rep amount = 0;
    
  • 存储时间点的时钟类型Clock::time_point(预定义的时钟类型使用 time_point<system_clock, nanoseconds>)。例如:

    system_clock::time_point start;
    

所有时钟类型都有一个成员 now,返回当前时间(相对于时钟的纪元)的时间点。它是一个静态成员,可以这样使用:

system_clock::time_point tp = system_clock::now();

chrono 命名空间中,有三种预定义的时钟类型:

  • system_clock:‘墙钟’,使用系统的实时时钟;
  • steady_clock:一个随着真实时间流逝而增加的时钟;
  • high_resolution_clock:计算机的最快时钟(即,具有最短计时器滴答间隔的时钟)。实际上,这与 system_clock 是相同的。

此外,__file_clock 时钟类型在 std::filesystem 命名空间中定义。__file_clock 的纪元时间点与其他时钟类型的纪元时间点不同,但 __file_clock 有一个静态成员 to_sys(__file_clock::time_point),可以将 __file_clock::time_point 转换为 system_clock::time_point(有关 __file_clock 的更多细节,请参见第 4.3.1 节)。

除了 nowsystem_clockhigh_resolution_clock(以下称为 Clock)类还提供了以下两个静态成员:

  • std::time_t Clock::to_time_t(Clock::time_point const &tp):返回与 time_point 相同的时间点的 std::time_t 值(与 C 语言的 time(2) 函数返回的类型相同)。
  • Clock::time_point Clock::from_time_t(std::time_t seconds):返回与 std::time_t 相同的时间点的 time_point

以下示例演示了如何调用这些函数:

system_clock::from_time_t(system_clock::to_time_t(system_clock::from_time_t(time(0))));

时间点:std::chrono::time_point

单个时间点可以通过 std::chrono::time_point 类的对象来指定。使用 time_point 类之前,必须包含 <chrono> 头文件。

duration 类似,time_point 类需要两个模板参数:一个时钟类型和一个持续时间类型。通常使用 system_clock 作为时钟类型,默认持续时间类型为纳秒(如果纳秒是预期的持续时间类型,则可以省略)。否则,需要将持续时间类型指定为 time_point 的第二个模板参数。因此,以下两个时间点定义使用相同的时间点类型:

time_point<system_clock, nanoseconds> tp1;
time_point<system_clock> tp2;

time_point 类支持三个构造函数:

  • time_point()
    默认构造函数,初始化为时钟的纪元开始时间。对于 system_clock,纪元是 1970 年 1 月 1 日 00:00,但请注意,filesystem::__file_clock 使用不同的纪元(见第 4.3.1 节)。

  • time_point(time_point<Clock, Duration> const &other)
    复制构造函数,将一个时间点对象初始化为由 other 定义的时间点。如果 other 的分辨率使用比构造对象的分辨率更大的周期,则 other 的时间点将在构造对象的分辨率中表示(下面的描述中提供了一个示例,说明了成员函数 time_since_epoch)。

  • time_point(time_point<Clock, Duration> &&tmp)
    移动构造函数,类似于复制构造函数,将 tmp 的分辨率转换为构造对象,并将 tmp 移动到构造对象中。

可用的运算符和成员包括:

  • time_point &operator+=(duration const &amount)
    amount 表示的时间量添加到当前 time_point 对象中。这个运算符也可以作为二元算术运算符,使用 time_point const &duration const & 操作数(无论顺序如何)。示例:

    system_clock::now() + seconds{ 5 };
    
  • time_point &operator-=(duration const &amount)
    从当前 time_point 对象中减去 amount 表示的时间量。这个运算符也可以作为二元算术运算符,使用 time_point const &duration const & 操作数(无论顺序如何)。示例:

    time_point<system_clock> point = system_clock::now();
    point -= seconds{ 5 };
    
  • duration time_since_epoch() const
    返回从纪元以来的时间量,这个时间量由调用该成员的时间点对象表示。

  • time_point min() const
    返回时间点的 duration::min 值。示例:

    cout << time_point<system_clock>::min().time_since_epoch().count() << '\n';
    // 显示:-9223372036854775808
    
  • time_point max() const
    返回时间点的 duration::max 值。

所有预定义的时钟使用纳秒作为其时间分辨率。要以较不精确的分辨率表示时间,请取一个较不精确的分辨率的时间单位(例如,hours(1)),并将其转换为纳秒。然后,将 time_pointtime_since_epoch().count() 成员返回的值除以转换为纳秒的较不精确分辨率的 count 成员。使用这个过程可以确定自纪元开始以来经过的小时数:

cout << system_clock::now().time_since_epoch().count() /
       nanoseconds(hours(1)).count() << " hours since the epoch\n";

基于系统时钟或高分辨率时钟的时间点对象可以转换为 std::time_t(或等效的 time_t)值。这些 time_t 值在转换时间为文本时使用。对于这样的转换,通常使用 manipulator put_time(见第 6.3.2 节),但 put_time 必须提供 std::tm 对象的地址,而 std::tm 对象可以从 std::time_t 值获得。整个过程相当复杂,核心元素在图 4.1 中可视化。

最终,将时间点值插入到 std::ostream 的关键步骤是使用 system_clock::to_time_t(time_point<system_clock> const &tp) 将时间点转换为 time_t 值(也可以使用 high_resolution_clock)。如何将时间点插入到 std::ostream 中,在第 6.4.4 节中进行了描述。
alt text

std::filesystem 命名空间

计算机通常将需要在重启后仍然保存的信息存储在文件系统中。传统上,C 语言提供了执行所需系统调用的函数来操作文件系统。这些函数(如 rename(2)truncate(2)opendir(2)realpath(3))当然也在 C++ 中可用,但它们的签名和使用方式通常不够友好,因为它们通常期望 char const * 参数,并且可能使用静态缓冲区或基于 malloc(3)free(3) 的内存分配。

自 2003 年以来,Boost 库提供了这些函数的包装器,提供了更符合 C++ 风格的接口。

目前,C++ 直接在 std::filesystem 命名空间中支持这些功能。这些功能可以在包含 <filesystem> 头文件后使用。

std::filesystem 命名空间非常广泛:它包含了 10 多个不同的类和 30 多个自由函数。要引用 std::filesystem 命名空间中定义的标识符,可以使用它们的完全限定名(例如,std::filesystem::path)。另外,在指定了 using namespace std::filesystem; 之后,可以不使用进一步的限定符直接使用这些标识符。

类似地,也可以使用 namespace fs = std::filesystem; 的命名空间别名,允许像 fs::path 这样的规范。

std::filesystem 命名空间中的函数可能会失败。当函数无法执行其指定任务时,它们可能会抛出异常(见第 10 章),或者将错误码赋值给作为参数传递给这些函数的 error_code 对象(见第 4.3.2 节)。

__file_clock 类型

在第 4.2.3 节中提到,C++ 提供了多种预定义的时钟,其中 system_clock 是计算机本身使用的时钟。std::filesystem 命名空间使用了不同的时钟类型:std::filesystem::__file_clock。使用 __file_clock 获得的时间点与使用系统时钟获得的时间点有所不同:__file_clock 基于的纪元(epoch)目前位于远远超出系统时钟使用的纪元(1970年1月1日 00:00:00)的时间点,例如:2173年12月31日 23:59:59。两个纪元在时间轴上的位置可以如下表示:

<------|-----------------------|-----------------------|------->
system_clock's--------> present <-------- __file_clock's
epoch starts positive             negative   epoch starts
              count                 count

__file_clock 有其独特之处:静态成员 now 可用,同时也可以使用一些非静态成员:例如可以进行持续时间的加法和减法操作,还可以使用 time_since_epoch 成员。其他成员(如 to_time_tfrom_time_tminmax)则不可用。

由于 __file_clock 不支持 to_time_t,那么如何显示时间或获取 time_point<__file_clock> 对象的时间组件呢?目前,有两种方法可以实现这一目标:一种是手动计算修正值,另一种是使用静态成员函数 __file_clock::to_sys__file_clock 的时间点转换为系统时钟(system_clock)、稳定时钟(steady_clock)或高精度时钟(high_resolution_clock)所使用的时间点。

计算两个纪元之间的差异,我们发现有 6,437,663,999 秒。我们可以将这个时间加到 __file_clock 纪元以来的时间上,从而得到自系统时钟(system_clock)纪元以来的时间。如果 timePt 表示自 __file_clock 纪元以来的持续时间,那么

6'437'663'999 + system_clock::to_time_t(
    time_point<system_clock>{ nanoseconds(timePt) })

等于自系统时钟纪元以来的秒数。

这个过程的潜在缺点是,由于 __file_clock 的名字以下划线开头,其纪元的起始点可能会发生变化。通过使用两个时钟的 now 成员函数,这个缺点可以避免:

auto systemNow = system_clock::now().time_since_epoch();
auto fileNow = __file_clock::now().time_since_epoch();
time_t diff = (systemNow - fileNow) / 1'000'000'000;
time_t seconds = diff + system_clock::to_time_t(
    time_point<system_clock>{ nanoseconds(timePt) });

虽然能够自己计算时间偏移在理解方面很有吸引力,但在日常实践中可能也有些繁琐。可以使用静态函数 __file_clock::to_sys__file_clock 的时间点转换为 system_clock 的时间点。有关 __file_clock::to_sys 函数的详细内容将在第 4.3.3.2 节中讨论。

std::error_code

std::error_code(注意:不是 std::filesystem::error_code!)用于封装错误值及其相关的错误类别(参见第10.9节)。error_code 类可以在包含 <system_error> 头文件后使用,也可以在包含 <filesystem> 头文件后使用。

传统上,错误值是通过全局变量 errno 提供的。按照约定,当 errno 的值为零时,表示没有错误。这一约定也被 error_code 采纳了。

错误代码可以定义用于许多不同概念的情况。这些情况由各自的错误类别来描述。

错误类别用于将 error_code 对象与那些定义了错误的类别关联起来。默认可用的错误类别可能使用像 EADDRINUSE(或等效的 enum class errc::address_in_use)这样的值,但也可以定义适用于其他上下文的新错误类别。定义错误类别的内容在 C++ 注释的末尾(第23.7.1节)中介绍。在此,仅简要介绍两个 error_category 成员:

  • std::string message(int err):返回错误 err 的文本描述(例如,当 err 等于 address_in_use 时,返回 “address already in use”)。
  • char const *name():返回错误类别的名称(例如,通用类别为 “generic”)。

错误类别类是单例类:每个错误类别只有一个对象。在 filesystem 命名空间的上下文中,使用了标准类别 system_category,并且通过自由函数 std::system_category 返回对 system_category 对象的引用。

std::error_code 的公共接口

std::error_code 声明了以下构造函数和成员函数:

构造函数:
  • error_code() noexcept:该构造函数初始化对象的错误值为 0 和错误类别为 system_category。值 0 被认为不是错误。
  • 复制构造函数和移动构造函数:都可用。
  • error_code(int ec, error_category const &cat) noexcept:该构造函数使用错误值 ec(例如,由失败的函数设置的 errno)和对适用错误类别的常量引用(例如,通过 std::system_category()std::generic_category() 提供)。示例:
    error_code ec{ 5, system_category() };
    
  • error_code(ErrorCodeEnum value) noexcept:这是一个成员模板(参见第22.1.3节),使用模板头文件模板 <class ErrorCodeEnum>。它通过 make_error_code(value) 初始化对象(见下文)。在第23.7节中介绍了定义 ErrorCodeEnum。注意:ErrorCodeEnum 作为这样的标识符并不存在,它只是用于现有 ErrorCodeEnum 枚举的占位符。
成员函数:
  • 重载的赋值运算符和接受 ErrorCodeEnum 的赋值运算符
  • void assign(int val, error_category const &cat):将新值赋给对象的错误值和错误类别。例如:
    ec.assign(0, generic_category());
    
  • error_category const &category() const noexcept:返回对象的错误类别的引用。
  • void clear() noexcept:将 error_code 的值设置为 0,并将错误类别设置为 system_category
  • error_condition default_error_condition() const noexcept:返回当前类别的默认错误条件,它使用当前对象的错误值和错误类别初始化(有关 error_condition 类的详细信息,请参见第10.9.2节)。
  • std::string message() const:返回与当前对象的错误值相关联的消息(等同于 category().message(ec.value()))。
  • explicit operator bool() const noexcept:如果对象的错误值不等于 0(即,它表示一个错误),则返回 true
  • int value() const noexcept:返回对象的错误值。
自由函数:
  • 比较两个 error_code 对象:可以使用 operator==operator< 进行比较(检查相等性或排序)。

  • 两个 error_code 对象的比较:两个 error_code 对象可以进行相等性比较和排序(使用 operator<)。但不同错误类别的 error_code 对象之间的排序没有意义。只有当错误类别相同的情况下,才可以根据错误代码值进行比较(参见 SG14 讨论总结)。

  • error_code make_error_code(errc value) noexcept:返回一个 error_code 对象,该对象使用 static_cast<int>(value)generic_category() 初始化。这个函数将 enum class errc 值转换为 error_code 对象。其他与错误相关的枚举也可以定义,并与特定的 make_error_code 函数关联(参见第23.7节)。

  • std::ostream &operator<<(std::ostream & os, error_code const &ec):执行以下语句:

    return os << ec.category().name() << ':' << ec.value();
    

    该操作符用于将 error_code 对象输出到流中。

多个函数定义了一个可选的 last error_code &ec 参数。这些函数具有 noexcept 规范。如果这些函数无法完成其任务,则 ec 会被设置为适当的错误代码,如果没有遇到错误,则会调用 ec.clear()。如果没有提供 ec 参数,则这些函数在无法完成任务时会抛出 filesystem_error 异常。

文件系统条目的名称:path

std::filesystem::path 类用于保存文件系统条目的名称。path 类是一个值类:它提供了默认构造函数(空路径),以及标准的拷贝/移动构造和赋值功能。此外,还可以使用以下构造函数:

  • path(string &&tmp);
  • path(Type const &source);:接受任何提供路径字符的类型(例如,源类型是一个 NTBS)。
  • path(InputIter begin, InputIter end);:从 beginend 的字符定义了路径的名称。

通过这些构造函数创建的路径不一定需要引用一个现有的文件系统条目。

路径构造函数接受的字符序列(包括 NTBS)可以由以下可选元素组成:

  • 根名称(例如磁盘名称(如 E:)或设备指示符(如 //nfs))
  • 根目录,如果在(可选)根名称之后的第一个字符出现,则表示根目录
  • 文件名字符(不包含目录分隔符)。此外,“单点文件名”(.)表示当前目录,“双点文件名”(…)表示当前目录的父目录
  • 目录分隔符(默认是正斜杠)。多个连续的分隔符会自动合并为一个分隔符。
    构造函数还定义了一个最后的参数 ftmp = auto_format,在实际使用中几乎不需要提供此参数(有关详细信息,请参见 cppreference)。许多函数期望 path 类型的参数,这些参数通常可以从 NTBS 或 std::string 对象创建,因为 path 允许类型转换(参见第 11.5 节)。例如,filesystem 函数 absolute 期望一个 const &path 参数。它可以像这样调用:absolute("tmp/filename")

访问器、修改器和运算符

path 提供了以下运算符和成员:

运算符:
  • path &operator/=(Type const &arg):与构造函数中可以传递的参数类似,这个成员函数接受 arg 作为参数,将其添加到当前路径的末尾,并用目录分隔符分隔(除非路径最初为空,如 cout << path{}.append("entry"))。另见下面的成员函数 appendconcat。自由运算符 / 接受两个路径(可以转换的)参数,返回一个包含两个路径并用目录分隔符分隔的路径(例如,lhs / rhs 返回一个包含 lhs/rhs 的路径对象)。

  • path &operator+=(Type const &arg):类似于 /=,但在将 arg 添加到当前路径时不使用目录分隔符。

  • 比较运算符:路径对象可以使用 ==<=> 运算符进行比较。路径对象通过字典顺序比较其 ASCII 字符内容。

访问器:

访问器返回特定的路径组件。如果路径不包含请求的组件,则返回一个空路径。

  • char const *c_str():返回路径内容的 NTBS(Null-Terminated Byte String)。

  • path extension():返回路径最后一个组件的扩展名(包括点)。

  • path filename():返回当前路径对象的最后路径内容。另见下面的 stem() 访问器。

  • bool is_absolute():如果路径对象包含绝对路径规范,则返回 true

  • bool is_relative():如果路径对象包含相对路径规范,则返回 true

  • path parent_path():返回当前路径内容中去掉最后一个元素后的路径内容。注意,如果路径对象包含一个文件名的路径(如 /usr/bin/zip),则 parent_path 会移除 /zip 并返回 /usr/bin,即返回的是 /zip 的实际目录,而不是它的父目录。

  • path relative_path():返回路径对象中根目录组件之后的路径内容。例如,如果路径 ulb{ "/usr/local/bin" } 被定义,则 ulb.relative_path() 返回包含 "usr/local/bin" 的路径。

  • path root_directory():返回路径对象的根目录组件。

  • path root_name():返回路径对象的根名称组件。

  • path root_path():返回路径对象的根路径组件。

  • path stem():返回当前路径对象的最后路径内容,去掉了点扩展名后的部分。

  • string():返回路径内容作为 std::string

类似的访问器还适用于以下字符串类型:wstringu8stringu16stringu32stringgeneric_stringgeneric_wstringgeneric_u8stringgeneric_u16stringgeneric_u32string

string()is_... 访问器系列外,还有布尔类型的 has_... 成员函数,如果路径包含指定组件,则返回 true(例如,has_extension 如果路径包含扩展名,则返回 true)。

成员函数:
  • path &append(Type const &arg):功能类似于 /= 运算符。

  • path::iterator begin():返回一个迭代器,包含路径的第一个组件。对 path::iterator 进行解引用会返回一个路径对象。当存在根名称和根目录时,这些会作为初始组件返回。遍历 path::iterator 时,逐个返回各个目录和最终的文件名组件。目录分隔符本身在解引用后不会被返回。

  • void clear():擦除路径的内容。

  • int compare(Type const &other):返回当前路径内容与 other 进行字典顺序比较的结果。other 可以是一个路径、一个字符串类型或一个 NTBS(Null-Terminated Byte String)。

  • path &concat(Type const &arg):功能类似于 += 运算符。

  • ostream &operator<<(ostream &out, path const &path):流插入运算符,将路径的内容以双引号包围的形式插入到 out 流中。

  • istream &operator>>(istream &in, path &path):从 in 流中提取路径的内容。提取的路径名称可以选择性地被双引号包围。在插入之前提取的路径对象时,只显示一组双引号。

  • path &remove_filename():移除存储路径的最后一个组件。如果只存储了根目录,则根目录会被移除。注意,最后一个目录分隔符会保留,除非它是唯一的路径元素。

  • path &replace_extension(path const &replacement = path{}):将存储路径最后一个组件的扩展名(包括扩展名的点)替换为 replacement。如果 replacement 为空,则扩展名会被移除。如果调用 replace_extension 的路径没有扩展名,则会添加 replacementreplacement 可以选择性地以点开始。路径对象的扩展名只会有一个点。

  • path &replace_filename(path const &replacement):用 replacement 替换存储路径的最后一个组件,replacement 本身可以包含多个路径元素。如果只存储了根目录,则用 replacement 替换它。如果当前路径对象为空,该成员的行为未定义。

自由函数

除了 path 类成员函数,还有各种自由函数可用。其中一些函数用于复制文件。这些函数接受一个可选的 std::filesystem::copy_options 参数。copy_options 枚举类定义了一些符号常量,用于细化这些函数的行为。枚举支持按位运算符(符号的值见括号中),并定义了这些符号:

  • 复制文件时

    • none (0):报告错误(默认行为);
    • skip_existing (1):保留现有文件,不报告错误;
    • overwrite_existing (2):替换现有文件;
    • update_existing (4):仅在现有文件比被复制的文件旧时才替换现有文件;
  • 复制子目录时

    • none (0):跳过子目录(默认行为);
    • recursive (8):递归地复制子目录及其内容;
  • 复制符号链接时

    • none (0):跟随符号链接(默认行为);
    • copy_symlinks (16):将符号链接复制为符号链接,而不是它们指向的文件;
    • skip_symlinks (32):忽略符号链接;
  • 控制复制行为本身

    • none (0):复制文件内容(默认行为);
    • directories_only (64):仅复制目录结构,不复制任何非目录文件;
    • create_symlinks (128):创建指向原始文件的符号链接,而不是创建文件副本(源路径必须是绝对路径,除非目标路径在当前目录中);
    • create_hard_links (256):创建硬链接,而不是创建文件副本,这些硬链接解析到与原始文件相同的文件。

以下函数期望路径参数:

  • path absolute(path const &src, [, error_code &ec]):返回 src 的绝对路径(即从文件系统的根目录(及可能的磁盘)名称开始)。可以像这样调用:absolute("tmp/filename"),返回(绝对的)当前工作目录,加上 absolute 的参数作为最终元素,用目录分隔符分隔。相对路径指示符(如 .././)被保留。返回的路径仅是绝对路径。如果需要移除相对路径指示符,请使用下一个函数。

  • path canonical(path const &src [, error_code &ec]):返回 src 的规范路径。参数 src 必须引用一个现有的目录项。例如:

    path man{ "/usr/local/bin/../../share/man" };
    cout << canonical(man) << '\n';
    // 显示: "/usr/share/man"
    
  • void copy(path const &src, path const &dest [, copy_options opts [, error_code &ec]])src 必须存在。如果 src 是一个目录,并且 dest 不存在,则创建 dest。如果指定了 copy_optionsrecursivenone,目录将被递归复制。

  • bool copy_file(path const &src, path const &dest [, copy_options opts [, error_code &ec]])src 必须存在。如果 src 是一个文件,则将其复制到 dest。符号链接会被跟随。复制成功时返回 true

  • void copy_symlink(path const &src, path const &dest [, error_code &ec]):创建一个符号链接 dest,作为符号链接 src 的副本。

  • bool create_directories(path const &dest [, error_code &ec]):创建 dest 的每个组件(如果尚不存在)。如果实际创建了 dest,则返回 true。如果返回 false,则 ec 包含一个错误码,如果 dest 已存在,则 ec.value() == 0。请参见下面的 create_directory

  • bool create_directory(path const &dest [, path const &existing [, error_code &ec]])dest 的父目录必须存在。如果 dest 尚不存在,则创建目录 dest。如果实际创建了 dest,则返回 true。如果返回 false,则 ec 包含一个错误码,如果 dest 已存在,则 ec.value() == 0。如果指定了 existing,则 dest 接收与 existing 相同的属性。

  • void create_directory_symlink(path const &dir, path const &link [, error_code &ec]):类似于 create_symlink,但用于创建指向目录的符号链接。

  • void create_hardlink(path const &dest, path const &link [, error_code &ec]):从 link 创建一个指向 dest 的硬链接。dest 必须存在。

  • void create_symlink(path const &dest, path const &link [, error_code &ec]):从 link 创建一个指向 dest 的符号(软)链接;dest 不必存在。

  • path current_path([error_code &ec]):返回当前工作目录(cwd)。void current_path(path const &toPath [, error_code &ec]):将 cwd 更改为 toPath。返回路径的最后一个字符不是斜杠,除非从根目录调用。

  • bool equivalent(path const &path1, path const &path2 [, error_code &ec]):如果 path1path2 引用相同的文件或目录,并且具有相同的状态,则返回 true。两个路径必须存在。

  • bool exists(path const &dest [, error_code &ec]):如果 dest 存在(实际上,如果 status(dest[, ec])(见下文)返回 true),则返回 true。注意:当迭代目录时,迭代器通常提供条目的状态。在这种情况下,调用 exists(iterator->status()) 比调用 exists(*iterator) 更有效。如果 dest 是符号链接的路径,则 exists 返回链接的目标是否存在(参见第 4.3.4 节的 statussymlink_status 函数)。

  • std::uintmax_t file_size(path const &dest [, error_code &ec]):返回常规文件(或符号链接目标)的大小(以字节为单位)。

  • std::uintmax_t hard_link_count(path const &dest [, error_code &ec]):返回与 dest 关联的硬链接数量。

  • time_point<__file_clock> last_write_time(path const &dest [, error_code &ec])void last_write_time(path const &dest, time_point<__file_clock> newTime [, error_code &ec]):前者返回 dest 的最后修改时间;后者将 dest 的最后修改时间更改为 newTimelast_write_time 的返回类型通过 chrono::time_point 的别名定义(见第 4.2.4 节)。返回的 time_point 保证覆盖当前文件系统中可能遇到的所有文件时间值。可以使用 __file_clock::to_sys 函数将 __file_clock 时间点转换为 system_clock 时间点。

  • path read_symlink(path const &src [, error_code &ec])src 必须指向一个符号链接,否则会生成错误。返回链接的目标。

  • bool remove(path const &dest [, error_code &ec])remove_all(path const &dest [, error_code &ec])remove 删除文件、符号链接或空目录 dest,如果 dest 可以被删除,则返回 trueremove_all 删除 dest 如果它是一个文件(或符号链接);并递归删除目录 dest,返回删除的条目数量。

  • void rename(path const &src, path const &dest [, error_code &ec]):将 src 重命名为 dest,就像使用标准 mv(1) 命令一样(如果 dest 存在,它会被覆盖)。

  • void resize_file(path const &src, std::uintmax_t size [, error_code &ec]):将 src 的大小更改为 size,就像使用标准 truncate(1) 命令一样。

  • space_info space(path const &src [, error_code &ec]):返回 src 所在文件系统的信息。

  • path system_complete(path const &src[, error_code& ec]):返回匹配 src 的绝对路径,以 current_path 作为基准。

  • path temp_directory_path([error_code& ec]):返回一个可以用于临时文件的目录的路径。该目录不会被创建,但其名称通常可以从环境变量 TMPDIR、`TMP

TEMPTEMPDIR中获得。否则,返回/tmp`。

  • time_point<system_clock> __file_clock::to_sys(time_point<__file_clock> timePoint):这是如何使用 system_clock 的纪元表示 last_write_time 返回的时间。示例:
    int main()
    {
        time_t seconds = system_clock::to_time_t(
            __file_clock::to_sys(last_write_time("lastwritetime.cc"))
        );
        cout << "lastwritetime.cc's last (UTC) write time: " <<
            put_time(gmtime(&seconds), "%c") << '\n';
    }
    

处理目录:directory_entry

文件系统是一个递归的数据结构。它的顶层条目是一个目录(根目录),其中包含普通目录条目(文件、(软)链接、命名套接字等),以及可能的(子)目录条目,引用嵌套的目录,这些目录又可能包含普通条目和(子)目录条目。

std::filesystem 命名空间中,目录的元素是 directory_entry 类的对象,包含该目录条目的名称和状态。

directory_entry 类支持所有标准构造函数和赋值运算符,此外还有一个接受路径的构造函数:

directory_entry(path const &entry);

directory_entry 类的对象可以通过名称构造,而不要求这些对象引用计算机文件系统中的现有条目。赋值运算符也是可用的,以及(ostream)插入运算符,可以将对象的路径插入到流中。提取运算符不可用。

directory_entry 对象可以使用 ==!=<<=>>= 运算符进行比较。这些运算符应用于它们的路径对象:例如,directory_entry("one") == directory_entry("one") 返回 true

除了这些运算符外,directory_entry 类还具有以下成员函数:

  • void assign(path const &dest):将当前路径替换为 dest(其操作与重载的赋值运算符相同);

  • void replace_filename(path const &dest):将当前对象路径的最后一个元素替换为 dest。如果该元素为空(例如,当对象路径以目录分隔符结尾时),则将 dest 附加到当前对象路径;

  • path const &path() constoperator path const &() const:返回当前对象的路径名称;

  • filesystem::file_status status([error_code &ec]):返回当前对象所引用的目录条目的类型和属性。如果当前对象引用的是符号链接,则返回符号链接所指向条目的状态。要获取条目的状态,即使它是符号链接,也可以使用 symlink_status(见第 4.3.5 节和 4.3.5.1 节)。

目录条目访问:(递归)目录迭代器

文件系统命名空间中有两个类可以简化目录处理:directory_iteratorrecursive_directory_iterator。这两个类的对象是输入迭代器,分别用于迭代目录中的条目和递归访问所有目录条目。

这些类提供默认、复制和移动构造函数。两个类的对象都可以通过路径和可选的 error_code 来构造。例如:

directory_iterator(path const &dest [, error_code &ec]);

标准输入迭代器的所有成员函数(参见第18.2节)都得到支持。这些迭代器指向 directory_entry 对象,后者引用计算机文件系统中的条目。例如:

cout << *directory_iterator{ "/home" } << '\n'; // 显示 /home 下的第一个条目

这些类的匹配终止迭代器可以通过默认构造的对象获得。此外,还可以使用基于范围的 for 循环,如下例所示:

for (auto &entry: directory_iterator("/var/log"))
    cout << entry << '\n';

也可以使用显式定义迭代器的 for 语句:

for (
    auto iter = directory_iterator("/var/log"),
         end = directory_iterator{};
    iter != end;
    ++iter
)
    cout << *iter << '\n';

在构造一个 recursive_directory_iterator base{"/var/log"} 对象后,它会引用目录中的第一个元素。此类迭代器还可以显式定义:

auto &iter = begin(base);
auto iter = begin(base);
auto &iter = base;
auto iter = base;

所有这些 iter 对象都引用 base 的数据,递增它们也会将 base 前进到下一个元素:

recursive_directory_iterator base{ "/var/log/" };
auto iter = base;
// 最后两个元素显示相同的路径,与第一个元素不同。
cout << *iter << ' ' << *++iter << ' ' << *base << '\n';

在上述示例中使用的 beginend 函数与 recursive_directory_iterator 一样可在文件系统命名空间中使用。recursive_directory_iterator 还接受一个 directory_options 参数(见下文),默认为 directory_options::none

recursive_directory_iterator(path const &dest,
                             directory_options options [, error_code &ec]);

枚举类 directory_options 定义了用于微调 recursive_directory_iterator 对象行为的值,支持按位操作符(其符号的值在括号中显示):

  • none(0):跳过目录符号链接,拒绝进入子目录的权限会生成错误;
  • follow_directory_symlink(1):跟随指向子目录的符号链接;
  • skip_permission_denied(2):无法进入的目录会被静默跳过。

recursive_directory_iterator 类还有以下成员函数:

  • int depth() const:返回当前迭代深度。构造时指定的初始目录深度为0;
  • void disable_recursion_pending():在递增迭代器之前调用时,下一个目录条目如果是子目录则不会被递归访问。然后,在递增迭代器之后,递归再次被允许。如果递归应在特定深度结束,则必须在 depth() 返回该深度之前反复调用此函数;
  • recursive_directory_iterator &increment(error_code &ec):行为与迭代器的递增操作符相同。然而,当发生错误时,操作符 ++ 会抛出 filesystem_error 异常,而 increment 则将错误赋值给 ec
  • directory_options options() const:返回构造时指定的选项;
  • void pop():结束当前目录的处理,并继续处理当前目录父目录中的下一个条目。在(如在 for 语句中)从初始目录调用时,该目录的处理结束;
  • bool recursion_pending() const:如果允许递归处理当前处理的目录的子目录,则返回 true。如果是这样,并且迭代器指向的目录条目是一个子目录,则在迭代器的下次递增时处理继续在该子目录中。

下面是一个小程序,显示一个目录及其所有直接子目录的所有目录元素:

int main()
{
    recursive_directory_iterator base{ "/var/log" };
    for (auto entry = base, endIt = end(base); entry != endIt; ++entry)
    {
        cout << entry.depth() << ": " << *entry << '\n';
        if (entry.depth() == 1)
            entry.disable_recursion_pending();
    }
}

上述程序按顺序处理条目。如果需要其他策略,则需要自己实现。例如,广度优先策略首先访问所有非目录条目,然后访问子目录。在下一个示例中,通过处理存储在 level 中的每个目录(最初它仅包含起始目录)实现了这一点。“处理一个目录”意味着直接处理其非目录条目,同时将其子目录的名称存储在 next 中。一旦 level 中的所有条目都被处理,下一层子目录的名称就会出现在 next 中,通过将 next 赋值给 level,即可处理下一层的所有目录。

当到达最深层嵌套的子目录时,next 为空,while 语句结束:

void breadth(path const &dir) {          // 起始目录
    vector<path> level{dir};             // 当前处理的层级
    while (not level.empty()) {          // 处理所有目录
        vector<path> next;               // 下一层的目录
        for (auto const &dir : level) {  // 访问当前层级的所有目录
            cout << "当前目录: " << dir << '\n';
            // 在每个目录中访问所有条目
            for (auto const &entry : directory_iterator{dir}) {
                if (entry.is_directory())   // 存储当前层级的所有目录
                    next.push_back(entry);
                else
                    // 或处理非目录条目
                    cout << " 条目: " << entry << '\n';
            }
        }
        level = next;  // 继续处理下一层,// 最终下一层将不存在
    }
}

这个函数通过广度优先方式遍历目录结构,逐层处理目录及其条目。

文件系统元素的类型(file_type)和权限(perms):file_status

文件系统条目(由 path 对象表示)具有多个属性:权限(例如,所有者可以修改条目,其他人只能读取条目)和类型(例如普通文件、目录和软链接)。以下类型和函数都定义在 std::filesystem 命名空间中,可以通过 namespace fs = std::filesystem; 进行简化。

文件系统条目的类型和权限可以通过 file_status 类的对象获得。file_status 类是一个值类,支持复制和移动构造函数以及赋值操作符。

构造函数:

explicit file_status(file_type type = file_type::none,
                     perms permissions = perms::unknown)

创建具有特定权限集的文件系统条目的文件状态。它也充当默认构造函数。

构造函数的第一个参数是一个枚举,用于指定由 path 对象表示的文件系统条目的类型:

  • not_found = -1 表示请求状态的文件系统条目未找到(这不被视为错误);
  • none 表示文件状态尚未评估,或在评估条目状态时发生了错误;
  • regular:条目是普通文件;
  • directory:条目是目录;
  • symlink:条目是符号链接;
  • block:条目是块设备;
  • character:条目是字符设备;
  • fifo:条目是命名管道;
  • socket:条目是套接字文件;
  • unknown:条目是未知类型的文件。

构造函数的第二个参数定义了枚举类 perms,用于指定文件系统条目的访问权限。枚举的符号选择旨在比 <sys/stat.h> 头文件中定义的常量更具描述性,但它们具有相同的值。所有按位操作符都可以用于 perms 枚举类的值。以下是 perms 枚举类定义的符号概览(注意,sys/stat.h 头文件中常用的八进制权限值不能直接用作 fs::perms 值,需要像 static_cast<fs::perms>(value) 这样的转换):
在这里插入图片描述

file_status 类提供了以下成员函数:

  • perms permissions() constvoid permissions(perms newPerms [, perm_options opts] [, error_code &ec])

    • 前者返回由 file_status 对象表示的文件系统条目的权限,后者可用于修改这些权限。枚举类 perm_options 具有以下值:
      • replace:当前选项被 newPerms 替换;
      • add:将 newPerms 添加到当前权限;
      • remove:从当前权限中移除 newPerms
      • nofollow:当 path 引用符号链接时,更新符号链接的权限,而不是链接所指向的文件系统条目的权限。
  • file_type type() constvoid type(file_type type)

    • 前者返回由 file_status 对象表示的文件系统条目的类型,后者可用于设置类型。

获取文件系统条目的状态

filesystem 函数 statussymlink_status 用于检索或更改文件系统条目的状态。这些函数可以带有一个最后(可选)的 error_code 参数,如果无法执行任务,该参数会被赋予相应的错误代码。如果省略该参数,则当无法执行任务时,这些函数会抛出异常:

  • file_status status(path const &dest [, error_code &ec]):返回 dest 的类型和属性。如果 dest 是符号链接,则返回链接目标的状态;
  • file_status symlink_status(path const &dest [, error_code &ec]):调用 symlink_status(dest) 时,返回 dest 本身的状态。因此,如果 dest 引用的是符号链接,那么 symlink_status 不会返回 dest 所指向的条目的状态,而是返回 dest 本身的状态:即符号链接(其 file_statustype() 成员将返回 file_type::symlink);
  • bool status_known(file_status const &status):如果 status 是确定的状态,则返回 true(即使 status 本身可能指示该实体不存在)。传递一个默认的状态对象(如 status_known(file_status{}))是获得 false 的一种方式。

一旦获得 file_status 对象,可以使用以下函数(定义在 filesystem 命名空间中,其中 WHATEVER 是请求的规范)来查询其表示的条目的文件类型:

bool is_WHATEVER(file_status status)
bool is_WHATEVER(path const &entry [, error_code &ec])

如果 statusentry 符合请求的类型,这些函数返回 true。以下是可用的函数:

  • is_block_file:路径引用的是块设备;
  • is_character_file:路径引用的是字符设备;
  • is_directory:路径引用的是目录;
  • is_empty:路径引用的是空文件或空目录;
  • is_fifo:路径引用的是命名管道;
  • is_other:路径不引用目录、常规文件或符号链接;
  • is_regular_file:路径引用的是常规文件;
  • is_socket:路径引用的是命名套接字;
  • is_symlink:路径引用的是符号链接。

另外,可以使用 file_status::type() 成员函数,例如在 switch 语句中根据其返回的 file_type 值来选择匹配的条目(有关 file_type 枚举定义的符号描述,请参见上一节 4.3.5)。

以下是一个小程序,展示了如何获取和显示文件状态(有关映射的内容请参见第 12.4.7 节):

#include <filesystem>
#include <iostream>
#include <unordered_map>
using namespace std::filesystem;
using namespace std;

namespace {
std::unordered_map<file_type, char const *> statusMap = {
    {file_type::not_found, "未知文件"},
    {file_type::none, "尚未或错误地评估的文件类型"},
    {file_type::regular, "常规文件"},
    {file_type::directory, "目录"},
    {file_type::symlink, "符号链接"},
    {file_type::block, "块设备"},
    {file_type::character, "字符设备"},
    {file_type::fifo, "命名管道"},
    {file_type::socket, "套接字文件"},
    {file_type::unknown, "未知文件类型"}};
}

int main() {
    cout << oct;
    string line;
    while (true) {
        cout << "请输入文件系统条目的名称: ";
        if (not getline(cin, line) or line.empty()) break;
        path entry{line};
        error_code ec;
        file_status stat = status(entry, ec);
        if (not status_known(stat)) {
            cout << entry << " 的状态是未知的。"
                 << "错误代码 = " << ec << '\n';
            continue;
        }
        cout << entry << " 的状态: 类型 = " << statusMap[stat.type()]
             << ", 权限: " << static_cast<size_t>(stat.permissions())
             << '\n';
    }
}

这段代码展示了如何获取并显示文件系统条目的状态。用户输入文件系统条目的名称,程序将显示该条目的类型和权限。如果状态未知,则显示错误代码。

文件系统的空间信息:space_info

每个现有的路径都存在于一个文件系统中。文件系统的大小通常非常大,但它们的大小是有限制的。可以通过 space(path const &entry [, error_code &ec]) 函数获取文件系统的大小、当前使用的字节数以及剩余的字节数。该函数返回一个包含有关包含 entry 的文件系统的信息的 POD 结构体 space_info。如果提供了 error_code 参数,则在没有错误发生时将其清除,如果发生错误,则设置为操作系统的错误代码。如果发生错误且未提供 error_code 参数,则会抛出 filesystem_error 异常,该异常的第一个参数为 path,操作系统的错误代码作为其 error_code 参数。返回的 space_info 具有三个字段:

  • uintmax_t capacity; // 总大小(以字节为单位)
  • uintmax_t free; // 文件系统上的空闲字节数
  • uintmax_t available; // 非特权进程的空闲字节数

如果某个字段无法确定,则其值设置为 -1(即 uintmax_t 类型的最大值)。可以这样使用该函数:

int main() {
    path tmp{"/tmp"};
    auto pod = space(tmp);
    cout << "包含 /tmp 的文件系统容量为 " << pod.capacity
         << " 字节,\n"
            "即 "
         << pod.capacity / (1024 * 1024)
         << " MB。\n"
            "空闲字节数: "
         << pod.free
         << "\n"
            "可用字节数: "
         << pod.available
         << "\n"
            "空闲 + 可用: "
         << pod.free + pod.available << '\n';
}

文件系统异常:filesystem_error

std::filesystem 命名空间提供了自己的异常类型 filesystem_error(参见第 10 章)。它的构造函数具有以下签名(方括号内的参数是可选的):

filesystem_error(
    std::string const &what,
    [path const &path1,
    [path const &path2,]
    ] error_code ec
);

由于文件系统功能与标准系统函数紧密相关,可以使用 errc 错误代码枚举值来获取 error_code,并将其传递给 filesystem_error,如下所示的程序:

int main() try {
    // 尝试代码块
    throw filesystem_error{"遇到异常", "p1", "p2",
                           make_error_code(errc::address_in_use)};
} catch (filesystem_error const &fse) {
    cerr << "what: " << fse.what()
         << "\n"
            "path1: "
         << fse.path1()
         << "\n"
            "path2: "
         << fse.path2()
         << "\n"
            "code: "
         << fse.code() << '\n';
    throw;
} catch (exception const &ec) {
    cerr << "\n"
            "普通异常的 what: "
         << ec.what() << "\n\n";
}

在这个例子中,filesystem_error 被抛出并包含了一个错误代码和两个路径。捕获异常后,程序会显示异常信息、路径和错误代码。如果捕获到 filesystem_error,它会显示相关的信息;如果捕获到其他异常,则显示普通异常的信息。

string 数据类型

C++ 提供了许多解决常见问题的方案。其中大部分设施是标准模板库(STL)的一部分,或者实现为通用算法(参见第 19 章)。C++ 程序员多次开发的功能之一是处理文本块,通常称为字符串。C 编程语言提供了基本的字符串支持。

为了处理文本,C++ 提供了 std::string 类型。在 C++ 中,传统的 C 库函数操作 C 字符串(NTB)已经被弃用,推荐使用字符串对象。许多 C 程序中的问题,如缓冲区溢出、边界错误和分配问题,都可以追溯到不正确地使用这些传统的 C 字符串库函数。使用 C++ 字符串对象可以避免许多这些问题。

实际上,字符串对象是类类型变量,从这个意义上说,它们与 cincout 等流对象类似。本节将介绍字符串类型对象的使用,重点是它们的定义和使用。在使用字符串对象时,通常使用成员函数语法:

stringVariable.operation(argumentList)

例如,如果 string1string2std::string 类型的变量,那么 string1.compare(string2) 可以用来比较两个字符串。

除了常见的成员函数外,std::string 类还提供了各种运算符,如赋值运算符(=)和比较运算符(==)。运算符通常会使代码更易于理解,通常比使用提供相似功能的成员函数更受欢迎。例如,而不是写:

if (string1.compare(string2) == 0)

通常更推荐使用:

if (string1 == string2)

要定义和使用字符串类型对象,源文件必须包含头文件 <string>。如果只是声明字符串类型,可以包含头文件 <iosfwd>

除了 std::string 之外,头文件 <string> 还定义了以下字符串类型:

  • std::wstring:由 wchar_t 字符组成的字符串类型;
  • std::u16string:由 char16_t 字符组成的字符串类型;
  • std::u32string:由 char32_t 字符组成的字符串类型。

对字符串的操作

一些可以在字符串上执行的操作会返回字符串中的索引。每当此类操作未能找到适当的索引时,返回值为 string::npos。这个值是 string::size_type 类型的符号值,通常是一个(无符号的)整数。

所有接受字符串对象作为参数的字符串成员函数也接受 NTBS(null-terminated byte strings)作为参数。对于接受字符串对象的操作符,通常也是如此。

一些字符串成员使用迭代器。迭代器在第18.2节中正式介绍。使用迭代器的成员函数在下一节(5.2节)中列出,但本章不会进一步讨论迭代器的概念。

字符串支持各种成员和操作符。本节提供了一个简要概述,列出了它们的功能,后续章节将详细讨论。总的来说,C++ 字符串非常灵活,几乎没有理由回退到 C 库来处理文本。C++ 字符串处理所有所需的内存管理,从而避免了 C 程序中常见的内存相关问题。然而,字符串的功能强大也使其变得复杂。学习和掌握其所有特性是困难的,最终你会发现并不是所有你预期的功能都有。例如,std::string 不提供不区分大小写的比较。但事实上,这种功能存在,只是有些隐蔽,目前在 C++ 注释中还不适合深入探讨。现在,C 标准库提供了有用的函数,可以在我们了解其限制并能够避免其陷阱的情况下使用。因此,当前要进行传统的不区分大小写的比较,可以使用以下代码:

strcasecmp(str1.c_str(), str2.c_str());

字符串支持以下功能:

  • 初始化:当字符串对象被定义时,它们总是被正确初始化。换句话说,它们总是处于有效状态。字符串可以被初始化为空,也可以使用已有的文本来初始化字符串。

  • 赋值:字符串可以被赋予新的值。可以使用成员函数(如 assign)来赋值,也可以使用普通的赋值操作符(即 =)。此外,还支持向字符缓冲区进行赋值。

  • 转换:字符串对象的部分或全部内容可以被解释为 C 字符串,但字符串的内容也可以作为一系列原始的二进制字节进行处理,不一定以值为 0 的字符结尾。此外,在许多情况下,普通字符和 C 字符串也可以用在接受 std::string 的地方。

  • 拆解:可以使用熟悉的索引操作符([])访问字符串中存储的单个字符,这允许我们访问或修改字符串中间的信息。

  • 比较:字符串可以与其他字符串(或 NTBS)进行比较,使用熟悉的逻辑比较操作符 ==!=<<=>>=。此外,还有一些成员函数提供了更细粒度的比较功能。

  • 修改:字符串的内容可以通过多种方式进行修改。操作符可以用于向字符串对象中添加信息、在字符串对象中间插入信息,或替换或删除(部分)字符串的内容。

  • 交换:字符串的交换能力允许我们在原则上交换两个字符串对象的内容,而不需要逐字节复制字符串的内容。

  • 搜索:可以从字符串对象中的任何位置搜索字符、字符集或字符序列,并可以选择向前或向后搜索。

  • 管理:提供了几种管理功能:可以查询字符串的长度或其空状态。此外,字符串对象也可以重新调整大小。

  • 流 I/O:字符串可以从流中提取或插入到流中。除了普通的字符串提取外,还可以安全地读取文本文件的一行,而不会出现缓冲区溢出的风险。由于提取和插入操作是基于流的,因此 I/O 功能与设备无关。

std::string 参考

在本节中,涉及到字符串的成员和与字符串相关的操作。各小节分别覆盖字符串的初始化器、迭代器、运算符和成员函数。以下术语在本节中使用:

  • 对象(object):始终指字符串对象。
  • 参数(argument):是 string const &char const *,除非另有说明。参数的内容不会被操作修改。
  • opos:指字符串对象中的偏移量。
  • apos:指参数中的偏移量。
  • on:表示对象中字符的数量(从 opos 开始)。
  • an:表示参数中字符的数量(从 apos 开始)。

oposapos 必须指向现有的偏移量,否则会生成异常(参见第10章)。相反,anon 可以超出可用字符的数量,这种情况下只考虑可用的字符。

许多成员函数为 onanapos 声明了默认值。opos 的默认值为 0,onan 的默认值是 string::npos,它可以解释为“到达字符串末尾所需的字符数量”。

对于从字符串对象的内容末尾向回处理的成员函数,opos 的默认值是对象的最后一个字符的索引,on 默认等于 opos + 1,表示以 opos 结尾的子字符串的长度。

以下成员函数概述中可以假设所有这些参数都接受默认值,除非另有说明。自然,如果一个函数需要的参数超出默认参数的范围,则不能使用默认参数值。

一些成员函数有重载版本,接受类型为 char const * 的初始参数。但即使不是这样,第一个参数也可以是 char const *,其中定义了 std::string 的参数。

若干成员函数接受迭代器。迭代器的技术细节在第18.2节中介绍,但此时可以忽略这些细节,而不会影响理解。与 aposopos 一样,迭代器必须指向现有的位置和/或字符串对象内容中的现有字符范围。

所有计算索引的字符串成员函数在失败时返回预定义常量 string::npos

s 字面量后缀用于指示在使用字符串字面量(如 "hello world")时,意图使用 std::string 常量。可以在声明 using namespace std 后使用,或者更具体地,在声明 using namespace std::literals::string_literals 后使用。

当显式定义或使用 std::string 对象时,字符串字面量的使用几乎不需要 s 后缀,但在使用 auto 关键字时,它可能会很有用。例如,auto str = "hello world"s 定义了 std::string 类型的 str,而如果省略字面量后缀,则它将是 char const *

初始化器

定义字符串对象后,它们保证处于有效状态。在定义时,可以通过以下几种方式初始化字符串对象:

可用的字符串构造函数如下:

  • string object;

    • 初始化对象为空字符串。定义字符串时,不应指定参数列表。
  • string object(string::size_type count, char ch);

    • 使用 count 个字符 ch 初始化对象。注意:使用此构造函数初始化字符串对象时,应使用这种构造函数形式,而不是使用大括号初始化列表构造函数,以避免选择初始化列表构造函数(见下文)。
  • string object(string const &argument);

    • 使用 argument 初始化对象。
  • string object(std::string const &argument, string::size_type apos, string::size_type an);

    • 使用 argument 的内容从索引位置 apos 开始,最多使用 anargument 的字符来初始化对象。
  • string object(InputIterator begin, InputIterator end);

    • 使用由两个 InputIterator 定义的字符范围中的字符来初始化对象。
  • string object(std::initializer_list<char> chars);

    • 使用初始化列表中指定的字符初始化对象。字符串也可以直接使用大括号初始化,如下所示:
      string str1({'h', 'e', 'l', 'l', 'o'});
      string str2{ 'h', 'e', 'l', 'l', 'o' };
      

迭代器

有关迭代器的详细信息,请参见第18.2节。作为对迭代器的简要介绍:迭代器类似于指针,并且在需要迭代器的情况下,通常可以使用指针。迭代器通常成对出现,定义了一组实体的范围。开始迭代器指向范围内的第一个实体,结束迭代器指向范围最后一个实体的下一位置。它们的差值等于迭代器范围内的实体数量。

迭代器在通用算法的上下文中扮演重要角色(参见第19章)。std::string 类定义了以下迭代器类型:

  • string::iteratorstring::const_iterator

    • 这些迭代器是前向迭代器。const_iteratorconst 字符串对象返回,而普通的迭代器由非 const 字符串对象返回。迭代器所引用的字符可以被修改。
  • string::reverse_iteratorstring::const_reverse_iterator

    • 这些迭代器也是前向迭代器,但当递增迭代器时,会到达字符串对象中的前一个字符。除此之外,它们与 string::iteratorstring::const_iterator 类似。

运算符

字符串对象可以通过成员函数进行操作,也可以通过运算符进行操作。使用运算符通常会使代码更自然。在运算符提供的功能与成员函数等效的情况下,通常更倾向于使用运算符。

以下是 std::string 对象可用的运算符(在示例中,“object”和“argument”指的是现有的 std::string 对象):

  • 普通赋值(=):

    • 可以将字符、C 字符串或 C++ 字符串赋值给字符串对象。赋值运算符返回其左操作数。例如:
      object = argument;
      object = "C string";
      object = 'x';
      object = 120;  // 等同于 object = 'x'
      
  • 加法(++=):

    • 算术加法赋值运算符和加法运算符用于将文本添加到字符串对象。复合赋值运算符返回其左操作数,加法运算符返回临时字符串对象中的结果。当使用加法运算符时,左操作数或右操作数必须是 std::string 对象,另一个操作数可以是 char、C 字符串或 C++ 字符串。例如:
      object += argument;
      object += "hello";
      object += 'x';
      argument + otherArgument;  // 两个 std::string 对象
      argument + "hello";
      "hello" + argument;
      argument + 'a';
      'a' + argument;
      
  • 索引运算符([]):

    • 索引运算符可用于检索对象的单个字符,或为非 const 字符串对象的单个字符赋值。此运算符不进行范围检查(使用 at() 成员函数进行范围检查)。此运算符返回 char &char const &。例如:
      object[3] = argument[5];
      
  • 逻辑运算符(==, !=, >, >=, <, <=):

    • 逻辑比较运算符可应用于两个字符串对象或一个字符串对象与 C 字符串,用于比较它们的内容。这些运算符返回一个 bool 值。可用的运算符有 ==!=>>=<<=。排序运算符按字典序比较它们的内容,使用 ASCII 字符排序顺序。例如:
      object == object;  // true
      object != (object + 'x');  // true
      object <= (object + 'x');  // true
      
  • 流相关运算符(<<>>):

    • 插入运算符(参见第3.1.4节)可用于将字符串对象插入到 ostream 中,提取运算符可用于从 istream 中提取字符串对象。提取运算符默认会先忽略所有空白字符,然后从 istream 中提取所有连续的非空白字符。虽然也可以提取字符数组,但使用字符串对象的好处是显而易见的:目标字符串对象会自动调整大小以适应所需的字符数。例如:
      cin >> object;
      cout << object;
      

成员函数

std::string 类提供了许多成员函数,以及一些额外的非成员函数,这些函数也被视为字符串类的一部分。以下是按字母顺序列出的所有这些函数。

符号值 string::npos 是由字符串类定义的,表示“未找到索引”,当成员函数返回字符串偏移位置时,会返回 npos。例如,当在不包含字符 x 的字符串对象上调用 object.find('x') 时,返回 npos,因为请求的位置不存在。

C 字符串中用于指示 NTBS 结束的最终 0 字节不被视为 C++ 字符串的一部分,因此当在包含 C 字符串字符的字符串对象中查找 0 时,成员函数会返回 npos,而不是 length()

以下是操作 std::string 对象的标准函数。当提到 size_t 参数时,可以将其解释为 string::size_type 类型的参数,但不定义默认参数值。size_type 应读作 string::size_type。在 size_type 中,提到的默认参数值适用。所有引用的函数都是 std::string 类的成员函数,除非另有说明。

  • char &at(size_t opos)

    • 返回指向指定位置字符的引用。对于 const 字符串对象,返回 char const &。该成员函数执行范围检查,如果传递了无效的索引,则会引发异常(默认情况下会中止程序)。
  • string &append(InputIterator begin, InputIterator end)

    • 将由 beginend 定义的字符范围追加到当前字符串对象中。
  • string &append(string const &argument, size_type apos, size_type an)

    • argument(或其子字符串)追加到当前字符串对象中。
  • string &append(char const *argument, size_type an)

    • argument 的前 an 个字符追加到字符串对象中。
  • string &append(size_type n, char ch)

    • n 个字符 ch 追加到当前字符串对象中。
  • string &assign(string const &argument, size_type apos, size_type an)

    • argument(或其子字符串)分配给字符串对象。如果 argument 类型为 char const * 并提供了一个额外的参数,则第二个参数被解释为初始化 an 的值,apos 使用 0 初始化。
  • string &assign(size_type n, char ch)

    • n 个字符 ch 分配给当前字符串对象。
  • char &back()

    • 返回对字符串对象中最后一个字符的引用。对于空字符串,结果是未定义的。
  • string::iterator begin()

    • 返回指向当前字符串对象第一个字符的迭代器。对于 const 字符串对象,返回 const_iterator
  • size_type capacity() const

    • 返回当前字符串对象中可以存储的字符数量,而无需调整大小。
  • string::const_iterator cbegin()

    • 返回指向当前字符串对象第一个字符的 const_iterator
  • string::const_iterator cend()

    • 返回指向当前字符串对象末尾的 const_iterator
  • void clear()

    • 清除字符串内容。
  • int compare(string const &argument) const

    • 使用 ASCII 字符排序序列对当前字符串对象中的文本与 argument 中的文本进行词典序比较。如果两个字符串内容相同,则返回零;如果当前对象中的文本应排在 argument 之前,则返回负值;如果当前对象中的文本应排在 argument 之后,则返回正值。
  • int compare(size_t opos, size_t on, string const &argument) const

    • 比较当前字符串对象中的子字符串与 argument 中的文本。最多比较从偏移 opos 开始的 on 个字符与 argument 中的文本。
  • int compare(size_t opos, size_t on, string const &argument, size_type apos, size_type an)

    • 比较当前字符串对象中的子字符串与 argument 中的子字符串。最多比较从偏移 opos 开始的 on 个字符与 argument 中的最多 an 个字符。在这种情况下,argument 必须是字符串对象。
  • int compare(size_t opos, size_t on, char const *argument, size_t an)

    • 比较当前字符串对象中的子字符串与 argument 中的子字符串。最多比较从偏移 opos 开始的 on 个字符与 argument 中的最多 an 个字符。argument 必须至少有 an 个字符。字符可以有任意值:0 值字符没有特殊意义。
  • bool contains(argument) const

    • 如果对象包含 argument 的字符作为子字符串,则返回 trueargument 可以是字符串、string_viewchar 或 NTBS。
  • size_t copy(char *argument, size_t on, size_type opos) const

    • 将当前字符串对象的内容(部分)复制到 argument 中。返回实际复制的字符数量。第二个参数指定要从当前字符串对象中复制的字符数量。没有 0 值字符被追加到复制的字符串中,但可以使用以下惯用法将 0 字节追加到复制的文本中:
      argument[object.copy(argument, string::npos)] = 0;
      

    当然,程序员应确保 argument 的大小足够容纳额外的 0 字节。

  • string::const_reverse_iterator crbegin()

    • 返回指向当前字符串对象最后一个字符的 const_reverse_iterator
  • string::const_reverse_iterator crend()

    • 返回指向当前字符串对象开头的 const_reverse_iterator
  • char const *c_str() const

    • 返回当前字符串对象的 NTBS 内容。
  • char const *data() const

    • 返回当前字符串对象的原始内容。由于此成员函数不会返回 NTBS(如 c_str),因此可以用来检索当前字符串对象中存储的任何信息,包括例如一系列 0 字节:
      string s(2, 0);
      cout << static_cast<int>(s.data()[1]) << '\n';
      
  • bool empty() const

    • 如果当前字符串对象不包含数据,则返回 true
  • string::iterator end()

    • 返回指向当前字符串对象最后一个字符之后的位置的迭代器。对于 const 字符串对象,返回 const_iterator
  • bool ends_with(argument) const

    • 如果对象的字符以 argument 结尾,则返回 trueargument 可以是字符串、string_viewchar 或 NTBS。
  • string &erase(size_type opos, size_type on)

    • 删除当前字符串对象中存储的信息的(子)字符串。
  • string::iterator erase(string::iterator begin, string::iterator end)

    • 参数 end 是可选的。如果省略,则使用当前对象的 end 成员返回的值。删除由 beginend 迭代器定义的字符。返回 begin 迭代器,该迭代器指向最后一个被删除字符之后的位置。
  • size_t find(string const &argument, size_type opos) const

    • 返回当前字符串对象中 argument 的第一次出现的索引。
  • size_t find(char const *argument, size_type opos, size_type an) const

    • 返回当前字符串对象中 argument 的第一次出现的索引。如果指定了所有三个参数,则第一个参数必须是 char const *
  • size_t find(char ch, size_type opos) const

    • 返回当前字符串对象中 ch 的第一次出现的索引。
  • size_t find_first_of(string const &argument, size_type opos) const

    • 返回当前字符串对象中第一个与 argument 中任何字符匹配的字符的索引。
  • size_type find_first_of(char const *argument, size_type opos, size_type an) const

    • 返回当前字符串对象中第一个与 argument 中任何字符匹配的字符的索引。如果提供了 opos,它指示从当前字符串对象中哪里开始搜索 argument。如果省略,则完全扫描字符串对象。如果提供了 an,它指示 char const * 参数中应使用的字符数量。它定义了一个从 argument 开始的子字符串。如果省略,则使用 argument 的所有字符。
  • size_type find_first_of(char ch, size_type opos)
    -返回当前字符串对象中第一个等于 ch 的字符的索引。

  • size_t find_first_not_of(string const &argument, size_type opos) const

    • 返回当前字符串对象中第一个不匹配 argument 中任何字符的字符的索引。
  • size_type find_first_not_of(char const *argument, size_type opos, size_type an) const

    • 返回当前字符串对象中第一个不匹配 argument 中任何字符的字符的索引。oposan 参数的处理方式与 find_first_of 类似。
  • size_t find_first_not_of(char ch, size_type opos) const

    • 返回当前字符串对象中第一个不等于 ch 的字符的索引。
  • size_t find_last_of(string const &argument, size_type opos) const

    • 返回当前字符串对象中最后一个与 argument 中任何字符匹配的字符的索引。
  • size_type find_last_of(char const *argument, size_type opos, size_type an) const

    • 返回当前字符串对象中最后一个与 argument 中任何字符匹配的字符的索引。如果提供了 opos,它指示从当前字符串对象中哪里开始搜索 argument(向字符串开头方向搜索)。如果省略,则完全扫描字符串对象。如果提供了 an,它指示 char const * 参数中应使用的字符数量。它定义了一个从 argument 开始的子字符串。如果省略,则使用 argument 的所有字符。
  • size_type find_last_of(char ch, size_type opos)

    • 返回当前字符串对象中最后一个等于 ch 的字符的索引。
  • size_t find_last_not_of(string const &argument, size_type opos) const

    • 返回当前字符串对象中最后一个不匹配 argument 中任何字符的字符的索引。
  • size_type find_last_not_of(char const *argument, size_type opos, size_type an) const

    • 返回当前字符串对象中最后一个不匹配 argument 中任何字符的字符的索引。oposan 参数的处理方式与 find_last_of 类似。
  • size_t find_last_not_of(char ch, size_type opos) const

    • 返回当前字符串对象中最后一个不等于 ch 的字符的索引。
  • char &front()

    • 返回对字符串对象中第一个字符的引用。对于空字符串,结果是未定义的。
  • allocator_type get_allocator()

    • 返回 std::string 类的分配器。
  • istream &std::getline(istream &istr, string &object, char delimiter = '\n')

    • 注意:这不是 std::string 类的成员函数。
    • istr 中读取一行文本。所有字符直到 delimiter(或流的结尾,以较早者为准)都被读取到 object 中。如果遇到 delimiter,则从流中移除它,但不存储在 object 中。
    • 如果未找到 delimiteristr.eof 返回 true。由于流可以被解释为布尔值(参见第 6.3.1 节),一个常见的惯用法是将所有行依次读取到字符串对象 line 中,如下所示:
      while (getline(istr, line))
          process(line);
      
    • 最后一行的内容,无论是否以分隔符结束,最终也会分配给 object
  • string &insert(size_t opos, string const &argument, size_type apos, size_type an)

    • argument 的(子)字符串插入到当前字符串对象的索引位置 opos 处。aposan 的参数必须同时提供或同时省略。
  • string &insert(size_t opos, char const *argument, size_type an)

    • argument(类型为 char const *)中的前 an 个字符插入到当前字符串对象的索引 opos 处。
  • string &insert(size_t opos, size_t count, char ch)

    • count 个字符 ch 插入到当前字符串对象的索引 opos 处。
  • string::iterator insert(string::iterator begin, char ch)

    • 在当前对象中由 begin 指定的位置插入字符 ch。返回 begin 迭代器。
  • string::iterator insert(string::iterator begin, size_t count, char ch)

    • 在当前对象中由 begin 指定的位置插入 count 个字符 ch。返回 begin 迭代器。
  • string::iterator insert(string::iterator begin, InputIterator abegin, InputIterator aend)

    • 在当前对象中由 begin 指定的位置插入由 abeginaend 定义的字符范围。返回 begin 迭代器。
  • size_t length() const

    • 返回当前字符串对象中存储的字符数量。
  • size_t max_size() const

    • 返回当前字符串对象可以存储的最大字符数量。
  • void pop_back()

    • 移除当前字符串对象中的最后一个字符。
  • void push_back(char ch)

    • 将字符 ch 附加到当前字符串对象的末尾。
  • string::reverse_iterator rbegin()

    • 返回指向当前字符串对象最后一个字符的反向迭代器。对于 const 字符串对象,返回 const_reverse_iterator
  • string::reverse_iterator rend()

    • 返回指向当前字符串对象中第一个字符之前的位置的反向迭代器。对于 const 字符串对象,返回 const_reverse_iterator
  • string &replace(size_t opos, size_t on, string const &argument, size_type apos, size_type an)

    • 将当前字符串对象中从 opos 开始的 on 个字符替换为 argument 的子字符串。如果 on 被指定为 0,则将 argument 插入到当前对象的偏移位置 opos
  • string &replace(size_t opos, size_t on, char const *argument, size_type an)

    • 将当前字符串对象中从 opos 开始的 on 个字符替换为 argument 中的前 an 个字符。
  • string &replace(size_t opos, size_t on, size_type count, char ch)

    • 将当前字符串对象中从 opos 开始的 on 个字符替换为 count 个字符 ch
  • string &replace(string::iterator begin, string::iterator end, string const &argument)

    • 将当前字符串对象中由 beginend 迭代器定义的字符序列替换为 argument。如果 argumentchar const * 类型,可以提供额外的参数 an,指定要替换的 argument 的字符数量。
  • string &replace(string::iterator begin, string::iterator end, size_type count, char ch)

    • count 个字符 ch 替换当前字符串对象中由迭代器 beginend 定义的字符序列。
  • string &replace(string::iterator begin, string::iterator end, InputIterator abegin, InputIterator aend)

    • 用由迭代器 abeginaend 定义的字符范围替换当前字符串对象中由迭代器 beginend 定义的字符序列。
  • void reserve(size_t request)

    • 将当前字符串对象的容量更改为至少 request。调用此成员后,capacity 返回值将至少为 request。请求小于 capacity 返回值的请求会被忽略。如果 request 超过 max_size 返回的值,会抛出 std::length_error 异常(std::length_errorstdexcept 头文件中定义)。调用 reserve() 的效果是重新定义字符串的容量:扩大容量时,额外的内存会被分配,但不会立即可用。这可以通过尝试访问超过字符串大小但未超过字符串容量的元素时抛出的 at() 成员函数异常来说明。
  • void resize(size_t size, char ch = 0)

    • 将当前字符串对象的大小调整为 size 个字符。如果字符串对象被调整为比当前大小更大的尺寸,则额外的字符将初始化为 ch。如果缩小尺寸,则会剪掉索引较高的字符。
  • size_t rfind(string const &argument, size_type opos) const

    • 返回当前字符串对象中 argument 最后出现的索引位置。搜索从当前对象的偏移位置 opos 向前进行,直到字符串的开头。
  • size_t rfind(char const *argument, size_type opos, size_type an) const

    • 返回当前字符串对象中 argument 最后出现的索引位置。搜索从当前对象的偏移位置 opos 向前进行,直到字符串的开头。参数 an 指定了要查找的 argument 子字符串的长度,从 argument 的开头开始。
  • size_t rfind(char ch, size_type opos) const

    • 返回当前字符串对象中 ch 最后出现的索引位置。搜索从当前对象的偏移位置 opos 向前进行,直到字符串的开头。
  • void shrink_to_fit()

    • 可选地将分配给字符串的内存减少到当前大小。实现者可以自由忽略或优化此请求。为了确保进行“收缩到适合”的操作,可以使用 string{stringObject}.swap(stringObject) 习惯用法。
  • size_t size() const

    • 返回当前字符串对象中存储的字符数量。此成员是 length() 的同义词。
  • bool starts_with(argument) const

    • 如果对象的字符范围以 argument 开头,则返回 true。参数 argument 可以是字符串、string_view、字符或 NTBS。
  • string substr(size_type opos, size_type on) const

    • 返回当前字符串对象中从索引 opos 开始的最多 on 个字符的子字符串。
  • void swap(string &argument)

    • 交换当前字符串对象的内容与 argument 的内容。对于此成员函数,argument 必须是一个 string 对象,而不能是 char const *

转换函数

有几个字符串转换函数可用于操作或生成 std::string 对象。以下是按字母顺序列出的这些函数。它们不是成员函数,而是声明在 std 命名空间中的无类(自由)函数。在使用这些函数之前,必须包含 <string> 头文件。

  • float stof(std::string const &str, size_t *pos = 0)

    • 初始的空白字符会被忽略。接下来,将以下字符序列转换为 float 值并返回:
      • 十进制浮点常量:
        • 可选的 +- 字符
        • 一系列十进制数字,可能包含一个小数点字符
        • 可选的 eE 字符,后跟一个可选的 -+ 字符,后跟一系列十进制数字
      • 十六进制浮点常量:
        • 可选的 +- 字符
        • 0x0X
        • 一系列十六进制数字,可能包含一个小数点字符
        • 可选的 pP 字符,后跟一个可选的 -+ 字符,后跟一系列十进制数字
      • 无穷大表达式:
        • 可选的 +- 字符
        • 单词 infinfinity(不区分大小写)
      • NaN 表达式:
        • 可选的 +- 字符
        • 单词 nannan(字母数字字符序列)(nan 是区分大小写的单词),返回 NaN 浮点值
    • 如果 pos != 0,则返回 str 中第一个未转换的字符的索引到 *pos。如果无法将 str 中的字符转换为 float,则会抛出 std::invalid_argument 异常,如果转换后的值超出 float 的范围,则会抛出 std::out_of_range 异常。
  • double stod(std::string const &str, size_t *pos = 0)

    • stof 描述的方式进行转换,但转换为 double 类型的值。
  • double stold(std::string const &str, size_t *pos = 0)

    • stof 描述的方式进行转换,但转换为 long double 类型的值。
  • int stoi(std::string const &str, size_t *pos = 0, int base = 10)

    • 初始的空白字符会被忽略。然后,将表示数字常量的所有字符转换为 int 类型的值并返回,所使用的数字系统的基数由 base 指定。可选的 +- 字符可以在数字字符前面。以 0 开头的值自动解释为八进制值,以 0x0X 开头的值作为十六进制字符解释。base 的值必须在 2 到 36 之间。如果 pos != 0,则返回 str 中第一个未转换的字符的索引到 *pos。如果无法将 str 中的字符转换为 int,则会抛出 std::invalid_argument 异常,如果转换后的值超出 int 的范围,则会抛出 std::out_of_range 异常。
    • 示例:
      int value = stoi("-123"s); // 赋值 value 为 -123
      value = stoi(" 123"s, 0, 5); // 赋值 value 为 38
      
  • long stol(std::string const &str, size_t *pos = 0, int base = 10)

    • stoi 描述的方式进行转换,但转换为 long 类型的值。
  • long long stoll(std::string const &str, size_t *pos = 0, int base = 10)

    • stoi 描述的方式进行转换,但转换为 long long 类型的值。
  • unsigned long stoul(std::string const &str, size_t *pos = 0, int base = 10)

    • stoi 描述的方式进行转换,但转换为 unsigned long 类型的值。
  • unsigned long long stoull(std::string const &str, size_t *pos = 0, int base = 10)

    • stoul 描述的方式进行转换,但转换为 unsigned long long 类型的值。
  • std::string to_string(Type value)

    • Type 可以是 intlonglong longunsignedunsigned longunsigned long longfloatdoublelong double 类型。将参数的值转换为文本表示形式,并返回 std::string 类型的值。
  • std::wstring to_wstring(Type value)

    • to_string 描述的方式进行转换,返回 std::wstring 类型的值。

std::string_view

除了 std::string 类之外,std::string_view 类也可以用作 char 数组的封装类。std::string_view 类可以视为一种轻量级的字符串类。在使用 std::string_view 对象之前,必须包含 <string_view> 头文件。

除了标准构造函数(默认构造、复制构造和移动构造)外,std::string_view 还提供了以下构造函数:

  • constexpr string_view(char const *src, size_t nChars)

    • src 的前 nChars 个字符构造一个 string_view 对象。范围 [src, src + nChars) 中的字符可以是值为 0 的字符。
  • constexpr string_view(char const *src)

    • src 开始的 NTBS(Null 终止的字符数组)构造一个 string_view 对象。传递给此构造函数的参数不能是空指针。
  • constexpr string_view(Iterator begin, Iterator end)

    • 从迭代器范围 [begin, end) 中的字符构造 string_view 对象。

std::string_view 对象不包含其初始化数据的副本。相反,它引用了构造时使用的字符。例如,以下程序可能会产生不可预测的输出,但当 hello 数组被定义为静态数组时,它会输出 “hello”:

#include <string_view>
#include <iostream>
using namespace std;

string_view fun() {
    char hello[] = "hello";
    return {hello};
}

int main() {
    string_view obj = fun();
    cout << obj << '\n';
}

std::string_view 类提供了与 std::string 相同的成员函数,但不包括扩展字符串视图的成员(例如,不能添加或插入字符)。然而,string_view 对象可以修改其字符(使用索引运算符或 at 成员函数)。

string_view 类还提供了一些额外的成员函数:

  • remove_prefix(size_t step)

    • 将对象字符范围的起始位置向前移动 step 个位置。
  • remove_suffix(size_t step)

    • 将对象字符范围的结束位置向后移动 step 个位置。
  • constexpr string_view operator""sv(char const *str, size_t len)

    • 返回一个包含 strlen 个字符的 string_view 对象。

std::string 类一样,std::string_view 类也提供了哈希功能,因此 string_view 对象可以用作例如 map 容器中的键。

输入输出流库

除了 C 语言中广泛使用的标准流(FILE)方法之外,C++ 提供了一个基于类的输入/输出(I/O)库。所有 C++ 的 I/O 功能都定义在 std 命名空间中,除非有歧义,否则下面省略了 std:: 前缀。

在第 3 章中,我们已经看到了一些使用 C++ I/O 库的例子,特别是展示了插入运算符(<<)和提取运算符(>>)。在本章中,我们将更详细地讨论 I/O。

C++ 的输入和输出功能的讨论重度使用了类概念和成员函数的概念。虽然类的构造(见第 7 章)和继承(在第 13 章正式讨论)尚未覆盖,但可以在这些技术背景尚未介绍之前讨论 I/O 功能。

大多数 C++ I/O 类的名称以 basic_ 开头(如 basic_ios)。然而,这些 basic_ 名称在 C++ 程序中并不常见,因为大多数类也通过 using 声明来定义,例如:

using ios = basic_ios<char>;

由于 C++ 支持各种字符类型(如 charwchar_t),I/O 功能通过模板机制开发,允许轻松转换为除传统 char 类型以外的字符类型。如第 21 章所述,这也允许构造通用软件,可以用于任何特定类型的字符。因此,类似于上述 using 声明,还存在:

using wios = basic_ios<wchar_t>;

这样,wios 可以用于 wchar_t 类型。由于存在这些类型定义,在 C++ 注释中省略了 basic_ 前缀而不会造成连续性的丧失。C++ 注释主要集中在标准的 8 位 char 类型。

iostream 对象不能使用标准的前向声明进行声明,例如:

class std::ostream;
// 现在错误

I/O流库

除了 C 语言中广泛使用的标准流(FILE)方法,C++ 提供了基于类的输入/输出(I/O)库。所有 C++ 的 I/O 功能都定义在 std 命名空间中,除非有歧义,否则下面省略了 std:: 前缀。

在第 3 章中,我们已经看到了一些使用 C++ I/O 库的例子,特别是展示了插入运算符(<<)和提取运算符(>>)。在本章中,我们将更详细地讨论 I/O。

C++ 的输入和输出功能的讨论重度使用了类概念和成员函数的概念。虽然类的构造(见第 7 章)和继承(在第 13 章正式讨论)尚未覆盖,但可以在这些技术背景尚未介绍之前讨论 I/O 功能。

大多数 C++ I/O 类的名称以 basic_ 开头(如 basic_ios)。然而,这些 basic_ 名称在 C++ 程序中并不常见,因为大多数类也通过 using 声明来定义,例如:

using ios = basic_ios<char>;

由于 C++ 支持各种字符类型(如 charwchar_t),I/O 功能通过模板机制开发,允许轻松转换为除传统 char 类型以外的字符类型。如第 21 章所述,这也允许构造通用软件,可以用于任何特定类型的字符。因此,类似于上述 using 声明,还存在:

using wios = basic_ios<wchar_t>;

这样,wios 可以用于 wchar_t 类型。由于存在这些类型定义,在 C++ 注释中省略了 basic_ 前缀而不会造成连续性的丧失。C++ 注释主要集中在标准的 8 位 char 类型。

iostream 对象不能使用标准的前向声明进行声明,例如:

class std::ostream;
// 现在错误

相反,声明 iostream 类时应该包含 <iosfwd> 头文件:

#include <iosfwd>
// 正确的 iostream 类声明方法

使用 C++ 的 I/O 提供了额外的类型安全性。对象(或普通值)被插入到流中。与 C 语言中常见的 printf 函数通过格式字符串指示期望值的类型的情况相比,C++ 的 iostream 方法直接在 cout 流中使用对象的位置,如:

cout << "There were " << nMaidens << " virgins present\n";

编译器会注意到 nMaidens 变量的类型,将其正确值插入到 cout 流中的适当位置。

与 C 语言中的情况相比,虽然 C 编译器变得越来越智能,虽然设计良好的 C 编译器可能会警告你格式说明符与变量类型之间的匹配不一致,但它们最多只能发出警告。C++ 中的类型安全性防止了类型不匹配,因为没有类型需要匹配。

此外,iostream 提供了与 C 语言标准 FILE I/O 方法相似的功能:可以打开、关闭、定位、读取、写入文件等。在 C++ 中,C 使用的基本 FILE 结构仍然可用。但 C++ 在此基础上增加了基于类的 I/O,带来了类型安全性、可扩展性和清晰的设计。

ANSI/ISO 标准规定了架构独立的 I/O。本章没有涵盖所有标准的具体规范,因为这些规范通常依赖于继承和多态性,这些主题在第 13 章和第 14 章中正式讨论。第 25 章中提供了一些示例,本章在适当的地方引用了其他章节的具体部分。本章组织如下(见图 6.1):

  • ios_base 类是 iostream I/O 库构建的基础。它定义了所有 I/O 操作的核心,并提供了检查 I/O 流状态和输出格式化的设施。

  • ios 类直接从 ios_base 派生。每个执行输入或输出的 I/O 库类都从 ios 类派生,因此继承了其(以及,暗示地:ios_base 的)功能。阅读本章时,请务必记住这一点。继承的概念在这里不讨论,而是在第 13 章中讨论。

  • ios 类很重要,因为它实现了与缓冲区的通信,流使用的缓冲区是一个 streambuf 对象,负责实际的 I/O 操作,如文件、键盘、屏幕、互联网连接等。因此,iostream 对象不会自己执行 I/O 操作,而是将这些操作委托给与它们关联的(流)缓冲区对象。

  • 接下来,讨论基本的 C++ 输出设施。用于输出操作的基本类是 ostream,定义了插入运算符以及其他将信息写入流的设施。除了将信息插入文件外,还可以将信息插入内存缓冲区,为此有 ostringstream 类。格式化输出在很大程度上可以使用 ios 类中定义的设施,但也可以使用操控符直接将格式化命令插入流中。本章还讨论了 C++ 输出的这一方面。

  • 基本的 C++ 输入设施由 istream 类实现。该类定义了提取运算符和相关的输入设施。与将信息插入内存缓冲区(使用 ostringstream)类似,还有 istringstream 类用于从内存缓冲区提取信息。

  • iostream 类结合了 istreamostream 提供的功能。因此,iostream 对象可以用于从同一个对象中读取和写入。

  • fstream 类是一个常见的 iostream 类示例:fstream 对象用于从同一个文件中读取和写入,这在处理数据库的程序中经常使用。本章还涵盖了从同一个流中读取和写入的主题,以及使用 filebuf 对象混合 C 和 C++ I/O 的主题。其他与 I/O 相关的主题在 C++ 注释的其他部分(参见第 20.15 节和第 25 章)中也有涵盖。

流对象有一个有限但重要的角色:它们是输入或输出对象与流缓冲区之间的接口,流缓冲区负责对访问的设备进行实际的输入和输出。

这种方法允许我们为新的设备构造新的 streambuf,并将该 streambuf 与“老旧的” istreamostream 类设施结合使用。了解 iostream 对象的格式化角色和 streambuf 对象实现的外部设备缓冲接口之间的区别非常重要。与新设备(如套接字或文件描述符)接口时,需要构造新的 streambuf 类型,而不是新的 istreamostream 对象。虽然可以围绕 istreamostream 类构建包装类,以简化对特殊设备的访问,这就是 stringstream 类构造的方式。

特殊头文件

有几个与 iostream 相关的头文件可供使用。根据实际情况,应使用以下头文件:

  • <iosfwd>:如果只需要声明流类,则应包含此头文件。例如,如果函数定义了一个对 ostream 的引用参数,那么编译器不需要知道 ostream 的具体实现。当声明这样的函数时,只需要声明 ostream 类。不能使用:

    class std::ostream; // 错误的声明
    void someFunction(std::ostream &str);
    

    而应该使用:

    #include <iosfwd> // 正确声明 ostream 类
    void someFunction(std::ostream &str);
    
  • <ios>:当使用 ios 类中定义的类型和设施(如 ios::off_type)时,应包含此头文件。

  • <streambuf>:当使用 streambuffilebuf 类时,应包含此头文件。参见第 14.8 节和 14.8.2 节。

  • <istream>:当使用 istream 类或使用既进行输入又进行输出的类时,应包含此头文件。参见第 6.5.1 节。

  • <ostream>:当使用 ostream 类或使用既进行输入又进行输出的类时,应包含此头文件。参见第 6.4.1 节。

  • <iostream>:当使用全局流对象(如 cincout)以及定义能够从同一设备进行读取和写入的流时,应包含此头文件(参见第 14.9 节)。

  • <fstream>:当使用文件流类时,应包含此头文件。参见第 6.4.2 节、第 6.5.2 节和第 6.6.3 节。

  • <sstream>:当使用字符串流类时,应包含此头文件。参见第 6.4.3 节和第 6.5.3 节。

  • <iomanip>:当使用带参数的操作符时,应包含此头文件。参见第 6.3.2 节。

基础:ios_base

std::ios_base 类构成了所有 I/O 操作的基础,定义了检查 I/O 流状态的设施和大部分输出格式化功能。所有 I/O 库的流类都通过 ios 类从这个类派生,并继承了它的能力。由于 ios_base 是 C++ I/O 的基础,我们在这里将其作为 C++ I/O 库中的第一个类进行介绍。

请注意,与 C 语言一样,C++ 中的 I/O 不是语言的一部分(尽管它是 ANSI/ISO C++ 标准的一部分)。虽然从技术上讲,忽略所有预定义的 I/O 设施是可能的,但没有人这样做,因此 I/O 库代表了 C++ 的事实上的 I/O 标准。同样需要注意的是,正如之前提到的,iostream 类本身并不负责最终的 I/O,而是将其委托给辅助类:streambuf 类或其派生类。

无法也不需要直接构造一个 ios_base 对象。其构造总是进一步构造 iostream 层次结构下的对象(如 std::ios)的副作用。ios 是 iostream 层次结构中的下一个类(参见图 6.1)。由于所有流类都从 ios 继承,因此也从 ios_base 继承,所以在实践中,ios_baseios 之间的区别并不重要。因此,实际上由 ios_base 提供的功能将在 ios 的功能中讨论。对某个特定功能定义的实际类感兴趣的读者应查阅相关的头文件(如 ios_base.hbasic_ios.h)。

接口 streambuf 对象:ios

std::ios 类直接从 ios_base 类派生,并实际定义了 C++ I/O 库所有流类的基础。

虽然可以直接构造一个 ios 对象,但这种做法很少见。ios 类的目的是提供 basic_ios 类的功能,并添加几个新的功能,这些功能都与由 ios 类管理的 streambuf 对象相关。

所有其他流类要么直接要么间接地从 ios 类派生。这意味着,如第 13 章所述,iosios_base 类的所有功能也适用于其他流类。在讨论这些附加流类之前,现介绍 ios 类(以及隐含地:ios_base)提供的特性。在某些情况下,可能需要显式地包含 ios。一个例子是源代码中提到格式化标志的情况(参见第 6.3.2.2 节)。

ios 类提供了多个成员函数,其中大多数与格式化相关。其他常用的成员函数包括:

  • std::streambuf* ios::rdbuf()
    返回指向 streambuf 对象的指针,该对象形成 ios 对象与其通信设备之间的接口。有关 streambuf 类的更多信息,请参见第 14.8 节和第 25.1.2 节。

  • std::streambuf* ios::rdbuf(std::streambuf* new)
    当前的 ios 对象与另一个 streambuf 对象关联。返回指向 ios 对象原始 streambuf 对象的指针。当流对象超出作用域时,该指针指向的对象不会被销毁,而是由 rdbuf 的调用者拥有。

  • std::ostream* ios::tie()
    返回当前与 ios 对象绑定的 ostream 对象的指针(见下一个成员函数)。返回值为 0 表示当前没有 ostream 对象绑定到 ios 对象。有关详细信息,请参见第 6.5.5 节。

  • std::ostream* ios::tie(std::ostream* outs)
    ostream 对象绑定到当前的 ios 对象。这意味着在当前 ios 对象执行输入或输出操作之前,ostream 对象会被刷新。返回指向 ios 对象原始 ostream 对象的指针。要解除绑定,请传递参数 0。有关示例,请参见第 6.5.5 节。

条件状态

流的操作可能因各种原因而失败。每当操作失败时,进一步对流的操作会被暂停。可以检查、设置和可能清除流的状态,使程序能够修复问题,而不是中止。当前章节描述了用于检查或操作流状态的成员。

条件通过以下条件标志表示:

  • ios::badbit
    如果此标志被设置,则表示在与流接口的 streambuf 对象的级别上请求了非法操作。有关示例请参见下面的成员函数。

  • ios::eofbit
    如果此标志被设置,则表示 ios 对象已检测到文件结尾(EOF)。

  • ios::failbit
    如果此标志被设置,则表示流对象执行的操作失败(例如尝试提取一个 int 但输入中没有数字字符)。在这种情况下,流本身无法执行请求的操作。

  • ios::goodbit
    当没有其他三个条件标志被设置时,此标志被设置。

有几个条件成员函数可用于操作或确定 ios 对象的状态。最初它们返回 int 值,但其当前返回类型为 bool

  • bool ios::bad()
    当流的 badbit 被设置时返回 true,否则返回 false。如果返回 true,则表示在与流接口的 streambuf 对象的级别上请求了非法操作。这意味着 streambuf 本身的行为不符合预期。例如:

    std::ostream error(0);
    

    这里构造了一个没有提供工作 streambuf 对象的 ostream 对象。由于这个 streambuf 永远不会正常工作,它的 badbit 标志从一开始就被设置:error.bad() 返回 true

  • bool ios::eof()
    当检测到文件结尾(EOF)(即 eofbit 标志已设置)时返回 true,否则返回 false。假设我们从 cin 逐行读取,但最后一行没有以 \n 字符结束。在这种情况下,std::getline 尝试读取 \n 分隔符时首先遇到文件结尾。这会设置 eofbit 标志,cin.eof() 返回 true。例如,假设有 std::string strmain 执行以下语句:

    getline(cin, str);
    cout << cin.eof();
    

    则:

    echo "hello world" | program
    

    打印值为 0(未检测到 EOF)。但在:

    echo -n "hello world" | program
    

    后,打印值为 1(检测到 EOF)。

  • bool ios::fail()
    bad 返回 truefailbit 标志被设置时返回 true,否则返回 false。在上述示例中,cin.fail() 返回 false,无论我们是否以分隔符结束最后一行(因为我们已读取一行)。但是,执行另一个 getline 将导致 failbit 标志被设置,导致 cin.fail() 返回 true。通常:fail 返回 true 如果请求的流操作失败。一个简单的示例是尝试提取一个 int,但输入流中包含文本 “hello world”。流对象的 bool 解释(见下文)返回值不是 fail()

条件状态

  • bool ios::good()
    返回 goodbit 标志的值。当没有其他条件标志(badbiteofbitfailbit)被设置时,goodbit 标志被设置,返回 true。考虑以下小程序:

    #include <iostream>
    #include <string>
    using namespace std;
    
    void state()
    {
        cout << "\n"
             "Bad: " << cin.bad() << " "
             "Fail: " << cin.fail() << " "
             "Eof: " << cin.eof() << " "
             "Good: " << cin.good() << '\n';
    }
    
    int main()
    {
        string line;
        int x;
        cin >> x;
        state();
        cin.clear();
        getline(cin, line);
        state();
        getline(cin, line);
        state();
    }
    

    当此程序处理一个包含两行的文件时,第一行是 “hello”,第二行是 “world”,而第二行没有以 \n 字符结束时,输出结果如下:

    Bad: 0 Fail: 1 Eof: 0 Good: 0
    Bad: 0 Fail: 0 Eof: 0 Good: 1
    Bad: 0 Fail: 0 Eof: 1 Good: 0
    

    因此,提取 x 失败(good 返回 false)。然后,错误状态被清除,第一行被成功读取(good 返回 true)。最后,第二行被读取(不完整):good 返回 falseeof 返回 true

  • 将流解释为布尔值
    流可以在期望逻辑值的表达式中使用。一些示例包括:

    if (cin)
    if (cin >> x)
    // cin 本身被解释为布尔值
    // cin 在提取后被解释为布尔值
    if (getline(cin, str)) // getline 返回 cin
    

    在将流解释为逻辑值时,实际上是解释了 not fail()。上述示例因此可以重写为:

    if (not cin.fail())
    if (not (cin >> x).fail())
    if (not getline(cin, str).fail())
    

    然而,前者几乎被独占使用。

用于管理错误状态的成员:

  • void ios::clear()
    当发生错误条件时,如果可以修复该条件,可以使用 clear 来清除文件的错误状态。clear 有一个重载版本接受状态标志,在首先清除当前标志集后设置新的状态标志:clear(int state)。其返回类型为 void

  • ios::iostate ios::rdstate()
    返回当前设置的 ios 对象的标志集(以 int 形式)。要测试特定标志,使用按位与运算符:

    if (!(iosObject.rdstate() & ios::failbit))
    {
        // 上一个操作没有失败
    }
    

    请注意,这个测试不能用于 goodbit 标志,因为其值等于零。要测试“好”的状态,可以使用如下构造:

    if (iosObject.rdstate() == ios::goodbit)
    {
        // 状态是“好”
    }
    
  • void ios::setstate(ios::iostate state)
    使用 setstate 可以为流设置特定的状态集。其返回类型为 void。例如:

    cin.setstate(ios::failbit);
    // 将状态设置为“fail”
    

    要在一个 setstate() 调用中设置多个标志,可以使用按位或运算符:

    cin.setstate(ios::failbit | ios::eofbit);
    

成员 clear 是清除所有错误标志的快捷方式。当然,清除标志并不自动意味着错误条件也被清除。策略应该是:

  • 检测到错误条件,
  • 修复错误,
  • 调用成员 clear

C++ 支持异常机制来处理异常情况。根据 ANSI/ISO 标准,异常可以与流对象一起使用。有关异常的内容,请参见第10章。使用异常与流对象一起的内容涵盖在第10.7节。

格式化输出和输入

信息如何写入流(或偶尔从流中读取)由格式化标志控制。格式化用于设置输出字段或输入缓冲区的宽度,并确定值的显示形式(例如,基数)。大多数格式化功能属于 ios 类的领域。格式化由 ios 类定义的标志控制。这些标志可以通过两种方式进行操作:使用专门的成员函数或使用直接插入或提取流的操纵符。通常,两种方法都可以使用,具体选择取决于实际情况。以下概述首先介绍各种成员函数,然后讨论标志和操纵符,并提供示例说明如何操作标志及其效果。

许多操纵符没有参数,只要包含了流头文件(例如 iostream),即可使用。某些操纵符需要参数。要使用这些操纵符,必须包含头文件 iomanip

6.3.2.1 格式修改成员函数

有几个成员函数可用于操作 I/O 格式标志。除以下列出的成员函数外,通常可以使用操纵符直接插入或提取流。可用的成员函数按字母顺序列出,但实际中最重要的包括 setfunsetfwidth

  • ios &ios::copyfmt(ios &obj)
    obj 的所有格式标志复制到当前的 ios 对象中。返回当前的 ios 对象。

  • ios::fill() const
    返回当前的填充字符。默认情况下,这是空格。

  • ios::fill(char padding)
    重新定义填充字符,返回重新定义前使用的填充字符。可以直接将 setfill 操纵符插入 ostream 中来代替使用此成员函数。示例:

    cout.fill('0');
    // 使用 '0' 作为填充字符
    cout << setfill('+'); // 使用 '+' 作为填充字符
    
  • ios::fmtflags ios::flags() const
    返回控制流格式状态的当前标志集。要检查是否设置了特定标志,使用按位与运算符。示例:

    if (cout.flags() & ios::hex)
        cout << "Integral values are printed as hex numbers\n";
    
  • ios::fmtflags ios::flags(ios::fmtflags flagset)
    返回以前的标志集,并将新标志集定义为 flagset。多个标志可以使用按位或运算符指定。示例:

    // 更改表示为十六进制
    cout.flags(ios::hex | cout.flags() & ~ios::dec);
    
  • int ios::precision() const
    返回输出浮点值时使用的有效数字的数量(默认:6)。

  • int ios::precision(int signif)
    设置输出实值时使用的有效数字的数量为 signif。返回先前使用的有效数字数量。如果所需的有效数字数量超过 signif,则以“科学”表示法显示数字(见第6.3.2.2节)。操纵符:setprecision。示例:

    cout.precision(3);
    // 3 位有效数字
    
  • cout << setprecision(3);
    使用操纵符

    cout << 1.23 << " " << 12.3 << " " << 123.12 << " " << 1234.3 << '\n';
    

    显示结果1.23 12.3 123 1.23e+03

  • ios::fmtflags ios::setf(ios::fmtflags flags)
    设置一个或多个格式化标志(使用按位或运算符组合多个标志)。已设置的标志不受影响。返回先前的标志集。可以使用操纵符 setiosflags 代替此成员函数。以下是一些示例:

  • ios::fmtflags ios::setf(ios::fmtflags flags, ios::fmtflags mask)
    清除 mask 中提到的所有标志,并设置 flags 中指定的标志。返回先前的标志集。以下是一些示例:

    // 在宽字段中左对齐信息:
    cout.setf(ios::left, ios::adjustfield);
    
    // 将整数值以十六进制形式显示:
    cout.setf(ios::hex, ios::basefield);
    
    // 以科学记数法显示浮点值:
    cout.setf(ios::scientific, ios::floatfield);
    
  • ios::fmtflags ios::unsetf(fmtflags flags)
    清除指定的格式化标志(保持其他标志不变)并返回先前的标志集。对要取消的活动默认标志(例如,cout.unsetf(ios::dec))的请求会被忽略。可以使用操纵符 resetiosflags 代替此成员函数。示例:

    cout << 12.24;                      // 显示 12.24
    cout.setf(ios::fixed);
    cout << 12.24;                      // 显示 12.240000
    cout.unsetf(ios::fixed);            // 取消之前的 ios::fixed 设置
    cout << 12.24;                      // 显示 12.24
    cout << resetiosflags(ios::fixed);  // 使用操纵符代替 unsetf
    
  • int ios::width() const
    返回下一个插入操作要使用的当前输出字段宽度。默认值为 0,表示“根据需要写入值所需的字符数”。

  • int ios::width(int nchars)
    设置下一个插入操作的字段宽度为 nchars,返回之前使用的字段宽度。此设置不是持久的。每次插入操作后,字段宽度会重置为 0。操纵符:std::setw(int)。示例:

    cout.width(5);
    cout << 12;  // 使用 5 个字符的字段宽度
    
    cout << setw(12) << "hello";  // 使用 12 个字符的字段宽度
    

格式化标志

大多数格式化标志与输出信息相关。信息可以以两种基本方式写入输出流:

  1. 使用二进制输出:信息直接写入输出流,而不先转换为可读的文本格式。
  2. 使用格式化输出:计算机内存中的值首先被转换为可读的文本,然后再写入输出流。

格式化标志用于定义这种转换的方式。本节将覆盖所有格式化标志。格式化标志可以通过成员函数(或如果有的话,通过操纵符)进行设置或清除。对于每个标志,都展示了如何通过成员函数或操纵符进行控制。

显示信息在宽字段中

  • ios::internal
    在负数的负号和数值之间添加填充字符(默认为空格)。其他值和数据类型右对齐。
    操纵符:std::internal
    示例:

    cout.setf(ios::internal, ios::adjustfield);
    cout << internal;
    // 使用操纵符
    cout << '\'' << setw(5) << -5 << "'\n";  // 显示 '-  5'
    
  • ios::left
    在宽于显示值的字段中左对齐值。
    操纵符:std::left
    示例:

    cout.setf(ios::left, ios::adjustfield);
    cout << left;
    // 使用操纵符
    cout << '\'' << setw(5) << "hi" << "'\n";  // 显示 'hi   '
    
  • ios::right
    在宽于显示值的字段中右对齐值。这是默认设置。
    操纵符:std::right
    示例:

    cout.setf(ios::right, ios::adjustfield);
    cout << right;
    // 使用操纵符
    cout << '\'' << setw(5) << "hi" << "'\n";  // 显示 '  hi'
    

使用不同的数字表示法

  • ios::dec
    以十进制形式显示整数值。
    操纵符:std::dec
    示例:

    cout.setf(ios::dec, ios::basefield);
    cout << dec;
    // 使用操纵符
    cout << 0x10;  // 显示 16
    
  • ios::hex
    以十六进制形式显示整数值。
    操纵符:std::hex
    示例:

    cout.setf(ios::hex, ios::basefield);
    cout << hex;
    // 使用操纵符
    cout << 16;  // 显示 10
    
  • ios::oct
    以八进制形式显示整数值。
    操纵符:std::oct
    示例:

    cout.setf(ios::oct, ios::basefield);
    cout << oct;
    // 使用操纵符
    cout << 16;  // 显示 20
    
  • std::setbase(int radix)
    这是一个操纵符,用于将数字表示法更改为十进制、十六进制或八进制。
    示例:

    cout << setbase(8);  // 八进制数字,使用 10 表示十进制,16 表示十六进制
    cout << 16;         // 显示 20
    

处理空白字符和刷新流

  • std::endl:

    • 描述: 插入一个换行符并刷新流。通常情况下,刷新流并不必要,这会不必要地降低 I/O 处理速度。因此,除非明确需要刷新流,否则应避免使用 std::endl(可以使用 '\n' 代替)。注意,程序终止时或当流“绑定”到另一个流时,流会自动刷新(详见第 6.3 节的 tie)。
    • 示例:
      cout << "hello" << endl;
      
  • std::ends:

    • 描述: 向流中插入一个 0 字节。通常与内存流一起使用(详见第 6.4.3 节)。
    • 建议: 优先使用 << '\n'
  • std::flush:

    • 描述: 使用该成员可以刷新流。通常情况下,刷新流并不必要,这会不必要地降低 I/O 处理速度。因此,除非明确需要刷新流,否则应避免使用 std::flush。注意,程序终止时或当流“绑定”到另一个流时,流会自动刷新(详见第 6.3 节的 tie)。
    • 示例:
      cout << "hello" << flush;
      
  • ios::skipws:

    • 描述: 跳过前导空白字符(空格、制表符、换行符等)。这是默认设置。如果未设置该标志,则不会跳过前导空白字符。
    • 建议: 如果可能,避免使用。
    • 操控符: std::skipws
    • 示例:
      cin.setf(ios::skipws); // 取消设置时使用 cin.unsetf(ios::skipws)
      cin >> skipws;
      int value;
      cin >> value;
      
  • ios::unitbuf:

    • 描述: 设置该标志的流会在每次输出操作后刷新其缓冲区。通常情况下,刷新流并不必要,这会不必要地降低 I/O 处理速度。因此,除非明确需要刷新流,否则应避免设置 unitbuf。注意,程序终止时或当流“绑定”到另一个流时,流会自动刷新(详见第 6.3 节的 tie)。
    • 操控符: std::unitbufstd::nounitbuf
    • 示例:
      cout.setf(ios::unitbuf);
      cout << unitbuf;
      cout.write("xyz", 3);
      
  • std::ws:

    • 描述: 移除当前位置的所有空白字符(空格、制表符、换行符等)。即使设置了 ios::noskipws 标志,仍然会移除空白字符。
    • 操控符: std::ws
    • 示例(假设输入包含 4 个空白字符,随后是字符 X):
      cin >> ws;
      cin.get(); // 跳过空白字符
      // 返回 'X'
      

输出

在 C++ 中,输出主要基于 std::ostream 类。ostream 类定义了基本的操作符和成员,用于将信息插入到流中:插入操作符 (<<) 和像 write 这样的特殊成员,用于将未格式化的信息写入流中。

ostream 类作为多个其他类的基类,这些类都提供了 ostream 类的基本功能,同时添加了各自的特性。在接下来的章节中,将讨论以下类:

  • ostream:提供基本的输出功能;
  • ofstream:允许我们写入文件(类似于 C 的 fopen(filename, "w"));
  • ostringstream:允许我们将信息写入内存(类似于 C 的 sprintf 函数)。

基本输出:ostream

ostream 类定义了基本的输出功能。coutclogcerr 对象都是 ostream 对象。所有与输出相关的功能,都是由 ios 类定义的,也都可以在 ostream 类中使用。

我们可以通过以下构造函数定义 ostream 对象:

  • std::ostream object(std::streambuf *sb);:该构造函数创建一个 ostream 对象,它是一个封装了现有 std::streambuf 对象的包装器。不能定义一个普通的 ostream 对象(例如,使用 std::ostream out;),然后用它进行插入操作。当使用 cout 或其相关对象时,实际上是在使用一个已经为我们定义好的 ostream 对象,该对象通过一个(也已预定义的)streambuf 对象处理实际的接口。

不过,可以定义一个 ostream 对象并传递一个空指针。这样的对象不能用于插入操作(即,当向其中插入内容时,它会设置 ios::bad 标志),但可以稍后给它一个 streambuf。因此,它可以被预先构造,直到适当的 streambuf 可用为止(见第 14.8.3 节)。

要在 C++ 源文件中定义 ostream 类,必须包含 <ostream> 头文件。要使用预定义的 ostream 对象(如 std::cerrstd::cout 等),则必须包含 <iostream> 头文件。

写入 ostream 对象

ostream 类支持格式化输出和二进制输出。

  • 格式化输出:使用插入操作符(<<)将值以类型安全的方式插入到 ostream 对象中。这种输出方式将计算机内存中存储的二进制值转换为符合一定格式规则的可读 ASCII 字符。这种方式叫做格式化输出。例如,语句 cout << "hello " << "world"; 中,<< 操作符将第一个字符串 "hello " 插入到 cout 中,然后返回 cout 对象,并继续处理第二个字符串 "world",最终将 "world" 也插入到 cout 中。

<< 操作符有很多重载变体,因此可以将多种类型的变量插入到 ostream 对象中。这些操作符包括处理 intdoublepointer 等类型。每个操作符都会返回插入信息的 ostream 对象,因此可以立即跟随下一个插入操作。

流对象没有像 C 语言的 printfvprintf 函数那样的格式化输出功能。虽然可以在流的世界中实现这些功能,但 C++ 程序几乎不需要类似 printf 的功能。此外,由于这种功能可能不安全,可能最好完全避免使用它。

  • 二进制文件的写入:当需要写入二进制文件时,通常不需要文本格式化。一个 int 值应该作为一系列原始字节写入,而不是作为一系列 ASCII 数字字符 0 到 9。可以使用以下 ostream 对象的成员函数来写入“二进制文件”:

    • ostream& put(char c);:将单个字符写入输出流。由于字符是一个字节,此成员函数也可以用于将单个字符写入文本文件。

    • ostream& write(char const *buffer, int length);:将最多 length 字节从 char const *buffer 中写入 ostream 对象。字节按原样写入缓冲区,不进行任何格式化。请注意,第一个参数是 char const *,写入其他类型需要进行类型转换。例如,要将一个 int 作为未经格式化的字节序列写入,可以使用:

      int x;
      out.write(reinterpret_cast<char const *>(&x), sizeof(int));
      

      上述 write 调用写入的字节顺序取决于底层硬件的字节序。大端计算机先写入多字节值的the most significant
      byte(s) ,小端计算机则先写入least significant byte(s)。

ostream 的定位

虽然并不是所有的 ostream 对象都支持重新定位,但通常它们是支持的。这意味着可以重新写入先前写入的流的某个部分。重新定位通常用于数据库应用程序中,那里需要能够随机访问数据库中的信息。

可以使用以下成员函数来获取和修改当前的位置:

  • ios::pos_type tellp();:返回当前文件中的位置,即下一个写操作将发生的位置。

  • ostream& seekp(ios::off_type step, ios::seekdir org);:修改流的实际位置。该函数接受一个 off_type 类型的 step 参数,表示相对于 org 的当前流位置移动的字节数。step 值可以是负值、零或正值。

    org 的来源是 ios::seekdir 枚举中的值,具体有:

    • ios::beg:步长相对于流的起始位置计算。默认使用该值。
    • ios::cur:步长相对于流的当前位置计算(由 tellp 返回)。
    • ios::end:步长相对于流的当前结束位置计算。

    可以在文件的最后位置之后进行寻址或写入。将字节写入超出 EOF 的位置会用值为 0 的字节填充中间的字节:即空字节。寻址到 ios::beg 之前会引发 ios::fail 标志。

    不同于 seekg(见第 6.5.1.2 节),seekp 不会清除流的 ios::eofbit。要将 ostream 的状态重置为“良好”,应调用其 clear 成员函数。

ostream 的刷新

除非设置了 ios::unitbuf 标志,否则写入 ostream 对象的信息不会立即写入物理流。相反,在写操作期间,内部缓冲区会被填满,缓冲区满时才会刷新。

可以通过程序控制刷新流的内部缓冲区:

  • ostream& flush();:将 ostream 对象内部存储的所有缓冲信息刷新到与 ostream 对象接口相连的设备。流会在以下情况下自动刷新:
    • 对象不再存在时;
    • 插入了 endlflush manipulator(见第 6.3.2.2 节)到 ostream 对象中;
    • 一个支持关闭操作的流被显式关闭(例如,std::ofstream 对象,见第 6.4.2 节)。

写入文件:ofstream

std::ofstream 类继承自 ostream 类:它具有与 ostream 类相同的功能,但可以用于访问文件或创建文件以进行写入。

在 C++ 源代码中使用 ofstream 类时,必须包含 <fstream> 头文件。包含 <fstream> 不会自动提供标准流 cincoutcerr,需要包含 <iostream> 来声明这些标准流。

以下是 ofstream 对象的构造函数:

  • ofstream object;
    这是基本构造函数。它定义了一个 ofstream 对象,可以稍后使用其 open() 成员与实际文件关联(见下文)。

  • ofstream object(char const *name, ios::openmode mode = ios::out);
    这个构造函数定义了一个 ofstream 对象,并立即将其与名为 name 的文件关联,使用输出模式 mode。第 6.4.2.1 节提供了可用输出模式的概述。例如:

    ofstream out("/tmp/scratch");
    

    不能使用文件描述符打开 ofstream。原因是文件描述符在不同操作系统上并不通用。幸运的是,可以通过 std::streambuf 对象(在某些实现中也可以通过 std::filebuf 对象)间接使用文件描述符。流缓冲区对象在第 14.8 节讨论,文件缓冲区对象在第 14.8.2 节讨论。可以先构造 ofstream 对象,稍后再打开它。

  • void open(char const *name, ios::openmode mode = ios::out);
    ofstream 对象与实际文件关联。如果在调用 open 之前已设置了 ios::fail 标志且打开成功,标志会被清除。打开一个已经打开的流会失败。要将流重新关联到另一个文件,必须先关闭它。

    ofstream out("/tmp/out");
    out << "hello\n";
    out.close();  // 刷新并关闭 out
    out.open("/tmp/out2");
    out << "world\n";
    
  • void close();
    关闭 ofstream 对象。此函数设置关闭对象的 ios::fail 标志。关闭文件会将任何缓冲的信息刷新到关联的文件。当关联的 ofstream 对象不再存在时,文件会自动关闭。

  • bool is_open() const;
    假设流已正确构造,但尚未附加到文件。例如,执行语句 ofstream ostr 时。如果现在通过 good() 检查其状态,返回一个非零(即 OK)值。这种“好”状态表示流对象已正确构造,但不表示文件也已打开。要测试流是否实际打开,应该调用 is_open。如果返回 true,则流已打开。例如:

    #include <fstream>
    #include <iostream>
    using namespace std;
    
    int main() {
        ofstream of;
        cout << "of's open state: " << boolalpha << of.is_open() << '\n';
        of.open("/dev/null");
        // 在 Unix 系统上
        cout << "of's open state: " << of.is_open() << '\n';
    }
    /*
    生成的输出:
    of's open state: false
    of's open state: true
    */
    

打开流对象的模式

在构造或打开 ofstream(或 istream,见第 6.5.2 节)对象时,可以使用以下文件模式或文件标志。这些值的类型是 ios::openmode。标志可以使用位或(|)运算符组合。

  • ios::app
    在每个输出命令之前,将流的位置重新定位到文件的末尾(另见 ios::ate)。如果文件不存在,则会创建它。在以此模式打开流时,文件的任何现有内容都会被保留。

  • ios::ate
    初始时从文件的末尾开始。请注意,只有当其他标志指示对象这样做时,现有内容才会被保留。例如,ofstream out("gone", ios::ate) 会重写文件 gone,因为隐含的 ios::out 会导致重写。如果要防止重写现有文件,也应指定 ios::in 模式。然而,当指定 ios::in 时,文件必须已经存在。ate 模式仅将文件最初定位在文件末尾的位置。之后,可以使用 seekp 在文件中间写入信息。当使用 app 模式时,信息仅写入文件末尾(有效地忽略 seekp 操作)。

  • ios::binary
    以二进制模式打开文件(在区分文本和二进制文件的系统上使用,如 MS Windows)。

  • ios::in
    打开文件以进行读取。文件必须存在。

  • ios::out
    打开文件以进行写入。如果文件不存在,则会创建它。如果文件已存在,则会重写该文件。

  • ios::trunc
    初始时以一个空文件开始。文件的任何现有内容都会丢失。

以下文件标志组合具有特殊含义:

  • in | out
    流可以进行读取和写入。然而,文件必须存在。

  • in | out | trunc
    流可以进行读取和写入。文件首先被(重新)创建为空。

一个有趣的细节是,ifstreamofstreamfstream 类的 open 成员函数有一个类型为 ios::openmode 的第二个参数。与此不同的是,位或运算符(|)在应用于两个枚举值时返回一个 int。为什么在这里仍然可以使用位或运算符,这个问题将在后面的章节中回答(见第 11.13 节)。

写入内存:ostringstream

要使用流设施将信息写入内存,应使用 std::ostringstream 对象。由于 ostringstream 类派生自 ostream 类,因此所有 ostream 的功能也适用于 ostringstream 对象。要使用和定义 ostringstream 对象,必须包含头文件 <sstream>。此外,ostringstream 类还提供了以下构造函数和成员:

  • ostringstream ostr(string const &init, ios::openmode mode = ios::out)
    当指定 openmodeios::ate 时,ostringstream 对象会被初始化为字符串 init,之后的插入会追加到 ostringstream 对象的内容中。

  • ostringstream ostr(ios::openmode mode = ios::out)
    这个构造函数也可以用作默认构造函数。它还允许,例如,强制将信息添加到当前对象中(使用 ios::app)。

    例子:

    std::ostringstream out;
    
  • std::string str() const
    返回存储在 ostringstream 对象中的字符串的副本。

  • void str(std::string const &str)
    用新的初始内容重新初始化当前对象。

以下示例展示了如何使用 ostringstream 类:将几个值插入到对象中。然后,将 ostringstream 对象包含的文本存储在 std::string 中,并打印其长度和内容。这样的 ostringstream 对象通常用于进行“类型到字符串”的转换,比如将 int 值转换为文本。格式化标志也可以与 ostringstream 一起使用,因为它们是 ostream 类的一部分。

示例代码:

#include <iostream>
#include <sstream>
using namespace std;

int main() {
    ostringstream ostr("hello ", ios::ate);
    cout << ostr.str() << '\n';
    ostr.setf(ios::showbase);
    ostr.setf(ios::hex, ios::basefield);
    ostr << 12345;
    cout << ostr.str() << '\n';
    ostr << "-- ";
    ostr.unsetf(ios::hex);
    ostr << 12;
    cout << ostr.str() << '\n';
    ostr.str("new text");
    cout << ostr.str() << '\n';
    ostr.seekp(4, ios::beg);
    ostr << "world";
    cout << ostr.str() << '\n';
}

程序输出:

hello
hello 0x3039
hello 0x3039-- 12
new text
new world

put_time 操作符

std::put_time(std::tm const *specs, char const *fmt) 操作符用于将时间规格插入到 std::ostream 对象中。时间规格以 std::tm 对象的形式提供,时间的显示方式由格式字符串 fmt 定义。

chrono::time_point 开始,插入时间点的时间到 std::ostream 的步骤如下:

  1. 获取一个 time_point(例如:system_clock{}.now())。
  2. 将时间点传递给时钟的 to_time_t 函数,保存返回的 time_t 值:
    time_t secs = system_clock::to_time_t(system_clock{}.now());
    
  3. secs 的地址传递给 std::localtimestd::gmtime。这些函数返回 std::tm 结构,包含相应的时间组件,分别以计算机的本地时间或 GMT 表示。
  4. localtimegmtime 返回值与格式字符串(例如,"%c")传递给 put_time,并插入到 std::ostream 中:
    // 显示,例如:Mon Nov 4 21:34:59 2019
    time_t secs = system_clock::to_time_t(system_clock{}.now());
    std::cout << std::put_time(std::localtime(&secs), "%c") << '\n';
    

可以定义一个简单的函数来返回 put_time 的返回值,并期望一个 time_point 和格式字符串,该函数处理上述两个语句。例如(为了简洁省略了 std::std::chrono::):

auto localTime(time_point<system_clock> const &tp, char const *fmt)
{
    time_t secs = system_clock::to_time_t(tp);
    return put_time(localtime(&secs), fmt);
}

// 使用示例:
std::cout << localTime(system_clock{}.now(), "%c") << '\n';

put_time 识别更多的格式说明符。说明符以 % 开头。要在格式字符串中显示一个百分号字符,请将其写成 %%。除了标准的转义序列外,还可以使用 %n 代替 \n,使用 %t 代替 \t

在这里插入图片描述

Input

在C++中,输入主要基于 std::istream 类。istream 类定义了从流中提取信息的基本操作符和成员:提取操作符(>>),以及像 istream::read 这样的特殊成员,用于从流中读取未格式化的信息。istream 类作为多个其他类的基类,这些类都提供了 istream 类的功能,但各自增加了特定的功能。接下来的章节将讨论以下类:

  • istream 类:提供基本的输入功能。
  • ifstream 类:允许我们从文件中读取数据(类似于C语言的 fopen(filename, "r"))。
  • istringstream 类:允许我们从内存中提取信息,而不是从文件中(类似于C语言的 sscanf 函数)。

基本输入:istream

istream 类定义了基本的输入功能。cin 对象是一个 istream 对象。所有由 ios 类定义的与输入相关的功能在 istream 类中也可用。

我们可以使用以下构造函数定义 istream 对象:

  • istream object(streambuf *sb)
    这个构造函数可以用来围绕现有的 std::streambuf 对象构造一个包装器。类似于 ostream 对象,istream 对象也可以通过最初传递一个 0 指针来定义。有关详细讨论,请参见第 6.4.1 节、第 14.8.3 节,以及第 25 章中的示例。

要在 C++ 源文件中定义 istream 类,必须包含 <istream> 头文件。要使用预定义的 istream 对象 cin,必须包含 <iostream> 头文件。

istream 对象读取数据

istream 类支持格式化输入和未格式化(即二进制)输入。提取运算符(>>)用于以类型安全的方式从 istream 对象中提取值。这称为格式化输入,其中人类可读的 ASCII 字符根据某些格式化规则转换为二进制值。

提取运算符指向接收新值的对象或变量。>> 运算符的正常结合性不变,因此当遇到类似以下的语句时:

cin >> x >> y;

首先会评估最左边的两个操作数(cin >> x),并返回一个 istream& 对象(实际上是相同的 cin 对象)。现在,语句简化为:

cin >> y

然后从 cin 中提取 y 变量。

>> 运算符有许多(重载的)变体,因此可以从 istream 对象中提取多种类型的变量。对于 intdoublestring、字符数组等,>> 运算符都有重载。字符串或字符数组提取默认首先跳过所有空白字符,然后提取所有连续的非空白字符。一旦提取运算符处理完毕,提取信息的 istream 对象会被返回,并且可以立即用于在同一表达式中出现的其他 istream 操作。

流不支持类似 C 的 scanfvscanf 函数提供的格式化输入功能。尽管将这些功能添加到流世界中并不困难,但在实际 C++ 程序中,通常不需要类似 scanf 的功能。此外,由于它可能是不安全的,最好避免使用 C 风格的格式化输入。

当需要读取二进制文件时,信息通常不应进行格式化:例如,int 值应以未更改的字节序列读取,而不是 ASCII 数字字符 0 到 9 的序列。以下是从 istream 对象中读取信息的成员函数:

  • int gcount() const
    返回最后一次未格式化输入操作从输入流中读取的字符数。

  • int get()
    返回下一个可用的单字符,使用 int 返回类型以 unsigned char 值形式返回。如果没有更多字符可用,则返回 EOF。

  • istream &get(char &ch)
    将从输入流中读取的下一个单字符存储在 ch 中。该成员函数返回流本身,可以检查流以确定是否获取了字符。

  • istream &get(char *buffer, int len, char delim = '\n')
    从输入流中读取最多 len - 1 个字符到以 buffer 开头的数组中,buffer 至少应有 len 字节长。读取也会在遇到分隔符 delim 时停止。然而,分隔符本身不会从输入流中移除。将字符存储到 buffer 后,会在最后一个存储的字符之后写入一个值为 0 的字符。如果在读取 len - 1 个字符之前遇到分隔符,或者在读取 len - 1 个字符后没有遇到分隔符,则 eoffail 函数返回 0(false)。指定值为 0 的字符分隔符是可以的:这种方式可以从(二进制)文件中读取 NTBS。

  • istream &getline(char *buffer, int len, char delim = '\n')
    这个成员函数的操作类似于 get 成员函数,但 getline 会在实际遇到分隔符时从流中移除 delim。如果遇到分隔符,它不会存储在缓冲区中。如果没有找到 delim(在读取 len - 1 个字符之前),fail 成员函数和可能的 eof 返回 true。请注意,std::string 类还提供了一个函数 std::getline,它通常优于这里描述的 getline 成员函数(见第 5.2.4 节)。

  • istream &ignore()
    从输入流中跳过一个字符。

  • istream &ignore(int n)
    从输入流中跳过 n 个字符。

  • istream &ignore(int n, int delim)
    最多跳过 n 个字符,但跳过字符会在移除 delim 后停止。

  • int peek()
    此函数返回下一个可用的输入字符,但不会实际移除字符。没有更多字符可用时返回 EOF。

  • istream &putback(char ch)
    将字符 ch ‘推回’到输入流中,作为下一个可用字符重新读取。如果不允许这样做,则返回 EOF。通常,可以推回一个字符。例如:

    string value;
    cin >> value;
    cin.putback('X');
    // 显示: X
    cout << static_cast<char>(cin.get());
    
  • istream &read(char *buffer, int len)
    从输入流中读取最多 len 字节到缓冲区。如果首先遇到 EOF,则读取的字节数会减少,成员函数 eof 返回 true。这个函数通常用于读取二进制文件。第 6.5.2 节包含一个示例,其中使用了此成员函数。可以使用成员函数 gcount() 来确定通过 read 检索到的字符数。

  • istream &readsome(char *buffer, int len)
    从输入流中读取最多 len 字节到缓冲区。所有可用字符都会被读取到缓冲区中,但如果遇到 EOF,则读取的字节数会减少,而不会设置 ios::eofbitios::failbit

  • istream &unget()
    将最后一个从流中读取的字符放回。

istream 定位

虽然并非所有的 istream 对象都支持重新定位,但有些对象是支持的。这意味着可以重复读取流的相同部分。重新定位在数据库应用中非常常见,在这些应用中必须能够随机访问数据库中的信息。

可以使用以下成员函数获取和修改当前的位置:

  • ios::pos_type tellg()
    返回流的当前(绝对)位置,即流下一个读取操作将发生的位置。

  • istream &seekg(ios::off_type step, ios::seekdir org)
    修改流的实际位置。此函数接受一个 off_type 类型的 step,表示当前流位置相对于 org 移动的字节数。step 值可以是负数、零或正数。

    步长的起点 orgios::seekdir 枚举中的一个值,其值为:

    • ios::beg
      步长相对于流的开始位置计算。默认使用此值。
    • ios::cur
      步长相对于流的当前位置信息(由 tellg 返回)计算。
    • ios::end
      步长相对于流的当前结束位置计算。可以在文件的最后位置之外进行定位。

    ios::beg 之前定位会触发 ios::failbit 标志。

调用 seekg 会清除 istreamios::failbit,但不会清除 ios::badbit。为了确保流的状态重置为“良好”,应调用成员函数 clear

示例说明:在以下示例中,cinios::eofbit 被设置。跟随 seekg 后,该标志被清除,但 ios::goodbit 仍未设置。由于 goodbit 未设置,因此 seekg 后的提取操作失败:

int main() {
    // 在 'src.cc' 中
    {
        cin.setstate(ios::eofbit | ios::failbit);
        cerr << cin.good() << ' ' << cin.eof() << '\n';
        cin.seekg(0);
        cerr << cin.good() << ' ' << cin.eof() << '\n' <<
        (cin.get() == EOF ? "failed" : "OK") << '\n';
        cin.clear();
        cerr << cin.good() << ' ' << cin.eof() << '\n' <<
        (cin.get() == EOF ? "failed" : "OK") << '\n';
    }
}

当作为 a.out < src.cc 调用时,输出结果为:

0 1
0 0
failed
1 0
OK

从文件读取:ifstream

std::ifstream 类从 istream 类派生而来:它具有与 istream 类相同的功能,但可以用于读取文件。

要在 C++ 源文件中使用 ifstream 类,必须包含 <fstream> 头文件。包含 <fstream> 并不会自动使标准流 cincoutcerr 可用。要声明这些标准流,请包含 <iostream> 头文件。

以下是 ifstream 对象的构造函数:

  • ifstream object
    这是基本构造函数。它定义一个 ifstream 对象,稍后可以使用其 open() 成员将其与实际文件关联(见下文)。

  • ifstream object(char const *name, ios::openmode mode = ios::in)
    这个构造函数用于定义一个 ifstream 对象,并立即将其与名为 name 的文件关联,使用输入模式 mode。有关可用输入模式的概述,请参见第 6.4.2.1 节。示例:

    ifstream in("/tmp/input");
    

    除了直接将 ifstream 对象与文件关联外,还可以先构造对象,然后再打开文件。

  • void open(char const *name, ios::openmode mode = ios::in)
    ifstream 对象与实际文件关联。如果在调用 open 之前设置了 ios::fail 标志,并且打开成功,则会清除该标志。打开已经打开的流会失败。要将流重新关联到另一个文件,必须先关闭流。

    ifstream in("/tmp/in");
    in >> variable;
    in.close(); // 关闭 in
    in.open("/tmp/in2");
    in >> anotherVariable;
    
  • void close()
    关闭 ifstream 对象。该函数会设置已关闭对象的 ios::fail 标志。关闭文件会将所有缓冲信息刷新到关联的文件中。当关联的 ifstream 对象不再存在时,文件会自动关闭。

  • bool is_open() const
    假设流已正确构造,但尚未附加到文件上。例如,如果执行了语句 ifstream ostr,则在我们现在通过 good() 检查其状态时,会返回一个非零(即 OK)值。good 状态表示流对象已正确构造,但不意味着文件也已打开。要测试流是否实际打开,应调用 is_open。如果返回 true,则流已打开。有关示例,请参见第 6.4.2 节。以下示例演示了如何从二进制文件中读取数据(另见第 6.5.1.1 节):

    #include <fstream>
    using namespace std;
    
    int main(int argc, char **argv) {
        ifstream in(argv[1]);
        double value;
        // 以原始的二进制形式从文件中读取 double 值。
        in.read(reinterpret_cast<char *>(&value), sizeof(double));
    }
    

从内存读取:istringstream

要使用流设施从内存中读取信息,应使用 std::istringstream 对象。由于 istringstream 类从 istream 类派生,因此 istream 的所有功能也适用于 istringstream 对象。要使用和定义 istringstream 对象,必须包含 <sstream> 头文件。此外,istringstream 类还提供了以下构造函数和成员:

  • istringstream istr(string const &init, ios::openmode mode = ios::in)
    使用 init 的内容初始化对象。

  • istringstream istr(ios::openmode mode = ios::in)
    这个构造函数通常用作默认构造函数。例如:

    std::istringstream in;
    
  • void str(std::string const &str)
    使用新的初始内容重新初始化当前对象。

以下示例演示了如何使用 istringstream 类:从对象中提取多个值。这样的 istringstream 对象通常用于进行“字符串到类型”的转换,例如将文本转换为 int 值(类似于 C 的 atoi 函数)。istringstream 还可以使用格式化标志,因为它们是 istream 类的一部分。在示例中,特别注意成员函数 seekg 的使用:

#include <iostream>
#include <sstream>
using namespace std;

int main() {
    istringstream istr("123 345");
    // 存储一些文本。
    int x;

    istr.seekg(2);      // 跳过 "12"
    istr >> x;          // 提取 int
    cout << x << '\n';  // 输出它
    istr.seekg(0);      // 从开头重新开始
    istr >> x;          // 提取 int
    cout << x << '\n';  // 输出它
    istr.str("666");    // 存储另一个文本
    istr >> x;          // 提取它
    cout << x << '\n';  // 输出它
}

该程序的输出:

3
123
666

复制流

通常,文件的复制是通过逐字符或逐行读取源文件来完成的。处理流的基本模式如下:

  • 连续循环:
    1. 从流中读取数据
    2. 如果读取不成功(即 fail 返回 true),则退出循环
    3. 处理已读取的信息

注意,读取必须在测试之前进行,因为只有在实际尝试从文件中读取数据后,才能知道读取是否成功。当然,可以有不同的变体:例如,getline(istream &, string &)(见第6.5.1.1节)返回一个 istream &,因此在一个表达式中可以合并读取和测试。然而,上述模式代表了常见情况。因此,以下程序可以用于将 cin 复制到 cout

#include <iostream>
using namespace std;

int main() {
    while (true) {
        char c;
        cin.get(c);
        if (cin.fail())
            break;
        cout << c;
    }
}

在这里,可以通过将 getif 语句结合来缩短代码:

if (!cin.get(c))
    break;

即使如此,这仍然遵循基本规则:“先读取,后测试”。

简单地复制文件并不常见。更常见的情况是处理文件的某个部分,然后直接复制文件的剩余信息。以下程序演示了这一点。通过 ignore 跳过第一行(为了示例假设第一行最多80个字符),第二个语句使用了 << 运算符的另一个重载版本,其中插入了一个 streambuf 指针。由于成员 rdbuf 返回一个流的 streambuf *,我们可以简单地将流的内容插入到 ostream 中:

#include <iostream>
using namespace std;

int main() {
    cin.ignore(80, '\n'); // 跳过第一行,然后...
    cout << cin.rdbuf(); // 通过 streambuf * 复制剩余内容
}

这种复制流的方法只假定存在一个 streambuf 对象。因此,它可以与所有 streambuf 类的特化一起使用。

流的耦合

ostream 对象可以通过 tie 成员函数与 ios 对象耦合。耦合的结果是,每当对与 ostream 对象耦合的 ios 对象执行输入或输出操作时,都会刷新 ostream 的缓冲区。默认情况下,cout 被与 cin 绑在一起(使用 cin.tie(cout))。这种绑定意味着每当对 cin 进行操作时,cout 会先被刷新。要解除绑定,可以调用 ios::tie(0)。例如:cin.tie(0)

另一个有用的流耦合示例是 cerrcout 之间的绑定。由于这种绑定,标准输出和错误信息会与它们生成的时间同步显示在屏幕上:

#include <iostream>
using namespace std;

int main() {
    cerr.tie(0); // 解除绑定
    cout << "first (buffered) line to cout ";
    cerr << "first (unbuffered) line to cerr\n";
    cout << "\n";
    cerr.tie(&cout); // 将 cout 绑定到 cerr
    cout << "second (buffered) line to cout ";
    cerr << "second (unbuffered) line to cerr\n";
    cout << "\n";
}

生成的输出为:

first (unbuffered) line to cerr
first (buffered) line to cout
second (buffered) line to cout second (unbuffered) line to cerr

另一种耦合流的方式是使流使用一个公共的 streambuf 对象。这可以通过 ios::rdbuf(streambuf *) 成员函数实现。这样,两条流可以使用它们自己的格式化,一个流可以用于输入,另一个用于输出,并且可以使用流库实现重定向,而不是操作系统调用。有关示例,请参见接下来的章节。

Advanced topics

移动流

流类(例如,本章讨论的所有流类)是可移动的,并且可以交换。这意味着可以为流类设计工厂函数。以下是一个示例:

ofstream out(string const &name) {
    ofstream ret(name);  // 构造 ofstream 对象
    return ret;          // 返回值优化,但
}  // 移动是支持的,所以这样做是可以的

int main() {
    ofstream mine(out("out"));  // 返回值优化,但
    // 移动是支持的,所以这样做是可以的
    ofstream base("base");

    ofstream other;
    base.swap(other); // 交换流是可以的
    other = std::move(base); // 移动流是可以的
    // other = base; // 这会失败:流不支持复制赋值
}

在上面的示例中:

  • ofstream out(string const &name) 函数创建一个 ofstream 对象并返回它。由于流支持移动操作,返回值优化(RVO)是可行的。
  • base.swap(other) 用于交换两个流对象的内容,交换流是允许的。
  • other = std::move(base)base 对象的内容移动到 other 中,移动流是允许的。
  • other = base 会失败,因为流类不支持复制赋值操作。

重定向流

使用 ios::rdbuf 可以强制流共享它们的 streambuf 对象。这样写入一个流的信息实际上会写入到另一个流中,这种现象通常称为重定向。重定向通常在操作系统级别实现,有时仍然需要使用操作系统调用(见第25.2.3节)。

一个常见的重定向场景是当错误信息应该写入文件而不是标准错误流时,标准错误流通常由文件描述符号2表示。在Unix操作系统的bash shell中,可以如下实现:

program 2>/tmp/error.log

在这个命令之后,任何由 program 写入的错误消息都会被写入到 /tmp/error.log,而不是显示在屏幕上。

以下是一个使用 streambuf 对象实现重定向的示例。假设程序需要一个参数来定义写入错误消息的文件名。可以这样调用程序:

program /tmp/error.log

程序如下,下面是程序源代码的解释:

#include <fstream>
#include <iostream>
using namespace std;

int main(int argc, char **argv) {
    ofstream errlog; //1
    streambuf *cerr_buffer = 0; //2
    
    if (argc == 2) {
        errlog.open(argv[1]); //3
        cerr_buffer = cerr.rdbuf(errlog.rdbuf()); //4
    } else {
        cerr << "Missing log filename\n";
        return 1;
    }

    cerr << "Several messages to stderr, msg 1\n";
    cerr << "Several messages to stderr, msg 2\n";
    cout << "Now inspect the contents of " << argv[1] << "... [Enter] ";
    cin.get(); //5
    
    cerr << "Several messages to stderr, msg 3\n";
    cerr.rdbuf(cerr_buffer); //6
    cerr << "Done\n"; //7
}

程序的输出如下(在文件 argv[1] 中):

cin.get() 时:

Several messages to stderr, msg 1
Several messages to stderr, msg 2

在程序结束时:

Several messages to stderr, msg 1
Several messages to stderr, msg 2
Several messages to stderr, msg 3

解释:

  • 第1-2行 定义了局部变量:errlog 是用来写入错误消息的 ofstream 对象,cerr_buffer 是指向 streambuf 的指针,用于指向原始的 cerr 缓冲区。
  • 第3行 打开备用错误流。
  • 第4行 进行重定向:cerr 现在写入到由 errlog 定义的 streambuf 中。重要的是要保存 cerr 使用的原始缓冲区,如下文所述。
  • 第5行 暂停。此时,两行信息已被写入备用错误文件。我们有机会查看其内容:确实写入了两行到文件中。
  • 第6行 终止重定向。这非常重要,因为 errlog 对象在 main 结束时会被销毁。如果 cerr 的缓冲区没有恢复,那么在那时 cerr 将会引用一个不存在的 streambuf 对象,这可能会产生意外结果。程序员需要确保在重定向之前保存原始的 streambuf,并在重定向结束时恢复它。
  • 第7行,由于重定向已终止,“Done” 再次写入到屏幕上。

读写流

流可以通过 std::iostream 对象进行读写。常见的有 std::fstream 对象,有时也会用到 std::stringstream 对象。其他可读写的流可以通过从 std::iostream 类派生来定义(参见第14章)。在本节中,我们集中讨论 std::fstream 类。与 ifstreamofstream 对象一样,fstream 构造函数也期望打开的文件名:

fstream inout("iofile", ios::in | ios::out);

注意使用了 ios::inios::out 常量,这表示文件必须以读写模式打开。可以使用多个模式指示符,通过按位或运算符连接。作为替代,可以使用 ios::app 替代 ios::out,这样写入操作将变成在文件末尾追加。

对同一个流进行读写通常有点麻烦:如果流可能还不存在,但如果它已经存在,就不应该被重写。为了实现这一点,可以使用以下方法:

#include <fstream>
#include <iostream>
#include <string>
using namespace std;

int main() {
    fstream rw("fname", ios::out | ios::in);
    if (!rw) {
        // 文件尚不存在
        rw.clear();
        // 重新尝试,使用 ios::trunc 创建文件
        rw.open("fname", ios::out | ios::trunc | ios::in);
    }
    if (!rw) {
        // 甚至无法创建:退出
        cerr << "打开 `fname` 失败" << '\n';
        return 1;
    }
    cerr << "当前位置: " << rw.tellp() << '\n';
    // 写入一些内容
    rw << "Hello world" << '\n';
    rw.seekg(0);
    // 回到开始位置读取写入的内容
    string s;
    getline(rw, s);
    cout << "读取到: " << s << '\n';
}

在这种方法下,如果构造失败,说明 fname 尚不存在。但是,在清除失败标志后,使用 ios::trunc 重新打开文件,这会创建一个空文件,但由于使用了 ios::in,文件也是可读的。此外,还可以指定 ios::ate,确保初始的读/写操作默认发生在文件末尾。

在 DOS 类操作系统下的流处理

在使用多个字符序列 \r\n 分隔文本文件中的行的 DOS 类操作系统中,处理二进制文件时需要使用 ios::binary 标志,以确保 \r\n 组合被处理为两个字符。一般来说,当处理二进制(非文本)文件时,应该指定 ios::binary。默认情况下,文件是以文本文件模式打开的。Unix 操作系统不会区分文本文件和二进制文件。

fstream(一般是 iostream)对象中,使用文件标志的组合来确保流在打开时是否会被(重新)创建为空文件。有关详细信息,请参见第 6.4.2.1 节。

一旦文件以读写模式打开,可以使用 << 运算符将信息插入文件,而 >> 运算符可以用于从文件中提取信息。这些操作可以按任意顺序执行,但在切换插入和提取操作时需要执行 seekgseekp 操作。seek 操作用于激活流的数据,用于读取或写入(反之亦然)。fstream 对象的 istreamostream 部分共享流的数据缓冲区,通过执行 seek 操作,流可以激活其 istreamostream 部分。如果省略 seek,在写入后读取或在读取后写入将失败。以下示例显示了从文件中读取一个以空格分隔的单词,然后在刚刚读取的单词结束位置之后写入另一个字符串。最后,再次读取另一个字符串,该字符串位于刚刚写入的字符串结束位置之后:

fstream f("filename", ios::in | ios::out);
string str;
f >> str; // 读取第一个单词
// 写入一个已知的文本
f.seekp(0, ios::cur);
f << "hello world";
f.seekg(0, ios::cur);
f >> str; // 再次读取

由于在同一个文件上交替进行读取和写入(提取和插入)操作时需要 seekclear 操作,因此不可能在一个表达式语句中执行一系列的 <<>> 操作。

当然,随机插入和提取操作很少使用。一般来说,插入和提取发生在文件中的已知位置。在这些情况下,可以通过 seekgseekptellgtellp 成员来控制和监控插入或提取的位置(参见第 6.4.1.2 和 6.5.1.2 节)。

由于例如读取超出文件末尾、到达文件末尾或定位到文件开始之前等情况发生的错误条件(参见第 6.3.1 节),可以通过 clear 成员函数清除。清除后可以继续处理。例如:

fstream f("filename", ios::in | ios::out);
string str;
f.seekg(-10); // 这会失败,但是...
f.clear();    // 处理 f 继续
f >> str;     // 读取第一个单词

在数据库应用程序中,文件既被读取又被写入的情况很常见,这些文件包含具有固定大小的记录,并且信息的位置信息和大小是已知的。例如,以下程序将文本行添加到(可能存在的)文件中。它也可以用来检索特定行,给定其在文件中的顺序编号。一个二进制文件索引允许快速检索行的位置。
seekg (Seek Get): 用于设置输入流的当前位置,即读取位置。
seekp (Seek Put): 用于设置输出流的当前位置,即写入位置。
tellg (Tell Get): 返回输入流的当前读取位置。
tellp (Tell Put): 返回输出流的当前写入位置。

#include <climits>
#include <fstream>
#include <iostream>
#include <string>
using namespace std;

// 错误处理函数:打印消息
void err(char const *msg) { cout << msg << '\n'; }
void err(char const *msg, long value) { cout << msg << value << '\n'; }

// 从文件中读取行
void read(fstream &index, fstream &strings) {
    int idx;
    if (!(cin >> idx)) {            // 读取索引
        cin.clear();                // 允许重新读取
        cin.ignore(INT_MAX, '\n');  // 跳过整行
        return err("需要行号");
    }
    index.seekg(idx * sizeof(long));  // 定位到索引偏移
    long offset;
    if (!index.read(reinterpret_cast<char *>(&offset), sizeof(long))) // 读取行偏移
        return err("没有找到行的偏移", idx);
    if (!strings.seekg(offset))       // 定位到行的偏移
        return err("无法定位到字符串偏移 ", offset);
    string line;
    if (!getline(strings, line))      // 读取行
        return err("在 ", offset, " 没有找到行");
    cout << "获取的行: " << line << '\n';  // 显示行内容
}

// 将行写入文件
void write(fstream &index, fstream &strings) {
    string line;  // 读取行
    if (!getline(cin, line)) return err("缺少行");
    strings.seekp(0, ios::end);  // 定位到 strings 文件末尾
    index.seekp(0, ios::end);    // 定位到 index 文件末尾
    long offset = strings.tellp();  // 获取写入偏移
    if (!index.write(reinterpret_cast<char *>(&offset), sizeof(long))) // 写入偏移到索引
        return err("写入索引失败: ", offset);
    if (!(strings << line << '\n')) // 写入行内容
        return err("写入 `strings` 失败");
    // 确认行内容已写入
    cout << "写入偏移 " << offset << " 行内容: " << line << '\n';
}

int main() {
    fstream index("index", ios::trunc | ios::in | ios::out);
    fstream strings("strings", ios::trunc | ios::in | ios::out);
    cout << "输入 `r <number>` 以读取第 <number> 行或 "
            "`w <line>` 以写入一行\n"
            "或输入 `q` 以退出。\n";
    while (true) {
        cout << "r <nr>, w <line>, q ? ";
        // 显示提示符
        index.clear();
        strings.clear();
        string cmd;
        cin >> cmd;
        // 读取命令
        if (cmd == "q")
            // 处理命令
            return 0;
        if (cmd == "r")
            read(index, strings);
        else if (cmd == "w")
            write(index, strings);
        else if (cin.eof()) {
            cout << "\n"
                    "意外的文件结束\n";
            return 1;
        } else
            cout << "未知命令: " << cmd << '\n';
    }
}

另一个示例展示了如何读取和写入文件,同时处理 NTBS(Null-Terminated Byte Strings):

#include <fstream>
#include <iostream>
using namespace std;

int main() {
    // 读写文件
    fstream f("hello", ios::in | ios::out | ios::trunc);
    f.write("hello", 6);  // 写入两个 NTBS 字符串
    f.seekg(0, ios::beg); // 重置到文件开始位置
    char buffer[100];    // 或者: char *buffer = new char[100]
    char c;
    // 读取第一个 `hello`
    cout << f.get(buffer, sizeof(buffer), 0).tellg() << '\n';
    f >> c; // 读取 NTB 定界符
    // 读取第二个 `hello`
    cout << f.get(buffer + 6, sizeof(buffer) - 6, 0).tellg() << '\n';
    buffer[5] = ' '; // 将 ASCII 字符串中的 '\0' 替换为空格
    cout << buffer << '\n'; // 显示两个 `hello`
}

代码解释

  1. 读取和写入函数

    • read 函数从 index 文件中读取行的偏移,然后从 strings 文件中读取并显示该行。
    • write 函数将输入的行写入 strings 文件,同时将行的偏移写入 index 文件。
  2. 主程序

    • 提供用户交互接口,允许通过输入命令读取或写入文件。
    • 支持 r 命令读取指定行,w 命令写入一行,q 命令退出程序。
  3. 第二个示例

    • 读写包含 NTBS 的文件并显示读取的内容。

这些示例展示了如何使用 C++ 标准库中的流类进行文件操作,包括处理文本和二进制数据。

#include <fstream>
#include <iostream>
#include <string>
using namespace std;

// 错误处理函数声明
void err(char const *msg);
void err(char const *msg, long value);

// 从输入流中读取数据
void read(istream &index, istream &strings) {
    index.clear();
    strings.clear();
    // 插入之前示例中的 read() 函数的主体
}

// 将数据写入输出流
void write(ostream &index, ostream &strings) {
    index.clear();
    strings.clear();
    // 插入之前示例中的 write() 函数的主体
}

int main() {
    ifstream index_in("index", ios::trunc | ios::in | ios::out);
    ifstream strings_in("strings", ios::trunc | ios::in | ios::out);
    ostream index_out(index_in.rdbuf());
    ostream strings_out(strings_in.rdbuf());

    cout << "输入 `r <number>` 以读取第 <number> 行或 "
            "`w <line>` 以写入一行\n"
            "或输入 `q` 以退出。\n";

    while (true) {
        cout << "r <nr>, w <line>, q ? ";
        // 显示提示符
        string cmd;
        cin >> cmd;                // 读取命令
        if (cmd == "q") return 0;  // 处理命令
        if (cmd == "r")
            read(index_in, strings_in);
        else if (cmd == "w")
            write(index_out, strings_out);
        else
            cout << "未知命令: " << cmd << '\n';
    }
}

示例说明

  • 在这个示例中:

    • streambuf 对象关联的流不是 ifstreamofstream 对象,而是 basic_istreambasic_ostream 对象。
    • streambuf 对象不是由 ifstreamofstream 对象定义的,而是通过使用 filebuf(见第 14.8.2 节)和类似以下的构造来定义的:
      filebuf fb("index", ios::in | ios::out | ios::trunc);
      istream index_in(&fb);
      ostream index_out(&fb);
      
    • ifstream 对象可以使用通常与 ofstream 对象一起使用的流模式进行构造。相反,ofstream 对象也可以使用通常与 ifstream 对象一起使用的流模式进行构造。
    • 如果 istreamostream 共享一个 streambuf,那么它们的读取和写入指针(应)指向共享缓冲区:它们是紧密耦合的。然而,使用两个对象而不是定义/使用一个 iostream 对象,接收 streambuf 自动确保了读取和写入的位置同步(参见第 14.9 节)。
  • 使用外部(分开的)streambuf 对象而不是预定义的 fstream 对象的好处是流不需要是一个命名文件(参见第 14.8 节)。

fstream 对象一样,string-stream 对象也可以用于读写。包含 <sstream> 头文件后,可以定义一个 std::stringstream 对象,它支持读写操作。插入信息到 stringstream 对象后,可以调用 seekg(0) 从其内容的开始位置读取信息。当 stringstream 必须重复用于读写时,可以在开始新的写入周期之前调用其 clearstr 成员。或者,可以通过 stringstream str = stringstream{}; 重新初始化 stringstream 对象。以下是一个示例:

#include <iostream>
#include <sstream>
using namespace std;

int main(int argc, char **argv) {
    stringstream io;
    for (size_t redo = 0; redo != 2; ++redo) {
        io.clear();
        // 清除标志
        io.str("");
        io << argv[0] << '\n';
        io.seekg(0);
        string line;
        while (getline(io, line))  // 结果为 io.eof()
            cout << line << '\n';
    }
}

示例说明

  • 该示例展示了如何使用 stringstream 对象进行读写操作。
  • for 循环中,stringstream 对象被清除并重新初始化。
  • 写入命令行参数 argv[0],然后从头开始读取并显示它。

类(Classes)

C 语言提供了两种方法来组织不同类型的数据。C 的 struct 用于保存各种类型的数据成员,而 C 的 union 也定义了各种类型的数据成员。然而,union 的所有数据成员都占用相同的内存位置,程序员可以决定使用哪个成员。

在本章中,我们将介绍类(class)。类是一种 struct,但其内容默认对外部世界不可见,而 C++ 的 struct 默认对外部世界可见。在 C++ 中,struct 的用途较少:它们主要用于在类的上下文中聚合数据或定义复杂的返回值。通常,C++ 的 struct 只是包含了普通的数据(POD,见第 9.10 节)。在 C++ 中,class 是主要的数据结构化工具,它默认实现了当今软件工程的两个核心概念:数据隐藏和封装(见第 3.2.1 节和第 7.1.1 节)。

union 是语言提供的另一种数据结构工具。传统的 C union 仍然可用,但 C++ 还提供了不受限制的 union。不受限制的 union 是可以包含类类型数据字段的 union。C++ 注解在第 9.9 节中介绍了这些不受限制的 union,在介绍了 C++ 的几个其他新概念之后。

C++ 扩展了 C 的 structunion 概念,允许在这些数据类型中定义成员函数(在本章中介绍)。成员函数是只能用于这些数据类型的对象或在这些数据类型的范围内使用的函数。其中一些成员函数是特殊的,它们在对象开始生命周期时(所谓的构造函数)或结束生命周期时(所谓的析构函数)总是被调用(通常是自动调用)。这些成员函数及其他类型的成员函数,以及类的设计和构造及其背后的哲学将在本章中介绍。

我们将逐步构建一个 Person 类,可以用于数据库应用程序来存储个人的姓名、地址和电话号码。

首先,让我们立即创建一个 Person 类。从一开始,就重要的是要区分类的接口和实现。类可以粗略地定义为“数据集合及所有操作这些数据的函数”。这个定义稍后会被精炼,但现在足以让我们开始。

类接口是定义,定义了该类对象的组织。通常,定义会导致内存的预留。例如,当定义 int 变量时,编译器确保在最终程序中预留一些内存来存储变量的值。尽管它是一个定义,但在编译器处理类定义后,不会为类预留内存。但是,类定义遵循“一次定义规则”:在 C++ 中,实体只能定义一次。由于类定义不意味着内存的预留,因此更倾向于使用类接口这个术语。

类接口通常包含在类的头文件中,例如 person.h。我们将从这里开始定义 Person 类的接口(见第 7.7 节以了解一些类的成员函数背后的 const 关键字的解释):

#include <string>

class Person {
    std::string d_name;    // 个人姓名
    std::string d_address; // 地址字段
    std::string d_phone;   // 电话号码
    size_t d_mass;         // 质量(以千克为单位)

public:
    // 成员函数
    void setName(std::string const &name);
    void setAddress(std::string const &address);
    void setPhone(std::string const &phone);
    void setMass(size_t mass);

    std::string const &name() const;
    std::string const &address() const;
    std::string const &phone() const;
    size_t mass() const;
};

类的成员函数和数据成员

在类的接口中声明的成员函数必须仍然被实现。成员函数的实现称为其定义。

除了成员函数外,类通常还定义了由这些成员函数操作的数据。这些数据称为数据成员。在 Person 类中,数据成员包括 d_named_addressd_phoned_mass。数据成员应该具有私有访问权限。由于类默认使用私有访问权限,因此它们通常会在类接口的顶部列出。

所有外部世界与类数据之间的通信都通过类的成员函数进行。数据成员可以接收新值(例如,使用 setName),也可以被检索以供检查(例如,使用 name)。仅返回存储在对象内部的值的函数,不允许调用者修改这些内部存储的值,称为访问器(accessors)。

语法上,类和 struct 之间只有微小的差别。类默认定义私有成员,而 struct 定义公共成员。然而,从概念上讲,它们之间存在差异。在 C++ 中,struct 的使用方式与 C 中类似:用于聚合数据,所有数据都是自由访问的。而 class 则隐藏其数据不被外部世界访问(这被称为数据隐藏),并提供成员函数来定义外部世界与类的数据成员之间的通信。

根据 Lakos(Lakos, J., 2001,《大型 C++ 软件设计》(Addison-Wesley))的建议,我建议按照以下方式设置类接口:

  • 所有数据成员都具有私有访问权限,并放在接口的顶部。
  • 所有数据成员以 d_ 开头,后跟一个表明其含义的名称(在第 8 章中,我们还会遇到以 s_ 开头的数据成员)。
  • 非私有数据成员确实存在,但应该谨慎定义数据成员的非私有访问权限(见第 13 章)。
  • 成员函数分为两大类:操控器(manipulators)和访问器(accessors)。操控器允许对象的使用者修改对象的内部数据。根据约定,操控器的名称通常以 set 开头,例如 setName
  • 对于访问器,仍然经常遇到 get 前缀,例如 getName。然而,按照 Qt 推广的约定(见 https://doc.qt.io,例如其类 QByteArray 和 QString),使用 get 前缀已经不推荐。因此,与其定义一个成员函数 getAddress,不如直接命名为 address
  • 通常(存在例外),类的公共成员函数会被列在数据成员之后的前面。这些函数是接口的重要部分,因为它们定义了类提供给用户的功能。按照惯例,应该将它们列在接口的顶部。关键字 private 在公共成员函数之后使用,以便将访问权限切换回私有权限,从而将“可以由公众使用的”成员与类自身的支持成员进行良好的分隔。

风格约定通常需要很长时间来发展。然而,这些约定并非强制性的。我建议有充分理由不遵循上述风格约定的读者使用自己的风格。其他读者强烈建议采用上述风格约定。

最后,回到第 3.1.2 节,using namespace std; 应在大多数(如果不是全部)源代码示例中使用。如第 7.11 和 7.11.1 节所解释,using 指令应该跟在预处理指令和头文件包含之后,使用如下设置:

#include <iostream>
#include "person.h"
using namespace std;
int main(){
...
}

构造函数

C++ 类通常包含两类特殊的成员函数,它们对类的正常工作至关重要。这两类是构造函数和析构函数。析构函数的主要任务是当对象“超出作用域”时,将对象分配的内存归还给公共内存池。内存分配在第 9 章讨论,因此对析构函数的深入讲解会推迟到那一章。在当前章节中,重点是类的内部组织和构造函数。

构造函数通过其名称来识别,构造函数的名称与其类名相同。构造函数不指定返回值,甚至不返回 void。例如,Person 类可以定义一个构造函数 Person::Person()。C++ 运行时系统确保在定义一个类的变量时,类的构造函数会被调用。可以定义一个没有任何构造函数的类。在这种情况下,编译器会定义一个默认构造函数,当该类的对象被定义时,会调用这个默认构造函数。实际发生的情况取决于该类定义的数据成员(参见第 7.3.1 节)。

对象可以在本地或全局定义。然而,在 C++ 中,大多数对象都是在本地定义的。全局定义的对象几乎不需要,并且有些被不推荐使用。

当一个函数定义了一个本地对象时,每次函数被调用时,该对象的构造函数都会被调用。对象的构造函数在对象定义的位置被激活(一个细微之处是,对象也可以在表达式中隐式地定义,例如,作为一个临时变量)。

当一个对象被定义为静态对象时,它在程序启动时被构造。在这种情况下,即使在 main 函数开始之前,该对象的构造函数也会被调用。例如:

#include <iostream>
using namespace std;

class Demo
{
public:
    Demo();
};

Demo::Demo()
{
    cout << "Demo constructor called\n";
}

Demo d;

int main()
{
    // 空体
}

生成的输出:

Demo constructor called

程序包含一个全局定义的 Demo 类对象,main 函数体为空。然而,程序仍然产生了由全局定义的 Demo 对象的构造函数生成的输出。

构造函数具有非常重要且明确的角色。它们必须确保在对象构造完成后,所有类的数据成员都有合理的或至少是明确的值。我们将很快回到这个重要任务。默认构造函数没有参数。如果定义了另一个构造函数,且没有抑制其定义,编译器会定义默认构造函数(参见第 7.6 节)。如果除了另一个构造函数之外还需要默认构造函数,那么默认构造函数也必须显式定义。C++ 提供了特殊的语法来实现这一点,而无需太多努力,这也是第 7.6 节中涵盖的内容。

第一个应用

我们的示例类 Person 有三个 std::string 类型的数据成员和一个 size_t 类型的数据成员 d_mass。对这些数据成员的访问是通过接口函数控制的。每当定义一个对象时,类的构造函数会确保其数据成员被赋予“合理”的值。因此,对象从不会存在未初始化的值。数据成员可以被赋予新的值,但不应该直接允许这样做。良好的类设计核心原则(称为数据隐藏)是其数据成员是私有的。因此,数据成员的修改完全由成员函数控制,从而间接地由类设计者控制。类封装了对其数据成员执行的所有操作,凭借这种封装,类对象可以对自身的数据完整性“负责”。以下是 Person 类操作成员的最简定义:

#include "person.h"
using namespace std;

// 先前给出的代码
void Person::setName(string const &name)
{
    d_name = name;
}

void Person::setAddress(string const &address)
{
    d_address = address;
}

void Person::setPhone(string const &phone)
{
    d_phone = phone;
}

void Person::setMass(size_t mass)
{
    d_mass = mass;
}

这是一种最简定义,没有进行检查。但应当明确的是,检查是很容易实现的。例如,为了确保电话号码只包含数字,可以定义如下:

void Person::setPhone(string const &phone)
{
    if (phone.empty())
        d_phone = " - not available -";
    else if (phone.find_first_not_of("0123456789") == string::npos)
        d_phone = phone;
    else
        cout << "A phone number may only contain digits\n";
}

注意这种实现中的双重否定。双重否定很难阅读,可以通过封装成员函数 hasOnly 来处理测试,并提高 setPhone 的可读性:

bool Person::hasOnly(char const *characters, string const &object)
{
    // object 只包含 'characters'
    return object.find_first_not_of(characters) == string::npos;
}

然后 setPhone 变成:

void Person::setPhone(string const &phone)
{
    if (phone.empty())
        d_phone = " - not available -";
    else if (hasOnly("0123456789", phone))
        d_phone = phone;
    else
        cout << "A phone number may only contain digits\n";
}

由于 hasOnly 是一个封装的成员函数,我们可以确保它只用于非空的字符串对象,因此 hasOnly 本身不需要进行检查。

对数据成员的访问由访问器成员控制。访问器确保数据成员不能受到无控制的修改。由于访问器在概念上不会修改对象的数据(只是检索数据),这些成员函数被标记为 const。它们被称为常量成员函数,因为它们保证不修改对象的数据,因此可以对可修改对象和常量对象都可用(参见第 7.7 节)。

为了防止后门,我们还必须确保通过访问器的返回值不能修改数据成员。对于内置的基本类型值,这很简单,因为它们通常是按值返回的,这些值是变量中的值的副本。但由于对象可能非常大,通常通过引用返回对象以避免复制。通过引用返回数据成员可能会创建后门,如以下示例所示,展示了函数定义下面的允许滥用:

string &Person::name() const
{
    return d_name;
}

Person somebody;
somebody.setName("Nemo");
somebody.name() = "Eve";
// 哦,后门,改变了名字

为了防止后门,对象从访问器返回时作为 const 引用返回。以下是 Person 的访问器的实现:

#include "person.h"
using namespace std;

// 先前给出的代码
string const &Person::name() const
{
    return d_name;
}

string const &Person::address() const
{
    return d_address;
}

string const &Person::phone() const
{
    return d_phone;
}

size_t Person::mass() const
{
    return d_mass;
}

Person 类的使用示例

Person 类的接口仍然是类设计的起点:它的成员函数定义了可以对 Person 对象执行的操作。最终,其成员的实现只是允许 Person 对象完成其工作的技术细节。

下面的示例展示了如何使用 Person 类。一个对象被初始化并传递给 printperson() 函数,打印该人的数据。注意 printperson 函数参数列表中的引用操作符。函数仅传递对现有 Person 对象的引用,而不是整个对象。printperson 不会修改其参数,这从参数声明为 const 可见。

#include <iostream>
#include "person.h"
using namespace std;

// 先前定义的 Person 类
void printperson(Person const &p)
{
    cout << "Name : " << p.name()
         << "\nAddress : " << p.address() << "\n"
         << "Phone : " << p.phone()
         << "\nMass : " << p.mass() << '\n';
}

int main()
{
    Person p;
    p.setName("Linus Torvalds");
    p.setAddress("E-mail: Torvalds@cs.helsinki.fi");
    p.setPhone("");
    p.setMass(75); // kg.

    printperson(p);
}

生成的输出:

Name : Linus Torvalds
Address : E-mail: Torvalds@cs.helsinki.fi
Phone : - not available -
Mass : 75

构造函数:有参数与无参数

Person 类的构造函数目前还没有接收任何参数。C++ 允许定义带参数和不带参数的构造函数。构造函数在对象定义时接受参数。

对于 Person 类,定义一个接受三个字符串和一个 size_t 的构造函数可能会很有用,分别表示人的姓名、地址、电话号码和体重。这个构造函数可以这样实现(但请参阅第7.3.1节):

Person::Person(string const &name, string const &address,
               string const &phone, size_t mass)
{
    d_name = name;
    d_address = address;
    setPhone(phone);
    d_mass = mass;
}

当然,这个构造函数也必须在类接口中声明:

class Person
{
    // 数据成员(未修改)
public:
    Person(std::string const &name, std::string const &address,
           std::string const &phone, size_t mass);
};

既然这个构造函数已经被声明了,如果我们仍然希望能够在不指定任何初始值的情况下构造一个普通的 Person 对象,那么默认构造函数也必须显式地声明。Person 类将支持两个构造函数,其声明部分现在变为:

class Person
{
    // 数据成员
public:
    Person();
    Person(std::string const &name, std::string const &address,
           std::string const &phone, size_t mass);
};

在这种情况下,默认构造函数不需要做太多工作,因为它不需要初始化 Person 对象的字符串数据成员。由于这些数据成员本身是对象,它们会通过自己的默认构造函数自动初始化为空字符串。然而,还有一个 size_t 数据成员。这个成员是内置类型的变量,这些变量没有构造函数,因此不会自动初始化。因此,除非显式初始化 d_mass 数据成员的值,否则它的值是:

构造函数:有参数与无参数

  • 局部对象Person 对象会有一个随机值。
  • 全局和静态对象Person 对象则会初始化为0。

虽然0值可能还可以接受,但通常我们不希望数据成员有随机值。因此,即使是默认构造函数也需要完成初始化数据成员的任务,将未初始化的数据成员设为合理的值。其实现可以是:

Person::Person()
{
    d_mass = 0;
}

接下来通过构造函数的使用示例来说明带参数和不带参数的构造函数的使用。对象 karel 使用带有非空参数列表的构造函数进行初始化,而默认构造函数用于初始化 anon 对象。在使用需要参数的构造函数构造对象时,建议使用花括号括起来的参数。虽然也可以使用括号,有时甚至必须使用(参见第12.4.2节),但盲目地使用括号可能会导致意外问题(参见第7.2节)。因此,建议优先使用花括号而非括号。以下是展示两个构造函数调用的示例:

int main()
{
    Person karel{ "Karel", "Rietveldlaan 37", "542 6044", 70 };
    Person anon;
}

这两个 Person 对象在 main 开始时定义,因为它们是局部对象,只在 main 活动期间存在。

如果需要使用其他参数定义 Person 对象,则必须将相应的构造函数添加到 Person 的接口中。除了重载类构造函数外,还可以提供具有默认参数值的构造函数。这些默认参数必须在类接口中的构造函数声明中指定,如下所示:

class Person
{
public:
    Person(std::string const &name,
           std::string const &address = "--unknown--",
           std::string const &phone = "--unknown--",
           size_t mass = 0);
};

构造函数的实现通常会非常相似。这是因为构造函数的参数通常为了方便而定义:一个不需要电话号码但需要体重的构造函数无法使用默认参数,因为电话号码不是构造函数的最后一个参数。因此,需要一个特殊的构造函数,其参数列表中不包含电话号码。然而,这并不一定意味着构造函数必须重复其代码,因为构造函数可以互相调用(称为构造函数委托)。构造函数委托将在第7.4.1节中说明。

构造顺序

构造函数接受参数的能力允许我们监控程序执行过程中的对象构造顺序。以下是一个使用 Test 类的示例程序。该程序定义了一个全局 Test 对象和两个局部 Test 对象。构造顺序如下:首先是全局对象,然后是 main 函数中的第一个局部对象,再是 func 函数中的局部对象,最后是 main 函数中的第二个局部对象:

#include <iostream>
#include <string>
using namespace std;

class Test
{
public:
    Test(string const &name);
};

// 带参数的构造函数
Test::Test(string const &name)
{
    cout << "Test object " << name << " created" << '\n';
}

Test globaltest("global");

void func()
{
    Test functest("func");
}

int main()
{
    Test first{ "main first" };
    func();
    Test second{ "main second" };
}

生成的输出:

Test object global created
Test object main first created
Test object func created
Test object main second created

模糊性解决

使用括号调用构造函数可能会导致意想不到的结果。假设你有如下的类接口:

class Data
{
public:
    Data();
    Data(int one);
    Data(int one, int two);
    void display();
};

意图是分别使用第一个和第二个构造函数定义两个 Data 对象,同时在对象定义中使用括号。你的代码如下(忽略“未使用”警告,它会正确编译):

#include "data.h"

int main()
{
    Data d1();
    Data d2(argc);
}

为了消除“未使用”的警告,添加两个语句到 main

d1.display();
d2.display();

但令人惊讶的是,编译器对前两个语句发出了如下错误:

error: request for member 'display' in 'd1', which is of non-class type 'Data()'

这是什么情况?首先,注意编译器所提到的数据类型是 Data(),而不是 Data。这些 () 是做什么的?

在回答这个问题之前,让我们稍微扩展一下故事。我们知道在某个库中存在一个工厂函数 dataFactory。这个工厂函数创建并返回一个 Data 对象,使用 Data 的默认构造函数。因此,dataFactory 不需要任何参数。我们想在程序中使用 dataFactory,但必须声明这个函数。所以我们在 main 中添加了声明,因为这是唯一使用 dataFactory 的地方。它是一个不需要参数的函数,返回一个 Data 对象:

Data dataFactory();

这与我们定义 d1 对象的代码非常相似:

Data d1();

我们找到问题的解决方案:Data d1() 实际上不是定义一个 d1 对象,而是声明一个返回 Data 对象的函数。所以,发生了什么,如何使用 Data 的默认构造函数来定义一个 Data 对象呢?

首先:发生的事情是,当编译器遇到 Data d1() 时,它实际上有两个选择:要么定义一个 Data 对象,要么声明一个函数。它选择了声明一个函数。

这里我们遇到了 C++ 语法的模糊性问题,根据语言标准,总是让声明优先于定义。在本节稍后我们将遇到更多这种模糊性的情况。

其次:我们可以通过几种方式解决这种模糊性问题,以达到我们想要的结果:

  • 仅仅提到它(就像 int x):Data d1;
  • 使用花括号初始化:Data d1{};
  • 使用赋值运算符和一个匿名的默认构造的 Data 对象:Data d1 = Data{};Data d1 = Data();

类型 ‘Data’ 与 ‘Data()’

在上一个示例中,Data() 定义了一个默认构造的匿名 Data 对象。这引出了编译器错误。根据编译器的提示,我们原始的 d1 显然不是 Data 类型,而是 Data() 类型。那么这是什么呢?

让我们先看一下我们的第二个构造函数。它期望一个 int 参数。我们希望使用第二个构造函数来定义另一个 Data 对象,并传递默认的 int 值给构造函数,使用 int()。我们知道这定义了一个默认的 int 值,因为 cout << int() << '\n' 显示了 0,而 int x = int() 也将 x 初始化为 0。因此,我们尝试在 main 中使用 Data di(int())

不行:当我们尝试使用 di 时,编译器再次报错。在 di.display() 之后,编译器告诉我们:

error: request for member 'display' in 'di', which is of non-class type 'Data(int (∗)())'

哎呀,又不像预期的那样……我们不是传递了 0 吗?为什么会有指针?好吧,我们再次遇到编译器相同的“尽可能使用声明”的策略。Type() 表示类型 Type 的默认值,但它也是一个指向不接受参数并返回 Type 值的函数的匿名指针的简写,你可以通过定义 int (*ip)() = nullptr,并将 ip 作为参数传递给 di 来验证这一点:di(ip) 可以正常编译。

那么,为什么在插入 int() 或将 int() 赋值给 int x 时不会出现错误呢?在这些情况下没有进行声明。实际上,“cout”和“int x =”需要的是产生值的表达式,而 int() 的“自然”解释提供了这一点。但是在 Data di(int()) 的情况下,编译器再次面临选择(并且根据设计,它选择了声明,因为声明优先)。

同样,如果 int x 已定义,Data b1(int(x)) 声明 b1 为一个函数,期望一个 int(因为 int(x) 表示一个类型),而 Data b2((int)x) 定义 b2 为一个 Data 对象,使用期望一个 int 值的构造函数。

再次提醒:为了使用默认实体、值或对象,建议使用 {} 而不是 ()Data di{ int{} } 定义了一个 Data 类型的 di,调用 Data(int x) 构造函数,并使用 int 的默认值 0。

那么编译器的原始抱怨是怎么回事?编译器告诉我们,我们原始的 d1 不是 Data 类型,而是 Data() 类型。那么这是什么?错误信息中的括号是重要的:它表示一个函数:d1,由于随后的括号,声明了一个不接受参数并返回一个 Data 对象的函数。简写为 Data(),详细表示为 Data(*)()。最后的示例:以下程序输出“hello world”两次,main 中的 Data d1() 实际上是全局定义的函数 d1 的显式局部声明,进一步通过将 d1 分配给一个显式声明的函数指针 pf 来说明:

#include <iostream>
#include "data.h"

Data::Data()
{}

Data d1()
{
    std::cout << "hello world\n";
    return Data{};
}

int main()
{
    Data d1();
    d1();
    Data (*pf)() = d1;
    pf(); // 或者 (同样):(*pf)()
}

因为 d1() 返回一个 Data 对象,所以像 d1().display() 这样的语句可以正确编译。

多余的括号

让我们继续探讨。在程序中的某个地方,我们定义了 int b。然后,在一个复合语句中,我们需要构造一个匿名的 Data 对象,用 b 进行初始化,并且我们还需要显示 b 本身:

int b = 18;
{
    Data(b);
    cout << b;
}

关于 cout 语句,编译器告诉我们(我修改了错误信息以揭示其含义):

error: cannot bind ‘std::ostream & << Data const &’

在这里,我们并没有插入 int b,而是 Data b。如果我们省略复合语句,编译器将会抱怨 b 实体被重复定义,因为 Data(b) 实际上意味着 Data b,一个通过默认构造函数构造的 Data 对象。在这种情况下,括号是多余的,编译器在解析定义或声明时可能会省略它们。

当然,现在的问题是如何定义一个通过 int b 初始化的临时 Data 对象。请记住,编译器可能会去除多余的括号。因此,我们需要在不使用 int 名称的情况下将 int 传递给匿名 Data 对象。

  • 我们可以使用强制类型转换:Data(static_cast<int>(b));
  • 我们可以使用大括号初始化:Data{ b };

值和类型之间的差异很大。考虑以下定义:

Data (*d4)(int);   // 1
Data (*d5)(3);     // 2

定义 1 不应有问题:它是一个指向函数的指针,该函数期望一个 int 参数,返回一个 Data 对象。因此,d4 是一个指针变量。

定义 2 稍微复杂一点。是的,它是一个指针。但是它与函数没有关系。那么参数列表中的 3 是干什么的呢?实际上,这不是参数列表。它是一个看起来像参数列表的初始化。记住:变量可以使用赋值语句、圆括号或大括号进行初始化。通常,它们是可以互换的。因此,我们可以将 (3) 替换为 = 3{3}。让我们选择第一个替代方案,结果是:

Data (*d5) = 3;

现在,我们再次“扮演编译器”。去除一些多余的括号,我们得到:

Data *d5 = 3;

这是一个指向 Data 对象的指针,初始化为 3。这在语法上是正确的,但在语义上是不正确的:地址 3 并不存在 Data 对象。如果我们最初写的是:

Data (*d5)(&d1);  // 2

那么 int 和 3 之间的对比产生的奇怪结果可能会被掩盖。

已有类型

一旦定义了类型名称,它将优先于代表变量的标识符,如果编译器有选择的话。这也可能导致一些有趣的构造。

假设有一个函数 process,它接受一个 int 类型的参数,并且存在于一个库中。我们想要使用这个函数来处理一些 int 数据值。在 main 函数中,process 被声明并调用:

int process(int Data);
process(argc);

在这里没有问题。但不幸的是,我们曾经决定“美化”代码,加入了一些多余的括号,如下所示:

int process(int (Data));
process(argc);

我们遇到了麻烦。现在编译器产生了一个错误,这是因为遵循了让声明优于定义的规则。Data 现在成为了 Data 类的名称,并且类似于 int (x),参数 int (Data) 被解析为 int (*)(Data):一个指向函数的指针,该函数接受一个 Data 对象,返回一个 int

另一个例子是。如果我们声明:

int process(int Data[10]);

而我们声明,如下所示,来强调传递的是一个数组:

int process(int (Data[10]));

那么 process 函数不再期望一个指向 int 值的指针,而是一个指向函数的指针,该函数期望一个指向 Data 元素的指针,返回一个 int。大小 10 被视为一个数字,并不会被字面解释,因为它是函数参数规格的一部分。

总结“模糊性解决”部分的发现:

  • 编译器会尝试去除多余的括号;
  • 但如果带括号的构造表示一个类型,它将尝试使用这个类型;
  • 更一般地,当可能时,编译器会将语法构造解释为声明,而不是作为一个对象或变量的定义;
  • 大多数由编译器将构造解释为声明而产生的问题是由于我们使用了括号。作为经验法则:在构造对象(或值)时,使用大括号而不是括号。

对象中的对象:组合

Person 类中,使用对象作为数据成员。这种构造技术称为组合

组合既不是特殊的,也不是 C++ 特有的:在 C 语言中,结构体(struct)或联合体(union)字段通常用于其他复合类型。在 C++ 中,它需要一些特别的思考,因为它们的初始化有时会受到限制,这将在接下来的几节中讨论。

组合与(常量)对象:(常量)成员初始化器

除非另有说明,类的对象数据成员会通过其默认构造函数进行初始化。使用默认构造函数可能并不是初始化对象的最佳方式,甚至可能根本不可行:一个类可能根本不定义默认构造函数。

之前我们遇到了 Person 类的构造函数如下:

Person::Person(string const &name, string const &address,
                string const &phone, size_t mass)
{
    d_name = name;
    d_address = address;
    d_phone = phone;
    d_mass = mass;
}

考虑一下这个构造函数的实现。在构造函数体内我们看到对 string 对象的赋值。由于赋值操作是在构造函数体内进行的,因此赋值的左侧对象必须已经存在。但在对象构造期间,构造函数必须已经被调用。这个初始化过程在 Person 类构造函数体内立即被覆盖。这不仅低效,有时甚至是不可能的。例如,假设类接口中提到了一个 const 类型的数据成员:一个值不应该改变的数据成员(比如生日,通常不会发生太大变化,因此是 const 类型的良好候选)。构造一个生日对象并为其提供初始值是可以的,但改变初始值是不允许的。

构造函数体内允许对数据成员进行赋值,但数据成员的初始化发生在此之前。C++ 定义了成员初始化器的语法,允许我们在构造对象时指定数据成员的初始化方式。成员初始化器指定在构造函数的参数列表后的冒号和构造函数体的开始大括号之间,如下所示:

Person::Person(string const &name, string const &address,
                string const &phone, size_t mass)
    : d_name(name),
      d_address(address),
      d_phone(phone),
      d_mass(mass)
{}

在这个示例中,成员初始化使用了围绕初始化表达式的圆括号。除了圆括号,还可以使用花括号。例如,d_name 也可以这样初始化:

d_name{ name },

成员初始化始终在类中构造对象时发生:如果在成员初始化列表中没有提到构造函数,则会调用对象的默认构造函数。请注意,这只适用于对象。基本数据类型的成员(如 intdouble)不会自动初始化。

然而,成员初始化也可以用于基本数据成员,如 intdouble。上面的示例显示了如何从参数 mass 初始化数据成员 d_mass。当使用成员初始化器时,即使数据成员的名称与构造函数的参数名称相同(虽然这已被弃用),也不会产生歧义,因为在成员初始化器中使用的第一个(左侧)标识符始终是一个正在初始化的数据成员,而圆括号中的标识符被解释为参数。

类类型数据成员的初始化顺序由在构成类接口中定义这些成员的顺序决定。如果构造函数中的初始化顺序与类接口中的顺序不同,编译器会发出警告,并将初始化顺序调整为与类接口中的顺序一致。

应该尽可能多地使用成员初始化器。正如所示,这可能是必需的(例如,为 const 数据成员初始化,或初始化缺少默认构造函数的类的对象),而且如果不使用成员初始化器,代码会变得低效,因为数据成员的默认构造函数会自动调用,除非明确指定了成员初始化器。在这种情况下,构造函数体内的重新赋值明显低效。当然,有时使用默认构造函数是可以的,但在这些情况下可以省略显式成员初始化器。

作为经验法则:如果在构造函数体内对数据成员进行了赋值,则尽量避免在赋值操作中使用构造函数体,而是使用成员初始化器。

组合与引用对象:引用成员初始化器

除了使用成员初始化器来初始化组合对象(无论它们是否是常量对象),还有一种情况需要使用成员初始化器。考虑以下情况:

一个程序使用 Configfile 类的对象来访问配置文件中的信息。配置文件包含程序参数,这些参数可以通过更改配置文件中的值来设置,而不是通过提供命令行参数。假设另一个在 main 中使用的对象是 Process 类的对象,它执行“所有工作”。我们有哪些方法可以告诉 Process 类的对象存在一个 Configfile 类的对象?

  • 对象可以被声明为全局对象。这是一种可能性,但不是很好,因为这样会失去所有局部对象的优势。
  • Configfile 对象可以在 Process 对象的构造时传递给它。直接传递对象(即按值传递)可能不是一个好主意,因为对象必须被复制到 Configfile 参数中,然后 Process 类的一个数据成员可以用来在整个 Process 类中访问 Configfile 对象。这可能涉及到另一个对象复制任务,如下所示:
Process::Process(Configfile conf)
{
    d_conf = conf;
    // 从调用者复制
    // 复制到 d_conf 成员
}
  • 如果使用指向 Configfile 对象的指针,可以避免复制指令,如下所示:
Process::Process(Configfile *conf)
    // 指向外部对象的指针
{
    d_conf = conf;
    // d_conf 是 Configfile *
}

这种构造本身是可以的,但强迫我们使用 -> 操作符来选择字段,而不是 . 操作符,这在概念上可能更倾向于将 Configfile 对象视为对象,而不是对象的指针。在 C 中,这可能是首选的方法,但在 C++ 中,我们可以做得更好。

  • 与其使用值或指针参数,不如将 Configfile 参数定义为 Process 构造函数的引用参数。接下来,在 Process 类中使用 Configfile 的引用数据成员。

但引用变量不能通过赋值来初始化,因此以下代码是不正确的:

Process::Process(Configfile &conf)
{
    d_conf = conf;
    // 错误:没有赋值
}

语句 d_conf = conf 失败了,因为它不是初始化,而是将一个 Configfile 对象(即 conf)赋值给另一个(d_conf)。对引用变量的赋值实际上是对引用变量所引用的变量进行赋值。但 d_conf 引用的是哪个变量?没有变量被引用,因为我们还没有初始化 d_conf。毕竟,语句 d_conf = conf 的整个目的是初始化 d_conf……如何初始化 d_conf?我们再次使用成员初始化器语法。以下是初始化 d_conf 的正确方法:

Process::Process(Configfile &conf)
    : d_conf(conf)
    // 初始化引用成员
{}

在使用引用数据成员的所有情况下,必须使用上述语法。例如,如果 d_ir 是一个 int 引用数据成员,则需要如下构造:

Process::Process(int &ir)
    : d_ir(ir)
{}

这确保了引用成员在对象构造时得到正确初始化。

数据成员初始化器

类的非静态数据成员通常由类的构造函数进行初始化。频繁地(但并非总是)相同的初始化操作被不同的构造函数使用,导致初始化在多个点进行,这使得类的维护变得复杂。

考虑一个定义了几个数据成员的类:一个指向数据的指针、一个存储指针所指向的数据元素数量的数据成员、一个存储对象序列号的数据成员。该类还提供了一组基本的构造函数,如下所示:

class Container
{
    Data *d_data;
    size_t d_size;
    size_t d_nr;
    static size_t s_nObjects;
public:
    Container();
    Container(Container const &other);
    Container(Data *data, size_t size);
    Container(Container &&tmp);
};

数据成员的初始值很容易描述,但实现起来有些困难。考虑初始情况,假设使用默认构造函数:所有数据成员都应该被设置为0,除了 d_nr 需要被赋值为 ++s_nObjects。由于这些是非默认操作,我们不能使用 = default 来声明默认构造函数,而必须提供实际的实现:

Container()
    : d_data(0),
      d_size(0),
      d_nr(++s_nObjects)
{}

实际上,所有构造函数都需要声明 d_nr(++s_nObjects) 初始化。如果 d_data 的类型是一个(支持移动的)类类型,我们仍然需要为上述所有构造函数提供实现。

然而,C++ 也支持数据成员初始化器,这简化了非静态数据成员的初始化。数据成员初始化器允许我们为数据成员分配初始值。编译器必须能够从初始化表达式中计算出这些初始值,但初始值不必是常量表达式。因此,++s_nObjects 可以作为初始值。

使用数据成员初始化器对于 Container 类,我们可以得到:

class Container
{
    Data *d_data = 0;
    size_t d_size = 0;
    size_t d_nr = ++s_nObjects;
    static size_t s_nObjects;
public:
    Container() = default;
    Container(Container const &other);
    Container(Data *data, size_t size);
    Container(Container &&tmp);
};

请注意,数据成员初始化器由编译器识别,并应用于默认构造函数的实现。实际上,所有构造函数都将应用数据成员初始化器,除非显式地初始化其他内容。例如,移动构造函数现在可以这样实现:

Container(Container &&tmp)
    : d_data(tmp.d_data),
      d_size(tmp.d_size)
{
    tmp.d_data = 0;
}

尽管 d_nr 的初始化在实现中被省略了,但由于在类的接口中提供了数据成员初始化器,它仍然会被初始化。

一个聚合(aggregate)是一个数组或一个类(通常是一个没有用户定义构造函数、没有私有或保护的非静态数据成员、没有基类(参见第13章),并且没有虚函数(参见第14章)的结构体)。例如:

struct POD {  // 定义聚合 POD 
    int first = 5;
    double second = 1.28;
    std::string hello{"hello"};
};

为了初始化这样的聚合,可以使用大括号初始化列表。实际上,使用大括号的初始化方式优于使用较旧的形式(使用圆括号),因为大括号避免了与函数声明的混淆。例如:

POD pod{ 4, 13.5, "hi there" };

使用大括号初始化列表时,并不是所有数据成员都需要被初始化。指定可以停止在任何数据成员,此时剩余的数据成员将使用默认(或显式定义的初始化)值。例如:

POD pod{ 4 };
// 使用 second: 1.28, hello: "hello"

委托构造函数

构造函数通常是彼此的特例,允许在构造对象时只指定部分参数,对于其余的数据成员使用默认参数值进行初始化。在 C++11 标准之前,常见的做法是定义一个成员函数(如 init),执行所有构造函数共同的初始化。然而,这种 init 函数不能用于初始化 const 或引用数据成员,也不能用于执行所谓的基类初始化(参见第13章)。

下面是一个可能使用 init 函数的示例。假设有一个 Stat 类,作为 C 的 stat(2) 函数的包装类。这个类可能定义了三个构造函数:一个不带参数并将所有数据成员初始化为适当的值;第二个构造函数接收一个文件名并调用 stat 函数;第三个构造函数接收文件名和搜索路径。为了避免在每个构造函数中重复初始化代码,可以将通用代码提取到一个 init 函数中,并在构造函数中调用这个 init 函数。

C++ 提供了一种替代方法,即允许构造函数调用彼此。这被称为委托构造函数,下面的例子演示了这一点:

class Stat
{
public:
    Stat()
        : Stat("", "")
    // 无文件名/搜索路径
    {}

    Stat(std::string const &fileName)
        : Stat(fileName, "") 
    // 只有文件名
    {}

    Stat(std::string const &fileName, std::string const &searchPath)
        : d_filename(fileName), d_searchPath(searchPath)
    {
        // 其他构造函数需要执行的操作
    }

private:
    std::string d_filename;
    std::string d_searchPath;
};

C++ 允许在类接口中初始化静态 const 整数数据成员(参见第8章)。C++11 标准在此基础上添加了在类接口中为普通数据成员定义默认初始化值的功能(这些数据成员可以是 const 或整数类型,但不能是引用数据成员)。

这些默认初始化值可以被构造函数覆盖。例如,如果 Stat 类使用一个默认值为 false 的布尔数据成员 d_hasPath,但第三个构造函数(如上所示)应将其初始化为 true,可以采用如下方法:

class Stat
{
    bool d_hasPath = false;

public:
    Stat(std::string const &fileName, std::string const &searchPath)
        : d_hasPath(true) 
    // 覆盖接口中指定的默认值
    {}
};

在这里,d_hasPath 只会被初始化一次:它通常初始化为 false,但在使用所示构造函数时,会初始化为 true

统一初始化

在定义变量和对象时,它们可以立即被赋予初始值。类类型的对象总是由其可用的构造函数之一进行初始化。C 语言已经支持由一组常量表达式组成的数组和结构体初始化列表,这些常量表达式由一对花括号包围。C++ 支持一种类似的初始化方式,称为统一初始化,其语法如下:

Type object{ value list };

在定义对象时,如果使用了一组对象,那么每个对象都可以使用其自己的统一初始化方式。

与使用构造函数相比,统一初始化的优势在于,使用构造函数参数有时会导致歧义,因为构造对象有时可能会与使用对象的重载函数调用运算符混淆(参见第11.11节)。由于初始化列表只能用于普通旧数据(POD)类型(参见第9.10节)以及“初始化列表感知”类(如 std::vector),因此当使用初始化列表时,不会出现歧义。

统一初始化可以用于初始化对象或变量,也可以在构造函数中初始化数据成员,或隐式地用于函数的返回语句中。以下是一些示例(为了简洁,采用类内实现):

class Person {
    // 数据成员
public:
    Person(std::string const &name, size_t mass) : d_name{name}, d_mass{mass} {}
    Person copy() const { return {d_name, d_mass}; }
};

在意料之外的地方可能会遇到对象定义,这很容易导致(人为的)混淆。考虑一个名为 func 的函数和一个非常简单的类 Fun(这里使用 struct,因为数据隐藏不是问题):

void func();

struct Fun
{
    Fun(void (*f)())
    {
        std::cout << "Constructor\n";
    }

    void process()
    {
        std::cout << "process\n";
    }
};

假设在 main 函数中定义了一个 Fun 对象如下:

Fun fun(func);

运行此程序时,将显示 Constructor,确认对象 fun 已被构造。接下来我们更改这行代码,从一个匿名的 Fun 对象调用 process

Fun(func).process();

如预期的那样,输出 Constructor,然后是 process

那么,仅仅定义一个匿名的 Fun 对象会怎样呢?我们这样做:

Fun(func);

现在,我们会感到惊讶。编译器抱怨 Fun 的默认构造函数丢失了。为什么会这样呢?在 Fun 后面插入一个空格,你会得到 Fun (func)。在标识符周围使用括号是可以的,并且在解析带括号的表达式时,括号会被移除。在这种情况下:

(func) 等于 func,因此我们得到了 Fun func:使用 Fun 的默认构造函数定义了一个 Fun func 对象(而 Fun 的类接口中并未声明该构造函数)。

那么为什么 Fun(func).process() 能够编译呢?在这种情况下,我们有一个成员选择器操作符,其左侧操作数必须是一个类类型对象。对象必须存在,而 Fun(func) 代表那个对象。这不是一个现有对象的名称,而是一个构造函数,期望一个函数(如 func)作为其参数。因此,编译器创建了一个匿名的 Fun 对象,并将 func 作为其参数。

显然,使用 Fun(func) 括号无法创建匿名的 Fun 对象。然而,可以使用统一初始化来定义匿名的 Fun 对象,语法如下:

Fun{ func };

(这也可以用于立即调用其成员之一。例如,Fun{ func }.process()

虽然统一初始化语法与初始化列表的语法略有不同(后者使用赋值运算符),但如果有一个支持初始化列表的构造函数,编译器仍然会使用初始化列表。举个例子:

class Vector
{
public:
    Vector(size_t size);
    Vector(std::initializer_list<int> const &values);
};

Vector vi = {4};

在定义 vi 时,调用的是期望初始化列表的构造函数,而不是期望 size_t 参数的构造函数。如果需要后者,必须使用标准构造函数语法,即 Vector vi(4)

初始化列表本身也是可以使用另一个初始化列表构造的对象。然而,存储在初始化列表中的值是不可变的。一旦定义了初始化列表,其值将保持不变。

初始化列表支持一组基本的成员函数和构造函数:

  • initializer_list<Type> object: 定义对象为一个空的初始化列表
  • initializer_list<Type> object { list of Type values }: 定义对象为包含 Type 值的初始化列表
  • initializer_list<Type> object(other): 使用其他列表中的值初始化对象
  • size_t size() const: 返回初始化列表中的元素数量
  • Type const ∗begin() const: 返回指向初始化列表中第一个元素的指针
  • Type const ∗end() const: 返回指向初始化列表中最后一个元素之后的位置的指针

默认的和删除的类成员

在日常的类设计中,经常会遇到两种情况:

  • 一个类提供了构造函数,因此必须显式定义一个默认构造函数;
  • 一个类(例如,实现流的类)不能通过复制已有对象的值来初始化对象(称为拷贝构造),也不能将对象相互赋值。

一旦一个类定义了至少一个构造函数,其默认构造函数将不会由编译器自动定义。C++ 通过提供 = default 语法在某种程度上放宽了这一限制。一个类在其默认构造函数声明中指定 = default 表示应由编译器提供一个平凡的默认构造函数。平凡的默认构造函数执行以下操作:

  • 其内建或原始类型的数据成员不进行初始化;
  • 其复合(类类型)数据成员由其默认构造函数初始化;
  • 如果该类是从基类派生的(参见第13章),则基类由其默认构造函数初始化。

平凡的实现也可以为拷贝构造函数、重载的赋值运算符和析构函数提供。这些成员将在第9章中介绍。

相反,某些情况下某些(否则会自动提供的)成员不应提供。这可以通过指定 = delete 来实现。以下示例说明了如何使用 = default= delete。默认构造函数获得其平凡实现,而拷贝构造则被禁止:

class Strings
{
public:
    Strings() = default;
    Strings(std::string const *sp, size_t size);

    Strings(Strings const &other) = delete;
};

常量成员函数与常量对象

关键字 const 经常出现在成员函数的参数列表后面。这个关键字表示成员函数不会修改其对象的数据成员。这种成员函数称为常量成员函数。在 Person 类中,我们看到访问器函数被声明为 const

class Person
{
public:
    std::string const &name() const;
    std::string const &address() const;
    std::string const &phone() const;
    size_t mass() const;
};

在第 3.1.1 节给出的经验法则同样适用于此处:出现在 const 关键字左侧的内容不会被修改。对于成员函数,这应理解为“不会修改其自身的数据”。

当实现一个常量成员函数时,必须重复使用 const 属性:

std::string const &Person::name() const
{
    return d_name;
}

编译器会阻止类的数据成员被其常量成员函数修改。因此,像下面这样的语句

d_name[0] = toupper(static_cast<unsigned char>(d_name[0]));

如果被添加到上述函数的定义中,会导致编译器错误。

常量成员函数用于防止无意的数据修改。除了构造函数和析构函数(参见第 9 章)外,只有常量成员函数可以与(普通、引用或指针)常量对象一起使用。

常量对象通常作为函数的 const & 参数出现。在这些函数内部,只有对象的常量成员可以使用。以下是一个示例:

void displayMass(std::ostream &out, Person const &person)
{
    out << person.name() << " weighs " << person.mass() << " kg.\n";
}

由于 person 被定义为 Person const &,函数 displayMass 不能调用如 person.setMass(75) 这样的函数。

const 成员函数属性可以用于重载成员函数。当函数根据其 const 属性进行重载时,编译器会使用最符合对象 const 限定符的成员函数:

  • 当对象是常量对象时,只能使用常量成员函数。
  • 当对象不是常量对象时,使用非 const 成员函数,除非只有常量成员函数可用。在这种情况下,将使用常量成员函数。

下面的示例演示了如何选择(非)常量成员函数:

#include <iostream>
using namespace std;

class Members {
public:
    Members();
    void member();
    void member() const;
};

Members::Members()
{}

void Members::member()
{
    cout << "non const member\n";
}

void Members::member() const
{
    cout << "const member\n";
}

int main()
{
    Members const constObject;
    Members nonConstObject;

    constObject.member();
    nonConstObject.member();
}

/*
生成的输出:
const member
non const member
*/

作为设计的一般原则:成员函数应始终赋予 const 属性,除非它们确实修改对象的数据。

匿名对象

有时候,对象的存在只是为了提供某种功能。这类对象的存在仅仅是为了实现某些功能,且对象本身的内容从未被改变。以下的 Print 类提供了打印字符串的功能,使用一个可配置的前缀和后缀。一个部分的类接口可以是这样的:

class Print
{
public:
    Print(std::ostream &out);
    void print(std::string const &prefix, std::string const &text, std::string const &suffix) const;
};

这样的接口可以让我们执行以下操作:

Print print{ std::cout };
for (int idx = 0; idx != argc; ++idx)
    print.print("arg: ", argv[idx], "\n");

这段代码运行良好,但如果我们能够将 print 的不变参数传递给 Print 的构造函数,它可以被大大简化。这将简化 print 的原型(只需要传递一个参数,而不是三个),而且我们可以将上述代码包装在一个接收 Print 对象的函数中:

void allArgs(Print const &print, int argc, char **argv)
{
    for (int idx = 0; idx != argc; ++idx)
        print.print(argv[idx]);
}

以上代码对于 Print 来说是相当通用的。由于前缀和后缀不变,它们可以被传递给构造函数,构造函数的原型可以定义为:

Print(std::ostream &out, std::string const &prefix = "", std::string const &suffix = "");

现在可以如下使用 allArgs 函数:

Print p1{ std::cout, "arg: ", "\n" }; // 打印到 cout
Print p2{ std::cerr, "err: --", "--\n" }; // 打印到 cerr
allArgs(p1, argc, argv); // 打印到 cout
allArgs(p2, argc, argv); // 打印到 cerr

但现在我们注意到,p1p2 仅在 allArgs 函数内部被使用。此外,从 print 的原型可以看出,print 不会修改它使用的 Print 对象的内部数据。在这种情况下,其实没有必要在使用对象之前定义它们。相反,可以使用匿名对象。匿名对象可以用于:

  • 初始化作为对象常量引用的函数参数;
  • 如果对象仅在函数调用内使用。

当匿名对象作为函数的 const & 参数传递时,它们被视为常量,因为它们仅仅是为了将(类类型)对象的信息传递给这些函数而存在的。这样,它们不能被修改,也不能使用它们的非 const 成员函数。当然,可以使用 const_cast 去除常量引用的常量性,但这被认为是函数接收匿名对象时的不良实践。而且,一旦函数返回,对匿名对象所做的任何修改都会丢失,因为匿名对象在函数调用后就不再存在了。用于初始化常量引用的匿名对象不应与传递给定义为右值引用参数的匿名对象混淆(参见 3.3.2 节),它们的存在目的完全不同。右值引用主要用于被接收它们的函数“吞噬”。因此,通过右值引用提供的信息比右值引用对象(也是匿名的)存在的时间更长。

当构造函数被使用而不为构造的对象提供名称时,匿名对象就被定义了。下面是相应的示例:

allArgs(Print{ std::cout, "arg: ", "\n" }, argc, argv); // 打印到 cout
allArgs(Print{ std::cerr, "err: --", "--\n" }, argc, argv); // 打印到 cerr

在这种情况下,Print 对象被构造,并立即作为第一个参数传递给 allArgs 函数,在函数内部可以通过参数 print 访问它们。当 allArgs 函数执行时,可以使用这些对象,但一旦函数完成,匿名的 Print 对象将不再可访问。

匿名对象的微妙之处

匿名对象可以用于初始化函数参数,这些参数是对象的常量引用。这些对象在调用这样的函数之前创建,并在函数终止后销毁。C++ 的语法允许我们在其他情况下使用匿名对象。考虑以下代码片段:

int main() {
    // 初始语句
    Print{"hello", "world"}; // 之后的语句
    // 假设存在匹配的构造函数
}

在这个例子中,一个匿名的 Print 对象被构造,然后立即销毁。因此,在“初始语句”之后,我们的 Print 对象被构造,然后它被销毁,接着执行“之后的语句”。

这个例子说明了标准的生命周期规则不适用于匿名对象。它们的生命周期仅限于语句,而不是定义它们的块的末尾。

普通的匿名对象至少在一种情况下是有用的。假设我们想在代码中放置一些标记,以便在程序的执行到达某个点时生成一些输出。可以通过实现一个构造函数来提供这种标记功能,允许我们通过定义匿名对象而不是命名对象来在代码中放置标记。

C++ 的语法还包含另一个显著特性,如下例所示:

int main(int argc, char **argv) {
    // 假设存在匹配的构造函数:
    Print p{ std::cout, "", "" };  // 1
    allArgs(Print{ p }, argc, argv);  // 2
}

在这个例子中,非匿名对象 p 在语句 1 中被构造,然后在语句 2 中被用来初始化一个匿名对象。该匿名对象又被用来初始化 allArgs 函数的常量引用参数。使用现有对象初始化另一个对象是一种常见做法,它基于所谓的复制构造函数的存在。复制构造函数使用现有对象的特性来初始化创建对象的数据(因为它是构造函数)。复制构造函数将在第九章详细讨论,但目前只涉及复制构造函数的概念。

在上述例子中,复制构造函数被用来初始化一个匿名对象。然后该匿名对象被用来初始化函数的参数。然而,当我们尝试将同样的技巧(即使用现有对象初始化一个匿名对象)应用于普通语句时,编译器会生成错误:对象 p 不能被重新定义(如下所示的语句 3):

int main(int argc, char *argv[]) {
    Print p{ "", "" }; // 1
    allArgs(Print(p), argc, argv); // 2
    Print(p); // 3 错误
}

这是否意味着使用现有对象初始化用作函数参数的匿名对象是可以的,而现有对象不能用于在普通语句中初始化匿名对象?

实际上,编译器为我们提供了这个表面矛盾的答案。关于语句 3,编译器会报告类似以下内容:

error: redeclaration of 'Print p'

这就解决了问题,因为在复合语句中可以定义对象和变量。在复合语句内,一个类型名后跟变量名是变量定义的语法形式。括号可以用来打破优先级,但如果没有优先级需要打破,它们就没有效果,编译器会简单地忽略它们。在语句 3 中,括号让我们摆脱了类型名和变量名之间所需的空格,但对编译器来说,我们写的是:

Print (p);

由于括号是多余的,这相当于 Print p;,从而导致 p 的重新声明。

作为进一步的例子:当我们使用内建类型(例如 double)定义变量时,使用多余的括号,编译器会悄悄地为我们去除这些括号:

double ((((a)))); // 奇怪,但可以接受。

总结关于匿名变量的发现:

  • 匿名对象非常适合用于初始化常量引用参数。
  • 然而,相同的语法也可以用于独立语句中,在这种情况下,如果我们的意图实际上是使用现有对象初始化匿名对象,它们会被解释为变量定义。
  • 由于这可能导致混淆,最好将匿名对象的使用限制在第一种(也是主要的)形式:初始化函数参数。

关键字‘inline’

让我们再看看函数 Person::name() 的实现:

std::string const &Person::name() const {
    return d_name;
}

这个函数用于获取类 Person 对象的 name 字段。示例:

void showName(Person const &person) {
    cout << person.name();
}

为了插入 person 的名字,执行了以下操作:

  • 调用 Person::name() 函数。
  • 该函数返回 persond_name 作为引用。
  • 将引用的名字插入到 cout

特别是这些操作中的第一步会导致一些时间损耗,因为为了获取 name 字段的值,需要额外的函数调用。有时希望有一种更快的方式,直接使 d_name 数据成员可用,而无需实际调用 name 函数。这可以通过使用内联函数来实现。内联函数是对编译器的一个请求,要求在函数调用的位置插入函数的代码。这可以通过避免函数调用来加速执行,函数调用通常会带来一些(堆栈处理和参数传递的)开销。需要注意的是,inline 只是对编译器的一个请求:编译器可能会忽略它,并且如果函数体包含太多代码,编译器很可能会忽略它。良好的编程习惯建议对此保持警觉,除非函数体很小(例如,最多只有一条语句,并且该语句极不可能会改变),否则应避免使用 inline。更多内容将在第7.8.2节讨论。

定义成员为内联函数

内联函数可以在类接口中直接实现。对于类 Person,这会导致如下的 name 实现:

class Person {
public:
    std::string const &name() const {
        return d_name;
    }
};

注意,现在函数 name 的内联代码确实出现在了类 Person 的接口中。关键字 const 再次添加到了函数的声明中。

尽管可以在类中(即在类接口内部)定义成员,但由于以下原因,这被认为是不好的做法:

  • 在接口内部定义成员会将实现与接口混在一起。接口的目的是记录类提供的功能。混合成员声明和实现细节会使理解接口变得复杂。读者需要跳过实现细节,这不仅费时,还使得难以掌握“整体情况”,从而难以一目了然地理解类的对象提供了哪些功能。
  • 私有成员函数的类内实现通常可以完全避免(因为它们是私有成员)。它们应该移到内部头文件中(除非内联的公共成员使用了这样的内联私有成员)。
  • 虽然适合内联编码的成员应保持内联,但确实存在这种内联成员从内联定义迁移到非内联定义的情况。类内的内联定义仍需编辑(有时需要大量编辑)才能编译。这种额外的编辑是不受欢迎的。

基于上述考虑,内联成员不应在类内定义。相反,它们应在类接口之后定义。因此,Person::name 成员最好定义如下:

class Person {
public:
    std::string const &name() const;
};

inline std::string const &Person::name() const {
    return d_name;
}

如果需要取消 Person::name 的内联实现,则其非内联实现如下:

#include "person.ih"

std::string const &Person::name() const {
    return d_name;
}

只需删除 inline 关键字,即可获得正确的非内联实现。

定义成员为内联函数有以下影响:每当调用内联定义的函数时,编译器可能会在函数调用的位置插入函数体代码。也可能从未实际调用过该函数。

这种结构,在函数代码本身被插入而不是调用函数时,称为内联函数。需要注意的是,使用内联函数可能会导致程序中多次出现这些函数的代码:每次调用内联函数都会生成一份副本。如果函数很小并且需要快速执行,这可能是可以接受的。但如果函数的代码很庞大,这就不太理想了。编译器对此也很清楚,将内联函数的使用视为一个请求,而非命令。如果编译器认为函数太长,它不会接受这个请求,而是将函数视为普通函数。

需要注意的是,constexpr 函数(参见第8.1.4节)隐式地被定义为内联函数。

引言:何时使用内联函数

作为第14章(多态性)的引言,有一种情况下应绝对避免使用内联函数。在本节中全面解释这些细节还为时过早,但由于 inline 关键字是本节的主题,所以在此给出相关建议是合适的。

有些情况下,编译器会遇到所谓的模糊链接(vague linkage)(参见 GCC文档)。这些情况发生在编译器不清楚应将编译代码放入哪个目标文件中。例如,对于通常在多个源文件中遇到的内联函数,这种情况就会发生。由于编译器可以在调用这些普通内联函数的地方插入代码,因此对于普通函数来说,模糊链接通常不是问题。

然而,正如第14章所解释的那样,在使用多态性时,编译器必须忽略 inline 关键字,并将所谓的虚函数定义为真正的(外部定义)函数。在这种情况下,模糊链接可能会导致问题,因为编译器必须决定将它们的代码放入哪个目标文件中。通常,只要函数至少被调用一次,这不是一个大问题。但虚函数的特殊之处在于,它们可能根本不会被显式调用。在某些架构(如armel)上,编译器可能无法编译这种内联虚函数,这可能导致使用它们的程序中缺少符号。使问题更为复杂的是:当使用共享库时,问题可能会出现,而使用静态库时则不会。

为避免所有这些问题,虚函数绝不应定义为内联函数,而应始终定义为外部函数。也就是说,它们应在源文件中定义。

内联变量

除了内联函数,还可以在多个翻译单元中定义(并相同地初始化)内联变量。例如,一个头文件可以包含以下内容:

inline int value = 15;  // 合法
class Demo {
    // static int s_value = 15;  // 非法
    static int constexpr s_value = 15;  // 合法
    static int s_inline;
    // 合法:参见下文,内联定义
    // 紧随类声明
};
inline int Demo::s_inline = 20;  // 合法

局部类:函数内部的类

类通常在全局或命名空间级别定义。然而,也完全可以在函数内部定义局部类,即在函数内部定义的类。这种类称为局部类。

局部类在涉及继承或模板的高级应用中非常有用(参见第13.8节)。在C++注释中,它们的使用较为有限,但其主要特性可以描述。以下是相关内容:

  • 局部类可以使用几乎所有正常类的特性。它们可以有构造函数、析构函数、数据成员和成员函数。
  • 局部类不能定义静态数据成员。然而,静态成员函数可以定义。
  • 由于局部类可以定义静态成员函数,因此在C++中可以在一定程度上定义嵌套函数,这类似于编程语言如Pascal允许定义嵌套函数的方式。
  • 如果局部类需要访问一个常量整数值,可以使用局部枚举。该枚举可以是匿名的,只暴露枚举值。
  • 局部类不能直接访问其所在上下文的非静态变量。例如,在下面的示例中,局部类 Local 不能直接访问 mainargc 参数。
  • 局部类可以直接访问其所在函数定义的全局数据和静态变量。这包括在包含局部类的源文件的匿名命名空间中定义的变量。
  • 局部类对象可以在函数体内定义,但不能作为其自身类型的对象离开函数。即,局部类名称不能用作其所在函数的返回类型或参数类型。
  • 作为继承(第13章)的引言:局部类可以从现有类派生,使得所在函数可以通过基类指针或引用返回动态分配的局部构造类对象、指针或引用。
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char **argv) {
    static size_t staticValue = 0;  // 静态局部变量
    // 定义局部类
    class Local {
        int d_argc;  // 非静态数据成员
    public:
        enum {
            VALUE = 5  // 局部枚举
        };
        Local(int argc)     // 构造函数
            : d_argc(argc)  // 初始化列表
        {
            // 访问全局数据
            cout << "Local constructor\n";
            // 访问静态函数变量
            staticValue += 5;
        }
        static void hello()  // 静态成员函数
        {
            cout << "hello world\n";
        }
    };
    Local loc{argc};
    // 调用 Local 类的静态成员函数
    Local::hello();
    // 定义 Local 类的对象
}

关键字 mutable

在第7.7节中,我们介绍了 const 成员函数和 const 对象的概念。C++ 还允许声明可以被修改的数据成员,即使是在 const 成员函数中也可以修改。这类数据成员的声明以关键字 mutable 开始。

mutable 应用于那些可以被修改而不会逻辑上改变对象的数据成员,因此对象仍然可以被视为常量对象。

一个适当地使用 mutable 的例子是字符串类的实现。考虑 std::stringc_strdata 成员。这两个成员返回的数据实际上是相同的,但 c_str 必须确保返回的字符串以 0 字节终止。由于字符串对象既有长度又有容量,确保字符串的容量至少比长度多一个字符是一种简单的实现 c_str 的方法。这个不变量允许 c_str 如下实现:

char const *string::c_str() const
{
    d_data[d_length] = 0;
    return d_data;
}

这个实现从逻辑上并不修改对象的数据,因为超出对象初始(长度)字符的字节具有未定义的值。但是,为了使用这种实现,d_data 必须声明为 mutable

mutable char *d_data;

关键字 mutable 在实现例如引用计数的类中也非常有用。考虑一个实现字符串引用计数的类。执行引用计数的对象可能是 const 对象,但类可能定义了一个拷贝构造函数。由于 const 对象不能被修改,拷贝构造函数如何能增加引用计数呢?在这里,mutable 关键字可以有效地解决这个问题,因为它可以被递增和递减,即使对象是 const 对象。

mutable 关键字应当谨慎使用。由 const 成员函数修改的数据不应逻辑上修改对象,并且应当容易证明这一点。作为一个经验法则:除非有非常明确的理由(对象在逻辑上未被改变)违反这一规则,否则不要使用 mutable

头文件组织

在第2.5.10节中,我们讨论了当C++程序也使用C函数时对头文件的要求。包含类接口的头文件还有额外的要求。

首先,源文件的处理。除了偶尔没有类的函数外,源文件通常包含类成员函数的代码。基本上有两种方法:

  1. 每个源文件中包含所有必要的头文件:这种方法的优点是编译器只需要读取特定源文件所需的头文件,这样节省了编译时间。缺点是程序开发者需要在源文件中重复包含多个头文件,这不仅耗时,而且需要考虑每个源文件所需的头文件。

  2. 所有必要的头文件(针对类的所有成员函数)包含在一个头文件中,并在每个定义了类成员的源文件中包含该头文件:这种方法的优点是对程序开发者更为经济:类的头文件汇集了所有需要的头文件,使得头文件更为通用。然而,缺点是编译器需要处理许多实际上未被使用的头文件,从而增加了编译时间。

随着计算机速度的不断提高(以及编译器变得越来越智能),第二种方法相较于第一种方法更受青睐。因此,作为起点,可以按照以下示例组织特定类 MyClass 的源文件:

#include <myclass.h>
int MyClass::aMemberFunction()
{}

这里只有一个包含指令。请注意,该指令引用了一个在 INCLUDE 环境变量中提到的目录中的头文件。也可以使用本地头文件(如 #include "myclass.h"),但这可能会使类头文件的组织变得更为复杂。

头文件本身的组织需要特别注意。考虑以下示例,其中使用了两个类 FileString

假设 File 类有一个成员函数 gets(String &destination),而 String 类有一个成员函数 getLine(File &file)。则 String 类的部分头文件如下:

#ifndef STRING_H_
#define STRING_H_
#include <project/file.h> 
class String
{
public:
    void getLine(File &file);
};
#endif

不幸的是,File 类也需要类似的设置:

#ifndef FILE_H_
#define FILE_H_
#include <project/string.h> 
class File
{
public:
    void gets(String &string);
};
#endif

这就造成了一个问题。编译器在编译 File::gets 的源文件时,步骤如下:

  1. 打开并读取头文件 project/file.h
  2. 定义 FILE_H_
  3. 打开并读取头文件 project/string.h
  4. 定义 STRING_H_
  5. 再次打开并读取头文件 project/file.h
  6. 发现 FILE_H_ 已经被定义,所以跳过 project/file.h 的其余部分;
  7. 解析 String 类接口;
  8. 在类接口中遇到 File 对象的引用;
  9. 由于 File 类尚未解析,File 仍然是未定义的类型,编译器因此报错。

解决这个问题的方法是,在类接口之前使用前向声明,并在类接口之后包含相应的类头文件。这样得到的代码是:

#ifndef STRING_H_
#define STRING_H_
class File; // 前向声明
class String
{
public:
    void getLine(File &file);
};
#include <project/file.h> // 了解 File 类
#endif

对于 File 类,也需要类似的设置:

#ifndef FILE_H_
#define FILE_H_
#include <project/string.h> 
class String; // 前向声明
class File
{
public:
    void gets(String &string);
};

#endif
#ifndef FILE_H_
#define FILE_H_
#include <project/string.h> 

class File
{
    String d_line; // 组合!
public:
    void gets(String &string);
};
#endif

String 类不能将 File 对象作为组合成员声明:这种情况会导致在编译这些类的源文件时,File 类仍未定义。

所有剩余的头文件(出现在类接口本身之后)仅仅因为它们在类的源文件中被使用。

这种方法允许我们引入另一个细化的做法:

  • 定义类接口的头文件应该在定义类接口本身之前声明可以声明的内容。因此,在类接口中提到的类应该使用前向声明,除非:
    • 它们是当前类的基类(见第13章);
    • 它们是组合数据成员的类类型;
    • 它们在内联成员函数中被使用。

特别地:对于以下情况,不需要额外的实际头文件:

  • 类类型的返回值;
  • 类类型的值参数。

组成或继承的类头文件,或在内联函数中使用的类,必须在当前类的接口开始之前被编译器知晓。头文件本身的信息由第2.5.10节引入的 #ifndef ... #endif 结构保护。

  • 程序源文件中使用类的地方只需要包含这个头文件。Lakos(2001)进一步细化了这一过程。有关更多细节,请参阅他的《Large-Scale C++ Software Design》一书。这些头文件应该放在一个知名的位置,例如标准 INCLUDE 路径的目录或子目录中。

  • 为了实现成员函数,需要类的头文件以及通常的附加头文件(如字符串头文件)。类的头文件及这些附加头文件应包括在一个单独的内部头文件中(建议使用 .ih 扩展名,即“内部头文件”)。

.ih 文件应该定义在与类源文件相同的目录中。它具有以下特点:

  • 不需要保护性 #ifndef .. #endif 保护,因为这个头文件不会被其他头文件包含。
  • 包含定义类接口的标准 .h 头文件。
  • 包含在标准 .h 头文件中作为前向引用的所有类的头文件。
  • 最后,包含类的源文件中需要的所有其他头文件。

以下是这种头文件组织的示例:

  • 第一部分,例如 /usr/local/include/myheaders/file.h

    #ifndef FILE_H_
    #define FILE_H_
    #include <fstream> // 用于组合 'ifstream'
    
    class Buffer; // 前向声明
    
    class File // 类接口
    {
        std::ifstream d_instream;
    public:
        void gets(Buffer &buffer);
    };
    #endif
    
  • 第二部分,例如 ~/myproject/file/file.ih,其中存储了 File 类的所有源文件:

    #include <myheaders/file.h> // 使 File 类已知
    #include <buffer.h> // 使 Buffer 类已知给 File
    #include <string> // 被类成员使用
    #include <sys/stat.h> // File 使用
    

在头文件中使用命名空间

当在头文件中使用命名空间中的实体时,如果这些头文件用于声明库中的类或其他实体,则不应在这些头文件中指定 using 指令。当头文件中使用 using 指令时,包含该头文件的用户被迫接受并使用所有在该头文件中声明的命名空间中的内容。

例如,如果在命名空间 special 中声明了一个对象 Inserter cout,那么 special::cout 当然与 std::cout 是不同的对象。现在,如果构造一个类 Flaw,其构造函数期望一个对 special::Inserter 的引用,那么这个类应该如下构造:

class special::Inserter;
class Flaw
{
public:
    Flaw(special::Inserter &ins);
};

假设设计类 Flaw 的人可能会觉得烦躁,不想在每次使用 special:: 前缀时都感到厌烦,因此可能会使用如下构造:

using namespace special;
class Inserter;
class Flaw
{
public:
    Flaw(Inserter &ins);
};

这种做法在一定程度上是有效的,但当有人希望在其他源文件中包含 flaw.h 时,会出现问题:由于 using 指令的存在,使用这个头文件的人也被隐式地引入了 special 命名空间,这可能会导致意外的效果:

#include <flaw.h>
#include <iostream>
using std::cout;

int main()
{
    cout << "starting\n"; // 编译失败
}

编译器遇到了两个对 cout 的解释:首先,由于在 flaw.h 头文件中使用了 using 指令,编译器将 cout 视为 special::Inserter,然后由于用户程序中的 using 指令,编译器将 cout 视为 std::ostream。因此,编译器报告了一个错误。

作为一个经验法则,面向一般使用的头文件不应包含 using 声明。这条规则不适用于只被类的源文件包含的头文件:在这种情况下,程序员可以自由地使用任意多的 using 声明,因为这些指令不会影响到其他源文件。

模块

自从C语言引入头文件以来,头文件一直是声明未定义但在源文件中使用的元素的主要工具。例如,在 main 函数中使用 printf 时,需要指定预处理指令 #include <stdio.h>

这种方法在C++中依然有效,但逐渐证明其效率低下。一个原因是,头文件必须为每个源文件处理一次,每个源文件都包括该头文件。这个问题在使用类时变得尤为明显,因为编译器必须对每个使用该类的源文件重复处理类的头文件。通常,这不仅仅是一个头文件,头文件往往会包含其他头文件,导致编译器必须一次又一次地处理大量的头文件。如果一个典型的源文件包括 h 个头文件,而 s 个源文件需要编译,那么这会导致显著的编译负担,因为编译器必须处理 s * h 个头文件。

预编译头文件提供了减少这种过度工作负担的初步尝试。然而,预编译头文件也有自己的问题:它们通常非常庞大(一个不到100字节的头文件可能会导致一个25MB或更大的预编译头文件),并且有些脆弱:如果仅仅是添加了注释,重新编译头文件可能会导致大量的开销。

传统头文件中常见的防御机制是使用包含保护符,确保一个头文件如果被多个其他头文件包含时只会被处理一次。这种包含保护符是宏,在第7.11节中有详细讨论。包含保护符有效,但完全依赖于保护标识符的唯一性,通常这些标识符是长名称,使用多个下划线的大写字母来增加其唯一性的可能性。

C++标准通过引入模块提供了上述问题的解决方案。在撰写本文时,Gnu g++ 编译器(仍然)遇到模块相关的问题。一旦这些问题得到解决,关于模块的独立章节将会被添加到 C++ 注释中。

应用 sizeof 运算符于类的数据成员

在C++中,广泛使用的 sizeof 运算符可以应用于类的数据成员,而无需指定对象。例如:

class Data
{
    std::string d_name;
    ...
};

要获取 Data 类中 d_name 成员的大小,可以使用以下表达式:

sizeof(Data::d_name);

需要注意的是,编译器在这里也会观察数据保护。只有当 d_name 可见时,即它可以被 Data 类的成员函数和友元使用时,才能使用 sizeof(Data::d_name)

静态数据和函数

在前面的章节中,我们介绍了每个对象都有自己的一组数据成员的类示例。类的每个成员函数都可以访问其类的任何对象的成员。

在某些情况下,可能希望定义所有对象都可以访问的公共数据字段。例如,程序扫描磁盘目录树时使用的启动目录名。另一个例子是一个指示是否已发生特定初始化的变量。在这种情况下,第一个构造的对象会执行初始化并将标志设置为“完成”。

在C中也会遇到类似的情况,即多个函数需要访问同一个变量。C语言中的常见解决方案是将所有这些函数定义在一个源文件中,并将变量定义为静态(static):变量名称在源文件的作用域之外不可见。这种方法是有效的,但违反了每个源文件只包含一个函数的原则。另一个C语言解决方案是给变量一个不常见的名字,例如 _6uldv8,以希望其他程序部分不会意外使用这个名字。这两种传统的C解决方案都不够优雅。

C++通过定义静态成员来解决这个问题:静态成员是类的所有对象共享的数据和函数(当在私有部分定义时,不可在类外访问)。这些静态成员是本章的主题。

静态成员不能定义为虚函数。虚成员函数是普通成员,因为它有一个 this 指针。由于静态成员函数没有 this 指针,因此不能声明为虚函数。

静态数据

任何类的数据成员都可以被声明为静态,无论是在类接口的公共部分还是私有部分。这样的数据成员只会被创建和初始化一次,这与非静态数据成员不同,后者会为每个类的对象重复创建。

静态数据成员在程序开始时创建。尽管它们是在程序执行周期的最初阶段创建的,但它们仍然是真正的类成员。

建议给静态成员的名称加上前缀 s_,以便在类的成员函数中容易与类的数据成员(最好以 d_ 开头)区分开来。公共静态数据成员是全局变量。它们可以被程序中的所有代码访问,只需使用类名、作用域解析运算符和成员名。例如:

class Test
{
    static int s_private_int;
public:
    static int s_public_int;
};

int main()
{
    Test::s_public_int = 145; // 正确
    Test::s_private_int = 12; // 错误,不要触碰
}

这个例子并不展示一个可执行程序。它只是说明了静态数据成员的接口,而静态数据成员的实现将在下文中讨论。

私有静态数据

为了说明类中私有静态数据成员的使用,请考虑以下示例:

class Directory
{
    static char s_path[];
public:
    // 构造函数、析构函数等
};

数据成员 s_path[] 是一个私有的静态数据成员。在程序执行期间,无论创建了多少个 Directory 类的对象,只有一个 Directory::s_path[] 实例存在。这个数据成员可以被类的构造函数、析构函数或其他成员函数访问或修改。

由于构造函数是为每个新对象调用的,静态数据成员不会由构造函数初始化。最多,它们会被修改。原因是静态数据成员在任何构造函数被调用之前就已经存在。静态数据成员在定义时进行初始化,就像普通(非类)全局变量的初始化一样。

静态数据成员的定义和初始化通常发生在类函数的源文件中,最好是在一个专门用于定义静态数据成员的源文件中,称为 data.cc

例如,上述的 s_path[] 数据成员可以在 data.cc 文件中定义和初始化如下:

#include "directory.ih"
char Directory::s_path[200] = "/usr/local";

在类接口中,静态成员实际上只是被声明。在实现(定义)中,类型和类名被明确提及。请注意,接口中的大小规格可以省略,如上所示。然而,在定义时必须(显式或隐式地)指定其大小。

任何源文件都可以包含类静态数据成员的定义。虽然建议使用单独的 data.cc 源文件,但包含 main() 的源文件也可以使用。当然,任何定义类静态数据的源文件也必须包含该类的头文件,以便编译器能够识别静态数据成员。

另一个有用的私有静态数据成员的示例如下。假设一个 Graphics 类定义了与图形设备(例如 VGA 屏幕)的通信。设备的初始化,即从文本模式切换到图形模式,是构造函数的一个动作,依赖于静态标志变量 s_nobjects。变量 s_nobjects 只是计数当前存在的 Graphics 对象的数量。同样,当最后一个 Graphics 对象销毁时,类的析构函数可以将模式切换回文本模式。Graphics 类的接口可能如下所示:

class Graphics
{
    static int s_nobjects; // 计数对象数量

public:
    Graphics();
    ~Graphics(); // 其他成员未显示

private:
    void setgraphicsmode(); // 切换到图形模式
    void settextmode(); // 切换到文本模式
};
int Graphics::s_nobjects = 0; // 静态数据成员的定义和初始化

Graphics::Graphics()
{
    if (!s_nobjects++)
        setgraphicsmode(); // 如果这是第一个 Graphics 对象,则初始化图形模式
}

Graphics::~Graphics()
{
    if (!--s_nobjects)
        settextmode(); // 如果这是最后一个 Graphics 对象,则恢复文本模式
}

显然,当 Graphics 类定义了多个构造函数时,每个构造函数都需要增加 s_nobjects 变量,并可能需要初始化图形模式。

公有静态数据

数据成员也可以在类的公有部分声明。然而,这种做法是不推荐的(因为它违反了数据隐藏的原则)。
例如,静态数据成员 s_path[] 可以在类定义的公有部分声明,这将允许程序的所有代码直接访问这个变量:

int main() {
    getcwd(Directory::s_path, 199); // 直接访问静态数据成员
}

声明并不等于定义。因此,变量 s_path 仍然需要被定义。这意味着某个源文件仍然需要包含 s_path[] 数组的定义。

初始化静态常量数据成员

静态常量数据成员应该像其他静态数据成员一样,在源文件中进行初始化。这些数据成员通常是整数或内置的原始数据类型,编译器通常接受这种内联初始化。然而,编译器是否接受这种初始化并没有正式的规则。编译是否成功可能取决于编译器所使用的优化选项(例如,使用 -O2 可能会成功编译,而 -O0(无优化)可能会导致编译失败,尤其是在使用共享库时)。

尽管如此,对于整数常量值(例如 charintlong 等,可能还有 unsigned 类型),仍然可以使用(例如,匿名)枚举来进行内联初始化。以下示例说明了如何做到这一点:

class X
{
public:
    enum { s_x = 34 };
    enum: size_t { s_maxWidth = 100 };
};

为了避免由于不同编译器选项引起的混淆,静态数据成员应始终在单一的源文件中显式定义和初始化,无论它们是否是常量数据。通过在源文件中定义它们,可以避免内联不一致的问题。

通用常量表达式(constexpr

在 C 语言中,宏常常用于让预处理器执行简单的计算。这些宏函数可以有参数,如下例所示:

#define xabs(x) ((x) < 0 ? -(x) : (x))

宏的缺点是众所周知的。避免使用宏的主要原因是它们不是由编译器解析的,而是由预处理器处理,结果仅仅是文本替换,从而避免了对宏定义本身的类型安全性或语法检查。此外,

由于宏由预处理器处理,它们的使用是不受限制的,不考虑它们应用的上下文。NULL 是一个臭名昭著的例子。试过定义一个枚举符号 NULL 吗?或者 EOF?很可能,如果你这样做了,编译器会给出奇怪的错误信息。

通用常量表达式可以作为替代方案。

通用常量表达式通过修饰符 constexpr(一个关键字)来识别,该修饰符应用于表达式的类型。

const 修饰符和 constexpr 修饰符之间有一个小的语法差异。虽然 const 修饰符可以应用于定义和声明,但 constexpr 修饰符只能应用于定义:

extern int const externInt;  // OK: const int 的声明
extern int constexpr error;  // ERROR: 不是定义

通用常量表达式(constexpr

使用 constexpr 修饰符定义的变量具有常量(不可变)值。但通用常量表达式不仅用于定义常量变量,还有其他应用。constexpr 关键字通常用于函数,使函数变成常量表达式函数。

常量表达式函数不应与返回常量值的函数混淆(尽管常量表达式函数确实返回一个(常量)值)。常量表达式函数具有以下特征:

  • 它返回一个值;
  • 它的返回类型使用 constexpr 修饰符;

这些函数也被称为具有参数的命名常量表达式函数。

这些常量表达式函数可以用编译时计算的参数调用,也可以用运行时计算的参数调用(需要注意,const 参数值并不会在编译时计算)。如果它们使用编译时计算的参数调用,则返回的值也被视为常量值。

这允许我们将可以在编译时计算的表达式封装在函数中,并允许我们在之前需要直接使用表达式的地方使用这些函数。封装减少了表达式出现的次数,简化了维护,并减少了错误的概率。

如果将不能在编译时计算的参数传递给常量表达式函数,这些函数的行为就像其他函数一样,其返回值不再被视为常量表达式。

假设需要将二维数组转换为一维数组。该一维数组必须具有 nrows * ncols + nrows + ncols + 1 个元素,用于存储行、列和总边际值,以及源数组本身的元素。此外,假设 nrowsncols 已被定义为全局可用的 size_t 常量值(它们可以是类的静态数据)。一维数组是类或结构体的数据成员,或者也可以定义为全局数组。

现在有了常量表达式函数,可以将返回所需元素数量的表达式封装在这样的函数中:

size_t const nRows = 45; 
size_t const nCols = 10;

size_t constexpr nElements(size_t rows, size_t cols)
{
    return rows * cols + rows + cols + 1;
}

int intLinear[nElements(nRows, nCols)];

struct Linear
{
    double d_linear[nElements(nRows, nCols)];
};

如果程序的其他部分需要使用不同大小的线性数组,那么常量表达式函数也可以被使用。例如:

string stringLinear[nElements(10, 4)];

常量表达式函数还可以在其他常量表达式函数中使用。以下常量表达式函数返回 nElements 返回值的一半,向上取整:

size_t constexpr halfNElements(size_t rows, size_t cols)
{
    return (nElements(rows, cols) + 1) >> 1;
}

类不应暴露其数据成员给外部软件,以减少类与外部软件之间的耦合。但如果一个类定义了一个静态 const size_t 数据成员,那么该成员的值可以用来定义在类的范围之外存在的实体,如数组的元素数量或某些枚举的值。在这种情况下,常量表达式函数是维护适当数据隐藏的完美工具:

class Data
{
    static size_t const s_size = 7;
public:
    static size_t constexpr size();
    size_t constexpr mSize();
};

size_t constexpr Data::size()
{
    return s_size;
}

size_t constexpr Data::mSize()
{
    return size();
}

double data[Data::size()]; // OK: 7 个元素
short data2[Data().mSize()]; // 也 OK: 见下文

需要注意以下几点:

  • 常量表达式函数隐式声明为内联函数。例如:文件 sum.h 声明了一个 constexpr 函数:int constexpr sum(int x, int y);。它被 main 使用:
#include <iostream>
#include "sum.h"

int main()
{
    std::cout << "sum: " << sum(21, 21) << '\n';
}

当编译这个文件(main.cc)时,编译器会报告:

sum.h:1:15: warning: inline function ‘constexpr int sum(int, int)’ used but never defined.

这里编译器明确指出 sum 是一个内联函数。此外,即使 sum.cc 定义了 sum,并且 main.osum.o 被链接,链接器也会报告:

undefined reference to ‘sum(int, int)’.

因此,constexpr 函数的定义必须在编译时可用,这在实际中意味着它们被定义在(类)头文件中;

  • 非静态常量表达式成员函数隐式为 const,并且它们的 const 成员修饰符是可选的;
  • 常量值(例如,静态常量数据成员)由常量表达式函数使用时,必须在编译器遇到函数定义时已知。这就是为什么 s_sizeData 的类接口中被初始化。一些最终说明:constexpr 函数可以:
    • 定义任何类型的变量,除了静态或线程局部变量;
    • 定义没有初始化的变量;
    • 使用条件语句(ifswitch);
    • 使用重复语句,包括范围基的 for 语句;
    • 使用改变 constexpr 函数局部对象值的表达式;
    • constexpr 成员函数可以是非 const。但非 constconstexpr 成员函数只能修改在调用该非 const constexpr 成员函数的 constexpr 函数中定义的局部数据成员。

常量表达式数据

如我们所见,原始数据类型的(成员)函数和变量可以使用 constexpr 修饰符定义。那么,类类型对象呢?

类的对象是类类型的值,像原始类型的值一样,它们也可以使用 constexpr 修饰符定义。常量表达式类类型对象必须用常量表达式参数进行初始化;实际使用的构造函数必须本身被声明为 constexpr。需要注意的是,constexpr 构造函数的定义必须在 constexpr 对象被构造之前被编译器看到:

class ConstExpr
{
public:
    constexpr ConstExpr(int x);
};

ConstExpr ok{ 7 }; 
// OK: 不是声明为 constexpr
constexpr ConstExpr err{ 7 }; 
// ERROR: 构造函数的定义
// 尚未被看到

constexpr ConstExpr::ConstExpr(int x)
{}
constexpr ConstExpr ok{ 7 }; 
// OK: 定义已被看到
constexpr ConstExpr okToo = ConstExpr{ 7 }; // 也 OK

一个常量表达式构造函数具有以下特征:

  • 它使用 constexpr 修饰符声明;
  • 它的成员初始化器只使用常量表达式;
  • 它的主体为空。

使用常量表达式构造函数构造的对象称为用户定义字面量(user-defined literal)。用户定义字面量的析构函数和拷贝构造函数必须是平凡的(trivial)。

用户定义字面量的 constexpr 特性可能会被其类的成员维护,也可能不会。如果成员没有声明为 constexpr 返回值,那么使用该成员不会导致常量表达式。如果成员声明了 constexpr 返回值,则该成员的返回值被视为 constexpr,前提是它本身是一个常量表达式函数。

为了保持其 constexpr 特性,它只能引用其类的数据成员,如果其对象已经使用 constexpr 修饰符定义,如以下示例所示:

class Data
{
    int d_x;
public:
    constexpr Data(int x) : d_x(x) {}
    int constexpr cMember()
    {
        return d_x;
    }

    int member() const
    {
        return d_x;
    }
};

Data d1{ 0 }; 
// OK,但不是常量表达式

enum e1 {
    ERR = d1.cMember()
    // ERROR: cMember():不再是常量表达式
};

constexpr Data d2{ 0 }; 
// OK,常量表达式

enum e2 {
    OK = d2.cMember(),
    // OK: cMember():现在是常量表达式
    ERR = d2.member(),
    // ERR: member():不是常量表达式
};

静态成员函数

除了静态数据成员外,C++ 还允许我们定义静态成员函数。类似于所有对象共享的静态数据,静态成员函数也可以在没有任何关联对象的情况下存在。

静态成员函数可以访问其类的所有静态成员,但也可以访问其类对象的成员(无论是私有的还是公共的),前提是它们了解这些对象的存在(如即将示例所示)。由于静态成员函数不与任何对象关联,因此它们没有 this 指针。实际上,静态成员函数完全可以与全局函数相比较,它们不与任何类相关联(即,在实践中,它们是这样的。请参见下一节(8.2.1)中的微妙说明)。由于静态成员函数不需要关联对象,声明在类接口的公共部分的静态成员函数可以在不指定其类对象的情况下被调用。以下示例说明了静态成员函数的这一特性:

class Directory
{
    string d_currentPath;
    static char s_path[];
public:
    static void setpath(char const *newpath);
    static void preset(Directory &dir, char const *newpath);
};

inline void Directory::preset(Directory &dir, char const *newpath)
{
    dir.d_currentPath = newpath;  // 1
}

char Directory::s_path[200] = "/usr/local";  // 2

void Directory::setpath(char const *newpath) {
    if (strlen(newpath) >= 200) throw "newpath too long";
    strcpy(s_path, newpath);  // 3
}

int main() {
    Directory dir;
    Directory::setpath("/etc"); // 4
    dir.setpath("/etc"); // 5
    Directory::preset(dir, "/usr/local/bin"); // 6
    dir.preset(dir, "/usr/local/bin"); // 7
}
  • 在 1 处,静态成员函数修改了对象的私有数据成员。然而,需要修改成员的对象作为引用参数传递给成员函数。请注意,静态成员函数可以定义为内联函数。
  • 在 2 处,定义了一个相对较长的数组以容纳长路径。替代方案可以使用 string 或指向动态内存的指针。
  • 在 3 处,将(可能较长,但不过长的)新路径名存储在静态数据成员 s_path[] 中。注意这里只使用了静态成员。
  • 在 4 处,调用了 setpath()。这是一个静态成员函数,因此不需要对象。但是,编译器必须知道该函数属于哪个类,因此使用作用域解析运算符提到类。
  • 在 5 处,使用了与 4 处相同的实现方式。这里使用 dir 来告诉编译器我们在谈论 Directory 类中的一个函数。静态成员函数可以像普通成员函数一样被调用,但这并不意味着静态成员函数会收到对象的地址作为 this 指针。在这里,成员调用语法作为 类名 加作用域解析运算符语法的替代方案使用。
  • 在 6 处,修改了 currentPath。与 4 处一样,使用类和作用域解析运算符。
  • 在 7 处,使用了与 6 处相同的实现方式。但这里使用 dir 来告诉编译器我们在谈论 Directory 类中的一个函数。特别需要注意的是,这里没有将 preset() 作为 dir 的普通成员函数使用:该函数仍然没有 this 指针,因此必须将 dir 作为参数传递,以便静态成员函数 preset 知道它应该修改哪个对象的 currentPath 成员。

在示例中只使用了公共静态成员函数。C++ 还允许定义私有静态成员函数。这些函数只能由它们类的成员函数调用。

调用约定

如前一节所述,静态(公共)成员函数类似于无类函数。然而,正式来说,这一说法并不完全准确,因为 C++ 标准并没有规定静态成员函数和无类全局函数相同的调用约定。

在实践中,这些调用约定是相同的,这意味着静态成员函数的地址可以用作接受指向(全局)函数的指针作为参数的函数的参数。如果必须避免任何不愉快的意外,建议创建全局的无类包装函数,包装那些必须作为回调函数传递给其他函数的静态成员函数。

认识到传统上在 C 中使用回调函数的情况在 C++ 中使用模板算法来解决(参见第19章),假设我们有一个 Person 类,它包含表示人的姓名、地址、电话和体重的数据成员。此外,假设我们要按指针所指向的 Person 对象进行排序。为了简化起见,假设存在以下公共静态成员:

int Person::compare(Person const *const *p1, Person const *const *p2);

该成员的一个有用特性是,它可以直接检查传递给成员函数的两个 Person 对象所需的数据成员(通过指向指针的指针,即双指针)。

大多数编译器允许我们将该函数的地址作为标准 C qsort() 函数的比较函数地址。例如:

qsort(
    personArray, nPersons, sizeof(Person *),
    reinterpret_cast<int(*)(void const *, void const *)>(Person::compare)
);

然而,如果编译器对静态成员和无类函数使用不同的调用约定,这可能不起作用。在这种情况下,可以使用类似以下的无类包装函数来解决问题:

int compareWrapper(void const *p1, void const *p2)
{
    return Person::compare(
        static_cast<Person const *const *>(p1),
        static_cast<Person const *const *>(p2)
    );
}

结果是调用 qsort() 函数如下:

qsort(personArray, nPersons, sizeof(Person *), compareWrapper);

注意:

  • 包装函数处理静态成员函数和无类函数之间的调用约定不匹配问题;
  • 包装函数处理所需的类型转换;
  • 包装函数可能执行一些额外的服务(如如果静态成员函数期望的是对 Person 对象的引用而不是双指针,可能需要解引用指针);
  • 作为附带说明:在 C++ 程序中,像 qsort() 这样的函数很少使用,因为优先使用现有的泛型模板算法(参见第19章)。

类与内存分配

与 C 语言中处理内存分配的一组函数(如 malloc 等)相比,C++ 中的内存分配由 newdelete 操作符处理。mallocnew 之间的重要区别包括:

  • 类型安全malloc 函数并不知道分配的内存将用于什么。例如,当分配整型内存时,程序员必须提供正确的表达式,通过 sizeof(int) 进行乘法运算。与此不同,new 操作符需要指定一个类型,sizeof 表达式由编译器隐式处理。因此,使用 new 更加类型安全。

  • 初始化malloc 分配的内存不进行初始化;calloc 用于初始化分配的内存块,将字符初始化为可配置的初始值。然而,这在处理对象时并不十分有用。由于 new 操作符了解分配实体的类型,它可能会调用分配类类型对象的构造函数。这个构造函数也可以接受参数。

  • 检查返回值:所有 C 语言的内存分配函数(如 malloc)都需要检查是否返回了 NULL。而使用 new 时不再需要检查。实际上,当 new 遇到内存分配失败时,其行为可以通过使用 new_handler 进行配置(参见第9.2.2节)。

freedelete 之间存在类似的关系:delete 确保在对象被释放时,会自动调用其析构函数。

对象创建和销毁时自动调用构造函数和析构函数的特性会带来一些影响,这些问题将在本章中讨论。在 C 语言程序开发过程中,许多问题都源于内存分配不当或内存泄漏:内存未分配、未释放、未初始化、边界被覆盖等。C++ 并不会“神奇地”解决这些问题,但它提供了一些工具来预防这些问题。

由于 malloc 和相关函数逐渐被弃用,许多基于 malloc 的常用字符串函数(如 strdup)应该在 C++ 程序中避免使用。相反,应该使用 string 类的功能和 newdelete 操作符。

内存分配过程会影响类的设计,特别是那些动态分配内存的类。因此,本章不仅讨论了 newdelete 操作符的特殊性,还讨论了:

  • 析构函数:在对象不再存在时被调用的成员函数;
  • 赋值运算符:允许我们将一个对象赋值给同类的另一个对象。
  • this 指针:允许显式引用调用成员函数的对象。
  • 拷贝构造函数:用于创建对象副本的构造函数。
  • 移动构造函数:用于从匿名临时对象创建对象的构造函数。

操作符 newdelete

C++ 定义了两个操作符用于分配内存和将其归还给“公共池”。这两个操作符分别是 newdelete。以下是它们的简单用法示例:一个 int 指针变量指向由操作符 new 分配的内存,这块内存随后由操作符 delete 释放。

int *ip = new int;
delete ip;

以下是操作符 newdelete 的一些特点:

  • newdelete 是操作符,因此不像 mallocfree 这样的函数需要括号。
  • new 返回指向请求的内存类型的指针(例如,它返回一个 int 类型的指针)。
  • new 以类型作为操作数,这带来了一个重要的好处:根据对象类型分配正确大小的内存。
  • 因此,new 是类型安全的操作符,它总是返回一个与操作符 new 相关的类型匹配的指针。此外,接收指针的类型必须与 new 操作符指定的类型匹配。
  • new 可能会失败,但通常程序员不需要关心这个问题。特别是,程序不需要测试内存分配的成功与否,而 malloc 等函数需要这样做。第 9.2.2 节详细讨论了 new 的这个方面。
  • delete 返回 void
  • 每次调用 new 后,应该最终执行一个匹配的 delete,以避免内存泄漏。
  • delete 可以安全地作用于 0 指针(什么都不做)。
  • 否则,delete 只能用于释放由 new 分配的内存。它不应该用于释放由 malloc 等分配的内存。
  • 在 C++ 中,malloc 等函数已经被弃用,应该避免使用。

操作符 new 可以用于分配基本类型,也可以用于分配对象。当分配一个基本类型或没有构造函数的 struct 类型时,分配的内存不会保证初始化为 0,但可以提供初始化表达式:

int *v1 = new int;         // 不保证初始化为 0
int *v1 = new int();       // 初始化为 0
int *v2 = new int(3);      // 初始化为 3
int *v3 = new int(3 * *v2); // 初始化为 9

当分配一个类类型对象时,如果构造函数需要参数,这些参数会紧随类型说明符后面在 new 表达式中指定,对象将通过指定的构造函数进行初始化。例如,要分配 string 对象,可以使用以下语句:

string *s1 = new string;      // 使用默认构造函数
string *s2 = new string{};    // 同上
string *s3 = new string(4, ' '); // 初始化为 4 个空格

除了使用 new 来为单个实体或实体数组分配内存(见下节),还有一个变体用于分配原始内存:operator new(sizeInBytes)。原始内存以 void* 的形式返回。在这种情况下,new 为未指定的目的分配了一块内存。

虽然原始内存可能由多个字符组成,但不应将其解释为字符数组。由于 new 返回的原始内存是 void* 类型,因此其返回值可以赋值给一个 void* 变量。更常见的是将其赋值给一个 char* 变量,通过类型转换实现。以下是一个示例:

char *chPtr = static_cast<char *>(operator new(numberOfBytes));

原始内存的使用经常与放置 new 运算符结合使用,后者将在第 9.1.5 节中讨论。

分配数组

运算符 new[] 用于分配数组。在 C++ 注释中,使用通用表示法 new[]。实际上,必须在方括号中指定要分配的元素数量,并且这个数量必须由要分配的实体类型前缀。例如:

int *intarr = new int[20]; // 分配 20 个整数
string *stringarr = new string[10]; // 分配 10 个字符串

运算符 new 与运算符 new[] 是不同的运算符。这种区别的后果将在下一节(9.1.2)中讨论。

通过运算符 new[] 分配的数组称为动态数组。它们在程序执行期间构造,其生命周期可能超过它们被创建的函数的生命周期。动态分配的数组可以持续到程序运行结束。

当使用 new[] 分配原始值数组或对象数组时,new[] 必须带有类型和方括号中的(无符号)表达式。编译器利用类型和表达式来确定需要分配的内存块的大小。当使用 new[] 时,数组的元素在内存中连续存储。之后,可以使用数组索引表达式访问数组的单个元素:intarr[0] 代表第一个整数值,紧接着是 intarr[1],依此类推,直到最后一个元素 intarr[19]

对于非类类型(原始类型、没有构造函数的 POD 类型),运算符 new[] 返回的内存块不保证初始化为 0。或者,可以通过在 new 表达式后加上 () 来将内存块初始化为零。例如:

分配数组

运算符 new[] 用于分配数组。通用表示法 new[] 可以用来分配数组。实际上,必须在方括号中指定要分配的元素数量,并且这个数量必须由要分配的实体类型前缀。例如:

int *intarr = new int[20]; // 分配 20 个整数
string *stringarr = new string[10]; // 分配 10 个字符串

运算符 newnew[] 是不同的运算符。这种区别的后果将在下一节(9.1.2)中讨论。

通过运算符 new[] 分配的数组称为动态数组。它们在程序执行期间构造,其生命周期可能超过它们被创建的函数的生命周期。动态分配的数组可以持续到程序运行结束。

当使用 new[] 分配原始值数组或对象数组时,new[] 必须带有类型和方括号中的(无符号)表达式。编译器利用类型和表达式来确定需要分配的内存块的大小。当使用 new[] 时,数组的元素在内存中连续存储。之后,可以使用数组索引表达式访问数组的单个元素:intarr[0] 代表第一个整数值,紧接着是 intarr[1],依此类推,直到最后一个元素 intarr[19]

对于非类类型(原始类型、没有构造函数的 POD 类型),运算符 new[] 返回的内存块不保证初始化为 0。或者,可以通过在 new 表达式后加上 () 来将内存块初始化为零。例如:

struct POD {
    int iVal;
    double dVal;
};

POD *p = new POD[5](); // 分配 5 个初始化为 0 的 POD 对象
double *d = new double[9](); // 分配 9 个初始化为 0 的 double 对象

此外,可以通过在括号中指定以逗号分隔的初始化值来推断数组的大小,例如 new int[](1, 2, 3)。这将分配一个包含三个整数的数组,分别初始化为 1、2 和 3。指定更大的值也是可能的。在这种情况下,前几个值按指定的初始化,剩余的值初始化为零(或由对象的默认构造函数初始化)。可以使用大括号代替圆括号。

如果结构体 POD 中的成员在结构体接口中被显式初始化(例如,int iVal = 12),或者结构体使用了组合,并且组合数据成员的类型定义了默认构造函数,那么接口中的初始化和组合数据成员构造函数执行的初始化将优先于 0 初始化。例如:

struct Data {
    int value = 100;
};

struct POD {
    int iVal = 12;
    double dVal;
    Data data;
};

POD *pp = new POD[5](); 

在这里,pp 指向五个 POD 对象,每个对象的 iVal 数据成员被初始化为 12,dVal 数据成员初始化为 0,data.value 数据成员初始化为 100。

当使用运算符 new[] 分配定义了默认构造函数的类类型的对象数组时,这些构造函数将自动被调用。因此,new string[20] 会生成一个包含 20 个初始化的 string 对象的内存块。非默认构造函数不能被调用,但通常可以通过某些方法进行解决(如第 13.8 节所讨论)。

运算符 new[] 方括号中的表达式表示要分配的数组元素的数量。C++ 标准允许分配大小为 0 的数组。语句 new int[0] 是正确的 C++。然而,它是没有意义且令人困惑的,应该避免。它没有指向任何元素的实际意义,且返回的指针具有无用的非零值。意图指向数组的指针应该被初始化为 0(如同任何尚未指向内存的指针),允许类似 if (ptr) ... 的表达式。

除了使用运算符 new[] 外,变量大小的数组也可以作为局部数组构造。这些数组不是动态数组,它们的生命周期仅限于定义它们的块的生命周期。

一旦分配,所有数组的大小都是固定的。没有简单的方法来增大或缩小数组。C++ 中没有 renew 运算符。第 9.1.3 节展示了如何增大数组。

删除数组

动态分配的数组使用运算符 delete[] 进行删除。它要求一个指向先前通过运算符 new[] 分配的内存块的指针。

当运算符 delete[] 的操作数是指向对象数组的指针时,会执行两个操作:

  1. 调用每个对象的析构函数:析构函数会对对象进行所有必要的清理操作,以便在对象不再存在时完成清理工作。
  2. 将内存返回到公共内存池:将指针指向的内存块返回给内存池。

以下是一个示例,展示如何分配和删除一个包含 10 个 std::string 对象的数组:

std::string *sp = new std::string[10];
delete[] sp;

对于动态分配的原始类型值数组的删除,没有特别的操作。例如,对于 int *it = new int[10],语句 delete[] it 只是将指针 it 指向的内存返回到内存池。

需要注意的是,作为原始类型的指针删除动态分配的指向对象的指针数组,并不会正确地销毁数组元素指向的对象。因此,以下示例会导致内存泄漏:

string **sp = new string *[5];
for (size_t idx = 0; idx != 5; ++idx)
    sp[idx] = new string;
delete[] sp; // 内存泄漏!

在这个示例中,delete[] 执行的唯一操作是将五个字符串指针的内存区域返回到内存池中。

在这种情况下,应该按照以下步骤执行销毁:

  1. 对数组的每个元素调用 delete
  2. 删除数组本身。

示例代码:

for (size_t idx = 0; idx != 5; ++idx)
    delete sp[idx];
delete[] sp;

一个后果是,在内存返回之前,指针和它包含的元素数量必须都可用。这可以通过将指针和元素数量存储在一个简单的类中,然后使用该类的对象来实现。

运算符 delete[] 与运算符 delete 是不同的运算符。记住的规则是:如果使用了 new[],也应该使用 delete[]

扩展数组

一旦分配了数组,其大小就固定了。C++ 没有 renew 运算符来扩展或缩小数组。扩展数组的基本步骤如下:

  1. 分配一个更大尺寸的新内存块
  2. 将旧数组的内容复制到新数组中
  3. 删除旧数组
  4. 将指向数组的指针指向新分配的数组

静态数组和局部数组不能调整大小。调整大小仅对动态分配的数组有效。

示例代码:

#include <string>
using namespace std;

string *enlarge(string *old, size_t oldsize, size_t newsize) {
    string *tmp = new string[newsize];  // 分配更大的数组
    for (size_t idx = 0; idx != oldsize; ++idx)
        tmp[idx] = old[idx];  // 复制旧数组到新数组
    delete[] old;  // 删除旧数组
    return tmp;    // 返回新数组
}

int main() {
    string *arr = new string[4];  // 初始时:4个字符串的数组
    arr = enlarge(arr, 4, 6);     // 扩展 arr 为 6 个元素
}

示例中的扩展过程也有几个缺点:

  • 新数组需要调用 newsize 个构造函数;
  • 在新数组中初始化了字符串后,oldsize 个字符串会被重新分配到原数组中对应的位置;
  • 所有旧数组中的对象会被销毁。

根据上下文,有多种方法可以提高此过程的效率。例如,可以使用指针数组(只需复制指针,不需要销毁或多余的初始化),或者结合使用原始内存和 placement new 运算符(保留对象数组,无需销毁或多余的构造)。

管理“原始”内存

正如我们所见,运算符 new 分配内存给对象,并随后通过调用其构造函数来初始化该对象。同样,运算符 delete 调用对象的析构函数,并随后将由 new 分配的内存返回给公共内存池。在下一节中,我们将遇到另一种 new 的用法,它允许我们在所谓的原始内存中初始化对象:这些内存仅由静态或动态分配提供的字节组成。

原始内存由 operator new(sizeInBytes)operator new[](sizeInBytes) 提供。这些变体返回的内存不应被解释为任何类型的数组,而只是动态分配的内存位置系列。这些变体不执行任何初始化。

这两种变体都返回 void*,因此需要进行静态类型转换以将返回值用作某种类型的内存。以下是两个示例:

// 为 5 个 int 分配空间:
int *ip = static_cast<int *>(operator new(5 * sizeof(int)));
// 与前一个示例相同:
int *ip2 = static_cast<int *>(operator new[](5 * sizeof(int)));
// 为 5 个 string 分配空间:
string *sp = static_cast<string *>(operator new(5 * sizeof(string)));

由于运算符 new 没有数据类型的概念,因此在为一定数量的对象分配原始内存时,必须指定预期数据类型的大小。因此,operator new 的使用在某种程度上类似于 malloc 的使用。

operator new 的对等操作是 operator delete。运算符 delete(或等效地,operator delete[])期望一个 void*(因此可以传递任何类型的指针)。指针被解释为原始内存的指针,并将其返回给公共内存池,不进行任何进一步的操作。特别地,operator delete 不会调用析构函数。因此,operator delete 的使用类似于 free。要返回上述变量 ipsp 指向的内存,应该使用:

// 删除由 operator new 分配的原始内存
operator delete(ip);
operator delete[](ip2);
operator delete(sp);

“Placement new” 操作符

一个引人注目的 new 操作符变体被称为 placement new 操作符。在使用 placement new 之前,必须包含 <memory> 头文件。

placement new 将一个现有的内存块作为参数,该内存块用于初始化对象或值。这个内存块应该足够大,以容纳对象,但除此之外没有其他要求。确定某个实体(对象或变量)使用了多少内存很简单:sizeof 操作符返回该实体占用的字节数。实体当然可以动态分配内存以供自身使用。然而,动态分配的内存不是实体内存“足迹”的一部分,而始终是在实体自身之外提供的。这就是为什么 sizeof 返回相同的值,而不同的 std::string 对象返回不同的长度和容量值。

placement new 操作符使用以下语法(使用 Type 表示使用的数据类型):

Type *new(void *memory) Type{ arguments };

其中,memory 是一个至少有 sizeof(Type) 字节的内存块,Type(arguments) 是类 Type 的任何构造函数。

placement new 操作符在类分配内存以供以后使用的情况下非常有用。例如,std::string 使用此操作符来更改其容量。调用 string::reserve 可能会扩大容量,而不会立即将超出字符串长度的内存提供给字符串对象的用户。但对象本身可以使用额外的内存。例如,当向字符串对象中添加信息时,它可以从其容量中提取内存,而不是为每个添加到内容中的字符执行重新分配。

让我们将这种理念应用于一个存储 std::string 对象的 Strings 类。该类定义了一个 string* d_memory 来访问保存 d_size 个字符串对象的内存,以及 d_capacity - d_size 保留的内存。假设默认构造函数将 d_capacity 初始化为 1,每次必须存储另一个字符串时,d_capacity 加倍,类必须支持以下基本操作:

  • 当所有备用内存(例如,由 reserve 提供)被消耗时,必须加倍其容量;
  • 添加另一个字符串对象;
  • Strings 对象不再存在时,正确删除已安装的字符串和内存。

私有成员函数 void Strings::reserve 在当前容量需要扩展到 d_capacity 时被调用。它的操作如下:

  1. 首先分配新的原始内存(第 1 行)。这块内存不会用字符串初始化。
  2. 然后,使用 placement new 将旧内存中的现有字符串复制到新分配的原始内存中(第 2 行)。
  3. 接下来,删除旧内存(第 3 行)。
void Strings::reserve() {
    using std::string;

    string *newMemory = static_cast<string *>(// 1
        operator new(d_capacity * sizeof(string)));
    for (size_t idx = 0; idx != d_size; ++idx)// 2
        new (newMemory + idx) string{d_memory[idx]};
    destroy();// 3
    d_memory = newMemory;
}

管理原始内存

成员函数 append 将另一个字符串对象添加到 Strings 对象中。一个(公共)成员函数 reserve(request)(在必要时扩展 d_capacity,并在扩展时调用 reserve())确保 Strings 对象的容量足够。然后使用 placement new 将最新的字符串安装到原始内存的适当位置:

void Strings::append(std::string const &next)
{
    reserve(d_size + 1);
    new (d_memory + d_size) std::string{ next };
    ++d_size;
}

Strings 对象生命周期结束时以及在扩展操作过程中,所有当前使用的动态分配内存必须被释放。这是 destroy 成员的责任,它由类的析构函数和 reserve() 调用。关于析构函数本身的更多内容将在下一节讨论,但支持成员函数 destroy 的实现如下。

使用 placement new 会遇到一个有趣的情况。对象(可能自己也分配内存)被安装在可能已经分配的内存中,但通常不会完全填满这些对象。因此,不能简单地使用 delete[]。另一方面,也不能使用每个对象的 delete 操作,因为这些 delete 操作还会尝试删除对象自身的内存,而这些内存并不是动态分配的。

这种特殊情况以特殊的方式解决,仅在使用 placement new 的情况下遇到:通过显式调用对象的析构函数来返回由 placement new 初始化的对象分配的内存。析构函数声明为一个成员,名称是类名前面加上一个波浪线(~),不使用任何参数。所以,std::string 的析构函数名为 ~string。对象的析构函数只释放由对象自身分配的内存,尽管它的名称不完全符合直观,但不会销毁对象本身。因此,我们类 Strings 中存储的字符串所分配的内存会通过显式调用它们的析构函数来正确销毁。之后,d_memory 恢复到其初始状态:它再次指向原始内存。然后,通过 operator delete 将这块原始内存返回到公共池中:

void Strings::destroy()
{
    for (std::string *sp = d_memory + d_size; sp-- != d_memory;) sp->~string();
    operator delete(d_memory);
}

到目前为止,一切正常。只要我们使用的只是一个对象,这一切都很好。那么,如何处理分配多个对象的情况呢?初始化仍然按照通常的方式进行。但就像 delete 一样,当缓冲区是静态分配的时,不能调用 delete[]。相反,当多个对象使用 placement new 和静态分配的缓冲区初始化时,必须显式调用所有对象的析构函数,如下例所示:

using std::string;
char buffer[3 * sizeof(string)];
string *sp = new (buffer) string[3];
for (size_t idx = 0; idx < 3; ++idx) sp[idx].~string();

标准模板库(STL)提供了一些处理未初始化(原始)内存的函数。有关详细信息,请参见第 19.1.58 节。

析构函数

与构造函数类似,类可以定义析构函数。析构函数是构造函数的对应部分,因为它在对象生命周期结束时被调用。析构函数通常会自动调用,但并非总是如此。动态分配的对象的析构函数不会自动调用,此外,当程序由于调用 exit 被中断时,只有已经初始化的全局对象的析构函数会被调用。在这种情况下,局部定义的对象的析构函数也不会被调用。这是避免在 C++ 程序中使用 exit 的一个(好的)理由。

析构函数遵循以下语法要求:

  • 析构函数的名称与其类名相同,但前面带有一个波浪线(~);
  • 析构函数没有参数;
  • 析构函数没有返回值。

析构函数在其类接口中声明。例如:

class Strings
{
public:
    Strings();
    ~Strings(); // 析构函数
};

按照惯例,构造函数首先声明,然后是析构函数,之后是其他成员函数。

析构函数的主要任务是确保当对象不再存在时,正确释放对象分配的内存。考虑以下 Strings 类的接口:

class Strings
{
    std::string *d_string;
    size_t d_size;
public:
    Strings();
    Strings(char const *const *cStrings, size_t n);
    ~Strings();

    std::string const &at(size_t idx) const;
    size_t size() const;
};

构造函数的任务是初始化对象的数据成员。例如,它的构造函数定义如下:

Strings::Strings()
    : d_string(0),
      d_size(0)
{}

Strings::Strings(char const *const *cStrings, size_t size)
    : d_string(new std::string[size]),
      d_size(size)
{
    for (size_t idx = 0; idx != size; ++idx)
        d_string[idx] = cStrings[idx];
}

这段代码展示了如何定义和实现 Strings 类的构造函数。第一个构造函数初始化了对象的数据成员,而第二个构造函数则动态分配内存并复制传入的字符串数组。

析构函数

由于 Strings 类的对象会分配内存,因此显然需要一个析构函数。析构函数可能会或可能不会自动调用,但请注意,析构函数只有在对象完全构造后才会被调用(或应当被调用)。C++ 认为对象在至少一个构造函数能够正常完成时是“完全构造”的。由于 C++ 支持构造函数委托,一个对象可能激活多个构造函数,因此可以理解为“至少一个构造函数”。接下来的规则适用于完全构造的对象:

  • 局部非静态对象的析构函数会在执行流离开其定义的块时自动调用;在函数外部块中定义的对象的析构函数会在函数终止之前被调用。
  • 静态或全局对象的析构函数会在程序终止时被调用。
  • 动态分配的对象的析构函数由 delete 调用,使用对象的地址作为操作数;
  • 动态分配的对象数组的析构函数由 delete[] 调用,使用数组第一个元素的地址作为操作数;
  • 使用 placement new 初始化的对象的析构函数需要通过显式调用对象的析构函数来激活。

析构函数的任务是确保所有动态分配的内存(且只由对象本身控制)被正确释放。因此,Strings 类的析构函数的任务是删除 d_string 指向的内存。其实现如下:

Strings::~Strings()
{
    delete[] d_string;
}

以下示例展示了 Strings 的实际使用。在 process 函数中创建了一个 Strings 对象,并显示其数据。process 返回一个动态分配的 Strings 对象到 main 函数中。main 函数中的 Strings* 接收到分配对象的地址,并再次删除该对象。接着,在 main 函数中创建了一个在本地块中分配的 Strings 对象,并需要显式调用 ~Strings 来释放该对象分配的内存。在这个例子中,只有一个 Strings 对象(即由 process 定义的局部 Strings 对象)会自动销毁。其他两个 Strings 对象需要显式操作来防止内存泄漏。

#include "strings.h"
#include <iostream>

using namespace std;

void display(Strings const &store)
{
    for (size_t idx = 0; idx != store.size(); ++idx)
        cout << store.at(idx) << '\n';
}

Strings *process(char *argv[], size_t argc)
{
    Strings store{ argv, argc };
    display(store);
    return new Strings{ argv, argc };
}

int main(int argc, char *argv[])
{
    Strings *sp = process(argv, argc);
    delete sp;

    char buffer[sizeof(Strings)];
    sp = new (buffer) Strings{ argv, static_cast<size_t>(argc) };
    sp->~Strings();
}

在这个示例中:

  • process 函数创建了一个 Strings 对象 store,显示其内容,然后返回一个动态分配的 Strings 对象。
  • main 函数调用 process 并删除返回的动态对象。
  • main 函数中的 char buffer[sizeof(Strings)] 创建了一个足够大的缓冲区,并使用 placement new 在该缓冲区中创建了一个 Strings 对象。随后,显式调用 ~Strings 析构函数以释放内存。

对象指针重新审视

运算符 newdelete 用于分配对象或变量。相较于 mallocfree 函数,newdelete 的一个优势是它们调用了相应的对象构造函数和析构函数。

使用运算符 new 分配一个对象是一个两步过程。首先分配对象本身的内存,然后调用其构造函数来初始化对象。与对象的构造类似,对象的销毁也是一个两步过程:首先调用类的析构函数来删除对象控制的内存,然后释放对象本身所占用的内存。

动态分配的对象数组也可以通过 newdelete 进行处理。使用运算符 new 分配对象数组时,会为数组中的每个对象调用默认构造函数。在这种情况下,必须使用 delete[] 运算符以确保对数组中每个对象调用析构函数。

然而,new Typenew Type[size] 返回的地址是相同类型的,在两种情况下都是 Type*。因此,不能通过指针的类型来确定指向动态分配内存的指针是指向单个实体还是实体数组。

如果使用 delete 而不是 delete[] 会发生什么?考虑以下情况,其中析构函数 ~Strings 被修改以输出析构函数被调用的消息。在 main 函数中,使用 new 分配了两个 Strings 对象的数组,并用 delete[] 删除它们。接着,重复相同的操作,但使用 delete 运算符而不带 []

#include <iostream>
#include "strings.h"

using namespace std;

Strings::~Strings()
{
    cout << "Strings destructor called" << '\n';
}

int main() {
    Strings *a = new Strings[2];
    cout << "Destruction with []'s" << '\n';
    delete[] a;

    a = new Strings[2];
    cout << "Destruction without []'s" << '\n';
    delete a;
}
生成的输出:
Destruction with []'s
Strings destructor called
Strings destructor called
Destruction without []'s
Strings destructor called

在这个示例中:

  • 使用 new Strings[2] 分配了一个 Strings 对象的数组。
  • 使用 delete[] 删除数组,输出了析构函数被调用的消息两次,表明数组中的每个对象的析构函数都被调用。
  • 再次使用 new Strings[2] 分配相同数量的 Strings 对象的数组。
  • 使用 delete 删除数组,虽然该操作没有显式的 [] 标记,仍然输出了析构函数被调用的消息一次,但并未正确处理每个对象的析构。

在实际编程中,确保使用 delete[] 来删除通过 new[] 分配的对象数组,以避免未定义行为和潜在的内存泄漏。

从生成的输出可以看出,当使用 delete[] 时,单个 Strings 对象的析构函数都会被调用,而如果省略 [],则只有第一个对象的析构函数会被调用。相反,如果在应该使用 delete 的情况下调用 delete[],结果是不可预测的,程序很可能会崩溃。这种问题行为是由于运行时系统存储分配数组大小信息的方式(通常是在数组第一个元素之前)。如果分配了单个对象,则没有数组特定的信息,但 delete[] 仍然假设存在这些信息。因此,该运算符会在数组第一个元素之前的内存位置中遇到虚假的值,然后将其解释为大小信息,通常会导致程序失败。

如果没有定义析构函数,编译器会定义一个默认析构函数。默认析构函数会确保调用组成对象的析构函数(如果类是派生类,还包括基类的析构函数)。这有严重的影响:分配内存的对象会产生内存泄漏,除非采取预防措施(通过定义适当的析构函数)。考虑以下程序:

#include <iostream>
#include "strings.h"

using namespace std;

Strings::~Strings()
{
    cout << "Strings destructor called" << '\n';
}

int main()
{
    Strings **ptr = new Strings* [2];
    ptr[0] = new Strings[2];
    ptr[1] = new Strings[2];
    delete[] ptr;
}

在此程序中,ptr 是一个指向 Strings 指针数组的指针,其中每个 Strings 指针指向一个 Strings 对象数组。使用 delete[] ptr 进行删除时,ptr 中的每个 Strings 指针指向的数组的析构函数都不会被调用,因此会导致内存泄漏。

另一个示例程序展示了在 Wrapper 类的析构函数正确调用情况下的行为:

#include <iostream>
using namespace std;

class Strings
{
public:
    ~Strings();
};

inline Strings::~Strings() { cout << "destructor called\n"; }

class Wrapper {
    Strings *d_strings;

public:
    Wrapper();
    ~Wrapper();
};

inline Wrapper::Wrapper() : d_strings(new Strings{}) {}
inline Wrapper::~Wrapper() { delete d_strings; }

int main() {
    auto ptr = new Strings *[4];
    // ... code assigning `new Strings` to ptr's elements
    delete[] ptr;
    // memory leak: ~Strings() not called
    cout << "===========\n";
    delete[] new Wrapper[4];  // OK: 4 x destructor called
}
生成的输出:
===========
destructor called
destructor called
destructor called
destructor called

在此程序中:

  • ptr 是一个指向 Strings 指针的数组,但在对 ptr 进行 delete[] 时,并未删除 Strings 对象,导致内存泄漏。
  • Wrapper 类的析构函数正确地调用了其成员 Strings 对象的析构函数。使用 delete[] 删除 Wrapper 对象数组时,会调用每个 Wrapper 对象的析构函数,输出4次 “destructor called”。

set_new_handler() 函数

C++ 运行时系统确保在内存分配失败时会激活一个错误处理函数。默认情况下,这个函数会抛出一个 bad_alloc 异常(参见第 10.8 节),从而终止程序。因此,通常不需要检查 new 运算符的返回值。不过,new 运算符的默认行为可以通过多种方式进行修改。其中一种方式是重新定义在内存分配失败时调用的函数。这样的函数必须符合以下要求:

  • 它没有参数;
  • 它的返回类型是 void

一个重新定义的错误处理函数可能会输出一条消息并终止程序。通过 set_new_handler 函数,用户编写的错误处理函数会成为分配系统的一部分。下面是一个示例1:

#include <iostream>
#include <string>
using namespace std;

void outOfMemory()
{
    cout << "Memory exhausted. Program terminates." << '\n';
    exit(1);
}

int main()
{
    long allocated = 0;
    set_new_handler(outOfMemory);  // 安装错误处理函数
    while (true)  // 耗尽所有内存
    {
        new int[100000]();
        allocated += 100000 * sizeof(int);
        cout << "Allocated " << allocated << " bytes\n";
    }
}

一旦安装了新的错误处理函数,当内存分配失败时,它会自动被调用,程序将终止。内存分配也可能在间接调用的代码中失败,例如在构造或使用流时,或在低级函数复制字符串时。

从理论上讲,在某些系统上,“内存不足”的情况可能实际上永远不会发生,因为操作系统可能会在运行时支持系统有机会停止程序之前进行干预。

传统的内存分配函数(如 strdupmallocrealloc 等)在内存分配失败时不会触发新的处理程序,因此应该避免在 C++ 程序中使用它们。

赋值运算符

在 C++ 中,结构体和类类型的对象可以像在 C 语言中一样直接赋予新值。对于非类类型的数据成员,这种赋值的默认行为是逐字节地从一个数据成员复制到另一个数据成员。目前,我们将使用以下简单的 Person 类:

class Person
{
    char *d_name;
    char *d_address;
    char *d_phone;

public:
    Person();
    Person(char const *name, char const *addr, char const *phone);
    ~Person();

private:
    char *strdupnew(char const *src); // 返回 src 的副本。
};

strdupnew 的实现很简单,下面是它的内联实现:

inline char *Person::strdupnew(char const *src)
{
    return strcpy(new char[strlen(src) + 1], src);
}

Person 的数据成员会被初始化为零,或者被初始化为传递给 Person 构造函数的 NTBS(以 \0 结尾的字符串)的副本,使用的是 strdup 的某种变体。分配的内存最终会由 Person 的析构函数释放。

现在,考虑在以下示例中使用 Person 对象的后果:

void tmpPerson(Person const &person)
{
    Person tmp;
    tmp = person;
}

在这里插入图片描述

当调用 tmpPerson 函数时,发生以下情况:

  • tmpPerson 期望一个对 Person 对象的引用作为其参数 person
  • 它定义了一个局部对象 tmp,其数据成员被初始化为零。
  • person 引用的对象被复制到 tmp:即从 persontmp 复制了 sizeof(Person) 字节的数据。

此时,一个潜在的危险情况已经产生。person 中的实际值是指针,指向已分配的内存。在赋值之后,这段内存被两个对象共享:persontmp

  • 这种潜在的危险情况在 tmpPerson 函数结束时变成了急剧的危险情况:tmp 被销毁。Person 类的析构函数释放了由 d_named_addressd_phone 字段指向的内存:不幸的是,这段内存同样被 person 指向……

这个有问题的赋值在图中有所展示。

执行完 tmpPerson 后,被 person 引用的对象现在包含指向已删除内存的指针。

显然,这不是使用 tmpPerson 这样一个函数所期望的效果。被删除的内存很可能会被随后的分配重新使用。而 person 的指针成员实际上已经变成了野指针,因为它们不再指向已分配的内存。一般来说,可以得出结论:

任何包含指针数据成员的类都是潜在的麻烦来源。

幸运的是,可以通过接下来讨论的方法来防止这些问题。
在这里插入图片描述

重载赋值运算符

显然,正确的方式是避免将一个 Person 对象的内容逐字节复制给另一个对象。更好的方法是创建一个等效的对象,该对象拥有自己分配的内存并包含原始字符串的副本。

赋值一个 Person 对象给另一个对象的方式如图所示。实际上有多种方式可以实现这一目的。一个方法是定义一个专门的成员函数来处理赋值操作。该成员函数的目的在于创建一个对象的副本,该副本拥有自己独立的姓名、地址和电话字符串。该成员函数可以如下所示:

void Person::assign(Person const &other) {
    // 删除我们之前使用的内存
    delete[] d_name;
    delete[] d_address;
    delete[] d_phone;
    // 复制另一个 Person 对象的数据
    d_name = strdupnew(other.d_name);
    d_address = strdupnew(other.d_address);
    d_phone = strdupnew(other.d_phone);
}

通过使用 assign,我们可以重新编写有问题的 tmpPerson 函数:

void tmpPerson(Person const &person) {
    Person tmp;
    // tmp(拥有自己的内存)持有 person 的副本
    tmp.assign(person);
    // 现在 tmp 被销毁也无关紧要了
}

这种解决方案是有效的,尽管它只是解决了表面问题。它要求程序员使用特定的成员函数,而不是赋值运算符。原来的问题(赋值操作产生了野指针)仍然没有解决。由于严格遵守规则很难,因此首选的解决方案当然是解决原始问题。

幸运的是,可以通过运算符重载来解决这个问题:C++ 提供了在特定上下文中重新定义运算符操作的可能性。运算符重载在之前已简要提及,当时对流运算符 <<>> 进行了重载以用于流操作(如 cincoutcerr),参见第 3.1.4 节。

重载赋值运算符可能是 C++ 中最常见的一种运算符重载形式。然而,仍需要适当的警告。虽然 C++ 允许运算符重载,但这并不意味着该特性应该被随意使用。以下是你应该牢记的要点:

  • 运算符重载应在运算符具有默认操作但在特定上下文中产生了不良副作用时使用。一个明显的例子是上面在 Person 类上下文中的赋值运算符。

  • 运算符重载可以在运算符被广泛应用且其重定义不会引入惊喜的情况下使用。一个适当使用运算符重载的例子是 std::string 类:将一个字符串对象赋值给另一个对象会将源字符串的内容复制到目标字符串中,这里没有任何惊喜。

  • 在所有其他情况下,应该定义一个成员函数,而不是重定义一个运算符。运算符应该只是做它本来应该做的事。关于运算符重载,经常遇到的短语是 “do as the ints do”。即运算符在应用于整数时的行为就是我们所期望的,所有其他实现可能会引发惊讶和混乱。因此,在流的上下文中重载插入 (<<) 和提取 (>>) 运算符可能是不合适的:流操作与位移操作毫无关系。

operator=() 成员函数

要向类添加运算符重载,只需在类接口中提供一个(通常是 public 的)成员函数,该成员函数的名称为特定运算符。当该成员函数被实现后,运算符重载便生效了。要重载赋值运算符 =,只需在类接口中添加一个成员函数 operator=(Class const &rhs)。请注意,函数名称由两部分组成:关键字 operator,然后是运算符本身。当我们在类接口中添加一个成员函数 operator= 时,该运算符会被重定义,从而阻止默认运算符的使用。

在前一节中,我们使用了 assign 函数来解决使用默认赋值运算符导致的问题。与其使用普通成员函数,C++ 更常使用专用的运算符重载来将运算符的默认行为推广到所定义的类中。之前提到的 assign 成员函数可以被重新定义如下(下面的 operator= 成员是重载赋值运算符的第一个版本,相对简单,稍后我们将进行改进):

class Person
{
public:
    // 扩展 Person 类
    // 假设已经定义了前面的成员
    void operator=(Person const &other);
};

它的实现可能如下:

void Person::operator=(Person const &other) {
    delete[] d_name;        // 删除旧数据
    delete[] d_address;
    delete[] d_phone;

    d_name = strdupnew(other.d_name);  // 复制另一个对象的数据
    d_address = strdupnew(other.d_address);
    d_phone = strdupnew(other.d_phone);
}

这个成员函数的操作与前面提到的 assign 成员函数类似,但它会在使用赋值运算符 = 时自动调用。实际上,有两种方式调用重载运算符,如下例所示:

void tmpPerson(Person const &person) {
    Person tmp;
    tmp = person;
    tmp.operator=(person);  // 效果相同
}

重载运算符很少显式调用,但当你希望从指向对象的指针显式调用重载运算符时,必须使用显式调用(当然也可以先解引用指针,然后使用普通的运算符语法,如下例所示):

void tmpPerson(Person const &person)
{
    Person *tmp = new Person;
    tmp->operator=(person);
    *tmp = person;  // 是的,这也是可以的...
    delete tmp;
}
  • 14
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值