Bài 32.4: Kết hợp Struct với con trỏ trong C++

Chào mừng bạn quay trở lại với chuỗi bài viết về C++! Chúng ta đã cùng nhau khám phá struct để nhóm các dữ liệu liên quan lại với nhau và tìm hiểu về con trỏ để làm việc trực tiếp với địa chỉ bộ nhớ. Hôm nay, chúng ta sẽ kết hợp hai khái niệm mạnh mẽ này để mở ra những cánh cửa mới trong lập trình C++.

Kết hợp structcon trỏ không chỉ giúp chúng ta thao tác với dữ liệu hiệu quả hơn, đặc biệt là với các cấu trúc dữ liệu lớn, mà còn là nền tảng cho việc sử dụng bộ nhớ động và xây dựng các cấu trúc dữ liệu phức tạp như danh sách liên kết, cây nhị phân, v.v.

Hãy cùng đi sâu tìm hiểu!

Nhắc lại nhanh: Struct và Con trỏ

Trước khi kết hợp, hãy ôn lại một chút về từng thành phần:

  • Struct (Cấu trúc): Là một kiểu dữ liệu do người dùng định nghĩa, cho phép nhóm các biến có kiểu dữ liệu khác nhau (hoặc giống nhau) lại thành một đơn vị duy nhất.

    struct Nguoi {
        string ten;
        int tuoi;
        double chieuCao;
    };
    
    // Khai báo và sử dụng struct
    Nguoi sinhVien;
    sinhVien.ten = "Nguyen Van A";
    sinhVien.tuoi = 20;
    cout << "Ten: " << sinhVien.ten << endl;
    

    Chúng ta truy cập các thành viên của struct bằng toán tử dấu chấm (.).

  • Con trỏ (Pointer): Là một biến lưu trữ địa chỉ của một biến khác trong bộ nhớ.

    int diem = 10;
    int* conTroDiem = &diem; // conTroDiem lưu địa chỉ của bien 'diem'
    
    cout << "Gia tri cua diem: " << diem << endl;
    cout << "Dia chi cua diem: " << &diem << endl;
    cout << "Dia chi luu trong conTroDiem: " << conTroDiem << endl;
    cout << "Gia tri tai dia chi ma conTroDiem tro toi: " << *conTroDiem << endl; // Toán tử * (dereference)
    

    Chúng ta dùng toán tử * (toán tử dereference) để truy cập giá trị tại địa chỉ mà con trỏ đang trỏ tới, và toán tử & (toán tử address-of) để lấy địa chỉ của một biến.

Con trỏ trỏ đến Struct

Bây giờ, làm thế nào để một con trỏ có thể trỏ đến một biến kiểu struct? Rất đơn giản, bạn chỉ cần khai báo con trỏ với kiểu dữ liệu là tên của struct, theo sau là dấu *.

Ví dụ, với struct Nguoi ở trên, bạn có thể khai báo con trỏ trỏ tới Nguoi như sau:

Nguoi* conTroNguoi;

Con trỏ này bây giờ có thể lưu trữ địa chỉ của một biến kiểu Nguoi.

Trỏ đến Struct trên Stack

Nếu bạn đã khai báo một biến struct thông thường (trên stack), bạn có thể làm cho con trỏ trỏ tới nó bằng cách sử dụng toán tử &:

#include <iostream>
#include <string>

struct Nguoi {
    string ten;
    int tuoi;
};

int main() {
    Nguoi nguoi1; // Khai báo struct tren stack
    nguoi1.ten = "Tran Thi B";
    nguoi1.tuoi = 25;

    Nguoi* conTroNguoi1 = &nguoi1; // Con tro tro toi nguoi1

    cout << "Thong tin nguoi 1 (truy cap truc tiep): " << nguoi1.ten << ", " << nguoi1.tuoi << endl;
    cout << "Dia chi cua nguoi 1: " << &nguoi1 << endl;
    cout << "Dia chi luu trong conTroNguoi1: " << conTroNguoi1 << endl;

    return 0;
}

Trong ví dụ này, conTroNguoi1 đang giữ địa chỉ của biến nguoi1.

Truy cập thành viên của Struct qua Con trỏ

Đây là phần quan trọng! Khi bạn có một con trỏ trỏ đến một struct, làm thế nào để truy cập các thành viên (ten, tuoi,...) của struct đó?

Cách đầu tiên là sử dụng toán tử * (dereference) để lấy chính biến struct mà con trỏ đang trỏ tới, sau đó sử dụng toán tử . để truy cập thành viên:

// Tiep tuc tu vi du tren
cout << "Truy cap qua con tro (*conTroNguoi1).ten: " << (*conTroNguoi1).ten << endl;
cout << "Truy cap qua con tro (*conTroNguoi1).tuoi: " << (*conTroNguoi1).tuoi << endl;

Lưu ý: Dấu ngoặc đơn ()bắt buộc vì toán tử . có độ ưu tiên cao hơn toán tử *. Nếu không có ngoặc, biểu thức *conTroNguoi1.ten sẽ được hiểu là *(conTroNguoi1.ten), điều này là không hợp lệconTroNguoi1.ten không phải là con trỏ.

Tuy nhiên, cách này hơi dài dòng và khó đọc. May mắn thay, C++ cung cấp một toán tử rất tiện lợi cho mục đích này: toán tử mũi tên ->.

Toán tử -> là viết tắt của việc "lấy giá trị tại địa chỉ mà con trỏ trỏ tới và sau đó truy cập thành viên". Nói cách khác, conTroNguoi1->tentương đương với (*conTroNguoi1).ten.

// Su dung toan tu mui ten ->
cout << "Truy cap qua con tro conTroNguoi1->ten: " << conTroNguoi1->ten << endl;
cout << "Truy cap qua con tro conTroNguoi1->tuoi: " << conTroNguoi1->tuoi << endl;

// Ban co the thay doi gia tri thanh vien qua con tro
conTroNguoi1->tuoi = 26;
cout << "Tuoi sau khi cap nhat qua con tro: " << nguoi1.tuoi << endl; // Gia tri cua nguoi1 thay doi

Toán tử -> được sử dụng rất phổ biến khi làm việc với con trỏ trỏ đến struct (hoặc class), vì nó giúp code ngắn gọndễ đọc hơn nhiều.

Kết hợp Struct với Con trỏ và Bộ nhớ động (Heap)

Một trong những ứng dụng quan trọng nhất của việc kết hợp structcon trỏ là làm việc với bộ nhớ động (heap). Thay vì khai báo struct trên stack với kích thước và vòng đời cố định trong một scope, bạn có thể cấp phát struct trên heap sử dụng toán tử new.

Khi bạn cấp phát bộ nhớ cho một struct bằng new, toán tử new sẽ trả về một con trỏ trỏ đến vùng bộ nhớ đã cấp phát đó.

#include <iostream>
#include <string>

struct Sach {
    string tieuDe;
    int namXuatBan;
};

int main() {
    // Cap phat dong mot doi tuong Sach tren heap
    Sach* conTroSach = new Sach;

    // Kiem tra xem viec cap phat co thanh cong khong
    if (conTroSach == nullptr) {
        cerr << "Loi: Khong du bo nho de cap phat Sach!" << endl;
        return 1; // Thoat voi ma loi
    }

    // Truy cap va gan gia tri cho cac thanh vien qua con tro su dung ->
    conTroSach->tieuDe = "Lap trinh C++";
    conTroSach->namXuatBan = 2023;

    // In thong tin Sach qua con tro
    cout << "Thong tin Sach (cap phat dong):" << endl;
    cout << "Tieu de: " << conTroSach->tieuDe << endl;
    cout << "Nam xuat ban: " << conTroSach->namXuatBan << endl;

    // *** Quan trong: Giai phong bo nho da cap phat dong ***
    delete conTroSach;
    conTroSach = nullptr; // Gan con tro ve nullptr de tranh loi dang treo (dangling pointer)

    // Accessing conTroSach after delete is undefined behavior!
    // cout << conTroSach->tieuDe << endl; // DO NOT DO THIS!

    return 0;
}

Trong ví dụ này:

  1. Sach* conTroSach = new Sach;: Chúng ta yêu cầu hệ điều hành cấp phát một vùng nhớ đủ lớn để chứa một đối tượng Sach trên heap. Toán tử new trả về địa chỉ của vùng nhớ đó, và chúng ta lưu địa chỉ này vào biến con trỏ conTroSach.
  2. Chúng ta sử dụng conTroSach->tieuDeconTroSach->namXuatBan để truy cập và gán giá trị cho các thành viên của đối tượng Sach trên heap thông qua con trỏ.
  3. delete conTroSach;: Đây là bước cực kỳ quan trọng! Khi bạn cấp phát bộ nhớ bằng new, bạn phải tự mình giải phóng nó khi không dùng nữa bằng toán tử delete. Nếu không, vùng nhớ này sẽ bị chiếm giữ cho đến khi chương trình kết thúc, dẫn đến tình trạng rò rỉ bộ nhớ (memory leak).
  4. conTroSach = nullptr;: Sau khi giải phóng bộ nhớ, con trỏ conTroSach vẫn giữ địa chỉ của vùng nhớ đã được giải phóng. Việc truy cập vào vùng nhớ này sẽ gây ra lỗi nghiêm trọng (Undefined Behavior). Gán con trỏ về nullptr là một thói quen tốt giúp bạn tránh vô tình sử dụng lại con trỏ đó.
Tại sao cần cấp phát động Struct?
  • Kích thước không xác định trước: Bạn có thể cần tạo một mảng các struct mà số lượng chỉ được biết khi chương trình đang chạy. Cấp phát động cho phép bạn tạo mảng có kích thước linh hoạt.
  • Vòng đời dài hơn scope: Bạn muốn một đối tượng struct tồn tại sau khi hàm hoặc khối lệnh hiện tại kết thúc. Cấp phát trên heap cho phép đối tượng tồn tại cho đến khi bạn chủ động delete nó.
  • Hiệu quả khi truyền qua hàm: Đối với các struct có kích thước lớn, việc truyền toàn bộ struct theo giá trị vào hàm sẽ tốn kém tài nguyên và thời gian do phải sao chép. Truyền con trỏ đến struct thì nhanh hơn nhiều vì chỉ phải sao chép địa chỉ (thường là 4 hoặc 8 byte).

Truyền Struct vào hàm bằng Con trỏ

Như đã đề cập, truyền struct bằng con trỏ vào hàm là một kỹ thuật hiệu quả. Bạn có thể truyền con trỏ để hàm chỉ đọc dữ liệu hoặc để hàm có thể thay đổi dữ liệu của struct gốc.

Truyền bằng con trỏ (có thể thay đổi)
#include <iostream>
#include <string>

struct SinhVien {
    string maSV;
    double diemTB;
};

// Ham thay doi diem trung binh cua sinh vien
void capNhatDiem(SinhVien* svPtr, double diemMoi) {
    // Kiem tra con tro khac nullptr truoc khi su dung
    if (svPtr != nullptr) {
        svPtr->diemTB = diemMoi; // Thay doi gia tri thanh vien goc qua con tro
        cout << "Da cap nhat diem cho SV co ma: " << svPtr->maSV << endl;
    } else {
        cerr << "Loi: Con tro SinhVien la nullptr!" << endl;
    }
}

int main() {
    SinhVien sv1;
    sv1.maSV = "SV001";
    sv1.diemTB = 8.5;

    cout << "Diem ban dau: " << sv1.diemTB << endl;

    // Truyen dia chi cua sv1 vao ham
    capNhatDiem(&sv1, 9.0);

    cout << "Diem sau khi cap nhat: " << sv1.diemTB << endl; // Diem da thay doi

    return 0;
}

Trong hàm capNhatDiem, tham số là một con trỏ SinhVien*. Khi gọi hàm capNhatDiem(&sv1, 9.0), chúng ta truyền địa chỉ của biến sv1. Bằng cách sử dụng svPtr->diemTB = diemMoi;, chúng ta thao tác trực tiếp lên vùng nhớ của sv1, do đó giá trị của sv1.diemTB trong main bị thay đổi.

Truyền bằng con trỏ const (chỉ đọc)

Nếu bạn chỉ muốn hàm truy cập dữ liệu của struct thông qua con trỏ mà không cho phép thay đổi dữ liệu đó, hãy sử dụng con trỏ const:

#include <iostream>
#include <string>

struct SanPham {
    string tenSP;
    double gia;
};

// Ham chi in thong tin san pham, khong thay doi du lieu
void inThongTinSanPham(const SanPham* spPtr) {
    if (spPtr != nullptr) {
        // spPtr->gia = 200; // Loi bien dich! Khong the thay doi thanh vien qua con tro const
        cout << "Ten san pham: " << spPtr->tenSP << endl;
        cout << "Gia: " << spPtr->gia << endl;
    } else {
        cerr << "Loi: Con tro SanPham la nullptr!" << endl;
    }
}

int main() {
    SanPham sp1;
    sp1.tenSP = "Laptop ABC";
    sp1.gia = 1500.0;

    // Truyen dia chi cua sp1 vao ham (duoc phep vi ham chi dung const pointer)
    inThongTinSanPham(&sp1);

    // Co the truyen con tro toi san pham cap phat dong
    SanPham* spDong = new SanPham;
    spDong->tenSP = "Mouse XYZ";
    spDong->gia = 25.0;
    inThongTinSanPham(spDong); // Truyen con tro vao ham

    delete spDong; // Giai phong bo nho dong
    spDong = nullptr;

    return 0;
}

Việc sử dụng const SanPham* là một thực hành tốt (good practice) để cho trình biên dịch và người đọc code biết rằng hàm này không có ý định sửa đổi đối tượng struct mà con trỏ trỏ tới, giúp tăng tính an toàn và rõ ràng cho code của bạn.

Bài tập ví dụ: C++ Bài 19.B1: Các khu phố còn lại

Các khu phố còn lại

Khi cuộc bầu cử đang đến gần ở đất nước FullHouseLand, chiến dịch vận động của FullHouse Dev đang diễn ra sôi nổi.

Thành phố của FullHouse Dev có chính xác 100 khu phố. FullHouse Dev đã thăm N khu phố để vận động tranh cử, và anh ấy sẽ không dừng lại cho đến khi thăm hết tất cả các khu phố!

Hãy tính xem FullHouse Dev cần thăm thêm bao nhiêu khu phố nữa?

INPUT FORMAT

  • Dòng duy nhất của input chứa một số nguyên N, là số lượng khu phố mà FullHouse Dev đã thăm.

OUTPUT FORMAT

  • In ra một số nguyên duy nhất: số lượng khu phố mà FullHouse Dev vẫn cần phải thăm.

CONSTRAINTS

  • 0 ≤ N ≤ 100
Ví dụ

Input 1

78

Output 1

22

Giải thích 1: FullHouse Dev đã thăm 78 trong số 100 khu phố. Điều đó có nghĩa là còn lại 22 khu phố cần thăm.

Input 2

100

Output 2

0

Giải thích 2: FullHouse Dev đã thăm mọi khu phố rồi. Chào bạn, đây là hướng dẫn giải bài "Các khu phố còn lại" bằng C++ theo yêu cầu, không cung cấp code hoàn chỉnh mà chỉ đưa ra các bước và gợi ý sử dụng các thành phần của thư viện chuẩn std.

Bài toán rất đơn giản: Có tổng cộng 100 khu phố, và bạn biết FullHouse Dev đã thăm N khu phố. Cần tìm số khu phố còn lại chưa được thăm.

Hướng dẫn giải:

  1. Hiểu bài toán: Bạn có tổng số lượng là 100 và một phần đã biết là N. Cần tìm phần còn lại.
  2. Công thức: Số khu phố còn lại chính là tổng số khu phố trừ đi số khu phố đã thăm.
  3. Đọc dữ liệu đầu vào:
    • Chương trình của bạn cần đọc một số nguyên từ đầu vào chuẩn (standard input). Số nguyên này chính là N.
    • Trong C++, bạn có thể sử dụng đối tượng cin để làm việc này.
    • Bạn cần khai báo một biến kiểu số nguyên (ví dụ int) để lưu giá trị đọc được từ cin.
  4. Thực hiện tính toán:
    • Lấy số tổng (100).
    • Trừ đi giá trị N mà bạn vừa đọc được.
    • Kết quả của phép trừ này là số khu phố còn lại.
  5. In kết quả:
    • Sau khi tính toán xong kết quả, bạn cần in nó ra đầu ra chuẩn (standard output).
    • Trong C++, bạn có thể sử dụng đối tượng cout để làm việc này.
    • In giá trị của kết quả tính toán ra màn hình.
  6. Các thành phần C++ cần dùng:
    • Để sử dụng cincout, bạn cần include header <iostream>.
    • Hàm chính của chương trình là main().
    • Sử dụng kiểu dữ liệu int để lưu số khu phố vì N và kết quả đều nằm trong phạm vi cho phép của int.

Tóm tắt các bước thực hiện trong code:

  1. Include <iostream>.
  2. Viết hàm main().
  3. Khai báo một biến kiểu int (ví dụ: n_visited).
  4. Đọc giá trị N từ cin vào biến vừa khai báo.
  5. Tính toán kết quả: 100 - n_visited.
  6. In kết quả ra cout.

Làm thêm nhiều bài tập miễn phí tại đây

Comments

There are no comments at the moment.