LPC 语言基础教程:5.7 LPC语言中的面向对象编程

本文节选自《LPC语言基础教程:从零学习游戏开发》,版权归@mudren,欢迎转载,但必须注明来源(mud.ren)。

除了语法的差别,支持面向对象是LPC语言和C语言最大的差别,因为C语言是面向过程的,如果看本教程的同学没有学过面向对象编程,只以C语言的基础可能需要更多的学习来理解。

面向对象编程

在JAVA、C++等主流的面向对象编程中,都有类(class)和对象(object)。与结构体类似,类可以看做是一种数据类型,它类似于普通的数据类型,但是又有别于普通的数据类型,类这种数据类型是一个包含成员变量和成员函数的集合。类是创建对象的模板,一个类可以创建多个对象,每个对象都是类类型的一个变量;创建对象的过程也叫类的实例化。每个对象都是类的一个具体实例(Instance),拥有类的成员变量和成员函数。

file

我们在第一章第一节已经提到,LPC语言中关键词class不像其它面向对象编程中用来定义类,而是结构体的别名,在LPC中虽然可以用class声明结构体,而且这个结构体的使用语法有点像类,但本质上也只是结构体,而不像在C++中把结构体升级为真正的类了。

LPC语言中对类的声明比较特别,不需要用语法声明,MUDLIB中所有 .c 文件都是一个类,类名就是文件名(这和JAVA语言有点类似,JAVA中一个文件中可以定义多个类,但只能有一个public类,而且文件名必须和这个类名一至)。载入游戏后的对象则是类的实例,在LPMUD开发中,我们开发的每个功能都是面向对象的,我们每建一个.c源文件就是一个类,哪怕没有任何代码,它也是一个空类,可以正常的实例化。

在LPC语言中创建对象的方式有二种:

  1. load_object
  2. clone_object

使用load_object只能创建蓝图对象,不能传参数,具体语法为:

   object load_object(string file);

使用clone_object创建对象可以传参数实例化不同属性的对象,也可以使用别名new,具体语法为:

    object clone_object(string file, mixed extra, ...);
    object new(string file, mixed extra, ...);

我们新建测试文件 5.7.1.c,代码如下:

// 示例:5.7.1
int main(object me, string arg)
{
    // 取得游戏中所有已加载对象
    object *obs = objects();

    // 输出对象列表
    printf("%O\n", obs);

    return 1;
}

以上代码中 objects() 是LPC提供的efun,可以返回游戏中所有载入对象的数组。输入指令后,显示结果可能如下:

({ /* sizeof() == 10 */
  /cmds/demo/5.7.1 ("/cmds/demo/5.7.1"),
  /cmds/test/x ("/cmds/test/x"),
  /cmds/update ("/cmds/update"),
  /system/object/user#1 ("iuv"),
  /system/object/login#0 ("/system/object/login#0"),
  /system/object/void ("/system/object/void"),
  /system/object/user ("/system/object/user"),
  /system/object/login ("/system/object/login"),
  /system/kernel/master ("ROOT"),
  /system/kernel/simul_efun ("NONAME")
})

对象后面的括号中的内容是主控对象中的 apply 方法 object_name() 返回的值。从列表可见,所有对象都是对应着.c类文件的实例,其中 user#1 是复制对象,代表不同的玩家对象,可以多连接几个客户端后再看看显示的结果。

再次新增 5.7.2.c 测试以下代码:

// 示例:5.7.2
int main(object me, string arg)
{
    // 复制 test 对象
    object new_ob = new("/cmds/test/test");

    // 取得游戏中所有已加载的复制对象
    object *obs = objects((:clonep:));

    printf("%O\n", obs);
    printf("new_ob = %O\n", new_ob);

    return 1;
}

更新后运行试试,显示对象中新出现了有 #id 后缀的 test 复制对象了,而且多执行几次 5.7.2 指令,会发现系统中会多很多有 #id 的 test 复制对象。因为LPC的代码中没有结束,如果不主动销毁,这些对象都会一直在内存中,我们可以使用 destruct() 外部函数销毁对象,而且摧毁原始蓝图对象不会影响复制对象。在游戏开发中可以使用 apply 方法 cleanup() 来定期清理,这里先了解即可。

在游戏中,复制对象的 id 是整个游戏中唯一,每个复制对象都有序递增,比如先 new 一个对象A的id是6,那再 new 一个对象B,id就是7。

面向对象编程有三个基本特征:封装继承多态。在LPC面向对象编程中,也有这些特征。

继承

先说继承的作用,继承(Inheritance)可以理解为一个类从另一个类获取成员变量和成员函数的过程。例如类 B 继承于类 A,那么 B 就拥有 A 的成员变量和成员函数。被继承的类称为父类或基类,继承的类称为子类或派生类。LPC中每个.c文件都是蓝图对象,也可以理解为类,一样可以子类继承父类,继承可以在无需重写代码的情况下让子类拥有父类的所有功能。

在LPC语言中继承使用 inherit 关键字来实现,语法:

inherit "path/to/file";

注意:继承的文件是相对根目录的,不是相对当前目录,所以路径要完整。

我们直接上代码来演示,先在 /cmds/test/ 目录下新建文件 5.7.3.c ,文件中代码如下:

// 示例:5.7.3

int main(object me, string arg)
{
    debug("我是示例 5.7.3!");
    return 1;
}

我们输入指令 5.7.3 ,应该会显示 我是示例 5.7.3!。再在 /cmds/test/ 目录下新建文件 5.7.3.1.c ,文件代码如下:

// 示例:5.7.3.1
inherit "/cmds/test/5.7.3";

我们输入指令 5.7.3.1 ,还会显示 我是示例 5.7.3!,这是因为类 5.7.3.1 继承(inherit)了类 5.7.3,运行时自动调用了 5.7.3 的 main()方法。

继承是可以多重继承的,我们再在目录下建一个文件 5.7.3.2.c,文件代码如下:

// 示例:5.7.3.2
inherit "/cmds/test/5.7.3.1";

输入指令 5.7.3.2 ,看看结果,还会显示 我是示例 5.7.3!

不只是可以多重继承,还可以同时继承多个对象,另外,继承对象中还可以直接使用父对象中的全局变量。我们再在目录下一个文件 5.7.4.1.c,代码如下:

// 示例:5.7.4.1

string s1 = "我在示例 5.7.4.1 中!";

void test1()
{
    debug(s1);
}

再新建一个测试文件 5.7.4.2.c,代码如下:

// 示例:5.7.4.2
string s2 = "我在示例 5.7.4.2 中!";

void test2()
{
    debug(s2);
}

请注意,在以上二个示例中没有 main() 方法,输入指令调用会返回默认的指令执行失败提示什么?

再新建文件 5.7.4.c 继承以上二个文件。

// 示例:5.7.4
inherit "/cmds/demo/5.7.4.1";
inherit "/cmds/demo/5.7.4.2";

int main(object me, string arg)
{
    // 直接调用继承对象中的方法
    test1();
    test2();
    // 直接使用继承对象的全局变量
    debug(s1);
    debug(s2);

    return 1;
}

以上的示例代码中继承了二个对象的方法,运行后输出如下:

我在示例 5.7.4.1 中!
我在示例 5.7.4.2 中!
我在示例 5.7.4.1 中!
我在示例 5.7.4.2 中!

在游戏开发中,我们会大量的使用继承,子对象除了拥有父对象的功能,还可以定义自己的新成员,以增强功能。

以下是两种典型的使用继承的场景:

  • 当你创建的新类与现有的类相似,只是多出若干成员变量或成员函数时,可以使用继承,这样不但会减少代码量,而且新类会拥有父类的所有功能。比如,在游戏中有铁剑、铜剑、木剑等很多剑类武器,基本属性一样,只是描述、价值和伤害不同,我们可以先定义一个基本的剑类对象,然后所有的剑继承这个对象,然后只用修改描述和伤害等值即可。而且如果将来要限制剑只能某个职业可装备,只用在被继承的剑类对象中增加限制就行了。
  • 当你需要创建多个类,它们拥有很多相似的成员变量或成员函数时,也可以使用继承。可以将这些类的共同成员提取出来,定义一个基类,然后从这个基类中继承,既可以节省代码,也方便后续修改成员。比如,在游戏中除了剑以外,还有棍、杖、刀等类型的武器,那么,我们把所有兵器共同的成员变量和方法提取出来,定义一个兵器蓝图对象,所有种类武器的蓝图对象都继承兵器蓝图对象。

