在指针使用之前检查是否为空。

10

大多数人使用指针时都是这样的...

if ( p != NULL ) {
  DoWhateverWithP();
}

然而,如果由于任何原因指针为null,则该函数将不会被调用。

我的问题是,不检查NULL可能更有益吗?显然,在安全关键系统中,这不是一个选项,但是如果程序仍然可以运行而不调用函数,那么程序崩溃的情况比函数未被调用更容易被发现。

与第一个问题相关的是,您是否总是在使用指针之前检查NULL?

其次,考虑您有一个以指针作为参数的函数,并且您在程序中多次使用此函数在多个指针上。您是否认为在函数中测试NULL更有益(好处是您不必在所有地方都进行NULL测试),还是在调用函数之前对指针进行测试(好处是避免从调用函数中产生的开销)?


19
大多数人在过马路前会四处张望,以确定是否可能被来车撞到。但这并非必要,你可以通过简单地穿过马路来找出是否会被车撞上。 :-) - Franci Penov
@Franci:大多数人会检查汽车,如果有汽车,就等待。许多程序也会检查汽车,如果有汽车,就跳到下一个斑马线。 - xtofl
1
我尽量避免这个问题,尽可能通过引用传递来解决。 - Steve Guidi
@Steve和其他人。显然这是理想的,但并不总是可行的选择。 - trikker
13个回答

15

你的想法没错,空指针常常会导致立即崩溃,但是不要忘记,如果你通过一个空指针对大数组进行索引,如果你的索引足够高,则确实可能获得有效的内存地址。然后,你就会遇到内存损坏或错误的内存读取,这将更难以定位。

每当我可以假设使用空指针调用函数是一个错误,绝不会在生产代码中发生时,我更喜欢在函数中使用ASSERT守卫进行保护,在调试版本中只编译为真正的代码,而不检查NULL。

从我的角度来看,一般来说,函数应该检查它的参数,而不是调用者。你应该始终假设你的调用者可能有点粗心大意,或者他们可能包含漏洞...

道德:在被调用的函数中检查NULL,可以通过一些if()语句抛出异常,或使用一些ASSERT构造(可能带有一个明确的消息,说明为什么会发生这种情况)。 同样,检查调用者中的NULL,但只有在调用者知道在正常程序执行中可能会发生这种情况并相应地采取行动时才这样做。


在我反复思考一段时间后,我终于得出结论:在空指针上调用函数会导致未定义的行为,即使它会让程序以壮烈的方式崩溃,也是一种未定义的壮烈。我宁愿检查是否为空,也不想冒这个险。 - trikker

11

当程序出现 NULL 指针时,如果可以接受程序直接崩溃,我倾向于使用:

assert(p);
DoWhateverWithP();

在调试版本中,这只会检查指针的情况,因为定义 NDEBUG 往往会在预处理器级别上 #undef assert()。 它记录了您的假设并有助于调试,但对发布的二进制文件没有任何性能影响(尽管,说实话,在绝大多数情况下检查 NULL 指针对性能应该没有实质性影响)。

作为一个副作用,这对于 C 和 C++ 同样合法,并且在后一种情况下,不需要在编译器/运行时启用异常。

关于您的第二个问题,我更喜欢将断言放在子例程的开头。 再次强调 assert() 的美妙之处在于它实际上没有什么“开销”。 因此,在子例程定义中只需要一个断言的好处是无需考虑其他权衡因素。

当然,警告是永远不要断言带有副作用的表达式:

assert(p = malloc(1)); // NEVER DO THIS!
DoSomethingWithP();    // If NDEBUG was defined, malloc() was never called!

