Skip to content

Latest commit

 

History

History
1317 lines (975 loc) · 53.7 KB

File metadata and controls

1317 lines (975 loc) · 53.7 KB

十、名称控制

创建名字是编程中的一项基本活动,当一个项目变得很大时,名字的数量很容易变得令人难以招架。

C++ 允许您对名称的创建和可见性、名称的存储位置以及名称的链接进行大量控制。

在人们知道术语“重载”是什么意思之前,C 中的关键字static就已经重载了,而 C++ 又增加了另一个意思。所有使用static的潜在概念似乎是“保持其位置的东西”(如静电),无论这是指内存中的物理位置还是文件中的可见性。

在这一章中,你将学习static如何控制存储和可见性,以及一种通过 C++ 的名称空间特性来控制名称访问的改进方法。您还将了解如何使用用 c 编写和编译的函数。

来自 C 的静态元素

在 C 和 C++ 中,关键字static有两个基本含义,不幸的是经常会踩到对方的脚趾。

  1. 在固定地址分配一次;也就是说,每次调用函数时,对象是在一个特殊的静态数据区域中创建的,而不是在堆栈中创建的。这就是静态存储的概念。
  2. 对于特定的翻译单元是局部的(对于 C++ 中的类范围也是局部的,您将在后面看到)。这里,static控制名字的可见性,这样名字在翻译单元或类之外就看不见了。这也描述了链接的概念,它决定了链接器将看到什么名称。

本节将着眼于从 c 语言继承而来的static的含义。

函数内部的静态变量

当您在函数中创建局部变量时,编译器会在每次调用该函数时通过将堆栈指针下移适当的量来为该变量分配存储空间。如果变量有一个初始化器,那么每次通过序列点时都会执行初始化。

但是,有时您希望在函数调用之间保留一个值。您可以通过创建一个全局变量来实现这一点,但是这样一来,该变量就不在函数的单独控制之下了。C 和 C++ 允许你在函数内部创建一个static对象;这个对象的存储不在堆栈上,而是在程序的静态数据区。该对象只初始化一次,即第一次调用函数时,然后在函数调用之间保留其值。例如,在清单 10-1 的中,该函数在每次被调用时返回数组中的下一个字符。

清单 10-1 。函数中的静态变量

//: C10:StaticVariablesInfunctions.cpp
#include "../require.h"       // To be INCLUDED from Header FILE in *[Chapter 9](09.html)*
#include <iostream>
using namespace std;

char oneChar(const char* charArray = 0) {
  static const char* s;
  if(charArray) {
    s = charArray;
    return *s;
  }
else
  require(s, "un-initialized s");
  if(*s == '\0')
    return 0;
  return *s++;
}

char* a = "abcdefghijklmnopqrstuvwxyz";

int main() {
  // oneChar(); // require() fails
  oneChar(a); // Initializes s to a
  char c;
  while((c = oneChar()) != 0)
    cout << c << endl;
} ///:∼

static char* s在调用oneChar()之间保存它的值,因为它的存储不是函数堆栈框架的一部分,而是在程序的静态存储区。当您用一个char*参数调用oneChar()时,s被赋给该参数,并返回数组的第一个字符。每一次不带参数的对oneChar()的后续调用都会产生charArray的默认值 0,这向函数表明您仍在从s的先前初始化值中提取字符。该函数将继续产生字符,直到它到达字符数组的空终止符,在这一点上,它停止递增指针,以便它不会溢出数组的末尾。

但是如果您调用oneChar()而没有参数,也没有预先初始化s的值,会发生什么呢?在s的定义中,你可以提供一个初始化器,比如

static char* s = 0;

但是如果你没有为一个内置类型的静态变量提供一个初始化器,编译器保证变量会在程序启动时被初始化为零(转换成合适的类型)。所以在oneChar()中,第一次调用函数时,s为零。在这种情况下,if(!s)有条件就会抓住它。

上面对s的初始化非常简单,但是对静态对象(像所有其他对象一样)的初始化可以是任意的表达式,包括常量和先前声明的变量和函数。

你要知道上面的函数非常容易出现多线程问题;每当你设计包含静态变量的函数时,你应该记住多线程的问题。

函数内部的静态类对象

用户定义类型的静态对象的规则是相同的,包括对象需要一些初始化。但是,赋零只对内置类型有意义;用户定义的类型必须用构造器调用来初始化。因此,如果你在定义静态对象时没有指定构造器参数,那么这个类必须有一个默认的构造器,正如你在清单 10-2 中看到的。

清单 10-2 。函数内部的静态类对象

//: C10:StaticObjectsInFunctions.cpp
#include <iostream>
using namespace std;

class X {
  int i;
public:
  X(int ii = 0) : i(ii) {} // Default
  ∼X() { cout << "X::∼X()" << endl; }
};

void f() {
  static X x1(47);
  static X x2; // Default constructor required
}

int main() {
  f();
} ///:∼

f()中类型为X的静态对象既可以用构造器参数列表初始化,也可以用默认构造器初始化。这种构造发生在控制第一次通过定义时,而且只有第一次。

静态对象析构函数

main()退出或者当标准 C 库函数exit()被显式调用时,静态对象的析构函数(即所有具有静态存储的对象,而不仅仅是上面例子中的本地静态对象)被调用。在大多数实现中,main()只是在终止时调用exit()。这意味着在析构函数中调用exit()可能是危险的,因为你可能会以无限递归结束。如果你使用标准 C 库函数abort()退出程序,静态对象析构函数不会被调用。

您可以通过使用标准 C 库函数atexit()来指定离开main()(或调用exit())时发生的动作。在这种情况下,atexit()注册的函数可能会在离开main()之前构造的任何对象的析构函数之前被调用(或者调用exit())。

像普通的销毁一样,静态对象的销毁与初始化的顺序相反。但是,只有已构造的对象才会被销毁。幸运的是,C++ 开发工具跟踪初始化顺序和已经构造的对象。全局对象总是在进入main()之前被构造,当main()退出时被销毁,但是如果一个包含局部静态对象的函数从来没有被调用,那么这个对象的构造器永远不会被执行,所以析构函数也不会被执行(参见清单 10-3 )。

