- 注册
- 2024/02/23
- 消息
- 233
- 金粒
- 1,127.19金粒
引子
前段时间看到一位正在复习 C++ 的朋友提出了一道面试题,大致内容就是问 C++ 的各种术语,例如左值,右值,泛型左值,纯右值之类的。虽然这没什么难点,只不过平时没人这么说罢了。但是有个很有意思的部分让我格外关注:泛型左值与左值建模。
这对于我们写 Java 的开发者而言是很有意思的,为那些很少写 Java 的读者解释一下:Java 为了保证向下兼容性(Java 1.4 API 之前没有泛型一说),即便是最新的 Java 也依然保留了泛型擦除机制。如果你没听懂的话,这么解释:如果我们有一个 ArrayList,类型是 String:
Java:
ArrayList<String> myList = Lists.newArrayList(); // Java 1.8 前的写法:Lists.newArrayList<String>();
Java:
ArrayList myList = Lists.newArrayList();
是的,没有泛型。
虽然这不是本文的核心论点,但有问题的读者此时就该有新的问题了,那就不妨回答一下:现在这代码里的泛型是干嘛的?
Jvm 在编译期会检查代码合法性的,是的,Jvm 运行期并不关心这些问题。
这也是为什么 Java 的注入与反射非常轻松的原因之一,Jvm 永远不关心发生了什么,他只负责运行和有问题时抛出问题。
泛型概念
何为泛型
如果读者从未接触过开发,那么引子应该就以及云里雾里了。什么 String?什么 ArrayList?什么擦除?好吧,那么再设想一下:
开发中的各种数据容器就像是各种箱子,箱子当然可以塞入各种物品,此时箱子的标记就是无类型。开发者们交付这个箱子给编译器的时候,编译期就不会因为箱子里什么都有而感到愤怒。
但如果预先为箱子贴上了标签:这个箱子应该放凤梨。如果此时箱子里出现了咖啡豆,那么交付给编译器的时候编译器就会指出这个问题:咖啡豆出现在了凤梨箱子里。
理想情况下是这样的,而在 Java 中泛型会被擦除,也就是通过编译后 Jvm 的编译部分就把这个标签给撕掉了。接下来就是猜盲盒环节。因此,泛型对于纯粹的 Java 开发者而言会相对神秘,在做一些原生开发的时候难免会遇到各种泛型问题。
D 语言中的泛型
在任何语言中泛型通常都指代同一个意思,并且都是字面意思。D 语言这种系统级编译语言当然不例外,虽然 D 语言对泛型的建模能力更加高级且抽象,但本文只谈论其基本意义。再引入一些代码作为引子:
代码:
int main(string[] args) {
callMe("Hello world!");
return 0;
}
void callMe(T)(T t) {
import std.stdio : writeln;
if (t is null) {
writeln("Null here!");
return;
}
auto id = typeid(t);
if (id == typeid(string)) {
writeln("String here!");
return;
}
if (id == typeid(int)) {
writeln("Integer here!");
return;
}
}
如果是 Java 开发者的话,那么应该觉得没什么问题。但如果是其他原生开发者,应该已经看到不可抵达的代码了。
事实上,这里的泛型其实并不可能是 Integer。不如我们再把这份代码编译成抽离语法的中间语法树看看:
代码:
int main(string[] args)
{
callMe("Hello world!");
return 0;
}
void callMe(T)(T t)
{
import std.stdio : writeln;
if (t is null)
{
writeln("Null here!");
return ;
}
auto id = typeid(t);
if (id == typeid(string))
{
writeln("String here!");
return ;
}
if (id == typeid(int))
{
writeln("Integer here!");
return ;
}
}
callMe!string
{
@system void callMe(string t)
{
import std.stdio : writeln;
if (t is null)
{
writeln("Null here!");
return ;
}
TypeInfo_Array id = typeid(string);
if (opEquals(id, typeid(string)))
{
writeln("String here!");
return ;
}
if (opEquals(id, typeid(int)))
{
writeln("Integer here!");
return ;
}
}
}
虽然这边出现了一个带泛型语法,但事实上它只是一个步骤方法。而正在调用的,是下方被称之为 “模板” 的方法。那么为什么说这里的泛型不能说 Integer?
请再思考一下:任何一项面向对象语言中,作为系统字段(SystemProperty)的类型 int,它可以是 null 吗?
由于这里会被抽离为 “模板”,意味着采用 Integer 的 “模板” 已经违背了基本语法,因此编译器在编译器间会错报。
也就是对编译器而言,这个方法在编译时是这样的:
代码:
void callMe(string t) {
import std.stdio : writeln;
if (t is null) {
writeln("Null here!");
return;
}
auto id = typeid(t);
if (id == typeid(string)) {
writeln("String here!");
return;
}
// 不可抵达后面的代码。
而如果这里是 int,那么 LSP(Language Server Protocol)就已经先坐不住了:
代码:
void callMe(int t) {
import std.stdio : writeln;
if (t is null) { <- Incompatible types of "t is null": int and typeof(null)
// 不可抵达后面的代码。
泛型作用域:编译前类型可知
应该没人会这么问,但我还是提前回答这个问题:你不能再局部作用域外使用泛型——左值泛型不可以是一个全局变量。这违反了两个部分:泛型编译定义,以及全局变量编译期可预见类型。
这两个部分应该都不难理解:泛型需要在编译器抽离与定义,而全局变量的类型必须在编译前就被确定。
因此泛型绝对不可能也不应该被考虑用作全局变量。不过有个特例:泛型指针。
void,意为空,任何 C-Like Style 语言的开发者门肯定都不陌生:方法或函数没有返回值时,返回类型就是 void。
但 void 以指针形式表达时,此内存将被编译器默许寄存任何类型。虽然 D Safe 和 D Pure 是不允许出现这个的...
但要注意的是,泛型指针的前提是目标必须是具备内存的左值,而新生的对象和系统字段通常是不具备合法内存的纯右值与将亡值,这意味着这些内容在有合法的 CPU 内存地址前无法被嵌入泛型指针。
代码:
void* p;
int main(string[] args) {
auto foo = new Foo();
p = &foo;
return 0;
}
class Foo {
}
当然,使用泛型指针是需要自己投射的。如果投射错了就... 或许动态类型语言开发者会比较享受吧,我不确定,作为强类型语言开发者,我认为这种方式非常的糟糕,面向对象不能只靠大脑编译和记住每个时段字段具体类型。因此非常不建议这种方式处理泛型。
泛型使用
抽象建模
对于面向对象语言,一个对象可以延伸出各种对象。但有时候,只使用接口定义是不完善的,它可能在建模时使用更高级的引用。那么泛型的用途在这时候就更明确了:泛型先打一个抽象建模,而具体需要什么由下一个使用者决定。
代码:
int main(string[] args) {
auto c = new InstanceClass!ChildInstanceFoo();
auto c2 = new InstanceClass!ChildFoo(); <- template instance InstanceClass!(ChildFoo) dose not match template declaration InstanceClass(T: InstanceFoo)
return 0;
}
class Foo {
}
class Bar(T : Foo) {
}
class InstanceFoo : Foo {
}
class ChildFoo : Foo {
}
class ChildInstanceFoo : InstanceFoo {
}
class InstanceClass(T : InstanceFoo) : Bar!T {
}
这个部分对于编程经验较浅的读者来说可能晦涩难懂,因此我准备了一张关系图来为读者梳理关系:

也就是说,泛型在继承时被升级,使用子类时就得使用被升级的泛类。但如果这边使用了接口,此处类型就只能继续按照接口继承,那么取得的数据有升级需求也必须得靠手动投射,看起来大概是这样的:
代码:
int main(string[] args) {
auto c = new InstanceClass(new InstanceFoo());
return 0;
}
interface IFoo {
}
interface IBar {
IFoo getFoo();
}
class Foo : IFoo {
}
class Bar : IBar {
private IFoo foo;
this(IFoo foo) {
this.foo = foo;
}
override IFoo getFoo() {
return this.foo;
}
}
class InstanceFoo : Foo {
}
class ChildFoo : Foo {
}
class ChildInstanceFoo : InstanceFoo {
}
class InstanceClass : Bar {
this(InstanceFoo foo) {
super(foo);
}
InstanceFoo getInstanceFoo() {
return cast(InstanceFoo) this.foo;
}
}
泛型缺陷
泛型属于语法糖的一种。众所周知,糖吃多了会蛀牙。虽然在中间语法树上,每个抽象模板都相当清晰,但对开发者而言就不一定了。泛型是一种动态类型的编程模式,滥用泛型不仅会破坏强类型语言的语法检查能力,在特定情况下会制造更大的麻烦。毕竟,你无法要求其他开发者仅为你的泛型提供某些类型。
虽然 C++ 与 D 都提供了类型模板,但这不在我们的讨论范围内,就泛型自身而言,能避则避。