Day 11 - OOP 初探 (1) - Closures 与继承链

前言

在学习 FP 的过程中,会看到 FP 常常被拿来跟 OOP 做比较,那 OOP 究竟是什麽呢?它们是对立关系只能选一种用吗?谁比较有优势呢?

今天会先简单介绍 OOP 大概是什麽,以及几个支持 OOP 的重要支柱们。

OOP(Object Oriented Programming)

中文翻作「物件导向程序设计」,简单来说就是以 物件(object) 为主体的程序设计风格。

这句话是否似曾相识呢?

没错,因为前三天提到的 FP(Functional Programming),就是以 函式(function) 为主体的程序设计风格。

OOP 与 FP 的差异

基於读者可能已经看完前面三天,对於 FP 这种程序设计风格有基础概念了,所以我想让读者在开始学习 OOP 之前,有正确的学习观念,以免被一些不由自主形成的对立影响了。

FP 是一种程序设计风格,OOP 则是另一种程序设计风格。

就好像 RPG 游戏里面玩骑士的角色擅长近距离战斗,但肯定也有不擅长的吧!所以我们会有其它角色像是弓箭手,擅长远距离战斗。

FP 跟 OOP 就像是上述的关系,FP 擅长处理「流程」相关的问题,那遇到「非流程」相关的问题该怎麽办?就可以考虑用其它像是 OOP 这种,擅长解决不同问题的程序设计风格。

我该选 OOP 还是 FP?

何不全都要?

RPG 游戏为了角色平衡,一次就只能选一种角色,但我们人类的潜力是无穷的,你可以是骑士在最前方冲锋陷阵,也可以同时配一把弓,在适合远距时攻击。

文艺复兴时期的达文西,在绘画、音乐、建筑、数学、几何学、解剖学、生理学、动物学、植物学、天文学、气象学、地质学、地理学、物理学、光学、力学、发明、土木工程等领域都有显着的成就。被称为「博学者」(polymath)。

有人会担心,是不是整个团队都一定要用 OOP 或都用 FP,不然会不会写到一半风格有落差开始打架?

其实不会的,因为一个网页包含了许多功能,每一个功能都是要解决使用者的问题,可以根据每个问题的特性,去思考要使用 OOP 或 FP,甚至其它,这样才不会发生让弓箭手近距离跟狂战士互 A 的惨况。

结论是通通学起来!

OOP 两大支柱

既然已经确定 OOP 就是可以成为自己的助力,那就开始来学习吧!

OOP 是以 object 为主体的思考,所以我们要先来学习,Javascript 跟物件相关的两个重点:Closures、Prototypal Inheritance

Closures

中文翻作「闭包」,它是 JavaScript 的一个资讯隐藏机制。

资讯隐藏

先从我们常见的物件当范例吧:

const person = {
    name: 'Joey',
    age: 20
};

有够普通的物件,但如果今天我们是个员工管理系统,可能需要纪录员工的薪水:

const person = {
    name: 'Joey',
    age: 20,
    salary: 40000,
};

阿捏母汤啊,我只要不小心印出 person.salary 就看光光了,甚至不小心 person.salary *= 3 就晋升中产阶级了。

即便不是这麽刻意,也很容易因为 Object.entries(person) 这种不经意的列举,就把比较隐私的资料暴露出来。

这边所谓的「隐私」资料其实不一定是「不能被看到」,因为要是不能看乾脆就不要放就好啦!这个「隐私」比较是「避免无意中被修改」。

私有特性(private properties)

为了避免无意中被修改,我们需要让某些 property 是 private,但 Javascript 并没有提供这样的关键字,以往都只能用 coding 的惯例来约束这件事,比如在私有特性的名称前後缀一个底线(_),比如 _salary,告诉其他开发者,这是一个不能直接被检视、编辑的属性。

但很明显这种方式,防君子不防猪队友,如果要硬改绝对是可以,因此它来了 - Closures。

搭配函式实现 Closures

要使用 closures 的机制,必须透过 function 来产生 object,先把上面的范例改成用 function 来产生:

const createPerson = (name, age) => {
    const salary = 40000;
    const getSalary = () => salary;
    
    return {
        name,
        age,
        getSalary
    };
}
const person = createPerson('Joey', 20);

console.log(person.salary);
console.log(person.getSalary());

执行结果

undefined
40000

有发现这里发生很神奇的事情吗?乍看之下 salary 这个变数只有在 createPerson 函式的 scope 里面,而且又没有被回传,感觉在函式外应该无法取得才对。。。

Closures 的形成方式

在函式中可以定义另一个函式时,如果内部的函式参照了外部的函式的变数,一旦外部的函式被执行,则产生闭包。

以上面的范例来说,会做以下判断:

  1. createPerson 里面的 salary 没有被存取
  2. 但是 salary 有出现在 getSalary 里面
  3. getSalary 被回传到外面去了
  4. 此时 closures 机制启动,把 salary 放到封闭的变数环境内
  5. 若呼叫 .getSalary(),就会到变数环境里面把 salary 抓出来

总结一句话就会是:内部函式变数有参照外部变数,就会产生 closures

Closures 机制会把资料储存在他们封闭起来的变数环境中,不提供对这些变数的直接存取。唯一的办法是,要在函式内明确提供存取它的方式。

Prototypal Inheritance

