Download PDF version of this article
这篇以及其他acmqueue文章已被翻译成葡萄牙语
葡萄牙语版 Q

通过针眼传递语言

Lua的可嵌入性如何影响其设计

Roberto Ierusalimschy、Luiz Henrique de Figueiredo和Waldemar Celes,lua.org


脚本语言是当前编程语言领域的重要组成部分。脚本语言的一个关键特性是它与系统语言集成的能力。7 这种集成主要有两种形式:扩展和嵌入。在第一种形式中,您使用系统语言编写的库和函数扩展脚本语言,并在脚本语言中编写您的主程序。在第二种形式中,您将脚本语言嵌入到宿主程序(用系统语言编写)中,以便宿主可以运行脚本并调用脚本中定义的函数;主程序是宿主程序。在这种设置中,系统语言通常被称为宿主语言。

许多语言(不一定是脚本语言)都支持通过FFI(外部函数接口)进行扩展。FFI不足以让系统语言中的函数完成脚本中函数可以完成的所有事情。然而,在实践中,FFI涵盖了扩展的大部分常见需求,例如访问外部库和系统调用。另一方面,嵌入更难支持,因为它通常需要宿主程序和脚本之间更紧密的集成,而仅靠FFI是不够的。

在本文中,我们将讨论可嵌入性如何影响语言的设计,特别是它如何从一开始就影响了Lua的设计。Lua3,4 是一种脚本语言,特别强调可嵌入性。它已被嵌入到广泛的应用中,并且是游戏脚本编写的领先语言。2

针眼

乍一看,脚本语言的可嵌入性似乎是其解释器实现的一个特性。给定任何解释器,我们可以为其附加一个API,以允许宿主程序和脚本进行交互。然而,语言本身的设计对其可嵌入的方式有很大的影响。反之,如果您在设计语言时考虑到可嵌入性,这种思维方式将对最终的语言产生很大的影响。

大多数脚本语言的典型宿主语言是C,因此这些语言的API主要由函数以及一些类型和常量组成。这给脚本语言API的设计施加了一个自然但狭窄的限制:它必须通过这个针眼提供对语言特性的访问。语法结构尤其难以通过。例如,在一种脚本语言中,方法必须在词法上写在其类内部,除非API提供合适的机制,否则宿主语言无法向类添加方法。同样,很难通过API传递词法作用域,因为宿主函数不能在词法上位于脚本函数内部。

可嵌入语言API中的一个关键要素是eval函数,它执行一段代码。特别是,当脚本语言被嵌入时,所有脚本都由宿主调用eval。一个eval函数也允许设计API的极简方法。有了足够的eval函数,宿主几乎可以在脚本环境中做任何事情:它可以赋值给变量(eval"a = 20"),查询变量(eval"return a"),调用函数(eval"foo(32,'stat')")等等。诸如数组之类的数据结构可以通过评估适当的代码来构造和分解。例如,再次假设一个假设的eval函数,以下C代码会将一个C整数数组复制到脚本中


void copy (int ar[], int n) {
    int i;
    eval("ar = {}"); /* 创建一个空数组 */
    for (i =0; i <n; i++){
        char buff[100];
        sprintf(buff, "ar[%d] = %d", i + 1, ar[i]);
        eval(buff); /* 赋值第i个元素 */
    }
}

尽管由单个eval函数组成的API具有令人满意的简单性和完整性,但它有两个缺点:它效率太低,无法密集使用,因为每次交互都需要解析和解释代码块的成本;而且它使用起来太麻烦,因为需要在C中进行字符串操作来创建命令,并且需要序列化所有通过API的数据。然而,这种方法经常在实际应用中使用。Python称之为“非常高级的嵌入”。8

为了获得更高效且更易于使用的API,我们需要更多的复杂性。除了一个eval用于执行脚本的函数之外,我们还需要直接的方法来调用脚本定义的函数、处理脚本中的错误、在宿主程序和脚本环境之间传输数据等等。在接下来的章节中,我们将讨论可嵌入语言API的这些不同方面,以及它们如何影响和受到Lua设计的影响,但首先我们讨论这样一个API的简单存在如何影响一种语言。

给定一个具有其API的可嵌入语言,在宿主语言中编写一个将API导出回脚本语言的库并不困难。因此,我们有一种有趣的反思形式,宿主语言充当一面镜子。Lua中的几种机制都使用了这种技术。例如,Lua提供了一个名为type的函数来查询给定值的类型。此函数在解释器外部的C中实现,通过一个外部库。该库只是向Lua导出一个C函数(称为luaB_type),该函数调用Lua API来获取其参数的类型。

一方面,这种技术简化了解释器的实现;一旦API中提供了某种机制,就可以很容易地将其提供给语言。另一方面,它也迫使语言特性通过针眼。当我们讨论异常处理时,我们将看到这种权衡的一个具体例子。

控制

每个脚本语言必须解决的与控制相关的第一个问题是“谁拥有main函数”的问题。当我们使用嵌入在宿主中的脚本语言时,我们希望该语言是一个库,main函数在宿主中。然而,对于许多应用程序,我们希望该语言是一个独立的程序,具有其自身的内部main函数。

Lua通过使用单独的独立程序来解决这个问题。Lua本身完全实现为一个库,目标是嵌入到其他应用程序中。Lua命令行程序只是一个小型应用程序,它像任何其他宿主一样使用Lua库来运行Lua代码片段。以下代码是此应用程序的一个基本版本。当然,实际的应用程序比这更长,因为它必须处理选项、错误、信号和其他实际细节,但它仍然少于500行C代码。


#include <stdio.h>

#include "luaxlib.h"
#include "lualib.h"

int main (void) {
    char line[256];
    lua_State *L = luaL_newstate(); /* 创建一个新的状态 */
    luaL_openlibs(L); /* 打开标准库 */

    /* 读取行并执行它们 */
    while (fgets(line, sizeof(line), stdin) != NULL) {
        luaL_loadstring(L, line); /* 将行编译为函数 */
        lua_pcall(L, 0, 0, 0); /* 调用函数 */
    }

    lua_close(L);
    return 0;
}

虽然函数调用构成了Lua和C之间控制通信的大部分,但还有其他形式的控制通过API公开:迭代器、错误处理和协程。Lua中的迭代器允许诸如以下结构,该结构迭代文件的所有行


for line in io.lines(file) do
    print(line)
end

虽然迭代器呈现出新的语法,但它们构建在第一类函数之上。在我们的示例中,调用io.lines(file)返回一个迭代函数,该函数在每次调用时从文件中返回新的一行。因此,API不需要任何特殊的东西来处理迭代器。Lua代码很容易使用C编写的迭代器(如io.lines的情况),C代码也很容易使用Lua编写的迭代器进行迭代。对于这种情况,没有语法支持;C代码必须显式地完成for结构在Lua中隐式地完成的所有操作。

错误处理是Lua受到API强烈影响的另一个领域。Lua中的所有错误处理都基于C的longjump机制。这是从API导出到语言的功能的一个例子。

API支持两种调用Lua函数的机制:非保护调用和保护调用。非保护调用不处理错误:调用期间的任何错误都会通过此代码longjump,最终到达调用堆栈中更深处的保护调用。保护调用使用setjmp设置恢复点,以便捕获调用期间的任何错误;调用始终返回适当的错误代码。这种保护调用在嵌入式场景中非常重要,在嵌入式场景中,宿主程序不能因为脚本中的偶尔错误而中止。刚刚展示的基本应用程序使用lua_pcall(保护调用)以保护模式调用每一行编译后的代码。

标准Lua库只是将保护调用API函数导出到Lua,名称为pcall。使用pcall,Lua中try-catch的等价物如下所示


local ok, errorobject = pcall(function()
    --此处放置受保护的代码
    ...
end)
if not ok then
    --此处放置错误处理代码
    --(errorobject包含有关错误的更多信息)
    ...
end

这当然比语言内置的try-catch原始机制更麻烦,但它与C API完美契合,并且实现非常轻量级。

Lua中协程的设计是API产生巨大影响的另一个领域。协程有两种风格:对称和非对称。1 对称协程提供一个单一的控制传输原语,通常称为transfer,它的作用类似于goto:它可以将控制从任何协程传输到任何其他协程。非对称协程提供两个控制传输原语,通常称为resumeyield,它们的作用类似于一对调用-返回:resume可以控制传输到任何其他协程;yield停止当前协程并返回到恢复它的协程。

很容易将协程视为调用堆栈(延续),它编码程序必须完成哪些计算才能完成该协程。对称协程的transfer原语对应于用传输目标的调用堆栈替换正在运行的协程的整个调用堆栈。另一方面,resume原语将目标堆栈添加到当前堆栈的顶部。

对称协程比非对称协程更简单,但对于像Lua这样的可嵌入语言来说,它会带来一个大问题。脚本中任何活动的C函数都必须在C堆栈中具有相应的激活寄存器。在脚本执行期间的任何时候,调用堆栈都可能混合了C函数和Lua函数。(特别是,调用堆栈的底部始终有一个C函数,它是启动脚本的宿主程序。)但是,程序无法从调用堆栈中删除这些C条目,因为C不提供任何操作其调用堆栈的机制。因此,程序无法进行任何传输。

非对称协程没有这个问题,因为resume原语不影响当前堆栈。仍然存在一个限制,即程序不能跨C调用yield——也就是说,在resumeyield之间不能有C函数在堆栈中。对于在Lua中允许可移植协程而言,此限制是需要付出的小小代价。

数据

API的极简eval方法的主要问题之一是需要将所有数据序列化为字符串或重建数据的代码段。因此,实用的API应该提供其他更有效的机制来在宿主程序和脚本环境之间传输数据。

当宿主调用脚本时,数据作为参数从宿主程序流向脚本环境,并以结果的形式反方向流动。当脚本调用宿主函数时,情况则相反。在这两种情况下,数据都必须能够双向流动。因此,与数据传输相关的大多数问题都与嵌入和扩展相关。

为了讨论Lua-C API如何处理这种数据流,让我们从一个关于如何扩展Lua的例子开始。以下代码显示了函数io.getenv的实现,该函数访问宿主程序的环境变量。


static int os_getenv (lua_State *L) {
    const char *varname = luaL_checkstring(L, 1);
    const char *value = getenv(varname);
    lua_pushstring(L, value);
    return 1;
}

为了让脚本能够调用此函数,我们必须在脚本环境中注册它。我们稍后会看到如何执行此操作;现在,让我们假设它已注册为全局变量getenv,可以像这样使用


print(getenv("PATH"))

此代码中首先要注意的是os_getenv的原型。该函数的唯一参数是Lua状态。解释器通过此状态内的数据结构将实际参数传递给函数(在本例中,为环境变量的名称)。此数据结构是Lua值堆栈;鉴于其重要性,我们将其称为堆栈。

当Lua脚本调用getenv时,Lua解释器调用os_getenv,堆栈仅包含传递给getenv的参数,第一个参数位于堆栈中的位置1。首先,os_getenv执行的操作是调用luaL_checkstring,它检查位置1处的Lua值是否真的是字符串,并返回指向相应C字符串的指针。(如果值不是字符串,luaL_checkstring使用longjump发出错误信号,因此它不会返回到os_getenv.)

接下来,该函数从C库调用getenvgetenvlua_pushstring,它将C字符串值转换为Lua字符串,并将该字符串压入堆栈。最后,os_getenv返回1。此返回值告诉Lua解释器堆栈顶部的多少个值应被视为函数结果。(Lua中的函数可以返回多个结果。)

现在让我们回到如何将os_getenv注册为getenv在脚本环境中的问题。一种简单的方法是更改我们之前的基本独立Lua程序示例,如下所示


  lua_State *L = luaL_newstate(); /* 创建一个新的状态 */
  luaL_openlibs(L); /* 打开标准库 */

+ lua_pushcfunction(L, os_getenv);
+ lua_setglobal(L, "getenv");

第一个添加的行是我们用宿主函数扩展Lua所需的所有魔力。函数lua_pushcfunction接收指向C函数的指针,并在堆栈上压入一个(Lua)函数,该函数在被调用时调用其对应的C函数。由于Lua中的函数是第一类值,因此API不需要额外的工具来注册全局函数、局部函数、方法等。API只需要单个注入函数lua_pushcfunction。一旦创建为Lua函数,这个新值就可以像任何其他Lua值一样进行操作。新代码中的第二个添加行调用lua_setglobal以将堆栈顶部的值(新函数)设置为全局变量getenv.

的值。除了是第一类值之外,Lua中的函数始终是匿名的。诸如

function inc (x) return x + 1 end

之类的声明是赋值

inc = function (x) return x + 1 end

的语法糖。我们用来注册函数getenv的API代码与Lua中的声明完全相同:它创建一个匿名函数并将其赋值给全局变量。

同样,API不需要不同的工具来调用不同类型的Lua函数,例如全局函数、局部函数、方法等。要调用任何函数,宿主首先使用API的常规数据操作工具将函数压入堆栈,然后压入参数。一旦函数(作为第一类值)和参数在堆栈中,宿主就可以使用单个API原语调用它,而无需考虑函数来自何处。

Lua最独特的特性之一是其对表的广泛使用。表本质上是一个关联数组。表是Lua中唯一的数据结构机制,因此它们比其他具有类似结构的语言中扮演着更大的角色。Lua不仅将表用于其所有数据结构(记录、数组等),还用于其他语言机制,例如模块、对象和环境。

下一个示例说明了通过API操作表。函数os_environ创建一个表并返回该表,其中包含进程可用的所有环境变量。该函数假定可以访问environ数组,该数组在Posix系统中是预定义的;此数组中的每个条目都是一个NAME=VALUE形式的字符串,描述一个环境变量。


extern char **environ;

static int os_environ (lua_State *L) {
    int i;
    /* 将新表压入堆栈 */
    lua_newtable(L);
    /* 为每个环境变量重复 */
    for (i = 0; environ[i] != NULL; i++) {
        /* 在NAME=VALUE中查找'=' */
        char *eq = strchr(environ[i], '=');
        if (eq) {
            /* 压入名称 */
            lua_pushlstring(L, environ[i], eq -environ[i]);
            /* 压入值 */
            lua_pushstring(L, eq + 1);
            /* table[name] = value */
            lua_settable(L, -3);
        }
    }
    /* 结果是表 */
    return 1;
}

的第一个步骤os_environ是通过调用lua_newtable在堆栈顶部创建一个新表。然后,该函数遍历数组environ以在Lua中构建一个表,反映该数组的内容。对于environ中的每个条目,该函数将变量名压入堆栈,压入变量值,然后调用lua_settable以将该对存储在新表中。(与lua_pushstring不同,后者假定以零结尾的字符串,lua_pushlstring接收显式长度。)

函数lua_settable假定新条目的键和值都在堆栈的顶部;调用中的参数-3告诉表在堆栈中的位置。(负数从顶部索引,因此-3表示从顶部算起三个槽位。)

函数lua_settable弹出键和值,但将表留在堆栈中的位置。因此,在每次迭代之后,表都会回到顶部。最终返回1告诉Lua,此表是的唯一结果os_environ.

Lua API的一个关键属性是,它没有为C代码提供直接引用Lua对象的方法;C代码要操作的任何值都必须在堆栈上。在我们的最后一个示例中,函数os_environ创建一个Lua表,用一些条目填充它,并将其返回给解释器。一直以来,表都保留在堆栈中。

我们可以将这种方法与使用某种C类型来引用语言的值进行对比。例如,Python具有类型PyObject;JNI(Java Native Interface)具有jobject。早期版本的Lua也提供了类似的东西:lua_Object类型。然而,过了一段时间,我们决定更改API。6

类型的lua_Object主要问题是与垃圾收集器的交互。在Python中,程序员负责调用诸如Py_INCREFDECREF之类的宏来递增和递减API操作的对象的引用计数。这种显式计数既复杂又容易出错。在JNI(和早期版本的Lua中),对象的引用在创建它的函数返回之前有效。这种方法比手动计数引用更简单、更安全,但程序员失去了对对象生命周期的控制。在函数中创建的任何对象只能在该函数返回时释放。相比之下,堆栈允许程序员以安全的方式控制任何对象的生命周期。当对象在堆栈中时,它无法被回收;一旦离开堆栈,就无法操作它。此外,堆栈还提供了一种自然的方式来传递参数和结果。

表中在Lua中的广泛使用对C API产生了明显的影响。在Lua中表示为表的任何内容都可以使用完全相同的操作进行操作。例如,Lua中的模块实现为表。Lua模块只不过是一个包含模块函数和偶尔数据的表。(请记住,函数是Lua中的第一类值。)当您编写类似math.sin(x)的内容时,您会认为它是从math模块调用sin函数,但实际上您是在调用存储在全局变量math中的表中字段“sin”的内容。因此,宿主很容易创建模块、向现有模块添加函数、 “导入” Lua编写的模块等等。

Lua中的对象遵循类似的模式。Lua对面向对象编程使用基于原型的风格,其中对象由表表示。方法实现为存储在原型中的函数。与模块类似,宿主很容易创建对象、调用方法等。在基于类的系统中,类及其子类的实例必须共享一些结构。基于原型的系统没有此要求,因此宿主对象可以继承脚本对象的行为,反之亦然。

eval 和环境

动态语言的主要特征是存在一个eval构造,它允许执行在运行时构建的代码。正如我们所讨论的,eval函数也是脚本语言API中的基本元素。特别是,eval是宿主运行脚本的基本手段。

Lua不直接提供eval函数。相反,它提供了一个load函数。(上面基本的Lua示例中的代码使用了luaL_loadstring函数,它是load的一个变体。)此函数不执行一段代码;相反,它生成一个Lua函数,该函数在被调用时执行给定的代码段。

当然,很容易将eval转换为load,反之亦然。尽管存在这种等价性,但我们认为loadeval有一些优势。从概念上讲,load将程序文本映射到语言中的值,而不是将其映射到操作。eval函数通常是API中最复杂的函数。通过将“编译”与执行分离,它变得稍微简单一些;特别是,与eval, load永远不会产生副作用。

编译和执行的分离也避免了组合问题。Lua有三个不同的load函数,具体取决于源:一个用于加载字符串,一个用于加载文件,一个用于加载给定读取器函数读取的数据。(前两个函数在后者的基础上实现。)

由于有两种调用函数的方式(保护和非保护),我们需要六个不同的eval函数来涵盖所有可能性。

错误处理也更简单,因为静态和动态错误是分开发生的。最后,load确保所有Lua代码始终位于某个函数内部,这使语言更具规律性。

eval函数密切相关的是环境的概念。每种图灵完备的语言都可以解释自身;这是图灵机的标志。使eval特殊的是,它在与使用它的程序相同的环境中执行动态代码。换句话说,eval构造提供了一定程度的反射。例如,用C编写C解释器并不太困难。但是面对诸如x=1之类的语句,如果程序中存在变量x,则此解释器无法访问变量x。(某些非ANSI工具,例如与动态链接库相关的工具,允许C程序查找给定全局符号的地址,但程序仍然无法找到有关其类型的任何信息。)

Lua中的环境只是一个表。Lua仅提供两种变量:局部变量和表字段。从语法上讲,Lua还提供全局变量:任何未绑定到局部声明的名称都被视为全局变量。从语义上讲,这些未绑定的名称引用与封闭函数关联的特定表中的字段;此表称为该函数的环境。在典型的程序中,大多数(或全部)函数共享一个环境表,然后该表充当全局环境的角色。

全局变量可以通过API轻松访问。由于它们是表字段,因此可以通过常规API来操作表来访问它们。例如,函数lua_setglobal,它出现在前面显示的基本Lua应用程序代码中,实际上是基于表操作原语编写的简单宏。

另一方面,局部变量遵循严格的词法作用域规则,因此它们根本不参与API。由于C代码不能在词法上嵌套在Lua代码中,因此C代码无法访问Lua中的局部变量(除非通过某些调试工具)。这实际上是Lua中唯一不能通过API模拟的机制。

这种例外情况有几个原因。词法作用域是一个古老而强大的概念,应该遵循标准行为。此外,由于无法从其作用域外部访问局部变量,因此词法作用域为程序员提供了访问控制和封装的基础。例如,任何Lua代码文件都可以声明仅在该文件内可见的局部变量。最后,局部变量的静态性质允许编译器将所有局部变量放置在Lua的基于寄存器的虚拟机中的寄存器中。5

结论

我们认为,为外部世界提供API不是脚本语言实现的细节,而是一个可能影响整个语言的决策。我们已经展示了Lua的设计如何受到其API的影响,反之亦然。

任何编程语言的设计都涉及许多这样的权衡。某些语言属性(例如简单性)有利于可嵌入性,而其他语言属性(例如静态验证)则不利于可嵌入性。Lua的设计涉及围绕可嵌入性的几个权衡。对模块的支持就是一个典型的例子。Lua以最少的额外机制支持模块,以牺牲某些功能(例如非限定导入)为代价,从而有利于简单性和可嵌入性。另一个例子是对词法作用域的支持。在这里,我们选择了更好的静态验证,但以牺牲可嵌入性为代价。我们对Lua中的权衡平衡感到满意,但通过针眼的经历对我们来说是一次学习。

