值与引用语义

750 阅读9分钟

值与引用语义

一、背景:

  值语义跟引用语义在不同的语言,甚至同一语言的不同上下文当中有不同的含义,讨论时经常产生误解,所以必须更加明确。这篇文章尝试从传参是何种语义角度来分类探讨该问题。

  先提几个问题,希望看完该文章之后大家都有了答案:

  在C++当中,值/引用跟变量类型绑定在一起,有值变量,引用变量,还有个指针变量(有时候我们也用指针说引用语义)。而对比C++,Swift中变量是没有值引用区分,而是类型自带值/引用语义区分,这有什么不同?

  在Java当中,我们都说内置类型是值语义,其他对象都是引用语义,这个说法准确(正确)么?

  Go当中变量类型也没有值引用区分,但是我们经常会提slice,map,channel是引用类型,这个说法跟前面又有些什么冲突?

二、引用语义分类

  在不同的语境下引用语义可以大致分为以下三类:

  1. 引用传参语义。
  2. 类型引用语义。
  3. 逻辑引用语义。

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. 语言本身提供修饰变量的机制来提供传值传参,引用传参的语义。
  2. 语言不同类型属于值类型,或者引用类型,对于引用类型提供(只读)引用传参语义,对于值类型提供传值传参语义。
  3. 语言不同类型属于值类型,或者引用类型,不同类型只代表绑定到的变量上的类型不同,值类型绑定的变量类型是值类型本身,引用类型绑定的变量类型是指针(引用)类型,传参只提供传值传参语义。
  4. 语言只提供值类型,传参只提供传值传参语义。但是语言的某些内置类型提供逻辑上的引用语义功能。

五、参考资料

[1]C++ Reference and Value Semantics.
[2]Wiki:variable.
[3]Value semantics.
[4]Java is Pass-by-Value, Dammit!.