梦断代码--一个程序员的自白 笔记

 不让转载,只好摘录,有空再整理笔记

原文:

 梦断代码--一个程序员的自白 (一)

梦断代码--一个程序员的自白 (二)

梦断代码--一个程序员的自白 (三)

梦断代码--一个程序员的自白 (四)

梦断代码--一个程序员的自白 (五)

梦断代码--一个程序员的自白(七)

梦断代码--一个程序员的自白(八)

梦断代码--一个程序员的自白(九 完)


        而对一个不理解的问题,设定了一个解决方案则是更不可思议的。在我看来,这根本就是软件设计中的大忌。
从我当时的判断来看,这是一个小而紧凑的系统,风险不大。因此,当务之急是搞清楚这个系统的职责范围,在整个大系统中的位置和功能。然后衡量非功能需求有哪些,是不是可以做到。对功能性需求,划定范围,犁清边界是重要的;对非功能需求做保底计算,确定可行性是重要的。当然经验也是重要的,可是不分析问题的原因,而是把问题本身作为一种恐慌而传播,在我看来就是不成熟和不职业的体现。


    ADP中将有一个很重要的部分就是管理property,ADP的性能和内存占用是很重要的,还特别提到要考虑如何缓存处理property。从我知道的信息来看,已经知道ADP是读写一个本地的文件包,并管理一系列property map的对象

    在DWF的property系统里弄了个cache机制,缓解内存占用的问题。当时的做法是,给property(名称的问题,实际上是property map)弄了个proxy,这样,就有机会不立刻加载property map,而是延后到实际访问的时候。为了进一步降低内存,还把已经加载,但是暂时不用的property map交换到外部磁盘去,我简单地弄了个文件做交换。而C++的花哨技巧也帮了忙,不需要改动其他部分代码,只要重新build就好了。


    DWF的代码在我看来,颇有些叠床架屋的味道。虽然满是腐朽坏味,但是如果能从中吸取教训,也不失为非常好的教材。既然工作在了DWF上,那就既来之,则安之吧。我被要求继续对DWF做优化。很快,我找到一个确实有价值的地方。考虑有许多property map对象,而这些map中的Property name名称是大量重复的,如果能够用flyweight模式的话,会节约大量内存。

   

     做了一个property name的string pool。这一次的结果测试效果非常好,最好可以节约50%左右的内存,最差的case的大约是16%左右,平均也有35%左右吧,记不太清楚了。

   我设计的交换文件其实很简单,内存中维护一个property到外部block的map,外部block就一个接一个放在文件中。


       AIRMax这个项目计划在三个方面做资源整合,即为所有产品提供统一的程序库或者框架:程序外观(GUI),渲染(主要是3D引擎),和文件格式(保存设计成果)。OPC是微软提出的一个所谓Open Packaging Conventions的文件包组织标准。Office 2007实际上已经采用了OPC,就是现在经常看到的docx,pptx什么的。简单说,就是一个Zip文件,而OPC规定了Zip内部的文件应该怎么组织。我们的数据将以XML文件的形式打包到Zip中。

        我们的软件产生的文件一般都相当大。200M算是常见的大小,如果采用一种很罗嗦的表示方法,文件大小就会膨胀到很危险的程度。另一个问题,这些现有的设计文件是有复杂的内部结构的,比如某产品在其设计文件内就做了类似数据库的东西。如果我们只是简单地把它们转成XML,并压缩放入Zip,除了性能会降到一个不可接受的程度,现有的文件访问工作流也将被破坏。所以,所谓的“统一的文件存储”绝不意味着一个万能的单一文件格式。在我看来,我们至少要做两种文件存储机制,一是供产品工作过程中使用的,高性能的文件存取机制,这种机制是附着在产品所在的机器上的,我们的目标是压榨所在机器上的所有资源获取高性能。另一种机制是设计成果的保存,这种文件是以“交换”为目的的。一个产品生成的文件,要能够在别的产品,别的操作系统,别的CPU架构上读取。这种为交换目的而作的设计,性能要求可以不那么苛刻,但是需要一个紧凑的格式,且能严格向后兼容。

    除了文件存储,还有一件事是ADP想做的,那就是运行时的数据管理。这个东西在一开始我并不理解。对对象存储,常规的方法不外乎是序列化,ORM,手工读写这些常规方法。但是,ADP没有这么做,而是提供了一个我怎么概括都觉得不准确的方法。让我们站在产品的角度来看一下这个运行时数据管理。对于写数据的过程,产品首先通过ADP的API创建一个数据集,接着创建内存视图,然后把自己对象的数据(和类型)写到视图,最后ADP扫描这个视图,写到外部文件中。读取的过程则相反。对于同一个逻辑数据,比如Point,各个产品当然都是早就有了的,而且因为代码不相通,显然在C++中,A产品的Point和B的不是同一个东西,甚至还有各不相同的方法。但是从数据模型的层面来说,这两个Point又是一样的,都是表示空间中的一个点,都有float型的x,y,z。这就意味着ADP无法写出一个和所有产品都一样的Point。所以,ADP只能是定义一个自己的Point,而各个产品自己再把ADP的Point转换成他们的。这么看,ADP的数据类型只是个中间产物,如果所有产品都能服从相同的Point的存储方式,实际上无需ADP的内存块那一层,直接把文件数据读取并转换成它们的数据就行了。ADP管理对象纯属多余,多做了一层,既慢还浪费内存。我当时第一时间就认识到这是一个浪费,但是也觉得ADP中似乎只能这么做。也就是说直觉和经验告诉我有问题,但是没找到问题根源是什么。

    ADP还定义了一些基础数值类型,对应到C++的基础类型。对于一个内存块,就看作是数据的连续存放。这样,用户在访问数据时,要知道需要的数据在内存块的偏移量和类型,然后ADP的函数帮助读取出来。这种读取方式当然是很不方便且相当危险的。因为缺乏对数据类型和布局进行描述的元数据,这要求应用程序对数据必须有精确的掌控。当时对那些固定长度的数据类型都已经写好了,但是有一种数据类型,字符串,因为长度不固定,所以还没弄好。怎么放字符串呢?这就是美国的O同事所谓的“challenge”。

    当时有两个显而易见的选择。一个是把字符串就写在内存块中,在前面标一下字符串长度就行了。另一个选择是,存一个指向new出来的字符串数组。两者各有优劣。考虑一个含有字符串数据成员的C++struct,第一个方案的好处是数据连续存放,数据复制很友好,memcpy就行。缺点是不能简单地定位特定数据成员在内存块的偏移了,因为字符串是变长的,必须从头开始扫描,才能计算出特定数据成员在内存块中的偏移。而后一个方案则相反,在内存块中定位数据成员仍旧容易,但是要多出一个分立的内存单元,memcpy显然不能工作了。一方面,ADP希望能支持memcpy写出数据到文件,另一方面,又想能简单定位数据成员。这种两难就是“challenge”的来源。我选择了第二种方案。

    选择第二种方案的理由之一是用户访问数据的方式已经很差了(麻烦且危险),不能再复杂了。另外,memcpy用在IO的过程中,IO本来就慢,多做点处理也影响不大。还有一个隐含的理由就是,ADP内部实际上是必须知道内存块中对象的布局的,否则在读取的时候,就不可能从文本重建出那个内存块。只是ADP当时还没有认识到这一点,所以也没有相关的API和专门的抽象机制,但是实际上是做了这个事的。我当时认为未来做数据布局乃至类型的抽象是必须的,那么现在在此假设上工作就不算浪费。于是我加入了字符串,实际上就是C++的字符数组的支持。然后小心地处理好和文件的读写、内存的复制,分配和释放问题,就觉得工作差不多完成了。

    另外的中国同时当时好像在设计IO的实现。美国的设计师G好象是打算用istream/ostream接口来访问数据,可是众所周知,这是一个恐怖的接口。除了istream,ostream之外,还有对应的streambuf接口,还有localization支持等等。所以G要求用boost的iostream来实现。这又是一个不面向问题,却面向技术的选择。最后则完全从代码中消失了。

     最开始ADP的IO接口在层和层之间不一致,上层都是用ADP(DWF)自己的IO接口,但是最底层(physical layer)用的是iostream。当时研究过之后发现boost的iostream基本就是对stl iosteam的另一层建模,一个数据处理节点叫device(不知道现在是不是还是如此),所以性能方面两者几乎一样。

          后来之所以全部换到ADP自己的io接口的主要原因,就是我们自己的io接口跟stl\boost的设计很不一致,语义经常有冲突,比如,iostream很娇气,稍微不小心就bad state了,之后所有的read write都不工作,所以就越发觉得不好管理,大家一气之下就都给换成了自个儿的io接口,类似的bug就少很多了。

      说到性能问题,据说第一版ADP比之前的data API平均慢了20%多(?),远远达不到美国同事要求的%5-%10之内,虽然我们提供了太多的功能。后来分析,IO这边的性能不是问题,单测logical layer往下甚至还更快,之所以慢的原因是ADP比原来的API多了n层,因为之前的API只有一层。那会儿偶很不理解为什么要拿一个n层的东西跟一个只有一层的东西比性能。


     所谓简单设计,简洁代码,也只能是对合格程序员说的,而不是让一个未入门的菜鸟觉得简单。菜鸟的唯一出路就是变成专业人士。我对自己写的代码的态度也是如此,只有代码表达不出来的东西,比如系统的高层设计,如问题分析,动机,原理这些无法在代码中体现的东西才需要文档。


    在我的极力主张下,把单元测试搞起来了,用的是boost.test作为测试框架。QA最初也使用boost.test。但是很不幸,首先是美国同事在UT中输出性能测试数据,后来又更过分地输出许多的log信息,这让我极为崩溃。


