在现代编译器中如何实现泛型?


15

我的意思是我们如何从某些模板T add(T a, T b) ...进入生成的代码?我已经想到了几种方法来实现,我们将通用函数存储在AST中Function_Node,然后每次使用它时,都会在原始函数节点中存储其自身的副本,其中所有类型都T替换为正在使用。例如add<int>(5, 6)将存储的通用功能的副本add并替换所有类型T 的副本int

所以它看起来像:

struct Function_Node {
    std::string name; // etc.
    Type return_type;
    std::vector<std::pair<Type, std::string>> arguments;
    std::vector<Function_Node> copies;
};

然后,您可以为这些代码生成代码,并在访问Function_Node副本列表所在的位置时copies.size() > 0调用visitFunction所有副本。

visitFunction(Function_Node& node) {
    if (node.copies.size() > 0) {
        for (auto& node : nodes.copies) {
            visitFunction(node);
        }
        // it's a generic function so we don't want
        // to emit code for this.
        return;
    }
}

这会很好吗?现代编译器如何解决这个问题?我认为,执行此操作的另一种方法可能是,您可以将副本注入AST,以便它贯穿所有语义阶段。我还认为也许您可以立即生成它们,例如Rust的MIR或Swifts SIL。

我的代码是用Java编写的,这里的示例是C ++,因为示例的冗长程度有所降低-但原理基本上是相同的。尽管可能会出现一些错误,因为它只是在问题框中手动写出的。

请注意,我的意思是说现代编译器是解决此问题的最佳方法。当我说泛型时,我的意思不是像Java泛型那样使用类型擦除。


在C ++中(其他编程语言具有泛型,但是它们各自实现不同),它基本上是一个巨大的编译时宏系统。实际代码是使用替换类型生成的。
罗伯特·哈维

为什么不输入擦除?请记住,不仅仅是Java可以做到这一点,而且它也不是坏技术(取决于您的要求)。
Andres F.

@AndresF。我认为,考虑到我的语言的工作方式,效果会不太理想。
Jon Flow

2
我认为您应该澄清您正在谈论的泛型。例如,C ++模板,C#泛型和Java泛型都非常不同。您说的不是意思是Java泛型,而是说的不是您的意思。
2013年

2
这确实需要专注于一种语言的系统,以避免过于广泛
Daenyth

Answers:


14

在现代编译器中如何实现泛型?

如果您想了解现代编译器的工作原理,我邀请您阅读现代编译器的源代码。我将从Roslyn项目开始,该项目实现C#和Visual Basic编译器。

特别要提请您注意实现类型符号的C#编译器中的代码:

https://github.com/dotnet/roslyn/tree/master/src/Compilers/CSharp/Portable/Symbols

您可能还需要查看可转换规则的代码。有很多关于泛型类型的代数运算的内容。

https://github.com/dotnet/roslyn/tree/master/src/Compilers/CSharp/Portable/Binder/Semantics/Conversions

我尽力使后者易于阅读。

我已经想到了几种方法来实现,我们将泛型函数作为Function_Node存储在AST中,然后每次使用它时,都会在原始函数节点中存储其自身的副本,其中所有类型T都替换为类型被使用。

您是在描述模板,而不是泛型。C#和Visual Basic在其类型系统中具有实际的泛型。

简而言之,他们就是这样工作的。

  • 我们首先为在编译时正式构成类型的规则建立规则。例如:int是类型,类型参数T是类型,对于任何类型X,数组类型X[]也是类型,依此类推。

  • 泛型规则涉及替代。例如class C with one type parameter不是类型。这是建立类型的模式。class C with one type parameter called T, under substitution with int for T 一种。

  • 在编译器中设计和实现了描述类型之间关系的规则-分配时的兼容性,如何确定表达式的类型等。

  • 设计并实现了一种在其元数据系统中支持通用类型的字节码语言。

  • 在运行时,JIT编译器将字节码转换为机器码。它负责在通用专业化的情况下构造适当的机器代码。