清单 10-3 。静态对象析构函数

//: C10:StaticDestructors.cpp
// Static object destructors
#include <fstream>
using namespace std;
ofstream out("statdest.out"); // Trace file

classObj {
  char c;                     // Identifier
public:
  Obj(char cc) : c(cc) {
    out << "Obj::Obj() for " << c << endl;
  }
  ∼Obj() {
    out << "Obj::∼Obj() for " << c << endl;
  }
};

Obj a('a');                   // Global (static storage)
// Constructor & destructor always called

void f() {
  static Obj b('b');
}

void g() {
  static Obj c('c');
}

int main() {
  out << "inside main()" << endl;
  f();                        // Calls static constructor for b
  // g() not called
  out << "leaving main()" << endl;
} ///:∼

Obj中,char c作为一个标识符,因此构造器和析构函数可以打印出它们正在处理的对象的信息。Obj a是一个全局对象,所以在进入main()之前总是会调用它的构造器,但是只有在调用这些函数时才会调用f()内的static Obj bg()内的static Obj c的构造器。

为了演示调用了哪些构造器和析构函数,只调用了f()。该程序的输出是

Obj::Obj() for a
inside main()
Obj::Obj() for b
leaving main()
Obj::∼Obj() for b
Obj::∼Obj() for a

在进入main()之前调用a的构造器,调用b的构造器只是因为调用了f()。当main()退出时,已经被构造的对象的析构函数以与它们的构造相反的顺序被调用。这意味着如果g() 调用,那么bc的析构函数被调用的顺序取决于是f()还是g()先被调用。

注意,跟踪文件ofstream对象out也是一个静态对象——因为它是在所有函数之外定义的,所以它位于静态存储区域。重要的是它的定义(相对于extern声明)出现在文件的开头,在可能使用out之前。否则,您将在对象被正确初始化之前使用它。

在 C++ 中,全局静态对象的构造器在进入main()之前被调用,所以你现在有了一个简单且可移植的方法在进入main()之前执行代码,在退出main()之后用析构函数执行代码。在 C 语言中,这总是一种尝试,需要你在编译器供应商的汇编语言启动代码中寻找。

控制链接

通常,文件范围内的任何名称(即,没有嵌套在类或函数中的名称)在程序中的所有翻译单元中都是可见的。这通常被称为外部链接 ,因为在链接时,该名称对于翻译单元外部的链接器是可见的。全局变量和普通函数有外部联系。

有时候你会想限制一个名字的可见性。您可能希望在文件范围内有一个变量,以便该文件中的所有函数都可以使用它,但是您不希望该文件之外的函数看到或访问该变量,或者无意中导致名称与文件之外的标识符冲突。

在文件作用域中被显式声明为static的对象或函数名对于其翻译单元是局部的(在本书中,声明发生在cpp文件中)。那个名字有内在联系。这意味着您可以在其他翻译单元中使用相同的名称,而不会发生名称冲突。

内部链接的一个优点是名字可以放在头文件中,不用担心链接时会有冲突。通常放在头文件中的名字,比如const定义和inline函数,默认为内部链接。(不过,const在 C++ 中默认只有内部联动;在 C 中,它默认为外部链接。)注意,链接仅指在链接/加载时具有地址的元素;因此,类声明和局部变量没有联系。

困惑

这里有一个例子可以说明static的两个意思是如何相互交叉的。所有的全局对象都隐式地拥有静态存储类,所以如果你说(在文件范围内),

int a = 0;

然后,a的存储将在程序的静态数据区,并且在进入main()之前,a的初始化将发生一次。此外,a的可见性在所有翻译单元中都是全局的。在可见性方面,与static ( 只在这个翻译单元中可见)相反的是extern,它明确声明名称的可见性是跨所有翻译单元的。所以上面的定义相当于说。

extern int a = 0;

但是如果你说,

static int a = 0;

你所做的只是改变了可见性,所以a有了内部链接。存储类保持不变—无论可见性是static还是extern,对象都驻留在静态数据区。

一旦进入局部变量,static就会停止改变可见性,转而改变存储类。

如果将看似局部变量的内容声明为extern,这意味着存储存在于其他地方(因此该变量实际上是函数的全局变量)。例如,参见清单 10-4清单 10-5

清单 10-4 。本地外部

//: C10:LocalExtern.cpp
//{L} LocalExtern2
#include<iostream>

int main() {
 extern int i;
 std::cout << i;
} ///:∼

清单 10-5 。另一个本地的外来者

//: C10:LocalExtern2.cpp {O}
int i = 5;
///:∼

对于函数名(对于非成员函数),staticextern只能改变可见性,所以如果你说

extern void f();

这和未经修饰的声明

void f();

如果你说,

static void f();

这意味着f()只在这个翻译单元内可见。这有时称为文件静态

其他存储类说明符

你会看到常用的staticextern。还有另外两种不常出现的存储类说明符*。auto说明符几乎从不使用,因为它告诉编译器这是一个局部变量。auto是“自动”的缩写,指的是编译器自动为变量分配存储的方式。编译器总能从定义变量的上下文中确定这个事实,所以auto是多余的。*

一个register变量是一个局部(auto)变量,伴随着一个提示编译器这个特殊的变量将被大量使用,所以编译器应该尽可能地把它保存在一个寄存器中。因此,它是一个优化辅助工具*。不同的编译器对这个提示有不同的反应;他们可以选择忽略它。如果你取变量的地址,那么register说明符几乎肯定会被忽略。你应该避免使用register,因为编译器通常能比你做得更好。

名称空间

尽管名字可以嵌套在类中,但是全局函数、全局变量和类的名字仍然在一个全局名字空间中。static关键字通过允许你给变量和函数内部链接(也就是说,使它们成为静态文件)来给你一些控制。但是在一个大型项目中,缺乏对全局名称空间的控制会导致问题。为了解决类的这些问题,供应商通常会创建不太可能冲突的长而复杂的名称,但这样一来,您就不得不键入这些名称。(经常用一个typedef来简化这个。)这不是一个优雅的、受语言支持的解决方案。

