Menu

calloc và realloc trong C: bộ nhớ khởi tạo 0 và mở rộng mảng

calloc cấp cho bạn vùng nhớ đã được đặt về 0 và nhân kích thước một cách an toàn; realloc mở rộng một khối bạn đã ghi dữ liệu. Bài này nói về cả hai, cách dùng con trỏ tạm để realloc thất bại không gây rò rỉ, và khi nào calloc tốt hơn malloc cộng memset.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

malloc trả lời một câu hỏi: cho tôi chừng này byte. Hai hàm đi cùng trong <stdlib.h> trả lời những câu hỏi tiếp theo: cho tôi chừng này byte, đã được xóa sạch (calloc), và tôi cần khối nhớ đang có lớn hơn (realloc).

calloc: số phần tử, kích thước và các số 0

void *calloc(size_t count, size_t size);

Hai đối số thay vì một, và kết quả được ghi đầy các byte 0.

Đây là trường hợp kinh điển: một mảng histogram hay mảng đếm mà mọi ô phải bắt đầu từ 0. Với malloc bạn cần một vòng lặp; calloc làm việc đó ngay trong lúc cấp phát, thường là không tốn thêm gì vì hệ điều hành vốn đã cấp các trang nhớ mới ở trạng thái toàn số 0.

Lý do về tràn số

Việc tách thành hai đối số không chỉ để cho đẹp. Thử xét một số lượng được đọc từ tệp:

size_t n = huge_value_from_input;

int *a = malloc(n * sizeof *a);     /* the product can wrap around */
int *b = calloc(n, sizeof *b);      /* required to detect the overflow */

Nếu n * sizeof *a vượt quá giá trị mà size_t chứa được, nó quay vòng thành một số nhỏ, malloc thành công với một khối bé xíu, và mọi lần ghi sau đó đều chạy xa quá cuối khối. Tiêu chuẩn C yêu cầu calloc phải thất bại và trả về NULL trong trường hợp này. Khi kích thước đến từ bên ngoài chương trình, phép kiểm tra đó rất đáng có.

"Đặt về 0" thật ra nghĩa là gì

calloc ghi các byte 0. Với số nguyên và ký tự, đó chính là giá trị 0, đúng thứ bạn cần. Với con trỏ và số thực, toàn bộ bit bằng 0 là con trỏ null và 0.0 trên mọi nền tảng phổ biến, nhưng tiêu chuẩn C không hứa điều đó. Code cần tính di động tuyệt đối sẽ gán rõ ràng các giá trị này.

calloc so với malloc + memset

int *a = calloc(n, sizeof *a);

int *b = malloc(n * sizeof *b);
if (b != NULL) {
    memset(b, 0, n * sizeof *b);
}

Hai cách cho ra cùng một kết quả. Nên ưu tiên calloc: chỉ một dòng, có kiểm tra tràn số, và với các khối lớn nó có thể không cần chạm vào vùng nhớ chút nào. Hãy dùng malloc thường khi bạn sắp ghi đè mọi byte: đặt về 0 cả một megabyte mà bạn sẽ ghi đầy ngay sau đó là lãng phí hoàn toàn.

realloc: thay đổi kích thước

void *realloc(void *p, size_t newsize);

realloc trả về một khối newsize byte có nội dung giống khối cũ trong phạm vi kích thước nhỏ hơn giữa hai khối. Nó có thể mở rộng khối ngay tại chỗ, hoặc cấp phát một khối mới, sao chép dữ liệu rồi giải phóng khối cũ. Bạn không thể biết là trường hợp nào, nên con trỏ duy nhất bạn được dùng sau đó là con trỏ nó trả về.

Cách dùng con trỏ tmp

Ba dòng xoay quanh tmp chính là lý do khiến hàm này nổi tiếng. Cách viết tắt hấp dẫn kia là sai:

/* BUG: leaks the original block when realloc fails */
p = realloc(p, newsize);
if (p == NULL) {
    return;
}

Khi realloc thất bại, nó trả về NULL và giữ nguyên khối ban đầu, vẫn được cấp phát và không thay đổi. Gán thẳng kết quả vào p sẽ ghi đè con trỏ duy nhất tới khối đó, nên khối ấy không bao giờ được giải phóng nữa: một vụ rò rỉ bộ nhớ, và lại xảy ra đúng lúc bộ nhớ đang khan hiếm. Hãy gán vào tmp, kiểm tra tmp, rồi mới chốt:

void *tmp = realloc(p, newsize);
if (tmp == NULL) {
    /* p is still valid; handle the failure however suits the caller */
    return 0;
}
p = tmp;

Thêm hai hành vi đáng biết: realloc(NULL, n) hoạt động y hệt malloc(n), nhờ đó một hàm mở rộng xử lý được lần gọi đầu tiên mà không cần trường hợp đặc biệt. Và khi mở rộng, các byte mới chưa được khởi tạo: chỉ nội dung cũ được giữ lại.

Mở rộng mảng: mẫu chuẩn

Đây là mẫu mà hầu hết các container trong C đều dùng: một độ dài, một sức chứa, và nhân đôi khi hai giá trị này gặp nhau.

Hai điểm thiết kế gánh phần chính ở đây.

Nhân đôi chứ không cộng thêm một. Mở rộng theo một lượng cố định khiến mỗi lần thêm phần tử đều phải sao chép cả mảng, nên ghi đầy n phần tử tốn khoảng n² lần sao chép byte. Nhân đôi khiến việc sao chép đủ hiếm để trung bình mỗi lần thêm chỉ tốn thời gian hằng số. Điểm khởi đầu cap == 0 ? 4 : cap * 2 xử lý lần thêm đầu tiên mà không cần nhánh riêng, vì realloc(NULL, n) chỉ là malloc.

Struct sở hữu một lần cấp phát duy nhất. v.data được giải phóng đúng một lần, bởi nơi sở hữu struct. Viết rõ quy tắc sở hữu đó ra, trong một chú thích, hoặc bằng cách luôn đi kèm mỗi vec_init với một vec_free, là điều giữ cho một container đang lớn dần không bị rò rỉ khi nó được chuyền qua các hàm.

Thu nhỏ

realloc với kích thước nhỏ hơn sẽ cắt bớt một khối, rất hữu ích sau khi bạn đã đọc một lượng dữ liệu không biết trước vào một bộ đệm quá lớn:

Thu nhỏ thất bại không phải lỗi đáng để dừng chương trình: khối cũ, lớn hơn, vẫn hợp lệ và vẫn đúng. Đây là trường hợp duy nhất mà bỏ qua thất bại là quyết định đúng, nhưng chú ý nó vẫn đi qua tmp, vì quy tắc không ghi đè con trỏ đang dùng không hề thay đổi.

Nên chọn hàm nào

Bạn cầnDùng
Một số byte, nội dung không quan trọngmalloc(n * sizeof *p)
Một số phần tử, đặt về 0calloc(n, sizeof *p)
Số lượng đến từ đầu vào không tin cậycalloc, để có kiểm tra tràn số
Khối đang có, lớn hơn hoặc nhỏ hơnrealloc qua một con trỏ tmp
Lần cấp phát đầu tiên trong vòng lặp mở rộngrealloc(NULL, n), không cần trường hợp đặc biệt

Cả bốn đều được giải phóng bằng cùng một free, đúng một lần.

Các lỗi thường gặp

  • p = realloc(p, n): rò rỉ bộ nhớ khi thất bại. Luôn đi qua tmp.
  • Giữ một con trỏ cũ trỏ vào bên trong khối. Sau realloc, dữ liệu có thể đã bị di chuyển, nên mọi con trỏ trỏ vào bên trong khối hay địa chỉ tính từ chỉ số đã lưu đều hết hiệu lực. Chỉ số vẫn đúng sau khi di chuyển; con trỏ thì không.
  • Cho rằng các byte mới bằng 0. Chỉ calloc mới đặt về 0. realloc để vùng mở rộng ở trạng thái chưa khởi tạo.
  • calloc(n * size, 1): cách này vứt bỏ chính phép kiểm tra tràn số mà dạng hai đối số được tạo ra để có.
  • Giải phóng con trỏ cũ sau khi realloc thành công. realloc đã giải phóng nó rồi; gọi free lần nữa là giải phóng hai lần (double free).

Câu hỏi thường gặp

Khác nhau giữa calloc và malloc trong C là gì?

Có hai điểm khác. calloc(n, size) nhận số phần tử và kích thước mỗi phần tử riêng biệt, đồng thời kiểm tra để phép nhân không bị tràn số, còn malloc(n * size) có thể âm thầm quay vòng. Ngoài ra calloc ghi toàn bộ khối bằng số 0, trong khi malloc để nguyên những gì có sẵn trong vùng nhớ đó.

realloc hoạt động như thế nào trong C?

realloc(p, newsize) trả về một khối có kích thước mới, giữ nguyên nội dung hiện có trong phạm vi kích thước nhỏ hơn giữa hai khối. Nó có thể mở rộng khối tại chỗ hoặc di chuyển khối đi nơi khác, nên con trỏ được trả về là con trỏ duy nhất bạn được tiếp tục dùng: con trỏ cũ có thể đã bị giải phóng.

Vì sao nên gán kết quả realloc vào một con trỏ tạm?

Vì p = realloc(p, n) ghi đè p bằng NULL khi lời gọi thất bại, trong khi khối ban đầu vẫn được cấp phát mà không còn con trỏ nào trỏ tới: đó là rò rỉ bộ nhớ. Hãy gán vào tmp trước, kiểm tra xem nó có bằng NULL không, rồi mới viết p = tmp.

Khi nào nên dùng calloc thay vì malloc?

Khi bạn thật sự cần vùng nhớ được đặt về 0 (một mảng bộ đếm, một struct mà các trường cần bắt đầu rỗng, một bộ đệm bạn chỉ ghi một phần) hoặc khi kích thước là số phần tử nhân kích thước phần tử và có thể rất lớn. Nếu đằng nào bạn cũng ghi đè mọi byte, malloc giúp bỏ qua bước đặt về 0.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu