在C语言中从函数返回一个数组

3

我编写了一个返回数组的函数,但我知道应该返回动态分配的指针。然而,我仍然想知道当我返回在函数内部声明的数组(未将其声明为静态)时会发生什么,当我注意到内部数组的内存没有被释放并成功将这个数组返回给主函数时,感到很惊讶。

主函数:
int main()
{
    int* arr_p;
    arr_p = demo(10);

    return 0;
}

并且这个函数:


int* demo(int i)
{
    int arr[10] = { 0 };
    for (int i = 0; i < 10; i++)
    {
        arr[i] = i;
    }
    return arr;
}

当我解引用arr_p,我可以在demo函数中看到0-9整数集。

两个问题:
  1. 为什么当我检查arr_p时,我看到它的地址与demo函数中的arr相同?
  2. 为什么demo_p指向的数据(0-9数字)不是已经在demo函数中释放了?我期望demo内部的arr将在我们退出demo作用域时被释放。

2
函数在执行时会在一个叫做“堆栈帧”的内存块中运行,该内存块适用于该函数的任何本地变量。当您退出demo()函数时,在大多数情况下,内存不会被销毁,这就是为什么您看到了那个结果。但是,如果您在demo()函数之后调用另一个函数,那么您拥有的数组几乎肯定会无效。请参考https://en.m.wikipedia.org/wiki/Call_stack#STACK-FRAME。 - Expert Thinker El Rey
2
我的函数内部数组的内存没有被释放。它确实被释放了,只是还没有被覆盖。但是它随时可以被覆盖。类比:你退房离开旅馆房间,留下一堆烂摊子。几天后你闯进去发现烂摊子还在那里。但是没有保证它仍然会在那里,当然也不会继续存在。真正的所有者随时可以进来清理干净。 - kaylum
5个回答

4
编程时需要注意的一点是要牢记规则,而不仅仅是看起来好用的代码。规则说你不能返回指向局部分配的数组的指针,这是一个真正的规则。
如果你写一个程序并返回指向局部分配数组的指针,却没有错误提示,这并不意味着它没问题(虽然这意味着你应该使用更新的编译器,因为任何像样的现代编译器都会警告此问题)。
如果你写了一个看起来工作正常的返回指向局部分配的数组的指针的程序,这也并不意味着它没问题。在编程中,尤其是在 C 中,看起来工作正常并不意味着你的程序就是正确的。你真正想要的是让你的程序因为正确的原因而工作。
假设你租了一间公寓,当你的租期结束并且你搬出去时,你的房东没有收回你的钥匙,但也没有换锁。几天后,你意识到你忘记了某件东西放在一个衣橱里,于是你不请自来地回去取。下面会发生什么?
- 事实上,你的钥匙还能打开门锁。这是完全意外的、有点出乎意料的,还是可以完全预料的? - 事实上,你忘记的物品还在衣橱里。它还没有被清理掉。这是完全意外的、有点出乎意料的,还是可以完全预料的? - 最终,你的老房东和警察都没有因为你的非法入侵而拦住你。这是完全意外的、有点出乎意料的,还是几乎完全可以预料到的?
你需要知道的是,在 C 中,重用不再允许使用的内存与悄悄回到已经不再租的公寓中类似。它可能会起作用,也可能不会。你的东西可能还在那里,也可能不在了。你可能会惹麻烦,也可能不会。无法预测会发生什么,无论发生了什么,也无法得出(有效)结论。
回到你的程序:像 arr 这样的局部变量通常存储在调用栈上,这意味着即使函数返回后它们仍然存在,并且直到下一个函数被调用并使用该栈区域进行其自己的操作(甚至可能不止),才会被覆盖。因此,如果你返回指向局部分配的内存的指针,并立即对该指针进行解引用(在调用任何其他函数之前),它至少有一定的概率“起作用”。这再次类比于公寓情况:如果还没有其他人搬进去,你忘记的物品仍然很可能在那里。但这显然不是你可以依赖的东西。

