Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

بخش ششم: اشاره‌گرها و حافظه

Authors
Affiliations
دانشگاه فردوسی مشهد
دانشگاه فردوسی مشهد
دانشگاه فردوسی مشهد
دانشگاه فردوسی مشهد

مقدمه

تا اینجای درس، هر متغیر برای ما یک نام بود و کامپایلر مدیریت حافظه را خودش به عهده داشت. اما چند سؤال را بی‌پاسخ گذاشته‌ایم. چرا scanf حتماً به ‎&‎ نیاز دارد؟ چرا تابعی مثل swap که دو متغیر را جابه‌جا کند با روش‌های فعلی ما کار نمی‌کند؟ و چرا تابع می‌تواند عناصر آرایه‌ی ورودی را تغییر دهد ولی متغیر ساده را نه؟ پاسخ هر سه یک کلمه است: آدرس.

این بخش دو قدم دارد. اول اشاره‌گرها (Pointers) را یاد می‌گیریم، یعنی متغیرهایی که به‌جای یک مقدار، آدرس یک خانه‌ی دیگر را نگه می‌دارند. بعد با همین ابزار سراغ حافظه‌ی پویا می‌رویم. آرایه‌ی معمولی باید از قبل اندازه داشته باشد، ولی تعداد دانشجویان یک کلاس را تا موقع اجرا نمی‌دانیم. با malloc و دوستانش اندازه را هنگام اجرا تعیین می‌کنیم.

سناریوی بخش قبل، یعنی سیستم مدیریت نمرات دانشجویان، اینجا هم ادامه دارد: کمترین و بیشترین نمره را با اشاره‌گر بیرون می‌دهیم و آرایه‌ی نمرات را دقیقاً به‌اندازه‌ی تعداد دانشجویان می‌سازیم.

نقشه کلی بخش:

Memory → Address (&) → Pointer (*) → Pointer + Function (swap, min_max)
                           │
                           ├──► Pointer + Array → Array Decay → Pointer Arithmetic
                           └──► Pointer to Pointer / void* / Function Pointer
                           │
        Stack vs Heap → malloc / calloc / realloc / free → Dynamic Array
                           │
        Memory Leak / Dangling Pointer / Double Free → AddressSanitizer

فصل ۱۴ : Pointers

14.1 حافظه و آدرس

حافظه را مثل یک خیابان بلند تصور کنید که هر خانه‌اش شماره‌ای دارد و یک بایت جا دارد. وقتی متغیری تعریف می‌کنیم، کامپایلر چند خانه‌ی پشت‌سرهم برایش برمی‌دارد: int معمولاً ۴ خانه و char یک خانه. شماره‌ی اولین خانه‌ی متغیر همان آدرس آن است.

 address      content        variable
+---------+--------------+------------+
| 0x1000  |      20      |    age     |   <- int  : 4 bytes
| 0x1004  |      3.5     |    price   |   <- float: 4 bytes
| 0x1008  |     'A'      |    grade   |   <- char : 1 byte
+---------+--------------+------------+

عملگر ‎&‎ (بخوانید «آدرسِ…») آدرس یک متغیر را می‌دهد و قالب ‎%p‎ آن را چاپ می‌کند:

#include <stdio.h>

int main(void)
{
    int age = 20;
    printf("value   : %d\n", age);
    printf("address : %p\n", (void *)&age);
    return 0;
}

خروجی (نمونه):

value   : 20
address : 0x7ffd5e3b2a1c

آدرس واقعی در هر سیستم و هر بار اجرا فرق می‌کند و این کاملاً طبیعی است. مهم این است که هر متغیر آدرسی دارد و می‌توانیم آن را بگیریم.

14.2 اشاره‌گر و عملگر ‎*‎

اشاره‌گر متغیری است که مقدارش یک آدرس است. تعریف ‎int *p;‎ یعنی p آدرسِ یک int را نگه می‌دارد. نوع اشاره‌گر مهم است، چون برای خواندن از یک آدرس باید بدانیم چند بایت و به چه شکلی بخوانیم. دو عملگر کار را کامل می‌کنند: ‎&x‎ آدرسِ x را می‌دهد و ‎*p‎ محتوای آدرسی را که p نگه می‌دارد، چه برای خواندن و چه برای نوشتن.

        x                           p
   +-----------+              +-------------+
   |    10     | <----------- |   0x1000    |      p "points to" x
   +-----------+              +-------------+
   address 0x1000             address 0x2000
#include <stdio.h>

int main(void)
{
    int x = 10;
    int *p = &x;          /* p holds the address of x */

    printf("x  = %d\n", x);
    printf("*p = %d\n", *p);   /* read through the pointer */

    *p = 99;                   /* write through the pointer */
    printf("x  = %d\n", x);    /* x changed! */
    return 0;
}
x  = 10
*p = 10
x  = 99
Loading...

14.3 اشاره‌گر بی‌مقصد و NULL

