作为前端开发,JavaScript 可谓是我们吃饭的家伙。
但我记得当我初学 JavaScript 的时候,总是搞不懂对形参的操作什么时候会影响到实参。
网络上关于这个问题众说纷纭,有说基本类型按值传递、复杂类型(对象、数组、函数等)按引用传递,更有人直接生造了一个词——按共享传递。
到底 JavaScript 的参数是按什么方式传递的呢?
考虑下面的例子:
function addOne(num) {
num += 1
}
let n = 1
addOne(n) // n = ?
乍一看可能会觉得 n === 2
,因为 num += 1
执行后 num
的值会变成 2
。
但实际结果却是 n === 1
,因为对形参的修改不会影响到实参的值。
按值传递?按引用传递?
再看另一个例子:
function addOne(obj) {
obj.num += 1
}
let o = { num: 1 }
addOne(o) // o.num = ?
如你所想,o.num
的值已经变成了 2
。由此,我们可以得出结论,JavaScript 的传参方式就是基本类型按值传递,复杂类型按引用传递。
真的是这样吗?再来看一个例子:
function addOne(obj) {
obj = { num: obj.num + 1 }
}
let o = { num: 1 }
addOne(o) // o.num = ?
出人意料的是,这次的 addOne
没能改掉 obj.num
的值,结果还是 1
。
这似乎和我们刚才得出的结论不太一样,如果对象是按引用传递的,那我们对形参 obj
的操作应该会反应到实参 o
上才对。
按共享传递
为了解释这个问题,有人提出了按共享传递。
按共享传递,是指在调用函数时,传递给函数的是实参的地址的拷贝(如果实参在栈中,则直接拷贝该值)。在函数内部对参数进行操作时,需要先拷贝的地址寻找到具体的值,再进行操作。如果该值在栈中,那么因为是直接拷贝的值,所以函数内部对参数进行操作不会对外部变量产生影响。如果原来拷贝的是原值在堆中的地址,那么需要先根据该地址找到堆中对应的位置,再进行操作。因为传递的是地址的拷贝所以函数内对值的操作对外部变量是可见的。
按指针传递
随着工作年限日久,对 JavaScript 的理解也愈发深刻。回过头来看这个问题,发现 JavaScript 的传参方式其实更像按指针传递。
想一下在 C 语言中我们如何修改实参?
void addOne(int* num) {
*num += 1;
}
int main() {
int n = 1;
addOne(&n); // n == 2
return 0;
}
由于 C 语言出生的年代尚未提出引用的概念,要修改实参,只能通过取地址运算符(&)和解引用运算符(*)直接操作实参的内存地址。但如果我们直接对形参赋值:
void addOne(int* num) {
num = num + 1;
}
int main() {
int n = 1;
addOne(&n); // n == 1
return 0;
}
会发现对实参 n
的影响消失了。因为我们让 num
这个指针指向了另一个地址,当然不会对实参所在的地址产生任何影响,也就不会修改到实参的值。
但在 JavaScript 中并没有解引用这个概念,为什么我们还是可以改变实参的值呢?
再来看一个 C 语言的例子。
typedef struct {
int num;
} Obj;
void addOne(Obj* obj) {
obj->num += 1;
}
int main() {
Obj o;
o.num = 1;
addOne(&o); // o.num == 2
return 0;
}
这个例子中我们用了 struct
结构体来模拟 JavaScript 中的对象。如果你看不懂也没关系,把它类比成 ES6 中的 class
定义即可。
我们发现通过箭头操作符 ->
可以修改实参,不过这个箭头可不是 ES6 里的那个箭头,它的作解引用,再取值。obj->num
就相当于 (*obj).num
。
通过 ->
操作符修改结构体的值,就跟我们在 JavaScript 里修改对象的属性一样自然。
类比一下,可以得出在传递参数时,JavaScript 是按指针传递的。而 JavaScript 中的.
操作符,就像的 C 语言中的 ->
,解引用再取值,就可以修改实参所在内存地址的值,从而影响到实参。由于 JavaScript 缺失了解引用操作符 *
,所以直接对形参赋值就相当修改指针指向的地址,不会影响实参的值。
令人意外的是,这个结论是普适的。也就是说它同时适用于基本类型和复杂类型。
function reassignPrimitive(num) {
num += 1 // 相当于 num = num + 1,对形参直接赋值不会影响到实参
}
function reassignComplex(obj) {
obj = { num: obj.num + 1 } // 对形参直接赋值不会影响到实参
}
function assignField(obj) {
// 相当于 obj.num = obj.num + 1,解引用后赋值,会改变实参
obj.num += 1
}
还有问题?
再考虑一种特殊情况:
function setField(num) {
num.a = 1
}
let n = 1
setField(n) // n.a = ?
答案是 n.a === undefined
。你也许会问,不是说修改形参的属性会对实参直接生效吗,为什么 n.a
的值还是 undefined
?
这倒不是因为 num
并非按指针传递,而是因为基本类型没有 prototype
,所有基本类型的方法都是通过其对应的复杂类型的实例来执行的。
例如:我们调用 num.toFixed(2)
,其中 num
是 Number
类型,相当于调用
let numObj = new Number(num)
numObj.toFixed(2)
numObj 在调用结束后被丢弃,所以即使我们修改了基本类型的属性,也不过是修改了其对应类型的实例的属性,这个实例在语句执行完之后就丢掉了。
let num = 1
num.a = 10
console.log(num.a) // undefined
就算我们给基本类型添加属性会立马打印,得到的结果也还是 undefined
。
这个是 JavaScript 的语言特性,与我们按指针传递的结论并不矛盾。
简而言之
如果你没有 C/C++/Golang 基础的话,可能对指针和地址的概念不太熟悉,很难理解到按指针传递的含义。你只需要记住,直接对形参赋值,不会影响到实参;对形参的属性赋值,会修改实参对应属性的值。
如今函数式编程大热,在日常编程中已经不推荐通过形参去修改实参的值(这种操作被称为副作用),而应该返回一个新的值(这种函数叫作纯函数)。但是了解 JavaScript 的传参原理,还是有助于分析遗留代码,以及用于在极端情况下提高性能。毕竟修改现有的对象比生成新对象快多了。