返回介绍

9.2 存储持续性、作用域和链接性

发布于 2024-10-08 23:14:07 字数 26117 浏览 0 评论 0 收藏 0

介绍过多文件程序后,接下来扩展第 4 章对内存方案的讨论,即存储类别如何影响信息在文件间的共享。现在读者阅读第 4 章已经有一段时间了,因此先复习一下有关内存的知识。C++使用三种(在 C++11 中是四种)不同的方案来存储数据,这些方案的区别就在于数据保留在内存中的时间。

  • 自动存储持续性:在函数定义中声明的变量(包括函数参数)的存储持续性为自动的。它们在程序开始执行其所属的函数或代码块时被创建,在执行完函数或代码块时,它们使用的内存被释放。C++有两种存储持续性为自动的变量。
  • 静态存储持续性:在函数定义外定义的变量和使用关键字 static 定义的变量的存储持续性都为静态。它们在程序整个运行过程中都存在。C++有 3 种存储持续性为静态的变量。
  • 线程存储持续性(C++11):当前,多核处理器很常见,这些 CPU 可同时处理多个执行任务。这让程序能够将计算放在可并行处理的不同线程中。如果变量是使用关键字 thread_local 声明的,则其生命周期与所属的线程一样长。本书不探讨并行编程。
  • 动态存储持续性:用 new 运算符分配的内存将一直存在,直到使用 delete 运算符将其释放或程序结束为止。这种内存的存储持续性为动态,有时被称为自由存储(free store)或堆(heap)。

下面介绍其他内容,包括关于各种变量何时在作用域内或可见(可被程序使用)以及链接性的细节。链接性决定了哪些信息可在文件间共享。

9.2.1 作用域和链接

作用域(scope)描述了名称在文件(翻译单元)的多大范围内可见。例如,函数中定义的变量可在该函数中使用,但不能在其他函数中使用;而在文件中的函数定义之前定义的变量则可在所有函数中使用。链接性(linkage)描述了名称如何在不同单元间共享。链接性为外部的名称可在文件间共享,链接性为内部的名称只能由一个文件中的函数共享。自动变量的名称没有链接性,因为它们不能共享。

C++变量的作用域有多种。作用域为局部的变量只在定义它的代码块中可用。代码块是由花括号括起的一系列语句。例如函数体就是代码块,但可以在函数体中嵌入其他代码块。作用域为全局(也叫文件作用域)的变量在定义位置到文件结尾之间都可用。自动变量的作用域为局部,静态变量的作用域是全局还是局部取决于它是如何被定义的。在函数原型作用域(function prototype scope)中使用的名称只在包含参数列表的括号内可用(这就是为什么这些名称是什么以及是否出现都不重要的原因)。在类中声明的成员的作用域为整个类(参见第 10 章)。在名称空间中声明的变量的作用域为整个名称空间(由于名称空间已经引入到 C++语言中,因此全局作用域是名称空间作用域的特例)。

C++函数的作用域可以是整个类或整个名称空间(包括全局的),但不能是局部的(因为不能在代码块内定义函数,如果函数的作用域为局部,则只对它自己是可见的,因此不能被其他函数调用。这样的函数将无法运行)。

不同的 C++存储方式是通过存储持续性、作用域和链接性来描述的。下面来看看各种 C++存储方式的这些特征。首先介绍引入名称空间之前的情况,然后看一看名称空间带来的影响。

9.2.2 自动存储持续性

在默认情况下,在函数中声明的函数参数和变量的存储持续性为自动,作用域为局部,没有链接性。也就是说,如果在 main( ) 中声明了一个名为 texas 的变量,并在函数 oil( ) 中也声明了一个名为 texas 变量,则创建了两个独立的变量——只有在定义它们的函数中才能使用它们。对 oil( ) 中的 texas 执行的任何操作都不会影响 main( ) 中的 texas,反之亦然。另外,当程序开始执行这些变量所属的代码块时,将为其分配内存;当函数结束时,这些变量都将消失(注意,执行到代码块时,将为变量分配内存,但其作用域的起点为其声明位置)。

如果在代码块中定义了变量,则该变量的存在时间和作用域将被限制在该代码块内。例如,假设在 main( ) 的开头定义了一个名为 teledeli 的变量,然后在 main( ) 中开始一个新的代码块,并其中定义了一个新的变量 websight,则 teledeli 在内部代码块和外部代码块中都是可见的,而 websight 就只在内部代码块中可见,它的作用域是从定义它的位置到该代码块的结尾:

然而,如果将内部代码块中的变量命名为 teledeli,而不是 websight,使得有两个同名的变量(一个位于外部代码块中,另一个位于内部代码块中),情况将如何呢?在这种情况下,程序执行内部代码块中的语句时,将 teledeli 解释为局部代码块变量。我们说,新的定义隐藏了(hide)以前的定义,新定义可见,旧定义暂时不可见。在程序离开该代码块时,原来的定义又重新可见(参见图 9.2)。

图 9.2 代码块和作用域

程序清单 9.4 表明,自动变量只在包含它们的函数或代码块中可见。

程序清单 9.4 auto.cpp

下面是该程序的输出:

在程序清单 9.4 中,3 个 texas 变量的地址各不相同,而程序使用当前可见的那个变量,因此将 113 赋给 oil( ) 中的内部代码块中的 texas,对其他同名变量没有影响。同样,实际的地址值和地址格式随系统而异。

现在总结一下整个过程。执行到 main( ) 时,程序为 texas 和 year 分配空间,使得这些变量可见。当程序调用 oil( ) 时,这些变量仍留在内存中,但不可见。为两个新变量(x 和 texas)分配内存,从而使它们可见。在程序执行到 oil( ) 中的内部代码块时,新的 texas 将不可见,它被一个更新的定义代替。然而,变量 x 仍然可见,这是因为该代码块没有定义 x 变量。当程序流程离开该代码块时,将释放最新的 texas 使用的内存,而第二个 texas 再次可见。当 oil( ) 函数结束时,texas 和 x 都将过期,而最初的 texas 和 year 再次变得可见。

使用 C++11 中的 auto

在 C++11 中,关键字 auto 用于自动类型推断,这在第 3、7 和 8 章介绍过。但在 C 语言和以前的 C++版本中,auto 的含义截然不同,它用于显式地指出变量为自动存储:

由于只能将关键字 auto 用于默认为自动的变量,因此程序员几乎不使用它。它的主要用途是指出当前变量为局部自动变量。

在 C++11 中,这种用法不再合法。制定标准的人不愿引入新关键字,因为这样做可能导致将该关键字用于其他目的的代码非法。考虑到 auto 的老用法很少使用,因此赋予其新含义比引入新关键字是更好的选择。

1.自动变量的初始化

可以使用任何在声明时其值为已知的表达式来初始化自动变量,下面的示例初始化变量 x、y 和 z:

2.自动变量和栈

了解典型的 C++编译器如何实现自动变量有助于更深入地了解自动变量。由于自动变量的数目随函数的开始和结束而增减,因此程序必须在运行时对自动变量进行管理。常用的方法是留出一段内存,并将其视为栈,以管理变量的增减。之所以被称为栈,是由于新数据被象征性地放在原有数据的上面(也就是说,在相邻的内存单元中,而不是在同一个内存单元中),当程序使用完后,将其从栈中删除。栈的默认长度取决于实现,但编译器通常提供改变栈长度的选项。程序使用两个指针来跟踪栈,一个指针指向栈底——栈的开始位置,另一个指针指向堆顶——下一个可用内存单元。当函数被调用时,其自动变量将被加入到栈中,栈顶指针指向变量后面的下一个可用的内存单元。函数结束时,栈顶指针被重置为函数被调用前的值,从而释放新变量使用的内存。

栈是 LIFO(后进先出)的,即最后加入到栈中的变量首先被弹出。这种设计简化了参数传递。函数调用将其参数的值放在栈顶,然后重新设置栈顶指针。被调用的函数根据其形参描述来确定每个参数的地址。例如,图 9.3 表明,函数 fib( ) 被调用时,传递一个 2 字节的 int 和一个 4 字节的 long。这些值被加入到栈中。当 fib( ) 开始执行时,它将名称 real 和 tell 同这两个值关联起来。当 fib( ) 结束时,栈顶指针重新指向以前的位置。新值没有被删除,但不再被标记,它们所占据的空间将被下一个将值加入到栈中的函数调用所使用(图 9.3 做了简化,因为函数调用可能传递其他信息,如返回地址)。

图 9.3 使用栈传递参数

3.寄存器变量

关键字 register 最初是由 C 语言引入的,它建议编译器使用 CPU 寄存器来存储自动变量:

这旨在提高访问变量的速度。

在 C++11 之前,这个关键字在 C++中的用法始终未变,只是随着硬件和编译器变得越来越复杂,这种提示表明变量用得很多,编译器可对其做特殊处理。在 C++11 中,这种提示作用也失去了,关键字 register 只是显式地指出变量是自动的。鉴于关键字 register 只能用于原本就是自动的变量,使用它的唯一原因是,指出程序员想使用一个自动变量,这个变量的名称可能与外部变量相同。这与 auto 以前的用途完全相同。然而,保留关键字 register 的重要原因是,避免使用了该关键字的现有代码非法。

9.2.3 静态持续变量

和 C 语言一样,C++也为静态存储持续性变量提供了 3 种链接性:外部链接性(可在其他文件中访问)、内部链接性(只能在当前文件中访问)和无链接性(只能在当前函数或代码块中访问)。这 3 种链接性都在整个程序执行期间存在,与自动变量相比,它们的寿命更长。由于静态变量的数目在程序运行期间是不变的,因此程序不需要使用特殊的装置(如栈)来管理它们。编译器将分配固定的内存块来存储所有的静态变量,这些变量在整个程序执行期间一直存在。另外,如果没有显式地初始化静态变量,编译器将把它设置为 0。在默认情况下,静态数组和结构将每个元素或成员的所有位都设置为 0。

注意:

传统的 K&R C 不允许初始化自动数组和结构,但允许初始化静态数组和结构。ANSI C 和 C++允许对这两种数组和结构进行初始化,但有些旧的 C++翻译器使用与 ANSI C 不完全兼容的 C 编译器。如果使用的是这样的实现,则可能需要使用这 3 种静态存储类型之一,以初始化数组和结构。

下面介绍如何创建这 3 种静态持续变量,然后介绍它们的特点。要想创建链接性为外部的静态持续变量,必须在代码块的外面声明它;要创建链接性为内部的静态持续变量,必须在代码块的外面声明它,并使用 static 限定符;要创建没有链接性的静态持续变量,必须在代码块内声明它,并使用 static 限定符。下面的代码片段说明这 3 种变量:

正如前面指出的,所有静态持续变量(上述示例中的 global、one_file 和 count)在整个程序执行期间都存在。在 funct1( ) 中声明的变量 count 的作用域为局部,没有链接性,这意味着只能在 funct1( ) 函数中使用它,就像自动变量 llama 一样。然而,与 llama 不同的是,即使在 funct1( ) 函数没有被执行时,count 也留在内存中。global 和 one_file 的作用域都为整个文件,即在从声明位置到文件结尾的范围内都可以被使用。具体地说,可以在 main( )、funct1( ) 和 funct2( ) 中使用它们。由于 one_file 的链接性为内部,因此只能在包含上述代码的文件中使用它;由于 global 的链接性为外部,因此可以在程序的其他文件中使用它。

所有的静态持续变量都有下述初始化特征:未被初始化的静态变量的所有位都被设置为 0。这种变量被称为零初始化的(zero-initialized)。

表 9.1 总结了引入名称空间之前使用的存储特性。下面详细介绍各种静态持续性。

表 9.1 指出了关键字 static 的两种用法,但含义有些不同:用于局部声明,以指出变量是无链接性的静态变量时,static 表示的是存储持续性;而用于代码块外的声明时,static 表示内部链接性,而变量已经是静态持续性了。有人称之为关键字重载,即关键字的含义取决于上下文。

表 9.1 5 种变量储存方式

存 储 描 述

持 续 性

作 用 域

链 接 性

如 何 声 明

自动

自动

代码块

在代码块中

寄存器

自动

代码块

在代码块中,使用关键字 register

静态,无链接性

静态

代码块

在代码块中,使用关键字 static

静态,外部链接性

静态

文件

外部

不在任何函数内

静态,内部链接性

静态

文件

内部

不在任何函数内,使用关键字 static

静态变量的初始化

除默认的零初始化外,还可对静态变量进行常量表达式初始化和动态初始化。您可能猜到了,零初始化意味着将变量设置为零。对于标量类型,零将被强制转换为合适的类型。例如,在 C++代码中,空指针用 0 表示,但内部可能采用非零表示,因此指针变量将被初始化相应的内部表示。结构成员被零初始化,且填充位都被设置为零。

零初始化和常量表达式初始化被统称为静态初始化,这意味着在编译器处理文件(翻译单元)时初始化变量。动态初始化意味着变量将在编译后初始化。

那么初始化形式由什么因素决定呢?首先,所有静态变量都被零初始化,而不管程序员是否显式地初始化了它。接下来,如果使用常量表达式初始化了变量,且编译器仅根据文件内容(包括被包含的头文件)就可计算表达式,编译器将执行常量表达式初始化。必要时,编译器将执行简单计算。如果没有足够的信息,变量将被动态初始化。请看下面的代码:

首先,x、y、z 和 pi 被零初始化。然后,编译器计算常量表达式,并将 y 和 z 分别初始化为 5 和 169。但要初始化 pi,必须调用函数 atan(),这需要等到该函数被链接且程序执行时。

常量表达式并非只能是使用字面常量的算术表达式。例如,它还可使用 sizeof 运算符:

C++11 新增了关键字 constexpr,这增加了创建常量表达式的方式。但本书不会更详细地介绍 C++11 新增的这项新功能。

9.2.4 静态持续性、外部链接性

链接性为外部的变量通常简称为外部变量,它们的存储持续性为静态,作用域为整个文件。外部变量是在函数外部定义的,因此对所有函数而言都是外部的。例如,可以在 main( ) 前面或头文件中定义它们。可以在文件中位于外部变量定义后面的任何函数中使用它,因此外部变量也称全局变量(相对于局部的自动变量)。

1.单定义规则

一方面,在每个使用外部变量的文件中,都必须声明它;另一方面,C++有“单定义规则”(One Definition Rule,ODR),该规则指出,变量只能有一次定义。为满足这种需求,C++提供了两种变量声明。一种是定义声明(defining declaration)或简称为定义(definition),它给变量分配存储空间;另一种是引用声明(referencing declaration)或简称为声明(declaration),它不给变量分配存储空间,因为它引用已有的变量。

