【问题标题】:How to enforce the use of a method's return value in C#?如何在 C# 中强制使用方法的返回值?
【发布时间】:2011-07-21 14:59:56
【问题描述】:

我有一个用流利语法编写的软件。方法链有一个明确的“结束”,在此之前,代码中实际上并没有做任何有用的事情(想想 NBuilder,或者 Linq-to-SQL 的查询生成实际上并没有命中数据库,直到我们使用 ToList() 迭代我们的对象之前) )。

我遇到的问题是其他开发人员对代码的正确使用感到困惑。他们忽略了调用“结束”方法(因此从未真正“做任何事情”)!

我有兴趣强制使用我的一些方法的返回值,这样我们就永远不能“结束链”而不调用“Finalize()”或“Save()”实际工作的方法。

考虑以下代码:

//The "factory" class the user will be dealing with
public class FluentClass
{
    //The entry point for this software
    public IntermediateClass<T> Init<T>()
    {
        return new IntermediateClass<T>();
    }
}

//The class that actually does the work
public class IntermediateClass<T>
{
    private List<T> _values;

    //The user cannot call this constructor
    internal IntermediateClass<T>()
    {
        _values = new List<T>();
    }

    //Once generated, they can call "setup" methods such as this
    public IntermediateClass<T> With(T value)
    {
        var instance = new IntermediateClass<T>() { _values = _values };
        instance._values.Add(value);
        return instance;
    }

    //Picture "lazy loading" - you have to call this method to
    //actually do anything worthwhile
    public void Save()
    {
        var itemCount = _values.Count();
        . . . //save to database, write a log, do some real work
    }
}

如您所见,这段代码的正确用法是这样的:

new FluentClass().Init<int>().With(-1).With(300).With(42).Save();

问题在于人们是这样使用它的(认为它与上面的实现相同):

new FluentClass().Init<int>().With(-1).With(300).With(42);

这个问题如此普遍,以至于另一位开发人员出于完全好的意图实际上更改了“Init”方法的名称,以表明该方法正在执行软件的“实际工作”。

像这样的逻辑错误很难发现,当然,它也可以编译,因为调用带有返回值的方法并只是“假装”它返回 void 是完全可以接受的。 Visual Studio 不在乎你是否这样做;您的软件仍将编译和运行(尽管在某些情况下我相信它会引发警告)。当然,这是一个很棒的功能。想象一个简单的“InsertToDatabase”方法,它将新行的 ID 作为整数返回 - 很容易看出在某些情况下我们需要该 ID,而在某些情况下我们可以不用它。

就这个软件而言,绝对没有任何理由避开方法链末端的“保存”功能。这是一个非常专业的实用程序,唯一的好处来自最后一步。

如果他们调用“With()”而不是“Save()”,我希望某人的软件在编译器级别失败

这似乎是传统方式不可能完成的任务 - 但这就是我来找你们的原因。是否有一个属性可以用来防止方法被“强制转换为无效”或类似的?

注意:已经向我建议的实现此目标的替代方法是编写一套单元测试来强制执行此规则,并使用http://www.testdriven.net 之类的东西将它们绑定到编译器。这是一个可以接受的解决方案,但我希望有更优雅的解决方案。

【问题讨论】:

  • 完全在黑暗中拍摄,我不知道这是可能的,但也许 .NET 代码合同?我认为它们已内置于 .NET 4.0
  • 代码契约没有需要检查返回值的属性或断言。
  • 如果出现此类问题,我会认为此 API 不可用。为什么在这里使用流畅的界面模式?
  • 流畅的界面是由客户端的请求。 Tejs 和 Vercas 等解决方案在技术上是万无一失的,但涉及语法更改;最终我可以玩这些,然后看看客户端是否可以处理 API 更改,但是由于该软件已经在使用中,如果能够在不更改任何公共方法签名的情况下应用修补程序会很好。跨度>

标签: c# compiler-construction attributes lazy-loading return-value


【解决方案1】:

我不知道在编译器级别强制执行此操作的方法。实现IDisposable 的对象也经常请求它,但实际上并不能强制执行。

然而,一个可能有帮助的选项是设置您的类,仅在 DEBUG 中,以具有记录/抛出/等的终结器。如果从未调用过Save()。这可以帮助您在调试时发现这些运行时问题,而不是依赖于搜索代码等。

