引用就像被编译器自动取消引用的常量指针。
尽管 Pascal 中也有引用,但 C++ 版本来自 Algol 语言。在 C++ 中,它们对于支持操作符重载的语法是必不可少的(参见第 12 章),但它们也是控制参数传入和传出函数的一种便利方式。
本章将首先简要介绍 C 和 C++ 中指针的区别,然后介绍引用。但是这一章的大部分将深入到一个对新 C++ 程序员来说相当困惑的问题:复制构造器*,一个特殊的构造器(需要引用),它从一个相同类型的现有对象创建一个新对象。编译器使用复制构造器通过值将对象*传入和传出函数。最后,说明了有点模糊的 C++ 指向成员的指针特性。**
*C++ 中的指针
C 和 C++ 中的指针最重要的区别是 C++ 是一种更强类型的语言。这一点与void*有关。c 不允许你随便把一种类型的指针赋给另一种类型,但是允许你通过void*来完成这个任务。因此,
bird *b;
rock *r;
void *v;
v = r;
b = v;因为 C 的这个“特性”允许你像对待其他类型一样安静地对待任何类型,所以它在类型系统中留下了一个大洞。C++ 不允许这样;编译器会给你一个错误消息,如果你真的想把一种类型当作另一种类型,你必须使用强制转换把它明确地告诉编译器和读者。
注 第 3 章介绍了 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 。栈框架
为函数的其余部分生成的代码希望内存完全按照这种方式布局,这样它就可以小心地从函数参数和局部变量中进行选择,而不会触及返回地址。我将把这个内存块称为函数框架,它是一个函数在函数调用过程中使用的所有东西。
您可能认为尝试在堆栈上返回值是合理的。编译器可以简单地推它,函数可以返回一个偏移量来指示返回值在堆栈中的起始位置。
再入〔t0〕〔t1〕
出现问题是因为 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 )。从第 10 章,你知道这样做的方法是通过包含一个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](09.html)*
#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的构造器初始化列表中被显式调用(在第 8 章中有简要介绍,在第 14 章中有完整介绍)。这样做的原因稍后会变得明显。
类Composite有WithCC和WoCC的成员对象,没有明确定义的复制构造器
注意嵌入对象wocc在构造器-初始化器列表中初始化,因为它必须是。
然而,在main( )中,使用定义中的复制构造器创建一个对象:
Composite c2 = c;Composite的复制构造器是由编译器自动创建的,程序的输出揭示了它的创建方式。
Contents of c: Composite()
Calling Composite copy-constructor
WithCC(WithCC&)
Contents of c2: Composite()为了给使用复合(和继承,在第 14 章中介绍)的类创建一个复制构造器,编译器递归调用所有成员对象和基类的复制构造器。也就是说,如果成员对象也包含另一个对象,它的复制构造器也被调用。所以在这种情况下,编译器调用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。 - 指向成员的指针与普通指针具有相同的功能:您可以在运行时选择特定的存储区域(数据或函数)。指向成员的指针只是碰巧使用类成员,而不是全局数据或函数。您获得了编程灵活性,允许您在运行时改变行为。**
