观察员设计模式问题

Observer Design Pattern Issues

本文关键字:问题 设计模式 观察员      更新时间:2023-10-16

我正在C++中进行一个大型项目,该项目将具有图形用户界面。用户界面将使用一些依赖于观察者模式的设计模式(MVVM/MVC)。

我的问题是,我目前无法预测模型的哪些部分应该是可观察的。还有很多很多部分。

由于这个问题,我发现自己被拉向了几个方向:

  1. 如果我开发不支持通知的后端类,我会发现自己违反了开放-关闭原则
  2. 如果我确实为所有Model类及其所有数据成员提供了通知支持,那么它将产生巨大的性能成本,这是不合理的,因为实际上只需要这种支持的一小部分(尽管这一部分是未知的)
  3. 如果我只通过使所有非常量方法都是虚拟的并通过基指针访问这些方法来提供对扩展的支持,那么情况也是如此。这也会在可读性方面产生成本

我觉得在这3个中,(1.)可能是较小的邪恶。

然而,我觉得理想的解决方案实际上应该存在于某些语言中(绝对不是C++),但我不知道它是否在任何地方得到支持。

我想到的独角兽解决方案是这样的:给定一个类Data,难道不应该让那些试图让Data变得可观察的客户端做一些类似的事情吗

@可观测(数据)

作为编译时构造。这反过来又可以对数据对象调用addObserver,并使用通知程序修改对数据成员的所有分配。这也会让你只为你得到的东西而付出绩效。

所以我的问题有两个:

  1. 我认为在我所说的三个选项中,(1.)是较小但必要的邪恶吗
  2. 我的独角兽解决方案在任何地方都存在吗?正在工作?或者由于某种原因无法实施

如果我理解正确,您关心的是为每个对象的每个可观察的property提供信号/通知的成本。

幸运的是,你很幸运,因为在任何语言或系统中,为每个对象的每个属性存储一个通用的线程安全通知程序通常都是非常昂贵的。

与其聪明地在编译时尝试解决这个问题,我建议这会排除一些对大型项目非常有用的选项(例如:插件和脚本),我建议考虑如何在运行时降低成本。您希望信号存储在比对象的单个属性更粗糙的级别。

如果您只存储一个带有对象的对象,该对象会传递关于在属性更改事件中修改了哪个属性的适当数据,以筛选要通知的客户端,那么现在我们的成本要低得多。我们正在为连接的插槽交换一些额外的分支和更大的聚合,但你会得到一个明显更小的对象来交换可能更快的读取访问,我认为这在实践中是一个非常有价值的交换。

您仍然可以设计您的公共接口,甚至事件通知机制,以便客户端以一种感觉它们连接到属性而不是整个对象的方式与系统一起工作,如果您需要或能够从属性提供指向对象的返回指针,甚至可以调用属性中的方法(如果是对象/代理)来连接插槽。

如果你不确定,我会错误地将事件槽附加到属性,并将其作为对象接口而不是属性接口的一部分进行修改,因为你将有更多的喘息空间来优化,以换取稍微不同的客户审美观(我真的不认为这种审美观不那么方便,只是"不同",或者至少可能值得为每个房产消除一个反向指针)。

这是在方便和包装类型的东西的领域。但是你不需要违反打开-关闭的原则来实现C++中的MVP设计。不要被数据表示挤在角落里。您在公共接口级别有很大的灵活性。

记忆压缩——为我们使用的东西付费

当我发现效率在这里起着重要作用时,我建议一些基本的思考方法来帮助实现这一点。

首先,一个对象具有something()之类的访问器并不意味着相关数据必须存储在该对象中。在调用该方法之前,它甚至不必存储在任何地方。如果你关心的是内存,那么它可以存储在外部的某个级别。

大多数软件都分解为拥有资源的聚合层次结构。例如,在3D软件中,顶点由网格所拥有,该网格由应用程序根所拥有的场景图所拥有。

如果你想要的设计几乎不需要为没有使用的东西支付任何内存成本,那么你需要在更粗略的级别上将数据与对象关联起来。如果您将其直接存储在对象中,那么无论是否需要,每个对象都会为something()返回的内容付费。如果您使用指针将其间接存储在对象中,那么您将为指向something()的指针付费,但除非使用它,否则不支付其全部成本。如果将它与对象的所有者关联,则检索它会产生查找成本,但其成本不如将它与该对象所有者的所有者关联高。

因此,如果你在一个足够粗糙的层次上进行关联,总有办法为你不使用的东西获得非常接近免费的东西。在细粒度级别上,您可以减少查找和间接开销,在粗略级别上,可以减少不使用的东西的成本。

大规模事件

考虑到正在处理数百万到数十亿个元素的巨大可扩展性问题,并且仍然希望其中一些元素生成事件,如果您可以使用异步设计,我真的建议您在这里使用它。您可以有一个无锁的每个线程事件队列,具有单个位标志集的对象将向该队列生成事件。如果未设置位标志,则不会设置。

这种延期,异步设计在这种规模下很有用,因为它为您提供了周期性的间隔(或者可能只是其他线程,尽管您需要写锁,也需要读锁,尽管在这种情况下写是很便宜的),在这个间隔中轮询并将全部资源用于大容量处理队列,而更耗时的处理可以在不与事件/通知程序同步的情况下继续系统

基本示例

