面向原型编程是一个大家很少提到的概念,不过它的代表语言特别流行,就是大家常用的Javascript。面向原型编程是其实是面向对象编程的一个特例,那么这种编程范式解决了什么问题、有什么特点,为什么要单独掕出来讲呢?本文就带大家来一探究竟。
想象一下,你有一块黏土,你可以用它塑造一个小猫咪的形状,这个小猫咪就是一个对象。现在,如果你想要更多相似的小猫咪,你可以用这个已经塑造好的小猫咪作为模板,继续塑造更多小猫咪。这就是基于原型编程的核心思想,你不需要先定义黏土的“类别”,而是直接塑造对象,然后复制它就行了。
在基于原型的编程中,世界是由一系列“对象”组成的,它们都是独一无二的。你可以想象成,每个对象都是一个独特的雪花,它们不需要通过“雪花类”来生成,而是直接从现有的雪花中复制而来。我们可以用一个词“classless”来记住这个特点。
想象你是一名建筑师,在设计一栋大楼时,你必须非常小心地选择基础材料。如果基础不稳固,整栋大楼都有可能倒塌。在编程世界里,想要在一开始就把所有问题洞察清楚往往很难做到,如果你使用类来构建程序的基础,那么当基类(就像建筑中的基础)出了问题,你修修补补的时候,所有依赖它的子类都会受到影响,这就是所谓的“脆弱基类问题”。
比如在C++这样的预编译语言中,子类可以从超类单独编译,对超类的变更会破坏预编译的子类方法。子类和超类之间的联系就像是用超强胶水粘在一起的乐高积木。一旦超类改变了形状,所有粘在它上面的子类都可能变得不再适合,导致整个结构破碎。
有一些语言试图解决这个问题:比如Smalltalk这样的动态语言,你可以在程序运行时随心所欲地改变类的定义。这听起来很酷,但就像在一辆行驶中的汽车上更换轮胎,非常危险且容易出错。
原型编程像是在玩乐高积木,但没有预先定义的形状。你可以随时根据需要塑造或改变积木的形状,而不用担心这会影响到其他的积木。
传统的面向对象编程先定义行为和结构,也就是类。是一种关注分类以及类之间关系的开发模型。
举个例子吧。假设我们有一个叫做“玩具士兵”的小玩具。这个玩具士兵会说“前进!”和“后退!”。现在,我们想要一个新的玩具士兵,希望它能多说一句“向左转!”。
在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为止。这种关系通常被称为原型链。
上边我们谈到谁是谁的原型,那么怎么查看对象的原型呢?通过 __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 属性的。这样内存的使用比较高效,因为不管你创建多少个对象,它们都会使用相同的属性和函数引用。
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();
在心理学中,一个原型是对某一类事物的最佳代表或“标准”形象。例如,当我们想到“鸟”的原型时,我们可能会想到一个具有典型鸟类特征的物种,如麻雀或鸽子,而不太可能想到企鹅或鸵鸟,尽管它们也是鸟类。
人们倾向于通过与已知原型的相似度来识别和分类新的对象。这意味着我们会将新遇到的事物与内心中的原型进行比较,以决定它属于哪个类别。例如,当我们看到一种新的飞行生物时,我们会根据它的特征判断它是否与我们心目中的“鸟”原型相似,从而识别出它是一种鸟。
虽然心理学中的原型和计算机中原型不是同一个概念,但也可以发现一些共通之处。在心理学中,原型帮助我们理解和分类新的信息;在计算机科学中,原型(尤其是JavaScript中的原型)允许对象继承特性,从而创建出具有相似特征的新对象。在两种情况下,原型都是一个参照点,帮助我们建立对新事物的理解。
为了方便交流,我创建了一个微/信/公/众/号:萤火架构,欢迎关注交流。
基于原型的编程就像是一场不需要预先设计蓝图的建筑盛宴。它允许你在一个充满可能性的世界中自由地塑造和重塑对象,而不必担心会受到脆弱基类问题的束缚。
在这个灵活的编程世界中,JavaScript作为其中的佼佼者,提供了一个完美的舞台,让开发者能够用最直观的方式去理解和应用原型编程的魅力。