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


109

我正在为emacs中的C#完成(智能)功能。

这个想法是,如果用户键入一个片段,然后通过特定的按键组合要求完成,则完成设施将使用.NET反射来确定可能的完成。

这样做需要知道完成的事物的类型。如果是字符串,则存在一组已知的可能方法和属性。如果是Int32,则具有单独的集合,依此类推。

使用语义,emacs中提供了一个代码lexer / parser程序包,我可以找到变量声明及其类型。鉴于此,很容易使用反射来获取类型上的方法和属性,然后向用户显示选项列表。(好吧, emacs中执行操作不是很简单,但是使用 emacs 中运行powershell进程的功能会变得容易得多。我编写了一个自定义.NET程序集进行反射,将其加载到powershell中,然后在运行时在elisp中运行emacs可以通过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是一个String,并提供适当的选项。它通过生成并编译以下源代码来完成此操作:

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

...然后通过简单的反射检查IL。

这也适用:

var x = new XmlDocument();
x.? 

引擎将适当的using子句添加到生成的源代码中,以便正确编译,然后进行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语法。

在考虑通过绝对的“有限的设计”(礼貌用语“ hack”)解决这些问题之前,我必须考虑这些事情的价值。

解决依赖于方法参数或实例方法的问题的方法是,在生成,编译和IL分析的代码片段中,用相同类型的“合成”局部变量替换对这些事物的引用。


另一个更新 -完成依赖实例成员的var上的工作。

我所做的是(通过语义)询问类型,然后为所有现有成员生成综合替代成员。对于这样的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中了解本地var 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.5s。击键之间的分析速度不够快,但足以支持按需生成完成列表。

我还没有研究LINQ。
这将是一个更大的问题,因为emacs对于C#具有语义词法分析器/解析器,而不是“执行” LINQ。


4
foo的类型由编译器通过类型推断找出并填充。我怀疑机制完全不同。也许类型推断引擎有一个钩子?至少我会使用“类型推断”作为标签。
乔治·莫尔

3
制作具有所有类型但不包含真实对象语义的“伪”对象模型的技巧是一种很好的方法。那就是我过去在Visual InterDev中对JScript进行IntelliSense的方式。我们制作了具有所有方法和类型但没有副作用的IE对象模型的“伪”版本,然后在编译时对已解析的代码运行了一个解释器,然后查看返回的类型。
埃里克·利珀特

Answers:


202

我可以为您介绍我们如何在“真实” 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,因此我们在名称空间中进行查找。它也不存在,因此我们查看using指令,发现其中存在一个“ using System”,并且System具有String类型。好,那就是x的类型。

然后,我们查询System.String的元数据以获取ToCharArray的类型,并说它是System.Char []。超。所以我们有一个y的类型。

现在我们问“ System.Char []是否在哪里有方法?” 否。所以我们看一下using指令。我们已经预先计算了一个数据库,其中包含所有可能使用的扩展方法的元数据。

现在我们说:“好吧,有十八种扩展方法,它们的作用域在范围内,它们中的任何一个是否具有与System.Char []兼容的第一个形式参数?” 因此,我们开始了一轮可转换性测试。但是,Where扩展方法是通用的,这意味着我们必须进行类型推断。

我编写了一个特殊的类型推断引擎,该引擎可以处理从第一个参数到扩展方法的不完全推断。我们运行类型推断器,发现有一个采用方法的Where方法IEnumerable<T>,并且我们可以从System.Char []进行推断IEnumerable<System.Char>,因此T为System.Char。

此方法的签名为Where<T>(this IEnumerable<T> items, Func<T, bool> predicate),我们知道T为System.Char。我们也知道,扩展方法括号内的第一个参数是lambda。因此,我们启动了一个lambda表达式类型推断器,该推断器说“假定形式参数foo为System.Char”,在分析其余lambda时使用此事实。

现在,我们拥有分析lambda主体(“ foo”)所需的所有信息。我们查找foo的类型,发现根据lambda绑定器是System.Char,我们完成了;我们显示System.Char的类型信息。

除了击键之间的“顶级”分析外,我们还进行其他所有操作。那才是真正的棘手问题。实际上,编写所有分析并不难。它的速度足够快,您可以以真正棘手的打字速度来完成它。

祝好运!


8
埃里克,感谢您的完整答复。你睁开了我的眼睛。对于emacs,我并不希望生产一种动态的,击键之间的引擎,该引擎在用户体验质量方面可以与Visual Studio竞争。一方面,由于设计中固有的〜0.5s延迟,基于emacs的功能现在仍将按需使用;没有提前输入的建议。再说一遍-我将实现对var locals的基本支持,但是当事情变得繁琐时,或者当依赖图超过一定限制时,我会很高兴地平底锅。不确定该限制是多少。再次感谢。
Cheeso 2010年

13
老实说,我的想法让我感到吃惊,所有这些功能都能如此快速,可靠地工作,特别是在使用lambda表达式和泛型类型推断的情况下。实际上,当我第一次编写lambda表达式时,我感到很惊讶,并且当我按下。时,Intellisense知道我的参数类型,即使该语句尚未完成,我也从未明确指定扩展方法的通用参数。感谢您对魔术的一瞥。
丹·布莱恩特

21
@Dan:我看过(或写过)源代码,这让我感到困惑,它也可以工作。:-)里面有一些毛茸茸的东西。
埃里克·利珀特

11
Eclipse的人可能做得更好,因为它们比C#编译器和IDE团队出色。
埃里克·利珀特

23
我完全不记得发表这个愚蠢的评论。这甚至没有意义。我一定喝醉了。抱歉。
Tomas Andrle 2010年

15

我可以大致地告诉您Delphi IDE如何与Delphi编译器一起工作以进行智能感知(代码洞察力是Delphi称之为的)。它不是100%适用于C#,但这是一种有趣的方法,值得考虑。

Delphi中的大多数语义分析都是在解析器本身中完成的。表达式是在解析时键入的,但不容易的情况除外-在这种情况下,使用先行解析来确定预期的内容,然后在解析中使用该决策。

该解析主要是LL(2)递归下降,但表达式除外,这些表达式是使用运算符优先级进行解析的。Delphi的与众不同之处之一是它是一种单通道语言,因此在使用结构之前必须先对其进行声明,因此不需要顶层通道即可带出该信息。

这些功能的组合意味着解析器可以在需要的任何地方大致掌握代码洞察所需的所有信息。它的工作方式是这样的:IDE将光标的位置(需要代码洞察力的点)通知编译器的词法分析器,然后词法分析器将其转换为特殊的标记(称为kibitz标记)。每当解析器遇到此令牌(可能在任何地方)时,它都知道这是将其拥有的所有信息发送回编辑器的信号。它使用longjmp执行此操作,因为它是用C编写的;它所做的是将发现kibitz点的语法结构(即语法上下文)以及该点必需的所有符号表通知最终调用者。例如 如果上下文位于作为方法参数的表达式中,则我们可以检查方法重载,查看参数类型,并将有效符号过滤为仅可解析为该参数类型的符号(这会减少下拉菜单中很多无关紧要的内容)。如果它在嵌套的作用域上下文中(例如,在“。”之后),则解析器将返回对该作用域的引用,并且IDE可以枚举在该作用域中找到的所有符号。

其他事情也完成了。例如,如果kibitz令牌不在其范围内,则跳过方法主体-乐观地完成此操作,如果跳过该令牌,则回滚方法主体。等效的扩展方法(Delphi中的类帮助器)具有一种版本化的缓存,因此查找速度相当快。但是Delphi的泛型类型推断要比C#弱得多。

现在,到一个特定的问题:推断用声明的变量的类型var等效于Pascal推断常量的类型。它来自初始化表达式的类型。这些类型是自下而上构建的。如果xis是type Integer,并且yis是type Doublex + y则将是type Double,因为这是语言的规则;等等。请遵循这些规则,直到在右侧具有完整表达式的类型为止,这是用于左侧符号的类型。



4

Intellisense系统通常使用抽象语法树表示代码,这使它们能够以与编译器相同的方式解析分配给'var'变量的函数的返回类型。如果使用VS Intellisense,您可能会注意到,在输入完有效的(可解析的)赋值表达式之前,它不会为您提供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来推断的,该选择使用a,Func<TIn,TOut>并且由于TIn是已知的(Foo),因此可以推断foo的类型。
  5. Foo类型具有Bar类型的属性Bar。我们知道IEnumerable<TOut>可以从lambda表达式的结果推断出Select return和TOut,因此,结果类型必须为IEnumerable<Bar>

