如何在设计时可靠地确定使用var声明的变量类型?

110
我正在为Emacs开发C#的完成(智能感知)功能。当用户键入一个片段,然后通过特定的按键组合请求完成时,完成工具将使用.NET反射来确定可能的完成项。要做到这一点,需要知道正在完成的内容的类型。如果是字符串,则有一组已知的可能方法和属性;如果是Int32,则有单独的一组等等。使用Emacs中可用的代码词法分析/解析包Semantic,可以找到变量声明及其类型。有了这个,就可以轻松地使用反射在类型上获取方法和属性,然后向用户呈现选项列表。(好吧,在Emacs内部并不完全容易实现,但使用在Emacs内运行PowerShell进程的能力,就变得容易得多了。我编写一个自定义的.NET程序集来进行反射,将其加载到PowerShell中,然后在Emacs中运行的elisp可以通过comint向PowerShell发送命令并读取响应。因此,Emacs可以快速获得反射的结果。)问题出现在代码中使用var声明正在完成的内容时。这意味着类型没有明确指定,因此无法完成。
当使用"var"关键字声明变量时,我该如何可靠地确定实际使用的类型?需要明确的是,我不需要在运行时确定它。我想在"设计时间"确定它。
到目前为止,我有以下想法:
1.编译和调用:
- 提取声明语句,例如 `var foo = "a string value";` - 连接语句 `foo.GetType();` - 动态编译生成的C#片段成一个新程序集 - 将程序集加载到新的AppDomain中,运行片段并获取返回类型。 - 卸载和丢弃程序集
我知道如何做到这一点。但是,对于编辑器中的每个完成请求,这听起来非常沉重。
我想我不需要每次都使用全新的AppDomain。我可以为多个临时程序集重复使用单个AppDomain,并将设置和拆卸的成本分摊到多个完成请求中。这只是基本想法的微调。
2.编译和检查IL
只需将声明编译成模块,然后检查IL以确定编译器推断出的实际类型。这可能吗?我要用什么来检查IL?
有更好的想法吗?评论?建议?

编辑 - 经过进一步思考,编译和调用不可接受,因为调用可能会产生副作用。因此,第一个选项必须被排除。

此外,我认为我不能假设 .NET 4.0 的存在。


更新 - 正确答案并未提及,但Eric Lippert轻轻地指出,即实现完全保真度类型推断系统。 这是可靠地在设计时确定var类型的唯一方法。 但是,这也不容易。 因为我没有幻想要尝试构建这样的东西,所以我选择了选项2的捷径 - 提取相关声明代码并编译它,然后检查生成的IL。

这实际上对于完成场景的相当一部分有效。

例如,假设在以下代码片段中,?是用户请求完成的位置。 这个可以工作:

var x = "hello there"; 
x.?

该完成函数意识到 x 是一个字符串,并提供了相应的选项。它通过生成并编译以下源代码来实现这一点:

namespace N1 {
  static class dmriiann5he { // randomly-generated class name
    static void M1 () {
       var x = "hello there"; 
    }
  }
}

...然后使用简单的反射来检查IL。

这个也可以:

var x = new XmlDocument();
x.? 

该引擎会向生成的源代码添加适当的使用语句,以便正确编译,然后IL检查也是相同的。

这也可以运作:

var x = "hello"; 
var y = x.ToCharArray();    
var z = y.?

我只是意味着IL检查必须找到第三个本地变量的类型,而不是第一个。

还有这个:

var foo = "Tra la la";
var fred = new System.Collections.Generic.List<String>
    {
        foo,
        foo.Length.ToString()
    };
var z = fred.Count;
var x = z.?

这只是比之前的例子深了一层。

但是,任何依赖于实例成员或本地方法参数初始化的本地变量上的自动完成都不起作用。例如:

var foo = this.InstanceMethod();
foo.?

没有LINQ语法。

在考虑通过一种明显是“有限设计”(委婉的说法是黑客)的完成方式解决这些问题之前,我必须思考这些东西的价值。

