如何理解引用传递

63 阅读4分钟

面试题

先看一个经典面试题:把下面的listdata里的val值倒序排列,即把listdata里面的val值变为4 3 2 1。

let listdata = {
    val: 1,
    next: {
        val: 2,
        next: {
            val: 3,
            next: {
                val: 4,
                next: null
            }
        }
    }
}

本题考察的是如何反向创建链表,答案如下:

let result = {}

while(listdata) {
    // 赋值
    result.val = listdata.val
    // 为第一个next赋值null
    result.next = result.next ?? null
    // 将result.next指向result,这里会发生循环引用
    result.next = JSON.parse(JSON.stringify(result))
    // 改变循环
    listdata = listdata.next
}

console.log(JSON.stringify(result.next))
// {"val":4,"next":{"val":3,"next":{"val":2,"next":{"val":1,"next":null}}}}

为什么要用JSON.parse(JSON.stringify())深拷贝result数据呢?

这里涉及到引用传递的相关知识,不妨先去掉看看有什么问题。

let result = {}

while(listdata) {
    result.val = listdata.val
    result.next = result.next ?? null
    result.next = result
    listdata = listdata.next
}

console.log(JSON.stringify(result.next))

报错了,报错信息如下:

console.log(JSON.stringify(result.next))
                 ^

TypeError: Converting circular structure to JSON

这是因为result.next存在循环引用,所以JSON.stringify()才报错。

为什么会这样呢?

result.next 指向 result,这里明显是有问题的,存在循环引用。如何解决呢?我们只能将 result深拷贝一份出去,让 result.next 指向这个深拷贝对象就可以了。

如果想要深入了解,请继续往下看,下面我们来仔细讨论引用传递这个话题。

引用传递

接着上面的 listdata,看看下面的例子:

...

// 赋值完成之后呢,testdata 和 listdata 都共享一个引用地址
let testdata = listdata
// 给listdata重新赋值
listdata = {}

console.log(JSON.stringify(testdata))
// {"val":1,"next":{"val":2,"next":{"val":3,"next":{"val":4,"next":null}}}}
console.log(listdata)
// {}

显然,通过上述示例,我们可以看到给 listdata 重新赋值后,并不会改变原地址的值。

为什么呢?

listdata 只是一个标识符,之前的值为一个引用数据类型的地址,比如:listdata: 0x00。现在只是给它赋值了一个新的地址listdata: 0x01,该地址存储的是 {}

那如果修改 listdata.val 呢,会发生什么?

...

let testdata = listdata

listdata.val = 'test'

console.log(JSON.stringify(testdata))
// {"val":"test","next":{"val":2,"next":{"val":3,"next":{"val":4,"next":null}}}}

可以看到 testdata 的值发生了变化,即原地址的值变了。

这里发生了什么呢?

其实我们可以将 . 看做是一个操作符,listdata.val 看成一个表达式。执行listdata.val = 'test'时,会从左到右计算表达式的值。listdata.val的计算结果:取 listdata 地址里的 val 属性;'test'(也是一个表达式)的计算结果为:'test',最后才是赋值操作。所以呢,该语句的执行结果就是将 listdata 地址里的 val 属性赋值为 'test',即修改了原地址里的值。

那如果是基本数据类型呢?

let a = 3 // js引擎开辟了一块内存 00x0,存储3,标识符a指向00x0
let b = a // b也指向00x0
a = 2 // 重新开辟了一块内存 00x1,存储2,标识符a指向00x1
console.log(b) // 所以呢b指向00x0,为3

我们常说的 Immutable(不可更改)指的就是 00x0, 00x1 里的原始值 3 2 无法更改。GC 也是通过引用实现的标记-清除算法,来清理内存。

形成 or 实参

示例1:

var a = 1;
// 函数声明时的参数x,为形参
function foo(x) {
    x = 2;
}
// 函数调用时传递的参数a,为实参
foo(a);
console.log(a); // 仍为1, 未受x = 2赋值所影响

示例2:

var obj = {x : 1};
function foo(o) {
    // 形参o和实参obj共享一个地址,下述语句,修改了该地址的值
    o.x = 3;
}
foo(obj);
console.log(obj.x); // 3, 被修改了!

通过上述两个示例可以看到,实参为对象时,会修改实参的值。其实,无论是基本类型,还是引用类型在进行参数传递时,传递的都是实参的副本(浅拷贝对象)。直接修改副本的值,如示例1、示例3,是不会影响实参的;只有通过操作符.时(o.x),才会影响原地址的值。因为副本和实参都指向同一个地址。

示例3:

var obj = {x : 1};

function foo(o) {
    // 形参o是实参obj的副本,修改o不会影响obj
    o = 3;
}
foo(obj);
console.log(obj.x); // 1

总结

在使用引用数据类型进行赋值或传参时,如非必要,不要影响实参的值。可传递实参的深拷贝对象:

  • object类型深拷贝方法:JSON.parse(JSON.stringify())
  • Iterator(如: Array, Map, Set)可使用扩展运算符:...