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

Hàm

Khi chương trình lớn dần, ta không thể nhồi mọi lệnh vào một hàm main khổng lồ. Càng dài thì càng khó đọc, khó sửa, và một đoạn tính toán giống nhau bị chép đi chép lại ở nhiều chỗ. Giải pháp là chia chương trình thành những khối nhỏ có tên, mỗi khối lo một việc rõ ràng. Khối đó gọi là hàm.

Hàm là một khối lệnh có tên, nhận đầu vào (tham số) và trả về kết quả. Hàm giúp tách chương trình thành những việc nhỏ, đặt tên rõ ràng, và tái sử dụng thay vì chép code nhiều lần. Viết hàm tinhTrungBinh một lần rồi gọi ở đâu cũng được, sửa công thức thì chỉ sửa một chỗ. Đây cũng là nền để hiểu phương thức trong OOP sau này, vì phương thức thực chất là hàm gắn với một đối tượng.

Hãy lấy một tình huống thật. Bạn viết chương trình quản lý điểm cho một lớp, và ở ba chỗ khác nhau bạn cần tính điểm trung bình có làm tròn: khi in bảng điểm, khi xét học bổng, và khi xuất báo cáo. Nếu chép cùng một đoạn tính vào cả ba chỗ, thì hôm nào nhà trường đổi quy tắc làm tròn, bạn phải nhớ sửa đủ ba chỗ. Sửa được hai, quên một, và chương trình bắt đầu cho ra hai kết quả khác nhau cho cùng một sinh viên. Đây không phải chuyện giả tưởng, nó là nguyên nhân của rất nhiều lỗi trong phần mềm thật. Hàm giải quyết tận gốc: viết tinhTrungBinh một lần, ba chỗ kia cùng gọi nó, và quy tắc làm tròn chỉ tồn tại đúng một bản duy nhất trong toàn chương trình.

Hàm còn mang lại một thứ tinh tế hơn: nó cho bạn quyền quên. Sau khi viết xong và thử kỹ hàm laNguyenTo, bạn không cần nhớ bên trong nó chạy vòng lặp tới đâu nữa. Bạn chỉ cần nhớ nó nhận một số nguyên và trả về đúng hay sai. Cái tên hàm trở thành một khái niệm mới để bạn tư duy ở tầng cao hơn, thay vì lúc nào cũng phải nghĩ bằng các phép chia lấy dư. Khả năng gói một mớ chi tiết lại sau một cái tên rồi quên chúng đi chính là thứ cho phép con người viết được những chương trình lớn hơn nhiều so với những gì trí nhớ ngắn hạn của mình chứa nổi.

Trong bài này ta đi từ cách khai báo và định nghĩa hàm, phân biệt tham số với đối số, hai kiểu truyền dữ liệu là theo giá trị và theo tham chiếu, giá trị trả về, rồi tới những công cụ tiện lợi của C++ như nạp chồng hàm và tham số mặc định. Cuối cùng ta chạm tới phạm vi biến, khung ngăn xếp và một chút đệ quy để chuẩn bị cho các bài sau.

Ý tưởng cốt lõi

  • int binhPhuong(int x) { return x * x; }: kiểu trả về int, tên, tham số x, thân hàm.
  • Khi gọi binhPhuong(5), máy tạo một khung (frame) riêng cho hàm với x = 5.
  • return đưa kết quả về nơi gọi rồi khung của hàm biến mất.

Thử ngay: xem hàm chạy từng bước

Mỗi lời gọi hàm có khung riêng C++
1int binhPhuong(int x) {
2 return x * x;
3}
4
5int main() {
6 int k = binhPhuong(5);
7 cout << k;
8}
binhPhuong(5)x = 5
Ngăn xếp stack
main()
k= ?
binhPhuong(x)
x= 5
Bộ nhớ động heap
(trống)
main gọi binhPhuong(5): tạo một KHUNG mới cho binhPhuong, đặt tham số x = 5.
1/4

Khai báo và định nghĩa hàm

Một hàm gồm bốn phần: kiểu trả về, tên hàm, danh sách tham số trong ngoặc đơn, và thân hàm trong ngoặc nhọn. Dòng đầu không kể thân gọi là chữ ký của hàm.

#include <iostream>
using namespace std;

