.NET中事件实现机制(下)——显式实现事件

上一篇日志提到了事件的实现方式。在浏览M$提供的Control.cs源代码时,按照设想M$应该也是采用了类似的方式去实现Control类的事件,但发现貌似不是这样。

打开Control.cs,发现M$定义了一堆object:

这一大堆乱七八糟的东西是干嘛的呢?

Control类作为每个Windows Forms上控件的基类,提供了各控件通用的事件。MSDN上提供的Control类的事件多达70多个,于是我们应该可以联想到M$这样做的目的。

界面上的控件都是从Control基类派生,这意味着每个控件至少都要支持这70多个事件(即使没有提供实现,Control基类也会提供实现)。假如每一个控件都存储多个委托字段,那么将会大量地占用内存。

不过还好,我们使用控件的形式相对比较固定:例如使用Button时,最常用的就是Click事件,对于其他的事件我们几乎很少会去关心。实际上,每个在界面中的控件,绝大多数都只有少数事件会被关联,而剩下的几十个在大部分时间都没有方法关联它。

这是M$优化Control类内存占用的一个基本前提。那么设计自然是,只记录被关联的委托,其他的都不记录以减少内存消耗。这篇日志主要来学习下M$的相关实现方法。

还是以KeyPress事件为例,我们查看其源代码:

        [SRCategory(SR.CatKey), SRDescription(SR.ControlOnKeyPressDescr)] 
        public event KeyPressEventHandler KeyPress {
            add { 
                Events.AddHandler(EventKeyPress, value);
            }
            remove {
                Events.RemoveHandler(EventKeyPress, value); 
            }
        }

这个Events对象是在Control的基类Component中定义的,我们转到Component类的代码中查看:

        protected EventHandlerList Events {
            get { 
                if (events == null) { 
                    events = new EventHandlerList(this);
                } 
                return events;
            }
        }

再跳转到EventHandlerList的定义,M$的说明是:Provides a simple list of delegates. 这意味着Control类的所有事件委托都存储于EventHandler对象中。回到KeyPress事件的add方法(在C#中的运算符是+=,在VB中是AddHandler),它调用了这个类的AddHandler方法,这个方法实现如下:

        public void AddHandler(object key, Delegate value) {
            ListEntry e = Find(key); 
            if (e != null) {
                e.handler = Delegate.Combine(e.handler, value);
            }
            else { 
                head = new ListEntry(key, value, head);
            } 
        }

现在明白了,Control类实际上为每个对象维护了一个内部的链表,这个链表记录了对于存在的每一个事件的委托(如果某个事件没有被关联,这个链表中就不会存在相对应的委托,所以就没有内存的占用)。

所以,M$在Control类中定义的每个事件的静态对象(就是第一张图中的一堆object)实际上是这个链表中的键,在存储和触发事件时,这个对象作为主键对其对应的委托进行查找。

当添加一个不存在的委托时,会向链表中插入一个新的委托实例:head = new ListEntry(key, value, head);,否则就直接添加到现有的委托链中:e.handler = Delegate.Combine(e.handler, value);。

(备注:调用Delegate.Combine和Remove方法后,委托的引用会被更改,所以要重新设置handler使其指向新的引用。)

ListEntry是EventHandlerList的一个内部类,只是一个简单的链表而已:

        private sealed class ListEntry {
            internal ListEntry next; 
            internal object key; 
            internal Delegate handler;

            public ListEntry(object key, Delegate handler, ListEntry next) {
                this.next = next;
                this.key = key;
                this.handler = handler; 
            }
        }

进行查找时,其Find方法只是简单地遍历链表查找其相应的记录。

在触发KeyPress事件时,如果没有事件与其关联,那么在EventHandlerList中就不存在引用,什么都不做直接返回,否则调用委托回调其关联的方法即可:

        protected virtual void OnKeyPress(KeyPressEventArgs e) {
            Contract.Requires(e != null); 
            KeyPressEventHandler handler = (KeyPressEventHandler)Events[EventKeyPress];
            if (handler != null) handler(this,e); 
        }

至此,我们可以看到使用EventHandlerList,在每个控件只存在少量事件关联的情况下,确实可以大量减少不必要的内存开销。因此建议在实现大量事件的类时,可以参照M$的实现方式。

对于EventHandlerList,有两点需要说明:

  • EventHandlerList是一个链表而不是哈希表,因此查找速度肯定不如Dictionary。但是由于这个类存在的场景是少量事件会被关联(在Control中,平均只有少于10个事件),那么这个问题可以被忽略。特别地,在只有一两个关联事件时,使用这种链表会比Dictionary占用更少的内存。
  • EventHandlerList不是线程安全的。因此在对线程安全有要求的场景需要自己处理同步的问题。但M$提供的代码中都没有考虑线程安全,我估计是订阅、取消订阅基本都是在初始化代码中的事情,因此大多数情况都不存在这个问题。

所以,在有特殊要求的情况下(比如必须保证线程安全;或者事件实在是太多,当然不太可能事件比Control类还多),可以自己实现一个类似的东西。(注意,在定义链表的索引时必须要定义成static的,否则每个对象都会有!此外,M$定义索引用的是object而不是字符串或者值类型的原因不明,但我估计是以后添加新的事件或子类添加新的事件时会很方便,子类不必关心Control类对每个事件定义了什么样的键。

对于内部使用链表而不是哈希表的问题,我个人认为应该使用链表,不赞成自己使用Dictionary替代原来的实现方式。

因为哈希表会有额外的开销(在Dictionary的实现中,会定义近10个实例字段,至少占用40个字节的额外空间;如果是线程安全的哈希表或64位系统还会更多)。而EventHandlerList对象是每个Control对象都会有的,如果有这么多额外开销反而会得不偿失。

所以我觉得在《CLR via C#》这本书中,提供的EventSet这种实现方式是不恰当的。


通过这个东西,我们发现M$在定义这种通用的、且到处都被用到的类时都非常小心,特别注意内存的开销。比如Control类对成员变量前的注释:Do not add anything to this list unless absolutely neccessary. Every control on a form has the overhead of all of these variables!

此外我们看到Object类没有定义任何成员变量,也是基于这一点考虑。

✏️ 有任何想法?欢迎发邮件告诉老夫:daozhihun@outlook.com