在没有正确实践过DBC之前,我不确定程序员是否能理解DBC的美妙。当我在过去的项目中同时运用DBC和异常安全时,我得到了如同编程初学者所写的那种简单直白的代码。所不同的是,我不露痕迹地完成了错误处理。整个代码几乎完全由Happy Path构成,逻辑主干突出,分支语句比例大幅度下降(if语句比例可下降2/3。一个极端的例子是1400多行代码缩减到不足200行)。那些代码看上去简单质朴,但实际上都是深思熟虑的。反璞归真,我认为我的代码是有能力达到这个境界的。对许多程序员,甚至是资深程序员来说,如何设计软件是两眼一抹黑的。“设计之道,不在增无可增,而在减无可减”,不理解这话的人都还在纠结加什么功能特性上,要处理那些错误和特例呢。然而DBC虽好,还必须让运用的程序员能立刻从中收益,才能被愉快地接受。


    我为ADP写了一个contract的库,提供了PRE_CONDITION,POST_CONDITION,和INVARIANT三个宏,而且说明,只有一个PRE_CONDITION是必须的,提供三个只是为了概念的完整。可以等同于assert来使用,搞不懂的时候,就只用PRE_CONDITION,用错了也出不了大事儿。唯一要牢记的是PRE_CONDITION(expr)中的expr不能有副作用,这对于理解assert的人来说,应该完全没有记忆负担。本来,我还打算继续在此基础上,讲述如何设计API,如何划分API职责,要注意些什么。最好,能推行异常安全代码。我把Contract库,一些资料的链接,以及解释写了封长邮件给美国的架构师G同事。他先是回了封邮件说很好,说质量很重要。但是,过了个周末,他又回了一封信,这是让我彻底抓狂的信。


    在我看来,一个技术,如果带给程序员很大的心智负担,那也不能算好。按照Brooks,并且我也认同的观点,软件开发自身的困难未来都是可以解决的,而要解决的问题域所含的困难是本质性的,永恒的。一切软件技术的进步都要以解放程序员的智力、精力,使之可以更多、更好地投入到分析问题中去。这就是为什么我只推荐一个PRE_CONDITION的原因,也是我不喜欢繁琐而严格的命名规范的原因。