您可以使用 C++ 的名称空间特性将全局名称空间细分为更易于管理的部分。与classstructenumunion类似,namespace关键字将其成员的名字放在一个不同的空间中。虽然其他关键字有额外的目的,但是创建新的名称空间是namespace的唯一目的。

创建名称空间

命名空间的创建非常类似于class的创建;参见清单 10-6

清单 10-6 。创建名称空间

//: C10:MyLib.cpp

namespace MyLib {

  // Declarations
}

int main() {} ///:∼

这将产生一个包含所包含声明的新名称空间。与classstructunionenum有显著差异,但是:

  • 命名空间定义只能出现在全局范围内,或者嵌套在另一个命名空间内。

  • 命名空间定义的右括号后不需要终止分号。

  • A namespace definition can be “continued” over multiple header files using a syntax that, for a class, would appear to be a redefinition (see Listing 10-7).

    清单 10-7 。说明名称空间定义的延续

    //: C10:Header1.h
    #ifndef HEADER1_H
    #define HEADER1_H
    namespace MyLib {
      extern int x;
      void f();
      // ...
    }
    #endif                // HEADER1_H ///:∼
    
    //: C10:Header2.h
    #ifndef HEADER2_H
    #define HEADER2_H
    #include "Header1.h"  // To be INCLUDED from Header FILE above
    // Add more names to MyLib
    namespace MyLib {     // NOT a redefinition!
      extern int y;
      void g();
      // ...
    }
    #endif                // HEADER2_H ///:∼
    
    //: C10:Continuation.cpp
    #include "Header2.h" // To be INCLUDED from Header FILE above
    int main() {} ///:∼
  • A namespace name can be aliased to another name, so you don’t have to type an unwieldy name created by a library vendor, as shown in Listing 10-8.

    清单 10-8 。说明名称空间定义的延续(在多个头文件上)

    //: C10:BobsSuperDuperLibrary.cpp
    namespace BobsSuperDuperLibrary {
      class Widget { /* ... */ };
      classPoppit { /* ... */ };
      // ...
    }
    // Too much to type! I'll alias it:
    namespace Bob = BobsSuperDuperLibrary;
    int main() {} ///:∼
  • 不能像创建类那样创建命名空间的实例。

未命名的 名称空间

每个翻译单元都包含一个未命名的名称空间,你可以在没有标识符的情况下通过说“namespace来添加它,如清单 10-9 中的所示。

清单 10-9 。未命名的名称空间

//: C10:UnnamedNamespaces.cpp
namespace {
  class Arm  { /* ... */ };
  class Leg  { /* ... */ };
  class Head { /* ... */ };
  class Robot {
    Arm arm[4];
    Leg leg[16];
    Head head[3];
    // ...
  } xanthan;
  int i, j, k;
}
int main() {} ///:∼

该空间中的名称在该翻译单元中自动可用,没有任何限制。保证未命名空间对于每个翻译单元是唯一的。如果您将本地名称放在一个未命名的名称空间中,您不需要通过使它们成为static来给它们内部链接。

C++ 反对使用文件静态,支持未命名的名称空间。

老友记

你可以通过在一个封闭的类中声明friend声明注入到一个名称空间中,如清单 10-10 所示。

清单 10-10 。将朋友注入名称空间

//: C10:FriendInjection.cpp
namespace Me {
class Us {
    //...
friend void you();
  };
}
int main() {} ///:∼

现在函数you()是名称空间Me的成员。

如果在全局命名空间的类中引入友元,则该友元被全局注入。

使用名称空间

您可以通过三种方式在名称空间中引用名称:使用范围解析操作符指定名称,使用using指令引入名称空间中的所有名称,或者使用using声明一次引入一个名称。

范围分辨率

名称空间中的任何名称都可以使用作用域解析操作符显式指定,就像引用一个类中的名称一样,如清单 10-11 所示。

清单 10-11 。在名称空间中显式指定名称(使用范围解析运算符)

//: C10:ScopeResolution.cpp
namespace X {
  class Y {
    static int i;
public:
  void f();
  };
  class Z;
  voidfunc();
}
int X::Y::i = 9;
class X::Z {
  int u, v, w;
public:
  Z(int i);
  int g();
};
X::Z::Z(int i) { u = v = w = i; }
int X::Z::g() { return u = v = w = 0; }
void X::func() {
  X::Z a(1);
  a.g();
}
int main(){} ///:∼

注意,定义X::Y::i很容易引用嵌套在类X中的类Y的数据成员,而不是名称空间X

到目前为止,名称空间看起来非常像类。

using 指令

因为在名称空间中键入标识符的完整限定可能会很快变得很繁琐,所以using关键字允许您一次导入整个名称空间。当与namespace关键字结合使用时,这被称为使用指令using指令使名字看起来好像属于最近的封闭名称空间范围,因此您可以方便地使用非限定名。考虑一个简单的名称空间,如清单 10-12 所示。

清单 10-12 。演示了一个简单的名称空间

//: C10:NamespaceInt.h
#ifndef NAMESPACEINT_H
#define NAMESPACEINT_H
namespace Int {
  enum sign { positive, negative };
  class Integer {
    int i;
    sign s;
public:
  Integer(int ii = 0)
    : i(ii),
      s(i>= 0 ? positive : negative)
    {}
    sign getSign() const { return s; }
    void setSign(sign sgn) { s = sgn; }
    // ...
  };
}
#endif // NAMESPACEINT_H ///:∼

using指令的一个用途是将Int中的所有名字放入另一个名称空间,让这些名字嵌套在名称空间中,如清单 10-13 所示。

清单 10-13 。说明 using 指令

//: C10:NamespaceMath.h
#ifndef NAMESPACEMATH_H
#define NAMESPACEMATH_H
#include "NamespaceInt.h"  // To be INCLUDED from Header FILE above
namespace Math {
  using namespace Int;
  Integer a, b;
  Integer divide(Integer, Integer);
  // ...
}
#endif // NAMESPACEMATH_H ///:∼

你也可以在一个函数中声明 Int 中的所有名字,但是让这些名字嵌套在函数中,如清单 10-14 所示。

清单 10-14 。说明 using 指令(尽管方式不同)

//: C10:Arithmetic.cpp
#include "NamespaceInt.h"
void arithmetic() {
  using namespace Int;
  Integer x;
  x.setSign(positive);
}
int main(){} ///:∼

