5. Python类和对象入门

我们已经学习了如何在游戏服务器内外运行一些简单的Python代码,也了解了游戏目录的结构。现在我们将开始实际使用它。

5.1. 导入模块

前几课中,我们已经学习了如何将资源导入代码。现在我们将更深入一些。

没有人会用一个庞大的文件来编写像在线游戏这样的大型项目。相反,代码会被拆分到不同的文件(模块)中,每个模块专注于不同的目的。这不仅使代码更清晰、更有组织、更易于理解,还能提高代码的可重用性——你只需导入需要的资源,确保只获取所需内容。这使得定位错误和识别代码质量更容易。

Evennia本身也以同样的方式使用你的代码——你只需告诉它特定类型代码的位置,它就会导入并使用它(通常代替其默认值)。

这是一个熟悉的例子:

> py import world.test ; world.test.hello_world(me)
Hello World!

在这个例子中,硬盘上的文件结构如下:

mygame/
    world/
        test.py    <- 这个文件中有一个hello_world函数

如果你遵循了之前的教程课程,mygame/world/test.py文件应该如下所示(如果没有,请修改):

def hello_world(who):
    who.msg("Hello World!")

重申一下,_python路径_描述了Python资源之间的关系,包括Python模块之间和内部(即以.py结尾的文件)。路径使用.并总是跳过.py文件扩展名。此外,Evennia已经知道从mygame/开始查找Python资源,所以这部分不应包含在路径中。

import world.test

import Python指令加载world.test使其可用。现在你可以”进入”这个模块获取想要的函数:

world.test.hello_world(me)

像这样使用import意味着每次想获取函数时都必须指定完整的world.test。这里有一个替代方案:

from world.test import hello_world

from ... import ...非常常见,特别是当你想获取路径较长的内容时。它直接导入hello_world,所以你可以立即使用它!

 > py from world.test import hello_world ; hello_world(me)
 Hello World!

假设你的test.py模块有一堆有趣的函数。你可以逐个导入它们:

from world.test import hello_world, my_func, awesome_func

如果有_很多_函数,你可以只导入test并在需要时从中获取函数(而不必每次都给出完整的world.test):

> from world import test ; test.hello_world(me)
Hello World!

你也可以_重命名_导入的内容。例如,如果导入到的模块已经有一个hello_world函数,但我们还想使用world/test.py中的那个:

from world.test import hello_world as test_hello_world

from ... import ... as ...形式会重命名导入。

> from world.test import hello_world as hw ; hw(me)
Hello World!

除非是为了避免上述的名称冲突,否则应避免重命名——你希望代码尽可能易于阅读,而重命名增加了潜在的混淆层。

Python基础介绍中,我们学习了如何打开游戏内的多行解释器。

> py
Evennia Interactive Python mode
Python 3.7.1 (default, Oct 22 2018, 11:21:55)
[GCC 8.2.0] on Linux
[py mode - quit() to exit]

现在你只需导入一次就可以重复使用导入的函数。

> from world.test import hello_world
> hello_world(me)
Hello World!
> hello_world(me)
Hello World!
> hello_world(me)
Hello World!
> quit()
Closing the Python console.

编写模块代码时也是如此——在大多数Python模块中,你会看到顶部有一堆导入,这些资源随后被该模块中的所有代码使用。

5.2. 关于类和对象

现在我们已经了解了导入,让我们来看一个真正的Evennia模块并尝试理解它。

在你选择的文本编辑器中打开mygame/typeclasses/scripts.py

# mygame/typeclasses/script.py
"""
模块文档字符串
"""
from evennia import DefaultScript

class Script(DefaultScript):
    """
    类文档字符串
    """
    pass

实际文件要长得多,但我们可以忽略多行字符串(""" ... """)。这些作为模块(顶部)和下面class的_文档字符串_。

在模块文档字符串下面我们有_导入_。这里我们从核心evennia库本身导入资源。我们稍后会深入探讨,现在只需将其视为黑盒。

名为ScriptclassDefaultScript继承。如你所见,Script几乎是空的。所有有用的代码实际上都在DefaultScript中(Script_继承_了这些代码,除非它用自己的同名代码_覆盖_它)。

我们需要稍微绕道来理解什么是’类’、‘对象’或’实例’。这些是在高效使用Evennia之前需要理解的基本概念。

