Con trỏ trong C (Pointers)
- 21-10-2023
- Toanngo92
- 0 Comments
Mục lục
Giới thiệu
Con trỏ cung cấp một cách để truy cập một biến mà không cần tham chiếu trực tiếp đến biến đó. Chúng cung cấp một cách biểu tượng để sử dụng địa chỉ. Buổi này xử lý về khái niệm con trỏ và cách sử dụng chúng trong ngôn ngữ lập trình C. Ngoài ra, chúng tôi sẽ thảo luận một số khái niệm liên quan đến con trỏ.
Con Trỏ Là Gì?
Một con trỏ là một biến chứa địa chỉ của một vị trí bộ nhớ của một biến khác, thay vì giá trị đã lưu của biến đó. Nếu một biến chứa địa chỉ của một biến khác, thì biến đầu tiên được gọi là con trỏ đến biến thứ hai. Con trỏ cung cấp một cách gián tiếp để truy cập giá trị của một mục dữ liệu. Hãy xem xét hai biến var1 và var2 sao cho var1 có giá trị là 500 và nằm trong vị trí bộ nhớ 1000. Nếu var2 được khai báo là một con trỏ đến biến var1, thì biểu diễn sẽ như sau:
Vị trí bộ nhớ | Giá trị được lưu trữ | Tên biến |
1000 | 500 | var1 |
1001 | ||
1002 | ||
… | ||
1108 | 1000 | var2 |
Ở đây, var2 chứa giá trị 1000, đó chính là địa chỉ của biến var1.
Các con trỏ có thể trỏ đến biến của các kiểu dữ liệu cơ bản khác nhau như nguyên, thực, hoặc kép, cũng như đối tượng dữ liệu cấu trúc như mảng.
Các mục đích chính của con trỏ bao gồm:
- Truy cập và thay đổi trực tiếp dữ liệu tại một vị trí bộ nhớ cụ thể.
- Thực hiện các thay đổi trên tham số hàm.
- Duyệt qua mảng hoặc các tập hợp dữ liệu khác.
- Quản lý động bộ nhớ.
Biến Con Trỏ
Để sử dụng một biến làm con trỏ, bạn phải khai báo nó. Một khai báo con trỏ bao gồm một kiểu (type), một dấu *, và tên biến. Cú pháp chung để khai báo một biến con trỏ là:
type *name;
Trong đó, type là bất kỳ kiểu dữ liệu hợp lệ nào và name là tên của biến con trỏ. Khai báo cho biết cho trình biên dịch rằng name sẽ được sử dụng để lưu trữ địa chỉ của một giá trị tương ứng với kiểu dữ liệu type. Trong câu khai báo, dấu * cho biết rằng một biến con trỏ đang được khai báo.
Trong ví dụ trên của var1 và var2, do var2 là một con trỏ, nó chứa giá trị của biến var1, nên nó sẽ được khai báo như sau:
int *var2;
Bây giờ, var2 có thể được sử dụng trong chương trình để truy cập giá trị của var1 một cách gián tiếp. Hãy nhớ rằng var2 không phải là kiểu int mà là một con trỏ tới một biến kiểu int.
Kiểu cơ bản của con trỏ xác định kiểu biến mà con trỏ có thể trỏ đến. Kỹ thuật, bất kỳ kiểu con trỏ nào cũng có thể trỏ đến bất kỳ vị trí nào trong bộ nhớ. Tuy nhiên, tất cả các phép tính con trỏ được thực hiện dựa trên kiểu cơ bản của nó, vì vậy quan trọng là phải khai báo con trỏ một cách chính xác.
Các Toán Tử Con Trỏ
Có hai toán tử đặc biệt được sử dụng với con trỏ: & và *. Toán tử & là một toán tử một ngôi và trả về địa chỉ bộ nhớ của toán hạng. Ví dụ:
var2 = &var1;
sẽ đặt địa chỉ bộ nhớ của var1 vào var2. Địa chỉ này là vị trí máy tính của biến var1 và không liên quan gì đến giá trị của var1. Toán tử & có thể hiểu là “trả về địa chỉ của“. Vì vậy, phép gán trên có nghĩa là “var2 nhận địa chỉ của var1“. Trong trường hợp này, giá trị của var2 là 300 và nó sử dụng vị trí bộ nhớ 1000 để lưu trữ giá trị này. Sau phép gán trên, var2 sẽ có giá trị là 1000.
Toán tử con trỏ thứ hai, * là phần bù của toán tử &. Nó cũng là một toán tử một ngôi và trả về giá trị chứa trong vị trí bộ nhớ mà con trỏ trỏ đến.
Hãy xem xét ví dụ trước, trong đó var1 có giá trị là 500 và được lưu trữ tại vị trí bộ nhớ 1000. Sau câu lệnh:
temp = *var2;
var1 chứa giá trị 1000 và sau phép gán temp sẽ chứa 500 chứ không phải là 1000. Toán tử * có thể hiểu là “tại địa chỉ“.
Cả & và * đều có mức độ ưu tiên cao hơn so với tất cả các phép toán số học khác, ngoại trừ toán tử phủ định một ngôi. Chúng có mức độ ưu tiên giống nhau như toán tử phủ định một ngôi.
Chương trình sau in ra giá trị của một biến số nguyên, địa chỉ của nó, được lưu trữ trong một biến con trỏ, và cũng in ra địa chỉ của biến con trỏ.
Ví dụ 1:
#include <stdio.h>
int main()
{
int var = 500, *ptr_var;
ptr_var = &var;
printf("The value %d is stored at address: %u", var, &var);
printf("\nThe value %d is stored at address: %u", *ptr_var, ptr_var);
printf("\nThe value %d is stored at address: %u", *ptr_var, ptr_var);
return 0;
}
Kết quả mẫu cho ví dụ trên sẽ là:
The value 500 is stored at address: 65500
The value 65500 is stored at address: 65502
The value 500 is stored at address: 65500
Trong ví dụ trên, ptr_var chứa địa chỉ 65500, đó là một vị trí bộ nhớ nơi giá trị của var được lưu trữ. Nội dung của vị trí bộ nhớ này (65500) có thể được lấy bằng cách sử dụng *ptr_var. Bây giờ *ptr_var đại diện cho giá trị 500, đó là giá trị của var. Vì ptr_var cũng là một biến, địa chỉ của nó có thể được in ra bằng cách sử dụng ptr_var. Trong trường hợp trên, ptr_var được lưu trữ tại vị trí 65502. Bộ chuyển đổi %u in ra các đối số dưới dạng số nguyên không dấu.
Hãy nhớ rằng một số nguyên chiếm 2 byte bộ nhớ. Vì vậy, giá trị của var được lưu tại 65500 và trình biên dịch gán vị trí bộ nhớ kế tiếp 65502 cho ptr_var. Tương tự, một số thực sẽ yêu cầu 4 byte và một số thực độ chính xác kép có thể yêu cầu 8 byte. Biến con trỏ lưu trữ một giá trị số nguyên. Đối với hầu hết các chương trình sử dụng con trỏ, các loại con trỏ có thể được coi là giá trị 16 bit chiếm 2 byte.
Lưu ý rằng hai câu lệnh sau đây đưa ra cùng kết quả.
printf("The value is %d", var);
printf("The value is %d", *ptr_var);
Trong ví dụ trên, chúng ta đã sử dụng con trỏ để truy cập giá trị của biến var và in ra giá trị của biến, địa chỉ của biến và địa chỉ của con trỏ. Các toán tử & và * đều được sử dụng để làm việc với con trỏ.
Gán Giá Trị cho Con Trỏ
Giá trị có thể được gán cho con trỏ thông qua toán tử &. Câu lệnh gán sẽ như sau:
ptr_var = &var;
trong đó địa chỉ của var được lưu trữ trong biến ptr_var. Cũng có thể gán giá trị cho con trỏ thông qua một con trỏ khác trỏ đến một mục dữ liệu cùng loại.
ptr_var = ptr_var2;
Giá trị NULL cũng có thể được gán cho một con trỏ bằng cách sử dụng như sau:
ptr_var = NULL;
Biến có thể được gán giá trị thông qua con trỏ của chúng:
*ptr_var = 10;
trong đó sẽ gán giá trị 10 cho biến var nếu ptr_var trỏ đến var.
Nói chung, các biểu thức liên quan đến con trỏ tuân theo các quy tắc giống như các biểu thức C khác. Rất quan trọng để 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 bất kỳ giá trị không thể dự đoán nào.
Toán Tử Của Con Trỏ
Phép cộng và phép trừ là các phép toán duy nhất có thể thực hiện trên con trỏ. Ví dụ sau đây minh họa điều này:
int var, *ptr_var;
ptr_var = &var;
var = 500;
Trong ví dụ trên, hãy giả sử rằng var được lưu trữ tại địa chỉ 1000. Sau đó, ptr_var có giá trị 1000 lưu trữ trong nó. Vì số nguyên chiếm 2 byte, sau biểu thức:
ptr_var = ptr_var + 1;
ptr_var sẽ chứa 1002 và KHÔNG PHẢI 1001. Điều này có nghĩa là ptr_var hiện đang trỏ đến số nguyên lưu trữ tại địa chỉ 1002. Mỗi lần ptr_var được tăng, nó sẽ trỏ đến số nguyên tiếp theo và vì số nguyên chiếm 2 byte, nên một bước là 2 byte.
So sánh Con Trỏ
Hai con trỏ có thể được so sánh trong một biểu thức quan hệ. Tuy nhiên, điều này chỉ có thể thực hiện nếu cả hai biến này đang trỏ đến các biến cùng loại. Giả sử ptr_a và ptr_b là hai biến con trỏ, mỗi biến này trỏ đến các phần tử dữ liệu a và b. Trong trường hợp này, các so sánh sau có thể được thực hiện:
- ptr_a < ptr_b: Trả về true nếu a được lưu trữ trước b.
- ptr_a > ptr_b: Trả về true nếu a được lưu trữ sau b.
- ptr_a <= ptr_b: Trả về true nếu a được lưu trữ trước hoặc ở cùng một vị trí với b.
- ptr_a >= ptr_b: Trả về true nếu a được lưu trữ sau hoặc ở cùng một vị trí với b.
- ptr_a == ptr_b: Trả về true nếu cả hai con trỏ đều trỏ đến cùng một phần tử dữ liệu.
- ptr_a != ptr_b: Trả về true nếu cả hai con trỏ đều trỏ đến các phần tử dữ liệu khác nhau nhưng cùng loại.
Ngoài ra, nếu ptr_begin và ptr_end trỏ đến các thành viên của cùng một mảng thì:
ptr_end - ptr_begin
sẽ trả về số lượng phần tử giữa ptr_begin và ptr_end.
Con trỏ và Mảng Một Chiều
Tên mảng thực sự là một con trỏ đến phần tử đầu tiên trong mảng đó. Do đó, nếu ary là một mảng một chiều, địa chỉ của phần tử đầu tiên trong mảng có thể được biểu diễn dưới dạng cả ary[0] hoặc đơn giản là ary. Tương tự, địa chỉ của phần tử thứ hai trong mảng có thể được viết là ary[1] hoặc là ary + 1, và cứ thế. Nói chung, địa chỉ của phần tử thứ (i+1) trong mảng có thể được biểu diễn bằng cả &ary[i] hoặc là ary + i. Do đó, địa chỉ của một phần tử trong mảng có thể được biểu diễn theo hai cách:
- Bằng cách viết trực tiếp tên phần tử của mảng sau dấu “&” (ví dụ: &ary[0]).
- Bằng cách viết biểu thức trong đó số thứ tự của phần tử trong mảng được cộng vào tên mảng (ví dụ: ary + 0).
Hãy nhớ rằng trong biểu thức (ary + 4), ary thể hiện một địa chỉ, trong khi 4 thể hiện một số nguyên. Ngoài ra, ary là tên của một mảng có thể chứa các số nguyên, ký tự, số thực, và còn nữa (tất nhiên, tất cả các phần tử phải cùng kiểu). Vì vậy, biểu thức trên không phải là một phép cộng đơn giản, nó thực sự chỉ định một địa chỉ, là một số lượng cố định các ô nhớ beyond phần tử đầu tiên. Giá trị của i đôi khi được gọi là “offset” khi được sử dụng theo cách này.
Biểu thức ary[i] và (ary + 4) đều thể hiện địa chỉ của phần tử thứ i của ary, và do đó cả hai đều thể hiện nội dung của địa chỉ đó, tức là giá trị của phần tử thứ i của ary. Cả hai cụm từ này có thể thay thế cho nhau và có thể được sử dụng trong bất kỳ ứng dụng cụ thể nào mà lập trình viên muốn.
Chương trình sau đây thể hiện mối quan hệ giữa các phần tử trong mảng và địa chỉ của chúng:
#include <stdio.h>
void main() {
static int ary[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
for (int i = 0; i < 10; i++) {
printf("\n i = %d, ary[i] = %d, *(ary+i) = %d", i, ary[i], *(ary + i));
printf("&ary[i] = %X, ary + i = %X", &ary[i], ary+i);
}
}
Chương trình đã định nghĩa một mảng một chiều gồm 10 phần tử kiểu số nguyên ary, trong đó mỗi phần tử được gán giá trị tương ứng từ 1 đến 10. Vòng lặp for được sử dụng để hiển thị giá trị và địa chỉ tương ứng của từng phần tử trong mảng. Lưu ý rằng giá trị của từng phần tử được chỉ định bằng hai cách khác nhau, như ary[i] và như *(ary + i), nhằm minh họa tính tương đương của chúng. Tương tự, địa chỉ của từng phần tử trong mảng cũng được hiển thị theo hai cách khác nhau. Kết quả của chương trình sẽ như sau:
"i=0 ary[i]=1 *(ary + i)=1 ary[i]=194 ary[i]=194"
"i=1 ary[i]=2 *(ary + i)=2 ary[i]=196 ary[i]=196"
"i=2 ary[i]=3 *(ary + i)=3 ary[i]=198 ary[i]=198"
"i=3 ary[i]=4 *(ary + i)=4 ary[i]=19A ary[i]=19A"
"i=4 ary[i]=5 *(ary + i)=5 ary[i]=19C ary[i]=19C"
"i=5 ary[i]=6 *(ary + i)=6 ary[i]=19E ary[i]=19E"
"i=6 ary[i]=7 *(ary + i)=7 ary[i]=1A0 ary[i]=1A0"
"i=7 ary[i]=8 *(ary + i)=8 ary[i]=1A2 ary[i]=1A2"
"i=8 ary[i]=9 *(ary + i)=9 ary[i]=1A4 ary[i]=1A4"
"i=9 ary[i]=10 *(ary + i)=10 ary[i]=1A6 ary[i]=1A6"
Kết quả này rõ ràng thể hiện sự khác biệt giữa ary[i], biểu thị giá trị của phần tử mảng, và *(ary + i), biểu thị địa chỉ của phần tử mảng.
Khi gán giá trị cho một phần tử mảng như ary[4], phía bên trái của câu lệnh gán có thể được viết dưới dạng ary[4] hoặc *(ary + 4). Do đó, giá trị có thể được gán trực tiếp cho một phần tử mảng hoặc có thể được gán cho vùng nhớ mà địa chỉ của nó là địa chỉ của phần tử mảng. Đôi khi, cần phải gán một địa chỉ cho một biến. Trong tình huống đó, một con trỏ phải xuất hiện ở phía bên trái của câu lệnh gán. Không thể gán một địa chỉ tùy ý cho một tên mảng hoặc một phần tử mảng. Do đó, các biểu thức như ary, (ary + 4) và &ary[4] không thể xuất hiện ở phía bên trái của một câu lệnh gán. Hơn nữa, địa chỉ của một mảng không thể được thay đổi tùy ý, do đó các biểu thức như ary++ không được phép. Lý do cho điều này là ary là địa chỉ của mảng ary. Khi mảng được khai báo, trình liên kết đã quyết định nơi mảng này sẽ được đặt. Ví dụ, tại địa chỉ 1002. Một khi đã có địa chỉ này, nó sẽ ở đó. Thử tăng địa chỉ này không có ý nghĩa, giống như đoạn code sau:
x = 5++
Bởi vì một hằng số không thể được tăng lên, trình biên dịch sẽ thông báo lỗi.
Trong trường hợp của mảng ary, ary cũng được gọi là một Hằng số trỏ. Hãy nhớ rằng (ary + 1) không di chuyển mảng ary đến vị trí (ary + 1), nó chỉ trỏ đến vị trí đó, trong khi ary++ thực sự cố gắng di chuyển ary đi 1 vị trí.
Địa chỉ của một phần tử không thể được gán cho một phần tử mảng khác, mặc dù giá trị của một phần tử mảng có thể được gán cho một phần tử khác thông qua con trỏ.
&ary[2] = &ary[3]; /* Không được phép */
ary[2] = ary[3]; /* Được phép */
Hãy nhớ rằng hàm scanf() yêu cầu rằng các biến có kiểu dữ liệu cơ bản phải được đặt trước dấu & (ví dụ: &x), trong khi tên mảng được miễn cưỡng khỏi yêu cầu này. Điều này giờ đây sẽ dễ hiểu, scanf() yêu cầu phải chỉ định địa chỉ của các mục dữ liệu đang được nhập vào bộ nhớ máy tính. Như đã nói trước đó, dấu & thực sự đại diện cho địa chỉ của biến và vì vậy nó cần phải đứng trước một biến đơn giá trị. Dấu & không được yêu cầu với tên mảng bởi vì tên mảng thực sự đại diện cho địa chỉ. Tuy nhiên, nếu muốn đọc một phần tử đơn giá trị của mảng, thì nó sẽ cần một dấu & phía trước nó:
scanf("%d", &ary[0]); /* Cho phần tử đầu tiên của mảng /
scanf("%d", &ary[2]); / Cho một phần tử mảng */
Con trỏ và Mảng Đa Chiều
Tương tự như cách một mảng một chiều có thể được biểu thị dưới dạng một con trỏ (tên mảng) và một độ lệch (chỉ số), một mảng đa chiều cũng có thể được biểu thị bằng một biểu thức con trỏ tương đương. Điều này bởi vì một mảng đa chiều thực tế là một tập hợp của các mảng một chiều. Ví dụ, một mảng hai chiều có thể được định nghĩa như một con trỏ trỏ đến một nhóm các mảng một chiều liền kề. Một khai báo mảng hai chiều có thể được viết là:
data_type (*ptr_var)[expr2];
thay vì:
data_type array[expr1][expr2];
Khái niệm này có thể được tổng quát hóa thành các mảng đa chiều có số chiều cao hơn, tức là:
data_type (*ptr_var)[exp1][exp2]...[expM];
có thể được viết thay vì:
data_type array[exp1][exp2]...[expM];
Trong những khai báo này, data_type đề cập đến kiểu dữ liệu của mảng, ptr_var là tên của biến con trỏ, aray là tên mảng tương ứng và exp 1, exp 2, exp 3,… exp M là các biểu thức số nguyên dương dùng để chỉ định số lượng tối đa các phần tử mảng được liên kết với từng chỉ mục.
Hãy lưu ý dấu ngoặc đơn bao quanh tên mảng và dấu hoa thị đứng trước trong phiên bản con trỏ của mỗi khai báo. Dấu ngoặc đơn này phải có; nếu không, định nghĩa sẽ biểu thị một mảng con trỏ thay vì một con trỏ đến một nhóm mảng.
Ví dụ, nếu ary là một mảng hai chiều có 10 hàng và 20 cột, nó có thể được khai báo như sau:
int (*ary)[20];
thay vì:
int ary[10][20];
Trong định nghĩa đầu tiên, ary được xác định là một con trỏ đến một nhóm các mảng số nguyên một chiều kề nhau. Do đó, ary trỏ đến phần tử đầu tiên của mảng, thực tế là hàng đầu tiên (hàng 0) của mảng hai chiều gốc. Tương tự, (ary + 1) trỏ đến hàng thứ hai của mảng hai chiều gốc và tiếp tục như vậy.
Một mảng ba chiều số thực fl_ary có thể được định nghĩa như sau:
float (*fl_ary)[20][30];
thay vì:
float fl_ary[10][20][30];
Trong khai báo đầu tiên, fl_ary được xác định là một nhóm các mảng hai chiều 20 x 30 số thực liền nhau. Do đó, ary trỏ đến mảng 20 x 30 đầu tiên, (ary + 1) trỏ đến mảng 20 x 30 thứ hai và cứ tiếp tục như vậy.
Trong trường hợp của mảng hai chiều ary, phần tử ở hàng 4 và cột 9 có thể được truy cập bằng câu lệnh:
ary[3][8];
hoặc
*(*(ary+3) + 8);
Biểu thức đầu tiên là cách thông thường mà một mảng thường được tham chiếu. Trong biểu thức thứ hai, *(ary + 3) là một con trỏ đến hàng thứ 4. Do đó, đối tượng của con trỏ này, * (ary + 3), tham chiếu đến hàng toàn bộ. Vì hàng thứ 3 là một mảng một chiều, * (ary + 3) thực sự là một con trỏ đến phần tử đầu tiên trong hàng 3, sau đó ta thêm 2 vào con trỏ này. Do đó, * (*ary + 3) + 8) là một con trỏ đến phần tử thứ 9 trong hàng 4. Đối tượng của con trỏ này, *(*ary + 3) + 8), do đó tham chiếu đến phần tử ở cột 9 của hàng 4, đó là ary[3][8].
Có nhiều cách khác nhau để định nghĩa mảng và xử lý từng phần tử của mảng. Sự lựa chọn giữa một phương pháp so với một phương pháp khác thường phụ thuộc vào sở thích của người dùng. Tuy nhiên, trong các ứng dụng liên quan đến mảng số học, thường dễ dàng hơn khi định nghĩa mảng theo cách truyền thống.
Con Trỏ và Chuỗi
Chuỗi không gì khác ngoài mảng một chiều, và vì mảng và con trỏ có mối quan hệ mật thiết, nên việc chuỗi cũng sẽ mật thiết liên quan đến con trỏ. Hãy xem xét trường hợp của hàm strchr(). Hàm này nhận vào một chuỗi và một ký tự cần tìm kiếm trong chuỗi đó, tức là:
ptr_str = strchr(str1, 'a');
Biến con trỏ ptr_str sẽ được gán địa chỉ của lần xuất hiện đầu tiên của ký tự ‘a‘ trong chuỗi str1. Điều này không phải là vị trí trong chuỗi, từ 0 đến cuối chuỗi, mà là địa chỉ, từ nơi chuỗi bắt đầu đến cuối chuỗi.
Chương trình dưới đây sử dụng strchr() trong một chương trình cho phép người dùng nhập một chuỗi và một ký tự cần tìm kiếm. Chương trình in ra địa chỉ bắt đầu của chuỗi, địa chỉ của ký tự và vị trí của ký tự liên quan đến đầu của chuỗi (0 nếu nó là ký tự đầu tiên, 1 nếu nó là ký tự thứ hai và cứ thế). Vị trí liên quan này là sự khác biệt giữa hai địa chỉ, địa chỉ bắt đầu của chuỗi và địa chỉ nơi xuất hiện đầu tiên của ký tự.”
Ví dụ 3:
#include <stdio.h>
#include <string.h>
void main() {
char a, str[81], *ptr;
printf("Nhập một câu: ");
gets(str);
printf("Nhập ký tự cần tìm: ");
a = getchar();
ptr = strchr(str, a);
/* trả về con trỏ tới ký tự */
printf("\nCâu bắt đầu tại địa chỉ: %u", str);
if (ptr != NULL) {
printf("\nVị trí xuất hiện đầu tiên của ký tự ở địa chỉ: %u", ptr);
} else {
printf("\nKý tự không tồn tại trong câu.");
}
}
Một ví dụ thực thi sẽ như sau:
Nhập một câu: Chúng ta cùng sống trong một con tàu màu vàng
Nhập ký tự cần tìm: Y
Chuỗi bắt đầu tại địa chỉ: 65420
Xuất hiện đầu tiên của ký tự nằm tại địa chỉ: 65437
Vị trí của xuất hiện đầu tiên (bắt đầu từ 0) là: 17
Trong câu lệnh khai báo, một biến con trỏ ptr được tạo để lưu trữ địa chỉ được trả về bởi strchr(), vì đây là một địa chỉ của một ký tự (ptr là kiểu char).
Hàm strchr() không cần phải khai báo nếu bao gồm tệp string.h
Cấp phát Bộ Nhớ
Đến điểm này trong thời gian, đã được thiết lập rằng tên mảng thực chất là một con trỏ đến phần tử đầu tiên của mảng. Cũng có khả năng định nghĩa mảng như một biến con trỏ thay vì mảng truyền thống. Tuy nhiên, nếu một mảng được khai báo một cách truyền thống, điều này dẫn đến việc dành một khối bộ nhớ cố định ở đầu thực thi chương trình, trong khi điều này không xảy ra nếu mảng được đại diện dưới dạng biến con trỏ. Do đó, việc sử dụng biến con trỏ để đại diện cho mảng đòi hỏi một phần gán bộ nhớ ban đầu nào đó trước khi các phần tử của mảng được xử lý. Việc cấp phát bộ nhớ này thường được thực hiện bằng cách sử dụng hàm thư viện malloc().
Hàm malloc() không cần phải được khai báo trong tệp string.h nếu nó đã được bao gồm.
Hãy xem xét một ví dụ. Một mảng số nguyên một chiều tên là ary với 20 phần tử có thể được định nghĩa như sau:
int *ary;
thay vì
int ary[20];
Tuy nhiên, ary sẽ không được tự động gán một khối bộ nhớ khi nó được định nghĩa như một biến con trỏ, trong khi một khối bộ nhớ đủ để lưu trữ 20 số nguyên sẽ được đặt trước nếu ary được định nghĩa như một mảng. Nếu ary được định nghĩa như một con trỏ, bộ nhớ đủ sẽ được cấp phát như sau:
ary = malloc(20 * sizeof(int));
Đoạn mã sau sẽ cấp phát một khối bộ nhớ có kích thước (theo byte) tương đương với kích thước của một số nguyên. Ở đây, một khối bộ nhớ cho 20 số nguyên được cấp phát. Số 20 gán 20 byte (một cho mỗi số nguyên) và sau đó nhân với sizeof(int), sẽ trả về 2 nếu máy tính sử dụng 2 byte để lưu trữ một số nguyên. Nếu máy tính sử dụng 1 byte để lưu trữ một số nguyên, thì không cần sử dụng hàm sizeof(). Tuy nhiên, việc sử dụng nó luôn được ưa chuộng vì nó giúp đảm bảo tính di động của mã. Hàm malloc() trả về một con trỏ, đó là địa chỉ bắt đầu của bộ nhớ được cấp phát. Nếu không có đủ không gian bộ nhớ, malloc() trả về NULL. Cấp phát bộ nhớ theo cách này, tức là khi cần trong một chương trình, được gọi là cấp phát bộ nhớ động.
Trước khi đi vào chi tiết hơn, hãy thảo luận về khái niệm Cấp phát Bộ nhớ Động. Một chương trình có thể lưu trữ thông tin trong bộ nhớ chính của máy tính theo hai cách chính. Phương pháp đầu tiên liên quan đến: biến toàn cục và cục bộ – bao gồm cả mảng. Đối với biến toàn cục và biến tĩnh, lưu trữ là cố định suốt thời gian chạy của chương trình. Những biến này yêu cầu người lập trình biết trước lượng bộ nhớ cần thiết cho mọi tình huống. Cách thứ hai mà thông tin có thể được lưu trữ là thông qua Hệ thống Cấp phát Động. Trong phương pháp này, bộ nhớ cho thông tin được cấp từ bể bộ nhớ tự do khi cần.
Hàm malloc() là một trong những hàm phổ biến nhất cho phép cấp phát bộ nhớ từ bể bộ nhớ tự do. Tham số cho malloc() là một số nguyên xác định số byte cần thiết.
Là một ví dụ khác, hãy xem xét một mảng hai chiều kiểu ký tự có tên ch_ary có 10 hàng và 20 cột. Định nghĩa và cấp phát bộ nhớ trong trường hợp này sẽ như sau:
char (*ch_ary)[20];
ch_ary = (char*)malloc(10 * 20 * sizeof(char));
Như đã đề cập trước đó, malloc() trả về một con trỏ kiểu void. Tuy nhiên, vì ch_ary là một con trỏ kiểu char, việc ép kiểu là cần thiết. Trong câu lệnh trên, (char*) ép kiểu malloc() để trả về một con trỏ kiểu char.
Tuy nhiên, nếu khai báo mảng cần bao gồm gán giá trị ban đầu, thì mảng phải được định nghĩa theo cách truyền thống thay vì là biến con trỏ như sau:
int ary[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
hoặc
int ary[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Ví dụ dưới đây tạo một mảng một chiều động và sắp xếp mảng theo thứ tự tăng dần. Nó sử dụng con trỏ và hàm malloc() để cấp phát bộ nhớ.
Ví dụ 4:
#include <stdio.h>
#include <stdlib.h>
void main() {
int *ptr, n, i, j, temp;
printf("\nNhập số lượng phần tử trong mảng: ");
scanf("%d", &n);
ptr = (int*)malloc(n * sizeof(int));
for (i = 0; i < n; i++) {
printf("\nNhập phần tử số %d: ", i + 1);
scanf("%d", ptr + i);
}
for (i = 0; i < n - 1; i++) {
for (j = i + 1; j < n; j++) {
if (*(ptr + i) > *(ptr + j)) {
temp = *(ptr + i);
*(ptr + i) = *(ptr + j);
*(ptr + j) = temp;
}
}
}
printf("Mảng đã sắp xếp theo thứ tự tăng dần:\n");
for (i = 0; i < n; i++) {
printf("%d ", *(ptr + i));
}
free(ptr); // Giải phóng bộ nhớ đã cấp phát bởi malloc.
}
Lưu ý đến câu lệnh malloc():
ptr = (int*)malloc(n * sizeof(int));
Ở đây, ptr được khai báo là một con trỏ kiểu integer và sau đó cấp phát một lượng bộ nhớ sử dụng malloc(). Dữ liệu được đọc vào mảng bằng cách sử dụng scanf().
for (i = 0; i < n; i++) {
printf("\nNhập phần tử số %d: ", i + 1);
scanf("%d", ptr + i);
}
Dữ liệu được nhập từ bàn phím cho từng phần tử của mảng và được lưu trữ trong vùng bộ nhớ đã cấp phát.
Cuối cùng, các phần tử của mảng đã được sắp xếp theo thứ tự tăng dần và được hiển thị bằng cách sử dụng printf().
printf("%d\n",*(p+i));
Lưu ý dấu hoa thị (* – asterisk) trong trường hợp này. Điều này là do giá trị được lưu trữ tại vị trí cụ thể đó cần được hiển thị. Nếu thiếu dấu hoa thị (*), thì hàm printf() sẽ hiển thị địa chỉ mà giá trị (marks) được lưu trữ, chứ không phải giá trị thực sự.
free()
Hàm free() được sử dụng để giải phóng bộ nhớ khi không cần nữa. Đây là định dạng chung của hàm free():
void free(void *ptr);
Hàm free() giải phóng bộ nhớ được trỏ đến bởi con trỏ ptr, giúp giải phóng bộ nhớ để sử dụng cho mục đích khác. Lưu ý rằng con trỏ ptr phải đã được sử dụng trong một cuộc gọi trước đó cho các hàm như malloc(), calloc(), hoặc realloc().
Dưới đây là một ví dụ về cách sử dụng hàm malloc(), lưu trữ một số nguyên, sau đó giải phóng bộ nhớ bằng cách sử dụng free():
#include <stdio.h>
#include <stdlib.h> /* cần thiết cho các hàm malloc và free */
int main() {
int number;
int *ptr;
printf("Bạn muốn lưu trữ bao nhiêu số nguyên? ");
scanf("%d", &number);
ptr = (int *)malloc(number * sizeof(int)); /* cấp phát bộ nhớ */
if (ptr != NULL) {
/* Lưu trữ số nguyên vào bộ nhớ đã cấp phát */
for (int i = 0; i < number; i++) {
*(ptr + i) = i;
}
printf("Các số nguyên đã được lưu trữ:\n");
for (int i = number - 1; i >= 0; i--) {
printf("%d\n", *(ptr + i)); /* in ra theo thứ tự ngược lại */
}
/* Giải phóng bộ nhớ sau khi đã sử dụng xong */
free(ptr);
} else {
printf("Cấp phát bộ nhớ thất bại - không đủ bộ nhớ.\n");
}
return 0;
}
Kết quả nếu nhập 3 vào ví dụ trên sẽ là:
Bạn muốn lưu trữ bao nhiêu số nguyên? 3
Các số nguyên đã được lưu trữ:
2
1
0
Calloc()
calloc() là một hàm tương tự malloc(), nhưng khác biệt chính là các giá trị lưu trữ trong không gian bộ nhớ được cấp phát bằng calloc() sẽ có giá trị mặc định là 0. Với malloc(), giá trị trong không gian bộ nhớ cấp phát sẽ không xác định và có thể là bất kỳ giá trị nào.
calloc() yêu cầu hai đối số. Đối số đầu tiên là số lượng biến bạn muốn cấp phát bộ nhớ cho chúng. Đối số thứ hai là kích thước của mỗi biến.
void *calloc(size_t num, size_t size);
Tương tự như malloc(), hàm calloc() cũng sẽ trả về một con trỏ kiểu void nếu việc cấp phát bộ nhớ thành công, ngược lại nó sẽ trả về một con trỏ NULL nếu việc cấp phát bộ nhớ thất bại.
Ví dụ 6:
#include <stdio.h>
#include <stdlib.h>
int main() {
float *calloc1, *calloc2;
calloc1 = (float *)calloc(3, sizeof(float));
calloc2 = (float *)calloc(3, sizeof(float));
if (calloc1 != NULL && calloc2 != NULL) {
for (int i = 0; i < 3; i++) {
*(calloc1 + i) = 40.5f + i;
*(calloc2 + i) = 80.5f + i;
printf("calloc1[%d] holds %f\n", i, *(calloc1 + i));
printf("calloc2[%d] holds %f\n", i, *(calloc2 + i));
}
free(calloc1);
free(calloc2);
return 0;
} else {
printf("Không đủ bộ nhớ\n");
}
return 0;
}
Output:
calloc1[0] holds 0.00000
calloc2[0] holds 0.00000
calloc1[1] holds 0.00000
calloc2[1] holds 0.00000
calloc1[2] holds 0.00000
calloc2[2] holds 0.00000
Trên tất cả các máy, các mảng calloc1 và calloc2 nên chứa giá trị số không, calloc đặc biệt hữu ích khi bạn sử dụng mảng đa chiều. Dưới đây là một ví dụ khác để minh họa việc sử dụng hàm calloc().
Ví dụ 7:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *a, i, n, sum = 0;
printf("\n%s\n\n%s", "Chương trình này sẽ tạo một mảng động.", "Nhập kích thước mảng n và sau đó là các số nguyên: ");
scanf("%d", &n); /* Nhập số lượng phần tử */
a = (int *)calloc(n, sizeof(int)); /* Cấp phát bộ nhớ */
/* Nhập giá trị cho từng phần tử */
for (i = 0; i < n; i++) {
printf("Nhập giá trị thứ %d: ", i + 1);
scanf("%d", &a[i]);
sum += a[i]; /* Tính tổng giá trị */
}
free(a); /* Giải phóng bộ nhớ */
/* In số lượng phần tử và tổng giá trị */
printf("\nSố lượng phần tử: %d\nTổng các phần tử: %d\n", n, sum);
return 0;
}
realloc()
Giả sử bạn đã cấp phát một số lượng byte nhất định cho một mảng, nhưng sau đó bạn muốn thêm giá trị vào nó. Bạn có thể sao chép tất cả vào một mảng lớn hơn, nhưng điều này không hiệu quả, hoặc bạn có thể cấp thêm byte bằng cách sử dụng realloc, mà không mất dữ liệu của bạn.
realloc() có hai đối số. Đối số đầu tiên là con trỏ trỏ đến vùng nhớ. Đối số thứ hai là tổng số byte bạn muốn cấp phát lại.
void *realloc( void *ptr, size_t size);
Truyền số 0 vào đối số thứ hai tương đương với việc gọi hàm free.
Một lần nữa, hàm realloc trả về một con trỏ kiểu void nếu thực hiện thành công, nếu không, nó sẽ trả về một con trỏ NULL.
Ví dụ này sử dụng calloc để cấp phát đủ bộ nhớ cho một mảng kiểu int gồm năm phần tử. Sau đó, realloc được gọi để mở rộng mảng để có thể chứa bảy phần tử.
Ví dụ 8:
#include <stdio.h>
#include <stdlib.h>
int main() {
int *ptr = (int *)calloc(5, sizeof(int));
if (ptr != NULL) {
ptr[0] = 2;
ptr[1] = 4;
ptr[2] = 8;
ptr[3] = 16;
ptr[4] = 32;
printf("Đang cấp thêm bộ nhớ...\n");
ptr = (int *)realloc(ptr, 7 * sizeof(int));
if (ptr != NULL) {
ptr[5] = 64;
for (int i = 0; i < 7; i++) {
printf("ptr[%d] chứa %d\n", i, ptr[i]);
}
realloc(ptr, 0); /* Tương đương với việc giải phóng bộ nhớ */
return 0;
} else {
printf("Không đủ bộ nhớ - realloc thất bại.\n");
return 1;
}
} else {
printf("Không đủ bộ nhớ - calloc thất bại.\n");
return 1;
}
}
Kết quả:
Now allocating more memory.
ptr[0] holds 1
ptr[1] holds 2
ptr[2] holds 4
ptr[3] holds 8
ptr[4] holds 16
ptr[S] holds 32
ptr[6] holds 64
Lưu ý hai phương pháp khác nhau được sử dụng khi khởi tạo giá trị cho phần tử trong mảng:
- ptr[2] = 4;: Đây là cách thông thường và dễ đọc khi bạn muốn gán giá trị 4 cho phần tử thứ 3 (chỉ mục bắt đầu từ 0) của mảng ptr.
- *(ptr + 2) = 4;: Đây là cách sử dụng con trỏ để thay đổi giá trị của một phần tử trong mảng. Nó tương đương với cách trên, nhưng sử dụng cú pháp con trỏ. *(ptr + 2) thay thế cho ptr[2] và gán giá trị 4 cho phần tử thứ 3 của mảng.
Tuy nhiên, trước khi sử dụng realloc(), gán giá trị cho ptr[5] không gây ra lỗi biên dịch. Chương trình vẫn hoạt động, nhưng giá trị của ptr[5] trước khi bạn gọi realloc() sẽ không thay đổi. Sau khi bạn gọi realloc(), vị trí bộ nhớ có thể thay đổi, và giá trị của ptr[5] sẽ phụ thuộc vào việc thay đổi này. Thường thì, bạn nên gán giá trị cho phần tử trong mảng sau khi cấp phát bộ nhớ lại (sử dụng realloc()) để đảm bảo tính nhất quán của dữ liệu.
Bài tập
- Viết 1 chương trình yêu cầu người dùng nhập vào 5 số nguyên, sử dụng con trỏ để lưu trữ 5 số nguyên này. Sau đó in danh sách số nguyên theo chiều xuôi và ngược.
- Viết 1 chương trình yêu cầu người dùng nhập vào một chuỗi, sau đó sử dụng con trỏ bổ sung đuôi .txt vào chuỗi và in ra màn hình. Sử dụng vòng lặp vô hạn để chương trình yêu cầu nhập tiếp và không thoát, nếu người dùng nhập exit thì thoát chương trình
- Viết chương trình yêu cầu người dùng nhập liệu 1 chuỗi, sử dụng con trỏ để kiểm tra xem chuỗi có phải chuỗi đối xứng không. Ví dụ: abba là chuỗi đối xướng, abc là chuỗi không đối xứng
- Viết chương trình khai báo con trỏ lưu trữ một danh sách cấu trúc sinh viên (biến kiểu dữ liệu struct), cho biết thông tin sinh viên cần nhập liệu bao gồm name,age,mark, birthyear.
- Khi bắt đầu chương trình, yêu cầu người dùng nhập số lượng sinh viên, sau đó dùng vòng lặp để nhập liệu thông tin sinh viên, cuối cùng in ra màn hình danh sách sinh viên
- Sắp xếp danh sách sinh viên theo tên sinh viên (alphabet) và in ra màn hình.