Skip to content
Scroll to top↑

Javascript和Lua中的原型

JS的原型机制

prototype__proto__傻傻分不清楚?MDN的这篇文档有一些介绍。其实记住一句话就够用了:派生类的prototype和父类的prototype相同,实例的__proto__和构造类的prototype相同。不过,这里的“相同”隐含了可以沿着原型链追溯的意思,并不一定真的完全相等。因为如果我们用派生类的实例去instanceof基类,结果应当也是true

INFO

由于__proto__是一个已经废弃的属性,下文我们使用官方推荐的APIObject.getPrototypeOfObject.setPrototypeOf来操作之。

js
function Base() {}

Base.prototype.sth = 42;

const foo = new Base();

console.log(foo.sth); // 42

完全可以把new看作是语法糖,自己实现“继承”:

js
function Base() {}

Base.prototype.sth = 42;

const foo = {};

// foo.__proto__ = Base.prototype
Object.setPrototypeOf(foo, Base.prototype);

console.log(foo.sth); // 42

同样的,class语法也是语法糖。要实现派生,核心是让派生类的prototype上具有的属性与基类prototype上的相同,可以直接设置派生类的prototype等于基类的,但更好的方式是充分利用原型链机制,让派生类Derivedprototype.__proto__和基类Baseprototype相等。这样,在Derived的实例bar上查找属性时,首先会通过bar.__proto__找到Derived.prototype,进一步沿着链向上到Base.prototype中查找:

js
class Derived extends Base {}

const bar = new Derived();

console.log(bar.sth); // 42
console.log(Object.getPrototypeOf(Derived.prototype) == Base.prototype); // true

Desugar:

js
function Derived() {}

// 比 Derived.prototype = Base.prototype 更好
Object.setPrototypeOf(Derived.prototype, Base.prototype);

const bar = new Derived();

console.log(bar.sth); // 42

TIP

某种意义上,我们可以将Derived.prototype看成是Base的实例,因为现在有如下等价关系:

js
Derived.prototype.__proto__ === Base.prototype;
bar.__proto__ === Base.prototype;

对比下可以发现Derived.prototypebar在地位上是相同的。因此,也可以这样实现继承:

js
Derived.prototype = new Base();

写这篇博客的起因是遇到了一个BUG,在试图修改并重用对象的__proto__时,不小心污染了Function.prototype。因此有这种需求的话要注意两点,一要避免丢失原型上本来有的东西,二要避免污染全局空间

js
// Base.__proto__不再是Function.prototype,意味着来自Function.prototype的bind、apply等方法都丢失了
Object.setPrototypeOf(Base, { x: 2 });

// Function.prototype.x = 2,污染了全局空间
Object.assign(Object.getPrototypeOf(Base), { x: 2 });

一种解决方法是先将对象的__proto__克隆一份,在克隆出来的原型上进行修改,最后再重设为对象的原型:

js
const proto = Object.getPrototypeOf(Base);
const newProto = Object.assign(Object.create(proto), { x: 2 });

Object.setPrototypeOf(Base, newProto);

这里使用Object.create也是必要的,因为常见的对象拷贝的方法不会拷贝诸如Function.prototype上的bind等固有属性(可以使用Object.getOwnPropertyNames查看固有属性):

js
p = Object.getPrototypeOf(Base);
x = {...p};
y = Object.assign(Object.create(null), p);
z = Object.create(p);

p.bind; // ƒ bind() { [native code] }
x.bind; // undefined
y.bind; // undefined
z.bind; // ƒ bind() { [native code] }

Lua中的对比

最近写了一点Lua,不妨做个类比。在Lua中有个与原型对象很相似的东西叫做元表(metatable),一个table有了元表,我们就可以通过在元表上定义一些元方法(metamethods)来控制table的行为。

这里的关键在于元表上的元方法,只有定义合适的元方法才能让元表起到和原型对象类似的作用。例如沿着原型链向上的查找机制,如果仅仅这样写是不够的:

lua
local a = { x = 2 }
local b = {}
local c = {}

setmetatable(c, b)
setmetatable(b, a)

print(c.x) -- nil

在Lua中,get和set涉及到的元方法是__index__newindex,在表上查找元素时,若没找到,会追溯到其元表,并根据元表的__index确定进一步查找的方式。__index的值可以是另一张表,此时会将该表作为新的查找对象。因此要让对c.x的访问能够向上查找到a,只需要给ba添加__index方法并指向自身即可:

lua
local a = { x = 2 }
local b = {}
local c = {}

setmetatable(c, b)
b.__index = b
setmetatable(b, a)
a.__index = a

print(c.x) -- 2

换句话说,上面先设置metatable再设置__index的过程与Javascript中的setPrototype异曲同工:

Lua
local function setPrototype(t, p)
  setmetatable(t, p)
  p.__index = p
end

现在来看看如何实现继承,《Programming in Lua》在讲到继承的时候有这么一段代码:

lua
Account = {balance = 0}

function Account:new (o)
  o = o or {}
  setmetatable(o, self)
  self.__index = self
  return o
end

function Account:deposit (v)
  self.balance = self.balance + v
end

SpecialAccount = Account:new()
s = SpecialAccount:new()

s.deposit(1)

这里用词new其实很迷惑,它的语义更像是派生,应该叫做deriveextend啥的,因为这里SpecialAccount = Account:new()的作用和class SpecialAccount extends Account差不多,而balance更像是一个静态属性。各实例在调用修改self.balance的时候能够互不干扰,完全是因为原型链的查找顺序是先从自己开始的,即首次访问self.balance的时候发现自己的o上没有,于是从metatable中拿到Accountbalance,然后在赋值的时候在自己的o上创建了一个副本,以后self.balance操作的是自己的了。

将静态方法和实例方法混为一谈有点不符合我们对OOP的认知,我们更习惯的new方法类似这样:

lua
SpecialAccount = Account:extend() -- 作用等同于 class SpecialAccount extends Account

s1 = Account:new()
s2 = SpecialAccount:new()

s1:deposit(1)
s2:deposit(2)

s1:new() -- error, no such method "new"

那么具体要怎么实现new方法和extend方法呢?可以从JS中受到启发,还是把握住“派生类的prototype和父类的prototype相同,实例的__proto__和构造类的prototype相同”这句话。

首先制作一个prototype,将类方法和实例方法区分开来。注意本文把newextend这些类对象上具有的方法称为类方法,常规的成员方法称为实例方法:

lua
Account.prototype = {balance = 0}

function Account.prototype:deposit(v)
  self.balance = self.balance + v
end

这里我们依然保留了利用原型链查找机制的静态默认值{balance = 0},如果要更OOP一点的话,可以在下面的构造函数new中设置o.balance = 0

创建实例的要点是让实例的__proto__等同于Account.prototype,并且能够向上在__proto__上查找属性:

lua
function Account:new() -- 注意不是Account.prototype:new
  local o = {
    __proto__ = self.prototype
  }

  setPrototype(o, o.__proto__)

  return o
end

创建派生类的要点是让派生类的prototype和基类prototype一致,这里直接用new可能有点hack,参见前面的 tip。同时让派生类获取Account上面的方法:

lua
function Account:extend()
  -- 让派生类的`prototype`和基类`prototype`一致
  local o = { prototype = self:new() }

  -- 获取Account上面的方法
  setPrototype(o, self)

  return o
end

完整的代码如下:

lua
local function setPrototype(t, p)
  setmetatable(t, p)
  p.__index = p
end

Account = { prototype = { balance = 0 } }

function Account.prototype:deposit(v)
  self.balance = self.balance + v
  print(self.balance)
end

function Account:new() -- 注意不是Account.prototype:new
  local o = {
    __proto__ = self.prototype
  }

  setPrototype(o, o.__proto__)

  return o
end

function Account:extend()
  -- 让派生类的`prototype`和基类`prototype`一致
  local o = { prototype = self:new() }

  -- 获取Account上面的方法
  setPrototype(o, self)

  return o
end

local s1 = Account:new()

print(s1.__proto__ == Account.prototype) -- true

SpecialAccount = Account:extend() -- 作用等同于 class SpecialAccount extends Account

print(SpecialAccount.prototype.__proto__ == Account.prototype) -- true

local s2 = SpecialAccount:new()

s1:deposit(1) -- 1
s2:deposit(2) -- 2

-- s1:new() -- error: attempt to call method 'new' (a nil value)

Account.prototype.foo = function() print(42) end

s2:foo() -- 42

上述实现有个微妙的问题:实例方法中的self始终指向的是Account.prototype,没办法拿到类Account自己,这时,依然可以模仿JS,在Account.prototype上加一个属性constructor,让其指向类对象自身。

TIP

这里有基于本文沉淀的一套原型链实现,支持newextendisInstanceisDerived等方法。