Hello World
Spiga

C#的设计缺陷(1):显式实现接口内的事件

2012-05-20 21:07 by 老赵, 6697 visits

其实使用C#这么多年,我时不时会遇到一些令人不爽的设计缺陷。这些缺陷大都是些限制,虽说无伤大雅,也很容易避免,但一旦遇到这些情况,总会令人心生不快,毕竟都是些无谓的限制。而且令人遗憾的是,虽说去除这些限制也不会带来什么问题,但我认为C#设计团队也基本不会去修复这些问题了,毕竟它们大都是些细枝末节。作为一名用C#的纯种码农,我突然一时兴起也要把这些设计缺陷记录下,也方便和大伙一起讨论下。那么这次就先从实现接口内的事件说起,当我们需要显式实现一个接口内的事件时,会发现我们必须提供add和remove访问器,这还会稍许影响到事件常用的使用模式。

强制add和remove访问器

这个问题听上去有些绕,不过看代码便一清二楚。例如,在项目中我会定义一个这样的INotifyPropertyChanged接口,其中包含一个PropertyChanged事件:

public interface INotifyPropertyChanged<TPropertyIdentity>
{
    event EventHandler<PropertyChangedEventArgs<TPropertyIdentity>> PropertyChanged;
}

public class PropertyChangedEventArgs<TPropertyIdentity> : EventArgs
{
    public PropertyChangedEventArgs(TPropertyIdentity propertyIdentity)
    {
        this.PropertyIdentity = propertyIdentity;
    }

    public TPropertyIdentity PropertyIdentity { get; private set; }
}

可以看出这个接口和.NET内置的INotifyPropertyChanged事件可谓如出一辙,其实他们的目的也一样,就是向外通知该对象的某个属性发生了改变。不同的是,系统内置的PropertyChangedEventArgs对象使用属性名,也就是一个字符串标识一个属性,而在如上带泛型的PropertyChangedEventArgs里,则可以使用任意类型的对象来标识属性,这无疑带来的更多的灵活性。例如,我们可以使用连续的整型数值来标识对象,这样我们就可以使用数组来创建一个索引,它的性能会比使用字符串为键值的字典要高出一些。

不过,我们实现系统自带的INotifyPropertyChanged属性时,并非是要“自行使用”,而往往是想让通知其他组件,例如ORM框架或是UI控件。因此,它其实已经是.NET平台上的统一约定,即便有所不足,也不能舍弃它。因此,我们往往需要在一个对象上同时实现两种INotifyPropertyChanged接口,例如:

public class Item : INotifyPropertyChanged<int>, INotifyPropertyChanged
{
    public event EventHandler<PropertyChangedEventArgs<int>> PropertyChanged;

    event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged
    {
        add { throw new NotImplementedException(); }
        remove { throw new NotImplementedException(); }
    }
}

以上是Visual Studio为两个事件实现自动生成的代码框架,且看第二个事件,它要求我们提供add和remove访问器。为什么?我不知道,C#开发团队自己可能也已经不太清楚这么规定的原因

Interesting question. I did some poking around the language notes archive and I discovered that this decision was made on the 13th of October, 1999, but the notes do not give a justification for the decision.

Off the top of my head I don't see any theoretical or practical reason why we could not have field-like explicitly implemented events. Nor do I see any reason why we particularly need to. This may have to remain one of the mysteries of the unknown.

Eric Lippert是老牌C#团队成员了,经常在Stack Overflow或是博客上写一些C#的设计内幕,可惜在这个问题上连他也认为是个“不解之谜”。此外,“自动属性”让这个限制进一步显得“无厘头”了,因为我们完全可以这么显式实现接口里的属性:

public interface INameProvider
{
    string Name { get; set; }
}

public class MyNameProvider : INameProvider
{
    string INameProvider.Name { get; set; }
}

既然如此,事件跟它又有什么本质区别呢?

相关问题

顺便一提,我们知道,在C#里不能把显式实现的接口成员标注为抽象成员,这对于事件来说还存在一些额外的问题。且看以下代码片段:

public abstract class Base : INotifyPropertyChanged<MyIdentity>
{
    public EventHandler<PropertyChangedEventArgs<MyIdentity>> PropertyChanged;

