我的外部記憶區

2006年5月8日星期一

Unix痛恨者手冊3:第二章:C plus plus

90年代的COBOL
 問:"C"和"C++"的名字是怎麼來的? 答:這是他們的成績  ——Jerry Leichter 
再沒有比C++更能體現Unix「絕不給用戶好臉」的哲學思想的了。 面向對像編程可以追溯到60年代的Simula語言,在70年代的Smalltalk語言上得到極大發展。許多書會告訴你面向對像語言如何能提高編程效率,使代碼更健壯,和減少維護費用。不過你甭想在C++裡得到這些。 這是因為C++根本就沒理解面向對象的實質。非但沒有簡化什麼,反而增加了更多的複雜性。和Unix一樣,C++從沒被好好設計過,它從一個錯誤走向另一個錯誤,是件打滿補丁的破衣服。連自己的語法都沒有嚴格定義(沒一個語言敢這樣),所以你甚至無法知道一行代碼是不是合法。 把C++比做COBOL,其實是對COBOL的污辱。COBOL在那個時代的技術條件下,是做出了很不同凡響的貢獻的。如果有誰用C++做成過什麼事,那就算是很不同凡響了。幸運的是,很多不錯的程序員知道必須盡量避免C++的傷害,他們只用C,對那些荒唐費解的功能敬而遠之。通常,這意味著他們必須自己寫個非面向對象的工具,以獲得自己想要的功能。當然,他們的代碼會顯得極為怪異,失去兼容性,難於理解和重用。不過只要有一點兒C++的味道,就足夠說服頭頭批准他們的項目。 許多公司已經被多年遺留下來的混亂難懂的COBOL代碼搞得焦頭爛額了。那些轉而使用C++的公司剛剛意識到自己上了當。當然,這已經太晚了。軟件災難的種子已經播下了,澆水施肥,得到悉心照料,就等著十幾年後長成參天大樹了。等著瞧吧! ==面向對象的彙編語言== C++沒有一絲一毫高層次語言的特性。為什麼這麼說?讓我們看看高層次語言應該具備那些特性: 優雅:在表示方式及其所表達的概念之間有著簡單易懂的關係 抽像:高層次語言的每個表達式只表示一個概念。概念能夠被獨立表達並能自由使用 強大:高層次語言的能夠對任何精確完整的程序行為進行提供直接了當的表述方式 高層次語言使程序員能夠採用問題空間的方式描述解決方案。高層次的程序很容易維護,因為它們的目的性(intent)十分明確。根據一行高層次程序代碼,現代編譯器能夠為各種平台生成高效的代碼,所以高層次程序的可移植性和可重用性自然會很強。 使用低層次語言則需要對考慮無數細節,其中大部分是和機器內部操作有關的東西,而不是要解決的問題本身。這不但造成代碼難於理解,而且很容易過時。現在幾乎每隔今年就要更新系統,過時的必須花費很高代價修改低層代碼或者徹底重寫。 ==對不起,你的內存洩漏了== 高層次語言對於常見問題有內置解決方案。例如,眾所周知內存管理是產生錯誤最多的地方。在使用一個對像之前,你必須為它分配內存,適當進行初始化,小心跟蹤使用,並正確釋放。當然,每件事兒都異常乏味而且很容易出錯,極小的一個錯誤可能會導致災難性後果。定位和修改這類錯誤是臭名昭著的困難,因為它們對於配置或使用方式的變化極其敏感。 使用未分配內存的結構指針會造成程序崩潰。使用未正確初始化的結構也會使你的程序崩潰,不過不一定立刻完蛋。如果未能好好跟蹤結構的使用情況,則很可能釋放一塊還在使用中的內存。還是崩潰。最好再分配一些結構用來跟蹤那些結構對象。不過如果你太保守,不去釋放那些不很肯定未在使用的內存,那麼你可要小心了。內存中很快就會充斥著無用的對象,直到內存耗盡,程序崩潰。這就是恐怖的「內存洩漏」。 如果你的內存空間碎片太多,那該怎麼辦呢?解決辦法是通過移動對像對內存重新歸整,不過在C++裡沒戲——如果你忘了更新對象的所有引用(reference),那麼你就會搞亂程序,結果還是崩潰。 很多真正的高層次語言提供了解決辦法——那就是垃圾回收(garbage collector)。它記錄跟蹤所有的對象,如果對像用完了會加以回收,永遠不會出錯。如果你使用有垃圾回收功能的語言,會得到不少好處: 大量的bug立馬無影無蹤。是不是很爽呀? 代碼會變得更短小更易讀,因為它不必為內存管理的細節操心。 代碼更有可能在不同平台和不同配置下高效運行。 唉,C++用戶必須自己動手去揀垃圾。他們中的許多人被洗了腦子,認為這樣會比那些專家提供的垃圾回收工具更為高效,如果要建立磁盤文件,他們估計不會使用文件名,而更願意和磁道扇區打交道。手動回收垃圾可能會對一兩中配置顯得更高效些,不過你當然不會這麼去使用字處理軟件。 你不必相信我們這裡說的。可以去讀一下B. Zorn的《保守垃圾回收的代價測量》(科羅拉多大學Boulder分校,技術報告CU-CS-573-92),文中對程序員用C手動優化的垃圾回收技術和標準垃圾回收器進行了性能比較,結果表明C程序員自己寫的垃圾回收器性能要差一些。 OK,假設你是個幡然醒悟的C++程序員,想使用垃圾回收。你並不孤單,很多人認為這是個好主意,決定寫一個。老天爺,猜猜怎麼著?你會發現根本沒法在C++中提供其他語言內置的那樣好的垃圾回收。其中一個原因是,(驚訝!)C++裡的對象在編譯後和運行時就不再是對象了。它們只是一塊十六進制的爛泥巴。沒有動態類型信息——垃圾回收器(還有調試器)沒法知道任何一塊內存裡的對象究竟是什麼,類型是什麼,以及是否有人此時正在使用它。 另一個原因是,即使你能寫個垃圾回收器,如果你用了其他未使用垃圾回收功能的代碼,你還是會被幹掉。因為C++沒有標準的垃圾回收器,而且很有可能永遠也不會有。假設我寫了一個使用了我的垃圾回收功能的數據庫程序,你寫了一個使用你自己的垃圾回收功能的窗口系統。但你關閉一個裝有我的數據記錄的窗口,你的窗口不會去通知我的數據記錄,告訴它已經沒有人引用它了。這個對象將不會被釋放,直到內存耗盡——內存洩漏,老朋友又見面了。 ==學起來困難?這就對了== C++和彙編語言很相像——難學難用,要想學好用好就更難了。 ---- 日期: Mon, 8 Apr 91 11:29:56 PDT 發信人: Daniel Weise 收信人: UNIX-HATERS 主題: From their cradle to our grave (從他們的搖籃到我們的墳墓) 造成Unix程序如此脆弱的一個原因是C程序員從啟蒙時期就是這麼被教育的。例如,Stroustrup(C++之父)的《C++編程語言》第一個完整程序(就是那個300K大小的"hello world"程序之後的那個)是一個英制/公制轉換程序。用戶 用結尾"i"表示英制輸入,用結尾"c"表示公制輸入。下面是這個程序的概要,是用真正的Unix/C風格寫的:
 #include   main() { [變量聲明] cin >> x >> ch; ;; A design abortion. ;; 讀入x,然後讀入ch。  if (ch == 'i') [handle "i" case] else if (ch == 'c') [handle "c" case] else in = cm = 0; ;; 好樣的,決不報告錯誤。 ;; 隨便做點兒什麼就成了。  [進行轉換] 