اشاره‌گری که مقدار اولیه نگرفته باشد به یک آدرس تصادفی اشاره می‌کند و نوشتن در آن رفتار تعریف‌نشده دارد: ممکن است برنامه همان لحظه کرش کند یا بی‌صدا داده‌های دیگر را خراب کند.

int *p;      /* garbage address */
*p = 5;      /* undefined behaviour */

اشاره‌گری که هنوز مقصدی ندارد را برابر NULL قرار می‌دهیم و پیش از استفاده بررسی می‌کنیم. قاعده ساده است: هر اشاره‌گر یا به یک آدرس معتبر اشاره می‌کند یا NULL است.

#include <stdio.h>

int main(void)
{
    int *p = NULL;
    if (p == NULL)
        printf("p points to nothing yet\n");

    int x = 8;
    p = &x;
    if (p != NULL)
        printf("now *p = %d\n", *p);
    return 0;
}
p points to nothing yet
now *p = 8

یک نکته‌ی تاریخی: تونی هوآر، که ایده‌ی «ارجاع تهی» را در سال ۱۹۶۵ وارد زبان ALGOL W کرد، در سال ۲۰۰۹ آن را «اشتباه یک‌میلیارد دلاری» خودش نامید، چون در دهه‌ها بی‌شمار خطا و کرش از آن به وجود آمد.

14.4 اشاره‌گر و تابع

در C پارامترهای تابع به‌صورت کپی ارسال می‌شوند. مثل این است که به همکارتان فتوکپیِ یک برگه را بدهید؛ هرچه روی کپی بنویسد، برگه‌ی اصلی عوض نمی‌شود. اما اگر آدرسِ برگه را بدهید، همکارتان می‌تواند خودش سراغ آن برود و آن را تغییر دهد.

#include <stdio.h>

void swap_wrong(int a, int b)
{
    int t = a; a = b; b = t;
}

void swap(int *a, int *b)
{
    int t = *a; *a = *b; *b = t;
}

int main(void)
{
    int x = 3, y = 7;

    swap_wrong(x, y);
    printf("after swap_wrong: x=%d y=%d\n", x, y);

    swap(&x, &y);
    printf("after swap      : x=%d y=%d\n", x, y);
    return 0;
}
after swap_wrong: x=3 y=7
after swap      : x=7 y=3
Loading...

همین توضیح می‌دهد که چرا می‌نویسیم ‎scanf("%d", &x)‎: scanf باید مقدار خوانده‌شده را داخل خودِ x بگذارد، پس آدرس x را لازم دارد.

کاربرد مهم دیگر این است که تابع بتواند بیش از یک نتیجه برگرداند. return فقط یک مقدار می‌دهد، ولی با اشاره‌گر می‌توانیم نتیجه‌های دیگر را «بیرون بفرستیم». در سیستم نمرات، کمترین و بیشترین نمره را هم‌زمان پیدا می‌کنیم. کلمه‌ی const به خواننده می‌گوید که تابع آرایه را فقط می‌خواند و تغییرش نمی‌دهد.

#include <stdio.h>

void min_max(const int a[], int n, int *min, int *max)
{
    *min = *max = a[0];
    for (int i = 1; i < n; i++) {
        if (a[i] < *min) *min = a[i];
        if (a[i] > *max) *max = a[i];
    }
}

int main(void)
{
    int scores[] = {18, 15, 20, 12, 17};
    int lo, hi;
    min_max(scores, 5, &lo, &hi);
    printf("min = %d, max = %d\n", lo, hi);
    return 0;
}
min = 12, max = 20

14.5 اشاره‌گر و آرایه

نام یک آرایه در بیشتر عبارت‌ها به آدرسِ اولین عنصرش تبدیل می‌شود (این تبدیل Array Decay نام دارد و در بخش 14.6 کامل‌تر می‌شود)، یعنی a همان ‎&a[0]‎ است. نتیجه این است که ‎a[i]‎ و ‎*(a + i)‎ دقیقاً یک چیزند.

 index        0       1       2       3       4
           +-------+-------+-------+-------+-------+
 a         |  10   |  20   |  30   |  40   |  50   |
           +-------+-------+-------+-------+-------+
 address    0x100   0x104   0x108   0x10C   0x110
              ^
              p   (p = a = &a[0])
#include <stdio.h>

int main(void)
{
    int a[5] = {10, 20, 30, 40, 50};
    int *p = a;                 /* same as &a[0] */

    printf("a[2]     = %d\n", a[2]);
    printf("*(a + 2) = %d\n", *(a + 2));
    printf("*(p + 2) = %d\n", *(p + 2));
    printf("p[2]     = %d\n", p[2]);

    for (int *q = a; q < a + 5; q++)
        printf("%d ", *q);
    printf("\n");
    return 0;
}
a[2]     = 30
*(a + 2) = 30
*(p + 2) = 30
p[2]     = 30
10 20 30 40 50 
Loading...

یک تفاوت ظریف: p متغیر است و می‌توان ‎p++‎ کرد، ولی نام آرایه ثابت است و ‎a++‎ خطای کامپایل می‌دهد.

