关键词“Function”及Methods异同分析

原文地址:Keyword "function" & Methods Explained

GM 版本: Studio 2.3+
针对平台: Windows / All
GMS2.3 发布以后在脚本和函数的定义和使用上跟以往有了较大的区别,因此在官方论坛出现了这一篇阐述其中基本概念和使用要点的教程,跟大家分享一下。

主题

今天我们来聊一聊函数,本文将会具体介绍关键词——“function”以及方法函数——“method()”
同时我们也会介绍“绑定”——它到底是什么以及为何在函数使用中这么重要?

提前预警,本文很长,但我会尽量用简单易懂的形式来进行说明😉。


我们先介绍一下本文的结构,首先我们会介绍函数的概念,然后会介绍“绑定”,最后则是 GML内置的 "method()"函数

注意:本教程中涉及的代码示例中不会使用对象(object)而是用结构体( struct )来进行说明,因为结构体更简单,也不用去处理各种事件。
当然这些代码在“结构体/对象”上都可以正常工作,如有必要只需要进行一些简单调整即可。

函数(Function)

(1) 函数类型
在 GameMaker 中函数有两种形式,分别是“命名函数”“匿名函数”
用实际例子来说明这个问题应该是最好的选择

//这是一个“命名函数”示例
function printHello() {
    show_debug_message("hello");
}
// 如你所见套用了以下格式:
// function name(arguments) { code }

// 这是一个“匿名函数”示例:
printWorld = function() {
    show_debug_message("World");
}
// 如你所见套用了以下格式:
// variable = function(arguments) { code }

正如你可能已经猜到的那样,命名函数有一个关联的“名称”,在编译过程中这个名称会单独分配一个索引用来被调用,而匿名函数没有这个索引名称,必须存储在某个变量中才能被调用,以下是函数类型的一些规则:

  • 命名函数在编译时会被分配一个索引(使用「 typeof() 」函数可以打印出类型为「number」)
  • 不能在作用域中重新声明一个已命名的函数(在同一作用域中不能有两个相同名称的函数)
  • 在脚本文件中声明的命名函数会成为全局函数(也称为脚本函数)
  • 匿名函数必须存储在变量中(使用 「typeof()」函数可以打印出类型为「method」)

我们已经简单介绍了函数类型,接下来我们简单说明一下全局函数

(2) 全局函数

如上所述,所有在脚本文件中声明的函数都是全局函数(即脚本函数)。这种函数在功能上与 GameMaker Studio 早期版本(2.3 以前)中的脚本,全局函数是指在整个项目范围都可以使用的函数,这意味着一旦声明,就可以在代码中的任意位置调用该函数。

我们来看一下这个脚本中声明的函数示例

function printHello() {
    show_debug_message("Hello");
}

你可以在任何对象/结构体/房间的代码中调用这个函数。
所以,也许很多人已经想到了这一点

所有内置的 GML 函数都是全局函数!!!

你的想法无比正确, GML本身提供的所有函数都是全局函数!

绑定(Binding)

(1) 这是什么?
现在我们要引入稍微复杂一点的概念——“绑定”。绑定是指“某个函数内的方法变量与其所作用的对象/结构的变量之间建立了链接”
换句话说,当一个函数被执行时,它代码中所使用的变量属于一个固定的作用域,可以说这个函数被绑定到该作用域

(2) 未绑定函数
我们首先介绍一下没有特定作用域的函数。
当这些函数被执行时,它们仅影响被调用位置的作用域。

举个例子,我们看下方的代码(如果你想跟着教程动动手,也可以把这些代码放到一个空白的脚本文件中)
GML:

// 该函数是全局函数
function printHp() {
    // 打印变量 HP 的值
    show_debug_message(hp);
}

// 这个构造函数定义了一个“Player”的结构体
function Player() constructor {
    hp = 500;

    printHp(); // 调用全局函数 printHp()
}

// 这个构造函数定义了一个“Enemy”结构体
function Enemy() constructor {
    hp = 200;

    printHp(); // 调用全局函数 printHp()
}

// 我们调用构造函数创建一个新的“Player”结构体
// 这将会执行构造函数内的代码
var _player = new Player();

// 我们调用构造函数创建一个新的“Enemy”结构体
// 这将会执行构造函数内的代码
var _enemy = new Enemy();

