把C++當腳本語言寫

把C++當腳本語言寫!

  提到腳本,腦海裏馬上閃過一大堆:Python,Perl,Ruby,PHP,JS,VBS,LUA。。。 不過你有沒聽說過,用經典的C++做腳本語言嗎?先不多說,上個圖。(先別糾結那個function,那僅僅是個宏而已,待會你就明白了)

  或許你在想這一定是瘋了,用世界上最複雜的語言做腳本,寫的人累不說,腳本引擎先累壞了。各種複雜的模板庫,要邊解釋邊運行,得有多強大的虛擬機才撐得住。

  好吧,那麼我們退一步,不強求解釋執行,迴歸到原始的編譯後執行。———— 不過那還算腳本嗎?

編譯速度

  事實上如今高性能的腳本都是先編譯後運行的,大名鼎鼎的JavaScript V8引擎,號稱速度最快的LUA-Jit,以及衆所周知的ActionScript。。。預先編譯不僅能大幅提高運行速度,更重要的是能夠提前發現腳本中顯式的錯誤。

  但腳本中所謂的編譯,和傳統語言的編譯,還是很大區別的。腳本的編譯,不過是代碼上的深度優化,很快就可以完成。相比複雜了多的C++來說,似乎是望塵莫及的。提到C++的編譯速度,大家的映象莫過於在VC裏按下F5之後,看着輸出框內一條一條的“Compiling…”緩緩出現。有時僅僅測試一個微小的修改,也要等上好幾秒的時間。緩慢的編譯速度備受煎熬,以至於簡單的程序往往選擇VB或C#這樣可以快速調試的語言。

  對於龐大的MFC程序來說,緩慢的編譯是理所當然的。但簡單的小程序出現過長的編譯時間,那一定是頭文件引用的不合理了。事實上,使用預處理頭文件的小程序,編譯僅僅是一瞬間的,之後的各種停頓往往是IDE引起的。

  那麼我們就來測試下,不用IDE,僅用純命令編譯個C++小程序。我們使用VC6.0的編譯器:CL.exe

  爲了確保純淨的編譯環境,我們把CL.exe必須依賴的文件複製到新建的文件夾裏。對於VC6的版本,只要有如下5個文件,就可以完成.cpp到.exe的編譯了。

CL.exe
  C1XX.DLL
  C2.DLL
  MSPDB60.DLL
Link.exe

  打開cmd,設置好環境變量,對應到VC6的頭目錄和庫目錄

SET INCLUDE=C:\Program Files (x86)\Microsoft Visual Studio\VC98\Include

SET LIB=C:\Program Files (x86)\Microsoft Visual Studio\VC98\Lib

  就可以調用命令編譯了:

cl test.cpp

  一眨眼的工夫,編譯和鏈接完成,生成了test.exe,一切正常。而這還是在沒有使用預編譯頭的情況下編譯的。

  由此可見,即使語言本身很複雜,但只要用它寫的代碼不復雜,編譯還是非常快的。

  仔細想想也應如此。以如今的硬件配置,運行98年的編譯器,編譯一個才幾行代碼的程序,自然是一瞬間的。

  命令行編譯簡單的C++程序是如此的快速,利用這個優勢,繼續我們的腳本探索。。。

