云计算百科
云计算领域专业知识百科平台

【Lua进阶系列】Lua 面向对象编程:从 Table 到类:构造函数、继承、多态、多继承与封装

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

🔥 本文专栏:Lua 🌸作者主页:努力努力再努力wz

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

💪 今日博客励志语录:有些路,只有走过之后才能知道值不值得;有些答案,只有经历之后才有资格回答。


思维导图

Lua 没有原生的 class / 类继承语法
↓
但已有 Table、函数、元表、闭包等基础机制
↓
Table 可以同时保存状态和函数
↓
直接用 Table 表示对象
↓
多个对象拥有相同的公共行为
↓
把公共方法提取到一个 Table(类)
↓
实例自身保存状态;通过 __index 查找公共方法
↓
重复创建对象不够方便
↓
通过普通函数封装创建、初始化、元表关联(构造函数)
↓
子类需要复用父类的行为
↓
继续利用 __index 建立多级方法查找链(继承)
↓
子类需要不同的实现
↓
同名方法优先命中子类(重写)
↓
同一调用方式作用于不同对象,执行不同行为(多态)
↓
一个类需要复用多个类的行为
↓
利用函数形式的 __index 自行决定查找顺序(多继承)
↓
对象内部某些状态不希望暴露给外部
↓
局部变量 + 闭包 + Upvalue(私密性)


一、从面向对象的基本认知开始:Lua 为什么没有 class,也能面向对象?

我们知道,对于 C++、Java 这样的语言,面向对象是语言直接提供的一套编程机制。以 C++ 为例,我们可以通过 class 定义类,利用访问控制符组织成员,并通过继承、虚函数等语言机制实现继承和运行时多态。

面向对象的三个经典特性,我们已经很熟悉了:封装、继承和多态。

但是在学习 Lua 时,首先需要区分两个概念:

语言原生提供类系统,和能够利用语言机制进行面向对象编程,并不是一回事。

Lua 没有内置的 class 关键字,也没有 C++ 那样专门声明类继承关系的语法。这并不意味着 Lua 无法实现面向对象。

因为 Lua 已经提供了一些基础能力:

  • Table:可以保存键值对,字段值可以是数字、字符串、函数或其他 Table。
  • 函数是值:函数可以保存到 Table 中,也可以作为普通值传递。
  • 元表与元方法:能够定制字段访问等操作的行为,尤其是 __index。
  • 词法作用域与闭包:可以隐藏内部状态,只向外暴露指定的访问接口。

我们要做的,其实是把这些已有机制组合起来,实现对象、类、构造函数、继承、多态等面向对象模型。

所以,对于 Lua 面向对象,不应该一开始就死记某一种 class 封装模板,而是先回答:每一种设计是在解决什么问题?又利用了 Lua 的哪一种原生机制?

二、Table 为什么天然具备表示对象的能力?

1. 对象可以理解为状态与行为的组合

先不讨论类,也不讨论继承。假设现在我们需要在游戏服务端表示一名玩家。

一个玩家通常具有状态,例如名称、等级、生命值;同时也具有行为,例如升级、受到伤害、显示信息。

我们知道,Table 本质上是一个键值对的集合,而函数又可以作为字段值,因此完全可以这样定义:

local player = {
name = "战士",
level = 10,
hp = 100,

levelUp = function(self)
self.level = self.level + 1
end
}

player:levelUp()
print(player.level) — 11

这里,name、level 和 hp 保存对象的状态,levelUp 则保存一个函数值,用于操作对象的状态。

也就是说,仅仅通过 Table + 函数,我们就已经创建出了一个具有状态和行为的对象。

2. 方法调用中的冒号和 self,本质上是什么?

之前学习函数时,我们已经认识过 Lua 的 : 语法。

例如:

function player:levelUp()
self.level = self.level + 1
end

可以理解为:

player.levelUp = function(self)
self.level = self.level + 1
end

