如何设计一门语言(三)——什么是坑(面向对象和异常处理)

小手一抖,来自VCZH的博客:
http://www.cppblog.com/vczh/archive/2013/05/05/199974.html
文中观点属于VCZH。不明觉厉。

在所有的文字之前,我需要强调一下,我本人对structure typing持反对态度,所以就算文中的内容“看起来很像”go的interface,读者们也最好不要觉得我是在赞扬go的interface。我比较喜欢的是haskell和rust的那种手法。可惜rust跟go一样恨不得把所有的单词都缩成最短,结果代码写出来连可读性都没有了,单词都变成了符号。如果rust把那乱七八糟的指针设计和go的那种屎缩写一起干掉的话,我一定会很喜欢rust的。同理,COM这个东西设计得真是太他妈正确了,简直就是学习面向对象手法的最佳范例,可惜COM在C++下面操作起来有点傻逼,于是很多人看见这个东西就呵呵呵了。

上一篇文章说这次要写类成员函数和lambda的东西,不过我想了想,还是先把OO放前面,这样顺序才对。

我记得我在读中学的时候经常听到的宣传,是面向对象的做法非常符合人类的思维习惯,所以人人喜欢,大行其道,有助于写出鲁棒性强的程序。如今已经过了十几年了,我发现网上再也没有这样的言论了,但是也没有跟反C++的浪潮一样拼命说面向对象这里不好那里不好要废除——明显人们还是觉得带面向对象的语言用起来还是比较爽的,不然也就没有那么多人去研究,一个特别合用来写functional programming的语言——javascript——是如何可以“模拟”面向对象语言里面的常用操作——new、继承和虚函数覆盖了。

所以像面向对象这种定义特别简单的东西,语法上应该做不出什么坑的了。那今天的坑是什么呢?答案:是人。

动态类型语言里面的面向对象说实话我也不知道究竟好在哪里,对于这种语言那来讲,只要做好functional programming的那部分,剩下的OO究竟要不要,纯粹是一个语法糖的问题。在动态类型语言里面,一个类和一个lambda expression的差别其实不大。

那么静态类型语言里面的面向对象要怎么看待呢?首先我们要想到的一个是,凡是面向对象的语言都支持interface。C++虽然没有直接支持,但是他有多重继承,我们只需要写出一个纯虚类出来,就可以当interface用了。

在这里我不得不说一下C++的纯虚类和interface的这个东西。假设一下我们有下面的C#代码:

我们先不管GUI是不是真的能这么写,我们就看看这个继承关系就好了。这是一个简单到不能再简单的例子。意思就是我有两种button的接口,我从一个实现里面扩展出一个支持另一种button接口的东西。但是大家都知道,我那个完美的GacUI用的是C++,那么在C++下面会遇到什么问题呢:

#region 抱怨

一般来说在C++里面用纯虚类来代替interface的时候,我们继承一个interface用的都是virtual继承。为什么呢?看上面那个例子,ISelectableButton继承自IButton,IDropdownButton继承自IButton。那么当你写一个MyPowerfulButton的时候,你希望那两个接口里面各自的IButton是不一样的东西吗?这当然不是。那如何让两个接口的IButton指向的是同一个东西呢?当然就是用virtual继承了。

好了,现在我们有CheckBox这个实现了ISelectableButton(带IButton)的类了,然后我们开始写MyPowerfulButton。会发生什么事情呢?

猜错了!答案是,其实我们可以写,但是Visual C++(gcc什么的你们自己玩玩就好了)会给我们一个warning,大意就是你IDropdownButton里面的IButton被CheckBox给覆盖了,再说抽象一点就是一个父类覆盖了另一个父类的虚函数。这跟virtual继承是没关系的,你怎么继承都会出这个问题。

但这其实也怪不了编译器,本来在其他情况下,虚函数这么覆盖自然是不好的,谁让C++没有interface这个概念呢。但是GUI经常会碰到这种东西,所以我只好无可奈何地在这些地方用#pragma来supress掉这个warning,反正我知道我自己在干什么。

C++没有interface的抱怨到这里就完了,但是virtual继承的事情到这里还没完。我再举一个例子:

大家都是知道什么是virtual继承的,就是像上面这个例子,D里面只有一个A对象,B和C在D里面共享了A。那么,我们给B和C用了不同的参数来构造,难道一个A对象可以用不同的参数构造两次吗,还是说编译器帮我们随便挑了一个?

呵呵呵呵呵呵呵呵,我觉得C++的virtual继承就是这里非常反直觉——但是它的解决方法是合理的。反正C++编译器也不知道究竟要让B还是C来初始化A,所以你为了让Visual C++编译通过,你需要做的事情是:

#endregion

大家估计就又开始吵了,C++干嘛要支持多重继承和virtual继承这两个傻逼东西呢?我在想,对于一个没有内建interface机制的语言,你要是没有多重继承和virtual继承,那用起来就跟傻逼一样,根本发挥不了静态类型语言的优势——让interface当contract。当然,我基本上用多重继承和virtual继承也是用来代替interface的,不会用来做羞耻play的。

当我们在程序里面拿到一个interface也好,拿到一个class也好,究竟这代表了一种什么样的精神呢?interface和class的功能其实是很相似的

interface IA:只要你拿到了一个IA,你就可以对她做很多很多的事情了,当然仅限大括号里面的!
class C : IA, IB:只要你拿到了一个C——哦不,你只能拿到interface不能拿到class的——反正意思就是,你可以对她做对IA和IB都可以做的事情了!

所以contract这个概念是很容易理解的,就是只要你跟她达成了contract,你就可以对她做这样那样的事情了。所以当一个函数返回给你一个interface的时候,他告诉你的是,函数运行完了你就可以做这样那样的事情。当一个函数需要一个interface的时候,他告诉你的是,你得想办法让我(函数)干这样那样的事情,我才会干活。

那class呢?class使用来实现interface的,不是给你直接用的。当然这是一个很理想的情况,可惜现在的语言糖不够甜,坚持这么做的话实在是太麻烦了,所以只好把某些class也直接拿来用了,GUI的控件也只好叫Control而不是IControl了。

其实说到底class和interface有什么区别呢?我们知道面向对象的一大特征就是封装,封装的意思就是封装状态。什么是状态呢?反正云风一直在说的“类里面的数据”就不是状态。我们先来看什么是数据:

这就是典型的数据,你往x和y里面随便写什么东西都是没问题的,反正那只是一个点。那什么是状态呢:

String和Point有什么不一样呢?区别只有一个:String的成员变量之间是满足一个不变量的:wcslen(buffer) == length;

如果我们真的决定要给String加上这么个不变量的话,那这里面包含了两点:
1:buffer永远不是nullptr,所以他总是可以被wcslen(buffer)
2:length的值和buffer有直接的关系

如果你要表达一个空字符串,你总是可以写buffer=L””,不过这就要你给String再加上一些数据来指明这个buffer需要如何被释放了,不过这是题外话了。我们可以假设buffer永远是new[]出来的——反正这里不关心它怎么释放。

这个不变量代表什么呢?意思就是说,无论你怎么折腾String,无论你怎么创建释放String,这个等式是一定要满足的。也就是说,作为String外部的“操作人员”,你应当没机会“观测”到这个String处于一个不满足不变量的状态。

所以这两个成员变量都不应该是public的。因为哪怕你public了他们其中的一个,你也会因为外部可以随意修改它而使他进入一个不满足不变量的状态。

这代表了,为了操作这些成员变量,我们需要public一些函数来给大家用。其实这也是contract,String的成员函数告诉我们,你可以对我(String)做很多很多的事情哦!

这同时也代表了,我们需要一个构造函数。因为如果我们在创建一个String之后,实例没有被正确初始化,那么他就处于了一个不满足不变量的状态,这就不满足上面说的东西了。有些人喜欢带一个Init函数和一个基本不干什么事情的构造函数,我想说的是,反正你构造完了不Init都不能用,你为什么非要我每次创建它的时候都立刻调用Init这么多次一举呢?而且你这样会使得我无法对于一个这样的函数f(shared_ptr<ClassThatNeedsInit> x)直接写f(make_shared(new ClassThatNeedInit))因为你的构造函数是残废的!

有些人会说,init没有返回值,我不知道他犯了错误啊——你可以用Exception

