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

Mảng struct, con trỏ struct và truyền vào hàm

Mảng structToán tử mũi tênBản sao hay con trỏconst T *p cho hàm chỉ đọc

Mảng struct, con trỏ struct và truyền vào hàm

Một danh sách sinh viên chỉ là một mảng struct. Khi đưa nó vào hàm thì câu hỏi đầu tiên phải hỏi là hàm nhận bản sao hay nhận địa chỉ, vì hai lựa chọn ấy cho hai kết quả khác hẳn nhau.

Bài cấu trúc struct đã cho bạn một kiểu dữ liệu mới cùng hai việc cơ bản với nó: khai báo, và truy cập thành viên bằng dấu chấm. Bài này đi tiếp hai bước còn thiếu để struct dùng được trong chương trình thật. Bước thứ nhất là giữ nhiều struct cùng lúc, tức mảng struct. Bước thứ hai là đưa một struct vào hàm, và đây chính là chỗ con trỏ struct trở nên bắt buộc chứ không còn là tuỳ chọn.

Mảng struct

Khi đã có kiểu SinhVien, một danh sách sinh viên chỉ đơn giản là một mảng struct. Mỗi phần tử của mảng là một sinh viên trọn vẹn, dữ liệu không còn bị xé lẻ như cách làm ban đầu.

#include <stdio.h>

typedef struct {
char ten[30];
double diem;
} SinhVien;

int main(void) {
SinhVien lop[3] = {
{"Tran Van A", 8.5},
{"Le Thi B", 9.0},
{"Pham Van C", 7.0}
};

int n = 3, best = 0; // index of the top student
for (int i = 0; i < n; i++) {
printf("%-12s %.1f\n", lop[i].ten, lop[i].diem);
if (lop[i].diem > lop[best].diem)
best = i; // update the leader
}
printf("Cao nhat: %s (%.1f)\n", lop[best].ten, lop[best].diem);
return 0;
}

Hãy để ý lop[i].tenlop[i].diem: trước hết lop[i] lấy ra sinh viên thứ i, rồi dấu chấm truy cập thành viên của sinh viên đó. Biến best chỉ giữ chỉ số của người dẫn đầu hiện tại; mỗi lần gặp ai điểm cao hơn thì cập nhật.

Con trỏ struct và toán tử mũi tên

Như đã học ở chương con trỏ, ta có thể lấy địa chỉ của một biến struct bằng & và lưu vào một con trỏ struct. Vấn đề là: làm sao truy cập thành viên thông qua con trỏ?

Cách viết đầy đủ là (*p).diem: trước hết *p lấy ra biến struct mà p trỏ tới, rồi dấu chấm truy cập thành viên. Cặp ngoặc bắt buộc vì dấu chấm có độ ưu tiên cao hơn *. Cách này đúng nhưng rườm rà, nên C cho ta một lối tắt: toán tử mũi tên (arrow operator) ->.

Ghi nhớ

p->diem hoàn toàn tương đương (*p).diem. Quy tắc nhớ đơn giản: khi đứng trước là một con trỏ thì dùng ->; khi đứng trước là một biến struct thì dùng dấu chấm.

#include <stdio.h>

typedef struct { char ten[30]; double diem; } SinhVien;

// receives the address, so it CAN modify the original
void tangDiem(SinhVien *p, double them) {
p->diem += them; // same as (*p).diem += them
}

int main(void) {
SinhVien sv = {"Le Thi B", 8.0};
SinhVien *p = &sv; // pointer to the struct

printf("Truoc: %.1f\n", p->diem); // arrow access through pointer
tangDiem(&sv, 0.5); // pass the address
printf("Sau : %.1f\n", sv.diem); // original changed: 8.5
return 0;
}

Hàm tangDiem nhận con trỏ SinhVien *p, tức là địa chỉ của sinh viên thật bên ngoài, nên khi viết p->diem += them nó sửa thẳng vào struct gốc. Đây là khác biệt cốt lõi so với truyền theo trị: lần này thay đổi hiệu lực ra ngoài.

Khi nào dấu chấm, khi nào mũi tên
  • sv.diem khi svbiến struct (truy cập trực tiếp).
  • p->diem khi pcon trỏ struct (viết tắt của (*p).diem).
  • lop[i].diemlop[i] là một biến struct.
  • Lỗi kinh điển: viết p.diem khi p là con trỏ, hoặc sv->diem khi sv là biến struct. Hãy nhìn lại xem thứ đứng trước là con trỏ hay biến struct.

Truyền struct vào hàm: bản sao hay con trỏ

C truyền tham số theo trị, nghĩa là hàm luôn nhận một bản sao của thứ ta đưa vào. Với int thì bản sao chỉ tốn 4 byte, chẳng ai bận tâm. Nhưng struct thì khác: nếu kiểu SinhVien nặng 48 byte, mỗi lần gọi hàm nhận SinhVien theo trị là một lần chép 48 byte lên ngăn xếp. Gọi trong một vòng lặp một triệu lần thì đó là 48 triệu byte bị chép đi chép lại một cách vô ích. Chi phí này chỉ càng lớn khi struct chứa mảng con hoặc struct lồng nhau.

Ngoài chi phí, truyền theo trị còn có một hệ quả về ngữ nghĩa mà người mới hay bị bất ngờ: hàm sửa bản sao, còn bản gốc bên ngoài không hề đổi. Hãy so sánh hai hàm sau.

#include <stdio.h>

typedef struct { char ten[30]; double diem; } SinhVien;

// by value: works on a COPY, the caller's struct is untouched
void tangSai(SinhVien sv, double them) {
sv.diem += them; // only the local copy changes
}

// by pointer: works on the ORIGINAL struct
void tangDung(SinhVien *p, double them) {
p->diem += them;
}

int main(void) {
SinhVien sv = {"Tran Van A", 8.0};

tangSai(sv, 1.0);
printf("Sau tangSai : %.1f\n", sv.diem); // still 8.0

tangDung(&sv, 1.0);
printf("Sau tangDung: %.1f\n", sv.diem); // now 9.0
return 0;
}

Hàm tangSai chạy không lỗi, biên dịch không cảnh báo, và nó thật sự có cộng thêm 1.0 vào diem, chỉ có điều nó cộng vào bản sao nằm trong ngăn xếp của chính nó. Bản sao ấy biến mất khi hàm kết thúc, nên khi quay về main ta thấy diem vẫn là 8.0. Đây là lỗi im lặng, kiểu lỗi tệ nhất, vì không có thông báo nào chỉ điểm cho bạn. Hàm tangDung nhận địa chỉ của struct gốc, nên p->diem += them chạm đúng vào ô nhớ thật.

Từ đó rút ra quy tắc thực hành gọn gàng. Nếu hàm cần sửa struct, bắt buộc nhận con trỏ. Nếu hàm chỉ đọc struct nhưng struct đủ lớn, vẫn nên nhận con trỏ để khỏi chép, và thêm const vào kiểu tham số (const SinhVien *p). Chữ const này không phải bình luận suông: nếu bạn lỡ tay viết p->diem = 0 bên trong, trình biên dịch báo lỗi ngay. Đổi lại, con trỏ có cái giá của nó, vì nó có thể là NULL hoặc trỏ tới vùng nhớ đã chết. Đó là đánh đổi cổ điển trong C: hiệu năng và khả năng sửa trực tiếp đổi lấy trách nhiệm tự kiểm soát tính hợp lệ của con trỏ.

Bài tập thực hành

Bài tập 2: tăng tuổi qua con trỏ struct

Định nghĩa kiểu SinhVien gồm ten, tuoidiem. Viết hàm void tangTuoi(SinhVien *p) dùng con trỏ struct và toán tử -> để tăng tuổi thêm 1. Gọi hàm rồi in lại để xác nhận tuổi gốc đã thay đổi.

Lời giải chi tiết

Bước 1: hàm nhận SinhVien *p (con trỏ), nên nó thao tác trên struct gốc chứ không phải bản sao. Bước 2: bên trong viết p->tuoi += 1, tương đương (*p).tuoi += 1. Bước 3: trong main, truyền &sv để đưa địa chỉ vào hàm, sau đó in sv.tuoi bằng dấu chấm (vì sv là biến struct) sẽ thấy giá trị đã tăng.

#include <stdio.h>

typedef struct {
char ten[30];
int tuoi;
double diem;
} SinhVien;

// pointer parameter: modifies the original struct
void tangTuoi(SinhVien *p) {
p->tuoi += 1; // arrow access through pointer
}

int main(void) {
SinhVien sv = {"Tran Van A", 20, 8.5};
tangTuoi(&sv); // pass the address
printf("%s, tuoi = %d\n", sv.ten, sv.tuoi); // 21
return 0;
}

Ý chính: truyền con trỏ struct cho phép hàm sửa thẳng dữ liệu gốc, khác hẳn truyền theo trị vốn chỉ làm việc trên bản sao.

Bài tập 3: lọc danh sách theo điểm

Tạo một mảng SinhVien gồm vài người khởi tạo sẵn. Duyệt mảng và in ra những sinh viên có điểm từ 8.0 trở lên.

Lời giải chi tiết

Bước 1: khai báo kiểu SinhVien bằng typedef, rồi tạo mảng struct khởi tạo sẵn. Bước 2: dùng một vòng for duyệt qua từng phần tử. Với mỗi lop[i], ta lấy ra một biến struct, nên truy cập thành viên bằng dấu chấm: lop[i].diem. Bước 3: nếu lop[i].diem >= 8.0 thì in tên và điểm của người đó.

#include <stdio.h>

typedef struct {
char ten[30];
double diem;
} SinhVien;

int main(void) {
SinhVien lop[4] = {
{"Tran Van A", 8.5},
{"Le Thi B", 9.0},
{"Pham Van C", 7.0},
{"Hoang Thi D", 8.0}
};
int n = 4;

printf("Sinh vien tu 8.0 tro len:\n");
for (int i = 0; i < n; i++) {
if (lop[i].diem >= 8.0)
printf(" %-12s %.1f\n", lop[i].ten, lop[i].diem);
}
return 0;
}

Ý chính: mảng struct giữ trọn vẹn từng sinh viên nên việc lọc theo một thuộc tính (diem) trở nên gọn gàng và không sợ dữ liệu lẫn lộn như khi dùng các mảng rời rạc.

Tự kiểm tra

1) Khi nào dùng dấu chấm, khi nào dùng ->?

Dùng dấu chấm khi đứng trước là một biến struct (sv.diem, lop[i].diem). Dùng -> khi đứng trước là một con trỏ struct (p->diem, viết tắt của (*p).diem).

2) Vì sao hàm sửa struct nên nhận con trỏ thay vì nhận theo trị?

Truyền theo trị tạo một bản sao toàn bộ struct, sửa bản sao không ảnh hưởng struct gốc, lại tốn bộ nhớ nếu struct lớn. Nhận con trỏ (SinhVien *p) cho phép hàm sửa thẳng struct gốc và không phải sao chép.

Điều rút ra

Có hai toán tử truy cập nhưng chỉ một quy tắc để chọn: nhìn thứ đứng ngay trước toán tử, là biến struct thì dùng dấu chấm, là con trỏ thì dùng mũi tên. Về truyền tham số, C truyền theo trị, nên nhận struct theo trị vừa tốn chi phí sao chép vừa khiến mọi thay đổi bên trong hàm không có hiệu lực ra ngoài. Mẫu hình chuẩn là nhận con trỏ: T *p khi cần sửa, const T *p khi chỉ đọc.

Câu hỏi tự kiểm

Kiểm tra nhanh: con trỏ struct và truyền vào hàm0/3 đúngchưa trả lời
  1. 1Nếu p là con trỏ tới struct, cách truy cập thành viên diem đúng cú pháp là gì?
  2. 2Truyền một struct vào hàm theo trị rồi sửa thành viên bên trong hàm thì điều gì xảy ra?
  3. 3Một hàm chỉ ĐỌC một struct nặng 48 byte và không sửa gì. Nên khai báo tham số của nó thế nào?

Học tiếp