assert存在的问题是它会在调试模式下引起额外的延迟。因此,时间与发布版本不同。特别是在实时应用程序中,如果有许多asserts,则整个程序可能会停止工作。 - PauliL
我曾经在处理一些代码,其中包含类似于 assert(ValidGraph(g)) 的语句,其中 ValidGraph() 是一个昂贵的例程,用于检查图的许多特征。这个语句被放置在一个循环内部,并导致调试版本无法使用(而且该应用程序不是实时的)。 - mrkj
assert()并不是万能的解决方案,但我相信在许多常见、现实的情况下,它是一个很有吸引力的解决方案。 - mrkj
1
assert存在的问题是它会在调试模式下引起额外的延迟。因此,时间与发布版本不同。特别是在实时应用程序中,如果有许多asserts,则整个程序可能会停止工作。如果您依赖于调试构建与发布构建执行相同,那么您就错了。STL在调试模式下通常要慢得多,这在第三方库中很常见。 - Mr. Boy

10

不要仅仅检查空指针并在找到空指针时什么都不做。

如果指针允许为空,那么您必须考虑当其实际为空时您的代码会发生什么。通常,什么都不做是错误的答案。通过细心,可以定义像这样工作的API,但这需要远不止于在各个地方散布一些NULL检查。

因此,如果指针允许为空,则必须检查空指针,并执行任何适当的操作。

如果指针不允许为空,则编写在其为空时调用未定义行为的代码是完全合理的。这与编写字符串处理例程、如果输入未以NUL结尾则调用未定义行为的例程、编写使用缓冲区且调用方传递了错误长度值的例程或者编写一个接受file *参数的函数,并在用户传递文件描述符reinterpret_cast到file *中调用未定义行为没有任何区别。在C和C++中,您只需要能够依赖调用方告诉您什么就好,垃圾进去,垃圾出来。

但是,您可能希望编写帮助调用方(毕竟他们很可能是您自己)的代码,当传递的最可能是垃圾时。断言和异常对此很有帮助。

从Franci在问题评论中提出的类比中可以得出:大多数人在穿过人行道或在沙发上坐下之前不寻找汽车。他们仍然可能被汽车撞击。这种情况经常发生。但通常认为,在这些情况下花费任何精力检查汽车或者在罐头汤的说明书上写“首先,请检查厨房中是否有汽车。然后加热汤”是偏执的。

您的代码也是如此。将无效值传递给函数比意外驾驶汽车进入某人的厨房要容易得多。但如果司机这样做并且撞到了某人,那么这仍然是司机的过错,而不是厨师未行使谨慎责任。您不一定希望厨师(或被调用方)用应该是多余的检查来混淆他们的食谱(代码)。

除了使用单元测试和调试器等方式,还有其他方法可以找到问题。无论如何,在不必要的地方(道路)创造一个无车环境要比在任意地方随意驾驶汽车并希望每个人都能随时应对更为安全。因此,如果你在不允许的情况下检查 null 值,你不应该让这给人们留下它是被允许的想法。