但是,请确保在发布模式下不使用它,因为它会产生性能开销,因为添加不必要的终结器对 GC 性能非常不利。

【讨论】:

  • +1,对于 Debug 中巧妙的终结器技巧,之前使用它来提醒自己代码中的关键托管本机故障。
  • 越想越喜欢。尤其是因为,这是我在原帖中没有提到的踢球者,我的软件本身仅用于测试;我什至不必指定“仅调试”,因为代码在主类库中已经没有位置了。我会尽快尝试一下,看看我是否可以让它工作,但在考虑了一段时间之后,我怀疑你已经成功了(至少出于我的目的)。谢谢!
【解决方案2】:

您可能需要特定的方法来使用这样的回调:

new FluentClass().Init<int>(x =>
{
    x.Save(y =>
    {
         y.With(-1),
         y.With(300)
    });
});

with 方法返回一些特定对象,获取该对象的唯一方法是调用 x.Save(),它本身有一个回调,可以让您设置不确定数量的 with 语句。所以 init 需要这样的东西:

public T Init<T>(Func<MyInitInputType, MySaveResultType> initSetup) 

【讨论】:

    【解决方案3】:

    我能想到三个几个解决方案,并不理想。

    1. AIUI 你想要的是一个在临时变量超出范围时调用的函数(例如,当它可用于垃圾收集时,但可能在一段时间内不会被垃圾收集)。 (见:The difference between a destructor and a finalizer?)这个假设函数会说“如果你在这个对象中构造了一个查询但没有调用保存,产生一个错误”。 C++/CLI 调用这个 RAII,而在 C++/CLI 中,当对象不再被使用时,有一个“析构函数”的概念,当它最终被垃圾回收时调用一个“终结器”。非常令人困惑的是,C#只有一个所谓的析构函数,但这只是被垃圾收集器调用(框架更早调用它是有效的,就好像它正在立即部分清理对象一样,但 AFAIK 它不会做那样的事情)。所以你想要的是一个 C++/CLI 析构函数。不幸的是,AIUI 这映射到 IDisposable 的概念,它公开了一个 dispose() 方法,可以在调用 C++/CLI 析构函数或调用 C# 析构函数时调用该方法——但是 AIUI 你仍然必须调用“dispose” " 手动,这不符合要点?

    2. 稍微重构界面以更准确地传达概念。调用诸如“prepareQuery”或“AAA”或“initRememberToCallSaveOrThisWontDoAnything”之类的初始化函数。 (最后一点有些夸张,但可能有必要说明这一点)。

    3. 这更像是一个社会问题而不是技术问题。界面应该使做正确的事情变得容易,但程序员必须知道如何使用代码!召集所有的程序员。简单地一劳永逸地解释这个简单的事实。如有必要,让他们都在一张纸上签名,说他们理解,如果他们故意继续编写无用的代码,他们对公司来说比没用更糟糕,并且会被解雇。

    4. 摆弄运算符的链接方式,例如。让每个中间类函数组装一个包含所有参数的聚合中间类对象(您通常这样做已经是(?)),但需要原始类的类似 init 的函数将其作为参数,而不是让它们链接在它之后,然后你可以让 save 和其他函数返回两个不同的类类型(具有基本相同的内容),并让 init 只接受一个正确类型的类。

    仍然是一个问题的事实表明,要么你的同事需要一个有用的提醒,要么他们相当低于标准,或者界面不是很清晰(也许它非常好,但是作者没有意识到,如果你只是顺便使用而不是了解它,那就不清楚了),或者你自己误解了这种情况。一个技术解决方案会很好,但您可能应该考虑一下为什么会出现问题以及如何更清楚地沟通,可能会询问资深人士的意见。

    【讨论】:

      【解决方案4】:

      经过深思熟虑和反复试验,结果证明从 Finalize() 方法抛出异常对我不起作用。显然,您根本无法做到这一点。异常被吃掉了,因为垃圾收集是不确定的。我也无法让软件从析构函数中自动调用 Dispose()。 Jack V. 的评论很好地解释了这一点;这是他发布的链接,用于冗余/强调:

      The difference between a destructor and a finalizer?

      更改语法以使用回调是一种使行为万无一失的聪明方法,但商定的语法是固定的,我不得不使用它。我们公司就是关于流畅的方法链。老实说,我也是“输出参数”解决方案的粉丝,但最重要的是,方法签名根本无法更改。

      关于我的特定问题的有用信息包括我的软件作为单元测试套件的一部分运行 - 所以效率不是问题.

      我最终做的是使用 Mono.Cecil 来反映调用程序集(调用我的软件的代码)。请注意,System.Reflection 不足以满足我的目的,因为它无法精确定位方法引用,但我仍然需要(?)使用它来获取“调用程序集”本身(Mono.Cecil 仍然没有充分记录,所以我可能只需要更熟悉它才能完全取消 System.Reflection;这还有待观察....)

      我将 Mono.Cecil 代码放在 Init() 方法中,结构现在看起来像:

      public IntermediateClass<T> Init<T>()
      {
          ValidateUsage(Assembly.GetCallingAssembly());
          return new IntermediateClass<T>();
      }
      
      void ValidateUsage(Assembly assembly)
      {
          // 1) Use Mono.Cecil to inspect the codebase inside the assembly
          var assemblyLocation = assembly.CodeBase.Replace("file:///", "");
          var monoCecilAssembly = AssemblyFactory.GetAssembly(assemblyLocation);
      
          // 2) Retrieve the list of Instructions in the calling method
          var methods = monoCecilAssembly.Modules...Types...Methods...Instructions
          // (It's a little more complicated than that...
          //  if anybody would like more specific information on how I got this,
          //  let me know... I just didn't want to clutter up this post)
      
          // 3) Those instructions refer to OpCodes and Operands....
          //    Defining "invalid method" as a method that calls "Init" but not "Save"
          var methodCallingInit = method.Body.Instructions.Any
              (instruction => instruction.OpCode.Name.Equals("callvirt")
                           && instruction.Operand is IMethodReference
                           && instruction.Operand.ToString.Equals(INITMETHODSIGNATURE);
      
          var methodNotCallingSave = !method.Body.Instructions.Any
              (instruction => instruction.OpCode.Name.Equals("callvirt")
                           && instruction.Operand is IMethodReference
                           && instruction.Operand.ToString.Equals(SAVEMETHODSIGNATURE);
      
          var methodInvalid = methodCallingInit && methodNotCallingSave;
      
          // Note: this is partially pseudocode;
          // It doesn't 100% faithfully represent either Mono.Cecil's syntax or my own
          // There are actually a lot of annoying casts involved, omitted for sanity
      
          // 4) Obviously, if the method is invalid, throw
          if (methodInvalid)
          {
              throw new Exception(String.Format("Bad developer! BAD! {0}", method.Name));
          }
      }
      

      相信我,实际代码比我的伪代码更难看.... :-)

      但 Mono.Cecil 可能是我最喜欢的新玩具。

      我现在有一个拒绝运行其主体的方法,除非调用代码“承诺”之后还会调用第二个方法。这就像一种奇怪的代码契约。我实际上正在考虑使这个通用和可重用。你们中的任何人都对这样的东西有用吗?说,如果它是一个属性?

      【讨论】:

        【解决方案5】:

        如果你让InitWith 不返回FluentClass 类型的对象怎么办?让他们返回,例如,UninitializedFluentClass 包装了一个 FluentClass 对象。然后在UnitializedFluentClass 对象上调用.Save(0 在包装的FluentClass 对象上调用它并返回它。如果他们不调用Save,他们就不会得到FluentClass 对象。

        【讨论】:

          【解决方案6】:

          在调试模式下,除了实现 IDisposable 之外,您还可以设置一个计时器,如果未调用 resultmethod,则该计时器将在 1 秒后引发异常。

          【讨论】:

          • 如果系统过载,一秒钟可能会很短。您可以改为在终结器中引发异常。这两种技术都会增加开销,但计时器可能更昂贵。
          【解决方案7】:

          使用out 参数!必须使用所有outs。

          编辑:我不确定它是否会有所帮助,尽管... 它会破坏流畅的语法。

          【讨论】:

            猜你喜欢
            • 2021-10-14
            • 2021-02-09
            • 2014-01-30
            • 2018-04-09
            • 1970-01-01
            • 2011-12-05
            • 1970-01-01
            相关资源
            最近更新 更多