Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 12.518 phút đọc

Con trỏ và hàm

Sau bài này bạn sẽ làm được

  • Viết hàm nhận con trỏ để sửa dữ liệu của người gọi
  • Biết khi nào trả về con trỏ là an toàn
  • Nhận ra con trỏ treo do trả về địa chỉ biến cục bộ
  • Đặt quy ước rõ ràng về ai sở hữu vùng nhớ

Con trỏ mở ra hai khả năng mà hàm C bình thường không có: sửa được dữ liệu của người gọi, và làm việc với dữ liệu lớn mà không phải sao chép. Đổi lại, nó mang tới một câu hỏi mới mà bạn phải trả lời cho mọi hàm: khi hàm kết thúc, vùng nhớ đó còn sống không.

#Truyền con trỏ vào hàm

truyen.c
#include <stdio.h>

void tang(int *p)
{
    if (p == NULL) return;

    *p += 1;
}

int main(void)
{
    int n = 41;

    tang(&n);
    printf("%d\n", n);      /* 42 */

    return 0;
}

Bài 8.7 đã nói kỹ: C luôn truyền theo giá trị, kể cả ở đây. Bản thân con trỏ p là bản sao của &n. Thứ được chia sẻ là ô nhớ mà nó trỏ tới, không phải bản thân con trỏ.

void thu(int *p)
{
    p = NULL;         /* chỉ đổi bản sao trong hàm, q của người gọi không đổi */

    /* Nếu bỏ dòng trên và viết *p = 1; thì mới sửa được dữ liệu của người gọi.
       Nhưng viết *p = 1; NGAY SAU dòng trên là giải tham chiếu con trỏ rỗng. */
}

int  n = 5;
int *q = &n;

thu(q);
printf("%p\n", (void *)q);   /* vẫn là địa chỉ của n */

#Tránh sao chép dữ liệu lớn

chi-phi.c
typedef struct {
    double m[100][100];      /* 80 000 byte */
} MaTran;

double tong_1(MaTran m)             /* sao chép 80 KB mỗi lời gọi */
{
    double s = 0.0;

    for (int i = 0; i < 100; ++i)
        for (int j = 0; j < 100; ++j)
            s += m.m[i][j];

    return s;
}

double tong_2(const MaTran *m)      /* sao chép 8 byte */
{
    double s = 0.0;

    for (int i = 0; i < 100; ++i)
        for (int j = 0; j < 100; ++j)
            s += m->m[i][j];

    return s;
}
terminal
# Gọi mỗi hàm một trăm nghìn lần
./chi-phi
truyen theo gia tri : 2.418 s
truyen con tro      : 1.032 s
ti le               : 2.3 lan
Kích thước dữ liệuNên truyền thế nào
Tới khoảng 16 byteTheo giá trị, quy ước gọi hàm đặt gọn vào thanh ghi
Lớn hơn, hàm không sửaconst con trỏ
Lớn hơn, hàm có sửaCon trỏ không const
Cần bản sao độc lậpTheo giá trị, dù lớn

#Trả về con trỏ

Trả về con trỏ hữu ích nhưng đặt ra câu hỏi vòng đời. Có bốn nguồn gốc cho con trỏ trả về, và chỉ một trong bốn là sai.

Con trỏ trỏ tớiAn toànVí dụ
Biến cục bộ của hàmKHÔNGreturn &x; với int x cục bộ
Vùng vừa cấp phát độngCó, nhưng người gọi phải giải phóngreturn malloc(n);
Dữ liệu mà người gọi truyền vàoreturn strchr(s, c);
Biến static hoặc toàn cụcCó, nhưng chỉ có một bản dùng chungreturn bo_dem_static;
Trả về địa chỉ biến cục bộ
int *tao_so(void)
{
    int x = 42;

    return &x;      /* x chết ngay khi hàm kết thúc */
}

int *p = tao_so();

printf("%d\n", *p);   /* có thể in 42, có thể in rác, tùy lần chạy */
Ba cách đúng
/* Cách 1: người gọi cấp chỗ, hàm chỉ ghi vào */
void tao_so(int *ra)
{
    *ra = 42;
}

int n;
tao_so(&n);

/* Cách 2: hàm cấp phát, người gọi giải phóng */
int *tao_so(void)
{
    int *p = malloc(sizeof *p);

    if (p != NULL) *p = 42;

    return p;
}

int *p = tao_so();

if (p != NULL) {
    printf("%d\n", *p);
    free(p);
}

/* Cách 3: trả về con trỏ vào dữ liệu người gọi đã có */
char *tim(char *s, char c)
{
    for (; *s != '\0'; ++s)
        if (*s == c) return s;

    return NULL;
}
terminal
gcc -std=c17 -Wall sai.c -o sai
sai.c:6:12: warning: function returns address of local variable [-Wreturn-local-addr]

Trường hợp mảng static là ngoại lệ

