[心得] 原型宣告與 namespace 之敵:ADL

看板Programming作者 (purpose)時間13年前 (2012/04/20 02:57), 編輯推噓2(200)
留言2則, 2人參與, 最新討論串1/1
之前這篇文章 #1FZiIX_c (C_and_CPP) [ptt.cc] [問題] member function與friend function 重複 討論一系列函數原型宣告的議題,最後版主提及 ADL 相關的東西, 看完後有點想法,所以寫篇 ADL 的介紹文章跟大家交流一下。 論原型宣告 在 C 語言中不強制要求原型宣告,對於參數的資料形態檢查也很寬鬆。 C++ 後開始嚴格要求,一方面是 overload 需要名稱先修飾過才能達成, 而名稱修飾要先知道參數的形態。另一方面是實際參數的形態跟 形式參數的形態可以相異,這種種要求導致 C++ 對原型宣告的渴望。 原型宣告只是手段,真正的目的是取得函數的 signature,也就是 函數名稱為何、每個參數的資料型態、順序。 現今三大主流語言 C/C++, JAVA, C# 中,只剩下 C++ 依賴原型宣告, 所以要用 include 把 header 裡的原型宣告全部塞進原始碼。 而其他兩大陣營各有辦法取得 signature,其細節稍後再談,總之 在 JAVA 使用 import,在 C# 則使用 using namespace。 論 namespace 承上可知,在 C++ 裡面的 namespace 其觀念、角色、責任, 相對於 C# namespace 來說,是比較狹窄的。 可能也因為這樣,很多 C++ Style 都建議不要把 C++ namespace 裡 的東西做縮排;而 C# namespace 裡的東西,則一向都有在縮排。 [h2] 為什麼要有 namespace? [/h2] 很多函數名稱很棒,比如 foo,這種好名字大家都搶著用。 即便有 overload 機制存在,還是會出現同名同參數的狀況,導致編譯錯誤。 所以 C++ 引入 namespace 觀念,只要把愛用的名字,比如 foo 放進 自己的 namespace 裡,那該函數就會擁有全名 (fully qualified name)。 其他人使用不同 namespace,所以大家全名不同,就避免掉 ambiguous。 在避免 ambiguous 的同時,又懶得每次都打函數全名,所以 using 出現。 總歸一句,C++ namespace 的功能就單純是:讓全名出現。 [h2] 廣義的 namespace (C# 與 JAVA) [/h2] C# namespace 則有更多的意義:程式組織的一部分、幫助取得 signature JAVA 的狀況跟 C# 接近,只是 namespace 變 package。 C# 跟 JAVA 都不像 C++ 是多範式的,他們是純種的物件導向。 就拿最基本的函數、變數來說,因為前兩者是純爺們,所以萬事萬物皆類別, 所以哪怕是再小的函數,那也必須是某類別的一部分。 而 C/C++ 全域函數本身,是不需要強制規屬於某類別的。 C# 類別必須是某 namespace 的一部分, 換句話說,每個 C# 程式的組織中必然要有至少一個 namespace,然後 每個 namespace 裡至少要有一個類別,最後每個類別裡才可以有函數存在。 因此要寫 C# 版的 Hello World 就得這樣搞: namespace HelloWorld { class Hello { static void Main() { System.Console.WriteLine("Hello World!"); } } } 如果省略 namespace HelloWord 那 C# 也會自動把 Hello 類別 加入到預設的 global namespace 裡去。 [h2] namespace 幫助取得 signature? [/h2] 函數的全名之中,包含其所屬的 namespace、class 等資訊, 可以知道該函數是屬於哪個組織的,當 C#、JAVA 編譯器知道該函數的 完整組織時,就能找到 signature。 JAVA 程式組織很單純,一個原始碼檔 MyApp.java 對應一個 MyApp 類別, 編譯後變成一個 MyApp.class 檔案。 (而 C# 一個原始碼檔案 *.cs 裡可以有多個 namespace) 而且一個 JAVA package 又對應一個檔案系統裡的資料夾。 所以知道 JAVA 函數全名,比如叫 MyPack.MyClass.foo 就能知道 在 MyClass.class 檔案裡,可以解讀出 foo 的 signature 資訊。 而這個 MyClass.class 檔有可能位於 MyPack 資料夾裡面,也有可能跟 其他 *.class 檔被合併包裝成一個 jar 檔案。其中會有一個環境變數叫 classpath 裡面的目錄也會是搜尋目標。 使用 foo 函數時,不一定每次都打全名,有可能只寫 MyClass.foo(); 所以得先用 using 或 import 使編譯器知道哪些 namespace 下, 可能找得到該類別,順利得知其全名資訊。 至於 C# 的做法是直接開 .exe、.dll 檔,這些由 C# 生成的二進位檔, 有包含中介資訊,所以可以用 ildasm.exe 將其反向得知包含哪些 namespace、class、method,甚至是由 IL 碼撰寫的 method 定義,那取得 一個區區的 signature 自然不在話下。 但具體的查找規則我就不知道了,就假設是窮舉吧。 可以想像成 C# 有能力把成品的 .exe、.dll 反向出原始碼,再從原始碼查找 signature;相對的 C / C++ 即便用最強的 Hex Rays Decompiler 雖然函數 可以被反向成 C 語言,但函數的原始名稱、參數的原始資料形態, 這些都已經遺失掉,無法像 C# 一樣得到精確的資訊。 全域函數 (C/C++ 特產) 不屬於 class 的函數,都是全域函數,只有非純 OO 的語言才能擁有。 就拿運算子多載來說,C# 限定它只能是類別裡的 method,但 C++ 可以有兩種寫法,另外一種就是寫成全域函數。 可以想見的是,當某類別的開發不是由你掌管,你也可以自己寫一個 全域版本的運算子多載來擴充,從這角度來看,C++ 在運算子上的 多型能力是比 C# 強悍的。(所以 C# 比較少強調運算子) 當然因為有兩個選擇,如果兩者同時存在,就會發生 ambiguous。 namespace 觀點下的全域函數 namespace test{ void foo() { } } 要使用時,就打全名呼叫 test::foo(); 或者用 using namespace test; 就可以直接使用。 ADL 先講結論: 少用 ADL,如同少用 friend 少加味精一般 ADL 全名是 <STRIKE> 反人類社會極端捉摸不定異常撲朔迷離... </STRIKE> 引數相關名稱查詢 (Argument-Dependent name Lookup)。 簡單來說,每個函數呼叫,原本的全名查詢規則很單純,先看 this->函數名(...) 是否存在,沒有就找全域函數。 有了 ADL 之後,this->函數名(...) 依然最優先選用, 但是會多開一個後門,編譯器多一個查詢去處。 方法是確認函數呼叫的 argument 是哪個 namespace,然後檢查 arg-namespace::函數名(...) 是否存在。 所以明知道 istream& getline ( istream& is, string& str ); 此函數全名是 std::getline 的情況下,直接用 #include <iostream> #include <string> int main() { std::string buf; getline(std::cin, buf); return 0; } 這樣寫也能順利呼叫 std::getline,因為 ADL 幫忙開了後門。 乍看之下,多了一個後門可以走好像很爽,而且只有 C++ 才能用, 其他兩大陣營的 JAVA / C# 都沒有。 但現在時代趨勢就是語言要換來換去,跟著大部隊的腳步走才 是正確的。好不容易 basic 語法沒落,現在大家的 for, while, if 語法終於能互通。平常因為 C++ 沒有垃圾收集,整天要記得寫 C++ 時 new 完要自己 delete 已經很累了,哪有心力為了 C++ 多記 一個函數名稱查詢規則? 無視 ADL,當這條規則不存在就沒事了嗎? 用程式碼驗證: // ################################################# #include <stdio.h> #include <iostream> // ################################################# namespace air { class CO2 { /* nothing inside */ }; } // namespace air // ################################################# namespace air { void foo(CO2 &obj) { puts("in air::foo()"); } } // namespace air // ################################################# namespace myfavorite { void foo(air::CO2 &obj) { puts("in myfavorite::foo()"); } } // namespace myfavorite // ################################################# class CCLemon { public: void foo(air::CO2 &obj) { puts("in CCLemon::foo()"); } void FindMrRight(air::CO2 &obj) { foo(obj); } }; // ################################################# int main() { // To PROVE functions in the same class are higher than ADL. // (While the function name is not fully qualified.) air::CO2 obj; CCLemon cc; cc.FindMrRight(obj); // 最終會印出 in CCLemon::foo() // To PROVE compile error (vc, gcc) occurs and // the "using namespace" become useless because of ADL. using namespace myfavorite; foo(obj); return 0; } // ################################################# 其中關鍵是 return 0; 上方那兩行 using namespace myfavorite; foo(obj); 既然 foo(obj); 沒有使用全名,且前方有 using,那按照 C++、JAVA、C# 一致同意的觀念,這裡應該要呼叫 myfavorite::foo(obj); 但因為 ADL 的查詢規則存在,所以 air::foo(obj); 也可行,因此會編譯錯誤。 可以使用全名解決,或者拿掉 air、myfavorite 其中一個的 foo。 如果 air 類別以及 air::foo 的作者是甲,其餘的部份都是乙寫的。 乙寫的部份幾百年前就寫好,本來都運行正常。 可是甲新增了 air::foo 後,就害乙這邊的不能編譯。 照理說先寫先贏,而且根據 namesapce 的觀念,本來乙寫的就正確用法, 沒有要乙改的道理,但是甲方那邊不需要依賴乙的 myfavorite,所以 不會碰到編譯錯誤,他只要裝傻說他那邊可以用,堅持他不必改,又該如何? 這就是 ADL 讓人詬病的地方,為了小小的方便,沒事開個後門, 導致無數衝突的發生。這跟 friend 很像,你讓 DLL 函數當 friend, 哪天資料有錯誤要抓病源時,就會因為該朋友函數不是你維護的而無力。 [h2] ADL 與 cout [/h2] cout << 3.2F << cust::aMyCustomClassObj; 之所以能在不更改 ostream 類別原始碼的情況下,能夠如此流線地處理 你的自訂類別,是因為有全域函數可以用來自訂運算子, 所以這樣的用法,不會出現在 C#。 但是全域函數又可能定義在 namespace cust 裡,此時不破壞這一致性的 呼叫形式又要正確調用 cust::operator<<(...) 就只能是透過 ADL。 編譯器首先翻譯成 operator<<( operator<<(cout, 3.2F), cust::aMyCustomClassObj ); 接著透過 ADL 後門得知,第一次要呼叫 std::operator<< 而第二次要呼叫 cust::operator<< 或 ::operator<< 如果沒有 ADL 的話,就沒有 cin, cout 的經典用法了,所以大概 新版的 C++ 標準不太可能拿掉這個東西。 -- ※ 發信站: 批踢踢實業坊(ptt.cc) ◆ From: 124.8.135.79 purpose:轉錄至看板 C_and_CPP 04/20 02:57

04/20 09:27, , 1F
長知識了~ 推
04/20 09:27, 1F

04/20 12:39, , 2F
推~ 原來平常不以為意的東西這麼複雜
04/20 12:39, 2F
文章代碼(AID): #1Fa607Rc (Programming)
文章代碼(AID): #1Fa607Rc (Programming)