深入理解JavaScript系列(19):求值策略(Evaluation strategy)详解

作者:闭包旅人时间:2026-10-29 06:40:34浏览:187

本文深入解析ECMAScript中的参数传递机制,澄清了关于“按值传递”与“按引用传递”的常见误解。文章指出JavaScript实际采用“按共享传递”(Call by sharing)策略,即传递对象引用的副本。通过对比按值、按引用及按共享传递的行为差异,结合伪代码与JS实例,详细说明了修改对象属性与重新赋值对原对象的不同影响,帮助开发者准确理解变量绑定与内存地址的关系。

引言

本章导读

本章将详细讲解在ECMAScript中向函数传递参数时所采用的策略。

在计算机科学领域,这种机制通常被称为“求值策略”(evaluation strategy)。尽管对于该术语的翻译存在争议——有人译为“求值策略”,也有人译为“赋值策略”——但结合下文内容,将其理解为“求值策略”更符合大众认知,因此标题沿用此说法。它主要规定了编程语言中表达式求值或计算的规则,而函数传参则是这一规则下的一个特例。

原文链接:http://dmitrysoshnikov.com/ecmascript/chapter-8-evaluation-strategy/ 撰写本文的初衷源于论坛上用户对于准确解释传参策略的强烈需求。本文提供了相应的定义,旨在帮助读者深入理解这一概念。

许多程序员坚信,在JavaScript(以及其他一些语言)中,对象是按引用传递参数,而原始值类型则是按值传递。此外,许多文章都将此视为“既定事实”。然而,真正理解这一术语含义的人有多少?这些观点又有多少是完全正确的?本篇将逐一剖析这些问题。

一般理论

一般理论

在赋值理论中,通常存在两种赋值策略:严格(strict)与非严格(non-strict)。严格策略意味着参数在进入程序之前已经经过计算;非严格策略则意味着参数的计算是根据具体需求延迟进行的(即延迟计算)。

接下来,我们将关注基本的函数传参策略,这对理解ECMAScript至关重要。首先需要注意的是,ECMAScript(以及C、Java、Python和Ruby等其他语言)均使用了严格的参数传递策略。

此外,参数计算的顺序也非常重要。在ECMAScript中,计算顺序是从左到右,而其他语言实现从左到右或从右到左的顺序也是可行的。

严格的传参策略又细分为几种子策略,本章将详细讨论其中最重要的几种。

下面讨论的策略并非全部应用于ECMAScript,因此在阐述这些策略的具体行为时,我们将使用伪代码进行演示。

按值传递

按值传递

按值传递(Call by value)是许多开发人员所熟悉的机制。在此策略下,传递给函数的是调用者传递的对象值的拷贝(copy of value)。函数内部对参数值的修改不会影响外部的对象(即函数外该参数的值)。通常,这会重新分配新的内存空间(无论是栈内存还是动态内存分配,具体实现方式不在此讨论),新内存块的值是外部对象的拷贝,并在函数内部使用。

代码如下:

bar = 10
 
procedure foo(barArg):
barArg = 20;
end
 
foo(bar)
 
// foo内部改变值不会影响内部的bar的值
print(bar) // 10

然而,如果函数参数不是原始值而是复杂的结构对象,这将带来巨大的性能问题。例如在C++中,将结构体作为值传入函数时,需要进行完整的拷贝。

让我们给出一个通用示例,使用上述赋值策略进行检验。假设有一个函数接受两个参数:第一个参数是对象的值,第二个参数是一个布尔型标记,用于指示是完全修改传入的对象(即重新赋值)还是仅修改该对象的部分属性。

代码如下:

// 注:以下都是伪代码,不是JS实现
bar = {
x: 10,
y: 20
}
 
procedure foo(barArg, isFullChange):
 
if isFullChange:
barArg = {z: 1, q: 2}
exit
end
 
barArg.x = 100
barArg.y = 200
 
end
 
foo(bar)
 
// 按值传递,外部的对象不被改变
print(bar) // {x: 10, y: 20}
 
// 完全改变对象(赋新值)
foo(bar, true)
 
//也没有改变
print(bar) // {x: 10, y: 20}, 而不是{z: 1, q: 2}

按引用传递