事实上,ADP早期还用着异常规范呢,这可是2007年了。我后来还是忍不住,从《Exceptional C++》里面扣出几条,说明别用那悲催玩意儿了,这才把异常规范去掉。

       没有一项技术是银弹。一般来说,也没有一项技术是应该单一实施的,总是需要其他的技术来支撑,或相互支撑。因此,即使不钻牛角尖,单说某一项技术好或坏也是靠不住的。DBC是有价值的,但是也需要许多其他技术的配合,才能发挥价值。如果缺少配套技术,DBC成为障碍也不奇怪。我文中提到的其他技术,便是与之配套的。
现实是,我们现在能够有一整套的、相容的、相互配合的软件开发技术,从而极大提升软件生产力和质量。而DBC则是这一套技术的核心之一,也是在我的工作生涯中被实践了的。DBC当然也有力不从心的地方,但是那一般来说可以被别的技术适当解决。


dbc的核心思想源自卢梭的《社会契约论》。契约是对两个完全责任人的权利和义务的合同记录。到程序中,契约意味着是caller和callee的权利和义务。pre是申明自己的权利,别人的义务。post是申明自己的义务,别人的权利。而且权利义务是伴生的,一方的权利必然是另一方的义务。怎么划分权利义务,正是软件设计的职责所在,也是魅力所在。

    和市俗的契约一样,可以强调、检查自己的权利,或别人的义务;但自己(实际是任何人)不能强调和检查别人的权利,C++中因为没有语言支持,也很难检查自己的义务(本来就是要保证做到的,知道怎么检查的话直接做掉更切合实际)。在C++这种不支持DBC的语言中,pre容易做,价值很高,post很难做,价值很低。这就是我为什么只强调pre的原因。


他所担心的问题之一是内存碎片化,而我对此则根本没放在心上。他认为,对一个数据对象,存放在连续内存中是必须的,而在我看来完全不必要。而我则低估了他对这一点的看重。在我看来,因为这一层是紧靠IO层之上的。相对于IO的时间来说,内存操作的开销就不算什么了。另外,内存排布问题基本上可以通过一个定制的内存管理器,比如allocator之类的东西去控制。也就是说程序不直接关心内存从哪里来,反而给予allocator以机会去充分利用操作系统的各种机制去优化。从上层来说,使用方便也比一点点性能损失来的重要。何况运行时对象管理实现得越简单,从长远来看,反而有利于性能。悲剧的是,我错误地理解了对方心中主要问题。


   我当时主要的关注点是三个方面的问题。第一,这个runtime不能依赖于一个物理的IO接口,必须将IO操作集合先抽象出来。我们既不能假设IO输出到文件,也不能假设其输出成XML。一个条件反射式的方案就是adaptor模式。更进一步,在adaptor中支持compose模式。那么IO的骨干就完工了。我们很容易扩展adaptor,为输入/输出到本地文件系统,zip文件,http,ftp写专门的adaptor,这是一个刚毕业的程序员也能够胜任的工作。运用compose模式,我们就很容易实现加密、签名,支持文本、XML、二进制格式数据。更重要的是,这样做的好处是,工作被充分分解了,每一项工作对人员的水平要求都降低了,还可以多人并行工作。容易测试,也容易保证质量。这么做还有一个理由就是ADP只是个程序库,最终用户(产品)有各种理由要求调整和扩展。因此,ADP根本就不能和具体的IO adaptor绑死,而是必须提供多个小的组件供用户选择并装配后使用。当然,ADP可以提供一些预置的装配,方便使用,但最终 决定权必须交给产品。也就是说,可扩展性,可配置性(可装配性),简单性三者必须都要照顾到。后来的事实也证明,这种预见是必须的。否则,就是噩梦般的折磨。


   当然,这么做IO adaptor的设计风险也增加了。但是这些风险实际都是微不足道的。对于C++来说,可能的抽象惩罚也是几乎不存在--事实上,只要在设计和实现中避免冗余的数据复制就差不多了。毕竟,我们只是在和一个低速的IO系统打交道。当然,如果要较真,adaptor模式显然不适合大规模、重度的IO场景,比如,同时处理成千上万的文件、网络连接。如果是那样,就需要慎重对待了。显然ADP不是这样的项目。


   我关心的第二个方面是用户代码如何去访问这样一个内存中的数据?O给出的接口是非常原始和不安全的。例如,为了读一个int数据,用户需要准备一个int变量,然后调用接口,把数据复制回用户的int变量中。这种内存复制的操作是非常低效和不安全的,因为用户还要告诉接口int变量的字节数。接口实际只相当于memcpy,只是知道从哪里开始复制给用户而已。所以,我认为我们需要给用户一个高效、安全的数据访问接口。这不难做到。如果要访问一个int,那API就返回那个数据的int引用给用户即可。至于string,也是如此。当然,这就要求ADP的对象管理内部真的放了一个string对象才可以这么做。这也就是我为什么的解决方案中用了个string类型来放字符串的原因。


   第三个问题是真正的核心问题。至少我是这样认为的。O设计的数据类型,除了C++中的基础类型(Fundamental type)外,就只有个未实现的字符串类型。还有其他几个长度固定数据类型,比如二维向量Vecotor2,2×2矩阵e2X2Matrix之类。这就导致一个严重的问题,数据类型不是可扩展的,那个runtime的实现也支持不了内容变长的数据类型,如数组。这也是为什么O同事认为字符串非常困难的一个原因。不能支持数组也是个说不过去的事情,而支持数组本质上说就比支持字符串困难。数组的困难在于,若要真正支持,就必须允许元素的类型是可变的。这就意味着数组本身不是一种具体类型,指定了元素类型的数组才是个、具体类型。这也意味着需要有一种简单的类型复合机制。另一方面,用户实际使用的数据类型是很多的,比如Point,Vector。仅仅是一个点,就存在Point2F,Point2D,Point3F,Point3D,甚至是Point4D。这多出来的一D是时间。同样叫Light,不同产品、同一产品的不同场合中各个Light数据成员可以完全不同。如果ADP不能够提供一种机制来定义和产品中一致的数据类型,那么产品就只好使用基础数据类型来生搬硬套。这将在事实上导致产品不可能直接用ADP的runtime数据来建模,而只是将ADP当作一个数据序列化的目的地或来源,把数据再次翻译到他们已有的数据模型中去。这个推理结果是必然的,事实上也正是如此。不可能有任何理智的程序员会将数据模型直接架构在ADP的runtime上。


    这样,ADP的一个主要目标,统一公司的数据模型对象的企图就必然要破产了。然而,也并不是说ADP支持了自定义类型和类型组合就能成功。这个机制的效率,空间效率和时间效率都必须非常高,要和C++相当,至少差别不大。此外,还必须有非常健壮、易用的API,才有可能让产品接受。要知道产品都是给公司挣钱的,都非常强势。幸运的是,这几个要求都是能够做到的。不幸的是,ADP一条也没做到。


    关于这个类型的扩展、组合机制,我后来又在另外两个程序库项目中见到,都是采取蛮干的办法,例如通过硬编码支持int,double,float,和string的数组。既不能允许用户自己扩展,也不允许组合新类型。唯一的办法就是修改库代码来支持新类型。因为修改程序库不方便,所以,产品么,只好将就吧。


    对于这三个问题的认识并非事后诸葛亮,而且,我也估计O同时并没有想那么多。所以,虽然O对我的方案很有意见,但是我没特别放在心上,而是认为事实会让他清醒的。后来的事实表明,我再次幼稚了。


    因为之前在DWF项目上时就profile过其中string类型的效率,指出了其中一些实现上的严重问题,也包括一些接口设计的问题。结果关于性能的结果得到了重视,而接口上的毛病则毫无音信,这颇让我怀疑美国同事在这方面的水准和品位--Linus 所谓的那种品位。因为DWF string的性能有问题,所以,我们应该避免这些问题,然后实作一个更高质量的string了吧?我还写了封信给G,说我打算实现一个更适合ADP的string,因为那也算是补足O的方案中未完成的部分。G的答复让我先是极为诧异,然后就凌乱了。G同事决定,我们不能使用string数据类型,因为string类型性能低下,所以,我们使用char*,哦,因为要支持本地化,所以,我们要统一使用wchar_t*!我理解不了这理由和决定之间的逻辑是什么。WTF,这是一种什么样的精神?至于我原来打算怎么设计那个string,G自然也是毫无兴趣过问了。


    这件事让我对自己也起了很大的怀疑,因为这样的事情不是第一次了。为什么我屡次想做得更好,却总是得到了一个更糟糕的结果?我究竟错在哪里?怎么做才是对的?是我根本就不适合在一个从属地位上做团队合作吗?短时间内一系列出人意料的结果,从UT,DBC,到这个string,第一次让我怀疑起自己和别人的沟通能力。我已经把对自己的要求降低到不指望和外行人交流了,可是现实却告诉我,我和内行人交流一样失败。我不知道为什么会这样,我开始对自己的能力感到强烈的不自信和怀疑。


    这个string的故事还没完。wchar_t*显然是很难使用的,可ADP还是就这样坚持一两年。最后实在受不了性能问题--实际上是必然的一个后果,过度扫描和复制字符串--要用引用计数。好像是美国的M同事,写了个叫StringPointer的东西。看名字就能知道,根本就不会有什么像样的API,事实上也确实如此。StringPointer一直用到ADP快结束时,为了将ADP集成到另一个叫Protein的项目中去,又弄出一大堆string类型来。这一次出来的东西更加让人受不了了,然而寿命却注定了出奇的长。留待后继再吐槽吧。


    继续第三个问题。类型复合和自定义用户类型是可能的,因为我们项目所使用的语言,C++就是一个现成的例子,JSON则是另一个例子。C++中可以定义任意类型的数组,这一点在std::vector表现的更清楚,通过变更模板参数就可以做到,当然如何在运行时做到还需要一点小技巧。而C的struct就是自定义数据类型的典范。如果ADP能够做成这两件事,那么就能够具有和C一样的用类型建模的能力--当然,离抽象数据类型(ADT)还差点儿。


    在那两三个月之后吧,我花了一个周末的时间做了个原型,尝试了一下想法是否可行。对于基础数据类型,我们可以知道,也只需关心其对象大小,对齐属性。我把要自定义的复合数据类型用一个表格来描述,就像struct所作的那样。这样,我就可以算出每个成员的大小和在对象中的偏移量。最后,得到的复合数据类型也具有一定的大小和对齐要求。这就和基础属性所需关心的特性一样了,换句话说,可以和基础类型一样被处理了。复杂的基础类型,如string,还需要关心构造和析构,这是为了和C++对象交互所必须的,其他某些语言则可以不考虑这个问题。而复合类型的构造和析构也很容易自动生成出来:按照恰当的顺序遍历成员进行析构和构造就行了。


    对于数组的支持稍稍复杂一点。我需要实现一个特别的vector类,元素类型不是模板参数,而是在构造时告诉vector的一个描述数据,即Type。vector内部就医根据Type提供的size,alignment等信息,计算出给定元素的偏移量,也可以用Type给出的构造析构方法初始化元素。


    这样定制出来的新数据类型可以和C++的struct描述的结果内存布局完全一致(当然你不能在C++中放任何virtual的东西),内存布局一致,就意味着用户可将之cast成他们喜欢的数据对象并直接操作。当然,直接cast是危险的,也是高效的。我们还可以提供一套带检查的cast机制,作为常规的使用方式。


    但是我当时在做这个的时候也还是犯了点小错误。出于简单起见,那个原型中作为输入的复合数据类型的描述表是一个数据结构,而不是文本,这样可以避免写一个解析器。但是,那个数据结构中的描述可能是有错误和矛盾的啊。检测错误是容易的,可是如何报告错误,并且尽可能精确定位错误呢?我不想,也不应该在将来解析一个有错误的文本时,扔出一个失败的结果,却不告诉用户错在哪儿了。用户不是小网站站长,我也不能做真理部不是?哦,sorry,Traslation team,我真的不是在影射你们,你们不会告诉用户任何错误的,还会毁尸灭迹。

 

   我搞混了那个输入数据结构和文本的本质区别。以至于我后来为了实现一个即是异常安全的,又能精确报告所发生错误的解析器花了不少时间。要么难看之极,要么没有达成所有目标。最后才意识到自己理解上的错误,这大概就是一个人写代码所必须付出的代价吧,没人会指出你再明显不过的愚蠢之处。以后会有对这一蠢行更具体的记叙。当然,最后还是完满解决了。那是一个正面的教训:绝不轻言放弃,绝不轻易妥协。