中文是「原型继承」。这东西大家应该就比较有概念了,因为这东西可能从学习 Javascript 的第一天,就已经在用继承的东西了。

阵列的身世之谜

比如说:

const arr = ['Jack', 'Allen', 'Alice'];

arr.forEach(item => console.log(item));

看起来很熟悉对吧!但你有想过吗?我们使用点运算子(.)都是用在物件取得 property 对吧(比如说 person.name),那这明明是个阵列,为什麽可以用 arr.forEach

这边有个惊人的事实要告诉你:

const arr = ['Jack', 'Allen', 'Alice'];
console.log(typeof arr);

执行结果

"object"

是的各位观众,阵列在 Javascript 里面,被分类在 object 里面

其实前几天在讲阵列的时候就隐约有提过,阵列其实就是 key 比较特别的物件,因为阵列只能用数字当 key。

现在你知道有继承的概念了,就不难猜到,阵列就是继承物件产生的,所以阵列可以使用点运算子(.),也就一点都不奇怪了。

内建函式的源头?

即便知道阵列是其中一种物件,但我又没有在这个物件宣告 forEach 这个 property,为什麽还是可以直接用?

那是因为虽然我们宣告阵列都偷懒使用中括号:

const arr = ['Jack', 'Allen', 'Alice'];

但其实它在背後是这样跑的:

const arr = new Array('Jack', 'Allen', 'Alice');

各位,「继承」的关键语法登场了你有看到吗?

继承的关键语法

隆重介绍!「继承」的关键语法就是 new

new 的功能是「产生物件」,但要产生什麽物件呢?要在 new 後面放一个 constructor(建构子),定义要产生什麽物件,可以想像建构子就是个工厂,专门量产物件用的,而建构子必须要是一个 function,我们拿上面 closures 提到的例子来改一下:

const Person = function (name, age) {
    const salary = 40000;
    this.name = name;
    this.age = age;
    this.getSalary = () => salary;
}
const person = new Person('Joey', 20);

console.log(person.salary);
console.log(person.getSalary());

执行结果

undefined
40000

注意几个重点:

  • 建构子(constructor)命名尽量使用大写开头(方便一眼识别)
  • 建构子函式必须是一般的 function,不可以用箭头函式
  • this 关键字代表「这个物件」

从范例可以看到:

  1. 有一个建构子函式叫做 Person
  2. 我透过在 new 的後面放上 Person,产生了一个 Person 物件
  3. 并且把这个 Person 物件放到 person 变数

透过继承得到建构子的 property

如果以上你都有看懂,那能否回答我,为什麽 person.getSalary() 可以执行呢?

思考的分界线

没错,因为我们在 Person 的这个建构子函式中,有宣告了 getSalary 这个 property,因此 person 才可以直接使用 person.getSalary()

同理可证,上面那个 arr.forEach 的问题,你了解为什麽 arr.forEach() 可以执行了吗 : )?

继承链

把原型想像成有一条链子,链子上勾着一个个物件,而每个物件的原型都指向前面的物件(可以透过 .__proto__ 找到上层):

const arr = [];
console.log(arr);
console.log(arr.__proto__); // Array 的原型
console.log(arr.__proto__.__proto__); // Object 的原型
console.log(arr.__proto__.__proto__.__proto__); // null

可以看到 arr 的 property 是一层一层继承下来的,所以它才会同时有 Array 跟 Object 的原生属性。

换句话说,原本你可以直接用 arr.forEach,但如果打断原型链,就会突然没了爸爸的庇护(?):

const arr = [];
console.log(arr.forEach);

arr.__proto__ = null;
console.log(arr.forEach);

执行结果

ƒ forEach() { [native code] }
undefined

所以没事不要乱断绝父子关系啊(?),会少了很多援军啊!

结语

今天开始了 OOP 之旅,但其实也是藉 OOP 的名义,来学习一些跟 object 相关的进阶观念,当然其实是满复杂的,平常很多 method 我们用得很顺,也不曾认真去思考它从何而来。

当然我们不是来学考古的,即便不知道 arr.forEach 怎麽来的又怎样?但那是因为要进到 OOP 的领域,有一些先备知识需要先掌握,明天才会如鱼得水!

真正要上场实战之前,还是要先蹲一下马步罗!

我来自於你
来自於天空
来自於宇宙

参考资料

Closures MDN
new operator MDN


<<:  11. Bug x Debug x Debug Tool

>>:  Day26 Project3 - LINE Bot 注册

Day24-"取址运算子、提令运算子"

取址运算子「&」 我们可以称为位址运算子或是参考运算子,主要是用来取出变数的记忆体位址,这...

Day-29 快速学习Excel时间函式

今日练习档 ԅ( ¯་། ¯ԅ) 今天要来带大家认识两个与时间有关的函式,分别为TODAY()以及N...

分支系列 - 7:合并发生冲突怎麽办?

角色情境 小明同时学会输入指令操作着终端机、 以及透过滑鼠操作着图像化介面的 Sourcetree ...

Day 13:架设 Grafana (1)

那麽今天再回来继续处理我们的 dashboard 吧,上次虽然找到了这个 caddy 的 dashb...

Day18 遇到问题该怎麽办?

大家好,我是乌木白,今天想和大家聊聊,如果在学习时或者在写专案时遇到问题该怎麽办? 遇到问题? 今...