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.

    #include <iostream>
    #include <string>
    
    struct Nguoi {
        string ten;
        int tuoi;
        double chieuCao;
    };
    
    int main() {
        Nguoi sv;
        sv.ten = "Nguyen Van A";
        sv.tuoi = 20;
        cout << "Ten: " << sv.ten << endl;
        return 0;
    }
    
    Ten: Nguyen Van A

    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ớ.

    #include <iostream>
    
    int main() {
        int d = 10;
        int* p = &d;
    
        cout << "Gia tri cua d: " << d << endl;
        cout << "Dia chi cua d: " << &d << endl;
        cout << "Dia chi luu trong p: " << p << endl;
        cout << "Gia tri tai dia chi p tro toi: " << *p << endl;
        return 0;
    }
    
    Gia tri cua d: 10
    Dia chi cua d: 0x7ffeee9268bc  (Địa chỉ có thể khác nhau tùy môi trường)
    Dia chi luu trong p: 0x7ffeee9268bc (Địa chỉ có thể khác nhau tùy môi trường)
    Gia tri tai dia chi p tro toi: 10

    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 nguoi;
    nguoi.ten = "Tran Thi B";
    nguoi.tuoi = 25;

    Nguoi* p = &nguoi;

    cout << "Thong tin nguoi (truc tiep): " << nguoi.ten << ", " << nguoi.tuoi << endl;
    cout << "Dia chi cua nguoi: " << &nguoi << endl;
    cout << "Dia chi luu trong p: " << p << endl;

    return 0;
}
Thong tin nguoi (truc tiep): Tran Thi B, 25
Dia chi cua nguoi: 0x7ffeee9268a0 (Địa chỉ có thể khác nhau tùy môi trường)
Dia chi luu trong p: 0x7ffeee9268a0 (Địa chỉ có thể khác nhau tùy môi trường)

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

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
#include <iostream>
#include <string>

struct Nguoi {
    string ten;
    int tuoi;
};

int main() {
    Nguoi nguoi;
    nguoi.ten = "Tran Thi B";
    nguoi.tuoi = 25;

    Nguoi* p = &nguoi;

    cout << "Thong tin nguoi (truc tiep): " << nguoi.ten << ", " << nguoi.tuoi << endl;
    cout << "Dia chi cua nguoi: " << &nguoi << endl;
    cout << "Dia chi luu trong p: " << p << endl;

    cout << "Truy cap qua con tro (*p).ten: " << (*p).ten << endl;
    cout << "Truy cap qua con tro (*p).tuoi: " << (*p).tuoi << endl;

    return 0;
}
Thong tin nguoi (truc tiep): Tran Thi B, 25
Dia chi cua nguoi: 0x7ffeee9268a0 (Địa chỉ có thể khác nhau tùy môi trường)
Dia chi luu trong p: 0x7ffeee9268a0 (Địa chỉ có thể khác nhau tùy môi trường)
Truy cap qua con tro (*p).ten: Tran Thi B
Truy cap qua con tro (*p).tuoi: 25

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 ->
#include <iostream>
#include <string>

struct Nguoi {
    string ten;
    int tuoi;
};

int main() {
    Nguoi nguoi;
    nguoi.ten = "Tran Thi B";
    nguoi.tuoi = 25;

    Nguoi* p = &nguoi;

    cout << "Thong tin nguoi (truc tiep): " << nguoi.ten << ", " << nguoi.tuoi << endl;
    cout << "Dia chi cua nguoi: " << &nguoi << endl;
    cout << "Dia chi luu trong p: " << p << endl;

    cout << "Truy cap qua con tro (*p).ten: " << (*p).ten << endl;
    cout << "Truy cap qua con tro (*p).tuoi: " << (*p).tuoi << endl;

    cout << "Truy cap qua con tro p->ten: " << p->ten << endl;
    cout << "Truy cap qua con tro p->tuoi: " << p->tuoi << endl;

    p->tuoi = 26;
    cout << "Tuoi sau khi cap nhat qua con tro: " << nguoi.tuoi << endl;

    return 0;
}
Thong tin nguoi (truc tiep): Tran Thi B, 25
Dia chi cua nguoi: 0x7ffeee9268a0 (Địa chỉ có thể khác nhau tùy môi trường)
Dia chi luu trong p: 0x7ffeee9268a0 (Địa chỉ có thể khác nhau tùy môi trường)
Truy cap qua con tro (*p).ten: Tran Thi B
Truy cap qua con tro (*p).tuoi: 25
Truy cap qua con tro p->ten: Tran Thi B
Truy cap qua con tro p->tuoi: 25
Tuoi sau khi cap nhat qua con tro: 26

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 S { // Sach
    string tieuDe;
    int namXB; // namXuatBan
};

int main() {
    S* pS = new S;

    if (pS == nullptr) {
        cerr << "Loi: Khong du bo nho!" << endl;
        return 1;
    }

    pS->tieuDe = "Lap trinh C++";
    pS->namXB = 2023;

    cout << "Thong tin Sach (cap phat dong):" << endl;
    cout << "Tieu de: " << pS->tieuDe << endl;
    cout << "Nam xuat ban: " << pS->namXB << endl;

    delete pS;
    pS = nullptr;

    return 0;
}
Thong tin Sach (cap phat dong):
Tieu de: Lap trinh C++
Nam xuat ban: 2023

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 SV { // SinhVien
    string ma; // maSV
    double diem; // diemTB
};

void capNhatDiem(SV* pSV, double diemMoi) {
    if (pSV != nullptr) {
        pSV->diem = diemMoi;
        cout << "Da cap nhat diem cho SV ma: " << pSV->ma << endl;
    } else {
        cerr << "Loi: Con tro SV la nullptr!" << endl;
    }
}

int main() {
    SV sv;
    sv.ma = "SV001";
    sv.diem = 8.5;

    cout << "Diem ban dau: " << sv.diem << endl;

    capNhatDiem(&sv, 9.0);

    cout << "Diem sau khi cap nhat: " << sv.diem << endl;

    return 0;
}
Diem ban dau: 8.5
Da cap nhat diem cho SV ma: SV001
Diem sau khi cap nhat: 9

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 SP { // SanPham
    string ten; // tenSP
    double gia;
};

void inThongTinSP(const SP* pSP) {
    if (pSP != nullptr) {
        // pSP->gia = 200; // Loi bien dich!
        cout << "Ten san pham: " << pSP->ten << endl;
        cout << "Gia: " << pSP->gia << endl;
    } else {
        cerr << "Loi: Con tro SP la nullptr!" << endl;
    }
}

int main() {
    SP sp1;
    sp1.ten = "Laptop ABC";
    sp1.gia = 1500.0;

    inThongTinSP(&sp1);

    SP* pSD = new SP; // SanPhamDong
    pSD->ten = "Mouse XYZ";
    pSD->gia = 25.0;
    inThongTinSP(pSD);

    delete pSD;
    pSD = nullptr;

    return 0;
}
Ten san pham: Laptop ABC
Gia: 1500
Ten san pham: Mouse XYZ
Gia: 25

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.