引用声明使用关键字 extern,且不进行初始化;否则,声明为定义,导致分配存储空间:

如果要在多个文件中使用外部变量,只需在一个文件中包含该变量的定义(单定义规则),但在使用该变量的其他所有文件中,都必须使用关键字 extern 声明它:

在这里,所有文件都使用了在 file01.cpp 中定义的变量 cats 和 dogs,但 file02.cpp 没有重新声明变量 fleas,因此无法访问它。在文件 file01.cpp 中,关键字 extern 并非必不可少的,因为即使省略它,效果也相同(参见图 9.4)

图 9.4 定义声明和引用声明

请注意,单定义规则并非意味着不能有多个变量的名称相同。例如,在不同函数中声明的同名自动变量是彼此独立的,它们都有自己的地址。另外,正如后面的示例将表明的,局部变量可能隐藏同名的全局变量。然而,虽然程序中可包含多个同名的变量,但每个变量都只有一个定义。

如果在函数中声明了一个与外部变量同名的变量,结果将如何呢?这种声明将被视为一个自动变量的定义,当程序执行自动变量所属的函数时,该变量将位于作用域内。程序清单 9.5 和程序清单 9.6 在两个文件中使用了一个外部变量,还演示了自动变量将隐藏同名的全局变量。它还演示了如何使用关键字 extern 来重新声明以前定义过的外部变量,以及如何使用 C++的作用域解析运算符来访问被隐藏的外部变量。

程序清单 9.5 external.cpp

程序清单 9.6 support.cpp

下面是该程序的输出:

2.程序说明

程序清单 9.5 和程序清单 9.6 所示程序的输出表明,main( ) 和 update( ) 都可以访问外部变量 warming。注意,update( ) 修改了 warming,这种修改在随后使用该变量时显现出来了。

在程序清单 9.5 中,warming 的定义如下:

在程序清单 9.6 中,使用关键字 extern 声明变量 warming,让该文件中的函数能够使用它:

正如注释指出的,该声明的的意思是,使用外部定义的变量 warming。

另外,函数 update() 使用关键字 extern 重新声明了变量 warming,这个关键字的意思是,通过这个名称使用在外部定义的变量。由于即使省略该声明,update( ) 的功能也相同,因此该声明是可选的。它指出该函数被设计成使用外部变量。

local( ) 函数表明,定义与全局变量同名的局部变量后,局部变量将隐藏全局变量。例如,local( ) 函数显示 warming 的值时,将使用 warming 的局部定义。

C++比 C 语言更进了一步——它提供了作用域解析运算符(::)。放在变量名前面时,该运算符表示使用变量的全局版本。因此,local( ) 将 warming 显示为 0.8,但将::warming 显示为 0.4。后面介绍名称空间和类时,将再次介绍该运算符。从清晰和避免错误的角度说,相对于使用 warming 并依赖于作用域规则,在函数 update() 中使用::warming 是更好的选择,也更安全。

全局变量和局部变量

既然可以选择使用全局变量或局部变量,那么到底应使用哪种呢?首先,全局变量很有吸引力——因为所有的函数能访问全局变量,因此不用传递参数。但易于访问的代价很大——程序不可靠。计算经验表明,程序越能避免对数据进行不必要的访问,就越能保持数据的完整性。通常情况下,应使用局部变量,应在需要知晓时才传递数据,而不应不加区分地使用全局变量来使数据可用。读者将会看到,OOP 在数据隔离方面又向前迈进了一步。

然而,全局变量也有它们的用处。例如,可以让多个函数可以使用同一个数据块(如月份名数组或原子量数组)。外部存储尤其适于表示常量数据,因为这样可以使用关键字 const 来防止数据被修改。

在上述示例中,第一个 const 防止字符串被修改,第二个 const 确保数组中每个指针始终指向它最初指向的字符串。

9.2.5 静态持续性、内部链接性

将 static 限定符用于作用域为整个文件的变量时,该变量的链接性将为内部的。在多文件程序中,内部链接性和外部链接性之间的差别很有意义。链接性为内部的变量只能在其所属的文件中使用;但常规外部变量都具有外部链接性,即可以在其他文件中使用,如前面的示例所示。

如果要在其他文件中使用相同的名称来表示其他变量,该如何办呢?只需省略关键字 extern 即可吗?

这种做法将失败,因为它违反了单定义规则。file2 中的定义试图创建一个外部变量,因此程序将包含 errors 的两个定义,这是错误。

但如果文件定义了一个静态外部变量,其名称与另一个文件中声明的常规外部变量相同,则在该文件中,静态变量将隐藏常规外部变量:

这没有违反单定义规则,因为关键字 static 指出标识符 errors 的链接性为内部,因此并非要提供外部定义。

