编程无需类?探索原型编程的奇妙之旅

2024-01-09 09:52:01

面向原型编程是一个大家很少提到的概念,不过它的代表语言特别流行,就是大家常用的Javascript。面向原型编程是其实是面向对象编程的一个特例,那么这种编程范式解决了什么问题、有什么特点,为什么要单独掕出来讲呢?本文就带大家来一探究竟。

1. 基于原型的编程简介

1.1 原型编程是啥?

想象一下,你有一块黏土,你可以用它塑造一个小猫咪的形状,这个小猫咪就是一个对象。现在,如果你想要更多相似的小猫咪,你可以用这个已经塑造好的小猫咪作为模板,继续塑造更多小猫咪。这就是基于原型编程的核心思想,你不需要先定义黏土的“类别”,而是直接塑造对象,然后复制它就行了。

1.2 不需要“类”就能创建对象

在基于原型的编程中,世界是由一系列“对象”组成的,它们都是独一无二的。你可以想象成,每个对象都是一个独特的雪花,它们不需要通过“雪花类”来生成,而是直接从现有的雪花中复制而来。我们可以用一个词“classless”来记住这个特点。

2. 为什么要用原型编程?

2.1 脆弱基类问题

想象你是一名建筑师,在设计一栋大楼时,你必须非常小心地选择基础材料。如果基础不稳固,整栋大楼都有可能倒塌。在编程世界里,想要在一开始就把所有问题洞察清楚往往很难做到,如果你使用类来构建程序的基础,那么当基类(就像建筑中的基础)出了问题,你修修补补的时候,所有依赖它的子类都会受到影响,这就是所谓的“脆弱基类问题”。

比如在C++这样的预编译语言中,子类可以从超类单独编译,对超类的变更会破坏预编译的子类方法。子类和超类之间的联系就像是用超强胶水粘在一起的乐高积木。一旦超类改变了形状,所有粘在它上面的子类都可能变得不再适合,导致整个结构破碎。

有一些语言试图解决这个问题:比如Smalltalk这样的动态语言,你可以在程序运行时随心所欲地改变类的定义。这听起来很酷,但就像在一辆行驶中的汽车上更换轮胎,非常危险且容易出错。

2.2 消除类和对象的二元性

原型编程像是在玩乐高积木,但没有预先定义的形状。你可以随时根据需要塑造或改变积木的形状,而不用担心这会影响到其他的积木。

3. 如何进行基于原型的编程?

传统的面向对象编程先定义行为和结构,也就是类。是一种关注分类以及类之间关系的开发模型。

3.1 一般方法

  • 从对象实例开始:在原型编程中,一切从对象开始。想象你进入了一个满是玩具的房间,而不是从玩具的说明书开始。你先找到一个喜欢的玩具,然后去制造更多相似的玩具。
  • 创建原型对象:在原型编程里,你不需要一个玩具制造机(类),你只需要一个现成的玩具(原型对象)。找到一个原型后,你可以无限制地复制它,每个复制品都可以根据需要进行调整和改造。
  • 修改原型:在基于原型的语言中,修改原型就像是调整灯光一样简单。你可以随时根据需要增加亮度或改变颜色,而不会影响到其他的灯。

3.2 基本规则

  1. 所有的数据都是对象,就像在一个玩具屋里,一切都是玩具。
  2. 要得到一个对象,你不需要生产线(实例化类),只需要复制一个已有的玩具(对象)。
  3. 对象知道它们是从哪个玩具复制来的(记住它的原型)。
  4. 如果一个玩具不能做某件事,它会请教它的模板玩具(委托给它的原型)。

4. 面向原型编程语言 - JavaScript

4.1 JavaScript中的原型

举个例子吧。假设我们有一个叫做“玩具士兵”的小玩具。这个玩具士兵会说“前进!”和“后退!”。现在,我们想要一个新的玩具士兵,希望它能多说一句“向左转!”。

在JavaScript中,我们可以这么做:

function ToySoldier() {
  this.name = "Soldier";
  this.speakForward = function() {
    console.log(this.name + '前进!');
  };
  this.speakBackward = function() {
    console.log(this.name + '后退!');
  };
}

// 创建一个玩具士兵
var originalSoldier = new ToySoldier();

// 现在,我们想要一个改进版的士兵
var improvedSoldier = Object.create(originalSoldier);
improvedSoldier.speakLeftTurn = function() {
  console.log(this.name + '向左转!');
};

// 使用原始士兵
originalSoldier.speakForward(); // 输出:前进!
originalSoldier.speakBackward(); // 输出:后退!

// 使用改进版的士兵
improvedSoldier.speakForward(); // 输出:前进!(继承自原始士兵)
improvedSoldier.speakLeftTurn(); // 输出:向左转!(新增功能)

可以看到改进版的士兵improvedSoldier是以originalSoldier为基础创建的,originalSoldier是improvedSoldier的原型;而originalSoldier是ToySoldier这个构造函数的实例,originalSoldier的原型是ToySoldier的prototype属性。prototype是什么?下边马上会讲。

在JavaScript中,每个对象的内部都链接到另一个对象,称为其原型。这个原型也有自己的原型,以此类推,直到一个对象的原型为null为止。这种关系通常被称为原型链。

4.2 __proto__ 和 prototype

上边我们谈到谁是谁的原型,那么怎么查看对象的原型呢?通过 __proto__。

__proto__

它是对象的隐藏属性,它指向了对象的原型,决定了对象可以从原型继承什么。

使用 new xxx 来创建一个实际的对象时,会让对象的__proto__指向 xxx.prototype。对于上文示例,下面的表达式是成立的:

originalSoldier.__proto__ === ToySoldier.prototype

使用 Object.create 创建新对象时,其 __proto__指向传入的原型对象,对于上文示例,下面的表达式是成立的:

improvedSoldier.__proto__ === originalSoldier

虽然这个属性在很多Javascript的运行环境中都存在,但它不是一个标准的属性,建议使用 Object.getPrototypeOf(obj) 来获取对象的原型,这个方法更标准、更可靠。

prototype

上文我们提到originalSoldier的原型是ToySoldier的prototype属性。那么prototype是什么呢?

prototype是构造函数ToySoldier的一个属性,它定义了通过这个构造函数创建的所有对象共享的属性和方法。

在上边的例子中我们是在ToySoldier这个构造函数内部创建的属性和方法,使用new创建对象时,ToySoldier的属性和方法会一次性复制到新的对象中,然后再改变ToySoldier的属性和方法,新对象也不会受到任何影响。

还有另外一种声明属性和方法的方式,可以让原型链上的对象都受到影响,那就是通过构造函数的prototype属性进行定义。我们可以把上边的示例进行修改:

function ToySoldier() {
}

ToySoldier.prototype.name = "ToySoldier";
ToySoldier.prototype.speakForward = function() {
    console.log(this.name+'前进!');
};
ToySoldier.prototype.speakBackward = function() {
    console.log(this.name+'后退!');
};

// 创建一个玩具士兵
var originalSoldier = new ToySoldier();

// 现在,我们想要一个改进版的士兵
var improvedSoldier = Object.create(originalSoldier);
improvedSoldier.speakLeftTurn = function() {
  console.log(this.name+'向左转!');
};

ToySoldier.prototype.name="HelloSoldier";

// 使用原始士兵
originalSoldier.speakForward(); // 输出:前进!
originalSoldier.speakBackward(); // 输出:后退!
// 使用改进版的士兵
improvedSoldier.speakForward(); // 输出:前进!(继承自原始士兵)
improvedSoldier.speakLeftTurn(); // 输出:向左转!(新增功能)
improvedSoldier.speakBackward(); // 输出:后退!