نکته‌ی جالب: چون ‎a[i]‎ در واقع ‎*(a + i)‎ است و جمع جابه‌جایی‌پذیر است، عبارت ‎2[a]‎ هم معتبر است و برابر ‎a[2]‎ است. البته برای خوانایی هیچ‌کس چنین نمی‌نویسد.

حساب اشاره‌گر

وقتی به اشاره‌گر یک واحد اضافه می‌کنیم (‎p + 1‎)، به عنصر بعدی می‌رویم، نه بایت بعدی. کامپایلر اندازه‌ی نوع را خودش حساب می‌کند: آدرس جدید برابر است با آدرس فعلی به‌علاوه‌ی حاصل‌ضرب n در اندازه‌ی نوع (sizeof).

#include <stdio.h>

int main(void)
{
    int    i[2];
    double d[2];
    char   c[2];

    int    *pi = i;
    double *pd = d;
    char   *pc = c;

    printf("int    step: %ld bytes\n", (long)((char *)(pi + 1) - (char *)pi));
    printf("double step: %ld bytes\n", (long)((char *)(pd + 1) - (char *)pd));
    printf("char   step: %ld bytes\n", (long)((char *)(pc + 1) - (char *)pc));
    return 0;
}
int    step: 4 bytes
double step: 8 bytes
char   step: 1 bytes

روی اشاره‌گر می‌توان عدد جمع و تفریق کرد، دو اشاره‌گرِ هم‌آرایه را از هم کم کرد (حاصل، تعداد عناصر بین آن‌هاست) و آن‌ها را مقایسه کرد. جمع دو اشاره‌گر، ضرب و تقسیم معنی ندارند. همچنین حساب اشاره‌گر فقط داخل محدوده‌ی همان آرایه معتبر است؛ بیرون رفتن از آن و خواندن یا نوشتن، رفتار تعریف‌نشده دارد.

14.6 تبدیل آرایه به اشاره‌گر (Array Decay) و ارسال آرایه به تابع

در بخش قبل دیدیم که نام آرایه در بیشتر عبارت‌ها به آدرسِ اولین عنصرش تبدیل می‌شود. این تبدیل خودکار را Array Decay می‌نامند: آرایه‌ای از نوع ‎int[5]‎ به اشاره‌گری از نوع ‎int *‎ تبدیل می‌شود. هر جا بنویسید ‎a + 1‎، ‎*a‎ یا ‎p = a‎، یا آرایه را به تابع بدهید، همین اتفاق افتاده است.

سه جایی که Decay رخ نمی‌دهد

نام آرایه در سه حالت به اشاره‌گر تبدیل نمی‌شود: وقتی عملوند sizeof باشد، وقتی عملوند ‎&‎ باشد، و وقتی رشته‌ی ثابتی آرایه‌ی char را مقداردهی کند (مثل ‎char s[] = "abc";‎). برنامه‌ی زیر تفاوت را نشان می‌دهد:

#include <stdio.h>

int main(void)
{
    int a[5] = {10, 20, 30, 40, 50};

    printf("a == &a[0]     : %d\n", a == &a[0]);
    printf("sizeof(a)      : %zu\n", sizeof(a));
    printf("sizeof(a + 0)  : %zu\n", sizeof(a + 0));
    printf("step of a + 1  : %ld bytes\n", (long)((char *)(a + 1) - (char *)a));
    printf("step of &a + 1 : %ld bytes\n", (long)((char *)(&a + 1) - (char *)&a));
    return 0;
}

خروجی (روی سیستم ۶۴ بیتی):

a == &a[0]     : 1
sizeof(a)      : 20
sizeof(a + 0)  : 8
step of a + 1  : 4 bytes
step of &a + 1 : 20 bytes

در ‎sizeof(a)‎ آرایه سالم مانده و اندازه‌اش ۵ × ۴ = ۲۰ بایت است. اما در ‎a + 0‎ تبدیل به اشاره‌گر انجام شده و اندازه‌ی یک اشاره‌گر، یعنی ۸ بایت، به دست می‌آید. عبارت ‎&a‎ هم آدرسِ کل آرایه است (از نوع ‎int (*)[5]‎). آدرسش با a یکی است، ولی ‎&a + 1‎ به‌اندازه‌ی کل آرایه، یعنی ۲۰ بایت، جلو می‌رود و نه ۴ بایت.

ارسال آرایه به تابع

وقتی آرایه را به تابع می‌دهیم، Decay رخ می‌دهد: کپی آرایه نمی‌رود و فقط آدرسِ اولین عنصرش می‌رود. به همین دلیل تابع می‌تواند آرایه‌ی اصلی را تغییر دهد. در پارامتر تابع سه نوشتار ‎int arr[]‎، ‎int arr[10]‎ و ‎int *arr‎ کاملاً معادل‌اند و عددِ داخل کروشه نادیده گرفته می‌شود.

#include <stdio.h>

void show(int arr[])
{
    /* arr is really an int* here */
    printf("inside show : sizeof(arr) = %zu\n", sizeof(arr));
}