继承时注意对全局变量要避免重复定义的问题。

封装

封装,直白的就就是把细节包装起来,限制操作权限,在面向对象编程中都是使用“访问控制符”来控制哪些细节需要封装,哪些细节需要暴露的。在LPC语言中增加了三个访问控制符:publicprotectedprivate。如果学过C++语言会发现这个和C++完全一样,但和C++不同的是在LPC语言中如果没有指定访问控制符,默认是 public 而不是 private

在对象的内部,无论成员(函数和变量)被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。而在对象的外部(定义对象的代码之外),只能在对象上通过 -> 访问对象的成员函数,并且只能访问 public 属性的成员,不能访问 private、protected 属性的成员。但是当存在继承关系时,父对象中的 protected 成员可以在子对象中使用,而父对象中的 private 成员不能在子对象中使用。

我们来演示一下,新建三个文件 5.7.5.c5.7.5.1.c5.7.5.2.c,代码如下:

// 示例:5.7.5
private string s1;
protected string s2;
public string s3;

void create()
{
    s1 = "我是示例5.7.5 的 private 类型变量";
    s2 = "我是示例5.7.5 的 protected 类型变量";
    s3 = "我是示例5.7.5 的 public 类型变量";
}

private void test1()
{
    debug("调用 test1 方法");
    debug("我是示例5.7.5 的 private 类型函数");
}

protected void test2()
{
    debug("调用 test2 方法");
    debug("我是示例5.7.5 的 protected 类型函数");
}

public void test3()
{
    debug("调用 test3 方法");
    debug("我是示例5.7.5 的 public 类型函数");
}

void test()
{
    debug("调用 test 方法");
    debug(s1);
    debug(s2);
    debug(s3);
}

int main(object me, string arg)
{
    debug("调用 main 方法");
    test();
    test1();
    test2();
    test3();

    return 1;
}

void set_s1(string s)
{
    s1 = s;
}

string get_s1()
{
    return s1;
}

运行结果:

调用 main 方法
调用 test 方法
我是示例5.7.5 的 private 类型变量
我是示例5.7.5 的 protected 类型变量
我是示例5.7.5 的 public 类型变量
调用 test1 方法
我是示例5.7.5 的 private 类型函数
调用 test2 方法
我是示例5.7.5 的 protected 类型函数
调用 test3 方法
我是示例5.7.5 的 public 类型函数
// 示例:5.7.5.1

int main(object me, string arg)
{
    object ob = load_object("/cmds/test/5.7.5");

    ob->test1(); // 无效
    ob->test2(); // 无效(MUDOS中有效)
    ob->test3();
    ob->test();
    // 使用封装的方法修改值
    ob->set_s1("5.7.5.1 重新设置 private 变量的值");
    // 通地封装的方法获取值
    debug(ob->get_s1());
    return ob->main();
}

运行结果:

调用 test3 方法
我是示例5.7.5 的 public 类型函数
调用 test 方法
我是示例5.7.5 的 private 类型变量
我是示例5.7.5 的 protected 类型变量
我是示例5.7.5 的 public 类型变量
5.7.5.1 重新设置 private 变量的值
调用 main 方法
调用 test 方法
5.7.5.1 重新设置 private 变量的值
我是示例5.7.5 的 protected 类型变量
我是示例5.7.5 的 public 类型变量
调用 test1 方法
我是示例5.7.5 的 private 类型函数
调用 test2 方法
我是示例5.7.5 的 protected 类型函数
调用 test3 方法
我是示例5.7.5 的 public 类型函数
// 示例:5.7.5.2
inherit "/cmds/demo/5.7.5";

int main(object me, string arg)
{
    debug("直接继承变量!");
    debug(s2);
    debug(s3);
    debug("直接继承方法!");
    test2();
    test3();
    // 可以修改变量的值
    set_s1("我是 5.7.5.2 通过方法修改的 s1");
    s2 = "我是 5.7.5.2 直接修改 s2";
    s3 = "我是 5.7.5.2 直接修改 s3";
    test();

    return 1;
}