5.2.1. 类和实例

‘类’可以看作是对象’类型’的’模板’。类描述了该类的每个对象的基本功能。例如,我们可以有一个Monster类,它具有从一个房间移动到另一个房间的资源。

新建一个文件mygame/typeclasses/monsters.py。添加以下简单类:

class Monster:

    key = "Monster"

    def move_around(self):
        print(f"{self.key} is moving!")

上面我们定义了一个Monster类,有一个变量key(即名称)和一个_方法_。方法类似于函数,但它”位于”类上。它还总是至少有一个参数(几乎总是写作self,尽管原则上你可以使用其他名称),这是对自身的引用。所以当我们打印self.key时,我们指的是类上的key

类只是一个模板。在使用之前,我们必须创建类的_实例_。如果Monster是一个类,那么实例就是Fluffy,一个特定的龙个体。你通过_调用_类来实例化,就像调用函数一样:

fluffy = Monster()

让我们在游戏中试试(我们使用py多行模式,这样更容易):

> py
> from typeclasses.monsters import Monster
> fluffy = Monster()
> fluffy.move_around()
Monster is moving!

我们创建了一个Monster的_实例_,存储在变量fluffy中。然后我们调用fluffy上的move_around方法来获取打印输出。

注意我们_没有_像fluffy.move_around(self)这样调用方法。虽然在定义方法时self必须存在,但在调用方法时我们_从不_显式添加它(Python会在幕后自动为我们添加正确的self)。

让我们创建Fluffy的兄弟Cuddly:

> cuddly = Monster()
> cuddly.move_around()
Monster is moving!

现在我们有两个怪物,它们会一直存在,直到我们调用quit()退出这个Python实例。我们可以让它们移动任意多次。但无论我们创建多少怪物,它们都会显示相同的打印输出,因为key始终固定为”Monster”。

让我们让类更灵活一些:

class Monster:

    def __init__(self, key):
        self.key = key

    def move_around(self):
        print(f"{self.key} is moving!")

__init__是Python识别的一个特殊方法。如果提供,它会处理实例化新Monster时的额外参数。我们让它添加一个参数key,存储在self上。

现在,为了让Evennia看到这个代码更改,我们需要重新加载服务器。你可以这样做:

> quit()
Python Console is closing.
> reload

或者你可以使用单独的终端从游戏外部重启:

$ evennia reload   (或 restart)

无论哪种方式,你都需要再次进入py

> py
> from typeclasses.monsters import Monster
fluffy = Monster("Fluffy")
fluffy.move_around()
Fluffy is moving!

现在我们向类传递了"Fluffy"作为参数。这进入了__init__并设置了self.key,我们稍后用它来打印正确的名字!

5.2.2. 对象有什么好处?

到目前为止,我们看到类所做的只是表现得像我们最初的hello_world函数,但更复杂。我们本可以只做一个函数:

     def monster_move_around(key):
        print(f"{key} is moving!")

函数和类的实例(对象)之间的区别在于对象保持_状态_。一旦你调用了函数,它就会忘记你上次调用它时的一切。而另一方面,对象会记住变化:

> fluffy.key = "Fluffy, the red dragon"
> fluffy.move_around()
Fluffy, the red dragon is moving!

fluffy对象的key被更改,只要它存在就会保持。这使得对象对于表示和记住数据集合非常有用——其中一些数据又可以是其他对象。一些例子:

  • 具有所有属性的玩家角色

  • 具有HP的怪物

  • 装有若干金币的箱子

  • 内有其他对象的房间

  • 政党的当前政策立场

  • 解决挑战或掷骰子的规则方法

  • 复杂经济模拟的多维数据点

  • 还有更多!

5.2.3. 类可以有子类

类可以相互_继承_。”子”类将从其”父”类继承所有内容。但如果子类添加了与父类同名的内容,它将_覆盖_从父类获得的内容。

让我们用另一个类扩展mygame/typeclasses/monsters.py

class Monster:
    """
    这是Monster的基类。
    """

    def __init__(self, key):
        self.key = key

    def move_around(self):
        print(f"{self.key} is moving!")


