一.为什么需要面向对象思想
代码实现其实最终还是对现实世界运行规律的一种描述,面向对象的思想其实是对于如何描述的一种解决方案,对于同一件事情可以有不同的描述方式,
比如以"操作玩家打怪"这件事情,我们可以这样去描述:
function 操作玩家打怪1()
1.点击ui
2.播放玩家攻击动作
3.播放玩家攻击特效
4.播放玩家攻击音效
5.计算双方属性造成扣血
6.怪物播放受击表现
...
end
如果只是这样是没有什么问题的,最终的结果也是符合预期的,但是如果我们扩展一下,需要操作玩家用动作A,播放特效B,造成伤害C,怪物要表现受击动作D,受击特效F
如果还按照上面的思路,那么可能会变成这样:
function 操作玩家打怪2()
1.点击ui
2.播放玩家攻击动作A
3.播放玩家攻击特效B
4.播放玩家攻击音效C
5.计算双方属性造成扣血
6.怪物播放受击动作D
7.怪物播放受击特效F
...
end
这里其实大家已经知道问题在哪里了,这样的描述方式,对于变化是脆弱的,当变化来临的时候是需要整体进行改动的。那么我们换一种描述方式:
function 操作玩家打怪3()
玩家:丢技能()
end
function 玩家角色:丢技能()
1.依据不同情况创建不同技能,执行技能执行流程
end
function 技能:执行流程()
1.播放攻击者动作
2.播放攻击者特效
3.播放攻击者音效
4.受击者:受击表现(动作X)
5.受击特效
end
function 怪物:受击表现(动作X)
1.播放受击动作
end
这样的描述方式针对上面的变化的时候,只需要修改“技能”这一个模块就可以了,通过这样的一种描述方式的转变,我们把上面变化的部分拆分了出来,放到了“技能”这一概念中,达到了对变化的适应,这里面其实是有抽象和封装俩层概念的
通过“面向对象式”的思考描述方式,可以简化一件复杂事情的理解复杂度,是相对符合我们人脑的一种思维方式,对于变化可以更加快速反应
最终目的还是为了让代码的可维护性更高,可扩展性更好,可重用性更强;
对于ECS模式,我的理解本质上也还是离不开“对象式”的模式,是对象模式的另外一种表现,这个我们就不展开了。毕竟我们项目现在没有使用这种结构,但是unity本身的设计是遵循这种思想的。
二.Lua中如何实现面向对象
由于我们的业务逻辑大部分都写在了lua层,所以了解lua中实现面向对象的方式是第一步。
1.继承
function class.class(super)
--原表构造
local mt = {__call = function(_c, ...)
local function _create(_c, _o, ...)
--递归调用constructor,实现子类构造默认先调用父类构造
if _c.__super then _create(_c.__super, _o, ...) end
if rawget( _c,"constructor") then _c.constructor(_o, ...) end
return _o
end
local _o = setmetatable({}, _c)
return _create(_c, _o, ...)
end}
mt.__index = super or mt
--给子类添加父类索引,类似于base关键字
local c = {__super = super}
--调用__call 方法后会返回一个元表是c的空表,所以需要把c赋值给c.__index
c.__index = c
--析构函数,递归调用
c.delete = function (obj)
local function _remove(_c)
if rawget( _c,"destructor") then _c.destructor(obj); end
if _c.__super then _remove(_c.__super); end
end
_remove(obj);
end
--设置原表操作
return setmetatable(c, mt)
end
这里主要需要理解__index,__call, rawget,setmetatable方法
需要注意的是这里面__super, constructor 是我们自己定义的写法,并不是lua源生的方法
具体写法:
local FatherClass = class.class()
function FatherClass:constructor()
log("父类构造");
end
function FatherClass:TestFun1(testStr)
log("父类测试方法1",testStr);
end
function FatherClass:TestFun2(testStr)
log("父类测试方法2",testStr);
end
local SonClass = class.class(FatherClass);
function SonClass:constructor()
log("子类构造");
end
function SonClass:TestFun1(testStr)
SonClass.__super.TestFun1(self, testStr);
log("子类测试方法1");
end
有了单一继承的机制可能还不太够,让我们思考以下的情景:
Class Father;
Father Walk()
Father Run()
Father Fly()
父类有走,跑,飞3种行为
--son1只会走飞
Son1 : Father
--son2只会走跑
Son2 : Father
对于这种情况,父类的定义就有点不合适了,对于son2来说不需要飞,对于son1来说不需要跑
为了解决这个问题,就需要重新提炼,调整父类职责,父类只实现走的行为。
把跑和飞定义为接口,通过接口多重继承的方式,给子类赋予跑或者飞的能力
interface IRun
interface IFly
Class Father;
Father Walk()
Son1 : Father,IFly
Son2 : Father, IRun
要么把Walk,Run,Fly 都抽象成组件,不通过继承的方式,而是通过组合的方式去解决,组合的思想我们放到后面来说
2.多重继承
为了解决上面的问题,首先需要在lua中实现一下类似多重继承的机制:
function class.MultipleClass(...)
local classList = {...};
local metaTable = {};
metaTable.__call = function(_c, ...)
local function _create(_c, _o, ...)
if _c.__super then _create(_c.__super, _o, ...) end
if rawget( _c,"constructor") then _c.constructor(_o, ...) end
return _o
end
local _o = setmetatable({}, _c)
return _create(_c, _o, ...)
end
--多重继承中原表的__index元方法不能直接设置为父类表,需要遍历父类列表
metaTable.__index = function (sourceTable, key)
for _, oneFatherClass in ipairs(classList) do
if (oneFatherClass[key]) then
return oneFatherClass[key];
end
end
end;
--默认第一个参数是父类,后续参数是接口
local super = classList[1] or {}
local c = {__super = super};
c.__index = c;
return setmetatable(c, metaTable);
end
常用的使用方式有:
local Class = require("Behaviour/Class");
local XHWLuaTest = {};
--------------------------------------------------
local IFly = {}
function IFly:Fly( )
log("xhw 飞飞飞")
end
--------------------------------------------------
local IRun = {}
function IRun:Run( )
log("xhw 跑跑跑")
end
--------------------------------------------------
local FatherClass = Class.class();
function FatherClass:constructor(name)
log("xhw 父类构造",name)
self.fatherAttr = 111;
end
function FatherClass:Walk()
log("xhw 父类走")
log("xhw, father attr = ", self.fatherAttr)
end
function FatherClass:Run( )
log("xhw 父类跑")
end
--------------------------------------------------
local SonClass = Class.MultipleClass(FatherClass, IFly, IRun);
--local SonClass = Class.MultipleClass(FatherClass);
function SonClass:constructor(name)
log("xhw 子类构造", name)
self.sonAttr = 222
end
function SonClass:Walk()
self.fatherAttr = 333;
SonClass.__super.Walk(self);
log("xhw 子类走");
end
--------------------------------------------------
function XHWLuaTest.XHWTest()
local son = SonClass("小明")
son:Walk();
son:Fly();
son:Run();
end
return XHWLuaTest;
最终的打印结果:
接口继承我们现在可能需要使用的场景就是可以解决基类过于臃肿的问题,之前基类包了含很多虚方法,可以通过接口继承的方式把这些虚方法抽离出去。
项目中还有一些接口则是从概念上不适合放在基类里的,比如以下
这些接口的存在可能只是因为某个需求在做的时候为了调用方便,就直接放到了基类里,实际上这部分逻辑是需要放到他应该存在的类内部的,比如is_unlock()可能只是一些资源点的一个方法,很显然他不应该出现在数据基类里,需要放到资源田的基类里。获取所属玩家和所属工会,很明显也不应该是放在数据基类里,这里可能就得从业务需求本身出发,考虑调用的时候如何做处理,不太应该为了方便而随意给基类赋予他职责外的事情。
从以上的问题可以看出,一个类内部的属性和函数划分很大程度是取决于业务需要的,如果你的子类都是会Walk,Run,Fly的话,那么把这三个方法放到基类没有问题,但是如果并不是这样的就需要考虑如何调整,让基类更合适了,由此我们引出下面的话题,到底什么是类
三.什么是类
我的理解:一个类是对一类事物的一种抽象归纳总结,就像一类对象的一个模具一样,他描述了这个对象是什么,以及它有什么和能做什么这几件事情,也只做这几件事。
我们常用的一些类有:
- 描述现实世界某个实体的类,比如我们游戏里的部队,资源田,城堡等
- 描述一些抽象概念的类,比如:移动组件类,是用来给单位提供移动能力的,阵型模版类,是用来给部队子单位排位置的,可战斗单位基类,用来描述一些具有战斗功能的对象的
- 静态帮助类:只是用来归纳一些同类操作,或者同概念操作的,比如各种工厂方法类,utils类,我们的AppConfig之类的都可以算作帮助类
四.为什么需要类
这部分已经有前人帮我们做了很细致的总结,以下引用自《代码大全》第二部分,第六章——创建类的原因
五.如何构建一个类
知道什么是类,以及为什么需要有类这个东西之后,接下来就需要知道到底应该怎么去创建一个比较合理的类,一个类内部应该包含哪些东西,不应该包含哪些东西,类的内部细节应该怎么去实现。
首先我们需要知道几个前提,在我们设计构建一个类的过程中需要时刻反思遵守的一些规则:
1.几个需要遵守的原则
- 单一职责原则(Single Responsibility Principle, SRP):
一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因。
这个原则可以从俩个层面去理解和应用
1.类的单一职责:
2.函数的单一职责:简而言之就是函数要短小,尽量短小,我的习惯是除非某些特别复杂的算法实现函数,内部如果进行拆分之后会影响算法理解完整性,不然一个函数最多不会超过50行。
详细的内容,推荐大家去看一下《代码简洁之道》第三章关于函数部分的内容,里面还有很多其他的场景按理,可供反思
- 迪米特法则(Law of Demeter, LoD)(最少知识法则):
迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。
在将迪米特法则运用到系统设计中时,要注意下面的几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限
不同类之间应该在满足业务需求的前提下,更少的知道对方的一些内部细节
比如在lua中
外部模块直接调用某个类的内部属性
local obj = {}
function obj:constructor()
obj.score = 100;
end
function obj:GetScore()
return obj.score;
end
local ExtraModule = {}
functionn ExtraModule:Test()
--这一步的本质其实就是默认你知道 obj内部有这个score方法,相当于你关心了obj模块内部的一些细节
local objScore= obj.score;
....
但是如果通过一个接口把这层细节封装一下,就可以一定程度上隔绝内部实现,当score的计算规则改变的 时候,外部模块也不需要去调整,相当于把score的具体逻辑隐藏了起来
local objScore = obj:GetScore();
end
函数参数传递的问题
2.函数是否过长
一般我会限定自己一个函数在单一职责的基础上最长不超过一页代码,来减少理解和阅读成本
3.反复思考共性提取,做小范围的迭代重构
比如move逻辑的提取,基类逻辑的提取,在实现过程中是需要持续迭代的
简单来说一个类主要包含的就俩个部分,自己有什么(属性)和自己能做什么(方法)
针对lua侧,以最近实现的几个模块为例,基于上面的原则,我们从需求入手来分析如何从接到需求开始,一步步完成需求(以部队部分为例子)
1.需求分析(从策划比较偏感性的认知,拆分成程序向结构化的思维,把一个个大的需求点初步明确出来)
2.系统层次概念抽象(需要考虑到一定的扩展需求情况,来搭建系统,做到层级之间,模块之间的初步划分,初步思考数据流和执行流俩个流程的流转情况)
部队部分的数据源头在哪,
3.类抽象(系统层次下搭建一个合理的类型抽象,包含现实世界存在的对象抽象(比如部队类)以及一些概念抽象,大致可以理解为抽象类(比如阵型模板类))
4.依据需求点一步步去充实类
5.单元测试
6.全局自测
部队类:
有什么(属性定义)
能做什么(方法)
类其实是对于变化的一种提炼,比如move,template,skill,buff,troop,help,factory,都是针对可能的变化进行的概念提炼抽象
六.设计模式思想的应用实例分析
单例模式:各种mgr类
状态模式:战斗部分
工厂模式:单位构造部分
模板方法模式:阵型模版
外观模式:接口封装思想
观察者模式:如何通过观察者模式把部队模块和tips解耦的
组合模式:如何通过组合模式把移动逻辑解耦的
中介者模式:如何通过静态帮助类,隔离了模块之间的调用
设计模式的本质和灵活应用
设计模式其实是对于一些比较常见的业务场景提出的一类合理的层次划分和概念抽象的方向,主要还是要get到设计模式的核心思想核心点,去灵活应用
七.Lua层实现业务逻辑的特殊情况以及解决方式
1.get,set接口的定义(区分属性作用域),为什么不直接.调用类内部字段,哪些类可以接受直接.调用
属性作用域的限定,以及最小知识原则的应用,只有通过get,set接口才能访问属性,没提供的 不支持外部访问
哪些可以接受.调用呢,比如模块内部,以及子类是可以接受点调用的
2.函数模块划分(人为区分不同函数的作用域,or 大小驼峰命名法)
由于lua没有作用域的关键字,理论上来说都是公有的,所以在阅读别人代码的时候需要很费劲的去梳理需要关注哪些函数,通过人为的一些限定,去规划自己模块的函数作用域,可以提高可读性
3.尽量不使用同名函数或者同名字段(命名的时候稍微思考一下,除了override的函数,一般不要有太多的和其他模块同名的函数)
由于lua中没有引用查找的功能,所以如果有太多的同名字段或者函数,在搜索的时候很不方便,如果不是很熟悉整个代码调用的情况下,需要关注的地方太多了,如果有重构的需要也很不方便
但是以上几点其实都是习惯层面的东西,最终其实都是为了提高代码的可读性,减少阅读理解成本和debug成本,虽然lua有提供一种私有性的解决方式,但是其实也还是需要大家在写的时候注意
总结:目标是什么?效率,准确率,扩展性,复用性,Debug方便
参考资料:
- 《代码大全》
- 《代码简洁之道》
- 《面向对象分析与设计》
- 《Lua程序设计》
- 《大话设计模式》
- 《游戏编程模式》
- 浅谈《守望先锋》中的 ECS 构架