----> Lời khuyên cho các bạn bắt đầu học lập trình: nếu có tham khảo code trên mạng thì các bạn nên xem nó và tự viết lại rồi tìm hiểu xem từng phần trong code ý nó có mục đích là gì chứ đừng nên copy và paste một cách máy móc mà mình chả hiểu được code ý nó viết gì.
Đối tượng là một thành phần quan trọng trong công nghiệp phần mềm hiện nay. Có thể nói, nhờ có đối tượng, chúng ta có thể thiết kế, phát triển phần mềm linh hoạt hơn rất nhiều. Chúng ta sẽ làm quen với đối tượng, từ từ thôi.
Chúng ta đã gần tới đích, riêng bài này, ngôn ngữ C không thể thực hiện, vì đây là một trong số những cải tiến mới rất đặc biệt của C++: khả năng hỗ trợ lập trình hướng đối tượng (Object Oriented Programing – OOP). Nhờ đối tượng, chúng ta có thể giải quyết nhiều bài toán rắc rối bằng cách tổ chức mã nguồn nhỏ lẻ, đơn giản nhưng hiệu quả. Giới thiệu thế thôi.
Vào vấn đề chính, hướng đối tượng là gì?
Đây là một phong cách lập trình mới dựa trên thực tế đời sống chúng ta. Nhìn quanh căn phòng bạn đang ở, nó được cấu tạo nên bởi rất nhiều vật thể, ví dụ như cái bàn, cái ghế,… Những thứ đó tôi gọi là đối tượng. Trong C++, chúng ta có thể tạo nên chúng, một đối tượng ‘ảo’. Phong cách lập trình sử dụng đối tượng làm mấu chốt, chúng ta gọi là hướng đối tượng.
Xét một đối tượng, ví dụ cái quạt. Nó có gì, màu trắng, nó cao 1m, rộng 30cm2. Những thứ tôi vừa kể được sử dụng để mô tả cái quạt, trong hướng đối tượng, nó gọi là thuộc tính của cái quạt. Những gì quạt có thể làm, ví dụ như “Quay” giúp không khí lưu thông gọi là hành động của nó – khi lập trình chúng ta gọi tên nó là hàm (phương thức).
Nói một chút về struct. Bạn biết struct có biến thành phần, nhưng nó không có khả năng làm cái gì hết (tạm coi như thế đi). Chúng ta chỉ có thể đặt giá trị, truy cập các biến thành phần của nó, hết! Vì thế struct không đáp ứng yêu cầu của một đối tượng là phải có hành động (hay còn gọi là hàm). Lại nói về fstream bạn đã học ở bài trước, khi khai báo biến f thuộc kiểu fstream, bạn có thể sử dụng các hàm như open, close thao tác trên chính nó biến đó. Vậy, fstream có đủ tính chất của đối tượng. Thực tế, fstream chính là đối tượng. Có thể bạn đã thắc mắc từ bài trước, hàm open khi sử dụng lại viết f.open(…). Nếu muốn áp dụng hàm open cho biến f2, thì f2.open(…) chứ không được viết f.open(…). Chúng ta sẽ giải quyết vấn đề này bên dưới.
Đối tượng trong C++ có tên gọi là lớp. Nó là một kiểu dữ liệu, giống như struct, bạn phải định nghĩa nó trước khi sử dụng. Khuôn mẫu của lớp như sau:
1 2 3 | class MyClass{ // Thân class }; |
Gần giống như struct, chỉ thay đổi từ khoá thành class thôi. Tiếp, chúng ta cần khai báo danh sách biến thành phần – còn gọi là thuộc tính hay biến thành viên (member) – cho lớp. Khai báo biến như bình thường bạn vẫn làm với struct.
1 2 3 | class MyClass{ int i, j; }; |
Bạn đã định nghĩa xong class rồi đấy. Sử dụng nó ngay không?
1 2 3 4 | int main(){ MyClass cl; return 0; } |
Cho tới bây giờ, class vẫn y chang như struct. Trước khi đi tiếp, tôi trình bày cho bạn hai khái niệm là biến thể (instance) và khuôn mẫu (template) trong class. Khuôn mẫu là những gì bạn đang và đã thiết kế, thể hiện qua quá trình định nghĩa. Nó chỉ là những bản vẽ của bạn, chứ không phải là một đối tượng thực. Ở trên, tôi thiết kế class bao gồm hai thuộc tính là i, j có cùng kiểu integer. Đó là khuôn mẫu, chứ không phải đối tượng thực tế. Học cao hơn, bạn sẽ gặp từ template thường xuyên hơn đó.
Từ khuôn mẫu, bạn tạo ra đối tượng thật, khi viết MyClass cl trong hàm main thì cl được gọi là biến thể (instance) của khuôn mẫu. Tức là bạn sử dụng khuôn mẫu, để tạo ra biến thể thực. Không chỉ một, bạn có thể tạo ra rất rất nhiều biến thể. Điểm khác biệt giữa các biến thể là chúng có thuộc tính khác nhau do nằm trên vùng nhớ khác nhau, không liên quan gì tới nhau hết (tương đương như struct). Như một chiếc ô tô khi xuất xưởng, nó đều được làm từ cùng 1 bản vẽ (template) nhưng lại có thuộc tính khác nhau là màu sơn. Chiếc thì màu đỏ, chiếc thì màu xanh. Ta nói, hai xe cùng thuộc một loại, nhưng là hai biến thể, không phải một. Mỗi chiếc là một biến thể. Lý thuyết là thế đó, từ từ hiểu cũng được.
Tự dưng nói tới đây tôi lại muốn thiết kế ô tô rồi, OK, sửa lại class MyClass thành class Oto thôi. Hiệu Ford đi (lần này tôi có thêm một hàm thành viên tên Chay):
1 2 3 4 5 | class Ford{ int mau; int chieucao; void Chay(); }; |
Tôi thiết kế nó có hai thuộc tính màu và chiều cao (ô tô thực tế có nhiều thuộc tính lắm, liệt kê hết làm gì cho mệt). Ổn chưa? Xong phần thiết kế, bây giờ tôi vào trong hàm main, tạo ra 2 cái ô tô thật.
1 2 | Ford f1; Ford f2; |
Đó là hai đối tượng của chúng ta. Để truy cập các thuộc tính (thành viên – member), bạn thao tác giống như struct, sử dụng dấu chấm:
1 | f1.mau = 1; |
Oh, bạn thử chưa, Visual sẽ báo lỗi: member “Ford::mau” is inaccessible – không thể truy cập biến thành viên! Chuyện gì đã xảy ra?
Đây là điểm khác thứ hai của class. Mặc định, các thuộc tính của class không thể để cho bên ngoài truy cập (ví dụ như hàm main). Khác với struct cho truy cập thoải mái. Hiện tại, thuộc tính mau và chieucao đang ở trong trạng thái “private” (cá nhân): bên ngoài không thể truy cập, trạng thái mặc định nếu bạn không chỉ định. Để thay đổi, bạn hãy thêm dòng “public:” phía trên chúng:
1 2 3 4 5 | class Ford{ public: int mau; int chieucao; }; |
Khi này hai thuộc tính đã được dán nhãn “công cộng”, mọi người đều có thể truy cập. Đó là trạng thái của biến.
Trong class có hai loại trạng thái chính:
Cách sử dụng chúng tôi đã nêu ở trên, ngoại trừ vấn đề tầm vực của nó. Một khi đã tuyên bố trạng thái, từ đó trở đi, tất cả các biến, hàm sẽ nhận trạng thái đó cho đến khi bạn tuyên bố trạng thái khác. Tuyên bố bằng cách dùng “private:” hay “public:”.
Ngoài việc có thể lưu trữ thuộc tính (member), class còn có thể chứa hàm (ví dụ Chay() của lớp Ford). Hàm thì giống nhau giữa tất cả các biến thể. Nhưng các biến thể có thuộc tính khác nhau, nên nhiều lúc hàm sẽ có phản ứng khác. Hàm thành viên của lớp có đặc quyền truy cập tất cả các biến thành viên của lớp đó, lớp mà hàm được gọi. Gọi hàm, chúng ta làm theo cách:
1 | f1.Chay(); |
Sử dụng dấu chấm, sau đó là tên hàm, kết thúc bởi danh sách tham số. Bạn thấy quen thuộc không, kiểu dữ liệu fstream có khả năng này đó. Bởi vì thực tế, chúng là đối tượng.
Tôi nhắc lại: Hàm thành viên giống nhau giữa tất cả các biến thể. Hàm thành viên có đặc ân lớn là khả năng truy cập tới tất cả biến thành viên của class. Làm sao để biết hàm sẽ truy cập biến thành viên của class nào? Dựa vào lời gọi hàm: f1.Chay() thì f1 chính là biến thể mà hàm Chay() sẽ sử dụng. Dựa vào biến thành viên mà hàm sẽ có phản ứng khác nhau.
Để khai báo hàm thành viên, bạn chỉ cần khai báo chúng bên trong khuôn mẫu của class như tôi đã làm. Bạn có thể chỉ khai báo thôi, không viết phần thân cũng được. Tuy thân hàm nhất định phải viết, nhưng nó không bắt buộc bạn phải viết trong khuôn mẫu. Nên nhớ, thân hàm chỉ có 1, không 2.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | #include <iostream> using namespace std; class Ford{ public: int mau; int chieucao; // tôi viết thân hàm ngay bên trong khuôn mẫu class void Chay(){ // hàm này không sử dụng biến thành viên, // nó luôn làm một chuyện: in dòng chữ ra ngoài cout << "Khong chay duoc!" << endl; } }; int main(){ Ford f1, f2; f1.Chay(); // gọi hàm thành viên f2.Chay(); return 0; } |
Ấn F5 và xem kết quả, hàm Chay() được gọi hai lần bởi hai biến thể, kết quả in ra y chang nhau do tôi không sử dụng lợi thế của biến thành viên. Đồng thời tôi cũng ví dụ cho bạn việc viết hàm ngay bên trong khuôn mẫu class. Bây giờ tôi sẽ thêm một hàm thành viên nữa, nhưng sẽ viết thân hàm bên ngoài khuôn mẫu.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | #include <iostream> using namespace std; class Ford{ public: int mau; int chieucao; void Chạy(){ cout << "Khong chay duoc!" << endl; } void InMau(); // tôi viết thân hàm bên ngoài }; int main(){ Ford f1, f2; f1.mau = 1; f2.mau = 2; f1.InMau(); // gọi hàm thành viên f2.InMau(); } void Ford::InMau(){ // quy ước 1 là màu đỏ, còn lại là màu xanh if (mau == 1) cout << "Mau do!" << endl; else cout << "Mau xanh!" << endl; } |
Dấu :: có nghĩa là bạn đang truy cập tới thành phần của class. Lần này, hàm InMau sẽ dựa vào biến thành phần của class. Xe f1 tôi gán giá trị 1 cho mau, xe f2 tôi gán giá trị 2. Sau đó gọi hàm InMau của f1. Hàm InMau sẽ chạy, truy cập tới biến thành viên mau của f1, lúc này giá trị của nó là 1, nên hàm in “Mau do” ra màn hình. Lần gọi hàm thứ hai, chúng ta gọi hàm InMau của f2, biến thành viên mau của f2 có giá trị 2 nên nó in “Mau xanh” là chuyện hiển nhiên rồi.
Dành cho những bạn còn thắc mắc (nhắc lại): hai đối tượng f1, f2 được tạo ra từ khuôn mẫu Ford, chúng không liên quan gì tới nhau sau khi tạo ra. Mỗi đối tượng được cấp cho một vùng nhớ riêng để lưu các biến thành viên, nên biến mau của f1 khác biến mau của f2.
Lại nói về vòng đời, một biến thể được tạo ra và mất đi giống quy luật của biến. Nó được tạo khi bạn khai báo, mất đi khi kết thúc khối lệnh.
1 2 3 4 5 6 | int main(){ Ford f1, f2; // f1 và f2 được sinh ra // Làm cái gì đó đó return 0; } // tới đây, f1 và f2 đã bị huỷ |
Hỏi cho vui: nếu viết như thế này thì vòng đời của đối tượng sẽ như thế nào?
1 2 3 4 5 | int main(){ Ford *f; // Làm cái gì đó đó return 0; } |
Chẳng có thế nào hết, không có đối tượng nào được tạo ra cũng như mất đi. Vì tôi chỉ khai báo con trỏ, trỏ tới kiểu Ford chứ chưa hề khai báo đối tượng. Để tạo đối tượng, tôi dùng cấp phát động:
1 2 3 4 5 6 | int main(){ Ford *f = new Ford; // cấp phát bộ nhớ cho Ford // TODO: ... delete f; // dùng xong rồi thì xoá return 0; } |
Bạn thấy cách sử dụng chúng rất quen thuộc phải không.
Bài tập áp dụng: tạo class DiemCacMon với các thuộc tính Van, Toan, TiengAnh,… bao nhiêu tuỳ thích. Một hàm tính điểm trung bình các môn, một hàm phân loại trình độ học sinh (yếu, trung bình, khá, giỏi, xuất sắc). Sau đó viết chương trình yêu cầu người dùng nhập điểm các môn rồi in ra kết quả trung bình và xếp loại.
Gợi ý: tôi mô tả tường minh rồi, còn gợi ý nào nữa đâu.
Làm đi nhé!
Công việc đầu tiên cần làm là thiết kế khuôn mẫu (định nghĩa) class, tên của nó là DiemCacMon. Các thuộc tính điểm và hàm thành viên tôi cho hết nó vào public. Điểm số bạn quyết định sử dụng số thực hay số nguyên? Chọn số thực đi. Về phần hàm thành viên, hàm TinhDiemTrungBinh có kiểu trả về là float, không có tham số. Hàm xếp loại sẽ dựa vào hàm tính trung bình đó để phân loại học sinh.
Code tham khảo:
Phần 1: Thiết kế class và hàm thành viên
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | #include <iostream> using namespace std; class DiemCacMon{ public: float Van, Toan, TiengAnh; float TinhDiemTrungBinh(); void InXepLoai(); }; void DiemCacMon::InXepLoai(){ // trước hết cần tính điểm trung bình float tb = TinhDiemTrungBinh(); if (tb >= 9.5) // chúng ta sử dụng phương án loại trừ cout << "Xuat sac" << endl; else if (tb >= 8) cout << "Gioi" << endl; else if (tb >= 7) // 7 hay 7.5? cout << "Kha" << endl; else if (tb >= 5) cout << "Trung binh" << endl; else cout << "Yeu"; } float DiemCacMon::TinhDiemTrungBinh(){ float tb = Van + Toan + TiengAnh; tb = tb / 3; // trung bình 3 số return tb; } |
Phần 2: hàm main
1 2 3 4 5 6 7 8 9 10 | int main(){ DiemCacMon diem; cout << "Diem Toan: "; cin >> diem.Toan; cout << "Diem Van: "; cin >> diem.Van; cout << "Diem Tieng Anh: "; cin >> diem.TiengAnh; cout << "Diem trung binh: " << diem.TinhDiemTrungBinh() << endl; cout << "Xep loai: "; diem.InXepLoai(); return 0; } |
Bạn thấy không, bằng cách viết hàm vào trong class, hàm main() của chúng ta trông thật đơn giản và dễ hiểu.
Hai đứa nó gần giống nhau.
Struct lưu tất cả các biến thành viên của nó trong bộ nhớ. Nếu có 3 biến int, nó sẽ chiếm 12 byte. Class cho dù có thêm các hàm thành viên, những những gì được lưu trong bộ nhớ chỉ có biến thành viên. Dù class có bao nhiêu hàm đi chăng nữa mà chỉ có 1 biến int thành viên, kích thước nó chiếm trong bộ nhớ có 4 byte. Bạn cần chú ý chuyện này khi lưu class vào file nhá.
Tất cả biến thành viên của struct chỉ có trạng thái public, cho phép truy cập tất cả. Trong khi đó, nếu không chỉ định, trạng thái mặc định cho hàm và biến thành viên của class là private. Do không có trạng thái private, mức độ bảo mật của struct kém hơn.
Ví dụ dùng trạng thái private, class Count có một biến i, bạn không cho phép giá trị của nó giảm xuống:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | #include <iostream> using namespace std; class Count{ private: // biến đếm đặt trong vùng bảo vệ // chỉ có hàm thành viên mới có thể truy cập int i; public: void AddOne(){ // nếu muốn tăng giá trị của i, gọi hàm này i++; } // lấy giá trị của i hiện tại int CurrentValue(){ return i; } // tạo giá trị ban đầu cho i void AssignValue(int value){ if (value >= 0) // chỉ gán giá trị dương i = value; else i = 0; } }; int main(){ // hàm main không thể truy cập, đặt giá trị tuỳ i cho i được // mà phải thông qua hàm AssignValue Count c; c.AssignValue(0); // tương đương c.i = 0 nếu i public c.AddOne(); // tăng giá trị i lên 1 cout << c.CurrentValue(); return 0; } |
Giá trị i trong class Count không bao giờ có thể nhận giá trị âm, hàm AssignValue đã quản lý việc này.
Bạn định nghĩa struct như định nghĩa một kiểu dữ liệu mới. Khi định nghĩa class, chúng ta cũng đang làm chuyện đó.
Lấy ví dụ về class Count tôi làm ở trên. Biến i đươc khai báo trong class, trước khi có thể gọi hàm AddOne, biến i bắt buộc phải được khởi tạo một giá trị nào đó. Nếu không quá trình sử dụng class sẽ không như ý muốn. Đúng không, khi khai báo i, i nhận giá trị rác. Không gán giá trị cho nó nên khi AddOne, bạn đang tăng giá trị rác lên 1. Thử xem, xoá dòng AssignValue trong hàm main đi, F5 kiểm tra.
Liệu có hàm nào mặc định sẽ được chạy khi đối tượng được tạo ra nhằm mục đích khởi tạo giá trị cho i không? Hàm như thế C++ gọi là hàm dựng (constructor), cách viết hàm dựng như sau:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | #include <iostream> using namespace std; class Count{ private: int i; public: // hàm dựng có tên trùng với tên class, không có kiểu trả về Count(){ // hàm này sẽ được gọi khi đối tượng được tạo ra i = 0; // chúng ta khởi tạo giá trị 0 cho i } void AddOne(){ i++;} int CurrentValue(){ return i; } }; int main(){ Count c; // hàm dựng được thực thi tại đây c.AddOne(); // tăng giá trị i lên 1 // tại đây, chúng ta sẽ in giá trị 1 // chứ không phải giá trị rác nữa cout << c.CurrentValue(); return 0; } |
Hàm dựng được thực thi khi đối tượng được tạo ra. Nếu khai báo bình thường như trên, bạn biết thời điểm mà hàm dựng của Count chạy rồi chứ. Tôi đã chú thích rồi. Còn nếu sử dụng con trỏ, hàm dựng thực thi khi bạn cấp phát bộ nhớ cho nó!
Hỏi nhỏ: liệu hàm cấp phát bộ nhớ malloc có thể thực hiện giống như new đã làm không? Trả lời: không, malloc chỉ cung cấp bộ nhớ, nó không chạy thêm hàm nào khác. Trong khi đó, new không chỉ cấp phát bộ nhớ, mà nó còn chạy hàm dựng của chúng ta.
Hàm dựng có tham số, nó sẽ giúp bạn yêu cầu người dùng cung cấp giá trị nào đó khi khởi tạo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | #include <iostream> using namespace std; class Count{ private: int i; public: // hàm dựng có tên trùng với tên class, không có kiểu trả về Count(){ i = 0; // mặc định là 0 } Count(int start){ // chấp nhận giá trị khởi tạo từ người dùng // hàm chỉ chạy khi gọi Count x(5); i = 0; if (start >= 0) i = start; } // hàm huỷ: tên giống tên class, có kí tự ~, không tham số ~Count(){ } // thực chất không phải hàm hủy không có gì mà IDE nó sẽ tự tạo hàm hủy cho chúng ta mà chúng ta không cần phải làm gì. void AddOne(){ i++;} int CurrentValue(){ return i; } }; int main(){ Count c(5); // yêu cầu khởi tạo giá trị 5 c.AddOne(); // tăng giá trị i lên 1 cout << c.CurrentValue(); // in ra 6 return 0; } |
Tôi viết hai hàm dựng, nó khác nhau tham số. Nếu không cung cấp như ví dụ trước, mặc định hàm dựng không tham số sẽ được gọi, giá trị i = 0. Nếu tôi cung cấp tham số như trên, hàm dựng tương ứng sẽ được gọi, giá trị i được gán bằng với giá trị tôi đã cung cấp.
Tương ứng với hàm dựng, C++ cung cấp thêm hàm huỷ. Có 2 mục đích thường thấy khi sử dụng hàm huỷ, một là đóng các file đang mở (nếu có truy cập tập tin), hai là huỷ vùng nhớ nếu có sử dụng cấp phát động. Hàm huỷ được gọi khi đối tượng sắp “biến” khỏi bộ nhớ.
Dưới đây tôi sẽ trình diễn một class có sử dụng cấp phát động, các bạn hãy nghiên cứu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | // class String hỗ trợ các thao tác trên chuỗi class String{ private: char* m_str; // chuỗi kí tự public: String(); String(const char* src); ~String(); int Length(); // trả về độ dài void Set(const char* str); // gán bằng void View(); // in ra màn hình void Cat(const char* str); // gắn chuỗi vào cuỗi }; String::String(){ m_str = NULL; // đảm bào m_str chưa trỏ tới vùng dữ liệu nào } String::String(const char *src){ int l = strlen(src); // lấy độ dài chuỗi m_str = new char[l + 1]; // tạo vùng nhớ strcpy(m_str, src); // chép qua } String::~String(){ if (m_str) // nếu m_str đang trỏ tới dữ liệu delete[] m_str; // thì huỷ vùng nhớ đó } int String::Length(){ if (m_str == NULL) // nếu chưa có gì thì trả về 0 return 0; else return strlen(m_str); } void String::Set(const char* str){ if (m_str) delete[] m_str; // xoá vùng nhớ hiện tại đi m_str = NULL; // đảm bảo chưa trỏ tới cái nào if (str){ // nếu có dữ liệu int l = strlen(str) + 1; m_str = new char[l]; // thì tạo cấp phát vùng nhớ strcpy(m_str, str); // sau đó chép dữ liệu qua } } void String::View(){ if (m_str) cout << m_str; } void String::Cat(const char* str){ int l1 = 0, l2 = 0; char* _new; // mảng tạm để lưu cả 2 chuỗi if (!str) return; // không xử lý nữa khi không có str if (m_str) l1 = strlen(m_str); // độ dài chuỗi hiện tại l2 = strlen(str); // độ dài chuỗi cần nối _new = new char[l1 + l2 + 1]; // cấp phát đủ vùng nhớ cần thiết if(m_str) strcpy(_new, m_str); // chép chuỗi hiện tại qua strcpy(_new + l1, str); // chép tiếp vào đằng sau delete[] m_str; // xoá vùng nhớ cũ m_str = _new; // cập nhật vùng nhớ mới } |
CHÚ Ý: quan trọng nhất trong các thao tác đó là hàm Cat và hàm huỷ, bạn hãy để ý kĩ chúng.
Sử dụng:
1 2 3 4 5 6 7 8 | int main(){ String s; s.Set("Hello everybody!"); s.View(); cout << endl; s.Cat(" OK boys!"); // nối thêm vào s.View(); } |
Đó là những kiến thức cơ bản về hướng đối tượng. Hãy hệ thống lại chúng:
Bổ sung thêm: vòng đời của class mô tả qua sơ đồ (Class Lifetime)
Tham khảo thêm: http://en.wikipedia.org/wiki/C%2B%2B_classes.