查看原文
其他

你的项目该用哪种编程模式?

嵌入式ARM 2021-01-31

The following article is from 嵌入式资讯精选 Author 世纪伯乐

本文授权转载自“嵌入式资讯精选”,作者:世纪伯乐,版权归原作者所有

哎,一个1970年的问题,争论了快50年了,还有那么多引战的。

 

客观一点讲,对于玩过不少语言、大体上几种模式都上过项目的我来讲,几种编程模式的本质问题都是管理问题。 


01

面向过程,本质是“顺序,循环,分支”


拉个小学生过来,十分钟讲明白,速度出项目,速度出东西。简单明了,大家都不乱。但这么耿直的状况,现在基本上已经不可能见到了。任何事物分解,用最简单的面向过程方式分解,都会让后期复杂度提升到一个爆炸的状态。

 

面向过程开发,就像是总有人问你要后续的计划一样,下一步做什么,再下一步做什么。意外、事物中断、突发事件,当今一切事物的复杂度,都不是“顺序循环分支”几句话能说清楚的。

 

来来来,几十件事儿并发,多线程工作,你觉得用“顺序循环分支”描述一个人、一个模块,单线工作还好,稍微大一点儿,如果用这种最简单的描述方式,要么几乎无法使用,缺失细节太多,要么事无巨细,用最简单的描述,描述到量级超出人类的掌控范围。

 

这是个要么写个小工具,要么写个小玩具的代码结构,玩玩就行了,上项目还是算了。有些稳定性要求极高,掌控度要求极高的特殊环境还可能会使用。

 

02

面向对象,本质是“继承,封装,多态”


可以理解为将一切事物模块化,不要问我你想做什么,怎么去做。而是计算手里有什么资源,有多少人,每个人会什么,每个人能做到什么。基于现状,我们能完成什么。至于具体要干嘛,组合放这儿了,事物来了,就按照计划,各自负责各自的。

 

上层回避下层细节,让每一层中间层接触理解的事物控制在很小的范围内。很长一段时间里,大量的项目几乎只有面向对象这一种模式。人脑同时能接收的信息是有限的,同时接收的信息过多,不是忘事儿漏事儿,就是漏洞百出。

 

面向对象的代码结构,有效做到了层层分级、层层封装,每一层只理解需要对接的部分,其他被封装的细节不去考虑,有效控制了小范围内信息量的爆炸。每个模块,每个人做好自己的事儿就行了,自然运转,来事儿了干事儿,没事儿了闲置。然而当项目的复杂度超过一定程度的时候,会演变成一个完全无法管理的失控状态。一个为了简化思维出现的模式,严重增加了整个系统的复杂度。

 

第一个问题:模块间对接的代价远远高于实体业务干活的代价。

 

类似金字塔管理模式,模块数超出一个极限之后,最底层实现业务的部分写不了几十行。但是该模块的上层对接,上层的上层对接,各种框架对接代码作为中间层远远超出业务的执行。到了最后,所有逗逼程序猿都在研究结构怎么设计,框架怎么设计,应该使用哪种抽象,怎么抽象能简化框架,简化结构。

 

中间层、对接层的复杂度远远高于实体业务,已经没有任何一个人,任何一个再牛的架构师知道整个项目的细节,已经没有人能掌握,甚至因为复杂,因为不了解,已经几乎无法改动了。

 

这也是亚马逊的工程师描述的:“我的工作,就是每天进到一大片屎山里,然后进到屎山的中心,去找到底是哪里出了问题,为什么唯独这一块儿这么臭。。。”

 

第二个问题:代码膨胀让原本写不了两行的实体代码膨胀到数十倍。

 

原本是有什么事儿,完成什么事儿。因为面向对象概念的层级划分,要实现的业务需要封装,封装好跟父类对接。多继承是万恶之源,让整个系统结构变成了网状、环,最后变成一坨乱麻。

 

而单继承,原本直接找业务代码实现的事情,需要层层对接,层层调度。每一层对接代码,都是要人写的。虽然基本都是没有任何技术难度的纯敲,但是代码量的提升,无论再简单的业务,再简单的结构,代码量庞大本身就已经是一个重大的隐患性bug了。毕竟出问题了,读下来、找问题也是要浪费大量时间的。

 

层级划分的代码量提升同时也造成了第三个问题。


第三个问题:过多的间接过程才能访问到实体业务,严重影响性能,速度极慢。

 

