基于策略的设计在创建高度可定制的类时,提供了极佳的灵活性。然而,有时这种灵活性和强大功能反而会成为良好设计的阻碍。在本节中,我们将回顾基于策略设计的优点与缺点,并提出一些通用的建议。
基于策略的设计在设计上的主要优势在于,其灵活性和可扩展性。从高层次来看,这些优势与策略模式所提供的类似,但基于策略的设计是在编译时实现的。允许开发者在编译时,为系统执行的每个特定任务或操作从多个算法中选择其一。由于对这些算法的唯一约束是,必须满足将其集成到系统其余部分的接口要求,因此同样可以编写新的策略来扩展系统,以支持可定制的操作。
从宏观层面看,基于策略的设计使得软件系统能够由组件构建而成。这种思想本身并非全新,也绝不仅限于基于策略的设计。然而,基于策略设计的重点在于使用组件来定义行为以及单个类的实现。策略与回调之间存在一定的相似性 —— 两者都允许在特定事件发生时执行用户指定的操作。但策略比回调更加通用:回调通常只是一个函数,而策略则是完整的类,可以包含多个函数,甚至拥有复杂的内部状态。
这些通用概念转化为该设计所特有的一系列优势,核心围绕着灵活性和可扩展性。在系统整体结构和高层组件由高层设计确定的前提下,策略允许在原始设计所设定的约束范围内进行各种底层的定制。策略可以扩展类的接口(添加公有成员函数)、实现或扩展类的状态(添加数据成员),以及提供具体实现(添加私有成员函数)。原始设计通过确定类的整体结构及其相互关系,实质上授权了每个策略承担上述一种或多种角色。
其结果是一个可扩展的系统,即使面对设计之初未曾预见或未知的演变需求,也能进行相应的调整。系统的整体架构保持稳定,而策略的可选范围及其接口的约束则提供了一种系统化、规范化的软件修改与扩展途径。
基于策略的设计首先让人想到的问题:使用特定策略集声明基于策略的类时,代码会变得极其冗长,尤其是当需要更改列表末尾的某个策略时。考虑一下,将本章中实现的所有策略组合起来,声明一个智能指针的情形:
SmartPtr<int, DeleteByOperator<int>, NoMoveNoCopy, ExplicitRaw, WithoutArrow, NoDebug> p;
这还仅仅是一个智能指针 —— 一个接口相对简单、功能有限的类。尽管不太可能有人需要一个集成了所有这些定制功能的指针,但基于策略的类往往确实拥有大量的策略。这个问题可能最为明显,但实际上并非最严重的。模板别名有助于为特定应用程序,使用的少数几种策略组合提供简洁的名称。在模板上下文中,作为函数参数的智能指针类型可以自动推导,无需显式指定。在普通代码中,可以使用 auto 来节省大量输入,并使代码更加健壮 —— 当用一种自动生成一致类型的方式,替代必须手动保持一致的复杂类型声明时,因在不同位置输入了微小差异而导致的错误就会消失(如果有一种方法能让编译器生成“构造即正确”的代码,就应该使用)。
另一个更为严重、尽管不那么明显的问题是,所有这些具有不同策略的基于策略的类型,实际上是不同的类型。两个指向相同对象类型,但删除策略不同的智能指针,是不同的类型;两个其他方面完全相同但复制策略不同的智能指针,也是不同的类型。为什么这会成为一个问题?设想一个函数,通过智能指针接收一个对象并对其进行操作。该函数并不复制这个智能指针,复制策略是什么应该无关紧要 —— 根本不会使用,但这个函数的参数类型应该是什么?并不存在一个能够容纳所有智能指针的单一类型,即使这些指针的功能非常相似。
这里有几个可能的解决方案,最直接的方法是将所有使用基于策略类型的函数都变成模板。这确实简化了编码,并减少了代码重复(至少是源代码的重复),但它也有自身的缺点 —— 由于每个函数都有多个副本,机器代码会变得更大,并且所有模板代码都必须放在头文件中。
另一个选择是擦除策略类型,第6章中已经了解过类型擦除技术。类型擦除解决了存在大量相似类型的问题 —— 可以让所有智能指针,无论其策略如何,都属于同一类型(前提是策略仅影响实现,而不影响公共接口),但这样做代价非常高昂。
模板普遍存在的一个主要缺点,特别是基于策略的设计,就在于它们提供了零开销抽象 —— 可以用方便的高层抽象和概念来表达程序,但编译器会将其全部剥离,内联所有模板,并生成最少必要的代码。而类型擦除不仅抵消了这一优势,反而产生了相反的效果 —— 引入了高昂的开销,包括内存分配和间接函数调用。
最后一个选择是避免使用基于策略的类型,至少在某些操作中如此。有时,这种选择会带来额外的成本 —— 一个需要操作对象但不删除或拥有该对象的函数,应该通过引用而不是智能指针来接收对象。除了能清晰地表达函数不会拥有该对象这一事实外,这种方法也巧妙地解决了参数类型的问题 —— 无论对象来自哪个智能指针,引用的类型都是相同的。然而,这是一种有限的方法 —— 更多时候,确实需要操作整个基于策略的对象,而这些对象通常比简单的指针要复杂得多(例如,自定义容器通常使用策略来实现)。
最后一个缺点是基于策略类型的总体复杂性,尽管这种说法需要谨慎对待 —— 关键问题是,与什么相比的复杂性?基于策略的设计通常用于解决复杂的设计问题,其中一系列相似的类型服务于相同的总体目的(做什么),但实现方式略有不同(如何做)。这引出了关于策略使用的建议。
基于策略设计的指导原则归结为一点:管理复杂性,并确保最终收益大于付出 —— 设计的灵活性和最终解决方案的优雅性,应当足以证明其在实现和使用上复杂性的合理性。
由于大部分复杂性来源于策略数量的增加,大多数指导原则都集中于此。有些策略最终会将实现上相似,但类型上截然不同的东西组合在一起。这类基于策略类型的目标是减少代码重复。虽然这是一个值得追求的目标,但通常这并不足以成为向类型最终用户暴露,大量不同策略选项的充分理由。如果两种不同的类型或类型族恰好具有相似的实现,可以将这些实现提取出来复用。设计中私有的、隐藏的、仅用于实现的部分本身也可以使用策略,只要这能让实现变得更简单。
然而,这些隐藏的策略不应由使用端来选择 —— 使用端应该指定在应用中有意义的类型,以及定制可见行为的策略。从这些类型和策略出发,实现可以按需推导出相应的类型。这与从多个不相关的算法中调用一个公共函数(例如在序列中查找最小元素)并无不同:公共代码没有重复,但也不会暴露给用户。
那么,一个基于策略的类型在什么情况下,应该拆分成两个或更多部分呢?一个很好的判断方法是:考虑使用特定策略集的主类型,是否拥有一个能准确描述它的具体名称。例如,一个不可复制的独占型指针(无论是否可移动)就是一个“唯一指针”(unique pointer) —— 在任意时刻,每个对象只能有一个这样的指针。对于删除或转换策略都成立。另一方面,一个引用计数的指针就是“共享指针”(shared pointer),同样,无论其他策略如何选择。
我们仍然可以获得一定的代码复用,例如删除策略对这两种指针类型是通用的,无需实现两次。事实上,C++ 标准正是这样选择的。std::unique_ptr 只有一个策略,即删除策略。std::shared_ptr 也拥有相同的策略,并可以使用相同的策略对象,但其采用了类型擦除,所有指向特定对象的共享指针都属于同一类型。
但其他策略又如何呢?这里引出了第二条指导原则 —— 那些限制类使用的策略,其合理性应由试图避免的错误可能带来的代价来衡量。例如,真的需要一个“不可移动”策略吗?一方面,如果对象的所有权绝对不能转移,可能有助于避免编程错误。另一方面,开发者会简单地修改代码以使用可移动的指针。此外,还必须使用可移动的指针才能从工厂函数中按值返回。然而,“不可复制”策略通常合理,且应作为默认设置。例如,大多数容器默认不可复制有充分理由:复制大型数据集合几乎总是粗心编码的结果,通常发生在向函数传递参数时。
类似地,虽然从基本编码规范的角度来看,避免隐式转换为裸指针可能可取,但总有一种方式可以显式地将智能指针转换为裸指针 —— 无论如何,&*p 总是有效的。再次强调,精心限制接口带来的好处可能并不足以证明添加此策略的合理性。然而,它作为一个紧凑的学习示例非常出色,展示了可用于创建更复杂、更有用策略的一系列技术,因此我们花时间学习这个策略的工作原理完全值得。
当一个影响公共接口的策略证明是合理时,必须在基于约束的策略(限制现有成员函数)和基于 CRTP 的策略(添加成员函数)之间做出选择。通常情况下,依赖约束的设计更可取,即使在 C++20 之前需要使用“伪概念”。然而,这种方法不能用于向类中添加成员变量,只能用于成员函数。
另一种思考“正确策略集是什么”,以及“哪些策略应被拆分为独立组”的方法,是回到基于策略设计的根本优势 —— 由不同策略表达的行为的可组合性。如果有一个类,拥有四个不同的策略,每个策略又有四种不同的实现,这个类就有 256 种不同的版本。当然,不太可能需要全部 256 种。但关键在于,当实现该类时,并不知道将来实际需要哪些版本。可以猜测并仅实现最可能用到的少数几个。如果猜错了,就会导致大量代码重复和复制粘贴。而基于策略的设计,使我们有能力实现行为的任意组合,而无需在前期显式地编写所有组合。
现在理解了基于策略设计的这一优势,就可以用它来评估特定的策略集 —— 是否需要可组合?我们是否需要以不同方式组合?如果某些策略总是以特定组合或组的形式出现,这就需要从一个主要的用户指定策略中自动推导出这些策略。另一方面,一组可以任意组合的、很大程度上相互独立的策略,很可能是一组好的策略。
解决基于策略设计某些弱点的另一种方法是,尝试通过不同的手段达成相同目标。策略所提供的全部功能都无可替代 —— 策略模式的存在自有其道理。然而,也存在一些替代模式,提供表面上的相似性,可能用于解决基于策略设计所应对的某些问题。我们将在第16章中讨论装饰器时看到这样一个替代方案。它没有那么通用,但当它适用时,可以在避免一些问题的同时,提供策略的所有优势,特别是在可组合性方面。