警告;
在现代 C++中,应尽可能避免底层内存操作,而使用现代结构,例如容器和智能指针。
使用动态内存
警告:
作为经验法则,每次声明一个指针变量时,务必立即用适当的指针或 nullptr 进行初始化!
例如,图 7-1 显示了执行以下代码后的内存状态。这行代码在一个函数内,所以i是一个局部变量:
int i = 7;
i是在栈上分配的自动变量。当程序流离开作用域(变量在这个作用域中声明)时,会自动释放i。
使用 new 关键字时,内存分配在堆上。下面的代码在堆栈上创建了一个变量 ptr,然后在堆上分配内存,ptr 指向这块内存。
int* ptr = nullptr;
ptr = new int;
也可缩减为一行:
int* ptr = new int;
图 7-2 显示了执行该代码后内存的状态。注意,变量 ptr 仍在堆栈上,即使它指向的是堆中的内存。指针只是一个变量,可在堆栈或堆中,不过人们很容易忘记这一点。然而,动态内存总是在堆上分配。
警告:
作为经验法则,每次声明一个指针变量时,务必立即用适当的指针或 nullptr 进行初始化!
下一个例子展示了指针既可在堆栈中,也可在堆中。
int** handle = nullptr;
handle = new int*;
*handle = new int;
上面的代码首先声明一个指向整数指针的指针变量 handle。然后,动态分配足够的内存来保存一个指向整数的指针,并将指向这个新内存的指针保存在 handle 中。接下来,将另一块足以保存整数的动态内存的指针保存在*handle 的内存位置。图 7-3 展示了这个两级指针,其中一个指针保存在堆栈中(handle),另一个指针保存在堆中(*handle)。
使用 new 和 delete
例如,下面的代码孤立了一块保存 int 的内存。图 7-4 显示了代码执行后的内存状态。当堆中有数据块无法从堆栈中直接或间接访问时,这块内存就被孤立(或泄漏?) 。
void leaky()
{
new int;
cout<<"I just leaked an int"<<endl;
}
关于 malloc()函数
如果你是一位 C 程序员,你可能想知道 malloc()函数存在什么问题。在 C 中,通过 malloc()分配给定字节数的内存。大多数情况下,使用 malloc()简单明了。尽管在 C++中仍然存在 malloc(),但应避免使用它。new 相比 malloc()的主要好处在于,new 不仅分配内存,还构建对象。例如,考虑下面两行代码,这段代码使用了一个名为 Foo 的假想类:
Foo* myFoo = (Foo*)malloc(sizeof(Foo));
Foo* myOtherFoo = new Foo():
执行这些代码行后,myFoo 和 myOtherFoo 将指向堆中足以保存 Foo 对象的内存区域。通过这两个指针可访问 Foo 的数据成员和方法。不同之处在于,myFoo 指向的 Foo 对象不是一个正常的对象,因为这个对象从未构建。malloc()函数只负责留出一块一定大小的内存。它不知道或关心对象本身。相反,调用 new 不仅会分配正确大小的内存,还会调用相应的构造函数以构建对象。
在 C++中有一个继承自 C 语言的函数 realloc()。不要使用它! 在 C 中,realloc0用于改变数组的大小,采取的方法是分配新大小的新内存块,然后将所有旧数据复制到新位置,再删除旧内存块。在 C++中这种做法是极其危险的,因为用户定义的对象不能很好地适应按位复制。
警告:
不要在 C++中使用 realloc0)。这个函数很危险。
对象的数组
class Simple
{
public:
Simple(){cout<<"Simple constructor called!"<<endl;}
~Simple(){cout<<"Simple destructor called!"<<endl;}
}
如果要分配包含4个 Simple 对象的数组,那么 Simple 构造函数会被调用 4 次,
Simple* mySimpleArray = new Simple[4];
删除数组
Simple* mySimpleArray = new Simple[4];// Use mySimpleArray ...delete [] mySimpleArray;mySimpleArray = nullptr;
多维堆数组
char** board = new char[i][j]; // BUG! Doesn't compile
[外链图片转存中…(img-YQoyyAa7-1632236813906)]
智能指针有多种类型。最简单的智能指针类型对资源有唯一的所有权, 当智能指针离开作用域或被重置时,会释放所引用的内存。标准库提供了 std::unique_ptr,这是一个具有“唯一所有权”语义的智能指针。然而,指针的管理不仅是在指针离开作用域时释放它们。有时,多个对象或代码段包含同一个指针的多个副本。这个问题称为别名。为正确释放所有内存,使用这个资源的最后一个代码块应该释放该指针指向的资源。
然而,往往很难知道哪个代码块在最后使用这块内存。甚至有时不可能判断代码的执行顺序,因为这取决于运行时的输入。因此,一种更成熟的智能指针类型实现了“引用计数”来跟踪指针的所有者。每次复制这个“引用计数”智能指针时,都会创建一个指向同一资源的新实例,将引用计数增加 1。当这样的一个智能指针实例离开作用域或被重置时,引用计数会减 1。当引用计数降为 0 时,则资源不再有所有者,因此智能指针释放资源。标准库提供了 std::shared_ptr, 这是一个使用引用计数且具有“共享所有权”语义的智能指针。标准的 shared_ptr是线程安全的,但这不意味着所指向的资源是线程安全的。第 23 章将讨论多线程。
注意:
应将 unique ptr 用作默认智能指针。仅当真正需要共享资源时,才使用 shared_ ptr。
警告:
永远不要将资源分配结果指定给普通指针。无论使用哪种资源分配方法,都应当立即将资源指针存储在智能指针 unique_ptr 或 shared_ ptr 中,或使用其他RAI 类。RAI 代表 Resource Acquisition Is Initialization(资源获取即初始化)。RAI 类获取某个资源的所有权,并在适当的时候进行释放。第 28 章将讨论这种设计技术。
unique_ptr
作为经验法则,总将动态分配的对象保存在堆栈的 unique_ptr 实例中。创建 unique_ptrs考虑下面的函数,这个函数在堆上分配了一个 Simple 对象,但是不释放这个对象,故意产生内存泄漏。
void leak()
{
Simple* mySimplePtr = new Simple();
mySimplePtr->go();
}
有时你可能认为,代码正确地释放了动态分配的内存。遗憾的是,这种想法几乎总是不正确的。看看下面的函数:
void couldBeLeaky()
{
Simple* mySimplePtr = new Simple();
mySimplePtr->go();
delete mySimplePtr;
}
上面的函数动态分配一个 Simple 对象,使用该对象,然后正确地调用 delete。但是,这个例子仍然可能会产生内存泄漏! 如果 go(方法抛出一个异常,将永远不会调用 delete,导致内存泄漏。这两种情况下应使用 unique ptr。对象不会显式删除,但实例 unique_ptr 离开作用域时(在函数的末尾,或者因为抛出了异常),就会在其析构函数中自动释放 Simple 对象:
void notLeaky()
{
auto mySimpleSmartPtr = make_unique<Simple>();
mySimpleSmartPtr->go();
}
这段代码使用 C++14 中的 make_unique(0和 auto 关键字,所以只需要指定指针的类型,本例中是 Simple。如果 Simple 构造函数需要参数,就把它们放在 make_uniqueO调用的圆括号中。如果编译器不支持 make_unique0,可创建自己的 unique_ptr,如下所示,注意 Simple 必须写两次:
unique_ptr<Simple>mySimpleSmartPtr(new Simple());
在 C++17 之前,必须使用 make_unique(),一是因为只能将类型指定一次,二是出于安全考虑! 考虑下面对 foo()函数的调用
foo(unique_ptr<Simple>(new Simple()),unique_ptr<Bar>(new Bar(data())))
如果 Simple、Bar 或 data(0)函数的构造函数抛出异常(具体取决于编译器的优化设置),很可能是 Simple 或Bar 对象出现了内存泄漏。而使用 make_unique(),则不会发生内存泄漏;
foo(make_unique<Simple>(),make_unique<Bar>(data()));
在 C++17 中, 对 foo()的两个调用都是安全的, 但仍然建议使用 make_unique(),这样代码更便于读取。
注意;
始终使用 make_unique()来创建 unique_ptr。
\2. 使用 unique_ptrs
, ”这个标准智能指针最大的一个亮点是: 用户不需要学习大量的新语法,就可以获得巨大好处。像标准指针一样,仍可以使用*或->对智能指针进行解引用。例如,在前面的例子中,使用->运算符来调用 go()方法:
mySimpleSmartPtr->go()
与标准指针一样,也可将其写作,
(*mySimpleSmartPtr).go()
get()方法可用于直接访问底层指针。这可将指针传递给需要普通指针的函数。例如,假设具有以下函数:
void processData(Simple* simple){}
可采用如下方式进行调用:
auto mySimpleSmartPtr = make_unique<Simple>();processData(mySimpleSmartPtr.get());
可释放 unique_ptr 的底层指针,并使用 reset0根据需要将其改成另一个指针。例如:
mySimpleSmartPtr.reset();// Free resource and set to nullptr
mySimpleSmartPtr.reset(new Simple()); // Free resource and set to a new .
// Simple instance
可使用 release(0)断开 unique_ptr 与底层指针的连接。release()方法返回资源的底层指针,然后将智能指针设置为nullptr。实际上,智能指针失去对资源的所有权,负责在你用完资源时释放资源。例如:
Simple* simple = mySimpleSmartPtr.release();
delete simple;
simple = nullptr;
由于 unique_ptr 代表唯一拥有权,因此无法复制它! 使用 std::move0实用工具(见第 9 章中的讨论),可使用移动语义将一个 unique_ptr 移到另一个。这用于显式移动所有权,如下所示:
class Foo
{
public:
Foo(unique_ptr<int> data):mData(move(data)){}
private:
unique_ptr<int>mData;
};
auto myIntSmartPtr = make_unique<int>(42);
Foo f(move(myIntSmartPtr));
\3. unique_ptr 和 C 风格数组
unique ptr 适用于存储动态分配的旧式 C 风格数组。下例创建了一个 unique_ptr 来保存动态分配的、包含10 个整数的 C 风格数组:
auto myVariableSizedArray = make_unique<int[]>(10);
即使可使用 unique _ptr 存储动态分配的 C 风格数组,也建议改用标准库容器,例如 std::array 和 std::vector 等。
\4. 自定义 deleter
默认情况下,unique_ptr 使用标准的 new 和 delete 运算符来分配和释放内存。可将此行为改成;
int* malloc_int(int value)
{
int *p = (int *)malloc(sizeof(int));
*p = value;
return p;
}
int main()
{
unique_ptr<int,decltype(free)*> myIntSmartPtr(malloc_int(42),free);
return 0;
}
这段代码使用 malloc_int()给整数分配内存。unique_ptr 调用标准的 free()函数来释放内存。如前所述,在 C++中不应该使用 malloc(),而应改用 new。然而,unique_ptr 的这项特性是很有用的,因为还可管理其他类型的资源而不仅是内存。例如,当 unique _ptr 离开作用域时,可自动关闭文件或网络套接字以及其他任何资源但是,unique ptr 的自定义 deleter 的语法有些费解。需要将自定义 deleter 的类型指定为模板类型参数。在本例中,decltype(free)用于返回 free()类型。模板类型参数应当是函数指针的类型,因此另外附加一个* ,如decltype(free)*。使用 shared_ptr 的自定义 deleter 就容易多了。下面讨论 shared_ptr 的 7.4.2 节将演示如何使用shared_ptr,在 shared_ptr 离开作用域时自动关闭文件。
shared_ptr
shared_ptr 的用法与 unique_ptr 类似。要创建 shared_ptr,可使用 make_ shared0),它比直接创建 shared_ptr更高效。例如:
auto mySimpleSmartPtr = make_shared<Simple>();
警告;
总是使用 make_shared0创建 shared_ptr。
从 C++17 开始,就像unique ptr 一样,shared_ptr 可用于存储动态分配的旧式 C 风格数组的指针。这在 C++17之前是无法实现的。但是,尽管这在 C++17 中是可能的,仍建议使用标准库容器而非 C 风格数组。与 unique_ptr 一样,shared ptr 也支持 get()和 reset()方法。唯一的区别在于,当调用 reset0时,由于引用计数, 仅在最后的 shared_ptr 销毁或重置时, 才释放底层资源。注意, shared_ptr 不支持 release()。可使用 use_count()来检索共享同一资源的 shared_ptr 实例数量。与 unique_ ptr 类似,shared_ptr 默认情况下使用标准的 new 和 delete 运算符来分配和释放内存; 在 C++17中存储 C 风格数组时,使用 new[]和 delete[]。可更改此行为,如下所示:
// Implementation of malloc_int() as before.shared_ptr<int> myIntSmartPtr(malloc_int(42), free);
可以看到,不必将自定义 deleter 的类型指定为模板类型参数,这比 unique ptr 的自定义 deleter 更简便。下面的示例使用 shared_ptr 存储文件指针。当 shared ptr 离开作用域时(此处为脱离作用域时),会调用CloseFile()函数来自动关闭文件指针。回顾一下,C++中有可以操作文件的面向对象的类(参见第 13 章)。这些类在离开作用域时会自动关闭文件.这个例子使用了旧式 C 语言的 fppen()和 fclose()函数, 只是为了演示 shared_ptr除了管理纯粹的内存之外还可以用于其他目的:
void CloseFile(FILE* filePtr){ if(filePtr == nullptr) return; fclose(filePtr); cout<<"File closed."<<endl;}int main(){ FILE* f = fopen("data.txt","w"); shared_ptr<FILE>filePtr(f,CloseFile); if(filePtr == nullptr) { cerr<<"Error opening file"<<endl; } else { cout<<"File opened"<<endl; } return 0;}
- 强制转换 shared_ptr
可用于强制转换 shared_ptrs 的函数是 const_pointer_cast()、dynamic_pointer_cast()和 static_pointer_cast()。C++17 又添加了 reinterpret_pointer_ cast()。它们的行为和工作方式类似于非智能指针转换函数 const_cast()、dynamic_cast()、static_cast()和 reinterpret_cast(),第 11 章将详细讨论这些方法。
- 引用计数的必要性
作为一般概念,引用计数(reference counting)用于跟踪正在使用的某个类的实例或特定对象的个数。引用计数的智能指针跟踪为引用一个真实指针(或某个对象)而建立的智能指针的数目。通过这种方式,智能指针可以避免双重删除。
双重删除的问题很容易出现。考虑前面引入的 Simple 类,这个类只是打印出创建或销毁一个对象的消息。如果要创建两个标准的 shared_ptrs,并使它们都指向同一个 Simple 对象,如下面的代码所示,在销毁时,两个智能指针将尝试删除同一个对象
void doubleDelete()
{
Simple* mySimple = new Simple();
Shared_ptr<Simple> smartPtr1(mySimple);
Shared_ptr<Simple> smartPtr2(mySimple);
}
根据编译器,这段代码可能会月溃! 如果得到了输出,则输出为
Simple constructor called!
Simple destructor called!
Simple destructor called!
糕! 只调用一次构造函数,却调用两次析构函数? 使用 unique_ptr 也会出现同样的问题。连引用计数的shared_ptr 类也会以这种方式工作。然而,根据 C++标准,这是正确的行为。不应该像以上 doubleDelete(0)函数那样创建两个指向同一个对象的 shared_ptr,而是应该建立副本,如下所示:
void noDoubleDelete()
{
auto smartPtr1 = make_shared<Simple>();
shared_ptr<Simple> smartPtr2(smartPtr1);
}
这段代码的输出如下所示:
Simple constructor called!Simple destructor called!
即使有两个指向同一个 Simple 对象的 shared_ptr,Simple 对象也只销毁一次。回顾一下,unique_ptr 不是引用计数的。事实上,unique_ptr 不允许像 noDoubleDelete()函数中那样使用复制构造函数。如果真的需要编写像之前 doubleDelete()函数中那样的代码, 就需要实现自己的智能指针, 以避免双重删除。不过要重申一次,建议使用标准的 shared_ptr 模板共享资源,避免 doubleDelete0函数中那样的代码,应该改用复制构造函数。
- 别名
shared_ptr 支持所谓的别名。这人允许一个 shared_ptr 与另一个 shared_ptr 共享一个指针(拥有的指针),但指向不同的对象(存储的指针)。例如,这可用于使用一个 shared_ptr 指向一个对象的成员,同时拥有该对象本身,例如:
class Foo
{
public:
Foo(int value):mData(value){}
int mData;
};
auto foo = make_shared<Foo>(42);
auto aliasing = shared_ptr<int>(foo,&foo->mData);
仅当两个 shared_ptrs(foo 和 aliasing)都销毁时,才销毁 Foo 对象。“拥有的指针”用于引用计数,当对指针解引用或调用它的 get()时,将返回“存储的指针”。存储的指针用于大多数操作,如比较运算符。可以使用 owner_ before()方法或 std::owner less 类, 基于拥有的指针执行比较。在某些情况下(例如在 std::set 中存储 shared_ptrs()),这很有用。第 17 章将详细讨论 set 容器。
Weak_ptr
在 C++中还有一个类与 shared_ptr 模板有关, 那就是 weak_ptr。weak_ptr 可包含由 shared_ptr 管理的资源的引用。weak_ptr 不拥有这个资源,所以不能阻止 shared_ptr 释放资源。weak_ptr 销毁时(例如离开作用域时)不会销毁它指向的资源,然而,它可用于判断资源是否已经被关联的 shared_ptr 释放了。weak_ptr 的构造函数要求将一个 shared_ptr 或另一个 weak_ptr 作为参数。为了访问 weak_ptr 中保存的指针,需要将 weak_ptr 转换为shared_ptr。这有两种方法:e 使用 weak_ptr 实例的 lock()方法,这个方法返回一个 shared_ptr。如果同时释放了与 weak_ptr 关联的shared_ptr,返回的 shared_ptr 是 nullptr。。 ee 创建一个新的 shared_ptr 实例,将 weak_ptr 作为 shared_ptr 构造函数的参数。如果释放了与 weak_ptr关联的 shared_ptr,将抛出 std::bad_weak_ptr 异常。下例演示了 weak_ ptr 的用法:
void useResource(weak_ptr<Simple>& weakSimple)
{
auto resource = weakSimple.lock();
if(resource)
{
cout<<"Resource still alive"<<endl;
}
else
{
cout<<"Resource has been freed!"<<endl;
}
}
int main()
{
auto sharedSimple = make_shared<Simple>();
weak_ptr<Simple> weakSimple(sharedSimple);
// Try to use the weak_ptr.
useResource(weakSimple);
// Reset the shared_ptr.
// Since there is only 1 shared_ptr to the Simple resource,this will
// free the resource,even though there is still a weak_ptr alive.
sharedSimple.reset();
// Try to use the weak_ptr a second time.
useResource (weakSimple);
}
上述代码的输出如下:
Simple constructor called!Resource still alive.Simple destructor called!Resource has been freed!
从 C++17 开始,shared _ptr 支持 C 风格的数组; 与此类似,weak_ptr 也支持 C 风格的数组。
移动语义
shared_ptr、unique_ptr 和 weak_ptr 都支持移动语义,使它们非常高效。第 9 章将详细讲解移动语义,此处不做详述。这里只需要了解,从函数返回此类智能指针也很高效。例如,可编写以下函数 create(),并像在 main()函数中演示的那样使用这个函数,
unique_ptr<Simple> create(){ auto ptr = make_unique<Simple>(); //Do something with ptr... return ptr;}int main(){ unique_ptr<Simple> mySmartPtr1 = create(); auto mySmartPtr2 = create(); return 0;}
enable_shared_ from_this
std::enable_shared_from_this 混入类允许对象上的方法给自身安全地返回 shared_ptr 或 weak_ptr。 第 28 章将讨论混入类。enable_shared_from_this 混入类给类添加了以下两个方法。shared_from_this(): 返回一个 shared_ptr,它共享对象的所有权。weak_from_ this(): 返回一个 weak_ptr,它跟踪对象的所有权。这是一项高级功能,此处不做详述,下面的代码简单演示了它的用法:
class Foo:public enable_shared_from_this<Foo>{ public: shared_ptr<Foo> getPointer() { return shared_from_shis(); }};int main(){ auto ptr1 = make_shared<Foo>(); auto ptr2 = ptr1->getPointer();}
注意, 仅当对象的指针已经存储在 shared_ptr 时, 才能使用对象上的 shared_from_this()。 在本例中, 在 main()中使用 make_shared()来创建一个名为 ptrl 的 shared_ptr(其中包含 Foo 实例)。创建这个 shared_ptr 后,将允许它调用 Foo 实例上的 shared_from_this()。下面的 getPointer()方法的实现是完全错误的:
class Foo{ public: shared_ptr<Foo> getPointer() { return shared_ptr<Foo>(this); }};
如果像前面那样为 main()使用相同的代码,Foo 的该实现将导致双重删除。有两个完全独立的 shared_ptr(ptrl 和 ptr2)指向同一对象,在超出作用域时,它们都会尝试删除该对象。
旧的、过时的/取消的 auto_ptr
在 C++ll 之前,老的标准库包含了一个智能指针的简单实现,称为 auto_ptr。遗憾的是,auto_ptr 存在一些严重缺点。缺点之一是在标准库容器(例如 vector)中使用时,auto_ptr 不能正常工作。C++11 和 C++14 已经正式废弃了 auto_ptr, C++17 则完全取消了 auto_ptr。 auto_ptr 已被 shared_ ptr 和 unique_ptr 取代。 这里提到 auto_ptr的原因是为了确保你知道这个智能指针,并且绝不要使用它。
警告:
不要再使用旧的 auto_ptr 智能指针,而使用 unique_ptr 或 shared_ptr!
常见的内存陷阱
很难准确地指出在哪些情况下会导致内存相关的 bug。每个内存泄漏或错误指针都有微妙的差别。没有解决所有内存问题的灵丹妙药,但有一些常见类型的问题是可以检测和解决的。
分配不足的字符串
与 C 风格字符串相关的最常见问题是分配不足。大多数情况下,都是因为程序员没有分配尾部的\0终止字符。当程序员假设某个固定的最大大小时,也会发生字符串分配不足的情况。基本的内置 C 风格字符串函数不会针对固定的大小操作一一而是有多少写多少,如果超出字符串的末尾,就写入未分配的内存。以下代码演示了字符串分配不足的情况。它从网络连接读取数据,然后写入一个 C 风格的字符串。这个过程在一个循环中完成,因为网络连接一次只接收少量的数据。在每个循环中调用 getMoreData()函数,这个函数返回一个指向动态分配内存的指针。当 getMoreData0返回 nullptr 时,表示已收到所有数据。strcat()是一个C 函数,它把第二个参数的 C 风格字符串连接到第一个参数的 C 风格字符串的尾部。它要求目标缓存区足够大。
char buffer[1024] = {0};// Allocate a whole bunch of memory.
while(true)
{
char* nextChunk = getMoreData();
if(nextChunck == nullptr)
{
break;
}
else
{
strcat(buffer,nextChunk); // BUG! No guarantees against buffer overrunl
delete[] nextChunk;
}
}
有三种方法用于解决可能的分配不足问题。按照优先级降序排列,这三种方法为:
(1) 使用 C++风格的字符串,它可自动处理与连接字符串关联的内存。
(2) 不要将缓冲区分配为全局变量或分配在堆栈上,而是分配在堆上。当剩余空间不足时,分配一个新组冲区,它大到至少能保存当前内容加上新内存块的内容,将原来缓冲区的内容复制到新缓冲区,将新内容追加到后面,然后删除原来的缓冲区。
(3) 创建另一个版本的 getMoreData(),这个版本接收一个最大计数值(包括\0’字符),返回的字符数不多于这个值,然后跟踪剩余的空间数以及缓冲区中当前的位置。
访问内存越界
本章前面提到,指针只不过是一个内存地址,因此指针可能指向内存中的任何一个位置。这种情况很容易出现。例如,考虑一个 C 风格的字符串,它不小心丢失了0终止字符。下面这个函数试图将字符串填满 m 字符,但实际上可能会继续在字符串后面填充 m:
void fillWithM(char* inStr){ int i = 0; while(inStr[i] != '\0') { inStr[i] = 'm'; i++; }}
如果把不正确的终止字符串传入这个函数,那么内存的重要部分被改写而导致程序崩溃只是时间问题。考虑如果程序中与对象关联的内存突然被 m 改写了会发生什么。这很糟糕! 写入数组尾部后面的内存产生的 bug 称为缓冲区滋出错误。这种 bug 已经被一些高危的恶意程序使用,例如病毒和蠕虫。狼猫的黑客可利用改写部分内存的能力,将代码注入正在运行的程序中。许多内存检测工具也能检测缓冲区溢出。使用像 CH+ string 和 vector 这样的高级结构有助于避免产生一些和 C 风格字符串和数组相关的 bug。
警告:
避免使用旧的 C 风格字符串和数组,它们没有提供任何保护; 而要改用像 C++ string 和 vector 这样安全的现代结构,它们能够自动管理内存。
内存泄漏
C 和 C++编程中遇到的另一个令人诅丧的问题是找到和修复内存泄漏。程序终于开始工作,看上去能给出正确结果。然后,随着程序的运行,吞掉的内存越来越多。这是因为程序有内存泄漏。通过智能指针避免内存泄漏是解决这个问题的首选方法。
分配了内存,但没有释放,就会发生内存泄漏。起初, 这听上去好像是粗心编程的结果,应该很容易避免。毕竟,如果在编写的每个类中,每个 new 都对应一个 delete,那么应该不会出现内存泄漏,对不对? 实际上并不总是如此。在下面的代码中,Simple 类编写正确,释放了每一处分配的内存。当调用 doSomething()函数时,outSimplePtr 指针修改为指向另一个 Simple 对象, 但是没有释放原来的 Simple对象。为了演示内存泄漏,doSomething()函数故意没有删除旧的对象。一旦失去对象的指针,就几乎不可能删
class Simple{ public: Simple(){ mIntPtr = new int(); } ~Simple(){ delete mIntPtr; } void setValue(int value){ *mIntPtr = value; } private: int* mIntPtr;};void doSomething(Simple*& outSimplePtr){ outSimplePtr = new Simple(); // BUG! Doesn't delete the original.}int main(){ Simple* simplePtr = new Simple();// Allocate a Simple object, doSomething(simplePtr); delete simplePtr; //Only cleans up the second object. return 0;}
警告:
记住,上述代码仅用于演示目的! 在生产环境的代码中,应当使 mIntPtr 和 simplePtr 成为 unique ptr,使outSimplePtr 成为 unique_ptr 的引用。
在上例中,内存泄漏可能来自程序员之间的沟通不畅或惜糕的代码文档。doSomething()的调用者可能没有意识到,该变量是通过引用传递的,因此,没有理由期望该指针会重新赋值。如果他们注意到这个参数是一个指针的非 const 引用,就可能怀疑会发生奇怪的事情,但是 doSomething()周围并没有说明这个行为的注释。
- 通过 Visual C++在 Windows 中查找和修复内存泄漏
内存泄漏很难追查,因为不能轻松地在内存中查看哪些对象在使用,以及最初把对象分配到了内存的哪里。然而有些程序可自动完成这项工作。有很多内存泄漏检测工具,从昂贵的专业软件包到可免费下载的工具。如果使用的是 Visual C++(有一个免费的 Visual C++版本,称为 Community Edition),其调试库内建了对内存泄漏检测的支持。这个内存泄漏检测功能默认情况下没有启用,除非创建的是 MFC 项目。要在其他项目中启用它,需要在代码开头添加以下三行代码
#define _CRTDBG_MAP_ALLOC #include <cstdlib>#include <crtdbg.h>
这几行应该和以上顺序完全一致。接下来,需要重新定义 new 运算符,如下所示:
#ifdef _DEBUG #ifndef DBG_NEW #define DBG_NEW new(_NORMAL_BLOCK,___FILE__,__LINE__) #define new DBG_NEW #endif#endif //DEBUG
请注意新定义的 new 运算符在“贡fdef _DEBUG”语句中,所以只有在编译调试版的应用程序时,才会使用新的 new。这通常就是所需要的。发行版通常不会执行对内存油漏的任何检测。最后,需要在 main()函数的第一行中添加下面这行代码:
CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
这行代码告诉 Visual C++ CRTI(C 运行时)库,在应用程序退出时,将所有检测到的内存泄漏写入调试输出控制台。对于前面那个存在内存泄漏的程序,调试控制台应该会包含以下输出:
Detected memory leaks!
Dumping objects ->
c:\leaky\leaky.cpp(15) : {147} normal block at 0x014FABF8, 4 bytes long.
Data: <> 00 00 00 00
c:\leaky\leaky.cpp(33) : {146} normal block at 0x014F5048, 4 bytes long.
Data: <Pa > 50 61 20 01
Object dump complete.
” 上述输出清楚地表明在哪个文件的哪一行分配了内存但没有释放。文件名后面括号中的数字就是行号。大括号之间的数字是内存分配的计数器.例如, {147}表示这是程序开始之后进行的第147次分配。可使用Visual C++的_CrtSetBreakAlloc0函数告诉 Visual C++调试运行时,进行特定分配时进入调试器。例如,把下面这行代码添加到 main()函数的开头,让调试器在第 147 次分配时中断:
_CrtSetBreakAlloc(147);
在这个存在内存泄漏的程序中,有两处泄漏一 第一个 Simple 对象没有释放(第 33 行),这个对象在堆中创建的整数也没有释放(第 15 行)。在 Visual C++的调试器输出窗口中,只需要双击某个内存泄漏,就可以自动跳到代码中的那一行。
当然,本小节讲解的 Visual C++和下一节讲解的 Valgrind 这类程序都不能实际修复内存泄漏一 否则还有什么乐趣? 通过这些工具提供的信息,可找到实际的问题。通常情况下,需要逐步跟踪代码,找到指向某个对象的指针在哪里改写了,而原始对象却没有释放。大多数调试器都提供了“观察点(watch poinb”功能,用于在发生这类事件时中断程序的执行。
. 在 Linux 中通过 Valgrind 查找并修复内存泄漏
双重删除和无效指针
通过 delete 释放某个指针关联的内存时,这个内存就可以由程序的其他部分使用了。然而,无法禁止再次使用这个指针,这个指针成为悬挂指针(dangling pointe)。双重删除也是一个问题。如果第二次在同一个指针上执行 delete 操作,程序可能会释放重新分配给另一个对象的内存。双重删除和使用已释放的内存都是很难追查的问题,因为症状可能不会立即显现。如果双重删除在较短的时间内发生,程序可能产生未定义的行为,因为关联的内存可能不会那么快重用。同样,如果删除的对象在删除后立即使用,这个对象很有可能仍然完好无缺。当然,无法保证这种行为会继续出现。一旦删除对象,内存分配器就没有义务保存任何对象。即使程序能正常工作,使用已删除的对象也是极糟糕的编程风格。很多内存泄漏检测程序(例如 Visual C++和 Valgrind),也会检测双重删除和已释放对象的使用。如果不按推荐的方式使用智能指针而是使用普通指针,至少在释放指针关联的内存后,将指针设置为nullptr。这样能防止不小心两次删除同一个指针和使用无效的指针。注意,在 nullptr 指针上调用 delete 是允许的,只是这样没有任何效果。
编写类
编写类时,需要指定行为或方法(应用于类的对象),还需要指定属性或数据成员(每个对象都会包含)。编写类有两个要素: 定义类本身和定义类的方法。
类定义
下面开始尝试编写一个简单的 SpreadsheetCell 类,其中每个单元格只存储一个数字:
class SpreadsheetCell{ public: void setValue(double inValue); double getValue() const; private: double mValue;}
#include <string>#include <string_view>class SpreadsheetCell{ public: void setValue(double inValue); double getValue() const; void setString(std::string_view inString); std::string getString() const; private: std::string doubleToString(double inValue) const; double stringToDouble(std::string_view inString) const; double mValue;};
注意:
上述代码使用 C++17 std::string view 类。如果你的编译器与 C++17 不兼容,可用 const std::string&替代std::string_view。
这个类版本只能存储 double 数据。如果客户将数据设置为 string,数据就会转换为 double。如果文本不是有效数字,就将 double 值设置为 0.0 这个类定义显示了两个设置并获取单元格文本表示的新方法,还有两个新的用于将 double 转换为 string、将 string 转换为 double 的私有帮助方法。下面是这些方法的实现。
#include "SpreadsheetCell.h"
using namespace std;
void SpreadsheelCell::setString(string_view inString)
{
mValue = stringToDouble(inString);
}
string SpreadsheelCell::getString() const
{
return doubleToString(mValue);
}
string SpreadsheetCell::doubleToString(double inValue) const
{
return to_string(inValue);
}
double SpreadsheetCell::stringToDouble(string_view inString) const
{
return strtod(inString.data(),nullptr);
}
如果对象的某个方法调用了某个函数(或方法),而这个函数采用指向对象的指针作为参数,就可使用 this指针调用这个函数。例如,假定编写了一个独立的 printCell()函数(不是方法),如下所示:
void printCell(const SpreadsheetCell& cell){ cout<<cell.getString()<<endl;}
如果想用 setvalue()方法调用 printCell(),就必须将*this 指针作为参数传递给 printCell(),这个指针指向setValue()操作的 SpreadsheetCell 对象。
void SpreadsheetCell::setValue(double value){ this->value = value; printCell(*this);}
使用对象
在 C++中通过声明 SpreadsheetCell 类型的变量,可根据 SpreadsheetCell 类的定义构建一个SpreadsheetCell“ 对象” 就像施工人员可以根据给定的蓝图建造多个房子一样, 程序员可以根据 SpreadsheetCell类创建多个 SpreadsheetCell 对象。可采用两种方法来创建和使用对象: 在堆栈中或者在堆中。
- 堆栈中的对象
下面的代码在堆栈中创建并使用 SpreadsheetCell 对象
SpreadsheetCell myCell,anotherCell;
myCell.setValue(6);
anotherCell.setString("3.2");
cout<<"cell 1: "<<myCell.getValue()<<endl;
cout<<"cell 2: "<<anotherCell.getValue()<<endl;
- 堆中的对象
还可使用 new 动态分配对象;
SpreadsheeCell* myCellp = new SpreadsheetCell();
myCellp->setValue(3.7);
cout<<"cell 1: "<<myCellp->getValue()<<" "<<myCellp->getString()<<endl;
delete myCellp;
myCellp = nullptr;
在堆中创建对象时,通过“箭头”运算符访问其成员。
就如同必须释放堆中分配的其他内存一样,也必须在对象上调用 delete,释放堆中为对象分配的内存。为避免发生内存错误,强烈建议使用智能指针;
auto myCellp = make_unique<SpreadsheetCell>();myCellp->setValue(3.7);cout<<"cell 1: "<<myCellp->getValue()<<" "<<myCellp->getString()<<endl;
使用智能指针时,不需要手动释放内存,内存会自动释放。
警告:
如果用 new 为某个对象分配内存,那么使用完对象后,要用 delete 销毁对象,或者使用智能指针自动管理内存。
注意:
如果没有使用智能指针,当删除指针所指的对象时,最好将指针重置为 null。这并非强制要求,但这样做可以防止在删除对象后意外使用这个指针,以便于调试。
在堆中使用构造函数
当动态分配 SpreadsheetCell 对象时,可这样使用构造函数;
auto smartCellp = make_unique<SpreadsheetCell>(4);// ... do something with the cell,no need to delete the smart pointer// Or with raw pointers,Wwithout smart pointers (not recommended)SpreadsheetCell* myCellp = new SpreadsheetCell(5);SpreadsheetCell* anotherCellp = nullptr;anotherCellp = new SpreadsheetCell(4);delete myCellp;myCellp = nullptr;delete anotherCellp;anotherCellp = nullptr;
注意可以声明一个指向 SpreadsheetCell 对象的指针,而不立即调用构造函数。堆栈中的对象在声明时会调用构造函数。
无论在堆栈中(在函数中)还是在类中(作为类的数据成员)声明指针,如果没有立即初始化指针,都应该像前面声明 anotherCellp 那样将指针初始化为 nullptr。如果不赋予 nullptr 值,指针就是未定义的。意外地使用未定义指针可能会导致无法预料的、难以诊断的内存问题。如果将指针初始化为 nullptr,在大多数操作环境下使用这个指针,都会引起内存访问错误,而不是难以预料的结果。同样,记得对使用 new 动态分配的对象使用 delete,或者使用智能指针。
- 默认构造函数
什么时候需要默认构造函数
考虑一下对象数组。创建对象数组需要完成两个任务: 为所有对象分配内存连续的空间,为每个对象调用默认构造函数。C++没有提供任何语法,让创建数组的代码直接调用不同的构造函数。例如,如果没有定义SpreadsheetCell 类的默认构造函数,下面的代码将无法编译:
SpreadsheetCell cells[3]; //FAILS compilation without default constructor
SpreadsheetCell* myCellp = new SpreadsheetCell[10]; //Also FAILS
对于基于堆栈的数组,可使用下面的初始化器(initializeD)绕过这个限制
SpreadsheetCell cells[3] = {SpreadsheetCell(0),SpreadsheetCell(23),SpreadsheetCell(41)};
然而,如果想创建某个类的对象数组,最好还是定义类的默认构造函数。如果没有定义自己的构造函数,编译器会自动创建默认构造函数。编译器生成的构造函数在下一节讨论。如果想在标准库容器(例如 std::vector中存储类,也需要默认构造函数。在其他类中创建类对象时,也可以使用默认构造函数,本节中的“5. 构造函数初始化器”部分将讲述这一内容。
警告;
在堆栈中创建对象时,调用默认构造函数不需要使用圆括号。
对于堆中的对象,可以这样使用默认构造函数:
auto smartCellp = make_unique<SpreadsheetCell>();SpreadsheetCell* myCellp = new SpreadsheetCell();delete myCellp;myCellp = nullptr;
显式删除构造函数
C++还支持显式删除构造函数(explicitly deleted constructors)。例如,可定义一个只有静态方法的类(见第 9章),这个类没有任何构造函数,也不想让编译器生成默认构造函数。在此情况下可以显式删除默认构造函数:
class MyClass
{
public:
MyClass() = delete;
};
数据类型 | 说明 |
---|---|
const 数据成员 | const 变量创建后无法对其正确赋值,必须在创建时提供值 |
引用数据成员 | 如果不指向什么,引用将无法存在 |
没有默认构造函数的对象数据成员 | C++尝试用默认构造函数初始化成员对象。如果不存在默认构造函数,就无法初始化它们 |
没有默认构造函数的基类 | 在第 10 章讲述 |
警告:
使用 ctor-initializer 初始化数据成员的顺序如下: 按照类定义中声明的顺序而不是 ctor-initializer 列表中的顺序。
按引用传递对象
向函数或方法传递对象时,为避免复制对象,可让函数或方法采用对象的引用作为参数。按引用传递对象通常比按值传递对象的效率更高,因为只需要复制对象的地址,而不需要复制对象的全部内容。此外,按引用传递可避免对象动态内存分配的问题,这些内容将在第 9 章讲述。按引用传递某个对象时,使用对象引用的函数或方法可修改原始对象。如果只是为了提高效率才按引用传递,可将对象声明为 const 以排除这种可能。这称为按 const 引用传递对象, 本书中的多个示例一直是这么做的。
注意,SpreadsheetCell 类有多个接收 std::string_view 参数的方法。如第 2 章所述,string_view 基本上就是指针和长度。因此,复制成本很低,通常按值传递。另外,诸如 int 和 double 等基本类型应当按值传递。按 const 引用传递这些类型什么也得不到。SpreadsheetCell 类的 doubleToString()方法总是按值返回字符串,因为该方法的实现创建了一个局部字符串对象,在方法的最后返回给调用者。返回这个字符串的引用是无效的,因为它引用的字符串在函数退出时会被销毁。
将复制构造函数定义为显式默认或显式删除
可用下面的方法将编译器生成的复制构造函数设为默认或者将其删除
SpreadsheetCell (const SpreadsheetCell& src) = default;
或者
SpreadsheetCell (const SpreadsheetCell& src) = delete;
通过删除复制构造函数,将不再复制对象。这可用于禁止按值传递对象,如第 9 章所述。
委托构造函数
委托构造函数(delegating constructors)允许构造函数调用同一个类的其他构造函数。然而,这个调用不能放在构造函数体内,而必须放在构造函数初始化器中,且必须是列表中唯一的成员初始化器。下面给出了一个示例:
SpreadsheetCell::SpreadsheetCell(string_view initialValue) :SpreadsheetCell(stringToDouble(initialValue)) {}
当调用这个 sting_view 构造函数(委托构造函数)时,首先将调用委托给目标构造函数,也就是 double 构造函数。当目标构造函数返回时,再执行委托构造函数。当使用委托构造函数时,要注意避免出现构造函数的递归。例如;
class MyClass{ MyClass(char c):MyClass(1.2){} MyClass(double d):MyClass('m'){}}
第一个构造函数委托第二个构造函数,第二个构造函数又委托第一个构造函数。C++标准没有定义此类代码的行为,这取决于编译器。
注意默认构造函数和复制构造函数之间缺乏对称性。只要没有显式定义复制构造函数,编译器就会自动生成一个。另一方面,只要定义了任何构造函数,编译器就不会生成默认构造函数。
通过移动语义(move semantics),编译器可使用移动构造函数而不是复制构造函数,从 getString()返回该字符串,这样做效率更高。第 9 章将讨论移动语义。
友元
class Foo { friend class Bar;};
现在,Bar 类的所有成员可访问 Foo 类的 private、protected 数据成员和方法。也可将 Bar 类的一个特定方法作为友元。假设 Bar 类拥有一个 processFoo(const Foo& fbo)方法,下面的语法将使该方法成为 Foo 类的友元:
class Foo{ friend void Bar::processFoo(const Foo& foo);};
独立函数也可成为类的友元。例如,假设要编写一个函数,将 Foo 对象的所有数据转储到控制台。你可能希望将这个函数放在 Foo 类之外,以模拟外部审计,但该函数应当可以访问 Foo 对象的内部数据成员,对其进行适当检查。下面是 Foo 类定义和 dumpFoo()友元函数:
class Foo
{
friend void dumpFoo(const Foo& foo);
};
类中的 friend 声明用作函数的原型。不需要在别处编写原型(当然,如果你那样做,也无害处)。下面是函数定义:
void dumpFoo(const Foo& foo)
{
}
friend 类和方法很容易被滥用,友元可以违反封装的原则,将类的内部暴露给其他类或函数。因此,只有在特定的情况下才应该使用它们
析构函数与类(和构造函数)同名,名称的前面有一个波浪号。析构函数没有参数,并且只能有一个析构函数。为析构函数隐式标记 noexcept,因为它们不应当抛出异常。
- Spreadsheet 类的复制构造函数
class Spreadsheet
{
public:
Spreadsheet(const Spreadsheet& src);
};
- Spreadsheet 类的赋值运算符
class Spreadsheet
{
public:
Spreadsheet& operator=(const Spreadsheet& rhs);
};
下面是一个不成熟的实现:
class Spreadsheet
{
// check for self-assignment
if(this == &ths)
{
return *this;
}
//Free the old memory
for(size_t i = 0;i < mWidth;i++)
{
delete[] mCells[i];
}
delete[] mCells;
mCells = nullptr;
//Allocate new memory
mWidth = rhs.mWidth;
mHeight = rhs.mHeight;
mCells = new SpreadsheetCell*[mWidth];
for(size_t i = 0; i < mWidth; i++)
{
mCells[i] = new SpreadsheetCell[mHeigh];
}
//Copy the data
for(size_t i = 0; i < mWidth; i++)
{
for(size_t j = 0;j < mHeight; j++)
{
mCells[i][j] = rhs.mCells[i][j];
}
}
return *this;
}
代码首先检查自赋值,然后释放 this 对象的当前内存,此后分配新内存,最后复制各个元素。这个方法存有不少问题, 有不少地方会出错。this 对象可能进入无效状态。例如, 假设成功释放了内存, 合理设置了 mWidth和 mHeight,但分配内存的循环地出了异常。如果发生这种情况,将不再执行该方法的剩余部分,而是从该方法中退出。此时,Spreadsheet 实例受损,它的 mWidth 和 mHeight 数据成员声明了指定大小,但 mCells 数据成员不具有适当的内存量。基本上,该代码不能安全地处理异常!
我们需要一种全有或全无的机制; 要么全部成功,要么该对象保持不变。为实施这样一个能安全处理异常的赋值运算符,建议使用“复制和交换”惯用语法。这里将非成员函数 swap(实现为 Spreadsheet 类的友元。如果不使用非成员函数 swap0,那么可以给类添加 swap()方法。但是,建议你练习将 swap0实现为非成员函数,这样一来,各种标准库算法都可使用它。下面是包含赋值运算符和 swap()函数的 Spreadsheet 类的定义;
class Spreadsheet{ public: Spreadsheet& operator=(const Spreadsheet& rhs); friend void swap(Spreadsheet& first,Spreadsheet& second) noexcept;};
要实现能安全处理异常的“复制和交换”惯用语法,要求 swap()函数永不抛出异常,因此将其标记为noexcept。swap()函数的实现使用标准库中提供的 std::swap(0工具函数(在头文件中定义),交换每个数据
void swap(Spreadsheet& first,Spreadsheet& second) noexcept{ using std::swap; swap(first.mWidth,second.mWidth); swap(first.mHeigh,second.mHeight); swap(first.mCells,second.mCells);}
现在就有了能安全处理异常的 swap()函数,它可用来实现赋值运算符:
Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs){ if(this == &rhs) return *this; Spreadsheet temp(rhs); swap(*this,temp); return *this;}
该实现使用“复制和交换”惯用语法。为提高效率,有时也为了正确性,赋值运算符的第一行检查自赋值。接下来,对右边进行复制,称为 tmp。然后用这个副本替代*this。这个模式可确保“稳健地”安全处理异常(strong exception safety)。这意味着如果发生任何异常,当前的 Spreadsheet 对象保持不变。这通过三个阶段来实现:
第一个阶段创建一个临时副本。这不修改当前 Spreadsheet 对象的状态,因此,如果在这个阶段发生异常,不会出现问题。
第二个阶段使用 swap()函数,将创建的临时副本与当前对象交换。swap()永远不会抛出异常。
第三个阶段销毁临时对象(由于发生了交换,现在包含原始对象)以清理任何内存。
除复制外,C++还支持移动语义,移动语义需要移动构造函数和移动赋值运算符。在某些情况下它们可以用来增强性能,稍后的 9.2.4 节“使用移动语义处理移动”将对此进行详细讨论。
- 禁止赋值和按值传递
在类中动态分配内存时,如果只想禁止其他人复制对象或者为对象赋值,只需要显式地将 operator=和复制构造函数标记为 delete。通过这种方法,当其他任何人按值传递对象时、从函数或方法返回对象时,或者为对象赋值时,编译器都会报错。下面的 Spreadsheet 类定义禁止赋值并按值传递:
class Spreadsheet{ public: Spreadsheet(size_t width,size_t height); Spreadsheet(const Spreadsheet& src) = delete; ~Spreadsheet(); Spreadsheet& operator=(const Spreadsheet& rhs) = delete;};
不需要提供 delete 复制构造函数和赋值运算符的实现。链接器永远不会查看它们,因为编译器不允许代码调用它们。当代码复制 Spreadsheet 对象或者对 Spreadsheet 对象赋值时,编译器将给出如下消息:
"Spreadsheet 5Spreadsheet::operator =(const Spreadsheet 5) , : attempting to reference a deleted function
注意:
如果编译器不支持显式删除成员函数,那么可以把复制构造函数和赋值运算符标记为 private,且不提供任何实现,从而禁用复制和赋值。
- 右值引用
在 C++中,左值(lvalue)是可获取其地址的一个量,例如一个有名称的变量。由于经常出现在赋值语句的左边,因此将其称作左值。另外,所有不是左值的量都是右值(rvalue),例如字面量、临时对象或临时值。通常右值位于赋值运算符的右边。例如,考虑下面的语句:int a=4* 2; 在这条语句中, a 是左值, 它具有名称,它的地址为&a。 右侧表达式 4* 2 的结果是右值。它是一个临时值,将在语句执行完毕时销毁。在本例中,将这个临时副本存储在变量a中。
右值引用是一个对右值(rvalue)的引用。特别地,这是一个当右值是临时对象时才适用的概念。右值引用的目的是在涉及临时对象时提供可选用的特定函数。由于知道临时对象会被销毁,通过右值引用,某些涉及复制大量值的操作可通过简单地复制指向这些值的指针来实现。
函数可将&&作为参数说明的一部分(例如 type&&name), 以指定右值引用参数。通常, 临时对象被当作 const type&,但当函数重载使用了右值引用时,可以解析临时对象,用于该函数重载。下面的示例说明了这一点。代码首先定义了两个 handleMessage()函数,一个接收左值引用,另一个接收右值引用:
void handleMessage(std::string& message)
{
cout<<"handleMessage with lvalue reference: "<<message<<endl;
}
void handleMessage(std::string&& message)
{
cout<<"handleMessage with rvalue reference: "<<message<<endl;
}
可使用具有名称的变量作为参数调用 handleMessage0)函数:
std::string a = "Hello ";
std::string b = "World";
handleMessage(a); //Calls handleMessage(string& value)
由于 a 是一个命名变量,调用 handleMessage()函数时,该函数接收一个左值引用。handleMessage()函数通过其引用参数所执行的任何更改来更改 a 的值。还可用表达式作为参数来调用 handleMessage()函数:
handleMessage(a + b); //Calls handleMessage(string&& value)
字面量也可作为handleMessageO)调用的参数, 此时同样会调用右值引用版本, 因为字面量不能作为左值(但字面量可作为 const 引用形参的对应实参传递)。
handleMessage("hello world"); //calls handleMessage(string && message)
handleMessage(std::move(b)); //Calls handleMessage(string && value)
\2. 实现移动语义
移动语义是通过右值引用实现的。为了对类增加移动语义,需要实现移动构造函数和移动赋值运算符。移动构造函数和移动赋值运算符应使用 noexcept 限定符标记,这告诉编译器,它们不会抛出任何异常。这对于与标准库兼容非常重要,因为如果实现了移动语义,与标准库的完全兼容只会移动存储的对象,且确保不抛出异常。下面的 Spreadsheet 类定义包含一个移动构造函数和一个移动赋值运算符。也引入了两个辅助方法 cleanup()和 moveFrom()。前者在析构函数和移动赋值运算符中调用。后者用于把成员变量从源对象移动到目标对象,接着重置源对象。
class Spreadsheet
{
public:
Spreadsheet(Spreadsheet&& src) noexcept; //Move constructor
Spreadsheet& operator=(Spreadsheet&& rhs) noexcept; //Move assign
private:
void cleanup() noexcept;
void moveFrom(Spreadsheet& src) noexcept;
};
实现代码如下所示
void Spreadsheet::cleanup() noexcept
{
for(size_t i = 0;i < mWidth; i++)
{
delete[] mCells[i];
}
delete[] mCells;
mCells = nullptr;
mWidth = mHeight = 0;
}
void Spreadsheet::moveFrom(Spreadsheet & src) noexcept
{
mWidth = src.mWidth;
mHeigh = src.mHeight;
mCells = src.mCells;
src.mWidth = 0;
src.mHeigh = 0;
src.mCells = nullptr;
}
Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept
{
moveFrom(src);
}
Spreadsheet* Spreadsheet::operator=(Spreadsheet && rhs) noexcept
{
if(this == &rhs)
{
return *this;
}
cleanup();
moveFrom(this);
return *this;
}
移动构造函数和移动赋值运算符都将 mCells 的内存所有权从源对象移动到新对象, 这两个方法将源对象的移动构造函数和移动赋值运算符都将 mCells 的内存所有权从源对象移动到新对象, 这两个方法将源对象的例如,就像普通的构造函数或复制赋值运算符一样,可显式将移动构造函数和/或移动赋值运算符设置为默认或将其删除,如第 8 章所述。仅当类没有用户声明的复制构造函数、复制赋值运算符、移动赋值运算符或析构函数时,编译器才会为类自动生成默认的移动构造函数。
注意:
可使用 noexcept 标记函数,指示不会抛出异常。例如:
void myNonThrowingFunction() noexcept {}
析构函数隐式使用 noexcept,因此不必专门添加这个关键宇。如果 noexcept 函数真的抛出了异常,程序将终止。有关 noexcept 的更多信息,以及为什么必须避免析构邓数抛出异常的信息,请参阅第 14 章。
零规则
前面介绍过 5 规则(rule of five)。前面的讨论一直在解释如何编写以下 5 个特殊的成员函数: 析构函数、复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符。但在现代 C++中,你需要接受零规则(rule of Zero)。“零规则”指出,在设计类时,应当使其不需要上述 5 个特殊成员函数。如何做到这一点? 基本上,应当避免拥有任何旧式的、动态分配的内存。而改用现代结构,如标准库容器。例如,在 Spreadsheet 类中,用vector<vector>替代 SpreadsheetCell**数据成员。该 vector 自动处理内存,因此不需要上述 5个特殊成员函数。
警告;
在现代 C++中,要应用零规则!
与方法有关的更多内容
C++为方法提供了许多选择,本节详细介绍这些技巧。
静态方法
与数据成员类似,方法有时会应用于全部类对象而不是单个对象,此时可以像静态数据成员那样编写静态方法。以第 8 章的 SpreadsheetCell 类为例,这个类有两个辅助方法: stringToDouble()和 doubleToString()。这两个方法没有访问特定对象的信息,因此可以是静态的。下面的类定义将这些方法设置为静态的;
class SpreadsheetCell{ //Omitter for brevity private: static std::string doubleToString(double inValue); static double stringToDouble(std::string_view inString);};
这两个方法的实现与前面的实现相同,在方法定义前不需要重复 static 关键字。然而,注意静态方法不属于特定对象,因此没有 this 指针,当用某个特定对象调用静态方法时,静态方法不会访问这个对象的非静态数据成员。实际上,静态方法就像普通函数,唯一区别在于静态方法可以访问类的 private 和 protected 静态数据成员。如果同一类型的其他对象对于静态方法可见(例如传递了对象的指针或引用),那么静态方法也可访问其他对象的 private 和 protected 非静态数据成员。
类中的任何方法都可像调用普通函数那样调用静态方法,因此 SpreadsheetCell 类中所有方法的实现都没有改变。如果要在类的外面调用静态方法,需要用类名和作用域解析运算符来限定方法的名称(就像静态数据成员那样),静态方法的访问控制与普通方法一样。将 stringToDouble()和 doubleToString()设置为 public,这样类外面的代码也可以使用它们。此时,可在任意位置这样调用这两个方法:
string str = SpreadsheetCell::doubleToString(5.0)
const 方法
const(常量)对象的值不能改变。如果使用常量对象、常量对象的引用和指向常量对象的指针,编译器将不人允许调用对象的任何方法, 除非这些方法承诺不改变任何数据成员。为了保证方法不改变数据成员, 可以用 const关键字标记方法本身。下面的 SpreadsheetCell 类包含了用 const 标记的不改变任何数据成员的方法。
class SpreadsheetCell{ public: double getValue() const; std::string getString() const;};
const 规范是方法原型的一部分,必须放在方法的定义中:
double SpreadsheetCell::getValue() const{ return mValue;}std::string SpreadsheetCell:getString() const{ return doubleToString(mValue);}
将方法标记为 const,就是与客户代码立下了契约,承诺不会在方法内改变对象内部的值。如果将实际上修改了数据成员的方法声明为const, 编译器将会报错。不能将静态方法声明为const, 例如9.3.1 节的doubleToString0)和 stringToDouble()方法,因为这是多余的。静态方法没有类的实例,因此不可能改变内部的值。const 的工作原理是将方法内用到的数据成员都标记为 const 引用,因此如果试图修改数据成员,编译器会报错。非 const 对象可调用 const 方法和非 const 方法。然而,const 对象只能调用 const 方法,下面是一些示例:
SpreadsheetCell myCell(5);
cout<<myCell.getValue()<<endl; //ok
myCell.setString("6"); //ok
const SpreadsheetCell& myCellConstRef = myCell;
cout<<myCellConstRef.getValue()<<endl; //ok
myCellConstRef.getString("6"); //Com
应该养成习惯,将不修改对象的所有方法声明为 const,这样就可在程序中引用 const 对象。注意 const 对象也会被销毁,它们的析构函数也会被调用,因此不应该将析构函数标记为 const。
- 基于 const 的重载
还要注意,可根据 const 来重载方法。也就是说,可以编写两个名称相同、参数也相同的方法,其中一个是 const,另一个不是。如果是 const 对象,就调用 const 方法, 如果是非 const 对象,就调用非 const 方法。
通常情况下,const 版本和非 const 版本的实现是一样的。为避免代码重复, 可使用 Scott Meyer 的 const_cast()模式。例如,Spreadsheet 类有一个 getCellAt(方法,该方法返回 SpreadsheetCell 的非 const 引用。可添加 const重载版本,它返回 SpreadsheetCell 的 const 引用。
class Spreadsheet
{
public:
SpreadsheetCell& getCellAt(size_t x,size_t y);
const SpreadsheetCell& getCellAt(size_t x,size_t y) const;
};
对于 Scott Meyer 的 const_cast()模式,你可像往常一样实现 const 版本,此后通过适当转换,传递对 const版本的调用,以实现非 const 版本。基本上,你使用 std::as_const(0(在中定义)将*this 转换为 constSpreadsheet&,调用 getCellAt()的 const 版本,然后使用 const_cast),从结果中删除 const:
const SpreadsheetCell& Spreadsheet::getCellAt(size_t x,size_t y) const;
{
verifyCoordinate(x,y);
return mCells[x][y];
}
SpreadsheetCell& Spreadsheet::getCellAt(size_t x,size_t y)
{
return const_cast<SpreadsheetCell&>(std::as_const(*this).getCellAt(x,y));
}
自 C++17 起,std::as_const()函数可供使用。如果你的编译器还不支持该函数,可改用以下 static_cast():
return const_cast<SpreadsheetCell&>(static_cast<const Spreadsheet&>(*this).getCellAt(x,y));
有了这两个重载的 getCellAt0,现在可在 const 和非 const 的 Spreadsheet 对象上调用 getCellAt();
Spreadsheet sheet1(5,6);SpreadsheetCells& cell1 = sheet1.getCellAt(1,1);const Spreadsheet sheet2(5,6);const SpreadsheetCell& cell2 = sheet2.getCellAt(1,1);
\2. 显式删除重载
重载方法可被显式删除,可以用这种方法禁止调用具有特定参数的成员函数。例如,考虑下面的类;
class MyClass{ public: void foo(int i);};
可以用下面的方式调用 foo()方法:
MyClass c;c.foo(123);c.foo(1.23);
在第三行,编译器将 double 值(1.23)转换为整型值(0),然后调用 foo(int i)。编译器可能会给出警告,但是仍然会执行这一隐式转换。显式删除 foo()的 double 实例,可以禁止编译器执行这一转换,
class MyClass{ public: void foo(int i); void foo(double d) = delete;};
通过这一改动,以 double 为参数调用 foo()时,编译器会给出错误提示,而不是将其转换为整数。
- inline
许多 C++程序员了解 inline 方法的语法并使用这种语法,但不理解把方法标记为内联的结果。把方法或函数标记为 inline,仅提示编译器要内联函数或方法。编译器只会内联最简单的方法和函数,如果将编译器不想内联的方法定义为内联方法,编译器会自动忽略这个指令。现代编译器在内联方法或函数之前,会考虑代码膨胀等指标,因此不会内联任何没有效益的方法。
不同的数据成员类型
C++为数据成员提供了多种选择。除了在类中简单地声明数据成员外,还可创建静态数据成员(类的所有对象共享)、静态常量数据成员、引用数据成员、常量引用数据成员和其他成员。本节解释这些不同类型的数据成员。
静态数据成员
有时让类的所有对象都包含某个变量的副本是没必要的。数据成员可能只对类有意义,而每个对象都拥有其副本是不合适的。例如,每个电子表格或许需要一个唯一的数字ID,这需要一个从 0 开始的计数器,每个对象都可以从这个计数器得到自身的 ID。 电子表格的计数器确实属于 Spreadsheet 类, 但没必要使每个 Spreadsheet对象都包含这个计数器的副本,因为必须让所有的计数器都保持同步。C++用静态数据成员解决了这个问题。静态数据成员属于类但不是对象的数据成员,可将静态数据成员当作类的全局变量。下面是 Spreadsheet 类的定义,其中包含了新的静态数据成员 SCounter:
class Spreadsheet{ private: static size_t sCounter;};
不仅要在类定义中列出 static 类成员,还需要在源文件中为其分配内存,通常是定义类方法的那个源文件。在此还可初始化静态成员,但注意与普通的变量和数据成员不同,默认情况下它们会初始化为 0。static 指针会初始化为 nullptr。下面是为 SCounter 分配空间并初始化为 0 的代码:
size_t Spreadsheet::sCounter;
静态数据成员默认情况下初始化为 0,但如果需要,可将它们显式地初始化为 0,所下所示:
size_t Spreadsheet::sCounter = 0
这行代码在函数或方法外部, 与声明全局变量非常类似,只是使用作用域解析 Spreadsheet:指出这是Spreadsheet 类的一部分。
- 内联变量
从 C++17 开始,可将静态数据成员声明为 inline。这样做的好处是不必在源文件中为它们分配空间。下面是一个示例:
class Spreadsheet{ private: static inline size_t sCounter = 0;};
注意其中的 inline 关键字。有了这个类定义,可从源文件中删除下面的代码行;
size_t Spreadsheet::sCounter;
- 在类方法内访问静态数据成员
在类方法内部,可以像使用普通数据成员那样使用静态数据成员。例如,为 Spreadsheet 类创建一个 mld成员,并在 Spreadsheet 构造函数中用 sSCounter 成员初始化它。下面是包含了 mld 成员的 Spreadsheet 类定义:
class Spreadsheet
{
public:
size_t getId() const;
private:
static size_t sCounter;
size_t mId = 0;
};
下面是 Spreadsheet 构造函数的实现,在此赋予初始 ID:
Spreadsheet::Spreadsheet(size_t width,size_t height)
:mId(scounter++),mWidth(width),mHeight(height)
{
mCells = new SpreadsheetCell*(mWidth);
for(size_t i = 0;i < mWidth;i++)
{
mCells[i] = new SpreadsheetCell[mHeight];
}
}
可以看出,构造函数可访问 sCounter,就像这是一个普通成员。在复制构造函数中,也要指定新的ID。由于 Spreadsheet 复制构造函数委托给非复制构造函数(会自动创建新的 ID),因此这可以自动进行处理。在赋值运算符中不应该复制 ID。一旦给某个对象指定 ID,就不应该再改变。建议把 mId 设置为 const 数据成员。
- 在方法外访问静态数据成员
访问控制限定符适用于静态数据成员: sCounter 是私有的,因此不能在类方法之外访问。如果 sCounter 是公有的,就可在类方法外访问,有具体方法是用::作用域解析运算符指出这个变量是 Spreadsheet 类的一部分:
int c = Spreadsheet::sCounter;
然而,建议不要使用公有数据成员(9.4.2 节讨论的静态常量数据成员属于例外)。应该提供公有的 get/set 方法来授予访问权限。如果要访问静态数据成员,应该实现静态的 get/set 方法。
9.4.2 ”静态常量数据成员
类中的数据成员可声明为 const,意味着在创建并初始化后,数据成员的值不能再改变。如果某个常量只适用于类,应该使用静态常量(static const 或 const static)数据成员,而不是全局常量。可在类定义中定义和初始化整型和枚举类型的静态常量数据成员,而不需要将其指定为内联变量。例如,你可能想指定电子表格的最大高度和宽度。如果用户想要创建的电子表格的高度或宽度大于最大值,就改用最大值。可将最大高度和宽度设置为 Spreadsheet 类的 static const 成员:
class Spreadsheet
{
public:
static const size_t kMaxHeight = 100;
static const size_t kMaxWidth = 100;
};
可在构造函数中使用这些新常量,如下面的代码片段所示:
Spreadsheet::Spreadsheet(size_t width,size_t height)
:mId(sCounter++)
,mWidth(std::min(width,kMaxWidth))
,mHeigh(std::min(height,kMaxHeight))
{
mCells = new SpreadsheetCell*[mWidth];
for(size_t i =0;i < mWidth;i++)
{
mCells[i] = new SpreadsheetCell[mHeight];
}
}
注意;
当高度或宽度超出最大值时,除了自动使用最大高度或宽度外,也可以抛出异常。然而,在构造函数中抛出异常时,不会调用析构函数,因此需要谨慎处理。第 14 章将对此进行详细解释。
kMaxHeight 和 kMaxWidth 是公有的,因此可在程序的任何位置访问它们,就像它们是全局变量一样,只是语法略有不同。必须用作用域解析运算符::指出该变量是 Spreadsheet 类的一部分:
cout<<"Maximum height is: "<<Spreadsheet::kNaxHeight<<endl;
这些常量也可用作构造函数参数的默认值。记住,只能为一组连续的参数(从最右面的参数开始)指定默认值:
class Spreadsheet{ public: Spreadsheet(size_t width = kMaxWidth,size_t height = kMaxHeight);};
9.4.3 引用数据成员
Spreadsheets 和 SpreadsheetCells 很好,但这两个类本身并不能组成非常有用的应用程序。为了用代码控制整个电子表格程序,可将这两个类一起放入 SpreadsheetApplication 类。这个类的实现在此并不重要。现在考虑这个架构存在的问题: 电子表格如何与应用程序通信? 应用程序存储了一组电子表格, 因此可与电子表格通信。与此类似, 每个电子表格都应存储应用程序对象的引用。Spreadsheet类必须知道 SpreadsheetApplication 类,SpreadsheetApplication 类也必须知道 Spreadsheet 类。这是一个循环引用问题,无法用普通的#include 解决。解决方案是在其中一个头文件中使用前置声明。下面是新的使用了前置声明的 Spreadsheet 类定义,用来通知编译器关于 SpreadsheetApplication 类的信息。第 11 章解释前置声明的另一个优势, 可缩短编译和链接时间。
class SpreadsheetApplication:
class Spreadsheet
{
public:
Spreadsheet(size_t width,size_t height,SpreadsheetApplication& theApp);
private:
SpreadsheetApplication& mTheApp;
};
这个定义将一个 SpreadsheetApplication 引用作为数据成员添加进来.在此情况下建议使用引用而不是指针,因为 Spreadsheet 总要引用一个 SpreadsheetApplication,而指针则无法保证这一点。注意存储对应用程序的引用,仅是为了演示把引用作为数据成员的用法。不建议以这种方式把 Spreadsheet和 SpreadsheetApplication 类组合在一起,而应改用 MVC(模型-视图-控制器)范型(见第 4 章)。在构造函数中,每个 Spreadsheet 都得到了一个应用程序引用。如果不引用某些事物,引用将无法存在,因此在构造函数的 ctor-initializer 中必须给 mTheApp 指定一个值。
Spreadsheet::Spreadsheet(size_t width,size_t height,SpreadsheetApplication& theApp) :mId(sCounter++) ,mWidth(std::min(width,kMaxWidth)) ,mHeight(std::min(height,kMaxHeight)) ,mTheApp(theApp) { }
在复制构造函数中也必须初始化这个引用成员。由于 Spreadsheet 复制构造函数委托给非复制构造函数(初始化引用成员),因此这将自动处理。记住,在初始化一个引用后,不能改变它引用的对象,因此不可能在赋值运算符中对引用赋值。这意味着根据使用情形,可能无法为具有引用数据成员的类提供赋值运算符。如果属于这种情况,通常将赋值运算符标记为 deleted。
常量引用数据成员
就像普通引用可引用常量对象一样,引用成员也可引用常量对象。例如,为让 Spreadsheet 只包含应用程序对象的常量引用,只需要在类定义中将 mTheApp 声明为常量引用:
class Spreadsheet{ public: Spreadsheet(size_t width,size_t height, const SpreadsheetApplication& theApp); private: const SpreadsheetApplication& mTheApp;}
常量引用和非常量引用之间存在一个重要差别。常量引用 SpreadsheetApplication 数据成员只能用于调用SpreadsheetApplication 对象上的常量方法。如果试图通过常量引用调用非常量方法,编译器会报错。还可创建静态引用成员或静态常量引用成员,但一般不需要这么做。
嵌套类
类定义不仅可包含成员函数和数据成员,还可编写媒套类和嵌套结构、声明 typedef或者创建枚举类型。类中声明的一切内容都具有类作用域。如果声明的内容是公有的,那么可在类外使用 ClassName::作用域解析语法访问。可在类的定义中提供另一个类定义。例如,假定 SpreadsheetCell 类实际上是 Spreadsheet 类的一部分,因此不妨将 SpreadsheetCell 重命名为 Cell。可将二者定义为:
class Spreadsheet{ public: class Cell { public: Cell() = default; Cell(double initialValue); }; Spreadsheet(size_t width,size_t height,const SpreadsheetApplication& theApp);};
现在 Cell 类定义位于 Spreadsheet 类内部,因此在 Spreadsheet 类外引用 Cell 必须用 Spreadsheet::作用域限定名称,即使在方法定义时也是如此。例如,Cell 的 double 构造函数应如下所示;
Spreadsheet::Cell::Cell(double initialValue)
:mValue(initialValue)
{
}
甚至在 Spreadsheet 类中方法的返回类型(不是参数)也必须使用这一语法;
Spreadsheet::Cell& Spreadsheet::getCellAt(size_t x, size_t y)
{
verifyCoordinate(x,y);
return mCells[x][y];
}
如果在 Spreadsheet 类中直接完整定义嵌套的 Cell 类,将使 Spreadsheet 类的定义略显腑肿。为缓解这一点,只需要在 Spreadsheet 中为 Cell 添加前置声明,然后独立地定义 Cell 类,如下所示:
class Spreadsheet
{
public:
class Cell;
Spreadsheet(size_t width,size_t height,const SpreadsheetApplication* theApp);
};
class Spreadsheet::Cell
{
public:
Cell() = default;
Cell(double initialValue);
};
普通的访问控制也适用于嵌套类定义。如果声明了一个 private 或 protected 媒套类,这个类只能在外围类(outer class,即包含它的类)中使用。风套的类有权访问外围类中的所有 private 或 protected 成员; 而外围类却只能访问嵌套类中的 public 成员。
类内的枚举类型
如果想在类内定义许多常量,应该使用枚举类型而不是一组#define。例如,可在 SpreadsheetCell 类中支持单元格颜色,如下所示:
class SpreadsheetCell
{
public:
enum class Color{Red = 1,Green,Blue,Yellow};
void setColor(Color color);
Color getColor() const;
private:
Color mColor = Color::Red;
}
运算符重载
经常需要在对象上执行操作,例如相加、比较、将对象输入文件或者从文件读取。对电子表格而言,只有能执行算术运算(例如将整行单元格相加)才算真正有用。
示例: 为 SpreadsheetCell 实现加法
将运算符+作为方法重载
class SpreadsheetCell
{
public:
SpreadsheetCell operator+(const SpreadsheetCell& cell) const;
};
SpreadsheetCell SpreadsheetCell::operator+(const SpreadsheetCell& cell) const
{
return SpreadsheetCell(getValue() + cell.getValue());
}
aThirdCell = myCell + 4;//Works fine
aThirdCell = myCell + 5.4;//Works fine
aThirdCell = 4 + myCell; //FAILS TO COMPILE!
aThirdCell = 5.6 + myCell; //FAILS TO COMPILE!
第三次尝试: 全局 operator+
SpreadsheetCell operator+(const SpreadsheetCell& lhs,const SpreadsheetCell& rhs)
{
return SpreadsheetCell(lhs.getValue() + rhs.getValue());
}
需要在头文件中声明运算符:
SpreadsheetCell operator+(const SpreadsheetCell& lhs,const Spreadsheet& rhs);
这样,下面的 4 个加法运算都可按预期运行
aThirdCell = myCell + 4;//Works fine
aThirdCell = myCell + 5.4;//Works fine
aThirdCell = 4 + myCell; //Works fine
aThirdCell = 5.6 + myCell; //Works fine
创建稳定的接口
使用接口类和实现类
即使提前进行估算并采用最佳设计原则,C++语言本质上对抽象原则也不友好。其语法要求将 public 接口和 private(或 protected)数据成员及方法放在一个类定义中,从而将类的某些内部实现细节向客户公开。这种做法的缺点在于,如果不得不在类中加入新的非公有方法或数据成员,所有的客户代码都必须重新编译,对于较大项目而言这是负担。
有个好消息: 可创建清晰的接口,并隐藏所有实现细节,从而得到稳定的接口。还有个坏消息: 这样做有点繁杂。基本原则是为想编写的每个类都定义两个类: 接口类和实现类。实现类与已编写的类相同(假定没有采用这种方法),接口类给出了与实现类一样的 public 方法,但只有一个数据成员: 指向实现类对象的一个指针。这称为 pimpl idiom(private implementation idiom,私有实现习语)或 bridge 模式,接口类方法的实现只是调用实现类对象的等价方法。这样做的结果是无论实现如何改变,都不会影响 public 接口类,从而降低了重新编译的必要性。当实现改变(只有实现改变)时,使用接口类的客户不需要重新编译。注意只有在单个数据成员是实现类的指针时,这个习语才有效。如果它是按值传递的数据成员,在实现类的定义改变时,客户代码必须重新编译。
为将这种方法应用到 Spreadsheet 类,需要定义如下 public 接口类 Spreadsheet:
#include "SpreadsheetCell.h"
#include <memory>
class SpreadsheetApplication;
class Spreadsheet
{
public:
Spreadsheet(const SpreadsheetApplication& theApp,size_t width = kMaxWidth,size_t height = kMaxHeight);
Spreadsheet(const Spreadsheet& src);
~Spreadsheet();
Spreadsheet& operator=(const Spreadsheet& rhs);
void setCellAt(size_t x,size_t y,const SpreadsheetCell& cell);
SpreadsheetCell& getCellAt(size_t x,size_t y);
size_t getId() const;
static const size_t kMaxHeight = 100;
static const size_t kMaxWidth = 100;
friend void swap(Spreadsheet& first,Spreadsheet& second) noexcept;
private:
class Impl;
std::unique_ptr<Impl> mImpl;
};
实现类 Impl 是一个 private 峰套类,因为只有 Spreadsheet 需要了解这个实现类。Spreadsheet 现在只包含一个数据成员: 指向 Impl 实例的指针。public 方法与旧式的 Spreadsheet 相同。嵌套的 Spreadsheet:Impl 类的接口与原来的 Spreadsheet 类的接口完全相同。但由于 Impl 是 Spreadsheet 的private 嵌套类,因此不能有以下全局友元函数 swap*(),该函数交换两个 Spreadsheet::Impl 对象;
friend void swap(Spreadsheet::Impl& first,Spreadsheet::Impl& second) noexcept;
相反,为 Spreadsheet::Impl 类定义 private swap()方法,如下所示:
void swap(Impl& other) noexcept
实现方式十分简单,但需要记住,这是一个嵌套类,因此需要指定 Spreadsheet:Impl::swap(),而非仅仅指定 Impl:swap()。其他成员同样如此。要了解细节,可查看前面介绍嵌套类的部分,下面是 swap()方法:
void Spreadsheet::Impl::swap(Impl& other) noexcept{ using std::swap; swap(mWidth,other.mWidth); swap(mHeight,other.mHeight); swap(mCells,other.mCells);}
注意,Spreadsheet 类有一个指向实现类的 unique_ptr,Spreadsheet 类需要一个用户声明的析构函数。我们不需要对这个析构函数进行任何处理,可在文件中设置=default,如下所示:
Spreadsheet::~Spreadsheet() = default;
这说明不仅可在类定义中,也可在实现文件中给特殊成员函数设置=default。Spreadsheet 方法(例如 setCellAt()和 getCellAt())的实现只是将请求传递给底层的 Impl 对象:
Spreadsheet::~Spreadsheet() = default;
这说明不仅可在类定义中,也可在实现文件中给特殊成员函数设置=default。Spreadsheet 方法(例如 setCellAt()和 getCellAt())的实现只是将请求传递给底层的 Impl 对象:
void Spreadsheet::setCellAt(size_t x,size_t y,const SpreadsheetCell& cell){ mImpl->setCellAt(x,y,cell);}SpreadsheetCell& Spreadsheet::getCellAt(size_t x,size_t y){ return mImpl->getCellAt(x,y);}
Spreadsheet 的构造函数必须创建一个新的 Impl 实例来完成这个任务。
Spreadsheet::Spreadsheet(const SpreadsheetApplication& theApp,size_t width,size_t height){ mImpl = std::make_unique<Impl>(theApp,width,height);}Spreadsheet::Spreadsheet(const Spreadsheet& src){ mImpl = std::make_unique<Impl>(&src.Impl);}
复制构造函数看上去有点奇怪,因为需要从源 Spreadsheet 复制底层的 Impl。 由于复制构造函数采用一个指向 Impl 的引用而不是指针,因此为了获取对象本身,必须对 mImpl 指针解除引用,这样构造函数就可以使用它的引用作为参数。Spreadsheet 赋值运算符必须采用类似方式将值传递给底层的 Impl:
Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs){ *mImpl = *rhs.mImpl; return *this;}
真正将接口和实现分离的技术功能强大。尽管开始时有点笨拙,但是一旦适应这种技术,就会觉得这么做 很自然。然而,在多数工作环境中这并不是常规做法,因此这么做会遇到来自同事的一些阻力。支持这种方法最有力的论据不是将接口分离的美感,而是类的实现改变后大幅缩短构建时间。一个类不使用pimpl idiom 时,对实现类的更改将触发一个长时间的构建过程。例如,给类定义增加数据成员时,将触发其他所有源文件(包括类定义)的重新构建; 而使用 pimpl idiom,可以修改实现类的定义,只要 public 接口类保持不变,就不会触发长时间的构建过程。
使用稳定接口类,可缩短构建时间。
为将实现与接口分离,另一种方法是使用抽象接口以及实现该接口的实现类;抽象接口是只有纯虚方法(pure virtual method)的接口。第 10 章将讨论抽象接口。
扩展类
当使用 C++编写类定义时,可以告诉编译器,该类继承(或扩展)了一个已有的类。通过这种方式,该类将自动包含原始类的数据成员和方法,原始类称为父类(parent class)、基类或超类(superclass)。扩展已有类可以使该类(现在称为派生类或子类)只描述与父类不同的那部分内容。在 C++中,为扩展一个类,可在定义类时指定要扩展的类。为说明继承的语法,此处使用了名为 Base 和Derived 的类。不要担心一 后面有许多更有趣的示例。首先考虑 Base 类的定义:
class Base
{
public:
void someMethod();
protected:
int mPrectectedInt;
private:
int mPrivateInt;
};
如果要构建一个从 Base 类继承的新类 Derived,应该使用下面的语法告诉编译器: Derived 类派生自 Base 类:
class Derived : public Base{ public: void someOtherMethod();};
\1. 客户对继承的看法
对于客户或代码的其他部分而言,Derived 类型的对象仍然是 Base 对象,因为 Derived 类从 Base 类继承。这意味着 Base 类的所有 public 方法和数据成员, 以及 Derived 类的所有 public 方法和数据成员都是可供使用的。在调用某个方法时,使用派生类的代码不需要知道是继承链中的哪个类定义了这个方法。例如,下面的代码调用了 Derived 对象的两个方法,而其中一个方法是在 Base 类中定义的:
Derived myDerived;myDerived.someMethod();myDerived.someOtherMethod();
要知道继承的运行方式是单向的,这一点很重要。Derived 类与 Base 类具有明确的关系,但是 Base 类并不知道与 Derived 类有关的任何信息。这意味着 Base 类型的对象不支持 Derived 类的 public 方法和数据成员,因为 Base 类不是 Derived 类。下面的代码将无法编译,因为 Base 类不包含名为 someOtherMethod()的 public 方法:
Base myBase;myBase.someOtherMethod();//Error
指向某个对象的指针或引用可以指向声明类的对象,也可以指向其任意派生类的对象。本章后面将详细介绍这一灵活主题。此时需要理解的概念是,指向 Base 对象的指针可以指向 Derived 对象,对于引用也是如此。客户仍然只能访问 Base类的方法和数据成员, 但是通过这种机制, 任何操作 Base 对象的代码都可以操作 Derived对象。
例如,下面的代码可以正常编译并运行,尽管看上去好像类型并不匹配;
Base* base = new Derived(); //Create Derived,store it in base pointer
然而,不能通过 Base 指针调用 Derived 类的方法。下面的代码无法运行:
base->someOtherMethod();
编译器会报错,因为尽管对象是 Derived 类型,并且定义了 someOtherMethod()方法,但编译器只是将它看成 Base 类型,而 Base 类型没有定义 someOtherMethod()方法。
\2. 从派生类的角度分析继承
对于派生类自身而言,其编写方式或行为并没有改变。仍然可以在派生类中定义方法和数据成员,就像这是一个普通的类。前面 Derived 类的定义中声明了一个名为 someOtherMethod(0)的方法,因此 Derived 类增加了一个额外的方法,从而扩展了 Base 类。
派生类可访问基类中声明的 public、protected 方法和数据成员,就好像这些方法和数据成员是派生类自己的,因为从技术上讲,它们属于派生类。例如,Derived 类中 someOtherMethod(O)的实现可以使用在 Base 类中声明的数据成员 mProtectedInt。下面的代码显示了这一实现,访问基类的数据成员和方法与访问派生类中的数据成员和方法并无不同之处。
void Derived::someOtherMethod(){ cout<<"I can access base class data member mProtectedInt "<<endl; cout<<"Its value is "<<mProtectedInt<<endl;}
第 8 章介绍访问说明符(public、private 和 protected)时,private 和 protected 的区别可能令人感到迷惑。现在,理解了派生类,这一区别就变得更加清晰了。如果类将数据成员和方法声明为 protected,派生类就可以访问它们;如果声明为 private,派生类就不能访问。
下面的 someOtherMethod()实现将无法编译,因为派生类试图访问基类的 private 数据成员:
void Derived::someOtherMethod()
{
cout<<"I can access base class data member mProtectedInt "<<endl;
cout<<"Its value is "<<mProtectedInt<<endl;
cout<<"The value of mPrivateInt is "<<mPrivateInt<<endl;//Error;
}
private 访问说明符可控制派生类与基类的交互方式。建议将所有数据成员都默认声明为 private,如果希望任何代码都可以访问这些数据成员,就可以提供 public 的获取器和设置器。如果仅希望派生类访问它们,就可以提供受保护的获取器和设置器。把数据成员默认设置为 private 的原因是,这会提供最高级别的封装,这意味着可改变数据的表示方式, 而 public 或 protected 接口保持不变。 不直接访问数据成员, 也可在 public 或 protected设置器中方便地添加对输入数据的检查。方法也应默认设置为 private,只有需要公开的方法才设置为 public,只有派生类需要访问的方法才设置为 protected。
- 3. 禁用继承
C++人允许将类标记为 final,这意味着继承这个类会导致编译错误。将类标记为 final 的方法是直接在类名的后面使用 final 关键字。例如,下面的 Base 类被标记为 final;
class Base final
{
};
下面的 Derived 类试图从 Base 类继承,但是这会导致编译错误,因为 Base 类被标记为 final。
class Derived : public Base
{
};
\1. 将所有方法都设置为 virtual,以防万一
在 C++中,重写(override)方法有一点别扭,因为必须使用关键字 virtual。只有在基类中声明为 virtual 的方法才能被派生类正确地重写。virtual 关键字出现在方法声明的开头,下面显示了 Base 类的修改版本,
class Base{ public: virtual void someMethod(); protected: int mProtectedInt; private: int mPrivateInt;};
Virtual 关键字有些微妙之处,常被当作语言的设计不当部分。经验表明,最好将所有方法都设置为 virtual。这样就不必担心重写方法是否可以运行,这样做唯一的缺点是对性能具有轻微的影响。virtual 关键字会贯穿本章,详见稍后的“5. virtual 的真相”部分。即使 Derived 类不大可能扩展,也最好还是将这个类的方法设置为 virtual,以防万一。
class Derived : public Base
{
public:
virtual void someOtherMethod();
};
根据经验,为避免因为遗漏 virtual 关键字引发的问题,可将所有方法设置为 virtual(包括析构函数,但不包括构造孙数)。注意,由编译器生成的析构邓数不是 virtuall
\2. 重写方法的语法
为了重写某个方法,需要在派生类的定义中重新声明这个方法,就像在基类中声明的那样,并在派生类的实现文件中提供新的定义。例如,Base 类包含了一个 someMethod()方法,在 Base.cpp 中提供的 someMethod()方法定义如下:
void Base::someMethod()
{
cout<<"This is Base's version of someMethod "<<endl;
}
注意在方法定义中不需要重复使用 virtual 关键字。如果希望在 Derived 类中提供 someMethod()的新定义,首先应该在 Derived 类定义中添加这个方法,如下所示:
class Derived : public Base
{
public:
virtual void someMethod() override;
virtual void someOtherMethod();
};
建议在重写方法的声明末尾添加 override 关键字,override 关键字详见本章后面的内容。someMethod()方法的新定义与 Derived 类的其他方法一并在 Derived.cpp 中给出;
void Derived::someMethod()
{
cout<<"This is Derived's version of someMethod() "<<endl;
}
一且将方法或析构函数标记为 virtual, 它们在所有派生类中就一直是 virtual, 即使在派生类中删除了 virtual关键字,也同样如此。例如,在下面的 Derived 类中,someMethod()仍然是 virtual,可以被 Derived 的派生类重写,因为在 Base 类中将其标记为 virtual。
class Derived : public Base
{
public:
void someMethod() override;
};
客户对重写方法的看法
经过前面的改动后,其他代码仍可用先前的方法调用 someMethod(0),可用 Base 或 Derived 类的对象调用这个方法。然而,现在 someMethod()的行为将根据对象所属类的不同而变化。例如,下面的代码与先前一样可以运行,调用 Base 版本的 someMethod():
Base myBase;myBase.someMethod();//Calls Base's version of someMethod();
这段代码的输出为:
This is Base's Version of someMethod () .
如果声明一个 Derived 类对象,将自动调用派生类版本的 someMethod0):
Derived myDerived;myDerived.someMethod(); //Calls Derived's version of someMethod()
这段代码的输出为:
This is Derived's version of someMethod()
Derived 类对象的其他方面维持不变。从 Base 类继承的其他方法仍然保持 Base 类提供的定义,除非在Derived 类中显式地重写这些方法。
如前所述,指针或引用可指向某个类或其派生类的对象。对象本身“知道”自己所属的类,因此只要这个方法声明为 virtual,就会自动调用对应的方法。例如,如果一个对 Base 对象的引用实际引用的是 Derived 对象,调用 someMethod()实际上会调用派生类版本,如下所示。如果在基类中省略了 virtual 关键字,重写功能将无法正确运行。
Derived myDerived;
Base& ref = myDerived;
ref.someMethod(); //Calls Derived's version of someMethod();
记住,即使基类的引用或指针知道这实际上是一个派生类,也无法访问没有在基类中定义的派生类方法或成员。下面的代码无法编译,因为 Base 引用没有 someOtherMethod()方法:
Derived myDerived;Base& ref = myDerived;MyDerived.someOtherMethod(); //This is fineref.someOtherMethod(); //Error
。 非指针或非引用对象无法正确处理派生类的特征信息。可将 Derived 对象转换为 Base 对象,或将 Derived对象赋值给 Base 对象,因为 Derived 对象也是 Base 对象。然而,此时这个对象将遗失派生类的所有信息:
Derived myDerived;Base assignedObject = myDerived; //Assigns a Derived to a BaseassignedObject.someMethod(); //Calls Base's version of someMethod()
为记住这个看上去有点奇怪的行为, 可考虑对象在内存中的状态.将 Base 对象当作占据了一块内存的盒子。Derived 对象是稍微大一点的盒子,因为它拥有 Base 对象的一切,还添加了一点内容。对于指向 Derived 对象的引用或指针,这个盒子并没有变-一 只是可以用新的方法访问它。然而,如果将 Derived 对象转换为 Base 对象,就会为了适应较小的盒子而扔掉 Derived 类全部的“独有特征”。
注意:
基类的指针或引用指向派生类对象时,派生类保留其重写方法。但是通过类型转换将派生类对象转换为基类对象时,就会委失其独有特征。重写方法和派生类数据的丢失称为截断(slicing).
override 关键字
有时,可能会偶然创建一个新的虚方法,而不是重写基类的方法。考虑下面的 Base 和 Derived 类,其中Derived 类正确重写了 someMethod(),但没有使用 override 关键字:
class Base
{
public:
virtual void someMethod(double d);
};
class Derived : public Base
{
public:
virtual void someMethod(double d);
};
可通过引用调用 someMethod0),如下所示:
Derived myDerived;
Base& ref = myDerived;
ref.someMethod(1.01);//Calls Derived's version of someMethod();
上述代码能正确地调用 Derived 类重写的 someMethod0。现在假定重写 someMethod()时,使用整数(而不是双精度数)作为参数,如下所示:
class Derived : public Base
{
public:
virtual void someMethod(int i);
};
这些代码没有重写 Base 类的 someMethod(),而是创建了一个新的虚方法。如果试图像下面的代码那样通过引用调用 someMethod0),将调用 Base 类的 someMethod()而不是 Derived 类中定义的那个方法。
Derived myDerived;
Base& ref = myDerived;
ref.someMethod(1.1); //Calls Base's version of someMethod()
如果修改了 Base 类但忘记更新所有派生类,就会发生这类问题。例如,或许 Base 类的第一个版本有一个以整数作为参数的 someMethod()方法。然后在 Derived 派生类中重写了 someMethod()方法,仍然以整数作为参数。后来发现 Base 类中的 someMethod()方法需要一个双精度数而不是整数,因此更新了 Base 类中的someMethod()。此时你可能忘记更新派生类中的 someMethod(),让它们接收双精度数而不是整数。由于忘记了这一点,实际上就是创建了一个新的虚方法,而不是正确地重写这个方法。可用 override 关键字避免这种情况,如下所示:
class Derived : public Base
{
public:
virtual void someMethod(int i) override;
};
Derived 类的定义将导致编译错误,因为 override 关键字表明,重写 Base 类的 someMethod()方法,但 Base类中的 someMethod()方法只接收双精度数,而不接收整数。重命名基类中的某个方法,但忘记重命名派生类中的重写方法时,就会出现上述“不小心创建了新方法,而不是正确重写方法”的问题。
注意:要想重写基类方法,始终在方法上使用 override 关键字。
隐藏而不是重写
下面的代码显示了一个基类和一个派生类,每个类都有一个方法。派生类试图重写基类的方法,但是在基类中没有将这个方法声明为 virtual。
class Base
{
public:
void go(){cout<<"go() called on Base"<<endl;}
};
class Derived : public Base
{
public:
void go() { cout<< "go() called on Derived"<<endl; }
};
试着用 Derived 对象调用 go()方法好像没有问题。
Derived myDerived;
myDerived.go();
正如预期的那样,这个调用的结果是“go0 called on Derived”。然而,由于这个方法不是 virtual,因此实际上没有被重写。相反,Derived 类创建了一个新的方法,名称也是 go(),这个方法与 Base 类的 go()方法完全没有关系。为证实这一点,只需要用 Base 指针或引用调用这个方法:
Derived myDerived;
Base& ref = myDerived;
ref.go();
你可能希望输出是“go() called on Derived”,但实际上,输出是“go() called on Base”。这是因为 ref 变量是一个 Base 引用,并省略了 virtual 关键字。当调用 go()方法时,只是执行了 Base 类的 go()方法。由于不是虚方法,不需要考虑派生类是否重写了这个方法。
警告:
试图重写非虚方法将“隐藏”基类定义的方法,并且重写的这个方法只能在派生类环境中使用。
如何实现 virtual
为理解如何避免隐藏方法,需要了解 virtual 关键字的真正作用。C++在编译类时,会创建一个包含类中所有方法的二进制对象。在非虚情况下,将控制交给正确方法的代码是硬编码,此时会根据编译时的类型调用方法。这称为静态绑定(static binding),也称为早绑定(early binding)。
如果方法声明为 virtual,会使用名为虚表(vtable)的特定内存区域调用正确的实现。每个具有一个或多个虚方法的类都有一张虚表,这种类的每个对象都包含指向虚表的指针,这个虚表包含指向虚方法实现的指针。通过这种方法,当使用某个对象调用方法时,指针也进入虚表,然后根据实际的对象类型执行正确版本的方法。这称为动态绑定(dynamic binding)或晚绑定(late binding)。为更好地理解虚表是如何实现方法的重写的,考虑下面的Base 和 Derived 类:
class Base
{
public:
virtual void func1(){}
virtual void func2(){}
void nonVirtualFunc(){}
};
class Derived : public Base
{
public:
virtual void func2() override{}
void nonVirtualFunc(){}
};
对于这个示例,考虑下面的两个实例;
Base myBase;
Derived myDerived;
图 10-4 显示了这两个实例虚表的高级视图。myBase 对象包含了指向虚表的一个指针,虚表有两项,一项是 fun1(),另一项是 func2()。这两项指向 Base::func1()和 Base::func2()的实现。
[外链图片转存中…(img-eHONMaHB-1632236813909)]
myDerived 也包含指向虚表的一个指针, 这个虚表也包含两项, 一项是 func1(), 另一项是 func2()。 myDerived虚表的 func1项指向 Base::func1,因为 Derived 类没有重写f; 但是 myDerived 虚表的 func2()项指向
使用 virtual 的理由
前面建议将所有方法都声明为 virtual,既然这样,为什么要使用 virtual 关键字呢? 编译器不能自动将所有方法都声明为 virtual 吗? 答案是可以。许多人认为 C++语言应该将所有方法都声明为 virtual,Java 语言就是这么做的。有关无所不在地使用 virtual 的争论,以及首先创建该关键字的原因,都与虚表的开销有关。要调用虚方法,程序需要执行一项附加操作,即对指向要执行的适当代码的指针解除应用。在多数情况下,这样做会轻微地影响性能,但是 C++的设计者认为,最好让程序员决定是否有必要影响性能。如果方法永远不会重写,就没必要将其声明为 virtual,从而影响性能。然而对于当今的 CPU 而言,对性能的影响可以用十亿分之一秒来度量,将来的 CPU 会使时间进一步缩短。在多数应用程序中,无法察觉到使用虚方法和不使用虚方法带来的性能差别,因此应该遵循建议,将所有方法声明为 virtual,包括析构函数。
但在某些情况下,性能开销确实不小,需要避免。例如,假设 Point 类有一个虚方法。如果另一个数据结构存储着数百万个甚至数十亿个 Point 对象,在每个 Point 对象上调用虚方法将带来极大的开销。此时,最好避免在 Point 类中使用虚方法。virtual 对于每个对象的内存使用也有轻微影响。除了方法的实现之外,每个对象还需要一个指向虚表的指针,这个指针会占用一点空间。绝大多数情况下,这都不是问题。但有时并非如此。再看下 Point 类以及存储数百万个 Point 对象的容器。此时,附带的内存开销将很大。
虚析构函数的需求
即使认为不应将所有方法都声明为 virtual 的程序员,也坚持认为应该将析构函数声明为 virtual。原因是,如果析构函数未声明为 virtual,很容易在销毁对象时不释放内存。唯一允许不把析构函数声明为 virtual 的例外情况是,类被标记为 final。
例如,派生类使用的内存在构造函数中动态分配,在析构函数中释放。如果不调用析构函数,这块内存将无法释放。类似地,如果派生类具有一些成员,这些成员在类的实例销毁时自动删除,如 std::unique ptrs,那么如果从未调用析构函数,将不会删除这些成员。如下面的代码所示,如果析构函数不是 virtual,很容易欺骗编译器忽略析构函数的调用。如下面的代码所示,如果析构函数不是 virtual,很容易欺骗编译器忽略析构函数的调用。
class Base
{
public:
Base(){}
~Base(){}
};
class Derived:public Base
{
public:
Derived()
{
mString = new char[30];
cout<<"mString allocated"<<endl;
}
~Derived()
{
delete[]mString;
cout<<"mString deallocated "<<endl;
}
private:
};
int main()
{
Base* ptr = new Derived();
delete ptr;
return 0;
}
从输出可以看到,从未调用 Derived 对象的析构函数:
mString allocated
实际上,在上面的代码中,delete 调用的行为未在标准中定义。在这样的不明确情形中,C++编译器会随意做事,但大多数编译器只是调用基类,而非派生类的析构函数。
注意:
如果在析构函数中什么都不做,只想把它设置为 virtual,可显式地设置“= default”,例如:
class Base
{
public:
virtual ~Base() = default;
}
如第 8 章所述,注意从 C++1l 开始,如果类具有用户声明的析构函数,就不赞成生成复制构造函数和复制赋值运算符。在此类情况下,如果仍然需要编译器生成的复制构造函数或复制赋值运算符,可将它们显式设置为默认。为保持简洁,本章的这个示例没有这么做。
敬告;
除非有特别原因,或者类被标记为 final,否则强烈建议将所有方法(包括析构函数,构造函数除外)声明为virtual。构造函数不需要,也无法声明为 virtual,因为在创建对象时,总会明确地指定类。
\6. 禁用重写
C++人允许将方法标记为 final,这意味着无法在派生类中重写这个方法。试图重写 final()方法将导致编译错误。考虑下面的 Base 类:
class Base
{
public:
virtual ~Base() = default;
virtual void someMethod() final;
};
在下面的 Derived 类中重写 someMethod()会导致编译错误,因为 someMethod()在 Base 类中标记为 final。
class Derived : public Base{ public: virtual void someMethod() override;//Error};
10.2 ”使用继承重用代码
熟悉了继承的基本语法后,下面解释为什么继承是 C++语言中的重要特性。继承是利用已有代码的工具,本节给出了使用继承重用代码的实际程序。假定要编写一个简单的天气预报程序,同时给出华氏温度和摄氏温度。天气预报可能超出了程序员的研究领域, 因此程序员可以使用一个第三方的类库, 这个类库根据当前温度和火星与木星之间的距离(这荒廖吗? 不,是有点道理的)预测天气。为保护预报算法的知识产权,将第三方的包作为已编译的库分发,但是可以看到类的定义。WeatherPrediction 类的定义如下:
// Predicts the weather using proven new-age techniques given the current
// temperature and the distance from Jupiter to Mars. If these values are
// not provided, a guess is still given but it's only 99% accurate.
class WeatherPrediction
{
public:
// Virtual destructor
virtual ~WeatherPrediction();
// Sets the current temperature in Fahrenheit
virtual void setCurrentTempFahrenheit(int temp);
// Sets the current distance between Jupiter and Mars
virtual void setPositionOfJupiter(int distanceFromMars);
// Gets the prediction for tomorrow's temperature
virtual int getTomorrowTempFahrenheit() const;
// Gets the probability of rain tomorrow. 1 means
// definite rain. 0 means no chance of rain.
virtual double getChanceOfRain() const;
// Displays the result to the user in this format:
// Result: x.xx chance. Temp. xx
virtual void showResult() const;
// Returns a string representation of the temperature
virtual std::string getTemperature() const;
private:
int mCurrentTempFahrenheit;
int mDistanceFromMars;
};
注意这个类将所有方法标记为 virtual,因为这个类假定这些方法可能在派生类中重写。这个类解决了大部分问题。然而与多数情况一样,它与该程序的需求并不完全吻合。首先,所有的温度都以华氏温度给出,程序还需要处理摄氏温度。其次,showResult()方法的结果显示方式可能并不是程序想要的。
在派生类中添加功能
第 5 章讲述继承时,首先描述的技巧就是添加功能。基本上该程序需要一个类似于 WeatherPrediction 的类,还需要添加一些附属功能。使用继承重用代码听起来是个好主意。首先定义一个新类 MyWeatherPrediction,这个类从 WeatherPrediction 类继承:
#include "WeatherPrediction.h"
class MyWeatherPrediction : public WeatherPrediction
{
};
前面的类定义可以成功编译。MyWeatherPrediction 类已经可以替代 WeatherPrediction 类。这个类可提供相同的功能,但没有新功能。开始修改时,要在类中添加摄氏温度的信息。这里有点小问题,因为不知道这个类的内部在做什么。如果所有的内部计算都使用华氏温度,如何添加对摄氏温度的支持呢? 方法之一是采用派生类作为用户(可以使用两种温度)和基类(只理解华氏温度)之间的中间接口。支持摄氏温度的第一步是添加新方法,允许客户用摄氏温度(而不是华氏温度)设置当前的温度,从而获取明天以摄氏温度(而不是华氏温度)表示的天气预报。还需要包含在摄氏温度和华氏温度之间转换的私有辅助方法。这些方法可以是静态方法,因为它们对于类的所有实例都相同。
#include "WeatherPrediction.h"
class MyWeatherPrediction : public WeatherPrediction
{
public:
virtual void setCurrentTempCelsius(int temp);
virtual int getTomorrorTempCelsius() const;
private:
static int convertCelsiusToFahrenheit(int celsius);
static int convertFahrenheiToCelsius(int fahrenheit);
}
新方法遵循与父类相同的命名约定。记住,从其他代码的角度看,MyWeatherPrediction 对象具MyWeatherPrediction 和 WeatherPrediction 类定义的所有功能。采用父类的命名约定可以提供前后一致的接口。我们把振氏温度/华氏温度转换方法的实现作为练习留给读者-一这是一种乐趣。另外两个方法更有趣。为 了用摄氏温度设置当前温度,首先需要转换温度,其次将其以父类可以理解的单位传递给父类。
void MyWeatherPrediction:setCurrentTempCelsius(int temp)
{
int fahrenheitTemp = convertCelsiusToFahrenheit(temp);
setCurrentTempFahrenheit(fahrenheitTemp);
}
可以看出,执行温度转换后,这个方法调用了基类中的已有功能。同样,getTomorrowTempCelsius()的实现使用了父类的已有功能,获取华氏温度,但是在返回结果之前将其转换为摄氏温度。
int MyWeatherPrediction::getTomorrowTempCeisius() const;
{
int fahrenheitTemp = getTomorrowTempFahrenheit();
return convertFahrenheitToCelsius(fahrenheitTemp);
}
这两个新方法都有效地重用了父类,因为它们以某种方式“封装”了类已有的功能,并提供了使用这些功能的新接口。还可添加与父类已有功能无关的全新功能。例如,可添加一个方法,从 Intemet 获取其他天气预报,或添加一个方法,根据天气预报给出建议的活动。
在派生类中替换功能
与派生类相关的另一个主要技巧是替换已有的功能。WeatherPrediction 类中的 showResult()方法急需修改。MyWeatherPrediction 类可以重写这个方法,以蔡换原始实现中的行为。新的 MyWeatherPrediction 类定义如下所示:
class MyWeatherPrediction : public WeatherPrediction
{
public:
virtual void setCurrentTempCelsius(int temp);
virtual int getTomorrowTempCelsius() const;
virtual void showResult() const override;
private:
static int convertCelsiusToFahrenheit(int celsius);
static int convertFahrenheitToCelsius(int fahrenheit);
};
利用父类
编写派生类时,需要知道父类和派生类之间的交互方式。 创建顺序、构造函数链和类型转换都是潜在的bug
父类构造函数
对象并不是突然建立起来的,创建对象时必须同时创建父类和包含于其中的对象。C++定义了如下创建顺序:
(1) 如果某个类具有基类,执行基类的默认构造函数。除非在 ctor-initializer 中调用了基类构造函数,否则此时调用这个构造函数而不是默认构造函数。
(2) 类的非静态数据成员按照声明的顺序创建。
(3) 执行该类的构造函数。
可递归使用这些规则。如果类有祖父类,祖父类就在父类之前初始化,依此类推。下面的代码显示了创建顺序。通常建议不要在类定义中直接实现方法,如下面的代码所示。为了使示例简洁并易于阅读,我们违反了自己的规则。代码正确执行时输出结果为 123。
class Something
{
public:
Something(){cout<<"2";}
};
class Base
{
public:
Base() {cout<<"1";}
};
class Derived : public Base
{
public:
Derived(){cout<<"3";}
private:
Something mDataMember;
};
int main()
{
Derived myDerived;
return 0;
}
创建 myDerived 对象时,首先调用 Base 构造函数,输出字符串“1”。随后,初始化 mDataMember,调用Something 构造函数,输出字符串“2”。最后调用 Derived 构造函数,输出“3 ”。注意 Base 构造函数是自动调用的。C++将自动调用父类的默认构造函数(如果存在的话)。如果父类的默认构造函数不存在, 或者存在默认构造函数但希望使用其他构造函数, 可在构造函数初始化器(constructor initialize)中像初始化数据成员那样链接构造函数。 例如,下面的代码显示了没有默认构造函数的 Base 版本。相关版本的Derived 必须显式地告诉编译器如何调用 Base 构造函数,否则代码将无法编译。
class Base
{
public:
Base(int i);
};
class Derived : public Base
{
public:
Derived();
};
Drived::Derived():Base(7)
{
}
在前面的代码中,Derived 构造函数向 Base 构造函数传递了固定值(7)。如果 Derived 构造函数需要一个参数,也可以传递变量:
Derived::Derived(int i) : Base(i) {}
从派生类向基类传递构造函数的参数很正常,毫无问题,但是无法传递数据成员。如果这么做,代码可以编译, 但是记住在调用基类构造函数之后才会初始化数据成员。如果将数据成员作为参数传递给父类构造函数,数据成员不会初始化。
警告:
虚方法的行为在构造函数中是不同的,如果派生类重写了基类中的虚方法,从基类构造函数中调用虚方法,就会调用虚方法的基类实现而不是派生类中的重写版本。
父类的析构函数
由于析构函数没有参数, 因此始终可自动调用父类的析构函数。析构函数的调用顺序刚好与构造函数相反
(1) 调用类的析构函数。
(2) 销毁类的数据成员,与创建的顺序相反。
(3) 如果有父类,调用父类的析构函数。
也可递归使用这些规则。 链的最底层成员总是第一个被销毁。下面的代码在前面的示例中加入了析构函数。所有析构函数都声明为 virtual,这一点非常重要,将在本例之后进行讨论。执行时代码将输出“123321”。
class Something
{
public:
Something(){cout<<"2";}
virtual ~Something(){cout<<"2";}
};
class Base
{
public:
Base(){cout<<"1";}
virtual ~Base(){cout<<"1";}
};
class Derived : public Base
{
public:
Derived(){cout<<"3";}
virtual ~Derived(){cout<<"3";}
private:
Something mDataMember;
};
即使前面的析构函数没有声明为 virtual,代码也可以继续运行。然而,如果代码使用 delete 删除一个实际指向派生类的基类指针, 析构函数调用链将被破坏。例如, 下面的代码与前面示例类似, 但析构函数不是 virtual。当使用指向 Base 对象的指针访问 Derived 对象并删除对象时,就会出问题。
Base* ptr = new Derived();
delete ptr;
代码的输出很短, 是“1231”.。当删除 ptr 变量时, 只调用了 Base 析构函数, 因为析构函数没有声明为 virtual。结果是没有调用 Derived 析构函数,也没有调用其数据成员的析构函数。从技术角度看,将 Base 析构函数声明为 virtual,可纠正上面的问题。派生类将自动“虚化"。然而,建议显式地将所有析构函数声明为 virtual,这样就不必担心这个问题。
警告:
将所有析构函数声明为 virtual! 编译器生成的默认析构邓数不是 virtual,因此应该定义自己(或显式设置为默认)的庶析构函数,至少在父类中应该这么做。
警告:
与构造函数一样,在析构函数中调用虚方法时,虚方法的行为将有所不同。如果派生类重写了基类中的虚方法,在基类的析构函数中调用该方法,会执行该方法的基类实现,而不是派生类的重写版本。
使用父类方法
在派生类中重写方法时,将有效地替换原始方法。然而,方法的父类版本仍然存在,仍然可以使用这些方法。例如, 某个重写方法可能除了完成父类实现完成的任务之外, 还会完成一些其他任务。考虑 WeatherPrediction类中的 getTemperature()方法,这个方法返回当前温度的字符串表示:
class WeatherPrediction
{
public:
virtual std::string getTemperature() const;
};
在 MyWeatherPrediction 类中,可按如下方式重写这个方法:
class MyWeatherPrediction:public WeatherPrediction
{
public:
virtual std::string getTemperature() const override;
}
假定派生类要先调用基类的 getTemperature()方法,然后将"F 添加到 string。为此,编写如下代码;
string MyWeatherPrediction::getTemperature() const
{
return getTemperature() + "\u00B0F"; //BUG
}
然而,上述代码无法运行,根据 C++的名称解析规则,首先解析的是局部作用域,然后是类作用域,根据这个顺序,函数中调用的是 MyWeatherPrediction::getTemperature()。其结果是无限递归,直到耗尽堆栈空间(某些编译器在编译时,会发现这种错误并报错)。 .
为让代码运行,需要使用作用域解析运算符,如下所示:
string MyWeatherPrediction::getTemperature() const
{
return WeatherPrediction::getTemperature() + "\u00B0F";
}
在 C++中,调用当前方法的父类版本是一种常见操作。如果存在派生类链,每个派生类都可能想执行基类中已经定义的操作,同时添加自己的附加功能。另一个示例是书本类型的类层次结构。图 10-5 显示了这个层次结构。由于层次结构底层的类更具体地指出了书本的类型,获取书本描述信息的方法实际上需要考虑层次结构中的所有层次。为此,可连续调用父类方法,下面的代码演示了这一模式:
class Book
{
public:
virtual ~Book() = default;
virtual string getDescription() const { return "book"; }
virtual int getHeight() const { return 120; }
};
class Paperback:public Book
{
public:
virtual string getDescription() const override
{
return "Paperback "+Book::getDescription();
}
};
class Romance : public Paperback
{
public:
virtual string getDescription() const override
{
return "Romance "+ Paperback::getDescription();
}
virtual int getHeight() const override
{
return Paperback::getHeight() / 2;
}
};
class Techical : public Book
{
public:
virtual string getDescription( ) const override
{
return "Technical "+ Book::getDescription();
}
};
int main()
{
Romance novel;
Book book;
cout << novel.getDescription() << endl;
cout << book.getDescription() << endl;
cout << novel.getHeight() << endl;
cout << book.getHeight() << endl;
return 0;
}
Book 基类有两个虚方法:getDescription()和 getHeight()。所有派生类都重写了 getDescription(), 只有 Romance类通过调用父类(Paperback)的 getHeight(),然后将结果除以 2,重写了 getHeight()。Paperback 类没有重写getHeight(),因此 C++会沿着类层次结构向上寻找实现了 getHeight()的类。在本例中,Paperback::getHeight()将解析为 Book::getHeight()。
向上转型和向下转型
如前所述,对象可转换为父类对象,或者赋值给父类。如果类型转换或赋值是对某个普通对象执行,会产生截断
Base myBase = myDerived;
这种情况下会导致截断,因为赋值结果是 Base 对象,而 Base 对象缺少 Derived 类中定义的附加功能。然而,如果用派生类对基类的指针或引用赋值,则不会产生截断:
Bases myBase = myDerived; // No slicingl!
这是通过基类使用派生类的正确途径, 也叫作向上转型(upcasting)。 这也是让方法和函数使用类的引用而不是直接使用类对象的原因。使用引用时,派生类在传递时没有截断。
警告:
当向上转型时,使用基类指针或引用以避免截断。
将基类转换为其派生类也叫作向下转型(towncasting),专业的 C++程序员通常不赞成这种转换,因为无法保证对象实际上属于派生类,也因为向下转型是不好的设计。例如,考虑下面的代码;
void presumptuous(Base* base)
{
Derived* myDerived = static_cast<Derived*>(base);
}
如果 presumptuous()的作者还编写了调用 presumptuous()的代码,那么可能一切正常,因为作者知道这个函数需要 Derived*类型的参数。然而,如果其他程序员调用 presumptuous(),他们可能传递 Base*。编译时检测无法强制参数类型,因此函数盲目地假定 inBase 实际上是一个指向 Derived 对象的指针。
向下转型有时是必需的,在可控环境中可充分利用这种转换。然而,如果打算进行向下转型,应该使用dynamic cast(),以使用对象内建的类型信息,拒绝没有意义的类型转换。这种内建信息通常驻留在虚表中,这意味着 dynamic_cast()只能用于具有虚表的对象, 即至少有一个虚编号的对象。如果针对某个指针的 dynamic_cast()失败,这个指针的值就是 nullptr,而不是指向某个无意义的数据。如果针对对象引用的 dynamic_cast()失败,将抛出 std::bad_cast 异常。第 11 章将详细讨论类型转换。前面的示例应该这样编写:
void lessPresumptuous(Base* base)
{
Derived* myDerived = dynamic_cast<Derived*>(base);
if(myDerived != nullptr)
{
}
}
向下转型通常是设计不良的标志。你应当反思,并修改设计,以避免使用向下转型。例如,lessPresumptuous()函数实际上只能用于 Derived 对象,因此不应当接收 Base 指针,而应接收 Derived 指针。这样就不需要进行向下转型了。如果函数用于从 Base 继承的不同派生类,则应考虑使用多态性的解决方案,如下所述。
警告;
仅在必要的情况下才使用向下转型,一定要使用 dynamic_cast()。
继承与多态性
理解了派生类与父类的关系后,就可以用最有力的方式使用继承一多态性(polymorphism)。第 5 章说过,多态性可以互换地使用具有共同父类的对象,并用对象替换父类对象。
回到电子表格
第 8 章和第 9 章使用电子表格程序作为示例来说明面向对象设计。SpreadsheetCell 代表一个数据元素。在前面,这个元素始终存储的是单个双精度值。下面给出了简化的 SpreadsheetCell 类定义。注意单元格可以是双精度值或字符串,然而这个示例中单元格的当前值总以字符串的形式返回。
class SpreadsheetCell
{
public:
virtual void set(double inDouble);
virtual void set(std::string_view inString);
virtual std::string getString() const;
private:
static std::string doubleToString(double inValue);
static double stringToDouble(std::string_view inString);
double mValue;
};
在实际的电子表格应用程序中,单元格可以存储不同的数据类型, 有时单元格是双精度值,有时是文本。如果单元格需要其他类型,例如公式单元格或日期单元格,该怎么办?
\2. 纯虚方法和抽象基类
纯虚方法(pure virtual methods)在类定义中显式说明该方法不需要定义。如果将某个方法设置为纯虚方法,就是告诉编译器当前类中不存在这个方法的定义。具有至少一个纯虚方法的类称为抽象类,因为这个类没有实例。编译器会强制接受这个事实: 如果某个类包含一个或多个纯虚方法,就无法构建这种类型的对象。采用专门的语法指定纯虚方法:方法声明后紧接着=0。不需要编写任何代码。
class SpreadsheetCell
{
public:
virtual ~SpreadsheetCell() = default;
virtual void set(std::string_view inString) = 0;
virtual std::string getString() const = 0;
};
现在基类成了抽象类,无法创建 SpreadsheetCell 对象,下面的代码将无法编译,并给出诸如“error C2259:“SpreadsheetCelh:cannot instantiate abstract class”的错误。
SpreadsheetCell cel1; // Error! RARttempts creating abstract class instance
然而,一旦实现了 StringSpreadsheetCell 类,下面的代码就可成功编译,原因在于实例化了抽象基类的派生类;
std::unique_ptr<SpreadsheetCell> cell(new StringSpreadsheetCell());
注意:
抽象类提供了一种禁止其他代码直接实例化对象的方法,而它的派生类可以实例化对象。
注意,并不需要 SpreadsheetCellcpp 源文件,因为没有要实现的内容。大多数方法都是纯虚方法,在类定义中将析构函数显式地设置为默认。
\1. StringSpreadsheetCell 类定义
编写 StringSpreadsheetCell 类定义的第一步是从 SpreadsheetCell 类继承。第二步是重写继承的纯虚方法,此次不将其设置为 0。最后一步是为字符串单元格添加一个私有数据成员 mValue, 在其中存储实际单元格数据。这个数据成员是std::optional,从 C++17 开始定义在头文件中。optional 类型是一个类模板,因此必须在尖括号之间指定所需的实际类型,如 optional。第 12 章将详细讨论类模板。通过使用 optional 类型,可确认是否已经设置了单元格的值。第 20 章将详细讨论 optional 类型,但基本用法相当简单。
class StringSpreadsheetCell:public SpreadsheetCell
{
public:
virtual void set(std::string_view inString) override;
virtual std::string getString() const override;
private:
std::optional<std::string> mValue;
};
\3. DoubleSpreadsheetCell 类的定义和实现
双精度版本遵循类似的模式,但具有不同的逻辑。除了以 string_view 作为参数的基类的 set(方法之外,还提供新的 set()方法以允许用户使用双精度值设置其值。两个新的 private static 方法用于转换字符串和双精度值。与 StringSpreadsheetCell 相同,这个类也有一个 mValue 数据成员,此时这个成员的类型是 optional。
class DoubleSpreadsheetCell : public SpreadsheetCell
{
public:
virtual void set(double inDouble);
virtual vois set(std::string_view inString) override;
private:
static std::string doubleToString(double inValue);
static double stringToDouble(std::string_view inValue);
std::optional<double> mValue;
};
以双精度值作为参数的 set()方法简单明了。string_view 版本使用 private static 方法 stringToDouble()。getString()方法返回存储的双精度值作为字符串; 如果未存储任何值,则返回一个空字符串。它使用 std::optional的 has_value()方法来查询 optional 是否具有实际值。如果具有值,则使用 value()方法来获取。
从多个类继承
从语法角度看,定义具有多个父类的类很简单。为此,只需要在声明类名时分别列出基类;
class Baz : public Foo,public Bar
{
};
由于列出了多个父类,Baz 对象具有如下特性:
Baz 对象支持 Foo 和 Bar 类的 public 方法,并且包含这两个类的数据成员。
Baz 类的方法有权访问 Foo 和 Bar 类的 protected 数据成员和方法。
Baz 对象可以向上转型为 Foo 或 Bar 对象。
创建新的 Baz 对象将自动调用 Foo 和 Bar 类的默认构造函数,并按照类定义中列出的类顺序进行。
删除 Baz 对象将自动调用 Foo 和 Bar 类的析构函数调用顺序与类在类定义中的顺序相反。
名称冲突和层义基类
多重继承崩溃的场景并不难想象,下面的示例显示了一些必须考虑的边缘情况。
\1. 名称歧义
如果 Dog 类和 Bird 类都有一个 eat()方法,会发生什么? 由于 Dog 类和 Bird 类毫不相干,eat()方法的一个版本无法重写另一个版本一 在派生类 DogBird 中这两个方法都存在。
[外链图片转存中…(img-IA3RMoTi-1632236813911)]
下例显示了一个 DogBird 类,它有两个父类-一 Dog 类和 Bird 类,如图 10-8 所示。这是一个荒雇的示例,但是不应该认为多重继承本身是匾廖的,请自行判断。
class Dog
{
public:
virtual void bark(){cout<<"Woof!"<<endl;}
};
class Bird
{
public:
virtual void chirp(){cout<<"Chirp!"<<endl;}
};
class DogBird : public Dog,public Bird
{
};
使用具有多个父类的类对象与使用具有单个父类的类对象没什么不同。实际上,客户代码甚至不需要知道这个类有两个父类。需要关心的只是这个类支持的属性和行为。在此情况下,DogBird 对象支持 Dog 和 Bird 类所有的 public 方法。
DogBird myConfusedanimal;
myConfusedRnimal.bark();
myConfusedRnimal.chirp();
Woof!
Chirp!
名称冲突和层义基类
多重继承崩溃的场景并不难想象,下面的示例显示了一些必须考虑的边缘情况。
\1. 名称歧义
如果 Dog 类和 Bird 类都有一个 eat()方法,会发生什么? 由于 Dog 类和 Bird 类毫不相干,eat()方法的一个版本无法重写另一个版本一 在派生类 DogBird 中这两个方法都存在。只要客户代码不调用 eat()方法,就不会出现问题。尽管有两个版本的 eat()方法,但 DogBird 类仍然可以正确编译。然而,如果客户代码试图调用 DogBird 类的 eat()方法,编译器将报错,指出对 eat()方法的调用有歧义。编译器不知道该调用哪个版本。下面的代码存在歧义错误:
class Dog
{
public:
virtual void bark(){cout<<"Woof!"<<endl;}
virtual void eat(){cout<<"The dog ate "<<endl;}
};
class Bird
{
public:
virtual void chirp(){cout<<"Chirp!"<<endl;}
virtual void eat(){cout<<"The bird ate"<<endl; }
};
class DogBird : public Dog,public Bird
{
};
int main()
{
DogBird myConfusedAnimal;
myConfusedAnimal.eat(); // Error! Rmbiguous call to method eat ()
return 0;
}
为了消除歧义,可使用 dynamic_cast()显式地将对象向上转型(本质上是向编译器隐藏多余的方法版本),也可以使用歧义消除语法。下面的代码显示了调用 eat()方法的 Dog 版本的两种方案:
dynamic_cast<Dog&> (myConfusedAnimal).eat(); // Calls Dog::eat()
myConfusedAnimal.Dog::eat(); //Calls Dog::eat()
使用与访问父类方法相同的语法(::运算符),派生类的方法本身可以显式地为同名的不同方法消除歧义。例如,DogBird 类可以定义自己的 eat()方法,从而消除其他代码中的歧义错误。在方法内部,可以判断调用哪个父类版本:
class DogBird : public Dog,public Bird
{
public:
void eat() override;
};
void DogBird::eat()
{
Dog::eat(); // Explicitly call Dog's version of eat()
}
另一种防止歧义错误的方式是使用 using 语句显式指定,在 DogBird 类中应继承哪个版本的 eat()方法,如下面的 DogBird 类定义所示:
class DogBird : public Dog, public Bird
{
public:
using Dog::eat; // Explicit inherit Dog's version of eat()
};
\2. 歧义基类
另一种引起歧义的情况是从同一个类继承两次。例如,如果出于某种原因 Bird 类从 Dog 类继承,DogBird类的代码将无法编译,因为 Dog 变成了歧义基类。
class Dog {}
class Bird : public Dog {}
class DogBird : public Bird,public Dog {}; // Error
”多数歧义基类的情况或者是由人为的“what-if”示例(如前面的示例)引起的,或者是由于类层次结构的混乱引起的。图 10-9 显示了前面示例中的类图,并指出了歧义。
数据成员也可以引起歧义。如果 Dog 和 Bird 类具有同名的数据成员,当客户代码试图访问这个成员时,就会发生歧义错误。
多个父类本身也可能有共同的父类。例如,Bird 和 Dog 类可能都是 Animal 类的派生类,如图 10-10 所示。
[外链图片转存中…(img-2aJSOiNh-1632236813913)]
[外链图片转存中…(img-XsW73n0K-1632236813914)]
C++允许这种类型的类层次结构, 尽管仍然存在着名称歧义。例如, 如果 Animal 类有一个公有方法 sleep(),DogBird 对象无法调用这个方法,因为编译器不知道调用 Dog 类继承的版本还是 Bird 类继承的版本。使用“萎形”类层次结构的最佳方法是将最顶部的类设置为抽象类,将所有方法都设置为纯虚方法。由于类只声明方法而不提供定义,在基类中没有方法可以调用,因此在这个层次上就没有歧义。下例实现了菱形类层次结构,其中有一个每个派生类都必须定义的纯虚方法 eat)。DogBird 类仍须显式说明使用哪个父类的 eat()方法,但是 Dog 和 Bird 类引起歧义的原因是它们具有相同的方法,而不是因为从同一个类继承。
class Animal
{
public:
virtual void eat() = 0;
};
class Dog : public Animal
{
public:
virtual void bark(){ cout<<"Woof!"<<endl; }
virtual void eat() override {cout<<"The dog ate "<<endl; }
};
class Bird : public Animal
{
public:
virtual void chirp() {cout<<"Chirp!"<<endl; }
virtual void eat() override {cout<<"The bird ate "<<endl; }
};
class DogBird : public Dog,public Bird
{
public:
using Dog::eat;
};
虚基类是处理鞭形类层次结构中项部类的更好方法,将在本章最后讲述。
\3. 多重继承的用途
为什么程序员要在代码中使用多重继承? 多重继承最直接的用例就是定义一个既“是一个”事物,又“是一个”其他事物的类对象。第 5 章已经说过,遵循这个模式的实际对象很难恰当地转换为代码。多重继承最简单有力的用途就是实现混入(mix-in)类。混入类参见第 5 章。使用多重继承的另一个原因是模拟基于组件的类。第 5 章给出了飞机模拟示例,Airplane 类有引擎、机身、控制系统和其他组件。尽管 Airplane 类的典型实现是将这些组件当作独立的数据成员, 但也可以使用多重继承。飞机类可从引擎、机身、控制系统继承,从而有效地获得这些组件的行为和属性。建议不要使用这种类型的代码, 这将“有一个”关系与继承混淆了, 而继承用于“是一个关系。推荐的解决方案是让 Airplane 类包含 Engine、Fuselage 和 Controls 类型的数据成员。
有趣而晦涩的继承问题
扩展类引发了多种问题。类的哪些特征可以改变,哪些不能改变? 什么是非公共继承? 什么是虚基类? 下面将回答这些问题。
重写某个方法的主要原因是为了修改方法的实现。然而,有时是为了修改方法的其他特征。
\1. 修改方法的返回类型
根据经验,重写方法要使用与基类一致的方法声明(或方法原型)。实现可以改变,但原型保持不变。然而事实未必总是如此,在 C++中,如果原始的返回类型是某个类的指针或引用,重写的方法可将返回类型改为派生类的指针或引用。这种类型称为协变返回类型(covariant return types)。如果基类和派生类处于平行层次结构(parallel hierarchy)中,使用这个特性可以带来便利。平行层次结构是指,一个类层次结构与另一个类层次结构没有相交,但是存在联系。
例如,考虑樱桃果园模拟程序。可使用两个类层次结构模拟不同但明显相关的实际对象。第一个是 Cherry类层次结构,Cherry 基类有一个名为 BingCherry 的派生类。 与此类似,另一个类层次结构的基类为 CherryTree,派生类为 BingCherryTree。图 10-11 显示了这两个类层次结构。
[外链图片转存中…(img-yMskIG0g-1632236813916)]
Cherry* CherryTree::pick()
{
return new Cherry();
}
注意:
为了演示如何更改返回类型,本例未返回智能指针,而是返回普通指针。本节末尾将解释其中的原因。当然,调用者应当在智能指针(而非普通指针)中立即存储结果。
在 BingCherryTree 派生类中,要重写这个方法。或许冰樱桃在摘下来时需要擦拭(请允许我们这么说)。由于冰樱桃也是樱桃,在下例中,方法的原型保持不变,而方法被重写。BingCherry 指针被自动转换为 Cherry 指针。注意这个实现使用 unique_ptr 来确保 polish()抛出异常时,没有泄漏内存。
Cherry* BingCherryTree::pick()
{
auto theCherry = std::make_unique<BingCherry>();
theCherry->polish();
return theCherry.release();
}
上面的实现非常好,这也是作者想使用的方法。然而,由于 BingCherryTree 类始终返回 BingCherry 对象,因此可通过修改返回类型,向这个类的潜在用户指明这一点,如下所示:
BingCherry* BingCherryTree::pick()
{
auto theCherry = std::make_unique<BingCherry>();
theCherry->polish();
return theCherry.release();
}
下面是 BingCherryTree::pick()方法的用法:
BingCherryTree theTree;
std::unique_ptr<Cherry> theCherry(theTree.pick());
theCherry->printType();
为判断能否修改重写方法的返回类型,可以考虑已有代码是否能够继续运行,这称为里氏替换原则( Liskov Substitution Principle,LSP)。在上例中,修改返回类型没有问题,因为假定 pick()方法总是返回 Cherry*仍然可以成功编译并正常运行。由于冰樱桃也是樱桃,因此任何根据 CherryTree 版本的 pick()返回值调用的方的代码法,仍然可以基于 BingCherryTree 版本的 pick()返回值进行调用。不能将返回类型修改为完全不相关的类型,例如 void*。下面的代码无法编译;
void* BingCherryTree::pick()
{
auto theCherry = std::make_unique<BingCherry>();
theCherry->polish();
return theCherry.release();
}
这段代码会导致编译错误,如下所示:
"BingCherryTree::pick': overriding virtual function return type differs and is not covariant from 'cherryTree::pick'
如前所述,这个示例正用普通指针替代代智能指针。将 std::unique_ptr 用作和返回类型时,这不能用于本例。假设 CherryTree::pick()返回 unique_ptr,如下所示:
std::unique_ptr<Cherry> CherryTree::pick()
{
return std::make_unique<Cherry>();
}
此时,无法将 BingCherryTree::pick()方法的返回类型改成 unique_ptr。下面的代码无法编译:
void *BingCherryTree::pick() //ERROR
{
auto theCherry = std::make_unique<BingCherry>();
theCherry->polish();
return theCherry.release();
}
这段代码会导致编译错误,如下所示:
"BingCherryTree::pick': overriding virtual function return type differs and is not covariant from 'CherryTree::pick'
如前所述,这个示例正用普通指针蔡代智能指针。将 std::unique_ptr 用作返回类型时,这不能用于本例。假设 CherryTree::pick()返回 unique_ptr,如下所示:
std::unique_ptr<Cherry>CherryTree::pick()
{
return std::make_unique<Cherry>();
}
此时,无法将 BingCherryTree::pick()方法的返回类型改成 unique_ptr。下面的代码无法编译:
class BingCherryTree:public CherryTree
{
public:
virtual std::unique_ptr<BingCherry>pick() override;
};
原因在于 std:unique ptr 是类模板,第 12 章将详细讨论类模板。创建 unique_ptr 类模板的两个实例unique_ptr和 unique_ptr。这两个实例是完全不同的类型,完全无关。无法更改重写方法的返回类型来返回完全不同的类型。
\2. 修改方法的参数
如果在派生类的定义中使用父类中虚方法的名称,但参数与父类中同名方法的参数不同,那么这不是重写父类的方法, 而是创建一个新方法。回到本章前面的 Base 和 Derived 类示例,可试着在 Derived 类中使用新的参数列表重写 someMethod()方法,如下所示:
class Base
{
public:
virtual void someMethod();
};
class Derived : public
{
public:
virtual void someMethod(int i); // Compiles,but doesn't override
virtual void someOtherMethod();
}
这个方法的实现如下所示:
void Derived::someMethod(int i)
{
cout<<"This is Derived's version of someMethod with argument "<<i<<"."<<endl;
}
前面的类定义可以编译,但没有重写 someMethod()方法。因为参数不同, 所创建的是一个只存在于 Derived类中的新方法。如果需要 someMethod()方法采用 int 参数,并且只将这个方法应用于 Derived 类对象,前面的代码没有问题。
实际上,C++标准指出,当 Derived 类定义了这个方法时,原始的方法被隐藏。下面的代码无法编译,因为没有参数的 someMethod()方法不再存在。
Derived myDerived;
myDerived.someMethod();// Error! Won't compile because original method is hidden.
如果希望重写基类中的 someMethod()方法,就应该像前面建议的那样使用 override 关键字。如果在重写方法时发生错误,编译器会报错。可使用一种较上涩的技术兼顾二者。也就是说,可使用这一技术在派生类中有效地用新的原型“重写”某个方法,并继承该方法的基类版本。这一技术使用 using 关键字显式地在派生类中包含这个方法的基类定义;
class Base
{
public:
virtual void someMethod();
};
class Derived : public Base
{
public:
using Base::someMethod; // Explicity "inherits" the Base version
virtual void someMethod(int i); // Adds a new version of someMethod
virtual void someOtherMethod();
};
注意;
派生类的方法与基类方法同名但参数列表不同的情况很少见。
继承的构造函数
节提到,可在派生类中使用 using 关键字显式地包含基类中定义的方法。这适用于普通类方法,也适用于构造函数,允许在派生类中继承基类的构造函数。考虑下面的 Base 和 Derived 类定义:
class Base
{
public:
virtual ~Base() = default;
Base() = default;
Base(std::string_view str);
};
class Derived:public Base
{
public:
Derived(int i);
};
只能用提供的 Base 构造函数构建 Base 对象,要么是默认构造函数,要么是包含 string_view 参数的构造函数。另外,只能用 Derived 构造函数创建 Derived 对象,这个构造函数需要一个整数作为参数。不能使用 Base类中使用接收 string_view 的构造函数来创建 Derived 对象。例如:
Base base("Hello"); //Ok,calls string_view Base ctor
Derived derived(1); //Ok,calls integer Derived ctor
Derived derived2("hello"); //Error,Derived does not inherit string_view ctor
如果喜欢使用基于 string_view 的 Base 构造函数构建 Derived 对象,可在 Derived 类中显式地继承 Base 构造函数,如下所示:
class Derived : public Base
{
public:
using Base::Base;
Derived(int i);
};
using 语句从父头继承除默认构造函数外的其他所有构造轴数, 现在可通过下面两种方法构建 Derived 对象;
Derived derived1(1); // OK "calls integer Derived ctor
Derived derived2("Hello"); // OK,calls inherited string_view Base ctor
Derived 类定义的构造函数可与从 Base 类继承的构造函数有相同的参数列表。与所有的重写一样,此时Derived 类的构造函数的优先级高于继承的构造函数。在下例中,Derived 类使用 using 关键字继承了 Base 类中除默认构造函数外的其他所有构造函数。然而,由于 Derived 类定义了一个使用浮点数作为参数的构造函数,从 Base 类继承的使用浮点数作为参数的构造函数被重写。
class Base1
{
public:
virtual ~Base1() = default;
Base1() = default;
Base1(float f);
};
class Base2
{
public:
virtual ~Base2() = default;
Base2() = default;
Base2(std::string_view str);
Base2(float f);
};
class Derived : public Base1,public Base2
{
public:
using Base1::Base1:
using Base2::Base2;
Derived(char c);
};
Derived 类定义中的第一条 using 语句继承了 Basel 类的构造函数。这意味着 Derived 类具有如下构造函数:
Derived(float f); // Inherited from Basel
Derived 类定义中的第二条 using 子句试图继承 Base2 类的全部构造函数。然而,这会导致编译错误,因为这意味着 Derived 类拥有第二个 Derived(float) 构造函数。为解决这个问题,可在 Derived 类中显式声明冲突的构造函数,如下所示:
class Derived : public Base1,public Base2
{
public:
using Base1::Base1;
using Base2::Base2;
Derived(char c);
Derived(float f);
};
现在,Derived 类显式地声明了一个采用浮点数作为参数的构造函数,从而解决了歧义问题。如果愿意,在Derived类中显式声明的使用浮点数作为参数的构造函数仍然可以在 ctor-initializer中调用 Basel 和 Base2 构造函数,如下所示:
Derived::Derived(float f):Base1(f),Base2(f){}
当使用继承的构造函数时,要确保所有成员变量都正确地初始化。例如,考虑下面 Base 和 Derived 类的新定义。这个示例没有正确地初始化 mInt 数据成员,在任何情况下这都是一个严重错误。
class Base
{
public;
virtual ~Base() = default;
Base(std::string_view str):mStr(str){}
private:
std::string mStr;
};
class Derived : public Base
{
public:
using Base::Base;
Derived(int i) : Base(""),mInt(i){}
private:
int mInt;
}
可采用如下方法创建一个 Derived 对象:
Derived s1(2);
这条语句将调用 Derived(int i)构造函数,这个构造函数将初始化 Derived 类的 mInt 数据成员,并调用 Base构造函数,用空字符串初始化 mStr 数据成员。 -
Derived s2("Hello World")
这条语句调用从 Base 类继承的构造函数。然而,从 Base 类继承的构造函数只初始化了 Base 类的 mStr 成员变量,没有初始化 Derived 类的 mInt 成员变量,mInt 处于未初始化状态。通常不建议这么做。解决方法是使用类内成员初始化器,第 8 章已讨论过这个特性。以下代码使用类内成员初始化器将 mInt初始化为0。Derived(int )构造函数仍可修改这一初始化行为,将 mInt 初始化为参数i 的值。
class Derived : public Base
{
public:
using Base::Base;
Derived(int i):Base(""),mInt(i){}
private:
int mInt = 0;
};
重写方法时的特殊情况
当重写方法时,需要注意几种特殊情况。本节将列出可能遇到的一些情况。
- 静态基类方法
在 C++中,不能重写静态方法。对于多数情况而言,知道这一点就足够了。然而,在此需要了解一些推论。首先,方法不可能既是静态的又是虚的。出于这个原因,试图重写一个静态方法并不能得到预期的结果。如果派生类中存在的静态方法与基类中的静态方法同名,实际上这是两个独立的方法。下面的代码显示了两个类,这两个类都有一个名为 beStatic()的静态方法。这两个方法毫无关系。
class BaseStatic
{
public:
static void beStatic()
{
cout<<"BaseStatic being static"<<endl;
}
};
class DerivedStatic : public BaseStatic
{
public:
static void beStatic()
{
cout<<"DerivedStatic keepin's in static "<<endl;
}
};
由于静态方法属于类,调用两个类的同名方法时,将调用各自的方法。
BaseStatic::beStatic();
DerivedStatic::beStatic();
输出
BaseStatic being static.
DerivedStatic keepin' it static-.
用类名访问这些方法时一切都很正常。当涉及对象时,这一行为就不是那么明显。在 C++中,可以使用对象调用静态方法,但由于方法是静态的,因此没有 this 指针,也无法访问对象本身,使用对象调用静态方法,等价于使用 classname::method()调用静态方法。回到前面的示例,可以编写如下代码,但是结果令人惊讶:
DerivedStatic myDerivedStatic;
BaseStatic& ref = myDerivedStatic;
myDerivedStatic.beStatic();
ref.beStatic();
对 beStatic()的第一次调用显然调用了 DerivedStatic 版本,因为调用它的对象被显式地声明为 DerivedStatic对象。第二次调用的运行方式可能并非预期的那样。这个对象是一个 BaseStatic 引用,但指向的是一个DerivedStatic 对象。在此情况下,会调用 BaseStatic 版本的 beStatic()。原因是当调用静态方法时,C++不关心对象实际上是什么,只关心编译时的类型。在此情况下,该类型为指向 BaseStatic 对象的引用。前面示例的输出如下:
DerivedStatic keepin's it static
BaseStatic being static
注意:
静态方法属于定义它的类,而不属于特定的对象。当类中的方法调用静态方法时,所调用的版本是通过正常的名称解析来决定的。当使用对象调用时,对象实际上并不涉及调用,只是用来判断编译时的类型。
\2. 重载基类方法
当指定名称和一组参数以重写某个方法时,编译器隐式地隐藏基类中同名方法的所有其他实例。想法为如果重写了给定名称的某个方法,可能是想重写所有的同名方法,只是忘记这么做了,因此应该作为错误处理。这是有意义的,可以这么考虑一 为什么要修改方法的某些版本而不修改其他版本呢? 考虑下面的 Derived类,它重写了一个方法,而没有重写相关的同级重载方法:
class Base
{
public:
virtual ~Base() = default;
virtual void overload(){cout<<"Base's overload()"<<endl;}
virtual void overload(int i){cout<<"Base'overload(int i)"<<endl;}
};
class Derived : public Base
{
public:
virtual void overload() override
{
cout<<"Derived's overload()"<<endl;
}
};
如果试图用 Derived 对象调用以 int 值作为参数的 overload()版本,代码将无法编译,因为没有显式地重写这个方法。
Derived myDerived;
myDerived.overload(2); //Error no mathing method for overload(int)
然而,使用 Derived 对象访问该版本的方法是可行的。只需要使用指向 Base 对象的指针或引用,
Derived myDerived;
Base& ref = myDerived;
ref.overload(7);
在 C++中,隐藏未实现的重载方法只是表象。显式声明为子类型实例的对象无法使用这些方法,但可将其转换为基类类型,以使用这些方法。
如果只想改变一个方法,可以使用 using 关键字避免重载该方法的所有版本。在下面的代码中,Derived 类定义中使用了从 Base 类继承的一个 overload()版本,并显式地重写了另一个版本:
class Base
{
public:
virtual ~Base() = default;
virtual void overlaod(){cout<<"Base's overload()"<<endl;}
virtual void overload(int i){cout<<"Base's overload(int i)"<<endl;}
};
class Derived : public Base
{
public:
virtual void overload() override
{
cout<<"Derived's overload()"<<endl;
};
}
如果试图用 Derived 对象调用以 int 值作为参数的 overload()版本,代码将无法编译,因为没有显式地重写这个方法。
Derived myDerived;
myDerived.overload(2); //Error! No mathing method for overloaded(int)
然而,使用 Derived 对象访问该版本的方法是可行的。只需要使用指向 Base 对象的指针或引用,
Derived myDerived;
Base& ref = myDerived;
ref.overload(7);
在 C++中,隐藏未实现的重载方法只是表象。显式声明为子类型实例的对象无法使用这些方法,但可将其转换为基类类型,以使用这些方法。
如果只想改变一个方法,可以使用 using 关键字避免重载该方法的所有版本。在下面的代码中,Derived 类定义中使用了从 Base 类继承的一个 overload()版本,并显式地重写了另一个版本:
class Base
{
public:
virtual ~Base() = default;
virtual void overload(){cout<<"Base's overload()"<<endl;}
virtual void overload(int i) {cout<<"Base's overload(int i)"<<endl; }
};
class Derived : public Base
{
public:
using Base::overload;
virtual void overload() override
{
cout<<"Derived's overload()"<<endl;
}
};
using 子句存在一定风险。假定在 Base 类中添加了第三个 overload()方法,本来应该在 Derived 类中重写这个方法。但是由于使用了 using 子句, 在派生类中没有重写这个方法不会被当作错误, Derived 类显式地说明 “我将接收父类其他所有的重载方法。”
警告:
为了避免歧义 bug,应该重写重载方法的所有版本,可以显式重写,也可以使用 using 关键字,但要留意使用 using 关键字的风险。
\3. private 或 protected 基类方法
重写 private 或 protected 方法当然没有问题。记住方法的访问说明符会判断谁可以调用这些方法。派生类无法调用父类的 private 方法,并不意味着无法重写这个方法。实际上,在 C++中,重写 private 或 protected 方法是一种常见模式。这种模式允许派生类定义自己的“独特性” 在基类中会引用这种独特性。注意 Java 和 C#仅允许重写 public 和 protected 方法,不能重写 private 方法。例如,下面的类是汽车模拟程序的一部分,根据汽油消耗量和剩余的燃料计算汽车可以行驶的里程。
class MileEstimator
{
public:
virtual ~MilesEstimator() = default;
virtual int getMilesLeft() const;
virtual void setGallonsLeft(int gallons);
virtual int getGallonsLeft() const;
private:
int mGallonsLeft;
virtual int getMilesPerGallon() const;
};
这些方法的实现如下所示;
int MilesEstimator::getMilesLeft() const
{
return getMilesPerGallon() * getGallonsLeft();
}
void MilesEstimator::setGallonsLeft(int gallons)
{
mGallonsLeft = gallons;
}
int MilesEstimator::getGallonsLeft() const
{
return mGallonsLeft;
}
int MilesEstimator::getMilesPerGallon() const
{
return 20;
}
getMilesLeft()方法根据两个方法的返回结果执行计算。下面的代码使用 MilesEstimator 计算两加仑汽油可以行驶的里程
MilesEstimator myMilesEstimator;
myMilesEstimator.setGallonsLeft(2);
cout<<"Normal estimator can go "<<myMilesEstimator.getMilesLeft()<<" more miles"<<endl;
代码的输出如下;
Normal estimator can go 40 more miles
为让这个模拟程序更有趣,可引入不同类型的车辆,或许是效率更高的汽车。现有的 MilesEstimator 假定所有的汽车燃烧一加仓的汽油可以跑 20 公里, 这个值是从一个单独的方法返回的, 因此派生类可以重写这个方法。下面就是这样一个派生类;
class EfficientCarMilesEstimator : public MilesEstimator
{
private:
virtual int getMilesPerGallon() const override;
};
实现代码如下:
int EfficientCarMilesEstimator::getMilesPerGallon() const
{
return 35;
}
通过重写这个 private 方法,新类完全修改了没有更改的现有 public 方法的行为。基类中的 getMilesLeft()方法将自动调用 private getMilesPerGallcn()方法的重写版本。下面是一个使用新类的示例:
EfficientCarMilesEstimator myEstimator;
myEstimator.setGallonsLeft(2);
cout<<"Efficient estimator can go "<<myEstimator.getMilesLeft()<<" more miles."<<endl;
此时的输出表明了重写的功能:
Efficient estimator can go 70 more miles
注意:
重写 private 或 protected 方法可在不做重大改动的情况下改变类的某些特性。
\4. 基类方法具有默认参数
派生类与基类可具有不同的默认参数,但使用的参数取决于声明的变量类型,而不是底层的对象。下面是一个简单的派生类示例,派生类在重写的方法中提供了不同的默认参数:
class Base
{
public:
virtual ~Base() = default;
virtual void go(int i = 2)
{
cout<<"Base's go with i="<<i<<endl;
}
};
class Derived:public Base
{
public:
virtual void go(int i = 7) override
{
cout<<"Derived's go with i="<<i<<endl;
}
};
派生类与基类可具有不同的默认参数,但使用的参数取决于声明的变量类型,而不是底层的对象。下面是一个简单的派生类示例,派生类在重写的方法中提供了不同的默认参数:
class Base
{
public:
virtual ~Base() = default;
virtual void go(int i = 2)
{
cout<<"Base's go with i="<<i<<endl;
}
};
class Derived : public Base
{
public:
virtual void go(int i = 7) override
{
cout<<"Derived's go with i="<<i<<endl;
}
};
如果调用 Derived 对象的 go(),将执行 Derived 版本的 go(),默认参数为 7。如果调用 Base 对象的 go(),将执行 Base 版本的 go(),默认参数为 2。然而(有些怪异),如果使用实际指向 Derived 对象的 Base 指针或 Base引用调用 go(),将调用 Derived 版本的 go(),但使用 Base 版本的默认参数 2。下面的示例显示了这种行为;
Base myBase;
Derived myDerived;
Base& myBaseReferenceToDerived = myDerived;
myBase.go();
myDerived.go();
myBaseReferenceToDerived.go();
代码的输出如下所示:
Base's go with i = 2
Derived's go with i = 7
Derived's go with i = 2
产生这种行为的原因是 C++根据表达式的编译时类型(而非运行时类型)绑定默认参数。在 C++中,默认参数不会被“继承” 如果上面的 Derived 类没有像父类那样提供默认参数,就用新的非 0 参数版本重载 go()方法。
注意:
当重写具有默认参数的方法时,也应该提供默认参数,这个参数的值应该与基类版本相同。建议使用符号常量作为默认值,这样可在派生类中使用同一个符号常量。
\5. 派生类方法具有不同的访问级别
可以采用两种方法来修改方法的访问级别一- 可以加强限制,也可放宽限制。在 C++中,这两种方法的意义都不大,但是这么做也是有合理原因的。为加强某个方法(或数据成员)的限制,有两种方法。一种方法是修改整个基类的访问说明符,本章后面将讲述这种方法。另一种方法是在派生类中重新定义访问限制,下面的 Shy 类演示了这种方法:
class Gregarious
{
public:
virtual void talk()
{
cout<<"Gregarious says hi!"<<endl;
}
};
class Shy : public Gregarious
{
protected:
virtual void talk() override
{
cout<<"Shy reluctantly says hello"<<endl;
}
};
Shy 类中 protected 版本的 talk()方法适当地重写 Gregarious::talk(方法。任何客户代码试图使用 Shy 对象调用talk()都会导致编译错误。
Shy myShy;
myShy.talk(); //Error Attempt to access protected method .
然而,这个方法并不是完全受保护的。可使用 Gregarious 引用或指针访问 protected 方法。
Shy myShy;
Gregarious& ref = myShy;
ref.talk();
上面代码的输出如下:
Shy reluctantly says hello
这说明在派生类中将方法设置为 protected 实际上是重写了这个方法(因为可正确地调用这个方法的派生类版本),此外还证明如果基类将方法设置为 public,就无法完整地强制访问 protected 方法。
注意;
无法(也没有很好的理由)限制访问基类的 public 方法。
注意:
上例重新定义了派生类中的方法,因为它希望显示另一条消息。如果不希望修改实现,只想改变方法的访问级别,首选方法是在具有所需访问级别的派生类定义中添加 using 语句。
在派生类中放宽访问限制就比较容易(也更有意义)。最简单的方法是提供一个 public 方法来调用基类的protected 方法,如下所示:
class Secret
{
protected:
virtual void dontTell() {cout<<"I'll never tell"<<endl;}
};
class Blabber : public Secret
{
public:
virtual void tell() { dontTell();}
};
调用 Blabber 对象的 public 方法 tell()的客户代码可有效地访问 Secret 类的 protected 方法。当然,这并未真正改变 dontTell()的访问级别,只是提供了访问这个方法的公共方式。也可在 Blabber 派生类中显式地重写 dontTell(),并将这个方法设置为 public。这样做比降低访问级别更有意义,因为当使用基类指针或引用时,可以清楚地表明发生的事情。例如,假定 Blabber 类实际上将 dontTell()方法设置为 public:
class Blabber : public Secret
{
public:
virtual void dontTell() override{cout<<"I'll tell all!"<<endl;}
};
调用 Blabber 对象的 dontTell()方法:
myBlabber.dontTell(); // outputs "IT'11 tell alllm
如果不想更改重写方法的实现,只想更改访问级别,可使用 using 子多,例如,
class Blabber : public Secret
{
public:
using Secret::dontTell;
};
这也允许调用 Blabber 对象的 dontTell()方法,但这一次,输出将会是“l’ll never tell.”:
myBlabber.dontTell(); //outputs "l'll never tell"
然而,在上述情况下,基类中的 protected 方法仍然是受保护的,因为使用 Secret 指针或引用调用 Secret 类的 dontTell()方法将无法编译。
Blabber myBlabber;
Secret& ref = myBlabber;
Secret& ptr = &myBlabber;
ref.dontTell(); //Error Attempt to access protected method
ptr->dontTell();//Error Attempt to access protected method
注意:
修改方法访问级别的唯一真正有用的方式是对 protected 方法提供较宽松的访问限制。
派生类中的复制构造函数和赋值运算符
第 9 章讲过,在类中使用动态内存分配时,提供复制构造函数和赋值运算符是良好的编程习惯。当定义派生类时,必须注意复制构造函数和 operator=。
如果派生类没有任何需要使用非默认复制构造函数或 operator=的特殊数据(通常是指针),无论基类是否有这类数据,都不需要它们。如果派生类省略了复制构造函数或 operator=,派生类中指定的数据成员就使用默认的复制构造函数或 operator=,基类中的数据成员使用基类的复制构造函数或 operator=。另外,如果在派生类中指定了复制构造函数,就需要显式地链接到父类的复制构造函数,下面的代码演示了这一内容。如果不这么做,将使用默认构造函数(不是复制构造函数! )初始化对象的父类部分。
class Base
{
public:
virtual ~Base() = default;
Base() = default;
Base(const Base& src);
};
Base::Base(const Base& src)
{
}
class Derived:public Base
{
public:
Derived() = default;
Derived(const Derived& src);
};
Derived::Derived(const Derived& src) : Base(src)
{
}
与此类似,如果派生类重写了 operator=,则几乎总是需要调用父类版本的 operator=。唯一的例外是因为某些奇怪的原因, 在赋值时只想给对象的一部分赋值。下面的代码显示了如何在派生类中调用父类的赋值运算符。
Derived& Derived::operator=(const Derived& rhs)
{
if(&rhs == this)
{
return *this;
}
Base::operator=(rhs); //Calls parent's operator=
return *this;
}
敬告;
如果派生类不指定自己的复制构造函数或 operator=,基类的功能将继续运行。 否则,就需要显式引用基类版本。
注意:
如果在继承层次结构中需要复制功能,专业 C++开发人员惯用的做法是实现多态 clone()方法,因为不能完全依靠标准复制构造函数和复制赋值运算符来满足需要。第 12 章将讨论多态 clone()方法。
”运行时类型工具
相对于其他面向对象语言,C++以编译时为主。如前所述,重写方法是可行的,这是由于方法和实现之间的间隔,而不是由于对象有关于自身所属类的内建信息。然而在 C++中,有些特性提供了对象的运行时视角。这些特性通常归属于一个名为运行时类型信息(RunTime Type Information,RTTI)的特性集。RTTI 提供了许多有用的特性,用于判断对象所属的类。其中一一特性是本章前面说过的 dynamic_cast(),可在 OO 层次结构中进行安全的类型转换。本章前面讨论过这一点。如果使用类上的 dynamic_ cast(),但没有虚表,即没有虚方法,将导致编译错误。
RITI 的第二个特性是 typeid 运算符,这个运算符可在运行时查询对象,从而判别对象的类型。大多数情况下,不应该使用 typeid,因为最好用虚方法处理基于对象类型运行的代码。下面的代码使用了 typeid,根据对象的类型输出消息:
#include <typeinfo>
class Animal{public: virtual ~Animal() = default; };
class Dog : public Animal{};
class Bird : public Animal{};
void spreak(const Animal &animal)
{
if(typeid(animal) == typeid(Dog))
{
cout<<"Woof!"<<endl;
}
else if(typeid(animal) == typeid(Bird))
{
cout<<"Chirp!"<<endl;
}
}
一旦看到这样的代码,就应该立即考虑用虚方法重新实现该功能。在此情况下,更好的实现是在 Animal类中声明一个 speak()虚方法。Dog 类会重写这个方法,输出"Woof! ";,Bird 类也会重写这个方法,输出"Chimp! "。这种方式更适合面向对象程序,会将与对象有关的功能给予这些对象。
警告;
类至少有一个虚方法,typeid 运算符才能正常运行。如果在没有虚方法的类上使用 dynamic_cast(),会导致编译错误。typeid 运算符也会从实参中去除引用和 const 限定符。
typeid 运算符的主要价值之一在于日志记录和调试。下面的代码将 typeid 用于日志记录。logObject()函数将“可记录”对象作为参数。设计是这样的: 任何可记录的对象都从 Loggable 类中继承,都支持 getLogMessage()方法。
class Loggable
{
public:
virtual ~Loggable() = default;
virtual std::string getLogMessage() const = 0;
};
class Foo : public Loggable
{
public:
std::string getLogMessage() const override;
};
std::string Foo::getLogMessage() const
{
return "Hello logger";
}
void logObject(const Loggable& loggableObject)
{
cout<<typeid(loggableObject).name()<<":";
cout<<loggableObject.getLogMessage()<<endl;
}
logObject()函数首先将对象所属类的名称写到输出流,随后是日志信息。这样以后阅读日志时,就可以看出文件每一行涉及的对象。用 Foo 实例调用 logObject()函数时, Microsoft Visual C++ 2017 生成的输出如下所示:
class Foo: Hello logger.
可以看到,typeid 运算符返回的名称是 class Foo。但这个名称因编译器而异。例如,若用 GCC 编译相同的代码,输出将如下所示;
JFoo:Hello logger
注意:
如果不是为了日志记录或调试而使用 typeid,应该考虑用虚方法替代 typeid。
非 public 继承
在前面的所有示例中,总是用 public 关键字列出父类。父类是否可以是 private 或 protected? 实际上可以这样做,尽管二者并不像 public 那样普遍。如果没有为父类指定任何访问说明符,就说明是类的 private 继承、结构的 public 继承。
将父类的关系声明为 protected,意味着在派生类中,基类所有的 public 方法和数据成员都成为受保护的。与此类似,指定 private 继承意味着基类所有的 public、protected 方法和数据成员在派生类中都成为私有的。使用这种方法统一降低父类的访问级别有许多原因,但多数原因都是层次结构的设计缺陷。有些程序员滥用这一语言特性, 经常与多重继承一起实现类的“组件”. 不是让 Airplane 类包含引擎数据成员和机身数据成员,而将 Airplane 类作为 protected 引擎和 protected 机身。这样,对于客户代码来说,Airplane 对象看上去并不像引擎或机身(因为一切都是受保护的),但在内部可以使用引擎和机身的功能。
注意:
非 public 继承很少见,建议慎用这一特性,因为多数程序员并不熟悉它。
虚基类
本章前面学习了歧义父类,当多个基类拥有公共父类时,就会发生这种情况,如图 10-12 所示。我们建议的解决方案是让共享的父类本身没有任何自有功能。这样就永远无法调用这个类的方法,因此也就不存在歧义。如果希望被共享的父类拥有自己的功能,C++提供了另一种机制来解决这个问题。如果被共享的基类是一个虚基类(virtual base class),就不在歧义。如果希望被共享的父类拥有自己的功能,C++提供了另一种机制来解决这个问题。如果被共享的基类是一个虚基类(virtual base class),就不存在歧义。以下代码在 Animal 基类中添加了 sleep()方法,并修改了 Dog 和 Bird 类,从而将 Animal 作为虚基类继承。如果不使用 virtual 关键字,用 DogBird 对象调用 sleep()会产生歧义,从而导致编译错误。因为 DogBird对象有 Animal 类的两个子对象,一个来自 Dog 类,另一个来自 Bird 类。然而,如果 Animal 被作为虚基类,DogBird 对象就只有 Animal 类的一个子对象,因此调用 sleep()也就不存在歧义。
class Animal
{
public:
virtual void eat() = 0;
virtual void sleep(){cout<<"zzzzz...."<<endl;}
};
class Dog:public virtual Animal
{
public:
virtual void bark(){cout<<"Woof!"<<endl;}
virtual void eat() override{cout<<"The dog ate"<<endl; }
};
class Bird:public virtual Animal
{
public:
virtual void chirp(){cout<<"Chirp!"<<endl;}
virtual void eat() override{cout<<"The bird ate"<<endl;}
};
class DogBird : public Dog,public Bird
{
public:
virtual void eat() override{Dog::eat();}
};
int main()
{
DogBird myConfuseAnimal;
myConfusedAnimal.sleep();
return 0;
}
注意;
庶基类是在类层次结构中避免歧义的好办法.唯一的缺点是许多 C++程序员不熟悉这个概念。
理解灵活而奇特的 C++
引用
专业的 C++代码(包括本书中的许多代码)会大量使用引用。现在有必要回过头来考虑一下究竟什么是引用,引用的工作原理是什么。在 C++中,引用是另一个变量的别名。对引用的所有修改都会改变被引用的变量的值。可将引用当作隐式指针,这个指针没有取变量地址和解除引用的麻烦。也可将引用当作原始变量的另一个名称。可创建单独的引用变量,在类中使用引用数据成员,将引用作为函数和方法的参数,也可让函数或方法返回引用。
引用变量
引用变量在创建时必须初始化,如下所示:
int x = 3;
int& xRef = x;
在赋值后,xRef 就是 x 的另一个名称。使用 xRef就是使用 x 的当前值。对 xRef 赋值会改变 x 的值。例如,下面的代码通过 xRef将x 的值设置为 10:
xRef = 107
不能在类的外部声明一个引用而不初始化它:
int& emptyRef; // DOES NOT COMPIIE!
警告;
创建引用时必须总是初始化它。 通常会在声明引用时对其进行初始化,但是对于包含类而言,需要在构造函数初始化器中初始化引用数据成员。不能创建对未命名值(例如一个整数字面量)的引用, 除非这个引用是一个 const 值。 在下例中, unnamedRefl将无法编译,因为这是一个针对常量的非 const 引用。这条语句意味着可改变常量 $ 的值,而这样做没有意义。由于 unnamedRef2 是一个 const 引用,因此可以运行,不能编写“unnamedRef2 = 7”。
int & unnamedRef1 = 5; //DOES NOT COMPILE
const int& unnamedRef2 = 5; //Works as expected
临时对象同样如此。不能具有临时对象的非 const 引用,但可具有 const 引用。例如,假设以下函数返回一个 std::string 对象:
std::string getString() { return "Hello world"; }
对于调用 getString()的结果,可以有一个 const 引用; 在该 const 引用超出作用域之前,将使 std::string 对象一直处于活动状态
std::string& string1 = getString(); //DOES NOT COMPILE
const std::string& string2 = getString(); //Works as expected
\1. 修改引用
引用总是引用初始化的那个变量,引用一旦创建,就无法修改。这一规则导致许多让人迷惑的语法。如果在声明引用时用一个变量“赋值” 那么这个引用就指向这个变量。然而,如果在此后使用变量对引用赋值,被引用变量的值就变为被赋值变量的值。引用不会更新为指向这个变量。下面是示例代码;
int x = 3,y = 4;
int& xRef = x;
xRef = y;//Changes value of x to 4 Doesn't make xRef refer to y
你可能试图在赋值时取 y 的地址,以绕过这一限制:
xRef = &y; // DOES NOT COMPILE!
上面的代码无法编译。y 的地址是一个指针,但 xRef 被声明为一个指向 int 值的引用,而不是指向指针的引用。一些程序员更进一步,尝试回避引用的语义。如果将一个引用赋值给另一个引用,会发生什么? 这么做会让第一个引用指向第二个引用所指的变量吗? 编写下面的代码:
int x = 3,z = 5;
int& xRef = x;
int& zRef = z;
zRef = xRef; //Rssigns values,not references
最后一行代码没有改变 zRef,只是将z的值设置为3,因为 xRef 指向 x,x 的值是 3。
警告;
在初始化引用之后无法改变引用所指的变量,而只能改变该变量的值。
- 指向指针的引用和指向引用的指针
可创建任何类型的引用,包括指针类型。下面列举一个指向 int 值的指针的引用:
int* intP;
int*& ptrRef = inP;
ptrRef = new int;
*ptrRef = 5;
这一语法有一点奇怪: 你可能不习惯看到*和&彼此相邻。然而,语义实际上很简单: ptrRef 是一个指向 intP的引用,intP 是一个指向 int 值的指针。修改 pttRef 会更改 intP。指针的引用很少见,但在某些场合下很有用,本章的 11.1.3 节将讨论这一内容。
注意,对引用取地址的结果与对被引用变量取地址的结果相同。例如:
int x = 3;
int& xRef = x;
int* xPtr = &xRef; // Rddress of a reference is pointer to value
*xPtr = 100;
上述代码通过取 x 引用的地址,使 Ptr 指向 x。将*xPtr 赋值为 100,x 的值也变为 100。比较表达式“xPtrxRef”将无法编译,因为类型不匹配; xPtr 是一个指向 int 值的指针,而 xRef 是一个指向 int 值的引用。比较表达式“xPtr == &xRef”和“xPtr &x”都可以正确编译,结果都是 true。最后要注意,无法声明引用的引用或者指向引用的指针。例如,不允许使用 int& &或 int&* 。
引用数据成员
第 9 章讲过,类的数据成员可以是引用。如果不指向其他变量,引用就无法存在。因此,必须在构造函数初始化器(constructor initialize)中初始化引用数据成员,而不是在构造函数体内。下面列举一个简单示例,
class MyClass
{
public:
MyClass(int &ref):mRef(ref){}
private:
int& mRef;
};
引用参数
C++程序员通常不会单独使用引用变量或引用数据成员。引用经常用作函数或方法的参数。默认的参数传递机制是按值传递, 函数接收参数的副本。修改这些副本时,原始的参数保持不变。引用允许指定另一种向函数传递参数的语义: 按引用传递。当使用引用参数时,函数将引用作为参数。如果引用被修改,最初的参数变量也会被修改。例如,下面给出了一个简单的交换函数,交换两个 int 变量的值;
void swap(int &first,int &second)
{
int temp = first;
first = second;
second = temp;
}
可采用下面的方式调用这个函数:
int x = 5,y = 6;
swap(x,y);
当使用 x 和 y 作为参数调用函数 swap0时,first 参数被初始化为 x 的引用,second 参数被初始化为y 的引用。当使用 swap(0修改 first 和 second 时,x 和y 实际上也被修改。就像无法用常量初始化普通引用变量一样,不能将常量作为参数传递给“按非 const 引用传递”的函数:
swap(3,4); // DOES NOT COMPILE
使用“按 const 引用传递”(将在本章后面讨论)或“按右值引用传递”(见第 9 章的讨论),可将常量作为参数传递给函数。
-
将指针转换为引用
某个函数或方法需要以一个引用作为参数,而你拥有一个指向被传递值的指针,这是一种常见的困境。在此情况下,可对指针解除引用(dereferencing),将指针“转换”为引用。这一行为会给出指针所指的值,随后编译器用这个值初始化引用参数。例如,可以这样调用 swap():
int x = 5,y = 6;
int *xp = &x,*yp = &y;
swap(*xp,*yp);
- 按引用传递与按值传递
如果要修改参数,并修改传递给函数或方法的变量,就需要使用按引用传递。然而,按引用传递的用途并不局限于此。按引用传递不需要将参数的副本复制到函数,在有些情况下这会带来两方面的好处。(D 效率: 复制较大的对象或结构需要较长时间。按引用传递只是把指向对象或结构的指针传递给函数。(2) 正确性: 并非所有对象都允许按值传递, 即使允许按值传递的对象, 也可能不支持正确的深度复制(deepcopying)。第 9 章提到,为支持深度复制,动态分配内存的对象必须提供自定义复制构造函数或复制赋值运算符。如果要利用这些好处,但不想修改原始对象,可将参数标记为 const,从而实现按常量引用传递参数。本章后面将详细讨论这一主题。按引用传递的这些优点意味着,只有在参数是简单的内建类型(例如 int 或 double),且不需要修改参数的情况下,才应该使用按值传递。在其他所有情况下都应该使用按引用传递。
将引用作为返回值
还可让函数或方法返回一个引用。这样做的主要原因是为了提高效率。返回对象的引用而不是返回整个对象可避免不必要的复制。当然,只有涉及的对象在函数终止之后仍然存在的情况下才能使用这一技巧。
警告:如果变量的作用域局限于函数或方法(例如堆栈中自动分配的变量,在函数结束时会被销毁),绝不能返回这个变量的引用。如果从函数返回的类型支持移动语义(见第 9 章),按值返回就几乎与返回引用一样高效。返回引用的另一个原因是希望将返回值直接赋为左值(value)(赋值语句的左边)。一些重载的运算符通常会返回引用,第 9 章列举了一些示例,在第 15 章可以看到这一技术的更多应用。
右值引用
右值(rvalue)就是非左值dvaluej),例如常量值、临时对象或值。通常而言,右值位于赋值运算符的右侧。第9 章讨论了右值引用,这里做一下简单回顾;
void handleMessage(std::string& message)
{
cout<<"handleMessage with lvalue reference "<<message<<endl;
}
对于这个 handleMessage()版本,不能采用如下方式调用它:
handleMessage("Hello world");// A Literal is not an lvalue.
std::string a = "Hello ";
std::string b = "World";
helloMessage(a+b);// R temporary is not an lvalue.
使用引用还是指针
在 C++中,可认为引用是多余的: 几乎所有使用引用可以完成的任务都可以用指针来完成。例如,可这样编写前面的 swap()函数:
void swap(int *first,int *second)
{
int temp = *first;
*first = *second;
*second = temp;
}
然而,这些代码不如使用引用的版本那么清晰: 引用可使程序整洁并易于理解。此外,引用比指针安全不可能存在无效引用,也不需要显式地解除引用,因此不会遇到像指针那样的解除引用问题。有人说引用更安全,但这个论点只有在不涉及指针的情况下才成立。例如,下面的函数将指向 int 值的引用作为参数:
void refcall(int& t) { ++t; }
可声明一个指针并将其初始化为指向内存的某个随机位置。然后可对这个指针解除引用,并将其作为引用参数传递给 refcall(),下面的代码可以编译,但是试图执行时会崩溃,
int *ptr = (int *)8;
refcall(*ptr);
大多数情况下,应该使用引用而不是指针。对象的引用甚至可像指向对象的指针那样支持多态性。但也有一些情况要求使用指针,一个例子是更改指向的位置,因为无法改变引用所指的变量。例如,动态分配内存时,应该将结果存储在指针而不是引用中。需要使用指针的另一种情况是可选参数,即指针参数可以定义为带默认值 nullptr 的可选参数,而引用参数不能这样定义。还有一种情况是要在容器中存储多态类型。
有一种方法可以判断使用指针还是引用作为参数和返回类型, 考虑谁拥有内存。如果接收变量的代码负责释放相关对象的内存,那么必须使用指向对象的指针,最好是智能指针,这是传递拥有权的推荐方式。如果接收变量的代码不需要释放内存,那么应该使用引用。
注意:
要优先使用引用。 也就是说,只有在无法使用引用的情况下,才使用指针。考虑将一个 int 数组分割为两个数组的函数: 一个是偶数数组,另一个是奇数数组。这个函数并不知道源数组中有多少奇数和偶数,因此只有在检测完源数组后,才能为目标数组动态分配内存,此外还需要返回这两个新数组的大小。因此总共需要返回 4 项: 指向两个新数组的指针和两个新数组的大小。显然必须使用按引用传递,用规范的 C 语言方式编写的这个函数如下所示:
void separateOddsAndEvens(const int arr[],size_t size,int** odds,size_t *numOdds,int **events,size_t numEvent)
{
//Count the number of odds and events
*numOdds = *numEvents = 0;
for(size_t i = 0;i < size; ++i)
{
if(arr[i] % 2 == 1)
{
++(*numOdds);
}
else
{
++(*numEvents);
}
}
//Allocate two new arrays of the appropriate size
*odds = new int[*numOdds];
*events = new int[*numEvents];
size_t oddsPos = 0,evensPos = 0;
for(size_t i = 0;i<size;++i)
{
if(arr[i] % 2 == 1)
{
(*odds)[oddsPos++] = array[i];
}
else
{
(*events)[eventsPos++] = arr[i];
}
}
}
函数的后 4 个参数是“引用”参数。为修改它们指向的值,separateOddsAndEvens()必须解除引用,这使函数体内的语法有点难看。此外,如果要调用 separateOddsAndEvens(),就必须传递两个指针的地址,这样函数才能修改实际的指针,还必须传递两个 int 值的地址,这样函数才能修改实际的 int 值。另外注意,主调方负责删除由 separateOddsAndEvens()创建的两个数组!
int unSplit[] = {1,2,3,4,5,6,7,8,9,10};
int* oddNums = nullptr;
int* eventNums = nullptr;
size_t numOdds = 0,numEvents = 0;
separateOddsAndEvens(unSplit,std::size(unSplit),&oddNums,&numOdds,&evenNums,&numEvens);
//Use the arrays...
delete[] oddNums;oddNums = nullptr;
delete[] eventNums;eventNums = nullptr;
如果觉得这种语法很难理解(应该是这样的),可以用引用实现真正的按引用传递,如下所示:
void separateOddsAndEvents(const int arr[],size_t size,int*&odds,size_t& numOdds,int*&events,size_t &numEvents)
{
numOdds = numEvents = 0;
for(size_t i = 0;i<size;++i)
{
if(arr[i] % 2 == 1)
++numOdds;
else
++numEvents;
}
odds = new int[numOdds];
events = new int[numEvents];
size_t oddsPos = 0,eventsPos = 0;
for(size_t i = 0;i < size;++i)
{
if(arr[i] % 2 == 1)
{
odds[oddsPos++] = arr[i];
}
else
{
events[eventsPos++] = arr[i];
}
}
}
在此情况下,adds 和 evens 参数是指向 int*的引用。separateOddsAndEvensO可以修改用作函数参数的 intx(通过引用),而不需要显式地解除引用。这一逻辑同样适用于 numOdds 和 numEvens,这两个参数是指向 int 值的引用。使用这个版本的函数时,不再需要传递指针或 int 值的地址,引用参数会自动进行处理:
separateOddsAndEvens(unSplit,std::size(unSplit),oddNums,numOdds,eventNums,numEvens)
虽然与使用指针相比,使用引用参数总是更整洁,但建议尽可能避免动态分配数组。例如,使用标准库的vector 容器重写前面的 separateOddsAndEvens()函数,可以使其更安全、更紧凑、更易读,因为所有的内存分配和释放内存都是自动完成的。
void separateOddsAndEvents(const vector<int>& arr,vector<int>& odds,vector<int>& events)
{
for(int i : arr)
if(i % 2 == 1)
odds.push_back(i)
else
events.push_back(i);
}
可这样使用这个版本的函数:
vector<int> vecUnSplit = {1,2,3,4,5,6,7,8,9,10};
vector<int> odds,evens;
separateOddsAndEvents(vecUnSplit,odds,evens);
注意,不需要释放 odds 和 evens 容器; 该任务由 vector 类负责完成。与使用指针或引用的版本相比,这个版本更容易使用。第 17 章将详细讨论标准库的 vector 容器。二 虽然使用 vector 容器的版本比使用指针或引用的版本好得多,但通常要尽量避免使用输出参数。如果函数需要返回一些内容,则直接返回,而不要使用输出参数。自从 C++11 引入移动语义以来,从函数返回值变得十分高效, 而 C++17 引入了结构化绑定(见第 1 章),从函数返回多个值是十分简便的。因此,对于 separateOddsAndEvens()函数而言,不是接收两个输出矢量(vecton,而是返回矢量 pair。中定义的 std::pair 实用工具类将在第 17 章中讨论,其用法相当简单。基本上,矢量 pair 可存储两个相同类型的值,也可存储两个不同类型的值。它是一个类模板,需要使用放在尖括号之间的类型来指定值的类型。可使用std::make_pair(0)创建矢量 pair。下面的 separateOddsAndEvens()函数返回矢量 pair:
pair<vector<int>,vector<int>> separateOddsAndEvents(const vector<int> &arr)
{
vector<int>odds,evens;
for(int i : arr)
{
if(i % 2 == 1)
odds.push_back(i)
else
evens.push_back(i);
}
return make_pair(odds,evens);
}
通过使用结构化绑定,调用 separateOddsAndEvens())的代码变得更紧凑,更容易读取和理解;
vector<int> vecUnSplit = {1,2,3,4,5,6,7,8,9,10};
auto[odds,evens] = separateOddsAndEvens(vecUnSplit);
关键字的疑问
C++中的两个关键字 const 和 static 非常让人困惑。这两个关键字有多个不同的含义,每种用法都很微妙,理解这一点十分重要。
const 关键字
const 是 constant 的缩写,指保持不变的量。编译器会执行这一要求,任何尝试改变常量的行为都会被当作错误处理。此外,当启用优化时,编译器可利用此信息生成更好的代码。关键字 const 有两种相关的用法。可以用这个关键字标记变量或参数,也可以用其标记方法。本节将明确讨论这两种含义。
- const 变量和参数
可使用 const来“保护”变量不被修改。这个关键字的一个重要用法是替换#define 来定义常量,这是 const最直接的应用。例如,可以这样声明常量 PI:
const double PI = 3.141592653589793238462;
可将任何变量标记为 const,包括全局变量和类数据成员。”′ ,还可使用 const 指定函数或方法的参数保持不变。例如,下面的函数接收一个 const 参数。在函数体内,不能修改整数 param。如果试图修改这个变量,编译器将生成错误。
void func(const int param)
{
//Not allowed to change param...
}
下面详细讨论两种特殊的 const 变量或参数: const 指针和 const 引用。
int* ip;
ip = new int[10];
ip[4] = 5;
假定要将 const 应用到 让。暂时不考虑这么做有没有作用,只考虑这样做意味着什么。是想阻止修改ip 变量本身,还是阻止修改 ip 所指的值? 也就是说,是阻止上例的第 2 行还是第3 行?为阻止修改所指的值(第 3 行),可在 ip 的声明中这样添加关键字 const;
const int* ip;
ip = new int[10];
ip[4] = 5;//DOES NOT COMPILE!
现在无法改变 ip 所指的值。下面是在语义上等价的另一种方法;
int const* ip;
ip = new int[10];
ip[4] = 5;//DOES NOT COMPILE!
将 const 放在 int 的前面还是后面并不影响其功能。
如果要将 ip 本身标记为 const(而不是 ip 所指的值),可以这样做:
int* const ip = nullptr;
ip = new int[10]; //DOES NOT COMPILE
ip[4] = 5; //ERROR.dereferencing a null pointer
现在 ip 本身无法修改,编译器要求在声明 ip 时就执行初始化,可以使用前面代码中的 nullptr,也可以使用新分配的内存,如下所示:
int* const ip = new int[10];
int[4] = 5;
还可将指针和所指的值全部标记为 const,如下所示:
const int* const ip = nullptr;
尽管这些语法看上去有点混乱,但规则实际上非常简单: 将 const 关键字应用于直接位于它左边的任何内容。再次考虑这一行;
int const * const ip = nullptr;
从左到右,第一个 const 直接位于 int 的右边,因此将 const 应用到 ip 所指的 int,从而指定无法修改 ip 所指的值。第二个 const 直接位于*的右边,因此将 const 应用于指向 int 变量的指针,也就是让变量。因此,无法修改 ip(指针)本身。
这一规则由于一个例外而变得令人费解: 第一个 const 可出现在变量的前面,如下所示。
const int* const ip = nullptr;
这种“异常的”语法相比其他语法更常见。可将这个规则应用到任意层次的间接取值,例如:
const int * const * const * const ip = nullptr
注意:
还有一种易于记忆的、用于指出复杂变量声明的规则: 从右向左读。考虑示例“int* const ip”。从右向左读这条语揣,就可以知道“ip 是一个指向 int 值的 const 指针”。 另外,“int const* ip”读作“ip 是一个指向 const int 的指针。?
const 引用
将 const 应用于引用通常比应用于指针更简单,原因有两个。首先,引用默认为 const,无法改变引用所指的对象。因此,不必显式地将引用标记为 const。其次,无法创建指向引用的引用,所以引用通常只有一层间接取值。获取多层间接取值的唯一方法是创建指向指针的引用。因此,C++程序员提到“const 引用”时,含义如下所示;
int z;
const int& zRef = z;
zRef = 4; //DOES NOT COMPILE
const 引用经常用作参数,这非常有用。如果为了提高效率,想按引用传递某个值,但不想修改这个值,可将其标记为 const 引用。例如:
void doSomething(const BigClass& arg)
{
//Implementation here
}
将对象作为参数传递时,默认选择是 const 引用。 只有在明确需要修改对象时,才能忽略 const。
- constexpr 关键字
C++中一直存在常量表达式的概念,在某些情况下需要常量表达式。例如当定义数组时,数组的大小就必须是一个常量表达式。由于这一限制,下面的代码在 C++中是无效的:
const int getArraySize() { return 32; }
int main()
{
int myArray[getArraySize()]; //Invalid in c++
return 0;
}
可使用 constexpr 关键字重新定义 getArraySize()函数,把它变成常量表达式。常量表达式在编译时计算。
constexpr int getArraySize() { return 32; }
int main()
{
int myArray[getArraySize()];
return 0;
}
甚至可这样做:
int myArray[getArraySize() + 1];//OK
将函数声明为 constexpr 会对函数的行为施加一些限制,因为编译器必须在编译期间对 constexpr 函数求值,. 函数也不允许有任何副作用。下面是几个限制;
e 函数体不包含 goto 语句、try catch 块、未初始化的变量、非字面量类型的变量定义,也不抛出异常,但可调用其他 constexpr 函数。“字面量类型”(literal type)是 constexpr 变量的类型,可从 constexpr 函数返回。字面量类型可以是 void(可能有 consyvolatile 限定符)、标量类型(整型和浮点类型、枚举类型、指针类型、成员指针类型,这些类型有 const/volatile 限定符)、引用类型、字面量数组类型或类类型。类类型可能也有 constvolatile 限定符,具有普通的(即非用户提供的)析构函数,至少有一个 constexpr 构造函数,所有非静态数据成员和基类都是字面量类型
函数的返回类型应该是字面量类型
如果 constexpr 函数是类的一个成员,那么这个函数不能是虚函数。
函数所有的参数都应该是字面量类型
在编译单元(translation unib中定义了 constexpr 函数后,才能调用这个函数,因为编译器需要知道完整的定义。
不允许使用 dynamic_cast()和 reinterpret_cast()。
不允许使用 new 和 delete 表达式。
通过定义 constexpr 构造函数, 可创建用户自定义类型的常量表达式变量constexpr 构造函数具有很多限制,其中的一些限制如下所示:
类不能具有任何虚基类。
构造函数的所有参数都应该是字面量类型。
构造函数体不应该是 function-try-block
构造函数体应该满足与 constexpr 函数体相同的要求,并显式设置为默认(=default)。
所有数据成员都应该用常量表达式初始化。
例如, 下面的 Rect 类定义了一个满足上述要求的 constexpr 构造函数, 此外还定义了一个 constexpr getArea()方法,执行一些计算。
class Rect
{
public:
constexpr Rect(size_t width,size_t height)
:mWidth(width),mHeigh(height){}
constexpr size_t getArea() const { return mWidth * mHeight;}
private:
size_t mWidth,mHeight;
};
使用这个类声明 constexpr 对象相当直接:
constexpr Rect r(8,2);
int myArray[r.getArea()]; //ok
static 关键字
在 C++中,static 关键字有多种用法,这些用法之间好像并没有关系。“重载”这个关键字的部分原因是为了避免在语言中引入新的关键字。
- 静态数据成员和方法
可声明类的静态数据成员和方法。静态数据成员与非静态数据成员不同,它们不是对象的一部分。相反,这个数据成员只有一个副本,这个副本存在于类的任何对象之外。静态方法与此类似,位于类层次(而不是对象层次)。静态方法不会在某个特定对象环境中执行。第 9 章提供了静态数据成员和静态方法的示例。
- 静态链接(static linkage)
在解释用于链接的 static 关键字之前,首先要理解 C++中链接的概念。C++的每个源文件都是单独编译的,编译得到的目标文件会彼此链接。C++源文件中的每个名称,包括函数和全局变量,都有一个内部或外部的链接。外部链接意味着这个名称在其他源文件中也有效,内部链接(也称为静态链接)意味着在其他源文件中无效。默认情况下,函数和全局变量都拥有外部链接。然而,可在声明的前面使用关键字 static 指定内部(或静态)链接。例如,假定有两个源文件 FirstFile.cpp 和 AnotherFile.cpp,下面是 FirstFile.cpp:
void f();
int main()
{
f();
return 0;
}
注意这个文件提供了 f()函数的原型,但没有给出定义。下面是 AnotherFile.cpp:
#include <iostream>
void f();
void f()
{
std::cout<<"f\n";
}
这个文件同时提供 f()函数的原型和定义。注意在两个不同文件中编写相同函数的原型是合法的。如果将原型放在头文件中,并在每个源文件中都用#include 包含这个头文件,预处理器就会自动在每个源文件中给出函数原型。之所以使用头文件,是原因它便于维护(并保持同步)原型的副本。然而,这个示例没有使用头文件。这两个源文件都可成功编译,程序链接也没有问题:因为 f()函数具有外部链接,main()函数可从另一个文件调用这个函数。现在假定在 AnotherFile.cpp 中将 static 应用到 f()函数原型。注意不需要在 f()函数的定义前面重复使用 static关键字。只需要在函数名称的第一个实例前使用这个关键字,不需要重复它:
#include <iostream>
static void f();
void f()
{
std::cout<<"f\n";
}
现在每个源文件都可成功编译,但链接时将失败,因为 函数具有内部(静态)链接,FirstFile.cpp 无法使用这个函数。如果在源文件中定义了静态方法但是没有使用它,有些编译器会给出警告(指出这些方法不应该是静态的,因为其他文件可能会用到它们将 static 用于内部链接的另一种方式是使用匿名名称空间(anonymous namespaces)。可将变量或函数封装到一个没有名字的名称空间,而不是使用 static,如下所示:
#include <iostream>
namespace
{
void f();
void f()
{
std::cout<<"f\n";
}
}
在同一源文件中,可在声明匿名名称空间之后的任何位置访问名称空间中的项,但不能在其他源文件中访问。这一语义与 static 关键字相同。
警告:
要获取内部链接,建议使用匿名名称空间,而不要使用 static 关键字。
extern 关键字
extern 关键字好像是 static 的反义词, 将它后面的名称指定为外部链接。某些情况下可使用这种方法。例如,const 和 typedef 在默认情况下是内部链接,可使用 extern 使其变为外部链接。然而,extem 有一点复杂。当指定某个名称为 extern 时,编译器将这条语句当作声明而不是定义。对于变量而言,这意味着编译器不会为这个变量分配空间。必须为这个变量提供单独的、不使用 extem 关键字的定义行。例如,下面是 AnotherFile.cpp 的内容:
extern int x;
int x = 3;
也可在 extern 行初始化 x,这一行既是声明又是定义;
extern int x = 3;
这种情形下的 extem 并不是非常有用,因为x默认具有外部链接。当另一个源文件 FirstFile.cpp 使用x 时,才会真正用到 extern;
#include <iostream>
extern int x;
int main()
{
std::cout<<x<<std::endl;
}
FirstFile.cpp 使用了 extem 声明,因此可使用x。编译器需要知道 x 的声明,才能在 main()函数中使用这个变量。然而,如果声明 x 时未使用 extem 关键字,编译器会认为这是定义,因而会为 x 分配空间,导致链接步又失败(因为有两个全局作用域的x 变量)。使用 extern,就可在多个源文件中全局访问这个变量。
警告;
然而,建议不要使用全局变量。全局变量会让人迷惑,并且容易出错,在大型程序中尤其如此!
函数中的静态变量
C++中 static 关键字的最终目的是创建离开和进入作用域时都可保留值的局部变量.。 函数中的静态变量就像只能在函数内部访问的全局变量。静态变量最常见的用法是“记住”某个函数是否执行了特定的初始化操作。例如,下面的代码就使用了这一技术:
void performTask()
{
static bool initialized = false;
if(!initlized)
{
cout<<"initializing"<<endl;
initialized = false;
}
}
然而静态变量容易让人迷惑,在构建代码时通常有更好的方法,以避免使用静态变量。在此情况下,可编写一个类,用构造函数执行所需的初始化操作。注意;避免使用单独的静态变量,为了维持状态可以改用对象。但有时,它们十分有用。一个例子是实现 Meyer 的 singleton(单例)设计模式,详见第 29 章。
注意:
performTask()的实现不是线程安全的,它包含一个竞态条件。在多线程环境中,需要使用原子或其他机制来同步多个线程。多线程参见第 23 章。
非局部变量的初始化顺序
在结束讨论静态数据成员和全局变量的主题前,考虑这些变量的初始化顺序。程序中所有的全局变量和类的静态数据成员都会在 main()函数开始之前初始化。给定源文件中的变量以在源文件中出现的顺序初始化。例如,在下面的文件中,Demo::x 一定会在y之前初始化:
class Demo
{
public:
static int x;
};
int Demo::x = 3;
int y = 4;
然而,C++没有提供规范,用以说明在不同源文件中初始化非局部变量的顺序。如果在某个源文件中有一个全局变量 x,在另一个源文件中有一个全局变量 y,无法知道哪个变量先初始化。通常,不需要关注这一规范的缺失,但如果某个全局变量或静态变量依赖于另一个变量,就可能引发问题。对象的初始化意味着调用构造函数,全局对象的构造函数可能会访问另一个全局对象,并假定另一个全局对象已经构建。如果这两个全局对象在不同的源文件中声明,就不能指望一个全局对象在另一个全局对象之前构建,也无法控制它们的初始化顺序。不同编译器可能有不同的初始化顺序,即使同一编译器的不同版本也可能如此,甚至在项目中添加另一个源文件也可能影响初始化顺序。
警告:
不同源文件中非局部变量的初始化顺序是不确定的。
非局部变量的销毁顺序
非局部变量按初始化的逆序进行销毁。不同源文件中非局部变量的初始化顺序是不确定的,所以销毁顺序也是不确定的。
类型和类型转换
类型别名
类型别名为现有的类型声明提供了新名称。 可将类型别名视作为现有类型声明引入同义词的语法(不创建新类型)。下面为 int* 类型声明指定新名称 IntPtr:
using IntPtr = int*;
可以互换使用新的类型名和别名定义。例如,以下两行是有效的:
int * p1;
intPtr p2;
使用新类型名称创建的变量与使用原始类型声明创建的变量完全兼容。当给定上面的定义后,编写下面的代码是完全合法的,因为它们不仅是“兼容的”类型,它们根本就是同一类型:
p1 = p2;
p2 = p1;
类型别名最常见的用法是当实际类型的声明过于笨拙时,提供易于管理的名称,这一情形通常出现在模板中。 例如, 第 1 章介绍了标准库中的 std::vector。 为声明一个 string 矢量, 需要将其声明为 std::vector<std::string>。这是一个模板类,因此只要想使用 vector 类型,就要指定模板参数,模板将在第 12 章详细讨论。在声明变量、指定函数参数等操作中,必须编写 std::vector<std::string>:
void processVector(const std::vector<std::string>& vec){}
int main()
{
std::vector<std::string>myVector;
processVector(myVector);
return 0;
}
使用类型别名,可创建更简短、更有意义的名称:
using StringVector = std::vector<std::string>;
void processVector(const StringVector& vec){}
int main()
{
StringVector myVector;
processVector(myVector);
return 0;
}
类型别名可包括作用域限定符。上例显示了这一点,其中包括 StringVector 的作用域 std。标准库广泛使用类型别名来提供类型的简短名称。例如,std::string 实际上就是这样一个类型别名
using string = basic_string<char>;