// Interned strings are very useful here for fast lookups
// and reduced redundancy in memory.
// They're basically just indices or pointers to an 
// associative string container (ex: hash or trie).
// Some contextual class for the thread storing things like a handle
// to its event queue, thread-local lock-free memory allocator, 
// possible error codes triggered by functions called in the thread,
// etc. This is optional and can be replaced by thread-local storage 
// or even just globals with an appropriate lock. However, while
// inconvenient, passing this down a thread's callstack is usually 
// the most efficient and reliable, lock-free way.
// There may be times when passing around this contextual parameter
// is too impractical. There TLS helps in those exceptional cases.
class Context;
// Variant is some generic store/get/set anything type. 
// A basic implementation is a void pointer combined with 
// a type code to at least allow runtime checking prior to 
// casting along with deep copying capabilities (functionality
// mapped to the type code). A more sophisticated one is
// abstract and overriden by subtypes like VariantInt
// or VariantT<int>
typedef void EventFunc(Context& ctx, int argc, Variant** argv);
// Your universal object interface. This is purely abstract:
// I recommend a two-tier design here: 
// -- ObjectInterface->Object->YourSubType
// It'll give you room to use a different rep for 
// certain subtypes without affecting ABI.
class ObjectInterface
{
public:
virtual ~Object() {}
// Leave it up to the subtype to choose the most
// efficient rep.
virtual bool has_events(Context& ctx) const = 0;
// Connect a slot to the object's signal (or its property 
// if the event_id matches the property ID, e.g.).
// Returns a connection handle for th eslot. Note: RAII 
// is useful here as failing to disconnect can have 
// grave consequences if the slot is invalidated prior to 
// the signal.
virtual int connect(Context& ctx, InternedString event_id, EventFunc func, const Variant& slot_data) = 0;
// Disconnect the slot from the signal.
virtual int disconnect(Context& ctx, int slot) = 0;
// Fetches a property with the specified ID O(n) integral cmps.
// Recommended: make properties stateless proxies pointing
// back to the object (more room for backend optimization).
// Properties can have set<T>/get<T> methods (can build this
// on top of your Variant if desired, but a bit more overhead
// if so).
// If even interned string compares are not fast enough for
// desired needs, then an alternative, less convenient interface
// to memoize property indices from an ID might be appropriate in
// addition to these.
virtual Property operator[](InternedString prop_id) = 0;
// Returns the nth property through an index.
virtual Property operator[](int n) = 0;
// Returns the number of properties for introspection/reflection.
virtual int num_properties() const = 0;
// Set the value of the specified property. This can generate
// an event with the matching property name to indicate that it
// changed.
virtual void set_value(Context& ctx, InternedString prop_id, const Variant& new_value) = 0;
// Returns the value of the specified property.
virtual const Variant& value(Context& ctx, InternedString prop_id) = 0;
// Poor man's RTTI. This can be ignored in favor of dynamic_cast
// for a COM-like design to retrieve additional interfaces the
// object supports if RTTI can be allowed for all builds/modes.
// I use this anyway for higher ABI compatibility with third
// parties.
virtual Interface* fetch_interface(Context& ctx, InternedString interface_id) = 0;
};

我将避免讨论数据表示的细节——重点是它是灵活的。重要的是给自己买个房间,以便根据需要进行更改。保持对象的抽象性,将属性保持为无状态代理(指向对象的后向指针除外),等等都为分析和优化提供了很大的喘息空间。

对于异步事件处理,每个线程都应该有一个关联的队列,该队列可以通过这个Context句柄向下传递到调用堆栈。当事件发生时,例如属性更改,如果has_events() == true,对象可以通过它将事件推送到该队列。同样,connect不一定要向对象添加任何状态。它可以再次通过Context创建一个关联结构,该结构将对象/event_id映射到客户端。CCD_ 9还将其从该中心线程源中移除。即使是将插槽连接到信号/从信号断开连接的行为也可以被推送到事件队列中,以便在中央全局位置进行处理和进行适当的关联(再次防止没有观察者的对象支付任何内存成本)。

当使用这种类型的设计时,每个线程的入口点都应该有一个线程的出口处理程序,它将推送到线程事件队列的事件从线程本地队列转移到某个全局队列。这需要一个锁,但可以不太频繁地执行,以避免激烈的争用,并允许每个线程在性能关键区域不会因事件处理而减慢速度。某种类型的thread_yield类型的函数同样应该提供这样的设计,该设计也从线程本地队列转移到用于长寿命线程/任务的全局队列。

全局队列在不同的线程中进行处理,从而向连接的插槽触发适当的信号。在那里,它可以专注于在队列不为空时对其进行批量处理,在队列为空时进行休眠/让步。所有这些的全部目的都是为了提高性能:与每次修改对象属性时发送同步事件的可能性相比,推送到队列非常便宜,而且在处理大规模输入时,这可能是一项非常昂贵的开销。因此,简单地推送到队列可以使该线程避免将时间花在事件处理上,从而将其推迟到另一个线程。

模板库可以帮助您完全解耦GUI和域逻辑,并使消息系统和通知程序变得灵活/可扩展/易于维护。

然而,有一个缺点——您需要C++11支持。

看看这篇文章驯服qt以及github中的示例:解耦的GuiExamples

因此,您可能不需要在每个类上都有通知程序,您只需要从内部函数和特定类上拍摄消息,就可以让任何类向GUI发送任何您想要的消息。