void getData(void* dest, const char* pdata, size_t offset, size_t size) 
T& Object::getData<T>(const FieldDescription& field)比起来,后一个是否安全和高效一点?这两个getData都是暴露给最终用户的,不能排除用户在算offset/size的时候会算错,而这种计算将散布于用户代码中--我们有多个用户。
当然用户也可能传错类型T,但是FieldDescription中可以记录typeinfo信息,考虑到性能,至少可以在debug build下做一定的检测。而第一个版本,基本上就是依靠程序员素质和测试质量了。


1.程序内是个善意合作环境,而非对抗合作环境。对于C++来说,同时提供const和非const T引用完全不是问题。相当于一个是get,另一个是get+set。如果有人非要从返回的T取地址,然后写内存搞破坏,那就是恶意的了,因为getData可以防御不小心造成的破坏。
2.如果size很大或调用频繁,复制的开销是不可忍受地高。
3.第一个形式只能工作于POD,无法用于非POD。


      对于对象跨DLL边界,后来我发现不是O一个人的问题,许多人的理解都有问题。他们其实不明白为什么有时候一个对象不能跨边界,那些有问题的做法又是怎么导致问题的。其实跨边界行不行很容易判断,只要看边界两侧的代码对同一个对象的内存布局的假设(或者说约定,但是实际上没有约过)是否一致。如果是一致的,就能够跨,不一致,就跨不了。而对象方法的代码是否一致根本不重要,一个debug build的DLL有时候可以链接另一个release build的DLL就是例子。曾经容易出问题的一个地方是堆内存,原因就是边界两边使用的就不是同一个堆管理对象啊(其实只是些数据结构),但是现在堆在许多情况下已经不是问题了,比如VC就运行库。事实上,C++标准库早就跨边界传递对象--不是对象指针--好多年了。


     多年以后,我也渐渐有点明白O的思路,到底错在了哪里。O当时一心想弄一个非常“Low level”的runtime,然后再加一层好用的wrapper,给用户用,底层负责解决高性能问题,wrapper负责解决易用性。貌似我刚开始写程序的时候也有过类似的想法。软件要分层是没错,但不是这么个分法。如果分层是个简单的活,那软件也太好做了。而且,这种分层法注定要很难逃过抽象惩罚的,因为不同层不但概念分层了,运行时也分层了。层次越多惩罚越重。写到这里,我忽然想,是不是很多的抽象惩罚其实都是这种无意义的自虐呢?


ADP的两大主要目标全告失败了。目标之一是可交换的文件格式,之二是公司范围的统一对象模型。

按照我的快速失败的观点来看,ADP此时就可以停止并且反省了。要么改目标,要么纠正方向。