注意:

在多文件程序中,可以在一个文件(且只能在一个文件)中定义一个外部变量。使用该变量的其他文件必须使用关键字 extern 声明它。

可使用外部变量在多文件程序的不同部分之间共享数据;可使用链接性为内部的静态变量在同一个文件中的多个函数之间共享数据(名称空间提供了另外一种共享数据的方法)。另外,如果将作用域为整个文件的变量变为静态的,就不必担心其名称与其他文件中的作用域为整个文件的变量发生冲突。

程序清单 9.7 和程序清单 9.8 演示了 C++如何处理链接性为外部和内部的变量。程序清单 9.7(twofile1.cpp)定义了外部变量 tom 和 dick 以及静态外部变量 harry。这个文件中的 main( ) 函数显示这 3 个变量的地址,然后调用 remote_access( ) 函数,该函数是在另一个文件中定义的。程序清单 9.8(twofile2.cpp)列出了该文件。除定义 remote_access( ) 外,该文件还使用 extern 关键字来与第一个文件共享 tom。接下来,该文件定义一个名为 dick 的静态变量。static 限定符使该变量被限制在这个文件内,并覆盖相应的全局定义。然后,该文件定义了一个名为 harry 的外部变量,这不会与第一个文件中的 harry 发生冲突,因为后者的链接性为内部的。随后,remote-access( ) 函数显示这 3 个变量的地址,以便于将它们与第一个文件中相应变量的地址进行比较。别忘了编译这两个文件,并将它们链接起来,以得到完整的程序。

程序清单 9.7 twofile1.cpp

程序清单 9.8 twofile2.cpp

下面是编译程序清单 9.7 和程序清单 9.8 生成的程序的输出:

从上述地址可知,这两个文件使用了同一个 tom 变量,但使用了不同的 dick 和 harry 变量。具体的地址和格式可能随系统而异,但两个 tom 变量的地址将相同,而两个 dick 和 harry 变量的地址不同。

9.2.6 静态存储持续性、无链接性

至此,介绍了链接性分别为内部和外部、作用域为整个文件的变量。接下来介绍静态持续家族中的第三个成员——无链接性的局部变量。这种变量是这样创建的,将 static 限定符用于在代码块中定义的变量。在代码块中使用 static 时,将导致局部变量的存储持续性为静态的。这意味着虽然该变量只在该代码块中可用,但它在该代码块不处于活动状态时仍然存在。因此在两次函数调用之间,静态局部变量的值将保持不变。(静态变量适用于再生——可以用它们将瑞士银行的秘密账号传递到下一个要去的地方)。另外,如果初始化了静态局部变量,则程序只在启动时进行一次初始化。以后再调用函数时,将不会像自动变量那样再次被初始化。程序清单 9.9 说明了这几点。

程序清单 9.9 static.cpp

顺便说一句,该程序演示了一种处理行输入可能长于目标数组的方法。本书前面讲过,方法 cin.get(input, ArSize) 将一直读取输入,直到到达行尾或读取了 ArSize-1 个字符为止。它把换行符留在输入队列中。该程序使用 cin.get(next) 读取行输入后的字符。如果 next 是换行符,则说明 cin.get(input, ArSize) 读取了整行;否则说明行中还有字符没有被读取。随后,程序使用一个循环来丢弃余下的字符,不过读者可以修改代码,让下一轮输入读取行中余下的字符。该程序还利用了这样一个事实,即试图使用 get(char *, int) 读取空行将导致 cin 为 false。

下面是该程序的输出:

注意,由于数组长度为 10,因此程序从每行读取的字符数都不超过 9 个。另外还需要注意的是,每次函数被调用时,自动变量 count 都被重置为 0。然而,静态变量 total 只在程序运行时被设置为 0,以后在两次函数调用之间,其值将保持不变,因此能够记录读取的字符总数。

9.2.7 说明符和限定符

有些被称为存储说明符(storage class specifier)或 cv-限定符(cv-qualifier)的 C++关键字提供了其他有关存储的信息。下面是存储说明符:

  • auto(在 C++11 中不再是说明符);
  • register;
  • static;
  • extern;
  • thread_local(C++11 新增的);
  • mutable。

其中的大部分已经介绍过了,在同一个声明中不能使用多个说明符,但 thread_local 除外,它可与 static 或 extern 结合使用。前面讲过,在 C++11 之前,可以在声明中使用关键字 auto 指出变量为自动变量;但在 C++11 中,auto 用于自动类型推断。关键字 register 用于在声明中指示寄存器存储,而在 C++11 中,它只是显式地指出变量是自动的。关键字 static 被用在作用域为整个文件的声明中时,表示内部链接性;被用于局部声明中,表示局部变量的存储持续性为静态的。关键字 extern 表明是引用声明,即声明引用在其他地方定义的变量。关键字 thread_local 指出变量的持续性与其所属线程的持续性相同。thread_local 变量之于线程,犹如常规静态变量之于整个程序。关键字 mutable 的含义将根据 const 来解释,因此先来介绍 cv-限定符,然后再解释它。

