Skip to content

Latest commit

 

History

History
1395 lines (987 loc) · 83.7 KB

02.md

File metadata and controls

1395 lines (987 loc) · 83.7 KB

二、使用内存、数组和指针

C++ 允许你通过指针直接访问内存。这为您提供了很大的灵活性,并有可能通过消除一些不必要的数据复制来提高代码的性能。然而,它也提供了额外的错误来源;有些可能对您的应用是致命的,或者更糟(是的,比致命更糟!)因为内存缓冲区使用不当会在代码中打开安全漏洞,从而让恶意软件接管机器。很明显,指针是 C++ 的一个重要方面。

在本章中,您将看到如何声明指针并将其初始化到内存位置,如何在堆栈和上分配内存,C++ 自由存储,以及如何使用 C++ 数组。

在 C++ 中使用内存

C++ 使用与 C 相同的语法来声明指针变量并将其分配给内存地址,并且它具有类似 C 的指针算法。像 C 一样,C++ 也允许你在堆栈上分配内存,所以当堆栈框架被破坏时,会自动清理内存,程序员有责任释放内存的动态分配(在 C++ 自由存储上)。本节将介绍这些概念。

使用 C++ 指针语法

用 C++ 访问内存的语法很简单。&运算符返回对象的地址。对象可以是变量、内置类型或自定义类型的实例,甚至是函数(函数指针将在下一章讨论)。地址被分配了一个类型化的指针变量或void*指针。void*指针应该被视为仅仅是存储内存地址,因为您不能访问数据,也不能对void*指针执行指针算术(即,使用算术运算符操作指针值)。指针变量通常使用类型和*符号来声明。例如:

    int i = 42; 
    int *pi = &i;

在这段代码中,变量i是一个整数,编译器和链接器将决定这个变量分配到哪里。通常,函数中的一个变量会在堆栈框架上,这将在后面的章节中描述。运行时,将创建堆栈(本质上是分配一大块内存),并将在堆栈内存中为变量i保留空间。然后,该程序在该存储器中放入一个值(42)。接下来,分配给变量i的内存地址被放置在变量pi中。下图说明了之前代码的内存使用情况:

指针保存0x007ef8c的值(注意,最低字节存储在内存的最低字节中;这是针对 x86 机器的)。内存位置0x007ef8c的值为0x0000002a,即变量i的值 42。由于pi也是一个变量,它也占用内存空间,在这种情况下,编译器将内存中的指针放在比它所指向的数据更低的位置,在这种情况下,这两个变量是不连续的。

像这样在堆栈上分配变量,您不应该假设变量在内存中的分配位置,也不应该假设它们相对于其他变量的位置。

这段代码假设一个 32 位的操作系统,因此指针pi占据 32 位并且包含一个 32 位的地址。如果操作系统是 64 位,那么指针将是 64 位宽(但是整数可能仍然是 32 位)。在本书中,我们将使用 32 位指针,因为 32 位地址比 64 位地址需要更少的输入。

类型化的指针用*符号声明,我们称之为int*指针,因为指针指向保存int的内存。声明指针时,惯例是将*放在变量名旁边,而不是类型旁边。这个语法强调了类型指向的是一个int。但是,如果在一条语句中声明了多个变量,则使用以下语法非常重要:

    int *pi, i;

很明显,第一个变量是int*指针,第二个是int。以下不太清楚:

    int* pi, i;

你可能会把这解释为两个变量的类型都是int*,但事实并非如此,因为这声明了一个指针和一个int。如果要声明两个指针,那么对每个变量应用*:

    int *p1, *p2;

最好是在单独的行上声明这两个指针。

当您将sizeof运算符应用于指针时,您将获得指针的大小,而不是它所指向的内容。因此,在 x86 机器上,sizeof(int*)将返回 4;在 x64 机器上,它将返回 8。这是一个重要的观察,尤其是当我们在后面的部分讨论 C++ 内置数组时。

要访问指针指向的数据,您必须使用*操作符取消引用:

    int i = 42; 
    int *pi = &i; 
    int j = *pi;

像这样在赋值的右边使用,取消引用的指针给出了对指针所指向的值的访问,因此j被初始化为 42。将此与指针的声明进行比较,其中也使用了*符号,但具有不同的含义。

取消引用操作符不仅仅是对内存位置的数据进行读访问。只要指针不限制它(使用const关键字;请参阅后面的内容),您也可以取消引用指针来写入内存位置:

    int i = 42; 
    cout << i << endl; 
    int *pi { &i }; 
    *pi = 99; 
    cout << i << endl;

在这段代码中,指针pi指向变量i在内存中的位置(在这种情况下,使用大括号语法)。分配取消引用的指针会将值分配给指针指向的位置。结果是在最后一行,变量i的值将是 99,而不是 42。

使用空指针

指针可以指向安装在计算机内存中的任何位置,通过取消引用的指针进行分配意味着您可能会覆盖操作系统使用的敏感内存,或者(通过直接内存访问)写入计算机硬件使用的内存。但是,操作系统通常会给可执行文件一个它可以访问的特定内存范围,试图访问该范围之外的内存将导致操作系统内存访问冲突。

因此,您应该总是使用&操作符或从对操作系统函数的调用中获取指针值。你不应该给指针一个绝对地址。唯一的例外是无效内存地址的 C++ 常量nullptr:

    int *pi = nullptr; 
    // code 
    int i = 42; 
    pi = &i; 
    // code 
    if (nullptr != pi) cout << *pi << endl;

该代码将指针pi初始化为nullptr。在代码的后面,指针被初始化为一个整数变量的地址。在代码的后面,使用指针,但是不是立即调用它,而是首先检查指针以确保它已经被初始化为非空值。编译器将检查您是否要使用尚未初始化的变量,但是如果您正在编写库代码,编译器将不知道代码的调用方是否会正确使用指针。

The type of constant nullptr is not an integer, it is std::nullptr_t. All pointer types can be implicitly converted to this type, so nullptr can be used to initialize variables of all pointer types.

记忆的类型

一般来说,你可以把记忆看作四种类型之一:

  • 静态或全局
  • 字符串池
  • 自动或堆叠
  • 免费商店

当您在全局级别声明一个变量时,或者如果您有一个在函数中声明为static的变量,那么编译器将确保该变量是从与应用具有相同生存期的内存中分配的——该变量在应用启动时创建,在应用结束时删除。

当您使用字符串文字时,数据实际上也是一个全局变量,但存储在可执行文件的不同部分。对于 Windows 可执行文件,字符串文字存储在可执行文件的.rdata PE/COFF 部分。文件的.rdata部分用于只读初始化数据,因此您不能更改数据。Visual C++ 允许您更进一步,并为您提供了字符串池选项。考虑一下:

    char *p1 { "hello" }; 
    char *p2 { "hello" }; 
    cout << hex; 
    cout << reinterpret_cast<int>(p1) << endl; 
    cout << reinterpret_cast<int>(p2) << endl;

在这段代码中,两个指针用字符串hello的地址初始化。在下面两行中,每个指针的地址都打印在控制台上。由于char*<<运算符将变量视为指向字符串的指针,因此它将打印字符串而不是指针的地址。为了解决这个问题,我们调用reinterpret_cast运算符将指针转换为整数并打印该整数的值。

如果使用 Visual C++ 编译器在命令行编译代码,您将看到打印了两个不同的地址。这两个地址在.rdata部分,都是只读的。如果您使用/GF开关编译此代码以启用字符串池(这是 Visual C++ 项目的默认设置),编译器将看到两个字符串是相同的,并且将只在.rdata部分存储一个副本,因此此代码的结果将是一个地址在控制台上打印两次。

在这段代码中,两个变量p1p2是自动变量,也就是说,它们是在为当前函数创建的堆栈上创建的。调用函数时,会为函数分配一大块内存,其中包含传递给函数的参数和调用函数的代码的返回地址的空间,以及函数中声明的自动变量的空间。当函数完成时,堆栈框架被破坏。

The calling convention of the function determines whether the calling function or the called function has the responsibility to do this. In Visual C++, the default is the __cdecl calling convention, which means the calling function cleans up the stack. The __stdcall calling convention is used by Windows operating system functions and the stack clean up is carried out by the called function. More details will be given in the next chapter.

只要函数和这些变量的地址在函数中没有任何意义,自动变量就不会存在。在本章的后面,您将看到如何创建数据数组。作为自动变量分配的数组在堆栈上被分配到编译时确定的固定大小。大型数组可能会超出堆栈的大小,尤其是递归调用的函数。在 Windows 上,默认堆栈大小是 1 MB,在 x86 Linux 上,是 2 MB。Visual C++ 允许您使用/F编译器开关(或/STACK链接器开关)指定更大的堆栈。gcc 编译器允许您使用--stack开关更改默认堆栈大小。

最后一种内存是在免费商店上创建的动态内存,有时也称为。这是使用内存最灵活的方式。顾名思义,您在运行时分配的内存大小是在运行时确定的。免费存储的实现依赖于 C++ 实现,但是您应该将免费存储视为与您的应用具有相同的生存期,因此从免费存储分配的内存应该至少与您的应用一样长。

然而,这里有潜在的危险,特别是对于长寿命的应用。当你用完空闲存储时,从空闲存储分配的所有内存都应该返回到空闲存储,这样空闲存储管理器就可以重用这些内存。如果您没有适当地返回内存,那么空闲存储管理器可能会耗尽内存,这将提示它向操作系统请求更多内存,因此,应用的内存使用量将随着时间的推移而增长,从而由于内存分页而导致性能问题。