解决依赖于方法参数或实例方法的问题的方法是,在生成、编译和IL分析代码片段时,将对这些内容的引用替换为相同类型的“合成”本地变量。


另一个更新 - 现在可以完成依赖于实例成员的变量。

我的做法是通过语义来查询类型,然后为所有现有成员生成合成替代成员。对于像这样的 C# 缓冲区:

public class CsharpCompletion
{
    private static int PrivateStaticField1 = 17;

    string InstanceMethod1(int index)
    {
        ...lots of code here...
        return result;
    }

    public void Run(int count)
    {
        var foo = "this is a string";
        var fred = new System.Collections.Generic.List<String>
        {
            foo,
            foo.Length.ToString()
        };
        var z = fred.Count;
        var mmm = count + z + CsharpCompletion.PrivateStaticField1;
        var nnn = this.InstanceMethod1(mmm);
        var fff = nnn.?

        ...more code here...

...生成的代码被编译,所以我可以从输出的IL中学习本地变量nnn的类型,看起来像这样:

namespace Nsbwhi0rdami {
  class CsharpCompletion {
    private static int PrivateStaticField1 = default(int);
    string InstanceMethod1(int index) { return default(string); }

    void M0zpstti30f4 (int count) {
       var foo = "this is a string";
       var fred = new System.Collections.Generic.List<String> { foo, foo.Length.ToString() };
       var z = fred.Count;
       var mmm = count + z + CsharpCompletion.PrivateStaticField1;
       var nnn = this.InstanceMethod1(mmm);
      }
  }
}

所有实例和静态类型成员在骨架代码中都可用。它能够成功编译。此时,通过反射确定本地变量的类型就很简单了。
这是可能的原因是:
- 在emacs中运行powershell的能力;
- C#编译器非常快速。在我的机器上,将内存中的程序集编译大约需要0.5秒。不足以支持按键间分析,但足以支持按需生成完成列表;
我还没有研究LINQ。那将是一个更大的问题,因为emacs对C#的语义分析器/解析器不支持LINQ。

4
编译器通过类型推断确定并填写 foo 的类型。我怀疑机制完全不同。也许类型推断引擎有一个钩子?至少我会使用“类型推断”作为标签。 - George Mauer
3
你制作“虚假”对象模型的技术很好,它具有真实对象的所有类型但没有语义。这是我以前在Visual InterDev中为JScript编写智能感知时使用的方法;我们创建了一个“虚假”版本的IE对象模型,其中包含所有方法和类型,但没有任何副作用,然后在编译时对解析的代码运行一个小解释器,看看返回的类型是什么。 - Eric Lippert
8个回答

203
我可以为您描述我们如何在“真正的”C# IDE中高效地完成这项工作。
首先,我们运行一个分析源代码中仅涉及“顶层”内容的过程。我们跳过所有方法体。这样可以快速建立有关程序源代码中名称空间、类型和方法(以及构造函数等)的信息数据库。如果你试图在击键之间完成对每个方法体中每行代码的分析,那将花费太长时间。
当IDE需要确定方法体内特定表达式的类型时——比如你输入了“foo.”,我们需要找出foo的成员是什么——我们做同样的事情;我们尽可能地跳过一些工作。
我们从分析该方法内部局部变量声明开始。运行此过程时,我们将从“作用域”和“名称”对映射到“类型确定器”。 “类型确定器”是表示“如果需要,我可以计算出此本地变量的类型”的对象。计算本地变量的类型可能很昂贵,因此如果需要,我们希望推迟该工作。
现在,我们拥有了一个惰性构建的数据库,可以告诉我们每个本地变量的类型。所以,回到那个“foo.”——我们确定相关表达式位于哪个语句中,然后只针对该语句运行语义分析器。例如,假设您有以下方法体:
String x = "hello";
var y = x.ToCharArray();
var z = from foo in y where foo.

现在我们需要确定foo的类型是char。我们建立一个包含所有元数据、扩展方法、源代码类型等信息的数据库。我们建立一个具有x、y和z类型确定器的数据库。我们分析包含有趣表达式的语句。我们从对其进行语法转换开始。

var z = y.Where(foo=>foo.

为了确定foo的类型,我们首先必须知道y的类型。因此,在这一点上,我们询问类型确定器“y的类型是什么”?然后它启动一个表达式求值器,解析x.ToCharArray()并询问“x的类型是什么”?我们有一个类型确定器,它说“我需要在当前上下文中查找String”。当前类型中没有String类型,所以我们查找命名空间。它也不在那里,因此我们查找使用指令并发现有一个“using System”,而System有一个String类型。好的,那就是x的类型。
然后,我们查询System.String的元数据以获取ToCharArray的类型,它说它是System.Char[]。很好,所以我们有了y的类型。
现在我们问“System.Char[]是否有Where方法?”没有。因此,我们查找使用指令;我们已经预先计算了一个包含可能使用的所有扩展方法的元数据的数据库。
现在我们说“好的,在范围内有18打扩展方法名为Where,它们中有没有第一个形式参数的类型与System.Char[]兼容的方法?”因此,我们开始进行可转换性测试。但是,Where扩展方法是泛型的,这意味着我们必须进行类型推断。
我编写了一个特殊的类型推理引擎,可以处理从扩展方法的第一个参数到不完整推断的情况。我们运行类型推断器,并发现有一个接受IEnumerable的Where方法,我们可以从System.Char[]推断出IEnumerable,因此T是System.Char。
该方法的签名是Where(this IEnumerable items, Func predicate),我们知道T是System.Char。此外,我们知道括号内第一个参数是一个lambda表达式。因此,我们启动一个lambda表达式类型推理器,它说“假定形式参数foo是System.Char”,在分析lambda的其余部分时使用这个事实。
现在我们拥有分析lambda主体所需的所有信息,即“foo.”。我们查找foo的类型,根据lambda绑定器的说法,我们发现它是System.Char,我们完成了;我们显示System.Char的类型信息。
除了“顶层”分析之外,我们几乎在每次击键之间都进行了所有操作。那才是真正棘手的部分。实际编写所有分析并不难;使它足够快,以便您可以以输入速度执行它,这才是真正棘手的部分。

8
Eric,感谢你的详细回复。你让我大开眼界。 对于emacs,我并没有期望创造一个在用户体验方面与Visual Studio相媲美的动态、按键之间引擎。首先,由于我的设计固有的约0.5秒延迟,基于emacs的功能仅在需求时提供,不会有预测输入的建议。另外,我将实现var本地变量的基本支持,但在事情变得棘手或依赖图超过某个限制时,我将乐意踢球。还不确定那个限制是什么。再次感谢。 - Cheeso
13
令我十分惊讶的是,所有这些操作都可以如此快速和可靠地运行,尤其是在使用lambda表达式和泛型类型推断时。当我第一次编写lambda表达式并按下“.”键时,Intellisense就已经知道了我的参数类型,即使语句还没有完成,而且我从未明确指定过扩展方法的泛型参数,这让我感到非常惊讶。谢谢你为我们揭开了这个魔法的一角。 - Dan Bryant
21
@Dan:我曾经看过(或编写过)源代码,令人难以置信的是它确实运行起来了。:-) 里面有一些棘手的东西。 - Eric Lippert
11
Eclipse的人可能做得更好,因为他们比C#编译器和IDE团队更厉害。 - Eric Lippert
23
我完全不记得自己说过这个愚蠢的评论。它根本没有意义。我一定是喝醉了。对不起。 - Tomas Andrle
显示剩余13条评论

15

我能大概告诉你 Delphi IDE 是如何与 Delphi 编译器一起工作实现智能感知(Delphi 称之为代码洞察)。虽然这种方法不完全适用于 C#,但它是一个值得考虑的有趣方案。

在 Delphi 中,大部分语义分析都是在解析器自身中完成的。表达式在解析时被赋予类型,除非这样做不容易——在这种情况下,会使用向前看解析来确定意图,然后将该决策用于解析。

解析器主要使用 LL (2) 递归下降算法,仅对表达式使用运算符优先级进行解析。Delphi 的一个独特之处在于它是一种单遍语言,因此需要在使用之前声明构造,不需要顶层遍历来提取信息。

这些特性的组合意味着解析器具有几乎所有需要的信息以进行代码洞察。它的工作方式如下:IDE 通知编译器的词法分析器光标位置(即需要代码洞察的位置),并将其转换为特殊令牌(称为 kibitz 令牌)。每当解析器遇到此令牌时(可能出现在任何位置),它就知道这是发回所有它所拥有的信息,返回给编辑器的信号。由于写入了 C,它使用了 longjmp 来实现这一点。它所做的就是通知最终调用方 kibitz 点的语法结构(即语法上下文),以及该点所需要的所有符号表。例如,如果上下文在传递给方法的参数的表达式中,则我们可以检查方法重载,查看参数类型,并将有效符号过滤为只能解析为该参数类型的符号(这样可以减少下拉框中的无关垃圾)。如果在嵌套作用域上下文中(例如在“.”之后),解析器将返回对作用域的引用,IDE 可以枚举该作用域中找到的所有符号。

其他事情也会被处理,例如,如果kibitz标记不在方法体的范围内,就会跳过它——这是一种乐观的优化策略,如果跳过了标记,就会回滚。类似于扩展方法的东西——Delphi中的类帮助器——具有一种版本化缓存,因此它们的查找速度相对较快。但是,Delphi的泛型类型推断比C#要弱得多。
现在,针对具体问题:推断用var声明的变量类型等同于Pascal推断常量类型的方式。它来自初始化表达式的类型。这些类型是从下往上构建的。如果xInteger类型,而yDouble类型,则x + y将是Double类型,因为这是语言的规则;依此类推。您遵循这些规则,直到您获得右侧表达式的完整类型,然后使用该类型作为左侧符号的类型。

7

如果您不想自己编写解析器来构建抽象语法树,可以考虑使用来自SharpDevelopMonoDevelop的解析器,两者都是开源的。


4
智能感知系统通常使用抽象语法树来表示代码,这使得它们可以以与编译器相同或相似的方式解析分配给“var”变量的函数的返回类型。如果您使用VS智能感知,则可能会注意到,在输入有效(可解决的)赋值表达式之前,它不会向您提供var的类型。如果表达式仍然模棱两可(例如,无法完全推断表达式的泛型参数),则var类型将无法解析。这可能是一个相当复杂的过程,因为您可能需要深入遍历树才能解析类型。例如:
var items = myList.OfType<Foo>().Select(foo => foo.Bar);

返回类型是IEnumerable<Bar>,但解决这个问题需要知道:

  1. myList是实现了IEnumerable接口的类型。
  2. 有一个扩展方法OfType<T>适用于IEnumerable。
  3. 结果值是IEnumerable<Foo>,并且有一个扩展方法Select适用于此。
  4. Lambda表达式foo => foo.Bar的参数foo的类型为Foo。这是通过使用Select来推断的,它采用Func<TIn,TOut>,由于TIn已知(Foo),因此可以推断出foo的类型。
  5. 类型Foo有一个属性Bar,其类型为Bar。我们知道Select返回IEnumerable<TOut>,并且可以从lambda表达式的结果中推断出TOut的类型,因此项的结果类型必须是IEnumerable<Bar>

没错,这可能会变得非常深入。我很擅长解决所有依赖关系。 仔细想想,我描述的第一个选项——编译和调用——绝对是不可接受的,因为调用代码可能会产生副作用,比如更新数据库,而这不是编辑器应该做的事情。编译可以,调用不行。至于构建AST,我不认为我想要那样做。实际上,我想把这个工作推迟到编译器,因为编译器已经知道如何做了。我想要能够询问编译器告诉我我想知道的内容。我只想要一个简单的答案。 - Cheeso
从编译的角度来检查它的挑战在于依赖关系可能是任意深的,这意味着您可能需要构建所有内容,以便编译器生成代码。如果您这样做,我认为您可以使用调试器符号和生成的IL匹配每个本地变量的类型与其符号。 - Dan Bryant
1
@Cheeso:编译器目前还不支持这种类型的分析服务。但我希望未来会有,尽管不能保证。 - Eric Lippert
是的,我认为这可能是解决问题的方法 - 解决所有依赖关系,然后编译并检查IL。@Eric,好的,知道了。目前,如果我不渴望进行完整的AST分析,那么我必须使用现有工具采用肮脏的黑客手段来生成此服务。例如,编译一个智能构造的代码片段,然后使用ILDASM(或类似工具)以编程方式获取所需的答案。 - Cheeso

4
由于您的目标是Emacs,最好从CEDET套件开始。通过CEDET/Semantic工具中的代码分析器已经涵盖了Eric Lippert提到的所有细节。还有一个C#解析器(可能需要一些TLC),因此唯一缺少的部分与调整用于C#的必要部分有关。
基本行为是由依赖于每种语言定义的可重载函数定义的核心算法定义的。完成引擎的成功取决于已完成多少调整工作。借助C++作为指南,获得类似于C++的支持不应该太困难。
Daniel的答案建议使用MonoDevelop进行解析和分析。这可以作为现有C#解析器的替代机制,也可以用于增强现有解析器。

我知道CEDET,并且正在使用semantic目录中的C#支持。Semantic提供了本地变量及其类型的列表。完成引擎可以扫描该列表并向用户提供正确的选择。问题是当变量为“var”时。Semantic正确地将其标识为var,但不提供类型推断。我的问题特别围绕如何解决这个问题。我还研究了如何插入现有的CEDET完成,但我无法弄清楚如何操作。CEDET的文档...啊...不完整。 - Cheeso
旁注 - CEDET的雄心壮志令人钦佩,但我发现它很难使用和扩展。目前,解析器将“namespace”视为C#中的指示符号。我甚至无法弄清楚如何将“namespace”添加为一个独立的语法元素。这样做会阻止所有其他语法分析,并且我无法弄清原因。我之前已经解释了我在完成框架方面遇到的困难。除了这些问题外,各个部分之间还存在缝隙和重叠。例如,导航既是语义的一部分,也是senator的一部分。CEDET似乎很诱人,但最终...它太笨重了,难以承诺。 - Cheeso
Cheeso,如果你想充分利用CEDET文档较少的部分,最好的方法是尝试使用邮件列表。问题很容易深入到尚未开发完善的领域,因此需要几次迭代才能找出好的解决方案或解释现有的解决方案。对于C#,特别是因为我对它一无所知,不会有简单的答案。 - Eric

2
这是一个很难做好的问题。基本上,你需要通过大部分的词法分析/语法分析/类型检查来模拟语言规范/编译器,并构建源代码的内部模型,然后可以查询该模型。Eric详细介绍了C#的情况。您可以随时下载F#编译器源代码(F# CTP的一部分)并查看service.fsi,以查看F#编译器公开的接口,以供F#语言服务用于提供智能感知、推断类型的工具提示等。如果您已经将编译器可作为API调用,则可能会产生一个“接口”的感觉。 另一种方法是像你描述的那样重复使用编译器,然后使用反射或查看生成的代码。从这个角度来看,这是有问题的,因为您需要“完整程序”才能从编译器获得编译输出,而在编辑器中编辑源代码时,您通常只有“不完整程序”,还没有解析、还没有实现所有方法等。 简而言之,我认为“低成本”版本非常难做好,而“真正的”版本则非常,非常难做好。(此处的“难”既包括“努力”又包括“技术难度”。)

是的,“低预算”版本有一些明显的限制。我正在尝试决定什么是“足够好”的,以及我是否能够达到这个标准。在我自己的经验中,使用我已经拥有的工具来进行dogfooding,可以让在emacs中编写C#更加愉快。 - Cheeso

2

0

对于解决方案“1”,在.NET 4中您有一种快速简便地完成此操作的新工具。因此,如果您可以将程序转换为.NET 4,则这将是最佳选择。


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