而次要目标,运行时的高性能支持,已经注定是不可能做到的了。错误报告机制,实际上就是个Log。我个人是不大喜欢Log的,也不认同很多人对Log的使用方式。我认为Log是用来记录工作流的检查点,而不是用来核对程序正确性的。程序正确性需要靠UT来保证。Log虽然也能起到错误诊断的作用,但是那只是副产品,就好比某人爱拍视频,但显然不是为了作为破案的证据的,虽然它确实有那作用。我写了个Log的设计给O,那个设计中提供了三个接口,Logger,Formatter和Device。分别用于过滤Log等级,格式化,指定输出设备。设计成接口的目的当然是为了可以定制和替换。另外,免不了的,还会有个总成的地方。并且,我也强调了,初始化这个日志系统的决定权应该交给最终用户。这次还不错,O觉得很好。然而正是这个东西,最后让我下决心远离ADP的主要工作,以免自己的声誉受损。

    那时候G同事忽然插进来,说生产者/消费者可能更好,然后logger的实现就可以非常简单,只要把数据打包然后发到一个队列里去就行了。这样,生产者都不需要考虑同步,消费者处理负责同步就行了。他所谓的生产者是用户代码,消费者是将数据打包发送到一个内部队列,这个队列被一个工作线程维护,然后那个工作线程负责格式化并把数据写出到设备。好处是Log调用处不会被阻塞。这样,我们只需要一个logger的实现就够了。

    当时正是多核狂热的时候,多线程和并行计算也被热切地讨论。G有这个想法很自然。而且,总的想法也不坏,可惜太不完善。首先让人难受的是术语。在讨论多线程的时候,我没见过把不需要同步的东西叫生产者/消费者的,只能理解为之前几天他被多线程的话题轰炸太多了。其次,说一个logger实现就够了,只是自大的妄想。今天回头再看当时的邮件,可明显地看出我和G在设计软件上的差异:我考虑的是Logger要做什么,应该让用户怎么用,那些地方用户会需要扩展,怎么扩展;G则是着眼Logger应该怎么实现,怎么可以一下全部完工,怎么用上先进的技术。我认为我们两者的一个根本差异在于,我相信程序库和产品是一个共存的关系,程序库要遵循Open-close原则,而G眼中的组件都是封闭的。

    其实,G所谓的那些更好的选择,其实在我的设计里面已经都解决了。我的那个Device接口只有一个write函数,非常容易实现,也就意味着非常容易扩展,这是故意的。要同时输出到多个设备,只要实现一个伪Device类,转发给多个其他Device对象就完了,根本不是个事。至于输出阻塞不阻塞,是不是放到队列里去,是不是起一个线程,有必要在Logger设计中考虑吗?留给扩展就行了。其次,Logger还负有过滤日志等级的职责,第一时间过滤当然是代价最小的。因为Log一般还要采集当时的一些执行数据,这就意味着要格式化以后才能打包进队列,这是开销很大的事,如果日志很多的话,这么做性能会很成问题。即使将格式化后移到工作线程中去做,前面打包数据进队列也是开销很大的,这还限制了所能采集的数据类型,数据得能打包才行啊。所以显然的,日志等级过滤必须第一时间做,甚至还要优化--理论上可以优化到只有一次标志检查的开销。

    本来这个Log是实现在一个DLL中的,G曾经要求将它放到叫core的静态库中去。O没有理会G的意见重新设计,但还是把代码提交到了Core中。结果,没多久就遇到了一个Bug,有两个DLL都link了Core,出现了两份Log管理器的实例。实际上我的原始设计中已经提供了解决方案,只要显式地初始化Log系统,就可以使两个Log系统使用相同的Logger,Formatter和Device对象。G对此很不满,认为我们如果按照他的意见实现就不会有这个问题。其实按他的要求实现只会问题更大,怎么启动的那个worker线程就会惹上大堆的麻烦,事实上也确实如此。不管怎么说,G后来还是给出了自己的设计,然后让另一个同事实现了。从此这个东西就开始一直折磨我们。一会儿性能出问题了,一会儿加载DLL锁死了,一会儿又程序退出锁死了。有产品要求我们不能默认启动,有产品又要求启动。还有产品一会儿要求自动启动,然后又要求不自动启动。程序崩溃时锁死,不能退出这个特性为我们招揽来了许多的崩溃报告。当然,修这样的Bug,还是可以彰显ADP的存在感的,多酷啊。从那件事以后,我就再也没有动力去思考ADP的方向了,我只冷眼旁观,只是多少有些不甘心。

    接下来的日子里,我发现我就没做成过什么事,ADP也没什么事能让我看得上眼。有人写个Iterator(不是STL那种风格,first/next/valid风格),能在构造函数里把对应容器的元素指针全部复制到Iterator的一个容器成员变量中,所得的好处是遍历的时候是线程安全的,全然不顾复杂度从O(1)降到O(N).至于我当时被迫要求用Design By Policy去糅合一堆的Iterator就不要提了,那代码写完了就没人能改动,我也不行。不懂Memory Model,一样敢用Lock-Free。为容器中的每个元素创建锁--当然要用Lock-Free的技术,然后锁还要是按需创建的。用原子的Flag代替Mutex,用Yeild让出CPU代替Wait锁。真是恣意奔放,激情燃烧啊。只可惜烈火中没有重生的凤凰,只有灰烬。


    我那时其实已经有数年的多线程项目经验,并行计算,Lock-Free什么的也是接触了好几年,至少也是从正儿八经的教材和文章开始学起的。CSP虽然到今天我也没啃下来,但好歹是知道点方向的。可是有什么用呢?G同学拿着Lock-Free的大锤满世界砸的时候,我只能重复Andrei Alexandrescu的话,告诫说:“General Programming很难,异常安全代码也很难,可是和多线程比起来,那俩不过是小娃吃奶”。我想,那时候我没告诉他们有种东西叫MPI,大概是我在ADP项目中作出的唯一正确的选择。渐渐地,当有别的team人问我ADP的问题的时候,我开始不愿意去解释那些垃圾的设计。因为当别人一脸谦逊地问我为什么要这么做,有什么好处时,我觉得无地自容。我实在是没法解释,只能为自己开脱,说:这代码不是我写的,那个不是我设计的。这是怎样的一种悲凉啊。

    ADP还以一种病态的方式追求性能。比如坚持使用memcpy,坚持不用传统的Mutex,用TBB替换掉Boost.Thread等等,却在真正需要性能的地方挥霍。为了从ADP的runtime复制一个整数,首先要通过字符串名字查map来确定偏移量,然后再new两个串接起来的对象以shared_ptr返回。仅仅为了读一个整数,整个过程要做一个LgN的查询和3次内存分配!Runtime相关的测试中,内存分配释放一度耗费70%执行时间。就这样的结果还好意思提什么高效内存管理?G同学也不甘落后,实现了一个可局部排他锁定的图的实现,本意是想多个线程访问同一个图时,如果操作的部分不重叠,就可以避免锁等待。我自始至终就没看懂那个图的实现,但是我会测试啊。结果测试数据拟合下来的复杂度是O(N^4)!就这样的东西,我真无法想象是如何还有勇气推销给别的团队的。明明就是个本地变量,栈分配就够了啊,非要new出来,觉得指针就是高效的?莫名其妙的做法不胜枚举,可以写本C++傻事儿大全了。


    渐渐地,我发现自己离开了开发的核心位置。我开始做各种杂乱的事务,比如在Linux上用scons,用SWIG导出API(必须抱怨一下,SWIG不支持嵌套类,害死我了),去写Python/C#的sample,修Memory Leak,Performance Tuning,编译器升级。甚至还做了一个诡异的API调用序列的回放工具(做这个被dynamic_cast折磨死了,实在是太多了,而且不能查找替换,因为有些是不需要处理的)。要么就去做一些看上去有点难度的事情,比如写一个内存池--因为boost那个太慢了,heap优化。还有那让我抓狂的,取代boost的,shared_ptr/weak_ptr,只有一个额外要求:同时能支持intrusive的counter。shared_from_this是不满足这个条件的,它仍然需要一个分离的counter。谁有兴趣的话可以挑战一下这个任务。我反正是做不出来,实在没办法,只好不管怎样都会创建counter,但是review代码的美国同事无人发表任何意见。

    并不是很复杂的业务需求被搞得十分的扭曲。这里面一些人的错误决定明显是要负责任的。他们都忘记了一条,那就是永远采取最简单最直接明了的方式来解决问题。程序要关注目标而不是关注实现。很显然做决策的人没有经验或本身水平很烂,就这么回事。


   提供公共接口的话,我觉得用纯C的接口比较好。char *作为字符串没什么问题。String的话,因为不同的产品可能使用不同的String,甚至是自定义的String,反而不方便。不论什么String,一定会提供和char *的互相转换的方法。你们的库要想得到其他产品的认同,必然有个过程,其他Team不可能因为你们的库,就放弃自己的类型。

关于logger,在早期ADP中全局的static变量是有问题的,因为那个Core是个静态库,多个DLL就意味着有多个变量实例,运行时这是会有矛盾的。logger,可以设计为根据一个全局变量/static变量,决定是否启用。
字段在Struct中的偏移,把地址0强制转化为结构体类型,就可以直接获得每一个字段的偏移量了。


即使把数据存成文本,依然需要制定存储和转换协议,并预言中的问题却一一变成现实。一个是本地化的问题。例如在德语中,千分位和小数点符号不同于英语,而是相反。因此,“3.142”这样的数,到了德语里面就被读取成3142,这显然是不能接受的。这个问题在项目早期很好解决,就是要规定外部文本的解析方式,比如统一使用C locale保存就很容易搞定。另外一个是精度问题。比如0.3这样的数。用户输入的0.3,下次再打开,显示的却可能是0.2999999999。其实对于明白原因的人来说,这再正常不过了,可是对某些人却是难以接受的。由于Protein内部同时使用float和double类型,double还好一点,float出现前面问题的机会非常大。于是一个凑合的方案出台了,就是全部改成使用double,显示的时候,截短显示位数,利用平台API的能力来达到显示为0.3的效果。这种做法是非常脆弱和幼稚的。我们需要通过制定数据规格,让用户理解,什么样的显示差异是有道理的,也是符合预期的,而不是凑一个让用户满意的结果。如果我们明确了这种差异和差异的原因,用户代码自然就有据可凭,去填补数据表示和显示之间的空隙,而不是一团糊涂地代劳。事实上,提高精度和利用平台API的方法,在用户做了一个float到double再到float的转换后,因为精度丢失,就失效了。精度问题在后来Protein 3.0引入限制数据范围的特性后变得尤为严重。从字面上看是合法的数据,但是代码执行中却可能是非法的。这是因为double转成float会四舍五入,所以舍入后方向并不确定的缘故。这种问题对某些软件来说完全不重要,对另一些软件来说,则是至关重要,因为它影响到数据正确性。我们公司的软件正是后者,多是用来做各种设计--也就是创造数据的。


    另一个我认为很重要的数据完整性(包括正确性)问题,则从未得到重视,或者说从未被理解过。在我看来一个很显然的事实是,对于ADP、Protein要处理的任何外部数据,都要持恶意假定,即可能是任何形式的坏数据。在此情形下,在处理到任何坏数据时,都不能出现崩溃或其他的致命错误(如内存耗尽,死循环等等)。即便是安全性错误也是要竭力避免的,如临时文件漏洞,缓冲区溢出等等。对于保存数据,ADP采用了创建--改名的机制,虽然很慢,很笨拙,却也有效地避免了数据完整性问题。就地修改的特性,且不采用回滚日志,最后也没做出来--幸好没做出来。时至今日,ADP、Protein在读取到坏文件时,几乎100%会发生故障,安全性问题更是没有任何评估。然而就这样的东西,现在居然有人将之部署到网站,用来处理用户提交的数据文件!这是打算引诱人犯罪吗?


    当被自己预言过的问题坑了的时候,我不会觉得自己英明,而是为自己的无能为力感到羞耻乃至愤怒--愤怒不过是无能的另一种表现形式罢了。乃至于到我快离开ADP时,还给了我一个几乎让我暴走的任务:为了避免阻塞在费时的ADP保存数据操作上,要让写出过程是异步的,用多线程解决。要知道,这是ADP的运行时太慢导致的,即使我异步处理,仍然要读ADP运行时数据,而且为了保持数据一致性,就必须要锁住ADP系统--ADP可没有快照机制--直到保存数据操作完毕,那么要异步何用?


    Protein团队的组织有点松散。我到了Protein长期处于无事可干的状态。在完成了Property和PropertyTable的实现之后,发了两次邮件请示工作无果后,我也就懒得关心,看看代码就是了。Protein由多个模块组成,我们的主要工作是其中一个dll,即提供对材质库的访问和管理,以及为材质对象建模。Protein API的风格应该是受Java或者.Net的极大影响,全部设计为接口,整个库导出一个唯一的全局函数(GetIAssetLibraryManager())作为入口点。因为没有导出任何实例类,所以一切的工作都要从这个library manager对象出发,逐步展开。对于这样风格的API,如果设计的好,未尝不可。但不管怎么说,这样的系统问题也是很明显的:
    1.难以获得语言的支持和配合。C++鼓励扁平化的类设计,为什么?说白了就是对非扁平化的设计支持不好。
    2.从依赖关系上来说,library manager依赖整个系统,任何使用了library manager的地点,都隐含地依赖整个系统,这回导致系统僵化,难以重构。
    3.无法直接创建任何一个接口的实例类,必须从library manager出发去创建。而library manager又不是factory,因此,任何一个组件都无法从系统中单独拿出来考察。这意味着组件隔离做不到,按需创建测试目标代价高昂,单元测试将非常困难和低效。
    
    除了两个给用户的扩展外,Protein的每一个接口,实际上只有一个实现类--就像Pimpl的接口和实现那样。然而,和Pimpl不同的是,Protein的接口和实现类的功能不是一一对应的,接口功能只是实现类的一个子集。这就导致一个很大的问题,在Protein的实现代码中,拿到一个接口指针,第一件事就是Downcast成其对应的实现类对象。这是典型的抽象不足的表现。然而,我们是缺乏API重构的权限的。事实上,大多数项目都缺乏此权限。我当然理解API变化会影响客户代码,但是我不明白,为什么在项目早期也不鼓励改进API呢?我坚信,没有人可以在不写实现代码的情况下就设计出足够好的API来。


    另外,因为完全依赖C++的接口,在C++中就不可避免地要返回接口指针,如何维护这些返回对象的生命周期就是要慎重对待的。当然,如果有统一的约定,情况还好些,可惜Protein并不统一,所幸问题到也不很严重。Protein的实现完全是构建在FBX之上的,因此了解FBX也是必要的。FBX当中有一些质量不错的东西,但是也有大量不可理喻的东西。我不了解FBX的历史,倘若FBX最初写于1995年前,我觉得这就是一个不错的东西,如果写于2000年,那就平庸得很了。这让我想起AutoCAD的2D引擎Heidi。很多人对Heidi表示不屑,但是如果考虑到那是在80年代一个人搞出来的东西,就不能不表示尊敬,Heidi达到了它那个时代业界的最高质量标准。即使放到今天来看,Heidi所表现出的简洁性、一致性、严密性仍然是值得称道的--尽管已不符合当今一些人的口味。


    除了接口,Protein的实现质量在我看来也是很糟糕的。各种special case--我就不知道有啥不包含特例的--对我这样的人来说,记忆这种逻辑不明的东西就是个噩梦。大量的代码逻辑复制粘帖自FBX,以至于读代码是完全不知道在干什么,性能也很糟糕,各模块的交叉依赖简直是做到了极致。另外,Protein还附带了一些渲染处理,界面工具,而非仅仅是一个数据模型和包管理。这种混乱的层次关系也让我对Protein很是不喜欢。Protein唯一值得表扬的就是,它确实做了一件用户需要的事情,用户也确实在用。因此,我认为无论如何,Protein比ADP强。特别地,当有人拿ADP和Protein做比较,说Protein质量差时,我都忍不住要反驳:Protein很烂,但是有人离不开它,他有用;ADP不那么烂,但是所有人都不想要,恨不得扔掉它。至少从我的角度来说,我希望ADP彻底消失。一来可以使用了它的那些软件免于腐蚀,二来可以掩饰我的失败。

    Protein所谓的材质(Material),实际上是一个属性集合对象(差不多是这样,Protein管它叫Instance)不同属性可能有不同的数据类型和值,某些属性还引用了另一个Instance。Instance中的属性值有些可以改变,有些不能改变。Instance不能添加或删除任何属性,因此,每个Instance都从一个模板--叫Definition--clone出来的。可诡异的是Definition没提供接口去访问其中的属性,要想查询,先clone一个Instance吧!想修改,没门。到了2011年,我们还搞了个叫Schema的东西,专门定义Defintition/Instance有哪些属性,属性的数据类型,默认值等等。这种三层的结构实在是让人头疼到爆。然而,我们无权更改API。


  






  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值