这个答案中提出的类比让我非常想起了这个非常相似的类比 - Andreas Wenzel
@AndreasWenzel “不,你在说什么呢,它们完全不同!你提到的比喻是关于C++的,但这个是关于C的!” :-) - Steve Summit
@AndreasWenzel 说真的,是的,那个不错。我以前见过它,但在写这个答案时忘记了它。(还没注意到kaylum上面非常相似的评论。) - Steve Summit

3

arr 是在函数 demo 中的一个本地变量,当你从函数返回时,它将被销毁。由于你返回了指向该变量的指针,所以该指针被称为“悬挂指针(dangling pointer)”。对指针进行间接引用会导致你的程序出现“未定义行为”。

修复这个问题的一种方法是使用 malloc 函数分配所需的内存。

例子:

#include <stdio.h>
#include <stdlib.h>

int* demo(int n) {
    int* arr = malloc(sizeof(*arr) * n);  // allocate

    for (int i = 0; i < n; i++) {
        arr[i] = i;
    }

    return arr;
}

int main() {
    int* arr_p;
    arr_p = demo(10);
    printf("%d\n", arr_p[9]);
    free(arr_p)                 // free the allocated memory
}

输出:

9

为什么demo_p指向的数据(0-9数字)在demo之外已经被释放了,我原以为demo内部的arr会在我们退出demo作用域时被释放。

arr对象的生命周期已经结束,在读取先前由arr占用的内存地址时,程序将出现未定义行为。您可能能够看到旧数据,或者程序可能崩溃 - 或做完全不同的事情。 任何都有可能发生。


注意 - 将函数的返回值存储在 arr_p 中已经导致了未定义行为。悬空指针具有不确定的值,可能是陷阱表示。 - M.M

2
我注意到函数内部数组的内存并没有被释放。但是,除了查看记录内存预留情况(在本例中为堆栈指针)的数据外,你无法注意或观察到内存的释放。当内存被预留或释放时,这只是一个关于可用或不可用内存的簿记过程。释放内存不一定会擦除内存或立即将其用于其他目的。查看内存并不能告诉您它是否正在使用。
int arr[10] = { 0 }; 出现在函数内部时,它定义了一个在函数开始执行时自动分配的数组(或在某些嵌套作用域内部分别执行)。这通常通过调整堆栈指针来完成。在常见系统中,程序有一个称为堆栈的内存区域,堆栈指针包含一个地址,标记当前已为其保留使用的堆栈部分的末尾。 当函数开始执行时,堆栈指针被更改以为该函数的数据保留更多的内存。 当函数执行结束时,堆栈指针被更改以释放该内存。
如果您保留对该内存的指针(如何进行此操作是另一个问题,在下面讨论),则在函数返回后立即不会“注意”或“观察”到该内存的任何更改。这就是为什么您看到 arr_p 的值是 arr 所具有的地址,并且在该内存中看到旧数据的原因。
如果调用其他函数,则堆栈指针将被调整为新函数,该函数通常将使用内存进行自己的目的,然后该内存的内容将发生更改。您在 arr 中的数据将消失。初学者经常遇到的一个常见例子是:
int main(void)
{
    int *p = demo(10);
    // p points to where arr started, and arr’s data is still there.

    printf("arr[3] = %d.\n", p[3]);
    // To execute this call, the program loads data from p[3]. Since it has
    // not changed, 3 is loaded. This is passed to printf.

    // Then printf prints “arr[3] = 3.\n”. In doing this, it uses memory
    // on the stack. This changes the data in the memory that p points to.

    printf("arr[3] = %d.\n", p[3]);
    // When we try the same call again, the program loads data from p[3],
    // but it has been changed, so something different is printed. Two
    // different things are printed by the same printf statement even
    // though there is no visible code changing p[3].
}