[编辑 - 我刚刚遇到一个检查 null 值无法找到无效指针的 bug 示例。我将使用一个 map 来保存一些对象,并使用指针来表示一个图,这很好,因为 map 从不重新定位其内容。但我尚未为这些对象定义排序(这可能有点棘手)。所以,为了推动事情并证明一些其他代码的工作,我使用了一个 vector 和一个线性搜索而不是一个 map。没错,我不是指 vector,我是说 deque。所以,第一次 vector 调整大小后,我没有传递空指针到函数中,但我传递了指向已释放内存的指针。

我犯的愚蠢错误几乎与我无效地传递 null 指针的愚蠢错误相同频率。因此,无论我是否添加了检查 null,我仍然需要能够诊断程序因我无法检查的原因而崩溃的问题。由于这也将诊断空指针访问,除非我正在编写代码以通常检查函数进入前提条件,否则我通常不会检查 null。


经过一夜的思考,我稍微重新考虑了这个问题。由于程序的性质,在任何情况下指针都不应该为NULL。然而,在程序员未能将指针初始化为正确值或像(if p = NULL)这样愚蠢的操作下,检查NULL可能是有用的。仅仅看到程序崩溃并没有什么帮助,因此在发生这种情况时打印一条消息似乎是理想的(仅在调试模式下)。 - trikker
1
仅仅看到程序崩溃并不是非常有帮助 - 如果您在调试器下运行测试,通常情况下会有所帮助。但是,根据您的平台,您可能没有可用的调试器。即使有调试器,您也可能不总是使用它。因此,我认为开关式检查,例如assert,是有用的。 - Steve Jessop
如果我的程序崩溃了,我通常会快速浏览一下我认为可能存在的问题。如果我找不到任何问题,或者我完全错了,我就会去调试器。然而,一个显示“空指针错误”的消息肯定会让搜索变得更容易,因为我知道要寻找什么。 - trikker

8
我喜欢这种风格:
if (p == NULL) {
    // throw some exception here
}

DoWhateverWithP();

这意味着,如果pNULL,那么包含此代码的函数将会迅速失败。您是正确的,如果pNULL,那么DoWhateverWithP函数无法执行,但使用空指针或简单地不执行函数都是处理pNULL的不可接受方式。

重要的是要记住尽早退出并快速失败 - 这种方法可以产生更易于调试的代码。


4
+1 表示抛出异常。异常可使代码更易读且更易于调试。 - Paulo Santos
6
抛出异常的得分为-1。这是真正的特殊情况,还是你的代码必须能够处理的常规事情? - mch
@mch,如果程序必须能够处理它,那么崩溃肯定不是一个选项。这就是正在讨论的问题。 - Bahbar
1
@mch,是的,这是一个异常情况。 - rpg

4

除了其他答案,这要取决于NULL表示什么。例如,以下代码完全可以,并且非常惯用:

while (fgets(buf, sizeof buf, fp) != NULL) {
    process(buf);
}

在这里,NULL 值不仅表示错误,还表示文件结束的条件。同样地,strtok() 返回 NULL 表示“没有更多的标记”(虽然应该避免使用 strtok(),但我偏离了主题)。在这种情况下,如果返回的指针不是 NULL,则调用函数是完全可以的,否则什么也不做。 编辑:另一个更接近要求的例子:
const char *data = "this;is;a;test;";
const char *curr = data;
const char *p;
while ((p = strchr(curr, ';')) != NULL) {
    /* process data in [curr, p) */
    process(curr, p);
    curr = p + 1;
}

再次提醒,这里的NULLstrchr()无法找到;的指示,我们应该停止进一步处理数据。
话虽如此,如果不使用NULL作为指示,那么就取决于以下几点:
  • 如果在代码的这一点上指针不能为NULL,则在开发时使用assert(p != NULL);很有用,并且还要使用fprintf(stderr, "Can't happen\n");或类似语句,然后根据情况采取适当的操作(在这一点上,abort()或类似的选择可能是唯一明智的选择)。
  • 如果指针可以是NULL,并且它不是关键性的,则最好绕过空指针的使用。假设您尝试为编写日志消息分配内存,并且malloc()失败了。因为这个原因而中止程序是不合适的。如果malloc()成功,则希望调用函数(sprintf()/等)来填充缓冲区。
  • 如果指针可以是NULL,并且它是关键性的。在这种情况下,您可能想要失败,希望这种情况不会经常发生。

其次,考虑您有一个函数,该函数将指针作为参数,并且您在整个程序中多次使用此函数。您认为在函数中测试NULL更有益(好处是您不必在所有地方测试NULL),还是在调用函数之前对指针进行测试更有利(好处是没有从调用函数产生的开销)?

这取决于很多因素。如果我能确信有时或大多数情况下传递给函数的指针不能为NULL,则函数中的额外检查是浪费的。如果传递的指针来自许多地方,并且在所有地方放置检查都很棘手,那么在函数本身中进行检查肯定是有好处的。
标准库函数在大多数情况下不检查NULLstr*mem*函数等。 free()是一个例外,它确实检查NULL
关于assert的注释:assert如果定义了NDEBUG,则是无操作,因此不应将其用于调试——它唯一的用途是在开发过程中捕获编程错误。此外,在C89中,assert需要一个int,因此在这种情况下,assert(p != NULL)比仅使用assert(p)更好。

2

可以通过使用引用而不是指针来避免此非NULL检查。这样,编译器确保传递的参数不为NULL。例如:

void f(Param& param)
{
    // "param" is a pointer that is guaranteed not to be NULL      
}

在这种情况下,检查工作由客户端负责。然而,大多数情况下,客户端的情况会是这样的:
Param instance;
f(instance);

不需要进行非空性检查。

当在堆上分配的对象中使用时,您可以执行以下操作:

Param& instance = *new Param();
f(*instance);
更新: 如用户 Crashworks 所述,仍有可能导致程序崩溃。但是,当使用引用时,客户端有责任传递有效的引用,正如我在示例中所展示的那样,这非常容易做到。

1
一个引用是可能为空的。例如,如果我调用 int *foo = NULL; f(*foo); 这将编译并调用f,然后在实际使用引用时导致段错误。 - Crashworks
显然你不会故意这样解引用一个空指针,但在大型程序中可能会发生这种情况,其中某些变量通常存储为指针,并且你将其解引用以传递到类似的函数中。我已经修复了许多此类崩溃。 - Crashworks
1
@Crashworks:在这些罕见的情况下,我建议您在客户端代码中进行非NULL性检查。 - Dimitri C.
在C++中,有时候无法避免使用指针。许多标准库和外部API都使用指针,因此你不得不将它们强制转换为引用,这使得代码变得更加丑陋。在我看来,这是使用C++的一部分。 - Mr. Boy
@John:你能解释一下“if (ptr!=NULL) f(*ptr)”这个解决方案有什么不好吗? - Dimitri C.
显示剩余2条评论

1
怎么样:添加一条澄清意图的注释?如果意图是“这不可能发生”,那么使用断言语句可能比if语句更合适。
另一方面,如果空值是正常的情况,也许需要一个“else注释”来解释为什么我们可以跳过“then”步骤。Stevel McConnel在《代码大全》中有一个很好的章节介绍了if/else语句,以及缺少else语句是一个非常常见的错误(分心、忘记了?)。
因此,我通常会为“无操作else”添加注释,除非它是“如果完成,则返回/中断”的形式。

1

当你检查 NULL 时,仅仅跳过函数调用并不是一个好主意。你应该有一个else部分,在 NULL 的情况下做一些有意义的事情,例如抛出错误或将错误代码返回到上层。

另一方面,NULL 并不总是表示错误。它经常用于指示数据已经结束。在这种情况下,你需要像处理正常程序流程一样处理这种情况。


0

首先回答第一个问题:你谈论的是理想情况,我看到的大部分使用if ( p != NULL )的代码都是遗留代码。另外,假设你想返回一个评估器,并使用数据调用该评估器,但是如果没有适用于该数据的评估器,则返回NULL并在调用评估器之前检查NULL是有逻辑意义的。

第二个问题的答案取决于具体情况,例如删除操作会检查NULL指针,而许多其他函数则不会。有时,如果在函数内部测试指针,则可能需要在许多函数中进行测试,例如:

ABC(p);
a = DEF(p);
d = GHI(a);
JKL(p, d);

但是这段代码会更好:

if(p)
{
 ABC(p);
 a = DEF(p);
 d = GHI(a);
 JKL(p, d);
}

0

我认为我已经看到了更多。这样,如果您知道它会爆炸,您就不会继续进行。

if (NULL == p)
{
  goto FunctionExit; // or some other common label to exit the function.
}

1
额... Dijkstra 不会赞同的:http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html - mrkj
我不赞成普通人使用C++。这只是我在实际应用中所见到的情况...因为他/她曾经被C++咬伤过很多次,所以他不相信任何传入的数据...每一步调用链都会检查相同的参数。 - Gishu
也许是因为异常更加繁琐且更加显眼,这正是作者想要避免的。 - Gishu
3
我认为常见的标签被称为 return; - GManNickG
2
这正是析构函数和RAII的作用所在。 - Crashworks
@Crashworks:这需要代码的作者知道自己在做什么,显然这里并非如此。 - MSalters

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