指针算法

指针指向内存,指针的类型决定了可以通过指针访问的数据的类型。所以一个int*指针会指向内存中的一个整数,你去引用这个指针(*)得到这个整数。如果指针允许(没有标记为const,可以通过指针算术改变其值)。例如,您可以递增或递减指针。内存地址值的变化取决于指针的类型。由于一个类型化指针指向一个类型,任何指针算法都会以该类型的大小为单位改变指针。

如果增加一个int*指针,它将指向内存中下一个整数*,内存地址的变化取决于整数的大小。这相当于数组索引,其中像v[1]这样的表达式意味着您应该从v中第一个项目的内存位置开始,然后在内存中再移动一个项目,并在那里返回该项目:*

    int v[] { 1, 2, 3, 4, 5 };
    int *pv = v;
    *pv = 11;
    v[1] = 12;
    pv[2] = 13;
    *(pv + 3) = 14;

第一行在堆栈上分配一个五个整数的数组,并将值初始化为数字 1 到 5。在这个例子中,因为使用了初始化列表,编译器将为所需数量的项目创建空间,因此没有给出数组的大小。如果在括号之间给出数组的大小,那么初始化列表中的项目不能超过数组的大小。如果列表中的项目较少,那么数组中的其余项目将被初始化为默认值(通常为零)。

这段代码的下一行获取指向数组中第一项的指针。这一行很重要:数组名被视为指向数组中第一项的指针。以下几行以各种方式改变数组项。第一个(*pv)通过取消指针引用并为其赋值来更改数组中的第一项。第二个(v[1])使用数组索引为数组中的第二项赋值。第三个(pv[2])使用索引,但这次使用指针,并为数组中的第三个值赋值。最后一个例子(*(pv + 3))使用指针算法来确定数组中第四项的地址(记住第一项的索引为 0),然后取消指针引用来为该项赋值。在这些之后,数组包含值{ 11, 12, 13, 14, 5 }和内存布局如下图所示:

如果您有一个包含值的内存缓冲区(在本例中,通过数组分配),并且您想要将每个值乘以 3,您可以使用指针算法来实现:

    int v[] { 1, 2, 3, 4, 5 }; 
    int *pv = v; 
    for (int i = 0; i < 5; ++ i) 
    { 
        *pv++ *= 3; 
    }

循环语句比较复杂,需要参考第 1 章理解语言特性中给出的运算符优先级。后缀增量运算符的优先级最高,次高的优先级是取消引用运算符(*),最后,*=运算符是三个运算符中最低的,因此运算符按以下顺序运行:++ **=。后缀运算符在增量之前返回值*,因此尽管指针增加到内存中的下一项,表达式使用增量之前的地址。该地址随后被取消引用,该地址由赋值运算符赋值,该运算符用乘以 3 的值替换该项。这说明了指针和数组名之间的一个重要区别;您可以递增指针,但不能递增数组:*

    pv += 1; // can do this 
    v += 1; // error

当然,您可以在数组名和指针上使用索引(带[])。

使用数组

顾名思义,C++ 内置数组是零个或多个相同类型的数据项。在 C++ 中,方括号用于声明数组和访问数组元素:

    int squares[4]; 
    for (int i = 0; i < 4; ++ i)  
    { 
        squares[i] = i * i; 
    }

squares变量是整数数组。第一行为四个整数分配足够的内存,然后for循环用前四个方块初始化内存。编译器从堆栈中分配的内存是连续的,数组中的项目是顺序的,因此squares[3]的内存位置是squares[2]之后的sizeof(int)。因为数组是在堆栈上创建的,所以数组的大小是编译器的一条指令;这不是动态分配,因此大小必须是常数。

这里有一个潜在的问题:数组的大小被提到两次,一次在声明中,然后再次在for循环中。如果使用两个不同的值,则初始化的项目可能太少,或者可能会访问数组之外的内存。ranged for语法允许您访问数组中的每个项目;编译器可以确定数组的大小,并在范围内的for循环中使用它。在下面的代码中,有一个故意的错误显示了数组大小的问题:

    int squares[5]; 
    for (int i = 0; i < 4; ++ i)  
    { 
        squares[i] = i * i; 
    } 
    for(int i : squares) 
    { 
        cout << i << endl; 
    }

数组的大小和第一个for循环的范围不一致,因此最后一项不会被初始化。然而,范围内的for循环将循环所有五个项目,因此将打印出最后一个值的一些随机值。如果使用相同的代码,但声明squares数组有三个项目,该怎么办?这取决于您正在使用的编译器以及您是否正在编译调试版本,但显然您将写入分配给数组的内存之外的内存*。*

有一些方法可以缓解这些问题。第一种方法是为数组的大小声明一个常量,并在代码需要知道数组大小时使用它:

    constexpr int sq_size = 4; 
    int squares[sq_size]; 
    for (int i = 0; i < sq_size; ++ i) 
    { 
        squares[i] = i * i; 
    }

数组声明必须有一个大小常量,并且使用sq_size常量变量进行管理。

您可能还想计算已经分配的数组的大小。当应用于数组时,sizeof运算符返回整个数组的字节大小,因此您可以通过将该值除以单个项目的大小来确定数组的大小:

    int squares[4]; 
    for (int i = 0; i < sizeof(squares)/sizeof(squares[0]); ++ i) 
    { 
        squares[i] = i * i; 
    }

这是更安全的代码,但显然是冗长的。C 运行时库包含一个名为_countof的宏,它执行这个计算。

功能参数

如图所示,数组会自动转换为适当的指针类型,如果将数组传递给函数或从函数返回数组,就会出现这种情况。这种向哑指针的衰减意味着其他代码无法假设数组的大小。指针可以指向堆栈上分配的内存,其中内存寿命由函数确定,或者指向全局变量,其中内存寿命是程序的内存寿命,或者它可以指向动态分配的内存,并且内存由程序员确定。指针声明中没有任何内容指示内存的类型或谁负责内存的释放。哑指针中也没有指针指向多少内存的信息。当您使用指针编写代码时,您必须遵守如何使用指针的规则。

一个函数可以有一个数组参数,但这意味着比它显示的要少得多:

    // there are four tires on each car 
    bool safe_car(double tire_pressures[4]);

该函数将检查数组的每个成员是否有一个介于允许的最小值和最大值之间的值。一辆车上任何时候都有四个轮胎在使用,所以函数应该用四个值的数组来调用。问题是,虽然看起来编译器应该检查传递给函数的数组大小是否合适,但事实并非如此。您可以这样调用这个函数:

    double car[4] = get_car_tire_pressures(); 
    if (!safe_car(car)) cout << "take off the road!" << endl; 
    double truck[8] = get_truck_tire_pressures(); 
    if (!safe_car(truck)) cout << "take off the road!" << endl;

当然,对于开发人员来说,卡车不是汽车应该是显而易见的,所以这个开发人员不应该编写这段代码,但是编译语言的通常优势是编译器会为您执行一些健全性检查。在数组参数的情况下,不会。

原因是数组是作为指针传递的,所以虽然参数看起来是一个内置数组,但是您不能使用您习惯于在像 ranged for这样的数组中使用的工具。事实上,如果safe_car函数调用sizeof(tire_pressures),它将得到双指针的大小,而不是四int数组的字节大小 16。

数组参数的这个衰减为指针的特性意味着,只有当你明确告诉一个数组参数的大小时,函数才会知道它的大小。您可以使用一对空方括号来指示应该向该项传递数组,但它实际上与指针相同:

    bool safe_car(double tire_pressures[], int size);

这里,该函数有一个指示数组大小的参数。前面的函数与将第一个参数声明为指针完全相同。以下不是函数的重载;它是相同的功能:

    bool safe_car(double *tire_pressures, int size);

重要的一点是,当你把一个数组传递给一个函数时,数组的第一维被当作一个指针。到目前为止,数组是一维的,但它们可能不止一维。

多维数组

数组可以是多维的,要添加另一个维度,需要添加另一组方括号:

    int two[2]; 
    int four_by_three[4][3];

第一个示例创建一个两个整数的数组,第二个示例创建一个二维数组,其中 12 个整数排列成四行三列。当然,是任意的,将二维数组当作常规的电子表格,但这有助于可视化数据在内存中的排列方式。

请注意,每个维度周围都有方括号。C++ 在这方面与其他语言不同,所以int x[10,10]的声明将被 C++ 编译器报告为错误。

初始化多维数组涉及一对大括号和数据,其顺序将用于初始化维度:

    int four_by_three[4][3] { 11,12,13,21,22,23,31,32,33,41,42,43 };

在本例中,具有最高数字的值反映最左边的索引,较低数字反映最右边的索引(在这两种情况下,都比实际索引多一个)。显然,您可以将它拆分成几行,并使用空白来将值组合在一起,以使其更易读。也可以使用嵌套大括号。例如:

    int four_by_three[4][3] = { {11,12,13}, {21,22,23}, 
                                {31,32,33}, {41,42,43} };

如果从左到右读取维度,就可以读取更深层次嵌套的初始化。有四行,因此在外部大括号中有四组嵌套的大括号。有三列,因此在嵌套的大括号中,有三个初始化值。

嵌套大括号不仅仅是为了方便格式化 C++ 代码,因为如果您提供一对空大括号,编译器将使用默认值:

    int four_by_three[4][3] = { {11,12,13}, {}, {31,32,33}, {41,42,43} };

这里,第二行项目被初始化为 0。

增加尺寸时,原则适用:增加最右侧尺寸的嵌套:

    int four_by_three_by_two[4][3][2]  
       = { { {111,112}, {121,122}, {131,132} }, 
           { {211,212}, {221,222}, {231,232} }, 
           { {311,312}, {321,322}, {331,332} }, 
           { {411,412}, {421,422}, {431,432} }  
         };

这是四行三列的对(如您所见,当尺寸增加时,很明显术语在很大程度上是任意的)。

您可以使用相同的语法访问项目:

    cout << four_by_three_by_two[3][2][0] << endl; // prints 431

就内存布局而言,编译器以下列方式解释语法。第一个索引以六个整数(3 * 2)的块来确定从数组开始的偏移量,第二个索引以两个整数的块来指示这六个整数之一本身内的偏移量,第三个索引是以单个整数表示的偏移量。因此[3][2][0]从一开始就是 (3 * 6) + (2 * 2) + 0 = 22 整数,将第一个整数视为索引零。

多维数组被视为数组的数组,所以每个“行”的类型是int[3][2],从声明中我们知道有四个。

将多维数组传递给函数

您可以将多维数组传递给函数:

    // pass the torque of the wheel nuts of all wheels 
    bool safe_torques(double nut_torques[4][5]);

这将编译,您可以以 4x5 数组的形式访问该参数,假设该车辆有四个车轮,每个车轮上有五个螺母。

如前所述,当您传递数组时,第一维将被视为指针,因此,虽然您可以将 4x5 数组传递给此函数,但您也可以传递 2x5 数组,编译器不会抱怨。但是,如果您传递一个 4x3 数组(也就是说,第二个维度与函数中声明的维度不同),编译器将发出一个数组不兼容的错误。该参数可以更准确地描述为double row[][5]。由于第一个维度的大小不可用,函数应该用该维度的大小声明:

    bool safe_torques(double nut_torques[][5], int num_wheels);

这说明nut_torques是一个或多个“行”,每个“行”有五个项目。由于数组不提供关于其行数的信息,所以您应该提供它。另一种声明方式是:

    bool safe_torques(double (*nut_torques)[5], int num_wheels);

括号在这里很重要,如果省略使用double *nut_torques[5],那么意味着*会引用数组中的类型,也就是编译器会把nut_torques当作double*指针的五元数组。我们以前见过这样一个数组的例子:

    void main(int argc, char *argv[]);

argv参数是一组char*指针。也可以将argv参数声明为char**,意义相同。

通常,如果您打算将数组传递给函数,最好使用自定义类型,或者使用 C++ 数组类型。

对多维数组使用 ranged for比第一眼看到的要复杂一点,需要使用引用,这将在本章后面的章节中解释。

使用字符数组

字符串将在第 6 章使用字符串中详细介绍,但这里值得指出的是,C 字符串是字符数组,通过指针变量访问。这意味着,如果您想要操纵字符串,您必须操纵指针指向的内存,而不是操纵指针本身。

比较字符串

下面分配两个字符串缓冲区,并调用strcpy_s函数用相同的字符串初始化每个缓冲区:

    char p1[6]; 
    strcpy_s(p1, 6, "hello"); 
    char p2[6]; 
    strcpy_s(p2, 6, p1); 
    bool b = (p1 == p2);

strcpy_c函数将把最后一个参数给出的指针中的字符(直到终止的NUL)复制到第一个参数给出的缓冲区中,第二个参数给出了缓冲区的最大大小。这两个指针在最后一行进行比较,这将返回一个值false。问题是比较函数比较的是指针的值,而不是指针指向的内容。两个缓冲区有相同的字符串,但是指针不同,所以b将是false

比较字符串的正确方法是逐个字符地比较数据,看它们是否相等。C 运行时提供strcmp来逐个字符地比较两个字符串缓冲区,std::string类定义了一个名为compare的函数,该函数也将执行这样的比较;但是,请注意这些函数返回的值:

    string s1("string"); 
    string s2("string"); 
    int result = s1.compare(s2);

返回值不是表示两个字符串是否相同的bool类型;它是一个int。这些比较函数执行字典式比较,如果参数(本代码中的s2)字典式大于操作数(s1),则返回负值,如果操作数大于参数,则返回正数。如果两个字符串相同,函数返回 0。记住一个bool是 0 值的false,非零值的true。标准库为std::string==操作符提供了一个重载,所以这样写代码是安全的:

    if (s1 == s2) 
    { 
        cout << "strings are the same" << endl; 
    }

运算符将比较两个变量中包含的字符串。

防止缓冲区溢出

用于操作字符串的 C 运行时库因允许缓冲区溢出而臭名昭著。例如,strcpy函数将一个字符串复制到另一个字符串,您可以通过<cstring>头来访问这个字符串,它包含在<iostream>头中。你可能会想写这样的东西:

    char pHello[5];          // enough space for 5 characters 
    strcpy(pHello, "hello");

问题是strcpy将复制到的所有字符,包括终止的NULL字符,因此您将复制六个字符到一个只有五个的空格数组中。您可能从用户输入中获取一个字符串(例如,从网页上的文本框中获取),并认为您分配的数组足够大,但是恶意用户可能会故意提供一个过长的字符串,使其大于缓冲区,从而覆盖程序的其他部分。这样的缓冲区溢出导致了很多程序被黑客控制服务器,以至于 C 字符串函数都被更安全的版本取代。事实上,如果你想输入前面的代码,你会发现strcpy是可用的,但是 Visual C++ 编译器会发出一个错误:

error C4996: 'strcpy': This function or variable may be unsafe. 
Consider using strcpy_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.

如果您有使用strcpy的现有代码,并且您需要编译该代码,您可以在<cstring>之前定义符号:

    #define _CRT_SECURE_NO_WARNINGS 
    #include <iostream>

防止这个问题的最初尝试是调用strncpy,它将复制特定数量的字符:

    char pHello[5];             // enough space for 5 characters 
    strncpy(pHello, "hello", 5);

该功能最多复制五个字符,然后停止。问题是要复制的字符串有五个字符,因此结果不会是NULL终止。这个函数的安全版本有一个参数,可以用来表示目标缓冲区有多大:

    size_t size = sizeof(pHello)/sizeof(pHello[0]); 
    strncpy_s(pHello, size, "hello", 5);

在运行时,这仍然会引起问题。您已经告诉函数,缓冲区的大小是五个字符,它将确定这个大小不足以容纳您要求它复制的六个字符。更安全的字符串函数将调用一个名为约束处理程序的函数,默认版本将关闭程序,理由是缓冲区溢出意味着程序被破坏,而不是让程序安静地继续,缓冲区溢出导致问题。

C 运行时库字符串函数最初是为了返回函数的结果而编写的,现在更安全的版本会返回一个错误值。也可以告诉strncpy_s函数截断副本,而不是调用约束处理程序:

    strncpy_s(pHello, size, "hello", _TRUNCATE);

C++ string类保护您免受此类问题的影响。

在 C++ 中使用指针

指针在 C++ 中显然非常重要,但与任何强大的功能一样,也有问题和危险,因此值得指出一些主要问题。指针指向内存中的单个位置,指针的类型指示应该如何解释内存位置。你最多可以假设内存中那个位置的字节数是指针类型的大小。就这样。这意味着指针本身是不安全的。然而,在 C++ 中,它们是使您的进程中的代码能够访问大量数据的最快方法。

越界访问

当您分配一个缓冲区时,无论是在堆栈上还是在空闲存储区上,当您得到一个指针时,几乎没有什么可以阻止您访问尚未分配的内存,无论是在缓冲区位置之前还是之后。这意味着当您使用指针算法或数组上的索引访问时,您要仔细检查您不会访问超出界限的数据。有时错误可能不会立即显现:

    int arr[] { 1, 2, 3, 4 }; 
    for (int i = 0; i < 4; ++ i)  
    { 
        arr[i] += arr[i + 1]; // oops, what happens when i == 3? 
    }

当您使用索引时,您必须不断提醒自己数组是从零开始索引的,因此最高索引是数组的大小减 1。

指向解除分配内存的指针

这适用于堆栈上分配的内存和动态分配的内存。下面是一个写得不好的函数,它返回在函数堆栈上分配的字符串:

    char *get() 
    { 
        char c[] { "hello" };
        return c;
    }

前面的代码分配了一个六个字符的缓冲区,然后用字符串文字hello的五个字符和NULL终止字符初始化它。问题是,一旦函数完成,堆栈框架就会被拆除,这样内存就可以被重新使用,指针会指向其他东西可以使用的内存。这个错误是由糟糕的编程引起的,但是它可能没有这个例子中那么明显。如果函数使用几个指针并执行指针赋值,您可能不会立即注意到您已经返回了指向堆栈分配对象的指针。最好的做法是不要从函数返回原始指针,但是如果您确实想使用这种编程风格,请确保内存缓冲区是通过参数传入的(因此函数不拥有缓冲区),或者是动态分配的,并且您将所有权传递给调用方。

这就引出了另一个问题。如果您在指针上调用delete,然后在代码的后面,尝试访问指针,您将访问可能被其他变量使用的内存。为了缓解这个问题,你可以养成在删除时给null_ptr分配一个指针的习惯,并在使用指针前检查null_ptr。或者,您可以使用一个智能指针对象来实现这一点。智能指针将包含在第 4 章中。

转换指针