int add(int a, int b) { // kiểu trả về int, tên add, hai tham số a và b
return a + b;
}

int main() {
cout << add(3, 4); // 7
}

C++ đòi hàm phải được biết tới trước chỗ gọi. Nếu muốn đặt định nghĩa hàm phía dưới main, ta viết một dòng khai báo trước (prototype) ở trên, chỉ gồm chữ ký và dấu chấm phẩy, còn phần thân để sau. Khai báo nói cho trình biên dịch biết hàm tồn tại và có dạng ra sao; định nghĩa mới là phần thân thật sự.

#include <iostream>
using namespace std;

int add(int a, int b); // khai báo trước: chỉ có chữ ký

int main() {
cout << add(3, 4); // gọi được vì đã khai báo ở trên
}

int add(int a, int b) { // định nghĩa đầy đủ đặt sau main
return a + b;
}

Tham số và đối số

Hai từ nghe giống nhau nhưng khác vai trò. Tham số là biến ghi trong ngoặc lúc định nghĩa hàm, ví dụ ab. Đối số là giá trị thật ta đưa vào lúc gọi, ví dụ 34 trong add(3, 4). Khi gọi, đối số được gán vào tham số theo đúng thứ tự: a nhận 3, b nhận 4. Sai thứ tự thì kết quả sai, nên thứ tự đối số phải khớp thứ tự tham số.

Điều nguy hiểm là trình biên dịch không thể cứu bạn khi hai tham số cùng kiểu. Với hàm int tinhLai(int soTien, int soThang), lời gọi tinhLai(12, 5000000) dịch trót lọt vì cả hai đối số đều là int, chỉ có điều bạn vừa nói với máy rằng khách gửi mười hai đồng trong năm triệu tháng. Cách phòng tránh là đặt tên tham số thật rõ nghĩa và giữ số lượng tham số ít. Một hàm nhận sáu tham số cùng kiểu int gần như chắc chắn sẽ bị gọi sai thứ tự vào một ngày nào đó.

Một tham số cũng chính là một biến cục bộ của hàm, chỉ khác ở chỗ nó được đặt sẵn giá trị lúc gọi thay vì bạn tự gán. Vì vậy bạn hoàn toàn có quyền gán lại nó trong thân hàm, và làm thế không ảnh hưởng gì tới nơi gọi, miễn tham số được truyền theo giá trị. Vì sao lại như vậy, mục truyền theo giá trị và theo tham chiếu ở phía sau sẽ nói rõ.

Mã nguồn

#include <iostream>
using namespace std;

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

int main() {
int k = binhPhuong(5);
cout << k; // 25
}

Đây đúng là chương trình mà trình mô phỏng ở đầu bài đã chạy. Giờ có đủ khái niệm rồi, ta đi chậm lại một lần nữa bằng lời. Chương trình luôn bắt đầu ở main. Gặp binhPhuong(5), máy tạm dừng main lại, ghi nhớ rằng khi xong việc phải quay về đúng chỗ này để gán kết quả vào k. Nó dựng một vùng làm việc mới cho binhPhuong, chép giá trị 5 vào tham số x nằm trong vùng đó, rồi nhảy vào thân hàm. Trong thân, x * x cho 25, và return làm hai việc cùng lúc: đặt 25 vào chỗ đã hẹn để main nhặt lấy, và kết thúc hàm ngay lập tức. Vùng làm việc của binhPhuong bị thu hồi, x biến mất khỏi bộ nhớ. main tỉnh dậy đúng chỗ nó đã dừng, nhận 25, gán vào k, rồi in ra.

Hãy chú ý một chi tiết dễ trôi qua: main không hề biết bên trong binhPhuong có gì, và binhPhuong không hề biết kết quả của nó sẽ được gán vào biến tên k. Hai bên chỉ giao tiếp qua đúng hai thứ là đối số truyền vào và giá trị trả về. Ranh giới sạch sẽ đó là lý do bạn có thể viết lại ruột binhPhuong bất cứ lúc nào mà không sợ làm hỏng main.

Tự kiểm tra

Tham số x biến mất khi nào?

Khi hàm kết thúc (gặp return hoặc hết thân). Khung của lời gọi bị thu hồi cùng mọi biến cục bộ trong đó, kể cả x. Lần gọi sau lại tạo khung mới, các lời gọi độc lập nhau.

Ghi nhớ

Hàm = đầu vào → xử lý → return kết quả. Mỗi lời gọi có khung riêng trên ngăn xếp, sống đúng trong thời gian hàm chạy. Hiểu khung gọi là chìa khóa để theo dõi mọi chương trình.

Truyền theo giá trị và theo tham chiếu

Đây là điểm quan trọng nhất về hàm trong C++. Mặc định C++ truyền tham số theo giá trị: hàm nhận một bản sao của đối số. Mọi thay đổi bên trong hàm chỉ tác động lên bản sao đó, biến gốc ở nơi gọi không hề đổi.

#include <iostream>
using namespace std;

void tangTri(int x) { x++; } // sửa bản sao, biến gốc không đổi
void tangRef(int& x) { x++; } // sửa trực tiếp biến gốc

int main() {
int a = 5;
tangTri(a); cout << a << "\n"; // 5, không đổi
tangRef(a); cout << a << "\n"; // 6, đã đổi
}

Chỉ khác một dấu & trong int& x mà hành vi đổi hẳn. Dấu & biến tham số thành tham chiếu, tức là một tên gọi khác trỏ thẳng vào biến gốc. Hàm thao tác trên tham chiếu chính là thao tác trên biến của người gọi. Ta dùng tham chiếu khi muốn hàm thay đổi biến gốc, hoặc muốn trả về nhiều kết quả cùng lúc.

Tham chiếu còn quan trọng vì hiệu năng. Truyền một vector hay mảng lớn theo giá trị buộc máy sao chép toàn bộ phần tử mỗi lần gọi, rất tốn thời gian và bộ nhớ. Truyền theo tham chiếu tránh sao chép. Nếu chỉ đọc mà không sửa, thêm const để trình biên dịch canh giúp, vừa an toàn vừa vẫn nhanh.

#include <iostream>
#include <vector>
using namespace std;

long long tongVec(const vector<int>& v) { // tham chiếu, không sao chép
long long s = 0;
for (int x : v) s += x;
return s;
}

int main() {
vector<int> a = {3, 1, 4, 1, 5};
cout << tongVec(a); // 14
}

Vậy là có ba cách truyền tham số, và chọn cách nào là một quyết định có ý nghĩa chứ không phải chuyện phong cách. Bảng dưới tóm lại sự khác biệt:

Cách viếtCó sao chép khôngHàm sửa được biến gốc khôngDùng khi nào
void f(int x)có, chép giá trịkhôngkiểu nhỏ, hàm chỉ cần đọc
void f(int& x)khôngmuốn hàm thay đổi biến của người gọi
void f(const vector<int>& v)khôngkhông, trình dịch chặndữ liệu lớn, hàm chỉ đọc

Quy tắc thực dụng: với các kiểu nhỏ như int, double, char, bool thì cứ truyền theo giá trị, vì bản sao chỉ vài byte, còn rẻ hơn cả việc đi vòng qua tham chiếu. Với các kiểu lớn như vector hay string, mặc định dùng const và tham chiếu. Chỉ bỏ const khi bạn thật sự có ý định sửa dữ liệu của người gọi.

Cái giá của việc quên const không nằm ở tốc độ mà ở sự an tâm. Khi đọc chữ ký long long tongVec(const vector<int>& v), bạn biết chắc chắn hàm này không đụng vào v, khỏi phải mở thân hàm ra kiểm tra. Nếu ai đó lỡ tay viết v.push_back(9); bên trong, trình biên dịch sẽ báo lỗi ngay chứ không để lỗi trôi tới lúc chạy. const biến một lời hứa suông thành một điều kiện được máy kiểm tra.

Về hiệu năng, hãy hình dung một vector chứa một triệu số nguyên, tức bốn triệu byte. Mỗi lần gọi hàm truyền theo giá trị là một lần máy phải xin bộ nhớ mới rồi chép đủ bốn triệu byte đó. Truyền theo tham chiếu chỉ chuyển đi một địa chỉ, tốn tám byte, bất kể dữ liệu to cỡ nào. Trong một vòng lặp gọi hàm nhiều lần, khác biệt giữa hai cách có thể là giữa một chương trình chạy tức thì và một chương trình treo máy.

Tham chiếu cũng là cách C++ để một hàm trả về nhiều kết quả. Hàm chỉ return được đúng một giá trị, nhưng nếu bạn cần vừa thương vừa dư, hãy cho hai tham số tham chiếu:

#include <iostream>
using namespace std;

void chia(int a, int b, int& thuong, int& du) {
thuong = a / b; // ghi thang vao bien cua nguoi goi
du = a % b;
}

int main() {
int t, d;
chia(17, 5, t, d);
cout << t << " " << d << "\n"; // 3 2
return 0;
}

Giá trị trả về

return làm hai việc: đưa kết quả về nơi gọi và kết thúc hàm ngay lập tức. Kiểu của giá trị trả về phải khớp kiểu ghi ở chữ ký. Hàm khai báo int thì return một số nguyên, khai báo double thì trả về số thực.

Hàm không cần trả gì thì khai báo kiểu void. Trong hàm void vẫn dùng được return; trống để thoát sớm, nhưng không kèm giá trị.

#include <iostream>
using namespace std;

void inChan(int n) {
if (n % 2 != 0) return; // số lẻ thì thoát, không in gì
cout << n << " la so chan";
}

int main() {
inChan(7); // không in gì
inChan(8); // 8 la so chan
}

Một hàm khai báo kiểu khác void mà đi hết thân không gặp return sẽ trả về một giá trị rác. Hãy bảo đảm mọi nhánh của hàm đều kết thúc bằng một return hợp lệ.

Nạp chồng hàm và tham số mặc định

C++ cho phép nạp chồng hàm (overload): nhiều hàm cùng tên nhưng khác danh sách tham số. Trình biên dịch dựa vào số lượng và kiểu đối số lúc gọi để chọn đúng hàm.

#include <iostream>
using namespace std;

int cong(int a, int b) { return a + b; }
double cong(double a, double b) { return a + b; }

int main() {
cout << cong(2, 3) << "\n"; // gọi bản int, in 5
cout << cong(2.5, 1.5) << "\n"; // gọi bản double, in 4
}

Lưu ý các bản nạp chồng phải khác nhau ở tham số. Chỉ khác kiểu trả về thôi thì không đủ và trình biên dịch báo lỗi.

Tham số mặc định cho phép bỏ bớt đối số khi gọi; nếu thiếu, hàm dùng giá trị đặt sẵn. Các tham số có mặc định phải nằm ở cuối danh sách.

#include <iostream>
using namespace std;

int luyThua(int co_so, int mu = 2) { // mu mặc định là 2
int kq = 1;
for (int i = 0; i < mu; i++) kq *= co_so;
return kq;
}

int main() {
cout << luyThua(5) << "\n"; // 25, dùng mu = 2
cout << luyThua(2, 10) << "\n"; // 1024, mu = 10
}

Phạm vi biến

Phạm vi của một biến là vùng code nơi biến đó tồn tại và dùng được. Biến khai báo trong một hàm là biến cục bộ: nó chỉ sống trong hàm đó, các hàm khác không thấy. Hai hàm khác nhau có thể đặt biến trùng tên mà không đụng nhau vì mỗi biến nằm trong phạm vi riêng.

#include <iostream>
using namespace std;

void f() {
int x = 10; // x của f
cout << x << "\n";
}

int main() {
int x = 99; // x của main, khác hẳn x của f
f(); // in 10
cout << x; // in 99
}

Biến khai báo ngoài mọi hàm là biến toàn cục, mọi hàm đều thấy. Ngoài ra biến còn bị giới hạn theo khối { }: biến khai báo trong thân một vòng for chỉ sống hết vòng đó. Nên hạn chế biến toàn cục vì chúng dễ bị sửa nhầm từ nhiều nơi.

Lý do sâu xa nên tránh biến toàn cục là nó phá vỡ đúng cái ranh giới sạch sẽ mà hàm dựng lên. Một hàm chỉ đọc tham số và trả về kết quả thì bạn đọc chữ ký là hiểu nó phụ thuộc vào gì. Một hàm lén đọc và sửa biến toàn cục thì kết quả của nó phụ thuộc vào lịch sử của cả chương trình, và để hiểu vì sao nó ra số đó, bạn phải truy ngược mọi nơi từng động vào biến toàn cục kia. Hàm mất khả năng được thử độc lập, và bạn mất luôn cái quyền quên đã nói ở đầu bài.

Khung ngăn xếp

Phần này giải thích cơ chế nằm dưới mọi thứ ở trên. Khi một hàm được gọi, máy dựng cho nó một vùng bộ nhớ riêng gọi là khung (stack frame). Khung chứa các tham số, các biến cục bộ, và địa chỉ để quay về nơi gọi sau khi hàm xong. Các khung xếp chồng lên nhau trên một vùng nhớ gọi là ngăn xếp (stack), theo đúng nghĩa đen của từ chồng: khung mới đặt lên trên cùng, và chỉ khung trên cùng mới đang chạy.

Cơ chế này giải thích gọn ba điều mà người mới hay thắc mắc. Thứ nhất, vì sao biến cục bộ biến mất khi hàm kết thúc: vì cả khung bị gỡ khỏi ngăn xếp, không phải máy đi xóa từng biến. Thứ hai, vì sao hai hàm đặt biến trùng tên mà không đụng nhau: vì chúng nằm ở hai khung khác nhau, hoàn toàn tách biệt trong bộ nhớ. Thứ ba, vì sao mỗi lời gọi của cùng một hàm lại độc lập: mỗi lời gọi dựng một khung mới, nên gọi binhPhuong mười lần là mười khung sinh ra rồi mất đi lần lượt.

Ngăn xếp có kích thước hữu hạn, thường vài megabyte. Nếu các khung chồng lên nhau quá sâu mà chưa cái nào chịu kết thúc, ngăn xếp đầy và chương trình sập với lỗi tràn ngăn xếp (stack overflow). Đây chính xác là chuyện xảy ra khi một hàm đệ quy quên trường hợp cơ sở: nó gọi mãi, khung chồng mãi, tới lúc hết chỗ.

Trả về tham chiếu tới biến cục bộ

Đừng bao giờ viết int& f() { int x = 5; return x; }. Biến x sống trong khung của f, mà khung đó bị thu hồi ngay khi f kết thúc, nên cái tham chiếu trả về đang trỏ vào một vùng nhớ không còn thuộc về ai. Dùng nó là hành vi không xác định: chương trình có thể in ra số đúng, in ra số rác, hoặc sập, tùy lúc. Muốn trả giá trị ra thì cứ trả theo giá trị (int f() { int x = 5; return x; }), máy sẽ chép nó về nơi gọi trước khi khung biến mất.

Hàm nội tuyến sơ lược

Mỗi lời gọi hàm có một chi phí nhỏ: dựng khung, chép đối số, nhảy tới thân hàm, rồi nhảy về. Với một hàm thân chỉ vỏn vẹn một dòng như int binhPhuong(int x) { return x * x; }, chi phí thủ tục đó có khi còn lớn hơn chính phép nhân. Từ khóa inline là lời gợi ý cho trình biên dịch: thay vì thật sự gọi hàm, hãy chép thẳng thân hàm vào chỗ gọi, coi như bạn đã tự tay viết x * x ở đó.

#include <iostream>
using namespace std;

inline int binhPhuong(int x) { // goi y trinh dich chen thang than ham vao cho goi
return x * x;
}

int main() {
cout << binhPhuong(5); // trinh dich co the bien thanh: cout << 5 * 5;
return 0;
}

Ba điều cần nhớ và đừng hiểu sai. Một, inline chỉ là gợi ý, trình biên dịch có toàn quyền phớt lờ, và ngược lại nó cũng thường tự nội tuyến các hàm nhỏ dù bạn không viết gì. Hai, nội tuyến không miễn phí: thân hàm bị chép ra nhiều chỗ nên mã máy phình to, và mã phình to lại chạy chậm đi vì bộ nhớ đệm lệnh của CPU chứa được ít hơn. Vì vậy chỉ hợp với hàm rất ngắn. Ba, đừng vì ham tốc độ mà bỏ hàm để viết mã dàn trải; hãy cứ chia hàm cho rõ ý, rồi để trình biên dịch lo phần tối ưu. Người mới gần như không cần gõ chữ inline bao giờ, biết nó tồn tại và hiểu nó nói về cái gì là đủ.

Đệ quy sơ lược

Đệ quy là khi một hàm tự gọi lại chính nó để giải bài toán nhỏ hơn cùng dạng. Mỗi hàm đệ quy cần hai phần: trường hợp cơ sở để dừng, và bước truy hồi gọi lại hàm với dữ liệu nhỏ hơn. Thiếu trường hợp cơ sở thì hàm gọi mãi và tràn ngăn xếp.

#include <iostream>
using namespace std;

long long giaiThua(int n) {
if (n <= 1) return 1; // trường hợp cơ sở
return n * giaiThua(n - 1); // bước truy hồi
}

int main() {
cout << giaiThua(5); // 120
}

Lời gọi giaiThua(5) chờ giaiThua(4), cái này chờ giaiThua(3), cứ thế xuống tới giaiThua(1) trả về 1, rồi các khung lần lượt nhân ngược lên 120. Mỗi lời gọi có một khung riêng trên ngăn xếp, đúng như đã học ở phần đầu bài.

Những nhầm lẫn hay gặp
  • Tưởng truyền theo giá trị mà sửa được biến gốc. Muốn hàm đổi biến của người gọi phải thêm & để dùng tham chiếu.
  • Quên return trong hàm khác void, hoặc có nhánh if không trả về gì, khiến hàm trả giá trị rác.
  • Truyền vector hay mảng lớn theo giá trị làm chương trình sao chép hàng loạt, chạy chậm; hãy dùng const vector<int>&.
  • Viết hàm đệ quy quên trường hợp cơ sở, hoặc bước truy hồi không nhỏ dần, dẫn tới gọi vô hạn và tràn ngăn xếp.
  • Đặt tham số mặc định không nằm ở cuối danh sách, hoặc nạp chồng hai hàm chỉ khác kiểu trả về, đều gây lỗi biên dịch.

Bài tập

Bài 1. Viết hàm bool laNguyenTo(int n) kiểm tra số nguyên tố.
#include <iostream>
using namespace std;

bool laNguyenTo(int n) {
if (n < 2) return false; // 0, 1 và số âm không phải nguyên tố
for (int i = 2; i * i <= n; i++) { // chỉ xét tới căn bậc hai của n
if (n % i == 0) return false; // có ước thì không nguyên tố
}
return true;
}

int main() {
cout << laNguyenTo(17) << "\n"; // 1 (true)
cout << laNguyenTo(18); // 0 (false)
}

Điều kiện i * i <= n thay cho i <= n giúp giảm số vòng lặp rất nhiều mà vẫn đủ để kết luận, vì một ước lớn hơn căn bậc hai luôn đi kèm một ước nhỏ hơn.

Bài 2. Viết hàm void hoanDoi(int& a, int& b) tráo giá trị hai biến.
#include <iostream>
using namespace std;

void hoanDoi(int& a, int& b) {
int tam = a; // giữ tạm giá trị a
a = b;
b = tam;
}

int main() {
int x = 3, y = 8;
hoanDoi(x, y);
cout << x << " " << y; // 8 3
}

Phải dùng tham chiếu int&, nếu không hàm chỉ tráo bản sao và hai biến gốc x, ymain không đổi. Biến tam giữ giá trị cũ để không bị mất khi gán đè.

Bài 3. Viết hàm đệ quy tính tổng 1 + 2 + ... + n.
#include <iostream>
using namespace std;

long long tong(int n) {
if (n == 0) return 0; // trường hợp cơ sở
return n + tong(n - 1); // bước truy hồi
}

int main() {
cout << tong(100); // 5050
}

Hàm cộng n với tổng của phần còn lại, gọi xuống dần tới tong(0) trả về 0 rồi cộng ngược lên. Đây là mẫu đệ quy đơn giản nhất, phản chiếu đúng công thức toán học.

Bài 4. Viết hàm trả về đồng thời giá trị nhỏ nhất và lớn nhất của một vector<int>.
#include <iostream>
#include <vector>
using namespace std;

// v truyen theo const tham chieu: khong sao chep, ham khong sua duoc v
// mn va mx truyen theo tham chieu: day la hai duong ra cua ham
void timMinMax(const vector<int>& v, int& mn, int& mx) {
mn = v[0];
mx = v[0];
for (int i = 1; i < (int)v.size(); i++) { // ep size() ve int truoc khi so sanh
if (v[i] < mn) mn = v[i];
if (v[i] > mx) mx = v[i];
}
}

int main() {
vector<int> a = {4, -2, 9, 3};
int nho, lon;
timMinMax(a, nho, lon);
cout << nho << " " << lon << "\n"; // -2 9
return 0;
}

Bài này gom đủ ba cách truyền tham số trong một chữ ký. Dữ liệu vào là v, lớn và chỉ cần đọc, nên dùng const vector<int>&. Hai kết quả ra là mnmx, mà return chỉ trả được một giá trị, nên chúng thành tham số tham chiếu. Hai chi tiết đáng chú ý. Thứ nhất, phải khởi tạo mnmx bằng v[0] chứ đừng đặt bừa một hằng như 0, vì nếu mọi phần tử đều âm thì mx sẽ ở lại 0 một cách sai lầm. Thứ hai, v.size() là kiểu không dấu nên ta ép về int trước khi so sánh với i, tránh cảnh báo và tránh lỗi khi vector rỗng. Hàm này giả định v có ít nhất một phần tử; trong mã thật nên kiểm tra rỗng trước khi gọi.

Bài 5. Viết hàm double trungBinh(const vector<int>& v) trả về giá trị trung bình.
#include <iostream>
#include <vector>
using namespace std;

double trungBinh(const vector<int>& v) {
if (v.empty()) return 0.0; // tranh chia cho 0
long long tong = 0; // tong nhieu so int co the vuot int
for (int x : v) tong += x;
return (double)tong / (int)v.size(); // ep sang double de chia thuc
}

int main() {
vector<int> a = {3, 4, 4};
cout << trungBinh(a) << "\n"; // 3.66667
return 0;
}

Lời giải này né ba cái bẫy cùng lúc. Một, v có thể rỗng, nên phải chặn trước bằng v.empty() kẻo chia cho 0. Hai, tổng của nhiều số int có thể vượt hai tỉ, nên tong phải là long long. Ba, nếu viết return tong / v.size(); thì phép chia diễn ra giữa hai số nguyên, phần lẻ bị cắt và hàm trả về 3 thay vì 3.66667, dù kiểu trả về đã khai báo là double. Việc ép kiểu phải xảy ra trong biểu thức chia chứ không phải lúc gán, nên ta viết (double)tong / (int)v.size().

Câu hỏi tự kiểm

Kiểm tra nhanh: hàm, tham số và khung gọi0/4 đúngchưa trả lời
  1. 1Với void tang(int x) { x++; }, sau khi gọi tang(a) với a = 5 thì a bằng bao nhiêu?
  2. 2Dùng giá trị trả về của int& f() { int x = 5; return x; } là gì?
  3. 3Khai báo nào hợp lệ về vị trí tham số mặc định?
  4. 4Hàm chỉ đọc một vector<int> lớn nên nhận tham số kiểu nào?

Tóm tắt

  • Hàm là khối lệnh có tên gồm kiểu trả về, tên, tham số và thân, giúp tái sử dụng và chia nhỏ chương trình.
  • Tham số là biến lúc định nghĩa, đối số là giá trị thật lúc gọi, gán theo đúng thứ tự.
  • Truyền theo giá trị dùng bản sao; truyền theo tham chiếu (&) tác động thẳng lên biến gốc và tránh sao chép tốn kém.
  • return trả kết quả và kết thúc hàm; hàm void không trả giá trị.
  • Nạp chồng hàm cho nhiều hàm cùng tên khác tham số; tham số mặc định giúp bỏ bớt đối số khi gọi.
  • Biến cục bộ chỉ sống trong hàm hay khối của nó; đệ quy cần trường hợp cơ sở để dừng.
  • Mỗi lời gọi dựng một khung trên ngăn xếp chứa tham số và biến cục bộ, khung bị thu hồi khi hàm kết thúc; ngăn xếp hữu hạn nên đệ quy quá sâu sẽ tràn.
  • inline là gợi ý chép thân hàm vào chỗ gọi để bỏ chi phí gọi, chỉ hợp với hàm rất ngắn và trình biên dịch có quyền bỏ qua.
  • Đừng trả về tham chiếu tới biến cục bộ, và hạn chế biến toàn cục vì chúng phá vỡ ranh giới rõ ràng giữa hàm với nơi gọi.