值与引用语义
一、背景:
值语义跟引用语义在不同的语言,甚至同一语言的不同上下文当中有不同的含义,讨论时经常产生误解,所以必须更加明确。这篇文章尝试从传参是何种语义角度来分类探讨该问题。
先提几个问题,希望看完该文章之后大家都有了答案:
在C++当中,值/引用跟变量类型绑定在一起,有值变量,引用变量,还有个指针变量(有时候我们也用指针说引用语义)。而对比C++,Swift中变量是没有值引用区分,而是类型自带值/引用语义区分,这有什么不同?
在Java当中,我们都说内置类型是值语义,其他对象都是引用语义,这个说法准确(正确)么?
Go当中变量类型也没有值引用区分,但是我们经常会提slice,map,channel是引用类型,这个说法跟前面又有些什么冲突?
二、引用语义分类
在不同的语境下引用语义可以大致分为以下三类:
- 引用传参语义。
- 类型引用语义。
- 逻辑引用语义。
2.1、引用传参语义
引用语义在函数传值下是比较明确的定义的。函数传参上对传入的变量的地址进行拷贝则是支持引用传参语义。如果函数传参上是对传入的变量进行值拷贝赋予参数,则是传值传参语义。
到底怎么确定语言是否支持引用传参语义?一个可参考的测试是:是否能在语言当中写出swap函数?该swap函数接收两个参数,函数内部将这两个参数的值进行交换,并且会影响到外面传入的实参变量值。
swap(Type arg1, Type arg2) {
Type tmp = arg1;
arg1 = arg2;
arg2 = tmp;
}
例如在C++里面,可以如下简单实现swap:
void swap(int& arg1, int& arg2) {
int tmp = arg1;
arg1 = arg2;
arg2 = tmp;
}
void CallSwap() {
int a = 0, b = 1;
swap(a, b); // 这时a为1,b为0
}
而java当中无法实现对应的swap函数。
这里可能会提前涉及一些术语的问题,先尝试自问自答:
Q:那C++里的指针作为参数传递时是引用传参语义么?
A:指针实际上是一个值,但是它的实际作用有限:”存储的数据指向一个值,解引用可以访问到对应的值“,传参时实际上拷贝了这个指针值,所以是传值传参语义。
Q:C++中的引用类型到底是什么?
A:引用是修饰变量的,其实是表明该标识符是另外一个变量所指向值的别名。从这个语境出发,可以推出是没有引用的引用类型的。
Q:那变量又是啥?
A:变量是个二元组:值地址,标识符。这是个逻辑概念,在后端生成代码时不一定存在。在不同的上下文时,我们说变量a可能所指东西不一样,可能是指变量a所对应的值,或者是变量a本身。
2.2、类型引用语义
在类型引用语境下,变量是否是引用类型是跟类型强绑定,前面的测试传值引用语义的swap函数则只支持引用类型。
例如在Swift当中,class是引用类型,struct是值类型,swap仅仅能支持class。但是swift提供了inout修饰函数参数,提供引用传参语义。
protocol MyObject {
var m_value: Int {get set}
}
class RefObject: MyObject {
var m_value: Int = 1
}
struct ValueObject: MyObject {
var m_value: Int = 1
}
// RefObject是引用类型,所以传参是引用传参语义
func swap_by_ref(_ arg1: RefObject, _ arg2: RefObject) {
let tmp = arg1.m_value
// 直接ar1=arg2会报arg1是let const错误,所以swift实际只提供引用类型下的只读引用传参语义
arg1.m_value = arg2.m_value
arg2.m_value = tmp
}
// 使用inout时,所有参数都是引用传参语义了
func swap_by_inout<T>(_ arg1: inout T, _ arg2: inout T) {
let tmp = arg1
arg1 = arg2
arg2 = tmp
}
var ref_o1 = RefObject()
var ref_o2 = RefObject()
ref_o2.m_value = 2
swap_by_inout(&ref_o1, &ref_o2)
print("\(ref_o1.m_value), \(ref_o2.m_value)") // 2 1
swap_by_ref(ref_o1, ref_o2)
print("\(ref_o1.m_value), \(ref_o2.m_value)") // 1 2
var val_o1 = ValueObject()
var val_o2 = ValueObject()
val_o2.m_value = 2
// ValueObject是值类型,必须使用inout的引用传参语义版本
swap_by_inout(&val_o1, &val_o2)
print("\(val_o1.m_value), \(val_o2.m_value)")
// swap_by_ref_generic(val_o1, val_o2)
// 如果写泛型尝试通过赋值m_value字段修改,会报let constant错误
// error: cannot assign to property: 'arg1' is a 'let' constant
// arg1.m_value = arg2.m_value
/*
func swap_by_ref_generic<T: MyObject>(_ arg1: T, _ arg2: T) {
let tmp = arg1.m_value
arg1.m_value = arg2.m_value
arg2.m_value = tmp
}
*/
所以可以看到Swift通过inout支持所有类型的引用传参语义,如果类型是引用类型时则提供只读引用传参语义。
在某些语言(Java,C#等等)下,类型是引用类型,只是说它绑定的变量的类型是一个引用(指针)。
2.3、逻辑上的引用语义
在Go当中,类型没有值/引用区分,也没有类似引用类型/inout机制来支持引用传参语义,所以所有的传参都是传值语义。但是slice,map,channel又经常提到是引用类型,这是怎么回事?
实际上这三个类型也是值类型,只是因为内部保存了指向其他值的指针,其定义的下标赋值操作实际操作的是指向值的数据,所以从逻辑上来讲是一个引用语义,但它本身却是一个不可不扣的值类型!(这里还可以提一点,因为slice这种内部有几个字段,在并发情况下可能出现内存不安全)
package main
import "unsafe"
import "fmt"
type SliceInternal struct {
ptr unsafe.Pointer
len int
cap int
}
// []int是个值,可以取它的指针
func CallByPtr(s *[]int) {
var internal_ptr = (*SliceInternal)(unsafe.Pointer(s))
// 直接修改len,破坏内存安全
internal_ptr.len += 1
}
func main() {
var s = make([]int, 0, 0)
fmt.Println(s) // []
CallByPtr(&s)
fmt.Println(s) // [0?]
}
三、各语言当中引用语义分析
3.1、C++当中的引用语义
从2.1当中的例子已经可以知道:C++提供的是很明确的引用传参语义,通过引用标识符&修饰参数变量类型来达到区分是值传参还是引用传参。 例子:
// 值语义
void CallByValue(int v) {}
void DoSomethingByValue() {
int v1 = 0;
int v2 = v1;
CallByValue(v1):
}
// 引用语义
void CallByRef(int &v) {}
void DoSomethingByRef() {
int v1 = 0;
int &v2 = v1;
CallByRef(v1);
CallByValue(v2); // 值语义
}
C++当中还可以在类当中自定义operator=来改变赋值的行为,从而达到不一定是标准的值语义行为。但是这个操作符必须是方法重载,所以左边传入参数必须是值类型(实际是传了this),而不是引用类型,例如:
class Object {
public:
Object& operator=(const Object& o);
int m_value;
};
Object& Object::operator=(const Object& o) {
this = &o; // 编译会报错,this无法赋值。
return *this;
}
3.2、Java当中的引用语义
在Java当中,我们是无法书写一个swap函数来交换传入两个参数对应的外部实参值,所以Java是不支持引用传参语义。但是我们经常说Java内置类型函数调用是传值,对象类型函数调用是传引用,这个说法其实是不够正确的。如下代码:
class MyObject {
String name;
public static void swap(MyObject o1, MyObject o2) {
// 注意这里以下几行代码实际交换的只是swap函数内的o1跟o2,o1跟o2本质上是指针
MyObject tmp = o1;
o1 = o2;
o2 = tmp;
}
public static void main(String[] args) {
MyObject o1 = new MyObject();
MyObject o2 = new MyObject();
o1.name = "o1";
o2.name = "o2";
swap(o1, o2);
System.out.println(o1.name); // o1
System.out.println(o2.name); // o2
}
}
从上述分析,我们可以看到java中区分值类型跟引用类型,区别是引用类型绑定的变量类型是指针,而值类型绑定的变量类型就是值类型本身!所以都是传值传参语义。
3.3、Swift当中的引用语义
Swift的结论可以参考2.2结论。
3.4、Go当中的引用语义
Go的结论可以参考2.3结论。
3.5、Rust当中的引用语义
Rust比较特别,传值/赋值,默认的是移动语义(移动语义我们这里不探讨),只有实现了copy trait才是传值传参语义。但是它提供mut借用,其实就是传参引用语义,如下官方文档中的代码:
use std::mem;
let mut x = 5;
let mut y = 42;
mem::swap(&mut x, &mut y);
assert_eq!(42, x);
assert_eq!(5, y);
3.6、Python当中的引用语义
Python也无法实现swap函数,但是我们却说Python当中所有类型都是引用类型的,这其实是种误解,跟Java一样,其变量本质上是个指针,例如下述代码:
class MyObject(object): pass
def swap(a, b):
tmp = a
a = b
b = tmp
def main():
a = MyObject()
b = MyObject()
a.name = "a"
b.name = "b"
swap(a, b)
print(a.name) # "a"
print(b.name) # "b"
所以Python的变量其实也是地址(指针),是传值传参语义。
3.7、C#当中的引用语义
C#当中也区分引用类型跟值类型,跟Java一样,这只影响绑定的变量类型是否是指针,传参上默认是传值传参。但是C#支持ref修饰参数指明需要引用传参语义。
using System;
struct ValueObject {
public string m_name;
}
class RefObject {
public string m_name;
}
class Program {
public static void swap_by_ref<T>(ref T o1, ref T o2) {
T tmp = o1;
o1 = o2;
o2 = tmp;
}
public static void swap<T>(T o1, T o2) {
T tmp = o1;
o1 = o2;
o2 = tmp;
}
public static void Main() {
RefObject ref_o1 = new RefObject();
RefObject ref_o2 = new RefObject();
ref_o1.m_name = "ref_o1";
ref_o2.m_name = "ref_o2";
swap(ref_o1, ref_o2);
// 虽然ref_o1,ref_o2都是引用类型,但是变量本身本质是指针,所以swap不起作用
System.Console.WriteLine(ref_o1.m_name); // ref_o1
System.Console.WriteLine(ref_o2.m_name); // ref_o2
// ref机制提供引用传参语义,会修改函数外的值
swap_by_ref(ref ref_o1, ref ref_o2);
System.Console.WriteLine(ref_o1.m_name); // ref_o2
System.Console.WriteLine(ref_o2.m_name); // ref_o1
ValueObject val_o1 = new ValueObject();
ValueObject val_o2 = new ValueObject();
val_o1.m_name = "val_o1";
val_o2.m_name = "val_o2";
// swap对于值类型同样不起作用,但是内部的tmp赋值却有拷贝花销
swap(val_o1, val_o2);
System.Console.WriteLine(val_o1.m_name); // val_o1
System.Console.WriteLine(val_o2.m_name); // val_o2
// ref机制对于值类型也起效
swap_by_ref(ref val_o1, ref val_o2);
System.Console.WriteLine(val_o1.m_name); // val_o2
System.Console.WriteLine(val_o2.m_name); // val_o1
}
}
四、总结
引用语义在不同的语境下有着不同的含义的,只有上下文提供某个具体语言甚至具体到某个语言的某个类型时,才能确定其确切含义。这篇文章从传参的角度来确定引用语义到底是什么含义,并且分析了常用语言的传参语义到底指什么。总结如下:
- 语言本身提供修饰变量的机制来提供传值传参,引用传参的语义。
- 语言不同类型属于值类型,或者引用类型,对于引用类型提供(只读)引用传参语义,对于值类型提供传值传参语义。
- 语言不同类型属于值类型,或者引用类型,不同类型只代表绑定到的变量上的类型不同,值类型绑定的变量类型是值类型本身,引用类型绑定的变量类型是指针(引用)类型,传参只提供传值传参语义。
- 语言只提供值类型,传参只提供传值传参语义。但是语言的某些内置类型提供逻辑上的引用语义功能。
五、参考资料
[1]C++ Reference and Value Semantics.
[2]Wiki:variable.
[3]Value semantics.
[4]Java is Pass-by-Value, Dammit!.