Bài 13: Con trỏ - Lý thuyết
Giới thiệu
- Con trỏ cung cấp một cách thức truy xuất biến mà không tham chiếu trực tiếp đến biến. Nó cung cấp cách thức sử dụng địa chỉ. Bài này sẽ đề cập đến các khái niệm về con trỏ và cách sử dụng chúng trong C.
1. Con trỏ là gì?
- Một con trỏ là một biến, nó chưa địa chỉ vùng nhớ của một biến khác, chứ không lưu trữ giá trị của biến đó. Nếu một biến chứa địa chỉ của một biến khac, thì biến này được gọi là con trỏ đến biến thứ hai kia. Một con trỏ cung cấp phương thức gián tiếp để truy xuất giá trị của các phần tử dữ liệu. Các con trỏ có thể trỏ đến các biến của các kiểu dữ liệ cớ sở như int, char, hay double hoặc dữ liệu có cấu trúc như mảng.
2. Tại sao con trỏ được dùng?
- Con trỏ có thể được sủ dụng trong một số trường hợp sau:
+ Để trả về nhiều hơn một giá trị từ một hàm.
+ Thuận tiện hơn trong việc truyền các mảng và chuỗi từ một hàm đến một hàm khác.
+ Sử dụng con trỏ để làm việc với các phần tử của mảng thay vì truy xuất trực tiếp vào các phần tử này98.
+ Để cấp phát bộ nhớ động và truy xuất vào vùng nhớ được cấp phát này (dynamic memory allocation)
3. Các biến con trỏ
- Nếu một biến được sử dụng như một con trỏ, nó phải được khai báo trước. Câu lệnh khai báo con trỏ bao gồm một kiểu dữ liệu cơ bản, một dấu *, và một tên biến. Cú pháp tổng quát để khai báo một biến con trỏ như sau:
- Ở đó type là một kiểu dữ liệu hợp lệ bất kỳ, và name là tên của biến con trỏ. Câu lện khai báo trên nói với trình biên dịch là name được sử dụng để lưu địa chỉ của một biến dữ liệu type. Trong câu lệnh khai báo, * xác định rằng một biến con trỏ đang được khai báo.
- Kiểu dữ liệu cơ sở của con trỏ xác định kiểu của biến mà con trỏ trỏ đến. Về mặt kỹ thuật, một con trỏ có kiểu bất kỳ có thể trỏ đến bất kỳ vị trí nào trong bộ nhớ. Tuy nhiên, tất cả các phép toán số học trên con trỏ đều có liên quan đến kiểu cơ sở của nó, vì vậy khai báo kiểu dữ liệu của con trỏ một cách rõ ràng là điều rất quan trọng.
4. Các toán tử con trỏ
- Có hai toán tử đặc biệt được dùng với con trỏ: * và &. Toán tử & là một toán tử một ngôi và nó trả về địa chỉ của toán hạng. Toán tử thứ hai, toán tử *, được dùng với con trỏ là phần bổ xung của toán tử &. Nó là một toán tử một ngôi và trả về giá trị chứa trong vùng nhớ được trỏ bởi giá trị của biến con trỏ. Cả hai toán tử * và & có độ ưu tiên cao hơn tất cả các toán tử toán học ngoại trừ toán tử lấy giá trị âm. Chúng có cùng độ ưu tiên với toán tử với toán tử lấy giá trị âm
5. Gán giá trị cho con trỏ
- Các giá trị có thể được gán cho biến con trỏ thông qua toán tử &. Câu lệnh gán sẽ là:
Code:
pointer_variable = &variable;
- Lúc này địa chỉ variable được lưu trong biến pointer_variable. Cũng có thể gán giá trị cho con trỏ thông qua một biến con trỏ khác trỏ đến phần tử dữ liệu có cùng kiểu.
Code:
pointer_variable = &variable;
pointer_variable2 = pointer_variable;
- Giá trị NULL cũng có thể được gán đến một biến con trỏ bằng số 0 như sau:
Code:
pointer_variable = 0;
- Các biến cũng có thể được gán giá trị thông qua con trỏ của chúng.
Code:
*pointer_variable = 10;
- Nói chung, các biểu thức có chứa con trỏ cũng theo cùng quy luật như các biểu thức khác trong C. Điều quan trọng cần chú ý phải gán giá trị cho biến con trỏ trước khi sử dụng chúng; nếu không chúng có thể trỏ đến một giá trị không xác định nào đó.
6. Phép toán số học con trỏ
- Chỉ phép cộng và trừ là các toán tử có thể được thực hiện trên các con trỏ, dưới đây là bảng liệt kê một vài ví dụ:

- Mỗi khi con trỏ được tăng giá trị, nó sẽ trỏ đến ô nhớ của phần tử kế tiếp. Mỗi khi nó được giảm giá trị, nó sẽ trỏ đến vị trí của phần tử đứng trước nó. Với những con trỏ trỏ tới các ký tự, nó xuất hiện bình thường, bởi vì mỗi ký tự chiếm 1 byte. Tuy nhiên, tất cả những con trỏ khác sẽ tăng hoặc giảm trị tùy thuộc và độ dài kiểu dữ liệu mà chúng trỏ tới. Trong các ví dụ ở bảng trên, ngoài các toán tử tăng trị và giảm trị, các số nguyên cũng có thể được cộng vào và trừ ra với con trỏ. Ngoài phép cộng và trừ một con trỏ với một số nguyên, không có một phép toán nào khác có thể thực hiện được trên các con trỏ. Nói rõ hơn, các con trỏ không thể được nhân hoặc chia. Cũng như kiểu float và double không thể được cộng hoặc trừ với con trỏ.
7. So sánh con trỏ
- Hai con trỏ có thể so sánh trong một biểu thức quan hệ. Tuy nhiên, điều này chỉ có thể nếu cả hai biến này đều trỏ đến các biến có cùng kiểu dữ liệu. Ta giả sử có 2 biến con trỏ ptr_a và ptr_b là hai biến có cùng kiểu dữ liệu và đều trỏ đến các phần tử dữ liệu là a và b. Trong trường hợp này, các phép so sánh sau đây là có thể thực hiện được, đây là bảng liệt kê.

- Tương tự ta có 2 con trỏ ptr_begin và ptr_end trỏ đến các phần tử của cùng một mảng thì ptr_end - ptr_begin sẽ trả về số bytes cách biệt giữa hai vị trí mà chúng trỏ đến.
8. Con trỏ và mảng một chiều
- Tên của một mảng thật ra là một con trỏ trỏ đến phần tử đầu tiên của mảng đó. Vì vậy, nếu ary là một mảng một chiều, thì địa chỉ địa chỉ phần tử đầu tiên trong mảng có thể được biểu diễn là &ary[0] hoặc đơn giản chỉ là ary. Tương tự, địa chỉ của phần tử mảng thứ hai có thể được viết như &ary[0] hoặc ary+1,... Tổng quát, địa chỉ của phần tử mảng thứ (i+1) có thể được biểu diễn là &ary[i] hoặc hay (ary+i). Như vậy, địa chỉ của một phần tử mảng bất kỳ có thể được biểu diễn theo hai cách:
+ Sử dụng ký hiệu & trước mỗi phần tử trong mảng
+ Sử dụng một biểu thức trong đó chỉ số được cộng vào tên của mảng, với giá trị của chỉ số cộng vào là một số nguyên.
9. Con trỏ và mảng nhiều chiều
- Một mảng nhiều chiều cũng có thể được biểu diễn dưới dạng con trỏ của mảng một chiều (tên của mảng) và một độ dời chỉ số. Thực hiện điều này là bởi vì một mảng nhiều chiều là một tập hợp của các mảng một chiều. Cú pháp như sau:
Code:
data_type (*ptr_var) [expr 2]...[expr N];
Thay vì
Code:
data_type [expr 1] [expr 2]...[expr N];
- Trong khai báo trên data_type là kiểu dữ liệu của mảng, ptr_var là tên của biến con trỏ, array là tên mảng, và expr 1, expr 2, expr 3, ... expr N là các giá trị nguyên dương xác định số lượng tối đa các phần tử mảng được kết hợp với mỗi chỉ số. Chú ý dấu ngoặc () bao quanh tên mảng và dấu * phía trước trên mảng trong cách khai báo theo dạng con trỏ. Cặp dấu ngoặc () là không thể thiếu, ngược lại cú pháp khai báo sẽ khai báo một mảng của các con trỏ chứ không phải một con trỏ của một nhóm các mảng.
- Có nhiều cách thức để định nghĩa mảng, và có nhiều cách để xử lý các phần tử mảng. Lựa chọn cách thức nào tùy thuộc và người dùng. Tuy nhiên, trong các ứng dụng có các mảng dạng số, định nghĩa mảng theo cách thông thường sẽ dễ dàng hơn.
10. Con trỏ và chuỗi
- Chuỗi đơn giản chỉ là một mảng một chiều có kiểu ký tự. Mảng và con trỏ có mối liên hệ mật thiết, và như vậy, một cách tự nhiên chuỗi cũng sẽ có mối liên hệ mật thiết với con trỏ. Xem trường hợp hàm strchr(), hàm này nhận các tham số là một chuỗi và một ký tự để tìm kiếm ký tự đó trong mảng nghĩa là,
Code:
ptr_str = strchr(str1, 'a');
- Biến con trỏ ptr_str sẽ được gán địa chỉ của ký tự 'a' đầu tiên xuất hiện trong chuỗi str. Đây không phải là vị trí trong chuỗi, từ 0 đến cuối chuỗi, mà là địa chỉ, từ địa chỉ bắt đầu chuỗi đến địa chỉ kết thúc của chuỗi.
11. Cấp phát bộ nhớ
- Cho đến thời điểm này thì chúng ta đã biết đến trên của một mảng thật ra là một con trỏ trỏ tới phần tử đầu tiên của mảng. Hơn nữa, ngoài cách định nghĩa một mảng thông thường có thể định nghĩa một mảng như một biến con trỏ. Tuy nhiên, nếu một mảng được khai báo một cách bình thường, kết quả là một khối bộ nhớ cố định được dành sẵn tại thời điểm bắt đầu thực thi chương trình, trong khi điều này không xảy ra nếu mảng được khai báo như là một biến con trỏ. Sử dụng một biến con trỏ để biểu diễn một mảng đòi hỏi việc gán một vài ô nhớ khởi tạo trước khi các phần tử mảng được xử lý. Sự cấp phát bộ nhớ như vậy thông thường được thực hiện bằng cách sử dụng hàm thư viện.
+ malloc() : cấu trúc câu lệnh như sau:
Code:
pointer_name = malloc(block_of_memory * sizeof(type_data));
- Trong đó pointer_name là một con trỏ, block_of_memory là khối bộ nhớ mà ta sẽ cung cấp cho mảng được nhân với sizeof(type_date) là kiểu dữ liệu được cung cấp cho mảng con trỏ.
+ free() : hàm này có thể được sử dụng để giải phóng bộ nhớ khi nó không còn cần thiết. Dạng tổng quát của hàm free() là:
Code:
void free(void *ptr);
- Hàm free() giải phóng không gian được trỏ bởi ptr, không gian được giải phóng này có thể sử dụng cho việc cấp phát hoặc lưu trữ sau này
+ calloc() : cũng tương tự như malloc, nhưng khác biệt chính là mặc nhiên các giá trị được lưu trong không gian bộ nhớ đã cấp phát là 0. Với malloc, cấp phát bộ nhớ có thể có giá trị bất kỳ, đây là cấu trúc chung cho hàm calloc():
Code:
void * calloc(size_t num, size_t size);
- calloc đòi hỏi hai đối số. Đối số thứ nhất là số các biến mà bạn muốn cấp phát bộ nhớ cho. Đối số thứ hai là kích thước của mỗi biến, cũng giống như malloc, calloc sẽ trả về một con trỏ rỗng (void) nếu sự cấp phát bộ nhớ là thành công, ngược lại nó sẽ trả về một con trỏ NULL.
+ realloc() : giả sử chúng ta đã cấp phát một số bytes cho một mảng nhưng sau đó nhận ra là bạn muốn thêm các giá trị. Bạn có thể sao chép mọi thứ vào một mảng lớn hơn, cách này không hiệu quả. Hoặc bạn có thể cấp phát thêm cá bytes sử dụng bằng cách họi hàm realloc, mà dữ liệu của bạn không bị mất đi.
- relloc() nhận hai đối số. Đối số thứ nhất là một con trỏ tham chiếu đến bộ nhớ. Đối số thứ hai là tổng số bytes bạn muốn cấp phát thêm.
Code:
void * realloc(void *ptr, size_t size);
- Nếu ta truyền giá trị 0 vào đối số thứ hai thì tương đương với việc gọi hàm free(). Hảm realloc trả về một con trỏ rỗng (void) nếu thành công, ngược lại một con trỏ NULL được trả về.