您可以输入指针,也可以输入void*指针。类型化指针将像访问指定类型一样访问内存(当您继承类时,这将产生有趣的结果,但这将留给第 4 章。因此,如果您将指针转换为不同的类型并取消引用它,内存将被视为包含转换类型。这样做很少有意义。void*指针不能被取消引用,所以你永远不能通过void*指针访问数据,要访问数据你必须转换指针。

void*指针类型的全部原因是它可以指向任何东西。一般来说,void*指针应该只在类型与函数无关时使用。例如,C malloc函数返回一个void*指针,因为该函数只是分配内存;它不在乎那段记忆会被用来做什么。

常数指针

指针可以被声明为const,这取决于您应用它的位置,意味着指针指向的内存通过指针是只读的,或者指针的值是只读的:

    char c[] { "hello" }; // c can be used as a pointer 
    *c = 'H';             // OK, can write thru the pointer 
    const char *ptc {c};  // pointer to constant 
    cout << ptc << endl;  // OK, can read the memory pointed to 
    *ptc =  'Y';          // cannot write to the memory 
    char *const cp {c};   // constant pointer 
    *cp = 'y';            // can write thru the pointer 
    cp++ ;                 // cannot point to anything else

这里,ptc是一个指向常量char的指针,也就是说,虽然你可以改变ptc指向的东西,你可以读取它指向的东西,但是你不能用它来改变记忆。另一方面,cp是一个常量指针,意味着你可以读写指针指向的内存,但不能改变它指向的位置。典型的做法是将const char*指针传递给函数,因为函数不知道字符串被分配到了哪里,也不知道缓冲区的大小(调用者可能会传递一个无法更改的文字)。请注意,没有const*运算符,因此char const*被视为const char*,一个指向常量缓冲区的指针。

您可以使用强制转换使指针成为常量、更改指针或移除指针。为了证明这一点,下面对const关键词做了一些相当无意义的改变:

    char c[] { "hello" }; 
    char *const cp1 { c }; // cannot point to any other memory 
    *cp1 = 'H';            // can change the memory 
    const char *ptc = const_cast<const char*>(cp1); 
    ptc++ ;                 // change where the pointer points to 
    char *const cp2 = const_cast<char *const>(ptc); 
    *cp2 = 'a';            // now points to Hallo

指针cp1cp2可以用来改变它们所指向的内存,但是一旦被赋值,它们都不能指向其他内存。第一个const_castconst的属性丢弃到一个指针上,该指针可以被更改为指向其他内存,但不能用于更改该内存,ptc。第二个const_cast去掉了ptcconst属性,这样就可以通过指针cp2改变记忆。

更改指向的类型

static_cast运算符用于通过编译时检查进行转换,而不是运行时检查,因此这意味着指针必须相关。void*指针可以转换成任何指针,所以下面的编译是有意义的:

    int *pi = static_cast<int*>(malloc(sizeof(int))); 
    *pi = 42; 
    cout << *pi << endl; 
    free(pi);

C malloc函数返回一个void*指针,所以你必须转换它才能使用内存。(当然,C++ new运算符不需要这样的强制转换。)内置类型对于static_cast在指针类型之间转换来说不够“相关”,所以不能使用static_castint*指针转换为char*指针,即使intchar都是整数类型。对于通过继承相关的自定义类型,可以使用static_cast强制转换指针,但是没有运行时检查强制转换是否正确。要进行运行时检查,您应该使用dynamic_cast,更多细节将在第 4 章中给出。

reinterpret_cast运算符是强制转换运算符中最灵活、最危险的,因为它将在任何指针类型之间转换,而无需任何类型检查。它本身就不安全。例如,下面的代码用文字初始化一个宽字符数组。阵列wc将有六个字符,hello后跟NULLwcout对象将wchar_t*指针解释为指向wchar_t字符串中第一个字符的指针,因此插入wc将打印该字符串(直到NUL的每个字符)。要获得实际的内存位置,您必须将指针转换为整数:

    wchar_t wc[] { L"hello" }; 
    wcout << wc << " is stored in memory at "; 
    wcout << hex; 
    wcout << reinterpret_cast<int>(wc) << endl;

同样,如果在wcout对象中插入一个wchar_t,它将打印字符,而不是数值。因此,为了打印出单个字符的代码,我们需要将指针转换为合适的整数指针。本代码假设shortwchar_t大小相同:

    wcout << "The characters are:" << endl; 
    short* ps = reinterpret_cast<short*>(wc); 
    do  
    {  
        wcout << *ps << endl;  
    } while (*ps++);

在代码中分配内存

C++ 定义了两个运算符newdelete,它们从空闲存储区分配内存并将内存释放回空闲存储区。

分配单个对象

new运算符与类型一起使用来分配内存,它将返回一个指向该内存的类型化指针:

    int *p = new int; // allocate memory for one int

new操作符将调用默认构造函数为其创建的每个对象定制类型(如第 4 章中所述)。内置类型没有构造函数,因此会发生类型初始化,这通常会将对象初始化为零(在本例中为零整数)。

通常,如果没有显式初始化,就不应该使用为内置类型分配的内存。事实上,在 Visual C++ 中,new运算符的调试版本将为每个字节将内存初始化为一个值0xcd,以在调试器中直观地提醒您尚未初始化内存。对于自定义类型,由类型的作者来初始化分配的内存。

重要的是,当您用完内存时,您要将它返回到空闲存储区,以便分配器可以重用它。您可以通过呼叫delete操作员来完成:

    delete p;

删除指针时,对象的析构函数被调用。对于内置类型,这没有任何作用。在你删除了一个指向nullptr的指针之后,初始化它是一个很好的做法,如果你在使用它之前使用检查指针值的惯例,这将保护你不使用被删除的指针。C++ 标准规定,如果删除一个值为nullptr的指针,delete运算符将无效。

C++ 允许您在调用new运算符时初始化一个值,有两种方式:

    int *p1 = new int (42); 
    int *p2 = new int {42};

对于自定义类型,new运算符将调用该类型的构造函数;对于内置类型,最终结果是相同的,并通过将该项初始化为提供的值来实现。您也可以使用初始化的列表语法,如前面代码的第二行所示。需要注意的是,初始化是指向的内存,而不是指针变量。

分配对象数组

您也可以使用new操作符在动态内存中创建对象数组。您可以通过在一对方括号中提供想要创建的项目数来实现这一点。下面的代码为两个整数分配内存:

    int *p = new int[2]; 
    p[0] = 1; 
    *(p + 1) = 2; 
    for (int i = 0; i < 2; ++ i) cout << p[i] << endl; 
    delete [] p;

运算符返回一个指向所分配类型的指针,您可以使用指针算术或数组索引来访问内存。您不能在new语句中初始化内存;您必须在创建缓冲区后这样做。当您使用new为多个对象创建缓冲区时,您必须使用适当版本的delete运算符:[]用于指示删除多个项目,并将调用每个对象的析构函数。重要的是,您要始终使用与用于创建指针的new版本相适应的正确版本的delete

自定义类型可以为单个对象定义自己的运算符new和运算符delete,也可以为对象数组定义运算符new[]和运算符delete[]。自定义类型作者可以使用这些为其对象使用自定义内存分配方案。

处理失败的分配

如果new运算符不能为对象分配内存,它将抛出std::bad_alloc异常,返回的指针将是nullptr。例外情况在第 7 章诊断和调试中有所介绍,因此这里只给出语法的简要概述。检查生产代码中分配内存的失败是很重要的。下面的代码展示了如何保护分配,以便您可以捕获std::bad_alloc异常并处理它:

    // VERY_BIG_NUMER is a constant defined elsewhere 
    int *pi; 
    try 
    { 
        pi = new int[VERY_BIG_NUMBER]; 
        // other code 
    } 
    catch(const std::bad_alloc& e)  
    {  
        cout << "cannot allocate" << endl;  
        return; 
    } 
    // use pointer 
    delete [] pi;

如果try块中的任何代码抛出异常控制,它将被传递到catch子句,忽略任何其他尚未执行的代码。catch子句检查异常对象的类型,如果它是正确的类型(在这种情况下是分配错误),它创建对该对象的引用,并将控制传递给catch块,并且异常引用的范围是该块。在这个例子中,代码只是打印一个错误,但是您可以使用它来采取措施,以确保内存分配失败不会影响后续代码。

使用新运算符的其他版本

此外,自定义类型可以定义放置操作符new,允许您为自定义new功能提供一个或多个参数。放置的语法new是通过括号提供放置字段。

new运算符的 C++ 标准库版本提供了一个可以将常量std::nothrow作为放置字段的版本。如果分配失败,此版本不会引发异常,相反,只能从返回指针的值来评估失败:

    int *pi = new (std::nothrow) int [VERY_BIG_NUMBER]; 
    if (nullptr == pi)  
    { 
        cout << "cannot allocate" << endl; 
    } 
    else 
    { 
        // use pointer 
        delete [] pi; 
    }

类型前的括号用于传递放置字段。如果在类型后使用括号,如果分配成功,这些括号将给出一个值来初始化对象。

记忆寿命

new分配的内存将保持有效,直到你调用delete。这意味着您可能有较长生命周期的内存,并且代码可能在代码中的各种函数之间传递。请考虑以下代码:

    int *p1 = new int(42); 
    int *p2 = do_something(p1); 
    delete p1; 
    p1 = nullptr; 
    // what about p2?

这段代码创建一个指针,并初始化它所指向的内存,然后将指针传递给函数,函数本身返回一个指针。由于不再需要p1指针,因此将其删除并分配给nullptr使其不能再次使用。这段代码看起来很好,但问题是如何处理函数返回的指针?假设该函数只是操纵指针指向的数据:

    int *do_something(int *p) 
    { 
        *p *= 10; 
        return p; 
    }

实际上,调用do_something会创建指针的副本,但不会创建它所指向的内容的副本。这意味着当p1指针被删除时,它所指向的内存不再可用,因此指针p2指向无效内存。

这个问题可以通过一种叫做资源获取是初始化 ( RAII )的机制来解决,这意味着使用 C++ 对象的特性来管理资源。C++ 中的 RAII 需要类,特别是复制构造函数和析构函数。智能指针类可以用来管理指针,这样当指针被复制时,它所指向的内存也会被复制。析构函数是当对象超出范围时自动调用的函数,因此智能指针可以使用它来释放内存。智能指针和析构函数将包含在第 4 章中。

视窗软件开发工具包和指针

从函数中返回指针有其固有的危险:内存的责任被传递给调用者,调用者必须确保内存被适当地取消分配,否则这可能会导致内存泄漏并导致相应的性能损失。在这一节中,我们将了解 Window 的软件开发工具包 ( SDK )提供访问内存缓冲区的一些方式,并学习一些 C++ 中使用的技术。

首先,值得指出的是,Windows SDK 中任何返回字符串或具有字符串参数的函数都将有两个版本。以A为后缀的版本表示函数使用 ANSI 字符串,W版本将使用宽字符串。出于讨论的目的,使用 ANSI 函数更容易。

GetCommandLineA功能有以下原型(考虑到 Windows SDK typedef):

    char * __stdcall GetCommandLine();

所有的窗口函数都被定义为使用__stdcall调用约定。通常,你会看到WINAPItypedef用于__stdcall呼叫惯例。

这个函数可以这样调用:

    //#include <windows.h>
    cout << GetCommandLineA() << endl;

请注意,我们没有努力释放返回的缓冲区。原因是指针指向了你生命过程中的记忆,所以你不应该释放它。的确,如果你要释放它,你会怎么做?您不能保证该函数是用您正在使用的相同编译器或相同库编写的,因此您不能使用 C++ delete运算符或 C free函数。

当一个函数返回一个缓冲区时,重要的是查阅文档,看看谁分配了缓冲区,谁应该释放它。

再比如GetEnvironmentStringsA:

    char * __stdcall GetEnvironmentStrings();

这也会返回一个指向缓冲区的指针,但是这一次文档很清楚,在使用缓冲区之后,您应该释放它。SDK 提供了一个名为FreeEnvironmentStrings的函数来实现这一点。缓冲区为每个环境变量包含一个形式为name=value的字符串,每个字符串以一个NUL字符结束。缓冲区中的最后一个字符串只是一个NUL字符,也就是说,缓冲区的末尾有两个NUL字符。这些功能可以这样使用:

    char *pBuf = GetEnvironmentStringsA(); 
    if (nullptr != pBuf) 
    { 
        char *pVar = pBuf; 
        while (*pVar) 
        { 
            cout << pVar << endl; 
            pVar += strlen(pVar) + 1; 
        } 

        FreeEnvironmentStringsA(pBuf); 
    }

strlen函数是 C 运行时库的一部分,它返回字符串的长度。您不需要知道GetEnvironmentStrings函数如何分配缓冲区,因为FreeEnvironmentStrings将调用正确的解除分配代码。

在某些情况下,开发人员有责任分配缓冲区。视窗软件开发工具包提供了一个名为GetEnvironmentVariable的函数来返回一个命名环境变量的值。当您调用这个函数时,您不知道环境变量是否被设置,或者它是否被设置,或者它的值有多大,所以这意味着您很可能必须分配一些内存。该功能的原型是:

    unsigned long __stdcall GetEnvironmentVariableA(const char *lpName,   
        char *lpBuffer, unsigned long nSize);

有两个参数是指向 C 字符串的指针。这里有一个问题,一个char*指针可能正在将中的传递给函数,或者它可能被用来传递一个缓冲区,以便将一个字符串从中返回*。你怎么知道char*指针是用来做什么的?*

完整的参数声明为您提供了一个线索。lpName指针被标记为const,因此函数不会改变它所指向的字符串;这意味着它是参数中的*。此参数用于传入要获取的环境变量的名称。另一个参数只是一个char*指针,因此它可以用来将一个字符串中传递给函数,或者将中传递出去,或者将中传递出去,将中传递出去。了解如何使用该参数的唯一方法是阅读文档。在这种情况下,它是一个参数;如果变量存在,函数将在lpBuffer中返回环境变量的值,或者如果变量不存在,函数将保持缓冲区不变,并将值返回 0。您有责任以您认为合适的任何方式分配该缓冲区,并且您可以在最后一个参数nSize中传递该缓冲区的大小。*

该函数的返回值有两个目的。它用于指示发生了错误(只有一个值 0,这意味着您必须调用GetLastError函数来获取错误),它还用于为您提供关于缓冲区的信息lpBuffer。如果函数成功,则返回值是复制到缓冲区中的字符数,不包括NULL终止字符。但是,如果函数确定缓冲区太小(它从nSize参数知道缓冲区的大小)而无法保存环境变量值,则不会发生复制,并且函数将返回所需的缓冲区大小,即环境变量中包含NULL终止符的字符数。

调用此函数的常见方法是调用它两次,首先使用大小为零的缓冲区,然后在再次调用它之前使用返回值分配缓冲区:

    unsigned long size = GetEnvironmentVariableA("PATH", nullptr, 0); 
    if (0 == size)  
    { 
        cout << "variable does not exist " << endl; 
    } 
    else 
    { 
        char *val = new char[size]; 
        if (GetEnvironmentVariableA("PATH", val, size) != 0) 
        { 
            cout << "PATH = ";
            cout << val << endl; 
        } 
        delete [] val; 
    }

一般来说,与所有库一样,您必须阅读文档来确定如何使用参数。窗口文档将告诉您指针参数是输入、输出还是输入/输出。它还会告诉您谁拥有内存,以及您是否有责任分配和/或释放内存。

每当您看到函数的指针参数时,请特别注意查看文档,了解指针的用途以及如何管理内存。

内存和 C++ 标准库

C++ 标准库提供了各种类,允许您操作对象集合。这些类被称为标准模板库 ( STL ),提供了将项目插入集合对象的标准方法,以及访问项目和遍历整个集合的方法(称为迭代器)。STL 定义了集合类,这些集合类被实现为队列、堆栈或具有随机访问的向量。这些类将在第 5 章中使用标准库容器进行深入讨论,因此在这一节中,我们将只讨论两个行为类似于 C++ 内置数组的类。

标准库阵列

c++ 标准库提供了两个容器,通过索引器对数据进行随机访问。这两个容器还允许您访问底层内存,并且由于它们保证了在内存中顺序且连续地存储项目,所以当您需要提供指向缓冲区的指针时,可以使用它们。这两种类型都是模板,这意味着您可以使用它们来保存内置类型和自定义类型。这两个采集类分别是arrayvector

使用基于堆栈的数组类

array类在<array>头文件中定义。该类允许您在堆栈上创建固定大小的数组,并且与内置数组一样,它们在运行时不能收缩或扩展。因为它们是在堆栈上分配的,所以在运行时不需要调用内存分配器,但是很明显,它们应该小于堆栈帧的大小。这意味着一个array是一个小物品阵列的好选择。array的大小必须在编译时知道,并作为模板参数传递:

    array<int, 4> arr { 1, 2, 3, 4 };

在这段代码中,尖括号(<>)中的第一个模板参数是数组中每个项目的类型,第二个参数是项目的数量。这段代码用一个初始化列表初始化数组,但是请注意,您仍然需要在模板中提供数组的大小。这个对象将像一个内置数组(或者任何标准库容器)一样工作,排列为for:

    for (int i : arr) cout << i << endl;

原因是array实现了该语法所需的beginend功能。您也可以使用索引来访问项目:

    for (int i = 0; i < arr.size(); ++ i) cout << arr[i] << endl;

size函数将返回数组的大小,方括号索引器对数组成员进行随机访问。您可以访问数组边界之外的内存,因此对于之前定义的有四个成员的数组,您可以访问arr[10]。这可能会导致运行时出现意外行为,甚至出现某种内存故障。为了防止这种情况,类提供了一个函数at,它将执行范围检查,如果索引超出范围,类将抛出 C++ 异常out_of_range

使用array对象的主要优点是,您可以获得编译时检查,以查看您是否无意中将该对象作为哑指针传递给了函数。考虑这个函数:

    void use_ten_ints(int*);

在运行时,函数不知道传递给它的缓冲区的大小,在这种情况下,文档中说您必须传递一个具有 10 个int类型变量的缓冲区,但是,正如我们所看到的,C++ 允许使用一个内置数组作为指针:

    int arr1[] { 1, 2, 3, 4 }; 
    use_ten_ints(arr1); // oops will read past the end of the buffer

没有编译器检查,也没有任何运行时检查来捕获此错误。array类不会允许这样的错误发生,因为没有自动转换成哑指针:

    array<int, 4> arr2 { 1, 2, 3, 4 };  
    use_ten_ints(arr2); // will not compile

如果您真的坚持要获取一个哑指针,那么您可以这样做,并保证可以将数据作为连续的内存块进行访问,其中的项目是按顺序存储的:

    use_ten_ints(&arr2[0]);    // compiles, but on your head be it 
    use_ten_ints(arr2.data()); // ditto

该类不仅仅是内置数组的包装器,它还提供了一些附加功能。例如:

    array<int, 4> arr3; 
    arr3.fill(42);   // put 42 in each item 
    arr2.swap(arr3); // swap items in arr2 with items in arr3

使用动态分配的向量类

标准库还在<vector>头中提供了vector类。同样,这个类是一个模板,因此您可以将其与内置类型和自定义类型一起使用。然而,与array不同,内存是动态分配的,这意味着vector可以在运行时扩展或收缩。这些项目是连续存储的,因此您可以通过调用data函数或访问第一个项目的地址来访问底层缓冲区(为了支持调整集合的大小,缓冲区可能会发生变化,因此此类指针只能临时使用)。当然,和array一样,没有自动转换成哑指针。vector类提供带方括号语法的索引随机访问和at函数的范围检查。该类还实现了允许容器与标准库函数一起使用的方法,并设置了for

vector类比array类更灵活,因为你可以插入物品,移动物品,但这确实会带来一些开销。因为类的实例在运行时动态分配内存,所以使用分配器是有成本的,并且在初始化和销毁(当vector对象超出范围时)时会有一些额外的开销。vector类的对象占用的内存也比它保存的数据多。因此不适合少量物品(此时array是更好的选择)。

参考

引用是对象的别名。也就是说,它是对象的另一个名称,因此通过引用访问对象与通过对象的变量名访问对象是一样的。引用使用引用名称上的&符号来声明,其初始化和访问方式与变量完全相同:

    int i = 42; 
    int *pi = &i;  // pointer to an integer 
    int& ri1 = i;  // reference to a variable 
    i = 99;        // change the integer thru the variable 
    *pi = 101;     // change the integer thru the pointer 
    ri1 = -1;      // change the integer thru the reference 
    int& ri2 {i};  // another reference to the variable 
    int j = 1000; 
    pi = &j;       // point to another integer

在这段代码中,一个变量被声明和初始化,然后一个指针被初始化指向这个数据,一个引用被初始化为变量的别名。引用ri1用赋值运算符初始化,而引用ri2用初始化列表语法初始化。

The pointer and reference have two different meanings. The reference is not initialized to the value of the variable, the variable's data; it is an alias for the variable name.

无论在哪里使用变量,都可以使用引用;无论您对引用做什么,实际上都与对变量执行相同的操作相同。指针指向数据,因此您可以通过取消指针引用来更改数据,但同样,您也可以使指针指向任何数据,并通过取消指针引用来更改该数据(这在前面代码的最后两行中有说明)。一个变量可以有多个别名,每个别名都必须在声明时初始化为该变量。一旦声明,就不能使引用引用不同的对象。

以下代码不会编译:

    int& r1;           // error, must refer to a variable 
    int& r2 = nullptr; // error, must refer to a variable

因为引用是另一个变量的别名,所以如果没有初始化为变量,它就不能存在。同样,您不能将其初始化为除变量名之外的任何东西,因此不存在空引用的概念。

一旦初始化,引用就只是一个变量的别名。实际上,当您将引用用作任何运算符的操作数时,操作是在变量上执行的:

    int x = 1, y = 2;  
    int& rx = x; // declaration, means rx is an alias for x 
    rx = y;      // assignment, changes value of x to the value of y

在这段代码中,rx是变量x的别名,所以最后一行的赋值只是给x赋值y:赋值是对别名变量进行的。此外,如果你取一个引用的地址,你会得到它引用的变量的地址。虽然可以有对数组的引用,但不能有引用数组。

常量引用

到目前为止使用的引用允许您更改它是别名的变量,因此它具有左值语义。还有const左值引用,也就是对一个可以读,但不能写的对象的引用。

const指针一样,在左值引用上使用const关键字声明const引用。这基本上使引用成为只读的:您可以访问变量的数据来读取它,但不能更改它。

    int i = 42; 
    const int& ri = i; 
    ri = 99;           // error!

返回引用

有时一个对象会被传递给一个函数,而函数的语义是该对象应该被返回。这方面的一个例子是与流对象一起使用的<<运算符。对该操作员的呼叫是链式的:

    cout << "The value is " << 42;

这实际上是对名为operator<<的函数的一系列调用,一个使用const char*指针,另一个使用int参数。这些函数还有一个用于将要使用的流对象的ostream参数。然而,如果这仅仅是一个ostream参数,那么这将意味着该参数将被复制,并且插入将在该副本上执行。流对象通常使用缓冲,因此对流对象副本的更改可能不会产生预期的效果。此外,为了启用插入操作符的链接,插入函数将返回作为参数传递的流对象。目的是通过多个函数调用传递同一个流对象。如果这样的函数返回一个对象,那么它将是一个副本,这不仅意味着一系列的插入将涉及大量的副本,这些副本也将是临时的,因此对流的任何更改(例如,操纵器,如std::hex)都不会持续。为了解决这些问题,使用了引用。这种功能的典型原型是:

    ostream& operator<<(ostream& _Ostr, int _val);

显然,您必须小心返回引用,因为您必须确保对象生存期与引用持续的时间一样长。这个operator<<函数将返回在第一个参数中传递的引用,但是在下面的代码中,一个引用被返回到一个自动变量:

    string& hello() 
    { 
        string str ("hello"); 
        return str; // don't do this! 
    }   // str no longer exists at this point

在前面的代码中,string对象只和函数一样长,所以这个函数返回的引用会引用一个不存在的对象。当然,您可以返回对函数中声明的static变量的引用。

从函数中返回引用是一个常见的习惯用法,但是每当您考虑这样做时,请确保别名变量的生存期不在函数的范围内。

临时和参考文献

左值引用必须引用一个变量,但是 C++ 在引用堆栈上声明的const引用时有一些奇怪的规则。如果引用是const,编译器将在引用的生存期内延长临时的生存期。例如,如果使用初始化列表语法,编译器将创建一个临时的:

    const int& cri { 42 };

在这段代码中,编译器将创建一个临时的int并将其初始化为一个值,然后将其别名为cri引用(重要的是该引用是const)。当临时处于范围内时,可通过引用获得。这可能看起来有点奇怪,但是考虑在该函数中使用const引用:

    void use_string(const string& csr);

您可以使用string变量调用该函数,该变量将显式转换为string或使用string文字:

    string str { "hello" }; 
    use_string(str);      // a std::string object 
    const char *cstr = "hello"; 
    use_string(cstr);     // a C string can be converted to a std::string 
    use_string("hello");  // a literal can be converted to a std::string

在大多数情况下,您不会希望有一个对内置类型的const引用,但是对于定制类型来说,复制会有开销,这是一个优势,正如您在这里看到的,如果需要,编译器将返回到创建临时的。

右值引用

C++ 11 定义了一种新的引用类型,右值引用。在 C++ 11 之前,代码(比如赋值运算符)无法判断传递给它的右值是否是临时对象。如果这样的函数被传递了一个对象的引用,那么函数必须小心不要改变引用,因为这会影响它所引用的对象。如果引用的是一个临时对象,那么函数可以对临时对象做它喜欢做的事情,因为在函数完成后,对象将不会存在。C++ 11 允许您专门为临时对象编写代码,因此在赋值的情况下,临时对象的操作符只需将数据从临时对象移动到正在被赋值的对象中。相比之下,如果引用不是临时对象,则数据必须被复制*。如果数据很大,这就防止了潜在的昂贵的分配和复制。这使得所谓的移动语义成为可能。*

考虑一下这段相当做作的代码:

    string global{ "global" }; 

    string& get_global() 
    { 
        return global; 
    } 

    string& get_static() 
    { 
        static string str { "static" }; 
        return str; 
    } 

    string get_temp() 
    { 
        return "temp"; 
    }

这三个函数返回一个string对象。在前两种情况下,string具有程序的生存期,因此可以返回一个引用。在最后一个函数中,该函数返回一个字符串,因此构建了一个临时的string对象。这三个都可以用来提供string值。例如:

    cout << get_global() << endl; 
    cout << get_static() << endl; 
    cout << get_temp() << endl;

这三个都可以提供一个字符串,用于分配一个string对象。重要的一点是,前两个函数沿着一个活动对象返回,但是第三个函数返回一个临时对象,但是这些对象可以被相同地使用。

如果这些函数返回了对一个大对象的访问,您就不会希望将该对象传递给另一个函数,因此,在大多数情况下,您会希望将这些函数返回的对象作为引用传递。例如:

    void use_string(string& rs);

引用参数阻止字符串的另一个副本。然而,这只是故事的一半。use_string功能可以操纵字符串。例如,下面的函数根据参数创建了一个新的string,但是用下划线替换了字母 A、B 和 O(表示没有这些字母的单词中的空格,复制了没有血型 A、B 和 O 的人的生活)。一个简单的实现如下所示:

    void use_string(string& rs) 
    { 
        string s { rs }; 
        for (size_t i = 0; i < s.length(); ++ i) 
        { 
            if ('a' == s[i] || 'b' == s[i] || 'o' == s[i])  
            s[i] = '_'; 
        } 
        cout << s << endl; 
    }

字符串对象有一个索引操作符([]),因此您可以将其视为一个字符数组,既可以读取字符的值,也可以为字符位置赋值。string的大小通过length功能获得,该功能返回一个unsigned int ( typedefsize_t)。由于参数是一个引用,这意味着对string的任何更改都将反映在传递给函数的string中。这段代码的目的是保持其他变量不变,所以它首先复制了参数。然后在副本上,代码遍历所有字符,在打印结果之前将abo字符改为下划线。

这个代码显然有一个复制开销——从引用创建stringsrs;但是,如果我们想将类似于get_globalget_static的字符串传递给这个函数,这是必要的,因为否则将对实际的全局变量和static变量进行更改。

但是get_temp返回的临时string是另外一种情况。这个临时对象只存在于调用get_temp的语句结束之前。因此,在知道变量不会影响其他任何东西的情况下,可以对其进行更改。这意味着您可以使用移动语义:

    void use_string(string&& s) 
    { 
        for (size_t i = 0; i < s.length(); ++ i) 
        { 
            if ('a' == s[i] || 'b' == s[i] || 'o' == s[i]) s[i] = '_'; 
        } 
        cout << s << endl; 
    }

这里只有两个变化。首先,使用类型的&&后缀将参数标识为右值引用。另一个变化是对引用的对象进行了更改,因为我们知道它是临时的,更改将被丢弃,所以它不会影响其他变量。请注意,现在有两个函数,重载具有相同的名称:一个带有左值引用,一个带有右值引用。当您调用此函数时,编译器将根据传递给它的参数调用正确的函数:

    use_string(get_global()); // string&  version 
    use_string(get_static()); // string&  version 
    use_string(get_temp());   // string&& version 
    use_string("C string");   // string&& version 
    string str{"C++ string"}; 
    use_string(str);          // string&  version

回想一下get_globalget_static返回对将在程序生命周期中存在的对象的引用,因此编译器选择采用左值引用的use_string版本。更改是在函数中的临时变量上进行的,这有一个复制开销。get_temp返回一个临时对象,因此编译器调用接受右值引用的use_string的重载。这个函数会改变引用所引用的对象,但这并不重要,因为对象不会超过行尾的分号。用类似 C 的字符串文字调用use_string也是如此:编译器将创建一个临时的string对象,并调用具有右值引用参数的重载。在这段代码的最后一个例子中,在堆栈上创建了一个 C++ string对象,并将其传递给use_string

编译器发现这个对象是一个左值,并且有可能被改变,所以它调用重载,该重载接受左值引用,该引用以只改变函数中临时局部变量的方式实现。

此示例显示,C++ 编译器将检测参数何时是临时对象,并将使用右值引用调用重载。通常,在编写复制构造函数(用于从现有实例创建新的自定义类型的特殊函数)和赋值运算符时使用该工具,以便这些函数可以实现左值引用重载来从参数复制数据,右值引用重载来将数据从临时对象移动到新对象。其他用途是编写自定义类型,即仅移动,在这里它们使用不可复制的资源,例如文件句柄。

范围为和参考文献

作为一个你可以用引用做什么的例子,值得看看 C++ 11 中的远程for工具。下面的代码非常简单;数组squares用 0 到 4 的平方初始化:

    constexpr int size = 4; 
    int squares[size]; 

    for (int i = 0; i < size; ++ i) 
    { 
        squares[i] = i * i; 
    }

编译器知道数组的大小,所以你可以使用 ranged for打印出数组中的值。在下文中,在每次迭代中,局部变量j是数组中该项的副本。作为副本,这意味着您可以读取该值,但是对变量所做的任何更改都不会反映到数组中。因此,下面的代码工作正常;它打印出数组的内容:

    for (int j : squares) 
    { 
        cout << J << endl; 
    }

如果您想要更改数组中的值,那么您必须能够访问实际值,而不是副本。在范围for中这样做的方法是使用一个引用作为循环变量:

    for (int& k : squares) 
    { 
        k *= 2; 
    }

现在,在每次迭代中,k变量是数组中实际成员的别名,所以无论您对k变量做什么,实际上都是在数组成员上执行的。在这个例子中,squares数组的每个成员都乘以 2。您不能将int*用于k的类型,因为编译器看到数组中项目的类型是int,并将此用作范围内的for的循环变量。由于引用是变量的别名,编译器将允许引用作为循环变量,此外,由于引用是别名,您可以使用它来更改实际的数组成员。

对于多维数组来说,Ranged for变得很有趣。例如,在下面,声明了一个二维数组,并尝试使用auto变量使用嵌套循环:

    int arr[2][3] { { 2, 3, 4 }, { 5, 6, 7} };   
    for (auto row : arr) 
    { 
        for (auto col : row) // will not compile
        { 
            cout << col << " " << endl; 
        } 
    }

由于二维数组是数组的数组(每一行都是一维数组),目的是在外循环中获得每一行,然后在内循环中访问该行中的每一项。这种方法有几个问题,但直接的问题是这段代码无法编译。

编译器会抱怨内部循环,说找不到类型int*beginend函数。原因是 ranged for使用迭代器对象,而对于数组,它使用 C++ 标准库函数beginend,来创建这些对象。编译器将从外部范围的arr数组中看到每个项目都是一个int[3]数组,因此在外部for循环中,循环变量将是每个元素的副本,在本例中是一个int[3]数组。你不能像这样复制数组,所以编译器会提供一个指向第一个元素的指针,一个int*,这个在内部for循环中使用。

编译器将尝试获取int*的迭代器,但这是不可能的,因为int*不包含它指向多少项的信息。有一个版本的beginend是为int[3](和所有尺寸的阵列)定义的,但不是为int*定义的。

一个简单的改变就可以编译这段代码。只需将row变量转换为引用即可:

    for (auto& row : arr) 
    { 
        for (auto col : row) 
        { 
            cout << col << " " << endl; 
        } 
    }

参考参数表示别名用于int[3]数组,当然,别名与元素相同。使用auto隐藏了实际情况的丑陋。当然,内部循环变量是一个int,因为这是数组中项目的类型。外环变量实际上是int (&)[3]。也就是说,它引用了一个int[3](括号用来表示它引用了一个int[3]而不是一个int&数组)。

在实践中使用指针

一个常见的要求是拥有一个可以是任意大小的集合,并且可以在运行时增长和收缩。C++ 标准库提供了各种类来允许您这样做,这将在第 5 章使用标准库容器中描述。以下示例说明了如何实现这些标准集合的一些原则。一般来说,您应该使用 C++ 标准库类,而不是实现自己的类。此外,标准库类代码封装在一个类中,由于我们还没有涉及类,下面的代码将使用可能被错误调用的函数。所以,你应该把这个例子看作是示例代码。链表是一种常见的数据结构。这些通常用于项目顺序很重要的队列。例如,先进先出队列,其中任务按插入队列的顺序执行。在此示例中,每个任务都表示为一个结构,该结构包含任务描述和指向要执行的下一个任务的指针。

如果指向下一个任务的指针是nullptr,那么这意味着当前任务是列表中的最后一个任务:

    struct task 
    { 
        task* pNext; 
        string description; 
    };

您可以通过实例使用点运算符访问结构的成员:

    task item; 
    item.descrription = "do something";

在这种情况下,编译器将创建一个用字符串do something初始化的string对象,并将其分配给名为item的实例的description成员。您也可以使用new操作符在免费商店创建一个task:

    task* pTask = new task; 
    // use the object 
    delete pTask;

在这种情况下,必须通过指针来访问对象的成员,C++ 提供了->操作符来为您提供这种访问:

    task* pTask = new task; 
    pTask->descrription = "do something"; 
    // use the object 
    delete pTask;

这里description成员被分配给字符串。请注意,由于task是一个结构,因此没有访问限制,这一点对于类很重要,并在第 4 章中进行了描述。

创建项目

C:\Beginning_C++ 下创建一个名为Chapter_04的新文件夹。启动 Visual C++ 并创建一个 C++ 源文件,并将其保存到您刚刚创建的文件夹中,作为tasks.cpp。增加一个没有参数的简单main函数,使用 C++ 流提供输入输出支持:

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

    int main() 
    {
    }

main函数上方,为列表中代表任务的结构添加一个定义:

    using namespace std;  
 struct task { task* pNext; string description; };

这有两个成员。物体的内脏是description项。在我们的例子中,执行一个任务将涉及到将description项打印到控制台。在实际项目中,您很可能会有许多与任务相关联的数据项,您甚至可能有成员函数来执行任务,但是我们还没有涉及成员函数;这是第四章的话题。

链表的管道是另一个成员,pNext。请注意,task结构在pNext成员声明时尚未完全定义。这不是问题,因为pNext指针。不能有未定义或部分定义类型的数据成员,因为编译器不知道要为其分配多少内存。您可以让指针成员指向部分定义的类型,因为无论指针成员指向什么,其大小都是相同的。

如果我们知道列表中的第一个链接,那么我们就可以访问整个列表,在我们的示例中,这将是一个全局变量。当构造列表时,构造函数需要知道列表的结尾,以便它们可以向列表附加新的链接。同样,为了方便起见,我们将把它作为一个全局变量。在task结构的定义后添加以下指针:

 task* pHead = nullptr; task* pCurrent = nullptr;  
    int main() 
    {
    }

就目前的情况来看,代码没有任何作用,但是这是一个编译文件来测试没有错别字的好机会:

cl /EHsc tasks.cpp

向列表中添加任务对象

提供代码的下一步是向任务列表中添加一个新任务。这需要创建一个新的task对象,并对其进行适当的初始化,然后通过改变列表中的最后一个链接来将其添加到列表中,以指向新的链接。

main功能之上,增加以下功能:

    void queue_task(const string& name) 
    { 
        ...
    }

该参数是一个const引用,因为我们不会更改该参数,也不希望产生复制开销。该函数必须做的第一件事是创建一个新链接,因此添加以下行:

    void queue_task(const string& name) 
    { 
 task* pTask = new task; pTask->description = name; pTask->pNext = nullptr; 
    }

第一行在自由存储上创建一个新的链接,接下来的几行初始化它。这不一定是初始化这样一个对象的最佳方式,更好的机制,构造器,将在第 4 章中介绍。注意pNext项初始化为nullptr;这表示链接将位于列表的末尾。

这个函数的最后一部分将链接添加到列表中,也就是说,它使链接成为列表中的最后一个。但是,如果列表为空,则表示该链接也是列表中的第一个链接。代码必须执行这两个操作。在函数末尾添加以下代码:

    if (nullptr == pHead) 
    { 
        pHead = pTask; 
        pCurrent = pTask; 
    } 
    else 
    { 
        pCurrent->pNext = pTask; 
        pCurrent = pTask; 
    }

第一行检查列表是否为空。如果pHeadnullptr,则表示没有其他链接,因此当前链接是第一个链接,因此pHeadpCurrent都被初始化为新的链接指针。如果列表中存在现有链接,则必须将该链接添加到最后一个链接,因此在else子句中,第一行使最后一个链接指向新链接,第二行用新链接指针初始化pCurrent,使新链接成为列表中任何新插入的最后一个链接。

通过在main函数中调用该函数,项目被添加到列表中。在本例中,我们将对任务进行排队,为房间贴壁纸。这包括去除旧墙纸,填充墙上的任何洞,调整墙壁的大小(用稀释的浆糊涂在墙上,使墙壁变粘),然后将粘贴的墙纸挂在墙上。您必须按此顺序执行这些任务,不能更改顺序,因此这些任务非常适合链接列表。在main功能中添加以下行:

    queue_task("remove old wallpaper"); 
    queue_task("fill holes"); 
    queue_task("size walls"); 
    queue_task("hang new wallpaper");

在最后一行之后,列表已经创建。pHead变量指向列表中的第一个项目,您可以通过跟随pNext成员从一个链接到下一个链接来访问列表中的任何其他项目。

您可以编译代码,但是没有输出。更糟糕的是,从代码来看,存在内存泄漏。该程序没有代码来deletenew操作者在自由存储上创建的task对象所占用的内存。

删除任务列表

遍历列表很简单,你跟随pNext指针从一个链接到下一个链接。在此之前,让我们先修复上一节中介绍的内存泄漏。在main功能之上,增加以下功能:

    bool remove_head() 
    { 
        if (nullptr == pHead) return false; 
        task* pTask = pHead; 
        pHead = pHead->pNext; 
        delete pTask; 
        return (pHead != nullptr); 
    }

该功能将删除列表开头的链接,并确保pHead指针指向下一个链接,该链接将成为列表的新开头。该函数返回一个bool值,指示列表中是否还有其他链接。如果此函数返回false,则表示整个列表已被删除。

第一行检查是否用空列表调用了这个函数。一旦我们确信列表至少有一个链接,我们就创建这个指针的临时副本。原因是意图是删除第一项并使pHead指向下一项,而要做到这一点,我们必须反过来做那些步骤:使pHead指向下一项,然后删除pHead之前指向的项。

要删除整个列表,您需要遍历链接,这可以使用while循环来实现。在remove_head功能下方,添加以下内容:

    void destroy_list() 
    { 
        while (remove_head()); 
    }

要删除整个列表,并解决内存泄漏问题,请在主函数的底部添加以下行

 destroy_list(); 
    }

