您的位置:首页 > 编程语言 > C#

C#的十大遗憾

2016-07-12 20:42 281 查看
本文翻译自 Eric Lippert 的博客 https://ericlippert.com/2015/08/18/bottom-ten-list/。他曾经是
C# 设计组的一员,而且是 《Essential C# 6.0》 第5版的作者之一。
-------- 分割线 -----------
我以前在 C# 设计组的时候,每年都有几场见面会活动,回答 C# 爱好者的问题。最常见的问题可能是这个:有没有什么设计决定是让你们现在后悔的?我的答案是,那必须有啊!
本文中列出了我个人心目中的“C#的最差10大特性”,以及从这些决定中我们可以学到的语言设计经验。
正式开始之前,必须声明一下,第一,我的这些意见只代表我自己,不代表 C# 设计组。第二,所有这些设计决定都是由非常聪明的人做的,他们一直都在试图在各种设计目标之间寻找平衡。每一种情况,在当时都有强力的论点支撑,事后诸葛亮来看的话我们当然可以轻易提出批评。所有这些特性其实都只是一门成功的语言中的非常小的瑕疵。
现在开始吐槽了!
10:空语句毫无意义
跟许多其它的C系列的语言一样,C# 也要求一条语句要么用 } 结尾,要么用分号 ; 结尾。一个容易被忽视的特性是,这些语言中,一个单独的分号也是一条合法的语句:

void M()
{
; // 完全合法
}


你为什么需要一条什么都不做的空语句?下面是几个合理的场景:
你可以为空语句设置断点。在 Visual Studio 里,有些时候一条断点是在语句的开始还是中间是让人迷惑的,如果在空语句中设置断点就没有歧义了;
有些场景下,你需要一条语句,但是又不需要做什么事情:

while(whatever)
{
while(whatever)
{
while(whatever)
{
if (whatever)
goto outerLoop;//跳出两层循环
[...]
}
}
outerLoop: ;
}


C# 里跳转标签后面必须有一条语句;这里空语句就是跳转目标。当然,如果有人请我来检查这段代码,我马上会建议把深度嵌套的循环加 goto 跳转重构为其它的更易读更容易维护的代码。这样的分支结构在现代代码中是非常少见的。
我已经解释了这个功能的好处 —— 当然跟其它语言保持一致性也很不错 —— 但是这些优点其实都没什么吸引力。下面这个示例展现了这个功能的缺点:

while(whatever); // 靠!
{

[...]

}


第一行最后的那个出其不意的分号几乎很难被看到,但是它对程序的含义有巨大的影响。这个循环体是一个空语句,跟着一个语句块,这个语句块很可能才是期望的循环体。如果循环条件是 true 的话,这段代码将是死循环;如果条件是 false 的话,它只会把循环体执行一次。
C# 编译器对这种情况给了一个“可能是意料之外的空语句”警告。警告表明这段代码几乎肯定是错的;理想情况,语言应该避免那些很可能是错误的用法,而不是仅仅警告它们!如果编译器团队必须为一个功能设计、实现、测试一条警告,往往说明这个功能很可能一开始就是有问题的,当然对于相对轻量级的空语句功能,这个设计的开销不是很大。幸运的是,这个缺陷在产品中是非常少见的;编译器警告了它,在测试的时候这个死循环是很容易发现的。
最后,在 C# 中有不止一种方法来构造空语句;比如空语句块:

{}


意外地写出一个空语句块是困难的,在源代码中很难忽视,所以这才是需要空语句场景中,我首选的语法。
分号空语句是一个冗余的、很少用的、容易出错的功能。而且它给编译器组带来了额外的工作量,去实现一条警告来告诉你不要使用它。这个功能应该从 C# 1.0 版本就砍掉。
我这篇文章中提到的所有功能的问题是,一但我们有了这个功能,设计者就必须永远保留它。后向兼容性对于 C# 设计组来说是宗教。
教训:当你设计一门语言的第一版的时候,仔细思考每个功能的价值。许多其它的语言可能有这个不重要的小功能,但是这并不是把它加入新语言中的充分理由。
9:太多的判等方式
假如你想实现一个支持各种算术运算的值类型,比如,有理数类型。用户可能希望能比较两个有理数是否相等。但是怎么搞?很简单,只要实现下面的这些:
用户自定义运算符, >, <, >=, <=, ==, 以及 !=
重写 Equals(object) 方法