现在也许你已经猜到控制台会输出什么内容了,对吗?
这应该不难,但我们还是来看一下具体的输出内容

500 // Player 的 HP 值
200 // Enemy 的 HP 值

由此,我们可以了解到“全局函数”都是没有绑定(链接)到任何作用域的。
我们在上面的示例代码下面增加以下内容,来进一步做个实验

// ...
var _player = new Player(); // 老代码,输出为 500
var _enemy = new Enemy(); // 老代码,输出为 200

//这里的调用不在 Player 或 Enemy 结构体中,因此获取不到 HP 变量
printHp(); // 这个调用就会引发报错

在这种情况下,我们在一个没有变量 HP 存在的作用域里调用了全局函数printHp(),就会抛出异常。
同一个函数可以给出三个完全不同的输出,换句话说,未绑定函数的具体输出内容取决于你调用该函数的作用域。

(3) 绑定函数(方法变量)
上面的例子听起来显得简单且直观,这很可能是因为老版 GameMaker Studio 里的脚本就是这么工作的

但是,正如之前所说,除了全局函数(那些在脚本文件中命名和声明的函数)之外,我们还有“方法变量”(所有的非全局函数)

这包含了所有的匿名函数以及未在脚本文件中声明的命名函数。

方法变量的声明会自动在函数和作用域之间创建绑定关系。

为了能更好理解这一概念,让我们看一下下面的代码(同样的,如果你想跟着操作可以把这些内容放到一个空的脚本文件中)。这些内容跟之前的非常类似,但是这次我们在结构体内定义函数(这些将成为方法变量)

function Player() constructor {
    name = "player";
    hp = 100;

    // 这是结构体内的一个命名函数 (方法变量)
    function printName() {
        show_debug_message(name);
    }

    // 这是结构体内的一个匿名函数 (方法变量)
    printHp = function() {
        show_debug_message(hp);
    }
}

function Enemy() constructor {
    name = "enemy";
    hp = 2000;
}

var _player = new Player();

_player.printName(); // 打印结果 -> player
_player.printHp(); // 打印结果 -> 100

相信上方的代码会十分容易理解,现在我们要添加一些内容来更深入学习一下。
让我们把“ printName ”和“ printHp ”存储到敌人内部并运行一下:

var _enemy = new Enemy();

_enemy.printName = _player.printName;
_enemy.printHp = _player.printHp;

_enemy.printName(); // ??
_enemy.printHp(); // ??

你能猜出上面会输出什么结果吗?
再仔细看一看代码和我们前面所说的规则,来试着推算一下吧。

player
100

你做对了吗?意外吗?
让我们再来看一下这段代码,然后排一下这段代码的执行顺:

function Player() constructor {
    name = "player"; // (2)
    hp = 100; // (3)

    // 这是结构体内的一个命名函数 (方法变量)
    function printName() { // (4)
        show_debug_message(name);
    }

    // 这是结构体内的一个匿名函数 (方法变量)
    printHp = function() { // (5)
        show_debug_message(hp);
    }
}

function Enemy() constructor {
    name = "enemy"; // (9)
    hp = 2000; // (10)
}

var _player = new Player(); // (1) <------------------- 开始执行的起点

_player.printName(); // (6) 输出结果 -> player
_player.printHp(); // (7)输出结果 -> 100

// 到此为止一切都很顺利!

var _enemy = new Enemy(); // (8)

_enemy.printName = _player.printName; // (11)
_enemy.printHp = _player.printHp; // (12)

_enemy.printName(); // (13) ----> 此处将调用 (4)
_enemy.printHp(); // (14) ----> 此处将调用 (5)

