c - 与 c 中的 NULL 指针的比较
问题描述
这是来自教程的代码,用户在其中输入字符串的大小和字符串本身。代码应该使用内存分配来重现相同的字符串。我对代码几乎没有疑问-
- 为什么
*text
指针一开始就初始化为NULL?这种初始化在程序的后面部分是否有用,或者初始化为 NULL 是一种好习惯。 - 为什么将指针与NULL进行比较。一旦我们为指针分配一个字符串,地址不会改变吗?在字符串的末尾会指针指向NULL(无地址)吗?
- 有什么用
scanf(" ")
? - 释放
*text
指针后,它又被分配给 NULL。那么它是否有效地释放了内存?
#include <stdio.h>
#include <stdlib.h>
int main()
{
int size;
char *text = NULL; //---------------------------------------------------------->1
printf("Enter limit of the text: \n");
scanf("%d", &size);
text = (char *) malloc(size * sizeof(char));
if (text != NULL) //---------------------------------------------------------->2
{
printf("Enter some text: \n");
scanf(" "); //---------------------------------------------------------->3
gets(text);
printf("Inputted text is: %s\n", text);
}
free(text);
text = NULL;//---------------------------------------------------------->4
return 0;
}
解决方案
为什么 *text 指针一开始就初始化为 NULL?
主要是为了保护你免受你自己的人性伤害。随着代码的发展,通常很容易忘记在一个或多个代码分支中初始化指针,然后取消引用未初始化的指针 - 这是未定义的行为,因此不能保证会崩溃。在最坏的情况下,如果您不使用 Valgrind 之类的适当工具(它会立即指出),您可能会花费数小时或数天时间来发现此类问题,因为它是不可预测的,并且行为会根据调用之前堆栈上还有什么 - 因此您可能会在完全不相关且完全没有错误的代码中看到“错误”。
为什么将指针与NULL进行比较。
因为malloc
可以返回 NULL 并且仅仅因为它返回它并不意味着您可以取消引用它。空指针值很特殊:它的意思是“嘿,这个指针无效,不要用它做任何事情”。因此,在取消引用从 返回的任何内容之前malloc
,您必须检查它是否不为空。否则是未定义的行为,当出现这种行为时,现代编译器可能会对您的代码做一些意想不到的事情。但在提出这样的问题之前,我建议您始终检查您想知道的实际设计功能是什么。谷歌cppref malloc
和第一个命中是:https ://en.cppreference.com/w/c/memory/malloc 。在那里,在Return value的标题下,我们读到:
失败时,返回一个空指针。
这就是为什么它将指针与 NULL 进行比较的原因!
scanf("") 有什么用?
那很简单:你可以自己查一下。C 标准库有据可查:https ://en.cppreference.com/w/c/io/fscanf
当您阅读它时,相关部分是:
- 格式:指向以空字符结尾的字符串的指针,指定如何读取输入。格式字符串由 [...] 空白字符组成:格式字符串中的任何单个空白字符都会消耗输入中所有可用的连续空白字符(就像在循环中调用 isspace 一样确定)。请注意,格式字符串中的“\n”、“”、“\t\t”或其他空格之间没有区别。
这就是你的答案:scanf(" ")
将消耗输入中的任何空白字符,直到它到达 EOF 或第一个非空白字符。
在释放 *text 指针后,它又被分配给 NULL。那么它是否有效地释放了内存?
不。首先,这里使用的语言是错误的:指针被赋值为 NULL。什么都没有分配!指针就像一个邮政地址。你可以用“NOWHERE”来代替它,这就是NULL。但是,在您的通讯录中添加诸如“此人没有地址”之类的内容,您并没有“分配”任何东西。
是的 -free
确实释放了内存。然后您可以将其设置为 NULL,因为您是人类,这样您就不会轻易忘记指针的值不再有效。在这种情况下,它是“给自己的便条”。人们往往会忘记指针为空,然后会使用它。这种使用是未定义的行为(您的程序可以做任何事情,例如擦除硬盘驱动器)。因此,text = NULL
分配与机器无关。它与您有关:人类并不完美,最好进行防御性编程,以便您在更改代码或在截止日期压力下工作等时减少引入错误的机会。
一般来说,在这样一个简单的短节目中NULL
,末尾的赋值main
是不必要的。但是你必须认识到在它被-dtext
之后不能被取消引用的事实。free
就个人而言,我发现最好利用 C 语言赋予变量词法范围的属性。一旦作用域结束,变量就无法访问,所以你不能编写一个可以使用的错误text
——它不会编译。这被称为“设计上的正确性”:您以这样一种方式设计软件,使某些错误通过构造是不可能的,如果您编写错误代码,那么代码将无法编译。这比在运行时捕获错误要好一百万倍,或者更糟 - 必须调试它,可能在不相关的代码中(请记住:未定义的行为是令人讨厌的 - 它通常表现为远离源代码数千行的问题)。
所以这就是我为了解决这个问题而重写它的方式(还有其他问题仍然存在):
#include <stdio.h>
#include <stdlib.h>
void process_text(int size)
{
char *const text = malloc(size * sizeof(char));
if (!text) return;
printf("Enter some text: \n");
scanf(" ");
gets(text);
printf("Inputted text is: %s\n", text);
free(text);
}
int main()
{
int size;
printf("Enter limit of the text: \n");
scanf("%d", &size);
process_text(size);
}
的范围text
仅限于 的块process_text
。您在声明时立即对其进行初始化:这始终是首选。无需先将其设置为 NULL,因为您会立即分配所需的值。您检查是否可能malloc
返回了 NULL(即,它未能分配内存),如果是,则立即从函数返回。NULL 检查惯用地写为if (pointer) /* do something if the pointer is non-null */
或if (!pointer) /* do something if the pointer IS NULL */
。这种方式不那么冗长,任何阅读此类代码的人都应该知道如果他们有任何经验,这意味着什么。现在你也知道这样的代码是什么意思了。意识到这个成语并不是一个大障碍。它减少了打字和分心。
一般来说,提前返回的代码应该优先于嵌套if
块和无休止的缩进级别。当一个函数在完成它的工作之前有多个检查时,它们通常以嵌套if
语句结束,使函数更难阅读。
有一个反面:在 C++ 中,代码应该利用 C++(即它不仅仅是用 C++ 编译器编译的 C),并且从函数返回时必须释放的资源应该由生成的编译器自动释放调用析构函数的代码。但是在 C 中没有进行这样的自动析构函数调用。所以如果你提前从一个函数返回,你必须确保你已经释放了之前分配的所有资源。有时嵌套if
语句对此有所帮助,因此您不应该在不了解建议的上下文和假设的情况下盲目地遵循一些建议:)
虽然这确实是一个偏好问题 - 而且我有 C++ 背景,上面编写的代码更自然 - 在 C 中,最好不要早点返回:
void process_text_alternative_version(int size)
{
char *text = malloc(size * sizeof(char));
if (text) {
printf("Enter some text: \n");
scanf(" ");
gets(text);
printf("Inputted text is: %s\n", text);
}
free(text);
}
的值text
仅在它不为空时使用,但我们不会提前从函数返回。这确保了在所有情况下都将释放指向的内存块text
(如果有的话)!这一点非常重要:这是编写设计正确的代码的另一种方式,即以一种使某些错误不可能或更难犯的方式。如上所述,您无法忘记释放内存(除非您在内部某处添加 return 语句)。
必须说,尽管在 C 语言库的设计中做出的一些决定是残酷的,但它的接口free
还是经过深思熟虑的,以使上述代码有效。free
明确允许传递一个空指针。当您向它传递一个空指针时 - 例如,当malloc
上面未能分配内存时 - 它什么也不做。也就是说:“释放”一个空指针是一件非常有效的事情。它没有做任何事情,但它不是一个错误。它可以像上面那样编写代码,很容易看出在所有情况下text
都会被释放。
一个非常重要的推论free
:(在 C 中)或(在 C++ 中)之前的空指针检查delete
表明代码的作者不知道free
and的最基本行为delete
:它通常表明代码将被编写为如果是凡人无法理解的黑色魔法。如果作者不明白,那就是。但我们可以而且必须做得更好:我们可以自我教育关于我们使用的函数/运算符的作用。它已记录在案。查找该文档不需要花钱。人们花费了很长时间来确保任何人都可以看到文档。忽略它是恕我直言精神错乱的定义。在狂野的过山车上,这完全是不合理的。对于我们当中理智的人:只需要在某处包含单词cppref的谷歌搜索。您将在顶部获得cppreference链接,这是一个可靠的资源 - 并且是协作编写的,因此您可以修复您注意到的任何缺点,因为它是一个 wiki。它被称为“cpp”引用,但它实际上是两个引用合二为一:一个C++ 引用和一个C 引用。
不过,回到有问题的代码:有人可以这样写:
void process_text_alternative_version_not_recommended(int size)
{
char *text = malloc(size * sizeof(char));
if (text) {
printf("Enter some text: \n");
scanf(" ");
gets(text);
printf("Inputted text is: %s\n", text);
free(text);
}
}
text
它同样有效,但这样的形式违背了目的:总是被释放的一目了然。您必须检查if
块的状况以说服自己它确实会被释放。这段代码暂时还可以,几年后有人会改变它,让它变得更漂亮if
。现在你得到了内存泄漏,因为在某些情况下malloc
会成功,但是free
不会被调用。你现在希望未来的程序员,在压力和压力下工作(几乎总是如此!)会注意到并抓住问题。防御性编程意味着我们不仅可以保护自己免受错误输入(无论是错误的还是恶意的)的影响,还可以保护自己免受人类固有的错误。因此,我认为使用第一个替代版本最有意义:无论您如何修改if
条件,它都不会变成内存泄漏。但请注意:如果测试被破坏使得尽管指针为空但主体仍会执行,那么弄乱if
条件可能会将其变成未定义的行为。if
有时,我们不可能完全保护自己免受我们的伤害。
就 constness 而言,有 4 种方式来声明text
指针。我将解释它们的含义:
char *text
- 指向非常量字符的非常量指针:指针可以稍后更改以指向其他内容,并且它指向的字符也可以更改(或者至少编译器不会阻止您正在做)。char *const text
- 一个指向非 const 字符的 const 指针 - 指针本身不能在这一点之后更改(如果您尝试,代码将无法编译),但允许更改字符(编译器不会抱怨但这并不意味着这样做是有效的——程序员要了解情况是什么)。const char *text
- 指向 const 字符的非 const 指针:稍后可以更改指针以指向其他位置,但不能使用该指针更改它指向的字符 - 如果您尝试,代码将不会编译。const char *const text
- 指向 const 字符的 const 指针:指针在定义后不能更改,也不能用于更改它指向的字符 - 尝试这样做会阻止代码编译。
我们选择了变体 #2:指向的字符不能保持不变,因为gets
肯定会改变它们。如果您使用变体 #4,代码将无法编译,因为gets
需要一个指向非常量字符的指针。
选择 #2 我们不太可能把它搞砸,而且我们很明确:这里的这个指针在这个函数的其余部分将保持不变。
我们还在free
离开函数之前立即使用指针:在它被释放后我们不会无意中使用它,因为在free
.
这种编码风格可以保护您免受自己的人性侵害。请记住,许多软件工程与机器无关。机器不太关心代码的可理解性:它会按照它的指示去做——代码对于任何人来说都是完全无法理解的。机器一点也不在乎。唯一受到代码设计影响的实体(正面或负面)是人类开发人员、维护人员和用户。他们的人性是他们存在的一个不可分割的方面,这意味着他们是不完美的(与通常完全可靠的机器相反)。
最后,这段代码有一个大问题——它又与人类有关。实际上,您要求用户输入文本的大小限制。但假设必须是人类——作为人类——总是会把事情搞砸。如果你责怪他们把事情搞砸了,那你就完全错了:犯错是人之常情,如果你假装不这样,那么你只是一只把头埋在沙子里假装没有问题的鸵鸟。
用户很容易出错并输入比他们声明的大小更长的文本。这是未定义的行为:此时程序可以做任何事情,包括擦除硬盘驱动器。这甚至不是一个玩笑:在某些情况下,可以人为地为该程序创建一个输入,从而导致硬盘驱动器确实被擦除。您可能认为这是一种遥远的可能性,但事实并非如此。如果您在 Arduino 上编写此类程序并连接 SD 卡,我可以为大小和文本创建输入,这将导致 SD 卡的内容归零 - 甚至可能是一个可以全部输入的输入不使用特殊控制字符的键盘。我在这里是 100% 认真的。
是的,通常这种“未定义的行为意味着你将格式化你的硬盘”是开玩笑的,但这并不意味着它在正确的情况下不能成为一个真实的陈述(通常情况下成本越高,它变得更真实 - 这就是生活)。当然,在大多数情况下,用户不是恶意的——只是容易出错:他们会烧毁你的房子,因为他们喝醉了,而不是因为他们想杀了你——我敢肯定,这是一个很棒的安慰!但是,如果您遇到的用户是对手 - 哦,男孩,他们绝对会利用所有此类缓冲区溢出错误来接管您的系统,并很快让您认真考虑您的职业选择。也许美化没有'
在第二轮修复中,我们需要摆脱gets
调用:这基本上是原始 C 标准库的作者所犯的一个大而荒谬的错误。当我说几十年来已经损失了数百万甚至数十亿美元时,我并不是在开玩笑,因为gets
同样不安全的接口永远不应该诞生,并且因为程序员一直在不知不觉中使用它们,尽管它们固有地被破坏、危险和不安全的设计. 问题是什么:好吧,你到底怎么知道gets
限制输入的长度以实际适合你提供的内存?可悲的是,你不能。gets
假设你是程序员没有犯错,并且无论输入来自哪里都适合可用的空间。尔格gets
完全被破坏了,任何合理的 C 编码标准都会简单地声明“gets
不允许调用”。
是的。忘了gets
。忘记你看到的人们打电话的任何例子gets
。他们都错了。他们每一个人。我是认真的。所有使用的代码gets
都被破坏了,这里没有限定。如果您使用gets
,您基本上是在说“嘿,我没有什么可失去的。如果某个大型机构暴露了数百万用户的数据,我可以接受被起诉并在此后不得不住在桥下”。我敢打赌,你不会因为被一百万愤怒的用户起诉而感到高兴,所以故事就这样gets
结束了。从现在开始它就不存在了,如果有人告诉你使用gets
,你需要奇怪地看着他们并告诉他们“你在说什么WTF?你疯了吗?!”。这是唯一正确的回应。就是这么糟糕的问题。这里不夸张,我不是想吓唬你。
至于做什么而不是gets
?当然,这是一个很好解决的问题。请参阅此问题以了解您应该了解的所有信息!
推荐阅读
- c# - 实体框架 6.4 在表上复制数据库名称,例如:DatabaseName.DatabaseName.TableName
- python - 在 Dask Dataframe 上使用 set_index() 并写入 parquet 会导致内存爆炸
- kotlin - Ktor - 使用协程发布未处理的错误
- express - 将带有 OIDC 的快速服务器部署到 Azure 应用服务时出现端口错误
- java - 26. 从有序数组中删除重复项 - Java
- javascript - 比较 javascript/angular 中的两个对象数组并在第一个条件满足时立即返回(任何 lodash 运算符)
- go - MQTT 版本 5 在 paho.mqtt.golang 中发布属性?
- python - 从节点连接读取描述符失败
- coq - 当我知道磁头不会失败时,我可以避免使用选项 A 吗?
- google-drive-api - 使用 http 上传到 Google Drive 文件夹