要知道,《UNIX编程艺术》,第一原则就是KISS原则,整本书都贯彻了KISS原则。(keep it simple, stupid!)

 

写项目、写代码,目的都是为了解决问题,写出解决问题的产品。但是庞大的面向对象模型,让你花费或者说浪费了过多的时间在考虑与要解决的问题完全无关,而且非常复杂的管理调度问题上。但是,面向对象模式虽然是个最容易出屎山的模式,但是在很大程度上,无论任何一种架构的引入,我们现代编程,项目里对接使用最多的,仍然是面向对象的工作模式。


大家都很痛苦,大家都知道问题在哪儿,大家都在骂面向对象,但毕竟没有更好的解决方案,不是么?

 

03

函数式编程本质是“函数映射”,通俗一点讲叫规则制定

 

这个函数不是面向过程编程的函数,这个函数是数学概念里的函数。定义是这样的:两个非空集合A与B存在着对应关系f,而且对于A中的每一个元素x,B中总有唯一的一个元素y与它对应,就这种对应为从A到B的映射,记作函数f(A)。。。

 

这个东西理解起来比较麻烦,通俗易懂一点讲,在我们尝试设计了无数的语言模型,试图用编程模式来描述事物的运转,描述要做的复杂业务的执行模式,甚至描述这个世界本身的时候,从来都没有人真正解决过,最后都倒在了人脑跟不上这一点。事物变得越庞大复杂,对事物复杂度的描述总是会越来越失控。

 

终于,1956年开始出现质疑,1960年lisp出现,1970年lisp混合使用函数式模型,在当时还只是理念。John Backus在他1977年的图灵奖颁奖演讲中正式提出函数式编程概念。而函数式编程所做的事情很简单,就是放弃这些愚昧无知的人类创造的语言和概念,去使用“上帝”创世的时候一直在使用的语言“数学”。这个世界的复杂度远远高于任何一个计算机软件工程。开始有人试图理解并模仿世界最初的构造,去写代码,用这种方式构建项目。

 

抽象的东西不说那么多,说白了,模仿的方式就是用“数学”描述“规则制定”。有人会感觉,计算机编程不也是在用数学写吗,跟函数式编程非要单独提出来的“数学”区别在哪儿?函数描述和语句描述最直观的界限,我这里举一个简单而又熟悉的例子,就清楚了。

 

从前写数学题的时候最经典的一个写法。设x1=3,x2=9,y=x1+x2,得出y=12。如果类比到编程语言里,我们会感觉这种写法很冗余,会变成let x1 = 3; return x1 = x1 + 9;这事儿看习惯代码了觉得没毛病,还原回数学模型,细思极恐。x1=3;x1 = x1 + 9???3= 3 + 9???计算机概念最初就引入了一个反数学模型的概念:变量。而变量的存在,是可以保存数据的。这个数据保存已经不是纯粹的数学描述了。

 

函数式模型描述的是事物是怎么运行的,而不是事物运行本身。所谓怎么运行的,就像是写一个数学公式,传入参数,传出参数。其本体是这个函数,而不是传入的什么参数。而运行本身,最大的区别,在常规编程概念里叫“变量”,函数式编程里叫“副作用”。也就是是否将传入的数据本身作为函数。

 

在常规编程模型里,变量是函数的一部分,但是在数学概念里,数学公式本身就是全部,不包含使用数学公式传入的参数。这些是更本质的函数式编程的描述,单看这一部分,比前面的几个模型概念已经复杂很多了。这些本身只是表象,后面说具体怎么解决问题的,舍弃变量的好处和代价又是什么呢?

 

计算机程序模块因为有变量这个概念,模块切分之后往往并不总是正确的。某些初始化没执行,后面的模块自己跑,因为数据没构造、没有数据结构等等问题,单独截出来的部分直接是错误的。

 

而数学公式,无论从任何一个位置截取出来,公式的任何一个子公式都始终保持永远是正确的。子公式带入更复杂的公式,最终得到更庞大的复杂公式。这些过程无论是组合还是截取,永远都保持着公式的正确性。

 

那么我们如果不出现这种类似,设x1 = 3;x1 = x1 + 9这种变量式代码。保持高中写题的模式,设x1=3;x2=x1+9;将第一个公式带入第二个公式,x2=3+9这种思维模式,得到x2=12。所有代码模块,始终保持传入参数,参数按照公式运行得出结果的这唯一的lambda表达式模型。

 

整个程序就变成了无数的,绝对正确的函数(公式),以及函数之间的互相带入。系统的运转就是传入参数,得到结果。不存在变量状态、变量管理等等无数的问题。相当于永远设定各种各样的规则,永远不出现某规则里在什么具体的情况应该怎么怎么,而是让该规则保持正确,剩余的一切事物只需要在该规则下自然运转,不需要所谓的中间层管理层去做任何的程序运转的干涉。

 

采用这种代码设计模式,第一个特点,就是脑壳疼。最经典的一个例子,看下面这段代码,函数返回从x加到y,x<y这个代码。


function test (x, y) { let res = 0; for(let i = x; i <= y; i++) { res=res+i; } return res;}


这里已经违反了函数式编程模型,变量res是个副作用参数,i是个副作用参数。开头设res=0了,res=res+x这数学模型已经被颠覆了。如果从中间截取任何一个流程,已经是错的了。不引入变量,常规编程模型里,循环这个概念已经不可能实现了。我们的做法是:


function test_run (x, y) { return test2 (x, y, x);}function test2 (x, y, res) { if(y ===x) { return x; }else { console.log(x, y-1, res+y); return test2 (x, y - 1, res+y); }}test_run (1, 100);


这个函数,除了x和y,没有任何变量因素存在。这就是所谓“规则制定”最应该存在的状态。无论在什么状态下,无论在整个系统运行的哪一个步骤被截取出来,这个公式都是永远正确的,所谓模仿数学模型的函数式模型。

 

上面这个已经不是循环了,毕竟循环这个概念存在最根本的基石就是变量。变量作为计数,才能从x加到y。这是个尾递归函数。

 

注意要认清楚,尾递归和递归本质上是两个东西,是可以替代迭代循环的,而递归则不行。内存膨胀太严重,虽然尾递归看起来很像递归。

 

采用函数式编程极大减少了系统的复杂度,而且减少了运维成本。因为整个系统里没有所谓的运行期,也就没有运行期错误。中间层,对阶层,管理模块全部都可以删除了。只要按照设定好的规则,只要不超出规则,就不需要太多管理。公式规则的设定不应该涉及实际运行过程中的细节变化问题,不应该出现在什么情况下要怎么样,什么情况下怎么怎么的写法。有且仅只有输入和输出以及公式的运转逻辑,可以出现参数,但不允许出现副作用参数。只能以类似设x2=x1+9;x3=x1*x2这种写法。

 

仅制定合理有效正确的规范,而不纠结规则规范下的具体运行。规则本身已经是最好的管理了。

 

函数式编程的第二个特点是:只要确保函数正确、函数运行正确、函数组合的复杂函数正确,你不需要关注函数在干什么,你只需要知道函数传入什么,传出什么,而且可以用任何多线程、多调用之类的方式造任何模式的函数。因为本身不存在变量概念,也就不存在多线程编程里最恶心的临界区问题了。

 

函数式编程的第三个特点是:快。没有中间商赚差价,没有中间层瞎折腾,资源可以完全利用在该利用的地方,运行快。

 

函数式编程的第四个特点是:快。这个快,是写代码快。我们写项目,其实根本就不是在写业务。业务代码两三天敲完了,然后结构设计,框架设计,对接设计,数据管理,内存管理,调度管理,资源管理。这些都还好说,运行起来出bug,10%时间写bug,90%时间调bug。(你是我们公司来专职写bug的么?)

 

而函数式编程任何一个无副作用参数的模块取出来,都是与数据本身无关的公式。只要你写的最小的那个模块没错,只要你没写错逻辑,就不会出错。

 

经常有说法,一个项目开发,C++和java要写一年,C语言要写5年,而lisp可能只需要三个月。这个对比有点夸大的成分,但单从代码量上讲,这个量级也差不多。

 

说着说着是不是感觉函数式编程就是未来,就是以后的趋势了?是趋势没错,肯定该学的都还是要学的。我要开始打脸了。

 

如果真的函数式编程这么好用,效果也这么好,真要是没点儿致命的缺点,为什么50多年了,都还没有那么普及,现在普及的还是面向对象呢。

 

缺点一:门槛太高,也就意味着你招不到人。


面向对象模式讲究的是群体开发,每个人只需要关注你所在位置,你的眼前需要关注的问题,用封装屏蔽底层运行细节的方式,简化一次性接收的信息量。在写好架构的情况下,分发架构文档,多人同时开发。你优秀,把你水准拉低,封装到代码层内。你水平低,过不了TDD测试,只要能过,也出不了什么大问题,这是一种可以量产,可以找大量廉价劳动力堆出来一个能用的屎山的工作模式。同时不需要能力过高的人,不需要会很多,覆盖面很广的人。