现在我们来按照顺序逐行分析这些代码

  • (1) 我们开始构建一个Player结构体实例(是所有代码实际开始执行的起点)
  • (2) & (3) 声明了Player的内部变量 name and hp
  • (4) 创建了一个命名函数,它在Player结构体内部,因此是一个方法变量(这个变量将绑定作用于这个Player结构体)
  • (5)我们又创建了一个匿名函数,因此它也是个方法变量(此变量同样绑定在这个Player作用域)
  • 至此构造函数结束并返新创建的Player结构体
  • (6) 调用 printName 将打印“name”变量的值 "player"
  • (7) 调用 printHp 将打印对应的值100
  • (8) 我们创建了一个Enemy的实例
  • (9) & (10) 初始化Enemy内的变量(name & hp)
  • -- 至此Enemy的构造函数结束并返回新创建的Enemy结构体实例
  • (11) & (12) 我们把Player中的方法变量 (printName & printHp) 储存到了Enemy结构体中
  • (13) 我们调用Enemy中的 方法变量 printName,但因为这个函数已经绑定在Player的作用域上,因此这个函数最后按照Player范围内的值执行给出最终结果。
  • (14) 我们调用Enemy中的 方法变量 printHp,但因为这个函数也绑定在Player的作用域上,因此这个函数最后也按照Player范围内的值执行给出最终结果。

如上所述,方法变量(与全局函数相反)会绑定到特定的范围,即声明该变量的作用域。
在对象/房间/结构实例间传递函数并不会改变函数的执行结果。
因此,根据目前为止我们所学到的内容进行总结,可以得到下面这个表,让我们更清晰地了解命名函数和匿名函数在不同位置声明会得到一个什么样的函数:

NOTE: 你可能已经发现了,在这个表格里把实例和房间分成了一组,这是因为房间的创建代码本质上其实是GMS在房间内创建了一个特定实例来执行这些代码的。

(4) 内置的“方法”函数
到目前为止,我们已经讨论了未绑定函数(比如全局函数)和绑定函数(比如方法变量),你应该已经基本了解这些概念了,这很好,但是在GML中还有一个非常简单的内置函数,可以用来:

  • 绑定一个未绑定函数
  • 解除一个绑定函数的绑定关系
  • 重新绑定一个已经绑定的函数(不会覆盖原函数而是新建一个方法变量实例)

这一切都要归功于“ method() ”这个内置函数,这个函数可以传入一个作用域(实例/结构体/为定义)和一个可调用的函数(函数/方法变量),然后返回一个绑定到该作用域的新的方法变量。
下面我们用个代码例子来帮助我们更好地理解这一点:

// 这是个脚本文杰,因此下面的函数是个全局函数
// 所以如我们前面讨论的这个函数没有绑定任何作用域
function printName() {
    show_debug_message(name);
}

// 我们创建了一个player/enemy的结构体实例
// 这次直接使用了结构体而非构造函数(为了展示两者效果完全一致)
player = { name: "Hero" };
enemy = { name: "Demon" };

// 如果你把下面这一行的注释给取消并运行应该会提示错误,因为在函数的作用域中没有"name"这个变量
// printName() -----> [ERROR]

// 但是我们使用method()函数可以把对应的函数和作用域给绑定到一起
printPlayerName = method(player, printName);

printPlayerName(); // 这里会输出"Hero"!

// 我们还可以利用这个函数去绑定创建一个已经绑定的函数
// 在下面这个例子里会基于绑定到player的函数重新生成一个新的绑定到enemy作用域的函数
printEnemyName = method(enemy, printPlayerName);

// 现在这个函数将使用enemy作用域
printEnemyName(); // 这会输出 "Demon"!

// 上面还提过可以定义“undefined”(未定义)作用域
// 这么做会解除一个绑定函数的绑定作用域
unboundPrintName = method(undefined, printPlayerName);

// [注意] 'unboundPrintName' 并不等同于 'printName'
// 尽管两者都是未绑定函数而去都会调用他们被调用域内的变量
// 'printName' 是全局函数可以在项目内任何位置调用,而 'unboundPrintName' 不是

// 取消下面这一行的注释会得到跟前面 'printName'一样的报错提示
// unboundPrintName() -----> [ERROR]

有了以上代码中的详细注释,我想你应该更了解函数/方法/作用域/绑定这些概念了。
但是在继续学习之前,还是要总结一下"method()"函数

  • 该函数允许把可调用的函数方法等绑定到某一作用域(实例/结构)
  • 该函数不会直接修改传入函数的作用域,而是会返回一个绑定于新作用域的新的方法变量
  • "undefined"作用域会返回一个未绑定的方法变量(仅在调用作用域内执行)

