个体经验与中医

我听说过无数关于治疗感冒的偏方,有喝酒的,有吃生鸡蛋的,有喝姜汤的。

对现代医学来说,感冒基本上还是一种“不治之症”,没有针对感冒病毒的有效药物,而完全要靠人体自愈,所以多喝开水多休息就是最合理的治疗方案,在没有细菌并发感染的情况下就使用抗生素简直是自取灭亡之道,而市面上的感冒药真正的有效成分都是用于缓解感冒症状的,如果你没办法休息,需要坚持工作,那么吃点感冒药缓解一些流鼻涕之类的症状无可厚非,但感冒药广告中声称的消灭感冒病毒就是胡说了。

所以只要你好好休息,只要你身体强壮,几乎吃任何东西都可以当成治疗感冒的偏方。这里可以看出个体经验的不可信之处,把时间上的先后关系等同于因果关系是人类思考时的简化模式,但常常会犯错误,我吃了一个生鸡蛋,第二天感冒好了些,再吃一个,第三天彻底好了,所以吃生鸡蛋治疗感冒是有效的,甚至不单单对我有效,我把这个经验推广给别人发现也是有效的。一部分中医的有效就是这么来的。

西学东渐的一百多年中,中国几乎全盘接受了西方的科学技术,唯独在医学上扭扭捏捏,这里面有好几重原因。

第一,现代医学本来起步就晚,很多理论还不完善,还有很多不治之症,给中国传统医学留下了空间;

第二,经济原因,中国传统医学讲的是万物皆可入药,“赤脚医生”不管能不能治病,至少花很少的钱给了患者一点希望,毕竟先进合理的医疗不是人人都能享有的;

第三,可能是最重要的原因,医学很难和个体经验剥离,每个人都认为自己很了解自己的身体,尽管医学可能不比物理学简单,但物理学是抽象的,与普通人距离很远,而医学研究人的身体,人人都觉得自己很熟悉,虽然这可能是一个幻觉。既然“人人都懂医学”,中国人为什么不能比西方人更懂医学?坚持一套独特的医学体系已经成为一种文化上的抵抗,让一门本应该是科学的学科成为了泛泛的文化,这是中国人的悲哀。中医成了文化,绑架了现代医学,然后再搭上民族自尊心和爱国主义,所以问题变得越发复杂。

剥离不了个体经验的学科是难以成为一门科学的,或者说是不具有普遍意义和推广价值的,吃生鸡蛋治感冒这样的经验,是不能治病的。所幸,现代医学已经发展出一套合理的方法,来剥离个体经验,下次我们再谈谈安慰剂和双盲测试。

都是你自己的错

前几天跟某个人约好,让他帮我一个忙,他满口答应了,今天晚上再确认的时候,他说情况有变,搞得我很被动,计划都被打乱了,事实上,如果我早几个小时确认,还有很好的补救措施。

当然我也在内心里埋怨了几句这个人的不靠谱行为,当初答应的事情,如果情况有变,应该通知我的。可是仔细想想,这又有什么用呢?需要把一件事情做成的人是我,抱怨是自己无能的表现,人生在世,难免需要别人帮忙,但是对不可控因素没有预估,造成了损失,骂娘也没什么用了。千错万错,错在我没有及时确认,让自己陷入了被动的局面。虽然这是一件小事情,但是以小见大,值得三思。

及时的确认很重要,有备用方案很重要。

面向巧合编程

比起面向过程编程或者面向对象编程,其实很多程序员最常用的编程范式是面向巧合编程。当然我们可以站在高处批判这种作法,但是我想这里面的原因也值得追溯一下。

一个理想的程序员当然是热爱编程的人,所以它会对自己写的每行代码都较真,要去弄清背后的事情,但是更多的程序员只不过是打一份工而已,完成老板交待的任务就好了,然后打卡下班,所以软件工程才显得那么重要,一个函数可以写得很烂,但如果它通过了所有的测试,也就变得可以接受了。

有很多项目,不过是领导上拍脑袋想出来的,出生的一刻就注定了被丢弃的命运,虽然我们说不要写学生代码,但是毕业了不见得就不要写一次性的演示代码。工程师思维的特点之一是也解决问题为主要目标,至于为什么解决了问题,相对没有那么重要,所以你发现很多面向巧合编程的最终似乎也解决了问题,It just works。

面向巧合编程的巧合来自两个层面,一个是原理层面的,代码原理未彻底搞懂,但是实现了效果,这里面的主要风险在于测试不充分,真正运行起来面对复杂的应用方式总会有跪掉的一刻。其实还有另外一个层面的巧合,就是未彻底理解用户需求,按自己理解瞎搞了一番,结果用户接受了,原因可能是往往用户自己也不知道自己的需求是什么,所以现在都强调迭代开发,用户参与。

