编程范式:函数式编程&防御式编程&响应式编程&契约式编程&流式编程

时间:2022-11-05 07:11:26


不长的编码生涯,看到无数概念和词汇:面向对象编程、过程式编程、指令式编程、函数式编程、防御式编程、流式编程、响应式编程、契约式编程、进攻式编程、声明式编程……有种生无可恋的感觉。
本文试图加以汇总和整理,搞清除某个概念所指,并大致加以区分。虽然,严格区分不同概念/名词之间的区别,对于指导我们编程的实战意义不是特别大。
注:资源源自于互联网,仅有部分加上参考来源。

编程范式

所谓编程范式(programming paradigm),指的是计算机编程的基本风格或典范模式。
借用哲学的术语,如果说每个编程者都在创造虚拟世界,那么编程范式就是他们置身其中自觉不自觉采用的世界观和方法论。编程是为了解决问题,而解决问题可以有多种视角和思路,其中普适且行之有效的模式被归结为范式。
由于着眼点和思维方式的不同,相应的范式自然各有侧重和倾向,即 oriented-面向,导向。换言之,每种范式都引导人们带着某种的倾向去分析问题、解决问题。
如果把一门编程语言比作兵器,它的语法、工具和技巧等是招法,它采用的编程范式则是心法。编程范式是抽象的,必须通过具体的编程语言来体现。它代表的世界观往往体现在语言的核心概念中,代表的方法论往往体现在语言的表达机制中。一种范式可以在不同的语言中实现,一种语言也可以同时支持多种范式。

比如,PHP可以面向过程编程,也可以面向对象编程。任何语言在设计时都会倾向某些范式,同时回避某些范式,由此形成不同的语法特征和语言风格。抽象的编程范式须要通过具体的编程语言来体现。范式的世界观体现在语言的核心概念之中,范式的方法论体现在语言的表达机制中。一种语言的语法和风格与其所支持的编程范式密切相关。

过程式编程

命令式编程

声明式编程

命令式编程:面向CPU编程、面向算法编程;

声明式编程:面向解释器编程、面向结构编程。

非命令式的编程都可归为声明式编程,命令式、函数式和逻辑式是最核心的三种范式。

编程范式:函数式编程&防御式编程&响应式编程&契约式编程&流式编程


命令式编程和声明式编程起源的不同决定这两大类范式代表着迥然不同的编程理念和风格:命令式编程是行动导向( Action-Oriented )的,因而算法是显性而目标是隐性的;声明式编程是目标驱动( Goal-Driven )的,因而目标是显性而算法是隐性的。

参考

​从软件工程角度看大前端技术栈​

面向对象编程

这个应该没有什么好说的,OOP。

函数式编程

一种编程范式,与面向对象编程(OOP)和过程式编程(Procedural programming)并列。函数式编程也遵从 数据结构+算法 的约束。
函数式编程关心类型(代数结构)之间的关系,关心的是结构、数据的映射或者变换;
函数式编程中的lambda可以看成是两个类型之间的关系,一个输入类型和一个输出类型。lambda演算就是给lambda表达式一个输入类型的值,则可以得到一个输出类型的值,这是一个计算,计算过程满足 -等价和 -规约。

函数式编程的思维就是如何将这个关系组合起来,用数学的构造主义将其构造出你设计的程序。
函数式编程即为:构造类型的映射关系。
函数式编程,设计函数的复合与代数数据结构,提供对并发计算的支持,这是其他编程范式难以实现的。

用Haskell来说,这个关系就是运算符(->),其表示一个lambda演算的类型,在值的层面和符号’'一起构造一个lambda表达式。空类型()、积类型(a, b)与和类型Either a b是最基本的数据类型的构造,其和curry和uncurry一起,还有米田定理、伴随函子,使得可以构造任意复杂的数据类型和程序。比如Functor、Applicative、Monad/Comonad、Limit/Colimit、End/Coend、Left Kan Extenstion/Right Kan extension等。

命令式编程关心解决问题的步骤;

在函数式中,函数是一等公民,函数能作为变量的值,函数可以是另一个函数的参数,函数可以返回另一个函数等。

