Chuyển tới nội dung chính

Chương 6: Con trỏ

Kỹ thuật lập trình quan trọng liên quan đến quản lý bộ nhớ của biến, cấp phát và thu hồi vùng nhớ trong quá trình thực thi chương trình C/C++.


1. Bộ nhớ máy tính khi lập trình

1.1. Bộ nhớ máy tính

Bộ nhớ máy tính:

  • Bộ nhớ RAM chứa rất nhiều ô nhớ, mỗi ô nhớ có kích thước 1 byte.
  • Mỗi ô nhớ có địa chỉ duy nhất và địa chỉ này được đánh số từ 0 trở đi.
  • RAM dùng để lưu trữ mã chương trình và dữ liệu trong suốt quá trình thực thi.

1.2. Vùng nhớ máy tính và biến

Khi khai báo biến, máy tính sẽ dành riêng một vùng nhớ để lưu biến đó.

Khi tên biến được gọi, máy tính sẽ thực hiện 2 bước sau:

  1. Tìm kiếm địa chỉ ô nhớ của biến.
  2. Truy xuất hoặc thiết lập giá trị của biến được lưu trữ tại ô nhớ đó.

2. Con trỏ

2.1. Khái niệm

Con trỏ là một biến dùng để lưu trữ địa chỉ của một biến khác. Con trỏ phải cùng kiểu dữ liệu với biến đang được lưu địa chỉ.

2.2. Vai trò của con trỏ

  • Quản lý bộ nhớ: Con trỏ cho phép lập trình viên cấp phát và giải phóng bộ nhớ thủ công, tối ưu hóa việc sử dụng bộ nhớ và tránh lãng phí.
  • Truy cập trực tiếp: Con trỏ cho phép truy cập trực tiếp vào các ô nhớ, giúp thao tác dữ liệu nhanh chóng và hiệu quả.
  • Khả năng linh hoạt: Con trỏ giúp truy cập các cấu trúc dữ liệu phức tạp như DSLK, cây.

Lưu ý:

  • Nguy cơ rò rỉ bộ nhớ: Cần giải phóng bộ nhớ đúng cách để tránh rò rỉ.
  • Lỗi truy cập vùng nhớ: Sử dụng con trỏ sai có thể dẫn đến lỗi chương trình.

2.3. Khai báo và khởi tạo biến con trỏ

2.3.1. Khai báo

Công thức khai báo:

<kiểu dữ liệu> *<tên biến>;

Ví dụ:

int a;
int *p = &a;

2.3.2. Khởi tạo giá trị

Ta dùng toán tử & lấy địa chỉ một biến để gán cho con trỏ

Ví dụ:

int a;
int *p = &a;

2.4. Các phép toán với biến con trỏ

2.4.1. Các toán tử thường gặp

  • Toán tử & (Address-of Operator): Đặt trước tên biến và cho biết địa chỉ của vùng nhớ của biến.
  • Toán tử * (Dereferencing / Indirection Operator): Đặt trước một địa chỉ và cho biết giá trị lưu trữ tại địa chỉ đó.

Tương tự, với con trỏ:

  • Toán tử & đặt trước biến con trỏ: Cho biết địa chỉ vùng nhớ của chính biến con trỏ.
  • Toán tử * đặt trước biến con trỏ: Truy xuất đến giá trị ô nhớ mà con trỏ đang trỏ đến.

Note: Vì các con trỏ được cấp bộ nhớ là như nhau (đều lưu địa chỉ), nên kích thước bộ nhớ của chúng là bằng nhau.

2.4.2. Phép gán con trỏ

Có thể gán biến con trỏ cho con trỏ khác, cũng như có thể gán giá trị cho vùng nhớ mà con trỏ đã xác định.

Ví dụ 1 (Hợp lệ):

VD1:
int x = 10;
int *p1 = &x;
int *p2 = p1; // p2 trỏ cùng chỗ với p1

Ví dụ 2:

int *p;
*p = 20; ( lỗi )

Note: Chỉ được gán khi con trỏ đang chứa giá trị.

2.4.3. Các phép toán trên con trỏ

Để dễ hiểu định nghĩa về các phép toán trên con trỏ, ta cho trước con trỏ p1, p2 có cùng kiểu dữ liệu Tn là một số nguyên. [cite_start]Các phép toán sau là hợp lệ [cite: 64-65]:

  • p1 + n : Kết quả là con trỏ trỏ đến vị trí cách n phần tử từ p1[cite: 66].
  • p1 - n : Kết quả là con trỏ trỏ đến vị trí cách n phần tử về phía trước của p1[cite: 67].
  • ++p1 : Di chuyển con trỏ p1 đến phần tử liền kề phía sau (p1 không phải hằng)[cite: 68].
  • p1++ : Di chuyển con trỏ p1 đến phần tử liền kề phía sau (p1 không phải hằng)[cite: 69].
  • --p1 : Di chuyển con trỏ p1 đến phần tử liền trước đó (p1 không phải hằng)[cite: 70].
  • p1-- : Di chuyển con trỏ p1 đến phần tử liền trước đó (p1 không phải hằng)[cite: 71].
  • p1 - p2 : Kết quả là khoảng cách (theo số lượng phần tử) giữa hai con trỏ[cite: 72].

Trong đó: Phần tử ở đây là 1 vùng nhớ có kích cỡ sizeof(T), con trỏ đang xét trỏ đến các phần tử của cùng một mảng hoặc khối bộ nhớ liên tục [cite: 73-74].

  • Các phép toán so sánh: So sánh địa chỉ giữa hai con trỏ (thứ tự ô nhớ): == , !=, >, >=, <, <=.

Note: Con trỏ không thể thực hiện các phép toán: *, /, %.

2.5. Con trỏ Void

Con trỏ void là một con trỏ không mang một kiểu dữ liệu nào. [cite_start]Con trỏ void có thể được ép sang bất kỳ kiểu dữ liệu nào và chứa địa chỉ của một biến có kiểu giá trị bất kỳ [cite: 79-80].

Ví dụ:

int a = 123;
void *ptr = &a; // Trỏ đến biến kiểu int

char p = 'c';
ptr = &p; // Trỏ đến biến kiểu char

2.6. Con trỏ NULL, nullptr

2.6.1. Con trỏ NULL

Con trỏ NULL là con trỏ không chứa bất kỳ giá trị nào.

Ví dụ:

int *ptr = NULL;

2.6.2. Con trỏ nullptr

Con trỏ nullptr giống với con trỏ NULL, nhưng con trỏ nullptr an toàn hơn con trỏ NULL (nullptr được hỗ trợ bởi mọi chương trình hiện đại từ C++11 trở đi).

Ví dụ:

int *ptr = nullptr;

2.7. Con trỏ và khóa constant

Constant là một khóa mang tính hằng số, không thể thay đổi được xuyên suốt chương trình. Có 3 trường hợp khi dùng con trỏ và khóa constant:

TH1: Con trỏ tới một giá trị hằng

  • Cú pháp:
    <Kiểu_Dữ_Liệu_Y> <Tên_Biến_Có_Kiểu_Dữ_Liệu_Y>;

    const <Kiểu_Dữ_Liệu_Y> * <Tên_Biến_Con_Trỏ> = &<Tên_Biến_Có_Kiểu_Dữ_Liệu_Y>;
  • Ý nghĩa: Không thể thay đổi giá trị tại vùng nhớ mà con trỏ đang trỏ tới, nhưng con trỏ có thể thay đổi trỏ tới địa chỉ khác.

