JavaScript 中的编程范式与面向对象编程

1,663 阅读9分钟

对于所有开发者而言(不论你使用什么语言编程),肯定都听过编程范式面向对象编程你这两个词语,但是呢我们大部分人又都很难解释清楚,就好像可能我们每天都在使用相关的内容来编写我们的代码,已经形成了肌肉记忆和习惯,但是你让我解释为什么这么写的时候,我也说不清楚。其实笔者也经常会这样,因此为了让所有人都能理解这两个词,有了这篇文章,目的就是用个人的简介搭配通俗易懂的语言案例,帮助各位从此掌握这两个概念。

因为笔者是前端开发,所以文章里的代码内容都会用 JavaScript 来编写,其他语言大同小异,并不是语言类文章,只是辅助大家了解这两个概念。

编程范式

编程范型编程范式程序设计法(英语:Programming paradigm),是指软件工程中的一类典型的编程风格。常见的编程范型有:函数式编程指令式编程声明式式编程面向对象编程等等。

上面是 Wiki 百科对于编程范式的解释,说实话,解释得真好,我一搜我都震惊了,因为啥呢,我想写这篇文章的时候,想到的标题就是《编程范式和面向对象编程》,但是我不知道应该怎么自然的起转承接从编程范式到面向对象编程。而看完维基百科的定义,一下豁然开朗了。

Wiki 解释了编程范式其实就是一种编程风格,或者程序设计法,每一种编程范式都有自己的特点以及使用场景,开发者可以使用特定的编程范式来解决特定场景问题,也可以在一个完整的项目中使用不同的编程范式来解决不同问题。

指令式编程(命令式编程)

首先来介绍的就是指令式编程我们更习惯称之为命令式编程,为啥第一个要介绍它呢?因为相信即使你从来没了解过编程范式,但是你第一个写出来的有编程范式风格的代码一定是命令式编程。是不是不信?我们来看一下这个例子:我是一个幼儿园园长,幼儿园有 5 位小朋友,我想统计一下 3 岁以上的小朋友有多少人?

let childs = [{
  name: 'luffy01',
  age: 3
}, {
  name: 'luffy02',
  age: 4
}, {
  name: 'luffy03',
  age: 5
}, {
  name: 'luffy04',
  age: 2
}, {
  name: 'luffy05',
  age: 3
}];
const result = [];
/**
 * 命令式统计幼儿园5位小朋友的年龄超过3岁的数量
 **/
for (let i = 0; i < childs.length; i++) {
  if (childs[i].age > 3) result.push(childs[i]);
}
console.log(`3 岁以上的小朋友有 ${result.length} 人`)

是不是很熟悉?没错,我们计算机入门课程里,学的 for 循环/ while 循环/ do...while 等等,就是命令式编程范式,命令式编程其实就是制订好一系列的步骤,第一步做什么,第二步做什么...然后让计算机按照制定的步骤去执行,最后返回结果,这就是命令式编程的特点。

函数式编程

讲完命令式编程,接下来给大家介绍的就是 —— 函数式编程。为啥第二个介绍它呢,因为他俩简直太像了,我来写代码你们看看:

let childs = [{
  name: 'luffy01',
  age: 3
}, {
  name: 'luffy02',
  age: 4
}, {
  name: 'luffy03',
  age: 5
}, {
  name: 'luffy04',
  age: 2
}, {
  name: 'luffy05',
  age: 3
}];
/**
 * 函数式统计幼儿园5位小朋友的年龄超过3岁的数量
 **/
function calcOlder3Age() {
  const result = [];
  /**
   * 命令式统计幼儿园5位小朋友的年龄超过3岁的数量
   **/
  for (let i = 0; i < childs.length; i++) {
    if (childs[i].age > 3) result.push(childs[i]);
  }
  return result;
}
const older3AgeChilds = calcOlderAge();
console.log(`3 岁以上的小朋友有 ${older3AgeChilds.length} 人`);