1.cv-限定符

下面就是 cv 限定符:

  • const;
  • volatile。

(读者可能猜到了,cv 表示 const 和 volatile)。最常用的 cv-限定符是 const,而读者已经知道其用途。它表明,内存被初始化后,程序便不能再对它进行修改。稍后再回过头来介绍它。

关键字 volatile 表明,即使程序代码没有对内存单元进行修改,其值也可能发生变化。听起来似乎很神秘,实际上并非如此。例如,可以将一个指针指向某个硬件位置,其中包含了来自串行端口的时间或信息。在这种情况下,硬件(而不是程序)可能修改其中的内容。或者两个程序可能互相影响,共享数据。该关键字的作用是为了改善编译器的优化能力。例如,假设编译器发现,程序在几条语句中两次使用了某个变量的值,则编译器可能不是让程序查找这个值两次,而是将这个值缓存到寄存器中。这种优化假设变量的值在这两次使用之间不会变化。如果不将变量声明为 volatile,则编译器将进行这种优化;将变量声明为 volatile,相当于告诉编译器,不要进行这种优化。

2.mutable

现在回到 mutable。可以用它来指出,即使结构(或类)变量为 const,其某个成员也可以被修改。例如,请看下面的代码:

veep 的 const 限定符禁止程序修改 veep 的成员,但 access 成员的 mutable 说明符使得 access 不受这种限制。

本书不使用 volatile 或 mutable,但将进一步介绍 const。

3.再谈 const

在 C++(但不是在 C 语言)中,const 限定符对默认存储类型稍有影响。在默认情况下全局变量的链接性为外部的,但 const 全局变量的链接性为内部的。也就是说,在 C++看来,全局 const 定义(如下述代码段所示)就像使用了 static 说明符一样。

C++修改了常量类型的规则,让程序员更轻松。例如,假设将一组常量放在头文件中,并在同一个程序的多个文件中使用该头文件。那么,预处理器将头文件的内容包含到每个源文件中后,所有的源文件都将包含类似下面这样的定义:

如果全局 const 声明的链接性像常规变量那样是外部的,则根据单定义规则,这将出错。也就是说,只能有一个文件可以包含前面的声明,而其他文件必须使用 extern 关键字来提供引用声明。另外,只有未使用 extern 关键字的声明才能进行初始化:

因此,需要为某个文件使用一组定义,而其他文件使用另一组声明。然而,由于外部定义的 const 数据的链接性为内部的,因此可以在所有文件中使用相同的声明。

内部链接性还意味着,每个文件都有自己的一组常量,而不是所有文件共享一组常量。每个定义都是其所属文件私有的,这就是能够将常量定义放在头文件中的原因。这样,只要在两个源代码文件中包括同一个头文件,则它们将获得同一组常量。

如果出于某种原因,程序员希望某个常量的链接性为外部的,则可以使用 extern 关键字来覆盖默认的内部链接性:

在这种情况下,必须在所有使用该常量的文件中使用 extern 关键字来声明它。这与常规外部变量不同,定义常规外部变量时,不必使用 extern 关键字,但在使用该变量的其他文件中必须使用 extern。然而,请记住,鉴于单个 const 在多个文件之间共享,因此只有一个文件可对其进行初始化。

在函数或代码块中声明 const 时,其作用域为代码块,即仅当程序执行该代码块中的代码时,该常量才是可用的。这意味着在函数或代码块中创建常量时,不必担心其名称与其他地方定义的常量发生冲突。

9.2.8 函数和链接性

和变量一样,函数也有链接性,虽然可选择的范围比变量小。和 C 语言一样,C++不允许在一个函数中定义另外一个函数,因此所有函数的存储持续性都自动为静态的,即在整个程序执行期间都一直存在。在默认情况下,函数的链接性为外部的,即可以在文件间共享。实际上,可以在函数原型中使用关键字 extern 来指出函数是在另一个文件中定义的,不过这是可选的(要让程序在另一个文件中查找函数,该文件必须作为程序的组成部分被编译,或者是由链接程序搜索的库文件)。还可以使用关键字 static 将函数的链接性设置为内部的,使之只能在一个文件中使用。必须同时在原型和函数定义中使用该关键字:

这意味着该函数只在这个文件中可见,还意味着可以在其他文件中定义同名的的函数。和变量一样,在定义静态函数的文件中,静态函数将覆盖外部定义,因此即使在外部定义了同名的函数,该文件仍将使用静态函数。

单定义规则也适用于非内联函数,因此对于每个非内联函数,程序只能包含一个定义。对于链接性为外部的函数来说,这意味着在多文件程序中,只能有一个文件(该文件可能是库文件,而不是您提供的)包含该函数的定义,但使用该函数的每个文件都应包含其函数原型。

