面向对象编程

面向对象编程 (OOP) 是一种编程范式,它是许多编程语言(包括 Java 和 C++)的基础。本文将概述 OOP 的基本概念。我们将描述三个主要概念:类和实例继承封装。目前,我们将不特别参考 JavaScript 来描述这些概念,因此所有示例都使用伪代码给出。

注意:准确地说,这里描述的特性是一种特定风格的 OOP,称为基于类的或“经典”OOP。当人们谈论 OOP 时,通常指的就是这种类型。

之后,在 JavaScript 中,我们将探讨构造函数和原型链如何与这些 OOP 概念相关联,以及它们之间的差异。在下一篇文章中,我们将探讨 JavaScript 中一些额外的特性,这些特性使得实现面向对象程序变得更加容易。

预备知识 熟悉 JavaScript 基础(尤其是对象基础)和本模块先前课程中涵盖的面向对象 JavaScript 概念。
学习成果
  • 面向对象编程 (OOP) 概念:类、实例、继承和封装。
  • 这些 OOP 概念如何应用于 JavaScript,以及它与 Java 或 C++ 等语言之间的差异。

面向对象编程是将系统建模为对象的集合,其中每个对象代表系统的某个特定方面。对象既包含函数(或方法)又包含数据。对象向其他想要使用它的代码提供公共接口,但维护自己的私有内部状态;系统的其他部分不必关心对象内部发生了什么。

类和实例

当我们用 OOP 中的对象来建模问题时,我们创建抽象定义来表示我们希望在系统中拥有的对象类型。例如,如果我们正在模拟一所学校,我们可能希望有代表教授的对象。每个教授都有一些共同的属性:他们都有一个名字和一门他们教授的科目。此外,每个教授都可以做某些事情:他们都可以批改论文,并且在学年开始时向学生介绍自己。

因此,Professor 可以是我们系统中的一个。类的定义列出了每个教授拥有的数据和方法。

在伪代码中,Professor 类可以这样编写

class Professor
    properties
        name
        teaches
    methods
        grade(paper)
        introduceSelf()

这定义了一个 Professor 类,具有

  • 两个数据属性:nameteaches
  • 两个方法:grade() 用于批改论文,introduceSelf() 用于自我介绍。

类本身不执行任何操作:它是一种创建该类型具体对象的模板。我们创建的每个具体教授都称为 Professor 类的实例。创建实例的过程由一个特殊函数执行,该函数称为构造函数。我们将值传递给构造函数,用于在新实例中初始化任何内部状态。

通常,构造函数作为类定义的一部分编写,并且通常与类本身同名

class Professor
    properties
        name
        teaches
    constructor
        Professor(name, teaches)
    methods
        grade(paper)
        introduceSelf()

这个构造函数接受两个参数,因此我们可以在创建新的具体教授时初始化 nameteaches 属性。

现在我们有了一个构造函数,我们可以创建一些教授。编程语言通常使用关键字 new 来表示正在调用构造函数。

js
walsh = new Professor("Walsh", "Psychology");
lillian = new Professor("Lillian", "Poetry");

walsh.teaches; // 'Psychology'
walsh.introduceSelf(); // 'My name is Professor Walsh and I will be your Psychology professor.'

lillian.teaches; // 'Poetry'
lillian.introduceSelf(); // 'My name is Professor Lillian and I will be your Poetry professor.'

这会创建两个对象,它们都是 Professor 类的实例。

继承

假设在我们的学校里,我们也想代表学生。与教授不同,学生不能批改论文,不教授特定科目,并且属于特定的年级。

然而,学生确实有名字,并且可能也想介绍自己,所以我们可能会这样写出学生类的定义

class Student
    properties
        name
        year
    constructor
        Student(name, year)
    methods
        introduceSelf()

如果我们能够表示学生和教授共享一些属性的事实,或者更准确地说,在某种程度上,他们是同一种事物,那将很有帮助。继承使我们能够做到这一点。

我们首先观察到学生和教授都是人,人有名字并且想介绍自己。我们可以通过定义一个新的类 Person 来建模这一点,在该类中我们定义了人的所有共同属性。然后,ProfessorStudent 都可以从 Person 派生,添加它们的额外属性

class Person
    properties
        name
    constructor
        Person(name)
    methods
        introduceSelf()

class Professor : extends Person
    properties
        teaches
    constructor
        Professor(name, teaches)
    methods
        grade(paper)
        introduceSelf()

class Student : extends Person
    properties
        year
    constructor
        Student(name, year)
    methods
        introduceSelf()

在这种情况下,我们会说 PersonProfessorStudent超类父类。相反,ProfessorStudentPerson子类

你可能会注意到 introduceSelf() 在所有三个类中都有定义。原因是虽然所有人都想介绍自己,但他们这样做的方式不同

js
walsh = new Professor("Walsh", "Psychology");
walsh.introduceSelf(); // 'My name is Professor Walsh and I will be your Psychology professor.'

