=
=

Bài 8: Con trỏ

I. Biến con trỏ

I.1. Khái niệm

Các biến được sử dụng từ trước đến nay đều là biến có kích thước và kiểu dữ liệu xác định, người ta gọi những biến này là biến tĩnh (static) Khi chạy chương trình, gặp những biến này, máy sẽ cung cấp lượng bộ nhớ và địa chỉ cho các biến đó mà không cần biết các biến đó sử dụng lúc nào hoặc thậm chí có được sử dụng hay không. Các biến tĩnh sẽ tồn tại trong suốt thời gian thực hiện chương trình, vì vậy nếu ta chạy một chương trình lớn trong khi máy của ta lại hạn chế bộ nhớ thì sẽ xảy ra tình trạng không đủ bộ nhớ. Nhưng khi dùng mảng ta phải khai báo kích thước mảng. Vì vậy đối với những chương trình mà ta không dự đoán trước được kích thước của chúng ra sao thì sẽ xảy ra tình trạng:

Đó là những nhược điểm của biến tĩnh.

Vậy biến tĩnh là biến có kích thước, kiểu của biến và địa chỉ của chúng là không đổi, các biến này tồn tại trong suốt quá trình chạy chương trình.

Để khắc phục những nhược điểm trên, các ngôn ngữ lập trình thường sử dụng những biến động vì có những đặc điểm sau:

Thế nhưng biến động cũng có nhược điểm là không thể truy cập đến nó được bởi vì biến động không chứa địa chỉ nhất định. Để khắc phục nhược điểm này người ta sử dụng một loại biến đặc biệt gọi là biến con trỏ (pointer).

Biến con trỏ có những đặc điểm sau:

Vậy: "biến con trỏ là loại biến chuyên dùng để chứa địa chỉ của biến động, giúp ta truy nhập đến biến động".

I.2. Khai báo biến con trỏ

Con trỏ là một biến dùng để chứa địa chỉ, vì có nhiều loại địa chỉ nên cũng có nhiều kiểu con trỏ tương ứng:

Khai báo:

Kiểu *Tên_biến_con_trỏ

Ví dụ:

int x, y, *px, *c;
// Khai báo 2 biến kiểu int, 2 biến con trỏ kiểu int là px và c.

Quy định vùng trỏ tới

Cú pháp:

Tên con trỏ = &biến

Ví dụ khai báo các biến con trỏ:

int x, y, *px, *c;
float *t, *d;

Trong ví dụ trên:

Kiểu giá trị trong khai báo

Nếu kiểu của con trỏ và kiểu của biến mà nó trỏ tới không cùng một kiểu sẽ gây ra lỗi. Ví dụ:

float a, b[7], f(), *px;
// mọi thành phần trong khai báo này đều cho hoặc nhận giá trị kiểu float.

"Mọi thành phần của cùng một khai báo (biến, phần tử mảng, con trỏ) khi xuất hiện trong biểu thức đều cho cùng một kiểu giá trị".

I.3. Sử dụng con trỏ trong các biểu thức

Sau khi khai báo biến con trỏ, bạn có thể sử dụng các biến này trong các biểu thức. Ví dụ đối với con trỏ px kể trên ta có thể sử dụng cách viết các toán hạng trong biểu thức:

Sử dụng tên con trỏ và phép gán

Vì con trỏ cũng là một biến nên khi tên của nó xuất hiện trong biểu thức thì giá trị của nó cũng được sử dụng trong biểu thức này. Giá trị của nó có nghĩa là địa chỉ của biến nào đó (biến động).

Khi tên con trỏ ở bên trái của toán tử gán thì giá trị của biểu thức ở bên phải được gán cho con trỏ. Ví dụ:

(1) int a, *p, *a;
(2) p = &a;
(3) q = p;

Trong ví dụ trên:

Kết quả con trỏ q cũng chứa địa chỉ của biến a.

Cũng giống như các biến khác, nội dung của biến con trỏ cũng có thể thay đổi. Ví dụ nếu con trỏ p chứa địa chỉ của phần tử mảng arr[i] thì sau khi thực hiện phép toán ++p hoặc p++ nó sẽ chứa địa chỉ của phần tử arr[i+1]. Mảng 1 chiều và con trỏ sẽ được tìm hiểu trong mục kế tiếp của bài này.