面向巧合编程无疑是相当危险的,它通过放弃深究原理来赢取时间,稍有不慎就更加浪费时间,作为一个有追求的程序员应该彻底摒弃这种范式,如果面对不得不写这种代码的窘境,最好的出路是换份工作,作为管理者,自然是优先去招有追求的程序员,把测试等外部监管手段作为最后的防御。毕竟,我们生活在一个现实的世界,追求有美感的东西可以帮助工程师更好的解决问题,但解决问题是最终目标。

这是一份草稿

不要傲慢

最近其实上网闲逛很少了,但是偶尔看看,感觉中文互联网上弥漫着一股傲慢的气息,感觉非常不好。表现有以下几点:

  1. 不容许任何对中国的批评,认为批评的人都居心不良,要不是拿了黑钱就是内心阴暗;
  2. 认为西方在不断衰退,中国在不断强大,西方都要开始跪舔中国,西方领导人以及西方人都是白痴,比如美国人就选出来了川普这样的白痴;
  3. 随时都敢跟美国干一架,至于收拾日本、台湾,更是分分钟不在话下;

中国最近几十年经历了高速发展,可能是世界上进步最大的国家了,90后,00后出生在经济高速发展的年代,确实有自傲的资本,可是自豪变成了傲慢,是相当可怕的事情。

站在国家的角度来看,中国无论是硬实力还是软实力,仍然和发达国家有客观存在的差距。硬实力主要看科技,真正的科技工作者都会明白这中间还有很多差距,以互联网行业而言,中国企业几乎还没有过真正的创新,都是把硅谷成功的模式抄到中国,当前大热的深度学习等理念,依然是美国在主导。软实力可以看国民素质,哪些屡见不鲜的中国游客的新闻提醒我们,我们离一个文明的族群还有一定距离。把川普看作白痴的人,估计大概率要被打脸的。

站在个人的角度来看,年轻人失去了知耻而后勇的尽头,中国的发展也就要告一段落了,一个真正努力的人,必然不会把爱国挂在嘴边,当成自己最大的资本,看了几篇几百字的分析文章,就开始对世界局势指点江山,一副老子天下第一的架势,借的不是自己有本事的底气,而是身处一个族群的虚无的荣光,这样的人如果占了主体,就真的完蛋了。

傲慢带来的,无论是发展的停滞,还是战火的危险,都不是个人之幸,国族之幸,但愿我只是神经过敏,杞人忧天。

中医是个伪概念

一直想写一写中医的话题,主要是想给家人看看,可是一直都不敢写,因为这个话题太大,它不单单是个科学问题,也牵涉到民族自尊心,政府在有意识的推广,甚至给予了立法支持,医学院有中医的课程,制药企业、医学界、媒体都牵涉其中,中医药是一个产值4100亿的产业,我有什么资格去谈呢?我觉得我学养不够,既不懂中医的阴阳五行,也不懂西医的循证医学。好在我还有理性思维,有些问题其实不必要掌握所有的细节,仅凭理性分析与随处可见的事实就能得出很多结论。

对中医的认识,和很多人一样,我曾经是很模糊的,我跟很多人都聊过这个话题,包括各种学历层次各个地域的中国人,其实很多人并没有细想过这个问题,生病就去医院看医生,然后医生开药,一般多是西药夹杂一些中药,少数人被慢性病折磨时会想到去看中医,抓几副药去吃,有吃了没效果的,也有吃了觉得效果很好的。

是的,一个国家的医学的一半,至少试图是一半,都在讲望闻问切、针灸配伍,一个普通人,有什么能力去分辨这一切的是非呢?可是当我不幸或有幸不断思考中医的问题后,就发现生活在一个巨大的有意无意的4100亿产值的骗局之中,这么多钱,这个产业中已经没有几个是干净的了。某一天,我突然想明白中医是个伪概念,突然觉得豁然开朗,即使还有很多技术性细节问题,但是只要明白这一点,一个人对中医的认识也会发生天翻地覆的变化,朝着科学理性的方向发展。如果不能接受这个观念,也几乎不能再一起讨论下去。

为什么说中医是个伪概念呢?

我们听过无数遍这样的广告词“中西药结合疗效好”、“西医治标,中医治本”。可见,中医这个概念和西医这个概念是伴生的,在中国人见识所谓的西医之前,是没有中医的概念的,而只有医学的概念。任何国家、任何族群的人都会生病,他们都会想办法去治病,这些经验或理论总结出来就形成了医学。类比一下,在接触西方的数学体系之前,中国人也有自己的数学,有祖冲之这样的数学家,但是奇怪的是,现在的中国,没有所谓的中数、西数之分,而只有一门学科,就叫数学。

中医、西医两个概念是伴生的,但是这里的思维陷阱在于中医应当是中国传统医学,而西医则是西方现代医学,如果有兴趣读一读医学史就知道,现代这套打针吃药输血做手术的医学,在西方兴起也不过几百年,在此之前,西方人(欧洲人)是怎么治病的呢?有草药,有放血疗法,甚至也有类似针灸的做法,只不过后来他们发现这些方法不科学,而找到了一套更加科学的理论和方法,逐渐形成了所谓的现代医学。

