第六章:限制作用域的过度暴露
到目前为止,我们的重点是解释作用域和变量的工作原理。有了坚实的基础,我们的注意力将转移到更高层次的思考上:我们应用于整个程序的决策和模式。
首先,我们要看看如何以及为什么要使用不同级别的作用域(函数和块)来组织程序的变量,特别是要减少作用域的过度暴露。
最小暴露
函数定义自己的作用域是合情合理的。但为什么我们还需要块来创建作用域呢?
软件工程阐明了一门基本学科,通常应用于软件安全,称为「最小权限原则」(POLP)。1而适用于我们当前讨论的这一原则的变体通常被称为「最小暴露」(POLE)。
POLP 表达了对软件架构的一种防御姿态:系统组件应设计为以最小权限、最小访问、最小暴露的方式运行。如果每个组件都以最低限度的必要功能连接起来,那么从安全的角度来看,整个系统就会更强大,因为一个组件的受损或失效对系统其他组件的影响就会降到最低。
如果说 POLP 侧重于系统级的组件设计,那么 POLE 暴露变体则侧重于更低层次;我们将把它应用于作用域之间的交互。
在遵循 POLE 的过程中,我们要尽量减少哪些变量的暴露?很简单:在每个作用域中注册的变量。
你可以这样想:为什么不把程序中的所有变量都放在全局作用域中呢?这可能会让人立刻觉得这是个坏主意,但值得考虑一下为什么会这样。当程序的一部分使用的变量通过作用域暴露给程序的另一部分时,通常会产生三大危害:
-
命名碰撞:如果在程序的两个不同部分中使用了一个共同的、有用的变量/函数名,但标识符来自一个共享作用域(如全局作用域),那么就会发生命名碰撞,很可能会出现错误,因为一部分以另一部分意想不到的方式使用了该变量/函数。
例如,试想一下,如果您的所有循环都使用了一个全局
i
索引变量,然后一个函数中的一个循环在另一个函数的循环迭代期间运行,现在共享的i
变量得到了一个意想不到的值。 -
意想不到的行为:如果你将变量/函数的用途公开,而这些变量/函数的用途在程序中是私有的,那么其他开发人员就可以用你没有想到的方式使用它们,这可能会违反预期行为并导致错误。
例如,如果你的程序假定数组包含所有数字,但其他人的代码访问并修改了数组,使其包含布尔和字符串,那么你的代码可能会出现意想不到的错误行为。
更糟糕的是,暴露隐私细节会招致那些居心不良的人试图绕过你施加的限制,用你的那部分软件做一些不该做的事情。
-
意外的依赖:如果你不必要地暴露变量/函数,就会招致其他开发人员使用和依赖这些原本私人的部分。虽然这不会破坏你现在的程序,但会给将来的重构带来隐患,因为现在你不可能轻易地重构该变量或函数,而不会破坏软件中你无法控制的其他部分。2
例如,如果您的代码依赖于数字数组,而您后来决定最好使用其他数据结构而不是数组,那么您现在就必须承担调整软件其他受影响部分的责任。
POLE 应用于变量/函数的作用域时,主要是说,默认情况下只暴露必要的最低限度,其他一切尽可能保持私有。在尽可能小的深度嵌套作用域中声明变量,而不是把所有东西都放在全局(甚至外层函数)作用域中。
如果在设计软件时考虑到这一点,就有更大的机会避免(或至少减少)这三种危害。
思考一下:
function diff(x, y) {
if (x > y) {
let tmp = x;
x = y;
y = tmp;
}
return y - x;
}
diff(3, 7); // 4
diff(7, 5); // 2
在这个 diff(..)
函数中,我们要确保 y
大于或等于 x
,这样当我们减去 (y-x
),结果就是 0
或更大。如果 x
最初较大(结果将为负数!),我们将使用 tmp
变量交换 x
和 y
,以保持结果为正数。
在这个简单的例子中,tmp
是否在 if
代码块中或是否属于函数级似乎并不重要。它当然不应该是一个全局变量!然而,根据 POLE 原则,tmp
应尽可能隐藏在作用域中。因此,我们将 tmp
的作用域屏蔽(使用 let
)到 if
代码块中。
隐藏在普通(函数)作用域内
现在我们应该清楚为什么要尽可能将变量和函数声明隐藏在最低(嵌套最深)的作用域中了。但我们该如何做呢?
我们已经看到了 let
和 const
关键字,它们是块作用域声明符;稍后我们将更详细地讨论它们。但首先,如何在作用域中隐藏 var
或 function
声明呢?这可以通过在声明周围包装一个 function
作用域来轻松实现。
让我们举一个例子来说明 function
作用域的作用。
数学运算「阶乘」3(写为 “6!”)是一个给定整数与所有依次低至 1
的整数相乘—实际上,你可以停在 2
,因为与 1
相乘没有任何作用。换句话说,“6!” 等同于 “6 5!" ,同样等同于 "6 5 * 4!”,以此类推。由于所涉及数学的性质,一旦计算出任何给定整数的阶乘(如 “4!”),我们就不需要再做这项工作了,因为答案总是一样的。
因此,如果你天真地计算了 6
的阶乘,然后又想计算 7
的阶乘,你可能会不必要地重新计算从 2 到 6 的所有整数的阶乘。如果你愿意用内存来换取速度,你可以在计算时缓存每个整数的阶乘来解决这种浪费:
var cache = {
};
function factorial(x) {
if (x < 2) return 1;
if (!(x in cache)) {
cache[x] = x * factorial(x - 1);
}
return cache[x];
}
factorial(6);
// 720
cache;
// {
// "2": 2,
// "3": 6,
// "4": 24,
// "5": 120,
// "6": 720
// }
factorial(7);
// 5040
我们将所有计算出的阶乘存储在 cache
中,这样在多次调用 factorial(..)
时,之前的计算结果就会保留下来。但 cache
变量显然是 factorial(..)
工作原理中的一个私有细节,不应该暴露在外部作用域中,尤其是全局作用域。
注意: |
---|
这里的 factorial(..) 是递归的—从内部调用自身,但这只是为了代码的简洁;非递归的实现会产生与 cache 相同的作用域。 |
然而,要解决这个过度暴露的问题,并不像看起来那样简单,只需将 cache
变量隐藏在 factorial(..)
中即可。因为我们需要 cache
在多次调用后仍能存活,所以它必须位于该函数之外的作用域中。那么我们该怎么办呢?
为 cache
定义另一个中间作用域(介于外部/全局作用域和 factorial(..)
内部之间):
// 外层/全局作用域
function hideTheCache() {
// "中间作用域",在这里我们隐藏了 `cache`
var cache = {
};
return factorial;
// **********************
function factorial(x) {
// 内部作用域
if (x < 2) return 1;
if (!(x in cache)) {
cache[x] = x * factorial(x - 1);
}
return cache[x];
}
}
var factorial = hideTheCache();
factorial(6);
// 720
factorial(7);
// 5040
hideTheCache()
函数除了为 cache
创建一个作用域,以便在多次调用 factorial(..)
时持续存在之外,没有其他作用。但为了让 factorial(..)
能够访问 cache
,我们必须在同一个作用域中定义 factorial(..)
。然后,我们从 hideTheCache()
返回函数引用作为值,并将其存储在外作用域变量中,该变量也命名为 factorial
。现在,当我们调用 factorial(..)
(多次!)时,其持久的 cache
将保持隐藏,但只有 factorial(..)
可以访问!
好吧,但是…每次需要隐藏变量/函数时,都要定义(并命名!)hideTheCache(..)
函数作用域,这将会非常繁琐,尤其是我们可能希望通过为每次出现的函数命名一个唯一的名称来避免名称冲突。唉。
注意: |
---|
图解技术:缓存函数的计算输出4中提到,重复调用相同输入时优化性能在函数式编程(FP)领域非常常见,通常称为「记忆函数 (memoization)」;这种缓存依赖于闭包(参见第 7 章)。此外,还有内存使用方面的问题(在附录 B 的「内存问题」中讨论)。FP 库通常会为函数的内存化提供一个经过优化和审查的实用程序,在这里它将取代 hideTheCache(..) 。记忆函数超出了我们的讨论范围 |