现在,您可以编译代码并运行它。但是,您将看不到任何输出,因为所有代码都是创建一个列表,然后删除它。

迭代任务列表

下一步是从每个pNext指针后面的第一个链接开始迭代列表,直到我们到达列表的末尾。对于访问的每个链接,都应该执行任务。首先编写一个函数,通过打印出任务的描述,然后返回一个指向下一个任务的指针来执行任务。在main功能的正上方,添加以下代码:

    task *execute_task(const task* pTask) 
    { 
        if (nullptr == pTask) return nullptr; 
        cout << "executing " << pTask->description << endl; 
        return pTask->pNext; 
    }

这里的参数标记为const,因为我们不会改变指针指向的task对象。这向编译器表明,如果代码试图更改对象,就会出现问题。第一行检查以确保函数没有用空指针调用。如果是,那么下面的行将取消引用无效指针,并导致内存访问错误。最后一行返回指向下一个链接的指针(列表中最后一个链接可以是nullptr),这样就可以在循环中调用该函数。在此函数之后,添加以下内容来迭代整个列表:

    void execute_all() 
    { 
        task* pTask = pHead; 
        while (pTask != nullptr) 
        { 
            pTask = execute_task(pTask); 
        } 
    }

这段代码从开头pHead开始,在列表中的每个链接上调用execute_task,直到函数返回一个nullptr。在main函数的末尾添加对该函数的调用:

 execute_all(); 
        destroy_list(); 
    }