如果没有using指令,命名空间中的所有名称都需要完全限定。

最初,using指令的一个方面可能看起来有点违反直觉。用一个using指令引入的名字的可见性是该指令的作用域。但是您可以覆盖来自using指令的名字,就好像它们已经被全局声明到那个作用域一样!参见清单 10-15 中的示例。

清单 10-15 。说明命名空间覆盖

//: C10:NamespaceOverriding1.cpp
#include "NamespaceMath.h"    // To be INCLUDED from Header FILE
                              // above

int main() {

  using namespace Math;

  Integer a;
       // Hides Math::a;
  a.setSign(negative);
       // Now scope resolution is necessary
       // to select Math::a :

  Math::a.setSign(positive);
} ///:∼

假设您有第二个名称空间,其中包含了namespace Math中的一些名字(参见清单 10-16 )。

清单 10-16 。说明名称空间覆盖(同样,尽管以不同的方式)

//: C10:NamespaceOverriding2.h
#ifndef NAMESPACEOVERRIDING2_H
#define NAMESPACEOVERRIDING2_H
#include "NamespaceInt.h"
namespace Calculation {
  using namespace Int;
  Integer divide(Integer, Integer);
  // ...
}
#endif // NAMESPACEOVERRIDING2_H ///:∼

因为这个名称空间也是用一个using指令引入的,所以有可能会发生冲突。然而,歧义出现在名称的使用处,而不是在using指令处,正如你在清单 10-17 中看到的。

清单 10-17 。说明压倒一切的模糊性

//: C10:OverridingAmbiguity.cpp
#include "NamespaceMath.h"
#include "NamespaceOverriding2.h"    // To be INCLUDED from Header
                                     // FILE above
void s() {
  using namespace Math;
  using namespace Calculation;
  // Everything's ok until:
  //! divide(1, 2); // Ambiguity
}
int main() {} ///:∼

因此,可以编写using指令来引入多个名称冲突的名称空间,而不会产生歧义。

using 声明

您可以使用 using 声明 将名称一次注入到当前作用域中。与using指令不同的是,using声明是当前作用域内的声明,而using指令将名称视为作用域内的全局声明。这意味着它可以覆盖来自using指令的名字(见清单 10-18 )。

清单 10-18 。说明 using 声明

//: C10:UsingDeclaration.h
#ifndef USINGDECLARATION_H
#define USINGDECLARATION_H
namespace U {
  inline void f() {}
  inline void g() {}
}
namespace V {
  inline void f() {}
  inline void g() {}
}
#endif                          // USINGDECLARATION_H ///:∼

//: C10:UsingDeclaration1.cpp
#include "UsingDeclaration.h"   // To be INCLUDED from Header                                // FILE above
void h() {
  using namespace U;            // Using directive
  using V::f;                   // Using declaration
  f(); // Calls V::f();
  U::f();                       // Must fully qualify to call
}
int main() {} ///:∼

using声明只是给出了标识符的完整名称,但没有类型信息。这意味着如果名称空间包含一组同名的重载函数,using声明将声明重载集中的所有函数。

您可以将using声明放在普通声明可以出现的任何地方。除了一点之外,using声明在所有方面都像普通声明一样工作:因为你没有给出参数列表,所以using声明有可能导致具有相同参数类型的函数重载(,这在普通重载中是不允许的)。然而,这种模糊性直到使用时才显现出来,而不是在声明时。

一个using声明也可以出现在一个名称空间中,它和其他任何地方具有相同的效果——这个名称是在空间中声明的(参见清单 10-19 )。

清单 10-19 。阐释命名空间中的 using 声明

//: C10:UsingDeclaration2.cpp
#include "UsingDeclaration.h"
namespace Q {
  using U::f;
  using V::g;
  // ...
}
void m() {
  using namespace Q;
  f(); // Calls U::f();
  g(); // Calls V::g();
}
int main() {} ///:∼

using声明是一个别名,它允许您在不同的名称空间中声明相同的函数。如果您最终通过导入不同的名称空间来重新声明同一个函数,这是可以的;不会有任何含糊不清或重复。

名称空间的使用

这些规则中的一些乍一看可能有点令人生畏,尤其是如果你觉得你会一直使用它们。然而,一般来说,只要您理解名称空间是如何工作的,您就可以轻松地使用名称空间。要记住的关键点是,当你引入一个全局的using指令(通过任何作用域之外的using namespace)时,你已经打开了那个文件的名称空间。这对于一个实现文件(cpp文件)来说通常没问题,因为using指令只在该文件编译结束之前有效。也就是说,它不影响任何其他文件,所以您可以一次一个实现文件地调整名称空间的控制。例如,如果您发现一个名字冲突是因为在一个特定的实现文件中有太多的using指令,这是一件简单的事情,修改这个文件,使它使用显式限定或using声明来消除冲突,而不修改其他的实现文件。

头文件是一个不同的问题。实际上,您永远不希望在头文件中引入一个全局的using指令,因为这将意味着包含您的头文件的任何其他文件也将打开名称空间(并且头文件可以包含其他头文件)。

因此,在头文件中,你应该使用显式限定或者限定范围的using指令和using声明。这是你将在本书中找到的实践,通过遵循它,你将不会“污染”全局名称空间,并把你自己扔回到 C++ 的前名称空间世界。

C++ 中的静态成员

有时,您需要一个存储空间供一个类的所有对象使用。在 C 语言中,你会使用一个全局变量,但这不是很安全。任何人都可以修改全局数据,其名称可能会与大型项目中的其他相同名称冲突。如果数据可以像全局数据一样存储,但隐藏在一个类中,并与该类明确关联,这将是非常理想的。

这是通过类中的static数据成员来完成的。对于一个static数据成员有一个单独的存储,不管您创建了多少个该类的对象。所有对象为该数据成员共享相同的static存储空间,因此这是它们相互“通信”的一种方式。但是static数据属于类;它的名字作用于类内部,可以是publicprivateprotected

为静态数据成员定义存储

