我從來沒有在別的語言的粉里面看見過這么容易展示人性丑陋一面的粉,就算是從十幾年前開始的C++和C對噴,GC和非GC對噴,靜態類型動態類型對噴的時候,甚至是云風出來噴C++黑得那么驚天動地的時候,都沒有發生過這么腦殘的事情。這種事情只發生在go語言的腦殘粉的身上,這究竟代表什么呢?想學go語言的人最好小心一點了,學怎么用go沒關系,go學成了因為受不了跳到別的語言去也沒關系,就算是抖M很喜歡被折騰所以堅持用go也沒關系,但是把自己學成了腦殘粉,自己的心智發生不可逆轉的變換,那就不好了。
當然,上一篇文章最后那個例子應該是我還沒說清楚,所以有些人有這種“加上一個虛析構函數就可以了”的錯覺也是情有可原的。Base* base = new Derived;之后你去delete沒問題,是因為析構函數你還可以聲明成虛的。但是Base* base = new Derived[10];之后你去delete[]發生了問題,是因為Derived和Base的長度不一樣,所以當你開始試圖計算&base[1]的時候,你實際上是拿到了第一個Derived對象的中間的一個位置,根本不是第二個Derived。這個時候你在上面做各種操作(譬如調用析構函數),你連正確的this指針都拿不到,你再怎么虛也是沒用的。不過VC++單純做delete[]的話,在這種情況下是不會有問題的,我猜它內部不僅記錄了數組的長度,還記錄了每一個元素的尺寸。當然,你直接用bases[1]->DoSomething()的時候,出事是必須的。
所以今天粉絲群在討論昨天的這個例子的時候,我們的其中一位菊苣就說了一句話:
當你使用C++的時候C的一部分子集最好少碰
我也很贊同。反正C++已經有各種內置類型了,譬如typeid出來的按個東西(我給忘了)啊,initialization_list啊,range什么的。為什么就不給new T[x]創建一個類型呢?不過反正都已經成為現實了,沒事就多用用vector和shared_ptr吧,不要想著什么自己new自己delete了。
今天我們來講一個稍微“高級”一點點的坑。這是我在工作之后遇到的一個現實的例子。當然,語言的坑都擺在那里,人往坑里面跳都肯定是因為自己知道的東西還不夠多造成的。但是坑有三種,第一種是很明顯的,只要遵守一些看起來很愚蠢但是卻很有效的原則(譬如說if(1 == a)…)就可以去除的。第二種坑是因為你不知道一些高級的知識(譬如說lambda和變量揉在一起的生命周期的事情)從而跳坑的。第三種純粹就是由于遠見不夠了——譬如說下面的例子。
在春光明媚的一個早上,我接到了一個新任務,要跟另一個不是我們組的人一起寫一個圖像處理的pipeline的東西。這種pipeline的節點無非就是什么直方圖啊,卷積啊,灰度還有取邊緣什么的。于是第一天開會的時候,我拿到了一份spec,上面寫好了他們設計好但是還沒開始寫的C++的interface(沒錯,就是那種就算只有一個實現也要用interface的那種流派),讓我回去看一看,過幾天跟他們一起把這個東西實現出來。當然,這些interface里面肯定會有矩陣:
template<typename T> class IMatrix { public: virtual ~IMatrix(){} virtual T* GetData()=0; virtual int GetRows()=0; virtual int GetColumns()=0; virtual int GetStride()=0; virtual T Get(int r, int c)=0; virtual void Set(int r, int c, T t)=0; };
其實說實話,IMatrix這么寫的確沒什么大問題。于是我們就很愉快的工作了幾天,然后把這些純粹跟數學有關的算法都完成了,然后就開始做卷積的事情了。卷積所需要的那一堆數字其實說白了他不是矩陣,但因為為這種東西專門做一個類也沒意義,所以我們就用行列一樣多的矩陣來當filter。一開始的接口定義成這個樣子,因為IBitmap可能有不同的儲存方法,所以如何做卷積其實只有IBitmap的實現自己才知道:
template<typename TChannel> class IBitmap { ...... virtual void Apply(IMatrix<float>& filter)=0; ...... };
于是我們又愉快的度過了幾天,直到有一天有個人跳出來說:“Apply里面又不能修改filter,為什么不給他做成const的?”于是他給我們展示了他修改后的接口:
template<typename TChannel> class IBitmap { ...... virtual void Apply(IMatrix<const float>& filter)=0; ...... };
我依稀還記得我當時的表情就是這樣子的→囧。
語言的類型系統是一件特別復雜的事情,特別是像C++這種,const T<a, b, c>和T<const a, const b, cont c>是兩個不一樣的類型的。一們語言,凡是跟優美的理論每一個不一致的地方都是一個坑,區別只是有些坑嚴重有些坑不嚴重。當然上面這個不是什么大問題,因為真的按照這個接口寫下去,最后會因為發現創建不了IMatrix<const float>的實現而作罷。
而原因很簡單,因為一般來說IMatrix<T>的實現內部都有一個T*代表的數組。這個時候給你換成了const float,你會發現,你的Set函數在也沒辦法把const float寫進const float*了,然后就掛了。所以正確的方法當然是:
virtual void Apply(const IMatrix<float>& filter)=0;
不過在展開這個問題之前,我們先來看一個更加淺顯易懂的“坑”,是關于C#的值類型的。譬如說我們有一天需要做一個超高性能的包含四大力學的粒子運動模擬程序——咳咳——總之從一個Point類型開始。一開始是這么寫的(C# 5.0):
struct Point { public int x; public int y; } var ps = new Point[] { new Point { x = 1, y = 2 } }; ps[0].x = 3;
已開始運作的很好,什么事情都沒有發生,ps[0]里面的Point也被很好的更改了。但是有一天,情況變了,粒子之間會開始產生和消滅新的粒子了,于是我把數組改成了List:
var ps = new List<Point> { new Point { x = 1, y = 2 } }; ps[0].x = 3;
結果編譯器告訴我最后一行出了一個錯誤:
Cannot modify the return value of 'System.Collections.Generic.List<ArrayTest2.Program.Point>.this[int]' because it is not a variable
C#這語言就是牛逼啊,我用了這么久,就只找出這個“不起眼的問題”的同時,還是一個編譯錯誤,所以用C#的時候根本沒有辦法用錯啊。不過想想,VB以前這么多人用,除了on error resume next以外也沒用出什么坑,可見Microsoft設計語言的功力比某狗公司那是要強多了。
于是我當時就覺得很困惑,隨手寫了另一個類來驗證這個問題:
class PointBox { public int Number { get; set; } public Point Point { get; set; } } var box = new PointBox() { Number = 1, Point = new Point { x = 1, y = 2 } }; box.Number += 3; box.Point.x = 5;
結果倒數第二行過了,倒數第一行還是編譯錯誤了。為什么同樣是屬性,int就可以+=3,Point就不能改一個field非得創建一個新的然后再復制進去呢?后來只能得到一個結論,數組可以List不可以,屬性可以+=不能改field(你給Point定義一個operator+,那你對box.Point做+=也是可以的),只能認為是語言故意這么設計的了。
寫到這里,我想起以前在MSDN上看過的一句話,說一個結構,如果超過了16個字節,就建議最好不要做成struct。而且以前老趙寫了一個小sample也證明大部分情況下用struct其實還不如用class快。當然至于是為什么我這里就不詳細展開了,我們來講語法上的問題。
在C#里面,struct和class的區別,就是值和引用的區別。C#專門做了值類型和引用類型,值類型不能轉成引用(除非box成object或nullable或lazy等),引用類型不能轉值類型。值不可以繼承,引用可以繼承。我們都知道,你一個類繼承自另一個類,目的說到底都是為了覆蓋幾個虛函數。如果你不是為了覆蓋虛函數然后你還要繼承,八成是你的想法有問題。如果繼承了,你就可以從子類的引用隱式轉換成父類的引用,然后滿足里氏代換原則。
但是C#的struct是值類型,也就是說他不是個引用(指針),所以根本不存在什么拿到父類引用的這個事情。既然你每一次見到的類型都是他真正的類型(而不像class,你拿到IEnumerable<T>,他可能是個List<T>),那也沒有什么必要有虛函數了。如果你在struct里面不能寫虛函數,那還要繼承干什么呢?所以struct就不能繼承。
然后我們來看一看C#的屬性。其實C#的operator[]不是一個操作符,跟C++不一樣,他是當成屬性來看待的。屬性其實是一個語法糖,其中的getter和setter是兩個函數。所以如果一個屬性的類型是struct,那么getter的返回值也是struct。一個函數返回struct是什么意思呢?當然是把結果【復制】一遍然后返回出去了。所以當我們寫box.Point.x=5的時候,其實等價于box.get_Point().x=5。你拿到的Point是復制過的,你對一個復制過的struct來修改里面的x,自然不能影響box里面存放著的那個Point。所以這是一個無效語句,C#干脆就給你定了個編譯錯誤了。不過你可能會問,List和Array大家都是operator[]也是一個屬性,那為什么Array就可以呢?答案很簡單,Array是有特殊照顧的……
不過話說回來,為什么很少人遇到這個問題?想必是能寫成struct的這些東西,作為整體來講本身是一個狀態。譬如說上面的Point,x和y雖然是分離的,但是他們并不獨立代表狀態,代表狀態的是Point這個整體。Tuple(這是個class,不過其實很像struct)也一樣,還有很多其他的.net framework里面定義的struct也一樣。因此就算我們經常構造List<Point>這種東西,我們也很少要去單獨修改其中一個element的一部分。
那為什么struct不干脆把每一個field都做成不可修改的呢?原因是這樣做完全沒有帶來什么好處,反正你誤操作了,總是會有編譯錯誤的。還有些人可能會問,為什么在struct里面的方法里,對this的操作就會產生影響呢?這個問題問得太好了,因為this是一個本質上是“指針”的東西。
這就跟上一篇文章所講的東西不一樣了。這篇文章的兩個“坑”其實不能算坑,因為他們最終都會引發編譯錯誤來迫使你必須修改代碼。所以說,如果C++的new T[x]返回的東西是一個貨真價實的數組,那該多好啊。數組質檢科從來沒有什么轉換的。就像Delphi的array of T也好,C#的T[]也好,C++的array<T>或者vector<T>也好,你從來都不能把一個T的數組轉成U的數組,所以也就沒有這個問題了。所以在用C++的時候,STL有的東西,你就不要自己擼了,只傷身體沒好處的……
那么回到一開始說的const的問題。我們在C++里面用const,一般都是有兩個目的。第一個是用const引用來組織C++復制太多東西,第二個是用const指針來代表某些值是不打算讓你碰的。但是一個類里面的函數會做什么我們并不知道,所以C++給函數也加上了const。這樣對于一個const T的類型,你只能調用T里面所有標記了const的函數了。而且對于標記了const的成員函數,他的this指針也是const T* const類型的,而不是以前的T* const類型。
那類似的問題在C#里面是怎么解決的呢?首先第一個問題是不存在的,因為C#復制東西都是按bit復制的,你的struct無論怎么寫都一樣。其次,C#沒有const類型,所以如果你想表達一個類不想讓別人修改,那你就得把那些“const”的部分抽出來放在父類或父接口里面了。所以現在C#里面除了IList<T>類型以外,還有IReadOnlyList<T>。其實我個人覺得IReadOnlyList這個名字不好,因為這個對象說不定底下是個List,你用著用著,因為別人改了這個List導致你IReadOnlyList讀出來的東西變了,迷惑性就產生了。所以在這種情況下,我寧可叫他IReadableList。他是Readable的,只是把write的接口藏起來的你碰不到而已。
所以,const究竟是在修飾什么的呢?如果是修飾類型的話,跟下面一樣讓函數的參數的類型都變成const,似乎完全是沒有意義的:
int Add(const int a, const int b);
或者更甚,把返回值也改成const:
const int Add(const int a, const int b);
那他跟
int Add(int a, int b);
究竟有什么區別呢?或許在函數內部你不能把參數a和b當變量用了。但是在函數的外部,其實這三個函數調用起來都沒有任何區別。而且根據我們的使用習慣來講,const修飾的應該不是一個類型,而是一個變量才對。我們不希望IBitmap::Apply函數里面會修改filter,所以函數簽名就改成了:
virtual void Apply(const IMatrix<float>& filter)=0;
我們不希望用宏來定義常數,所以我們會在頭文件里面這么寫:
const int ADD = 1; const int SUB = 2; const int MUL = 3; const int DIV = 4; const int PUSH = 5; const int POP = 6;
或者干脆用enum:
enum class Instructions { ADD = 1, SUB, MUL, DIV, PUSH, POP };
對于C++來講,const還會對鏈接造成影響。整數數值類型的static const成員變量也好,const全局變量也好,都可以只寫在頭文件給一個符號,而不需要在cpp里面定義它的實體。但是對于非static const的成員變量來說,他又占用了class的一些位置(C#的const成員變量跟static是不相容的,它只是一個符號,跟C++完全不是一回事)。
而且根據大部分人對const的認識,我們用const&也好,const*也好,都是為了修飾一個變量或者參數。譬如說一個臨時的字符串:
const wchar_t* name = L"@GeniusVczh";
或者一個用來計算16進制編碼的數組:
const wchar_t code[] = L"0123456789ABCDEF";
其實說到底,我們心目中的const都是為了修飾變量或者參數而產生的,說白了就是為了控制一個內存中的值是否可以被更改(這一點跟volatile一樣,而C#的volatile還帶fence語義,這一點做得比C++那個只用來控制是否可以被cache進寄存器的要強多了)。所以C++用const來修飾類型又是一個違反直覺的設計了。當然,如果去看《C++設計與演化》的話,的確可以從中找到一些講為什么const會用來描述類型的原因。不過從我的使用經驗上來看,const至少給我們帶來了一些不方便的地方。
第一個就是讓我們寫一個正確的C++ class變得更難。就像C#里面說的,一個只讀的列表,其實跟一個可讀寫的列表的概念是不一樣的。在C++里面,一個只讀的列表,是一個可以讓你看見寫函數卻不讓你用的一個進入了特殊狀態的可讀寫的列表。一般來說,一個軟件都要幾千個人一起做。我今天寫了一個類,你明天寫了一個帶const T&參數的模板函數,后天他發現這兩個東西湊在一起剛好能用,但是一編譯發現那個類的所有成員函數都不帶const結果沒辦法搞了。怎么辦?重寫嗎,那我們得自己維護多出來的一份代碼,還可能跟原類的作者犯下一樣的錯誤。修改它的代碼嗎,鬼知道給一個函數加上const會不會給這個超大的軟件的其他部分帶來問題,說不定就像字符串類一樣,有一些語義上是const的函數實際上需要修改一些成員變量結果你又不得不給那些東西加上mutable關鍵字了。你修改了之后,代碼誰來維護,又成為一個跟技術無關的政治問題了。而且就算你弄明白了什么函數要加const,結果你聲明一個const變量的時候const放錯了位置,也會有一些莫名其妙的問題出現了。
如果從一開始就用C#的做法,把它分離成兩個接口,這樣做又跟C++有點格格不入,為什么呢?為什么STL那么喜歡泛型+值類型而不是泛型+引用類型?為什么C#就喜歡泛型+引用類型而不是泛型+值類型?其實這兩種設計并沒有誰好誰不好的地方,至于C++和C#有不同的偏愛,我想原因應該是出在GC上。語言有GC,你new的時候就不需要擔心什么時候去delete,反正內存可以循環回收總是用不完的。C++卻不行,內存一旦leak就永遠的leak了,這么下去遲早都會掛掉的。所以當我們在C++和C#里面輸入new這個關鍵字的時候,心情其實是差別相當大的。所以大家在C++里面就不喜歡用指針,而在C#里面就new的很開心。既然C++不喜歡指針,類似IReadOnlyList<T>的東西不拿指針直接拿來做值類型的話又是沒有什么意義的,所以干脆就加上了const來“禁止你訪問類里面的一部分東西”。于是每當你寫一個類的時候,你就需要思考上一段所描述的那些問題。但是并不是所有C++的程序員都知道所有的這些細節的,所以后面加起來,總會有傻逼的時候——當然這并不怪C++,怪的是你面試提出的太容易,讓一些不合格的程序員溜進來了。C++不是誰都可以用的。
第二個問題就是,雖然我們喜歡在參數上用const T&來避免無謂的復制,但是到底在函數的返回值上這么做對不對呢?const在返回值的這個問題上這是一把雙刃劍。我自己寫過一個linq for C++,山寨了一把IEnumerable和IEnumerator類,在Current函數里面我返回的就是一個const T&。本來容器自己的IEnumerator寫的挺好,因為本來返回的東西就在容器里面,是有地址的。但是開始寫Select和Where的時候就傻逼了。我為了正確返回一個const T&,我就得返回一個帶內存地址的東西,當然最終我選擇了在MoveNext的時候把結果cache在了這個SelectEnumerator的成員變量里面。當然這樣做是有好處的,因為他強迫我把所有計算都放在MoveNext里面,而不會偷懶寫在Current里。但是總的來說,要不是我寫代碼的時候蛋定,說不定什么時候就掉坑里了。
總的來說,引入const讓我們寫出一個正確的C++程序的難度變大了。const并不是一無是處,如果你是在想不明白什么時候要const什么時候不要,那你大不了不要在自己的程序里面用const就好了。當然我在這里并不是說C語言什么都沒有就比C++好。一個語言是不可能通過刪掉什么來讓他變得更好的。C語言的抽象能力實在是太低了,以至于讓我根本沒辦法安心做好邏輯部分的工作,而總要關心這些概念究竟要用什么樣的扭曲的方法才能在C語言里面比較順眼的表達出來(我知道你們最后都選擇了宏!是吧!是吧?。瑥亩屛易?#8220;煩”,bug就變多,程序到最后也懶得寫好了,最后變成了一坨屎。
嘛,當然如果你們說我沒有linus牛逼,那我自然也沒辦法說什么。但是C語言大概就是那種只有linus才能用的順手的語言了。C++至少如果你心態好的話,沒事多用STL,掉坑的概率就要比直接上C語言小多了。
語言的坑這種事情實在是罄竹難書啊,本來以為兩篇文章就可以寫完的,結果發現遠遠不夠。看在文章長度的份上,今天就到此為止了,下一篇文章還有大家喜聞樂見的函數指針和lambda的大坑等著你們……
待續