中国人几乎接受了所有的现代科学理论,比如物理化学生物等等,并且将自己的科学史也纳入到整个科学史的一部分,无数的科研人员也投入了现代科学的建设,没有人去区分那是中国的物理还是西方的物理。

可是由于历史的原因,学科的特殊性等等,唯独医学,被区分成了中医和西医。这里不想展开产生这个区分的原因,而是想从逻辑上厘清这样区分的谬误。中医对应的应该是其他国家的传统医学,比如西欧的传统医学,印度的传统医学,而西医这个概念也不应该存在,有的只是现代医学,中国有无数的现代医学研究者,他们研究的不是西医,而是全人类的医学。

总结一下,简单画一个表应该是这样:

  传统医学 现代医学
中国 1中国传统医学(中医) 2中国现代医学(西医?)
西方 3西方传统医学(西方草药学等) 4西方现代医学(西医?)

概念1只和概念3有对比,概念2和概念4应该是同一个概念。下一次再谈谈谈为啥唯独医学这门学科在中国被分为中西医,而物理化学没有。

记一次AccessViolationException异常的排查

前几天,同事出差说程序遇到一个偶发的bug,程序会报告内存错误然后崩溃掉。今天终于有时间仔细排查了一下。

背景:这是一个C#和C++/CLI混合编程的程序。

1 复现

首先按同事描述的操作方法复现了bug,发现虽然是偶发的,单其实很有规律,在操作的某个时间点后必然出现。

2 基本原理

内存错误显然是非托管代码带来的,在.Net环境中被封装为AccessViolationException。

首先看看AccessViolationException的MSDN文档,它是”在试图读写受保护内存时引发的异常”。具体说明如下:

当非托管或不安全代码尝试读写未分配或不具有访问权限的内存空间时,就会产生访问冲突。 这种情况通常因为指针具有错误的值而发生。 并非所有通过无效指针的读或写操作都会导致访问冲突,所以访问冲突通常指示已经通过无效指针进行多次读或写操作,并且内存内容可能已损坏。 因此,访问冲突几乎总是指示存在严重的编程错误。 AccessViolationException 明确标识这些严重错误。

在完全由可验证托管代码组成的程序中,所有引用都有效或者为空,因而不会产生访问冲突。 AccessViolationException 只在可验证托管代码与非托管代码或非安全托管代码交互时才会引发。

如果发生异常在公共语言运行时,会保留的内存不足 .NET Framework 4开始,公共语言运行时本身引发的 AccessViolationException 异常没有被在结构化异常处理程序的 catch 语句处理。 为了处理这种 AccessViolationException 异常,则应用 HandleProcessCorruptedStateExceptionsAttribute 特性到引发异常的方法。 用户代码不影响 AccessViolationException 异常的此更改,可以继续用 catch 语句捕获。 对于针对要重新编译并运行,而无需修改.NET Framework 4.NET Framework 的早期版本编写的代码,则可以添加元素到 应用的配置文件。 注意也可能收到异常的通知,如果定义 AppDomain.FirstChanceException 或 AppDomain.UnhandledException 事件的处理程序。

看完先给发生问题的函数加上了HandleProcessCorruptedStateExceptions,捕获到异常了,但是没什么实际作用,这种错误明显不应该发生的。

3 发现错误源头

刚开始用VS2010调试,对C++/CLI太不友好了,看不到太多有用的信息,后来改用VS2015调试,能在崩溃时看到C++/CLI代码的详细信息了,然后发现了一个空指针,程序试图在空指针上进行结构体的访问,必然会崩溃啊。

对空指针的来源进行了仔细的回溯排查,发现在某个查找操作中返回了空指针,这个空指针还传递了好几个函数,衍生出好几个空指针,直到最后试图使用指针时崩溃了。

到这里发现了第一个问题,C++/CLI代码中的托管对象封装了传统C++的指针,对托管对象的操作,实际上会转发到指针上去,如果指针为空,这个托管对象实际上没有存在的意义,但是之前的代码忽略了这个问题。

在构建托管对象时,应当检验其封装的指针,如果为空指针,那么对象也应当初始化未null。

此时,AccessViolationException已经变成了C#程序员天天见的NullReferenceException,如果封装时注意一点,就不会这么辛苦找源头了。

4 出现空值的原因

产生空值的查找操作,按理说不应当出现,而且它的出现也是有一些偶发性的,如果把异常截获,程序还能继续跑下去也没什么问题,也就是,待查询的对象集合在大部分时间都是正常的,但在某个瞬间出了问题。

仔细排查了一下哪些操作可能更动这个对象集合,发现了一个重启操作,包含了清空与加载两步,而且这个重启操作与出问题的查询操作在不同的线程中。