因为不管创建了多少个对象,数据都只有一个存储,所以必须在一个地方定义这个存储。编译器不会为您分配存储空间。如果声明了一个static数据成员但没有定义,链接器将报告一个错误。

定义必须出现在类之外(不允许内联),并且只允许一个定义。因此,通常将其放在类的实现文件中。语法有时会给人带来麻烦,但它实际上是相当符合逻辑的。例如,如果在类中创建静态数据成员,例如:

class A {
  static int i;
public:
  //...
};

然后,您必须在定义文件中为该静态数据成员定义存储,如下所示:

int A::i = 1;

如果你要定义一个普通的全局变量,你会说

int i = 1;

但是这里使用范围解析操作符和类名来指定A::i

有些人很难接受A::i就是private的想法,然而似乎有什么东西在公开地操纵着它。这不是打破了保护机制吗?这是一种完全安全的做法,原因有二。首先,这种初始化唯一合法的地方是在定义中。事实上,如果static数据是一个带有构造器的对象,你应该调用构造器而不是使用=操作符。第二,一旦定义完成,最终用户就不能进行第二次定义;链接器将报告一个错误。并且类创建者被迫创建定义,否则代码在测试期间不会链接。这确保了定义只出现一次,并且在类创建者的手中。

静态成员的整个初始化表达式都在类的范围内。例如,参见清单 10-20

清单 10-20 。说明静态初始化器的范围

//: C10:Statinit.cpp
// Scope of static initializer
#include <iostream>
using namespace std;

int x = 100;

class WithStatic {
  static int x;
  static int y;
public:
  void print() const {
    cout << "WithStatic::x = " << x << endl;
    cout << "WithStatic::y = " << y << endl;
  }
};

int WithStatic::x = 1;
int WithStatic::y = x + 1;
// WithStatic::x NOT ::x

int main() {
  WithStatic ws;
  ws.print();
} ///:∼

这里,限定符WithStatic::WithStatic的范围扩展到了整个定义。

静态数组初始化

第 8 章介绍了static const变量,它允许你在类体内定义一个常量值。也可以创建static对象的数组,包括const和非const。语法相当一致,正如你在清单 10-21 中看到的。

清单 10-21 。静态数组的语法

//: C10:StaticArray.cpp
// Initializing static arrays in classes
class Values {
  // static consts are initialized in-place:
  static const int scSize = 100;
  static const long scLong = 100;
  // Automatic counting works with static arrays.
  // Arrays, Non-integral and non-const statics
  // must be initialized externally:
  static const int scInts[];
  static const long scLongs[];
  static const float scTable[];
  static const char scLetters[];
  static int size;
  static const float scFloat;
  static float table[];
  static char letters[];
};

int Values::size = 100;
const float Values::scFloat = 1.1;

const int Values::scInts[] = {
  99, 47, 33, 11, 7
};

const long Values::scLongs[] = {
  99, 47, 33, 11, 7
};

const float Values::scTable[] = {
  1.1, 2.2, 3.3, 4.4
};

const char Values::scLetters[] = {
  'a', 'b', 'c', 'd', 'e',
  'f', 'g', 'h', 'i', 'j'
};

float Values::table[4] = {
  1.1, 2.2, 3.3, 4.4
};

char Values::letters[10] = {
  'a', 'b', 'c', 'd', 'e',
  'f', 'g', 'h', 'i', 'j'
};

int main() { Values v; } ///:∼

对于整型类型的static const s,你可以在类内部提供定义,但是对于其他所有类型(包括整型类型的数组,即使它们是static const)你必须为成员提供一个外部定义。这些定义有内部联系,所以可以放在头文件中。初始化静态数组的语法与任何聚合相同,包括自动计数。

你也可以创建类类型的static const对象和这些对象的数组。然而,你不能使用整型内置类型的static const所允许的“内联语法”来初始化它们(参见清单 10-22 )。

清单 10-22 。说明类对象的静态数组

//: C10:StaticObjectArrays.cpp
// Static arrays of class objects
class X {
  int i;
public:
  X(int ii) : i(ii) {}
};

class Stat {
  // This doesn't work, although
  // you might want it to:
//!  static const X x(100);
  // Both const and non-const static class
  // objects must be initialized externally:
  static X x2;
  static X xTable2[];
  static const X x3;
  static const X xTable3[];
};

X Stat::x2(100);

X Stat::xTable2[] = {
  X(1), X(2), X(3), X(4)
};

const X Stat::x3(100);

const X Stat::xTable3[] = {
  X(1), X(2), X(3), X(4)
};

int main() { Stat v; } ///:∼

类对象的const和非const static数组的初始化必须以相同的方式执行,遵循典型的static定义语法。

嵌套类和局部类

您可以轻松地将静态数据成员放入嵌套在其他类中的类中。这种成员的定义是一种直观而明显的扩展——您只需使用另一个级别的范围解析。然而,在局部类中不能有static数据成员(局部类是在函数中定义的类)。例如,参考清单 10-23 中的代码。

清单 10-23 。阐释静态成员和局部类

//: C10:Local.cpp
// Static members & local classes
#include <iostream>
using namespace std;

// Nested class CAN have static data members:
class Outer {
  class Inner {
    static int i; // OK
  };
};

int Outer::Inner::i = 47;

// Local class cannot have static data members:
void f() {
  class Local {
  public:
//! Static int i;  // Error
    // (How would you define i?)
  } x;
}

int main() { Outer x; f(); } ///:∼

您可以看到局部类中的static成员的直接问题:如何在文件范围内描述数据成员以定义它?实际上,很少使用局部类。

静态成员函数

你也可以创建static成员函数,像static数据成员一样,为整个类工作,而不是为一个类的特定对象工作。不要让一个全局函数存在于并“污染”全局或局部命名空间,而是将该函数放入类中。当你创建一个static成员函数时,你表达了与一个特定类的关联。

你可以用普通的方式调用一个static成员函数,用点或箭头,与一个对象相关联。然而,更典型的是使用范围解析操作符单独调用一个static成员函数,没有任何特定的对象,如清单 10-24 所示。

清单 10-24 。演示了一个简单的静态成员函数

//: C10:SimpleStaticMemberFunction.cpp
class X {
public:
  static void f(){};
};

int main() {
  X::f();
} ///:∼