summers = new Student("Summers", 1);
summers.introduceSelf(); // 'My name is Summers and I'm in the first year.'

我们可能有一个默认的 introduceSelf() 实现,用于不是学生教授的人

js
pratt = new Person("Pratt");
pratt.introduceSelf(); // 'My name is Pratt.'

这种特性——当一个方法在不同类中具有相同的名称但有不同的实现时——称为多态性。当子类中的方法替换超类的实现时,我们说子类覆盖了超类中的版本。

封装

对象向其他想要使用它们的代码提供接口,但维护自己的内部状态。对象的内部状态是私有的,这意味着它只能由对象自己的方法访问,而不能由其他对象访问。将对象的内部状态保持私有,并通常在其公共接口和私有内部状态之间做出明确划分,称为封装

这是一个有用的特性,因为它使程序员能够更改对象的内部实现,而无需查找和更新所有使用它的代码:它在该对象和系统其余部分之间创建了一种防火墙。

例如,假设二年级及以上的学生可以学习射箭。我们可以通过公开学生的 year 属性来实现这一点,其他代码可以检查该属性来决定学生是否可以参加课程

js
if (student.year > 1) {
  // allow the student into the class
}

问题是,如果我们决定改变允许学生学习射箭的标准——例如,还需要家长或监护人同意——我们将需要更新系统中执行此测试的每个地方。最好在 Student 对象上有一个 canStudyArchery() 方法,该方法在一个地方实现逻辑

class Student : extends Person
    properties
       year
    constructor
       Student(name, year)
    methods
       introduceSelf()
       canStudyArchery() { return this.year > 1 }
js
if (student.canStudyArchery()) {
  // allow the student into the class
}

这样,如果我们想改变学习射箭的规则,我们只需要更新 Student 类,所有使用它的代码仍然可以正常工作。

在许多 OOP 语言中,我们可以通过将某些属性标记为 private 来阻止其他代码访问对象的内部状态。如果对象外部的代码尝试访问它们,这将生成错误

class Student : extends Person
    properties
       private year
    constructor
        Student(name, year)
    methods
       introduceSelf()
       canStudyArchery() { return this.year > 1 }

student = new Student('Weber', 1)
student.year // error: 'year' is a private property of Student

在不强制执行此类访问的语言中,程序员使用命名约定,例如以下划线开头命名,以表明该属性应被视为私有。

OOP 与 JavaScript

在本文中,我们描述了 Java 和 C++ 等语言中实现的基于类的面向对象编程的一些基本特性。

在前两篇文章中,我们探讨了 JavaScript 的几个核心特性:构造函数原型。这些特性当然与上述某些 OOP 概念有一定关系。

  • JavaScript 中的构造函数为我们提供了一种类似于类定义的东西,使我们能够在一个地方定义对象的“形状”,包括它包含的任何方法。但原型也可以在这里使用。例如,如果一个方法定义在构造函数的 prototype 属性上,那么使用该构造函数创建的所有对象都通过它们的原型获得该方法,我们不需要在构造函数中定义它。

  • 原型链似乎是实现继承的自然方式。例如,如果我们有一个原型是 PersonStudent 对象,那么它可以继承 name 并覆盖 introduceSelf()

但是值得理解这些特性与上面描述的“经典”OOP 概念之间的区别。我们将在这里强调其中几个。

首先,在基于类的 OOP 中,类和对象是两个独立的构造,对象总是作为类的实例创建。此外,用于定义类(类语法本身)的特性与用于实例化对象(构造函数)的特性之间存在区别。在 JavaScript 中,我们可以而且经常在没有任何单独类定义的情况下创建对象,无论是使用函数还是对象字面量。这使得使用对象比在经典 OOP 中轻量得多。

其次,尽管原型链看起来像继承层次结构,并且在某些方面表现得像它一样,但在其他方面它有所不同。当实例化一个子类时,会创建一个单独的对象,该对象结合了子类中定义的属性和层次结构中更高层定义的属性。使用原型时,层次结构的每个级别都由一个单独的对象表示,它们通过 __proto__ 属性链接在一起。原型链的行为更像是委托,而不是继承。委托是一种编程模式,其中一个对象在被要求执行任务时,可以自己执行任务或要求另一个对象(其委托者)代表它执行任务。在许多方面,委托是一种比继承更灵活的组合对象的方式(例如,可以在运行时更改或完全替换委托者)。

尽管如此,构造函数和原型可用于在 JavaScript 中实现基于类的 OOP 模式。但是直接使用它们来实现继承等功能是很棘手的,因此 JavaScript 提供了额外的功能,建立在原型模型之上,更直接地映射到基于类的 OOP 概念。这些额外功能是下一篇文章的主题。

总结

本文描述了基于类的面向对象编程的基本特性,并简要介绍了 JavaScript 构造函数和原型与这些概念的比较。

在下一篇文章中,我们将探讨 JavaScript 为支持基于类的面向对象编程而提供的功能。