Khi chương trình ngày càng lớn, việc viết tất cả mã nguồn trong một hàm main() duy nhất
sẽ khiến code trở nên khó đọc, khó bảo trì và đầy rẫy sự trùng lặp. Hàm (function) chính là công cụ
giúp bạn chia nhỏ vấn đề, tái sử dụng logic và xây dựng phần mềm có cấu trúc rõ ràng. Trong bài này,
chúng ta sẽ tìm hiểu toàn diện về hàm, phạm vi biến và đệ quy trong C.
1. Tại sao cần hàm? (Why Functions?)
Hãy xem xét nguyên tắc DRY — Don't Repeat Yourself. Nếu bạn cần tính diện tích hình chữ nhật ở 5 chỗ khác nhau trong chương trình, thay vì copy-paste công thức 5 lần, bạn chỉ cần viết một hàm và gọi lại nó.
Trước khi dùng hàm:
#include <stdio.h>
int main() {
// 1st time: area of the living room
double dai1 = 5.0, rong1 = 4.0;
double dt1 = dai1 * rong1;
printf("Dien tich phong khach: %.2f m2\n", dt1);
// 2nd time: area of the bedroom
double dai2 = 3.5, rong2 = 3.0;
double dt2 = dai2 * rong2;
printf("Dien tich phong ngu: %.2f m2\n", dt2);
// 3rd time: area of the kitchen
double dai3 = 4.0, rong3 = 2.5;
double dt3 = dai3 * rong3;
printf("Dien tich nha bep: %.2f m2\n", dt3);
// ... repeating the same formula forever!
return 0;
}
Sau khi dùng hàm:
#include <stdio.h>
double dien_tich(double dai, double rong) {
return dai * rong;
}
int main() {
printf("Phong khach: %.2f m2\n", dien_tich(5.0, 4.0));
printf("Phong ngu: %.2f m2\n", dien_tich(3.5, 3.0));
printf("Nha bep: %.2f m2\n", dien_tich(4.0, 2.5));
return 0;
}
Lợi ích rõ ràng:
- Tái sử dụng (Reusability): Viết một lần, gọi bao nhiêu lần tùy thích.
- Mô-đun hóa (Modularization): Chia chương trình thành các khối chức năng nhỏ, dễ đọc.
- Dễ bảo trì: Khi cần sửa công thức, chỉ sửa một chỗ duy nhất.
- Dễ kiểm thử: Có thể kiểm thử từng hàm riêng lẻ (unit testing).
2. Khai báo, Định nghĩa & Nguyên mẫu hàm
Trình biên dịch C đọc mã nguồn từ trên xuống dưới. Nếu bạn gọi một hàm trước khi trình biên dịch nhìn thấy định nghĩa của nó, nó sẽ báo lỗi hoặc đưa ra cảnh báo nguy hiểm.
Lỗi khi không có nguyên mẫu (prototype):
#include <stdio.h>
int main() {
// ERROR! The compiler has not seen add() yet
int result = add(3, 5);
printf("Tong = %d\n", result);
return 0;
}
// The definition sits AFTER main()
int add(int a, int b) {
return a + b;
}
// Compiling gives: warning: implicit declaration of function 'add'
// With -Werror it becomes an error!
Sửa lỗi bằng nguyên mẫu hàm (function prototype):
#include <stdio.h>
// Function prototype (forward declaration)
// Tells the compiler: "add takes 2 ints and returns an int"
int add(int a, int b);
int main() {
int result = add(3, 5); // OK! The compiler already knows the signature
printf("Tong = %d\n", result);
return 0;
}
// The full function definition
int add(int a, int b) {
return a + b;
}
Tóm tắt:
-
Khai báo / Nguyên mẫu (Declaration / Prototype):
int add(int a, int b);— chỉ cho trình biên dịch biết chữ ký hàm, không có thân hàm. -
Định nghĩa (Definition): Bao gồm cả chữ ký hàm lẫn thân hàm
{ ... }chứa mã nguồn thực thi. -
Trong dự án lớn, nguyên mẫu hàm thường được đặt trong file header (
.h), còn định nghĩa nằm trong file nguồn (.c).
3. Truyền tham trị (Pass by Value)
Một điểm cực kỳ quan trọng: C chỉ có truyền tham trị (pass by value). Khi bạn truyền một biến vào hàm, C tạo một bản sao của giá trị đó trên stack frame mới. Hàm làm việc với bản sao, không phải biến gốc.
#include <stdio.h>
void increment(int x) {
x = x + 1; // Only changes the copy on the stack
printf("Trong ham: x = %d\n", x); // x = 11
}
int main() {
int a = 10;
increment(a);
printf("Outside: a = %d\n", a); // a = 10 (UNCHANGED!)
return 0;
}
Minh họa Stack Frame:
┌─────────────────────────┐
│ Stack Frame: increment │
│ x = 11 (bản sao) │ ← hàm thay đổi bản sao này
├─────────────────────────┤
│ Stack Frame: main │
│ a = 10 (biến gốc) │ ← biến gốc không bị ảnh hưởng
└─────────────────────────┘
Để thực sự thay đổi biến gốc từ bên trong hàm, bạn cần truyền con trỏ (pointer) — bản chất vẫn là truyền tham trị, nhưng giá trị truyền vào là địa chỉ bộ nhớ. Chi tiết sẽ được đề cập sâu trong Bài 8: Con trỏ.
#include <stdio.h>
void increment(int *px) {
*px = *px + 1; // Changes the value at the address px points to
}
int main() {
int a = 10;
increment(&a); // Pass the address of a
printf("a = %d\n", a); // a = 11 (CHANGED!)
return 0;
}
Đặt hai chương trình cạnh nhau là thấy ngay khác biệt: bản trước in a = 10 sau khi gọi
hàm, bản này in a = 11. Cùng một ý định "tăng biến lên 1", nhưng chỉ bản thứ hai làm được
— vì nó nhận địa chỉ của a chứ không phải bản sao giá trị. Đây là lý do
scanf ở Bài 2 luôn cần dấu &.
4. Biến cục bộ, Biến toàn cục & Từ khóa static
A. Biến cục bộ (Local Variables)
Biến được khai báo bên trong một hàm hoặc một khối { } chỉ tồn tại trong phạm vi đó. Khi
hàm kết thúc, vùng nhớ trên stack được giải phóng, biến cục bộ biến mất.
#include <stdio.h>
void foo() {
int local = 42; // Lives on the stack, dies when foo() returns
printf("local = %d\n", local);
}
int main() {
foo();
// printf("%d", local); // ERROR: 'local' is not declared here
return 0;
}
Dòng printf bị chú thích ở cuối là phần đáng chú ý: nó
không biên dịch được, chứ không phải in ra số rác. Biến cục bộ không chỉ mất giá trị
khi hàm kết thúc — ngoài phạm vi hàm, trình biên dịch coi như cái tên đó chưa từng tồn tại.
B. Biến toàn cục (Global Variables)
Biến khai báo ngoài mọi hàm có thời gian sống bằng toàn bộ chương trình. Chúng được lưu trong vùng nhớ Data segment (nếu được khởi tạo) hoặc BSS segment (nếu chưa khởi tạo, mặc định = 0).
#include <stdio.h>
int counter = 0; // A global variable - every function can reach it
void tang_dem() {
counter++;
}
int main() {
tang_dem();
tang_dem();
tang_dem();
printf("counter = %d\n", counter); // counter = 3
return 0;
}
⚠️ Tại sao biến toàn cục nguy hiểm?
-
Xung đột tên: Trong dự án lớn với nhiều file
.c, hai lập trình viên có thể vô tình đặt cùng tên biến toàn cục, gây lỗi linker hoặc hành vi không mong muốn. - Khó debug: Bất cứ hàm nào cũng có thể thay đổi giá trị biến toàn cục, rất khó truy vết khi có lỗi.
- Không thread-safe: Trong lập trình đa luồng (multi-threading), nhiều luồng cùng đọc/ghi biến toàn cục mà không có khóa (lock) sẽ gây race condition.
C. Từ khóa static
Từ khóa static có hai ý nghĩa khác nhau tùy ngữ cảnh:
1. Biến static cục bộ: Giữ giá trị qua các lần gọi hàm (không bị mất khi
hàm kết thúc).
#include <stdio.h>
void dem_so_lan_goi() {
static int count = 0; // Initialised exactly once
count++;
printf("Ham duoc goi lan thu: %d\n", count);
}
int main() {
dem_so_lan_goi(); // Ham duoc goi lan thu: 1
dem_so_lan_goi(); // Ham duoc goi lan thu: 2
dem_so_lan_goi(); // Ham duoc goi lan thu: 3
return 0;
}
2. Biến/hàm static toàn cục: Giới hạn phạm vi truy cập chỉ trong file
.c hiện tại (internal linkage). File khác không thể dùng extern để truy cập.
// file: helper.c
static int internal_counter = 0; // Only this file can reach it
static void reset_counter() { // A private, file-local function
internal_counter = 0;
}
// file: main.c
// extern int internal_counter; // LINKER ERROR: symbol not found
Tổng kết phạm vi biến:
┌──────────────────┬────────────────┬──────────────┬───────────────┐
│ Loại biến │ Thời gian sống │ Phạm vi │ Lưu trữ │
├──────────────────┼────────────────┼──────────────┼───────────────┤
│ Local │ Trong hàm │ Trong hàm │ Stack │
│ Global │ Toàn chương │ Toàn bộ file │ Data/BSS │
│ static local │ Toàn chương │ Trong hàm │ Data/BSS │
│ static global │ Toàn chương │ Chỉ file đó │ Data/BSS │
└──────────────────┴────────────────┴──────────────┴───────────────┘
5. Hàm đệ quy (Recursion)
Đệ quy là kỹ thuật trong đó một hàm gọi chính nó. Mỗi hàm đệ quy đều phải có hai thành phần:
- Base case (Điều kiện dừng): Điều kiện để dừng việc gọi đệ quy, tránh lặp vô hạn.
- Recursive case (Bước đệ quy): Hàm gọi chính nó với đầu vào nhỏ hơn, tiến dần về base case.
Ví dụ: Tính giai thừa (Factorial)
Giai thừa: n! = n × (n-1) × (n-2) × ... × 1, với 0! = 1.
#include <stdio.h>
long long factorial(int n) {
if (n <= 1) return 1; // Base case
return n * factorial(n - 1); // Recursive case
}
int main() {
int n = 5;
printf("%d! = %lld\n", n, factorial(n)); // 5! = 120
return 0;
}
Minh họa Call Stack khi gọi factorial(4):
factorial(4) ← gọi
└─ 4 * factorial(3) ← gọi
└─ 3 * factorial(2) ← gọi
└─ 2 * factorial(1) ← gọi
└─ return 1 ← BASE CASE, bắt đầu trả về
└─ return 2 * 1 = 2
└─ return 3 * 2 = 6
└─ return 4 * 6 = 24 ← Kết quả cuối cùng
Fibonacci: Đệ quy O(2^N) vs Vòng lặp O(N)
Fibonacci là ví dụ kinh điển cho thấy đệ quy không phải lúc nào cũng hiệu quả.
#include <stdio.h>
// Option 1: recursion - O(2^N) time, O(N) stack memory
// VERY SLOW for large N: it recomputes the same values over and over
long long fib_recursive(int n) {
if (n <= 1) return n;
return fib_recursive(n - 1) + fib_recursive(n - 2);
}
// Option 2: iteration - O(N) time, O(1) memory
// Dramatically faster
long long fib_iterative(int n) {
if (n <= 1) return n;
long long prev = 0, curr = 1;
for (int i = 2; i <= n; i++) {
long long next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}
int main() {
int n = 40;
// fib_recursive(40) takes seconds: roughly 2^40 calls
printf("fib_recursive(%d) = %lld\n", n, fib_recursive(n));
// fib_iterative(40) returns almost instantly
printf("fib_iterative(%d) = %lld\n", n, fib_iterative(n));
return 0;
}
Tại sao đệ quy Fibonacci chậm? Vì nó tính lại cùng giá trị nhiều lần. Ví dụ
fib(5) gọi fib(3) hai lần, fib(2) ba lần... Số lần gọi hàm tăng
theo cấp số nhân.
Đệ quy đuôi (Tail Recursion)
Đệ quy đuôi xảy ra khi lời gọi đệ quy là phép tính cuối cùng của hàm — không có phép
tính nào sau đó. Trình biên dịch có thể tối ưu hóa đệ quy đuôi thành vòng lặp (với cờ
-O2), giúp tiết kiệm bộ nhớ stack.
#include <stdio.h>
// PLAIN recursion: n * factorial(n-1) - must wait for the result to multiply
long long factorial_plain(int n) {
if (n <= 1) return 1;
return n * factorial_plain(n - 1); // The multiply happens AFTER the recursive call
}
// TAIL recursion: the result accumulates through a parameter
long long factorial_tail(int n, long long acc) {
if (n <= 1) return acc; // Base case: return the accumulated result
return factorial_tail(n - 1, n * acc); // The recursive call is the LAST thing done
}
int main() {
printf("5! = %lld\n", factorial_tail(5, 1)); // 120
return 0;
}
// Build with: gcc -O2 tail_recursion.c -o tail_recursion
// The compiler may turn this into a loop (tail call optimisation)
Nguy cơ Stack Overflow
Mỗi lần gọi đệ quy tạo một stack frame mới trên call stack. Stack có kích thước giới hạn (thường ~1-8 MB trên hệ điều hành hiện đại). Đệ quy quá sâu sẽ gây Stack Overflow — chương trình bị crash.
#include <stdio.h>
void count_forever(int n) {
printf("n = %d\n", n);
count_forever(n + 1); // No base case -> stack overflow!
}
int main() {
count_forever(1); // CRASH: Segmentation fault
return 0;
}
6. Hàm Variadic (Variadic Functions)
Bạn có bao giờ thắc mắc tại sao printf("a=%d b=%d", a, b) có thể nhận số lượng tham số
tùy ý? Đó là nhờ variadic functions — hàm với số lượng tham số không cố định.
C cung cấp thư viện <stdarg.h> với các macro sau:
va_list— kiểu dữ liệu để duyệt qua danh sách tham số-
va_start(ap, last_fixed)— khởi tạo,last_fixedlà tham số cố định cuối cùng va_arg(ap, type)— lấy tham số tiếp theo với kiểutypeva_end(ap)— dọn dẹp
#include <stdio.h>
#include <stdarg.h>
// A sum function taking any number of arguments
// The first parameter (count) says how many numbers follow
double tong(int count, ...) {
va_list args;
va_start(args, count); // Initialise after the last fixed parameter
double sum = 0.0;
for (int i = 0; i < count; i++) {
sum += va_arg(args, double); // Pull each argument as a double
}
va_end(args); // Mandatory cleanup
return sum;
}
int main() {
printf("Tong 3 so: %.1f\n", tong(3, 1.5, 2.5, 3.0)); // 7.0
printf("Tong 5 so: %.1f\n", tong(5, 1.0, 2.0, 3.0, 4.0, 5.0)); // 15.0
return 0;
}
⚠️ Cảnh báo về an toàn:
-
Không có kiểm tra kiểu: Nếu bạn truyền
intnhưng dùngva_arg(args, double), chương trình sẽ đọc sai dữ liệu trên stack — undefined behavior. - Phải biết khi nào dừng: Không có cách nào tự động biết số lượng tham số. Bạn phải dùng một tham số đếm (như ví dụ trên) hoặc một giá trị sentinel (giá trị đánh dấu kết thúc).
-
Lý do
printfan toàn hơn: Nó dùng format string (%d,%s, ...) để biết kiểu và số lượng tham số cần đọc.
7. Con trỏ hàm & Callback (Giới thiệu)
Trong C, hàm cũng có địa chỉ trong bộ nhớ. Bạn có thể lưu địa chỉ của hàm vào một biến gọi là con trỏ hàm (function pointer), rồi gọi hàm thông qua con trỏ đó. Đây là nền tảng cho kỹ thuật callback — truyền một hàm vào hàm khác để tùy biến hành vi.
Một ví dụ thực tế: hàm qsort() trong <stdlib.h> nhận một hàm so sánh
do người dùng định nghĩa:
#include <stdio.h>
#include <stdlib.h>
// Comparison function for ascending order
int so_sanh_tang(const void *a, const void *b) {
return (*(int*)a - *(int*)b);
}
// Comparison function for descending order
int so_sanh_giam(const void *a, const void *b) {
return (*(int*)b - *(int*)a);
}
void in_mang(int arr[], int n) {
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
}
int main() {
int arr[] = {42, 17, 88, 5, 63, 29};
int n = sizeof(arr) / sizeof(arr[0]);
printf("Mang goc: ");
in_mang(arr, n);
// qsort takes the comparison function pointer as a callback
qsort(arr, n, sizeof(int), so_sanh_tang);
printf("Tang dan: ");
in_mang(arr, n);
qsort(arr, n, sizeof(int), so_sanh_giam);
printf("Giam dan: ");
in_mang(arr, n);
return 0;
}
Chi tiết về con trỏ hàm, cú pháp khai báo, và các ứng dụng nâng cao sẽ được trình bày đầy đủ trong Bài 8: Con trỏ & Quản lý bộ nhớ.
📥 Tải về mã nguồn mẫu: functions_recursion.c
Thử thách nhỏ dành cho bạn:
Viết hàm đệ quy power(int base, int exp) để tính base^exp (ví dụ:
power(2, 10) = 1024). Gợi ý: base^exp = base * base^(exp-1), base case là
exp == 0 → return 1.
void foo() {
static int x = 0;
x++;
printf("%d ", x);
}
int main() {
foo(); foo(); foo();
return 0;
}
Bình luận