到这里,发现原来是典型的操作原子性未保持导致的多线程错误,清空后还没加载时去查询,自然会出现空值,给对象集合所在对象加了个锁总算没有异常了。当然,后来我发现这个重启的必要性很可疑,那是另外的话题了。

5 小结

总计一下:

  1. 应当让错误早点暴露,限制空指针的传播范围,具体到C++/CLI中,要把它封装成null。
  2. 多线程的坑层出不穷,行为难以预料,要谦虚谨慎。

屁股决定脑袋

有句说出来不甚雅观的俗语叫“屁股决定脑袋”,我近几年越来越觉得这句话很有道理。我亲眼看见某些人对同一件事情的看法发生天翻地覆的变化,只是因为他前后两次所处的地位不一样。

每个人倾向于将自己的思想和行为变得融洽,而一个人所处的位置往往决定了他会有哪些行动,所谓“人在江湖,身不由己”,即使刚开始,他可能不大情愿去做某些事,但是时间长了次数多了,他必然要修正自己的三观去匹配自己的行为,这就是行为反作用于思想的可怕之处。如果不修正自己的三观,那么他就面临时时刻刻的矛盾,自己不认同自己所做的事情,陷入痛苦不堪的境地。电影《朗读者》中的女主角参与了屠杀犹太人,但她只把那当成了自己的日常工作,她不会让自己意识到自己的行为有多么罪恶,这就是典型的屁股决定了脑袋。

还有句俗话叫“不要因人废言”,但是仔细想想,一句话从谁嘴里说出来与这句话的可信度还真是有很大的关系,我们首先得看看说这句话得人屁股在什么地方,看清楚他得利益所在,再去判断这句话有没有意义,比如我们为什么会觉得“五毛”和“美分”说的话不可行,因为他们拿钱发帖,显然不会发自真心的去考究自己的话。又比如罗振宇在跨年演讲时说了一堆趋势,可是仔细想想他的屁股在自媒体和知识服务上,所以他的说法都是对应这些来说的,那么我们作为听众就得看清楚自己的屁股可能不再那里,所以参考价值可能比想象中低多了。

超越自己的屁股相当困难,至少有两个障碍,一是认知上的障碍,每个人的视角都是有限的,观点自然都是从自己熟悉的领域出发,向外辐射时肯定会有局限,二是利益上的障碍,少有人会发表对自己所处地位不利的观点,即使他能分辨所谓的善恶对错,但是谈论的可能还是利弊,更可怕的时他还会修正自己的三观去适应自己的利益。

所以,要想少被人在脑子里跑马,听人说话时得看看他的屁股在哪里。而自己的思想想要超出自己的位置,那是需要反复的修炼的,就像《无间道》中道明叔说的:从来都是事情改变人,而少有人改变事情。通过改变思想改变自己的行为和位置很难,而因为自己处在一个位置就改变了思想则是不知不觉的。

五步阅读法

简要总结一下自己最近的阅读方法,还需要不断迭代更新。

阅读的基本目标应该是知行合一,所以以娱乐为目的的阅读不在本文讨论范畴。本文适用于读书,也适用于阅读中短篇的文章。

第一步 速览

这一步是专门针对我这样的急性子而设定的,拿到一本好书,总是会火急火燎的想把它看完,虽然仔细想想,怎么才算把一本书看完,也是很需要思考的重大问题。所以,如果实在克制不了,也不妨放自己一马,先快速读一遍,也算是留个大概印象。另外一种情况是快速读一遍之后发现这本书不值得细读,也算是节约自己时间了。从这个角度出发的话,速读也大可不必,速览一遍,观其大概就行了,当是挑书的一个过程。

第二步 演读

不知道这个词是不是我生造的,我的意思是详细的去读书,详细到作者的思路能在脑子里演绎,如果不能达到这个程度,可能有几个原因:

  1. 你的理解能力不够,背景知识不够,这本书还没到你读的时候;
  2. 你需要多读几遍;
  3. 这是本烂书。

如何分辨这三种情况,实在是一个困难的事情,需要不断的积累经验,一般而言一个领域懂得越多,分辨起来就会越轻松。

演读的目的在于真正把书的内容吃透,理解作者的思想。

注意这个过程最好不要做笔记,更不要画重点,不要用手头的忙碌掩盖思维的懒惰,做出一副认真读书的样子自欺欺人。

第三步 提取

把书合上,开始做笔记,根据书的信息容量,看完一部分还是全部看完再做这一步可以灵活把握。

合上书做笔记,是一种简单而有效的自我检查手段,它要求你把学到的东西再反馈到纸上,这个笔记可以是提纲挈领式的,只要能根据这些提要把所记忆理解的东西复述出来,当然有时间的话还是越详细越好。实在没有时间或条件,至少合上书,自问自答一番。

做完笔记,可以再翻开书比对一番,看是否有错误与遗漏。

第四步 连网