運行環境

  如果要寫一個生成100個隨機序列號的小程序,你會使用哪類語言?

  相比傳統語言要先創建一個工程項目,我們直接在桌面新建個文本文件就可以寫腳本了。

  雖然用文本編輯器寫代碼沒任何優勢,但對於簡單的程序足矣。之後程序交給其他人使用時,腳本優勢就淋漓盡致的體現出來了:當他們自己想簡單修改一些邏輯規則時,只需用記事本打開就可以,而記事本每臺電腦上都有。

  相反,傳統語言寫的程序,即使有源代碼,用戶想簡單的修改下也無法生效,還需安裝並配置好相應的開發環境才行,這對不熟悉的人來說頗費周折。

  所以腳本必須足夠簡單 —— 簡單到用戶只管修改和運行就可以,其他步驟都交給腳本宿主自動完成。

  如果想用C++寫腳本,那麼代碼的編譯和鏈接當然必須是全自動的,這並不複雜。

  但僅僅依靠CL.exe等幾個命令還是不夠的,因爲在其他的電腦上並沒有相應的開發環境 —— Include和Lib文件夾,因此就無法通過編譯和鏈接了。

  而這些頭文件庫文件,一共多達上千個,全都帶上則有近百兆!顯然,我們的腳本只用到幾個基本功能就可以了,那些複雜的windows頭文件就沒必要了。

  事實上,程序的頭文件只是函數和結構的定義,僅僅用來給編譯器分析而已,最終並不生成實際的指令。所以,我們把常用的頭文件,事先生成一個.pch預編譯頭文件就可以。以後編譯時,將他對應到某個頭文件就可以了,例如stdafx.h。這樣就無需使用任何頭文件了。即使stdafx.h也不在,編譯仍然能通過,因爲這一切都打包在.pch裏面了。並且大量的頭文件經過事先的分析,編譯時就無需再編譯它們了,速度大幅提升。

  至於Lib文件,裏面都是庫函數的內容。除非整個程序不使用任何C運行時庫,那麼我們可以不帶上任何lib,但那樣只能寫最基本的代碼了。對於一般的簡單腳本程序,只需幾個必要的lib即可:KERNEL32.LIB,LIBCMT.LIB,LIBCPMT.LIB,OLDNAMES.LIB。總共才1M多。

  我們把這幾個lib文件以及.pch文件,放在cl.exe同個目錄下,這樣就無需指定INCLUDE和LIB環境變量。

  至此,我們有了一個精簡版的VC6編譯器。通過上述10個文件,我們可以不依賴任何環境,獨立編譯C++程序了。

cl /Yu”stdafx.h” /Fp”MyDLL.pch” test.cpp

實際運行

  現在,我們可以動態產生C++代碼文件,並且自動編譯的能力了。但是如何將最終的二進制文件與腳本宿主交互呢?

  由於exe只能運行在獨立的進程裏,數據交互只能通過匿名管道,要實現回調什麼的非常困難。

  但若換成dll就可以大顯身手了,不僅運行在同一進程空間內,更重要的是dll是可以動態加載卸載的,這一點太符合腳本程序的特性了。並且當某個模塊更新了之後,就可以把先前的模塊釋放掉,加載最新的。而這一切都是動態的,無需重啓宿主即可完成!

  而且dll可以導出內部的函數,宿主用GetProcAddress()就可以輕鬆獲得某個函數地址;至於回調,傳遞一個宿主的函數指針給腳本就可以了。只要約定好函數聲明,雙方都可以用最簡單原始的方法互相調用,甚至共享同一塊內存空間。

  爲了讓函數導出更簡潔,本例中定義了個叫function的宏:

define function extern “C” __declspec(dllexport) void

  於是就可以簡單的定義一個導出函數了:

function Test()
{
// some code here
}

  是不是很有腳本的感覺呢:)

語法檢查

  一個用文本編輯器敲出來的代碼,拼寫錯誤是難免的。所以一個好的腳本引擎,會在運行前做一次全面的語法檢查,事先排除明顯的錯誤,而不是邊解釋邊運行。

  C++就是將其做到了極限,不僅能查出致命的錯誤,甚至不規範的代碼也會有警告提示。這是非常值得的,一個小bug浪費的時間,足夠幾萬次編譯了。

  想要在我們的C++腳本里實現這個功能,其實是非常簡單的。因爲在調用cl.exe編譯時,要是有編譯錯誤就會反饋出來。我們根據對應的錯誤行號,提示用戶就可以了。

 
調試環境

  一個強大的腳本引擎,往往帶有調試器。雖然編譯器能夠預先排除一些錯誤,但是邏輯上的錯誤只有在運行時才能出現。

  對於簡單的腳本程序,這項功能似乎不那麼重要。畢竟在調試狀態下運行,性能會有所影響。

  在C++腳本里,我們可以通過宏來擴展調試功能,決定是否輸出調試信息。不過對於異常錯誤,處理就比較講究了。

  由於我們最終運行的是二進制dll模塊,這和普通的腳本有着天壤之別。dll模塊是和宿主共用一個進程的,所以一旦當dll內異常觸發時,整個進程包括宿主一塊進入調試狀態了(系統裝有開發環境的話)。如果錯誤過於嚴重,會導致整個進程的崩潰。這是個非常值得注意的地方,也是C++作腳本在權限上的隱患。所以儘可能少用指針特性,使用更安全的代碼,讓代碼風險降到最少。

  對於致命的錯誤,宿主記錄下dump文件是非常重要的,方便調試。

  不過出於簡單,本例的宿主是用VB寫的,也就無法在調用前使用__try{}進行SEH捕捉。如果宿主也是C++實現的話,則儘可能捕捉dll內的異常。

開發環境

  有別於腳本語言,C++本身就是用於大型程序的開發,所以開發環境是非常完善的。

  但作爲一個腳本,往往都是單個的文本文件,而不是一個項目組。任何版本的VC編輯單個cpp文件,和編輯純文本文件幾乎沒有區別。因此我們事先得建立一個模板項目,將需要編輯的cpp移到此項目內開發,這樣纔會有下拉框智能提示等功能。

  不過既然選擇它作爲腳本來使用,那就應該用來處理一些簡單的,經常變更的邏輯事務。對於複雜的腳本程序,還不如直接寫在宿主裏面了。

  事實上,“程序”和“腳本”之間從沒一條固定的界限。用純粹的程序也可以寫一個複雜的遊戲故事情節,用純粹的腳本也可以開發一個大型項目。只不過太過死板,或太過靈活,都會增加額外的工作量。

總結

  與其稱之爲C++腳本,倒不如說是插件———可以根據需求,動態產生指令的插件。

  雖然可以玩轉出一些腳本的特徵,然而C++終究是門嚴格的語言。相比腳本的靈活性,C++固然更爲嚴謹和死板。當然,憑藉強大的宏、模版、運算符重載,我們可以充分擴展,爲腳本提供豐富多樣的特徵和語法糖。

  當然,它的優勢也是顯而易見的:性能超高,交互簡單。並且完全支持C++的特性。

  事實上,不僅僅是C++,任何一門高級語言都可以當“腳本”使用,只要調用它們的編譯器即可。如果喜歡C#,或者Java風格,只需稍作修改就可以。

  爲了簡單演示,本例使用VB寫了個簡單的宿主程序,包括基本的編譯,鏈接,加載,語法檢查功能。

  宿主提供了一個叫“Console”的接口,可以輸出字符串。要實現更多接口和擴展功能,修改cl文件夾內的T.h即可。

  源碼可以在這裏下載:http://files.cnblogs.com/index-html/CppScript.rar

  其中有一個DLLTmpl的工程,沒有任何用處,僅僅爲了生成一個.pch預編譯頭文件而已。如果想在腳本里使用更多的頭文件,就得在StdAfx.h內添加。編譯之後的release/MyDll.pch複製到cl文件夾,覆蓋原有的即可。

後記:

當初寫這篇文章,是優化了一個防火牆數據包過濾系統的心得。

因爲數據包數量非常大,如果每次都通過傳統的腳本判斷,性能開銷較大;如果寫在 DLL 之類的模塊裏,雖然性能很高,但每次修改規則都得重新發布一次,很麻煩。

所以最後把規則寫成純文本的 C++ 代碼,程序啓動時自動將其編譯成 DLL,中途代碼若有修改,也可以熱更新。這樣性能和配置都可兼得。

當然,有條件的話做成 JIT 系統是最好的,例如 winpcap 過濾器那種。不過出於成本,直接調編譯器是最簡單的。而且套用現成的語言,學習成本也很小。

(後來 C++0x 出來了,有一個 auto 關鍵字,可以類型自動推斷。所以還可以 #define var auto,那樣就更像 javascript 腳本了:)

最近網上看到篇文章 《什麼是“腳本語言”》,終於把之前想說的都說出來了,所以特來更新下。(2015/06/26)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章