Basic·Bài B4

Functions

Đến bài này, mọi chương trình đều nằm gọn trong main. Cách đó ổn cho tới khi cùng một việc phải làm nhiều lần. Ví dụ đếm số cách chọn kk bạn từ nn bạn: toán học cho sẵn công thức n!k!⋅(n−k)!\dfrac{n!}{k! \cdot (n-k)!}, trong đó n!n! (đọc là “nn giai thừa”) là tích 1⋅2⋅3⋯n1 \cdot 2 \cdot 3 \cdots n. Công thức cần ba giai thừa, tức ba vòng lặp gần như giống hệt nhau. Chép ba lần thì code dài gấp ba, và sửa lỗi ở một bản rất dễ quên hai bản còn lại. Hàm (function) tránh chuyện đó: ta viết đoạn code một lần, đặt cho nó một cái tên, rồi gọi tên ấy bao nhiêu lần tùy ý, mỗi lần với dữ liệu khác.

Các bài tập của bài này đều yêu cầu viết phần xử lý trong hàm riêng, đúng tên đề cho, và gọi từ main; máy chấm chỉ so kết quả, còn việc tổ chức hàm do giáo viên đọc code.

Định nghĩa và gọi hàm

Muốn in số cách chọn kk bạn từ nn bạn với n≤20n \le 20, ta tính n!n!, k!k! và (n−k)!(n-k)!. Viết thẳng trong main sẽ thành ba vòng lặp:

C++
long long f1 = 1;
for (int i = 2; i <= n; i++) f1 *= i;
long long f2 = 1;
for (int i = 2; i <= k; i++) f2 *= i;
long long f3 = 1;
for (int i = 2; i <= n - k; i++) f3 *= i;
cout << f1 / (f2 * f3) << '\n';

Ba đoạn khác nhau đúng một chỗ: con số đứng sau i <=. Chỗ khác đó là dữ liệu đưa vào, phần còn lại là việc chung. Hàm tách hai thứ ấy ra: việc chung nằm một lần trong thân hàm, dữ liệu đưa vào là tham số.

C++
#include <bits/stdc++.h>
using namespace std;

long long fact(int m) {
    long long res = 1;
    for (int i = 2; i <= m; i++) res *= i;
    return res;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n, k;
    cin >> n >> k;
    cout << fact(n) / (fact(k) * fact(n - k)) << '\n';
}
5 2

Với n=5n = 5, k=2k = 2 chương trình in 120/(2⋅6)=10120 / (2 \cdot 6) = 10: có 10 cách chọn 2 bạn từ 5 bạn, đúng như khi liệt kê tay. Muốn đổi cách tính giai thừa, ta chỉ sửa một chỗ.

Dòng đầu của hàm fact gồm ba phần, đọc từ trái sang phải:

  • long long là kiểu trả về (return type): loại giá trị hàm gửi lại cho nơi gọi.
  • fact là tên hàm. Đặt tên theo việc hàm làm để đọc lời gọi là hiểu.
  • (int m) là danh sách tham số (parameter): những biến nhận dữ liệu vào. Mỗi tham số có kiểu riêng, cách nhau bằng dấu phẩy, như int add(int a, int b). Viết int add(int a, b) là lỗi vì b thiếu kiểu.

Phần trong { } là thân hàm. Lệnh return res; gửi giá trị của res về nơi gọi và kết thúc hàm. main cũng là một hàm: hệ thống gọi nó đầu tiên, và nếu thân main không có return thì C++ tự coi như return 0;.

Lời gọi hàm là tên hàm kèm cặp ngoặc, trong ngoặc là các đối số (argument). Trong fact(n - k), đối số là n - k. Tham số là biến nằm trong định nghĩa, đối số là giá trị đưa vào lúc gọi. Gặp một lời gọi, chương trình làm bốn việc theo thứ tự:

  1. Tính giá trị các đối số. Với n=5n = 5, k=2k = 2, đối số n - k bằng 3.
  2. Tạo tham số m và gán giá trị vừa tính cho nó, ở đây m = 3.
  3. Chạy thân hàm cho tới khi gặp return.
  4. Thay cả lời gọi bằng giá trị trả về, rồi chạy tiếp từ chỗ vừa gọi.

Nhờ bước 4, fact(k) * fact(n - k) đọc đúng như tích của hai số: lời gọi hàm đặt được ở bất cứ chỗ nào đặt được một giá trị.

Mỗi lời gọi fact mở một khung biến mới bên phải; return đóng khung và giá trị thay chỗ cho lời gọi.

Hàm trả giá trị về chứ không tự in nó. Việc in thuộc về nơi gọi, như cout << fact(5). Đặt cout trong hàm thì hàm in ngay lúc chạy, kể cả khi người gọi chỉ muốn lấy giá trị để cộng hay so sánh. Một lỗi cú pháp hay gặp: gõ ; ngay sau ) ở dòng đầu của định nghĩa; trình biên dịch hiểu đó là lời khai báo, nên cặp { } bên dưới không thuộc hàm nào và nó báo lỗi ở dấu {.

Hỏi nhanh

Cho hàm int f(int x) { cout << x; return x * 2; }. Dòng cout << f(3); trong main in ra gì?

Ví dụGọi hàm hai lần

Đề. Viết hàm square nhận xx và trả về x2x^2, rồi gọi nó hai lần để in a2+b2a^2 + b^2 (−1000≤a,b≤1000-1000 \le a, b \le 1000).

Lời giải. Hàm có một tham số và một lệnh return x * x;. Trong main, hai lời gọi square(a) và square(b) đứng ngay trong biểu thức cộng, như hai con số. aa âm không cần xử lý gì: bình phương luôn không âm. Giá trị lớn nhất là 2⋅1062 \cdot 10^6, vừa int.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int a, b;
    cin >> a >> b;
    cout << square(a) + square(b) << '\n';
}
3 4

Giá trị trả về và return

Một lệnh return chạy là hàm kết thúc ngay, mọi lệnh phía sau bị bỏ qua. Điều này tạo ra một mẫu rất hay dùng. Với câu hỏi dạng “có tồn tại... không?”, ta trả lời “có” ngay lúc gặp một trường hợp thỏa, còn “không” chỉ được trả lời sau khi đã xét hết. Ví dụ: nn có phải số chính phương không? Thử i=0,1,2,…i = 0, 1, 2, \dots chừng nào i2≤ni^2 \le n; gặp i2=ni^2 = n thì trả true luôn, thử hết mà không thấy thì trả false.

C++
#include <bits/stdc++.h>
using namespace std;

bool isSquare(int n) {
    for (int i = 0; i * i <= n; i++) {
        if (i * i == n) return true;
    }
    return false;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    cin >> n;
    if (isSquare(n)) cout << "YES\n";
    else cout << "NO\n";
}
49

Hàm bool trả true hoặc false; tên thường bắt đầu bằng is để đọc như một câu hỏi. Với n≤109n \le 10^9, i * i tối đa chừng 1,0⋅1091{,}0 \cdot 10^9 nên vẫn vừa int.

Hỏi nhanh

Một bạn viết isSquare với vòng for (int i = 0; i * i <= n; i++). Trong thân vòng lặp bạn ấy viết if (i * i == n) return true; else return false;, sau vòng lặp có thêm return false;. Với n = 49 hàm trả về gì?

Hàm bool có thể trả thẳng một phép so sánh, như return n % 2 != 0; cho câu hỏi “nn lẻ không”. Nhớ -3 % 2 bằng -1, nên viết n % 2 == 1 thì mọi số lẻ âm bị coi là chẵn. cout << isOdd(n) in ra 1 hoặc 0, không in true hay false.

Kiểu trả về quyết định kết quả. Phép chia hai số int là chia nguyên, nên double half(int x) { return x / 2; } vẫn cho half(7) bằng 3.

Hỏi nhanh

Với double half(int x) { return x / 2; }, dòng cout << half(7); in ra gì?

Kiểu trả về cũng phải đủ rộng cho kết quả. fact ở đầu bài trả long long vì 13!=622702080013! = 6227020800 đã vượt int; khai báo int fact(int m) thì từ m=13m = 13 kết quả sai. Tham số cũng vậy: truyền một số long long lớn vào tham số int thì giá trị bị cắt.

Cuối cùng, hàm không phải void phải gặp return trên mọi đường đi. Quên một đường thì hàm trả về một giá trị không ai đoán trước được: máy bạn có thể chạy đúng còn máy chấm thì không. Hàm dưới đủ ba đường, kể cả n=0n = 0:

C++
int sign(int n) {
    if (n > 0) return 1;
    if (n < 0) return -1;
    return 0;
}

Ví dụHàm kiểm tra nguyên tố

Đề. Số nguyên tố là số nguyên lớn hơn 1 có đúng hai ước dương. Viết hàm isPrime nhận nn (1≤n≤10001 \le n \le 1000); in YES nếu nn nguyên tố, ngược lại in NO.

Lời giải. Đúng mẫu “có tồn tại không”, nhưng hỏi ngược: nn không nguyên tố khi có một ước dd với 2≤d<n2 \le d < n. Trước hết loại n<2n < 2 bằng return false, vì test ví dụ chính là n=1n = 1. Rồi thử từng dd; gặp ước thì return false ngay, hết vòng mới return true. Nhánh else return true đặt trong vòng là lỗi của câu hỏi nhanh trên: với n=9n = 9 hàm sẽ trả true ngay ở d=2d = 2. Với n≤1000n \le 1000, vòng chạy tối đa 998 lượt.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;

bool isPrime(int n) {
    if (n < 2) return false;
    for (int d = 2; d < n; d++) {
        if (n % d == 0) return false;
    }
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    cin >> n;
    if (isPrime(n)) cout << "YES\n";
    else cout << "NO\n";
}
1

Hàm void: làm việc mà không trả giá trị

Có hàm chỉ làm một thao tác, như in một hàng ký tự, mà không có kết quả nào để trả. Hàm đó dùng kiểu trả về void (“không có gì”) và không cần return; muốn thoát sớm thì viết return; không kèm giá trị. Chương trình dưới in tam giác dấu # có nn hàng; hàm printRow in nn ký tự cc rồi xuống dòng.

C++
#include <bits/stdc++.h>
using namespace std;

void printRow(int n, char c) {
    for (int i = 0; i < n; i++) cout << c;
    cout << '\n';
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) printRow(i, '#');
}
4

Hàm có hai tham số khác kiểu, int và char. Lời gọi ghép đối số với tham số theo thứ tự, không theo tên. Đảo thành printRow('#', 3) vẫn biên dịch, vì C++ tự đổi qua lại giữa số và ký tự (mã của # là 35), nhưng chương trình in 35 ký tự vô nghĩa thay vì ba dấu #.

Hàm void được gọi như một câu lệnh. Viết int x = printRow(3, '#'); là lỗi vì không có giá trị nào để gán. Còn hàm tính ra kết quả thì nên trả kết quả về để main quyết định dùng nó thế nào: hàm add tự in tổng sẽ không dùng lại được khi cần (a+b)⋅c(a + b) \cdot c.

Ví dụHàm in dòng sao

Đề. Viết hàm printStars kiểu void, nhận nn (1≤n≤1001 \le n \le 100) và in một dòng gồm nn ký tự * liền nhau. Đọc nn và gọi hàm.

Lời giải. Không có giá trị nào để trả: việc in chính là mục đích, nên hàm là void. Thân hàm có vòng for in đúng nn dấu *, rồi xuống dòng một lần, sau vòng. Đặt '\n' trong vòng thì mỗi dấu * nằm một dòng. main chỉ đọc và gọi, không in gì thêm.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;

void printStars(int n) {
    for (int i = 0; i < n; i++) cout << '*';
    cout << '\n';
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    cin >> n;
    printStars(n);
}
4

Biến cục bộ và truyền theo giá trị

Biến khai báo trong hàm, kể cả tham số, là biến cục bộ (local variable): chỉ dùng được trong hàm đó và biến mất khi hàm kết thúc. Hai hàm cùng đặt tên n vẫn là hai biến khác nhau, nên ta viết từng hàm mà không cần biết các hàm khác dùng tên gì. Biến cục bộ cũng không tự bằng 0: int sum; rồi sum += x cộng vào một giá trị rác, nên luôn viết int sum = 0;.

Khi gọi hàm, tham số được khởi tạo bằng bản sao giá trị của đối số. Cách này gọi là truyền theo giá trị (pass by value). Hàm thoải mái sửa tham số của mình, nhưng biến ở nơi gọi không bị đụng tới.

C++
#include <bits/stdc++.h>
using namespace std;

void addOne(int x) {
    x = x + 1;
    cout << "x = " << x << '\n';
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int a = 5;
    addOne(a);
    cout << "a = " << a << '\n';
}
x nằm trong khung riêng của addOne, chỉ là bản sao của a: sửa x không đụng tới a.

Hỏi nhanh

Cho void reset(int n) { n = 0; }. Trong main có int n = 42; reset(n); cout << n;. Chương trình in ra gì?

Muốn hàm sửa được biến của nơi gọi, thêm & sau kiểu tham số. Tham số khi đó không phải bản sao mà là cái tên thứ hai của chính biến được truyền vào, gọi là tham chiếu (reference). Ví dụ hoán đổi hai số:

C++
#include <bits/stdc++.h>
using namespace std;

void doiCho(int &a, int &b) {
    int t = a;
    a = b;
    b = t;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int x, y;
    cin >> x >> y;
    doiCho(x, y);
    cout << x << ' ' << y << '\n';
}
3 8

Đối số của tham số & phải là một biến: doiCho(3, 8) báo lỗi vì không có biến nào để sửa. Thư viện đã có sẵn swap(a, b) làm đúng việc này, nên ví dụ trên đặt tên khác để khỏi lẫn. Chỉ dùng & khi hàm thật sự phải sửa biến gốc.

Ví dụGiữ số ban đầu

Đề. Viết hàm digitSum nhận nn (0≤n≤1090 \le n \le 10^9) và trả về tổng các chữ số của nn. In nn ban đầu rồi tổng chữ số trên một dòng; nn trong main phải giữ nguyên sau khi gọi hàm.

Lời giải. Thân hàm là vòng bóc chữ số của B3: cộng n % 10 vào sum, rồi n /= 10, tới khi n=0n = 0. Vòng này phá hủy nn, nhưng nn trong hàm chỉ là bản sao: tham số không có & nên n ở main còn nguyên để in. Chính vì vậy tham số không được khai báo int &n; có & thì main sẽ in 0 6. Với n=0n = 0 vòng không chạy và hàm trả 0.

n của digitSum và n của main là hai ô khác nhau: một ô về 0, ô kia giữ nguyên.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;

int digitSum(int n) {
    int sum = 0;
    while (n > 0) {
        sum += n % 10;
        n /= 10;
    }
    return sum;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    cin >> n;
    cout << n << ' ' << digitSum(n) << '\n';
}
123

Thứ tự hàm và ghép các hàm nhỏ

Trình biên dịch đọc file từ trên xuống. Gặp lời gọi cube(3) trong main mà chưa từng thấy cube, nó báo 'cube' was not declared in this scope. Cách chữa thông dụng nhất là đặt định nghĩa hàm lên trên chỗ gọi: hàm A gọi hàm B thì B nằm trên A, và main nằm cuối file. Cách thứ hai là khai báo trước bằng nguyên mẫu hàm (function prototype): dòng đầu của hàm kết thúc bằng ;, như int cube(int x);, đặt trên main; thân hàm viết ở đâu phía sau cũng được. Khai báo mà không bao giờ định nghĩa thì chương trình vẫn biên dịch, nhưng lỗi ở bước liên kết (undefined reference) khi có lời gọi. Với bài ngắn, đặt hàm lên trên main là đủ.

Bài toán: cho tháng và năm, in số ngày của tháng đó. Ta chia thành hai việc. isLeap(y) trả lời năm có nhuận không, đúng điều kiện của B2. days(m, y) dựa vào đó để trả số ngày.

C++
#include <bits/stdc++.h>
using namespace std;

bool isLeap(int y) {
    return y % 400 == 0 || (y % 4 == 0 && y % 100 != 0);
}

int days(int m, int y) {
    if (m == 2) {
        if (isLeap(y)) return 29;
        return 28;
    }
    if (m == 4 || m == 6 || m == 9 || m == 11) return 30;
    return 31;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int m, y;
    cin >> m >> y;
    cout << days(m, y) << '\n';
}
2 2024

days gọi isLeap, nên isLeap nằm phía trên. Mỗi hàm làm đúng một việc và tên hàm nói đúng việc đó; hàm tính toán không đọc input và không in, main lo đọc, gọi và in. Khi bài dài ra, hãy thử từng hàm riêng trên vài giá trị biên (isLeap(1900), isLeap(2000), days(2, 2023)) trước khi ghép, để khi sai ta biết sai ở hàm nào.

Ví dụHàm kiểm tra đối xứng

Đề. Số đối xứng là số đọc xuôi và đọc ngược như nhau, như 1221 hay 7. Viết hàm reverseNumber đảo các chữ số của nn, rồi dùng nó trong hàm isPalindrome. Cho nn (0≤n≤1090 \le n \le 10^9), in YES nếu nn đối xứng, ngược lại in NO.

Lời giải. nn đối xứng khi và chỉ khi đảo các chữ số của nn lại được chính nn. Việc đảo dùng cách bóc chữ số của ví dụ trước, nhưng chữ số lấy ra không cộng dồn mà được đẩy vào cuối số mới: r = r * 10 + n % 10. Nhân r với 10 dịch các chữ số đã có sang trái một chỗ, rồi chữ số mới chiếm hàng đơn vị. isPalindrome chỉ còn một dòng return reverseNumber(n) == n;, và phải nằm dưới reverseNumber.

r lớn dần từng chữ số; ba khung biến là ba hàm đang chờ nhau. Thử n = 1231.

Số tận cùng bằng 0 không làm hỏng cách này: 3050 đảo thành 503 vì các số 0 đứng đầu tự mất, 503 khác 3050 nên ra NO, đúng vì chữ số đầu 3 khác chữ số cuối 0. Còn n=0n = 0 đảo ra 0, ra YES. Số đảo của một số 10 chữ số có thể vượt int (1999999999 đảo thành 9999999991), nên dùng long long cho chắc.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;

long long reverseNumber(long long n) {
    long long r = 0;
    while (n > 0) {
        r = r * 10 + n % 10;
        n /= 10;
    }
    return r;
}

bool isPalindrome(long long n) {
    return reverseNumber(n) == n;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    long long n;
    cin >> n;
    if (isPalindrome(n)) cout << "YES\n";
    else cout << "NO\n";
}
1221

Khi đi thi

  • Thấy cùng một đoạn code xuất hiện từ hai lần (tính giai thừa, kiểm tra nguyên tố, đảo số) là tách thành hàm ngay; hàm tính thì return, còn main đọc và in.
  • Chọn kiểu trả về và kiểu tham số đủ rộng cho cả số trung gian (giai thừa, lũy thừa, số đảo đều nên là long long), rồi thử hàm trên giá trị biên 0, 1 và số âm.
  • Hàm nhận bản sao nên cứ sửa tham số thoải mái, còn muốn sửa biến ngoài thì thêm &; mọi đường đi của hàm không void đều phải kết thúc bằng return, và hàm phải nằm trên chỗ gọi nó.

Tóm tắt

  • Hàm gồm kiểu trả về, tên, danh sách tham số và thân; return gửi một giá trị về nơi gọi và kết thúc hàm ngay.
  • Câu hỏi “có tồn tại không”: return true ngay khi gặp, return false một lần sau vòng lặp.
  • Hàm tính ra kết quả thì trả về, không tự in; hàm void dành cho việc mà bản thân thao tác là mục đích, như in.
  • Tham số là bản sao của đối số: sửa tham số không ảnh hưởng biến ở nơi gọi, trừ khi tham số có &.
  • Hàm phải được định nghĩa hoặc khai báo trước chỗ gọi; hàm gọi hàm thì hàm được gọi nằm phía trên.

Bài tập

1
Hàm bình phươngcanucbasic4l01 · Basic 4 · HọcMột tham số, một lệnh return x * x;; in kết quả ở main, không in trong hàm.
LQDOJ
2
Hàm tính tổngcanucbasic4l02 · Basic 4 · HọcHai tham số int a, int b; hàm trả về a + b.
LQDOJ
3
Hàm chu vicanucbasic4e01 · Basic 4 · LuyệnChu vi 2(a+b)2(a + b) trả về từ hàm perimeter; main chỉ đọc và in.
LQDOJ
4
Hàm chọn số lớncanucbasic4l03 · Basic 4 · HọcSo sánh bằng if rồi trả về số lớn hơn; hai số bằng nhau thì trả số nào cũng được.
LQDOJ
5
Hàm kiểm tra chẵncanucbasic4l04 · Basic 4 · HọcHàm bool; dùng n % 2 == 0, số âm chẵn vẫn có dư 0.
LQDOJ
6
Hai dòng saocanucbasic4e10 · Basic 4 · LuyệnGọi hàm in dòng sao hai lần, lần đầu với aa, lần sau với bb.
LQDOJ
7
Hàm tổng từ mộtcanucbasic4l07 · Basic 4 · HọcVòng for cộng dồn vào biến khởi tạo bằng 0; n=0n = 0 thì vòng lặp không chạy mà kết quả vẫn đúng.
LQDOJ
8
Hàm khoảng cáchcanucbasic4e03 · Basic 4 · LuyệnKhoảng cách là số lớn trừ số nhỏ; dùng if để kết quả không âm.
LQDOJ
9
Hàm nhỏ nhất trong bacanucbasic4e02 · Basic 4 · LuyệnViết hàm tìm số nhỏ hơn trong hai số, rồi dùng nó hai lần cho ba số.
LQDOJ
10
Ghép hai hàmcanucbasic4l10 · Basic 4 · HọcKết quả của một lời gọi hàm dùng được ngay làm đối số của lời gọi khác: larger(square(a), square(b)).
LQDOJ
11
Hàm lũy thừacanucbasic4e04 · Basic 4 · LuyệnKhởi tạo kết quả bằng 1 rồi nhân với aa đúng bb lần; b=0b = 0 cho kết quả 1.
LQDOJ
12
Hàm tổng chữ sốcanucbasic4l08 · Basic 4 · HọcHàm digitSum như ví dụ Giữ số ban đầu, lần này chỉ in tổng.
LQDOJ
13
Tổng chữ số của hai sốcanucbasic4e09 · Basic 4 · LuyệnGọi digitSum cho aa, gọi tiếp cho bb, rồi cộng hai kết quả.
LQDOJ
14
Hàm đếm ướccanucbasic4e05 · Basic 4 · LuyệnDuyệt ii từ 1 tới nn và đếm các ii thỏa n % i == 0.
LQDOJ

Đọc thêm

  • Introduction to function parameters and argumentslearncpp.comTham số, đối số và truyền theo giá trị, giải thích từng bước khi hàm được gọi.
  • Forward declarationslearncpp.comVì sao trình biên dịch đọc từ trên xuống, nguyên mẫu hàm và lỗi liên kết khi chỉ khai báo mà không định nghĩa.
  • Functionscplusplus.comHàm void, tham số giá trị và tham chiếu, giá trị mặc định, mỗi ý có ví dụ chạy được.
  • 函数OI WikiBản tóm gọn của cộng đồng Tin học Trung Quốc: khai báo, return, giá trị và tham chiếu trong một trang ngắn.
  • Hàm trong C++VibloBài tiếng Việt về chia chương trình thành hàm, biến cục bộ và toàn cục; đọc phần đầu là đủ cho bài này.