class Dragon(Monster):
    """
    这是一个龙怪物。
    """

    def move_around(self):
        print(f"{self.key} flies through the air high above!")

    def firebreath(self):
        """
        让我们的龙喷火。
        """
        print(f"{self.key} breathes fire!")

我们添加了一些文档字符串以提高清晰度。添加文档字符串总是一个好主意;你也可以为方法添加文档字符串,如新的firebreath方法所示。

我们创建了新类Dragon,但我们也通过添加括号中的父类指定MonsterDragon的_父类_。class Classname(Parent)是这样做的方式。

让我们试试新类。首先reload服务器,然后:

> py
> from typeclasses.monsters import Dragon
> smaug = Dragon("Smaug")
> smaug.move_around()
Smaug flies through the air high above!
> smaug.firebreath()
Smaug breathes fire!

因为我们没有(重新)在Dragon中实现__init__,所以我们从Monster中获取了它。我们确实在Dragon中实现了自己的move_around,所以它_覆盖_了Monster中的那个。而firebreath只对Dragon可用。在Monster上有这个方法没有多大意义,因为不是每个怪物都能喷火。

即使你正在覆盖某些内容,也可以强制一个类使用父类的资源。这是通过super()方法完成的。如下修改你的Dragon类:

# ...

class Dragon(Monster):

    def move_around(self):
        super().move_around()
        print("The world trembles.")

    # ...

保留Monsterfirebreath方法。上面的# ...表示其余代码未更改。

super().move_around()行意味着我们正在调用类父类上的move_around()。所以在这种情况下,我们将在做自己的事情之前先调用Monster.move_around

要查看,reload服务器然后:

> py
> from typeclasses.monsters import Dragon
> smaug = Dragon("Smaug")
> smaug.move_around()
Smaug is moving!
The world trembles.

我们可以看到Monster.move_around()首先被调用并打印”Smaug is moving!”,然后是Dragon类中关于世界颤抖的额外内容。

继承是一个强大的概念。它允许你组织和重用代码,同时只添加你想改变的特殊内容。Evennia经常使用这一点。

5.2.4. 多重继承一瞥

在你选择的文本编辑器中打开mygame/typeclasses/objects.py

"""
模块文档字符串
"""
from evennia import DefaultObject

class ObjectParent:
    """
    类文档字符串 
    """

class Object(ObjectParent, DefaultObject):
    """
    类文档字符串
    """
    pass

在这个模块中,我们有一个名为ObjectParent的空class。它不做任何事情,它的唯一代码(除了文档字符串)是pass,意思是,嗯,跳过并不做任何事情。由于它也没有从任何东西_继承_,它只是一个空容器。

名为ObjectclassObjectParentDefaultObject继承。通常一个类只有一个父类,但这里有两个。我们已经了解到,除非子类覆盖它,否则子类会从父类继承所有内容。当有多个父类(“多重继承”)时,继承从左到右发生。

所以如果objObject的一个实例,我们尝试访问obj.foo,Python会首先检查Object类是否有属性/方法foo。接下来它会检查ObjectParent是否有。最后,它会检查DefaultObject。如果都没有,你会得到一个错误。

为什么Evennia要设置一个像这样的空父类?为了回答这个问题,让我们看看另一个模块mygame/typeclasses/rooms.py

"""
...
"""

from evennia.objects.objects import DefaultRoom

from .objects import ObjectParent

class Room(ObjectParent, DefaultRoom):
    """
	...
    """
    pass

这里我们看到Room从相同的ObjectParent(从objects.py导入)和来自evennia库的DefaultRoom父类继承。你会发现CharacterExit也是如此。这些都是”游戏内对象”的例子,所以它们很可能有很多共同点。ObjectParent的存在为你提供了一种(可选的)方式来添加_应该对所有游戏实体相同_的代码。只需将该代码放入ObjectParent,所有对象、角色、房间和出口都会自动拥有它!

我们将在下一课中回到objects.py模块。

5.3. 总结

我们从类创建了第一批龙。我们学习了如何将类_实例化_为_对象_。我们看到了一些_继承_的例子,并测试了用子类中的方法_覆盖_父类中的方法。我们还有效地使用了super()

到目前为止,我们使用了相当原始的Python。在接下来的课程中,我们将开始研究Evennia提供的额外内容。但首先我们需要学习到哪里找到所有东西。