第二章:值 - 值与引用
值与引用
在其他许多语言中,根据你使用的语法,值可以通过值拷贝,也可以通过引用拷贝来赋予/传递。
比如,在 C++ 中如果你想要把一个 number
变量传递进一个函数,并使这个变量的值被更新,你可以用 int& myNum
这样的东西来声明函数参数,当你传入一个变量 x
时,myNum
将是一个 指向 x
的引用;引用就像一个特殊形式的指针,你得到的是一个指向另一个变量的指针(像一个 别名(alias)) 。如果你没有声明一个引用参数,被传入的值将 总是 被拷贝的,就算它是一个复杂的对象。
在 JavaScript 中,没有指针,并且引用的工作方式有一点儿不同。你不能拥有一个从一个 JS 变量到另一个 JS 变量的引用。这是完全不可能的。
JS 中的引用指向一个(共享的) 值,所以如果你有十个不同的引用,它们都总是同一个共享值的不同引用;它们没有一个是另一个的引用/指针。
另外,在 JavaScript 中,没有语法上的提示可以控制值和引用的赋值/传递。取而代之的是,值的 类型 用来 唯一 控制值是通过值拷贝,还是引用拷贝来赋予。
让我们来展示一下:
var a = 2;
var b = a; // `b` 总是 `a` 中的值的拷贝
b++;
a; // 2
b; // 3
var c = [1,2,3];
var d = c; // `d` 是共享值 `[1,2,3]` 的引用
d.push( 4 );
c; // [1,2,3,4]
d; // [1,2,3,4]
简单值(也叫基本标量) 总是 通过值拷贝来赋予/传递:null
、undefined
、string
、number
、 boolean
、以及 ES6 的 symbol
。
复合值 —— object
(包括 array
,和所有的对象包装器 —— 见第三章)和 function
—— 总是 在赋值或传递时创建一个引用的拷贝。
在上面的代码段中,因为 2
是一个基本标量,a
持有一个这个值的初始拷贝,而 b
被赋予了这个值的另一个拷贝。当改变 b
时,你根本没有在改变 a
中的值。
但 c
和 d
两个都 是同一个共享的值 [1,2,3]
的分离的引用。重要的是,c
和 d
对值 [1,2,3]
的“拥有”程度上是一样的 —— 它们只是同一个值的对等引用。所以,不管使用哪一个引用去修改(.push(4)
)实际上共享的 array
值本身,影响的仅仅是这一个共享值,而且这两个引用将会指向新修改的值 [1,2,3,4]
。
因为引用指向的是值本身而不是变量,你不能使用一个引用来改变另一个引用所指向的值:
var a = [1,2,3];
var b = a;
a; // [1,2,3]
b; // [1,2,3]
// 稍后
b = [4,5,6];
a; // [1,2,3]
b; // [4,5,6]
当我们做赋值操作 b = [4,5,6]
时,我们做的事情绝对不会对 a
所指向的 位置([1,2,3]
)造成任何影响。如果那可能的话,b
就会是 a
的指针而不是这个 array
的引用 —— 但是这样的能力在 JS 中是不存在的!
这样的困惑最常见于函数参数:
function foo(x) {
x.push( 4 );
x; // [1,2,3,4]
// 稍后
x = [4,5,6];
x.push( 7 );
x; // [4,5,6,7]
}
var a = [1,2,3];
foo( a );
a; // [1,2,3,4] 不是 [4,5,6,7]
当我们传入参数 a
时,它将一份 a
引用的拷贝赋值给 x
。x
和 a
是指向相同的 [1,2,3]
的不同引用。现在,在函数内部,我们可以使用这个引用来改变值本身(push(4)
)。但是当我们进行赋值操作 x = [4,5,6]
时,不可能影响原来的引用 a
所指向的东西 —— 它仍然指向(已经被修改了的)值 [1,2,3,4]
。
没有办法可以使用 x
引用来改变 a
指向哪里。我们只能修改 a
和 x
共通指向的那个共享值的内容。
要想改变 a
来使它拥有内容为 [4,5,6,7]
的值,你不能创建一个新的 array
并赋值 —— 你必须修改现存的 array
值:
function foo(x) {
x.push( 4 );
x; // [1,2,3,4]
// 稍后
x.length = 0; // 原地清空既存的数组
x.push( 4, 5, 6, 7 );
x; // [4,5,6,7]
}
var a = [1,2,3];
foo( a );
a; // [4,5,6,7] 不是 [1,2,3,4]
正如你看到的,x.length = 0
和 x.push(4,5,6,7)
没有创建一个新的 array
,但是修改了现存的共享 array
。所以理所当然地,a
引用了新的内容 [4,5,6,7]
。
记住:你不能直接控制/覆盖值拷贝和引用拷贝的行为 —— 这些语义是完全由当前值的类型来控制的。
为了实质上地通过值拷贝传递一个复合值(比如一个 array
),你需要手动制造一个它的拷贝,使被传递的引用不指向原来的值。比如:
foo( a.slice() );
不带参数的 slice(..)
方法默认地为这个 array
制造一个全新的(浅)拷贝。所以,我们传入的引用仅指向拷贝的 array
,这样 foo(..)
不会影响 a
的内容。
反之 —— 传递一个基本标量值,使它的值的变化可见,就像引用那样 —— 你不得不将这个值包装在另一个可以通过引用拷贝来传递的复合值中(object
、array
等等):
function foo(wrapper) {
wrapper.a = 42;
}
var obj = {
a: 2
};
foo( obj );
obj.a; // 42
这里,obj
作为基本标量属性 a
的包装。当传递给 foo(..)
时,一个 obj
引用的拷贝被传入并设置给 wrapper
参数。我们现在可以使用 wrapper
引用来访问这个共享的对象,并更新它的值。在函数完成时,obj.a
将被更新为值 42
。
你可能会遇到这样的情况,如果你想要传入一个像 2
这样的基本标量值的引用,你可以将这个值包装在它的 Number
对象包装器中(见第三章)。
这个 Number
对象的引用的拷贝 将 会被传递给函数是事实,但不幸的是,和你可能期望的不同,拥有一个共享独享的引用不会给你修改这个共享的基本值的能力:
function foo(x) {
x = x + 1;
x; // 3
}
var a = 2;
var b = new Number( a ); // 或等价的 `Object(a)`
foo( b );
console.log( b ); // 2, 不是 3
这里的问题是,底层的基本标量值是 不可变的(String
和 Boolean
也一样)。如果一个 Number
对象持有一个基本标量值 2
,那么这个 Number
对象就永远不能再持有另一个值;你只能用一个不同的值创建一个全新的 Number
对象。
当 x
用于表达式 x + 1
时,底层的基本标量值 2
被自动地从 Number
对象中开箱(抽出),所以 x = x + 1
这一行很微妙地将 x
从一个共享的 Number
对象的引用,改变为仅持有加法操作 2 + 1
的结果 3
的基本标量值。因此,外面的 b
仍然引用原来的未被改变/不可变的,持有 2
的 Number
对象。
你 可以 在 Number
对象上添加属性(只是不要改变它内部的基本值),所以你可间接地通过这些额外的属性交换信息。
不过,这可不太常见;对大多数开发者来说这可能不是一个好的做法。
与其这样使用 Number
包装器对象,使用早先的代码段中那样的手动对象包装器(obj
)要好得多。这不是说像 Number
这样包装好的对象包装器没有用处 —— 而是说在大多数情况下,你可能应该优先使用基本标量值的形式。
引用十分强大,但是有时候它们碍你的事儿,而有时你会在它们不存在时需要它们。你唯一可以用来控制引用与值拷贝的东西是值本身的类型,所以你必须通过你选用的值的类型来间接地影响赋值/传递行为。