上面这个方法需要把结构体装箱,所以你会想要一个 Equals(MyStruct) 方法,它可以用于实现

IEquatable<MyStruct>.Equals(MyStruct)


你最好还要实现这个

IComparable<MyStruct>.CompareTo(MyStruct)


额外提一点,你还可以实现一个非泛型版本的 IComparable.CompareTo 方法,尽管现在我很可能不会这么做

我上面提到了 9 种(或者10种)方案,它们必须互相之间保持一致性;如果 x.Equals(y) 是 true,但是 x == y 或者 x >= y 是 false 的话,就很扯蛋了。这看起来像个 bug。
开发者必须实现9种方法,保持一致;然而只要其中一个方法的输出(泛型的 CompareTo)就足够让其它8种方法推理出结果了。开发者的负担超过了实际需要的N倍。
另外,对于引用类型来说,当你想做“值相等”的时候,很容易意外使用了“引用判等”的操作,然后就错了。
整个事情是毫无必要的搞复杂了。语言应该设计为,如果你实现了 CompareTo 方法,其它的方法都自动实现了。
寓意:太多的灵活性会让代码冗长,而且创造了产生bug的机会。利用这个机会去消灭、清除、避开设计中不必要的重复的冗余吧。
8:左移右移运算符
跟许多 C 系列的其它语言一样,C#有左移 << 和右移 >> 运算符。它们有许多的设计问题。
首先,如果把一个 32 位整数左移 32 位,你觉得应该是什么结果?这事看起来毫无意义,但是它必须执行,那它应该怎么执行?你可能会觉得左移 32 位等同于把整数循环执行 32 次左移 1 位的操作,因此结果是 0。
这个假设非常有道理但是完全错了。32 位整数左移 32 位是一个空操作;跟左移 0 一样。更扯蛋的是:左移 33 位跟左移 1 位是一样的。C#标准规定,移位数值被处理为 & 0x1F。跟C语言的“未定义行为”相比,这是一个进步,但是这个设计也不怎么样。
这个规定也暗示了,左移 -1 并不等于右移 1,这个结果又是没什么道理的。实际上,搞不懂为什么 C# 一开始就有两个移位操作符;为什么不是一个操作符,并接受使用正或者负操作数?(回答这个假设性问题需要深挖C语言的历史,不在本文讨论范围内)
我们来退一大步再想。为什么我们要把整数当成一个小bit数组来看待(它的名字就暗示了它应该被当成数字来看待)?绝大多数今天的 C# 程序员根本不需要写位运算;他们都在用整数写业务逻辑。C#原本应该创建一个“32位的数组”类型,把整数隐藏到后面,并且把位运算相关操作应用到这个特殊的类型上。C# 设计者已经为 pointer-sized 整数和 enum 做了一些类似的整数运算限制。
这里学到了两个教训:
遵循最小惊讶原则。如果一个功能让几乎所有人感到吃惊,那它很可能不是一个好设计。
充分利用类型系统的优势。如果有两个毫不相干的使用场景,比如“数字”和“一组bit”,应该使用两个类型。
7:我为 lambda 狂
C# 2.0 加入了匿名委托:

Func<int, int, int> f =
delegate (int x, int y)
{
return x + y;
};


注意这是一个非常“重量级”的语法结构;它要求 delegate 关键字,参数列表必须显式标记类型,而且函数体是一个语句块。返回类型是自动推理的。C# 3.0 需要一个更加轻量级的语法,让 LINQ 工作起来,所有的类型都是自动推理的,函数体也可以是表达式,而不是语句块:

Func<int, int, int> f = (x, y) => x + y;