有面向对象架构师一个人把握全局就可以了,其他人只用看着文档、无脑堆代码、过测试就行了。重复工作量几百倍几千倍的堆,跑不起来,浪费资源,那就拿钱砸。

 

要知道,拿钱砸硬件,是看得见的,而且是诚实的。一倍的钱,砸一倍的性能,不需要写的太好。而人存在着无数不确定性,用钱很难直观体现出来能力大小,增加算法复杂度,让水平低的看不懂,导致各种问题,还不如用钱砸硬件。

 

水平拉低的另一个好处,招人好招,都可以互相替代,难度都不大。找个能加班的,比找个能力强的有用。

 

面向对象开发模式是非常适合商业运作的开发模式。而函数式编程的门槛,我就两个字,“数学?”,呵呵。。。


function test_run (x, y) { return test2 (x, y, x);}function test2 (x, y, res) { if(y === x) { return x; }else { console.log(x, y-1, res+y); return test2 (x, y - 1, res+y); }}test_run (1, 100);


就上面这个东西,一个简单的从1加到100,能看出来这是个循环的,估计都已经不好招了。

 

缺点二:慢。。。


上面说快,这边开始打脸。我说的慢,真的就是运行起来,运行资源占用导致的慢。举个例子,现在神经网络算法非常火,有几个真的知道神经网络算法是什么的?这个名词其实根本没那么玄幻,简单一点说,神经网络算法的本质就是多项式分解。

 

最常见的多项式分解,f(x)=an·x^n+an-1·x^(n-1)+…+a2·x^2+a1·x+a0,这个东西还有三角多项式分解什么的,都学过没什么好介绍的。

 

多项式分解就是使用n次幂的多项式,可以描述任何一个函数,可以将任何一个函数分解为无限次幂的多项式函数。神经网络就是能用神网络加权算法描述任何一个函数,可以将任何一个函数分解为无限级神经网络加权的多项式函数。但是为什么不用初中还是高中就学过的多项式分解,一定要引入一个这么复杂的神经网络,还这么火呢?

 

说白了,计算机说起来很强大,我们好像都觉得CPU很强大。学计算机的都知道,计算机CPU这个寄存器设计能做的事情很有限,根本就不强大。多项式分解这种东西,你教一个初中生算,现在大体上都会,貌似是不是小学都开始教这个了?但是你让计算机算多项式分解,用了无数的套路才抽象出一个近似的多级的幂运算,或者多级的sin,cos运算。

 

神经网络算法,每一个神经元节点的加权运算,看起来输入输出权值运算比多项式分解复杂多了,但实际上每个神经元的权值运算,计算机是算的动的,而幂运算,计算机代价无法估量了。完全以数学思维写代码,带入的某些数学概念,在计算机上运行时,因为不擅长,会多出来大量额外的工作,而且可能是近似,永远都算不对。

 

毕竟某种意义上来说,计算机概念使用的“浮点数”这个概念,已经是算个近似,注定永远都不可能像数学一样准确运算了。而如果用其他代码回避浮点数概念,增加其他机制,复杂度和资源占用又要开始爆表了。再说2^32,2^64这几个寄存器限制,大数运算,仅仅是加减法,可能都已经是一个很复杂的运算了。这些问题如果放在真正的数学运算里原本是不需要考虑的。

 

再举个简单的例子,计算机根本就不会算除法。

 


我们当今世界的网络安全基础,就是RSA算法,谁都解不开。肯定不是真的解不开,只是因为计算机不会算除法,两个大质数相乘的乘积,除开这件事情,是一个世界难题。有些事情其实是细思极恐,你会算除法么?除法是什么?你会的除法运算是不是只是背了个乘法表,然后反过来背着乘法表,撞除法运算。

 

如果真的拿数学理念上,让计算机这种低级CPU的一个字节8位去算,来上几次方,几个大数,几十行代码,能给你把超级服务器算崩溃。但凡你觉得很简单的概念上到计算机上,算法的膨胀,资源的消耗很可能在你没注意的,莫名其妙的梗上卡死。

 

缺点三:慢。。。


这个慢,对应上面的开发效率慢、写代码慢。

 