要想链牢固的掌握所阅的东西,关键是把它转化为自己知识体系的一部分,将它链接到已有的神经网络中去。有如下基本基本的方法。

  1. 比较:跟已经掌握的知识做比较,看看有哪些异同点,是不是已有知识的另外一个面目,或者一个重要的补充;
  2. 反思:书中所说的有没有不准确的地方,有没有不详细的地方,或者能不能换一个更易于理解的说法。

第五步 践行

这是最后一部,也是最重要的不能省略的一步,尤其是对于有方法论指导的书而言,如果一本书中有一个重要的实践手段得到了你的理解认同,那么最好就是照着去做,这是阅读的最佳归宿,一本书能有这么一个点就是价值百倍了。

当然,不是所有书都有直接的方法论可供实践的,那么可以有几个退而求其次的办法:

  1. 读书笔记,这个读书笔记最好是对第三步笔记的改进版,用你自己的理解去重新整理,或者添加了你自己的理解,或者用学到的道理去阐释一个新的问题。
  2. 交谈,向一个没看过这本书的人去介绍其中的内容,根据不同的交流对象,选择合适的方式和内容,但是传递同样的思想内核。

到这里,大概算是读完了一本书。

最后要说明的是这五个步骤不是严格割裂和先后排序的,具体应用时可以灵活裁剪及交叉进行,但是最后一步是不能省略的,否则就只能算是娱乐性阅读,即使你读的是相当严肃的书。

高可配置性软件开发

可配置性是软件工程实践中的重要问题之一,配置文件与可执行文件一起构成了软件运行时版本控制的要素,Visual Basic,Python、Lua等解释型语言,常以所谓“脚本”的形式出现,进一步提升了操作系统、游戏等大型软件的可配置性,而领域驱动设计在强调领域模型的同时,也带动了描述配置领域模型的领域特定语言的发展。在工业控制领域,软件的可配置性用组态的概念来描述;在游戏开发领域,一般用引擎和脚本的概念来描述。

1 基本原理

软件复杂性的根源在现实世界的复杂性,软件的复杂性超过人的理解能力时,会带来软件开发的困难乃至失败,好的软件工程方法论能给出现实世界的良好抽象模型,通过合理的抽象层次结构管理软件的复杂性,使人能从整体到细节分层次的把握软件。源自现实世界的复杂性并不会降低或消失,但却得到了约束。

高可配置性可在一定程度上解决快速需求变更问题,把握软件中不变的部分作为模型,将易变的部分通过配置文件等方式约束起来。

graph TB subgraph 软件 complexity2[复杂性]-->abstract[共通抽象] end subgraph 软件 complexity1[复杂性]-->complexity3[复杂性] end

2 构建方法

构建高可配置性软件,首先应当针对领域特点建立通用软件模型,然后针对模型使用配置技术。不同的配置加上模型最后构建了不同的软件实例。

graph TB subgraph 软件2 config2[软件配置2]-->abstract2[通用模型] end subgraph 软件1 config1[软件配置1]-->abstract1[通用模型] end

2.1 建模

建模问题是一个很复杂的问题,取决于设计时不同的场景,需要领域知识与设计经验,并且和领域专家密切合作。这方面有几本经典的书:《领域驱动设计》《实现领域驱动设计》

2.2 配置技术

2.2.1 配置文件

配置文件是使用最广泛也是最基本的一种配置技术,配置文件主要用于描述软件启动及运行时所需要的数据,一般不直接描述软件的运行逻辑,常见的配置文件格式包括ini、xml、json等,简单比较如下表所示:

格式 表达能力 可读性 典型应用场景 举例
ini 较弱,不可嵌套 最好 软件启动参数。 启动目录、ip地址。
xml 最强 最差 复杂对象的序列化。 数据帧结构与解析规则。
json 一般 一般 web API接口。 天气预报服务的接口。

2.2.2 脚本语言

一些大型的程序,往往通过结合多种编程语言来实现,一般通过编译型语言实现系统的相对固定的部分与运行效率关键部分,通过解释型语言来实现系统的易变部分。比如Linux操作系统用C实现了很多工具,而通过bash脚本对这些工具进行综合应用,大型游戏基于C++游戏引擎构造,而使用Lua脚本等描述游戏动作逻辑。

2.2.3 领域特定语言

领域特定语言介于一般的配置文件和程序代码之间,即针对某一特定领域,具有受限表达性的一种计算机程序设计语言。

领域特定语言又分为内部领域特定语言、外部领域特定语言、语言工作台等,其中内部领域特定语言是主要语言程序代码的一种特殊写法,不能算是配置,外部领域特定语言则设计新的语言来进行表达,仍可归为文本配置的范围。而语言工作台则主要提供可视化的配置方式或者成为可视化编程。

领域特定语言介于配置文件和脚本程序之间,提供了领域内较高的表达能力,也便于领域专家的理解,可加速专家知识向软件的生成。

针对不同模型的需要,以上3种配置技术在高可配置性软件开发均可应用,下表对三种配置技术的主要特点和典型应用场景做了比较。

