BÀI 6. HÀM
Mục tiêu
Sau bài này, học viên:
- Tách chương trình thành hàm: khai báo (nguyên mẫu), định nghĩa, và lời gọi.
- Phân biệt tham số hình thức và đối số thực, giá trị trả về và
void. - Giải thích được truyền theo giá trị: hàm nhận bản sao, không nhận chính biến của nơi gọi.
- Phân biệt phạm vi, thời gian sống, và lớp lưu trữ
auto/staticcục bộ / toàn cục. - Viết được đệ quy có điểm dừng, vẽ được cây gọi, và biết khi nào đệ quy kém vòng lặp.
- Ở mức cao thủ: dùng con trỏ hàm để tách “việc cần làm” khỏi “cách duyệt”, hiểu khung stack, và tránh đệ quy không đổi bài toán con.
Mảng và struct làm tham số sẽ hoàn thiện ở Bài 7 và Bài 8. Ở bài này, tham số là các kiểu cơ bản. Địa chỉ và con trỏ đã được học kỹ ở Bài 3. Bài này dùng lại chúng trong chữ ký hàm: khi nào đưa giá trị, khi nào đưa số nhà.
1. Vì sao phải có hàm
Một khối code chỉ nằm trong main thì không gọi tên được, không kiểm thử riêng được, và bị nhân bản khi cần lần thứ hai. Hàm là một đơn vị có tên, có đầu vào, có đầu ra, và có một việc.
Hợp đồng của hàm gồm:
- Tiền điều kiện: tham số phải thỏa gì thì hàm mới hứa đúng.
- Hậu điều kiện: giá trị trả về hoặc hiệu ứng (in ra, ghi biến) là gì.
- Không hứa những gì: có sửa đối số của nơi gọi hay không.
Ví dụ unsigned long long giai_thua(int n) hứa: nếu 0 <= n <= 20 thì trả về n!. Nếu n ngoài đoạn, hàm được phép từ chối. Cách từ chối phải được ghi: trả về 0, hoặc không được gọi khi sai. Đừng để người gọi đoán.
2. Cấu trúc
kiểu_trả_về tên_hàm(kiểu tham_số, kiểu tham_số)
{
thân;
return giá_trị;
}
void ở kiểu trả về nghĩa là không có giá trị. void trong danh sách tham số nghĩa là không nhận đối số: int main(void).
return vừa cho ra giá trị vừa kết thúc hàm ngay lập tức. Hàm void có thể return; trần. Hàm không void mà chạy hết } không gặp return là hành vi không xác định nếu nơi gọi dùng giá trị đó.
Nguyên mẫu là dòng khai báo kết thúc bằng ;, đặt trước nơi gọi, thường trên main hoặc trong file .h:
int lon_hon(int a, int b);
Định nghĩa là phần có thân { }. Một hàm có một định nghĩa trong cả chương trình sau liên kết, nhưng có thể có nhiều khai báo khớp nhau.
Thứ tự dịch: trình biên dịch đọc từ trên xuống trong một file. Gọi hàm khi chưa thấy nguyên mẫu là lỗi ở C hiện đại, hoặc suy diễn sai kiểu ở trình biên dịch cổ. Hãy khai báo trước.
3. Truyền theo giá trị
C luôn sao chép giá trị đối số vào tham số. Trong hàm, gán cho tham số không đổi biến ở nơi gọi.
void tang(int x)
{
x = x + 1;
}
tang(n) không đổi n. Muốn đổi n, có hai đường:
- Trả giá trị mới về và để nơi gọi gán:
n = tang(n);vớiint tang(int x). - Truyền địa chỉ:
void tang(int *x) { *x = *x + 1; }gọi bằngtang(&n);.
Đường 2 dùng khi cần trả nhiều kết quả hoặc sửa đối tượng lớn. Bài này dùng đường 2 có chủ đích ở ví dụ hoán đổi. Bài 7 sẽ giải thích con trỏ cùng mảng, nơi nó không còn là mẹo mà là mô hình bộ nhớ.
Đừng trả về địa chỉ của biến cục bộ. Biến đó chết khi hàm return, địa chỉ thành vô nghĩa.
4. Phạm vi và thời gian sống
| Loại | Nhìn thấy từ đâu | Sống bao lâu |
|---|---|---|
Biến cục bộ auto | Trong khối khai báo. auto là mặc định, không cần viết | Từ lúc vào khối đến lúc ra khối |
| Tham số | Trong hàm | Suốt một lần gọi |
static cục bộ | Trong hàm | Suốt chương trình, giữ giá trị giữa các lần gọi, khởi tạo một lần |
| Toàn cục | Từ chỗ khai báo đến hết file | Suốt chương trình |
extern | File khác muốn dùng biến toàn cục đó | Không tạo ô nhớ mới, chỉ giới thiệu tên |
static toàn cục | Chỉ trong file khai báo nó | Suốt chương trình, file khác không gọi tên được |
register là gợi ý tốc độ cho biến cục bộ. Không dùng trong bài giải của khóa. volatile thuộc Bài 3: ô nhớ có thể đổi từ ngoài chương trình.
Biến toàn cục làm hàm phụ thuộc ngữ cảnh giấu. Khóa học hạn chế chúng. static cục bộ hợp lệ cho bộ đếm, bộ nhớ đệm, nhưng phải nói rõ hàm không còn “thuần” (cùng đối số có thể ra khác kết quả).
Tên tham số che tên toàn cục. Đừng dựa vào sự che này.
Mỗi lần gọi tạo một khung stack: chỗ cho tham số, biến cục bộ, địa chỉ trở về. Hết return, khung bị hủy. Đệ quy sâu là nhiều khung cùng tồn tại. Hết stack thì chương trình chết, dù thuật toán trên giấy có điểm dừng.
5. Đệ quy
Một hàm gọi chính nó là đệ quy. Hai phần bắt buộc:
- Điểm dừng: một nhánh không gọi nữa.
- Bước thu nhỏ: lời gọi sau phải gần điểm dừng hơn.
Giai thừa:
0! = 1
n! = n * (n - 1)! với n > 0
Khi giai_thua(3) chạy, stack xếp khung theo chiều đi xuống, rồi nhân khi quay lên. Mỗi khung giữ một n riêng.
Gọi giai_thua(3)
┌─────────────────────────┐
│ khung n = 3 │ chờ 3 * kết quả của n = 2
│ ┌───────────────────┐ │
│ │ khung n = 2 │ │ chờ 2 * kết quả của n = 1
│ │ ┌─────────────┐ │ │
│ │ │ khung n = 1 │ │ │ điểm dừng, trả 1, không gọi nữa
│ │ └─────────────┘ │ │
│ │ trả 2 * 1 = 2 │ │
│ └───────────────────┘ │
│ trả 3 * 2 = 6 │
└─────────────────────────┘
Ba khung cùng sống một lúc. Hết return thì khung trên cùng bị gỡ. Nếu không có điểm dừng, chồng này cao mãi đến khi hết stack.
Fibonacci naive F(n) = F(n-1) + F(n-2) đúng toán, nhưng không phải một chồng thẳng. Nó là một cây: F(5) gọi hai nhánh, mỗi nhánh lại gọi hai nhánh. Số khung sâu nhất chỉ khoảng n, còn tổng số lần gọi khoảng O(φ^n). Bản nhớ hoặc bản vòng lặp là O(n). Cao thủ biết chọn.
F(4)
├── F(3)
│ ├── F(2)
│ └── F(1)
└── F(2)
F(2) xuất hiện hai lần. Đó là chỗ đệ quy này làm lại việc đã làm.
Đệ quy đuôi: lời gọi đệ quy là việc cuối, không còn tính sau khi nó trả về. Trình biên dịch có thể biến thành vòng lặp, nhưng C không bắt buộc. Đừng dựa vào tối ưu đó để chứng minh không tràn stack.
6. Con trỏ hàm
Tên hàm, trong biểu thức, được chuyển thành địa chỉ của hàm. Kiểu phải mô tả giá trị trả về và các tham số:
int (*phep)(int, int);
Dấu ngoặc quanh *phep là bắt buộc. int *phep(int, int) là hàm trả về con trỏ int, hoàn toàn khác.
Hình một biến con trỏ hàm: nó không chứa mã. Nó chứa số nhà của một hàm đã viết sẵn. Gọi phep(2, 3) là đi tới nhà đó rồi chạy hàm đang ở đó.
phep cong(int, int) nhan(int, int)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ địa chỉ hàm │── lúc này ─►│ return a+b │ │ return a*b │
└──────────────┘ └──────────────┘ └──────────────┘
│
└── sau phep = nhan, mũi tên chuyển sang nhà bên phải
Đọc kiểu từ trong ra: ( *phep ) là con trỏ, (int, int) là hai tham số, int bên trái là giá trị trả về. Thiếu ngoặc quanh *phep, sao dính vào int và thành “hàm trả về con trỏ”.
Con trỏ hàm dùng để viết một thuật toán tổng quát (tìm, sắp, tính tổng theo một quy tắc) và nhồi quy tắc từ ngoài. qsort trong thư viện chuẩn hoạt động theo cách này; ta sẽ gọi qsort ở Bài 7 và Bài 8.
7. Ví dụ
Ví dụ 6.1 — Hàm lớn nhất của hai số
Phân tích. Tách phép so sánh khỏi main để nơi gọi đọc như một câu. Hàm thuần: không in, không sửa đối số, cùng đầu vào thì cùng đầu ra.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int lon_hon(int a, int b)
{
if (a >= b) {
return a;
}
return b;
}
int main(void)
{
int x;
int y;
printf("x y: ");
if (scanf("%d %d", &x, &y) != 2) {
return 1;
}
printf("Lon hon hoac bang: %d\n", lon_hon(x, y));
return 0;
}
Kết quả.
x y: -4 2
Lon hon hoac bang: 2
Phân tích chi tiết. return b; không cần else vì return phía trên đã thoát. Hai return làm hai hậu điều kiện loại trừ nhau. Hàm không in giúp ta dùng nó trong biểu thức khác, ví dụ lon_hon(lon_hon(a, b), c) cho ba số. Đó là lợi ích của việc không trộn tính toán với nhập xuất.
Ví dụ 6.2 — Hoán đổi đúng bằng địa chỉ
Phân tích. Viết hai hàm: hoan_doi_sai nhận int và hoan_doi nhận int *. Gọi cả hai với cùng cặp số để thấy truyền giá trị.
Lưu đồ của bản đúng.
Mã nguồn.
#include <stdio.h>
void hoan_doi_sai(int a, int b)
{
int t = a;
a = b;
b = t;
}
void hoan_doi(int *a, int *b)
{
int t = *a;
*a = *b;
*b = t;
}
int main(void)
{
int x = 3;
int y = 8;
hoan_doi_sai(x, y);
printf("Sau ban sai: %d %d\n", x, y);
hoan_doi(&x, &y);
printf("Sau ban dung: %d %d\n", x, y);
return 0;
}
Kết quả.
Sau ban sai: 3 8
Sau ban dung: 8 3
Phân tích chi tiết. hoan_doi_sai đổi hai bản sao rồi hủy khung stack. x và y không liên quan đến các bản sao đó sau khi hàm trả về. *a nghĩa là “giá trị nằm tại địa chỉ a”. &x là địa chỉ của x. Gọi hoan_doi(x, y) sẽ không biên dịch được vì kiểu không khớp — may mắn, trình biên dịch chặn giúp. Gọi hoan_doi(NULL, &y) thì biên dịch được và nổ lúc chạy. Tiền điều kiện: hai con trỏ khác NULL và không trỏ chồng nếu bạn không chủ đích.
Ví dụ 6.3 — Nguyên tố, giai thừa, Fibonacci vòng lặp
Phân tích. Ba hàm thuần dùng lại các thuật toán Bài 2 và Bài 5. main chỉ nhập và in. Đây là hình dạng chuẩn của bài tập từ đây về sau.
Lưu đồ của la_nguyen_to. Ba hàm kia là vòng cộng hoặc vòng nhân đã học; lưu đồ dưới đây là nhánh có lối thoát sớm.
Mã nguồn.
#include <stdio.h>
int la_nguyen_to(int n)
{
if (n < 2) {
return 0;
}
for (int d = 2; d <= n / d; d++) {
if (n % d == 0) {
return 0;
}
}
return 1;
}
unsigned long long giai_thua(int n)
{
unsigned long long kq = 1;
if (n < 0 || n > 20) {
return 0;
}
for (int i = 2; i <= n; i++) {
kq *= (unsigned long long) i;
}
return kq;
}
unsigned long long fibonacci(int n)
{
unsigned long long truoc = 0;
unsigned long long hien = 1;
if (n < 0) {
return 0;
}
if (n == 0) {
return 0;
}
for (int i = 2; i <= n; i++) {
unsigned long long tiep = truoc + hien;
truoc = hien;
hien = tiep;
}
return hien;
}
int main(void)
{
int n;
printf("n = ");
if (scanf("%d", &n) != 1) {
return 1;
}
printf("Nguyen to? %d\n", la_nguyen_to(n));
printf("n! = %llu\n", giai_thua(n));
printf("F(n) = %llu\n", fibonacci(n));
return 0;
}
Kết quả.
n = 7
Nguyen to? 1
n! = 5040
F(n) = 13
Phân tích chi tiết. la_nguyen_to trả 0/1 để nơi gọi dùng trong if. Điều kiện d <= n / d tránh d * d tràn. giai_thua trả 0 khi đầu vào bất hợp lệ; người gọi phải biết 0 không phải là giai thừa của số dương nào trong hợp đồng này, và cũng phải biết không được hiểu 0 là “hợp lệ”. fibonacci(7): 0, 1, 1, 2, 3, 5, 8, 13. Bản vòng chỉ giữ hai số, bộ nhớ O(1), thời gian O(n).
Ví dụ 6.4 — Đệ quy giai thừa và Euclid
Phân tích. Viết lại hai hàm theo định nghĩa truy hồi. Vẽ cây gọi cho giai_thua_dq(4): một đường thẳng 4 → 3 → 2 → 1 → 0, rồi nhân dồn khi trở về. Sâu tối đa 5 khung. Với Euclid, mỗi lời gọi giảm theo số dư.
Lưu đồ giai thừa đệ quy.
Mã nguồn.
#include <stdio.h>
unsigned long long giai_thua_dq(int n)
{
if (n <= 1) {
return 1;
}
return (unsigned long long) n * giai_thua_dq(n - 1);
}
int ucln(int a, int b)
{
if (b == 0) {
return a;
}
return ucln(b, a % b);
}
int main(void)
{
printf("4! = %llu\n", giai_thua_dq(4));
printf("UCLN(48, 18) = %d\n", ucln(48, 18));
return 0;
}
Kết quả.
4! = 24
UCLN(48, 18) = 6
Phân tích chi tiết. giai_thua_dq không kiểm tra n âm. Nếu n âm, n <= 1 không đúng theo ý “điểm dừng của giai thừa”, và n - 1 càng âm: đệ quy không thu nhỏ về 0, tràn stack. Hợp đồng phải nói n >= 0, và hàm nên trả sớm nếu n âm. Euclid đệ quy và Euclid vòng là cùng bất biến. Bản đệ quy của Euclid rất nông vì số bước O(log n), an toàn thực tế.
Ví dụ 6.5 — Tháp Hà Nội
Phân tích. Chuyển n đĩa từ cọc nguon sang cọc dich, dùng cọc phu. Chỉ đặt đĩa nhỏ hơn lên đĩa lớn hơn. Thuật toán:
- Chuyển n - 1 đĩa từ nguồn sang phụ.
- Chuyển đĩa lớn nhất từ nguồn sang đích.
- Chuyển n - 1 đĩa từ phụ sang đích.
Số bước là 2^n - 1. Với n = 3 thì 7 bước. Đừng chạy n = 64.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
void hanoi(int n, char nguon, char phu, char dich)
{
if (n <= 0) {
return;
}
hanoi(n - 1, nguon, dich, phu);
printf("Chuyen dia tu %c sang %c\n", nguon, dich);
hanoi(n - 1, phu, nguon, dich);
}
int main(void)
{
hanoi(3, 'A', 'B', 'C');
return 0;
}
Kết quả.
Chuyen dia tu A sang C
Chuyen dia tu A sang B
Chuyen dia tu C sang B
Chuyen dia tu A sang C
Chuyen dia tu B sang A
Chuyen dia tu B sang C
Chuyen dia tu A sang C
Phân tích chi tiết. Lời gọi đầu chuyển 2 đĩa sang B (dùng C làm phụ), rồi đĩa lớn A → C, rồi 2 đĩa từ B sang C. Đếm 7 dòng. Bộ nhớ stack O(n), thời gian O(2^n). Đây là ví dụ chuẩn cho “đệ quy đúng nhưng đắt”. Khi viết đệ quy, luôn nêu cả độ sâu lẫn số lần gọi, đừng chỉ nêu độ sâu.
Ví dụ 6.6 — Con trỏ hàm: một vòng lặp, nhiều phép
Phân tích. Tính tổng f(1) + … + f(n) mà f do người gọi chọn: hàm hằng đẳng, hàm bình phương. Vòng lặp không biết f là gì, chỉ biết kiểu.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
int chinh_no(int x)
{
return x;
}
int binh_phuong(int x)
{
return x * x;
}
long long tong_f(int (*f)(int), int n)
{
long long tong = 0;
for (int i = 1; i <= n; i++) {
tong += f(i);
}
return tong;
}
int main(void)
{
printf("Tong 1..4 = %lld\n", tong_f(chinh_no, 4));
printf("Tong binh phuong 1..4 = %lld\n", tong_f(binh_phuong, 4));
return 0;
}
Kết quả.
Tong 1..4 = 10
Tong binh phuong 1..4 = 30
1+4+9+16 = 30.
Phân tích chi tiết. tong_f(chinh_no, 4) không viết chinh_no(): dấu () là gọi, còn tên trần là địa chỉ hàm. Kiểu lệch (hàm trả double đưa vào chỗ chờ int (*)(int)) phải bị trình biên dịch từ chối. Đây là cách qsort nhận hàm so sánh. Nắm ví dụ này là nắm được thư viện đó, không cần học thuộc.
8. Bài tập có gợi ý
Bài 6.G1 — Bộ ba hàm số học
Viết tong(n), giai_thua(n), la_nguyen_to(n) và main gọi cả ba.
Gợi ý. Nhìn Ví dụ 6.3. Đừng printf bên trong hàm tính.
Bài 6.G2 — Fibonacci đệ quy và vòng
Cùng n = 10, in cả hai kết quả. Thêm biến đếm toàn cục hoặc static để đếm số lần vào hàm đệ quy.
Gợi ý. static int dem tăng ở đầu hàm đệ quy. Với n = 10 bạn sẽ thấy số lần gọi lớn hơn 10 rất nhiều. Đừng để static trong bản nộp chính thức nếu đề không yêu cầu đo.
Bài 6.G3 — Đệ quy đảo chữ số
In các chữ số của n từ trái sang phải bằng đệ quy: gọi trên n / 10 trước, in n % 10 sau.
Gợi ý. Điểm dừng n < 10 thì in chính nó. Ca 0 phải được xử lý ở main hoặc ở lớp bọc, vì 0/10 không tự in một số 0 nếu bạn không có nhánh riêng.
Bài 6.G4 — Nhiều kết quả
Viết hàm tính đồng thời thương và dư của phép chia nguyên dương. Vì return chỉ có một giá trị, hãy dùng hai con trỏ kết quả.
Gợi ý. void chia(int a, int b, int *thuong, int *du). Tiền điều kiện b != 0 và hai con trỏ khác NULL.
Bài 6.G5 — static cục bộ
Viết int lan_goi(void) trả về số thứ tự lần gọi: 1, 2, 3, …
Gợi ý. static int dem = 0; rồi dem++. Giải thích vì sao int dem = 0 không static thì luôn trả 1.
Bài 6.G6 — Ép kiểu con trỏ hàm
Cố tình gán hàm double f(int) cho biến int (*)(int). Chép cảnh báo hoặc lỗi. Sửa bằng cách viết hàm bọc trả int.
Gợi ý. Đừng ép kiểu (int (*)(int)) f để bịt cảnh báo. Đó là cách tạo hành vi không xác định.
9. Bài tập tự làm
Cơ bản
- Hàm trị tuyệt đối số nguyên, không dùng
abs. - Hàm
max3dùng lạimax2. - Hàm đổi độ C sang độ F và ngược lại.
- Hàm kiểm tra năm nhuận, hàm trả về số ngày của một tháng.
Vững
- Hàm
ucln,bcnncho hai số nguyên dương.bcnngọiucln. - Hàm lũy thừa
double luy_thua(double x, int n)với n có thể âm: nếu n âm và x khác 0, trả1 / luy_thua(x, -n). - Hàm đệ quy
tong_chu_so. 1302 trả về 6. - Hàm in n dòng đầu của tam giác Pascal bằng tổ hợp. Tổ hợp tính bằng hàm riêng, cẩn thận tràn khi nhân.
Nâng cao
- Đệ quy tương hỗ.
int chan(int n)vàint le(int n)gọi lẫn nhau: 0 là chẵn; n dương là chẵn nếu n - 1 là lẻ. Vẽ chuỗi gọi chochan(4). - Tháp Hà Nội có đếm. Hàm trả về số bước thay vì in. Đối chiếu
2^n - 1với n từ 1 đến 10. Không in 1023 dòng. - Nguyên mẫu và hai file. Tách khai báo vào
toan.h, định nghĩa vàotoan.c,mainvàomain.c. Biên dịchgcc -std=c11 -Wall main.c toan.c. Cố tình quêntoan.cvà ghi lại lỗi liên kết.
Siêu khó, có ứng dụng
- Bộ tích phân hình thang.
double tich_phan(double (*f)(double), double a, double b, int n)xấp xỉ tích phân f trên[a, b]bằng n hình thang. Viết sẵnflàx^2. Đối chiếu tích phân từ 0 đến 1 củax^2với giá trị đúng1/3. Tăng n = 10, 100, 1000 và lập bảng sai số. Đây là khuôn của mọi thư viện số: thuật toán một nơi, hàm toán học truyền từ ngoài. - Phân tích một biểu thức đệ quy có chi phí. Hàm
ackermann(m, n)bản nhỏ:A(0, n) = n + 1,A(m, 0) = A(m - 1, 1),A(m, n) = A(m - 1, A(m, n - 1)). Chỉ tính các cặp(1, n)với n <= 5 và(2, n)với n <= 3. Giải thích vì sao không gọi(4, 1)trong bài nộp. Nêu độ sâu stack quan sát được bằng một tham số đếm độ sâu truyền thêm, hoặc bằng lập luận. - API nhật ký đo. Thiết kế các hàm
void log_ghi(const char *muc, const char *thong_diep)vàint log_so_dong(void).log_ghiin một dòng có mứcINFOhoặcLOI. Số dòng dùngstatic. Viết thêmvoid log_reset(void). Chứng minh hàmlog_ghikhông thuần. Nêu vì sao một thư viện nhúng vẫn cố ý làm vậy, và nguy cơ khi sau này có hai luồng cùng gọi (chỉ cần nêu, chưa cần viết khóa).
10. Câu hỏi tự kiểm
- Nguyên mẫu khác định nghĩa ở đâu?
- Vì sao
void tang(int x)không đổi được biến nơi gọi? - Điều gì xảy ra nếu trả về địa chỉ biến cục bộ?
- Đệ quy Fibonacci naive tốn kém ở số khung stack hay ở số lần gọi?
int (*p)(int)vàint *p(int)khác nhau thế nào?staticcục bộ sống bao lâu?
11. Tóm tắt và cầu nối
Hàm là hợp đồng: tham số, giá trị trả về, tiền điều kiện, hậu điều kiện. C truyền giá trị. Muốn sửa nơi gọi thì trả kết quả về hoặc truyền địa chỉ một cách tường minh. Đệ quy là công cụ cho bài toán chia nhỏ, với điểm dừng và bước thu nhỏ; nó không miễn phí stack và không miễn phí số lần gọi. Con trỏ hàm tách thuật toán khỏi quy tắc.
Bài 7 đưa vào cấu trúc dữ liệu đầu tiên lưu nhiều giá trị cùng kiểu: mảng. Hàm sẽ nhận mảng thông qua con trỏ, và truyền theo giá trị sẽ lộ rõ giới hạn của nó: thứ được sao chép là địa chỉ phần tử đầu, không phải cả mảng.
12. Đào sâu: hàm là một hợp đồng, không phải một khối được đặt tên
Đặt tên cho một đoạn main rồi cắt ra chưa thành hàm tốt. Hàm tốt trả lời được:
- Đầu vào nào là hợp lệ?
- Giá trị trả về nghĩa là gì khi thành công, nghĩa là gì khi từ chối?
- Hàm có in không? Có sửa ô nhớ bên ngoài không?
- Cùng đầu vào, lần sau có ra cùng kết quả không?
Hàm vừa tính vừa printf thì không gọi được trong một biểu thức khác. Hàm sửa biến toàn cục thì người đọc không thấy đầu vào trên chữ ký. Hàm đệ quy thiếu bước thu nhỏ thì không phải thuật toán.
Con trỏ trong chữ ký hàm, đã học ở Bài 3, nghĩa là "tôi cần số nhà, không cần bản sao giá trị". void tang(int x) nhận bản sao. void tang(int *x) nhận số nhà. Đừng dạy lại từ đầu: hãy vẽ lại hình hai ngôi nhà mỗi khi một hàm "không chịu đổi biến".
Đệ quy tốn một khung stack mỗi lần gọi chưa trả về. Giai thừa n tạo n khung xếp hàng. Fibonacci naive tạo một cây, số nút bùng nổ, độ sâu vẫn chỉ là n. Đếm số lần gọi và độ sâu là hai số khác nhau. Phải nêu cả hai.
Ví dụ 6.7 — Hàm đổi độ C, thuần, có kiểm tra
Phân tích. double c_sang_f(double c) trả c * 1.8 + 32. Không in. main nhập và từ chối nếu không đọc được số. Không cấm độ âm: âm độ C là hợp lệ.
Lưu đồ.
Mã nguồn.
#include <stdio.h>
double c_sang_f(double c)
{
return c * 1.8 + 32.0;
}
int main(void)
{
double c;
if (scanf("%lf", &c) != 1) {
return 1;
}
printf("%.2f F\n", c_sang_f(c));
return 0;
}
Kết quả. Đầu vào 0 in 32.00 F. Đầu vào 100 in 212.00 F. Đầu vào -40 in -40.00 F.
Phân tích chi tiết. -40 là điểm hai thang trùng nhau. Đó là ca kiểm thử quý vì dễ tính nhầm công thức thành c * 1.8 + 32 bị viết ngược c * 32 + 1.8 mà ca 0 vẫn tình cờ ra 32 nếu bạn viết hỏng theo kiểu khác. Hãy giữ ca 0, ca 100 và ca -40.
13. Bài tập mở rộng
Dễ
int gap_doi(int x)trả về2 * x.mainin kết quả. Khôngprintftrong hàm.int max2(int a, int b)vàint max3gọimax2. Vẽ lưu đồmax2.int tri_tuyet_doi(int x). Ca âm, dương, 0.
Vừa
int ucln(int a, int b)đệ quy và một bản vòng. Cùng bộ test.void hoan_doi(int *a, int *b)như Bài 3, nhưng lần này viết lại từ hợp đồng: tiền điều kiện hai con trỏ khácNULLvà được phép trỏ cùng một ô. Nếu cùng một ô, hàm không được làm mất giá trị.- Đệ quy in chữ số từ trái sang phải. Vẽ cây gọi của 1302.
Khó
- Tổ hợp
C(n, k)đệ quy theo Pascal:C(n,0) = C(n,n) = 1,C(n,k) = C(n-1,k-1) + C(n-1,k). Vẽ cây choC(5,2). Đếm số lần gọi. Nêu vì sao bản này chậm. - Hai file
toan.hvàtoan.c, hàmla_nguyen_to.main.cinclude header. Lệnh biên dịch đủ hai file.c. - Hàm
double trung_binh(double a, double b, double c). Từ chối không làm trong hàm này; việc từ chối điểm ngoài 0..10 nằm ởmain. Giải thích vì sao tách như vậy.
Siêu khó
long long luy_thua(long long x, int n)vớin >= 0, thuật toán bình phương liên tiếp, vòng lặp. Đếm số phép nhân chon = 13và so với nhân 13 lần. Vẽ lưu đồ.- Con trỏ hàm
int (*f)(int). Viếttong(f, n)như Ví dụ 6.6 choflà lập phương. Vẽ lưu đồ lời gọi.
Dự án thật
- Thư viện ngày tháng nhỏ. Các hàm:
int la_nhuan(int nam),int so_ngay_thang(int thang, int nam),int hop_le(int d, int m, int y),int ngay_trong_nam(int d, int m, int y). Không hàm nào in.maincó một menu một lượt: nhập ngày và in số thứ tự trong năm. Nộp lưu đồngay_trong_nam, mã, và 8 ca gồm 29/2/2000, 29/2/1900, 31/12/2024, 1/1/2024. Đây là dạng thư viện mà các bài struct ở Bài 8 sẽ bọc lại thành kiểuNgaymà không phải viết lại quy tắc.
Bộ 25 bài tập
int gap_doi(int x)trả về2 * x. Khôngprintftrong hàm. Gợi ý: return x * 2. main mới được printf.int tri_tuyet_doi(int x). Ca âm, dương, 0. Gợi ý: Một if. Đừng gọi abs.int max2(int a, int b)rồimax3gọimax2. Gợi ý: max3 là max2(max2(a,b), c).- Đổi độ C sang F và ngược lại, mỗi việc một hàm thuần. Gợi ý: Hai hàm thuần. Âm độ C vẫn hợp lệ.
- Hàm năm nhuận và hàm số ngày của một tháng. Gợi ý: Tháng 2 gọi hàm nhuận. Các tháng kia dùng switch.
void tang(int *x)tăng ô được trỏ. CaNULLkhông ghi. Gợi ý: if (x == NULL) return; rồi *x += 1.staticđếm số lần gọi một hàm. Giải thích vì sao bỏstaticthì luôn ra 1. Gợi ý: static int dem = 0 chỉ khởi tạo một lần.- Nguyên mẫu đặt trước
main. Cố tình xóa nguyên mẫu và ghi lỗi biên dịch. Gợi ý: Gọi hàm khi trình biên dịch chưa thấy dòng khai báo có dấu chấm phẩy. uclnvàbcnn.bcnngọiucln. Gợi ý: bcnn = a / ucln(a,b) * b với số dương.- Giai thừa trả
unsigned long long, từ chối n ngoài 0..20 bằng giá trị 0 và nói rõ hợp đồng. Gợi ý: Vòng nhân trong hàm. n sai thì return 0 và ghi điều đó cạnh nguyên mẫu. - Fibonacci vòng lặp O(n). Không dùng đệ quy ở bài này. Gợi ý: Hai biến giữ hai số cuối. Không gọi đệ quy.
- Đệ quy tổng chữ số. 1302 trả về 6. Gợi ý: Điểm dừng một chữ số. Còn lại thì chữ số đơn vị cộng với lời gọi n/10.
- Đệ quy in chữ số từ trái sang phải. Vẽ cây gọi của 1302. Gợi ý: Gọi n/10 trước, in n%10 sau. Số 0 xử lý ở main.
void hoan_doi(int *a, int *b). Nếu hai con trỏ cùng một ô thì không làm mất giá trị. Gợi ý: Đổi *a và *b. Nếu a == b thì return ngay.- Lũy thừa
double, số mũ có thể âm, từ chối mũ âm khi cơ số bằng 0. Gợi ý: n âm thì return 1.0 / lũy thừa dương, trừ khi x == 0. - Euclid đệ quy và Euclid vòng, cùng một bộ test. Gợi ý: Điểm dừng b == 0 thì trả a. Cùng bộ (48,18), (0,5) nếu bạn cho phép.
chanvàlegọi lẫn nhau. Vẽ chuỗi gọichan(4). Gợi ý: chan(0) trả 1. chan(n) gọi le(n-1). le gọi chan.- Tháp Hà Nội trả về số bước, đối chiếu
2^n - 1với n từ 1 đến 10. Gợi ý: Đếm 1 + số bước của hai lời gọi n-1. Không in. - Tổ hợp Pascal đệ quy. Đếm số lần gọi
C(5,2)và giải thích vì sao chậm. Gợi ý: Điểm dừng k == 0 hoặc k == n. Thêm biến đếm static hoặc tham số. - Con trỏ hàm tính tổng
f(1)+…+f(n)vớiflà lập phương. Gợi ý: Tham số int (*f)(int). Gọi f(i) trong vòng, không gọi một hàm cố định. - Tách
toan.h,toan.c,main.c. Ghi lỗi liên kết khi quêntoan.c. Gợi ý: gcc main.c toan.c. Chỉ main.c thì undefined reference. - Thư viện ngày.
la_nhuan,so_ngay_thang,hop_le,ngay_trong_nam. Không hàm nào in. Tám ca gồm 29/2/2000 và 29/2/1900. Gợi ý: ngay_trong_nam cộng số ngày các tháng trước rồi cộng ngày. Không printf trong các hàm này. - Tích phân hình thang. Hàm nhận con trỏ hàm
f. Đối chiếu tích phânx^2từ 0 đến 1 với1/3. Bảng n = 10, 100, 1000. Gợi ý: Chia đoạn [a,b] thành n hình thang. f truyền từ ngoài, ví dụ hàm x*x. - Nhật ký.
log_ghi,log_so_dong,log_reset. Số dòng dùngstatic. Chứng minh hàm ghi không thuần. Gợi ý: static giữ số dòng giữa các lần gọi. reset đưa biến đó về 0. - Máy tính một phép bằng hàm.
cong,tru,nhan,chialà bốn hàm.chiatừ chối mẫu 0 bằng mã trả về, không in hộ.mainchỉ nhập và in. Gợi ý: chia trả một mã lỗi riêng khi mẫu bằng 0. main nhìn mã đó rồi mới in.