当你在一个类中看到static成员函数时,记住设计者希望这个函数在概念上与类作为一个整体相关联。

一个static成员函数不能访问普通数据成员,只能访问static数据成员。它只能调用其他的static成员函数。正常情况下,调用任何一个成员函数都会悄悄传入当前对象的地址(this),但是一个static成员没有this,这就是它不能访问普通成员的原因。因此,您可以获得全局函数带来的微小速度提升,因为static成员函数没有传递this的额外开销。与此同时,您还可以获得在类中使用该函数的好处。

对于数据成员,static表示一个类的所有对象只存在一个成员数据存储区。这类似于使用static来定义函数“内部”的对象,这意味着只有一个局部变量的副本用于该函数的所有调用。

清单 10-25 是显示一起使用的static数据成员和static成员函数的例子。

清单 10-25 。说明静态数据成员和静态成员函数(组合使用)

//: C10:StaticMemberFunctions.cpp
class X {
  int i;
  static int j;
public:
  X(int ii = 0) : i(ii) {
     // Non-static member function can access
     // static member function or data:
  j = i;
  }
  intval() const { return i; }
  static int incr() {
    //! i++;      // Error: static member function
    // cannot access non-static member data
    return ++j;
  }
  static int f() {
    //! val();    // Error: static member function
    // cannot access non-static member function
    returnincr(); // OK -- calls static
  }
};

int X::j = 0;

int main() {
  X x;
  X* xp = &x;
  x.f();
  xp->f();
  X::f();         // Only works with static members
} ///:∼

因为没有this指针,static成员函数既不能访问非static数据成员,也不能调用非static成员函数。

注意在main()中,可以使用通常的点或箭头语法选择一个static成员,将该函数与一个对象相关联,但也可以不与任何对象相关联(因为一个 static 成员与一个类相关联,而不是一个特定的对象),使用类名和范围解析操作符。

这里有一个有趣的特性:由于static成员对象的初始化方式,您可以将同一个类的static数据成员放在该类的“内部”。清单 10-26 是一个例子,通过使构造器私有,只允许一个E类型的对象存在。您可以访问该对象,但是不能创建任何新的E对象。

image 注意这就是所谓的*“独生子女”模式*!

清单 10-26 。说明“单例”模式

//: C10:Singleton.cpp
// Static member of same type, ensures that
// only one object of this type exists.
// Also referred to as the "singleton" pattern.
#include <iostream>
using namespace std;

class E {
  static Ee;
  int i;
  E(int ii) : i(ii) {}
  E(const E&); // Prevent copy-construction
public:
  static E* instance() { return &e; }
  int val() const { return i; }
};

E E::e(47);

int main() {
//!  E x(1);   // Error -- can't create an E
  // You can access the single instance:
  cout << E::instance()->val() << endl;
} ///:∼

e的初始化发生在类声明完成之后,因此编译器拥有分配存储和调用构造器所需的所有信息。

为了完全防止创建任何其他对象,还添加了其他东西:第二个私有构造器叫做复制构造器 。在这本书的这一点上,你不能知道为什么这是必要的,因为复制构造器直到下一章才会被介绍。然而,作为一个预览,如果你要删除在清单 10-26 中定义的复制构造器,你将能够创建一个E对象,如下所示:

E e = *Egg::instance();
E e2(*Egg::instance());

这两种方法都使用复制构造器,所以为了防止复制构造器被声明为私有的。

image 注意没有定义是必要的,因为它从来没有被调用过。

下一章的很大一部分是关于复制构造器的讨论,所以你应该很清楚了。

静态初始化依赖关系

在特定的翻译单元中,静态对象的初始化顺序保证是对象定义在该翻译单元中出现的顺序。销毁的顺序保证与初始化的顺序相反。

然而,不能保证静态对象翻译单元中的初始化顺序,语言也没有提供指定这种顺序的方法。这可能会导致严重的问题。举一个瞬间灾难的例子(它将暂停原始的操作系统并终止复杂系统的进程),如果一个文件包含

//: C10:Out.cpp {O}
// First file
#include <fstream>
std::ofstream out("out.txt"); ///:∼

另一个文件在它的初始化器中使用了out对象

//: C10:Oof.cpp
// Second file
//{L} Out
#include <fstream>
Extern std::ofstream out;
classOof {
public:
  Oof() { std::out << "ouch"; }
} oof;
int main() {} ///:∼

这个计划可能行得通,也可能行不通。如果编程环境构建程序时,第一个文件在第二个文件之前初始化,那么就不会有问题。然而,如果第二个文件在第一个文件之前被初始化,Oof的构造器依赖于还没有被构造的out的存在,这将导致混乱。

这个问题只发生在相互依赖的静态对象初始化器上。翻译单元中的静态数据在该单元中第一次调用函数之前被初始化——但也可能是在main()之后。如果静态对象在不同的文件中,你不能确定它们的初始化顺序。

*一个更微妙的例子可以在手臂上找到。在全局范围的一个文件中,

extern int y;
int x = y + 1;

在另一个全局范围的文件中

extern int x;
int y = x + 1;

对于所有静态对象,链接加载机制保证在程序员指定的动态初始化发生之前,静态初始化为零。在前面的例子中,fstream out对象占用的存储空间的清零没有特殊的意义,所以在调用构造器之前,它确实是未定义的。但是,对于内置类型,初始化为零没有意义,如果按照上面显示的顺序初始化文件,y开始静态初始化为零,因此x变为一,y动态初始化为二。但是,如果以相反的顺序初始化文件,x静态初始化为零,y动态初始化为一,x则变为二。

程序员必须意识到这一点,因为他们可以创建一个具有静态初始化依赖关系的程序,并让它在一个平台上工作,但将它移到另一个编译环境中,它会突然神秘地不起作用。

解决问题

处理这个问题有三种方法。

  1. 别这么做。避免静态初始化依赖是最好的解决方案。
  2. 如果您必须这样做,请将关键的静态对象定义放在一个文件中,这样您就可以通过将它们按正确的顺序放置来方便地控制它们的初始化。
  3. 如果您确信在翻译单元中分散静态对象是不可避免的——就像在一个库的情况下,您无法控制使用它的程序员——有两种编程技术可以解决这个问题。