现在,您可以编译并运行代码。结果将是:

    executing remove old wallpaper
executing fill holes
 executing size walls executing hang new wallpaper

插入项目

链接列表的一个优点是,您可以通过只分配一个新项目并更改指向它的适当指针,并使其指向列表中的下一个项目,来将项目插入列表。这与分配一组task对象形成对比;如果你想在中间的某个地方插入一个新的项目,你必须为旧项目和新项目分配一个足够大的新数组,然后将旧项目复制到新数组中,在正确的位置复制新项目。

墙纸任务清单的问题是房间里有一些油漆过的木头,正如任何装饰师所知,最好在挂墙纸之前,通常是在给墙壁定尺寸之前,油漆一下木制品。我们需要在填充任何孔洞和确定墙壁尺寸之间插入一项新任务。此外,在你做任何装饰之前,你应该在做任何其他事情之前覆盖房间里的任何家具,所以你需要在开始时添加一个新任务。

第一步是找到我们新任务的位置,油漆木制品。我们将在插入任务之前寻找我们想要的任务。在main前增加以下内容:

    task *find_task(const string& name) 
    { 
        task* pTask = pHead; 

        while (nullptr != pTask) 
        { 
            if (name == pTask->description) return pTask; 
            pTask = pTask->pNext; 
        }  
        return nullptr; 
    }

