Lập trình Java

Công ty cổ phần thương mại Vạn Tín Việt

Tổng Quan Về Java

Nào chúng ta hãy bắt đầu làm quen với chương trình học này thông qua việc tìm hiểu sơ lược về ngôn ngữ Java của bài hôm nay.

Dạo Qua Một Chút Lịch Sử Của Java

Java được tạo ra bởi James Gosling và cộng sự của ông ở Sun Microsystem vào năm 1991 (sau này Oracle mua lại Sun Microsystem vào năm 2010). Ban đầu ngôn ngữ này có tên là Oak (Cây sồi – Do bên ngoài công ty lúc bấy giờ có trồng nhiều cây này – Các tài liệu khác nói vậy, mình chỉ copy thôi). Oak chính thức được đổi tên thành Java vào năm 1995 (chắc do mấy cây sồi bị đốn sạch – Mình đùa đấy!!).

Các Đặc Điểm Chính Của Java

Java là một Ngôn Ngữ Lập Trình Hướng Đối Tượng (OOP). Do đó khi lập trình với ngôn ngữ này bạn sẽ phải làm việc với các lớp (Class). Cú pháp của Java được vay mượn nhiều từ C/C++ nhưng lại có đặc tính hướng đối tượng đơn giản hơn và ít tính năng xử lý cấp thấp hơn, nên việc tiếp cận Java sẽ dễ dàng hơn C/C++. Nếu bạn nào đã có nền tảng về C/C++ thì chắc chắn sẽ dễ dàng đón nhận và tiếp cận Java hơn.

Khẩu hiệu nổi tiếng của Java chắc bạn cũng biết (hoặc hôm nay sẽ biết), đó là Viết một lần, Chạy mọi nơi. Viết ở đây là viết code, còn chạy nghĩa là thực thi ứng dụng. Điều này có nghĩa là, phần mềm được viết bằng ngôn ngữ Java có thể chạy được trên mọi nền tảng (platform) khác nhau. Để làm được điều này thì Java đưa ra khái niệm Máy ảo Java, hay JVM (Java Virtual Machine). Khi bạn biên dịch một chương trình, thay vì mã nguồn sẽ được dịch trực tiếp ra mã máy như nhiều ngôn ngữ khác, thì với Java, mã nguồn đó sẽ được dịch thành mã có tên là bytecode trước. Bytecode này sau đó sẽ được bạn phân phối đến các thiết bị khác nhau, chính JVM được cài sẵn ở các thiết bị đó sẽ dịch tiếp bytecode này thành mã máy giúp bạn. Có thể mô tả quá trình biên dịch này bằng sơ đồ sau.

Sơ đồ biên dịch mã của Java
Sơ đồ biên dịch mã của Java

Tại Sao Bạn Phải Chọn Java?

Tiếp theo đây chúng ta sẽ cùng điểm qua các điểm mạnh của Java để bạn có thể hiểu rõ hơn về ngôn ngữ này.

Cú Pháp (Syntax) Đơn Giản

Như mình đã nói ở trên đây, do Java được kế thừa từ C/C++, nên sẽ vẫn giữ được sự đơn giản ở cú pháp so với những gì C/C++ đã đạt được.

Mặt khác Java còn giảm bớt các khái niệm “đau đầu” mà C/C++ đang có, làm cho ngôn ngữ này trở nên đơn giản và dễ sử dụng hơn nữa. Có thể kể đến một vài sự giảm bớt này như là: bỏ đi các câu lệnh Goto, không còn khái niệm Nạp Chồng Toán Tử (Overload Operator), bỏ đi khái niệm Con Trỏ (Pointer), bỏ file Header, bỏ luôn UnionStruct,…

Hoàn Toàn Hướng Đối Tượng (OOP)

Cũng có nhiều ý kiến xoay quanh hai chữ “hoàn toàn” này. Thực tế thì chỉ có các kiểu dữ liệu nguyên thủy của Java như intlongfloat,… thì không hướng đối tượng. Ngoài các kiểu dữ liệu nguyên thủy đó ra thì khi tiếp xúc với Java, bạn luôn luôn phải suy nghĩ và làm việc theo hướng đối tượng. Vậy hướng đối tượng là gì? Bạn sẽ hiểu rõ thôi vì chúng ta sẽ bắt đầu nói đến hướng đối tượng từ bài học số 15.

Độc Lập Với Nền Tảng Hệ Điều Hành Và Phần Cứng

Như đã nói ở trên, khẩu hiệu của Java là Viết một lần, Chạy mọi nơi. Điều này đã giúp cho ngôn ngữ Java được độc lập với nền tảng phần cứng. Khi lập trình với Java, bạn sẽ không phải suy nghĩ đến sự tương thích với kiến trúc của từng loại hệ điều hành hay phần cứng, chính JVM sẽ giúp các bạn lo điều này.

Là Một Ngôn Ngữ Mạnh Mẽ

Nói Java mạnh mẽ là bởi vì ngôn ngữ này hỗ trợ lập trình viên rất nhiều điều. Đầu tiên, như mình có nhắc đến ở trên, Java có thể chạy trên nhiều nền tảng. Java còn có Bộ Dọn Rác (Garbage Collection) giúp tự động dọn dẹp các đối tượng đã qua sử dụng để giải phóng bộ nhớ, mà với các ngôn ngữ khác, lập trình viên phải thực hiện việc giải phóng này một cách thủ công. Java còn hỗ trợ chạy đa nhiệm (Multithread) rất tốt. Và còn nhiều thứ khác chúng ta sẽ cùng nhau tìm hiểu qua từng bài học cụ thể nhé.

Môi Trường Phát Triển Phầm Mềm Java

Cũng giống như mình đã định nghĩa bên các bài học AndroidMôi trường Phát triển Phần mềm là một môi trường mà ở đó Nhà Phát Triển Phần Mềm có được những công cụ cần thiết nhất để viết ra một ứng dụng hoàn chỉnh. Vì bài học liên quan đến lập trình Java, do đó chúng ta sẽ tập trung vào tìm hiểu Môi trường Phát triển Phần mềm Java (Java Development Environment) sẽ bao gồm những gì.

Hệ Điều Hành (Operating System)

Dù cho bạn đang lập trình dựa trên hệ điều hành nào, WindowsLinux hay Mac, thì bạn đều có thể cài đặt được một Môi trường Phát triển Phần mềm cho Java.

Java Development Kit (JDK)

Bộ Công Cụ Phát Triển Cho Java (JDK), bộ công cụ này sẽ cung cấp cho bạn các công cụ cần thiết để biên dịch, thực thi, và có cả môi trường để ứng dụng Java của bạn có thể chạy lên nữa.

Công Cụ Biên Dịch

Công cụ Biên dịch mà mình muốn nói đến ở đây chính là một IDE (Integrated Development Environment). Hay nói cách khác là một công cụ để bạn có thể viết code Java lên đó, công cụ này có thể đủ mạnh để ngoài việc bạn có thể code được, nó còn giúp kiểm tra lỗi cú pháp khi code, giúp liên kết với JDK để tự động biên dịch và thực thi chương trình.

Không giống như bên bài học Android mình chỉ định các lập trình viên sử dụng Android Studio để biên dịch. Với Java các bạn có nhiều chọn lựa hơn, các bạn có thể sử dụng một trong các công cụ sau đây.

Netbeans

Logo của Netbeans
Logo của Netbeans

Netbeans là một IDE mã nguồn mở, mạnh mẽ, miễn phí. Tuy nhiên hiện tại mình thấy xung quanh dường như ít sử dụng công cụ này. Nhưng có nhiều bạn cũng liên hệ mình và nói rằng Netbeans vẫn tiếp tục được cập nhật phiên bản mới, chứ không lỗi thời gì cả. Không sao, nếu bạn thích tìm hiểu và trải nghiệm, thì cứ dùng Netbeans nhé. Còn như bài học của mình mình sẽ chỉ giới hạn vào việc nói đến cụ thể ở 2 công cụ tiếp theo dưới đây.

Eclipse

Logo của Eclipse
Logo của Eclipse

Eclipse cũng là một IDE mã nguồn mở, sự phổ biến của nó không kém gì Netbeans, thậm chí có thời gian Eclipse còn được sử dụng phổ biến hơn. Và tất nhiên công cụ này cũng miễn phí. Nhưng có thể nói rằng dù cho Eclipse một thời rất được cộng đồng Java sử dụng, thì với công cụ InteliJ mới mẻ mà mình sẽ nói đến ở mục dưới đây, có thể sẽ làm cho Eclipse dần bị lỗi thời không kém gì Netbeans.

InteliJ

Logo của Netbeans
Logo của Netbeans

Dù sinh sau đẻ muộn, nhưng InteliJ nhanh chóng chiếm cảm tình của cộng đồng lập trình Java nhờ vào sự mạnh mẽ và giao diện hiện đại của nó. Đặc biệt từ khi Android Studio chính thức được Google giới thiệu là công cụ lập trình ứng dụng Android chính thống, mà Android Studio lại được xây dựng từ InteliJ, nên IDE này bỗng dưng trở nên rất hot. InteliJ có hai phiên bản, có phí và miễn phí. Và dĩ nhiên chúng ta chỉ cần bản miễn phí thôi là dùng đủ tốt rồi nhé.

Ngoài 3 IDE phổ biến được nói ở trên, còn có nhiều công cụ khác hỗ trợ lập trình Java. Bạn có thể chọn cho mình một IDE, nhưng các bài học trong chương trình của mình sẽ thống nhất chọn InteliJ (Các phiên bản ban đầu của bài học thì mình chỉ dùng Eclipse thôi, nhưng từ khi chuyển sang InteliJ thì mình rất thích em nó, cho nên mình quyết định chỉnh sửa lại tất cả các bài học để chỉ hoạt động trên InteliJ. Nếu bạn có đang code trên Eclipse hay thậm chí Netbeans, bạn vẫn có thể tiếp tục theo dõi các bài viết trên InteliJ của mình mà không có bất kì trở ngại nào cả nhé).

Cài Đặt Các Công Cụ Phát Triển Cho Java

Và bài học hôm trước mình cũng đã nói rõ cho bạn biết rằng một môi trường lập trình cho Java sẽ phải cần đến những công cụ gì rồi đúng không nào. Vậy thì không cần phải nói nhiều, chúng ta hãy cùng bắt tay vào xây dựng từng công cụ đi thôi.

Cài Đặt JDK

Tương tự như phần hướng dẫn cài đặt JDK ở bên bài học Android, nếu bạn nào đã làm theo hướng dẫn đó rồi thì có thể bỏ qua phần này mà đến thẳng việc cài đặt InteliJ bên dưới, vì 2 phần hướng dẫn của 2 bài giống nhau.

Còn nếu chưa cài đặt JDK bao giờ thì bạn hãy vào trang của Oracle để download file cài đặt, bạn có thể vào bằng đường dẫn này đây (bạn cũng có thể search Google với từ khóa JDK Download). Sau khi mở link, bạn sẽ thấy danh sách các gói cài đặt như hình sau (hình ảnh có thể khác với máy của bạn ở thời điểm hiện tại do trang web này luôn được làm mới thường xuyên).

Nhiều gói JDK cho bạn cài đặt
Nhiều gói JDK cho bạn cài đặt

Bạn hãy chọn gói theo hệ điều hành mà bạn đang dùng rồi down về thôi. Sau khi download JDK về thì bạn tiến hành cài đặt nhé.

Thiết Lập PATH & JAVA_HOME cho Windows

Nếu bạn cài đặt JDK cho MacOS thì có thể bỏ qua mục này. Nhưng nếu bạn đang cài đặt JDK này trên nền Windows, bạn nên làm tiếp một bước tiếp theo này.

Nghe qua có vẻ xa lạ, nhưng nếu bạn nào đã từng quen thuộc với các dòng lệnh trên Command Line sẽ hiểu việc thiết lập Path & Java Home này là gì. Cơ bản thì việc thiết lập này sẽ giúp hệ điều hành Windows có thể thực thi được các dòng lệnh Java bên trong jdk/bin mà bạn mới cài đặt xong.

Do mình không dùng Windows nên các chỉ dẫn của mình có thể không rõ ràng, các bạn có thể dễ dàng tìm kiếm với từ khóa Path & Java Home Windows sẽ ra các chỉ dẫn rõ ràng hơn nhé. Hoặc bạn có thể để lại bình luận, hoặc chat với mình nếu gặp bất cứ vấn đề nào về việc thiết lập này nhé.

Cài Đặt InteliJ

Việc cài đặt InteliJ cũng vô cùng dễ dàng.

Trước hết bạn hãy vào đường dẫn này để download InteliJ về. Khi mở link lên bạn sẽ nhìn thấy trang download của InteliJ như sau (một lần nữa mình lưu ý là hình ảnh có thể khác với máy của bạn ở thời điểm hiện tại nhé).

Trang download của InteliJ
Trang download của InteliJ

Khi này bạn hãy nhấn vào nút Download.

Sau khi đã vào trang download, bạn sẽ được lựa chọn các gói download phù hợp với hệ điều hành của bạn.

Các tùy chọn cài đặt InteliJ
Các tùy chọn cài đặt InteliJ

Như hình trên bạn có thể thấy, ngoài lựa chọn theo hệ điều hành ra thì còn có thêm 2 phiên bản của InteliJ IDEA cho bạn chọn nữa, bao gồm Ultimate và Community. Như mình có nói ở bài học đầu tiên, chúng ta chỉ cần phiên bản Community miễn phí của InteliJ là đã có thể dùng để học được rồi. Vậy bạn hãy nhấn vào nút Download bên Community nhé.

Sau khi chờ đợi trong ít phút, gói cài đặt đã được down về. Bạn hãy tiến hành cài đặt.

Sau khi cài đặt xong, bạn hãy mở InteliJ lên để làm quen nào. Khi lần đầu tiên mở InteliJ lên bạn sẽ thấy một màn hình như thế này.

Màn hình đầu tiên khi khởi chạy InteliJ
Màn hình đầu tiên khi khởi chạy InteliJ

Làm Quen Với InteliJ & Tạo Mới Project

Chào mừng các bạn đến với bài học Java số 3, bài học về tạo mới project Java bằng InteliJ. Bài học này nằm trong chuỗi bài học lập trình ngôn ngữ Java của Yellow Code Books.

Với việc tìm hiểu về ngôn ngữ và cách thức cài đặt một môi trường lập trình Java từ hai bài trước. Hôm nay chúng ta cùng mở InteliJ lên để bắt đầu làm quen với IDE và với đoạn code Java đầu tiên của bạn.

Mình xin nhắc lại rằng, mình đang sửa chữa lại các bài viết của mình sao cho hoạt động dựa trên một IDE duy nhất là InteliJ, mặc dù có nhiều bài viết khác của mình vẫn đang viết dựa trên Eclipse (hoặc cả hai). Nên nếu bạn đọc sang các bài tiếp theo, rất có thể bạn sẽ nhìn thấy có sự tồn tại song song của cả hai IDE. Dù vậy mình nghĩ rằng nếu bạn đang quen với bất kỳ IDE nào, bạn vẫn tiếp tục đọc các bài viết của mình mà không bị bất kỳ cản trở nào nhé. Và nếu bạn muốn (mình nghĩ là nên vậy), bạn hãy xem lại các bài viết về Java từ đầu của mình để học cách sử dụng InteliJ thay cho Eclipse (nếu bạn đang dùng Eclipse), bạn sẽ thích InteliJ này sớm thôi.

Làm Quen Với InteliJ

Chúng ta bắt đầu thao tác gì đó với InteliJ để cùng quen thuộc với công cụ lập trình này nào.

Tạo Mới Project Bằng InteliJ

Chúng ta hãy thử tạo một project… ủa mà project là gì?

Một project nó như là một ứng dụng của chúng ta vậy. Thường người ta tạo ra project để phát triển ra một thành phẩm nào đó. Trong project sẽ chứa đựng các thành phần có liên quan với nhau, cùng kết hợp với nhau để cho ra một sản phẩm cuối cùng. Sản phẩm đó có thể là một ứng dụng, một thư viện, hay có thể là một bài học độc lập mà bạn muốn. Do đó trong khuôn khổ bài học và thực tập của các bạn, bạn có thể tổ chức sao cho mỗi project là một bài học riêng biệt, hoặc một project chung cho tất cả các bài học đều được. Project của bạn có thể chỉ chứa một lớp (bạn sẽ làm quen đến khái niệm lớp sớm thôi), hoặc vô số lớp tùy thích bạn nhé.

Quay lại việc tạo project trong InteliJ. Cơ bản thì bạn có hai cách sau.

Nếu mở IntelJ lên mà nhìn thấy màn hình Welcome như dưới đây thì bạn có thể nhấn vào New Project để tạo một project mới.

Tạo project mới từ màn hình Welcome
Tạo project mới từ màn hình Welcome

Còn nếu bạn đã đang mở một project nào đó mà muốn tạo một project mới khác thì có thể chọn theo menu File > New > Project… như hình dưới đây.

Tạo project mới từ menu của màn hình chính
Tạo project mới từ menu của màn hình chính

Dù chọn tạo mới project theo cách nào thì cửa sổ sau cũng sẽ xuất hiện sau đó.


Màn hình New Project giúp khai báo một project cần tạo

Bạn hãy đảm bảo Java được chọn ở danh sách các ngôn ngữ hay platform bên trái ở màn hình trên, và để mặc định ở bên phải rồi nhấn Next.

Sau đó có xuất hiện màn hình nào nữa thì bạn cứ tiếp tục nhấn Next. Cho tới khi đến màn hình sau.

Bước khai báo tên project và đường dẫn chứa nó trong máy
Bước khai báo tên project và đường dẫn chứa nó trong máy

Màn hình trên là nơi chúng ta bắt đầu đặt tên cho project ở mục Project name, và đường dẫn chứa project đó trong máy ở mục Project location. Với Project name thì mình đặt là HelloWord. Còn Project location thì mình để như trên, bạn có thể chỉ định thư mục nào mà bạn muốn nhé.

Tại sao project này lại có tên HelloWorld bạn biết không? Vì đây là project Java đầu tiên của bạn tính từ đầu bài học tới giờ. Và sở dĩ project đầu tiên lại có tên như vậy vì nó thể hiện rằng đây là dấu ấn của bạn với một ngôn ngữ lập trình mới, mà với lập trình viên, dấu ấn đầu tiên đó được xem như một sự chào hỏi của bạn đến với thế giới. Nghe hoành tráng ha, thực ra thì mình cũng đùa một tí, câu chào hello world! luôn được các cuốn sách hay các trang web hướng dẫn lập trình sử dụng khi hướng dẫn mọi người ở bài học đầu tiên, nó mang ý nghĩa bắt đầu cho những điều hay ho phía trước. Và bài học của mình cũng không ngoại lệ, cũng hello world!. Bạn có quyền đặt bất kỳ cái tên nào ở bài hôm nay cũng được.

Sau khi chỉ định tên và nơi chứa project xong hết rồi thì bạn nhấn nút Finish. Lúc này màn hình chính của InteliJ sẽ hiện ra.

Tổng Quan InteliJ

Nếu bạn nào từng làm quen với lập trình Android thì sẽ thấy, InteliJ và Android Studio nó rất rất rất giống nhau (thực chất thì chúng là một). Tuy nhiên mình cũng sẽ liệt kê lại các thành phần chính của InteliJ như sau.

Tổng quan InteliJ
Tổng quan InteliJ

1. Toolbar( thanh công cụ): nơi đây bạn có được các nút điều khiển chính, chẳng hạn như các nút Mở projectLưu projectCắt/Dán dữ liệu,… Hoặc đặc thù hơn với lập trình có các nút Khởi chạy ứng dụngDebug ứng dụng,…

2. Navigation bar (thanh điều hướng): giúp bạn theo dõi file nào đang được mở, đường dẫn file đó trong project của bạn như thế nào.

3. Editor window (cửa sổ soạn thảo): là nơi bạn code vào đây.

4. Tool window bar (các điều khiển cho các công cụ khác): các công cụ khác chính là các công cụ cho bạn can thiệp vào các công cụ quản lý của hệ thống. Chẳng hạn như Quản lý logQuản lý quá trình debugQuản lý kết quả tìm kiếm,  Xem cây thư mục của project,… Tuy nhiên dàn nút trên đây chỉ là cho phép bạn tắt mở các công cụ tương ứng mà thôi. Mỗi công cụ sẽ được mở ra ở dạng cửa sổ như mục số 5.

5. Tool windows: chính là các cửa sổ được điều khiển tắt mở từ thanh số 4 mà mình có nói đến trên đây.

6. Status bar: thanh trạng thái, hiển thị trạng thái của project và của chính InteliJ. Bạn sẽ thấy thông báo ứng dụng đang được thực thi, có thành công không, có lỗi gì không,…

Tạo Mới Một Lớp Bằng InteliJ

Bạn đã hiểu sơ sơ về project rồi ha. Giờ bạn cần phải hiểu thêm về khái niệm lớp, hay class.

Vậy thì lớp là gì? Như ở bài trước mình cũng có nói Java là một ngôn ngữ hướng đối tượng (OOP). Ngay khi làm việc với Java bạn buộc phải suy nghĩ và làm việc với các đối tượng dù bạn có là người mới vào hay không. Và lớp là một trong những khái niệm của hướng đối tượng. Bạn sẽ bắt đầu biết rõ về lớp từ bài học số 16.

Nhưng không phải cứ làm việc theo hướng đối tượng là phải biết về OOP, ở các bài đâu tiên này bạn cứ chấp nhận chuyện tạo mới một lớp. Bạn chỉ cần biết lớp là nơi mà chúng ta sẽ code vào đó, hệ thống sẽ tìm kiếm đến các lớp để mà biên dịch source code thành mã có thể thực thi được, mọi dòng code để bên ngoài lớp đều không hợp lệ và hệ thống sẽ báo lỗi ngay.

Trước khi tạo mới một lớp, bạn chắc rằng cửa sổ nhỏ bên trái InteliJ được mở, cửa sổ này có tên Project. Đó là nơi hiển thị tất cả các file và folder trong project HelloWorld của bạn theo kiểu cây thư mục, với project bạn vừa tạo xong, Project hiển thị như sau.

Cửa sổ Project
Cửa sổ Project

Để tạo lớp, nhấn chuột phải vào thư mục src bên trong cửa sổ Project và chọn New > Java Class.

Chọn tạo mới một class
Chọn tạo mới một class

Một hộp thoại nhỏ xuất hiện, bạn đặt tên cho class ở mục Name, như hình sau mình đặt tên cho class này là MyFirstClass. Bạn cũng đảm bảo vệt sáng ở dưới tên class đang tô sáng mục Class nhé (nhưng nếu bạn quên chú ý phần này cũng không sao, chúng ta hoàn toàn có thể chỉnh sửa code ở Editor sau này).

Tạo mới một class
Tạo mới một class

Sau khi enter ở bước trên đây, bạn sẽ thấy MyFirstClass.java xuất hiện ở khung Project bên trái, và nội dung của class này cũng được mở sẵn trong Editor như sau.

MyFirstClass.java vừa được tạo
MyFirstClass.java vừa được tạo

Một chút so sánh nếu như bạn đã từng làm việc với Eclipse.

Với InteliJ như bạn vừa thấy, code tạo ra không có tùy chọn tạo sẵn cho chúng ta phương thức main như với Eclipse. Không sao, cái đó chúng ta tự gõ vào sau.

Vậy phương thức là gì và phương thức main là gì? Mình nói sơ ở đây luôn nhé. Phương thức main là một phương thức mà hệ thống sẽ tìm đến đầu tiên nhất và bắt đầu thực thi các dòng code từ đây cho bạn. Nếu không có phương thức main thì hệ thống sẽ không biết ứng dụng của bạn bắt đầu từ đâu, và vì vậy không có dòng code nào được thực thi hết. Bạn sẽ biết rõ hơn về khái niệm phuơng thức cũng như được hiểu rõ về phương thức main và các phương thức khác ở các bài học sau này nữa.

Đến bước này thì bạn đã xong phần làm quen với InteliJ rồi. Chúng ta sẽ bắt đầu code từ mục tiếp theo sau đây.

Hello World!

Bây giờ là lúc bạn code dòng code Java đầu tiên. Với class MyFirstClass.java được mở như trên. Như đã nói, bạn cần khai báo một phương thức main để hệ thống biết mà thực thi các dòng code bên trong phương thức đó. Bạn hãy gõ từng chữ cho giống với đoạn code sau.

public class MyFirstClass {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}

Mình có một góp ý ngay chỗ này, khi bạn code các đoạn code đầu tiên. Có thể có bạn sẽ lười bằng cách thay vì code thì bạn lại copy/paste code từ trang web này vào. Có thể lắm, có đúng là bạn không? Nếu đúng thì bạn nên bỏ các dòng code vừa paste đó đi nhé. Hãy code từ chính đôi tay của bạn.

Ở các bài học sau cũng vậy, khi gặp các dòng code hay các yêu cầu buộc bạn phải code, thì bạn cũng đừng nên copy, mà hãy đọc trước yêu cầu, rồi thử code trước.

Nhưng nếu bạn không biết code ra sao nữa thì có thể nhìn các dòng code mẫu và code lại. Sau đó bạn thử thực thi chương trình xem kết quả có đúng hay không. Nếu là do bạn tự code, và kết quả thực thi của bạn không đúng với mình, thì hoặc là bạn sai, hoặc mình sai, và bạn có thể để lại bình luận bên dưới bài học để nhắc nhở mình. Còn nếu kết quả thực thi của bạn và mình quá chuẩn, nhưng code có khác nhau, cũng đâu có sao, lập trình là một tư duy mở, và mỗi chúng ta có một cách thức suy luận khác nhau, miễn sao cùng đi đến một kết quả chung là được. Bạn đã hiểu sơ về cách thức học lập trình rồi đúng không nào.

Có một lưu ý hay là trong quá trình code, sau khi bạn gõ vài từ trong Editor của InteliJ thì IDE này sẽ gợi ý các dòng code hoàn chỉnh cho chúng ta. Khi đó bạn có thể nhấn enter (máy Mac là return) để chọn nhanh phương thức gợi ý đầu tiên nếu thấy nó phù hợp, hoặc dùng phím mũi tên để chọn các dòng code khác rồi enter.

InteliJ nhắc bạn các tùy chọn hoàn thành dòng code
InteliJ nhắc bạn các tùy chọn hoàn thành dòng code

Nếu gõ lệnh mà bạn thấy có xuất hiện icon hình bóng đèn màu đỏ, hoặc dòng code bạn gõ biến thành màu đỏ, hoặc dòng code bị gạch chân màu đỏ, hoặc bạn cứ thấy có gì đó đỏ đỏ, thì đó là do IDE cảnh báo rằng bạn đang code lỗi chỗ nào đó, có thể là do bạn code chưa xong, hay chưa kết thúc câu lệnh bằng “;”,… Bạn cứ bình tĩnh xem xét lại từng câu chữ, chấp nhận các gợi ý hoàn thành dòng code luôn là một lựa chọn khôn ngoan. Nhưng nếu có lỗi nào xảy ra mà bạn không biết cách khắc phục thì hãy để lại bình luận bên dưới bài này nhé, mình sẽ giúp bạn.

InteliJ đang báo rằng bạn đã code sai
InteliJ đang báo rằng bạn đã code sai

Sau khi tự tin code xong rồi, bạn không cần phải save lại khi làm việc với InteliJ nhé, IDE này sẽ tự động sao lưu code ngay khi bạn gõ xong, thật sự rất tuyệt. Bạn chỉ cần qua bước tiếp theo để thực thi chương trình thôi.

Thực Thi Chương Trình

Sau khi code xong cho project, nếu không còn lỗi nào nữa, chúng ta hoàn toàn có thể thực thi, hay chạy chương trình để xem thành quả mà chúng ta xây dựng nên.

Với InteliJ bạn có nhiều cách để thực thi một chương trình, nhưng ở bài học hôm nay, nhanh nhất là bạn có thể tìm nhấn vào icon hình tam giác bên trong Editor nơi chứa MyFirstClass.java luôn nhé (như hình dưới). Sau đó chọn Run ‘MyFirstClass.main()’. Lưu ý là nếu bạn không nhìn thấy icon hình tam giác đâu cả thì có thể là bạn đã gõ sai câu lệnh nào đó khiến IDE không biết rằng đó là phương thức main để có thể thực thi được, khi này bạn cần phải kiểm tra kỹ code của bạn nhé.

Chọn thực thi với InteliJ
Chọn thực thi với InteliJ

Rất nhanh, bạn sẽ thấy cửa sổ Console xuất hiện với nội dung Hello World! mà bạn vừa code lúc nãy, nhờ vào câu lệnh System.out.println() đã giúp in log ra console. Nếu thấy cửa sổ và nội dung như dưới đây thì bạn đã thực thi thành công chương trình.

Cửa sổ console của InteliJ
Cửa sổ console của InteliJ

Biến Và Hằng Trong Java

Với project HelloWorld mà bạn đã tạo ra ở bài trước. Hôm nay bạn hãy bắt đầu tìm hiểu về ngôn ngữ Java bằng việc tiếp cận hai khái niệm biến và hằng, bằng việc thực hành khai báo chúng ở mức cơ bản. Và để dễ tiếp cận nhất có thể, với mỗi đoạn code thực hành, mình cũng sẽ đưa ra các trường hợp ĐÚNG và SAI của code, để các bạn vừa hiểu nhanh mà cũng vừa hiểu chắc bài học. Mời các bạn bắt đầu bài học.

Biến – Variable

Việc đầu tiên tiếp cận với một ngôn ngữ lập trình, không riêng gì Java, bạn phải làm quen với khái niệm biến, các tài liệu khác có thể gọi là variable.

Khái Niệm Biến

Biến là các định nghĩa từ bạn, khi bạn định nghĩa ra một biến, hệ thống sẽ căn cứ vào biến này để tạo ra các vùng nhớ, để lưu giữ các giá trị trong ứng dụng giúp cho bạn.

Mỗi biến đều có một kiểu dữ liệu riêng, kiểu dữ liệu này sẽ được hệ thống biết và cấp phát một “độ lớn” cho nó. Độ lớn của biến sẽ cho biết khả năng lưu trữ giá trị của biến. Bạn sẽ được nắm rõ về độ lớn lưu trữ của mỗi loại biến ở bài học này. Ngoài ra thì mỗi biến còn phải được bạn đặt một cái tên giúp hệ thống có thể quản lý, truy xuất dữ liệu trong vùng nhớ của biến đó.

Khai Báo Biến

Như khái niệm trên đây, chắc bạn cũng đã nắm phần nào, việc khai báo một biến thực chất là việc bạn sẽ đặt tên cho biến, rồi đặt một kiểu dữ liệu cho biến, và có thể lưu trữ giá trị ban đầu vào cùng nhớ của biến nữa.

Để khai báo một biến trong Java, bạn hãy ghi nhớ cú pháp sau:

kiểu_dữ_liệu tên_biến;

hoặc

kiểu_dữ_liệu tên_biến = giá_trị;

Mình giải thích một chút về cú pháp trên.

  • kiểu_dữ_liệu sẽ quyết định đến độ lớn của biến, các loại kiểu_dữ_liệu và các cách dùng sẽ được nói rõ hơn ở phần tiếp theo của bài học.
  • tên_biến là… tên của biến :)), cách đặt tên cho biến sẽ được mình nói rõ hơn ở phần bên dưới luôn.
  • Khi khai báo biến, bạn có thể “gán” cho biến một giá_trị ban đầu, việc gán giá trị này sẽ làm cho biến có được dữ liệu ban đầu ngay khi được tạo ra. Nếu một biến được tạo ra mà không có giá trị gì được gán, nó sẽ được gán giá trị mặc định, giá trị mặc định này sẽ tùy theo kiểu_dữ_liệu của biến mà chúng ta sẽ nói đến bên dưới.
  • Cuối cùng, sau mỗi khai báo biến bạn nhớ có dấu “;” để báo cho hệ thống biết là kết thúc việc khai báo, nếu không có “;” hệ thống sẽ báo lỗi ngay.

Thực Hành Khai Báo Biến

Nào, giờ thì bạn hãy mở InteliJ lên.

Thông thường nếu trước đó bạn đang thao tác sẵn với project cũ, chính là project Welcome, thì khi bạn mở IDE này lên, project cũ sẽ hiển thị ra ngay cho bạn tiến hành code. Còn nếu như thấy màn hình Welcome như hình dưới đây, thì đơn giản, hãy nhấn chọn vào project HelloWorld ở danh sách các project bên trái để mở project này lên.

Mở project HelloWorld từ màn hình Welcome
Mở project HelloWorld từ màn hình Welcome

Với project HelloWorld đã được mở ra. Bạn hãy tìm và mở file MyFirstClass.java nếu như nó chưa được mở sẵn trong editor. Sau đó, bạn thử gõ thêm dưới dòng code mà bạn đã code từ bài trước các dòng khai báo biến như sau nhé. Chỉ là gõ cho quen thôi. Bạn sẽ có cơ hội được khai báo các biến ở các mục phía dưới.

public class MyFirstClass {
 
public static void main(String[] args) {
System.out.println("Hello World!");
 
double salary;
int count = 0;
boolean done = false;
long earthPopulation;
}
 
}

Đặt Tên Cho Biến

Như ví dụ ở trên, các tên biến được đặt tên là salarycountdone hay earthPopilation. Việc đặt tên cho biến đơn giản đúng không nào, đặt như thế nào cũng được, miễn sao bạn đọc lên được (ngôn ngữ của con người), và bạn thấy nó dễ hiểu và đúng với công năng của biến đó là được rồi.

Tuy nhiên thì việc đặt tên cho biến cũng không hoàn toàn tự do, có một số “luật” đặt tên sau đây, nếu bạn mắc phải một trong các quy tắc đặt tên này thì hệ thống sẽ báo lỗi ngay đấy.

Quy Tắc 1

Ký tự bắt đầu của tên biến phải là chữ cái, hoặc dấu gạch dưới (_), hoặc ký tự đô la ($). Ví dụ cho việc đặt tên biến đúng như sau.

int count;

int Count;

int _count;

int $count;
Đúng

Còn đặt tên biến như sau sẽ bị lỗi. Vì các ký tự đầu tiên của chúng hoặc là một số, hoặc là một ký tự đặc biệt không được phép. Nếu không tin bạn cứ thử gõ vào IDE xem sao nhé.

int 5count;

int 5Count;

int #count;

int /count;
Sai

Quy Tắc 2

Không được có khoảng trắng giữa các ký tự của biến. Ví dụ đặt tên biến như sau là sai.

int this is count;
Sai

Nếu bạn muốn tên biến trên được tách biệt rõ ràng từng chữ cho rõ nghĩa thì có thể đặt như sau.

int thisIsCount;

int this_is_count;

int This_Is_Count;
Đúng

Đến đây thì cho mình nói ngoài lề xíu: trong trường hợp biến có nhiều từ như ví dụ trên, việc bạn chọn muốn đặt theo kiểu nào trong 3 cách đúng như trên đều được, chúng đều là các cách đặt tên biến phổ biến ngày nay. Thậm chí một trong số cách đó còn có những cái tên thú vị.

Chẳng hạn biến thisIsCount được gọi là cách đặt tên theo camelCase (đặt kiểu con lạc đà), có lẽ là bởi hình dáng của nó. Với cách này thì chữ đầu tiên this được viết thường, nên trông thấp nhỏ như phần đầu lạc đà. Các chữ cái ở mỗi từ tiếp theo sẽ viết hoa IsCount, từng chữ viết hoa làm chúng nhô lên cao như những cái bứu trên lưng con lạc đà vậy.

Còn biến this_is_count được gọi là cách đặt tên theo snake_case (đặt kiểu con rắn), cũng bởi đặc điểm hình dáng của biến. Với cách đặt tên này thì các chữ cái đều viết thường hết (cũng có người viết hoa mỗi chữ cái đầu như với This_Is_Count, nhưng cách đặt này làm chúng ta tốn thời gian gõ tên biến hơn) và được nối với nhau bởi dấu gạch dưới. Việc đặt tên này làm cho biến bị kéo dài ra giống như con rắn vậy. Cách đặt này giúp chúng ta rất dễ đọc từng từ vì chúng cách biệt khá rõ ràng.

Với lập trình Java thì các bạn nên chọn ra cho mình một phong cách đặt tên biến rõ ràng, đừng nhập nhằng lúc thì camelCase lúc thì snake_case. Với mình thì mình khuyên bạn nên đặt tên biến theo kiểu camelCase (bạn sẽ thấy từ đây về sau mình sẽ dùng cách đặt tên này) vì đó là “lệ” đặt tên biến chung của các ngôn ngữ Pascal, Java, C# hay Visual Basic rồi.

Lưu ý là đặt kiểu nào cho biến cũng được nhưng nhớ đừng có chữ nào cũng viết hoa hết như ThisIsCount vì nó rất dễ nhầm lẫn với đặt tên cho class mà bạn sẽ học ở các bài sau, hay THIS_IS_COUNT vì nó lại nhầm với hằng số mà bạn cũng sắp được làm quen ở bài hôm nay nhé.

Quy Tắc 3

Không chứa ký tự đặc biệt bên trong tên biến như !@#%^&*. Ví dụ đặt tên biến như sau là sai.

int c@unt;

int count#;

int count*count;
Sai

Quy Tắc 4

Không được đặt tên biến trùng với keyword. Keyword là các từ khóa mà ngôn ngữ Java dành riêng cho một số mục đích của hệ thống. Các keyword của Java được liệt kê trong bảng sau.

abstract continue for new switch
assert default goto package synchronized
boolean do if private this
break double implements protected throw
byte else import public throws
case enum instanceof return transient
catch extends int short try
char final interface static void
class finally long strictfp volatile
const float native super while

Ví dụ đặt tên biến như sau là sai.

boolean continue = true;

long class;

int final;
Sai

Tuy nhiên bạn có thể đặt tên biến có chứa keyword bên trong đó mà không bị bắt lỗi, như ví dụ sau.

boolean continue1 = true;

long classMySchool;

int finalTarget;
Đúng

Kiểu Dữ Liệu Của Biến

Như đã nói ở trên, mỗi biến đều phải có một kiểu_dữ_liệu kèm theo nó. Kiểu dữ liệu sẽ báo cho hệ thống biết biến đó có “độ lớn” bao nhiêu, độ lớn này sẽ cho biết khả năng lưu trữ giá trị của biến.

Việc chọn một kiểu dữ liệu cho biến là phụ thuộc ở bạn. Thông thường bạn nên dựa trên công năng của biến đó, chẳng hạn như biến thuộc kiểu ký tự hay kiểu số, với kiểu số thì lại là số nguyên hay số thực. Hoặc bạn có thể dựa trên khả năng lưu trữ giá trị của biến đó, như biến là số integer hay số long, số float hay số double.

Chúng ta hãy bắt đầu làm quen với 8 kiểu dữ liệu nguyên thủy của biến thông qua sơ đồ sau.

Gọi là kiểu dữ liệu nguyên thủy vì chúng là các kiểu dữ liệu cơ bản và được cung cấp sẵn của Java. Về mặt lưu trữ thì kiểu dữ liệu nguyên thủy lưu trữ dữ liệu trong chính bản thân nó, việc sử dụng kiểu này cũng rất đơn giản, và không dính líu gì đến hướng đối tượng cả. Ngược lại với kiểu dữ liệu nguyên thủy là kiểu dữ liệu hướng đối tượng sẽ được mình nói ở các bài sau khi bạn đã nắm được kiến thức căn bản của Java.

Sơ đồ các kiểu dữ liệu nguyên thủy trong Java
Sơ đồ các kiểu dữ liệu nguyên thủy trong Java

Nhìn nhiều vậy thôi chứ các kiểu dữ liệu nguyên thủy mà chúng ta cần quan tâm chính là các ô màu xanh lá cây, bao gồm các kiểu intshortlongbytefloatdoublechar, và boolean. Các ô màu khác chỉ là gom nhóm các kiểu dữ liệu lại để chúng ta dễ nhớ và dễ sử dụng hơn thôi

Chúng ta cùng nhau đi vào từng loại kiểu dữ liệu để biết rõ hơn.

Kiểu Số Nguyên

Kiểu này dùng để lưu trữ và tính toán các số nguyên, bao gồm các số nguyên có giá trị âm, các số nguyên có giá trị dương và số 0. Sở dĩ có nhiều kiểu số nguyên như trên, là vì tùy vào độ lớn của biến mà bạn sẽ khai báo kiểu dữ liệu tương ứng, chúng ta cùng xem qua bảng giá trị của các kiểu dữ liệu số nguyên như sau.

Kiểu Dữ LiệuBộ Nhớ Lưu trữĐộ Lớn Của Biến
byte 1 byte Từ –128 Đến 127
short 2 byte Từ –32,768 Đến 32,767
int 4 byte Từ –2,147,483,648 Đến 2,147,483, 647
long 8 byte Từ –9,223,372,036,854,775,808 Đến 9,223,372,036,854,775,807

Theo như bảng trên thì bạn thấy việc chọn lựa độ lớn cho kiểu là rất quan trọng, như với kiểu là byte, hệ thống sẽ chỉ cấp phát cho chúng ta vùng nhớ lưu trữ đúng 1 byte dữ liệu, với vùng nhớ này bạn chỉ được phép sử dụng độ lớn của biến từ -128 đến 127 mà thôi.

Ví dụ bạn khai báo biến như sau là hợp lệ.

byte month = 5;

short salaryUSD = 2000;
Đúng

Còn khai báo như sau sẽ vượt ra ngoài khả năng lưu trữ của biến và vì vậy bạn sẽ bị báo lỗi ngay.

byte day = 365;

short salaryVND = 40000000;
Sai

Do đó khi sử dụng kiểu dữ liệu cho biến bạn phải cân nhắc đến độ lớn lưu trữ của chúng. Bạn không nên lúc nào cũng sử dụng kiểu dữ liệu long cho tất cả các trường hợp vì như vậy sẽ làm tiêu hao bộ nhớ của hệ thống khi chương trình được khởi chạy. Bạn nên biết giới hạn của biến để đưa ra kiểu dữ liệu phù hợp. Ví dụ nếu dùng biến để lưu trữ các tháng trong năm, thì kiểu byte là đủ. Hoặc dùng biến để lưu tiền lương của nhân viên theo VND, thì dùng kiểu int, hoặc thậm chí là kiểu long luôn vì biết đâu có người thu nhập đến hơn 2 tỷ VND mỗi tháng (eo ôi!).

Nếu không khai báo giá trị cho biến, thì biến kiểu số nguyên sẽ có giá trị mặc định là 0 (hoặc 0L đối với kiểu long).

Lưu ý nhỏ đối với giá trị mặc định của các kiểu dữ liệu.

Như trên mình có nói là, nếu không khai báo giá trị cho biến, thì biến kiểu số nguyên sẽ có giá trị mặc định là 0 (hoặc 0L đối với kiểu long). Ở các mục bên dưới, với từng loại kiểu dữ liệu khác nhau, mình cũng có nói từng loại giá trị mặc định tương ứng. Tuy nhiên nếu bạn nào đã từng biết chút Java và có thử nghiệm các kiểu giá trị mặc định như đoạn code sau.

public static void main(String[] args) {
int vacationDay;
System.out.println(vacationDay);
}

Bạn cứ nghĩ là nó sẽ in ra console giá trị 0 đúng không? Sai rồi, IDE sẽ báo lỗi như sau.

Tưởng đâu không lỗi nhưng IDE báo lỗi lạ lắm
Tưởng đâu không lỗi nhưng IDE báo lỗi lạ lắm

Theo như lỗi trên thì IDE bắt chúng ta phải khai báo giá trị cho biến trước khi dùng vào việc gì đó. Nếu bạn thử chọn gợi ý Initialize variable ‘vacationDay’ thì sẽ thấy biến vacationDay được tự sửa bằng cách gán giá trị ban đầu là 0.

int vacationDay = 0;
System.out.println(vacationDay);

Điều này có nghĩa là vacationDay không có giá trị mặc định ban đầu như mình có nói. Vậy bạn có thể nhớ nhanh ở mục này là, giá trị mặc định của một biến chỉ dùng cho trường hợp khi biến này đóng vai trò là thuộc tính của lớp mà bạn sẽ tìm hiểu sau. Còn với vai trò là biến cục bộ bên trong một phương thức như ví dụ trên, bạn vẫn phải khai báo tường minh một giá trị mặc định ban đầu cho biến.

Kiểu Số Thực

Kiểu này dùng để lưu trữ và tính toán các số thực, chính là các số có dấu chấm động. Cũng giống như kiểu số nguyên, kiểu số thực cũng được chia ra thành nhiều loại với nhiều độ lớn khác nhau tùy theo từng mục đích, như bảng sau.

Kiểu Dữ LiệuBộ Nhớ Lưu trữĐộ Lớn Của Biến
float 4 byte Xấp xỉ ±3.40282347E+38F
double 8 byte Xấp xỉ ±1.79769313486231570E+308

Với sự thoải mái trong cấp phát bộ nhớ của kiểu số thực thì bạn không quá lo lắng đến việc phân biệt khi nào nên dùng kiểu float hay double. Ví dụ sau đây cho thấy trường hợp dùng kiểu số thực.

float rating = 3.5f;

double radius = 34.162;
Đúng

Sở dĩ với kiểu float ở trên phải để chữ f vào cuối khai báo float rating = 3.5f; là vì thỉnh thoảng chúng ta phải nhấn mạnh cho hệ thống biết rằng chúng ta đang xài kiểu float chứ không phải kiểu double, nếu bạn không để thêm f vào cuối giá trị thì hệ thống sẽ báo lỗi. Tương tự bạn cũng có thể khai báo double radious = 34.162d; nhưng vì hệ thống đã hiểu đây là số double rồi nên bạn không có chữ d trong trường hợp này cũng không sao.

Nếu không khai báo giá trị cho biến, thì biến kiểu số thực sẽ có giá trị mặc định là 0.0f đối với kiểu float và 0.0d đối với kiểu double.

Kiểu char

Kiểu char dùng để khai báo và chứa đựng một ký tự. Bạn có thể gán một ký tự cho kiểu này trong một cặp dấu nháy đơn như ‘a’ hay ‘B’ như ví dụ sau.

char thisChar = 'a';
Đúng

Bạn phải luôn nhớ là ký tự ‘a’ được khai báo ở trên phải nằm trong cặp nháy đơn chứ không phải nháy kép nhé. Nháy kép là dành cho chuỗi sẽ được nói đến ở bài học sau. Và nhớ là nếu không có nháy cũng sai. Ví dụ sau khai báo sai kiểu char.

char thisCharFail1 = "a";

char thisCharFail2 = b;

char thisCharFail3 = 'ab';
Sai

Ngoài việc khai báo như trên, kiểu char còn được dùng theo mã Unicode. Mã Unicode bắt đầu bằng ‘\u0000’ và kết thúc bằng ‘\uffff’. Lưu ý là với cách dùng kiểu mã Unicode này, bạn vẫn phải dùng nháy đơn cho việc khai báo. Ký hiệu \u cho biết bạn đang dùng với mã Unicode chứ không phải ký tự bình thường. Với cách khai báo theo kiểu mã Unicode này, bạn có thể đưa vào chương trình một số ký tự đặc biệt, hãy thử thực hành code như sau và in ra log, bạn sẽ thấy hiệu quả của Unicode nhé.

public class MyFirstClass {
public static void main(String[] args) {
char testUnicode1 = '\u2122';
char testUnicode2 = '\u03C0';
 
System.out.println("See this character " + testUnicode1 + " and this character " + testUnicode2);
}
}

Kết quả in ra console như sau.

Kết quả in ra console
Kết quả in ra console

Nếu không khai báo giá trị cho biến, thì biến kiểu char sẽ có giá trị mặc định là ‘\u0000’.

Kiểu boolean

Khác với C/C++, kiểu boolean trong ngôn ngữ Java chỉ được biểu diễn bởi hai giá trị là true và false mà thôi. Do vậy mà kiểu dữ liệu này chỉ được dùng trong việc kiểm tra các điều kiện logic, chứ không dùng trong tính toán, và bạn cũng không thể gán một kiểu số nguyên về kiểu boolean như trong C/C++ được.

Khai báo một kiểu boolean đúng như sau.

boolean male = true;

boolean graduated = false;
Đúng

Việc khai báo một biến boolean như sau là sai.

boolean male = 1;
Sai

Nếu không khai báo giá trị cho biến, thì biến kiểu boolean sẽ có giá trị mặc định là false.

Hằng – Const

Hằng Số, hay còn gọi là const, là viết tắt của từ constantHằng cũng tương tự như biến, nhưng đặc biệt ở chỗ nếu một biến được khai báo là hằng thì nó sẽ không được thay đổi giá trị trong suốt chương trình.

Vì các ví dụ trên đây chỉ giúp cho các bạn khai báo một biến mà không đá động gì đến việc thay đổi giá trị biến đó, nhưng thực ra một biến có thể bị thay đổi giá trị nhiều lần trong suốt quá trình thực thi của ứng dụng, bạn sẽ làm quen với việc thay đổi giá trị của biến ở các bài sau. Còn hằng thì không có sự thay đổi nào cả, nếu bạn cố tình thay đổi hay gán lại giá trị mới của hằng sau khi nó được khai báo, bạn sẽ nhận được thông báo lỗi.

Để khai báo một hằng số, bạn cũng khai báo giống như biến nhưng thêm final vào trước khai báo. Bạn sẽ được biết nhiều hơn đến hằng số qua bài học riêng về từ khóa final. Giờ thì bạn có thể xem ví dụ sau để hiểu sơ về hằng.

final float PI = 3.14f;
final char FIRST_CHARACTER = 'a';
final int VIP_TYPE = 1;

Bạn nên tập khai báo hằng bằng các ký tự viết hoa như ví dụ trên đây, điều đó giúp chúng ta dễ dàng phân biệt được đâu là biến và đâu là hằng sau này.

Toán Tử (Operator)

Nếu như ở bài trước các bạn đã làm quen với việc khai báo và sử dụng các biến và hằng trong Java. Thì đến bài hôm nay chúng ta cùng học cách vận dụng các biến và hằng này vào các logic tính toán trong chương trình, thông qua việc tìm hiểu về các toán tử.

Trước khi vào làm quen với các toán tử, mình muốn nói về khái niệm biểu thức cái đã.

Biểu Thức

Bạn nên biết là lập trình không khác gì làm toán cả, tất cả chúng ta, những Lập Trình Viên, chỉ đơn giản là đang vận dụng các phép toán mà chúng ta đã từng được học vào trong việc lập trình ra các ứng dụng mà thôi. Và để làm quen lại với kiến thức toán, chúng ta cùng quay về với khái niệm biểu thức. Trong toán học định nghĩa rằng biểu thức (expression) là sự kết hợp giữa các toán tử (operator) và các toán hạng (operand) theo đúng một trật tự nhất định. Trong đó mỗi toán hạng có thể là một hằng, một biến hoặc một biểu thức khác.

Mình xin minh họa một biểu thức như sau.

Hình minh họa một Biểu Thức
Hình minh họa một Biểu Thức

Qua đó và + là các toán tử2y và 5 là các toán hạng. Rõ hơn nữa thì 2 và 5 là các hằng, còn y là biến.

Cũng giống như trong toán học, bạn có thể dùng cặp dấu ngoặc đơn () để gom nhóm các biểu thức lại, và khi đó thì biểu thức trong dấu ngoặc đơn sẽ được ưu tiên thực hiện trước.

Ví dụ minh họa cho một biểu thức có dấu ngoặc đơn như sau.

2 * (y + 5)

Ngoài các toán tử * và + trên đây, trong lập trình còn nhiều các toán tử đặc thù khác nữa. Mời các bạn cùng nhau làm quen tiếp nào.

Toán Tử Số Học

Chúng ta hãy làm quen với toán tử đầu tiên trong lập trình đó chính là toán tử số học (arithmetic operator)Toán tử này thì hoàn toàn giống với toán học, đó là các phép toán cộngtrừnhânchia. Cụ thể về các phép toán trong toán tử số học được mình liệt kê ở bảng sau.

Toán TửÝ Nghĩa
+ Toán tử cộng. Tương tự phép toán cộng trong toán học. Tuy nhiên trong lập trình có thể dùng toán tử này để cộng chuỗi (sẽ được mình nói cụ thể ở bài học chuỗi sau).
Toán tử trừ. Tương tự phép toán trừ trong toán học.
* Toán tử nhân. Tương tự phép toán nhân trong toán học.
/ Toán tử chia. Hơi khác với toán một chút là nếu hai phần tử trong phép toán đều là số nguyên thì kết quả của phép toán cũng là số nguyên, ví dụ 5/2 sẽ bằng 2. Còn hai phần tử là số thực thì kết quả sẽ là số thực, ví dụ 5.0/2.0 sẽ bằng 2.5.
% Toán tử chia lấy phần dư. Ví dụ 5%2 sẽ bằng 1, đó chính là số dư của phép chia.

Bài Thực Hành Số 1

Bạn hãy thử code để biết kết quả in ra của phép gán (toán tử Gán sẽ được nói rõ ở mục tiếp theo) từ một biểu thức tới biến age với các toán tử số học như sau.

int age;
int thisYear = 2016;
int yearOfBirth = 1990;
age = thisYear - yearOfBirth;
System.out.println("I am " + age + " years old");

Kết quả như sau

I am 26 years old

Bài Thực Hành Số 2

Bạn thử đoán xem kết quả in ra của các các so1so2, và so3 như ví dụ bên dưới, và xem kết quả có đúng với bạn đoán không nhé.

int so1;
int so2;
float so3;
 
so1 = 15 / 6;
so2 = 15 % 6;
so3 = 15.0f / 6.0f;
 
System.out.println("Ket qua so1 la " + so1 + ", so2 la " + so2 + ", so3 la " + so3);

Và kết quả.

Ket qua so1 la 2, so2 la 3, so3 la 2.5

Toán Tử Gán

Giờ chúng ta cùng ra khỏi toán học để đến biểu thức có vẻ giống lập trình hơn xíu. Trong lập trình, ngoài các toán tử cộng, trừ, nhân chia như trong toán học ra, chúng ta còn có nhiều toán tử đặc thù khác, một trong số đó là toán tử gán (assignment operator).

Trong Java, toán tử gán được thực hiện thông qua ký hiệu “=”. Quen lắm phải không nào, vì bài trước bạn đã thực hành khai báo một biến như sau 

int count = 5;

 thì dấu “=” ở đây chính là một phép gán (chứ không phải so sánh bằng, việc so sánh sẽ được nói đến ở toán tử bên dưới). Phép gán được định nghĩa cụ thể thông qua cú pháp.

tên_biến = biểu_thức

Khi đó kết quả của một biểu_thức (có thể là một biểu thức gồm nhiều toán tử và toán hạng, cũng có thể chỉ là một con số như các ví dụ ở bài trước) sẽ được gán vào tên_biến. Hay hiểu cách khác, đó là tên_biến sẽ chứa đựng giá trị bằng với biểu_thức mang đến thông qua phép gán này.

Bài Thực Hành Số 3

Với bài thực hành này, bạn hãy thực hiện phép gán sau đây và cho biết kết quả in ra log sau khi gán giá trị cho biến thisYear là gì nhé.

final int THIS_YEAR = 2016;
int thisYear = 2000;
thisYear = THIS_YEAR;
 
System.out.println("This year is " + thisYear);

Kết quả như sau, bạn nên thử code và thực thi bằng IDE, hoặc tự đoán kết quả trước khi xem kết quả bên dưới.

This year is 2016

Gán Cho Nhiều Biến Cùng Lúc

Trong trường hợp bạn có một giá trị giống nhau được gán cho nhiều biến khác nhau, thay vì như ví dụ dưới đây bạn phải gán giá trị đó cho từng biến một.

int x = 10;
int y = 10;
int z = 10;

Thì bạn có thể thực hiện việc gán chỉ với một dòng như dưới đây. Đảm bảo đúng.

int x;
int y;
int z;
x = y = z = 10;

Hoặc bạn có thể viết ngắn gọn hơn, cách viết này gom các khai báo biến với cùng một kiểu dữ liệu vào cùng một dòng.

int x, y, z;
x = y = z = 10;

Nhưng lưu ý bạn không thể viết như vậy được:

int x = int y = int z = 10;

.

Đầy Đủ Các Toán Tử Gán

Thực sự thì toán tử gán không chỉ có mỗi toán tử “=”. Trong Java có rất nhiều các toán tử gán nữa được mình liệt kê ở bảng dưới đây.

Toán TửÝ Nghĩa
= Đã được mình nói rõ ở trên kia rồi nhé.
+= Cộng giá trị của tên_biến bên trái với biểu_thức bên phải và gán kết quả đó lại cho tên_biến.
-= Ngược lại với toán tử trên, toán tử này trừ giá trị của tên_biến bên trái một giá trị biểu_thức bên phải, rồi gán kết quả đó lại cho tên_biến.
*= Tương tự, nhân tên_biến với biểu_thức với và gán kết quả lại cho tên_biến.
/= Chia tên_biến cho biểu_thức và gán kết quả lại cho tên_biến.
%= Chia lấy phần dư tên_biến cho biểu_thức và gán kết quả lại cho tên_biến.
<<= Dịch trái tên_biến sang một giá trị biểu_thức và gán kết quả lại cho tên_biến (bạn hãy xem toán tử bitwise bên dưới để biết dịch trái nghĩa là gì nhé).
>>= Ngược lại. Dịch phải tên_biến sang một giá trị biểu_thức và gán kết quả lại cho tên_biến (và bạn cũng xem toán tử bitwise bên dưới để biết dịch phải nghĩa là gì nhé).
&= AND tên_biến với biểu_thức và gán kết quả lại cho tên_biến (cũng xem toán tử bitwise để rõ hơn về phép toán AND).
|= OR tên_biến với biểu_thức và gán kết quả lại cho tên_biến (cũng xem toán tử bitwise để rõ hơn về phép toán OR).
^= XOR tên_biến với biểu_thức và gán kết quả lại cho tên_biến (cũng xem toán tử bitwise để rõ hơn về phép toán XOR).

Để hiểu rõ hơn về các toán tử gán liệt kê trên đây mời bạn đến với bài thực hành sau.

Bài Thực Hành Số 4

Bạn thử đoán xem các so1so2so3so4 sẽ có các giá trị như thế nào sau các phép gán sau nhé.

int so1, so2, so3, so4;
so1 = so2 = so3 = so4 = 10;
 
so1 += so2;
so2 -= 5;
so3 /= 2;
so4 %= 3;
 
System.out.println("Ket qua so1 la " + so1 + ", so2 la " + so2 + ", so3 la " + so3 + ", so4 la " + so4);

Và kết quả in ra là.

Ket qua so1 la 20, so2 la 5, so3 la 5, so4 la 1

Mình xin giải thích một chút cho các bạn dễ hiểu. Mình không nói lại việc khai báo và gán giá trị ban đầu là 10 cho các biến nữa. Chúng ta bắt đầu với đoạn code sau.

so1 += so2;
 câu lệnh này sẽ lấy giá trị của so1 ban đầu là 10 cộng với so2 cũng là 10, kết quả của phép cộng là 20 sẽ được gán cho so1. Nếu bạn thấy khó hiểu, thì mình xin bật mí cách viết này là viết ngắn gọn của câu lệnh 
so1 = so1 + so2;
 mà thôi.

Tương tự 

so2 -= 5;
 là cách viết ngắn gọn của 
so2 = so2 - 5;
. Cách này lấy so2 trừ đi cho 5 rồi gán kết quả lại cho so2.

Và 

so3 /= 2;
 cũng giống như 
so3 = so3 / 2;
.
so4 %= 3;
 giống như 
so4 = so4 % 3;
.

Bạn hãy tự suy luận tương tự với các toán tử còn lại ở bảng trên kia nhé.

Toán Tử Một Ngôi

Một toán tử đặc trưng của lập trình nữa, đó là toán tử một ngôi (unary operator). Với toán tử này, thì chỉ cần một toán tử kết hợp với một toán hạng thôi là đã có thể cho ra kết quả. Các bạn hãy nhìn vào bảng sau.

Toán TửÝ Nghĩa
+ Toán tử cộng một ngôi. Nó khác với toán tử cộng số học ở bảng trên. Toán tử này biểu diễn số dương. Vì các số dương bình thường không cần phải hiển thị toán tử này, nên bạn cũng sẽ không thấy công dụng của nó.
Toán tử trừ một ngôi. Toán tử trừ này biểu diễn số âm.
++ Toán tử tăng. Toán tử này sẽ làm tăng giá trị của toán hạng lên 1 đơn vị.
– – Toán tử giảm. Toán tử này sẽ làm giảm giá trị của toán hạng đi 1 đơn vị.
! Toán tử phủ định logic. Toán tử này sẽ đảo ngược giá trị của biến biểu thức logic. Nếu biểu thức logic đang là true thì sẽ bị đảo ngược thành false và ngược lại

Bài Thực Hành Số 5

Bạn hãy nhìn biểu thức dưới đây, soGi sẽ được gán bằng một biểu thức, mà ở đó soDuong được cộng với một toán tử một ngôi biểu thị số âm.

int soDuong = 4;
int soGi = soDuong + -10;
System.out.println("Ket qua la " + soGi);

Và kết quả như sau chắc bạn cũng dễ dàng đoán được.

Ket qua la -6

Bài Thực Hành Số 6

Bạn hãy chú ý vào các toán tử “++” và “–“ sau đây, như đã nói, các toán tử này sẽ làm toán hạng đứng sau nó tăng lên hoặc giảm đi 1 rồi mới thực hiện các phép toán khác.

int so1 = 4;
int so2 = 10;
int soKetQua = ++so1 + --so2;
System.out.println("So1 la " + so1 + ", so2 la " + so2 + ", ket qua la " + soKetQua);

Và kết quả là.

So1 la 5, so2 la 9, ket qua la 14

Mình xin giải thích một chút ở Bài Thực Hành Số 6 trên đây, vì toán tử một ngôi dạng này sẽ hơi lạ với các bạn mới làm quen với lập trình. Với việc thực hiện 

soKetQua = ++so1 + --so2;
, hệ thống sẽ thực hiện biểu thức 
++so1
 trước và so1 sau đó mang giá trị là 5 (
++so1
 lúc bấy giờ tương tự như biểu thức 
so1 = so1 + 1;
 vậy). Rồi hệ thống cũng thực hiện tiếp 
--so2
 ra giá trị 9 (tương tự thì 
--so2
 cũng sẽ giống như 
so2 = so2 - 1;
). Sau cùng thì tiến hành cộng hai số này lại và gán cho biến soKetQua.

Có bạn nào thắc mắc là hai cách viết 

++so1
 và 
so1++
 có khác nhau không? Câu trả lời là . Cũng giống như C/C++, khi bạn viết 
++so1
, Java sẽ thực hiện việc tăng giá trị của so1 trước khi lấy giá trị đó dùng vào trong biểu thức. Còn nếu viết 
so1++
, Java sẽ lấy giá trị của so1 dùng vào trong biểu thức trước khi tăng giá trị của nó.

Để hiểu rõ hơn những ý mình liệt kê trên đây, bạn hãy thử sửa code trên lại một chút như sau và chạy lại nhé.

int so1 = 4;
int so2 = 10;
int soKetQua = so1++ + so2;
System.out.println("So1 la " + so1 + ", so2 la " + so2 + ", ket qua la " + soKetQua);

Kết quả in ra là.

So1 la 5, so2 la 10, ket qua la 14

Với thử nghiệm trên, bạn thấy 

so1++
 không hề được áp dụng tăng giá trị của so1 vào biểu thức, mà so1 vẫn mang giá trị 4 rồi cộng với so2 là 10 ra kết quả 14. Sau khi thự hiện xong biểu thức thì so1 mới được tăng giá trị và vì vậy bạn thấy in ra so1 là 5.

Toán Tử Quan Hệ

Nghe đến quan hệ sao nó… thôi bỏ qua. Nhìn vào nghĩa tiếng Anh thì toán tử quan hệ chính là relational operator, có thể hiểu như là các phép toán thể hiện các mối liên hệ. Chúng sẽ giúp so sánh các biểu thức với nhau để cho ra kết quả là một giá trị boolean, tức là cho ra một trong hai giá trị hay false. Bạn hãy nhìn vào bảng để xem các toán tử quan hệ sau.

Toán TửÝ Nghĩa
== Bằng
!= Không bằng (khác)
> Lớn hơn
>= Lớn hơn hoặc bằng
< Nhỏ hơn
<= Nhỏ hơn hoặc bằng

Chú ý rằng kết quả của toán tử này luôn trả về một giá trị boolean nhé.

Bài Thực Hành Số 7

Bạn thử ngẫm xem kết quả in ra sẽ là giá trị true hay false?

double weight = 71.23;
int height = 191;
boolean married = false;
boolean attached = false;
char gender = 'm';
 
System.out.println("Ket qua 1: " + (!married == attached));
System.out.println("Ket qua 2: " + (gender != 'f'));
System.out.println("Ket qua 3: " + (height >= 180));
System.out.println("Ket qua 4: " + (weight > 90));

Bạn đã có kết quả của riêng mình chưa? Nếu có rồi thì cùng so sánh với kết quả bên dưới nhé.

Ket qua 1: false
Ket qua 2: true
Ket qua 3: true
Ket qua 4: false

Toán Tử Điều Kiện

Các toán tử điều kiện sẽ so sánh các biểu thức mang giá trị true và false với nhau. Toán tử này còn được gọi là toán tử logic (logical operator). Bạn cứ tưởng tượng toán tử này sẽ hoạt động theo kiểu câu nói “nếu trời không mưa và tôi có tiền thì tôi sẽ đi chơi hôm nay”. Trong đó “trời không mưa” mang một giá trị boolean, và “tôi có tiền” cũng là một giá trị boolean, hai giá trị này được so sánh bởi toán tử “và”. Vậy nếu “trời không mưa” và “tôi có tiền” đều mang giá trị true thì kết quả so sánh sẽ là true, tức “tôi sẽ đi chơi hôm nay” là true, tức là tôi sẽ đi chơi. Ngược lại nếu một trong hai vế đầu là false thì kết quả sẽ là false, tức tôi sẽ không đi chơi. Các toán tử điều kiện được nêu trong bảng sau.

Toán TửÝ Nghĩa
&& So sánh AND.
|| So sánh OR.
?: So sánh theo điều kiện, sẽ được nhắc đến khi chúng ta học đến cách viết điều kiện if..else.. ở bài học sau.

Sau đây là bảng chân trị, tức các kết quả so sánh điều kiện của các toán tử && và || như sau.

pqp && qp || q
false false false false
false true false true
true false false true
true true true true

Bài Thực Hành Số 8

Bạn thử gõ các câu lệnh sau và ngẫm kết quả của nó nhé.

int age = 18;
double weight = 71.23;
int height = 191;
boolean married = false;
boolean attached = false;
char gender = 'm';
 
System.out.println(!married && !attached && (gender == 'm'));
System.out.println(married && (gender == 'f'));
System.out.println((height >= 180) && (weight >= 65) && (weight <= 80));
System.out.println((height >= 180) || (weight >= 90));

Kết quả như sau.

true
false
true
true

Chúng ta thử giải nghĩa dòng in log thứ nhất để hiểu rõ hơn. Đầu tiên biến married có giá trị false!married đảo ngược giá trị thành true. Tương tự attached là false và !attached là true(gender==’m’) sẽ là true. Tóm lại chúng ta có true && true && true, và kết quả cuối cùng theo bảng chân trị là true.

Dòng thứ hai, ưu tiên biểu thức trong ngoặc đơn trước (gender == ‘f’) sẽ cho kết quả false. Biến married được khai báo là falsefalse && false kết quả theo bảng chân trị là false.

Bạn cứ giải nghĩa tương tự cho các dòng còn lại.

Toán Tử Bitwise

Toán tử bitwise là toán tử giúp chúng ta tương tác trực tiếp trên các bit (dạng nhị phân) của các toán hạng. Để hiểu rõ cách tổ chức và cách làm việc với các giá trị nhị phân thì dài dòng lắm, có thể mình sẽ nói rõ hơn ở các bài khác. Nhưng bạn có thể hiểu bản chất của tất cả dữ liệu mà máy tính có thể hiểu và làm việc chính là các dữ liệu dạng nhị phân được biểu diễn chỉ bởi hai giá trị là 0 và 1. Mỗi một giá trị 0 hay 1 như vậy là một bit, bạn đã hiểu tại sao lại có tên bitwise rồi đó. Nhờ vào việc đây là ngôn ngữ “mẹ đẻ” của máy tính, nên nếu bạn dùng chính cách thức nhị phân này để “trò chuyện” với máy, sẽ nhanh chóng hơn rất nhiều so với các cách tương tác với các dạng số khác.

Bảng sau là các toán tử bitwise.

Toán TửÝ Nghĩa
& AND
| OR
^ XOR
~ NOT, đảo ngược bit 0 thành 1, và ngược lại 1 thành 0.
>> Dịch phải
<< Dịch trái

Bạn nên nhớ AND ở toán tử bitwise chỉ có một dấu &, còn AND ở toán tử điều kiện có hai dấu &&. Và một điều khác nhau nữa là && chỉ so sánh các biểu thức boolean với nhau, còn & so sánh dựa trên các bit, vì vậy mà & có thể làm việc với hầu như mọi kiểu dữ liệu nguyên thủy trong Java (trừ kiểu số thực). Tương tự cho khác nhau giữa || và |.

Trước khi đi vào bài thực hành toán tử bitwise, bạn hãy xem qua bảng chân trị cho bitwise như sau.

pqp & qp | qp ^ q
0 0 0 0 0
0 1 0 1 1
1 0 0 1 1
1 1 1 1 0

Bài Thực Hành Số 9

Ở bài thực hành này chúng ta cùng code thử các toán tử bitwise như sau, mình sẽ giải thích kết quả tại sao ở bên dưới bài thực hành này.

byte so1 = 5;
byte so2 = 12;
 
System.out.println("Toan tu &: " + (so1 & so2));
System.out.println("Toan tu |: " + (so1 | so2));
System.out.println("Toan tu ^: " + (so1 ^ so2));
System.out.println("Toan tu ~: " + (so1 & ~so2));
System.out.println("Toan tu <<: " + (so1<<1));
System.out.println("Toan tu >>: " + (so2>>2));

Kết quả ở dưới đây.

Toan tu &: 4
Toan tu |: 13
Toan tu ^: 9
Toan tu ~: 1
Toan tu <<: 10
Toan tu >>: 3

Giờ hãy cùng nhau giải thích ý nghĩa của các phép toán. Chúng ta đều biết kiểu dữ liệu byte được hệ thống cấp phát cho 1 byte bộ nhớ, ngoài ra 1 byte = 8 bits, vậy hai biến so1 và so2 có giá trị lần lượt là 5 và 12 sẽ được biểu diễn dạng bit như sau.

5: 0000 0101
12: 0000 1100

so1 & so2
: nếu bạn lấy 
0000 0101 & 0000 1100
, bạn hãy áp dụng bảng chân trị vào so sánh từng bit với nhau, bạn sẽ có kết quả là 0000 0100, đây là biểu diễn bit của số 4, và là kết quả của dòng log đầu tiên.
so1 | so2
: tương tự như trên 
0000 0101 | 0000 1100
 sẽ là 0000 1101, là dạng bit của số 13.
so1 ^ so2
0000 0101 ^ 0000 1100
 sẽ là 0000 1001, là dạng bit của số 9.
so1 & ~so2
: ta xét từ 
~so2
~ là toán tử NOT, nó sẽ đảo ngược các bit của so2 thành 1111 0011, vậy 
so1 & ~so2
 sẽ là 
0000 0101 & 1111 0011
, sẽ là 0000 0001, là dạng bit của số 1.
so1<<1
: tức là lấy dãy bit của so1 dịch trái đi 1 bit, sẽ thành 0000 1010, là dạng bit của số 10.
so2>>2
: tức là lấy dãy bit của so2 dịch phải đi 2 bit, sẽ thành 0000 0011, là dạng bit của số 3.

Ép Kiểu & Comment Source Code

Bài hôm nay chúng ta sẽ nói về 2 vấn đề “nhỏ”, đó là ép kiểu và comment source code. Bạn cũng nên biết nhỏ ở đây là nhỏ về tổng số chữ viết, chứ thực ra hai vấn đề hôm nay đều rất quan trọng cho các bài học kế tiếp đấy nhé. Với kiến thức về ép kiểu, chúng là kiến thức nền để bạn hiểu rõ cách sử dụng các kiểu dữ liệu khác nhau trong các ứng dụng của bạn. Còn comment source code sẽ trở thành phong cách viết code sao cho có đầy đủ chú thích dễ hiểu cho chính bạn và những người khác đọc source code của bạn sau này.

Nào để hiểu nó là gì, mời bạn đến với bài học.

Khái Niệm Ép Kiểu

Trước khi đi sâu vào tìm hiểu về ép kiểu, mình cũng xin nhắc lại một chút, là chúng ta đã từng làm quen với việc khai báo một biến (hoặc hằng), khi đó bạn cần chỉ định một kiểu dữ liệu cho biến hoặc hằng đó trước khi sử dụng. Việc khai báo một kiểu dữ liệu ban đầu như vậy mang tính tĩnh. Có nghĩa là nếu bạn định nghĩa biến đó là kiểu int, nó sẽ mãi là kiểu int, nếu bạn định nghĩa nó là kiểu float, nó sẽ mãi là kiểu float. Có bao giờ bạn thắc mắc nếu đem hai biến có kiểu dữ liệu khác nhau này vào tính toán với nhau, liệu nó sẽ tạo ra một biến kiểu gì? Và liệu chúng ta có thể thay đổi kiểu dữ liệu của một biến, hay một giá trị đã được khai báo hay không?

Câu hỏi trên cũng chính là nội dung của bài ép kiểu hôm nay. Cụ thể có thể mô tả ép kiểu như sau, ép kiểu là hình thức chuyển đổi kiểu dữ liệu của một biến sang một biến mới có kiểu dữ liệu khác. Vậy việc ép kiểu này không làm thay đổi kiểu dữ liệu của biến cũ, nó chỉ giúp bạn tạo ra một biến mới với kiểu dữ liệu mới, và mang dữ liệu từ biến cũ sang biến mới này. Khái niệm là vậy, còn mục đích là gì các bạn hãy đọc tiếp bài học hôm nay nhé.

Nên nhớ là vì các bạn chỉ mới làm quen với kiểu dữ liệu nguyên thủy, nên ép kiểu hôm nay cũng chỉ nói đến ép kiểu dữ liệu nguyên thủy mà thôi.

Phân Loại Ép Kiểu

Nếu phân loại ép kiểu dựa vào khả năng lưu trữ của biến, thì chúng ta có hai loại ép kiểu sau.

  • Nới rộng (widening) khả năng lưu trữ. Việc ép kiểu này sẽ làm chuyển đổi dữ liệu từ kiểu dữ liệu có kích thước nhỏ hơn sang kiểu dữ liệu có kích thước lớn hơn. Điều này không làm mất đi giá trị của dữ liệu sau khi thực hiện việc ép kiểu. Ví dụ ban đầu bạn có một biến kiểu int, có giá trị là 6, bạn ép dữ liệu từ kiểu int sang float, rồi gán vào biến mới float, thì biến mới float sẽ mang giá trị 6.0f. Việc ép kiểu theo dạng này thông thường người ta cứ để cho hệ thống thực hiện một cách ngầm định.
  • Thu hẹp (narrowing) khả năng lưu trữ. Việc ép kiểu này sẽ làm chuyển đổi dữ liệu từ kiểu dữ liệu có kích thướng lớn hơn sang kiểu dữ liệu có kích thước nhỏ hơn. Điều này có thể làm mất đi giá trị của dữ liệu. Ví dụ bạn ban đầu bạn có một biến kiểu float, có giá trị là 6.5, bạn ép dữ liệu từ kiểu float sang int, rồi gán vào biến mới int, thì biến mới int sẽ mang giá trị 6. Việc ép kiểu này không thể để cho hệ thống thực hiện một cách ngầm định được, lúc này hệ thống sẽ báo lỗi, bạn phải thực hiện ép kiểu tường minh cho nó.

Kiểu phân loại thứ hai được chia làm hai dạng, đó là ngầm định và tường minh như mình có nhắc đến trên đây. Việc chia thành hai dạng này mang tính đặt tên để gọi đến, với lại để cho bạn nắm được cách gọi phòng khi bạn đọc tài liệu đâu đó có dùng đến các từ này, bản chất của nó vẫn tương ứng với phân biệt theo khả năng lưu trữ trên kia.

  • Ép kiểu ngầm định. Việc ép kiểu này có thể diễn ra một cách tự động bởi hệ thống, khi hệ thống phát hiện thấy cần phải thực hiện việc ép kiểu, nó sẽ tự thực hiện. Như mình có nhắc đến ở ngay trên đây, ép kiểu ngầm định chính là nới rộng khả năng lưu trữ, kiểu nới rộng này sẽ không làm mất đi giá trị của dữ liệu. Khi bắt đầu thực hiện ép kiểu, hệ thống sẽ kiểm tra các nguyên tắc sau, nếu thỏa sẽ ép.
    • byte có thể ép kiểu sang shortintlongfloatdouble.
    • short có thể ép kiểu sang intlongfloatdouble.
    • int có thể ép kiểu sang longfloatdouble.
    • long có thể ép kiểu sang floatdouble.
    • float có thể ép kiểu sang double.
  • Ép kiểu tường minh. Khi không thỏa điều kiện để có thể tự động ép kiểu, thì hệ thống sẽ báo lỗi. Việc còn lại là bạn phải chỉ định ép kiểu một cách tường minh. Vì cách ép kiểu này có thể làm mất đi giá trị của dữ liệu, cho nên rất cần bạn đưa ra quyết định, chứ hệ thống không dám tự quyết. Bạn sẽ chỉ định như sau khi muốn thực hiện việc ép kiểu tường minh này.
(kiểu_dữ_liệu_cần_ép) tên_biến;

Bài Thực Hành Số 1

Bài thực hành này sẽ giúp bạn làm quen với dạng ép kiểu ngầm định. Với cách ép kiểu này bạn chú ý là chúng ta không cần làm gì cả, cứ code đi rồi hệ thống sẽ tự thực hiện việc ép kiểu thôi. Bạn hãy nhìn code sau.

public class MyFirstClass {
public static void main(String[] args) {
byte b = 50;
short s = b;
int i = s;
long l = i;
float f = l;
double d = f;
 
System.out.println("This is a byte: " + b);
System.out.println("This is a short: " + s);
System.out.println("This is a int: " + i);
System.out.println("This is a long: " + l);
System.out.println("This is a float: " + f);
System.out.println("This is a double: " + d);
System.out.println("What type is it? " + (i + f));
}
}

Bạn thấy với b ban đầu được khai báo là kiểu byte với giá trị 50. Sau phép gán cho biến s (giá trị là short) thì giá trị 50 trong biểu thức này được chuyển tự động thành kiểu giữ liệu short cao hơn và gán vào biến s. Tương tự cho các phép gán vào ilfd.

Dòng cuối cùng in ra “What type is it?” cho thấy một dạng ép kiểu ngầm định khác của hệ thống. Khi này bạn không khái báo trước một kiểu dữ liệu nào cả mà dùng hai biến có các kiểu int và float vào biểu thức. Bạn thấy hệ thống sẽ tự ép kiểu dữ liệu nhỏ hơn về kiểu lớn hơn, cụ thể lúc này là ép int về float và thực hiện phép cộng với hai số float. Kết quả chúng ta có một kiểu float in ra màn hình.

Bạn hãy so sánh kết quả in ra như hình sau.

Kết quả sau khi ép kiểu ngầm định
Kết quả sau khi ép kiểu ngầm định

Nhưng nếu bạn thử trắc nghiệm bằng cách kêu hệ thống ép một kiểu dữ liệu lớn hơn về kiểu nhỏ hơn xem. Hệ thống sẽ báo lỗi ngay. Và vì vậy bạn cần phải can thiệp mục kế tiếp dưới đây.

int i = 50;
short s = i;

Bài Thực Hành Số 2

Quay lại ví dụ báo lỗi trên đây, hệ thống đã từ chối tự động ép kiểu ngầm định, vậy nếu vẫn có nhu cầu muốn ép kiểu thì bạn hãy ép kiểu tường minh cho nó, lúc này bạn cần dùng đến cấu trúc ép kiểu tường minh mà mình có đưa ra ở trên kia, như vầy.

public class MyFirstClass {
public static void main(String[] args) {
int i = 50;
short s = (short) i;
 
System.out.println("This is a short: " + s);
}
}

Bạn hãy nhìn vào dòng 

short s = (short) i;
, dòng này sẽ ép kiểu giá trị của i về short và gán cho biến s. Việc bạn chỉ định ép kiểu với (short) nhìn vào là biết ý đồ ngay, vì vậy mới có cái tên là tường minh.

Bạn hãy xem một ví dụ nữa thực tế hơn, có một số trường hợp bạn muốn bỏ dấu thập phân của một kiểu số thực, cách nhanh nhất để làm điều này là ép kiểu số thực này về một kiểu số nguyên. Cách ép kiểu này thực chất là làm mất giá trị của biến một cách… cố tình.

public class MyFirstClass {
public static void main(String[] args) {
double d = 7.5;
int i = (int) d;
 
System.out.println("This is a int: " + i);
System.out.println("This is a double: " + d);
}
}

Kết quả của việc ép kiểu này được in ra như sau, bạn chú ý dòng in ra kiểu int nhé, mất tiêu phần thập phân rồi.

Kết quả sau khi ép kiểu tường minh
Kết quả sau khi ép kiểu tường minh

Comment Source Code

Vì kiến thức về ép kiểu ngắn quá, nên mình nói luôn về comment source code ở bài này. Hai kiến thức này không ăn nhập gì với nhau hết, nhưng nó sẽ giúp bổ trợ tốt cho bạn trong quá trình code đấy.

Sở dĩ mình dùng từ comment chứ không dịch sang tiếng Việt là vì để tránh hiểu lầm thôi, vì đa số các bạn đều biết comment nghĩa là “bình luận”, nhưng thực ra mục đích của comment trong việc code lại chính là “ghi chú” hay “chú thích”. Nó giúp bạn giải nghĩa cho một số dòng code, bạn có thể comment thoải mái ở bất kỳ đâu trong source code, trình biên dịch sẽ bỏ qua các comment này khi build, do đó sẽ không có lỗi xảy ra với chúng.

Cách Thức Comment

Bạn có 3 cách comment như sau.

  • // text
    . Khi trình biên dịch gặp ký hiệu //, nó sẽ bỏ qua tất cả các ký tự từ // đến ký tự cuối cùng của dòng đó. Như vậy với mỗi // sẽ giúp bạn comment được 1 dòng.
  • /* text */
    . Khi trình biên dịch gặp ký hiệu /*, nó sẽ bỏ qua tất cả các ký tự từ /* đến hết */. Cách comment này có thể giúp bạn comment được nhiều dòng cùng lúc.
  • /** document */
    . Tương tự như trên nhưng bạn chú ý có hai dấu * khi bắt đầu. Cách comment này giúp trích xuất ra các tài liệu hướng dẫn. Người ta gọi là document. Cách comment này mình đã nói rõ hơn ở bài viết này.

Bài Thực Hành Số 3

Với bài thực hành này bạn hãy tiến hành gõ comment tổng hợp “3 trong 1” sau đây, mình đã gộp cả ba cách comment được nói ở trên vào một chương trình. Thực ra thì bạn không cần phải comment quá nhiều như ví dụ, bạn nên comment khi cần thiết phải giải nghĩa cho một dòng code lạ nào đó, hoặc ghi chú tác giả dòng code đó là bạn, hoặc ngày sửa chữa dòng code này,…

 
public class MyFirstClass {
 
/**
* Kiểu comment cho document, sẽ được nói đến sau
*
* @param args
*/
public static void main(String[] args) {
 
// Comment một dòng
// Muốn dòng thứ 2 thì như thế này
double d = 7.5;
int i = (int) d; // Cast the double into int
 
/*
* Các câu lệnh bên dưới sẽ in ra console,
* và có thể comment nhiều dòng như sau
* một dòng nữa, chỉ cần enter mà thôi
*/
System.out.println("This is a int: " + i);
System.out.println("This is a double: " + d);
}
}

Nhập/Xuất Trên Console

Thực ra bài hôm nay mình sẽ nói hơi xa hơn kiến thức của các bạn đã làm quen từ các bài học trước, kiến thức hôm nay có liên quan nhiều đến OOP (hướng đối tượng) mà bạn sẽ học ở các bài sau.

Ồ đừng vội nản nhé. Bạn có thể xem bài hôm nay sẽ hướng dẫn bạn sử dụng một “công cụ” để các bạn tiến hành nhập/xuất thông qua Console. Công cụ này giúp cho các bạn sau khi nhập dữ liệu vào từ bàn phím, nó sẽ lấy dữ liệu được nhập này rồi sau đó chuyển vào chương trình của bạn, để chương trình xử lý, rồi xuất ra kết quả thông qua màn hình Console.

Sau bài học hôm nay thì các bạn có thể test thoải mái các dòng code, để hỗ trợ tốt vào quá trình học Java của bạn. Nhưng trước hết, chúng ta hãy cùng tìm hiểu sâu hơn về khái niệm nãy nhờ được nhắc đi nhắc lại này.

Khái Niệm Console

Đầu tiên bạn có thể hiểu Console chúng như là một bảng điều khiển chuyên dụng. Như các Game Console chính là các thiết bị chuyên cho việc điều khiển game. Hay các trang web quản lý như quản lý nhân viên cũng có thể coi là các Console chuyên dụng cho việc quản lý.

Trong ngôn ngữ lập trình, Console được biết đến như là một cách điều khiển ứng dụng đơn thuần nhất thông qua các dòng text (dòng lệnh), nó được phân biệt với điều khiển bằng UI.

À sẵn tiện mình muốn nói rõ luôn, đó là tất cả các bài học trong chương trình học Java của chúng ta sẽ dựa trên việc điều khiển bằng Console, do đó nó sẽ khác với các ứng dụng Java có giao diện cụ thể bạn nhé. Các bài học chủ yếu mang đến cho bạn kiến thức làm thế nào sử dụng ngôn ngữ Java (để có thể lập trình ứng dụng Android), hơn là lập trình ứng dụng Java nên sẽ không có giao diện hoành tráng. Bạn nên nhớ điều này.

Và Console trong chương trình học của chúng ta là hình minh họa dưới đây, ở các bài làm quen sơ sơ trước, khi bạn thực thi chương trình thì cũng đã biết tới cửa sổ Console này rồi đúng không nào.

Màn hình Console trong InteliJ
Màn hình Console trong InteliJ

Nhập Trên Consolse

Chúng ta đang nói đến các cách thức để bạn nhập dữ liệu từ Console. Để bắt đầu, bạn hãy mở InteliJ lên. Bạn hãy mở lại project HelloWord mà chúng ta đã tạo ra từ bài hôm trước.

Đầu tiên mình muốn bạn thử khai báo một đối tượng Scanner như code bên dưới. Bạn code đi rồi mình sẽ nói rõ hơn lý do tại sao phải code như vậy ở bên dưới dòng code này.

Scanner scanner = new Scanner(System.in);

Các khái niệm lớp (class) hay đối tượng gì gì đó thì bạn sẽ được làm quen sau này, nó thuộc về kiến thức OOP như mình có nói đến ở đầu bài học. Nhưng bạn có thể hiểu trước là đối tượng cũng giống như biến vậy, bạn có thể khai báo nó với cái tên tùy ý (theo quy tắc đặt tên của biến). Như ở trên chúng ta khai báo một đối tượng của Scanner mang tên là scanner. Chỉ khác biến ở chỗ là đối tượng của lớp phải được khởi tạo bằng từ khóa new như trên. Khi đó tổng thể code của bạn như sau.

public class MyFirstClass {
 
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
}
 
}

Lan Man Về Cách Thức Bạn Code

Mục này cho phép mình nói lan man về kinh nghiệm code một tí xíu. Đó là nhiều bạn sau khi gõ xong dòng code trên đây, có thể sẽ có báo lỗi từ InteliJ như hình sau.

Khi bạn code có thể gặp tình huống báo lỗi như này
Khi bạn code có thể gặp tình huống báo lỗi như này

Sở dĩ có báo lỗi là vì hệ thống đang không biết lớp Scanner là gì :)). Đó là bởi vì trước đây bạn chỉ sử dụng các biến nguyên thủy, hoặc các lớp nằm trong package java.lang. Với việc sử dụng này thì chúng ta không cần phải quan tâm, sẽ chẳng có lỗi nào như bạn đã từng thực hành với các bài học trước. Chà vậy chúng ta cần quan tâm gì? Thực ra trong Java, mỗi khi bạn code, bạn phải chỉ định nguồn gốc của các lớp bạn sẽ dùng, thông qua từ khóa import. Các dòng import được khai báo ở đầu mỗi file. Mỗi dòng import như vậy bao gồm từ khóa import, theo sau nó là package có chứa lớp cần dùng (bạn có thể xem thêm kiến thức về package ở đây).

Theo đó, ở ví dụ trên chúng ta cần đến lớp Scanner. Thì chúng ta cần import đúng package mà hệ thống muốn.

Để import đúng package có lớp Scanner cần dùng trên đây thực ra là một điều cực kỳ dễ dàng, có nhiều cách để thực hiện điều này, bạn hãy chọn một trong những cách sau.

Sử Dụng Gợi Ý Hoàn Thành Code Từ IDE

Có thể nói đây là cách thức dễ nhất để InteliJ tự động hoàn thành việc import một package cho bạn.

Việc của bạn là hãy gõ vài từ vào IDE, bạn sẽ thấy các gợi ý hoàn thành dòng code như hình bên dưới. Bạn hãy nhấn Enter nếu gợi ý đầu tiên bạn thấy là đúng, hoặc bạn có thể đưa vệt sáng đến dòng gợi ý nào khác rồi nhấn Enter. Ngoài việc kết quả dòng code được tự động hoàn thành thì hệ thống cũng import sẵn package java.util.Scanner cho bạn mà bạn không cần phải nhớ chúng là ai và từ đâu đến đúng không nào.

InteliJ tự gợi ý hoàn thành dòng code, và nếu bạn chấp nhận, nó cũng tự import package cho bạn
InteliJ tự gợi ý hoàn thành dòng code, và nếu bạn chấp nhận, nó cũng tự import package cho bạn

Bạn thử gõ hết câu lệnh trên với việc chọn những gợi ý ở các thành phần tiếp theo (scannernewScannerSystem) xem sao nhé. Kết quả sẽ không còn lỗi nữa.

Kêu IDE Import Giúp

Có đôi khi IDE không đưa ra bất kì gợi ý nào để hoàn thành dòng code như trên kia. Khi này bạn hãy cứ gõ hết tất cả dòng code cần thiết của bạn, đừng lo lắng nếu có báo lỗi. Sau khi gõ xong hết, thì bạn cứ để ý trên editor của IDE, chắc chắn sẽ xuất hiện các hướng dẫn tận tình giúp chúng ta sửa lỗi.

Như trường hợp dưới đây, sau khi gõ xong, bạn sẽ thấy các nơi báo đỏ, khi đưa con trỏ chuột vào những nơi bị tô đỏ đó, nếu là lỗi thiếu import, bạn sẽ nhìn thấy một popup hiện ra như thế này, chỉ cần nhấn chọn vào chữ Import Class là xong.

Sử dụng gợi ý import từ InteliJ
Sử dụng gợi ý import từ InteliJ

Import Thủ Công

Tất nhiên cách này dành cho bạn nào biết chính xác lớp này nằm trong package nào rồi nên bạn có thể gõ import một cách thủ công.


Lan man hơi dài rồi, quay trở lại với việc bạn vừa khai báo đối tượng của lớp Scanner trên kia. Dòng tiếp theo bạn chỉ cần gọi 

scanner.nextXxx();

 thì khi bạn chạy chương trình, khi hệ thống thực thi đến dòng này, nó sẽ dừng lại chờ, và lúc đó Console sẽ xuất hiện con nháy chờ người dùng nhập vào một giá trị có kiểu dữ liệu là Xxx rồi mới tiến hành gán giá trị này vào biến tương ứng và thực hiện tiếp các câu lệnh bên dưới.

Bạn hãy thử vài ví dụ sau cho từng 

scanner.nextXxx();

 cụ thể nhé.

Ví Dụ Nhập Dữ Liệu Kiểu Chuỗi

Với ví dụ này bạn thử cho người dùng nhập vào tên từ Console rồi in ra dòng xin chào ngay trên Console luôn như sau.

Để đợi người dùng nhập vào tên bạn gõ dòng sau vào sau khi khai báo scanner.

String name = scanner.nextLine();

Khi đó tên người dùng nhập vào từ Console sẽ được gán vào biến kiểu String (đây là kiểu chuỗi mà bạn sẽ được làm quen sau) có tên là name. Nhưng để dễ dàng hơn cho người dùng, chúng ta nên có các dòng System.out để in ra chỉ dẫn cho người dùng trước dòng đợi nhập tên. Code tổng thể của chúng ta như sau.

import java.util.Scanner;;
 
public class MyFirstClass {
 
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("Please enter your name here: ");
String name = scanner.nextLine();
System.out.println("Hello! " + name);
}
 
}

Khi này nếu bạn chạy chương trình, chỉ có dòng text “Please enter your name here: “ xuất hiện, đừng tắt cửa sổ Console nhé, hãy tiếp tục nhập vào một text, sau khi Enter bạn sẽ nhận được một text nữa in ra với nội dung “Hello!” và text bạn vừa nhập, text in ra đó chính là nội dung biến name được scanner lấy dữ liệu từ Console rồi gán vào đấy.

Kết quả thực thi chương trình khi nhập vào một String
Kết quả thực thi chương trình khi nhập vào một String

Ví Dụ Nhập Dữ Liệu Kiểu int

Tương tự nếu ví dụ này hỏi người dùng nhập tuổi, bạn cũng nên có câu in ra gợi ý, dòng scanner.nextInt(), và dòng in ra kết quả sau đó. Code tổng thể như sau.

Scanner scanner = new Scanner(System.in);
System.out.println("How old are you? ");
int age = scanner.nextInt();
System.out.println("Your age is: " + age);

Ví Dụ Nhập Dữ Liệu Kiểu Float

Scanner hỗ trợ nhập cho hầu như tất cả các kiểu dữ liệu nguyên thủy (trừ kiểu char, vì thực ra kiểu char cũng có thể lấy ra từ việc nhập một String với một ký tự, bạn hãy xem phần bình luận bên dưới để biết cách nhập vào kiểu char nếu có nhu cầu nhé). Ví dụ sau nhập vào kiểu float và bạn hoàn toàn có thể áp dụng cho các kiểu dữ liệu nguyên thủy được hỗ trợ còn lại.

Scanner scanner = new Scanner(System.in);
System.out.println("How about your salary? ");
float salary = scanner.nextFloat();
System.out.println("Your salary is: " + salary);

Xuất Trên Console

Chắc bạn cũng biết, để xuất dữ liệu ra Console thì cứ gọi 

System.out.println();

 thôi chứ gì. Dễ quá, các bài trước và bài hôm nay bạn đã làm quen rồi. Nhưng kiến thức về xuất dữ liệu ra Console còn vài thứ vui vẻ nữa, mời các bạn cùng xem tiếp.

Xuất Mặc Định

Mình dùng từ xuất mặc định là bởi vì chúng ta không can thiệp gì đến cách mà Console hiển thị một nội dung cả. Đó chính xác là cách gọi 

System.out.println();

 như chúng ta đã làm quen. Mình thêm một vài lưu ý sau thôi.

  • Bạn có thể dùng câu lệnh print() thay vì println(). Nếu như với println() thì các bạn đã biết nó giúp xuất dữ liệu ra rồi kèm theo xuống hàng sau khi xuất. Thì print() chỉ xuất dữ liệu thôi và không xuống hàng. Bạn có thể thử áp dụng print() cho các thông báo chỉ dẫn như “Please enter your name here: “ trên kia để xem sự khác biệt nhé.
  • Nếu bạn muốn hiển thị các ký tự đặc biệt sau: dấu nháy đơn (‘), dấu nháy kép (“), và dấu gạch chéo (\). Thì bạn cứ kèm theo một gạch chéo (\) nữa ở trước các ký tự này. Chẳng hạn bạn muốn hiển thị dòng Thư mục chứa file “ấy” là C:\Location\Ay, thì bạn gõ lệnh xất như sau System.out.print(“Thư mục chứa file \”ấy\” là C:\\Location\\Ay”);.
  • Ngoài ra bạn còn có thể chèn thêm các ký tự giúp định dạng dữ liệu xuất, như “\t” sẽ chèn thêm một tab, hay “\n” sẽ giúp xuống dòng. Bạn thử tự kiểm chứng bằng cách gõ xòng lệnh này nhé System.out.print(“\tHello\nWorld”);.

Tuy nhiên, với kiểu xuất mặc định này, có đôi lúc chúng ta tưởng như việc xuất nội dung là bình thường, nhưng nó lạ lắm. Bạn hãy xem kết quả in ra Console bên dưới đây, rồi chúng ta sẽ xem xuất “không mặc định” nó sẽ như thế nào sau nhé.

double x = 10000.0/3.0;
System.out.println("The result is " + x);

Kết quả sẽ in ra nội dung 

The result is 3333.3333333333335

. Thì cũng đúng thôi, kết quả phép chia cho ra dãy số vậy là đúng rồi. Thế nhưng nếu chúng ta muốn kiểm soát việc in ra Console sao cho dữ liệu xuất được đẹp hơn thì sao. Chẳng hạn bạn muốn in ra giá trị cho tiền tệ, khi mà sau dấu thập phân bạn muốn làm tròn đến 2 con số thôi. Khi đó bạn hãy đến với kiến thức về xuất theo định dạng như sau.

Xuất Theo Định Dạng

Nếu như với xuất mặc định, bạn đã biết là nên dùng một trong hai phương thức là print() hay println(). Thì xuất theo định dạng cung cấp cho bạn một phương thức hiệu quả hơn đó là printf() (chữ f cuối cùng của phương thức viết tắt của từ format, chính là định dạng).

Khi đó để kết quả của ví dụ trên in ra một con số làm tròn “đẹp đẽ” sao cho chỉ có 2 chữ số sau dấu thập phân thôi, bạn có thể viết lại như sau.

double x = 10000.0/3.0;
System.out.printf("The result is %.2f", x);

Kết quả in ra là: 

The result is 3333.33

. Đẹp hơn đúng không nào, mình giải thích sơ qua cho các bạn hiểu cách làm việc của printf() ở bên dưới (nếu bạn nào muốn tìm hiểu kỹ hơn tất cả cách sử dụng của printf() thì có thể đọc bài viết này của mình).

printf() ở mức cơ bản cần bạn phải truyền vào 2 thành phần, cách nhau bởi dấu (,).

  • Thành phần thứ nhất nói cho phương thức này biết nội dung xuất ra màn hình, trong nội dung đó có chỉ định một hoặc nhiều dấu hiệu định dạng. Trong ví dụ trên thì dấu hiệu này là %10.2fDấu hiệu định dạng này mang ý nghĩa rằng bạn muốn in một số thực bất kỳ, nhưng nó phải được hiển thị bởi ký tự sau dấu thập phân.
  • Thành phần thứ hai của phương thức chính là giá trị cần định dạng. Hệ thống sẽ tự động tìm kiếm nơi mà bạn đã khai báo dấu hiệu định dạng trong chuỗi xuất ở thành phần thứ nhất, cụ thể là %10.2f như bạn thấy, và thay thế vào đó giá trị cần định dạng, cụ thể là biến x của chúng ta.

Câu Lệnh Điều Kiện

Từ bài học hôm nay chúng ta đã có thể vận dụng tốt kiến thức về nhập/xuất trên console rồi nhé.

Câu lệnh điều kiện là một phần kiến thức của câu lệnh điều khiển luồng (control flow). Cũng bởi vì kiến thức về câu lệnh điều khiển luồng này hơi nhiều và quan trọng, nên mình tách chúng ra làm hai nhóm. Nhóm thứ nhất bao gồm các câu lệnh điều kiện (hay còn gọi là các câu lệnh rẽ nhánh) mà bạn sẽ làm quen hôm nay. Nhóm còn lại là câu lệnh lặp bạn sẽ được làm quen ở bài sau.

Đầu tiên chúng ta nói về khái niệm chung của hai nhóm, khái niệm câu lệnh điều khiển luồng là gì nhé.

Khái Niệm Câu Lệnh Điều Khiển Luồng

Để dễ hiểu khái niệm này nhất, thì bạn hãy nhớ lại việc code của mình ở các bài học trước xem nào (mặc dù chúng ta chưa code nhiều lắm). Các bạn có thể thấy khi bạn code, và các dòng code đó được IDE thực thi, chúng sẽ được trình biên dịch này đọc và thực hiện một cách tuyến tính từ trên xuống đúng không nào, từ dòng số 1 đến dòng cuối cùng.

Nhưng thực tế không phải lúc nào chúng ta cũng xây dựng một ứng dụng với logic đơn giản như vậy. Các project thực tế đều cần các giải thuật phức tạp hơn, chẳng hạn như cần truy xuất vào cơ sở dữ liệu và in ra console từng thông tin của sinh viên. Thì khi đó việc thực hiện tuyến tính từng dòng code sẽ vô cùng phức tạp, bạn phải viết hàng ngàn dòng code cho việc đọc tuần tự hàng ngàn sinh viên trong cơ sở dữ liệu. Chưa hết nếu với mỗi sinh viên được đọc lên có một số điều kiện nào đó, như chỉ in ra số sinh viên có giới tính nữ, thì việc code và thực thi tuyến tính thật sự là một cơn ác mộng.

Chính vì vậy mà các câu lệnh điều khiển luồng được các ngôn ngữ cho ra đời, nhằm tạo ra một luồng thực thi mới, đó có thể là một luồng lặp, hay luồng rẽ nhánh, sao cho chúng có thể hướng trình biên dịch thực thi một đoạn code nào đó nhiều lần, hoặc bỏ qua không thực thi đoạn code nào đó,… Như đã nói thì bài hôm nay bạn làm quen với nhóm đầu tiên trong câu lệnh điều khiển luồng, đó là nhóm các câu lệnh điều kiện giúp rẽ nhánh luồng.

Trước khi vào làm quen đến các câu lệnh, mình xin bắt đầu nói rõ về hai ký hiệu “thần thánh” mà từ bài đầu tiên bạn đã gặp, hai ký hiệu này giúp ích rất nhiều cho bài học hôm nay và cả việc code của các bạn sau này, đó là ký hiệu { và }. Cặp ngoặc nhọn này giúp tạo thành một khối lệnh (hay còn gọi là block).

Khái Niệm Khối Lệnh (Block)

Như bạn vừa biết thì khối lệnh trong Java được biểu thị bằng cặp dấu ngoặc nhọn ({ và }).

Ngược lại quá khứ quay về các bài trước, bạn sẽ thấy cặp ngoặc này đã xuất hiện trong khai báo class (bạn sẽ học đến class ở các bài viết về OOP sau). Trong trường hợp này cặp ngoặc đã tạo ra một khối lệnh đóng vai trò bao lấy code và cho biết tất cả các code bên trong đó đều là các code của class. Khi đó, chúng (các code trong cặp ngoặc đó) phải tuân theo các nguyên tắc của class (bạn sẽ biết các nguyên tắc này sau). Mọi dòng code nằm ngoài cặp ngoặc nhọn này sẽ không thuộc quyền quản lý của class đó. Cặp ngoặc nhọn mà mình nói đến xuất hiện như hình sau.

Minh họa cặp ngoặc nhọn bao lấy code của class
Minh họa cặp ngoặc nhọn bao lấy code của class

Hay cặp ngoặc nhọn xuất hiện ở khai báo phương thức (bạn cũng sẽ học đến phương thức ở bài sau), giúp tạo ra một khối lệnh đóng vai trò bao lấy code cho biết tất cả các code bên trong đó đều là code của phương thức đó. Cũng như trên, mọi dòng code nằm ngoài cặp ngoặc nhọn của phương thức này sẽ nằm ngoài xử lý logic của phương thức đó. Cặp ngoặc nhọn phương thức xuất hiện như sau.

Minh họa cặp ngoặc nhọn bao lấy code của phương thức
Minh họa cặp ngoặc nhọn bao lấy code của phương thức

Ngoài các cặp ngoặc nhọn của class và của phương thức ra thì bạn cũng có thể tạo bất cứ khối lệnh nào trong các dòng code của bạn, chỉ cần bao các câu lệnh đó vào một cặp ngoặc nhọn. Việc tạo ra các khối lệnh như thế này có thể giúp cho các dòng code được tổ chức rõ ràng hơn.

Minh họa cặp ngoặc nhọn bao lấy code của một khối lệnh
Minh họa cặp ngoặc nhọn bao lấy code của một khối lệnh

Và hiển nhiên khối lệnh còn được áp dụng cho các câu lệnh điều kiện mà chúng ta sẽ làm quen dưới đây nữa. Chính vì vậy mà chúng ta cần làm quen với khối lệnh trước khi đi vào bài học chính thức là vậy.

Nhưng dù cho có sử dụng khối lệnh với mục đích nào đi nữa, thì bạn cũng phải nhớ một điều, là nếu có khai báo dấu { để bắt đầu một khối lệnh, thì phải có dấu } ở đâu đó để đóng khối lệnh lại. Nếu một chương trình mà có tổng số lượng dấu { không bằng với tổng số lượng dấu } sẽ có lỗi xảy ra đấy nhé.

Phạm Vi Của Biến (Scope)

Chúng ta làm quen với một kiến thức nữa. Vì khi các bạn đã quen với khối lệnh, thì bạn cũng nên biết phạm vi của biến. Vì phạm vi của biến sẽ bị ảnh hưởng rất lớn dựa trên các khối lệnh này.

Chúng ta xác định phạm vi của biến như thế nào? Thực ra mình cũng có đọc nhiều tài liệu về vấn đề này, có nhiều cách để xác định phạm vi, nhưng cách xác định trực quan nhất có lẽ là phân biệt phạm vi của biến dựa trên ảnh hưởng local hay global của nó.

  • Phạm vi local, là phạm vi mà biến đó chỉ ảnh hưởng cục bộ trong một khối lệnh, không thể dùng đến biến đó ở bên ngoài khối lệnh.
  • Phạm vi global, là phạm vi mà biến đó được khai báo ở khối lệnh bên ngoài, khi đó nó có ảnh hưởng đến các khối lệnh bên trong, tức các khối lệnh bên trong có thể dùng được biến global này.

Trong ví dụ dưới đây, bạn có thể thấy là biến name được mình khai báo trong một khối lệnh, nên nó là biến local của khối lệnh đó, bạn không thể dùng lại biến này ở khối lệnh khác (bạn có thể thấy hệ thống báo lỗi như hình dưới). Bạn chỉ có thể dùng được biến tên name này nếu khai báo lại biến ở khối lệnh khác, nhưng lưu ý khi đó hai biến name ở hai khối lệnh khác nhau sẽ chẳng liên quan gì với nhau cả.

Ví dụ biến name là biến local bên trong một khối lệnh
Ví dụ biến name là biến local bên trong một khối lệnh

Cũng ví dụ này nhưng mình khai báo biến name ở bên ngoài khối lệnh, khi đó biến name này được xem như biến global của hai khối lệnh con, và vì vậy nó được gọi đến thoải mái mà không bị lỗi.

Ví dụ biến name là biến global của 2 khối lệnh con
Ví dụ biến name là biến global của 2 khối lệnh con

Được nước lấn tới, mình tiếp tục ví dụ với biến name được để bên ngoài phương thức main() luôn, khi này nó sẽ được xem là biến global của tất cả các phương thức có trong class này (không riêng gì phương thức main() đâu nhé, và bạn cũng đừng để ý đến khai báo static của biến, khai báo dạng này sẽ được nói đến ở bài này khi bạn học sang OOP).

Ví dụ biến name là biến global các phương thức bên trong class
Ví dụ biến name là biến global các phương thức bên trong class

Hai ví dụ sau cùng trên đây đều cho ra console cùng một kết quả. Bạn cứ code và thực thi thử chương trình để kiểm chứng nhé.

Câu Lệnh if

Đến đây thì chúng ta đã xong kiến thức râu ria rồi, giờ hãy bắt đầu đi vào câu lệnh điều kiện đầu tiên, câu lệnh if. Chỉ với cái tên if thôi nhưng thực chất có tới bốn biến thể của câu lệnh dạng này mà bạn phải nắm, đó là: ifif elseif else if, và ?:. Chúng ta bắt đầu làm quen với từng loại như sau.

if

Cú pháp cho câu lệnh if như sau.

if (biểu_thức_điều_kiện) {
     các_câu_lệnh;
}

Trong đó:

  • biểu_thức_điều_kiện là một biểu thức mà sẽ trả về kết quả là một giá trị boolean.
  • các_câu_lệnh sẽ được thực thi chỉ khi mà biểu_thức_điều_kiện trả về giá trị true mà thôi. Bạn thấy rằng khối lệnh đã được áp dụng để bao lấy các_câu_lệnh.

Ví dụ cho câu lệnh if.

Scanner scanner = new Scanner(System.in);
System.out.println("Please enter your age: ");
int age = scanner.nextInt();
 
if (age < 18) {
System.out.printf("You can not access");
}

Mình giải thích một chút ví dụ trên, các bạn thấy biểu_thức_điều_kiện lúc này là 

age < 18
. Nghĩa là nếu biến age mà user nhập từ bàn phím nhỏ hơn 18, thì biểu thức này sẽ trả về true, khi đó trong khối lệnh của câu lệnh if này (dòng in ra console câu thông báo chưa đủ tuổi) sẽ được thực thi. Còn nếu user nhập vào một age lớn hơn 18, sẽ không có chuyện gì xảy ra, ứng dụng kết thúc. Ví dụ này đã bắt đầu dùng đến kiến thức về nhập/xuất trên console rồi đấy nhé.

Một lưu ý nhỏ thôi, là với trường hợp trong khối lệnh của if nếu chỉ có một dòng code như ví dụ trên, nhiều khi người ta bỏ luôn cả dấu { và }, khi đó câu lệnh if trên sẽ như sau.

if (age < 18)
System.out.printf("You can not access");

Hay thậm chí viết như sau.

if (age < 18) System.out.printf("You can not access");

if else

Cú pháp cho câu lệnh if else như sau.

if (biểu_thức_điều_kiện) {
     các_câu_lệnh_1;
} else {
     các_câu_lệnh_2;
}

Trong đó:

  • biểu_thức_điều_kiện cũng sẽ trả về kết quả là một giá trị boolean.
  • các_câu_lệnh_1 được thực thi trong trường hợp biểu_thức_điều_kiện là true.
  • các_câu_lệnh_2 sẽ được thực thi trong trường hợp biểu_thức_điều_kiện là false.

Ví dụ cho câu lệnh if else.

if (age < 18) {
System.out.printf("You can not access");
} else {
System.out.printf("Welcome to our system!");
}

Bạn thấy ví dụ này làm rõ hơn trường hợp user nhập một age lớn hơn 18, khi đó biểu_thức_điều_kiện sẽ trả về kết quả false, và vì vậy các_câu_lệnh_2 sẽ được thực thi, trong ví dụ này là dòng in ra console “Welcome to our system!”.

Cũng bởi khối lệnh của if và else chỉ có một dòng nên bạn có quyền viết thế này.

if (age < 18)
System.out.printf("You can not access");
else
System.out.printf("Welcome to our system!");

Hay thế này, nhưng lưu ý code dài quá sẽ khó đọc lắm đấy, mình không khuyến khích viết như vậy.

if (age < 18) System.out.printf("You can not access"); else System.out.printf("Welcome to our system!");

if else if

Cú pháp cho câu lệnh if else if như sau.

if (biểu_thức_điều_kiện_1) {
     các_câu_lệnh_1;
} else if (biểu_thức_điều_kiện_2) {
     các_câu_lệnh_2;
} else if (...) {
     ...
} else if (biểu_thức_điều_kiện_n) {
     các_câu_lệnh_n;
} else {
     các_câu_lệnh_n+1;
}

Đây là dạng mở rộng hơn của câu lệnh if else. Khi đó:

  • các_câu_lệnh_1 được thực thi trong trường hợp biểu_thức_điều_kiện_1 trả về true.
  • các_câu_lệnh_2 được thực thi trong trường hợp biểu_thức_điều_kiện_1 trả về false và biểu_thức_điều_kiện_2 trả về true.
  • các_câu_lệnh_n được thực thi trong trường hợp các biểu_thức_điều_kiện trước nó đều trả về false và biểu_thức_điều_kiện_n trả về true.
  • Nếu không có bất kỳ biểu_thức_điều_kiện nào trả về true cả thì các_câu_lệnh_n+1 sẽ được thực thi.

Ví dụ cho câu lệnh if else if.

Scanner scanner = new Scanner(System.in);
System.out.println("Please enter a number of week (1 is Monday): ");
int day = scanner.nextInt();
 
if (day == 1) {
System.out.printf("Monday");
} else if (day == 2) {
System.out.printf("Tuesday");
} else if (day == 3) {
System.out.printf("Wednesday");
} else if (day == 4) {
System.out.printf("Thursday");
} else if (day == 5) {
System.out.printf("Friday");
} else if (day == 6) {
System.out.printf("Saturday");
} else if (day == 7) {
System.out.printf("Sunday");
} else {
System.out.printf("Invalid number!");
}

?:

Câu lệnh này thực chất không mới, nó như là câu lệnh if else nhưng được biểu diễn ngắn gọn hơn. 

Mình thấy nhiều tài liệu gom kiến thức về việc sử dụng ?: này vào bài viết về các Toán tử. Khi đó nó được gọi là toán tử tam nguyên (ternary operator). Vì công dụng của nó được dùng cho mục đích tính toán nhanh giá trị hơn là một câu lệnh giúp điều khiển luồng. Nhưng với mình nó không khác gì if else cả, mình xem nó là một cách viết ngắn gọn hơn của if else nên gộp chung vào mục này. Còn sở dĩ gọi là toán tử tam nguyên là bởi hai ký tự mà bạn nhìn thấy (? và :) giúp tách các thành phần của câu lệnh ra làm ba phần (hay ba toán hạng), các bạn xem cú pháp của nó như sau.

[kết_quả =] biểu_thức_điều_kiện ? câu_lệnh_nếu_true : câu_lệnh_nếu_false;

Trong đó:

  • kết_quả có thể có hoặc không, biến kết_quả này sẽ lưu lại giá trị là kết quả của câu lệnh, nó phải là kiểu dữ liệu của câu_lệnh_nếu_true và câu_lệnh_nếu_false, lý do tại sao thì mời bạn đọc tiếp đoạn sau.
  • biểu_thức_điều_kiện tương tự như ở các câu lệnh if phía trên.
  • câu_lệnh_nếu_true sẽ thực thi khi biểu_thức_điều_kiện trả về true, vế này sẽ trả về một kết quả về cho kết_quả, có thể là kiểu String, hoặc boolean, hoặc int,…
  • Ngược lại câu_lệnh_nếu_false sẽ thực thi khi biểu_thức_điều_kiện trả về false, và vế này cũng sẽ trả kết quả về cho kết_quả.

Ví dụ cho câu lệnh ?: (ví dụ này được viết lại từ ví dụ if else ở trên).

Scanner scanner = new Scanner(System.in);
System.out.println("Please enter your age: ");
int age = scanner.nextInt();
 
String access = (age < 18) ? "You can not access" : "Welcome to our system!";
 
System.out.printf(access);

Bạn cũng thấy rằng câu lệnh này chỉ thích hợp thay thế cho if else mà thôi, và thực sự nó giúp chúng ta rút ngắn số dòng code lại, nhưng lại làm cho thuật toán khó đọc hơn đúng không nào. Tùy bạn cân nhắc sử dụng if else hay ?: nhé.

Một chút lưu ý là với code trên, bạn không cần dùng biến kết_quả access mà in trực tiếp ra console từ câu lệnh này luôn cũng được, mình điều chỉnh một tí như sau.

Scanner scanner = new Scanner(System.in);
System.out.println("Please enter your age: ");
int age = scanner.nextInt();
 
System.out.printf((age < 18) ? "You can not access" : "Welcome to our system!");

Câu Lệnh switch case

Câu lệnh này có thể dùng để thay thế if else if nói trên nếu như các biểu_thức_điều_kiện đều dùng một đối tượng giống nhau để so sánh (ví dụ ở if else if trên đây chúng ta dùng biến day để so sánh đi so sánh lại với các giá trị khác nhau). Khi này bạn nên dùng switch case để giúp cho if else if trông tường minh hơn.

Cú pháp cho câu lệnh switch case như sau:

switch (đối_tượng_so_sánh) {
     case giá_trị_1:
          các_câu_lệnh_1;
          break;
     case giá_trị_2:
          các_câu_lệnh_2;
          break;
     case ...:
          ...;
          break;
     case giá_trị_n:
          các_câu_lệnh_n;
          break;
     default:
          các_câu_lệnh_n+1;
          break;
}

Thay vì so sánh đối tượng ở từng biểu_thức_điều_kiện như ở if else if, bạn chỉ cần truyền nó vào đối_tượng_so_sánh, rồi chỉ định từng giá_trị_x của nó để thực thi kết quả của nó ở các_câu_lệnh_x tương ứng.

Bạn nhớ ở mỗi case đều có kết thúc là câu lệnh đặc biệt break (câu lệnh break này sẽ được nói ở bài sau). Hiện tại, bạn chỉ nên biết là câu lệnh break này giúp bạn dừng việc thực thi ở một khối lệnh của các_câu_lệnh_x nào đó, nếu không có break, hệ thống sẽ đi tiếp qua case tiếp theo để xử lý và như vậy sẽ cho kết quả sai.

Thành phần cuối cùng trong câu lệnh này là từ khóa default, thành phần này giống như else cuối cùng của một if else if, nó biểu thị rằng nếu như các so sánh case không thỏa các giá_trị của đối_tượng_so_sánh, thì các_câu_lệnh_n+1 trong default sẽ được gọi.

Ví dụ cho câu lệnh switch case (ví dụ này được viết lại từ ví dụ if else if ở trên).

 
Scanner scanner = new Scanner(System.in);
System.out.println("Please enter a number of week (1 is Monday): ");
int day = scanner.nextInt();
 
switch (day) {
case 1:
System.out.printf("Monday");
break;
case 2:
System.out.printf("Tuesday");
break;
case 3:
System.out.printf("Wednesday");
break;
case 4:
System.out.printf("Thursday");
break;
case 5:
System.out.printf("Friday");
break;
case 6:
System.out.printf("Saturday");
break;
case 7:
System.out.printf("Sunday");
break;
default:
System.out.printf("Invalid number!");
break;
}
 

Bạn có thấy giống if else if không nào.

Câu Lệnh Lặp

Khái Niệm Lặp

Lặp (tiếng Anh gọi là loop) trong lập trình là một hành động lặp đi lặp lại một khối lệnh nào đó khi mà một điều kiện nào đó còn thỏa (thỏa – hay còn hiểu là kết quả của biểu thức đó là true). Nếu như với các câu lệnh điều kiện giúp bạn rẽ nhánh các dòng code, thì các câu lệnh lặp bài này lại giúp bạn lặp lại các dòng code nào đó.

Mình ví dụ có một yêu cầu bắt bạn in ra màn hình 1000 con số từ 1 đến 1000, chẳng lẽ bạn lại gọi 1000 lần câu lệnh 

System.out.println()

?

Ví dụ thực tế hơn, nếu như có yêu cầu muốn bạn in ra tên tất cả sinh viên của trường bạn (giả sử bạn đã biết câu lệnh đọc một thông tin sinh viên lên từ cơ sở dữ liệu), chẳng lẽ bạn lại viết code đọc dữ liệu của từng sinh viên và in ra màn hình?

Các ví dụ trên cho chúng ta thấy khái niệm thực tế rõ ràng và sự cần thiết khi sử dụng đến các câu lệnh lặp trong bài hôm nay.

Các Câu Lệnh Lặp

Chúng ta có 3 loại câu lệnh lặp cần làm rõ trong bài hôm nay, đó là: whiledo while và for.

while

Cú pháp cho câu lệnh while như sau.

while (điều_kiện_lặp) {
     các_câu_lệnh;
}

Cú pháp của câu lệnh while khá đơn giản, ban đầu chương trình sẽ kiểm tra điều_kiện_lặp, nếu điều kiện này trả về kết quả true, thì các_câu_lệnh trong khối lệnh của while sẽ được thực thi, rồi sau đó chương trình sẽ lại kiểm tra điều_kiện_lặp. Vòng lặp while chỉ được kết thúc khi điều_kiện_lặp trả về kết quả false.

Ví dụ.

int i = 0;
while (i < 10) {
System.out.println("Hello!");
i++;
}

Bạn thấy ví dụ trên đây phải khởi tạo biến i (người ta gọi đây là biến đếm, vì đây là biến dùng để điều khiển số lần lặp của vòng while). Bắt đầu vào while, bạn thấy điều_kiện_lặp được đưa ra là nếu biến i còn nhỏ hơn 10 thì các_câu_lệnh bên trong được thực hiện, ở ví dụ này chỉ là hàm in ra màn hình câu chào “Hello!”. Bạn chú ý một điều, trong thân hàm while bạn luôn phải thay đổi giá trị của biến đếm, trong ví dụ này 

i++;

 giúp tăng biến đếm lên 1 đơn vị, để sao cho đến một lúc nào đó điều_kiện_lặp phải bị phá vỡ, trường hợp này là i bằng 10, thì hàm while mới kết thúc.

Nếu lấy can đảm bỏ dòng i++; ở ví dụ trên đi rồi chạy lại, bạn sẽ thấy dòng in ra màn hình được gọi mãi mãi, người ta gọi trường hợp này là lặp vô tận.

Bài Thực Hành Số 1

Bạn thử áp dụng vòng lặp while để thực hiện yêu cầu sau: hãy in ra console tổng các số chẵn từ dãy số nguyên có độ lớn từ 1 đến 10.

Gợi ý: bạn có thể xác định một số là chẵn bằng cách thực hiện phép chia dư số đó với 2, nếu kết quả phép chia dư là 0 thì đó là số chẵn.

Bạn hãy thử code, rồi so sánh với kết quả sau nhé.

int i = 1;
int sumEven = 0;
while (i <= 10) {
if (i % 2 == 0)
sumEven += i;
i++;
}
 
System.out.println("Sum: " + sumEven);

Bài Thực Hành Số 2

Hãy dùng vòng lặp while để tìm ra các số nguyên tố trong dãy số nguyên từ 1 đến 100 và in chúng ra console.

Gợi ý: số nguyên tố là các số chỉ chia hết cho 1 và chính nó. Ví dụ như số 23571113,…

Bạn hãy thử code trước rồi so sánh với kết quả sau nhé.

int number = 1; // Các số tăng dần từ 1 đến 100 để kiểm tra
while (number <= 100) {
int count = 0; // Đếm số lượng số mà number chia hết, luôn phải khởi tạo là 0
int j = 1; // Biến chạy từ 1 đến number để kiểm tra
while (j <= number) {
if (number % j == 0) {
// Tìm thấy một số mà number chia hết, tăng biến đếm lên 1 để đếm
count++;
}
j++; // Nhớ dòng này
}
if (count == 2) {
// Nếu count là 2, tức là số đó chỉ chia hết cho 2 số là 1 và chính nó
System.out.println(number);
}
number++; // Nhớ tăng number để kiểm tra số tiếp theo
}

do while

Cú pháp cho câu lệnh do while như sau.

do {
     các_câu_lệnh;
} while (điều_kiện_lặp);

Bạn có để ý thấy là cú pháp do while khác với while chỗ nào không? Đó là nếu với while, hệ thống phải kiểm tra điều_kiện_lặp trước, nếu thỏa thì mới thực hiện các_câu_lệnh. Còn với do while, hệ thống sẽ thực hiện các_câu_lệnh trước rồi mới kiểm tra điều_kiện_lặp xem có cần thực hiện việc lặp lại hay không.

Như vậy với do while thì các_câu_lệnh được thực hiện ít nhất 1 lần. Còn với while thì các_câu_lệnh có thể sẽ không được thực hiện bao giờ nếu điều_kiện_lặp không thỏa.

do while sẽ ít khi được dùng hơn là while. Bạn không cần phải xem ví dụ cho do while, hãy bắt tay vào thử vài bài tập như sau.

Bài Thực Hành Số 3

Bạn hãy in ra console thông báo kêu người dùng nhập vào một con số từ console, rồi sau đó cho biết các thứ trong tuần tương ứng. Với 1 thì in ra “Monday”,… 7 in ra “Sunday”. Chương trình sẽ hỏi người dùng nhập số hoài cho đến khi họ nhập vào một số không phải giá trị từ 1 đến 7.

Code tham khảo như sau.

Scanner scanner = new Scanner(System.in);
int getNumber = 0;
do {
System.out.print("Enter a number: ");
getNumber = scanner.nextInt();
switch (getNumber) {
case 1:
System.out.println("Monday");
break;
case 2:
System.out.println("Tuesday");
break;
case 3:
System.out.println("Wednesday");
break;
case 4:
System.out.println("Thursday");
break;
case 5:
System.out.println("Friday");
break;
case 6:
System.out.println("Saturday");
break;
case 7:
System.out.println("Sunday");
break;
default:
System.out.println("Invalid number!");
break;
}
} while (getNumber >= 1 && getNumber <= 7);

for

Cú pháp cho câu lệnh for như sau.

for (khởi_tạo_biến_đếm; điều_kiện_lặp; điều_chỉnh_biến_đếm) {
     các_câu_lệnh;
}

Câu lệnh for sẽ lặp các_câu_lệnh bên trong nó dựa trên việc kiểm tra 3 thành phần truyền vào, các thành phần này được phân cách bởi các dấu ;. Trong đó.

– khởi_tạo_biến_đếm cũng giống như bạn khai báo biến và khởi tạo (gán cho nó một giá trị) mà bạn đã học ở bài 4biến_đếm này sẽ là căn cứ để chương trình lặp lại dòng code của bạn bao nhiêu lần.
– điều_kiện_lặp là điều kiện đặt ra cho vòng lặp kiểm tra xem có nên lặp tiếp một vòng các_câu_lệnh nữa hay không.
– điều_chỉnh_biến_đếm giúp thay đổi giá trị của biến_đếm mỗi khi các_câu_lệnh được thực hiện xong một lần lặp. Tại sao? Vì cũng giống như các câu lệnh lặp trên đây, nếu không có việc điều chỉnh lại biến đếm, thì các biến đếm sẽ không bao giờ bị thay đổi giá trị, và điều_kiện_lặp sẽ luôn luôn trả về cùng một giá trị, và vì vậy vòng lặp có thể sẽ bị lặp vô tận, hoặc sẽ không thực hiện bất kỳ lần lặp nào.

Bạn sẽ hiểu thêm câu lệnh for qua ví dụ sau đây.

for (int i = 0; i < 10; i++) {
System.out.println("Hello!");
}

Ví dụ trên rất đơn giản, khởi_tạo_biến_đếm là 

int i = 0

 , giúp khai báo và khởi tạo cho biến i giá trị ban đầu là 0điều_kiện_lặp 

i < 10

 sẽ lặp lại khối lệnh của vòng for khi mà biến i còn nhỏ hơn 10điều_chỉnh_biến_đếm 

i++

 sẽ tăng i lên 1 đơn vị sau khi thực hiện các_câu_lệnhcác_câu_lệnh trong vòng for này chỉ là hàm in ra console dòng chữ “Hello!”. Vậy bạn thử nghĩ xem có bao nhiêu dòng “Hello!” được in ra màn hình ở ví dụ trên?

Bài Thực Hành Số 4

Bạn hãy viết lại Bài Thực Hành Số 1 trên kia bằng vòng lặp for nhé.

int sumEven = 0;
for(int i = 1; i <= 10; i++) {
if (i % 2 == 0)
sumEven += i;
}
 
System.out.println("Sum: " + sumEven);

Bạn có thể thấy là với for chúng ta có thể khai báo biến đếm i vào trong vòng lặp và khởi tạo cho nó trong đó, khác với while phải khởi tạo bên ngoài. Và việc tăng biến i được để ở thành phần thứ 3 của vòng for chứ không nằm trong thân hàm như với while nữa.

Bài Thực Hành Số 5

Bạn hãy viết lại Bài Thực Hành Số 2 trên kia bằng vòng lặp for nhé.

for (int number = 1; number <= 100; number++) {
int count = 0; // Đếm số lượng số mà number chia hết, luôn phải khởi tạo là 0
for(int j = 1; j <= number; j++) {
if (number % j == 0) {
// Tìm thấy một số mà number chia hết, tăng biến đếm lên 1 để đếm
count++;
}
}
if (count == 2) {
// Nếu count là 2, tức là số đó chỉ chia hết cho 2 số là 1 và chính nó
System.out.println(number);
}
}

for Và Một Vài Mở Rộng

Đa số các bạn sẽ gặp khó khăn khi bước đầu làm quen với for, mình cũng vậy, từ lúc bắt đầu biết đến for cho đến một thời gian về sau mình mới dùng tốt for. Trước đó mình thường dùng while thay cho for vì while dễ tiếp cận và dễ nhớ hơn. Nhưng như bạn thấy while lại chiếm nhiều dòng code hơn, cái nào cũng có giá của nó 🙂

Mục mở rộng này dành cho bạn nào đã am hiểu về for rồi và muốn xem thêm for có sức mạnh nào khác không nhé.

Khai Báo Biến Đếm Bên Ngoài for

Với các cách sử dụng trên đây của for, bạn được giới thiệu là khai báo và khởi tạo biến đếm ở thành phần thứ nhất bên trong vòng for. Tuy nhiên cách này bạn sẽ không dùng được biến đếm này ở bên ngoài for.

Lấy lại ví dụ ở Bài Thực Hành Số 5 trên đây, giả sử bạn muốn kiểm tra lại thực sự sau khi kết thúc for, biến number có đúng đếm đến số 100 không, bạn viết như sau. Bạn sẽ thấy chương trình bị báo lỗi ngay ở dòng được tô sáng.

for (int number = 1; number <= 100; number++) {
// ... Code của bài thực hành số 5 ở đây
}
System.out.println("Check the final number: " + number);

Để chương trình có thể chạy được, bạn hoàn toàn có thể mang khai báo của biến đến number ra ngoài vòng for. Việc khởi tạo biến đếm vẫn để nguyên trong for như sau.

int number;
for (number = 1; number <= 100; number++) {
// ... Code của bài thực hành số 5 ở đây
}
System.out.println("Check the final number: " + number);

Ứng dụng chạy tốt, nhưng dòng in ra cuối cùng cho thấy number sau khi ra khỏi vòng for mang giá trị 101, không phải 100!!!

Check the final number: 101

.

Bạn có thể tự giải thích được không?

for Bị Khuyết Các Thành Phần

Mặc dù for được thiết kế để truyền vào 3 thành phần, nhưng bạn vẫn có thể khai báo các for bị khuyết một thành phần. Chẳng hạn code dưới đây khuyết điều_chỉnh_biến_đếm, khi đó bạn có quyền điều khiển biến đếm bên trong thân hàm for.

for (int i = 1; i <= 10; ) {
System.out.println("Hello!");
i++;
}

Hay có thể khuyết khởi_tạo_biến_đếm.

int i = 1;
for (; i <= 10; i++) {
System.out.println("Hello!");
}

Bạn có thể dùng vòng for bị khuyết cả 2 thành phần nào đó, và bạn có quyền điều khiển sự khiếm khuyết này thông qua các điều khiển bên trong và ngoài for. Như ví dụ sau.

int i = 1;
for (; i <= 10; ) {
System.out.println("Hello!");
i++;
}

Dã man hơn, bạn có thể khuyết luôn 3 thành phần, người ta hay dùng kiểu vòng for này để xây dựng nhanh một chức năng lặp vô tận, dùng cho một số tình huống không xác định rõ khi nào cần dừng vòng lặp, lặp sẽ chạy đến điều kiện nào đó cần dừng thì có các hàm “nhảy” ra khỏi vòng lặp mà chúng ta sẽ cùng làm quen ở bài kế tiếp. Bạn hãy xem ví dụ về for vô tận này ở code bên dưới. Và nhớ, “đừng thử vòng for vô tận này ở nhà” nha.

for (; ; ) {
System.out.println("Please don't try at home");
}

Sức Mạnh Của Việc Điều Chỉnh Biến Đếm

Bạn đã thấy việc tăng biến đếm ở thành phần thứ 3 của for lên một đơn vị. Và tất nhiên bạn vẫn có thể tăng nó lên mấy đơn vị cũng được. Ví dụ sau viết lại Bài Thực Hành Số 4 trên đây nhưng với ít dòng code hơn khi tận dụng việc tăng biến đếm lên 2 đơn vị và bỏ qua việc kiểm tra số chẵn.

int sumEven = 0;
for(int i = 2; i <= 10; i += 2) {
sumEven += i;
}
System.out.println("Sum: " + sumEven);

Hơn nữa, nếu bạn có thể chỉ định việc tăng biến đếm, thì bạn hoàn toàn có thể giảm biến đếm để tạo thành một vòng for đếm ngược như sau.

for(int i = 10; i >= 0; i--) {
System.out.println("Counting down..." + i);
}

Hết Sức Cẩn Thận Với Biến Đếm Là Số Thực

Bạn đã biến biến đếm sử dụng dễ dàng và hiệu quả như thế nào nếu nó là kiểu số nguyên như tất cả các ví dụ trên đây. Nhưng nếu bạn có ý định sử dụng số thực cho biến đếm? Mình khuyên bạn là đừng bao giờ suy nghĩ đến tình huống này.

Nếu bạn nhất quyết muốn thử, hãy xem code sau.

for(double x = 0; x <= 1; x += 0.1) {
System.out.println("See x: " + x);
}

Bạn mong muốn vòng lặp sẽ in ra mỗi giá trị tăng dần 0.1 đơn vị của số thực x, nhưng bạn thấy việc tính toán nhị phân với số thực sẽ không hoàn toàn chính xác tuyệt đối, do đó sẽ không có các giá trị đẹp đẽ 00.10.20.3,… được in ra đâu nhé. Nó sẽ như thế này.

See x: 0.0
See x: 0.1
See x: 0.2
See x: 0.30000000000000004
See x: 0.4
See x: 0.5
See x: 0.6
See x: 0.7
See x: 0.7999999999999999
See x: 0.8999999999999999
See x: 0.9999999999999999

Tai hại hơn nếu bạn thử thực thi code như dưới đây, ứng dụng sẽ không bao giờ kết thúc do không thể có một con số 1 tròn trĩnh để so sánh với x để kết thúc vòng for.

for(double x = 0; x != 1; x += 0.1) {
System.out.println("See x: " + x);
}

Vậy nên, tốt nhất là không nên dùng số thực cho biến đếm trong vòng for sẽ giúp ứng dụng của bạn an toàn hơn.

Các Câu Lệnh Nhảy (break/continue & label) Trong Vòng Lặp

Mình xin giải thích một chút ý nghĩa của bài hôm nay. Bài học hôm nay đáng lý ra phải nằm ở bài 9 – Bài nói về các câu lệnh lặp – Vì kiến thức của bài hôm nay có liên quan các câu lệnh lặp đó. Tuy nhiên mình nghĩ nếu nói về kiến thức này ở bài trước sẽ làm cho bài học bị dài ra, vừa khó để các bạn nhớ, vừa khó để thực hành, lại khó cho mình khi phải viết một bài quá dài. Chính vì vậy mình tách ra thành một bài học hôm nay.

Chúng ta sẽ nói đến 2 câu lệnh nhảy trong bài hôm nay, đó là break và continue. Trong khi có một câu lệnh nhảy khác là return sẽ được trao đổi ở bài viết về Phương Thức sau nhé.

Sở dĩ Java dùng từ “nhảy” không phải vì nó làm cho các vòng lặp của bạn thêm nhảy nhót. Nhảy ở đây là nhảy ra khỏi vòng lặp, hay nhảy đến một lần lặp tiếp theo, bỏ qua các câu lệnh còn lại bên trong thân vòng lặp đó. Một số tài liệu khác gọi đây là các câu lệnh điều khiển, nhưng mình thích từ nhảy hơn, tiếng Việt nghe hơi chuối nhưng tiếng Anh họ gọi là jump statements, nghe rõ nghĩa hơn.

Chúng ta cùng xem qua nhé.

break – Câu Lệnh Dừng

Bạn hiểu nghĩa break là đập vỡ cũng đúng, nhưng trong tình huống này nó có nghĩa là dừng thì hay hơn.

Theo đúng tên gọi, khi câu lệnh này xuất hiện ở đâu đó trong vòng lặp, chúng sẽ làm phá vỡ, hay dừng vòng lặp đó dù cho vẫn còn các câu lệnh khác bên trong vòng lặp chưa được xử lý.

Nếu bạn còn nhớ, ở bài 8 chúng ta cùng nói qua câu lệnh break này cho cấu trúc switch case đúng không nào. Vâng, break ở switch case và break ở câu lệnh lặp cũng có công dụng tương tự như nhau thôi.

Bài Thực Hành Số 1

Bài thực hành này muốn bạn in ra console tất cả các số nguyên tố từ 1 đến 10.000.

Khoan! Có gì đó sai sai! In số nguyên tố đã được thực hành ở bài trước rồi mà! Và in số nguyên tố thì có liên quan gì đến break!?!

Bạn yên tâm, in số nguyên tố ở bài trước chỉ là một giải thuật “gà”. Trong lập trình, đặc biệt là lập trình các thuật toán, sẽ luôn luôn hoan nghênh các bạn có những giải thuật cải tiến sao cho ứng dụng chạy nhanh, mượt. Muốn được như vậy thì các thuật toán của các bạn phải gọn, bắt máy tính làm việc ít hơn, số lượng vòng lặp giảm thiểu,… Có lẽ chúng ta sẽ nói đến chủ đề này ở bài khác.

Quay lại bài thực hành, chúng ta sẽ cải tiến giải thuật tìm số nguyên tố, kết hợp với câu lệnh break để dừng việc kiểm tra sớm một khi đã biết số đó không phải là số nguyên tố.

Bạn đã biết số nguyên tố là số chỉ chia hết cho 1 và chính nó. Để làm vậy, với cách cũ bạn duyệt qua các số hạng từ 1 đến chính nó, đếm xem có bao nhiêu số hạng mà nó chia hết cho, nếu đếm thấy 2 số hạng thì nó đúng là số nguyên tố. Với cách này bạn luôn phải cho vòng lặp chạy hết tất cả các số hạng. Cụ thể, với việc kiểm tra số 10.000, thì ứng dụng của bạn sẽ phải lặp 10.000 lần để đếm. Cách này mình đo thấy ứng dụng tốn 376 ms (mili giây) để chạy, tức là gần nửa giây. Bạn có thể áp dụng break vào việc giảm số lần lặp bằng 2 cách sau.

  • Đừng cho vòng lặp chạy từ 1 đến chính nó, mà hãy cho vòng lặp chạy từ số 2 đến chính nó giảm đi 1 đơn vị, tức là chạy trong khoảng [2, chính nó). Hễ tìm được một số nào đó trong khoảng này mà chính nó chia hết, thì gọi lệnh break ngay để kết thúc lặp, không cần lặp nữa vì chắc chắn nó không phải số nguyên tố (ngoài số 1 với chính nó ra thì nó còn chia hết cho ít nhất một số khác rồi). Với giải thuật này thì giả sử để kiểm tra số 10.000, ứng dụng của bạn chỉ cần chạy đến số hạng 2 là đã break rồi. Cách này tốn 167 ms để in ra hết các số nguyên tố, chỉ tốn một nửa thời gian so với cách thứ nhất. Ôi cool quá!
  • Cách này hay hơn nữa, theo nghiên cứu (chưa rõ từ nguồn nào) thì bạn chỉ cần kiểm tra trong khoảng 2 đến căn bậc hai của chính nó, tức là cho vòng lặp chạy trong khoảng [2, căn bậc 2 chính nó]. Nếu tìm thấy một số trong khoảng này mà nó chia hết cho, thì nó không phải là số nguyên tố. Trong Java, hàm lấy căn bậc hai một số là 
    Math.sqrt(số_thực);
    . Giải thuật thứ ba này chỉ mất có 15 ms để chạy thôi nhé, nhanh gấp 25 lần so với cách thứ nhất.

Mình áp dụng cách chạy nhanh nhất trên đây vào code bên dưới, bạn áp dụng cách nào? Hãy thử code nhé.

for (int number = 2; number <= 10000; number++) {
boolean isPrime = true; // Thay vì biến count, dùng biến này để kiểm tra số nguyên tố
for(int j = 2; j <= Math.sqrt(number); j++) {
if (number % j == 0) {
// Chỉ cần một giá trị được tìm thấy trong khoảng này,
// thì number không phải số nguyên tố
isPrime = false;
break; // Thoát ngay và luôn khỏi for (vòng for bên ngoài vẫn chạy)
}
}
if (isPrime) {
// Nếu isPrime còn giữ được sự "trong trắng" đến cùng thì đó là số nguyên tố
System.out.println(number);
}
}

continue – Câu Lệnh Bỏ Qua

Lại một lần nữa nghĩa và công dụng của từ này bị lẫn lộn. Bạn giỏi tiếng Anh nên hiểu nghĩa của continue là tiếp tục cũng không sai, nhưng tình huống này chúng ta hiểu là bỏ qua.

Khác với dừng trên kia – Dừng là dừng luôn không quay lại nó nữa – Còn bỏ qua có nghĩa là bỏ các câu lệnh còn lại bên trong vòng lặp để thực hiện một chu trình lặp mới. Câu lệnh này không làm dừng vòng lặp như break, mà chỉ thực hiện vòng lặp mới, nhưng vòng lặp cũng có thể bị dừng bởi continue khi mà việc thực hiện vòng lặp mới sẽ kiểm tra và thấy điều_kiện_lặp không còn thỏa.

Có một lưu ý nhỏ khi bạn dùng continue cho for và while/do while như sau.

  • Với while/do while: bởi vì continue sẽ tự thực hiện một vòng lặp mới nên có khả năng biến đếm sẽ bị bỏ qua nếu bạn để câu lệnh tăng biến đếm ở sau continue, như vậy sẽ có trường hợp bạn bị rơi vào vòng lặp vô tận, điều này dễ gặp ở while và do while lắm đấy nhé.
  • Với for: khi sử dụng với for thì bạn yên tâm, câu lệnh continue ở vòng lặp này sẽ bao gồm việc tăng biến đếm (nếu bạn có định nghĩa việc tăng biến đếm này ở thành phần thứ ba của for) rồi mới thực hiện vòng lặp mới. Chính vì vậy nên việc dùng continue trong for sẽ an toàn hơn.

Bài Thực Hành Số 2

Bài này bạn sẽ phải in ra console ngược lại với bài thực hành ở trên, kết hợp với continue. Bạn hãy viết chương trình in ra console tất cả các số KHÔNG PHẢI LÀ SỐ NGUYÊN TỐ trong khoảng từ 1 đến 100.

Chúng ta sẽ áp dụng giải thuật tìm số nguyên tố, hễ biết đó là số nguyên tố thì sẽ dùng continue “lướt” qua lần lặp mới, các câu lệnh in ra console sẽ để bên dưới câu lệnh continue để đảm bảo in ra những gì không thỏa điều kiện của continue. Code chúng ta như sau.

for (int number = 1; number <= 100; number++) {
if (number == 1) {
// 1 không phải số nguyên tố, in ra rồi biến
System.out.println(number);
continue;
}
 
boolean isPrime = true; // Biến kiểm tra số nguyên tố
for(int j = 2; j <= Math.sqrt(number); j++) {
if (number % j == 0) {
// Chỉ cần một giá trị được tìm thấy trong khoảng này,
// thì number không phải số nguyên tố
isPrime = false;
break; // Thoát ngay và luôn khỏi for (vòng for bên ngoài vẫn chạy)
}
}
 
if (isPrime) {
// Nếu isPrime cho biết đây là số nguyên tố
// bỏ qua câu lệnh dưới đây mà qua lần lặp kế
continue;
}
 
System.out.println(number);
}

Label – Gán Nhãn Cho Vòng Lặp

Mục này mình mới thêm vào sau này, ở giai đoạn đầu tiên của bài viết mình không nói đến việc gán nhãn cho vòng lặp. Tại sao mình lại bỏ qua nó? Thứ nhất bạn nên hiểu rằng Java gần như đã bỏ khái niệm goto của C++. Với kiến thức về goto thì bạn có thêm một tùy chọn cho việc điều khiển luồng trong ứng dụng, nó cho phép bạn rẽ nhánh xử lý đến bất kỳ nơi nào bạn chỉ định. Điều này gây vô số khó khăn trong việc kiểm soát luồng chạy của ứng dụng, rất khó để tìm kiếm và gỡ lỗi nếu có. Nên với Java, ít có nơi nào nói kỹ nên gần như chúng ta quên mất khái niệm goto này. Thứ hai, nếu không biết gì về kiến thức goto chúng ta vẫn xây dựng hoàn chỉnh các ứng dụng mong muốn.

Tuy nhiên với một vài tình huống nhất định, nhất là khi sử dụng các vòng lặp lồng vào nhau, có khi bạn mong muốn được dừng hay bỏ qua vòng lặp con để về một vòng lặp bên ngoài bất kỳ, khi đó goto phát huy tác dụng. Java hỗ trợ cho chúng ta kỹ thuật này cho tình huống này, nó được gọi là label, gán nhãn cho vòng lặp, giúp bạn gán một nhãn bất kỳ cho vòng lặp, sau đó break hay continue sẽ được chỉ định để dừng hoặc bỏ qua vòng lặp hiện tại để về đúng nhãn mà bạn đã gán.

Bạn hãy nhìn kỹ cách sử dụng nhãn ở bài thực hành dưới đây. Mình dùng nhãn chung với lệnh continue, nhưng bạn hoàn toàn có thể hiểu và áp dụng tương tự với break. Trong trường hợp bạn còn mông lung về việc gán nhãn và sử dụng nhãn, thì mình khuyên bạn không nên có gắng dùng nó mà làm chi nhé.

Bài Thực Hành Số 3

Bài này chúng ta lại tiếp tục cải tiến cho việc in ra console tất cả các số nguyên tố từ 1 đến 10.000 mà Bài Thực Hành Số 1 trên kia dường như làm khá tốt rồi.

Ý tưởng của Bài Thực Hành Số 1 là cứ mỗi số cần kiểm tra (ở vòng lặp ngoài cùng), cờ isPrime sẽ được khai báo lại là true rồi vào vòng lặp bên trong sẽ xác định và set cờ isPrime là false hay không. Ra khỏi vòng lặp con mà isPrime vẫn còn là true thì sẽ in số đó ra vì nó chính là số nguyên tố.

Bài thực hành này chúng ta không cần dùng đến cờ isPrime luôn, mà ở vòng lặp trong, nếu biết đó là số nguyên tố, lệnh continue kết hợp với nhãn sẽ dẫn luồng xử lý về lại vòng lặp for được gán nhãn. Mời bạn xem code và gác diễn giải ngay trong code.

Nếu bạn muốn biết việc so sánh thời gian xử lý, thì Bài Thực Hành Số 1 cũng đã là một giải thuật quá hay rồi, nên bài thực hành này cũng chỉ giúp giảm thời gian thêm một chút xíu thôi, cụ thể mình đo và thấy thời gian xử lý của bài này là 10 ms.

// Đặt nhãn cho vòng for ngoài cùng là loop_root
// Quy luật đặt tên nhãn giống như đặt tên biến vậy
loop_root: for (int number = 2; number <= 10000; number++) {
for (int j = 2; j <= Math.sqrt(number); j++) {
if (number % j == 0) {
// Chỉ cần một giá trị được tìm thấy trong khoảng này,
// thì number không phải số nguyên tố
continue loop_root; // Bỏ qua các câu lệnh xử lý khác mà về lại với nơi được gán nhãn loop_root và thực hiện tiếp vòng lặp
}
}
// Cuối cùng nếu đến được đây thì in number ra vì nó là số nguyên tố
System.out.println(number);
}

Mảng (Array)

Đầu tiên mình nói một chút về Mảng trước khi đi vào chi tiết. Có một số tài liệu hoặc chương trình học Java nói rất muộn về Mảng, thường thì họ dành để nói về lập trình hướng đối tượng (OOP) trước. Theo mình thì điều này mới nghĩ sẽ thấy hợp lý, vì trong Java, Mảng không phải là kiểu dữ liệu nguyên thủy mà là một cấu trúc dữ liệu mới dựa trên các nền tảng của hướng đối tượng. Vì vậy, biết về hướng đối tượng rồi mới tới Mảng thì xem ra không sai tí nào.

Tuy nhiên để hiểu và sử dụng tốt Mảng trong Java, mình không nghĩ bạn phải cần một kiến thức sâu rộng về OOP. Vả lại Mảng rất hiệu quả, mà nếu sử dụng Mảng trễ quá về sau, mình e rằng bạn sẽ đánh mất một số cơ hội tốt để tiếp cận một số kiến thức thú vị của Java ở giai đoạn này. Thêm nữa, Mảng cũng có thể xem như nền tảng để tạo thành khái niệm Chuỗi (String) mà chúng ta cùng nói đến ở bài sau nữa, và String cũng là một khái niệm của OOP mà chúng ta cũng sẽ phải làm quen sớm vì tính hiệu quả của nó.

Khái Niệm Mảng

Mảng, hay còn gọi là Array, là một cấu trúc dữ liệu, dùng để chứa một tập hợp các phần tử có kiểu dữ liệu tương tự nhau. Giả sử chúng ta có mảng các số nguyênMảng này sẽ chứa tập hợp các phần tử có cùng kiểu dữ liệu là int. Ngoài tập hợp số nguyên như ví dụ vừa nêu, hay tập hợp các kiểu dữ liệu nguyên thủy mà bạn cũng dần được làm quen, Mảng còn chứa đựng tập hợp các dữ liệu “không nguyên thủy” mà chúng ta sẽ nói đến ở các bài sau nữa.

Khi bạn khai báo một Mảng, bạn phải chỉ định độ lớn cho nó, tức là chỉ định số phần tử tối đa mà Mảng đó có thể chứa. Mỗi một phần tử trong Mảng sẽ được quản lý theo chỉ số (index), chỉ số sẽ bắt đầu bằng số 0 (bạn sẽ được làm quen với cách truy cập các phần tử Mảng dựa vào chỉ số ở các ví dụ phía dưới).

Giả sử mình đã tạo ra một Mảng có 10 phần tử kiểu int, khi lưu trữ mảng này vào bộ nhớ, chúng sẽ được sắp xếp gần nhau theo mô hình giả lập sau, mỗi ô là một phần tử kiểu int với các số trong đó là giá trị được lưu trữ vào.


Minh họa một mảng với 10 phần tử

Bạn đã rõ hơn về Mảng, vậy tại sao phải dùng cấu trúc này?

Tại Sao Phải Dùng Mảng?

Ý nghĩa của phần này muốn nói đến lợi ích của việc dùng Mảng.

Đúng như tên gọi và định nghĩa Mảng trên đây, cấu trúc dữ liệu Mảng sẽ giúp bạn lưu trữ danh sách các phần tử. Mình ví dụ như bạn sẽ cần lưu danh sách các sinh viên. Danh sách này sẽ được tổ chức sao cho bạn có thể truy xuất ngẫu nhiên nhanh chóng đến từng phần tử con. Chẳng hạn bạn muốn lấy thông tin sinh viên ở vị trí thứ 10. Pùm! Có ngay!. Danh sách này có thể được sắp xếp lại trật tự theo một tiêu chí nào đó, khi đó bạn có thể nhanh chóng đọc ra top 10 sinh viên có điểm số cao nhất. Hay một lợi thế nữa của Mảng đó là với việc tổ chức các phần tử có kiểu dữ liệu tương tự nhau như vậy sẽ làm cho code của chúng ta tường minh và dễ quản lý hơn, giúp tối ưu code.

Tuy nhiên Mảng cũng có một bất lợi là bạn phải khai báo độ lớn, tức khai báo sẵn số lượng phần tử mà Mảng này sẽ sử dụng. Điều này làm code của bạn kém linh động vì có khi chúng ta muốn số lượng phần tử của Mảng đã khai báo được nới rộng hơn. Hoặc làm hiệu năng của hệ thống bị giảm do bạn khai báo sẵn một mảng với độ lớn quá cao mà không sử dụng hết số lượng vùng nhớ đã khai báo. Tất nhiên để khắc phục nhược điểm này của Mảng thì Java cũng có khái niệm Array List mà chúng ta sẽ làm quen ở một bài học khác.

Cách Sử Dụng Mảng

Cũng giống như khi bạn làm quen đến khái niệm biến, bạn phải biết cách khai báo, gán dữ liệu, sử dụng biến đó trong biểu thức. Với Mảng cũng vậy, nhưng bởi vì Mảng hơi đặc biệt là nó chứa đựng các phần tử con, cho nên ngoài những gì bạn có thể vận dùng từ việc sử dụng biến, sẽ có một vài cái mới ở đây mà bạn phải làm quen. Chúng ta cùng đi qua các bước trong việc sử dụng Mảng như sau nhé.

Khai Báo Mảng

Bạn có thể chọn một trong hai cách khai báo sau.

kiểu_dữ_liệu[] tên_mảng;

hoặc

kiểu_dữ_liệu tên_mảng[];

Bạn nhớ là phải có cặp [ ] ở phần kiểu_dữ_liệu hoặc tên_mảng. Sở dĩ Java định nghĩa cả 2 cách khai báo như trên là vì cách thứ nhất là đúng chuẩn Java, trong khi cách thứ 2 thì Java hỗ trợ cách khai báo Mảng từ C/C++.

Cặp [ ] cũng thể hiện rằng đây là Mảng, bạn có để ý thấy là nếu không có cặp ngoặc đó thì khai báo trên đây không khác gì việc khai báo một biến cả đúng không nào.

Uhm… Nhưng mà khác với biến ở chỗ Mảng ở bước khai báo này vẫn chưa sử dụng được, như đã nói, chúng ta cần chỉ định độ lớn của mảng, vấn đề này sẽ được nói ở mục sau.

Bây giờ là một ví dụ. Để khai báo một mảng các số nguyên, chúng ta code như sau.

int[] myArray;

Cấp Phát Bộ Nhớ Cho Mảng

Bạn phải dùng từ khóa new để cấp phát bộ nhớ cho Mảng. Lần trước bạn dùng đến new là ở Bài 7, khi đó bạn khởi tạo một biến scanner, bạn có nhớ không nào.

Dùng từ khóa new để khởi tạo một biến đối tượnghttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/10/Screenshot-2022-10-31-at-16.03-1.png?resize=300%2C86&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/10/Screenshot-2022-10-31-at-16.03-1.png?resize=768%2C221&ssl=1 768w" data-lazy-loaded="1" sizes="(max-width: 910px) 100vw, 910px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Dùng từ khóa new để khởi tạo một biến đối tượng

Với Mảng, việc cấp phát bộ nhớ cũng sẽ tương tự, như sau.

tên_mảng = new kiểu_dữ_liệu[kích_cỡ_mảng];

Ở bước này rất cần thiết phải có kích_cỡ_mảng, nếu không truyền vào tham số này, việc cấp phát sẽ phát sinh lỗi. Kích cỡ này là do bạn quyết định, thông thường trong một ứng dụng, nếu biết chính xác thông số kích cỡ này và kích cỡ này sẽ không bị thay đổi trong suốt quá trình sống của ứng dụng, thì bạn hãy sử dụng mảng, nếu không biết chính xác hoặc kích cỡ luôn luôn thay đổi thì chúng ta nên dùng đến Array List như mình có nhắc đến.

Tiếp nối ví dụ khai báo ở trên kia, chúng ta thêm phần cấp phát như sau.

int[] myArray = new int[10];

Khi ứng dụng chạy đến bước này, nó sẽ tạo ra một mảng với 10 phần tử số nguyên như minh họa sau.

Minh họa một mảng với 10 phần tử chưa khai báo giá trịhttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-2-1.png?resize=300%2C107&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-2-1.png?resize=1024%2C366&ssl=1 1024w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-2-1.png?resize=768%2C275&ssl=1 768w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-2-1.png?resize=1536%2C549&ssl=1 1536w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-2-1.png?resize=1140%2C408&ssl=1 1140w" data-lazy-loaded="1" sizes="(max-width: 1000px) 100vw, 1000px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Minh họa một mảng với 10 phần tử chưa khai báo giá trị

Sở dĩ bạn thấy hình minh họa tạo ra Mảng với các phần tử có giá trị 0, nó đúng với thực tế bạn code. Nếu bạn khai báo một Mảng rồi cấp phát độ lớn cho nó, với Mảng kiểu số bạn sẽ có một Mảng mặc định là các phần tử mang giá trị 0. Với Mảng kiểu boolean sẽ là các phần tử false. Và với Mảng kiểu đối tượng thì các phần tữ mặc định sẽ là null (bạn sẽ hiểu giá trị này khi làm quen với kiểu đối tượng sau).

Khởi Tạo Mảng

Như bạn thấy, bước cấp phát trên đây đã thực hiện một thao tác giữ chỗ trong hệ thống một vùng nhớ đủ rộng để chứa 10 phần tử số nguyên. Mảng của bạn đã chứa các phần thử mang giá trị mặc định và đã sẵn sàng sử dụng rồi. Nhưng để cho Mảng thực sự mang đúng trọng trách của nó, việc kế tiếp là khởi tạo, hay còn hiểu là chứa dữ liệu thực sự vào cho các phần tử.

Có nhiều cách khởi tạo giá trị cho các phần tử Mảng.

Cách Thứ Nhất

Khởi tạo ngay khi vừa cấp phát bộ nhớ cho Mảng, khi đó bạn không cần dùng từ khóa new nữa, chỉ khai báo và khởi tạo trên một dòng như ví dụ dưới. Dùng cách này khi bạn đã biết trước mình cần khởi tạo một Mảng như thế nào ngay từ ban đầu. Với ví dụ mảng kiểu int như trên kia mình sẽ khởi tạo Mảng theo cách thứ nhất như sau.

int[] myArray = {3, 5, 7, 30, 10, 5, 8, 23, 0, -5};

Cách Thứ Hai

Khởi tạo từng giá trị cho từng phần tử Mảng bằng cách truy xuất đến chúng dựa vào chỉ số và gán cho chúng giá trị. Cách này được sử dụng khi bạn không biết ban đầu nên khởi tạo Mảng ra sao, nhưng sau đó, với logic của ứng dụng, bạn sẽ điền tuần tự từng phần tử theo chỉ số của nó.

int[] myArray = new int[10];
myArray[0] = 3;
myArray[1] = 5;
myArray[2] = 7;
myArray[3] = 30;
myArray[4] = 10;
myArray[5] = 5;
myArray[6] = 8;
myArray[7] = 23;
myArray[8] = 0;
myArray[9] = -5;

Cả 2 cách trên đây đều cho ra Mảng trong hệ thống diễn đạt như sơ đồ sau.

Minh họa một mảng với 10 phần tử được gán giá trịhttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1.png?resize=300%2C107&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1.png?resize=1024%2C366&ssl=1 1024w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1.png?resize=768%2C275&ssl=1 768w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1.png?resize=1536%2C549&ssl=1 1536w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1.png?resize=1140%2C408&ssl=1 1140w" data-lazy-loaded="1" sizes="(max-width: 1000px) 100vw, 1000px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Minh họa một mảng với 10 phần tử được gán giá trị

Cách Khác

Ngoài ra thì đôi khi bạn còn dùng cả vòng lặp để khởi tạo giá trị, dùng trong trường hợp các giá trị mảng giống nhau, hay theo một trật tự nào đó, mình ví dụ với khởi tạo như sau.

int arraySize = 10;
int[] myArray = new int[arraySize];
for (int i = 0; i < arraySize; i++) {
myArray[i] = i;
}

Khi này mảng sẽ chứa danh sách các giá trị giống như vầy.

Minh họa một mảng với 10 phần tử được gán giá trị bằng vòng forhttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-3-1.png?resize=300%2C107&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-3-1.png?resize=1024%2C366&ssl=1 1024w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-3-1.png?resize=768%2C275&ssl=1 768w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-3-1.png?resize=1536%2C549&ssl=1 1536w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-3-1.png?resize=1140%2C408&ssl=1 1140w" data-lazy-loaded="1" sizes="(max-width: 1000px) 100vw, 1000px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Minh họa một mảng với 10 phần tử được gán giá trị bằng vòng for

Truy Cập Mảng

Như bạn đã làm quen với những cách trên đây, chúng ta sẽ truy cập vào mảng dựa vào chỉ số. Bạn luôn phải nhớ rằng chỉ số đầu tiên (chỉ số thứ nhất) của phần tử mảng bắt đầu từ 0, và chỉ số của phần tử thứ n sẽ là n-1.

Ví dụ như bạn muốn in ra console giá trị phần tử cuối cùng trong mảng 10 phần tử trên đây.

int[] myArray = {3, 5, 7, 30, 10, 5, 8, 23, 0, -5};
System.out.println("Phan tu thu 10: " + myArray[9]);

Bạn có thể dùng 

myArray.length

 để biết được độ lớn của Mảng một cách tự động, thay vì nhớ số lượng này rồi dùng một con số cứng như ví dụ trên. Khi đó ví dụ trên có thể viết lại như sau.

int[] myArray = {3, 5, 7, 30, 10, 5, 8, 23, 0, -5};
System.out.println("Phan tu thu 10: " + myArray[myArray.length - 1]);

Nên nhớ rằng 

myArray.length
 sẽ là 10, còn vị trí của phần tử cuối cùng trong mảng là
myArray.length - 1
 nhé.

Hay nếu bạn muốn in ra tất cả các giá trị phần tử trong mảng, cách tốt nhất là hãy dùng vòng for.

int[] myArray = {3, 5, 7, 30, 10, 5, 8, 23, 0, -5};
for (int i = 0; i < myArray.length; i++) {
System.out.println("Phan tu thu " + (i + 1) + ": " + myArray[i]);
}

Một Số Thực Hành Với Mảng

Bài Thực Hành Số 1

Bạn hãy tạo Mảng CÁC SỐ NGUYÊN và khởi tạo giá trị cho mảng như sau {3, 5, 7, 30, 10, 5, 8, 23, 0, -5}. Hãy in ra console TỔNG và TRUNG BÌNH CỘNG của các giá trị phần tử trong Mảng.

Và đây là code của bài thực hành.

int[] myArray = { 3, 5, 7, 30, 10, 5, 8, 23, 0, -5 };
int sum = 0;
double avg;
int count = myArray.length;
for (int i = 0; i < count; i++) {
sum += myArray[i];
}
avg = (double) sum / count;
System.out.println("Sum is " + sum);
System.out.println("Avegare is " + avg);

Bài Thực Hành Số 2

Với Mảng các số nguyên như bài thực hành trên. Bạn hãy in ra VỊ TRÍ (thứ tự) của các phần tử nhỏ hơn hay bằng 0.

Và code của chương trình.

int[] myArray = { 3, 5, 7, 30, 10, 5, 8, 23, 0, -5 };
int count = myArray.length;
boolean isFound = false;
for(int i = 0; i < count; i++) {
if (myArray[i] <= 0) {
System.out.println("The position below or equal zero is: " + i);
isFound = true;
}
}
 
if (!isFound) {
System.out.println("Can not found the position you need");
}

Bài Thực Hành Số 3

Cũng với Mảng các số nguyên như bài thực hành trên. Giờ bạn hãy sắp xếp lại các phần thử mảng theo thứ tự TĂNG DẦN, sao cho khi in ra console nội dung sẽ như vầy “-5  0  3  5  5  7  8  10  23  30”.

Và code như sau.

int[] myArray = {3, 5, 7, 30, 10, 5, 8, 23, 0, -5};
 
for (int i = 0; i < myArray.length - 1; i++) {
for (int j = i; j <= myArray.length - 1; j++) {
if (myArray[i] > myArray[j]) {
// Thao tác này đổi chỗ 2 giá trị ở 2 vị trí i, j của mảng
int temp;
temp = myArray[i];
myArray[i] = myArray[j];
myArray[j] = temp;
}
}
}
 
for (int i = 0; i < myArray.length; i++) {
System.out.print(myArray[i] + " ");
}

Tiếp Tục Nói Về Mảng

Còn nhớ bài trước chúng ta cùng nói về khái niệm và cách thức sử dụng Mảng trong Java, khi đó mình cũng có nói đến cách thức hiệu quả nhất để duyệt qua các phần tử trong Mảng là dùng vòng lặp for. Hôm nay chúng ta tìm hiểu một “biến thể” khác của for dành riêng cho Mảng, giúp bạn có thể duyệt qua Mảng nhanh hơn, đó là foreach. Và còn một phần nâng cao của Mảng nữa cũng sẽ được nói đến ở bài hôm nay, đó là Mảng nhiều chiều.

foreach

Làm Quen Với foreach

Như lời mở đầu mình có nói, foreach là một biến thể của for, vậy tại sao mình không nói về foreach ở bài học về for? Câu trả lời là, đúng là foreach cũng là for – Vì nó là sự kết hợp giữa for và each. Cú pháp của foreach cũng kế thừa từ for, và cách thức hoạt động của foreach dĩ nhiên cũng sẽ giống for rồi. Nhưng foreach lại sinh ra để làm việc chung với Mảngforeach giúp thi triển các dòng code để duyệt trên mảng được dễ dàng hơn. Chính vì vậy mà foreach luôn được nói kèm với khái niệm về Mảng.

Vậy foreach có quan trọng không? Câu trả lời là, không quan trọng, nên sẽ không bắt buộc bạn phải dùng foreach đâu nhé. Như nói ở câu trả lời trên thì foreach chỉ giúp cho việc thi triển code trên vòng lặp được dễ dàng hơn thôi, bạn hoàn toàn có thể dùng for truyền thống để duyệt qua Mảng mà không cần đến foreach.

Cách Sử Dụng foreach

Trước hết chúng ta nói về cú pháp của foreach.

for (khai_báo_phần_tử_lặp : mảng) {
     các_câu_lệnh;
}

Bạn có thấy giống với for không nào, uhm mình đồng ý, chỉ giống mỗi chữ for. Mặc dù được gọi với cái tên foreach nhưng khi khai báo chúng ta vẫn chỉ cần đến một chữ for.

Nhớ lại một tí, với for bạn cần đưa vào 3 thành phần để định nghĩa các quy luật lặp cho nó, đồng thời giúp bạn kiểm soát chỉ số (index) mà vòng lặp đang thực hiện đến. Còn với foreach, vòng lặp này sẽ tự động biết nó phải đi qua lần lượt các phần tử trong mảng rồi, bạn cũng không cần khai báo hay quan tâm đến chỉ số mà vòng lặp đang thực hiện, nên việc tham số cho vòng lặp này lại trở nên đơn giản hơn for nhiều. Mời bạn làm quen từng thành phần trong cú pháp foreach này.

  • khai_báo_các_phần_tử_lặp là nơi bạn sẽ khai báo một biến mới để dùng trong thân hàm foreach này. Biến này sẽ chứa đựng giá trị của phần tử mà vòng lặp này đang lặp đến, do đó nó phải có kiểu dữ liệu giống như kiểu dữ liệu của từng phần tử Mảng.
  • mảng chính là Mảng bạn cần lặp. Đây có thể là một hàm trả về giá trị Mảng. Hàm, hay còn gọi là Phương thức, sẽ được nói đến ở bài học sau nhé.
  • Dấu hai chấm (:) được hiểu như là “trong”, vậy nghĩa cho tất cả các tham số trong foreach này là “từng phần tử trong mảng”.
  • các_câu_lệnh chính là nơi bạn dùng đến biến ở khai_báo_các_phần_tử_lặp ra dùng.

Bài Thực Hành Số 1

Chúng ta cùng làm lại Bài thực hành số 1 ở bài Mảng hôm trước và thực hiện lại với foreach ở bài hôm nay. Mình copy lại nội dung bài thực hành hôm trước như sau.

Bạn hãy tạo MẢNG CÁC SỐ NGUYÊN và khởi tạo giá trị cho mảng như sau {3, 5, 7, 30, 10, 5, 8, 23, 0, -5}. Hãy in ra console TỔNG và TRUNG BÌNH CỘNG của các giá trị phần tử trong mảng.

Và đây là code của bài thực hành.

int[] myArray = { 3, 5, 7, 30, 10, 5, 8, 23, 0, -5 };
int sum = 0;
double avg;
for (int i : myArray) {
sum += i;
}
avg = (double) sum / myArray.length;
System.out.println("Sum is " + sum);
System.out.println("Avegare is " + avg);

Trường Hợp Nào Bạn Không Nên Dùng foreach?

Mặc dù foreach khá hay, mình cũng thích dùng nó, nhưng nên nhớ không phải lúc nào bạn cũng dùng foreach được đâu nhé, và sau đây là các trường hợp ngoại lệ đó.

  • Không được dùng foreach để remove một phần tử nào đó khỏi Danh sách (mình dùng từ Danh sách – Array List – Chứ không phải Mảng – Array, vì Mảng không cho phép bạn thêm hay bớt một phần tử trong nó, chỉ có Danh sách mới cho phép. Và vì foreach cũng sẽ làm việc trên Danh sách tương tự như Mảng nên mình nhắc đến ý này ở đây, về sau khi nói đến Danh sách mình sẽ nhắc lại cho bạn nhớ).
  • Vì foreach tự duyệt tuần tự trên các phần tử Mảng, nên nó rất dở trong việc xác định chỉ số (index) của từng phần tử. Do đó đừng bắt foreach hoạt động khi bạn muốn biết chỉ số của phần tử hiện tại đang xử lý, hay muốn truy xuất nhanh đến vị trí của bất kỳ phần tử nào đó trong Mảng.

Mảng Hai Chiều

Đến bước này chắc chắn bạn đã hiểu về khái niệm Mảng. Khi người ta nói tới Mảng, thì có nghĩa họ đang nói đến “Mảng một chiều”. Như bạn biết thì Mảng được xem như một danh sách (cố định) các phần tử trải dài theo một chiều duy nhất. Như minh họa bài trước thì Mảng (một chiều) nó như thế này.

Hình minh họa mảng một chiều mà chúng ta đã biếthttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1-1.png?resize=300%2C107&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1-1.png?resize=1024%2C366&ssl=1 1024w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1-1.png?resize=768%2C275&ssl=1 768w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1-1.png?resize=1536%2C549&ssl=1 1536w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Array-Sample-1-1-1.png?resize=1140%2C408&ssl=1 1140w" data-lazy-loaded="1" sizes="(max-width: 1000px) 100vw, 1000px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Hình minh họa mảng một chiều mà chúng ta đã biết

Bạn nghĩ sao nếu có một biểu diễn Mảng theo một chiều mới, khi này Mảng của bạn được gọi là Mảng hai chiều, mà người ta còn gọi là Ma trận. Biểu diễn của Mảng hai chiều sẽ trông như sau.

Hình minh họa mảng hai chiềuhttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-1-1.png?resize=300%2C268&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-1-1.png?resize=1024%2C915&ssl=1 1024w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-1-1.png?resize=768%2C686&ssl=1 768w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-1-1.png?resize=957%2C855&ssl=1 957w" data-lazy-loaded="1" sizes="(max-width: 1000px) 100vw, 1000px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Hình minh họa mảng hai chiều

Khai Báo Mảng Hai Chiều

Tương tự như khai báo Mảng, khai báo một Mảng hai chiều tương tự như vậy nhưng sẽ cần đến hai cặp ngoặc vuông [ ][ ].

kiểu_dữ_liệu[][] tên_mảng;

hoặc

kiểu_dữ_liệu tên_mảng[][];

Chúng ta thử khai báo Mảng hai chiều cho minh họa trên đây.

int[][] myMatrix;

Cấp Phát Bộ Nhớ Cho Mảng Hai Chiều

Cũng như Mảng. Cú pháp cấp phát cho Mảng hai chiều như sau.

tên_mảng = new kiểu_dữ_liệu[số_lượng_dòng][số_lượng_cột];

Vậy với ví dụ khai báo cho Mảng hai chiều ở trên, chúng ta tiến hình cấp phát như sau.

int[][] myMatrix = new int[4][5];

Khởi Tạo Mảng Hai Chiều

Lại cũng giống như MảngMảng hai chiều cũng có nhiều hình thức khởi tạo. Giả sử mình muốn tạo ra một Mảng hai chiều như hình minh họa trên kia.

Cách Thứ Nhất

Dùng khi bạn biết trước dữ liệu cần khởi tạo cho Mảng hai chiều, như là dữ liệu mình đưa ra trên đây, bạn khởi tạo như sau.

int[][] myMatrix = {
{3, 5, 7, 30, 10},
{5, 8, 23, 0, -5},
{100, -9, 4, 2, 55},
{-80, -22, 11, 1, 12}};

Cách Thứ Hai

Dùng cách này khi không biết dữ liệu ban đầu như thế nào, về sau tùy logic chương trình mà Mảng hai chiều sẽ được điền dữ liệu từ từ, khi đó bạn phải làm việc với chỉ số của nó. Một lưu ý là với Mảng hai chiều, chỉ số của nó được thể hiện như sau.

Minh họa các chỉ số (index) của mảng hai chiềuhttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-2-1.png?resize=300%2C261&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-2-1.png?resize=1024%2C891&ssl=1 1024w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-2-1.png?resize=768%2C668&ssl=1 768w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-2-1.png?resize=983%2C855&ssl=1 983w" data-lazy-loaded="1" sizes="(max-width: 1000px) 100vw, 1000px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Minh họa các chỉ số (index) của mảng hai chiều

Và để làm việc với chỉ số, thì bạn chú ý cách sử dụng chỉ số như khai báo sau nhé.

int[][] myMatrix = new int[4][5];
 
myMatrix[0][0] = 3;
myMatrix[0][1] = 5;
myMatrix[0][2] = 7;
myMatrix[0][3] = 30;
myMatrix[0][4] = 10;
 
myMatrix[1][0] = 5;
myMatrix[1][1] = 8;
myMatrix[1][2] = 23;
myMatrix[1][3] = 0;
myMatrix[1][4] = -5;
 
myMatrix[2][0] = 100;
myMatrix[2][1] = -9;
myMatrix[2][2] = 4;
myMatrix[2][3] = 2;
myMatrix[2][4] = 55;
 
myMatrix[3][0] = -80;
myMatrix[3][1] = -22;
myMatrix[3][2] = 11;
myMatrix[3][3] = 1;
myMatrix[3][4] = 12;

Cách Khác

Cũng giống như MảngMảng hai chiều cũng có thể được khởi tạo bằng vòng lặp for, tất nhiên là bằng cách lồng hai for vào nhau rồi. Dùng cách này cho việc khởi tạo các giá trị cho Mảng hai chiều theo một trật tự nào đó. Bạn hãy xem ví dụ sau.

int row = 4;
int column = 5;
int[][] myMatrix = new int[row][column];
 
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
myMatrix[i][j] = i + j;
}
}

Bạn có biết code trên đây sẽ tạo ra một Ma trận như thế nào không. Ma trận bạn tạo ra chính là hình minh họa bên dưới.

Mảng hai chiều sau khi được tạo giá trị từ vòng lặp forhttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-3-1.png?resize=300%2C268&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-3-1.png?resize=1024%2C915&ssl=1 1024w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-3-1.png?resize=768%2C686&ssl=1 768w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Matrix-Sample-3-1.png?resize=957%2C855&ssl=1 957w" data-lazy-loaded="1" sizes="(max-width: 1000px) 100vw, 1000px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Mảng hai chiều sau khi được tạo giá trị từ vòng lặp for

Chắc chắn đến đây bạn đã hiểu rõ Mảng hai chiều hay Ma trận rồi, tuy nhiên nếu có thắc mắc thì hãy để lại bình luận bên dưới bài học này cho mình nhé. Bây giờ chúng ta qua phần thực hành cho Ma trận.

Bài Thực Hành Số 2

Bài này chúng ta sẽ thử thao tác với Ma trận. Nhưng thay vì khai báo và khởi tạo sẵn một Ma trận, bạn hãy thử nhập chúng từ console xem sao, sẽ rất thú vị đấy, nội dung của bài thực hành này như sau.

Tạo một Ma trận các số nguyên bằng cách.

  • In ra console dòng “Please enter number of row:” và đợi người dùng nhập vào số lượng hàng của Ma trận.
  • In ra console dòng “Please enter number of columns:” và đợi người dùng nhập vào số lượng cột của Ma Trận.
  • In ra console từng dòng yêu cầu người dùng nhập từng phần tử của Ma trận.

Với Ma trận do người dùng nhập vào ở trên, thực hiện thao tác sau.

  • In ra Ma trận mà người dùng vừa mới nhập.
  • In ra dòng và cột có tổng lớn nhất trong Ma trận.

Nếu bạn đã hiểu yêu cầu bài thực hành thì tiến hành code nhé, code xong rồi thì mời bạn so sánh với kết quả của mình bên dưới. Hoan nghênh các bạn chia sẻ những suy nghĩ hoặc những giải thuật của bạn về bài thực hành này ở phần bên dưới bài học.

Đại loại của chương trình khi chạy, và khi tương tác với user sẽ như sau.

Minh họa kết quả của chương trìnhhttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Screenshot-2022-12-09-at-10.38-1.png?resize=300%2C292&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Screenshot-2022-12-09-at-10.38-1.png?resize=1024%2C996&ssl=1 1024w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Screenshot-2022-12-09-at-10.38-1.png?resize=768%2C747&ssl=1 768w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Screenshot-2022-12-09-at-10.38-1.png?resize=879%2C855&ssl=1 879w" data-lazy-loaded="1" sizes="(max-width: 1000px) 100vw, 1000px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Minh họa kết quả của chương trình
Scanner scanner = new Scanner(System.in);
 
// Nhập số lượng hàng
System.out.print("Please enter number of row: ");
int row = scanner.nextInt();
 
// Nhập số lượng cột
System.out.print("Please enter number of column: ");
int column = scanner.nextInt();
 
int[][] myMatrix = new int[row][column];
 
// Kêu user nhập vào từng phần tử của Ma Trận
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
System.out.print("Matrix[" + i + "][" + j + "] = ");
myMatrix[i][j] = scanner.nextInt();
}
}
 
// In ra ma trận user mới nhập
System.out.println("Your Matix here");
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
System.out.print(myMatrix[i][j] + " ");
}
System.out.println(); // Đơn giản là xuống dòng
}
 
// Tìm dòng lớn nhất
int[] sumRow = new int[row]; // Mảng chứa tổng từng dòng
for (int ro = 0; ro < row; ro++) {
for (int co = 0; co < column; co++) {
sumRow[ro] += myMatrix[ro][co];
}
}
int maxIndexRow = 0;
int maxSumRow = sumRow[maxIndexRow]; // Giả sử dòng 0 là dòng lớn nhất
for (int i = 1; i < row; i++) {
if (maxSumRow < sumRow[i]) {
maxSumRow = sumRow[i];
maxIndexRow = i; // Lưu lại chỉ số dòng của số lớn nhất
}
}
System.out.println("Max row in Matrix is row: " + maxIndexRow);
 
// Tìm cột lớn nhất
int[] sumColumn = new int[column]; // Mảng chứa tổng từng cột
for (int ro = 0; ro < row; ro++) {
for (int co = 0; co < column; co++) {
sumColumn[co] += myMatrix[ro][co];
}
}
int maxIndexColumn = 0;
int maxSumColumn = sumColumn[maxIndexColumn]; // Giả sử cột 0 là dòng lớn nhất
for (int i = 1; i < column; i++) {
if (maxSumColumn < sumColumn[i]) {
maxSumColumn = sumColumn[i];
maxIndexColumn = i; // Lưu lại chỉ số cột của số lớn nhất
}
}
System.out.println("Max column in Matrix is column: " + maxIndexColumn);

Có Còn Thể Loại Mảng Nào Nữa?!!

Phần râu ria còn lại của bài học hôm nay mình xin gom hết vào đây, mình chỉ nói qua một lượt mà không có thực hành, cũng không cần các bạn phải hiểu rõ, vì phần này nói về các thể loại Mảng còn lại trong Java mà hầu như chúng ta sẽ không dùng đến nó. Sự thật là mình chưa bao giờ dùng đến các thể loại này, nhưng biết đâu được ở đâu đó bạn sẽ chạm trán với nó, vậy thì hãy thử xem qua nó là gì nhé.

Mảng Ba Chiều

Vâng Mảng hai chiều đã đủ làm bạn chóng mặt, nay còn có cái thể loại Mảng ba chiều, thậm chí Mảng bốn chiều,… nhưng khoan khoan… bạn không cần phải dùng đến Mảng nhiều chiều quá vậy đâu, hãy thử xem với Mảng ba chiều thì thao tác trên mảng sẽ như sau, đủ để bạn nản lòng rồi đấy.

int[][][] array3D = new int[4][5][3];
for (int row = 0; row < 4; row++) {
for (int col = 0; col < 5; col++) {
for (int ver = 0; ver < 3; ver++) {
array3D[row][col][ver] = row + col + ver;
}
}
}

Mảng Răng Cưa

Ặc cái thể loại mảng này, mình cũng tránh xa. Cơ bản là mảng này được biểu diễn không “đều”, chẳng hạn như bạn có một Ma trận có m dòng, mà mỗi dòng lại có n cột khác nhau.

int[][] myJaggedArr = { { 3, 4, 5 }, { 77, 50 } };
for (int i = 0; i < myJaggedArr.length; i++) {
for (int j = 0; j < myJaggedArr[i].length; j++) {
System.out.print(myJaggedArr[i][j] + " ");
}
System.out.println();
}

Chuỗi (String)

Các bài trước chúng ta đã dành hết công lực để nói và học về mảng. Qua đó chúng ta đã biết rằng mảng là một cấu trúc dữ liệu khá mạnh và hiệu quả, chúng giúp quản lý danh sách các phần tử có cùng một kiểu dữ liệu, và còn giúp truy xuất rất nhanh đến một phần tử bất kỳ nữa. Hôm nay bạn lại được làm quen với một ứng dụng khá hay nữa của mảng, đó là mảng các ký tự, hay được gọi với một cái tên ngắn gọn và dễ hiểu hơn, đó là chuỗi.

Khái Niệm Chuỗi

Chuỗi, hay còn gọi là String đơn giản là một mảng các ký tự. Tuy nhiên về khái niệm cơ bản thì là vậy, còn về mặt cấu trúc thì chuỗi là một đối tượng (khái niệm mới mẻ này chúng ta sẽ nói đến ở mục hướng đối tượng – OOP ở vài bài sau nữa). Và vì nó là đối tượng, nó sẽ được hệ thống xây dựng sẵn các phương thức hữu ích. Việc của chúng ta ở bài học hôm nay là làm rõ khái niệm quan trọng của chuỗi, làm quen với việc khởi tạo chuỗi, và quen với các phương thức cần thiết, như phương thức so sánh chuỗicắt chuỗithay thếtìm độ dàitìm chuỗi con,….

Khi làm quen với chuỗi, bạn sẽ được nghe đâu đó nói rằng chuỗi là không thể thay đổi được, tiếng Anh gọi là immutable. Bạn có thể hiểu là một khi bạn khởi tạo một chuỗi thì giá trị khởi tạo đó sẽ là cố định. Mỗi khi bạn thay đổi giá trị cho chuỗi, hệ thống sẽ tạo ra một chuỗi mới. Do đó sẽ không ảnh hưởng lắm nếu như chương trình của bạn tạo ra các chuỗi để dùng mà bạn không có nhu cầu chỉnh sửa chúng hay chỉ chỉnh chút ít như các bài tập của bài học hôm nay. Nhưng nếu bạn có một chuỗi nào đó mà liên tục liên tục bị thay đổi giá trị, thì thay vì khai báo một chuỗi kiểu String, bạn có thể sử dụng kiểu StringBuffer hay StringBuilder thay thế. Bài hôm nay sẽ không đủ để nói về StringBuffer và StringBuilder, mình sẽ tập trung vào String và hẹn các bạn tiếp tục ở bài kế tiếp.

Khai Báo Và Khởi Tạo Một Chuỗi

Mình xin trắc nghiệm các bạn một tí. Dựa vào tất cả những gì mình nói ở trên đây, giả sử như như bây giờ mình kêu các bạn hãy thử khai báo một chuỗi và khởi tạo chuỗi đó với nội dung là “Hello World!”, bạn sẽ nghĩ ngay đến khai báo sau đúng không nào.

char[] chuoi = {'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd', '!'};

Chưa đúng đâu nhé, code trên đây chỉ giúp bạn khởi tạo một mảng các ký tự thôi, với chuỗi thì dễ hơn nhiều đấy bạn, bạn có thể chọn một trong hai cách khai báo và khởi tạo như sau.

Khởi tạo theo kiểu “nguyên thủy”: tức là bạn xem chuỗi như là một biến với kiểu dữ liệu nguyên thủy vậy (bạn biết thực chất không phải mà đúng không). Mình đưa ra ví dụ khai báo cho chuỗi “Hello World!” luôn chứ không đưa cú pháp như sau.

String myLiteralStr = "Hello World!";

Hoặc khởi tạo theo kiểu “đối tượng”: tức là bạn dùng từ khóa new để khởi tạo như những gì bạn thao tác với Scanner hay mảng ở bài trước, bạn xem.

String myObjectStr = new String("Hello World!");

Bạn chú ý ở khai báo và thao tác với chuỗi chúng ta bao chuỗi trong dấu nháy kép (” “) chứ không phải nháy đơn (‘ ‘) như với ký tự nhé.

Nói về kiến thức sâu một tí thì hai cách khởi tạo chuỗi trên đây thực ra là khác nhau, là bởi vì liên quan đến tính chất không thể thay đổi được mà mình đã nhắc ở trên. Nhưng mình sẽ không nói sâu về sự khác nhau đó ở đây. Còn nhớ khi mới tiếp cận Java mình cũng chỉ ghi nhớ hai cách khởi tạo trên, và mình thích cách đầu tiên nhất, cách đó trực quan và rất nhanh. Mình sẽ lập một mục riêng để bàn về hai cách khởi tạo này ở một bài viết khác nhé.

Một Số Phương Thức Hữu Ích Của Chuỗi

Đến đây mình không biết trình bày theo kiểu nào là tốt nhất. Vì về nguyên tắc chuỗi vẫn là một khái niệm hướng đối tượng (OOP), nó sẽ xa vời với đa số các bạn mới làm quen với lập trình. Nhưng mình nghĩ là không sao, việc sử dụng chuỗi khá là dễ, mình sẽ cố gắng liệt kê tất cả các phương thức hữu ích của chuỗi, bạn hãy làm quen với cách sử dụng các phương thức này, sau đó nếu có gặp lại ở đâu đó thì bạn hãy nhớ đến bài học hôm nay vậy.

So Sánh Chuỗi

Khi bạn có hai chuỗi, và muốn biết chúng giống hay khác nhau, hay khác nhau là khác nhau như thế nào, thì có thể dùng đến các hàm so sánh như sau.

equals()

Nếu có hai chuỗi, mình ví dụ chuoi1 và chuoi2, bạn có thể gọi 

chuoi1.equals(chuoi2);

, kết quả trả về là một kiểu boolean với kết quả là true nếu chúng giống nhau và false nếu không giống nhau.

Bài Thực Hành Số 1

Với chuỗi gốc là “Hello World!”, bạn hãy in ra kết quả của so sánh chuỗi gốc với hai chuỗi “HelloWorld!” và “hello world!” nhé.

String rootStr = "Hello World!";
System.out.println("Compare 1: " + rootStr.equals("HelloWorld!"));
System.out.println("Compare 2: " + rootStr.equals("hello world!"));

Kết quả của hai câu lệnh in ra console trên hiển nhiên là false và false rồi.

equalsIgnoreCase()

Cách sử dụng của phương thức này cũng giống với equals(), nhưng equalsIgnoreCase() lại trả về kết quả của sự so sánh mà không phân biệt chữ hoa-thường. Tức là Chuỗi “A” và “a” sẽ là như nhau trong phép so sánh này.

Bài Thực Hành Số 2

Bạn hãy in ra kết quả so sánh chuỗi “Hello World!” và “hello world!” nhé.

String rootStr = "Hello World!";
System.out.println("Compare: " + rootStr.equalsIgnoreCase("hello world!"));

Kết quả của câu lệnh so sánh chắc chắn là true.

So Sánh Với Toán Tử ==

Tuy đưa toán tử == ra đây, nhưng ở mức độ của bài học hôm nay, chúng ta lại không bàn về việc dùng toán tử == để so sánh hai chuỗi trong Java. Về mặt ngữ pháp lập trình thì không sai, bạn có thể thử dùng toán tử này để thực hành so sánh nếu chuoi1 == chuoi2. Nhưng một khi bạn chưa hiểu rõ về chuỗi và về hướng đối tượng thì mình khuyên bạn nên bỏ khỏi đầu ý định so sánh hai chuỗi trong Java bằng toán tử == nhé, mình sẽ nói tại sao ở một bài khác.

compareTo()

Phương thức so sánh này khá đặc biệt, nó sẽ lấy từng ký tự ra so sánh, với mỗi ký tự nó sẽ lấy giá trị Unicode ra rồi tiến hành phép trừ các ký tự này, một khi đã phát hiện ta ký tự khác biệt (phép trừ cho ra kết quả âm hay dương) thì sẽ ngừng việc trừ lại.

Cụ thể như ví dụ 

chuoi1.compareTo(chuoi2)

 thì.

  • Kết quả trả về là một số âm khi chuoi1 có thứ tự ký tự trong Unicode nhỏ hơn chuoi2.
  • Kết quả trả về là số 0 khi chuoi1 giống hệt chuoi2.
  • Kết quả trả về là một số dương khi chuoi1 có thứ tự ký tự trong Unicode lớn hơn chuoi2.
Bài Thực Hành Số 3

Giả sử bạn lấy đâu đó ra hai chuỗi “1.5.5.3” và “1.5.6.1” chính là hai version của ứng dụng. Bằng cách nào bạn có thể in ra console version nào là version mới nhất?

String version1 = "1.5.5.3";
String version2 = "1.5.6.1";
int compare = version1.compareTo(version2);
if (compare < 0) {
System.out.println(version2 + " mới hơn " + version1);
} else if (compare > 0) {
System.out.println(version1 + " mới hơn " + version2);
} else {
System.out.println(version1 + " giống " + version2);
}

Nối Chuỗi

Khi bạn có hai chuỗi, và muốn nối chúng lại với nhau, bạn có thể dùng đến các hàm sau.

Nối Chuỗi Với Toán Tử +

Thật dễ dàng như 1 + 1 = 2 vậy, bạn hãy xem qua ví dụ sau.

String str1 = "Hello World!";
String str2 = str1 + " Hello Yellow Code Books!";
 
System.out.println(str2);

Với cách nối chuỗi bằng toán tử + này thì bạn có thể nối thoải mái bao nhiêu chuỗi lại với nhau cũng được. Và nên nhớ là không hề có các toán tử * hay / với chuỗi nhé bạn.

Với ví dụ nối chuỗi bằng toán tử + trên đây thì bạn hãy nhớ lại đi, bạn đã và đang làm quen với cách nối chuỗi này từ các bài học trước và kể cả bài học hôm nay, ở các bài thực hành ở trên, đó là các hàm in ra console, bạn kiểm chứng lại nhé.

concat()

Với việc gọi 

chuoi1.concat(chuoi2)

 thì kết quả cho ra sẽ là một chuỗi mới tương tự như gọi 

chuoi1 + chuoi2

 trên đây vậy.

Bài Thực Hành Số 4

Bạn hãy viết lại câu lệnh nối chuỗi với toán tử + trên đây bằng hàm concat() nhé.

String str1 = "Hello World!";
System.out.println(str1.concat(" Hello Yellow Code Books!"));

join()

Một phương thức khá hay nữa của chuỗi. Phương thức này cho phép bạn truyền vào bao nhiêu tham số cũng được, chỉ cần các tham số đó cũng là các chuỗi. Khi đó kết quả của phương thức này chính là một chuỗi mới được ghép lại từ các chuỗi truyền vào, nhưng được phân cách nhau bởi chuỗi đầu tiên. Nói thì khá khó hiểu, mời bạn đến với Bài thực hành bên dưới.

Bài Thực Hành Số 5

In ra màn hình tất cả các size áo có thể có của một cửa hàng, nội dung đại loại như sau “There are sizes: S, M, L, XL”. Bạn có thể đơn giản in hết nội dung này ra màn hình một lúc, nhưng khi dùng join() nó như sau.

String allSizes = String.join(", ", "S", "M", "L", "XL");
System.out.println("There are sizes: " + allSizes);

repeat()

Phương thức này được thêm vào từ Java 11, nó đơn giản là giúp lặp chuỗi với x lần để tạo ra chuỗi mới.

Bài Thực Hành Số 6

In ra màn hình dòng chữ “Are you sure? Sure! Sure! Sure! Sure! Sure! Sure!”.

String sureSixTimes = "Sure!".repeat(6);
System.out.println("Are you sure? " + sureSixTimes);

Rút Trích Chuỗi Con

Nếu bạn có một chuỗi, và muốn lấy ra chuỗi con trong chuỗi gốc này, mời bạn làm quen với hai phương thức sau.

subString(int startIndex)

Nếu bạn gọi 

chuoi.subString(startIndex)

 thì kết quả sẽ trả về một chuỗi con với nội dung bắt đầu từ chỉ số startIndex của chuoi, lưu ý là giống như với mảng: chỉ số của các ký tự trong chuỗi được bắt đầu từ 0.

Bài Thực Hành Số 7

Với chuỗi gốc là “Hello World! Hello Yellow Code Books!”, bạn hãy trích ra chuỗi con là “Hello Yellow Code Books!” nhé.

String rootStr= "Hello World! Hello Yellow Code Books!";
System.out.println(rootStr.substring(13));

subString(int startIndex, int endIndex)

Tương tự như trên nhưng nếu bạn gọi 

chuoi.subString(startIndex, endIndex)

 thì kết quả sẽ trả về một chuỗi con với nội dung bắt đầu từ chỉ số startIndex đến chỉ số endIndex của chuoi.

Bài Thực Hành Số 8

Với chuỗi gốc là “Hello World! Hello Yellow Code Books!”, bạn hãy thử trích ra chuỗi con là “Yellow Code Books” xem sao.

String rootStr= "Hello World! Hello Yellow Code Books!";
System.out.println(rootStr.substring(13, rootStr.length() - 2));

Lưu ý ở bài thực hành này mình có gọi đến phương thức 

length()
 của chuỗi, phương thức này sẽ trả về độ lớn của chuỗi, là tổng số ký tự trong chuỗi đó.

Chuyển Đổi In Hoa – In Thường

Ở chuỗi có các hàm sau để bạn có thể chuyển đổi về in hoa hay in thường cho tất cả các ký tự trong chuỗi.

toUpperCase()

Nếu bạn gọi 

chuoi.toUpperCase()

 thì nó sẽ trả về một chuỗi mới với tất cả các ký tự được in hoa từ chuoi.

Bài Thực Hành Số 9

Với chuỗi gốc là “Hello World! Hello Yellow Code Books!”, bạn hãy in hoa hết tất cả các ký tự của chuỗi và in ra console.

String rootStr= "Hello World! Hello Yellow Code Books!";
rootStr = rootStr.toUpperCase();
System.out.println(rootStr);

toLowerCase()

Phương thức này ngược lại hoàn toàn với 

toUpperCase()

, nó sẽ giúp trả về một chuỗi với tất cả các ký tự trong chuoi về thành ký tự thường.

Một Số Phương Thức Thông Dụng Khác Của Chuỗi

Dưới đây là một số phương thức thông dụng khác của chuỗi mà bạn cần phải biết.

trim()

Phương thức này loại bỏ các khoảng trắng ở trước và sau chuỗi.

Bài Thực Hành Số 10

Với chuỗi gốc là ”   Hello World!       “. Bạn hãy cắt bỏ các khoảng trắng ở đầu và cuối chuỗi rồi in ra console.

String rootStr= " Hello World! ";
rootStr = rootStr.trim();
System.out.println(rootStr);

startsWith() Và endsWith()

Hai phương thức này đều trả về một kiểu boolean, cho biết chuỗi có bắt đầu hay kết thúc với một chuỗi được so sánh hay không.

Bài Thực Hành Số 11

Với chuỗi gốc là “Hello World! Hello Yellow Code Books!”, hãy in ra console cho biết chuỗi này có bắt đầu là “Hello” và có kết thúc là “Hello” hay không.

String rootStr= "Hello World! Hello Yellow Code Books!";
System.out.println("String start with Hello? " + rootStr.startsWith("Hello"));
System.out.println("String end with Hello? " + rootStr.endsWith("Hello"));

Kết quả của hai câu in ra console trên lần lượt là true và false.

charAt()

Phương thức này trả về ký tự của chuỗi tại chỉ mục được truyền vào.

Bài Thực Hành Số 12

Với chuỗi gốc là “Hello World! Hello Yellow Code Books!”, bạn hãy in ra console ký tự thứ 11 trong chuỗi.

String rootStr= "Hello World! Hello Yellow Code Books!";
System.out.println("The 11th char is " + rootStr.charAt(11));

replace()

Phương thức này có hai tham số truyền vào chính là hai chuỗi, như này 

chuoi.replace(chuoi1, chuoi2)

. Nếu chuoi1 có tồn tại trong chuoi thì tất cả những nơi nào xuất hiện chuoi1 trong chuoi sẽ bị thay bằng chuoi2.

Bài Thực Hành Số 13

Với chuỗi gốc là “Hello World! Hello Yellow Code Books!”, bạn hãy thay thế tất cả các “Hello” trong chuỗi gốc này thành “Hi” nhé.

String rootStr= "Hello World! Hello Yellow Code Books!";
rootStr = rootStr.replace("Hello", "Hi");
System.out.println(rootStr);

indexOf()

Phương thức này sẽ tìm trong chuỗi vị trí xuất hiện đầu tiên của ký tự được truyền vào trong phương thức.

Bài Thực Hành Số 14

Với Chuỗi gốc là “Hello World! Hello Yellow Code Books!”, bạn hãy in ra console vị trí của ký tự “!”.

String rootStr= "Hello World! Hello Yellow Code Books!";
System.out.println("The ! char at " + rootStr.indexOf("!")) ;

Kết quả câu lệnh này sẽ in ra console số 11.

Ngoài các phương thức được liệt kê trên đây thì chuỗi còn có khá nhiều các phương thức hữu ích khác, nhưng trong khuôn khổ bài viết này mình tạm không nói hết. Hoặc có những phương thức mang đậm tính Hướng đối tượng hơn, có thể khiến bạn phải hoang mang, nên mình sẽ dành khi nào dùng đến các phương thức đặc biệt này sẽ nói rõ hơn.

StringBuffer Và StringBuilder

Bài này là bài học mở rộng so với bài về Chuỗi của bữa trước. Có thể nói là bài học bổ sung, nó không quan trọng lắm, nhưng không nói thì lại áy náy, ăn ngủ không yên. Nếu bạn nào quan tâm đến hiệu năng (performance) của ứng dụng, thì chú ý kỹ các bài học dạng này.

Để làm quen với các kiến thức và ví dụ của bài học hôm nay, bạn nhất thiết phải thực hành một chút với các phương thức của chuỗi ở bài trước để hiểu được cơ bản đối tượng này, thì hôm nay bạn mới có thể làm quen dễ hơn với các khái niệm và phương thức mở rộng.

Tại Sao Phải Biết Về StringBuffer Và StringBuilder?

Tất nhiên đây sẽ là thắc mắc đầu tiên của các bạn khi mình giới thiệu hai đối tượng này trong ngày hôm nay.

Chà… thực ra bài học hôm nay mình cũng cân nhắc nhiều. Bạn cũng biết là khi tìm hiểu về chuỗi thì coi như bạn đã “đặt một chân” vào cánh cửa OOP rồi, mà chúng ta lại chưa có bài viết nào về OOP cả. Vậy mà StringBuffer và StringBuilder của bài hôm nay lại tiếp tục nói nữa về OOP. Nhưng mình nghĩ nếu biết về chúng trễ quá sau khi học hết OOP thì lại không hay chút nào. Thế là mình quyết định thôi thì nói trước, sau này làm quen với OOP chắc chắn các bạn sẽ hiểu rõ hơn bài học hôm nay.

Vậy quay lại câu hỏi tại sao phải biết hai đối tượng này? Như bài hôm trước mình có nói, nếu bạn đã làm quen với chuỗi thì nên biết rằng chuỗi mang đặc tính không thể thay đổi được, tiếng Anh gọi là immutable. Điều này có nghĩa là khi bạn tạo ra một chuỗi, thì chuỗi đó là cố định và bạn không thể thay đổi được.

Nhưng mà khoan!!! Bài hôm trước chúng ta có thực hành việc nối chuỗicắt chuỗichuyển in hoa/thường cho chuỗi… thì đó không phải là thay đổi chuỗi hay sao??? Hoang mang quá.

Thực ra là, nếu bạn tác động để làm một chuỗi thay đổi, hệ thống sẽ tạo ra chuỗi mới cho bạn. Chuỗi cũ (trước khi bị bạn thay đổi) sẽ còn lại trong hệ thống và sẽ trở thành rác, làm ảnh hưởng đến hiệu năng của ứng dụng.

Khi đó StringBuffer và StringBuilder ra đời nhằm đáp ứng nhu cầu của bạn trong các trường hợp muốn sử dụng đến chuỗi có khả năng thay đổi được (mutable). Sử dụng chúng như thế nào? Có khác với chuỗi truyền thống không? Mời các bạn xem tiếp phần dưới sẽ rõ.

StringBuffer Và StringBuilder Khác Nhau Ra Sao?

Lại một câu hỏi nữa khiến mình càng thêm cân nhắc khi nói đến hai đối tượng này ở bài hôm nay. Vì câu trả lời cho câu hỏi này lại liên quan đến khái niệm luồng, hay đa luồng. Cụ thể là: StringBuffer và StringBuilder có công năng và cách sử dụng hoàn toàn giống nhau, tuy nhiên về mặt cấu trúc thì có khác nhau đôi chút, đó là StringBuffer được cấu tạo để ứng dụng vào các xử lý đa luồng (multithreading) giúp tránh các tranh chấp giữa các luồng (thread), còn StringBuilder được cấu tạo để ứng dụng trong một luồng mà thôi. Khái niệm luồng hay đa luồng sẽ được nói đến ở các bài học sau nữa, do đó một lần nữa mình chỉ muốn nêu ra cho các bạn biết, còn để hiểu rõ hơn thì bạn lại phải tiếp tục theo dõi dông dài các bài viết của Yellow Code Books rồi 😉 .

Nếu bạn đọc document từ trang chính chủ Oracle thì sẽ biết, sử dụng StringBuilder sẽ cho ra tốc độ xử lý nhanh hơn so với StringBuffer. Nội dung của document này cụ thể như sau (dòng này là đang nói về StringBuilder).

This class provides an API compatible with StringBuffer, but with no guarantee of synchronization. This class is designed for use as a drop-in replacement for StringBuffer in places where the string buffer was being used by a single thread (as is generally the case). Where possible, it is recommended that this class be used in preference to StringBuffer as it will be faster under most implementations.

Cách Sử Dụng StringBuffer Và StringBuilder

Do mình đã nói rằng, cách sử dụng StringBuffer và String Builder là hoàn toàn giống nhau, do đó ở mục cách sử dụng này, mình chỉ đưa ra ví dụ về StringBuffer thôi nhé, bạn hoàn toàn có thể áp dụng các kiến thức của phần này dành cho StringBuffer vào StringBuilder mà không gặp chút rắc rối nào.

Khai Báo Và Khởi Tạo

Bạn có nhớ hai cách khai báo và khởi tạo một chuỗi không? Nếu quên thì xem lại bài trước cho nhớ nhé. Cơ bản với chuỗi bạn sẽ có hai cách khởi tạo, hoặc là khởi tạo kiểu nguyên thủy”, hoặc là khởi tạo kiểu “đối tượng”.

Còn với StringBuffer (và cả StringBuilder) của bài hôm nay thì hơi khác chuỗi thông thường một chút, đó là chỉ có một cách duy nhất để khởi tạo chúng mà thôi, chính là khởi tạo kiểu “đối tượng”.

Như đã trình bày ở bài chuỗi, bài này mình cũng xin đưa ra ví dụ về khai báo StringBuffer mà không đưa ra cú pháp.

StringBuffer strBuffer_1 = new StringBuffer(); // Khởi tạo một StringBuffer rỗng, với khả năng chứa đựng ban đầu là 16 ký tự
StringBuffer strBuffer_2 = new StringBuffer(50); // Khởi tạo một StringBuffer rỗng, với khả năng chứa đựng ban đầu do bạn định nghĩa
StringBuffer strBuffer_3 = new StringBuffer("Hello World!"); // Giống với Chuỗi, cách này khởi tạo một StringBuffer với chuỗi xác định

Và vì StringBuffer và StringBuilder là các cấu trúc giúp thay đổi chuỗi thoải mái, nên bạn cứ yên tâm là sẽ không ảnh hưởng đến hiệu suất của hệ thống nếu bạn thay đổi nhiều và liên tục trên chuỗi. Dưới đây là các phương thức hữu ích của StringBuffer và StringBuilder mà bạn có thể xem sơ qua.

Nối Chuỗi – append()

Nếu như với chuỗi, bạn có thể dùng phương thức concat() hay toán tử + để nối hai chuỗi lại với nhau, nhưng cách này như chúng ta biết nó sẽ tạo ra một chuỗi mới là sự kết kợp của hai chuỗi cũ. Thì với bài này phương thức nối chuỗi chính là append() sẽ có tác dụng gần như tương tự, tức là sẽ thêm chuỗi mới vào chuỗi cũ (hệ thống vẫn không tạo ra chuỗi khác). Bạn xem ví dụ cách sử dụng hàm này như sau.

StringBuffer str1 = new StringBuffer("Hello World!");
str1.append(" Hello Yellow Code Books!");
 
System.out.println(str1); // Kết quả in ra là "Hello World! Hello Yellow Code Books!"

Chèn Chuỗi – insert()

Phương thức này giúp chèn một chuỗi vào một vị trí nào đó ở chuỗi gốc. Ví dụ dưới đây giúp chèn thêm chuỗi “Java” vào sau chuỗi “Hello” ở ví dụ trên, để tạo thành chuỗi “Hello Java World! Hello Yellow Code Books!”.

StringBuffer str2 = new StringBuffer("Hello World!");
str2.append(" Hello Yellow Code Books!");
str2.insert(5, " Java");
 
System.out.println(str2); // Kết quả in ra là "Hello Java World! Hello Yellow Code Books!"

Thay Thế – replace()

Nếu bạn còn nhớ thì ở chuỗi chúng ta cũng đã làm quen với phương thức có tên replace() này rồi. Nhưng khi đó tham số truyền vào của replace() là hai chuỗi, giúp bạn thay thế tất cả chuỗi nếu có tồn tại trong chuỗi gốc bằng một chuỗi mới. Qua đến replace() của bài hôm nay các bạn phải chỉ định vị trí bắt đầu và kết thúc của chuỗi gốc cần được thay thế bằng chuỗi mới. Như ví dụ sau (mình lấy lại từ yêu cầu thực hành thay thế chuỗi “Hello” thành “Hi” ở bài trước).

StringBuffer str3 = new StringBuffer("Hello World! Hello Yellow Code Books!");
str3.replace(0, 5, "Hi");
 
System.out.println(str3); // Kết quả in ra là "Hi World! Hello Yellow Code Books!"

Bạn lưu ý là chuỗi mới “Hi” chỉ thay thế cho chuỗi “Hello” có vị trí từ 0 đến 5 trong chuỗi gốc. chuỗi “Hello” đứng sau đó vẫn còn trong chuỗi gốc, điều này khác với replace() ở bài Chuỗi là thay thế hết tất cả “Hello” thành “Hi” luôn đấy nhé.

Xóa Chuỗi – delete()

Phương thức này không có trong bài trước, dùng để xóa chuỗi từ vị trí bắt đầu đến vị trí kết thúc trong chuỗi gốc. Ví dụ sau xóa chuỗi “Java” ra khỏi chuỗi gốc ban đầu.

StringBuffer str4 = new StringBuffer("Hello Java World! Hello Yellow Code Books!");
str4.delete(6, 11);
 
System.out.println(str4); // Kết quả in ra là "Hello World! Hello Yellow Code Books!"

Đảo Ngược Chuỗi – reverse()

Một phương thức khá thú vị ở bài hôm nay, đó là đảo chuỗi. Bạn hãy xem kết quả đảo ngược từ ví dụ bên dưới.

StringBuffer str5 = new StringBuffer("Hello World! Hello Yellow Code Books!");
str5.reverse();
 
System.out.println(str5); // Kết quả in ra là "!skooB edoC wolleY olleH !dlroW olleH"

Trích Xuất Chuỗi – toString()

Trong trường hợp bạn đã xử lý xong các thao tác trên chuỗi thông qua StringBuffer hay StringBuilder, mà muốn chúng xuất ra một chuỗi cuối cùng có kiểu String, thì hãy dùng phương thức toString() như sau.

StringBuffer str6 = new StringBuffer("Hello World! Hello Yellow Code Books!");
str6.reverse();
 
String strFinal = str6.toString();
System.out.println(strFinal);

Kiểm Tra Dung Lượng Bộ Đệm – capacity()

Ở bước khởi tạo trên đây bạn đã biết đến khả năng chứa đựng số lượng ký tự của StringBuffer và StringBuilder. Nếu bạn khởi tạo các đối tượng này rỗng, dung lượng mặc định ban đầu là 16. Hãy xem ví dụ chứng minh sau đây.

StringBuffer str7 = new StringBuffer();
System.out.println(str7.capacity()); // Kết quả in ra là 16

Điều này cũng giống khi bạn làm việc với Mảng, khi đó bạn cấp phát một bộ nhớ cho mảng có khả năng chứa 16 phần tử chẳng hạn mà chưa khởi tạo giá trị cho Mảng đó. Thì với cách khai báo trên đây của StringBuffer cũng vậy. Hay với cách khai báo trên đây có truyền vào số lượng ký tự có thể chứa ban đầu.

StringBuffer str8 = new StringBuffer(30);
System.out.println(str8.capacity()); // Kết quả in ra là 30

Mình tiếp tục thử thêm chuỗi vào rồi kiểm tra lại capicity() nhé.

StringBuffer str9 = new StringBuffer();
str9.append("Hello World!");
System.out.println(str9.capacity()); // Kết quả in ra là 16

Vậy khi bạn khai báo và thêm một chuỗi “Hello World!” vào thì dung lượng mặc định ban đầu vẫn dựa vào khả năng chứa 16 ký tự. Nếu số lượng ký tự của chuỗi vượt qua 16, khả năng chứa sẽ tự tăng lên. Điều này làm cho StringBuffer và StringBuilder khá linh động và hiệu năng sử dụng bộ nhớ cũng được tối ưu nữa.

Ngoài các phương thức cho StringBuffer và StringBuilder trên đây, đó là các phương thức đặc biệt linh động mà với chuỗi ở bài trước không có. Thì hai anh em mới này của chuỗi cũng vẫn có các phương thức tương tự như bên chuỗi mà bạn có thể mang ra dùng, mình chỉ điểm mặt thôi, đó là.

  • subString(startIndex);
  • subString(startIndex, endIndex);
  • charAt(index);
  • indexOf(Chuỗi);

Tổng Quan Lập Trình Hướng Đối Tượng

Các Hướng Tư Duy Trong Lập Trình

Lập trình hướng-đối-tượng hay hướng-cái-gì đi nữa thì cũng chỉ là một giải pháp, một tư duy trong lập trình mà thôi. Nó không cao siêu hay phức tạp gì. Nó cũng không phụ thuộc vào ngôn ngữ lập trình Java hay gì cả. Nó chỉ là tập hợp các phương pháp và nguyên tắc để giúp bạn suy nghĩ và tổ chức vấn đề, từ đó giúp làm ra một chương trình phức tạp và to lớn một cách dễ dàng hơn.

Và dĩ nhiên với việc suy nghĩ theo một hướng lập trình nào đó, thì ngôn ngữ lập trình cũng phải hỗ trợ lập trình viên các cú pháp theo các nguyên tắc hướng đó. Chính vì vậy chúng ta mới có các ngôn ngữ lập trình hướng đối tượng hay không hướng đối tượng. Như ngôn ngữ Java mà các bạn đang tìm hiểu là một ngôn ngữ hướng đối tượng.

Có thể nói là có rất nhiều tài liệu đã mô tả cụ thể về thế nào là lập trình theo hướng đối tượng và thế nào là lập trình không hướng đối tượng. Nhưng với một vài tài liệu mà mình biết, chắc chắn là rất khó để các bạn hiểu sự khác nhau này. Vậy với các bài học của mình thì mình mời các bạn tiếp cận các hướng lập trình theo một cách hoàn toàn khác, đó là, chúng ta hãy cùng xem qua một ví dụ đơn giản, và cùng nhau thay đổi tư duy dần dần để từng bước khám phá cách thức xây dựng một ứng dụng theo hướng cũ và hướng đối tượng sẽ ra sao nhé.

Xây Dựng Ứng Dụng Theo Hướng “Cũ”

Giả sử có yêu cầu rằng bạn hãy viết một chương trình kêu người dùng nhập vào Bán kính của một Hình tròn, sau đó chương trình sẽ in ra console Chu vi và Diện tích của Hình tròn đó.

Nếu bạn bắt tay vào code nhanh sườn của chương trình, thì nó sẽ trông như thế này.

public static void main(String[] args) {
// Kêu người dùng nhập vào Bán kính Hình tròn
System.out.print("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
float r = scanner.nextFloat();
 
// Tính Chu vi Hình tròn ở đây
// ...
 
// Tính Diện tích Hình tròn ở đây
// ...
 
// Xuất kết quả Chu vi và Diện tích của Hình tròn ra console
System.out.println("Chu vi Hình tròn: ");
System.out.println("Diện tích Hình tròn: ");
}

Với cách tiếp cận từ Bài học số 1 đến giờ, rất dễ để bạn bắt tay vào hoàn thành chương trình này đúng không nào. Để tính Chu vi và Diện tích của Hình tròn, chúng ta chỉ cần áp dụng kiến thức toán học vào mà thôi, bạn xem code hoàn chỉnh như sau.

public static void main(String[] args) {
// Khai báo số PI là hằng số
final float PI = 3.14f;
 
// Kêu người dùng nhập vào Bán kính Hình tròn
System.out.print("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
float r = scanner.nextFloat();
 
// Tính Chu vi Hình tròn ở đây
float cv = 2*PI*r;
 
// Tính Diện tích Hình tròn ở đây
float dt = PI*r*r;
 
// Xuất kết quả Chu vi và Diện tích của Hình tròn ra console
System.out.println("Chu vi Hình tròn: " + cv);
System.out.println("Diện tích Hình tròn: " + dt);
}

Ôi có khó gì đâu! Cách tư duy như chúng ta làm với ví dụ này người ta gọi là tư duy theo hướng thủ tục (POP – Procedure Oriented Programming), hay có thể gọi là hướng cấu trúc. Bởi vì chúng ta tư duy theo kiểu chia nhỏ yêu cầu ra thành các “thủ tục”, các thủ tục ở đây là các hành động có thể có của một chương trình. Chẳng hạn như với ví dụ trên chúng ta có các thủ tục tính Chu vi Hình tròntính Diện tích Hình tròn. Bạn cũng có thể tách từng thủ tục này thành các phương thức (chúng ta sẽ học đến phương thức sau) cho chương trình dễ nhìn hơn kiểu như sau.

// Khai báo số PI là hằng số
static final float PI = 3.14f;
 
public static void main(String[] args) {
// Kêu người dùng nhập vào Bán kính Hình tròn
System.out.print("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
float r = scanner.nextFloat();
 
// Chu vi Hình tròn
float cv = chuViHinhTron(r);
 
// Diện tích Hình tròn
float dt = dienTichHinhTron(r);
 
// Xuất kết quả Chu vi và Diện tích của Hình tròn ra console
System.out.println("Chu vi Hình tròn: " + cv);
System.out.println("Diện tích Hình tròn: " + dt);
}
 
public static float chuViHinhTron(float r) {
return 2 * PI * r;
}
 
public static float dienTichHinhTron(float r) {
return PI * r * r;
}

Câu chuyện chưa dừng lại ở đây. Giả sử yêu cầu được nâng tầm lên. Đòi hỏi chương trình hỗ trợ thêm các phương thức liên quan đến Hình trụ. Cụ thể là nhập thêm chiều cao Hình trụ rồi sau đó in ra thêm Thể tích Hình trụ (Hình trụ này có Bán kính mặt tròn bằng với Bán kính Hình tròn trên kia).

Vậy là với tư duy hướng thủ tục, bạn cũng sẽ kêu dễ, và… Boom! Code mới ra đời với một số thủ tục mới.

// Khai báo số PI là hằng số
static final float PI = 3.14f;
 
public static void main(String[] args) {
// Kêu người dùng nhập vào Bán kính Hình tròn
System.out.print("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
float r = scanner.nextFloat();
 
// Kêu người dùng nhập vào Chiều cao Hình trụ
System.out.print("Hãy nhập vào Chiều cao Hình trụ: ");
float h = scanner.nextFloat();
 
// Chu vi Hình tròn
float cv = chuViHinhTron(r);
 
// Diện tích Hình tròn
float dt = dienTichHinhTron(r);
 
// Thể tích Hình trụ
float ttHinhTru = theTichHinhTru(dt, h);
 
// Xuất kết quả Chu vi và Diện tích của Hình tròn, Thể tích Hình trụ ra console
System.out.println("Chu vi Hình tròn: " + cv);
System.out.println("Diện tích Hình tròn: " + dt);
System.out.println("Thể tích Hình trụ: " + ttHinhTru);
}
 
public static float chuViHinhTron(float r) {
return 2 * PI * r;
}
 
public static float dienTichHinhTron(float r) {
return PI * r * r;
}
 
public static float theTichHinhTru(float dt, float h) {
return dt * h;
}

Cũng may là các hàm tính Chu vi, Diện tích, Thể tích trong ví dụ này rất ngắn. Nhưng bạn có thể thấy nếu yêu cầu ngày một phát sinh, như đòi hỏi tính thêm Chu vi hay Diện tích hay Thể tích cho các Hình vuông, Hình bình hành, Hình cầu,… thì các thủ tục trong chương trình của bạn sẽ phình ra đến mức nào, làm cho chương trình càng khó quản lý nữa. Do đó cần phải có một kiểu tư duy mới khiến cho việc quản lý code được dễ dàng hơn, thậm chí có thể rút ngắn thời gian code thông qua việc tận dụng lại các thủ tục tính toán nữa. Vậy là từ mong muốn đó, tư duy hướng đối tượng ra đời.

Có nhiều tài liệu cho rằng, phương thức suy nghĩ theo tư duy hướng thủ tục này là tư duy kiểu top-down, tức là theo kiểu từ trên-xuống, hay còn có thể hiểu là tư duy theo kiểu tổng quát đến cụ thể. Vậy là sao? Tức là bạn sẽ nhìn tổng thể yêu cầu của chương trình trước, như ví dụ bạn thấy ngay yêu cầu là tính Chu vi và Diện tích hình tròn, bạn sẽ xây dựng các thủ tục tính toán đó trước. Sau khi tiếp cận cái tổng thể đó, thì bạn thấy cần phải viết thêm các thủ tục nhỏ hơn để mà dễ quản lý, chẳng hạn bạn muốn thêm các thủ tục tính Bình phương, Thập phương của một số hạng. Nếu còn có thể phân chia thủ tục nhỏ đó thành các thủ tục nhỏ hơn nữa thì cứ phân chia. Đó chính là tư duy kiểu top-down.

Xây Dựng Ứng Dụng Theo Hướng Đối Tượng

Hướng đối tượng (OOP – Object Oriented Programming). Cách tư duy mới này sẽ không hướng đến các thủ tục nữa, mà là hướng đến các đối tượng. Ngoài thực tế thì các đối tượng sẽ là các thực thể hay sự vật mà chúng ta có thể cầm nắm, có thể gọi tên được, và còn có cả trạng thái và hành vi. Áp dụng vào trong chương trình phần mềm thì khái niệm đối tượng vẫn y như vậy.

Chẳng hạn với ví dụ viết chương trình tính Chu vi và Diện tích Hình tròn trên đây. Thì đối tượng mà ta cần quan tâm đó chính là Hình tròn. Vậy làm sao để giải quyết yêu cầu? Như bạn đã biết, đối tượng được sinh ra trong lập trình (hay ngoài đời thực) đều có những trạng thái (đặc điểm) và hành vi (hành động) nhất định. Như ở đây Hình tròn có đặc điểm là độ lớn của nó, chính là giá trị Bán kính, còn hành động của nó chính là các phép tính Chu vi và Diện tích của bản thân mình. Thậm chí bạn có thể xây dựng cho Hình tròn tự nó phải có trách nhiệm kêu người dùng nhập vào Bán kính và lưu lại. Rồi trách nhiệm tự nó in kết quả Chu vi và Diện tích ra console luôn. Khỏe!!! Như mình minh họa bằng code dưới đây, bạn không cần phải biết chính xác code này là như nào đâu, chỉ cần biết ý tưởng của hướng đối tượng như thế nào là được.

public static void main(String[] args) {
// Khai báo Đối tượng Hình tròn và ra lệnh cho đối tượng này thực hiện
// các yêu cầu
HinhTron hinhTron = new HinhTron();
 
// Hình tròn tự kêu người dùng nhập Bán kính
hinhTron.nhapBanKinh();
 
// Hình tròn tự nó biết cách tính Chu vi
hinhTron.tinhChuVi();
 
// Hình tròn tự nó biết cách tính Diện tích
hinhTron.tinhDienTich();
 
// Hình tròn tự in kết quả ra console
hinhTron.inChuVi();
hinhTron.inDienTich();
}

Bạn có nhận ra sự gọn gàng và dễ quản lý trong code không? Mình rất thích nhìn thấy code như thế này.

Khác biệt sẽ rất lớn khi mà các thủ tục tính Chu vitính Diện tích là các thủ tục lớn và phức tạp. Khi đó các thủ tục của Đối tượng nào sẽ được giao cho Đối tượng đó, tức là bạn xây dựng các thủ tục đặc thù đó bên trong các Đối tượng (bạn sẽ được biết cách xây dựng này sau), và khi cần đến các thủ tục nào, chúng ta chỉ cần gọi đến thủ tục thông qua đối tượng đã được khai báo, như đối tượng HinhTron trên đây.

Với việc bạn chỉ định Hình tròn phải có các thủ tục của nó. Thì việc thêm vào một Hình trụ cũng chỉ là việc khai báo thêm một đối tượng mới, với các thủ tục mới tương xứng. Ngoài ra thì đối tượng Hình trụ còn có các đặc tính và thủ tục giống như Hình tròn, do đó chúng ta hoàn toàn có thể dùng lại được các thủ tục đã khai báo bên Hình tròn. Người ta gọi việc dùng lại các thủ tục này là tính kế thừa trong hướng đối tượng, thông qua việc chỉ định Hình trụ là con của Hình tròn (chúng ta sẽ xem xét mối quan hệ kế thừa này ở các bài học sau). Việc Hình trụ là con Hình tròn khiến nó có được các trạng thái và hành vi mà cha nó là Hình tròn đã định nghĩa, nên việc quản lý và sử dụng lại code rất dễ dàng. Về sau nếu như xuất hiện thêm Hình cầu, Hình abc, hay Hình xyz gì đó, thì chúng ta hoàn toàn có thể tạo các đối tượng và chúng sẽ có mối quan hệ nào đó với nhau, kèm các thủ tục đặc trưng hay kế thừa của nhau.

Mình cũng ví dụ bằng code cho mong muốn được tính Thể tích Hình trụ như sau.

public static void main(String[] args) {
// Khai báo Đối tượng Hình trụ
// Và ra lệnh cho đối tượng này thực hiện
// các yêu cầu
HinhTru hinhTru = new HinhTru();
 
// Hình trụ tự kêu người dùng nhập Bán kính
// Thực chất bên trong thủ tục này Hình trụ gọi đến
// phương thức nhập bán kính
// mà cha của nó là Hình tròn đã khai báo
hinhTru.nhapBanKinh();
 
// Hình trụ tự kêu người dùng nhập Chiều cao
hinhTru.nhapChieuCao();
 
// Hình trụ tự nó biết cách tính Chu vi
// Cũng dùng đến phương thức
// tính Chu vi mà cha nó là
// HinhTron đã khai báo
hinhTru.tinhChuVi();
 
// Hình trụ tự nó biết cách tính Diện tích
// Cũng dùng đến phương thức
// tính Diện tích mà cha nó là
// Hình tròn đã khai báo
hinhTru.tinhDienTich();
 
// Hình trụ tự nó biết cách tính Thể tích
hinhTru.tinhTheTich();
 
// Hình trụ tự in kết quả ra console
hinhTru.inChuVi(); // Có gọi đến phương thức in kết quả của cha nó là Hình tròn
hinhTru.inDienTich(); // Có gọi đến phương thức in kết quả của cha nó là Hình tròn
hinhTru.inTheTich();
}

Nếu hướng thủ tục trên kia là tư duy theo kiểu top-bottom. Thì việc hướng đối tượng này, với tiếp cận đầu tiên đến từng đối tượng như Hình tròn, Hình trụ như ví dụ trên, sau đó ta mới xây dựng các thủ tục cho nó, thêm các mối quan hệ, chỉnh sửa các vai trò cho từng đối tượng khi mà yêu cầu hệ thống càng ngày càng phức tạp. Cách suy nghĩ này người ta gọi là tư duy kiểu bottom-up, tức là theo kiểu từ dưới lên, hay còn có thể hiểu là từ cụ thể đến tổng quát.

Vậy thôi! Tư duy theo hướng đối tượng cốt lõi là thế thôi. Bạn đã thấy thích lập trình theo hướng đối tượng rồi đúng không nào. Không thích cũng phải thích nhé, vì nếu không có hướng đối tượng, thì các ứng dụng lớn sau này bạn không thể xây dựng nổi đâu. Trên đây chỉ là ví dụ vắn tắt về hướng đối tượng thôi, mọi thứ râu ria và thế mạnh của nó chúng ta sẽ dần dần nói đến ở các bài học sau nữa nhé.

Vậy Tóm Lại, Tư Duy Theo Hướng Đối Tượng Là Như Thế Nào?

Như bạn đã làm quen trên đây, mục này mình chốt lại thôi. Đó là, đã gọi là hướng đối tượng, tức là mong muốn bạn luôn suy nghĩ mọi thứ theo đối tượng.

Chẳng hạn với yêu cầu xây dựng phần mềm quản lý Sinh viên, thì Trường học, Lớp học, Sinh viên, Giáo viên, thậm chí Bàn ghế, Máy chiếu, Môn học,… đều có thể trở thành đối tượng để chúng ta vận dụng trong việc quản lý. Miễn là đối tượng đó có xuất hiện bên trong ứng dụng của chúng ta.

Hay với yêu cầu xây dựng phần mềm quản lý Tour (như TourNote bên bài học Android), thì Ghi chú, Chủ đề, Tài khoản, Hành trình,… đều là các đối tượng mà bạn có thể tổ chức.

Đối Tượng (Object) & Lớp (Class)

Đối Tượng (Object)

Như bạn cũng đã biết ở bài học khởi đầu hôm trước, đối tượng là các thực thể hay sự vật mà chúng ta có thể cầm nắm, hoặc có thể gọi tên được. Nghe thì có vẻ lan man, nhưng thực chất là… lan man thật. Mình nói đùa thôi, thực chất là vì hướng đối tượng cũng là một cách tư duy mở. Tức là tùy quan niệm của bạn muốn quản lý đến đối tượng nào mà thôi.

Ví dụ như với phần mềm quản lý việc kinh doanh xe hơi. Thì bạn chọn Xe hơi là một đối tượng để quản lý. Nhưng mình nói rằng Khách hàng cũng là một đối tượng. Và Hóa đơn cũng là một đối tượng nữa. Đấy, tất cả những gì xuất hiện trong một ứng dụng, mà bạn muốn chúng là một thực thể để quản lý, thì bạn cứ xem nó là một đối tượng thôi.

Ngoài ra thì nếu bạn đã chọn thực thể nào đó là đối tượng, thì đối tượng đó ắt hẳn phải có các trạng thái và hành vi.

Trạng Thái Của Đối Tượng

Trạng thái của đối tượng nói lên các đặc điểm của đối tượng đó.

Ví dụ, khi bạn “nắm” lấy một đối tượng Xe hơi trong ứng dụng quản lý xe hơi trên đây để xem, thì sẽ thấy các trạng thái của nó như: xe này màu xanh, thuộc hãng Ford, 7 chỗ ngồi. Nhưng nếu bạn “cầm” lấy một chiếc xe khác, sẽ thấy các trạng thái khác, như: xe này màu trắng, thuộc hãng Toyota, 4 chỗ ngồi.

Bạn thấy đấy, tư duy trong lập trình theo hướng đối tượng trông rất thực tế đúng không nào. Bạn nhớ các trạng thái này của đối tượng nhé, chúng ta sẽ cần đến nó ở các ví dụ trong các kiến thức tiếp theo bên dưới.

Hành Vi Của Đối Tượng

Khác với trạng thái là các đặc điểm của đối tượng. Thì hành vi chính là các hành động của đối tượng đó, hay có thể hiểu đó là các hành động mà đối tượng đó có trách nhiệm phải thực hiện.

Như bạn đã làm quen với ví dụ mở màn ở bài trước, khi chúng ta dần chuyển các code tính toán ở main() vào cho đối tượng Hình tròn. Thì các hàm tính toán đó trở thành các hành vi của đối tượng Hình tròn này. Điều này có nghĩa là Hình tròn phải có trách nhiệm thực hiện các hành vi tính Chu vi và Diện tích của nó. Và dĩ nhiên Hình tròn không có trách nhiệm phải thực hiện các hành động tính Chu vi và Diện tích Hình vuông, vì các hành vi này sẽ thuộc về sự quản lý của đối tượng Hình vuông. Bạn đã hiểu rõ hơn về hành vi của từng đối tượng rồi đúng không nào.

Lớp (Class)

Nếu bạn đã nắm về khái niệm đối tượng rồi. Thì với khái niệm lớp sẽ đơn giản hơn một tí. Lớp được xem là một khuôn mẫu để tạo ra các đối tượng. Ngắn gọn vậy thôi đó bạn, nhưng ngắn vậy thì chẳng hiểu gì cả. Thực chất không khó lắm đâu, bạn nhất định phải phân biệt giữa lớp và đối tượng. Chúng ta cùng đi từ từ qua các ví dụ sau.

Ví Dụ 1: Làm Bánh

Nào chúng ta hãy quên đi kiến thức lập trình một chút, hãy bắt tay vào làm các bánh quy bơ như sau. Giả sử bạn đã biết hết các công thức làm bánh. Và mình nhờ bạn giúp mình làm ra chúng với hình trái tim như này để mình mang đi tặng “người ấy”.

Ví dụ làm bánh
Ví dụ làm bánh

Nhưng bạn lại nói rằng biết công thức thì chưa đủ. Nếu muốn bạn làm chính xác các bánh có hình thù như vậy, thì hãy đưa bạn cái khuôn. Vâng, thì đây là cái khuôn.

Ví dụ khuôn mẫu khi làm bánh
Ví dụ khuôn mẫu khi làm bánh

Đó không phải câu chuyện về tặng “người ấy” như thế nào, mà là câu chuyện giữa bánh quy và khuôn. Bạn hãy đến Ví dụ 2 trước khi mình giải thích chúng.

Ví Dụ 2: Thiết Kế Các Mẫu Xe Hơi

Mình muốn các bạn đi lan man một chút qua ví dụ kế tiếp. Giả sử bạn là một họa sĩ 3D, chuyên về vẽ texture hoàn thiện hình ảnh cho xe hơi. Với yêu cầu rằng bạn hãy cho ra thành phẩm là những chiếc xe hơi với các màu như sau.

Ví dụ thiết kế xe hơi
Ví dụ thiết kế xe hơi

Ồ tất nhiên bạn sẽ làm được thôi. Nhưng trước hết bạn yêu cầu một bản vẽ concept (hay đại loại một ý tưởng gì đấy) về mẫu xe hơi này, thì bạn mới có thể khoác lên chúng các màu sắc được chứ. Vâng mình cũng đã chuẩn bị cho bạn, bản vẽ concept đây.

Ví dụ khuôn mẫu của thiết kế xe hơi
Ví dụ khuôn mẫu của thiết kế xe hơi

Vậy đó là câu chuyện về các thành phẩm bản vẽ xe hơi và bản vẽ concept.

Những ví dụ trên đây mang lại một thông điệp gì. Mình muốn các bạn hình dung từ thực tế rằng, các đối tượng mà chúng ta cần quan tâm đến từ các ví dụ này chính là những thứ hữu hình có thể nhìn thấy hay tương tác được từ phía người dùng. Trong đó thì các thành phẩm bánh quy và bản vẽ xe hơi chính là các đối tượng. Các đối tượng này được tạo ra dựa trên các khuôn mẫu của chúng, chính là cái khuôn bánh và bản vẽ concept, chúng chính là các lớp.

Ví Dụ 3: Làm Phần Mềm Quản Lý Sinh Viên

Giờ thì không ví dụ lan man nữa. Chúng ta quay về lĩnh vực lập trình với ví dụ về quản lý Sinh viên. Giả sử trong ngôi trường mà chúng ta cần quản lý có các Sinh viên sau.

Ví dụ xây dựng phần mềm quản lý sinh viên
Ví dụ xây dựng phần mềm quản lý sinh viên

Áp dụng từ các ví dụ 1 và 2. Sinh viên chắc chắn là đối tượng cần quản lý rồi. Vậy đâu sẽ là khuôn mẫu cho các đối tượng Sinh viên này. Trong trường hợp này, cái Khuôn mẫu ấy hơi trừu tượng xíu, nó sẽ là một hình tượng chung chung với các nhãn như: Giới tínhTênLớpTuổiQuê quánChiều caoCân nặng. Như hình bên dưới.

Ví dụ khuôn mẫu (hay lớp) trong phần mềm quản lý sinh viên
Ví dụ khuôn mẫu (hay lớp) trong phần mềm quản lý sinh viên

Từ khái khuôn mẫu đó, hay ta đã biết nó là lớp, nó sẽ giúp tạo ra nhiều đối tượng Sinh viên khác nhau. Như John-Giới tính Nam-22 tuổi-Lớp Java. Hay Elly-Giới tính Nữ-21 tuổi-Lớp Android,…

Vậy bạn đã hiểu khái niệm lớp là một khuôn mẫu để tạo ra các đối tượng rồi đúng không nào. Và nếu như đối tượng có các trạng thái và hành vi, thì lớp cũng có các thuộc tính và phương thức tương ứng.


Khai Báo Lớp

Từ bài hôm trước tới giờ chúng ta chỉ lan man nói và nói. Giờ thì đến lúc code rồi. Nhưng trước khi đi vào cách thức code một Lớp, thì hãy cùng nhau đi qua phần này trước.

Hình Dung Về Lớp

Khi bạn muốn đưa một thực thể nào đó về một đối tượng để quản lý, thì bạn phải tạo khuôn mẫu cho đối tượng đó. Đó chính là lớp. Và trước khi bắt tay vào tạo một lớp, bạn phải hình dung lớp đó với ba thành phần chính như biểu diễn sau.

Các thành phần của lớp
Các thành phần của lớp

Khai Báo Lớp

Một khi bạn đã hình dung ra một lớp với ba thành phần như trên, thì việc biểu diễn ra thành code Java sẽ theo cú pháp sau.

class tên_lớp {
     các_thuộc_tính;
     các_phương_thức;
}

Trong đó:

  • class là một keyword cho biết bạn đang khai báo một lớp.
  • tên_lớp là tên định danh cho lớp đó, quy luật đặt tên cho lớp cũng giống như quy luật đặt tên cho BiếnNhưng bạn lưu ý một tí là tên_lớp này nên viết in hoa hết từng chữ đầu của mỗi từ nhé, ví dụ như HinhTron, SinhVien, HoaDon,… để mà phân biệt với tên của đối tượng sẽ được nói ở phần dưới đây.
  • các_thuộc_tính và các_phương_thức mình cũng đã nhắc đến trên đây rồi. Nhưng các bài sau mình sẽ nói rõ hơn các định nghĩa này.

Bài Thực Hành Số 1

Bài thực hành này chúng ta sẽ tiến hành xây dựng lớp Hình tròn mà ở bài hôm trước mình có ví dụ cho các bạn xem.

Bài thực hành này không đòi hỏi các bạn phải hiểu hết tất cả các dòng code, các bạn chỉ cần nhìn vào cấu trúc tổng quát của lớp Hình tròn như những gì mình nói đến ở bài hôm nay. Tất cả những chi tiết đắt giá của lớp sẽ được mình nói tiếp ở các bài học sau.

Chúng ta tạm thời code vào chung với lớp đang chứa đựng main(), và bên dưới main() này bạn khai báo HinhTron như sau.

public class MyFirstClass {
public static void main(String[] args) {
}
 
// Khai báo một Lớp Hình tròn
class HinhTron {
// Dưới đây là các Thuộc tính
final float PI = 3.14f;
float r;
float cv;
float dt;
 
// Dưới đây là các Phương thức
void nhapBanKinh() {
System.out.println("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
r = scanner.nextFloat();
}
 
void tinhChuVi() {
cv = 2 * PI * r;
}
 
void tinhDienTich() {
dt = PI * r * r;
}
 
void inChuVi() {
System.out.println("Chu vi Hình tròn: " + cv);
}
 
void inDienTich() {
System.out.println("Diện tích Hình tròn: " + dt);
}
}
}

Mình nói thêm một chút. Với khai báo một lớp như trên thì:

  • tên_lớp lúc này chính là HinhTron.
  • các_thuộc_tính bao gồm PIrcvdt.
  • các_phương_thức bao gồm nhapBanKinh()tinhChuVi()tinhDienTich()inChuVi()inDienTich().

Khai Báo Và Khởi Tạo Đối Tượng

Khai Báo Đối Tượng

Bạn đã biết lớp chính là khuôn mẫu để tạo nên đối tượng. Và Bài thực hành số 1 trên kia bạn cũng đã tạo ra một lớp HinhTron. Vậy thì từ lớp HinhTron này bạn hoàn toàn có thể tạo ra các đối tượng khác nhau như sau.

HinhTron hinhTron1;
HinhTron hinhTron2;
HinhTron hinhTron3;
...
HinhTron hinhTronX;

Ồ, hẳn bạn có ngay so sánh, khi chúng ta làm quen đến biến, chúng ta cũng có các khai báo tương tự với các kiểu dữ liệu nguyên thủy.

int count;
int index;
boolean result;

Vậy thì khai báo một biến kiểu đối tượng và khai báo một biến kiểu nguyên thủy có khác nhau hay không. Câu trả lời là có, thậm chí chúng còn có một sự khác biệt lớn.

Với kiểu dữ liệu nguyên thủy, như bạn cũng đã biết, khi bạn khai báo một biến, giá trị của biến được lưu trữ vào trong chính biến đó, bạn hãy tưởng tượng việc lưu trữ này được mô tả theo sơ đồ như sau.

Sơ đồ minh họa dữ liệu được lưu trữ trong kiểu nguyên thủy
Sơ đồ minh họa dữ liệu được lưu trữ trong kiểu nguyên thủy

Sơ đồ thể hiện giá trị mặc định của kiểu số là 0 và boolean là false. Nếu chúng ta gán giá trị khác vào biến thì chúng ta vẫn hiểu là giá trị đó cũng được lưu vào biến như sơ đồ trên.

Nhưng khi bạn khai báo các đối tượng của lớp, hệ thống không tạo ra một biến có chứa giá trị là một đối tượng, mà sẽ tạo ra một tham chiếu đến đối tượng cụ thể. Như các code trên đây khi tạo các đối tượng hình tròn sẽ được minh họa như sau.

Sơ đồ minh họa dữ liệu được lưu trữ trong kiểu đối tượng khi mới khai báo
Sơ đồ minh họa dữ liệu được lưu trữ trong kiểu đối tượng khi mới khai báo

Giá trị null trong sơ đồ trên biểu thị rằng các đối tượng hinhTron1hinhTron2,… tuy có kiểu dữ liệu là HinhTron, nhưng hiện tại các đối tượng này đang mang giá trị là các tham chiếu, mà các tham chiếu này không trỏ đến bất kỳ đối tượng nào (hay hiểu tham chiếu không có giá trị), nên được gán bằng giá trị null. Chúng ta sẽ nói rõ về giá trị null sau, nhưng bạn cũng nên biết rằng, với tham chiếu là null, các đối tượng chúng ta tạo ra chưa sử dụng được.

Ví dụ bạn code như sau sẽ bị báo lỗi.

HinhTron hinhTron1;
hinhTron1.nhapBanKinh();

Chúng ta hãy đến bước tiếp theo để có thể sử dụng các kiểu đối tượng đã khai báo này.

Khởi Tạo Đối Tượng

Đối tượng sẽ được khởi tạo giá trị, hay như biểu đồ trên bạn hiểu là làm cho tham chiếu trỏ đến một giá trị đối tượng cụ thể, bằng cách sử dụng từ khóa 

new

, kèm với phương thức khởi tạo (constructor) của lớp mà chúng ta sẽ nói ở bài học này sau.

Vậy với các khai báo đối tượng hình tròn ở ví dụ trên chúng ta khởi tạo chúng như sau.

HinhTron hinhTron1 = new HinhTron();
HinhTron hinhTron2 = new HinhTron();
HinhTron hinhTron3 = new HinhTron();
...
HinhTron hinhTronX = new HinhTron();

Code trên sẽ tạo ra các đối tượng hinhTron1hinhTron2,… được tạo ra từ một khuôn mẫu chính là lớp HinhTron, được mang giá trị chính là các tham chiếu đến các đối tượng cụ thể. Khi đó sơ đồ được hiểu như sau.

Sơ đồ minh họa dữ liệu được lưu trữ trong kiểu đối tượng khi đã khởi tạo
Sơ đồ minh họa dữ liệu được lưu trữ trong kiểu đối tượng khi đã khởi tạo

Đến đây chắc bạn đã hiểu rõ vấn đề rồi đúng không nào. Giờ thì bạn hãy nhìn lại các code chúng ta đã dùng để khai báo các đối tượng để mà sử dụng từ trước đến giờ xem. Không cần tìm đâu xa, ngay cả trong bài thực hành trên cũng có đấy thôi, chính là dòng khai báo đối tượng scanner từ lớp Scanner (constructor của lớp Scanner có truyền thêm tham số 

System.in

 có hơi khác với constructor của HinhTron chúng ta vừa làm quen, đến bài về constructor bạn sẽ hiểu rõ thôi).

void nhapBanKinh() {
System.out.println("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
r = scanner.nextFloat();
}

Bài Thực Hành Số 2

Với lớp HinhTron đã khai báo ở Bài thực hành số 1. Bây giờ bạn hãy thử khai báo và khởi tạo đối tượng Hình tròn bên trong main() như sau.

public static void main(String[] args) {
// Khai báo Đối tượng Hình tròn và ra lệnh cho đối tượng này thực hiện
// các yêu cầu
HinhTron hinhTron = new MyFirstClass().new HinhTron();
 
// Hình tròn tự kêu người dùng nhập Bán kính
hinhTron.nhapBanKinh();
 
// Hình tròn tự nó biết cách tính Chu vi
hinhTron.tinhChuVi();
 
// Hình tròn tự nó biết cách tính Diện tích
hinhTron.tinhDienTich();
 
// Hình tròn tự in kết quả ra console
hinhTron.inChuVi();
hinhTron.inDienTich();
}

Một lần nữa đừng chú trọng quá vào cách thức sử dụng các phương thức trong một đối tượng, bạn sẽ quen với đối tượng sớm thôi.

Bạn chỉ cần chú ý một số chỗ.

  • Dòng code 
    HinhTron hinhTron = new
     chính là nơi bạn khai báo một đối tượng là hinhTronĐối tượng hinhTron này được tạo ra từ Lớp HinhTron. Như bạn biết rằng bạn có thể tạo ra bao nhiêu đối tượng Hình tròn từ lớp HinhTron đều được cả. Thậm chí bạn có thể tạo ra mảng các đối tượng Hình tròn mà chúng ta sẽ xem xét ở một bài khác.
  • Từ khóa new như mình nói là bắt buộc khi khai báo và khởi tạo các kiểu dữ liệu đối tượng này, mục đích là giúp cho đối tượng được khai báo có tham chiếu rõ ràng đến giá trị của đối tượng.
  • Các dòng code còn lại là các dòng gọi đến các thuộc tính hay các phương thức của đối tượng đó.

Giờ đây nếu thực thi dòng code này thì bạn đã có thể tương tác với console được rồi đấy nhé.

Thuộc Tính Của Lớp

Chúng ta đã cùng nhau đi qua các bài giới thiệu về lập trình hướng đối tượng, và chắc chắn các bạn cũng đã hiểu rõ thế nào là Đối tượng và thế nào là Lớp rồi phải không. Chúng ta sẽ còn nhiều bài học và bài tập thú vị liên quan đến các kiến thức hướng đối tượng này ở phía trước, và tất cả chúng đều xoay quanh hai khái niệm trọng tâm, đó là đối tượng và lớp.

Từ bài học hôm nay chúng ta sẽ bắt đầu đi chi tiết vào từng khái niệm và cách sử dụng của các thành phần bên trong một lớp. Và mình đã rất mong muốn nói hết kiến thức về thuộc tính và phương thức của một lớp ở trong bài học hôm nay. Tuy nhiên, trong khi từ từ diễn đạt các vấn đề liên quan đến thuộc tính trước, mình mới ngỡ ra là có rất nhiều thứ để nói về nó. Thế là mình chỉ đủ “giấy mực” để nói đến thuộc tính mà thôi, hẹn các bạn bài sau sẽ là sân khấu cho phương thức nhé.

Trước khi đi vào chi tiết về thuộc tính, chúng ta hãy cùng nhau tạo một project mới.

Thực Hành Tạo Một Project Mới

Chắc chắn bạn có thắc mắc, rằng bài hôm trước chúng ta đã thực hành việc khai báo một lớp rồi, sao không dùng lớp đó mà học tiếp cho bài hôm nay. Tuy nhiên, mình cũng muốn nói rằng, hôm trước bạn đã thử tạo lớp HinhTron cùng cấp với main(), mà như bạn đã biết thì main() này lại đang nằm trong một lớp khác, cụ thể lớp này có tên MyFirstClass mà chúng ta đã tạo từ thuở nảo thuở nào, từ bài học số 3 lận, hi vọng bạn còn nhớ.

Như vậy túm lại là lớp HinhTron mà bạn đã tạo từ bài trước đang nằm trong lớp MyFirstClass, điều này bạn chưa được tiếp cận, nó liên quan đến kiến thức lớp lồng (inner class hay nested class) mà chúng ta sẽ nói đến sau. Để hỗ trợ tốt việc quản lý lớp bên trong các ứng dụng, InteliJ hay các IDE khác đều hỗ trợ bạn tạo nhiều lớp khác nhau mà mỗi lớp như vậy sẽ tách biệt nhau bởi một file .java. Và bài thực hành hôm nay chúng ta sẽ bắt đầu làm quen với việc quản lý các lớp một cách tách biệt như vậy.

Tạo Project Mới

Bạn hãy xem lại mục này của bài 3 để biết cách tạo mới một project và tạo các class với InteliJ nhé. Sau đó hãy tự tạo một project mới có tên OOPLearning. Rồi cũng hãy tự tạo lớp MainClass. Đảm bảo có phương thức main() bên trong lớp MainClass. Lớp HinhTron thì sẽ được tạo ở mục thực hành kế tiếp bên dưới.

Hình ảnh của project mới, lớp MainClass.java và code của main() được diễn tả bằng hình sau.

Tạo một project mới với lớp MainClass và phương thức main()
Tạo một project mới với lớp MainClass và phương thức main()

Như bạn biết, một lớp sẽ bao gồm các thuộc tính và phương thức. Và như mình nói đó, do không đủ giấy mực để nói hết hai thành phần này trong bài hôm nay, nên mình sẽ nói các vấn đề liên quan đến khai báo và sử dụng thuộc tính trước.

Nhắc lại các thành phần của lớp
Nhắc lại các thành phần của lớp

Thuộc Tính Của Lớp

Thuộc tính hay còn gọi là field. Như bạn đã biết, các thuộc tính của lớp sẽ giúp tạo ra các trạng thái, hay các đặc điểm của các đối tượng được tạo ra từ lớp này.

Để khai báo một thuộc tính thì chúng ta có cú pháp sau.

[khả_năng_truy_cập] kiểu_thuộc_tính tên_thuộc_tính [= giá_trị_ban_đầu];

Ngoại trừ khả_năng_truy_cập sẽ được nói đến ở bài học sau (vì thành phần này nằm trong cặp ngoặc vuông, có nghĩa là có khai báo nó hay không thì cũng sẽ không bị hệ thống báo lỗi, do đó hôm nay mình sẽ chưa nói đến nó). Thì các thành phần còn lại bạn khai báo hoàn toàn giống với cách khai báo một biến (hay hằng). Nếu bạn không tin thì có thể xem lại bài viết về biến và hằng nhé.

Thực Hành Tạo Các Thuộc Tính

Chúng ta tiếp tục tạo mới lớp HinhTron như ở bài thực hành hôm trước. Tuy nhiên ở bài này bạn hãy thực hành việc tạo một lớp tách biệt ra với lớp MainClass.

Tạo Mới Lớp HinhTron

Chi tiết lớp HinhTron này khi tạo xong sẽ như hình bên dưới đây.

Tạo mới lớp HinhTron
Tạo mới lớp HinhTron

Thêm Thuộc Tính Vào Lớp HinhTron

Bạn hãy thêm các thuộc tính sau vào lớp HinhTron vừa mới tạo.

Khai báo các thuộc tính cho HinhTron
Khai báo các thuộc tính cho HinhTron

Code của nó như sau.

public class HinhTron {
final float PI = 3.14f;
 
float r;
float cv;
float dt;
}

Mọi thứ đều như bài học hôm trước. Tuy nhiên với bài hôm nay bạn đã hiểu hơn về quy tắc khai báo các thuộc tính đúng không nào.

Mối Liên Hệ Giữa Thuộc Tính Và Biến

Qua bài thực hành trên thì bạn có thể thấy rằng các thuộc tính mà bạn khai báo bên trong một lớp, chúng không khác gì so với các biến bên trong một phương thức (chẳng hạn như trong phương thức main() mà bạn đã làm quen từ các bài trước) đúng không nào.

Tuy nhiên thuộc tính là thuộc tính, và biến là biến. Chúng không phải là một. Có vài tài liệu gọi rõ ra biến với cái tên là biến local, hay biến cục bộ (local variable). Để giúp phân biệt rõ hơn với từ thuộc tính, còn được gọi là thuộc tính global, hay thuộc tính toàn cục (global field). Vậy mục này mình sẽ giúp các bạn nắm rõ hơn về thuộc tính thông qua các mối liên hệ giữa thuộc tính của lớp và biến của phương thức nhé.

Mối Liên Hệ Thứ Nhất

Khai báo chúng rất giống nhau. Như mình có trình bày trên kia, ngoại trừ khả_năng_truy_cập sẽ được nói ở bài sau ra, thì khai báo của chúng được khai báo với một nguyên tắc như nhau.

public class HinhTron {
/**
* Demo sự giống nhau giữa Thuộc tính và Biến
* nếu như không có thành phần khả_năng_truy_cập trước
* các thuộc tính
*/
 
// Thuộc tính
final float PI = 3.14f;
float r;
float cv;
float dt;
 
void tinhChuVi() {
// Biến
float banKinh = 10;
 
cv = 2 * PI * banKinh;
}
}

Mối Liên Hệ Thứ Hai

Biến chỉ được sử dụng cục bộ bên trong khối lệnh của phương thức, trong khi thuộc tính được sử dụng toàn cục.

Và bởi vì biến chỉ được sử dụng cục bộ bên trong khối lệnh của phương thức, nên khi một phương thức thực thi hết các dòng code của nó, thì biến này cũng sẽ bị xóa khỏi bộ nhớ. Như vậy biến bên trong phương thức chỉ dùng để tính toán tạm mà thôi.

Còn thuộc tính thì được dùng toàn cục, nên khi một phương thức kết thúc, thuộc tính này vẫn sẽ được lưu giữ giá trị cuối cùng của nó. Như ví dụ bên dưới, thuộc tính r sẽ được mang giá trị là 10 sau khi bạn gọi đến tinhDienTich(). Điều này cũng đã được bạn kiểm chứng từ thực hành của bài trước, khi mà bạn gọi liên tiếp các phương thức hinhTron.nhapBanKinh() rồi hinhTron.tinhChuVi(),… hinhTron.inChuVi().

public class HinhTron {
 
/**
* Demo cách sử dụng toàn cục của thuộc tính,
* và cách sử dụng cục bộ của biến
*/
 
// Thuộc tính
final float PI = 3.14f;
float r;
float cv;
float dt;
 
void tinhChuVi() {
// Biến banKinh định nghĩa ở đây chỉ dược sử
// dụng cục bộ trong phương thức này
float banKinh = 10;
 
cv = 2 * PI * banKinh;
}
 
void tinhDienTich() {
// r là thuộc tính của lớp, được sử dụng thoải mái
// trong các phương thức
r = 10;
 
// Dĩ nhiên dt và PI cũng là thuộc tính toàn cục
dt = PI * r * r;
}
}

Mối Liên Hệ Thứ Ba

Chuyện gì xảy ra nếu bạn định nghĩa một biến có cùng tên với một thuộc tính?

Khi này hệ thống sẽ giúp bạn xác định đâu là biến cục bộ, còn đâu là thuộc tính toàn cục. Bạn có thể xem ví dụ sau. Khi biến r được khai báo bên trong phương thức, dù trùng tên với thuộc tính r, nhưng r trong tinhChuVi() vẫn được hiểu là biến cục bộ đấy nhé.

public class HinhTron {
 
/**
* Demo khi một biến trong một phương thức trùng tên
* với một thuộc tính trong một lớp
*/
 
// Thuộc tính
final float PI = 3.14f;
float r;
float cv;
float dt;
 
void tinhChuVi() {
// Biến r này là biến cục bộ, vì nó được
// khai báo lại, dù cho nó trùng tên
float r = 10;
 
cv = 2 * PI * r;
}
 
void tinhDienTich() {
// Thuộc tính r này là toàn cục
r = 15;
 
dt = PI * r * r;
}
}

Cũng ở mối liên hệ này, mình cũng muốn nói đến một vấn đề nhỏ nữa. Đó là nhiều khả năng sau này trong một phương thức, bạn đã khai báo một biến trùng tên với thuộc tính của lớp rồi, nhưng… trớ trêu thay bạn vẫn sẽ muốn lúc thì dùng biến, lúc thì dùng thuộc tính với cùng một tên ấy. Để giải quyết tình trạng này, Java có hỗ trợ từ khóa thisthis có ý nói: chính là đối tượng này. Đó là lý do vì sao bảng các keyword mà bạn làm quen ở bài 4 ngày xưa có liệt kê từ khóa this vào đó. Bạn xem ví dụ sử dụng this như sau. Cách sử dụng cụ thể của từ khóa this sẽ được mình nói ở bài này.

public class HinhTron {
 
/**
* Demo khi một biến trong một phương thức trùng tên
* với một thuộc tính trong một lớp. Nhưng chúng ta
* có thể dùng từ khóa this để chỉ định đến thuộc tính của lớp.
*/
 
// Thuộc tính
final float PI = 3.14f;
float r;
float cv;
float dt;
 
void tinhChuVi() {
// Biến r cục bộ
float r = 10;
 
// this.r lại là thuộc tính, dòng code này
// gán giả trị của biến r vào thuộc tính r
this.r = r;
 
// r trong phép tính này là biến r cục bộ đấy nhé
cv = 2 * PI * r;
}
}

Truy Xuất Đến Thuộc Tính Của Lớp Từ Bên Ngoài

Khi bạn ở đâu đó bên ngoài một lớp, và muốn truy xuất đến các thuộc tính của lớp đó, như bạn cũng đã biết đâu đó qua các bài thực hành trước, đó là bạn có thể sử dụng toán tử “.”.

Mình ví dụ với lớp HinhTron mà chúng ta đã khai báo ở trên kia, giả sử ở main() khi chúng ta đã khai báo đối tượng hinhTron, chúng ta có thể truy xuất đến các thuộc tính của hinhTron như sau.

public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron();
hinhTron.r = 10.0f;
}
}

Tuy nhiên code trên đây chỉ là một ví dụ về cách truy xuất đến thuộc tính của lớp thôi. Theo nguyên tắc của gói ghém dữ liệu bên trong các lớp của OOP, thì một lớp sẽ không cho phép bên ngoài truy xuất đến các thuộc tính của nó một cách trực tiếp như vậy, bằng việc định nghĩa các khả_năng_truy_cập mà mình đã nói ở trên kia. Và chắc chắn chúng ta sẽ nói đến điều này khi giải thích cụ thể từng khả_năng_truy_cập ở bài sau nhé.

Phương Thức Của Lớp

Phương Thức Của Lớp

Phương thức, hay còn gọi là method. Một số tài liệu khác gọi là hàm. Các phương thức chính là các hành động của một lớp. Sau này khi các đối tượng được tạo ra từ lớp, thì các hành động này cũng chính là các hành động hay các hành vi của đối tượng đó.

Cú pháp của một phương thức như sau.

[kả_năng_truy_cập] kiểu_trả_về tên_phương_thức ( [tham_số] ) {
     // Các dòng code
}
  • khả_năng_truy_cập mình sẽ nói ở bài sau, tuy nhiên, bạn cũng nên biết khả_năng_truy_cập của một phương thức hoàn toàn giống với khả_năng_truy_cập của thuộc tính ở bài học trước, nên chúng ta sẽ chỉ cần nói một lần cho cả hai ở bài học sau mà thôi.
  • tên_phương_thức là tên của phương thức, các quy tắc đặt tên cho phương thức tương tự như quy tắc đặt tên cho tên_thuộc_tính ở bài hôm trước, hay tên_biến ở bài học về biến và hằng.
  • tham_số là các đối số truyền vào cho phương thức, sẽ được nói rõ trong bài học hôm nay.

Thực Hành Tạo Các Phương Thức

Tiếp tục với project OOPLearning hôm trước, khi mà chúng ta đã tạo một lớp HinhTron ở một file .java tách biệt, rồi thêm vào cho lớp này các thuộc tính như những gì chúng ta cùng nhau thực hành ở bài trước nữa, bài học số 16.

Hôm nay chúng ta sẽ thêm vào các phương thức quen thuộc từ bài học số 16 mà chúng ta cũng đã làm quen. Một lần nữa có thể bạn sẽ chưa hiểu hết từng thành phần trong khai báo một phương thức, bạn chỉ đã hiểu tường tận các dòng code bên trong từng phương thức mà thôi. Nhưng không sao, bạn cứ code đi nhé, chúng ta sẽ dần quen thuộc với phương thức ở các phần sau của bài học hôm nay.

Và code của bài hôm trước sẽ trông như thế này của bài học hôm nay (tuy hình ảnh này được chụp từ Eclipse nhưng bạn có thể code tương tự nếu dùng InteliJ).

Hãy thêm các dòng định nghĩa các phương thức cho lớp HinhTron
Hãy thêm các dòng định nghĩa các phương thức cho lớp HinhTron

Kiểu Trả Về Của Phương Thức

Khi khai báo một phương thức, bạn buộc phải chỉ định một kiểu_trả_về. Kiểu trả về này thường là kết quả cuối cùng mà phương thức đó thực hiện, nó có thể là một kiểu dữ liệu nguyên thủy, nó có thể là một giá trị boolean, nó có thể là một mảng, hoặc thậm chí nó có thể là một đối tượng. Mục đích của việc trả về này, là giúp cho các đối tượng nào đó bên ngoài lớp, hoặc các phương thức khác bên trong lớp, có thể nhận được kết quả đó để thực hiện một mục đích nào đó. Chúng ta có thể xem kết quả trả về là “đầu ra” của phương thức, khi mà “đầu vào” chính là tham_số mình sẽ nói ở mục sau.

Và như mình có nói, một phương thức bắt buộc phải khai báo một kiểu_trả_về. Khi bắt đầu trả về một kết quả, chúng ta dùng từ khóa return. Bạn xem các ví dụ sau để hiểu rõ cách sử dụng của lệnh này.

Thực chất các kiểu_trả_về đều có cách sử dụng như nhau, nhưng với từng ví dụ sau mình sẽ tách các kiểu_trả_về ra làm từng mục, để các bạn dễ tiếp cận.

Ví Dụ Kiểu Trả Về Là Một Kiểu Nguyên Thủy

Bạn có còn nhớ các kiểu dữ liệu nguyên thủy là gì không, nếu quên thì xem lại mục Kiểu Dữ Liệu Của Biến ở bài học số 4 nhé.

Với ví dụ ở mục này, mình xây dựng ba phương thức bên trong lớp HinhTron có khai báo các kiểu_trả_về, bạn chú ý các source code có comment nhé.

public class HinhTron {
 
/**
* Demo các phương thức có khai báo kiểu trả về.
* Bạn hãy chú ý các phương thức sau:
* - getBanKinh()
* - tinhChuVi()
* - vongTronLon()
*/
 
final float PI = 3.14f;
 
float r;
float cv;
float dt;
 
void nhapBanKinh() {
System.out.println("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
r = scanner.nextFloat();
}
 
void tinhDienTich() {
dt = PI * r * r;
}
 
// Phương thức này có kiểu trả về là một dữ liệu kiểu float,
// bạn chú ý dòng code return, dòng này chỉ định giá trị mà hàm sẽ trả về,
// giá trị trả về phải cùng kiểu với khai báo trả về của hàm.
float getBanKinh() {
return r;
}
 
// Phương thức tinhChuVi() được chuyển sang có kiểu trả về.
// Bạn có thể để bao nhiêu dòng code trước khi quyết định gọi return.
float tinhChuVi() {
cv = 2 * PI * r;
return cv;
}
 
// Phương thức này trả về kết quả là một kiểu boolean,
// nếu bán kính lớn hơn 10 sẽ trả về true (vòn tròn lớn),
// ngược lại sẽ trả về false (vòn tròn không lớn).
// Bạn thiết kế bao nhiêu return bên trong một hàm có kiểu trả về cũng được,
// chỉ cần đảm bảo kết thúc một hàm kiểu này luôn có return là được.
boolean vongTronLon() {
if (r > 10) {
return true;
} else {
return false;
}
}
}

Và rồi bước tiếp theo ở hàm main() bên trong lớp MainClass, chúng ta sẽ sử dụng kết quả trả về của các hàm bên trong lớp HinhTron để làm một vài tác vụ nào đó (như in ra console chẳng hạn).

public class MainClass {
 
public static void main(String[] args) {
// Khai báo đối tượng hinhTron, từ lớp HinhTron
HinhTron hinhTron = new HinhTron();
 
// hinhTron kêu người dùng nhập bán kính và lưu lại
hinhTron.nhapBanKinh();
 
// Nếu hinhTron có bán kính lớn, in ra lỗi. Ngược lại sẽ tính chu vi hinhTron đó
if (hinhTron.vongTronLon()) {
// Lấy kết quả trả về từ phương thức getBanKinh() của HinhTron ra dùng
System.out.println("Hình tròn có bán kính " + hinhTron.getBanKinh() + " quá lớn!");
} else {
// Lấy kết quả trả về từ phương thức tinhChuVi() của HinhTron ra dùng
float chuvi = hinhTron.tinhChuVi();
System.out.println("Chu vi Hình tròn: " + chuvi);
}
}
 
}

Đơn giản đúng không. Bạn chỉ cần nhớ một số nguyên tắc khi khai báo kiểu_trả_về của một phương thức:

  • Nếu như phương thức có khai báo kiểu_trả_về, thì hiển nhiên phương thức đó phải kết thúc với một hoặc nhiều câu lệnh return. Nếu không có return đối với phương thức này, hệ thống sẽ báo lỗi đấy nhé.
  • kiểu_trả_về của phương thức phải cùng kiểu với biểu thức (hoặc giá trị) sau câu lệnh return, như bạn đã thấy ở ví dụ trên. Nếu khác nhau về kiểu, sẽ bị báo lỗi luôn.
  • Bạn có thể ép kiểu tường minh một biểu thức (hoặc giá trị) trước khi return nó. Mình làm tạm một ví dụ sau.
/**
* Demo việc ép kiểu một biểu thức (hoặc một giá trị)
* trước khi return nó
*/
int getBanKinh() {
return (int) r;
}
  • Với một phương thức có khai báo kiểu_trả_về, bạn có thể không cần sử dụng đến kiểu trả về của phương thức đó khi gọi đến từ bên ngoài. Mình lấy ví dụ trên đây, hàm tinhChuVi() có khai báo kiểu trả về là một float, nhưng ở hàm main() bạn có thể không cần dùng đến kiểu trả về này. Mình minh họa bằng code như sau.
hinhTron.tinhChuVi();
hinhTron.inChuVi();

Ví Dụ Kiểu Trả Về Là Một Đối Tượng

Với ví dụ này, mọi thứ sẽ không khác gì so với bạn khai báo một kiểu_trả_về là một biến nguyên thủy cả, chỉ là lần này bạn thử trả về là một đối tượng. Bạn xem ví dụ bên dưới.

Giả sử HinhTron mà chúng ta xây dựng sẽ được vẽ đâu đó lên màn hình, như vậy phải cần người dùng nhập vào tọa độ (x, y) của Hình tròn đó. Và như tư duy của Hướng đối tượng, mình chỉ định Tọa độ cũng là một đối tượng cần quản lý. Đầu tiên mình sẽ định nghĩa một lớp có tên ToaDo, lớp này có hai thuộc tính là x và y, và không có phương thức nào cả. Bạn thử tạo xem nhé.

Thử tạo thêm một lớp ToaDo
Thử tạo thêm một lớp ToaDo

Sau đó ở lớp HinhTron, mình khai báo một thuộc tính với kiểu dữ liệu là ToaDoHinhTron sẽ hỗ trợ nhập tọa độ xy vào từ console. Và cuối cùng là hàm getToaDo() trả ra ngoài kiểu dữ liệu là ToaDo.

public class HinhTron {
 
/**
* Demo phương thức có khai báo kiểu trả về là một đối tượng.
* Bạn hãy chú ý đến phương thức:
* - getToaDo()
*/
 
float r;
ToaDo toaDo;
 
// Hàm này quen thuộc từ mấy bài học rồi nhé
void nhapBanKinh() {
System.out.println("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
r = scanner.nextFloat();
}
 
// Hàm này giúp nhập tọa độ vào lớp ToaDo, bạn nhớ khởi tạo nó nhé
void nhapToaDo() {
// Bạn phải khởi tạo lớp ToaDo, hay bất kỳ lớp nào bằng từ khóa new trước khi sử dụng
toaDo = new ToaDo();
Scanner scanner = new Scanner(System.in);
 
System.out.println("Hãy nhập vào Tọa độ Hình tròn: ");
System.out.println("x = ");
toaDo.x = scanner.nextInt();
 
System.out.println("y = ");
toaDo.y = scanner.nextInt();
}
 
float getBanKinh() {
return r;
}
 
// Phương thức này trả về một đối tượng ToaDo
ToaDo getToaDo() {
return toaDo;
}
 
}

Còn ở hàm main(), mình thử xuất ra xem kết quả nhập tọa độ vào cho HinhTron có đúng chưa.

public class MainClass {
 
public static void main(String[] args) {
// Khai báo đối tượng hinhTron, từ lớp HinhTron
HinhTron hinhTron = new HinhTron();
 
// hinhTron kêu người dùng nhập bán kính và lưu lại
hinhTron.nhapBanKinh();
 
// hinhTron kêu người dùng nhập tọa độ x, y và lưu lại
hinhTron.nhapToaDo();
 
// Xuất kết quả mà người dùng vừa nhập
float banKinh = hinhTron.getBanKinh();
ToaDo toaDo = hinhTron.getToaDo(); // Không cần từ khóa new, vì đối tượng đã được khởi tạo bên trong HinhTron rồi
System.out.println("Bạn vừa nhập một Hình tròn có Bán kính: " + hinhTron.getBanKinh() + "\n" +
"Và tọa độ:\n" +
"- x = " + toaDo.x + "\n" +
"- y = " + toaDo.y);
}
 
}

Ví Dụ Kiểu Trả Về Là void

Chắc chắn là các bạn sẽ thắc mắc nhiều lắm nếu mình không đề cập đến mục này. Vì trong quá trình thực hành bạn nhìn thấy kiểu trả về nhiều nhất đó chính là kiểu void!?!

Như mình có khẳng định chắc nịch bên trên rằng một phương thức bắt buộc phải khai báo một kiểu_trả_về. Nhưng với nhu cầu thực tế, không phải lúc nào chúng ta cũng cần phải trả ra khỏi phương thức một kết quả, minh chứng rõ ràng nhất là các hàm inChuVi()inDienTich() mà chúng ta đã làm quen, các hàm này chỉ cần các câu lệnh in ra console, và rồi hết.

Vậy để giải quyết vấn đề không cần kiểu trả về này, Java (hay C/C++ cũng thế) đã định nghĩa cho chúng ta một kiểu dữ liệu hơi lạ, kiểu void. Kiểu dữ liệu này thực chất là kiểu rỗng, nó không là gì cả, nó không chứa bất kỳ dữ liệu nào. Do đó chúng ta cứ hiểu rằng nếu một phương thức có kiểu_trả_về  voidthì có nghĩa là nó không có kiểu trả về. Và vì là một phương thức không có kiểu trả về, nên bạn cũng không cần câu lệnh return bên trong phương thức này.

Chắc có lẽ mình không cần đưa thêm ví dụ nào về kiểu void này, các bài thực hành đều dùng nhiều đến nó. Mình chỉ có một ý nho nhỏ, rằng trong một phương thức với kiểu void, bạn hoàn toàn có thể dùng từ khóa return để kết thúc. Cách sử dụng câu lệnh return đã được mình nhắc sơ qua từ bài học số 10, và ý nghĩa của return trong một phương thức void không khác với break trong một Câu lệnh điều khiển luồng cả.

Để ví dụ về cách sử dụng return trong phương thức void, mình xin mượn ý tưởng tìm số nguyên tố từ Bài Thực Hành Số 1 của bài học số 10. Mình biến chế một xíu, đó là khi nào tìm thấy số nguyên tố đầu tiên, thì sẽ in số đó ra console và kết thúc ngay phương thức đó.

void timSoNguyenTo() {
for (int number = 2; number <= 10000; number++) {
boolean isPrime = true; // Thay vì biến count, dùng biến này để kiểm tra số nguyên tố
for(int j = 2; j <= Math.sqrt(number); j++) {
if (number % j == 0) {
// Chỉ cần một giá trị được tìm thấy trong khoảng này,
// thì number không phải số nguyên tố
isPrime = false;
break; // Thoát ngay và luôn khỏi for (vòng for bên ngoài vẫn chạy)
}
}
if (isPrime) {
// Nếu isPrime còn giữ được sự "trong trắng" đến cùng thì đó là số nguyên tố,
// in nó ra rồi thoát
System.out.println(number);
return;
}
}
}

Các Tham Số Truyền Vào Của Phương Thức

Chúng ta vừa đi qua kiểu_trả_về, điều tiếp theo chúng ta quan tâm đó là các tham_số truyền vào.

Bạn nên biết là, một phương thức có thể không có tham số truyền vào. Điều này mình không cần đưa thêm ví dụ nào cả, vì các phương thức mà bạn thực hành từ mấy bài học OOP đều như vậy cả. Khi đó sẽ không có gì bên trong cặp dấu () sau tên phương thức, như là tinhChuVi()tinhDienTich().

Còn với thể loại phương thức có tham số truyền vào. Bạn có thể truyền vào nó bất cứ kiểu dữ liệu nào, từ một kiểu nguyên thủy, một mảng, hay một đối tượng nào đó. Bạn có thể truyền vào nó nhiều tham số, mỗi tham số như vậy cách nhau bởi dấu phẩy. Bạn có thể xem ví dụ sau.

public class HinhTron {
 
/**
* Demo phương thức có tham số truyền vào.
* Bạn hãy chú ý đến phương thức:
* - setBanKinh(float r)
* - setToaDo(ToaDo toaDo)
* - setToaDo(int x, int y)
*/
 
float r;
ToaDo toaDo;
 
// Phương thức này có một tham số truyền vào là kiểu nguyên thủy
void setBanKinh(float r) {
// Gán dữ liệu từ tham số r vào thuộc tính r (this.r) của lớp
this.r = r;
}
 
// Phương thức này có một tham số truyền vào là kiểu đối tượng
void setToaDo(ToaDo toaDo) {
this.toaDo = toaDo;
}
 
// Phương thức này có hai tham số truyền vào
void setToaDo(int x, int y) {
// Phải new một đối tượng trước khi dùng đến các thuộc tính
this.toaDo = new ToaDo();
 
// Gán dữ liệu từ từng tham số vào cho this.toaDo
this.toaDo.x = x;
this.toaDo.y = y;
}
 
void xuatBanKinh() {
System.out.println("Bán kính Hình tròn: " + this.r);
}
 
void xuatToaDo() {
System.out.println("Tọa độ Hình tròn: ");
System.out.println("x = " + this.toaDo.x);
System.out.println("y = " + this.toaDo.y);
}
 
}

Có thể có bạn lần đầu tiên làm quen với phương thức có tham số truyền vào này. Vậy mình có một số ý nhỏ sau đây giúp bạn nào còn bỡ ngỡ sẽ có một cách tiếp cận tốt hơn.

  • Tham số truyền vào cho từng phương thức chính là “cửa ngõ” của phương thức. Nó giúp cho các thành phần bên ngoài lớp có cơ hội truyền dữ liệu vào bên trong một lớp, giúp lớp đó có “nguyên liệu” để thực hiện các logic. Như ví dụ ngay trên đây, nếu bạn xây dựng lớp HinhTron tự nó kêu người dùng nhập bán kính và các tọa độ thì không sao, nhưng nếu bạn muốn các hàm nhập nằm ở ngoài lớp, rồi sau đó truyền dữ liệu này vào trong HinhTron, thì bạn xây dựng như ví dụ trên.
  • Bạn có quyền khai báo nhiều tham số truyền vào cho một phương thức, mỗi tham số được định nghĩa một kiểu dữ liệu. Nhưng khi ở đâu đó gọi đến phương thức này, bạn phải truyền đầy đủ số lượng tham số và đúng với kiểu dữ liệu đã khai báo. Ở mục sau nữa chúng ta sẽ xem đến cách gọi đến phương thức có tham số truyền vào.
  • Nếu như với ngôn ngữ khác, như C/C++ chẳng hạn, có quan tâm đến hai loại tham số, đó là Tham chiếu và Tham trị. Với tham số là Tham chiếu, thì khi ở đâu đó gọi đến phương thức và truyền biến vào phương thức, nếu bên trong phương thức đó có “lỡ tay” làm thay đổi giá trị của tham số, thì ở nơi gọi đến phương thức đó, biến truyền vào cũng sẽ bị thay đổi. Còn với tham số là Tham trị, thì biến truyền vào sẽ độc lập với tham số bên trong phương thức, có nghĩa là phương thức đó cứ thay đổi giá trị của tham số thoải mái, mà biến bên ngoài vẫn không đổi. Với Java, chỉ có duy nhất một loại tham số kiểu Tham trị, không có tham số kiểu Tham chiếu bạn nhé. Nếu bạn nào còn mơ hồ mục này, hãy đọc bài viết này, mình đã giải thích cặn kẽ hai loại tham số này rồi.
  • Trong một lớp, có thể có nhiều phương thức có cùng tên nhưng khác tham số truyền vào, như các phương thức setToaDo(ToaDo toaDo) và setToaDo(int x, int y). Điều này rất bình thường trong một lớp, và bạn đừng lo lắng nhiều quá, chúng ta sẽ có một bài học viết riêng về trường hợp này.

Truy Xuất Đến Phương Thức Của Lớp Từ Bên Ngoài

Cũng tương tự như bên thuộc tính. Đó là:

  • Khi bạn ở đâu đó bên ngoài một lớp, và muốn truy xuất đến các phương thức của lớp đó, bạn vẫn sẽ sử dụng toán tử “.”.
  • Tuy nhiên không phải lúc nào một lớp cũng cho phép bạn truy xuất đến các phương thức của nó đâu. Nó tùy thuộc vào khả_năng_truy_cập mà mình sẽ nói cụ thể ở bài sau.

Như các ví dụ ở mục Kiểu Trả Về Của Phương Thức trên đây, các bạn đã hình dung đến việc truy xuất đến một phương thức không có tham số truyền vào rồi. Nên mình sẽ tập trung vào việc truy xuất đến một phương thức có tham số truyền vào cho ví dụ bên dưới nhé. Mình lấy lại ví dụ trên, khi khai báo một HinhTron với các phương thức có tham số truyền vào, giờ là lúc chúng ta thử xem việc gọi đến nó ở hàm main() là như thế nào.

public class MainClass {
 
public static void main(String[] args) {
// Khai báo đối tượng hinhTron, từ lớp HinhTron
HinhTron hinhTron = new HinhTron();
 
// Kêu người dùng nhập vào Bán kính Hình tròn, rồi truyền vào cho HinhTron
System.out.println("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
float bk = scanner.nextFloat();
hinhTron.setBanKinh(bk);
 
// Kêu người dùng nhập vào Tọa độ Hình tròn, rồi truyền vào cho HinhTron, với 2 cách
System.out.println("Hãy nhập vào Tọa độ Hình tròn: ");
System.out.println("x = ");
ToaDo toaDo = new ToaDo();
toaDo.x = scanner.nextInt();
System.out.println("y = ");
toaDo.y = scanner.nextInt();
hinhTron.setToaDo(toaDo); // Cách 1, gọi đến phương thức có tham số là một đối tượng
hinhTron.setToaDo(toaDo.x, toaDo.y); // Cách 2, gọi đến phương thức có tham số là hai kiểu nguyên thủy
 
// In kết quả vừa nhập
hinhTron.xuatBanKinh();
hinhTron.xuatToaDo();
}
 
}

Mình vừa trình bày xong kiến thức về sử dụng phương thức trong một lớp. Tất nhiên đây chưa phải tất cả những gì liên quan đến phương thức mà OOP mang lại cho chúng ta. Còn rất nhiều điều thú vị xoay quanh cách sử dụng phương thức nữa, các bạn chờ xem nhé.

Package

Với hai bài học về Thuộc tính và Phương thức mà các bạn đã làm quen, mình đều có để ra đó và không nói gì cả kiến thức về khả_năng_truy_cập. Cái khả năng truy cập này lại rất phụ thuộc vào việc tổ chức cấu trúc project theo package. Vậy thì package là gì và nó giúp ích được gì cho project của bạn? Hôm nay mình sẽ nói rõ về package trước. Và rồi đến bài học sau các bạn sẽ thấy chúng liên quan mật thiết đến các khả_năng_truy_cập của các lớp như thế nào nhé. Mời các bạn cùng xem.

Package

Package – Dịch ra tiếng việt là Gói, hay . Công dụng của nó thì y chang như nghĩa mà nó mang lại. Nó sẽ giúp chúng ta hai việc.

  •  – Hay có thể nói công dụng này là gom các Java class mà bạn đã tạo ra làm thành các nhóm. Mỗi nhóm như vậy bao gồm các class có chung một công năng nào đó. Mục đích chính là giúp bạn tổ chức nên những kiến trúc rõ ràng hơn cho source code của ứng dụng.
  • Gói – Có thể hiểu rằng đây là công dụng đóng gói. Nó giúp bạn xuất bản các thư viện đến cho người dùng theo dạng các gói (người dùng ở đây hiểu theo nghĩa là các lập trình viên khác ấy nhé). Như bạn có từng làm quen ở bài học số 7, khi đó bạn dùng đến lớp Scanner để đọc dữ liệu nhập vào từ console, nhưng nếu không có dòng import java.util.Scanner ở trên cùng của file class thì sẽ không thể nào dùng được Scanner. Lúc đó mình có nhắc một tí đến package, và java.util chính là packagepackage này đã giúp gói lớp Scanner vào để mang đến cho bạn dùng đó.

Nếu như với project đơn giảnh như OOPLearning mà chúng ta đã thực hành từ các bài trước, thì bạn hầu như chẳng cần quan tâm đến package mà làm gì cho mệt, vì chúng có bao nhiêu lớp đâu. Nhưng khi bạn xây dựng các ứng dụng phức tạp hơn, có cả trăm lớp trong đó, như các bạn có thể thấy khi bước qua các project bên lập trình Android, thì việc tổ chức các lớp theo package lại rất hiệu quả.

Package Và Việc Tổ Chức Source Code Theo Thư Mục

Chúng ta vừa nói đến việc gom nhóm các lớp, khoan hãy tìm hiểu cách thức tạo ra package để làm việc như thế nào, mà hãy xem ngoài việc gom nhóm này ra, package còn có tác dụng gì khác không nhé.

Gom nhóm ở trên đây có nghĩa là khi nhìn vào Eclipe hay InteliJ, bạn sẽ thấy chúng cùng nằm trong một nhóm với nhau, như bài thực hành bên dưới sẽ nói rõ. Tuy nhiên việc gom nhóm này còn ảnh hưởng cả với các file .java được tạo ra ở ổ cứng. Ví dụ như nếu bạn tạo một package có tên là main, thì ở ổ cứng, nơi chứa đựng project của bạn, cũng sẽ có một thư mục là main. Bạn có thể tạo package bên trong package, khi đó các package lồng vào nhau được ngăn cách bởi dấu chấm, như bạn có thể thấy lớp Scanner nằm trong package java.util, thực chất đây là hai package java và util lồng vào nhau, như vậy thì trong ổ cứng cũng sẽ có hai thư mục java > util. Và cuối cùng thì lớp java nằm trong package nào thì tương tự ở ổ cứng, các file .java cũng nằm trong thư mục tương ứng.

Mục này không quá khó nên mình chỉ nói đến mà không có thực hành hay ví dụ cụ thể. Lát nữa đây khi thực hành tạo các lớp và package thì bạn hãy mở thư mục ở máy lên để kiểm chứng nhé.

Tạo Package Với Eclipse

Eclipse (hay sau này bạn lập trình Android với Android Studio cũng vậy) giúp cho chúng ta tạo ra và quản lý package một cách dễ dàng. Với Eclipse thì khi bạn tạo mới một project, sẽ chẳng có package nào cả. Điều này như mình nói ở trên, là với các project đơn giản, thì bạn không cần quan tâm lắm đến package. Và khi bạn tạo một class bên trong thư mục src/ như những bài thực hành trước, Eclipse sẽ gán vào trong cấu trúc lớp của chúng ta một cái tên “default package”, có nghĩa là chẳng có package gì ráo.

Khi không tạo package từ Eclipse, một package mặc định sẽ hiện ra
Khi không tạo package từ Eclipse, một package mặc định sẽ hiện ra

Vậy thì hôm nay chúng ta cùng tạo một project mới, hoàn hảo hơn, với cấu trúc rõ ràng được gom nhóm theo package, để biết được thực sự package giúp ích gì cho chúng ta nhé.

Thực Hành Tạo Mới Project Với Package

Bài thực hành hôm nay vẫn xoay quanh việc tính chu vi và diện tích các hình học, nhưng có nhiều điểm khác biệt. Thứ nhất, chúng ta cùng tạo mới project, với cái tên là PackageLearning để dễ làm việc. Thứ hai, chúng ta sẽ có nhiều lớp hơn để thử nghiệm việc tổ chức chúng theo package.

Đóng Project OOPLearning Lại

Nếu bạn còn đang mở Eclipse với project OOPLearning thì bạn nên đóng nó lại trước khi tạo mới PackageLearning. Đóng lại chứ không phải xóa đi nhé. Việc đóng một project lại giúp bạn có thể mở nó ra lại một cách nhanh chóng, vì nó vẫn nằm ở cửa sổ Package Explorer, có điều trạng thái của nó hơi khác thôi, ngoài ra khi đóng project lại, bạn sẽ dễ tập trung vào các project đang mở khác, trong trường hợp bạn có quá nhiều project cần quản lý.

Để đóng một project, bạn có thể click chuột phải vào project đó ở cửa sổ Package Explorer rồi chọn Close Project (như hình) hoặc chọn trên menu Project > Close Project. Thế là xong. Sau này bạn muốn mở project này lại thì lại click phải chuột vào nó, rồi chọn Open Project, hoặc tương tự với menu Project > Open Project.

Tùy chọn đóng một Project với Eclipse
Tùy chọn đóng một Project với Eclipse

Tạo Mới Project PackageLearning

Việc tạo mới project này không khác gì với các bài thực hành trước cả, các bạn cùng tạo một project với tên PackageLearning luôn nhé.

Hình sau là project PackageLearning mình mới tạo, bên cạnh project OOPLearning đã được đóng.

Project OOPLearning đã đóng, project PackageLearning vừa mới tạo
Project OOPLearning đã đóng, project PackageLearning vừa mới tạo

Tạo MainClass.java Nằm Trong Package main

Lần này bạn vẫn tạo một lớp chính chứa đựng phương thức main(), mình vẫn muốn đặt tên lớp chính này là MainClass.java. Tuy nhiên ở bài hôm nay, thay vì chỉ thiết lập thông số ở mục Name, và check vào public static void main(String[] args), thì bạn hãy thiết lập một cái tên cho mục Package (như hình dưới đây). Tên cho package bạn cũng đặt theo quy tắc đặt tên biến vậy, lần này mình đặt tên cho package chứa lớp MainClass này là main.

Tạo lớp MainClass đồng thời khai báo package có tên main
Tạo lớp MainClass đồng thời khai báo package có tên main

Sau khi nhấn Finish ở cửa sổ trên, bạn sẽ thấy một sự khác biệt, nơi mà với project OOPLearning hiển thị là “default package” thì nay lại là main.

Bạn đã thấy package main vừa được tạo
Bạn đã thấy package main vừa được tạo

Một khác biệt nữa, là với các lớp nằm trong một package, chúng sẽ có thêm một dòng khai báo package ở trên cùng của file. Như bạn nhìn thấy code của MainClass.java sau. Dòng khai báo package này phải khớp với tên package hiển thị ở cửa sổ Package Explorernhé. Sở dĩ mình nhắc điều này vì nếu vì một lý do nào đó, bạn sửa lại cấu trúc của các lớp ở Package Explorer, hay sửa tên của package ở cửa sổ này, mà quên sửa dòng khai báo package tương ứng ở file .java, thì hệ thống sẽ báo lỗi.

package main;
 
public class MainClass {
 
public static void main(String[] args) {
// TODO Auto-generated method stub
 
}
 
}

Tạo Một Package Mới Với Tên shapes

Chúng ta tạm thời để một mình lớp MainClass vào bên trong package main thôi. Sau này nếu có các lớp khác với ý nghĩa dùng cho các mục đích quản lý chung chung thì sẽ thêm vào package này. Còn bây giờ là lúc chúng ta tạo ra các lớp hình học, mỗi hình như vậy sẽ có các thuộc tính và phương thức đặc thù. Và bởi vì các lớp hình học này có cùng một cấu trúc, nên chúng ta gom chúng vào chung một package, mình đặt tên package này là shapes.

Package shapes này sẽ ngang cấp với package main. Để tạo một package ngang cấp với main, bạn nên click chuột phải vào src/ (cha của main) và chọn New > Package. Hoặc đảm bảo vệt sáng ở thư mục src/ và chọn từ menu File > New > Package. Hình dưới là trường hợp click chuột phải vào src/.

Tùy chọn tạo mới một package với Eclipse
Tùy chọn tạo mới một package với Eclipse

Ở hộp thoại xuất hiện, bạn gõ shapes vào trong mục Name như hình dưới đây. Lưu ý là nếu bạn muốn tạo package là con của một package khác, thì ở mục Name này bạn cứ gõ theo kiểu package_cha.package_con, trong đó package_cha đã tồn tại, là được.

Khai báo thông tin package mới
Khai báo thông tin package mới

Sau khi nhấn Finish, bạn sẽ thấy package shapes xuất hiện trong cửa sổ quản lý Package Explorer. Tạm thời package shapes chưa có bất kỳ lớp nào bên trong đó, nên Eclipse mới dùng icon hơi khác với package main chút xíu.

Package shapes vừa mới tạo
Package shapes vừa mới tạo

Tạo Các Lớp Hình Học Bên Trong Package shapes

Chúng ta sẽ tạo môt lớp HinhTron, một lớp HinhChuNhat bên trong package shapes này. Bạn tự tạo nhé. Source code của hai hình mình để ở dưới, còn đây là hình ảnh khi mà bạn tạo đúng cấu trúc của bài thực hành hôm nay.

Các lớp được tạo hoàn chỉnh vào các package
Các lớp được tạo hoàn chỉnh vào các package

Nội dung của lớp HinhTron như sau. Bạn chú ý là mình có thêm các từ khóa public vào trước các phương thức trong các lớp, tạm thời bạn đừng quan tâm vội, đó là khả_năng_truy_cập của phương thức, mà mình sẽ nói cụ thể ở bài học sau.

package shapes;
 
import java.util.Scanner;
 
public class HinhTron {
 
final float PI = 3.14f;
 
float r; // Bán kính
float cv; // Chu vi
float dt; // Diện tích
 
public void nhapBanKinh() {
System.out.println("Hãy nhập vào Bán kính Hình tròn: ");
Scanner scanner = new Scanner(System.in);
r = scanner.nextFloat();
}
 
public void tinhChuVi() {
cv = 2 * PI * r;
}
 
public void tinhDienTich() {
dt = PI * r * r;
}
 
public void inChuVi() {
System.out.println("Chu vi Hình tròn: " + cv);
}
 
public void inDienTich() {
System.out.println("Diện tích Hình tròn: " + dt);
}
}

Nội dung của lớp HinhChuNhat như sau. Tương tự, các phương thức ở lớp này cũng được gán khả năng truy cập là public.

package shapes;
 
import java.util.Scanner;
 
public class HinhChuNhat {
 
float dai; // Chiều dài
float rong; // Chiều rộng
float cv; // Chu vi
float dt; // Diện tích
 
public void nhapChieuDai() {
System.out.println("Hãy nhập vào Chiều dài Hình chữ nhật: ");
Scanner scanner = new Scanner(System.in);
dai = scanner.nextFloat();
}
 
public void nhapChieuRong() {
System.out.println("Hãy nhập vào Chiều rộng Hình chữ nhật: ");
Scanner scanner = new Scanner(System.in);
rong = scanner.nextFloat();
}
 
public void tinhChuVi() {
cv = 2 * (dai + rong);
}
 
public void tinhDienTich() {
dt = dai * rong;
}
 
public void inChuVi() {
System.out.println("Chu vi Hình chữ nhật: " + cv);
}
 
public void inDienTich() {
System.out.println("Diện tích Hình chữ nhật: " + dt);
}
}

Sử Dụng Các Lớp HinhTron Và HinhChuNhat Từ MainClass

Vấn đề đến đây là khá dễ dàng với chúng ta. Bạn hãy thử quay lại phương thức main() của MainClass để yêu cầu người dùng nhập lần lượt các giá trị của hình tròn và hình chữ nhật thông qua khai báo các đối tượng từ hai lớp HinhTron và HinhChuNhat mà chúng ta vừa định nghĩa ra, rồi gọi đến các phương thức tính chu vi và diện tích, sau cùng thì gọi các phương thức in kết quả của chúng.

Có một điều khác biệt mà các bạn cần chú ý ở lớp MainClass này, đó là bởi vì MainClass ở khác package so với HinhTron và HinhChuNhat, nên khi gọi đến các lớp này, MainClass buộc phải import chúng ở đầu file, nếu không bạn sẽ thấy hệ thống báo lỗi. Kinh nghiệm để mà hệ thống tự động import các lớp từ các package khác thì mình đã nói ở Bài 7 khi mà chúng ta cần sử dụng lớp Scanner từ package java.util rồi nhé.

package main;
 
import shapes.HinhChuNhat;
import shapes.HinhTron;
 
public class MainClass {
 
public static void main(String[] args) {
// Khai báo các đối tượng bằng từ khóa new
HinhTron hinhTron = new HinhTron();
HinhChuNhat hinhChuNhat = new HinhChuNhat();
 
// Nhập dữ liệu vào cho hinhTron, tính chu vi, diện tích, và in ra
hinhTron.nhapBanKinh();
hinhTron.tinhChuVi();
hinhTron.tinhDienTich();
hinhTron.inChuVi();
hinhTron.inDienTich();
 
// Ngăn cách các hình cho người dùng đỡ nhầm lẫn
System.out.println("\n\n");
 
// Nhập dữ liệu vào cho hinhChuNhat, tính chu vi, diện tích, và in ra
hinhChuNhat.nhapChieuDai();
hinhChuNhat.nhapChieuRong();
hinhChuNhat.tinhChuVi();
hinhChuNhat.tinhDienTich();
hinhChuNhat.inChuVi();
hinhChuNhat.inDienTich();
}
 
}

Vậy là kiến thức về package đã xong, package này sẽ ảnh hưởng nhiều đến khả_năng_truy_cập vào các thuộc tính và phương thức của lớp ở bài học sau.

Phương Thức Khởi Tạo – Constructor

Hôm nay thật là một ngày đẹp trời để cùng nhau xem qua khái niệm và cách sử dụng về Phương thức khởi tạo trong Java.

Tất nhiên bạn sẽ phát hiện ra rằng bài học này đang nói về một loại phương thức, vậy thì tại sao mình không kết hợp vào bài học về phương thức luôn cho rồi. Vâng, không riêng gì bạn đâu, có rất nhiều bạn đã thắc mắc với mình như vậy. Nhưng bạn biết không, phương thức mà bạn làm quen hôm nay sẽ hơi đặc biệt hơn một chút so với các phương thức mà chúng ta đã nói qua, đặc biệt như thế nào thì bạn hãy xem nội dung bên dưới nhé. Và vì nó đặc biệt, nó hơi khác, nó lại quan trọng nữa, nên mình tách loại phương thức này ra một bài học riêng, để các bạn có một sự tiếp cận thoải mái hơn, độc lập hơn, không bị nhập nhằng giữa phương thức bình thường và phương thức khởi tạo này.

Khái Niệm Phương Thức Khởi Tạo – Constructor

Phương thức khởi tạo, hay gọi Hàm khởi tạo cũng được, bạn cũng có thể gọi là Constructor, mình thì mình sẽ dùng constructor luôn cho ngắn gọn.

Thực chất thì constructor này cũng là một phương thức, nhưng nó đặc biệt ở chỗ là, ngay khi mà bạn khởi tạo một đối tượng bằng từ khóa new, thì constructor của đối tượng đó sẽ lập tức được gọi đến một cách tự động. Có nghĩa là nếu với phương thức bình thường, bạn phải gọi đến nó thông qua toán tử chấm (“.”) thì phương thức đó mới được thực thi, còn với constructor, ngay khi biên dịch đến từ khóa new, hệ thống sẽ thực thi một constructor tương ứng của đối tượng, tùy vào constructor nào mà bạn chỉ định.

Mục đích chính mà constructor mang lại, không gì khác ngoài tác dụng Khởi tạo. Constructor giúp đối tượng vừa được tạo ra đó, có cơ hội được khởi tạo các giá trị cho các thuộc tính bên trong nó. Hoặc có thể giúp đối tượng đó gọi đến các phương thức tương ứng khác nhằm khởi tạo các logic bên trong đối tượng.

Trước khi hiểu rõ hơn về cách sử dụng một constructor, chúng ta hãy xem cách khai báo chúng.

Khai Báo Constructor

Trước hết mình xin nói qua cú pháp cho một constructor, để bạn có thể mang ra so sánh với việc khai báo một phương thức bình thường ở bài 18, xem có khác gì không nhé. Cú pháp của một constructor như sau.

[khả_năng_truy_cập]  tên_phương_thức  () {
     // Các dòng code
}

Như vậy bạn cũng có thể thấy sự khác biệt, tuy nhiên mình cũng điểm qua các thành phần bên trong cú pháp trên cho nó rõ ràng.

– Đầu tiên, constructor không có kiểu_trả_về như phương thức bình thường nhé.

– Khả_năng_truy_cập – Chúng ta sẽ nói về vấn đề này ở một bài khác, cùng với khả_năng_truy_cập vào các thuộc tính và phương thức bình thường của một lớp. Tuy nhiên trong bài học hôm nay, mình đều sẽ dùng public cho các constructor, nó có nghĩa là ở đâu cũng có thể dùng đến các constructor này.

– tên_phương_thức – Khác với các phương thức bình thường, tên của constructor phải cùng với tên lớp. Để giúp phân biệt đâu là constructor và đâu là phương thức bình thường í mà.

– các_tham_số_truyền_vào – Phần này thì giống với phương thức bình thường, không có gì để nói thêm.

Thực Hành Khai Báo Các Constructor Cho Lớp HinhTron

Chúng ta vẫn lấy project PackageLearning từ bài học trước, để cùng nhau tạo các constructor cho các đối tượng.

Bạn hãy lấy lớp HinhTron ra làm chuột bạch. Bạn chú ý đến hai constructor mà mình đưa ra ở ví dụ sau. Mình chỉ mới khai báo ra thôi, chưa code gì bên trong các constructor này cả, quy tắc khai báo các constructor này hoàn toàn tuân theo các gạch đầu dòng trên đây.

Bạn hãy thử nhìn vào hai constructor này rồi ghi nhớ và tự code lại nhé, nhớ là phải code lại, đừng copy/paste, bạn sẽ học được nhiều điều thông qua các dòng code cho constructor này đấy, tin mình đi.

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
public class HinhTron {
 
    /**
     * Demo cách khai báo các constructor,
     * các thuộc tính và phương thức vẫn được giữ nguyên như bài 19,
     * bạn hãy chú ý các constructor được comment
     */
 
    final float PI = 3.14f;
 
    float r;
    float cv;
    float dt;
 
    // Một constructor, chú ý không có kiểu trả về,
    // và constructor này không có tham số truyền vào
    public HinhTron() {
        // Chúng ta khởi tạo gì đó sau
    }
 
    // Một constructor khác, cũng không có kiểu trả về,
    // nhưng có một tham số truyền vào
    public HinhTron(float r) {
        // Chúng ta khởi tạo gì đó sau
    }
 
    public void nhapBanKinh() {
        System.out.println("Hãy nhập vào Bán kính Hình tròn: ");
        Scanner scanner = new Scanner(System.in);
        r = scanner.nextFloat();
    }
 
    public void tinhChuVi() {
        cv = 2 * PI * r;
    }
 
    public void tinhDienTich() {
        dt = PI * r * r;
    }
 
    public void inChuVi() {
        System.out.println("Chu vi Hình tròn: " + cv);
    }
 
    public void inDienTich() {
        System.out.println("Diện tích Hình tròn: " + dt);
    }
}

Thông qua ví dụ trên đây, mình có thêm một vài ghi chú nữa bổ sung cho bốn cái gạch đầu dòng trên kia. Mình muốn các bạn code qua constructor xong rồi mới nói đến điều này để khỏi nhầm lẫn.

– Trong một lớp, bạn hoàn toàn có thể có nhiều constructor, mỗi constructor như vậy phải khác tham số truyền vào (chứ không phải khác tên nhé, như trên kia có nói rằng constructor phải cùng tên với tên lớp, như vậy các constructor đều phải có tên giống nhau rồi). Bạn có thể xem lại ví dụ trên sẽ thấy có hai constructor, nhưng bạn có thể tạo thêm nhiều constructor khác cũng được.

– Với một lớp có nhiều constructor, bạn hoàn toàn có thể từ constructor này gọi đến constructor khác, việc gọi đến này không tạo thêm một thể hiện mới của lớp, mà mục đích chính của việc này là để tận dụng lại các dòng code khởi tạo của các constructor mà thôi. Vấn đề này bạn sẽ hiểu rõ hơn ở bài học về cách sử dụng từ khóa this ở bài học sau,

– Dù cho lớp đó có bao nhiêu constructor đi nữa, thì khi khai báo đối tượng, bạn phải chỉ định một và chỉ một constructor mà thôi. Điều này khác với các phương thức bình thường khác, khi mà bạn có thể gọi đến bao nhiêu phương thức cũng được. Ở bước dưới đây nữa chúng ta cùng xem cách chỉ định một constructor cho đối tượng như thế nào nhé.

– Một constructor chỉ được thực thi một lần khi từ khóa new được gọi. Bạn không thể nào thực thi lại một constructor trong suốt đời sống của đối tượng được nữa. Nếu như bạn muốn thực thi lại một constructor, thì bạn lại phải dùng từ khóa new, như vậy là bạn đã tạo ra một đối tượng mới rồi. Điều này cũng khác với các phương thức bình thường khác có khả năng gọi lại hoài được. Chính vì vậy mà nếu bạn có nhu cầu cần khởi tạo các giá trị thì cứ khởi tạo hết trong một constructor đi nhé.

– Và một ý nữa cũng khá quan trọng. Nếu bạn quên không khai báo constructor cho một lớp thì sao? Cũng giống như là từ các bài học trước tới giờ, bạn chỉ tạo các thuộc tính và các phương thức cho lớp, có tạo constructor cho chúng đâu! Thì khi này, hệ thống sẽ luôn ngầm tạo cho chúng ta một constructor không có tham số truyền vào, không có nội dung gì bên trong constructor đó, y như constructor đầu tiên của lớp HinhTron ở ví dụ trên đây. Như vậy mặc định luôn luôn lúc nào chúng ta cũng sẽ có được một constructor từ hệ thống.

Bài thực hành kế tiếp các bạn sẽ thêm code vào trong các constructor.

Thực Hành Khởi Tạo Các Giá Trị Thông Qua Constructor

Giờ thì bạn đảm bảo lớp HinhTron đang mở, bạn thử code các dòng lệnh sau vào hai constructor.

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
public class HinhTron {
 
    /**
     * Demo cách khai báo các constructor,
     * các thuộc tính và phương thức vẫn được giữ nguyên như bài 19,
     * bạn hãy chú ý các constructor được comment
     */
 
    final float PI = 3.14f;
 
    float r;
    float cv;
    float dt;
 
    // Constructor không có tham số truyền vào
    public HinhTron() {
        nhapBanKinh();  // Thử gọi hàm nhapBanKinh()
    }
 
    // Constructor có một tham số r truyền vào
    public HinhTron(float r) {
        this.r = r; // Gán biến r vào thuộc tính r
    }
 
    public void nhapBanKinh() {
        System.out.println("Hãy nhập vào Bán kính Hình tròn: ");
        Scanner scanner = new Scanner(System.in);
        r = scanner.nextFloat();
    }
 
    public void tinhChuVi() {
        cv = 2 * PI * r;
    }
 
    public void tinhDienTich() {
        dt = PI * r * r;
    }
 
    public void inChuVi() {
        System.out.println("Chu vi Hình tròn: " + cv);
    }
 
    public void inDienTich() {
        System.out.println("Diện tích Hình tròn: " + dt);
    }
}

Bạn có thể thấy rằng.

Ở constructor thứ nhất không có tham số truyền vào, trong này mình gọi đến hàm kêu nhập bán kính. Bạn nhớ nội dung này của constructor thứ nhất nhé, để một lát nữa thực thi chương trình bạn sẽ dễ hiểu tại sao.

Ở constructor thứ hai có một tham số truyền vào là biến r, khi nhận được biến này, chúng ta gán nó vào cho thuộc tính r luôn.

Nếu bạn vẫn chưa rõ constructor có tác dụng gì, thì đừng vội nản, tiếp tục đọc phần tiếp theo, phần thực thi hàm khởi tạo, bạn sẽ ngày càng hiểu rõ hơn thôi.

Khai Báo Đối Tượng Thông Qua Constructor

Nhớ lại đi, bạn đã khai báo đối tượng hinhTron và hinhChuNhat ở các bài học trước như thế nào? Có phải như vậy không?

1
2
HinhTron hinhTron = new HinhTron();
HinhChuNhat hinhChuNhat = new HinhChuNhat();

Như mình nói, nếu như bạn không khai báo bất kỳ constructor nào cho lớp HinhTron và HinhChuNhat, thì thực ra hệ thống đã khai báo cho bạn một constructor cho từng lớp đó như sau.

1
2
public HinhTron() {
}
1
2
public HinhChuNhat() {
}

Và vì vậy, nên khi bạn khai báo hai đối tượng này, bạn đã gọi đến chúng thông qua các constructor mặc định, đó là new HinhTron(), và new HinhChuNhat(). Bạn thấy có mối liên hệ không nào.

Vậy quay lại với lớp HinhTron mà bạn đã thực hành trên kia, chúng ta đã khai báo hai constructor vào bên trong lớp. Và mình cũng có nói rằng bạn chỉ được thực thi một và chỉ một constructor mà thôi, vậy bạn sẽ có một trong hai cách khởi tạo HinhTron như sau.

1
2
3
4
5
// Cách khai báo HinhTron dựa vào constructor thứ nhất
HinhTron hinhTron1 = new HinhTron();
 
// Cách khai báo HinhTron dựa vào constructor thứ hai
HinhTron hinhTron2 = new HinhTron(10);

Với hai cách khởi tạo này, hinhTron1 và hinhTron2 sẽ khác nhau như thế nào, chúng ta cùng qua bài thực hành.

Thực Hành Khai Báo HinhTron Thông Qua Các Constructor

Chúng ta cùng về lại lớp MainClass để mà khởi tạo HinhTron thông qua các constructor nhé.

Chúng ta cùng nhau code 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
public class MainClass {
 
    public static void main(String[] args) {
        // Cách khai báo HinhTron dựa vào constructor thứ nhất
        HinhTron hinhTron1 = new HinhTron();
 
        // Cách khai báo HinhTron dựa vào constructor thứ hai
        HinhTron hinhTron2 = new HinhTron(10);
 
        // Tính toán và in ra kết quả cho hinhTron1
        System.out.println("======== Kết quả hinhTron1 ========");
        hinhTron1.tinhChuVi();
        hinhTron1.tinhDienTich();
        hinhTron1.inChuVi();
        hinhTron1.inDienTich();
         
        // Tính toán và in ra kết quả cho hinhTron2
        System.out.println("======== Kết quả hinhTron2 ========");
        hinhTron2.tinhChuVi();
        hinhTron2.tinhDienTich();
        hinhTron2.inChuVi();
        hinhTron2.inDienTich();
}

Điều này có nghĩa là chúng ta cùng tạo ra hai đối tượng hinhTron1 và hinhTron2 từ lớp HinhTron. Trong lớp HinhTron chúng ta có hai constructor, hinhTron1 được tạo ra thông qua constructor không có tham số truyền vào, hinhTron2 được tạo ra thông qua constructor có tham số truyền vào là một biến float.

Giờ đây nếu bạn thực thi chương trình này, bạn sẽ thấy có một lần console yêu cầu nhập bán kính hình tròn. Đó là bởi vì constructor của hinhTron1 gọi đến phương thức nhapBanKinh(). Ngay khi hinhTron1 được khai báo thông qua HinhTron hinhTron1 = new HinhTron();, constructor tương ứng (constructor không có tham số truyền vào) lập tức được triển khai.

Tương tự, với hinhTron2 được khai báo bằng HinhTron hinhTron2 = new HinhTron(10);, có nghĩa đối tượng này đã lựa chọn constructor có một tham số float truyền vào để khởi tạo, và vì bạn truyền vào giá trị 10 ngay constructor đó, nên bên trong thân của constructor, nó gán giá trị này vào thuộc tính r, và rồi khi bạn gọi tinhChuVi() và tinhDienTich() trên đối tượng hinhTron2 này, thì giá trị 10 sẽ được sử dụng.

Kết quả in ra console như hình sau, khi mà bạn chỉ nhập 5 cho bán kính hinhTron1hinhTron2 đã được khởi tạo bán kính bằng 10 rồi.

Screen Shot 2017-06-28 at 12.48.52

Bạn đã hiểu constructor rồi đúng không nào.

Trên đây là các kiến thức về constructor. Bạn nên hiểu một constructor cũng là một phương thức, nhưng nó có một số điểm khác biệt như mình có trình bày trên đây. Constructor được sử dụng rất phổ biến, nhằm mang đến một giá trị khởi tạo cho đối tượng nào đó một cách tức thời ngay khi đối tượng đó được khởi tạo.

Có thể những ý mình nêu trên chưa hoàn toàn đầy đủ với một constructor. Hoặc cách thức mình trình bày có phần khó hiểu. Thì các bạn hãy để lại comment bên dưới bài học hôm nay cho mình nhé.

Làm Quen Với Kế Thừa

Vậy là chúng ta đã bước qua lần lượt nhiều kiến thức quan trọng trong lập trình hướng đối tượng, như thuộc tínhphương thứcconstructor. Nhưng có một loại kiến thức có thể nói là tinh hoa của hướng đối tượng, mà chúng ta sẽ tiếp cận bắt đầu từ bài học hôm nay, sẽ làm bạn có một cách sử dụng và tổ chức các lớp trong ứng dụng theo một cách thức hoàn toàn nâng cao và hiệu quả hơn so với các cách mà bạn đã làm quen từ các bài học trước, đó là kiến thức về kế thừa.

Nếu bạn là người đang tập làm quen với Java và hướng đối tượng, thì kế thừa sẽ khiến bạn càng thêm khó khăn hơn một chút. Đến lúc này, Java code không còn quan trọng bằng việc bạn tổ chức cấu trúc cho các lớp hay đối tượng bên trong ứng dụng nữa. Theo kinh nghiệm của mình, thì sau khi làm quen đến kế thừa, sẽ có vô số thắc mắc mang đến với các bạn. Có bạn sẽ thắc mắc không biết khi nào nên kế thừa. Cũng có bạn thì hiểu rõ kế thừa nhưng lại tổ chức kế thừa sai “họ hàng” của chúng, khiến việc kế thừa trở nên rối hơn. Rồi thì khi nào nên chặn tính kế thừa của bất kỳ một lớp nào đó. Vân vân và vân vân. Những thắc mắc đó mình sẽ cố gắng trình bày kỹ càng trong loạt bài viết về kế thừa này, để bạn có một cách thức vận dụng tốt nhất tính kế thừa vào trong sản phẩm của bạn.

Làm Quen Với Kế Thừa

Kế Thừa Là Gì?

Kế thừa trong lập trình hướng đối tượng ám chỉ đến một mối quan hệ giữa các đối tượng, có người thì nói mối quan hệ này là cha-con, có người thì nói là quan hệ mở rộng Người ta có vẻ thích cái khái niệm cha-con hơn, nhưng mình thấy mở rộng sẽ sát với ý nghĩa của kế thừa hơn. Bởi vì nó thực chất là một sự dùng lại, một số trường hợp là mở rộng hơn các đặc tính, của một đối tượng từ một đối tượng nào đó khác.

Như vậy, để hiểu một cách thực tế, giả sử đầu tiên chúng ta có một lớp nào đó, lớp này có thể là do chúng ta viết ra, hay “lượm lặt” ở đâu đó, mình tạm gọi tên lớp sẵn có này là A. Sau đó, để tận dụng lại các phương thức hay các thuộc tính của A mà không cần phải viết lại (hoặc copy lại, có thể vi phạm bản quyền), thì chúng ta xây dựng một lớp mới kế thừa từ A, mình gọi lớp mới này là B. Khi đó B của chúng ta sẽ có sẵn các phương thức và thuộc tính mà A có. Cũng có lúc không vì mục đích dùng lại các giá trị của A, mà là vì một vài giá trị của A không phù hợp với nhu cầu của B, thế là việc kế thừa từ A còn giúp cho B có cơ hội được hoàn thiện lại (hay còn gọi là mở rộng) các giá trị chưa phù hợp đó của A mà không làm thay đổi bản chất của A. Hôm nay chúng ta tập trung vào mục đích dùng lại, mục đích mở rộng mình sẽ nói đến ở bài học sau.

Tại Sao Phải Kế Thừa?

Qua các ý trên đây của mình, có lẽ các bạn cũng đã hiểu lý do tại sao phải kế thừa đúng không nào.

Vâng, mục đích chính mà kế thừa mang lại, đó là việc tận dụng lại, và mở rộng hơn các thuộc tính và phương thức có sẵn từ một đối tượng nào đó.

Vậy thôi, mục đích của kế thừa không cao siêu gì cả, nhưng tác dụng mà nó mang lại là rất to lớn. Với việc dùng lại những cái sẵn có này, sẽ làm cho cấu trúc project của bạn trông chuyên nghiệp hơn, khi đó code của bạn sẽ dễ đọc hơn. Ngoài ra thì việc kế thừa còn giúp cho bạn giảm bớt gánh nặng phải code nhiều, vì đã tận dụng lại code đã có của các lớp khác.

Kế Thừa Trong Java

Nãy giờ chúng ta đang nói chung chung về các khái niệm. Vậy thì làm sao để thể hiện sự kế thừa trong Java? Trong Java, để thể hiện một lớp muốn kế thừa từ một lớp nào đó, bạn sử dụng từ khóa extends.

Bạn hãy nhìn vào ví dụ dưới đây, bạn đừng nên code vội, lát nữa vào phần thực hành chúng ta sẽ cùng code.

class HinhTron {
float bk;
 
float getBanKinh() {
return bk;
}
}
 
class HinhTru extends HinhTron {
}

Code trên đây thể hiện rằng lớp HinhTru kế thừa HinhTron bằng từ khóa extends. Và theo quy luật kế thừa, thì HinhTru có thể sẽ thừa hưởng các giá trị (thuộc tính và phương thức) mà HinhTron đã khai báo. Qua mối quan hệ kế thừa như vậy, người ta có thể gọi lớp HinhTron là lớp cơ sở (base class), hay lớp cha (super classparent class). Còn lớp HinhTru được gọi là lớp dẫn xuất (derived class) hay lớp con (sub classchild class).

Thông thường thì các lớp cha, hay lớp cơ sở, là các lớp chứa đựng các giá trị chung, hay các giá trị cơ sở nhất cho các lớp con. Nên nếu như bạn có nhiều lớp có sự tương đồng nhất định, như Hình tròn và Hình trụ ở ví dụ trên, các lớp này đều có mặt tròn (Hình trụ có hai mặt tròn), nên bạn có thể dùng Hình tròn làm lớp cơ sở (vì nó chứa các giá trị tối thiểu mà Hình trụ có thể tận dụng lại được, trong trường hợp này chính là mặt tròn). Hoặc có những trường hợp có nhiều các lớp có cùng các giá trị tương đồng, mà bạn có thể gom thành một lớp cơ sở duy nhất, rồi các lớp con chỉ việc kế thừa và sử dụng lại các giá trị tương đồng đó mà không cần phải khai báo gì thêm, như bài thực hành bên dưới mình sẽ tạo một lớp HinhHoc là cơ sở nhất cho các lớp HinhTronHinhVuongHinhChuNhat,…

Trên đây là một ví dụ cho việc khi nào thì bạn cần kế thừa, còn bây giờ mình xin liệt kê một số ý quan trọng trong quá trình bạn tổ chức kế thừa.

  • Một lớp chỉ được phép kế thừa từ một và chỉ một lớp cha mà thôi.
  • Tuy không được kế thừa từ nhiều lớp cha, nhưng lớp cha mà đối tượng đang kế thừa này có thể kế thừa từ một lớp cha khác, bạn có thể gọi chơi lớp cha khác này là “lớp ông nội” cho dễ nhớ, và chắc chắc có thể có cả “lớp ông cố” nữa nếu như “lớp ông nội” lại kế thừa một lớp khác nữa.
  • Nếu một lớp không khai báo kế thừa gì hết (như các lớp mà chúng ta đã thực hành từ các bài học trước), thì khi này hệ thống sẽ mặc định xem là nó đang kế thừa từ lớp Object. Bài học sau chúng ta sẽ cùng nói rõ về lớp Object này nhé.

Làm Quen Với Sơ Đồ Lớp

Khái niệm kế thừa ở bài học hôm nay chỉ như vậy thôi. Nhưng trước khi đi vào thực hành cụ thể, mình mời các bạn cùng làm quen với một sơ đồ thần thánh, mà nếu là một dân lập trình chính thống, bạn không thể không biết đến. Cái chính của mục này là nói về cách thức để bạn nhìn và hiểu sơ đồ. Vì project của chúng ta ngày một phức tạp, sẽ có nhiều và rất nhiều lớp, chúng sử dụng nhau, kế thừa nhau. Và vì vậy mà nếu không có sơ đồ, chúng ta sẽ không thể nào diễn tả hết được các mối quan hệ này bằng lời.

Sơ đồ lớp của mục này được mình lấy ra từ nguyên tắc xây dựng sơ đồ lớp (class diagram) của UML. UML là một bộ nhiều các nguyên tắc khác nhau dành cho việc đặc tả và thiết kế hệ thống phần mềm. Nói như vậy để bạn hiểu đây là một sơ đồ tuân thủ theo các nguyên tắc chuẩn, nếu bạn hiểu và tuân thủ các nguyên tắc này, bạn sẽ tạo ra một mô hình có tiếng nói chung, mà ai đọc vào cũng hiểu bạn muốn thể hiện gì cho phần mềm của bạn.

Bạn biết không, bạn đã từng làm quen một chút với sơ đồ này rồi, vì mình đã từng dùng qua ở bài 16, khi đó mình muốn diễn đạt một lớp có ba thành phần chính như sau, và hình khối mà bạn trông thấy chính là cách mà sơ đồ lớp biểu diễn ra một thực thể lớp.

Sơ đồ lớp
Sơ đồ lớp

Hình dáng và màu sắc của các khối trong sơ đồ lớp có thể khác nhau ở bài học của mình và ở các tài liệu khác, tùy vào công cụ để vẽ nó. Nhưng cho dù chúng khác nhau về ngoại hình, thì chung quy lại các dữ liệu mà mỗi sơ đồ thể hiện phải đều tuân thủ một nguyên tắc hình khối với ba thành phần trên.

Mình lấy ví dụ lớp HinhTron như code trên kia, thì khi biểu diễn thành một thực thể trong sơ đồ lớp, sẽ trông như thế này.

Sơ đồ lớp HinhTron
Sơ đồ lớp HinhTron

Nhìn vào sơ đồ, bạn biết ngay cần xây dựng một lớp có tên HinhTron, lớp này có một thuộc tính tên bk có kiểu dữ liệu là float, và một phương thức getBanKinh() trả về kiểu float. Tuy mang giá trị tĩnh, tức là nó không thể hiện được nội dung hay mối quan hệ của các thành phần bên trong một lớp, nhưng chắc hẳn bạn cũng dễ hiểu và hình dung ra được cách thức xây dựng một lớp dựa trên sơ đồ này là như thế nào.

Tiếp theo, mình nói tiếp sơ đồ lớp này thể hiện sự kế thừa như thế nào. Với mong muốn lớp HinhTru kế thừa từ HinhTron, người ta thể hiện qua sơ đồ lớp mối quan hệ này bằng một dấu mũi tên như sau. Bạn chú ý phải là dấu mũi tên rỗng ruột như hình, nếu bạn dùng mũi tên kiểu khác, thì sẽ gây nhầm lẫn với các mối quan hệ khác đấy nhé.

Sơ đồ kế thừa của HinhTru với HinhTron
Sơ đồ kế thừa của HinhTru với HinhTron

Một ý nữa của sơ đồ, ví dụ như trường hợp ở bài 18, lớp HinhTron có sử dụng lớp ToaDo để làm một thuộc tính, thuộc tính này có tên là toaDo, vậy thì sơ đồ sẽ thể hiện sự sử dụng này như sau.

Sơ đồ thể hiện có sử dụng lớp ToaDo
Sơ đồ thể hiện có sử dụng lớp ToaDo

Mọi thứ thật sự rõ ràng đúng không nào. Xong rồi, với bài học hôm nay mình chỉ trình bày sơ lược về kế thừa và về sơ đồ lớp như vậy thôi. Chúng ta sẽ bổ sung các kiến thức, các ký hiệu cho sơ đồ này ở các bài học tiếp theo. Còn bây giờ chúng ta cần thực hành cho quen tay.

Thực Hành Kế Thừa

Chúng ta sẽ tiếp tục cùng nhau xây dựng ứng dụng tính toán các giá trị hình học cho HinhTronHinhTruHinhChuNhatHinhVuong. Bạn cũng nên biết là, nếu không áp dụng kiến thức về kế thừa của bài hôm nay, thì bạn vẫn xây dựng được kết quả của bài thực hành này một cách hoàn hảo bằng các kiến thức về OOP ở các bài trước, bạn có thể thử. Nhưng với việc áp dụng tính kế thừa, như mình có nói, bạn sẽ tiết kiệm được các dòng code một cách đáng kể. Vậy thì mời bạn cùng thử xây dựng một project mới với mình nhé.

Trước hết mình mời bạn xem qua sơ đồ lớp của bài hôm nay (có áp dụng kế thừa) như sau, bạn hãy nghiền ngẫm một tí nhé (chú ý các dấu + ở trước mỗi phương thức hay thuộc tính trong sơ đồ là các khai báo với từ khóa public, đây là khả_năng_truy_cập vào các giá trị lớp mà chúng ta sẽ nói sau).

Sơ đồ lớp của các lớp trong bài thực hành
Sơ đồ lớp của các lớp trong bài thực hành

Trước khi đi vào chính thức, mình sẽ nhìn sơ đồ và nói chi tiết từng lớp, rồi cho bạn xem kết quả chạy chương trình, và cuối cùng là code của từng lớp. Bạn khoan hãy xem code của các lớp vội, mà hãy dựa vào sơ đồ và kết quả rồi thử code nhé, mỗi người sẽ có một cách code khác nhau, bạn không nhất thiết phải code giống như mình, miễn sao chương trình của bạn chạy ổn là được.

  • Lớp HinhHoc. Đây là lớp cha của các lớp còn lại, hay mang ý nghĩa là lớp cơ bản nhất. Do là lớp cơ bản, nên tốt nhất nó phải chứa các thuộc tính hay phương thức mà sẽ hữu dụng cho các lớp con, hay có thể nói rằng lớp con hoàn toàn có thể kế thừa lại các giá trị đó một cách hiệu quả. Chẳng hạn hằng số PI mình sẽ khai báo ở lớp này. Thuộc tính ten tuy dùng chung nhưng sẽ được các lớp con định nghĩa cụ thể theo tên của chúng. Các chuVidienTichtheTich cũng vậy, tuy định nghĩa chung nhưng các lớp con sẽ chứa các giá trị khác nhau. Các phương thức của lớp cha này cũng mang ý nghĩa sẽ được các lớp con dùng đến, nên chúng sẽ có thân hàm cụ thể, như xuatTen() sẽ xuất biến ten ra consolse. Hay inChuVi()inDienTich()inTheTich() cũng sẽ xuất các biến chuVidienTichtheTich tương ứng.
  • Lớp HinhTron. Là lớp con của HinhHoc. Như bạn biết HinhTron sẽ kế thừa các giá trị từ lớp cha của nó. Ngoài các thuộc tính mà nó có được từ lớp cha là tenchuVidienTichtheTich, thì nó cũng định nghĩa thêm một thuộc tính banKinh đặc biệt của riêng nó. Bạn có thể khởi tạo biến ten cho HinhTron ở constructor HinhTron(). Các phương thức nhapBanKinh()tinhChuVi()tinhDienTich() không khai báo ở lớp cha, HinhTron tự nó thiết kế.
  • Lớp HinhTru. Lớp này là con của HinhTron, bởi như mình nói trên kia, HinhTru có các mặt tròn, mặt tròn này không khác gì các đặc tính của một HinhTron, nên HinhTron nên là một lớp cơ bản của HinhTru. Chính vì HinhTru kế thừa các giá trị từ HinhTron, mà HinhTron kế thừa từ HinhHoc, nên HinhTru có đủ hết các giá trị của HinhHoc và HinhTron. Nó chỉ cần thêm thuộc tính chieuCao, và các phương thức nhapChieuCao()tinhTheTich() của riêng nó nữa mà thôi.
  • Tương tự cho các mối quan hệ của HinhChuNhat và HinhVuong. Có một điều đặc biệt ở lớp HinhVuong, đó là vì các cạnh dài và rộng của hình này bằng nhau, nên phương thức nhapCanh() của hình vuông chỉ kêu người dùng nhập một cạnh, sau đó bạn gán cùng giá trị cạnh này cho các biến dai và rong, và thế là HinhVuong không cần phải xây dựng thêm các phương thức nào cả, kế thừa hoàn toàn xuất sắc từ lớp cha của nó.

Kết quả chạy chương trình giống giống như sau.

Kết quả thực thi chương trình
Kết quả thực thi chương trình

Về phần project, mình tạo ra một project mới tên là InheritanceLearning. Với cách tổ chức các lớp vào trong các package như sau.

Tổ chức lớp trong chương trình
Tổ chức lớp trong chương trình

Sau đây là source code của các lớp tương ứng trong chương trình cho bạn tham khảo.

Lớp HinhHoc.

package shapes;
 
public class HinhHoc {
public final float PI = 3.14f;
 
public String ten;
public float chuVi;
public float dienTich;
public float theTich;
 
public void xuatTen() {
System.out.println("\n\n===== " + ten + " =====");
}
 
public void inChuVi() {
System.out.println("Chu vi = " + chuVi);
}
 
public void inDienTich() {
System.out.println("Diện tích = " + dienTich);
}
 
public void inTheTich() {
System.out.println("Thể tích = " + theTich);
}
}

Lớp HinhTron.

package shapes;
 
import java.util.Scanner;
 
public class HinhTron extends HinhHoc {
public float banKinh;
 
// Constructor
public HinhTron() {
ten = "Hình Tròn";
}
 
public void nhapBanKinh() {
System.out.println("Bán kính = ");
Scanner scanner = new Scanner(System.in);
banKinh = scanner.nextFloat();
}
 
public void tinhChuVi() {
chuVi = 2 * PI * banKinh;
}
 
public void tinhDienTich() {
dienTich = PI * banKinh * banKinh;
}
}

Lớp HinhTru.

package shapes;
 
import java.util.Scanner;
 
public class HinhTru extends HinhTron {
public float chieuCao;
 
// Constructor
public HinhTru() {
ten = "Hình Trụ";
}
 
public void nhapChieuCao() {
nhapBanKinh();
 
System.out.println("Chiều cao = ");
Scanner scanner = new Scanner(System.in);
chieuCao = scanner.nextFloat();
}
 
public void tinhTheTich() {
tinhDienTich();
theTich = dienTich * chieuCao;
}
}

Lớp HinhChuNhat.

package shapes;
 
import java.util.Scanner;
 
public class HinhChuNhat extends HinhHoc {
public float dai;
public float rong;
 
// Constructor
public HinhChuNhat() {
ten = "Hình Chữ Nhật";
}
 
public void nhapChieuDai() {
System.out.println("Chiều dài = ");
Scanner scanner = new Scanner(System.in);
dai = scanner.nextFloat();
}
 
public void nhapChieuRong() {
System.out.println("Chiều rộng = ");
Scanner scanner = new Scanner(System.in);
rong = scanner.nextFloat();
}
 
public void tinhChuVi() {
chuVi = 2 * (dai + rong);
}
 
public void tinhDienTich() {
dienTich = dai * rong;
}
}

Lớp HinhVuong.

package shapes;
 
import java.util.Scanner;
 
public class HinhVuong extends HinhChuNhat {
// Constructor
public HinhVuong() {
ten = "Hình Vuông";
}
 
public void nhapCanh() {
System.out.println("Cạnh = ");
Scanner scanner = new Scanner(System.in);
dai = rong = scanner.nextFloat();
}
}

Và cuối cùng là lớp MainClass.

package main;
 
import shapes.HinhChuNhat;
import shapes.HinhTron;
import shapes.HinhTru;
import shapes.HinhVuong;
 
public class MainClass {
public static void main(String[] args) {
// Thử nghiệm với lớp Hình tròn
HinhTron hinhTron = new HinhTron();
hinhTron.xuatTen();
hinhTron.nhapBanKinh();
hinhTron.tinhChuVi();
hinhTron.tinhDienTich();
hinhTron.inChuVi();
hinhTron.inDienTich();
 
// Thử nghiệm với lớp Hình trụ
HinhTru hinhTru = new HinhTru();
hinhTru.xuatTen();
hinhTru.nhapChieuCao();
hinhTru.tinhTheTich();
hinhTru.inTheTich();
 
// Thử nghiệm với lớp Hình chữ nhật
HinhChuNhat hinhChuNhat = new HinhChuNhat();
hinhChuNhat.xuatTen();
hinhChuNhat.nhapChieuDai();
hinhChuNhat.nhapChieuRong();
hinhChuNhat.tinhChuVi();
hinhChuNhat.tinhDienTich();
hinhChuNhat.inChuVi();
hinhChuNhat.inDienTich();
 
// Thử nghiệm với lớp Hình vuông
HinhVuong hinhVuong = new HinhVuong();
hinhVuong.xuatTen();
hinhVuong.nhapCanh();
hinhVuong.tinhChuVi();
hinhVuong.tinhDienTich();
hinhVuong.inChuVi();
hinhVuong.inDienTich();
}
}

Từ Khóa this & Từ Khóa super

Vậy là bạn đã vừa mới làm quen với kế thừa trong Java từ bài học hôm trước, qua đó bạn đã biết làm thế nào để khai báo một mối quan hệ kế thừa, khi nào nên kế thừa, và đặc tính thừa kế lại các giá trị từ lớp cha cho lớp con là như thế nào.

Sang đến bài học hôm nay, chúng ta đành tạm khoan hãy nói về tính phủ quyết trong kế thừa, mà hãy xem định nghĩa và cách sử dụng của hai loại từ khóa this và super. Chúng khá quan trọng, nhưng nếu nói sớm quá thì không được, vì chúng có liên quan đến tính kế thừa, mà nói trễ quá thì các bạn sẽ không thể hiểu được một số chỗ cần sử dụng chúng.

Nào chúng ta hãy bắt đầu.

Từ Khóa this

Chắc các bạn còn nhớ. Từ khóa this này đã được mình dùng đến ở bài 17 rồi, là khi bạn muốn phân biệt đâu là biến còn đâu là thuộc tính của lớp, nếu chúng có trùng tên với nhau. Và ở bài học đó mình cũng chưa nói rõ về this lắm. Mục này mình sẽ giúp bạn liệt kê tất cả các công dụng mà this mang lại.

Như bạn đã biết, từ khóa this mang ý nghĩa là: chính là đối tượng này. Nó tham chiếu ngược lại đến đối tượng, giúp bạn có thể truy xuất đến các giá trị của đối tượng đó. Sau đây là một vài vai trò hữu ích của this, bạn sẽ còn được thấy this được sử dụng khá nhiều trong các bài học Java về sau, và cả trong lập trình Android nữa.

Sử Dụng this Khi Truy Xuất Đến Thuộc Tính Và Phương Thức Trong Lớp

Như bạn có làm quen từ bài 17, bạn đã dùng đến this để truy xuất đến thuộc tính của một lớp, thì vai trò của this cũng sẽ tương tự khi bạn dùng để truy xuất đến phương thức.

Mục đích chính của việc sử dụng this khi này như bạn đã biết, đó là giúp phân biệt được đâu là biến và đâu là thuộc tính của lớp, khi chúng có cùng tên với nhau. Tuy nhiên, bạn hoàn toàn có thể sử dụng this ở mọi lúc mọi nơi khi muốn truy xuất đến các thuộc tính và phương thức này, như ví dụ dưới đây. Nhưng bạn cũng đừng nên sử dụng this tùy tiện quá, nó khiến cho code của chúng ta trở nên rườm rà, như ví dụ dưới hơi bị lạm dụng this, chỉ là mình muốn hiển thị chúng nhiều nhiều cho các bạn xem chơi thôi.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class HinhTron {
 
    public float banKinh;
 
    // Constructor
    public HinhTron(float banKinh) {
        this.banKinh = banKinh;
    }
 
    public void tinhChuVi() {
        // Tính chu vi hình tròn và in ra console
    }
 
    public void tinhDienTich() {
        // Tính diện tích hình tròn và in ra console
    }
 
    public void inHinhTron() {
        System.out.println("Hình tròn bán kính = " + this.banKinh);
        this.tinhChuVi();
        this.tinhDienTich();
    }
}

Sử Dụng this Khi Gọi Đến Một Constructor Khác Bên Trong Lớp

Theo lẽ ở bài về constructor mình nên nói luôn về cách sử dụng này của this, nhưng mình cố tình để đến bài hôm nay sẽ nói chung một lượt.

Nếu như trong một lớp của bạn có nhiều constructor, và bạn muốn một constructor nào đó gọi đến một constructor khác, thường thì kiểu gọi này giúp các constructor tận dụng lại được các code khởi tạo của nhau, tránh việc viết lại.

this được dùng đến trong trường hợp này hơi khác trường hợp trên một chút, đó là bạn phải gọi với tham số truyền vào như này this([tham_số_truyền_vào]), như khi bạn gọi đến một phương thức vậy. Mình gọi tắt thành this() cho dễ trình bày.

Lúc này, khi gọi đến, this() sẽ gọi đến một constructor nào đó, tùy vào tham_số_truyền_vào cho nó mà constructor tương ứng được gọi. Nhưng việc gọi đến một constructor khác này không tạo ra một lớp mới, mà chỉ là tận dụng lại constructor như khi bạn gọi hàm nào đó bên trong một class mà thôi. Bạn xem ví dụ.

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
public class HinhChuNhat extends HinhHoc {
 
    public float dai;
    public float rong;
 
    // Constructor
    public HinhChuNhat() {
        ten = "Hình Chữ Nhật";
    }
 
    // Constructor
    public HinhChuNhat(float dai, float rong) {
        this(); // Gọi đến HinhChuNhat()
        this.dai = dai;
        this.rong = rong;
    }
 
    // Constructor
    public HinhChuNhat(float canh) {
        this(canh, canh); // Gọi đến HinhChuNhat(dai, rong)
    }
 
    public void tinhChuVi() {
        chuVi = 2 * (dai + rong);
    }
 
    public void tinhDienTich() {
        dienTich = dai * rong;
    }
 
}

Có một điều lưu ý rằng, từ khóa this() dùng trong trường hợp này chỉ được dùng trong các constructor, để mà tận dụng lại các constructor khác như ví dụ trên, nếu bạn để this() này vào các phương thức bình thường khác, sẽ có báo lỗi xảy ra từ hệ thống. Thêm một điều nữa, là this() nếu có, thì nó phải là dòng code đầu tiên bên trong một constructor, trường hợp dưới đây là sai, hệ thống sẽ báo lỗi vì trước this() có các dòng code khác.

1
2
3
4
5
6
// Constructor
public HinhChuNhat(float dai, float rong) {
    this.dai = dai;
    this.rong = rong;
    this();
}

Sử Dụng this Làm Tham Số Truyền Vào Một Phương Thức Hay Một Constructor Khác

Về phần này thì qua đến lập trình Android, bạn sẽ dùng đến nhiều hơn. Còn với khuôn khổ bên bài học Java này, sẽ hơi khó tìm thấy một ví dụ thực tế nào hay ho, do đó mình mượn code trên mạng về cho bạn thấy cách sử dụng từ khóa this cho mục đích tham số lần này. Bạn cũng sẽ thấy dễ hiểu thôi.

Ví dụ sau là dành cho việc sử dụng this làm tham số truyền vào một phương thức. Trường hợp cũng tương tự cho việc sử dụng this để làm tham số truyền vào một constructor. Nhìn vào ví dụ, ở trong lớp Bar, lớp Foo được khai báo làm tham số cho hàm barMethod() của lớp Bar. Sau đó ở lớp Foo, chỗ dòng được tô sáng, lớp này chỉ cần truyền vào barMethod() thông qua từ khóa this như vậy giúp lớp Bar được phép sử dụng Foo là một lớp đã được khai báo hoàn chỉnh, tức là bạn không cần sử dụng từ khóa new để tạo ra một lớp Foo nào nữa.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Foo {
    public void useBarMethod() {
        Bar theBar = new Bar();
        theBar.barMethod(this);
    }
 
    public String getName() {
        return "Foo";
    }
}
 
public class Bar {
    public void barMethod(Foo obj) {
        obj.getName();
    }
}

Sử Dụng this Là Một Thể Hiện Của Kết Quả Trả Về

Trường hợp trả về một kết quả là this, kết quả trả về chính thể hiện của lớp đó. Công dụng này của this có thể làm bạn, và cả mình nữa, cảm thấy bối rối. Bạn hãy nhìn vào ví dụ sau trước khi xem điều bối rối mà mình muốn nói đến là gì nhé, và vì sự bối rối này mà bạn có thể không cần dùng đến this trong trường hợp này ở ngoài thực tế. Nếu bạn nào biết rõ this trong trường hợp này thực sự mang ý nghĩa ra sao, để giúp mọi người bớt bối rối, thì hãy để lại comment cho mình nhé.

Đầu tiên mình có một lớp Student. Bạn thấy ở phương thức getStudent(), lớp này trả ra ngoài từ khóa this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Student {
 
    public String name;
    public String age;
 
    // Constructor
    public Student(String name, String age) {
        this.name = name;
        this.age = age;
    }
 
    public Student getStudent() {
        return this;
    }
}

Sau đó, ở hàm main() có khai báo lớp Student, bạn xem main() sử dụng đến hàm getStudent() như thế nào nhé.

1
2
3
4
5
6
7
8
9
10
public class MainClass {
 
    public static void main(String[] args) {
        Student student = new Student("Yellow", "20");
 
        System.out.println("Name: " + student.getStudent().name);
        System.out.println("Age: " + student.getStudent().age);
    }
 
}

Thực ra hàm getStudent() trong lớp Student giúp trả về this, chính là thể hiện hiện tại của lớp, và vì vậy khi bạn gọi đến student.getStudent().name, thực chất vẫn là đang truy xuất đến thuộc tính name của Student, và vì vậy kết quả in ra console của code trên sẽ lần lượt là “Yellow” và “20”. Bối rối thật sự xảy ra, khi bạn hoàn toàn có thể in ra kết quả như trên mà không cần đến getStudent() như sau. Bạn hãy chú ý sự khác biệt giữa hai hàm main().

1
2
3
4
5
6
7
8
9
10
public class MainClass {
 
    public static void main(String[] args) {
        Student student = new Student("Yellow", "20");
 
        System.out.println("Name: " + student.name);
        System.out.println("Age: " + student.age);
    }
 
}

Từ Khóa super

Khác với từ khóa this giúp tham chiếu đến chính đối tượng hiện tại. Từ khóa super lại giúp tham chiếu đến lớp cha, mà là lớp cha gần nhất của nó. Tức là bạn có thể hiểu vui là super không thể được sử dụng để tham chiếu đến lớp “ông nội” được nhé.

Dưới đây tiếp tục là một vài vai trò của super, bạn có thể so sánh với vai trò của this trên kia để xem sự khác biệt, và để dễ nhớ nữa.

Sử Dụng super Khi Truy Xuất Đến Thuộc Tính Và Phương Thức Của Lớp Cha Gần Nhất

Nếu như mục đích đầu tiên của this trên kia là để phân biệt đâu là biến và đâu là thuộc tính khi chúng nó bị trùng tên trong một lớp. Thì mục đích đầu tiên của super này là để phân biệt đâu là giá trị của lớp con và đâu là giá trị của lớp cha gần nhất khi chúng bị trùng tên. Sự trùng tên giữa phương thức của lớp con và lớp cha xảy ra nhiều hơn là trùng tên giữa các thuộc tính, và sự trùng tên này là có chủ đích, bạn có thể xem trước bài học về tính phủ quyết để hiểu rõ hơn vì sao có sự trùng tên này.

Ví dụ sau cho thấy lớp HinhTron định nghĩa trùng phương thức xuatTen() với cha của nó là HinhHoc, và rồi super xuất hiện ở xuatTen() của HinhTron giúp nó gọi đến xuatTen() ở cha trước, rồi mới đến các code còn lại bên trong xuatTen() của HinhTron, bạn có thể chạy thử code bên dưới.

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
public class HinhHoc {
 
    public String ten;
 
    public void xuatTen() {
        System.out.println("\n\n===== " + ten + " =====");
    }
 
    // Các phương thức khác
    // ...
}
 
public class HinhTron extends HinhHoc {
 
    // Constructor
    public HinhTron() {
        ten = "Hình Tròn";
    }
 
    public void xuatTen() {
        super.xuatTen();
        System.out.println("\nHàm xuất tên từ HinhTron");
    }
 
    // Các phương thức khác
    // ...
}
 
public class MainClass {
 
    public static void main(String[] args) {
        // Thử nghiệm với lớp Hình tròn
        HinhTron hinhTron = new HinhTron();
        hinhTron.xuatTen();
    }
 
}

Sử Dụng super Khi Gọi Đến Một Constructor Của Lớp Cha Gần Nhất

Cũng tương tự như khi bạn dùng this([tham_số_truyền_vào])super([tham_số_truyền_vào]) giúp bạn gọi đến, và tận dụng lại các code khởi tạo bên lớp cha gần nhất.

Và cũng giống với từ khóa this()super() chỉ được dùng trong các constructor, nếu bạn để super() này vào các phương thức bình thường khác, sẽ có báo lỗi xảy ra từ hệ thống. Và tương tự, từ khóa super() nếu có, thì nó phải được khai báo đầu tiên bên trong một constructor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class HinhHoc {
 
    public String ten;
 
    // Constructor
    public HinhHoc(String ten) {
        this.ten = ten;
    }
 
    // Các phương thức khác
    // ...
}
 
public class HinhTron extends HinhHoc {
 
    // Constructor
    public HinhTron() {
        super("Hình Tròn");
    }
 
    // Các phương thức khác
    // ...
}

Như vậy là chúng ta vừa xem xong các khái niệm và trường hợp sử dụng của hai từ khóa this và super. Chúng khá quan trọng và xuất hiện nhiều trong các project hay các ví dụ của chúng ta từ bây giờ, các bạn hãy từ từ nắm vững và thực tập vận dụng chúng nhé. Tất nhiên bài hôm nay chỉ có lý thuyết, các thực hành liên quan đến hai từ khóa này sẽ xuất hiện ở các bài học sau.

Tính Phủ Quyết (Overriding) Trong Kế Thừa

Với bài học hôm nay, mình sẽ bổ sung kiến thức tiếp theo trong phần kiến thức về kế thừa. Nếu như ở bài 21, các bạn đã biết cách thức sử dụng từ khóa extends để thể hiện sự kế thừa từ một lớp tới một lớp khác. Và khi đó, bạn cũng làm quen được việc tận dụng lại tất cả các giá trị từ một lớp cha (hay lớp cơ sở) để lại cho lớp con (hay lớp dẫn xuất), lúc đó mình gọi đây là sự dùng lại trong kế thừa. Hôm nay chúng ta sẽ xem đến một khía cạnh tiếp theo trong kế thừa, nó không còn ý nghĩa dùng lại nữa, mà là sự phủ quyết (overriding).

Tính Phủ Quyết (Overriding) Là gì?

Mình quen dùng từ phủ quyết, có thể là do thói quen từ thời còn sử dụng C++ hay C#. Sang đến ngôn ngữ Java mình thấy người ta hay gọi là ghi đè. Bạn cũng có thể không cần quan tâm đến hai từ tiếng Việt này, mà chỉ cần nhớ từ tiếng Anh của nó là overriding là được.

Như mình có nói ở trên, khi bạn làm quen với kế thừa, bạn biết rằng khái niệm này ám chỉ đặc tính dùng lại mà chúng ta đã tìm hiểu sơ qua ở bài 21, đặc tính này cho phép các lớp con có thể mặc định có được các phương thức và thuộc tính mà lớp cha của nó đã khai báo (và cho phép).

Vậy còn đặc tính phủ quyết? Đặc tính này cho phép lớp con có thể khai báo các phương thức trùng với các phương thức của lớp cha, rồi sau đó lớp con đó sẽ định nghĩa lại nội dung của phương thức đó. Nó mang ý nghĩa “không chấp nhận” phương thức đó của lớp cha. Chính vì ý nghĩa này mà người ta hay gọi là phủ quyết (hay ghi đè, hay overriding) là vậy.

Phủ Quyết (Overriding) Như Thế Nào?

Như đã nói ở trên, để thực hiện cho nhu cầu overriding, bạn chỉ cần đặt tên phương thức ở lớp con trùng tên và tham số truyền vào với phương thức đã có ở lớp cha. Sau đó, tuy không bắt buộc, nhưng bạn nên khai báo một annotation (đừng dịch annotation ra tiếng Việt) là @Override ở mỗi phương thức overriding để có sự rõ ràng về code. Với phương thức đã override này ở lớp con, bạn có thể thiết kế lại logic bên trong nó, một khi ở nơi nào đó gọi đến phương thức đó của lớp con, thay vì sử dụng phương thức của lớp cha theo nguyên lý kế thừa, thì phương thức của lớp con được sử dụng.

Chúng ta cùng xem ví dụ sau. Ví dụ sẽ sử dụng hai lớp HinhTron và HinhTru, trong đó HinhTru kế thừa từ HinhTron. Chúng ta hãy thử khai báo phương thức xuatThongTin() ở cả hai lớp, theo sơ đồ lớp sau.

Sơ đồ lớp của HinhTron và HinhTru
Sơ đồ lớp của HinhTron và HinhTru

Code của chúng cũng đơn giản như sau.

public class HinhTron {
public void xuatThongTin() {
System.out.println("Đây là Hình tròn");
}
}
public class HinhTru extends HinhTron {
@Override
public void xuatThongTin() {
System.out.println("Đây là Hình trụ");
}
}

Với việc khai báo hai lớp như trên, thì bạn xem cách gọi đến chúng ở main() như thế nào nhé.

public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron();
HinhTru hinhTru = new HinhTru();
 
hinhTron.xuatThongTin();
hinhTru.xuatThongTin();
}
}

Nếu thực thi chương trình, bạn sẽ thấy hai lệnh xuatThongTin() ở hai lớp sẽ in ra console như sau.

Đây là Hình tròn
Đây là Hình trụ

Đó là bởi vì xuatThongTin() ở HinhTron đã bị override (hay bị phủ quyết, ghi đè) bởi xuatThongTin() ở HinhTru. Nếu bạn thử nghiệm bằng cách xóa xuatThongTin() ở HinhTru đi, bạn sẽ nhận được hai dòng in ra console như nhau, đó là bởi vì nếu không có sự override này, thì đặc tính dùng lại của kế thừa sẽ được tận dụng. Bạn đã hiểu khái niệm overriding rồi đúng không nào.

Có một ý mở rộng cho bạn. Đó là nếu bạn muốn phương thức overriding có dùng đến phương thức trùng nhau đó của lớp cha, thì bạn cứ mạnh dạn sử dụng từ khóa super như ví dụ dưới đây nhé.

public class HinhTru extends HinhTron {
@Override
public void xuatThongTin() {
super.xuatThongTin();
System.out.println("Đây là Hình trụ");
}
}

Thực Hành Kế Thừa (Có Tính Phủ Quyết)

Lý thuyết của overriding chỉ có như trên đây thôi. Dễ nhớ đúng không nào.

Giờ thì chúng ta thử tạo ra một mối quan hệ hoàn chỉnh giữa hai lớp HinhTron và HinhTru. Tất nhiên vẫn là quan hệ kế thừa, nhưng bạn sẽ thấy đầy đủ hai tính năng dùng lại và phủ quyết.

Bạn có thể tạo project mới cho bài thực hành này.

Chúng ta cùng nhìn qua sơ đồ lớp sau.

Sơ đồ lớp của bài thực hành
Sơ đồ lớp của bài thực hành

Ở lớp HinhTron, chúng ta không cần phương thức kêu người dùng nhập bán kính từ console như các bài thực hành trước nữa, mà hãy truyền bán kính này vào constructor luôn cho nó lẹ. Các phương thức tinhChuVi() và tinhDienTich() đều trả kết quả tính toán dựa vào bán kính có được từ constructor. Và cuối cùng, phương thức xuatThongTin() sẽ như ví dụ trên kia, phương thức này sẽ bị override bởi lớp HinhTru kế thừa sau đó.

public class HinhTron {
public final float PI = 3.14f;
public float banKinh;
 
// Constructor
public HinhTron(float banKinh) {
this.banKinh = banKinh;
}
 
public float tinhChuVi() {
return 2 * PI * banKinh;
}
 
public float tinhDienTich() {
return PI * banKinh * banKinh;
}
 
public void xuatThongTin() {
System.out.println("Đây là Hình tròn");
System.out.println("Hình tròn có Chu vi: " + tinhChuVi() + " và Diện tích: " + tinhDienTich());
}
}

Ở lớp Hinhtru, lớp này kế thừa các thuộc tính PI và banKinh, các phương thức tinhChuVi() và tinhDienTich()HinhTru khai báo thêm thuộc tính chieuCao và phương thức tinhTheTich(). Và cuối cùng, phương thức xuatThongTin() sẽ override lại so với lớp cha.

public class HinhTru extends HinhTron {
public float chieuCao;
 
// Constructor
public HinhTru(float banKinh, float chieuCao) {
super(banKinh);
this.chieuCao = chieuCao;
}
 
public float tinhTheTich() {
return tinhDienTich() * chieuCao;
}
 
@Override
public void xuatThongTin() {
System.out.println("Đây là Hình trụ");
System.out.println("Hình trụ có Thể tích: " + tinhTheTich());
}
}

Xong. Bây giờ thì chúng ta sẽ sử dụng chúng ở main().

public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron(10);
HinhTru hinhTru = new HinhTru(10, 20);
 
hinhTron.xuatThongTin();
hinhTru.xuatThongTin();
}
}

Và đây là kết quả khởi chạy chương trình.

Đây là Hình tròn
Hình tròn có Chu vi: 62.800003 và Diện tích: 314.0
Đây là Hình trụ
Hình trụ có Thể tích: 6280.0

Lớp Object

Trước hết mình xin chúc mừng các bạn đã vượt qua nhiều cửa ải khó khăn trong việc tiếp cận ngôn ngữ lập trình Java, và cả OOP nữa. Đến đây, nếu bạn nào chưa từng lập trình Android, mà muốn tìm hiểu cách viết ứng dụng trên hệ điều hành này, thì bạn hãy bắt đầu đọc các bài học bên lập trình Android được rồi nhé. Tuy nhiên, dù thành tích học hành của các bạn rất đáng nể, nhưng chúng ta vẫn còn khá nhiều kiến thức ở phía trước, do đó mình hi vọng các bạn vẫn luôn sẵn sàng lòng tin và sự hứng khởi trong suốt các bài học về Java và cả các bài học về các ngôn ngữ lập trình khác mà Yellow Code Books mang lại.

Bài hôm nay chúng ta tiếp tục nói sâu hơn về OOP, đặc biệt vẫn là xoay quanh về tính Kế thừa. Ôn lại một chút rằng, nếu như ở ngày nào đấy bạn vừa mới tiếp cận vào kế thừa, rồi bạn làm quen tiếp đến sự phủ quyết, hay ghi đè trong kế thừa, thì đến bài hôm nay, bạn sẽ được làm quen với một lớp, có tên gọi là lớp Object, để xem lớp này ảnh hưởng như thế nào đến việc sử dụng các lớp hay các đối tượng mà bạn đã từng làm quen nhé.

Lớp Object Là Gì?

Việc xuất hiện lớp Object ở thời điểm này có thể khiến bạn có chút bối rối. Thế nhưng khi bạn vừa mới tiếp cận Java, ở các dòng code đầu tiên của bạn, lớp này đã xuất hiện rồi đó. Lớp này có cái tên là Object, nó là một lớp được hệ thống tạo ra sẵn, và hệ thống cũng chỉ định nó là lớp cha cao nhất của tất cả các lớp trong Java.

Để hiểu rõ hơn, chúng ta hãy cùng nhau xem lại khai báo lớp HinhTron đẹp đẽ từ những bài trước.

1
2
3
public class HinhTron {
 
}

Bạn thấy rằng HinhTron này không có kế thừa từ bất cứ lớp nào khác, và bạn nghĩ rằng nó là cha cao nhất, nếu như có khai báo các lớp khác extends từ nó? Không đâu nhé, như mình có nói trên đây, trong Java, nếu như bạn không chỉ định lớp cha cho một lớp nào đó, thì hệ thống mặc định cho lớp đó kế thừa từ một lớp, chính là lớp Object. Thế nhưng vì nó là mặc định được các lớp khác kế thừa đến (trừ lớp đã khai báo kế thừa đến một lớp khác rồi, vì bạn đã biết một lớp không thể có nhiều lớp cha mà), nên bạn không nhất thiết phải khai báo cho rõ ràng làm chi sự kế thừa đến lớp Object này.

Code dưới đây mình muốn viết ra cho nó tường minh thôi nhé, cho bạn biết rằng việc khai báo như thế này là ngầm định, chẳng ai viết như vậy đâu.

1
2
3
public class HinhTron extends Object {
 
}

Lớp Object Có Tác Dụng Gì?

Vâng chắc chắn bạn sẽ thắc mắc, thế thì sinh ra cái lớp quái quỷ này làm gì, để mà nó làm cha người khác à?

Thực ra, nếu đọc qua ý của mình sau đây, bạn sẽ thấy rằng lớp Object khá là quan trọng.

Thứ nhất, Object có thể dùng để bạn khai báo trước một đối tượng mà bạn còn chưa biết cái đối tượng đó là đối tượng gì, về sau khi vào từng tình huống cụ thể, bạn sẽ ép kiểu lớp Object tạm này về các lớp con cụ thể tương ứng. Nghe qua có vẻ mơ hồ, nhưng bạn hãy ghi nhớ, bạn sẽ hiểu rõ lợi ích này qua các bài học sau này.

Thứ hai, như bạn đã biết công dụng chính mà sự kế thừa mang lại, đó là công dụng giúp gom nhóm các giá trị giống nhau của các lớp vào cùng một lớp cơ sở, như ví dụ về lớp HinhHoc chính là lớp cơ sở cho các lớp HinhTronHinhChuNhatHinhVuong,… ở bài 21. Thì lớp Object cũng vậy, lớp này lúc này đóng vai trò là một lớp cơ sở cho bất kỳ lớp nào bạn tạo ra trong một project Java. Vậy thì lớp cơ sở này đây sẽ chứa các giá trị chung nhất hữu ích nào cho các lớp còn lại vậy, mời bạn xem tiếp.

Các Phương Thức Mà Lớp Object Cung Cấp

Các phương thức dưới đây của lớp Object không mang ý nghĩa thực tiễn tức thì, tức là bạn chỉ đọc qua rồi để đó thôi, sau này có dịp thì dùng đến. Bạn nên nhớ là các phương thức này được Object cung cấp sẵn cho tất cả các lớp, nên hôm nay bạn đọc sơ qua, rồi nhớ lấy, sau này có dịp thì lôi ra dùng chứ đừng tự thiết kế lại làm chi cho mất công nhé.

public final Class getClass()

Phương thức này trả về một lớp Class, lớp Class này chứa đựng các thông tin được xây dựng sẵn liên quan đến đối tượng đang gọi.

Bạn có thể tham khảo code sau, bạn thấy đó, chúng ta đâu có khai báo các phương thức này đâu, nó nằm sẵn ở lớp Object và chúng ta chỉ việc lấy ra dùng mà thôi.

1
2
3
4
5
6
public static void main(String[] args) {
    HinhTron hinhTron = new HinhTron();
    System.out.println("Thông tin đối tượng HinhTron: " + hinhTron.getClass());
    System.out.println("Thông tin đối tượng HinhTron: " + hinhTron.getClass().getName());
    System.out.println("Thông tin đối tượng HinhTron: " + hinhTron.getClass().getSimpleName());
}

Kết quả in ra vài thông tin hữu ích của đối tượng này, tùy vào phương thức bạn gọi sâu vào trong của Class.

Lớp Object - Phương thức getClass()

int hashCode()

Phương thức này trả về một giá trị hash code. Mình chưa từng dùng qua phương thức này, nhưng mình nghĩ nó có thể được dùng như các giá trị giúp phân biệt các đối tượng với nhau.

Như code dưới đây, bạn có thể thấy khi in hash code của hai đối tượng của cùng một lớp.

1
2
3
4
5
6
7
public static void main(String[] args) {
    HinhTron hinhTron1 = new HinhTron();
    HinhTron hinhTron2 = new HinhTron();
 
    System.out.println("Hashcode của HinhTron 1: " + hinhTron1.hashCode());
    System.out.println("Hashcode của HinhTron 2: " + hinhTron2.hashCode());
}

Lớp Object - Phương thức hashCode()

boolean equals(Object obj)

Phương thức equals() này có quen không bạn? Không à? Nhớ lại đi. 🙂

Thực ra thì ở bài 13, bài học về Chuỗi, mình có nói đến phương thức equals() này ở Chuỗi giúp so sánh hai Chuỗi có giống nhau không. Giờ thì bạn đã biết rằng Chuỗi cũng là một Đối tượng, và vì vậy nó cũng sẽ kế thừa phương thức equals() này từ lớp cha Object. Nhưng thú vị hơn, đó là equals() của Chuỗi đã có override lại phương thức này của lớp cha chứ không hoàn toàn kế thừa nhé.

Vậy cũng tương tự như Chuỗi, phương thức equals() này giúp so sánh hai đối tượng bất kỳ với nhau xem chúng có giống nhau không. Hai đối tượng được gọi là giống nhau, không phải là do chúng được tạo ra từ một lớp, mà vì chúng có giá trị tham chiếu như nhau. Vấn đề về tham chiếu này thực chất có liên quan đến kiến thức về con trỏ, nhưng Java không muốn các lập trình viên của mình biết về con trỏ, vậy thì mình sẽ nói đến cái khái niệm tham chiếu sau nhé, lằng nhằng lắm.

Chắc bạn cũng đoán được kết quả trả về của hai cách so sánh dưới đây rồi đúng không nào.

1
2
3
4
5
6
public static void main(String[] args) {
    HinhTron hinhTron1 = new HinhTron();
    HinhTron hinhTron2 = new HinhTron();
 
    System.out.println(hinhTron1.equals(hinhTron2));
}
1
2
3
4
5
6
public static void main(String[] args) {
    HinhTron hinhTron1 = new HinhTron();
    HinhTron hinhTron2 = hinhTron1;
 
    System.out.println(hinhTron1.equals(hinhTron2));
}

Bạn cũng có thể in ra hash code giữa chúng để có thể thấy mối liên hệ giữa equals() và hashCode() nhé.

Object clone()

Phương thức này giúp khởi tạo và trả về một bản sao của đối tượng được gọi. Thực chất phương thức này mình chỉ nêu ra mà không có ví dụ cụ thể cho nó, vì hiện tại lớp Object đang khai báo Khả năng truy cập của phương thức này là protected. Bạn sẽ hiểu loại khả năng truy cập này sau, nhưng đại loại là nó được bảo vệ và chỉ hữu dụng khi lớp con của nó override phương thức này thôi.

String toString()

Phương thức này giúp trả về một kiểu Chuỗi diễn đạt cho đối tượng này. Nội dung của nó là sự kết hợp của chuỗi (getClass().getName() + “@” + Integer.toHexString(hashCode())).

Có một điều thú vị rằng là, phương thức này có thể không cần phải gọi đến một cách tường minh. Chính vì vậy mà các đối tượng Chuỗi mà bạn đã làm quen không cần bất cứ câu lệnh toString() nào, như ví dụ dưới đây cho thấy chương trình in ra hai đối tượng, một Chuỗi, cộng với một hinhTron1.

1
2
3
4
5
public static void main(String[] args) {
    HinhTron hinhTron1 = new HinhTron();
 
    System.out.println("Không cần gọi toString() tường minh: " + hinhTron1);
}

Nó tương đương với cách bạn gọi toString() một cách tường minh như sau, nhưng bạn không cần phải làm vậy.

1
2
3
4
5
public static void main(String[] args) {
    HinhTron hinhTron1 = new HinhTron();
 
    System.out.println("Thử gọi toString() tường minh: ".toString() + hinhTron1.toString());
}

Kết quả thực thi của chúng là giống nhau.

Lớp Object - Phương thức toString()

void finalize()

Các lớp con của Object có thể overide lại phương thức này, để có thể khai báo vào trong đó các hành động giải phóng các tài nguyên đang dùng, trước khi bộ dọn rác (Garbage Collection – Mình có nhắc đến bộ dọn rác này một chút ở bài mở màn) tiến hành dọn dẹp đối tượng không còn được sử dụng này.

Bạn cũng thấy rằng phương thức này được gọi một cách tự động bởi hệ thống. Và nó cũng khá chuyên sâu nữa, thỉnh thoảng được dùng đến khi bạn đang sử dụng và muốn giải phóng các tài nguyên liên quan đến đọc file, hay kết nối mạng, mà chúng ta sẽ làm quen ở các bài sau.

Ngoài các phương thức hữu ích trên mà lớp Object mang lại cho các lớp con của nó, thì còn có một số phương thức khác nữa, như notify()notifyAll()wait() sẽ được mình nhắc đến ở các bài học có liên quan đến chúng sau.

Khả Năng Truy Cập (Access Modifier)

Bài hôm nay chúng ta sẽ nói đến một vấn đề đã được hứa khá lâu, từ khi các bạn mới vừa làm quen với các thuộc tính và phương thức của lớp, đó là kiến thức về khả năng truy cập vào các thành phần của lớp. Vậy thì cái khả năng truy cập này là gì và chúng quan trọng như thế nào, mời các bạn cùng theo dõi bài học nhé.

Khả Năng Truy Cập (Access Modifier) Là Gì?

Khả năng truy cập, hay còn gọi là access modifier, là các từ khóa được Java cung cấp sẵn. Và bạn, hay các lập trình viên khác sẽ sử dụng các định nghĩa của các từ khóa này, để thể hiện các quyền được truy cập vào các thành phần của lớp. Các thành phần của lớp ở đây mình muốn nhắc đến bao gồm các thuộc tính, các phương thức, và cả các constructor của một lớp.

Các từ khóa liên quan đến access modifier trong Java chính là: privateprotectedpublic và default.

Định Nghĩa Khả Năng Truy Cập Như Thế Nào?

Chúng ta khoan hãy nói đến từng khả năng truy cập đã nêu trên, mà hãy điểm lại xem làm cách nào để khai báo một khả năng truy cập nhé.

Bạn hãy nhớ lại đi, trong các cú pháp liên quan đến việc khai báo các thành phần của lớp, mình đều có nêu cách khai báo khả năng truy cập này rồi đấy.

Chẳng hạn như khi khai báo thuộc tính của lớp này.

[khả_năng_truy_cậpkiểu_thuộc_tính tên_thuộc_tính [= giá_trị_ban_đầu];

Hay khi khai báo phương thức của lớp này.

[kả_năng_truy_cậpkiểu_trả_về tên_phương_thức  () {
     // Các dòng code
}

Hay khi khai báo một constructor này.

[khả_năng_truy_cập]  tên_phương_thức  () {
     // Các dòng code
}

Vậy bạn có thể hiểu cách để định nghĩa một khả năng truy cập vào các thành phần lớp rồi đúng không nào. Dưới đây là một ví dụ đầy đủ cho việc khai báo các khả năng truy cập này ở lớp HinhTron.

public class HinhTron {
protected final float PI = 3.14f;
private float banKinh;
 
// Constructor
public HinhTron(float banKinh) {
this.banKinh = banKinh;
}
 
protected float tinhChuVi() {
return 2 * PI * banKinh;
}
 
protected float tinhDienTich() {
return PI * banKinh * banKinh;
}
 
public void xuatThongTin() {
System.out.println("Đây là Hình tròn");
}
}

Ý Nghĩa Của Các Khả Năng Truy Cập

Nào, giờ chúng ta sẽ đi qua từng khả năng truy cập xem chúng có ý nghĩa và lợi ích gì nhé.

Trước hết, mình xin tổng hợp các khả năng truy cập dựa trên một bảng dưới đây, để cho các bạn dễ nhớ. Bảng này chỉ đơn giản mang các giá trị “Có” nếu như định nghĩa này cho phép truy cập ở một phạm vi nào đó, và mang giá trị “Không” cho ý nghĩa ngược lại.

Phạm viprivatedefaultprotectedpublic
Bên trong lớp
Ở lớp khác, nhưng trong cùng package Không
Ở lớp con, trong cùng package Không
Ở lớp con, khác package Không Không
Ở bất cứ lớp nào khác (không phải lớp con), khác package Không Không Không
Bảng ý nghĩa các khả năng truy cập

Giờ mình sẽ nói rõ hơn từng khả năng truy cập.

Khả Năng Truy Cập private

Nếu bạn chỉ định một thành phần nào đó (thuộc tính, phương thức hay constructor) của lớp là private, thì như bảng trên, bạn chỉ có thể truy xuất đến thành phần này bên trong lớp đó mà thôi.

Khả năng private này mang ý nghĩa bảo vệ các thành phần bên trong lớp, không cho các lớp bên ngoài có thể “nhìn thấy” (đọc và chỉnh sửa) được.

  • Nếu private được chỉ định cho một thuộc tính, thuộc tính đó sẽ không được phép truy cập, hay chỉnh sửa từ các lớp khác, trừ khi bạn xây dựng các phương thức getter và setter cho thuộc tính đó mà mình sẽ nói sau.
  • Còn nếu private được chỉ định cho một phương thức, phương thức đó sẽ không được truy cập hay kế thừa từ lớp khác.
  • Và nếu private được chỉ định cho constructor, constructor này sẽ không dùng được để khởi tạo đối tượng cho lớp đó.

Bạn nên tập một thói quen, đó là luôn khai báo private cho các thuộc tính bên trong lớp. Với phương thức thì nếu bạn không chắc nó có được dùng ở đâu đó bên ngoài lớp này hay không, thì cứ chỉ định private luôn cho chắc cú.

Bài Thực Hành Số 1

Bài thực hành này mình tạo lớp HinhTron có khai báo các thành phần là private. Bạn có thể thấy các thành phần này khi dùng bên trong lớp HinhTron thì rất tốt, không bị báo lỗi từ hệ thống.

public class HinhTron {
private final float PI = 3.14f;
private float banKinh;
 
// Constructor
private HinhTron() {
}
 
private float tinhChuVi() {
return 2 * PI * banKinh;
}
 
private float tinhDienTich() {
return PI * banKinh * banKinh;
}
}

Vậy bạn có thể tự trắc nghiệm xem, với việc gọi đến các thành phần này của HinhTron từ main(), thì dòng code nào sau đây sẽ bị hệ thống báo lỗi nhé.

public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron();
hinhTron.banKinh = 20;
hinhTron.tinhDienTich();
}
}

Kết quả dòng bị báo lỗi ở dưới đây.

Dòng 3 báo lỗi
Dòng 4 báo lỗi
Dòng 5 báo lỗi

Khả Năng Truy Cập default

Thực ra không có một kiểu định nghĩa khả năng default nào cả. default ở đây có nghĩa là bạn không định nghĩa khả năng truy cập gì hết, nó giống như các bài học mở đầu mà chúng ta đã làm quen, khi đó chúng ta tạm thời để trống các khai báo khả năng truy cập này.

Và với việc để trống khả năng truy cập cho các thành phần của lớp, thì bạn có thể thấy rằng, nếu bên trong lớp đó hoặc các lớp khác trong cùng package, sẽ có thể nhìn thấy và truy xuất đến các thành phần đó. Khả năng nhìn thấy và truy xuất chỉ bị chặn khi bạn gọi đến các thành phần này từ một lớp nằm ngoài package mà thôi.

Khả năng này nên ít được dùng hơn, vì bạn nên tập thói quen chỉ định khả năng truy cập một cách tường minh từ bây giờ.

Bài Thực Hành Số 2

Ở đây mình bỏ hết các khai báo khả năng truy cập cho các thành phần của lớp HinhTron.

package shapes;
 
public class HinhTron {
final float PI = 3.14f;
float banKinh;
 
// Constructor
HinhTron() {
}
 
float tinhChuVi() {
return 2 * PI * banKinh;
}
 
float tinhDienTich() {
return PI * banKinh * banKinh;
}
}

Và tương tự, với các dòng code khai báo ở main() như sau, bạn đoán xem dòng nào sẽ bị lỗi nhé.

package main;
 
import shapes.HinhTron;
 
public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron();
hinhTron.banKinh = 20;
hinhTron.tinhDienTich();
}
}

Kết quả dòng bị lỗi như sau.

Dòng 7 báo lỗi
Dòng 8 báo lỗi
Dòng 9 báo lỗi

Sở dĩ các dòng này báo lỗi, là vì chúng ở package main khác với package shapes của HinhTron. Bạn thử đưa chúng về cùng một package rồi kiểm chứng lại nhé.

Khả Năng Truy Cập protected

Khả năng này mang ý nghĩa bảo vệ các thành phần của lớp cho các lớp con của nó dùng lại hoặc ghi đè. Do đó có thể hiểu, khả năng protected sẽ trao quyền sử dụng hoàn toàn tự do cho các lớp con, dù có ở cùng hay khác package. Khả năng này chỉ giới hạn với các lớp không phải lớp con của nó và nằm ngoài package mà thôi.

Bài Thực Hành Số 3

Lần này bạn hãy xem các khai báo protected cho các thành phần bên trong HinhTron như sau.

package shapes;
 
public class HinhTron {
protected final float PI = 3.14f;
protected float banKinh;
 
// Constructor
public HinhTron(float banKinh) {
this.banKinh = banKinh;
}
 
protected float tinhChuVi() {
return 2 * PI * banKinh;
}
 
protected float tinhDienTich() {
return PI * banKinh * banKinh;
}
}

Và với code ở main(), bạn lại thử đoán xem dòng code nào bị báo lỗi.

package main;
 
import shapes.HinhTron;
 
public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron(10);
hinhTron.tinhDienTich();
}
}

Kết quả dòng bị lỗi như sau.

Dòng 8 báo lỗi

Phương thức tinhDienTich() rơi vào trường hợp “Không” duy nhất của protected. Bởi nó vừa gọi từ một lớp không có quan hệ kế thừa với HinhTron, và cũng nằm ngoài package so với HinhTron nữa.

Bài Thực Hành Số 4

Vẫn với code của Bài thực hành số 3, lần này mình khai báo thêm HinhTru kế thừa từ HinhTron, và main() như sau. Bạn xem có dòng nào báo lỗi không nhé.

package shapes;
 
public class HinhTru extends HinhTron {
public float chieuCao;
 
// Constructor
public HinhTru(float banKinh, float chieuCao) {
super(banKinh);
this.chieuCao = chieuCao;
}
 
public float tinhTheTich() {
return tinhDienTich() * chieuCao;
}
}
package main;
 
import shapes.HinhTru;
 
public class MainClass {
public static void main(String[] args) {
HinhTru hinhTru = new HinhTru(10, 20);
hinhTru.tinhTheTich();
}
}

Đây là kết quả các dòng báo lỗi.

Không dòng nào báo lỗi hết.

Vì HinhTru kế thừa các phương thức từ cha và set lại khả năng truy cập là public.

Khả Năng Truy Cập public

Có lẽ không cần tốn nhiều giấy mực để nói về khả năng này. Nếu bạn chỉ định một thành phần trong lớp là public, thì coi như tất cả các lớp nào khác đều có thể truy xuất đến các thành phần này.

Từ Khóa final Trong Lập Trình Hướng Đối Tượng

Chắc bạn còn nhớ bài học về hằng số, khi đó bạn đã biết, để khai báo một hằng, bạn phải dùng từ khóa final. Sang đến các bài học về OOP, hằng số vẫn được dùng với ý nghĩa nguyên vẹn nếu như bạn khai báo một thuộc tính của lớp với từ khóa final này. Vậy thì, ngoài ý nghĩa là một hằng số ra, final còn làm được gì trong các mối quan hệ OOP này. Mời bạn cùng xem tiếp bài học hôm nay để hiểu rõ hơn về final nhé.

Biến final & Thuộc Tính final

Như mình có nói, từ khóa final khi dùng cho biến trong một phương thức, cũng có ý nghĩa tương tự như khi dùng từ khóa này với thuộc tính của lớp. Nó đều mang giá trị là một hằng số không thể thay đổi được.

Dưới đây là một ví dụ của việc khai báo final cho một biến, kiến thức cổ xưa này chắc bạn cũng đã nắm rất kỹ rồi.

protected float tinhChuVi() {
final float PI = 3.14f; // Khai báo final cho biến
return 2 * PI * banKinh;
}

Còn về việc khai báo final cho một thuộc tính, thì cũng cổ xưa không kém, bạn đã từng làm quen rất nhiều khi khai báo thuộc tính hằng số PI trong lớp HinhTron ở các bài học trước. Tuy nhiên, bạn nên nhớ rằng, các khai báo khả năng truy cập vào các thuộc tính final hay không final là hoàn toàn tương tự nhau nhé.

public class HinhTron {
private final float PI = 3.14f; // Khai báo final cho thuộc tính
protected float banKinh;
 
// Constructor
public HinhTron(float banKinh) {
this.banKinh = banKinh;
}
 
protected float tinhChuVi() {
return 2 * PI * banKinh;
}
 
protected float tinhDienTich() {
return PI * banKinh * banKinh;
}
}

Và như mình cũng có khuyên bạn khi nói về hằng số, rằng bạn nên viết hoa hết các ký tự biến hay thuộc tính với khai báo final, nó giúp code của chúng ta rõ ràng và mạch lạc hơn.

Thuộc Tính final Trống

Phần này mình muốn nói thêm về final khi dùng cho biến hay thuộc tính. Đó là khi bạn khai báo chúng là final, bạn không cần thiết phải chỉ định ngay giá trị cho thuộc tính hay biến đó, mà có thể để trống, để sau này khi có giá trị cụ thể, thì bạn gán vào các thuộc tính hay biến final đó sau, và chỉ gán một lần duy nhất thôi nhé. Điều này giúp cho final tưởng như rất cứng nhắc này lại trở nên linh động hơn một chút.

Ví dụ sau mô phỏng cho việc set giá trị cho biến final PI sau khi chúng ta tìm thấy một giá trị PI chính xác hơn bằng phương thức.

protected float tinhChuVi() {
final float PI; // Khai báo hằng
PI = tinhPI(); // Gán giá trị cho hằng sau khi có giá trị cụ thể
return 2 * PI * banKinh;
}

Còn ví dụ sau mô phỏng việc set giá trị cho thuộc tính final PI sau khi tìm thấy giá trị PI chính thức, nhưng hơi khác với ví dụ về biến trên kia một chút, rằng với thuộc tính final, thì bạn chỉ được phép gán giá trị sau cho final ở constructor mà thôi.

public class HinhTron {
private final float PI; // Khai báo hằng
protected float banKinh;
 
// Constructor
public HinhTron(float banKinh) {
// Gán giá trị cho hằng ở constructor
PI = (float) tinhPi();
this.banKinh = banKinh;
}
 
protected float tinhChuVi() {
return 2 * PI * banKinh;
}
 
protected float tinhDienTich() {
return PI * banKinh * banKinh;
}
 
// Bạn đừng quan tâm quá đến code của phương thức này
private double tinhPi() {
return Math.PI;
}
}

Phương Thức final

Cũng dễ dàng suy luận thôi. Nếu như với thuộc tính, khi bị chỉ định là final, thì có nghĩa là giá trị của thuộc tính đó sẽ không thể thay đổi được nữa. Thì với phương thức, khi bạn chỉ định final cho nó, thì xem như phương thức đó không thể override lại được, cũng tương đương với việc bạn không thay đổi được phương thức đó từ lớp con.

Quay lại lop HinhTron, giả sử các phương thức tinhChuVi() và tinhDienTich() đều được khai báo là final.

public class HinhTron {
private final float PI = 3.14f;
protected float banKinh;
 
// Constructor
public HinhTron(float banKinh) {
this.banKinh = banKinh;
}
 
protected final float tinhChuVi() {
return 2 * PI * banKinh;
}
 
protected final float tinhDienTich() {
return PI * banKinh * banKinh;
}
}

Vậy thì ở lớp HinhTru kế thừa từ HinhTron, bạn có thể thấy rằng việc HinhTru sử dụng các phương thức tinhChuVi() và tinhDienTich() từ HinhTron là hoàn toàn bình thường.

public class HinhTru extends HinhTron {
public float chieuCao;
 
// Constructor
public HinhTru(float banKinh, float chieuCao) {
super(banKinh);
this.chieuCao = chieuCao;
}
 
public float tinhTheTich() {
return tinhDienTich() * chieuCao;
}
}

Nhưng như mình có nói, lỗi chỉ thực sự xảy ra khi HinhTru override lại một phương thức final nào đó từ HinhTron mà thôi. Ví dụ như nếu bạn xây dựng thêm em này ở HinhTru thì hệ thống mới báo lỗi.

@Override
protected float tinhChuVi() {
return super.tinhChuVi();
}

Với việc thiết lập một phương thức là final này, như bạn đã biết, các lập trình viên sẽ tránh không cho các lớp khác kế thừa từ phương thức nào đó của một lớp. Việc làm này vẫn giúp chia sẻ phương thức đó cho các lớp con kế thừa (miễn đừng khai báo private), nhưng sẽ không cho phép bất cứ sự sửa chữa nào, đảm bảo sự nguyên vẹn ban đầu của phương thức. Bạn đã thấy sự vi diệu của OOP chưa nào.

Vâng, bạn có thể chỉ định final cho bất cứ phương thức nào. Nhưng, bạn nên lưu ý rằng, bạn không thể chỉ định final cho một constructor nhé, vì constructor có bao giờ bị override đâu nào.

Lớp final

Tác dụng của final đến với lớp tương tự như tác dụng của em ấy đến với phương thức. Đó là bạn sẽ không thể kế thừa từ bất cứ lớp nào được khai báo là final.

Giả sử lớp HinhTron có khai báo final cho lớp như thế này, thì lớp HinhTru kế thừa từ HinhTron mà chúng ta đã biết sẽ báo lỗi.

final class HinhTron {
 
// ...
}

Nếu bạn xuất bản một lớp, và chỉ cho người khác sử dụng chúng mà thôi, không cho ai có quyền kế thừa để override bất kỳ phương thức nào, thì cứ đặt lớp đó là final nhé.

Có nhiều lớp được dựng sẵn trong Java mà bạn đã biết được khai báo là final. Chẳng hạn lớp String. Với việc khai báo final cho String thì bạn không thể tạo ra một lớp con nào khác của String, bạn chỉ việc dùng String mà thôi.

public final class String
implements java.io.Serializable, Comparable<java.lang.String>, CharSequence,
Constable, ConstantDesc {
// ...
}

Kết Luận

Chúng ta kết thúc bài học final nhẹ nhàng ở đây. Tuy nhiên bài học khá hữu dụng trong việc vận dụng một từ khóa final để mang lại một số ý nghĩa về bảo vệ thông tin của một đối tượng. Nó sẽ hữu ích khi bạn muốn xây dựng các gói thư viện Java và xuất bản nó cho nhiều người dùng. Khi đó, việc cho phép một lớp khác có quyền kế thừa, hay override một phương thức nào đó của lớp hay không, là quyền ở bạn, như lớp String mà mình có giới thiệu trong bài học.

Gói Ghém Dữ Liệu Với Getter Và Setter

Bài học hôm nay chúng ta cùng xem qua cách tổ chức các phương thức getter và setter trong Java. Hai phương thức này là gì? Chúng có liên hệ như thế nào đến việc gói ghém dữ liệu? Và gói ghém dữ liệu là gì? Mời các bạn cùng đọc tiếp nhé.

Làm Quen Với Getter, Setter Và Gói Ghém Dữ Liệu

Getter và setter là hai tên gọi của hai thể loại phương thức. Chúng có liên quan đến đặc tính gói ghém dữ liệu trong lập trình hướng đối tượng, tiếng Anh gọi đặc tính này là encapsulation. Trước đó, các định nghĩa về khả năng truy cập cũng là một dạng của gói ghém dữ liệu.

Chúng ta làm quen với gói ghém dữ liệuTính gói ghém dữ liệu là một trong những đặc tính cơ bản trong kế thừa, bên cạnh các đặc tính khác, như tính kế thừa (inheritance) mà bạn đã biết, tính đa hình (polymorphism) và tính trừu tượng (abstraction) mà bạn sẽ làm quen sau. Đặc tính gói ghém dữ liệu giúp che đi các thuộc tính của một lớp, khiến cho các lớp khác không thể “nhìn thấy”, không thể thay đổi hay chỉnh sửa các giá trị của các thuộc tính này. Bạn cũng được biết các khả năng truy cập private hay protected đều giúp ích rất nhiều trong việc gói ghém dữ liệu đúng không nào.

Vậy getter và setter liên quan gì đến gói ghém dữ liệu? Các phương thức này giúp việc quản lý các thuộc tính private hay protected của một lớp được thoải mái và tường minh hơn. Bạn có thể chỉ định các thuộc tính này sao cho chỉ được phép đọc (thông qua phương thức getter), hoặc chỉ được phép ghi (thông qua phương thức setter), hoặc cho phép cả đọc lẫn ghi (thông qua cả getter và setter) từ các lớp bên ngoài.

Định nghĩa trên chắc đủ rõ ràng cho bạn để hiểu loại phương thức này đúng không. Nếu chưa thực sự hiểu về nó thì mời bạn đọc tiếp các phần sau nhé.

Tổ Chức Các Phương Thức Getter Và Setter Như Thế Nào Để Đảm Bảo Tính Gói Ghém Dữ Liệu

Để getter và setter được tạo ra nhưng vẫn đảm bảo sự gói ghém cho các thuộc tính trong một lớp, chúng ta có các nguyên tắc sau.

– Luôn có thói quen định nghĩa khả năng truy cập cho các thuộc tính trong một lớp là private.
– Sau đó tùy nhu cầu mà xây dựng phương thức getter hay setter, hay cả getter và setter cho từng thuộc tính private đó. Với các phương thức setter bạn nên đặt tên theo cấu trúc setTênThuộcTính(). Còn với getter thì bạn nên đặt là getTênThuộcTính(). Việc xuất hiện của getter và setter cho các thuộc tính private là không bắt buộc. Bạn có thể không cần khai báo bất cứ getter hay setter nào cả nếu như bạn không muốn thuộc tính đó được thấy bởi bất kỳ lớp nào. Hoặc bạn chỉ cần xây dựng getter cho một thuộc tính private nếu bạn muốn thuộc tính đó chỉ được xem, không được sửa chữa. Hoặc chỉ xây dựng setter cho một thuộc tính private nếu bạn muốn thuộc tính đó chỉ có khả năng chỉnh sửa từ bên ngoài mà không được xem. Hoặc có cả getter và setter nếu nó được phép xem và sửa.

Trường hợp thuộc tính private có cả getter lẫn setter thoạt nghe đến bạn có thể nghĩ rằng, tại sao không xây dựng public cho các thuộc tính này, tại sao lại phải rườm rà set nó là private, sau đó lại thêm cả getter lẫn setter?

Bạn nên biết, sự rườm rà này rất đáng giá, bạn nên tập làm quen với nguyên tắc gói ghém dữ liệu kiểu này. Đầu tiên nó tạo cho bạn một thói quen làm việc theo nguyên tắc, OOP là một lĩnh vực có khá nhiều nguyên tắc và đây là nguyên tắc đầu tiên mà bạn có thể dễ dàng để thực hiện. Sau đó, cách xây dựng getter và setter như thế này giúp giảm thiểu lỗi cho ứng dụng, ai mà biết với việc khai báo các thuộc tính của lớp là public ngay từ đầu, các lớp khác đều có quyền thấy và sửa chữa các thuộc tính này có gây ra bất kỳ lỗi tiềm ẩn nào hay không. Bạn tưởng tượng xem khi đối tượng HinhTron được khởi tạo với một banKinh được chỉ định ở constructor, nhưng khi bắt đầu vào tính diện tích thì ở đâu đó lại truy xuất đến thuộc tính banKinh public và chỉnh sửa nó, phương thức tính diện tích lúc này có thể sẽ cho ra kết quả không mong muốn. Nhưng với getter và setter được khai báo, bạn có quyền thêm các dòng code đảm bảo tính đúng đắn của dữ liệu truyền vào cho thuộc tính banKinh (bài thực hành số 2 dưới đây giúp bạn rõ hơn), hơn nữa bạn còn có thể kiểm tra các điều kiện xem thuộc tính này có đang được dùng để tính toán ở đâu không, có cần phải thay đổi ngay lúc này hay delay bao lâu để tránh việc sai số cho các tính toán hay không.

Bài Thực Hành Số 1

Nào, để dễ hiểu một chút, chúng ta cùng đến với bài thực hành sau. Chúng ta hãy xây dựng lại lớp HinhHoc theo đúng tiêu chí của gói ghém dữ liệu bằng cách chỉ định các thuộc tính ở lớp này là private.

public class HinhTron {
private final float PI = 3.14f;
private float banKinh;
private float chuVi;
private float dienTich;
 
// Constructor
public HinhTron(float banKinh) {
this.banKinh = banKinh;
}
 
public void tinhChuVi() {
chuVi = 2 * PI * banKinh;
}
 
public void tinhDienTich() {
dienTich = PI * banKinh * banKinh;
}
}

Bạn có thể thấy, lớp HinhTron đã gói ghém các thuộc tính của nó, sao cho banKinhchuVi và dienTich đều là privatebanKinh chỉ được khởi tạo duy nhất khi HinhTron được khởi tạo. Sau đó, việc tinhChuVi() hay tinhDienTich() diễn ra an toàn, banKinh không thể được thay đổi ở bất kỳ một chỗ nào đó khác trong toàn bộ chương trình. Các lớp khác bên ngoài cũng không thể nào tự thay đổi các thuộc tính chuVi và dienTich, nó thuộc quyền quản lý và tùy ý thay đổi của riêng HinhTron mà thôi. Bạn hãy xem main() sử dụng HinhTron như sau.

public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron(20);
hinhTron.tinhChuVi();
hinhTron.tinhDienTich();
}
}

Nếu giờ đây bạn muốn main() in ra kết quả của các phép tính chu vi và diện tích, hãy thêm getter cho các thuộc tính này bên trong HinhTron.

public class HinhTron {
private final float PI = 3.14f;
private float banKinh;
private float chuVi;
private float dienTich;
 
// Constructor
public HinhTron(float banKinh) {
this.banKinh = banKinh;
}
 
public float getChuVi() {
return chuVi;
}
 
public float getDienTich() {
return dienTich;
}
 
public void tinhChuVi() {
chuVi = 2 * PI * banKinh;
}
 
public void tinhDienTich() {
dienTich = PI * banKinh * banKinh;
}
}

Khi đó main() có thể đọc được các giá trị bên trong HinhTron như thế này.

public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron(20);
hinhTron.tinhChuVi();
hinhTron.tinhDienTich();
 
float chuVi = hinhTron.getChuVi();
float dienTich = hinhTron.getDienTich();
System.out.println("Chu vi hình tròn: " + chuVi + "; Và diện tích: " + dienTich) ;
}
}

Bạn đã hiểu cách dùng getter và setter rồi đúng không nào. Bài thực hành tiếp theo đây cho chúng ta thấy thêm một lợi ích và cách sử dụng nữa của việc sử dụng chúng.

Bài Thực Hành Số 2

Bài thực hành này xây dựng lớp SinhVien với các thuộc tính private kèm với các phương thức getter và setter cho chúng. Bạn có thể thấy rằng bạn hoàn toàn có thể tùy biến sao cho chính setter là một phương thức private (bạn không cho ghi vào thuộc tính từ bên ngoài lớp luôn, nhưng chính setter private này lại giúp kiểm tra chút ràng buộc điều kiện). Phương thức getter getTuoi() cũng được xử lý một chút trước khi trả kết quả ra bên ngoài.

public class SinhVien {
private String ten;
private int tuoi;
 
public SinhVien(String ten, int tuoi) {
setTen(ten);
setTuoi(tuoi);
}
 
private void setTen(String ten) {
if (ten == null || ten.isEmpty()) {
// Nếu biến ten chưa khởi tạo (mang giá trị null), hoặc biến ten có nội dung rỗng
// Thì hãy lưu với tên là "Không biết"
this.ten = "Không biết";
} else {
this.ten = ten;
}
}
 
public String getTen() {
return ten;
}
 
private void setTuoi(int tuoi) {
// Kiểm tra tuổi có hợp lý hay không, nếu hợp lý thì lưu vào,
// nếu không sẽ tìm cách báo lỗi bằng cách lưu giá trị âm
if (tuoi > 18) {
this.tuoi = tuoi;
} else {
this.tuoi = -1;
}
}
 
public String getTuoi() {
if (this.tuoi != -1) {
// Tuổi hợp lệ
return String.valueOf(tuoi);
} else {
return "Tuổi không hợp lệ";
}
}
}

Với khai báo lớp như trên. Giả sử mình khởi tạo các đối tượng sinh viên này ở main() như sau.

public class MainClass {
public static void main(String[] args) {
// Khởi tạo các đối tượng sinhVien
// với các thông tin về tên và tuổi
SinhVien sinhVien1 = new SinhVien("", 23);
SinhVien sinhVien2 = new SinhVien("Peter", 17);
 
// In thông tin các sinh viên
System.out.println("Sinh viên 1 có tên: " + sinhVien1.getTen() + ", tuổi: " + sinhVien1.getTuoi());
System.out.println("Sinh viên 2 có tên: " + sinhVien2.getTen() + ", tuổi: " + sinhVien2.getTuoi());
}
}

Sau đây là kết quả in ra console.

Sinh viên 1 có tên: Không biết, tuổi: 23
Sinh viên 2 có tên: Peter, tuổi: Tuổi không hợp lệ

Dùng InteliJ Để Khai Báo Getter Và Setter Tự Động

Như trên mình có nói, việc đặt tên cho getter và setter nên là getTênThuộcTính() và setTênThuộcTính(). Tuy nhiên, InteliJ đã hỗ trợ chúng ta một công cụ tự động phát sinh tên cho các phương thức getter và setter của thuộc tính private nào đó .

Bạn hãy cùng mình thử nghiệm tính năng sinh code tự động qua các bước sau. Đầu tiên bạn hãy thử tạo một lớp ToaDo có khai báo một hay nhiều thuộc tính private như sau.

Khai báo lớp có các thuộc tính privatehttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2022/12/Screenshot-2022-12-20-at-11.23-1.png?resize=300%2C202&ssl=1 300w" data-lazy-loaded="1" sizes="(max-width: 636px) 100vw, 636px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px; vertical-align: bottom;">
Khai báo lớp có các thuộc tính private

Nếu bạn click lên một trong các thuộc tính private này, bạn sẽ thấy icon cảnh báo màu vàng bên thanh trái của editor. Vì hệ thống thấy rằng chúng ta khai báo private cho thuộc tính, mà không sử dụng chúng ở bên trong lớp, thì hiển nhiên là có vấn đề rồi, vì bên ngoài lớp có nhìn thấy các giá trị này đâu.

Khi này bạn cứ click vào một trong các cảnh báo này, một dialog nhỏ xuất hiện như sau.

Dialog xuất hiện giúp bạn có những chọn lựa thú vị
Dialog xuất hiện giúp bạn có những chọn lựa thú vị

Bạn đã thấy có các tùy chọn Create getter for ‘x’ và Create setter for ‘x’. Vâng, đó là các cách mà InteliJ sẽ thêm tự động các phương thức getter và setter. Bạn có thể tự trải nghiệm nhé, mình toàn dùng chúng cho việc tạo nhanh code đấy.

Kết Luận

Vậy là chúng ta vừa xem qua kiến thức về xây dựng các phương thức getter  setter trong hướng đối tượng. Thực ra, việc có nên xây dựng getter  setter cho các thuộc tính của lớp trong Java hay cứ khai báo public hết cho chúng, từng là chủ đề tranh cãi. Vì nhìn chung lại getter  setter sẽ làm cho các lớp trở nên rườm rà hơn. Có một số ý kiến cũng cho rằng hiệu năng của ứng dụng cũng giảm đi với getter  setter. Nhưng như bài học có nói đến, rằng đây là một trong những cách gói ghém dữ liệu của lớp. Với getter  setter, bạn sẽ có thể chỉ định được thuộc tính đó hoàn toàn ẩn, hay chỉ đọc, hay chỉ ghi, hay có thể đọc và ghi. Một cách để làm cho code bạn được tường minh hơn, ít lỗi hơn, và tuân thủ nguyên tắc OOP hơn.

Từ Khóa static

Có thể nói, cho đến bài học OOP hôm nay, đã có ngày càng nhiều từ khóa được lần lượt giới thiệu đến các bạn. Để mình giúp các bạn ôn lại một chút các từ khóa và tên gọi quan trọng mà chúng ta đã xem qua.

  • Từ khóa extends – Bạn bắt đầu làm quen với từ khóa này khi thể hiện sự kế thừa.
  • Từ khóa this – Giúp tham chiếu đến các thành phần bên trong lớp.
  • Từ khóa super – Giúp tham chiếu đến các thành phần của lớp cha gần nhất.
  • Annotation @Overriding – Thể hiện phương thức đang định nghĩa là phương thức phủ quyết (hay ghi đè, hay overriding).
  • Từ khóa Object – Dùng để chỉ lớp Object, lớp cha nhất của tất cả các lớp trong Java.
  • Các từ khóa private/protected/public – Dùng khi định nghĩa khả năng truy cập vào các thành phần lớp.
  • Từ khóa final – Giúp định nghĩa hằng số, hoặc ngăn chặn sự overriding bên trong OOP.
  • Tên gọi getter và setter – Nhằm ám chỉ đến sự gói ghém dữ liệu thông qua các phương thức get/set cho thuộc tính private.

Vậy thì hôm nay, chúng ta lại tiếp tục tìm hiểu thêm một từ khóa mới, từ khóa static, xem từ khóa này là gì, và nó có công dụng gì nữa nhé.

Khái Niệm static

Có thể nói, một trong những nghĩa tiếng Việt trong ngôn ngữ lập trình gây khó hiểu nhất là đây. Static – Dịch ra là tĩnh. Không hiểu gì cả.

Thực ra thì từ khóa static sẽ được áp dụng khi bạn khai báo các thành phần của lớp như bên dưới mình sẽ trình bày rõ. Nó mang tác dụng chính đối với sự quản lý về mặt bộ nhớ. Cụ thể là, các thành phần static sẽ thuộc về quản lý bộ nhớ của lớp, chứ không thuộc về quản lý của thể hiện lớp (hay có thể hiểu là đối tượng).

Vẫn chưa hiểu!.

Mình mời bạn nhìn vào ví dụ sau, có thể qua ví dụ bạn vẫn chưa nắm rõ công dụng mà từ khóa static mang lại, nhưng hãy xem nó tác động như thế nào đến việc quản lý các thành phần bên trong lớp mà mình có nói trên đây. Bạn hãy chú ý các thành phần static và không static trong lớp ToaDo.

public class ToaDo {
public static String thongTin;
public int x;
public int y;
}

Vậy như mình có nói. Thuộc tính thongTin là static, và nó sẽ thuộc quyền quản lý của lớp ToaDo. Các thuộc tính xy là không static, sẽ thuộc quyền quản lý của các thể hiện, hay các đối tượng được khai báo từ ToaDo.

Vấn đề sẽ rõ ràng hơn khi bạn dùng đến các thành phần static này của ToaDo ở một lớp khác.

public class MainClass {
public static void main(String[] args) {
// Các thuộc tính x, y này chỉ được truy xuất đến thông qua thể hiện toaDo1 của lớp ToaDo
ToaDo toaDo1 = new ToaDo();
toaDo1.x = 10;
toaDo1.y = 20;
 
// Các thuộc tính x, y này chỉ được truy xuất đến thông qua thể hiện toaDo2 của lớp ToaDo
ToaDo toaDo2 = new ToaDo();
toaDo2.x = 5;
toaDo2.y = 6;
 
// Thuộc tính thongTin lại được truy xuất đến thông qua lớp ToaDo
ToaDo.thongTin = "Lưu tọa độ các hình học";
}
}

Bạn có thấy rằng, các thuộc tính xy, cũng như bao thành phần không static khác mà bạn từng biết, đều phải được gọi thông qua một thể hiện của lớp, như toaDo1toaDo2 ở ví dụ. Còn thuộc tính thongTin lại được truy cập trực tiếp thông qua lớp ToaDo, mà không cần bất cứ một thể hiện nào cả.

Đến đây thì bạn đã hiểu được thành phần static là gì đúng không nào. Chắc bạn cũng “hườm hườm” ngẫm được tại sao nó lại có nghĩa là tĩnh.

Tiếp theo chúng ta cùng tìm hiểu, từ khóa static sinh ra để làm gì.

Công Dụng Của Từ Khóa static

Như một số thông tin mà mình đã trình bày trên đây, bạn có thể thấy rằng, với việc gọi đến thành phần của lớp thông qua tên lớp, mà không cần phải khởi tạo đối tượng của lớp đó, giúp cho các thành phần được khai báo static sẽ dễ dàng truy xuất hơn.

Đặc biệt hơn cả là, các thành phần được khai báo static này có thể được dùng chung bởi các lớp khác trong ứng dụng. Nó mang ý nghĩa toàn cục trong cả ứng dụng. Bạn cứ thử nhìn vào ví dụ trên, bạn đã thử gán giá trị cho static thongTin trong ToaDo bằng dòng lệnh 

ToaDo.thongTin = "Lưu tọa độ các hình học";

, thì ở một chỗ khác, trong lớp khác chẳng hạn, bạn có thể lấy giá trị ToaDo.thongTin này ra dùng, hoặc gán lại cho nó một giá trị mới. Như vậy thành phần static sẽ được chia sẻ, được dùng chung, bởi bất kỳ chỗ nào, trong lớp đó hoặc bên ngoài lớp (miễn là thành phần đó đừng khai báo là private).

Nhưng việc các thành phần của lớp được truy cập và sửa đổi thoải mái như thế này lại là con dao hai lưỡi. Nếu bạn lạm dụng từ khóa static, bạn sẽ vô tình làm phá vỡ nguyên tắc của OOP. Vốn dĩ OOP ra đời là để phân chia đặc điểm và trách nhiệm cho từng đối tượng, để dễ quản lý. Thì từ khóa static lại mang các tính chất vốn dĩ thuộc trách nhiệm của đối tượng nào đó, ra dùng chung một cách đại trà. Do đó, mình khuyên là bạn nên thật cân nhắc khi khai báo static cho bất cứ thành phần nào của lớp.

Vậy từ khóa static thực chất được dùng ở đâu? Mình mời các bạn đọc qua phần dưới đây. Ở từng công dụng cụ thể của từ khóa static, mình sẽ tìm những ví dụ thực tế nhất mà từ khóa static mang lại trong quá trình xây dựng ứng dụng.

Khai Báo Thuộc Tính static

Có lẽ không cần phải nói nhiều nữa. Thuộc tính static là thuộc tính có khai báo từ khóa static. Chúng được sử dụng như thế nào thì chúng ta cùng xem qua các ví dụ dưới đây, bạn sẽ hiểu rõ thôi.

Bài Thực Hành Số 1

Chúng ta cùng quay lại ví dụ về các thể loại hình học. Ví dụ như chúng ta có HinhHoc là lớp cha của hai lớp HinhTron và HinhChuNhat. Và yêu cầu của bài thực hành hôm nay là, mỗi khi chúng ta tạo ra một đối tượng hình học, thì có một biến đếm tự động tăng lên cho biết số lượng hình được tạo ra trong một chương trình.

Nào cùng xem qua lớp HinhHoc, giả sử chúng ta không quan tâm đến các dòng code tính toán khác, chỉ chú tâm vào biếm dem được set static bên trong lớp này.

public class HinhHoc {
public static int dem = 0;
 
public HinhHoc() {
dem++;
}
 
// Các dòng code khác
// ...
}

Sau đó, cứ mỗi constructor của lớp con, chúng ta đều gọi đến lớp cha gần nhất của nó thông qua phương thức super(). Việc gọi này sẽ làm tăng biến dem này lên ở constructor của lớp cha.

public class HinhTron extends HinhHoc {
// Constructor
public HinhTron() {
super();
}
 
// Các code tính toán chu vi, diện tích
// ...
}
public class HinhChuNhat extends HinhHoc {
// Constructor
public HinhChuNhat() {
super();
}
 
// Các code tính toán chu vi, diện tích
// ...
}

Và rồi ở hàm main(), bạn hãy thử khai báo các đối tượng của HinhTron và HinhChuNhat thoải mái, rồi hãy in biến dem ra console xem sao nhé.

public class MainClass {
public static void main(String[] args) {
// Tạo các thể hiện của các lớp
HinhHoc hinhHoc = new HinhHoc();
HinhTron hinhTron1 = new HinhTron();
HinhTron hinhTron2 = new HinhTron();
HinhChuNhat hinhChuNhat = new HinhChuNhat();
 
System.out.println("Có tất cả " + HinhHoc.dem + " hình trong ứng dụng.");
}
}

Kết quả in ra 

có 4 hình trong ứng dụng
. Bạn thấy biến dem được khai báo static sẽ được dùng chung như thế nào rồi đúng không nào.

Bài Thực Hành Số 2

Có khi nào bạn từng thắc mắc rằng, sẽ làm sao nếu như mong muốn ứng dụng có các giá trị cố định dùng chung, nó mang ý nghĩa là các giá trị cấu hình cho ứng dụng. Chẳng hạn như khai báo đường dẫn tĩnh để lưu file này, hay khai báo đường dẫn đến một trang web này, hay đường dẫn API này,…. Thì tất cả các nhu cầu về cấu hình tĩnh đó, bạn có thể gom chung chúng vào một file và khai báo các thuộc tính static kết hợp với final cho nó.

Tiếp tục với ví dụ về việc đếm các hình học trên đây. Giả sử yêu cầu của bài toán lúc này đặt ra là bạn không được khai báo quá 5 hình. Nếu ứng dụng có quá 5 hình, thay vì câu lệnh in ra số lượng hình học ở trên đây, bạn có thể in ra câu thông báo nào đó. Trong trường hợp này, số lượng tối đa 5 hình trong ràng buộc được xem như là một cấu hình tĩnh của ứng dụng.

Đầu tiên, chúng ta sẽ cần phải tạo một lớp chỉ chuyên cho việc cấu hình thôi. Mình đặt tên lớp này là Configs.

public class Configs {
// Cấu hình số lượng hình học
public static final int SO_LUONG_HINH_TOI_THIEU = 0;
public static final int SO_LUONG_HINH_TOI_DA = 5;
 
// Các cấu hình khác nếu có
// public static final xxx xxx
// public static final xxx xxx
// ...
}

Quay lại main(), bạn hãy thử khai báo nhiều nhiều các đối tượng hình học xem nào. Sau đây là các kiểm tra ràng buộc dựa trên các giá trị cấu hình tĩnh lấy từ lớp Configs.

public class MainClass {
public static void main(String[] args) {
// Tạo các thể hiện của các lớp
HinhHoc hinhHoc1 = new HinhHoc();
HinhHoc hinhHoc2 = new HinhHoc();
HinhTron hinhTron1 = new HinhTron();
HinhTron hinhTron2 = new HinhTron();
HinhChuNhat hinhChuNhat1 = new HinhChuNhat();
HinhChuNhat hinhChuNhat2 = new HinhChuNhat();
 
if (HinhHoc.dem > Configs.SO_LUONG_HINH_TOI_DA) {
System.out.println("Bạn đã khai báo vượt số lượng hình học cho phép!");
System.out.println("Số lượng hình học tối thiểu là: " + Configs.SO_LUONG_HINH_TOI_THIEU);
System.out.println("Số lượng hình học tối đa là: " + Configs.SO_LUONG_HINH_TOI_DA);
} else {
System.out.println("Có tất cả " + HinhHoc.dem + " hình trong ứng dụng.");
}
}
}

Giờ thì bạn có thể chạy thử ứng dụng lên kiểm chứng nhé.

Khai Báo Phương Thức static

Phương thức static được sử dụng với đặc điểm và mục đích tương tự như thuộc tính static trên kia. Đó là.

  • Các phương thức static vẫn thuộc quản lý của lớp chứ không phải của thể hiện lớp.
  • Một phương thức được set là static khi chúng được dùng chung bởi tất cả các đối tượng bên trong ứng dụng. Như các phương thức về kiểm tra tính đúng đắn của giá trị nào đó, phương thức về chuyển đổi tiền tệ, chuyển đổi đơn vị, phương thức về lưu trữ giữ liệu xuống bộ nhớ, phương thức kết nối với server,…

Tuy nhiên có một lưu ý hết sức đặc biệt trong Java là, tất cả các phương thức static chỉ có thể gọi đến các thuộc tính được khai báo là static mà thôi. Điều này được dễ dàng kiểm chứng khi rất lâu rồi, ở bài 8, mình buộc phải khai báo biến static name khi biến này để ở ngoài main(), và vì main() được khai báo là static (bạn đã biết là main() bắt buộc phải khai báo là phương thức static mà đúng không, nguyên nhân là để hệ thống có thể truy xuất bất cứ lúc nào thông qua lớp, chứ không cần khai báo thể hiện của lớp nào đó), nên nếu main() muốn dùng đến name, thì name cũng phải static.

Bài Thực Hành Số 3

Với bài thực hành này, giả sử ứng dụng hình học của chúng ta đòi hỏi khắt khe hơn về mặt nhập liệu và tính toán dựa trên các đơn vị. Và giả sử chúng ta chấp nhận hai đơn vị nhập vào là inch và centimet.

Trước mắt, lớp Configs sẽ phải xây dựng thêm một số giá trị cấu hình tĩnh, và phải thêm các phương thức tĩnh chuyển đổi đơn vị nữa.

public class Configs {
// Cấu hình số lượng hình học
public static final int SO_LUONG_HINH_TOI_THIEU = 0;
public static final int SO_LUONG_HINH_TOI_DA = 5;
 
// Các cấu hình khác
public static final float PI = 3.14f;
public static final float INCH_CM = 2.54f; // 1 inch = 2.54 cm
public static final int DON_VI_CM = 1; // Đánh dấu ứng dụng đang dùng đơn vị centimet
public static final int DON_VI_INC = 2; // Đánh dấu ứng dụng đang dùng đơn vị inch
public static int donVi = DON_VI_CM; // Cờ Cho biết đang dùng đơn vị gì
 
// Phương thức static giúp chuyển đổi centimet sang inch
public static float ChuyenCentimetSangInch(float cm) {
float inch = cm / INCH_CM;
return inch;
}
 
// Phương thức static giúp chuyển đổi inch sang centimet
public static float ChuyenInchSangCentimet(float inch) {
float cm = inch * INCH_CM;
return cm;
}
}

Sau đó, ở lớp hình học nào cũng vậy, ví dụ dưới đây mình xây dựng cho lớp HinhTron, lớp này sẽ hỏi người dùng đang dùng đơn vị hình học nào, và sẽ xuất thông tin tương ứng kèm với thông tin chuyển đổi sang đơn vị còn lại dựa vào các cấu hình tĩnh và các phương thức tĩnh trong lớp Configs.

public class HinhTron extends HinhHoc {
protected float banKinh;
private Scanner scanner;
 
// Constructor
public HinhTron() {
super();
scanner = new Scanner(System.in);
}
 
public void nhapBanKinh() {
// Nhập đơn vị tính là centimet hay inch
System.out.println("Bạn dùng đơn vị tính nào: ");
System.out.println("\t1 - Centimet");
System.out.println("\t2 - inch");
Configs.donVi = scanner.nextInt();
 
// Sau đó nhập bán kính
System.out.print("Hãy nhập vào Bán kính Hình tròn: ");
banKinh = scanner.nextFloat();
}
 
public void inThongTin() {
if (Configs.donVi == Configs.DON_VI_CM) {
System.out.println("Hình tròn có bán kính " + banKinh + " cm");
System.out.println("Tương đương " + Configs.ChuyenCentimetSangInch(banKinh) + " inch");
} else {
System.out.println("Hình tròn có bán kính " + banKinh + " inch");
System.out.println("Tương đương " + Configs.ChuyenInchSangCentimet(banKinh) + " cm");
}
}
}

Phương thức main() được xây dựng đơn giản như sau.

public class MainClass {
public static void main(String[] args) {
HinhTron hinhTron = new HinhTron();
hinhTron.nhapBanKinh();
hinhTron.inThongTin();
}
}

Bạn tự chạy và kiểm chứng kết quả của chương trình nhé.

Kết Luận

Vậy là chúng ta vừa đi qua khái niệm và cách sử dụng từ khóa static trong Java. Bạn có thể thấy rằng các thành phần static thực sự rất dễ dùng đúng không nào. Và lời khuyên của mình vẫn luôn là, bạn hãy cân nhắc, đừng lạm dụng từ khóa static này quá. Nếu không vì mục đích dùng chung như các ví dụ trên đây của mình, thì bạn đừng nên sử dụng đến từ khóa static nhé.

Nạp Chồng Phương Thức (Overloading)

Nếu bạn nhớ, chúng ta đã học về overriding. Hôm nay bạn lại được biết thêm về overloading. Cẩn thận coi chừng nhầm lẫn nha bạn. Overriding, overloading, over, and over…

Mình đùa tí thôi, mình giúp các bạn nhớ lại một tí như sau.

  • Overriding là cái sự lớp con ghi đè phương thức của lớp cha.
  • Overloading là nạp chồng phương thức.

Mời các bạn cùng đến với bài học hôm nay. Bài học sẽ giúp bạn nắm được khái niệm overloading là gì. Bên cạnh đó nó còn giúp bạn đừng bị nhầm lẫn giữa overriding và overloading nữa đấy.

Nạp Chồng Phương Thức (Overloading) Là Gì?

Đây là một khái niệm khá hay trong OOP. Overloading cho phép một lớp có khả năng định nghĩa ra nhiều phương thức có cùng tên, nhưng khác nhau về tham số truyền vào. Phương thức ở đây có bao gồm luôn constructor nhé.

Kỹ thuật overloading làm tăng tính sử dụng cho các phương thức bên trong một lớp.

Bạn thử nhìn vào ví dụ sau đây.

public class HinhTron {
private float banKinh;
 
// Constructor không tham số
public HinhTron() {
this.banKinh = 0;
}
 
// Constructor một tham số banKinh
public HinhTron(float banKinh) {
this.banKinh = banKinh;
}
 
// Phương thức không tham số
public float tinhChuVi() {
// return kết quả tính chu vi
}
 
// Phương thức một tham số donVi
public float tinhChuVi(int donVi) {
// return kết quả tính chu vi dựa vào tham số donVi truyền vào
}
}

Như mình có nói trên kia rằng, overloading cho phép bạn khai báo nhiều phương thức hoặc nhiều constructor trong một lớp có tên trùng nhau, nhưng khác tham số. Bạn đã thấy ví dụ trên khai báo hai constructor trùng tên là HinhTron() và hai phương thức trùng tên tinhDienTich() rồi đó. Đó chính là overloading, nó giống như bạn cung cấp các lớp constructor và phương thức trùng tên (khác tham số), chúng như được xếp chồng lên nhau, hay nạp chồng lên nhau.

Bạn cũng đã thấy, rõ ràng overriding và overloading dễ nhầm lẫn nhau ở chỗ, rằng chúng đều nói đến sự đặt tên trùng nhau của các phương thức. Nhưng overriding là nguyên tắc đặt tên phương thức của lớp con trùng với tên phương thức của lớp cha (đồng thời các tham số truyền vào cũng phải trùng nhau). Còn overloading lại bắt buộc đặt tên các phương thức trong một lớp trùng nhau (không liên quan gì đến lớp cha cả, và các tham số truyền vào các phương thức này phải khác nhau).

Đau đầu đúng không. Bạn sẽ quen nhanh thôi khi thao tác nhiều với hai kỹ thuật này.

Nạp Chồng Phương Thức Có Tác Dụng Gì?

Theo như mình thấy. Nếu bạn áp dụng overloading vào một lớp, tức xây dựng nhiều phương thức trùng tên trong một lớp. Nó sẽ không có tác dụng ngay cho lớp đó, chẳng hạn như nó không giúp code của lớp đó gọn gàng, hay rõ ràng hơn để bạn dễ code hay dễ quản lý gì đâu, đôi khi nó gây ra một sự xáo trộn nhất định về mặt tổ chức bên trong lớp đó, nếu như bạn có quá nhiều các phương thức trùng tên.

Nhưng overloading lại phát huy tác dụng rất lớn khi bạn gọi đến chúng từ các lớp khác. Nó làm tăng tính sử dụng của lớp có dùng kỹ thuật overloading.

Mình sẽ cho bạn một vài dẫn chứng, bạn có nhớ ở bài học về cách sử dụng StringBuffer (hay StringBuilder) không, khi đó constructor của các lớp này được nạp chồng khiến cho khi bạn khởi tạo chúng, bạn nhận ra các constructor như hình sau.

Nạp chồng constructor đối với lớp StringBuffer
Nạp chồng constructor đối với lớp StringBuffer

Hay mỗi khi gọi đến phương thức để in dữ liệu ra console, bạn có thấy rất nhiều các phương thức 

println()

 được nạp chồng cùng tên hay không. Như hình sau.

Nạp chồng phương thức println()
Nạp chồng phương thức println()

Bạn thấy đó, với việc nạp chồng nhiều constructor hay phương thức như trên giúp làm tăng tính hiệu quả sử dụng của lớp StringBuffer cũng như các phương thức bên trong System.out. Cụ thể như với System.out, khi này thì các phương thức nạp chồng println() đã “bao sô” hết tất cả các tham số có thể có rồi, do đó bạn có thể yêu cầu phương thức này xuất ra console bất cứ dữ liệu nào mà bạn muốn, bởi chỉ một phương thức println(), nó đều làm được mà không ỏng ẹo gì cả, rất dễ nhớ và vận dụng đúng không nào. Nếu không có overloading, chắc chắn trường hợp này bạn phải cần rất nhiều phương thức cho từng trường hợp, như printlnInt()printlnChar()printlnLong(),… đúng không, vậy thì rối quá.

Quay lại lớp HinhTron mà bạn vừa thử nghiệm trên kia, nếu như ở đâu đó có khai báo lớp này, bạn sẽ thấy danh sách nạp chồng constructor của nó như sau.


Nạp chồng constructor đối với lớp HinhTron

Hay khi gọi đến các phương thức nạp chồng 

tinhChuVi()

 bên trong lớp này, bạn sẽ thấy sự gợi ý đến các phương thức này như hình bên dưới. Thật là “chuyên nghiệp”.

Nạp chồng phương thức tinhChuVi()
Nạp chồng phương thức tinhChuVi()

Bạn đã hiểu được hiệu quả khi dùng kỹ thuật overloading rồi dùng không nào. Giờ thì code thôi.

Thực Hành Xây Dựng Ứng Dụng Tính Lương Cho Nhân Viên

Đã lâu rồi chúng ta không làm cái gì đó cho nó mới mẻ hay hoành tráng. Vậy thì bài hôm nay bạn hãy cố gắng vận động gân cốt một tí. Mời bạn mở InteliJ lên và thử xây dựng project nho nhỏ sau. Bạn hãy đọc yêu cầu rồi cố gắng vận dụng hết tất cả các kiến thức về OOP từ trước tới giờ vô bài thực hành luôn nhé.

Yêu Cầu Chương Trình

Ứng dụng của chúng ta phục vụ một công ty nhỏ. Với một số nguyên tắc sau.

  • Công ty này có hai loại nhân viên, đó là nhân viên toàn thời gian và nhân viên thời vụ.
  • Nhân viên toàn thời gian là lính sẽ hưởng lương 10 củ một tháng. Nhân viên toàn thời gian là sếp sẽ hưởng lương 20 củ một tháng.
  • Nhân viên toàn thời gian nếu làm thêm ngày nào thì sẽ được cộng thêm 800k mỗi ngày, bất kể chức vụ.
  • Nhân viên thời vụ cứ làm mỗi giờ được 100k, không phân biệt chức vụ gì cả. Làm nhiều thì hưởng nhiều.

Vậy thôi, ứng dụng sẽ cho phép nhập vào từng nhân viên. Mỗi nhân viên có tên nhân viên. Có loại nhân viên toàn thời gian hay bán thời gian. Nhân viên toàn thời gian thì là nhân viên lính hay nhân viên sếp, có làm thêm ngày nào không. Nhân viên thời vụ thì làm được mấy giờ. Cuối cùng dựa vào các thông tin đó, sẽ xuất ra màn hình lương tương ứng.

Sơ Đồ Lớp

Do yêu cầu chương trình có phần phức tạp, nên chỉ có thể giải thích rõ ràng nhất thông qua sơ đồ lớp mà thôi.

Sơ đồ lớp
Sơ đồ lớp

Bạn có thể thấy rằng, sơ đồ này có thêm nhiều thông tin hơn so với những ngày đầu bạn mới làm quen. Để mình giải thích một số thông tin bổ sung này.

– Thông tin package được thể hiện ở dưới tên lớp, với font chữ nhỏ hơn. Như vậy nhìn sơ đồ chúng ta thấy các lớp NhanVienNhanVienFullTime và NhanVienPartTime nằm trong cùng package model. Lớp Configs nằm trong package util.
– Khả năng truy cập của các thuộc tính và phương thức nay rõ ràng hơn. Khi đó dấu (-) là private, dấu (+) là public, và dấu (#) là protected.
– Hằng số là các giá trị được viết in hoa.
– Còn giá trị static sẽ được gạch chân, như các thuộc tính trong lớp Configs.

Như vậy sơ đồ của chúng ta ngày càng rõ ràng hơn rồi đó.

Xây Dựng Các Lớp

Đến đây bạn tự code được rồi đó.

Đầu tiên là lớp Configs để lưu các giá trị tĩnh, như mức lương tháng, lương ngày, lương giờ,…

package util;
 
public class Configs {
// Loại nhân viên
public static final int NHAN_VIEN_SEP = 1;
public static final int NHAN_VIEN_LINH = 2;
 
// Lương nhân viên
public static final long LUONG_NHAN_VIEN_FULL_TIME_SEP = 20000000; // Lương tháng của sếp
public static final long LUONG_NHAN_VIEN_FULL_TIME_LINH = 10000000; // Lương tháng của lính
public static final long LUONG_LAM_THEM_MOI_NGAY = 800000; // Làm thêm mỗi ngày của nhân viên toàn thời gian được 800 k
public static final long LUONG_NHAN_VIEN_PART_TIME_MOI_GIO = 100000; // Lương nhân viên thời vụ mỗi giờ 100 k
}

Kế đến là lớp cha NhanVien.

package model;
 
public class NhanVien {
protected String ten;
protected long luong;
 
public NhanVien() {
 
}
 
public NhanVien(String ten) {
this.ten = ten;
}
 
protected String loaiNhanVien() {
// Lớp con phải override để lo vụ loại nhân viên này
return "";
}
 
public void xuatThongTin() {
System.out.println("===== Nhân viên: " + ten + " =====");
System.out.println("- Loại nhân viên: " + loaiNhanVien());
System.out.println("- Lương: " + luong + " VND");
}
}

Rồi đến hai lớp con NhanVienFullTime và NhanVienPartTime.

package model;
 
import util.Configs;
 
/**
* NhanVienFullTime chính là nhân viên toàn thời gian
*/
public class NhanVienFullTime extends NhanVien {
private int ngayLamThem; // Ngày làm thêm của nhân viên
private int loaiChucVu; // Chức vụ là lính hay sếp
 
public NhanVienFullTime(String ten) {
super(ten);
this.loaiChucVu = Configs.NHAN_VIEN_LINH; // Mặc định là lính
}
 
public NhanVienFullTime(String ten, int ngayLamThem) {
super(ten);
this.ngayLamThem = ngayLamThem;
this.loaiChucVu = Configs.NHAN_VIEN_LINH; // Mặc định là lính
}
 
public void setLoaiChucVu(int loaiChucVu) {
this.loaiChucVu = loaiChucVu;
}
 
@Override
public String loaiNhanVien() {
if (loaiChucVu == Configs.NHAN_VIEN_LINH) {
return "Lính toàn thời gian" + (ngayLamThem > 0 ? " (có làm thêm ngày)":"");
} else {
return "Sếp toàn thời gian" + (ngayLamThem > 0 ? " (có làm thêm ngày)":"");
}
}
 
public void tinhLuong() {
if (loaiChucVu == Configs.NHAN_VIEN_LINH) {
luong = Configs.LUONG_NHAN_VIEN_FULL_TIME_LINH + ngayLamThem * Configs.LUONG_LAM_THEM_MOI_NGAY;
} else if (loaiChucVu == Configs.NHAN_VIEN_SEP) {
luong = Configs.LUONG_NHAN_VIEN_FULL_TIME_SEP + ngayLamThem * Configs.LUONG_LAM_THEM_MOI_NGAY;
}
}
}
package model;
 
import util.Configs;
 
/**
* NhanVienPartTime chính là nhân viên thời vụ
*/
public class NhanVienPartTime extends NhanVien {
private int gioLamViec; // Tổng số giờ làm việc của nhân viên
 
public NhanVienPartTime(String ten, int gioLamViec) {
this.ten = ten;
this.gioLamViec = gioLamViec;
}
 
@Override
public String loaiNhanVien() {
return "Nhân viên thời vụ";
}
 
public void tinhLuong() {
luong = Configs.LUONG_NHAN_VIEN_PART_TIME_MOI_GIO * gioLamViec;
}
}

Và đây là lời gọi đến từ main().

package main;
 
import model.NhanVienFullTime;
import model.NhanVienPartTime;
import util.Configs;
 
public class MainClass {
public static void main(String[] args) {
// Công ty có 3 nhân viên toàn thời gian, trong đó có 1 sếp, sếp không làm thêm ngày nào
NhanVienFullTime sep = new NhanVienFullTime("Trần Văn Sếp");
sep.setLoaiChucVu(Configs.NHAN_VIEN_SEP);
NhanVienFullTime linh1 = new NhanVienFullTime("Nguyễn Văn Lính"); // Không làm thêm ngày nào
NhanVienFullTime linh2 = new NhanVienFullTime("Lê Thị Lính", 3); // Làm thêm 3 ngày
 
// Công ty đang thuê 1 nhân viên thời vụ
NhanVienPartTime thoiVu = new NhanVienPartTime("Phan Thị Thời Vụ", 240); // Cô ấy siêng làm lắm
 
// Tính lương cho nhân viên
sep.tinhLuong();
linh1.tinhLuong();
linh2.tinhLuong();
thoiVu.tinhLuong();
 
// In thông tin nhân viên
sep.xuatThongTin();
linh1.xuatThongTin();
linh2.xuatThongTin();
thoiVu.xuatThongTin();
}
}

Đây là kết quả khi bạn thực thi chương trình lên.

===== Nhân viên: Trần Văn Sếp =====
- Loại nhân viên: Sếp toàn thời gian
- Lương: 20000000 VND
===== Nhân viên: Nguyễn Văn Lính =====
- Loại nhân viên: Lính toàn thời gian
- Lương: 10000000 VND
===== Nhân viên: Lê Thị Lính =====
- Loại nhân viên: Lính toàn thời gian (có làm thêm ngày)
- Lương: 12400000 VND
===== Nhân viên: Phan Thị Thời Vụ =====
- Loại nhân viên: Nhân viên thời vụ
- Lương: 24000000 VND

Kết Luận

Chúng ta vừa trải qua một kiến thức thú vị nữa của Java, kiến thức về nạp chồng phương thức, hay còn gọi overloading. Qua bài học thì bạn cũng đã nắm rõ và phân biệt tốt thế nào là overriding và thế nào là overloading rồi đúng không nào.

 

Đa Hình (Polymorphism)

Bài hôm nay chúng ta sẽ nói sâu về tính Đa hình trong Java. Nghe qua đặc tính này thì có vẻ khó. Một phần vì ứng dụng của chúng không nhiều. Với cái tên nghe chẳng có cố định gì cả, như là biến hình gì gì đó. Cộng với khá ít tài liệu viết rõ về công năng này của OOP.

Vậy thì chúng ta cùng đi sâu vào bài học để xem Đa hình là gì và nó có thực sự khó không nhé.

Tính Đa Hình (Polymorphism) Là Gì?

Lần này thì nghĩa tiếng Anh và tiếng Việt trong lập trình Java lại khớp với nhau. Không nhiều các từ lan man, chỉ có Đa hình, hoặc Polymorphism mà thôi.

Vậy tại sao lại Đa hình? Như bạn biết, vốn dĩ OOP là một cách thức tư duy lập trình hướng thực tế, nên hiển nhiên các khái niệm của nó cũng phải sát với các đặc điểm trong thực tế. Trong đó có Đa hình. Trong thực tế, sự Đa hình được xem như một đối tượng đặc biệt, có lúc đối tượng này mang một hình dạng (trở thành một đối tượng) nào đó, và cũng có lúc đối tượng này lại mang một hình dạng khác nữa, tùy vào từng hoàn cảnh. Sự “nhập vai” vào các hình dạng (đối tượng) khác nhau này giúp cho đối tượng Đa hình ban đầu có thể thực hiện những hành động khác nhau của từng đối tượng cụ thể. Chẳng hạn nếu ở công ty bạn, có nhân viên nhận hai trách nhiệm khác nhau, họ vừa là nhân viên toàn thời gian ở các ngày trong tuần, nhưng làm bán thời gian ở các ngày cuối tuần. Vậy thì, để tính lương cho nhân viên này, tùy vào từng thời điểm mà hệ thống sẽ xem nhân viên đó là toàn thời gian hay bán thời gian, và phương thức tính lương của mỗi loại nhân viên sẽ thực hiện tính toán một cách hiệu quả nhất dựa vào từng vai trò khác nhau này. Bạn cũng hiểu sơ sơ về Đa hình rồi đúng không nào.

Có một điều chắc chắn rằng. Nếu như không xem hành động tính lương của nhân viên như ví dụ trên kia là Đa hình, thì chúng ta vẫn cứ xây dựng được một hệ thống tính lương hoàn chỉnh, nhưng sẽ phức tạp hơn là nếu bạn biết kiến thức về Đa hình là gì.

Và còn một ý nữa. Rằng tính Đa hình của bài hôm nay cũng là một trong các đặc tính nổi trội mà OOP mang lại đấy nhé. Bạn cố gắng nắm bắt và tận dụng. Ôn lại một tí các đặc tính cốt lõi của OOP bao gồm:

– Tính Gói ghém dữ liệu (Encapsulation). Tính chất này được thể hiện qua các kiến thức về khả năng truy cậpgetter/setter.
– Tính Kế thừa (Inheritance). Tính chất này được thể hiện qua các kiến thức về kế thừaoverridingoverloading.
– Tính Đa hình (Polymorphism). Bài hôm nay chúng ta sẽ học.
– Tính Trừu tượng (Abstraction). Bài sau chúng ta sẽ học.

Sử Dụng Tính Đa Hình Như Thế Nào?

Đến đây chắc chắn bạn đã hiểu sơ bộ khái niệm Đa hình. Vậy thì trong OOP chúng ta tổ chức và sử dụng đặc tính Đa hình này như thế nào?

Thứ nhất, Đa hình sẽ gắn liền với kế thừa. Và, Đa hình cũng sẽ gắn liền với ghi đè phương thức (overriding) nữa. Bởi vì như trên đây có nói đó, Đa hình là nói đến một đối tượng nào đó có khả năng nhập vai thành các đối tượng khác. Vậy thì để mà một đối tượng có thể là một đối tượng nào đó, ắt hẳn nó phải là đối tượng cha. Và để đối tượng cha có thể là một trong các đối tượng con ở từng hoàn cảnh, thì nó phải định nghĩa ra các phương thức để con của nó có thể ghi đè. Điều này giúp hệ thống xác định được đối tượng nào và phương thức nào thực sự đang hoạt động khi ứng dụng đang chạy. Nên nhiều tài liệu gọi Đa hình này là Đa hình tại runtime là vậy.

Chúng ta sẽ đến ví dụ sau cho dễ hiểu hơn. Ví dụ khá đơn giản. Lớp HinhHoc là lớp cha, hai lớp con HinhTron và HinhChuNhat đều override phương thức tinhDienTich() từ cha.

Đa hình - Sơ đồ lớp ví dụ

Code của chúng cũng khá đơn giản, chúng ta loại bỏ hết tất cả các râu ria khác, chỉ tập trung vào các phương thức override mà thôi.

– HinhHoc

1
2
3
4
5
6
public class HinhHoc {
     
    public void tinhDienTich() {
        System.out.println("Chưa biết hình nào");
    }
}

– HinhTron

1
2
3
4
5
6
7
8
public class HinhTron extends HinhHoc {
     
    @Override
    public void tinhDienTich() {
        System.out.println("Đây là Diện tích hình Tròn");
    }
  
}

– HinhChuNhat

1
2
3
4
5
6
7
8
public class HinhChuNhat extends HinhHoc {
  
    @Override
    public void tinhDienTich() {
        System.out.println("Đây là Diện tích hình Chữ nhật");
    }
 
}

Nào, sự diệu kỳ của tính Đa hình là đây, bạn hãy chú ý vào đoạn code khai báo và sử dụng các phương thức được overriding trên kia như sau.

– MainClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MainClass {
  
    public static void main(String[] args) {
        HinhHoc hinhHoc = new HinhHoc();
        hinhHoc.tinhDienTich(); // Đoạn code này bình thường, sẽ in ra "Chưa biết hình nào"
         
        // Có lúc hinhHoc đóng vai trò là HinhTron trong một ngữ cảnh nào đó
        hinhHoc = new HinhTron();
        hinhHoc.tinhDienTich(); // Đoạn code này sẽ in ra "Đây là Diện tích Hình tròn"
         
        // Có lúc hinhHoc đóng vai trò là HinhChuNhat trong một ngữ cảnh nào đó
        hinhHoc = new HinhChuNhat();
        hinhHoc.tinhDienTich(); // Đoạn code này sẽ in ra "Đây là Diện tích Chữ nhật"
    }
  
}

Bạn đã thấy đó, đối tượng HinhHoc bản thân nó có một phương thức tinhDienTich(). Nhưng khác với cách sử dụng các đối tượng từ các bài học từ trước đến giờ, rằng mỗi khi cần đến các lớp con thực hiện việc tính diện tích, chúng ta sẽ khai báo lớp con và gọi phương thức được override ở lớp con. Thì bài hôm nay chúng ta cho phép lớp HinhHoc có khả năng đóng vai trò là lớp con, bằng cách khởi tạo lại đối tượng là lớp con của nó, HinhHoc hinhHoc = new HinhTron(), rồi chính nó sẽ đóng vai là lớp con đó. Tính Đa hình là đây.

Thực Hành Xây Dựng Ứng Dụng Tính Lương Cho Nhân Viên

Nếu như ở bài học trước chúng ta đã xây dựng “hoàn chỉnh” một hệ thống tính lương “phức tạp” cho một công ty “bự”. Nhưng code khi đó lại không mang rõ tính ứng dụng thực tế, bởi vì chúng ta đã code “cứng” ở chỗ biết trước anh nhân viên nào là lính, anh nào là sếp, anh nào làm toàn thời gian, anh nào làm bán thời gian, để mà khai báo các đối tượng NhanVienFullTime hay NhanVienPartTime tương ứng.

Vậy sang bài hôm nay, chúng ta sẽ hoàn thiện ứng dụng tính lương nhân viên của bài trước. Làm cho hệ thống trở nên thực tế hơn. Cụ thể, bài này chúng ta sẽ cho người dùng nhập bằng tay thông tin nhân viên. Và vì vậy sẽ có một mảng các nhân viên trong ứng dụng. Lớp NhanVien sẽ là lớp có sử dụng đặc tính Đa hình để có thể đóng vai trò là NhanVienFullTime hoặc NhanVienPartTime ở từng hoàn cảnh cụ thể.

Mô Tả Lại Yêu Cầu Chương Trình

Yêu cầu của chương trình tính lương không hề thay đổi so với bài trước. Mình chỉ mô tả lại thôi.

– Công ty có hai loại nhân viên: nhân viên toàn thời gian và nhân viên thời vụ.
– Nhân viên toàn thời gian là lính sẽ hưởng lương 10 củ một tháng. Nhân viên toàn thời gian là sếp sẽ hưởng lương 20 củ một tháng.
– Nhân viên toàn thời gian nếu làm thêm ngày nào thì sẽ được cộng thêm 800k mỗi ngày, bất kể chức vụ.
– Nhân viên thời vụ cứ làm mỗi giờ được 100k, không phân biệt chức vụ gì cả. Làm nhiều thì hưởng nhiều.

Ứng dụng sẽ cho phép người dùng nhập vào số lượng nhân viên. Sau đó với từng nhân viên, người dùng phải nhập vào tên nhân viên, loại nhân viên toàn thời gian hay bán thời gian, nhân viên toàn thời gian thì là nhân viên lính hay nhân viên sếp, có làm thêm ngày nào không, nhân viên thời vụ thì làm được mấy giờ. Cuối cùng dựa vào các thông tin đó, sẽ xuất ra màn hình lương tương ứng cho tất cả nhân viên.

Sơ Đồ Lớp

Chúng ta vẫn dựa vào sơ đồ lớp của bài trước. Nhưng chỉnh sửa một chút sao cho lớp NhanVien sẽ “vào vai” tốt các lớp con của nó. Bằng cách xây dựng thêm phương thức tinhLuong() ở lớp này, rồi ở các lớp con sẽ phải override lại.

Đa hình - Sơ đồ lớp bài thực hành

Xây Dựng Các Lớp

Lớp Configs không hề thay đổi.

Lớp NhanVien chỉ có thêm phương thức tinhLuong() để thực hiện Đa hình trên phương thức này.

Lớp NhanVienFullTime và NhanVienPartTime cũng không thay đổi gì. Chỉ có giảm bớt overloading ở constructor để khâu nhập liệu được dễ dàng hơn thôi.

Và đây. Mọi thay đổi sẽ nằm ở phương thức main(). Nếu bạn đừng để ý đến các đoạn code râu ria nhập liệu từ console. Thì phương thức main() có các ý sau chúng ta nên lưu tâm.

– Lần đầu tiên, chúng ta sử dụng đến mảng các đối tượng. Và bạn thấy rằng, mảng các đối tượng cũng chẳng khác mảng của các kiểu nguyên thủy mà bạn đã học là mấy.
– Với mỗi phần tử trong mảng các NhanVien. Chúng ta khởi tạo NhanVien này là NhanVienFullTime hay NhanVienPartTime là do điều kiện mà người dùng nhập vào. Tính Đa hình phát huy tác dụng ở đây.

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
package main;
 
import java.util.Scanner;
 
import model.NhanVien;
import model.NhanVienFullTime;
import model.NhanVienPartTime;
 
public class MainClass {
 
    public static void main(String[] args) {
        // Kêu người dùng nhập vào số lượng nhân viên trong công ty
        Scanner scanner = new Scanner(System.in);
        System.out.print("Hãy nhập số lượng nhân viên: ");
        int tongNhanVien = Integer.parseInt(scanner.nextLine());
         
        // Khai báo mảng các nhân viên
        NhanVien[] mangNhanVien = new NhanVien[tongNhanVien];
        for (int i = 0; i < tongNhanVien; i++) {
            // Khai báo từng loại nhân viên, và kêu người dùng nhập thông tin nhân viên
            System.out.print("Tên nhân viên " + (i + 1) + ": ");
            String ten = scanner.nextLine();
            System.out.print("Là nhân viên (1-Toàn thời gian; 2-Bán thời gian): ");
            int laNhanVien = Integer.parseInt(scanner.nextLine());
            if (laNhanVien == 1) {
                // Nhân viên toàn thời gian
                System.out.print("Chức vụ nhân viên (1-Sếp; 2-Lính): ");
                int chucVu = Integer.parseInt(scanner.nextLine());
                System.out.print("Ngày làm thêm (nếu có): ");
                int ngayLamThem = Integer.parseInt(scanner.nextLine());
                mangNhanVien[i] = new NhanVienFullTime(ten, ngayLamThem, chucVu);
            } else {
                System.out.print("Giờ làm: ");
                int gioLamViec = Integer.parseInt(scanner.nextLine());
                mangNhanVien[i] = new NhanVienPartTime(ten, gioLamViec);
            }
        }
         
        System.out.println("\nKết quả tính lương\n");
         
        // Tính lương và xuất thông tin nhân viên
        for (NhanVien nhanVien : mangNhanVien) {
            nhanVien.tinhLuong();
            nhanVien.xuatThongTin();
        }
    }
 
}

Cuối cùng là kết quả thực thi chương trình. Ngoài việc nhập liệu động ra thì kết quả in ra là như bài hôm trước. Bạn thử kết hợp việc nhập liệu động của bài hôm nay với việc không sử dụng tính Đa hình, mà dùng như bài học hôm trước xem. Với cách thử nghiệm này, bạn sẽ hiểu rõ hơn về thế mạnh của Đa hình đấy.

Đa hình - Kết quả console bài thực hành

Đa hình là vậy, bạn có thấy khó không nào. Sẽ còn kiến thức có liên quan đến Đa hình nữa, như ép kiểu trong OOP chẳng hạn, mà chúng ta sẽ nói ở bài học sau.

Ép Kiểu Trong OOP

Chắc các bạn còn nhớ, chúng ta đã nói về ép kiểu ở bài học số 6. Và hiển nhiên bạn đã biết khái niệm ép kiểu là gì rồi, bài này chúng ta không cần nhắc đến.

Và ép kiểu ở bài học đó chính là ép kiểu trên các dữ liệu nguyên thủy. Việc ép kiểu lúc bấy giờ được phân biệt làm hai trường hợp riêng biệt, đó là ép kiểu ngầm định và ép kiểu tường minh. Xem ra thì ép kiểu mà bạn đã biết cũng không có gì phức tạp lắm nhỉ. Vậy thì hôm nay, khi biết về OOP, chúng ta sẽ xem việc ép kiểu trên các dữ liệu không-phải-nguyên-thủy sẽ trông như thế nào nhé.

Ép Kiểu Ngầm Định

Vâng, trong OOP, chúng ta cũng có thể phân loại ép kiểu ra làm hai dạng, tường minh và ngầm định.

Ép kiểu ngầm định với các lớp trong OOP cũng tương tự như ép kiểu ngầm định với các biến theo kiểu dữ liệu nguyên thủy. Đó là nếu như không có sự mất mát dữ liệu, hay có thể nới rộng khả năng lưu trữ của dữ liệu, thì xem như hệ thống sẽ hoàn toàn ngầm định giúp chúng ta ép kiểu.

Thế nhưng, với OOP, chúng ta có hai trường hợp ép kiểu ngầm định sau đây, mời bạn cùng đi đến hai mục nhỏ bên dưới.

Ép Kiểu Ngầm Định Với Các Đối Tượng Cùng Một Lớp

Thực sự vấn đề này cũng quen thuộc thôi. Nó như là bạn khai báo hai biến int a và int b, rồi bạn gán b = a, và kết quả là a và b sẽ có cùng một giá trị. Với OOP thì sao, cũng vậy, nó sẽ như là bạn khai báo NhanVien a và NhanVien b, rồi bạn gán b = a, kết quả là b với a sẽ mang cùng một nội dung.

Tuy vậy nhưng cũng không hẳn là vậy. Với việc gán hai biến nguyên thủy int vào int như trên thì hết sức bình thường. Nhưng việc bạn gán hai đối tượng NhanVien vào NhanVien thì được xem như một phép ép kiểu, vì sao vậy, chúng ta thử đến ví dụ bên dưới đây.

Giả sử có một lớp NhanVien cực kỳ đơn giản, lớp này chỉ có một thuộc tính ten và các phương thức getter/setter cho nó, và một phương thức giúp xuất thông tin ten ra console.

public class NhanVien {
 
protected String ten;
 
public String getTen() {
return ten;
}
 
public void setTen(String ten) {
this.ten = ten;
}
 
public void xuatThongTin() {
System.out.println("Nhân viên: " + ten);
}
}

Đến phương thức main(). Chúng ta sẽ khai báo hai đối tượng nhanVien1 và nhanVien2 từ lớp NhanVien này, và bạn hãy xem hệ thống gán, hay ép kiểu ngầm định hai đối tượng này cho nhau như thế nào với các dòng code sau nhé.

public class MainClass {
 
public static void main(String[] args) {
// Khai báo hai đối tượng nhanVien1 và nhanVien2 từ một lớp NhanVien
NhanVien nhanVien1 = new NhanVien();
NhanVien nhanVien2 = new NhanVien();
 
// Set tên cho hai nhân viên
nhanVien1.setTen("Hùng");
nhanVien2.setTen("Trang");
 
// Xuất thông tin hai đối tượng lần 1: bạn có thể đoán ra nội dung xuất đúng không
nhanVien1.xuatThongTin(); // In ra "Nhân viên: Hùng"
nhanVien2.xuatThongTin(); // In ra "Nhân viên: Trang"
 
// Gán hai đối tượng nhân viên, hệ thống cũng sẽ ép kiểu ngầm định nhanVien1 về nhanVien2
nhanVien2 = nhanVien1;
 
// Xuất thông tin hai đối tượng lần 2: kết quả không gì lạ, vì nhanVien2 sẽ mang nội dung giống với nhanVien1
nhanVien1.xuatThongTin(); // In ra "Nhân viên: Hùng"
nhanVien2.xuatThongTin(); // In ra "Nhân viên: Hùng"
 
// Thay đổi thông tin giá trị ten bên trong nhanVien2
nhanVien2.setTen("Khải");
 
// Xuất thông tin hai đối tượng lần 3: lạ chưa, cả hai đối tượng đều bị thay đổi giá trị thuộc tính
nhanVien1.xuatThongTin(); // In ra "Nhân viên: Khải"
nhanVien2.xuatThongTin(); // In ra "Nhân viên: Khải"
}
 
}

Bạn chú ý vào comment “xuất thông tin hai đối tượng lần 3”. Trước khi xuất thông tin ở lần này cho cả hai đối tượng nhanVien1 và nhanVien2, thì chỉ có mỗi nhanVien2 là có sự thay đổi giá trị đến thuộc tính ten mà thôi.

// Thay đổi thông tin giá trị ten bên trong nhanVien2
nhanVien2.setTen("Khải");

Vậy cớ sự làm sao mà ten ở nhanVien1 cũng bị chung số phận? Vì bạn nên nhớ rằng, trong OOP, khi bạn thực hiện phép gán hai đối tượng cho nhau, như ví dụ là phép gán 

nhanVien2 = nhanVien1;

 , thì hệ thống sẽ ép kiểu dữ liệu, và cả tham chiếu, của cả hai đối tượng vào nhau, làm cho chúng giờ đây trở thành một. Điều này khác hoàn toàn với việc sử dụng phép gán ở kiểu dữ liệu nguyên thủy, bạn hãy ghi nhớ điều này nhé.

Ép Kiểu Ngầm Định Từ Lớp Con Sang Lớp Cha

Cũng giống như ép kiểu ngầm định dạng mở rộng khả năng lưu trữ ở các kiểu dữ liệu nguyên thủy, hệ thống sẽ tự thực hiện ép kiểu khi mà có sự chuyển đổi dữ liệu từ kiểu dữ liệu có kích thước nhỏ sang kiểu dữ liệu có kích thước lớn hơn, như từ int sang float chẳng hạn. Thì hệ thống cũng sẽ làm tương tự vậy với OOP, nếu có sự chuyển đổi dữ liệu từ lớp con sang lớp cha.

Giờ giả sử chúng ta xây dựng một lớp con của NhanVien, chính là lớp NhanVienFullTime. Lớp con này có override lại phương thức xuatThongTin() của lớp cha. Code của lớp NhanVienFullTime này như sau.

public class NhanVienFullTime extends NhanVien {
 
@Override
public void xuatThongTin() {
System.out.println("Nhân viên toàn thời gian: " + ten);
}
}

Rồi. Như vậy chúng ta cùng xem ở phương thức main(), xem việc ép kiểu ngầm định diễn ra như thế nào nhé. Bạn có thể thấy rằng kết cục của phép gán, và ép kiểu giữa các lớp trong OOP đều mang đến kết quả là hai đối tượng sẽ trở thành một (vì cùng một tham chiếu). Sau khi gán, hễ bạn thay đổi giá trị của một lớp, thì lớp cùng trong phép gán kia cũng sẽ bị thay đổi giá trị tương tự.

public class MainClass {
public static void main(String[] args) {
// Khai báo hai đối tượng nhanVien và nhanVienFullTime
NhanVien nhanVien = new NhanVien();
NhanVienFullTime nhanVienFullTime = new NhanVienFullTime();
 
// Set tên cho hai nhân viên
nhanVien.setTen("Hùng");
nhanVienFullTime.setTen("Trang");
 
// Xuất thông tin hai đối tượng lần 1
nhanVien.xuatThongTin(); // In ra "Nhân viên: Hùng"
nhanVienFullTime.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Trang"
 
// Ép kiểu ngầm định từ NhanVienFullTime về NhanVien, hoàn toàn tự động
nhanVien = nhanVienFullTime;
 
// Xuất thông tin hai đối tượng lần 2
nhanVien.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Trang"
nhanVienFullTime.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Trang"
 
// Thay đổi thông tin giá trị ten bên trong nhanVienFullTime
nhanVienFullTime.setTen("Khải");
 
// Xuất thông tin hai đối tượng lần 3
nhanVien.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Khải"
nhanVienFullTime.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Khải"
}
}

Ép Kiểu Tường Minh

Bạn hoàn toàn có thể đoán được khi nào chúng ta cần thiết phải ép kiểu tường minh rồi đúng không nào. Đó là khi mà hệ thống phát hiện thấy bạn đang muốn chuyển dữ liệu từ kiểu dữ liệu có kích thước lớn hơn sang kiểu dữ liệu có kích thước nhỏ hơn. Với OOP thì từ lớp cha sang lớp con.

Chúng ta đến với ví dụ với phép gán ngược lại với ví dụ ngay trên đây.

public class MainClass {
 
public static void main(String[] args) {
// Khai báo hai đối tượng nhanVien và nhanVienFullTime
NhanVien nhanVien = new NhanVien();
NhanVienFullTime nhanVienFullTime = new NhanVienFullTime();
 
// Set tên cho hai nhân viên
nhanVien.setTen("Hùng");
nhanVienFullTime.setTen("Trang");
 
// Xuất thông tin hai đối tượng lần 1
nhanVien.xuatThongTin(); // In ra "Nhân viên: Hùng"
nhanVienFullTime.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Trang"
 
// Ép kiểu tường minh từ NhanVien sang NhanVienFullTime
nhanVienFullTime = (NhanVienFullTime) nhanVien;
}
 
}

Tại sao code lần này ít thế. Thực ra bạn không nên code nữa, có lỗi xảy ra ở dòng cuối cùng rồi. Dù cho sau khi bạn code, trình biên dịch không hề báo lỗi, nhưng nếu ngay bây giờ bạn thực thi ứng dụng, sẽ nhận được một exception ClassCastException như sau.

Ép kiểu lỗi runtime
Ép kiểu lỗi runtime

Lỗi này có nghĩa là khi bạn ép kiểu tường minh từ nhanVien về nhanVienFullTime, lúc này trình biên dịch vẫn thấy có lý, vì chúng quan hệ cha con mà. Nhưng khi thực thi ở môi trường thực tế, thì lớp nhanVien vốn dĩ không biết đến nhanVienFullTime là gì, nên không thể thực hiện việc ép kiểu được.

Vậy thì ép kiểu tường minh đối với OOP là như thế nào? Thực ra, nếu như chúng ta có áp dụng tính đa hình, tức là ở lúc nào đó nhanVien phải “đặt mình” vào vai trò là một nhanVienFullTime, thì chúng mới hiểu nhau khi cùng nhau sánh bước trong đường đời” phía trước. Code như thế này sẽ chạy tốt.

public class MainClass {
public static void main(String[] args) {
// Khai báo hai đối tượng nhanVien1 và nhanVien2 từ một lớp NhanVien
NhanVien nhanVien = new NhanVien();
NhanVienFullTime nhanVienFullTime = new NhanVienFullTime();
 
// Set tên cho hai nhân viên
nhanVien.setTen("Hùng");
nhanVienFullTime.setTen("Trang");
 
// Xuất thông tin hai đối tượng lần 1
nhanVien.xuatThongTin(); // In ra "Nhân viên: Hùng"
nhanVienFullTime.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Trang"
 
// Ép kiểu tường minh từ NhanVien sang NhanVienFullTime, nhưng NhanVien phải có tính đa hình trước đó
nhanVien = new NhanVienFullTime();
nhanVienFullTime = (NhanVienFullTime) nhanVien;
 
// Thay đổi thông tin giá trị ten bên trong nhanVien
nhanVien.setTen("Khải");
 
// Xuất thông tin hai đối tượng lần 2
nhanVien.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Khải"
nhanVienFullTime.xuatThongTin(); // In ra "Nhân viên toàn thời gian: Khải"
}
}

Từ khóa instanceof

Bạn đã thấy một ví dụ dẫn đến một exception có tên ClassCastException được tung ra ở ví dụ trên kia của mình. Đó là một cảnh báo nhãn tiền cho việc sử dụng ép kiểu tường minh đối với OOP đấy. Nó cho thấy nếu như bạn sử dụng viện ép kiểu không khéo, dẫn đến việc ép sai lớp của đối tượng, ứng dụng của bạn sẽ chết, hay crash.

Để đảm bảo việc ép kiểu tường minh được diễn ra an toàn, một yêu cầu đối với các lập trình viên là phải luôn dùng instanceof để kiểm tra đối tượng có thuộc lớp cụ thể nào không trước khi chính thức sử dụng ép kiểu.

Cách sử dụng instanceof cũng khá đơn giản, bạn hãy nhìn vào code sau sẽ rõ cách dùng. Mình thêm dòng if vào ví dụ bạn vừa code trên đây.

// Ép kiểu tường minh từ NhanVien sang NhanVienFullTime, nhưng NhanVien phải có tính đa hình trước đó
nhanVien = new NhanVienFullTime();
// Trước khi ép kiểu, nên kiểm tra xem nhanVien có đúng là lớp NhanVienFullTime (chấp nhận đa hình) hay không
if (nhanVien instanceof NhanVienFullTime) {
nhanVienFullTime = (NhanVienFullTime) nhanVien;
}

Ứng Dụng Của Ép Kiểu Tường Minh

Nếu bạn đọc đến đây của bài viết, mình chắc bạn đã nắm được thế nào là ép kiểu ngầm định và thế nào là ép kiểu tường minh rồi. Thế nhưng cũng sẽ có bạn thắc mắc rằng những sự ép kiểu này sẽ được sử dụng ở tình huống nào trong thực tế?

Nếu như với ép kiểu ngầm định thì với việc gán các đối tượng cho nhau là đã thực hiện một phép ép kiểu dễ dàng và an toàn rồi, nên mình sẽ không nói đến việc ứng dụng của kiểu ép này. Nhưng với ép kiểu tường minh, ứng dụng của nó khá hẹp, nếu muốn nói rằng hầu như bạn cũng chả cần đến. Nhưng nếu như bạn muốn nhìn code được tường minh hơn, hay muốn chỉ định rõ ràng phương thức mà chỉ có ở lớp con, khi đó bạn có thể dùng đến ép kiểu tường minh về lớp con như ví dụ sau.

Đầu tiên chúng ta phải làm cho lớp con NhanVienFullTime có phương thức mà lớp cha nó không có, như sau mình thêm vào thuộc tính thuong (thưởng) và phương thức setThuong().

public class NhanVienFullTime extends NhanVien {
private float thuong;
 
public void setThuong(float thuong) {
this.thuong = thuong;
}
 
@Override
public void xuatThongTin() {
System.out.println("Nhân viên toàn thời gian: " + ten + ", thưởng: " + thuong);
}
}

Ở main() lần này chúng ta khai báo mảng các NhanVien có tên là mangNhanVien. Trong đó mangNhanVien[0] mang tính đa hình để có thể trở thành NhanVienFullTime, các thành phần mangNhanVien còn lại đều là kiểu lớp cha NhanVien.

Bạn thấy trong vòng lặp đầu tiên, nếu mong muốn rằng mangNhanVien[i] nếu là NhanVienFullTime, thì ngoài việc setTen() ra nó còn phải gọi thêm setThuong() nữa. Khi này ép kiểu tường minh phát huy tác dụng.

public class MainClass {
public static void main(String[] args) {
// Khai báo mảng các NhanVien
NhanVien[] mangNhanVien = new NhanVien[3];
mangNhanVien[0] = new NhanVienFullTime(); // Nhân viên này là NhanVienFullTime
mangNhanVien[1] = new NhanVien(); // Nhân viên này là NhanVien
mangNhanVien[2] = new NhanVien(); // Nhân viên này là NhanVien
 
// Gán tên và thiết lập thưởng cho các nhân viên
for (int i = 0; i < mangNhanVien.length; i++) {
mangNhanVien[i].setTen("Nhan viên " + i);
if (mangNhanVien[i] instanceof NhanVienFullTime) {
// Nếu nhân viên kiểu NhanVienFullTime
// Thì ép kiểu tường minh về NhanVienFullTime để
// dùng đến phương thức đặc biệt của lớp con này
((NhanVienFullTime) mangNhanVien[i]).setThuong(100);
}
}
 
// Dùng foreach để duyệt qua từng nhân viên để xuất thông tin
for (NhanVien nhanVien : mangNhanVien) {
nhanVien.xuatThongTin();
}
}
}

Kết quả thực thi chương trình của ví dụ trên đây.

Nhân viên toàn thời gian: Nhan viên 0, thưởng: 100.0
Nhân viên: Nhan viên 1
Nhân viên: Nhan viên 2

Tính Trừu Tượng (Abstraction)

Với việc đi qua bài học hôm nay thì chúng ta cũng sẽ nói đủ bốn đặc tính cốt lõi trong lập trình hướng đối tượng. Bốn đặc tính đó là:

– Tính Gói ghém dữ liệu, hay còn gọi là Encapsulation. Bạn có thể xem lại tính chất này ở các bài: khả năng truy cậpgetter/setter.
– Tính Kế thừa, hay còn gọi là Inheritance. Bạn có thể xem lại tính chất này ở các bài: kế thừaoverridingoverloading.
– Tính Đa hình, hay còn gọi là Polymorphism. Bạn có thể xem lại tính chất này ở bài về polymorphism.
– Và hôm nay. Tính Trừu tượng, hay còn gọi là Abstraction.

Nghe qua thì thấy rất trừu tượng. Liệu kiến thức này có khó hiểu không và nó giúp ích gì cho việc tổ chức code trong OOP? Chúng ta cùng đi vào bài học luôn nhé.

Tính Trừu Tượng (Abstraction) Là Gì?

Trừu tượng trong thực tế còn có thể hiểu là cái gì đó không có thực. Vậy tính Trừu tượng trong OOP ý muốn nói đến một lớp nào đó mang một đặc tính trừu tượng, không có thực. Và như vậy bạn có thể hiểu là lớp Trừu tượng sẽ là lớp không tồn tại đúng không nào?

Thực ra thì lớp Trừu tượng vẫn có tồn tại, vẫn là một lớp thôi. Nhưng nó trừu tượng ở chỗ, nó không thể được dùng để tạo ra các đối tượng như những lớp bình thường khác. Lớp Trừu tượng khi này chỉ là cái “xác không hồn”, hay bạn có thể hiểu nó chỉ là một cái sườn, để mà bạn có thể tạo ra các lớp con của nó dựa vào sự ràng buộc từ cái sườn này.

Nghe đến đây đủ thấy trừu tượng rồi ha. Chúng ta cùng xem khai báo một lớp là Trừu tượng là như thế nào, và các đặc điểm của một lớp Trừu tượng sẽ ra sao với mục kế tiếp.

Khai Báo Lớp Trừu Tượng Như Thế Nào?

Sau đây là cú pháp để bạn có thể khai báo một lớp Trừu tượng.

abstract  class  tên_lớp {
	các_thuộc_tính;
	các_phương_thức;
}

Bạn có thể thấy, để khai báo một lớp là Trừu tượng, chỉ cần thêm vào trước từ khóa class một từ khóa abstract mà thôi.

Nhưng như vậy thì mục đích của sự Trừu tượng này là gì, chúng ta cùng đi đến mục sau đây.

Tại Sao Phải Trừu Tượng?

Lý do tiên quyết mà bạn phải biết đến Trừu tượng là vì có trên 90% khả năng bạn đi xin việc, người phỏng vấn sẽ hỏi bạn câu này: “em hãy giúp phân biệt giữa lớp abstract và interface”!!! Mình đùa thôi! Nhưng họ hỏi thật đấy!

Thực ra lý do để khai báo một lớp Trừu tượng là vì trong lớp có ít nhất một phương thức Trừu tượng. Có thể nói ngược lại cho dễ hiểu như sau, nếu mà một lớp có ít nhất một phương thức được định nghĩa là Trừu tượng, thì lớp đó phải khai báo Trừu tượng. Đến đây bạn sẽ có rất nhiều thắc mắc, mình xin giải đáp từ từ.

Phương thức Trừu tượng là gì? Chính là phương thức có định nghĩa từ khóa abstract khi khai báo. Cú pháp khai báo một phương thức Trừu tượng cũng giống như khai báo một lớp Trừu tượng thôi.

[kả_năng_truy_cập]  abstract  kiểu_trả_về  tên_phương_thức  ([ các_tham_số_truyền_vào"]);

Phương thức Trừu tượng có tác dụng gì? Phương thức có khai báo abstract sẽ buộc phải không có thân hàm. Như bạn xem cú pháp ở trên, sẽ không có thông tin về khối lệnh của phương thức dạng này. Bạn có thể so sánh với cú pháp của phương thức bình thường ở đây. Bạn chỉ có thể khai báo tên, các tham số truyền vào (nếu có), kiểu trả về, và rồi kết thúc khai báo bằng (;) cho các phương thức Trừu tượng. Có thể thấy rõ sự ràng buộc của tính Trừu tượng bắt đầu ở ngay đây. Sở dĩ các phương thức Trừu tượng không có thân hàm, vì chúng không cần phải làm vậy. Một khi có một lớp nào đó kế thừa từ lớp Trừu tượng này, thì lớp kế thừa đó buộc phải hiện thực (tiếng Anh gọi là implement) nội dung cho tất cả các phương thức Trừu tượng này, bằng cách override lại chúng.

Đến đây bạn có thể thấy lớp Trừu tượng thực sự không quá cao siêu. Nó chỉ là một lớp không có thể hiện (không thể khởi tạo đối tượng từ nó). Nhưng nó lại ràng buộc các lớp con của nó buộc phải hiện thực nội dung cho các phương thức Trừu tượng bên trong nó. Thế thôi.

Bạn có thể bắt gặp tính Trừu tượng này ở đâu đó khi sử dụng các gói thư viện từ hệ thống (thỉnh thoảng có nơi gọi là Java platform) hoặc từ các nhà phân phối khác. Khi bạn kế thừa các lớp của họ, bỗng dưng hệ thống báo lỗi và bắt bạn phải override ngay phương thức nào đó của lớp đó, thì chắc chắn bạn sẽ hiểu ngay lớp đó chính là lớp Trừu tượng. Một lát nữa chúng ta cùng khảo sát xem trong hệ thống thì lớp nào là lớp Trừu tượng nhé, sau khi đi qua bài thực hành bên dưới.

Thực Hành Xây Dựng Ứng Dụng Tính Lương Cho Nhân Viên

Chúng ta cùng quay lại bài toán tính lương cho nhân viên, mà ở bài học số 30 chúng ta đã làm cho nó trọn vẹn. Theo lý thì bạn không cần phải bổ sung gì cho bài thực hành này. Phần này chỉ nhằm mục đích cho bạn thấy một lớp Trừu tượng sẽ được khai báo và sử dụng như thế nào thôi. Bạn không nhất thiết phải áp dụng Trừu tượng một cách máy móc như bài thực hành.

Mô Tả Lại Yêu Cầu Chương Trình

Yêu cầu của chương trình tính lương không hề thay đổi so với bài trước. Mình chỉ mô tả lại thôi.

– Công ty có hai loại nhân viên: nhân viên toàn thời gian và nhân viên thời vụ.
– Nhân viên toàn thời gian là lính sẽ hưởng lương 10 củ một tháng. Nhân viên toàn thời gian là sếp sẽ hưởng lương 20 củ một tháng.
– Nhân viên toàn thời gian nếu làm thêm ngày nào thì sẽ được cộng thêm 800k mỗi ngày, bất kể chức vụ.
– Nhân viên thời vụ cứ làm mỗi giờ được 100k, không phân biệt chức vụ gì cả. Làm nhiều thì hưởng nhiều.

Ứng dụng sẽ cho phép người dùng nhập vào số lượng nhân viên. Sau đó với từng nhân viên, người dùng phải nhập vào tên nhân viên, loại nhân viên toàn thời gian hay bán thời gian, nhân viên toàn thời gian thì là nhân viên lính hay nhân viên sếp, có làm thêm ngày nào không, nhân viên thời vụ thì làm được mấy giờ. Cuối cùng dựa vào các thông tin đó, sẽ xuất ra màn hình lương tương ứng cho tất cả nhân viên.

Nâng Cấp Ứng Dụng Bằng Việc Xây Dựng Lớp NhanVien Là Trừu Tượng

Đây là hình ảnh code của lớp NhanVien ở bài thực hành hôm đó.

Tính trừu tượng - Lớp NhanVien khi chưa có áp dụng trừu tượng

Bạn xem, ở hai phương thức loaiNhanVien() và tinhLuong() được mình khoanh tròn, có phải bạn rất cần các lớp con buộc phải override hai phương thức này? Với cách code như thế này thì tính ràng buộc không có, và lỡ như ai đó thực hiện việc code các lớp con của NhanVien, liệu họ có nhớ mà override hai phương thức này hay không?

Vậy yêu cầu của bài hôm nay rõ ràng rồi nhé, chúng ta sẽ nâng cấp cho ứng dụng tính lương này bằng việc khai báo NhanVien là lớp Trừu tượng.

Sơ Đồ Lớp

Chúng ta vẫn dựa vào sơ đồ lớp của bài trước. Nhưng các phương thức loaiNhanVien() và tinhLuong() sẽ là các phương thức Trừu tượng. Và dĩ nhiên lớp NhanVien cũng phải là lớp Trừu tượng nốt. Trong sơ đồ lớp, các khai báo Trừu tượng sẽ được in nghiêng (mình còn in đậm ra cho bạn dễ thấy).

Tính trừu tượng - Sơ đồ lớp

Xây Dựng Các Lớp

Lớp Configs không hề thay đổi.

Lớp NhanVien khi này là lớp Trừu tượng. Và đây là code cho lớp NhanVien, bạn có thể so sánh với hình ảnh code trên kia của lớp này để xem sự thay đổi.

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
package model;
 
public abstract class NhanVien {
 
    protected String ten;
    protected long luong;
      
    public NhanVien() {
    }
      
    public NhanVien(String ten) {
        this.ten = ten;
    }
      
    // Lớp con phải override để lo vụ loại nhân viên này
    protected abstract String loaiNhanVien();
      
    // Lớp con phải override để lo vụ tính lương này
    public abstract void tinhLuong();
      
    public void xuatThongTin() {
        System.out.println("===== Nhân viên: " + ten + " =====");
        System.out.println("- Loại nhân viên: " + loaiNhanVien());
        System.out.println("- Lương: " + luong + " VND");
    }
}

Các lớp con của NhanVien sẽ có một chút ràng buộc, mình sẽ đi chi tiết từng bước xây dựng lớp NhanVienFullTime để bạn xem. Khi bạn vừa mới khai báo lớp này kế thừa từ NhanVien, bạn sẽ thấy hệ thống báo lỗi bằng một icon hình bóng đèn bên cạnh dấu chéo màu đỏ (mình có nói đến tính năng báo lỗi dạng này của Eclipse ở bài này, bạn tham khảo nhé).Tính trừu tượng - Kế thừa từ lớp trừu tượng NhanVien

Để khắc phục lỗi này rất dễ, bạn chỉ cần override các phương thức Trừu tượng từ lớp NhanVien. Nếu bạn không biết có bao nhiêu phương thức Trừu tượng cần phải override, thì cứ đưa trỏ chuột vào icon bóng đèn đó, bạn sẽ thấy nó liệt kê tất cả các phương thức bạn cần.

Tính trừu tượng - Các gợi ý chính là các phương thức trừu tượng

Hoặc click chuột hẳn vào cái bóng đèn, bạn sẽ thấy gợi ý. Khi này hãy chọn Add unimplemented methods.

Tính trừu tượng - Chọn lựa để implement các phương thức trừu tượng

Sau khi chọn tùy chọn trên, hệ thống sẽ tạo ra tất cả các phương thức override lại các phương thức Trừu tượng từ NhanVien. Và khi này hệ thống cũng không còn báo lỗi nữa. Nếu NhanVienFullTime kế thừa từ NhanVien, mà không hiện thực hết tất cả các phương thức Trừu tượng từ NhanVien, thì bạn sẽ không bao giờ có thể thực thi ứng dụng được.

Tính trừu tượng - Code tự động implement các phương thức trừu tượng

Và đây là code cuối cùng của lớp NhanVienFullTime.

Code của NhanVienPartTime.

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
package model;
 
import util.Configs;
 
/**
 * NhanVienPartTime chính là nhân viên thời vụ
 */
public class NhanVienPartTime extends NhanVien {
     
    private int gioLamViec; // Tổng số giờ làm việc của nhân viên
     
    public NhanVienPartTime(String ten, int gioLamViec) {
        this.ten = ten;
        this.gioLamViec = gioLamViec;
    }
     
    @Override
    public String loaiNhanVien() {
        return "Nhân viên thời vụ";
    }
     
    @Override
    public void tinhLuong() {
        luong = Configs.LUONG_NHAN_VIEN_PART_TIME_MOI_GIO * gioLamViec;
    }
}

Code ở phương thức main() vẫn sẽ như bài 30 hôm trước mà thôi.

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
package main;
 
import java.util.Scanner;
 
import model.NhanVien;
import model.NhanVienFullTime;
import model.NhanVienPartTime;
 
public class MainClass {
 
    public static void main(String[] args) {
        // Kêu người dùng nhập vào số lượng nhân viên trong công ty
        Scanner scanner = new Scanner(System.in);
        System.out.print("Hãy nhập số lượng nhân viên: ");
        int tongNhanVien = Integer.parseInt(scanner.nextLine());
         
        // Khai báo mảng các nhân viên
        NhanVien[] mangNhanVien = new NhanVien[tongNhanVien];
        for (int i = 0; i < tongNhanVien; i++) {
            // Khai báo từng loại nhân viên, và kêu người dùng nhập thông tin nhân viên
            System.out.print("Tên nhân viên " + (i + 1) + ": ");
            String ten = scanner.nextLine();
            System.out.print("Là nhân viên (1-Toàn thời gian; 2-Bán thời gian): ");
            int laNhanVien = Integer.parseInt(scanner.nextLine());
            if (laNhanVien == 1) {
                // Nhân viên toàn thời gian
                System.out.print("Chức vụ nhân viên (1-Sếp; 2-Lính): ");
                int chucVu = Integer.parseInt(scanner.nextLine());
                System.out.print("Ngày làm thêm (nếu có): ");
                int ngayLamThem = Integer.parseInt(scanner.nextLine());
                mangNhanVien[i] = new NhanVienFullTime(ten, ngayLamThem, chucVu);
            } else {
                System.out.print("Giờ làm: ");
                int gioLamViec = Integer.parseInt(scanner.nextLine());
                mangNhanVien[i] = new NhanVienPartTime(ten, gioLamViec);
            }
        }
         
        System.out.println("\nKết quả tính lương\n");
         
        // Tính lương và xuất thông tin nhân viên
        for (NhanVien nhanVien : mangNhanVien) {
            nhanVien.tinhLuong();
            nhanVien.xuatThongTin();
        }
    }
 
}

Qua bài thực hành này thì bạn đã hiểu được mục đính và cách thức ràng buộc của một lớp Trừu tượng rồi đúng không nào. Mục cuối cùng này mình sẽ dẫn chứng cho bạn xem một số lớp Trừu tượng được xây dựng sẵn trong thư viện của Java, để bạn có một sự hiểu biết rộng hơn về kiến thức này.

Trải Nghiệm Một Vài Lớp Trừu Tượng Của Java Platform

Việc trải nghiệm này chỉ để kiểm chứng một số lớp Trừu tượng có sẵn từ hệ thống. Chúng ta không hoàn toàn lúc nào cũng phải kế thừa từ các lớp này, trừ khi bạn muốn override một số phương thức của nó để mở rộng hơn khả năng của lớp gốc, người ta gọi hành động này là custom lại một đối tượng. Bạn sẽ bắt gặp nhiều hơn đến việc custom cho các lớp Trừu tượng của hệ thống này khi xây dựng ứng dụng Android.

Trải nghiệm đầu tiên. Nếu bạn thử xây dựng một lớp, rồi kế thừa từ lớp Graphics của hệ thống, bạn sẽ nhận được sự “mời gọi” phải hiện thực hàng tá phương thức Trừu tượng từ lớp này.

Điều này tương tự nếu bạn kế thừa từ lớp Number.

Hoặc với lớp DateFormat và với nhiều lớp khác trong Java platform.

Vậy thôi, đủ để bạn thấy là Java platform đã xây dựng sẵn rất nhiều lớp Trừu tượng. Sau này nếu có gặp phải bất kỳ một đòi hỏi phải implement các phương thức khi bạn kế thừa một lớp lạ hoắc nào đó, thì bạn đã biết lớp đó chính là lớp Trừu tượng. Với việc khảo sát một số lớp Trừu tượng từ hệ thống như vậy thì chúng ta cũng đã kết thúc bài học hôm nay.

Interface

Vâng, hôm nay chúng ta sẽ nói về interface. Nhưng bạn hãy dừng việc tưởng tượng rằng chúng ta đang nói đến việc làm sao xây dựng một giao diện cho ứng dụng Java. Tuy interface có nghĩa là giao diện, nhưng giao diện mà bạn đang nghĩ tới thì người ta gọi là UI cơ.

Vậy interface là gì nếu nó không phải là giao diện? Mời bạn đọc tiếp bài học hôm nay. Trước hết, để có thể hiểu bài viết về interface này, bạn nhất định phải đọc và hiểu khái niệm trừu tượng (abstraction) là gì trước.

Vì kiến thức về interface khá nhiều, mình cũng không chắc có thể nói hết tất cả các vấn đề của interface vào một bài viết hay không. Mình sẽ cố gắng trình bày nhiều nhất và rõ ràng nhất có thể, để bạn nắm được khái niệm và cách khai báo, sau này khi bạn bước qua lập trình ứng dụng Android, bạn sẽ hiểu rõ hơn về công dụng thực tế của interface.

Interface Là Gì?

Như mình có nói, interface không thể hiểu là giao diện được, đó là nghĩa tiếng Việt, còn với tiếng Anh thì  interface cũng không phải là UI.

Interface thực ra là cái gì đó ở giữa hệ thống và các đối tượng bên ngoài hệ thống đó. Nó định nghĩa ra các hành động, để giúp kết nối giữa các đối tượng bên ngoài vào hệ thống. Bạn cứ tưởng tượng cái ti vi nhà bạn là một hệ thống, thì các nút nhấn của ti vi chính là interface mà chúng ta đang nói tới. Các nút này định nghĩa sẵn các hành động tác động đến hệ thống nếu người dùng nhấn vào các nút trên đó, như hành động tắt, mở, chuyển kênh,… Hệ thống tivi sẽ phải làm việc cật lực (chứ không phải interfaceinterface chỉ định nghĩa một “giao diện” tương tác thôi) để đáp ứng lại các hành động tương tác từ người dùng.

Quay lại OOP, giả sử nếu bạn có một hệ thống tính lương nhân viên hoàn chỉnh. Thì các tổ chức định nghĩa sẵn các hành động, giúp cho người quản trị có thể tương tác với hệ thống, hoặc giúp cho hệ thống tương tác với nhau. Các tổ chức đó chính là các interface.

Vậy đó, tổng hợp lại bạn có thể hiểu rằng interface chính là nơi nhận tương tác từ một đối tượng đến các đối tượng đang lắng nghe sự tương tác đó. Ngoài ra interface còn giúp định nghĩa sẵn các hành động, để các đối tượng bên ngoài biết đường tương tác, và các đối tượng bên trong hệ thống sẵn sàng đáp ứng các tương tác đó thông qua việc hiện thực sẵn các hành động đã được định nghĩa đó.

Đến đây thì bạn đã hườm hườm hiểu nghĩa và công dụng của interface rồi ha. Chúng ta sẽ từng bước đi sâu vào interface ở các mục bên dưới.

Khai Báo Interface Như Thế Nào?

Chưa cần lắm sự hiểu biết tường tận interface. Mình mời bạn xem qua cú pháp để khai báo một interface trước.

interface  tên_interface {
	các_thuộc_tính;
	các_phương_thức;
}

Mời bạn so sánh giữa cú pháp khai báo một interface của bài hôm nay với cú pháp khai báo một lớp ở bài trước đó này đây, để dễ dàng cho cả hai khi mình nói đến ý sau đây.

Qua cú pháp trên, bạn có thể thấy interface hơi giống với lớp bình thường mà bạn biết. Bạn xem, chỉ khác với lớp ở chỗ, khi khai báo một lớp thì bạn dùng từ khóa class, còn khi khai báo interface thì bạn dùng từ khóa interface.

Một lát nữa đây khi tiến hành khai báo cụ thể một interface, bạn còn bắt gặp interface rất giống với một lớp trừu tượng (abstract class) ở chỗ, nó không thể được dùng để tạo ra các đối tượng như những lớp bình thường khác. Nhiều tài liệu còn gọi interface là trừu tượng trăm phần trăm (absolute abstraction), bởi vì interface chỉ chứa các phương thức trừu tượng (chính là các phương thức không có thân hàm), trong khi lớp trừu tượng có thể chứa các phương thức trừu tượng lẫn các phương thức không trừu tượng bên trong nó, bạn có thể xem lại ý này qua bài học hôm trước nhé.

Đến đây thì bạn đã hiểu rõ hơn những khái niệm của interface mà mình cố gắng diễn đạt trên kia.

– Nói interface là một nơi chứa các hành động để nhận sự tương tác. Vâng, chính các phương thức trừu tượng của nó chính là các hành động.
– Nói interface giúp các đối tượng bên ngoài tương tác với hệ thống. Vì interface định nghĩa sẵn các phương thức trừu tượng, nó buộc các đối tượng bên trong hệ thống khi triển khai các interface này, buộc phải hiện thực các phương thức (hay cũng có thể hiểu là các hành động). Chính việc ràng buộc này sẽ rất dễ cho các đối tượng bên ngoài hiểu và tương tác vào.

Nếu bạn vẫn chưa hiểu lắm, thì hãy cùng mình khai báo một interface như thế nào nhé.

Thực Hành Khai Báo Một Interface

Chúng ta cùng nhau quay lại với project về tính chu vi, diện tích, thể tích của hình học mà bạn đã làm quen từ bài học số 21. Nếu như ở bài học đó, bạn xây dựng lớp HinhHoc là lớp cha nhất, lớp này chứa đựng các thuộc tính và các phương thức dùng chung cho các lớp con của nó. Bạn có thể nâng cấp lớp HinhHoc này thành một lớp trừu tượng, để có nhiều hơn ràng buộc phải hiện thực các phương thức trừu tượng cho lớp con. Nhưng với ví dụ này đây, mình xem HinhHoc là một interface, để có một ràng buộc trừu tượng trọn vẹn. Bạn xem khai báo một interface như sau.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package shapes;
 
interface HinhHoc {
     
    float PI = 3.14f;
     
    float tinhChuVi();
     
    float tinhDienTich();
     
    float tinhTheTich();
     
    void xuatThongTin();
}

Các Đặc Tính Của Interface

Nào, trước khi sử dụng interface HinhHoc mà bạn vừa khai báo ở ví dụ trên, chúng ta cùng nhau điểm qua một vài đặc tính của interface.

– Đầu tiên, như bạn thấy ở cú pháp và ví dụ trên, để khai báo một interface, bạn phải dùng từ khóa interface thay vì từ khóa class như khi dùng với các lớp mà bạn biết.
– Tiếp theo, như mình có nói, interface không thể khai báo các đối tượng của nó, giống như lớp trừu tượng vậy. Không tin à, bạn thử xem.
– Và mình cũng đã nói, interface chính là lớp trừu tượng trăm phần trăm. Vì các phương thức bên trong interface phải đều là các phương thức trừu tượng, dù cho bạn không hề khai báo bất kỳ từ khóa abstract nào cho các phương phương thức bên trong interface này cả, bạn có thể đọc thêm ý này bên dưới đây.
– Interface không hề có constructor. Đặc tính này khác với lớp trừu tượng, lớp trừu tượng cho phép có constructor, mặc dù chúng ta không thể tạo ra đối tượng từ constructor này của lớp trừu tượng, nhưng constructor của nó vẫn được dùng để khởi tạo các giá trị khi các lớp con của nó gọi đến. Bạn sẽ thấy điều này ở bài thực hành dưới đây.
– Mặc định thì các thuộc tính bên trong interface sẽ là publicstatic và final. Đó là lý do vì sao thuộc tính PI ở khai báo trên kia không cần dùng đến các từ khóa này.
– Mặc định thì các phương thức bên trong interface sẽ là abstract và public. Do đó sẽ không có phương thức nào trong interface được phép có thân hàm. Bạn cũng có thể kiểm chứng lại khai báo ở bài thực hành trên, tuy bạn không khai báo chúng là trừu tượng, nhưng mặc định chúng là vậy, nếu bạn thử khai báo thân hàm cho chúng, bạn sẽ nhận được thông báo lỗi đấy.
– Để khai báo một lớp “con” của interface, chính là lớp sẽ hiện thực (implement) các phương thức trừu tượng của nó, chúng ta dùng từ khóa implements thay vì extends như bạn đã biết. Bạn sẽ hiểu rõ hơn ý này khi xem bài thực hành dưới đây.

Thực Hành Hiện Thực Các Lớp Con Của Interface

Nói là lớp con của interface cũng không hẳn là đúng, chính xác nhất chúng ta nên nói đây là các lớp triển khai của interface thì hay hơn. Dưới đây là một vài lớp triển khai từ interface HinhHoc trên kia.

Trước hết, chúng ta sẽ xây dựng lớp HinhTron. Để HinhTron là lớp triển khai của HinhHoc, như đã nói, chúng ta sẽ dùng từ khóa implements thay cho extends. Và cũng như bạn đã từng thực hành với lớp trừu tượng, khi bạn triển khai từ một interface nào đó, sẽ có một báo lỗi bằng một icon hình bóng đèn bên cạnh dấu chéo màu đỏ như thế này.

Hiện thực một interfacehttps://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2017/10/Screen-Shot-2017-10-13-at-12.54.13.png?resize=300%2C134&ssl=1 300w, https://i0.wp.com/yellowcodebooks.com/wp-content/uploads/2017/10/Screen-Shot-2017-10-13-at-12.54.13.png?resize=768%2C344&ssl=1 768w" data-lazy-loaded="1" sizes="(max-width: 502px) 100vw, 502px" loading="eager" style="box-sizing: border-box; height: auto; max-width: 100%; border-style: none; margin: 0px auto; clear: both; text-align: center;">

Và cũng tương tự như bài thực hành trước, bạn hoàn toàn dễ dàng nhờ hệ thống tự tạo ra các phương thức override lại các phương thức trừu tượng từ HinhHoc giúp bạn.

Interface - Hoàn thiện các phương thức từ interface

Việc của bạn là hoàn chỉnh lớp HinhTron dựa trên các phương thức được override “tạm bợ” bởi hệ thống qua bước trê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
29
30
31
32
33
34
35
36
37
package shapes;
 
public class HinhTron implements HinhHoc {
     
    protected String ten;
    protected float banKinh;
     
    // Constructor
    public HinhTron(float banKinh) {
        this.ten = "Hình Tròn";
        this.banKinh = banKinh;
    }
 
    @Override
    public float tinhChuVi() {
        return 2 * PI * banKinh;
    }
 
    @Override
    public float tinhDienTich() {
        return PI * banKinh * banKinh;
    }
 
    @Override
    public float tinhTheTich() {
        // Do HinhTron không có tính thể tích, nên phương thức này chỉ override từ HinhHoc mà không làm gì cả
        return 0;
    }
 
    @Override
    public void xuatThongTin() {
        System.out.println(ten);
        System.out.println("Chu vi: " + tinhChuVi());
        System.out.println("Diện tích: " + tinhDienTich());
    }
     
}

Đến đây, nếu bạn xây dựng thêm lớp HinhTru kế thừa từ HinhTron thì sao? Lúc bấy giờ sự ràng buộc từ các phương thức trừu tượng của ông nội HinhHoc với cháu HinhTru đã hết. Nếu có ràng buộc thì chỉ là đòi hỏi bạn phải khai báo một constructor cho HinhTru để khởi tạo contructor của cha nó mà thôi.

Interface - Hoàn thiện các phương thức từ interface

Nếu bạn vẫn muốn sự ràng buộc với các phương thức của HinhHoc? Ok, bạn có thể khai báo HinhTron là lớp trừu tượng. Lưu ý là việc thay đổi HinhTron thành lớp trừu tượng chỉ để bạn tham khảo thôi nhé, vì nếu làm vậy bạn sẽ mất cơ hội khởi tạo đối tượng cho nó đấy.

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
package shapes;
 
public abstract class HinhTron implements HinhHoc {
     
    protected String ten;
    protected float banKinh;
     
    // Constructor
    public HinhTron(float banKinh) {
        this.ten = "Hình Tròn";
        this.banKinh = banKinh;
    }
 
    @Override
    public float tinhChuVi() {
        return 2 * PI * banKinh;
    }
 
    @Override
    public float tinhDienTich() {
        return PI * banKinh * banKinh;
    }
 
    @Override
    public abstract float tinhTheTich();
 
    @Override
    public void xuatThongTin() {
        System.out.println(ten);
        System.out.println("Chu vi: " + tinhChuVi());
        System.out.println("Diện tích: " + tinhDienTich());
    }
     
}

Có một điều hay ho rằng, với code trên của lớp trừu tượng HinhTron, bạn hoàn toàn có thể xóa bỏ khai báo phương thức tinhTheTich() đi. Thật vậy, khi mà HinhTron đã trở thành một lớp trừu tượng, thì nó không nhất thiết phải hiện thực tất cả các phương thức trừu tượng từ interface HinhHoc nữa, vì hệ thống biết rằng con của nó rồi sẽ phải hiện thực các phương thức trừu tượng còn lại này. Bạn thử xem nhé.

Vậy là với thay đổi từ code trên, HinhTru sẽ buộc phải hiện thực phương thức trừu tượng tinhTheTich(). Còn phương thức xuatThongTin() mình chủ động override lại để xuất thêm thông tin thể tích mà thôi.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package shapes;
 
public class HinhTru extends HinhTron {
     
    protected float chieuCao;
    protected float theTich;
 
    public HinhTru(float banKinh, float chieuCao) {
        super(banKinh);
        this.ten = "Hình Trụ";
        this.chieuCao = chieuCao;
    }
 
    @Override
    public float tinhTheTich() {
        return tinhDienTich() * chieuCao;
    }
     
    @Override
    public void xuatThongTin() {
        super.xuatThongTin();
        System.out.println("Thể tích: " + tinhTheTich());
    }
}

Các Đặc Tính Của Các Lớp Triển Khai Interface

Trên kia bạn đã biết các đặc tính của một interface, vậy còn với các lớp triển khai từ interface đó thì sao, chúng có những đặc trưng và đặc quyền gì.

– Bạn nên biết, một lớp có thể triển khai nhiều interface một lúc. Các interface được triển khai vẫn được khai báo sau từ khóa implements, nhưng cách nhau bởi dấu phẩy (,). Điều này khác với tính chất của kế thừa, khi mà một lớp chỉ được kế thừa đến một lớp cha mà thôi, nếu quên thì bạn xem lại bài 21 nhé. Ví dụ như, bạn có thể khai báo một lớp HinhTru triển khai từ nhiều interface như sau public class HinhTru extends HinhTron implements InterfaceA, InterfaceB.
– Như bài thực hành trên bạn có thể thấy nếu một lớp triển khai từ interface mà là lớp trừu tượng, thì lớp đó không còn buộc phải hiện thực các phương thức trừu tượng từ interface đó.

Mình biết kiến thức về interface khá rối. Nhưng bạn cũng nên biết tại sao phải có interface.

Tại Sao Phải Dùng Interface?

Mình hi vọng ở ý cuối cùng này sẽ giúp bạn hiểu hết về interface. Còn việc thực sự sử dụng interface như thế nào, thì mình khuyên bạn nên đọc nhiều code từ nhiều nguồn tài liệu khác, hoặc bắt đầu học lập trình ứng dụng Android, bạn sẽ biết rõ về interface hơn đấy.

– Đầu tiên bạn có thể thấy rằng, interface giống với lớp trừu tượng ở chỗ nó giúp bạn tạo ra ràng buộc đối với các đối tượng bên trong hệ thống. Và nếu bạn cần một ràng buộc toàn vẹn, thì interface là một giải pháp tốt.
– Bạn có thể tận dụng tính chất một lớp có thể triển khai nhiều interface trên kia để mở rộng hơn cho một số mục đích kế thừa.
– Và cũng với ý mà mình nói từ đầu bài học, interface là một giao diện nhận tương tác từ bên ngoài, nên nếu bạn có xây dựng UI cho ứng dụng, hoặc bạn xây dựng các ứng dụng Android bằng Java như mình có nhắc đến, thì interface sẽ được tận dụng để xây dựng các sự kiện tương tác giữa người dùng và hệ thống, như sự kiện nhấn lên Button, sự kiện nhấn chọn một item list,..

Chúng ta vừa xem xong kiến thức về interface. Bạn có thấy kiến thức này khó hiểu không? Nếu có bất cứ thắng mắc gì thì để lại bình luận bên dưới bài học hôm nay cho mình nhé.

Lớp Lồng

Đọc đến tiêu đề chắc hẳn bạn đã biết nội dung mà bài học hướng đến rồi. Đó chính là kiến thức về việc khai báo một lớp ở bên trong một lớp khác. Đây không phải là quan hệ cha-con gì nhé, do đó nó không phải là kế thừa, nó chỉ là một lớp được khai báo bên trong lớp khác mà thôi. Vậy chúng ta cùng nhau tìm hiểu xem việc lồng các lớp vào với nhau là gì và tại sao lại làm như vậy nhé.

Lớp Lồng Là Gì?

Mình chỉ nhắc lại ý trên kia thôi, lớp lồng chính là việc khai báo một lớp ở bên trong một lớp khác.

Lớp mà chứa các lớp khác bên trong nó người ta gọi là Outer Class, có thể hiểu theo tiếng Việt là lớp Bao.

Còn lớp ở bên trong Outer Class thì được chia ra làm hai loại khác nhau.

  • Một là lớp không-static (non-static class), người ta gọi loại này là Inner Class.
  • Loại còn lại là lớp static (static class), người ta gọi loại này là Static Nested Class.

Khoan hãy nói đến vai trò của từng loại Inner Class và Static Nested Class, chúng ta hãy đến với cú pháp khai báo của từng loại như sau.

Đây là cú pháp của một Inner Class.

class OuterClass {
     ...
     class InnerClass {
          ...
     }
}

Còn đây là cú pháp của một Static Nested Class.

class OuterClass {
     ...
     static  class  StaticNestedClass {
          ...
     }
}

Hai cú pháp không khác gì cả ngoài từ khóa static đúng không nào. Qua cú pháp trên, chúng ta có một số ý sau cần ghi nhớ.

  • Tuy cú pháp chỉ có một Outer Class chứa một Inner Class hoặc một Static Nested Class bên trong. Nhưng bạn nên biết rằng bên trong một Outer Class có thể chứa nhiều Inner Class, nhiều Static Nested Class, hoặc chứa nhiều cả Inner Class lẫn Static Nested Class.
  • Và tuy cú pháp chỉ nói đến lớp lồng, nhưng bạn có thể lồng interface vào trong lớp, hoặc lồng interface vào với nhau, hay lồng lớp vào trong interface đều được nhé.
  • Inner class lúc này được xem như một thành phần của Outer Class, chính vì vậy bạn có thể chỉ định cho nó các khả năng truy cập, như privatepublicprotected, hoặc default (tức là không có khai báo khả năng truy cập gì). Điều này khác với Outer Class hay các lớp mà bạn đã từng làm quen, đều chỉ có thể khai báo public hoặc default (khai báo này chỉ cho phép các lớp cùng trong một package mới có thể nhìn thấy nhau) mà thôi.

Thực Hành Khai Báo & Sử Dụng Inner Class

Bạn nên nhớ là ứng dụng của lớp lồng là khá nhiều, nhưng việc sử dụng chúng hay không là không bắt buộc, và nếu cần sử dụng thì cũng không khó lắm.

Bài thực hành này chúng ta hãy xem việc định nghĩa và sử dụng một Inner Class như thế nào. Xong bài thực hành này chúng ta sẽ nói cụ thể hơn về công dụng của lớp lồng sau nhé.

Code sau khai báo lớp ToaDo được đặt bên trong lớp HinhHoc. Bạn cũng có thể khai báo lớp ToaDo ở ngoài HinhHoc như đã thực hành ở bài nào đấy, nhưng việc để ToaDo vào trong HinhHoc, bạn thấy lớp này có thể sử dụng thuộc tính tenHinh của lớp bao.

public class HinhHoc {
 
public static final float PI = 3.14f;
 
public String tenHinh;
public ToaDo toaDo;
 
// Constructor
public HinhHoc(int x, int y) {
this.tenHinh = "Hình Học";
this.toaDo = new ToaDo();
this.toaDo.x = x;
this.toaDo.y = y;
}
 
public class ToaDo {
 
int x;
int y;
 
public void xuatThongTin() {
System.out.println("Hình: " + tenHinh);
System.out.println("Tọa độ: x = " + x + "; y = " + y);
}
}
}

Việc lớp lồng sử dụng được thuộc tính của lớp bao sẽ được mình nói rõ hơn ở mục bên dưới các bài thực hành.

Giờ thì bạn tiếp tục xem ở phương thức main() chúng ta khai báo lớp HinhHoc và gọi đến xuatThongTin() của ToaDo như sau, chuyện gì sẽ xảy ra?

public class MainClass {
 
public static void main(String[] args) {
HinhHoc hinhHoc = new HinhHoc(10, 20);
hinhHoc.toaDo.xuatThongTin();
}
 
}

Chúng ta sẽ nhận được thông tin sau.

Kết quả in ra console
Kết quả in ra console

Với code trên của phương thức main(), bạn có thể thấy chúng ta khai báo đối tượng của lớp HinhHoc, rồi dùng đến biến toaDo của nó, trên kia viết như này hinhHoc.toaDo.xuatThongTin(). Bạn cũng có thể khai báo đối tượng riêng của lớp ToaDo để dùng cho những lần sau, như sau.

public class MainClass {
 
public static void main(String[] args) {
HinhHoc hinhHoc = new HinhHoc(10, 20);
HinhHoc.ToaDo toaDo = hinhHoc.new ToaDo();
toaDo.xuatThongTin();
}
 
}

Bạn có thấy cách khai báo lớp ToaDo như code trên đây tuy lạ mà quen đúng không nào. Nhớ lại, ở các bài đầu tiên của OOP, khi mà mình chưa hướng dẫn các bạn có thể tạo nhiều lớp bên trong một project Java, mình đã phải dùng đến lớp lồng, bạn xem code ở bài này. Khi đó cũng có bạn nhanh trí nhìn ra vấn đề và hỏi, đến bây giờ mình mới có dịp được nhắc lại.

Gợi nhớ lại câu hỏi liên quan đến lớp lồng đã xuất hiện trước đó
Gợi nhớ lại câu hỏi liên quan đến lớp lồng đã xuất hiện trước đó

Bạn có thể hiểu lúc này ToaDo được xem như là một thành viên của HinhHoc. Chính vì vậy bạn chỉ có thể khởi tạo độc lập ToaDo thông qua đối tượng của HinhHoc là hinhHoc mà thôi.

Khi bạn thực thi dòng code trên kia của main(), bạn sẽ nhận được kết quả hơi khác chút như sau. Bạn có thể tự giải đáp tại sao kết quả lại khác như vậy không?

Kết quả của lần sửa cách dùng ToaDo
Kết quả của lần sửa cách dùng ToaDo

Thực Hành Khai Báo & Sử Dụng Static Nested Class

Mình cũng sẽ sử dụng kịch bản từ bài thực hành trên, nhưng thay lớp ToaDo bên trong HinhHoc bằng một Static Nested Class. Bạn xem có gì khác biệt không nhé.

public class HinhHoc {
 
public static final float PI = 3.14f;
 
public static String tenHinh;
public ToaDo toaDo;
 
// Constructor
public HinhHoc(int x, int y) {
this.tenHinh = "Hình Học";
this.toaDo = new ToaDo();
this.toaDo.x = x;
this.toaDo.y = y;
}
 
public static class ToaDo {
 
int x;
int y;
 
public void xuatThongTin() {
System.out.println("Hình: " + tenHinh);
System.out.println("Tọa độ: x = " + x + "; y = " + y);
}
}
}

Bạn có thể thấy, vì ToaDo được định nghĩa là một lớp static, nên nó chỉ có thể truy xuất đến các thuộc tính và phương thức static của lớp bao. Điều này giống với phương thức static mà bạn đã học, khi đó phương thức static cũng chỉ có thể gọi đến các thuộc tính được khai báo static mà thôi.

Với việc khai báo ToaDo như thế này, bạn vẫn gọi đến và sử dụng thông qua đối tượng bao hinhHoc một cách bình thường.

public class MainClass {
 
public static void main(String[] args) {
HinhHoc hinhHoc = new HinhHoc(10, 20);
hinhHoc.toaDo.xuatThongTin();
}
 
}

Hoặc bạn có thể khai báo độc lập trực tiếp ToaDo hơi khác với khi khai báo ToaDo là Inner Class như trên.

public class MainClass {
 
public static void main(String[] args) {
HinhHoc.ToaDo toaDo = new HinhHoc.ToaDo();
toaDo.xuatThongTin();
}
 
}

Bạn thử tự thực thi ứng dụng để xem console in ra gì nhé.

Khi Nào Nên Sử Dụng Lớp Lồng & Công Dụng Của Nó?

Dựa trên những gì chúng ta đã làm quen với lớp lồng trên đây, chúng ta đã có đủ trải nghiệm để tổng hợp lại vài công dụng của nó.

  • Nếu bạn có một lớp A chỉ dùng đến một lớp B nào đó. Tức là B không được ai dùng đến cả ngoại trừ A thôi. Thì bạn có thể xem xét tổ chức theo cách B sẽ là lớp lồng vào trong lớp A. Lợi ích của việc này thì bạn có thể xem các ý dưới đây.
  • Việc lồng các lớp vào nhau giúp tăng tính gói gém dữ liệu (encapsulation). Chẳng hạn với việc lớp B nằm trong lớp A, thì bạn có thể khai báo B là private, khi đó ngoài A ra không có bất kỳ lớp nào khác có thể biết đến sự tồn tại của B và các giá trị của nó, có thể nói rằng bạn đã “giấu” B khỏi thế giới, ngoại trừ A.
  • Ở chiều ngược lại. Lớp lồng B có thể truy cập đến tất cả các thành viên (thuộc tính và phương thức) của lớp bao A, ngay cả khi các thành viên của được khai báo là private.
  • Tất nhiên, khi lồng các lớp vào nhau như vậy, code của bạn sẽ dễ đọc hơn (vì không phải tìm và mở quá nhiều lớp), dẫn đến việc bảo trì cũng dễ dàng hơn.

Bài Tập Số 1

Chúng ta thử trắc nghiệm xem đã hiểu bài chưa thông qua bài tập này.

Giả sử có định nghĩa lớp bao và lớp lồng như sau.

public class OuterClass {
 
public void show() {
InnerClass innerClass = new InnerClass();
innerClass.show();
}
 
public class InnerClass {
 
public void show() {
System.out.println("Đây là inner class");
}
}
}

Bạn thử trả lời xem kết quả in ra console sẽ như thế nào với đoạn code ở phương thức main() như sau.

public class MainClass {
 
public static void main(String[] args) {
OuterClass outerClass = new OuterClass();
outerClass.show();
 
OuterClass.InnerClass innerClass = outerClass.new InnerClass();
innerClass.show();
}
 
}

Hãy tham khảo đáp án bên dưới để so sánh với câu trả lời của bạn.

Đây là inner class
Đây là inner class

Bài Tập Số 2

Tương tự với bài tập số 1. Giả sử mình có định nghĩa lớp bao và lớp lồng như sau.

public class NgayThangNam {
 
public int ngay, thang, nam;
 
public class GioPhutGiay {
 
public int gio, phut, giay;
 
public void xuatThongTin() {
System.out.println("Ngày: " + ngay + "/" + thang + "/" + nam);
System.out.println("Giờ: " + gio + ":" + phut + ":" + giay);
}
}
}

Và rồi ở phương thức main() mình gọi đến chúng như sau.

public class MainClass {
 
public static void main(String[] args) {
NgayThangNam date = new NgayThangNam();
date.ngay = 31;
date.thang = 10;
date.nam = 2017;
 
NgayThangNam.GioPhutGiay time = date.new GioPhutGiay();
time.gio = 10;
time.phut = 15;
time.giay = 30;
 
time.xuatThongTin();
}
 
}

Bạn thử trả lời kết quả in ra console là gì nhé. Và đây là đáp án.

Ngày: 31/10/2017
Giờ: 10:15:30

Lớp Vô Danh (Anonymous Class)

Bài học hôm nay chúng ta sẽ đến với một cách sử dụng lớp và đối tượng bên trong Java một cách thú vị hơn. Thông qua bài học bạn sẽ biết được rằng, không phải lúc nào cũng cứ phải khai báo một lớp rõ ràng rồi sử dụng nó thông qua các khai báo đối tượng từ lớp đó. Bạn có thể tạo ra đối tượng thoải mái từ một lớp mà chẳng ai biết là đối tượng đó thuộc lớp nào cả.

Sau bài học, bạn sẽ biết cách sử dụng lớp trong Java một cách linh động hơn, ngắn gọn hơn, và nếu nhìn quen mắt thì sẽ còn thấy dễ hiểu hơn đấy. Mời các bạn cùng đến với bài học hôm nay.

Lớp Vô Danh Là Gì?

Lớp Vô Danh, tiếng Anh gọi là Anonymous Class, hay nhiều khi được gọi với cái tên đầy đủ hơn là Anonymous Inner Class.

Tất nhiên một lớp được gọi là Vô Danh thì có nghĩa là nó sẽ không có cái tên cụ thể. Lớp Vô Danh sẽ gắn liền với kế thừa (bao gồm cả kế thừa từ một lớp cha bình thường hoặc lớp cha trừu tượng), và gắn liền với interface nữa. Và bởi vì lớp Vô Danh còn có cái tên Anonymous Inner Class, nên nó cũng có liên quan chút đỉnh đến kiến thức về lớp lồng đấy nhé.

Vậy chúng ta cùng nhau tìm hiểu thực sự lớp Vô Danh là gì mà lại dính liếu đến nhiều kiến thức đến như vậy.

Nhận Diện Lớp Vô Danh

Bạn có thể xem mục này đang nói đến cú pháp của một lớp Vô Danh. Nó sẽ giúp bạn nhận diện đâu là một lớp được khai báo bình thường, và đâu là một lớp Vô Danh. Và vì các lớp Vô Danh không có một điểm chung nhất định, nên chúng ta không nói đến cú pháp, mà chỉ nói đến sự nhận diện thôi.

Bạn đều biết, để tạo ra một lớp nào đó, bạn phải khai báo lớp đó với từ khóa class, hiển nhiên, sau đó, bạn phải đặt tên cho nó, như những gì bạn được học ở các bài đầu tiên về OOP vậy.

class tên_lớp {
     các_thuộc_tính;
     các_phương_thức;
}

Từ khai báo lớp trên, bạn sẽ tạo ra các đối tượng của lớp với từ khóa new sau này.

Còn với các lớp Vô Danh, bạn sẽ không cần phải khai báo một lớp nào, mà vẫn có thể tạo ra các đối tượng của nó. Nghe qua có vẻ mơ hồ, không có lớp thì đối tượng tạo ra sẽ dựa vào đâu.

Trước hết, mời bạn xem “cú pháp” sau của một lớp Vô Danh. Qua cách viết sau đây bạn sẽ tạo ra một đối tượng có tên nhanVien, vậy đối tượng này sẽ là đối tượng được tạo ra từ lớp nào, có phải là lớp NhanVien?

NhanVien nhanVien = new NhanVien() {
	các_thuộc_tính;
	các_phương_thức;
}

Mình xin nói trước rằng, lớp NhanVien như đoạn code trên không phải là một lớp Vô Danh, lớp NhanVien sẽ được bạn khai báo một cách bình thường ở đâu đó. Lớp Vô Danh với ví dụ trên chính là cái lớp nào đấy được định nghĩa bằng khối lệnh với các_thuộc_tính và các_phương_thức, lớp Vô Danh này chính là lớp con của lớp NhanVien, rồi lớp Vô Danh này được gán vào cho đối tượng nhanVien. Sau này bạn có thể sử dụng thoải mái đối tượng nhanVien này.

Chốt mọi thứ lại, nếu hỏi rằng nhanVien được tạo ra từ lớp nào, thì câu trả lời chỉ có thể là Vô Danh.

Nếu bạn còn chưa hiểu rõ lắm về khái niệm Vô Danh, thì có thể đọc tiếp bên dưới, mình sẽ trình bày cụ thể hơn nhé.

Khi Nào Nên Sử Dụng Lớp Vô Danh?

Như các ý trên đây, mình xin tổng hợp lại. Lớp Vô Danh thường được dùng khi bạn không muốn phải khai báo cụ thể lớp con của một lớp nào đó (bao gồm cả lớp trừu tượng và lớp bình thường mà bạn biết), kể cả khi bạn không muốn khai báo cụ thể lớp triển khai của một interface nào đó, mà vẫn muốn sử dụng các đối tượng của chúng.

Nói vòng vo cũng chỉ bấy nhiêu ý trên thôi. Sau đây mình xin đưa ra một ví dụ về sử dụng lớp Vô Danh để bạn dễ hiểu hơn.

Thực Hành Xây Dựng Lớp Vô Danh

Bài thực hành này giúp bạn hiểu rõ thế nào là một lớp Vô Danh, thông qua một interface có sẵn có tên HinhHoc.

Mình xin phép lấy lại bài thực hành khai báo một interface mà bạn biết để tạo ra một interface với cái tên HinhHoc trước, code này chưa liên quan gì đến lớp Vô Danh cả nhé.

1
2
3
4
5
6
7
8
9
10
11
12
interface HinhHoc {
      
    float PI = 3.14f;
 
    void nhapBanKinh(float banKinh);
     
    float tinhChuVi();
     
    float tinhDienTich();
      
    void xuatThongTin();
}

Với interface HinhHoc trên đây, nếu sử dụng theo cách thông thường, bạn có thể khai báo một lớp “Hữu Danh” có tên HinhTron. Lớp HinhTron này triển khai các phương thức trừu tượng từ interface HinhHoc 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
24
25
26
27
28
29
30
31
32
33
34
public class HinhTron implements HinhHoc {
      
    protected String ten;
    protected float banKinh;
      
    // Constructor
    public HinhTron(float banKinh) {
        this.ten = "Hình Tròn";
        this.banKinh = banKinh;
    }
 
    @Override
    public void nhapBanKinh(float banKinh) {
        this.banKinh = banKinh;
    }
  
    @Override
    public float tinhChuVi() {
        return 2 * PI * banKinh;
    }
     
    @Override
    public float tinhDienTich() {
        return PI * banKinh * banKinh;
    }
  
    @Override
    public void xuatThongTin() {
        System.out.println(ten);
        System.out.println("Chu vi: " + tinhChuVi());
        System.out.println("Diện tích: " + tinhDienTich());
    }
      
}

Tiếp tục, nếu muốn sử dụng lớp HinhTron trên đây, thì chúng ta sẽ khai báo đối tượng và gọi đến như bình thường.

1
2
3
4
public static void main(String[] args) {
    HinhTron hinhTron = new HinhTron(10);
    hinhTron.xuatThongTin();
}

Việc bạn khai báo lớp có tên là HinhTron, thì coi như bạn đã khai báo một lớp có tên hẳn hoi rồi.

Giờ chúng ta đến với cách sử dụng lớp Vô Danh. Đầu tiên, bạn chẳng cần phải khai báo lớp HinhTron nào cả, nhưng bạn vẫn cần interface HinhHoc để làm lớp cha cơ bản. Như vậy, bạn hãy quên đi câu lệnh khai báo lớp HinhTron trên kia, mà hãy sử dụng ngay và luôn đối tượng được khởi tạo ở code sau.

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
public static void main(String[] args) {
 
    HinhHoc hinhGiDo = new HinhHoc() {
             
        protected float banKinh;
             
        @Override
        public void nhapBanKinh(float banKinh) {
            this.banKinh = banKinh;
        }
             
        @Override
        public float tinhChuVi() {
            return 2 * PI * banKinh;
        }
             
        @Override
        public float tinhDienTich() {
            return PI * banKinh * banKinh;
        }
             
        @Override
        public void xuatThongTin() {
            System.out.println("Hình Vô Danh");
            System.out.println("Chu vi: " + tinhChuVi());
            System.out.println("Diện tích: " + tinhDienTich());
        }
    };
         
    hinhGiDo.nhapBanKinh(10);
    hinhGiDo.xuatThongTin();
}

Với code trên đây, bạn phải chú ý nhìn kỹ, hiệu ứng phụ của code sẽ làm hoa mắt với những ai chưa nhìn quen.

Bạn xem, với khai báo HinhHoc hinhGiDo = new HinhHoc{…}, khai báo này rõ ràng sẽ tạo ra một đối tượng hinhGiDo, nhưng bạn đã biết hinhGiDo không phải được tạo ra từ HinhHoc đúng không nào. Rất dễ để giải thích, vì HinhHoc là interface, mà bạn đã biết một interface không thể dùng để tạo ra một đối tượng!!! Vậy hinhGiDo là một đối tượng được tạo ra từ một lớp Vô Danh, mà lớp Vô Danh này đã triển khai đầy đủ interface HinhHoc thông qua khối lệnh của nó. Các dòng code sau đó của phương thức trên là các sử dụng đối tượng hinhGiDo để nhập thông tin và xuất thông tin ra console để cho ra kết quả tương tự như khi không sử dụng lớp Vô Danh trên kia. Bạn thử thực thi nhé.

Phân Loại Lớp Vô Danh

Bạn đã làm quen và đã hiểu rõ về lớp Vô Danh rồi đúng không nào. Để có thể dễ dàng phát hiện và áp dụng lớp Vô Danh vào thực tế, mình xin tổng hợp phân loại của lớp Vô Danh. Một số thông tin của mục này có thể sẽ được bắt gặp ở các bài học sau, hoặc ở các bài học Android, bạn có thể xem trước, sau này khi sử dụng lại thông tin của lớp Vô Danh, mình sẽ nhắc lại cho bạn nhớ.

Lớp Vô Danh Được Tạo Ra Thông Qua Kế Thừa Từ Lớp Khác

Nếu bạn có một lớp nào đó, dù cho đó là lớp thường hay lớp trừu tượng. Thì thay vì khai báo một lớp con của nó với tên tuổi hẳn hoi, bạn có thể khai báo một lớp con Vô Danh. Như sau này bạn cần phải kế thừa lớp Thread từ hệ thống như sau.

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
    // Ví dụ về lớp Vô Danh được tạo ra
    // thông qua kế thừa từ lớp Thread
    Thread t = new Thread() {
 
        @Override
        public void run() {
            System.out.println("Bạn có thể thử nghiệm thực thi code này");
        }
    };
    t.start();
}

Ví dụ này chắc không khó với bạn. Đối tượng t mà bạn khai báo không phải là đối tượng của lớp Thread, mà chỉ là đối tượng của lớp Vô Danh kế thừa từ Thread mà thôi.

Lớp Vô Danh Được Tạo Ra Thông Qua Triển Khai Từ Interface Khác

Mục này tương tự như ví dụ của bài học trên kia. Khi mà bạn có một interface, thay vì khai báo một triển khai rõ ràng từ interface này, bạn có thể áp dụng lớp Vô Danh.

Ví dụ sau đây sẽ triển khai từ interface có tên Runnable. Bạn cũng sẽ được làm quen với code này ở bài học về đa luồng (multi thread) sau.

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
    // Ví dụ về lớp Vô Danh được tạo ra
    // thông qua triển khai từ Interface Runnable
    Runnable r = new Runnable() {
             
        @Override
        public void run() {
            System.out.println("Bạn có thể thử nghiệm thực thi code này");
        }
    };
    Thread t = new Thread(r);
    t.start();
}

Lớp Vô Danh Được Dùng Như Một Tham Số Truyền Vào

Đây là phần mở rộng hơn so với hai cách trên, nếu bạn nào đang lập trình Android, sẽ thấy ứng dụng rất nhiều.

Ví dụ dưới đây cho thấy thay vì truyền r vào phương thức khởi tạo của Thread(), bạn có thể tạo lớp Vô Danh như một tham số thay cho khai báo r.

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
    // Ví dụ về lớp Vô Danh được tạo ra
    // như một tham số truyền vào
    Thread t = new Thread(new Runnable() {
             
        @Override
        public void run() {
            System.out.println("Bạn có thể thử nghiệm thực thi code này");
        }
    });
    t.start();
}

Dưới đây là hình ảnh bổ sung thêm cho ví dụ về việc sử dụng lớp Vô Danh như một tham số truyền vào của dòng code xây dựng ứng dụng TourNote bên các bài học Android. Bạn tham khảo tính hữu dụng của nó nhé.

Screen Shot 2017-12-25 at 14.22.34

Các Đặc Điểm Của Lớp Vô Danh

Dưới đây chúng ta sẽ tổng hợp lại một số đặc điểm đáng nhớ của lớp Vô Danh. Và vì lớp Vô Danh sẽ được sử dụng khá nhiều do tính tiện dụng của nó, nên bạn cũng cố gắng ghi nhớ các đặc điểm này nhé.

– Nếu một lớp bình thường có thể triển khai bao nhiêu interface cũng được, thì lớp Vô Danh chỉ có thể triển khai từ duy nhất một interface mà thôi.
– Nếu một lớp bình thường vừa có thể kế thừa từ một lớp nào đó, vừa có thể triển khai từ nhiều interface khác, thì lớp Vô Danh chỉ có thể hoặc là kế thừa hoặc là triển khai một lớp hay một interface khác thôi.
– Với một lớp bình thường, bạn có thể định nghĩa các constructor tùy thích. Nhưng lớp Vô Danh lại không hề có bất kỳ constructor nào. Điều này cũng dễ hiểu, vì constructor buộc phải có tên trùng với tên lớp, mà lớp Vô Danh thì không có tên, nên chẳng bao giờ bạn có thể định nghĩa được constructor cho nó.
– Và vì một lớp Vô Danh được khai báo bên trong lớp khác, nên nó có một đặc điểm giống với lớp Lồng, ở chỗ nó có thể truy cập đến các thành viên của lớp bao của nó.

Trên đây là những kiến thức liên quan đến lớp Vô Danh. Hi vọng đọc xong bài viết này, nhiều bạn sẽ ồ lên rằng thì ra các cách sử dụng các lớp dạng này từ trước đến giờ được gọi với một cái tên như vậy. Vì mình cũng đã từng ồ lên như vậy rồi mà.

Lớp Wrapper

ài học hôm nay đưa chúng ta ngược về quá khứ, để bổ sung thêm kiến thức còn khá “mộc mạc” ở một bài học khi đó. Quá khứ ở đây là lúc mà chúng ta còn đang làm quen với các kiểu dữ liệu nguyên thủy của Java. Lúc bấy giờ bạn đã biết Java có 8 kiểu nguyên thủy, chúng bao gồm intshortlongbytefloatdoublechar và boolean. Bạn có thể xem lại toàn bộ bài học số 4 để biết rõ thông tin về các kiểu dữ liệu này.

Hôm nay bạn sẽ được làm quen với các lớp có tên là Wrapper. Để xem các lớp này giúp bổ sung gì cho các kiểu dữ liệu nguyên thủy vừa được liệt kê trên đây nhé.

Làm Quen Với Lớp Wrapper

Lớp Wrapper thực chất chỉ là một cái tên chung cho rất nhiều lớp khác nhau. Vì tất cả các lớp thuộc bài hôm nay có cùng một công năng, nên mới gọi chung một cái tên Wrapper như vậy.

Và bởi như mình có nói, Java có 8 kiểu dữ liệu nguyên thủy, nên cũng sẽ có 8 lớp Wrapper cho từng kiểu nguyên thủy này. Chúng bao gồm.

– Lớp Byte là lớp Wrapper cho kiểu dữ liệu byte.
– Lớp Short là lớp Wrapper cho kiểu dữ liệu short.
– Lớp Integer là lớp Wrapper cho kiểu dữ liệu int.
– Lớp Long là lớp Wrapper cho kiểu dữ liệu long.
– Lớp Float là lớp Wrapper cho kiểu dữ liệu float.
– Lớp Double là lớp Wrapper cho kiểu dữ liệu double.
– Lớp Character là lớp Wrapper cho kiểu dữ liệu char.
– Lớp Boolean là lớp Wrapper cho kiểu dữ liệu boolean.

Như vậy bạn có thể thấy, mỗi lớp Wrapper cho mỗi kiểu dữ liệu nguyên thủy sẽ là một đối tượng bao lấy kiểu nguyên thủy mà nó hỗ trợ. Hay nói cách khác, mỗi lớp Wrapper sẽ chứa một kiểu nguyên thủy bên trong nó. Và, lớp Wrapper giúp xây dựng ra những phương thức khác nhằm bổ sung thêm cho công năng đơn giản ban đầu của kiểu nguyên thủy.

Có một điều bạn có thể ghi nhớ là, giống như với kiến thức về Chuỗi mà bạn đã biết, các lớp Wrapper cũng là các lớp có giá trị không thể thay đổi được, tiếng Anh gọi là Immutable. Bạn có thể xem thêm một tí kiến thức về Immutable ở bài này. Thêm nữa, các lớp Wrapper là các lớp final, và vì vậy bạn không thể nào tạo được lớp con của chúng.

Tại Sao Phải Dùng Đến Lớp Wrapper?

Trước tiên, điều cơ bản nhất cho chuyện này là, các lớp Wrapper sẽ giúp chúng ta chuyển đổi qua lại giữa một kiểu dữ liệu nguyên thủy sang kiểu dữ liệu đối tượng và ngược lại.

Bạn có thể xem ví dụ cho việc sử dụng kiểu dữ liệu nguyên thủy và kiểu Wrapper của nó như sau.

1
2
int a = 20; // a là biến có kiểu dữ liệu int nguyên thủy
Integer i = Integer.valueOf(a); // i là biến có kiểu dữ liệu đối tượng Integer, được tạo ra từ biến nguyên thủy a

Bạn có thể thấy, biến a là kiểu int, còn biến i là kiểu Integer.

Ý tiếp theo cho câu hỏi tại sao này là, nếu với các kiểu dữ liệu nguyên thủy, bạn chỉ có một chọn lựa là tạo ra biến rồi sử dụng giá trị của nó (nếu bạn không gán giá trị thì nó vẫn được tạo một giá trị mặc định, bạn có thể xem lại ở bài các kiểu dữ liệu nguyên thủy). Còn với các kiểu đối tượng, giá trị mặc định của nó là null, giá trị null này có thể được tận dụng trong một số trường hợp, như mang ý nghĩa chưa có giá trị chẳng hạn. Ngoài ra, các kiểu đối tượng còn mang theo nó nhiều phương thức hữu dụng, làm phong phú hơn tính ứng dụng của kiểu dữ liệu.

Thêm nữa, một số cấu trúc khác bên trong ngôn ngữ Java, như các cấu trúc về các danh sách mà chúng ta sẽ làm quen sau như ArrayList hay Vector đều chứa đựng các tập hợp kiểu dữ liệu đối tượng thay vì kiểu nguyên thủy, nên việc biết và vận dụng các lớp Wrapper là một bắt buộc.

Ngoài ra thì kiểu dữ liệu đối tượng sẽ thích hợp hơn trong việc thực thi đa luồng (multithreading) và đồng bộ hóa (synchronization) mà chúng ta sẽ cùng thảo luận ở bài viết khác.

Chuyển Đổi Qua Lại Giữa Kiểu Nguyên Thủy Và Kiểu Wrapper

Chuyển Đổi Kiểu Nguyên Thủy Sang Kiểu Wrapper

Việc chuyển đổi một kiểu nguyên thủy sang kiểu Wrapper của nó người ta gọi là Boxing. Không phải mang ý nghĩa là môn đấm bốc đâu. Boxing ở đây mang ý nghĩa là đóng hộp, tức là đóng dữ liệu nguyên thủy vào trong cái hộp Wrapper của nó đấy. Như ví dụ mà bạn xem trên kia, khi một kiểu int a được chuyển thành kiểu Integer i.

Bạn có thể thực hiện việc boxing thông qua các phương thức khởi tạo của các lớp Wrapper.

1
2
3
4
5
6
7
8
// Các dạng Boxing
int a = 500;
Integer i = new Integer(a);
Integer j = new Integer(500);
Float f = new Float(4.5);
Double d = new Double(5);
Character ch = new Character('a');
Boolean b = new Boolean(true);

Hoặc có thể gán trực tiếp các giá trị nguyên thủy vào cho các lớp Wrapper, cách này người ta còn gọi là Autoboxing, có nghĩa là hệ thống sẽ chuyển đổi một cách tự động.

1
2
3
4
5
6
7
8
9
10
11
12
// Các dạng Autoboxing
int a = 500;
Integer i = a;
Integer j = 500;
Float f = 4.5f;
Double d = 5d;
Character ch = 'a';
Boolean b = true;
 
// Đây cũng là một dạng Autoboxing mà bạn sẽ được biết khi học đến bài về Collection
ArrayList<Integer> arrInt = new ArrayList<Integer>();
arrInt.add(25);

Chuyển Đổi Kiểu Wrapper Sang Kiểu Nguyên Thủy

Ngược lại với trên kia, khi bạn chuyển từ một kiểu Wrapper sang kiểu nguyên thủy của nó người ta gọi là Unboxing, có nghĩa là mở hộp, tức là mở cái hộp Wrapper để lấy dữ liệu nguyên thủy ra.

Bạn có thể thực hiện việc unboxing thông qua các phương thức xxxValue(). Với xxx là đại diện cho từng loại dữ liệu, như với ví dụ sau.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int a = 500;
Integer i = a; // Autoboxing
int i2 = i.intValue(); // Unboxing
         
Integer j = 500; // Autoboxing
int j2 = j.intValue(); // Unboxing
         
Float f = 4.5f; // Autoboxing
float f2 = f.floatValue(); // Unboxing
         
Double d = 5d; // Autoboxing
double d2 = d.doubleValue(); // Unboxing
         
Character ch = 'a'; // Autoboxing
char ch2 = ch.charValue(); // Unboxing
         
Boolean b = true; // Autoboxing
boolean b2 = b.booleanValue(); // Unboxing
         
ArrayList<Integer> arrInt = new ArrayList<Integer>();
arrInt.add(25); // Autoboxing
int arr0 = arrInt.get(0).intValue(); // Unboxing

Cũng tương tự như autoboxing, kỹ thuật unboxing cũng có thể được viết như thế này.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int a = 500;
Integer i = a;
int i2 = i; // Unboxing
         
Integer j = 500;
int j2 = j; // Unboxing
         
Float f = 4.5f;
float f2 = f; // Unboxing
         
Double d = 5d;
double d2 = d; // Unboxing
         
Character ch = 'a';
char ch2 = ch; // Unboxing
         
Boolean b = true;
boolean b2 = b; // Unboxing
         
ArrayList<Integer> arrInt = new ArrayList<Integer>();
arrInt.add(25);
int arr0 = arrInt.get(0); // Unboxing

Các Phương Thức Hữu Ích Của Lớp Wrapper

Như trên kia mình có nói là các lớp Wrapper giúp tạo thêm cho các kiểu dữ liệu nguyên thủy vốn chỉ có tác dụng chứa đựng dữ liệu, trở thành một đối tượng với nhiều phương thức hữu dụng hơn. Sau đây mình sẽ liệt kê các phương thức hữu dụng của chúng để bạn tham khảo nhé.

parseXxx()

Tham số truyền vào cho phương thức static này là một chuỗi, kết quả nhận được là một giá trị nguyên thủy tương ứng với chuỗi truyền vào.

1
2
3
4
5
6
7
int i = Integer.parseInt("10");
float f = Float.parseFloat("4.5");
boolean b = Boolean.parseBoolean("true");
         
System.out.println(i);
System.out.println(f);
System.out.println(b);

Chắc bạn cũng đoán được kết quả in ra console của dòng code trên đây rồi đúng không nào.

toString()

Khác với toString() của lớp ObjecttoString() lần này của các lớp Wrapper là phương thức static, nó có một giá trị truyền vào là kiểu dữ liệu nguyên thủy tương ứng với lớp Wrapper đó, và kết quả trả về là một chuỗi tương ứng với giá trị truyền vào. Ví dụ này thử nghiệm với lớp Integer, các lớp Wrapper khác cũng sẽ tương tự.

1
2
String sI = Integer.toString(10);
System.out.println(sI);

Kết quả in ra console là chuỗi “10”.

xxxValue()

Bạn đã làm quen với phương thức này ở trên kia. Cụ thể thì các phương thức dạng này giúp chuyển đổi một giá trị của lớp Wrapper nào đó về kiểu dữ liệu nguyên thủy (unboxing). Thỉnh thoảng phương thức này còn giúp chuyển đổi kiểu dữ liệu như khi bạn ép kiểu tường minh một giá trị vậy. Bạn có thể dễ hiểu hơn khi xem ví dụ sau.

1
2
3
4
5
6
7
Double d = 50.5;
int i = d.intValue();
byte b = d.byteValue();
         
System.out.println(d);
System.out.println(i);
System.out.println(b);

Kết quả in ra console lần lượt là 50.550 và 50.

compareTo()

Nếu bạn còn nhớ, ở bài học về chuỗi chúng ta cũng đã nói qua phương thức compareTo() dùng để so sánh các giá trị chuỗi với nhau. Vậy thì đối với các lớp Wrapper mà chúng ta làm quen hôm nay, thì công năng của nó vẫn được áp dụng trọn vẹn. Tức là phương thức này sẽ được dùng để so sánh hai giá trị của hai lớp Wrapper (có cùng kiểu dữ liệu) với nhau.

Cụ thể, với việc bạn gọi lopWrapper1.compareTo(lopWrapper2), thì kết quả sẽ như sau.

– Nếu kết quả của phương thức trả về một số âm, thì lopWrapper1 sẽ có giá trị nhỏ hơn lopWrapper2.
– Nếu kết quả của phương thức trả về số 0, thì lopWrapper1 sẽ có giá trị bằng với lopWrapper2.
– Nếu kết quả của phương thức trả về một số dương, thì lopWrapper1 sẽ có giá trị lớn hơn lopWrapper2.

Bạn có thể xem ví dụ sau để hiểu rõ hơn.

1
2
3
4
5
6
7
8
Integer i = 50;
Integer i1 = Integer.parseInt("50");
Integer i2 = Integer.valueOf(52);
Integer i3 = 30;
         
System.out.println("CompareTo i & i1: " + i.compareTo(i1));
System.out.println("CompareTo i & i2: " + i.compareTo(i2));
System.out.println("CompareTo i & i3: " + i.compareTo(i3));

Kết quả in ra console sẽ là.

1
2
3
CompareTo i & i1: 0
CompareTo i & i2: -1
CompareTo i & i3: 1

compare()

Phương thức này có công dụng và cách sử dụng giống giống với compareTo() trên kia. Khác ở chỗ đây là phương thức static của mỗi lớp Wrapper, nên bạn có thể gọi trực tiếp từ lớp. Đồng thời tham số truyền vào là hai giá trị của hai lớp Wrapper. Kết quả trả về của compare() cũng mang một trong ba giá trị (âm0dương) như với compareTo() trên đây.

1
2
3
4
5
6
7
8
9
Integer i1 = Integer.parseInt("50");
Integer i2 = Integer.valueOf(52);
         
System.out.println("Compare i1 & i2: " + Integer.compare(i1, i2));
         
Float f1 = new Float("20.25f");
Float f2 = new Float("2.43f");
         
System.out.println("Compare f1 & f2: " + Float.compare(f1,f2));

Kết quả in ra console là.

1
2
Compare i1 & i2: -1
Compare f1 & f2: 1

equals()

Tương tự như equals() khi so sánh chuỗi. Phương thức này sẽ so sánh các giá trị của các lớp Wrapper và trả về một kiểu boollean, với true là bằng nhau và false là khác nhau. Như ví dụ sau.

1
2
3
4
5
6
7
8
9
Integer i1 = Integer.parseInt("50");
Integer i2 = Integer.valueOf(50);
         
System.out.println("Compare i1 & i2: " + i1.equals(i2));
         
Float f1 = new Float("20.25f");
Float f2 = new Float("2.43f");
         
System.out.println("Compare f1 & f2: " + f1.equals(f2));

Kết quả in ra console là.

1
2
Compare i1 & i2: true
Compare f1 & f2: false

Bài Tập Số 1

Bạn hãy thử thực thi dòng code sau, và thử tìm hiểu xem tại sao dữ liệu in ra console lại như vậy nhé.

1
2
3
4
5
int i = Integer.parseInt("10");
float f = Float.parseFloat("4.5a");
         
System.out.println(i);
System.out.println(f);

Bài Tập Số 2

Bạn hãy cho biết kết quả in ra console của dòng code sau.

1
2
3
int i = Integer.parseInt("10.5");
         
System.out.println(i);

Bài Tập Số 3

Ngoài các phương thức hữu ích của các lớp Wrapper mà mình có liệt kê trên kia, bạn hãy tự tìm hiểu thêm các phương thức khác nữa nhé, chúng hữu ích không kém những gì mình nêu ra đâu đấy. Chẳng hạn như.

– Các phương thức static của các lớp IntegerLongFloat, và DoubletoHexString()toOctalString()toBinaryString()max()min(),…
– Các phương thức static của lớp CharacterisLowerCase()isUpperCase()isDigit()toLowerCase()toUpperCase(),…

Như vậy qua bài hôm nay, bạn đã biết được các kiểu dữ liệu nguyên thủy trong Java đã được ngôn ngữ này “bao bọc” bởi các lớp Wrapper, để làm cho chúng trở nên đa dạng và tiện dụng hơn như thế nào rồi đúng không.

Exception Tập 1 – Làm Quen Với Exception

Trước khi bắt đầu đi chi tiết vào bài học, mình muốn các bạn biết rằng, ở đời không ai là hoàn hảo cả. Khi con người ta đủ lớn khôn, họ sẽ bắt đầu mắc lỗi. Điều quan trọng trong cuộc sống này là, không phải lúc nào bạn cũng cứ tránh xảy ra lỗi (vì nói thẳng ra là chẳng ai muốn dây vào lỗi hết), mà là nên học cách như thế nào đó để khi mắc lỗi rồi thì sẽ khắc phục và vượt qua lỗi lầm như thế nào thôi.

Ôi sao bài học hôm nay nặng triết lý quá vậy. Thực ra mình dẫn dụ tí cho vui. Đại ý của bài học hôm nay cũng sẽ gần như triết lý trên vậy. Tức là chúng ta sẽ xem xét đến một khía cạnh LỖI. LỖI ở đây là LỖI xảy ra trong các đoạn code mà chúng ta viết ra, một cách nghiêm túc và chuyên sâu.

Quay lại triết lý một chút, rằng một khi mà ứng dụng của chúng ta đủ lớn (cả về số dòng code lẫn tính năng), thì chắc chắn khi đó chúng ta sẽ khó kiểm soát được tính ổn định của logic ứng dụng. Và đến một lúc nào đó sẽ có LỖI xảy ra, LỖI nhẹ sẽ khiến hệ thống tung ra một số thông báo lạ lẫm đến người dùng, làm họ hoang mang, lỗi nặng hơn nữa sẽ làm cho ứng dụng bị kết thúc một cách đột ngột. Và cũng như triết lý trên kia, bạn không mong muốn LỖI xảy ra, nhưng bạn nên hiểu và phân biệt được các dạng LỖI trong Java, để có thể cung cấp cho ứng dụng một kịch bản đủ mạnh để vượt qua được LỖI mà không bị kết thúc một cách đột ngột, chí ít là cũng có thể thông báo một nội dung rõ ràng về tình trạng LỖI, hơn là để họ loay hoay với cái chức năng mà ứng dụng của bạn đã mất khả năng kiểm soát.

LỖI mà chúng ta đang nói đến được gọi chung với cái tên Exception trong Java. Và vì tầm quan trọng của nó, nên vấn đề sẽ được nói trong nhiều phần khác nhau. Mời bạn cùng làm quen với tập đầu tiên trong series về Exception này nhé.

Khái Niệm Exception

Exception có thể dịch ra tiếng Việt là Biệt lệ hoặc Ngoại lệ.

Tại sao chúng ta đang nói đến Lỗi, mà lại không phải là Error? Thực ra Exception có bao gồm Error trong đó. Exception là một khái niệm dùng để chỉ hiện tượng khi mà logic của ứng dụng bị tác động (theo chiều hướng xấu đi) bởi một vấn đề nào đó (có thể là lỗi hoặc cũng có thể không). Khi Exception diễn ra, nó làm cho chương trình bị lệch khỏi luồng chuẩn mà chúng ta đã lập trình ra, dẫn đến việc ứng dụng không thể xử lý được những logic của nó, hoặc có thể bị ngưng đột ngột (tình trạng này có thể gọi là “chết”“đột tử”, hay “crash”).

Exception này diễn ra một cách không mong muốn. Và cho dù bạn đã chuẩn bị kỹ càng các tình huống, thì sự sai lệch luồng vẫn cứ mãi còn tiềm ẩn. Như một số ví dụ sau cho bạn thấy rõ hơn mối nguy hiểm thực sự đến từ đâu.

– Exception có thể xảy ra khi người dùng nhập dữ liệu sai lệch vào cho ứng dụng. Do vô tình hay cố ý. Như bài tập số 1 của bài hôm trước, nếu bạn muốn một số thực cho chương trình, mà người dùng lại nhập vào một ký tự!?!
– Hoặc khi ứng dụng tìm đọc một file nào đó mà lại không thể thấy được. Có thể file đó đã bị xóa trước đó rồi.
– Hay hệ thống bị hết bộ nhớ trong lúc ứng dụng đang chạy khiến cho ứng dụng không thể thực thi được các logic của nó.

Và còn nhiều tình huống khác nữa. Tóm lại như mình đã nói, Exception có thể được sinh ra từ lỗi nào đó, như lỗi code ẩu của lập trình viên, lỗi nhập liệu ẩu bởi người dùng. Và Exception còn được sinh ra từ các tình huống không phải là lỗi nữa, như hết bộ nhớ, như rớt kết nối mạng, như sự can thiệp vô tình bởi người dùng,…

Bạn đã hiểu tại sao lại gọi là Exception rồi đúng không nào. Để hiểu rõ hơn, chúng ta xem người ta phân loại Exception ra làm các loại nào nhé.

Phân Loại Exception

Để hiểu rõ và dễ dàng làm việc với Exception hơn, Java chia nó ra làm 3 loại như sau.

Checked Exception

Đây là những Exception không vượt qua được “cửa ải” của trình biên dịch. Tức là bạn sẽ nhận được các thông báo lỗi từ trình biên dịch khi mà nó phát hiện ra rằng bạn đang code các dòng code nào đó mà có khả năng xảy ra Exception. Và do bởi trình biên dịch đã xuất sắc phát hiện Exception cho bạn rồi nên mới có cái tên là Checked, tức là “đã kiểm duyệt”.

Nếu bạn muốn thử lòng trình biên dịch, cụ thể là Eclipse, thì hãy thử gõ các câu lệnh sau vào phương thức main nhé. Bạn chưa cần biết thực chất các dòng code sau nói lên điều gì đâu, nhưng bạn cứ thử gõ đi, đảm bảo import java.io.File và java.io.FileReader nhé.

Minh họa Checked Exception

Đấy, bạn đã thấy trình biên dịch phát hiện và báo lỗi. Nhưng khác với lỗi thông thường ở chỗ, nếu đó là lỗi, như bạn gõ nhầm System.out thành Ssytem.out chẳng hạn, thì bạn phải sửa lỗi ngay, còn với các Exception, chúng ta không sửa chữa gì cả, mà sẽ tìm cách “bắt” lỗi và bẻ luồng logic của ứng dụng theo một chiều hướng khác, mà ở bài sau chúng ta sẽ xem xét kỹ hơn.

Unchecked Exception

Exception này khá nguy hiểm, khi mà trình biên dịch không thể nào kiểm tra giúp bạn sự lai lệch luồng có thể xảy đến như trên kia. Và ứng dụng của bạn khi đến tay người dùng, khi người dùng đang thao tác thì… bùm! Ứng dụng “đột tử”.

Bởi vì Exception này xảy ra khi ứng dụng đang được thực thi, nên nó còn được gọi là Runtime Exception.

Thông thường khi ứng dụng bị chết đột ngột kiểu này, thì hệ thống vẫn có log cho chúng ta biết là lỗi như thế nào. Như mình cũng có nói đến log cho dạng này ở bên Android, được gọi là Stack Trace, bạn có thể tham khảo thêm.

Bạn có thể thử tạo ra Exception dạng này khi thử biên dịch đoạn code sau.

1
2
int num[] = {1, 2, 3, 4};
System.out.println(num[5]);

Đoạn code tưởng chừng như vô hại (vì không có báo lỗi gì khi code cả). Nhưng hệ thống sẽ không thể tìm thấy phần tử mảng thứ 5 nếu thực thi ứng dụng, vì mảng của chúng ta chỉ có khai báo 4 phần tử mà thôi. Và vì vậy ứng dụng bị chết khi không biết làm gì khác. Bạn có thể thử thực thi và xem log in ra console với dòng code trên nhé.

Error

Error ở đây cũng là Exception nhưng hơi khác với Exception một chút. Với hai loại Exception mà mình nói đến trên đây thực ra chưa phải là lỗi, bởi vì bạn không nên né tránh nó mà nên đối mặt và “bẻ” luồng ứng dụng sao cho khi gặp Exception này ứng dụng sẽ biết cách phản hồi đúng đắn. Bài sau chúng ta sẽ thực hành việc bẻ luồng này.

Còn Error thực sự là một điều không thể làm gì khác được. Nó vượt quá xử lý của chúng ta. Dù cho bạn biết trước có thể Error sẽ xảy ra. Khi ứng dụng bị Error, thường thì chúng ta chỉ có thể code lại chỗ đó cho nó khác đi rồi cập nhật một version mới cho ứng dụng chứ chẳng làm gì được. Chẳng hạn như khi bộ nhớ hệ thống đã bị cạn kiệt, hoặc khi bạn đang sử dụng một thư viện, và chẳng may một ngày nào đó một lớp hay một phương thức của thư viện đó không còn tìm thấy nữa.

Cây Phân Cấp Các Lớp Exception Trong Java

Bản thân các Exception được nói đến trên kia chính là các lớp trong Java. Khi ứng dụng bị sai lệch luồng, thì hệ thống cũng sẽ gán sự sai lệnh đó cụ thể cho một lớp Exception nào đó quản lý. Và bởi vì Exception được chia ra làm 3 loại như trên đây, nên bạn có thể xem sự phân cấp các lớp Exception này cũng chia ra làm 3 nhóm chính.

Cây phân cấp lớp Exception

Nếu chưa hiểu gì cả về Exception thì bạn cứ xem qua nhé. Bài học sau chúng ta sẽ bắt đầu “bắt” các Exception dựa vào các lớp bên trong cây phân cấp theo sơ đồ trên. Và chúng ta còn có thể tự tạo ra các Exception riêng theo yêu cầu của ứng dụng nữa đấy.

Theo như cây phân cấp trên, thì lớp cao nhất liên quan đến toàn bộ bài học hôm nay là lớp Throwable. Chúng ta sẽ làm quen với Throwable và các phương thức hữu dụng của lớp cha này ở bài học sau, khi mà chúng ta đã biết cách bắt Exception như thế nào.

Hai lớp con của Throwable chính là Exception và Error. Trong Exception có hai loại lớp con. Một loại là RuntimeException và các con của nó, chúng là các Unchecked Exception như mình có nói đến ở trên kia, các Exception ở nhánh này sẽ không được kiểm duyệt bởi trình biên dịch, và vì vậy nó tiềm ẩn nhiều rủi ro khi ứng dụng đang chạy ở ngoài thực tế. Các con còn lại của Exception ngoài RuntimeException mà mình vừa nói đến trên đây là các Exception khác, chúng đều là các Checked Exception, nếu bạn có sử dụng các code có “đụng chạm” đến các Exception ở nhánh này sẽ bị hệ thống báo lỗi, như ví dụ mà bạn đã thấy trên kia. Còn các Exception bên nhánh Error cũng sẽ không được báo lỗi bởi hệ thống, nó sẽ tìm ẩn nguy cơ ứng dụng bị chết khi đang chạy mà chúng ta không thể lường trước được, như ở mục trên mình có nói đến.

Bài Tập Số 1

Bạn hãy thử code lại các dòng code của Bài tập số 1 của bài trước, nhưng lần này hãy để ý kỹ Exception mà console thông báo.

1
2
3
4
5
int i = Integer.parseInt("10");
float f = Float.parseFloat("4.5a");
          
System.out.println(i);
System.out.println(f);

Làm quen với NumberFormatException

Bạn hãy thử tìm hiểu xem NumberFormatException mà console hiển thị thuộc loại Exception nào mà chúng ta đã nói đến: Checked ExceptionUnchecked Exception, hay Error?

Gợi ý: bạn có thể dùng tổ hợp Ctrl + click trái chuột (hoặc Command + click trái chuột với Mac) lên một lớp bất kỳ trong Eclipse để được dẫn đến khai báo của lớp đó trong hệ thống, bạn sẽ hiểu nhiều hơn về mối quan hệ của lớp đó với các lớp khác.

Bài Tập Số 2

Tương tự, bạn hãy thử code dòng sau và cho biết Exception của nó là gì, và Exception đó thuộc loại nào nhé.

1
2
int value = 10 / 0;
System.out.println(value);

Bài Tập Số 3

Bạn cũng hãy thử tìm hiểu xem code sau sẽ tạo ra Exception loại gì nhé.

1
2
String s = null;
System.out.println(s.length());

Exception Tập 2 – Bắt (Bẫy) Exception Thông Qua Try Catch

Ở bài hôm trước các bạn đã làm quen với khái niệm Exception, và thử tạo một vài Exception để cùng “xem chơi”. Tuy nhiên, cái chính mà Exception ra đời không phải để chúng ta chỉ đứng nhìn một cách “bất lực” như vậy đâu. Chúng ta hoàn toàn có thể đối mặt với Exception, bằng cách bắt lấy chúng, chặn chúng lại không để chương trình bị lỗi, và làm những gì chúng ta mong muốn, như bài trước mình có dùng từ “bẻ luồng”.

Như vậy, phần thứ hai của chuỗi bài về Exception hôm nay chúng ta nói đến các kỹ thuật để bắt các Exception này.

Bạn có thể xem lại kiến thức giới thiệu về Exception ở phần đầu tiên này.

Làm Quen Với Try Catch

Như phần đầu bài học có giới thiệu, hôm nay chúng ta tìm cách bắt Exception, hay một số tài liệu gọi là “bẫy lỗi”.

Và tất nhiên, làm gì cũng vậy, nếu muốn chúng ta làm một điều gì đó, phải cung cấp cho chúng ta công cụ để thực hiện. Công cụ bẫy được cái Exception mà chúng đa đang nói tới có cái tên Try Catch.

Try catch là một công cụ giúp bạn bao bọc lấy đoạn code có khả năng xảy ra Exception. Hoặc các đoạn code đã được báo lỗi bởi hệ thống (chính là các Checked Exception). Các đoạn code mình nói đến này sẽ được chúng ta bao trong khối try. Để rồi nếu quả thực cái Exception đó xảy ra trong khối try đó, thì hệ thống sẽ nhanh chóng “bẻ” luồng logic của ứng dụng, chuyển sang thực thi các đoạn code bên trong khối catch.

Dựa vào lý giải trên đây, mời bạn cùng xem qua cú pháp cho try catch như sau.

try {
	// Các dòng code có khả năng gây ra Exception
} catch (ExceptionClass e) {
	// Nếu thực sực Exception xảy ra, các dòng code này sẽ được gọi
}

Để dễ hiểu hơn, mời bạn cùng try catch thử cho một trường hợp có khả năng xảy ra lỗi mà bài học trước bạn đã biết thông qua bài thực hành sau.

Thực Hành Xây Dựng Try Catch

Chúng ta cùng xem loại đoạn code có khả năng gây ra Exception, bạn có thể xem lại chúng ở mục này. Còn đây mình sẽ viết chúng lại.

1
2
int num[] = {1, 2, 3, 4};
System.out.println(num[5]);

Bạn đã biết code đây rơi vào một Unchecked Exception, tức là trình biên dịch sẽ không nhận biết và kiểm tra giúp bạn liệu Exception có xảy ra hay không.

Nhưng nếu trình biên dịch không làm gì cả thì chúng ta cũng đừng đứng khoanh tay nhé. Để chắc chắn ứng dụng của chúng ta “khỏe mạnh”, bạn nên lường trước các tình huống có khả năng xảy ra Exception và bao các đoạn code “có khả năng gây bệnh” vào khối try catch.

Nếu ngoan cố thực thi ứng dụng, bạn sẽ nhận được một thông báo lỗi như thế này. Bạn hãy chú ý cái Exception nhé, chúng ta sẽ catch chúng lại ở bên dưới.

Try Catch - Thực hành hiển thị Exception

Nào, chúng ta hãy bao đoạn có khả năng gây ra lỗi bằng khối try catch như sau.

1
2
3
4
5
6
try {
    int num[] = {1, 2, 3, 4};
    System.out.println(num[5]);
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("Không thể in được vì không tìm thấy phần tử mảng như mong đợi.");
}

Khi này, nếu bạn thực thi lại ứng dụng, người dùng sẽ dễ hiểu hơn với kiểu thông báo lỗi mang “tính người” hơn như sau.

Thông báo lỗi tốt hơn thông qua try catch

Thực Hành Kiểm Chứng Cách Hoạt Động Của Try Catch

Với bài thực hành này bạn sẽ hiểu rõ hơn thực chất việc “bẫy” lỗi và việc thực hiện các dòng code catch xảy ra ngay như thế nào khi cái “bẫy” đã bẫy được lỗi nhé.

Bạn xem đoạn code với try catch đầy đủ như sau. Hãy thử đoán xem kết quả in ra console sẽ như thế nào.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try {
    System.out.println("Bài toán thực hiện phép chia:");
             
    int ketQua = 10 / 2;
    System.out.println("10 chia 2 bằng " + ketQua);
             
    ketQua = 10 / 1;
    System.out.println("10 chia 1 bằng " + ketQua);
             
    ketQua = 10 / 0;
    System.out.println("10 chia 0 bằng " + ketQua);
} catch (ArithmeticException e) {
    System.out.println("Có một phép chia không thực hiện được");
}

Và đây là kết quả.

Thực hành kiểm chứng cách hoạt động của try catch

Ồ. Dòng in ra consolde System.out.println(“10 chia 0 bằng ” + ketQua); đâu rồi nhỉ. Thực ra là, ngay khi hệ thống phát hiện có Exception trong khối try xảy ra, thì các dòng code bên dưới của khối try đó sẽ không được thực thi nữa mà nhường chỗ cho các khối lệnh trong catch thực thi kế tiếp. Như minh họa vui mắt một chút như sau.

Minh họa luồng thực thi khi có try catch

Một Số Quy Tắc Với Try Catch

Đến đây thì bạn đã biết cách bao lấy các dòng code có khả năng gây ra Exception bằng try catch như thế nào rồi. Tuy nhiên mình biết với các bạn mới bắt đầu làm quen với try catch sẽ có rất nhiều câu hỏi, mình xin liệt kê chúng dưới dạng các quy tắc sau.

Không Phải Lúc Nào Try Thì Luồng Cũng Bị Bẻ Vào Catch

Như bạn đã làm quen, không phải lúc nào các câu lệnh bên trong khối try cũng gây ra Exception, và vì vậy không phải lúc nào các câu lệnh bên trong khối catch cũng được thực thi đâu nhé, nếu logic trong try được thực thi an toàn. Như ví dụ ngay trên đây, nếu bạn không thực hiện việc chia 10 cho 0 thì sẽ không có câu thông báo “Có một phép chia không thực hiện được” được in ra màn hình.

Nhưng Nếu Bạn Định Nghĩa Try, Bạn Phải Định Nghĩa Catch

Tuy không phải lúc nào các đoạn code trong try cũng gây ra Exception. Nhưng nếu có định nghĩa try, thì bạn buộc phải định nghĩa catch kèm theo. Đó là một sự đảm bảo. Rằng bạn cứ phải luôn chỉ định logic dự phòng cho trường hợp có Exception xảy ra thật.

Catch Với Lớp Exception Luôn Được Không?

Bạn không nhất thiết phải catch với cụ thể các lớp con như ArrayIndexOutOfBoundsException hay ArithmeticException như các ví dụ trên kia. Mà bạn có thể sử dụng lớp cha Exception luôn. Khi đó câu lệnh sẽ là try {…} catch (Exception e) {…}. Vì đâu phải lúc nào bạn cũng nhớ các lớp con của Exception đâu! Nhưng hãy cẩn thận! Hãy học việc sử dụng các Exception con cụ thể, đừng vì lười mà sử dụng luôn lớp Exception, thậm chí là lớp Throwable để thay thế.

Minh họa không nên dùng lớp Exception trong try catch

Để làm chi ư, dĩ nhiên đầu tiên code của bạn sẽ dễ dàng để quản lý hơn, và việc tung ra lỗi cho người dùng sẽ cụ thể hơn, điều này sẽ được sáng tỏ khi bạn làm quen với việc bắt nhiều lỗi cùng lúc như mục dưới đây.

Try Với Nhiều Catch

Có nhiều khi chúng ta nhồi nhét quá nhiều code vào khối try, khiến nó có khả năng gây ra nhiều hơn một Exception. Thì chúng ta hoàn toàn có thể áp dụng cú pháp sau để có thể bắt nhiều Exception ở cùng một lần try.

try {
	// Các dòng code có khả năng gây ra Exception
} catch (ExceptionClass1 e1) {
	// Bắt các EceptionClass1
} catch (ExceptionClass2 e2) {
	// Bắt các EceptionClass2
} catch (ExceptionClass3 e3) {
	// Bắt các EceptionClass3
}

Theo như cú pháp trên đây, bạn vẫn sẽ bao lấy các câu lệnh có khả năng gây ra Exception vào một khối try. Rồi mỗi catch sẽ là một Exception riêng biệt. Sau đó, với bất cứ dòng nào gây ra Exception, hệ thống sẽ lần lượt tìm đến các khối catch bên dưới try cho đến khi tìm thấy một Exception tương ứng.

Đối với Java 7, bạn còn có thể viết cú pháp try với nhiều catch như sau. Các Exception ở cách viết này được nằm trong cùng một catch thôi nhưng chúng cách nhau bởi ký tự “|“.

try {
	// Các dòng code có khả năng gây ra Exception
} catch (ExceptionClass1|ExceptionClass2|ExceptionClass3 e) {
	// Bắt các EceptionClass1, EceptionClass2 và EceptionClass3 
}

Chúng ta sẽ làm quen với việc xây dựng nhiều catch như thế này ở bài thực hành tiếp theo.

Thực Hành Xây Dựng Try Với Nhiều Catch

Ở bài thực hành này, bạn sẽ được làm quen với các Checked Exception, và làm quen với cách nhanh nhất để thêm khối lệnh try catch (hoặc try với nhiều catch) trong Eclipse.

Đầu tiên, bạn hãy code dòng sau vào một phương thức nào đó.

1
2
3
4
FileOutputStream outputStream;
outputStream = new FileOutputStream("E://file.txt");
outputStream.write(65);
outputStream.close();

Bạn sẽ ngay lập tức thấy các báo lỗi từ trình biên dịch.

Thực hành show ra báo lỗi khi chưa có try catch

Đây không hẳn là một lỗi. Vì khi bạn click vào icon hình bóng đèn bên trái, bạn sẽ thấy một gợi ý khắc phục như sau.

Thêm try catch thông qua hỗ trợ của Eclipse

Rõ ràng đây là một Checked Exception, và hệ thống đang gợi ý cho bạn với hai lựa chọn.

– Add throws declaration: lựa chọn này cho phép bạn không cần phải tạo ra bất kỳ khối try catch nào, mà tiếp tục “quăng cho thằng nào đó” try catch giúp. Kỹ thuật này sẽ được mình nói đến ở bài tiếp theo.
– Surround with try/catch: đây là lựa chọn mà chúng ta mong muốn, với việc chọn lựa tùy chọn này, hệ thống sẽ bao lấy code của bạn bằng một khối try catch với Exception có tên là FileNotFoundException.

Bạn có thể tự gõ vào try catch như gợi ý. Nhưng nếu bạn chọn tùy chọn thứ hai, hệ thống sẽ try catch tự động cho bạn, tuy nhiên cái tự động này khá “vụng về”, bạn cứ nên sửa lại cho code nhìn đẹp đẹp như sau.

1
2
3
4
5
6
7
8
9
try {
    FileOutputStream outputStream;
    outputStream = new FileOutputStream("E://file.txt");
    outputStream.write(65);
    outputStream.close();
} catch (FileNotFoundException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}

Và bạn có thể thấy, dù cho đã try catch với FileNotFoudException, thì hệ thống vẫn cứ đang báo lỗi. Chúng ta cùng xem.

Thêm try catch thông qua sự hỗ trợ của Eclipse

Thì ra là hệ thống cần bạn phải catch thêm một Exception khác có tên IOException. Lúc bấy giờ tùy chọn có thể nhiều hơn. Như hình trên, chúng bao gồm.

– Add throws declaration: giống như mình đã nói ở trên.
– Add catch clause to surrounding try: thêm một catch nữa, dạng try catch catch như cú pháp thứ nhất trong mục này.
– Add exception to existing catch clause: thêm một Exception vào trong catch sẵn có. Dạng try catch (ExceptionClass1 | ExceptionClass2) như cú pháp thứ hai trong mục này.
– Surround with try/catch: lồng thêm một try catch vào try catch hiện tại, cách này mình ít áp dụng vì nhìn code phức tạp lắm.

Đến đây bạn có thể chọn cách thứ hai để code trông như thế này.

1
2
3
4
5
6
7
8
9
10
11
12
try {
    FileOutputStream outputStream;
    outputStream = new FileOutputStream("E://file.txt");
    outputStream.write(65);
    outputStream.close();
} catch (FileNotFoundException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}

Hoặc chọn cách thứ ba để ra code này.

1
2
3
4
5
6
7
8
9
try {
    FileOutputStream outputStream;
    outputStream = new FileOutputStream("E://file.txt");
    outputStream.write(65);
    outputStream.close();
} catch (FileNotFoundException | IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}

Bài Tập Số 1

Giả sử mình có chương trình sau. Chương trình sẽ tạo ra 10 số nguyên ngẫu nhiên và lưu vào mảng 10 phần tử. Chương trình sẽ cho người dùng nhập vào một chỉ số của mảng rồi xuất giá trị của mảng đó ra console.

1
2
3
4
5
6
7
8
9
10
11
12
13
// Tạo một mảng 10 các số nguyên ngẫu nhiên
int randomIntNumbers[] = new int[10];
Random rand = new Random();
for(int i = 0; i < 10; i++) {
    randomIntNumbers[i] = rand.nextInt(100);
}
         
// Cho người dùng nhập một số nguyên và in ra màn hình
// phần tử mảng tương ứng
Scanner input = new Scanner(System.in);
System.out.println("Bạn muốn in ra phần tử mảng thứ mấy? ");
int index = input.nextInt();
System.out.println("OK, phần tử mảng thứ " + index + " mang giá trị " + randomIntNumbers[index]);

Tất nhiên, chương trình này tiềm ẩn nhiều nguy cơ gây ra Exception ở runtime. Bạn hãy cố gắng ràng các try catch tương ứng để khi có Exception xảy ra thì chương trình có thể thông báo lỗi kịp thời đến với người dùng nhé.

Và đây là code đáp án, bạn nên tự code và tự “phá” chương trình trước khi click vào đáp án này.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Tạo một mảng 10 các số nguyên ngẫu nhiên
int randomIntNumbers[] = new int[10];
Random rand = new Random();
for(int i = 0; i < 10; i++) {
    randomIntNumbers[i] = rand.nextInt(100);
}
         
try {
    // Cho người dùng nhập một số nguyên và in ra màn hình
    // phần tử mảng tương ứng
    Scanner input = new Scanner(System.in);
    System.out.println("Bạn muốn in ra phần tử mảng thứ mấy? ");
    int index = input.nextInt();
    System.out.println("OK, phần tử mảng thứ " + index + " mang giá trị " + randomIntNumbers[index]);
} catch (InputMismatchException e) {
    System.out.println("Phần tử mảng chưa hợp lệ, vui lòng nhập vào một số nguyên!");
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("Phần tử mảng chưa hợp lệ, vui lòng nhập vào giá trị từ 0 đến 9!");
}

Bài Tập Số 2

Mình có đoạn code sau đã được try catch “cẩn thận”, bạn xem try catch như vậy có còn khả năng gây ra lỗi nào nữa không.

1
2
3
4
5
6
7
8
9
10
11
12
Scanner input = new Scanner(System.in);
Integer intNumber = null;
         
try {
    System.out.println("Hãy nhập vào một số nguyên: ");
    String strNumber = input.nextLine();
    intNumber = new Integer(strNumber);
} catch (NumberFormatException e) {
    System.out.println("Vui lòng nhập vào một số nguyên");
}
         
System.out.println("Chuyển thành Hexa: " + Integer.toHexString(intNumber));

Và mình đã phát hiện nó chưa tốt. Vì nếu có Exception xảy ra với đoạn kêu người dùng nhập vào một số, thì intNumber khi đó chưa được khởi tạo (mang giá trị null), và sẽ có một Exception khác xảy ra cho đoạn code cuối cùng. Mình sửa lại code này như sau.

1
2
3
4
5
6
7
8
9
10
11
12
try {
    Scanner input = new Scanner(System.in);
    Integer intNumber = null;
             
    System.out.println("Hãy nhập vào một số nguyên: ");
    String strNumber = input.nextLine();
    intNumber = new Integer(strNumber);
             
    System.out.println("Chuyển thành Hexa: " + Integer.toHexString(intNumber));
} catch (NumberFormatException e) {
    System.out.println("Vui lòng nhập vào một số nguyên");
}

Các bạn vừa xem xong phần 2 của loạt bài về Exception. Qua bài học này chúng ta đã cùng nhau làm quen với việc bắt lỗi và bẻ luồng của ứng dụng thông qua công cụ try catchTry catch sẽ còn có “biến thể” nữa và mình sẽ dành phần sau để nói tiếp nhé.

Exception Tập 3 – Try Catch Và Hơn Thế Nữa

Thông qua hai bài viết về Exception, bạn đã phần nào yên tâm hơn cho “số phận” của các dòng code mà bạn tạo ra rồi đúng không nào. Sự thật là với việc sử dụng tốt try catch mà hai bài học trước mình đã nói rất kỹ, thì có thể nói ứng dụng của bạn sẽ trở nên rất mạnh mẽ, an toàn, và cũng khá thông minh khi có thể thông báo kịp thời các trường hợp lỗi cho user.

Tuy nhiên, nếu đã nói về Exception thì phải nói cho tới. Đừng lấp lửng nửa vời mà lỡ mất các chức năng đặc sắc mà công cụ này mang đến. Hôm nay chúng ta tiếp tục đào sâu về Exception khi nói về try catch với finallytry catch với resource, và các phương thức hữu ích của lớp Exception.

Try Catch Với Finally

Mình nhắc lại một chút từ các bài học trước, rằng khi làm quen với cách thức bắt Exception thông qua try catch, thì try giúp bao bọc đoạn code có khả năng gây ra lỗi, còn catch sẽ giúp xử lý “hậu quả” khi mà code trên đã chính thức bị lỗi.

Tuy nhiên nhiêu đó chưa đủ. Trong Java, đôi khi chúng ta có sử dụng đến một số tài nguyên của hệ thống. Tài nguyên này chính là các resource có khả năng dùng chung giữa các ứng dụng. Đó có thể đơn giản chỉ là một file nào đó. Và bởi vì đây là các resource dùng chung, nên nếu có một tình huống khi ứng dụng của bạn đang mở một resource ra xem, nhưng chẳng may lại gây ra một Exception nào đó trong quá trình này, khi đó ứng dụng thông báo lỗi, và… xong… vô tâm không đóng lại cái resource ấy. Trường hợp này, hệ thống đang tưởng rằng resource vẫn được quản lý bởi ứng dụng của bạn, nhưng thực chất là ứng dụng của bạn đã chết từ lâu rồi.

Qua đó bạn có thể thấy rằng, trong lập trình bạn không nên để ứng dụng nắm giữ bất kỳ tài nguyên nào cho riêng bạn, bạn nên đóng nó lại sau khi dùng xong. Bạn sẽ hiểu rõ điều này khi làm quen với kiến thức về Input/Output ở các bài tiếp theo. Từ tình huống trên mà finally ra đời. Finally sẽ giúp chúng ta dọn dẹp các tàn dư từ try catch để lại, trong bài học hôm nay nhiệm vụ chính của nó là đóng các tài nguyên lại khi có Exception xảy ra. Trước hết mời bạn xem cú pháp sau.

try {
	// Các dòng code có khả năng gây ra Exceptipn
} catch (ExceptionClass1 e1) {
	// Bắt các EceptionClass1
} catch (ExceptionClass2 e2) {
	// Bắt các EceptionClass2
} catch (ExceptionClass3 e3) {
	// Bắt các EceptionClass3
} finally {
	// Giải phóng các tài nguyên đang sử dụng
}

Qua cú pháp trên, bạn có thể thấy finally sẽ đi kèm với try catch. Nếu không có try catch sẽ không có finally. Nhưng dù try có đi kèm với bao nhiêu catch đi chăng nữa, thì chỉ cần có một finally để dọn dẹp những gì bạn đã dùng là đủ.

Dưới đây là một vài ý liên quan đến finally, mình hi vọng các mục này sẽ giúp bạn có cái nhìn rõ nhất về finally này.

Chỉ Cần Có Try – Finally (Mà Không Cần Catch) Cũng Được

Bạn có thể hiểu rằng finally trong trường hợp này được dùng để dọn dẹp cái gì đó mà không nhất thiết phải có Exception xảy ra. Vì nhu cầu sử dụng finally khi này rất ít nên ví dụ dưới đây của mình sẽ không có tính thực tế lắm, chủ yếu giúp bạn hiểu thôi.

1
2
3
4
5
6
7
try {
    System.out.println("Bài toán thực hiện phép chia:");
    int ketQua = 10 / 2;
    System.out.println("10 chia 2 bằng: " + ketQua);
} finally {
    System.out.println("Kết thúc chương trình");
}

Chắc bạn cũng đoán biết kết quả rồi. Rằng cả 3 dòng in ra console ở trên đều thực hiện một cách trôi chảy.

Exception tập 3 - try catch với finally

Bạn Có Thể Không Cần Đến Finally, Nhưng Đã Có Finally Thì Code Trong Finally Luôn Được Gọi Đến Cuối Cùng

Ví dụ về try catch không cần đến finally thì bạn đã biết thông qua bài học trước. Nhưng nếu bạn có khai báo finally cho try catch, thì các câu lệnh trong khối finally sẽ được thực thi cuối cùng, sau khi ứng dụng đã thực thi các câu lệnh không gây ra Exception ở try, rồi đến các câu lệnh trong khối catch tương ứng nếu có, cuối cùng mới đến finally.

1
2
3
4
5
6
7
8
9
try {
    System.out.println("Bài toán thực hiện phép chia:");
    int ketQua = 10 / 0;
    System.out.println("10 chia 0 bằng: " + ketQua);
} catch (ArithmeticException e) {
    System.out.println("Phép chia không thực hiện được");
} finally {
    System.out.println("Kết thúc chương trình");
}

Với các dòng code này thì console sẽ in ra như sau. Và có lẽ mình cũng không cần giải thích gì nhiều.

Exception tập 3 - try catch với finally

Trong Finally Vẫn Có Thể Có Try Catch Trong Đó

Nếu như trong try hay catch ta đều có thể lồng thêm try catch vào. Thì finally cũng cho phép như thế. Mặc dù trông có vẻ phức tạp và hơi thiếu an toàn, nhưng đời không có gì hoàn hảo cả, đã bắt lỗi rồi vẫn có thể xảy ra lỗi ở quá trình bắt lỗi là chuyện thường tình.

Bạn sẽ được làm quen với finally và cả try catch bên trong finally qua bài thực hành dưới đây.

Thực Hành Xây Dựng Try Catch Với Finally

Bài thực hành này giúp chúng ta làm quen với việc sử dụng try catch và finally một cách thực tế hơn. Chúng ta sẽ kế thừa code từ bài thực hành hôm trước.

Vì bài thực hành hôm trước chúng ta chỉ mới làm cách nào để bắt Exception hiệu quả thôi. Thực tế bạn đang sử dụng FileOutputStream để mở một file từ hệ thống và thao tác với file đó. File này chính là tài nguyên có khả năng dùng chung bởi các chương trình khác. Như mình có nói, nếu bạn sử dụng file này mà lỡ có chuyện gì xảy ra mà bạn không giải phóng việc sở hữu đó, thì khi đó bạn đang “ích kỷ” chỉ giữ file đó cho riêng bạn, các ứng dụng khác không thể đọc được file này.

Đó là lý do vì sao chúng ta cần xây dựng finally để giải phóng cái tài nguyên này như sau.

Đầu tiên, chúng ta cần phải code các dòng có gây ra Checked Exception như sau.

1
2
3
4
FileOutputStream outputStream = null;
outputStream = new FileOutputStream("E://file.txt");
outputStream.write(65);
outputStream.close();

Và như với việc thực hành ở bài trước, bạn đã biết cách thức dễ nhất để hệ thống tự thêm vào các try catch cho các dòng đang bị báo lỗi. Sau khi áp dụng các sự gợi ý từ Eclipse (xem ở bài trước), code của chúng ta trông khá là đẹp như sau.

1
2
3
4
5
6
7
8
9
10
11
12
FileOutputStream outputStream = null;
try {
    outputStream = new FileOutputStream("E://file.txt");
    outputStream.write(65);
    outputStream.close();
} catch (FileNotFoundException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}

Vấn đề của code trên đây là gì? Bạn biết rằng file có cái tên file.txt được xem như một tài nguyên dùng chung. Có nghĩa là trong lúc bạn mở file này ra để ghi vào giá trị 65, trong lúc đó, có thể có một chương trình khác cũng đang muốn thao tác với file này. Và vấn đề xảy ra là, ngỡ như trong quá trình bạn ghi vào file mà có bất cứ lỗi nào xảy ra, khiến cho chương trình bị bẻ luồng thực thi vào các dòng code bên trong khối catch bên dưới. Thì câu lệnh outputStream.close() sẽ không có cơ hội thực thi. File mà bạn thao tác sẽ vẫn được hệ thống xem là đang được sử dụng. Và như vậy ứng dụng khác phải chờ hoài mà không thấy ứng dụng của bạn trả lại quyền sử dụng file này.

Các vấn đề thao tác với file chúng ta sẽ làm quen ở các bài học sau. Còn bây giờ, hãy đảm bảo rằng file mà bạn đang dùng luôn được đóng lại một cách an toàn. Trả lại quyền sử dụng tài nguyên cho các chương trình khác. Thật đơn giản, chúng ta cùng thêm finally và đoạn code đóng file này lại trong khối này là xong. Như hình sau.

Screen Shot 2018-05-24 at 16.44.03

Ồ nhưng nhìn xem, code để vào trong finally vẫn thuộc thẩm quyền của Checked Exception. Rất tốt, chúng ta cũng nên cần một lần try catch nữa. Code cuối cùng sẽ như sau.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FileOutputStream outputStream = null;
try {
    outputStream = new FileOutputStream("E://file.txt");
    outputStream.write(65);
    outputStream.close();
} catch (FileNotFoundException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} finally {
    try {
        outputStream.close();
    } catch (IOException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }
}

Try Catch Với Resource

Kiểu try catch này xuất hiện từ Java 7. Và đây là một kiểu nâng cấp so với try catch với finally mà bạn vừa làm quen trên kia.

Cụ thể là, thông qua bài thực hành trên đây, bạn đã thấy tầm quan trọng khi giải phóng các tài nguyên của hệ thống rồi đúng không nào. Và bạn cũng đã thấy cách đóng file lại trong khối finally rồi đó. Nhưng nếu chú ý kỹ bài thực hành trên thì, chúng ta thấy khối finally sẽ được gọi đến dù cho câu lệnh khởi tạo FileInputStream có thực hiện hoàn hảo hay không, do đó việc đóng file trong khối finally hoàn toàn có thể gây ra một Exception khác. Và cũng vì lý do này mà chúng ta mới có thêm try catch bên trong finally.

Nhưng việc thực hiện thủ công finally và try catch bên trong finally như ví dụ trên cũng có cái dở, là nếu có Exception xảy ra với các code trong try và cả với code trong finally, thì các dòng lỗi in ra sẽ loạn cả lên.

Chính vì vậy mà Java 7 đã cho chúng ta một công cụ mới với cái tên try catch với resource (Try With Resources). Cú pháp của nó như sau.

try(// Khởi tạo các tài nguyên) {
	// Sử dụng các tài nguyên đã khởi tạo
} catch (ExceptionClass1 e1) {
	// Bắt các EceptionClass1
} catch (ExceptionClass2 e2) {
	// Bắt các EceptionClass2
} catch (ExceptionClass3 e3) {
	// Bắt các EceptionClass3
}

Như bạn thấy, cách try catch này không còn đi kèm với finally nữa. Vì các tài nguyên được khởi tạo bên trong cặp ngoặc đơn của try như trên giờ đây đã có thể tự biết cách đóng lại khi kết thúc khối lệnh try.

Trước khi đi vào bài thực hành chi tiết, chúng ta xem qua một vài ý chính của try catch với resource để giúp chúng ta hiểu rõ hơn.

Không Phải Tài Nguyên Nào Cũng Dùng Được Trong Try Catch Với Resource

Chỉ có các đối tượng có hiện thực interface java.lang.AutoCloseable, như FileOutputStream ở ví dụ trên kia, mới có thể dùng trong try catch với resource.

Trong Quá Trình Đóng Các Tài Nguyên Hệ Thống, Nếu Xảy Ra Lỗi Thì Sẽ Không Có Exception Của Quá Trình Này

Ý này có nghĩa là, nếu với cách sử dụng try catch với finally trên kia, giả sử khi bạn khởi tạo FileOutputStream bị lỗi, Exception của sự khởi tạo này được tung ra, chương trình bị bẻ luồng vào catch tương ứng, điều này thì bạn biết quá rõ rồi. Nhưng sau đó các code bên trong khối finally thực hiện, nếu việc đóng FileOutputStream thông qua câu lệnh outputStream.close() vẫn gây ra lỗi, Exception của sự đóng này lại được tung ra, và chương trình lại bị bẻ luồng vào catch bên trong finally này.

Nhưng với try catch với resource thì lại khác. Nếu cái sự khởi tạo FileOutputStream bị lỗi, chỉ có Exception của try được tung ra và chương trình chỉ bị bẻ luồng vào catch tương ứng. Việc đóng FileOutputStream lại nếu có lỗi hay không thì chương trình cũng không tung ra bất kỳ Exception nào nữa cả. Đó là sự khác biệt cơ bản giữa hai try catch mà chúng ta đang nói đến của bài hôm nay.

Có Thể Khởi Tạo Nhiều Tài Nguyên Bên Trong Khối Try

Có nhiều khi chúng ta muốn đảm bảo nhiều hơn một tài nguyên bên trong khối try, chúng ta chỉ việc khởi tạo các tài nguyên này và cách nhau bằng dấu (;) mà thôi. Chi tiết về ý này sẽ được mình thể hiện ở các bài thực hành sau đây.

Thực Hành Xây Dựng Try Catch Với Resource

Chúng ta sẽ sửa code của bài thực hành trên kia bằng kiểu try catch với resource.

Rất dễ và nhanh chóng, bạn chỉ cần đưa code khởi tạo tài nguyên vào trong cặp ngoặc đơn của try, và bỏ khối finally (và tất cả các code đóng tài nguyên lại) của bài thực hành trên là được thôi.

1
2
3
4
5
6
7
8
9
try(FileOutputStream outputStream = new FileOutputStream("E://file.txt")) {
    outputStream.write(65);
} catch (FileNotFoundException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}

Thực Hành Xây Dựng Try Catch Với Nhiều Resource

Như đã hứa trên kia, bài thực hành này sẽ giúp bạn hiểu rõ try catch với việc khởi tạo nhiều hơn một tài nguyên bên trong cặp ngoặc đơn của try như thế nào.

Có thể bạn chưa hiểu hết các câu lệnh sau, nhưng bạn cứ thực hành đi, sau này chúng ta sẽ nói rõ hơn các câu lệnh này ở các bài học liên quan. Các câu lệnh dưới đây sẽ đọc file nào đó do bạn chỉ định. Và do các dòng code sau có chứa hai tài nguyên có hiện thực interfce java.lang.AutoCloseable, chúng là FileInputStream và BufferedInputStream, nên chúng ta sẽ khởi tạo chúng bên trong cặp ngoặc đơn của try như sau. Hai tài nguyên được khởi tạo này sẽ được tự động đóng lại sau khi khối lệnh try catch kết thúc.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try(FileInputStream inputStream = new FileInputStream("E://file.txt");
        BufferedInputStream bufferInputStream = new BufferedInputStream(inputStream)) {
    int data = bufferInputStream.read();
    while(data != -1) {
        System.out.print((char) data);
        data = bufferInputStream.read();
    }
} catch (FileNotFoundException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}

Vài Phương Thức Hữu Ích Của Lớp Exception

Mục này là phần nói thêm của mình trong bài hôm nay. Tuy mình chỉ nêu vài phương thức thôi, nhưng mình hi vọng bạn sẽ hiểu rõ hơn về cách sử dụng Exception và ứng dụng nó vào một số hoàn cảnh cụ thể nào đó.

getMessage()

Phương thức này trả về một String mô tả về Exception vừa xảy ra. Bạn có thể thử nghiệm bằng cách gọi đến các dòng code sau. Mình cố tình làm cho các dòng code gây ra Exception.

1
2
3
4
5
6
7
try(FileOutputStream outputStream = new FileOutputStream("E://file_not_found.txt")) {
    outputStream.write(65);
} catch (FileNotFoundException e) {
    System.out.println(e.getMessage());
} catch (IOException e) {
    System.out.println(e.getMessage());
}

Và đây là kết quả mà phương thức getMessage() này trả về.

Exception tập 3 - Phương thức getMessage()

toString()

Trả về tên của lớp Exception đang tung ra lỗi, kèm với nội dung của phương thức getMessage() trên kia. Bạn có thể thử.

1
2
3
4
5
6
7
try(FileOutputStream outputStream = new FileOutputStream("E://file_not_found.txt")) {
    outputStream.write(65);
} catch (FileNotFoundException e) {
    System.out.println(e.toString());
} catch (IOException e) {
    System.out.println(e.toString());
}

Và đây là kết quả.

Exception tập 3 - Phương thức toString()

printStackTrace()

Đây là một phương thức giúp bạn in ra nội dung của phương thức toString() trên kia, kèm với các dòng log dạng Stack Trace nữa.

Nào chúng ta cùng thử nghiệm.

1
2
3
4
5
6
7
try(FileOutputStream outputStream = new FileOutputStream("E://file_not_found.txt")) {
    outputStream.write(65);
} catch (FileNotFoundException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}

Và cái kết.

Exception tập 3 - Phương thức printStackTrace()

Các bạn vừa xem xong phần 3 của loạt bài về Exception. Bài học hôm nay giúp chúng ta đi sâu hơn về cách sử dụng try catch để bắt các Exception và giải phóng tài nguyên. Chúng ta còn một phần nữa về kiến thức Exception này, bạn đón xem nhé.

 

Exception Tập 4 – Throw, Throws & Custom Exception

Mình thông báo cho các bạn biết rằng đây là bài viết cuối cùng của mình về “series” Exception này. Như vậy nhìn lại chúng ta có các bài viết về Exception như sau.

– Làm quen với Exception – Đây là bài học mở đầu về Exception. Bài học giúp cho bạn bắt đầu tiếp cận với khái niệm LỖI trong Java. Từ đó hiểu rõ thế nào là Exception. Ngoài ra bài học còn giúp bạn phân biệt các loại Exception trong hệ thống Java nữa.
– Bắt (bẫy) Exception – Sau khi đã hiểu Exception là gì, thì bạn sẽ biết được cách thức bắt, hay bẫy Exception. Biết được kiến thức này, bạn có thể “bẻ luồng” logic của chương trình thông qua công cụ try catch. Việc bẻ luồng này nhằm mục đích vẫn đảm bảo sao cho ứng dụng của bạn vẫn luôn luôn “sống” dù cho nó có bị các LỖI nào tác động đến đi chăng nữa.
– Tìm hiểu sâu hơn về try catch – Khi bạn đã biết bắt Exception thông qua try catch, bạn sẽ được biết đến công dụng cao hơn nữa của công cụ này qua hai khái niệm Try Catch với Finally và Try Catch với Resource. Và mình cũng tranh thủ nói đến một số phương thức hữu ích của lớp Exception ở bài này, giúp bạn có thể hiểu rõ hơn về cách sử dụng công cụ này, và có thể vận dụng các phương thức đó cho bài học ngày hôm nay.

Bài hôm nay chúng ta đến với kiến thức còn lại của Exception. Bài học sẽ hướng dẫn bạn đạt đến một cảnh giới hoàn toàn không sợ sệt gì với Exception cả. Thậm chí bạn có thể “tung hứng” chúng, đẩy chúng về một nơi khác xử lý, và rồi bạn còn có thể tự tạo ra một Exception cho riêng bạn nữa.

Nếu bạn thấy thú vị thì mời các bạn cùng đến với bài viết.

Throw – Tự Tung Ra Một Exception

Tất cả chúng ta cho đến giờ phút này đều hiểu rõ về Exception rồi. Nhưng chúng ta vẫn chỉ đang “chờ đón” Exception đến một cách thụ động mà thôi. Tức là chúng ta chỉ mới đang try và chờ đón (thậm chí không mong muốn) catch ra lỗi.

Nhưng đôi khi trong thực tế, có một số trường hợp buộc chúng ta muốn nhanh chóng hơn tung ra một Exception. Mình dùng từ “tung ra” bởi chính ở nghĩa của từ throw: ném, quăng, tung. Exception được tung ra này có thể là Checked hay Unchecked Exception. Và việc tung ra một Exception này là do chủ ý của bạn.

Cách sử dụng throw cũng khá là đơn giản. Bất cứ nơi nào đó bên trong một phương thức, hay một khối lệnh, mà bạn muốn tung ra Exception, thì cứ dùng throw như với bài thực hành sau.

Thực Hành Sử Dụng Throw Để Tung Ra Một Exception

Chúng ta bắt đầu với việc thiết kế một chương trình cho phép người dùng nhập vào tuổi của nhân viên, rồi in ra tuổi vừa mới nhập.

Yêu cầu của chương trình tưởng như vô cùng đơn giản, thế nhưng như bạn biết, nếu không khéo, ứng dụng có thể sẽ bị “crash” nếu như người dùng cố tình nhập vào một dữ liệu tuổi không hợp lệ.

Với bài thực hành này chúng ta thử thiết kế riêng một phương thức nhập tuổi như sau.

1
2
3
4
5
6
private static int nhapTuoiNhanVien() {
    Scanner scanner = new Scanner(System.in);
    System.out.print("Hãy nhập tuổi nhân viên: ");
    int tuoi = scanner.nextInt();
    return tuoi;
}

Bạn có thể thấy, phương thức nhapTuoiNhanVien() này có kiểu trả về là int (trả về tuổi vừa mới nhập vào). Và phương thức này còn được khai báo với từ khóa static, để lát nữa chúng ta có thể gọi đến từ phương thức main() (vốn là một phương thức static). Để hiểu rõ tại sao nhapTuoiNhanVien() lại phải khai báo static như vậy thì bạn có thể xem lại bài học này nhé.

Tiếp theo, ở phương thức main(), để đảm bảo ứng dụng không bị crash, chúng ta nên bao bọc lời gọi đến nhapTuoiNhanVien() vào bên trong một try catch hợp lý.

1
2
3
4
5
6
7
8
public static void main(String[] args) {
    try {
        int tuoi = nhapTuoiNhanVien();
        System.out.println("Tuổi đã nhập: " + tuoi);
    } catch (InputMismatchException e) {
        System.out.println("Tuổi nhập vào chưa hợp lệ. Lỗi: " + e.toString());
    }
}

Từ các bài học trước, bạn có thể dễ dàng đoán được rằng nếu nhập vào một tuổi 28 chẳng hạn, ứng dụng chạy tốt. Nhưng nếu người dùng cắc cớ nhập vào 28a, rất nhanh chóng ứng dụng sẽ thông báo lỗi ra cho người dùng ngay! Ôi mình yêu try catch biết bao nhiêu.

Exception - Bắt lỗi nhập vào không phải số

Thế nhưng nếu người dùng vẫn cắc cớ, nhập vào một tuổi mang giá trị âm, -5 chẳng hạn. Ứng dụng của chúng ta vẫn hơi “ngu” khi để lọt một sơ hở này.

Exception - Ví dụ để lọt một giá trị âm cho tuổi

Làm gì có cái tuổi như thế này! Vậy để nâng cấp cho cái sự thông minh của ứng dụng, chúng ta sẽ tận dụng từ khóa throw. Như mình có nói, từ khóa này sẽ được tận dụng để chúng ta có thể tung ra một Exception bất cứ lúc nào chúng ta muốn, trong trường hợp này chúng ta nên tung ra một Exception khi mà người dùng nhập vào một tuổi âm. Bạn có thể xem mình code thêm vào phương thức nhapTuoiNhanVien() như sau.

1
2
3
4
5
6
7
private static int nhapTuoiNhanVien() {
    Scanner scanner = new Scanner(System.in);
    System.out.print("Hãy nhập tuổi nhân viên: ");
    int tuoi = scanner.nextInt();
    if (tuoi < 0) throw new InputMismatchException("tuổi không được nhỏ hơn 0.");
    return tuoi;
}

Bạn có thể thấy code bổ sung được tô sáng như trên. Mình giải thích tí xíu. Bên trong nhapTuoiNhanVien(), một khi kiểm tra thấy tuổi do người dùng nhập vào nhỏ hơn 0, ứng dụng sẽ tung ra (throw) bất kỳ một Exception nào, trong ví dụ này InputMismatchException được tung ra. Và bạn có thể hiểu bạn có thể tung ra bất cứ Exception nào bạn muốn. Bên cạnh việc tung ra một Exception, bạn có thể định nghĩa ra một thông báo kiểu String và truyền vào constructor của Exception như code trên nữa. Sau đó ở nơi gọi đến nhapTuoiNhanVien(), nếu nơi đó có try catch hợp lý, và gặp đúng điều kiện mà Exception được tung ra (tuổi nhập vào nhỏ hơn 0) thì như các bài học trước, logic của ứng dụng sẽ bị bẻ về khối catch.

Giờ đây, với sự nâng cấp này, nếu người dùng nhập vào một giá trị âm, bạn xem.

Exception - Tung ra một lỗi

Qua bài thực hành này thì bạn đã hiểu cách dùng throw rồi đúng không nào.

Trước khi qua mục tiếp theo, mình có một lưu ý rằng. Với ví dụ trên đây, khi tung ra một Exception, chúng ta chọn InputMismatchException. Đây là một Unchecked Exception. Do đó nơi gọi đến phương thức có tung Exception này không bị trình biên dịch báo lỗi bắt bạn phải thêm vào một try catch. Nhưng nếu bạn thử nghiệm với một Checked Exception xem, khi đó sẽ có nhiều điều đáng nói đến, và còn liên quan đến khái niệm throws nữa nên mình sẽ dành trường hợp này và nói chung ở mục kế tiếp sau.

Throws – Đẩy Exception Cho Nơi Khác Xử Lý

Bạn chú ý đừng nhầm lẫn nhé. Mục trên kia có nói đến việc tự ý tung ra một Exception thông qua từ khóa throw. Còn mục này đang nói đến throws (có thêm chữ s).

Khác với throw rằng bạn có thể sử dụng bên trong một phương thức hay một khối lệnh nào đó. Throws lại dùng ngay khi bạn khai báo một phương thức.

Throws được dùng khi bạn không muốn phải xây dựng try catch bên trong một phương thức nào đó, bạn “đẩy trách nhiệm” phải try catch này cho phương thức nào đó bên ngoài có gọi đến nó phải try catch giúp cho bạn.

Chúng ta cùng đến với các bài thực hành sau để hiểu rõ nhất về throws, và về cả việc vận dụng tốt với nhau giữa throw và throws nữa nhé.

Bài Thực Hành Số 1: Sử Dụng Throw Để Tung Ra Một Checked Exception

Bạn đang thắc mắc tại sao đang nói về throws mà tiêu đề lại là thực hành với throw?

Bạn cứ thử qua các bước sau đây sẽ rõ thực hư. Mình sẽ lấy lại source code của bài thực hành trên kia. Trong phương thức nhapTuoiNhanVien(), chúng ta thử thay thế việc throw một InputMismatchException, bằng một Checked Exception nào đó xem sao. Mình sẽ thử với IOException. Code của phương thức này sẽ như sau.

1
2
3
4
5
6
7
private static int nhapTuoiNhanVien() {
    Scanner scanner = new Scanner(System.in);
    System.out.print("Hãy nhập tuổi nhân viên: ");
    int tuoi = scanner.nextInt();
    if (tuoi < 0) throw new IOException("tuổi không được nhỏ hơn 0.");
    return tuoi;
}

Sau khi tung ra một Checked Exception như trên, bạn dễ dàng nhận thấy hệ thống sẽ yêu cầu bạn phải làm một trong hai việc như sau.

Exception - Lựa chọn throws

Lựa chọn đầu tiên Add throws declaration sẽ khai báo một throws cho phương thức nhapTuoiNhanVien() này. Và như mình có nói, nếu phương thức đã khai báo throws, nó không cần phải try catch gì nữa, mà có thể đẩy trách nhiệm try catch này cho phương thức nào đó khác. Lựa chọn thứ hai Surround with try/catch thì bạn cũng biết từ các bài học trước rồi, nó sẽ giúp bao đoạn code này lại bằng khối lệnh try catch. Chúng ta nên chọn lựa chọn đầu tiên trong tình huống này, vừa đúng với yêu cầu bài học, vừa giúp dễ quản lý code nữa, vì bạn vừa tung ra một Exception mà lại try catch nó ngay, thì trông hơi bị dở người tí.

Với lựa chọn đầu tiên, code của chúng ta sẽ như sau.

1
2
3
4
5
6
7
private static int nhapTuoiNhanVien() throws IOException {
    Scanner scanner = new Scanner(System.in);
    System.out.print("Hãy nhập tuổi nhân viên: ");
    int tuoi = scanner.nextInt();
    if (tuoi < 0) throw new IOException("tuổi không được nhỏ hơn 0.");
    return tuoi;
}

Với việc đẩy trách nhiệm phải try catch Checked Exception này, thì phương thức gọi đến nhapTuoiNhanVien() sẽ báo lỗi quen thuộc như sau.

Exception - Try Catch với Exception đã throws

Mình cá là các bạn đã biết cách làm gì rồi. Và đây, mình đã sửa lại theo ý các bạn, try catch với IOException.

1
2
3
4
5
6
7
8
public static void main(String[] args) {
    try {
        int tuoi = nhapTuoiNhanVien();
        System.out.println("Tuổi đã nhập: " + tuoi);
    } catch (IOException e) {
        System.out.println("Tuổi nhập vào chưa hợp lệ. Lỗi: " + e.toString());
    }
}

Bạn đã hiểu cách thức dùng đến throws để đẩy trách nhiệm try catch đi chỗ khác chưa nào.

Bài Thực Hành Số 2: Sử Dụng Throws Để Đẩy Exception Đi Nơi Khác

Với bài thực hành trên kia có lẽ bạn cũng đã hiểu rõ cách dùng throws rồi. Tuy nhiên mình muốn các bạn làm thêm một ví dụ nữa để chỉ tập trung vào một mình throws thôi, không có throw xuất hiện trong này.

Chúng ta cùng quay lại xây dựng tình huống try catch như bài thực hành này của bài trước. Chỉ khác một chỗ lần này chúng ta xây dựng riêng một phương thức ghi file và trong phương thức này không hề có một try catch nào cả.

Nào, bắt đầu với việc xây dựng phương thức ghi file (code ghi file giống bài thực hành hôm trước thôi).

1
2
3
4
5
6
private static void ghiFile() {
    FileOutputStream outputStream;
    outputStream = new FileOutputStream("E://file.txt");
    outputStream.write(65);
    outputStream.close();
}

Chúng ta lại sẽ nhận được thông báo lỗi.

Exception - Báo lỗi chưa catch

Khi click vào icon cái bóng đèn bên trái, bạn đã biết là nên chọn tùy chọn này.

Exception - Chọn throws

Và khi đã throws đầy đủ, phương thức này sẽ trông như thế này đây.

1
2
3
4
5
6
private static void ghiFile() throws FileNotFoundException, IOException {
    FileOutputStream outputStream;
    outputStream = new FileOutputStream("E://file.txt");
    outputStream.write(65);
    outputStream.close();
}

Cuối cùng, hiển nhiên ở nơi gọi đến phương thức này sẽ buộc phải try catch đầy đủ, hoặc throws tiếp cho phương thức nào đó kế tiếp.

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
    try {
        ghiFile();
    } catch (FileNotFoundException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    } catch (IOException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }
}

Tự Tạo Một Exception Cho Riêng Bạn

Đến bước này đây, tất cả chúng ta đều đã đạt đến đỉnh cao nhất của việc hiểu và sử dụng Exception rồi. Khi mà tất cả chúng ta rồi đây sẽ tự tạo ra một Exception cho riêng chúng ta.

Việc xây dựng một Exception cho riêng chúng ta sẽ được gọi với một cái tên ngắn thôi: Custom ExceptionCustom Exception thực chất cũng là một Exception nào đó, nó được đặt một cái tên hợp lý hơn trong hoàn cảnh ứng dụng của bạn (thay vì các cái tên Exception có sẵn mà bạn đã từng làm quen). Rồi thông qua Custom Exception, bạn có thể điều chỉnh việc thông báo lỗi, để mang đến một sự rõ ràng hơn cho ứng dụng.

Trước khi chính thức tạo một Custom Exception, chúng ta cùng điểm qua mấy ý quan trọng này trước.

– Tất cả các Custom Exception phải đều là con của lớp Throwable (bạn có thể xem lại kiến thức ở mục này nếu lỡ quên lớp Throwable là lớp gì nha).
– Nếu bạn muốn tạo ra một Custom Exception và muốn hệ thống báo lỗi khi không try catch cho nó, thì hãy tạo một lớp và kế thừa từ lớp Exception (có thể xem ở link trên để biết lớp Exception là lớp gì). Và dĩ nhiên lớp Exception là con của Throwable rồi nên nếu bạn làm theo ý này thì cũng sẽ thỏa ý tên kia thôi.
– Còn nếu bạn muốn tạo một Custom Exception và không cần hệ thống báo lỗi, bạn chỉ cần tạo một lớp và kế thừa từ lớp RuntimeException mà thôi (RuntimeException cũng là con của Throwable, và link trên cũng đã nói về lớp này).

Các ý trên đây cỏn con thôi đúng không nào. Chúng ta cùng đến với bài thực hành.

Thực Hành Tự Tạo Một Exception Kiểm Tra Tuổi

Chúng ta sẽ làm lại một chương trình nhập vào tuổi nhân viên và kiểm tra tuổi như trên kia. Nhưng thay vì dùng IOException để kiểm tra và tung ra một lỗi, trông chẳng ăn nhập gì, chúng ta sẽ nên tạo ra một Exception đúng nhất trong trường hợp của bài thực hành này. Custom Exception này sẽ thuộc loại Checked Exception. Và Custom Exception này còn có thể được điều chỉnh để có thể hiển thị lỗi rõ ràng hơn nữa.

Custom Exception mà chúng ta đang nói đến sẽ có cái tên AgeCheckingException. Bạn hãy tạo mới một lớp như sau.

1
2
3
4
5
6
7
8
9
10
11
public class AgeCheckingException extends Exception {
 
    public AgeCheckingException(String message) {
        super(message);
    }
     
    @Override
    public String getMessage() {
        return "Lỗi nhập vào một tuổi: " + super.getMessage();
    }
}

Rất đơn giản. Custom Exception này sẽ kế thừa từ lớp Exception. Như vậy đây chính là một Checked Exception rồi. Bạn cũng nên xây dựng một constructor cho nó để nhận vào một message rồi truyền message này cho lớp cha thông qua lời gọi super(). Cuối cùng, Custom Exception này sẽ phủ quyết phương thức getMessage() để trả về nhiều thông tin hơn.

Chúng ta sẽ xây dựng phương thức nhapTuoiNhanVien() như sau. Chắc bạn đã hiểu và quen thuộc với các cách sử dụng Exception như sau rồi nên mình không giải thích thêm.

1
2
3
4
5
6
7
8
9
10
11
12
private static int nhapTuoiNhanVien() throws AgeCheckingException {
    Scanner scanner = new Scanner(System.in);
    System.out.print("Hãy nhập tuổi nhân viên: ");
    int tuoi = 0;
    try {
        tuoi = scanner.nextInt();
        if (tuoi < 0) throw new AgeCheckingException("tuổi không được nhỏ hơn 0.");
    } catch (InputMismatchException e) {
        throw new AgeCheckingException("tuổi phải là một số.");
    }
    return tuoi;
}

Và rồi ở phương thức main() nơi gọi đền nhapTuoiNhanVien() chúng ta sẽ try catch như sau.

1
2
3
4
5
6
7
8
public static void main(String[] args) {
    try {
        int tuoi = nhapTuoiNhanVien();
        System.out.println("Tuổi đã nhập: " + tuoi);
    } catch (AgeCheckingException e) {
        System.out.println(e.getMessage());
    }
}

Kết quả sẽ như sau nếu bạn cố tình nhập vào một số âm.

Custom Exception - Báo lỗi tuổi nhỏ hơn 0

Hay khi nhập một số có kèm ký tự.

Custom Exception - Báo lỗi tuổi không phải số

Như vậy kết thúc bài học hôm nay chúng ta cũng đã xong luôn kiến thức thú vị về Exception. Bài sau sẽ là một kiến thức thú vị khác của Java, đó là kiến thức về Thread.

 

Thread Tập 1 – Thread Và Các Khái Niệm

Như mình có nói từ bài học số 1, ngay khi bạn vừa mới chập chững biết đến Java là gì, thì mình cũng có nói đến một trong những thế mạnh của ngôn ngữ này, đó là Hỗ trợ chạy đa nhiệm (Multithread) rất tốt. Và mãi cho đến bài học hôm nay, thì chúng ta mới cùng nhau làm rõ hơn cái sự mạnh mẽ này của Java.

Khái Niệm Thread, Hay Multithread

Thread hay Multithread đều có ý nghĩa như nhau trong kiến thức của bài học này. Thread dịch ra tiếng Việt là Luồng, và Multithread là Đa luồng. Luồng ở đây chính là Luồng xử lý của hệ thống. Và bởi vì lý do chính đáng để cho Thread ra đời cũng chính là để cho các ứng dụng có thể điều khiển nhiều Thread khác nhau một cách đồng thời, mà nhiều Thread đồng thời như vậy cũng có nghĩa là Đa Thread, hay Multithread. Chính vì vậy mà kiến thức Thread hay Multithread cũng chỉ là một.

Vai trò của Thread hay Multithread dĩ nhiên là cái gì đó liên quan đến Đa Luồng, Đa Nhiệm rồi. Nói cụ thể ra đó là hệ thống sẽ hỗ trợ chúng ta tách các tác vụ của ứng dụng ra làm nhiều Luồng (hay Thread), và hệ thống sẽ giúp xử lý các Luồng này một cách đồng thời. Như vậy nếu theo như những gì chúng ta làm quen với Java từ trước đến giờ, đó là nếu chúng ta có các tác vụ ABC, với các cách code cũ, hệ thống sẽ luôn xử lý tuần tự các tác vụ này, giả sử A sẽ được xử lý trước tiên, rồi đến B, và cuối cùng là đến C. Nhưng sau bài học hôm nay, nếu chúng ta tổ chức sao cho mỗi AB và C là mỗi Thread, thì sẽ rất tuyệt vì chúng ta hoàn toàn có thể kêu hệ thống xử lý cả A, B và C cùng một lúc.

Một ví dụ thực tế để dễ hình dung hơn về Thread như sau. Giả sử bạn đang dùng ứng dụng Google Maps trên nền Android được viết bằng Java. Bạn có nhu cầu tìm một địa chỉ trên bản đồ, địa chỉ đó là “Chợ Bến Thành” chẳng hạn.

Ví dụ về việc sử dụng Thread
Ví dụ về việc sử dụng Thread

Khi bạn gõ từng chữ cái vào khung tìm kiếm ở trên cùng của màn hình, bạn mong muốn được thấy các gợi ý địa điểm sẽ liên tục được cập nhật theo từng chữ bạn gõ vào ở dưới. Như hình trên. Bạn thấy rằng, nếu không có Thread, hệ thống sẽ đợi bạn gõ xong một chữ, rồi mới bắt đầu tìm kiếm và gợi ý các địa điểm liên quan đến chữ đó, và trong lúc hệ thống đang tìm kiếm, bạn không thể gõ được chữ kế tiếp, vì với cách làm này, cùng một lúc hệ thống chỉ đáp ứng một chuyện thôi, hoặc là nhận ký tự bạn gõ hoặc là tìm kiếm, hết. Nhưng với việc áp dụng Multithread, chúng ta sẽ có 2 Thread chạy song song, một Thread nhận dữ liệu nhập vào của người dùng, Thread còn lại cứ dựa vào dữ liệu đã nhập đấy mà tìm kiếm, điều này làm cho trải nghiệm của người dùng được trọn vẹn, hệ thống mượt mà, nhanh chóng, không bị giật.

Bạn có thể xem mình minh họa việc đáp ứng của hệ thống đối với từng trường hợp tìm kiếm như ví dụ trên ở hình bên dưới. Trong đó, để tìm thấy kết quả “Chợ Bến Thành” xuất hiện trong danh sách gợi ý. Thì với cột Không Multithread bên trái, việc bạn gõ một từ rồi đợi hệ thống tìm kiếm, thì kết quả tìm được sẽ rất lâu so với cột MultiThread bên phải, việc bạn gõ và việc hệ thống tìm kiếm được tách ra, và thực hiện một cách song song nhau, là rất nhanh chóng và dễ chịu.

So sánh giữa việc sử dụng Multithread và không sử dụng Multithread
So sánh giữa việc sử dụng Multithread và không sử dụng Multithread

Phân Biệt Các Khái Niệm Liên Quan

Mục này mình nói thêm, cho các bạn mới tiếp cận với Thread (thậm chí với các bạn đã hiểu rõ Thread rồi) đỡ bị lăn tăn khi đọc các tài liệu liên quan đến kiến thức hôm nay. Khi đó hẳn các bạn sẽ bị loạn lên với các khái niệm sau: ThreadMultithreadTaskMultitaskProcessMultiprocess.

Đầu tiên, ở mức bao quát nhất, bạn đều biết các hệ thống máy tính ở thời đại ngày nay đều được nhắc đến với việc có khả năng xử lý đa nhiệm. Đa nhiệm ở đây chính là việc xử lý nhiều nhiệm vụ cùng một lúc. Việc xử lý đa nhiệm này càng ngày càng phổ biến và được quan tâm nhiều hơn khi các dòng máy tính đều cho ra đời các cỗ máy với nhiều CPU. Như vậy cái nhiệm vụ mà hệ thống cần xử lý đó thường được gọi là Task, và một hệ thống đa nhiệm được gọi là Multitask.

Từ nhu cầu muốn xử lý Multitask đó, hệ thống mới định ngĩa ra các Process. Process được hiểu là một chương trình. Khi ứng dụng của bạn được khởi chạy, chúng sẽ được hệ thống tạo ra một Process và ứng dụng đó sẽ được thực thi và được quản lý bên trong Process đó. Hệ thống sẽ quản lý một lúc nhiều Process khác nhau, mỗi Process như vậy được cấp phát các tài nguyên hệ thống một cách độc lập với nhau. Và bởi cùng một lúc có thể có nhiều Process (hay nhiều ứng dụng) chạy song song với nhau, nên người ta gọi cái sự Đa Process này là Multiprocess.

Khi một Process được tạo, ứng dụng bên trong Process đó có thể tạo ra nhiều Thread khác. Về cơ bản thì Thread cũng sẽ được hệ thống quản lý như Process vậy, tức là chúng có thể được chạy song song với nhau, nên mới có thêm khái niệm Multithread. Nhưng các Thread bên trong một Process chỉ được hoạt động bên trong giới hạn của Process đó thôi. Các Thread sẽ được sử dụng các tài nguyên y như là Process của nó được phép. Nhưng có một khác biệt đó là các Thread rất nhẹ và có thể dễ dàng chia sẻ tài nguyên dùng chung với nhau bên trong một Process.

Như vậy thôi. Và mặc dù lan man nhiều khái niệm như vậy, nhưng bạn nên nhớ, bài học này chúng ta chỉ quan tâm đến một loại khái niệm mà thôi, đó chính là Thread.

Khi Nào Thì Sử Dụng Thread

Câu hỏi này tuy dễ trả lời, song nếu suy nghĩ kỹ nó lại chứa đựng nhiều thông tin hơn chúng ta tưởng.

Đầu tiên như mình có nói đến một cách giông dài trên kia rằng, nếu như bạn muốn có nhiều tác vụ muốn làm việc đồng thời với nhau, thì hãy sử dụng các Thread. Chẳng hạn bạn vừa muốn ứng dụng lấy thông tin người dùng nhập gì vào khung hội thoại, vừa gọi lên server để kiểm tra kết quả nhập vào. Hay nếu bạn lập trình game, bạn vừa muốn game nhận tín hiệu điều khiển từ bàn phím, song song với việc vẽ nhân vật trên màn hình theo sự điều khiển đó, song song với việc điều khiển các chướng ngại vật, hoặc điều khiển các đường đạn bắn, các hiển thị về điểm số, v.v…

Ý thứ hai trong việc sử dụng Thread là, cũng na ná như việc muốn xử lý các tác vụ đồng thời, nhưng khi này bạn mong muốn được điều khiển các Luồng theo một sự đồng bộ nhất định. Chẳng hạn bạn khởi động nhiều Thread trong một ứng dụng, nhưng bạn muốn các Thread khác tuy được khởi động nhưng khoan hãy chạy, mà phải đợi một Thread nào đó kết thúc thì mới được hoạt động. Ở Tập 3 của loạt bài về Thread mình sẽ nói rõ cách sử dụng này của Thread.

Ý thứ ba trong việc sử dụng Thread, cũng không ngoài việc muốn xử lý các tác vụ đồng thời, nhưng khi này là tình huống khi bạn muốn ứng dụng thực thi các tác vụ quá lớn. Lớn ở đây thường là lớn về mặt thời gian. Ví dụ khi ứng dụng phải download một file từ server, hay phải thực hiện công việc gì đó mà thời gian hoàn thành của nó không phải tức thời. Khi đó nếu không có Thread, các tác vụ lâu lắc này có thể sẽ làm ảnh hưởng nghiêm trọng đến trải nghiệm của người dùng.

Chúng ta sẽ đến phần ví dụ về Thread thông qua bài thực hành sau đây để giúp bạn hiểu rõ hơn.

Thực Hành Xây Dựng Ứng Dụng Quay Số Ngẫu Nhiên

Bài thực hành này giúp bạn có một cảm quan ban đầu về việc Thread là gì và nó hoạt động như thế nào. Mình không mong muốn các bạn phải hiểu hết các dòng code của bài thực hành này, nhưng mình hi vọng các bạn sẽ thử code, và thực thi thành công chương trình. Nếu có bất kỳ lỗi nào phát sinh xảy ra với bạn thì cứ liên lạc với mình hoặc để lại comment ở dưới bài viết hôm nay nhé.

Bạn cứ tưởng tượng rằng bài thực hành này đang xây dựng một trò chơi. Trong trò chơi này có một bàn xoay, trên đó có đánh các con số từ 0 đến 100. Người chơi sẽ bắt đầu xoay bàn xoay, sau khi bàn xoay được xoay thì người chơi chẳng nhìn thấy các con số trên đó, cho đến khi họ dừng bàn xoay lại, thì con số nào ở ngay người chơi chính là con số được người chơi chọn lựa.

Ồ, ứng dụng của chúng ta sẽ không xây dựng theo kiểu một trò chơi hoàn chỉnh như thế đâu. Thay vào đó chúng ta sẽ tập trung vào logic của trò chơi. Chúng ta sẽ thay việc xoay bàn xoay bằng một lệnh gõ vào ký tự bất kỳ trên console. Sau khi nhận ký tự vừa gõ, thay vì có một bàn xoay xoay tít, thì chúng ta sẽ khởi chạy một Thread, Thread này sẽ lần lượt “quay” các con số, sao cho nó đếm tuần tự từ 0 đến 100 rồi lại quay lại số 0. Thread của chúng ta chạy mãi cho đến khi người chơi nhập thêm một ký tự từ console và Thread kết thúc. Con số mà Thread vừa “dừng” lại chính là số được người chơi chọn lựa.

Bạn đã hiểu yêu cầu của việc xây dựng trò chơi này rồi đúng không nào. Để bắt đầu vào xây dựng trò chơi, bạn nên tự tạo một project mới bằng Eclipse hoặc InteliJ. Bạn có thể đặt tên cho project này là ThreadLearning cũng được. Sau đó tạo mới một class MainClass có chứa phương thức main() trong đó.

Tiếp theo, bạn hãy tự tạo mới một lớp mới có tên CountTheNumberThread. Khoan hãy code gì cho lớp này cả. Lớp này chính là Thread với trách nhiệm “quay vòng” các con số như chúng ta đã bàn đến. Tổng quan thì ứng dụng của chúng ta có cấu trúc như hình sau.

Cấu trúc các lớp trong ứng dụng
Cấu trúc các lớp trong ứng dụng

Nào giờ bạn hãy code cho thân lớp CountTheNumberThread như sau.

public class CountTheNumberThread extends Thread {
 
private int count = 0;
private boolean isStop = false;
 
@Override
public void run() {
while (!isStop) {
count++;
 
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
if (count > 100) {
count = 0;
}
}
}
 
public void end() {
isStop = true;
}
 
public int getCount() {
return count;
}
}

Dù cho có thể bạn chưa hiểu rõ về Thread, nhưng mình cũng muốn giải thích một chút, để đến bài học sau bạn sẽ dễ dàng tiếp cận với Thread hơn.

Đầu tiên thì lớp CountTheNumberThread muốn thành một Thread thì nó phải kế thừa từ lớp Thread (và còn có cách khác nữa để biến một lớp thành Thread mà bài sau chúng ta sẽ nói đến). Sau đó bên trong lớp này phải override phương thức run(). Chính các code bên trong phương thức run() sẽ là một Luồng, Luồng này sẽ được hệ thống thực thi song song với các Luồng đang thực thi khác nếu có trong hệ thống.

Trong CountTheNumberThread còn sử dụng phương thức Thread.sleep(100). Phương thức này làm cho các Thread đang chạy trở nên “ngủ”  trong một khoảng thời gian được tính bằng mili giây rồi “thức dậy” và tiếp tục chạy với vòng lặp mới. Tại sao chúng ta lại cho Thread ngủ, vì cơ bản vòng lặp while được chạy với tốc độ rất nhanh, chúng ta muốn mỗi while sẽ chậm nhịp lạ một chút (100 mili giây) để sau này thuộc tính isStop bên trong CountTheNumberThread sẽ dễ được thay đổi hơn, giảm thiểu lỗi xảy ra như ở các đoạn comment bên dưới bài học hôm nay có nói đến. Chú ý là bạn phải try catch phương thức Thread.sleep() này với một Checked Exception có tên InteruptedException. Và mình sẽ nói rõ về phương thức Thread.Sleep() ở bài sau nhé.

Vậy thôi, ngoài các ý lớn mình nói đến như này ra thì các khai báo còn lại của CountTheNumberThread đều quá đỗi quen thuộc nên mình không trình bày nhiêu khê.

Đến lúc này thì CountTheNumberThread cũng chỉ là một lớp mà thôi. Muốn khởi chạy lớp này để trở thành một Thread thì mình mời bạn đến với các code ở phương thức main() như sau.

public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
 
// Đợi người dùng nhấn một phím để bắt đầu
System.out.println("Nhấn phím bất kỳ để quay số");
scanner.nextLine();
 
// Khai báo & Khởi chạy CountTheNumberThread như là một Thread thông qua
// phương thức start()
CountTheNumberThread countingThread = new CountTheNumberThread();
countingThread.start();
 
// Đợi người dùng nhấn một phím để kết thúc
System.out.println("Nhấn phím bất kỳ để kết thúc quay số");
scanner.nextLine();
 
// Ngừng Thread và xem hiện đang "quay" tới số nào
countingThread.end();
System.out.println("Con số may mắn: " + countingThread.getCount());
}

Nhìn vào code trên, bạn có thể thấy chút khác biệt giữa việc sử dụng một Thread so với một lớp bình thường khác. Nếu như việc khởi tạo một lớp bình thường mà bạn biết so với một Thread là như nhau. Thì với một Thread, để khởi chạy Thread đó (tức là bạn biến nó thành một Luồng trong hệ thống), bạn phải gọi phương thức start() của nó, đây là phương thức được xây dựng sẵn ở lớp Thread. Khi start() được gọi, Thread cha sẽ khởi chạy các code bên trong phương thức run() mà bạn đã khai báo bên trong CountTheNumberThread. Và thế là việc đón nhận dữ liệu gõ vào từ bàn phím ở phương thức main() sẽ song song với vòng lặp bên trong phương thức run() này, không code nào phải chờ đợi code nào cả.

Nếu bạn thực thi chương trình, hãy nhập một ký tự vào console để bắt đầu “quay số” và nhập một lần nữa để kết thúc, bạn sẽ nhận được một số may mắn của riêng bạn như sau.

Kết quả thực hành với Thread
Kết quả thực hành với Thread

Opps! Mình quay ra số 69, còn bạn thì sao. Chúc bạn quay tay may mắn. 😉

Bài Tập

Với bài tập này mình muốn bạn hãy thử bỏ qua việc kế thừa Thread của lớp CountTheNumberThread, khi đó thì phương thức run() của lớp này cũng không còn từ khóa override nữa, như vậy bạn đã biến lớp này thành một lớp bình thường và run() cũng chỉ là một phương thức bình thường khác. Rồi sau đó ở main() bạn đừng gọi countingThread.start() nữa mà hãy gọi countingThread.run(). Tức là bạn không sử dụng Thread trong trường hợp này. Bạn hãy thực thi lại ứng dụng và so sánh xem việc áp dụng Thread với trò chơi này sẽ khác với việc không áp dụng Thread sẽ như thế nào nhé.

Chúng ta vừa mới tiếp cận kiến thức khá mới mẻ và thú vị của Java về vấn đề Đa nhiệm, cụ thể là Thread. Bài hôm nay chỉ mới là các kiến thức làm quen ban đầu, Thread còn rất nhiều kiến thức thú vị khác mà mình sẽ lần lượt trình bày ở các bài viết sắp tới nữa.

Thread Tập 2 – Các Cách Thức Để Tạo Một Thread

Sau khi tập 1 về Thread ra lò, mình nhận được nhiều chia sẻ và phản hồi từ các bạn. Mình cảm nhận được mối quan tâm rất lớn của các bạn với kiến thức này. Điều này thật sự thú vị. Thực ra mình cũng từng rất thích thú khi tiếp cận với Thread. Tuy chỉ là một kiến thức nhỏ nhoi trong biển kiến thức Java, nhưng Thread như mang đến một làn gió mới, một khả năng mới để chúng ta xây dựng các ứng dụng đa nhiệm, mạnh mẽ, thiết thực hơn, tận dụng tối đa hiệu năng của hệ thống hơn. Và đặc biệt hơn nữa, sau khi biết đến Thread là gì, thì chúng ta đã có thể bắt tay vào tìm hiểu các kiến thức về xây dựng một game viết bằng Java được rồi đấy.

Vậy hôm nay, chúng ta sẽ tiếp tục củng cố cái sự quan tâm đối với Thread bằng cách đi cụ thể hơn về nó, chúng ta sẽ nói đến các cách thức để khai báo và khởi tạo một Thread.

Tạo Một Thread

Ở bài hôm trước bạn cũng đã làm quen với một cách để tạo ra một Thread rồi. Nhưng mình mong muốn bài hôm nay bạn hãy… quên kiến thức bài trước đi, chúng ta cùng đi lại từ đầu cho nó hệ thống nào.

Trong Java, có hai cách để bạn tạo một Thread. Tuy cả hai cách đều giúp bạn tạo ra một Thread, nhưng mỗi cách lại có những mục đích và lợi ích khác nhau. Nhiệm vụ của bạn là phải biết rõ cả hai cách này. Tại sao phải biết cả hai cách? Ngoài việc bạn phải biết hết để có thể khai báo và sử dụng, bạn còn phải biết để còn đọc hiểu source code của người khác khi họ không dùng giống bạn nữa.

Vậy hai cách để tạo ra Thread là gì.

Cách 1 – Kế Thừa Từ Lớp Thread

Cách này ở bài hôm trước… ồ mình đã kêu các bạn quên đi rồi mà. Vậy thì, với cách này bạn làm như sau.

  • Bạn tạo mới một lớp và kế thừa lớp này từ lớp cha Thread.
  • Trong lớp mới tạo đó, bạn override phương thức run().
  • Cuối cùng, ở nơi khác, khi muốn tạo ra một Thread từ lớp này, bạn khai báo đối tượng cho nó, rồi gọi đến phương thức start() của nó để bắt đầu khởi chạy Thread.

Thật đơn giản đúng không nào. Không ngờ kiến thức về Thread lại dễ đến vậy. Như bạn đã biết sơ qua từ bài hôm trước rằng, để khai báo một lớp là Thread, thì đơn giản chỉ kế thừa nó từ lớp cha Thread, chính phương thức run() bên trong lớp đó sẽ trở thành một Luồng xử lý bởi hệ thống khi đâu đó bên ngoài gọi đến phương thức start() của lớp này.

Chúng ta cùng đến với bài thực hành để hiểu rõ hơn.

Thực Hành Tạo Một Thread Bằng Cách Kế Thừa Từ Lớp Thread

Bài thực hành này chúng ta thử nghiệm tạo một Thread đếm ngược 10 giây. Khi start, Thread sẽ bắt đầu in ra console giá trị 10, mỗi một giây trôi qua Thread sẽ giảm con số này đi một đơn vị và lại in ra console, đến khi giảm đến giá trị 0 Thread sẽ in “Hết giờ”.

Bạn có thể sử dụng lại project đã tạo từ bài hôm trước, hôm nay bạn tạo một lớp mới có tên CountDownThread. Lớp này sẽ kế thừa từ lớp Thread như những gì mình đã nói ở các gạch đầu dòng trên kia như sau.

public class CountDownThread extends Thread {
 
@Override
public void run() {
// Bước sau chúng ta sẽ code thêm
}
}

Một khung sườn cho Thread chỉ như vậy thôi. Như đã nói, CountDownThread khi được start sẽ bắt đầu đếm ngược từ 10 giây, đến 0 giây sẽ hiển thị chuỗi “Hết giờ”. Việc hiển thị số giây ra console thì bạn biết rồi, mình chỉ bật mí là để làm cho con số này chỉ được cập nhật và hiển thị ở mỗi giây thì chúng ta sử dụng phương thức Thread.sleep(1000). Phương thức này bạn đã làm quen từ bài hôm trước rồi. Mình nhắc lại một tí, nó sẽ giúp làm cho các Thread đang chạy trở nên “ngủ” trong một khoảng thời gian được tính bằng mili giây, trong trường hợp này chúng ta truyền vào 1000 mili giây, tức là 1 giây. Sau khi ngủ hết thời lượng cho phép, Thread sẽ “thức dậy” và thực hiện tiếp tác vụ của nó. Chú ý là bạn phải try catch phương thức Thread.sleep() này với một Checked Exception có tên InteruptedException. Và mình sẽ nói rõ về phương thức Thread.Sleep() ở bài sau nhé. Còn đây là code hoàn chỉnh của CountDownThread.

public class CountDownThread extends Thread {
 
@Override
public void run() {
int count = 10;
for (int i = count; i > 0; i--) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println("Hết giờ");
}
}

Để khởi chạy Thread vừa tạo thì chúng ta sẽ gọi phương thức start() của nó như sau.

public static void main(String[] args) {
CountDownThread countDownThread = new CountDownThread();
countDownThread.start();
}

Còn đây là màn hình console của “bộ đếm giờ” mà chúng ta vừa tạo. Cứ mỗi một giây sẽ có một con số xuất hiện cho đến khi chữ “Hết giờ” xuất hiện cuối cùng sẽ là lúc kết thúc chương trình (và kết thúc cả CountDownThread).

Kết quả in ra chương trình của Thread đếm ngược
Kết quả in ra chương trình của Thread đếm ngược

Cách 2 – Impement Từ Interface Runnable

Nếu như cách trên kia thì bạn phải kế thừa từ lớp Thread, thì cách này bạn lại implement một interface có tên Runnable. Với cách này bạn làm như sau.

  • Bạn tạo mới một lớp và implement lớp này với interface có tên Runnable.
  • Trong lớp mới tạo đó, bạn override phương thức run().
  • Cuối cùng, ở nơi khác, khi muốn tạo ra một Thread từ lớp này, trước hết bạn khai báo đối tượng cho nó, rồi bạn khai báo thêm một đối tượng của Thread nữa và truyền đối tượng của lớp này vào hàm khởi tạo của Thread. Khi phương thức start() của lớp Thread vừa tạo được gọi đến, thì phương thức run() bên trong lớp dẫn xuất của Runnable sẽ được gọi để tạo thành một Luồng trong hệ thống.

Nghe có vẻ phức tạp hơn cách thứ nhất trên kia đúng không nào. Nhưng bạn cũng nên thử qua cho biết bằng cách đến với bài thực hành sau.

Thực Hành Tạo Một Thread Bằng Cách Implement Từ Interface Runnable

Chúng ta vẫn sẽ xây dựng lại ví dụ về một Thread đếm ngược 10 giây trên kia bằng cách thứ 2 này.

Với cách này thì bạn chỉ cần chỉnh sửa một tí ở lớp CountDownThread của bạn, sao cho từ extends Thread sang implements Runnable là xong. Bạn hãy xem code sau sẽ rõ.

public class CountDownThread implements Runnable {
 
@Override
public void run() {
int count = 10;
for (int i = count; i > 0; i--) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println("Hết giờ");
}
}

Vấn đề khai báo một Thread không khác nhau lắm giữa hai cách đúng không nào. Khác biệt nhiều hơn sẽ nằm ở cách khởi chạy Thread. Code ở phương thức main() sẽ phải thay đổi như sau.

public static void main(String[] args) {
CountDownThread countDownThread = new CountDownThread();
Thread thread = new Thread(countDownThread);
thread.start();
}

Bạn hãy thực thi lại chương trình. Kết quả hai cách làm này đều cho ra kết quả như nhau cả.

Áp Dụng Kiến Thức Lớp Vô Danh Trong Việc Tạo Mới Một Thread

Nếu bạn đã quên lớp Vô Danh là lớp gì rồi, thì có thể đọc lại bài học ở link này.

Còn nếu bạn thắc mắc Thread thì liên quan gì đến lớp Vô Danh? Thì mình sẽ giải thích sơ qua thế này. Cơ bản thì Thread được xem là một cách gọn nhẹ cho hệ thống (và cả chúng ta) để thực thi các tác vụ song song. Và để làm cho sự gọn nhẹ đó càng thêm gọn nhẹ (về mặt quản lý code), thì việc kết hợp giữa Thread và lớp Vô Danh sẽ là giải pháp tốt cho ý này. Vì khi đó, chúng ta sẽ không cần thiết phải khai báo rõ ràng một lớp Thread nào cả, chỉ đơn giản là dựng lên một lớp Vô Danh, và start nó thôi.

Việc kết hợp giữa Thread và lớp Vô Danh là khá phổ biến, và người ta đã gộp 2 cái tên này lại thành một tên chung, gọi là Thread Vô Danh (Anonymous Threads).

Nào chúng ta cùng xem các cách sau để “vô danh hóa” một Thread. Mình dùng lại ví dụ Thread đếm ngược trên kia để bạn xem nhé.

Tạo Một Thread Vô Danh Từ Việc Kế Thừa Lớp Thread

Nào, chúng ta cùng tạo lại một Thread từ việc kế thừa lớp Thread, nhưng “vô danh hóa” nó như sau.

public static void main(String[] args) {
Thread countDownThread = new Thread() {
@Override
public void run() {
int count = 10;
for (int i = count; i > 0; i--) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println("Hết giờ");
}
};
countDownThread.start();
}

Đấy, trên đây là một Thread Vô Danh. Đến đây sẽ có nhiều bạn thắc mắc rằng Thread Vô Danh thực chất có giúp làm gọn hơn cho việc quản lý code hay không. Thì mình có vài ý muốn trao đổi thêm như sau.

Thực ra việc sử dụng Thread Vô Danh so với Thread bình thường có làm code trở nên gọn hay không cũng tùy vào cách nhìn code của mỗi người thôi. Bạn xem, với bài thực hành xây dựng một Thread bình thường trên kia (mình sẽ gọi tắt Thread-bình-thường là Thread), bạn phải xây dựng một lớp CountDownThread.java hẳn hoi, code này có cái hay là rất tường minh. Còn với code ví dụ ở mục này, chúng ta đã tạo ra một đối tượng countDownThread không phải từ lớp CountDownThread hay từ lớp Thread, mà là từ một lớp Vô Danh kế thừa từ lớp Thread nhé. Cách khai báo này giúp giảm đi việc phải tạo ra một file Java nào khác, chúng ta chỉ đơn giản khai báo và dùng thôi, ngoài ra thì Thread Vô Danh còn dùng được các thành viên của lớp chứa nó nữa.

Điểm khác biệt nữa giữa việc khai báo một Thread và một Thread Vô Danh là, với Thread bạn có thể xây dựng constructor cho nó, nên bạn có thể truyền vào Thread các biến nào đó phục vụ cho logic của ứng dụng. Còn Thread Vô Danh thì không có constructor, nên bạn có thể phải dùng biến toàn cục của lớp khai báo.

Nhưng bạn cũng nên cân nhắc, dù cho cách sử dụng Thread Vô Danh khá là nhanh chóng và tiện lợi, chúng có thể sẽ làm code ở lớp sử dụng này phình lên, khó quản lý hơn nếu có quá nhiều Thread Vô Danh như thế này đấy nhé.

Quay lại kiến thức của Thread Vô Danh, với code trên đây, chúng ta còn có thể viết gọn hơn lại nữa cơ. Bằng việc không cần phải khai báo tên đối tượng, mà có thể start() luôn, như thế này.

public static void main(String[] args) {
new Thread() {
@Override
public void run() {
int count = 10;
for (int i = count; i > 0; i--) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println("Hết giờ");
}
}.start();
}

Tạo Một Thread Vô Danh Bằng Cách Implement Từ Interface Runnable

Nếu bạn hiểu Thread Vô Danh từ cách kế thừa lớp Thread trên kia, thì việc tạo một Thread Vô Danh từ interface Runnable có lẽ bạn cũng có thể tự viết được.

public static void main(String[] args) {
Runnable countDownThread = new Runnable() {
@Override
public void run() {
int count = 10;
for (int i = count; i > 0; i--) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println("Hết giờ");
}
};
Thread thread = new Thread(countDownThread);
thread.start();
}

Code này cũng có thể viết ngắn gọn hơn bằng cách bỏ đi khai báo đối tượng từ lớp Thread như sau.

public static void main(String[] args) {
Runnable countDownThread = new Runnable() {
@Override
public void run() {
int count = 10;
for (int i = count; i > 0; i--) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println("Hết giờ");
}
};
new Thread(countDownThread).start();
}

Hoặc có thể ngắn gọn hơn nữa khi không cần khai báo đối tượng của Thread Vô Danh. Nhưng khi này bạn phải truyền lớp Vô Danh này vào Thread như là một tham số. Như sau.

public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
int count = 10;
for (int i = count; i > 0; i--) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println("Hết giờ sss");
}
}).start();
}

Hi vọng kiến thức về Thread Vô Danh không làm bạn quá đau đầu. Mình mời các bạn cùng đến với bài tập sau đây để có thể “quen tay” hơn trong việc tạo ra một Thread.

Bài Tập 1: Tạo Trò Chơi 2 Thread Cùng Đoán Số

Yêu cầu bài tập như sau. Bạn hãy tạo ra một trò chơi để người dùng có thể nhập vào một số nguyên trong khoảng từ 1 đến 100. Sau đó bạn xây dựng một Thread đoán số, Thread này sẽ đạo ra các con số random cũng trong khoảng 1 đến 100 đó. Cứ mỗi lần random được một số, Thread sẽ in ra console cho người chơi có thể nhìn thấy. Thread sẽ dừng lại khi random ra một số trùng với số mà người chơi vừa nhập, đồng thời in ra số lần “đoán” để ra được con số đó.

Lưu ý rằng, có 2 Thread cùng đoán số, để “thi thố” xem Thread nào “đoán” ra con số của người chơi nhanh nhất.

Để dễ hình dung hơn, mình đưa ra kết quả console dự kiến của trò chơi sẽ như thế này. Kết quả này dựa trên sự “đoán” cật lực của 2 Thread, để có thể biết được người chơi đã nhập vào con số 15. Và như hình thì Thread 2 đã thắng với 68 lần đoán. Thread 1 “kém thông minh” hơn và tiếp tục đoán cho đến lần đoán thứ 360.

Mô phỏng trò chơi hai Thread cùng đoán số
Mô phỏng trò chơi hai Thread cùng đoán số

Mình có hai gợi ý để bạn chỉ tập trung vào code cho Thread thôi, đỡ phải lăn tăn tìm hiểu code khác trên mạng.

  • Để random một con số từ 1 đến 100, bạn code: 
randomNumber = (int) (Math.random() * 100 + 1);
  • Thread cha có một phương thức setName() để bạn đặt tên cho Thread đang chạy, vì vậy bạn có thể tận dụng để đặt các tên “Thread 1”“Thread 2”. Rồi bạn có thể in ra console bằng cách gọi đến tên đã đặt bằng phương thức getName().

Xong rồi, mời bạn code.

Sau khi code xong, bạn có thể so sánh với đáp án của mình. Đầu tiên là Thread đoán số, mình đặt tên nó là GuessANumberThread.

public class GuessANumberThread extends Thread {
 
private int guessNumber = 0;
private int count = 0;
 
public GuessANumberThread(int guessNumber) {
this.guessNumber = guessNumber;
}
 
@Override
public void run() {
int randomNumber = 0;
do {
randomNumber = (int) (Math.random() * 100 + 1);
count++;
System.out.println(getName() + " đoán số " + randomNumber);
 
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
} while (randomNumber != guessNumber);
 
System.out.println(getName() + " đã đoán ra số " + guessNumber + " trong " + count + " lần đếm");
}
}

Còn đây là nơi kêu người dùng nhập vào một con số cần đoán, và tạo ra 2 Thread để thi thố. Nơi này chính là phương thức main().

public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("Nhập một số nguyên để các thread đoán: ");
int number = scanner.nextInt();
 
GuessANumberThread thread1 = new GuessANumberThread(number);
GuessANumberThread thread2 = new GuessANumberThread(number);
 
thread1.setName("Thread 1");
thread2.setName("Thread 2");
 
thread1.start();
thread2.start();
}

Kết quả thực thi chương trình sẽ như hình trên kia.

Bài Tập 2: Làm Lại Bài Tập 1 Với Runnable

Bạn hãy thử code lại trò chơi đoán số trên đây bằng Thread implement từ Runnable nhé.

Chỉ có một lưu ý cho bạn dễ code rằng, để có thể gọi đến tên của Thread implement từ Runnable, thì không thể cứ gọi getName() như Bài tập 1 được, mà bạn phải gọi Thread.currentThread().getName().

Bài Tập 3: Làm Lại Bài Tập 1 Với Thread Vô Danh

Lần này bạn hãy code lại trò chơi đoán số này bằng Thread Vô Danh xem sao nhé.

Đây là cách mình làm, cách của các bạn thế nào.

 
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("Nhập một số nguyên để các thread đoán: ");
int guessNumber = scanner.nextInt();
 
Runnable anonymousGuessANumber = new Runnable() {
 
@Override
public void run() {
int randomNumber = 0;
int count = 0;
do {
randomNumber = (int) (Math.random() * 100 + 1);
count++;
System.out.println(Thread.currentThread().getName() + " đoán số " + randomNumber);
 
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
} while (randomNumber != guessNumber);
 
System.out.println(Thread.currentThread().getName() + " đã đoán ra số " + guessNumber + " trong " + count + " lần đếm");
}
};
 
Thread thread1 = new Thread(anonymousGuessANumber);
thread1.setName("Thread 1");
 
Thread thread2 = new Thread(anonymousGuessANumber);
thread2.setName("Thread 2");
 
thread1.start();
thread2.start();
}
 

Trên đây là tất cả các cách để bạn có thể tạo ra một Thread. Các bạn thấy thế nào, Thread có dễ dùng hay không. Hãy để lại comment bên dưới bài học nếu bạn có bất cứ thắc mắc nào nhé.

Thread Tập 3 – Vòng Đời Của Thread

Bước sang phần này của Thread, chúng ta cùng tìm hiểu sâu hơn về Thread, để xem khi bạn tạo ra một Thread nào đó, thì vòng đời của nó sẽ như thế nào? Thread đó sẽ trải qua những trạng thái nào trong vòng đời đó? Dựa vào các trạng thái đó, làm sao để các Thread có thể đồng bộ hoá, hay có thể hiểu là tự điều chỉnh độ ưu tiên trong việc thực thi tác vụ giữa các Thread trong cùng một Process với nhau? Mời bạn cùng đến với những kiến thức thú vị này hôm nay.

Trước hết, chúng ta cùng trả lời thắc mắc đầu tiên.

Vòng Đời Của Một Đối Tượng Là Gì?

Nếu đã làm quen với Android, thì bạn đã từng biết đến khái niệm “vòng đời” này rồi, như Vòng đời ActivityVòng đời Fragment. Mình nhắc lại một chút thôi, đó là sở dĩ chúng ta xem xét “vòng đời” của một đối tượng nào đó, là khi mà đối tượng đó có một thời gian sống nhất định, và trong quá trình sống của đối tượng đó chúng ta muốn biết nó có thể sẽ trải qua nhiều trạng thái khác nhau như thế nào. Các trạng thái đó có phải là Sinh, Lão, Bệnh, Tử hay không? ^^

Tóm cái gì đó lại là, không phải cái gì chúng ta cũng đều nói về vòng đời của nó cả, chỉ những đối tượng có thời gian sống đủ lâu, và trải qua nhiều trạng thái trong quá trình sống, thì chúng ta mới xem xét đến vòng đời của nó thôi, như Activity và Fragment bên kiến thức Android, và Thread trong kiến thức Java này chẳng hạn.

Vậy tìm hiểu vòng đời, hay các trạng thái bên trong một vòng đời để làm gì.

Tại Sao Nên Tìm Hiểu Vòng Đời Của Một Đối Tượng?

Một lý do chính đáng nhất để chúng ta nên biết về vòng đời của một đối tượng nào đó, ngoài việc nó được sinh ra khi nào, và bị chết khi nào. Thì sự hiểu các trạng thái mà đối tượng đó trải qua trong quá trình sống đó cũng khá là quan trọng. Khi bạn nắm được các trạng thái của một vòng đời, bạn sẽ hiểu về đối tượng đó nhiều hơn, từ đó bạn có thể dễ dàng can thiệp vào nó, chèn vào các trạng thái đó các tác vụ tương ứng phù hợp nhất. Mục đích cuối cùng là làm cho ứng dụng của chúng ta trở nên mạnh mẽ hơn, và thậm chí, thông minh hơn nữa kìa. Chi tiết như thế nào mời các bạn cùng xem tiếp.

Tìm Hiểu Vòng Đời Của Thread

Nào chúng cùng quay lại phần chính của bài học, và cùng nhau tìm hiểu vòng đời của Thread. Trước hết mời bạn xem sơ qua vòng đời này thông qua sơ đồ sau.

Sơ Đồ Minh Họa Vòng Đời Của Thread

Sơ đồ mô tả vòng đời của Thread
Sơ đồ mô tả vòng đời của Thread

Sơ đồ này dựng lên dựa trên các trạng thái đã được định nghĩa bên trong khai báo enum của lớp Thread. Enum là gì thì chúng ta sẽ nói đến ở bài học sau. Cơ bản thì bạn cứ hiểu enum giúp chúng ta định nghĩa ra một tập hợp các hằng số vậy, và trong tình huống này các hằng số này cũng chính là các trạng thái của vòng đời Thread.

Bạn có thể vào trong lớp Thread để xem việc khai báo các giá trị bên trong một enum là như thế nào. À nhiều bạn hỏi mình chỗ này, nên mình bổ sung luôn, với Eclipse hay InteliJ, khi muốn vào xem source code của một lớp trong thư viện, bạn hãy nhấn giữ Ctrl đối với Windows (Cmd đối với Mac) rồi click vào lớp cần xem nhé, IDE sẽ dẫn bạn sang một tab mới với source code đầy đủ.

Các trạng thái chính bên trong vòng đời của Thread
Các trạng thái chính bên trong vòng đời của Thread

Mô Tả Vòng Đời Của Thread

Như mình có nói, sơ đồ hay các enum bên trong một Thread đã thể hiện rõ nhất các trạng thái bên trong một vòng đời của Thread này. Chúng được mô tả một cách tổng quát như sau.

Ngay khi bạn tạo mới một Thread, nhưng vẫn chưa gọi đến phương thức start(), trạng thái của nó sẽ là NEW.

Trạng thái NEW
Trạng thái NEW

Còn khi bạn đã gọi đến start(), Thread đó sẽ vào trạng thái RUNNABLE, trạng thái này đưa Thread vào hàng đợi để đợi hệ thống cấp tài nguyên và khởi chạy sau đó.

Trạng thái RUNNABLE
Trạng thái RUNNABLE

Trong quá trình Thread đang chạy, nếu có bất kỳ tác động nào, ngoại trừ làm kết thúc vòng đời của Thread, nó sẽ vào trạng thái BLOCKED, hoặc WAITING, hoặc TIMED_WAITING.

Các trạng thái Non-Runnable
Các trạng thái Non-Runnable

Cuối cùng, khi một Thread kết thúc, nó đến trạng thái TERMINATED.

Trạng thái TERMINATED
Trạng thái TERMINATED

Tổng quan là vậy, còn chi tiết từng trạng thái thì mình mời các bạn đến với mục tiếp theo sẽ rõ.

Các Trạng Thái Bên Trong Một Vòng Đời

Mục này chúng ta sẽ xem kỹ từng trạng thái một. Cái chính của mục này đó là giúp chúng ta hiểu được khi nào mà một Thread rơi vào một trạng thái nào đó. Qua đó bạn có thể tận dụng cho các mục đích cụ thể ở các project cụ thể của bạn sau này.

NEW

Trạng thái này rất dễ hiểu, khi bạn khởi tạo một Thread, nhưng vẫn chưa gọi đến phương thức start() của nó, thì Thread này sẽ rơi vào trạng thái NEW. Không tin à, mời bạn đến bài thực hành sau.

Bài Thực Hành Số 1

Ở bài thực hành đầu tiên này, chúng ta cùng xem trạng thái khi mà một Thread được khởi tạo nhưng phương thức start() vẫn chưa được gọi có phải là NEW hay không.

Để có thể xem được trạng thái của một Thread, chúng ta sẽ gọi đến phương thức getState(). Phương thức này được xây dựng sẵn ở lớp cha Thread.

Bạn hãy tạo mới một Thread nhé. Tạo bằng cách kế thừa từ lớp Thread hay implement từ interface Runnable cũng được. Hãy đặt tên Thread này là MyThread. Đây là cách mình xây dựng MyThread.

public class MyThread extends Thread {
 
@Override
public void run() {
System.out.println("Thread Start");
}
}

Để xem được trạng thái NEW này, ở phương thức main() bạn hãy khai báo MyThread rồi in ra ngay getState() mà không cần phải start() nó.

public static void main(String[] args) {
MyThread myThread = new MyThread();
System.out.println(myThread.getState());
}

Và đây là “thành phẩm”.

Kết quả in ra trạng thái NEW
Kết quả in ra trạng thái NEW

RUNNABLE

Trạng thái này xảy ra khi Thread đã được gọi phương thức start(). Ồ, bạn cũng nên biết một chút rằng không phải start() xong là Thread được chạy ngay đâu, nó còn phải chờ đợi hệ thống cấp phát tài nguyên xong xuôi thì mới bắt đầu chạy. Chính vì vậy mà bên trong trạng thái này dường như chia ra làm 2 trạng thái con, đó là, Ready to Run – Chờ đợi cấp phát tài nguyên, và Running – Đã chính thức chạy.

Bài Thực Hành Số 2

Bài này chúng ta sẽ thử gọi phương thức start() của MyThread ở Bài thực hành số 1 trên kia. Rồi cũng gọi đến phương thức getState() ngay sau đó để xem trạng thái của MyThread lúc này là gì nhé.

Code ở phương thức main() như sau.

public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
System.out.println(myThread.getState());
}

Kết quả in ra console.

Kết quả in ra trạng thái RUNNABLE
Kết quả in ra trạng thái RUNNABLE

Lưu ý rằng không phải lúc nào code trên đây cũng luôn in trạng thái RUNNABLE ra console đâu nhé. Vì sao vậy? Bạn có thể thấy rằng bên trong MyThread chỉ có in ra console chuỗi “Thread Start” thôi, sau khi in xong chuỗi này Thread sẽ kết thúc vòng đời của nó ngay. Do đó có trường hợp geState() ở phương thức main() sẽ gọi khi MyThread đã kết thúc rồi, nên RUNNABLE có thể sẽ không được in ra (mà là một trạng thái nào đó khác ở các mục sau bạn sẽ rõ) là vậy.

BLOCKED

Một Thread khi rơi vào trạng thái BLOCKED là khi nó không có đủ điều kiện để chạy. Không đủ điều kiện để chạy là như thế nào? Bạn có thể hiểu là, bản chất các Thread trong một ứng dụng đều có khả năng chạy song song khi chúng được start(). Như vậy thì sẽ xảy ra trường hợp cùng một thời điểm nào đó, sẽ có nhiều hơn một Thread đều có “mưu đồ” muốn chỉnh sửa một File hay một đối tượng nào đó, chúng ta gọi tắt các File hay các đối tượng bị “tranh chấp” này là các tài nguyên dùng chung. Nếu có sự tranh chấp này xảy ra, sẽ khiến cho ứng dụng bị lỗi, có thể dẫn đến mất mát dữ liệu hoặc các tính toán sai lầm. Do đó, trong Java có một cơ chế giúp điều khiển các Thread, cơ chế này đảm bảo một thời điểm nào đó chỉ có một Thread có thể can thiệp vào tài nguyên dùng chung mà thôi. Cơ chế này liên quan đến khái niệm Synchronization (đồng bộ hoá) mà chúng ta sẽ nói đến ở Bài 45. Như vậy nếu có sự đồng bộ hoá này xảy ra, thì chỉ một Thread là được ưu tiên sử dụng đến tài nguyên dùng chung này, các Thread còn lại  bị khoá và phải đợi cho Thread ưu tiên kia sử dụng xong tài nguyên rồi mới được chạy, các Thread bị khoá này sẽ bị rơi vào trạng thái BLOCKED.

Như vậy để có thể nhìn thấy được trạng thái này, chúng ta sẽ làm quen trước một tí với kiến thức về Đồng bộ hoá ở bài thực hành sau, để rồi chúng ta sẽ nói kỹ hơn về nó sau nhé.

Bài Thực Hành Số 3

Để thực hành mục này, chúng ta hãy tạo ra một tài nguyên dùng chung, chính là một lớp nào đó, mình đặt tên lớp dùng chung này là DemoSynchronized. Trong lớp này có chứa một phương thức static có đánh dấu synchronized. Phương thức này có tên commonResource(). Bạn cũng đừng tập trung vào từ khoá synchronized quá, bài sau mình sẽ giải thích rõ. Bạn chỉ cần hiểu rằng phương thức commonResource() này được đánh dấu synchronized sẽ được hệ thống “bảo trợ” sao cho chỉ có một Thread được truy cập đến nó mà thôi.

Nào chúng ta cứ xây dựng trước lớp này nhé.

public class DemoSynchronized {
 
public static synchronized void commonResource() {
for (int i = 0; i < 100000; i++) {
// Không làm gì cả, chỉ chạy vòng lặp để đảm
// bảo phương thức này sống lâu một tí,
// để cho có Thread dùng đến và các Thread
// khác phải chờ đợi
}
}
}

Sau đó chúng ta để cho MyThread (đã code ở các bài thực hành trên đây) có cơ hội gọi đến commonResource(). Như sau.

public class MyThread extends Thread {
 
@Override
public void run() {
DemoSynchronized.commonResource();
}
}

Và rồi ở phương thức main(), chúng ta sẽ tạo nhiều hơn một đối tượng của MyThread, cụ thể là 2 đối tượng, bạn cũng có thể tạo ra 3, hay 4 MyThread để kiểm chứng. Sau khi tạo ra các MyThread, chúng ta đều cùng start() chúng, để chúng cùng lúc gọi đến commonResource() khi chạy. Sau đó bạn chỉ cần “ung dung” gọi getState() của chúng.

public class MainClass {
 
public static void main(String[] args) {
// Khai báo nhiều đối tượng của MyThread
MyThread myThread1 = new MyThread();
MyThread myThread2 = new MyThread();
 
// Đều start() hết các đối tượng MyThread
// để xem Thread nào sẽ được vào commonResource()
myThread1.start();
myThread2.start();
 
// In ra các trạng thái của chúng
System.out.println(myThread1.getName() + ": " + myThread1.getState());
System.out.println(myThread2.getName() + ": " + myThread2.getState());
}
}

Kết quả là, chỉ có một Thread lúc này là RUNNABLE thôi, còn lại sẽ đều là BLOCKED.

Kết quả in ra trạng thái BLOCKED của Thread-1
Kết quả in ra trạng thái BLOCKED của Thread-1

WAITING

Trạng thái này xảy ra khi một Thread phải đợi Thread nào đó hoàn thành tác vụ của nó, với một khoảng thời gian không xác định trước. Trạng thái này khác với trạng thái BLOCKED trên kia nhé, ở trên kia là các Thread bị hệ thống khoá lại khi cùng truy xuất chung đến một tài nguyên hệ thống. Còn trạng thái này là giữa các Thread tự điều đình với nhau. BLOCKED giống như các phương tiện bị chú cảnh sát giao thông chặn lại để nhường cho phương tiện được ưu tiên khác. Còn WAITING là tự các phương tiện tự nhường nhịn nhau, không cần phải có công an điều tiết ấy mà.

Do là các Thread sẽ nhường nhịn nhau, nên nếu một Thread nào đó có động thái gọi đến một trong các phương thức sau, nó sẽ “nhường” và tự rơi vào trạng thái WAITING này, các phương thức đó là.

  • Object.wait()
  • Thread.join()
  • LockSupport.park()

Một số phương thức trên đây sẽ được nói ở bài học sau. Còn bài hôm nay chúng ta thử thực hành với phương thức join(). Khi một Thread gọi đến phương thức join() của Thread khác, nó sẽ phải đợi Thread khác đó hoàn thành xong thì nó mới được thực hiện tiếp tác vụ còn lại của nó. Cụ thể về join() mình có viết ra ở đây cho bạn tìm hiểu kỹ hơn.

Chúng ta cùng đến với bài thực hành để hiểu rõ hơn về trạng thái này của Thread.

Bài Thực Hành Số 4

Trước hết chúng ta cùng chỉnh sửa một tí lớp MyRunnable như sau.

public class MyRunnable implements Runnable {
 
@Override
public void run() {
System.out.println("MyRunnable Start");
for (int i = 0; i < 100; i++) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println("MyRunnable End");
}
 
}

Code trên đây chưa liên quan gì đến việc đưa Thread vào trạng thái WAITING đâu nhé. Code này chỉ có in ra console cho thấy MyRunnable vừa được “Start”, rồi cho làm một việc nặng nặng nào đó, như lặp 100 lần, mỗi lần lặp sẽ ngủ 100 mili giây thôi. Cuối cùng sẽ in ra console cho thấy MyRunnable đã “End”.

Tiếp theo chúng ta đến với lớp MyThread. Ở MyThread này, khi được khởi chạy, chúng ta cố tình khai báo rồi khởi chạy MyRunnable luôn. Nhưng khi vừa mới khởi chạy MyRunnable, chúng ta gọi đến phương thức join() của nó. Điều này báo với hệ thống rằng, MyThread này sẽ đợi MyRunnable chạy hết (kết thúc vòng đời của MyRunnable) thì MyThread mới chạy tiếp.

Đây là code của MyThread.

public class MyThread extends Thread {
 
@Override
public void run() {
System.out.println("MyThread Start");
Thread myRunnableThread = new Thread(new MyRunnable());
myRunnableThread.start();
 
try {
myRunnableThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
 
System.out.println("MyThread End");
}
}

Sau đó ở phương thức main() bạn chỉ cần khởi chạy MyThread, đợi khoảng 100 mili giây thì in trạng thái của MyThread ra console để xem chơi. Sở dĩ phải đợi một tí mới in trạng thái của MyThread là vì để đảm bảo MyThread có đủ thời gian để khởi chạy MyRunnable nữa, rồi thời gian mà MyThread vào WAITING nhường cho MyRunnable nữa, bạn in vội quá thì khó mà trông thấy trạng thái của MyThread.

public static void main(String[] args) {
MyThread myThread = new MyThread();
 
myThread.start();
 
try {
Thread.sleep(100);
System.out.println("MyThread State: " + myThread.getState());
} catch (InterruptedException e) {
e.printStackTrace();
}
 
}

Kết quả in ra console như sau.

Kết quả in ra trạng thái WAITING
Kết quả in ra trạng thái WAITING

TIMED_WAITING

Cũng tương tự với WAITING trên kia thôi, nhưng khi này các phương thức khiến một Thread “nhường” cho một Thread khác thực thi có truyền vào đối số là khoảng thời gian mà Thread đó nhường. Các phương thức đó là.

  • Thread.sleep(long milis) hay Thread.sleep(long milis, int nanos)
  • Object.wait(int timeout) hay Object.wait(int timeout, int nanos)
  • Thread.join(long milis) hay Thread.join(long milis, int nanos)
  • LockSupport.parkNanos()
  • LockSupport.parkUtil()

Chà, phương thức sleep() quen thuộc lắm đúng không. Giờ thì bạn mới hiểu, rằng ở đâu đó trong Thread khi gọi đến Thread.sleep() này, thì Thread đó sẽ rơi vào trạng thái TIMED_WAITING và “nhường” cho các Thread khác chạy trong khoảng thời gian mili giây chỉ định trước.

Bạn có thể xem một vài phương thức đã được mình liệt kê cụ thể ở bài viết này.

Bài Thực Hành Số 5

Với bài thực hành này bạn chỉ cần chỉnh sửa một chút so với Bài thực hành 4 trên kia. Cái nơi mà MyThread khởi chạy MyRunnable rồi gọi join() để nhường cho MyRunnable í, giờ bạn hãy truyền giá trị mili giây vào phương thức join() này. Nó có nghĩa rằng là tuy MyThread có nhường MyRunnable chạy trước đấy, nhưng chỉ nhường với một khoản thời gian đã chỉ định thôi, hết thời gian đó là tao chạy, đụng ai thì đụng nhé.

public class MyThread extends Thread {
 
@Override
public void run() {
System.out.println("MyThread Start");
Thread myRunnableThread = new Thread(new MyRunnable());
myRunnableThread.start();
 
try {
myRunnableThread.join(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
System.out.println("MyThread End");
}
}

Nếu bạn thực thi chương trình, hãy để ý kỹ, sau khi in ra “MyThread State: TIMED_WAITING” rồi, MyThead sẽ đợi MyRunnable trong khoảng thời gian (chưa tới) 500 mili giây còn lại, và sẽ in ra “MyThread End”. Vấn đề là MyRunnable vẫn chưa kết thúc vòng lặp, nên mãi sau nó mới kết thúc và in ra “MyRunnable End”. Bạn có thấy sự nhịp nhàng giữa các Thread không nào.

Kết quả in ra trạng thái TIMED_WAITING
Kết quả in ra trạng thái TIMED_WAITING

TERMINATED

Trạng thái này đánh dấu sự kết thúc vòng đời của Thread. Xảy ra khi Thread kết thúc hết các tác vụ bên trong phương thức run() của nó, hoặc có những kết thúc một cách không bình thường khác, như rơi vào Exception chẳng hạn.

Bài Thực Hành Số 6

Code của bài này không có gì nhiều. Bạn cứ lấy code của Bài thực hành số 5 trên kia. Rồi ở phương thức main(), bạn sleep() lâu lâu một tí, nhằm mục đích đợi cho MyThread kết thúc tác vụ của nó rồi thì in trạng thái của nó ra. Mình cho thời gian ngủ là 20 giây, như sau.

public static void main(String[] args) {
MyThread myThread = new MyThread();
 
myThread.start();
 
try {
Thread.sleep(20000);
System.out.println("MyThread State: " + myThread.getState());
} catch (InterruptedException e) {
e.printStackTrace();
}
 
}

Kết quả in ra console như sau.

Kết quả in ra trạng thái TERMINATED
Kết quả in ra trạng thái TERMINATED

Kết Luận

Phù! Chúng ta vừa đi qua kiến thức về vòng đời của một Thread, qua đó chúng ta biết được một Thread sẽ trải qua các trạng thái của nó như thế nào trong suốt đời sống của nó. Chắc bạn cũng biết rằng không phải lúc nào một Thread cũng trải qua cả đủ các trạng thái kể trên đâu nhé, có Thread chỉ NEWRUNNABLE rồi TERMINATED thôi. Tuy nhiên qua kiến thức về vòng đời này, bạn cũng đã biết được sơ sơ cách các Thread đồng bộ hoá, nhường nhịn nhau để thực thi các tác vụ như thế nào rồi đúng không nào. Và kiến thức về Thread cũng còn khá nhiều và không kém phần thú vị. Hẹn các bạn ở các bài học sau.

Đồng Bộ Hoá Tập 1 – Làm Quen Với Đồng Bộ Hoá

Với việc kết thúc Bài 43 vừa rồi thì chúng ta đã sơ bộ làm quen với Thread rồi. Nếu bạn nào còn muốn tìm hiểu nhiều hơn về các phương thức khác của Thread thì có thể xem thêm ở bài viết mở rộng này của mình.

Hôm nay chúng ta sẽ sang kiến thức mới mẻ hơn, cũng liên quan đến Thread, nhưng nói về cái sự Đồng bộ hoá các Thread. Trong quá trình tiếp cận khái niệm về Đồng bộ hoá, bạn sẽ hiểu rõ hơn các phương thức hữu dụng bên trong một Thread mà mình đã đề cập ở bài học trước hay bài viết mở rộng. Mời các bạn cùng đến với bài học.

Đồng Bộ Hoá Là Gì?

Như các bạn cũng biết sơ qua ở các dòng giới thiệu trên đây của mình. Bài hôm nay nói về Đồng bộ hoá, nhưng không phải cái sự đồng bộ dữ liệu giữa các thiết bị offline với dữ liệu trên mây đâu nha. Đồng bộ ở đây là đồng bộ về cách thức hoạt động giữa các Thread với nhau. Vậy rốt cục thì nó là cái gì? Mình giải thích cho nó rõ ra là, qua các phần về Thread, nhất là Thread tập 3 vừa rồi, chắc chắn bạn đã hiểu rõ Thread, và bạn cũng biết luôn rằng ở Thread đang tồn tại một vấn đề khá đau đầu, đó là cho dù Thread là một cách thức rất hay để chúng ta tổ chức các tác vụ bên trong ứng dụng được nhanh hơn, mượt mà hơn nhờ vào đặc tính xử lý song song của nó, thì, điều này lại dẫn đến một nguy cơ, đó là cùng một lúc, có thể có nhiều hơn một Thread muốn can thiệp vào một tài nguyên dùng chung nào đó. Chúng ta cần phải có một cơ chế giúp điều tiết sao cho trong cùng một thời điểm, chỉ có một Thread có quyền được sử dụng tài nguyên dùng chung này, các Thread khác phải chờ đợi đến lượt mình. Cơ chế điều tiết này được gọi với cái tên Đồng bộ hoá (Tiếng Anh gọi là Synchronized).

Tuy nhiên, mình cũng xin nói thêm một vấn đề nữa của Đồng bộ hoá. Đó là, nếu Đồng bộ hoá chỉ được nhắc đến một cách độc lập, thì bạn có thể hiểu chức năng của nó như mình nói trên đây. Còn nếu Đồng bộ được so sánh với Bất đồng bộ (khi này Đồng bộ là Synchronous, còn Bất đồng bộ là Asynchronous) thì vấn đề lại khác đi. Synchronous giúp tổ chức các Thread theo một trật tự nhất định, Thread này xong thì Thread kia mới được thực thi tiếp, tuần tự và nhịp nhàng. Còn Asynchronous là sự tổ chức một cách… vô tổ chức, tức là chúng ta sẽ không quan tâm đến Thread nào xong trước Thread nào xong sau. Và loạt bài về Đồng bộ hoá này chúng ta chỉ nói đến khái niệm Synchronized thôi nhé (tức là không có sự so sánh giữa Synchronous và Asynchronous).

Khi Nào Sẽ Dùng Đến Đồng Bộ Hoá?

Như mình có nói ở trên, đồng bộ hoá giúp điều chỉnh sao cho cùng một thời điểm chỉ có một Thread là được sử dụng đến tài nguyên dùng chung nào đó. Vậy thì khi nào mới phải có sự điều chỉnh này? Thực ra thì không phải nhất thiết lúc nào các tài nguyên bên trong ứng dụng (các tài nguyên này là các file hoặc các đối tượng nào đó) đều cần phải có sự đồng bộ của hệ thống. Chỉ những tài nguyên nào có sự tranh chấp, có sự dùng chung giữa các Thread với nhau, dẫn đến nguy cơ có Thread này đang chỉnh sửa giá trị của đối tượng, đồng thời Thread khác cũng thực hiện việc chỉnh sửa lên đối tượng này, dẫn đến các “hiểu lầm” không cần thiết ở các Thread, có thể sẽ gây ra các tai nạn ở runtime,… thì mới dùng đến Đồng bộ hoá mà thôi.

Một ví dụ thực tế là ở ứng dụng quản lý tài khoản của ngân hàng chẳng hạn. Giả sử bạn là người xây dựng ứng dụng quản lý tài khoản này, trong ứng dụng này của bạn có một đối tượng chuyên đọc ghi cơ sở dữ liệu về số dư tài khoản của khách hàng. Một ngày nọ, khách hàng ra cây ATM để rút tiền trong tài khoản ra, giả sử tài khoản của anh này còn 20 triệu VND, anh này cần rút 15 triệu VND. Bạn cũng biết rằng để rút được tiền, ATM (tức ứng dụng của bạn) phải trải qua thao tác kiểm tra số dư trong tài khoản đó, sau đó ATM này nhận lệnh rút tiền của khách và chuẩn bị thực hiện việc rút tiền (lúc này đối tượng trong ứng dụng của bạn chỉ mới đọc dữ liệu số dư, chưa có sự sửa chữa số dư). Nhưng đồng thời cùng thời điểm đó, ở nhà, cô vợ của khách hàng đó cũng vào cùng một tài khoản với chồng mình, tiến hành chuyển hết 20 triệu VND trong tài khoản của anh chồng qua tài khoản của cô ấy, trải qua thao tác kiểm tra số dư trong tài khoản, cô vợ thấy vẫn còn đủ tiền (vì cây ATM vẫn chưa làm thao tác trừ tiền), cô vợ thực hiện lệnh chuyển tiền. Và rồi chuyện gì xảy ra? Vì đối tượng trong ứng dụng của bạn thấy còn tiền (ở cả cây ATM của anh chồng và trang web chuyển khoản của cô vợ), nó thực hiện thao tác trừ 15 triệu VND trong cơ sở dữ liệu đối với anh chồng, và trừ 20 triệu VND trong cơ sở dữ liệu nữa đối với cô vợ. Anh chồng sẽ nhận được khoản tiền cần rút, và, tài khoản của cô vợ cũng nhận được khoản tiền vừa chuyển qua. Tóm lại, ngân hàng sẽ mất tiền (mất 15 triệu VND), bạn bị đuổi việc.

Nào, nào, mình chỉ giả sử thôi, chắc chắn sau khi đọc qua loạt bài về Đồng bộ hoá này, bạn hoàn toàn có thể tránh được lỗi lầm có thể xảy đến trong tương lai như ví dụ thôi. Để dễ hiểu hơn, chúng ta cùng làm lại tình huống này thành một project nho nhỏ như sau.

Ví Dụ Rút Tiền Ngân Hàng Khi Chưa Đồng Bộ Hoá

Ở ví dụ này mình mời các bạn cùng xây dựng một ứng dụng mô phỏng việc rút tiền từ ngân hàng như trên đây.

Đầu tiên chúng ta xây dựng một đối tượng nắm giữ thông tin số dư tài khoản của khách hàng. Đối tượng này được bạn xây dựng rất cẩn thận, nó có thể kiểm tra số dư tài khoản trước khi cho phép rút (số dư được thiết lập ban đầu là 20 triệu). Sau khi kiểm tra số dư và thấy được phép rút tiền, nó sẽ trừ số dư trong tài khoản đi. Giả sử các thao tác kiểm tra số dư tài khoản và cập nhật lại số dư mới vào cơ sở dữ liệu mất 2 giây cho mỗi thao tác. Tất cả đều được giả lập thông qua code của lớp sau. Lớp này mình đặt tên là BankAccount.

public class BankAccount {
 
long amount = 20000000; // Số tiền có trong tài khoản
 
public boolean checkAccountBalance(long withDrawAmount) {
// Giả lập thời gian đọc cơ sở dữ liệu và kiểm tra tiền
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
if (withDrawAmount <= amount) {
// Cho phép rút tiền
return true;
}
 
// Không cho phép rút tiền
return false;
}
 
public void withdraw(String threadName, long withdrawAmount) {
// In thông tin người rút
System.out.println(threadName + " withdraw: " + withdrawAmount);
 
if (checkAccountBalance(withdrawAmount)) {
// Giả lập thời gian rút tiền và
// cập nhật số tiền còn lại vào cơ sở dữ liệu
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
amount -= withdrawAmount;
}
 
// In ra số dư tài khoản
System.out.println(threadName + " see balance: " + amount);
}
}

Code trên đây dễ hiểu đúng không bạn. Bạn cũng đã biết rằng, BankAccount này chính là một tài nguyên dùng chung. Và đã là tài nguyên dùng chung, thì bạn nên xây dựng một Thread rút tiền như sau. Thread này sẽ cho phép truyền vào tài nguyên dùng chung này, và truyền luôn số tiền cần rút, rồi sau đó nó sẽ gọi đến phương thức rút tiền của tài nguyên đó. Mình đặt tên cho Thread rút tiền này là WithdrawThread.

public class WithdrawThread extends Thread {
 
String threadName = "";
long withdrawAmount = 0;
BankAccount bankAccount;
 
public WithdrawThread(String threadName, BankAccount bankAccount, long withdrawAmount) {
this.threadName = threadName;
this.bankAccount = bankAccount;
this.withdrawAmount = withdrawAmount;
}
 
@Override
public void run() {
bankAccount.withdraw(threadName, withdrawAmount);
}
}

Cuối cùng, code cho phương thức main() khá đơn giản, chúng ta chỉ cần khai báo 2 Thread rút tiền rồi cho chúng sử dụng chung cái tài nguyên bankAccount thôi.

public static void main(String[] args) {
BankAccount bankAccount = new BankAccount();
 
// Người chồng rút 15 triệu
WithdrawThread husbandThread = new WithdrawThread("Husband", bankAccount, 15000000);
husbandThread.start();
 
// Người vợ rút hết tiền (20 triệu)
WithdrawThread wifeThread = new WithdrawThread("Wife", bankAccount, 20000000);
wifeThread.start();
}

Và khi thực thi chương trình này lên, bạn sẽ thấy kết quả như hình dưới. Kết quả này chính là quá trình mà hai Thread husbandThread và wifeThread cùng vào kiểm tra tài khoản và thấy có khả năng rút được, sau đó chúng cùng thực hiện lệnh rút tiền, và kết quả cả hai đều thấy tiến trình thực hiện thành công. Nhưng… giá trị số dư lại là số âm (có nghĩa là ngân hàng mất tiền).

Kết quả chương trình khi chưa đồng bộ hoá
Kết quả chương trình khi chưa đồng bộ hoá

Chúng ta chỉ dừng lại ở ví dụ trên đây thôi, đến bài học sau, khi chúng ta bắt đầu các kiến thức cụ thể về các cách thức Đồng bộ hoá, chúng ta sẽ biết làm thế nào để ngân hàng trên đây không bị mất tiền, và có thể giữ lại cái mạng, à không, cái job cho bạn.

Các Cách Thức Đồng Bộ Hoá

Ở loạt bài về đồng bộ hoá này, mình sẽ chia chúng làm 2 cách, cũng là 2 phần lớn để chúng ta dễ dàng tiếp cận và ghi nhớ.

Cách thứ nhất được gọi là Mutual Exclusive. Có thể hiểu là Loại trừ lẫn nhau. Cách này hệ thống sẽ giúp ưu tiên một Thread và giúp ngăn chặn các Thread khác, khỏi nguy cơ xung đột với nhau. Do đặc tính này của cơ chế làm chúng ta liên tưởng tới một sự can thiệp mạnh tay, quyết liệt của hệ thống, nên mới có cái tên “loại trừ”. Cách này sẽ được mình gói gọn trong bài học kế tiếp.

Cách thứ hai được gọi là Cooperation. Có thể hiểu là Cộng tác với nhau. Cách này bản thân các Thread sẽ bắt tay với nhau, cùng nhau điều tiết thứ tự ưu tiên để có thể tự bản thân chúng tránh sự xung đột. Cách này sẽ được mình trình bày ở bài kế tiếp theo bài về cách loại trừ trên đây.

Thực ra ở bài học trước, bạn cũng đã được làm quen sơ qua với hai cách thức đồng bộ này rồi. Bạn nhớ lại đi. Link này là cách Mutual Exclusive, còn link này là cách Cooperation.

Kết Luận

Xong rồi, bài học nhẹ nhàng thôi đúng không nào. Kiến thức của bài viết chỉ tập trung vào việc giới thiệu khái niệm Đồng bộ hoá và hai cách thức lớn của Đồng bộ hoá mà chúng ta sẽ xem xét chúng ở các phần sau nữa nhé.

Đồng Bộ Hóa Tập 2 – Đồng Bộ Mutual Exclusive & Từ Khóa synchronized

Như vậy là sau khi kết thúc bài học mở màn về Đồng bộ hóa hôm trước, mình có nói rằng sẽ có hai cách thức để đồng bộ các Thread với nhau. Các cách đồng bộ này đều mang đến một mục tiêu chung là giới hạn các Thread truy cập vào cùng một tài nguyên dùng chung. Và bài học hôm nay mình sẽ trình bày cụ thể cách thức đầu tiên trong hai cách trên đây, cách thức này có cái tên Loại trừ lẫn nhau (Mutual Exclusive). Sau các bài học về đồng bộ này, bạn sẽ biết cách làm thế nào để tránh sự xung đột về tài nguyên hệ thống khi làm việc với Multithread, và cả biết xem khi nào thì nên dùng cách thức đồng bộ nào nữa đấy.

Mời các bạn cùng đến với bài học.

Đồng Bộ Mutual Exclusive Là Gì?

Chắc chắn Đồng bộ Mutual Exclusive là một phương pháp đồng bộ Thread, giúp cho các Thread được đồng bộ sao cho không đồng thời can thiệp vào tài nguyên dùng chung rồi. Vậy thì tại sao lại gọi là Loại trừ lẫn nhau (Mutual Exclusive)Loại trừ ở đây có nghĩa là Ngăn chặn, nghĩa là hệ thống sẽ chặn lại các Thread cùng gọi đến tài nguyên dùng chung, và chỉ cho phép một Thread được dùng đến tài nguyên này mà thôi. Các Thread bị ngăn chặn đó sẽ phải đợi đến khi chúng bị hết ngăn chặn, mới có thể dùng đến tài nguyên đó. Tóm lại, Loại trừ ở đây hiểu đúng là Ngăn chặn, chứ không là Hủy bỏ Thread đi đâu nhé.

Vậy thì hệ thống sẽ thực hiện việc loại trừ, hay ngăn chặn ấy bằng cách nào? Để dễ hình dung nhất, chúng ta hãy lấy một ví dụ thực tế sau đây (đây là ví dụ dễ hiểu nhất về Mutual Exclusive mà mình thấy nhiều tài liệu dùng đến). Ví dụ trong một hội nghị nọ có nhiều diễn giả cùng ngồi với nhau, họ đều cùng nhau nói về một chủ đề là làm sao để học Java tốt nhất có thể (chủ đề này thì do mình chế). Vấn đề xảy ra giống như với Thread mà chúng ta đang nói đến là, chỉ có một chủ đề thôi, mà mỗi diễn giả đều có một ý kiến, và ai cũng tranh giành để được nêu ý kiến của mình lên. Kết quả là người nghe sẽ nhận được các thông tin hỗn tạp, chẳng ai hiểu được nội dung mà hội nghị mang đến là gì.

Ví dụ về các diễn giả để nói lên bài toán đồng bộ
Ví dụ về các diễn giả để nói lên bài toán đồng bộ

Nếu xem mỗi diễn giả là một Thread, và chủ đề mà họ đang nói đến chính là tài nguyên dùng chung. Thì chính các diễn giả là những nhân tố làm cho cái chủ đề nó trở nên banh chành như vậy. Để giải quyết vấn đề này, bạn nghĩ ra một cơ chế, cơ chế này có cái tên Mutual Exclusive. Ý tưởng của cơ chế chính là làm sao cho các diễn giả phải tự giành lấy quyền được nói của họ, để loại trừ các quyền được nói của diễn giả khác, buộc các diễn giả khác phải lắng nghe cho tới khi diễn giả đang nói ấy xong câu chuyện. Ồ vậy phải làm sao, bạn không thể xen vào chỉ định ai sẽ nói và ai sẽ phải nghe rồi, vì làm vậy sẽ mất công quá. Không, bạn không làm vậy. Bạn cung cấp cho họ một cái microphone. Bum! Vấn đề đã được giải quyết. Với một microphone để ở trên bàn, chính diễn giả nào nhận được microphone về phía mình, diễn giả đó có quyền nói, những người khác lắng nghe cho đến khi diễn giả đang nói ấy nhường lại microphone. Các diễn giả đang lắng nghe đó có quyền đăng ký được nói vào một danh sách, để khi diễn giả kia kết thúc bài nói, người tiếp theo trong danh sách sẽ được quyền sử dụng microphone.

Ví dụ về các diễn giả sau khi được đồng bộ việc phát biểu
Ví dụ về các diễn giả sau khi được đồng bộ việc phát biểu

Ví dụ trên đây đã nói lên rõ phương pháp để thực hiện đồng bộ Mutual Exclusive rồi đấy. Chúng ta chỉ cần xem xét xem khi áp dụng ví dụ trên vào kiến thức về Đồng bộ Thread, thì hệ thống sẽ làm như thế nào, mời bạn cùng đến với mục tiếp theo.

Đồng Bộ Mutual Exclusive Như Thế Nào?

Cái cơ chế mà diễn giả chỉ được phép nói khi có microphone, khi áp dụng vào đồng bộ Thread, người ta gọi nó với một cái tên nữa là Monitor & Lock, hay nhiều tài liệu gọi ngắn là Monitor Lock. Không phải hiểu Monitor là màn hình và Lock là cái ổ khóa đâu nhé. Cơ chế này được hiểu rằng, microphone, hay các tài liệu dùng chung khác, sẽ được một đối tượng được gọi là Monitor, bảo hộ. Với mỗi một diễn giả (hay Thread) muốn sử dụng microphone (hay tài nguyên dùng chung), phải đăng ký qua Monitor để có được một Lock. Mỗi Monitor sẽ chỉ có một Lock. Thread nào lấy được Lock trên Monitor đó, Thread đó được phép sử dụng tài nguyên dùng chung, cho đến khi nào Thread đó kết thúc việc sử dụng tài nguyên và trả lại Lock cho MonitorLock này sẽ được chuyển qua cho Thread kế tiếp trong danh sách đợi ở Monitor, để Thread kế tiếp đó có cơ hội sử dụng và Lock tài nguyên đó. Cứ như vậy Lock được truyền nhau cho hết Thread còn đợi trong Monitor.

Cơ chế là như vậy, cũng không quá khó khăn để hiểu đúng không nào. Vậy áp dụng Monitor Lock vào cho code của chúng ta như thế nào, mời các bạn cùng đến với mục kế tiếp.

Từ Khóa synchronized

Chúng ta đang làm quen với một cách thức đồng bộ có tên là Mutual Exclusive. Chúng ta biết rằng cơ chế để hệ thống thực hiện đồng bộ được gọi là Monitor Lock. Và để gọi được hệ thống sử dụng cơ chế này để đồng bộ, thì chúng ta lại phải làm quen với cách sử dụng đến một từ khóa mới trong Java, từ khóa này có tên là synchronized.

Điều này có nghĩa là, khi chúng ta muốn đối tượng nào đó được bảo hộ bởi Monitor, thì hãy đặt vào trong nó từ khóa synchronized. Việc sử dụng từ khóa synchronized bên trong một đối tượng nào đó thì mình sẽ nói ở các mục cụ thể bên dưới. Việc của bạn hiện tại nên hiểu rằng, khi đối tượng nào đó có từ khóa synchronized bên trong, nó sẽ được hệ thống quản lý trong một Monitor. Mỗi đối tượng sẽ có một Monitor quản lý riêng biệt. Và vì vậy, như bạn biết, các Thread muốn sử dụng đến các phương thức synchronized bên trong đối tượng đó, nó phải có Lock. Và khi một Monitor của đối tượng mà nó quản lý trao Lock về Thread nào đó, nó phải đợi Thread đó trao trả Lock lại thì Thread khác mới có thể sử dụng được các phương thức synchronized này. Và như vậy bài toán Đồng bộ hóa của bạn được giải quyết.

Về cơ bản thì cách sử dụng synchronized cũng không khó. Đầu tiên bạn có thể hiểu rằng synchronized có thể được khai báo ở cấp độ phương thức trong lớp, hoặc ở cấp độ khối lệnh bên trong phương thức.

Chúng ta sẽ tiến hành khảo sát từng loại synchronized ở các mục cụ thể sau.

Dùng synchronized Cho Phương Thức

Khi bạn khai báo một phương thức, nếu muốn đồng bộ hóa trên phương thức này, hãy thêm vào từ khóa synchronized như code minh họa sau.

public synchronized void withdraw() {
// Nội dung phương thức
// ...
}

Để dễ hiểu hơn, chúng ta cùng đến với bài thực hành.

Bài Thực Hành Số 1

Ở bài thực hành này chúng ta cùng lấy lại ví dụ rút tiền từ ngân hàng ở bài trước.

Mình tóm tắt một chút ở ví dụ này. Ở ví dụ hôm trước bạn đã xây dựng một lớp BankAccount. Lớp này chứa hai phương thức checkAccountBalance() và withdraw(), chúng lần lượt là các phương thức kiểm tra số dư và rút tiền từ ngân hàng nếu số dư đó vẫn còn đủ để rút. Sau đó chúng ta khai báo hai Thread là husbandThread và wifeThread rồi cùng tiến hành rút tiền, thì kết quả nhận được ở bài hôm trước như sau.

Kết quả của ví dụ rút tiền ở bài trước
Kết quả của ví dụ rút tiền ở bài trước

Các dòng trên console thể hiện rằng hai Thread muốn rút tiền, và kết quả sau khi rút ở cả 2 Thread nhìn thấy đều là số âm, chứng tỏ việc kiểm tra số dư đã có sai sót, do cả hai lần kiểm tra số dư ở cả hai Thread đều thấy khả dụng, và đều thực hiện việc rút với tổng số tiền vượt quá số dư cho phép.

Như vậy chúng ta cần phải đồng bộ lại các Thread này, cụ thể là can thiệp vào cái tài nguyên dùng chung BankAccount. Làm cho đối tượng của BankAccount được bảo hộ bằng Monitor. Và khi đó các Thread muốn sử dụng đến các phương thức của đối tượng này, chúng phải yêu cầu Lock. Vậy theo như bài học, chúng ta chỉ cần thêm từ khóa synchronized vào BankAccount như sau (mình có thay đổi code chỗ in ra console cho nó rõ nghĩa hơn so với bài hôm trước).

public class BankAccount {
 
long amount = 20000000; // Số tiền có trong tài khoản
 
public synchronized boolean checkAccountBalance(long withDrawAmount) {
// Giả lập thời gian đọc cơ sở dữ liệu và kiểm tra tiền
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
if (withDrawAmount <= amount) {
// Cho phép rút tiền
return true;
}
 
// Không cho phép rút tiền
return false;
}
 
public synchronized void withdraw(String threadName, long withdrawAmount) {
// In thông tin người rút
System.out.println(threadName + " check: " + withdrawAmount);
 
if (checkAccountBalance(withdrawAmount)) {
// Giả lập thời gian rút tiền và
// cập nhật số tiền còn lại vào cơ sở dữ liệu
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
amount -= withdrawAmount;
System.out.println(threadName + " withdraw successful: " + withdrawAmount);
} else {
System.out.println(threadName + " withdraw error!");
}
 
// In ra số dư tài khoản
System.out.println(threadName + " see balance: " + amount);
}
}

Khi thực thi lên, bạn hãy so sánh kết quả.

Kết quả sau khi đồng bộ với synchronized cho phương thức
Kết quả sau khi đồng bộ với synchronized cho phương thức

Bạn xem, kết quả của việc đồng bộ này là, husbandThread yêu cầu rút tiền trước, nó sẽ được cấp phát Lock trước, cho đến khi husbandThread kết thúc việc rút tiền, thì wifeThread mới bắt đầu được thực hiện và không hề bị tranh chấp gì cả.

Dùng synchronized Cho Khối Lệnh Bên Trong Phương Thức

Đến đây thì bạn đã hiểu phần nào cách thức hoạt động của từ khóa synchronized rồi đúng không nào. Mình nói rõ hơn một tí là, khi bạn đặt từ khóa synchronized vào một hoặc nhiều phương thức bên trong lớp. Thì đối tượng của lớp đó sẽ được Monitor quản lý, một khi có một Thread đăng ký sử dụng đến một trong các phương thức có từ khóa synchronizedMonitor đó cấp Lock cho Thread đó cho đến khi nó hoàn thành xong các phương thức đó.

Vậy có những lúc bạn không cần phải xin Lock cho toàn bộ phương thức. Nếu bạn chỉ cần một phần trong phương thức đó được bảo hộ bởi Monitor thôi. Thì hãy áp dụng cách thức synchronized cho khối lệnh của mục này.

Bạn có thể tham khảo cú pháp của việc synchronized đến khối lệnh bên trong phương thức như sau.

synchronized (đối_tượng) {
     // Nội dung của khối lệnh
}

Cú pháp trên không quá khó, bạn chỉ cần quan tâm đến tham số đối_tượng truyền vào cho khối synchronized thôi. Tham số này báo cho hệ thống biết đối tượng nào cần được Monitor của nó quản lý sự đồng bộ mà thôi. Để dễ hiểu hơn mời bạn đến với bài thực hành.

Bài Thực Hành Số 2

Chúng ta sẽ lấy lại code của lớp BankAccount ở bài thực hành số 1 trên kia. Nhưng khi này chúng ta chỉ cần hệ thống đồng bộ một khối lệnh được tô sáng sau. Bạn thấy ngoài việc bao khối synchronized này vào các dòng code quen thuộc, và bỏ các synchronizedkhỏi các phương thức như ở bài thực hành số 1 ra, thì mọi thứ không thay đổi nhé.

public class BankAccount {
 
long amount = 20000000; // Số tiền có trong tài khoản
 
public boolean checkAccountBalance(long withDrawAmount) {
// Giả lập thời gian đọc cơ sở dữ liệu và kiểm tra tiền
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
if (withDrawAmount <= amount) {
// Cho phép rút tiền
return true;
}
 
// Không cho phép rút tiền
return false;
}
 
public void withdraw(String threadName, long withdrawAmount) {
// In thông tin người rút
System.out.println(threadName + " check: " + withdrawAmount);
 
synchronized (this) {
if (checkAccountBalance(withdrawAmount)) {
// Giả lập thời gian rút tiền và
// cập nhật số tiền còn lại vào cơ sở dữ liệu
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
amount -= withdrawAmount;
System.out.println(threadName + " withdraw successful: " + withdrawAmount);
} else {
System.out.println(threadName + " withdraw error!");
}
}
 
// In ra số dư tài khoản
System.out.println(threadName + " see balance: " + amount);
}
}

Với code trên thì mình chỉ đồng bộ một khối lệnh nhỏ. Các dòng in ra màn hình đều nằm ngoài sự đồng bộ này, và khi thực thi ứng dụng, kết quả có phần hơi khác tí xíu. Tuy nhiên ứng dụng vẫn chạy đúng.

Như đã nói ở cú pháp trên kia, việc truyền this vào khối synchronized là báo cho Monitorthực hiện bảo hộ trên đối tượng này, nhưng chỉ bảo hộ trong khối lệnh mà thôi.

Kết quả sau khi đồng bộ với synchronized cho khối lệnh
Kết quả sau khi đồng bộ với synchronized cho khối lệnh

Nếu như với bài thực hành số 1 thì do sự đồng bộ là trên cả 2 phương thức kiểm tra và rút tiền, nên khi người chồng vào trước, hệ thống sẽ kiểm tra thấy người chồng hoàn thành hết mọi thao tác thì mới phục vụ cho người vợ. Như vậy, với hệ thống của bài thực hành số 1 thì khi người vợ vào sử dụng hệ thống, sẽ phải đợi lâu mới thấy hệ thống phản hồi, do còn phải đợi người chồng xong việc. Còn với bài thực hành này, bạn thấy rằng hệ thống sẽ đáp ứng ngay cho cả hai (in số dư khả dụng ra màn hình) vì chưa vào đến các dòng code đồng bộ. Đến khi người chồng chính thức vào phương thức kiểm tra tiền, hệ thống mới thực hiện Mutual Exclusive đến người vợ. Và như những gì bạn đã thấy trên console trên kia.

Đồng Bộ Hóa Tập 3 – Đồng Bộ Cooperation – Các Từ Khóa wait/notify/notifyall

Thật là một thời gian khá lâu cho bài học về Java phần tiếp theo này. Đây có thể được xem là bài viết Java khởi đầu cho năm mới, tuy nhiên lại là một chủ đề “còn nợ” lại từ năm cũ. Có thể vì thời gian đợi khá lâu sẽ làm bạn quên đôi chút. Mình xin nhắc lại là chúng ta đang nói về các cách thức Đồng bộ hóa thread trong lập trình Java. Chúng ta tìm cách làm sao để các Thread tuy được “tự do tự tại” trong việc thực thi các tác vụ song song, lại có thể biết tuân thủ theo các nguyên tắc trật tự nào đó khi chúng có sử dụng chung đến các đối tượng, hay chúng ta gọi là các tài nguyên. Bài hôm trước là một cách, hôm nay chúng ta đến với cách thứ hai. Mời các bạn đến với bài học.

Đồng Bộ Cooperation Là Gì?

Đồng bộ Cooperation, hay có một số tài liệu gọi là Inter-Thread Communication, mục đích của phương pháp đồng bộ này không ngoài mong muốn tránh các xung đột từ các Thread khi chúng đồng thời cùng sử dụng đến cùng một đối tượng, hay tài nguyên của hệ thống. Vậy tại sao lại gọi là Cooperation? Khác với bài trước có nêu lên phương pháp đồng bộ Mutual Exclusive, phương pháp này của bài hôm trước tạo ra một cơ chế loại trừ, giúp các Thread nào dùng đến tài nguyên trước sẽ được ưu tiên, Thread dùng sau sẽ bị loại trừ và phải đợi. Phương pháp hôm nay thì ngược lại hoàn toàn. Không hề có sự “đến trước sẽ ưu tiên trước” nữa. Mà chúng có sự “cộng tác” với nhau (Cooperation), cộng tác theo một tinh thần mà một Thread có thể hoàn toàn “nhường” cho Thread khác sử dụng đến tài nguyên mà nó đã “giành” trước, để rồi khi Thread nào đó khác sử dụng xong tài nguyên đó, Thread đó phải “đánh thức” nó dậy để nó tiếp tục công việc trên tài nguyên đó.

Đồng Bộ Cooperation Như Thế Nào?

Như bạn cũng biết. Cốt lõi của phương pháp đồng bộ của bài hôm nay đó là các Thread sẽ phải tự nó điều chỉnh và nhường nhịn lẫn nhau trong việc sử dụng tài nguyên. Chính vì vậy chúng ta phải làm quen với các phương thức liên quan đến sự điều chỉnh và nhường nhịn này, thay vì chỉ với một từ khóa như bài hôm trước. Chúng ta đang nói đến các phương thức wait()notify() và notifyAll().

Trước khi cùng tìm hiểu ý nghĩa và cách sử dụng của 3 phương thức này, thì mình muốn nhắc lại một chút về cơ chế Monitor & Lock mà bài hôm trước có nhắc đến. Vì sự đồng bộ của bài hôm nay không nằm ngoài việc sử dụng đến Monitor và Lock này.

Như bài trước có nói rằng, mỗi một đối tượng trong hệ thống sẽ có một Monitor quản lý. Mỗi một Monitor như vậy chỉ có một Lock. Khi Thread nào muốn sử dụng đến đối tượng đó, nó phải đăng ký qua Monitor của đối tượng, Monitor sẽ trao Lock về cho Thread đó để được quyền sử dụng đến đối tượng. Từ khóa synchronized trên đối tượng mà bạn đã làm quen giúp cho Monitor biết rằng nếu trao Lock về cho Thread nào đó, thì Thread khác sẽ phải ở Monitor đợi cho đến khi Lock được trả về.

Vậy vẫn với cơ chế Monitor & Lock này thì bài hôm nay sẽ như thế nào. Chúng ta cùng đến với các phương thức mà mình vừa nhắc đến trên đây. Lưu ý là các phương thức này đều được xây dựng sẵn ở lớp cha Object, điều đó có nghĩa là tất cả các đối tượng trong Java đều có các phương thức này cả nhé.

wait()

Phương thức này khi được gọi, nó sẽ làm Thread đang nắm giữ Lock trên đối tượng phải trả Lock này lại cho Monitor của đối tượng đó. Đồng thời Thread đó rơi vào trạng thái ngủ, đợi cho một Thread nào đó khác “đánh thức” dậy bằng một trong hai phương thức dưới đây, hoặc tự dậy khi hết thời gian ngủ nếu gọi đến wait(long timeoutMilis).

notify()

Như đã nói ở trên, phương thức này giúp “đánh thức” Thread đã vào trạng ngủ bởi phương thức wait(). Nếu có nhiều Thread cùng gọi đến wait(), tức là cùng bị ngủ khi gọi đến đối tượng này, phương thức notify() sẽ đánh thức bất kỳ Thread nào trong các Thread đang ngủ.

notifyAll()

Phương thức này mở rộng hơn cho notify(). Nó giúp “đánh thức” tất cả các Thread nào đã gọi đến wait() bên trong đối tượng này.

Bạn có thể thấy rằng, ý tưởng cốt lõi trong việc sử dụng các phương pháp đồng bộ của bài hôm nay, đó là việc “nhường”. Một Thread đã vào trong Monitor của một đối tượng trước tiên, lấy được Lock của đối tượng đó rồi, nhưng vì một tình huống thực tế nào đó, mà Thread đó vẫn chưa sử dụng đến đối tượng này ngay. Nó tiến hành wait() để nhường cho Thread nào đó vào sử dụng đối tượng này trước, rồi đợi, cho đến khi được đánh thức (hoặc tự hết giờ đợi), để nó tiếp tục công việc hiện tại còn đang dang dở.

Để hiểu rõ hơn về cách sử dụng các phương thức trong cách đồng bộ của bài hôm nay, chúng ta cùng đến với bài thực hành sau.

Thực Hành Giải Quyết Bài Toán Rút Tiền Từ Ngân Hàng

Chúng ta cùng tiếp tục xây dựng các tác vụ liên quan đến nghiệp vụ ngân hàng. Bạn có thể xem lại bài thực hành hôm trước để ôn lại cách thức đồng bộ cũ, và so sánh với kịch bản của bài thực hành hôm nay.

Hôm trước bạn đã xây dựng nên một giải thuật “hoàn hảo”, khi mà cả ông chồng lẫn cô vợ cùng thực hiện việc rút tiền trên cùng một tài khoản. Bạn đã làm cho ứng dụng phân biệt được ai rút trước, ai rút sau, để đảm bảo kiểm tra số dư một cách tuần tự, giúp ngân hàng không bị “lỗ” khi có tình huống rút cùng lúc như thế này. Và mình nghĩ cách thức giải quyết tránh xung đột của bài hôm trước hoàn toàn phù hợp cho kịch bản như thế.

Chính vì vậy mà hôm nay chúng ta không dùng đến kịch bản này nữa. Chúng ta thay đổi một chút. Giả sử ngân hàng mà bạn đang làm việc có một dịch vụ mới, đó là hỗ trợ người dùng đặt lệnh rút tiền ngay khi số dư tài khoản đủ cho khách hàng đó rút. Điều này có nghĩa rằng là, nếu trong lúc khách hàng đặt lệnh rút, mà tài khoản ngân hàng đủ cho tác vụ này, thì khách hàng sẽ nhận được tiền ngay, còn như nếu không đủ, nó sẽ chờ cho đến khi tài khoản vừa đủ tiền thì thực hiện lệnh rút. Yêu cầu của vế thứ 2 này hơi phức tạp. Nếu bạn không xem qua bài học hôm nay, bạn có thể xây dựng cho ứng dụng một chức năng kiểm tra thường xuyên tài khoản khách hàng, cứ mỗi phút kiểm tra một lần chẳng hạn, ngay khi đủ tiền rút, sẽ thực hiện lệnh rút ngay. Ý tưởng kiểm tra định kỳ coi bộ đúng, nhưng chưa hay, nó sẽ làm chậm hệ thống nếu bạn có quá nhiều khách hàng.

Và tình huống thực hành của chúng ta là, có một anh chồng, tài khoản của anh còn 5 triệu VND. Cô vợ muốn rút 10 triệu VND nhưng không đủ, cô sử dụng dịch vụ rút tiền ngay khi tài khoản đủ như mình có nói trên đây. Một ngày nọ, anh chồng nạp vào tài khoản 5 triệu VND. Thỏa điều kiện. Ứng dụng thực hiện việc rút tiền với cô vợ ngay lập tức.

Để làm được điều này, chúng ta sẽ xây dựng lớp BankAccount như sau. Như bạn biết, BankAccount chính là lớp quản lý thông tin số dư. Chính các Thread của ông chồng hay cô vợ đều dùng đến lớp này để thay đổi thông tin số sư của tài khoản. Lớp BankAcount đã được xây dựng từ bài trước với các phương thức checkAccountBalance() và withdraw() cho tình huống kiểm tra số dư và rút tiền song song. Bài này chúng ta sẽ xây dựng thêm hai phương thức là withdrawWhenBalanceEnough() và deposit() cho yêu cầu của dịch vụ mới mà mình mới trình bày ở trên. Lớp BankAccount:

public class BankAccount {
 
long amount = 5000000; // Số tiền có trong tài khoản
 
public boolean checkAccountBalance(long withDrawAmount) {
// Giống code bài hôm trước, bạn tự copy/paste vào, bài này mình không hiển thị lại
}
 
public synchronized void withdraw(String threadName, long withdrawAmount) {
// Giống code bài hôm trước, bạn tự copy/paste vào, bài này mình không hiển thị lại
}
 
public synchronized void withdrawWhenBalanceEnough(String threadName, long withdrawAmount) {
// In thông tin người rút
System.out.println(threadName + " check: " + withdrawAmount);
 
while (!checkAccountBalance(withdrawAmount)) {
// Nếu không đủ tiền, thì đợi cho đến khi có đủ tiền thì rút
System.out.println(threadName + " wait for balance enough");
try {
wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
 
// Đủ tiền, hoặc không còn đợi nữa, thì được phép rút
// Giả lập thời gian rút tiền và
// cập nhật số tiền còn lại vào cơ sở dữ liệu
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
amount -= withdrawAmount;
System.out.println(threadName + " withdraw successful: " + withdrawAmount);
}
 
public synchronized void deposit(String threadName, long depositAmount) {
// In thông tin người nạp tiền
System.out.println(threadName + " deposit: " + depositAmount);
 
// Giả lập thời gian nạp tiền và
// cập nhật số tiền mới vào cơ sở dữ liệu
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
amount += depositAmount;
 
// Đánh thức đối tượng đang ngủ và chờ có tiền thì rút
notify();
}
}

Bạn hãy chú ý các phương thức wait() và notify() được dùng trong các phương thức của BankAccount. Đầu tiên bạn nên biết rằng các phương thức đồng bộ của bài hôm nay phải được để trong khối synchronized để đảm bảo tránh xung đột trước. Do đó các phương thức withdrawWhenBalanceEnough() và deposit() đều là các phương thức synchronized cả. Bạn hãy xem lại bài hôm trước nếu chưa hiểu từ khóa synchrozied được dùng làm gì nhé.

Quay lại BankAccount. Phương thức withdrawWhenBalanceEnough() vừa vào đã kiểm tra số dư. Nếu như số dư không đủ, nó gặp ngay lệnh wait(). Như đã nói, lệnh này làm cho Thread đang nắm giữ Lock hiện tại phải trả Lock lại và ngủ, đợi chờ Thread nào đó khác đánh thức dậy. Vòng lặp while trong việc kiểm tra số dư ở đoạn này giúp cho Thread khi sống dậy vẫn phải kiểm tra số dư lại nữa. Nếu số dư khi thức dậy đã đủ, thì sẽ thực hiện lệnh rút tiền ở các dòng code bên dưới nó. Còn số dư chưa đủ, lại wait() và ngủ tiếp. Bạn hiểu chưa nào.

Còn phương thức deposit() ở BankAccount sẽ là phương thức nạp tiền vào tài khoản. Sau khi nạp tiền xong, phương thức này cứ việc gọi đến notify() để đánh thức Thread nào đó đang ngủ và chờ được rút nếu có. Và thực ra notify() ở deposit() không hề biết có Thread nào đang ngủ và chờ đánh thức đâu, nên nếu chắc chắn, bạn cứ gọi đến notifyAll() để đánh thức tất cả các Thread đã gọi đến wait() bên trong BankAccount này cũng được nhé.

Sau đó, để đỡ rối, chúng ta xây dựng 2 Thread, một Thread để rút tiền nếu đủ, và một Thread để nạp tiền.

Thread rút tiền như sau.

public class WithdrawThread extends Thread {
 
String threadName = "";
long withdrawAmount = 0;
BankAccount bankAccount;
 
public WithdrawThread(String threadName, BankAccount bankAccount, long withdrawAmount) {
this.threadName = threadName;
this.bankAccount = bankAccount;
this.withdrawAmount = withdrawAmount;
}
 
@Override
public void run() {
bankAccount.withdrawWhenBalanceEnough(threadName, withdrawAmount);
}
}

Thread rút tiền dễ hiểu đúng không bạn. Không có gì mới lạ cả. Còn đây là Thread nạp tiền.

public class DepositThread extends Thread {
 
String threadName = "";
long depositAmount = 0;
BankAccount bankAccount;
 
public DepositThread(String threadName, BankAccount bankAccount, long depositAmount) {
this.threadName = threadName;
this.bankAccount = bankAccount;
this.depositAmount = depositAmount;
}
 
@Override
public void run() {
bankAccount.deposit(threadName, depositAmount);
}
}

Các Thread rút tiền và nạp tiền không khác nhau là bao, chúng ta chỉ tách ra 2 Thread để lát nữa vào phương thức main() dễ nhìn thôi. Bạn có thể gộp cả 2 Thread này lại thành 1 vẫn được nhé. Bạn thử đi.

Phương thức main() chúng ta sẽ gọi đến 2 Thread như sau.

public class MainClass {
 
public static void main(String[] args) {
BankAccount bankAccount = new BankAccount();
 
// Cô vợ muốn rút 10 triệu VND (bạn chú ý khi này tiền không đủ để rút)
WithdrawThread wifeThread = new WithdrawThread("Wife", bankAccount, 10000000);
wifeThread.start();
 
// Anh chồng nạp vào 5 triệu VND
DepositThread husbandThread = new DepositThread("Husband", bankAccount, 5000000);
husbandThread.start();
}
}

Đến đây bạn có thể thực thi chương trình để xem kết quả in ra console thế nào rồi nhé.

Kết quả thực thi chương trình
Kết quả thực thi chương trình

Deadlock

Với việc kết thúc bài học hôm trước thì chúng ta cũng đã xong kiến thức về Đồng bộ hóa Thread. Bạn đã thấy vai trò rất đắc lực của từ khóa synchronized trong việc đảm bảo không xảy ra sự tranh chấp đối với tài nguyên dùng chung rồi đúng không nào. Quả thật synchronized rất tốt, nhưng nếu lạm dụng nó không đúng chỗ, bạn sẽ gặp một tình huống mà bài học hôm nay nhắm đến. Tình huống đó có tên là Deadlock.

Deadlock Là Gì?

Thoạt nghe qua cái tên làm chúng ta liên tưởng đến “cái chết” (Dead) nào đó!?! Có thể nói, bài học hôm nay không nằm ngoài cái sự chết chóc. Cụ thể hơn, mình đang nói về cái chết của ứng dụng của chúng ta, cái chết này gây ra bởi các Thread trong chương trình của bạn, chúng “chờ đợi” nhau cho đến chết!

Nếu như sự “chết chóc” nghe thấy sợ quá, thì mình mời bạn cùng đến với 2 tình huống sau, tình huống đầu mình sẽ lấy ví dụ vui từ thực tế, tình huống sau sẽ đi cụ thể vào trong lập trình xem Deadlock là gì nhé.

Hiểu Deadlock Qua Ví Dụ Thực Tế

Tuy đây là tình huống không dẫn đến cái sự “chờ nhau đến chết” như trong lập trình, nhưng nó cũng khiến cho các đương sự không biết phải sử xự ra sao.

Hình dưới là cảnh một cảnh sát đang nắm giữ một tên cướp. Anh cảnh sát muốn tên cướp còn lại phải trao trả con tin trước thì ảnh mới thả tên cướp đang nắm giữ. Trong khi đó, tên cướp kia thì nhất định không trả con tin, buộc anh cảnh sát phải thả đồng bọn của hắn ra trước.

Mô phỏng khả năng xảy ra deadlock trong thực tế
Mô phỏng khả năng xảy ra deadlock trong thực tế

Vậy là, mỗi phe trong tình huống này đều nắm giữ riêng con tin của họ, và không phe nào chịu trao trả con tin về cho phe kia cả. Tình huống này rõ ràng là sẽ khó có một thỏa hiệp đạt được trong một thời gian ngắn. Deadlock khi này đã xảy ra.

Deadlock Trong Lập Trình

Trong lập trình thì tình huống Deadlock cũng tương tự như ví dụ thực tế vui trên đây. Nếu xem như Cảnh sát và Cướp là mỗi Thread. Thì Đồng bọn của cướp và Con tin chính là các tài nguyên. Thread Cảnh sát đang nắm giữ tài nguyên Đồng bọn của cướp thông qua từ khóa synchronized, nhưng Thread Cảnh sát lại rất muốn giữ luôn cả Con tin. Mà Con tin lại đang bị nắm giữ bởi Thread Cướp cũng bằng từ khóa synchrozied, trong khi đó Cướp cũng lại muốn nhận về Đồng bọn của cướp. Trong lập trình thì khi này Deadlock cũng sẽ xảy ra.

Thực Hành Xây Dựng Ứng Dụng Gây Ra Deadlock

Nếu như trên đây là các tình huống thực tế và lý thuyết để bạn dễ hiểu hơn về Deadlock. Thì bây giờ mình mời các bạn cùng xây dựng một ứng dụng thật, có khả năng gây ra Deadlock nhé.

Chúng ta vẫn sẽ đến với kịch bản xây dựng ứng dụng ngân hàng như các bài học về đồng bộ Thread mà các bạn đã rất quen thuộc. Giả sử hôm nay sếp ngân hàng đến nói với bạn rằng hãy xây dựng thêm chức năng chuyển khoản giữa các tài khoản với nhau. Sau khi chức năng xây dựng xong, ở một gia đình nọ có hai vợ chồng. Anh chồng có mở tài khoản riêng, và cô vợ cũng có tài khoản riêng ở cùng một ngân hàng. Một ngày nọ, do không hiểu ý nhau, anh chồng vô tài khoản của ảnh chuyển cho cô vợ 3 triệu VND, đồng thời cùng lúc đó, cô vợ cũng vô tài khoản của cổ chuyển cho anh chồng 2 triệu VND. Vấn đề trớ trêu là 2 người này cùng gần như thực hiện đồng thời lệnh chuyển tiền. Và lạ thay, ứng dụng bị treo, có nghĩa là 2 vợ chồng họ đợi hoài mà lệnh chuyển tiền vẫn không thành công. Tại sao vậy, chúng ta cùng xem qua đoạn code mà bạn đã viết.

Giả sử lớp BankAccount của bạn có sẵn các phương thức rút (withdraw) và nạp (deposit) được xây dựng từ các bài trước. Ở đây mình viết ngắn lại hơn so với các bài trước, bỏ qua các kiểm tra số dư và giả lập thời gian rút/nạp tiền ra cho lớp này ngắn gọn nhất có thể.

public class BankAccount extends Object {
 
long amount = 5000000; // Số tiền có trong tài khoản
String accountName = "";
 
public BankAccount(String accountName) {
this.accountName = accountName;
}
 
public synchronized void withdraw(long withdrawAmount) {
// In ra trạng thái bắt đầu trừ tiền
System.out.println(accountName + " withdrawing...");
 
// Trừ tiền
amount -= withdrawAmount;
}
 
public synchronized void deposit(long depositAmount) {
// In ra trạng thái bắt đầu nạp tiền
System.out.println(accountName + " depositting...");
 
// Nạp tiền
amount += depositAmount;
}
}

Và giờ bạn xây dựng thêm phương thức chuyển tiền cho lớp này. Bạn đặt tên nó là transferTo(). Do quá cẩn thận, bạn viết thêm các khối synchronized trong phương thức này. Code đầy đủ của lớp BankAccount sẽ như sau.

public class BankAccount extends Object {
 
long amount = 5000000; // Số tiền có trong tài khoản
String accountName = "";
 
public BankAccount(String accountName) {
this.accountName = accountName;
}
 
public synchronized void withdraw(long withdrawAmount) {
// In ra trạng thái bắt đầu trừ tiền
System.out.println(accountName + " withdrawing...");
 
// Trừ tiền
amount -= withdrawAmount;
}
 
public synchronized void deposit(long depositAmount) {
// In ra trạng thái bắt đầu nạp tiền
System.out.println(accountName + " depositting...");
 
// Nạp tiền
amount += depositAmount;
}
 
public void transferTo(BankAccount toAccount, long transferAmount) {
synchronized(this) {
// Rút tiền từ tài khoản này
this.withdraw(transferAmount);
 
synchronized(toAccount) {
// Nạp tiền vào toAccount
toAccount.deposit(transferAmount);
}
 
// In số dư tài khoản khi kết thúc quá trình chuyển tiền
System.out.println("The amount of " + accountName + " is: " + amount);
}
}
}

Ở phương thức main() chỉ việc gọi các lệnh chuyển khoản như sau.

public static void main(String[] args) {
// Khai báo tài khoản của anh chồng và cô vợ riêng
BankAccount husbandAccount = new BankAccount("Husband's Account");
BankAccount wifeAccount = new BankAccount("Wife's Account");
 
// Anh chồng muốn chuyển 3 triệu từ tài khoản của ảnh qua tài khoản cô vợ
Thread husbandThread = new Thread() {
@Override
public void run() {
husbandAccount.transferTo(wifeAccount, 3000000);
}
};
 
// Cô vợ muốn chuyển 2 triệu từ tài khoản của cổ qua tài khoản của anh chồng
Thread wifeThread = new Thread() {
@Override
public void run() {
wifeAccount.transferTo(husbandAccount, 2000000);
}
};
 
// Hai người thực hiện lệnh chuyển tiền gần như đồng thời
husbandThread.start();
wifeThread.start();
}

Và đây là kết quả khi thực thi chương trình.

Kết quả in ra console khi thực thi chương trình
Kết quả in ra console khi thực thi chương trình

Như bạn cũng đã hiểu rồi đó. Bạn xem, cả 2 Thread khi được khởi chạy, chỉ làm được mỗi thao tác trừ tiền của chính tài khoản nguồn. Còn sau đó đến phương thức nạp tiền cho tài khoản đích thì… không thể gọi đến được. Ứng dụng lúc này vẫn đang chạy, bằng chứng là nút Stop hình vuông màu đỏ bên cạnh tab Console vẫn sáng, tức là ứng dụng vẫn chạy và Eclipse khi này vẫn đang cho phép bạn dừng ứng dụng lại bất cứ khi nào. Cái sự ứng dụng mãi mãi không thể kết thúc được là vì khi này bản thân mỗi Thread khi được khởi tạo đã giữ lấy Lock trên Monitor của một tài khoản, các Thread khác không thể can thiệp vào tài khoản mà mỗi Thread đang giữ được. Việc mỗi Thread đều giữ một tài khoản và chờ đến lượt sử dụng tài khoản khác (cũng đang bị giữ bởi một Thread khác) như vậy được gọi là Deadlock.

Nó tương tự như sơ đồ sau.

Sơ đồ gây ra Deadlock của ví dụ
Sơ đồ gây ra Deadlock của ví dụ trên

Deadlock Xuất Hiện Khi Nào?

Như bạn đã làm quen trên đây, Deadlock thường xuất hiện khi chúng ta quá lạm dụng từ khóa synchronized. Nó khiến cho các Thread nắm giữ các đối tượng dùng chung mãi mãi mà không chịu trả ra cho các đối tượng khác dùng.

Tuy nhiên thì bài học về Deadlock này cũng chỉ là một bài học về lý thuyết, theo mình thì nó mang tính cảnh báo là chính. Trong thực tế sẽ rất khó để có thể xảy ra tình trạng Deadlock như thế này. Tuy nhiên, dù khó xảy ra nhưng nó cũng đã từng xảy ra, và nhiệm vụ của chúng ta là các lập trình viên, chúng ta vẫn cần phải biết và chuẩn bị các kiến thức cần thiết về nó.

Tránh Deadlock Và Xử Lý Như Thế Nào Nếu Gặp Deadlock?

Như mình nói, thì Deadlock rất khó xảy ra trong thực tế. Thực sự thì trong quãng đời lập trình của mình, mình chưa hề đụng đến trường hợp này. Một phần như mình biết thì các ứng dụng của chúng ta tuy có sử dụng nhiều Thread nhưng chưa đến mức đủ nhiều và phức tạp để gây ra sự xung đột như các ví dụ phía trên.

Nhưng dù cho nó có khó xảy ra đi nữa. Chúng ta vẫn phải nên biết để mà tránh đến mức thấp nhất nguy cơ xảy ra hiện tượng Deadlock này. Và cho dù tránh né như vậy, mà nếu lỡ chẳng may một ngày nào đó Deadlock xảy ra với ứng dụng của bạn thì sao. Thì bạn vẫn nên chuẩn bị sẵn các kiến thức để mà sửa lại source code và phát hành bản sửa lỗi ngay lập tức chớ sao. Mục này sẽ nói chung về việc tránh, và sửa lỗi, đối với Deadlock như thế nào.

Đầu tiên, theo mình, để tránh Deadlock, bạn vẫn phải nên hiểu rõ code của bạn. Bạn phải nắm được các Thread đã dùng có sử dụng các tài nguyên nào. Có nhiều Thread đang chiếm dụng các tài nguyên dùng chung hay không. Đảm bảo các Thread nếu đang chiếm dụng tài nguyên rồi thì cuối cùng cũng phải trả tài nguyên về hệ thống một cách nhanh nhất, để các Thread khác có cơ hội sử dụng và kết thúc các đời sống của các Thread đó. Dễ nhất là bạn đừng có viết lồng các khối synchronized lại như ví dụ trên kia là bảo đảm rất rất khó có thể xảy ra Deadlock.

Sau đó, nếu ứng dụng của bạn khi thực thi ở môi trường thực tế, mà gặp phải Deadlock. Nếu project của bạn tương đối nhỏ, bạn cũng có thể phát hiện bằng cách đọc code và suy luận. Nhưng nếu như project quá lớn, bạn có thể dùng đến một số công cụ có chức năng Thread Dump. Như hình bên dưới mình nhờ đến công cụ có sẵn trong thư mục /bin của JDK, công cụ có tên jvisualvm. Cách sử dụng công cụ và tìm Thread Dump thì bạn có thể tham khảo thêm trên mạng, hoặc bạn có thể xem ở link này để biết tất cả các công cụ, kể cả jvisualvm nhé.

Công cụ phát hiện Deadlock ở các Thread và các tài nguyên liên quan
Công cụ phát hiện Deadlock ở các Thread và các tài nguyên liên quan

Kết Luận

Bài học về Deadlock kết thúc tại đây. Bạn có thể thấy rằng Deadlock vẫn liên quan đến chuỗi kiến thức về Thread mạnh mẽ. Qua đó bạn đã thấy tầm quan trọng của Thread trong Java rồi đúng không nào. Tuy nhiên chúng ta vẫn còn phải nói nhiều về Thread, và bài học sau cũng không nằm ngoài kiến thức thú vị về Thread.

Thread Pool Tập 1 – Làm Quen Với Thread Pool

Như mình có nói, Java vốn tự hào là ngôn ngữ mạnh mẽ, một trong số đó là việc hỗ trợ các lập trình viên chúng ta thao tác với Thread. Và bạn đã chứng kiến điều đó thông qua khá nhiều các bài học liên quan đến các định nghĩa, cách sử dụng, và cả những vấn đề liên quan đến tổ chức, đồng bộ Thread nữa. Hôm nay, chúng ta lại tiếp tục làm quen một kiến thức nữa, cũng không nằm ngoài Thread. Một kiến thức nghe rất hay, Thread pool – Một cái hồ bơi dành cho Thread?!.

Kiến thức về Thread Pool này theo mình thì không nhiều, và cũng không khó để tiếp cận. Nhưng nói đến nó thì khá là vòng vo, với lại nó cần rất nhiều code minh họa. Do đó mình sẽ tách chúng ra làm 3 phần. Phần hôm nay chúng ta sẽ cùng làm quen với Thread Pool là gì. 2 phần sau sẽ cùng tìm hiểu 2 cách sử dụng Thread Pool.

Thread Pool Là Gì?

Như phần mở đầu mình có nói, đọc đến Thread Pool thì trong đầu bạn hiểu ngay nghĩa của nó là một cái Hồ bơi dành cho Thread?.

Thread Pool là một cái hồ bơi cho Thread
Thread Pool là một cái hồ bơi cho Thread

Nhưng theo mình, hiểu như vậy chẳng giúp gợi nhớ được gì. Để dễ tiếp cận hơn, bạn nên xem chữ Pool này như là một cái Hồ điều tiết thì hay hơn. Bạn tưởng tượng một thành phố nọ tuy có xây dựng các đường cống thoát nước rất hay, nhưng chẳng may một ngày kia có một cơn mưa rất lớn, lượng nước mưa chảy vào các cống này quá lớn khiến cho chúng không còn có khả năng thoát nước hiệu quả ra sông nữa. Khi này lãnh đạo thành phố mới nảy sinh sáng kiến nên xây dựng các hồ điều tiết. Thay vì nước mưa quá lớn gây ngập lụt đường phố, thì lượng nước này lại được trữ trong các hồ điều tiết. Đến khi cơn mưa dần giảm, nước trong hồ điều tiết lần lượt được xả vào trong các đường cống và thoát từ từ ra sông.

Hiểu Thread Pool là một hồ điều tiết Thread nghe hay hơn
Hiểu Thread Pool là một hồ điều tiết Thread nghe hay hơn

Bạn có thể so sánh một thành phố ở ví dụ trên chính là ứng dụng của chúng ta. Khi đó các đường cống thoát nước chính là các tài nguyên hệ thống đã cấp phát cho ứng dụng. Và lượng nước từ cơn mưa lớn chính là các Thread mà chúng ta tạo ra. Đồng ý là Thread giúp hệ thống xử lý các tác vụ song song, về cơ bản các tác vụ song song này giúp ứng dụng chạy mượt mà hơn. Nhưng nếu bạn tạo quá nhiều Thread và thực thi chúng cùng thời điểm, vô tình bạn tạo ra một áp lực rất lớn cho hệ thống. Càng nhiều Thread thì xem như lượng nước mưa càng lớn. Khi này các đường cống thoát nước sẽ không thể nào tải nổi nước mưa nữa, nó gây ra ngập lụt, tê liệt các hoạt động giao thông của thành phố. Điều này cũng sẽ tương tự với ứng dụng của bạn, hệ thống sẽ không thể nào đáp ứng tất cả các Thread đang hoạt động, ứng dụng sẽ dần trở nên chậm chạp, thiết bị sẽ nóng lên, gây ra nhiều hệ lụy không tốt cho cả người dùng lẫn thiết bị phần cứng. Chính vì lý do vậy mà chúng ta sẽ cần xây dựng một Hồ điều tiết cho ứng dụng, và kiến thức về Thread Pool cũng vì lẽ đó ra đời.

Khi Nào Thì Cần Dùng Đến Thread Pool?

Đến đây thì có lẽ bạn đã hiểu phần nào công năng của Thread Pool trong ứng dụng Java rồi. Nhưng liệu lúc nào chúng ta cũng cần đến Thread Pool không? Câu trả lời là không phải lúc nào cũng dùng đến Thread Pool đâu. Việc đầu tiên khi bạn muốn xây dựng các tác vụ song song cho ứng dụng, để chúng có thể chạy mượt mà hơn, là cứ tạo ra các Thread. Nếu không phải là làm game thì thường mỗi Thread bạn tạo ra trong ứng dụng có vòng đời khá ngắn, chỉ từ vài trăm mili giây cho đến vài chục giây là kết thúc rồi, nên bạn cũng chẳng cần phải nghĩ đến Thread Pool làm chi cho nó mệt. Nhưng nếu bạn xây dựng chức năng mà cùng một lúc có thể tạo ra rất nhiều Thread, có thể lên tới hàng trăm Thread hoặc hơn. Chẳng hạn chức năng up file lên mây, người dùng có thể chọn rất nhiều file để up (bạn có thể để ý cách Google Drive hoạt động khi up rất nhiều file). Với mỗi file như vậy sẽ cần 1 Thread để quản lý quá trình up. Khi này bạn nhất định phải xây dựng Thread Pool. Sau khi xây dựng xong một Pool, bạn chỉ việc chỉ định mỗi Pool như vậy có thể chứa bao nhiêu Thread, chẳng hạn 5 Thread đi, thì khi người dùng bắt đầu thao tác up file, hệ thống chỉ chạy tối đa 5 Thread một lần, tức sẽ up 5 file mỗi lần. Thread nào up xong thì Thread khác bắt đầu, đảm bảo cùng một lúc không thể quá 5 Thread up file được chạy, cứ như vậy cho đến khi nào up hết file của người dùng thì thôi. Bạn đã hiểu Thread Pool được dùng khi nào chưa nào.

Sử Dụng Thread Pool Như Thế Nào?

Lý thuyết là vậy, cũng không hoàn toàn khó hiểu đúng không bạn. Vậy thì áp dụng Thread Pool trong Java như thế nào, có khó không? Thực ra thì không khó đâu, vì Java đã cung cấp cho chúng ta một công cụ tuyệt vời, cách sử dụng công cụ này lại không khó tí nào cả. Công cụ mà mình nói đến, chính là một lớp có tên ThreadPoolExecutor.

Làm Quen Với ThreadPoolExecutor

Lớp này đã xây dựng sẵn cho bạn một hàng đợi có tên là Task Queue, và một Pool trong đó. Khi bạn có quá nhiều Thread (chính là các Runnable trong Application như hình dưới), thì thay vì cứ start chúng thành các Thread, bạn cứ “quăng” tất cả Runnable vào trong ThreadPoolExecutor đã khai báo. Tất cả các Runnable này sau đó được ThreadPoolExecutor để vào trong Task Queue. Và sẽ chỉ lấy ra đủ số lượng Runnable mà bạn đã chỉ định, để thực thi chúng thành các Thread. Dễ dàng thôi đúng không.

Mô phỏng hoạt động của ThreadPoolExecutor
Mô phỏng hoạt động của ThreadPoolExecutor

Cách thức sử dụng trực tiếp ThreadPoolExecutor được mình nói sau bài viết về cách sử dụng Thread Pool như mục dưới đây.

Làm Quen Với Executors, Executor Và ExecutorService

Trước khi tìm hiểu cách sử dụng ThreadPoolExecutor như thế nào, thì bài sau mình sẽ nói về cách sử dụng ExecutorsExecutor và ExecutorService trước. Ôi sao đau đầu vậy?

Thực ra cái tên chính yếu bạn nên nhớ chỉ là ThreadPoolExecutor thôi, và cách hoạt động của nó mình đã minh họa ở mục trên đây. Nhưng ThreadPoolExecutor được xem như một cách sử dụng Thread Pool “thủ công”, tức là bạn phải điều khiển các tham số truyền vào theo đúng ý bạn, điều này tuy khá thú vị nhưng cần chúng ta phải có một ý niệm về Thread Pool trước đã. Chính vì vậy chúng ta gác ThreadPoolExecutor lại, nói sau. Chúng ta nói trước về ExecutorsExecutor và ExecutorService.

ExecutorsExecutor và ExecutorService được xem như bộ ba tiện ích, chúng giúp tách bạn ra khỏi ThreadPoolExecutor. Bằng cách xây dựng sẵn các phương thức và các ràng buộc mà bạn sẽ dễ dàng hơn trong việc hiểu và sử dụng Thread Pool. Bản chất của chúng vẫn là gọi lại ThreadPoolExecutor mà thôi. Thông qua bộ 3 tiện ích này, bạn sẽ có kiến thức gần như hoàn toàn đầy đủ về Thread Pool, khi đó tiếp cận ThreadPoolExecutor sẽ dễ dàng mà thôi.

Thread Pool Tập 2 – Executors, Executor Và ExecutorService

Như vậy là sau tập đầu tiên của Thread Pool, bạn đã biết được rằng mình đang cố gắng giúp bạn ghi nhớ được ý nghĩa của kỹ thuật này như là một cái Hồ điều tiết Thread. Từ việc hiểu ý nghĩa đó, bạn cũng đã nắm được công năng và nắm được có bao nhiêu cách để có thể tổ chức nên cái hồ này.

Tuy nhiên tất cả cũng vẫn dừng lại ở lý thuyết. Việc hiểu tường tận hơn từng cách sử dụng Hồ, và làm quen với các ví dụ, chính là mục đích chính bắt đầu từ bài học hôm nay.

Mặc dù mình đã cố gắng trình bày hết mức có thể cách sử dụng cũng như ý nghĩa của các phương thức mà bộ ba các lớp của bài hôm nay mang lại, nhưng mình biết sẽ còn thiếu sót nhiều lắm. Một phần vì giới hạn của bài viết, tránh viết quá dài (thực ra nó cũng dài lắm rồi, và tốn rất nhiều thời gian để viết bài này), phần nữa vì mình cũng chưa có cơ hội sử dụng hết tất cả các kiến thức mà Thread Pool mang lại, vì chúng khá rộng lớn. Nên qua bài học nếu bạn nào có những đóng góp, xây dựng thì hãy liên lạc với mình qua các kênh mình liệt kê ở cuối bài viết hôm nay nhé.

Nào chúng ta cùng bắt đầu làm quen với cách sử dụng Thread Pool thông qua bộ ba tiện ích: ExecutorsExecutor và ExecutorService.

Giới Thiệu Executors, Executor Và ExecutorService

Như đã giới thiệu từ bài học trước, bộ ba ExecutorsExecutor và ExecutorService giúp bạn sử dụng ThreadPoolExecutor được dễ dàng hơn. Cụ thể chúng là gì thì mình có thể diễn đạt ra như sau.

  • Executors: Đây được xem như một Helper Class. Nó cũng chỉ là một class thông thường thôi, nhưng lại cung cấp các phương thức hữu dụng, và dễ dàng nhất, để khởi tạo ra các ThreadPoolExecutor. Chúng ta gọi các lớp như vậy là Helper Class. Một lát bạn để ý xem Executors rất dễ sử dụng như thế nào nhé.
  • Executor: Là một interface. Nó chỉ chứa mỗi phương thức execute(Runnable). Chính ThreadPoolExecutor phải implement interface này và hiện thực phương thức này, giúp bạn đưa một Runnable vào Thread Pool một cách dễ dàng.
  • ExecutorService: Là lớp triển khai của của Executor, và vì vậy nó cũng là một interface. Nó cung cấp một số phương thức ràng buộc mở rộng hơn Executor mà lát nữa bạn sẽ được làm quen ở mục tiếp theo.
  • Thực ra nếu nói sâu hơn nữa còn có ScheduledExecutorService, thằng này lại là lớp triển khai của ExecutorService trên đây. Lớp này cho phép bạn lên lịch (schedule) cho việc thực thi các tác vụ, tuy nhiên do bài viết hôm nay vốn đã rất dài rồi nên mình sẽ không dành thêm giấy mực để trình bày đến lớp này nhé.

Ví dụ sau cho thấy code của việc khai báo một Thread Pool có thể thực thi đồng thời 3 Thread một lúc.

ExecutorService executorService = Executors.newFixedThreadPool(3);

Trước khi bắt đầu làm quen với các bài thực hành để biết rõ hơn về việc sử dụng Thread Pool. Thì mình mời các bạn cùng làm quen đến một số phương thức mà Helper Class Executors mang đến, để xem lớp này giúp bạn “triệu hồi” các thể loại Thread Pool nào nhé.

Làm Quen Với Executors

Như bạn biết, Executors là một Helper Class, nó chính là một lớp đắc lực để bạn có thể thi triển các ThreadPoolExecutor mà không cần biết tí gì về ThreadPoolExecutor cả. Chung quy lại thì Executors có các phương thức xây dựng sẵn hữu ích mà mình biết như sau (ngoài các phương thức này ra còn khá là nhiều các phương thức khác mà thú thiệt mình vẫn chưa có cơ hội tìm hiểu đến, nếu được bạn hãy tự mày mò tìm hiểu thêm nhé).

  • newSingleThreadExecutor(): Giúp tạo ra một Thread Pool có khả năng thực thi 1 Thread trong đó. Như vậy nếu bạn dùng đến Thread Pool dạng này. Hồ điều tiết của bạn sẽ khá nhỏ, vì các Thread khi này được xem như được thực hiện tuần tự từng em một.
  • newCachedThreadPool(): Thread Pool này mình chưa dùng bao giờ. Nghe nói là hệ thống sẽ tự quyết định số lượng Thread được thực thi trong Hồ. Có vài thông tin hay ho đối với Pool này, đó là Pool sẽ cache và sử dụng lại cấu trúc của Thread cũ đã xử lý xong để thực thi cho Thread mới. Ngoài ra nếu một Thread trong Pool này không được sử dụng trong vòng 60 giây sẽ bị gỡ ra khỏi cache. Những tính năng này giúp cho Pool được khởi tạo theo kiểu này tận dụng được hiệu năng của hệ thống, đồng thời cũng giúp tránh bị tình trạng nắm giữ resource của hệ thống quá lâu.
  • newFixedThreadPool(int nThreads): Đây là Thread Pool thông dụng mà mình thấy. Phương thức này giúp tạo ra một Pool có thể chứa tối đa nThreads. Khi Pool đạt đến giá trị tối đa nThreads, các Thread còn lại sẽ được đưa vào hàng đợi và chờ đến khi có Thread trong Pool được xử lý xong mới được thực thi tiếp.

Chúng ta sẽ cùng làm quen với các thể loại Thread Pool được liệt kê trên đây mà lớp Executors cung cấp thông qua các bài thực hành sau. Các bạn cùng code với mình nhé.

Bài Thực Hành Số 1 – newSingleThreadExecutors

Như đã nói trên kia, phương thức newSingleThreadExecutors của Executors giúp tạo ra một Thread Pool mà chỉ có duy nhất 1 Thread được thực thi một lần.

Để kiểm chứng, chúng ta cùng xây dựng Thread Pool này. Nhưng trước hết, mình muốn các bạn xây dựng một Runnable để chúng ta có thể truyền chúng vào trong Thread Pool mà chúng ta sẽ xây dựng ở bước kế tiếp sau. Runnable này mình đặt tên là MyRunnable nhé.

public class MyRunnable implements Runnable {
 
// Tên của Runnable, giúp chúng ta phân biệt Runnable nào đang thực thi trong Thread Pool
private String name;
 
public MyRunnable(String name) {
// Khởi tạo Runnable với biến name truyền vào
this.name = name;
}
 
@Override
public void run() {
System.out.println(name + " đang thực thi...");
 
// Giả lập thời gian chạy của Runnable mất 2 giây
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
System.out.println(name + " kết thúc.");
}
 
}

Trên đây là một Runnable bình thường thôi. Tại sao chúng ta lại phải sử dụng Runnable? Vì Thread Pool chỉ nhận các Runnable truyền vào (và Callable nữa mà bạn sẽ làm quen sau, nhưng Callable cũng giống Runnable mà thôi). Bạn nên nhớ là các Runnable khi chưa gọi phương thức start() thì vẫn chưa được hệ thống thực thi thành các Thread. Bạn có thể xem lại kiến thức này ở bài học về Thread này. Do đó việc chúng ta truyền các Runnable vào Thread Pool là để cho Thread Pool sắp xếp chúng vào hàng đợi. Đến khi quyết định thực thi Runnable nào, các Thread Pool sẽ tiến hành start() Runnable đó.

Tiếp theo sau là code ở phương thức main.

public static void main(String[] args) {
// Khai báo một Thread Pool thông qua newSingleThreadExecutor() của Executors
ExecutorService executorService = Executors.newSingleThreadExecutor();
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
MyRunnable myRunnable = new MyRunnable("Runnable " + i);
executorService.execute(myRunnable);
}
 
// Phương thức này sẽ được nói sau ở ExecutorService
executorService.shutdown();
}

Bạn có thể thấy, dòng đầu tiên của code trên khai báo một Thread Pool thông qua phương thức Executors.newSingleThreadExecutor(). Phương thức này của Executors giúp tạo một Thread Pool với chỉ duy nhất một Thread được thực thi. Thread Pool này được quản lý thông qua biến executorService, đây chính là lớp ExecutorService mà chúng ta sẽ tìm hiểu sau. Bạn chỉ cần biết thêm là phương thức executorService.execute(myRunnable) giúp lần lượt đưa các Runnable được khởi tạo vào trong Thread Pool và lần lượt thực thi sau đó.

Đến đây bạn có thể thực thi chương trình để xem, bạn có thể thấy cứ mỗi 2 giây, chỉ có 1 Thread được chạy và in ra console mà thôi. Hình dưới là kết quả ở console của máy mình.

Kết quả in ra console của newSingleThreadExecutor()
Kết quả in ra console của newSingleThreadExecutor()

Bài Thực Hành Số 2 – newFixedThreadPool

Thông qua bài thực hành trên, bạn đã hiểu cách làm việc với Thread Pool rồi. Bài thực hành số 2 này mình không nói nhiều. Bạn tự code lại theo khai báo mới của Thread Pool (như tô sáng ở code dưới) rồi trải nghiệm nhé. Lưu ý là chúng ta dùng lại MyRunnable và không thay đổi gì cả trong code của lớp này để cùng so sánh xem Thread Pool mới này sẽ cho ra kết quả khác như thế nào ở console.

public static void main(String[] args) {
// Khai báo một Thread Pool thông qua newFixedThreadPool(5) của Executors.
// Thread Pool này cho phép thực thi cùng một lúc 5 Thread
ExecutorService executorService = Executors.newFixedThreadPool(5);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
MyRunnable myRunnable = new MyRunnable("Runnable " + i);
executorService.execute(myRunnable);
}
 
// Phương thức này sẽ được nói sau ở ExecutorService
executorService.shutdown();
}
Kết quả in ra console của newFixedThreadPool()
Kết quả in ra console của newFixedThreadPool()

Bài Thực Hành Số 3 – newCachedThreadPool

Cũng tương tự, bạn hãy thử dùng newCachedThreadPool để tạo một Thread Pool xem. Với cách này hệ thống sẽ thực thi hết các Thread trong phương thức main của chúng ta.

public static void main(String[] args) {
// Khai báo một Thread Pool thông qua newCachedThreadPool của Executors.
ExecutorService executorService = Executors.newCachedThreadPool();
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
MyRunnable myRunnable = new MyRunnable("Runnable " + i);
executorService.execute(myRunnable);
}
 
// Phương thức này sẽ được nói sau ở ExecutorService
executorService.shutdown();
}
Kết quả in ra console của newCachedThreadPool()
Kết quả in ra console của newCachedThreadPool()

Làm Quen Với Executor Và ExecutorService

Executor và ExecutorService là một. Tại sao thì mình đã có giải thích ở mục giới thiệu về chúng trên kia rồi. Do đặc tính này mà mình sẽ tập trung nói về ExecutorService, vì nó vừa bao gồm phương thức execute(Runnable) của Executor, vừa xây dựng thêm nhiều phương thức hữu ích khác nữa.

Bạn cũng có thể thấy rằng, thông qua các ví dụ trên đây, chúng ta đã cùng làm quen, và cùng hiểu sơ qua công dụng của ExecutorService rồi. Đó là nó giúp chúng ta đưa các Runnable vào bên trong Thread Pool thông qua phương thức execute(Runnable). Chính các Thread Pool sẽ quyết định thực thi các Runnable này theo kịch bản mà chúng được khai báo.

Sau đây là tất cả các phương thức mà ExecutorService cung cấp.

  • execute(Runnable): Phương thức này bạn đã được làm quen thông qua các bài thực hành trên đây rồi nên mình không nói lại nữa. Nhưng bạn cũng nên biết, sau khi đã thực hành các bài trên. Đó là phương thức này được xem như việc đưa các Runnable vào Thread Pool và khởi chạy chúng theo kiểu bất đồng bộ. Đó là bạn sẽ không biết được khi nào các Runnable kết thúc, và các kết quả mà chúng trả về là gì.
  • submit(Runnable) và submit(Callable): Phương thức submit() cho phép truyền vào hoặc là Runnable như cách bạn thực hành với execute() trên kia, hoặc là Callable. Về cơ bản thì Callable cũng như Runnable, chúng cũng có khả năng tạo ra một Thread. Nhưng Callable thì lại cho phép Thread này trả kết quả về một cách đồng bộ, khi mà Runnable lại không làm được điều đó. Một lát nữa bạn sẽ được trải nghiệm sử dụng Callable. Quay lại phương thức submit() khác với execute() như thế nào? Đó kà submit() có trả về kết quả cuối cùng thông qua lớp FutureFuture này giúp bạn xác định xem Thread Pool này đã hoàn thành xong hay chưa. Một lát nữa bạn cũng sẽ được trải nghiệm kết quả Future này.
  • invokeAny() và invokeAll(): Một cách sử dụng khác của Thread Pool, ngoài việc dùng execute() hay submit() để đưa vào Pool từng Runnable hay Callable. Với 2 phương thức này bạn có thể truyền vào chúng danh sách các Callable. Phương thức invokeAny() sẽ thực thi các Callable theo quy luật khai báo Thead Pool như chúng ta làm quen ở các bài ví dụ trên, nhưng khi có bất kỳ Callable nào hoàn thành trong danh sách các Callable truyền vào đó, Thread Pool sẽ chấm dứt các Thread còn lại, dù cho chúng đã được đưa vào Pool và đang chờ thực thi. invokeAll() thì ngược lại, nó sẽ thực thi tất cả các Callable và chờ nhận các kết quả trả về của các Callable này thông qua danh sách các đối tượng Future.
  • shutdown() và shutdownNow(): Khi bạn đã thêm các Runnable hay Callable vào trong Thread Pool, bạn có thể gọi lệnh shutdown() để xem như đóng Thread Pool đó lại, Thread Pool lúc này sẽ từ chối nhận thêm task nữa. Tại sao phải gọi shutdown()? Bạn nên biết rằng một ExecutorService không tự động kết thúc khi chúng thực thi hết các Thread, nó vẫn ở đó và khiến ứng dụng của bạn vẫn chạy mặc dù các Thread trong Thread Pool đã hoàn thành. Và lệnh shutdown() vừa giúp đóng Thread Pool lại, vừa giúp ExecutorService cũng kết thúc luôn khi nó hoàn thành nhiệm vụ. Tương tự, shutdownNow() cũng có công năng như vậy, chỉ khác một chỗ phương thức này buộc ExecutorService kết thúc ngay khi được gọi, lúc này đây các Thread chưa được thực thi sẽ bị buộc phải kết thúc theo ExecutorService.

Chúng ta sẽ không thực hành với phương thức execute() nữa, vì các bài thực hành trước đã sử dụng rồi. Hãy cùng thực hành với các phương thức còn lại của ExecutorService nào.

Bài Thực Hành Số 4 – submit(Runnable)

Do bài thực hành này cũng dùng đến Runnable, nên mình sẽ vẫn dùng lại MyRunnable đã được xây dựng từ các bài thực hành trước. Bạn chú ý một chút cách gọi submit() để trả về danh sách các Future như sau.

public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(5);
List<Future> listFuture = new ArrayList<Future>(); // Khởi tạo danh sách các Future
 
for (int i = 1; i <= 10; i++) {
MyRunnable myRunnable = new MyRunnable("Runnable " + i);
// Bước này chúng ta dùng submit() thay vì execute()
Future future = executorService.submit(myRunnable);
listFuture.add(future); // Từng Future sẽ quản lý một Runnable
}
 
for (Future future : listFuture) {
try {
// Khi Thread nào kết thúc, get() của Future tương ứng sẽ trả về null
System.out.println(future.get());
} catch (ExecutionException | InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
 
// Phương thức này đã nói ở trên đây rồi
executorService.shutdown();
}

Bạn thấy rằng kết quả sau đây có in ra các dòng “null”. Chúng là các kết quả của các lời gọi future.get(). Và vì phương thức submit() này của ExecutorService giúp trả về các kết quả theo kiểu đồng bộ, tức là khi Thread kết thúc thì null mới được trả về thông qua future.get(), nên dù cho bạn đã gọi chúng rất sớm, chúng vẫn sẽ chỉ in ra null khi nào Thread kết thúc mà thôi.

Kết quả in ra console của submit(Runnable)
Kết quả in ra console của submit(Runnable)

Bài Thực Hành Số 5 – submit(Callable)

Bài thực hành này chúng ta cũng sẽ làm quen với Callable. Cơ bản thì Callable cũng khá giống với Runnable, nó cũng giúp để khởi tạo Thread trong ThreadPool. Nhưng Callable hữu ích hơn Runnable ở chỗ nó cho phép trả về một kết quả mà bạn định nghĩa sẵn. Chúng ta cùng đến với code khai báo một Callable như sau, rồi cùng nhau nói về nó tiếp theo nữa nhé. Callable này mình đặt tên là MyCallable.

public class MyCallable implements Callable<String> {
 
// Tên của Callable, giúp chúng ta phân biệt Runnable nào đang thực thi trong
// Thread Pool
private String name;
 
public MyCallable(String name) {
// Khởi tạo Callable với biến name truyền vào
this.name = name;
}
 
@Override
public String call() throws Exception {
System.out.println(name + " đang thực thi...");
 
// Giả lập thời gian chạy của Callable mất 2 giây
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
// Trả kết quả về là một kiểu String
return name;
}
}

Dòng đầu tiên bạn thấy có khai báo lớp MyCallable kế thừa từ Callable<String>. Cách viết Callable<String> mình sẽ nói rõ hơn khi nói về kiến thức Generic sau này. Cơ bản bạn có thể hiểu Callable<String> là một Callable với một ràng buộc trong code của bạn phải trả về kiểu dữ liệu là String. Nếu bạn muốn trả về kiểu dữ liệu khác thì cứ thay String bằng kiểu dữ liệu bạn muốn là được.

Tương tự, vì Callable<String> ràng buộc bạn phải trả về kiểu String, nên code bên trong nó bạn phải Override phương thức call() với kiểu trả về là String luôn. Phương thức call() này của Callable thay cho run() bên Runnable, chỉ khác là call() có đòi hỏi kết quả trả về. Lúc này chúng ta trả về biến name luôn và in dòng kết thúc ở bên ngoài nhé.

Với việc khai báo Callable như trên, mình sẽ tiến hành gọi như sau ở phương thức main. Bạn xem.

public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(5);
List<Future<String>> listFuture = new ArrayList<Future<String>>(); // Khởi tạo danh sách các Future
 
for (int i = 1; i <= 10; i++) {
// Dùng Callable thay cho Runnable
MyCallable myCallable = new MyCallable("Callable " + i);
 
Future<String> future = executorService.submit(myCallable);
listFuture.add(future); // Từng Future sẽ quản lý một Callable
}
 
for (Future future : listFuture) {
try {
// Khi Thread nào kết thúc, get() của Future tương ứng sẽ trả về kết quả mà Callable return
System.out.println(future.get() + " kết thúc");
} catch (ExecutionException | InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
 
// Phương thức này đã nói ở trên đây rồi
executorService.shutdown();
}

Có một điều chắc bạn cũng sẽ hơi nhức đầu. Đó là thay vì khai báo List<Future> như bài thực hành trước (bài học về ListArrayList gì đó, chính là các Collection mình cũng sẽ nói sau), thì bây giờ lại phải khai báo List<Future<String>>. Bạn nên biết rằng, chắc chắn cái đoạn <String> ở khai báo List này có liên quan mật thiết với <String> ở khai báo lớp Callable trên kia rồi, và như đã nói, mình sẽ nói sau ở bài về Generic. Bây giờ khi thực thi chương trình, kết quả không nằm ngoài tiên liệu của chúng ta. Có điều do phương thức future.get() có một độ trễ nhất định, nên việc in ra Callable nào kết thúc không ngay tức thời, nên nhìn vào kết quả console sau, bạn sẽ thấy dường như có nhiều Thread được thêm vào Thread Pool hơn khai báo 5 Thread ban đầu.

Kết quả in ra console của submit(Callable)
Kết quả in ra console của submit(Callable)

Bài Thực Hành Số 6 – invokeAny()

Do bài học đã quá dài, nên mục invokeAny() mình chỉ cho các bạn xem code, và kết quả thực thi chương trình. Ý nghĩa của phương thức này mình đã giải thích trên kia rồi nhé. Có khác một tí là ví dụ này mình không dùng đến MyCallable nữa, mình muốn khai báo các Callable trực tiếp trong vòng for bằng cách dùng đến lớp vô danh cho code được gọn nhẹ.

public static void main(String[] args) throws InterruptedException, ExecutionException {
// Khai báo một Thread Pool thông qua newSingleThreadExecutor() của Executors
ExecutorService executorService = Executors.newSingleThreadExecutor();
List<Callable<String>> listCallable = new ArrayList<Callable<String>>(); // Khởi tạo danh sách các Callable
 
for (int i = 1; i <= 5; i++) {
final int _i = i;
// Khởi tạo từng Callable
listCallable.add(new Callable<String>() {
 
@Override
public String call() throws Exception {
// Trả về kết quả ở mỗi Callable
return "Callable " + _i;
}
});
}
 
// Callable nào kết thúc ở đây cũng sẽ dừng luôn Thread Pool
String result = executorService.invokeAny(listCallable);
System.out.println("Result: " + result);
 
// Phương thức này đã nói ở trên đây rồi
executorService.shutdown();
}
Kết quả in ra console của invokeAny()
Kết quả in ra console của invokeAny()

Bài Thực Hành Số 7 – invokeAll()

public static void main(String[] args) throws InterruptedException, ExecutionException {
// Khai báo một Thread Pool thông qua newSingleThreadExecutor() của Executors
ExecutorService executorService = Executors.newSingleThreadExecutor();
List<Callable<String>> listCallable = new ArrayList<Callable<String>>(); // Khởi tạo danh sách các Callable
 
for (int i = 1; i <= 5; i++) {
final int _i = i;
// Khởi tạo từng Callable
listCallable.add(new Callable<String>() {
 
@Override
public String call() throws Exception {
// Trả về kết quả ở mỗi Callable
return "Callable " + _i;
}
});
}
 
// Dùng Future để lấy về danh sách các kết quả trả về từ mỗi Callable
List<Future<String>> futures = executorService.invokeAll(listCallable);
for(Future<String> future : futures) {
System.out.println("Result: " + future.get());
}
 
// Phương thức này đã nói ở trên đây rồi
executorService.shutdown();
}
Kết quả in ra console của invokeAll()
Kết quả in ra console của invokeAll()

Thread Pool Tập 3 – ThreadPoolExecutor

Như vậy sau khi làm quen với Phần 2 về Thread Pool, bạn đã biết đến một cách khá dễ dàng để chúng ta có thể nhanh chóng xây dựng một Thread Pool mà không cần biết quá nhiều về các thông số rườm rà khác. Tuy nhiên cái gì cũng vậy, tiện dụng thì kèm theo đó công năng có thể không đủ mạnh. Chính vì vậy mà chúng ta phải cần tìm hiểu kỹ hơn về cách sử sụng Thread Pool trong Java, thông qua một cách sử dụng có phần “thủ công” hơn. Rồi cuối cùng sẽ cùng so sánh xem là với mỗi cách, điểm mạnh điểm yếu của chúng là gì nhé.

Giới Thiệu Về ThreadPoolExecutor

Như đã được giới thiệu ở bài đầu tiên, ThreadPoolExecutor chính là nhân vật chính của Thread Pool. Việc mà bài hôm trước Helper Class Executors gọi ra các câu lệnh, chẳng hạn như Executors.newFixedThreadPool(5), thực chất cũng là vì muốn giúp chúng ta đơn giản hơn trong việc khai báo một ThreadPoolExecutor mà thôi. Bạn có thể dừng ở việc hiểu về Executors ở bài trước, điều đó đủ để bạn có thể xây dựng các Thread Pool tiện dụng được sử dụng ở hầu hết các nhu cầu của bạn. Nhưng với việc tìm hiểu thêm về ThreadPoolExecutor, bạn sẽ biết được bản chất thực sự của một Thread Pool được khai báo trong Java như thế nào.

Chúng ta hãy bắt đầu với việc nhìn lại sơ đồ mô phỏng một ThreadPoolExecutor sau.

Đây chính là sơ đồ mô phỏng ThreadPoolExecutor
Đây chính là sơ đồ mô phỏng ThreadPoolExecutor

Chúng ta cũng cần thống nhất lại một số cách gọi trong bài viết hôm nay để đỡ đau đầu. Khi nói đến các Runnable, thì đó chính là các tác vụ bên trái ở sơ đồ trên, chúng sẽ được đưa vào Pool. Còn khi nói đến Queue hay hàng đợi, đó chính là các Runnable đã được vào Pool nhưng vẫn phải xếp hàng ở Task Queue ở giữa sơ đồ trên và chờ được thực thi. Và khi nói đến Thread chính là các Runnable khi này đã được thực thi (hay start) thành Thread ở dãy bên phải trong Pool.

Một vài quy ước dùng từ trong bài viết hôm nay

Sơ đồ là vậy, còn về code, bài hôm nay chúng ta sẽ làm quen với phương thức khởi tạo một ThreadPoolExecutor với các tham số đầy đủ như sau.

ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler);

Ối sao nhiều tham số vậy. Bạn đừng lo, dù khá là nhiều tham số, nhưng vai trò của chúng khá rạch ròi, và việc hiểu các thông số này không quá khó, nếu kết hợp bài bản với nhau bạn sẽ có trong tay một công cụ tuyệt vời để xây dựng một Thread Pool hoàn toàn theo ý muốn.

À, phương thức khởi tạo mẫu mình đưa ra trên đây là phương thức với đầy đủ tham số nhất. Sẽ có những phương thức khởi tạo ít tham số hơn được dùng trong các bài thực hành của bài học, bạn sẽ được làm quen hết với các loại phương thức khởi tạo này.

Một ý nữa, nếu nhìn vào khai báo lớp này, thì ThreadPoolExecutor thực ra cũng là một lớp triển khai của ExecutorService. Cho nên bài viết của ngày hôm nay sẽ tập trung hoàn toàn vào việc khai báo ThreadPoolExecutor, còn việc sử dụng lớp này như thế nào thì y chang như hướng dẫn ở phần này của bài hôm trước bạn nhé.

Và một ý cuối cùng trước khi đi qua các phần bên dưới. Rằng bài học tuy dài và có nhiều source code, nhưng các source code cũng chỉ là sự lặp đi lặp lại việc phối hợp các tham số khởi tạo cho ThreadPoolExecutor mà thôi, bạn sẽ không bị quá đau đầu về số lượng code của bài hôm nay đâu.

Làm Quen Các Tham Số Của ThreadPoolExecutor

Như bạn có thể thấy trên đây. Để điều khiển ThreadPoolExecutor, việc đầu tiên đó là chúng ta cần nắm vững các tham số của phương thức khởi tạo lớp này trước, rồi sau đó mới vận dụng chúng để tạo một Thread Pool mong muốn.

  • corePoolSize: đây chính là giá trị giúp khai báo số lượng Thread mà Thread Pool này cho phép chạy cùng lúc. Có thể gọi là core thread. Một Thread đã được gọi là core thread thì chắc chắn sẽ được thực thi. Do bạn đã quen với việc sử dụng Thread Pool ở bài trước rồi thì tham số này cũng không quá khó để hiểu.
  • maximumPoolSizeThreadPoolExecutor cho phép bạn thiết lập thêm một thông số về số lượng Thread có thể chạy bên trong Pool này nữa. Giá trị này có thể lớn hơn hoặc bằng corePoolSize, cho phép Thread Pool có thể thực thi thêm Thread nữa, nhiều hơn số lượng core thread, khi mà Queue đã chứa đầy các Runnable đang đợi. Điều này giúp ThreadPoolExecutor có thể linh động giải quyết các Runnable bị “tồn đọng” quá nhiều trong Queue.
  • keepAliveTime: nếu như trong Pool hiện tại có nhiều Thread hơn con số corePoolSize (và dĩ nhiên là nhỏ hơn hay bằng con số maximumPoolSize) còn đang “rảnh” (không có Runnable nào để thực thi nữa cả), thì những Thread này sẽ bị đếm thời gian dựa trên thông số keepAliveTime. Nếu hết thời gian chờ theo thông số này mà Thread đó vẫn chưa được thực thi, nó sẽ bị hủy. Thông số này giúp hệ thống quản lý các Thread Pool đang rảnh được hiệu quả hơn, giúp giải phóng các resource nhàn rỗi. Và vì thông số này mình chưa thấy có tác động lớn đến việc tổ tức ThreadPoolExecutor của chúng ta nên bài viết này mình sẽ không nói chi tiết em nó, chỉ nêu ra ở mục này mà thôi.
  • unit: đơn vị thời gian của keepAliveTime. Chẳng hạn nếu muốn đơn vị là mili giây thì chúng ta sẽ dùng enum TimeUnit.MILLISECONS.
  • threadFactory: giúp bạn tự định nghĩa ra một cách thức tạo mới Thread của riêng bạn. Nếu bạn không cung cấp thông số này thì Thread Pool sẽ dùng một cách thức có sẵn mà ở phần dưới của bài viết hôm nay chúng ta sẽ nói đến.
  • workQueue: hàng đợi dùng để chứa các Runnable mà Thread Pool sẽ lấy ra và thực thi lần lượt. Hàng đợi này quyết định số Runnable sẽ được đợi và số Thread được thực thi đồng thời theo corePoolSize hay maximumPoolSize. Số lượng Runnable sau khi đưa vào Pool mà vượt quá số lượng cho phép của hàng đợi sẽ bị “đối xử” tùy theo việc thiết lập handler như sau.
  • handler: quyết định cách thức mà một Runnable bị từ chối đưa vào hàng đợi. Chúng ta sẽ tìm hiểu kỹ ý nghĩa của tham số này sau.

Nào chúng ta sẽ cùng hiểu rõ dần các tham số trên thông qua các bài thực hành sau.

Bài Thực Hành Số 1 – Xây Dựng Thread Pool Giống Như newSingleThreadExecutors

Bài này chúng ta cùng xây dựng lại Thread Pool mà ở Bài thực hành số 1 của bài học trước chúng ta đã xây dựng thông qua Executors. Khi đó Thread Pool của chúng ta mỗi lần chỉ có duy nhất một Thread được chạy.

Trước hết, mình xin hiển thị lại lớp MyRunnable, mình lấy lại lớp này từ bài hôm trước, nhưng có thay đổi một chút xíu như sau để các bài thực hành được rõ nghĩa hơn.

  • Mình truyền thêm ThreadPoolExecutor vào phương thức khởi tạo luôn, để mỗi lần MyRunnable này được thực thi, chúng ta sẽ in ra số lượng Thread đang chạy và đang chờ trong Pool của chúng ta.
  • Mình override lại phương thức toString() để lát nữa đây chúng ta có thể in ra tên của MyRunnable này từ main().
public class MyRunnable implements Runnable {
 
// Tên của Runnable, giúp chúng ta phân biệt Runnable nào đang thực thi trong Thread Pool
private String name;
// ThreadPoolExecutor để in ra số lượng Thread đang chạy và đang chờ trong Pool
private ThreadPoolExecutor executor;
 
public MyRunnable(String name, ThreadPoolExecutor executor) {
this.name = name;
this.executor = executor;
}
 
@Override
public void run() {
// In tên Thread, kèm số lượng Thread đang chạy và đang chờ trong Pool
System.out.println(name + " đang thực thi... (số thread chạy: " + executor.getPoolSize() + ", số thread chờ: " + executor.getQueue().size() + ")");
 
// Giả lập thời gian chạy của Runnable mất 2 giây
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
// In trạng thái kết thúc
System.out.println(name + " kết thúc.");
}
 
@Override
public String toString() {
return name;
}
}

Và đây là cách dùng ThreadPoolExecutor ở phương thức main().

public static void main(String args[]) {
int corePoolSize = 1;
int maximumPoolSize = 1;
long keepAliveTime = 0L;
TimeUnit unit = TimeUnit.MILLISECONDS;
LinkedBlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>();
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Bạn có thể so sánh nhanh với code của bài hôm trước. Hôm nay chúng ta “thủ công hóa” với một đống tham số dùng cho phương thức khởi tạo của ThreadPoolExecutor (nếu bạn biết cách vào xem phương thức newSingleThreadExecutors() được xây dựng sẵn của bài hôm trước trên Eclipse hay InteliJ thì thấy với khai báo tham số hôm nay là tương tự nhau). Chắc các bạn cũng đoán được nhưng mình cũng xin giải nghĩa một chút cách sử dụng các tham số.

  • corePoolSize chúng ta khai báo là 1, điều này khiến cho Thread Pool chỉ chứa tối đa 1 Thread như bài hôm trước.
  • maximumPoolSize cũng là 1, chúng ta không muốn Thread Pool đưa thêm bất kỳ Thread nào vào nữa, chỉ một mà thôi.
  • keepAliveTime khi này được chỉ định là (mili giây), vì với ví dụ này tham số này không có tác dụng gì cả vì mỗi corePoolSize và maximumPoolSize là như nhau, không có Thread nào start khi vượt quá corePoolSize cả.
  • workQueue sẽ chứa trong một LinkedBlockingQueue. Mình sẽ nói cụ thể về các loại Queue sau. Bạn chỉ cần biết với việc dùng LinkedBlockingQueue thì Queue này giúp chứa không giới hạn số lượng các Runnable đợi được thực thi khi mà corePoolSize đã đầy.

Có thể bạn chưa hiểu lắm các thông số. Nhưng cứ thực thi chương trình đi nhé, chúng ta sẽ làm rõ dần các thông số này ở các bài thực hành tiếp theo bên dưới. Và đây là kết quả in ra console của code trên đây.

Kết quả luôn luôn có 1 Thread trong Pool được chạy
Kết quả luôn luôn có 1 Thread trong Pool được chạy

Bạn có thể thấy số Thread đang chạy luôn luôn là 1. Cứ mỗi một Runnable được thực thi, thì con số Thread đang chờ (đúng hơn sẽ là các Runnable đang chờ) sẽ giảm dần.

Bài Thực Hành Số 2 – Xây Dựng Thread Pool Giống Như newFixedThreadPool

Chúng ta lại thử xây dựng Thread Pool như Bài thực hành số 2 của bài học trước. Để cùng nhau thấy rằng việc dùng Thread Pool với cách của bài trước hay bài hôm nay đều cho các kết quả tương đương, chỉ khác một chút cách khai báo tham số mà thôi.

Bài thực hành này mong muốn rằng mỗi một lần sẽ có 5 Thread được start. Cứ Thread nào kết thúc mà trong Queue vẫn còn Runnable đang đợi thì sẽ được Thread Pool start tiếp cho đủ số lượng corePoolSize mong muốn. Code sau cũng không khác Bài thực hành 1 trên kia lắm, chỉ thay các thông số corePoolSize và maximumPoolSize từ 1 lên 5 mà thôi.

public static void main(String args[]) {
int corePoolSize = 5;
int maximumPoolSize = 5;
long keepAliveTime = 0L;
TimeUnit unit = TimeUnit.MILLISECONDS;
LinkedBlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>();
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Kết quả thực thi Pool này như bài học trước bạn cũng thấy. Đầu tiên 5 Thread được thực thi. Sau đó hễ Thread nào xong thì Thread khác được start. Đảm bảo luôn luôn không quá 5 Thread được thực thi trong Pool.

Kết quả luôn luôn có tối đa 5 Thread trong Pool được chạy
Kết quả luôn luôn có tối đa 5 Thread trong Pool được chạy

Sở dĩ hàng chờ ban đầu không có Thread nào là vì Thread nào đưa vào Queue cũng được thực thi ngay, không có Thread nào kịp vào chờ cả. Sau đó khi corePoolSize đã đầy, thì Queue mới chứa các Runnable.

À và vì việc các Thread được start và câu lệnh in ra console không “đồng bộ” với nhau khiến thứ tự in ra của chúng hơi lộn xộn, bạn nên nhìn theo số thứ tự của Runnable, như Runnable 1Runnable 2,… sẽ thấy số thread đang chạy sẽ tăng theo đúng với số Thread trong Pool thật bạn nhé.

Nói thêm

Bài Thực Hành Số 3 – Xây Dựng Thread Pool Giống Như newCachedThreadPool

Tiếp tục với việc xây dựng Thread Pool tương đồng với Bài thực hành số 3 của bài học hôm trước, để chúng ta hiểu rõ hơn về cách kiểm soát ThreadPoolExecutor của bài hôm nay.

Thực sự thì với việc sử dụng Executors.newCachedThreadPool() từ bài hôm trước, có thể bạn cũng không mường tượng được hết công dụng của loại Thread Pool này. Hôm nay với việc khai báo thủ công Thread Pool này một lần nữa, sẽ hé lộ thêm nhiều thông tin đấy nhé. Trước hết hãy đến việc code cái nào.

public static void main(String args[]) {
int corePoolSize = 0;
int maximumPoolSize = Integer.MAX_VALUE;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
SynchronousQueue<Runnable> workQueue = new SynchronousQueue<>();
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Wow, cách sử dụng tham số khi này có một chút đặc biệt. Chúng ta hãy cùng xem qua.

  • corePoolSize được chỉ định là 0, nhưng maximumPoolSize lại là giá trị lớn nhất mà kiểu Int có thể hỗ trợ. Điều này ban đầu giúp cho Thread Pool không quan tâm giá trị corePoolSize nữa mà sẽ thực thi với số lượng Thread lớn nhất mà hệ thống có thể đáp ứng được, vì giá trị maximumPoolSize khi này rất lớn.
  • keepAliveTime được chỉ định 60 giây. Và bởi vì corePoolSize được chỉ định là 0, nên khi này tất cả các Thread đều bị áp đặt bởi việc đếm ngược 60 giây này. Các Thread không được start trong thời gian này sẽ bị hủy.
  • workQueue khi này lại là một SynchronousQueue. Cũng là Queue như 2 bài thực hành trên thôi, nhưng việc dùng khác loại Queue này sẽ tác động đến cách Runnable được nằm trong hàng đợi như thế nào. Cụ thể thì SynchronousQueue khá đặc biệt, nó giúp start luôn Runnable được đưa vào và không nắm giữ chúng ở Queue gì cả. Các mục dưới của bài học sẽ làm rõ các Queue bạn yên tâm.

Kết quả thực thi cho thấy tất cả các Thread khi này đều được thực thi cùng lúc, và không có Thread nào phải chờ cả.

Kết quả tất cả các Thread trong Pool đều được chạy cùng lúc
Kết quả tất cả các Thread trong Pool đều được chạy cùng lúc

Với 3 bài thực hành trên đây đã giúp bạn phần nào hiểu rõ hơn về ThreadPoolExecutor đúng không nào. Việc của bạn chỉ là hiểu rõ và phối hợp các tham số khởi tạo để có được một Thread Pool đa dạng theo nhu cầu của bạn mà thôi.

Tuy nhiên các ví dụ trên cũng chỉ là các gợi ý ban đầu về việc kết hợp các tham số khởi tạo này. Chúng ta hãy cùng nhau đi sâu hơn một tí về từng tham số để cùng hiểu sâu hơn.

Tìm Hiểu corePoolSize Và maximumPoolSize

Như giới thiệu từ đầu bài học thì bạn đã hiểu corePoolSize và maximumPoolSize rồi. Hai tham số này giúp điều khiển số lượng Thread cùng chạy song song bên trong một Pool.

Nếu corePoolSize và maximumPoolSize có cùng giá trị. Chúng được gọi là Fixed-size Thread Pool, tức là các Thread Pool ở Bài thực hành số 1 và 2 trên đây. Điều này chúng ta không cần phải nói nhiều.

Nhưng nếu chúng khác nhau về giá trị, khi đó maximumPoolSize buộc phải lớn hơn giá trị corePoolSize nếu không muốn ứng dụng sẽ tung ra một IllegalArgumentException. Với việc khai báo kiểu này, thì chỉ khi Queue đã chứa đầy các Runnable cần chạy, khi đó số lượng Thread trong Pool mới được thực thi vượt giá trị corePoolSize và có thể đạt đến con số maximumPoolSize.

Chúng ta hãy đến với các bài thực hành sau để hiểu rõ hơn cách dùng hai tham số này.

Bài Thực Hành Số 4 – Thread Pool Đạt Tới Mức maximumPoolSize

Chúng ta cùng xem. Với corePoolSize là 2, và maximumPoolSize là 4, Queue của chúng ta chỉ khai báo cho phép chứa được 6 Runnable. Thì với việc nhét 10 Runnable vào Pool, với hình chụp console bên dưới bạn sẽ thấy nhanh chóng Queue sẽ chứa đầy 6 Runnable. Việc Queue “đầy ắp” thế này khiến Thread Pool sẽ lấy tới con số lớn nhất là 4 Thread cùng chạy cùng một lúc, nếu không sẽ “vỡ đê” (thực ra nếu Thread Pool không mở rộng số Thread cần chạy, nhiều Runnable sẽ bị hủy nếu bạn xem tiếp các ví dụ bên dưới bài học).

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(6);
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}
Kết quả có tới 4 Thread chạy cùng lúc
Kết quả có tới 4 Thread chạy cùng lúc

Bài Thực Hành Số 5 – Thread Pool Start Ở Mức corePoolSize

Cũng với code trên, lần này chúng ta nới thêm số lượng Queue lên 15, khiến cho số lượng Runnable đưa vào Pool không đủ làm đầy Queue này, chính vì thế mà chúng ta thấy mỗi lần chỉ có 2 Thread được thực thi thôi, đó cũng chính là giá trị của corePoolSize.

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(15);
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}
Kết quả chỉ có 2 Thread chạy cùng lúc
Kết quả chỉ có 2 Thread chạy cùng lúc

Tìm Hiểu threadFactory

Ở các ví dụ trên đây chúng ta không cần để ý đến threadFactory, khi này Thread Pool sẽ mặc định gọi đến Executors.defaultThreadFactory() cho chúng ta. Vậy threadFactory là gì?

Như chúng ta đã biết, khi dùng đến Thread Pool, chúng ta chỉ truyền vào đây các Runnable. Mà như cách thức tạo Thread từ Runnable mình có nói, để một Runnable start được, chúng ta phải đưa nó vào một khởi tạo của lớp Thread rồi gọi đến phương thức start() từ lớp Thread này. Trong Thread Pool cũng vậy, nó cũng cần có một Thread bao lấy Runnable cần chạy rồi start Runnable đó. Và Executors.defaultThreadFactory() giúp tận dụng pattern Factory để xây dựng sẵn một kịch bản giúp tạo ra Thread từ Runnable này, nó không đơn thuần chỉ new một Thread rồi truyền Runnable vào đâu, mà nó còn tạo sẵn các giá trị đồng nhất cho các Thread trong Pool, như đặt tên mặc định cho Thread, nhóm chung vào một ThreadGroup, khai báo cùng độ ưu tiên,…

Bài Thực Hành Số 6 – Tự Xây Dựng threadFactory Trong Thread Pool

Cũng với code quen thuộc như các bài thực hành trước, nhưng dưới đây mình thay việc sử dụng MyRunnable bằng một lớp vô danh của Runnable để nhanh chóng sửa nội dung in ra console của nó khi được chạy.

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(15);
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Tạo một Runnable vô danh rồi in tên của lớp ra khi được thực thi
Runnable myRunnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " thực thi");
}
};
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Bạn thấy rằng code trên chưa cũng chưa khai báo gì tới threadFactory cả và vì vậy mà tên của Runnable khi start sẽ như sau.

Kết quả khi in ra tên mặc định của Thread
Kết quả khi in ra tên mặc định của Thread

Bạn có thấy rằng các Thread này được nhóm chung một ThreadGroup nên chúng có cùng tên pool-1 ở đầu không. Còn tại sao chỉ chó thread-1 và thread-2 được thực thi trong khi chúng ta có tới 10 Runnable, điều này là vì 2 Thread được tạo ra với các tên thread-1 và thread-2 được tận dụng trở lại để khởi chạy các Runnable tiếp theo trong Queue, hệ thống không tạo ra thêm một Thread nào mới ngoài 2 Thread này hết. Bạn đã thấy sự lợi hại của Thread Pool trong việc tận dụng tối đa resource cho việc thực thi các tác vụ song song chưa nào.

Dưới đây là việc thay đổi nhỏ, bằng cách tạo ra một lớp vô danh nữa kế thừa từ lớp ThreadFactory và rồi override phương thức newThread của nó. Bằng cách này chúng ta sẽ kiểm soát tên Thread được khởi tạo thông qua hai tham số của Thread như sau.

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(15);
 
ThreadFactory threadFactory = new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "Thread của tôi");
}
};
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Tạo một Runnable vô danh rồi in tên của lớp ra khi được thực thi
int index = i;
Runnable myRunnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " - " + index + " thực thi");
}
};
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Với việc thay đổi trên thì console sẽ như sau.

Kết quả khi in ra tên Thread được xây dựng lại thông qua threadFactory
Kết quả khi in ra tên Thread được xây dựng lại thông qua threadFactory

Tìm Hiểu workQueue

Như các ví dụ trên bạn cũng đã hiểu workQueue là một tham số giúp chúng ta định nghĩa thể loại Queue mà Thread Pool dùng để chứa đựng các Runnable đã vào Pool nhưng còn đang đợi, chưa được thực thi. Có Queue cho phép chúng ta chỉ định cứng độ lớn cho hàng đợi, có Queue không cần. Vậy thì tóm lại là ThreadPoolExecutor có thể có bao nhiêu hàng đợi. Mình xin liệt kê chúng từng phần như sau.

SynchronousQueue

Queue này được giới thiệu là một dạng Queue bàn giao trực tiếp. Tức là chúng không lưu trữ bất kỳ Runnable nào trên Queue cả, ngay khi chúng nhận được các Runnable, chúng sẽ tìm cách thực thi ngay các Runnable này. Queue này không thể chỉ định độ lớn, nó chứa đựng tối đa các Runnable đưa vào Pool. Bạn đã làm quen với Queue này ở Bài thực hành số 3 trên đây.

Tuy nhiên nếu như có nhiều Runnable vào Queue hơn số lượng có thể thực thi (thông qua các tham số corePoolSize và maximumPoolSize mà bạn đã biết), thì các Runnable chưa được start ngay sẽ bị hủy, và một RejectedExecutionException sẽ được tung ra. Bài thực hành dưới đây mình sẽ cho bạn xem ý này, một lát nữa ở mục bên dưới chúng ta sẽ xử lý Exception này sao cho đẹp mắt hơn.

Bài Thực Hành Số 7 – Sử Dụng SynchronousQueue

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
SynchronousQueue<Runnable> workQueue = new SynchronousQueue<>();
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Bạn thấy rằng Pool của chúng ta được nới rộng ra chạy với tối đa Thread thì… boom, Exception bị tung ra và các Runnable còn chưa kịp start đều đã bị hủy hết.

Kết quả khi thực thi chỉ được 4 Thread thì Exception tung ra
Kết quả khi thực thi chỉ được 4 Thread thì Exception tung ra

LinkedBlockingQueue

Được mệnh danh là Queue không giới hạn. Thường thì người ta dùng đến hàng đợi này mà không quá quan tâm đến sức chứa tối đa của nó (mặc dù bạn hoàn toàn có thể chỉ định sức chứa này thông qua phương thức khởi tạo của nó). Việc xem như có sức chứa không giới hạn này giúp cho các Thread bên trong Thread Pool thường chỉ dùng đến số lượng corePoolSize để thực thi các tác vụ, và như vậy maximumPoolSize cũng sẽ bị lu mờ trong việc dùng hàng đợi kiểu này. Bạn xem code ở bài thực hành sau (Bài thực hành số 1 & 2 trên đây đã nói về LinkedBlockingQueue rồi, tuy nhiên lúc đó bạn chỉ định corePoolSize và maximumPoolSize như nhau, bài thực hành dưới đây cho thấy nếu chúng ra dùng khác nhau 2 thông số này).

Bài Thực Hành Số 8 – Sử Dụng LinkedBlockingQueue

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
LinkedBlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>();
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Bạn xem Pool của chúng ta chỉ chạy mỗi 2 Thread một lần mà thôi, và không có Thread nào bị hủy do Queue chứa được rất nhiều.

Kết quả chỉ có 2 Thread chạy cùng lúc
Kết quả chỉ có 2 Thread chạy cùng lúc

ArrayBlockingQueue

Nếu bạn sợ các hàng đợi không giới hạn trên đây có thể gây ảnh hưởng xấu đến hiệu năng của hệ thống. Thì đây ArrayBlockingQueue luôn đòi hỏi bạn phải khai báo độ lớn của hàng đợi. Tuy nhiên việc dùng Queue kiểu này thường khá đau đầu, vì chúng ta không biết chỉ định độ lớn của hàng đợi bao nhiêu là đủ. Không phải việc tiết kiệm hàng đợi thông qua chỉ định độ lớn nhỏ lại là một ý hay đâu nhé, việc các Runnable không được vào hàng đợi do không đủ chỗ cho nó, có thể khiến các Runnable này bị hủy, và lại dẫn tới việc tăng corePoolSize hay maximumPoolSize lên, thì lại là vấn đề đau đầu khác. Như bài thực hành sau nếu bạn chỉ định hàng đợi chỉ có 2 Runnable chờ, thì không đủ để chạy 10 Runnable, và như vậy bạn sẽ thấy số còn lại chưa kịp vào hàng đợi sẽ bị hủy và một Exception cũng xuất hiện.

Bài Thực Hành Số 9 – Sử Dụng ArrayBlockingQueue

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}
Kết quả không đủ 10 Runnable được thực thi
Kết quả không đủ 10 Runnable được thực thi

Tìm Hiểu handler

Các ví dụ trên đây chưa có ví dụ nào nói rõ về việc sử dụng handler này cả. Mình dùng đến tên tham số handle để chỉ về mục cuối cùng cần tìm hiểu của bài viết hôm nay thôi, thực ra nó là RejectedExecutionHandler. Đọc đầy đủ tên lớp giúp chúng ta nắm rõ hơn về công năng của thành phần này đúng không nào. Đây là thành phần giúp chúng ta quản lý cách thức hành xử khi mà các Runnable bị từ chối thực hiện bởi Thread Pool, bởi vì không vào được Queue, như hai Bài thực hành số 7 & 9 trên đây.

Để hiểu về RejectedExecutionHandler hơn là để vận dụng các handler vào thực tế, chúng ta hãy đến với bài thực hành sau.

Bài Thực Hành Số 10 – Xây Dựng RejectedExecutionHandler Của Chúng Ta

Với bài thực hành này chúng ta sẽ xây dựng một thể hiện của RejectedExecutionHandler, với thể hiện này chúng ta chỉ cần in ra console Runnable nào đã được hủy mà thôi. Code của Bài thực hành số 9 trên đây được xây dựng lại như sau.

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
RejectedExecutionHandler handler = new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println(r.toString() + " bị hủy.");
}
};
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler);
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Với việc chỉ định handler như trên, bạn thấy rằng khi này console sẽ in ra các Runnable bị hủy rõ ràng đúng không nào, thay vì tung ra một Exception và không rõ nghĩa gì cả ở Bài thực hành số 9.

Kết quả không đủ 10 Runnable được thực thi, nhưng biết được Runnable nào bị hủy
Kết quả không đủ 10 Runnable được thực thi, nhưng biết được Runnable nào bị hủy

Tuy với bài thực hành trên chúng ta hiểu được cách mà Thread Pool gọi đến handle một khi Runnable bị hủy. Nhưng cách thức trên đây dù sao cũng chỉ mang tính in ra console để tham khảo. Thực tế khi sử dụng ThreadPoolExecutor chúng ta có các handler được xây dựng sẵn như sau.

ThreadPoolExecutor.AbortPolicy

Nếu bạn sử dụng handler này. Uhm, nó y chang Bài thực hành số 9. Vì thực ra nếu chúng ta không khai báo gì cho handlerThreadPoolExecutor sẽ sử dụng ThreadPoolExecutor.AbortPolicy làm mặc định cho chúng ta. Tuy nhiên bài thực hành tiếp theo đây chúng ta cũng sẽ khai báo nó một cách tường minh cho dễ hiểu.

Bài Thực Hành Số 11 – Sử dụng ThreadPoolExecutor.AbortPolicy

Bạn hãy để ý đến tham số cuối cùng của ThreadPoolExecutor như sau.

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, new ThreadPoolExecutor.AbortPolicy());
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Kết quả như mình có nói, không khác Bài thực hành số 9.

Kết quả giống như bài thực hành số 9
Kết quả giống như bài thực hành số 9

ThreadPoolExecutor.CallerRunsPolicy

Khi sử dụng handler này thì bạn có thể yên tâm rằng không có một Runnable nào bị hủy cả. Ngay khi Runnable đó bị từ chối, thay vì tung ra một Exception, handle dựng sẵn này sẽ thực thi lại Runnable đó.

Bài Thực Hành Số 12 – Sử dụng ThreadPoolExecutor.CallerRunsPolicy

Nào chúng ta chỉ thay thế một chút tham số của code ở bài thực hành trên thôi.

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, new ThreadPoolExecutor.CallerRunsPolicy());
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Kết quả là đầy đủ 10 Runnable được thực thi.

Kết quả đủ 10 Runnable được thực thi
Kết quả đủ 10 Runnable được thực thi

ThreadPoolExecutor.DiscardPolicy

Handler này chỉ đơn giản là bỏ qua Runnable bị từ chối thôi, không làm gì cả.

Bài Thực Hành Số 13 – Sử dụng ThreadPoolExecutor.DiscardPolicy

Không nói nhiều, mời bạn cùng xem code.

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, new ThreadPoolExecutor.DiscardPolicy());
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}
Kết quả chỉ có 6 Runnable được thực thi, số Runnable bị hủy không hiện ra
Kết quả chỉ có 6 Runnable được thực thi, số Runnable bị hủy không hiện ra

ThreadPoolExecutor.DiscardOldestPolicy

Handler này đảm bảo khi Runnable chưa được thực thi mà bị hủy, thì những Runnable tồn tại lâu nhất sẽ bị hủy trước.

Bài Thực Hành Số 14 – Sử dụng ThreadPoolExecutor.DiscardOldestPolicy

Cũng chỉ với một thay đổi ở tham số.

public static void main(String args[]) {
int corePoolSize = 2;
int maximumPoolSize = 4;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
 
// Khai báo một Thread Pool thông qua ThreadPoolExecutor()
ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, new ThreadPoolExecutor.DiscardOldestPolicy());
 
// Khai báo 10 Runnable, và "quăng" chúng vào Thread Pool vừa khai báo
for (int i = 1; i <= 10; i++) {
// Phương thức khởi tạo của MyRunnable có tham số name và executor truyền vào
MyRunnable myRunnable = new MyRunnable("Runnable " + i, executor);
executor.execute(myRunnable);
}
 
// Phương thức này đã nói ở phần ExecutorService của bài hôm trước
executor.shutdown();
}

Bạn xem với 4 Runnable đầu không có gì phải bàn, nó là các Runnable được Thread Pool mang vào thực thi sớm nhất. Thế nhưng khi Runnable được lấy tiếp từ Queue để thực thi, thì chính là các Runnable số 9 & 10 được chỉ định, đây là các Runnable “trẻ nhất”, chúng được thêm vào Queue sau cùng, các Runnable “già” khác đã thăng thiên sớm rồi nhé.

Kết quả chỉ có 6 Runnable được thực thi, sự ưu tiên Runnable tiếp theo đó trong Queue đáng chú ý
Kết quả chỉ có 6 Runnable được thực thi, sự ưu tiên Runnable tiếp theo đó trong Queue đáng chú ý

Generic Tập 1 – Làm Quen Với Generic

Sau chuỗi bài dài đăng đẳng về lập trình song song, đa nhiệm thông qua Thread và những kiến thức mở rộng liên quan đến nó. Hôm nay chúng ta sẽ đi đến một kiến thức nhẹ ký hơn, nhưng không kém phần quan trọng trong việc lập trình với ngôn ngữ Java. Kiến thức về Generic.

Tuy bài học được chia nhỏ làm nhiều phần, nhưng lượng kiến thức mà chúng mang lại cũng không quá nhiều. Cái chính là những giải nghĩa để giúp các bạn hiểu rõ về Generic này. Vì theo kinh nghiệm của mình đã từng lân la tìm hiểu ở khá nhiều tài liệu khác nhau, thì Generic ở các tài liệu này viết rất khó hiểu, đặc biệt là để giải nghĩa cho câu hỏi tại sao phải dùng Generic, thì ít nơi nào nói rõ. Thôi thì mời các bạn cùng đọc bài viết của mình để cùng so sánh nhé.

Generic Là Gì?

Dĩ nhiên việc đầu tiên khi tìm hiểu đến một kỹ thuật mới mẻ, chúng ta lại tra từ điển. Generic, dịch ra là chung. Thực ra không phải nghĩa của nó là chung chung, là không rõ ràng đâu. Mà nó là một loại, hay một tập hợp chung nào đó. Trái nghĩa với đặc trưng, đặc thù.

Generic trong lập trình cũng vậy, nó được dùng trong ngữ cảnh khi bạn muốn xây dựng các chức năng hay các đối tượng nào đó mà bạn không cần quan tâm đến một kiểu dữ liệu đặc trưng cụ thể, bạn định nghĩa ra một kiểu tập hợp chung cho các kiểu dữ liệu. Cái kiểu tập hợp chung đó được bạn định nghĩa ra ở thời điểm mà bạn lập trình, khi này bạn không bị gò bó quá nhiều vào kiểu dữ liệu đặc trưng nữa, bạn thoải mái sáng tác ra các chức năng hay đối tượng có thể dùng chung cho nhiều kiểu dữ liệu khác nhau. Khi thực thi ứng dụng, bạn mới cần chỉ định cụ thể kiểu dữ liệu đặc trưng cần dùng đến, hoặc hệ thống cũng tự suy ra kiểu dữ liệu đặc trưng cho bạn.

Bởi vì vậy mà Generic còn được gọi là Typed Parameters. Tức là khi này tham số bạn đưa vào cho chức năng hay đối tượng khi thực thi chính là một kiểu dữ liệu đặc trưng nào đó.

Trước khi đi vào tìm hiểu kỹ hơn về Generic, mình xin nhắc lại rằng thực ra đâu đó, bạn cũng từng ít nhiều lần gặp đến Generic này. Vì một lẽ Generic khá phố biến và được sử dụng rất nhiều trong Java, nếu bạn còn nhớ, ở bài thực hành số 5 của bài 49 này cũng có đoạn code mà chúng ta phải dùng đến GenericCallable<String>, khi còn chưa biết đến nó là gì.

Bài thực hành số 5 của bài 49 đã dùng đến Generic
Bài thực hành số 5 của bài 49 đã dùng đến Generic

Qua đó bạn có thể thấy Generic là cái gì đó cũng khá là gần gũi và dễ dùng đúng không nào. Vâng, việc sử dụng code đã có Generic đôi khi cũng dễ dàng và tường minh như ví dụ trên. Tuy nhiên hãy cùng mình đi tiếp các kiến thức tiếp theo của chuỗi bài này, để hiểu rõ hơn về Generic. Chắc chắn trong sự nghiệp code Java của bạn, bạn sẽ phải cần đủ lượng kiến thức để mà còn giải quyết các lỗi lạ lùng khi sử dụng đến sự kết hợp của nhiều Generic trong code. Cuối cùng bạn cũng phải tự viết ra các phương thức hay đối tượng có sử dụng Generic của riêng bạn nữa chứ.

Tại Sao Lại Cần Generic?

Có thể các ý trên đây đã nói rõ về mục đích cần đến Generic. Nhưng đó là ý đó nói vậy, chứ bạn vẫn chưa thấy được sự cần thiết của kiểu dữ liệu chung này. Vậy hãy cùng mình đi đến ví dụ rất đơn giản như sau.

Giả sử bạn có yêu cầu phải viết một phương thức: tìm số nhỏ nhất từ hai số truyền vào. Rất dễ đúng không nào. Nhanh chóng thôi, bạn đã viết xong phương thức đó có tên minFromTwoNumbers() như sau.

public static int minFromTwoNumbers(int one, int two) {
if (one < two) {
return one;
} else {
return two;
}
}
 
public static void main(String[] args) {
int one = 7;
int two = 3;
System.out.println("Số nhỏ nhất giữa " + one + " và " + two + " là " + minFromTwoNumbers(one, two));
}
 
// Kết quả
// Số nhỏ nhất giữa 7 và 3 là 3

Nhìn có vẻ mọi thứ ổn thỏa. Tuy nhiên, ngay khi áp dụng minFromTwoNumbers() của bạn vào thực tế, bạn mới nhận ra rằng thực ra phương thức của bạn chỉ giúp tìm số nhỏ nhất giữa hai số nguyên thôi, với hai số kiểu float như sau thì ứng dụng lại báo lỗi không thực thi.

float oneF = 3.5f;
float twoF = 5.7f;
System.out.println("Số nhỏ nhất giữa " + oneF + " và " + twoF + " là " + minFromTwoNumbers(oneF, twoF));

Ôi có gì đâu mà. Bạn đã biết rằng các phương thức trong một lớp có thể khai báo theo kiểu nạp chồng. Do đó bạn hoàn toàn có thể xây dựng các phương thức có cùng tên là minFromTwoNumbers() nhưng khác kiểu tham số truyền vào là xong chứ gì.

public static int minFromTwoNumbers(int one, int two) {
if (one < two) {
return one;
} else {
return two;
}
}
 
public static float minFromTwoNumbers(float one, float two) {
if (one < two) {
return one;
} else {
return two;
}
}
 
public static void main(String[] args) {
int one = 7;
int two = 3;
System.out.println("Số nhỏ nhất giữa " + one + " và " + two + " là " + minFromTwoNumbers(one, two));
 
float oneF = 3.5f;
float twoF = 5.7f;
System.out.println("Số nhỏ nhất giữa " + oneF + " và " + twoF + " là " + minFromTwoNumbers(oneF, twoF));
}
 
// Kết quả
// Số nhỏ nhất giữa 7 và 3 là 3
// Số nhỏ nhất giữa 3.5 và 5.7 là 3.5

Cơ mà thấy có gì đó dư thừa code ở đây. Mặc dù ứng dụng chạy tốt, nhưng việc viết lặp lại hai phương thức minFromTwoNumbers() khiến ứng dụng phình ra một cách không đáng có. Những lập trình viên có kinh nghiệm sẽ tránh việc lặp lại này. Vả lại ứng dụng của chúng ta vẫn còn lỗi khi mà nhu cầu thực tế muốn tìm số nhỏ nhất giữa hai số double thì sao. Chẳng lẽ chúng ta phải nạp chồng tất cả các kiểu số? Điều đó làm tăng sự lặp lại. Vả lại việc xây dựng nhiều phương thức nạp chồng như thế này còn có thể gây ra những lỗi tiềm ẩn khác khi chúng ta có nhu cầu chỉnh sửa chúng sau này. Hãy cùng mình giải quyết bài toán này với hai cách như sau, sẽ giúp bạn có cái nhìn rõ ràng ban đầu về Generic.

Cách Giải Quyết Không Dùng Generic

Sau một thời gian tra cứu Google, và cũng tham khảo nhiều ở blog của mình. Bạn nhận ra rằng bạn có thể chỉ cần đến duy nhất một phương thức minFromTwoNumbers() mà thôi. Khi đó đầu vào cho phương thức này phải là một lớp đại diện cho tất cả các kiểu dữ liệu. Uhm… lớp đại diện, có rồi, chính là lớp Object. Bạn tiến hành chỉnh sửa minFromTwoNumbers() như sau.

public static Object minFromTwoNumbers(Object objOne, Object objTwo) {
// Chuyển 2 object objOne và objTwo về 2 số có thể so sánh được
double one = ((Number) objOne).doubleValue();
double two = ((Number) objOne).doubleValue();
 
if (one < two) {
return objOne;
} else {
return objTwo;
}
}
 
public static void main(String[] args) {
Integer one = 7;
Integer two = 3;
System.out.println("Số nhỏ nhất giữa " + one + " và " + two + " là " + minFromTwoNumbers(one, two));
 
Float oneF = 3.5f;
Float twoF = 5.7f;
System.out.println("Số nhỏ nhất giữa " + oneF + " và " + twoF + " là " + minFromTwoNumbers(oneF, twoF));
 
Double oneD = 3.4567;
Double twoD = 3.45577;
System.out.println("Số nhỏ nhất giữa " + oneD + " và " + twoD + " là " + minFromTwoNumbers(oneD, twoD));
}
 
// Kết quả
// Số nhỏ nhất giữa 7 và 3 là 3
// Số nhỏ nhất giữa 3.5 và 5.7 là 5.7
// Số nhỏ nhất giữa 3.4567 và 3.45577 là 3.45577

Khi này bạn không cần để ý đến kiểu dữ liệu int hay float hay bất kỳ kiểu số nào cho tham số đầu vào của minFromTwoNumbers() nữa. Mà bạn sử dụng luôn hai Object. Có điều để có thể so sánh được hai giá trị kiểu Object này, bạn sẽ phải tìm cách ép kiểu chúng về các số có thể so sánh được. Trong trường hợp này để tránh mất mát dữ liệu, bạn dùng kiểu double cho chắc. Nhưng muốn về được kiểu double nguyên thủy thì bạn lại phải thông qua việc ép kiểu Object về Number, rồi từ Number mới unboxing về lại kiểu nguyên thủy double.

Number là một lớp abstract. Nó là cha của các lớp biểu diễn số quen thuộc như ByteShortIntegerLongFloatDouble,…. Rất hữu ích khi cần đến Number để chuyển đổi các giá trị nguyên thủy tương ứng như byteshortintlongfloatdouble,… như một ví dụ mà chúng ta vừa thấy trên đây.

Hiểu thêm về Number

Tuy hơi lằng nhằng xíu nhưng đáng giá đúng không nào. Khi này bạn chỉ cần duy nhất một minFromTwoNumbers() cho tất cả các kiểu dữ liệu số. Tuy nhiên do đầu vào là các Object, do đó khi sử dụng ở main(), tốt hơn chúng ta sẽ dùng các lớp wrapper cho từng loại dữ liệu, như IntegerFloat hay Double.

Cách tiếp cận đến lớp Object cho ví dụ trên đây để tránh việc ràng buộc vào một kiểu dữ liệu nào đó, người ta gọi là cách tiếp cận theo kiểu kế thừa kiểu dữ liệu. Cách tiếp cận này được sử dụng trước khi Generic được hỗ trợ bởi Java. Tuy cách này cũng khá hiệu quả đấy, nhưng nó tiềm ẩn nhiều rủi ro cho chúng ta. Chúng ta cùng điểm qua một vài rủi ro có thể trông thấy được như sau.

Thứ nhất, bạn cũng dễ dàng thấy rằng do tham số đầu vào của phương thức là một Object, nó sẽ nhận tất cả các kiểu dữ liệu mà bạn truyền vào. Bạn muốn rằng chương trình sẽ kiểm tra hai giá trị số, nhưng trong thực tế nếu hai số đó là kiểu String như sau thì sao.

String oneS = "1";
String twoS = "2";
System.out.println("Số nhỏ nhất giữa " + oneS + " và " + twoS + " là " + minFromTwoNumbers(oneS, twoS));

Với code trên đây trình biên dịch không báo lỗi hay cảnh báo gì cả. Cũng đúng thôi vì nó nghĩ bạn đang truyền đúng kiểu dữ liệu đầu vào, String cũng là một Object mà thôi (tính đa hình). Nhưng khi thực thi chương trình, lỗi sẽ tung ra và ứng dụng của bạn sẽ chết. Như vậy rủi ro đầu tiên này là: rất khó để chương trình của chúng ta kiểm soát đầu vào vì bạn đang sử dụng kiểu Object, là bất kỳ kiểu dữ liệu nào.

Thứ hai, bởi vì đầu vào là một Object, bạn sẽ cần ở đâu đó việc ép kiểu các kiểu dữ liệu này về các kiểu dữ liệu mong muốn, việc ép kiểu sẽ gây ra việc crash ứng dụng nếu chúng ta không cẩn thận. Bạn có thể thêm try catch hoặc câu lệnh kiểm tra điều kiện các giá trị đầu vào, nhưng như vậy cũng khá là tốn công.

Hãy xem cách thức sử dụng Generic như sau.

Cách Giải Quyết Với Generic

Nào bạn hãy cùng so sánh nhé. Chưa cần biết nhiều về Generic nhưng hãy xem trước cách sử dụng chúng thông qua thay đổi code cho minFromTwoNumbers() như sau.

 
public static <T extends Comparable> T minFromTwoNumbers(T one, T two) {
if (one.compareTo(two) < 0) {
return one;
} else {
return two;
}
}
 
public static void main(String[] args) {
Integer one = 7;
Integer two = 3;
System.out.println("Số nhỏ nhất giữa " + one + " và " + two + " là " + minFromTwoNumbers(one, two));
 
Float oneF = 3.5f;
Float twoF = 5.7f;
System.out.println("Số nhỏ nhất giữa " + oneF + " và " + twoF + " là " + minFromTwoNumbers(oneF, twoF));
 
Double oneD = 3.4567;
Double twoD = 3.45577;
System.out.println("Số nhỏ nhất giữa " + oneD + " và " + twoD + " là " + minFromTwoNumbers(oneD, twoD));
 
String oneS = "1";
String twoS = "2";
System.out.println("Số nhỏ nhất giữa " + oneS + " và " + twoS + " là " + minFromTwoNumbers(oneS, twoS));
}
 
// Kết quả
// Số nhỏ nhất giữa 7 và 3 là 3
// Số nhỏ nhất giữa 3.5 và 5.7 là 3.5
// Số nhỏ nhất giữa 3.4567 và 3.45577 là 3.45577
// Số nhỏ nhất giữa 1 và 2 là 1
 

Có thể bạn nhìn chưa quen, nhưng bạn có thể đoán ngay được T đã thay thế cho ObjectT là một kiểu dữ liệu chung nào đó mà không phải Object. Bạn thấy rằng khi này T không “mơ hồ” như Object, mà nó là một kiểu dữ liệu nào đó extends từ Comparable. Như vậy T thể hiện một sự cụ thể và rõ ràng hơn so với Object. Với kiểu khai báo “sơ” về T như vậy, hệ thống sẽ biết T là một kiểu Comparable, các kiểu số hay String đều implement từ Comparable cả. Do đó nó giúp ràng buộc đầu vào cho phương thức, chẳng hạn bạn không thể truyền lớp Circle mà ở các bài học trước bạn tự tạo ra được rồi đó, vì Circle không implement Comparable. Điều này giúp tránh xảy ra crash ứng dụng như với cách dùng Object. Hơn nữa với việc hiểu T là kiểu dữ liệu gì, thì khi vào thân hàm, chúng ta không cần đến ép kiểu dài dòng nữa, mà dùng luôn phương thức compareTo() có sẵn ở Comparable, hệ thống tự hiểu và cho phép bạn làm điều này.

Bạn có thấy là khi dùng sang Generic như thế này, bạn hoàn toàn có thể so sánh cả hai chuỗi oneS và twoS luôn rồi đấy.

Generic Tập 2 – Phương Thức Generic & Lớp Generic

Hi vọng bài học hôm trước đã giúp cho các bạn có cái nhìn rõ ràng ban đầu về Generic, khiến bạn có hứng thú và tò mò hơn trong việc tìm hiểu xem Generic sẽ được sử dụng như thế nào trong code Java của các bạn. Mình sẽ giúp các bạn giải quyết sự tò mò đó qua bài học hôm nay. Bài học sẽ nói rõ hơn cách dùng Generic, vận dụng nó trong tổ chức xây dựng ở phương thức và cả cho lớp nữa. Và điều quan trọng hơn, có thể giúp các bạn mới làm quen với Generic đỡ “hoa mắt” hơn khi nhìn vào một mớ ký hiệu <> trong code.

Cách Khai Báo Generic

Khoan hãy nghĩ đến Generic được dùng ở đâu khi mà bạn còn chưa biết một vài nguyên tắc ban đầu trong việc khai báo chúng.

Các kiểu dữ liệu mới, hay như mình có nói với cái tên Typed parameter, sẽ được khai báo bên trong cặp ngoặc nhọn (< >), một số tài liệu gọi đây là ký tự diamond, bạn thấy nó giống viên kim cương không, mình thì thấy chả giống gì.

Cũng giống như đặt tên biến, bạn cũng cần đặt tên cho kiểu dữ liệu mới với một cái tên đại diện nào đó, chẳng hạn là T (quy ước đặt tên mình sẽ nói thêm bên dưới). Như ở ví dụ của bài trước, mới vừa làm quen đến Generic, bạn đã khai báo một kiểu dữ liệu <T>, điều này có nghĩa bạn đã định nghĩa cho hệ thống biết rằng bạn vừa mới “chế” ra một tập hợp các kiểu dữ liệu chung nào đó, mà tập hợp này bạn đặt tên là T, vậy đó.

Để thống nhất với nhau trong cách đặt tên, bạn nên dùng các chữ cái in hoa. Điều này vừa thể hiện sự ngắn gọn, vừa làm nổi bật kiểu dữ liệu mới lên, vừa không làm lập trình viên phân tâm, ảnh hưởng đến các dòng code logic khác. Hơn nữa Typed parameter này không đại diện cho các kiểu dữ liệu nguyên thủy, chính vì vậy chúng ta mới cần dùng đến chữ cái in hoa, thể hiện rằng nó là một lớp nào đó.

Về chữ cái, thì người ta hay dùng các chữ EKVTUS. Trong đó E hay dùng để thể hiện viết tắt của từ Element, dùng để đại diện cho các kiểu dữ liệu của các phần tử trong collection. K và V hay dùng để đại diện cho các kiểu cho Key và Value dùng trong table, nếu bạn chưa từng dùng đến kiểu table này thì từ từ nhé, mình cũng sẽ nói đến nó sớm thôi. Các chữ cái còn lại sẽ dùng cho các mục đích còn lại khác, chẳng hạn code của bài hôm trước, mình dùng chữ T để đại diện. Nếu trong một phương thức hay một lớp có nhiều hơn một kiểu dữ liệu được định nghĩa mới, thì U và S sẽ được dùng tiếp theo.

Ngoài các chữ cái mình liệt kê trên đây, nếu bạn có nhu cầu phát sinh các kiểu dữ liệu cần được chuyển sang dùng Generic, thì bạn cứ đặt tên bằng các chữ cái khác mà bạn nghĩ ra cũng được nhé. Chẳng hạn I nếu bạn nghĩ rằng muốn dùng cho kiểu Info nào đó, hay N dùng cho kiểu Number chẳng hạn.

Chú ý về cách đặt tên cho kiểu dữ liệu trong Generic

Phương Thức Generic

Lý thuyết chung cho việc bắt đầu dùng Generic là như vậy. Giờ thì chúng ta sẽ cùng nói rõ hơn về việc sử dụng Generic cho các phương thức.

Khai Báo

Để khai báo kiểu dữ liệu mới được dùng cho phương thức, bạn định nghĩa Typed parameter này ngay sau khai báo khả năng truy cập của phương thức. Trong trường hợp ví dụ dưới đây, <T> được khai báo sau từ khóa public.

public <T> void printCoordinate(T latitude, T longitude) {
System.out.println("(" + latitude + ", " + longitude + ")");
}

Bạn nên nhìn qua nhìn lại một chút khai báo phương thức printCoordinate() như trên. Nếu lần đầu tiên tiếp cận với Generic, bạn sẽ hơi rối mắt xíu đấy, nhưng khi thường xuyên sử dụng, bạn sẽ quen dần thôi.

Phương thức trên cũng bình thường như các phương thức mà bạn đã biết khác, chỉ khác một chỗ tham số truyền vào phương thức lúc này là hai phần tử thuộc kiểu dữ liệu T nào đó. T được định nghĩa bên trong khai báo <T> sau từ khóa public. Bạn nên nhớ phải khai báo <T> trước khi dùng kiểu dữ liệu này cho hai tham số latitude và longitude nhé.

Bạn hãy thử gõ phương thức trên vào project Java của bạn để thấy rõ ràng hiệu ứng, hãy thoải mái xóa bỏ khai báo <T> hay thay đổi T bằng một chữ cái khác để xem hệ thống báo lỗi và đáp ứng như thế nào, bạn sẽ học được vài bài học từ việc mày mò ban đầu này đấy.

Nhắc bạn

Với các phương thức cần trả về kết quả là một Typed parameter thì sao, thì bạn cứ thay void như printCoordinate() trên kia bằng kiểu dữ liệu mới thôi. Như sau.

public <T> T getFirst(T[] a) {
return a[0];
}

Phương thức getFist() cũng có một kiểu T được định nghĩa bên trong khai báo <T> sau từ khóa publicgetFirst() nhận tham số đầu vào là một mảng các T. Hơn nữa, nó lại trả về một kiểu T. Chính vì vậy mà bên trong phương thức này, nó phải return một kiểu T, chính là một phần tử bên trong mảng các T này, phần tử đầu tiên, a[0]. Một lần nữa, bạn hãy tập nhìn cho kỹ rồi thử code lại ở phía bạn, chỉnh sửa một chút để học hỏi thêm từ chính kinh nghiệm của các bạn nữa nhé.

Trong trường hợp bạn muốn khai báo nhiều hơn một Typed parameter cho phương thức thì sao. Khi này bạn cứ thoải mái dùng nhiều chữ cái bên trong cặp ngoặc nhọn, và cách nhau giữa chúng bằng dấu phẩy, như phương thức sau.

public <T, U> boolean isValidValues(T latitude, T longitude, U[] a) {
if (latitude == null || longitude == null || a == null || a.length <= 0) {
return false;
}
return true;
}

Phương thức isValidValues() nhận vào hai tham số latitude và longitude cùng kiểu dữ liệu T, còn tham số mảng a thì là kiểu dữ liệu UisValidValues() sẽ trả về false nếu một trong các giá trị truyền vào không thể dùng được, như mang giá trị null hoặc mảng rỗng, ngược lại nó sẽ trả về true. Ví dụ này cho thấy cách khai báo nhiều Typed parameter trong một phương thức thôi, chứ thực ra hai kiểu T và U ở phương thức ví dụ này của mình chưa thực sự rõ ràng về nhu cầu phải sử dụng hai kiểu tách biệt như thế, đến phần lớp Generic dưới đây bạn sẽ thấy rõ ràng hơn nhu cầu cần đến hai kiểu dữ liệu trong cùng một lớp hay phương thức.

Sử Dụng

Việc sử dụng một phương thức Generic cũng gần giống như sử dụng một phương thức bình thường vậy.

Hãy thử với printCoordinate() trên kia.

public static <T> void printCoordinate(T latitude, T longitude) {
System.out.println("(" + latitude + ", " + longitude + ")");
}
 
public static void main(String[] args) {
System.out.print("Tọa độ 1: ");
printCoordinate(10.78838, 106.64811);
 
System.out.print("Tọa độ 2: ");
printCoordinate("10.79226", "106.69437");
}
 
// Kết quả
// Tọa độ 1: (10.78838, 106.64811)
// Tọa độ 2: (10.79226, 106.69437)

Do được gọi đến từ phương thức main() – Là một phương thức static – Nên printCoordinate() khi này cũng phải trở thành phương thức static luôn (điều này mình có nói sơ qua ở mục này rồi nhé). Ngoài việc thêm từ khóa static vào phương thức ra, và <T> khi này phải nằm sau từ khóa static luôn, thì không có gì khác ảnh hưởng đến kiến thức về Generic mà chúng ta đang nói đến đâu nhé.

Quay lại printCoordinate(), khi bạn sử dụng đến nó, bạn thấy rằng bạn có thể truyền vào phương thức này bất cứ dữ liệu gì, nó cũng đều chịu. Và khi này bạn không cần phải chỉ ra kiểu dữ liệu đang dùng cho tham số của phương thức, hệ thống sẽ tự hiểu và suy luận ra giúp bạn kiểu dữ liệu cụ thể khi thực thi. Ví dụ lời gọi printCoordinate(10.78838, 106.64811) thì hệ thống hiểu rằng bạn đang muốn truyền vào hai tham số kiểu Double, nó sẽ chuyển từ định nghĩa chung T của bạn thành Double và sử dụng tham số. Hay lời gọi printCoordinate(“10.79226”, “106.69437”) sẽ hiểu rằng bạn đang sử dụng String thay cho T.

Giờ thì chúng ta qua đến phương thức getFirst().

public static <T> T getFirst(T[] a) {
return a[0];
}
 
public static void main(String[] args) {
Integer[] arr = new Integer[] { 10, 20, 6 };
Integer first = getFirst(arr);
System.out.println("Giá trị đầu tiên: " + first);
}
 
// Kết quả
// Giá trị đầu tiên: 10

Cũng giống như printCoordinate(), khi bạn truyền vào phương thức này một mảng các Integer, tuy không nói gì với hệ thống cả, nhưng khi này hệ thống sẽ tự suy luận ra rằng bạn muốn thay thế T bằng kiểu Integer vậy thôi. Nếu bạn trắc nghiệm hệ thống, thử truyền vào một mảng Integer nhưng nhận về một kiểu khác xem, bạn sẽ thấy hệ thống nhận ra và báo lỗi ngay, cái hay của Generic là vậy.

Hệ thống sẽ biết và bắt lỗi nếu bạn dùng sai kiểu dữ liệu (chụp trên InteliJ IDEA)
Hệ thống sẽ biết và bắt lỗi nếu bạn dùng sai kiểu dữ liệu (chụp trên InteliJ IDEA)

Lớp Generic

Khai báo

Nếu bạn đã nhìn quen một chút với phương thức Generic, thì lớp Generic cũng được khai báo và sử dụng gần như vậy. Để khai báo kiểu dữ liệu được dùng cho lớp, bạn cũng định nghĩa Typed parameter ngay sau tên lớp. Với việc khai báo thêm kiểu dữ liệu này ở ngay đầu mỗi lớp, thì các thuộc tính và phương thức bên trong lớp đó sẽ không cần định nghĩa lại các kiểu dữ liệu này nữa.

Chúng ta hãy cùng xem việc khai báo một lớp Pair như sau.

public class Pair<T> {
private T first;
private T second;
 
public Pair() {
first = null;
second = null;
}
 
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
 
public T getFirst() {
return first;
}
 
public void setFirst(T first) {
this.first = first;
}
 
public T getSecond() {
return second;
}
 
public void setSecond(T second) {
this.second = second;
}
}

Bạn có thể thấy lớp Pair định nghĩa thêm một kiểu dữ liệu gọi là T. Khi này các thuộc tính của nó, như first và second đều có thể xem T là một kiểu dữ liệu mới mà không cần định nghĩa lại. Hơn nữa các phương thức cũng có thể khai báo T như là tham số truyền vào, như setFirst(T first), hay trả về kết quả là T mà cũng chẳng cần phải định nghĩa thêm như việc sử dụng phương thức Generic đơn lẻ trên kia.

Qua đó bạn có thể hiểu, Pair này được dựng lên để chứa một cặp giá trị (first và second) với kiểu dữ liệu bất kỳ. Nó chỉ dùng để lưu trữ vậy thôi, nó có các phương thức khởi tạo để gán giá trị ban đầu cho cặp giá trị này, kèm với các phương thức getter/setter để nhận và trả từng giá trị cụ thể của cặp giá trị này mà thôi.

Trong trường hợp bạn muốn khai báo nhiều hơn một Typed parameter cho lớp Generic này thì bạn cứ khai báo y như với phương thức Generic trên kia vậy, như sau (nhưng chúng ta không nói về Pair<T, U> này ngay mục này đâu nhé mà hãy dành cho bài thực hành ở cuối bài học).

public class Pair<T, U> {
...
}

Sử Dụng

Bạn có thể nghĩ rằng việc sử dụng Pair<T> cũng đơn giản như sử dụng phương thức Generic trên kia đúng không nào, ví dụ bạn có thể new lớp Pair này như sau.

Pair pairString = new Pair();
Pair pairInteger = new Pair(15, 20);

Vâng bạn đã đúng rồi đó, trong hầu hết các trường hợp, bạn khởi tạo các lớp Pair như trên không có bất kỳ lỗi nào từ trình biên dịch cả, hệ thống sẽ tự suy luận ra kiểu dữ liệu thay thế cho T là gì. Nhưng với một lớp, việc định nghĩa ra một kiểu T rồi sau đó dùng cho khá nhiều các thuộc tính hay phương thức bên trong lớp đó khiến nó cần được khởi tạo rõ ràng hơn. Do đó việc chỉ định tường minh kiểu dữ liệu mà bạn mong muốn khi khởi tạo một lớp Generic nhiều khi cũng khá cần thiết. Bạn nên sửa lại việc khởi tạo hai lớp Pair trên kia như sau.

Pair<String> pairString = new Pair<String>();
Pair<Integer> pairInteger = new Pair<Integer>(15, 20);

Khai báo tường minh thì tốt, nhưng khai báo như trên này lại dư thừa, như bạn thấy với pairString bạn có tới hai <String> trong một dòng khởi tạo, do đó để ngắn gọn hơn, bạn nên bỏ bớt một String ở sau đi. Rốt lại thì chỉ còn như này thôi.

Pair<String> pairString = new Pair<>();
Pair<Integer> pairInteger = new Pair<>(15, 20);

Đến đây thì bạn đã cơ bản hiểu và sử dụng được một vài khai báo kiểu dữ liệu Generic vào project của bạn được rồi đấy. Để tăng thêm tính ứng dụng và nhìn thấy rõ hơn sự kết hợp giữa phương thức Generic và lớp Generic, thì chúng ta cùng đến với vài bài thực hành nho nhỏ sau đây.

Bài Thực Hành Số 1: Ứng Dụng Lớp Pair<T> Để Chứa Giá Trị Lớn Nhất & Nhỏ Nhất Trong Mảng

Chúng ta cùng sử dụng lại lớp Pair<T> đã khai báo trên kia. Áp dụng vào bài toán muốn tìm ra giá trị lớn nhất và nhỏ nhất trong mảng.

Bạn có thấy quen không, thực ra code ví dụ của phần trước, phương thức minFromTwoNumbers() đã giúp bạn tự chế ra một phương thức minFromArray() có sử dụng Generic được rồi đúng không nào. Rồi sau đó bạn xây dựng thêm maxFromArray() nữa là đã có thể xong bài thực hành này rồi. Tuy nhiên để tăng tính thực tế, mình sẽ xây dựng một phương thức thôi, có tên minMaxFromArray(), thay vì trả về hoặc giá trị min hoặc giá trị max ở từng phương thức, thì phương thức tổng hợp này sẽ lưu hai giá trị min và max này vào trong một lớp Pair mà chúng ta đã xây dựng trên kia. Thú vị không nào.

Phương thức minMaxFromArray() như sau.

public static <T extends Comparable> Pair<T> minMaxFromArray(T[] arr) {
T min = arr[0];
T max = arr[0];
 
for (int i = 0; i < arr.length; i++) {
if (min.compareTo(arr[i]) > 0) min = arr[i];
if (max.compareTo(arr[i]) < 0) max = arr[i];
}
 
return new Pair<>(min, max);
}

Mình mượn lại việc khai báo kiểu dữ liệu <T> là <T extends Comparable> như bài hôm trước. Mình sẽ giải thích các khai báo kiểu này ở bài sau. Đại loại với cách khai báo như vậy thì chúng ta sẽ dùng được phương thức khá hữu hiệu của T (lúc này T là con của Comparable) đó là compareTo().

Qua đó bạn có thể thấy Pair là một lớp Generic, lớp này cho phép làm việc với một kiểu dữ liệu mới toanh là T. Còn minMaxFromArray() là phương thức Generic, vì bản thân phương thức này không có nằm trong một lớp Generic nào, nó tự định nghĩa ra một kiểu dữ liệu T luôn.

Sau đây là code ở main() sử dụng các thành phần chúng ta đã định nghĩa ra.

public static void main(String[] args) {
String[] arrStr = new String[] { "Một", "Hai", "Ba", "Bốn", "Năm", "Sáu", "Bảy" };
Pair<String> pairStr = minMaxFromArray(arrStr);
System.out.println("Giá trị nhỏ nhất của màng String là " + pairStr.getFirst() + "; Lớn nhất là: " + pairStr.getSecond());
 
Integer[] arrInt = new Integer[] { 5, -19, 40, 33, 25, -7 };
Pair<Integer> pairInt = minMaxFromArray(arrInt);
System.out.println("Giá trị nhỏ nhất của mảng Integer là " + pairInt.getFirst() + "; Lớn nhất là: " + pairInt.getSecond());
}
 
// Kết quả
// Giá trị nhỏ nhất của màng String là Ba; Lớn nhất là: Sáu
// Giá trị nhỏ nhất của mảng Integer là -19; Lớn nhất là: 40

Bài Thực Hành Số 2: Xây Dựng Ứng Dụng Từ Điển

Nói xây dựng ứng dụng từ điển thì nghe ghê quá. Nhưng lớp Dictionary mà bạn sắp làm quen đây cũng có thể giúp bạn có thêm một ý tưởng để xây dựng một ứng dụng từ điển hoàn chỉnh cho riêng bạn chăng.

Đầu tiên, từ điển thì cần phải chứa từng từ cần tra và nghĩa của từ đó. Chúng ta xây dựng lớp Word chứa cả từ cần tra và nghĩa của nó ngay trong một lớp. Chúng ta gọi từ cần tra là key, và nghĩa của nó là value cho nó ngắn gọn. Dĩ nhiên key chưa chắc phải là String và value cũng vậy, vì biết đâu sau này bạn nâng cấp từ điển này sao cho chứa các kiểu dữ liệu nào đó khác thì sao. Do đó chúng ta áp dụng lớp Generic vào Word như sau.

public class Word<K, V> {
 
private K key;
private V value;
 
public Word(K key, V value) {
this.key = key;
this.value = value;
}
 
public K getKey() {
return key;
}
 
public void setKey(K key) {
this.key = key;
}
 
public V getValue() {
return value;
}
 
public void setValue(V value) {
this.value = value;
}
 
// Phương thức giúp so sánh key này với key nào đó khác
public boolean isKeyEquals(K anotherKey) {
return (this.key.equals(anotherKey));
}
}

Lần này Word cần hai Typed parameter là K và V đại diện cho key và value. Các phương thức của nó cũng không có gì quá khó khăn đúng không nào. Mình thêm vào một phương thức isKeyEquals() giúp nó so sánh key của nó với anotherKey mà chúng ta sẽ dùng sau.

Chúng ta cần một lớp Dictionary để tổ chức lưu trữ các Word, đồng thời xây dựng giải thuật tra từ ở đây.

public class Dictionary<K, V> {
 
private Word<K, V>[] words;
 
public Dictionary(Word<K, V>[] words) {
this.words = words;
}
 
public V findWord(K keySearch) {
for (Word<K, V> word : words) {
if (word.isKeyEquals(keySearch)) {
return word.getValue();
}
}
return null;
}
}

Bạn có thể thấy Dictionary cũng cần khai báo hai kiểu K và V để vào trong nó còn làm việc với Word<K, V> nữa chứ. Bạn có thể cũng nhận ra phương thức findWord() là phương thức tra từ trong từ điển, cơ mà lại dùng vòng lặp mà duyệt như thế này thì ứng dụng từ điển sẽ chạy chậm lắm đấy. Bạn đã nghĩ đúng, đây chỉ là code mẫu để chúng ta làm quen với Generic thôi. Nếu bạn muốn xây dựng một ứng dụng từ điển thực sự thì bạn phải nghĩ đến một giải thuật khác để lưu trữ hay tra từ. “Bảng băm” là một từ khóa mà bạn có thể nghĩ ra cho một ứng dụng từ điển, tuy nhiên chúng ta không nói dông dài vào cấu trúc dữ liệu này, mình chỉ gợi ý là Java cũng có hỗ trợ các kiểu dữ liệu cao cấp là HashMap và HashTable mà bạn có thể xem qua để tham khảo và học hỏi, chúng ta sẽ cùng tìm hiểu các kiểu dữ liệu này sau.

Tổ chức xong rồi, sau đây là code ở main(). Mình dùng lại vòng lặp do while để giúp người dùng nhập vào từng từ muốn tra cho đến khi người dùng không còn muốn dùng ứng dụng của chúng ta nữa thì cứ nhấn “q” hoặc “Q” ở console để thoát. À mà từ điển mình muốn thử là từ điển về các ngôn ngữ lập trình nhé.

public static void main(String[] args) {
// Khai báo dữ liệu cho từ điển
Word<String, String>[] words = new Word[]
{
new Word<>("Java", "Là một ngôn ngữ lập trình cấp cao, hướng đối tượng mà bạn đang học"),
new Word<>("Kotlin", "Là một ngôn ngữ lập trình đa nền tảng, tương thích hoàn toàn với Java, nếu thích bạn cứ học."),
new Word<>("C", "Là ngôn ngữ lập trình kinh điển trong các trường học."),
new Word<>("Objective-C", "Ngôn ngữ được dùng để viết ứng dụng trên các thiết bị của Apple."),
new Word<>("Swift", "Là ngôn ngữ được dùng để thay thế cho Objective-C."),
};
 
// Nạp dữ liệu vào từ điển thông qua phương thức khởi tạo
Dictionary<String, String> dictionary = new Dictionary<>(words);
 
Scanner scanner = new Scanner(System.in);
String language;
do {
// Lặp đến khi language là "q" hoặc "Q"
System.out.print("Nhập ngôn ngữ bạn muốn biết, nhấn Q để thoát: ");
language = scanner.nextLine();
 
// Tra từ
String result = dictionary.findWord(language);
if (result != null) {
// In kết quả nếu tra ra từ
System.out.println(result);
} else {
// Không tìm thấy từ cần tra
System.out.println("Chưa có dữ liệu về ngôn ngữ bạn cần");
}
} while (language != null && !language.equalsIgnoreCase("q"));
}

Bạn có thấy Word<String, String> và Dictionary<String, String> khi được sử dụng nên khai báo tường minh kiểu dữ liệu cụ thể mà nó cần dùng, khi này K và V từ khai báo nay đã cụ thể thành String và String rồi đúng không nào.

Dưới đây là kết quả thực thi chương trình.

Kết quả thực thi chương trình
Kết quả thực thi chương trình

Lạm Bàn Về Tham Chiếu/Tham Trị Trong Java

Bài viết hôm nay chúng ta sẽ cùng tìm hiểu và làm rõ ra thế nào là truyền tham số kiểu Tham chiếu, hay Tham trị, vào phương thức trong Java. Có thực sự như lời đồn rằng Java chỉ hỗ trợ việc truyền tham số kiểu Tham trị hay không, như phần này của Bài 18 mình có nhắc đến.

Thực ra cũng bởi có nhiều bạn liên hệ mong muốn mình làm rõ vấn đề này của Java, nên nhân đây mình viết ra luôn. Vâng, mình cũng nhận thấy rằng kiến thức này khá là quan trọng, nó không những nói rõ hơn về việc truyền tham số kiểu Tham chiếu và Tham trị là gì, mà nó còn giúp chúng ta mở rộng hơn trong việc nhìn nhận một biến được tổ chức và quản lý trong Java như thế nào. Và mình cũng muốn nói thêm một ý rằng, cái chủ đề về Tham chiếu/Tham trị này là một chủ đề thắc mắc của không riêng bạn đâu, nó như trở thành một cuộc tranh luận sôi nổi trên các diễn đàn, một lát sau vào bài viết bạn sẽ thấy tại sao nó lại gây nên sự tranh luận này, nếu bạn chưa tin lắm, hãy thử Google với từ khóa “Is Java pass-by-reference or pass-by-value” sẽ thấy.

Bắt Đầu Từ Việc Khai Báo Biến

Vâng đúng rồi, bạn nhớ đúng, biến đã được nói đến ở Bài 4. Ở đây chúng ta sẽ không nói lại biến là gì và khai báo ra sao. Mà sẽ phân tích sâu hơn về việc quản lý biến trong Java, lát nữa vấn đề về Tham chiếu và Tham trị sẽ được sáng tỏ hơn nhờ việc phân tích này.

Giả xử chúng ta khai báo một biến x như sau trong Java.

int x = 10;

Khi dòng code trên được thực thi, dĩ nhiên Giá trị 10 sẽ được chứa trong bộ nhớ. Tuy nhiên, có một giá trị khác cũng được hệ thống tạo ra, đó chính là Địa chỉ chỉ đến nơi chứa giá trị 10 này. Bạn cứ tưởng tượng bạn đi nhà sách, bạn cần phải để túi sách bên ngoài trước khi vào nhà sách đó, bạn sẽ đi đến các tủ chứa dành cho khách, cất túi xách vào một ngăn tủ, đóng cửa tủ lại. Bạn thấy rằng cửa tủ có ghi một con số, và khi bạn khóa cửa lại, trên chìa khóa cũng có một số tương ứng. Vậy thì túi xách của bạn tương tự như giá trị 10 mà bộ nhớ (hay cái tủ) dùng để chứa, còn con số trên chìa khóa tủ chính là địa chỉ của cái tủ, tương tự địa chỉ dẫn đến giá trị 10 của bộ nhớ. Địa chỉ trên chìa khóa giúp bạn không bị nhầm lẫn giữa tủ đồ của bạn và của người khác, và địa chỉ của hệ thống cũng giúp biến x tìm đến nơi chứa giá trị của nó trong bộ nhớ.

Việc khai báo và kịch bản lưu trữ trên đây được mình mô tả bằng sơ đồ trực quan, dễ hiểu như sau.

Sơ đồ diễn tả việc khai báo giá trị cho biến
Sơ đồ diễn tả việc khai báo giá trị cho biến

Sơ đồ cho thấy khi dòng code khai báo trên được thực thi, giá trị 10 chứa trong bộ nhớ màu cam, và địa chỉ đến giá trị này (khung màu xanh dương) được tạo ra và lưu trữ như thế nào.

Đến Truyền Biến Vào Trong Phương Thức

Thế Nào Là Truyền Kiểu Tham Trị

Bây giờ mới bắt đầu nội dung chính của bài hôm nay. Trước hết chúng ta hãy nhìn vào code sau.

public static void main(String[] args) {
int x = 10;
 
System.out.println("Before call process: " + x);
process(x);
System.out.println("After call process: " + x);
}
 
public static void process(int x) {
x = 7;
}

Ở phương thức main(), chúng ta cũng bắt đầu với việc khai báo x = 10. Sau đó chúng ta truyền biến x này dưới dạng tham số vào cho phương thức process(). Thế nhưng vào bên trong process(), tham số lúc này cũng có tên là x (hay thực ra đặt cái tên khác cũng được, mình cố tình đặt chung để dễ thấy sự liên quan với nhau hơn), và chúng ta còn gán lại giá trị mới cho x nữa, là 7. Vậy sau khi process() kết thúc, câu lệnh in x ra console “After call process: “ sẽ in ra số 10 hay số 7 vậy các bạn?

Là số… 10. Hi vọng các bạn đều thuộc team nói đúng.

Before call process: 10
After call process: 10

Trên đây là ví dụ rõ ràng nhất cho khái niệm truyền kiểu Tham trị vào phương thức. Vậy truyền theo kiểu Tham trị là gì. Đó là việc truyền tham số trong trường hợp trên đây, nó chỉ là sự sao chép giá trị khi tham số được truyền vào trong một phương thức. x trước khi truyền vào trong phương thức process() mang giá trị 10. Còn x bên trong process() không phải x ở ngoài, nó chỉ là một biến khác được sao chép ra, cũng mang tên x, cũng mang giá trị 10, nhưng không còn là x ở ngoài nữa. Do đó dù bên trong process() chúng ta có thay đổi x như thế nào thì cũng không ảnh hưởng với x bên ngoài.

Có thể vẽ lại sơ đồ trực quan như sau.

Sơ đồ diễn tả việc truyền tham số kiểu tham trị
Sơ đồ diễn tả việc truyền tham số kiểu tham trị

Sơ đồ cho thấy x ở trên cùng là x trước khi đưa vào trong process() mang giá trị 10, khi đưa vào trong process()x này sẽ là một x khác hoàn toàn, khác nơi chứa giá trị 10 và hiển nhiên sẽ khác luôn địa chỉ (địa chỉ của chúng khác nhau được minh họa bằng hai màu khác nhau). Việc thay đổi từ giá trị 10 sang 7 bên trong process() chỉ làm thay đổi x bên trong đó mà thôi, hoàn toàn không ảnh hưởng gì với x bên ngoài cả.

Thế Nào Là Truyền Kiểu Tham Chiếu

Trước khi đi vào giải nghĩa, mình xin nhắc lại rằng, Java chỉ hỗ trợ chuyền tham số kiểu Tham trị mà thôi.

Nói là không hỗ trợ vì chẳng có một cách nào để kêu Java giúp chúng ta truyền kiểu Tham chiếu cả. Tuy nhiên một số ngôn ngữ khác lại “công khai” hỗ trợ điều này, như mình biết đó là C++. Mình cũng còn nhớ một ít C++ nên sẽ minh họa việc truyền theo Tham chiếu như thế nào theo code như sau (bạn nào biết code C++ mà nhận thấy mình viết sai thì giúp mình sửa nhé, lâu quá không dùng đến mình cũng quên nhiều).

#include <iostream>
 
int main() {
int x = 10;
 
cout << "Before call process: " << x << endl;
process(someValue);
cout << "After call process: " << x << endl;
 
return 0;
}
 
void process(int& x) {
x = 7;
}

Bạn thấy code C++ cũng dễ hiểu đúng không nào. Cũng có phương thức main(), cũng in ra console trước và sau khi gọi phương thức process(). Nhưng khi khai báo tham số truyền vào cho process(), C++ cho phép khai báo kiểu int& hoặc int. Với khai báo tham số là kiểu int thì kết quả sẽ truyền tham số kiểu Tham trị như code Java trên kia, còn khai báo int& thì tham số sẽ truyền theo kiểu Tham chiếu. Và bạn biết không, với code C++ này thì sau khi thực thi, kết quả in ra ở dòng cuối cùng sẽ cho thấy x bị thay đổi sang giá trị 7.

Before call process: 10
After call process: 7

Vậy bạn đã phần nào phân biệt giữa truyền tham số kiểu Tham trị và Tham chiếu chưa. Truyền tham số theo kiểu Tham chiếu như ví dụ ngay trên đây, đó là việc truyền tham số dựa trên việc truyền địa chỉ của biến (không sao chép giá trị như code Java trên kia). x trước khi truyền vào trong phương thức process() mang giá trị 10, khi truyền vào trong process() chính là x bên ngoài, do đó khi chúng ta thay đổi giá trị x bên trong process()x bên ngoài cũng thay đổi theo.

Chúng ta mô hình hóa một chút nào.

Sơ đồ diễn tả việc truyền tham số kiểu tham chiếu
Sơ đồ diễn tả việc truyền tham số kiểu tham chiếu

Sơ đồ cho thấy x ở trên cùng mang giá trị 10, khi đưa vào trong process(), địa chỉ của x sẽ truyền vào, và như vậy chúng cùng chỉ đến chung một bộ nhớ. Do đó, việc thay đổi x từ giá trị 10 sang 7 bên trong process() cũng làm cho x bên ngoài thay đổi từ 10 sang 7 theo.

Chốt lại là bạn đã phân biệt giữa truyền tham số dạng Tham trị và truyền tham số dạng Tham chiếu chưa nào. Ơ nhưng bài viết chưa kết thúc? Phần tranh cãi trên mạng chính là phần sau này đây, mời bạn xem tiếp.

Vậy Nếu Tham Số Là Một Đối Tượng Thì Sao

Vâng, trên đây là các ví dụ liên quan đến truyền tham số là một giá trị nguyên thủy vào trong phương thức. Bạn thấy rõ rằng Java là một ngôn ngữ chỉ cho phép truyền tham số kiểu Tham trị. Tức là giá trị bên trong phương thức truyền vào chỉ là một bản sao của giá trị bên ngoài, việc thay đổi giá trị này bên trong phương thức Java không gây ảnh hưởng hay thay đổi giá trị của biến bên ngoài phương thức.

OK. Vậy mời bạn xem ví dụ sau. Trước hết chúng ta cần khai báo một lớp cái đã, mình giả sử có lớp MyCat, lớp bạn mèo này có một thuộc tính name để dễ phân biệt.

public class MyCat {
private String name;
 
public MyCat(String name) {
this.name = name;
}
 
public String getName() {
return name;
}
 
public void setName(String name) {
this.name = name;
}
}

Trường Hợp 1

Ở trường hợp thử nghiệm này, chúng ta xem hai anh chàng main() và process() khi này sử dụng MyCat như thế nào.

public static void main(String[] args) {
MyCat myCat = new MyCat("Kitty");
 
System.out.println("Before call process: " + myCat.getName());
process(myCat);
System.out.println("After call process: " + myCat.getName());
}
 
public static void process(MyCat myCat) {
myCat.setName("Doraemon");
}

Bạn có đoán được hai dòng in ra màn hình sẽ in tên của chú mèo nhà ta như thế nào không. Có phải cùng là “Kitty” không. Bạn có lý khi nghĩ là cùng in ra “Kitty”, vì như đã nói, Java chỉ hỗ trợ truyền tham số kiểu Tham trịmyCat bên trong process() không phải myCat bên ngoài. Vâng, mời bạn xem kết quả.

Before call process: Kitty
After call process: Doraemon

Ơ sao kỳ vậy. myCat đã thay đổi name bên trong process() được ư. Vậy sao gọi là Tham trị? Vâng, thắc mắc trên các diễn đàn hỏi đáp là đây. Khái niệm Tham trị của Java bị lung lay ư? Thực chất thì đến giờ phút này, mình vẫn khẳng định rằng Java vẫn chỉ hỗ trợ kiểu tham số là Tham trị thôi nhé.

Trường Hợp 2

Chúng ta cùng sửa lại code trên một chút để thử nghiệm một trường hợp khác xem sao.

public static void main(String[] args) {
MyCat myCat = new MyCat("Kitty");
 
System.out.println("Before call process: " + myCat.getName());
process(myCat);
System.out.println("After call process: " + myCat.getName());
}
 
public static void process(MyCat myCat) {
myCat = new MyCat("Doraemon");
}

Kết quả in ra console ngay đây.

Before call process: Kitty
After call process: Kitty

Uhm trường hợp này hơi rõ ràng rồi đấy. Với code ở trường hợp 2 trên đây đủ thấy rằng, myCat bên ngoài được khởi tạo với cái tên “Kitty”, sau khi truyền vào process(), dù cho có được khởi tạo lại với cái tên “Doraemon” nhưng thực chất chúng không phải là một, nên myCat bên trong không làm ảnh hưởng đến myCat bên ngoài. Vậy trường hợp này có thể khẳng định, đây đúng là truyền tham số kiểu Tham trị.

Dù vậy thì trường hợp 1 là gì, tại sao truyền tham số kiểu Tham trị mà có thể làm thay đổi giá trị của đối tượng truyền vào được.

Giải Thích Các Trường Hợp

Trước khi đi vào giải thích từng trường hợp. Chúng ta hãy quay lại việc khai báo biến kiểu đối tượng. Hãy nhìn lại dòng khai báo sau.

MyCat myCat = new MyCat("Kitty");

Nếu dùng sơ đồ trên để miêu tả. Thì chúng ta sẽ có một nơi trong bộ nhớ lưu trữ Giá trị của biến myCat đúng không nào. Và một Địa chỉ chỉ đến Giá trị này của biến. Ồ nhưng trong MyCat có chứa thuộc tính name, vậy lại phải có một Địa chỉ chỉ đến Giá trị của name nữa. Lòng vòng như vậy cho chúng ta thấy thực ra việc cấp phát và lưu trữ một đối tượng nó sẽ hơi khác với biến nguyên thủy một chút. Bạn xem.

Sơ đồ diễn tả việc khai báo đối tượng myCat
Sơ đồ diễn tả việc khai báo đối tượng myCat

Ô màu xanh dương chính là Địa chỉ của biến myCat, chỉ đến Giá trị của nó là ô màu đỏ. Ô màu đỏ sẽ lại chứa các Địa chỉ chỉ đến các thuộc tính của MyCat, trong trường hợp này chỉ có mỗi thuộc tính name.

Như vậy với trường hợp 1 trên đây. myCat được truyền vào trong process(), thực chất đúng là truyền kiểu Tham trị. Có nghĩa là giá trị của biến myCat được sao chép qua. Nhưng trớ trêu thay, giá trị của một đối tượng lúc bây giờ chính là địa chỉ đến các thành phần của đối tượng đó. Do đó việc sao chép địa chỉ lúc này cũng như sao chép chìa khóa mà thôi, nó vẫn dùng để mở cùng một tủ. Bạn xem minh họa cho trường hợp 1 như sau.

Sơ đồ diễn tả việc truyền tham số kiểu tham trị cho đối tượng myCat
Sơ đồ diễn tả việc truyền tham số kiểu tham trị cho đối tượng myCat

Cũng giống như sơ đồ truyền biến nguyên thủy trên kia. Giá trị của myCat khi này lại là cục màu đỏ, mà như chúng ta đã biết, việc sao chép cục màu đỏ cũng chính là sao chép lại địa chỉ tham chiếu đến thuộc tính name. Tuy myCat bên ngoài và bên trong khác nhau về địa chỉ, nhưng lại cùng giá trị chính là địa chỉ đến name. Chính vì vậy mà việc thay đổi name đối với myCat trong trường hợp 1 lại làm thay đổi cả name của myCat trước khi truyền vào process(). Nhưng như bạn thấy, Java khi này vẫn tuân thủ là truyền tham số kiểu Tham trị đấy nhé.

Trường hợp 2. Khi myCat truyền vào trong process(), việc sao chép giá trị (chính là địa chỉa của name) vào trong phương thức vẫn diễn ra. Nhưng vào đây, myCat được khởi tạo lại thành một đối tượng mới thông qua toán tử new, thế là giá trị mới cũng thay đổi theo (màu đỏ thay bằng màu tím), nên việc đặt tên cho myCat bên trong process() như thế nào trong trường hợp 2 này cũng không ảnh hưởng đến bên ngoài nhé.

Sơ đồ diễn tả việc truyền tham số kiểu tham trị cho đối tượng myCat
Sơ đồ diễn tả việc truyền tham số kiểu tham trị cho đối tượng myCat

Doc Comment Và Javadoc Trong Java

Trước khi đi sâu vào tìm hiểu chủ đề và nội dung cụ thể của bài hôm nay. Mình xin trình bày trước rằng bài viết này là một mở rộng hơn cho Bài 6: Ép Kiểu & Comment Source Code.

Ở bài số 6, mình có trình bày với các bạn các cách thức để comment vào source code, giúp cho các dòng code trở nên rõ nghĩa hơn. Và khi đó mình cũng hứa với các bạn rằng kiểu comment để tạo document cho source code sẽ được mình nói rõ hơn ở một bài viết khác. Cũng có không ít bạn đã đòi nợ mình, vâng vậy thì hôm nay mình sẽ trả món nợ này.

Nhắc Lại Kiểu Documentation Comment

Từ bây giờ chúng ta hãy gọi chức năng này bằng một tên chuẩn tiếng Anh cho thống nhất, hãy gọi chức năng này là Documentation Comment, hay gọi tắt là Doc Comment cũng được. Chúng ta đều hiểu nó là cách comment code theo kiểu document vậy.

Vậy thì đây là một kiểu comment đặc biệt. Khác với kiểu // Text là comment trên một dòng hay /* Text */ là comment trên nhiều dòng code. Kiểu Doc Comment được ghi theo format /** Document */.

Tất cả các kiểu comment đều có một điểm giống nhau là khi build, trình biên dịch sẽ bỏ qua chúng, không build comment vào file build cuối cùng. Nhưng, khác với anh em trong họ comment, Doc Comment không đơn thuần chỉ là để comment, chúng được dùng trong một chuyện khác. Công dụng cụ thể của Doc Comment là gì thì mời bạn xem qua mục sau. Dưới đây là một ví dụ sử dụng comment theo kiểu Doc Comment.

/**
* The MainClass is the class that help to print out the "Hello World!" text
*
* @author yellowcode
* @version 1.0
* @since 2021-05-04
*
*/
public class MainClass {
 
/**
* The main function, entry point of this app
* @param args
*/
public static void main(String[] args) {
System.out.println("Hello World!");
}
}

Công Dụng Của Doc Comment

Về phía kinh nghiệm code bao lâu nay của mình, mình vẫn rất thích kiểu Doc Comment này hơn các kiểu comment khác, là vì có các lợi ích sau đây.

Thứ nhất, về mặt giải thích cho các dòng code bạn đang làm, thì Doc Comment sẽ luôn rõ ràng hơn do chúng có được sự hỗ trợ về mặt định dạng nổi bật hơn cho các tham số.

Định dạng tham số bắt mắt (hiển thị trên InteliJ)
Định dạng tham số bắt mắt (hiển thị trên InteliJ)

Thứ hai, là lợi ích về mặt sử dụng các dòng code có comment theo kiểu Doc Comment này. Thì khi sử dụng các thành phần được comment “chuẩn”, bạn sẽ thấy comment, hay document sẽ xuất hiện ở thanh ngữ cảnh của Eclipse hay InteiJ (bạn dễ dàng nhìn thấy các document này khi đưa chuột vào lớp hay hàm có Doc Comment).

 

Thứ ba, về mặt xuất xưởng các thư viện. Doc Comment sẽ được một công cụ có tên Javadoc build ra một trang mô tả theo kiểu HTML. Nó là một trang Web được xây dựng hoàn chỉnh và bạn có thể dùng để publish hay nhúng vào trang Web khác. Rất thích hợp để bạn tạo ra các thư viện Java và gửi đến người dùng thư viện của bạn với đầy đủ các hướng dẫn sử dụng các Java code mà bạn xây dựng. Với lợi ích thứ ba này thì mình mời các bạn đến với mục tiếp theo để trải nghiệm nhé.

Sẽ thật thiếu sót nếu như nói về Doc Comment mà quên nói sơ về Javadoc.

Javadoc là một công cụ có sẵn đi kèm với JDKJavadoc dùng để xuất xưởng ra một document theo định dạng HTML, để giúp bạn mô tả rõ hơn về các source code Java của bạn hay của tổ chức của bạn. Nhưng để Javadoc có thể tạo ra một document, thì bạn nên tuân thủ theo những định dạng mà công cụ này cung cấp. Ở phần sau đây chúng ta sẽ đi sâu hơn về cách vận dụng các nguyên tắc và định dạng của Javadoc.

Nói sơ tí về Javadoc

Thử Tạo Một HTML Document

Bước này chúng ta hãy cũng trải nghiệm việc sử dụng công cụ Javadoc để tạo ra một HTML document xịn xò.

Thật may là Eclipse hay InteliJ đều hỗ trợ các tương tác đến công cụ Javadoc một cách dễ dàng. Bạn hãy chọn một trong hai công cụ này để thực hành theo các chỉ dẫn sau.

Tạo HTML Document trên Eclipse

Với Eclipse. Với project đang mở. Và dĩ nhiên phải có một vài Doc Comment đã được bạn định nghĩa trong source code. Bạn hãy chọn theo menu Project > Generate Javadoc….

Chọn Generate Javadoc từ menu
Chọn Generate Javadoc từ menu

Một cửa sổ xuất hiện, bạn hãy để nguyên như mặc định. Chúng là các thiết lập đường dẫn đến file thực thi Javadoc, project cần tạo Javadoc, cũng như nơi mà thành phẩm HTML document được trích xuất ra (đó chính là thư mục /doc bên trong project của bạn).

Tùy chọn cho việc tạo Javadoc
Tùy chọn cho việc tạo Javadoc

Hãy đảm bảo các chọn lựa của bạn giống như hình trên. Sau đó nhấn Next. Một cửa sổ chọn lựa khác xuất hiện như sau.

Tùy chọn tiếp theo cho việc tạo Javadoc
Tùy chọn tiếp theo cho việc tạo Javadoc

Ở bước trên, bạn hãy nhập vào tiêu đề cho document (mục Document title). Khi này bạn có thể nhấn Finish vì thực chất bước sau nữa cũng không có gì đáng chú ý cả.

Sau một lúc, bạn sẽ thấy xuất hiện thêm một thư mục /doc bên trong project của bạn ở của sổ Package Explorer. Hãy xổ thư mục này ra và tìm đến file index.html và click đúp vào đó, bạn sẽ thấy nội dung document đã được tạo ra tự động y như một trang Web thực thụ vậy. Và đây là những gì chúng ta đã comment vào source code theo dạng Doc Comment.

Kết quả tạo ra Document cho các lớp chúng ta đã tạo
Kết quả tạo ra Document cho các lớp chúng ta đã tạo

Bạn hãy thử trải nghiệm bằng cách click chuột đi tới đi lui trong trang Web này để xem Javadoc giúp tạo các hướng dẫn cho code của chúng ta như thế nào.

Ở mục sau chúng ta sẽ tìm hiểu sâu hơn việc tạo document một cách chỉn chu hơn, đầy đủ và chuyên nghiệp hơn như thế nào nhé.

Tạo HTML Document Trên InteliJ

Với InteliJ. Với project đang mở. Và dĩ nhiên phải có một vài Doc Comment đã được bạn định nghĩa trong source code. Bạn hãy chọn theo menu Tools > Generate JavaDoc….

Chọn Generate Javadoc từ menu
Chọn Generate Javadoc từ menu

Một cửa sổ xuất hiện, bạn hãy để nguyên như mặc định. Chúng là các thiết lập phạm vi áp dụng để tạo HTML document (scope), cấp độ chia sẻ private/package/protected/public. Và thiết lập nơi mà thành phẩm HTML document được trích xuất ra, bạn có thể chỉ định xuất vào thư mục /doc bên trong project của bạn như dưới đây.

Tùy chọn cho việc tạo Javadoc
Tùy chọn cho việc tạo Javadoc

Sau khi nhấn OK ở cửa sổ trên, bạn sẽ thấy ngay lập tức Web Browser mặc định trên máy bạn được mở ra với nội dung chính là giới thiệu về project của bạn kèm với các Doc Comment trong đó.

Kết quả tạo ra Document cho các lớp chúng ta đã tạo
Kết quả tạo ra Document cho các lớp chúng ta đã tạo

Bạn hãy thử trải nghiệm bằng cách click chuột đi tới đi lui trong trang Web này để xem Javadoc giúp tạo các hướng dẫn cho code của chúng ta như thế nào.

Ở mục sau chúng ta sẽ tìm hiểu sâu hơn việc tạo document một cách chỉn chu hơn, đầy đủ và chuyên nghiệp hơn như thế nào nhé.

Định Dạng Java Doc Thông Qua Sử Dụng Tag

Ở các ví dụ trên đây, bạn đã nhìn thấy một số Tag được dùng trong Javadoc như @author@version@since@param. Và bạn đã hiểu các Tag này giống như các tham số giúp cho Javadoc có thể tạo ra các HTML và truyền các định nghĩa của từng Tag vào HTML như thế nào rồi đúng không nào. Các Tag trong Javadoc thường không ràng buộc một công thức nào kèm theo cả, bạn chỉ cần vận dụng Tag ở những nơi bạn cần HTML làm nổi bật thông tin đó lên thôi, vì dù sao Doc Comment cũng chỉ là một kiểu comment, nên bạn cứ thoải mái sử dụng đi nhé.

Mình sẽ không giải thích dài dòng về Tag nữa mà vào cụ thể việc sử dụng Tag trong Javadoc như thế nào luôn.

@author, @version, @since

  • @author: dùng để hiển thị thông tin tác giả.
  • @version: dùng để hiển thị version của document.
  • @since: dùng hiển thị ngày hoặc version tạo ra document.

Mời bạn xem ví dụ sử dụng Tag và kết quả xuất ra dưới dạng HTML document.

Ví dụ sử dụng các tag @author, @version, @since
Ví dụ sử dụng các tag @author, @version, @since

{@code}, @param

  • {@code}: giúp hiển thị code bên trong HTML. Các code này được hiển thị với font chữ khác các font chữ còn lại, và bạn cũng không cần lo lắng nếu có các dòng code xung đột với các tag của HTML khi này.
  • @param: theo sau tag này sẽ là tên tham số của hàm, theo sau nữa sẽ là lời giải thích cho tham số đó.

Chi tiết về cách sử dụng 2 Tag này được thể hiện qua ví dụ dưới đây.

Ví dụ sử dụng các tag <a href=
Ví dụ sử dụng các tag {@code}, @param

@deprecated, {@link}<span< a=""> class="ez-toc-section-end" style="box-sizing: border-box;">

“Hiệu ứng” của các Tag này được minh họa bằng các ví dụ dưới.

Ví dụ sử dụng các tag @deprecated, <a href=

@exception, @throws

Hai Tag này có công dụng như nhau. Giúp thêm một thông tin Throws trong document báo hiệu phương thức này sẽ tung ra một exception.

Ví dụ sử dụng tag @exception
Ví dụ sử dụng tag @exception

@return, @see

  • @return: giải thích cho return của hàm.
  • @see: đưa ra các tham khảo đến các class khác.
Ví dụ sử dụng các tag @return, @see
Ví dụ sử dụng các tag @return, @see

{@value}

Giúp hiển thị giá trị của các static field.

Ví dụ sử dụng tag <a href=
Ví dụ sử dụng tag {@value}>

Tham Khảo Thêm Các Định Dạng Khác

Trên đây mình có trình bày qua các định dạng Tag phổ biến trong Javadoc. Tuy nhiên vẫn còn một số định dạng khác, chẳng hạn như vận dụng thêm các thẻ HTML vào Doc Comment, thì bạn có thể làm quen thông qua việc tìm hiểu chính source code của “chính chủ” Oracle, hoặc bạn hãy để ý các Doc Comment từ các source code của các thư viện khác. Đảm bảo bạn sẽ thấy thích và ngộ ra được nhiều phong cách Doc Comment từ các nguồn này, bạn sẽ nhanh “lên tay” hơn cho việc comment cho source code của chính bạn thôi.

Để xem source code của JDK, đơn giản, khi Eclipse hoặc InteliJ đang mở, hãy nhấn giữ phím Ctrl (Windows) hoặc Command (Mac) và click vào lớp được xây dựng sẵn từ JDK. Như ví dụ dưới đây mình mở ra lớp String, bạn sẽ nhanh chóng nhìn thấy source code của lớp này trên chính IDE của bạn.

Xem source code của file String
Xem source code của file String

Hoặc bạn có thể xem ở một số link online cũng được. Như một vài link mình liệt kê sau.

Kết Luận

Chúng ta vừa xem qua các cách sử dụng Doc Comment trong lập trình Java như thế nào. Hi vọng thông qua bài viết này, các bạn sẽ nâng cao hơn tính “thẩm mỹ” và tính dễ đọc đối với các source code của các bạn thông qua việc nâng cao kỹ năng comment. Cũng như hiểu rõ các document được tạo ra như thế nào ở các thư viện mà các bạn đang dùng.

Tổng Hợp Các Phương Thức Của Thread

Chào các bạn. Bài viết này ra đời trong bối cảnh có rất nhiều bạn quan tâm đến các bài viết về Thread và đồng bộ hóa của mình, bắt đầu từ Bài 41.

Nếu các bạn có theo dõi các bài viết này, sẽ thấy mình đã bỏ hẳn một bài viết giúp tổng hợp các phương thức hữu ích của Thread. Thật ra nếu bỏ qua việc tìm hiểu các phương thức này, cũng không gây khó khăn cho việc chúng ta đi nhanh qua các bài học sau. Nhưng như vậy thì thật là tiếc vì bản thân Thread có nhiều phương thức khá hay, nếu lúc nào đó bỗng nhiên bạn cần đến, thì việc tìm hiểu thêm về chúng cũng mất kha khá thời gian.

Và cũng nhân tiện có nhiều bạn cũng đặt các câu hỏi xoay quanh một vài phương thức được dùng nhiều, hôm nay mình viết hẳn một bài để tổng hợp lại các phương thức đó của Thread lại. Mình sẽ tập trung giải nghĩa cụ thể vào từng phương thức, có ví dụ rõ ràng, để các bạn nắm rõ hơn và để mình nhanh chóng cho ra những bài viết còn lại của chuỗi kiến thức Thread khá là đồ sộ này nhé.

Lưu ý rằng bài viết hôm nay sẽ chưa có đủ mặt các phương thức, nhưng mình sẽ cập nhật thêm sau này và sẽ để lại đường link đến bài này từ các bài viết liên quan khác.

Thread.sleep()

Một phương thức đơn giản nhưng khá nhiều bạn thắc mắc.

Giải Nghĩa

sleep() là một phương thức static của Thread. Do đó chúng ta gọi kèm với tên của lớp: Thread.sleep().

Thread.sleep() làm cho Thread hiện tại (chính là Thread đang gọi đến lệnh Thread.sleep()) phải tạm hoãn lại việc thực thi trong một khoảng thời gian được chỉ định. Nói cho đầy đủ thì là vậy, nhưng các lập trình viên hay dùng theo tên của phương thức, đó là “Ngủ”.

Tuy Thread.sleep() được nạp chồng cho phép bạn chỉ định thời gian ngủ cho Thread hiện tại tính đến nano giây. Nhưng bạn đừng có nghĩ rằng Thread.sleep() sẽ thực sự ngủ với chính xác khoảng thời gian mà bạn định nghĩa nhé. Vì việc ngủ và thức này khiến cho hệ thống phải xử lý và cho ra các độ trễ khác nhau tùy vào thiết bị phần cứng nữa. Do đó Thread.sleep() cũng chỉ tương đối, và chúng ta không bao giờ được dùng Thread.sleep() để xây dựng một chức năng hẹn giờ hay đếm ngược thời gian, điều mà đòi hỏi tính chính xác về mặt thời gian rất cao.

Sử Dụng

Thread.sleep() được nạp chồng bởi 2 phương thức.

  • Thread.sleep(long millis): xác định thời gian ngủ cho Thread, tính bằng mili giây.
  • Thread.sleep(long millis, int nanos): như trên, nhưng bạn có thể thêm vào tham số thứ hai, giúp cộng thêm thời gian ngủ tính bằng nano giây.

Khi sử dụng Thread.sleep(), bạn phải try catch phương thức này bằng một Checked Exception có tên là InterruptedException. Exception này sẽ được tung ra nếu như có một Thread nào đó khác interrupt Thread này (dừng Thread lại) khi Thread.sleep() đang hoạt động.

Ví Dụ

Một ví dụ đơn giản, ví dụ này sẽ tin ra console các con số từ 0 đến 5. Mỗi lần in cách nhau nửa giây (500 mili giây). Và vì chúng ta không start() bất cứ Thread nào trong đoạn code dưới, điều đó không có nghĩa rằng là hiện tại ứng dụng đang rỗng không có Thread nào đấy nhé. Bản thân phương thức main() tự nó đã được đưa vào một Thread rồi, gọi là main thread, nên bạn có thể thoải mái gọi đến Thread.sleep() ở đây.

public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(i);
}
}

join()

Giải Nghĩa

Phương thức join() cho phép một Thread phải chờ cho đến khi các Thread khác kết thúc. Vậy Thread nào phải chờ Thread nào? Vấn đề này khiến nhiều bạn bỡ ngỡ ban đầu, do chỉ có một phương thức join() thì thể hiện thế nào về việc Thread nào chờ Thread nào. Bạn chỉ cần nhớ, Thread nào sử dụng (hay khai báo) các Thread khác, mà một trong các Thread được khai báo này gọi đến phương thức join(), Thread sử dụng sẽ phải đợi Thread khai báo đó kết thúc mới làm tiếp công việc của mình.

join() cũng có các phương thức nạp chồng cho phép Thread hiện tại chỉ cần phải chờ trong một khoảng thời gian tính bằng mili giây hoặc thêm đến nano giây. Và cũng như Thread.sleep()join() không thực sự đếm thời gian chính xác, nên bạn cũng đừng dùng join() cho một số tình huống đòi hỏi mặt khắt khe về thời gian nhé.

Sử Dụng

join() được nạp chồng bởi 3 phương thức.

  • join(): chờ đến khi Thread này kết thúc.
  • join(final long millis): chờ trong khoảng thời gian tính bằng mili giây. Nếu truyền vào đây giá trị 0, sẽ trở thành join() trên kia.
  • join(long millis, int nanos): như trên nhưng có thể cộng thêm thời gian tính bằng nano giây vào tham số thứ hai. Nếu truyền vào 2 tham số đều là 0 thì sẽ trở thành join().

Cũng giống như Thread.sleep(), sử dụng join() cũng cần phải try catch với InterruptedException. Cả hai phương thức này sẽ tung ra Exception này khi được gọi bởi lệnh interrupt.

Ví Dụ

Chúng ta hãy xây dựng một Thread đơn giản sau đây. Thread này khi chạy sẽ in ra các con số từ 0 đến 5 ở mỗi 0,5 giây.

public class MyThread extends Thread {
 
public MyThread(String threadName) {
super(threadName);
}
 
@Override
public void run() {
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName() + " " + i);
}
}
}

Còn ở phương thức main() chúng ta sẽ khai báo 3 đối tượng Thread từ lớp MyThread này. Sau khi start thread1 xong thì gọi thread1.join(), sau đó chúng ta cũng sẽ start các Thread còn lại.

public static void main(String[] args) {
MyThread thread1 = new MyThread("Thread1");
MyThread thread2 = new MyThread("Thread2");
MyThread thread3 = new MyThread("Thread3");
 
thread1.start();
try {
thread1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
thread2.start();
thread3.start();
}

Kết quả in ra console như sau.

Thread1 0
Thread1 1
Thread1 2
Thread1 3
Thread1 4
Thread2 0
Thread3 0
Thread2 1
Thread3 1
Thread2 2
Thread3 2
Thread2 3
Thread3 3
Thread2 4
Thread3 4

Bạn có thể thấy, sau khi thread1.start()thread1 này sẽ bắt đầu in các con số ra console. Tuy nhiên lời gọi thread1.join() sau đó khiến Thread đang sử dụng thread1 này (chính là main thread, thread đang chứa phương thức main() của chúng ta) rơi vào trạng thái đợi cho thread1 hoàn thành, trong quá trình đợi đó, các thread2 và thread3 khi này vẫn chưa được thực thi với hàm start(). Sau khi thread1 đếm xong và hoàn thành nhiệm vụ, các Thread còn lại mới được chạy và bắt đầu đếm là vậy.

interrupt()/isInterrupted()/Thread.interrupted()

Giải Nghĩa

Đôi khi chúng ta cần phải ngưng một Thread nào đó đang hoạt động. Cách hợp lý là gọi đến phương thức interrupt() của Thread. Lời gọi interrupt() thực chất cũng không làm cho Thread được gọi đến ngưng tác vụ ngay đâu, điều đó khá là nguy hiểm khi mà hệ thống không biết chắc Thread này đang làm việc gì, biết đâu nó đang ghi một file quan trọng nào đó. Việc một Thread khác interrupt() thoải mái một Thread nào đó có thể gây nên hậu quả khôn lường. Do đó, interrupt() không được xây dựng sẵn các lệnh giúp dừng Thread từ hệ thống, nó chỉ được xem như là một chỉ thị đưa ra cho Thread đang hoạt động biết, tốt hơn hết chính Thread đang hoạt động đó phải biết được chỉ thị này và tự biết cách ngưng tác cụ của mình lại. Một lát ở ví dụ bên dưới chúng ta sẽ nắm được điều này. Hai phương thức còn lại isInterrupted() và Thread.interrupted() dùng để kiểm tra Thread này có bị interrupt hay chưa.

Sử Dụng

  • interrupt(): đưa ra chỉ thị cho Thread rằng nó đang được gọi để chấm dứt việc thực thi.
  • isInterrupted(): kiểm tra xem Thread đó có bị chấm dứt hay chưa.
  • Thread.interrupted(): cũng như isInterrupted(), nhưng việc gọi kiểm ta với phương thức này cũng kèm với việc trả lại trạng thái ban đầu cho Thread sau đó. Nên việc gọi kiểm tra một Thread có bị chấm dứt hay chưa bởi hai lần gọi phương thức này có thể cho ra hai kết quả khác nhau.

Ví Dụ

Ví dụ lần này chúng ta hãy đi từ phương thức main(), để xem với lời gọi interrupt() như thế này thì MyThread phải làm sao là đúng nhé.

public static void main(String[] args) {
MyThread thread = new MyThread();
 
System.out.println("We start this Thread");
thread.start();
 
try {
thread.join(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
 
System.out.println("We stop this Thread");
thread.interrupt();
}

Nếu như ở MyThread, chúng ta “thờ ơ” với InterruptedException, tức là không có chuẩn bị gì cho sự interrupt cả, như sau.

public class MyThread extends Thread {
 
@Override
public void run() {
for (int i = 0; i < 5; i++){
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
}
}
}

Kết quả in ra console sẽ như sau. Bạn thấy ở phương thức main() gọi interrupt() trong “vô vọng”, vòng lặp vẫn lặp và đếm như thường, nó chỉ kết thúc khi hoàn thành tác vụ mà thôi.

We start this Thread
0
1
We stop this Thread
2
3
4

Tuy nhiên, nếu chúng ta sửa MyThread một chút, có “trách nhiệm” hơn vớ InterruptedException, Thread này sẽ kết thúc dễ dàng do có sự chuẩn bị trước.

public class MyThread extends Thread {
 
@Override
public void run() {
for (int i = 0; i < 5; i++){
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("OK, I'm stop.");
return;
}
}
}
}

Console sẽ in ra như sau.

We start this Thread
0
1
We stop this Thread
OK, I'm stop.

Hoặc try catch như thế này cũng cho ra kết quả viên mãn như trên.

public class MyThread extends Thread {
 
@Override
public void run() {
try {
for (int i = 0; i < 5; i++) {
System.out.println(i);
Thread.sleep(1000);
}
} catch (InterruptedException e) {
System.out.println("OK, I'm stop.");
}
}
}

Thread.currentThread()

Giải Nghĩa

Lời gọi Thread.currentThread() sẽ nhận về một tham chiếu đến đối tượng Thread hiện tại. Phương thức này cũng không quá phức tạp nên mình cũng không giải thích gì nhiều.

Ví Dụ

Bạn hãy nhìn vào MyRunnable sau. Trong trường hợp này thì để có thể lấy được tên của Thread đang chạy, chỉ có cách gọi đến Thread.currentThread() mà thôi.

public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " started.");
}
}

Phương thức main() như sau.

public static void main(String[] args) {
MyRunnable myRunnable1 = new MyRunnable();
MyRunnable myRunnable2 = new MyRunnable();
 
new Thread((myRunnable1)).start();
new Thread((myRunnable2)).start();
}

Kết quả in ra console.

Thread-1 started.
Thread-0 started.

getName()/setName()

Giải Nghĩa

Mặc định thì các Thread khi khởi chạy trong ứng dụng, sẽ được hệ thống đặt cho một cái tên, tuần tự, như sau: Thread-0Thread-1,….

Nếu bạn thấy các tên này rất khó để gợi nhớ, hãy đặt cho chúng một cái tên khác theo ý bạn.

Sử Dụng

  • getName(): trả về tên của Thread.
  • setName(String name): đặt một tên mới cho Thread.

Ví Dụ

Như bao ví dụ khác, chúng ta cần một Thread khá đơn giản như sau. Thread này sẽ in ra các con số từ 0 đến 5 ở mỗi 0,5 giây. Tuy nhiên Thread có kèm theo phương thức getName() để hiển thị tên của nó ra console.

public class MyThread extends Thread {
 
@Override
public void run() {
for (int i = 0; i < 5; i++){
System.out.println(getName() + " " + i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

Ở main() chúng ta sẽ start Thread với cái tên mặc định trước. Đợi Thread này chạy 1 giây sau rồi đổi tên cho nó.

public static void main(String[] args) {
MyThread thread = new MyThread();
 
thread.start();
try {
thread.join(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread.setName("Thread Changed Name");
}

Kết quả in ra console, tên Thread bị đổi sau 2 lần lặp.

Thread-0 0
Thread-0 1
Thread Changed Name 2
Thread Changed Name 3
Thread Changed Name 4

Định Dạng String/Output trong Java

Thời gian qua có một số bạn nêu lên một thắc mắc khá thực tế, đó là làm sao để có thể in ra console các nội dung được định dạng theo mong muốn của chương trình. Thắc mắc này không những đến từ các bạn muốn được xuất chuỗi ra console đẹp hơn. Mà còn cả các bạn đang làm UI hẳn hoi, thậm chí Android nữa, muốn quản lý dữ liệu chuỗi được đẹp và hiệu quả trên giao diện ứng dụng của các bạn.

Vậy bài viết này mình xin tổng hợp lại các thắc mắc, cũng như nêu lên các cách mà ngôn ngữ Java hỗ trợ chúng ta trong việc định dạng này. Nếu bạn nào vẫn chưa thực sự hiểu phần giới thiệu này đang nói về điều gì thì có thể đến với mục tiếp theo, mình sẽ nói rõ hơn.

Tại Sao Phải Định Dạng String/Output

Để trả lời câu hỏi này, mình mời các bạn đến với một tình huống sau (mình có đưa ví dụ tình huống này ở mục Xuất trên console của Bài 7), khi đó mình muốn in ra console câu lệnh này.

public static void main(String[] args) {
System.out.println("The result is " + (10000.0/3.0));
}

Kết quả consle sẽ xuất hiện nội dung: 

The result is 3333.3333333333335

.

Rất nhiều trường hợp chúng ta sẽ mong muốn con số in ra không quá dài như vậy, chúng ta muốn một con số được làm tròn. Ví dụ con số làm tròn ngắn hơn 3333.33, hay có thêm % như thế này 3333.33%.

Ví dụ tiếp theo cho thấy nhu cầu mong muốn được định dạng cũng xuất hiện ở việc sử dụng kiểu String (mặc dù mình vẫn dùng các phương thức in ra console, nhưng mình đã cố tình dùng biến kiểu String trước đó, để cho thấy tình huống sử dụng kiểu String như thế này có thể gặp đâu đó trong lập trình ứng dụng Java có giao diện, hoặc lập trình Android).

public static void main(String[] args) {
String today = "Today is " + new Date();
System.out.println(today);
}

Kết quả của biến today sẽ chứa nội dung: 

Today is Fri Nov 25 11:19:53 ICT 2022

.

Bạn có thích kiểu ngày tháng được lưu trữ trên String như vậy không? Dĩ nhiên bạn sẽ cần một định dạng thân thiện hơn với người dùng để hiện thị ra UI rồi.

Thật may mắn là mong muốn đó của chúng ta đã được Java hỗ trợ “tận răng”, nhưng không phải ai cũng biết mà sử dụng nó, hoặc biết nhưng chưa sử dụng hiệu quả. Mời các bạn cùng xem trước 2 ví dụ trên được mình viết lại như sau bằng cách sử dụng các cách để định dạng các Output cũng như định dạng String.

System.out.printf("The result is %.2f", 10000.0/3.0);

Kết quả in ra: 

The result is 3333.33

.

String today = String.format("Today is %1$tB %1$te, %1$tY", new Date());
System.out.println(today);

Kết qủa in ra: 

Today is November 25, 2022

.

Bạn có thấy kết quả in ra khác biệt và dễ hiểu hơn đúng không nào. Bạn cũng có nhìn thấy sự khác biệt giữa code được sử dụng ở từng trường hợp không. Sẽ không sao nếu bạn chưa hiểu tại sao các con số in ra lại đẹp như vậy. Nếu bài viết đúng với nhu cầu cần tìm hiểu của bạn, thì hãy cùng mình đi đến các bước tiếp theo về việc làm thế nào để có thể định dạng chúng nhé.

Giới Thiệu Các Phương Thức Định Dạng String/Output

Chắc chắn bạn đã hiểu định dạng String/Output là gì và tại sao chúng ta lại cần phải định dạng chúng để dữ liệu trông đẹp đẽ hơn rồi.

Mục này mình muốn giới thiệu với các bạn các phương thức cụ thể dùng vào việc định dạng này.

Hai Phương Thức Định Dạng String/Output

printf()

Như bạn cũng đã được làm quen. Thay vì sử dụng các phương thức “thông thường” là print() hay println() để xuất dữ liệu “thô” ra console, bạn có thể sử dụng printf() để định dạng lại dữ liệu cần xuất. Chữ f ở cuối phương thức là chữ viết tắt của format (định dạng).

String.format()

Một phương thức static hữu ích được bổ sung vào lớp String, cũng giúp chúng ta định dạng chuỗi để lưu vào một biến String nào đó.

Cách Sử Dụng Hai Phương Thức Định Dạng String/Output

Hai phương thức được nêu ở mục trên tuy cơ bản là dùng ở hai mục đích khác nhau, nhưng cách sử dụng chúng là như nhau. Các ví dụ tiếp theo của bài viết mình sẽ dùng luân phiên từng phương thức, nhưng bạn vẫn hoàn toàn có thể yên tâm áp dụng cách dùng đến phương thức còn lại nhé.

Quay lại cách sử dụng, cả hai phương thức trên đều có hai cách truyền vào các tham số như nhau.

  • (String format, Object… args): cách này cho chúng ta truyền vào một chuỗi trong đó có chứa một hoặc nhiều dấu hiệu định dạng ở tham số format. Sau đó chúng ta sẽ truyền thêm vào các giá trị cần định dạng ở các tham số tiếp theo của phương thức.
  • (Locale l, String format, Object… args): cách này có thêm tham số Locale ở đầu, giúp chúng ta chỉ định việc định dạng theo quốc gia cụ thể. Các quốc gia sẽ có sự khác nhau ở một số hiển thị như: dấu phân cách phần ngàn, dấu thập phân, hay hiển thị thứ, tháng dạng chữ, hiển thị ngày/đêm trong giờ,… Một lát nữa ở các ví dụ bên dưới bạn sẽ có cơ hội được trải nghiệm việc chỉ định Locale để định dạng hiển thị ngày tháng theo tiếng Việt.

Mục tiếp theo chúng ta sẽ đi qua các cách để định dạng String/Output.

Định Dạng String/Output

Đây là phần chính yếu và quan trọng của bài viết. Trước khi đi cụ thể vào từng cách thức định dạng, mình mời các bạn xem sơ qua về Sơ đồ cú pháp (syntax diagram) của việc định dạng như sau.


Sơ đồ cú pháp của việc định dạng

Tuy nhìn hơi rối nhưng thực sự sơ đồ trên giúp tóm gọn lại tất cả các cách định dạng, khiến chúng trở nên dễ nhớ và dễ sử dụng hơn. Chúng ta cùng nhau mổ xẻ sơ đồ nào, thông qua việc bám sát vào sơ đồ bạn sẽ tìm ra tất cả các trường hợp hữu dụng của việc định dạng đấy nhé.

Quy Ước Sử Dụng Sơ Đồ

Nhìn vào sơ đồ, bạn thấy có ba loại hình biểu diễn.

  • Hình mũi tên cho thấy thứ tự xuất hiện của từng thành phần trong định dạng. Bạn thấy chúng ta sẽ nhìn theo chiều mũi tên từ trái sang phải để cho ra các trường hợp khác nhau của định dạng. Mũi tên có thể biểu diễn bởi chỉ một đường đi thẳng nếu bạn không có nhu cầu rẽ nhánh, hoặc mũi tên giúp rẽ xuống nhánh dưới hơn cho các nhu cầu khác nhau. Lát nữa bạn sẽ rõ từng nhánh nhu cầu.
  • Hình tròn màu cam có hiển thị các ký tự. Đây là các ký tự được định sẵn. Bạn thấy bắt đầu của dấu hiệu định dạng luôn phải khai báo bởi ký tự % (bạn hãy nhìn lại một chút ví dụ có sử dụng định dạng ở trên sẽ rõ). Ký tự % này sẽ báo với hệ thống rằng, đến đây thì String hay Output không phải hiển thị chuỗi đang được khai báo nữa, mà sẽ được thay thế bởi một giá trị cần định dạng nào đó đã được khai báo ở các tham số tiếp theo của phương thức. % cũng giúp hệ thống bắt đầu nhìn vào các ký tự sau nó (chính là các ký tự được mô tả trong hình chữ nhật màu tím) mà định dạng giá trị cho bạn.
  • Hình chữ nhật màu tím cho chúng ta điền vào các tùy chọn, giúp chúng ta tạo ra các định dạng mong muốn. Dĩ nhiên là chúng ta cần phải học cách sử dụng từng nội dung cụ thể của từng trường hợp rồi. Từng trường hợp tiếp sẽ nói rõ hơn các cách sử dụng đối với thành phần này.

Trường Hợp 1: %-conversion_character

Đầu tiên chúng ta hãy xem xét sơ đồ với một trường hợp đơn giản nhất. Chính là biểu diễn bởi đường này.


Trường hợp %-conversion_character

Conversion character cho phép chúng ta chỉ định kiểu dữ liệu cần hiển thị. Mình xin liệt kê tất cả các conversion character ở bảng sau rồi đến các ví dụ để bạn rõ hơn về cách dùng (lưu ý mình không liệt kê đầy đủ các conversion character đâu nhé, có vài conversion character không dùng vào chỗ nào cả nên mình bỏ qua cho đỡ rối).

Conversion characterMục đíchVí dụ
d Định dạng kiểu số nguyên (thập phân). 159
x Định dạng kiểu hexa (thập lục phân). 9f
o Định dạng kiểu octal (bát phân). 237
f Định dạng kiểu số thực (với dấu chấm cố định). 15.9
e Định dạng kiểu số thực (với số mũ kèm theo). 1.59e+01
s Định dạng kiểu chuỗi. Hello
c Định dạng kiểu ký tự. H
b Định dạng kiểu boolean. true
% Đây không phải % bắt đầu cho định dạng như đã nói trên kia. Ký tự này đặc biệt hơn, giúp hiển thị % ra cùng với kết quả được định dạng. %

Ví Dụ 1

Ví dụ sau kêu người dùng nhập vào tên, sau đó in ra console câu chào “Hello! <tên vừa nhập>!” (Mình lấy lại ví dụ của bài này để bạn so sánh).

Scanner scanner = new Scanner(System.in);
System.out.print("Please enter your name here: ");
String name = scanner.nextLine();
System.out.printf("Hello! %s!", name);

Bạn có thấy cách sử dụng của printf() ở dòng được tô sáng trong ví dụ trên không. Như mình có nói, String.format() cũng sẽ có cách dùng tương tự vậy.

Mình giải thích lại, nếu như bạn đã hiểu cách dùng của các phương thức định dạng rồi thì bỏ qua phần này.

printf() hay String.format() cho phép truyền vào nhiều tham số, nếu bỏ qua việc dùng tham số Locale thì tham số thứ nhất phải là một chuỗi mà nội dung có chứa dấu hiệu định dạng. Và như bạn cũng đã biết, cú pháp của dấu hiệu định dạng này bắt đầu bằng % và kèm theo các ký tự đã được quy định. Bạn thấy ở ví dụ trên có xuất hiện dấu hiệu định dạng là %s%s được bắt đầu bởi % báo cho hệ thống biết chúng ta cần phải hiển thị một giá trị cần định dạng gì đó ở đây. Theo sau % là conversion character s, ý muốn nói hệ thống hãy hiển thị giá trị cần định dạng là một kiểu chuỗiGiá trị cần định dạng chính là tham số tiếp theo, chính là biến name.

Số lượng xuất hiện của các dấu hiệu định dạng bên trong tham số thứ nhất của hai phương thức này là không giới hạn. Tuy nhiên bạn cũng cần phải khai báo nhất quán số lượng giá trị cần định dạng ở các tham số tiếp theo của phương thức sao cho đồng nhất với số lượng dấu hiệu định dạng, nếu bạn không muốn ứng dụng bị crash. Bạn hãy đến với ví dụ sau sẽ rõ hơn.

Ví Dụ 2

Ví dụ sau kêu người dùng nhập vào tên, rồi nhập vào tuổi, sau đó in ra console câu chào “Hello! <tên vừa nhập>! You are <tuổi vừa nhập> years old.”.

Scanner scanner = new Scanner(System.in);
System.out.print("Please enter your name here: ");
String name = scanner.nextLine();
 
System.out.print("How old are you? ");
int age = scanner.nextInt();
 
System.out.printf("Hello! %s! You are %d years old", name, age);

Bạn có thấy xuất hiện hai dấu hiệu định dạng là %s và %d không. Và do đó các tham số name và age cũng phải được khai báo kèm theo ở 2 tham số tiếp theo của phương thức. Hai tham số name và age phải đảm bảo đúng số lượng và đúng thứ tự với %s và %d trong chuỗi định dạng.

Ví Dụ 3

Chúng ta hãy hiển thị giá trị của phép chia sau, đảm bảo kết quả có thêm dấu % sau cùng.

System.out.printf( "%s %f%%", "The result is: ", 1.0/4.0);

Kết quả như chúng ta mong muốn.

The result is: 0.250000%

Trường Hợp 2: %-argument_index-$-conversion_character

Mức độ khó nâng lên. Nếu nhìn tiêu đề của trường hợp này rối quá thì mời bạn xem lại một phần hướng đi của sơ đồ mà chúng ta muốn xem xét.

Trường hợp %-argument_index-$-conversion_character
Trường hợp %-argument_index-$-conversion_character

Trường hợp cú pháp này cho bạn thêm một tùy chọn định dạng, đó chính là argument index. Tham số này giúp bạn chỉ định vị trí của các giá trị cần định dạng. Nếu như với Trường hợp 1 bạn đã làm quen trên kia, khi không chỉ định argument index, bạn nhất thiết phải truyền vào phương thức các giá trị cần định dạng theo đúng số lượng và thứ tự của các dấu hiệu định dạng. Còn Trường hợp 2 này giúp bạn linh động hơn khi chỉ định vị trí của các tham số này.

Argument index không cần đến một bảng liệt kê gì cả, vì nó là vị trí nên bạn cũng đoán nó sẽ chứa các con số rồi. Nhưng ngoài số ra thì argument index cũng có một ký tự được quy định là ‘<‘, ký tự này dùng khi bạn muốn dùng lại giá trị vị trí của dấu hiệu định dạng đứng trước nó. Và có một lưu ý là argument index bắt đầu bằng 1 để chỉ định vị trí đầu tiên của tham số cần định dạng (không phải bắt đầu là 0 như với mảng).

Ví Dụ 4

Chúng ta hãy lấy lại Ví dụ 2 nhưng có chỉ định thêm argument index xem sao nhé.

Scanner scanner = new Scanner(System.in);
System.out.print("Please enter your name here: ");
String name = scanner.nextLine();
 
System.out.print("How old are you? ");
int age = scanner.nextInt();
 
System.out.printf("Hello! %1$s! You are %2$d years old", name, age);

Bạn đã hiểu %1$s sẽ tìm và thay thế bởi tham số giá trị cần định dạng đầu tiên là name. Tương tự %2$d là age. Trong thực tế nếu muốn hiển thị định dạng như thế này thì mình thích dùng như Ví dụ 2 hơn, vì nó sẽ ngắn gọn hơn, mình sẽ rất ít dùng Ví dụ 3 trừ trường hợp nếu muốn hiển thị vị trí tường minh hơn, nhưng cũng sẽ rườm rà hơn. Tuy nhiên cách dùng argument index lại hiệu quả ở một số trường hợp, như với ví dụ tiếp theo.

Ví Dụ 5

Chúng ta sẽ đến một ví dụ thực tế hơn cho argument index này. Ví dụ này sẽ kêu người dùng nhập vào tên và họ, sau đó in ra câu chào hỏi và hiển thị lại đầy đủ họ tên như sau: “Hello <tên>! Your full name is <tên> <họ>.”. Bạn có thấy rằng tuy người dùng chỉ cần nhập vào hai biến là tên và họ, nhưng khi in ra console, chúng ta có nhu cầu xuất ra với 3 vị trí của dấu hiệu định dạng, trong đó có 2 vị trí trùng vào giá trị cần định dạng là tên. Chúng ta có thể sử dụng argument index để lấy vị trí giá trị cần định dạng tên như sau.

Scanner scanner = new Scanner(System.in);
System.out.print("Please enter your first name: ");
String firstName = scanner.nextLine();
 
System.out.print("Please enter your last name: ");
String lastName = scanner.nextLine();
 
System.out.printf("Hello %1$s! Your full name is %1$s %2$s.", firstName, lastName);

Bạn có thấy mình dùng 2 dấu hiệu định dạng là %1$s ở 2 chỗ không, chúng đều có ý định rằng bạn muốn hiển thị cùng một giá trị cần định dạng thứ 1 vào 2 vị trí trong chuỗi cần định dạng. Nếu bạn không thích có thể viết như thế này:

System.out.printf("Hello %1$s! Your full name is %2$s.", firstName, (firstName + " " + lastName));

Hay thế này cũng được.

System.out.printf("Hello %s! Your full name is %s.", firstName, (firstName + " " + lastName));

Nhưng cách dùng lại vị trí %1$s theo mình là tường minh nhất. Cùng tùy bạn thôi. Nhưng như mình có nói, với cách dùng argument index thì còn có ký tự ‘<‘, cách dùng của ký tự này như sau.

System.out.printf("Hello %1$s! Your full name is %<s %2$s.", firstName, lastName);

Trường Hợp 3: %-flag-conversation_character

Sơ đồ cơ bản của trường hợp này như bạn đã biết như sau.


Trường hợp %-flag-conversation_character

Tuy nhiên chúng ta cũng nên biết rằng do đường chạy của mũi tên khá linh động, nên dù cho chúng ta đang nói đến cách dùng flag trong trường hợp này, nó cũng có nghĩa chúng ta hoàn toàn có thể kết hợp lại với argument index trên kia nhé. Khi đó nó sẽ như thế này.


Kết hợp của 2 trường hợp là flag và argument index

Nếu như conversion character giúp kiểm soát kiểu dữ liệuargument index giúp chỉ định vị trí của tham số giá trị cần định dạng. Thì flag giúp chỉ định cách thức hiển thị của các giá trị cần xuất. Các cách hiển thị được mình tổng hợp lại trong bảng sau.

FlagMục đíchVí dụ
+ Hiện thị dấu cho số dương và âm. +3333.33
Khoảng trắng Nhớ là chỉ 1 khoảng trắng thôi. Thay vì hiển thị dấu thì cách này dùng để thêm 1 khoảng trắng trước số dương (không có khoảng trắng nào được thêm nếu nó là số âm). | 3333.33|
0 Thêm số 0 vào trước số (kiểu này chỉ làm việc được khi đi kèm với width ở Trường hợp kế tiếp mà bạn sẽ làm quen). 003333.33
Canh trái số (kiểu này cũng chỉ làm việc được khi đi kèm với width ở Trường hợp kế tiếp). |3333.33 |
( Thay vì hiển thị dấu thì cách này giúp bao lấy số âm bởi cặp ngoặc tròn. (3333.33)
, Phân tách phần ngàn của số bởi dấu (,). Rất hiệu quả khi bạn hiển thị số quá lớn. 3,333.33

Ví Dụ 6

Bạn có thể thử tưởng tượng ra các cách để thử nghiệm cho vui. Như cách sau mình hiển thị kết quả của phép chia cho ra số âm với nhiều tùy chọn định dạng.

System.out.printf("The negative numbers:\n%1$+f\n%1$(f\n%1$(,f", (-10000.0/3.0));

Kết quả in ra console như sau.

The negative numbers:
-3333.333333
(3333.333333)
(3,333.333333)

Trường Hợp 4: %-width-conversion_character

Trường hợp %-width-conversion_character
Trường hợp %-width-conversion_character

Dĩ nhiên là Trường hợp 4 này cũng có thể dùng chung với các Trường hợp đã nói ở trên đấy nhé.

Trường hợp này giúp bạn chỉ định số lượng ký tự của giá trị cần định dạng.

Ví Dụ 7

Ví dụ sau đây chỉ định số lượng ký tự của phép chia.

System.out.printf("The result is %20f.", (10000.0/3.0));

Kết quả in ra sẽ là.

The result is 3333.333333.

Như dấu hiệu định dạng bạn chỉ định %20f, tức bạn muốn hiển thị 20 ký tự số lúc này. Mà kết quả của phép chia là 3333.333333 có tổng cộng 11 ký tự (kể cả dấu chấm thập phân). Chính vì vậy hệ thống sẽ phải thêm 9 ký tự trống vào trước kết quả.

Bạn có thể kết hợp với hai giá trị flag là 0 và  mà bảng ở Trường hợp 3 có mô tả. Bạn sẽ thấy với flag 0 thì hệ thống sẽ điền thêm 0 vào trước định dạng cho đủ số ký tự đã khai báo ở width. Còn flag – thì canh chỉnh số được canh trái trong khi thêm khoảng trắng vào bên phải số cho đủ số ký tự.

System.out.printf("The result is:\nOriginal: %1$20f.\nAdded 0: %1$020f.\nLeft alight: %1$-20f.", (10000.0/3.0));

Và kết quả.

The result is:
Original: 3333.333333.
Added 0: 0000000003333.333333.
Left alight: 3333.333333 .

Trường Hợp 5: %-.-precision-conversion_character

Chúng ta hãy đến với sơ đồ của trường hợp này.

Trường hợp %-.-precision-conversion_character
Trường hợp %-.-precision-conversion_character

Trường hợp này ghi là precision, nhưng bạn có thể nhớ mục đích chính của nó là chỉ định số lượng chữ số sau dấu thập phân, và có thể làm tròn số khi cần. Ngoài ra thì định dạng này còn có thể áp dụng được với chuỗi hay kiểu giá trị khác, nhưng mình chưa gặp nhiều ứng dụng thực tế ở khoản này. Bạn có thể xem các ví dụ sau để hiểu rõ cách dùng.

Ví Dụ 8

Mình lấy lại code của mục Tại sao ở đầu bài viết.

System.out.printf("The result is %.2f", 10000.0/3.0);

Bạn cũng đã biết kết quả của câu lệnh xuất này là: 

The result is 3333.33

.

Giờ thì bạn đã hiểu %.2f đã chỉ định giá trị cần định dạng hãy hiển thị 2 chữ số sau dấu thập phân.

Bạn có thể kết hợp cả width lẫn precision như sau.

System.out.printf("The result is %20.2f", 10000.0/3.0);

Kết quả như bạn tưởng tượng, phần thập phân vẫn là 2 chữ số. Nhưng tổng cộng các chữ số khi in ra phải đảm bảo đủ 20 ký tự (bao gồm cả 2 ký tự thập phân và dấu chấm).

The result is 3333.33

Trường Hợp 6: %-t-conversion_character

Trường hợp này khá đặc biệt, nó khác với các Trường hợp đã nói ở trên. Thứ nhất, tuy nó có thể kết hợp giữa các Trường hợp 1-2-3-4, nhưng không kết hợp với Trường hợp 5 (bạn thấy nó đi một nhánh khác vòng qua Trường hợp 5 là vậy). Hơn nữa, nó không dùng lại conversion character mà chúng ta đã biết ở các Trường hợp trước, mà dùng một định nghĩa các conversion character khác, mình sẽ liệt kê bảng này ở bên dưới đây. Mời bạn xem sơ đồ.

Trường hợp %-t-conversion_character
Trường hợp %-t-conversion_character

Sử dụng đến kiểu định dạng này nếu bạn muốn định dạng sự xuất ra của kiểu Date (giúp hiển thị thời gian). Mời bạn xem qua bảng tổng hợp tất cả các ký tự định dạng như sau.

Conversion characterMục đíchVí dụ
c Hiển thị đầy đủ ngày tháng. Mon Nov 28 15:17:03 ICT 2022
F Hiển thị theo chuẩn ISO 8601. 2022-11-28
D Hiển thị ngày theo chuẩn Mỹ (month/day/year). 11/28/22
T Hiển thị giờ theo 24-giờ. 15:21:47
r Hiển thị giờ theo 12-giờ. 03:21:47 PM
R Hiển thị theo 24-giờ, nhưng không có giây. 15:21
Y Hiển thị năm với 4 ký tự. 2022
y Hiển thị năm với 2 ký tự. 22
C Hiển thị 2 ký tự đầu của năm. 20
B Hiển thị đầy đủ tháng dạng chữ. November/tháng 11
b hay h Hiển thị viết tắt của tháng dạng chữ. Nov/thg 11
m Hiển thị tháng dạng số với 2 ký tự (thêm 0 vào trước với tháng có 1 ký tự). 02
d Hiển thị ngày với 2 ký tự (thêm 0 vào trước với ngày có 1 ký tự). 09
e Hiển thị ngày (không thêm 0 vào trước với ngày có 1 ký tự). 9
A Hiển thị đầy đủ ngày trong tuần. Monday/Thứ Hai
a Hiển thị viết tắt của ngày trong tuần. Mon/Th 2
j Hiển thị ngày của năm với 3 số (tự thêm 0 vào trước cho đủ). 069
H Hiển thị giờ với 2 ký tự, loại 24-giờ (thêm 0 vào trước với giờ có 1 số). 15
k Hiển thị giờ, loại 24-giờ (không thêm 0 vào trước với giờ có 1 số). 15
I Hiển thị giờ với 2 ký tự, loại 12-giờ (thêm 0 vào trước với giờ có 1 số). 03
l Hiển thị giờ với 2 ký tự, loại 12-giờ (không thêm vào trước với giờ có 1 số). 3
M Hiển thị phút với 2 ký tự (thêm 0 vào trước với phút có 1 số). 05
S Hiển thị giây với 2 ký tự (thêm 0 vào trước với giây có 1 số). 19
L Hiển thị mili giây với 3 ký tự (thêm 0 vào trước cho đủ). 047
p Hiển thị sáng/tối. pm/ch
z Hiển thị múi giờ. Hay nói đầy đủ nó là độ lệnh giờ của múi giờ hiện tại của bạn so với giờ GMT. +0700
Z Hiển thị tên của múi giờ. ICT
s Hiển thị số giây tính từ 00:00:00 GMT ngày 01/01/1970. 1669626632
Q Hiển thị số mili giây tính từ 00:00:00 GMT ngày 01/01/1970. 1669626632777

Ví Dụ 9

Chúng ta hãy cùng lấy lại code ở mục Tại sao ở đầu bài viết để hiểu rõ hơn.

System.out.printf("Today is %1$tB %1$te, %1$tY", new Date());

Giờ chắc bạn đã hiểu %1$tB giúp lấy tham số giá trị cần định dạng thứ nhất, chính là new Date(), việc khởi tạo một kiểu Date như thế này cho chúng ta thông tin của thời gian hiện tại. Sau đó định dạng tB tức là hiển thị dạng đầy đủ của tháng. Rồi đến te giúp hiển thị ngày. Cuối cùng tY là năm. Kết quả của câu lệnh là.

Today is November 28, 2022

Nếu bạn muốn hiển thị ngày tháng theo kiểu tiếng Việt ư, vậy thì hãy thêm Locale vào tham số đầu tiên của phương thức như sau nhé.

Locale locale = new Locale("vi", "VN");
System.out.printf(locale, "Hôm nay là ngày %1$te, %<tB, năm %<tY", new Date());

Kết quả sẽ là.

Hôm nay là ngày 28, tháng 11, năm 2022

Cám ơn bạn đã đọc tài liệu của chúng tôi

Công ty cổ phần thương mại Vạn Tín Việt

0936.006.058
0936.006.058