Mảng và con trỏ trong C++: địa chỉ, nullptr và std::array

Mảng và con trỏ trong C++: địa chỉ, nullptr và std::array - Khuê Nguyễn Creator

Trong bài này chúng ta sẽ tìm hiểu mảng và con trỏ trong C++: mảng được xếp trong bộ nhớ thế nào, địa chỉ là gì, cách dùng con trỏ với toán tử & và *, nullptr, mối quan hệ giữa con trỏ và mảng, và cuối cùng là std::array của C++ hiện đại. Con trỏ là phần làm nhiều bạn sợ nhất, nên mình sẽ in địa chỉ thật ra màn hình để các bạn nhìn tận mắt chứ không chỉ học lý thuyết nhé.

Bài 11 trong Serie Lập trình C++ từ A tới Z

Mảng trong C++

Mảng (array) là một dãy các phần tử cùng kiểu, nằm liền nhau trong bộ nhớ. Thay vì khai báo 5 biến soLuong1, soLuong2… để lưu số cuốn còn trong kho của 5 đầu sách, chúng ta khai báo một mảng 5 phần tử:

#include <iostream>

int main()
{
    int soLuong[5] = {3, 0, 7, 2, 5};   // so cuon con trong kho cua 5 dau sach

    std::cout << "Phan tu dau:  " << soLuong[0] << '\n';
    std::cout << "Phan tu cuoi: " << soLuong[4] << '\n';

    soLuong[1] = 10;                     // nhap them 10 cuon cho dau sach thu 2

    int tong = 0;
    for (int x : soLuong)
        tong += x;
    std::cout << "Tong so cuon: " << tong << '\n';
    std::cout << "sizeof(soLuong) = " << sizeof(soLuong) << " byte, so phan tu = "
              << sizeof(soLuong) / sizeof(soLuong[0]) << '\n';
    return 0;
}

Khai báo mảng 5 phần tử, truy cập theo chỉ số, tính tổng và sizeof mảng bằng 20 byte

  • Khai báo int soLuong[5]: kiểu phần tử, tên mảng, số phần tử trong ngoặc vuông. Số phần tử phải là hằng số biết lúc biên dịch, như constexpr ở bài hằng và biến C++.
  • Chỉ số bắt đầu từ 0: mảng 5 phần tử có chỉ số từ 0 tới 4.
  • sizeof(soLuong) là kích thước cả mảng, 5 x 4 = 20 byte. Chia cho kích thước một phần tử thì ra số phần tử.

Mảng nằm trong bộ nhớ như thế nào?

Mỗi ô nhớ có một địa chỉ (address), giống như số nhà trên một con phố. Toán tử & đặt trước biến cho biết địa chỉ của nó. In thử địa chỉ của từng phần tử:

#include <iostream>

int main()
{
    int soLuong[4] = {3, 10, 7, 2};

    for (int i = 0; i < 4; i++)
        std::cout << "soLuong[" << i << "] = " << soLuong[i]
                  << "  dia chi: " << &soLuong[i] << '\n';
    return 0;
}

Địa chỉ các phần tử mảng int cách nhau 4 byte

Địa chỉ được in ở dạng hệ 16. Để ý các địa chỉ cách nhau đúng 4, vì mỗi phần tử int chiếm 4 byte và các phần tử nằm sát nhau:

Sơ đồ mảng và con trỏ trong C++: mảng int soLuong[4] nằm liền nhau trong bộ nhớ, con trỏ p trỏ vào phần tử đầu, p + 1 cộng 4 byte

Chính vì nằm liền nhau nên truy cập soLuong[i] rất nhanh: máy chỉ cần lấy địa chỉ phần tử đầu, cộng thêm i x 4 byte là tới.

Con trỏ là gì?

Con trỏ (pointer) là một biến lưu địa chỉ của biến khác. Nếu biến thường là ngôi nhà, thì con trỏ là tờ giấy ghi địa chỉ ngôi nhà đó. Có tờ giấy thì tìm được tới nhà, vào nhà xem hay sửa đồ bên trong.

#include <iostream>

int main()
{
    int soSach = 42;
    int* p = &soSach;          // p luu dia chi cua soSach

    std::cout << "Gia tri soSach:  " << soSach << '\n';
    std::cout << "Dia chi soSach:  " << &soSach << '\n';
    std::cout << "Gia tri cua p:   " << p << '\n';
    std::cout << "*p (gia tri tai dia chi p): " << *p << '\n';

    *p = 50;                   // sua soSach thong qua con tro
    std::cout << "Sau *p = 50, soSach = " << soSach << '\n';

    int* q = nullptr;          // con tro chua tro vao dau
    if (q == nullptr)
        std::cout << "q chua tro vao dau\n";
    return 0;
}

Con trỏ p lưu địa chỉ của soSach, sửa *p làm soSach đổi thành 50, con trỏ nullptr

Hai toán tử cần nhớ:

  • &bien: lấy địa chỉ của biến.
  • *con_tro: lấy (hoặc sửa) giá trị tại địa chỉ mà con trỏ đang giữ, gọi là giải tham chiếu (dereference).

int* p đọc là “p là con trỏ tới int“. Giá trị của p đúng bằng địa chỉ của soSach, và *p = 50 sửa thẳng vào soSach.

nullptr

Con trỏ chưa trỏ vào đâu thì gán bằng nullptr. Đây là từ khóa của C++11, thay cho NULL hay số 0 trong C vì nó có kiểu riêng, không bị nhầm với số nguyên khi nạp chồng hàm.

Lưu ý: Giải tham chiếu con trỏ nullptr hoặc con trỏ chưa khởi tạo (*q khi q là nullptr) sẽ làm chương trình crash với lỗi Segmentation fault. Luôn khởi tạo con trỏ, và kiểm tra if (p != nullptr) trước khi dùng con trỏ có thể rỗng.

Quan hệ giữa mảng và con trỏ trong C++

Tên mảng, khi dùng trong biểu thức, tự chuyển thành địa chỉ của phần tử đầu tiên. Vì vậy có thể gán thẳng mảng cho con trỏ:

#include <iostream>

int main()
{
    int soLuong[4] = {3, 10, 7, 2};
    int* p = soLuong;            // ten mang chuyen thanh dia chi phan tu dau

    std::cout << "*p       = " << *p << '\n';
    std::cout << "*(p + 1) = " << *(p + 1) << '\n';
    std::cout << "p[2]     = " << p[2] << '\n';
    std::cout << "p     = " << p << '\n';
    std::cout << "p + 1 = " << p + 1 << "  (tang " << sizeof(int) << " byte)\n";
    return 0;
}

Con trỏ trỏ vào mảng, *(p + 1) và p[2] truy cập phần tử, p + 1 tăng 4 byte

p + 1 không tăng địa chỉ lên 1 byte, mà tăng đúng bằng kích thước một phần tử, ở đây là 4 byte. Nhờ vậy *(p + 1) chính là phần tử tiếp theo, và p[2] viết gọn của *(p + 2). Phép toán này gọi là số học con trỏ (pointer arithmetic).

Truyền mảng vào hàm

Đây là chỗ mảng và con trỏ gây lỗi nhiều nhất. Viết hàm tính tổng số sách trong mảng:

#include <iostream>

int tinhTong(int mang[])
{
    int soPhanTu = sizeof(mang) / sizeof(mang[0]);   // sai!
    int tong = 0;
    for (int i = 0; i < soPhanTu; i++)
        tong += mang[i];
    return tong;
}

int main()
{
    int soLuong[5] = {3, 10, 7, 2, 5};
    std::cout << "Tong: " << tinhTong(soLuong) << '\n';
    return 0;
}

g++ cảnh báo sizeof trên tham số mảng trả về kích thước con trỏ, hàm tính tổng ra 13 thay vì 27

Tổng đúng phải là 27, nhưng chương trình in ra 13. Khi truyền vào hàm, mảng phân rã (decay) thành con trỏ tới phần tử đầu, nên int mang[] thực chất là int* mang. sizeof(mang) giờ là kích thước một con trỏ (8 byte trên máy 64-bit), chia 4 ra 2, và hàm chỉ cộng 2 phần tử đầu: 3 + 10 = 13.

Cách sửa của C là truyền thêm số phần tử:

#include <iostream>

int tinhTong(const int* mang, int soPhanTu)
{
    int tong = 0;
    for (int i = 0; i < soPhanTu; i++)
        tong += mang[i];
    return tong;
}

int main()
{
    int soLuong[5] = {3, 10, 7, 2, 5};
    std::cout << "Tong: " << tinhTong(soLuong, 5) << '\n';
    return 0;
}

Truyền mảng vào hàm kèm số phần tử, tổng đúng bằng 27

const int* báo rằng hàm chỉ đọc mảng, không sửa. Cách này chạy đúng, nhưng phải luôn truyền kèm số phần tử, truyền sai là lỗi. Mục std::array bên dưới giải quyết gọn hơn.

Truy cập vượt ngoài mảng

C++ không kiểm tra chỉ số mảng. Viết i <= 5 thay vì i < 5 là đọc lố sang ô nhớ không thuộc mảng:

#include <iostream>

int main()
{
    int soLuong[5] = {3, 10, 7, 2, 5};
    int tong = 0;

    for (int i = 0; i <= 5; i++)      // sai: i chay toi 5
        tong += soLuong[i];

    std::cout << "Tong: " << tong << '\n';
    return 0;
}

Đọc vượt ngoài mảng không báo lỗi, AddressSanitizer phát hiện stack-buffer-overflow ở dòng 9

Lần chạy đầu in ra 27, trông như đúng. Thực ra soLuong[5] đọc phải một ô nhớ nằm ngoài mảng, lần này tình cờ chứa 0. Đây là undefined behavior: chạy đúng trên máy này, sai trên máy khác, hoặc ghi đè lên biến khác nếu là lệnh gán.

Cách bắt lỗi này là biên dịch thêm cờ -fsanitize=address (AddressSanitizer). Chương trình sẽ dừng ngay lúc truy cập sai và chỉ đúng dòng kho.cpp:9.

Lưu ý: Khi chương trình có mảng, con trỏ mà kết quả lúc đúng lúc sai, hãy biên dịch lại với -g -fsanitize=address để chạy thử. Cờ này làm chương trình chậm hơn nên chỉ dùng lúc tìm lỗi, không dùng cho bản chính thức.

std::array: mảng kiểu C++

std::array trong thư viện <array> là mảng có kích thước cố định giống mảng thô, nhưng khắc phục được các nhược điểm vừa gặp:

#include <iostream>
#include <array>

int tinhTong(const std::array<int, 5>& mang)
{
    int tong = 0;
    for (int x : mang)
        tong += x;
    return tong;
}

int main()
{
    std::array<int, 5> soLuong = {3, 10, 7, 2, 5};

    std::cout << "So phan tu: " << soLuong.size() << '\n';
    std::cout << "Tong: " << tinhTong(soLuong) << '\n';
    std::cout << "at(2) = " << soLuong.at(2) << '\n';
    std::cout << "at(7) = " << soLuong.at(7) << '\n';
    return 0;
}

std::array có size() bằng 5, at(7) vượt mảng ném ngoại lệ std::out_of_range

  • Biết số phần tử bằng .size(), không cần mẹo sizeof.
  • Truyền vào hàm bằng tham chiếu const& như bài về hàm, không bị phân rã thành con trỏ.
  • .at(i) kiểm tra chỉ số: vượt mảng thì dừng chương trình với thông báo out_of_range rõ ràng, thay vì âm thầm đọc rác. Toán tử [] vẫn dùng được và không kiểm tra, nhanh hơn một chút.

Chương trình bị dừng ở at(7) là do ngoại lệ, phần xử lý ngoại lệ chúng ta sẽ học ở chương nâng cao. Còn khi cần mảng thay đổi được số phần tử lúc chạy, các bạn dùng std::vector.

So sánh con trỏ và tham chiếu

Con trỏ int* p Tham chiếu int& r
Có thể rỗng Có (nullptr) Không
Bắt buộc khởi tạo Không (nhưng nên) Có
Đổi sang biến khác Được Không
Cách dùng Phải viết *p Dùng như biến thường
Số học (p + 1) Có Không

Quy tắc chọn: dùng tham chiếu bất cứ khi nào có thể, vì an toàn và dễ đọc hơn. Chỉ dùng con trỏ khi cần giá trị “không có gì” (nullptr), cần đổi sang đối tượng khác, hoặc làm việc với bộ nhớ cấp phát động mà chúng ta sẽ học ở bài sau.

Áp dụng vào chương trình thư viện

Hàm tìm sách theo mã trả về con trỏ tới phần tử tìm được, không tìm thấy thì trả về nullptr. Đây là trường hợp tham chiếu không làm được, vì tham chiếu không thể rỗng:

#include <iostream>

// Tra ve con tro toi phan tu co ma can tim, khong co thi tra ve nullptr
int* timMaSach(int* danhSach, int soPhanTu, int maCanTim)
{
    for (int i = 0; i < soPhanTu; i++)
    {
        if (danhSach[i] == maCanTim)
            return &danhSach[i];
    }
    return nullptr;
}

int main()
{
    int maSach[6] = {1001, 1002, 1005, 1010, 1024, 1100};

    int* ketQua = timMaSach(maSach, 6, 1024);
    if (ketQua != nullptr)
        std::cout << "Tim thay 1024 o vi tri " << ketQua - maSach << '\n';

    ketQua = timMaSach(maSach, 6, 9999);
    if (ketQua == nullptr)
        std::cout << "Khong co sach ma 9999\n";
    return 0;
}

Hàm tìm mã sách trả về con trỏ, tìm thấy 1024 ở vị trí 4, không có mã 9999 trả về nullptr

ketQua - maSach là hiệu 2 con trỏ, cho biết 2 địa chỉ cách nhau bao nhiêu phần tử, tức là vị trí của cuốn sách trong mảng.

Bài tập

  1. Nhập 5 số nguyên vào mảng, in ra số lớn nhất và vị trí của nó.
  2. Viết hàm void daoNguoc(int* mang, int soPhanTu) đảo ngược thứ tự các phần tử trong mảng.
  3. Viết lại bài 1 bằng std::array. Thử truy cập phần tử thứ 10 bằng [] và bằng .at(), so sánh kết quả.
  4. Cho int a[3] = {1, 2, 3}; int* p = a + 1;. Đoán giá trị của *p, p[1] và *(p - 1), rồi chạy thử để kiểm tra.

Lời giải sẽ có trong bài tổng hợp bài tập nhé.

Kết

Mảng và con trỏ trong C++ gắn liền với nhau: mảng là dãy ô nhớ liền kề, con trỏ là biến giữ địa chỉ, và tên mảng chuyển thành con trỏ tới phần tử đầu. Hiểu điều này sẽ giải thích được 2 lỗi kinh điển là sizeof sai khi truyền mảng vào hàm và đọc vượt ngoài mảng. Trong code C++ mới, các bạn nên ưu tiên std::array, tham chiếu, và chỉ dùng con trỏ thô khi thật sự cần.

Muốn tra cứu chi tiết về mảng, con trỏ và std::array, các bạn xem thêm tài liệu chuẩn trên cppreference.

Phần nền tảng về mảng và con trỏ giống hệt C, các bạn có thể đọc thêm bài mảng trong C và con trỏ trong C. Bài tiếp theo chúng ta sẽ dùng con trỏ để cấp phát bộ nhớ động với new và delete. Có chỗ nào chưa hiểu, các bạn để lại bình luận bên dưới nhé.

Rate this post

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *