Chúng ta đã bước đến bài học cuối cùng trong chương đầu tiên nói về các khái niệm cơ bản trong C++. Hãy chú ý, kiến thức trong bài học này tương đối khó hơn nếu so sánh với những gì đã học từ trước tới giờ.
Chủ đề về con trỏ luôn được coi là 1 trong những bài học khó nhất nhưng bắt buộc phải biết khi bạn học lập trình. Đây cũng là 1 trong những bài học phức tạp nhất trong giáo trình của chúng ta. Sau khi học xong và hiểu được nó, các bạn sẽ thấy rất nhiều thứ trở nên đơn giản và rõ ràng hơn nhiều.
Con trỏ được sử dụng trong tất cả các chương trình C++. Thậm chí kể cả bạn cũng đã sử dụng chúng mà không biết vì cho đến giờ thì các bạn vẫn chưa từng chính thức thao tác trực tiếp với chúng. Từ bài này sẽ bắt đầu, chúng ta sẽ học quản lý 1 cách tinh tế từng phần bộ nhớ của máy tính.
Như tôi đã nói nhiều lần, với những bài học khó, đừng ngại đọc đi đọc lại nhiều lần để hiểu rõ. Bài này được đánh giá với độ khó cao nên tôi sẽ không nghi ngờ là các bạn sẽ còn quay lại đây nhiều trong tương lai.
Các bạn có còn nhớ bài học về quản lý bộ nhớ trong đó tôi đã giới thiệu với các bạn về khái niệm biến chứ? Nếu không nhớ rõ, hãy đọc lại nó và trên hết là nhớ lấy các sơ đồ chúng ta đã vẽ để mô tả về bộ nhớ.
Chúng ta đã biết rằng khi chương trình khai báo 1 biến, máy tính sẽ “cho mượn” 1 chỗ trong bộ nhớ của nó và gắn lên đấy 1 cái nhãn có tên của biến.
int main(){ int tuoiNguoiDung(16); return 0; }
Đoạn mã trên đã được chúng ta biểu diễn thế này.
Đơn giản biết chừng nào ! Đáng buồn là tôi phải nói cho các bạn biết rằng tôi đã nói dối. Ít nhất tôi đã đơn giản hóa mọi thứ đi nhiều. Khi các bạn nhận ra rằng với máy tính mọi thứ đều được sắp xếp có trật tự và logic thì các bạn cũng sẽ thấy rằng hình vẽ trên của chúng ta không còn chính xác lắm.
Bộ nhớ của máy tính đúng thật là được tạo thành từ các ô nhớ, có thể lên đến hàng tỉ đối với 1 chiếc máy tính đời mới. Vậy nên cần có 1 hệ thống quản lý để giúp máy có thể tìm lại được ô nhớ mà mình cần. Vì thế mỗi ô nhớ bản thân nó sẽ gắn với 1 số thứ tự duy nhất mà chúng ta gọi là địa chỉ (address).
Trong sơ đồ chúng ta nhìn thấy các ô nhớ và cả địa chỉ của chúng. Chương trình chỉ sử dụng 1 ô nhớ để lưu trữ biến, đó là ô 53768.
! Ta không thể lưu 2 biến vào cùng 1 ô nhớ
Quan trọng ở đây là mỗi biến sở hữu 1 địa chỉ và mỗi địa chỉ chỉ ứng với 1 biến duy nhất.
Địa chỉ cung cấp cho ta 1 cách thức khác để có thể tiếp cận biến như trong hình. Vậy là ta sẽ có 2 cách:
Vẫn là 1 số người hay thắc mắc sẽ hỏi là viêc này thì được lợi ích gì bởi vì sử dụng tên biến như những cái nhãn đã khá đơn giản và hiệu quả rồi. Đúng là như vậy, tuy nhiên, tôi chỉ có thẻ nói với các bạn, việc truy cập dựa vào địa chỉ đôi khi trong 1 số trường hợp là cần thiết.
Hãy bắt đầu bằng cách tìm ra đia chỉ của 1 biến nhé!
Trong C++, để nhận lấy địa chỉ của 1 biến, chúng ta sử dụng dấu &
. Lấy ví dụ nếu tôi muốn địa chỉ của biến tuoi
thì tôi cần phải viết &tuoi
.
#include <iostream> using namespace std; int main(){ int tuoi(16); cout << "Dia chi o nho la : " << &tuoi << endl; //Hien thi dia chi o nho return 0; }
Kết quả nhận được là :
! Khi chạy chương trình trên máy của bạn kết quả chắc chắn sẽ khác vì ô nhớ được sử dụng thay đổi mỗi lần chạy khác nhau của chương trình và khác nhau với mỗi máy tính.
Mặc dù kết quả có chứa các chữ nhưng tôi đảm bảo với các bạn rằng đây là 1 số, chỉ đơn giản là được viết trong hệ thập lục phân (hexa), một cách khác để biểu diễn 1 số. Máy tính rất thích làm việc với hệ này thay vì hệ thập phân như con người hay dùng (bới vì máy tính thì nghĩ khác với con người – Imitation game). Giá trị trên tương đương với 2686716 trong hệ thập phân của chúng ta cho những ai muốn biết. Tuy nhiên nó chỉ mang tính chất tham khảo chứ không có ý nghĩa sử dụng thực tế.
Chắc chắn là chỉ hiển thị giá trị địa chỉ này thì không có nhiều lợi ích cho lắm. Các bạn chỉ cần nhớ lấy cú pháp cần sử dụng với dấu &
là được.
! Chúng ta cũng từng sử dụng dấu &
khi khai báo tham chiếu. Cùng 1 dấu được sử dụng cho 2 mục đích khác nhau vậy nên chú ý đừng nhầm lẫn.
Tiếp đây là cách chúng ta sử dụng các địa chỉ này.
Các địa chỉ cũng chỉ là các con số. Chúng ta đã biết nhiều kiểu dữ liệu để lưu trữ số như int
, unsigned int
, double
. Vậy có thể nào lưu giá trị của địa chỉ trong 1 biến ?
Câu trả lời là « có » nhưng không phải sử dụng những kiểu dữ liệu mà bạn đã biết mà là 1 kiểu đặc biệt : con trỏ (pointer).
Con trỏ là 1 biến chứa giá trị địa chỉ của 1 biến khác.
Hãy nhớ lấy câu này! Nó sẽ giúp bạn rất nhiều hơn bạn tưởng.
Khi khai báo con trỏ, chúng ta cần 2 thông tin :
Với tên con trỏ, các bạn cần áp dụng tất cả các quy tắc chúng ta đã biết với các loại tên khác như tên biến, tên hàm, vv…
Kiểu dữ liệu của con trỏ thì hơi đặc biệt. Bạn cần phải chỉ ra kiểu dữ liệu của biến được lưu trong địa chỉ theo sau đó là dấu *
như trong câu lệnh dưới đây.
int *conTro;
Câu lệnh này khai báo 1 con trỏ chứa địa chỉ của 1 biến kiểu int
.
! Chúng ta cũng có thể viết int* conTro;
(nghĩa là dấu * sát với kiểu dữ liệu thay vì sát với tên biến). Cú pháp này có đôi chút bất tiện vì không cho phép chúng ta khai báo đồng thời nhiều biến trên cúng 1 dòng. Cần chú ý là nếu bạn viết là int* conTro1, conTro2, conTro3;
thì chỉ có conTro1
là 1 biến con trỏ, 2 biến còn lại chỉ là biến kiểu số nguyên bình thường.
Chúng ta có thể khai báo con trỏ trên bất cứ kiểu dữ liệu nào.
double *conTroA; //Con tro chua dia chi chua so thap phan unsigned int *conTroB; // Con tro chua dia chi chua so nguyen duong string *conTroC; //Con tro chua dia chi chua chuoi ky tu vector<int> *conTroD; // Con tro chua dia chi chua mang dong cac so nguyen int const *conTroE; // Con tro chua dia chi chua hang so nguyen
Hiện thời, sau khi khai báo thì con trỏ đang chứa giá trị không xác định. Tình huống này là rất nguy hiểm vì khi bạn thao tác với con trỏ này, bạn không biết là mình đang thay đổi ô nhớ nào. Ô nhớ này có thể là bất cứ ô nào, có thể là chứa mật khẩu Windows của bạn hoặc cũng có thể là chứa giá trị thông báo thười gian trong máy tính. Ví dụ như thế hy vọng sẽ giúp các bạn hiểu hậu quả nghiêm trọng có thể sinh ra khi thao tác sai lầm trên các con trỏ. Vậy nên tuyệt đối không bao giờ được khai báo 1 con trỏ mà không khởi tạo cho nó 1 giá trị.
Để đảm bảo nhất thì mỗi khi khai báo 1 con trỏ, hãy gán cho nó giá trị bằng 0.
double *conTroA(0); unsigned int *conTroB(0); string *conTroC(0); vector<int> *conTroD(0); int const *conTroE(0);
Nếu các bạn chú ý sơ đồ mà tôi đã vẽ bên trên thì ô nhớ đầu tiên có địa chỉ là 1. Trong thực tế thì địa chỉ số 0 không hề tồn tại. Khi bạn khởi tạo giá trị 0 cho con trỏ thì có nghĩa là con trỏ này không hề chứa địa chỉ của ô nhớ nào cả.
! Tôi nhắc lại là việc khởi tạo giá trị 0 cho con trỏ khi khai báo là rất quan trọng.
Sau khi đã tạo ra biến con trỏ, việc tiếp theo là gán cho nó 1 giá trị. Các bạn hẳn chưa quên cách để lấy địa chỉ của 1 biến mà tôi đã nói ngay bên trên.
int main(){ int tuoi(16); //Bien chua kieu du lieu int int *ptr(0); //Con tro chua dia chi 1 so nguyen ptr = &tuoi; //Luu dia chi cua ‘tuoi’ vao trong con tro ‘ptr’ return 0; }
Câu lệnh ptr = &tuoi;
chính là lệnh chúng ta cần quan tâm. Câu lệnh này ghi địa chỉ của tuoi
vào con trỏ ptr
. Chúng ta nói là « con trỏ ptr
trỏ lên biến tuoi
».
Sơ đồ sau miêu tả quá trình xảy ra trong bộ nhớ.
Trong sơ đồ này, chúng ta vẫn có thể thấy bộ nhớ được chia thành nhiều ô nhỏ cùng với biến tuoi
nằm trong ô 53768. Khác biệt là bây giờ có thêm ô 14566 chứa biến tên là ptr
mang giá trị địa chỉ của tuoi
, nghĩa là 53768.
Nhiều bạn sẽ hỏi là « Tại sao lại cần lưu địa chỉ của biến trong 1 ô nhớ khác nữa ?» thì xin trả lời là « Hãy từ từ, mọi thứ sẽ dần sáng tỏ thôi. ». Trước mắt, hãy tập trung hiểu kỹ sơ đồ trên.
Giống như tất cả các biến khác, chúng ta cũng có thể hiển thị ra màn hình giá trị của con trỏ.
#include <iostream> using namespace std; int main(){ int tuoi(16); int *ptr(0); ptr = &tuoi; cout << "Dia chi cua 'tuoi' la : " << &tuoi << endl; cout << "Gia tri cua con tro la : " << ptr << endl; return 0; }
Kết quả nhận được cho ta thấy là giá trị chủa con trỏ chính là địa chỉ của biến được trỏ.
Tôi đã nói bên trên là nhiệm vụ của con trỏ là cho phép truy nhập vào biến mà không cần thông qua tên biến. Để làm thế thì cú pháp là chỉ cần viết dấu *
trước tên con trỏ là đủ
int main(){ int tuoi(16); int *ptr(0); ptr= &tuoi; cout << "Gia tri duoc tro la : " << *ptr << endl; return 0; }
Khi xử lý dòng lệnh cout << *ptr;
thì máy tính sẽ thực hiện những việc sau :
Sử dụng dấu *
đã giúp chúng ta truy nhập vào giá trị được trỏ. Thuật ngữ chuyên môn gọi việc này là tham chiếu ngược.
Vẫn là câu hỏi như bên trên được đặt ra, đó là mục đích của việc này là gì. Xin hãy kiên nhẫn và đọc tới cuối bài học.
Chắc mọi người cũng đồng ý là cú pháp của con trỏ cũng khá phức tạp, luôn luôn cần chú ý vì dấu *
và dấu &
đều được dùng cho nhiều mục đích khác nhau. Tôi xin tóm lược lại 1 chút cho dễ nhớ.
Ví dụ với 1 biến số nguyên so
kiểu int
so
là tên biến, cho phép truy nhập vào giá trị biến&so
cho phép truy nhập vào giá trị của địa chỉ của biếnVới 1 con trỏ int *conTro
conTro
là tên con trỏ cho phép truy nhập vào giá trị của con trỏ, nghĩa là địa chỉ của ô nhớ được trỏ đến.*conTro
cho phép truy nhập vào giá trị lưu trong ô nhớ được trỏ đến.Đây là những điều quan trong cần nhớ trong phần này. Tôi đề nghị các bạn thử tự viết 1 chương trình hiển thị các giá trị khác nhau như giá trị con trỏ, địa chỉ biến, địa chỉ được trỏ đến, vv… để làm rõ các khái niệm trong đầu.
Không có lập trình viên tài năng nào mà không trải qua bước học lập trình với con trỏ. Và tôi cũng xin đảm bảo với bạn là họ cũng hết sức đau đầu khi bắt đầu trải qua bước này. Vậy nên nếu bạn chưa hiểu ngay cũng không cần hoảng hốt, hãy từ từ đọc lại và ghi nhớ từng chút một.
Nếu các bạn vẫn háo hức muốn sử dụng con trỏ thì sau đây sẽ là công dụng đầu tiên.
Trong bài học trước đây nói về các biến, tôi đã từng giải thích cho các bạn việc khai báo biến được chia ra thành 2 bước :
Việc này được thực hiện 1 cách tự động bới chương trình. Thêm vào đó, khi kết thúc 1 hàm, chương trình cũng trả lại vùng nhớ đó. Chúng ta gọi là giải phóng bộ nhớ. Và việc này cũng được thực hiện hoàn toàn tự động.
Những việc này chúng ta cũng có thể tự làm để có quản lý ở mức tinh tế hơn. Dưới đây là cách thức để thực hiện chúng.
Để tự thực hiện 1 yêu cầu phân bổ vùng nhớ, chúng ta cần sử dụng phép toán new
. new
yêu cầu máy tính cấp cho một 1 nhớ và trả về 1 con trỏ trỏ tới ô nhớ được cấp.
int *conTro(0); conTro = new int;
Câu lệnh thứ 2 xin cấp 1 ô nhớ để lưu số nguyên và gán địa chỉ ô nhớ đó cho giá trị của con trỏ.
Hình vẽ bên trên khá giống với cái sơ đồ lúc trước, bao gồm:
Không có gì mới mẻ nhưng quan trọng là chúng ta có 1 biến trong ô nhớ 14563 mà không có nhãn. Cách duy nhất để sử dụng ô nhớ này là thông qua con trỏ.
! Nếu bạn thay đổi giá trị của con trỏ, chúng ta sẽ không còn cách nào có thể truy cập vào ô nhớ trên nữa, và vì thế không thể thay đổi hay xóa nó. Thế nên ô nhớ đó vẫn sẽ tiếp tục tồn tại và chiếm vùng nhớ trong bộ nhớ. Hiện tượng này được gọi là rò rỉ bộ nhớ.
Một khi vùng nhớ đã được phân bổ, ta có thể sử dụng biến này như tất cả các biến thông thường khác, chỉ khác là chúng ta cần phải sử dụng đến tham chiếu ngược thay vì dùng tên biến.
int *conTro(0); conTro = new int; *conTro = 2; //Truy nhap den o nho de thay doi gia tri
Dữ liệu đã được ghi vào ô nhớ không có nhãn lúc trước.
Hoàn toàn tương tự như cách chúng ta sử dụng 1 biến thông thường. Bây giờ, khi đã dùng xong, cũng nên học cách trả ô nhớ lại cho máy tính.
Sau khi sử dụng xong, chúng ta cần trả lại ô nhớ đã mượn cho máy tính bằng cách dùng phép toán delete
.
int *conTro(0); conTro = new int; delete conTro; //Giai phong o nho
Ô nhớ được trả lại sẽ được máy tính dùng vào việc khác. Con trỏ vẫn còn tồn tại nhưng các bạn không thể truy nhập vào đó nữa.
Như có thể thấy trong hình, con trỏ của chúng ta đang trỏ vào 1 ô nhớ mà có thể đang được sử dụng bới 1 chương trình khác. Cần phải ngăn chặn tình huống này bằng cách gán cho con trỏ giá trị địa chỉ là 0 sau khi thực hiện phép toán delete
. Như thế, mũi tên màu vàng trong hình sẽ biến mất. Quên làm việc này có thể dẫn đến việc chương trình bị treo khi đang chạy.
int *conTro(0); conTro = new int; delete conTro; //Giai phong o nho conTro = 0; //Con tro khong tro vao bat cu o nho nao
! Đừng quên giải phóng các ô nhớ mà bạn đã dùng xong. Nếu không, khi mà chương trình dùng nhiều ô nhớ đến khi không còn các ô nhớ trống sẽ dẫn đến chương trình đang chạy bị lỗi.
1 ví dụ quen thuộc nhưng được viết lại bằng cách sử dụng con trỏ : yêu cầu người dùng nhập số tuổi và in giá trị ra màn hình.
#include <iostream> using namespace std; int main(){ int* conTro(0); conTro = new int; cout << "Ban bao nhieu tuoi ? "; cin >> *conTro; //Ghi du lieu vao o nho tro boi 'conTro' cout << "Ban da " << * conTro << " tuoi." << endl; delete conTro; //Giai phong o nho conTro = 0; //Con tro khong tro vao bat cu o nho nao return 0; }
Có đôi chút phức tạp hơn khi cần tự quản lý phân bổ động bộ nhớ. Trái lại thì chúng ta hoàn toàn làm chủ vùng nhớ về khía cạnh lúc nào cần yêu cầu và lúc nào thì cần giải phóng.
Trong phần lớn trường hợp thì việc này là không cần thiết. Thế nhưng khi bạn làm việc với Qt
sau này, bạn sẽ rất hay dùng đến new
và delete
. Chúng ta sẽ nói rõ hơn vào lúc đó.
Sau đây là các trường hợp mà chúng ta cần thao tác với con trỏ trong chương trình.
Nếu bạn không nằm trong 3 trường hợp trên, bạn có thể không cần dùng đến con trỏ. Trong số 3 trường hợp này thì trường họp 1 vứa được nói đến bên trên vậy nên chúng ta sẽ tập trung vào 2 trường hợp còn lai trong phần tới đây.
Bây giờ thì tôi vẫn chưa thể đưa ra 1 đoạn mã nguồn hoàn chỉnh cho các bạn xem. Khi nào chúng ta nói về lập trình hướng đối tượng trong chương sau, các bạn sẽ có cả đống mã nguồn ví dụ nếu muốn. Còn lúc này, tôi sẽ đưa ra 1 ví dụ trực quan hơn .
Đúng thế, đây là hình ảnh trong Warcraft III, trò chơi chiến thuật nổi tiếng của Blizzard. Những nhân vật trong trò chơi như thế này được thiết kế khá là phức tạp, tuy nhiên chúng ta vẫn có thể đoán ra 1 số cơ chế đã được áp dụng trong đó.
Trong hình, chúng ta có thể thấy quân human (màu đỏ) đang tấn công quân orc (màu xanh). Mỗi quân lính có một mục tiêu riêng xác định, ví dụ như quân lính ở chính giữa màn hình đang tấn công quân tướng cầm riu bên đối phương.
Trong chương sau, chúng ta sẽ xem cách để tạo ra các đối tượng, 1 kiểu biến “phức hợp” với những kiểu dữ liệu cũng đặc biệt không kém. Nói chung, trong C++, mọi thứ đều có thể biểu diễn dưới dạng các đối tượng.
Trong lúc này, chúng ta sẽ quan tâm những câu hỏi kiểu như “Làm sao để biểu diễn mục tiêu của 1 nhân vật bên đỏ?”.
Dù không thể viết ra được đoạn mã cụ thể nhưng chúng ta cũng có thể phán đoán phần nào. Đúng thế, chúng ta sẽ sử dụng con trỏ ! Mỗi nhân vật sẽ có 1 con trỏ trỏ về mục tiêu của mình để biết cần nhắm đến và tấn công cái gì. Đoạn mã sẽ kiểu kiểu như sau.
NhanVat *mucTieu; //Con tro tro len muc tieu la 1 nhan vat khac
Khi không có trận chiến, con trỏ sẽ trỏ về địa chỉ 0, nghĩa là không có mục tiêu. Còn khi có chiến đấu, con trỏ sẽ trỏ về 1 nhân vật bên địch. Nếu mục tiêu chết thì con trỏ sẽ chuyển sang 1 nhân vật mục tiêu khác.
Bạn có thể hình dung con trỏ giống như 1 mũi tên trỏ vào nhân vật bên địch.
Trong chương sau, chúng ta sẽ xem đoạn mã kiểu này được viết thế nào. Tôi đang nghĩ biết đâu chúng ta có thể viết 1 mini-RPG (trò chơi nhập vai) trong các bài tập ở chương sau nhỉ . Nhưng thôi, để sau hãy nói.
Con trỏ còn cho phép chương trình thay đổi xử lý tùy thuộc vào lựa chọn của người dùng. Hãy lấy ví dụ của 1 bài kiểm tra trắc nghiệm, trong đó chúng ta yêu cầu người dùng chọn 1 trong 3 câu trả lời cho 1 câu hỏi. Sau khi người dùng trả lời xong, con trỏ sẽ trỏ đến câu trả lời được chọn.
#include <iostream> #include <string> using namespace std; int main(){ string cauTraLoiA, cauTraLoiB, cauTraLoiC; cauTraLoiA = "Nam 2000"; cauTraLoiB = "Ngay tan the"; cauTraLoiC = "Ky nguyen moi"; cout << "Y nghia cua Y2K la gi ? " << endl; //Dat cau hoi cout << "A) " << cauTraLoiA << endl; //Hien thi 3 cau tra loi cout << "B) " << cauTraLoiB << endl; cout << "C) " << cauTraLoiC << endl; char cauTraLoi; cout << "Cau tra loi cua ban la (A,B hay C) : "; cin >> cauTraLoi; //Nhan cau tra loi tu nguoi dung string *cauTraLoiCaNhan(0); //Con tro tr ove cau tra loi switch(cauTraLoi){ case 'A': cauTraLoiCaNhan = & cauTraLoiA; //Thay doi con tro tuy theo lua chon cua nguoi dung break; case 'B': cauTraLoiCaNhan = & cauTraLoiB; break; case 'C': cauTraLoiCaNhan = & cauTraLoiC; break; } //Hien thi cau tra loi duoc chon nho con tro cout << "Ban da chon cau tra loi la : " << * cauTraLoiCaNhan << endl; return 0; }
Trong trường hợp này, biến sẽ mang 1 giá trị mà chúng ta không biết trước do nó phụ thuộc vào câu trả lời của người dùng. Vì thế chương trình sẽ diễn tiến khác nhau tùy theo giá trị được nhập.
Trong 3 trường hợp chúng ta đã nêu ở trên thì đây là trường hợp hiếm gặp nhất nhưng vẫn nên biết là đôi khi chúng ta sẽ rơi vào trường hợp này.
&
để lấy ra giá trị của địa chỉ của biến, ví dụ : &tenBien
.int *conTro;
(ví dụ cho những con trỏ về kiểu int
).*conTro
.new
và giải phóng vùng nhớ với phép toán delete
.