    protected void OnPropertyChanged(PropertyChangedEventArgs<MyIdentity> args)
    {
        var propertyChanged = this.PropertyChanged;
        if (propertyChanged != null)
        {
            propertyChanged(this, args);
        }
    }
}

Base是个基类,因此它往往会暴露个OnXyz方法,以便子类触发Xyz事件。在OnPropertyChanged方法中,我们会先判断_propertyChanged是否为null,因为null表示还没有人注册过事件——这是事件使用时的常见模式。事件本身没有注册任何处理器,则意味着事件本身不触发亦可,同样意味着我们甚至可以不去创建事件所需的EventArgs参数。但是,如果我们是要在子类里触发事件(即调用OnXxx方法),则没有办法检查该事件有没有注册处理器。假如这个EventArgs对象创建起来成本较高,就会造成一定的性能损失。

解决方法倒也简单,例如,在基类里增加一个事件:

public abstract class Base : INotifyPropertyChanged<MyIdentity>
{
    public abstract event EventHandler<PropertyChangedEventArgs<MyIdentity>> MyIdentityPropertyChanged;

    event EventHandler<PropertyChangedEventArgs<MyIdentity>> INotifyPropertyChanged<MyIdentity>.PropertyChanged
    {
        add { this.MyIdentityPropertyChanged += value; }
        remove { this.MyIdentityPropertyChanged -= value; }
    }
}

或干脆加一个“延迟”构造EventArgs的重载:

public abstract class Base : INotifyPropertyChanged<MyIdentity>
{
    private EventHandler<PropertyChangedEventArgs<MyIdentity>> _propertyChanged;

    event EventHandler<PropertyChangedEventArgs<MyIdentity>> INotifyPropertyChanged<MyIdentity>.PropertyChanged
    {
        add { this._propertyChanged += value; }
        remove { this._propertyChanged -= value; }
    }

    protected void OnPropertyChanged(PropertyChangedEventArgs<MyIdentity> args) { ... }

    protected void OnPropertyChanged(Func<PropertyChangedEventArgs<MyIdentity>> argsFactory) { ... }
}

于是在基类里触发事件时即可:

this.OnPropertyChanged(() => new PropertyChangedEventArgs<MyIdentity>(new MyIdentity()));

如果您觉得在没有事件处理器的情况下创建一个委托对象也是一种浪费,那么就自己想办法解决咯。没什么困难的,不应该想不出。

更新

文章写完后很快就有同学回复,说其实Eric Lippert下方那位同学的回答更靠谱。我看了看又想了想,的确如此。

事件是个很特别的成员,平时在使用事件的时候,只能将其放在+=或-=的左边,表示为事件添加或移除处理器——除非是在定义事件的类型内部,我们可以将其“赋值”给其他变量,或是当作参数传递,这时候其实操作的就是一个委托对象了。但如果我们是显式声明一个接口内的事件,我们其实是先要将this转化为具体的接口类型才去使用的:

var propertyChanged = ((INotifyPropertyChanged)this).PropertyChanged;

但我们把this转化为具体类型之后,我们实际上是在从“外部”访问接口上定义的成员。换句话说,在这种情况下我们并不处于定义事件的对象内部,我们无法获得这个所谓的“委托”对象,因为此时可能根本不存在这么一个委托对象,我们只知道事件的add和remove访问器。那么,我们又如何让其自动实现呢?此时强制提供add和remove访问器就是顺理成章的事情了。

总体而言,我这系列的第一枪打得有点歪,开了个坏头。当然从好处想,也是通过交流让我,还有潜在不明真相的同学了解到一些细节。还有再次确认了不能迷信权威,这次Eric Lippert的回答的确不够有说服力,但他还是得到了最多的支持,名气这东西的确挺令人嘘唏的。话说我刚才去StackOverflow上对他的回答投了反对票,可能是已经被采纳为正确答案了吧,我反而还被扣了一份,哈哈。

相关文章

  • C#的设计缺陷(1):显式实现接口内的事件
Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