我觉得所有人都会同意,为同一个东西提供两个不一致的语法是非常令人遗憾的。C# 不得不这么做,因为 C# 2.0 代码仍然在使用老语法。
重量级的C# 2.0语法在那个时候看起来是有益的。当时的想法是,用户可能会对嵌套方法感到疑惑,设计组希望用一个清晰的关键字来告诉用户,嵌套方法在被转换成一个委托。没有人会想到,在几年之后,我们还需要一个更轻量级得多的语法。
这个寓意很简单:你无法预测未来,当到了未来的那一刻,你也不能不顾后向兼容性问题。哪怕你做出了理性的决策达成了妥协,当需求意外变更的时候,你依然可能是错的。设计一门成功的语言,最困难的事情就是,让简洁性、清晰性、普遍性、灵活性、效率等等保持平衡。
6:位运算的额外括号
在第 8 项中,我建议如果bit操作运算符被独立出来应用于一个特殊的类型就好了;当然,enum 枚举就是其中一个例子。对于 flag 枚举经常会碰到下面这样的代码:

if ( (flags & MyFlags.ReadOnly) == MyFlags.ReadOnly)


在现代风格中,我们应该使用第4版的 .NET 框架加入的 HasFlag 方法,但这种模式在遗留代码中依然非常常见。为什么这些小括号是必须的?因为在C#中,“按位与” 运算的优先级比 “等于” 运算的优先级低。比如说,下面的两行代码是一样的意思:

if ( flags & MyFlags.ReadOnly == MyFlags.ReadOnly)
if ( flags & ( MyFlags.ReadOnly == MyFlags.ReadOnly) )


显然开发者不是这样的意图,幸亏C#的类型检查,这样的代码编译不通过。
“逻辑与”运算符的优先级也比“等于”符号优先级低,但这是个好事。我们希望这段代码:

if ( x != null && x.Y  )


被这么理解:

if ( (x != null) && x.Y  )


而不是这样:

if ( x != (null && x.Y) )


总结一下:
& 和 | 基本都用于算术运算,因此它们应该比等于优先级高,跟其它算术运算符一样。
具有短路功能的 && 和 || 优先级比等于号低,这是好事。为了一致性,& 和 | 的优先级也应该更低,对不对?
基于此论点,&& 和 & 应该都比 || 和 | 优先级高,但是事实也并非如此。
结论:太乱了。为什么C#这么搞?因为C语言是这么搞的。为什么?我引用最近的C的设计者 Dennis Ritchie 的原话:

回想起来,要是把 & 符号的优先级改为比 == 优先级高就好了。但是仅仅是把 & 和 && 分开而不调动 & 和已有的运算符的优先级顺序看起来更安全。(毕竟,我们有好多兆的源码,而且可能有[三个]设备上安装……)

Ritchie 的讽刺说明了一个教训。为了避免修复几台机器上的几千行代码,我们最终在许多后继的语言中保留了这个设计错误,现在压根不知道有多少万亿行代码受影响。如果你要做一个后向不兼容的改变,现在是最合适的,越拖越糟糕。
5:类型开头,问题在后
在第 6 项中已经看到,C# 从 C 语言以及许多其它先行者中继承了 “类型先行” 的模式:

int x;
double M(string y) { ... }


跟 Visual Basic 相比:

Dim x As Integer
Function M(Y As String) As Double


或者 TypeScript:

var x : number;
function m(y : string) : number


好吧,VB 中的 dim 有点古怪,但是这些语言以及其它许多语言都遵循了一条简单明智的模式 “种类,名字,类型”:这玩意是个什么东西?(一个变量) 这变量的名字是什么?(x) 它的类型是什么?(数字)
反过来,像 C/C#/Java 这样的语言,“种类”是从上下文中推理出来的,而且一直把类型放到名字签名,好像类型是最重要的东西一样。
为什么这个设计比另一个(C这种)好?考虑一下lambda的样子:

x => f(x)


它的返回类型是什么?是 => 箭头右边的那个东西的类型。所以,如果我们像普通函数一样写这段代码,为什么需要把返回类型尽可能放左边?不论从编程还是数学上来看,惯例是计算结果写在右边,所以 C 系列语言把类型放左边是很诡异的事情。
“种类,名字,类型”这种语法的另外一个好处是,它对初学者很友好,可以很清楚地在源码中看出来“这是一个函数,这是一个变量,这是一个事件”,等等。
教训:当你设计一门新语言的时候,不要盲从以前的语言的传统。C# 要是把类型标记放到右边的话,依然可以让 C 背景的程序员容易理解。像 TypeScript, Scala 以及其它许多语言,都是这么做的。
4:枚举 flag 让人失望
在C#中,枚举 enum 仅仅只是类型系统中对整数类型的薄薄的一层包装。针对枚举类型的所有操作都被规定为整数的操作,而且枚举类型的成员名字就跟常量一样。因此,下面的这个枚举是完全合法的:

enum Size { Small = 0, Medium = 1, Large = 2 }


而且可以用任意值赋值:

Size size = (Size) 123;


这是危险的行为,因为 Size 类型本来只准备有 3 种取值可能,如果你给它一个范围外的值它就乱套了。写这种非预期输入的不健壮的代码实在太容易了,而这种问题恰恰应该是类型系统来解决的问题,而不是恶化问题。
我们是否应该简单的说,用取值范围外的值做赋值操作是非法的?那我们就必须生成动态检查的代码,但是收益与代价根本不相称。当涉及到 flag 枚举的时候问题来了:

[Flags] enum Permissions
{ None = 0, Read = 1, Write = 2, Delete = 4 }


它们可以用位运算操作符组合起来,表达“可读或者可写但不可删除”。这个值应该是 3,然而根本不在这个枚举的可选范围之内。如果有大量的 flag,把所有合法的组合都列出来是非常大的累赘。
跟前面讨论的一样,问题在于,我们把两个概念混为一谈了:一组间断的选项中的选择,以及一些 bit 的数组。要是我们有两种 enum 的话概念就清晰多了,一个针对不同选项的操作,另一个针对一组flag的操作。前一种可以有取值范围检查机制,而后一种可以有高效的位运算操作。这种混到一起的做法让我们两边不讨好。
这里的教训和第8条类似:
enum 的值有可能在它的成员的取值范围之外,这事违反了最小惊讶原则。
如果两个使用场景基本没什么共同点,就不要把它们在类型系统中混为一个概念。
3:自增运算符是减分
我们再一次碰到 C# 中的这种功能,它们的存在是因为它们在 C 语言中存在,而不是因为它们是好主意。自增自减操作符就是这样的,用的很普遍,经常被误解,而且基本上是毫无用处的。
首先,它们的特点就在于,既提供值又提供副作用,这对我来说是一个大大的自动减分项。表达式的用处应该体现在,它们提供值,而且计算过程中没有副作用;语句应该产生唯一的副作用。几乎所有的自增自减操作符的使用场景都违反了这个原则,除了这种情况:

x++;


它也可以这么写:

x += 1;


或者更清晰点:

x = x + 1;


其次,几乎没有人可以精确地说清楚前置和后置运算符的区别。我听到的最常见的错误的描述是这样的:“前置形式先做加法,再赋值,最后生成值;后置形式先生成值,然后做加法,最后赋值”。为什么这个描述是错的?因为它暗示了每件事情的先后顺序,而C#实际上不是这么做的。当操作数是变量的时候,真正的行为是这样的:
确定变量的值,两种形式都一样。
确定往内存写入什么值,两种形式都一样。
执行赋值,两种形式都一样。
生成表达式的值。后置形式生成原始的那个值,前置形式生成赋值的那个值。
宣称后置形式首先生成值,然后执行加法和赋值是完全错误的。(在C和C++中有可能,C#中不是。)在C#中,赋值操作必须在表达式生成值之前完成。
我承认,这个吹毛求疵的微小的细节几乎对真实代码没有影响,但是我仍然觉得很烦,因为大部分使用这个功能的程序员说不清楚它真正做了什么事情。
我觉得更坏的事情是,我根本没办法记清楚下面哪句是正确描述 x++ 的:
运算符在操作数的 后面,所以结果是加法 后 的值。
操作数在操作符的 前面,所以结果是加法 前 的值。
两种记法都有道理——它们互相矛盾。
当我写这篇文章的时候,我不得不打开 C# 标准去检查一下我是不是记错了,而这是一个已经使用这个操作符 25 年的人,并为这个功能在多门语言的编译器中写过它们的代码生成。我肯定不是唯一一个觉得这功能没什么卵用的人。
最后,许多从 C++ 背景中过来的人会非常惊奇地发现 C# 处理自定义自增自减运算符的方式和 C++ 完全不同。或许更严谨的说,他们一点也没感到奇怪——他们写错了而且根本没发现有什么区别。在 C# 中,用户自定义自增自减运算符返回的值赋值的那个值;它们不修改内存。
教训:一门新语言不应该仅仅因为传统,就加入一个功能。许多语言没有这样的功能一样活得很好,而且C#已经有了多种自增变量的办法。
额外的特别吐槽!
我对赋值操作符既有值,又有副作用,有同样的想法。

M(x = N());


它的意思是“调用 N,赋值给 x,然后使用这个值作为 M 的参数”。这个赋值操作符在这里同时用到了它的值和副作用,令人费解。
C# 本应该设计成赋值运算符只在语句中合法,在表达式中不合法。不多说了。
2:我想把 finalizers 析构掉
C# 终结器(Finalizer,也被称做析构函数 destructor),语法跟 C++ 的析构函数一模一样,但是语义完全不同。2015年5月,我写了一系列的文章说明终结器的危险,我不会在这里重复一次。简单点说,在C++中,析构函数是确定性的,在当前线程执行的,而且永远不会在“部分构造”的对象上执行。在C#中,终结器可能不会执行,可能由垃圾回收器决定它何时执行,可能在另外一个线程执行,可能在任意的对象上执行——哪怕这个对象构造函数都被异常打断没执行完。这些区别导致了写一个完全健壮的终结器非常困难。
另外,任何时候终结器执行的时候,你可以说这个程序要么有一个 bug 要么处于一个危险的状态,比如通过 abort 意外终止一个线程。需要析构的对象很可能需要的是通过 Dispose 机制实现的确定性析构,它会压制终结器的执行,所以终结器执行往往是一个 bug。在进程中的对象在被意外销毁的时候不应该调用终结器,就好比大楼开始倒塌的时候没必要继续洗碗一样。
这个功能令人费解,容易出错,经常被误解。它的语法对C++用户非常熟悉,但是有奇怪的不同的语义。大部分情况,使用这个功能是危险的,不必要的,或者是bug的征兆。
显然我不喜欢这个功能。然而,确实有些场景很适合它,一些关键的资源必须被释放。这些代码应该由那些完全理解它的专家来写。
教训:有些时候你需要实现一个仅仅适合专家使用的基础的功能,这些功能应该显式标记为危险的——而不是搞得和其它语言中的功能相似。
1:你不能把老虎放进金鱼缸,但是你可以尝试
假如我们有一个基类 Animal,两个子类 Goldfish 和 Tiger。这段代码可以编译:

Animal[] animals = new Goldfish[10];
animals[0] = new Tiger();


当然它会在运行的时候可怕地崩溃,你不能把老虎放到一个金鱼的数组中。但是,这难道不应该是类型系统的全部意义吗?如果你犯了这样的错误,它应该给一个编译错误从而避免运行时崩溃。
这个功能叫做 “array covariance”(数组协变),它允许开发者处理这样的场景:你有一个金鱼数组,有一个用动物的数组作为参数的方法,这个方法只读这个数组,而不想去重新分配内存做一份数组的拷贝。当然,如果这个数组试图往数组中写内容的话问题就出现了。
显然,这是一个危险的小知识。但是既然我们知道了,我们就可以避免,是不是?当然,但是这个功能的缺点不止是这个危险性。想一下上面这个程序中运行阶段的异常应该怎么产生吧。每一次你写一个表达式,里面有一个引用类型的数组,元素是它的子类型的话,运行时就必须做这样的类型检查,来保证这个数组内的元素是相容的。为了能在调用这样的函数快一点,几乎所有的数组“写操作”都会变得慢一点。
C# 设计组在 C# 4.0 里面加入了类型安全的协变逆变功能,因此一个金鱼的数组可以安全的转换为 IEnumerable<Animal> 序列。因为这个序列的接口没有提供修改数组的功能,所以它是安全的。如果方法只需要读这个容器,可以用这个序列类型而不是数组。
C# 1.0 有不安全的数组协变,不是因为 C# 设计者觉得这玩意特别令人信服,而是因为 CLR 运行时的类型系统有这个功能,所以 C# 可以轻松使用。CLR有这个功能是因为 Java 有这个;CLR 设计组希望设计一个可以高效实现Java的运行时,所以这个功能是必须的。反正我不知道为什么 Java 有这个功能。
这里可以学到三个教训:
可以轻松实现并不意味这是个好主意。
要是 C# 1.0 设计者知道 C# 4.0 会在接口类型中加入安全的泛型协变,他们应该会反对实现不安全的数组协变。但是,当然他们不知道这些。(为将来的功能做设计很难,记得吗?)
Benjamin Franklin (没有) 说过,语言设计者如果想通过牺牲一点类型安全来获得性能提升,他们会发现两样都没了。
不光彩的提示
还有一些有问题的功能,没有在这十大列表中出现:
for 循环有个古怪的语法,以及一些基本不怎么用的功能,在现代代码中基本完全是没必要的,然而它还是很流行。
在委托和事件上用的 += 运算符经常让我觉得怪异。它跟委托泛型协变也没法一起工作。
冒号(:)在类型声明的时候同时表示“扩展这个基类”和“实现这个接口”。对于读者和写编译器的来说,都很令人困惑。Visual Basic 把它们区分得很明显。
前述的冒号后面的名字解析规则没有设计好。你可能最后发现,当你想知道一个类型的基类是什么的时候,你需要先确定基类是什么。
void类型没有值,而且不能用在任何需要用类型的场景,除了用于返回类型和指针类型之外。我们还把它当做一个类型,看起来就很诡异了。
静态类就是 C# 中的模块。为什么我们不把它们叫“模块”?
假如单目加法运算符明天就消失,没有人会为它流泪。
总结
编程语言设计者有句谚语:“每一门新语言都是对其它语言的优点和缺点的回应。” C# 是专门为那些熟悉 C,C++,Java 的人设计的,同时解决那些语言中的缺点。反过来看我总结的十大遗憾,大多数都是因为最初直接把其它语言中的功能包括进来,因为这样可以让其它语言的用户觉得熟悉。这里首要的一条教训就是,不要把一个可疑的功能加进来,仅仅因为它有很长的历史和熟悉度。当我们考虑哪些功能应该被加进来的时候,我们应该像这样问问题:
如果这个功能有用,有没有更好的语法?开发者基本上都很聪明灵活;它们一般可以很快学会新语法。
在现代的业务程序中,有哪些真实使用场景?我们应该怎么设计功能来解决这些情况?
如果这个功能是一把“双刃剑”,我们应该怎么对那些粗心的开发者减少它的危险性?
语言设计决定通常是一些聪明的人做的善意的努力的结果,为了保持许多互相矛盾的目标的平衡:功能性,简洁性,熟悉度,一致性,健壮性,性能,可预测性,可扩展性——还有许多许多。但是有时候,事后诸葛亮的回顾,可以让我们看到它们还可以走另外一条路。

本文同步发布于微信公众号:Rust编程,欢迎关注。

作者:F001
链接:https://zhuanlan.zhihu.com/p/21541848
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  C# 十大 遗憾