Chuyển tới nội dung chính

Hàm trong C

Cho tới giờ, mọi chương trình của chúng ta đều nằm gọn trong một hàm main. Cách viết đó ổn với bài nhỏ, nhưng khi bài toán lớn dần thì main phình ra hàng trăm dòng, lặp đi lặp lại những đoạn xử lý giống nhau và rất khó đọc. Hàm (function) là một khối lệnh có tên, làm một việc xác định, có thể được gọi đi gọi lại nhiều lần.

Hãy hình dung một tình huống rất thật. Bạn viết chương trình quản lý điểm cho lớp bốn mươi sinh viên: nhập điểm, kiểm tra điểm có nằm trong khoảng hợp lệ từ 0 tới 10 hay không, tính trung bình, xếp loại, tìm người đứng đầu, in bảng kết quả. Nếu nhét tất cả vào main, bạn sẽ có vài trăm dòng, trong đó riêng đoạn kiểm tra điểm hợp lệ xuất hiện ba lần (lúc nhập, lúc sửa, lúc nạp từ tệp). Ngày mai thầy đổi thang điểm từ 10 sang 100, bạn phải lần mò khắp chương trình để tìm cho hết những chỗ cần sửa. Bỏ sót một chỗ thì chương trình vẫn dịch được, vẫn chạy, chỉ có kết quả là sai một cách âm thầm.

Hàm sinh ra để chặn đúng loại rắc rối đó. Bạn viết một lần int hopLe(double diem) rồi gọi nó ở cả ba nơi; đổi thang điểm chỉ là sửa đúng một dòng bên trong hàm ấy. Nói cách khác, hàm cho phép ta đặt tên cho một ý tưởng, gói ý tưởng đó lại, rồi từ đó chỉ cần nhớ cái tên mà không phải nhớ cách nó làm. Đây là bước trưởng thành lớn của người mới học: chuyển từ tư duy viết lệnh sang tư duy dựng khối.

Thử ngay: xem lời gọi hàm chạy từng bước

Bấm ▶ Chạy (hoặc kéo thanh trượt) để theo dõi: khi main gọi cong(x, y), một khung (stack frame) mới được đẩy lên ngăn xếp cho hàm cong, đối số được sao chép vào tham số, hàm chạy xong thì khung được gỡ và giá trị trả về main. Các mục bên dưới sẽ lần lượt gọi tên từng chi tiết bạn vừa thấy, từ tham số tới khung ngăn xếp.

Lời gọi hàm tạo khung mới trên ngăn xếp C
1#include <stdio.h>
2
3int cong(int a, int b) {
4 int tong = a + b;
5 return tong;
6}
7
8int main(void) {
9 int x = 5, y = 8;
10 int kq = cong(x, y);
11 printf("Tong = %d\n", kq);
12 return 0;
13}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu hàm main(). Ngăn xếp có một khung duy nhất cho main, chưa có biến nào.
1/9

Vì sao cần hàm

Hãy hình dung bạn cần tính trung bình cộng ở ba chỗ khác nhau. Nếu chép cùng một đoạn lệnh ba lần, chỉ cần sửa công thức một chỗ là phải nhớ sửa cả ba. Thay vào đó, ta viết một hàm rồi gọi tên nó ở ba chỗ. Đây chính là tinh thần của lập trình cấu trúc (structured programming): chia bài toán lớn thành nhiều việc nhỏ, mỗi việc gói vào một hàm, rồi ghép lại.

Bốn lợi ích của hàm

Tái sử dụng (viết một lần, dùng nhiều lần), dễ đọc (mỗi hàm là một ý), dễ sửa (lỗi nằm gọn trong một hàm), và chia để trị (nhiều người cùng làm, mỗi người vài hàm). Thật ra ta đã quen với hàm từ chương đầu mà không để ý: printfscanf đều là hàm viết sẵn trong thư viện chuẩn.

Định nghĩa và lời gọi hàm

Một định nghĩa hàm (function definition) gồm bốn phần: kiểu trả về, tên hàm, danh sách tham số trong cặp ngoặc tròn, và thân hàm trong cặp ngoặc nhọn.

#include <stdio.h>