这里的 self 不是一种特殊类型,而是一个普通的形参名。使用冒号定义方法时,Lua 会在形参列表前面隐式加入 self。

同样:

player:levelUp()

可以理解为:

player.levelUp(player)

因此,方法内部执行:

self.level = self.level + 1

实际上修改的就是传入对象 player 的 level 字段。

到这里应该建立第一个认知:

Lua 最简单的对象,就是一个同时保存属性和函数的 Table;所谓对象方法,本质上还是保存在 Table 中的函数值,而 self 让这个函数知道自己正在操作哪个对象。

三、既然可以直接创建对象,为什么还要引入“类”?

1. 直接创建多个对象,会产生重复的行为定义

假设我们需要创建一名战士和一名法师:

local p1 = {
name = "战士",
level = 10,
levelUp = function(self)
self.level = self.level + 1
end
}

local p2 = {
name = "法师",
level = 20,
levelUp = function(self)
self.level = self.level + 1
end
}

我们会发现,两个玩家的名称、等级不同,但是 levelUp 的实现逻辑完全一致:都是把当前玩家的等级加一。

这里还有一个细节:上面两个函数表达式分别创建了不同的函数值,所以:

print(p1.levelUp == p2.levelUp) — false

这意味着,如果我们一直采用这样的写法,那么每创建一个对象,都要重复组织一遍相同的公共行为。

我们真正希望的是:

每个对象保存自己的状态,而具有相同逻辑的公共方法只需要集中定义一次,然后被不同对象共同使用。

当然,仅仅为了共享一个函数,我们不一定非要引入类。例如:

local function levelUp(self)
self.level = self.level + 1
end

local p1 = { level = 10, levelUp = levelUp }
local p2 = { level = 20, levelUp = levelUp }

这里的两个对象就已经保存了同一个函数值。

所以,共享函数并不是类独有的能力。引入“类”这一抽象,更重要的意义在于:把同一类对象的公共行为统一组织起来,规范对象的创建过程,并为继承、方法重写等能力提供一个清晰的基础。

2. 用一个 Table 保存公共方法

我们可以定义一个 Player:

local Player = {}

function Player:levelUp()
self.level = self.level + 1
end

function Player:showInfo()
print(self.name, self.level)
end

这里的 Player 本质上仍然是一个普通 Table。

只不过我们在设计上赋予了它“类”的角色:集中保存玩家的公共方法。

接下来的问题是:如果实例自己不保存 levelUp、showInfo,它们又该如何取得这些函数值?

这就需要回到之前学习过的元表与 __index。

四、利用 __index 建立“实例状态独立,公共方法共享”的模型

1. 先使用单独的元表

local Player = {}

function Player:levelUp()
self.level = self.level + 1
end

function Player:showInfo()
print(self.name, self.level)
end

local mt = {
__index = Player
}

local p1 = setmetatable({ name = "战士", level = 10 }, mt)
local p2 = setmetatable({ name = "法师", level = 20 }, mt)

p1:levelUp()
p1:showInfo() — 战士 11
p2:showInfo() — 法师 20

这里存在三个不同的角色:

  • p1、p2:两个玩家实例,保存各自的状态。
  • Player:用于保存公共方法的类 Table。
  • mt:两个实例关联的元表,其中 __index 引用 Player。

那么,当我们执行:

p1:levelUp()

实际过程是什么?

首先,Lua 需要取得 p1.levelUp 对应的函数值。但是 p1 中只有 name 和 level,并不存在 levelUp 字段。

因此,字段查找会触发 p1 元表中的 __index。由于这个 __index 是 Table Player,于是 Lua 继续在 Player 中查找 levelUp,最终取得对应的函数值。

接着执行方法调用,将 p1 作为 self 传入:

Player.levelUp(p1)

所以,虽然执行的是 Player 里面保存的函数,但修改的仍然是 p1.level,不会影响 p2。