往後翻13頁(第31頁),給了一個索引範圍從n到m的數組(而不是從0到m)的實現例子。如果程序員使用了超出範圍的索引,這個程序只是笑嬉嬉地返回數組的第一個元素。Unix的終極腦死亡。 ==語法的吐根糖漿(Syrup of Ipecac,一種毒藥)==
 語法糖蜜(Syntactic sugar)是分號癌症的罪魁禍首。  ——Alan Perlis 
在使用C編程語言中所能遇到的所有語法錯誤幾乎都能被C++接受,成功編譯。不幸的是,這些語法錯誤並不總能生成正確的代碼,這是因為人不是完美的,他們總是敲錯鍵盤。C一般總能在編譯是發現這些錯誤。C++則不然,它讓你順利通過編譯,不過如果真的運行起來,就等著頭痛吧。 C++的語法形成也和它自身的發展密不可分。C++從來沒有被好好設計過:它只是逐步進化。在進化過程中,一些結構的加入造成了語言的二義性。特別的規則被用於解決這些二義性,這些難懂的規則使得C++複雜難學。於是不少程序員把它們抄在卡片上以供不時之需,或者根本就不去使用這些功能。 例如,C++有個規則說如果一個字符串既可以被解析為聲明也可以被解析為語句,那麼它將被當做聲明。解析器專家看到這個規則往往會渾身發冷,他們知道很難能正確實現它。AT&T自己甚至都搞不對。比如,當Jim Roskind想理解一個結 構 的意思時(他覺得正常人會對它有不同的理解),他寫了段測試代碼,把它交給AT&T的"cfront"編譯器。Cfront崩潰了。 事實上,如果你從ics.uci.edu上下載Jim Roskind的開放C++語法,你會發現ftp/pub目錄裡的c++grammar2.0.tar.Z有這樣的說明:「注意我的語法和cfront不一定保持一致,因為 a) 我的語法內部是一致的(這源於它的規範性以及 yacc的確證。b) yacc生成的解析器不會吐核(core dump)。(這條可能會招來不少臭雞蛋,不過...每次當我想知道某種結構的語法含義是,如果ARM(Annotated C++ Reference Manual, 帶註釋的C++參考手冊)對它的表 述不清楚,我就會拿cfront來編譯它,cfront這時總是吐核(core dump))」 ---- 日期: Sun, 21 May 89 18:02:14 PDT 發信人: tiemann (Michael Tiemann) 收信人: sdm@cs.brown.edu 抄送: UNIX-HATERS 主題: C++ Comments (C++註釋) 日期: 21 May 89 23:59:37 GMT 發信人: sdm@cs.brown.edu (Scott Meyers) 新聞組: comp.lang.c++ 組織: 布朗大學計算機系 看看下面這行C++代碼:
 //********************** 