大家可以看到,基本上核心代码是一模一样的,只不过函数式编程把命令式编程的步骤封装成了一个函数,就可以称之为函数式编程了。那有小伙伴可能会问了,你这么解释是不是有点牵强了,感觉意义不大啊,难道函数式编程只是换汤不换药的臭流氓吗?当然不是,如果我们把代码改造成下面这样,大家是不是更容易理解了?

function calcOlderAge(arr, targetAge) {
  const result = [];
  for (let i = 0; i < childs.length; i++) {
    if (childs[i].age > targetAge) result.push(childs[i]);
  }
  return result;
}
const older3AgeChilds = calcOlderAge(childs, 3);
console.log(`3 岁以上的小朋友有 ${older3AgeChilds.length} 人`)
const older4AgeChilds = calcOlderAge(childs, 4);
console.log(`4 岁以上的小朋友有 ${older4AgeChilds.length} 人`)

这么看是不是清晰多了?上面的代码只是为了让大家更简单的理解命令式和函数式的区别,既然是典型的编程范式,那么它肯定是有适用场景的,比如命令式编程的代码,计算超过 3 岁的孩子人数,那么当需求发生变化,需要计算超过 4 岁孩子的人数了,就需要重复编写指令代码。

而函数式编程的好处就是,通过函数进行封装,把变量通过参数传递到函数内部,这样既有利于模块化编程,又能减少副作用和全局变量(因为变量都是在函数内部产生,它不会更改自身作用域以外的变量),同时提升代码的可维护性

声明式编程

接下来给大家介绍的应该是我认为最好理解的声明式编程。不是说声明式编程简单易懂,而是绝大部分开发者看到上面那道题,用 IDE 写出来的代码,基本上就是声明式编程,不信你们就试试?

let childs = [{
  name: 'luffy01',
  age: 3
}, {
  name: 'luffy02',
  age: 4
}, {
  name: 'luffy03',
  age: 5
}, {
  name: 'luffy04',
  age: 2
}, {
  name: 'luffy05',
  age: 3
}];
/**
 * 过程式统计幼儿园5位小朋友的年龄超过3岁的数量
 **/
const result = childs.filter(child => child.age > 3);
console.log(`3 岁以上的小朋友有 ${result.length} 人`)

不知道我写的和大家写的是否一致,没错,我们日常开发使用的比如 Array.prototype.map/Array.prototype.forEach等,就是声明式编程,与命令式编程一条一条写指令不同,声明式编程看重的是结果。什么意思?就是我需要的是年龄超过 3 岁小朋友的数量,我不管你是去查去问还是其他方式,总之你告诉我结果就行了。声明式编程的好处就是将复杂的计算逻辑和指令隐藏在函数内部,这种方式让代码写起来更符合我们的思维方式,最直观的就是数组原型上挂载的各种方法。

但是值得注意的是,虽然现在我们开发习惯写的代码是声明式编程,但是其实计算机底层执行这段代码的时候,依然是命令式编程,因为它依然需要一个一个的遍历然后计算,只是开发人员不再需要编写复杂的步骤指令了。

面向对象编程

最后给大家介绍的就是编程范式里最受欢迎,使用最广泛的面向对象编程也就是常说的 OOP(Object-Oriented Programming)。

面向对象编程的核心就是 Class 也就是类,因为面向对象,这个对象就是通过 Class 实例化出来的实例,而每一个实例内部都包含一组信息(属性)以及行为(方法),我们可以访问实例的属性,也可以通过实例来调用这些方法。

那上面那个例子使用面向对象编程来解决的话就应该是下面这个样子:

