问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

全局变量并非“万恶之源”:深度剖析与正确使用之道

创作时间:
作者:
@小白创作中心

全局变量并非“万恶之源”:深度剖析与正确使用之道

引用
CSDN
1.
https://m.blog.csdn.net/2401_86652632/article/details/145450308

在编程的世界里,有一条似乎被奉为圭臬的规则:全局变量是不好的,应当尽量避免使用。我们在编程学习的初期,就被反复灌输这个观念,仿佛全局变量是编程道路上的“洪水猛兽”。但事实真的如此吗?今天,就让我们深入探讨全局变量的真实面目,通过实际案例来重新认识它。

一、被误解的全局变量:从一个案例说起

先来看一段有趣的代码示例。假设我们想要统计一个名为simple的函数在抛出异常(代码中未展示异常部分)之前被调用的次数,以便在函数顶部设置断点进行调试。下面是一段没有使用全局变量的代码:

let counter = { count:0 }
let obj = { counter:counter };
function simple(obj) { 
    console.log(++obj.counter.count) 
    if (obj.counter.count == 123) {
        //let's set a breakpoint before the exception
    }
    /* rest of func with buggy logic */
}
function complex(obj) {
    let temp = structuredClone(obj)
    simple(temp)
    simple(temp)		
}
simple(obj)
simple(obj)
complex(obj)
simple(obj)  

运行这段代码后,你会发现输出结果并非如预期的那样依次递增,而是出现了重复的数字,比如1 2 3 4 3,而不是1 2 3 4 5。这是为什么呢?问题就出在structuredClone这个函数上,它对对象进行了深拷贝。当complex函数调用simple函数时,修改的是深拷贝后的对象中的counter,而不是原始对象的counter,这就导致计数出现错误,让我们难以确定正确的断点设置时机。

再看看使用全局变量的版本:

let count = 0 //global 
let obj = { };
function simple(obj) { 
    console.log(++count) 
    if (count == 123) {
        //let's set a breakpoint before the exception
    }
    /* rest of func with buggy logic */
}
function complex(obj) {
    let temp = structuredClone(obj)
    simple(temp)
    simple(temp)		
}
simple(obj)
simple(obj)
complex(obj)
simple(obj)  

在这个版本中,代码能够正确地统计函数调用次数。通过这个对比,我们可以发现,避免使用全局变量并不一定就能编写出完美无缺的代码,有时反而会引入新的问题。

二、究竟什么是全局变量?

在深入探讨之前,我们需要先明确一下全局变量的定义。简单来说,在我看来,任何不是作为参数传递进函数,也不是在函数内部定义的变量,都可以被看作是全局变量。不同编程语言中,全局变量有多种形式:

  1. 全局变量(Global):这类变量定义在类和函数之外,并且在其他文件中也能访问到。不过,我很少使用这种全局变量,因为它的作用域过于广泛,容易导致代码的可维护性变差。

  2. 私有 / 静态变量(Private/Static):这是一种在文件内部可见的全局变量。我主要使用的是这种类型的全局变量,它在一定程度上限制了变量的作用范围,减少了对其他代码的干扰。

  3. 线程局部变量(Thread Local):这类全局变量(可能是静态的)在每个线程中都有一个唯一的实例。当我进行多线程编程时,会避免使用其他类型的全局变量,而选择线程局部变量,以确保每个线程的数据独立性,避免数据竞争和不一致的问题。

  4. 静态成员(Static Member):在一些语言中,你可以使用静态成员来创建只读的“空值”“最小值”“最大值”等变量。但我通常会尽可能避免使用它,因为它可能会带来一些意想不到的问题,比如内存管理和作用域相关的问题。

  5. 静态函数变量(Static Function Variable):以C语言为例,在函数内部可以声明静态变量。虽然它在函数内部定义,但它存储在堆上,并且可以在函数外部被返回和修改。除了用作计数器且不返回其地址的情况外,我绝对会避免使用这种变量,因为它的行为特性可能会导致代码的复杂性增加,难以调试和维护。

此外,还有一些情况也可以被看作是在使用全局变量,比如调用一个内部使用全局变量的函数,就像上面代码中如果把++count替换为调用inc()函数来实现计数功能;调用一个有不易察觉副作用的函数,像print函数、写入文件或音频设备的函数;修改那些不会改变程序主要状态的变量,比如日志记录的详细程度级别,或者用于触发某些数据重新计算以检测逻辑错误的变量。