/* Mảng static sống suốt chương trình nên trả về địa chỉ của nó là hợp lệ. */
const char *thu_trong_tuan(int n)
{
    static const char *ten[] = {
        "Chu nhat", "Thu hai", "Thu ba", "Thu tu",
        "Thu nam", "Thu sau", "Thu bay",
    };

    return (n >= 0 && n < 7) ? ten[n] : NULL;
}

Nhưng nhớ cái bẫy ở Bài 8.9: nếu hàm ghi vào bộ đệm static rồi trả về nó, hai lời gọi liên tiếp sẽ giẫm lên nhau. Chỉ dùng cách này khi dữ liệu là hằng.

#Quy tắc vàng về vòng đời

Quy tắc vàng
Con trỏ chỉ hợp lệ trong khoảng thời gian mà đối tượng nó trỏ tới còn sống. Trước khi trả về hay lưu lại một con trỏ, luôn hỏi: khi nào vùng nhớ này chết.
Đối tượngSống từ khi nào tới khi nào
Biến cục bộTừ khi vào khối tới khi ra khỏi khối
Tham số hàmTừ khi vào hàm tới khi hàm kết thúc
Biến static hoặc toàn cụcSuốt chương trình
Hằng chuỗiSuốt chương trình
Vùng cấp phát độngTừ malloc tới free, do bạn quyết định
vong-doi.c
int *p;

{
    int x = 42;

    p = &x;
}                    /* x chết ở đây, p thành con trỏ treo */

printf("%d\n", *p);  /* hành vi không xác định */

/* Cùng vấn đề với vòng lặp */
int *q;

for (int i = 0; i < 3; ++i) {
    int tam = i;

    q = &tam;        /* tam mới mỗi lượt, và chết mỗi lượt */
}

printf("%d\n", *q);  /* hành vi không xác định */

#Ai sở hữu vùng nhớ

Quyền sở hữu
Quy ước cho biết ai có trách nhiệm giải phóng một vùng nhớ. C không có cơ chế nào cho việc này, nên nó hoàn toàn là chuyện tài liệu và kỷ luật.
Kiểu hàmAi giải phóngVí dụ trong thư viện chuẩn
Hàm trả về vùng vừa cấpNgười gọimalloc, strdup, fopen
Hàm trả về con trỏ vào dữ liệu có sẵnKhông ai, vì không cấp gìstrchr, strstr, strtok
Hàm ghi vào bộ đệm người gọi cấpNgười gọi, nhưng bộ đệm của họsnprintf, fgets, memcpy
Hàm trả về con trỏ tới dữ liệu tĩnhKhông ai, và không được giải phónglocaltime, getenv

Ghi quy ước vào tên và vào tài liệu

/* Trả về vùng nhớ mới. Người gọi PHẢI gọi free.
   Trả về NULL nếu không đủ bộ nhớ. */
char *tao_ban_sao(const char *s);

/* Ghi vào bộ đệm do người gọi cấp. Không cấp phát gì.
   Trả về độ dài đáng lẽ cần, giống snprintf. */
size_t dinh_dang(char *ra, size_t co, int n);

/* Trả về con trỏ vào chính chuỗi đầu vào. Không được giải phóng.
   Chỉ hợp lệ khi chuỗi đầu vào còn sống. */
const char *tim_tu(const char *s, const char *tu);

Tự làm thử

  1. Viết hàm nhận con trỏ và sửa giá trị của người gọi, thêm kiểm tra NULL ở đầu hàm.
  2. Viết hàm gán NULL cho tham số con trỏ bên trong, chứng minh con trỏ của người gọi không đổi.
  3. Đo thời gian truyền một struct 80 kilobyte theo giá trị và theo con trỏ, mỗi cách một trăm nghìn lần.
  4. Cài chương trình trả về địa chỉ biến cục bộ, in giá trị trước và sau khi gọi một hàm khác, ghi lại kết quả.
  5. Viết ba phiên bản hàm tạo chuỗi theo ba quy ước sở hữu trong bảng, so sánh mã ở chỗ gọi.
  6. Gọi free lên con trỏ trả về từ strchr và chạy dưới valgrind, đọc thông báo.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Truyền con trỏ cho phép hàm sửa dữ liệu của người gọi, nhưng bản thân con trỏ vẫn là bản sao.
  • Dữ liệu lớn hơn khoảng mười sáu byte nên truyền bằng con trỏ, kèm const nếu hàm không sửa.
  • Không bao giờ trả về địa chỉ biến cục bộ. Lỗi này thường cho kết quả đúng ở lần thử đầu tiên.
  • Con trỏ chỉ hợp lệ trong khoảng thời gian đối tượng nó trỏ tới còn sống.
  • Mỗi hàm trả về con trỏ phải ghi rõ ai sở hữu vùng nhớ đó và ai có trách nhiệm giải phóng.