配置技术 表达能力 可读性 典型应用场景 举例
配置文件 描述软件运行需要的数据,一般不直接描述软件运行逻辑。 取决于具体的配置文件格式。 软件启动参数。 启动目录、网络通信地址。
脚本语言 直接描述软件的运行逻辑。 仅适合程序员理解,不利于领域专家理解。 基本接口形式固定时,描述易变逻辑。 在C++构建的设备监控接口基础上用Python脚本描述监控流程。
领域特定语言 描述领域模型的配置,从而间接描述软件运行逻辑。 既适合程序员理解,也适合领域专家理解。 建立了认可度较高的领域模型,模型的配置丰富易变。 用“状态-反应”模型表现故障处理,建立领域特定语言描述具体的内容。

3 工程实践

如图所示,高可配置性软件开发实际改变了传统软件的开发流程,突出了软件配置在软件生命周期中的重要地位。下面将阐述高可配置性对传统软件开发各阶段带来的影响。

graph TB subgraph 高可配置性软件流程 requirements2[需求分析]-->construct2[软件主体构建] construct2-->config[软件配置] config-->test2[软件主体测试] config-->config_test[软件配置测试] test2-->maintain2[软件主体维护] config_test-->config_maintain[软件配置维护] end subgraph 一般软件流程 requirements1[需求分析]-->construct1[软件构建] construct1-->test1[软件测试] test1-->maintain1[软件维护] end

3.1 需求分析

在需求分析阶段,相比普通开发,高可配置性软件开发需要再重点分析以下问题:

  1. 采用高可配置性技术的必要性,这主要是由软件的适用范围决定的,即软件是否要具备可推广性,软件应对的需求是否易变。软件需要适配的场景越多,需求越易变,则必要性越大,如果软件只用于一个基本固定的场景,则没有必要使用配置。
  2. 区分模型需求与配置需求,在需求采集时,要采集软件在各种应用场景的需求,分析软件可能因推广带来的需求扩展,尽可能分析较多的案例,深入分析提取系统的不变性,抽象出系统的模型,而将系统的易变部分识别为配置需求。

3.2 软件构建

高可配置性软件开发的软件构建分为两个阶段:

  1. 软件主体构建:该阶段由程序员完成,高可配置性对架构设计要求较高,在使用面向对象范式时,需要深刻理解与应用面向对象的设计原则,大量应用各类构件型设计模式,以及反射、依赖注入等运行时关联技术;
  2. 软件配置阶段,该阶段可由程序员完成,也可由领域专家完成。由于配置成为软件的重要组成部分,所以对配置文件应当和对源代码一样严格管理,纳入版本管理系统,由于人工配置容易产生错误,所以应当尽量提供可视化的配置工具,限制用户随意修改配置。

3.3 软件测试

对应软件的两阶段开发,高可配置性软件的测试也分为两个部分:

  1. 软件主体测试,构造测试用例应当设计各种场景的配置,这些场景要考虑模型的边界情况,通过测试尽量消除软件主体的缺陷,减少软件推广应用时在配置后发现缺陷。

  2. 软件配置测试,完成配置测试才能在具体的场景应用,配置过程往往由领域专家而非程序员完成,因此最好对配置进行校验,并提供配置完成后的测试工具与测试方法细则,使得领域专家也能完成软件的测试。

3.4 软件维护

对应软件的两阶段开发,高可配置性软件的维护也分为两个部分:

  1. 软件主体维护,通用的功能升级通过对软件主体的升级完成,软件主体功能的修改应当保持配置的兼容,如果不得不改变配置的格式时,应当提供配置的升级转换功能;

  2. 软件配置维护,用户需求与应用场景发生了变化,而这些变化在软件主体预期之内时,可由领域专家修改配置完成,软件配置逐渐成为用户的重要资产。

如何创建一个对象(C#篇)

在面向对象编程范式的语言中,对象是最重要的一个概念,对象维护自身的状态,通过发消息(调用)来影响其他对象,从而完成整个程序的运转。何时以何种方式创建对象,是面向对象设计中需要重点考量的问题之一。本文对C#语言中创建对象的方法进行简要总结,涉及语言特性、设计模式等,所有的例子以能说明清楚问题为目的,不注重其实用性。相信本文对Java等其他面向对象语言也有相似的价值。

1. new与构造函数

一个C#对象封装了一些字段和一些方法(属性、事件等本质上都是方法),考察一个C#对象的内存图景可以发现,每个具体的对象实际上拥有的是字段,而方法本质上都是全局性的,对象上方法的调用A.b(...)可视为b(A,...),对象的this指针将作为方法的一个参数传入,从这个角度理解就不难发现,创建一个对象本质上需要根据对象的字段划分一块内存,并将这些字段的内容初始化。完成这项工作最直接的途径就是new与构造函数,这也是C#语言提供的构建对象的基本机制。

考虑下面这个简单的例子,Shape类主要抽象了计算面积这一行为,派生类RectangleCircle具体完成这一行为。

public abstract class Shape
{
    public abstract double Area();
}

public class Rectangle:Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public Rectangle(double width, double height)
    {
        Width = width;
        Height = height;
    }
    public override double Area()
    {
        return Width * Height;
    }
}

