《More Effictive C++》学习笔记 — 技术(六)

条款31 — 让函数根据一个以上的对象类型来决定如何虚化

多态是C++的特性之一,用我们这个条款里的话来描述,就是函数的调用是根据当前对象类型(*this)决定的。那么,自然而然,我们会产生更为泛化的需求:如何根据几个对象的类型决定调用哪个函数?

这个条款的场景如下:

如果我们尝试写一个视频游戏软件,场景发生于外层空间,涉及宇宙飞船、太空站、小行星等天体。

当宇宙飞船、太空站、小行星飕飕略过你所构筑的人工世界,它们自然也应该有碰撞的风险。假设碰撞规则如下:
1、如果宇宙飞船和太空站以低速碰撞,宇宙飞船会停靠进太空站内。否则宇宙飞船和太空站收到的损害与其碰撞速度成正比。
2、如果宇宙飞船和宇宙飞船碰撞,或是太空站和太空站碰撞,碰撞双方都遭受损害,损害程度与撞击速度成正比。
3、如果小号的小行星碰撞到宇宙飞船或太空站,小行星会损毁。如果碰撞的是大号小行星,则宇宙飞船或太空站损毁。
4、如果小行星撞击另一颗小行星,两者都碎裂成更小的小行星,向四面八方逸散。

首先,我们需要标记处宇宙飞船、太空站、小行星三者的共同特性。如果没有意外,他们统统处于运动状态,所以它们都有速度、体积等等。有了这样的共同特性,我们自然会想到定义一个被三者继承的基类。这样的基类在实际设计时,几乎必然成为一个抽象基类。其继承体系如下:
在这里插入图片描述
如果我们进行了一定的编码,实现的碰撞检测函数可能会是:

void checkForCollision(GameObject& obj1, GameObject& obj2)
{
	if (theyJustCollided(obj1, obj2))
	{
		processCollision(obj1, obj2);
	}
	else
	{
		...
	}
}

现在的问题是我们知道两个不同类型的 GameObject 相互碰撞的结果取决于它们的类型,但是我们并不知道它们到底是什么。如果碰撞的处理程序只根据 obj1 的动态类型而定,我们可以使 processCollision 称为 GameObject 的虚函数,然后通过 obj1.processCollision(obj2) 处理碰撞。类似地,如果处理程序完全取决于 obj2 的类型,也可以通过类似的方法解决。然而事实上,处理程序取决于 obj1obj2 的类型。因此,只针对一个对象而设置的虚函数并不能满足我们的要求。

因此,我们需要某种行为根据一个以上的对象类型而确定的函数。如何在C++中满足此要求呢?在对象程序设计领域中,人们常常把一个虚函数调用动作称为 message dispatch。因此某个函数如果根据两个参数虚化,自然被称为 double dispatch。C++语言本身不支持这种机制,所以我们需要自行解决此问题。

1、虚函数 + RTTI

虚函数虽然不能完全满足我们的要求,但至少它实现了其中一半。以飞船类为例:

class GameObject
{
public:
	virtual void collide(GameObject& otherObject) = 0;
};

class SpaceShip : public GameObject 
{
public:
	virtual void collide(GameObject& otherObject);
};

最粗暴的做法就是使用 if-else,根据对象类型实现分支处理:

void SpaceShip::collide(GameObject& otherObject)
{
	auto& objectTypeInfo = typeid(otherObject);

	if (objectTypeInfo == typeid(SpaceShip))
	{
		SpaceShip& spaceShip = static_cast<SpaceShip&>(otherObject);
		collideWithSpaceShip();
	}
	else if (objectTypeInfo == typeid(SpaceStation))
	{
		SpaceStation& spaceStation = static_cast<SpaceStation&>(otherObject);
		collideWithSpaceStation();
	}
	else if (objectTypeInfo == typeid(Asteroid))
	{
		Asteroid& asteroid = static_cast<Asteroid&>(otherObject);
		collideWithAsteroid();
	}
	else
	{
		throw exception("no matched GameObject type");
	}
}

