(【理论】)编译器和不同的传递类型

(theoretical) Compilers and different pass-by types

本文关键字:类型 理论 编译器      更新时间:2023-10-16

新前言

我对构造函数理论不太了解,这是一个理论问题,关于类C++语言的理论构造函数如何编译与汇编程序非常相似的方法(并进一步编译成二进制代码),以及该汇编程序在三个不同的方法中是否相似,每个方法都采用相同数量的参数,但提供相同的功能(也许是一个打印两个整数值的平凡方法?)。

每个方法都以一对整数作为参数,但方式不同:一个方法通过值传递,另一个通过引用传递,最后一个通过地址传递。由于传递引用和地址变体必须作用于所提供的内存位置的实际值,因此它们(除了访问值的代码)在编译版本中是否包含相同的代码?

public void Foo (int a, int b)
{
std:cout << a << " " << b <<endl;
}
public void Bar (int* a, int* b)
{
// (aside from dereferencing code) the same code as Foo
}
public void FooBar (int& a, int& b)
{
// again, the same code (fundamentally) here
}

原始问题

假设我已经写了三个相同的方法"Foo"、"Bar"answers"FooBar"。每个方法都接受相同的有限数量的参数,并且所有方法都是无效的。

public void Foo (int a, int b)
{
// some code here
}
public void Bar (int* a, int* b)
{
// (aside from dereferencing code) the same code as Foo
}
public void FooBar (int& a, int& b)
{
// again, the same code (fundamentally) here
}

假设该语言的编译器运行良好,没有任何错误,并且进行了充分优化。

这样一个完美的系统中,输出的程序集和三种方法的二进制代码会不匹配吗?如果没有,它们之间会有巨大的差异吗?

澄清:假设这些方法非常琐碎,因为它们不会更改传入参数的值。

显然,如果代码确实修改了ab,则前两个函数和后两个函数的语义完全不同;这显然会迫使编译器生成不同的代码;除此之外,如果在给定程序的每种情况下,这三个函数实际上都是相同的,那么编译器当然可以根据"好像"规则将所有函数简化为一个函数。

但这个问题没有多大意义——或者至少,询问"完美系统"(不可能存在的东西)中二进制代码生成(一个非常具体的实现细节)的具体差异对我来说并没有真正的意义。

为了回到真实的系统,首先我们必须考虑内联;如果函数是内联的,那么它们会与父函数的其余代码混合,并且每次扩展的输出可能不同(可以有不同的寄存器分配,不同的指令混合以最大限度地提高流水线利用率,…)

因此,比较应该是关于每个函数的"独立"输出。

我希望第二个和第三个扩展到几乎完全相同的代码:引用是指针的语法糖,而*a*b可能在Bar中没有检查的情况下被取消引用,这已经考虑到了不能有NULL引用的事实(这告诉优化器假设它们永远不会是NULL)。此外,我不知道有什么C++ABI可以区分指针和引用。

至于Foo,它将取决于许多因素:

  • 如果我们正在编译一个库,编译器不能随心所欲,函数必须遵守一些ABI;出于这个原因,首先,在最后两种情况下,参数实际上是指针,在第一种情况下是值(根据平台ABI的不同,结果会有所不同)
  • 如果编译器不能LTCG,并且我们希望使用其他模块中的这些函数(即,这些函数没有标记为static),则这种情况也会发生
  • 在最后两种情况下,为了生成相同的输出,编译器可能需要证明引用/指针指向不同的值,以生成与Foo相同的输出
  • 还必须能够证明ab在整个函数中没有变化;特别是,在每次外部(=非完全内联)函数调用之后,指向的对象可能已经改变;这两者都可能是复杂的任务,而且,如果程序由几个模块组成,它们可能需要LTCG

所以我实际期望的是:

  • 对于独立版本,Foo!= BarFooBarBar==FooBar
  • 对于内联版本,编译器可能会有更简单的时间来确定将BarFooBar"转换"为Foo的相同语义的条件,但当然,生成的代码与不同函数的代码混合的事实将导致不同的汇编输出(以至于可能很难理解子程序的代码从哪里开始/结束)

优化编译器很可能为Foo生成比BarFooBar更好的代码,除了一些微不足道的函数。

原因是对于Foo,编译器可能会假设ab的值在整个函数中是恒定的,除非对其中一个变量进行了显式赋值。即使存在,大多数现代编译器的中间表示也会将这样的赋值表示为变量,并从赋值开始简单地使用该变量而不是原始变量。

然而,对于BarFooBar,这种推理只有在

  • 该函数不调用其他非内联函数,因为任何此类函数都可以更改ab所指向的值

  • 该函数不会通过char*类型的指针修改任何内存,因为这样的指针可以合法地指向任何类型的数据,包括int(谷歌对完整独家的"严格别名规则")

BarFooBar可能会生成类似的代码,除非您在某个神秘的平台上,ABI对引用和指针的处理方式不同。

在一个完美的系统中,是的。。!!

然而,这种级别的优化是相当极端的。BarFooBarFoo几乎相同,但它们几乎相同的事实会使编译器很难检测到相似之处,因为它基本上是对生成的代码进行差异,然后必须计算出差异的显著性。

如果你想要这种级别的优化,那么你最好写这样的代码

public void Foo(int a, int b)
{
// Whatever
}
public void Bar (int* a, int* b)
{
Foo(*a, *b);
}
public void FooBar (int& a, int& b)
{
Foo(a, b);
}

现在编译器可以选择将Foo内联到BarFooBar中,这对于编译器来说是一个相当简单的优化决策。

假设C++风格的声明,Bar和Foobar除了调试信息外将是相同的,因为内部引用是指针,只有访问风格会从指针中分离它们。

对于第一个(Foo),这取决于如果不知道函数将来将如何使用,您是否真的允许修改函数声明样式。如果他们的接口假设他们可以更改指针下的值(即使在非常极端的条件下),则不允许只传递值的优化。但是,假设经常调用函数,编译器和/或运行时可以通过直接值传递将其更改为不带指针的变体。(但是,更有可能的是,还会应用一些其他优化,例如全函数内联。)

在一个完美的系统中,所有三种方法的输出程序集和二进制代码会不匹配吗?

否,因为如果代码写入a和b,那么Foo会修改a和b的本地副本,而FooBar则会修改原始(调用方的副本)a和b。