c++ 面向对象的类设计
类的设计在于用恰到好处的信息来完整表达一个职责清晰的概念,恰到好处的意思是不多也不少,少了,就概念就不完整;多了,就显得冗余,累赘,当然特例下,允许少许的重复,但是,这里必须要有很好的理由。冗余往往就意味着包含了过多的信息,概念的表达不够精准,好比goto,指针,多继承这些货色,就是因为其过多的内涵,才要严格限制其使用。好像,more effective c++上说的,class的成员函数,应该是在完整的情况下保持最小化。但是,这里我们的出发点,是成员数据的完整最小化。
最小化的好处是可以保持概念最大的独立性,也意味着,可以用最小的代价来实现这个概念,也意味着对应用层的代码要求越少,非侵入式?好比c++11 noexcept取代throw(),好比从多继承中分化出来接口的概念,好比不考虑多继承虚继承的普通成员函数指针。又比如,如果不要求只读字符串以0结束,那么就可以把只读字符串的任何一部分都当成是只读字符串。类的对外功能固然重要,但是,类不能做的事情,也很重要。
首先是要有清晰的概念以及这个概念要支持的最基本运算,然后在此基础上组织数据,务求成员数据的最小化。当然,概念的产生,并非拍着脑袋想出来的,是因为代码里面出现太多那种相关数据的次数,所以就有必要把这些数据打包起来,抽象成一个概念。好比说,看到stl算法函数参数到处开始结束的迭代器,就有必要把开始结束放在一起。比如说,string_view的出现,这里假设其字符存储类型为char,string_view就是连续char内存块的意思,可以这样表示
struct string_view { const char* textBegin; size_t length; //或者 const char* textEnd };
这里的重点是,string_view里面的两个成员字段缺一不可,但是也不必再添加别的什么其他东西。然后,在这两个数据上展开实现一系列的成员函数,这里,成员函数和成员字段这两者,有一点点鸡生蛋生鸡的纠结,因为必要成员函数的集合(原始概念的细化),成员函数决定了成员字段的表示,而成员字段定下来之后,这反过来又能够验证成员函数的必要性。不管怎么说都好,成员函数的设计,也必须遵从最小完整化的原则。再具体一点,就是说但凡一个成员函数可以通过其他成员函数来实现,就意味着这个函数应该赶出类外,作为全局函数存在。当然,这也不是死板的教条,有些很特殊的函数,也可以是成员函数,因为成员函数的使用,确实很方便。
可能会有疑惑,感觉所有的成员函数其实都可以是全局函数。或者说,我们可以对每一个成员字段都搞一对set、get的函数,那么所有的其他成员函数就可以是全局函数的形式,很容易就可以遵守最小完整化的原则。当然,这是明显偷懒,拒绝思考的恶劣行为。与其这样,还不如就开放所有的成员字段,那样子就落入c语言的套路了。所以的法论是,一个函数,这里假设是全局函数,如果它的实现必须要访问到成员字段,不能通过调用该类的成员函数(一般不是get,set)来达到目的,或者,也可以强行用其他函数来完成任务,但是很麻烦,或者要付出时间空间上的代价,那么就意味着这个函数应该是该类的成员函数。
类的设计,就是必不可少的成员字段和必不可少的成员函数,它们一起,实现了对类的原始概念的完整表达,其他什么的,都不必理会。一个类如果不好写,往往意味着这个类的功能不专一,或者其概念不完整,这时,可以不要急着抽象,如果一个类有必要诞生,那么在代码的编写中,该类的抽象概念将一再重复出现,猿猴对它的理解也越来越清晰,从而,水到渠成地把它造出来。所有非需求推动,非代码推动的,拍着脑袋,想当然的造类行为,都是在臆造抽象,脱离实际生活的艺术,最终将被淘汰。
类的设计,其着眼点在于用必要的数据来完整表达一个清晰的概念。而继承,则是对类的概念进行细化,也就是分类,好比说生物下面开出来动物、植物这两个子类,就是把生物分成动物、植物这两类,继承与日常生活的分类不太一样,继承的分类方式是开放式,根据需要,随时可以添加新的子类别。整个类的体系,是一颗严格的单根树,任何类只能有一个根类。从任何类开始,只能有一条路径回溯到最开始的根类,java、C#中就是Object,所有的类都派生自Object,这是一棵大树。单根系下,万物皆是对象,这自然很方便,起码,这就从语言层面上直接支持c++ std的垃圾any了。而由于java、C#完善的反射信息,抛弃静态类型信息,也可以做动态语言层面上的事情,而c,c++的void*,所有的动态类型信息全部都在猿猴的大脑中。java平台上生存着大把的动态语言,而且,性能都还很不错。
相对很多语言来说,c++就是怪胎就是异数,自有其自身的设计哲学,它是多根系的,它不可能也没必要搞成单根系,当然,我们可以假设一个空类,然后所有的类都默认继承自这个空类。c++的所有类组成一个森林,森林里的树都长自大地。但是不管怎么说都好,只能允许单继承,千万不要有多继承,这是底线,千万千万不能违背(当然,奇技淫巧的场合,就不必遵守这个戒条,多继承千般不是,但是不可或缺,因为它可以玩出来很多花样,并且都很实用很必要)。最起码,单根系出来的内存布局直观可预测,一定程度上跨编译器,只有良好的内存布局,才有望良好的二进制复用。另外,父类对子类一无所知,不要引用到子类一丁点的信息,要保持这种信息的单向流动性。
在这种单根系的等级分明的阶级体系下,一切死气沉沉,没有一点点的社会活力。显然,只有同属于同一父类的类别之间,才能共享那么一丁点可怜的共性。如果没有接口捣乱,将是怎样的悲剧,最好的例子,mfc,真是厉害,没有用到接口,居然可以做出来严谨满足大多数需要的gui框架,没有接口,并不表示它不需要,因为mfc开了后门,用上了更厉害的玩意----消息发送,即便如此,mfc有些地方的基类还有依赖到子类,这就很让人无语了。
c++下,类的设计绝对不对儿戏,一定要清楚自己想要的是什么,抽象出来的概念才不会变成垃圾。大而全的类,远远不如几个小而专的细类。java,C#下的类开发很方便,但是粒度过大,把一揽子的东西都丢给你,强卖强买,反正只要类一定义,必然相应的就会出现一大坨完善的反射信息,而对象里面也包含了一些无关紧要的成员字段,而对象的访问,也全部都是间接引用的访问,虽然,现在计算机性能过剩,这些都无伤大雅。c++给了开发者最大的选择,而搞c++的猿猴,基本上都智力过剩,对于每种选择,都清楚其背后的代价以及所要到达的目的,所以虽然开发时候,存在心智包袱影响开发效率,但是,但内心就不会存在什么性能包袱的负罪感。就个人而言,还是喜欢c++这种最高自由度的语言,有时候,对于内存最细致的控制,可以得到更精简的设计,这里无关运行性能,好比说,在c++中,只要内存布局一致,即便是不同类型的对象,通过强制类型转换来统一对待,进而做匪夷所思之事,好比COM里面,为了聚合复用,一个类,竟然可以针对同一个接口提供两套实现方式。这种方便,在其他强类型语言中是不支持的。
某种意义上讲,c++在面向对象上提供的语言机制,就是为了方便地生成各种内存布局,以及此内存布局上所能支持的操作,虚函数用以生成一堆成员函数指针,继承则用以方便地生成一坨成员字段,……。所以,c++的面向对象就是面向内存布局地设计,而多继承、虚继承、模板这些鬼东西很容易就导致内存布局的失控,不过,如果使用得当,却又有鬼斧神工之奇效,创造出来其他语言所没有的奇迹。真的,论动态行为艺术,任何语言在c++这个大人面前都是幼儿园里的小学生。
为了引出接口,本座花大力气做科普。这也没办法,因为类虽然是基础,但是静态面向对象的精华,全部都在接口上。只有清晰明确类的功能职责,才能理解接口的必要性以及其多样性。那么,可不可以只有接口,没有类的。可以,就好像com那样子,而代价是,使用起来,各种不方便。这个世界,从来就不存在包治百病之万能药。什么事情都能做的意思就是什么都做不好。