C#的十大遗憾
本文翻譯自 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 里,有些時候一條斷點是在語句的開始還是中間是讓人迷惑的,如果在空語句中設置斷點就沒有歧義了;
- 有些場景下,你需要一條語句,但是又不需要做什么事情:
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
來源:知乎
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
總結
- 上一篇: 被人画是怎样一种体验?
- 下一篇: 图普科技招聘有关深度学习的解题?