samp | open.mp 联机社区论坛
[教程] 代码优化 #2 - 打印版本

+- samp | open.mp 联机社区论坛 (https://open-mp.cn)
+-- 板块: SA-MP (https://open-mp.cn/forumdisplay.php?fid=12)
+--- 板块: 教程 (https://open-mp.cn/forumdisplay.php?fid=17)
+--- 主题: [教程] 代码优化 #2 (/showthread.php?tid=5)



[教程] 代码优化 #2 - XiaoNiao - 02-28-2026

内容

本线程由 Y_LESS 编写,我不对其内容负责。我只是希望重新发布它,因为它可以帮助许多脚本编写者。

目录

引言

这些只是我在路上学到的一些让你的代码运行得更快的技巧。请注意,我绝不自称是这个领域的权威,这些只是我所知道的,其他人可能知道其他方法,在这种情况下请务必分享它们,因为即使没有其他人关心,我也会有兴趣了解它们。还要注意,这些技巧中有许多适用于 PAWN 以外的语言(我写的最后一个技巧被指责为愚蠢,因为 PAWN 没有动态内存分配(我个人认为这是一件好事,但就这样吧)),然而很多其他语言可能会将其中一些技巧融入编译器中,以进行内联优化。

这并不保证能让你的代码变得优秀,只是希望能变得更好。另外请注意,有些部分相当复杂,它针对的是更高级的教程,所以它在某些领域确实假设了一些知识。

测试

首先,我将解释我的测试过程。如果我有两段代码做同样的事情,但方式不同,我想知道哪段更快,我会对它们进行计时:

代码:
#define CODE_1 printf("%d", 42);
#define CODE_2 new str[4]; format(str, sizeof (str), "%d", 42); print(str);
#define ITERATIONS (10000)

Test()
{
    new
        t0,
        t1,
        t2,
        i;
    t0 = GetTickCount();
    for (i = 0; i < ITERATIONS; i++)
    {
        CODE_1
    }
    t1 = GetTickCount();
    for (i = 0; i < ITERATIONS; i++)
    {
        CODE_2
    }
    t2 = GetTickCount();
    printf("时间1: %04d, 时间2: %04d", t1 - t0, t2 - t1);
}

显然,这两段代码都会在服务器控制台中显示数字"42",但它们以不同的方式实现。希望没有人需要运行这段代码来知道哪种方法更快,但这是一个很好的简单例子,用来测试两段等效代码哪段更快。ITERATIONS 循环很重要,这两段代码很可能各自耗时不到一毫秒,所以它们报告的时间都将是零。而且,如果你只做一次,线程会成为主要问题,如果一个版本被操作系统中断,它可能会报告自己花费了相当长的时间,而实际上它更快。如果两者都做了很多很多次,那么中断有望相互抵消,并且每个循环将耗时超过一毫秒。代码的布局也很重要,所有变量都首先声明,以将其开销移到循环之外(它们的执行时间如此之短,可能不会影响结果,但为了保持一致,这样很好)。

有时将 Test 函数包装在另一个循环中也很好,特别是当结果非常接近时,以验证结果。由于线程的原因,执行时间可能会有轻微变化,如果你多次运行测试,可以看到这种变化,表现为每次时间可能有几毫秒的差异。如果你重复运行接近的结果,就可以检查哪一个持续更快,而不是只快一次,因为那可能是一次偶然,90% 的时间另一个更快,只是那一次不是。

有时你可能需要更高级的测试代码,例如测试两个以上的等效代码,这很容易扩展:

代码:
#define CODE_1 printf("%d", 42);
#define CODE_2 new str[4]; format(str, sizeof (str), "%d", 42); print(str);
#define CODE_3 print("42");
#define ITERATIONS (10000)

Test()
{
    new
        t0,
        t1,
        t2,
        t3,
        i;
    t0 = GetTickCount();
    for (i = 0; i < ITERATIONS; i++)
    {
        CODE_1
    }
    t1 = GetTickCount();
    for (i = 0; i < ITERATIONS; i++)
    {
        CODE_2
    }
    t2 = GetTickCount();
    for (i = 0; i < ITERATIONS; i++)
    {
        CODE_3
    }
    t3 = GetTickCount();
    printf("时间1: %04d, 时间2: %04d, 时间3: %04d", t1 - t0, t2 - t1, t3 - t2);
}

等等...

foreach

我最近对我的 foreach 函数与默认的 for/IsPlayerConnected (IPC) 循环进行了计时。foreach 使用玩家的链接列表,因此循环时遍历连接的玩家,而 IPC 代码则遍历所有玩家并检查他们是否连接。我知道在玩家不多的大型服务器上它更快,但我不确定在服务器满员的情况下。所以我进行了计时。我没有 200 名玩家进行测试,但我知道 IsPlayerConnected 的运行时间无论返回 true 还是 false 都差不多(它基本上只是返回服务器中的一个变量,该变量可以是任一值),所以这不是问题,因为对于给定数量的玩家,IPC 将以恒定速度运行,无论他们是否在线。foreach 的运行方式很大程度上取决于有多少玩家在线,在没有玩家时几乎不花时间,而在服务器满员时需要更长时间,我只是想知道这个最长执行时间是否比 IPC 运行的恒定时间更长。我实际上想分析所有玩家数量下的性能,这意味着在 foreach 中模拟玩家连接,这并不难,因为它是我的代码,你只需调用一个连接函数。这个的测试代码最终看起来像这样:

代码:
#define FAKE_MAX 200
#define SKIP 0
Iter_Create(P2, FAKE_MAX);
*
TestFunc()
{
    new
        fep = 0,
        fet = 0,
        fip = 0,
        fit = 0,
        i = 0;
    while (i < SKIP)
    {
        Itter_Add(P2, i++);
    }
    while (i <= FAKE_MAX)
    {
        new
            t0,
            t1,
            t2,
            j;
        t0 = GetTickCount();
        for (j = 0; j < 10000; j++)
        {
            for (new playerid = 0; playerid < FAKE_MAX; playerid++)
            {
                if (IsPlayerConnected(playerid))
                {
                    // 做点什么
                }
            }
        }
        t1 = GetTickCount();
        for (j = 0; j < 10000; j++)
        {
            foreach(P2, playerid)
            {
                // 做点什么
            }
        }
        t2 = GetTickCount();
        printf("玩家数: %04d, for: %04d, foreach: %04d", i, t1 - t0, t2 - t1);
        fit = fit + t1 - t0;
        fet = fet + t2 - t1;
        fip += FAKE_MAX;
        fep += i;
        if (i < FAKE_MAX)
        {
            Itter_Add(P2, i);
        }
        i++;
    }
    printf("for ms/p: %04d, foreach ms/p: %04d", (fit * 100) / fip, (fet * 100) / fep);
}

这段代码运行了 201 次,每个玩家数量(0-200)一次(对于 foreach 和 IPC,哪个玩家在线并不影响速度)。它还允许我模拟更多玩家,例如测试这段代码在具有 500 名玩家的 0.3 服务器上如何运行,如果你测试的玩家不存在,IPC 实际上会稍微快一点,但它仍然较慢,所以这相当有结论性。我没有运行的一个测试是:

代码:
t0 = GetTickCount();
        for (j = 0; j < 10000; j++)
        {
            for (new playerid = 0; playerid < FAKE_MAX; playerid++)
            {
                Kick(playerid);
            }
        }
        t1 = GetTickCount();
        for (j = 0; j < 10000; j++)
        {
            foreach(P2, playerid)
            {
                Kick(playerid);
            }
        }
        t2 = GetTickCount();

Kick,实际上所有的玩家函数,内部都有一个 IsPlayerConnected 检查,所以如果你在一个循环中只运行一个函数,那么不调用 IsPlayerConnected 而直接调用该函数会更有效。如果玩家在线,你就节省了一次函数调用,如果他们不在线,你也没有损失,因为执行的代码与调用 IsPlayerConnected 相同。不幸的是,这个例子可能会影响 foreach 在高玩家数量下的速度,因为你将在同一个循环中使用 foreach 代码和 IPC 代码。如果你这样做:

代码:
t0 = GetTickCount();
        for (j = 0; j < 10000; j++)
        {
            for (new playerid = 0; playerid < FAKE_MAX; playerid++)
            {
                if (IsPlayerConnected(playerid))
                {
                    Kick(playerid);
                }
            }
        }
        t1 = GetTickCount();
        for (j = 0; j < 10000; j++)
        {
            foreach(P2, playerid)
            {
                Kick(playerid);
            }
        }
        t2 = GetTickCount();

那么 foreach 会更快,但这不是第一种情况下最有效的方式。

我实际上研究过修改编译器,以便你可以做类似的事情:

代码:
eqiv{{for (new playerid = 0; playerid < FAKE_MAX; playerid++){if (IsPlayerConnected(playerid)){Kick(playerid);}}}{foreach(P2, playerid){Kick(playerid);}}}

它将编译并优化这两个版本的代码,然后基于生成的代码和已知的操作码时钟周期精确计时,以查看哪个更快。然而,关于测试集存在各种各样的问题,我还没有做,所以这真的没有实际意义(说实话,我想对许多语言编译器进行一些改进,我只是还没有做)。

微优化

IEEE数字

有一种浮点数表示法表示正无穷大、负无穷大和无效数字。我见过人们尝试各种表示法来表示大数或无效浮点数,看看这些例子:

代码:
if (!IsPlayerConnected(playerid))
{
    return -1.0;
}
return GetDistance(playerid);

代码:
new
    bool:first = true,
    Float:distance = 0.0;
foreach (Player, playerid)
{
    if (first)
    {
        first = false;
        distance = GetDistance(playerid);
    }
    else
    {
        new
            Float:temp = GetDistance(playerid);
        if (temp < distance)
        {
            distance = temp;
        }
    }
}

第一个代码可以做任何事情,距离 -1.0 意味着玩家未连接,所以返回一个无效距离。第二个代码找到离某物最近的人(具体细节不重要)。第二段代码可以通过选择一个非常大的起始数来优化,而不是使用 "first" 变量:

代码:
new
    Float:distance = 100000.0;
foreach (Player, playerid)
{
    new
        Float:temp = GetDistance(playerid);
    if (temp < distance)
    {
        distance = temp;
    }
}

但这忽略了当玩家距离超过 100000 单位时的罕见情况(注意实际上你会使用平方值,如下所述,但这仅作为示例)。这两个例子都有明确定义的解决方案:

代码:
#define FLOAT_INFINITY  (Float:0x7F800000)
#define FLOAT_NEG_INFINITY (Float:0xFF800000)
#define FLOAT_NAN    (Float:0xFFFFFFFF)

这将使上面的代码变成:

代码:
new
    Float:distance = FLOAT_INFINITY;
foreach (Player, playerid)
{
    new
        Float:temp = GetDistance(playerid);
    if (temp < distance)
    {
        distance = temp;
    }
}

代码:
if (!IsPlayerConnected(playerid))
{
    return FLOAT_NAN;
}
return GetDistance(playerid);

"NaN" 是一个非常特殊的数字------将其与任何其他数字(包括它自身)比较都将返回 false。要检查 NaN,你可以这样做:

代码:
stock IsNaN(number)
{
    return !(number <= 0 || number > 0);
}

如果传递的数字小于、等于或大于 0,该函数返回 false------所有数字,包括正负无穷大,都符合这些条件------而 NaN 不符合,因为它不是一个数字,所以没有值。使用这些值可以保证(它们在 IEEE 浮点数规范中定义)你永远不会使用可能与实际值混淆的数字。由于 NaN 的独特属性,下面这个看起来非常奇怪的代码也应该可以工作:

代码:
stock IsNaN(number)
{
    return (number != number);
}

不要尝试

代码:
if (number == FLOAT_NAN)

这段代码会失败,因为如前所述,NaN 甚至不等于它自身。

让我们看看当你想要距离以检查某人是否在某物范围内时的情况:

代码:
if (DistanceWithConnectionCheck(playerid) < 100.0)
{
    // 他们在范围内且已连接
}

返回无穷大将意味着检查失败,返回 NaN 也是如此(它不大于、不小于也不等于 10)------相比之下,你需要在这里添加额外代码来检查 "-1.0":

代码:
new
    Float:distance = DistanceWithConnectionCheck(playerid);
if (distance == -1.0)
{
    // 他们没有连接
}
else if (distance < 100.0)
{
    // 他们在范围内且已连接
    // -1.0 小于 100,所以我们需要特别检查它
}

参见 YSI 对象加载器中的实际用法。

重新排列

编译器可以进行常量数学计算,这意味着如果你这样做:

代码:
printf("%d", 4 + 5);

编译器会这样做:

代码:
printf("%d", 9);

它不会费心加入代码进行数学计算,因为没必要------结果总是相同的。通常,简单的公式重排可以帮助你的代码:

代码:
new
    var = (4 + somevar) - 11;

这将编译为执行两步数学运算,首先将一个数加 4,然后从结果中减 11(一些编译器可能实际上能够按照我将要描述的方式优化这一点,但这只是一个简单的例子)。如果你重新排列这个求和,你会得到:

代码:
new
    var = somevar + (4 - 11);

编译器可以非常快速地优化为:

代码:
new
    var = somevar - 7;

一个没有编译器优化的更复杂的例子:

代码:
new
    gLastTime[MAX_PLAYERS];
*
#define EXPIRY 1000

代码:
new
    time = GetTickCount();
foreach (Player, playerid)
{
    if (time - gLastTime[playerid] > EXPIRY)
    {
        SendClientMessage(playerid, 0xFF0000AA, "你的时间已过期");
    }
}

这是一个基本的例子,检测自玩家上次做某事以来是否经过了一定时间。你可能认为那里没有优化的选项,也许你是对的,但尝试总是好的。这里的方程是:

代码:
time - gLastTime[playerid] > EXPIRY

=, ==, >= 等都可以用同样的方式重新排列,所以上面的方程等价于:

代码:
time > EXPIRY + gLastTime[playerid]

更重要的是,它也等价于:

代码:
time - EXPIRY > gLastTime[playerid]

为什么这很重要?就循环而言,"time - EXPIRY"现在是一个常数,因为两者在循环中都不改变。这意味着你可以这样做:

代码:
new
    time = GetTickCount() - EXPIRY;
foreach (Player, playerid)
{
    if (time > gLastTime[playerid])
    {
        SendClientMessage(playerid, 0xFF0000AA, "你的时间已过期");
    }
}

你刚刚几乎不费吹灰之力就减少了多达 200 次重复的减法运算。你能在求和式中获得的常数或伪常数元素越多越好,尤其是当你多次执行它们时。如果你只检查一个玩家,我会倾向于把所有东西,包括 GetTickCount() 调用,都放在 if 语句中,但如果你多次执行,就不要这样做。

数据重排

另一件要考虑的事情是你的数据如何布局以及你想如何访问它。以下面的数据为例:

代码:
#define MAX_OWNDED_VEHICLES 10

new gVehicleOwner[MAX_OWNDED_VEHICLES] = {0, 2, 4, 6, 8, 10, 12, 14, 16, 18};

这里有 10 辆车,每辆车都有一个拥有它的玩家(假设在这个例子中一个玩家最多只能拥有一辆车)。如果你想找出谁拥有一辆车,你可以简单地这样做:

代码:
printf("车辆 %d 的车主是 %d", vehicleid, gVehicleOwner[vehicleid]);

但是如果你想找出一个玩家拥有哪辆车呢?为此,你需要做类似这样的事情:

代码:
new i = 0;
while (i < MAX_OWNDED_VEHICLES)
{
    if (gVehicleOwner[i] == playerid)
    {
        printf("玩家 %d 拥有车辆 %d", playerid, i);
        break;
    }
    i++;
}
if (i == MAX_OWNDED_VEHICLES)
    printf("玩家 %d 没有拥有车辆", playerid);

现在让我们换一种方式来看:

代码:
#define MAX_PLAYERS 20

new gPlayerVehicle[MAX_PLAYERS] = {0, -1, 1, -1, 2, -1, 3, -1, 4, -1, 5, -1, 6, -1, 7, -1, 8, -1, 9, -1};

现在如果你想找出一个玩家拥有哪辆车,只需简单的数组查找,但如果你想看看谁拥有一辆车,就需要一个循环。这里你需要考虑的问题是,你更想知道哪个?如果你不在乎给定车辆的车主是谁,但确实在乎某人拥有哪辆车,那么使用第二种布局,反之亦然使用第一种。如果你两者都经常使用,你可能实际上要考虑在两个不同的数组中镜像数据。这是速度和内存之间的权衡,这是人们经常面临的一个非常常见的权衡。我个人认为在现代 32 位和 64 位处理器上,速度比内存更重要,所以我会使用两个数组,但考虑到它们运行在千兆赫兹的频率下,你可能会不同意,所以会使用一个数组和一个循环。

让我们看一个更清晰的例子,这是我最近读到一个话题中的。这个例子非常精简,这里我只用 10 个模型:

代码:
new
    gCars[] = {400, 403, 404, 406, 408, 409},
    gHeavyVehicles[] = {400, 402, 408},
    gBoats[] = {401, 407},
    gFireEngines[] = {402, 405};

如果你想了解一个模型是否是汽车,你需要遍历"gCar"直到找到该模型或到达末尾。另一方面,使用这段代码很容易知道给定位置的车是什么型号,但这毫无意义,因为这是你可能永远不想知道的数据。所以问题是;为什么容易得到你不想要的数据,而难以得到你想要的数据?这完全说不通......我们知道对于给定的模型,我们想知道它是不是汽车,所以我们需要更改代码,将模型用作数组索引(减去 400),就像我们上面做的那样,找出玩家拥有哪辆车:

代码:
new
    gIsACar[] = {1, 0, 0, 1, 1, 0, 1, 0, 1, 1},
    gIsAHeavyVehicle[] = {1, 0, 1, 0, 0, 0, 0, 0, 1, 0},
    gIsABoat[] = {0, 1, 0, 0, 0, 0, 0, 1, 0, 0},
    gIsAFireEngine[] = {0, 0, 1, 0, 0, 1, 0, 0, 0, 0};

现在如果你想找出一个模型是不是汽车,你只需这样做:

代码:
if (gIsACar[model - 400])

这比循环简单和快速多了,不是吗?

使用整个单元格来存储布尔值(1 或 0)也非常低效,但我们稍后会讨论这个。

了解语言

我的意思是非常了解。正如有人前几天提醒我的那样(我在 IRC 上评论过),不久前我发现:

代码:
if (2 <= a <= 4)

在 PAWN 中有效(在 C 中无效),我之前以为它和 C 中一样,所以一直在做:

代码:
if (2 <= a && a <= 4)

虽然不是很大的改进,但大多数都不是,重要的是综合和重复的效果。

例如,如果你不知道 "&" 运算符,并且想测试一个数字的第二位是否被设置,你需要做类似这样的事情:

代码:
if ((a << 30) >>> 31)

或者:

代码:
if ((a % 4) >>> 1)

这两段代码都会确保只设置了一个数字的一位,并且会做你想做的事,但了解 "&" 要好得多:

代码:
if (a & 2)

显然更快(移位不算太坏,但 MOD 非常慢,在其他两个版本中,如果你必须选一个,总是选第一个),而且你试图做什么也更明显。如果你不知道它是做什么的------去阅读 pawn-lang.pdf。

这显然是一个非常基本的例子,但还有许多许多其他的例子。当我告诉他们阅读 pawn-lang.pdf 时,人们往往会退缩,然后想知道为什么我似乎比他们更了解 PAWN,2 + 2 = ...... 我把它加入书签是有原因的,并不是为了快速复制链接发帖给别人(尽管这也很方便)。

正如本节开头的例子所示,无论你可能已经知道多少,总有新东西等着你去学习。我可以想到 PAWN 的两大块我完全不了解,而我没有意识到其他领域的事实只是意味着我还不知道它们,并不意味着它们不存在(很难知道你不知道什么)。

距离检查

0.3 现在自动支持距离检查,只需使用:

代码:
IsPlayerInRangeOfPoint(playerid, Float:range, Float:x, Float:y, Float:z);

这是人们非常常做的事情,也是非常容易出错的事情。例如:

代码:
if (PlayerDistanceToPoint(playerid, 10.0, 20.0, 2.0) <= 5.0)
{
    // 他们在 10.0, 20.0, 2.0 附近 - 做点什么
}

这段代码可以工作,会在玩家距离某点 5.0 单位内时触发,但计算到某点的距离非常慢。计算两点(x1, y1, z1 和 x2, y2, z2)之间距离的公式是:

代码:
(((x1 - x2) ^ 2) + ((y1 - y2) ^ 2) + ((z1 - z2) ^ 2)) ^ 0.5

这里的 "^" 表示乘方,不是异或,"^ 0.5" 是平方根(相信我------在计算器上试试)。最常见的实现是:

代码:
floatsqroot(floatadd(floatadd(floatpower(floatsub(x1, x2), 2), floatpower(floatsub(y1, y2), 2)), floatpower(floatsub(z1, z2), 2)));

不要问我为什么最初写它的人不使用标准运算符,我不知道,但简化后这段代码是:

代码:
floatsqroot(floatpower(x1 - x2, 2) + floatpower(y1 - y2, 2) + floatpower(z1 - z2, 2));

现在,首先要注意的是,计算任意次幂的代码很复杂,它不会针对像 2 这样的简单次幂进行优化,而是使用基本算法。我们知道一个数的 2 次幂就是这个数乘以它自身(或者你应该知道)。3^2 等于 33,57^2 等于 5757 等等。乘法比乘方简单得多,所以代码变成:

代码:
floatsqroot(((x1 - x2) * (x1 - x2)) + ((y1 - y2) * (y1 - y2)) + ((z1 - z2) * (z1 - z2)));

你可以去掉一些括号,但没必要,这和减少括号的版本一样快,而且更明确。这里的代码更多了,但速度快得多。现在优化追求更有趣的东西。我们每个减法做了两次,你可以将它们导出到变量中,只做一次,但这样就有了额外的变量写入,这可能无法抵消速度的提升:

代码:
x1 -= x2;
y1 -= y2;
z1 -= z2;
floatsqroot((x1 * x1) + (y1 * y1) + (z1 * z1));

现在我们有了获取某人到某物距离的高效代码,但还有一个主要问题------到目前为止所做的所有优化都远不及 floatsqroot,这是一个极其低效的函数(嗯,它并不是低效,实际上它非常高效,但这并不意味着它很快,因为它太复杂了)。信不信由你,大多数时候你实际上并不需要确切知道某人离某点多远,只需要知道他们是否在点附近。现在你应该已经阅读了关于重新排列的部分(如果没有,回去再读一遍),所以让我们在这里应用它:

代码:
((x * x) + (y * y) + (z * z)) ^ 0.5 <= 5.0

你可以像常规方程一样重新排列不等式:

代码:
((x * x) + (y * y) + (z * z)) ^ 0.5 <= 5.0
(x * x) + (y * y) + (z * z) <= 5.0 ^ 2
(x * x) + (y * y) + (z * z) <= 5.0 * 5.0

任何熟悉数学的人都应该能够证明这个非常简单的重排是正确的。我们知道如何快速计算平方(正如我刚才告诉你的),并且我们知道平方根非常慢,所以这是一个巨大的改进:

代码:
if (PlayerDistanceToPointSquared(playerid, 10.0, 20.0, 2.0) <= 5.0 * 5.0)
{
    // 他们在 10.0, 20.0, 2.0 附近 - 做点什么
}

或者:

代码:
if (IsPlayerInRangeOfPoint(playerid, 5.0, 10.0, 20.0, 2.0))
{
    // 他们在 10.0, 20.0, 2.0 附近 - 做点什么
}

代码:
stock IsPlayerInRangeOfPoint(playerid, Float:range, Float:x, Float:y, Float:z)
{
    new
        Float:px,
        Float:py,
        Float:pz;
    GetPlayerPos(playerid, px, py, pz);
    px -= x;
    py -= y;
    pz -= z;
    return ((px * px) + (py * py) + (pz * pz)) < (range * range);
}

当然你可以自己编写代码,但由于一些我不能深入的原因,我强烈建议使用那个函数名和参数顺序。

更新:

我找到了我之前在这个领域做的计时分析,结果并不像某些人预期的那样。我比较了一大堆不同的距离分析函数,包括人们为了速度而使用的精度较低的函数,例如:

代码:
Type1(Float:x1, Float:y1, Float:z1, Float:x2, Float:y2, Float:z2, Float:dist)
{
    x1 = (x1 > x2) ? x1 - x2 : x2 - x1;
    if (x1 > dist) return false;
    y1 = (y1 > y2) ? y1 - y2 : y2 - y1;
    if (y1 > dist) return false;
    z1 = (z1 > z2) ? z1 - z2 : z2 - z1;
    if (z1 > dist) return false;
    return true;
}

结果是,即使是这些"更快"的实现也比上面概述的实现慢,而且精度更低。

我得到的结果是:

代码:
1703 1781 1594 1641 2265 1782 2281 1891

1703 是"更快"版本的时间,1594 是我的版本的时间。结论------不要试图通过让距离检查变得更差来优化它们------原始的版本既更快又更准确。

注意,运行链接的代码会产生 9 个值,由于一个错误------忽略最后一个值(我犯的一个致命错误)。

我的分析代码可以在这里找到:链接

速度顺序

不同的语言特性执行时间不同,一般来说,顺序是(从最快到最慢):

  • 常量
  • 变量
  • 数组
  • 原生函数
  • 自定义函数
  • 远程函数

例如:

代码:
for (new i = 0; i < MAX_PLAYERS; i++)

比以下更快:

代码:
for (new i = 0, j = GetMaxPlayers(); i < j; i++)

因为第一个循环的主要部分使用常量,而第二个使用变量(循环中单次函数调用的开销与重复检查相比微不足道)。第二个版本本身比以下更快:

代码:
for (new i = 0; i < GetMaxPlayers(); i++)

因为这第三个版本使用重复的函数调用,而不是变量或常量。

我不确定控制结构在列表中的位置,例如,我不确定以下哪个更快:

代码:
new var = a ? 0 : 1;
printf("%d", var);
printf("%d", var);
printf("%d", var);
printf("%d", var);
printf("%d", var);

代码:
printf("%d", a ? 0 : 1);
printf("%d", a ? 0 : 1);
printf("%d", a ? 0 : 1);
printf("%d", a ? 0 : 1);
printf("%d", a ? 0 : 1);

我怀疑第一个,除非你只有一个打印,那么肯定是第二个,但同样,对于更复杂的例子,就不那么清楚了。这需要计时,但我还没做(而且有很多控制结构,所以我只是将我的通用规则(见下文)应用于这些情况)。

那么为什么"无"在列表中?考虑以下两段代码:

代码:
new var = random(10);
printf("%d", var);

代码:
printf("%d", random(10));

显然第二个更快,因为没有中间步骤。实际上,大多数编译器会优化掉变量,但并非所有都这样做。

当涉及到重复调用时,界限开始变得模糊。例如,以下哪个更快:

代码:
new var = gArr[10];
printf("%d %d", var, var);

代码:
printf("%d %d", gArr[10], gArr[10]);

第一个有一次数组访问、一次变量写入和两次变量读取,第二个有两次数组访问。老实说,我不确定哪个更快,但我有一个通用规则:

超过一个函数调用------将其保存在变量中,超过两次数组读取------将其保存在变量中,所以对于上面的代码,我可能会使用第二个版本,然而三个元素的打印需要三次数组访问,这超过了两次,因此我会使用一个中间变量:

代码:
new var = gArr[10];
printf("%d %d %d", var, var, var);

而且我从不重复调用同一个函数超过一次(特别是因为这可能产生意想不到的结果,如果函数有变化的返回值或副作用)。

了解你的值

另一个常见的代码片段(我相信你们大多数人都会认出它)是这样的:

代码:
if (killerid == INVALID_PLAYER_ID)
    SendDeathMessage(INVALID_PLAYER_ID, playerid, reason);
else
    SendDeathMessage(killerid, playerid, reason);

让我们看看另一个类似代码的例子,试图更清楚地说明这到底在做什么以及为什么它很愚蠢:

代码:
if (var == 1)
    printf("%d", 1);
else
    printf("%d", var);

如果 var 是 1,这打印 '1',如果 var 不是 1,这打印 var 的值,无论哪种方式,var 的值都被打印出来,那么 if 做了什么?这可以简单地写成:

代码:
printf("%d", var);

出于同样的原因,这与上面的代码功能相同:

代码:
SendDeathMessage(killerid, playerid, reason);

原始代码来自 0.2 版本的 LVDM,但在那里还做了其他事情,使得检查并非毫无意义,但人们拿走了这段代码,删除了其他部分,并没有思考代码现在实际上在做什么。如果 killerid 无效也没关系,因为如上所示,INVALID_PLAYER_ID 对于 SendDeathMessage 来说是一个完全可以接受的输入。

返回值

与维基百科所说的相反,许多函数的返回值很重要,因为它们指示(原生)函数是否成功。这可以被利用,因为我们之前知道变量比函数调用快,而且一次做事比两次快。一个例子:

代码:
new Float:health;
for (new i = 0; i < MAX_PLAYERS; i++)
    if (IsPlayerConnected(i))
    {
        GetPlayerHealth(i, health);
        SetPlayerHealth(i, health + 10.0);
    }

非常不言自明且典型的代码,但如果我们理解错误返回,这可以优化。由于 0.1 版本中的错误,所有玩家和车辆函数现在都有检查以确保你操作的东西确实存在,所以如果你对一个不存在的玩家执行 GetPlayerHealth,函数将失败,health 变量将和之前的值相同。更重要的是,几乎所有没有重要返回值的函数在失败时返回 0,成功时返回 1。在 GetPlayerHealth 内部的玩家连接检查与 IsPlayerConnected 中的几乎完全相同,所以我们两次检查玩家是否连接。如果他们未连接,GetPlayerHealth 会立即结束,所以我们可以用以下代码代替上面的代码:

代码:
new Float:health;
for (new i = 0; i < MAX_PLAYERS; i++)
    if (GetPlayerHealth(i, health))
        SetPlayerHealth(i, health + 10.0);

对于未连接的玩家,这不会更慢,对于已连接的玩家则更快,所以最坏情况下你没有改进,最好情况下有很大改进。

这也可以应用于其他函数,即使它们有返回值:

代码:
if (IsPlayerInAnyVehicle(playerid))
{
    new vehicleid = GetPlayerVehicleID(playerid);
    SetVehiclePos(vehicleid, 0.0, 0.0, 10.0);
}

同样,这是非常常见的代码,但再次我们需要问这些函数实际返回什么?GetPlayerVehicleID 返回玩家所在车辆的 ID,如果玩家不在车里,它返回 0(因为这是一个无效的车辆 ID)。所以,如果我们要获取他们所在车辆的 ID,并且这个函数知道他们是否在车里并且可以告诉你,那为什么要检查他们是否在车里呢?

代码:
new vehicleid = GetPlayerVehicleID(playerid);
if (vehicleid)
    SetVehiclePos(vehicleid, 0.0, 0.0, 10.0);

现在,不是两个函数调用,你只有一个,并且检查该调用的返回值是否有效(即不是 0)。

返回值的另一个方面是它们存在多长时间。如果你在 if 语句中设置一个变量(如果不小心,会给出意外的赋值警告),你刚刚设置的值仍然在 if 语句中,所以如果你这样做:

代码:
new
    a = 1,
    b = 0;
if ((b = a))

(注意双括号以避免意外的赋值警告)

然后 "a" 会被赋给 "b",所以 "b" 将是 1,而这个 1 仍然在 if 中作为赋值的结果有效,所以这个 if 为真,然而:

代码:
new
    a = 0,
    b = 1;
if ((b = a))

在这段代码之后,"b" 将是 0,因为 "a" 已成功赋给 "b",但由于这个赋值的结果是 0,if 失败。只是为了说明这一点,弄清楚这段代码(如果需要,重新阅读关于字符串的部分):

代码:
stock strcpy(dest[], src[])
{
    new i = 0;
    while ((dest[i] = src[i])) i++;
}

这也意味着你可以这样做:

代码:
new vehicleid;
for (new i = 0; i < MAX_PLAYERS; i++)
    if ((vehicleid = GetPlayerVehicleID(i)))
        SetVehiclePos(vehicleid, 0.0, 0.0, 10.0);

使用此方法在 PAWN 中实现玩家名称检查的示例:

代码:
NameCheck(name[])
{
    new
        i,
        ch;
    while ((ch = name[i++]) && ((ch == ']') || (ch == '[') || (ch == '_') || ('0' <= ch <= '9') || ((ch |= 0x20) && ('a' <= ch <= 'z')))) {}
    return !ch;
}

如果名称有效(即所有字符都是 0-9、a-z、A-Z、[、] 或 _),此函数返回 true,否则返回 false。(ch |= 0x20) 是另一个小技巧,用于将字符转换为小写,无论之前是什么大小写,基于 ASCII 中大写和小写字符正好相差 0x20 的事实(A = 0x40,a = 0x60)。

小片段

等价性

多件事情可以表示同一件事,例如:

代码:
if (string[1] == 65)

等同于:

代码:
if (string[1] == 0x41)

等同于:

代码:
if (string[1] == 0b01000001)

等同于:

代码:
if (string[1] == 'A')

尽管它们都表示同一件事,因此不会带来任何速度提升,但最后一个更明显地表明你正在尝试做什么(检查一个字符是否为 'A')。然而,在其他情况下,其他两个之一可能更合适,例如,你想看看某物是否为 65,而它恰好与 'A' 相同,这仅仅是巧合。

这可以更进一步用零:

代码:
if (string[1] == 0)

等同于:

代码:
if (string[1] == 0x00)

等同于:

代码:
if (string[1] == ((27 + 3) / 5) - 6)

等同于:

代码:
if (string[1] == 0b00000000)

等同于:

代码:
if (string[1] == '\0')

等同于:

代码:
if (string[1] == false)

等同于:

代码:
if (string[1] != true)

等同于:

代码:
if (string[1] == 0.0)

等同于:

代码:
if (!string[1])

实际上,这可以更进一步(floatround_round、radian、SPECIAL_ACTION_NONE、seek_start、io_read 和 PLAYER_STATE_NONE 也都是 0)。

空字符串

检查字符串是否为空的常用方法是:

代码:
if (strlen(string) == 0)
{
    // 字符串为空,因为它的长度是 0
}

这显然检查了字符串是否为空,如果字符串为空则很快,但如果字符串不为空则较慢。strlen 的工作方式是循环遍历字符串直到找到结尾(正如你应该从关于字符串的另一主题中知道的,结尾由 NULL 字符表示),一旦命中结尾,就返回长度,所以如果字符串不为空,找到结尾需要一些时间。

正如我们所知,字符串的结尾由 NULL 字符表示,我们只需要查看第一个字符是否是字符串的结尾:

代码:
if (string[0] == '\0')
{
    // 字符串为空,因为它的第一个字符就是结尾
}

这可以进一步改进:

代码:
if (!string[0])
{
    // 字符串为空,因为它的第一个字符不存在
}

这不适用于通过 CallRemoteFunction 和 CallLocalFunction 传递的字符串。由于 PAWN VM 的工作方式,这些字符串不能长度为 0,所以空字符串作为 "\1\0" 传递(即字符 1 (SOH),字符 0 (NULL))。要检查这个,请执行:

代码:
if (string[0] == '\1' && string[1] == '\0')
{
    // 字符串为空,因为它被特别标记为空
}

或者,使用 YSI 中的 isnull:

代码:
#define isnull(%1) \
    ((!(%1[0])) || (((%1[0]) == '\1') && (!(%1[1]))))

代码:
if (isnull(string))
{
    // 字符串为空,因为 isnull 这么说的
}

感谢 Simon 的建议。

复制字符串

很多人倾向于这样复制字符串:

代码:
format(dest, sizeof (dest), "%s", src);

这是最糟糕的方法之一!我对六种不同的字符串复制方法进行了计时,在所有情况下,"b" 是目标,"a" 是源。"strcpy" 是一个手工编写的 PAWN 函数,用于复制字符串:

代码:
strmid(b, a, 0, strlen(a), sizeof (b));

format(b, sizeof (b), "%s", a);

b[0] = '\0';
strcat(b, a, sizeof (b));

memcpy(b, a, 0, strlen(a) * 4 + 4, sizeof (b)); // 长度以字节为单位,不是单元格。

strcpy(b, a);

b = a;

注意,"b = a;" 是标准的 PAWN 数组复制,并且仅适用于在编译时已知大小相同或目标更大的数组。不幸的是,我运行了一系列测试,它们并没有指向一个单一的最佳函数。它们确实非常清楚地表明,手工编码的 PAWN 版本和 format 在复制字符串时非常慢:

对于短字符串在小数组中,"b = a;" 在适用时最快,先 NULL 终止的 strcat(重要)其次。

对于短字符串在大数组中,strcat 最快。

对于较长字符串在较长数组中,"b = a;" 再次最快,memcpy 其次。

对于巨大数组,"b = a;" 似乎最快。

在可能的情况下,使用标准的数组赋值,但这并不总是可能的,例如,当未知大小的字符串传递给函数时。在这些情况下,我建议使用 strcat(如果你感兴趣,注意奇怪的语法):

代码:
#define strcpy(%0,%1,%2) \
    strcat((%0[0] = '\0', %0), %1, %2)

用法:

代码:
strcpy(dest, src, sizeof (dest));

感谢 Simon 的建议。

假设

引言

底线是不要做假设!

假设是指你认为某物总是某种情况,仅仅因为它经常是这样。例如:

代码:
public OnGameModeInit()
{
    AddPlayerClass(167, 0.0, 0.0, 5.0, 0.0, 0, 0, 0, 0, 0, 0); // Clucking Bell 员工
    AddPlayerClass(179, 0.0, 0.0, 5.0, 0.0, 0, 0, 0, 0, 0, 0); // Ammunation 员工
}

public OnPlayerRequestClass(playerid, classid)
{
    switch (classid)
    {
        case 0:
        {
            GameTextForPlayer(playerid, "Clucking Bell", 5000, 3);
        }
        case 1:
        {
            GameTextForPlayer(playerid, "Ammunation", 5000, 3);
        }
    }
    return 1;
}

这段代码对你们几乎所有人来说可能非常熟悉------设置你的模式的皮肤并根据所选皮肤执行操作(我在这里省略了设置摄像机,因为它与要点无关)。在你自己的私人服务器上,这可能没问题,你知道只有两个皮肤,你知道它们添加的顺序,你知道它们永远不会被修改------没问题。但是如果你要发布一个模式,这是非常危险的。可能出现的一些问题:
  • 使用你模式的人也有添加皮肤的资源过滤器。这会打乱你的类值。
  • 使用你模式的人决定添加皮肤,并将它们添加到列表的开头。这再次打乱你的类值。
  • SA:MP 版本更改改变了 ID 的分配方式。完全改变了你的类 ID。

我相信还有更多,但这些是基础。这里的问题是你使用常量并假设它们永远不会改变,无论是通过模式修改还是通过 AddPlayerClass 不返回你期望的值。对于这个问题,有几种解决方案。

解决方案

忽略它

如果人们想以不同于你意图的方式使用你的模式,那是他们自己的问题,他们可以相应地修改代码。我相信这里的每个人都有这样的经验:当人们在 GF 中添加新车时,为什么所有其他房子和工作的车辆都会乱套。这是因为 GF 是为单个服务器编写的,目的是不被修改,它对有哪些车辆做了假设。

这显然根本不是解决方案。

使修改更容易

第二种解决方案,平衡效率和修改,是做出假设,但尽量减少它们的使用。你可能有一些依赖于所选皮肤的命令,在这种情况下你可能会有如下代码:

代码:
dcmd_chicken(playerid, params[])
{
    if (gClass[playerid] != 0) return SendClientMessage(playerid, 0xFF0000AA, "抱歉,你不是 Clucking Bell 员工");
    ...
}

你对 Clucking Bell 类是类 0 的假设现在在你的模式中出现了两次------一次在 OnPlayerRequestClass 中,一次在 dcmd_chicken 中。假设你的模式超过 20 行,你最终可能会有数百次出现,使得修改成为一场噩梦,因为你必须确保修改每个实例。解决这个问题的最简单方法是使用 define(或 enum):

代码:
#define CLUCKING_BELL_CLASS (0)
#define AMMUNATION_CLASS (1)

public OnGameModeInit()
{
    AddPlayerClass(167, 0.0, 0.0, 5.0, 0.0, 0, 0, 0, 0, 0, 0); // Clucking Bell 员工
    AddPlayerClass(179, 0.0, 0.0, 5.0, 0.0, 0, 0, 0, 0, 0, 0); // Ammunation 员工
}

public OnPlayerRequestClass(playerid, classid)
{
    switch (classid)
    {
        case CLUCKING_BELL_CLASS:
        {
            GameTextForPlayer(playerid, "Clucking Bell", 5000, 3);
        }
        case AMMUNATION_CLASS:
        {
            GameTextForPlayer(playerid, "Ammunation", 5000, 3);
        }
    }
    return 1;
}

dcmd_chicken(playerid, params[])
{
    if (gClass[playerid] != CLUCKING_BELL_CLASS) return SendClientMessage(playerid, 0xFF0000AA, "抱歉,你不是 Clucking Bell 员工");
    ...
}

现在当你在列表开头添加一个新皮肤时,或者当你运行带有自己皮肤的资源过滤器时,你只需要修改模式中的一个部分来反映这个变化。然而,如果你不能保证返回值总是常量,这仍然会引起问题。

防御性编码

确保永远不会遇到问题的唯一方法是根本不做任何假设。保存返回值并使用这些已知的保存值,不要试图猜测它可能是什么:

代码:
new
    gCluckingBellSkin = -1,
    gAmmunationSkin = -1;

public OnGameModeInit()
{
    gCluckingBellSkin = AddPlayerClass(167, 0.0, 0.0, 5.0, 0.0, 0, 0, 0, 0, 0, 0); // Clucking Bell 员工
    gAmmunationSkin = AddPlayerClass(179, 0.0, 0.0, 5.0, 0.0, 0, 0, 0, 0, 0, 0); // Ammunation 员工
}

public OnPlayerRequestClass(playerid, classid)
{
    if (classid == gCluckingBellSkin)
    {
        GameTextForPlayer(playerid, "Clucking Bell", 5000, 3);
    }
    else if (classid == gAmmunationSkin)
    {
        GameTextForPlayer(playerid, "Ammunation", 5000, 3);
    }
    return 1;
}

dcmd_chicken(playerid, params[])
{
    if (gClass[playerid] != gCluckingBellSkin) return SendClientMessage(playerid, 0xFF0000AA, "抱歉,你不是 Clucking Bell 员工");
    ...
}

现在返回值可能是什么并不重要,因为它们在保存和使用之间不可能改变。

重要提示

有些时候假设是可以的,就像我说的,如果你知道你的模式不会被发布,并且你知道你自己不会修改它,或者愿意接受修改所需的所有额外工作,那就去做吧。但不要惊讶,当一段时间后,因为你错过了一些东西,一切都崩溃了。

内存减少

回想一下前面的代码:

代码:
new
    gIsACar[] = {1, 0, 0, 1, 1, 0, 1, 0, 1, 1},
    gIsAHeavyVehicle[] = {1, 0, 1, 0, 0, 0, 0, 0, 1, 0},
    gIsABoat[] = {0, 1, 0, 0, 0, 0, 0, 1, 0, 0},
    gIsAFireEngine[] = {0, 0, 1, 0, 0, 1, 0, 0, 0, 0};

在 PAWN 中,所有变量都是 32 位大小,这意味着它们可以存储高达 4294967296,这段代码只存储 0 或 1------你可以在一个位中做到这一点。这里有十辆车,每辆车有四个信息片段(暂时忽略像 IsACar/IsABoat 这样的互斥信息),那只是 40 个二进制信息片段(即 40 个真/假),以 32 位每单元格计算,那是两个单元格的数据存储在 40 个单元格中(5 字节的数据(对齐到 8 字节)存储在 160 字节中------32 倍的增加,巨大的浪费)。有几种简单的方法可以减少这种使用(尽管这里列出的方法都无法达到完全压缩)。第一种是用一个变量标记所有车辆的一个属性,第二种是用一个变量标记所有车辆的所有属性。

所有车辆

每辆车要么是某物,要么不是;在这个例子中,它们要么是汽车,要么不是。还有 10 辆车,这意味着有 10 位二进制数据,我们将存储在一个变量中,这仍然浪费了 22 位,但这比浪费 310 位要好,并且最多 32 辆车将减少浪费,而不是增加浪费。

代码:
位: 0 1 2 3 4 5 6 7 8 9 10 ...31
值: 1 2 4 8 16 32 64 128 256 512 1024 ... 2147483648
1/0: 1 0 0 1 1 0 1 0 1 1 x ... x

x 意味着我们不关心这些位的值,因为它们不代表车辆(理论上我们甚至可以在这些浪费的位中塞入另一条信息,但现在不会)。假设所有其他位都是 0,那么这个数字是"857",不幸的是,看十进制它没什么意义,所以我们需要以二进制形式读取它。

假设我们想知道车辆型号 403 是否是汽车。首先减去 400 得到范围内的数字,然后我们需要测试第四位(0, 1, 2, 3)。第四位的值是八,所以我们需要测试数字 857 是否设置了八位,这是通过按位 AND 完成的:

代码:
if (857 & 8)
{
    // 位被设置,这辆车是汽车
}

或者,使用我们的数组(现在只是一个变量,因为我们已将其减少到 1 个单元格):

代码:
if (gIsACar & 8)
{
    // 位被设置,这辆车是汽车
}

很好,但目前这将产生如下代码:

代码:
switch (model - 400)
{
case 0: if (gIsACar & 1) ...
case 1: if (gIsACar & 2) ...
case 2: if (gIsACar & 4) ...
case 3: if (gIsACar & 8) ...
case 4: if (gIsACar & 16) ...

这毫无意义,因为如果你要费那个劲,你不如直接做:

代码:
switch (model - 400)
{
    case 0: return true;
    case 1: return false;
    case 2: return false;
    case 3: return true;
    case 4: return true;
}

我们需要某种方法来从型号生成位。1 是 2^0(这里 ^ 表示"的次方",不是异或),2 是 2^1,4 是 2^2,8 是 2^3,所以我们想要的位 8 是 2^3,其中 3 是 403-400:

代码:
new
    bit = 1;
model -= 400;
for (new i = 0; i < model; i++)
{
    bit *= 2;
}

那将得到正确的结果,但有一种更简单的方法来计算 2 的 n 次幂,即左移位运算符:

代码:
8 = 1 << 3

所以现在我们可以这样做:

代码:
if (gIsACar & (1 << (model - 400)))
{
    return true;
}
return false;

比这更好的是,if 检查一个布尔值,当它为真时我们返回真,为假时返回假,所以我们可以完全跳过检查:

代码:
stock IsACar(model)
{
    return gIsACar & (1 << (model - 400));
}

这个函数现在很小,你可以直接把它做成一个 define:

代码:
#define IsACar(%0) \
    (gIsACar & (1 << ((%0) - 400)))

所有属性

每辆车都有一个标记,表示它是否是汽车、是否是船、是否是消防车、是否重型。这是每辆车 4 位信息,正如我们刚刚展示的,你可以使用位在单个单元格中存储多条信息,其中每一位都是一个单独的真/假,我们可以单独访问这些位。那么,如果我们不是用每个位代表不同的车辆,而是用每个位代表不同的属性呢;所以如果位 0 被设置,它是汽车,如果位 1 被设置,它是船,位 2 是重型,位 3 是消防车?让我们看一个例子:

型号 402 是一辆重型消防车,它不是汽车或船。所以我们设置了位 2 和 3,即 4 和 8,4 + 8(技术上 4 | 8)是 12,所以我们有:

代码:
new gModel402 = 12;

现在我们要检查它是否是船,那是位 1,或数字 2:

代码:
return gModel402 & 2;

这将返回 false(如果为真,它将返回 TWO,而不是 ONE,这是许多人错误检查的)。

这可以转化为所有型号的属性数组:

代码:
new gModels[] = {x, x, 12, x, x, x, x, x, x, x};

(x 表示未知的其他)

代码:
return gModels[model - 400] & 2;

无法简化 2,你只需使用像这样的函数:

代码:
#define IsACar(%0) \
    (gModels[(%0) - 400] & 1)

#define IsABoat(%0) \
    (gModels[(%0) - 400] & 2)

#define IsAHeavy(%0) \
    (gModels[(%0) - 400] & 4)

这无法进一步优化,但可以使它更具可读性和更易于编辑。如果我告诉你一个型号是类型 10,你可能需要查看上面的列表才能知道它是一艘消防船,但如果我告诉你它是一个重型汽车,你会立刻知道它是什么。所以让我们这样做:

代码:
#define MODEL_CAR  (1)
#define MODEL_BOAT  (2)
#define MODEL_HEAVY (4)
#define MODEL_FIRE  (8)

new gModels[] =
    {
        x,
        x,
        MODEL_HEAVY | MODEL_FIRE,
        x,
        x,
        x,
        x,
        x,
        x,
        x
    };

#define IsACar(%0) \
    (gModels[(%0) - 400] & MODEL_CAR)

#define IsABoat(%0) \
    (gModels[(%0) - 400] & MODEL_BOAT)

#define IsAHeavy(%0) \
    (gModels[(%0) - 400] & MODEL_HEAVY)

你现在可以立即从数组条目中看出车辆是什么,但还有一种更好的方法。我不会详细讨论这个,因为它在 pawn-lang.pdf 中有解释,但你可以这样做:

代码:
enum (<<= 1)
{
    MODEL_CAR = 1,
    MODEL_BOAT,
    MODEL_HEAVY,
    MODEL_FIRE
}

其余代码完全相同,但这减少了输入,并且意味着你可以在 MODEL_CAR 和 MODEL_BOAT 之间添加内容而无需更新任何值。

超过32个值

当你有超过 32 个值要存储时会发生什么?在这种情况下,你需要一个数组,其索引是值超过 32 的次数。所以 1 将是单元格 0(因为它没超过 32),位 2;32 将是 1,0;66 将是 2,3。在 YSI 的 YSI_bit 中有简化此操作的代码。在这种情况下,你的代码看起来会像这样:

代码:
enum e_MODELS
{
    MODEL_CAR,
    MODEL_BOAT,
    MODEL_HEAVY,
    MODEL_FIRE,
    ...
    MODEL_SOMETHING
}

new Bit:gModels[410 - 400][Bit_Bits(e_MODELS)];

#define IsACar(%0) \
    (Bit_Get(gModels[(%0) - 400], MODEL_CAR))

#define IsSomething(%0) \
    (Bit_Get(gModels[(%0) - 400], MODEL_SOMETHING))

此代码的最新版本(我在写本教程时重写了它,因为它让我重新审视了我自己的代码)可以在这里找到。

多余的维度

我不知道为什么人们会这样做,我敢肯定他们是从更常见的模式中复制过来的,但这并不意味着它是正确的,我也不知道为什么一开始要这样做。如果你有一个值数组,不要浪费维度。例子:

我有一个包含 10 个值的数组,为了讨论方便,我们称它们为武器价格。所以我们有 10 种武器,每种都有一个价格,我们想将它们存储在一个数组中。每种武器都有一个 ID,从 0 到 9,所以要获得该武器的价格,你需要访问数组中的该索引:

代码:
new
    gPrices[10] = // 10 种武器,因此 10 个价格
    {
        1000,
        2000,
        5000,
        2000,
        10000,
        500,
        3000,
        2000,
        100,
        750
    };

代码:
new
    weaponPrice = gPrices[5];

这就是你所需要的------这是基本的数组访问,然而由于某些原因,人们坚持做以下事情:

代码:
new
    gPrices[10][1] = // 10 种武器,因此 10 个价格
    {
        {1000},
        {2000},
        {5000},
        {2000},
        {10000},
        {500},
        {3000},
        {2000},
        {100},
        {750}
    };

代码:
new
    weaponPrice = gPrices[5][0];

额外的维度有什么作用?完全没有!如果你每种武器有两个价格,那么是的------你需要额外的维度,但你没有,所以你就不要------就不要这样做,简单明了!这是浪费时间------速度更慢,而且浪费空间------它更大。

CPUvs内存

这是编写代码时非常常见的因素,值得特别提及。几乎在所有事情上,你都有如何做的选择,通常一种方式会很快但使用大量内存,另一种方式会很慢但使用很少内存。例如,在上面的 foreach 示例中,foreach 代码更快,但使用了一个大数组,IPC 更慢但几乎不使用内存,因为它没有任何存储。在所有这些情况下,你只需要根据情况做出决定。前面关于内存减少的部分列出了各种使用更少内存的方法,但它们都使用了额外的代码来实现,使得它们比原始的大数组慢。然而在这种情况下,减少幅度如此之大(内存使用大约减少了 32 倍),以至于它超过了轻微的速度下降(如上所述,位运算符也非常快)。

然而,还有第三种选择------复杂性。

在 foreach/IPC 示例中,foreach 速度更快,IPC 内存占用更小,但通过编写更复杂的代码,有可能结合两者的优点。更复杂的代码通常更明确地处理更多情况,这意味着你不必编写处理所有可能性的通用代码。在玩家循环示例中,这将表现为类似:

代码:
if (IsPlayerConnected(0))
{
    // 做点什么
}
if (IsPlayerConnected(1))
{
    // 做点什么
}
if (IsPlayerConnected(2))
{
    // 做点什么
}

...

if (IsPlayerConnected(199))
{
    // 做点什么
}

显然,这段代码看起来非常低效,也很难维护,但你摆脱了循环和变量,使代码更快,并且内存占用为 0 个单元格。从表中我们还知道常量(0、1 等)比变量(i)更快。我没有对这段代码进行计时,所以我不知道对于大量玩家(在低数量时没有竞争)来说,它与 foreach 相比哪个更快,但它肯定比 IPC 循环快。显然,这个例子仍然更适合使用更多内存,但有些情况下可能不是这样。

列表

这一节和下一节基本上直接来自维基百科,因为这是我最初写在那里的。

列表是一种非常有用的结构类型,它们基本上是一个数组,其中下一个相关数据由上一个数据指向。

例子:

假设你有以下数组:

代码:
3, 1, 64, 2, 4, 786, 2, 9

如果你想对数组进行排序,你会得到:

代码:
1, 2, 2, 3, 4, 9, 64, 786

然而,如果你想保留数据的原始顺序,但又想以某种顺序知道数字(这只是一个例子),你就有问题了,你如何让数字同时按两种顺序排列?这将是列表的一个好用途。要从此数据构建一个列表,你需要将数组变成二维数组,其中第二维有 2 个单元格大,一个包含原始数字,另一个包含下一个最大数字的索引。你还需要一个单独的变量来保存最小数字的索引,所以你的新数组看起来像:

代码:
start = 1
data: 3, 1, 64, 2, 4, 786, 2, 9
next: 3, 5, 6, 7, -1, 0, 2, 4

与 786 关联的下一个索引是 -1,这是一个无效的数组索引,表示列表的结束,即没有更多数字了。两个 2 显然可以任意顺序,数组中第一个出现在列表中的也是第一个,因为它是更可能首先遇到的。

这种排序方法的另一个优点是添加更多数字要快得多。如果你想在排序数组中添加另一个数字 3,你需要首先至少将 4 个数字向右移动一个槽位,在这里不算太糟,但在更大的数组中非常慢。使用列表版本,你可以简单地将 3 附加到数组的末尾,并修改列表中的一个值;

代码:
start = 1
data: 3, 1, 64, 2, 4, 786, 2, 9, 3
next: 8, 3, 5, 6, 7, -1, 0, 2, 4
      ^ 修改这个值
        ^ 下一个更高的槽位

其他数字都没有移动,所以其他索引都不需要更新,只需让下一个最低的数字指向新数字,并让新数字指向下一个最低数字曾经指向的数字。删除一个值甚至更容易:

代码:
start = 1
data: 3, 1, 64, X, 4, 786, 2, 9, 3
next: 8, 6, 5, 6, 7, -1, 0, 2, 4
      ^ 更改为跳过失删除的值

这里第一个 2 已被移除,指向该数字的数字(1)已被更新,以指向被移除数字所指向的数字。在这个例子中,被移除的数字的指针和数字都没有被删除,但你不可能通过跟随列表到达那个槽位,所以没关系,它被有效地移除了。

类型

上面例子中的列表只是基本的单链表,你还可以有双链表,其中每个值都指向下一个值和上一个值,这些往往也有一个指向列表末尾的指针,以便向后遍历(例如,以降序获取数字):

代码:
start = 1
end = 5
value: 3, 1, 64, 2, 4, 786, 2, 9, 3
next: 8, 3, 5, 6, 7, -1, 0, 2, 4
last: 6, -1, 7, 1, 8, 2, 3, 4, 0

你必须小心处理这些,特别是当你有多个相同值时,要确保 last 指针指向的数字的 next 指针直接指向回它,例如这是错误的:

代码:
2, 3, 3
1, 2, -1
-1, 2, 0

2 的 next 指针指向槽位 1 中的 3,但那个 3 的 last 指针不指向回 2,两个列表各自有序(因为两个 3 可以任意顺序),但在一起时是错误的,正确的版本应该是:

代码:
2, 3, 3
1, 2, -1
-1, 0, 1

这两个列表都从末尾的两个数字开始和结束,错误例子中的反向列表从中间的数字开始。

另一种列表类型是循环列表,其中最后一个值指向第一个值。这样做明显的好处是,你可以从任何值到达任何其他值,而无需事先知道目标是在起点之前还是之后,你只需要小心不要进入无限循环,因为没有明确的 -1 结束点。这些列表仍然有起点。你还可以做双循环列表,其中有一个 next 和一个 last 列表,两者都循环:

代码:
start = 1
end = 5
value: 3, 1, 64, 2, 4, 786, 2, 9, 3
next: 8, 3, 5, 6, 7, 1, 0, 2, 4
last: 6, 5, 7, 1, 8, 2, 3, 4, 0



RE: [教程] 代码优化 #2 - XiaoNiao - 02-28-2026

混合列表

混合列表是同时包含多个列表的数组。一个例子可以是一个值数组,通过一个列表排序,用另一个列表链接所有未使用的槽位,这样你就知道可以在哪里添加一个新值。例子(X 表示未使用(空闲)槽位):

代码:
sortedStart = 3
unusedStart = 1
value: 34, X, X, 6, 34, 46, X, 54, 23, 25, X, 75, X, 45
sort: 4, 8, 13, 7, 11, 9, 0, -1, 5
free: 2, 6, 10, 12, -1

显然,这两个列表从不交互,所以两者都可以使用同一个槽位来存储它们的下一个值:

代码:
sortedStart = 3
unusedStart = 1
value: 34, X, X, 6, 34, 46, X, 54, 23, 25, X, 75, X, 45
next: 4, 2, 6, 8, 13, 7, 10, 11, 9, 0, 12, -1, -1, 5

代码

在你开始编码之前,你需要决定哪种列表最适合你的应用,这完全基于应用,这里不容易涵盖。所有这些例子都是混合列表,一个列表用于所需的值,一个用于未使用的槽位。

这个例子展示了如何为按数字升序排序的列表编写代码。

代码:
#define NUMBER_OF_VALUES (10)

enum E_DATA_LIST
{
    E_DATA_LIST_VALUE,
    E_DATA_LIST_NEXT
}

new
    gListData[NUMBER_OF_VALUES][E_DATA_LIST],
    gUnusedStart = 0,
    gListStart = -1; // 一开始没有列表

// 此函数初始化列表
List_Setup()
{
    new
        i;
    size--;
    for (i = 0; i < size; i++)
    {
        // 一开始所有槽位都是未使用的
        gListData[i][E_DATA_LIST_NEXT] = i + 1;
    }
    // 结束列表
    gListData[size][E_DATA_LIST_NEXT] = -1;
}

// 此函数向列表添加一个值(使用基本排序)
List_Add(value)
{
    // 检查数组中是否有空闲槽位
    if (gUnusedStart == -1) return -1;
    new
        pointer = gListStart,
        last = -1,
        slot = gUnusedStart;
    // 将值添加到数组
    gListData[slot][E_DATA_LIST_VALUE] = value;
    // 更新空闲列表
    gUnusedStart = gListData[slot][E_DATA_LIST_NEXT];
    // 循环遍历列表,直到找到更大/相同大小的数字
    while (pointer != -1 && gListData[pointer][E_DATA_LIST_VALUE] < value)
    {
        // 保存上一个值的位置
        last = pointer;
        // 移动到下一个槽位
        pointer = gListData[pointer][E_DATA_LIST_NEXT];
    }
    // 如果我们到了这里,要么数字用完了,要么遇到了更大的数字
    // 检查我们是否检查了任何数字
    if (last == -1)
    {
        // 第一个数字更大,或者没有列表
        // 无论哪种情况,将新值添加到列表开头
        gListData[slot][E_DATA_LIST_NEXT] = gListStart;
        gListStart = slot;
    }
    else
    {
        // 将新值放入列表
        gListData[slot][E_DATA_LIST_NEXT] = pointer;
        gListData[last][E_DATA_LIST_NEXT] = slot;
    }
    return slot;
}

// 此函数从数组中删除给定槽位的值(由 List_Add 返回)
List_Remove(slot)
{
    // 这是一个有效的槽位吗?
    if (slot < 0 || slot >= NUMBER_OF_VALUES) return 0;
    // 首先找到它前面的槽位
    new
        pointer = gListStart,
        last = -1;
    while (pointer != -1 && pointer != slot)
    {
        last = pointer;
        pointer = gListData[pointer][E_LIST_DATA_NEXT];
    }
    // 我们在列表中找到了这个槽位吗?
    if (pointer == -1) return 0;
    if (last == -1)
    {
        // 该值是列表中的第一个
        // 在列表中跳过这个槽位
        gListStart = gListData[slot][E_LIST_DATA_NEXT];
    }
    else
    {
        // 该值在列表中
        // 在列表中跳过这个槽位
        gListData[last][E_LIST_DATA_NEXT] = gListData[slot][E_LIST_DATA_NEXT];
    }
    // 将此槽位添加到空闲列表
    // 空闲列表没有特定顺序,所以这没关系
    gListData[slot][E_LIST_DATA_NEXT] = gUnusedStart;
    gUnusedStart = slot;
    return 1;
}

二叉树

二叉树是一种通过使用非常特殊的列表系统在数组中搜索数据时非常快速的方法。最著名的二叉树可能是 20 个问题游戏,只需要 20 个是/否问题,你就可以拥有超过 1048576 个项目。顾名思义,二叉树是一种树,类似于家谱树,其中每个项目有 0、1 或 2 个子节点。它们不像列表那样用于排序数据,而是用于非常高效的搜索。基本上,你从一个大致位于有序对象列表中间的项目开始(例如,已排序数组的中间数字),并将该值与你要查找的值进行比较。如果相同,你就找到了你的项目,如果更大,你就移动到右边的项目(不一定是紧挨着的右边,中间项目右边的项目是四分之三处的项目),如果更小,你就向左移动,然后重复这个过程。

示例

代码:
1 2 5 6 7 9 12 14 17 19 23 25 28 33 38

你有上面的有序数组,你想找出数字 7 在哪个槽位(如果存在的话),在这个例子中,直接循环遍历数组找到它可能更高效,但这并不是重点,这种方法的时间随数组大小线性增长,而二分查找的时间随数组大小的指数增长而线性增长。即,一个 128 大小的数组直接查找需要的时间是一个 64 大小数组的两倍,但一个 128 大小的二分查找只比 64 大小的二分查找多一次检查,而不是多很多。

如果我们从上面的数据构建一个二叉树,我们得到:

[图片: Binarytree.gif]

二叉树

如果你从左到右阅读,忽略垂直方面,你可以看到数字是按顺序的。现在我们可以尝试找到 7。

起始数字是 14,7 小于 14,所以我们转到 14 的左分支指向的槽位。这让我们来到 6,7 大于 6,所以我们向右到 9,然后向左到 7。这种方法用了 4 次比较就找到了数字(包括最后确认我们在 7 上),而直接查找需要 5 次。

假设没有 7,我们会得到这个二叉树:

[图片: Binarytree-7-less.gif]
二叉树缺少7

这与上面的例子不同,它有一个单子数字(9),以及 2 个和 0 个子数字。只有当有 (2^n)-1 个数字(0, 1, 3, 7, 15, 31 ...)时,你才能得到完美的树,任何其他数字都会得到一个不完整的树。在这种情况下,当我们到达 9 时,也就是 7 应该在的位置,我们会发现没有左分支,这意味着 7 不存在(它不可能在树中的其他地方,想想看),所以我们返回 -1 表示无效槽位。

平衡与非平衡

上面例子中的树被称为平衡二叉树,这意味着尽可能让所有分支长度相同(显然在第二个例子中,由于数字不足,不可能做到,但已经尽可能平衡了)。构建平衡树并不容易,通常接受的方法是随机顺序放入数字,这可能导致像这样的结果:

[图片: Binarytree-uneven.gif]
不平衡二叉树

显然,这棵树仍然是有效的,但右侧比左侧大得多,然而查找 25 仍然只需要 7 次比较,而在直接列表中需要 12 次。另外,只要你从一个相当中间的数字开始,随机插入方法应该会产生一个相当平衡的树。你能做的最糟糕的事情是按顺序放入数字,因为那样就不会有任何左分支(或者反过来就没有右分支),然而即使在这种最坏的情况下,二叉树的搜索时间也不会比直接列表长。

修改
  • 添加

向二叉树添加一个值相对容易,你只需沿着树走,使用你想要添加的值作为参考,直到你到达一个空分支,然后在那里添加数字。例如,如果你想向原始平衡树中添加数字 15,它最终会出现在 17 的左分支上。如果我们想向第二个平衡树(没有 7 的那个)添加数字 8,它最终会出现在 9 左边的 7 的旧槽位。
  • 删除

从二叉树中删除一个数字可能很难,也可能很容易。如果数字在分支的末端(例如原始树中的 1、5、7、12 等),你只需删除它们。如果一个数字只有一个子节点(例如第二个例子中的 9),你只需将该子节点(例如 12)移到它的位置(所以在新的第二个例子中,删除 9 后,6 的子节点将是 2 和 12)。只有当一个节点有两个子节点时,删除才变得有趣。至少有四种方法可以做到这一点:

第一种方法在计算上最简单。基本上你选择一个分支(左或右,假设这里用右),然后用该分支的第一个节点(即被删除节点的右子节点)替换你删除的节点。然后你沿着那个新分支向左走,直到到达末尾,并将左分支放在那里。例如,如果你从原始示例中删除 14,你会得到 25 占据树顶的位置,而 6 连接到 17 的左分支。这种方法很快,但很快就会导致树非常不平衡。

第二种方法是获取你刚刚删除节点的所有子节点,并从它们重新构建一个新的二叉树,然后将该树的顶部放入你刚刚删除的节点中。这能使树保持相当平衡,但显然更慢。

第三种方法是结合上述两种方法,并就地重建树,这编码更复杂,但能保持树平衡,并且比第二种方法更快(尽管远不如第一种快)。

这里列出的最后一种方法是简单地在值上设置一个标志,表示它不再被使用,这甚至比第一种方法更快,并且保持结构,但除非你以后能找到替换它的值,否则你无法重用该槽位。

* * *