对,它会变得很深。我对解决所有依赖关系很满意。只是考虑一下,我描述的第一个选项-编译和调用-绝对是不可接受的,因为调用代码可能会产生副作用,例如更新数据库,而这不是编辑器应该做的。编译可以,调用不可以。就构建AST而言,我认为我不想这样做。确实,我想将这项工作交给编译器,而编译器已经知道该怎么做。我希望能够要求编译器告诉我我想知道的内容。我只想要一个简单的答案。
Cheeso 2010年

从编译检查它的挑战在于,依赖关系可以任意深,这意味着您可能需要构建所有内容才能使编译器生成代码。如果这样做,我认为您可以将调试器符号与生成的IL配合使用,并将每个本地符号的类型与其匹配。
丹·布莱恩特

1
@Cheeso:编译器不提供这种类型分析作为服务。我希望将来会,但没有希望。
埃里克·利珀特

是的,我认为这可能是解决问题的方法-解决所有依赖关系,然后编译和检查IL。@Eric,很高兴认识。现在,如果我不希望进行完整的AST分析,那么我必须诉诸于肮脏的技巧,以使用现有工具来产生此服务。例如,编译一个智能构建的代码片段,然后以编程方式使用ILDASM(或类似方法)来获取我寻求的答案。
Cheeso 2010年

4

由于您的目标是Emacs,因此最好从CEDET套件开始。在C ++的CEDET /语义工具的代码分析器中,已经涵盖了Eric Lippert的所有详细信息。还有一个C#解析器(可能需要一点TLC),因此缺少的唯一部分与调整C#的必要部分有关。

基本行为是在核心算法中定义的,这些算法依赖于按语言定义的可重载函数。完成引擎的成功取决于已完成多少调整。以c ++为指导,获得与C ++类似的支持应该还不错。

Daniel的答案建议使用MonoDevelop进行解析和分析。这可以是替代现有C#解析器的替代机制,也可以用于扩充现有解析器。


是的,我了解CEDET,并且正在使用contrib目录中的C#支持获取语义。语义提供了局部变量及其类型的列表。完成引擎可以扫描该列表,并向用户提供正确的选择。问题是当变量为时var。语义正确地将其标识为var,但不提供类型推断。我的问题专门围绕如何解决这个问题。我还研究了如何插入现有的CEDET完成,但是我不知道如何实现。CEDET的文档......不完整。
Cheeso 2010年

旁注-CEDET雄心勃勃,但我发现它很难使用和扩展。当前,解析器将“命名空间”视为C#中的指示符。我什至不知道如何添加“命名空间”作为独特的语法元素。这样做阻止了所有其他语法分析,我不知道为什么。之前,我解释了完成框架存在的困难。除了这些问题之外,各部分之间还存在接缝和重叠。举一个例子,导航既是语义又是参议员的一部分。CEDET似乎很诱人,但最终……实在太笨拙了。
Cheeso 2010年

Cheeso,如果您想充分利用CEDET的文献记载少的部分,最好的选择是尝试使用邮件列表。问题很容易探究尚未完善的领域,因此需要反复进行迭代才能得出好的解决方案或解释现有的解决方案。特别是对于C#,由于我对此一无所知,因此不会有简单的答案。
埃里克

2

做好很难解决。基本上,您需要通过大多数词法分析/解析/类型检查来对语言规范/编译器进行建模,并构建源代码的内部模型,然后可以对其进行查询。Eric针对C#详细介绍了它。您始终可以下载F#编译器源代码(F#CTP的一部分),并查看一下service.fsiF#编译器所暴露的接口,F#语言服务使用该接口来提供智能感知,推断类型的工具提示等。它提供了如果您已经可以使用编译器作为要调用的API,则表示可能的“接口”。

另一种方法是按照您的描述按原样重用编译器,然后使用反射或查看生成的代码。从角度来看,这是有问题的,您需要“完整程序”才能从编译器获得编译输出,而在编辑器中编辑源代码时,您通常仅拥有尚未解析的“部分程序”,而不会已经实现了所有方法,等等。

总之,我认为,“低预算”的版本是很难做的很好,而“真实”的版本是非常,非常难做好。(这里的“困难”指的是“努力”和“技术难度”。)


是的,“低预算”版本有一些明显的局限性。我正在尝试确定“足够好”是什么,以及我是否可以达到那个标准。以我自己的经验来总结我到目前为止所取得的成就,这使得在emacs中编写C#变得更好。
Cheeso 2010年


0

对于解决方案“ 1”,您可以在.NET 4中使用新功能来快速轻松地执行此操作。因此,如果您可以将程序转换为.NET 4,那将是您的最佳选择。

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.