This C programming guide is currently only available in Vietnamese. Please toggle the language switch (🇻🇳) in the top navigation to read the full article.

In this fifth part of the series, we cover function declaration, definition and prototypes, pass-by-value semantics, local/global/static variables, recursion (factorial, Fibonacci), variadic functions, and a brief introduction to function pointers and callbacks in C.

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:

no_function.c
#include <stdio.h>

int main() {
    // Lần 1: Tính diện tích phòng khách
    double dai1 = 5.0, rong1 = 4.0;
    double dt1 = dai1 * rong1;
    printf("Dien tich phong khach: %.2f m2\n", dt1);

    // Lần 2: Tính diện tích phòng ngủ
    double dai2 = 3.5, rong2 = 3.0;
    double dt2 = dai2 * rong2;
    printf("Dien tich phong ngu: %.2f m2\n", dt2);

    // Lần 3: Tính diện tích nhà bếp
    double dai3 = 4.0, rong3 = 2.5;
    double dt3 = dai3 * rong3;
    printf("Dien tich nha bep: %.2f m2\n", dt3);

    // ... lặp lại mãi với cùng một công thức!
    return 0;
}

Sau khi dùng hàm:

with_function.c
#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):

no_prototype_error.c
#include <stdio.h>

int main() {
    // LỖI! Trình biên dịch chưa biết hàm cong() là gì
    int result = cong(3, 5);
    printf("Tong = %d\n", result);
    return 0;
}

// Định nghĩa hàm nằm SAU main()
int cong(int a, int b) {
    return a + b;
}
// Biên dịch: warning: implicit declaration of function 'cong'
// Với cờ -Werror: error!

Sửa lỗi bằng nguyên mẫu hàm (function prototype):

with_prototype.c
#include <stdio.h>

// Nguyên mẫu hàm (forward declaration)
// Báo cho trình biên dịch: "Hàm cong nhận 2 int, trả về int"
int cong(int a, int b);

int main() {
    int result = cong(3, 5);  // OK! Trình biên dịch đã biết chữ ký hàm
    printf("Tong = %d\n", result);
    return 0;
}

// Định nghĩa hàm đầy đủ (function definition)
int cong(int a, int b) {
    return a + b;
}

Tóm tắt:

  • Khai báo / Nguyên mẫu (Declaration / Prototype): int cong(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.

pass_by_value.c
#include <stdio.h>

void tang_mot(int x) {
    x = x + 1;  // Chỉ thay đổi bản sao trên stack
    printf("Trong ham: x = %d\n", x);  // x = 11
}

int main() {
    int a = 10;
    tang_mot(a);
    printf("Ngoai ham: a = %d\n", a);  // a = 10 (KHÔNG ĐỔI!)
    return 0;
}

Minh họa Stack Frame:

┌─────────────────────────┐
│   Stack Frame: tang_mot │
│   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ỏ.

pass_by_pointer.c
#include <stdio.h>

void tang_mot(int *px) {
    *px = *px + 1;  // Thay đổi giá trị tại địa chỉ mà px trỏ tới
}

int main() {
    int a = 10;
    tang_mot(&a);   // Truyền địa chỉ của a
    printf("a = %d\n", a);  // a = 11 (ĐÃ THAY ĐỔI!)
    return 0;
}

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.

local_var.c
#include <stdio.h>

void foo() {
    int local = 42;  // Sống trên stack, chết khi foo() kết thúc
    printf("local = %d\n", local);
}

int main() {
    foo();
    // printf("%d", local);  // LỖI: 'local' chưa được khai báo ở đây
    return 0;
}

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).

global_var.c
#include <stdio.h>

int dem = 0;  // Biến toàn cục — mọi hàm đều truy cập được

void tang_dem() {
    dem++;
}

