如何在本质上具有状态的程序中避免使用全局变量?

3

我正在使用C语言编写一个小游戏,并感觉无法摆脱全局变量的影响。

例如,我将玩家位置存储为全局变量,因为其他文件需要它。我制定了一些规则来保持代码的清晰。

  1. 如果可能的话,仅在定义该全局变量的文件中使用全局变量。

  2. 从另一个文件直接更改全局变量的值是不允许的(可以使用extern从另一个文件读取)。

因此,例如,图形设置会作为文件范围变量存储在graphics.c中。如果其他文件中的代码想要更改图形设置,则必须通过graphics.c中的函数进行更改,如graphics_setFOV(float fov)

您认为这些规则足以避免长期使用全局变量带来的问题吗?

文件范围变量有多糟糕?

使用extern从其他文件读取变量是否合适?


这里没有硬性规定。有时使用全局变量是有意义的,但在这种情况下,将它们保持为一个翻译单元的本地变量几乎是必需的。extern变量只会导致混淆。此外,尝试通过命名约定清楚地区分全局变量。 - Peter
好的编程目标之一是隐藏变量,使其不易受到外部影响。一个简单的方法是在函数内部隐藏一个静态变量,这样它只能通过该函数访问。然后通过相关头文件中的原型将该函数对其他编译单元可见。 - user3629249
3个回答

4
通常,这种问题是通过传递共享上下文来处理的: graphics_api.h
#ifndef GRAPHICS_API
#define GRAPHICS_API

typedef void *HANDLE;

HANDLE init_graphics(void);
void destroy_graphics(HANDLE handle);
void use_graphics(HANDLE handle);

#endif

graphics.c

#include <stdio.h>
#include <stdlib.h>
#include "graphics_api.h"

typedef struct {
    int width;
    int height;
} CONTEXT;

HANDLE init_graphics(void) {
    CONTEXT *result = malloc(sizeof(CONTEXT));
    if (result) {
        result->width = 640;
        result->height = 480;
    }
    return (HANDLE) result;
}

void destroy_graphics(HANDLE handle) {
    CONTEXT *context = (CONTEXT *) handle;
    if (context) {
        free(context);
    }
}

void use_graphics(HANDLE handle) {
    CONTEXT *context = (CONTEXT *) handle;
    if (context) {
        printf("width  = %5d\n", context->width);
        printf("height = %5d\n", context->height);
    }
}

main.c

#include <stdio.h>
#include "graphics_api.h"

int main(void) {
    HANDLE handle = init_graphics();
    if (handle) {
        use_graphics(handle);
        destroy_graphics(handle);
    }
    return 0;
}

输出

width  =   640
height =   480

使用void指针隐藏上下文细节可以防止用户更改指向的内存中包含的数据。

使用空指针隐藏上下文的详细信息可以防止用户更改数据,但这并不是完全防止,只是使其更加困难。 数据块仍然在用户拥有的进程的地址空间中。 - Andrew Henle
注意:以一个或两个下划线开头的预处理器符号名称是保留的。 - wildplasser
关于:HANDLE *init_graphics(void) { CONTEXT *result = malloc(sizeof(CONTEXT)); if (result) { result->width = 640; result->height = 480; } return (HANDLE *) result;}malloc()失败时,将返回一个空指针。调用此函数的代码未检查此NULL返回值。当调用代码尝试访问该“已分配内存”时,将发生段错误事件。 - user3629249
关于 typedef void *HANDLE; 这是在 typedef 中隐藏指针,这是非常糟糕的编程结构。例如:HANDLE *init_graphics(void) 这意味着:void **init_graphics( void ) 这不是你想要的。 - user3629249

