前言
我这两天在做鸿蒙相关的团队开发。给大伙补基础功课的时候,正好和周围同学聊到了值类型、引用类型的相关话题。
大伙虽然都把这一块的内容想得很简单,感觉应该是初学者才会搞不懂的内容,但实际上,稍稍聊细一点,大伙的发言就开始经不起推敲了。
你别不信,我现在举出几个问题,看看你能不能回答出来(当然,这些都是很经典的面试八股,请务必掌握):
- 值类型和引用类型的区别是什么?
- 在绝大部分编程语言当中,引用类型的参数传递真的是引用传递吗?
- 值类型数据真的一定存储于栈上,引用类型数据真的一定存储于堆上吗?
故这次编写这篇文章,简单聊聊我对于值、引用、参数传递和闭包的理解,大伙可以借此机会查缺补漏,也欢迎读完后与我深入探讨并交流指正。
值类型和引用类型
虽说这两个概念已经被写技术博客的大伙嚼得不能再烂了,但对于这篇文章的读者朋友们来说,大概还是需要先把这两个概念搞清楚,才能更好地理解后面的内容。
按照惯例,既然要讲值类型和引用类型,这里就必须先把C、C++这种语言踢出去,后面会说原因。
栈和堆
对于绝大部分的编程语言来说,在计算机的内存中,数据有两种主要的存储区域:栈和堆。 既然叫栈和堆,那必定和数据结构中我们学到的知识类似。
栈是一种先进后出的数据结构,它的存储方式是连续的,数据的存储和释放都是由编译器自动完成的。当有一个函数被调用时,函数的参数、返回地址、局部变量等数据都会被存储在栈上,当函数调用结束时,这些数据会被自动释放。
栈的大小是有限且默认受编译器或引擎制约的,比如说,在JS中,浏览器一般情况下是1MB到2MB,Nodejs下是984KB,在Java、C#中一般情况下是1MB,当然这些都是可以通过修改配置进行调整的。
堆是一种无序的数据结构,它的存储方式是离散的,在编程语言没有提供垃圾回收机制(GC)的情况下,数据的存储和释放理论上都由程序员手动完成(说的就是你,C++)。
堆的大小理论上是无限的,不过操作系统和部分编程语言还是会对其进行限制,这里例子太多,排列组合起来挺麻烦,就不一一列举了。
值类型
值类型又常常被叫做基本类型,它的数据大部分情况被存储在栈上。在各式各样的编程语言当中,我们常常说的值类型有:int
、float
、double
、number
、char
、bool
、enum
、struct
等。
当我们把一个值类型的变量给另一个变量赋值或参数传递时,实际上是把这个值复制了一份,然后存储在另一个变量中。比如下面这段JS代码:
let a = 1
let b = a
a = 2
console.log(b) // 输出1
引用类型
引用类型变量大部分情况下是在栈上存储指向堆中数据的引用(内存地址),换句话说真实的数据大部分情况被存储在堆上。在各式各样的编程语言当中,我们常常说的引用类型有:object
、array
、function
、class
、interface
等。(是的,函数也是一种引用类型)
特别值得注意的是,除了在JavaScript这种编程语言当中,string
是值类型,其他编程语言当中,string
都是引用类型,只是被进行了特殊处理,使得在传递时依旧是通过赋予一个副本的方式进行了传递。
此外,在Java、Python等部分语言中,enum
其实是引用类型,因此可以利用机制实现单例模式,但这并非是这篇文章需要讨论的内容。
顺带一提,在Java等语言当中,对于值类型会有对应的包装类型,比如int
对应Integer
,char
对应Character
等,这些包装类型都是引用类型。
引用类型对象的赋值和传递在不同编程语言的实现当中有所不同。(记住这点,后面要考) 对于JAVA、C#等语言来说,当我们把一个引用类型的变量赋值给另一个变量时,实际上是把这个引用复制了一份,传递一个引用的副本,然后存储在另一个变量中。换言之,两个变量指向的是同一个数据。比如下面这段JS代码:
let a = { value: 1 }
let b = a
a.value = 2
console.log(b.value) // 输出2
值类型和引用类型的区别
简单总结下前文,不难得出结论:
- 存储位置不同:一般情况下,值类型的数据存储在栈上,引用类型的数据存储在堆上。
- 赋值和传递方式不同:值类型的赋值和传递是直接复制自身的数据,引用类型的传递常常是复制自身指向的真实数据的引用。
参数传递
回顾值类型和引用类型的时候,我们一直在聊赋值操作的相关话题,故这里很难不继续深入讨论下参数传递的问题。
先抛出一个问题:大伙经常在文章中看到引用类型的传递被称为“引用传递”,但实际上真的是这样吗?
我们不妨针对编程语言中的参数传递方式进行一一分析。
值传递(Pass-by-Value)
值传递是指在调用函数时,实参将自己的值复制一份传递给形参,形参接收到的是实参的一个副本,故在函数内部对形参的修改不会影响到实参。
顾名思义,值传递的方式适用于值类型的数据,上面已经提过了,这里就不再赘述。 不妨简单下个定义:值类型的参数传递是值传递。
还是举一个JS的例子:
function changeValue(a) {
a = 2
}
let b = 1
changeValue(b)
console.log(b) // 输出1
指针传递(Pass-by-Pointer)
指针传递目前只在C、C++、c#(unsafe模式下支持指针)一类支持指针的语言中存在,和其他语言中引用类型的参数传递性质是类似的,只是传递的是变量的地址。
可以通过指针传递修改对应地址的值和对象内部属性,而修改指针时,也只是指向另一个地址,总体行为其实就是其他语言中引用类型的参数传递那一套。
不多赘述,简单举个C++的例子大伙就懂了:
void changeValue(int *a) {
*a = 2; // 通过*取得了a指向的地址的值
int b = 3;
a = &b; // 将a重新指向了b的地址
cout << *a << endl; // 输出3
}
int c = 1;
changeValue(&c); // 通过&取得了c的地址
cout << c << endl; // 输出2
引用传递(Pass-by-Reference)
引用传递是指在调用函数时,实参的引用被直接传递给形参,形参接收到的是实参的引用,故在函数内部对形参的引用的修改会影响到实参。
抓住关键词,这里传递的不是引用的副本,而是直接传递了引用本身。不妨举个C++的例子,方便大家理解:
void changeValue(int &a) {
a = 2;
}
int b = 1;
changeValue(b);
cout << b << endl; // 输出2
这个时候你肯定会发问:这个和我们在其他语言中所熟知的引用传递不一样啊?你这直接改变了原变量本身啊,而且你传递的分明是值类型的数据。
那么我不妨反问,我们平常谈到的“引用传递”真的是引用传递吗?
反正你先别急,我这里先卖个关子。
言归正传,上述C++代码中的这种传递方式在绝大部分编程语言当中是不存在的。
在C++当中,引用的定义和其他语言不太一致。引用实际上其实是一个变量的别名,它允许你通过另一个名字来访问同一个变量。换句话说,在C++当中甚至没有严格的引用类型变量的概念,引用类型变量实际上是一个指针类型变量。
虽然说绝大部分编程语言都不支持这样的传递方式,不过C#中可以通过ref
和out
关键字来实现引用传递,比如:
void Swap(ref int a, ref int b) {
var temp = a;
a = b;
b = temp;
}
var x = 1;
var y = 2;
Swap(ref x, ref y);
Console.WriteLine($"x = {x}, y = {y}"); // 输出:x = 2, y = 1
共享传递(Pass-by-Sharing)
值得一提的是,其实在很多文章里把共享传递视为值传递,这种说法也是正确的。
共享传递是指在调用函数时,实参的引用被复制一份传递给形参,形参接收到的是实参的引用的副本,故在函数内部对形参字段的修改会影响到实参,但是如果对形参进行重新赋值,那么就不会影响到实参。
同样抓住关键词,这里传递的是引用的副本,而不是引用本身。
换句话说,共享传递本质上是一种特殊的值传递,如果值传递传的是值的副本,那么共享传递就是传递对象引用的副本。
因此,真相大白,绝大部分编程语言的引用类型的参数传递实际上是共享传递,根本不是什么“引用传递”。或者说,像Java这样的语言,在使用中只存在值传递的概念。
那些最早把其他编程语言的引用类型的传递称为“引用传递”而非“共享传递”的文章和教程制作者,八成是学C++学的,只是因为在其他语言中听到了“引用”这个词,就直接把C++的概念套用到了其他语言中,以讹传讹,最终混淆了二者的概念。
不论如何,最后还是用JS举一下正例和反例吧:
function changeValue(obj) {
obj.value = 2
}
let a = { value: 1 }
changeValue(a)
console.log(a.value) // 输出2
function changeValue(obj) {
obj = { value: 2 }
}
let a = { value: 1 }
changeValue(a)
console.log(a.value) // 输出1
闭包
闭包的定义
废话不说,先对闭包做一个简单的定义:闭包是指能够访问自由变量的函数。
当然,上面这句话根本不是说给人听的,所以做一个不太简单的定义:访问了函数作用域外部变量的函数,就是闭包。
然后你就会发现我还是说了一坨废话。
用代码解决问题吧:
// 写一个经典的节流函数实现
// 节流,顾名思义,就是控制流量,让函数在一定时间内只执行一次
// 参数1是要执行的函数,参数2是节流时间,返回一个新的函数
function throttle(fn, delay) {
let canExec = true // 设置一个布尔值,用来控制是否可以执行
return function() {
if (!canExec) return // 如果不能执行,直接返回
canExec = false // 否则,将canExec设置为false
// 设置一个定时器,delay时间后执行函数并将canExec设置为true
setTimeout(() => {
fn.apply(this, arguments)
canExec = true
}, delay)
}
}
不难看出,上述代码返回的函数访问了canExec
这个变量,而canExec
这个变量是在返回的函数外部定义的,那么这个变量就被称为自由变量,而返回的函数就是一个闭包。
说到这,很多朋友可能会问:我们上面说过,函数内部的变量在函数执行完毕后会被释放,那么闭包是如何访问到自由变量的呢?这里的canExec
在函数执行后到底还存在吗,如果存在,又是如何存在的呢?
以至于引申出一个非常经典的问题:值类型的数据真的一定存储于栈上,引用类型的数据真的一定存储于堆上吗?
作用域链
这里就要引出一个新的概念:作用域链。
作用域链是指在嵌套函数中,内部函数可以访问其外部函数的变量。这是因为每个函数都有一个对其外部词法环境的引用,这个引用形成了作用域链。
翻译成人话,就是在函数执行时,函数内部和函数外部的变量相关联的一个用来查找变量的值的链条就叫做作用域链。
在函数执行时,会先在自身的作用域中查找变量,如果没有找到,就会沿着作用域链向上查找,直到找到为止。
在绝大部分支持闭包的编程语言中,作用域链是在函数定义时就已经确定的,而不是在函数执行时确定的,这种作用域链的查找方式被称为词法作用域。
当闭包被创建的时候,在外部的自由变量会被捕获并保存在闭包的作用域链中,这样即使外部函数执行完毕,这些自由变量也不会被释放,直到闭包被销毁。
闭包的存储
这个时候我们结合一下栈和堆的定义,不难发现,如果把捕获到的自由变量存储在栈上,那么在函数执行完毕后,这些自由变量是不是就会被当场释放掉?
不同语言的闭包实现方式可能不同,但理论都是大同小异的。因此,我们可以非常笃定地定义,闭包中的自由变量是存储在堆上的,这样即使函数执行完毕,这些自由变量也不会被释放,直到闭包被销毁。
这下一开始提到的问题也被解决了:值类型数据在闭包情况下必定存储在堆上,网上那些“值类型数据必定存储在栈上”的说法都是错的。
总结
老实说这篇文章字数并不多,只是简单总结了值类型、引用类型、参数传递和闭包这几个概念。虽然大体思想一致,但其背后的原理和实现细节在不同语言当中并不一致。本文只是抛砖引玉,简单开一个好头,希望大家能在此基础上继续深入学习,多在掘金之类的平台逛逛,找找底层原理的相关文章,多看看源码,写代码的时候多思考原理,相信你会有更多的收获。