关于c#:多少空检查就足够了?

How much null checking is enough?

什么是什么时候没有必要检查空?

我最近一直在研究的许多继承代码都有空检查令人作呕。 Null检查普通函数,对表示非空返回的API调用进行空检查等。在某些情况下,空检查是合理的,但在许多地方,null不是合理的期望。

我听过很多论点,从"你不能相信其他代码"到"总是防守程序"到"直到语言保证我的非空值,我总会检查。"我当然同意这些原则中的许多原则,但我发现过多的空值检查会导致其他通常违反这些原则的问题。顽强的空检查真的值得吗?

通常,我观察到过多的空检查代码实际上质量较差,而不是质量较高。许多代码似乎都专注于空检查,开发人员已经忽略了其他重要的特性,例如可读性,正确性或异常处理。特别是,我看到很多代码忽略了std :: bad_alloc异常,但对new进行了空检查。

在C ++中,由于解除引用空指针的不可预测行为,我在某种程度上理解这一点;在Java,C#,Python等中更优雅地处理null dereference。我刚刚看到了警惕的空检查的不良例子,还是真的有什么东西可以解决这个问题?

这个问题旨在与语言无关,尽管我主要对C ++,Java和C#感兴趣。

我见过的一些空检查的例子似乎过多,包括:

这个例子似乎是非标准编译器的原因,因为C ++规范说失败的新抛出异常。除非您明确支持不合规的编译器,否则这有意义吗?这在Java或C#(甚至C ++ / CLR)等托管语言中是否有意义?

1
2
3
4
5
6
7
8
9
10
11
12
try {
   MyObject* obj = new MyObject();
   if(obj!=NULL) {
      //do something
   } else {
      //??? most code I see has log-it and move on
      //or it repeats what's in the exception handler
   }
} catch(std::bad_alloc) {
   //Do something? normally--this code is wrong as it allocates
   //more memory and will likely fail, such as writing to a log file.
}

另一个例子是在处理内部代码时。特别是,如果它是一个可以定义自己的开发实践的小团队,这似乎是不必要的。在某些项目或遗留代码中,信任文档可能不合理......但对于您或您的团队控制的新代码,这是否真的有必要?

如果您可以看到并且可以更新(或者可以对负责的开发人员大喊大叫)的方法有合同,是否仍然需要检查空值?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//X is non-negative.
//Returns an object or throws exception.
MyObject* create(int x) {
   if(x<0) throw;
   return new MyObject();
}

try {
   MyObject* x = create(unknownVar);
   if(x!=null) {
      //is this null check really necessary?
   }
} catch {
   //do something
}

在开发私有函数或其他内部函数时,当合约只调用非空值时,是否真的有必要显式处理null?为什么null检查比断言更可取?

(显然,在您的公共API上,空值检查至关重要,因为对于错误使用API??而对用户大喊大叫是不礼貌的)

1
2
3
4
5
6
7
8
//Internal use only--non-public, not part of public API
//input must be non-null.
//returns non-negative value, or -1 if failed
int ParseType(String input) {
   if(input==null) return -1;
   //do something magic
   return value;
}

相比:

1
2
3
4
5
6
7
8
//Internal use only--non-public, not part of public API
//input must be non-null.
//returns non-negative value
int ParseType(String input) {
   assert(input!=null :"Input must be non-null.");
   //do something magic
   return value;
}


