编程范式与函数式编程
一、编程范式的分类
常见的编程范式有:函数式编程、程序编程、面向对象编程、指令式编程等。在面向对象编程的世界,程序是一系列相互作用(方法)的对象(Class Instances),而在函数式编程的世界,程序会是一个无状态的函数组合序列。不同的编程语言也会提倡不同的“编程范型”。一些语言是专门为某个特定的范型设计的,如Smalltalk和Java支持面向对象编程。而Haskell和Scheme则支持函数式编程。现代编程语言的发展趋势是支持多种范型,如 C#、Java 8+、Kotlin、 Scala、ES6+ 等等。
与成百种编程语言相比,编程范式要少得多。多数范式之间仅相差一个或几个概念,比如函数编程范式,在加入了状态(state)之后就变成了面向对象编程范式。
1.命令式编程(Imperative programming)
计算机的硬件负责运行使用命令式的风格来写的机器码。计算机硬件的工作方式基本上都是命令式的。大部分的编程语言都是基于命令式的。高级语言通常都支持四种基本的语句:
(1)运算语句
一般来说都表现了在存储器内的数据进行运算的行为,然后将结果存入存储器中以便日后使用。高阶命令式编程语言更能处理复杂的表达式,产生四则运算和函数计算的结合。
(2)循环语句
容许一些语句反复运行数次。循环可依据一个默认的数目来决定运行这些语句的次数;或反复运行它们,直至某些条件改变。
(3)条件分支
容许仅当某些条件成立时才运行某个区块。否则,这个区块中的语句会略去,然后按区块后的语句继续运行。
(4)无条件分支
容许运行顺序转移到程序的其他部分之中。包括跳跃(在很多语言中称为Goto)、副程序和Procedure等。
循环、条件分支和无条件分支都是控制流程。
早期的命令式编程语言,例如汇编,都是机器指令。虽然硬件的运行更容易,却阻碍了复杂程序的设计。
2.面向对象编程(元编程)(Object-oriented programming,OOP)
怎样为一个模糊不清的问题找到一个最恰当的描述(问题描述)? 抽象(Abstraction)通常是我们用来简化复杂的现实问题的方法。在面向对象程序编程里,计算机程序会被设计成彼此相关的对象。对象则指的是类的实例。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及经常修改对象相关连的数据。对象包含数据(字段、属性)与方法。
面向对象程序设计可以看作一种在程序中包含各种独立而又互相调用的对象的思想,这与传统的思想刚好相反:传统的程序设计主张将程序看作一系列函数的集合,或者直接就是一系列对计算机下达的指令。面向对象程序设计中的每一个对象都应该能够接受数据、处理数据并将数据传达给其它对象,因此它们都可以被看作一个小型的“机器”,即对象。目前已经被证实的是,面向对象程序设计推广了程序的灵活性和可维护性,并且在大型项目设计中广为应用。此外,支持者声称面向对象程序设计要比以往的做法更加便于学习,因为它能够让人们更简单地设计并维护程序,使得程序更加便于分析、设计、理解。反对者在某些领域对此予以否认。
当我们提到面向对象的时候,它不仅指一种程序设计方法。它更多意义上是一种程序开发方式。在这一方面,我们必须了解更多关于面向对象系统分析和面向对象设计(Object Oriented Design,简称OOD)方面的知识。许多流行的编程语言是面向对象的,它们的风格就是会透由对象来创出实例。重要的面向对象编程语言包含Common Lisp、Python、C++、Objective-C、Smalltalk、Delphi、Java、Swift、C#、Perl、Ruby 与 PHP等。面向对象编程中,通常利用继承父类,以实现代码重用和可扩展性。
3.声明式编程(Declarative programming)
一种编程范式,与命令式编程相对立。它描述目标的性质,让计算机明白目标,而非具体过程。声明式编程不用告诉计算机问题领域,从而避免随之而来的副作用。而命令式编程则需要用算法来明确的指出每一步该怎么做。声明式编程通常被看做是形式逻辑的理论,把计算看做推导。声明式编程大幅简化了并行计算的编写难度。
常见的声明式编程语言有:
数据库查询语言(SQL,XQuery)
正则表达式
逻辑编程
函数式编程
组态管理系统等。
声明式编程透过函数、推论规则或项重写(term-rewriting)规则,来描述变量之间的关系。它的语言运行器(编译器或解释器)采用了一个固定的算法,以从这些关系产生结果。很多文本标记语言例如HTML、MXML、XAML和XSLT往往是声明式的。函数式编程,特别是纯函数式编程,尝试最小化状态带来的副作用,因此被认为是声明式的。不过,大多数函数式编程语言,例如Scheme、Clojure、Haskell、OCaml、Standard ML和Unlambda,允许副作用的存在。
二、3种最常用编程范式的特点
- 过程式编程的核心在于模块化,在实现过程中使用了状态,依赖了外部变量,导致很容易影响附近的代码,可读性较少,后期的维护成本也较高。
- 函数式编程的核心在于“避免副作用”,不改变也不依赖当前函数外的数据。结合不可变数据、函数是第一等公民等特性,使函数带有自描述性,可读性较高。
- 面向对象编程的核心在于抽象,提供清晰的对象边界。结合封装、集成、多态特性,降低了代码的耦合度,提升了系统的可维护性。
不同的范式的出现,目的就是为了应对不同的场景,但最终的目标都是提高生产力。
1、过程式编程(Procedural)
过程式编程和面向对象编程的区别并不在于是否使用函数或者类,也就是说用到类或对象的可能是过程式编程,只用函数而没有类的也可能是面向对象编程。那么他们的区别又在哪儿呢?
面向过程其实是最为实际的一种思考方式,可以说面向过程是一种基础的方法,它考虑的是实际地实现。一般的面向过程是从上往下步步求精,所以面向过程最重要的是模块化的思想方法。当程序规模不是很大时,面向过程的方法还会体现出一种优势。因为程序的流程很清楚,按着模块与函数的方法可以很好的组织。
2、函数式编程(Functional)
当谈论函数式编程,会提到非常多的“函数式”特性。提到不可变数据,第一类对象以及尾调用优化,这些是帮助函数式编程的语言特征。提到mapping(映射),reducing(归纳),piplining(管道),recursing(递归),currying(科里化),以及高阶函数的使用,这些是用来写函数式代码的编程技术。提到并行,惰性计算以及确定性,这些是有利于函数式编程的属性。
最主要的原则是避免副作用,它不会依赖也不会改变当前函数以外的数据。
声明式的函数,让开发者只需要表达 “想要做什么”,而不需要表达 “怎么去做”,这样就极大地简化了开发者的工作。至于具体 “怎么去做”,让专门的任务协调框架去实现,这个框架可以灵活地分配工作给不同的核、不同的计算机,而开发者不必关心框架背后发生了什么。
3、面向对象编程(Object-Oriented)
并不是使用类才是面向对象编程。如果你专注于状态改变和密封抽象,你就是在用面向对象编程。类只是帮助简化面向对象编程的工具,并不是面向对象编程的要求或指示器。封装是一个过程,它分隔构成抽象的结构和行为的元素。封装的作用是分离抽象的概念接口及其实现。类只是帮助简化面向对象编程的工具,并不是面向对象编程的要求或指示器。
随着系统越来越复杂,系统就会变得越来越容易崩溃,分而治之,解决复杂性的技巧。面对对象思想的产生是为了让你能更方便的理解代码。有了那些封装,多态,继承,能让你专注于部分功能,而不需要了解全局。
总结
命令式编程、面向对象编程、函数式编程,虽然受人追捧的时间点各不相同,但是本质上并没有优劣之分。 面向对象和函数式、过程式编程也不是完成独立和有严格的界限,在抽象出各个独立的对象后,每个对象的具体行为实现还是有函数式和过程式完成。
三、重点说一下函数式编程
1.函数式编程中的一些基本概念
(1)函数
函数式编程中的函数,这个术语不是指命令式编程中的函数(我们可以认为C++程序中的函数本质是一段子程序Subroutine),而是指数学中的函数,即自变量的映射(一种东西和另一种东西之间的对应关系)。也就是说,一个函数的值仅决定于函数参数的值,不依赖其他状态。
在函数式语言中,函数被称为一等函数(First-class function),与其他数据类型一样,作为一等公民,处于平等地位,可以在任何地方定义,在函数内或函数外;可以赋值给其他变量;可以作为参数,传入另一个函数,或者作为别的函数的返回值。
(2)纯函数
纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。不依赖外部状态,不改变外部状态。
比如Javascript里slice和splice,这两个函数的作用相似。 slice符合纯函数的定义是因为对相同的输入它保证能返回相同的输出。splice却会嚼烂调用它的那个数组,然后再吐出来;这就会产生可观察到的副作用,即这个数组永久地改变了。
var xs = [1,2,3,4,5];
// 纯的
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
// 不纯的
xs.splice(0,3);
//=> [1,2,3]
xs.splice(0,3);
//=> [4,5]
xs.splice(0,3);
//=> []
(3)变量与表达式
纯函数式编程语言中的变量也不是命令式编程语言中的变量(存储状态的内存单元),而是数学代数中的变量,即一个值的名称。变量的值是不可变的(immutable),也就是说不允许像命令式编程语言中那样能够多次给一个变量赋值。比如说在命令式编程语言我们写x = x + 1。函数式语言中的条件语句,循环语句等也不是命令式编程语言中的控制语句,而是一种表达式。
“表达式”(expression)是一个单纯的运算过程,总是有返回值;
“语句”(statement)是执行某种操作(更多的是逻辑语句。),没有返回值。
函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。比如在Scala语言中,if else不是语句而是三元运算符,是有返回值的。严格意义上的函数式编程意味着不使用可变的变量,赋值,循环和其他命令式控制结构进行编程。 当然,很多所谓的函数式编程语言并没有严格遵循这一类的准则,只有某些纯函数式编程语言,如Haskell等才是完完全全地依照这种准则设计的。
(4)状态
首先要意识到,我们的程序是拥有“状态”的。 想一想我们调试C++程序的时候,经常会在某处设置一个断点。程序执行断点就暂停了,也就是说程序停留在了某一个状态上。这个状态包括了当前定义的全部变量,以及一些当前系统的状态,比如打开的文件、网络的连接、申请的内存等等。具体保存的信息和语言有关系。比如使用过Matlab、R之类的科学计算语言的朋友会知道,在退出程序的时候它会让你选择是否要保存当前的session,如果保存了,下次打开时候它会从这个session开始继续执行,而不是清空一切重来。你之前定义了一个变量x = 1,现在这个x还在那里,值也没变。这个状态就是图灵机的纸带。有了状态,我们的程序才能不断往前推进,一步步向目标靠拢的。函数式编程不一样。函数式编程强调无状态,不是不保存状态,而是强调将状态锁定在函数的内部,不依赖于外部的任何状态。更准确一点,它是通过函数创建新的参数或返回值来保存程序的状态的。
状态完全存在的栈上。
(5)函数柯里化
curry 的概念很简单:将一个低阶函数转换为高阶函数的过程就叫柯里化。
// 柯里化之前
function add(x, y) {
return x + y;
}
add(1, 2) // 3
// 柯里化之后
function addX(y) {
return function (x) {
return x + y;
};
}
addX(2)(1) // 3
(6)函数组合(Pointfree:不使用所要处理的值,只合成运算过程)
为了解决函数嵌套过深,洋葱代码:h(g(f(x))),我们需要用到“函数组合”,我们一起来用柯里化来改他,让多个函数像拼积木一样。
const compose = (f, g) => (x => f(g(x)));
var first = arr => arr[0];
var reverse = arr = arr.reverse();
var last = compose(first, reverse);
last([1, 2, 3, 4, 5]); // 5
(7)声明式与命令式代码
在我们日常业务开发中,写的代码绝大多数都为命令式代码;
我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。
而声明式就要优雅很多了,我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。
//命令式
let CEOs = [];
for (var i = 0; i < companies.length; i++) {
CEOs.push(companies[i].CEO)
}
//声明式
let CEOs = companies.map(c => c.CEO);
2.函数式编程的特性
(1)高阶函数
高阶函数就是参数为函数或返回值为函数的函数。有了高阶函数,就可以将复用的粒度降低到函数级别。相对于面向对象语言,高阶函数的复用粒度更低。高阶函数提供了一种函数级别上的依赖注入(或反转控制)机制,在上面的例子里,sum函数的逻辑依赖于注入进来的函数的逻辑。很多GoF设计模式都可以用高阶函数来实现,如Visitor,Strategy,Decorator等。比如Visitor模式就可以用集合类的map()或foreach()高阶函数来替代。
(2)闭包
闭包的基础是一等函数(First-class function)。闭包在形式上就是一个函数内部定义另一个函数,函数的堆栈在在函数返回后并不释放,我们也可以理解为这些函数堆栈并不在栈上分配而是在堆上分配。
(3)访问权限控制
js中的作用域
(4)延长变量生命周期
在面向对象语言里,函数内的变量都是在栈上分配的,函数调用完成后,栈销毁,变量的生命周期结束。而对象是在堆分配的,会常驻内存,除非被手动或自动回收掉。
(5)函子
Functor(函子)遵守一些特定规则的容器类型。任何具有map方法的数据结构,都可以当作函子的实现。
Functor 是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。把东西装进一个容器,只留出一个接口 map 给容器外的函数,map 一个函数时,我们让容器自己来运行这个函数,这样容器就可以自由地选择何时何地如何操作这个函数。
3.函数式编程的好处
由于命令式编程语言也可以通过类似函数指针的方式来实现高阶函数,函数式的最主要的好处主要是不变性带来的。
(1)引用透明(Referential transparency)
引用透明(Referential transparency),指的是函数的运行不依赖于外部变量或”状态”,只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。其他类型的语言,函数的返回值往往与系统状态有关,不同的状态之下,返回值是不一样的。这就叫”引用不透明”,很不利于观察和理解程序的行为。
没有可变的状态,函数就是引用透明(Referential transparency)
(2)没有副作用(No Side Effect)。(Lodash.js)
副作用(side effect),指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。函数式编程强调没有”副作用”,意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。函数即不依赖外部的状态也不修改外部的状态,函数调用的结果不依赖调用的时间和位置,这样写的代码容易进行推理,不容易出错。这使得单元测试和调试都更容易。
还有一个好处是,由于函数式语言是面向数学的抽象,更接近人的语言,而不是机器语言,代码会比较简洁,也更容易被理解。
(3)无锁并发
没有副作用使得函数式编程各个独立的部分的执行顺序可以随意打乱,(多个线程之间)不共享状态,不会造成资源争用(Race condition),也就不需要用锁来保护可变状态,也就不会出现死锁,这样可以更好地进行无锁(lock-free)的并发操作。尤其是在对称多处理器(SMP)架构下能够更好地利用多个处理器(核)提供的并行处理能力。
(4)惰性求值
惰性求值(lazy evaluation,也称作call-by-need)是这样一种技术:是在将表达式赋值给变量(或称作绑定)时并不计算表达式的值,而在变量第一次被使用时才进行计算。
这样就可以通过避免不必要的求值提升性能。(简单例子&&)