内联函数不受这项规则的约束,这允许程序员能够将内联函数的定义放在头文件中。这样,包含了头文件的每个文件都有内联函数的定义。然而,C++要求同一个函数的所有内联定义都必须相同。

C++在哪里查找函数

假设在程序的某个文件中调用一个函数,C++将到哪里去寻找该函数的定义呢?如果该文件中的函数原型指出该函数是静态的,则编译器将只在该文件中查找函数定义;否则,编译器(包括链接程序)将在所有的程序文件中查找。如果找到两个定义,编译器将发出错误消息,因为每个外部函数只能有一个定义。如果在程序文件中没有找到,编译器将在库中搜索。这意味着如果定义了一个与库函数同名的函数,编译器将使用程序员定义的版本,而不是库函数(然而,C++保留了标准库函数的名称,即程序员不应使用它们)。有些编译器-链接程序要求显式地指出要搜索哪些库。

9.2.9 语言链接性

另一种形式的链接性——称为语言链接性(language linking)也对函数有影响。首先介绍一些背景知识。链接程序要求每个不同的函数都有不同的符号名。在 C 语言中,一个名称只对应一个函数,因此这很容易实现。为满足内部需要,C 语言编译器可能将 spiff 这样的函数名翻译为_spiff。这种方法被称为 C 语言链接性(C language linkage)。但在 C++中,同一个名称可能对应多个函数,必须将这些函数翻译为不同的符号名称。因此,C++编译器执行名称矫正或名称修饰(参见第 8 章),为重载函数生成不同的符号名称。例如,可能将 spiff(int)转换为_spoff_i,而将 spiff(double,double)转换为_spiff_d_d。这种方法被称为 C++语言链接(C++ language linkage)。

链接程序寻找与 C++函数调用匹配的函数时,使用的方法与 C 语言不同。但如果要在 C++程序中使用 C 库中预编译的函数,将出现什么情况呢?例如,假设有下面的代码:

它在 C 库文件中的符号名称为_spiff,但对于我们假设的链接程序来说,C++查询约定是查找符号名称_spiff_i。为解决这种问题,可以用函数原型来指出要使用的约定:

第一个原型使用 C 语言链接性;而后面的两个使用 C++语言链接性。第二个原型是通过默认方式指出这一点的,而第三个显式地指出了这一点。

C 和 C++链接性是 C++标准指定的说明符,但实现可提供其他语言链接性说明符。

9.2.10 存储方案和动态分配

前面介绍 C++用来为变量(包括数组和结构)分配内存的 5 种方案(线程内存除外),它们不适用于使用 C++运算符 new(或 C 函数 malloc( ))分配的内存,这种内存被称为动态内存。第 4 章介绍过,动态内存由运算符 new 和 delete 控制,而不是由作用域和链接性规则控制。因此,可以在一个函数中分配动态内存,而在另一个函数中将其释放。与自动内存不同,动态内存不是 LIFO,其分配和释放顺序要取决于 new 和 delete 在何时以何种方式被使用。通常,编译器使用三块独立的内存:一块用于静态变量(可能再细分),一块用于自动变量,另外一块用于动态存储。

虽然存储方案概念不适用于动态内存,但适用于用来跟踪动态内存的自动和静态指针变量。例如,假设在一个函数中包含下面的语句:

由 new 分配的 80 个字节(假设 float 为 4 个字节)的内存将一直保留在内存中,直到使用 delete 运算符将其释放。但当包含该声明的语句块执行完毕时,p_fees 指针将消失。如果希望另一个函数能够使用这 80 个字节中的内容,则必须将其地址传递或返回给该函数。另一方面,如果将 p_fees 的链接性声明为外部的,则文件中位于该声明后面的所有函数都可以使用它。另外,通过在另一个文件中使用下述声明,便可在其中使用该指针:

注意:

在程序结束时,由 new 分配的内存通常都将被释放,不过情况也并不总是这样。例如,在不那么健壮的操作系统中,在某些情况下,请求大型内存块将导致该代码块在程序结束不会被自动释放。最佳的做法是,使用 delete 来释放 new 分配的内存。

1.使用 new 运算符初始化

如果要初始化动态分配的变量,该如何办呢?在 C++98 中,有时候可以这样做,C++11 增加了其他可能性。下面先来看看 C++98 提供的可能性。

如果要为内置的标量类型(如 int 或 double)分配存储空间并初始化,可在类型名后面加上初始值,并将其用括号括起:

这种括号语法也可用于有合适构造函数的类,这将在本书后面介绍。

然而,要初始化常规结构或数组,需要使用大括号的列表初始化,这要求编译器支持 C++11。C++11 允许您这样做:

在 C++11 中,还可将列表初始化用于单值变量:

2.new 失败时

new 可能找不到请求的内存量。在最初的 10 年中,C++在这种情况下让 new 返回空指针,但现在将引发异常 std::bad_alloc。第 15 章通过一些简单的示例演示了这两种方法的工作原理。