有一点要记住,您今天编写的代码虽然可能是一个小团队而且您可以拥有良好的文档,但会变成其他人必须维护的遗留代码。我使用以下规则:

  • 如果我正在编写一个将向其他人公开的公共API,那么我将对所有引用参数进行空检查。

  • 如果我正在为我的应用程序编写一个内部组件,当我需要在存在null时执行某些特殊操作时,或者当我想使其非常清楚时,我会编写空检查。否则我不介意获取空引用异常,因为这也很清楚发生了什么。

  • 当处理来自其他人框架的返回数据时,我只检查null是否可能并且返回null是有效的。如果他们的合同说它没有返回空值,我将不会进行检查。


  • 首先请注意,这是一个特殊的合同检查案例:您编写的代码除了在运行时验证是否符合记录的合同之外什么都不做。失败意味着某些代码在某处出错。

    我总是对实施一个更普遍有用的概念的特殊情况略显怀疑。合同检查很有用,因为它在第一次跨越API边界时捕获编程错误。关于空值有什么特别之处,这意味着它们是您需要检查的合同的唯一部分?仍然,

    关于输入验证的主题:

    null在Java中是特殊的:编写了许多Java API,使得null是唯一无效的值,它甚至可以传递给给定的方法调用。在这种情况下,空检查"完全验证"输入,因此适用于合同检查的完整参数适用。

    另一方面,在C ++中,NULL只是指针参数可以采用的近2 ^ 32(在新架构上为2 ^ 64)无效值中的一个,因为几乎所有地址都不是正确类型的对象。除非您在该类型的所有对象中都有列表,否则无法"完全验证"您的输入。

    那么问题是,NULL是一个足够常见的无效输入,以获得(foo *)(-1)无法获得的特殊处理?

    与Java不同,字段不会自动初始化为NULL,因此垃圾未初始化的值与NULL一样合理。但有时候C ++对象的指针成员显然是NULL-inited,意思是"我还没有"。如果您的调用者执行此操作,则会出现一大类编程错误,可通过NULL检查进行诊断。他们调试异常可能比他们没有源代码的库中的页面错误更容易。因此,如果你不介意代码膨胀,它可能会有所帮助。但是你应该考虑的是你的来电者,而不是你自己 - 这不是防御性编码,因为它只能"防御"反对NULL,而不是反对(foo *)( - 1)。

    如果NULL不是有效输入,您可以考虑通过引用而不是指针来获取参数,但是许多编码样式不赞成非const引用参数。如果调用者通过你* fooptr,其中fooptr为NULL,那么无论如何它都没有任何好处。你要做的是在函数签名中加入更多的文档,希望你的调用者更有可能想到"嗯,这里可能是无效的吗?"当他们必须明确取消引用它时,而不是将它作为指针传递给你。它只是到目前为止,但就它而言,它可能会有所帮助。

    我不知道C#,但我知道它就像Java一样,引用保证具有有效值(至少在安全代码中),但与Java不同,并非所有类型都具有NULL值。所以我猜这些空检查很少有价值:如果你使用的是安全代码,那么不要使用可空类型,除非null是有效输入,如果你处于不安全的代码中,那么同样的推理也适用于在C ++中。

    关于输出验证的主题:

    出现类似的问题:在Java中,您可以通过了解其类型来"完全验证"输出,并且该值不为null。在C ++中,您无法通过NULL检查"完全验证"输出 - 因为您知道该函数返回了指向其自身堆栈上刚刚解开的对象的指针。但是如果由于被调用者代码的作者通常使用的构造,NULL是一个常见的无效返回,那么检查它将有所帮助。

    在所有情况下:

    使用断言而不是"真实代码"来尽可能检查合同 - 一旦你的应用程序工作,你可能不希望每个被调用者的代码膨胀检查其所有输入,并且每个调用者检查其返回值。

    在编写可以移植到非标准C ++实现的代码的情况下,那么代替检查null并且还捕获异常的问题中的代码,我可能有这样的函数:

    1
    2
    3
    4
    5
    6
    template<typename T>
    static inline void nullcheck(T *ptr) {
        #if PLATFORM_TRAITS_NEW_RETURNS_NULL
            if (ptr == NULL) throw std::bad_alloc();
        #endif
    }

    然后,作为移植到新系统时所做的事情之一,您可以正确定义PLATFORM_TRAITS_NEW_RETURNS_NULL(可能还有其他一些PLATFORM_TRAITS)。显然,您可以编写一个标题,为您知道的所有编译器执行此操作。如果有人接受你的代码并将其编译在你不了解的非标准C ++实现上,那么他们基本上是出于更大的原因而不是自己,所以他们必须自己做。

    好。


    如果您编写代码及其合同,您有责任在合同中使用它并确保合同正确。如果您说"返回非空"x,则调用者不应检查null。如果使用该引用/指针发生空指针异常,则表示您的合同不正确。

    当使用不受信任的库或没有合适的合同时,空检查应该只是极端。如果是您的开发团队的代码,请强调不得破坏合同,并在发生错误时错误地跟踪使用合同的人员。


    这取决于实际情况。我的其余部分假设是C ++。

    • 我从未测试过new的返回值
      因为我使用的所有实现
      失败时抛出bad_alloc。如果我
      看到新回归的遗留测试
      在我正在处理的任何代码中为null,我
      把它剪掉,不要费心去做
      用任何东西代替它。
    • 除非是小心眼的编码标准
      禁止它,我断言记录
      前提条件。破碎的代码
      违反已公布的合同需求
      立即失败
      大幅提升。
    • 如果null来自运行时
      失败不是因为破碎
      代码,我扔了。 fopen失败和
      malloc失败(虽然我很少
      曾经在C ++中使用它们会下降
      进入这一类。
    • 我不试图从中恢复
      分配失败。 Bad_alloc得到了
      陷入main()。
    • 如果是空测试
      是为了一个对象
      我班的合作者,我改写了
      通过引用获取它的代码。
    • 如果合作者真的可能不会
      存在,我使用Null对象
      设计模式来创建一个
      占位符在明确定义中失败
      方法。

    部分原因取决于代码的使用方式 - 例如,它是仅在项目中与公共API相关的方法。 API错误检查需要比断言更强大的功能。

    因此,虽然这在一个项目中很好,但它支持单元测试和类似的东西:

    1
    2
    3
    4
    5
    internal void DoThis(Something thing)
    {
        Debug.Assert(thing != null,"Arg [thing] cannot be null.");
        //...
    }

    在一种方法中,你无法控制谁调用它,这样的事情可能会更好:

    1
    2
    3
    4
    5
    6
    7
    8
    public void DoThis(Something thing)
    {
        if (thing == null)
        {
            throw new ArgumentException("Arg [thing] cannot be null.");
        }
        //...
    }


    一般来说,NULL检查是邪恶的,因为它会为代码可测试性添加一个小的否定令牌。对于任何地方的NULL检查,你不能使用"传递null"技术,它会在单元测试时命中你。对于方法进行单元测试比使用空检查更好。

    请查看关于该问题的正确演示文稿以及Misko Hevery的一般单元测试,网址为http://www.youtube.com/watch?v=wEhu57pih5w&feature=channel


    旧版本的Microsoft C ++(可能还有其他版本)没有通过new抛出失败的分配异常,但返回NULL。必须在符合标准的版本和旧版本中运行的代码将具有您在第一个示例中指出的冗余检查。

    使所有失败的分配遵循相同的代码路径会更清晰:

    1
    2
    if(obj==NULL)
        throw std::bad_alloc();

    我会说这取决于你的语言,但是我使用Resharper和C#,它基本上没有告诉我"这个引用可能为null",在这种情况下我添加一个检查,如果它告诉我"这个将永远是真的"为"if(null!= oMyThing && ....)"然后我听它不要测试null。


    是否检查是否取决于具体情况。

    例如,在我们的商店中,我们检查我们在方法中为null创建的方法的参数。原因很简单,作为原始程序员,我很清楚该方法应该做什么。即使文件和要求不完整或不尽如人意,我也理解上下文。后来负责维护的程序员可能无法理解上下文,并且错误地认为传递null是无害的。如果我知道null会有害并且我可以预期某人可能会传递null,我应该采取简单的步骤来确保该方法以优雅的方式作出反应。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public MyObject MyMethod(object foo)
    {
      if (foo == null)
      {
        throw new ArgumentNullException("foo");
      }

      // do whatever if foo was non-null
    }

    当我看到NULL时,我知道该怎么办,我只检查NULL。"知道该做什么"在这里意味着"知道如何避免崩溃"或"知道除了崩溃位置之外告诉用户的内容"。例如,如果malloc()返回NULL,我通常没有选项可以中止程序。另一方面,如果fopen()返回NULL,我可以让用户知道无法打开的文件名,可能是错误的。如果find()返回end(),我通常知道如何继续而不会崩溃。


    当您可以指定正在使用哪个编译器时,对于系统函数(例如"new")检查null是代码中的错误。这意味着您将复制错误处理代码。重复代码通常是错误的来源,因为通常会有一个更改而另一个不会。如果您不能指定编译器或编译器版本,则应该更具防御性。

    对于内部功能,您应指定合同并确保通过单元测试强制执行合同。我们的代码中有一个问题,我们要么抛出异常,要么在数据库丢失对象的情况下返回null。这只会让api的调用者感到困惑,所以我们经历了整个代码库中的一致性并删除了重复的检查。

    重要的是(恕我直言)是没有重复的错误逻辑,永远不会调用一个分支。如果你永远不能调用代码,那么就无法测试它,你永远不会知道它是否被破坏。


    众所周知,有程序导向的人(专注于以正确的方式做事)和以结果为导向的人(得到正确的答案)。我们大多数人都躺在中间的某个地方。看起来你已经找到了面向程序的异常值。这些人会说"除非你完全理解事情,否则一切皆有可能;所以要做好准备。"对他们来说,你看到的是正确的。对于他们来说,如果你改变它们,他们会担心,因为鸭子不是全部排成一列。

    在处理别人的代码时,我会尽力确保我知道两件事。
    1.程序员的意图
    2.为什么他们按照他们的方式编写代码

    对于跟进A类程序员,也许这有帮助。

    所以"多少就足够了"最终成为一个社会问题和技术问题 - 没有商??定的方法来衡量它。

    (它也让我疯狂。)


    就个人而言,我认为在绝大多数情况下,空测试是不必要的。如果新的失败或malloc失败,你会遇到更大的问题,如果你没有编写内存检查程序,恢复的可能性几乎为零!此外,null测试在开发阶段隐藏了很多错误,因为"null"子句通常只是空的并且什么都不做。


    我不认为这是不好的代码。在某种类型的失败时,相当数量的Windows / Linux API调用返回NULL。所以,当然,我以API指定的方式检查失败。通常我最终将控制流传递给某种方式的错误模块,而不是复制错误处理代码。


    我的第一个问题是它会导致代码中出现空检查等等。这会伤害可读性,我甚至会说它会损害可维护性,因为如果你正在写一段代码而某些引用确实永远不会为空,那么很容易忘记一个空检查。而且你只知道在某些地方会缺少空检查。这实际上使调试比它需要的更难。如果原始异常没有被捕获并被错误的返回值替换,那么我们将获得一个有价值的异常对象,其中包含一个信息丰富的堆栈跟踪。丢失的空检查给你什么?一段代码中的NullReferenceException让你走了:wtf?这个引用永远不应该为null!

    那么你需要开始弄清楚如何调用代码,以及为什么引用可能为null。这可能需要花费大量时间,并且会严重影响调试工作的效率。最终你会发现真正的问题,但很可能它隐藏得很深,你花了很多时间来搜索它而不是你应该拥有的。

    在所有地方进行空值检查的另一个问题是,一些开发人员在获得NullReferenceException时并没有真正花时间正确地考虑真正的问题。我实际上看到很多开发人员只是在发生NullReferenceException的代码上面添加一个空检查。太好了,例外不再发生!欢呼!我们现在可以回家了!嗯......怎么回事'不,你不能,你应该得到一个肘部的脸'?真正的错误可能不会再导致异常,但现在你可能有丢失或错误的行为......并且也不例外!哪个更痛苦,需要更多时间进行调试。


    如果我收到一个语言无法保证的指针不是null,并且我将以null将打破我的方式取消引用它,或者传递出我的函数我说我不会产生NULL,我检查是否为NULL。

    它不仅仅是关于NULL,如果可能的话,函数应检查前后条件。

    如果给我指针的函数的合约说它永远不会产生空值,那就没关系了。我们都犯了错误。有一个很好的规则,即程序应该尽早并且经常失败,因此我将失败,而不是将错误传递给另一个模块并使其失败。在测试时使事情变得更容易调试。此外,在关键系统中,可以更轻松地保持系统的正常运行。

    此外,如果异常转义为main,则堆栈可能不会被卷起,从而阻止析构函数运行(请参阅terminate()上的C ++标准)。这可能很严重。因此,保持bad_alloc未经检查可能比看起来更危险。

    断言与失败以及运行时错误是完全不同的主题。

    如果标准new()行为未被更改为返回NULL而不是抛出,则在new()之后检查NULL似乎已过时。

    还有一个问题,即即使malloc返回了一个有效的指针,它也不意味着你已经分配了内存并且可以使用它。不过那是另一回事了。


    较低级别的代码应检查更高级代码的使用。通常这意味着检查参数,但它可能意味着检查来自upcalls的返回值。不需要检查Upcall参数。

    目的是以立即和明显的方式捕获错误,并在代码中记录合同,而不是谎言。


    起初,这似乎是一个奇怪的问题:null检查是伟大的,是一个有价值的工具。检查new返回null肯定是愚蠢的。我只是会忽略这样一个事实:有些语言允许这样做。我确定有正当理由,但我真的不认为我能够处理那个现实生活:)除了开玩笑之外,看起来你至少应该指定new应该返回null内存不足。

    无论如何,在适当的地方检查null会导致更清晰的代码。我甚至可以说从不分配函数参数默认值是下一个逻辑步骤。更进一步,返回空数组等,在适当的情况下导致更清晰的代码。很高兴不必担心获得null s,除非它们具有逻辑意义。可以更好地避免将Null作为错误值。

    使用断言是一个非常好的主意。特别是如果它允许您在运行时关闭它们的选项。另外,它是一种更明确的契约风格:)