阅读 103

函数式编程入门 (二)面向对象与函数式编程

这篇文章是我读完《SICP》第三章后梳理的读书笔记。

很多语言都号称自己是面向对象。从我们开始学习 JavaScript 语言,就知道这是一门面向对象的语言。

直到有一天,面试官问到什么是OOP,OOP?WTF?那人厌恶地说就是面向对象编程。

我说:JavaScript 就是面向对象,就是一切以对象为载体,去编程,去面对。面试官: go out ! now!

面向对象的历史

到底什么是面向对象?我们先来看看面向对象的历史吧。

面向对象是一种编程范式(也叫方法论、思维方法等)。目前大部分语言都是支持多种范式的,包括面向对象这种范式。

  • 1960 Simula 语言初步实现了面向对象思想
  • 1970 Smalltalk 出现。Smalltalk 主要是受 Simula 启发。基本实现了面向对象语法,Smalltalk 是公认的鼻祖之一。

Lisp 又受 Smalltalk 的影响,也开始接纳面向对象思想,最终导致了 Flavors(首个引入 mixin 的语言),而这两门语言有导致了『Common Lisp 对象系统』的出现。

从这里可以看出面向对象和函数式不是对立的,可以同时存在

  • 1990 面向对象思想被广泛接纳,甚至成为主流编程思想,尤其是用户界面编程领域(比如前端)。事件驱动编程在它的带领下也开始变得流行。

从这些历史可以看出,面向对象是一种思潮,并没有明确的定义,是整个技术社区共同完善的结果。

所以追问面向对象的定义可能得不到结果(实际上函数式编程也是类似的)。

不知道面向对象的定义,那我们怎么去研究它呢?实际上,面向对象还有一些重要的内核,我们将这些内核弄明白,就能够明白什么是面向对象。

面向对象语言的分类

  • 纯面向对象语言:一切都是对象,包括数字、字符串也是对象,如 Python、Ruby、Scala、Smalltalk
  • 完全支持面向对象,也支持过程式,如 Java、C++、C#
  • 本来不支持面向对象,后来加上的,如 PHP、Perl
  • 看起来像面向对象,但没有完全使用面向对象(比如基于原型来模拟面向对象):JavaScript、Lua
  • 其他还有很多种。

原来一种面向对象的语言可以既支持面向对象,也支持过程式。而我们常用的 JavaScript 是基于原型来模拟的面向对象。

设计模式

设计模式指的是对于某个经常出现的问题提出大家都能用的解决方法。

比如说模块与模块之间怎么通信、怎么用缓存来增加系统性能。

某些面向对象语言内置了一些套路,如单例、事件、工厂等。(JavaScript 什么都没提供)

对面向对象的批评

有人批评说它

  1. 复用性和模块化并没有达到预期目标。

    比如 npm 把模块划分的太细了,安装一个包,就相当于安装了很多个。

    就像冰山一样,我们看到的是露出的部分,没有露出的非常庞大。

  1. 过分强调模型,而忽略了计算和算法。

入门

创建3个儿子:

  1. 我们需要对象

     var a1 = {name:'frank', age:18, type:'a', xxx: function(){}}
     var b1 = {name:'jack', age:18, type:'b', y1: function(){}, y2: function(){}}
     var c1 = {name:'jack', age:18, type:'c', z1: function(){}, z2: function(){}, z3: function(){}}
    
    复制代码

创建9个儿子:

  1. 我们需要很多对象

     var a1 = {name:'frank', age:18, type:'a', xxx: function(){}}
     var a2 = {name:'frank2', age:18, type:'a', xxx: function(){}}
     var a3 = {name:'frank3', age:18, type:'a', xxx: function(){}}
    
     var b1 = {name:'jack', age:18, type:'b', y1: function(){}, y2: function(){}}
     var b2 = {name:'jack2', age:18, type:'b', y1: function(){}, y2: function(){}}
     var b3 = {name:'jack3', age:18, type:'b', y1: function(){}, y2: function(){}}
    
     var c1 = {name:'pony', age:18, type:'c', z1: function(){}, z2: function(){}, z3: function(){}}
     var c2 = {name:'pony2', age:18, type:'c', z1: function(){}, z2: function(){}, z3: function(){}}
     var c3 = {name:'pony3', age:18, type:'c', z1: function(){}, z2: function(){}, z3: function(){}}
    
    复制代码
  2. 写上面代码的人怕不是个傻子?

    1. 使用函数简化代码,这就是 JavaScript 的做法

      这种做法有一个问题,内存占用太多,a1、a2、a3 的 xxx: function(){} 都是同一个函数,但是创建了3遍。

      所以 JavaScript 必须引入原型链。

       var a1 = createA(); 
       var a2 = createA(); 
       var a3 = createA();
      
      复制代码
    2. 直接使用 class。

      使用 new 关键字直接创建,我们不用关心 class 是如何优化内存的

       var a1 = new A()
       var a2 = new A()
       var a3 = new A()
      
      复制代码