TH2: Con trỏ hằng tới một giá trị không hằng

  • Cú pháp:
    <Kiểu_Dữ_Liệu_Y> <Tên_Biến_Có_Kiểu_Dữ_Liệu_Y>;

    <Kiểu_Dữ_Liệu_Y> * const <Tên_Biến_Con_Trỏ> = &<Tên_Biến_Có_Kiểu_Dữ_Liệu_Y>;
  • Ý nghĩa: Con trỏ không được phép thay đổi địa chỉ đang trỏ tới (read-only), nhưng vùng nhớ con trỏ trỏ tới có thể thay đổi giá trị.

TH3: Con trỏ hằng tới một giá trị hằng

  • Cú pháp:
    <Kiểu_Dữ_Liệu_Y> <Tên_Biến_Có_Kiểu_Dữ_Liệu_Y>;

    const <Kiểu_Dữ_Liệu_Y> * const <Tên_Biến_Con_Trỏ> = &
    <Tên_Biến_Có_Kiểu_Dữ_Liệu_Y>;
  • Ý nghĩa: Con trỏ và cả vùng nhớ con trỏ đang trỏ tới không được phép thay đổi giá trị (read-only).

2.8. Con trỏ và mảng 1 chiều

2.8.1. Khái niệm

Mảng 1 chiều chính là một con trỏ hằng.

2.8.2. Lấy địa chỉ

Cú pháp:

int a[3] = {0, 1, 2};
int *ptr = &a[0];

2.8.3. Truy xuất

  • Cách 1: ptr = a;
  • Cách 2: ptr = &a[0];

Truy xuất tới giá trị của phần tử thứ i của mảng (xét i là chỉ số hợp lệ của mảng):

  • Giá trị: arr[i] == *(arr + i) == parr[i] == *(parr + i)

  • Địa chỉ: &arr[i] == arr + i == &parr[i] == parr + i

2.9. Con trỏ và mảng 2 chiều

Xét mảng 2 chiều a. Trong đó: T là kiểu dữ liệu.

Ta có 2cách sau để khai báo con trỏ quản lý mảng 2 chiều a:

#define T int
...
T *p = (T*)a; // Ép kiểu mảng a thành con trỏ cấp 1
T (*p1)[MAXC]= a; // p1 là con trỏ đến mảng 2 chiều.

3. Con trỏ động

3.1. Cấp phát tĩnh, Cấp phát động

3.1.1. Khái niệm

Cấp phát tĩnh (Static memory allocation và Automatic memory allocation):

  • Khai báo biến, cấu trúc, mảng v.v .
  • Bắt buộc phải biết trước cần bao nhiêu bộ nhớ lưu trữ.
  • Không thay đổi được kích thước suốt chương trình.
  • Tồn tại trong suốt thời gian tồn tại của chương trình.

Cấp phát động (Dynamic memory allocation):

  • Cần bao nhiêu cấp phát bấy nhiêu.
  • Có thể giải phóng nếu không cần sử dụng.
  • Sử dụng vùng nhớ dành cho cấp phát động (Heap).

3.1.2. Câu lệnh cấp phát

Cấp phát bộ nhớ:

  • Trong C++: Toán tử new.
  • Trong C: Hàm malloc, calloc, realloc (<stdlib.h> hoặc <alloc.h>).

Cú pháp trong C++:

<type> *pointerName = new <type>;
<type> *pointer = new <type>(value);

Ví dụ:

int *ptr = new int;
int *ptr1 = new int(100); // (*ptr)=100.

Giải phóng bộ nhớ:

  • Trong C++: Toán tử delete.
  • Trong C: Hàm free.

Cú pháp trong C++:

delete pointerName;

Ví dụ:

delete ptr;
delete ptr1;

3.1.3. Code minh họa

Code minh họa cách dùng của 2 toán tử trên (newdelete):

#include <iostream>
using namespace std;

int main() {
int n;
cin >> n;

// Cấp phát động mảng bằng toán tử new
int* a = new int[n];

for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < n; i++) cout << a[i] << " ";

// Giải phóng bộ nhớ
delete[] a;
return 0;
}

