C#编译如何避免需要头文件的问题?

31

我一直从事C#开发人员的职业生涯。作为学生时,我偶尔使用C语言,但并没有深入研究其编译模型。最近我跟风开始学习Objective-C。我的第一步只是让我意识到我现有知识中的漏洞。

根据我的研究,C/C++/ObjC编译要求预先声明所有遇到的符号。我还了解到构建是一个两步过程。首先,将每个单独的源文件编译为单独的目标文件。这些目标文件可能具有未定义的"符号"(通常对应于头文件中声明的标识符)。其次,将目标文件链接在一起形成最终输出。这是一个相当高级别的解释,但足以满足我的好奇心。但我也想了解C#构建过程类似的高层次理解。

Q:C#构建过程如何避免需要头文件?我想可能编译步骤进行了两次处理?

(编辑: 这里有一个后续问题当涉及到使用库时,C/C++/Objective-C与C#如何比较?)

5个回答

100

更新:这个问题是我在2010年2月4日的博客主题。感谢提出了这个好问题!

让我为您解释一下。最基本的意义上,编译器是一个“两遍编译器”,因为编译器经历的阶段是:

  1. 生成元数据
  2. 生成IL

元数据是描述代码结构的所有“顶层”内容。命名空间、类、结构体、枚举、接口、委托、方法、类型参数、形式参数、构造函数、事件、属性等等。基本上,除了方法体之外的一切都是元数据

IL包含的是方法体中的所有内容 - 实际的命令式代码,而不是关于代码结构的元数据。

第一个阶段实际上是通过对源码进行多次通行来实现的。它超过了两次。

我们要做的第一件事就是将源代码的文本分割成一系列的标记流。也就是说,我们进行了词法分析,以确定

class c : b { }

是类、标识符、冒号、标识符、左大括号、右大括号。

然后我们进行“顶层解析”,验证令牌流是否定义了一个语法正确的C#程序。但是,我们跳过解析方法体。当我们遇到方法体时,我们只需快速通过令牌,直到找到匹配的闭合大括号。稍后我们会回来处理它;此时我们只关心获取足够的信息以生成元数据。

然后我们进行“声明”遍历,记录程序中每个命名空间和类型声明的位置。

然后我们进行遍历,验证所有声明的类型在其基类型中没有循环。我们需要首先执行此操作,因为在随后的每个遍历中,我们需要能够沿着类型层次结构向上行走,而不必处理循环。

然后我们进行遍历,验证泛型类型上的所有泛型参数约束也是无环的。

然后我们进行遍历,检查每个类型的每个成员——类的方法、结构体的字段、枚举值等——是否一致。没有枚举中的循环,每个重写方法都重写了实际虚拟的内容等。此时,我们可以计算所有接口、具有虚拟方法的类等的“虚函数表”布局。

然后我们进行遍历,计算所有“常量”字段的值。

此时,我们已经有足够的信息来生成几乎所有此程序集的元数据。我们仍然没有有关迭代器/匿名函数闭包或匿名类型的元数据信息;我们稍后处理这些。

现在我们可以开始生成IL代码。对于每个方法体(以及属性、索引器、构造函数等),我们将词法分析器倒回到方法体开始的位置,并解析该方法体。

一旦方法体被解析,我们就进行了初始的“绑定”处理,在这个过程中,我们尝试确定每个语句中每个表达式的类型。然后我们对每个方法体进行了一整堆的处理。
首先,我们运行一个处理循环转换为goto和标签的过程。
(接下来的几个步骤寻找不良内容。)
然后我们运行一个检查使用已弃用类型的警告的过程。
然后我们运行一个搜索我们尚未为其发出元数据的匿名类型使用的过程,并发出这些警告。
然后我们运行一个搜索表达式树的错误使用情况的过程。例如,在表达式树中使用++运算符。
然后我们运行一个查找在主体中定义但未使用的所有局部变量的过程,以报告警告。
然后我们运行一个查找迭代器块内部非法模式的过程。
然后我们运行可达性检查器,以给出有关无法访问的代码的警告,并在您忘记在非void方法的末尾返回时通知您。
然后我们运行一个验证每个goto目标合理的标签的过程,并且每个标签都是由可达的goto目标。
然后我们运行一个检查所有局部变量在使用之前都被明确分配的过程,记录哪些局部变量是匿名函数或迭代器的外部变量,并且哪些匿名函数在可达代码中。 (此过程做得太多了。我一直想重构它。)
此时,我们已经完成了寻找不良内容的工作,但是在睡觉之前我们还有更多的处理要做。
接下来我们运行一个程序,检测COM对象调用中缺少的引用参数并进行修复。(这是C# 4中的新功能。)
然后,我们运行一个程序,查找"new MyDelegate(Foo)"形式的内容,并将其重写为对CreateDelegate的调用。
接着,我们运行一个程序,将表达式树转换为必须在运行时创建表达式树的工厂方法调用序列。
然后,我们运行一个程序,将所有可空算术转换为测试HasValue的代码,等等。
然后,我们运行一个程序,查找所有base.Blah()形式的引用,并将它们重写为调用基类方法的非虚拟调用代码。
然后,我们运行一个程序,查找对象和集合初始化程序,并将它们转换为适当的属性设置,等等。
接下来,我们运行一个程序,查找动态调用(C# 4中),并将它们重写为使用DLR的动态调用站点。
然后,我们运行一个程序,查找已删除的方法的调用。(也就是说,没有实际实现的部分类方法,或者没有定义条件编译符号的条件方法。)它们被转换为no-op(no operation)。
然后,我们查找无法访问的代码并从树中删除它。没有必要将它编译成IL。
接下来,我们运行一个优化程序,重写微不足道的"is"和"as"运算符。
然后,我们运行一个优化程序,查找switch(constant)并将其重写为直接跳转到正确case的分支。
然后,我们运行一个程序,将字符串连接转换为对String.Concat正确重载版本的调用。
(啊,回忆。当我加入编译器团队时,这两个步骤是我首先要处理的事情。)
然后我们运行一个步骤,将命名和可选参数的用法重写为调用,其中副作用按正确顺序发生。
然后我们运行一个步骤来优化算术运算;例如,如果我们知道 M() 返回 int,并且我们有 1 * M(),那么我们只需将其转换为 M()。
然后我们首先生成此方法使用的匿名类型的代码。
然后我们将此体中的匿名函数转换为闭包类的方法。
最后,我们将迭代器块转换为基于 switch 的状态机。
然后我们发出刚刚计算出的转换树的 IL。
简单易懂!

24
对我来说更像是一个需要进行30次编译的编译器 ;) - RCIX
6
哇,这是我读过的最有见地的回答之一!谢谢! - G-Wiz
1
我希望我能够收藏答案而不仅仅是问题。 - Callum Rogers
3
对我来说,做一个馅饼比编程难多了。 - Valentin Golev
3
不,但现在很多人这样做。我的博客文章大多以我在其他地方回答的“问题”为起点;过去我的主要问题来源是微软的内部编程语言讨论电子邮件列表。我还经常重复使用我对书籍中错误分析的许多内容;我把编辑C#书籍作为一种业余爱好。但SO是一个丰富的资源库,提供了很多好问题,现在成为了我的主要来源。 - Eric Lippert
显示剩余4条评论

40

我看到有多种解释这个问题。我回答了 intra-solution 解释,但让我补充一下所有我知道的信息。

"头文件元数据" 存在于已编译程序集中,因此您添加引用的任何程序集都将允许编译器从中提取元数据。

至于尚未编译的内容,在当前解决方案中,它会执行两次编译,首先读取命名空间、类型名称、成员名称,例如除代码以外的所有内容。然后当这些内容检查通过后,它将读取代码并对其进行编译。

这使得编译器知道什么存在,什么不存在(在它的宇宙中)。

要查看双通行编译器的效果,请测试以下代码,其中包含 3 个问题,两个与声明相关的问题和一个代码问题:

using System;

namespace ConsoleApplication11
{
    class Program
    {
        public static Stringg ReturnsTheWrongType()
        {
            return null;
        }

        static void Main(string[] args)
        {
            CallSomeMethodThatDoesntExist();
        }

        public static Stringg AlsoReturnsTheWrongType()
        {
            return null;
        }
    }
}

请注意编译器只会抱怨找不到两个Stringg类型。如果你修复了那些类型,那么它会抱怨在Main方法中调用的方法名称找不到。


这真的回答了问题吗?正如所述。这是一个很好的例子,说明两遍编译器如何解决当前源文件中的引用。但是,C/C++头文件通常用于提供签名和extern定义,这些定义将由项目中的其他源/对象(或另一个项目)提供。因此,实际答案似乎是这样的元数据是在引用的程序集中提供的,因此不需要头文件。 - Kevin Brock
我认为它回答了这个问题。问题在于编译器如何知道后续编译过程中使用的类型。编译器首先必须解析现有的C#代码以生成类型信息。严格来说,如果不需要导入类型,则无需引用任何其他库,但仍然需要从整个源代码构建符号表。 - codekaizen
@Kevin和@codekaizen,我认为你们两个都有很好的观点。我的问题没有明确说明我是否对导入类型时的过程感兴趣。(当时我不知道自己确实感兴趣。)我会更新我的问题来澄清这一点。 - G-Wiz
实际上,我会创建一个新的问题,而不是编辑这个问题。 - G-Wiz

5

它使用参考程序集中的元数据。这包含完整的类型声明,就像您在头文件中找到的一样。

它是一个两遍编译器,可以实现另一个功能:您可以在另一个源代码文件中声明类型之前,在一个源文件中使用该类型。


啊,汇编元数据...我得去研究一下。谢谢。 - G-Wiz

1

这不是一个完整的答案... 它只涉及项目内引用,而不是库间引用。 - Jeffrey Hines
问题似乎并不涉及理解类型系统 - 只涉及编译器如何在程序集范围内解析类型引用。 - codekaizen
@codekaizen - 我必须不同意,因为C头文件既用于代码中的前向引用,也用于对外部库的引用。 - Jeffrey Hines
@Jeffery,好的,但那并不是问题所问的。此外,在C#中引用外部类型的机制与C++有根本性的不同,这不仅仅是“引用”而已。C++将外部类型定义包含在头文件中,而C#访问程序集元数据并使用公共类型系统。 - codekaizen

1

所有必要的信息都可以从引用的程序集中获取。

因此,没有头文件,但编译器需要访问正在使用的 DLL。

是的,这是一个两遍编译器,但这并不能解释它如何获取有关库类型的信息。


但是这个问题实际上并不涉及类型表示。 - codekaizen
+1 这将是我对所述问题的答案,也是没有必要包含头文件的真正原因。 - Kevin Brock
@Ken - 如果没有导入任何类型,怎么办?在这种情况下,它并没有真正回答问题。 - codekaizen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接