注意: 创建新方法并不会创建新函数。我们需要密切注意​这里的命名函数本身并不是一个真正的函数。
可以这么理解,method()是对“可调用函数和作用域”的一个包装器,因此你不需要复制函数本身,而是创建了一个新的包装器。

静态函数(Static Functions)

在结束之前,我们还要聊一下“静态(static)”变量。之所以要在本文中简要介绍“静态函数”,是因为它们的工作方式跟我们之前所学到的那些有些区别。
在上面的内容里我们学到了匿名函数会自动绑定到声明这个函数的作用域上,确实如此,除非这是一个静态函数
为了更清晰地理解这个问题,我们来看看下面的代码:

function Player() constructor {

    name = "Hero";
    hp = 1000;

    // 我们来定义一个匿名函数
    //这个函数不会绑定当前作用域
    static printName = function() {
        show_debug_message(name);
    }

}

var _player = new Player();
// 从player实例中调用该匿名函数,因此会输出player的name变量的值
_player.printName(); // Hero

var _enemy = { name: "Demon" };
_enemy.printName = _player.printName;

//这个函数从enemy实例进行调用,因此输出enemy的name值
_enemy.printName(); // Demon

在这个例子里,我们可以看到静态函数的执行机制与全局函数非常相似,因为他们都没有绑定到任何作用域上,但同时其实静态函数并不是全局可调用的。因此,简而言之静态函数“几乎就像”一个自动绑定到"undefined"域的匿名函数。基于这一点,我们可以进一步完善上面那个表格

Q&A

(1) SELF & OTHER
When we use method variables (bound functions) how does self and other behave? Looking at everything you've learned above about functions and methods you might be tempted to say that method variables kind of "work very similar" to a with statement calling an unbound function inside it, right?
当我们使用方法变量(绑定函数)时,self和other会如何工作?回忆一下你在上面函数和方法中所了解到的内容,你可能会想说方法变量和使用with()语句然后在其中调用一个未绑定函数的工作机制非常相似对吗?

with(myScope) {
    myUnboundFunction();
}

听起来不错,并且最终执行效果也确实一样,但你还是要注意:

  • self inside a method variable with work as expected and return the current scope of the method.
  • 在方法变量中“self”会按预期执行,并返回当前方法变量的作用域
  • 而“other”不会按预期工作,因为调用方法变量并不改变作用域

注意: 比较一下这两种实现的性能差异,“方法变量”比使用 “with()语句”差不多快2倍

(2) 绑定函数中的未绑定函数
如果我在绑定函数内调用了一个未绑定函数会发生什么事情?
The quick answer for that is "the unbound function will be called within the scope of the bound function".
最简单的答案就是——“将会在绑定函数的作用域内调用这个未绑定函数”。

让我们来看一下代码示例(本代码片段同样位于脚本文件中)

// 这是一个全局函数因此是未绑定的
function printName() {
    show_debug_message(name);
}

// 这也是个全局函数因此也是未绑定的
function printStats() {
    show_debug_message("Name: " + name + ", Hp: " + string(hp) + ", Mp: " + string(mp));
}

function Player() constructor {

    name = "hero";
    hp = 100;
    mp = 10;

    // 这是一个位于结构体内的非静态的匿名函数
    // 看一下我们上面画的表格我们可以发现这是个绑定于声明这个函数所在域的函数
    contextExecutor = function(_func) {
        // 所有的代码都会在player这个域中执行
        // 因此当我们执行这个当作参数传入的函数时就会有两种情况出现:
        // - 如果这个函数是绑定的那就会基于绑定的域执行
        // - 如果是未绑定函数就会基于当前实例的域来执行
        _func();
    }
}

// 下面两行代码如果执行都会报错
// printName(); // [ERROR] 变量 'name' 不存在
// printStats(); // [ERROR] 变量 'name'/'hp'/'mp' 不存在

var _player = new Player();

// 但是下面的两行代码则可以正常执行
// 我们把未绑定函数作为参数传递给了包装器方法
// 这样他们都会在player实例的域内执行
_player.contextExecutor(printName);
_player.contextExecutor(printStats);

// 即便我们把方法变量存储到局部变量中
// 这些方法仍然可以在player的作用域内执行因此可以输出符合预期的结果内容
var executor = _player.contextExecutor;
executor(printName);
executor(printStats);

