Lua面向对象编程

作者: 杰嗒嗒的阿杰 | 来源:发表于2018-05-04 15:30 被阅读985次

    在lua原生语法特性中是不具备面向对象设计的特性。因此,要想在lua上像其他高级语言一样使用面向对象的设计方法有以下两种选择:一种是使用原生的元表(metatable)来模拟面向对象设计,另外一种则是使用第三方框架LuaScriptoCore来实现。下面将逐一讲解这两种方式的实现过程(以下内容将基于Lua 5.3版本进行阐述)。

    1. 元表方式

    1.1 关于元表(metatable)

    在lua中每种类型变量都可以有一个元表,而元表实际上是一个table,它用于定义原始值在特定操作下的行为。如果想改变一个变量在特定操作下的行为,则可以在它的元表中设置对应元方法(metamethod)。换种说法,元表就是一个变量钩子,用来钩取变量的底层处理方法(即元方法),然后改写这些方法的处理行为。

    元表原理示意图

    其中元方法如下面表格所示:

    元方法 说明
    __index 当访问变量某个key时,如果没有对应的value,则会访问元表__index元方法所指定的对象。如果指定值为table类型,则会访问该table的key所对应的值;如果指定值为function类型,则该方法返回值作为对应key的值
    __newindex 当设置变量的某个key时,如果没有对应的key,则会访问元表__newindex元方法来处理键值设置
    __add 当两个变量进行加法操作时触发,如:var1 + var2
    __sub 当两个变量进行减法操作时触发,如:var1 - var2
    __mul 当两个变量进行乘法操作时触发,如:var1 * var2
    __div 当两个变量进行除法操作时触发,如:var1 / var2
    __mod 当两个变量进行取模操作时触发,如:var1 % var2
    __unm 当变量进行取反操作时触发,如:~var
    __pow 当变量进行幂操作时触发,如:var^2
    __concat 当两个变量进行连接时触发,如:var1 .. var2
    __eq 当两个变量判断是否相等时触发,如:var1 == var2
    __lt 当一个变量判断是否小于另一个变量时触发,如:var1 < var2
    __le 当一个变量判断是否小于或等于另一个变量时触发,如:var1 <= var2`
    __call 当变量被用作方法调用时触发,一般来说function类型是允许被调用的,对于其他类型默认是不能进行调用的,那么该元方法的作用就是让你的变量能够像function一样被调用
    __tostring 当使用tostring转换变量为字符串或者调用print进行打印时触发,如:local t = {}; print (t);
    __gc 当变量被回收时触发。
    __mode 当设置table为弱引用table时使用。弱引用table可以让保存的key或者value为弱引用状态,方便GC标记和回收(如果非弱引用情况下,必须要table被回收时,其内部的key和value才允许GC回收)。

    元表的设置基本分为三个步骤:

    1. 先创建一个作为元表的table
    2. 设置需要实现的元方法。
    3. 使用setmetatable方法将元表绑定到变量中。

    实现代码如下:

    -- 创建元表并设置元方法
    local mt = {};
    mt.__index = function (table, key)
    
      return "Hello Metatable!";
    
    end
    
    -- 创建实例并绑定元表
    local t = {};
    setmetatable(t, mt);
    
    

    上面的元方法在本篇文章中不会一一细说,在后面的章节会针对面向对象需要使用的__index__newindex__call__gc__tostring这几个元方法进行举例说明。

    为了帮助大家理解如何实现lua的面向对象,下面的章节会逐步地构建面向对象所需要的特性,完整地演示整个演化过程。废话不多说,直接开干~

    1.2 类型声明

    在开始构建类型前,我们先为面向对象设想一些基本的规则,这样可以避免后面参与扩展和开发的人因为理解的不一样,导致整个结构的规则混乱和不一致。根据需要我们先设定如下几点:

    • 类型名称首字母必须大写
    • 类型必须为全局的变量
    • 类型必须使用__index元方法指向自身
    • 类型必须使用__gc元方法进行销毁时的操作
    • 类型的属性必须使用点语法进行声明和访问
    • 类型的类方法和实例方法声明和调用必须使用冒号(:)语法声明,目的让方法都带有一个默认的self参数
    • 类型的构造方法命名为create,并且为类方法

    根据上面的约定,我们先来定义一个所有对象的基类Object

    -- 声明类型
    Object = {};
    
    -- 设置__index元方法
    Object.__index = Object;
    
    -- 设置__gc元方法
    Object.__gc = function (instance)
      -- 进行对象销毁工作
      print(instance, "destroy");
    end
    
    -- 定义构造函数
    function Object:create() 
    
      local instance = {};
      setmetatable(instance, self);
      return instance;  
    
    end
    
    -- 定义实例方法
    function Object:toString()
    
      print (tostring(self));
    
    end
    
    

    可以看到我们创建了一个全局的table变量Object来作为类型。

    然后使用了元方法__index进行自身指向,这样做的目的是使实例对象能够访问对象所定义的属性或者方法。因为__index的特点是当变量访问指定不存在的key时,就会去调用其元表的__index方法,由于__index指向就是Object,因此就会判断Object是否存在该key,并进行返回。另外如果指向的对象也设置了元表并且使用了__index,那么会继续寻找其元表的__index指向,直到最终没有设置元表的对象(这个特性在实现继承时特别关键,并且效果很好)。具体找寻方式如下图所示:

    __index处理流程示意图

    元方法__gc在这里也使用到了,利用其特性可以轻松地知道对象实例销毁时机,可以在方法里面进行一些后续的处理,类似C++中的析构函数。在这里只是简单地打印是哪个对象被销毁。

    接着我们讲解一下构造方法create的实现,方法中调用了setmetatable方法将self(即类型Object)元表绑定到instance这个变量,配合之前设置__index元方法,实例变量就会拥有与Object相同的一些属性和方法定义了。

    toString方法则是一个实例方法, 主要用于将对象转换成字符串。

    该类型具体使用方法如下所示:

    local obj = Object:create();
    print(obj:toString());
    

    冒号(:)是lua的语法糖,其等效写法为Object.create(Object)obj.toString(obj),省略了把obj自身作为参数传入到方法中的这个步骤,让整个调用看起来更像是obj自身提供的方法。

    1.3 添加属性声明

    类型的定义少不了属性和方法的声明,上例中只进行了方法的声明,这节将会重点讲述属性如何进行声明。利用上面的例子,我们再给Object增加一个属性。

    -- 声明属性
    Object.tag = 999;
    

    很简单,这样就完成了一个简单的属性定义。在代码中可以这样使用:

    local obj  = Object:create();
    print (obj.tag);      -- 输出999
    

    但是问题来了,在面向对象中,其实类型和实例应该都会拥有属性,即类属性和实例属性。那么如果直接在Object中定义属性是没有办法区分这是类属性还是实例属性的。所以,这里借鉴了javascript中的prototype机制。简单来说就是把类型和实例定义分离,让所有的实例属性和实例方法定义到prototype中。类型变量中只保留类属性和类方法。接下来开始动手改写Object

    -- 定义类型
    Object = {};
    
    -- 设置__index元方法
    Object.__index = Object
    
    -- 定义类型属性
    Object.objCount = 0;
    
    -- 定义构造函数
    function Object:create() 
    
      Object.objCount = Object.objCount + 1;
      
      local instance = {};
      setmetatable(instance, self.prototype);
      return instance;  
    
    end
    
    -- 定义类方法
    function Object:outputObjectTag(object)
    
      print (object.tag);
    
    end
    
    -- 定义类型的prototype
    Object.prototype = {};
    
    -- 设置prototype的__index元方法
    Object.prototype.__index = Object.prototype;
    
    -- 设置prototype的__gc元方法
    Object.prototype.__gc = function (instance)
        print(instance, "destroy");
    end
    
    -- 定义实例对象属性
    Object.prototype.tag = 999;
    
    -- 定义实例对象方法
    function Object.prototype:toString()
        return tostring(self);
    end
    

    上面例子主要做出了如下几点改动:

    • 在类型Object中增加一个类属性prototype,并设置其__index元方法指向。
    • create方法中之前设置元表为Object,现在改为Object.prototype
    • __gc元方法从Object转移到Object.prototype中,因为实例构造时绑定元表是prototype,所以销毁监听要相对应转移。
    • tag属性和toString方法转移到Object.prototype中进行定义。

    通过这样的调整,整个结构被划分为两部分,让类型结构更加清晰。使用方式是没有变化的,如下面代码所示:

    local obj  = Object:create();
    print (obj.tag);                  -- 输出999
    Object:outputObjectTag(obj);      -- 输出999
    print (Object.objCount);          -- 输出1
    

    1.4 类型继承

    继承是面向对象中一个很重要的特性,要想在lua中实现该特性也相对比较简单,配合元表和__index元方法就可以了。下面我们新增一个Person类型继承于Object

    -- 创建类型并绑定父类作为元表
    Person = {};
    Person.__index = Person;
    setmetatable(Person, Object);
    
    -- 创建prototype并绑定父类prototype作为元表
    Person.prototype = {};
    Person.prototype.__index = Person.prototype;
    setmetatable(Person.prototype, Object.prototype);
    
    

    通过上面的写法即可实现类型的继承,调用代码如下:

    local person = Person:create();
    print (person.tag)       -- 输出999
    

    上面代码中没有看到Person类型定义create方法,但是能够正常被调用并生成对象,这得益于Objectcreate方法绑定元表的操作使用的是self而不是指定类型,对于其他的方法也可以尝试使用这种技巧进行处理。

    但是如果每次创建一个新的类型都要写这么一大串东西是一件繁琐的事情。为了让我们可以偷懒,下面对这块内容进行封装并改写Object

    
    function Object:subclass(typeName)
    
      -- 以传入类型名称作为全局变量名称创建table
      _G[typeName] = {};
      -- 设置元方法__index,并绑定父级类型作为元表
      local subtype = _G[typeName];
      subtype.__index = subtype;
      setmetatable(subtype, self);
    
      -- 创建prototype并绑定父类prototype作为元表
      subtype.prototype = {};
      subtype.prototype.__index = subtype.prototype;
      subtype.prototype.__gc = self.prototype.__gc;
      setmetatable(subtype.prototype, self.prototype);
    
      return subtype;
    
    end
    

    上面代码主要新增了subclass方法。将之前写的一大串操作放入了这个方法,以后要创建新的子类则可以像下面这样操作:

    -- 创建继承Object的Person类
    Object:subclass("Person");
    Person.prototype.name = "vim";
    
    local p = Person:create();
    print (p.name);   -- 输出vim
    
    -- 创建继承Person的Chinese类
    Person:subclass("Chinese");
    Chinese.prototype.skin = "yellow";
    
    local ch = Chinese:create();
    print (ch.name, ch.skin);  -- 输出vim  yellow
    
    

    1.5 方法重载

    在类型继承的同时,可能会出现父类的某个方法需要在子类中进行特殊化处理的情况,那么方法的重载就非常有必要了。而在重载过程有可能需要调用父类方法,为了达到这种效果,我们对继承方法进行改写,将继承链搭建起来,修改代码如下:

    function Object:subclass(typeName)
        
    -- 以传入类型名称作为全局变量名称创建table
    _G[typeName] = {};
    
    -- 设置元方法__index,并绑定父级类型作为元表
    local subtype = _G[typeName];
    -- 设置类型父类
    subtype.super = self;
    subtype.__index = subtype;
    setmetatable(subtype, self);
    
    -- 创建prototype并绑定父类prototype作为元表
    subtype.prototype = {};
    subtype.prototype.__index = subtype.prototype;
    subtype.prototype.__gc = self.prototype.__gc;
    setmetatable(subtype.prototype, self.prototype);
    
    return subtype;
    
    end
    

    上例改写了subclass方法,在类型中新增super属性,让子类与父类进行关联。接下来要再给出一条规范:调用父类方法时需要使用Class.super.prototype.function(self, params)形式。 为了加深理解,我们直接上例子:

    function Object.prototype:output(a, b)
      -- 求和
      return a + b;
    end
    
    -- 创建子类Child
    Object:subclass("Child");
    
    
    function Child.prototype:output(a, b)
      -- 求和后平方
      local sum = Child.super.prototype.output(self, a, b);
      return sum ^ 2;
    end
    
    local c = Child:create();
    print (c:output(2, 2));    -- 输出16
    

    上例中Object对象定义了output方法,用来输出两个参数的总和。而在子类Child中重载了该方法,先调用父类方法求和,然后对结果求平方并返回。

    调用父类的方式显得有点臃肿和不简洁,但这是我目前唯一能够想到的方法,如果有更好的方式欢迎大家提出来给我建议。

    1.6 优化工作

    通过上面的设计,在lua中面向对像编程基本上能够满足需要,在这小节中主要是针对设计中一些操作进行简化。

    1.6.1 类型的字符串描述转换

    之前,我们定义了一个toString 的实例方法来让对象进行字符串的转换。其实__tostring元方法可以实现这样的功能,并且能够很好地结合printtostring系统方法。下面我们把toString方法替换掉:

    function Object:create() 
    
      local instance = {};
      instance.class = self;
      setmetatable(instance, self.prototype);
      return instance;  
    
    end
    
    function Object:subclass(typeName)
        
      -- 以传入类型名称作为全局变量名称创建table
      _G[typeName] = {};
    
      -- 设置元方法__index,并绑定父级类型作为元表
      local subtype = _G[typeName];
    
      subtype.name = typeName;
      subtype.super = self;
      subtype.__index = subtype;
      setmetatable(subtype, self);
    
      -- 创建prototype并绑定父类prototype作为元表
      subtype.prototype = {};
      subtype.prototype.__index = subtype.prototype;
      subtype.prototype.__gc = self.prototype.__gc;
      subtype.prototype.__tostring = self.prototype.__tostring;
      setmetatable(subtype.prototype, self.prototype);
    
      return subtype;
    
    end
    
    Object.prototype.__tostring = function (instance)
        
      return "[" .. instance.class.name .." object]";
    
    end
    

    代码中主要调整内容为:

    • create方法中在创建实例时将类型赋予实例的属性class。主要是为了方便演示__tostring的输出。
    • subclass方法中需要对子类prototype__tostring元方法进行赋值,让其与父类处理相同(注:__gc__tostring__index有点不一样,不会递归需要元表方法,作用只在本级元表有效)。
    • 移除toString方法,并增加__tostring元方法。

    下面例子演示其输出:

    local obj = Object:create();
    print(obj);    -- 输出[Object object];
    
    Object:subclass("Child");
    local c = Child:create();
    print (c);    -- 输出[Child object];
    

    1.6.2 简化构造函数

    目前我们所定义的构造函数create其实是类型的一个方法,虽然有规范来约束编码人的行为,但是也有可能存在构造函数被覆盖的情况。那么最好的办法就是让我们的构造函数整合到lua语法中,刚好lua提供的__call元方法就能够做到这一点。它能够使用括号语法来让类型进行实例构建,如:local instance = Object()。下面我们直接贴出实现代码:

    -- 对象构造器
    local ObjectConstructor = {};
    ObjectConstructor.__call = function (type)
    
        local instance = {};
        instance.class = type;
        setmetatable(instance, type.prototype);
        return instance;  
    
    end
    
    Object = {};
    Object.__call = ObjectConstructor.__call;
    setmetatable(Object, ObjectConstructor);
    
    function Object:subclass(typeName)
        
        -- 以传入类型名称作为全局变量名称创建table
        _G[typeName] = {};
    
        -- 设置元方法__index,并绑定父级类型作为元表
        local subtype = _G[typeName];
    
        subtype.name = typeName;
        subtype.super = self;
        subtype.__call = ObjectConstructor.__call;
        subtype.__index = subtype;
        setmetatable(subtype, self);
    
        -- 创建prototype并绑定父类prototype作为元表
        subtype.prototype = {};
        subtype.prototype.__index = subtype.prototype;
        subtype.prototype.__gc = self.prototype.__gc;
        subtype.prototype.__tostring = self.prototype.__tostring;
        setmetatable(subtype.prototype, self.prototype);
    
        return subtype;
    
    end
    
    -- 省略Object其他定义实现
    
    

    上述代码中创建了一个ObjectConstructor的变量,主要实现了__call元方法,而元方法中的实现就是我们之前的create方法实现。

    为什么需要多出一个ObjectConstructor?主要是因为元方法只对绑定其的变量有效,所以如果你直接在Object中实现__call元方法,然后调用Object()其实是不起作用的,因为它自身作为变量的时候就不是元表。因此才需要有一个ObjectConstructor作为Object的元表。

    我们也看到Object._call = ObjectConstructor.__callsubtype.__call = ObjectConstructor.__call,这里表示类型还是要定义__call元方法的,目的是为了被子类化时,子类也要能够正常调用构造函数。

    通过这样的调整,构造就变得很简洁了,也不怕存在构造函数覆盖问题,如:

    local obj = Object();
    print (obj);      -- 输出[Object object];
    
    Object:subclass("Child");
    local c = Child();
    print (c);    -- 输出[Child object];
    

    上面所说的就是使用元表来实现的面向对象的全部内容,接下来要讲的就是使用LuaScriptCore(简称LSC)这个第三方框架是如何实现面向对象编程。

    2. LuaScriptCore方式

    LSC是鄙人开发的一套用于简化iOS、OSX、Android、Unity3D平台与lua桥接的框架。它能够很方便地将原生定义的类型导出到lua层中使用,同时也涵盖了上面使用元表实现的所有面向对象功能。感兴趣的同学可以到这里了解和下载,接下来我们一起看看怎么样使用LSC来面向对象编程。

    LSC的集成,这里就不在详细描述了,可以根据自己集成的平台查看相关的文档(iOS & OSXAndroidUnity3D)。

    2.1 Object类型

    LSC中默认声明Object类型,该类型包含下面方法和属性:

    名称 说明
    Object.name 类型的名称,每个类型都包含这个属性
    Object.prototype 类型原型,用于定义类型实例的属性和方法
    Object.super 类型的父类,Object没有父类,为nil
    Object:subclass(typeName) 子类化方法,向该方法传入类型名称则可以构建对应名称的类型,如:Object:subclass('Child')
    Object:subclassOf(type) 用于检测指定类型是否为该类型的父类,如:Child:subclassOf(Object)
    Object:typeMapping(platform, nativeTypeName, alias) 将原生类型映射到alias指定的别名中,往后可以根据别名来方面该类型。如:Object:typeMapping('ios', 'LSCTestType', 'Test')
    Object.prototype.super 类型原型的父级原型,Object没有父级原型,为nil
    Object.prototype:instanceOf(type) 判断是否为该类型或其子类型实例。如:local c = Child(); obj:instanceOf(Object);

    如果需要构造一个Object对象,可以如下操作:

    local obj = Object();
    

    如果需要监听类型的构造和销毁,可以在prototype中定义initdestroy方法,如:

    function Object.prototype:init()
      print ("对象被构造...");
    end
    
    function Object.prototype:destroy()
      print ("对象被销毁...");
    end
    
    local obj = Object();
    obj = nil; 
    

    执行后打印以下内容:

    对象被构造...
    对象被销毁...
    

    2.2 子类化

    通过Object.subclass可以进行类型子类化,如:

    Object:subclass('Person');
    Person:subclass('Chinese');
    

    子类化后可以为类型定义方法和属性,如:

    Person.prototype.age = 0;
    
    Person.prototype.name = {
      get = function (self)
        return self._name;
      end
      set = function (self, value)
        self._name = value;
      end
    };
    
    function Person.prototype:speak()
      print ("xxxxxxxxx");
    end
    
    function Chinese.prototype:speak()
      print ("我说的是中文....");
    end
    
    

    上面代码需要注意的地方是name属性的定义,LSC支持为属性定义gettersetter。具体的格式要求就如上面,需要传入一个table变量,变量中带有getset两个key,并且两个key的value都是一个function

    2.3 方法重载

    LSC中方法重载在上例中已经演示(Personspeak方法在Chinese中重写)。其形式与元表方式相同,如果需要调用父级方法,需要通过Class.prototype.function(self, params)形式进行。

    LSC其实是对元表方式的一种封装,其中大部分约定和实现方式都与上面所说的基本一致。目的就是让开发者不用过多关注里面的实现,像一般的高级语言一样可以进行面向对象的编程。

    到这里所有内容都已经讲述完毕,如果有疑问和建议,欢迎留言和提问~

    相关文章

      网友评论

        本文标题:Lua面向对象编程

        本文链接:https://www.haomeiwen.com/subject/uldwxxtx.html