class School {
  constructor(name, childs = []) {
    this.name = name;
    this.childs = childs;
  }
​
  calcOlderAgeChilds(targetAge) {
    const result = this.childs.filter(child => child.age > targetAge);
    console.log(`${targetAge} 岁以上的小朋友有 ${result.length} 人`)
  }
​
  add(name, age) {
    this.childs.push({ name, age })
  }
​
  intro() {
    console.log(`大家好,我们是${this.name},目前我们园区一共有 ${this.childs.length} 名小朋友~`);
  }
}
​
const school = new School('井冈山革命基地幼儿园', [{
  name: 'luffy01',
  age: 3
}, {
  name: 'luffy02',
  age: 4
}, {
  name: 'luffy03',
  age: 5
}, {
  name: 'luffy04',
  age: 2
}, {
  name: 'luffy05',
  age: 3
}]);
​
school.intro();
school.calcOlderAgeChilds(3);
school.add('luffy06', 4);
school.intro();
school.calcOlderAgeChilds(3);

上面提到的例子,笔者可能被自己前面的举例局限住了,因为希望一个例子展示不同编程范式的区别,然后就这么写了,可能不太贴切,但是不要紧,因为既然是最受欢迎的编程范式,那么笔者肯定会更详细的来介绍一下的,接下来,我们就来详细的聊聊面向对象编程这个话题。

上面介绍了几种比较常见的编程范式,并没有罗列完全,也永远不会罗列完成,为什么呢?因为编程范式是一种编程风格,是可以不断推陈出新的,当旧的范式无法解决新的问题的时候,就会促进新的编程范式产生,这是必然的规律。

面向对象编程

上面介绍了编程范式的几种模式并且用简单的例子对比了一下彼此的区别和联系,既然前面说了面向对象编程是当下互联网各个语言领域里最受欢迎的编程范式,那么它肯定有自己独特的优势,接下来就用具体案例来介绍一下面向对象编程的特性以及适用场景。

定义

面向对象程序设计(英语:Object-oriented programming,缩写:OOP)是种具有对象概念的编程典范,同时也是一种程序开发的抽象方针。它可能包含数据特性代码方法。对象则指的是(class)的实例。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及经常修改对象相关连的数据。在面向对象程序编程里,计算机程序会被设计成彼此相关的对象。

上面这段话是面向对象编程的定义,简单点理解其实就是把一系列的事物特性通过抽离封装成类,每个类具有属性和方法,在使用时将他们实例化出来的设计模式。下面就是一个简单的几个类:

const GENDER_ENUM = {
  0: '雄性',
  1: '雌性',
}
​
class Cat {
  constructor(name, gender, age, color) {
    this.name = name;
    this.gender = gender;
    this.age = age;
    this.color = color;
  }
​
  hello() {
    console.log(`我是一只${GENDER_ENUM[this.gender]}小${this.color}猫,名字是:${this.name},今年${this.age}岁了。`);
  }
}
​
class Dog {
  constructor(name, gender, age, color) {
    this.name = name;
    this.gender = gender;
    this.age = age;
    this.color = color;
  }
​
  hello() {
    console.log(`我是一只${GENDER_ENUM[this.gender]}小${this.color}狗,名字是:${this.name},今年${this.age}岁了。`);
  }
}
​
const cat = new Cat('Tom', 0, 6, '灰');
const dog = new Dog('Lucy', 1, 5, '白');
cat.hello(); // 我是一只雄性小灰猫,名字是:Tom,今年6岁了。
dog.hello(); // 我是一只雌性小白狗,名字是:Lucy,今年5岁了。

上面我们就创建了两个简单的类,一个 Cat 类,一个 Dog 类,关于类的说明有几点注意事项:

  • 类并不是具体的实体或者对象,类是我们抽象出来的一种自定义数据结构类型。
  • 使用 class 关键字来定义类,通常类使用大驼峰的命名方式。
  • 可以通过 new 关键字对类进行实例化。
  • 类必须有一个 constructor 方法,用来对类进行实例化。
  • this 关键字指向类本身,并在 constructor 方法内定义类的属性。
  • 类内部的方法就是函数,但是不需要进行 fucntion 关键字声明,直接书写名称以及内部需要执行的代码即可。
  • 【前端专属】:JavaScript 中的 Class 在 ES6 被引入,实际上只是函数的语法糖。

四大原则