Sử dụng dạng khai báo của con trỏ

Nếu con trỏ px trỏ tới biến x thì các cách viết x và *px là tương đương nhau. Sau khi khai báo float x, y, z, *px, *py thì các câu lệnh sau đây đều có tác dụng như nhau:

y = 3*x + z;
*px = 3*x + z;
*py = 3*(*px) + z;

Như vậy khi đã biết địa chỉ của một biến thì không những sử dụng giá trị của nó mà còn có thể gán cho nó một giá trị mới để thay đổi nội dung của biến.

I.4. Hàm có tham số con trỏ

Ở mục này chúng ta sẽ nghiên cứu các tham số thực là các biến tĩnh sẽ truyền cho các tham số hình thức của hàm là các biến con trỏ.

Đa số các trường hợp người ta sử dụng cách truyền theo giá trị. Còn nếu muốn truyền theo tham chiếu (theo biến) tức là truyền cả địa chỉ và nội dung biến thì phải sử dụng biến con trỏ. Ví dụ chương trình sau dùng hàm hoanvi() với tham số là hai con trỏ.

Nhập 2 số nguyên bất kỳ cách nhau bởi khoảng trắng vào ô Stdin Inputs. Nhấn nút Execute màu xanh để xem kết quả.

Người ta chia tham số của hàm thành 2 loại:

Ví dụ cần lập hàm giải PT bậc 2: ax2 + bx + c = 0

Để trả lời câu hỏi "Khi nào thì sử dụng tham số con trỏ?". Câu trả lời là: "Chỉ sử dụng cho các tham số ra."

I.5. Cấp phát vùng nhớ, thay đổi kích thước và giải phóng vùng nhớ

Cấp phát vùng nhớ để lưu dữ liệu

Thông thường chúng ta chỉ quan tâm đến khai báo và cung cấp vùng nhớ để lưu địa chỉ của biến con trỏ mà không lưu ý đến việc cung cấp vùng nhớ để lưu trữ dữ liệu nên khi chạy chương trình thường mắc sai lầm.

Khi ta khai báo: int *px; chỉ có tác dụng cung cấp cho bản thân biến px một vùng nhớ là 2 byte. Khai báo này không hề cung cấp vùng nhớ để lưu trữ dữ liệu mà px trỏ tới. Vì vậy khi sử dụng biến con trỏ, ta phải:

Khi khai báo : int x, *px; có nghĩa là đã cung cấp vùng nhớ cho bản thân con trỏ px và cũng đã cung cấp vùng nhớ cho biến x.

Còn câu lệnh: px = &x; có nghĩa là chỉ định vùng nhớ mà px trỏ tới là vùng nhớ của x mà vùng nhớ này đã được cung cấp rồi bằng hiệu ứng int x;

Để tránh những sai lầm đáng tiếc, Turbo C có sẵn 2 câu lệnh malloc hoặc calloc định nghĩa trong alloc.h hay stdlib.h để cung cấp vùng nhớ trực tiếp cho biến con trỏ.

Cú pháp tổng quát là:

Biến con trỏ = malloc(sizeof(tên kiểu));
Biến con trỏ = calloc(n, sizeof(tên kiểu));

Sự khác biệt duy nhất giữa 2 lệnh ở chỗ calloc vùng nhớ bằng n lần số bytes xác định bởi sizeof(tên kiểu)

Ví dụ các câu lệnh sau đây có giá trị tương đương:
px = malloc (sizeof(int));
px = calloc (1, sizeof(int));
px = calloc (2, sizeof(char));

malloc và calloc cho ra các con trỏ trỏ tới kí tự, cho nên nếu muốn cấp phát bộ nhớ cho các loại biến khác thì phải dùng phép biến đổi kiểu cưỡng bức.

Thay đổi kích thước vùng nhớ động

Sau khi đã cấp phát vùng nhớ để lưu dữ liệu bằng 1 trong 2 cách:

Ta có thể thay đổi kích thước vùng nhớ đã cấp phát bằng lệnh realloc định nghĩa trong alloc.h có cú pháp như sau:

void *realloc(void *ptr, size)

Tham số