函数式编程关注的是:describe what to do, rather than how to do it。故而,过程式编程范式又叫 Imperative Programming – 指令式编程,函数式编程范式叫做 Declarative Programming – 声明式编程

纯函数三个重要的特点:

  1. 函数的结果只受函数参数影响
  2. 函数内部不使用能被外部函数影响的变量
  3. 函数的结果不影响外部变量

特点

stateless:函数不维护任何状态。函数式编程的核心精神是 stateless,简而言之就是它不能存在状态,你给我数据我处理完扔出来,里面的数据是不变的。
immutable:输入数据是不能动的,动了输入数据就有危险,所以要返回新的数据集。

优势

  • 没有状态就没有伤害
  • 并行执行无伤害
  • Copy-Paste 重构代码无伤害
  • 函数的执行没有顺序上的问题
  • 惰性求值。这需要编译器的支持。表达式不在它被绑定到变量之后就立即求值,而是在该值被取用的时候求值。也就是说,语句如 x:=expression; (把一个表达式的结果赋值给一个变量) 显式地调用这个表达式被计算并把结果放置到 x 中,但是先不管实际在 x 中的是什么,直到通过后面的表达式中到 x 的引用而有了对它的值的需求的时候,而后面表达式自身的求值也可以被延迟,最终为了生成让外界看到的某个符号而计算这个快速增长的依赖树。
  • 确定性。所谓确定性,就是像在数学中那样,f(x) = y 这个函数无论在什么场景下,都会得到同样的结果,这个我们称之为函数的确定性。而不是像程序中的很多函数那样,同一个参数,却会在不同的场景下计算出不同的结果。所谓不同的场景,就是我们的函数会根据运行中的状态信息的不同而发生变化。

技术

函数式编程常用技术:

  • first class function(头等函数) :这个技术可以让你的函数就像变量一样来使用。也就是说,你的函数可以像变量一样被创建、修改,并当成变量一样传递、返回,或是在函数中嵌套函数。
  • tail recursion optimization(尾递归优化) : 递归的问题:递归很深时,会出现*异常;即使没有达到抛出异常的条件,Stack过深时,也会导致性能大幅度下降。因此,使用尾递归优化技术——每次递归时都会重用 stack,这样能够提升性能。当然,这需要语言或编译器的支持。Python不支持。
  • map & reduce :函数式编程最常见的技术就是对一个集合做 Map 和 Reduce 操作。这比起过程式的语言来说,在代码上要更容易阅读。(传统过程式的语言需要使用 for/while 循环,然后在各种变量中把数据倒过来倒过去的)这个很像 C++ STL 中 foreach、find_if、count_if 等函数的玩法。
  • pipeline(管道):这个技术的意思是,将函数实例成一个一个的 action,然后将一组 action 放到一个数组或是列表中,再把数据传给这个 action list,数据就像一个 pipeline 一样顺序地被各个函数所操作,最终得到我们想要的结果。
  • recursing(递归) :递归最大的好处就简化代码,它可以把一个复杂的问题用很简单的代码描述出来。注意:递归的精髓是描述问题,而这正是函数式编程的精髓。
  • currying(柯里化) :将一个函数的多个参数分解成多个函数, 然后将函数多层封装起来,每层函数都返回一个函数去接收下一个参数,这可以简化函数的多个参数。在 C++ 中,这很像 STL 中的 bind1st 或是 bind2nd。
  • higher order function(高阶函数):所谓高阶函数就是函数当参数,把传入的函数做一个封装,然后返回这个封装函数。现象上就是函数传进传出,就像面向对象对象满天飞一样。这个技术用来做 Decorator 很不错。

注:菜逼我水平有限,还不能理解函数式编程和范畴论有什么关系;但是总是在讲述函数式编程的文章中看到范畴论



防御式编程

参考《代码大全》第二版—第八章,防御式编程。
防御式编程:对代码的健壮性极其重视,以测试驱动编程,思考可能发生的问题提前进行预防。以异常处理为例,简单粗暴的try…catch加Exception把所有异常都包起来,简单省事。这种方式最主要的问题在于:
Exception会吃掉所有可以处理的异常,使得对于某些特定异常无法捕获,因为对于不同的异常可能需要做不同的处理,有些可以在本函数内处理掉,有些需要提示用户(例如文件不存在,网络无法访问),有些需要告诉上一层代码该如何处理,所有这些在直接用Exception处理异常时都无法做到,简而言之就是无法做到异常的精细化处理;

