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

Chương 4. Hàm

Đơn vị cơ bản giúp tái sử dụng mã nguồn, giảm thiểu trùng lặp và tổ chức chương trình theo cấu trúc khoa học, linh hoạt.


1. Lập trình hàm​

1.1. Giới thiệu về hàm​

1.1.1. Khái niệm​

Hàm trong C++\texttt{C++} là một khối lệnh có thể tái sử dụng, được thiết kế để thực hiện một chức năng cụ thể trong chương trình. Hàm có thể nhận đầu vào (tham số) và trả về kết quả (giá trị trả về).

1.1.2. Lợi ích của hàm​

  • Giảm lặp mã: Code được gom vào một nơi, không phải viết lại nhiều lần.
  • Dễ đọc – dễ quản lý: Chương trình rõ ràng hơn nhờ chia thành các phần nhỏ.
  • Tái sử dụng: Một hàm có thể dùng ở nhiều nơi hoặc nhiều chương trình khác nhau.
  • Dễ bảo trì: Khi cần sửa logic, chỉ sửa trong hàm thay vì toàn bộ chương trình.
  • Hỗ trợ chia bài toán lớn thành các phần nhỏ: Giúp lập trình có cấu trúc và dễ phát triển.

1.2. Cấu trúc và sử dụng hàm​

1.2.1. Cú pháp định nghĩa hàm​

Cú pháp:

<kiểu trả về> <tên hàm>(<danh sách tham số>) {
<các câu lệnh>
return <giá trị trả về>;
}

Ví dụ:

int add(int a, int b) // Định nghĩa hàm
{
return a + b; // Khối lệnh của hàm
}

Trong đó:

  • <kiểu trả về>: Kiểu dữ liệu C/C++ (không trả về thì kiểu là void)
  • <tên hàm>: Như quy tắc đặt tên định danh
  • <danh sách tham số>: Như khi khai báo các biến trên một dòng, cách nhau bằng dấu “,”
  • <giá trị trả về>: Kết quả trả về của hàm (phải cùng kiểu với <kiểu_trả_về>)
Lưu ý cho kiểu void

Đối với kiểu void:

Đối với hàm có kiểu trả về là void:

  • Không có giá trị trả về: Việc sử dụng câu lệnh return <giá_trị>; sẽ gây lỗi biên dịch vì hàm void không trả về bất kỳ dữ liệu nào.

  • Dùng return để ngắt hàm: Bạn vẫn có thể sử dụng câu lệnh return; (không kèm giá trị) khi muốn kết thúc thực thi hàm sớm tại một thời điểm nhất định. (Ví dụ minh họa được nêu ở mục: 1.2.2. Tham số và Đối số)

1.2.2. Tham số và đối số​

Đây là khái niệm nền tảng để hiểu cách C++ truyền dữ liệu vào hàm.

  • Tham số (parameter / formal parameter): biến xuất hiện trong phần khai báo hoặc định nghĩa hàm.

  • Đối số (argument / actual argument): giá trị hoặc biểu thức cụ thể được truyền vào khi gọi hàm.

Ví dụ nhanh:

  • Trong hàm int tong(int a, int b), thì a, b là tham số.
  • Khi gọi tong(3, 5), thì 3, 5 là đối số.

Để một hàm được thực thi, ta cần phải gọi nó thông qua Lời gọi hàm (Function Call). Lời gọi này có thể nằm trong hàm main() hoặc nằm bên trong khối lệnh của một hàm khác. Khi gọi hàm, chương trình nhảy vào thân hàm tương ứng, thực hiện các lệnh, rồi trả về giá trị (nếu có).

Cú pháp:

<tên hàm>(<danh sách đối số>);

Ví dụ về minh họa cho Tham số, Đối số và Lời gọi hàm:

Loading...

Stdin:

Stdout:

Variable Scope

Nhắc lại kiến thức về biến cục bộ: Biến cục bộ a và b được khai báo trong một khối ngoặc nhọn { } (phạm vi của hàm add()). Biến chỉ có tác dụng trong khối ngoặc nhọn đó và sẽ bị xóa khỏi bộ nhớ khi chương trình chạy ra khỏi khối.