借助虚函数,在调用 collide 时,我们已经知道 *this 的对象类型。因此,我们只需要根据入参对象类型决定如何处理碰撞。

然而,这样的设计违反了开闭原则。GameObject 的所有子类都需要相互了解,且每增加一个子类,所有子类的代码都需要随之修改。这会造成代码难以维护。

2、只使用虚函数

我们可以尝试只使用虚函数处理。但是显然,一层虚函数调用只能解决一次分发。因此,我们需要两次虚函数的调用来分别根据左右两侧的参数类型调用相应的虚函数。这要求我们将 GameObject 声明如下:

class SpaceShip;
class SpaceStation;
class Asteroid;
class GameObject
{
public:
	virtual void collide(GameObject& otherObject) = 0;
	virtual void collide(SpaceShip& otherObject) = 0;
	virtual void collide(SpaceStation& otherObject) = 0;
	virtual void collide(Asteroid& otherObject) = 0;
};
class SpaceShip : public GameObject 
{
public:
	virtual void collide(GameObject& otherObject);
	virtual void collide(SpaceShip& otherObject);
	virtual void collide(SpaceStation& otherObject);
	virtual void collide(Asteroid& otherObject);
};

那么我们可以第一个函数的实现就简单很多:

void SpaceShip::collide(GameObject& otherObject)
{
	otherObject.collide(*this);
}

这里的关键在于参数的引用传递,这使第二次虚函数调用称为可能。

好吧,这个函数看起来比前面的结局方案清楚明朗很多,不需要过多的分支处理。它避免了每次增加子类都修改 if-else 分支语句。然而同样地,每次增加子类都需要增加虚函数和相应子类中的函数实现。这样的行为是非常不可取的,会导致严重的二进制兼容问题。我个人觉得它甚至还不如前面增加逻辑分支和处理函数!

二者都可能遇到的问题是:我们也许会使用框架开发,而我们希望继承框架中的某个类以实现多重虚化,这是不可能的。

3、自行仿制虚函数表格

回忆虚函数的实现:将虚函数按照一定顺序放到虚函数表中,通过 vptr 进行调用。这里的重点在于虚函数的索引一定要正确。那么如果我们想进一步改进我们的实现,不妨考虑与编译器采用相同的方式。毕竟这个实现方案已经经过了时间的检验。

首先修改 GameObject 继承体系中的声明:

class SpaceShip;
class SpaceStation;
class Asteroid;
class GameObject
{
public:
	virtual void collide(GameObject& otherObject) = 0;
};

class SpaceShip : public GameObject 
{
public:
	virtual void collide(GameObject& otherObject);
	virtual void collideWithSpaceShip(SpaceShip& otherObject);
	virtual void collideWithSpaceStation(SpaceStation& otherObject);
	virtual void collideWithAsteroid(Asteroid& otherObject);
};

这里将 collideWithSpaceShip 等函数设置为虚函数应该是为了其他类可以继承 SpaceShip 并重写这些碰撞函数。

(1)函数映射数组

正如前面所说,现在的主要任务就是构建出 SpaceShip 的虚函数表。因此,我们需要将参数 OtherObject 的动态类型映射至某个成员方法指针,以调用适当的碰撞处理函数。一种简单的做法就是产生一个关系型数组。只要获得类名,就能找到对应的函数指针。我们可以在SpaceShip 增加查找接口以简化设计,声明如下:

class SpaceShip : public GameObject 
{
private:
	using CollideFunction = typename function<void(SpaceShip&, GameObject&)>;
	static CollideFunction* findCollideFunction(GameObject&);
};

碰撞函数的实现就简单很多:

void SpaceShip::collide(GameObject& otherObject)
{
	auto collideFunctionPtr = findCollideFunction(otherObject);

	if (collideFunctionPtr != nullptr)
	{ 
		(*collideFunctionPtr)(*this, otherObject);
	}
	else
	{
		throw exception("no related collide function");
	}
}

(2)虚函数表格初始化

那么如何实现这样的映射关系呢?最好的实现方式莫过于使用 map。这样一种映射关系应该在被使用之前进行初始化,并在不再被使用时销毁。如果我们使用 newdelete 实现,容易发生错误。因此我们选择使用局部静态对象完成此项工作。当然,为了保证初始化过程只发生一次,我们需要提供一个初始化函数:

class SpaceShip : public GameObject 
{
private:
	using CollideFunctionMap = typename map<string, CollideFunction>;
	static CollideFunctionMap initializationCollideFunctionMapPtr();
};

auto SpaceShip::findCollideFunction(GameObject& otherObject) -> CollideFunction*
{
	static auto collideFunctionMap = initializationCollideFunctionMapPtr();

	auto iter = collideFunctionMap.find(typeid(otherObject).name());
	if (iter == collideFunctionMap.end())
	{
		return nullptr;
	}
	else
	{
		return &iter->second;
	}
}

书中使用了智能指针,不过C++11中的移动语义使我们可以忽略这个问题。

那么初始化函数应该是怎样的呢?观念上, 应该这样实现:

auto SpaceShip::initializationCollideFunctionMapPtr() -> CollideFunctionMap
{
	CollideFunctionMap collideFunctionMap;

	collideFunctionMap[typeid(SpaceShip).name()] = &collideWithSpaceShip;
	collideFunctionMap[typeid(SpaceStation).name()] = &collideWithSpaceStation;
	collideFunctionMap[typeid(Asteroid).name()] = &collideWithAsteroid;

	return collideFunctionMap;
}

然而,这样的编译时无法通过的。map 的值类型为 function<void(SpaceShip&, GameObject&)>,而这些函数的第二个参数都是 GameObject 的派生类应用。在函数符的构造中,可以用参数类型为基类的函数指针初始化模板参数中设定的参数类型为派生类的函数符对象。反过来却是不行的,因为多态并不支持从将基类引用转化为派生类引用。

如果我们不使用函数符,而使用函数指针,可能会考虑使用 reinterpret_cast进行强制转换以解决类型匹配问题。然而,如果我们在代理中使用了虚基类或多继承,可能导致运行时的异常行为。我们告诉编译器传递给该函数的是一个 GameObjcet 指针,那么编译器就会按照该种对象的偏移量去调用相应的函数。然而,真实传入的参数却是其他类型,这可能导致函数寻址全然错误,引发异常。

那么最适宜的做法就是修改碰撞函数的参数为 GameObject&

class SpaceShip : public GameObject 
{
public:
	virtual void collideWithSpaceShip(GameObject& otherObject);
	virtual void collideWithSpaceStation(GameObject& otherObject);
	virtual void collideWithAsteroid(GameObject& otherObject);
}

看到这我们不难理解为什么不使用重载的方式定义这些函数,而是每种碰撞函数都有各自的名字。

此时,函数中得到的不再是某个具体的类型对象,而是一般性的 GameObject 参数。因此,为了处理各种碰撞,我们依然需要使用 RTTI 进行类型转换:

void SpaceShip::collideWithSpaceShip(GameObject& otherObject)
{
	SpaceShip& spaceShipObject = dynamic_cast<SpaceShip&>(otherObject);
	...
}

void SpaceShip::collideWithSpaceStation(GameObject& otherObject)
{
	SpaceStation& spaceStationObject = dynamic_cast<SpaceStation&>(otherObject);
	...
}

void SpaceShip::collideWithAsteroid(GameObject& otherObject)
{
	Asteroid& asteroidObject = dynamic_cast<Asteroid&>(otherObject);
	...
}

为了安全可以加上 bad_cast 异常捕获。