运行结果:

直接继承变量!
我是示例5.7.5 的 protected 类型变量
我是示例5.7.5 的 public 类型变量
直接继承方法!
调用 test2 方法
我是示例5.7.5 的 protected 类型函数
调用 test3 方法
我是示例5.7.5 的 public 类型函数
调用 test 方法
我是 5.7.5.2 通过方法修改的 s1
我是 5.7.5.2 直接修改 s2
我是 5.7.5.2 直接修改 s3

需要注意的是,使用 -> 访问无权访问的方法时,不会编译错误,就像访问一个不存在的方法一样,返回值为 0 。

private 关键字的作用在于更好地隐藏类的内部实现,该向外暴露的接口(能通过对象访问的成员)都声明为 public,不希望外部知道、或者只在类内部使用的、或者对外部没有影响的成员,都建议声明为 private。

根据C++软件设计规范,实际项目开发中的成员变量以及只在类内部使用的成员函数(只被成员函数调用的成员函数)都建议声明为 private,而只将允许通过对象调用的成员函数声明为 public。

将成员变量都声明为 private,如何给它们赋值呢,又如何读取它们的值呢?我们可以额外添加两个 public 属性的成员函数,一个用来设置成员变量的值,一个用来修改成员变量的值。给成员变量赋值的函数通常称为 set 函数,它们的名字通常以set开头,后跟成员变量的名字;读取成员变量的值的函数通常称为 get 函数,它们的名字通常以get开头,后跟成员变量的名字。

除了 set 函数和 get 函数,在创建对象时还可以调用 create() apply 方法来初始化默认成员变量,不过只能给成员变量赋值一次,以后再修改还得借助 set 函数。

这种将成员变量声明为 private、将部分成员函数声明为 public 的做法体现了类的封装性。所谓封装,是指尽量隐藏类的内部实现,只向用户提供有用的成员函数。

有读者可能会说,额外添加 set 函数和 get 函数多麻烦,直接将成员变量设置为 public 多省事!确实,这样做 99.9% 的情况下都不是一种错误,但是,将成员变量设置为 private 是一种软件设计规范,而且让你的代码更安全,尤其是在大中型项目中,还是请大家尽量遵守这一原则。

多态

多态有二种方式:覆盖和重载。覆盖是子对象重新定义继承对象中的方法;重载是指允许存在多个同名方法,而这些方法的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。但是,LPC语言中只支持覆盖,不支持重载,如果在同一个对象中定义多个同名方法是会报错的,而在子对象定义和父对象同名不同参数的对象会警告函数参数和以前定义的不一样。

varargs

虽然LPC语言中不支持重载,但是有一个和重载类似的功能,使用 varargs 声明可变参数的方法,这个也可以理解为LPC语言中的重载,只是实现时不用写成多个方法,而是在一个不定参数的方法中。

如:

varargs void test(int x, string y, mapping z)
{
    //...
}

在调用中我们可以使用test()test(1)test(1,"test")test(1,"test",(["a":1])),请注意,参没有传参的形式参数,会被初始化为 int 型 0 值,可以使用nullp()undefinedp()判断是否传参数。

另外,除了varargs的方式,还有一种可变参数的用法,请看以下示例:

void test(mixed *arg...)
{
    //...
}

对这种方式,我们可以传的任何参数都被处理成 arg 数组的元素,如:test(1,2,3,4,5,6) 后, arg = ({1,2,3,4,5,6})

对覆盖的方法,调用父类方法使用操作符 ::

如下示例 5.7.5.3.cmain() 方法覆盖了父对象的同名方法,但是可以使用 ::main(me, arg) 直接调用继承对象的方法:

// 示例:5.7.5.3
inherit "/cmds/test/5.7.5";

int main(object me, string arg)
{
    debug("示例5.7.5.3");

    s2 = "我是示例5.7.5.3 的 protected 类型变量";
    s3 = "我是示例5.7.5.3 的 public 类型变量";

    ::main(me, arg);

    return 1;
}

执行结果:

