十、名称控制
创建名字是编程中的一项基本活动,当一个项目变得很大时,名字的数量很容易变得令人难以招架。
C++ 允许您对名称的创建和可见性、名称的存储位置以及名称的链接进行大量控制。
在人们知道术语“重载”是什么意思之前,C 中的关键字static
就已经重载了,而 C++ 又增加了另一个意思。所有使用static
的潜在概念似乎是“保持其位置的东西”(如静电),无论这是指内存中的物理位置还是文件中的可见性。
在这一章中,你将学习static
如何控制存储和可见性,以及一种通过 C++ 的名称空间特性来控制名称访问的改进方法。您还将了解如何使用用 c 编写和编译的函数。
来自 C 的静态元素
在 C 和 C++ 中,关键字static
有两个基本含义,不幸的是经常会踩到对方的脚趾。
- 在固定地址分配一次;也就是说,每次调用函数时,对象是在一个特殊的静态数据区域中创建的,而不是在堆栈中创建的。这就是静态存储的概念。
- 对于特定的翻译单元是局部的(对于 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*
#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 b
和g()
内的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()
被调用,那么b
和c
的析构函数被调用的顺序取决于是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;
///:∼
对于函数名(对于非成员函数),static
和extern
只能改变可见性,所以如果你说
extern void f();
这和未经修饰的声明
void f();
如果你说,
static void f();
这意味着f()
只在这个翻译单元内可见。这有时称为文件静态 。
其他存储类说明符
你会看到常用的static
和extern
。还有另外两种不常出现的存储类说明符*。auto
说明符几乎从不使用,因为它告诉编译器这是一个局部变量。auto
是“自动”的缩写,指的是编译器自动为变量分配存储的方式。编译器总能从定义变量的上下文中确定这个事实,所以auto
是多余的。*
一个register
变量是一个局部(auto
)变量,伴随着一个提示编译器这个特殊的变量将被大量使用,所以编译器应该尽可能地把它保存在一个寄存器中。因此,它是一个优化辅助工具*。不同的编译器对这个提示有不同的反应;他们可以选择忽略它。如果你取变量的地址,那么register
说明符几乎肯定会被忽略。你应该避免使用register
,因为编译器通常能比你做得更好。
名称空间
尽管名字可以嵌套在类中,但是全局函数、全局变量和类的名字仍然在一个全局名字空间中。static
关键字通过允许你给变量和函数内部链接(也就是说,使它们成为静态文件)来给你一些控制。但是在一个大型项目中,缺乏对全局名称空间的控制会导致问题。为了解决类的这些问题,供应商通常会创建不太可能冲突的长而复杂的名称,但这样一来,您就不得不键入这些名称。(经常用一个typedef
来简化这个。)这不是一个优雅的、受语言支持的解决方案。
您可以使用 C++ 的名称空间特性将全局名称空间细分为更易于管理的部分。与class
、struct
、enum
和union
类似,namespace
关键字将其成员的名字放在一个不同的空间中。虽然其他关键字有额外的目的,但是创建新的名称空间是namespace
的唯一目的。
创建名称空间
命名空间的创建非常类似于class
的创建;参见清单 10-6 。
清单 10-6 。创建名称空间
//: C10:MyLib.cpp
namespace MyLib {
// Declarations
}
int main() {} ///:∼
这将产生一个包含所包含声明的新名称空间。与class
、struct
、union
和enum
有显著差异,但是:
-
命名空间定义只能出现在全局范围内,或者嵌套在另一个命名空间内。
-
命名空间定义的右括号后不需要终止分号。
-
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
数据属于类;它的名字作用于类内部,可以是public
、private
或protected
。
为静态数据成员定义存储
因为不管创建了多少个对象,数据都只有一个存储,所以必须在一个地方定义这个存储。编译器不会为您分配存储空间。如果声明了一个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
的范围扩展到了整个定义。
静态数组初始化
第八章介绍了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
对象。
注意这就是所谓的*“独生子女”模式*!
清单 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());
这两种方法都使用复制构造器,所以为了防止复制构造器被声明为私有的。
注意没有定义是必要的,因为它从来没有被调用过。
下一章的很大一部分是关于复制构造器的讨论,所以你应该很清楚了。
静态初始化依赖关系
在特定的翻译单元中,静态对象的初始化顺序保证是对象定义在该翻译单元中出现的顺序。销毁的顺序保证与初始化的顺序相反。
然而,不能保证静态对象在翻译单元中的初始化顺序,语言也没有提供指定这种顺序的方法。这可能会导致严重的问题。举一个瞬间灾难的例子(它将暂停原始的操作系统并终止复杂系统的进程),如果一个文件包含
//: 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
则变为二。
程序员必须意识到这一点,因为他们可以创建一个具有静态初始化依赖关系的程序,并让它在一个平台上工作,但将它移到另一个编译环境中,它会突然神秘地不起作用。
解决问题
处理这个问题有三种方法。
- 别这么做。避免静态初始化依赖是最好的解决方案。
- 如果您必须这样做,请将关键的静态对象定义放在一个文件中,这样您就可以通过将它们按正确的顺序放置来方便地控制它们的初始化。
- 如果您确信在翻译单元中分散静态对象是不可避免的——就像在一个库的情况下,您无法控制使用它的程序员——有两种编程技术可以解决这个问题。
技巧一
这种技术是由杰瑞·施瓦茨在创建 iostream 库时首创的(因为cin
、cout
和cerr
的定义是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 ///:∼
x
和y
的声明只声明了这些对象的存在,但是它们没有为这些对象分配存储空间。然而,Initializer init
的定义在包含头文件的每个文件中为该对象分配存储空间。但是因为名字是static
(这次控制的是可视性,而不是存储分配的方式;默认情况下,存储在文件范围内),它只在翻译单元内可见,因此链接器不会抱怨多个定义错误。
清单 10-28 包含了x
、y
和initCount
的定义。
清单 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;
///:∼
评论当然,在包含头文件的时候,
init
的一个文件静态实例也被放在这个文件中。
假设库用户创建了另外两个文件(参见清单 10-29 和 10-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
将为零,因此将执行初始化。
注意这很大程度上取决于这样一个事实,即在任何动态初始化发生之前,静态存储区被设置为零。
对于所有剩余的翻译单元,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()
包装Dependency1
和Dependency2
对象的静态实例。现在,您可以访问静态对象的唯一方法是调用函数,这将在第一次函数调用时强制静态初始化。这意味着初始化保证是正确的,当你运行程序并查看输出时,你会看到这一点。
下面是如何组织代码来使用这种技术。通常,静态对象会被定义在单独的文件中(因为出于某种原因,您被迫这样做;请记住,在单独的文件中定义静态对象是导致问题的原因),所以应该在单独的文件中定义包装函数。但是它们需要在头文件中声明,参见清单 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-36 和 10-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++ 的替代链接规范,所以您不必担心。
审查会议
static
关键字可能会引起混淆,因为在某些情况下,它控制存储的位置,而在其他情况下,它控制名称的可见性和链接。- 随着 C++ 名称空间的引入,您有了一个改进的和更加灵活的选择来控制大型项目中名称的扩散。
- 在类中使用 static 是控制程序名称的另一种方式。名字不会与全局名字冲突,可见性和访问保持在程序内部,给你更大的控制权来维护你的代码。**
十一、引用和复制构造器
引用就像被编译器自动取消引用的常量指针。
尽管 Pascal 中也有引用,但 C++ 版本来自 Algol 语言。在 C++ 中,它们对于支持操作符重载的语法是必不可少的(参见第十二章),但它们也是控制参数传入和传出函数的一种便利方式。
本章将首先简要介绍 C 和 C++ 中指针的区别,然后介绍引用。但是这一章的大部分将深入到一个对新 C++ 程序员来说相当困惑的问题:复制构造器*,一个特殊的构造器(需要引用),它从一个相同类型的现有对象创建一个新对象。编译器使用复制构造器通过值将对象*传入和传出函数。最后,说明了有点模糊的 C++ 指向成员的指针特性。**
*C++ 中的指针
C 和 C++ 中的指针最重要的区别是 C++ 是一种更强类型的语言。这一点与void*
有关。c 不允许你随便把一种类型的指针赋给另一种类型,但是允许你通过void*
来完成这个任务。因此,
bird *b;
rock *r;
void *v;
v = r;
b = v;
因为 C 的这个“特性”允许你像对待其他类型一样安静地对待任何类型,所以它在类型系统中留下了一个大洞。C++ 不允许这样;编译器会给你一个错误消息,如果你真的想把一种类型当作另一种类型,你必须使用强制转换把它明确地告诉编译器和读者。
注 第三章介绍了 C++ 改进的“显式”强制转换语法。
C++ 中的引用
引用(&
)就像一个常量指针,它被自动解引用。它通常用于函数参数列表和函数返回值。但是你也可以做一个独立的参考。例如,见清单 11-1 。
清单 11-1 。说明独立式参考
//: C11:FreeStandingReferences.cpp
#include <iostream>
using namespace std;
// Ordinary free-standing reference:
int y;
int& r = y;
// When a reference is created, it must
// be initialized to a live object.
// However, you can also say:
const int& q = 12; // (1)
// References are tied to someone else's storage:
int x = 0; // (2)
int& a = x; // (3)
int main() {
cout << "x = " << x << ", a = " << a << endl;
a++;
cout << "x = " << x << ", a = " << a << endl;
} ///:∼
在第(1)行,编译器分配一块存储空间,用值 12 初始化它,并将引用绑定到那块存储空间。关键是任何引用都必须绑定到某人的存储块。当你访问一个引用时,你就是在访问那个存储。因此,如果你写像(2)和(3)这样的行,那么增加a
实际上是增加x
,如main( )
所示。再说一次,考虑引用最简单的方法是把它当作一个漂亮的指针。这个“指针”的一个优点是你永远不必担心它是否已经被初始化(编译器强制它)以及如何去引用它(编译器这样做)。
使用引用时有一定的规则 。
- 创建引用时必须对其进行初始化。(指针可以随时初始化。)
- 一旦引用被初始化为一个对象,它就不能被更改为引用另一个对象。(指针可以随时指向另一个对象。)
- 不能有空引用。您必须始终能够假设引用连接到合法的存储区。
函数 中的引用
最常见的引用是函数参数和返回值。当引用被用作函数参数时,对函数内部引用的任何修改都会导致函数外部参数的改变。当然,你可以通过传递一个指针来做同样的事情,但是引用的语法要干净得多。
注如果你愿意,你可以把引用看作仅仅是一种语法上的便利。
如果你从一个函数返回一个引用,你必须像从一个函数返回一个指针一样小心。当函数返回时,无论引用连接到什么都不应该消失;否则你将引用未知的内存。参见清单 11-2 中的示例。
清单 11-2 。演示简单的 C++ 引用
//: C11:Reference.cpp
// Simple C++ references
int *f(int* x) {
(*x)++;
return x; // Safe, x is outside this scope
}
int& g(int& x) {
x++; // Same effect as in f()
return x; // Safe, outside this scope
}
int& h() {
int q;
//! return q; // Error
static int x;
return x; // Safe, x lives outside this scope
}
int main() {
int a = 0;
f(&a); // Ugly (but explicit)
g(a); // Clean (but hidden)
} ///:∼
对f( )
的调用没有使用引用的方便和简洁,但是很明显传递的是一个地址。在对g( )
的调用中,一个地址正在被传递(通过一个引用),但是您没有看到它。
常量引用
只有当参数是非const
对象时,Reference.cpp
中的引用参数才有效。如果是const
对象,函数g( )
不会接受实参,这其实是一件好事,因为函数确实修改了外面的实参。如果你知道这个函数将遵守一个对象的const attribute
,使参数成为一个const
引用将允许这个函数在所有情况下使用。这意味着,对于内置类型,函数不会修改参数,对于用户自定义类型,函数只会调用const
成员函数,不会修改任何public
数据成员。
在函数参数中使用const
引用尤其重要,因为您的函数可能会接收一个临时对象。这可能是作为另一个函数的返回值创建的,或者是由您的函数的用户显式创建的。临时对象总是const
,所以如果你不使用const
引用,编译器不会接受这个参数。清单 11-3 是一个非常简单的例子。
清单 11-3 。说明引用的传递为常量
//: C11:ConstReferenceArguments.cpp
// Passing references as const
void f(int&) {}
void g(const int&) {}
int main() {
//! f(1); // Error
g(1);
} ///:∼
对f(1)
的调用会导致编译时错误,因为编译器必须首先创建一个引用。它通过为一个int
分配存储空间,将其初始化为 1,并产生绑定到引用的地址。存储器必须是的const
,因为改变它是没有意义的——你永远也不可能再得到它。对于所有的临时对象,你必须做出相同的假设:它们是不可访问的。当你改变这些数据时,编译器告诉你是有价值的,因为结果会丢失信息。
指针引用
在 C 中,如果你想修改指针的内容而不是它所指向的内容,你的函数声明应该是这样的
void f(int**);
当你传入指针时,你必须接受它的地址,比如:
int i = 47;
int* ip = &i;
f(&ip);
用 C++ 中的引用 ,语法更干净。函数参数变成了对指针的引用,你不再需要获取指针的地址,因此清单 11-4 中的代码。
清单 11-4 。示出了对指针的引用
//: C11:ReferenceToPointer.cpp
#include <iostream>
using namespace std;
void increment(int*& i) { i++; }
int main() {
int* i = 0;
cout << "i = " << i << endl;
increment(i);
cout << "i = " << i << endl;
} ///:∼
通过运行这个程序,您将向自己证明指针是*递增的,*不是它所指向的。
论证传递准则
向函数传递参数时,您通常的习惯应该是通过const
引用传递。虽然乍一看,这似乎只是一个效率问题(在设计和汇编程序时,您通常不希望自己关心效率调整),但这涉及到更多的问题:正如您将在本章的剩余部分看到的,需要一个复制构造器来按值传递对象,而这并不总是可用的。
对于这样一个简单的习惯来说,效率的节省是巨大的:通过值传递一个参数需要一个构造器和析构函数调用,但是如果你不打算修改参数,那么通过const
引用传递只需要一个压入堆栈的地址。
事实上,实际上唯一一次传递地址不是更可取的时候是当你要对一个对象做这样的破坏,以至于通过值传递是唯一安全的方法(而不是修改外部对象,这是调用者通常不期望的)。这是下一节的主题。
复制构造器
现在您已经理解了 C++ 中引用的基础,您已经准备好处理语言中更容易混淆的概念之一:复制构造器,通常称为X(X&)
(" X of X ref ")。此构造器对于在函数调用期间通过值控制用户定义类型的传递和返回是必不可少的。事实上,这很重要,如果你自己没有提供复制构造器,编译器会自动合成一个,你会看到的。
通过值传递和返回
为了理解对复制构造器的需求,考虑一下 C 在函数调用期间通过值传递和返回变量的方式。如果声明一个函数并进行函数调用,如:
int f(int x, char c);
int g = f(a, b);
编译器如何知道如何传递和返回那些变量?它就是知道!它必须处理的类型范围很小(char
、int
、float
、double
以及它们的变体),因此这些信息被内置到编译器中。
如果您知道如何用您的编译器生成汇编代码,并确定对f( )
的函数调用所生成的语句,您将得到相当于
push b
push a
call f()
add sp, 4
mov g, register a
这段代码经过了大量的清理,使其具有通用性;根据变量是全局变量(在这种情况下,它们将是_b
和_a
)还是局部变量(编译器将从堆栈指针中索引它们),对于b
和a
的表达式会有所不同。对于g
的表达也是如此。对f( )
调用的外观将取决于您的名称修饰方案,而register a
取决于 CPU 寄存器在您的汇编程序中是如何命名的。然而,代码背后的逻辑将保持不变。
在 C 和 C++ 中,参数首先从右到左推入堆栈,然后进行函数调用。调用代码负责清除堆栈中的参数(这是add sp, 4
的原因)。但是请注意,为了通过值传递参数,编译器只是将副本压入堆栈。它知道它们有多大,并且推动这些参数会产生它们的精确副本。
f( )
的返回值放在寄存器中。同样,编译器知道关于返回值类型的所有信息,因为该类型内置于语言中,所以编译器可以通过将它放在寄存器中来返回它。对于 C # 中的原始数据类型,复制值的位的简单行为等同于复制对象。
传递和返回大型物体
现在让我们考虑用户定义的类型。如果你创建了一个类,你想通过值传递这个类的一个对象,编译器怎么知道该做什么?这不是编译器内置的类型;这是你创造的一种类型。为了研究这个问题,你可以从一个简单的结构开始,这个结构显然太大而不能在寄存器中返回,如清单 11-5 中的所示。
清单 11-5 。说明大型建筑的经过
//: C11:PassingBigStructures.cpp
struct Big {
char buf[100];
int i;
long d;
} B, B2;
Big bigfun(Big b) {
b.i = 100; // Do something to the argument
return b;
}
int main() {
B2 = bigfun(B);
} ///:∼
这里解码汇编输出稍微复杂一点,因为大多数编译器使用“助手”函数,而不是将所有功能内联。在main( )
中,对bigfun( )
的调用如你所料开始:B
的全部内容被压入堆栈。
注意在这里你可能会看到一些编译器用
Big
的地址和大小来加载寄存器,然后调用一个帮助器函数将Big
推到堆栈上。
在前面的代码片段中,在进行函数调用之前,只需要将参数推送到堆栈上。然而,在PassingBigStructures.cpp
( 清单 11-5 )中,您将看到一个额外的动作:在进行调用之前,推送B2
的地址,尽管这显然不是一个参数。为了理解这里发生的事情,你需要理解编译器在进行函数调用时的约束。
函数-调用堆栈帧
当编译器为一个函数调用生成代码时,它首先将所有参数推入堆栈,然后它进行调用。在函数内部,生成代码来进一步下移堆栈指针,以便为函数的局部变量提供存储空间。(“下”在这里是相对的;在推送过程中,您的机器可能会递增或递减堆栈指针。)但是在汇编语言调用过程中,CPU 会推送程序代码中函数调用来自的地址,所以汇编语言返回可以使用那个地址返回到调用点。当然,这个地址是神圣的,因为没有它,你的程序将会完全丢失。图 11-1 显示了在函数中调用和分配局部变量存储后堆栈帧的样子。
图 11-1 。栈框架
为函数的其余部分生成的代码希望内存完全按照这种方式布局,这样它就可以小心地从函数参数和局部变量中进行选择,而不会触及返回地址。我将把这个内存块称为函数框架,它是一个函数在函数调用过程中使用的所有东西。
您可能认为尝试在堆栈上返回值是合理的。编译器可以简单地推它,函数可以返回一个偏移量来指示返回值在堆栈中的起始位置。
再入
出现问题是因为 C 和 C++ 中的函数支持中断;也就是说,语言是可重入的。它们还支持递归函数调用。这意味着在程序执行的任何时候,一个中断都可以在不中断程序的情况下发生。当然,编写中断服务程序(ISR) 的人负责保存和恢复 ISR 中使用的所有寄存器,但是如果 ISR 需要使用堆栈中更低的任何内存,这必须是一件安全的事情。
注意你可以把一个 ISR 想象成一个普通的函数,没有参数,
void
返回值保存和恢复 CPU 状态。ISR 函数调用是由一些硬件事件触发的,而不是来自程序内部的显式调用。
现在想象一下,如果一个普通的函数试图返回堆栈上的值,会发生什么。你不能接触返回地址之上的栈的任何部分,所以函数必须把值推到返回地址之下。但是当执行汇编语言返回时,堆栈指针必须指向返回地址(或者在它的正下方,这取决于你的计算机),所以就在返回之前,函数必须向上移动堆栈指针,从而清除它的所有局部变量。如果你试图在返回地址下面返回栈上的值,你在那个时刻变得脆弱,因为一个中断可能会出现。ISR 会向下移动堆栈指针来保存它的返回地址和局部变量,并覆盖你的返回值。
为了解决这个问题,调用者可以在调用函数之前负责在堆栈上为返回值分配额外的存储空间。但是,C 不是这样设计的,C++ 必须兼容。您很快就会看到,C++ 编译器使用了一种更有效的方案。
您的下一个想法可能是返回某个全局数据区域中的值,但这也不行。可重入性意味着任何函数都可以是任何其他函数的中断例程,包括您当前所在的同一个函数。因此,如果您将返回值放在一个全局区域中,您可能会返回到同一个函数中,这将覆盖该返回值。同样的逻辑也适用于递归。
返回值的唯一安全的地方是在寄存器中,所以我们又回到了当寄存器不足以容纳返回值时该怎么办的问题上。答案是将返回值的目的地地址推送到堆栈上,作为函数参数之一,让函数将返回信息直接复制到目的地。这不仅解决了所有问题,而且效率更高。这也是为什么在PassingBigStructures.cpp
(清单 11-5)中,编译器在调用main( )
中的bigfun( )
之前推送B2
的地址。如果您查看bigfun( )
的汇编输出,您可以看到它期望这个隐藏的参数,并在函数中执行复制到目标的操作。
下面将讨论与这种可重入函数相关的汇编语言代码。为了从键盘输入字符,你使用一个系统服务来读取一个字符串( syscall 8)。可以使用的特定组assembly language instructions
是
li $v0, 8 # system call code to Read a String
la $a0, buffer # load address of input buffer into $a0
li $a1, 60 # Length of buffer
syscall
这显然是一个以十六进制表示读取值的不可重入函数。
编写可重入代码有两条规则。
- 所有局部变量必须在堆栈上动态分配。
- 全局数据段中不应存在任何读/写数据。
因此,为了使这样的函数可重入,必须从全局数据段中移除字符缓冲区的空间分配,并且必须将代码插入到函数中,以便在堆栈上为字符缓冲区动态分配空间。
假设您想在堆栈上为 32 个字符的输入缓冲区分配空间,在$a0 中初始化一个指针指向这个缓冲区中的第一个字符,然后从键盘读入一个字符串。这可以通过以下汇编语言代码来实现:
addiu $sp, $sp, -32 # Allocate Space on top of stack
move $a0, $sp # Initialize $a0 as a pointer to the buffer
li $a1, 32 # Specify length of buffer
li $v # System call code to Read String
syscall
位复制与初始化
到目前为止,一切顺利!传递和返回大型简单结构有一个可行的过程。但是请注意,您所拥有的只是一种将位从一个地方复制到另一个地方的方法,这对于 C 语言查看变量的原始方式来说当然很好。但是在 C++ 中,对象可以比一片比特复杂得多;它们有意义。这个意义可能不太适合复制它的位。
考虑一个简单的例子:一个类知道在任何时候在有多少属于它的类型的对象(见清单 11-6 )。从第十章,你知道这样做的方法是通过包含一个static
数据成员。
清单 11-6 。说明了一个对其对象进行计数的类(通过包含一个静态数据成员)
//: C11:HowMany.cpp
// A class that counts its objects
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany.out");
classHowMany {
static int objectCount;
public:
HowMany() { objectCount++; }
static void print(const string&msg = "") {
if(msg.size() != 0) out << msg << ": ";
out << "objectCount = "
<< objectCount << endl;
}
∼HowMany() {
objectCount--;
print("∼HowMany()");
}
};
int HowMany::objectCount = 0;
// Pass and return BY VALUE:
HowManyf(HowMany x) {
x.print("x argument inside f()");
return x;
}
int main() {
HowMany h;
HowMany::print("after construction of h");
HowMany h2 = f(h);
HowMany::print("after call to f()");
} ///:∼
类HowMany
包含一个static int objectCount
和一个static
成员函数print( )
来报告那个objectCount
的值,以及一个可选的消息参数。每当创建一个对象时,构造器递增计数,析构函数递减计数。
然而,输出并不是您所期望的。
after construction of h: objectCount = 1
x argument inside f(): objectCount = 1
∼HowMany(): objectCount = 0
after call to f(): objectCount = 0
∼HowMany(): objectCount = -1
∼HowMany(): objectCount = -2
创建h
后,对象计数为 1,没问题。但是在调用了f( )
之后,您会期望对象计数为 2,因为h2
现在也在范围内。取而代之的是,计数为 0,这表明出现了可怕的错误。最后的两个析构函数使对象计数变为负数,这是不应该发生的事情,这一事实证实了这一点。
看一下f( )
里面的点,它发生在参数通过值传递之后。这意味着原始对象h
存在于函数框架之外,在函数框架内还有一个额外的对象*,它是通过值传递的副本。然而,该参数是使用 C 的原始位复制概念传递的,而 C++ HowMany
类需要真正的初始化来保持其完整性,因此默认的位复制无法产生预期的效果。*
当本地对象在对f( )
的调用结束时超出范围时,析构函数被调用,该析构函数递减objectCount
,因此函数外的objectCount
为零。h2
的创建也是使用位复制来执行的,所以这里也不会调用构造器,当h
和h2
超出范围时,它们的析构函数会导致objectCount
的负值。
复制构造
出现这个问题是因为编译器假设如何从现有对象创建新对象。当您通过值传递对象时,您将从现有对象(函数框架外的原始对象)创建一个新对象(函数框架内的传递对象)。当从一个函数返回一个对象时,这通常也是正确的。在表达式中
HowMany h2 = f(h);
先前未构造的对象h2
是从f( )
的返回值创建的,因此新对象也是从现有对象创建的。
编译器的假设是你想使用一个位拷贝来执行这个创建,并且在许多情况下这可能工作得很好,但是在HowMany
中它不能运行,因为初始化的意义超出了简单的拷贝。另一个常见的例子发生在类包含指针的时候:它们指向什么,你应该复制它们还是应该把它们连接到新的内存中?
幸运的是,您可以干预这个过程,防止编译器进行位复制。您可以通过定义自己的函数来做到这一点,只要编译器需要从现有对象创建一个新对象,就可以使用这个函数。从逻辑上来说,你在创建一个新的对象,所以这个函数是一个构造器,从逻辑上来说,这个构造器的单个参数和你正在构造的对象有关。但是那个对象不能通过值传递到构造器中,因为你试图定义处理通过值传递的函数,并且从语法上来说传递指针是没有意义的,因为毕竟你是从一个现有的对象创建新的对象。在这里,引用帮助了我们,所以我们使用源对象的引用。这个函数被称为复制构造器,通常被称为X(X&)
,这是它在一个名为X
的类中的表现。
如果创建复制构造器,编译器在从现有对象创建新对象时不会执行位复制。它总是调用你的复制构造器。所以,如果你不创建复制构造器,编译器会做一些明智的事情,但是你可以选择接管整个过程的控制权。
现在有可能修复HowMany.cpp
中的问题;见清单 11-7 。
清单 11-7 。说明如何解决问题
//: C11:HowMany2.cpp
// The copy-constructor
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany2.out");
class HowMany2 {
string name; // Object identifier
static int objectCount;
public:
HowMany2(const string &id = "") : name(id) {
++objectCount;
print("HowMany2()");
}
∼HowMany2() {
--objectCount;
print("∼HowMany2()");
}
// The copy-constructor:
HowMany2(const HowMany2 &h) : name(h.name) {
name += " copy";
++objectCount;
print("HowMany2(const HowMany2&)");
}
void print(const string &msg = "") const {
if(msg.size() != 0)
out << msg << endl;
out << '\t' << name << ": "
<< "objectCount = "
<< objectCount << endl;
}
};
int HowMany2::objectCount = 0;
// Pass and return BY VALUE:
HowMany2 f(HowMany2 x) {
x.print("x argument inside f()");
out << "Returning from f()" << endl;
return x;
}
int main() {
HowMany2 h("h");
out << "Entering f()" << endl;
HowMany2 h2 = f(h);
h2.print("h2 after call to f()");
out << "Call f(), no return value" << endl;
f(h);
out << "After call to f()" << endl;
} ///:∼
这里有一些新的变化,所以你可以更好地了解正在发生的事情。首先,当打印关于对象的信息时,stringname
作为对象标识符。在构造器中,您可以放置一个标识符字符串(通常是对象的名称),使用string
构造器将其复制到name
。默认的= ""
创建一个空的string
。构造器像以前一样递增*objectCount
*,析构函数递减。**
接下来是复制构造器,HowMany2(const HowMany2&)
。复制构造器只能从现有对象创建新对象,所以现有对象的名称被复制到name
,后面跟着单词“copy ”,这样您就可以知道它是从哪里来的。如果你仔细观察,你会发现构造器初始化列表*中的调用name(h.name)
实际上是在调用string
复制构造器。
在复制构造器内部,对象计数就像在普通构造器内部一样递增。这意味着当通过值传递和返回时,您现在将获得一个准确的对象计数。
对print( )
函数进行了修改,以打印出消息、对象标识符和对象计数。它现在必须访问特定对象的name
数据,所以它不再是一个static
成员函数。
在main( )
内部,可以看到已经添加了对f( )
的第二次调用。然而,这个调用使用了常见的忽略返回值的 C 方法。但是现在您知道了值是如何返回的(也就是说,函数中的代码处理返回过程,将结果放入一个目的地,该目的地的地址作为隐藏参数传递),您可能想知道当返回值被忽略时会发生什么。程序的输出会对此有所启发。
在显示输出之前,清单 11-8 是一个小程序,它使用iostream
给任何文件添加行号。
清单 11-8 。说明如何向任何文件添加行号(使用 iostream)
//: C11:Linenum.cpp
//{T} Linenum.cpp
// Add line numbers
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 9*
#include <vector>
#include <string>
#include <fstream>
#include <iostream>
#include <cmath>
using namespace std;
int main(int argc, char* argv[]) {
requireArgs(argc, 1, "Usage: linenum file\n"
"Adds line numbers to file");
ifstream in(argv[1]);
assure(in, argv[1]);
string line;
vector<string> lines;
while(getline(in, line)) // Read in entire file
lines.push_back(line);
if(lines.size() == 0) return 0;
int num = 0;
// Number of lines in file determines width:
const int width =
int(log10((double)lines.size())) + 1;
for(int i = 0; i < lines.size(); i++) {
cout.setf(ios::right, ios::adjustfield);
cout.width(width);
cout << ++num << ") " << lines[i] << endl;
}
} ///:∼
使用您在本书前面看到的相同代码将整个文件读入一个vector<string>
。当打印行号时,您希望所有的行都相互对齐,这需要调整文件中的行数,以便行号允许的宽度一致。使用vector::size( )
可以很容易的确定行数,但是你真正需要知道的是是否超过 10 行,100 行,1000 行等等。如果你取文件中行数的对数,以 10 为底,将其截成一个int
,并在值上加 1,你会发现你的行数的最大宽度。
您会注意到在for
循环中有几个奇怪的调用:setf( )
和width( )
。这些是 i ostream
调用,在这种情况下,允许您控制输出的调整和宽度。然而,每次输出一行时都必须调用它们,这就是为什么它们在for
循环中的原因。
当Linenum.cpp
应用于HowMany2.out
时,结果为
1) HowMany2()
2) h: objectCount = 1
3) Entering f()
4) HowMany2(const HowMany2&)
5) h copy: objectCount = 2
6) x argument inside f()
7) h copy: objectCount = 2
8) Returning from f()
9) HowMany2(const HowMany2&)
10) h copy copy: objectCount = 3
11) ∼HowMany2()
12) h copy: objectCount = 2
13) h2 after call to f()
14) h copy copy: objectCount = 2
15) Call f(), no return value
16) HowMany2(const HowMany2&)
17) h copy: objectCount = 3
18) x argument inside f()
19) h copy: objectCount = 3
20) Returning from f()
21) HowMany2(const HowMany2&)
22) h copy copy: objectCount = 4
23) ∼HowMany2()
24) h copy: objectCount = 3
25) ∼HowMany2()
26) h copy copy: objectCount = 2
27) After call to f()
28) ∼HowMany2()
29) h copy copy: objectCount = 1
30) ∼HowMany2()
31) h: objectCount = 0
正如您所料,首先发生的是为h
调用普通的构造器,这将对象计数增加到 1。但是,当输入f( )
时,编译器会悄悄地调用复制构造器来执行传值操作。创建了一个新对象,它是f( )
的函数框架内h
(因此得名h copy
)的副本,因此对象计数变为 2,这是由复制构造器提供的。
第八行表示从f( )
返回的开始。但是在局部变量h copy
可以被销毁之前(它在函数的最后超出了作用域),它必须被复制到返回值中,而返回值恰好是h2
。一个先前未构造的对象(h2
)是从一个现有的对象(f( )
中的局部变量)创建的,所以当然在第九行再次使用了复制构造器。现在名称变成了h2
标识符的h copy copy
,因为它是从f( )
中的本地对象拷贝而来的。在对象返回之后,但在函数结束之前,对象计数暂时变为 3,但随后本地对象hcopy
被销毁。在第 13 行对f( )
的调用完成后,只有两个对象,h
和h2
,您可以看到h2
确实以h copy copy
结束。
临时对象
第 15 行开始调用f(h)
,这次忽略返回值。您可以在第 16 行看到,复制构造器像前面一样被调用来传递参数。和以前一样,第 21 行显示了为返回值调用复制构造器。但是复制构造器必须有一个地址作为它的目的地(一个this
指针)。这个地址是哪里来的?
事实证明,编译器可以在需要的时候创建一个临时对象来正确地计算表达式。在这种情况下,它会创建一个您甚至看不到的值,作为被忽略的返回值f( )
的目的地。这个临时对象的生命周期越短越好,这样景观就不会被那些等待被破坏和占用宝贵资源的临时对象弄得乱七八糟。在某些情况下,临时对象可能会立即被传递给另一个函数,但是在这种情况下,在函数调用之后就不需要它了,所以一旦函数调用通过调用本地对象的析构函数而结束(第 23 和 24 行),临时对象就会被销毁(第 25 和 26 行)。
最后,在第 28-31 行,h2
对象被销毁,随后是h
,对象计数正确地回到零。
默认复制构造器
因为复制构造器通过值来实现传递和返回,所以在简单结构的情况下,编译器为您创建一个复制构造器是很重要的——实际上与它在 c 中所做的一样。但是,到目前为止,您所看到的都是默认的原始行为:位复制。
当涉及到更复杂的类型时,如果你不创建一个复制构造器,C++ 编译器仍然会自动创建一个。然而,同样,位复制没有意义,因为它不一定实现正确的含义。
下面的例子展示了编译器采用的更智能的方法。假设您创建了一个由几个现有类的对象组成的新类。这被恰当地称为组合,这是从现有类创建新类的方法之一。现在假设一个天真的用户试图通过这种方式创建一个新类来快速解决问题。你不知道复制构造器,所以你没有创建一个。清单 11-9 展示了编译器在为你的新类创建默认复制构造器时做了什么。
清单 11-9 。说明默认复制构造器的创建
//: C11:DefaultCopyConstructor.cpp
// Automatic creation of the copy-constructor
#include <iostream>
#include <string>
using namespace std;
class WithCC { // With copy-constructor
public:
// Explicit default constructor required:
WithCC() {}
WithCC(const WithCC&) {
cout << "WithCC(WithCC&)" << endl;
}
};
classWoCC { // Without copy-constructor
string id;
public:
WoCC(const string &ident = "") : id(ident) {}
void print(const string &msg = "") const {
if(msg.size() != 0) cout << msg << ": ";
cout << id << endl;
}
};
class Composite {
WithCC withcc; // Embedded objects
WoCC wocc;
public:
Composite() : wocc("Composite()") {}
void print(const string &msg = "") const {
wocc.print(msg);
}
};
int main() {
Composite c;
c.print("Contents of c");
cout << "Calling Composite copy-constructor"
<< endl;
Composite c2 = c; // Calls copy-constructor
c2.print("Contents of c2");
} ///:∼
类WithCC
包含一个复制构造器,它简单地声明它已经被调用,这带来了一个有趣的问题。在类Composite
中,使用默认的构造器创建了一个WithCC
对象。如果WithCC
中根本没有构造器,编译器会自动创建一个默认的构造器,在这种情况下它什么也不做。然而,如果你添加了一个复制构造器,你已经告诉编译器你将处理构造器的创建,所以它不再为你创建一个默认的构造器,并且会报错,除非你像对WithCC
那样显式地创建一个默认的构造器。
类WoCC
没有复制构造器,但是它的构造器会在内部string
中存储一条消息,这条消息可以使用print( )
打印出来。该构造器在Composite
的构造器初始化列表中被显式调用(在第八章中有简要介绍,在第十四章中有完整介绍)。这样做的原因稍后会变得明显。
类Composite
有WithCC
和WoCC
的成员对象,没有明确定义的复制构造器
注意嵌入对象
wocc
在构造器-初始化器列表中初始化,因为它必须是。
然而,在main( )
中,使用定义中的复制构造器创建一个对象:
Composite c2 = c;
Composite
的复制构造器是由编译器自动创建的,程序的输出揭示了它的创建方式。
Contents of c: Composite()
Calling Composite copy-constructor
WithCC(WithCC&)
Contents of c2: Composite()
为了给使用复合(和继承,在第十四章中介绍)的类创建一个复制构造器,编译器递归调用所有成员对象和基类的复制构造器。也就是说,如果成员对象也包含另一个对象,它的复制构造器也被调用。所以在这种情况下,编译器调用WithCC
的复制构造器。输出显示这个构造器被调用。因为WoCC
没有复制构造器,编译器为它创建了一个只执行位复制的构造器,并在Composite
复制构造器中调用它。对main()
中的Composite::print( )
的调用表明,这是因为c2.wocc
的内容与c.wocc
的内容相同。编译器合成复制构造器的过程被称为基于成员的初始化 。
最好是创建自己的复制构造器,而不是让编译器替你做。这保证了它将在你的控制之下。
复制构造的替代方案
在这一点上,您可能会头晕,您可能会想,在不了解复制构造器的情况下,您怎么可能编写出一个工作类。但是记住:只有当你打算通过值传递你的类的一个对象时,你才需要一个复制构造器。如果这永远不会发生,你就不需要复制构造器。
防止传值
“但是,”你说,“如果我不创建一个复制构造器,编译器会为我创建一个。那么我怎么知道一个对象永远不会被传值呢?”
有一个简单的技术可以防止传值:声明一个private
复制构造器。你甚至不需要创建一个定义,除非你的一个成员函数或者一个friend
函数需要执行一个传值操作。如果用户试图通过值传递或返回对象,编译器会产生一个错误消息,因为复制构造器是private
。它不能再创建默认的复制构造器,因为您已经明确声明您将接管该任务。清单 11-10 就是一个例子。
清单 11-10 。说明防止复制构造
//: C11:NoCopyConstruction.cpp
// Preventing copy-construction
Class NoCC {
int i;
NoCC(const NoCC&); // No definition
public:
NoCC(int ii = 0) : i(ii) {}
};
void f(NoCC);
int main() {
NoCC n;
//! f(n); // Error: copy-constructor called
//! NoCC n2 = n; // Error: c-c called
//! NoCCn3(n); // Error: c-c called
} ///:∼
注意使用了更一般的形式
NoCC(const NoCC&);
使用const
。
修改外部对象的功能
引用语法比指针语法更好用,但是它混淆了读者的意思。例如,在 iostreams 库中,get( )
函数的一个重载版本将一个char&
作为参数,该函数的全部目的是通过插入get( )
的结果来修改其参数。但是,当您使用这个函数读取代码时,您不会立即发现外部对象被修改了:
char c;
cin.get(c);
相反,这个函数调用看起来像一个传值函数,这表明外部对象是被而不是修改的。
因此,从代码维护的角度来看,在传递要修改的参数的地址时,使用指针可能更安全。如果你总是将地址作为const
引用传递,除了当你打算通过地址修改外部对象时,你通过非const
指针传递,那么你的代码对读者来说更容易理解。
指向成员的指针
指针是保存某个位置地址的变量。您可以更改指针在运行时选择的内容,指针的目标可以是数据或函数。C++ 指向成员的指针遵循同样的概念,除了它选择的是类内的一个位置。这里的困境是,一个指针需要一个地址,但是类内部没有“地址”;选择一个类的成员意味着偏移到该类中。只有将偏移量与特定对象的起始地址结合起来,才能产生实际的地址。指向成员的指针的语法要求您在解引用指向成员的指针的同时选择一个对象。
为了理解这个语法,考虑一个简单的结构,这个结构有一个指针sp
和一个对象so
。您可以使用清单 11-11 中所示的语法选择成员。
清单 11-11 。说明在简单结构中选择成员的语法
//: C11:SimpleStructure.cpp
struct Simple { int a; };
int main() {
Simple so, *sp = &so;
sp->a;
so.a;
} ///:∼
现在假设你有一个指向整数的普通指针,ip
。要访问ip
所指向的内容,您可以用一个‘*
’取消对指针的引用,如下所示:
*ip = 4;
最后,考虑一下,如果你有一个指针恰好指向一个类对象内部的某个东西,即使它实际上代表了一个对象的偏移量,会发生什么。要访问它所指向的内容,必须用*
取消对它的引用。但是它是一个对象的偏移量,所以你也必须引用那个特定的对象。因此,*
与对象解引用相结合。所以新的语法变成了指向对象的指针的–>*
,对象或引用的.*
,就像这样:
objectPointer->*pointerToMember = 47;
object.*pointerToMember = 47;
现在,定义pointerToMember
的语法是什么?像任何指针一样,你必须说出它所指向的类型,并且在定义中使用了一个*
。唯一的区别是你必须说明这个指向成员的指针是和什么类的对象一起使用的。当然,这是通过类名和范围解析操作符来实现的。因此,
int ObjectClass::*pointerToMember;
定义一个名为pointerToMember
的指向成员变量的指针,该变量指向ObjectClass
中的任何一个int
。您也可以在定义成员指针时(或在任何其他时候)初始化它,如:
int ObjectClass::*pointerToMember = &ObjectClass::a;
实际上没有ObjectClass::a
的“地址”,因为你只是引用这个类,而不是这个类的一个对象。因此,&ObjectClass::a
只能用作指向成员的指针语法。
清单 11-12 显示了如何创建和使用指向成员的指针。
清单 11-12 。说明数据成员的指向成员的语法(也演示了指向成员的指针的创建&用法)
//: C11:PointerToMemberData.cpp
#include <iostream>
using namespace std;
class Data {
public:
int a, b, c;
void print() const {
cout << "a = " << a << ", b = " << b
<< ", c = " << c << endl;
}
};
int main() {
Data d, *dp = &d;
int Data::*pmInt = &Data::a;
dp->*pmInt = 47;
pmInt = &Data::b;
d.*pmInt = 48;
pmInt = &Data::c;
dp->*pmInt = 49;
dp->print();
} ///:∼
显然,除了特殊情况(这正是它们为设计的),这些都太难用了。
此外,指向成员的指针非常有限:它们只能被分配到类中的特定位置。例如,你不能像普通指针那样递增或比较它们。
功能
类似的练习产生了成员函数的指向成员的语法(见清单 11-13 )。指向一个函数的指针(在第三章的结尾介绍)是这样定义的:
int (*fp)(float);
(*fp)
周围的括号是强制编译器正确评估定义所必需的。如果没有它们,这个函数似乎会返回一个int*
。
在定义和使用指向成员函数的指针时,括号也起着重要的作用。如果在一个类中有一个函数,那么可以通过在普通的函数指针定义中插入类名和作用域解析操作符来定义指向该成员函数的指针。
清单 11-13 。阐释成员函数的成员指针语法
//: C11:PmemFunDefinition.cpp
class Simple2 {
public:
int f(float) const { return 1; }
};
int (Simple2::*fp)(float) const;
int (Simple2::*fp2)(float) const = &Simple2::f;
int main() {
fp = &Simple2::f;
} ///:∼
在fp2
的定义中,你可以看到一个指向成员函数的指针也可以在它被创建时初始化,或者在其他任何时候初始化。与非成员函数不同,在获取成员函数的地址时,&
是而不是可选的。但是,您可以给出不带参数列表的函数标识符,因为重载决策可以由指向成员的指针的类型来确定。
一个例子
指针的价值在于你可以在运行时改变它所指向的内容,这为你的编程提供了重要的灵活性,因为通过指针你可以在运行时选择或改变行为。指向成员的指针也不例外;它允许您在运行时选择成员。通常,你的类只有公开可见的成员函数(数据成员通常被认为是底层实现的一部分),所以清单 11-14 在运行时选择成员函数。
清单 11-14 。说明运行时成员函数的选择
//: C11:PointerToMemberFunction.cpp
#include <iostream>
using namespace std;
class Widget {
public:
void f(int) const { cout << "Widget::f()\n"; }
void g(int) const { cout << "Widget::g()\n"; }
void h(int) const { cout << "Widget::h()\n"; }
void i(int) const { cout << "Widget::i()\n"; }
};
int main() {
Widget w;
Widget* wp = &w;
void (Widget::*pmem)(int) const = &Widget::h;
(w.*pmem)(1);
(wp->*pmem)(2);
} ///:∼
当然,期望普通用户创建如此复杂的表达式并不是特别合理。如果用户必须直接操作指向成员的指针,那么typedef
是合适的。要真正清理这些东西,您可以使用指向成员的指针作为内部实现机制的一部分。清单 11-15 是对清单 11-14 的修改,在类中使用了一个指向成员的指针*。用户需要做的就是输入一个数字来选择一个功能。*
清单 11-15 。说明在类中使用指向成员的指针
//: C11:PointerToMemberFunction2.cpp
#include <iostream>
using namespace std;
class Widget {
void f(int) const { cout<< "Widget::f()\n"; }
void g(int) const { cout<< "Widget::g()\n"; }
void h(int) const { cout<< "Widget::h()\n"; }
void i(int) const { cout<< "Widget::i()\n"; }
enum { cnt = 4 };
void (Widget::*fptr[cnt])(int) const;
public:
Widget() {
fptr[0] = &Widget::f; // Full spec required
fptr[1] = &Widget::g;
fptr[2] = &Widget::h;
fptr[3] = &Widget::i;
}
void select(int i, int j) {
if(i < 0 || i >= cnt) return;
(this->*fptr[i])(j);
}
int count() { return cnt; }
};
int main() {
Widget w;
for(int i = 0; i < w.count(); i++)
w.select(i, 47);
} ///:∼
在类接口和main( )
中,你可以看到整个实现,包括函数,都被隐藏起来了。代码甚至必须要求函数的count( )
。这样,类实现者可以改变底层实现中的函数数量,而不会影响使用该类的代码。
构造器中指向成员的指针的初始化可能看起来过分指定了。难道你不应该说
fptr[1] = &g;
因为名字g
出现在成员函数中,自动在类的作用域内?问题是这不符合指向成员的指针语法,这是每个人,尤其是编译器,都需要知道发生了什么的语法。类似地,当指向成员的指针被解引用时,看起来就像
(this->*fptr[i])(j);
也是超规定的;this
看起来多余。同样,该语法要求在取消引用对象时,指向成员的指针总是绑定到对象。
审查会议
- C++ 中的指针和 C 中的指针几乎一模一样,这很好。否则,很多 C 代码在 C++ 下都无法正常编译。您将产生的唯一编译时错误发生在危险的赋值中。如果这些确实是我们想要的,那么编译时错误可以通过一个简单的(并且显式的!)演员阵容。
- C++ 还增加了来自 Algol 和 Pascal 的引用,就像一个常量指针,被编译器自动解引用。引用保存一个地址,但是你把它当作一个对象。引用对于使用操作符重载(下一章的主题)的简洁语法是必不可少的,但是它们也为普通函数传递和返回对象增加了语法上的便利。
- 复制构造器引用了一个与它的参数类型相同的现有对象,它用于从一个现有对象创建一个新对象。当你通过值传递或返回一个对象时,编译器自动调用
copy-constructor
。虽然编译器会自动为您创建一个copy-constructor
,但是如果您认为您的类需要它,您应该自己定义它以确保正确的行为发生。如果你不想让对象通过值传递或返回,你应该创建一个私有的copy-constructor
。 - 指向成员的指针与普通指针具有相同的功能:您可以在运行时选择特定的存储区域(数据或函数)。指向成员的指针只是碰巧使用类成员,而不是全局数据或函数。您获得了编程灵活性,允许您在运行时改变行为。**