为了模拟真实系统,面向对象发明了一整套术语。

是具有相同属性的一些东西,对象 — 也叫实例。

一个类如果导出一些对象,这个导出对象的过程就叫做实例化

如果这个对象里有一个函数,这个函数就叫做方法

这个对象里的所有东西,都叫做属性,也叫成员。

以上概念都是很常见也容易理解的,今天我们就来一起探讨一个不容易理解的概念:消息传递。

消息传递

为了理解消息,我们要了解一下面向对象的鼻祖之一:Smalltalk

安装

github.com/mcandre/gst…

下载安装,然后打开 cmd(不能使用 git bash,原因未知),输入 gst 即可开始 Smalltalk 之旅。

语法

官方教程

双引号是注释,不要使用。

(1 + 2) * 3  // 结果 9
'Hello, world' printNl // printNl 在下一行打印出 print next line
// 'Hello, world' 
// 'Hello, world' 
复制代码

数组

x := Array new: 20 // new 一个长度为20的数组
x at: 1            // 取 x 数组的第一位
x at: 1 put: 99    // 将 x 数组第一位设置为 99
(x at: 1) + 1      // 将 x 数组第一位取出来 +1

复制代码

Set:不能包含重复元素的数组

使用英文句号断句

x := Set new                     // x 设置为空 Set
x add: 5. x add: 7. x add: 'foo' // x 加入 5/7/'foo' 用 . 来分割句子
x remove: 5                      // x 移除 5
x includes: 7                    // x 是否包含 7
复制代码

有一种简写形式,可以少写几次 x

x add: 5; add: 7; add: 'foo'     // ; 表示这句话没结束

复制代码

打印出 x

x 或者 x yourself 或者 x printNl

复制代码

字典

y := Dictionary new  // y 设置为字典
y at: 'One' put: 1   // 'One': 1
y at: 'Two' put: 2   // 'Two':2
y at: 1 put: 'One'   // 1: 'One'
y at: 2 put: 'Two'   // 2: 'Two'

y at: 1              // 'One'
y at: 'Two'          // 2
y
y!                   // 用于删除 y 的值
y                    // 结果为 nil

复制代码

回到消息

讲完了 Smalltalk 的语法,我们来回到消息。

为什么我们学习面向对象很少谈消息呢?因为我们看不出来哪里有消息。

之前一直搞不懂为什么有些人说 person.say('hi') 是

  1. 给 persom 对象发送了一个 say 消息
  2. person 对象会响应这个消息

这明明就是一个函数调用呀。

但是,一旦我们改成 Smalltalk 就理解了

person say: 'hi'
person say: 'hi'; say: 'hello'

是 person 说了两句话,而不是 person 调用两次函数。

person.say('hi')
person.say('hello')
复制代码

面向对象的核心就是对象与对象之间交互。

  1. 对象维护自己的状态和生命周期
  2. 每个对象独立
  3. 对象和对象直接通过消息传递来工作

这就是 Smalltalk 名称的由来:说小话。

我们不是在说函数式吗?写消息传递干什么?

面向对象与函数式的关系

说完了 Smalltalk,接下来回归正题。面向对象和函数式是什么关系?

面向对象和函数式是对立的(不可融合的)吗? 面向对象的优缺点是什么?函数式呢?

一个取钱的程序

解答上面问题之前,先来看银行里一个取钱的需求。

Lisp

(define money 100)
(define (take n)
    (set! money (- money n)) // 修改 money 值为 money - n, ! 的意思是这个语法不推荐
    money)

复制代码

可以将 money 改为局部变量

第一次取25元,返回75。第二次再取25元,返回50。

同一个函数,调用两次得到的结果是不同的。说明函数也可以保存状态。

(define taker
    (let (money 100)
        (lambda (n)
            (set! money (- money n))
            money)
    )

(taker 25)
75
(taker 25)
50
----------
改写为 JavaScript 

const taker = funcution () {
  let money = 100
  return (n) => {
    money = money -n
    return money
  }
}()

在JavaScript中,只要有闭包,函数也可以保存状态
复制代码

这段代码还可以这样写

(define (taker money)
    (lambda (n)
        (set! money (- money n))
        money)
(define taker1 (taker 100))
(define taker2 (taker 100))
(taker1 50)
50
(taker2 70)
30

复制代码

可以看出,同样一个函数,每次执行的结果却不一样。所以,第一章的代入法求值不再有用了。数学属性全部消失。 闭包可以存状态,不同的函数有各自的状态,目前还不是面向对象。

函数式也可以做成消息传递风格

消息传递风格使用消息传递风格就可以构造 account 对象了,account 对象可以响应 withdraw(取钱)和 deposit(存钱)消息:

let makeAccount = money => {
  let take = (n) => {
    money = money - n
    return money
  }
  let save = (n) => {
    money = money + n
    return money
  }
  // 派发
  let dispatch = (m) => {
    return (
    m === 'take' ? take :
    m === 'save' ? save :
    new Error('unknown request'))
  }
  return dispatch
}

复制代码

接下来是使用 makeAccount 创造两个 account 对象(其实是过程):

let account1 = makeAccount(100)
account1('take')(70) // 30
account1('save')(50) // 80

复制代码

写成 Lisp 其实更像是消息传递

((account1 'withdraw') 50)
((account1 'deposit') 50)

复制代码

以上只是利用『分派』模式来模拟对象。

赋值的利弊

将赋值引进程序设计语言,将会使我们陷入许多困难概念的丛林中。

但是它就没有好处吗?

与所有状态都必须现实地操作和传递额外参数的方式相比,通过引进赋值以及将状态隐藏在局部变量中的技术,能让我们以一种更模块化的方式构造系统。

每个对象可以存储自己的状态,只需要给对象说一句话(传递参数),这个对象就可以自己做事情。

好处就是封装的更彻底,不用去关心状态如何改变。

但是这本书马上又加了一句话:

可惜的是,我们很快就会发现,事情并不是这么简单。

接下来就进入了「赋值的代价」小节:

赋值操作使我们可以去模拟带有局部状态的对象,但是,这是有代价的,它是我们的程序设计语言不能再用代换模型来解释了。进一步说,任何具有「漂亮」数学性质的简单模型,都不可能继续适合作为处理对象和赋值的框架了。只要我们不使用赋值,一个「过程」接收同样的参数一定会产生同样的结果,因此就可以认为这个「过程」是在计算「数学函数」。不用任何赋值的程序设计称为函数式程序设计。

所有数学性质都消失了,一个函数不能返回相同的结果了。

示例

来看看下面这段代码吧,没有赋值也可以实现需求。

pager 组件的页码生成过程

当前,分页器的需求是:当前为第5页,一共有100页,显示以下按钮。 1 ... 3 4 5 6 7 ... 100

  • 目标:获取数组 [1, ..., 3, 4, 5, 6, 7, ..., 100]
  • 过程:
    • 创建数组 [1, 2, 3, 4, 5 ... 100]
      • new Array(10) 没有下标 0/1/2 ... ,只有 length: 10
      • Array.apply(null, { length: 10 }) 才有下标
    • 留下需要展示的按钮 [1, 3, 4, 5, 6, 7, 100]
    • 间距超过2页,加上标识 -1 ,[1, -1, 3, 4, 5, 6, 7, -1, 100]
    • -1 改为 ...
import React from "react";
import ReactDOM from "react-dom";

import "./styles.css";
// 分页器需求:
// 当前为第5页,一共有100页,显示以下按钮
// 1 ... 3 4 5 6 7 ... 100

function Pager(props) {
  const buttons = Array.apply(null, { length: props.total })
    .map((n, index) => index + 1) // 渲染所有按钮
    .filter(n => {  // 只留下需要展示的按钮
      if (n === 1) { // 第一页
        return true;
      } else if (n === props.total) { // 最后一页
        return true;
      } else if (Math.abs(n - props.current) <= 2) { // 当前页 + 左右两页
        return true;
      } else {
        return false;
      }
    })  // [1, 3, 4, 5, 6, 7, 100]
    .reduce((prev, n) => {   // 间距超过2页,加 -1 
      const last = prev[prev.length - 1];
      return prev.concat(n - last > 1 ? [-1, n] : [n]);
    }, []) // [1, -1, 3, 4, 5, 6, 7, -1, 100]
    .map(n => (n === -1 ? <span>...</span> : <button>{n}</button>)); // -1 变为 ...
  return <div>{buttons}</div>;
}

function App() {
  return (
    <div className="App">
      <Pager current={5} total={100} />
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
复制代码

从这段代码可以看出,没有赋值也可以编写代码实现需求。

在外面每次操作 buttons 数组的时候,都不会修改之前的结果。而是直接拿一个新的值,覆盖旧的值。

但不赋值的操作也明显的缺点,容易产生垃圾,每当我们用新的数据覆盖旧的数据时,旧的数据就变成了垃圾。

但是没有了赋值操作,我们就可以使用所有的「数学特性」。

赋值的本质

说了这么多不使用「赋值」的代码,「赋值」到底是什么?

const buttons = Array.apply(null, { length: props.total })
复制代码

这段代码不是「赋值」吗?

不是,这段代码是「定义」buttons。对一个变量进行第二次 = 操作,才是赋值。

再来看这段代码

(define taker
    (let (money 100)
        (lambda (n)
            (set! money (- money n))
            money)
    )

(taker 25)
75
(taker 25)
50
复制代码

如果没有赋值,money 只不过就是一个值的名字;

如果有了赋值,money 就是一个容器,可以保存不同的值,是一个可变的状态。

这会带来很多问题。

广泛采用赋值的程序设计叫做『命令式/指令式』程序设计

如果你的代码中经常使用 money = 1; money = 2; money =3 这种赋值操作,那么你的代码叫做「命令式/指令式」程序设计。

这种程序在遇到「并发」「克隆」等问题时经常很令人头疼。

并发

运行在客户端的 JavaScript 是没有「并发」问题的,但运行服务端的 Node.js 有。

let a = 3;
// 假设我们有一个赋值操作
a = a + 1;
复制代码

这个操作平时看起来没什么问题,但如果有10个人同时操作 a,此时 a 的值是多少?

a 没有确定的值。

所以并发的时候,赋值是非常危险的操作。必须加上「锁」,再检测锁。通过锁才能进行不危险的并发操作。

在函数式编程中,每个人都是复制一遍 a,然后操作自己的 a。

命令式隐含了「大家共享同一个 a」,函数式是「操作自己的 a」,所以函数式处理并发是更方便的。

克隆

克隆就更不用说了,前端面试深坑 「深拷贝」。

来看一道经典面试题

var a = {name: 'a'}
var b = a

b.name = 'b'
a.name // 'b'
复制代码

a.name 的值也被改变了,这就是赋值的副作用。

函数式编程就可以解决这个问题,因为这种编程思想不允许赋值。

如果要修改 b 的值,先复制一份 b。再去修改复制后 b 的值,不允许直接修改。

在函数式编程里,非常强调对象是不可变的。声明之后,不允许修改对象的值。

所以,「并发」「克隆」在 JavaScript 程序中,是非常让人头疼的问题。很容易修改别人的值。

如果必须赋值呢?

函数式编程主张将需要赋值的部分代码提取出来,保证 99% 代码都是不赋值的。

函数式编程的特点

最后,再说回到函数式编程的特定

  1. 数学!(公理化和可证明)

    函数式很容易证明你的代码有没有问题,因为是数学的。

  2. 更加强调程序执行的结果而非执行的过程。

    比如计算费波那西数列
    
    函数式编程:
    f(n) n = 1, 1
         n > 1, f(n-1)*n
    
    过程式编程:
    result = 1
    for(i~n)
      result = result * i
    return 
    
    函数式更关心执行结果,计算过程交给计算机。过程式则相反。
    复制代码
  3. 函数是一等公民(函数可以作为参数),高阶函数

  4. 纯函数,拒绝副作用。

    什么是副作用呢?赋值就是副作用。

  5. 不可变数据

  6. 数据即代码,代码即数据

  7. 引用透明

总结

  • 面向对象的历史
  • 对面向对象的批评
  • 面向对象与函数式的关系
  • 赋值的利弊
    • 不赋值,也能写出好程序
  • 不用任何赋值的程序设计成为函数式程序设计