条条大路通罗马,各语言的泛型实现比较:CPP JAVA GO
迄今为止,如果给所有阅读过的技术类书籍排序,CSAPP在我心目中稳居前五,因为在它的视角里,复杂丰繁的技术表象里,内在的原理逻辑反而是简化统一的,这样简化统一以后,计算机技术的发展脉络就异常清晰。
语言的实现,也是类似,C++JavaGo是目前使用的比较多的高级语言,对于同一个问题,他们各自会有什么样的处理方案,又有哪些共性和差异,这是非常有意思的视角,所以在这篇文章里,我想横向对比一下,也有助于在更广阔的视野里深化对语言机制的理解。知其然不如知其所以然。
泛型是广泛存在于各个语言中的概念,学习一门语言,我们初期就会花相当长的时间在容器概念上。
容器是泛型使用最频繁的地方,可以有效的节省代码量,目前高级语言实现泛型的方式,主要有两种:
这件事,总归要做,要么编译期做,要么运行时做,看你的关注点了。
除了上面两种,对于弱类型语言来说,我连类型都没有,泛型本就是天生支持!赢麻了。
这里不得不提C语言,C++和C语言究竟算是弱类型还是强类型语言,这个话题深究的话其实是有争议的,你说他是弱类型语言,他们是有类型声明的,你说是强类型语言,有Void*这种东西,再配合强制类型转换一起用,可不就是弱类型了么,可以变成你想要的各种模样。当然,现代C++已经不太提倡这种方式了。
有时候我们讨厌弱类型,或者动态类型语言,嫌弃他们太过灵活,不便操控,但是用久了强类型的语言,我们又特别变扭,硬是在C++里造出类型推导的auto、万能类型的any,在java里使用var。
足见类型这个东西,很是拧巴。
CPP的泛型实现是比较中古的方式,对新手不太友好的模板元编程。尽管它的名声不好,但是后来所谓的参数类型、类型参数,我觉得都可以看做是对模板元编程的模仿和改进。
因为最朴素,所以上手不难,但是花样太多,很难精通。嗯,很符合C++的整体气质。
上一个简单的例子:
C++的泛型就是这么简单,使用起来就是:
有什么忌讳和注意点么?
几乎没有,和使用一个普通的类几乎没有区别,理解这段程序,你只需要将T替换为实际传入的类型实参即可。
C++的实现也很简单粗暴,在编译的时候,检测源文件中传入的类型,针对每种类型编译一套结果。
但是,C++的模板编程不止于此,我们可以给类型形参设置默认类型、模板的形参也可以不是类型,而是一个固定常量,严格来说这些和泛型编程已经关系不大了,演化出了一种独特的编程哲学:模板元编程。核心就是利用这些规则和手段,将运行期的性能消耗转化到编译期。这里太过复杂,就不展开了。
在java虚拟机里,其实并不存在泛型这种概念,所有的泛型对象,都是普通对象。关键词:类型擦除
当我们定义一个泛型类型,编译后会成为一个原始的基本类型,就是删除了类型参数的类型名。
举个例子,我们自己编写一个泛型的容器类MyPair:
使用起来就像:
最终编译结果里可以看到,就只有一个class文件:
通过类型擦除,在JVM中,MyPair类型,实际是这样的:
java里默认所有类型会继承自Object,所以这样操作也是不会有什么问题的。与C++不同,即使是在使用的过程中,会有不同的类型参数,也不会产生新的定义。
原始类型用第一个限定的类型变量来替换,如果没有给定限定就用Object替换,对于一些比较复杂的泛型类声明,比如:
这里Interval类使用的类型参数是接口Comparable和Serializable的子类,根据上述规则,类型擦除后,其实使用的是Compareable类,但是偶尔会在必要的时候,对变量进行强制类型转化,转换为Serializable类。
如果我们把Comparable和Serializable的顺序替换一下呢?那类型擦除后的变量默认就是Serializable。
这里Compareable接口限定子类必须具有方法compareTo,而Serializable其实只是一个标记类,并不具备行为,所以可以推测出,类Interval作为Compareable子类被调用的时候更多一些,所以为了减少类型转换的消耗,把Compareable放在限定类型的第一个,是合理的。
综上,我们可以总结一个规则:撰写java的泛型程序时,为了减少运行的类型转换开销,合理规范类型限定类,最好是最大公共父类或者父接口,当类型参数是多个类和方法的子类时,把使用最多、方法较多的父类型放在第一个位置更好一些。
根据上述规则,我们可以重新审视之前的泛型代码:
其实等同于:
泛型方法也是同理。
这就结束了么?
事情并不是那么简单,泛型类本身也是可以被继承的,比如如下的例子:
如果只进行类型擦除,那么该类会变为:
一方面,DateInterval自己的方法被擦除了类型但是保留了参数的类型,另一方面,继承了被擦除类型的MyPair的方法,两者因为函数的签名不一致,在JVM都是有效的。
这个时候,类型擦除后的代码逻辑和类的多态机制存在了冲突,需要编译期层面对这种场景进行处理,编译器会生成相应的桥方法,对于setFirst方法,编译器生成方法setFirst(Objectfirst)然后在这个桥方法中调用上述定义的setFirst方法。
总之,需要记住有关Java泛型转换的事实:
很显然,如果不是继承自Object的类型,是无法使用泛型特性的。基本类型甚至不是类,自然不能被用来实例化泛型类。
因为运行时,只看得到是Object或者限定类。
禁止使用newPair[10]这种初始化参数化类型的数组。
由于类型擦除,这种声明和Object[]没有区别,会导致数组的类型检查功能失效。
由于类型参数会被替换成Object,那么newT()这种声明方式自然是不会达到预期目的的,作为替代方案,我们可以在泛型类中定义一个构造器表达式:
其中Supplier是一个函数式接口,表示一个无参数且返回类型是T的函数。
也是因为类型擦除
总的来看,由于类型擦除机制,引入了如此多的问题,本质都是同一个问题:虚拟机并不知道你传入的类型是什么。
很难说java的泛型设计能否算一个优秀的设计。
Go语言选择了类似C++的泛型机制,也就是真正的泛型,在编译期实实在在的编译出针对不同类型的逻辑。
在Go1.18的版本里,正式默认开放了泛型的使用,为此也引入了一些新的概念,我们可以列举一下:
写一个简单的go泛型的demo:
以上代码中,Slice是一个自定义的类型,和普通的类型定义不同,带有中括号,中括号内包含多个部分:
//一个泛型接口(关于泛型接口在后半部分会详细讲解)
typeIPrintData[Tint|float32|string]interface{
Print(dataT)
}
//一个泛型通道,可用类型实参int或string实例化
typeMyChan[Tint|string]chanT
通过使用泛型receiver定义泛型方法,我们就可以对泛型类型添加新的通用行为
例如上述定义,就给所有的Slice类型增加了求和统计的方法。
以上就是go泛型的基本使用。相对来说,坑点要比java少一些。
目前go的泛型机制就是依靠编译器生成多份代码,但是目前存在其他方向的演进趋势,值得后续进一步的观察。
主题测试文章,只做测试使用。发布者:最新稳定辅助网,转转请注明出处:https://www.744broad.com/14942.html