注意,我们向关系数组插入函数符时使用的关键字为 typeid.name 而不是直接使用类名。这样是为了和在关系数组查询使用的关键字保持一致。有些编译器的 typeid.name 和类名不完全一致。

4、使用非成员函数的碰撞处理函数

现在我们知道如何建立一个类似虚函数表的关系型数组。然而,由于这个数组的本质还是通过碰撞客体名称找到处理函数,因此,如果有新的 GameObject 加入这个游戏软件,我们仍需修改类定义,增加新的函数。这又会导致所有使用该模块的用户都需要重新编译。这种处理并不比我们仅使用虚函数解决问题要简单。

问题的核心在于成员函数。如果关系型数组内含的指针指向的是非成员函数,重新编译的问题就可以解决了。此外,改用非成员函数可以解决一个一直被我们忽略的设计问题:如果两个不同类型的物体发生碰转,到底哪一个类应该负责处理?以先前发展的情况来看,如果对象1和对象2碰撞,处理该碰撞的类型为函数的左侧操作数对应的类型。也就是完全取决于代码的编写方式。这合理吗?不应该是由第三者进行处理吗?

(1)非成员函数定义

如果我们使用非成员函数进行实现,其定义如下:

namespace
{
	void shipAsteroid(GameObject& spaceShip, GameObject& asteroid);
	void shipStation(GameObject& spaceShip, GameObject& spaceStation);
	void asteroidStation(GameObject& asteroid, GameObject& spaceStation);
	...
	
	void asteroidShip(GameObject& asteroid, GameObject& spaceShip)
	{
		shipAsteroid(spaceShip, asteroid);
	}

	void stationShip(GameObject& spaceStation, GameObject& spaceShip)
	{
		shipStation(spaceShip, spaceStation);
	}

	void stationAsteroid(GameObject& spaceStation, GameObject& asteroid)
	{
		asteroidStation(spaceStation, asteroid);
	}
}

使用匿名 namespace 是为了保证函数的内部链接性。我们提供了任意两个天体类型的碰撞函数,但是只有一个是主要处理函数,另一个仅仅提供转发功能。

(2)关系数组

同样,我们需要一个映射数组保存类型。这次关键字不是一个类型名,而是两个类型名:

namespace
{
	using CollideFunction = typename function<void(GameObject&, GameObject&)>;
	using CollideFunctionMapKey = typename pair<string, string>;
	using CollideFunctionMap = typename map<CollideFunctionMapKey, CollideFunction>;

	CollideFunction* findCollideFunction(const string& class1, const string& class2);
	CollideFunctionMap initializationCollideFunctionMapPtr();
}

那么初始化函数的实现可以为:

namespace 
{
	CollideFunction* findCollideFunction(const string& class1, const string& class2)
	{
		static auto collideFunctionMap = initializationCollideFunctionMapPtr();

		auto iter = collideFunctionMap.find(make_pair(class1,class2));
		if (iter == collideFunctionMap.end())
		{
			return nullptr;
		}
		else
		{
			return &iter->second;
		}
	}

	CollideFunctionMap initializationCollideFunctionMapPtr()
	{
		CollideFunctionMap collideFunctionMap;

		collideFunctionMap[make_pair(typeid(SpaceShip).name(), typeid(Asteroid).name())] = &shipAsteroid;
		collideFunctionMap[make_pair(typeid(SpaceShip).name(), typeid(SpaceStation).name())] = &shipStation;
		collideFunctionMap[make_pair(typeid(Asteroid).name(), typeid(SpaceStation).name())] = &asteroidStation;
		collideFunctionMap[make_pair(typeid(Asteroid).name(), typeid(SpaceShip).name())] = &asteroidShip;
		collideFunctionMap[make_pair(typeid(SpaceStation).name(), typeid(SpaceShip).name())] = &stationShip;
		collideFunctionMap[make_pair(typeid(SpaceStation).name(), typeid(Asteroid).name())] = &stationAsteroid;

		return collideFunctionMap;
	}
}

