我们在上一节中介绍的技术构成了基于策略设计的基础 —— 策略可以是类、模板实例化,或模板(通过双重模板参数使用)。策略类可以通过组合、继承或在编译时静态使用。如果策略需要知道主策略类的类型,可以使用 CRTP(奇异递归模板模式)。其余内容大多是这些主题的变体,以及巧妙结合多种技术以实现新功能的方法。现在,我们将探讨其中一些更高级的技术。
策略可用于自定义实现的几乎所有方面,也可用于修改类的接口。当尝试使用策略来定制类的构造函数时,会出现一些独特的挑战。
考虑当前智能指针的另一个限制,当智能指针销毁时,所拥有的对象总是删除。如果智能指针支持 release,可以调用 release() 成员函数,并完全负责该对象的删除。但如何确保这一删除操作的执行呢?最可能的方式是,我们将让另一个智能指针来拥有它:
SmartPtr<C> p1(new C);
SmartPtr<C> p2(&*p1); // 现在两个指针指向一个对象
p1.release();
这种方法既冗长又容易出错 —— 暂时让两个指针同时拥有同一个对象。如果此时发生某些情况导致这两个指针都销毁,将对同一对象进行两次销毁。还必须记得始终释放其中一个指针,但只能释放一个。应该从更高层次看待这个问题 —— 将对象的所有权从一个智能指针转移到另一个智能指针。
更好的方法是将第一个指针移动到第二个指针中:
SmartPtr<C> p1(new C);
SmartPtr<C> p2(std::move(p1));
现在,第一个指针处于“已移动”状态,可以对此进行定义(唯一的要求是析构函数调用必须有效)。我们选择将其定义为不拥有对象的指针,即处于已释放状态的指针。第二个指针则获得对象的所有权,并会在适当的时候将其删除。
为了支持此功能,必须实现移动构造函数。但有时可能有理由阻止所有权的转移,所以我们可能希望同时拥有可移动和不可移动的指针。这又需要另一个策略来控制是否支持移动操作:
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename MovePolicy = MoveForbidden
>
class SmartPtr ...;
为简单起见,我们恢复到仅使用另一个策略 —— 删除策略。之前考虑的其他策略可以与新的 MovePolicy 一同添加,删除策略可以用已学过的方式实现。由于可能受益于空基类优化,可继续采用基于继承的实现方式。移动策略可以用几种不同的方式实现,但继承可能最简单:
// Example 13
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy,
private MovePolicy {
T* p_;
public:
explicit SmartPtr(T* p = nullptr,
DeletionPolicy&& deletion_policy = DeletionPolicy())
: DeletionPolicy(std::move(deletion_policy)),
MovePolicy(), p_(p) {}
... SmartPtr code unchanged ...
SmartPtr(SmartPtr&& that) :
DeletionPolicy(std::move(that)),
MovePolicy(std::move(that)),
p_(std::exchange(that.p_, nullptr)) {}
SmartPtr(const SmartPtr&) = delete;
};
通过使用私有继承整合两个策略,现在得到一个具有多个基类的派生类。这种多重继承在 C++ 的基于策略设计中相当常见,无需担心。这种技术有时称为 mix-in,派生类的实现由基类提供的部分混合而成。在 C++ 中,“mix-in”一词也用于指代一种与 CRTP 相关的完全不同继承方案,该术语的使用常常引起混淆(大多数面向对象语言中,“mix-in”明确指代在此看到的多重继承应用)。
我们智能指针类的新特性是移动构造函数,移动构造函数无条件地存在于 SmartPtr 类中。然而,其实现要求所有基类都必须是可移动的。这为我们提供了一种通过不可移动的移动策略,来禁用移动支持的方法:
// Example 13
struct MoveForbidden {
MoveForbidden() = default;
MoveForbidden(MoveForbidden&&) = delete;
MoveForbidden(const MoveForbidden&) = delete;
MoveForbidden& operator=(MoveForbidden&&) = delete;
MoveForbidden& operator=(const MoveForbidden&) = delete;
};
可移动策略则简单得多:
// Example 13
struct MoveAllowed {
};
现在可以构造一个可移动的指针和一个不可移动的指针:
class C { ... };
SmartPtr<C, DeleteByOperator<C>, MoveAllowed> p = ...;
auto p1(std::move(p)); // 没问题
SmartPtr<C, DeleteByOperator<C>, MoveForbidden> q = ...;
auto q1(std::move(q)); // 无法编译
尝试移动一个不可移动的指针会编译失败,其中一个基类 MoveForbidden 不可移动(没有移动构造函数)。前面的例子中,移动过的指针 p 可以安全地销毁,但不能以其他方式使用。特别是,不能解引用。
当处理可移动指针时,提供一个移动赋值运算符也十分合理:
// Example 13
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy,
private MovePolicy {
T* p_;
public:
explicit SmartPtr(T* p = nullptr,
DeletionPolicy&& deletion_policy = DeletionPolicy())
: DeletionPolicy(std::move(deletion_policy)),
MovePolicy(), p_(p) {}
... SmartPtr code unchanged ...
SmartPtr& operator=(SmartPtr&& that) {
if (this == &that) return *this;
DeletionPolicy::operator()(p_);
p_ = std::exchange(that.p_, nullptr);
DeletionPolicy::operator=(std::move(that));
MovePolicy::operator=(std::move(that));
return *this;
}
SmartPtr& operator=(const SmartPtr&) = delete;
};
请注意对自赋值的检查。与复制赋值不同(标准要求自赋值时什么也不做),移动赋值受到的约束较少。唯一确定的要求是,自移动后对象必须处于一个明确定义的状态(“已移动”状态就是这种状态的一个例子)。不做操作的自移动并非必需,但也有效。另外,请注意基类的移动赋值方式 —— 最简单的方法是直接调用每个基类的移动赋值运算符。无需将派生类显式转换为各个基类类型 —— 这是隐式执行的转换。绝不能忘记将被移动的指针设置为 nullptr,否则,这些指针所拥有的对象将删除两次。
为简单起见,我们忽略了之前介绍的所有策略。并非所有设计都需要所有功能都由策略控制,而且无论如何,组合多个策略都相当直接。但这是一个很好的机会来指出,不同的策略有时是相关的 —— 如果同时使用释放策略和移动策略,使用可移动的移动策略强烈暗示对象必须支持释放(释放的指针类似于“已移动”状态的指针),可以使用模板元编程来强制策略之间的这种依赖关系。
一个需要禁用或启用构造函数的策略,并不一定非得用作基类 —— 移动赋值或构造也会移动所有数据成员,所以一个不可移动的数据成员同样可以禁用移动操作。在这里使用继承更重要的原因是空基类优化:如果在类中引入一个 MovePolicy 数据成员,将使对象大小在 64 位机器上从 8 字节增加到 16 字节。
我们已经考虑了让指针可移动。但复制呢?我们完全禁止了复制 —— 在我们的智能指针中,复制构造函数和复制赋值运算符从一开始就删除了。这很合理 —— 我们不希望两个智能指针拥有同一个对象并将其删除两次。但还有另一种所有权类型,复制操作在这种情况下完全合理 —— 即共享所有权,比如通过引用计数的共享指针实现的那样。在这种类型的指针中,允许复制指针,现在两个指针平等地拥有所指向的对象。通过维护一个引用计数来统计程序中指向同一对象的指针数量。当拥有特定对象的最后一个指针删除时,该对象本身也会删除,因为不再有对该对象的引用。
实现引用计数的共享指针有几种方法,但先从类及其策略的设计开始。仍然需要一个删除策略,并且由单个策略来控制移动和复制操作是合理的。为简单起见,将再次仅限于当前正在探讨的策略:
// Example 14
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename CopyMovePolicy = NoMoveNoCopy
>
class SmartPtr : private DeletionPolicy,
public CopyMovePolicy {
T* p_;
public:
explicit SmartPtr(T* p = nullptr,
DeletionPolicy&& deletion_policy = DeletionPolicy())
: DeletionPolicy(std::move(deletion_policy)), p_(p)
{}
SmartPtr(SmartPtr&& other) :
DeletionPolicy(std::move(other)),
CopyMovePolicy(std::move(other)),
p_(std::exchange(that.p_, nullptr)) {}
SmartPtr(const SmartPtr& other) :
DeletionPolicy(other),
CopyMovePolicy(other),
p_(other.p_) {}
~SmartPtr() {
if (CopyMovePolicy::must_delete())
DeletionPolicy::operator()(p_);
}
};
复制操作不再无条件地删除。现在提供了复制和移动构造函数(为简洁起见,省略了两个赋值运算符,但其实现方式应与之前相同)。
智能指针析构函数中对对象的删除不再无条件进行 —— 在引用计数指针的情况下,复制策略维护引用计数,并知道何时针对特定对象仅存在一个智能指针副本。
智能指针类本身为策略类规定了要求。不可移动、不可复制的策略必须禁止所有复制和移动操作:
// Example 14
class NoMoveNoCopy {
protected:
NoMoveNoCopy() = default;
NoMoveNoCopy(NoMoveNoCopy&&) = delete;
NoMoveNoCopy(const NoMoveNoCopy&) = delete;
NoMoveNoCopy& operator=(NoMoveNoCopy&&) = delete;
NoMoveNoCopy& operator=(const NoMoveNoCopy&) = delete;
constexpr bool must_delete() const { return true; }
};
此外,不可复制的智能指针总是在其析构函数中删除其所拥有的对象,因此 must_delete() 成员函数应始终返回 true。所有复制策略都必须实现此函数,即使实现非常简单,否则智能指针类将无法编译。可以完全期待编译器优化掉对该函数的调用,并在此策略被使用时无条件地调用析构函数。
仅移动策略与之前提到的可移动策略类似,但现在必须显式地启用移动操作并禁用复制操作:
// Example 14
class MoveNoCopy {
protected:
MoveNoCopy() = default;
MoveNoCopy(MoveNoCopy&&) = default;
MoveNoCopy(const MoveNoCopy&) = delete;
MoveNoCopy& operator=(MoveNoCopy&&) = default;
MoveNoCopy& operator=(const MoveNoCopy&) = delete;
constexpr bool must_delete() const { return true; }
};
同样,删除操作是无条件的(智能指针对象内部的指针在对象被移动后可能为空,但这并不妨碍我们对其调用 operator delete)。该策略允许移动构造函数和移动赋值运算符进行编译;SmartPtr 类为这些操作提供了正确的实现,策略本身无需提供支持。
引用计数复制策略则要复杂得多,我们需要确定共享指针的实现方式。最简单的实现是在单独的内存分配中分配引用计数器,该计数器由复制策略进行管理。先从不允许移动操作的引用计数复制策略开始:
// Example 14
class NoMoveCopyRefCounted {
size_t* count_;
protected:
NoMoveCopyRefCounted() : count_(new size_t(1)) {}
NoMoveCopyRefCounted(const NoMoveCopyRefCounted& other) :
count_(other.count_)
{
++(*count_);
}
NoMoveCopyRefCounted(NoMoveCopyRefCounted&&) = delete;
~NoMoveCopyRefCounted() {
--(*count_);
if (*count_ == 0) {
delete count_;
}
}
bool must_delete() const { return *count_ == 1; }
};
当使用此复制策略构造智能指针时,会分配一个新的引用计数器并将其初始化为 1(有一个智能指针指向特定对象 —— 即正在构造的这个)。当智能指针复制时,其所有基类(包括复制策略)也会复制。该策略的复制构造函数只是简单地将引用计数加 1。当智能指针删除时,引用计数会递减。最后一个删除的智能指针还会同时删除计数器本身。复制策略还控制着所指向对象的删除时机 —— 当引用计数降至 1 时,即将删除指向该对象的最后一个指针,此时对象将被删除。当然,确保在调用 must_delete() 函数之前计数器不会删除非常重要。是得到保证的,因为基类的析构函数会在派生类析构函数之后运行 —— 最后一个智能指针的派生类析构时看到计数器值为 1,于是删除对象;随后,复制策略的析构函数再将计数器减 1,使其降为 0,并删除计数器本身。
使用此策略,我们可以实现对象的共享所有权:
SmartPtr<C, DeleteByOperator<C>, NoMoveCopyRefCounted> p1{new C};
auto p2(p1);
现在,我们有了两个指向同一对象的指针,引用计数为二。当这两个指针中的最后一个删除时(假设在此之前没有创建更多副本),该对象就会删除。这个智能指针是可复制的,但不可移动:
SmartPtr<C, DeleteByOperator<C>, NoMoveCopyRefCounted> p1{new C};
auto p2(std::move(p1)); // 无法编译
当支持了引用计数的复制功能,就没有理由再禁止移动操作了,除非这些操作根本不需要(这种情况下,不支持移动的实现可能会略微更高效一些)。为了支持移动,必须仔细考虑引用计数策略在移动后的状态 —— 当它删除时,不能再递减引用计数器,一个已移动的指针不再拥有该对象。最简单的方法是重置指向引用计数器的指针,使其无法再从复制策略中访问。但这样一来,复制策略就必须能够处理计数器指针为空的特殊情况:
// Example 15
class MoveCopyRefCounted {
size_t* count_;
protected:
MoveCopyRefCounted() : count_(new size_t(1)) {}
MoveCopyRefCounted(const MoveCopyRefCounted& other) :
count_(other.count_)
{
if (count_) ++(*count_);
}
~MoveCopyRefCounted() {
if (!count_) return;
--(*count_);
if (*count_ == 0) {
delete count_;
}
}
MoveCopyRefCounted(MoveCopyRefCounted&& other) :
count_(std::exchange(other.count_, nullptr)) {}
bool must_delete() const {
return count_ && *count_ == 1;
}
};
最后,引用计数复制策略还必须支持赋值操作。这些操作的实现方式与复制或移动构造函数类似(注意,在将新值赋给策略之前,应使用左侧操作数的策略来删除左侧对象)。
一些策略的实现可能相当复杂,而它们之间的交互则更为复杂。幸运的是,基于策略的设计特别适合编写可测试的对象。这种基于策略设计的应用非常重要,值得特别提及。
现在,我们将向读者展示如何使用基于策略的设计来编写更好的测试,可以通过在单元测试中替换特定于测试的策略版本来替代常规策略,从而使代码更具可测试性。让我们以前一小节中的引用计数策略为例来说明。
该策略的主要挑战当然是维护正确的引用计数。可以轻松地开发一些测试,以覆盖引用计数的所有边界情况:
// 测试1:只有一个指针
{
SmartPtr<C, ... policies ...> p(new C);
} // 此处C对象应该删除
// 测试2:一次拷贝
{
SmartPtr<C, ... policies ...> p(new C);
{
uto p1(p); // 引用计数应该为2
} // 此处C对象不应该删除
} // 此处C对象应该删除
真正困难的部分实际上是,测试所有这些代码是否按预期工作。我们知道引用计数应该是多少,但无法检查它实际的值(为智能指针添加一个公共的 count() 函数可以解决这个问题,但这只是其中最小的困难之一)。我们知道对象应该在何时删除,但很难验证它是否真的删除了。如果对象删除了两次,我们可能会遇到崩溃,但即便如此也不能完全确定。更难捕捉的是对象根本没有删除的情况。内存检测工具(如 sanitizer)可以发现此类问题,至少在使用标准内存管理时是如此,但这些工具并非在所有环境中都可用,而且除非测试专门设计为在检测工具下运行,否则可能会产生大量干扰信息。
幸运的是,我们可以利用策略为测试提供一个窥探对象内部工作状态的窗口。如果没有在所有引用计数策略中实现公共的 count() 方法,可以为引用计数策略创建一个可测试的包装器:
class NoMoveCopyRefCounted {
protected:
size_t* count_;
...
};
class NoMoveCopyRefCountedTest :
public NoMoveCopyRefCounted {
public:
using NoMoveCopyRefCounted::NoMoveCopyRefCounted;
size_t count() const { return *count_; }
};
我们必须将主复制策略中的 count_ 数据成员从 private 改为 protected。也可以将测试策略声明为友元,但那样的话,每增加一个新的测试策略都需要这样做。现在,终于可以实际编写测试了:
// 测试1:只有一个指针
{
SmartPtr<C, ... NoMoveCopyRefCountedTest> p(new C);
assert(p.count() == 1);
} // 此处C对象应该删除
// Test 2: one copy
{
SmartPtr<C, ... NoMoveCopyRefCountedTest> p(new C);
{
auto p1(p); // 引用计数应该为2
assert(p.count() == 2);
assert(p1.count() == 2);
assert(&*p == &*p1);
} // 此处C对象不应该删除
assert(p.count == 1);
} // 此处C对象应该删除
同样,可以创建一个带有检测功能的删除策略,用于检查对象是否将删除,或者在某个外部日志记录对象中记录其确实已删除,并测试删除操作是否正确记录。我们需要对智能指针的实现进行插桩,使其调用调试或测试策略:
// Example 16:
template <... 模板参数...,
typename DebugPolicy = NoDebug>
class SmartPtr : ... 基类策略 ... {
T* p_;
public:
explicit SmartPtr(T* p = nullptr,
DeletionPolicy&& deletion_policy = DeletionPolicy()) :
DeletionPolicy(std::move(deletion_policy)), p_(p)
{
DebugPolicy::construct(this, p);
}
~SmartPtr() {
DebugPolicy::destroy(this, p_,
CopyMovePolicy::must_delete());
if (CopyMovePolicy::must_delete())
DeletionPolicy::operator()(p_);
}
...
};
调试和生产(非调试)策略都必须包含类中引用的所有方法,但非调试策略的空方法会内联并优化为无操作。
// Example 16
struct NoDebug {
template <typename P, typename T>
static void construct(const P* ptr, const T* p) {}
template <typename P, typename T>
static void destroy(const P* ptr, const T* p,
bool must_delete) {}
... other events ...
};
调试策略各不相同,基本的策略只是简单地记录所有可调试的事件:
// Example 16
struct Debug {
template <typename P, typename T>
static void construct(const P* ptr, const T* p) {
std::cout << "Constructed SmartPtr at " << ptr <<
", object " << static_cast<const void*>(p) <<
std::endl;
}
template <typename P, typename T>
static void destroy(const P* ptr, const T* p,
bool must_delete) {
std::cout << "Destroyed SmartPtr at " << ptr <<
", object " << static_cast<const void*>(p) <<
(must_delete ? " is" : " is not") << " deleted" <<
std::endl;
}
};
更复杂的策略可以验证对象的内部状态是否符合要求,并确保类的不变式得到维持。
至此,有读者很可能已经注意到,基于策略的对象的声明可能会相当冗长:
SmartPtr<C, DeleteByOperator<T>, MoveNoCopy, WithRelease, Debug> p( ... );
这是基于策略设计中最常被观察到的问题,应该考虑一些方法来缓解这个问题。
基于策略设计最明显的缺点,就是我们必须声明具体对象的方式 —— 特别是每次都需要重复的冗长策略列表。明智地使用默认参数有助于简化最常用的情况。例如,以下这个冗长的声明:
SmartPtr<C, DeleteByOperator<T>, MoveNoCopy, WithRelease, NoDebug> p( ... );
有时,这可以简化为以下形式:
SmartPtr<C> p( ... );
如果默认值代表了最常见的使用场景 —— 即可移动的、非调试的、使用 operator delete 的指针 —— 这种简化可行。然而,如果不打算使用这些策略,那添加它们又有什么意义呢?经过周密考虑的策略参数顺序,有助于让更常见的策略组合声明变得更短。如果最常变化的是删除策略,就可以在不重复那些无需更改的策略的情况下,声明一个具有不同删除策略而其余策略使用默认值的新指针:
这样,只需指定需要更改的策略,就能快速创建新的智能指针类型,提高了代码的简洁性和可读性。
SmartPtr<C, DeleteHeap<T>> p( ... );
这仍然无法解决那些不常用策略的问题。此外,策略通常是在设计后期,随着需要添加新功能时才被加入的。这些新策略几乎总是被添加到参数列表的末尾 —— 如果改变现有参数的顺序,就需要重写所有声明了该基于策略类的代码,后来添加的策略未必就使用频率较低。这种设计的演变可能导致一种情况:即使后面的参数使用默认值,也必须显式地写出许多前面的策略参数,才能修改其中一个靠后的参数。
虽然在传统基于策略设计的框架内没有通用的解决方案,但实践中,通常只有少数几种策略组合是常用的,再配合一些频繁变化的个别策略。例如,大多数智能指针可能都使用 operator delete,并支持移动和释放功能,但经常需要在调试版本和非调试版本之间切换。这种情况可以通过创建适配器来解决:该适配器将一个拥有多个策略的类转换为一个新的接口,只暴露经常需要更改的策略,而将其他策略固定为常用值。由于常用的策略组合可能不止一种,大型设计中通常需要多个这样的适配器。
编写此类适配器最简单的方法是使用 using 别名:
// Example 17
template <typename T, typename DebugPolicy = NoDebug>
using SmartPtrAdapter =
SmartPtr<T, DeleteByOperator<T>, MoveNoCopy,
WithRelease, DebugPolicy>;
另一种选择是使用继承:
// Example 18
template <typename T, typename DebugPolicy = NoDebug>
class SmartPtrAdapter : public SmartPtr<T,
DeleteByOperator<T>, MoveNoCopy,
WithRelease, DebugPolicy>
{...};
这种方法创建了一个派生类模板,将基类模板的某些参数固定,同时保留其余参数的可变性。基类的整个公共接口都继承,但需要特别注意基类的构造函数。默认情况下,基类的构造函数不会继承,因此新派生的类将只有编译器自动生成的默认构造函数。这通常不是我们想要的结果,因此必须将基类的构造函数(以及可能的赋值运算符)引入到派生类中:
// Example 18
template <typename T, typename DebugPolicy = NoDebug>
class SmartPtrAdapter : public SmartPtr<T,
DeleteByOperator<T>, MoveNoCopy,
WithRelease, DebugPolicy>
{
using base_t = SmartPtr<T, DeleteByOperator<T>,
MoveNoCopy, WithRelease, DebugPolicy>;
using base_t::SmartPtr;
using base_t::operator=;
};
使用别名无疑更易于编写和维护,但如果需要同时适配某些成员函数、嵌套类型等,派生类适配器则提供了更大的灵活性。
现在,当需要一个具有预设策略的智能指针,但又要快速更改调试策略时,就可以使用这个新适配器:
SmartPtrAdapter<C, Debug> p1{new C); // 调试版本的指针
SmartPtrAdapter<C> p2{new C); // 非调试版本的指针
策略最常见的用途是,为类的某些行为方面选择特定的实现。有时,这种实现上的差异也会反映在类的公共接口上 —— 某些操作可能仅对某些实现有意义,而对其他实现则无意义。确保不会调用与当前实现不兼容的操作的最佳方法,就是根本不提供该操作。
现在,让我们重新探讨,如何使用策略来选择性地启用公共接口的某些部分。
之前曾使用策略以两种方式来控制公共接口 —— 第一种是通过从策略类继承来注入公共成员函数。这种方法相当灵活且强大,但有两个缺点:首先,当公开继承一个策略,就无法控制哪些接口被注入 —— 策略类的每个公共成员函数都会成为派生类接口的一部分。其次,要通过这种方式实现有用的功能,必须允许策略类将自身转换为派生类,然后它就必须能够访问类的所有数据成员,甚至可能包括其他策略。
尝试的第二种方法依赖于构造函数的一个特定属性:要复制或移动一个类,就必须复制或移动它的所有基类或数据成员;如果其中一个基类或成员是不可复制或不可移动的,整个构造函数就会编译失败。不幸的是,这种失败通常伴随着相当不明显的语法错误 —— 远不如“在此对象中未找到复制构造函数”这样直接明了。可以将这种技术扩展到其他成员函数,例如赋值运算符,但这会使代码变得更加复杂。
现在,将介绍一种更直接的方式来操控基于策略类的公共接口。首先,让区分一下有条件地禁用现有成员函数和添加新成员函数这两种情况。前者是合理且通常安全的:如果某个特定实现无法支持接口提供的某些操作,这些操作一开始就不应该提供。后者则比较危险,允许对类的公共接口进行本质上任意且不受控制的扩展。因此,重点将是为基于策略类的所有可能用途提供接口,然后在某些策略选择下这些接口没有意义时,禁用其部分功能。
C++ 语言本身已经提供了一种机制来选择性地启用和禁用成员函数。在 C++20 之前,这种机制最常通过概念(如果可用)或 std::enable_if 来实现,但其背后的原理是我们曾在第 7 章中研究过的 SFINAE。在 C++20 中,更强大的概念可以在许多情况下取代 std::enable_if。
为了说明如何使用 SFINAE 让策略选择性地启用成员函数,重新实现控制公共 release() 成员函数的策略。之前在本章中已经通过继承一个可能提供或不提供 release() 成员函数的 ReleasePolicy 来实现过一次;如果提供了该函数,则必须使用 CRTP 来实现。现在,将使用 C++20 的概念来实现同样的功能。
依赖 SFINAE 和概念的策略不能向类的接口添加新的成员函数,只能禁用其中某些函数。因此,第一步是在 SmartPtr 类本身中添加 release() 函数:
// Example 19
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename ReleasePolicy = NoRelease>
class SmartPtr : private DeletionPolicy {
T* p_;
public:
void release() { p_ = nullptr; }
...
};
目前,该函数始终处于启用状态,需要根据 ReleasePolicy 的某个属性来条件性地启用。由于该策略控制单一行为,只需要一个常量值来明确是否应支持 release 功能:
// Example 19
struct WithRelease {
static constexpr bool enabled = true;
};
struct NoRelease {
static constexpr bool enabled = false;
};
现在,需要使用约束来条件性地启用 release() 成员函数:
// Example 19
template <...> class SmartPtr ... {
...
void release() requires ReleasePolicy::enabled {
p_ = nullptr;
}
};
这就是我们在 C++20 中所需要的全部内容,不需要从 ReleasePolicy 继承,因为其除了一个常量值之外没有内容。出于同样的原因,也无需移动或复制此策略。
在 C++20 之前以及概念出现之前,必须使用 std::enable_if 来启用或禁用特定的成员函数 —— 通常来说,表达式 std::enable_if<value, type> 在 value 为 true 时(必须是一个编译时的,即 constexpr 布尔值)会成功编译并产生指定的类型;如果 value 为 false,则类型替换会失败(不产生类型结果)。这个模板元函数的正确使用场景是在 SFINAE 上下文中,此时类型替换的失败不会导致编译错误,而只是简单地禁用引发该失败的函数(更准确地说,是将其从重载解析集合中移除)。
策略本身根本不需要改变:无论是 SFINAE 还是约束,都需要一个 constexpr bool 值。发生变化的只是用于禁用成员函数的表达式。开发者可能会倾向于简单地这样写:
template <...> class SmartPtr ... {
...
std::enable_if_t<ReleasePolicy::enabled> release() {
p_ = nullptr;
}
};
但这无法工作:对于 NoRelease 策略,即使不尝试调用 release(),编译也会失败。原因是 SFINAE 只在进行模板参数替换时才起作用(S 代表 Substitution),并且仅适用于函数模板。因此,release() 函数必须是一个模板函数,并且潜在的替换失败必须发生在模板参数替换期间。虽然声明 release() 并不需要模板参数,但为了使用 SFINAE,必须引入一个伪模板参数:
// Example 20
template <...> class SmartPtr ... {
...
template<typename U = T>
std::enable_if_t<sizeof(U) != 0 &&
ReleasePolicy::enabled> release() {
p_ = nullptr;
}
};
我们在第7章中描述“概念工具”(即在C++20之前模拟概念的一种方法)时,已经见过这种“伪模板”了。
现在有了一个模板类型参数;尽管它永远不会使用,并且总是设置为其默认值,但这并不影响结果。返回类型中的条件表达式使用了这个模板参数(尽管表达式中依赖该参数的部分实际上永远不会失败),所以现在处于SFINAE规则的适用范围内。
现在已经掌握了选择性禁用成员函数的方法,可以重新审视条件性启用的构造函数,看看如何同样地启用或禁用构造函数。
在C++20中,答案是“完全相同的方式”。我们需要一个带有 constexpr 布尔值的策略,并使用 requires 约束来禁用构造函数:
// Example 21
struct MoveForbidden {
static constexpr bool enabled = false;
};
struct MoveAllowed {
static constexpr bool enabled = true;
};
可以使用此策略来约束成员函数,包括构造函数:
// Example 21
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy {
public:
SmartPtr(SmartPtr&& other)
requires MovePolicy::enabled :
DeletionPolicy(std::move(other)),
p_(std::exchange(other.p_, nullptr)) {}
...
};
在C++20之前,必须使用SFINAE。这里的难点在于,构造函数没有返回类型,必须将SFINAE测试隐藏在其他地方。此外,再次需要将构造函数变为模板,可以再次使用一个伪模板参数:
// Example 22
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy {
public:
template <typename U = T,
std::enable_if_t<sizeof(U) != 0 && MovePolicy::enabled,
bool> = true>
SmartPtr(SmartPtr&& other) :
DeletionPolicy(std::move(other)),
p_(std::exchange(other.p_, nullptr)) {}
...
};
如果使用第7章中的概念工具,代码看起来会更简洁明了,尽管仍然需要一个伪模板参数:
// Example 22
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename MovePolicy = MoveForbidden>
class SmartPtr : private DeletionPolicy {
public:
template <typename U = T,
REQUIRES(sizeof(U) != 0 && MovePolicy::enabled)>
SmartPtr(SmartPtr&& other) :
DeletionPolicy(std::move(other)),
p_(std::exchange(other.p_, nullptr)) {}
...
};
现在,已经掌握了一种完全通用的方法来启用或禁用特定的成员函数(包括构造函数),有读者可能会疑惑,之前介绍的方法还有什么意义呢?首先,是为了简单性 —— enable_if 表达式必须在正确的上下文中使用,而如果出现细微错误,编译器生成的错误信息会非常不友好。另一方面,一个不可复制的基类会使整个派生类变得不可复制,这个概念非常基础且始终有效。这种技术甚至可以在 C++03 中使用,而在 C++03 中,SFINAE 的功能要有限得多,而且更难正确使用。
此外,有时策略需要向类中添加成员变量,而不仅仅是(或除了)成员函数。引用计数指针就是一个完美的例子:如果其中一个策略提供了引用计数功能,还必须包含计数器本身。成员变量无法通过约束来限制,因此必须来自基类策略。
另一个至少需要了解通过策略注入公共成员函数的原因是,有时 enable_if 的替代方案要求将所有可能的函数都声明在主类模板中,然后可以有选择地禁用其中一些。但有时,这组函数本身是相互矛盾的,无法同时存在。一个典型的例子是一组转换运算符。目前,我们的智能指针无法转换回原始指针,可以启用此类转换,并要求它们是显式的,或者允许隐式转换:
void f(C*);
SmartPtr<C> p(...);
f((C*)(p)); // 显式转换
f(p); // 隐式转换
转换运算符定义如下:
template <typename T, ...>
class SmartPtr ... {
T* p_;
public:
explicit operator T*() { return p_; } // 显式
operator T*() { return p_; } // 隐式
...
};
我们已经决定不希望这些运算符无条件地存在;相反,希望它们由一个“原始指针转换策略”来控制。先尝试使用之前启用成员函数时所采用的相同方法:
// Example 23
struct NoRaw {
static constexpr bool implicit_conv = false;
static constexpr bool explicit_conv = false;
};
struct ExplicitRaw {
static constexpr bool implicit_conv = false;
static constexpr bool explicit_conv = true;
};
struct ImplicitRaw {
static constexpr bool implicit_conv = true;
static constexpr bool explicit_conv = false;
};
首先编写 C++20 代码,在其中可以使用约束来限制显式和隐式转换运算符:
// Example 23
template <typename T, ..., typename ConversionPolicy>
class SmartPtr : ... {
T* p_;
public:
explicit operator T*()
requires ConversionPolicy::explicit_conv
{ return p_; }
operator T*()
requires ConversionPolicy::implicit_conv
{ return p_; }
explicit operator const T*()
requires ConversionPolicy::explicit_conv const
{ return p_; }
operator const T*()
requires ConversionPolicy::implicit_conv const
{ return p_; }
};
为完整性起见,也提供了到 const 原始指针的转换。在 C++20 中,可以使用条件显式说明符C++20 的另一项特性)更简洁地提供这些运算符:
// Example 24
template <typename T, ..., typename ConversionPolicy>
class SmartPtr : ... {
T* p_;
public:
explicit (ConversionPolicy::explicit_conv)
operator T*()
requires (ConversionPolicy::explicit_conv ||
ConversionPolicy::implicit_conv)
{ return p_; }
explicit (ConversionPolicy::explicit_conv)
operator const T*()
requires (ConversionPolicy::explicit_conv const ||
ConversionPolicy::implicit_conv const)
{ return p_; }
};
在 C++20 之前,可以尝试使用 std::enable_if 和 SFINAE 来基于转换策略启用这些转换运算符。问题在于,即使其中一个运算符后续会禁用,也不能同时声明到同一类型的隐式和显式转换。这些运算符从一开始就不能存在于同一个重载集合中:
// Example 25 – 无法编译!
template <typename T, ..., typename ConversionPolicy>
class SmartPtr : ... {
T* p_;
public:
template <typename U = T,
REQUIRES(ConversionPolicy::explicit_conv)>
explicit operator T*() { return p_; }
template <typename U = T,
REQUIRES(ConversionPolicy::implicit_conv)>
operator T*() { return p_; }
...
};
如果在智能指针类中拥有选择启用这些转换运算符的选项,就必须让这些运算符由基类策略生成。由于策略需要知道智能指针的具体类型,必须再次使用 CRTP(奇异递归模板模式)。以下是一组控制智能指针转换到原始指针的策略:
// Example 26
template <typename P, typename T> struct NoRaw {
};
template <typename P, typename T> struct ExplicitRaw {
explicit operator T*() {
return static_cast<P*>(this)->p_;
}
explicit operator const T*() const {
return static_cast<const P*>(this)->p_;
}
};
template <typename P, typename T> struct ImplicitRaw {
operator T*() {
eturn static_cast<P*>(this)->p_;
}
operator const T*() const {
return static_cast<const P*>(this)->p_;
}
};
这些策略将所需的公共成员函数运算符添加到派生类中,它们是需要使用派生类类型进行实例化的模板,所以转换策略是一个双重模板参数,其使用遵循 CRTP 模式:
// Example 26
template <typename T, ... 其他策略 ...
template <typename, typename>
class ConversionPolicy = ExplicitRaw>
class SmartPtr : ... 其他基类策略 ...,
public ConversionPolicy<SmartPtr<... paramerers ...>, T>
{
T* p_;
template<typename, typename>
friend class ConversionPolicy;
public:
...
};
再次注意模板模板参数的使用:模板参数 ConversionPolicy 本身不是一个类型,而是一个模板。在从该策略的实例继承时,必须写出 SmartPtr 类的完整类型,包括其所有模板参数。
我们将转换策略设计为接受两个参数的模板(第二个参数是对象类型 T),也可以从第一个模板参数(智能指针类型)中推导出类型 T,这在很大程度上是风格选择的问题。
所选的转换策略会将其公共接口(如果有的话)添加到派生类的接口中,一个策略添加一组显式转换运算符,而另一个则提供隐式转换。就像之前的 CRTP 示例一样,基类需要访问派生类的私有数据成员。可以选择将整个模板(及其所有实例化)声明为友元,或者更明确地,仅为每个智能指针用作基类的特定实例化声明友元:
friend class ConversionPolicy<SmartPtr<T, ... 参数 ..., ConversionPolicy>, T>;
我们已经学习了几种实现新策略的不同方法。有时,挑战在于如何重用已有的策略。下一节将展示其中一种方法。
策略列表可能会变得相当长,当只想修改其中一个策略,并创建一个与另一个类几乎完全相同但稍作修改的新类,目前至少有两种实现方法。
第一种方法非常通用,但有些冗长。第一步是在主模板内部将模板参数作为别名暴露出来。这本身就是一个很好的做法 —— 如果没有这样的别名,当需要在模板外部使用某个模板参数时,很难在编译时确定该参数的具体类型。例如,有一个智能指针,想要知道其删除策略是什么。最简单的方法莫过于借助智能指针类自身提供的一些帮助:
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename CopyMovePolicy = NoMoveNoCopy,
template <typename, typename>
class ConversionPolicy = ExplicitRaw>
class SmartPtr : ... 基类策略 ... {
T* p_;
public:
using value_type = T;
using deletion_policy_t = DeletionPolicy;
using copy_move_policy_t = CopyMovePolicy;
template <typename P, typename T1>
using conversion_policy_t = ConversionPolicy<P, T1>;
...
};
这里使用了两种不同类型的别名 —— 对于像 DeletionPolicy 这样的普通模板参数,可以使用 using 别名。而对于双重模板参数,必须使用模板别名,有时也称为模板 typedef —— 为了使用另一个智能指针重现相同的策略,需要知道模板本身,而不是模板的实例化,例如 ConversionPolicy<SmartPtr, T>。现在,如果需要创建另一个具有部分相同策略的智能指针,可以直接查询原始对象的策略:
// Example 27
SmartPtr<int,
DeleteByOperator<int>, MoveNoCopy, ImplicitRaw>
p1(new int(42));
using ptr_t = decltype(p1); // p1 的确切类型
SmartPtr<ptr_t::value_type,
ptr_t::deletion_policy_t, ptr_t::copy_move_policy_t,
ptr_t::conversion_policy_t> p2;
SmartPtr<double,
ptr_t::deletion_policy_t, ptr_t::copy_move_policy_t,
ptr_t::conversion_policy_t> p3;
现在,p2 和 p1 具有完全相同的类型,实现还有更简单的方法。但关键在于,可以更改列表中的一个类型,同时保持其余类型不变,从而得到一个与 p1 几乎完全相同、仅有一处差异的指针。例如,指针 p2 拥有相同的策略,但指向的是 double 类型。
这种情况实际上相当常见,因此存在一种方法,可以在保持其余模板参数不变的情况下,方便地将模板“重新绑定”到不同的类型。为了实现,主模板及其所有策略都需要支持这种重新绑定操作:
// Example 27
template <typename T> struct DeleteByOperator {
void operator()(T* p) const { delete p; }
template <typename U>
using rebind_type = DeleteByOperator<U>;
};
template <typename T,
typename DeletionPolicy = DeleteByOperator<T>,
typename CopyMovePolicy = NoMoveNoCopy,
template <typename, typename>
class ConversionPolicy = ExplicitRaw>
class SmartPtr : private DeletionPolicy,
public CopyMovePolicy,
public ConversionPolicy<SmartPtr<T, DeletionPolicy,
CopyMovePolicy, ConversionPolicy>, T> {
T* p_;
public:
...
template <typename U>
using rebind = SmartPtr<U,
typename DeletionPolicy::template rebind<U>,
CopyMovePolicy, ConversionPolicy>;
};
rebind 别名定义了一个仅有一个参数的新模板 —— 可以更改的类型。其余参数则来自主模板本身。其中一些参数是依赖于主类型 T 的类型,自身也需要重新绑定(例子中,删除策略即是如此)。通过选择不重新绑定复制/移动策略,这里隐含了一个要求:这些策略都不能依赖于主类型;否则,该策略也同样需要重新绑定。最后,模板转换策略不需要重新绑定 —— 在这里可以访问整个模板,所以将使用新的主类型进行实例化。现在,可以利用这种重新绑定机制来创建一个相似的指针类型:
SmartPtr<int,
DeleteByOperator<int>, MoveNoCopy, ImplicitRaw>
p(new int(42));
using dptr_t = decltype(p)::rebind<double>;
dptr_t q(new double(4.2));
如果能直接访问该智能指针类型,就可以用它来进行重新绑定(例如在模板上下文中)。否则,可以使用 decltype() 从该类型的变量中获取类型。指针 q 拥有与 p 相同的策略,但指向 double 类型,并且诸如删除策略等依赖类型的策略也已相应更新。
我们已经涵盖了策略实现和使用的主要方式,这些方式可用于定制基于策略的类。现在,是时候回顾一下所学到的内容,并总结一些关于使用基于策略设计的通用准则了。