【C++基础】第九十一课:[面向对象程序设计]容器与继承

容器与继承

Posted by x-jeff on January 1, 2024

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.容器与继承

当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式。因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器当中。

举个例子,假定我们想定义一个vector,令其保存用户准备购买的几种书籍。显然我们不应该用vector保存Bulk_quote对象。因为我们不能将Quote对象转换成Bulk_quote(参见:类型转换与继承),所以我们将无法把Quote对象放置在该vector中。

其实,我们也不应该使用vector保存Quote对象。此时,虽然我们可以把Bulk_quote对象放置在容器中,但是这些对象再也不是Bulk_quote对象了:

1
2
3
4
5
6
vector<Quote> basket;
basket.push_back(Quote("0-201-82470-1", 50));
//正确:但是只能把对象的Quote部分拷贝给basket
basket.push_back(Bulk_quote("0-201-54848-8", 50, 10, .25));
//调用Quote定义的版本,打印750,即15 * 50
cout << basket.back().net_price(15) << endl;

basket的元素是Quote对象,因此当我们向该vector中添加一个Bulk_quote对象时,它的派生类部分将被忽略掉(参见:类型转换与继承)。

1.1.在容器中放置(智能)指针而非对象

当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。和往常一样,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型:

1
2
3
4
5
vector<shared_ptr<Quote>> basket;
basket.push_back(make_shared<Quote>("0-201-82470-1", 50));
basket.push_back(make_shared<Bulk_quote>("0-201-54848-8", 50, 10, .25));
//调用Bulk_quote定义的版本;打印562.5,即15 * (1-0.25) * 50
cout << basket.back()->net_price(15) << endl;

实际调用的net_price版本依赖于指针所指对象的动态类型。在此例中,make_shared<Bulk_quote>返回一个shared_ptr<Bulk_quote>对象,当我们调用push_back时该对象被转换成shared_ptr<Quote>。因此尽管在形式上有所差别,但实际上basket的所有元素的类型都是相同的。

2.编写Basket类

对于C++面向对象的编程来说,一个悖论是我们无法直接使用对象进行面向对象编程。相反,我们必须使用指针和引用。因为指针会增加程序的复杂性,所以我们经常定义一些辅助的类来处理这种复杂情况。首先,我们定义一个表示购物篮的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Basket {
public:
	//Basket使用合成的默认构造函数和拷贝控制成员
	void add_item(const std::shared_ptr<Quote> &sale)
		{ items.insert(sale); }
	//打印每本书的总价和购物篮中所有书的总价
	double total_receipt(std::ostream&) const;
private:
	//该函数用于比较shared_ptr,multiset成员会用到它
	static bool compare(const std::shared_ptr<Quote> &lhs, const std::shared_ptr<Quote> &rhs)
		{ return lhs->isbn() < rhs->isbn(); }
	//multiset保存多个报价,按照compare成员排序
	std::multiset<std::shared_ptr<Quote>, decltype(compare)*> items{compare};
};

我们的类使用一个multiset来存放交易信息,这样我们就能保存同一本书的多条交易记录,而且对于一本给定的书籍,它的所有交易信息都保存在一起(参见:关键字类型的要求)。

2.1.定义Basket的成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
double Basket::total_receipt(ostream &os) const
{
	double sum = 0.0; //保存实时计算出的总价格
	//iter指向ISBN相同的一批元素中的第一个
	//upper_bound返回一个迭代器,该迭代器指向这批元素的尾后位置
	for(auto iter = items.cbegin(); iter != items.cend(); iter = items.upper_bound(*iter))
	{
		//我们知道在当前的Basket中至少有一个该关键字的元素
		//打印该书籍对应的项目
		sum += print_total(os, **iter, items.count(*iter));
	}
	os << "Total Sale: " << sum << endl; //打印最终的总价格
	return sum;
}

for循环中的“递增”表达式,与通常的循环语句依次读取每个元素不同,我们直接令iter指向下一个关键字,调用upper_bound函数可以令我们跳过与当前关键字相同的所有元素。对于upper_bound函数来说,它返回的是一个迭代器,该迭代器指向所有与iter关键字相等的元素中最后一个元素的下一位置。因此,我们得到的迭代器或者指向集合的末尾,或者指向下一本书籍。