实现技术

怎么做才好呢?具体如何处理这些没有完全标准的答案,软件设计本来就没有一劳永逸的解决方案,最关键的在于掌握好基础知识,因地制宜地采取措施。基本的实现软件健壮性的技术有以下几种:

  1. 断言
  2. 错误处理
  3. 异常
  4. 从设计上简化异常处理的技术;隔离程序
  5. 辅助调试的代码(print打印之类的小段函数)

断言

断言,是一种在开发阶段使用的,让程序在运行时进行自检的代码,断言为真,那么程序运行正常,断言为假,那么程序运行异常退出。实际上,先断言、后处理错误,而断言是在开发环境中的,正式上线后是不会有断言的。断言是给程序员看的,是用来查找bug。
所以应该在内部逻辑的问题上使用断言去检查一些理论上不可能发生的情况,因为如果发生就说明内部逻辑有问题,也就是有bug。
断言:判断一个布尔表达式的语句,如果这个布尔表达式为真,不会有任何效果,但是如果为假,根据不同实现技术会出现不同的效果。以Java语言为例,Assert就是断言最好的例子。
举个简单的例子,例如某个函数有一个参数,这个参数是某数据流,这个数据流是软件下层通过读取文件传进来的,调用这个函数的时候,内部逻辑已经确定是正确读取到了文件,否则是不会调用这个函数的,那么,一般会在函数开头,对这个参数用断言加以检查,如果不幸,出现问题,就说明内部逻辑错了(读取失败仍然调用?内存被意外析构?),这就是典型的通过断言查找程序bug的例子。

断言是用来检测程序内部逻辑的,如果是和外部有数据交流,就不是断言的范畴,因为外部的情况,程序是不能假定的,既然不能假定,就无法设断言,那应该是错误处理或者异常的范畴。因此,理解断言的关键点在于,作用于内部逻辑,用来查找bug。通常,现代编译工具都会在编译release版本软件的时候去掉异常,因为异常是给程序员看的。

错误处理

错误处理可以说是软件健壮性的核心,程序员在编写软件的时候,应该尽可能的预测到可能发生的错误,并对这些错误进行处理,正常情况下要对这些错误进行分类,重大错误,这类错误一般不可恢复,通常的做法都是报告后直接退出,类似windows中的蓝屏,普通程序在遇到堆栈溢出,内存不足等错误时也是会这样做;

无关用户的一般性错误,这类错误一般情况下不会导致程序退出,而且和用户没有直接的联系,这时最好的做法是能自动恢复并解决,如果不行,可以写入日志,以便以后进行排查,不过通常情况下需要用相对抽象的语言告诉用户(例如,程序遇到问题,可能是某些文件找不到),只是为了让用户知道这个操作没有成功,具体的技术原因可以写入日志。
与用户相关的一般性错误,这类错误通常是由于用户输入错误数据引起,例如本来程序UI需要用户输入年龄,结果用户输错,填入的不是整数。这个时候,通常需要告诉用户,让用户重新输入,以达到自动恢复的作用。所以通常的做法,都是弹出对话框(有UI)或者输出提示到标准输出(无UI);
理解错误处理的关键在于分清楚项目需要处理错误的类型,以及如何处理(集中处理?写入日志吗?通过网络提交错误报告?),要根据项目的类型设计好采取的策略(例如Service一类的通常都是只记入日志(会有各种日志,函数调用日志,错误日志,性能日志等等),因为不直接和用户打交道),具体情况具体分析地设计错误处理策略,并对不同的错误采取恰当的处理方式。

异常