int main(void)
{
    int a[10];
    printf("inside main : sizeof(a)   = %zu\n", sizeof(a));
    show(a);
    return 0;
}

خروجی (روی سیستم ۶۴ بیتی):

inside main : sizeof(a)   = 40
inside show : sizeof(arr) = 8

در main، نام a آرایه‌ی ۱۰ تایی از int است و ۱۰ × ۴ = ۴۰ بایت می‌شود. داخل show فقط یک اشاره‌گر داریم و اندازه‌ی اشاره‌گر ۸ بایت است؛ کامپایلر هم هشدار می‌دهد. نتیجه‌ی عملی این است که تابع اندازه‌ی آرایه را نمی‌داند و باید تعداد عناصر را جداگانه، مثلاً با پارامتر n، دریافت کند. اگر تابع آرایه را فقط می‌خواند، نوشتن const پیش از نوع پارامتر این قصد را مشخص می‌کند.

آرایه‌ی دوبعدی

برای آرایه‌ی دوبعدی ‎int m[2][3]‎ هم Decay رخ می‌دهد، اما نتیجه اشاره‌گر به یک سطر است، نه به یک int: نوع m به ‎int (*)[3]‎ تبدیل می‌شود. به همین دلیل در پارامتر تابع باید تعداد ستون‌ها را بنویسیم؛ کامپایلر برای محاسبه‌ی آدرسِ ‎m[i][j]‎ باید بداند هر سطر چند عنصر دارد.

void print_matrix(int m[][3], int rows);   /* same as: int (*m)[3] */

14.7 اشاره‌گر به اشاره‌گر، ‎void *‎ و اشاره‌گر به تابع

اشاره‌گر به اشاره‌گر

اشاره‌گر هم یک متغیر است و آدرس دارد، پس می‌توان اشاره‌گری ساخت که آدرسِ یک اشاره‌گر را نگه دارد. نوع چنین اشاره‌گری یک ستاره‌ی بیشتر دارد (‎int **‎).

      pp                    p                    x
  +---------+          +---------+          +---------+
  |  &p     | -------> |  &x     | -------> |    5    |
  +---------+          +---------+          +---------+
   int **               int *                 int
#include <stdio.h>

int main(void)
{
    int x = 5;
    int *p = &x;
    int **pp = &p;

    printf("x   = %d\n", x);
    printf("*p  = %d\n", *p);
    printf("**pp= %d\n", **pp);

    **pp = 42;
    printf("x   = %d\n", x);
    return 0;
}
x   = 5
*p  = 5
**pp= 5
x   = 42

این ساختار برای یک کار مشخص لازم می‌شود: تابعی که بخواهد خودِ یک اشاره‌گر را تغییر دهد. همان‌طور که برای تغییر یک int آدرسش را می‌دهیم (‎int *‎)، برای تغییر یک ‎int *‎ باید آدرسش را بدهیم (‎int **‎). در مثال زیر تابع، اشاره‌گر best را به بزرگ‌ترِ دو متغیر می‌برد:

#include <stdio.h>

void set_to_larger(int **pp, int *x, int *y)
{
    *pp = (*x > *y) ? x : y;
}

int main(void)
{
    int a = 3, b = 9;
    int *best = NULL;

    set_to_larger(&best, &a, &b);
    printf("*best = %d\n", *best);
    return 0;
}
*best = 9

همین الگو در ‎main(int argc, char *argv[])‎ هم دیده می‌شود. پارامتر argv آرایه‌ای از اشاره‌گر به رشته است و طبق Array Decay به ‎char **‎ تبدیل می‌شود.

اشاره‌گر عمومی ‎void *‎

گاهی نوع داده‌ی مقصد را از قبل نمی‌دانیم. ‎void *‎ اشاره‌گری است که آدرسِ هر نوعی را می‌تواند نگه دارد، اما چون نوع ندارد نمی‌توان مستقیم با ‎*‎ به محتوایش رسید؛ باید اول آن را به نوع درست تبدیل (cast) کرد. تابع malloc در فصل بعد همین نوع را برمی‌گرداند.

int n = 5;
void *vp = &n;
printf("%d
", *(int *)vp);   /* cast first, then dereference: prints 5 */

اشاره‌گر به تابع

هر تابع هم در حافظه آدرسی دارد و می‌توان آن را در یک اشاره‌گر نگه داشت. عبارت ‎int (*op)(int, int)‎ یعنی «op اشاره‌گر به تابعی است که دو int می‌گیرد و int برمی‌گرداند». پرانتزِ دور ‎*op‎ مهم است، چون بدون آن عبارت تابعی را تعریف می‌کند که ‎int *‎ برمی‌گرداند. نام تابع بدون پرانتز (add) همان آدرس تابع است، شبیه نام آرایه. با این ابزار می‌توان تابع را به‌عنوان ورودی به تابع دیگر داد:

#include <stdio.h>

int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

int apply(int (*op)(int, int), int a, int b)
{
    return op(a, b);
}

int main(void)
{
    printf("add: %d\n", apply(add, 6, 7));
    printf("mul: %d\n", apply(mul, 6, 7));
    return 0;
}
add: 13
mul: 42

مثال واقعی: qsort

تابع استاندارد qsort (در ‎<stdlib.h>‎) هر آرایه‌ای را مرتب می‌کند: int، double یا هر نوع دیگر. برای اینکه با هر نوعی کار کند، آرایه را به‌صورت ‎void *‎ می‌گیرد، و برای اینکه بداند عناصر را چطور مقایسه کند، تابع مقایسه را به‌صورت اشاره‌گر به تابع می‌گیرد. ما فقط همین مقایسه را می‌نویسیم. مثال زیر نمرات را نزولی مرتب می‌کند:

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

int by_score_desc(const void *x, const void *y)
{
    int a = *(const int *)x;
    int b = *(const int *)y;
    return (a < b) - (a > b);
}

int main(void)
{
    int scores[] = {18, 15, 20, 12, 17};
    int n = sizeof(scores) / sizeof(scores[0]);

    qsort(scores, n, sizeof(int), by_score_desc);

    for (int i = 0; i < n; i++)
        printf("%d ", scores[i]);
    printf("\n");
    return 0;
}
20 18 17 15 12 

تابع مقایسه باید عددی منفی، صفر یا مثبت برگرداند. عبارت ‎(a < b) - (a > b)‎ ترتیب نزولی می‌دهد و برخلاف ‎b - a‎ برای عددهای بزرگ سرریز نمی‌کند.

14.8 تمرین

تمرین ۱. خروجی برنامه‌ی زیر را بدون اجرا پیش‌بینی کنید.

#include <stdio.h>
int main(void)
{
    int a = 5, b = 10;
    int *p = &a, *q = &b;
    *p = *q + 1;
    q = p;
    *q = *q * 2;
    printf("%d %d\n", a, b);
    return 0;
}

تمرین ۲. تابع ‎void reverse(int *a, int n)‎ را بنویسید که آرایه را درجا معکوس کند. فقط از دو اشاره‌گر (یکی از ابتدا، یکی از انتها) استفاده کنید.

تمرین ۳. تابع ‎int *find_max(int *a, int n)‎ را بنویسید که اشاره‌گر به بزرگ‌ترین عنصر را برگرداند، نه خود مقدار. در main نشان دهید که با همان اشاره‌گر می‌توان مقدار اصلی را تغییر داد و اندیس آن را با ‎m - v‎ به دست آورد.

تمرین ۴. تابع ‎int elements(int a[])‎ می‌خواهد با عبارت ‎sizeof(a) / sizeof(a[0])‎ تعداد عناصر آرایه را حساب کند. چرا نتیجه‌اش درست نیست و راه درست چیست؟


فصل ۱۵ : Dynamic Memory

15.1 چرا حافظه‌ی پویا؟

آرایه‌ی معمولی دو محدودیت دارد: اندازه‌اش باید از قبل معلوم باشد، و متغیرهای محلی با پایان تابع از بین می‌روند. اگر ‎int a[100]‎ بگیریم و کلاس ۳ نفر باشد، حافظه هدر رفته است؛ اگر ۵۰۰ نفر باشد، جا نداریم.

برنامه‌ی در حال اجرا دو ناحیه‌ی اصلی برای داده دارد:

   +---------------------------+   high addresses
   |          STACK            |   local variables, parameters
   |   (grows downward)        |   created and destroyed with the function
   +---------------------------+
   |            ...            |
   +---------------------------+
   |           HEAP            |   malloc / calloc / realloc
   |   (grows upward)          |   lives until you call free()
   +---------------------------+
   |    globals / static       |
   +---------------------------+
   |    program code           |
   +---------------------------+   low addresses
پشته (Stack)هیپ (Heap)
محتوامتغیرهای محلی و پارامترهاحافظه‌ای که با malloc و مشابه آن می‌گیریم
مدیریتخودکاردستی، به عهده‌ی برنامه‌نویس
عمرتا پایان تابعتا زمانی که free کنیم
اندازهکوچک و محدودخیلی بزرگ‌تر

15.2 تابع‌های malloc و free

تابع malloc (در ‎<stdlib.h>‎) تعداد مشخصی بایت از هیپ می‌گیرد و آدرس اولین بایت را برمی‌گرداند، یا اگر حافظه کافی نباشد NULL. محتوای حافظه‌ی گرفته‌شده مقدار اولیه ندارد. الگوی استفاده همیشه چهار مرحله دارد: گرفتن، بررسی NULL، استفاده، و free.

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

int main(void)
{
    int *p = malloc(sizeof(int));
    if (p == NULL) {
        printf("out of memory!\n");
        return 1;
    }

    *p = 2026;
    printf("*p = %d\n", *p);

    free(p);
    p = NULL;
    return 0;
}
*p = 2026

تعداد بایت را همیشه با sizeof بنویسید، نه با عدد ثابت، تا برنامه روی هر سیستمی درست کار کند. نتیجه‌ی malloc را هم حتماً با NULL مقایسه کنید؛ استفاده از اشاره‌گر NULL باعث کرش می‌شود.

15.3 آرایه‌ی پویا

بعد از malloc، اشاره‌گر را دقیقاً مثل آرایه با ‎a[i]‎ استفاده می‌کنیم. حالا اندازه‌ی آرایه می‌تواند از ورودی کاربر بیاید. در سیستم نمرات، تعداد دانشجویان را می‌پرسیم و به همان اندازه جا می‌گیریم:

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

int main(void)
{
    int n;
    printf("How many students? ");
    if (scanf("%d", &n) != 1 || n <= 0) return 1;

    int *a = malloc(n * sizeof(int));
    if (a == NULL) {
        printf("out of memory!\n");
        return 1;
    }

    long sum = 0;
    for (int i = 0; i < n; i++) {
        printf("score[%d] = ", i);
        scanf("%d", &a[i]);
        sum += a[i];
    }
    printf("sum = %ld, average = %.2f\n", sum, (double)sum / n);

    free(a);
    return 0;
}

نمونه‌ی اجرا:

How many students? 4
score[0] = 18
score[1] = 15
score[2] = 20
score[3] = 12
sum = 65, average = 16.25
Loading...

خودِ اشاره‌گر a روی پشته است ولی داده‌ها روی هیپ‌اند. اگر ‎free(a)‎ را فراموش کنیم، آدرس هیپ از دست می‌رود و دیگر راهی برای آزادکردنش نداریم.

15.4 تابع‌های calloc و realloc

تابع ‎calloc(count, size)‎ مثل malloc حافظه می‌گیرد، با این تفاوت که همه‌ی بایت‌ها را صفر می‌کند و تعداد و اندازه‌ی هر عنصر را جدا می‌گیرد. خواندن از حافظه‌ای که با malloc گرفته شده، پیش از مقداردهی، خطاست؛ اگر به صفر نیاز دارید از calloc استفاده کنید.

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

int main(void)
{
    int *m = malloc(3 * sizeof(int));   /* garbage inside   */
    int *c = calloc(3, sizeof(int));    /* guaranteed zeros */

    printf("calloc: %d %d %d\n", c[0], c[1], c[2]);
    /* m[0..2] are indeterminate: reading them is a bug */
    m[0] = m[1] = m[2] = 7;
    printf("malloc after filling: %d %d %d\n", m[0], m[1], m[2]);

    free(m);
    free(c);
    return 0;
}
calloc: 0 0 0
malloc after filling: 7 7 7

تابع ‎realloc(ptr, new_size)‎ اندازه‌ی یک بلوک را بزرگ یا کوچک می‌کند و محتوای قبلی را نگه می‌دارد. ممکن است بلوک را به جای دیگری منتقل کند، پس بعد از آن فقط از اشاره‌گر جدید استفاده کنید. نوشتن ‎a = realloc(a, n)‎ خطرناک است، چون اگر realloc شکست بخورد NULL برمی‌گرداند و آدرس بلوک قبلی از دست می‌رود. راه امن این است که نتیجه را اول در یک اشاره‌گر موقت بگیریم.

در مثال زیر، هر بار که آرایه پر شد ظرفیتش دو برابر می‌شود. دو برابر کردن باعث می‌شود برای ۱۰۰۰۰ عنصر فقط حدود ۱۲ بار realloc لازم باشد، نه ۱۰۰۰۰ بار.

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

int main(void)
{
    int cap = 2, size = 0;
    int *a = malloc(cap * sizeof(int));
    if (a == NULL) return 1;

    for (int v = 1; v <= 10; v++) {
        if (size == cap) {                       /* full: double it */
            int *tmp = realloc(a, 2 * cap * sizeof(int));
            if (tmp == NULL) {                   /* keep old block on failure */
                free(a);
                return 1;
            }
            a = tmp;
            cap *= 2;
            printf("grew to capacity %d\n", cap);
        }
        a[size++] = v * v;
    }

    for (int i = 0; i < size; i++) printf("%d ", a[i]);
    printf("\n");

    free(a);
    return 0;
}
grew to capacity 4
grew to capacity 8
grew to capacity 16
1 4 9 16 25 36 49 64 81 100 
Loading...

15.5 خطاهای حافظه

حافظه‌ی پویا قدرت می‌دهد ولی مسئولیت هم می‌آورد. قاعده‌ی کلی: هر malloc یا calloc باید دقیقاً یک free داشته باشد، و بعد از free اشاره‌گر را NULL کنید (‎free(NULL)‎ بی‌خطر است). چهار خطای معروف این قاعده را نقض می‌کنند.

15.5.1 نشتی حافظه (Memory Leak)

حافظه گرفته شده ولی هیچ‌وقت آزاد نمی‌شود و آدرسش هم گم شده است. در برنامه‌ی کوتاه دیده نمی‌شود، اما در برنامه‌ای که ساعت‌ها اجرا می‌شود، حافظه کم‌کم تمام می‌شود.

#include <stdlib.h>

int main(void)
{
    int *p = malloc(100 * sizeof(int));
    p[0] = 1;
    return 0;            /* forgot free(p) */
}

15.5.2 اشاره‌گر معلق (Dangling Pointer)

اشاره‌گری که به حافظه‌ی آزادشده اشاره می‌کند. آن حافظه ممکن است هر لحظه به چیز دیگری داده شود، پس نتیجه‌ی استفاده از آن غیرقابل پیش‌بینی است.

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

int main(void)
{
    int *p = malloc(sizeof(int));
    *p = 5;
    free(p);
    printf("%d\n", *p);   /* use after free */
    return 0;
}
Loading...

شکل پنهان‌ترش برگرداندن آدرس متغیر محلی است. آن متغیر روی پشته است و با پایان تابع از بین می‌رود:

int *bad(void)
{
    int x = 42;
    return &x;      /* x dies when bad() returns */
}

15.5.3 آزادسازی دوباره (Double Free)

آزاد کردن یک بلوک برای بار دوم ساختار داخلی مدیریت حافظه را خراب می‌کند و معمولاً به کرش می‌رسد.

#include <stdlib.h>

int main(void)
{
    int *p = malloc(sizeof(int));
    free(p);
    free(p);              /* double free */
    return 0;
}

15.5.4 نوشتن بیرون از محدوده

int *a = malloc(5 * sizeof(int));
for (int i = 0; i <= 5; i++)    /* bug: a[5] is one past the end */
    a[i] = i;

استفاده از ‎<=‎ به‌جای ‎<‎ یک عنصر بیشتر می‌نویسد. این خطا ممکن است اصلاً علامتی نشان ندهد ولی داده‌های دیگر را خراب کند.

چنین خطاهایی فقط آزاردهنده نیستند. باگ معروف Heartbleed در کتابخانه‌ی OpenSSL (سال ۲۰۱۴) از همین خانواده بود: برنامه بیش از اندازه‌ی واقعی یک بافر را از حافظه می‌خواند و ممکن بود داده‌های حساس سرور را لو بدهد.

15.6 پیدا کردن خطاها با AddressSanitizer

بیشتر این خطاها موقع اجرا پیام نمی‌دهند، برای همین ابزار لازم داریم. gcc ابزار آماده‌ای به نام AddressSanitizer دارد و فقط کافی است موقع کامپایل دو گزینه اضافه کنید:

gcc -g -fsanitize=address -std=c11 prog.c -o prog
./prog

برای برنامه‌ی نشتی بخش ۱۵.۵.۱ گزارشی شبیه این (خلاصه‌شده) می‌گیرید:

ERROR: LeakSanitizer: detected memory leaks

Direct leak of 400 byte(s) in 1 object(s) allocated from:
    #0 ... in malloc
    #1 ... in main leak.c:4

SUMMARY: AddressSanitizer: 400 byte(s) leaked in 1 allocation(s).

برای اشاره‌گر معلق، گزارش با ‎heap-use-after-free‎ شروع می‌شود و برای آزادسازی دوباره با ‎attempting double-free‎. در هر گزارش دو چیز را نگاه کنید: نوع خطا و شماره‌ی خط در فایل خودتان. این ابزار روی لینوکس و macOS راحت کار می‌کند؛ روی بعضی نصب‌های ویندوز (مثل MinGW) ممکن است در دسترس نباشد و بهتر است از WSL استفاده کنید.

15.7 تابعی که حافظه برمی‌گرداند

حافظه‌ی هیپ با پایان تابع از بین نمی‌رود، پس تابع می‌تواند حافظه بگیرد و آدرسش را برگرداند. در این حالت مسئولیت free با فراخواننده است و بهتر است این موضوع در توضیح تابع نوشته شود. قاعده‌ی ساده: هر کس حافظه را می‌گیرد، یا خودش آزادش می‌کند یا صریحاً مسئولیتش را به دیگری می‌سپارد.

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

int *make_range(int n)
{
    int *a = malloc(n * sizeof(int));
    if (a == NULL) return NULL;
    for (int i = 0; i < n; i++) a[i] = i;
    return a;
}

int main(void)
{
    int *r = make_range(5);
    if (r == NULL) return 1;
    for (int i = 0; i < 5; i++) printf("%d ", r[i]);
    printf("\n");
    free(r);
    return 0;
}
0 1 2 3 4

15.8 تمرین

تمرین ۱. برنامه‌ای بنویسید که n را بخواند، با malloc آرایه‌ای به همان اندازه بسازد، n عدد بخواند و آن‌ها را از آخر به اول چاپ کند. در پایان حافظه را آزاد کند.

تمرین ۲. در هر قطعه‌کد یک خطای حافظه هست. نوع خطا و اصلاحش را بگویید.

/* snippet 1 */
for (int i = 0; i < 1000; i++) {
    int *p = malloc(100 * sizeof(int));
    p[0] = i;
}

/* snippet 2 */
int *p = malloc(sizeof(int));
*p = 7;
free(p);
*p = 8;

/* snippet 3 */
int *get(void)
{
    int v = 10;
    return &v;
}

/* snippet 4 */
int *a = malloc(5 * sizeof(int));
for (int i = 0; i <= 5; i++)
    a[i] = i;

تمرین ۳. برنامه‌ای بنویسید که تا زمانی که کاربر ‎-1‎ وارد نکرده عدد بخواند. تعداد ورودی‌ها از قبل معلوم نیست؛ آرایه با ظرفیت ۴ شروع شود و هر بار پر شد با realloc دو برابر شود. در پایان تعداد و میانگین را چاپ کند.


جمع‌بندی بخش

این بخش حول یک ایده‌ی ساده می‌چرخد: هر چیزی در حافظه آدرسی دارد و می‌توانیم آن آدرس را در یک متغیر نگه داریم. فصل ۱۴ نشان داد این ایده چه امکان‌هایی می‌دهد، و فصل ۱۵ نشان داد وقتی حافظه را خودمان می‌گیریم چه مسئولیتی داریم.

فصل ۱۴: اشاره‌گرها

  • آدرس و اشاره‌گر: ‎&x‎ آدرسِ x را می‌دهد و ‎*p‎ به محتوای آدرسی می‌رسد که p نگه می‌دارد. اشاره‌گری که هنوز مقصد ندارد باید NULL باشد.

  • ارسال با آدرس: C پارامترها را کپی می‌کند. برای اینکه تابع بتواند مقدار اصلی را تغییر دهد یا چند نتیجه برگرداند، آدرس می‌فرستیم. دلیل ‎&‎ در scanf هم همین است.

  • آرایه و اشاره‌گر: ‎a[i]‎ همان ‎*(a + i)‎ است، نام آرایه در بیشتر عبارت‌ها به آدرسِ عنصر اول تبدیل می‌شود (Array Decay) و ‎p + 1‎ یعنی عنصر بعدی، نه بایت بعدی. به همین دلیل تابع اندازه‌ی آرایه را نمی‌داند و باید آن را جداگانه دریافت کند.

  • مباحث تکمیلی: ‎int **‎ برای تغییر خودِ یک اشاره‌گر، ‎void *‎ برای داده‌ای که نوعش از قبل معلوم نیست، و اشاره‌گر به تابع برای فرستادن یک رفتار به‌عنوان ورودی، مثل تابع مقایسه در qsort.

فصل ۱۵: حافظه‌ی پویا

  • پشته و هیپ: متغیرهای محلی روی پشته‌اند و با پایان تابع از بین می‌روند. حافظه‌ی هیپ تا وقتی free نکنیم زنده می‌ماند.

  • چهار تابع: malloc حافظه می‌گیرد، calloc می‌گیرد و صفر می‌کند، realloc اندازه را عوض می‌کند و free پس می‌دهد. نتیجه را همیشه با NULL بسنجید و realloc را با اشاره‌گر موقت بنویسید.

  • خطاهای حافظه: نشتی، اشاره‌گر معلق، آزادسازی دوباره و نوشتن بیرون از محدوده. قاعده‌ی کلی این است که هر malloc یک free دارد و بعد از free اشاره‌گر را NULL می‌کنیم.

  • پیدا کردن خطا: AddressSanitizer با گزینه‌ی ‎-fsanitize=address‎ این خطاها را هنگام اجرا نشان می‌دهد.

اشتباه‌های رایج

اشتباهچه می‌شود؟راه درست
استفاده از اشاره‌گر بدون مقدار اولیهرفتار تعریف‌نشدهمقداردهی با آدرس معتبر یا NULL
فراموش‌کردن ‎&‎ در scanfرفتار تعریف‌نشده، معمولاً کرشفرستادن آدرس، مثل ‎&x‎
شمردن عناصر با ‎sizeof(a)‎ داخل تابعاندازه‌ی اشاره‌گر به دست می‌آیدفرستادن تعداد عناصر به‌صورت پارامتر
نادیده‌گرفتن NULL بعد از mallocکرش در صورت کمبود حافظهبررسی نتیجه پیش از استفاده
استفاده از اشاره‌گر بعد از freeاشاره‌گر معلقنوشتن ‎p = NULL;‎ بعد از free
نوشتن ‎a = realloc(a, n)‎در صورت شکست، بلوک قبلی گم می‌شودگرفتن نتیجه در اشاره‌گر موقت

بخش بعد

در بخش بعد داده‌های ناهمگن را کنار هم می‌گذاریم، مثلاً نام، شماره‌ی دانشجویی و نمره‌ی یک دانشجو در یک بسته (struct)، و سراغ فایل‌ها می‌رویم. دو چیزی که در این بخش یاد گرفتیم آنجا دوباره به کار می‌آید: اشاره‌گر به ساختار، که با ‎->‎ به فیلدهایش می‌رسیم، و تابع fopen که به‌جای یک مقدار، یک اشاره‌گر (‎FILE *‎) برمی‌گرداند.