Skip to content

JavaScript面向对象内涵

  • 前言
  • 面向对象导论
  • 封装与接口
    • 接口演进史
    • 破坏性修改
  • 对象的构造
  • 继承
    • 类的继承
    • 原型的继承

前言

几年前,我还是一个Java开发者。学习Java时,第一个要学的概念就是 面向对象 ,从一开始,我的学习思路就与大众的方式有所不同,我坚信:

由人创造的概念,最终总是逃脱不了人的思维模式,也与现实世界脱不了钩。

在学习计算机知识时,我喜欢以自然世界为参考模型,我发现有时候我们理解不了某个概念,就是因为太喜欢自以为是地站在计算机的视角,所以往往不得知识之精髓。

学习本章时,希望读者能多多类比现实世界,将计算机世界与现实世界关联起来。

面向对象导论

在C++、Java、C#等这种面向对象语言中,理解“对象”这个概念是第一步。但即便没有这些语言,我们仍然能理解什么对象:

对象是世界万物。

面向对象的编程语言中常常说“一切都是对象”,同理,我们现实世界中的一切也都是对象。举个例子,从“我”作为对象的角度去理解,对象有两个关键的特征:

  1. 属性:眼睛、鼻子、头发等物理特征
  2. 行为:吃饭、喝水等动作或行为

属性是 实体,在编程语言中对应 变量 这个概念;行为是一种 抽象 概念,在编程语言中对应 函数(或方法) 这个概念。

首先解释一下 实体抽象,实体是真实的,就像肉体必须有实际的地方安放,同理,变量也需要有一块对应的内存供其存储。

而抽象是“虚无”的,抽象在特定的时间、地点会被具化。这么说可能有点哲,举个例子,以吃饭这个行为为例,当我们在讨论吃饭时,我们真的吃了吗?没有。只有我(对象)今天中午(时间)在饭店(地点)和高启强一起吃猪脚面时,“吃饭”这个行为,才被我(这个对象)具化。

编程语言中经常称函数为封装,函数将一个行为的具体流程封装在内部,调用者只需要按照需求调用函数就可以非常快速地完成这个行为。就像我们每天洗澡,“洗澡”这个行为的整个流程已经在我们脑中形成一个“模板”,这个模板其实就是函数。只有你调用了这个函数,才把这个行为具化了。

行为的执行过程中往往需要实体参与, 就像吃饭需要饭菜、筷子、嘴巴等实体参与一样。

封装与接口

人们常说函数是封装,又说封装是面向对象概念中的核心之一。各种书籍把“封装”这个概念阐述得天花乱坠,方法论层出不穷,但我觉得,方法固然重要,但一味讲求方法不求思维方式提升的行为,都是本末倒置的。

封装在我们现实世界无处不在,我们使用的任何东西都是封装的结果。举个例子,我正在输入内容的键盘就是一个封装产物,其实没人关心键盘内部是什么构造,使用了什么样的电路,我们只关心键盘是不是我们熟悉的那些键位,是不是一插USB(或连接蓝牙)就可以使用了。这里提到两个重要信息:

  • 键位
  • 连接方式

这两个其实就是 封装所抛出的接口 ,封装与接口总是成对出现:封装是内部实现,而接口是对接使用者的部分 ,封装可以随意更改,但不要破坏接口(不能说因为改造键盘的内部电路把键位调整,也不能说想让传输更有效率,发明了一种新的连接方式);接口需要稳定,只能平滑升级,不能进行破坏性修改,过多的破坏性修改对使用者而言是一种伤害。

当你尝试将内部实现隐藏,并对接口进行谨慎设计的时候,就是在封装了。

不管在什么编程语言中,函数都是最直接的封装形式,例如设计一个加法函数:

js
function plus(a, b) {
  return a + b
}

使用者不关心内部实现是什么,如果他要知道12加5等于多少,只需要调用 plus(12, 5) 即可。

实现可以很优,也可以很烂,总体而言,对于用户是没什么太大影响的。例如以下实现在大数计算时运行很慢,但用户无法感知,实现于用户而言就如一个 黑盒

js
function plus(a, b) {
  let sum = 0
  for (let i = 0; i < a; i++) {
    sum++
  }
  for (let i = 0; i < b; i++) {
    sum++
  }
  return sum
}

你经常会改进自己的实现,例如上述函数不够严谨,存在传入非数值类型值的情况,因此在计算前,你需要处理一些边界情况:

js
function assetNumber(val) {
  if (typeof val !== 'number') throw new Error('not a number!')
}

function plus(a, b) {
  assetNumber(a)
  assetNumber(b)
  return a + b
}

有些人会觉得抛出错误这种办法过于强硬,对于用户不太友好。其实通过返回 nullNaN 也可以标识用户是否传错了参数(但却可以使应用不崩溃),这也是可行的,在接口文档中将这些情况说明清楚即可。

接口改进

当你为自己优良的设计沾沾自喜的时候,有用户提出了这样的需求:

“我需要计算 12 + 5 + 8 + 30 ,但是不得不这样调用:

js
let sum = plus(12, 5)
sum = plus(sum, 8)
sum = plus(sum, 30)

能不能不要限制参数只能为2呀,让我可以这样调用:plus(12, 5, 8, 30)

于是,你开始思考如何改进你的接口,但又要让用户不至于重写代码,幸好JavaScript中有剩余参数特性可以为你解决问题,于是你改进了接口与实现:

js
function assetNumber(val) {
  if (typeof val !== 'number') throw new Error('not a number!')
}

function plus(...numbers) {
  if (numbers.length <= 0) return 0;
  let sum = 0
  for (let i = 0, len = numbers.length; i < len; i++) {
    assetNumber(numbers[i])
    sum += numbers[i]
  }
  return sum
}

以前的代码可以照常运行,用户新的需求也得到了满足,你的首次接口改进大获成功!

破坏性修改

随着时间的推移与需求的变化,接口总是要不断地进行更新,有时候,就难免出现 破坏性修改 。还是上面的例子,用户希望传入一个参数 convert 表示是否要将非数值类型转成数值类型。因为剩余参数占用了所有参数列表,因此不得不重新设计参数:

js
function assetNumber(val) {
  if (typeof val !== 'number') throw new Error('not a number!')
  return val
}

function plus(numbers, convert) {
  if (numbers.length <= 0) return 0;
  let sum = 0;
  for (let i = 0, len = numbers.length; i < len; i++) {
    const num = convert ? Number(numbers[i]) : assetNumber(numbers[i])
    sum += num
  }
  return sum
}

这么一修改,现有使用者使用 plus(12, 5)plus(12, 5, 8, 30) 等调用都将失败,此时使用者不得不修改自己的代码。

注意

在设计接口时,要尽可能避免破坏性修改,过多的破坏性修改会让用户丧失信任度,对用户是一种伤害。

如果实在需要进行破坏性修改,可将原有接口标记为过时,新增一个新的接口,直到原接口没有用户使用时,再在下一个版本中将其删除。

重新理解封装

所有隐藏内部实现、抛出接口的行为,都是在封装。