说白了,本质上还是门槛太高。你招一批水平一般的面向对象程序员,设计好结构,呼呼啦啦都开始敲,敲了几千上万行代码,事儿搞定了。从代码量上说很庞大,解决一个很小的事儿,效率很低。实际上呢?你找了个函数式开发的高手,蹲在哪儿开始思考,写写删删,测测改改。几十行,百十来行写完项目。这个开发效率高?而且相对还很有意思,动脑程度很高?

 

如果他没考虑清楚呢?如果这哥们儿家里有事儿呢?如果这哥们儿生病了呢?如果这哥们儿技术其实没那么屌,脑子都快想炸了,憋不出来呢?

 

如果团队里水平都很高,都有很深厚的底子。项目开发效率会高,但真实情况是,这种条件你根本找不来。而哪怕你找来了,也没什么用,这就是第四个缺点了。

 

缺点四:lisp诅咒(The Lisp Curse)


关于这个名词,有很多文章都有讲,也是几十年来无数引战帖讨论过的问题。这个又是个描述起来比较麻烦的概念,同时也是我确信,哪怕以后程序员水准普遍提高,函数式编程再优秀,都永远都是小众,而且不建议铺开了上项目使用的最重要的原因。

 

对于lisp诅咒最准确的描述,也是大众最容易理解的描述,是这样的。Lisp是如此强大,以至于在其他编程语言中的技术问题,到了Lisp中变成了社会问题。

 

是的,你没有看错,不是技术问题,是社会问题。社会问题跟技术问题最大的区分点,是“人”。技术没问题,技术很强大,但是强大的技术模式,必然会导致人的分裂,社区的分裂,以至于函数式开发模型最后一定会死在技术无关的“管理问题”上。其中lisp诅咒导致的最严重的问题就是,“孤狼式开发”。

 

我举个例子:面向对象只是一种设计思路,是一种概念,并没有说什么C++是面向对象的语言,java是面向对象的语言。

 

C语言一样可以是面向对象的语言,Linux内核就是面向对象的原生GNU C89编写的,但是为了支持面向对象的开发模式,Linux内核编写了大量概念维护modules,维护struct的函数指针,内核驱动装载等等机制。而C++和java为了增加面向对象的写法,直接给编译器加了一堆语法糖。

 

而lisp、Haskell,在纯粹的函数式编程模型下,写出来一整套支持面向对象的概念和框架,需要多少代码呢?几百行。。。

 

这种开发优势最终导致了社区里有无数套随手写的、低质量的、无人维护的、bug无数的垃圾面向对象框架。也正因为如此,你只能自己写一套面向对象的框架,只要你理论知识扎实,估计写不了几百行,也就是一天两天的事儿。

 

每个人都在使用自己写的面向对象,每一个人都在使用自己写的库,而且上下是断层的。层次高的,写几套自己项目用的依赖没什么难度,却没有别人用。时间久了,很容易消失。项目交接的时候,大量无人维护的,个人开发的依赖,代码量很小,功能很强大,但是难度太大。项目交接几乎无法进行,除非来一个大牛接手,但同样的,如果不是这种方式,一旦技术断层,项目再也没人能看得懂了,最好的办法不是撞大运的招人,而是把看不懂,又不得不改的部分删了重写。

 

最终导致,逻辑过于复杂的函数式编程,总是会出现技术人员断层,毕竟能力参差不齐,一次性技术门槛提的过高了。还能维护的项目,几乎都是一个人写出来维护的项目,哪怕第二个人的存在,可能都只是暂时的。“孤狼式开发”,这个名字起的是非常到位的。

 

最后,总结一下,也是软件项目管理经典教材《人月神话》上讲的,“没有银弹”。

 

在什么情况使用什么样的模式。不要太自信,不要太相信自己习惯使用的开发模式和框架。你只会一种模式,怎么对比?你只会一种语言,怎么对比?


别争论了,让他们最后各归各位

 

在驱动开发、嵌入式底层开发这些地方,面向过程开发模式,干净,利索,直观,资源掌控度高。在这些环境,面向过程开发几乎是无可替代的。

 

在工作量大,难度较低、细节过多、用简单的规范规则无法面面俱到的环境下,用面向对象开发模式,用低质量人力砸出来产业化项目。

 

在复杂度过高、层级过高的架构顶层、面向对象模式增加的对接复杂度已经开始接近失控的环境下,把上层的中间层代码都删了,让少数精英使用制定规范的函数式模型,函数式编程的引入则能大大简化项目复杂度和开发成本。


要学的东西还很多,学无止境吧~


本文授权转载自“嵌入式资讯精选”,公众号见下方二维码

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存