这里一定要区分两个过程:

  • __index 负责字段查找,让实例找到公共方法的函数值。
  • 找到函数以后,才按照普通的函数调用规则执行,并把实例传给 self。
  • 换句话说,__index 本身并不是专门控制函数调用的机制,它只是在找不到字段时提供查找规则。找到的是函数值,我们就可以调用;找到的是数字、字符串,也可以直接使用。

    2. 为什么常见写法是 Player.__index = Player?

    上面的 mt 只有一个主要用途:告诉实例,字段找不到时应该去 Player 中查找。

    但我们知道,元表本身也只是一个普通 Table。因此,我们完全可以让 Player 同时承担“类”和“元表”两个角色:

    local Player = {}
    Player.__index = Player

    function Player:levelUp()
    self.level = self.level + 1
    end

    local p1 = setmetatable({ level = 10 }, Player)
    p1:levelUp()
    print(p1.level) — 11

    这里:

    Player.__index = Player

    只是给 Player 添加了一个字段,让这个字段引用 Player 自己。

    而:

    setmetatable(p1, Player)

    则是把 Player 设置为 p1 的元表。

    于是 Player 同时承担两种角色:一方面保存公共方法,另一方面通过 __index 为实例提供方法查找规则。

    在这里插入图片描述

    这里并没有出现什么新的“类类型”。从 Lua 语言的角度看,Player 始终只是一个 Table,只不过在我们的设计中承担了不同职责。

    另外需要注意,单独执行 Player.__index = Player 并不会让所有 Table 自动拥有 Player 的方法。只有实例建立了相应的元表关系,后续的字段查找才会按照这套规则进行。

    五、构造函数:为什么需要统一封装对象的创建过程?

    1. 直接创建实例存在重复操作

    到这里,我们已经能够创建共享公共方法的玩家实例:

    local p1 = setmetatable({ name = "战士", level = 10 }, Player)
    local p2 = setmetatable({ name = "法师", level = 20 }, Player)

    这种写法没有问题,但是每次创建玩家,都需要重复完成几件事情:创建 Table、初始化字段,以及关联元表。

    更重要的是,如果以后创建玩家还需要初始化生命值、检查等级是否合法、设置默认状态,那么这些逻辑就可能散落在不同的创建位置。

    因此,我们希望将对象创建的规则统一封装起来,对外提供一个创建入口:

    local p1 = Player:new("战士", 10)
    local p2 = Player:new("法师", 20)

    这就引出了构造函数。

    2. Lua 的构造函数本质上仍然是普通函数

    local Player = {}
    Player.__index = Player

    function Player:new(name, level)
    local obj = {
    name = name,
    level = level
    }

    setmetatable(obj, self)
    return obj
    end

    function Player:showInfo()
    print(self.name, self.level)
    end

    local p1 = Player:new("战士", 10)
    local p2 = Player:new("法师", 20)

    p1:showInfo() — 战士 10
    p2:showInfo() — 法师 20

    这里必须明确:new 不是 Lua 的关键字,也不是 Lua 内置的特殊构造函数。

    它只是我们自己定义的普通方法,只不过由我们约定它负责创建对象。

    当执行:

    local p1 = Player:new("战士", 10)

    可以按照下面的步骤理解:

    Player:new("战士", 10)
    ↓
    等价于调用 Player.new(Player, "战士", 10)
    ↓
    self 引用 Player
    ↓
    创建新的 Table obj,并初始化 name、level
    ↓
    setmetatable(obj, self)
    ↓
    把 Player 设置为 obj 的元表
    ↓
    return obj
    ↓
    p1 引用这个新创建的 Table

    这里顺便补充一下 setmetatable 的返回值。它成功执行以后,返回的就是第一个参数,即原来的 Table 本身,并不会另外创建一个 Table。

    因此:

    local obj = { name = "赵云" }
    local ret = setmetatable(obj, Player)

    print(obj == ret) — true

    也可以把创建与关联元表写成:

    local obj = setmetatable({ name = "赵云" }, Player)

    六、继承:从“实例找类”过渡到“子类找父类”

    1. 为什么会出现继承需求?

    前面我们已经定义了 Player 类。现在假设我们需要一个专门的战士类 Warrior。

    战士也是玩家,所以它理应具有玩家共有的行为,例如 showInfo。同时,战士还可能拥有自己特有的 attack 方法。

    显然,我们不希望在 Warrior 里面把 Player.showInfo 再重写一份。

    那么,能不能让 Warrior 在找不到某个方法时,继续去 Player 中查找?

    这个问题其实很熟悉:之前实例找不到公共方法时,就是通过 __index 到类中查找的。

    所以我们完全可以把同样的字段查找机制再用一次。

    2. 建立子类与父类的查找关系

    下面是一段完整示例:

    local Player = {}
    Player.__index = Player

    function Player:new(name, level)
    local obj = {
    name = name,
    level = level
    }

    setmetatable(obj, self)
    return obj
    end

    function Player:showInfo()
    print(self.name, self.level)
    end

    — 定义战士类
    local Warrior = {}
    Warrior.__index = Warrior

    — Warrior 中找不到字段时,继续到 Player 中查找
    setmetatable(Warrior, {
    __index = Player
    })

    function Warrior:attack()
    print(self.name .. "发起攻击")
    end

    local w1 = Warrior:new("赵云", 10)

    w1:showInfo() — 赵云 10
    w1:attack() — 赵云发起攻击

    重点关注两段:

    Warrior.__index = Warrior

    以及:

    setmetatable(Warrior, {
    __index = Player
    })

    第一段用于让 Warrior 的实例在找不到字段时去 Warrior 中查找;第二段则用于让 Warrior 这个 Table 在找不到字段时,进一步去 Player 中查找。

    注意,这不是让某个实例同时直接关联两个元表,而是在不同 Table 之间建立逐级的元表查找关系。

    3. 为什么 Warrior 没有 new,也能调用 Warrior:new()?

    这也是继承机制特别值得理解的地方。

    执行:

    local w1 = Warrior:new("赵云", 10)

    需要先查找 Warrior.new。然而,Warrior 自身并没有定义 new。

    由于 Warrior 的元表具有 __index = Player,于是字段查找会回退到 Player.new,最终获得父类中定义的构造函数。

    但是,方法从哪里找到,和调用时 self 引用谁,是两回事。

    因为调用表达式使用的是 Warrior:new(…),传入的 self 仍然是 Warrior,而不是 Player。

    所以,父类定义的 new 内部执行:

    setmetatable(obj, self)

    实际上等价于:

    setmetatable(obj, Warrior)

    最终创建的实例 w1,关联的是子类 Warrior,这也解释了前面为什么建议在构造函数中使用 self。

    4. 子类实例调用继承方法的完整查找链

    接下来执行:

    w1:showInfo()

    这里的查找过程如下:

    w1.showInfo
    ↓
    w1 自身没有 showInfo
    ↓
    w1 的元表是 Warrior
    ↓
    Warrior.__index = Warrior
    ↓
    继续访问 Warrior.showInfo
    ↓
    Warrior 自身也没有 showInfo
    ↓
    Warrior 的元表中 __index = Player
    ↓
    继续访问 Player.showInfo
    ↓
    找到函数值
    ↓
    调用 Player.showInfo(w1)
    ↓
    父类方法读取的是 w1 的 name、level

    在这里插入图片描述

    这里我们应该建立一个很重要的认知:

    Lua 常见的类继承,并不是把父类方法复制到子类,而是通过 __index 建立多级字段查找链。当子类不存在目标字段时,再尝试从父类中取得。

    七、方法重写与多态:为什么相同的方法调用会有不同的结果?

    1. 方法重写:子类不想完全使用父类的实现

    继承可以复用父类提供的方法,但有些行为虽然名字相同,不同类型的对象却希望采用不同实现。

    例如,父类的 attack 表示普通攻击,战士则需要执行自己的攻击逻辑。

    local Player = {}
    Player.__index = Player

    function Player:attack()
    print("玩家普通攻击")
    end

    local Warrior = {}
    Warrior.__index = Warrior

    setmetatable(Warrior, { __index = Player })

    function Warrior:attack()
    print("战士挥剑攻击")
    end

    local w1 = setmetatable({}, Warrior)
    w1:attack() — 战士挥剑攻击

    这里 Player 与 Warrior 都定义了 attack。当执行 w1:attack() 时:

    w1.attack
    ↓
    w1 没有 attack
    ↓
    通过 __index 查找 Warrior.attack
    ↓
    Warrior 中存在 attack
    ↓
    找到函数,停止查找
    ↓
    调用 Warrior.attack(w1)

    所以,父类中的 Player.attack 根本不需要被修改;只是因为子类中已经存在同名函数,字段查找优先命中了子类的实现。

    这就是方法重写:子类定义同名方法,让子类实例优先使用自己的实现。

    2. 多态:同一调用方式,根据实际对象执行不同方法

    现在再定义一个法师类:

    local Mage = {}
    Mage.__index = Mage

    setmetatable(Mage, { __index = Player })

    function Mage:attack()
    print("法师释放魔法")
    end

    local w1 = setmetatable({}, Warrior)
    local m1 = setmetatable({}, Mage)

    local function doAttack(obj)
    obj:attack()
    end

    doAttack(w1) — 战士挥剑攻击
    doAttack(m1) — 法师释放魔法

    注意 doAttack:

    local function doAttack(obj)
    obj:attack()
    end

    这个函数并不知道传入的是战士还是法师,它只按照统一的方式调用 attack。

    但运行时 obj 引用的对象不同,查找到的函数值也不同:

    obj = w1 → Warrior.attack → 战士挥剑攻击
    obj = m1 → Mage.attack → 法师释放魔法

    这就体现了多态。

    这里需要区分:重写描述的是子类提供同名方法的行为;多态描述的是相同调用操作作用于不同对象时,表现出不同的执行结果。

    对于 C++,典型的运行时多态依赖虚函数等语言机制;Lua 则利用运行时字段查找以及函数值调用完成动态分派。Lua 的思路直观,但并不意味着它的运行开销一定更低。

    另外,Lua 的多态并不一定需要继承。例如,两个毫无继承关系的 Table,只要都提供了 attack 方法,就都可以传给 doAttack。调用方只关心对象能否提供需要的行为,这也是常说的鸭子类型(Duck Typing)。

    3. 重写以后,子类如何调用父类原来的方法?

    有些时候,我们不想完全替换父类逻辑,而是希望先执行父类的通用行为,再追加子类自己的行为。

    例如:

    function Player:attack()
    print(self.name .. "执行普通攻击")
    end

    function Warrior:attack()
    Player.attack(self)
    print(self.name .. "追加战士攻击")
    end

    local w1 = setmetatable({ name = "赵云" }, Warrior)
    w1:attack()

    输出:

    赵云执行普通攻击
    赵云追加战士攻击

    这段代码中有两种机制,不能混为一谈。

    首先:

    w1:attack()

    需要通过实例的 __index 找到 Warrior.attack。

    进入子类方法以后:

    Player.attack(self)

    则是显式访问父类 Table 中的函数,然后把当前实例 self 传进去。

    由于 Player 自身存在 attack 字段,这次访问直接取得函数值,不需要通过 __index 继续查找。

    执行过程如下:

    w1:attack()
    ↓
    通过 __index 找到 Warrior.attack
    ↓
    执行 Warrior.attack(w1)
    ↓
    显式调用 Player.attack(w1)
    ↓
    父类通用逻辑执行结束
    ↓
    返回 Warrior.attack,继续后面的语句
    ↓
    执行战士自己的追加逻辑
    ↓
    结束

    注意,这里不能随意改成:

    Player:attack()

    因为冒号调用会把 Player 自己作为 self 传入,而不是把当前实例 w1 传入。

    因此我们可以总结:

    __index 负责找到要调用的方法;进入子类方法以后,是否调用父类原本的实现,取决于我们有没有显式编写 Player.attack(self)。Lua 不会因为发生重写就自动执行父类方法。

    八、多继承:一个类如何复用多个父类的行为?

    1. 从单继承过渡到多继承

    之前的例子中,Warrior 只需要继承 Player,这属于单继承。

    但如果现在游戏中存在一个圣骑士,它既能够攻击,也能够治疗,那么我们可能希望它同时复用两组行为:

    Fighter:attack()
    ↘
    Paladin
    ↗
    Healer:heal()

    这就是多继承的需求:一个类需要从多个父类或方法集合中复用行为。

    那么,为什么之前的写法不能直接照搬?

    因为 setmetatable 为一个 Table 直接关联的是一个元表,而我们现在希望字段访问失败以后,能够根据需要去多个候选表中查找。

    这时候就可以利用 __index 的另一种形式:函数。

    2. __index 既可以是 Table,也可以是函数

    之前我们使用的是:

    local mt = {
    __index = Player
    }

    这里的 __index 是 Table。字段访问失败时,Lua 会继续在它指向的 Table 中查找。

    实际上,__index 还可以设置为一个函数:

    local mt = {
    __index = function(obj, key)
    print("正在访问字段:", key)
    return nil
    end
    }

    当对象不存在目标字段时,Lua 会按照元方法的调用协议调用这个函数,并传入两个参数:

    • obj:触发此次 __index 的原始访问对象。
    • key:本次希望访问的字段名。

    例如,若 p1 的元表是 mt,执行 p1.attack 且 attack 不存在,就会调用这个 __index 函数,传入 p1 和字符串 "attack"。对于函数形式的 __index,函数的第一个返回值就成为本次字段访问的结果。

    这里不是说 __index 特别会“调用函数方法”,而是元方法本身被设置成了函数,所以按元方法协议执行函数调用。

    3. 通过函数自行实现多个父类的查找规则

    local Fighter = {
    attack = function(self)
    print(self.name .. "发起攻击")
    end
    }

    local Healer = {
    heal = function(self)
    print(self.name .. "进行治疗")
    end
    }

    local Paladin = {}

    Paladin.__index = function(obj, key)
    return Fighter[key] or Healer[key]
    end

    local p1 = setmetatable({ name = "圣骑士" }, Paladin)

    p1:attack() — 圣骑士发起攻击
    p1:heal() — 圣骑士进行治疗

    这里 Fighter 和 Healer 是两组可复用的方法。我们让 Paladin 充当实例的元表,并把 __index 设置成函数。

    当执行:

    p1:heal()

    整个过程可以理解为:

    p1.heal
    ↓
    p1 自身没有 heal
    ↓
    找到元表 Paladin
    ↓
    Paladin.__index 是函数
    ↓
    调用 __index(p1, "heal")
    ↓
    先计算 Fighter["heal"] → nil
    ↓
    由于左操作数为 nil,继续计算 Healer["heal"]
    ↓
    找到 heal 函数并返回
    ↓
    执行这个函数,并把 p1 作为 self 传入

    在这里插入图片描述

    其中:

    return Fighter[key] or Healer[key]

    利用了 Lua 的逻辑或 or 的短路求值规则:如果左操作数不是 nil、也不是 false,就直接返回左操作数,不再计算右操作数;否则继续计算并返回右操作数。

    所以,Fighter 拥有目标函数时,优先返回 Fighter 的函数;只有无法按这条规则使用左侧结果时,才继续到 Healer 中查找。

    这里要特别注意一个细节:or 判断的不只是 nil,还包括 false。如果父类某个字段确实存在,但字段值恰好为 false,使用 or 也会继续查找第二个父类。

    如果我们希望严格按照“字段是否为 nil”决定是否回退,可以写成:

    Paladin.__index = function(_, key)
    local value = Fighter[key]

    if value ~= nil then
    return value
    end

    return Healer[key]
    end

    这里的 _ 只是普通形参名,按照习惯表示这个参数当前没有被使用。第一个参数依然会由 Lua 传入,只是我们没有用到它。

    如果多个父类都提供了同名字段,代码中指定的查找顺序就决定了谁优先。实际项目里的多继承设计还需要考虑方法冲突、初始化顺序等问题,但基础机制就是如此。

    因此:

    Lua 实现多继承,并不需要给一个对象同时设置多个元表,而是可以通过一个函数形式的 __index,自行实现对多个父类或方法表的查找与优先级规则。

    九、封装与私密性:Lua 如何隐藏对象的内部状态?

    1. Table 的字段默认可以直接从外部访问

    到这里,我们已经认识了面向对象中的继承与多态。那么面向对象的另一项重要特性——封装——在 Lua 中又该如何体现?

    对于 C++,我们可以通过 private、public 等访问控制符限制成员的访问范围。

    但 Lua 的 Table 没有内置的 private 字段访问控制。例如:

    local player = {
    name = "战士",
    hp = 100
    }

    player.hp = –999
    print(player.hp) — -999

    既然 hp 就是 Table 的普通字段,那么外部代码自然能够直接访问和修改它。

    如果我们希望外部只能通过 getHp、setHp 这些接口访问生命值,应该怎么办?

    这里其实不需要引入新的语法,我们之前学习过的局部变量、闭包和 Upvalue 就能够解决这个问题。

    2. 把内部状态放到构造函数的局部变量中

    local function newPlayer(name)
    local hp = 100

    local player = {
    name = name
    }

    function player:getHp()
    return hp
    end

    function player:setHp(value)
    hp = value
    end

    return player
    end

    local p1 = newPlayer("战士")

    print(p1:getHp()) — 100
    p1:setHp(80)
    print(p1:getHp()) — 80

    这里的关键是:

    local hp = 100

    hp 并不是 player 这个 Table 中的字段,而是 newPlayer 调用期间创建的一个局部变量。

    随后,getHp 和 setHp 都引用了外层函数的 hp,因此这两个函数形成了闭包,并共同引用这一调用所创建的变量。

    3. 为什么构造函数返回以后,hp 仍然能被访问?

    这里需要把两个概念区分清楚:作用域和生命周期。

    首先,hp 的词法作用域仍然限定在它被定义的代码环境中。newPlayer 返回以后,外部代码不会因此获得一个可以直接使用的局部变量 hp。

    但是,newPlayer 返回的 Table 中保存了 getHp 和 setHp 这两个函数值,而它们都是引用了 hp 的闭包。

    因此,虽然 newPlayer 这次调用已经结束,hp 作为被捕获的 Upvalue 仍然可以继续存活,供这些闭包读取和修改。

    也就是说,局部变量的可见范围没有扩大,但被闭包引用使它的生命周期能够超出创建它的函数调用。

    4. 外部修改 p1.hp,为什么不会影响内部的 hp?

    继续执行:

    p1.hp = –999

    print(p1.hp) — -999
    print(p1:getHp()) — 80

    为什么会出现两个不同的结果?

    原因很简单:它们根本不是同一个变量。

    • p1.hp:是外部给 p1 这个 Table 新增的普通字段。
    • 闭包中的 hp:是 newPlayer 内部的局部变量,被 getHp 和 setHp 捕获。

    我们只是没有把需要隐藏的内部状态直接放进 Table 中,而是让外部通过闭包提供的函数访问它。

    在这里插入图片描述

    还要注意,如果我们再次调用:

    local p2 = newPlayer("法师")
    p2:setHp(50)

    print(p1:getHp()) — 80
    print(p2:getHp()) — 50

    那么两次 newPlayer 调用分别创建了各自的 hp,每个玩家的私有状态也是独立的。

    到这里就可以理解:

    Lua 的私密性,不是给某个 Table 字段设置了 private 权限,而是利用局部变量的词法作用域,把内部数据隐藏在外部无法直接索引的位置,再通过闭包提供操作这些数据的接口。

    十、重新把 Lua 面向对象的整个模型串起来

    现在我们回头看,会发现 Lua 面向对象的各个知识点其实是顺着同一条逻辑链逐步产生的。

    最开始,我们只是想表示一个有状态、有行为的玩家,于是利用 Table 存储字段和函数值,直接创建了一个对象。

    后来,我们需要创建大量玩家。每个玩家的状态不同,但很多行为逻辑相同,于是把公共方法提取到一个独立的 Table 中,让它承担“类”的角色,再通过 __index 让各个实例共享这些方法。

    接下来,我们发现每次创建实例都要重复初始化字段和关联元表,于是用一个普通的 new 方法,把对象的创建、初始化、元表关联和返回过程统一封装起来。

    当我们需要让战士复用玩家的公共行为时,继续利用 __index 建立子类与父类之间的多级字段查找关系,于是有了继承。

    如果子类希望采用自己的行为实现,那么直接在子类中定义同名函数即可。查找时优先命中子类,这就是重写;同一个方法调用在不同对象上找到不同函数并执行,就是多态。如果子类需要父类原本的行为,也可以在子类函数内部显式调用 Player.attack(self)。

    再进一步,如果一个类要复用多组父类行为,就可以把 __index 设置为函数,根据访问的字段名,自行决定到哪些父类中查找,以及先查找哪一个。

    最后,如果我们不希望对象内部某些状态被外部直接访问,就可以把这些状态保存在构造函数的局部变量中,再用闭包和 Upvalue 提供受控访问接口。

    把这些机制放在一起:

    面向对象概念Lua 中的常见实现方式本质
    对象 一个保存状态与函数值的 Table Table
    方法 Table 字段中保存的函数值 函数是一等值
    self 冒号定义和调用时使用的第一个参数 普通参数、冒号语法糖
    类 保存公共方法的 Table 设计约定,不是新数据类型
    实例与类的关系 setmetatable + __index 元表与字段查找
    构造函数 自行定义的 new 等普通函数 创建、初始化、关联、返回
    单继承 子类找不到字段时继续查父类 多级 __index 查找
    方法重写 子类保存同名方法,优先被找到 字段查找优先级
    多态 相同调用在不同对象上取得不同方法 动态字段查找与函数调用
    调用父类方法 Player.method(self) 显式访问并调用函数
    多继承 函数形式的 __index 查找多个表 自定义回退查找规则
    私密性 局部变量 + 闭包 + Upvalue 词法作用域与变量生命周期

    这里最后还需要区分一点:表中保存函数,并不意味着这个 Table 一定必须被称为“对象”;一个 Table 被用作元表,也不意味着它不能同时承担“类”的角色。这些名称只是我们为了组织程序而赋予 Table 的不同职责。

    Lua 并没有专门为“类、继承、多态”再引入一整套完全独立的机制,而是让开发者通过 Table、函数、元表和闭包这些已有能力,按需求搭建自己的面向对象模型。

    理解了这条逻辑链,再去阅读游戏服务端项目中封装的类系统时,我们就不必一开始死记各种接口名,而是可以回到几个最基础的问题:对象的状态放在哪里?公共方法存在哪里?实例与类如何关联?字段查找会经过哪些 Table?构造函数如何返回实例?某个字段究竟属于对象本身,还是来自闭包捕获的外部局部变量?

    只要能够沿着这些问题分析源码,复杂的 Lua 类封装最终也能拆解为我们已经认识的基础机制。

    在这里插入图片描述

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Lua进阶系列】Lua 面向对象编程:从 Table 到类:构造函数、继承、多态、多继承与封装
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!