是否保证在调用链式构造函数之前评估代码合约?

时间:2013-06-12 13:59:44

标签: c# code-contracts constructor-chaining

在开始使用代码契约之前,我在使用构造函数链时遇到与参数验证有关的繁琐。

这是一个(人为的)例子最简单的解释:

class Test
{
    public Test(int i)
    {
        if (i == 0)
            throw new ArgumentOutOfRangeException("i", i, "i can't be 0");
    }

    public Test(string s): this(int.Parse(s))
    {
        if (s == null)
            throw new ArgumentNullException("s");
    }
}

我希望Test(string)构造函数链接Test(int)构造函数,为此我使用int.Parse()

当然,int.Parse()不喜欢使用null参数,所以如果 s 为null,它将在我到达验证行之前抛出:

if (s == null)
    throw new ArgumentNullException("s");

这使得检查毫无用处。

如何解决这个问题?好吧,我有时习惯这样做:

class Test
{
    public Test(int i)
    {
        if (i == 0)
            throw new ArgumentOutOfRangeException("i", i, "i can't be 0");
    }

    public Test(string s): this(convertArg(s))
    {
    }

    static int convertArg(string s)
    {
        if (s == null)
            throw new ArgumentNullException("s");

        return int.Parse(s);
    }
}

这有点繁琐,当它失败时堆栈跟踪并不理想,但它有效。

现在,随着Code Contracts,我开始使用它们:

class Test
{
    public Test(int i)
    {
        Contract.Requires(i != 0);
    }

    public Test(string s): this(convertArg(s))
    {
    }

    static int convertArg(string s)
    {
        Contract.Requires(s != null);
        return int.Parse(s);
    }
}

一切都很好。它工作正常。但后来我发现我可以这样做:

class Test
{
    public Test(int i)
    {
        Contract.Requires(i != 0);
    }

    public Test(string s): this(int.Parse(s))
    {
        // This line is executed before this(int.Parse(s))
        Contract.Requires(s != null);
    }
}

然后,如果我var test = new Test(null),则Contract.Requires(s != null)this(int.Parse(s))之前执行。这意味着我可以完全取消convertArg()测试!

所以,关于我的实际问题:

  • 这种行为是否记录在任何地方?
  • 在为像这样的链式构造函数编写代码契约时,我可以依赖这种行为吗?
  • 我还有其他方法可以接近吗?

1 个答案:

答案 0 :(得分:7)

简短回答

是的,行为记录在“前置条件”的定义中,以及未处理Contract.EndContractBlock调用的遗留验证(if / then / throw)的处理方式。

如果您不想使用Contract.Requires,可以将构造函数更改为

public Test(string s): this(int.Parse(s))
{
    if (s == null)
        throw new ArgumentNullException("s");
    Contract.EndContractBlock();
}

答案很长

当您在代码中放置Contract.*调用时,实际上并未调用System.Diagnostics.Contracts命名空间中的成员。例如,Contract.Requires(bool)定义为:

[Conditional("CONTRACTS_FULL")]
public static void Requires(bool condition) 
{
    AssertMustUseRewriter(ContractFailureKind.Precondition, "Requires"); 
}

AssertMustUseRewriter无条件地抛出ContractException,因此在没有重写编译的二进制文件的情况下,如果定义了CONTRACTS_FULL,代码就会崩溃。如果未定义,则前提条件永远不会被检查,因为由于存在[Conditional] attribute,C#编译器会忽略对Requires的调用。

重写者

根据项目属性中选择的设置,Visual Studio将定义CONTRACTS_FULL并调用ccrewrite以生成适当的IL以在运行时检查合同。

合同示例:

private string NullCoalesce(string input)
{
    Contract.Requires(input != "");
    Contract.Ensures(Contract.Result<string>() != null);

    if (input == null)
        return "";
    return input;
}

使用csc program.cs /out:nocontract.dll编译,您得到:

private string NullCoalesce(string input)
{
    if (input == null)
        return "";
    return input;
}

使用csc program.cs /define:CONTRACTS_FULL /out:prerewrite.dll编译并运行ccrewrite -assembly prerewrite.dll -out postrewrite.dll,您将获得实际执行运行时检查的代码:

private string NullCoalesce(string input)
{
    __ContractRuntime.Requires(input != "", null, null);
    string result;
    if (input == null)
    {
        result = "";
    }
    else
    {
        result = input;
    }
    __ContractRuntime.Ensures(result != null, null, null);
    return input;
}

最感兴趣的是我们的Ensures(一个后置条件)被移到方法的底部,而我们的Requires(一个先决条件)并没有真正移动,因为它已经位于顶部方法。

这符合documentation's definition

  

[Preconditions]是调用方法时世界状态的契约   ...
  后置条件是终止时方法状态的契约。换句话说,在退出方法之前检查条件。

现在,您的场景中的复杂性存在于前置条件的定义中。基于上面列出的定义,前提条件在方法运行之前运行。问题是C#规范说构造函数初始化器(链式构造函数)必须在构造函数体[CSHARP 10.11.1]之前立即调用,这与前置条件的定义不一致。

魔术生活在这里

ccrewrite生成的代码因此不能表示为C#,因为该语言没有提供在链式构造函数之前运行代码的机制(除非通过在链接的构造函数参数列表中调用静态方法)。 ccrewrite,根据定义的要求采用构造函数

public Test(string s)
    : this(int.Parse(s))
{
    Contract.Requires(s != null);
}

编译为

the MSIL of the compiled code above

并在调用链式构造函数之前将调用移到require:

the msil of the code above passed through the contract rewriter

这意味着......

避免不得不求助于进行参数验证的静态方法的方法是使用合同重写器。您可以使用Contract.Requires来调用重写器,或者通过用Contract.EndContractBlock();结束代码块来表示代码块是一个先决条件。这样做会导致重写器在调用构造函数初始化程序之前将其放在方法的开头。