• 欢迎加入MineBBS QQ讨论群:点击查看所有的官方讨论群
  • 我们将于近期对服务器进行迁移,服务可能中断至多2日。请各位安排好自己的访问计划,造成不便敬请谅解!
  • MineBBS入站考试已经上线!想要成为【正式会员】解锁更多功能吗?快来参与吧!【点我去看】

教程 开发入门到入坟:使用 D 语言解释程序术语之泛型

Amaruq·Illaujaq

真的是狼嗎?

管理成员
版主
VIP
开发者
正式会员
注册
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 1.2 中看起来是这样的:

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 {
}

当然,使用泛型指针是需要自己投射的。如果投射错了就... 或许动态类型语言开发者会比较享受吧,我不确定,作为强类型语言开发者,我认为这种方式非常的糟糕,面向对象不能只靠大脑编译和记住每个时段字段具体类型。因此非常不建议这种方式处理泛型。

如果类型是 Object 理论上也可以当泛型。

泛型使用​

抽象建模​

对于面向对象语言,一个对象可以延伸出各种对象。但有时候,只使用接口定义是不完善的,它可能在建模时使用更高级的引用。那么泛型的用途在这时候就更明确了:泛型先打一个抽象建模,而具体需要什么由下一个使用者决定。

代码:
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 {
}

这个部分对于编程经验较浅的读者来说可能晦涩难懂,因此我准备了一张关系图来为读者梳理关系:

1776934251690.webp

也就是说,泛型在继承时被升级,使用子类时就得使用被升级的泛类。但如果这边使用了接口,此处类型就只能继续按照接口继承,那么取得的数据有升级需求也必须得靠手动投射,看起来大概是这样的:

代码:
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 都提供了类型模板,但这不在我们的讨论范围内,就泛型自身而言,能避则避。
 

在线会员

  • 宥时雨
  • 诺诺noo
  • 沐予
  • 可乐不加冰
  • 1002
  • 请输入用户名91
  • 小叶
  • 健纲 黄
后退
顶部 底部