参考文献

1. Lucia de Moura, A., Ierusalimschy, R. 2009. Revisiting coroutines. Transactions on Programming Languages and Systems 31(2): 6.1-6.31.

2. DeLoura, M. 2009. The engine survey: general results. Gamasutra; http://www.gamasutra.com/blogs/MarkDeLoura/20090302/581/The_Engine_Survey_General_results.php.

3. Ierusalimschy, R. 2006. Lua编程,第二版。Lua.org,巴西里约热内卢。

4. Ierusalimschy, R., de Figueiredo, L. H., Celes, W. 1996. Lua—一种可扩展的扩展语言。软件:实践与经验 26(6): 635-652.

5. Ierusalimschy, R., de Figueiredo, L. H., Celes, W. 2005. Lua 5.0的实现。通用计算机科学杂志 11(7): 1159-1176.

6. Ierusalimschy, R., de Figueiredo, L. H., Celes, W. 2007. Lua的演变。在第三届 SIGPLAN编程语言历史会议: 2.1-2.26, 加利福尼亚州圣地亚哥 (六月).

7. Ousterhout, J. K. 1998. 脚本:21世纪的更高级别编程。IEEE Computer, 31(3): 23-30.

8. Python软件基金会. 2011. 扩展和嵌入Python解释器,版本 2.7 (四月); https://docs.pythonlang.cn/extending/.

喜欢或讨厌它?请告诉我们

[email protected]

Roberto Ierusalimschy 是里约热内卢天主教大学 (PUC-Rio) 的计算机科学副教授,他在那里从事编程语言设计和实现工作。他是 Lua 编程语言的首席架构师,《Lua 编程》(现已出第二版)的作者。

Luiz Henrique de Figueiredo 拥有里约热内卢国家纯粹与应用数学研究所的数学博士学位,他在那里担任全职研究员,并且是视觉与图形实验室的成员。他还担任 PUC-Rio 计算机图形技术组 Tecgraf 的几何建模和软件工具顾问,在那里他帮助创建了 Lua。

Waldemar Celes 是里约热内卢天主教大学 (PUC-Rio) 计算机科学系的助理教授,也是康奈尔大学计算机图形项目的前博士后研究员。他目前的研究兴趣包括实时渲染、科学可视化、物理模拟和分布式图形应用程序。他是 PUC-Rio 计算机图形技术组的成员,在那里他协调可视化组。他也是 Lua 编程语言的作者之一。

© 2011 1542-7730/11/0500 $10.00

acmqueue

最初发表于 Queue vol. 9, no. 5
数字图书馆 中评论本文





更多相关文章

Matt Godbolt - C++ 编译器中的优化
在为编译器提供更多信息方面需要权衡:这可能会使编译速度变慢。诸如链接时优化之类的技术可以为您提供两全其美的优势。编译器中的优化在不断改进,而即将到来的间接调用和虚拟函数分派方面的改进可能很快就会带来更快的多态性。


Ulan Degenbaev, Michael Lippautz, Hannes Payer - 作为合资企业的垃圾回收
跨组件追踪是一种解决跨组件边界引用循环问题的方法。当组件可以形成具有跨 API 边界非平凡所有权的任意对象图时,就会出现这个问题。V8 和 Blink 中实现了一个 CCT 的增量版本,从而能够以安全有效的方式回收内存。


David Chisnall - C 并非低级语言
在最近的 Meltdown 和 Spectre 漏洞爆发后,值得花一些时间来审视根本原因。 这两个漏洞都涉及到处理器推测性地执行指令,绕过某种访问检查,并允许攻击者通过侧信道观察结果。 添加这些导致这些漏洞以及其他几个漏洞的功能,是为了让 C 程序员继续相信他们正在使用低级语言编程,尽管这种情况已经几十年没有发生了。


Tobias Lauinger, Abdelberi Chaabane, Christo Wilson - 汝等不应依赖于我
大多数网站都使用 JavaScript 库,其中许多库已知存在漏洞。 了解问题的范围以及库被包含的许多意想不到的方式,只是改进情况的第一步。 本文的目标是,其中包含的信息将有助于为社区提供更好的工具、开发实践和教育工作。





© 保留所有权利。

© . All rights reserved.