回到如何拥有指向内存的指针的问题,编译器遵循在C标准中抽象定义的规则。C标准定义了在demo函数中数组arr的抽象生命周期,并且说明当函数返回时,该生命周期结束。它进一步说明了指针的值在所指向的对象的生命周期结束时变为不确定。
如果您的编译器简单地生成代码(例如使用GCC编译时使用-O0关闭优化),它通常会保留p中的地址,并且您将看到上述描述的行为。但是,如果您打开优化并编译更复杂的程序,则编译器会尝试优化生成的代码。它不是机械地生成汇编代码,而是试图找到执行程序定义行为的“最佳”代码。如果您使用具有不确定值的指针或尝试访问生命周期已结束的对象,则程序没有定义行为,因此编译器的优化可能会产生新程序员意外的结果。

谢谢您提供带有示例的答案!您能否详细说明当我们调用两次prinft函数时会发生什么?我注意到在prinft调用期间堆栈指针没有改变,那么为什么帧会改变呢? 此外 - 您知道在Visual Studio中哪里可以设置我的编译器,以便它进行优化并在使用已释放内存时警告/抛出错误,就像我的演示示例一样吗?目前,我的VS 2019没有提到任何程序错误。 - IntToThe
@IntToThe 我看到你也在关于堆栈指针提出了这个问题,所以其中一些子问题可能会在那里得到回答。 - Steve Summit
@IntToThe 我觉得你很难开启一个关于“使用已释放内存”的警告,因为这在一般情况下很难检测到。虽然我不知道为什么VS2019没有警告返回局部变量的地址,但这是一个简单且有用的警告。gcc会提示“warning: function returns address of local variable”,clang会提示“warning: address of stack memory associated with local variable 'arr' returned”。 - Steve Summit
@IntToThe:当调用printf时,堆栈指针会发生变化。当printf返回时,它会返回到先前的值。Visual Studio在观察到本地对象的地址被返回时提供警告(C4172)。该警告包含在警告级别1(开关/W1)中,并且似乎已包含在默认设置中。使用/WX将警告提升为错误。 - Eric Postpischil

2
正如您所知,局部函数中声明的变量的存在仅限于该局部范围。一旦所需任务完成,函数终止后,局部变量将被销毁。由于您正在尝试从demo()函数返回指针,但问题是指针所指向的数组将在我们退出demo()后被销毁。因此,您正在尝试返回一个悬空指针,它指向已释放的内存。但我们的规则建议我们不惜一切代价避免悬挂指针。

因此,您可以通过在使用free()释放内存后重新初始化它来避免这种情况。或者,您还可以使用malloc()分配一些连续的内存块,或者在demo()中声明您的数组为静态数组。这将在局部函数成功退出时存储分配的内存常量。

谢谢您亲爱的。

#include<stdio.h>
#define N 10

int demo();
int main()
{
   int* arr_p;
   arr_p = demo();
   printf("%d\n", *(arr_p+3));
}

int* demo()
{
   static int arr[N];

for(i=0;i<N;i++)
{
   arr[i] = i;
}

   return arr;
}

OUTPUT : 3

或者您也可以这样写......
#include <stdio.h>
#include <stdlib.h>
#define N 10

int* demo() {
   int* arr = (int*)malloc(sizeof(arr) * N);

   for(int i = 0; i < N; i++)
{
   arr[i]=i;
}

   return arr;
}

int main()
{
  int* arr_p;
  arr_p = demo();
  printf("%d\n", *(arr_p+3));
  free(arr_p);  
  
  return 0;
}

OUTPUT : 3

0

在尝试从函数中返回字符数组时,我也遇到了类似的情况。但我总是需要一个固定大小的数组。

通过声明一个具有固定大小字符数组的结构体并从函数中返回该结构体来解决了这个问题:

#include <time.h>

typedef struct TimeStamp
{
    char Char[9];
} TimeStamp;

TimeStamp GetTimeStamp()
{
    time_t CurrentCalendarTime;

    time(&CurrentCalendarTime);

    struct tm* LocalTime = localtime(&CurrentCalendarTime);

    TimeStamp Time = { 0 };

    strftime(Time.Char, 9, "%H:%M:%S", LocalTime);

    return Time;
}

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