按引用传递

另一种众所周知的策略是按引用传递(Call by reference)。在此策略下,接收的不是值的拷贝,而是对象的隐式引用,即对象在外部直接引用的地址。函数内部对参数的任何修改都会影响函数外部该对象的值,因为两者引用的是同一个对象。也就是说,此时参数相当于外部对象的一个别名。

伪代码:

代码如下:

procedure foo(barArg, isFullChange):
 
if isFullChange:
barArg = {z: 1, q: 2}
exit
end
 
barArg.x = 100
barArg.y = 200
 
end
 
// 使用和上例相同的对象
bar = {
x: 10,
y: 20
}
 
// 按引用调用的结果如下:
foo(bar)
 
// 对象的属性值已经被改变了
print(bar) // {x: 100, y: 200}
 
// 重新赋新值也影响到了该对象
foo(bar, true)
 
// 此刻该对象已经是一个新对象了
print(bar) // {z: 1, q: 2}

该策略可以更有效地传递复杂对象,例如带有大量属性的大结构对象。

按共享传递

按共享传递(Call by sharing) 上述两种策略广为人知,但这里要讨论的策略可能大家不太了解(这更多是一个学术上的概念)。然而,我们很快就会发现,这正是ECMAScript中参数传递策略的关键所在。

该策略还有一些别名:“按对象传递”或“按对象共享传递”。

该策略由Barbara Liskov于1974年为CLU编程语言提出。

该策略的核心要点是:函数接收的是对象引用的拷贝(副本),该引用拷贝与形参及其值相关联。

这里出现的引用,不能称之为“按引用传递”,因为函数接收的参数并非直接的对象别名,而是该引用地址的拷贝。

最重要的区别在于:函数内部给参数重新赋新值不会影响外部对象(这与按引用传递的情况不同),但由于该参数是一个地址拷贝,因此内外访问的是同一个对象(例如,外部对象并非像按值传递那样被完全拷贝)。改变该参数对象的属性值将会影响外部对象。

代码如下:

procedure foo(barArg, isFullChange):
 
if isFullChange:
barArg = {z: 1, q: 2}
exit
end
 
barArg.x = 100
barArg.y = 200
 
end
//还是使用这个对象结构
bar = {
 x: 10,
 y: 20
}
 
// 按贡献传递会影响对象 
foo(bar)
 
// 对象的属性被修改了
print(bar) // {x: 100, y: 200}
 
// 重新赋值没有起作用
foo(bar, true)
 
// 依然是上面的值
print(bar) // {x: 100, y: 200}

这种处理方式的假设前提是大多数语言中使用的对象,而非原始值。

按共享传递是按值传递的特例

按共享传递策略在许多语言中都被使用,包括Java、ECMAScript、Python、Ruby、Visual Basic等。此外,Python社区已经广泛使用这一术语,其他语言也可沿用,因为其他名称往往会引起混淆。在大多数情况下,例如在Java、ECMAScript或Visual Basic中,这一策略也被称为按值传递——意味着传递的是特殊值——引用拷贝(副本)。

一方面,确实如此——传递给函数内部的参数仅仅是绑定值(引用地址)的一个名称,不会影响外部对象。

另一方面,如果不深入研究,这些术语确实容易被误解,因为许多论坛都在讨论如何将对象传递给JavaScript函数。

从一般理论来看,确实有按值传递的说法:但此时这个“值”就是我们所说的地址拷贝(副本),因此并未破坏规则。

在Ruby中,这一策略被称为按引用传递。再说一次:它不是按照大结构的拷贝来传递(即不是按值传递),另一方面,我们没有处理原始对象的引用,并且不能修改它;因此,这种跨术语的概念可能会造成更大的混乱。

理论中并没有像按值传递的特殊case那样来面试按引用传递的特殊case。

但依然有必要了解这些策略在上述提到的技术(Java、ECMAScript、Python、Ruby等)中,实际上——他们使用的策略就是按共享传递。

按共享与指针

对于C/C++,这一策略在思想上与按指针值传递是一样的,但有一个重要区别——该策略可以取消引用指针以及完全改变对象。但在一般情况下,分配一个值(地址)指针到新的内存块(即之前引用的内存块保持不变);通过指针改变对象属性的话会影响阿东外部对象。