Note: Dùng toán tử cấp phát ngôn ngữ nào thì đi với toán tử xóa ngôn ngữ ấy (Ví dụ: new đi với delete, malloc đi với free).

3.1.4. Biến

Biến cục bộ (Automatic variable):

  • Khai báo bên trong định nghĩa hàm.
  • Sinh ra khi hàm được gọi.
  • Hủy đi khi hàm kết thúc.
  • Biến cục bộ được đặt tên.
  • Thường gọi là biến tự động (automatic variable) nghĩa là được trình biên dịch quản lý một cách tự động.

Biến cấp phát động (Dynamic variable):

  • Sinh ra bởi cấp phát động.
  • Sinh ra và hủy đi khi chương trình đang chạy.
  • Vùng nhớ cấp phát động không có tên gọi (truy cập qua địa chỉ).
  • Biến cấp phát động hay Biến động là biến con trỏ, trước khi sử dụng phải được cấp phát bộ nhớ.

3.1.5. Code minh họa

Code minh họa cấp phát động cho một biến đơn:

#include <iostream>
using namespace std;

int main() {
// Cấp phát động cho một biến int
int* p = new int; // p là con trỏ trỏ tới vùng nhớ vừa cấp phát
*p = 42; // gán giá trị cho biến động

cout << "Gia tri bien cap phat dong: " << *p << endl;

// Giải phóng bộ nhớ
delete p;
return 0;
}

3.2. Mảng một chiều cấp phát động

3.2.1. Điểm mạnh

  • Không cần xác định trước kích thước tại thời điểm lập trình
  • Có thể cấp phát và giải phóng bộ nhớ trong quá trình thực thi

3.2.2. Khai báo

Cấp phát động cho biến con trỏ, sau đó dùng con trỏ như mảng chuẩn

Cú pháp:

type *pointer = new type[number_of_elements];

Ví dụ:

// typedef double * doublePtr;
// doublePtr d;
double* d;
d = new double[10]; // Tạo biến mảng cấp phát động d có 10 phần tử, kiểu cơ sở là double.

3.2.3. Xóa mảng động

Dùng toán tử delete để xóa. Ví dụ:

double *d = new double[10];
delete[] d;

3.2.4. Code minh họa

Code minh họa cho phần trên

#include <iostream>
using namespace std;

int main() {
int n;
cout << "Nhap so phan tu: ";
cin >> n;

// Cấp phát mảng động 1 chiều
int* arr = new int[n];

// Nhập giá trị cho mảng
for (int i = 0; i < n; i++) {
cout << "arr[" << i << "] = ";
cin >> arr[i];
}

// Xuất giá trị mảng
cout << "Mang vua nhap: ";
for (int i = 0; i < n; i++) {
cout << arr[i] << " ";
}
cout << endl;

// Giải phóng bộ nhớ
delete[] arr;
return 0;
}

3.3. Con trỏ cấp phát động và chuỗi

Chuỗi cũng là một dạng mảng nên có thể dùng con trỏ cấp phát động để quản lý.

Ví dụ:

char *ptr = new char[100];

3.4. Mảng hai chiều cấp phát động

3.4.1. Cấp phát

Ví dụ cấp phát 1 mảng 2 chiều có số hàng bằng rows vầ số cột bằng cols:

int** arr = new int*[rows];
for (int i = 0; i < rows; i++) {
arr[i] = new int[cols];
}

Note: có thể dùng type def để khai báo ngắn lại. Ví dụ:

typedef int* int_array_ptr;
int_array_ptr *arr = new int_array_ptr[rows];

3.4.2. Xóa mảng

Dùng toán tử delete[] để xóa mảng động. Ví dụ:

double **pd;
// ... Processing
for (int i = 0; i < row; i++)
delete[] p[i];
delete[] p;
  • Giải phóng tất cả vùng nhớ của mảng động này.
  • Cặp ngoặc vuông [] báo hiệu có mảng.

Note: Sau khi giải phóng, con trỏ d vẫn trỏ tới vùng nhớ đó (nhưng vùng nhớ đó không còn hợp lệ). Vì vậy sau khi delete, cần gán: d = nullptr;.

3.4.3. Code minh họa

Code minh họa cho phần trên:

#include <iostream>
using namespace std;

int main() {
int rows, cols;
cout << "Nhap so dong: ";
cin >> rows;
cout << "Nhap so cot: ";
cin >> cols;

// Cấp phát mảng 2 chiều
int** arr = new int*[rows];
for (int i = 0; i < rows; i++) {
arr[i] = new int[cols];
}

// Nhập giá trị
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
cout << "arr[" << i << "][" << j << "] = ";
cin >> arr[i][j];
}
}

// Xuất giá trị
cout << "Mang vua nhap:\n";
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
cout << arr[i][j] << " ";
}
cout << endl;
}

// Giải phóng bộ nhớ
for (int i = 0; i < rows; i++) {
delete[] arr[i];
}
delete[] arr;

return 0;
}

3.5. Truyền con trỏ cho hàm

3.5.1. Hàm truyền tham số là con trỏ

// Hàm xuất mảng
void Output(int *p, int n) {

cout << "\n Xuat mang 1 chieu: ";
for (int i = 0; i < n; i++)
cout << p[i] << " " ;

}

int main() {

int arr[] = {6, 7, 8, 9, 10}, n=5;
Output(arr, n);
return 0;
}

3.5.2. Hàm tuyền tham số là tham chiếu con trỏ

//Hàm khai báo tham chiếu con trỏ quản lý 1 vùng nhớ
#include <iostream>
using namespace std;
void Input(int *&p) {

p = new int;
cin >> *p;

}

int main() {

int *a;
Input(a);
return 0;

}

3.5.3. Hàm trả về con trỏ cấp phát động

Vì ta không thể trả về 1 mảng khi viết hàm, thay vào đó chúng ta sẽ khởi tạo một con trỏ tham chiếu đến mảng đó và trả về con trỏ đó.

#include <iostream>
using namespace std;
int* Input(int n) {
int *p = new int[n];
for (int i = 0; i < n; i++)
cin >> p[i];
return p;
}
int main() {
int *arr, n;
cout << "Nhap n: "; cin >> n;
arr = Input(n);
return 0;
}

Bài tập ôn luyện

1. Trình bày khái niệm con trỏ trong C/C++ và cho ví dụ minh họa.

2. Giải thích sự khác nhau giữa toán tử & và toán tử * khi làm việc với con trỏ.

3. Trình bày sự khác nhau giữa cấp phát tĩnh và cấp phát động trong C++.

4. Viết cú pháp dùng toán tử new để cấp phát một biến động kiểu int và một mảng động 1 chiều kiểu double có 10 phần tử.

5. Toán tử delete và delete[] khác nhau như thế nào?

6. Vì sao sau khi gọi delete ta nên gán con trỏ về nullptr?

7. Viết đoạn code minh họa cách cấp phát mảng động 2 chiều có m dòng và n cột bằng toán tử new.

8. Trình bày cú pháp giải phóng mảng động 2 chiều này.

9. Cho đoạn code:

int x = 10;
int *p1, *p2 = &x;
p1 = p2;
*p1 = 20;

Sau khi thực thi, giá trị của x là:

  • A. 10
  • B. 20
  • C. Không xác định
  • D. Lỗi biên dịch

10. Con trỏ kiểu void* có đặc điểm nào sau đây?

  • A. Chỉ trỏ được đến biến kiểu int
  • B. Có thể trỏ đến bất kỳ kiểu dữ liệu nào nhưng cần ép kiểu khi sử dụng
  • C. Không thể khai báo trong C++
  • D. Tự động giải phóng bộ nhớ

Tài liệu tham khảo

  1. UIT - Con trỏ
  2. UIT - Con trỏ cấp phát động