// function definition: returns the sum of two integers
int cong(int a, int b) {
int tong = a + b; // local variable holding the sum
return tong; // send the result back to the caller
}

int main(void) {
int x = 5, y = 8;
int kq = cong(x, y); // function call, kq receives 13
printf("Tong = %d\n", kq); // prints: Tong = 13
return 0;
}
  • Kiểu trả về (return type) là kiểu của giá trị mà hàm gửi ngược lại nơi gọi, ví dụ int, double, char. Nếu hàm không trả về gì thì kiểu trả về là void.
  • Danh sách tham số (parameters) là các biến nhận dữ liệu đầu vào, mỗi tham số ghi rõ kiểu và tên, cách nhau bằng dấu phẩy.
  • Thân hàm là các câu lệnh thực hiện công việc. Lệnh return kết thúc hàm và trả giá trị về.

Khi gặp dòng cong(x, y), chương trình tạm dừng main, nhảy vào thân hàm cong, thực hiện xong rồi quay lại đúng chỗ vừa rời đi, mang theo giá trị trả về. Ta nói mainnơi gọi (caller) còn conghàm được gọi (callee).

Cần tách bạch hai việc mà người mới hay gộp làm một. Khai báo (declaration) là cho trình biên dịch biết hàm tên gì, nhận vào kiểu gì, trả về kiểu gì. Định nghĩa (definition) là viết ra thân hàm, tức nói rõ nó làm việc đó thế nào. Một hàm chỉ được định nghĩa đúng một lần trong toàn chương trình, nhưng có thể được khai báo ở nhiều nơi. Khi bạn viết #include <stdio.h>, thứ được kéo vào chính là một loạt khai báo: tệp tiêu đề chỉ nói printf nhận gì và trả về gì, còn thân thật của printf nằm sẵn trong thư viện chuẩn dưới dạng mã máy đã dịch. Hiểu sự chia đôi này thì bạn đọc được thông báo lỗi: "undeclared" là thiếu khai báo, còn "undefined reference" là có khai báo nhưng không tìm thấy thân hàm ở đâu cả.

Tham số và đối số

Hai từ này nghe rất giống nhau nên người mới hay nhầm.

  • Tham số (parameter) là biến ghi trong ngoặc khi định nghĩa hàm. Trong ví dụ trên, ab là tham số. Chúng giống như những ô trống chờ được điền.
  • Đối số (argument) là giá trị cụ thể ta đưa vào khi gọi hàm. Khi viết cong(x, y), các đối số là giá trị của xy, tức 5 và 8.
Cách nhớ nhanh

Tham số là tên trong bản thiết kế của hàm, đối số là dữ liệu thật sự được rót vào lúc chạy. Đối số được gán cho tham số theo đúng thứ tự, từ trái sang phải.

Nguyên mẫu hàm

Trình biên dịch C đọc mã từ trên xuống dưới. Nếu main gọi một hàm mà tới lúc đó hàm chưa được khai báo, trình biên dịch sẽ báo lỗi hoặc đoán sai kiểu. Có hai cách xử lý: đặt định nghĩa hàm trước main, hoặc khai báo nguyên mẫu (prototype) ở đầu tệp. Nguyên mẫu là dòng đầu của định nghĩa hàm, kết thúc bằng dấu chấm phẩy, không có thân.

#include <stdio.h>

// prototype: declared before use, body comes later
int cong(int a, int b);

int main(void) {
printf("%d\n", cong(3, 4)); // 7
return 0;
}

// full definition placed after main
int cong(int a, int b) {
return a + b;
}

Vì sao trình biên dịch không tự đi tìm hàm ở cuối tệp cho xong? Vì để sinh mã cho một lời gọi, nó phải biết trước mỗi đối số chiếm bao nhiêu byte và giá trị trả về thuộc kiểu nào. Nguyên mẫu là mẩu thông tin tối thiểu để nó làm được việc đó ngay khi quét tới lời gọi. Nguyên mẫu còn có tác dụng canh giữ: đã khai báo int cong(int a, int b); thì lời gọi thiếu đối số hay sai kiểu sẽ bị chặn ngay lúc dịch. Trong dự án thật, người ta gom các nguyên mẫu vào một tệp tiêu đề .h để mọi tệp nguồn cùng nhìn thấy một bản mô tả thống nhất. Cũng nên viết (void) thay vì để ngoặc rỗng với hàm không có tham số, vì ngoặc rỗng mang nghĩa cổ điển là "chưa nói gì về tham số", tức trình biên dịch sẽ không kiểm tra giúp bạn.

Quên nguyên mẫu của hàm trả về số thực

Nếu hàm double binhPhuong(double x) đặt sau main mà không có nguyên mẫu, trình biên dịch cũ sẽ mặc định đoán rằng hàm trả về int, đọc sai vùng giá trị trả về và bạn nhận những con số vô nghĩa như 0.000000. Đây là loại lỗi tệ nhất: dịch được, chạy được, chỉ sai kết quả. Hãy bật cảnh báo (gcc -Wall) và coi mọi cảnh báo là lỗi phải sửa.

Truyền tham số theo trị

C truyền tham số theo trị (pass by value). Khi gọi hàm, đối số được sao chép sang tham số. Hàm làm việc trên bản sao đó, nên mọi thay đổi bên trong hàm không ảnh hưởng tới biến gốc ở nơi gọi.

#include <stdio.h>

void thuTangGapDoi(int x) { // x is a copy of the argument
x = x * 2; // changes the copy only
printf("Trong ham: x = %d\n", x);
}

int main(void) {
int a = 10;
thuTangGapDoi(a);
printf("Sau khi goi: a = %d\n", a); // still 10
return 0;
}

Kết quả in ra là Trong ham: x = 20 rồi Sau khi goi: a = 10. Biến a không hề đổi, vì x chỉ là bản sao.

Hãy giảng chậm điều gì xảy ra trong bộ nhớ. Trước lời gọi, main có một ô nhớ tên a chứa 10. Khi thực hiện thuTangGapDoi(a), máy đọc con số đang nằm trong ô a rồi chép nó vào một ô nhớ hoàn toàn mới thuộc về hàm, ô đó tên x. Từ lúc ấy, xa là hai ô độc lập ở hai địa chỉ khác nhau, chỉ tình cờ giữ cùng một con số. Lệnh x = x * 2 ghi 20 đè lên ô x; ô a ngoài kia không hề bị đụng tới, vì hàm không biết a nằm ở địa chỉ nào. Hàm kết thúc, ô x biến mất và 20 biến mất theo. Cụm từ truyền theo trị nghĩa đúng như vậy: cái đi vào hàm là giá trị, không phải bản thân biến.

Đây là một đánh đổi có chủ ý. Cái được là an toàn và dễ suy luận: đọc main, bạn biết chắc một lời gọi hàm thông thường không thể lén sửa biến của bạn. Cái mất là chi phí sao chép, không đáng kể với int hay double, nhưng đáng kể nếu tham số là một struct vài trăm byte, và đó là lý do struct lớn thường được truyền qua con trỏ.

Muốn sửa biến gốc thì phải truyền con trỏ

Vậy làm sao viết được hàm hoán đổi giá trị hai biến? Cách duy nhất là đưa cho hàm địa chỉ của biến thay vì giá trị của nó. Địa chỉ cũng là một giá trị nên vẫn được truyền theo trị, nhưng có nó thì hàm biết đường tới đúng ô nhớ ngoài kia mà ghi đè. Toán tử & lấy địa chỉ của biến, còn * đặt trước con trỏ cho phép đọc hoặc ghi vào ô mà nó trỏ tới.

#include <stdio.h>

// receives addresses, so it can modify the caller's variables
void hoanDoi(int *pa, int *pb) {
int tam = *pa; // read the value stored at address pa
*pa = *pb; // write into the caller's first variable
*pb = tam; // write into the caller's second variable
}

int main(void) {
int x = 3, y = 7;
hoanDoi(&x, &y); // pass the addresses
printf("x = %d, y = %d\n", x, y); // prints: x = 7, y = 3
return 0;
}

Chú ý là bản thân hai con trỏ papb vẫn chỉ là bản sao: viết pa = pb trong hàm thì chỉ bản sao đổi chỗ trỏ, main không hay biết gì. Thứ vượt ra được ngoài không phải phép gán cho con trỏ, mà là phép gán qua dấu sao, tức *pa = .... Đây chính là cơ chế của scanf: ta viết scanf("%d", &n)scanf cần địa chỉ của n mới ghi được số vừa nhập vào đúng ô nhớ ấy. Mảng là trường hợp trông như ngoại lệ: khi truyền mảng vào hàm, cái được sao chép là địa chỉ phần tử đầu chứ không phải toàn bộ nội dung, nên hàm sửa được mảng gốc, và cũng vì thế hàm không tự biết mảng dài bao nhiêu, ta phải truyền thêm kích thước.

Xem con trỏ sửa biến gốc chạy từng bước

Ở bản trace đầu bài, hàm cong chỉ nhận bản sao nên không đụng được biến của main. Trình mô phỏng dưới đây cho thấy mặt còn lại: khi truyền địa chỉ, hàm hoanDoi ghi thẳng vào đúng hai ô nhớ của main và hoán đổi được giá trị. Hãy chú ý các con trỏ a, b trong khung hoanDoi trỏ ngược về ô #1, #2 của main.

Truyền con trỏ để sửa được biến gốc C
1#include <stdio.h>
2
3void hoanDoi(int* a, int* b) {
4 int tam = *a;
5 *a = *b;
6 *b = tam;
7}
8
9int main(void) {
10 int x = 3, y = 7;
11 hoanDoi(&x, &y);
12 printf("%d %d\n", x, y);
13 return 0;
14}
Ngăn xếp stack
main()
#1int x
giá trị3
#2int y
giá trị7
Bộ nhớ động heap
(trống)
main tạo x = 3, y = 7. Xem mỗi biến là một ô nhớ riêng, đánh số #1 và #2.
1/7

Giá trị trả về

Lệnh return làm hai việc cùng lúc: kết thúc hàm ngay lập tức và gửi một giá trị về nơi gọi. Một hàm có thể có nhiều return ở nhiều nhánh, cái nào chạy tới trước thì hàm dừng ở đó. Hàm void không trả giá trị nào, chỉ có thể viết return; trơ trọi để thoát sớm. Một hàm chỉ trả về được đúng một giá trị, nên khi cần nhiều kết quả ta gói chúng vào struct, hoặc nhận thêm tham số con trỏ để ghi kết quả ra ngoài. Cách thứ hai đi kèm một quy ước rất đáng học: dùng giá trị trả về để báo thành công hay thất bại, còn dữ liệu thật thì ghi qua con trỏ.

#include <stdio.h>

// returns 1 on success and writes the result through the pointer,
// returns 0 when the division is impossible
int chia(double tu, double mau, double *ketQua) {
if (mau == 0.0) {
return 0; // failure, ketQua is left untouched
}
*ketQua = tu / mau;
return 1; // success
}

int main(void) {
double kq;
if (chia(7.0, 2.0, &kq)) {
printf("7 / 2 = %.2f\n", kq); // 3.50
}
if (!chia(1.0, 0.0, &kq)) {
printf("Khong the chia cho 0\n");
}
return 0;
}

Nhờ quy ước này, nơi gọi luôn có cách biết kết quả có tin được hay không trước khi đem dùng, thay vì phải đoán.

Đừng trả về địa chỉ của biến cục bộ

Biến cục bộ sống trong khung ngăn xếp của hàm và bị gỡ ngay khi hàm kết thúc. Một hàm trả về &bienCucBo sẽ đưa cho nơi gọi con trỏ chỉ vào vùng nhớ đã bị thu hồi, và mọi lần đọc hay ghi qua con trỏ đó là hành vi không xác định: có thể hôm nay chạy có vẻ đúng, hôm khác lại hỏng. Trả về giá trị thì luôn an toàn, vì giá trị được sao chép ra ngoài trước khi khung bị gỡ.

Biến cục bộ không tự khởi tạo về 0

Biến cục bộ chưa được gán mang đúng những byte rác còn sót trong ngăn xếp từ lời gọi trước, chứ không phải 0. Đoạn int tong; for (...) tong += a[i]; có thể ra đúng trên máy bạn và sai trên máy người khác. Hãy khởi tạo ngay khi khai báo: int tong = 0;. Chỉ biến toàn cục và biến static mới được bảo đảm bắt đầu từ 0.

Phạm vi của biến

Phạm vi (scope) của một biến là vùng mã mà trong đó biến tồn tại và dùng được.

  • Biến cục bộ (local variable) khai báo bên trong một hàm. Nó được tạo khi hàm bắt đầu chạy và bị xoá khi hàm kết thúc. Hai hàm khác nhau có thể dùng cùng một tên biến mà không xung đột.
  • Biến toàn cục (global variable) khai báo bên ngoài mọi hàm, tồn tại suốt thời gian chương trình chạy và mọi hàm đều nhìn thấy. Tiện khi chia sẻ dữ liệu, nhưng dùng nhiều thì khó kiểm soát vì bất kỳ hàm nào cũng sửa được.
  • Biến static: thêm từ khoá static trước một biến cục bộ thì nó giữ lại giá trị giữa các lần gọi hàm. Biến chỉ được khởi tạo một lần ở lần gọi đầu, các lần sau vẫn nhớ giá trị cũ.
#include <stdio.h>

int dem = 0; // global variable, visible everywhere

void tang(void) {
dem = dem + 1; // modifies the global variable
}

void demGoi(void) {
static int lan = 0; // initialized once, keeps its value
lan = lan + 1;
printf("Lan goi thu %d\n", lan);
}

int main(void) {
tang();
tang();
printf("Toan cuc: %d\n", dem); // prints 2
demGoi(); // prints: Lan goi thu 1
demGoi(); // prints: Lan goi thu 2
return 0;
}
Quy tắc thực hành

Ưu tiên biến cục bộ, truyền dữ liệu giữa các hàm qua tham số và giá trị trả về. Chỉ dùng biến toàn cục khi nhiều hàm thật sự cần chia sẻ chung một trạng thái.

Khung ngăn xếp khi gọi hàm

Muốn hiểu tận gốc vì sao biến cục bộ biến mất và vì sao đối số là bản sao, phải nhìn xuống ngăn xếp (call stack). Đó là vùng bộ nhớ hoạt động theo nguyên tắc vào sau ra trước, giống chồng đĩa: đĩa đặt lên sau cùng là đĩa được lấy ra đầu tiên.

Mỗi lời gọi hàm đẩy lên ngăn xếp một khung (stack frame) riêng, chứa tham số, biến cục bộ, và địa chỉ để quay về nơi gọi. Hàm chạy xong thì cả khung bị gỡ trong một nhịp, mọi thứ bên trong biến mất cùng lúc. Đây là lời giải thích ở mức bộ nhớ cho ba điều bạn vừa học: biến cục bộ chỉ sống trong một lần gọi vì nó nằm trong khung; tham số là bản sao vì nó là ô nhớ mới trong khung mới; và trả về địa chỉ biến cục bộ là nguy hiểm vì khung đã bị gỡ trước khi nơi gọi kịp dùng. Cách bố trí này cũng giải thích vì sao hai hàm khác nhau đặt trùng tên biến vẫn không va nhau, và vì sao một hàm gọi lại chính nó vẫn có bộ biến riêng cho mỗi lần gọi.

Ngăn xếp có giới hạn

Ngăn xếp thường chỉ vài megabyte. Hàm đệ quy thiếu điều kiện dừng sẽ đẩy khung mới mãi cho tới khi tràn ngăn xếp và bị hệ điều hành chấm dứt. Tương tự, khai báo mảng cục bộ cực lớn như int a[5000000]; bên trong hàm cũng có thể làm tràn ngay. Dữ liệu lớn nên để ở phạm vi toàn cục hoặc cấp phát động.

Chia nhỏ chương trình thành các hàm

Biết cú pháp chưa đủ, câu hỏi khó hơn là chia thế nào cho hợp lý. Nguyên tắc thứ nhất: mỗi hàm chỉ làm một việc, và tên hàm phải nói đúng việc đó. Nếu mô tả hàm mà phải dùng chữ "và", ví dụ "hàm này nhập điểm và tính trung bình và in bảng", thì nên tách thành ba hàm. Tên tốt bắt đầu bằng động từ: tinhTrungBinh, docDiemTuTep, inBangKetQua.

Nguyên tắc thứ hai: tách phần tính toán khỏi phần vào ra. Một hàm vừa scanf vừa tính vừa printf gần như không kiểm thử được, vì muốn thử phải ngồi gõ tay. Ngược lại, hàm thuần tính toán như double trungBinh(const double a[], int n) chỉ nhận dữ liệu và trả về số, nên bạn gọi nó với mọi bộ dữ liệu mẫu và so kết quả trong nháy mắt. Quy tắc thực hành: main lo giao tiếp với người dùng, các hàm khác lo tính toán.

Nguyên tắc thứ ba: danh sách tham số nên ngắn, và thân hàm nên nhìn hết được trong một màn hình. Hàm cần tới bảy tám tham số thường là dấu hiệu nó ôm quá nhiều việc, hoặc các tham số ấy vốn thuộc về nhau và nên gom thành một struct. Cuối cùng, nếu hàm chỉ đọc chứ không sửa dữ liệu được trỏ tới, hãy đánh dấu const như trong int tongMang(const int a[], int n). Từ khoá này vừa là lời hứa với người đọc, vừa nhờ trình biên dịch canh giữ lời hứa đó.

Bài tập thực hành

Bài tập 1: hàm tìm số lớn nhất trong ba số

Viết hàm int max3(int a, int b, int c) trả về số lớn nhất trong ba số nguyên. Trong main, gọi thử với vài bộ giá trị rồi in kết quả.

Lời giải chi tiết

Bước 1: lấy số lớn hơn giữa ab lưu vào một biến cục bộ. Bước 2: so biến đó với c để ra kết quả cuối. Cả ba tham số được truyền theo trị nên hàm chỉ đọc, không sửa biến gốc.

#include <stdio.h>

// returns the largest of three integers
int max3(int a, int b, int c) {
int lon = a; // local variable, assume a is largest
if (b > lon) lon = b;
if (c > lon) lon = c;
return lon;
}

int main(void) {
printf("%d\n", max3(3, 9, 5)); // 9
printf("%d\n", max3(8, 2, 8)); // 8
return 0;
}

Ý chính: gói logic so sánh vào một hàm giúp main gọn, và gọi lại được nhiều lần với đối số khác nhau.

Bài tập 2: chứng minh truyền theo trị

Viết một hàm thử thay đổi giá trị tham số bên trong, rồi in giá trị biến gốc trong main, qua đó chứng minh bằng kết quả chạy rằng C truyền tham số theo trị.

Lời giải chi tiết

Hàm nhận một bản sao của đối số. Mọi phép gán bên trong chỉ đụng vào bản sao đó, nên biến gốc ở main không đổi. In cả hai giá trị để thấy rõ.

#include <stdio.h>

void doiGiaTri(int n) { // n is a copy of the argument
n = 999; // changes the copy only
printf("Trong ham: n = %d\n", n); // 999
}

int main(void) {
int goc = 10;
doiGiaTri(goc);
printf("Sau khi goi: goc = %d\n", goc); // still 10
return 0;
}

Ý chính: vì tham số là bản sao, hàm không thể sửa trực tiếp biến của nơi gọi. Muốn sửa biến gốc phải truyền địa chỉ qua con trỏ, sẽ học ở chương Con trỏ.

Bài tập 3: biến static đếm số lần gọi

Dùng biến static trong một hàm void để đếm và in tổng số lần hàm đó đã được gọi kể từ đầu chương trình. Gọi hàm năm lần trong main để kiểm chứng.

Lời giải chi tiết

Biến static int dem chỉ khởi tạo về 0 một lần duy nhất ở lần gọi đầu. Mỗi lần vào hàm, nó tăng thêm một và nhớ giá trị cũ giữa các lần gọi, khác hẳn biến cục bộ thường.

#include <stdio.h>

void demSoLanGoi(void) {
static int dem = 0; // initialized once, persists across calls
dem = dem + 1;
printf("Da goi %d lan\n", dem);
}

int main(void) {
for (int i = 0; i < 5; i++)
demSoLanGoi(); // prints 1, 2, 3, 4, 5
return 0;
}

Ý chính: static cho hàm nhớ một thông tin riêng tư qua nhiều lần gọi mà không cần tới biến toàn cục. Nếu bỏ static, hàm sẽ luôn in Da goi 1 lan.

Bài tập 4: hàm sửa được biến gốc

Viết hàm void tangGapDoi(int *p) nhân đôi giá trị của biến mà p trỏ tới, sao cho sau lời gọi thì biến gốc trong main thật sự đổi. Hàm phải an toàn kể cả khi nơi gọi lỡ truyền vào con trỏ NULL.

Lời giải chi tiết

Mấu chốt là hàm nhận địa chỉ chứ không nhận giá trị, nhờ đó nó ghi thẳng vào ô nhớ của main qua *p. Trước khi dùng bất kỳ con trỏ nào ta phải kiểm nó khác NULL, vì đọc hay ghi qua con trỏ NULL là hành vi không xác định. Ở đây khi pNULL thì hàm lặng lẽ thoát, đó là lựa chọn an toàn cho một hàm void.

#include <stdio.h>

// doubles the caller's variable through a pointer
void tangGapDoi(int *p) {
if (p == NULL) { // check BEFORE dereferencing
return; // nothing to do
}
*p = *p * 2; // write into the caller's variable
}

int main(void) {
int a = 10;
tangGapDoi(&a); // pass the address of a
printf("a = %d\n", a); // a = 20, the original really changed

int *khong = NULL;
tangGapDoi(khong); // safe: the function returns immediately
printf("Van chay binh thuong\n");
return 0;
}

So với thuTangGapDoi(int x) ở trên: cùng ý tưởng nhân đôi, nhưng bản nhận int chỉ sửa bản sao nên a vẫn là 10, còn bản nhận int * sửa đúng ô nhớ gốc nên a thành 20. Đó là toàn bộ khác biệt giữa truyền giá trị và truyền địa chỉ.

Tự kiểm tra

1) Khi main gọi cong(x, y), điều gì xảy ra với ngăn xếp?

Một khung mới được đẩy lên cho cong, chồng lên khung main. Đối số được sao chép vào tham số. Khi cong trả về, khung của nó được gỡ và quyền điều khiển quay lại main.

2) Khác nhau giữa biến cục bộ và biến static là gì?

Biến cục bộ thường bị xoá khi hàm kết thúc và tạo lại mỗi lần gọi. Biến static chỉ khởi tạo một lần và giữ lại giá trị qua các lần gọi tiếp theo.

Câu hỏi tự kiểm

Kiểm tra nhanh: hàm, tham số và ngăn xếp0/4 đúngchưa trả lời
  1. 1Truyền tham số theo trị trong C nghĩa là gì?
  2. 2Một biến cục bộ chưa được gán mang giá trị gì?
  3. 3Vì sao không được trả về địa chỉ của một biến cục bộ?
  4. 4Muốn một hàm hoán đổi giá trị hai biến của nơi gọi, cần làm gì?

Tóm tắt

Hàm là đơn vị tổ chức cơ bản của chương trình C. Ta khai báo hàm bằng nguyên mẫu để trình biên dịch biết tên, kiểu tham số và kiểu trả về, rồi định nghĩa thân hàm đúng một lần. Khi gọi, đối số được sao chép vào tham số, vì C luôn truyền theo trị, nên hàm không thể tự ý sửa biến của nơi gọi; muốn sửa được biến gốc phải truyền địa chỉ qua con trỏ và ghi qua dấu sao, đúng như cách scanf vẫn làm.

Mỗi lời gọi tạo một khung riêng trên ngăn xếp chứa tham số và biến cục bộ, và khung ấy bị gỡ khi hàm kết thúc. Đó là lý do biến cục bộ không sống qua các lần gọi trừ khi khai báo static, là lý do không được trả về địa chỉ biến cục bộ, và là lý do đệ quy vô hạn làm tràn ngăn xếp. Về thiết kế, hãy giữ mỗi hàm làm đúng một việc, tách tính toán khỏi vào ra, khởi tạo biến cục bộ ngay khi khai báo, và đánh dấu const cho tham số mà hàm chỉ đọc.

Ghi nhớ

Mỗi lời gọi hàm tạo một khung riêng trên ngăn xếp chứa tham số và biến cục bộ của nó. Đối số được sao chép vào tham số (truyền theo trị), và giá trị trả về đi ngược lên nơi gọi khi khung được gỡ.