Contents
迄今为止,如果给所有阅读过的技术类书籍排序,CSAPP在我心目中稳居前五,因为在它的视角里,复杂丰繁的技术表象里,内在的原理逻辑反而是简化统一的,这样简化统一以后,计算机技术的发展脉络就异常清晰。
语言的实现,也是类似,C++\Java\Go是目前使用的比较多的高级语言,对于同一个问题,他们各自会有什么样的处理方案,又有哪些共性和差异,这是非常有意思的视角,所以在这篇文章里,我想横向对比一下,也有助于在更广阔的视野里深化对语言机制的理解。知其然不如知其所以然。
泛型的实现方式
泛型是广泛存在于各个语言中的概念,学习一门语言,我们初期就会花相当长的时间在容器概念上。
容器是泛型使用最频繁的地方,可以有效的节省代码量,目前高级语言实现泛型的方式,主要有两种:
- 编译时特化 或者宏展开:在编译期增加工作量,优点是运行时开销少,缺点是编译时间长,编译结果膨胀、且报错信息不明确, CPP GO RUST采用的是这种方式
- 用装箱的方式隐藏泛型的处理细节:Java就是这种方式,Java的泛型又被称作伪泛型,强行用Object类替换所有类,并且自动给你加上类型转换的过程,好处是确实不需要在编译期折腾了,坏处是运行时效率低
这件事,总归要做,要么编译期做,要么运行时做,看你的关注点了。
除了上面两种,对于弱类型语言来说,我连类型都没有,泛型本就是天生支持!赢麻了。
这里不得不提C语言,C++和C语言究竟算是弱类型还是强类型语言,这个话题深究的话 其实是有争议的,你说他是弱类型语言,他们是有类型声明的,你说是强类型语言,有Void *
这种东西,再配合强制类型转换一起用,可不就是弱类型了么,可以变成你想要的各种模样。当然,现代C++已经不太提倡这种方式了。
有时候我们讨厌弱类型,或者动态类型语言,嫌弃他们太过灵活,不便操控,但是用久了强类型的语言,我们又特别变扭,硬是在C++里造出类型推导的auto
、万能类型的any
,在java里使用var
。
足见类型这个东西,很是拧巴。
CPP
CPP的泛型实现是比较中古的方式,对新手不太友好的模板元编程。尽管它的名声不好,但是后来所谓的参数类型、类型参数,我觉得都可以看做是对模板元编程的模仿和改进。
因为最朴素,所以上手不难,但是花样太多,很难精通。嗯,很符合C++的整体气质。
上一个简单的例子:
template <typename T>
class MetaClass {
private:
T value;
public:
explicit MetaClass(T value) : value(value) {}
T getValue() {
return value;
}
void setValue(T v) {
this->value = v;
}
};
C++的泛型就是这么简单,使用起来就是:
auto intV = new MetaClass<int>{10};
std::cout << intV->getValue() << std::endl;
delete intV;
MetaClass<std::string> strV("hello");
std::cout << strV.getValue() << std::endl;
有什么忌讳和注意点么?
几乎没有,和使用一个普通的类几乎没有区别,理解这段程序,你只需要将T替换为实际传入的类型实参即可。
C++的实现也很简单粗暴,在编译的时候,检测源文件中传入的类型,针对每种类型编译一套结果。
但是,C++的模板编程不止于此,我们可以给类型形参设置默认类型、模板的形参也可以不是类型,而是一个固定常量,严格来说这些和泛型编程已经关系不大了,演化出了一种独特的编程哲学:模板元编程。核心就是利用这些规则和手段,将运行期的性能消耗转化到编译期。这里太过复杂,就不展开了。
Java泛型实现机制
类型擦除
在java虚拟机里,其实并不存在泛型这种概念,所有的泛型对象,都是普通对象。关键词:类型擦除
当我们定义一个泛型类型,编译后会成为一个原始的基本类型,就是删除了类型参数的类型名。
举个例子,我们自己编写一个泛型的容器类MyPair
:
public class MyPair<T> {
private T first;
private T second;
public MyPair(T first, T second) {
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public void setFirst(T first) {
this.first = first;
}
public T getSecond() {
return second;
}
public void setSecond(T second) {
this.second = second;
}
}
使用起来就像:
MyPair<Integer> myPair = new MyPair<>(1, 2);
System.out.println(myPair.getFirst());
System.out.println(myPair.getSecond());
最终编译结果里可以看到,就只有一个class文件:
通过类型擦除,在JVM中,MyPair
类型,实际是这样的:
public class MyPair {
private Object first;
private Object second;
public MyPair(Object first, Object second) {
this.first = first;
this.second = second;
}
public Object getFirst() {
return first;
}
public void setFirst(Object first) {
this.first = first;
}
public Object getSecond() {
return second;
}
public void setSecond(Object second) {
this.second = second;
}
}
java里默认所有类型会继承自Object
,所以这样操作也是不会有什么问题的。与C++不同,即使是在使用的过程中,会有不同的类型参数,也不会产生新的定义。
原始类型用第一个限定的类型变量来替换, 如果没有给定限定就用Object替换,对于一些比较复杂的泛型类声明,比如:
public class Interval<T extends Comparable<T> & Serializable> implements Serializable {
private T lower;
private T higher;
public Interval(T a, T b) {
if (a.compareTo(b) <= 0) {
lower = a;
higher = b;
} else {
lower = b;
higher = a;
}
}
public T getLower() {
return lower;
}
public void setLower(T lower) {
this.lower = lower;
}
public T getHigher() {
return higher;
}
public void setHigher(T higher) {
this.higher = higher;
}
}
这里Interval
类使用的类型参数是接口Comparable和Serializable的子类,根据上述规则,类型擦除后,其实使用的是Compareable类,但是偶尔会在必要的时候,对变量进行强制类型转化,转换为Serializable
类。
如果我们把Comparable和Serializable的顺序替换一下呢?那类型擦除后的变量默认就是Serializable
。
这里Compareable接口限定子类必须具有方法compareTo
,而Serializable其实只是一个标记类,并不具备行为,所以可以推测出,类Interval作为Compareable子类被调用的时候 更多一些,所以为了减少类型转换的消耗,把Compareable放在限定类型的第一个,是合理的。
综上,我们可以总结一个规则:撰写java的泛型程序时,为了减少运行的类型转换开销,合理规范类型限定类,最好是最大公共父类或者父接口,当类型参数是多个类和方法的子类时,把使用最多、方法较多的父类型放在第一个位置更好一些。
翻译泛型代码
根据上述规则,我们可以重新审视之前的泛型代码:
MyPair<Integer> myPair = new MyPair<>(1, 2);
Integer first = myPair.getFirst();
System.out.println(first);
其实等同于:
MyPair myPair = new MyPair(1, 2);
Integer first = (Integer)myPair.getFirst();
System.out.println(first);
泛型方法也是同理。
这就结束了么?
事情并不是那么简单,泛型类本身也是可以被继承的,比如如下的例子:
public class DateInterval extends MyPair<LocalDate> {
public DateInterval(LocalDate first, LocalDate second) {
super(first, second);
}
@Override
public LocalDate getFirst() {
return super.getFirst();
}
@Override
public void setFirst(LocalDate first) {
super.setFirst(first);
}
}
如果只进行类型擦除,那么该类会变为:
public class DateInterval extends MyPair {
public DateInterval(LocalDate first, LocalDate second) {
super(first, second);
}
public LocalDate getFirst() {
return super.getFirst();
}
public Object getFirst() {
return super.getFirst();
}
public void setFirst(LocalDate first) {
super.setFirst(first);
}
public void setFirst(Object first) {
super.setFirst(first);
}
}
一方面,DateInterval自己的方法被擦除了类型但是保留了参数的类型,另一方面,继承了被擦除类型的MyPair的方法,两者因为函数的签名不一致,在JVM都是有效的。
这个时候,类型擦除后的代码逻辑和类的多态机制存在了冲突,需要编译期层面对这种场景进行处理,编译器会生成相应的桥方法,对于setFirst方法,编译器生成方法setFirst(Object first)
然后在这个桥方法中调用上述定义的setFirst
方法。
总之,需要记住有关Java 泛型转换的事实:
- 虚拟机中没有泛型, 只有普通的类和方法。
- 所有的类型参数都用它们的限定类型替换。
- 桥方法被合成来保持多态。
- 为保持类型安全性,必要时插人强制类型转换。
约束和局限
- 不能用基本类型实例化类型参数
很显然,如果不是继承自Object的类型,是无法使用泛型特性的。基本类型甚至不是类,自然不能被用来实例化泛型类。
- 运行时类型查询只适用于原始类型
因为运行时,只看得到是Object或者限定类。
- 不能创建参数化类型的数组
禁止使用new Pair<String>[10]
这种初始化参数化类型的数组。
由于类型擦除,这种声明和Object[]
没有区别,会导致数组的类型检查功能失效。
- 无法在泛型类中实例化类型变量
由于类型参数会被替换成Object,那么new T()
这种声明方式自然是不会达到预期目的的,作为替代方案,我们可以在泛型类中定义一个构造器表达式:
public static <T> MyPair<T> makePair(Supplier<T> conster) {
return new MyPair<>(conster.get(), conster.get());
}
其中Supplier
- 泛型类的静态上下文中类型变量无效
也是因为类型擦除
- 不能抛出或者捕获泛型类的实例
总的来看,由于类型擦除机制,引入了如此多的问题,本质都是同一个问题:虚拟机并不知道你传入的类型是什么。
很难说java的泛型设计能否算一个优秀的设计。
Go语言泛型机制
Go语言选择了类似C++的泛型机制,也就是真正的泛型,在编译期实实在在的编译出针对不同类型的逻辑。
在Go 1.18的版本里,正式默认开放了泛型的使用,为此也引入了一些新的概念,我们可以列举一下:
- 类型形参 (Type parameter)
- 类型实参(Type argument)
- 类型形参列表( Type parameter list)
- 类型约束(Type constraint)
- 实例化(Instantiations)
- 泛型类型(Generic type)
- 泛型接收器(Generic receiver)
- 泛型函数(Generic function)
类型形参、类型实参、类型形参列表、类型约束和泛型类型
写一个简单的go泛型的demo:
type Slice[T int | float32 | float32] []T
var intSlice Slice[int] = []int{1, 3, 5}
for _, t := range intSlice {
println(t)
}
以上代码中,Slice是一个自定义的类型,和普通的类型定义不同,带有中括号,中括号内包含多个部分:
- T 表示类型形参,表示当前类型的底层类型不确定
- int|float32|float64 这部分被称为类型约束,中间的 | 的意思是告诉编译器,类型形参 T 只可以接收 int 或 float32 或 float64 这三种类型的实参,所以我们称其为 类型形参列表
- 这里新定义的类型名称叫 Slice[T]
我们把这种类型定义中带 类型形参 的类型,称之为 泛型类型。
泛型类型不能直接拿来使用,必须传入类型实参(Type argument) 将其确定为具体的类型之后才可使用。而传入类型实参确定具体类型的操作被称为 实例化(Instantiations)
上面只是个最简单的例子,实际上类型形参的数量可以远远不止一个,如下:type MyMap[KEY int | string, VALUE float32 | float64] map[KEY]VALUE var a MyMap[string, float64] = map[string]float64{ "jack_score": 9.6, "bob_score": 8.4, }
所有的类型定义都可以使用类型形参,所以结构体的定义也可以是:
// 一个泛型类型的结构体。可用 int 或 sring 类型实例化 type MyStruct[T int | string] struct { Name string Data T }
// 一个泛型接口(关于泛型接口在后半部分会详细讲解)
type IPrintData[T int | float32 | string] interface {
Print(data T)
}
// 一个泛型通道,可用类型实参 int 或 string 实例化
type MyChan[T int | string] chan T
与其他语法糖类似,泛型的引入,不可避免的会带来一些额外的语法规则,譬如:
1. 定义泛型类型的时候,基础类型不能只有类型形参
```go
// 错误,类型形参不能单独使用
type CommonType[T int|string|float32] T
- 当类型约束的一些写法会被编译器误认为是表达式时会报错。建议包裹上
interface{}
- 匿名结构体、匿名函数不支持泛型
通过使用泛型receiver定义泛型方法,我们就可以对泛型类型添加新的通用行为
func (slice Slice[T]) sum() T {
var sum T
for _, t := range slice {
sum += t
}
return sum
}
例如上述定义,就给所有的Slice类型增加了求和统计的方法。
以上就是go泛型的基本使用。相对来说,坑点要比java少一些。
go泛型的内部原理
目前go的泛型机制就是依靠编译器生成多份代码,但是目前存在其他方向的演进趋势,值得后续进一步的观察。
具体可以参考文章https://colobu.com/2021/08/30/how-is-go-generic-implemented/
发表回复
要发表评论,您必须先登录。