(3)使用

void processCollision(GameObject& object1, GameObject& object2)
{
	auto collideFunctionPtr = findCollideFunction(typeid(object1).name(), typeid(object2).name());
	if (collideFunctionPtr != nullptr)
	{
		(*collideFunctionPtr)(object1, object2);
	}
	else
	{
		throw exception("no related collide function");
	}
}

这里,我们没有将此函数放入 namespace 中,是希望将之暴露给用户。用户只需要告诉该函数碰撞的对象,便可以得到对应的碰撞处理。现在我们终于实现了我们的目标。一旦有新的 GameObject 派生类加入这个继承体系,我们只需改变 processCollision 的实现文件,原有的类和客户代码不需要再编译(除非它们想使用新的类)。

5、继承引发的问题

现在,如果我们引入了新的类型 MilitaryShipCommercialShip
在这里插入图片描述
如果希望新引入这两个类型的碰撞处理与 SpaceShip 相同。我们需要怎么做呢?书中提到没有解决办法,只能使用双重虚函数调用。但我个人觉得这和增加一个与 SpaceShip 平行的类没有什么区别,都是在关系数组中插入新项,只是这里的处理函数不需要重新编写而已(如果有朋友知道书中不使用关系数组处理此问题的理由,还请告知)

6、映射数组的另一种初始化方式

上面对映射数组的初始化是完全静态封闭的。也就是说,一方面,一旦初始化好,整个运行过程中就不可再修改;另一方面,用户对其完全无法操控。因此,一个替代的选择是提供一个类,以动态操作该映射数组:

class CollisionMap
{
public:
	using CollideFunction = typename function<void(GameObject&, GameObject&)>;

	void addEntry(const string& type1, const string& type2, CollideFunction coolideFunction, bool symmetric = true);

	void removeEntry(const string& type1, const string& type2);

	CollideFunction* findCollideFunction(const string& class1, const string& class2);

	static CollisionMap& getInstance();

private:
	using CollideFunctionMapKey = typename pair<string, string>;
	using CollideFunctionMap = typename map<CollideFunctionMapKey, CollideFunction>;

	CollisionMap() = default;
	CollisionMap(const CollisionMap&) = default;
};

这个类使用了单例模式,因为这个映射关系应该是全局唯一的。其实,为了广泛使用,可以将这个类设置为模板类,实例化的模板参数为基类类型。

用户可以这样使用该类:

CollisionMap::getInstance().addEntry("SpaceShip", "Asteroid", &shipAsteroid);

当然,这就把初始化的功能以及碰撞函数作为非成员函数实现的要求交给了 CollisionMap 的用户。此外,我们需要保证,在类对象的碰撞发生之前,相应的碰撞函数应该已经被加入到映射数组中。

书中提到我们可以选择在对象的构造函数中去检查。这样,在任何一个对象创建的时候都可以向数组中放入相应的条目。这样要求每个碰撞函数的两个操作数都需要进行检查,会有很多冗余的查询;同时,在增加类型的时候需要修改已有类,以保证与新类的碰撞函数在原对象的创建之前被加入数组中。

书中提供的另一种做法比较简单,提供一个类用于初始化:

class RegisterCollisionFunction
{
public:
	RegisterCollisionFunction(const string& type1, const string& type2, CollideFunction coolideFunction, bool symmetric = true)
	{
		CollisionMap::getInstance().addEntry(type1, type2, coolideFunction, symmetric);
	}
};

这样,我们可以在 main 函数执行之前,创建 RegisterCollisionFunction 对象来初始化映射数组:

int main()
{
	RegisterCollisionFunction cf1("SpaceShip", "Asteroid", &shipAsteroid);
	RegisterCollisionFunction cf2("SpaceShip", "SpaceStation", &shipStation);
	...
}

这种方法在新增派生类的时候也可以很好的支持。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值