Lập trình Hướng đối tượng (OOP) trong Java: Lớp, Đối tượng, Đóng gói, Kế thừa và Trừu tượng hóa
Học Lập trình Hướng đối tượng trong Java với các ví dụ thực tiễn. Nắm vững lớp, đối tượng, kế thừa, đóng gói và lớp trừu tượng thông qua hệ thống thực đơn nhà hàng.
Java luôn nằm trong top ba ngôn ngữ phổ biến nhất thế giới. Mức độ ứng dụng của Java trong các lĩnh vực như phát triển phần mềm doanh nghiệp, ứng dụng di động Android và ứng dụng web quy mô lớn là không gì sánh kịp. Hệ thống kiểu mạnh, hệ sinh thái phong phú và khả năng “viết một lần, chạy mọi nơi” khiến Java đặc biệt hấp dẫn để xây dựng các hệ thống vững chắc, có khả năng mở rộng. Trong bài viết này, chúng ta sẽ khám phá cách các tính năng lập trình hướng đối tượng của Java cho phép nhà phát triển khai thác hiệu quả những năng lực đó, giúp xây dựng ứng dụng dễ bảo trì và mở rộng thông qua tổ chức mã và tái sử dụng hợp lý.
Lưu ý về tổ chức và chạy mã Java
Trước khi bắt đầu viết mã, hãy thực hiện một số thiết lập.
Cũng như cú pháp, Java có các quy tắc nghiêm ngặt về tổ chức mã.
Trước hết, mỗi lớp public phải nằm trong một tệp riêng, được đặt tên chính xác theo tên lớp nhưng với phần mở rộng .java. Ví dụ, nếu tôi muốn viết lớp Laptop, tên tệp phải là Laptop.java—phân biệt chữ hoa chữ thường. Bạn có thể để các lớp không phải public trong cùng tệp, nhưng tốt nhất nên tách riêng. Tôi biết là chúng ta đang đi trước một bước—nói về tổ chức lớp trước khi viết chúng—nhưng có một ý niệm sơ bộ về nơi đặt mọi thứ từ trước luôn là ý hay.
Mọi dự án Java phải có tệp Main.java với lớp Main. Đây là nơi bạn kiểm thử các lớp bằng cách tạo đối tượng từ chúng.
Để chạy mã Java, chúng ta sẽ dùngIntelliJ IDEA, một IDE Java phổ biến. Sau khi cài đặt IntelliJ:
Tạo một dự án Java mới (File > New > Project)
Nhấp chuột phải vào thư mục src để tạo tệp Main.java và dán nội dung sau:
publicclassMain{publicstaticvoidmain(String[] args){// Create and tests objects here}}Được hỗ trợ bởi
Bất cứ khi nào nói về lớp, chúng ta sẽ viết mã trong các tệp khác ngoài tệp Main.java. Nhưng khi nói về việc tạo và kiểm thử các đối tượng, chúng ta chuyển sang Main.java.
Để chạy chương trình, bạn có thể nhấp nút phát màu xanh lá cạnh phương thức main:
Kết quả sẽ hiển thị trong cửa sổ Run ở phía dưới.
Nếu bạn hoàn toàn mới với Java, vui lòng xem khóa học Nhập môn Java, khóa học này bao quát các kiến thức nền tảng về kiểu dữ liệu và luồng điều khiển trong Java trước khi tiếp tục.
Nếu không, hãy bắt đầu ngay.
Các lớp và đối tượng trong Java
Vậy, lớp chính xác là gì?
Lớp là cấu trúc lập trình trong Java để biểu diễn các khái niệm trong thế giới thực. Ví dụ, hãy xem lớp MenuItem sau (tạo một tệp để viết lớp này trong IDE của bạn):
publicclassMenuItem{publicString name;publicdouble price;}Được hỗ trợ bởi
Lớp này cung cấp cho chúng ta một bản thiết kế hoặc khuôn mẫu để biểu diễn nhiều món trong thực đơn của nhà hàng. Bằng cách thay đổi hai thuộc tính của lớp, name, và price, chúng ta có thể tạo vô số đối tượng món như burger hay salad.
Vậy để tạo một lớp trong Java, bạn bắt đầu bằng một dòng mô tả mức truy cập của lớp (private, public, hoặc protected) theo sau là tên lớp. Ngay sau cặp ngoặc, bạn liệt kê các thuộc tính của lớp.
Nhưng làm thế nào để tạo đối tượng thuộc lớp này? Java cho phép điều đó thông qua các phương thức khởi tạo (constructor):
Constructor là một phương thức đặc biệt được gọi khi chúng ta tạo đối tượng mới từ một lớp. Nó khởi tạo các thuộc tính của đối tượng bằng các giá trị được truyền vào. Trong ví dụ trên, constructor nhận tham số name và price rồi gán cho các trường của đối tượng bằng từ khóa ‘this’ để tham chiếu tới thể hiện đối tượng sẽ được tạo.
Cú pháp của constructor khác với các phương thức lớp khác vì không yêu cầu chỉ định kiểu trả về. Ngoài ra, constructor phải trùng tên với lớp, và nên có cùng số thuộc tính như bạn đã khai báo sau định nghĩa lớp. Ở trên, constructor đang tạo hai thuộc tính vì chúng ta đã khai báo hai thuộc tính sau định nghĩa lớp: name và price.
Sau khi viết lớp và constructor của nó, bạn có thể tạo các thể hiện (đối tượng) trong phương thức main:
Ở trên, chúng ta tạo hai đối tượng MenuItem và gán vào biến burger và salad. Như Java yêu cầu, kiểu của biến phải được khai báo, ở đây là MenuItem. Sau đó, để tạo một thể hiện của lớp, chúng ta viết từ khóa new và gọi phương thức constructor.
Ngoài constructor, bạn có thể tạo các phương thức thông thường để bổ sung hành vi cho lớp. Ví dụ dưới đây, chúng ta thêm một phương thức tính tổng giá sau thuế:
publicclassMenuItem{publicString name;publicdouble price;// ConstructorpublicMenuItem(String name,double price){this.name = name;this.price = price;}// Method to calculate price after taxpublicdoublegetPriceAfterTax(){double taxRate =0.08;// 8% tax ratereturn price +(price * taxRate);}}Được hỗ trợ bởi
Giờ đây chúng ta có thể tính tổng giá đã bao gồm thuế:
publicclassMain{publicstaticvoidmain(String[] args){MenuItem burger =newMenuItem("Burger",3.5);System.out.println("Price after tax: $"+ burger.getPriceAfterTax());}}Được hỗ trợ bởi
Mục đích của lớp là cung cấp bản thiết kế để tạo đối tượng. Những đối tượng này sau đó sẽ được các script hay chương trình khác sử dụng. Ví dụ, các đối tượng MenuItem của chúng ta có thể được một giao diện người dùng dùng để hiển thị tên, giá và hình ảnh của chúng trên màn hình.
Vì lý do đó, chúng ta phải thiết kế lớp sao cho các thể hiện của nó chỉ có thể được sử dụng theo cách đã định. Hiện tại, lớp MenuItem của chúng ta còn rất cơ bản và dễ lỗi. Ai đó có thể tạo đối tượng với thuộc tính vô lý, như một chiếc bánh táo có giá âm hoặc một chiếc sandwich giá một triệu đô:
Vì vậy, việc đầu tiên sau khi viết một lớp là bảo vệ các thuộc tính của nó bằng cách hạn chế cách chúng được tạo và truy cập. Trước hết, chúng ta muốn chỉ cho phép các giá trị dương cho price và đặt một giá trị tối đa để tránh vô tình hiển thị các món có giá vô lý.
Java cho phép chúng ta làm điều này bằng các phương thức setter:
1. Chúng ta đặt các thuộc tính ở mức private bằng cách thêm từ khóa private. Điều này có nghĩa chúng chỉ có thể được truy cập bên trong lớp MenuItem. Đóng gói bắt đầu từ bước quan trọng này.
2. Chúng ta thêm một hằng số mới MAX_PRICE với các đặc điểm:
private (chỉ truy cập trong lớp)
static (dùng chung cho mọi thể hiện)
final (không thể thay đổi sau khi khởi tạo)
đặt là $100.0 như một mức giá tối đa hợp lý
3. Chúng ta thêm phương thức setPrice() để:
Nhận tham số price
Xác thực rằng price không âm
Xác thực rằng price không vượt quá MAX_PRICE
Ném IllegalArgumentException với thông điệp mô tả nếu kiểm tra không đạt
Chỉ gán giá nếu mọi kiểm tra đều đạt
4. Chúng ta sửa constructor để sử dụng setPrice() thay vì gán trực tiếp giá. Điều này đảm bảo việc xác thực giá diễn ra ngay khi tạo đối tượng.
Chúng ta vừa triển khai một trong những trụ cột cốt lõi của thiết kế hướng đối tượng tốt — đóng gói. Mô hình này thực thi việc ẩn dữ liệu và kiểm soát quyền truy cập vào các thuộc tính của đối tượng, đảm bảo chi tiết triển khai nội bộ được bảo vệ khỏi can thiệp bên ngoài và chỉ có thể được sửa đổi thông qua các giao diện xác định rõ ràng.
Hãy nhấn mạnh thêm bằng cách áp dụng đóng gói cho thuộc tính name. Hãy tưởng tượng chúng ta có một quán cà phê chỉ phục vụ latte, cappuccino, espresso, americano và mocha.
Vì vậy, tên món trong thực đơn chỉ có thể là một trong các mục này. Dưới đây là cách chúng ta thực thi điều đó trong mã:
// Rest of the class here...privatestaticfinalString[]VALID_NAMES={"latte","cappuccino","espresso","americano","mocha"};privateString name;publicvoidsetName(String name){String lowercaseName = name.toLowerCase();for(String validName :VALID_NAMES){if(validName.equals(lowercaseName)){this.name = name;return;}}thrownewIllegalArgumentException("Invalid drink name. Must be one of: "+String.join(", ",VALID_NAMES));}Được hỗ trợ bởi
Đoạn mã trên triển khai việc kiểm tra hợp lệ cho tên món trong một quán cà phê. Hãy phân tích:
1. Trước hết, nó định nghĩa một mảng private static final VALID_NAMES chứa các tên đồ uống được phép: latte, cappuccino, espresso, americano và mocha. Mảng này có các đặc điểm:
private: chỉ truy cập trong lớp
static: dùng chung cho mọi thể hiện
final: không thể sửa sau khi khởi tạo
2. Nó khai báo trường private String name để lưu tên đồ uống
3. Phương thức setName() triển khai logic kiểm tra:
Nhận tham số String name
Chuyển thành chữ thường để so sánh không phân biệt hoa thường
Lặp qua mảng VALID_NAMES
Nếu tìm thấy khớp, gán tên và trả về
Nếu không có khớp, ném IllegalArgumentException với thông điệp liệt kê tất cả tùy chọn hợp lệ
Dưới đây là toàn bộ lớp cho đến lúc này:
publicclassMenuItem{privateString name;privatedouble price;privatestaticfinalString[]VALID_NAMES={"latte","cappuccino","espresso","americano","mocha"};privatestaticfinaldoubleMAX_PRICE=100.0;publicMenuItem(String name,double price){setName(name);setPrice(price);}publicvoidsetName(String name){String lowercaseName = name.toLowerCase();for(String validName :VALID_NAMES){if(validName.equals(lowercaseName)){this.name = name;return;}}thrownewIllegalArgumentException("Invalid drink name. Must be one of: "+String.join(", ",VALID_NAMES));}publicvoidsetPrice(double price){if(price <0){thrownewIllegalArgumentException("Price cannot be negative");}if(price >MAX_PRICE){thrownewIllegalArgumentException("Price cannot exceed $"+MAX_PRICE);}this.price = price;}}Được hỗ trợ bởi
Sau khi bảo vệ cách tạo thuộc tính, chúng ta cũng muốn bảo vệ cách truy cập chúng. Điều này được thực hiện bằng các phương thức getter:
publicclassMenuItem{// Rest of the code here...publicStringgetName(){return name;}publicdoublegetPrice(){return price;}}Được hỗ trợ bởi
Các phương thức getter cung cấp quyền truy cập có kiểm soát tới các thuộc tính private của lớp. Chúng giải quyết vấn đề truy cập trực tiếp thuộc tính có thể dẫn đến thay đổi ngoài ý muốn và phá vỡ tính đóng gói.
Ví dụ, không có getter, chúng ta có thể truy cập thuộc tính trực tiếp:
MenuItem item =newMenuItem("Latte",4.99);String name = item.name;// Direct access to attribute
item.name ="INVALID";// Can modify directly, bypassing validationĐược hỗ trợ bởi
Với getter, chúng ta áp đặt cách truy cập đúng đắn:
MenuItem item =newMenuItem("Latte",4.99);String name = item.getName();// Controlled access through getter// item.name = "INVALID"; // Not allowed - must use setName() which validatesĐược hỗ trợ bởi
Cách đóng gói này:
Bảo vệ tính toàn vẹn dữ liệu bằng cách ngăn thay đổi không hợp lệ
Cho phép chúng ta thay đổi triển khai nội bộ mà không ảnh hưởng tới mã sử dụng lớp
Cung cấp một điểm truy cập duy nhất có thể bổ sung logic khi cần
Giúp mã dễ bảo trì và ít lỗi hơn
Kế thừa
Lớp của chúng ta bắt đầu trông ổn nhưng vẫn còn khá nhiều vấn đề. Ví dụ, với một nhà hàng lớn phục vụ nhiều loại món ăn và đồ uống, lớp này chưa đủ linh hoạt.
Nếu muốn thêm các loại món khác nhau, chúng ta sẽ gặp một số thách thức. Một số món có thể chuẩn bị mang đi, trong khi món khác cần dùng ngay. Các món có thể có mức giá và giảm giá khác nhau. Món ăn có thể cần theo dõi nhiệt độ hoặc bảo quản đặc biệt. Đồ uống có thể nóng hoặc lạnh với nguyên liệu tùy chỉnh. Một số món cần thông tin dị ứng và tùy chọn khẩu phần. Hệ thống hiện tại chưa xử lý được các yêu cầu đa dạng đó.
Kế thừa cung cấp một giải pháp thanh thoát cho tất cả các vấn đề này. Nó cho phép chúng ta tạo các phiên bản chuyên biệt của món trong thực đơn bằng cách định nghĩa lớp cơ sở MenuItem với các thuộc tính chung, rồi tạo các lớp con kế thừa những nền tảng đó đồng thời bổ sung tính năng riêng.
Ví dụ, chúng ta có thể có lớp Drink cho đồ uống với tùy chọn nhiệt độ, lớp Food cho món cần dùng ngay, và lớp Dessert cho món cần bảo quản đặc biệt — tất cả đều kế thừa chức năng cốt lõi của món trong thực đơn.
Mở rộng lớp
Hãy hiện thực hóa các ý tưởng đó, bắt đầu với Drink:
Để định nghĩa lớp con kế thừa từ lớp cha, chúng ta dùng từ khóa extends sau tên lớp con và theo sau là lớp cha. Sau định nghĩa lớp, chúng ta khai báo các thuộc tính mới của lớp con và triển khai constructor của nó.
Nhưng hãy chú ý rằng chúng ta phải lặp lại việc khởi tạo name và price cùng với isCold. Điều đó không lý tưởng vì lớp cha có thể có hàng trăm thuộc tính. Hơn nữa, đoạn mã trên sẽ lỗi khi biên dịch vì đó không phải cách đúng để khởi tạo thuộc tính của lớp cha. Cách đúng là dùng từ khóa super:
Từ khóa super được dùng để gọi constructor của lớp cha. Trong trường hợp này, super(name, price) sẽ gọi constructor của MenuItem để khởi tạo các thuộc tính đó, tránh lặp mã. Chúng ta chỉ cần khởi tạo thuộc tính mới isCold đặc thù của lớp Drink.
Từ khóa này rất linh hoạt vì bạn có thể dùng để tham chiếu lớp cha ở bất kỳ phần nào của lớp con. Ví dụ, để gọi phương thức của lớp cha, bạn dùng super.methodName() trong khi super.attributeName là cho thuộc tính.
Ghi đè phương thức
Bây giờ, giả sử chúng ta muốn thêm một phương thức mới vào các lớp để tính tổng giá sau thuế. Vì các món trong thực đơn có thể có mức thuế khác nhau (ví dụ, đồ ăn chế biến sẵn so với đồ uống đóng gói), chúng ta có thể dùng ghi đè phương thức để triển khai cách tính thuế riêng trong từng lớp con, đồng thời giữ một tên phương thức chung ở lớp cha.
Trông sẽ như sau:
publicclassMenuItem{// Rest of the MenuItem classpublicdoublecalculateTotalPrice(){// Default tax rate of 10%return price *1.10;}}publicclassFoodextendsMenuItem{privateboolean isVegetarian;publicFood(String name,double price,boolean isVegetarian){super(name, price);this.isVegetarian = isVegetarian;}@OverridepublicdoublecalculateTotalPrice(){// Food has 15% taxreturnsuper.getPrice()*1.15;}}publicclassDrinkextendsMenuItem{privateboolean isCold;publicDrink(String name,double price,boolean isCold){super(name, price);this.isCold = isCold;}@OverridepublicdoublecalculateTotalPrice(){// Drinks have 8% taxreturnsuper.getPrice()*1.08;}}Được hỗ trợ bởi
Trong ví dụ này, ghi đè phương thức cho phép mỗi lớp con cung cấp triển khai riêng cho calculateTotalPrice():
Lớp cơ sở MenuItem định nghĩa cách tính thuế mặc định là 10%.
Khi các lớp Food và Drink mở rộng MenuItem, chúng ghi đè phương thức này để triển khai mức thuế riêng:
Đồ ăn có mức thuế cao hơn là 15%
Đồ uống có mức thuế thấp hơn là 8%
Chú thích @Override được dùng để chỉ rõ rằng các phương thức này đang ghi đè phương thức của lớp cha. Điều này giúp phát hiện lỗi nếu chữ ký phương thức không khớp với lớp cha.
Mỗi lớp con vẫn có thể truy cập giá của lớp cha bằng super.getPrice(), cho thấy cách các phương thức bị ghi đè có thể tận dụng chức năng của lớp cha đồng thời bổ sung hành vi riêng.
Tóm lại, ghi đè phương thức là một phần không thể thiếu của kế thừa, cho phép lớp con cung cấp cách triển khai riêng cho các phương thức được định nghĩa ở lớp cha, tạo ra hành vi cụ thể hơn trong khi vẫn giữ nguyên chữ ký phương thức.
Lớp trừu tượng (Abstract Classes)
Hệ phân cấp lớp MenuItem của chúng ta hoạt động, nhưng có một vấn đề: có nên cho phép bất kỳ ai tạo một đối tượng MenuItem thuần túy không? Rốt cuộc, trong nhà hàng của chúng ta, mọi món đều là Food hoặc Drink - không có thứ gọi là “món chung chung”.
Chúng ta có thể ngăn điều này bằng cách biến MenuItem thành một lớp trừu tượng. Lớp trừu tượng chỉ cung cấp bản thiết kế cơ sở - chỉ có thể dùng làm lớp cha cho kế thừa, không thể khởi tạo trực tiếp.
Để biến MenuItem thành trừu tượng, chúng ta thêm từ khóa abstract sau bộ sửa truy cập:
publicabstractclassMenuItem{privateString name;privatedouble price;publicMenuItem(String name,double price){setName(name);setPrice(price);}// Existing getters/setters remain the same// Make this method abstract - every subclass MUST implement itpublicabstractdoublecalculateTotalPrice();}Được hỗ trợ bởi
Lớp trừu tượng cũng có thể có các phương thức trừu tượng như calculateTotalPrice() ở trên. Các phương thức trừu tượng này đóng vai trò như các hợp đồng buộc lớp con phải cung cấp triển khai. Nói cách khác, mọi phương thức trừu tượng trong lớp trừu tượng phải được các lớp con triển khai.
Vậy hãy viết lại Food và Drink với những thay đổi này:
Thông qua việc triển khai hệ thống thực đơn này, chúng ta đã thấy cách trừu tượng hóa và kế thừa phối hợp để tạo ra mã linh hoạt, dễ bảo trì và dễ thích nghi với các yêu cầu nghiệp vụ khác nhau.
Kết luận
Hôm nay, chúng ta đã lướt qua những gì Java có thể làm được với tư cách là một ngôn ngữ lập trình hướng đối tượng. Chúng ta đã tìm hiểu những điều cơ bản về lớp, đối tượng và một vài trụ cột chính của OOP: đóng gói, kế thừa và trừu tượng hóa thông qua hệ thống thực đơn nhà hàng.
Để đưa hệ thống này vào sản xuất, bạn vẫn còn nhiều điều phải học, như interface (một phần của trừu tượng hóa), đa hình (polymorphism) và các mẫu thiết kế OOP. Để tìm hiểu thêm về các khái niệm này, hãy tham khảo khóa học Nhập môn OOP trong Java của chúng tôi.
Yêu cầu tiên quyết để theo dõi hướng dẫn OOP Java này là gì?⌄
Bạn nên có kiến thức cơ bản về lập trình Java, bao gồm kiểu dữ liệu, biến, luồng điều khiển (if/else, vòng lặp) và cú pháp cơ bản. Khóa học Nhập môn Java của chúng tôi bao quát những nền tảng này nếu bạn cần ôn lại.
Vì sao dùng hệ thống thực đơn nhà hàng để giải thích các khái niệm OOP?⌄
Hệ thống thực đơn nhà hàng là một ví dụ trực quan thể hiện các ứng dụng thực tế của các nguyên tắc OOP. Nó thể hiện tự nhiên tính kế thừa (các loại món khác nhau), đóng gói (bảo vệ giá và tên), và interface (chương trình khách hàng thân thiết, kiểm soát nhiệt độ). Hầu hết mọi người đều hiểu cách nhà hàng vận hành, nên các khái niệm sẽ dễ nắm bắt hơn.
Sự khác nhau giữa lớp trừu tượng và interface trong Java là gì?⌄
Một lớp chỉ có thể mở rộng một lớp trừu tượng nhưng có thể triển khai nhiều interface.
Lớp trừu tượng có thể có trường và constructor; interface thì không.
Lớp trừu tượng có thể có cả phương thức trừu tượng và cụ thể; interface truyền thống chỉ có phương thức trừu tượng (trước Java 8).
Lớp trừu tượng cung cấp triển khai cơ sở, trong khi interface định nghĩa một hợp đồng.
Tôi là người sáng tạo nội dung về khoa học dữ liệu với hơn 2 năm kinh nghiệm và là một trong những tài khoản có lượng người theo dõi lớn nhất trên Medium. Tôi thích viết các bài chuyên sâu về AI và ML với chút giọng điệu mỉa mai, vì bạn cũng phải làm gì đó để chúng bớt khô khan. Tôi đã xuất bản hơn 130 bài viết và một khóa học trên DataCamp, cùng với một khóa nữa đang được thực hiện. Nội dung của tôi đã được hơn 5 triệu lượt xem, trong đó 20 nghìn người đã trở thành người theo dõi trên cả Medium và LinkedIn.
Java luôn nằm trong top ba ngôn ngữ phổ biến nhất thế giới. Mức độ ứng dụng của Java trong các lĩnh vực như phát triển phần mềm doanh nghiệp, ứng dụng di động Android và ứng dụng web quy mô lớn là không gì sánh kịp. Hệ thống kiểu mạnh, hệ sinh thái phong phú và khả năng “viết một lần, chạy mọi nơi” khiến Java đặc biệt hấp dẫn để xây dựng các hệ thống vững chắc, có khả năng mở rộng. Trong bài viết này, chúng ta sẽ khám phá cách các tính năng lập trình hướng đối tượng của Java cho phép nhà phát triển khai thác hiệu quả những năng lực đó, giúp xây dựng ứng dụng dễ bảo trì và mở rộng thông qua tổ chức mã và tái sử dụng hợp lý.
Lưu ý về tổ chức và chạy mã Java
Trước khi bắt đầu viết mã, hãy thực hiện một số thiết lập.
Cũng như cú pháp, Java có các quy tắc nghiêm ngặt về tổ chức mã.
Trước hết, mỗi lớp public phải nằm trong một tệp riêng, được đặt tên chính xác theo tên lớp nhưng với phần mở rộng
.java. Ví dụ, nếu tôi muốn viết lớp Laptop, tên tệp phải làLaptop.java—phân biệt chữ hoa chữ thường. Bạn có thể để các lớp không phải public trong cùng tệp, nhưng tốt nhất nên tách riêng. Tôi biết là chúng ta đang đi trước một bước—nói về tổ chức lớp trước khi viết chúng—nhưng có một ý niệm sơ bộ về nơi đặt mọi thứ từ trước luôn là ý hay.Mọi dự án Java phải có tệp
Main.javavới lớp Main. Đây là nơi bạn kiểm thử các lớp bằng cách tạo đối tượng từ chúng.Để chạy mã Java, chúng ta sẽ dùng IntelliJ IDEA, một IDE Java phổ biến. Sau khi cài đặt IntelliJ:
Main.javavà dán nội dung sau:Bất cứ khi nào nói về lớp, chúng ta sẽ viết mã trong các tệp khác ngoài tệp
Main.java. Nhưng khi nói về việc tạo và kiểm thử các đối tượng, chúng ta chuyển sangMain.java.Để chạy chương trình, bạn có thể nhấp nút phát màu xanh lá cạnh phương thức main:
Kết quả sẽ hiển thị trong cửa sổ Run ở phía dưới.
Nếu bạn hoàn toàn mới với Java, vui lòng xem khóa học Nhập môn Java, khóa học này bao quát các kiến thức nền tảng về kiểu dữ liệu và luồng điều khiển trong Java trước khi tiếp tục.
Nếu không, hãy bắt đầu ngay.
Các lớp và đối tượng trong Java
Vậy, lớp chính xác là gì?
Lớp là cấu trúc lập trình trong Java để biểu diễn các khái niệm trong thế giới thực. Ví dụ, hãy xem lớp MenuItem sau (tạo một tệp để viết lớp này trong IDE của bạn):
Lớp này cung cấp cho chúng ta một bản thiết kế hoặc khuôn mẫu để biểu diễn nhiều món trong thực đơn của nhà hàng. Bằng cách thay đổi hai thuộc tính của lớp,
name, vàprice, chúng ta có thể tạo vô số đối tượng món như burger hay salad.Vậy để tạo một lớp trong Java, bạn bắt đầu bằng một dòng mô tả mức truy cập của lớp (
private,public, hoặcprotected) theo sau là tên lớp. Ngay sau cặp ngoặc, bạn liệt kê các thuộc tính của lớp.Nhưng làm thế nào để tạo đối tượng thuộc lớp này? Java cho phép điều đó thông qua các phương thức khởi tạo (constructor):
Constructor là một phương thức đặc biệt được gọi khi chúng ta tạo đối tượng mới từ một lớp. Nó khởi tạo các thuộc tính của đối tượng bằng các giá trị được truyền vào. Trong ví dụ trên, constructor nhận tham số name và price rồi gán cho các trường của đối tượng bằng từ khóa ‘this’ để tham chiếu tới thể hiện đối tượng sẽ được tạo.
Cú pháp của constructor khác với các phương thức lớp khác vì không yêu cầu chỉ định kiểu trả về. Ngoài ra, constructor phải trùng tên với lớp, và nên có cùng số thuộc tính như bạn đã khai báo sau định nghĩa lớp. Ở trên, constructor đang tạo hai thuộc tính vì chúng ta đã khai báo hai thuộc tính sau định nghĩa lớp:
namevàprice.Sau khi viết lớp và constructor của nó, bạn có thể tạo các thể hiện (đối tượng) trong phương thức main:
Kết quả:
Ở trên, chúng ta tạo hai đối tượng
MenuItemvà gán vào biếnburgervàsalad. Như Java yêu cầu, kiểu của biến phải được khai báo, ở đây làMenuItem. Sau đó, để tạo một thể hiện của lớp, chúng ta viết từ khóa new và gọi phương thức constructor.Ngoài constructor, bạn có thể tạo các phương thức thông thường để bổ sung hành vi cho lớp. Ví dụ dưới đây, chúng ta thêm một phương thức tính tổng giá sau thuế:
Giờ đây chúng ta có thể tính tổng giá đã bao gồm thuế:
Kết quả:
Đóng gói (Encapsulation)
Mục đích của lớp là cung cấp bản thiết kế để tạo đối tượng. Những đối tượng này sau đó sẽ được các script hay chương trình khác sử dụng. Ví dụ, các đối tượng
MenuItemcủa chúng ta có thể được một giao diện người dùng dùng để hiển thị tên, giá và hình ảnh của chúng trên màn hình.Vì lý do đó, chúng ta phải thiết kế lớp sao cho các thể hiện của nó chỉ có thể được sử dụng theo cách đã định. Hiện tại, lớp
MenuItemcủa chúng ta còn rất cơ bản và dễ lỗi. Ai đó có thể tạo đối tượng với thuộc tính vô lý, như một chiếc bánh táo có giá âm hoặc một chiếc sandwich giá một triệu đô:Vì vậy, việc đầu tiên sau khi viết một lớp là bảo vệ các thuộc tính của nó bằng cách hạn chế cách chúng được tạo và truy cập. Trước hết, chúng ta muốn chỉ cho phép các giá trị dương cho price và đặt một giá trị tối đa để tránh vô tình hiển thị các món có giá vô lý.
Java cho phép chúng ta làm điều này bằng các phương thức setter:
Hãy xem có gì mới trong khối mã trên:
1. Chúng ta đặt các thuộc tính ở mức private bằng cách thêm từ khóa
private. Điều này có nghĩa chúng chỉ có thể được truy cập bên trong lớp MenuItem. Đóng gói bắt đầu từ bước quan trọng này.2. Chúng ta thêm một hằng số mới
MAX_PRICEvới các đặc điểm:3. Chúng ta thêm phương thức
setPrice()để:4. Chúng ta sửa constructor để sử dụng
setPrice()thay vì gán trực tiếp giá. Điều này đảm bảo việc xác thực giá diễn ra ngay khi tạo đối tượng.Chúng ta vừa triển khai một trong những trụ cột cốt lõi của thiết kế hướng đối tượng tốt — đóng gói. Mô hình này thực thi việc ẩn dữ liệu và kiểm soát quyền truy cập vào các thuộc tính của đối tượng, đảm bảo chi tiết triển khai nội bộ được bảo vệ khỏi can thiệp bên ngoài và chỉ có thể được sửa đổi thông qua các giao diện xác định rõ ràng.
Hãy nhấn mạnh thêm bằng cách áp dụng đóng gói cho thuộc tính name. Hãy tưởng tượng chúng ta có một quán cà phê chỉ phục vụ latte, cappuccino, espresso, americano và mocha.
Vì vậy, tên món trong thực đơn chỉ có thể là một trong các mục này. Dưới đây là cách chúng ta thực thi điều đó trong mã:
Đoạn mã trên triển khai việc kiểm tra hợp lệ cho tên món trong một quán cà phê. Hãy phân tích:
1. Trước hết, nó định nghĩa một mảng private static final
VALID_NAMESchứa các tên đồ uống được phép: latte, cappuccino, espresso, americano và mocha. Mảng này có các đặc điểm:2. Nó khai báo trường private String name để lưu tên đồ uống
3. Phương thức
setName()triển khai logic kiểm tra:VALID_NAMESDưới đây là toàn bộ lớp cho đến lúc này:
Sau khi bảo vệ cách tạo thuộc tính, chúng ta cũng muốn bảo vệ cách truy cập chúng. Điều này được thực hiện bằng các phương thức getter:
Các phương thức getter cung cấp quyền truy cập có kiểm soát tới các thuộc tính private của lớp. Chúng giải quyết vấn đề truy cập trực tiếp thuộc tính có thể dẫn đến thay đổi ngoài ý muốn và phá vỡ tính đóng gói.
Ví dụ, không có getter, chúng ta có thể truy cập thuộc tính trực tiếp:
Với getter, chúng ta áp đặt cách truy cập đúng đắn:
Cách đóng gói này:
Kế thừa
Lớp của chúng ta bắt đầu trông ổn nhưng vẫn còn khá nhiều vấn đề. Ví dụ, với một nhà hàng lớn phục vụ nhiều loại món ăn và đồ uống, lớp này chưa đủ linh hoạt.
Nếu muốn thêm các loại món khác nhau, chúng ta sẽ gặp một số thách thức. Một số món có thể chuẩn bị mang đi, trong khi món khác cần dùng ngay. Các món có thể có mức giá và giảm giá khác nhau. Món ăn có thể cần theo dõi nhiệt độ hoặc bảo quản đặc biệt. Đồ uống có thể nóng hoặc lạnh với nguyên liệu tùy chỉnh. Một số món cần thông tin dị ứng và tùy chọn khẩu phần. Hệ thống hiện tại chưa xử lý được các yêu cầu đa dạng đó.
Kế thừa cung cấp một giải pháp thanh thoát cho tất cả các vấn đề này. Nó cho phép chúng ta tạo các phiên bản chuyên biệt của món trong thực đơn bằng cách định nghĩa lớp cơ sở
MenuItemvới các thuộc tính chung, rồi tạo các lớp con kế thừa những nền tảng đó đồng thời bổ sung tính năng riêng.Ví dụ, chúng ta có thể có lớp
Drinkcho đồ uống với tùy chọn nhiệt độ, lớpFoodcho món cần dùng ngay, và lớpDessertcho món cần bảo quản đặc biệt — tất cả đều kế thừa chức năng cốt lõi của món trong thực đơn.Mở rộng lớp
Hãy hiện thực hóa các ý tưởng đó, bắt đầu với
Drink:Để định nghĩa lớp con kế thừa từ lớp cha, chúng ta dùng từ khóa
extendssau tên lớp con và theo sau là lớp cha. Sau định nghĩa lớp, chúng ta khai báo các thuộc tính mới của lớp con và triển khai constructor của nó.Nhưng hãy chú ý rằng chúng ta phải lặp lại việc khởi tạo
namevàpricecùng vớiisCold. Điều đó không lý tưởng vì lớp cha có thể có hàng trăm thuộc tính. Hơn nữa, đoạn mã trên sẽ lỗi khi biên dịch vì đó không phải cách đúng để khởi tạo thuộc tính của lớp cha. Cách đúng là dùng từ khóasuper:Từ khóa
superđược dùng để gọi constructor của lớp cha. Trong trường hợp này,super(name, price)sẽ gọi constructor củaMenuItemđể khởi tạo các thuộc tính đó, tránh lặp mã. Chúng ta chỉ cần khởi tạo thuộc tính mớiisColdđặc thù của lớpDrink.Từ khóa này rất linh hoạt vì bạn có thể dùng để tham chiếu lớp cha ở bất kỳ phần nào của lớp con. Ví dụ, để gọi phương thức của lớp cha, bạn dùng
super.methodName()trong khisuper.attributeNamelà cho thuộc tính.Ghi đè phương thức
Bây giờ, giả sử chúng ta muốn thêm một phương thức mới vào các lớp để tính tổng giá sau thuế. Vì các món trong thực đơn có thể có mức thuế khác nhau (ví dụ, đồ ăn chế biến sẵn so với đồ uống đóng gói), chúng ta có thể dùng ghi đè phương thức để triển khai cách tính thuế riêng trong từng lớp con, đồng thời giữ một tên phương thức chung ở lớp cha.
Trông sẽ như sau:
Trong ví dụ này, ghi đè phương thức cho phép mỗi lớp con cung cấp triển khai riêng cho
calculateTotalPrice():Lớp cơ sở
MenuItemđịnh nghĩa cách tính thuế mặc định là 10%.Khi các lớp
FoodvàDrinkmở rộngMenuItem, chúng ghi đè phương thức này để triển khai mức thuế riêng:Chú thích
@Overrideđược dùng để chỉ rõ rằng các phương thức này đang ghi đè phương thức của lớp cha. Điều này giúp phát hiện lỗi nếu chữ ký phương thức không khớp với lớp cha.Mỗi lớp con vẫn có thể truy cập giá của lớp cha bằng
super.getPrice(), cho thấy cách các phương thức bị ghi đè có thể tận dụng chức năng của lớp cha đồng thời bổ sung hành vi riêng.Tóm lại, ghi đè phương thức là một phần không thể thiếu của kế thừa, cho phép lớp con cung cấp cách triển khai riêng cho các phương thức được định nghĩa ở lớp cha, tạo ra hành vi cụ thể hơn trong khi vẫn giữ nguyên chữ ký phương thức.
Lớp trừu tượng (Abstract Classes)
Hệ phân cấp lớp
MenuItemcủa chúng ta hoạt động, nhưng có một vấn đề: có nên cho phép bất kỳ ai tạo một đối tượngMenuItemthuần túy không? Rốt cuộc, trong nhà hàng của chúng ta, mọi món đều là Food hoặc Drink - không có thứ gọi là “món chung chung”.Chúng ta có thể ngăn điều này bằng cách biến
MenuItemthành một lớp trừu tượng. Lớp trừu tượng chỉ cung cấp bản thiết kế cơ sở - chỉ có thể dùng làm lớp cha cho kế thừa, không thể khởi tạo trực tiếp.Để biến
MenuItemthành trừu tượng, chúng ta thêm từ khóaabstractsau bộ sửa truy cập:Lớp trừu tượng cũng có thể có các phương thức trừu tượng như
calculateTotalPrice()ở trên. Các phương thức trừu tượng này đóng vai trò như các hợp đồng buộc lớp con phải cung cấp triển khai. Nói cách khác, mọi phương thức trừu tượng trong lớp trừu tượng phải được các lớp con triển khai.Vậy hãy viết lại
FoodvàDrinkvới những thay đổi này:Thông qua việc triển khai hệ thống thực đơn này, chúng ta đã thấy cách trừu tượng hóa và kế thừa phối hợp để tạo ra mã linh hoạt, dễ bảo trì và dễ thích nghi với các yêu cầu nghiệp vụ khác nhau.
Kết luận
Hôm nay, chúng ta đã lướt qua những gì Java có thể làm được với tư cách là một ngôn ngữ lập trình hướng đối tượng. Chúng ta đã tìm hiểu những điều cơ bản về lớp, đối tượng và một vài trụ cột chính của OOP: đóng gói, kế thừa và trừu tượng hóa thông qua hệ thống thực đơn nhà hàng.
Để đưa hệ thống này vào sản xuất, bạn vẫn còn nhiều điều phải học, như interface (một phần của trừu tượng hóa), đa hình (polymorphism) và các mẫu thiết kế OOP. Để tìm hiểu thêm về các khái niệm này, hãy tham khảo khóa học Nhập môn OOP trong Java của chúng tôi.
Nếu bạn muốn kiểm tra kiến thức Java của mình, hãy thử trả lời một số câu hỏi trong bài viết Câu hỏi phỏng vấn Java của chúng tôi.