(3) 在结构体字段内定义函数
如果我们在结构体字段哪定义一个函数会发生什么呢?这是另一个特别搞混的问题。
让我们来设想一下,我们正处于某个作用域(域A)并且我们正要创建一个结构体字段

// 这一层是某域A

value = "OUTSIDE";

structLiteral = {
    // 这里已经进入了另一个域B

    value: "INSIDE",

    // 这里在域B中定义了一个匿名函数(自动绑定到域B)
    // 因此此处的变量“value”应该指向域B中的值“INSIDE”
    printValue: function() {
        show_debug_message(value);
    }
}

structLiteral.printValue(); // 输出"INSIDE"

如你所见,当我们在结构体内定义“pinrtValue”函数时它自动绑定到了结构体的域上,这意味着变量会指向结构体内部的变量。但是如果我们稍微调整一下代码的顺序就会发生一些变化,如下所示:

// 这一层是域A

value = "OUTSIDE";

// 这里在域A中定义了一个匿名函数(自动绑定到域A)
// 因此此处的变量“value”应该指向域A中的值“OUTSIDE”
var _printValue = function() {
    show_debug_message(value);
}

structLiteral = {
    // 这里开始进入域 B

    value: "INSIDE",

    // 这里我们把变量指向外层定义的匿名函数
    //因此函数的作用域不会发生变化依旧是域A
    printValue: _printValue
}

structLiteral.printValue() // 输出"OUTSIDE"

这种做法跟我们在结构体字段外、域A内定义一个"printValue"的函数没什么区别,因此它也会自动绑定这个域.
注意: 你可以把“静态函数”传给结构体字段,这会像未绑定函数一样进行工作

(4) 方法变量和作用域
我不知道你是否已经对这个问题有所疑惑,但这问题却是很容易引发一些困惑。
正常步骤是:创建一个函数“function A”,然后将其绑定到“object B”,简单直接!
但现在的问题是在对象B中是否存在这个“function A”呢?
让我们来看看下面的代码以便更好理解这一问题。

// 声明一个函数
var _functionA = function() {
    // 随便来一点代码
}

// 声明一个对象实例
var _objectB = { /* 一些变量属性 */ };

// 让好我们把函数A绑定到实例B上
var _boundFunctionA = method(_objectB, _functionA);

// 现在对象实例内部有这个函数了吗?
// 或者说
_objectB._boundFunctionA(); // 这样的代码是否可以执行??

你认为呢?答案是否定的!即便我们新构建的方法变量"_boundFunctionA"已经绑定到"_objectB"上,但在这个作用域内其实并不实际存在这个变量。正如我们描述的过程那样,我们正在“将函数绑定到结构/实例”,因此绑定的主体是函数,而结构体还是原来的样子。
如果这个结构体中原本就没有这个方法,那绑定之后也还是没有这个方法。

如果后续还有新的问题,我将在此继续更新相关答案和代码示例!请保持关注;)

以上,函数/方法/绑定这几个概念简而言之

  • 函数可以命名或匿名
  • 全局函数是在脚本文件中定义的命名函数
  • 方法变量(非全局变量)是在实例/结构体/构造函数中定义的所有函数
  • 方法变量会自动绑定到声明它们的作用域(静态变量不绑定到任何域)
  • 方法变量的执行结果与调用位置无关
  • 未绑定函数的结果取决于代码中调用它们的位置

信息量挺大的,要反复阅读牢记才能更好地理解这些概念。

Here xD from xDGameStudios,
Good coding to you all.

2021-07-22 14:31
Comments
Write a Comment
  • liurengfei reply

    结构中静态方法的作用域是未绑定的,为当前调用方法的实例。

    当引用结构的静态方法时,它的使用作用域也将变成调用实例。

    脚本中

    function jh_Test1(_a) constructor {

    a = _a

    static f_TT = function() {

    show_message(string(a))

    }

    }

    类创建事件

    a=0

    j_10 = new jh_Test1(10)

    j_11 = new jh_Test1(11)

    j_10.f_TT()//显示10

    j_11.f_TT()//显示11

    f_exe = j_10.f_TT

    f_exe()//显示0

    结论:我们需要方法的使用作用域为归属实例,所以结构静态函数无用??