异常是指程序无法预料到的情况引发的错误,通常本函数不知道这种错误该如何处理需要让调用方决定(例如系统库函数,像.NET的库函数都会有抛出异常的列表)。这通常是由语言支持的,在遇到异常而又没有捕获时,会中断本函数的执行去查看调用方是否处理,这就有了一种直接中断函数处理的方式,有人会说为什么不直接return呢?是的,return可以达到中断函数执行,但是却无法像异常那样让调用方针对特定的异常做出特定处理,毕竟return的东西有限,无法表示错误的类型,通常都只能返回一个false。
以.NET的CLR对异常处理机制(两轮遍历)为例:

发生异常后,CLR先去在引发异常的那一层搜索catch语句,看看有没有兼容此类型异常的处理代码,如果没有,则跳到上一层去搜索,如果还没有,则再上一层,直到应用程序的最顶层,此即为第一轮,查找合适的异常处理程序。
如果在某一层找到了异常处理处理程序,CLR不会马上执行,而是回到事故现场再次进行第二轮遍历,执行所有中间层次的finally语句块。

可见,异常的出现使得我们对于无法在本函数(局部)处理的错误提供了一种强大的手段,使得我们能够清楚的告诉函数调用链的上层,某函数发生错误了,需要处理。所以,理解异常,就要知道它是处理无法在本函数处理的错误,同时,一般情况下不要用Exception吃掉所有的异常,而要对异常进行精细化处理。但是也不是完全不用它,因为没有处理的异常通常会导致程序直接崩溃,这对用户非常不友好,所以处理异常要特别谨慎,我通常会在函数调用链的顶层使用Exception,并计入日志,以防止这一情况的发生。

隔离程序以简化错误处理

这是一种在设计上简化错误处理的策略,事实上,如果所有的代码都做异常和错误处理,会使代码变得臃肿,可读性下降,需要在高层次上面避免这种情况的发生;这个思想来自代码大全。本质上,它是将错误和异常处理集中化,通常的软件设计实际上都是对数据进行处理和再加工,以及展现,很大一部分的错误都是由于不正确的数据设置导致的,那么我们可以把数据的错误处理专门用一层来处理以使得内部的逻辑可以不用对数据进行检测,见下图:

编程范式:函数式编程&防御式编程&响应式编程&契约式编程&流式编程


专门增加一层来专门处理数据,以解放内部逻辑,这样结构更加清晰。

响应式编程

响应式编程是针对异步和事件驱动的非阻塞应用程序,并且需要少量线程来垂直缩放(即在 JVM 内)而不是水平(即通过集群)。
响应式应用的一个关键方面是“背压(backpressure)”的概念,这是确保生产者不会压倒消费者的机制。例如,当HTTP连接太慢时,从数据库延伸到HTTP响应的响应组件的流水线、数据存储库也可以减慢或停止,直到网络容量释放。
响应式编程也导致从命令式到声明异步组合逻辑的重大转变。与使用Java 8的 CompletableFuture 编写*代码相比,可以通过 lambda
表达式编写后续操作。

契约式编程

目前不是很流行,很是生僻的一个概念。Design by Contract,简称DbC。是Bertrand Meyer在 Eiffel 语言中提出的一个设计风格,契约式设计就是按照某种规定对一些数据等做出约定,如果超出约定,程序将不再运行。契约由先验条件、后验条件、错误和不变量等概念组成。契约的概念很简单——它只是必须为真的表达式。例如要求输入的参数必须满足某种条件,scala中有require和assume,java中有assert,以及Guava的Preconditions等。在Test-Driven Development (TDD)用得比较多。

DbC的价值:

  • 先验条件补充参数的要求,并过滤非法参数,有文档作用
  • 验证条件可以减少出错的几率,先验条件挡掉参数错误
  • 减少难以解释的错误的几率,基本排除不可预知的参数和组合错误

这个不能取代单元测试,反之亦然。

在语言内部支持契约的目的是:

  • 给契约一个一致的观感
  • 提供工具支持
  • 使编译器能够根据从契约中收集的信息生成更好的代码
  • 易于管理并强制实行契约
  • 处理契约继承

缺点:

A drawback of using these methods and Ensuring is that you can’t disable these checks in production.

也就是在生产中无法*地把这些契约disable,真要引入这种风格到你的code时,得编写一个模块来随时关闭这种功能。

参考

​函数式编程入门教程​​​​如何理解函数式编程?​​防御式编程