三、问题的根源:数据访问,而非全局变量本身

很多人认为全局变量存在问题,但实际上,问题的关键在于数据访问,也就是“远程作用(action at a distance)”。即使不使用全局变量,当程序保存了你传入的指针副本时,可能会出现一些对象之间意外的相互影响,而你却根本不知道它们之间存在关联。就像前面提到的代码示例,人们为了避免对象的意外修改而选择克隆对象,但在这个例子中,克隆反而引发了问题。

当全局变量出现问题时,人们往往很容易将责任归咎于它是全局变量这一事实。而且,使用全局变量时也容易养成一些不好的习惯。比如初学者可能会偷懒,为了避免修改十几个函数的参数签名,而选择使用全局变量,结果却在无意中覆盖了需要的值,给自己带来麻烦。

四、全局变量的用武之地

虽然全局变量常常被诟病,但在一些场景下,它确实能发挥出独特的优势:

  1. 函数调用计数:在调试过程中,我喜欢使用全局变量来统计某个特定函数的调用次数,就像前面示例中统计simple函数的调用次数一样,这对于设置断点、分析函数执行流程非常有帮助。

  2. 对象唯一标识:可以用全局变量生成一个串行ID,用于区分内容相同的对象。因为指针地址在不同的运行环境中可能不可靠,而全局变量生成的唯一ID能更稳定地完成这项工作。

  3. 日志记录、自定义内存分配器和线程安全的数据库连接池:在这些场景中,全局变量可以方便地在整个程序中共享数据。例如,日志记录需要在不同的函数和模块中记录信息,使用全局变量可以确保日志记录的一致性;自定义内存分配器和线程安全的数据库连接池也需要在程序的多个地方进行访问和管理,全局变量能够很好地满足这一需求。

  4. 消息队列和只追加工作列表:假设有一个函数用于处理事件,而这个事件可能会产生更多需要处理的事件。这时可以使用全局变量来管理消息队列或只追加工作列表。在入口函数中分配或清理当前工作列表和全局工作列表,将第一个事件添加到当前工作列表,然后函数循环处理当前工作列表中的事件。事件处理通过虚函数调用,这些函数会将新的工作追加到全局工作列表。当当前工作列表没有任务时,将其清理并与全局工作列表交换,如此循环,直到所有事件和任务都处理完毕。只要操作只有追加,并且只有入口函数进行交换(和清理)操作,这种方式就很难出错。

  5. 树遍历中的活动文件 / 缓冲区 / 节点:在进行树状结构遍历的程序中,如果需要在一个入口函数中设置一个活动文件、缓冲区或节点,并在遍历过程中使用它,那么全局变量是一个不错的选择。当然,如果函数是递归的,需要在函数返回时恢复原始的活动节点。虽然也可以通过向每个函数传递一个上下文对象来实现,但如果有上百种类型(我曾经真的遇到过这种情况,这些类型都重载了至少两个虚函数),在近千个调用点传递上下文参数会让代码显得非常冗长,而且大部分树节点只是简单地将上下文传递给叶子节点,看起来很多余。

五、让全局变量更安全:封装的力量

其实,只要进行适当的封装,全局变量可以变得更加可靠,减少出错的可能性。就像大家很少抱怨print函数或者内存分配操作本身,除非它们的使用过于频繁。例如,前面例子中的全局计数器可以封装成一个inc()函数;只追加工作列表可以通过一个函数或者只允许追加操作的类型来访问。通过这种封装方式,我们可以更好地控制对全局变量的访问,降低错误发生的概率,同时也提高了代码的可读性和可维护性。

全局变量并非绝对的“坏东西”,它在编程中有着自己的价值和适用场景。我们不能因为它可能带来的问题而完全摒弃它,而是要学会正确地使用它。在实际编程过程中,我们需要根据具体的需求和场景,谨慎地选择是否使用全局变量,并通过合理的设计和封装,充分发挥它的优势,避免潜在的问题。希望通过今天的分享,能让大家对全局变量有一个全新的认识,在编程时做出更明智的选择。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号