Trong các bài học trước, tôi đã cố ý tránh sử dụng con trỏ cùng với các lớp. Thực ra, con trỏ trong C++ là 1 chủ đề khá rộng và nhạy cảm. Như các bạn có thể nhận thấy, nếu thao tác với con trỏ thì cần phải rất thận trọng vì một lỗi nhỏ nhất có thể đem đến cho chúng ta những phiền phức rất lớn :
Vậy làm sao để sử dụng các lớp cùng với con trỏ ? Các nguyên tắc cần tuân theo là gì ?
Đừng lo, chúng ta sẽ dành hẳn 1 bài học dưới đây để trả lời cho những câu hỏi đó.
! Tôi đánh giá mức độ khó của bài học này là rất cao. Dù sao thì chúng ta cũng đang làm việc với 1 trong những công cụ mạnh mẽ nhất của C++. Vì vậy bài học này đòi hỏi tập trung cao hơn nhiều những bài học chúng ta đã xem trong quá khứ.
Chúng ta hãy quay về với lớp NhanVat quen thuộc. Trong bài học trước, chúng ta đã trực tiếp tạo ra 1 thuộc tính là 1 đối tượng VuKhi.
class NhanVat{ public: //Vai phuong thuc private: Vukhi m_vuKhi; //… };
Trong thực tế, có rất nhiều cách để liên kết các lớp với nhau. Cách mà chúng ta đã sử dụng khá hiệu quả. Tuy nhiên sự tồn tại của đối tượng VuKhi gắn liền với đối tượng NhanVat và không thể tách rời được. Đấy là 1 bất lợi.
Chúng ta có thể hình dung quan hệ của 2 đối tượng như sau :
Đối tượng VuKhi tồn tại bên trong NhanVat.
Một kỹ thuật khác trong C++ cho phép chúng ta liên kết các đối tượng, phức tạp hơn nhưng giữa các đối tượng lại có ít tính ràng buộc hơn, đấy là sử dụng 1 con trỏ thay vì tích hợp trực tiếp đối tượng VuKhi vào trong NhanVat. Đoạn mã khai báo sẽ không thay đổi nhiều so với lúc trước, chỉ thêm 1 dấu *
.
class NhanVat{ public: //Vai phuong thuc private: Vukhi *m_vuKhi; //Thuoc tinh nay la 1 con tro tro len 1 VuKhi //… };
Lúc này chúng ta không thể coi VuKhi như là 1 bộ phận của NhanVat được nữa.
Chúng ta coi VuKhi nằm ngoài NhanVat.
Một số điểm mạnh của kiểu liên kết này là :
! Những lợi thế này cho phép chúng ta giải quyết vấn đề gặp phải trong ví dụ trò chơi Warcraft ở bài trước khi chúng ta muốn thay đổi mục tiêu của nhân vật nhờ 1 con trỏ nội tại.
Ưu điểm là vậy nhưng không thể bỏ qua nhược điểm của kỹ thuật này. Các bạn sẽ thấy là thao tác với lớp có thêm con trỏ sẽ trở nên vô cùng đau đầu và không còn dễ dàng nữa.
! Hãy ghi nhớ là không có giải pháp nào là tối ưu cho mọi trường hợp. Mỗi phương pháp đều co điểm mạnh và điểm yếu của nó. Các bạn cần phải tự mình quyết định trong mỗi trường hợp xem có nên hay không sử dụng 1 con trỏ bên trong 1 đối tượng vì để đổi lấy các tính năng ưu việt, các thao tác sẽ trở nên rắc rối hơn nhiều.
Bắt đầu từ đây, chúng ta sẽ xem làm thế nào để thao tác với lớp có con trỏ. Ví dụ của chúng ta chính là trường hợp của lớp NhanVat mà tôi đã nêu bên trên.
class NhanVat{ public: //Vai phuong thuc private: Vukhi *m_vuKhi; //Thuoc tinh nay la 1 con tro tro len 1 VuKhi //… };
! Tôi cố ý không viết đoạn mã hoàn chỉnh để các bạn có thể chú tâm đến trọng điểm.
Vũ khí của chúng ta bây giờ chỉ là 1 con trỏ. Cần phải tạo ra 1 thực thể thông qua phân bổ động nhờ new
, nếu không thì đối tượng sẽ không tự mình sinh ra được.
Các bạn đoán ra nơi chúng cần thêm vào đoạn mã phân bổ động cho đối tượng VuKhi chứ ? Thật ra thì cũng không có quá nhiều lựa chọn : chúng ta cần thực hiện bên trong phương thức khởi tạo ! Trên thực tế thì cũng khá dễ hiểu bởi vì phương thức khởi tạo là nơi đảm bảo 1 đối tượng được tạo ra hoàn chỉnh, nghĩa là nếu bên trong nó có con trỏ thì con trỏ này cần trỏ tới 1 cái gì đó.
Trong trường hợp của chúng ta, chúng ta bắt buộc cần sử dụng phân bổ động thông qua new
. Phương thức khởi tạo mặc định của lớp sẽ có 1 số thay đổi.
NhanVat::NhanVat() : m_vuKhi(0), m_hp(100), m_mp(100){ m_vuKhi = new VuKhi(); }
Nếu tôi nhớ không lầm thì chúng ta còn tạo ra 1 phwuong thức khởi tạo khác cho phép người dùng sử dụng 1 vũ khí khác với vũ khí mặc định. Phương thức này cũng sẽ cần thêm phép phân bổ động.
NhanVat::NhanVat(string vuKhi, int dmgVuKhi) : m_vuKhi(0), m_hp(100), m_mp(100){ m_vuKhi = new VuKhi(vuKhi, dmgVuKhi); }
new VuKhi()
; sẽ gọi phương thức khởi tạo mặc định của VuKhi trong khi new VuKhi(vuKhi, dmgVuKhi)
; sẽ gọi phương thức khởi tạo nạp chồng của lớp này. new
sẽ trả về địa chỉ của đối tượng được tạo ra và giá trị này sẽ được lưu vào con trỏ m_vuKhi
.
Để đảm bảo, chúng ta sẽ khởi tạo con trỏ với giá trị là 0 trong danh sách khởi tạo, sau đó mới thực hiện phân bổ động trong xử lý của phương thức.
Bởi vì vũ khí của chúng ta bây giờ là 1 con trỏ nên khi chúng ta muốn xóa đối tượng NhanVat, đối tượng VuKhi sẽ không tự động biến mất. Nếu chúng ta chỉ thêm new
vào phương thức khởi tạo mà không thêm gì vào phương thức hủy, chúng ta sẽ gặp vấn đề khi đối tượng NhanVat bị xóa.
Đối tượng NhanVat được xóa hoàn toàn nhưng đối tượng VuKhi thì vẫn tồn tại. Nguy hiểm hơn là không có con trỏ nào lưu trữ giá trị của ô nhớ chứa đối tượng này. Vậy nên đối tượng sẽ vẫn luôn tồn tại trong bộ nhớ và chúng ta mãi chẳng thể nào xóa nó đi được. Đây chính là cái mà chúng ta gọi là rò rỉ bộ nhớ.
Để giải quyết vấn đề này, cần phải thực thi delete
đối tượng VuKhi trong phương thức hủy của NhanVat để xóa đối tượng VuKhi trước khi xóa NhanVat. Mã thực thi khá là đơn giản.
NhanVat::~NhanVat(){ delete m_vuKhi; }
Lúc này thì phương thức hủy trở nên quan trọng và không thể thiếu. Từ lúc này, mỗi khi có ai đó yêu cầu xóa NhanVat, chương trình sẽ thực hiện những xử lý sau :
delete
.Cuối cùng thì cả 2 đối tượng đều được xóa và bộ nhớ trở lại gọn gàng.
Chú ý là m_vuKhi
bây giờ là 1 con trỏ. Điều đó có nghĩa là chúng ta phải thay đổi tất cả các phương thức sử dụng thuộc tính này. Ví dụ :
void NhanVat::tanCong(NhanVat &mucTieu){ mucTieu.nhanSatThuong(m_vuKhi.getDmg()); }
sẽ trở thành
void NhanVat::tanCong(NhanVat &mucTieu){ mucTieu.nhanSatThuong(m_vuKhi->getDmg()); }
Hãy để ý là dấu .
đã bị thay thế bởi dấu ->
vì m_vuKhi
là 1 con trỏ.
this
Phần dưới đây, tôi xin trình bày với các bạn 1 khái niệm khá thú vị khi chúng ta thao tác với con trỏ cùng các lớp trong OOP, đấy là con trỏ this
.
Trong tất cả các lớp đều tồn tại 1 con trỏ đặc biệt tên là this
. Con trỏ này trỏ lên chính bản thân đối tượng. Tôi hiểu rằng là hơi khó để hình dung khái niệm này, vậy nên đã làm cho các bạn 1 hình vẽ.
Mỗi đối tượng đều có sở hữu con trỏ này.
! this
được sử dụng trong tất cả các lớp trong ngôn ngữ C++. Các bạn sẽ không thể tạo ra 1 biến tên là this bởi vì việc này sẽ sinh ra xung đột. Cũng vì lý do này, các bạn sẽ không thể tạo ra các biến hay hàm có tên là class
, new
, delete
, return
, vv… vì các từ khóa này đã được giữ bởi C++ dùng vào các mục đích riêng.
? Vậy con trỏ this
dùng để làm gì ?
Một câu hỏi khá tinh tế ! Con trỏ này thường được dùng khi một phương thức cần phải trả về giá trị là con trỏ trỏ về đối tượng bản thể của phương thức.
NhanVat* NhanVat::getDiaChi() const{ return this; }
Chắc các bạn vẫn còn nhớ là chúng ta đã từng dùng đến nó khi ghi đè phép toán operator+=
chứ.
ThoiGian& ThoiGian::operator+=(ThoiGian const& thoiGian2){ //Cac xu ly … return *this; }
this
là con trỏ lên bản thân đối tượng nên *this
chính là đối tượng. Lý do tại sao chúng ta cần trả về đối tượng thì khá là phức tạp để giải thích, chúng ta chỉ cần biết là đấy mới là nguyên mẫu chuẩn của phép toán, nên tốt nhất nếu có thể là các bạn nên học thuộc nó.
Ngoài trường hợp khi ghi đè phép toán, chúng ta sẽ không thường xuyên bắt gặp this
khi mới bắt đầu học lập trình. Tuy nhiên tôi vẫn giới thiệu con trỏ này với các bạn vì biết đâu có 1 ngày khi các bạn cần 1 con trỏ trỏ lên bản thân đối tượng thì các bạn biết rằng nó có tồn tại.
Tôi nhắc đến con trỏ này vì đây là thời điểm thích hợp nhất để giới thiệu nó với các bạn. Chúng ta sẽ chưa tận dụng được ngay lợi ích của nó nhưng sẽ rất có thể có cơ hội trong phần sau của giáo trình.
Như các bạn đã biết, phương thức khởi tạo sao chép là 1 phương thức nạp chồng đặc biệt của phương thức khởi tạo. Phương thức này trở nên quan trọng khi chúng ta thao tác với các lớp có chứa con trỏ như trường hợp ví dụ hiện tại của chúng ta.
Để hiểu rõ lợi ích của phương thức khởi tạo sao chép, chúng ta cần phải hiểu rõ xử lý của chương trình khi chúng ta muốn tạo ra 1 đối tượng dựa trên 1 đối tượng khác.
int main(){ NhanVat goliath("Kiem sat", 20); NhanVat david(goliath); //Tao ra david nhu 1 ban sao cua goliath return 0; }
Nhiệm vụ của phương thức khởi tạo sao chép là chép toàn bộ giá trị của các thuộc tính của đối tượng gốc sang đối tượng bản sao. Như vậy, đối tượng david sẽ có tất cả các đặc điểm của đối tượng goliath.
? Khi nào thì phương thức khởi tạo sao chép được sử dụng ?
Chúng ta đã thấy là phương thức khởi tạo sao chép được gọi khi mà chúng ta muốn tạo ra 1 đối tượng bằng cách truyền cho phương thức khởi tạo tham số là 1 đối tượng khác.
NhanVat david(goliath);
Xử lý hoàn toàn tương tự được thực hiện nếu chúng ta thực thi dòng lệnh sau.
NhanVat david = goliath;
Trong cả 2 trường hợp thì phương thức khởi tạo sao chép đều được sử dụng.
Không chỉ có thế, khi chúng ta truyền 1 đối tượng làm tham số cho hàm mà không sử dụng tham chiếu hay con trỏ thì đối tượng cũng được sao chép nhờ phương thức này.
Ví dụ như hàm sau đây :
void ham(NhanVat nhanVat){ }
Bởi vì tham số của hàm này được truyền không phải bằng cách sử dụng tham chiếu hay con trỏ nên đối tượng tham số sẽ được sao chép thông qua phương thức khởi tạo sao chép vào thời điểm mà hàm được gọi trong đoạn mã xử lý.
ham(goliath);
Đồng ý là chúng ta ưu tiên việc sử dụng tham chiếu để hạn chế sao chép đối tượng do sẽ làm tăng thời gian xử lý và tốn tài nguyên bộ nhớ. Thế nhưng cũng không thể loại trừ những trường hợp mà chúng ta bắt buộc phải sử dụng hàm thao tác với bản sao của đối tượng.
! Nếu các bạn không tự mình viết 1 phương thức khởi tạo sao chép cho lớp thì trình biên dịch sẽ tự động tạo ra 1 phương thức cho bạn. Vấn đề là trình biên dịch lại không quá thông minh trong vấn đề này. Phương thức được tự động tạo ra này chỉ đơn giản là sao chép giá trị của tất cả các thuộc tính, nghĩa là cả của con trỏ, lên đối tượng mới.
Vấn đề nằm chính ở chỗ này. Tại sao ? Bởi vì trong số thuộc tính của chúng ta có 1 con trỏ. Khi phương thức khởi tạo sao chép được gọi, nó sẽ chép giá trị của con trỏ này, tức là địa chỉ của đối tượng VuKhi, sang đối tượng bản sao. Kết quả là chúng ta sẽ có 2 đối tượng với thuộc tính con trỏ cùng trỏ tới 1 đối tượng VuKhi.
! Nếu chúng ta không giải quyết vấn đề này thì sẽ sinh ra rắc rối. Thử tưởng tượng nếu 2 nhân vật chiến đấu với nhau, 1 trong 2 bị tiêu diệt và bị xóa khỏi trò chơi thì vũ khí của nhân vật đó sẽ biến mất và nhân vật thứ 2 cũng mất luôn vũ khí của mình. Thêm nữa là khi nhân vật thứ 2 cũng bị tiêu diệt thì lệnh xóa vũ khí sẽ làm treo chương trình do vũ khí đã bị xóa từ trước rồi.
Nguồn gốc của vấn đề là do phương thức khởi tạo sao chép tạo ra bởi trình biên dịch không đủ thông minh để thực hiện phân bổ động tạo ra 1 đối tượng VuKhi mới. Vì thế nên chúng ta cần phải hướng dẫn nó thực hiện xử lý này.
Như đã nói bên trên thì phương thức khởi tạo sao chép chỉ là 1 phương thức nào chồng đặc biệt của phương thức khởi tạo mặc định. Phương thức này nhận vào 1 tham chiếu hằng trên 1 đối tượng cùng loại. Nếu các bạn vẫn thấy chưa rõ ràng thì sau đây là 1 ví dụ.
class NhanVat{ public: NhanVat(); NhanVat(NhanVat const& nhanVatGoc); //Nguyen mau phuong thuc khoi tao sao chep NhanVat(std::string vuKhi, int dmgVuKhi); ~NhanVat(); private: int m_hp; int m_mp; VuKhi *m_vuKhi; };
Tóm lại, nguyên mẫu tổng quát của phương thức này sẽ là :
DoiTuong(DoiTuong const& doiTuongGoc);
Từ khóa const
chỉ ra rằng chúng ta không có quyền thay đổi giá trị của tham chiếu.
Trong mã xử lý của phương thức, chúng ta sẽ phải chép các thuộc tính của doiTuongGoc
vào đối tượng mà chúng ta sẽ tạo ra. Sẽ dễ dàng hơn nếu chúng ta bắt đầu từ những thuộc tính thông thường, không phải con trỏ.
NhanVat::NhanVat(NhanVat const& nhanVatGoc) : m_hp(nhanVatGoc.m_hp), m_mp(nhanVatGoc.m_mp), m_vuKhi(0){ }
! 1 số bạn sẽ thắc mắc là làm thế naò mà chúng ta có thể truy cập đến các thuộc tính của đối tượng doiTuongGoc
. Nếu các bạn tự đặt ra câu hỏi đấy thì tôi xin chúc mừng vì các bạn đã bắt đầu ghi nhớ về tính đóng gói của OOP.
Trên thực tế, các thuộc tính trên có quyền truy cập « private » nên không thể sử dụng từ bên ngoài của lớp… trừ trong 1 trường hợp đặc biệt : nếu các bạn đang ở trong 1 phương thức của lớp thì bạn có quyền truy cập tới tất cả các thành phần của bất kỳ đối tượng nào của lớp kể cả khi thành phần đó có quyền truy cập là « private ».
Vậy là chỉ còn việc sao chép thuộc tính m_vuKhi
nữa là xong.
Nếu chúng ta sử dụng dòng lệnh sau :
m_vuKhi = nhanVatGoc.m_vuKhi;
thì cũng sẽ phạm phải cùng sai lầm như trình biên dịch, đấy là sao chép địa chỉ của đối tượng vũ khí chứ không phải bản thân đối tượng. Để giải quyết vấn đề này thì chúng ta cần thực hiện 1 phân bổ động với new
.
m_vuKhi = new VuKhi();
Dòng lệnh trên tạo ra 1 đối tượng mới nhưng vẫn không phải thứ chúng ta muốn vì nó sẽ chỉ tạo ra 1 đối tượng VuKhi cơ bản còn chúng ta muốn đối tượng VuKhi mới phải là bản sao của vũ khí của đối tượng nhanVatGoc
.
Thật may là chúng ta còn có thể lợi dụng phương thức khởi tạo sao chép được tự động tạo ra bởi trình biên dịch. Khi nào mà không một thuộc tính nào của lớp là con trỏ thì phương thức này còn hoạt động rất tốt. Vậy nên trong trường hợp của lớp VuKhi, chúng ta không cần đắn đo nhiều khi sử dụng phương thức này. Chỉ cần chú ý là tham số của phương thức này là bản thân đối tượng chứ không phải là địa chỉ của đối tượng được lưu bên trong con trỏ, Vậy nên chúng ta cần dùng đến dấu *
.
m_vuKhi = new VuKhi(*(nhanVatGoc.m_vuKhi));
Dòng lệnh trên sẽ thực sự tạo ra 1 đối tượng VuKhi mới dựa trên đối tượng vũ khí của nhân vật bị sao chép.
Tôi biết là khá lằng nhằng nhưng đừng ngại đọc lại từng bước lý luận của tôi và các bạn sẽ cảm thấy dễ hiểu hơn nhiều. Các bạn cũng cần nắm vững trước đó những kiến thức về con trỏ, tham chiếu và phương thức khởi tạo sao chép để có thể tiếp thu hoàn toàn những lập luận này.
Phương thức sao chép đúng sẽ có dạng như sau :
NhanVat::NhanVat(NhanVat const& nhanVatGoc) : m_hp(nhanVatGoc.m_hp), m_mp(nhanVatGoc.m_mp), m_vuKhi(0){ m_vuKhi = new VuKhi(*(nhanVatGoc.m_vuKhi)); }
2 đối tượng NhanVat giống nhau được tạo ra mà không gặp phải vấn đề được nêu bên trên.
Trong bài trước thì chúng ta từng đề cập đến việc ghi đè các phép toán. Thế nhưng còn 1 phép toán tôi chưa trình bày với các bạn, đấy là phép gán operator=
.
! Trình biên dịch cũng tự động tạo ra 1 phép gán mặc định cho lớp. Thế nhưng cũng giống với trường hợp của phương thức sao chép, phép toán này cũng có sai lầm khi chỉ đơn giản chép lại giá trị của các thuộc tính sang đối tượng mới.
operator=
sẽ được gọi khi chúng ta muốn gán giá tri của 1 đối tượng cho 1 đối tượng khác.
david = goliath;
! Không nên nhầm lẫn giữa phương thức sao chép với phép toán ghi đè operator=
. Chúng khá là giống nhau trừ việc là phương thức sao chép thì được gọi khi thực hiện khởi tạo đối tượng còn phép gán thì được sử dụng khi ta muốn gán 1 giá trị cho đối tượng sau khi đã khởi tạo trước đó.
NhanVat david = goliath; //Phuong thuc khoi tao sao chep david = goliath; //Phep gan
Xử lý của phép toán này giống hệt như phương thức sao chép nên đoạn mã của nó sẽ khá đơn giản nếu chúng ta đã hiểu được nguyên lý.
NhanVat& NhanVat::operator=(NhanVat const& nhanVatGoc) { if(this != &nhanVatGoc) { //Kiem tra xem ban the voi nhan vat muon sao chep co phai cung 1 doi tuong khong m_hp = nhanVatGoc.m_hp; m_mp = nhanVatGoc.m_mp; delete m_vuKhi; //Xoa vu khi cu m_vuKhi = new VuKhi(*(nhanVatGoc.m_vuKhi)); } return *this; }
Có 4 điểm khác biệt so với phương thức khởi tạo :
{}
.david = david;
. Để làm vậy thì chúng ta cần sử dụng đến địa chỉ của các đối tượng để so sánh.*this
giống như các phép toán khác.Các bạn sẽ thấy là với các lớp khác nhau thì phần khung của hàm này không có nhiều thay đổi, chỉ khác biệt do thuộc tính của các lớp khác nhau nên những lệnh gán sẽ khác nhau.
1 điều quan trọng cần ghi nhớ là việc ghi đè operator=
luôn đi kèm với việc sửa đổi phương thức khởi tạo sao chép. Không nên nhớ làm việc này mà quên đi việc kia vì chúng cần phải đi đôi với nhau. Chú ý là rất cần thiết phải tuân thủ quy tắc này, nếu không các bạn có thể gặp những vấn đề lớn với con trỏ.
Các bạn sẽ dần dần thấy lập trình hướng đối tượng trở nên phức tạp nhất là khi có sự tham gia của con trỏ. Thật may là trong phần sau, chúng ta sẽ thao tác với chúng nhiều hơn và tôi sẽ có cơ hội chỉ cho các bạn các sai lầm hay gặp để tránh mắc phải khi thao tác con trỏ.
this
tồn tại trong tất cả các đối tượng. Đó là con trỏ trỏ lên bản thân đối tượng.