ptr: Đây là con trỏ tới khối bộ nhớ đã được cấp phát trước đó với malloc, calloc hoặc realloc để được tái cấp phát. Nếu giá trị là NULL, thì một khối mới được cấp phát và một con trỏ tới nó được trả về bởi hàm này.

size: Đây là kích cỡ mới cho khối bộ nhớ. Nếu giá trị là 0 và con trỏ ptr trỏ tới một khối nhớ đang tồn tại, khối nhớ được trỏ tới bởi ptr được giải phóng và một con trỏ NULL được trả về.

Cho biết kích thước vùng nhớ còn lại

Giải phóng vùng nhớ động

void free(void *block)

Trong đó *block : Vùng nhớ cần giải phóng tức là tên của biến con trỏ đang chiếm giữ vùng nhớ.

Ưu điểm của biến con trỏ

Linh động trong việc cung cấp vùng nhớ: Khi sử dụng biến con trỏ trong chương trình thì việc cấp phát vùng nhớ sẽ được thực hiện trong quá trình chạy chương trình, không phải cung cấp ngay từ đầu nên thời gian nạp chương trình sẽ nhanh chóng hơn. Mặt khác các biến sau khi không còn sử dụng ta có thể giải phóng vùng nhớ (xóa dữ liệu) để sử dụng phần bộ nhớ này vào việc khác nên tiết kiệm được bộ nhớ.

Thay đổi cách thức truyền tham số: Việc truyền tham số thực cho các tham số hình thức ở trong các hàm có 2 cách: Truyền theo giá trị và truyền theo biến. Sự khác nhau cơ bản của 2 cách truyền này là:

Theo quy ước mặc định là truyền theo trị, còn nếu muốn truyền theo biến thì phải dùng biến con trỏ.

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

II.1. Địa chỉ của các phần tử mảng

Giả sử ta khai báo : int a[10];

Nghĩa là ta đã khai báo mảng a là một mảng 1 chiều có 10 phần tử kiểu số nguyên. Để lấy địa chỉ của một phần tử nào đó ta dùng lệnh: &a[i] Với i là phần tử trong khoảng từ 0 đến 9

Địa chỉ của phần tử mảng đầu tiên

Với khai báo int a[10] máy sẽ bố trí cho mảng a một vùng nhớ liên tiếp 20 bytes cho 10 phần tử mảng kiểu nguyên (mỗi phần tử 2 bytes). Như phần trên thì địa chỉ của từng phần tử mảng được xác định bằng phép toán &a[i]. Vậy địa chỉ của phần tử mảng đầu tiên sẽ là &a[0]. Do đó trong C quy định: &a[0] tương đương với a (tên mảng), &a[i] tương đương với a + i, a[i] tương đương với *(a +i). Như vậy tên mảng đồng nghĩa với phần tử đầu tiên của mảng, do đó có thể nói: "Tên mảng là một hằng địa chỉ"

II.2. Con trỏ trỏ tới phần tử mảng

Khi con trỏ pa trỏ tới phần tử a[k] thì:

Như vậy, sau hai câu lệnh:

float a[20], *p; p=a;
thì bốn cách viết sau có tác dụng như nhau:
a[i] *(a+i)
p[i] *(p+i)

Ví dụ tính tổng các phần tử của mảng 1 chiều.

Cách 1

Nhấn nút Execute màu xanh để xem kết quả.

Cách 2

Nhấn nút Execute màu xanh để xem kết quả.

Cách 3

Nhấn nút Execute màu xanh để xem kết quả.

Chú ý: Mảng một chiều và con trỏ tương ứng phải cùng kiểu.

II.3. Tham số thực và tham số hình thức trong mảng 1 chiều

Giả sử tham số thực là tên mảng a kiểu int (hoặc float, double...) thì tham số hình thức px tương ứng phải là một con trỏ cùng kiểu int (hoặc float, double...). Tham số hình thức có thể khai báo:

Theo kiểu con trỏ:

int *px;
float *px;
double *px;

Theo tên mảng:

int px[ ];
float px[ ];
double px[ ];

Khi hàm bắt đầu làm việc thì giá trị thực của a được truyền cho tham số hình thức px. Vì a là hằng địa chỉ xác định địa chỉ của phần tử đầu tiên của mảng nên con trỏ px sẽ chứa địa chỉ của phần tử này (tức là phần tử đầu tiên của mảng). Sau đó nếu muốn trỏ tới phần tử a[i] ta có thể sử dụng 1 trong 2 dạng sau trong thân hàm: *(px +i) và px[i]

Ví dụ tính tổng các phần tử của mảng a bằng cách truyền tham số thực là tên mảng cho các tham số hình thức trong hàm tong().

Cách 1

Nhấn nút Execute màu xanh để xem kết quả.

Cách 2

Nhấn nút Execute màu xanh để xem kết quả.

II.4. Mảng và chuỗi kí tự

  • Chuỗi kí tự là một dãy các kí tự kể cả kí tự trống được rào trong cặp dấu nháy kép ("").
  • Khi gặp một chuỗi kí tự máy sẽ cấp phát một vùng nhớ cho một mảng kiểu char để chứa các kí tự và chứa thêm kí tự “\0”( kí tự kết thúc một chuỗi). Mỗi kí tự trong chuỗi tương ứng với mỗi phần tử trong mảng. Vì vậy chuỗi kí tự nào cũng là một hằng địa chỉ biểu thị của phần tử đầu tiên. Do đó nếu ta khai báo biến ten như một con trỏ kiểu char thì sau đó có thể gán dữ liệu chuỗi cho biến này. Ví dụ:
char *ten;
ten = "Nguyen Van Xuan";

Sau đó nếu có in chuỗi này ra màn hình, ta có thể thực hiện 1 trong 2 câu lệnh sau:

printf("Nguyen Van Xuan");

Hoặc

printf(ten);

Nếu muốn nhập một chuỗi (chẳng hạn tên của một người) ta có thể thực hiện bằng 1 trong 2 cách:

Nếu dùng mảng:

char t[24];
printf("\n cho biet ten: ");
scanf("%s", t);

Nếu dùng con trỏ:

char *ten, t[24]; ten = t; printf("\n Cho biết tên: "); scanf("%s", ten);

hoặc

scanf("%s", t);

III. Con trỏ và mảng nhiều chiều

III.1. Nhập số liệu cho mảng nhiều chiều.

Mảng nhiều chiều phức tạp hơn mảng một chiều, ta không thể áp dụng các quy tắc của mảng 1 chiều cho mảng nhiều chiều được:

Tóm lại cả 3 cách nhập số liệu cho mảng 1 chiều đều không thể áp dụng cho mảng nhiều chiều. Vậy muốn nhập dữ liệu cho mảng hai chiều ta thực hiện theo nguyên tắc sau:

III.2. Phép cộng địa chỉ trong mảng hai chiều

Giả sử ta có mảng hai chiều a[2][3] có 6 phần tử úng với sáu địa chỉ liên tiếp trong bộ nhớ được xếp theo thứ tự sau:

Phần tử a[0][0] a[0][1] a[0][2] a[1][0] a[1][1] a[1][2]
Địa chỉ 1 2 3 4 5 6

Tên mảng a biểu thị địa chỉ đầu tiên của mảng. Phép cộng địa chỉ ở đây được thực hiện như sau : C coi mảng hai chiều là mảng (một chiều) của mảng, như vậy khai báo float a[2][3]; thì a là mảng mà mỗi phần tử của nó là một dãy 3 số thực (một hàng của mảng).

Vì vậy:

a trỏ phần tử thứ nhất của mảng: phần tử a[0][0], a+1 trỏ phần tử đầu hàng thứ hai của mảng: phần tử a[1][0]...

III.3. Con trỏ và mảng hai chiều

Để lần lượt duyệt trên các phần tử của mảng hai chiều ta có thể dùng con trỏ như minh họa ở ví dụ sau:

float *pa,a[2][3];
pa = (float*)a;

lúc đó:

pa trỏ tới a[0][0]
pa+1 trỏ tới a[0][1]
pa+2 trỏ tới a[0][2]
pa+3 trỏ tới a[1][0]
pa+4 trỏ tới a[1][1]
pa+5 trỏ tới a[1][2]

Đoạn mã minh họa:

float a[2][3],*pa;
int i;
pa = (float*)a;
for (i=0;i<6;++i)
    scanf("%f", pa+i);