int main() {
    tang_dem();
    tang_dem();
    tang_dem();
    printf("dem = %d\n", dem);  // dem = 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).

static_local.c
#include <stdio.h>

void dem_so_lan_goi() {
    static int count = 0;  // Chỉ khởi tạo 1 lần duy nhất
    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.

static_global.c
// file: helper.c

static int internal_counter = 0;  // Chỉ file này truy cập được

static void reset_counter() {     // Hàm private, chỉ dùng nội bộ
    internal_counter = 0;
}

// file: main.c
// extern int internal_counter;  // LỖI LINKER: không tìm thấy symbol

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.

factorial.c
#include <stdio.h>

long long giai_thua(int n) {
    if (n <= 1) return 1;       // Base case
    return n * giai_thua(n - 1);  // Recursive case
}

int main() {
    int n = 5;
    printf("%d! = %lld\n", n, giai_thua(n));  // 5! = 120
    return 0;
}

Minh họa Call Stack khi gọi giai_thua(4):

giai_thua(4)                          ← gọi
  └─ 4 * giai_thua(3)                ← gọi
       └─ 3 * giai_thua(2)           ← gọi
            └─ 2 * giai_thua(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ả.

fibonacci.c
#include <stdio.h>

// Cách 1: Đệ quy — O(2^N) thời gian, O(N) bộ nhớ stack
// RẤT CHẬM với N lớn vì tính lại cùng giá trị nhiều lần
long long fib_recursive(int n) {
    if (n <= 1) return n;
    return fib_recursive(n - 1) + fib_recursive(n - 2);
}

// Cách 2: Vòng lặp — O(N) thời gian, O(1) bộ nhớ
// NHANH hơn rất nhiều lần
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) mất vài giây vì tính ~2^40 lần
    printf("fib_recursive(%d) = %lld\n", n, fib_recursive(n));

    // fib_iterative(40) chạy gần như tức thì
    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.

tail_recursion.c
#include <stdio.h>

// Đệ quy THƯỜNG: n * giai_thua(n-1) — phải chờ kết quả để nhân
long long giai_thua_thuong(int n) {
    if (n <= 1) return 1;
    return n * giai_thua_thuong(n - 1);  // Phép nhân SAU lời gọi đệ quy
}

// Đệ quy ĐUÔI: kết quả được tích lũy qua tham số accumulator
long long giai_thua_duoi(int n, long long acc) {
    if (n <= 1) return acc;       // Base case: trả về kết quả tích lũy
    return giai_thua_duoi(n - 1, n * acc);  // Lời gọi đệ quy là phép tính CUỐI
}

int main() {
    printf("5! = %lld\n", giai_thua_duoi(5, 1));  // 120
    return 0;
}
// Biên dịch với: gcc -O2 tail_recursion.c -o tail_recursion
// Trình biên dịch có thể tối ưu thành vòng lặp (tail call optimization)

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.

stack_overflow.c
#include <stdio.h>

void dem_vo_han(int n) {
    printf("n = %d\n", n);
    dem_vo_han(n + 1);  // Không có base case → Stack Overflow!
}

int main() {
    dem_vo_han(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_fixed là tham số cố định cuối cùng
  • va_arg(ap, type) — lấy tham số tiếp theo với kiểu type
  • va_end(ap) — dọn dẹp
variadic_sum.c
#include <stdio.h>
#include <stdarg.h>

// Hàm tính tổng với số lượng tham số tùy ý
// Tham số đầu tiên (count) cho biết có bao nhiêu số cần cộng
double tong(int count, ...) {
    va_list args;
    va_start(args, count);  // Khởi tạo sau tham số cố định cuối

    double sum = 0.0;
    for (int i = 0; i < count; i++) {
        sum += va_arg(args, double);  // Lấy từng tham số kiểu double
    }

    va_end(args);  // Bắt buộc phải gọi để dọn dẹp
    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 int nhưng dùng va_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 printf an 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:

qsort_callback.c
#include <stdio.h>
#include <stdlib.h>

// Hàm so sánh để sắp xếp tăng dần
int so_sanh_tang(const void *a, const void *b) {
    return (*(int*)a - *(int*)b);
}

// Hàm so sánh để sắp xếp giảm dần
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 nhận con trỏ hàm so sánh làm 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 luy_thua(int base, int exp) để tính base^exp (ví dụ: luy_thua(2, 10) = 1024). Gợi ý: base^exp = base * base^(exp-1), base case là exp == 0 → return 1.

📝 Kiểm tra kiến thức bài 5
Đoạn code sau in ra kết quả gì?
void foo() {
    static int x = 0;
    x++;
    printf("%d ", x);
}
int main() {
    foo(); foo(); foo();
    return 0;
}

Related Articles

Bài viết liên quan trong series

Lesson 6: Arrays, Strings & Text Processing in C Bài 6: Mảng, Chuỗi ký tự & Xử lý văn bản trong C Lesson 4: Branching and Loops in C: Algorithm Fundamentals Bài 4: Cấu trúc rẽ nhánh & Vòng lặp trong C: Tư duy thuật toán Back to C Series Overview Quay lại Lộ trình C Series