C++編譯器該如何處理它呢?GNU g++編譯器認為這是一行由一堆星星(*)組成的註釋,然而AT&T編譯器認為這是一個斜槓加上一個註釋開始符(/*)。我想知道哪個是正確解析方式,可是Stroustrup的書(《C++編程語言》)裡面卻找不到答案。 實際上如果使用-E選項進行編譯,就會發現是預處理器(preprocessor)搞的鬼,我的問題是: 這是否AT&T預處理器的bug?如果不是,為什麼?如果是bug,2.0版是否會得到修正?還是只能這麼下去了? 這是否GNU預處理器的bug?如果是,為什麼? Scott Meyers sdm@cs.brown.edu ---- UNIX解析中有個古老的規則,盡量接受最長的語法單元(token)。這樣'foo'就不會被看成三個變量名('f', 'o'和'o'),而只被當成一個變量'foo'。看看這個規則在下面這個程序中是多 麼的有用(還有選擇'/*'作為註釋開始符是多麼的明智):
 double qdiv (p, q) double *p, *q; { return *p/*q; } 
為什麼這個規則沒有被應用到C++中呢?很簡單,這是個bug。 Michael ---- 糟糕的還在後頭,C++最大的問題是它的代碼難讀難理解,即使對於每天都用它的人也是如此。把另一個程序員的C++的代碼拿來看看,不暈才怪。C++沒有一絲品位,是個亂七八糟的醜八怪。C++自稱為面向對像語言,卻不願意承擔任何面向對象的責任。C++認為如果有誰的程序複雜到需要垃圾回收,動態加載或其他功能,那麼說明他有足夠的能力自己寫一個,並且有足夠的時間進行調試。 C++操作符重載(operator overloading)的強大功能在於,你可以把一段明顯直白的代碼變成能和最糟糕的APL, ADA或FORTH代碼相媲美的東西。每個C++程序員都能創建自己的方言 (dialect),把別的C++程序員徹底搞暈。 不過——嘿——在C++裡甚至標準的方言也是私有的(private)。 ==抽像些什麼?== 你可能會覺得C++語法是它最糟糕的部分,不過當你開始學習C++時,就會知道你錯了。一旦你開始用C++編寫一個正式的大型軟件,你會發現C++的抽像機制從根兒上就爛了。每本計算機科學教材都會這樣告訴你,抽像是良好設計之源。 系統各個部分的關聯會產生複雜性。如果你有一個100,000行的程序,其中每一行都和其他行代碼的細節相關,那你就必須照應著10,000,000,000種不同的關聯。抽像能夠通過建立清晰的接口來減少這種關聯。一段實現某種功能的代碼被隱藏在模塊化牆壁之後發揮作用。 類(class)是C++的核心,然而類的實現卻反而阻礙著程序的模塊化。類暴露了如此多的內部實現,以至於類的用戶強烈倚賴著類的具體實現。許多情況下,對類做一點兒改變,就不得不重新編譯所有使用它的代碼,這常常造成開發的停滯。你的軟件將不再「柔軟」和「可塑」了,而成了一大塊混凝土。 你將不得不把一半代碼放到頭文件裡面,以對類進行聲明。當然,類聲明所提供的public/private的區分是沒有什麼用的,因為「私有」(private)信息就放在了頭文件裡,所以成了公開(public)信息。一旦放到頭文件裡,你就不大願意去修改它,因為這會導致煩人的重編譯。程序員於是通過修補實現機制,以避免修改頭文件。當然還有其他一些保護機制,不過它們就像是減速障礙一樣,可以被心急的傢伙任意繞過。只要把所有對象都轉換(cast)成void*,再也沒有了討厭的類型檢查,這下世界清淨了。 其他許多語言都各自提供了設計良好的抽像機制。C++丟掉了其中一些最為重要的部分,對於那些提供的部分也叫人迷惑不解。你是否遇到過真正喜歡模板(template)的人?模板使得類的實現根據上下文不同而不同。許多重要的概念無法通過這種簡單的方式加以表達;即使表達出來了,也沒法給它一個直接的名字供以後調用。 例如,名空間(namespace)能夠避免你一部分代碼的名字和其他部分發生衝突。一個服裝製造軟件可能有個對象叫做"Button"(鈕扣),它可能會和一個用戶界面庫進行鏈接,那裡面也有個類叫做"Button"(按鈕)。如果使用了名空間,就不會有問題了,因為用法和每個概念的意思都很明確,沒有歧義。 C++裡則並非如此。你無法保證不會去使用那些已經在其他地方被定義了的名字,這往往會導致災難性後果。你唯一的希望是給名稱都加上前綴,比如ZjxButton,並但願其他人不會用同一個名字。 ---- 日期: Fri, 18 Mar 94 10:52:58 PST 發信人: Scott L. Burson 主題: preprocessor (預處理器) C語言迷們會告訴你C的一個最好的功能是預處理器。可事實上,它可能一個最蹩腳的功能。許多C程序由一堆蜘蛛網似的#ifdef組成 (如果各個Unix之間能夠互相兼容,就幾乎不會弄成這樣)。不過 這 僅僅是開始。 C預處理器的最大問題是它把Unix鎖在了文本文件的監牢裡,然後扔掉了牢 旁砍 。這樣除了文本文件以外,C源代碼不可能以任何其他方式存儲。為什麼?因為未被預處理的C代碼不可能被解析。例如:
 #ifdef BSD int foo() { #else void foo() { #endif /* ... */ } 
