从Python、C#、Java谈不可变类型和可变类型的赋值
柏舟 新冠4年 11-27
在之前的文章中介绍了编译型语言的赋值。对于Python、C#、Java这些带有虚拟机和GC的语言来说,大大简化了赋值和生命周期管理的心智负担,但是如果代码中存在内部状态改变且需要Clone,或者需要管理数据库资源、文件句柄、队列等资源对象的时候,GC帮不上什么忙,最后还是需要手动管理。只是说有的语言提供优美的解决方案,比如Python的with、C#的using,而Go的defer就非常丑陋,Java啥也没有。
不变类型和可变类型
需要注意的是Python、Java和C#这些语言都存在不变类型和可变类型(我理解成值类型和引用类型)。这是因为像Java在设计的时候受了Smalltalk很大的影响,认为所有类型都是一种对象。所以,Smalltalk的数据都会带一个头,这导致类似整数、浮点数非常占内存。
而这些语言为了提高效率就搞了个折衷的办法,小写的int、double、string是没有对象头的,在内存中传递也是直接复制的,并且是不可改变的,不能为null的。比如说Python里的int、float、str、complex、tuple都是不可变类型(值类型),作为参数传递时语义都是拷贝。
而可变类型list、dict、set、class都是可变的,当你赋值或向函数传递参数时传递的都是引用,并且这些类型都可以为null。这意味着如果调用了含副作用的函数,就会将影响传递到外面。
包括class内部成员也是,如果是字段是值类型,赋值时执行的是拷贝,如果你申明了final或readonly,那么它就真的不可变了。如果是引用类型,赋值时执行的是引用的拷贝,如果你申明了final或readonly,你还是可以执行带有副作用的函数。就像const char*一样,而不是char *const。
import dataclasses
@dataclasses.dataclass
class ClassType:
v: int
def SideEffect(self):
self.v += 1
def xxx(a: int, b: ClassType, c: tuple[ClassType]):
a = 1 # 赋值,并不会改变外面的值
b.SideEffect() # 会影响外面
c[0].SideEffect() # tuple内部的值是引用类型,会影响外面
c = (ClassType(0),) # c是个变量,可以重新赋值,不会影响外面
# c[0] = ClassType(0) # 非法操作,c的值是不可变类型
a = 1
b = ClassType(0)
c = (b,)
xxx(a, b, c)
print(a, b.v, c[0].v) # 1 2 2
这种赋值不统一的行为经常导致心智负担。所以你会看到像Vue.js这种框架会定义一个ref的东西把值类型包起来。大部分情况下这样很好,但是有的时候我想保存表单的初始状态,发送的时候将新的状态和初始状态对比一下就非常难受。因为Javascript居然没有deepcopy。
装箱
Python我不是很清楚,但是对于Java和C#同时存在不变类型和可变类型。为了兼容面向对象的体系,它们又可以隐式转换成object。因为在代码中申明的不变类型值都是已知的,所有使用引用的位置都是已知的,编译器自动完成类型的装箱,即为不变类型加上对象头。
Java和C#有很大的区别,Java只有自带的值类型,而C#可以自定义值类型struct。Java的泛型是伪泛型,它在编译时会擦除类型,也就是将类型转换为object,在运行时根据对象头找到对应的方法。而C#的类型参数为值类型时会将模板特化,如果为引用类型也会类型擦除。(很多是个人理解,不一定对)
浅谈对象头
我其实对对象头不是很了解,我的理解是一个指针指向虚函数表和类型元信息表,当存在继承行为、反射,需要运行时确定类型(virtual)时就需要使用对象头。除此之外,Java还有synchronized关键字,对象头里面好像还有锁。但是总的来说,都是一个引用指向实例在堆中的数据,数据包含字段信息和对象头引用。(C++支持多重继承,搞不清楚对象头怎么回事)
目前还有一种方案是Rust的dyn方案,它的引用是一个胖指针,一部分指向字段信息,一部分指向对象头信息。简单来说就是将字段的引用和对象头的引用放一起,而不是将字段信息和对象头引用放一起。它的好处是最大限度的减少装箱次数,装箱行为非常透明,而不是编译器搞黑箱操作。我看过知乎有文章直接用C++手动模拟胖指针,真的牛。
全局变量初始化时的赋值
所以你会发现一个变量既有可能是引用类型,也有可能是值类型。由于引用类型的初始化是个DAG,当你初始化全局变量的时候,你怎么确定编译器会安排好初始化的顺序,而不是null直接退出了?要知道C编译器的全局变量顺序是未定义行为,虽然这很大程度归功于糟糕的C和C++多模块编译。
当然Java和C#要好很多,虽然不存在全局变量,但是如果你使用单例模式或static来管理数据库句柄等仍然存在这些问题。这方面最佳实践是依赖注入。
其它
虽然不可变类型和可变类型与赋值高度相关,但它还有一些其它猥琐的细节,比如使用hash进行比较等等。不同操作系统的赋值也有区别,比如linux是写时分配页,一旦内存不够了,赋值的时候就会退出。