因此,和指针类别,我们可以明显看到,这是按地址值传递。 在这种情况下,按共享传递只是“语法糖”,像指针赋值行为一样(但不能取消引用),或者像引用一样修改属性(不需要取消引用操作),有时候,它可以被命名为“安全指针”。

然而,С/С+ +如果在没有明显指针的解引用的情况下,引用对象属性的时候,还具有特殊的语法糖:

代码如下:

obj->x instead of (*obj).x

和C++关系最为紧密的这种意识形态可以从“智能指针”的实现中看到,例如,在 boost :: shared_ptr里,重载了赋值操作符以及拷贝构造函数,而且还使用了对象的引用计数器,通过GC删除对象。 这种数据类型,甚至有类似的名字- 共享_ptr。

ECMAScript实现

ECMAScript实现

现在我们已经知道了ECMAScript中将对象作为参数传递的策略——按共享传递:修改参数的属性将会影响到外部,而重新赋值将不会影响到外部对象。但是,正如我们上面提到的,其中的ECMAScript开发人员一般都称之为是:按值传递,只不过该值是引用地址的拷贝。

JavaScript发明人布伦丹·艾希也写到了:传递的是引用的拷贝(地址副本)。所以论坛里大家曾说的按值传递,在这种解释下,也是对的。

更确切地说,这种行为可以理解为简单的赋值,我们可以看到,内部是完全不同的对象,只不过引用的是相同的值——也就是地址副本。

ECMAScript代码:

代码如下:

var foo = {x: 10, y: 20};
var bar = foo;
 
alert(bar === foo); // true
 
bar.x = 100;
bar.y = 200;
 
alert([foo.x, foo.y]); // [100, 200]

即两个标识符(名称绑定)绑定到内存中的同一个对象, 共享这个对象:

foo value: addr(0xFF) => {x: 100, y: 200} (address 0xFF) <= bar value: addr(0xFF) 而重新赋值分配,绑定是新的对象标识符(新地址),而不影响已经先前绑定的对象 :

代码如下:

bar = {z: 1, q: 2};
 
alert([foo.x, foo.y]); // [100, 200] – 没改变
alert([bar.z, bar.q]); // [1, 2] – 但现在引用的是新对象

即现在foo和 bar,有不同的值和不同的地址:


代码如下:

foo value: addr(0xFF) => {x: 100, y: 200} (address 0xFF)
bar value: addr(0xFA) => {z: 1, q: 2} (address 0xFA)

再强调一下,这里所说对象的值是地址(address),而不是对象结构本身,将变量赋值给另外一个变量——是赋值值的引用。因此两个变量引用的是同一个内存地址。下一个赋值却是新地址,是解析与旧对象的地址绑定,然后绑定到新对象的地址上,这就是和按引用传递的最重要区别。

此外,如果只考虑ECMA-262标准所提供的抽象层次,我们在算法里看到的只有“值”这个概念,实现传递的“值”(可以是原始值,也可以是对象),但是按照我们上面的定义,也可以完全称之为“按值传递”,因为引用地址也是值。

然而,为了避免误解(为什么外部对象的属性可以在函数内部改变),这里依然需要考虑实现层面的细节——我们看到的按共享传递,或者换句话讲——按安全指针传递,而安全指针不可能去解除引用和改变对象的,但可以去修改该对象的属性值。

术语版本

术语版本

让我们来定义ECMAScript中该策略的术语版本。

可以称之为“按值传递”——这里所说的值是一个特殊的case,也就是该值是地址副本(address copy)。从这个层面我们可以说:ECMAScript中除了异常之外的对象都是按值传递的,这实际上是ECMAScript抽象的层面。

或针对这种情况下,专门称之为“按共享传递”,通过这个正好可以看到传统的按值传递和按引用传递的区别,这种情况,可以分成2个种情况:1:原始值按值传递;2:对象按共享传递。

“通过引用类型将对象到函数”这句话和ECMAScript无关,而且它是错误的。

结论

结论

我希望这篇文章有助于宏观上了解更多细节,以及在ECMAScript中的实现。一如既往,如果有任何问题,欢迎讨论。

作者最新文章