這裡函數foo有兩種不同的開頭,根據宏'BSD'是否被定義而不同。直接對它進行解析幾乎是不可能的 (就我們所知,從來沒實現過)。 這為什麼如此可惡?因為這阻礙了我們為編程環境加入更多智能。許多Unix程序員從沒見過這樣的環境,不知道自己被剝奪了什麼。可是如果能夠對代碼進行自動分析,那麼就能提供很多非常有用的功能。 讓我們再看一個例子。在C語言當道的時代,預處理器被認為是唯一能提供開碼(open-coded,是指直接把代碼嵌入到指令流中,而不是通過函數調用)的方式。對於每個簡單常用的表達式,開碼是一個很高效的技術。比如,取小函數min可以使用宏實現:
 #define min(x,y) ((x) < (y) ? (x) : (y)) 
假設你想寫個工具打印一個程序中所有調用了min的函數。聽上去不是很難,是不是?但是你如果不解析這個程序就無法知道函數的邊界,你如果不做經過預處理器就無法進行解析,可是,一旦經過了預處理,所有的min就不復存在了!所以,你的只能去用grep了。 使用預處理器實現開碼還有其他問題。例如,在上面的min宏裡你一定注意到了那些多餘的括號。事實上,這些括號是必不可少的,否則當min在另一個表達式中被展開時,結果可能不是你想要的。(老實說,這些括號不都是必需的——至於那些括號是可以省略的,這留做給讀者的練習吧)。 min宏最險惡的問題是,雖然它用起來像是個函數調用,它並不真是函數。看這個例子:
 a = min(b++, c); 