public class Circle : Shape
{
    public double Radius { get; set; }
    public Circle(double radius)
    {
        Radius = radius;
    }
    public override double Area()
    {
        return System.Math.PI*Radius*Radius;
    }
}

public class Test
{
    public static void Main()
    {
        Shape rectangle = new Rectangle(3, 4);
        Shape circle = new Circle(5);
        System.Console.WriteLine(rectangle.Area());
        System.Console.WriteLine(circle.Area());
    }
}

我们可以通过如下命令编译与查看IL代码

csc Shape.cs
ildasm Shape.exe

Test类中Main函数的IL代码如下:

.method public hidebysig static void  Main() cil managed
{
  .entrypoint
  // 代码大小       65 (0x41)
  .maxstack  2
  .locals init (class Shape V_0,
           class Shape V_1)
  IL_0000:  nop
  IL_0001:  ldc.r8     3.
  IL_000a:  ldc.r8     4.
  IL_0013:  newobj     instance void Rectangle::.ctor(float64,
                                                      float64)
  IL_0018:  stloc.0
  IL_0019:  ldc.r8     5.
  IL_0022:  newobj     instance void Circle::.ctor(float64)
  IL_0027:  stloc.1
  IL_0028:  ldloc.0
  IL_0029:  callvirt   instance float64 Shape::Area()
  IL_002e:  call       void [mscorlib]System.Console::WriteLine(float64)
  IL_0033:  nop
  IL_0034:  ldloc.1
  IL_0035:  callvirt   instance float64 Shape::Area()
  IL_003a:  call       void [mscorlib]System.Console::WriteLine(float64)
  IL_003f:  nop
  IL_0040:  ret
} // end of method Test::Main

在IL代码中,构造函数编译为一个名为.ctor的方法,而new操作有对应newobj操作符,生成一个对象实例后将其加入Main函数的栈,而调用对象方法时的操作callvirt则保证了在运行时根据继承树找到合适的方法。

很容易发现,普通方法Area是运行时确定的,但是构造函数却不得不在编译时确定,或者说,对象的使用是多态的,对象的创建则是具体的,因此,对象的创建,或者说new与构造函数这个机制,成为制约程序变化的瓶颈。

2 new的隔离与封装

基于1中的例子,考虑如下的需求,用户从命令行输入参数信息,第一个参数代表形状的种类,后续参数代表构造这个形状需要的数据,然后由程序计算面积。代码如下:

public class Test
{
    public static void Main(string[] args)
    {
        Shape shape = ShapeFactory.CreateShape(args);
        if(args[0]=="Circle")
        {
          shape = new Circle(double.Parse(args[1]));
        }
        else if(args[0]=="Rectangle")
        {
          shape = new Rectangle(double.Parse(args[1]),double.Parse(args[2]));
        }
        if(shape!=null)
        {
          System.Console.WriteLine(shape.Area());
        }
    }
}

考虑以下两点原因,我们需要进一步封装上述代码中的条件分支语句:

  1. 在应用Shape这个体系的时候,类似的根据不同的字符串来创建不同的子类的代码还可能很多,比如根据配置文件来创建Shape,根据数据库中的数据来创建Shape等,需要消除重复代码;
  2. Shape的种类可能不断增多,需要隔离变化。

封装出的方法CreateShape可以作为Shape类的静态方法,或者作为一个新的工厂类ShapeFactory的静态方法。

public class ShapeFactory
{
  public static Shape CreateShape(string[] args)
  {
    Shape shape = null;
    if(args[0]=="Circle")
    {
      shape = new Circle(double.Parse(args[1]));
    }
    else if(args[0]=="Rectangle")
    {
      shape = new Rectangle(double.Parse(args[1]),double.Parse(args[2]));
    }
    return shape;
  }
}

public class Test
{
    public static void Main(string[] args)
    {
        Shape shape = ShapeFactory.CreateShape(args);
        if(shape!=null)
        {
          System.Console.WriteLine(shape.Area());
        }
    }
}

这一般称为简单工厂,算是一种准设计模式,代码的扩展点位于CreateShape中的条件分支语句。

3 工厂方法和抽象工厂

前述2中的ShapeFactory是一个具体类,如果提取出一个接口IShapeFactory来,二者就构成了一个工厂方法模式,不过这么做没什么意义,工厂方法更多的是体现在平行的类层次上,考虑这样的需求,假设我们在一个编辑环境中编辑这些Shape,双击它们的会弹出不同的设置窗口,即对不同的Shape有不同的Editor,这时候可以在抽象类Shape中加入一个工厂方法CreateEditor,它的具体创建行为延迟到子类中决定。

