经典的策略模式是一种行为型设计模式,允许在运行时从一组预定义的算法中选择特定算法,以实现某种具体行为。该模式也称为策略模式;这一名称早于其在C++泛型编程中的应用。策略模式的目标是提高设计的灵活性。
经典的面向对象策略模式中,选择具体使用哪个算法的决策被推迟到运行时。
与许多经典设计模式类似,C++中的泛型编程将这一思路应用于编译时的算法选择 —— 允许通过从一组相关且兼容的算法中进行选择,在编译时对系统行为的特定方面进行定制。接下来,将介绍在C++中实现基于策略类的基础知识,然后进一步研究更复杂多样的基于策略的设计方法。
当设计一个系统,该系统需要执行某些操作,但这些操作的具体实现尚不确定、存在多种可能或在系统实现后可能发生变化时 —— 我们知道系统“做什么”(what),但不知道“如何做”(how)时 —— 就应该考虑使用策略模式。
同样,编译时策略(或称策略)是一种实现类的方式:该类具有特定的功能,但实现该功能的方法有多种。
在本章中,将设计一个智能指针类,用以说明使用策略的不同方式。智能指针除了策略之外,还有许多其他必需和可选的功能,但不会在本章中一一涵盖。关于智能指针的完整实现,可以参考 C++ 标准库中的智能指针(如 unique_ptr 和 shared_ptr)、Boost 库中的智能指针,或 Loki 库中的智能指针(http://loki-lib.sourceforge.net/)。本章的内容将帮助你理解这些库的设计者所做出的取舍,以及如何设计自己的策略类。
一个非常简化的智能指针初始实现如下所示:
// Example 01
template <typename T> T* p_;
class SmartPtr {
public:
explicit SmartPtr(T* p = nullptr) : p_(p) {}
~SmartPtr() {
delete p_;
}
T* operator->() { return p_; }
const T* operator->() const { return p_; }
T& operator*() { return *p_; }
const T& operator*() const { return *p_; }
SmartPtr(const SmartPtr&) = delete;
SmartPtr& operator=(const SmartPtr&) = delete;
SmartPtr(SmartPtr&& that) :
p_(std::exchange(that.p_, nullptr)) {}
SmartPtr& operator=(SmartPtr&& that) {
delete p_;
p_ = std::exchange(that.p_, nullptr);
}
};
这个指针具有从同类型原始指针构造的构造函数,以及指针通常所需的运算符,即 * 和 →。这里最值得关注的部分是析构函数 —— 当该指针销毁时,会自动删除所指向的对象(在删除前无需检查指针是否为空;C++ 要求 delete 操作符能够接受空指针并安全地不执行操作),所以智能指针的典型用法为:
// Example 01
Class C { ... };
{
SmartPtr<C> p(new C);
... use p ...
} // *p 会自动销毁
这是一个典型的 RAII类示例。在这个例子中,RAII 对象 —— 即智能指针 —— 拥有资源(被构造的对象),并在其自身被销毁时释放(删除)该资源。正如第5章中详细讨论的那样,这种模式的常见应用场景是确保在某个作用域内构造的对象,无论以何种方式退出该作用域(例如,代码中间抛出异常),都能得到销毁。RAII 的析构函数机制保证了。
该智能指针还有两个成员函数,但这里关注的不是其实现,而是其缺失 —— 该指针设计为不可复制,其复制构造函数和赋值运算符都禁用了。这个细节有时会被忽略,但对于 RAII 类都至关重要:由于指针的析构函数会删除其所拥有的对象,绝不能出现两个智能指针指向并试图删除同一个对象的情况。另一方面,移动指针是一个合法的操作:它将所有权从旧指针转移到新指针。移动构造函数对于工厂函数的正常工作是必要的(至少在 C++17 之前)。
我们目前拥有的这个指针虽然功能可用,但其实现方式存在限制,只能拥有并删除通过标准 new 操作符创建的单个对象。虽然也可以获取通过自定义 operator new 分配的指针或指向数组元素的指针,但无法正确地删除这类对象。
我们可以为在用户自定义堆上创建的对象,实现一个不同的智能指针,为在使用端管理内存中创建的对象实现另一个智能指针,为每种对象创建方式,及其对应的删除方式都实现一个专用的智能指针。然而,这些指针的大部分代码是重复的 —— 其本质上都是指针,所有类似指针的 API 都必须在每个类中复制一遍。可以观察到,所有这些不同的类从根本上属于同一种类型 —— 回答“这是什么类型?”这个问题时,答案始终相同:是一个智能指针。
唯一的区别在于删除操作的具体实现方式。这种共同的意图,加上行为某个特定方面的差异,提示我们可以使用策略模式。可以实现一个更通用的智能指针,将如何处理对象删除的细节,委托给多种删除策略中的一种来完成:
// Example 02
template <typename T, typename DeletionPolicy>
class SmartPtr {
T* p_;
DeletionPolicy deletion_policy_;
public:
explicit SmartPtr(
T* p = nullptr,
const DeletionPolicy& del_policy = DeletionPolicy()) :
p_(p), deletion_policy_(del_policy)
{}
~SmartPtr() {
deletion_policy_(p_);
}
T* operator->() { return p_; }
const T* operator->() const { return p_; }
T& operator*() { return *p_; }
const T& operator*() const { return *p_; }
SmartPtr(const SmartPtr&) = delete;
SmartPtr& operator=(const SmartPtr&) = delete;
SmartPtr(SmartPtr&& that) :
p_(std::exchange(that.p_, nullptr)),
deletion_policy_(std::move(deletion_policy_))
{}
SmartPtr& operator=(SmartPtr&& that) {
deletion_policy_(p_);
p_ = std::exchange(that.p_, nullptr);
deletion_policy_ = std::move(deletion_policy_);
}
};
删除策略是一个模板参数,删除策略类型的对象会传递给智能指针的构造函数(默认情况下,会构造一个该类型的默认对象)。这个删除策略对象存储在智能指针内部,并在其析构函数中用来删除指针所指向的对象。
在为这类基于策略的类实现复制和移动构造函数时必须格外小心:很容易忘记策略本身也需要复制或移动到新对象中。我们的例子中,会禁用复制操作,但支持移动操作。移动操作不仅需要移动指针本身,还必须移动策略对象。像处理其他类成员一样处理即可:通过移动该对象来完成(对于指针这类内置类型,移动操作更复杂一些,但所有类都假定能正确处理移动操作,或明确删除它们)。赋值操作符中,要记住必须使用当前(即旧的)策略来删除指针当前拥有的对象;只有完成这一步后,才能从赋值操作的右侧对象移动策略过来。
对删除策略类型唯一的要求是它必须可调用 —— 该策略会像函数一样调用,接受一个参数,即指向需要删除对象的指针。例如,原始指针中调用 operator delete 的行为,可以通过以下删除策略来实现:
// Example 02
template <typename T>
struct DeleteByOperator {
void operator()(T* p) const {
delete p;
}
};
要使用此策略,必须在构造智能指针时指定其类型,并且可以选择性地将该类型的对象传递给构造函数。尽管在本例中,默认构造的对象就已足够:
class C { ... };
SmartPtr<C, DeleteByOperator<C>> p(new C(42));
在 C++17 中,构造函数模板参数推导(CTAD)通常可以自动推导出模板参数:
class C { ... };
SmartPtr p(new C(42));
如果删除策略与对象类型不匹配,编译器会在 operator() 的无效调用处报告语法错误。这通常并不理想:错误信息往往不够友好,而且策略所需满足的条件必须从模板代码中,对策略的使用方式去推断(我们的策略目前只有一个要求,但这是第一个也是最简单的策略)。编写带策略的类时的一个良好实践是,在一个明确的位置显式地验证并记录所有对策略的要求。在 C++20 中,这可以通过概念来实现:
// Example 03
template <typename T, typename F> concept Callable1 =
requires(F f, T* p) { { f(p) } -> std::same_as<void>; };
template <typename T, typename DeletionPolicy>
requires Callable1<T, DeletionPolicy>
class SmartPtr {
...
};
在 C++20 之前,可以通过编译时断言实现同样的效果:
// Example 04
template <typename T, typename DeletionPolicy>
requires Callable1<T, DeletionPolicy>
class SmartPtr {
...
static_assert(std::is_same<
void, decltype(deletion_policy_(p_))>::value, "");
};
即使在 C++20 中,可能仍然更倾向于使用断言产生的错误信息。这两种方式都达到了相同的目的:验证策略是否满足所有要求,同时以一种可读的方式,在代码的同一位置清晰地表达了这些要求。
至于是否将“可移动”包含在这些要求中,由开发者自行决定:只有当你需要移动智能指针本身时,策略才必须可移动。因此,允许存在不可移动的策略,并且仅在确实需要时才要求支持移动操作,这是一种合理的设计选择。
对于以不同方式分配的对象,需要其他类型的删除策略。如果一个对象是在用户提供的堆对象上创建的,而该堆对象的接口包含 allocate() 和 deallocate() 成员函数,分别用于分配和释放内存,则可以使用以下基于堆的删除策略:
// Example 02
template <typename T> struct DeleteHeap {
explicit DeleteHeap(Heap& heap) : heap_(heap) {}
void operator()(T* p) const {
p->~T();
heap_.deallocate(p);
}
private:
Heap& heap_;
};
另一方面,如果一个对象是在由调用者单独管理的内存中构造的,只需要调用该对象的析构函数即可:
// Example 02
template <typename T> struct DeleteDestructorOnly {
void operator()(T* p) const {
p->~T();
}
};
由于策略是作为可调用实体使用的(如 deletion_policy_(p_)),所以其是能够像函数一样调用的类型,包括真正的函数:
// Example 02
using delete_int_t = void (*)(int*);
void delete_int(int* p) { delete p; }
SmartPtr<int, delete_int_t> p(new int(42), delete_int);
这体现了 C++ 泛型编程的强大:策略可以是函数对象(仿函数)、函数指针、Lambda 表达式,甚至是带有 operator() 的类型,只要满足可调用且接口匹配的要求即可。这种灵活性使得策略模式在编译时,能够适应各种不同的行为定制需求。
template <typename T> void delete_T(T* p) { delete p; }
SmartPtr<int, delete_int_t> p(new int(42), delete_T<int>);
所有可能的删除策略中,有一种通常是最常用的。大多数程序中,很可能就是使用默认的 operator delete 函数进行删除。如果是这种情况,则每次使用时都指定这个策略就显得多余,所以将其设为默认值是合理的选择:
// Example 02
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
...
};
现在,我们的基于策略的智能指针,可以像最初只有一个删除选项的版本一样使用:
SmartPtr<C> p(new C(42));
第二个模板参数采用其默认值 DefaultDelete<T>,并且构造函数使用该类型的默认构造对象作为第二个参数的默认值。
在此,注意在实现此类基于策略的类时,可能犯的一个错误。策略对象在智能指针的构造函数中通过常量引用(const reference)捕获:
explicit SmartPtr(T* p = nullptr,
const DeletionPolicy& del_policy = DeletionPolicy());
这里的 const 引用很重要,非常量引用无法绑定到临时对象(将在本节后面讨论右值引用)。然而,策略通过值的方式存储在对象内部的,必须对策略对象进行一次复制:
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
T* p_;
DeletionPolicy deletion_policy_;
...
};
避免这次复制,转而尝试在智能指针内部也通过引用捕获策略,这种做法也颇具诱惑力:
// Example 05
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
T* p_;
const DeletionPolicy& deletion_policy_;
...
};
某些情况下,这甚至可以正常工作:
Heap h;
DeleteHeap<C> del_h(h);
SmartPtr<C, DeleteHeap<C>> p(new (&heap) C, del_h);
然而,当以默认方式创建智能指针,或使用临时策略对象初始化智能指针时,这种方式将无法正常工作:
SmartPtr<C> p(new C, DeleteByOperator<C>());
这段代码能够编译,但它是错误的 —— 临时的 DeleteByOperator<C> 对象在调用 SmartPtr 构造函数之前创建,但在语句结束时就销毁。SmartPtr 对象内部的引用,因此变成了悬空引用。
乍一看,这应该不会让人感到意外 —— 当然,临时对象的生命周期不会超过其创建所在的语句,最迟在遇到分号时就会删除。
对语言细节更熟悉的读者可能会问 —— 标准不是明确,延长了绑定到常量引用临时对象的生命周期吗?确实如此:
{
const C& c = C();
... c 并非是一个悬空引用! ...
} // 临时变量在这里删除
临时对象 C() 并不会在语句结束时销毁,而只会在其所绑定的引用的生命周期结束时才销毁。那么,为什么同样的技巧对我们的删除策略对象不起作用呢?答案是:确实起作用了 —— 在构造函数参数求值时创建的临时对象,当其绑定到 const 引用参数时,其生命周期并未在该参数的作用域结束时终止,而是延续到了整个构造函数调用期间。实际上,无论如何都不会提前销毁 —— 所有在函数参数求值期间创建的临时对象,都会在包含函数调用的语句结束时(即分号处)才销毁。这个例子中,函数就是对象的构造函数,这些临时对象的生命周期覆盖了整个构造函数调用过程。
然而,这种生命周期并不会延续到对象本身的生命周期。对象中的 const 引用成员并不是直接绑定到那个临时对象,而是绑定到了构造函数的参数(本身是一个 const 引用)。当构造函数返回后,这个参数引用就失效了,而成员引用指向的已是无效地址。
生命周期延长机制只作用一次 —— 只有直接绑定到临时对象的引用才能延长其生命周期。另一个引用如果只是绑定到第一个引用(而非原始临时对象),则不会产生延长效果;当原始临时对象销毁,这个二级引用就会变成悬空引用(GCC 和 CLANG 的地址 sanitizer(ASAN)有助于发现此类错误)。
如果策略对象需要作为智能指针的数据成员存储,就必须进行复制。通常,策略对象都很小,复制开销可以忽略不计。某些情况下,策略对象可能包含复杂的内部状态,复制代价较高;也可能设想存在不可复制的策略对象。此时,将参数对象移动到数据成员中就有意义了。可以通过声明一个类似于移动构造函数的重载来轻松实现:
// Example 06
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
T* p_;
DeletionPolicy deletion_policy_;
public:
explicit SmartPtr(T* p = nullptr,
DeletionPolicy&& del_policy = DeletionPolicy())
: p_(p), deletion_policy_(std::move(del_policy))
{}
...
};
策略对象通常都很小,因此复制它们很少会成为问题。如果确实需要同时提供复制和移动构造函数,请确保只有一个构造函数带有默认参数,以避免在调用不带参数或不带策略参数的构造函数时产生歧义。
现在,我们已经拥有一个只需实现一次的智能指针类,其删除行为可以在编译时通过指定不同的删除策略来自定义。甚至可以添加一个在设计该类时尚未存在的新删除策略,只要它符合相同的调用接口,就能正常工作。
接下来,将探讨实现策略对象的各种不同方式。
在上一节中,我们介绍了如何实现最简单的策略对象。只要符合接口约定,策略可以是任意类型,并作为数据成员存储在类中。策略对象最常见的是通过模板生成;但也可以是针对特定指针类型的普通非模板对象,甚至可以是函数。策略的使用通常局限于某个特定的行为方面,例如删除智能指针所拥有的对象。此类策略可以通过多种方式实现和使用。
首先,回顾一下带有删除策略的智能指针的声明:
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr { ... };
接下来,看看如何构造一个智能指针对象:
class C { ... };
SmartPtr<C, DeleteByOperator<C>> p(
new C(42), DeleteByOperator<C>());
这种设计的一个缺点立即显现 —— 对象 p 的定义中类型 C 出现了四次,这四处必须保持一致,否则代码将无法编译。C++17 允许对此定义进行一定程度的简化:
SmartPtr p(new C, DeleteByOperator<C>());
这里,构造函数用于从构造参数中推导类模板的参数,其方式类似于函数模板。但类型 C 仍然出现了两次,且必须保持一致。
另一种实现方式适用于无状态的策略,以及内部状态不依赖于主模板类型(例子中,即 SmartPtr 模板的类型 T)的策略对象:将策略本身设为非模板对象,但为其提供一个模板成员函数。例如,DeleteByOperator 策略是无状态的(对象没有数据成员),所以可以不用类模板来实现:
// Example 07
struct DeleteByOperator {
template <typename T> void operator()(T* p) const {
delete p;
}
};
这是一个非模板对象,不需要类型参数。成员函数模板会在需要删除的对象类型上进行实例化 —— 该类型由编译器推导得出。由于策略对象的类型始终相同,在创建智能指针对象时就无需担心类型的一致性问题:
// Example 07
SmartPtr<C, DeleteByOperator> p(
new C, DeleteByOperator()); // Before C++17
SmartPtr p(new C, DeleteByOperator()); // C++17
这个对象可以直接被我们的智能指针使用,无需对 SmartPtr 模板做修改,尽管希望更改默认的模板参数:
template <typename T,
typename DeletionPolicy = DeleteByOperator>
class SmartPtr { ... };
对于更复杂的策略,例如堆删除策略,仍然可以使用这种方法来实现:
struct DeleteHeap {
explicit DeleteHeap(SmallHeap& heap) : heap_(heap) {}
template <typename T> void operator()(T* p) const {
p->~T();
heap_.deallocate(p);
}
private:
Heap& heap_;
};
该策略具有内部状态 —— 即对堆的引用 —— 但此策略对象中除 operator() 成员函数外,没有内容依赖于删除对象的类型 T,所以该策略无需以对象类型进行参数化。
由于主模板 SmartPtr 在将策略从类模板转换为带有模板成员函数的非模板类时无需更改,因此需要在同一类中同时使用这两种类型的策略。事实上,前一小节中的类模板策略,仍然可以正常工作,所以可以将一些删除策略实现为类,而另一些实现为类模板。当策略的数据成员类型依赖于智能指针的对象类型时,后者尤其有用。
如果策略是作为类模板实现的,必须指定正确的类型来实例化策略,以便与每个特定的基于策略的类一起使用。这是一个非常重复的过程 —— 相同的类型被用来参数化主模板及其策略。如果使用整个模板而非其特定实例化作为策略,就可以让编译器完成这项工作:
// Example 08
template <typename T,
template <typename> class DeletionPolicy =
DeleteByOperator>
class SmartPtr {
public:
explicit SmartPtr(T* p = nullptr,
const DeletionPolicy<T>& del_policy =
DeletionPolicy<T>())
: p_(p), deletion_policy_(deletion_policy)
{}
~SmartPtr() {
deletion_policy_(p_);
}
...
};
请注意第二个模板参数的语法 —— template <typename> class DeletionPolicy,这称为双重模板参数 —— 即一个模板的参数本身也是一个模板。在 C++14 及更早版本中,必须使用 class 关键字;在 C++17 中,可以用 typename 替代。为了使用这个参数,需要用某种类型来实例化它;例子中,就是主模板的类型参数 T。这确保了主智能指针模板,及其策略中对象类型的一致性,尽管构造函数参数仍需用正确的类型来构造:
SmartPtr<C, DeleteByOperator> p(
new C, DeleteByOperator<C>());
再次强调,在 C++17 中,类模板参数可以通过构造函数推导出来;这也适用于双重模板参数:
SmartPtr p(new C, DeleteByOperator<C>());
当类型本身是从模板实例化而来时,双重模板参数似乎比常规类型参数更具吸引力。那么为什么不一直使用它们呢?首先,灵活性不如模板类参数:在策略是一个与类自身具有相同第一个参数的模板这种常见情况下,可以减少输入,但在其他情况下则无法使用(例如,策略可能是非模板,或需要多个参数的模板)。另一个问题是,双重模板参数有一个重要限制 —— 模板参数的数量必须与规范完全匹配,包括默认参数。换句话说,假设有以下模板:
template <typename T, typename Heap = MyHeap> class DeleteHeap { ... };
这个模板不能用作前述智能指针的参数 —— 有两个模板参数,而在 SmartPtr 的声明中只指定了一个(带有默认值的参数仍然是一个参数)。这个限制很容易解决:只需将双重模板参数定义为一个可变参数模板即可:
// Example 09
template <typename T,
template <typename...> class DeletionPolicy =
DeleteByOperator>
class SmartPtr {
...
};
现在,只要删除策略模板的类型参数具有默认值,其数量可以是任意的(在 SmartPtr 中使用的是 DeletionPolicy<T>,这必须能够成功编译)。
相比之下,我们可以将 DeleteHeap 模板的实例化用作删除策略是类型参数(而非双重模板参数)的智能指针 —— 只需要一个类,而 DeleteHeap<int, MyHeap> 和其他类一样适用。
目前,我们一直将策略对象作为基于策略的类的数据成员来捕获。这种将类集成到更大类中的方法称为组合。主模板还可以通过其他方式,获取策略所提供的定制化行为算法,接下来将探讨这些方法。
目前,我们所有的示例都将策略对象作为类的数据成员进行存储。这通常是存储策略的首选方式,但它有一个缺点 —— 数据成员的大小总是非零的。看一下我们使用某种删除策略的智能指针:
template <typename T> struct DeleteByOperator {
void operator()(T* p) const {
delete p;
}
};
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr {
T* p_;
DeletionPolicy deletion_policy_;
...
};
该策略对象没有数据成员,但该对象的大小并非为零,而是占一个字节(可以通过打印 sizeof(DeleteByOperator<int>) 的值来验证)。这是必要的,在 C++ 程序中,每个对象都必须具有唯一的地址
DeleteByOperator<int> d1; // &d1 = ....
DeleteByOperator<long> d2; // &d2 必须不等于 &d1
当两个对象在内存中连续布局时,它们地址之间的差值即为第一个对象的大小(必要时加上填充)。为了防止 d1 和 d2 对象位于同一地址,标准规定它们的大小至少为一个字节。
当作为另一个类的数据成员使用时,一个对象所占用的空间至少等于其自身大小,而在我们的情况下,这个大小为一个字节。假设指针占用 8 个字节,整个对象的长度就是 9 个字节。但对象的大小还必须根据对齐要求填充到最近的合适值 —— 如果指针的地址需要按 8 字节对齐,对象大小只能是 8 字节或 16 字节,而不能是中间值。因此,向类中添加一个空的策略对象,最终会导致其大小从 8 字节增加到 16 字节。这纯粹是内存的浪费,尤其是对于那些大量创建的对象(例如指针)。我们无法说服编译器创建一个大小为零的数据成员;标准禁止这样做。但还有另一种使用策略而无需额外开销的方法。
与组合相对的是继承 —— 可以将策略用作主类的基类:
// Example 10
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>>
class SmartPtr : private DeletionPolicy {
T* p_;
public:
explicit SmartPtr(T* p = nullptr,
DeletionPolicy&& deletion_policy = DeletionPolicy())
: DeletionPolicy(std::move(deletion_policy)), p_(p)
{}
~SmartPtr() {
DeletionPolicy::operator()(p_);
}
...
};
这种方法依赖于特定的优化 —— 如果一个基类是空的(没有非静态数据成员),可以完全从派生类的布局中优化掉。这称为空基类优化。C++ 标准允许该优化,通常不是强制要求的(C++11 对某些特定类要求此优化,但本章使用的类不在此列)。尽管不是强制的,现代编译器几乎普遍都会执行这种优化。通过空基类优化,派生类 SmartPtr 的大小仅需容纳其数据成员即可 —— 在我们的情况下,仅为 8 字节。
在使用继承实现策略时,必须在公有继承和私有继承之间做出选择。通常,策略用于提供特定行为方面的实现。这种为实现而进行的继承应通过私有继承来表达。有时,策略可能用于改变类的公有接口;此时应使用公有继承。对于删除策略,并未改变类的接口 —— 智能指针总是在其生命周期结束时删除对象;唯一的区别在于删除的方式,所以删除策略应使用私有继承。
虽然使用 operator delete 的删除策略是无状态的,但某些策略具有必须从构造函数参数保留下来的数据成员。因此,基类策略应通过复制或移动构造函数参数来初始化,这与我们初始化数据成员的方式类似。基类总是在成员初始化列表中优先于派生类的数据成员进行初始化。最后,可以使用 base_type::function_name() 的语法来调用基类的成员函数;在例子中,即 DeletionPolicy::operator()(p_)。
继承和组合是将策略类集成到主类中的两种选择。一般情况下,应优先选择组合,除非有使用继承的充分理由。我们已经看到其中一个理由 —— 空基类优化。如果希望影响类的公有接口,继承也是一个必要的选择。
现在,我们的智能指针还缺少一些在大多数智能指针实现中常见的关键功能。其中一个功能是释放指针的能力,即阻止对象被自动销毁。如果在某些情况下对象通过其他方式销毁,或者需要延长对象的生命周期,并将所有权转移给另一个资源持有对象,这一功能就非常有用。我们可以将此功能添加到智能指针中:
template <typename T,
typename DeletionPolicy>
class SmartPtr : private DeletionPolicy {
T* p_;
public:
~SmartPtr() {
if (p) DeletionPolicy::operator()(p_);
}
void release() { p_ = nullptr; }
...
};
可以对智能指针调用 p.release(),这样析构函数将不再执行操作。我们可以将释放功能硬编码到指针中,但有时可能希望强制执行删除操作,不允许释放。这就需要将释放功能设为可选的,由另一个策略来控制。可以添加一个 ReleasePolicy 模板参数来控制 release() 成员函数是否存在,但它应该做什么呢?当然,可以将 SmartPtr::release() 的实现移到策略中:
// Example 11
template <typename T> struct WithRelease {
void release(T*& p) { p = nullptr; }
};
现在,SmartPtr 的实现只需调用 ReleasePolicy::release(p_),即可将 release() 的相应处理委托给策略。但如果不想支持释放功能,什么才是合适的处理方式呢?我们的“无释放”策略可以简单地什么都不做,但这具有误导性 —— 用户会期望如果调用了 release(),对象就不会销毁。我们可以在运行时触发断言并终止程序。这会将开发者的逻辑错误(试图释放一个不允许释放的智能指针)转化为运行时错误。
最好的方法是,如果不需要该功能,SmartPtr 类根本就不要提供 release() 成员函数。这样,错误的代码将根本无法通过编译。实现的唯一方法是,让策略将一个新的公有成员函数注入到主模板的公有接口中。这可以通过公有继承来实现:
template <typename T,
typename DeletionPolicy,
typename ReleasePolicy>
class SmartPtr : private DeletionPolicy,
public ReleasePolicy {
...
};
现在,如果释放策略有一个名为 release() 的公有成员函数,那么 SmartPtr 类也会拥有这个函数。
这解决了接口问题,还有一个小的实现问题需要处理。release() 成员函数现在已移入策略类中,必须操作父类的 p_ 数据成员。一种方法是在构造期间,将指向该指针的引用从派生类传递给基类策略。这是一种丑陋的实现方式 —— 浪费了 8 个字节的内存来存储对一个几乎“触手可及”的数据成员的引用,而该数据成员就存储在派生类中,紧邻基类本身。更好的方法是从基类向正确的派生类进行类型转换。当然,要使此方法奏效,基类需要知道正确的派生类是什么。解决此问题的方法是我们在本书中研究过的“奇异递归模板模式”:策略应是一个模板(因此需要一个模板模板参数),并用派生类的类型进行实例化。
通过这种方式,SmartPtr 类既是释放策略的派生类,又是该策略模板的模板参数:
// Example 11
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
template <typename...> class ReleasePolicy =
WithRelease>
class SmartPtr : private DeletionPolicy,
public ReleasePolicy<SmartPtr<T,
DeletionPolicy, ReleasePolicy>>
{ ... };
ReleasePolicy 模板是用 SmartPtr 模板的具体实例化类型来特化的,这包括了所有的策略,也包括 ReleasePolicy 本身。
现在,释放策略知道了派生类的类型,并可以将自身安全地转换为该类型。这种情况是安全的,在构造时就保证了正确的派生类类型:
// Example 11
template <typename P> struct WithRelease {
void release() { static_cast<P*>(this)->p_ = nullptr; }
};
模板参数 P 将替换为智能指针的类型。当智能指针公有继承自释放策略,策略的公有成员函数 release() 就会继承,并成为智能指针公有接口的一部分。
关于释放策略实现的最后一个细节是访问权限问题。根据目前的写法,SmartPtr 类中的数据成员 p_ 是私有的,基类无法直接访问它。解决此问题的方法是,将相应的基类声明为派生类的友元:
// Example 11
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
template <typename...> class ReleasePolicy =
WithRelease>
class SmartPtr : private DeletionPolicy,
public ReleasePolicy<SmartPtr<T, DeletionPolicy,
ReleasePolicy>>
{
friend class ReleasePolicy<SmartPtr>;
T* p_;
...
};
在 SmartPtr 类的内部,无需重复所有的模板参数。简写形式 SmartPtr 指代当前正在实例化的模板。但这种简写不适用于类声明中左花括号 { 之前的部分,所以将策略指定为基类时,需要重复模板参数。
“不释放”策略的编写也同样简单:
// Example 11
template <typename P> struct NoRelease {};
这里没有 release() 函数,尝试在使用此策略的智能指针上调用 release() 将无法通过编译。这满足了我们的要求:仅在合理的情况下,智能指针才拥有 release() 公有成员函数。
基于策略的设计是一种复杂的模式,通常不会局限于单一的实现方式。我们还有另一种方法可以达到相同的目标,这将在本章后面的小节中进行研究。
策略对象的使用还存在另一种方式,这种方式仅适用于那些在设计上版本都无内部状态的策略。例如,删除策略有时无状态,但那个包含对调用者堆引用的策略则不是,因此并非总是无状态的。释放策略始终可以视为无状态的;我们没有理由为其添加数据成员,但它受限于必须通过公有继承使用,其主要作用是向主类注入一个新的公有成员函数。
考虑另一个可能需要自定义的行为方面 —— 调试或日志记录。出于调试目的,打印出智能指针拥有对象何时,以及何时删除可能会很直观。我们可以为智能指针添加一个调试策略来支持此功能。调试策略只需做一件事,即在智能指针构造或销毁时打印一些信息。如果向打印函数传递指针的值,就不需要访问智能指针本身。因此,可以将调试策略中的打印函数设为静态,并且完全不在智能指针类中存储该策略对象:
// Example 12
template <typename T,
typename DeletionPolicy,
typename DebugPolicy = NoDebug>
class SmartPtr : private DeletionPolicy {
T* p_;
public:
explicit SmartPtr(T* p = nullptr,
DeletionPolicy&& deletion_policy = DeletionPolicy())
: DeletionPolicy(std::move(deletion_policy)), p_(p) {
DebugPolicy::constructed(p_);
}
~SmartPtr() {
DebugPolicy::deleted(p_);
DeletionPolicy::operator()(p_);
}
...
};
为简单起见,省略了释放策略,但多个策略很容易组合。调试策略的实现非常直接:
// Example 12
struct Debug {
template <typename T>
static void constructed(const T* p) {
std::cout << "Constructed SmartPtr for object " <<
static_cast<const void*>(p) << std::endl;
}
template <typename T>
static void deleted(const T* p) {
std::cout << "Destroyed SmartPtr for object " <<
static_cast<const void*>(p) << std::endl;
}
};
我们选择将该策略实现为,一个带有模板静态成员函数的非模板类。或者,也可以将其实现为一个以对象类型 T 为参数的模板类。该策略的“无调试”版本(即默认版本)更为简单。必须定义相同的函数,但这些函数不执行操作:
// Example 12
struct NoDebug {
template <typename T>
static void constructed(const T* p) {}
template <typename T> static void deleted(const T* p) {}
};
可以期望编译器在调用点内联这些空的模板函数,并且由于无需生成代码,整个调用都会优化消除。
通过选择这种策略实现方式,做出了一项较为严格的設計决策 —— 所有版本的调试策略都必须是无状态的。将来,如果需要在调试策略内部存储一个自定义的输出流(而非默认的 std::cout),可能会为此决定感到后悔。但即便如此,也只需要修改智能指针类的实现 —— 使用端代码无需更改即可继续正常工作。
已经探讨了将策略对象融入基于策略类的三种不同方式:
组合:将策略作为数据成员存储。
继承:将策略作为基类(公有或私有继承)。
仅编译时融入:策略对象在运行时无需存储在主对象内部,仅通过静态函数等方式在编译时提供行为。
接下来,我们将继续介绍更高级的基于策略的设计技术。