程序代码虽然在机器上运行,但终究是由人来编写和维护的,因此代码的可读性、可维护性在软件开发中尤为重要。所以在编写代码的时候通常会遵循一些编码规范或风格,比如 Google 提出了最著名的关于主流语言的风格指南,但这些都比较微观和具体,如果宏观且抽象地来看,编码风格可以上升为编程范式。
编程范式(Programming Paradigm)也称“编程泛型”或“程序设计法”,是对代码编写方式的一种抽象,体现出了开发者对程序执行的看法。例如,在面向对象编程中,开发者认为程序是一系列相互作用的对象,而在函数式编程中一个程序会被看作是一个无状态的函数计算的序列。常见的编程范式有 2 种:命令式编程和声明式编程。
1. 命令式编程(Imperative Programming)
命令式编程是一种古老的编程范式,它的出现与冯·诺依曼架构(现代计算机的基础,一种将程序指令存储器和数据存储器合并在一起的电脑设计结构)有紧密关系。冯·诺依曼架构的基本工作原理是通过赋值语句来更改程序状态,然后根据这些状态来逐步执行任务。而命令式编程方式就是对这个工作过程的抽象,主要关注点是如何通过具体步骤得到计算结果。
(1)面向过程(Procedural Programming)
面向过程是一种以过程为中心的编程思想,在编程过程中分析出解决问题所需要的步骤,然后再按照执行过程编写代码。 这种编程范式比较具象,很符合人的直觉思维。比如,下面的代码就是一个面向过程的例子,calc() 函数接收两个参数,第一个是待遍历的数组 arr,第二个是计算类型 type:
function calc(arr, type) {
switch(type) {
case 'add':
return arr.reduce((acc, cur) => acc + cur, 0)
break;
case 'multiple':
return arr.reduce((acc, cur) => acc * cur, 1)
break;
}
}
这种编程范式流程明确,也不需要像面向对象那样生成实例,占用额外的存储空间,但它有个问题,就是代码的可扩展性不够。比如现在要加一个操作类型,那么又要修改 calc() 函数,添加一个逻辑分支。
(2)面向对象(Object-oriented Programming)
面向过程的编程范式要求按照流程步骤逐个地分析每个问题。很显然,并不是所有问题都适合这种过程化的思维方式,这也就导致了其他编程范式的出现,比如面向对象。
面向对象的核心是对象,它不是把问题抽象成流程步骤,而是抽象成对象,对象是程序代码中的基本单位,对应代码中的类或类的实例,对象内部封装了数据和方法。这种编程范式主要包含 3 个特性:封装、继承、多态。
- 封装(Encapsulation)
封装是通过限制只有特定类的对象可以访问这一特定类的成员,而它们通常利用接口实现消息的传入传出。简单说,就是给类的属性设定“权限”,将类的属性分为 3 类:公有成员、私有成员和受保护成员。公有成员可以由外部调用,私有成员只能在类内部访问,受保护的成员也只能在类内部或由子类访问。
下面两段代码分别是使用 JavaScript (ES5)和 TypeScript 实现封装特性的例子。在 JavaScript 中需要通过函数作用域来实现私有变量,在 TypeScript中则和面向对象语言 Java 的写法比较相近,通过关键字 private 和 public 声明即可。
// ES5 写法
function Animal() {
var name = ''
this.setName = function(a) {
name = a
}
this.getName = function() {
return name
}
}
// TypeScript 写法
class Animal {
private name:string;
public setName(name:string):void {
this.name = name
}
public getName():string {
return this.name
}
}
- 继承(Inheritance)
继承这个概念和生物学中的遗传有些类似,在创建子类的时候,会默认获得父类的一些非私有属性和方法。
下面两段代码分别是使用 JavaScript (ES5)和 TypeScript 实现继承特性的例子。
在 JavaScript 中需要通过原型对象 prototype 来实现继承,在 TypeScript中则和面向对象语言 Java 的写法比较相近,通过 extends 来继承父类。虽然在 JavaScript/TypeScript 都能实现基础的继承,但对于继承自多个父类的情况,实现起来会比较复杂。
// ES5 写法
function Dog() {
this.bark = function() {
return 'wang wang wang!!!'
}
}
Dog.prototype = new Animal()
// TypeScript 写法
class Dog extends Animal {
public bark():string {
return 'wang wang wang!!!'
}
}
- 多态(Polymorphism)
多态是指由继承而产生的相关的不同的类,其对象对同一消息会作出不同的响应。JavaScript/TypeScript 对多态的支持是不友好的,只能在函数内部通过判断参数类型来实现。
下面是一个简单的例子:
function Cat() {
this.bark = function(sound) {
if(undefined !== sound && null !== sound) {
return sound
} else {
return '...'
}
}
}
2. 声明式编程(Declarative Programming)
在计算机科学中,声明式编程是一种构建程序的样式,该表达式表示计算逻辑而无须谈论其控制流程。它通常将程序视为某种逻辑理论,可以简化编写并行程序的过程。重点是需要完成的工作,而不是应该如何完成。它只是声明我们想要的结果,而并不关注这个结果如何产生,这是命令式(如何做)和声明式(做什么)编程范式之间的唯一区别。
声明式编程也有一些子类,常见的包括:逻辑式编程、数据驱动编程和函数式编程。
(1)逻辑式编程(Logic Programming)
逻辑式编程通过设置答案须符合的规则来解决问题,而非设置步骤来解决问题。
这种风格很符合数学家和哲学家分析问题的方式,当我们需要解答一个新的问题时,先提出一个新的假设,然后再证明它跟现在的理论无冲突。逻辑提供了一个证明问题是真还是假的方法,创建证明的方法是人所皆知的,故逻辑是解答问题的可靠方法。逻辑式编程系统则自动化了这个程序,人工智能在逻辑式编程的发展中发挥了重要的影响。
(2)数据驱动编程(Data-driven Programming)
这种编程方法基于数据,程序语句由数据定义而非执行步骤。
数据库程序是业务信息系统的核心,并提供文件创建、数据输入、更新、查询和报告功能。有几种主要针对数据库应用程序开发的编程语言,比如 SQL,它应用于结构化数据流,以进行过滤、转换、聚合(如计算统计信息)或调用其他程序。
(3)函数式编程(Functional Programming)
函数式编程范式来源于数学而非编程语言,它的关键原理是通过执行一系列的数学函数来得到结果。核心依赖是用于某些特定计算的功能,而非数据结构,也就是说,数据与函数是松散耦合的,甚至严格说,数据应该是不可见的,因为它隐藏在函数的实现内部。
它主要有下面几个核心概念。
- 纯函数
若一个函数符合幂等性且无副作用那么就可以称为纯函数。幂等性是指在相同的输入值时,需产生相同的输出,与函数的输出和输入值以外的其他隐藏信息或状态无关。
// 非幂等
function rand() {
return Math.random()
}
// 幂等
function zero() {
return 0
}
副作用是指除了返回函数值之外,还对调用函数产生附加的影响。例如,修改全局变量(函数外的变量)、修改参数或改变外部存储。
// 无副作用
function add(a, b){
return a + b
}
// 有副作用
function setAddition(a, b) {
localstorage.setItem('sum', a + b)
}
从上面的例子可以看出纯函数的输出可以不用和所有的输入值有关,甚至可以与所有的输入值都无关。
纯函数会带来很多优点。首先它是无状态的,这也就意味着函数内部不需要额外的存储空间来保存数据;其次具有高度的可测试性,在前端框架中体现比较明显,之前用 jQuery 这类库来开发项目时,代码测试非常困难,因为很多函数是不纯的,内部可能涉及 DOM 操作、AJAX 请求、浏览器存储等各种副作用操作。
纯函数的使用也比较广泛,JavaScript 一些原生函数,例如 JSON.stringify() 就是纯函数,React 中也有与之对应的函数式组件。
- 高阶函数
高阶函数是指接收一个函数作为参数,然后返回另一个函数的函数。它体现的是一种高级的抽象思维,使用场景也比较多,例如 TypeScript 提供的装饰器功能来扩展函数,以及 React 中使用高阶组件来扩展组件行为。
- 柯里化
柯里化可以翻译成卡瑞化或加里化,是指把接受多个参数的函数变换成接受一个单一参数的函数,并且返回接受余下的参数且返回结果的新函数。这种处理函数的方式由克里斯托弗·斯特雷奇以逻辑学家哈斯凯尔·加里命名。
简单来说,就是在一个函数中预先填充几个参数,这个函数返回另一个函数,这个返回的新函数将其参数和预先填充的参数进行合并,再执行函数逻辑。