该代码在整个列表中搜索与参数匹配的description链接。这是通过使用string比较运算符的循环来实现的,如果找到了所需的链接,将返回指向该链接的指针。如果比较失败,循环将循环变量初始化为下一个链接的地址,如果这个地址是nullptr,则意味着所需的任务不在列表中。

在主功能中创建列表后,添加以下代码搜索fill holes任务:

    queue_task("hang new wallpaper"); 

 // oops, forgot to paint woodworktask
    * pTask = find_task("fill holes"); if (nullptr != pTask) { // insert new item after pTask } 
    execute_all();

如果find_task函数返回一个有效的指针,那么我们可以在这一点上添加一个项目。

这样做的功能将允许您在您传递给它的列表中的任何项目后添加一个新项目,如果您传递nullptr,它将在开头添加新项目。它叫做insert_after,但是很明显,如果你经过nullptr它也意味着在开头之前插入。在main功能的正上方添加以下内容:

    void insert_after(task* pTask, const string& name) 
    { 
        task* pNewTask = new task; 
        pNewTask->description = name; 
        if (nullptr != pTask) 
        { 
            pNewTask->pNext = pTask->pNext; 
            pTask->pNext = pNewTask; 
        } 
    }

第二个参数是const引用,因为我们不会改变string,但是第一个参数不是const指针,因为我们将改变它所指向的对象。该函数创建一个新的task对象,并将description成员初始化为新的任务名称。然后检查传递给函数的task指针是否为空。如果不是,则可以在列表中的指定链接后插入新项目*。为此,新链接pNext成员被初始化为列表中的下一项,并且先前链接的pNext成员被初始化为新链接的地址。*

*当传递函数nullptr作为后面要插入的项时,在开头插入一个项怎么样?增加以下else条款。

    void insert_after(task* pTask, const string& name) 
    { 
        task* pNewTask = new task; 
        pNewTask->description = name; 
        if (nullptr != pTask) 
        { 
            pNewTask->pNext = pTask->pNext; 
            pTask->pNext = pNewTask; 
        } 
        else { pNewTask->pNext = pHead; pHead = pNewTask; } 
    }

在这里,我们使新项目的pNext成员指向列表的旧开头,然后将pHead更改为指向新项目。

现在,在main功能中,您可以添加一个调用来插入一个新任务来油漆木制品,由于我们也忘记了指出最好在用防尘布覆盖所有家具后装饰房间,所以在列表中添加一个任务来首先完成该任务:

    task* pTask = find_task("fill holes"); 
    if (nullptr != pTask) 
    { 
 insert_after(pTask, "paint woodwork"); 
    } 
 insert_after(nullptr, "cover furniture");

现在可以编译代码了。当您运行代码时,您应该看到按要求的顺序执行的任务:

 executing cover furniture executing remove old wallpaper
executing fill holes
executing paint woodwork
executing size walls
executing hang new wallpaper

摘要

可以说,使用 C++ 的主要原因之一是您可以使用指针直接访问内存。这是大多数其他语言的程序员无法做到的一个特性。这意味着作为一名 C++ 程序员,你是一种特殊类型的程序员:一个被信任有记忆的人。在本章中,您已经看到了如何获取和使用指针,以及一些不恰当地使用指针会使您的代码严重出错的例子。

在下一章中,我们将讨论函数,其中将包括另一种类型指针的描述:函数指针。如果你被数据指针和函数指针所信任,你真的是一个特殊类型的程序员。**