C#确实是一个好语言,无论是从功能上还是性能上。但是用久了难免会发现并吐槽其中的一些SB设计。如果是稍微难用点(比如语法复杂点、调用麻烦点)那倒无所谓,但如果是为以后的bug开坑那就是不好的东西了,这里记录几个我最近发现的不爽的地方吧。
1. 支持协变数组
个人认为协变和逆变是面向对象编程语言中一个很好的特性。
比如我定义了一个哺乳动物Mammals类和狗Dog类(Dog继承自Mammals),那么假如我有一个以返回值为Mammals的委托,那么实际上返回值为Dog的方法是可以添加到委托里的。——这很自然,Dogs肯定包含了Mammals的所有定义。
反过来,假如我想要写一个通用的方法处理控件的各类事件,我可以这样定义方法:
private void MultiHandler(object sender, System.EventArgs e)
{
}
这个方法就能和各类事件比如MouseClick和KeyDown关联上:
public Form1()
{
InitializeComponent();
this.textBox1.KeyDown += this.MultiHandler; //works with KeyEventArgs
this.button1.MouseClick += this.MultiHandler; //works with MouseEventArgs
}
但是,这个特性不是到处都有用的。比如C#支持数组的协变,那么假如我定义了下面这两个类:
class Father
{
public string Name { get; set; }
}
class Child : Father
{
public int Age { get; set; }
}
我就可以把一个类型为Child的数组传递给一个类型为Father的数组引用:
Father[] f;
Child[] c = { new Child { Name = "JDP", Age = 24 }, new Child { Name = "ABC", Age = 2 } };
f = c;
初看似乎没什么问题,至少这样很灵活么?但是如果f这个变量是公用的,有好几个程序员都在用这个变量。那么很有可能有些人不知道f里面实际上是一个Child的数组(因为定义是Father),所以他把一个Father的对象放入数组中:
f[1] = new Father { Name = "XXY" };
因为运行时的类型是Child[],那么这么做自然是不被允许的:
这就很令人莫名其妙,而且错误只有在运行时才会产生,编译过程中不会出错(因为C#认为f是Father[],它不知道具体的运行时类型)。
其实.NET最初设计的时候,看到Java支持这个特性,所以就照搬了过来,目测是更多地“吸引”Java程序员并符合他们的习惯。在Java中这应该也是一个缺陷之一。
不过还好,.NET里面的泛型类是不支持这种协变的,比如我定义了一个List<object>,那么我是不能传递一个List<string>类型的实例对象的,在编译的时候就会被阻止:
要在泛型类中使用协变,需要借助接口的方差参数,比如IEnumerable的定义如下:
public interface IEnumerable<out T> : IEnumerable
在方差中使用out关键字,表示输出类型允许协变,这样下面的代码是合法的:
IEnumerable<Derived> d = new List<Derived>();
IEnumerable<Base> b = d;
无论如何,数组的协变是不合理的,而且与泛型的表现不一致,所以是C#的一大弊端。
2. 支持非虚方法
非虚方法是C#从C++抄过来的特性(毕竟都有C这个字母嘛,所以要像一点)。任何没有virtual关键字修饰的方法都是非虚的,子类不可以重写。
要是光是不能被重写,那还可以接受,但是C#偏偏支持方法的“遮蔽”new关键字,让子类把父类的相同签名的方法“隐藏”起来,这就让人很迷惑了——调用者甚至都不清楚自己到底是调的哪个方法。
C#是类型安全的编程语言,任何类型都无法伪装成另一种类型。这是为什呢?我们来看看Object类的定义:
可以看到,在实例方法中除了GetType以外,其他方法都是虚方法。那么就意味着GetType方法不允许被子类重写。Object类的GetType方法是CLR的内部方法,返回一个对象的具体类型(也就是一个Type类型的引用)。子类无法重写GetType方法,那么自然没有办法宣称自己是一个其他的类型——因为调用Object的GetType就知道真实的类型了。
但是有一点很容易被忽略:虽然GetType是非虚的,但是子类可以把它“遮蔽”掉,比如我是这样写一个类:
class Father
{
public string Name { get; set; }
public new Type GetType()
{
return typeof(String);
}
}
我想把自己“伪装”成String类,那么下面代码输出什么呢?
Father fat = new Father { Name = "Xiaoyu" };
Console.WriteLine(fat.GetType());
你答对了,自然是System.String。那么如果我们要获取其真实的类型那就要保证调用到Object的GetType方法。由于是非虚的,那么理论上要把fat对象放到一个object的引用里面去:
object o = fat;
Console.WriteLine(o.GetType());
这样子才能保证调用到Object的GetType方法。Microsoft似乎发现了这个问题,所以它提供了typeof运算符。
GetType只是一例,稍不留神就可能发生错误。那么其他自己写的方法就更加可能产生问题了。
Java就很好,干脆所有的方法都是虚方法,这样就完全没有C#的这种问题。此外,假如Java也使用GetType来获取类型,那它应该怎么样防止子类重写呢?哈哈,Java有final修饰符(.NET中方法的sealed修饰符只能把虚方法和虚属性的方法密封,所以GetType无法添加sealed修饰符)。
3. IEnumerable扩展方法的滥用
IEnumerable接口上的扩展方法是LINQ的基础,也是一个比较好用的工具。但是扩展方法很容易被不恰当地“滥用”——因为它实在是太方便了。而且,它的使用屏蔽掉了不该屏蔽的一些“实质”。
举一个比较极端的例子,我定义了一个ConcurrentQueue,里面有一堆的元素。但是队列嘛,一般只支持查看队首的元素,假如由于某种需求我需要查看其中所有的元素(这是前两个月我做的一个产品中的需求!),那么怎么办呢?
首先我自然是查看这个类中定义的方法,比如我看到了如下这个方法:
于是我高兴得蹦了起来(当然不是真实的“我”,因为我知道这个东西的实现原理),原来可以像数组一样直接按索引访问,哇哈啊哈,那问题就简单了。
为了说明会导致的问题,假如这个人采用了如下这种方式去计算队列里各元素之和(当然扩展方法提供了Sum聚集函数,但为了说明问题我只是举一个例子):
var cq = new ConcurrentQueue<int>();
for (int i = 0; i < 500000; i++)
cq.Enqueue(i);
Int64 sum = 0;
for (int i = 0; i < 500000; i++)
sum += cq.ElementAt(i);
Console.WriteLine(sum);
这会怎么样呢?按道理50万个元素遍历应该0.1秒都不需要,但是实际上运行的时候,很长时间程序都无响应——至少我等了十几秒都没看到出结果。
这是为什么呢?那么我们只有查看ElementAt这个扩展方法的定义:
public static TSource ElementAt<TSource>(this IEnumerable<TSource> source, int index);
可以看到ElementAt是IEnumerable<T>这个泛型接口的扩展方法,它接收一个IEnumerable<T>的实例对象以及索引作为参数——那么就意味着:每次访问ElementAt方法都会产生一个迭代器!
那么我们那个代码遍历50万个元素,每访问一次ConcurrentQueue中的元素都会生成一个包含50万个元素的迭代器(内部实现其实是把队列中的元素复制到一个List中的),程序的运行时间可想而知。
不光是这个方法,定义在数组、List等集合类型的扩展方法大都是IEnumerable接口的扩展方法。那么LINQ在执行的时候,比如下面这个查询语句:
var r = from c in cq
where c.Age > 20 && c.Name.Length > 0
orderby c.Name
select c;
在编译后,实际上是生成的下面这种链式查询:
var r2 = cq.Where(c => c.Age > 20 && c.Name.Length > 0).OrderBy(c => c.Name);
按照由于都是IEnumerable的扩展方法,所以这个查询实际上会产生2个额外的迭代器,也就是说对象的引用会被赋值2次,更复杂的查询肯定需要更多的操作。此外,由于是迭代器,所以不会像数据库那样会有任何优化(比如索引),元素都是按顺序遍历的(每个predicate都会针对每个元素执行一次),所以效率肯定不高。
我个人认为Microsoft提供这种LINQ方式(包括扩展方法)在很大程度上减少了程序员的负担,使其很方便地可以对数据进行复杂的操作。但由于这种便利,很容易被“初学者”滥用(不光是“初学者”,不少很有经验的程序员都意识不到这一点),在数据量稍大的情况下差异逐渐显现出来。
我觉得Microsoft提供LINQ这种强大的机制是好的,但是应该强调它的实质以及使用时注意事项,给予开发人员更多地提示(比如ElementAt方法),并且对于类似于ConcurrentQueue、Hashset之类不要实现IEnumerable的接口(本来用了这些特殊数据结构的程序无论如何都不应该去遍历元素),让他们意识到可能会发生的问题。
这样,在论坛上就不会看到N多新手喷LINQ的效率太差的情况了。
4. 不支持内联方法
C#把C++的非虚方法都抄过来了这个为啥不抄过来呢?还是以我那个超大数据的ConcurrentQueue为例,假如我要对其中每一个元素进行操作,比如返回所有人的名字的大写并生成迭代器:
var new_enumerator = cq.Select(c => c.Name.ToUpper());
Select扩展方法实际上接收的是一个委托:
public static IEnumerable<TResult> Select<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector);
那么对于我们这50万个元素的东东,这个委托就会被访问50万次,对于这种频繁调用方法的开销在这种情况下是非常大的。假使C#支持C++那种inline的方法就会显著改善这个问题。
但CLR生成代码的时候可能会产生优化生成内联的方法,但似乎有限制(比如方法不能超过32个字节等等)。无论如何不灵活也不透明,C#应该让程序员来选择哪些方法可以内联(况且实现的代价也不大我觉得)。