还有些人会说,exception safe的构造函数好难写啊——学啊我艸

但是这样仍然有些人会负隅顽抗,都这么麻烦了反正我可以用对Init和返回值就好了——你连exception safe的构造函数都不知道怎么写你怎么知道你可以“用对”它们

#region 题外话展开

但是有些人就喜欢返回error,怎么办呢?其实我们都很讨厌Java那个checked exception的对吧,要抛什么exception都得在函数签名里面写,多麻烦啊。其实这跟error是一样的。一个exception是可以带有很多丰富的信息的——譬如说他的callstack什么的,还可以根据需要有很多其他的信息,总之不是一个int可以表达的。这就是为什么exception【通常】都是一个类。那如果我们不能用exception,但是也要返回一样多的信息怎么办?你只好把函数的返回值写得相当的复杂,譬如说:

于是现在出问题了,我有一个ThatFunction调用ThisFunction,当错误是一种原因的时候我可以处理,当错误是另一种原因的时候我无法处理,所以在这种情况下我有两个选择:
1:把错误信息原封不断的返回
2:把ThisFunction的错误信息包装成ThatFunction的错误信息
不过我们知道其实这两种方法都一样,所以我们采用第一种:

你可能会说,为什么不把tuple包装成另一个struct?其实都一样,懒得写了。

我们知道,通常一个常见的几百人一起写的小软件都会有几百上千万行甚至几十G代码的,函数的调用层次只有几十层都已经很不错了。就算调用链里面只有10%的函数添加了自己的错误信息,那累积到最后肯定会很壮观的。而且只要底层的一个函数修改了错误信息,所有直接间接调用它的函数都会受到影响。

这简直就跟Java的checked exception一样嘛!

有些人会说,我们有error code就够了!我知道你们根本没有好好想“怎么做error recovery”这件事情

有些人还会说(就在我微博上看见的),用error code就是代表可以不处理,我干嘛要费那么多心思搞你这些返回值的东西?我对这种人只能呵呵呵了,转行吧……

这个时候我就会想,C++多好啊,我只要把ReturnValue<ReturnType, ErrorInfoForThisFunction>给改成ReturnType,然后在函数里面发生了错误还是构造一个ErrorInfoForThisFunction,然后直接给throw出来就好了。throw一个值我们还不用关心怎么释放它,多省事。对于ErrorInfoForThatFunction,我还可以让这两个struct都继承自同一个基struct(就是你们经常在别的语言里面看见的Exception类了),这样我在外面还可以直接catch(const 基struct& ex)。

有些人会说,为什么不强制所有继承都继承自Exception?我知道你们就只是想catch了之后不理罢了,反正C++也有catch(…)你们偷着乐就行了

用Exception有性能问题?反正在不发生错误的情况下,写了几句try也就只是操作了写在FS : [ 0 ]里面的一个链表而已,复制几个指针根本就不算什么影响

C++的catch不能抓到Access Violation(也就是segmant fault?)?现在连最新的.net你来写catch(Exception ex)也抓不到AccessViolationException了。都AV了你的内存都搞得一团糟了,如果你这个时候还不备份数据dump自己然后退出重启(如果需要的话),那你接着执行代码,天知道会发生什么事情啊!连C#都觉得这么做危险了,C++只能更危险——所以用SEH抓下来dump自己然后进程自杀吧。Java还区分Error和Exception,虽然我不知道他具体代表什么,反正一般来说Exception有两种
1:可以预见的错误,譬如说Socket断开了所以Write就是败了给个Exception之类的
2:必须修改代码的错误,譬如说数组下标越界——这除了你写错以外根本没有别的原因,就应该挂掉,这时候你debug的时候才能立刻知道,然后改代码

所以有三个基类然后最严重的那种不能catch我觉得也是一种好的选择。你可能会问,那C#在AV之后你又抓不到那怎么知道呢?答案:Application类有一个事件就是在发生这类事情的时候被调用的,在里面dump就好了。如果你非要抓AV,那也可以抓得到,就是有点麻烦……

#endregion

