如果我理解正确,您关心为property
每个对象的每个可观察对象提供信号/通知的成本。
幸运的是,您很幸运,因为在任何语言或系统中,为每个对象的每个属性存储一个通用的线程安全通知器通常都非常昂贵。
与其变得聪明并试图在编译时解决这个问题,我建议这样做会排除一些对大型项目非常有用的选项(例如:插件和脚本),我建议考虑如何制作这在运行时更便宜。您希望将信号存储在比对象的单个属性更粗略的级别。
如果您只存储一个对象,该对象传递有关在属性更改事件期间修改了哪些属性的适当数据以过滤要通知的客户端,那么现在我们的成本会降低很多。我们正在为连接的插槽交换一些额外的分支和更大的聚合,但是您会得到一个小得多的对象,以换取可能更快的读取访问,我建议这在实践中是一个非常有价值的交换。
您仍然可以设计您的公共接口甚至事件通知机制,以便客户端以一种感觉就像连接到属性而不是整个对象的方式与系统一起工作,甚至可能调用属性中的方法(如果它是一个对象/proxy) 来连接插槽,如果您需要或可以提供从属性指向对象的反向指针。
如果您不确定,我会错误地将事件槽附加到属性以及将它们修改为对象接口而不是属性接口的一部分,因为您将有更多的喘息空间来优化以换取稍微不同的客户审美(我真的不认为只是“不同”更方便,或者至少可能值得为每个属性消除反向指针的成本)。
那是在方便和包装类型的东西的领域。但是你不需要违反开闭原则来实现 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
句柄向下传递调用堆栈。当事件发生时,比如属性改变,对象可以通过 if 将事件推送到这个队列中has_events() == true
。同样,connect
不一定要向对象添加任何状态。它可以创建一个关联结构,再次通过Context
,将 object/event_id 映射到客户端。disconnect
也将其从该中心线程源中删除。甚至将插槽与信号连接/断开连接的行为也可以推送到事件队列,以便在一个中央全局位置进行处理并进行适当的关联(再次防止没有观察者的对象支付任何内存成本)。
当使用这种类型的设计时,每个线程应该在其入口点有一个线程的退出处理程序,它将推送到线程事件队列的事件从线程本地队列传输到某个全局队列。这需要一个锁,但可以不那么频繁地执行以避免严重的争用,并允许每个线程在性能关键区域期间不会因事件处理而减慢。类似的设计也应该提供某种thread_yield
类型的功能,它也可以从线程本地队列转移到全局队列以用于长期线程/任务。
全局队列在不同的线程中处理,触发连接槽的适当信号。在那里,它可以专注于在队列不为空时批量处理队列,在队列为空时进行休眠/让步。所有这些的全部目的是为了提高性能:与每次修改对象属性时发送同步事件的潜力相比,推送到队列非常便宜,并且在处理大规模输入时,这可能是非常昂贵的开销. 因此,简单地推送到队列允许该线程避免将时间花在事件处理上,将其推迟到另一个线程。