提到面向对象编程,就不可避免的要提到 OOP 四大原则:继承、封装、抽象、多态。我不是很喜欢说概念,如果能用代码来说明清楚,我觉得是最好的了,所以四大原则尽量用代码和白话来给大家说明清楚。

继承

前面的猫类和狗类,我们定义完会发现,其实除了名字不一样,构造函数里面的参数都是相同的,那么是不是有更好的办法来进行抽象呢?当然了,我们看下面的代码:

const GENDER_ENUM = {
  0: '雄性',
  1: '雌性',
}
​
class Animal {
  constructor(name, gender, age, color) {
    this.name = name;
    this.gender = gender;
    this.age = age;
    this.color = color;
  }
}
​
class Cat extends Animal {
  constructor(name, gender, age, color) {
    super(name, gender, age, color);
  }
​
  hello() {
    console.log(`我是一只${GENDER_ENUM[this.gender]}${this.color}猫,名字是:${this.name},今年${this.age}岁了。`);
  }
}
​
class Dog extends Animal {
  constructor(name, gender, age, color) {
    super(name, gender, age, color);
  }
​
  hello() {
    console.log(`我是一只${GENDER_ENUM[this.gender]}${this.color}狗,名字是:${this.name},今年${this.age}岁了。`);
  }
}
​
const cat = new Cat('Tom', 0, 6, '灰');
const dog = new Dog('Lucy', 1, 5, '白');
cat.hello();
dog.hello();

我们额外抽象出来一个类 Animal,这个类具备一切动物的共同属性:姓名、性别、年龄以及颜色。然后我们通过 extends 关键字来实现 CatDog 两个类继承动物,这就是最简单的继承。通过控制台输出,我们也可以看到是正常运行的。关于继承,这里也有一些注意事项:

  • 一个子类只能继承一个父类,不可以继承多个父类。
  • 你可以根据需求扩展继承链,设置父类、祖父类、太祖父类等。
  • 如果子类想要从父类继承一些属性,必须首先使用super()函数并将父类属性传参,然后再设定子类自己的属性。
  • 所有父类的方法和属性全部会被子类继承,子类并不能决定继承哪些,不继承哪些

关于第三点,简单解释一下就是super关键字在子类里必须出现,简单理解就是把父类的构造函数 constructor 在子类执行一遍,这样我们在子类里就能访问父类的属性和方法了。

class Dog extends Animal {
  constructor(name, gender, age, color, skill) {
    super(name, gender, age, color);
    this.skill = skill;
  }
​
  hello() {
    console.log(`我是一只${GENDER_ENUM[this.gender]}${this.color}狗,名字是:${this.name},今年${this.age}岁了。`);
  }
​
  show() {
    console.log(`我是一个有技能的狗,我的技能是:${this.skill}`);
  }
}
​
const dog = new Dog('Lucy', 1, 5, '白', '导盲犬');
dog.hello(); // 我是一只雌性小白狗,名字是:Lucy,今年5岁了。
dog.show(); // 我是一个有技能的狗,我的技能是:导盲犬

上面例子解释了子类除了继承父类的属性,还可以有自己独特的属性,但是在构造函数里,必须先把父类属性传参过后才能设定自己的属性,这点大家在实现的时候需要注意。

这里也是我们上面提到过的,类只是我们认为对事物进行抽象的结果,你如果能站在更高的层次抽象,那么你的代码肯定更容易维护,这个就是根据经验和积累而来的了。

封装

封装是面向对象编程的另一个原则,它表示一个类哪些属性可以暴露给外面,哪些不希望暴露给外面,封装是通过公有/私有_属性/方法来实现的。

不过 JavaScript 内部所有对象的属性和方法默认为公共的,也就是在外部是可以获取到所有的属性和方法的,但是我们可以通过下面这种方式在 JS 内部来实现封装的概念。

class Dog extends Animal {
  #birth;

  constructor(name, gender, age, color, skill, birth) {
    super(name, gender, age, color);
    this.skill = skill;
    this.#birth = birth;
  }