技巧一

这种技术是由杰瑞·施瓦茨在创建 iostream 库时首创的(因为cincoutcerr的定义是static,并且位于一个单独的文件中)。它实际上不如第二种技术,但是它已经存在很长时间了,所以你可能会遇到使用它的代码;因此,理解它的工作原理是很重要的。

这项技术需要在库头文件中添加一个额外的类。这个类负责库的静态对象的动态初始化。清单 10-27 显示了一个简单的例子。

清单 10-27 。说明“技术一”

//: C10:Initializer.h
// Static initialization technique
#ifndef INITIALIZER_H
#define INITIALIZER_H
#include <iostream>
extern int x; // Declarations, not definitions
extern int y;

class Initializer {
  static int initCount;
public:
  Initializer() {
    std::cout << "Initializer()" << std::endl;
    // Initialize first time only
    if(initCount++ == 0) {
      std::cout << "performing initialization"
                << std::endl;
      x = 100;
      y = 200;
    }
  }
  ∼Initializer() {
    std::cout << "∼Initializer()" << std::endl;
    // Clean up last time only
    if(--initCount == 0) {
      std::cout << "performing cleanup"
                << std::endl;
      // Any necessary cleanup here
    }
  }
};

// The following creates one object in each
// file where Initializer.h is included, but that
// object is only visible within that file:
static Initializer init;
#endif        // INITIALIZER_H ///:∼

xy的声明只声明了这些对象的存在,但是它们没有为这些对象分配存储空间。然而,Initializer init的定义在包含头文件的每个文件中为该对象分配存储空间。但是因为名字是static(这次控制的是可视性,而不是存储分配的方式;默认情况下,存储在文件范围内),它只在翻译单元内可见,因此链接器不会抱怨多个定义错误。

清单 10-28 包含了xyinitCount的定义。

清单 10-28 。说明清单 10-27 中头文件的定义

//: C10:InitializerDefs.cpp {O}
// Definitions for Initializer.h
#include "Initializer.h"      // To be INCLUDED from Header FILE
                              // above
// Static initialization will force
// all these values to zero:
int x;
int y;
int Initializer::initCount;
///:∼

image 评论当然,在包含头文件的时候,init的一个文件静态实例也被放在这个文件中。

假设库用户创建了另外两个文件(参见清单 10-2910-30 )。

清单 10-29 。说明静态初始化(针对第一个文件)

//: C10:Initializer.cpp {O}
// Static initialization
#include "Initializer.h"
///:∼

清单 10-30 。说明了更多的静态初始化(对于第二个文件)

//: C10:Initializer2.cpp
//{L} InitializerDefs Initializer
// Static initialization
#include "Initializer.h"
using namespace std;

int main() {
  cout << "inside main()" << endl;
  cout << "leaving main()" << endl;
} ///:∼

现在先初始化哪个翻译单元已经不重要了。第一次初始化包含Initializer.h的翻译单元时,initCount将为零,因此将执行初始化。

image 注意这很大程度上取决于这样一个事实,即在任何动态初始化发生之前,静态存储区被设置为零。

对于所有剩余的翻译单元,initCount将是非零的,初始化将被跳过。清理以相反的顺序发生,∼Initializer()确保它只会发生一次。

这个例子使用内置类型作为全局静态对象。该技术也适用于类,但是这些对象必须由Initializer类动态初始化。一种方法是创建没有构造器和析构函数的类,而是使用不同的名字初始化和清除成员函数。然而,更常见的方法是拥有指向对象的指针,并使用Initializer()中的new来创建它们。

技巧二

在技术一被使用很久之后,有人(我不知道是谁)提出了本节中解释的技术,它比技术一简单和干净得多。花了这么长时间才发现的事实是对 C++ 复杂性的一种赞颂。

这种技术依赖于这样一个事实,即函数内部的静态对象只在第一次调用函数时被初始化。请记住,我们在这里真正要解决的问题不是何时静态对象被初始化(可以单独控制),而是确保初始化以正确的顺序发生。

这种手法非常工整巧妙。对于任何初始化依赖项,都将静态对象放在返回对该对象的引用的函数中。这样,访问静态对象的唯一方法是调用函数,如果该对象需要访问它所依赖的其他静态对象,它必须调用它们的函数。第一次调用函数时,它会强制进行初始化。静态初始化的顺序保证是正确的,是因为代码的设计,而不是因为链接器建立的任意顺序。

举个例子,清单 10-31清单 10-32 包含两个相互依赖的类。第一个包含一个仅由构造器初始化的bolo,因此您可以判断该类的静态实例是否调用了构造器(静态存储区在程序启动时被初始化为零,如果没有调用构造器,它会为bolo生成一个false值)。

清单 10-31 。说明第一个依赖类

//: C10:Dependency1.h
#ifndef DEPENDENCY1_H
#define DEPENDENCY1_H
#include <iostream>

class Dependency1 {
  bool init;
public:
  Dependency1() : init(true) {
    std::cout << "Dependency1 construction"
              < <std::endl;
  }
void print() const {
  std::cout << "Dependency1 init: "
           << init << std::endl;
  }
};
#endif // DEPENDENCY1_H ///:∼

清单 10-32 。说明第二个依赖类

//: C10:Dependency2.h
#ifndef DEPENDENCY2_H
#define DEPENDENCY2_H
#include "Dependency1.h"      // To be INCLUDED from Header FILE
                              // above

class Dependency2 {
  Dependency1 d1;
public:
  Dependency2(const Dependency1& dep1): d1(dep1){
    std::cout << "Dependency2 construction ";
    print();
  }
  void print() const { d1.print(); }
};
#endif // DEPENDENCY2_H ///:∼

构造器也会在它被调用的时候发出声明,你可以print()对象的状态来发现它是否已经被初始化。

第二个类是从第一个类的对象初始化的,这将导致依赖关系(清单 10-32 )。

构造器声明自己并打印出d1对象的状态,这样您就可以看到在调用构造器时它是否已经被初始化了。