3.new:运算符、函数和替换函数

运算符 new 和 new []分别调用如下函数:

这些函数被称为分配函数(alloction function),它们位于全局名称空间中。同样,也有由 delete 和 delete []调用的释放函数(deallocation function):

它们使用第 11 章将讨论的运算符重载语法。std::size_t 是一个 typedef,对应于合适的整型。对于下面这样的基本语句:

将被转换为下面这样:

而下面的语句:

将被转换为下面这样:

正如您知道的,使用运算符 new 的语句也可包含初始值,因此,使用 new 运算符时,可能不仅仅是调用 new() 函数。

同样,下面的语句:

将转换为如下函数调用:

有趣的是,C++将这些函数称为可替换的(replaceable)。这意味着如果您有足够的知识和意愿,可为 new 和 delete 提供替换函数,并根据需要对其进行定制。例如,可定义作用域为类的替换函数,并对其进行定制,以满足该类的内存分配需求。在代码中,仍将使用 new 运算符,但它将调用您定义的 new() 函数。

4.定位 new 运算符

通常,new 负责在堆(heap)中找到一个足以能够满足要求的内存块。new 运算符还有另一种变体,被称为定位(placement)new 运算符,它让您能够指定要使用的位置。程序员可能使用这种特性来设置其内存管理规程、处理需要通过特定地址进行访问的硬件或在特定位置创建对象。

要使用定位 new 特性,首先需要包含头文件 new,它提供了这种版本的 new 运算符的原型;然后将 new 运算符用于提供了所需地址的参数。除需要指定参数外,句法与常规 new 运算符相同。具体地说,使用定位 new 运算符时,变量后面可以有方括号,也可以没有。下面的代码段演示了 new 运算符的 4 种用法:

出于简化的目的,这个示例使用两个静态数组来为定位 new 运算符提供内存空间。因此,上述代码从 buffer1 中分配空间给结构 chaff,从 buffer2 中分配空间给一个包含 20 个元素的 int 数组。

熟悉定位 new 运算符后,来看一个示例程序。程序清单 9.10 使用常规 new 运算符和定位 new 运算符创建动态分配的数组。该程序说明了常规 new 运算符和定位 new 运算符之间的一些重要差别,在查看该程序的输出后,将对此进行讨论。

程序清单 9.10 newplace.cpp

下面是该程序在某个系统上运行时的输出:

5.程序说明

有关程序清单 9.10,首先要指出的一点是,定位 new 运算符确实将数组 p2 放在了数组 buffer 中,p2 和 buffer 的地址都是 00FD9138。然而,它们的类型不同,p1 是 double 指针,而 buffer 是 char 指针(顺便说一句,这也是程序使用(void *) 对 buffer 进行强制转换的原因,如果不这样做,cout 将显示一个字符串)同时,常规 new 将数组 p1 放在很远的地方,其地址为 006E4AB0,位于动态管理的堆中。

需要指出的第二点是,第二个常规 new 运算符查找一个新的内存块,其起始地址为 006E4B68;但第二个定位 new 运算符分配与以前相同的内存块:起始地址为 00FD9138 的内存块。定位 new 运算符使用传递给它的地址,它不跟踪哪些内存单元已被使用,也不查找未使用的内存块。这将一些内存管理的负担交给了程序员。例如,在第三次调用定位 new 运算符时,提供了一个从数组 buffer 开头算起的偏移量,因此将分配新的内存:

第三点差别是,是否使用 delete 来释放内存。对于常规 new 运算符,下面的语句释放起始地址为 006E4AB0 的内存块,因此接下来再次调用 new 运算符时,该内存块是可用的:

然而,程序清单 9.10 中的程序没有使用 delete 来释放使用定位 new 运算符分配的内存。事实上,在这个例子中不能这样做。buffer 指定的内存是静态内存,而 delete 只能用于这样的指针:指向常规 new 运算符分配的堆内存。也就是说,数组 buffer 位于 delete 的管辖区域之外,下面的语句将引发运行阶段错误:

另一方面,如果 buffer 是使用常规 new 运算符创建的,便可以使用常规 delete 运算符来释放整个内存块。

定位 new 运算符的另一种用法是,将其与初始化结合使用,从而将信息放在特定的硬件地址处。

您可能想知道定位 new 运算符的工作原理。基本上,它只是返回传递给它的地址,并将其强制转换为 void *,以便能够赋给任何指针类型。但这说的是默认定位 new 函数,C++允许程序员重载定位 new 函数。

将定位 new 运算符用于类对象时,情况将更复杂,这将在第 12 章介绍。

6.定位 new 的其他形式

就像常规 new 调用一个接收一个参数的 new() 函数一样,标准定位 new 调用一个接收两个参数的 new() 函数:

定位 new 函数不可替换,但可重载。它至少需要接收两个参数,其中第一个总是 std::size_t,指定了请求的字节数。这样的重载函数都被称为定义 new,即使额外的参数没有指定位置。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文