  hello() {
    console.log(`我是一只${GENDER_ENUM[this.gender]}${this.color}狗,名字是:${this.name},今年${this.age}岁了。`);
  }

  show() {
    console.log(`我是一个有技能的狗,我的技能是:${this.skill}`);
  }

  tellBirth() {
    console.log(`我的出生日期是: ${this.#birth}。`)
  }
}
const dog = new Dog('Lucy', 1, 5, '白', '导盲犬', '1993-08-26');
dog.tellBirth(); // 我的出生日期是: 1993-08-26。
let birth = dog.#birth; // SyntaxError: Private field '#birth' must be declared in an enclosing class

image-20230203152402410

可以看到,我们将 Dog 类的出生日期封装到了类内部,初始化过后,外部无法再获取到 Dog 的 #birth 属性,也就是它变成了一个私有属性。并且你在打印这个类的时候,也无法看到这个属性:

Dog {
  name: 'Lucy',
  gender: 1,
  age: 5,
  color: '白',
  skill: '导盲犬'
}

使用封装可以避免“暴露”我们不想暴露的信息,因此当我们需要某个特定的属性或者方法只在对象的内部运作,并且不暴露在外部时,封装就能够发挥作用。

抽象

抽象是面向对象编程的另一个原则,但是这个原则可以说就是一个原则。(是不是听君一席话如听一席话~)啥意思呢?就是面向对象编程的实现是通过类,类本身就是符合抽象原则的,它帮助我们实现程序的设计与分离。还有一个很重要的一点就是,抽象原则建议开发者要做的事就是,你不应该把类内部所有的属性和方法都暴露出来,如果他们不会被外部使用,那么就应该使用封装将它们封装到内部。

可能还是没讲明白,没关系,因为即使不讲它,你也知道抽象是啥意思,只不过没办法用代码来表示而已,或者说,上面我们提到的命令式编程到函数式编程的演变过程,也是符合抽象原则的。抽象只是程序设计语言的一个通用特性。

多态

最后一个原则,就是多态,这个概念可能也不是很好说清楚,但是可以用代码来解释,甚至用代码才更容易理解。

const GENDER_ENUM = {
  0: '雄性',
  1: '雌性',
}

class Animal {
  constructor(name, gender, age, color) {
    this.name = name;
    this.gender = gender;
    this.age = age;
    this.color = color;
  }

  greet() {
    console.log(`我是动物,我会叫!`);
  }
}

class Cat extends Animal {
  constructor(name, gender, age, color) {
    super(name, gender, age, color);
  }

  greet() {
    console.log(`我是小猫,喵喵喵!`);
  }
}

class Dog extends Animal {
  #birth;

  constructor(name, gender, age, color, skill, birth) {
    super(name, gender, age, color);
    this.skill = skill;
    this.#birth = birth;
  }
}
cat.greet(); // 我是小猫,喵喵喵!
dog.greet(); // 我是动物,我会叫!

可以看到,我们父类中定义了 greet 方法,在 Cat 类中也定义了 greet 方法,我们在调用 cat.greet() 的时候,输出的就是 Cat 类内部实现的方法,而我们在 Dog 类中没有重写 greet 方法,因此输出的依然是从父类 Animal 那里继承的内容。所以,多态其实就是 “多种形态”,在类的表现形式下表示在不同的特定条件下使用一种方法返回不同的值。

这么一听感觉和函数重载很像啊,该怎么说呢?这两个概念不是在同一背景下产生的,多态是面向对象编程的一个原则,而函数重载是程序在运行时判断调用哪个具体的方法。不过从定义上来说,重载是多态的子集,因为名称相同,调用参数不同导致执行结果不同,这也是多态的一个场景。

总结

本文关于编程范式以及面向对象编程,属于浅尝辄止,如果大家想更深入的了解他们,建议去阅读相关书籍和文档,同时关于编程范式和面向对象编程,还是要通过实际需求去实践总结,才是最佳的答案。好记性不如烂笔头,同理你知道了他们但是从来没有使用在代码里,也就没有办法理解他们各自的特性以及适用场景了。