1
首先,你必须问自己的问题是:为什么编程世界会讨厌全局变量?显然,像你指出的那样,模拟全局状态的方式就是使用一个全局变量(集)。那么问题在哪里呢?
问题出在程序的所有部分都可以访问该状态。整个程序变得紧密耦合。全局变量违反了编程中的主导原则——分而治之。一旦所有函数都处理相同的数据,你也可以不需要函数:它们不再是关注点的逻辑分离,而是退化为避免大文件的符号方便。
写入访问比读取访问更糟糕:你将很难找出在某个特定点上状态为何出现意外;更改可能已经发生在任何地方。这很容易引诱人们采取捷径:"啊,我们可以在这里改变状态,而不是将计算结果返回给调用者,这样代码就会小得多。"
即使是只读访问也可以用来欺骗,例如根据某些全局信息更改一些深层次代码的行为:“啊,我们可以跳过渲染,因为还没有显示!”这样的决定不应该在渲染代码中进行,而应该在顶层进行。如果顶层将渲染到文件中怎么办?
这会同时带来调试和开发/维护方面的噩梦。如果每个代码片段都可能依赖于特定变量的存在和语义,并且可以更改它们!——那么调试或更改程序就变得指数级难度。围绕全局数据聚合的代码就像一个铸模,或者说像蟒蛇,开始束缚和扼杀您的程序。
这种编程方式可以通过(自我)纪律避免,但想象一下有许多团队的大型项目!最好“物理”上防止访问。不巧的是,所有在C之后的编程语言,即使它们在其他方面根本不同,都具有改进的模块化方法。
那么我们能做什么?
解决方案确实是将参数传递给函数,正如KamilCuk所说; 但是每个函数只应获取它们合法需要的信息。当然,最好的方法是访问只读,并且结果是返回值:纯函数根本无法更改状态,因此完全分离关注点。
但是,简单地传递指向全局状态的指针并不能解决问题:这只是一个掩盖不住的全局变量。
相反,状态应该被分成子状态。只有顶级函数(通常自己并不做太多事情,而是大多数委托)可以访问整体状态并将子状态交给他们调用的函数。第三层函数获得子子状态等等。在C中的相应实现是嵌套结构;指向成员的指针 - 尽可能使用const - 被传递给函数,因此无法看到,更不能更改其余的全局状态。因此,关注点的分离得到了保证。

1

如何在内在具有状态的程序中避免使用全局变量?

通过传递参数...

// state.h
/// state object:
struct state {
    int some_value;
};
/// Initializes state
/// @return zero on success
int state_init(struct state *s);
/// Destroys state
/// @return zero on success
int state_fini(struct state *s);
/// Does some operation with state
/// @return zero on success
int state_set_value(struct state *s, int new_value);
/// Retrieves some operation from state
/// @return zero on success
int state_get_value(struct state *s, int *value);

// state.c
#include "state.h"
int state_init(struct state *s) {
    s->some_value = -1;
    return 0;
}
int state_fini(struct state *s) {
    // add free() etc. if needed here
    // call fini of other objects here
    return 0;
}
int state_set_value(struct state *s, int value) {
    if (value < 0) { 
        return -1; // ERROR - invalid argument
                   // you may return EINVAL here
    }
    s->some_value = value;
    return 0; // success
}
int state_get_value(struct state *s, int *value) {
    if (s->some_value < 0) { // value not set yet
        return -1;
    }
    *value = s->some_value;
    return 0;
}

// main.c
#include "state.h"
#include <stdlib.h>
#include <stdio.h>
int main() {
    struct state state; // local variable
    int err = state_init(&state);
    if (err) abort();

    int value;
    err = state_get_value(&state, &value);
    if (err != 0) {
        printf("Getting value errored: %d\n", err);
    }

    err = state_set_value(&state, 50);
    if (err) abort();
    err = state_get_value(&state, &value);
    if (err) abort();
    printf("Current value is: %d\n", value);

    err = state_fini(&state);
    if (err) abort();
}

唯一需要使用全局变量(最好只有一个指向某些堆栈变量的指针)的情况是信号处理程序。标准方法是在信号处理程序中仅增加一个类型为sig_atomic_t的单个全局变量,并且不执行任何其他操作-然后通过检查该变量的值从代码的其余部分中正常流程执行所有与信号处理相关的逻辑。(在POSIX系统上)内核的所有其他异步通信,如timer_create,它们可以通过使用union sigval中的成员向通知函数传递参数。

您认为这些规则足以长期避免全局变量地狱吗?

主观上:不。我相信一个潜在的未受教育的程序员在给定第一条规则时在创建全局变量方面拥有太多自由。在复杂程序中,我会使用硬性规则:不要使用全局变量。如果在研究了所有其他方式和所有其他可能性之后,您必须使用全局变量,请确保全局变量留下尽可能小的内存印记。

在简单的短程序中,我不会太在意。

文件范围变量有多糟糕?

这是基于观点的 - 在一些项目中使用许多全局变量是可以接受的。我相信这个主题在全局变量是否有害和其他许多互联网资源中已经详细讨论过。

使用extern从其他文件读取变量是可以的吗?

是的,可以。

没有“硬性规定”,每个项目都有自己的规则。我也建议阅读c2 wiki 全局变量是否有害


显然,仅仅传递单例的地址只是访问该(逻辑上仍然是)全局变量的一个复杂化。 - Peter - Reinstate Monica

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接