public abstract class Shape
{
    ...
  	public abstract Editor CreateEditor();
}

public class Rectangle:Shape
{
    public override Editor CreateEditor()
      {
        return new RectangleEditor(this);
      }
}

public class Circle : Shape
{
    public override Editor CreateEditor()
      {
        return new CircleEditor(this);
      }
}

public abstract class Editor{}
public class RectangleEditor : Editor{}
public class CircleEditor : Editor{}

具体的创建行为在子类中,但是它们的关系依然是编译时决定并绑定死的。

抽象工厂可以视为工厂方法的扩展,假如Shape除了构造Editor类体系外,还构建自己的3D形式,那么需要在Shape中再加入一个Create3D方法,返回ThreeDShape,这时Shape类构建了一系列相关的类,成为一个抽象工厂,当然这时Shape类太过复杂,可以考虑把这些创建函数都提取到ShapeFactory中去,这里就不再赘述了。

4 反射工厂

回到1中的ShapeFactory,它的扩展点主要是根据不同的字符串生成不同的类,这是创建对象时最常见的场景之一,这个字符串往往来自于用户配置,如果事先知道有多少种子类,那么这个条件分支已经是程序中唯一的扩展点了,也是可以接受的,但如果事先不知道有多少种子类,比如我们把Shape类作为一个库提供给程序员使用,那么ShapeFactory的位置就会比较尴尬,而.Net的反射机制则能一劳永逸的解除这个尴尬。

根据一个字符串,创建一个同名的对象是很自然的想法,但在C++中,很难实现,因为C++的类编译后不再保有自己的类名信息,而.Net则在运行时一直保有类的详细信息。使用反射改写工厂方法如下:

public class ShapeFactory
{
  public static Shape CreateShape(string[] args)
  {
    System.Type type = System.Type.GetType(args[0]);
    object[] shapeArgs = new object[args.Length - 1];
    for(int i = 0; i < args.Length - 1; i++)
    {
      shapeArgs[i] = double.Parse(args[i+1]);
    }
    return (Shape)System.Activator.CreateInstance(type, shapeArgs);
  }
}

反射是C#和Java这类静态语言获得一定动态性的重要机制,在一些动态语言如Python中,由于Eval机制的存在,字符串和代码基本上是不分家的。

5 序列化与反序列化

简单理解的话,序列化等同于把运行时对象存储为一个字符串,而反序列化则等同于从这个字符串(常使用xml格式)恢复对象。一般可以用工厂方法来实现。比如可以这样设计Shape的反序列化。

public abstract class Shape
{
    ...
  	public static Shape Load(string str)
    {
      ...
    }
  	public abstract string Save();
}

Load方法中,由于要根据字符串来辨别子类,所以离不开反射机制。

.Net提供了比较友好的序列化与反序列化机制,只要我们设计类型时遵循一定的原则,比如需要无参构造函数等,就可以直接使用该机制。比如在Rectangle中加入无参构造函数后,可以把它保存到xml文件再恢复出来。

Shape rectangle = new Rectangle(3, 4);
XmlSerializer mySerializer = new XmlSerializer(typeof(Rectangle));
StreamWriter myWriter = new StreamWriter("rectangle.xml");
mySerializer.Serialize(myWriter, rectangle);
myWriter.Close();
FileStream myFileStream = new FileStream("rectangle.xml", FileMode.Open);
Shape rectangle2 = (Rectangle)mySerializer.Deserialize(myFileStream);

由于序列化和与反序列化机制的成熟,用类似上面的代码可以轻松的实现对象克隆,所以原型模式在C#中比较少使用。

一般的Xml序列化有一个问题,它不能保持对象间的引用关系,微软还将WPF中的Xaml序列化单独拿出来做成了库,所以不想自己实现一个保持引用关系的序列化机制的话,可以考虑System.Xaml 命名空间。

6 依赖注入

关于依赖注入的原理,看懂老马的这篇文章就够了。

依赖注入要解决的是一系列相关对象的创建问题,对象A的组成部分之一B是一个抽象类,那么在创建A的时候就免不了要创建B的实例,而这就免不了具体类的出现,因此需要使用工厂类完成这项工作,这样类A才能真正和B的具体子类解耦。依赖注入通过一个公有的的工厂机制来解决这个问题,只要你的类按照一定的规则设计,比如提供构造函数或者提供属性,用于设定类中包含的抽象类型的具体实例。

依赖注入框架一般支持代码创建对象,也支持xml配置文件创建对象,第一种方式将对象创建代码集中起来,但对象类型仍然是编译时确定的,而第二种方式则借助反射,使用描述式编程来管理对象创建,完全实现运行时确定对象类型。

C#中的依赖注入框架,除了官方的Unity外,还有Autofac,Ninject等。

对象的创建问题,到依赖注入框架,基本上也就到极致了。