Nên các biến a, b của hàm main() và các biến a, b của hàm add() có cùng tên, nhưng vì chúng hoạt động trên 2 phạm vi (khối lệnh) khác nhau trong chương trình nên mỗi biến sẽ sở hữu một ô nhớ riêng, tách biệt nhau hoàn toàn. Thay đổi giá trị a, b bên trong hàm add() sẽ không làm ảnh hưởng đến a, b bên trong hàm main().

Bảng phân biệt tham số và đối số

Tiêu chíTham sốĐối số
Vị trí xuất hiệnTrong phần khai báo/định nghĩa hàmTrong lời gọi hàm
Vai tròNơi nhận dữ liệu của hàmDữ liệu thực tế được đưa vào
Thời điểm tồn tạiKhi khai báo hàmTại nơi gọi hàm
Có thể là biểu thứcKhôngCó, ví dụ x + 1, 2 * y, 42
Ví dụint a trong void f(int a)10 trong f(10)

Gọi thực thi một hàm trong một hàm khác:

Loading...

Stdin:

Stdout:

Khuyến khích các bạn đọc hết mục 1.2.4 trước khi đọc sang Kiến thức mở rộng chương 4: Hàm để hiểu hơn về Tham số và Đối số nhé.

Ví dụ minh họa cho kiểu void:

Loading...

Stdin:

Stdout:

  • Lưu ý:
    • Vì câu lệnh return; được đặt ngay trước lệnh in chuỗi "Xin chao 2!", chương trình sẽ lập tức thoát khỏi hàm XinChao2(). Do đó, dòng chữ này sẽ không bao giờ được in ra màn hình.
    • Đối với hàm có kiểu trả về là void, câu lệnh return; (không kèm giá trị) đóng vai trò kết thúc và ngắt luồng thực thi của hàm sớm tại một thời điểm nhất định, hoàn toàn không mang ý nghĩa trả về kết quả tính toán.

Sai lầm dễ nhầm lẫn

    1. Đối số bắt buộc phải là biến: thực tế có thể là hằng số hoặc biểu thức.
    1. Trùng tên là cùng biến: biến trùng tên ở hàm khác nhau vẫn là biến khác nhau.
    1. Thứ tự đối số không quan trọng: thứ tự ảnh hưởng trực tiếp đến kết quả.
    1. Sửa tham số luôn làm đổi biến gốc: điều này còn phụ thuộc hàm nhận theo cách nào (tham trị, tham chiếu, con trỏ).

1.2.3. Khai báo hàm và nguyên mẫu hàm​

Nguyên mẫu hàm (Function Prototype): Là khai báo hàm nhưng không ghi tên tham số, chỉ ghi kiểu dữ liệu tham số.

int so_lon(int, int);

Khai báo hàm (Function Declaration) / Nguyên mẫu hàm (Function Prototype): Đây là câu lệnh thông báo trước cho trình biên dịch về tên hàm, kiểu trả về và số lượng/kiểu dữ liệu của các tham số. Phần thân hàm (nội dung chi tiết) có thể được định nghĩa sau.

int so_lon(int a, int b);

Hoặc lược bỏ tên tham số (chỉ giữ lại kiểu dữ liệu):

int so_lon(int a, int b);
Lưu ý quan trọng

Trong chương trình C/C++ cơ bản, cụm từ khai báo hàm và nguyên mẫu hàm thường được dùng gần như tương đương. Việc có ghi tên tham số trong khai báo hay không là tùy chọn; trình biên dịch chỉ cần kiểu trả về và kiểu tham số để kiểm tra lời gọi hàm.

Ghi chú:

  • Hàm có trả về giá trị →\to Thường được dùng để gán cho một biến khác, đưa vào biểu thức tính toán, hoặc in trực tiếp ra màn hình.
  • Hàm không trả về giá trị →\to Chỉ được gọi như một câu lệnh độc lập để thực thi một tác vụ nào đó.

1.2.4. Truyền tham trị, tham chiếu và tham chiếu hằng​

  • Truyền tham trị (pass by value): Hàm nhận bản sao của dữ liệu. Sửa trong hàm không làm đổi biến gốc.
  • Truyền tham chiếu (pass by reference): Hàm làm việc trực tiếp trên biến gốc qua ký hiệu &.
  • Truyền tham chiếu hằng (const reference): Dùng const T& để tránh sao chép dữ liệu lớn (giúp tối ưu hiệu suất, tránh lãng phí bộ nhớ không cần thiết) nhưng vẫn đảm bảo hàm không thể vô tình sửa đổi dữ liệu gốc.

Ví dụ:

Loading...

Stdin:

Stdout:


1.2.5. Tham số mặc định​

Tham số mặc định (default argument) cho phép gán giá trị sẵn cho tham số. Khi gọi hàm nếu thiếu đối số ở cuối danh sách, C++ sẽ dùng giá trị mặc định.

Loading...

Stdin:

Stdout:

Lưu ý khi dùng tham số mặc định

Trong C++, các tham số có giá trị mặc định bắt buộc phải được đặt ở cuối danh sách tham số (nằm ngoài cùng bên phải). Bạn không thể đặt một tham số mặc định xen kẽ hoặc đứng trước một tham số bắt buộc.

Lỗi: int tinhToan(int a = 5, int b); (Lỗi biên dịch) Hợp lệ: int tinhToan(int a, int b = 5);


1.3. Phạm vi và mở rộng hàm​

1.3.1. Phạm vi hoạt động của biến trong hàm​

Trong C++\texttt{C++}, các biến có phạm vi hoạt động khác nhau tùy thuộc vào vị trí và cách biến được khai báo. Dưới đây là các loại phạm vi biến trong hàm:

  • Biến cục bộ (Local Variable)
  • Biến tĩnh cục bộ (Static Local Variable)
  • Biến toàn cục (Global Variable)

1.3.1.1. Biến cục bộ​

Biến cục bộ (Local Variable) được khai báo trong một khối ngoặc nhọn { }. Biến chỉ có tác dụng trong khối ngoặc nhọn đó và sẽ bị xóa khỏi bộ nhớ khi chương trình chạy ra khỏi khối.

Ví dụ:

Loading...

Stdin:

Stdout:

Kiến thức được nêu ở trên thuộc phần kiến thức của Chương 2: Cú pháp lập trình - Phạm vi của biến để hiểu rõ hơn về phạm vi của biến nhé.

1.3.1.2. Biến toàn cục​

  • Biến toàn cục (Global variable): Biến toàn cục chỉ bị xóa khi chương trình kết thúc →\to Tốn bộ nhớ nếu lạm dụng
  • Biến toàn cục có thể bị thay đổi bởi bất kỳ hàm nào →\to Dễ gây lỗi logic nếu dùng bất cẩn →\to Khi dùng hàm nên hạn chế dùng biến toàn cục

Ví dụ:

Loading...

Stdin:

Stdout:

1.3.1.3. Biến tĩnh cục bộ​

Biến tĩnh cục bộ (Static Local Variable): Khác với biến cục bộ thông thường, biến tĩnh cục bộ không bị hủy khi hàm kết thúc mà tồn tại suốt thời gian chạy của chương trình. Giá trị của biến tĩnh cục bộ được bảo lưu giữa các lần gọi hàm và chúng được tạo ra ở thời điểm bắt đầu của chương trình.

Cú pháp: Bạn chỉ cần dùng thêm keyword static trước các kiểu dữ liệu có sẵn.

static <data_type> <variable_name> = <giá_trị_khởi_tạo>;

Ví dụ:

Loading...

Stdin:

Stdout:

Câu lệnh khởi tạo (ví dụ static int cnt = 0;) chỉ được thực thi đúng một lần duy nhất ở lần gọi hàm đầu tiên. Các lần gọi hàm sau, chương trình sẽ bỏ qua dòng khởi tạo này và dùng lại giá trị hiện có trên bộ nhớ.

Một đặc điểm cực kỳ quan trọng của biến tĩnh (và cả biến toàn cục) so với biến cục bộ thông thường là: nếu bạn không khởi tạo giá trị ban đầu cho nó (ví dụ: static int cnt = 0), chúng sẽ tự động được gán giá trị mặc định là 0, thay vì mang một giá trị rác ngẫu nhiên.

ghi chú

Bạn chỉ nên sử dụng Biến tĩnh cục bộ khi bạn cần một biến cục bộ có thể ghi nhớ giá trị của chính nó qua các lần gọi hàm


1.3.2. Nạp chồng hàm (Function Overloading)​

Nạp chồng hàm (Function Overloading) là kỹ thuật cho phép nhiều hàm cùng tên nhưng khác nhau về danh sách tham số (số lượng hoặc kiểu dữ liệu của tham số). Trình biên dịch sẽ tự chọn đúng hàm dựa trên đối số khi gọi.

Ví dụ:

Loading...

Stdin:

Stdout:

Lưu ý:

  • Nếu 2 hàm chỉ khác nhau kiểu trả về thì chương trình vẫn sẽ báo lỗi như thường

    int tong(int a, int b) { return a + b; }
    double tong(int a, int b) { return a + b; } // LỖI: Trình biên dịch không biết gọi hàm nào
  • Cần cẩn thận khi kết hợp nạp chồng hàm với tham số mặc định:

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

    // Khi bạn gọi:
    tong(3, 5); // LỖI: C++ không biết nên gọi hàm 1, hay gọi hàm 2 với c = 0

2. Hàm đệ quy​

Loading...

Stdin:

Stdout:

  • Nhìn vào đoạn chương trình trên, có thể thấy nếu muốn thêm các trường hợp như n = 4, 5, 6,... thì bạn phải viết thêm rất nhiều vòng lặp lồng nhau cho mỗi giá trị n mà bạn viết thêm (rất dễ sai và khó debug sau này)

  • Vì lý do trên, C++ cung cấp cho bạn một loại hàm đặt biệt có tên là hàm đệ quy để giải quyết logic rắc rối và phức tạp trên.

Trong mục này, mình sẽ cùng tìm hiểu về thế nào là Đệ quy và Hàm đệ quy

2.1. Giới thiệu đệ quy​

2.1.1. Khái niệm đệ quy​

Đệ quy là cách giải một bài toán bằng cách chia bài toán lớn thành các bài toán nhỏ hơn nhưng có cùng cấu trúc. Mỗi bài toán nhỏ lại được xử lý theo đúng quy tắc của bài toán gốc. Quá trình này lặp lại cho đến khi đạt đến một trường hợp đơn giản nhất, gọi là điểm dừng, nơi không cần chia nhỏ nữa.


2.1.2. Hàm đệ quy và Nguyên tắc hoạt động​

Hàm đệ quy (Recursion Function) là một hàm đặc biệt có khả năng tự gọi lại chính nó để xử lý phiên bản nhỏ hơn của bài toán, và kết quả cuối cùng được hình thành khi các lời gọi quay ngược lại (unwinding). Quá trình này gồm ba bước quan trọng:

  • Xác định điểm dừng (base case): Đây là trường hợp đơn giản nhất và là điểm kết thúc của quá trình đệ quy nơi mà hàm không gọi lại chính nó nữa. Luôn cần xác định được base case để ngăn chặn việc gọi hàm vô hạn.
  • Chia bài toán thành bài toán nhỏ hơn: Hàm xử lý bài toán lớn bằng cách tách nó thành phiên bản nhỏ hơn của chính nó. Mỗi lần gọi phải làm bài toán nhỏ đi, tiến gần đến điểm dừng.
  • Truy hồi kết quả (return ngược): Khi đạt đến điểm dừng, hàm bắt đầu trả kết quả ngược về các lời gọi trước đó. Các mức gọi được xếp chồng (stack) và được giải quyết theo thứ tự ngược lại.

Cấu trúc chung của hàm đệ quy:

<kiểu dữ liệu> <tên hàm>(<tham số>) {
if (<điều kiện dừng>) return <giá trị>; // base case
return <kết quả từ lời gọi chính nó>; // gọi lại chính hàm
}

Trong đó:

  • <điều kiện dừng> (base case): Là điều kiện đơn giản nhất, giúp hàm kết thúc và tránh gọi vô hạn
  • <lời gọi đệ quy> (recursive call): Xử lý phiên bản nhỏ hơn của bài toán, tiến gần đến base case

Ví dụ 1: Tổng từ 1 đến n

Loading...

Stdin:

Stdout:

Sơ đồ 1 - Luồng gọi hàm (tinhTong(5)):

Sơ đồ 2 - Truy hồi kết quả sau khi chạm base case:

ghi chú

Nếu các bạn thắc mắc, nếu không có base case thì chuyện gì sẽ xảy ra?

Thì mời các bạn đọc mục 2.5 để hiểu rõ hơn vì sao phải có base case cho các hàm đệ quy.

Sơ đồ hoạt động tổng quát của Stack Memory khi thực thi hàm đệ quy:

Ví dụ 2: Tính giai thừa 5!5!

Loading...

Stdin:

Stdout:

Sơ đồ hoạt động:


2.2. Cấu trúc và phân loại hàm đệ quy​

2.2.1. Các loại đệ quy​

Phân loại hàm đệ quy:

  • Đệ quy tuyến tính (Single Recursion): Trong thân hàm có duy nhất một lời gọi hàm gọi lại chính nó một cách tường minh.
  • Đệ quy nhị phân (Binary Recursion): Trong thân hàm có hai lời gọi hàm gọi lại chính nó một cách tường minh (Thường dùng cho các thuật toán sắp xếp như Quick Sort hay Merge Sort,..).
  • Đệ quy phi tuyến (Multiple Recursion): Trong thân hàm có từ hai lời gọi đệ quy trở lên (bao gồm cả đệ quy nhị phân).
  • Đệ quy hỗ tương (Mutual Recursion): Trong thân hàm này có lời gọi hàm tới hàm kia và bên trong thân hàm kia có lời gọi hàm tới hàm này.

2.2.1.1. Đệ quy tuyến tính​

Cấu trúc chương trình:

<kieu_du_lieu> tenHam(<tham_so>) {
if (<dieu_kien_dung>) return <gia_tri_base_case>;
return <xu_ly_hien_tai> + tenHam(<tham_so_nho_hon>);
}

Ví dụ: Tính tổng từ 1 đến n mà mình đã giới thiệu ở trên chính là một ví dụ điển hình của đệ quy tuyến tính.

Loading...

Stdin:

Stdout:

2.2.1.2. Đệ quy nhị phân​

Cấu trúc chương trình:

<kieu_du_lieu> tenHam(<tham_so>) {
if (<dieu_kien_dung>) return <gia_tri_base_case>;
return tenHam(<tham_so_1>) + tenHam(<tham_so_2>);
}

Ví dụ: Tính số Fibonacci thứ n

Loading...

Stdin:

Stdout:

ghi chú

Fibonacci đệ quy thuần có độ phức tạp gần O(2n)O(2^n) (Có thể hiểu như thời gian chạy của chương trình), rất chậm khi n lớn. Phần 2.5 sẽ giới thiệu cách tối ưu bằng ghi nhớ kết quả (memoization).

2.2.1.3. Đệ quy phi tuyến​

Cấu trúc chương trình:

<kieu_du_lieu> tenHam(<tham_so>) {
if (<dieu_kien_dung>) return <gia_tri_base_case>;
<kieu_du_lieu> ketQua = <gia_tri_khoi_tao>;
for (...) {
ketQua += tenHam(<tham_so_nho_hon>);
}
return ketQua;
}

Ví dụ: Đếm số cách viết n thành tổng của 1 và 2

Loading...

Stdin:

Stdout:

2.2.1.4. Đệ quy hỗ tương​

Cấu trúc chương trình:

<kieu_du_lieu> hamA(<tham_so>) {
// Điều kiện dừng
if (<dieu_kien_ket_thuc_A>) {
return <gia_tri>;
}

// Gọi sang hàm B
return hamB(<tham_so_thay_doi>);
}

<kieu_du_lieu> hamB(<tham_so>) {
// Điều kiện dừng
if (<dieu_kien_ket_thuc_B>) {
return <gia_tri>;
}

// Gọi ngược lại hàm A
return hamA(<tham_so_thay_doi>);
}

Ví dụ: Kiểm tra số chẵn lẻ bằng đệ quy hỗ tương

Loading...

Stdin:

Stdout:


2.3. Cách xây dựng hàm đệ quy​


2.4. Các vấn đề đệ quy thông dụng​

2.4.1. Tràn ngăn xếp (Stack Overflow)​

  • Xảy ra khi độ sâu đệ quy quá lớn hoặc không có điều kiện dừng rõ ràng.
  • Cách hạn chế: kiểm tra đầu vào, đảm bảo mỗi bước tiến gần base case.

Ví dụ:

Loading...

Stdin:

Stdout:

Sơ đồ hoạt động của countDown(5):

Nhận xét quan trọng

Đoạn code countDown ở trên thiếu điều kiện dừng nên hàm sẽ tự gọi vô hạn. Vì vậy, chương trình không có điểm dừng hợp lệ và sẽ kết thúc khi bị tràn ngăn xếp (stack overflow).

Ngăn xếp (Stack Memory) là một loại bộ nhớ trong ngôn ngữ C++, và bộ nhớ thì cũng có giới hạn lưu trữ của chính nó. Do đó, ngay cả khi hàm của bạn có điều kiện dừng hợp lệ, nhưng nếu n quá lớn (ví dụ n = 100,000), chương trình vẫn có thể bị Crash do tràn bộ nhớ Call Stack. Đây là nhược điểm lớn nhất của đệ quy.

2.4.2. Tính lại kết quả nhiều lần​

  • Thường gặp ở Fibonacci đệ quy thuần.
  • Cách xử lý: dùng memoization hoặc đổi sang quy hoạch động lặp (Thường chỉ được dạy trong môn Cấu trúc dữ liệu và Giải thuật).

Ví dụ đọc thêm cho các bạn tìm hiểu sâu hơn (Không bắt buộc) Fibonacci có ghi nhớ kết quả (memoization):

Loading...

Stdin:

Stdout:

2.4.3. Khi nào chọn đệ quy, khi nào chọn vòng lặp?​

  • Chọn đệ quy khi bài toán có cấu trúc chia nhỏ tự nhiên.
  • Chọn vòng lặp khi công thức lặp đơn giản và cần hiệu năng/bộ nhớ tốt hơn.

2.4.4. Truyền tham số sai​

Mô tả: Tham số không tiến gần về điều kiện dừng sẽ làm kết quả sai hoặc lặp vô hạn.

Ví dụ sai:

int giaiThua(int n) {
if (n == 0) return 1;
return n * giaiThua(n); // Sai: tham so khong giam
}

Sửa:

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

2.5. Ưu điểm và nhược điểm của đệ quy​

2.5.1. Ưu điểm​

  • Mã ngắn gọn, sát tư duy toán học với bài toán có cấu trúc tự lặp.
  • Dễ mô tả bài toán phân rã theo các bài toán con giống nhau.
  • Rất phù hợp cho cây, đồ thị, quay lui, chia để trị.

2.5.2. Nhược điểm​

  • Có nguy cơ tràn ngăn xếp nếu độ sâu lớn.
  • Có thể chậm nếu sinh nhiều lời gọi trùng nhau.
  • Khó debug hơn vòng lặp với người mới học.
Gợi ý học tập cho sinh viên năm nhất

Khi viết đệ quy, hãy luôn tự kiểm tra theo 3 câu hỏi:

  1. Điều kiện dừng là gì?
  2. Mỗi lời gọi có làm bài toán nhỏ đi không?
  3. Có bị tính lặp kết quả cũ nhiều lần không?

Tài liệu tham khảo

  1. UIT - Hàm
  2. UIT - Đệ quy
  3. Global and Static Local Variables
  4. Static Local Variables
  5. Maximum Stack Size for C/C++ Programme