Menu

Tham số hàm trong C: truyền tham trị, con trỏ và mảng

C truyền mọi thứ theo giá trị: hàm nhận bản sao, không bao giờ nhận bản gốc. Điều đó có ý nghĩa gì trong thực tế, con trỏ giả lập truyền tham chiếu ra sao, vì sao mảng lại khác, và cách trả về nhiều kết quả.

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.

Tham số và đối số

Hai từ hay bị dùng lẫn cho nhau nhưng nên tách bạch:

  • Tham số (parameter) là biến trong định nghĩa hàm, ví dụ int n trong int square(int n).
  • Đối số (argument) là giá trị bạn đưa vào tại chỗ gọi hàm, ví dụ 4 trong square(4).

Khi gọi một hàm, C tạo các tham số thành những biến cục bộ mới và sao chép đối số vào đó. Chính bản sao ấy là chủ đề của cả trang này.

C luôn truyền theo giá trị

Đây là quy tắc, và C không có ngoại lệ nào: hàm nhận bản sao của các đối số. Gán giá trị cho tham số chỉ thay đổi bản sao.

value vẫn là 42. Hàm nhận một bản sao tên x, đặt bản sao đó về 0, và bản sao biến mất khi hàm kết thúc. Đổi tên tham số thành value cũng không thay đổi gì: cái tên không nối hai biến lại với nhau, chúng vẫn là hai biến khác nhau.

Điều này đúng với mọi kiểu: int, double, char, con trỏ, kể cả nguyên một struct (sao chép từng trường). Nếu bạn đến từ Python, Java hay JavaScript, hãy lưu ý rằng các ngôn ngữ đó truyền tham chiếu tới đối tượng theo giá trị, nên sửa một đối tượng bên trong hàm thì bên ngoài cũng thấy. C không có cơ chế đó: C sao chép chính đối tượng.

Truyền theo giá trị là một tính năng, không phải hạn chế. Hàm không thể vô tình làm hỏng dữ liệu của bên gọi, nhờ vậy code dễ suy luận hơn nhiều.

Thất bại kinh điển: swap

Ví dụ trong mọi giáo trình là một hàm cố hoán đổi hai giá trị:

Logic hoàn toàn đúng: hàm thật sự hoán đổi a và b. Nhưng a và b là bản sao, nên toàn bộ công sức bị vứt bỏ ngay khi hàm kết thúc.

Giả lập truyền tham chiếu bằng con trỏ

Muốn hàm thay đổi được biến của bên gọi, hãy đưa cho nó địa chỉ của biến thay vì giá trị. Bản thân địa chỉ đó vẫn được truyền theo giá trị, tức là con trỏ bị sao chép, nhưng bản sao vẫn trỏ tới đối tượng gốc, nên ghi qua nó sẽ chạm tới vùng nhớ của bên gọi.

Ba mảnh cú pháp gánh toàn bộ ý tưởng:

  • int *a trong danh sách tham số khai báo a là "con trỏ tới một int".
  • &x tại chỗ gọi tạo ra địa chỉ của x.
  • *a bên trong hàm nghĩa là "số int mà a đang trỏ tới", có thể đọc và gán được.

Đây là điều lập trình viên C muốn nói khi dùng cụm "truyền tham chiếu", và cần nói cho chính xác: nó vẫn là truyền theo giá trị. Thứ được sao chép là con trỏ. Gán cho chính a (a = NULL;) chỉ đổi bản sao; gán cho *a mới đổi biến của bên gọi. Trang về con trỏ trình bày đầy đủ mô hình này.

Cái giá phải trả là nhìn vào chỗ gọi hàm, bạn không còn biết biến có thể bị thay đổi hay không, và đó chính là lý do & là bắt buộc. swap(x, y) sẽ không biên dịch được; bạn phải viết swap(&x, &y), và dấu & ấy là dấu hiệu nhìn thấy được rằng lời gọi này có thể sửa x.

Trả về nhiều kết quả

return chỉ tạo ra một giá trị, nên các đầu ra phụ đi qua tham số con trỏ. Dạng thường gặp là trả về một mã trạng thái, còn kết quả thật được ghi qua con trỏ:

Lưu ý rằng q và r giữ nguyên giá trị cũ khi lời gọi thất bại, vì hàm đã return trước khi ghi bất cứ thứ gì. Đó là giao kèo mà bên gọi phải tôn trọng, và cũng là lý do kiểm tra trạng thái phải đứng trước khi dùng các đầu ra.

Cách còn lại là trả về một struct chứa cả hai trường, vừa tránh hẳn con trỏ, vừa thường là thiết kế gọn gàng hơn khi các giá trị thật sự đi cùng nhau.

Mảng thì khác

Mảng không tuân theo quy tắc sao chép, và điều này làm gần như ai cũng vấp. Khi bạn truyền một mảng, nó suy biến (decay) thành con trỏ tới phần tử đầu tiên, nên hàm nhận một địa chỉ chứ không phải bản sao dữ liệu.

Mảng của bên gọi thật sự đã thay đổi, mà không cần & ở đâu cả, vì trong ngữ cảnh này data vốn đã là một địa chỉ. Có ba hệ quả:

int a[] và int *a có nghĩa hoàn toàn như nhau trong danh sách tham số. Ngay cả int a[100] cũng vậy; kích thước bị bỏ qua. Viết int a[] thể hiện ý định, nhưng đừng hiểu nó là "một mảng đang được sao chép".

sizeof bên trong hàm cho kết quả sai. Đây là bug đó:

Trong main, sizeof(data) là 20 byte và độ dài tính ra là 5. Bên trong hàm, sizeof(a) là kích thước của một con trỏ, 8 trên hệ thống 64-bit, nên "độ dài" thành 2. Ở đó đơn giản là không có thông tin về kích thước mảng. Luôn truyền độ dài bằng một tham số riêng. Các trình biên dịch hiện đại cảnh báo lỗi này khi bật -Wall.

Dùng const khi bạn không định ghi. const int a[] nói rằng hàm chỉ đọc, điều mà trình biên dịch bắt buộc tuân theo và người đọc code có thể tin cậy:

int sum(const int a[], int n);        /* promises not to modify the array */

Chuỗi cũng là mảng, nên điều tương tự áp dụng: void greet(const char *name) là chữ ký chuẩn cho một hàm đọc chuỗi mà không sửa nó.

Còn chi phí thì sao?

Truyền một struct lớn theo giá trị sẽ sao chép từng byte của nó ở mỗi lần gọi. Với struct chỉ có vài trường thì không sao, và thường còn nhanh hơn bước gián tiếp mà con trỏ thêm vào. Với struct lớn, hãy truyền con trỏ, và đánh dấu const nếu hàm chỉ đọc:

struct Config { char name[64]; int flags[32]; double weights[128]; };

void applyConfig(const struct Config *cfg);   /* no 512-byte copy per call */

const struct Config *cfg nghĩa là "con trỏ tới một Config mà tôi sẽ không sửa", vừa có hiệu quả của con trỏ vừa có sự an toàn của bản sao.

Lỗi thường gặp

  • Mong một tham số thường thay đổi được biến của bên gọi. Không thể. Hãy truyền con trỏ.
  • Quên & tại chỗ gọi. swap(x, y) khi swap cần con trỏ là lỗi kiểu: trình biên dịch bắt được, nhưng chỉ khi có prototype trong phạm vi.
  • Quên * bên trong hàm. a = b; trên hai tham số con trỏ chỉ hoán đổi các bản sao cục bộ, không phải giá trị. Bạn cần *a = *b;.
  • Dùng sizeof trên tham số mảng. Luôn sai. Hãy truyền độ dài.
  • Trả về con trỏ tới biến cục bộ. Biến cục bộ mất đi khi hàm kết thúc, nên bên gọi cầm một dangling pointer, và đó là undefined behavior. Hãy trả về theo giá trị, ghi vào bộ đệm do bên gọi cung cấp, hoặc cấp phát bằng malloc.
  • Không kiểm tra tham số con trỏ có phải NULL không. Hàm dereference mọi thứ được đưa vào sẽ crash khi đối số là null.

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

C có truyền tham chiếu không?

Không. C chỉ có đúng một cách: truyền theo giá trị. Hàm luôn nhận bản sao của từng đối số, nên gán giá trị cho tham số không bao giờ chạm tới biến của bên gọi. Thứ trông giống truyền tham chiếu thực ra là truyền một con trỏ theo giá trị: con trỏ được sao chép, nhưng bản sao vẫn trỏ tới đối tượng của bên gọi, nên hàm có thể sửa đối tượng đó thông qua con trỏ.

Truyền mảng vào hàm trong C như thế nào?

Viết tham số là int a[] hoặc int *a, hai cách này có nghĩa như nhau, rồi truyền độ dài bằng một tham số riêng: int sum(const int a[], int n). Mảng không bị sao chép; hàm nhận con trỏ tới phần tử đầu tiên, nên mọi thay đổi trên a[i] đều hiện ra ở bên gọi.

Vì sao sizeof không dùng được với tham số mảng trong C?

Vì tham số đó thực chất là con trỏ. sizeof(a) bên trong hàm cho kích thước của con trỏ (thường là 8 byte), không phải kích thước mảng, nên sizeof(a)/sizeof(a[0]) ra một số kiểu như 2 thay vì số phần tử. Luôn truyền độ dài một cách tường minh.

Làm sao để một hàm C trả về nhiều hơn một giá trị?

Truyền con trỏ cho các kết quả phụ rồi ghi qua chúng: int divide(int a, int b, int *quotient, int *remainder) trả về cờ báo thành công và điền cả hai đầu ra. Cách còn lại là trả về một struct gom các giá trị lại với nhau.

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