读书笔记:C++ Software Design(3)
5.适配器模式
5.1 适配器模式的特点
适配器设计模式侧重于标准化接口,并帮助以非侵入式的方式向现有的继承层次结构中添加功能。比如你已经有了word格式导出JSON的类,这时新增需求要将Pages格式导出为JSON,可以使用外部库OpenPages实现这一需求。通过适配器模式包装OpenPages的接口,代码如下
class Pages : public Document
{
public:
// ...
void exportToJSON( /*...*/ ) const override
{
exportToJSONFormat(pages, /*...*/);
}
void serialize( ByteStream& bs, /*...*/ ) const override
{
pages.convertToBytes(/*...*/);
}
// ...
private:
OpenPages pages; // Example of an object adapter
};
你已经有了Document基类(我们暂时忽略JSONExportable和Serializable接口),并且已经实现了几个不同类型的文档(例如,通过Word类)。这个层次结构中的新增部分是Pages类。Pages类通过将对Document接口的调用转发给相应的OpenPages函数来实现Document接口:对exportToJSON()的调用被转发给自由函数exportToJSONFormat(),而对serialize()的调用则被转发给成员函数convertToBytes()。
通过Pages类,你可以轻松地将这个第三方实现集成到你现有的类层次结构中。确实非常容易:你可以在不进行任何修改的情况下集成它。适配器设计模式的这种非侵入性,是你应该考虑到的适配器设计模式的最大优势之一:任何人都可以添加一个适配器,将一个接口适配到另一个现有接口。在这种背景下,Pages类作为OpenPages类中实际实现细节的抽象。因此,适配器设计模式将接口的关注点与实现细节分离开来。这很好地满足了单一职责原则(SRP),并且与开闭原则(OCP)的意图相符。
在某种程度上,Pages适配器充当了一种间接层,将一组函数映射到另一组函数。需要注意的是,这并不严格要求从一个函数映射到恰好另一个函数。相反,你完全可以根据需要灵活地将预期函数集映射到可用函数集上。因此,适配器不一定代表一对一的映射关系,也可以支持一对多的映射关系。
6.观察者模式
6.1 观察者模式特点
在许多软件场景中,我们希望在某些状态发生变化时立即获得反馈:任务队列中添加了新作业、配置对象的某个设置被更改、结果已准备好被获取,等等。但与此同时,在主体(被观察的、发生变化的实体)与其观察者(基于状态变化而被通知的回调)之间引入显式依赖是非常不可取的。恰恰相反,主体应该对可能存在的多种不同类型的观察者一无所知。原因很简单:任何直接依赖都会使软件更难更改和扩展。这种主体与其众多观察者之间的解耦,正是观察者设计模式的意图所在。
观察者类代表了所有可能的观察者实现的抽象。这些观察者被附加到一个特定的主体上,该主体由具体主体类(ConcreteSubject)来表示。为了减少观察者与主体之间的耦合,或者简单地通过为不同的观察者提供附加(attach)和分离(detach)的所有通用服务来减少代码重复,可以使用主体(Subject)抽象。这个主体还可以通知(notify)所有已附加的观察者有关状态的变化,并触发它们相应的更新(update)功能。

6.2 基于std::function的观察者模式
下面是一个基于std::function实现的事件响应系统:
class EventDispatcher
{
public:
using Callback = std::function<void(const Event&)>;
// 注册监听
void Register(EventType type, Callback cb)
{
m_callbacks[type].push_back(std::move(cb));
}
// 发布事件
void Emit(const Event& evt)
{
auto type = evt.GetType();
auto it = m_callbacks.find(type);
if (it == m_callbacks.end())
return;
for (auto& cb : it->second)
{
cb(evt);
}
}
private:
std::unordered_map<EventType, std::vector<Callback>> m_callbacks;
};
dispatcher.Register(EventType::MouseDown,
[](const Event& e)
{
const MouseEvent& me = static_cast<const MouseEvent&>(e);
printf("Lambda Mouse: %d %d\n", me.x, me.y);
});
在当前基于 std::function 的事件系统实现中,观察者模式的优点是高度解耦和灵活:事件发布者只负责发出事件,不关心谁在监听,监听者可以自由注册任意回调,包括普通函数、lambda 或成员函数,实现多播和动态扩展都很方便;缺点是潜在的性能和安全问题:每次事件分发都需要遍历回调列表并进行 static_cast,如果监听者过多或者事件频繁,会有开销,而且回调对象生命周期管理不当可能导致悬空指针或内存泄漏,因此在高性能或多线程场景下需要额外优化和生命周期控制。
7.CRTP模式
7.1 CRTP模式的特点
CRTP,即奇异递归模板模式(Curiously Recurring Template Pattern)。其核心思想是让派生类将自身类型作为模板参数传给基类。
template< typename Derived >
struct DenseVector
{
protected:
~DenseVector() = default;
public:
Derived& derived() { return static_cast<Derived&>( *this ); }
Derived const& derived() const { return static_cast<Derived const&>( *this );
}
size_t size() const { return derived().size(); }
// ...
};
CRTP 的奇特之处在于,基类 DenseVector 的新模板参数代表了相关派生类的类型。派生类,例如 DynamicVector,预期会提供自己的类型来实例化基类。通过这个模板参数,基类现在知道了派生类型的实际类型。虽然它仍然代表所有密集向量的抽象和通用接口,但现在它能够访问和调用派生类型中的具体实现。例如,在 size() 成员函数中就是这样:DenseVector 使用 static_cast 将自己转换为对派生类的引用,并在其上调用 size() 函数。乍一看可能像是递归函数调用(在 size() 函数内调用 size() 函数),但实际上是调用派生类中的 size() 成员函数。
CRTP 可以在编译期实现静态多态,使基类调用派生类的接口无需虚函数和运行时开销,从而提升性能,同时支持编译期类型检查和接口复用。它允许在基类中定义通用算法或行为,派生类只需实现特定接口即可,非常适合高性能场景和模板策略复用,例如迭代器、单例或事件系统中的静态回调。
CRTP 设计模式的缺点一是缺乏公共基类。我将重复这一点以强调其严重后果的严重性:没有公共基类!实际上,每一个派生类都有一个不同的基类。例如,DynamicVector<T> 类有 DenseVector<DynamicVector<T>> 基类。StaticVector<T, Size> 类有 DenseVector<StaticVector<T, Size>> 基类。因此,每当需要公共基类时,每当可以使用公共抽象时,例如将不同类型存储在集合中,CRTP 设计模式就不是正确的选择。
第二个也可能非常局限性的缺点是,所有与CRTP基类接触的东西本身都会变成模板。对于所有处理这种基类的函数来说尤其如此。例如,考虑升级后的输出运算符和l2norm()函数:
template< typename Derived >
std::ostream& operator<<( std::ostream& os, DenseVector<Derived> const& vector );
template< typename Derived >
auto l2norm( DenseVector<Derived> const& vector );
这两个函数应该适用于所有从DenseVector CRTP类派生的类。当然,它们不应该依赖于派生类的具体类型。因此,这两个函数必须是函数模板:必须推导Derived类型。虽然在线性代数库的上下文中,这通常不是问题,因为几乎所有功能都是用模板实现的,但在其他上下文中,这可能是一个很大的缺点。将大量代码变成模板并将定义移到头文件中,从而有效地牺牲源文件的封装,这可能是非常不可取的。是的,这可能确实是一个严重的缺点!
第三,CRTP是一种侵入式设计模式。派生类必须通过继承CRTP基类来显式选择加入。虽然这在我们的自己的代码中可能不是问题,但你不能轻易地为外部代码添加基类。在这种情况下,你必须求助于适配器设计模式。因此,CRTP不具备非侵入式设计模式的灵活性(例如,使用std::variant实现的访问者设计模式、适配器设计模式等)。
最后但同样重要的是,CRTP不提供运行时多态性,只提供编译时多态性。因此,只有当需要某种静态类型抽象时,该模式才有意义。如果不是这样,它再次不能替代所有的继承层次结构。
7.2 基于CRTP模式的静态混入类实现
Static Mixin Classes(静态混入类)是一种通过 CRTP 在编译期为类“按需添加功能”的技术。它的核心目的不是建立抽象层级,而是把一组可复用的行为模块化,然后以继承的方式静态组合到目标类型中。相比传统继承,它不依赖虚函数、不产生运行时开销,也不会强制形成“is-a”关系,更像是“给类拼装能力”。常见用途包括:为类型统一添加比较运算、算术运算、计数功能、日志能力、可打印能力等;多个 mixin 可以叠加组合,使最终类在不重复写代码的前提下获得一组通用功能,同时保持类型安全和零成本抽象。
// Mixin:提供 == 和 !=
template<typename Derived>
class EqualityComparable
{
public:
friend bool operator!=(const Derived& a, const Derived& b)
{
return !(a == b);
}
};
// 业务类型
class Meter : public EqualityComparable<Meter>
{
public:
explicit Meter(int v) : value(v) {}
friend bool operator==(const Meter& a, const Meter& b)
{
return a.value == b.value;
}
private:
int value;
};
template<typename Derived>
class Addable
{
public:
friend Derived operator+(const Derived& a, const Derived& b)
{
return Derived(a.Get() + b.Get());
}
};
class Kilometer
: public Printable<Kilometer>
, public Addable<Kilometer>
{
public:
explicit Kilometer(long v) : value(v) {}
long Get() const { return value; }
std::string ToString() const
{
return std::to_string(value) + " km";
}
private:
long value;
};
Static Mixin 的本质是“用 CRTP 在编译期拼装功能模块”。它特别适合做横切能力的复用(运算符、日志、统计、接口适配等),既避免了重复代码,又保持零成本抽象,是 CRTP 在现代 C++ 中最典型、也是最有价值的应用场景之一。

浙公网安备 33010602011771号