如何设计一门语言(二)——什么是坑(b)
我从来没有在别的语言的粉里面看见过这么容易展示人性丑陋一面的粉,就算是从十几年前开始的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语言里面比较顺眼的表达出来(我知道你们最后都选择了宏!是吧!是吧!),从而让我变“烦”,bug就变多,程序到最后也懒得写好了,最后变成了一坨屎。
嘛,当然如果你们说我没有linus牛逼,那我自然也没办法说什么。但是C语言大概就是那种只有linus才能用的顺手的语言了。C++至少如果你心态好的话,没事多用STL,掉坑的概率就要比直接上C语言小多了。
语言的坑这种事情实在是罄竹难书啊,本来以为两篇文章就可以写完的,结果发现远远不够。看在文章长度的份上,今天就到此为止了,下一篇文章还有大家喜闻乐见的函数指针和lambda的大坑等着你们……
待续