在这个例子中,我们通过 ToySoldier.prototype.name="HelloSoldier" 更新了name的值,原型链上的 originalSoldier 和 improvedSoldier 拿到的都会是这个新值,因为此时他们是共享 name 属性的。这样内存的使用比较高效,因为不管你创建多少个对象,它们都会使用相同的属性和函数引用。

4.3 对象的两种表现形式

Object

普通对象,它是JavaScript中最基本的数据结构。它是属性(properties)的集合,属性可以是基本值、对象或者函数。可以把它看作是键值对的容器。

普通对象可以通过对象字面量、Object构造函数或者使用Object.create方法来创建,示例如下:

// 对象字面量
var person = {
  name: "Alice",
  age: 25,
  greet: function() {
    console.log("Hello, my name is " + this.name + "!");
  }
};

// 使用Object构造函数
var book = new Object();
book.title = "1984";
book.author = "George Orwell";

// 使用Object.create方法
var animal = Object.create(null); // 创建一个没有原型的对象
animal.name = "Lion";

注意:所有普通对象的原型是Object.prototype。

Function

在JavaScript中,函数本身也是对象,它们是Function类型的实例。函数对象不仅可以像普通对象一样拥有属性和方法,还可以被调用或执行。

函数对象通常通过函数声明、函数表达式或Function构造函数来创建。示例如下:

// 函数声明
function sayHello() {
  console.log("Hello!");
}

// 函数表达式
var add = function(a, b) {
  return a + b;
};

// 使用Function构造函数
var multiply = new Function('a', 'b', 'return a * b;');

当然函数对象不仅可以执行代码,还可以像普通对象一样拥有属性和方法,在上边士兵的例子中我们已经见识过了。

注意:函数对象的原型是Function.prototype,它本身也是一个函数对象,最终继承自Object.prototype。

与传统面向对象概念的区别

在传统的面向对象编程(OOP)语言中,比如Java或C++,类(Class)是创建对象的模板。在这些语言中,对象通常是类的实例。

而JavaScript采用的是基于原型(Prototype)的面向对象编程。在JavaScript中,类的概念不是内置的,虽然ES6引入了class关键字,但它只是基于原型的语法糖。当我们说一个对象是某个对象的实例时,通常指的是通过构造函数创建的实例。

比如士兵例子中的 ToySoldier 是一个函数对象,实际上也是一个构造函数,通过 new 我们得到这个构造函数的新实例,也就是一个新对象,此时新对象的原型指向ToySoldier.prototype。

var originalSoldier = new ToySoldier();

5. 从心理学的角度看原型编程

在心理学中,一个原型是对某一类事物的最佳代表或“标准”形象。例如,当我们想到“鸟”的原型时,我们可能会想到一个具有典型鸟类特征的物种,如麻雀或鸽子,而不太可能想到企鹅或鸵鸟,尽管它们也是鸟类。

人们倾向于通过与已知原型的相似度来识别和分类新的对象。这意味着我们会将新遇到的事物与内心中的原型进行比较,以决定它属于哪个类别。例如,当我们看到一种新的飞行生物时,我们会根据它的特征判断它是否与我们心目中的“鸟”原型相似,从而识别出它是一种鸟。

虽然心理学中的原型和计算机中原型不是同一个概念,但也可以发现一些共通之处。在心理学中,原型帮助我们理解和分类新的信息;在计算机科学中,原型(尤其是JavaScript中的原型)允许对象继承特性,从而创建出具有相似特征的新对象。在两种情况下,原型都是一个参照点,帮助我们建立对新事物的理解。


为了方便交流,我创建了一个微/信/公/众/号:萤火架构,欢迎关注交流。

基于原型的编程就像是一场不需要预先设计蓝图的建筑盛宴。它允许你在一个充满可能性的世界中自由地塑造和重塑对象,而不必担心会受到脆弱基类问题的束缚。

在这个灵活的编程世界中,JavaScript作为其中的佼佼者,提供了一个完美的舞台,让开发者能够用最直观的方式去理解和应用原型编程的魅力。

文章来源:https://blog.csdn.net/bossma/article/details/135470740
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。