说了这么多,无非就是因为一个类的构造函数——其实他真的是一个函数,只是函数名和类名一样了,这种事情在js里面反正经常出现——强制了你只能返回正确的时候的结果,于是有些人没办法加入error code了,又不知道怎么正确使用exception,只好搞出个C语言的封建社会残留思想Init函数来。其实我们知道,一旦有了Exception,函数签名里面的返回值就是他正确执行的时候返回的东西,这根构造函数一样。

C++的exception在构造函数里面不好,其实是因为一旦构造函数发生了异常,那代表这个类没构造完,所以析构函数是不会执行的。这在一定程度上给你写一个正确的构造函数(这也是“如何写一个正确的类”的其中一个方面)带来了麻烦,所以很多人到这里就呵呵呵了。

这就跟很多人学习SQL语言结果到group by这里就怎样也跨不过去了一样——人和人之间说没有差距这个不符合客观现实啊……

不过我不否认,想写一个正确的C++程序是一件非常困难的事情,以至于连我在【只有我自己用的那部分library】里面都不是总是遵守各种各样的规则,反正我写的代码,我知道怎么用。不过公司的代码都是一大堆人一起写的,就像sqlserver一个组有一千多人一样(oracle是我们的十几倍,我们还能活下来真是太不容易了)——你能每个人都沟通到吗?撑死了就几十个吧,才不到10%。天知道别人会在代码里面干什么。所以写代码是不能太随便的。同理,招人也不能太随便,特别是你们这些连code review都不做的公司,你平时都不能阻止他checkin垃圾代码,你还敢招不合格的人吗

现在我们回到面向对象的东西。Exception其实也应该算在contract里面,所以其实interface里面的函数会抛什么异常是需要明确的表达出来的。但是checked exception这个东西实在是太蠢了,因为这个规则是不能组合的,会导致上面说的error返回值一样的“接口信息大爆炸”。

所有不能组合的东西都是很难用的,譬如checked exception,譬如锁,譬如第一篇文章说的C语言那个碉堡了的函数指针数组作为参数的一个成员函数指针类型的声明什么的。

如果你不直接写出这个函数会抛exception,那要怎么办呢?方法有两个:
1:你给我把文档写好了,而且你,还有你,用这个library之前,给我RTFM!
2:就跟VisualStudio一样支持xml注释,这样VS就可以在你调用这个函数的时候用tooltip的形式提示你,你需要注意这些那些事情……

什么?你不用IDE?给我RTFM!你连文档都不看?滚!明天不要来上班了!

突然发现本来要写面向对象的,结果Exception也写了相当长的一段。这件故事告诉我们,就算你不知道interface as contract是什么意思,你还能凑合写点能维护的代码。但是你Exception用得不好,程序就写成了渣,这个问题比较严重,所以写的也就比较多了。所以下面我们真正来谈contract的事情。需要注意的是,C++对这种东西是用你们很讨厌的东西来支持的——模板和偏特化。

contract的概念是很广泛的。对于面向对象语言来说,int这种东西其实也可以是一个类。你们不要老是想着编译后生成什么代码的事情,语法这种东西只是障眼法而已,编译出来的东西跟你们看到的可以是完全不同的。一个典型的例子就是尾递归优化了。还有C#的int虽然继承自object但是你直接用他的话生成出来的代码跟C++是没什么区别的——因为编译器什么后门都可以开!

那我们就拿int来说吧。int有一个很重要的特征,就是可以用来比较。C++怎么表达这个事情的呢?

如果你想写一个排序函数,内部想用<来排序的话,你不需要在接口上写任何东西,你只需要假设那个typename T的T可以被<就好了。所有带有<的类型都可以被这个函数使用。这特别的structure typing,而且C++没有concept mapping,导致了你无法在接口上表达“这个类必须带<”的这件事情,所以一旦你用错了,这错误信息只能跟烟雾一般缭绕了……

concept mapping其实也是一个面向对象的特别好用特征不过这太高级了估计很多人都没用过——你们又不喜欢haskell和rust——那对于我们熟悉的面向对象的特性来讲,这样的事情要怎么表达呢?

于是我们伟大的先知Anders Hejlsberg菊苣就做了这么个决定(不是他干的也是他手下干的!)

所以你的排序函数只需要写成:

看看IComparable<int>这个傻逼。我为什么要创建一个对象(IComparable<int>),他的职责就是跟另一个int作比较?这实在是太蠢了,无论怎么想都不能想出这种对象到底有什么存在的意义。不过因为C#没有concept mapping,于是看在interface as contract的份上,让interface来干这种事情其实也是很合理的。

所以contract这个东西又赋予了一个更加清晰的意义了。我们其实想要表达的事情是“我们可以对这个参数做什么事情”,而不是“这个参数是什么类型”。所以在这个Sort函数里面,这个T其实不代表任何事情,真正起到声明的作用的是where T : IComparable<T>这一句,指明了data数组里面的所有东西都是可以被排序的。那你可能会问,为什么不把IComparable<T>改成IComparable然后干脆把参数改成IComparable[] data呢?虽然说这样做的确更加“面向对象”,但是实在是太不现实了……

本来面向对象这种概念就不是特别的可以表达客观现实,所以出现一些这种状况,也是正常的。

#region 题外话

看看这两个函数:

他们互相之间存在了一个特别优美的(数学意义上的)变换,发现没有,发现没有!所以对于动态类型语言(interface可以从代码里面得到)做一些“静态化”的优化的时候,就可以利用类似的技巧——咳咳,说太远了,赶紧打住。谁说懂点代数对编程没用的?哼!

#endregion

在这里我们终于看到了contract在带泛型的纯洁的面向对象语言里面的两种表达方法。你可能会想,我想在java里面干这种事情怎么办?还是换C#吧。那我们拿到一个class的时候,这代表什么呢?其实我们应该看成,其实我们拿到的是一个interface,只是他恰好只有一个实现。所以在这种时候,你最好不要依赖于“这个interface恰好只有一种实现而且我知道他是什么”的这个事情,否则程序写大了,你会发现你越来越不满足“面向interface编程”的这个原则,代码越来越难处理了。

我们可能会想到另一件事情,先知们跟我们说,当你设计函数参数的类型的时候,这个类型越基,哦不,越是在继承链里面距离object靠得越近越好,这是为什么呢?这其实也是一个interface as contract的问题。举个例子,我们需要对一个数组求和:

费尽心思终于写好了,然后我们第二天发现,我们对List<T>也要做一样的事情。那怎么办呢?

在这里,Sum究竟对data有什么需求呢?其实研究一下就会发现,我们需要的只是想遍历data里面的所有内容而已。那data是不是一个数组,还是列表,还是一颗二叉树,还是你垃圾ipad里面的一些莫名其妙的数据也好,其实都一样。那C#里面什么interface代表“遍历”这个contract呢?当然是IEnumerable<T>了:

这样你的容器只要能够被foreach,也就是继承自IEnumearble<T>,就可以用这个函数了。这也是为什么”linq to 容器”基本上都是在IEnumerable上做的,因为他们只需要遍历。

哦,我们还说到了foreach。foreach是一个语句,用来遍历一个容器。那你如何表达一个容器可以被foreach拿来遍历的这个contract呢?还是IEnumerable<T>。interface拿来做contract的确是最佳匹配呀。

其实说了这么多,我只想表达一个东西。不要太在意“这个变量的类型是什么”,你要关心的是“我可以对这个变量做这样那样的事情”。这就是为什么我们会推荐“面向接口编程”,因为人总是懒散的,需要一些约束。interface不能用来new,不包含成员变量,刚好是contract的一种很好地表达方法。C++表达contract其实还可以用模板,不过这个就下次再说了。如果你们非得现在就知道到底怎么用的话,我就告诉你们,只要把C#的(i as IComparable<int>).CompareTo(j)换成Comparable<int>::Compare(i, j)就好了。

所以在可能的情况下,我们设计函数的参数和返回值的类型,也尽量用更基的那些类型。因为如果你跟上面的Sum一样,只关心遍历,那么你根本不应该要求你的参数可以被随机访问(数组就是这个意思)。

希望大家看完这篇文章之后可以明白很多我们在面向对象编程的时候,先知们建议的那些条款。当然这里还不涉及设计模式的东西。其实设计模式说白了是语法的补丁。设计模式这种东西用的最多,C#略少,你们会发现像listener模式这种东西C#就不常用,因为它有更好的东西——event。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注