預處理器做了替換之後,變成了:
 a = ((b++) < (c) ? (b++) : (c)) 
如果'b'小於'c','b'會被增加兩次而不是一次,返回的將是'b'的原始值加一。 如果min真是函數,那麼'b'將只會被增加一次,返回值將是'b'的原始值。 ==C++對於C來說,就如同是肺癌對於肺==
 「如果說C語言給了你足夠的繩子吊死自己,那麼C++給的繩子除了夠你上吊之外,還夠綁上你所有的鄰居,並提供一艘帆船所需的繩索。」  ——匿名 
悲哀的是,學習C++成了每個計算機科學家和嚴肅程序最為有利可圖的投資。它迅速成為簡歷中必不可少的一行。在過去的今年中,我們見過不少C++程序員,他們能夠用C++寫出不錯的代碼,不過... ...他們憎惡它。 ==程序員進化史== 初中/高中
 10 PRINT "HELLO WORLD" 20 END 
大學一年級
 program Hello(input, output); begin   writeln('Hello world'); end. 
大學四年級
 (defun hello ()   (print (list 'HELLO 'WORLD))) 
剛參加工作
 #include   main (argc, argv) int argc; char **argv; {   printf ("Hello World!\n"); } 
老手
 #include   const int MAXLEN = 80;  class outstring; class outstring { private:   int size;   char str[MAXLEN];  public:   outstring() { size=0; }   ~outstring() { size=0; }   void print();   void assign(char *chrs); };  void outstring::print() {   int in;   for (i=0; i  老闆  
 「喬治,我需要一個能打印'Hello World!'的程序」 
好了,換個角度想想,C++可能是你最好的朋友,C++之父Stroustrup之所以設計C++,其實 http://www.chunder.com/text/ididit.html 正是為了我們這些程序員啊,當然如果你真的發誓不當C++程序員了,而且一時半會兒也當不了老闆,你還可以考慮做系統管理員,叫人羨慕的sysadmin。 ==See also== * [[Unix痛恨者手冊]]

沒有留言: