Bài 10.4: Bài tập thực hành hàm nâng cao trong C++

Chào mừng các bạn quay trở lại với series bài viết về C++ của FullhouseDev! Sau khi đã cùng nhau tìm hiểu về các khái niệm cơ bản và nâng cao của hàm trong C++, hôm nay chúng ta sẽ đi sâu vào thực hành để củng cố kiến thức. Lý thuyết là quan trọng, nhưng chỉ khi bạn thực sự viết code, thử nghiệmgiải quyết vấn đề, kiến thức đó mới trở nên vững chắclinh hoạt.

Bài viết này sẽ tập trung vào các bài tập xoay quanh những khái niệm "nâng cao" về hàm mà chúng ta có thể đã lướt qua hoặc cần đào sâu hơn. Mục tiêu là giúp bạn làm quen và tự tin hơn khi sử dụng chúng trong các dự án thực tế. Hãy cùng bắt tay vào luyện tập nào!

1. Truyền tham chiếu (&) và con trỏ (*) vào hàm: Khi bạn cần thay đổi giá trị gốc

Chúng ta đã biết về truyền tham trị (pass by value), nơi hàm làm việc trên một bản sao của dữ liệu. Nhưng nếu bạn muốn hàm thực sự thay đổi giá trị của biến được truyền vào từ bên ngoài thì sao? Đây là lúc truyền tham chiếu và con trỏ phát huy tác dụng.

Bài tập 1: Hoán đổi giá trị hai biến

Viết một hàm để hoán đổi giá trị của hai số nguyên sử dụng: a) Truyền tham chiếu (&). b) Truyền con trỏ (*).

#include <iostream>

// a) Hoán đổi sử dụng tham chiếu
void swapByRef(int &a, int &b) {
    int temp = a;
    a = b;
    b = temp;
}

// b) Hoán đổi sử dụng con trỏ
void swapByPtr(int* a, int* b) {
    // Kiểm tra con trỏ null trước khi sử dụng
    if (a == nullptr || b == nullptr) {
        cerr << "Lỗi: Con trỏ null được truyền vào hàm swapByPtr." << endl;
        return;
    }
    int temp = *a; // Lấy giá trị mà con trỏ a trỏ tới
    *a = *b;       // Gán giá trị mà con trỏ b trỏ tới cho nơi con trỏ a trỏ tới
    *b = temp;     // Gán giá trị tạm cho nơi con trỏ b trỏ tới
}

int main() {
    int x = 10, y = 20;
    cout << "Trước khi hoán đổi (Ref): x = " << x << ", y = " << y << endl;
    swapByRef(x, y);
    cout << "Sau khi hoán đổi (Ref):  x = " << x << ", y = " << y << endl;

    int p = 5, q = 15;
    cout << "Trước khi hoán đổi (Ptr): p = " << p << ", q = " << q << endl;
    swapByPtr(&p, &q); // Truyền địa chỉ của p và q
    cout << "Sau khi hoán đổi (Ptr):  p = " << p << ", q = " << q << endl;

    return 0;
}

Giải thích code:

  • Hàm swapByRef(int &a, int &b) nhận hai đối số là tham chiếu đến int. Khi bạn truyền xy vào, a trở thành một bí danh (alias) cho x, và b là bí danh cho y. Mọi thay đổi trên a hoặc b đều ảnh hưởng trực tiếp đến xy gốc. Cú pháp & ở kiểu dữ liệu tham số báo hiệu đây là truyền tham chiếu.
  • Hàm swapByPtr(int* a, int* b) nhận hai đối số là con trỏ đến int. Khi bạn truyền &p&q (địa chỉ của pq), a lưu địa chỉ của p, và b lưu địa chỉ của q. Để truy cập hoặc thay đổi giá trị tại địa chỉ mà con trỏ trỏ tới, chúng ta sử dụng toán tử dereference * (ví dụ: *a). Thay đổi *a hoặc *b sẽ thay đổi giá trị tại địa chỉ đó, tức là thay đổi pq gốc.
  • Trong main, chúng ta thấy rõ sự khác biệt: khi gọi swapByRef, ta truyền trực tiếp tên biến (x, y); khi gọi swapByPtr, ta truyền địa chỉ của biến (&p, &q). Cả hai cách đều đạt được mục tiêu hoán đổi giá trị gốc. Truyền tham chiếu thường được ưa chuộng hơn trong C++ khi chỉ cần thay đổi giá trị, vì cú pháp của nó trực quan hơn.

2. Quá tải hàm (Function Overloading): Một tên, nhiều công dụng

Quá tải hàm cho phép bạn định nghĩa nhiều hàm có cùng tên nhưng khác nhau về danh sách tham số (số lượng tham số hoặc kiểu dữ liệu của tham số). Trình biên dịch sẽ dựa vào kiểu và số lượng đối số khi bạn gọi hàm để quyết định phiên bản hàm nào sẽ được thực thi.

Bài tập 2: Hiển thị thông tin đa dạng

Viết một tập hợp các hàm printInfo để hiển thị thông tin khác nhau: a) Hiển thị một số nguyên. b) Hiển thị một số thực (double). c) Hiển thị một chuỗi ký tự. d) Hiển thị một số nguyên và một chuỗi.

#include <iostream>
#include <string>

// a) Hiển thị số nguyên
void printInfo(int value) {
    cout << "Thông tin (int): " << value << endl;
}

// b) Hiển thị số thực
void printInfo(double value) {
    cout << "Thông tin (double): " << value << endl;
}

// c) Hiển thị chuỗi ký tự
void printInfo(const string& value) { // Sử dụng const & để tránh sao chép tốn kém
    cout << "Thông tin (string): " << value << endl;
}

// d) Hiển thị số nguyên và chuỗi
void printInfo(int number, const string& text) {
    cout << "Thông tin (int và string): Số = " << number << ", Chuỗi = " << text << endl;
}

int main() {
    printInfo(123);         // Gọi phiên bản a)
    printInfo(3.14159);     // Gọi phiên bản b)
    printInfo("Chào thế giới C++!"); // Gọi phiên bản c)
    printInfo(42, "Câu trả lời"); // Gọi phiên bản d)

    return 0;
}

Giải thích code:

  • Chúng ta có bốn hàm đều mang tên printInfo. Trình biên dịch phân biệt chúng dựa vào chữ ký hàm (function signature), tức là tên hàm kết hợp với danh sách kiểu dữ liệu của các tham số.
  • Khi gọi printInfo(123), đối số là kiểu int, nên trình biên dịch chọn void printInfo(int value).
  • Khi gọi printInfo(3.14159), đối số là kiểu double, nên void printInfo(double value) được gọi.
  • Khi gọi printInfo("..."), đối số là chuỗi ký tự (literals thường được coi là const char*, nhưng có thể chuyển đổi ngầm định hoặc phù hợp nhất với const string&), nên void printInfo(const string& value) được gọi.
  • Khi gọi printInfo(42, "..."), có hai đối số với kiểu intstring, nên void printInfo(int number, const string& text) được chọn.
  • Quá tải hàm giúp code của bạn dễ đọcdễ sử dụng hơn, cho phép bạn dùng một tên hàm có ý nghĩa cho các thao tác tương tự trên các loại dữ liệu khác nhau.

3. Tham số mặc định (Default Arguments): Sự linh hoạt trong gọi hàm

Tham số mặc định cho phép bạn cung cấp một giá trị mặc định cho một hoặc nhiều tham số của hàm. Nếu người gọi hàm không cung cấp giá trị cho tham số đó, giá trị mặc định sẽ được sử dụng. Điều này làm cho hàm linh hoạt hơn, cho phép nó được gọi với số lượng đối số ít hơn so với định nghĩa đầy đủ.

Lưu ý quan trọng: Các tham số mặc định phải được đặt ở cuối danh sách tham số trong định nghĩa hàm.

Bài tập 3: Hiển thị thông báo với tiền tố tùy chọn

Viết một hàm displayMessage để in ra một thông báo, có tùy chọn thêm một tiền tố (prefix) vào đầu thông báo. Tiền tố mặc định là "INFO: ".

#include <iostream>
#include <string>

// Tham số prefix có giá trị mặc định là "INFO: "
void displayMessage(const string& message, const string& prefix = "INFO: ") {
    cout << prefix << message << endl;
}

int main() {
    // Gọi hàm chỉ với thông báo (sử dụng tiền tố mặc định)
    displayMessage("Chương trình đang chạy bình thường.");

    // Gọi hàm với cả thông báo và tiền tố tùy chỉnh
    displayMessage("Đã phát hiện lỗi!", "ERROR: ");

    // Gọi hàm với tiền tố khác
    displayMessage("Tác vụ hoàn tất.", "SUCCESS: ");

    return 0;
}

Giải thích code:

  • Trong định nghĩa hàm displayMessage, tham số thứ hai prefix được gán giá trị "INFO: ". Điều này có nghĩa là nếu khi gọi hàm, bạn chỉ truyền một đối số (chuỗi thông báo), thì đối số thứ hai prefix sẽ tự động nhận giá trị "INFO: ".
  • Khi gọi displayMessage("Chương trình đang chạy bình thường."), chỉ có đối số message được cung cấp, nên prefix nhận giá trị mặc định và kết quả là "INFO: Chương trình đang chạy bình thường.".
  • Khi gọi displayMessage("Đã phát hiện lỗi!", "ERROR: "), cả hai đối số đều được cung cấp. Giá trị "ERROR: " được truyền vào sẽ ghi đè lên giá trị mặc định của prefix, và kết quả là "ERROR: Đã phát hiện lỗi!".
  • Tham số mặc định giúp giảm số lượng quá tải hàm cần thiết nếu các phiên bản hàm chỉ khác nhau ở việc sử dụng các giá trị cố định cho một số tham số nhất định.

4. Hàm nội tuyến (Inline Functions): Gợi ý tối ưu hóa

Từ khóa inline là một gợi ý (hint) cho trình biên dịch rằng nó nên cố gắng thay thế lời gọi hàm bằng thân hàm ngay tại chỗ gọi. Điều này có thể giúp giảm chi phí của việc gọi hàm (như tạo stack frame, lưu/phục hồi thanh ghi) cho các hàm rất nhỏ được gọi thường xuyên. Tuy nhiên, việc trình biên dịch có thực sự "nội tuyến hóa" hàm hay không hoàn toàn phụ thuộc vào nó.

Bài tập 4: Sử dụng inline cho hàm tính bình phương đơn giản

Viết một hàm tính bình phương của một số nguyên và đánh dấu nó là inline.

#include <iostream>

// Gợi ý trình biên dịch nội tuyến hóa hàm này
inline int square(int x) {
    return x * x;
}

int main() {
    int num = 5;
    int result = square(num); // Tại đây, trình biên dịch CÓ THỂ thay thế bằng: int result = num * num;

    cout << "Bình phương của " << num << " là " << result << endl;

    // Hàm inline vẫn có thể được gọi như hàm thông thường
    cout << "Bình phương của 10 là " << square(10) << endl;

    return 0;
}

Giải thích code:

  • Từ khóa inline đặt trước định nghĩa hàm square là một yêu cầu từ lập trình viên tới trình biên dịch. Yêu cầu này là: "Nếu có thể và thấy hợp lý về mặt hiệu năng, hãy chèn mã của hàm square trực tiếp vào nơi nó được gọi, thay vì thực hiện một lời gọi hàm thông thường."
  • Trong ví dụ int result = square(num);, trình biên dịch có thể (không đảm buộc) sẽ biên dịch nó thành một cái gì đó tương tự như int result = num * num;.
  • Lưu ý rằng việc sử dụng inline không phải lúc nào cũng tốt. Nếu hàm quá lớn, việc nội tuyến hóa có thể làm tăng kích thước mã chương trình, dẫn đến việc sử dụng bộ nhớ cache kém hiệu quả hơn, đôi khi lại làm chậm chương trình. inline phù hợp nhất cho các hàm nhỏ gọn, chỉ thực hiện vài phép tính đơn giản.

5. Con trỏ hàm (Function Pointers), function và Lambda Expressions: Truyền hành vi như dữ liệu

Đây là những kỹ thuật mạnh mẽ cho phép bạn coi hàm (hoặc các đối tượng có thể gọi được) như dữ liệu. Bạn có thể truyền chúng làm đối số cho hàm khác, lưu trữ chúng trong biến, hoặc đưa chúng vào cấu trúc dữ liệu. Điều này mở ra cánh cửa cho các kỹ thuật lập trình như callback, strategy pattern, và lập trình hàm (functional programming) ở mức độ cơ bản trong C++.

  • Con trỏ hàm: Kiểu dữ liệu của con trỏ trỏ đến một hàm cụ thể với một chữ ký nhất định. Cú pháp hơi lằng nhằng.
  • function: Một wrapper tổng quát hơn (có trong <functional>) có thể chứa con trỏ hàm, đối tượng hàm (functors), và cả lambda expressions. Cú pháp sạch sẽ hơn.
  • Lambda Expressions: (Từ C++11) Một cách để tạo ra một "hàm ẩn danh" (inline function object) ngay tại chỗ bạn cần nó, thường ngắn gọn và tiện lợi.
Bài tập 5: Triển khai một hàm thực hiện thao tác dựa trên đối số hàm

Viết một hàm applyOperation nhận hai số nguyên và một hàm/đối tượng có thể gọi được làm đối số. Hàm này sẽ áp dụng thao tác được truyền vào lên hai số nguyên. Sử dụng function và Lambda Expressions để minh họa.

#include <iostream>
#include <functional> // Cần cho function

// Hàm nhận 2 số và một đối tượng có thể gọi được (hàm, lambda, functor)
// Đối tượng có thể gọi được phải nhận 2 int và trả về int
int applyOperation(int a, int b, function<int(int, int)> operation) {
    return operation(a, b);
}

int main() {
    // Sử dụng Lambda Expression để định nghĩa phép cộng
    auto add = [](int x, int y) {
        return x + y;
    };

    // Sử dụng Lambda Expression để định nghĩa phép nhân
    auto multiply = [](int x, int y) -> int { // Có thể bỏ -> int, trình biên dịch suy luận được
        return x * y;
    };

    // Gọi applyOperation với lambda 'add'
    int sum_result = applyOperation(10, 5, add);
    cout << "Kết quả cộng (10 + 5): " << sum_result << endl;

    // Gọi applyOperation với lambda 'multiply'
    int prod_result = applyOperation(10, 5, multiply);
    cout << "Kết quả nhân (10 * 5): " << prod_result << endl;

    // Gọi applyOperation trực tiếp với một lambda tạm thời
    int diff_result = applyOperation(20, 7, [](int x, int y){ return x - y; });
    cout << "Kết quả trừ (20 - 7): " << diff_result << endl;

    return 0;
}

Giải thích code:

  • Hàm applyOperation có tham số thứ ba là function<int(int, int)>. Điều này có nghĩa là tham số này có thể nhận bất kỳ thứ gì có thể được gọi như một hàm nhận hai đối số int và trả về một int. function là một trình bao bọc linh hoạt và hiện đại để làm việc với các thực thể có thể gọi được.
  • auto add = [](int x, int y) { return x + y; }; là một lambda expression.
    • []: Phần capture list. Ở đây trống, nghĩa là lambda không "bắt" bất kỳ biến nào từ môi trường xung quanh.
    • (int x, int y): Danh sách tham số của lambda, giống như tham số của một hàm thông thường.
    • { return x + y; }: Thân hàm của lambda, chứa mã sẽ được thực thi.
  • auto multiply = [](int x, int y) -> int { return x * y; }; tương tự, nhưng có thêm -> int để chỉ định rõ kiểu trả về (tùy chọn nếu trình biên dịch có thể suy luận).
  • Trong main, chúng ta tạo ra các lambda addmultiply (có kiểu dữ liệu được suy luận là một loại closure type duy nhất cho mỗi lambda, mà function có thể chứa được).
  • Chúng ta gọi applyOperation và truyền các lambda này vào. applyOperation sau đó gọi operation(a, b), thực chất là đang gọi thân hàm của lambda được truyền vào.
  • Ví dụ cuối cùng cho thấy bạn có thể định nghĩa và sử dụng lambda trực tiếp tại điểm gọi hàm mà không cần gán nó vào một biến ([](int x, int y){ return x - y; }).
  • Việc sử dụng function và lambda là một kỹ thuật cực kỳ phổ biến trong C++ hiện đại, cho phép bạn viết code linh hoạt và mang tính biểu cảm cao hơn, đặc biệt khi làm việc với các thuật toán cần truyền vào các "hành động" tùy chỉnh (ví dụ: thuật toán sắp xếp với tiêu chí so sánh tùy chỉnh, xử lý sự kiện, v.v.).

Comments

There are no comments at the moment.