Bài 7: Hàm trong C++


Hàm trong C++

Mọi chương trình thực tế, dù đơn giản đến đâu, nếu được viết nghiêm túc đều phải chia thành các đơn vị xử lý nhỏ hơn gọi là hàm. Ý tưởng của hàm là tách một khối công việc cụ thể thành một thực thể độc lập có thể được gọi lặp lại từ nhiều vị trí khác nhau trong chương trình. Điều này giúp giảm lặp mã, cải thiện khả năng đọc hiểu và là tiền đề cho tư duy tổ chức chương trình hướng cấu trúc hoặc hướng đối tượng.

Trong C++, cú pháp khai báo và định nghĩa một hàm bao gồm ba thành phần: kiểu trả về, tên hàm, và danh sách tham số (có thể rỗng). Sau đó là phần thân hàm – là khối lệnh thực hiện chức năng được mô tả bởi tên hàm.

int binhPhuong(int x) {
    return x * x;
}

Hàm trên nhận một số nguyên x và trả về giá trị bình phương của x. Khi gọi hàm, ta truyền đối số cụ thể:

int a = binhPhuong(7);  // a = 49

Hàm có thể có bất kỳ số lượng tham số nào, kể cả không có tham số. Hàm cũng có thể trả về void, tức là không trả lại giá trị nào:

void xinChao() {
    cout << "Hello, world!" << endl;
}

Trong trường hợp hàm trả về một giá trị, mọi câu lệnh return bên trong hàm phải phù hợp với kiểu dữ liệu đã khai báo. Nếu kiểu trả về là int, thì return phải theo sau bởi một biểu thức trả về số nguyên.

Một đặc điểm quan trọng của hàm trong C++ là cơ chế truyền tham số. Theo mặc định, mọi đối số được truyền vào hàm theo giá trị (pass-by-value), nghĩa là bản sao của biến gốc được tạo ra và các thao tác bên trong hàm không ảnh hưởng gì đến biến bên ngoài:

void tang(int x) {
    x++;
}

Nếu ta gọi tang(a);, thì a vẫn giữ nguyên giá trị sau khi hàm chạy, vì x là bản sao. Để thay đổi được giá trị bên ngoài, cần dùng tham chiếu (reference):

void tang(int& x) {
    x++;
}

Giờ thì mọi thay đổi lên x sẽ phản ánh trực tiếp lên biến được truyền vào hàm. Trong thực tế, người lập trình phải cân nhắc kỹ giữa hiệu năng và an toàn khi lựa chọn truyền theo giá trị hay tham chiếu. Với các kiểu dữ liệu lớn (ví dụ: string, vector), truyền theo tham chiếu là lựa chọn ưu tiên để tránh chi phí copy.

Một tính năng bổ sung khác là tham số mặc định – dùng khi muốn cho phép gọi hàm với số lượng đối số linh hoạt:

void inDong(string s = "Hello") {
    cout << s << endl;
}

Gọi inDong(); sẽ in "Hello", nhưng cũng có thể gọi inDong("Hi"); để in "Hi". Tham số mặc định giúp viết mã ngắn gọn và thân thiện hơn.

Vấn đề quan trọng khi làm việc với nhiều hàm là thứ tự định nghĩa và gọi hàm. Trong C++, khi trình biên dịch gặp lệnh gọi hàm, nó phải biết hàm đó tồn tại và có dạng như thế nào. Nếu hàm được định nghĩa phía sau main(), ta cần phải khai báo nguyên mẫu hàm (function prototype) ở phía trước:

int tong(int a, int b);  // prototype

int main() {
    cout << tong(3, 4);
}

int tong(int a, int b) {
    return a + b;
}

Không có nguyên mẫu, trình biên dịch sẽ báo lỗi vì không biết tong là gì tại thời điểm gặp dòng gọi hàm.

Một chủ đề thường được sinh viên hỏi nhiều là đệ quy – kỹ thuật mà trong đó một hàm tự gọi chính nó. Đây là kỹ thuật nền tảng trong nhiều giải thuật: tính giai thừa, dãy Fibonacci, tìm kiếm nhị phân, đệ quy cây… Điều quan trọng nhất trong đệ quy là phải có điều kiện dừng rõ ràng.

Ví dụ, tính giai thừa:

int giaiThua(int n) {
    if (n == 0) return 1;
    return n * giaiThua(n - 1);
}

Ở đây, nếu không có điều kiện n == 0, chương trình sẽ gọi hàm mãi mãi cho các giá trị âm và rơi vào lỗi tràn ngăn xếp. Mỗi lời gọi hàm tạo ra một stack frame mới trong bộ nhớ, chứa thông tin tham số, địa chỉ trả về, và biến cục bộ. Khi số lượng lời gọi đệ quy quá lớn, sẽ dẫn tới hiện tượng gọi quá sâu (stack overflow).

Không phải mọi đệ quy đều tối ưu. Nhiều đệ quy có thể được thay bằng vòng lặp, giúp tiết kiệm bộ nhớ. Nhưng ngược lại, một số thuật toán đệ quy lại cực kỳ tự nhiên, như duyệt cây, sinh hoán vị, v.v.

Ngoài đệ quy, cũng cần hiểu rằng mọi biến khai báo trong một hàm là biến cục bộ, chỉ tồn tại trong thời gian hàm chạy. Khi hàm kết thúc, toàn bộ vùng nhớ dành cho các biến cục bộ sẽ được giải phóng. Việc trả về địa chỉ của một biến cục bộ là lỗi nghiêm trọng:

int* test() {
    int x = 5;
    return &x; // sai, x sẽ bị hủy sau khi ra khỏi hàm
}

Cuối cùng, cần nhấn mạnh rằng việc chia chương trình thành các hàm là bước đầu tiên hướng tới lập trình quy mô lớn. Một chương trình tốt thường có hàng chục, thậm chí hàng trăm hàm, mỗi hàm thực hiện một việc duy nhất, ngắn gọn, dễ kiểm thử. Tư duy chia để trị này sẽ dẫn ta đến tư duy module, lớp, và thiết kế phần mềm sau này.