代码风格与品味
这是一个分享我对代码和实践的思考的博客
在这篇文章中,我将展示一个例子,说明避免使用全局变量如何导致了一个错误,我将定义什么是全局变量,解释问题所在,然后给出我成功使用它们的例子。
全局变量不是问题
我们都被告知全局变量是不好的。它们可以在任何地方被修改,有时会强制函数以特定顺序调用,如果程序足够大或状态足够随机,可能无法调试。我们通常在编程的第一年就被教导不要使用它们,但许多人从未弄清楚什么时候应该使用。
首先,我们来看没有全局变量的代码。在这里,我们想看看在抛出异常(未显示)之前,“simple”函数被调用了多少次,这样我们就可以在函数的顶部设置一个断点。我们的计数器逻辑中有一个错误。如果问题不明显,你可能会感到沮丧。
let counter = { count:0 }
let obj = { counter:counter };
function simple(obj) {
console.log(++obj.counter.count)
if (obj.counter.count == 123) {
//让我们在异常之前设置一个断点
}
/* 剩下的函数逻辑有错误 */
}
function complex(obj) {
let temp = structuredClone(obj)
simple(temp)
simple(temp)
}
simple(obj)
simple(obj)
complex(obj)
simple(obj)
如果你运行这段代码,你会看到1 2 3 4 3被打印出来,而不是5。在我告诉你问题之前,让我们看看使用全局变量的版本,它运行正确。
let count = 0 //全局
let obj = { };
function simple(obj) {
console.log(++count)
if (count == 123) {
//让我们在异常之前设置一个断点
}
/* 剩下的函数逻辑有错误 */
}
function complex(obj) {
let temp = structuredClone(obj)
simple(temp)
simple(temp)
}
simple(obj)
simple(obj)
complex(obj)
simple(obj)
问题是structuredClone对我们的对象进行了深拷贝。当complex函数执行simple函数时,错误的计数器被修改了,导致我们看到重复的数字,并让我们不确定正确的断点时间。
现在我们可以看到避免全局变量仍然可能有问题,让我们定义什么是全局变量以及什么算是使用全局变量。
定义全局变量
我对全局变量的定义是任何不作为参数传递或在函数内定义的变量,以下是一些不同语言中的变量类型。
- 全局:这些是在类和函数外部定义的,并且对其他文件可见。我很少使用这些。
- 私有/静态:这些是全局变量,但在文件外部不可见。我主要使用这种。
- 线程局部:这些是全局变量(可能是静态的),在每个线程基础上有一个唯一的实例。当我处理线程时,我会避免所有类型的全局变量,而使用线程局部变量。
- 静态成员:一个你只有一个的变量。在某些语言中,你可以使用它来拥有一个只读的“空”、“最小”、“最大”和其他变量。我通常尽可能避免这种情况。
- 静态函数变量:在C语言中,在函数内部,你可以声明一个静态变量。你可以认为这是一个局部变量,但我不这么认为,因为它在堆上,并且可以在函数外部返回和修改。我绝对避免这种情况,除非作为一个我从不返回其地址的计数器。
你会认为以下情况是使用全局变量吗?
- 调用一个内部使用全局变量的函数。例如,在我们上面的代码中,我们可以调用inc()来递增计数器并返回值,而不是++count。
- 调用一个具有我们不容易看到的副作用的函数。例如,打印、写入文件、写入音频设备。
- 改变不改变程序状态的变量。例如,日志详细级别,或一个变量以启用某些数据的重新计算,以便我们可以检测逻辑是否错误。
问题是什么?
问题是数据访问。仅此而已。有一个与全局变量无关的术语:“远程操作。”如果一个程序保留了你传入的指针的副本,你可能会有对象在你不知道它们有任何关联的情况下影响另一个对象。人们喜欢克隆对象的一个原因是避免意外的突变。然而,在上面的示例代码中,克隆实际上导致了问题。
当你使用全局变量犯错误时,很容易归咎于它是全局的。人们通常不会通过克隆全局变量并恢复值来从错误中恢复。使用它们时也很容易养成坏习惯。初学者可能会懒惰,使用全局变量而不是改变十几个函数的签名,当他们覆盖他们需要的值时,这会让他们措手不及。
全局变量的使用场景
我尽量不写长文章。如果你想要代码示例,请告诉我,我可能会写一篇后续文章。以下是我喜欢使用全局变量的一些情况。
- 一个计数器,用于打印我进入特定函数的次数,我可能会用它来设置断点。
- 一个序列ID,用于轻松区分具有相同内容的两个对象(指针地址不可靠)
- 日志记录、自定义分配器、线程安全的数据库连接(或内存)池。
- 一个消息队列或仅追加的工作列表。例如,假设我有一个处理事件的函数,该事件可能会产生许多也需要处理的事件。入口函数可能会分配或清除当前工作列表和全局工作列表。第一个事件被添加到当前工作列表,然后函数在循环中处理当前工作列表。事件通过虚函数调用,这些函数将工作追加到全局工作列表。一旦当前工作列表没有更多工作,它被清除,然后与全局工作列表交换。然后重复,直到所有事件/工作都被处理。如果唯一操作是追加,并且只有入口函数进行交换(和清除),那么很难搞砸。
- 一个在入口函数中设置并在遍历树时使用的活动文件/缓冲区/节点。如果函数是递归的,你需要恢复原始的活动节点。你可以传递一个上下文对象给每个函数,而不是拥有一个活动的全局变量。我曾经有超过一百种类型(我是字面意思,它们都至少重载了两个虚函数),看到上下文参数传递到近千个调用点感觉非常冗长。大多数树只会将上下文传递给叶节点,这让人觉得更不必要。
通过一点封装,你可以使全局变量防错,毕竟,没有人抱怨打印或内存分配,除非它们太多。作为封装的例子,我们原始示例中的全局计数器可以是一个inc()函数。仅追加的工作列表可以通过一个函数或一个只允许追加操作的类型来访问。