示例5.7.5.3
调用 main 方法
调用 test 方法
我是示例5.7.5 的 private 类型变量
我是示例5.7.5.3 的 protected 类型变量
我是示例5.7.5.3 的 public 类型变量
调用 test1 方法
我是示例5.7.5 的 private 类型函数
调用 test2 方法
我是示例5.7.5 的 protected 类型函数
调用 test3 方法
我是示例5.7.5 的 public 类型函数

继承时一定要注意一个问题:避免钻石继承,所谓钻石继承,就是B和C都继承了A,D又继承了B和C,A、B、C、D的继承关系就成了钻石继承。

钻石继承很容易造成方法和变量重复定义的问题,我们建一个示例 5.7.5.4.c,内容如下:

// 示例:5.7.5.4
inherit "/cmds/demo/5.7.5.2";
inherit "/cmds/demo/5.7.5.3";

运行后,好像没毛病,看看日志文件 log_error,全是重复定义和重复继承的警告,这些都是可能造成BUG的因素。

如果继承了多个实现同名方法的对象,通过::操作符调用父类方法是会以找到的第一个方法的为准,如果继承了多个实现同名方法的对象,作为继承直接调用,会以最后继承的为准。

使用::和不使用调用父类的方法还有一个不同点,如果直接调用方法,非不定参数的方法参数不一致会报错,而通过::调用不会报错。

如果要指定继承对象的方法,可以使用 对象文件名::方法 的方式调用,需要注意的是文件名不能有特殊符号,只能是字母、数字和_,所以文件命名时要注意。

注意:因为文件名就是类名,所以我们在取名时文件名最好符合变量的命名规范:文件名不以数字开始,不包含除_以外的特殊符号,不使用LPC保留字,否则在代码中因为类的名称不合法而无法在调用父类方法时指定具体父类,另外如果文件名包括#也无法正常实例化。

nomask

对象中定义的方法可以被覆盖,但有的方法,我们不允许被覆盖,这时可以使用 nomask 修饰符限制方法的覆盖。如以下 test() 方法如果被覆盖的话编译会报错。

protected nomask void test()
{
    // ...
}

小结

在 LPC 中,每个 .c 文件通常就是定义了一个“对象类”(Class),可以理解为“类的定义模板”。

当这个文件被加载或克隆(clone)时,就会在游戏世界中生成一个“对象实例”(Instance)。


🧩 详细解释

🧱 每个 .c 文件 ≈ 一个“类”

  • 在 LPC 中,每个 .c 文件定义了一个可加载的对象,包括属性(变量)、行为(函数),以及继承关系等。
  • 你可以把这个文件类比为 C++/Java 中的“类”,但 LPC 更偏向“原型式继承”与“运行时动态加载”。
// 文件名:/d/city/room/street.c

inherit "/std/room";  // 继承自系统提供的房间基础类

void create() {
    set("short", "城市街道");
    set("long", "这是一条繁华的街道,两边是商铺和行人。");
    set("exits", ([
        "north" : "/d/city/room/square",
        "south" : "/d/city/room/gate",
    ]));
}

这个文件就定义了一个“房间类”,每次加载它,系统就会创建一个 room 对象。


🧬 对象实例的创建:new() vs clone_object()

  • 通常,系统会通过 clone_object() 或类似机制来创建一个 .c 文件的运行时实例
  • 例如怪物、道具、NPC 等,通常是每次创建一个新的副本(instance)
object ob;
ob = clone_object("/d/city/npc/guard");
move_object(ob, this_object());

📦 总结类与对象的区别(在 LPC 中):

概念 LPC 中的实现 类比于其他语言
类(Class) 一个 .c 文件,定义对象的结构、方法等 Java/C++ 中的类
对象(Object) 加载或克隆 .c 文件后生成的运行时实体 类的实例
继承 使用 inherit 关键字继承其他文件 类继承
构造函数 create() 函数,在对象加载或克隆时调用 构造函数

🎯 小提示:

  • 房间类NPC 类物品类等都是通过 .c 文件定义的。
  • 继承机制非常强大 —— 你可以建立一个继承链,比如所有房间都继承自 /std/room,所有 NPC 都继承 /std/npc
  • 动态热更新:你可以在不重启服务器的情况下重新加载某个 .c 文件,实现功能热更新。

关于LPC类和对象更多介绍可参考:https://bbs.mud.ren/threads/246

京ICP备13031296号-4