print_total的实参包括一个用于写入数据的ostream、一个待处理的Quote对象和一个计数值。当我们解引用iter后将得到一个指向准备打印的对象的shared_ptr。为了得到这个对象,必须解引用该shared_ptr。因此,**iter是一个Quote对象(或者Quote的派生类的对象)。我们使用multiset的count成员来统计在multiset中有多少元素的键值相同(即ISBN相同)。

2.2.隐藏指针

Basket的用户仍然必须处理动态内存,原因是add_item需要接受一个shared_ptr参数。因此,用户不得不按照如下形式编写代码:

1
2
3
Basket bsk;
bsk.add_item(make_shared<Quote>("123", 45));
bsk.add_item(make_shared<Bulk_quote>("345", 45, 3, .15));

我们的下一步是重新定义add_item,使得它接受一个Quote对象而非shared_ptr。新版本的add_item将负责处理内存分配,这样它的用户就不必再受困于此了。我们将定义两个版本,一个拷贝它给定的对象,另一个则采取移动操作

1
2
void add_item(const Quote& sale); //拷贝给定的对象
void add_item(Quote&& sale); //移动给定的对象

唯一的问题是add_item不知道要分配的类型。当add_item进行内存分配时,它将拷贝(或移动)它的sale参数。在某处可能会有一条如下形式的new表达式:

1
new Quote(sale)

不幸的是,这条表达式所做的工作可能是不正确的:new为我们请求的类型分配内存,因此这条表达式将分配一个Quote类型的对象并且拷贝sale的Quote部分。然而,sale实际指向的可能是Bulk_quote对象,此时,该对象将被迫切掉一部分。

2.3.模拟虚拷贝

为了解决上述问题,我们给Quote类添加一个虚函数,该函数将申请一份当前对象的拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
class Quote {
public:
	//该虚函数返回当前对象的一份动态分配的拷贝
	virtual Quote* clone() const & { return new Quote(*this); }
	virtual Quote* clone() && { return new Quote(std::move(*this)); }
	//其他成员与之前的版本一致
};
class Bulk_quote : public Quote {
	Bulk_quote* clone() const & { return new Bulk_quote(*this); }
	Bulk_quote* clone() && { return new Bulk_quote(std::move(*this)); }
	//其他成员与之前的版本一致
};

因为我们拥有add_item的拷贝和移动版本,所以我们分别定义clone的左值和右值版本。每个clone函数分配当前类型的一个新对象,其中,const左值引用成员将它自己拷贝给新分配的对象;右值引用成员则将自己移动到新数据中。

我们可以使用clone很容易地写出新版本的add_item:

1
2
3
4
5
6
7
8
class Basket {
public:
	void add_item(const Quote& sale) //拷贝给定的对象
	{ items.insert(std::shared_ptr<Quote>(sale.clone())); }
	void add_item(Quote&& sale) //移动给定的对象
	{ items.insert(std::shared_ptr<Quote>(std::move(sale).clone())); }
	//其他成员与之前的版本一致
};

和add_item本身一样,clone函数也根据作用于左值还是右值而分为不同的重载版本。在此例中,第一个add_item函数调用clone的const左值版本,第二个函数调用clone的右值引用版本。在右值版本中,尽管sale的类型是右值引用类型,但实际上sale本身(和任何其他变量一样)是个左值。因此,我们调用move把一个右值引用绑定到sale上。

我们的clone函数也是一个虚函数。sale的动态类型(通常)决定了到底运行Quote的函数还是Bulk_quote的函数。无论我们是拷贝还是移动数据,clone都返回一个新分配对象的指针,该对象与clone所属的类型一致。我们把一个shared_ptr绑定到这个对象上,然后调用insert将这个新分配的对象添加到items中。注意,因为shared_ptr支持派生类向基类的类型转换(参见:定义派生类),所以我们能把shared_ptr<Quote>绑定到Bulk_quote*上。