为了演示什么会出错,清单 10-33 中的代码首先将静态对象定义放在了错误的顺序中,因为如果链接器碰巧在初始化Dependency1对象之前初始化了Dependency2对象,就会出现这种情况。然后颠倒顺序,以显示如果顺序恰好是“正确的”,它是如何正确工作的。最后,演示技术二。

清单 10-33 。说明技术二

//: C10:Technique2.cpp
#include "Dependency2.h"      // To be INCLUDED from Header FILE
                              // above

using namespace std;

// Returns a value so it can be called as
// a global initializer:
int separator() {
  cout << "---------------------" << endl;
  return 1;
}

// Simulate the dependency problem:
extern Dependency1 dep1;
Dependency2 dep2(dep1);
Dependency1 dep1;
int x1 = separator();

// But if it happens in this order it works OK:
Dependency1 dep1b;
Dependency2 dep2b(dep1b);
int x2 = separator();

// Wrapping static objects in functions succeeds
Dependency1&d1() {
  static Dependency1 dep1;
  return dep1;
}

Dependency2&d2() {
  static Dependency2 dep2(d1());
  return dep2;
}

int main() {
  Dependency2& dep2 = d2();
} ///:∼

为了提供更具可读性的输出,创建了函数separator()。诀窍是你不能全局调用一个函数,除非这个函数被用来执行变量的初始化,所以separator()返回一个空值,用来初始化几个全局变量。

函数d1()d2()包装Dependency1Dependency2对象的静态实例。现在,您可以访问静态对象的唯一方法是调用函数,这将在第一次函数调用时强制静态初始化。这意味着初始化保证是正确的,当你运行程序并查看输出时,你会看到这一点。

下面是如何组织代码来使用这种技术。通常,静态对象会被定义在单独的文件中(因为出于某种原因,您被迫这样做;请记住,在单独的文件中定义静态对象是导致问题的原因),所以应该在单独的文件中定义包装函数。但是它们需要在头文件中声明,参见清单 10-34 和清单 10-35 。

清单 10-34 。说明了第一个头文件

//: C10:Dependency1StatFun.h
#ifndef DEPENDENCY1STATFUN_H
#define DEPENDENCY1STATFUN_H
#include "Dependency1.h"
extern Dependency1& d1();
#endif // DEPENDENCY1STATFUN_H ///:∼

实际上,“extern”对于函数声明来说是多余的。这里是第二个头文件(清单 10-35 )。

清单 10-35 。示出了第二头文件

//: C10:Dependency2StatFun.h
#ifndef DEPENDENCY2STATFUN_H
#define DEPENDENCY2STATFUN_H
#include "Dependency2.h"
extern Dependency2& d2();
#endif // DEPENDENCY2STATFUN_H ///:∼

现在,在之前放置静态对象定义的实现文件中,改为放置包装函数定义,如清单 10-3610-37 所示。

清单 10-36 。说明第一个实现文件

//: C10:Dependency1StatFun.cpp {O}
#include "Dependency1StatFun.h" // To be INCLUDED from Header FILE
                                // above

Dependency1&d1() {
  static Dependency1 dep1;
  return dep1;
} ///:∼

据推测,其他代码也可能放在这些文件中。这是另一个文件(清单 10-37 )。

清单 10-37 。示出了第二实现文件

//: C10:Dependency2StatFun.cpp {O}
#include "Dependency1StatFun.h"
#include "Dependency2StatFun.h"  // To be INCLUDED from Header FILE
                                 // above

Dependency2&d2() {
  static Dependency2 dep2(d1());
  return dep2;
} ///:∼

所以现在有两个文件可以以任何顺序链接,如果它们包含普通的静态对象,可以产生任何顺序的初始化。但是因为它们包含包装函数,不存在不正确初始化的威胁(见清单 10-38 )。

清单 10-38 。说明初始化不受链接顺序的影响

//: C10:Technique2b.cpp

//{L} Dependency1StatFun Dependency2StatFun

#include "Dependency2StatFun.h"

int main() { d2(); } ///:∼

当您运行这个程序时,您会看到静态对象Dependency1的初始化总是发生在静态对象Dependency2的初始化之前。您还可以看到,这是一种比技术一简单得多的方法。

您可能想将d1()d2()作为内联函数写在它们各自的头文件中,但是这是您绝对不能做的事情。一个内联函数 可以在它出现的每个文件中被复制——这种复制包括静态对象定义。因为内联函数自动默认为内部链接,这将导致跨各种翻译单元的多个静态对象,这肯定会导致问题。因此,您必须确保每个包装函数只有一个定义,这意味着不要将包装函数内联。

替代连杆规格

如果你用 C++ 写一个程序,你想使用 C 库,会发生什么?如果您声明了 C 函数,

float f(int a, char b);

C++ 编译器将把这个名字修饰成类似于_f_int_char的东西,以支持函数重载(和类型安全链接)。然而,编译你的 C 库的 C 编译器最确定的是而不是修饰了这个名字,所以它的内部名字将是_f。因此,链接器将不能解析你对f()的 C++ 调用。

C++ 中提供的转义机制是交替链接规范,它是通过重载extern关键字在语言中产生的。extern后面是一个字符串,它指定了声明的链接,后面是声明,比如:

extern "C" float f(int a, char b);

这告诉编译器把 C 链接到f(),这样编译器就不会修饰名字。该标准支持的唯一两种类型的链接规范是“C”“C++,”,但是编译器供应商可以选择以同样的方式支持其他语言。

如果您有一组具有替代链接的声明,请将它们放在大括号内,如下所示:

extern "C" {

  float f(int a, char b);
  double d(int a, char b);

}

或者,对于头文件,

extern "C" {
#include "Myheader.h"
}

大多数 C++ 编译器供应商都在头文件中处理可用于 C 和 C++ 的替代链接规范,所以您不必担心。

审查会议

  1. static关键字可能会引起混淆,因为在某些情况下,它控制存储的位置,而在其他情况下,它控制名称的可见性链接。
  2. 随着 C++ 名称空间的引入,您有了一个改进的和更加灵活的选择来控制大型项目中名称的扩散。
  3. 在类中使用 static 是控制程序名称的另一种方式。名字不会与全局名字冲突,可见性和访问保持在程序内部,给你更大的控制权来维护你的代码。**