1. Khái niệm về Con trỏ
Trong lập trình C, con trỏ là một khái niệm cơ bản và mạnh mẽ. Để hiểu rõ hơn, chúng ta cần phân biệt hai ý nghĩa chính của từ "con trỏ".
1.1. Con trỏ và Biến con trỏ
- Con trỏ (Pointer): Thuật ngữ "con trỏ" thực chất là một địa chỉ bộ nhớ. Mỗi ô nhớ trong máy tính có một địa chỉ duy nhất, và con trỏ chính là những số định danh này.
- Biến con trỏ (Pointer Variable): Đây là một biến đặc biệt dùng để lưu trữ các địa chỉ bộ nhớ. Khi chúng ta nói "khai báo một con trỏ", ý chúng ta thường là khai báo một biến con trỏ.
Ví dụ về khai báo một biến con trỏ:
int* my_ptr = NULL; // Biến con trỏ 'my_ptr' có kiểu 'int*' và lưu trữ địa chỉ của một số nguyên.
char *text_ptr = NULL; // Biến con trỏ 'text_ptr' có kiểu 'char*' và lưu trữ địa chỉ của một ký tự.
Dù có hai cách viết char* p hoặc char *p, cả hai đều hợp lệ. Tuy nhiên, char* p thường được ưu tiên vì nó nhấn mạnh rằng p là một biến thuộc kiểu dữ liệu char* (con trỏ tới ký tự).
2. Ý nghĩa của Kiểu dữ liệu Con trỏ
Trên các hệ thống 32-bit, mọi địa chỉ bộ nhớ (và do đó mọi con trỏ) đều có kích thước 4 byte. Vậy tại sao chúng ta vẫn cần các kiểu con trỏ khác nhau như int*, char*, hay float*?
2.1. Bước nhảy khác nhau của Con trỏ
Kiểu dữ liệu của con trỏ quy định kích thước "bước nhảy" khi thực hiện các phép toán cộng trừ số nguyên với con trỏ. Khi cộng hoặc trừ một số nguyên N vào một con trỏ, con trỏ đó sẽ dịch chuyển N * sizeof(kiểu_dữ_liệu_của_con_trỏ) byte trong bộ nhớ.
Ví dụ minh họa:
#include <stdio.h>
int main() {
int data_val = 12345;
char *char_ptr = (char*)&data_val; // Con trỏ kiểu char*
int *int_ptr = &data_val; // Con trỏ kiểu int*
printf("Địa chỉ của data_val: %p\n", &data_val);
printf("Địa chỉ của char_ptr: %p\n", char_ptr);
printf("Địa chỉ của char_ptr + 1: %p (dịch 1 byte)\n", char_ptr + 1);
printf("Địa chỉ của int_ptr: %p\n", int_ptr);
printf("Địa chỉ của int_ptr + 1: %p (dịch 4 byte)\n", int_ptr + 1); // int là 4 byte
return 0;
}
2.2. Quyền truy cập khác nhau khi giải tham chiếu
Kiểu dữ liệu của con trỏ cũng xác định số lượng byte mà con trỏ có thể truy cập hoặc thay đổi khi được giải tham chiếu (dùng toán tử *).
Ví dụ:
#include <stdio.h>
int main() {
int value = 0x11223344; // Giả sử kiến trúc little-endian
char *c_ptr = (char*)&value;
int *i_ptr = &value;
printf("Giá trị ban đầu: 0x%08x\n", value); // Output: 0x11223344
*c_ptr = 0x00; // Thay đổi byte thấp nhất (0x44 -> 0x00)
printf("Sau khi *c_ptr = 0x00: 0x%08x\n", value); // Output: 0x11223300
*i_ptr = 0x00000000; // Thay đổi toàn bộ 4 byte
printf("Sau khi *i_ptr = 0x00000000: 0x%08x\n", value); // Output: 0x00000000
return 0;
}
Tóm lại, kiểu dữ liệu của con trỏ ảnh hưởng đến hai yếu tố quan trọng: kích thước bước nhảy trong các phép toán con trỏ và phạm vi byte mà con trỏ có thể truy cập khi giải tham chiếu.
3. Con trỏ "hoang dã" (Wild Pointers)
Con trỏ "hoang dã" hay "con trỏ dở" (wild pointer) là những con trỏ trỏ đến một vị trí bộ nhớ không hợp lệ hoặc không thuộc quyền sở hữu của chương trình. Việc sử dụng chúng có thể dẫn đến lỗi nghiêm trọng hoặc hành vi không xác định.
3.1. Con trỏ chưa được khởi tạo
Khi một biến con trỏ cục bộ không được khởi tạo, nó sẽ chứa một giá trị ngẫu nhiên (rác), có thể là một địa chỉ không hợp lệ. Việc cố gắng giải tham chiếu con trỏ này sẽ dẫn đến lỗi.
#include <stdio.h>
int main() {
int *invalid_ptr; // Biến con trỏ cục bộ chưa được khởi tạo
// invalid_ptr chứa một địa chỉ ngẫu nhiên
*invalid_ptr = 100; // Lỗi! Cố gắng ghi vào một địa chỉ không xác định/không hợp lệ
return 0;
}
3.2. Con trỏ truy cập ngoài giới hạn mảng
Khi một con trỏ được sử dụng để duyệt qua một mảng nhưng vượt ra ngoài phạm vi của mảng đó, nó trở thành con trỏ "hoang dã".
#include <stdio.h>
int main() {
int my_array[5] = {0}; // Mảng 5 phần tử, chỉ số từ 0 đến 4
int *current_pos = my_array;
for(int i = 0; i <= 5; i++) { // Lặp đến i = 5, vượt quá giới hạn mảng (my_array[5] không tồn tại)
*(current_pos++) = i * 10; // Khi i = 5, current_pos trỏ ra ngoài mảng
}
// Sau vòng lặp, current_pos đã vượt ra ngoài vùng nhớ hợp lệ của my_array
printf("Giá trị cuối cùng của my_array[4]: %d\n", my_array[4]); // Vẫn hợp lệ
// printf("Giá trị tại current_pos (sau khi vượt giới hạn): %d\n", *current_pos); // KHÔNG AN TOÀN!
return 0;
}
3.3. Con trỏ trỏ đến vùng nhớ đã được giải phóng
Khi bộ nhớ được cấp phát động (ví dụ bằng malloc) và sau đó được giải phóng (bằng free), con trỏ ban đầu vẫn giữ địa chỉ của vùng nhớ đó. Nếu cố gắng truy cập vùng nhớ này sau khi nó đã được giải phóng, con trỏ đó trở thành con trỏ "hoang dã" (dangling pointer).
#include <stdio.h>
#include <stdlib.h> // Cần cho malloc và free
int main() {
int* data_ptr = (int*)malloc(sizeof(int)); // Cấp phát 4 byte
if (data_ptr == NULL) {
printf("Không thể cấp phát bộ nhớ!\n");
return 1;
}
*data_ptr = 50; // Ghi giá trị vào vùng nhớ
printf("Giá trị trước khi giải phóng: %d\n", *data_ptr);
free(data_ptr); // Giải phóng vùng nhớ. data_ptr bây giờ là dangling pointer.
// Cố gắng truy cập vùng nhớ đã được giải phóng
// printf("Giá trị sau khi giải phóng: %d\n", *data_ptr); // HÀNH VI KHÔNG XÁC ĐỊNH!
// Để tránh lỗi, nên gán NULL cho con trỏ sau khi free
data_ptr = NULL;
return 0;
}
3.4. Cách phòng tránh Con trỏ "hoang dã"
Để đảm bảo an toàn khi làm việc với con trỏ, hãy tuân thủ các nguyên tắc sau:
- Luôn khởi tạo con trỏ: Gán
NULLhoặc một địa chỉ hợp lệ ngay khi khai báo. - Kiểm soát giới hạn: Đảm bảo con trỏ không vượt ra ngoài vùng nhớ hợp lệ, đặc biệt khi làm việc với mảng hoặc cấp phát động.
- Gán
NULLsau khi giải phóng: Sau khi gọifree(), hãy gánNULLcho biến con trỏ để nó không còn trỏ đến vùng nhớ đã giải phóng. - Tránh trả về địa chỉ của biến cục bộ: Biến cục bộ sẽ bị hủy khi hàm kết thúc, làm cho con trỏ trỏ đến chúng trở thành "hoang dã".
- Kiểm tra con trỏ trước khi sử dụng: Luôn kiểm tra xem con trỏ có phải là
NULLhay không trước khi giải tham chiếu nó.
#include <stdio.h>
int main() {
int *safe_ptr = NULL; // Luôn khởi tạo con trỏ
int temp_val = 77;
safe_ptr = &temp_val; // Gán một địa chỉ hợp lệ
if (safe_ptr != NULL) { // Kiểm tra tính hợp lệ trước khi sử dụng
*safe_ptr = 88;
printf("Giá trị của temp_val: %d\n", temp_val);
} else {
printf("Con trỏ không hợp lệ!\n");
}
return 0;
}
4. Các phép toán với Con trỏ
4.1. Con trỏ cộng/trừ với số nguyên
Khi bạn cộng hoặc trừ một số nguyên N vào một con trỏ ptr, địa chỉ mà ptr trỏ tới sẽ thay đổi theo công thức: ptr + N * sizeof(*ptr). Điều này rất hữu ích khi duyệt qua các phần tử trong một mảng.
#include <stdio.h>
int main() {
float sensor_readings[4] = {10.5f, 20.3f, 5.7f, 15.0f};
float *current_reading_ptr;
// Khởi tạo tất cả giá trị về 0.0f
for (current_reading_ptr = &sensor_readings[0];
current_reading_ptr < &sensor_readings[4]; // Điều kiện dừng an toàn: trỏ đến ngay sau phần tử cuối cùng
current_reading_ptr++) {
*current_reading_ptr = 0.0f;
}
printf("Các giá trị sau khi đặt về 0:\n");
for (int i = 0; i < 4; i++) {
printf("%.1f ", sensor_readings[i]);
}
printf("\n");
return 0;
}
4.2. Con trỏ trừ Con trỏ
Phép toán trừ giữa hai con trỏ (cùng kiểu và trỏ vào cùng một mảng hoặc khối bộ nhớ) sẽ trả về số lượng phần tử giữa hai địa chỉ đó.
#include <stdio.h>
// Hàm tính độ dài chuỗi bằng phép trừ con trỏ
size_t custom_strlen(const char *input_str) {
const char *start_ptr = input_str;
while (*input_str != '\0') {
input_str++;
}
return input_str - start_ptr; // Trả về số ký tự
}
int main() {
char greeting[] = "Hello C Pointers!";
printf("Độ dài chuỗi \"%s\" là: %zu\n", greeting, custom_strlen(greeting));
int numbers[] = {10, 20, 30, 40, 50};
int *ptr1 = &numbers[1]; // Trỏ tới 20
int *ptr2 = &numbers[4]; // Trỏ tới 50
// Số phần tử giữa numbers[4] và numbers[1] là 3 (numbers[2], numbers[3], numbers[4])
printf("Khoảng cách giữa ptr2 và ptr1 (số phần tử): %td\n", ptr2 - ptr1); // Output: 3
return 0;
}
Lưu ý: Không có phép toán cộng giữa hai con trỏ vì nó không có ý nghĩa ngữ nghĩa trong quản lý bộ nhớ.
4.3. Lưu ý về Phép toán con trỏ
Tiêu chuẩn C cho phép so sánh một con trỏ trỏ đến một phần tử mảng với một con trỏ trỏ đến vị trí ngay sau phần tử cuối cùng của mảng. Tuy nhiên, việc so sánh con trỏ trỏ đến phần tử đầu tiên với một địa chỉ trước phần tử đầu tiên là hành vi không xác định.
Tránh các vòng lặp duyệt ngược không an toàn như sau:
#define ARRAY_SIZE 5
int data_items[ARRAY_SIZE];
int *data_ptr;
// KHÔNG NÊN DÙNG: Có thể gây ra hành vi không xác định
// Vì data_ptr có thể trỏ đến vị trí trước data_items[0]
for(data_ptr = &data_items[ARRAY_SIZE]; data_ptr > &data_items[0]; ) {
*(--data_ptr) = 0;
}
Thay vào đó, hãy sử dụng cách duyệt ngược an toàn hơn:
// Cách duyệt ngược an toàn và rõ ràng
for(data_ptr = &data_items[ARRAY_SIZE - 1]; data_ptr >= &data_items[0]; data_ptr--) {
*data_ptr = 0;
}
// Hoặc duyệt từ đầu mảng nếu muốn truy cập từ data_items[0]
// for(int i = 0; i < ARRAY_SIZE; i++) {
// data_items[i] = 0;
// }
5. Con trỏ cấp hai và Mảng con trỏ
5.1. Con trỏ cấp hai (Double Pointers)
Con trỏ cấp hai là một biến con trỏ dùng để lưu trữ địa chỉ của một biến con trỏ khác. Nó thường được sử dụng khi bạn cần thay đổi giá trị của một con trỏ thông qua một hàm, hoặc khi làm việc với mảng hai chiều.
#include <stdio.h>
int main() {
int variable_val = 200;
int* ptr_to_val = &variable_val; // ptr_to_val chứa địa chỉ của variable_val
int** ptr_to_ptr = &ptr_to_val; // ptr_to_ptr chứa địa chỉ của ptr_to_val
printf("Giá trị ban đầu của variable_val: %d\n", variable_val);
// Sử dụng con trỏ cấp hai để thay đổi ptr_to_val (con trỏ cấp một)
// Giả sử có một biến int khác
int another_val = 300;
*ptr_to_ptr = &another_val; // Tương đương với: ptr_to_val = &another_val;
// Bây giờ ptr_to_val trỏ tới another_val
printf("variable_val sau khi gán lại ptr_to_val: %d\n", variable_val); // Vẫn là 200
printf("Giá trị mà *ptr_to_val trỏ tới: %d\n", *ptr_to_val); // Bây giờ là 300 (another_val)
// Sử dụng con trỏ cấp hai để thay đổi giá trị mà con trỏ cấp một trỏ tới
**ptr_to_ptr = 400; // Tương đương với: *ptr_to_val = 400; HOẶC another_val = 400;
printf("Giá trị của another_val sau khi thay đổi qua **ptr_to_ptr: %d\n", another_val); // Output: 400
printf("Giá trị của variable_val (không đổi): %d\n", variable_val); // Output: 200
return 0;
}
5.2. Mảng con trỏ (Array of Pointers)
Mảng con trỏ là một mảng mà mỗi phần tử của nó là một con trỏ. Các phần tử này có thể trỏ đến các vị trí khác nhau trong bộ nhớ, hoặc đến các chuỗi ký tự, hoặc các mảng khác. Bản chất nó vẫn là một mảng, nhưng nội dung của các ô nhớ của mảng này là các địa chỉ.
#include <stdio.h>
int main() {
// Mảng chứa các số nguyên thông thường
int numbers_plain[3] = {1, 2, 3};
char chars_plain[4] = {'a', 'b', 'c', '\0'};
// Mảng con trỏ tới số nguyên
int* int_ptr_array[3]; // Một mảng 3 phần tử, mỗi phần tử là một con trỏ int*
int val_a = 10, val_b = 20, val_c = 30;
int_ptr_array[0] = &val_a;
int_ptr_array[1] = &val_b;
int_ptr_array[2] = &val_c;
printf("Truy cập qua mảng con trỏ int:\n");
for (int i = 0; i < 3; i++) {
printf("Phần tử %d: %d\n", i, *int_ptr_array[i]);
}
// Mảng con trỏ tới ký tự (thường dùng cho mảng chuỗi)
char* string_array[3] = {"Apple", "Banana", "Cherry"}; // Mảng các con trỏ char*
// Mỗi con trỏ trỏ đến đầu một chuỗi ký tự
printf("\nTruy cập qua mảng con trỏ char (mảng chuỗi):\n");
for (int i = 0; i < 3; i++) {
printf("Chuỗi %d: %s\n", i, string_array[i]);
}
return 0;
}