例如,在C#中,当您说

class C<T> { public void X(T t) { Console.WriteLine(t); } }
...
var c = new C<int>(); 
c.X(123);

然后,编译器将验证中C<int>的参数int是否有效替代T,并相应地生成元数据和字节码。在运行时,抖动检测到C<int>第一次创建a 并动态生成适当的机器代码。


9

泛型的大多数实现(或更确切地说:参数多态)都使用类型擦除。这大大简化了编译通用代码的问题,但仅适用于盒装类型:由于每个参数实际上都是不透明的指针,因此我们需要VTable或类似的调度机制来对参数执行操作。在Java中:

<T extends Addable> T add(T a, T b) { … }

可以被编译,类型检查和调用的方式与

Addable add(Addable a, Addable b) { … }

除了泛型在呼叫站点为类型检查器提供了更多信息之外。可以使用类型变量来处理这些额外的信息,尤其是在推断泛型类型时。在类型检查期间,每个通用类型都可以替换为一个变量,我们称之为$T1

$T1 add($T1 a, $T1 b)

然后使用已知的更多事实更新类型变量,直到可以将其替换为具体类型为止。类型检查算法必须以容纳这些类型变量的方式编写,即使它们尚未解析为完整的类型也是如此。在Java本身中,这通常可以轻松完成,因为通常在需要知道函数调用的类型之前就知道参数的类型。一个明显的例外是作为函数参数的lambda表达式,它要求使用此类类型变量。

很久以后,优化器可能会为一组特定的参数生成专门的代码,这实际上将是一种内联。

如果泛型函数不对该类型执行任何操作,而仅将其传递给另一个函数,则可以避免针对泛型类型的参数的VTable。例如,Haskell函数call :: (a -> b) -> a -> b; call f x = f x不必对x参数进行装箱。但是,这确实需要一个调用约定,该约定可以在不知道值大小的情况下传递值,这实际上将其限制为指针。


在这方面,C ++与大多数语言有很大不同。模板类或函数(在这里我仅讨论模板函数)本身是不可调用的。而是应将模板理解为返回实际函数的编译时元函数。暂时忽略模板参数推断,然后将一般方法归纳为以下步骤:

  1. 将模板应用于提供的模板参数。例如,调用template<class T> T add(T a, T b) { … }as add<int>(1, 2)将为我们提供实际功能int __add__T_int(int a, int b)(或使用任何名称处理方法)。

  2. 如果当前编译单元中已经生成了该功能的代码,请继续。否则,就像int __add__T_int(int a, int b) { … }在源代码中编写了函数一样,生成代码。这涉及用其值替换所有出现的模板参数。这可能是AST→AST转换。然后,对生成的AST执行类型检查。

  3. 就像源代码一样编译该调用__add__T_int(1, 2)

请注意,C ++模板与重载解析机制具有复杂的交互作用,在此不再赘述。还要注意,这种代码生成使得不可能拥有虚拟的模板化方法-基于类型擦除的方法不会受到这种实质性限制。


这对您的编译器和/或语言意味着什么?您必须仔细考虑要提供的泛型类型。如果支持盒装类型,则在没有类型推断的情况下进行类型擦除是最简单的方法。模板专门化似乎相当简单,但通常涉及名称修改和(对于多个编译单元)输出中的大量重复,因为模板是在调用站点而不是定义站点实例化的。

您所展示的方法实质上是一种类似于C ++的模板方法。但是,您将专用/实例化模板存储为主模板的“版本”。这具有误导性:它们在概念上并不相同,并且函数的不同实例可以具有截然不同的类型。如果还允许函数重载,从长远来看,这会使事情复杂化。取而代之的是,您将需要一个过载集的概念,其中包含所有可能共享名称的函数和模板。除了解决重载以外,您可以考虑将不同的实例化模板彼此完全分开。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.