19 条回复

  1. Latista
    114.246.179.*
    链接

    Latista 2012-05-20 22:10:46

    我觉得StackOverflow原文里Eric下面的那个回答比Eric的更靠谱

  2. 老赵
    admin
    链接

    老赵 2012-05-20 22:22:53

    @Latista

    似乎挺有道理的,我怎么就没往下看唉。看来这系列文章开局不利嘛……

  3. 装配脑袋
    207.46.92.*
    链接

    装配脑袋 2012-05-21 15:20:40

    发现VB可以的说……

  4. 老赵
    admin
    链接

    老赵 2012-05-21 16:15:59

    @装配脑袋

    啊哈,VB是怎么搞的啊?

  5. 装配脑袋
    207.46.92.*
    链接

    装配脑袋 2012-05-21 20:03:48

    VB可以用任意名字和访问级别的成员来实现接口,所以无需显式实现这一功能。而且也可以用普通的Event实现接口。

  6. 老赵
    admin
    链接

    老赵 2012-05-21 22:48:02

    @装配脑袋

    不过换个名字就类似于在C#里用另一个事件“委托”了一下,不是嘛……

  7. tokimeki
    203.69.196.*
    链接

    tokimeki 2012-05-23 15:09:53

    我這裡也有一個奇怪的問題,詢問過許多人,至今沒有找到解答:當用一個陣列當參數呼叫有一個 IList 做參數的方法時,為何此參數的 IsReadOnly 時會得到 True?

    這個問題有點奇怪,用程式來說明好了:

    using System;  
    using System.Collections;  
    using System.Collections.Generic;  
    
    namespace TestAny {  
        internal class Program {  
            private static void Main(string[] args) {  
                var t = new int[] { 1, 2, 3, 4 };  
                Console.WriteLine("Array.IsReadOnly = " + t.IsReadOnly);  
                Test0.Run(t);  
                Test1.Run(t);  
                Console.ReadKey();  
            }  
        }  
    
        public static class Test0 {  
            public static void Run(IList array) {  
                Console.WriteLine("IList.IsReadOnly = " + array.IsReadOnly);  
            }  
        }  
    
        public static class Test1 {  
            public static void Run(IList<int> array) {  
                Console.WriteLine("IList<T>.IsReadOnly = " + array.IsReadOnly);  
            }  
        }  
    }  
    

    輸出是:

    Array.IsReadOnly = False
    IList.IsReadOnly = False  
    IList<T>.IsReadOnly = True 
    

    直接檢查該陣列變數的 IsReadOnly 屬性,或是呼叫有 IList 參數的方法內檢查該變數的 IsReadOnly 屬性都是 False。

    唯獨在呼叫有 IList 參數的方法內檢查該變數的 IsReadOnly 屬性會變成 True。

  8. 链接

    weizhi he 2012-05-23 17:54:21

    @tokimeki

    从 .NET Framework 2.0 开始, Array 类实现 System.Collections.Generic.IList、 System.Collections.Generic.ICollection和 System.Collections.Generic.IEnumerable 泛型接口。 由于实现是在运行时提供给数组的,因而对于文档生成工具不可见。 因此,泛型接口不会出现在 Array 类的声明语法中,也不会有关于只能通过将数组强制转换为泛型接口类型(显式接口实现)才可访问的接口成员的参考主题。 将某一数组强制转换为这三种接口之一时需要注意的关键一点是,添加、插入或移除元素的成员会引发 NotSupportedException。

  9. tokimeki
    114.34.164.*
    链接

    tokimeki 2012-05-23 18:51:25

    @weizhi he

    你說的這些我都了解,我覺得奇怪的地方不在你說的數組轉型引發的副作用上,而是針對 IsReadOnly 這個屬性,在不同狀況下對一個數組做出的不同解釋。

    如果你用 ILSpy 之類的工具去觀察 .Net 的容器類別,你會發現像 ArrayList、List 的 IsReadOnly 都是直接返回 False,而像 ReadOnlyCollection 之類的 IsReadOnly 都是直接返回 True。

    也就是說,不同的容器在實作介面屬性時,是有一定的規則的。 我感到奇怪的地方是為何獨獨在 IList 的時候是例外,雖然數組不是顯式實現 IList 介面。

  10. 幸存者
    112.64.50.*
    链接

    幸存者 2012-05-23 21:24:31

    @tokimeki

    IList.IsReadOnly 继承自 ICollection.IsReadOnly,而 ICollection.IsReadOnly 的语义是指当其为 true 时集合不能增删,因为 ICollection 是没有索引访问器的,但是 IList.IsReadOnly 为 true 时除了指不能增删还包括不能修改元素,这导致 IList.IsReadOnly 和语义不一致,因为 IList 继承自 ICollection 而不继承自 IList。对于语言/框架设计者来说不可能去修改 IList.IsReadOnly 的语义因为有向后兼容的问题。

  11. 链接

    幸存者 2012-05-23 21:37:30

    @tokimeki

    IList< T>.IsReadOnly 继承自 ICollection< T>.IsReadOnly,而 ICollection< T>.IsReadOnly 的语义是指当其为 true 时集合不能增删,因为 ICollection< T> 是没有索引访问器的,所以肯定不能修改元素。但是 IList.IsReadOnly 为 true 时除了指不能增删还包括不能修改元素,这导致 IList< T>.IsReadOnly 和 IList.IsReadOnly 语义不一致,因为 IList< T> 继承自 ICollection< T> 而不继承自 IList。对于语言/框架设计者来说不可能去修改 IList.IsReadOnly 的语义因为有向后兼容的问题。

  12. tokimeki
    203.69.196.*
    链接

    tokimeki 2012-05-24 09:29:26

    我一直沒發現尖括號會被 escape 掉...

    @幸存者

    你給出的理由我只能同意歷史因素的那一半。

    但是在實務上,IList< T> 同時實作了 IList 以及 IList< T> 兩個接口,也就是說你提的那個狀況,完全可以用 IList.IsFixedSize 這個屬性來判斷。 退一步說,假設有一個 class Foo : IFoo 且 interface IFoo : IFooBase ,那麼你覺得 Foo 是比較像 IFoo 還是比較像 IFooBase? 而且接口定義的屬性語方法在被另一個接口繼承後,其含意可能會被"適化"以符合該接口的行為。

    當然你可以說某一類別的 ICollection< T>.IsReadOnly 跟 IList< T>.IsReadOnly 可能不同,這個我同意。 但是類別在實作接口時,是可以只實現一個同名的屬性或方法(只要其參數簽名相同)的,所以在需要偷懶的時候,我會選擇讓 IsReadOnly 的行為表現的像該類別所應該做的那樣。

  13. lecone
    65.49.2.*
    链接

    lecone 2012-05-24 11:28:19

    老赵,看你的博客还要翻墙啊。。。

  14. 新号外
    66.79.161.*
    链接

    新号外 2012-05-24 11:34:09

    先记录下来再说咯~

  15. 装配脑袋
    207.46.92.*
    链接

    装配脑袋 2012-05-24 13:19:18

    Interface I1
        Event E1 As EventHandler
    End Interface
    
    Class C1
        Implements I1
    
        Private Event MyExplicitE1 As EventHandler Implements I1.E1
    End Class
    

    代码测试。。似乎不能高亮

  16. 老赵
    admin
    链接

    老赵 2012-05-24 22:04:37

    @装配脑袋

    这相当于我说的在C#里用另一个事件代理一下嘛。

  17. 张占岭
    114.242.165.*
    链接

    张占岭 2012-05-25 15:20:57

    老赵同志,您好,终于看到了这篇文章,我在进行MVC架构时也遇到了类似的问题,事件是这样的:

    LinqToSql原生的类去继承一个实体基类,在基类中有一个子类中修改属性基类去订阅子类的PropertyChanged事件,当子类属性修改时向修改的字段和值返回给基类,好像在基类中是捕捉不到子类的这个事件的,最后我改了一个代码,但感谢程序不完美了

    子类的方法:

    partial void OnCreated()
    {
        this.PropertyChanged += new PropertyChangedEventHandler(base.PropertyChangedEvent);
    }
    

    基类的方法:

    protected virtual void OnPropertyChanged(String propertyName, object newValue)
    {
        // 记录字段变化 
    }
    

    其实我是想实现,只在基类中进行捕捉,不想把每个子类都写一段重复的代码,呵呵。

  18. 老赵
    admin
    链接

    老赵 2012-05-25 21:15:40

    @张占岭

    听不懂你什么意思,还有这真是类似的问题吗?

  19. 陈澄
    58.33.26.*
    链接

    陈澄 2012-06-09 15:40:37

    SO上downvote被扣了一分都必须的,防止恶意downvote的,你downvote了他,他扣2分,你扣1分。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我