Giáo trình Lập trình hướng đối tượng (Phần 2) - Đại học Công nghệ thuộc Đại học Quốc gia Hà Nội

pdf 99 trang Gia Huy 17/05/2022 1770
Bạn đang xem 20 trang mẫu của tài liệu "Giáo trình Lập trình hướng đối tượng (Phần 2) - Đại học Công nghệ thuộc Đại học Quốc gia Hà Nội", để tải tài liệu gốc về máy bạn click vào nút DOWNLOAD ở trên

Tài liệu đính kèm:

  • pdfgiao_trinh_lap_trinh_huong_doi_tuong_phan_2_dai_hoc_cong_ngh.pdf

Nội dung text: Giáo trình Lập trình hướng đối tượng (Phần 2) - Đại học Công nghệ thuộc Đại học Quốc gia Hà Nội

  1. Ch−¬ng 9. Vßng ®êi cña mét ®èi t−îng Trong chương này, ta nói về vòng đời của đối tượng: đối tượng được tạo ra như thế nào, nó nằm ở đâu, làm thế nào để giữ hoặc vứt bỏ đối tượng một cách có hiệu quả. Cụ thể, chương này trình bày về các khái niệm bộ nhớ heap, bộ nhớ stack, phạm vi, hàm khởi tạo, tham chiếu null 9.1. BỘ NHỚ STACK VÀ BỘ NHỚ HEAP Trước khi nói về chuyện gì xảy ra khi ta tạo một đối tượng, ta cần nói về hai vùng bộ nhớ stack và heap và cái gì được lưu trữ ở đâu. Đối với Java, heap và stack là hai vùng bộ nhớ mà lập trình viên cần quan tâm. Heap là nơi ở của các đối tượng, còn stack là chỗ của các phương thức và biến địa phương. Máy ảo Java toàn quyền quản lý hai vùng bộ nhớ này. Lập trình viên không thể và không cần can thiệp. Đầu tiên, ta hãy phân biệt rõ ràng biến thực thể và biến địa phương, chúng là cái gì và sống ở đâu trong stack và heap. Nắm vững kiến thức này, ta sẽ dễ dàng hiểu rõ những vấn đề như phạm vi của biến, việc tạo đối tượng, quản lý bộ nhớ, luồng, xử lý ngoại lệ những điều căn bản mà một lập trình viên cần nắm được (mà ta sẽ học dần trong chương này và những chương sau). Biến thực thể được khai báo bên trong một lớp chứ không phải bên trong một phương thức . Chúng đại diện cho các trường dữ liệu của mỗi đối tượng (mà ta có thể điền các dữ liệu khác nhau cho các thực thể khác nhau của lớp đó). Các biến thực thể sống bên trong đối tượng chủ của chúng. Biến địa phương, trong đó có các tham số, được khai báo bên trong một phương thức . Chúng là các biến tạm thời, chúng sống bên trong khung bộ nhớ của phương thức và chỉ tồn tại khi phương thức còn nằm trong bộ nhớ stack, nghĩa là khi phương thức đang chạy và chưa chạy đến ngoặc kết thúc (}). Vậy còn các biến địa phương là các đối tượng? Nhớ lại rằng trong Java một biến thuộc kiểu không cơ bản thực ra là một tham chiếu tới một đối tượng chứ không phải chính đối tượng đó. Do đó, biến địa phương đó vẫn nằm trong stack, còn đối tượng mà nó chiếu tới vẫn nằm trong heap. Bất kể tham chiếu được khai báo ở đâu, 143
  2. là biến địa phương của một phương thức hay là biến thực thể của một lớp, đối tượng mà nó chiếu tới bao giờ cũng nằm trong heap. () { } Vậy biến thực thể nằm ở đâu? Các biến thực thể đi kèm theo từng đối tượng, chúng sống bên trong vùng bộ nhớ của đối tượng chủ tại heap. Mỗi khi ta gọi new Cow(), Java cấp phát bộ nhớ cho đối tượng Cow đó tại heap, lượng bộ nhớ được cấp phát đủ chỗ để lưu giá trị của tất cả các biến thực thể của đối tượng đó. Nếu biến thực thể thuộc kiểu cơ bản, vùng bộ nhớ được cấp phát cho nó có kích thước tùy theo kích thước của kiểu dữ liệu nó được khai báo. Ví dụ một biến int cần 32 bit. Còn nếu biến thực thể là đối tượng thì sao? Chẳng hạn, Car HAS-A Engine (ô tô có một động cơ), nghĩa là mỗi đối tượng Car có một biến thực thể là tham chiếu kiểu Engine. Java cấp phát bộ nhớ bên trong đối tượng Car đủ để lưu biến tham chiếu engine. Còn bản thân biến này sẽ chiếu tới một đối tượng Engine nằm bên ngoài, chứ không phải bên trong, đối tượng Car. Hình 9.1: Đi t ng có bi ến th ực th ể ki ểu tham chi ếu. Vậy khi nào đối tượng Engine được cấp phát bộ nhớ trong heap? Khi nào lệnh new Engine() cho nó được chạy. Chẳng hạn, trong ví dụ Hình 9.2, đối tượng Engine được tạo mới để khởi tạo giá trị cho biến thực thể engine, lệnh khởi tạo nằm ngay trong khai báo lớp Car. 144
  3. Hình 9.2: Bi ến th ực th ể c kh ởi t ạo khi khai báo. Còn trong ví dụ Hình 9.3, không có đối tượng Engine nào được tạo khi đối tượng Car được cấp phát bộ nhớ, engine không được khởi tạo. Ta sẽ cần đến các lệnh riêng biệt ở sau đó để tạo đối tượng Engine và gán trị cho engine, chẳng hạn như c.engine = new Engine(); trong Hình 9.1. không có đối tượng Engine nào được tạo ra, biến engine chưa được khởi tạo bởi một đối tượng thực Hình 9.3: Bi ến th ực th ể không được kh ởi t ạo khi khai báo. Bây giờ ta đã đủ kiến thức nền tảng để bắt đầu đi sâu vào quá trình tạo đối tượng. 9.2. KHỞI TẠO ĐỐI TƯỢNG Nhớ lại rằng có ba bước khi muốn tạo mới một đối tượng: khai báo một biến tham chiếu, tạo một đối tượng, chiếu tham chiếu tới đối tượng đó. Ta đã hiểu rõ về hai bước 1 và 3. Mục này sẽ trình bày kĩ về phần còn lại: tạo một đối tượng. Khi ta chạy lệnh new Cow(), máy ảo Java sẽ kích hoạt một hàm đặc biệt được gọi là hàm khởi tạo (constructor ). Nó không phải một phương thức thông thường, nó chỉ chạy khi ta khởi tạo một đối tượng, và cách duy nhất để kích hoạt một hàm khởi tạo cho một đối tượng là dùng từ khóa new kèm theo tên lớp để tạo chính đối tượng 145
  4. đó. (Thực ra còn một cách khác là gọi trực tiếp từ bên trong một hàm khởi tạo khác, nhưng ta sẽ nói về cách này sau). Trong các ví dụ trước, ta chưa hề viết hàm khởi tạo, vậy nó ở đâu ra để cho máy ảo gọi mỗi khi ta tạo đối tượng mới? Ta có thể viết hàm khởi tạo, và ta sẽ viết nhiều hàm khởi tạo. Nhưng nếu ta không viết thì trình biên dịch sẽ viết cho ta một hàm khởi tạo mặc định. Hàm khởi tạo mặc định của trình biên dịch dành cho lớp Cow có nội dung như thế này: Hàm khởi tạo trông giống với một phương thức, nhưng có các đặc điểm là: không có kiểu trả về (và sẽ không trả về giá trị gì), và có tên hàm trùng với tên lớp. Hàm khởi tạo mà trình biên dịch tự tạo có nội dung rỗng, hàm khởi tạo ta tự viết sẽ có nội dung ở trong phần thân hàm. Đặc điểm quan trọng của một hàm khởi tạo là nó chạy trước khi ta làm được bất cứ việc gì khác đối với đối tượng được tạo, chiếu một tham chiếu tới nó chẳng hạn. Nghĩa là, ta có cơ hội đưa đối tượng vào trạng thái sẵn sàng sử dụng trước khi nó bắt đầu được sử dụng. Nói cách khác, đối tượng có cơ hội tự khởi tạo trước khi bất cứ ai có thể điều khiển nó bằng một cái tham chiếu nào đó. Tại hàm khởi tạo của Cow trong ví dụ Hình 9.4: Hàm khởi tạo không lấy đối số.Hình 9.4, ta không làm điều gì nghiêm trọng mà chỉ in thông báo ra màn hình để thể hiện chuỗi sự kiện đã xảy ra. Hình 9.4: Hàm kh ởi t ạo không l ấy đố i s ố. Nhiều người dùng hàm khởi tạo để khởi tạo trạng thái của đối tượng, nghĩa là gán các giá trị ban đầu cho các biến thực thể của đối tượng, chẳng hạn: public Cow() { 146
  5. weight = 10.0; } Đó là lựa chọn tốt nếu như người viết lớp Cow biết được đối tượng Cow nên có cân nặng bao nhiêu. Nhưng nếu những lập trình viên khác – người viết những đoạn mã dùng đến lớp Cow mới có thông tin này thì sao? Từ mục 5.4, ta đã biết về giải pháp dùng các phương thức truy nhập. Cụ thể ở đây ta có thể bổ sung phương thức setWeight() để cho phép gán giá trị cho weight từ bên ngoài lớp Cow. Nhưng điều đó có nghĩa người ta sẽ cần đến 2 lệnh để hoàn thành việc khởi tạo một đối tượng Cow: một lệnh new Cow() để tạo đối tượng, một lệnh gọi setWeight() để khởi tạo weight. Và ở giữa hai lệnh đó là khoảng thời gian mà đối tượng Cow tạm thời có weight chưa được khởi tạo 9. Hình 9.5: Ví d ụ v ề bi ến th ực th ể ch ưa được kh ởi t ạo cùng đối t ượng. Với cách làm như vậy, ta phải tin tưởng là người dùng lớp Cow sẽ khởi tạo weight và hy vọng họ sẽ không làm gì kì cục trước khi khởi tạo weight. Trông đợi vào việc người khác sẽ làm đúng cũng tương đương với việc hy vọng điều rủi ro sẽ không xảy ra. Tốt hơn cả là ta nên tự đảm bảo sao cho những tình huống không mong muốn sẽ không xảy ra. Nếu một đối tượng không nên được sử dụng trước khi nó được khởi tạo xong thì ta đừng cho ai động đến đối tượng đó trước khi ta hoàn thành việc khởi tạo. 9 Các biến thực thể có sẵn giá trị mặc định, weight có sẵn giá trị 0.0, 147
  6. Hình 9.6: Hàm kh ởi t ạo có tham s ố. Cách tốt nhất để hoàn thành việc khởi tạo đối tượng trước khi ai đó có được một tham chiếu tới đối tượng là đặt tất cả những đoạn mã khởi tạo vào bên trong hàm khởi tạo. Vấn đề còn lại chỉ là viết một hàm khởi tạo nhận đối số rồi dùng đối số để truyền vào hàm khởi tạo các thông số cần thiết cho việc khởi tạo đối tượng. Kết quả là sau đúng một lời gọi hàm khởi tạo kèm đối số, đối tượng được khởi tạo xong và sẵn sàng cho sử dụng. Xem minh họa tại Hình 9.6. Tuy nhiên, không phải lúc nào người dùng Cow cũng biết hoặc quan tâm đến trọng lượng cần khởi tạo cho đối tượng Cow mới. Ta nên cho họ lựa chọn tạo mới Cow mà không cần chỉ rõ giá trị khởi tạo cho weight. Cách giải quyết là bổ sung một hàm khởi tạo không nhận đối số và hàm này sẽ tự gán cho weight một giá trị mặc định nào đó. Hình 9.7: Hai hàm kh ởi t ạo ch ồng. Nói cách khác là ta có các hàm khởi tạo chồng nhau để phục vụ các lựa chọn khác nhau cho việc tạo mới đối tượng. Và cũng như các phương thức chồng khác, các hàm khởi tạo chồng nhau phải có danh sách tham số khác nhau. 148
  7. Như với khai báo lớp Cow trong ví dụ Hình 9.7, ta viết hai hàm khởi tạo cho lớp Cow, và người dùng sẽ có hai lựa chọn để tạo một đối tượng Cow mới: Cow c1 = new Cow(12.1); hoặc Cow c1 = new Cow(); Quay lại vấn đề về hàm khởi tạo không nhận đối số mà trình biên dịch cung cấp cho ta. Không phải lúc nào ta cũng có sẵn một hàm khởi tạo như vậy. Trình biên dịch chỉ cung cấp cho ta một hàm khởi tạo mặc định nếu ta không viết bất cứ một hàm khởi tạo nào cho lớp đó . Khi ta đã viết dù chỉ một hàm khởi tạo cho lớp đó, thì ta phải tự viết cả hàm khởi tạo không nhận đối số nếu cần đến nó. Những điểm quan trọng: • Biến thực thể sống ở bên trong đối tượng chủ của nó. • Các đối tượng sống trong vùng bộ nhớ heap. • Hàm khởi tạo là đoạn mã sẽ chạy khi ta gọi new đối với một lớp đối tượng • Hàm khởi tạo mặc định là hàm khởi tạo không lấy đối số. • Nếu ta không viết một hàm khởi tạo nào cho một lớp thì trình biên dịch sẽ cung cấp một hàm khởi tạo mặc định cho lớp đó. Ngược lại, ta sẽ phải tự viết hàm khởi tạo mặc định. • Nếu có thể, nên cung cấp hàm khởi tạo mặc định để tạo điều kiện thuận lợi cho các lập trình viên sử dụng đối tượng. Hàm khởi tạo mặc định khởi tạo các giá trị mặc định cho các biến thực thể. • Ta có thể có các hàm khởi tạo khác nhau cho một lớp. Đó là các hàm khởi tạo chồng. • Các hàm khởi tạo chồng nhau phải có danh sách đối số khác nhau. • Các biến thực thể luôn có sẵn giá trị mặc định, kể cả khi ta không tự khởi tạo chúng. Các giá trị mặc định là 0/0.0/false cho các kiểu cơ bản và null cho kiểu tham chiếu. 9.3. HÀM KHỞI TẠO VÀ VẤN ĐỀ THỪA KẾ Nhớ lại Mục 8.6 khi ta nói về cấu trúc bên trong của lớp con có chứa phần được thừa kế từ lớp cha, lớp Cow bọc ra ngoài cái lõi là phần Object mà nó được thừa kế. Nói cách khác, mỗi đối tượng lớp con không chỉ chứa các biến thực thể của chính nó mà còn chứa mọi thứ được hưởng từ lớp cha của nó . Mục này nói về việc khởi tạo phần được thừa kế đó 149
  8. 9.3.1. Gọi hàm khởi tạo của lớp cha Khi một đối tượng được tạo, nó được cấp phát bộ nhớ cho tất cả các biến thực thể của chính nó cũng như những thứ nó được thừa kế từ lớp cha, lớp ông, lớp cụ cho đến lớp Object trên đỉnh cây thừa kế. Tất cả các hàm khởi tạo trên trục thừa kế của một đối tượng đều phải được thực thi khi ta tạo mới đối tượng đó. Mỗi lớp tổ tiên của một lớp con, kể cả các lớp trừu tượng, đều có hàm khởi tạo. Tất cả các hàm khởi tạo đó được kích hoạt lần lượt mỗi khi một đối tượng của lớp con được tạo. Lấy ví dụ Hippo trong cây thừa kế Animal. Một đối tượng Hippo mới chứa trong nó phần Animal, phần Animal đó lại chứa trong nó phần Object. Nếu ta muốn tạo một đối tượng Hippo, ta cũng phải khởi tạo phần Animal của đối tượng Hippo đó để nó có thể sử dụng được những gì được thừa kế từ Animal. Tương tự, để tạo phần Animal đó, ta cũng phải tạo phần Object chứa trong đó. Khi một hàm khởi tạo chạy, nó lập tức gọi hàm khởi tạo của lớp cha. Khi hàm khởi tạo của lớp cha chạy, nó lập tức gọi hàm khởi tạo của lớp ông, cứ như thế cho đến khi gặp hàm khởi tạo của Object. Quy trình đó được gọi là dây chuyền hàm khởi tạo (Constructor Chaining ). 150
  9. c c n c n() e.o.nn(n n n); c c o een n c o() e.o.nn(n o); c c eo c c o n (n ) e.o.nn(n ); o = new o(); % java TestHippo Starting Making an Animal Making a Hippo Hình 9.8: Dây chuy ền hàm kh ởi t ạo. Ta minh họa dây chuyền hàm khởi tạo bằng ví dụ trong Hình 9.8. Trong ví dụ đó, mã chương trình TestHippo gọi lệnh new Hippo() để tạo đối tượng Hippo mới, lệnh này khởi động một dây chuyền hàm khởi tạo. Đầu tiên là Hippo() được kích hoạt, Hippo() gọi hàm khởi tạo của lớp cha – Animal(), đến lượt nó, Animal gọi hàm khởi tạo của lớp cha – Object(). Sau khi Object() chạy xong, hoàn thành khởi tạo phần Object trong đối tượng Hippo, nó kết thúc và trả quyền điều khiển về cho nơi gọi nó – hàm khởi tạo Animal(). Hàm khởi tạo Animal() khởi tạo xong phần Animal của đối tượng Hippo rồi kết thúc, trả quyền điều khiển về cho nơi gọi nó – hàm khởi tạo Hippo(). Hippo() thực hiện công việc của mình rồi kết thúc. Đối tượng Hippo mới đã được khởi tạo xong. Lưu ý rằng một hàm khởi tạo gọi hàm khởi tạo của lớp cha trước khi thực hiện bất kì lệnh nào trong thân hàm . Nghĩa là, Hippo() gọi Animal() trước khi thực hiện lệnh in ra màn hình. Vậy nên tại kết quả của chương trình TestHippo, ta thấy phần hiển thị của Animal() được in ra màn hình trước phần hiển thị của Hippo(). Ta vẫn nói rằng hàm khởi tạo này gọi hàm khởi tạo kia, nhưng trong Hình 9.8 hoàn toàn không có lệnh gọi Animal() từ trong mã của Hippo(), không có lệnh gọi Object() từ trong mã của Animal(). Một lần nữa, trình biên dịch đã làm công việc này thay cho lập trình viên, nó tự động điền lệnh super() vào ngay trước dòng đầu tiên của thân hàm khởi tạo . Việc này xảy ra đối với mỗi hàm khởi tạo mà tại đó lập trình viên không tự viết lời gọi đến hàm khởi tạo lớp cha. Còn đối với những hàm khởi tạo mà lập trình viên tự gọi super, lời gọi đó cũng phải lệnh đầu tiên trong thân hàm. Tại sao lời gọi super() phải là lệnh đầu tiên tại mỗi hàm khởi tạo? Đối tượng thuộc lớp con có thể phụ thuộc vào những gì nó được thừa kế từ lớp cha, do đó 151
  10. những gì được thừa kế nên được khởi tạo trước. Các phần thừa kế từ lớp cha phải được xây dựng hoàn chỉnh trước khi có thể xây dựng những phần của lớp con. Lưu ý rằng cách duy nhất để gọi hàm khởi tạo lớp cha từ trong hàm khởi tạo lớp con là lệnh super() chứ không gọi đích danh tên hàm như Animal() hay Object(). Lệnh gọi hàm khởi tạo lớp cha mà trình biên dịch sử dụng bao giờ cũng là super() không có đối số. Nhưng nếu ta tự gọi thì có thể dùng super() với đối số để gọi một hàm khởi tạo cụ thể trong các hàm khởi tạo ch ồng nhau của lớp cha. 9.3.2. Truyền đối số cho hàm khởi tạo lớp cha Ta hình dung tình huống sau: con vật nào cũng có một cái tên, nên đối tượng Animal có biến thực thể name. Lớp Animal có một phương thức getName(), nó trả về giá trị của biến thực thể name. Biến thực thể đó được đánh dấu private, nhưng lớp con Hippo thừa kế phương thức getName(). Vấn đề ở đây là Hippo có phương thức getName() qua thừa kế, nhưng lại không có biến thực thể name. Hippo phải nhờ phần Animal của nó giữ biến name và trả về giá trị của name khi ai đó gọi getName() từ một đối tượng Hippo. Vậy khi một đối tượng Hippo được tạo, nó làm cách nào để gửi cho phần Animal giá trị cần khởi tạo cho name? Câu trả lời là: dùng giá trị đó làm đối số khi gọi hàm khởi tạo của Animal. Ta thấy thân hàm Hippo(String name) trong ví dụ Hình 9.9 không làm gì ngoài việc gọi phương thức khởi tạo của lớp cha với danh sách tham số giống hệt. Có thể có người đọc thắc mắc vì sao phải viết hàm khởi tạo lớp con với nội dung chỉ như vậy. Trong khi nếu lớp con thừa kế lớp cha thì lớp con không cần cài lại cũng nghiễm nhiên được sử dụng phiên bản được thừa kế của lớp cha với danh sách tham số giống hệt, việc viết phương thức cài đè tại lớp con với nội dung chỉ gồm lời gọi tới phiên bản được thừa kế tại lớp cha là không cần thiết. Thực ra, tuy cùng là các phương thức khởi tạo và có cùng danh sách tham số, nhưng phương thức Hippo(String name) và Animal(String name) khác tên. Hippo(String name) không cài đè Animal(String name). Tóm lại, lớp con không thừa kế phương thức khởi tạo của lớp cha . 152
  11. con vật nào cũng có một cái tên, kể cả các lớp con () { return name; } public Animal(String n) { name = n; } } hàm tạo Animal lấy tham số n và gán nó cho biến thực thể name public class Hippo extends Animal { public Hippo(String name) { super(name); } hàm tạo Hippo lấy tham số name và truyền nó cho hàm tạo của Animal } public class TestHippo { public static void main (String[] args) { Hippo h = new Hippo("Hippy"); System.out.println(h.getName()); } } % java TestHippo gọi phương thức Hippo Hippy thừa kế từ Animal Hình 9.9: Truy ền đối s ố cho hàm kh ởi t ạo lớp cha. 9.4. HÀM KHỞI TẠO CHỒNG NHAU Xét trường hợp ta có các hàm khởi tạo chồng với hoạt động khởi tạo giống nhau và chỉ khác nhau ở phần xử lý các kiểu đối số. Ta sẽ không muốn chép đi chép lại phần mã khởi tạo mà các hàm khởi tạo đều có (vì khó bảo trì chẳng hạn), nên ta sẽ muốn đặt toàn bộ phần mã đó vào chỉ một trong các hàm khởi tạo. Và ta muốn rằng hàm khởi tạo nào cũng đều gọi đến hàm khởi tạo kia để nó hoàn thành công việc khởi tạo. Để làm việc đó, ta dùng this() để gọi một hàm khởi tạo từ bên trong một hàm khởi tạo khác của cùng một lớp. Ví dụ: Lời gọi this() chỉ có thể được dùng trong hàm khởi tạo và phải là lệnh đầu tiên trong thân hàm. Nhớ lại mục 9.3, yêu cầu cho lời gọi super() cũng y hệt như vậy. Vì lí do đó, mỗi hàm khởi tạo chỉ được chọn một trong hai việc: gọi super() hoặc gọi this(), chứ không thể gọi cả hai. 153
  12. 9.5. TẠO BẢN SAO CỦA ĐỐI TƯỢNG Ta đã biết rằng không thể dùng phép gán để sao chép nội dung đối tượng, nó chỉ sao chép nội dung biến tham chiếu. Vậy làm thế nào để tạo đối tượng mới là bản sao của một đối tượng có sẵn? Có hai kiểu sao chép nội dung đối tượng. Sao chép nông (shallow copy ) là sao chép từng bit của các biến thực thể. Đối tượng mới sẽ có các biến thực thể có giá trị bằng các biến tương ứng của đối tượng cũ, kể cả các biến thực thể là tham chiếu. Do đó, nếu đối tượng cũ có một tham chiếu tới một đối tượng khác thì đối tượng mới cũng có tham chiếu tới chính đối tượng đó. Đôi khi, đây là kết quả đúng. Chẳng hạn như khi ta tạo bản sao của một đối tượng Account (tài khoản ngân hàng), cả hai tài khoản mới và cũ đều có chung một chủ sở hữu tài khoản, nghĩa là biến thực thể owner của hai đối tượng này đều chiếu tới cùng một đối tượng Customer (khách hàng) – người sở hữu tài khoản. Trong những trường hợp khác, ta muốn tạo bản sao của cả các đối tượng thành phần. Sao chép sâu (deep copy ) tạo bản sao hoàn chỉnh của một đối tượng có sẵn. Chẳng hạn, khi thực hiện sao chép sâu đối với một đối tượng là danh sách chứa các đối tượng khác, kết quả là các đối tượng thành phần cũng được tạo bản sao hoàn chỉnh. Ta được đối tượng danh sách mới chứa các đối tượng thành phần mới, tách biệt hoàn toàn với danh sách cũ (thay vì tình trạng các đối tượng thành phần đồng thời nằm trong cả hai danh sách cũ và mới). Lấy ví dụ khác: một căn hộ có nhiều phòng, mỗi phòng có các đồ đạc nội thất. Khi tạo bản sao của một căn hộ, nhằm tạo ra một căn hộ khác giống hệt căn hộ ban đầu, ta phải sao chép cả các phòng cũng như tất cả đồ đạc nội thất chứa trong đó. Không phải tình trạng hai căn hộ nhưng lại có chung các phòng và chung nội thất. Để có được kiểu sao chép hoàn toàn này, lập trình viên phải tự cài đặt quy trình sao chép. Java có hỗ trợ sao chép nông và sao chép sâu với phương thức clone và interface Cloneable. Tuy nhiên, nhiều chuyên gia, trong đó có Joshua Bloch – tác giả cuốn Effective Java [7], khuyên không nên sử dụng hỗ trợ này do nó có lỗi thiết kế và hiệu lực thực thi không ổn định, thay vào đó, nên dùng hàm khởi tạo sao chép. Hàm khởi tạo sao chép (copy constructor ) là hàm khởi tạo với tham số duy nhất là một tham chiếu đối tượng và hàm này sẽ khởi tạo đối tượng mới sao cho có nội dung giống hệt đối tượng đã cho. Chẳng hạn: 154
  13. Trong đó, nội dung hàm khởi tạo Cow(Cow c) làm nhiệm vụ sao chép nội dung của đối tượng c vào đối tượng vừa tạo, ở đây chỉ là các phép gán giá trị cho các biến thực thể. Tuy nhiên, khi có quan hệ thừa kế, tình huống không phải lúc nào cũng đơn giản như ví dụ đó. Xét quan hệ thừa kế giữa Animal và Cat. Ta viết hàm khởi tạo sao chép cho cả hai lớp. Giả sử ta cần một tình huống đa hình chẳng hạn như một đoạn mã áp dụng cho các loại Animal nói chung, trong đó có Cat. Trong phương thức đó ta cần nhân bản các đối tượng mà không biết chúng thuộc lớp nào trong cây thừa kế Animal, chẳng hạn: Liệu trong tình huống này ta có thể dùng hàm khởi tạo sao chép của Animal để nhân bản các đối tượng thuộc các lớp con? Ta hãy thử xem. 155
  14. Hình 9.10: Hàm kh ởi t ạo sao chép và quan h ệ th ừa k ế. Ví dụ trong Hình 9.10 cho thấy câu trả lời là 'không thể'. Khi ta dùng lệnh new Animal(tom) gọi hàm khởi tạo sao chép nhằm tạo một bản sao của mèo Tom, thực ra ta đang tạo đối tượng Animal và dùng hàm khởi tạo của lớp Animal (nhớ lại rằng giữa các hàm khởi tạo không có quan hệ thừa kế do đó cũng không có đa hình). Cho nên kết quả của thao tác sao chép thứ hai không phải là một đối tượng mèo tên Tom mà là một đối tượng Animal tên Tom (phiên bản makeNoise() chạy cho đối tượng này in ra "Huh?" – đây là phiên bản của Animal chứ không phải phiên bản của Cat). Như vậy sử dụng hàm khởi tạo sao chép như trong tình huống này không cho ta kết quả mong muốn. Vậy phải làm cách nào để có hiệu ứng đa hình khi nhân bản đối tượng? Câu trả lời là sử dụng phương thức có tính đa hình. Ta bổ sung vào cài đặt của Animal và Cat ở trên một phương thức thực thể clone() với nhiệm vụ tạo và trả về một đối tượng mới là bản sao của đối tượng chủ. Thực ra clone() không làm gì ngoài việc gọi và trả về kết quả của hàm khởi tạo sao chép đối với chính đối tượng chủ. Vẫn là các hàm khởi tạo sao chép thực hiện việc nhân bản đối tượng, nhưng lần này chúng được bọc trong các phiên bản của clone(), mà clone() thì là phương thức có tính đa hình nên khi được gọi với đối tượng loại nào thì phiên bản tương ứng sẽ chạy. Điều đó đồng nghĩa với việc hàm khởi tạo sao chép tương ứng với loại đối tượng đó sẽ được gọi. Xem kết quả thử nghiệm trong Hình 9.11. 156
  15. Hình 9.11: Gi ải pháp nhân b ản h ỗ tr ợ đa hình. 157
  16. Khi đó, phương thức cloneAll() cần viết lại như sau: Giải pháp nhân bản đối tượng nói trên cũng chính là một ví dụ đơn giản sử dụng mẫu thiết kế Prototype (nguyên mẫu). Đôi khi việc tạo mới và xây dựng lại một đối tượng từ đầu là phức tạp hoặc tốn kém tài nguyên. Chẳng hạn, một công ty cần tổng hợp dữ liệu từ cơ sở dữ liệu vào một đối tượng để đưa vào mô đun phân tích dữ liệu. Cũng dữ liệu đó cần được phân tích độc lập tại hai mô đun phân tích khác nhau. Việc tổng hợp lại dữ liệu để tạo một đối tượng thứ hai có nội dung giống hệt đối tượng thứ nhất tốn kém hơn là nhân bản đối tượng thứ nhất thành đối tượng thứ hai, thứ ba Khi đó, nhân bản một đối tượng là giải pháp nên sử dụng. Mẫu thiết kế Prototype cho phép tạo các đối tượng đã được tinh chỉnh mà không cần biết chúng thuộc lớp nào hay chi tiết về việc cần phải tạo chúng như thế nào. Việc này được thực hiện bằng cách sử dụng một đối tượng mẫu và tạo các đối tượng mới từ việc sao chép nội dung của mẫu sang. Cài đặt mẫu Prototype cơ bản bao gồm ba loại lớp (xem Hình 9.12). Loại Client tạo đối tượng mới bằng cách yêu cầu đối tượng mẫu tự nhân bản. Loại Prototype định nghĩa một giao diện cho những lớp đối tượng có thể tự nhân bản. Các lớp ConcretePrototype (các bản mẫu cụ thể) cài đặt phương thức thực thể clone trả về bản sao của chính mình. Trong nhiều trường hợp, sao chép nông là đủ dùng cho phương thức clone(). Nhưng khi nhân bản các đối tượng có cấu trúc phức tạp, chẳng hạn như một đối tượng Maze (mê cung) hợp thành từ các bức tường, lối đi, chướng ngại vật thì sao chép sâu là cần thiết. 158
  17. Client Prototype clone() ConcretePrototype1 ConcretePrototype2 Hình 9.12: M ẫu thi ết k ế Prototype. 9.6. CUỘC ĐỜI CỦA ĐỐI TƯỢNG Cuộc đời của một đối tượng hoàn toàn phụ thuộc vào sự tồn tại của các tham chiếu chiếu tới nó. Nếu vẫn còn một tham chiếu, thì đối tượng vẫn còn sống trong heap. Nếu không còn một tham chiếu nào chiếu tới nó, đối tượng sẽ chết, hoặc ít ra cũng coi như chết. Tại sao khi không còn một biến tham chiếu nào chiếu tới thì đối tượng sẽ chết? Câu trả lời rất đơn giản: Không có tham chiếu, ta không thể với tới đối tượng đó, không thể lấy dữ liệu của nó, không thể yêu cầu nó làm gì. Nói cách khác, nó trở thành một khối bit vô dụng, sự tồn tại của nó không còn có ý nghĩa gì nữa. Garbage collector sẽ phát hiện ra những đối tượng ở tình trạng này và thu dọn vùng bộ nhớ của chúng để tái sử dụng. Như vậy, để có thể xác định độ dài cuộc đời hữu dụng của đối tượng, ta cần biết được độ dài cuộc đời của các biến tham chiếu. Cái này còn tùy biến đó là biến địa phương hay biến thực thể. Một biến địa phương chỉ tồn tại bên trong phương thức nơi nó được khai báo, và chỉ sống từ khi phương thức đó được chạy cho đến khi phương thức đó kết thúc. Một biến thực thể thuộc về một đối tượng và sống cùng với đối tượng đó. Nếu đối tượng vẫn còn sống thì biến thực thể của nó cũng vậy. Có ba cách hủy tham chiếu tới một đối tượng: 159
  18. 1. Tham chiếu vĩnh viễn ra ngoài phạm vi tồn tại. 2. Tham chiếu được chiếu tới một đối tượng khác. 3. Tham chiếu được gán giá trị null. 160
  19. Bài tập 1. Các phát biểu sau đây đúng hay sai? a) khi một đối tượng thuộc lớp con được khởi tạo, hàm khởi tạo của lớp cha phải được gọi một cách tường minh. b) nếu một lớp có khai báo các hàm khởi tạo, trình biên dịch sẽ không tạo hàm khởi tạo mặc định cho lớp đó. c) lớp con được thừa kế hàm khởi tạo của lớp cha. Khi khởi tạo đối tượng lớp con, hàm khởi tạo của lớp cha luôn luôn được gọi tự động để khởi tạo phần được thừa kế. 2. Từ khóa new dùng để làm gì? Giải thích chuyện xảy ra khi dùng từ khóa này trong một ứng dụng. 3. Hàm khởi tạo mặc định là gì? Các biến thực thể của một đối tượng được khởi tạo như thế nào nếu lớp đó không có hàm khởi tạo nào do lập trình viên viết. 4. Tìm lỗi biên dịch nếu có của các hàm khởi tạo trong cài đặt sau đây của lớp SonOfBoo. 161
  20. 5. Cho cài đặt lớp Foo ở cột bên trái, nếu bổ sung vào vị trí A một trong các dòng mã ở cột bên phải, dòng nào sẽ làm cho một đối tượng bị mất dấu và sẽ bị garbage collector thu hồi bất cứ lúc nào? 162
  21. Ch−¬ng 10. Thµnh viªn líp vµ thµnh viªn thùc thÓ Ta đã biết đối với các biến thực thể, mỗi đối tượng đều có một bản riêng của mỗi biến. Chẳng hạn, nếu khai báo lớp Cow có biến thực thể name, thì mỗi đối tượng Cow đều có một biến name của riêng nó nằm trong vùng bộ nhớ được cấp phát cho đối tượng đó. Hầu hết những phương thức ta đã thấy trong các ví dụ đều có hoạt động chịu ảnh hưởng của giá trị các biến thực thể. Nói cách khác, chúng có hành vi tùy thuộc từng đối tượng cụ thể. Khi gọi các phương thức, ta cũng đều phải gọi cho các đối tượng cụ thể. Nói tóm lại, đó là các phương thức thuộc về đối tượng. Nếu ta muốn có dữ liệu nào đó của lớp được chia sẻ giữa tất cả các đối tượng thuộc một lớp, các phương thức của lớp hoạt động độc lập với các đối tượng của lớp đó, thì giải pháp là các biến lớp và phương thức lớp. 10.1. BIẾN CỦA LỚP Đôi khi, ta muốn một lớp có những biến dùng chung cho tất cả các đối tượng thuộc lớp đó. Ta gọi các biến dùng chung này là biến của lớp (class variable ), hay gọi tắt là biến lớp . Chúng không gắn với bất cứ một đối tượng nào mà chỉ gắn với lớp đối tượng. Chúng được dùng chung cho tất cả các đối tượng trong lớp đó. Để phân biệt giữa biến thực thể và biến lớp khi khai báo trong định nghĩa lớp, ta dùng từ khóa static cho các biến lớp. Vì từ khóa đó nên biến lớp thường được gọi là biến static . Lấy ví dụ trong Hình 10.1, bên cạnh biến thực thể name, lớp Cow còn có một biến lớp numOfCows với mục đích ghi lại số lượng các đối tượng Cow đã được tạo. Mỗi đối tượng Cow có một biến name của riêng nó, nhưng numOfCows thì chỉ có đúng một bản dùng chung cho tất cả các đối tượng Cow. numOfCows được khởi tạo bằng 0, mỗi lần một đối tượng Cow được tạo, biến này được tăng thêm 1 (tại hàm khởi tạo dành cho đối tượng đó) để ghi nhận rằng vừa có thêm một thực thể mới của lớp Cow. 164
  22. biến thực thể, không có từ khóa static biến lớp, được khai báo với từ khóa static ) { mỗi lần hàm tạo chạy (một đối name = theName; tượng mới được tạo), bản duy nhât của numOfCows được tăng thêm 1 để ghi nhận đối tượng mới numOfCows++; System.out.println("Cow #"+numOfCows+" created."); } } public class CowTestDrive { public static void main(String[] args) { Cow c1 = new Cow(); Cow c2 = new Cow(); % java CowTestDrive } Cow #1 created. } Cow #2 created. Hình 10.1: Bi ến l ớp - bi ến static. Từ bên ngoài lớp, ta có thể dùng tên lớp để truy nhập biến static. Chẳng hạn, dùng Cow.numOfCows để truy nhập numOfCows: 10.2. PHƯƠNG THỨC CỦA LỚP Lại xét ví dụ trong Hình 10.1, giả sử ta muốn numOfCows là biến private để không cho phép ai đó sửa từ bên ngoài lớp Cow. Nhưng ta vẫn muốn cho phép đọc giá trị của biến này từ bên ngoài (các chương trình dùng đến Cow có thể muốn biết có bao nhiêu đối tượng Cow đã được tạo), nên ta sẽ bổ sung một phương thức, chẳng hạn getCount(), để trả về giá trị của biến đó. public int getCount() { return numOfCows; } Như các phương thức mà ta đã quen dùng, để gọi getCount(), người ta sẽ cần đến một tham chiếu kiểu Cow và kích hoạt phương thức đó cho một đối tượng Cow. Cần đến một con bò để biết được có tất cả bao nhiêu con bò? Nghe có vẻ không được tự nhiên lắm. Vả lại, gọi getCount() từ bất cứ đối tượng Cow nào thực ra cũng như nhau cả, vì getCount() không dùng đến một đặc điểm hay dữ liệu đặc thù nào của 165
  23. mỗi đối tượng Cow (nó không truy nhập biến thực thể nào). Hơn nữa, khi còn chưa có một đối tượng Cow nào được tạo thì không thể gọi được getCount()! Phương thức getCount() không nên bị phụ thuộc vào các đối tượng Cow cụ thể như vậy. Để giải quyết vấn đề này, ta có thể cho getCount() làm một phương thức của lớp (class method ), thường gọi tắt là phương thức lớp – hay phương thức static - để nó có thể tồn tại độc lập với các đối tượng và có thể được gọi thẳng từ lớp mà không cần đến một tham chiếu đối tượng nào. Ta dùng từ khóa static khi khai báo phương thức lớp: public static int getCount() { return numOfCows; } Các phương thức thông thường mà ta đã biết, ngoại trừ main(), được gọi là các phương thức của thực thể (instance method ) – hay các phương thức không static . Các phương thức này phụ thuộc vào từng đối tượng và phải được gọi từ đối tượng. Hình 10.2 là bản sửa đổi của ví dụ trong Hình 10.1. Trong đó bổ sung phương thức static getCount() và trình diễn việc gọi phương thức đó từ tên lớp cũng như từ tham chiếu đối tượng. Lần này, ta có thể truy vấn số lượng Cow ngay từ khi chưa có đối tượng Cow nào được tạo. Lưu ý rằng có thể gọi getCount() từ tên lớp cũng như từ một tham chiếu kiểu Cow. 166
  24. public clss Cow { prite tring nme; prite sttic int numOfCows ; public Cow(tring teme) { nme teme; phương thức lớp numOfCows; được khai báo bằng từ khóa static, } không động đến biến thực thể public static int getCount() { return numOfCows; } trước khi có đối tượng Cow đầu tiên public tring getme() { return nme; % java CountCows } 0 } 1 2 public clss CountCows { public sttic oi min(tring rgs) { stemoutprintln(CowgetCount()); Cow c new Cow(); stemoutprintln(CowgetCount()); có thể gọi từ tên lớp Cow c new Cow(); stemoutprintln(cgetCount()); hoặc gọi từ tham } chiếu đối tượng } Hình 10.2. Ph ươ ng thức l ớp. Đặc điểm độc lập đối với các đối tượng của phương thức static chính là lí do ta đã luôn luôn phải khai báo phương thức main() với từ khóa static. main() được kích hoạt để khởi động chương trình - khi chưa có bất cứ đối tượng nào được tạo – nên nó phải được phép chạy mà không gắn với bất cứ đối tượng nào. 10.3. GIỚI HẠN CỦA PHƯƠNG THỨC LỚP Đặc điểm về tính độc lập đó vừa là ưu điểm vừa là giới hạn cho hoạt động của các phương thức lớp. Không được gắn với một đối tượng nào, nên các phương thức static của một lớp chạy mà không biết một chút gì về bất cứ đối tượng cụ thể nào của lớp đó. Như đã thấy trong ví dụ Hình 10.2, getCount() chạy ngay cả khi không tồn tại bất cứ đối tượng Cow nào. Kể cả khi gọi getCount() từ tham chiếu c2 thì getCount() cũng vẫn không biết gì về đối tượng Cow mà c2 đang chiếu tới. Vì khi đó, trình biên dịch chỉ dùng kiểu khai báo của c2 để xác định nên chạy getCount() của lớp nào, nó không quan tâm c2 đang chiếu tới đối tượng nào. Cow.getCount() hay c2.getCount() chỉ là hai cách gọi phương thức, và với cách nào thì getCount() cũng vẫn là một phương thức static. 167
  25. Hình 10.3: Ph ươ ng th ức l ớp không th ể truy nh ập bi ến th ực th ể. Nếu một biến thực thể được dùng đến trong một phương thức lớp, trình biên dịch sẽ không hiểu ta đang nói đến biến thực thể của đối tượng nào, bất kể trong heap đang có 10 hay chỉ có duy nhất một đối tượng thuộc lớp đó. Ví dụ, chương trình trong Hình 10.3 bị lỗi biên dịch vì phương thức main() cố truy nhập biến name. Do main() là phương thức static, trình biên dịch không hiểu name mà main() đang nói đến là biến thực thể name của đối tượng nào. Lời thông báo lỗi có nội dung: biến thực thể name không thể được gọi đến từ một ngữ cảnh static. Ta dễ thấy rằng tham chiếu this cũng không thể sử dụng trong một phương thức lớp, bởi nó không hiểu đối tượng 'này' là đối tượng nào. Hiệu ứng dây chuyền của việc các phương thức static không thể dùng biến thực thể là chúng cũng không thể gọi đến các phương thức thực thể (phương thức thường) của lớp đó. Các phương thức thực thể được quyền dùng biến thực thể, gọi đến các phương thức thực thể đồng nghĩa với việc gián tiếp sử dụng biến thực thể. Hình 10.4: Ph ươ ng th ức l ớp không th ể g ọi ph ươ ng th ức th ực th ể. Ví dụ trong Hình 10.4 cũng gặp lỗi tương tự lỗi biên dịch trong Hình 10.3. 168
  26. Nhìn qua thì có vẻ như nội dung từ đầu chương đến đây là một loạt các quy tắc của ngôn ngữ Java mà lập trình viên cần nhớ. Nhưng thực ra thì tất cả chỉ là hệ quả của bản chất khái niệm: Thành viên lớp thuộc về lớp và độc lập với tất cả các thực thể của lớp đó. Trong khi đó, thành viên thực thể gắn bó chặt chẽ với từng thực thể cụ thể . Tất cả các 'quy tắc' đều là hệ quả của đặc điểm bản chất đó. Một phương thức thực thể có thể truy nhập các biến thực thể chẳng qua vì chúng thuộc về cùng một thực thể - đối tượng chủ mà tham chiếu this chiếu tới. Ví dụ, lệnh return name; trong phương thức getName() tại Hình 10.2 thực chất là return this.name; . getName() là phương thức thực thể nên nó có tham chiếu this để sử dụng cho việc này. Một phương thức lớp, trái lại, không thể truy nhập thẳng đến biến thực thể hay phương thức thực thể đơn giản là vì phương thức lớp không hề biết đến đối tượng chủ của các thành viên thực thể kia. Ví dụ, khi biến thực thể name được truy nhập tại phương thức main tại Hình 10.3, thực chất Java hiểu đó là this. name. Nhưng main là phương thức lớp, nó không gắn với đối tượng nào nên không có tham chiếu this để có thể gọi this.name. Tất cả quy tắc đều được dẫn xuất từ bản chất của khái niệm. Do đó, thực ra ta không cần nhớ quy tắc một khi đã nắm vững được khái niệm. 10.4. KHỞI TẠO BIẾN LỚP Các biến static được khởi tạo khi lớp được nạp vào bộ nhớ. Một lớp được nạp khi máy ảo Java quyết định đến lúc cần nạp, chẳng hạn như khi ai đó định tạo thực thể đầu tiên của lớp đó, hoặc dùng biến static hoặc phương thức static của lớp đó. Có hai đảm bảo về việc khởi tạo các biến static: (1) các biến static trong một lớp được khởi tạo trước khi bất cứ đối tượng nào của lớp đó có thể được tạo; (2) các biến static trong một lớp được khởi tạo trước khi bất cứ phương thức static nào của lớp đó có thể chạy; Ta có hai cách để khởi tạo biến static. Thứ nhất, khởi tạo ngay tại dòng khai báo biến, ví dụ như trong Hình 10.1: private static int numOfCows = 0; Cách thứ hai: Java cung cấp một cú pháp đặc biệt là khối khởi tạo static (static initialization block) – một khối mã được bọc trong cặp ngoặc { } và có tiêu đề là từ khóa static. static { numOfCows = 0; } Một lớp có thể có vài khối khởi tạo static đặt ở bất cứ đâu trong định nghĩa lớp. Chúng được đảm bảo sẽ được kích hoạt theo đúng thứ tự xuất hiện trong mã. Và 169
  27. quan trọng bậc nhất là chúng được đảm bảo sẽ chạy trước khi bất gì biến thành viên nào được truy nhập hay phương thức static nào được chạy. 10.5. MẪU THIẾT KẾ SINGLETON Một ứng dụng của các thành viên lớp là mẫu thiết kế Singleton. Mẫu này giải quyết bài toán thiết kế đảm bảo rằng một lớp chỉ có tối đa một thực thể, chẳng hạn như trong một hệ thống mà chỉ nên có một đối tượng quản lý cửa sổ ứng dụng, một hệ thống file, hay chỉ một đối tượng quản lý hàng đợi máy in (printer spooler). Các lớp singleton thường được dùng cho việc quản lý tập trung tài nguyên và cung cấp một điểm truy nhập toàn cục duy nhất đến thực thể duy nhất của chúng. Mẫu Singleton bao gồm một lớp tự chịu trách nhiệm tạo thực thể. Phương thức khởi tạo được đặt chế độ private để ngăn cản việc tạo thực thể từ bên ngoài lớp. Một biến lớp private giữ tham chiếu tới thực thể duy nhất. Lớp cung cấp điểm truy nhập toàn cục tới thực thể này qua một phương thức lớp public trả về tham chiếu tới thực thể đó. Hình 10.5 mô tả chi tiết về mẫu Singleton. Để ý rằng do hàm khởi tạo không thể được truy cập từ bên ngoài nên phương thức lớp getInstance() là cổng duy nhất cho phép lấy tham chiếu tới đối tượng Singleton. Phương thức này đảm bảo rằng chỉ có duy nhất một thực thể Singleton được tạo. Từ bên ngoài lớp Singleton, mỗi khi muốn dùng đến thực thể Singleton này, ta chỉ cần thực hiện lời gọi có dạng như sau: Singleton.getInstance().doSomething(); Người đọc có thể tìm hiểu thêm về mẫu thiết kế này và các ứng dụng của nó tại các tài liệu sau: 1. Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software , Addison-Wesley, 1994. 2. SingletonPattern | Object Oriented Design , URL: 170
  28. Hình 10.5: M ẫu thi ết k ế Singleton. 10.6. THÀNH VIÊN BẤT BIẾN – final Trong ngôn ngữ Java, từ khóa final mang nghĩa "không thể thay đổi". Ta có thể dùng từ khóa này để quy định về tính chất không thể thay đổi cho biến, phương thức, và cả lớp: 1. Một biến final là biến không thể sửa giá trị . Nói cách khác, biến final là hằng. Ta có biến static final là hằng của lớp, biến thực thể final là hằng của đối tượng. Biến địa phương, tham số cũng có thể được quy định là final. Trong ví dụ sau đây, 'cow' có nghĩa là 'bò cái' nên IS_FEMALE (là giống cái) là hằng mang giá trị true chung cho tất cả các đối tượng kiểu Cow, từng con bò không đổi màu nên color là một hằng cho từng đối tượng Cow. 2. Một phương thức final là phương thức mà lớp con không thể cài đè . 171
  29. 3. Một lớp final là lớp không thể có lớp con . An toàn là lí do cho việc khai báo final. Ví dụ, nếu có ai đó viết lớp con của String và cài đè các phương thức, người ta có thể nhờ đa hình mà dùng các đối tượng thuộc lớp mới này cho các đoạn mã chương trình vốn được viết cho String. Đây là tình huống không được mong muốn, do đó String được đặt chế độ final để tránh xảy ra tình huống đó. Nếu ta cần dựa vào cài đặt cụ thể của các phương thức trong một lớp, hãy cho lớp đó ở dạng final. Nếu ta chỉ cần cố định cài đặt của một vài phương thức trong một lớp, ta đặt chế độ final cho các phương thức đó chứ không cần đặt cho cả lớp. Tất nhiên, nếu một lớp là lớp final thì các phương thức trong đó nghiễm nhiên không thể bị cài đè, ta không cần đặt chế độ final cho chúng nữa. Những điểm quan trọng: • Phương thức lớp hay còn gọi là phương thức static không được gắn với một đối tượng cụ thể nào và không phụ thuộc đối tượng nào, nó chỉ được gắn với lớp • Nên gọi phương thức static từ tên lớp. • Phương thức static có thể được gọi mà không cần có đối tượng nào của lớp đó đang ở trong heap. • Do không được gắn với một đối tượng nào, phương thức static không thể truy nhập biến thực thể hay các phương thức thực thể. • Biến lớp hay còn gọi là biến static là biến dùng chung cho tất cả các đối tượng của lớp. Chỉ có duy nhất một bản cho cả lớp, chứ không phải mỗi đối tượng có một bản. • Phương thức static có thể truy nhập biến static. • Biến final chỉ được gán trị một lần và không thể bị thay đổi. • Phương thức final không thể bị đè. • Lớp final không thể có lớp con. 172
  30. Bài tập 1. Điền từ thích hợp vào chỗ trống a) Biến ___ đại diện cho một thông tin mà tất cả các đối tượng thuộc một lớp đều dùng chung. b) Từ khóa ___ quy định một biến không thể sửa giá trị. 2. Các phát biểu sau đây đúng hay sai? a) Để sử dụng lớp Math, trước hết cần tạo một đối tượng Math. b) Có thể dùng từ khóa static cho hàm khởi tạo c) Các phương thức static không thể truy nhập các biến thực thể của đối tượng hiện hành d) Có thể dùng biến static để đếm số thực thể của một lớp. e) Các hàm khởi tạo được gọi trước khi các biến static được khởi tạo f) MAX_SIZE là một tên biến tốt cho một biến final static g) Một khối khởi tạo static chạy trước khi hàm khởi tạo của một lớp được chạy h) Nếu một lớp được khai báo với từ khóa final, tất cả các phương thức của nó cũng phải khai báo là final. i) Một phương thức final chỉ có thể bị đè nếu lớp đó có lớp con. j) Không có lớp bọc ngoài cho các giá trị boolean. k) Lớp bọc ngoài được dùng khi ta muốn đối xử với một giá trị kiểu cơ bản như là một đối tượng. 173
  31. Ch−¬ng 11. Ngo¹i lÖ Lỗi chương trình là chuyện thường xảy ra. Các tình huống bất thường cũng xảy ra. Không tìm thấy file. Server bị sự cố. Ngoại lệ (exception ) là thuật ngữ chỉ tình trạng sai hoặc bất thường xảy ra khi một chương trình đang chạy. Ta có thể gặp vô số các tình huống như vậy, chẳng hạn như khi chương trình thực hiện phép chia cho 0 (ngoại lệ tính toán số học), đọc phải một giá trị không nguyên trong khi đang chờ đọc một giá trị kiểu int (ngoại lệ định dạng số), hoặc truy cập tới một phần tử không nằm trong mảng (ngoại lệ chỉ số nằm ngoài mảng). Các lỗi và tình trạng bất thường có thể xảy ra là vô số. Một chương trình dù được thiết kế tốt đến đâu thì vẫn có khả năng xảy ra lỗi trong khi thực thi. Dù có là lập trình viên giỏi đến đâu thì ta vẫn không thể kiểm soát mọi thứ. Trong những phương thức có khả năng gặp sự cố, ta cần những đoạn mã để xử lý sự cố nếu như chúng xảy ra. Một chương trình được thiết kế tốt cần có những đoạn mã phòng chống lỗi và các tình trạng bất thường. Phần mã này nên được đưa vào chương trình ngay từ giai đoạn đầu của việc phát triển chương trình. Nhờ đó, nó có thể giúp nhận diện các trục trặc trong quá trình phát triển. Phương pháp truyền thống cho việc phòng chống lỗi là chèn vào giữa logic chương trình những đoạn lệnh phát hiện và xử lý lỗi; dùng giá trị trả về của hàm làm phương tiện báo lỗi cho nơi gọi hàm. Tuy nhiên, phương pháp này có những nhược điểm như: các đoạn mã phát hiện và xử lý lỗi nằm lẫn trong thuật toán chính làm chương trình rối hơn, khó hiểu hơn, dẫn tới khó kiểm soát hơn; đôi khi giá trị trả về phải dành cho việc thông báo kết quả tính toán của hàm nên khó có thể tìm một giá trị thích hợp để dành riêng cho việc báo lỗi. Trong ngôn ngữ Java, ngoại lệ (exception handling ) là cơ chế cho phép xử lý tốt các tình trạng này. Nó cho phép giải quyết các ngoại lệ có thể xảy ra sao cho chương trình có thể chạy tiếp hoặc kết thúc một cách nhẹ nhàng, giúp lập trình viên tạo được các chương trình bền bỉ và chịu lỗi tốt hơn. So với phương pháp phòng chống lỗi truyền thống, cơ chế ngoại lệ có làm chương trình chạy chậm đi một chút, nhưng đổi lại là cấu trúc chương trình trong sáng hơn, dễ viết và dễ hiểu hơn. Chương này mô tả cơ chế sử dụng ngoại lệ của Java. Ta sẽ bắt đầu bằng việc so sánh cách xử lý lỗi truyền thống trong chương trình với cơ chế xử lý ngoại lệ mặc định của Java. Tiếp theo là trình bày về cách ngoại lệ được ném và bắt (xử lý) trong một chương trình, các quy tắc áp dụng cho các loại ngoại lệ khác nhau. Cuối cùng là nội dung về cách thiết kế và cài đặt lớp con của Exception để phục vụ nhu cầu về các loại ngoại lệ tự thiết kế. 174
  32. 11.1. NGOẠI LỆ LÀ GÌ? 11.1.1. Tình huống sự cố Đầu tiên, chúng ta lấy một ví dụ về ngoại lệ của Java. Trong Hình 11.1 là một chương trình đơn giản trong đó yêu cầu người dùng nhập hai số nguyên rồi tính thương của chúng và in ra màn hình. []) { Scanner scanner = new Scanner(System.in); System.out.print( "Numerator: " ); int numerator = scanner.nextInt(); System.out.print( "Denominator: " ); int denominator = scanner.nextInt(); int result = numerator/denominator; System.out.printf("\nResult: %d / %d = %d\n", numerator, denominator, result ); } } Hình 11.1: Một ch ươ ng trình ch ưa x ử lý ngo ại l ệ. Chương trình này hoạt động đúng nhưng chưa hề có mã xử lý lỗi. Nếu khi chạy chương trình, ta nhập dữ liệu không phải số nguyên như yêu cầu, chương trình sẽ bị dừng đột ngột với lời báo lỗi được in ra trên cửa sổ lệnh, ví dụ như trong Hình 11.2. Đó là hậu quả của việc ngoại lệ chưa được xử lý. Hình 11.2: Lỗi run-time do ngo ại l ệ không được x ử lý. Ta lấy thêm một ví dụ khác trong Hình 11.3. Giả sử ta cần ghi một vài dòng văn bản vào một file. Ta dùng đến các lớp File và PrintWriter trong gói java.io của thư viện chuẩn Java, File quản lý file, PrintWriter cung cấp các tiện ích ghi dòng văn bản. Chương trình chỉ làm công việc rất đơn giản là (1) mở file, (2) chuẩn bị cho việc ghi file, (3) ghi vào file một dòng văn bản, và (4) đóng file. Nhưng khi biên dịch, ta gặp 175
  33. thông báo lỗi cho lệnh new PrintWriter với nội dung rằng ngoại lệ FileNotFoundException chưa được xử lý và nó phải được bắt hoặc được tuyên bố ném tiếp. import các lớp cần dùng import java.io.PrintWriter; từ thư viện của Java import java.io.File; public class FileWriter { public static void write(String fileName, String s) { File file = new File(fileName); PrintWriter out = new PrintWriter(file); out.println(s); mở file và chuẩn bị out.close(); chi việc ghi file } } % javac FileWriter.java FileWriter.java:7: unreported exception java.io.FileNotFoundException; must be caught or declared to be thrown PrintWriter out = new PrintWriter(file); ^ 1 error Hình 11.3: L ỗi biên d ịch do ngo ại l ệ không được x ử lý. Hai ví dụ trên, và các tình huống có ngoại lệ khác tương tự nhau ở những điểm sau: 1. Ta gọi một phương thức ở một lớp mà ta không viết 2. Phương thức đó có thể gặp trục trặc khi chạy 3. Ta cần biết rằng phương thức đó có thể gặp trục trặc 4. Ta cần viết mã xử lý tình huống sự cố nếu nó xảy ra. Hai điểm cuối là việc chúng ta chưa làm và sẽ nói đến trong những phần tiếp theo. Các phương thức Java dùng các ngoại lệ để báo với phần mã gọi chúng rằng "Một tình huống không mong đợi đã xảy ra. Tôi gặp sự cố." Cơ chế xử lý ngoại lệ của Java cho phép xử lý những tình huống bất thường xảy ra khi chương trình đang chạy, nó cho phép ta đặt tất cả những đoạn mã xử lý lỗi vào một nơi dễ đọc dễ hiểu. Cơ chế này dựa trên nguyên tắc rằng nếu ta biết ta có thể gặp một ngoại lệ nào đó ta sẽ có thể chuẩn bị để đối phó với tình huống phát sinh ngoại lệ đó. Trước hết, điểm số 3, làm thế nào để biết một phương thức có thể ném ngoại lệ hay không và nó có thể ném cái gì? Khi biên dịch gặp lỗi hoặc khi chạy gặp lỗi như trong hai ví dụ trên, ta biết được một số ngoại lệ có thể phát sinh. Nhưng như vậy chưa đủ. Ta cần tìm đọc dòng khai báo throws tại dòng đầu tiên của khai báo phương thức, hoặc đọc tài liệu đặc tả phương thức để xem nó tuyên bố có thể ném cái gì. Phương thức nào cũng phải khai báo sẵn tất cả các loại ngoại lệ mà nó có thể ném. 176
  34. Hình 11.4 là ảnh chụp trang đặc tả hàm khởi tạo PrintWriter(File) tại tài liệu API của JavaSE phiên bản 6 đặt tại trang web của Oracle. Tại đó, ta có thể tra cứu đặc tả của tất cả các lớp trong thư viện chuẩn Java. Hình 11.4: Thông tin v ề ngo ại l ệ t ại đặ c t ả ph ươ ng th ức. Đặc tả của hàm khởi tạo PrintWriter(File) nói rằng nó có thể ném FileNotFoundException, và nó sẽ ném nếu như đối tượng File được cho làm đối số không đại diện cho một file ghi được hoặc không thể tạo file với tên đã cho, hoặc nếu xảy ra lỗi nào khác trong khi mở hoặc tạo file. Như vậy, ta đã biết nếu tạo một đối tượng PrintWriter theo cách như trong Hình 11.3 thì ta phải chuẩn bị đối phó với loại ngoại lệ nào trong tình huống nào. 11.1.2. Xử lý ngoại lệ Tiếp theo là điểm số 4, làm thế nào để xử lí ngoại lệ sau khi đã biết thông tin về các loại ngoại lệ có thể phát sinh từ các phương thức ta dùng đến trong chương trình? Có hai lựa chọn, một là giải quyết tại chỗ, hai là tránh né trách nhiệm. Thực ra lựa chọn thứ hai không hẳn là né được hoàn toàn, nhưng ta sẽ trình bày chi tiết về lựa chọn này sau. Trước hết, ta nói về cách xử lí ngoại lệ tại chỗ. Để xử lý các ngoại lệ có thể được ném ra từ một đoạn mã, ta bọc đoạn mã đó trong một khối try/catch . Chương trình trong Hình 11.3 sau khi được sửa như trong Hình 11.5 thì biên dịch và chạy thành công. 177
  35. Hình 11.5: X ử lí ngo ại l ệ v ới kh ối try/catch. Khối try/catch gồm một khối try chứa phần mã có thể phát sinh ngoại lệ và ngay sau đó là một khối catch với nhiệm 'bắt' ngoại lệ được ném từ trong khối try và xử lí sự cố đó (có thể có vài khối catch theo sau một khối try, ta sẽ nói đến vấn đề này sau). Nội dung của khối catch tùy vào việc ta muốn làm gì khi loại sự cố cụ thể đó xảy ra. Ví dụ, trong Hình 11.5, khối catch chỉ làm một việc đơn giản là gọi phương thức printStackTrace() của ngoại lệ vừa bắt được để in ra màn hình thông tin về dấu vết của ngoại lệ đó trong ngăn xếp các lời gọi phương thức (stack trace). Đây là hoạt động xử lý ngoại lệ thường dùng trong khi đang tìm lỗi của chương trình. 11.1.3. Ngoại lệ là đối tượng Cái gọi là ngoại lệ mà nơi ném nơi bắt đó thực chất là cái gì trong ngôn ngữ Java? Cũng như nhiều thứ khác trong chương trình Java, mỗi ngoại lệ là một đối tượng của cây phả hệ Exception. Nhớ lại kiến thức về đa hình, ta lưu ý rằng mỗi đối tượng ngoại lệ có thể là thực thể của một lớp con của Exception. Hình 11.6 mô tả một phần của cây phả hệ Exception với FileNotFoundException và ArithmeticException là những loại ngoại lệ ta đã gặp trong các ví dụ của chương này. 178
  36. Hình 11.6: M ột ph ần c ủa cây ph ả hệ Exception. Do mỗi ngoại lệ là một đối tượng, cái được 'bắt' trong mỗi khối catch là một đối tượng, trong đó đối số của catch là tham chiếu tới đối tượng đó. Khối catch trong Hình 11.5 có tham số e là tham chiếu được khai báo thuộc kiểu FileNotFoundException. Mội khối catch khai báo tham số thuộc kiểu ngoại lệ nào thì sẽ bắt được các đối tượng thuộc kiểu ngoại lệ đó. Cũng theo nguyên tắc thừa kế và đa hình rằng các đối tượng thuộc lớp con cũng có thể được coi như các đối tượng thuộc kiểu lớp cha. Do đó, một khối catch khai báo tham số kiểu lớp cha thì cũng bắt được đối tượng ngoại lệ thuộc các lớp con của kiểu đó. Ví dụ khối ca tch(Exception e) { } bắt được các đối tượng thuộc các lớp Exception, IOException, cũng như FileNotFoundException (xem quan hệ thừa kế trong Hình 11.6). 11.2. KHỐI try/catch Mục trước đã giới thiệu về việc dùng khối try/catch để bắt và xử lý ngoại lệ. Mục này trình bày kĩ hơn về cấu trúc và cơ chế hoạt động của khối try/catch. 11.2.1. Bắt nhiều ngoại lệ Như ta đã thấy, ví dụ Hình 11.1 khi chạy có thể phát sinh hai loại ngoại lệ InputMismatchException hay ArithmeticException. Để xử lý hai ngoại lệ này, ta cũng dùng một khối try/catch tương tự như đã làm trong Hình 11.5. Nhưng lần này ta dùng hai khối catch, mỗi khối dành để xử lý một loại ngoại lệ. Mỗi khối try/catch chỉ có một khối try, tiếp theo là một hoặc vài khối catch. Hình 11.7 là ví dụ minh họa đơn giản cho khối try/catch có nhiều hơn một khối catch. 179
  37. Hình 11.7: Kh ối try/catch có nhi ều kh ối catch. Khi một ngoại lệ xảy ra, trình biên dịch tìm một khối catch phù hợp trong các khối catch đi kèm. Trình tự tìm là lần lượt từ khối thứ nhất đến khối cuối cùng, khối catch đầu tiên bắt được ngoại lệ đó sẽ được thực thi. 11.2.2. Hoạt động của khối try/catch Khi ta chạy một lệnh/phương thức có thể sinh ngoại lệ, một trong hai trường hợp xảy ra: (1) phương thức được gọi thành công; (2) phương thức được gọi ném ngoại lệ và khối catch bắt được ngoại lệ đó, và (3) phương thức được gọi ném ngoại lệ nhưng khối catch không bắt được ngoại lệ đó. Luồng điểu khiển trong khối try/catch trong các trường hợp đó cụ thể như sau: (1) Phương thức được gọi thành công, và khối try được thực thi đầy đủ cho đến lệnh cuối cùng, còn khối catch bị bỏ qua vì không có ngoại lệ nào phải xử lý. Sau khi khối try chạy xong, lệnh đằng sau catch (nghĩa là nằm ngay sau khối try/catch) sẽ chạy. 180
  38. (2) Phương thức được gọi ném ngoại lệ và khối catch bắt được ngoại lệ đó. Các lệnh trong khối try ở sau lệnh phát sinh ngoại lệ bị bỏ qua, điều khiển chuyển tới khối catch, sau khi khối catch thực thi xong, phần còn lại của phương thức tiếp tục chạy. (3) Phương thức được gọi ném ngoại lệ nhưng khối catch không bắt được ngoại lệ đó. Nếu không dùng khối finally mà ta nói đến ở mục sau, điều khiển sẽ nhảy ra khỏi chương trình, bỏ qua phần còn lại của phương thức kể từ sau lệnh phát sinh ngoại lệ và ra khỏi phương thức hiện tại. Điều khiển sẽ quay về nơi gọi phương thức hiện tại hoặc chương trình dừng do lỗi run-time (chi tiết sẽ được trình bày ở Mục 11.4). Ba trường hợp trên được tóm gọn trong sơ đồ sau: 181
  39. 11.2.3. Khối finally – những việc dù thế nào cũng phải làm Phần try và phần catch trong khối try/catch là những phần bắt buộc phải có. Ngoài ra, ta còn có thể lắp một phần có tên finally vào làm phần cuối cùng của khối try/catch. Một khối finally là nơi ta đặt các đoạn mã phải được thực thi bất kể ngoại lệ có xảy ra hay không. Hình 11.8: Điều khi ển ch ươ ng trình tại kh ối try/catch. Ta lấy một ví dụ minh họa. Giả sử ta cần luộc trứng trong lò vi sóng. Nếu có sự cố xảy ra, chẳng hạn trứng bị nổ, ta phải tắt lò. Nếu trứng luộc thành công, ta cũng tắt lò. Tóm lại, dù chuyện gì xảy ra thì ta cũng đều phải tắt lò. Nếu không dùng khối finally, ta phải gọi turnOvenOff() ở cả khối try lẫn khối catch, nhưng kết quả là vẫn không thực hiện được nhiệm vụ đóng file nếu kết cục lại 182
  40. xảy ra theo trường hợp (3) đã nói đến, khi điều khiển chương trình bỏ qua cả khối catch để ra ngoài. Với khối finally, trong bất kể tình huống nào, luồng điều khiển cũng phải chạy qua khối lệnh đó. Khi ngoại lệ bị ném ra mà không có khối catch nào bắt được, khối finally cũng chạy trước khi luồng điều kiển ra khỏi phương thức. Ngay cả khi có lệnh return trong khối try hoặc một khối catch, khối finally cũng được thực thi trước khi quay lại chạy lệnh return đó. Với đặc điểm đó, khối finally cho phép ta đặt các đoạn mã dọn dẹp tại một nơi thay vì phải lặp lại nó tại tất cả các điểm mà điều khiển chương trình có thể thoát ra khỏi phương thức. Hình 11.9: Điều khi ển ch ươ ng trình khi có kh ối finally. Lưu ý rằng, về mặt cú pháp, ta không thể chèn mã vào giữa các phần try, catch, và finally trong một khối try/catch; khối try thì bắt buộc phải có, nhưng các khối catch và finally thì không; tuy nhiên, sau một khối try phải có ít nhất một khối catch hoặc finally. 11.2.4. Thứ tự cho các khối catch Như ta đã trình bày trong Mục 11.1.3, ngoại lệ cũng là các đối tượng nên có tính đa hình, và một khối catch dành cho ngoại lệ lớp cha cũng bắt được ngoại lệ lớp con. Ví dụ các khối catch sau đều bắt được ngoại lệ loại InputMismatchException: catch(InputMismatchException e) { } chỉ bắt InputMismatchException, catch(IOException e) { } bắt tất cả các IOException, trong đó có InputMismatchException catch(Exception e) { } bắt tất cả các Exception, trong đó có các IOException. 183
  41. Có thể hình dung catch(Exception e) là một cái rổ to nhất và hứng được các loại đồ vật với nhiều kích thước hình dạng khác nhau, catch(IOException e) là cái rổ nhỏ hơn chút nên hứng được ít loại đồ vật hơn, còn catch (InputMismatchException e) là cái rổ nhỏ nhất và chỉ hứng vừa một loại đồ vật. Ta có thể chỉ dùng một cái rổ to nhất – khối catch bắt loại ngoại lệ tổng quát nhất – để bắt tất cả các ngoại lệ và xử lý một thể. Tuy nhiên, nếu ta muốn xử lý tùy theo các ngoại lệ thuộc loại khác nhau thì nên dùng các khối catch khác nhau trong một khối try/catch. Vậy các khối catch đó nên được để theo thứ tự nào? Nhớ lại rằng khi một ngoại lệ được ném ra từ bên trong khối try, theo thứ tự từ trên xuống dưới, khối catch nào bắt được ngoại lệ đó thì sẽ được chạy. Do đó, nếu cái rổ to được thử hứng trước cái rổ nhỏ hơn, nghĩa là khối catch cho lớp cha được đặt trước khối catch dành cho lớp con, thì cái rổ to sẽ hứng được ngay còn cái rổ nhỏ hơn sẽ không bao giờ đến lượt mình hứng được cái gì. Vì lí do đó, trình biên dịch yêu cầu khối catch dành cho lớp ngoại lệ tổng quát hơn bao giờ cũng phải đặt sau khối catch dành cho lớp ngoại lệ chuyên biệt hơn . Trình biên dịch sẽ báo lỗi nếu ta không tuân theo quy tắc này. Ví dụ, nếu ta có ba khối catch với ba loại tham số Exception, IOException, và InputMismatchException, chúng sẽ buộc phải theo thứ tự sau: Xem lại ví dụ trong Hình 11.7. Tại đó ta có hai khối catch, một cho InputMismatchException, một cho ArithmeticException. Giữa hai loại ngoại lệ này không có quan hệ lớp cha-lớp con. Nói cách khác, khối này không thể bắt ngoại lệ của khối kia. Do đó thứ tự của hai khối này không có ý nghĩa gì, khối nào đặt trước cũng được. 11.3. NÉM NGOẠI LỆ Nếu mã chương trình của ta phải bắt ngoại lệ, thì mã của ai ném nó? Các ví dụ ta đã dùng từ đầu chương đều nói về các tình huống mà ngoại lệ được ném từ bên trong một hàm trong thư viện. Ta gọi một phương thức có khai báo một loại ngoại lệ, và phương thức đó ném ngoại lệ trở lại đoạn chương trình gọi nó. 184
  42. Trong thực tế, ta có thể phải viết cả mã ném ngoại lệ cũng như mã xử lý ngoại lệ. Vấn đề không phải ở chỗ ai viết cái gì, mà là biết rằng phương thức nào ném ngoại lệ và phương thức nào bắt nó. Nếu viết một phương thức có thể ném một ngoại lệ, ta phải làm hai việc: (1) tuyên bố tại dòng khai báo phương thức rằng nó có thể ném loại ngoại lệ đó (dùng từ khóa throws ); (2) tạo một ngoại lệ và ném nó (bằng lệnh throw ) tại tình huống thích hợp trong nội dung phương thức. Ví dụ: Hình 11.10: Ném và b ắt ngo ại l ệ. 11.4. NÉ NGOẠI LỆ Đôi khi, ta có một phương thức dùng đến những lời gọi hàm có thể phát sinh ngoại lệ, nhưng ta không muốn xử lý một ngoại lệ tại phương thức đó. Khi đó, ta có thể 'né' bằng cách khai báo throws cho loại ngoại lệ đó khi viết định nghĩa phương thức. Kết quả của khai báo throws đối với một loại ngoại lệ là: nếu có một ngoại lệ thuộc loại đó được ném ra bởi một lệnh nằm trong phương thức, nó không được 'đỡ' mà sẽ 'rơi' ra ngoài phương thức, tới nơi gọi phương thức (caller). 185
  43. Hình 11.11: Né ngo ại l ệ để n ơi g ọi x ử lý. Ta còn nhớ ví dụ trong Hình 11.5, tại đó phương thức write() gọi đến new PrintWriter() bắt và xử lý ngoại lệ do new PrintWriter() ném ra. Bây giờ ta không muốn bắt và xử lý ngoại lệ ngay tại write() mà để cho nơi gọi write xử lý. Ta bỏ khối try/catch tại write() và thay bằng khai báo throws, sửa FileWriter thành như trong Hình 11.11. Khi đó, việc bắt và xử lý ngoại lệ trở thành trách niệm của nơi gọi write(), như phương thức main trong Hình 11.11. Có thể hình dung cơ chế ném, bắt, né như thế này: Ngoại lệ như một đồ vật được ném ra từ phương thức đang chạy – nó nằm trên đỉnh ngăn xếp của các lời gọi phương thức ( method call stack ). Nó sẽ rơi từ trên xuống. Trong các phương thức đang nằm trong ngăn xếp, phương thức nào né với khai báo throws phù hợp sẽ giống như giương ra một cái lỗ vừa với ngoại lệ để nó lọt qua và rơi tiếp xuống dưới. Phương thức nào bắt với khối try/catch phù hợp giống như giương ra một cái rổ hứng lấy ngoại lệ, nó được bắt để xử lý tại đây nên không rơi xuống tiếp nữa. Tóm lại, sau khi một ngoại lệ được ném, nó rơi từ trên xuống, lọt qua các phương thức có khai báo throws (tính cả phương thức ném nó), và bị giữ lại tại phương thức đầu tiên có khai báo catch bắt được nó. Trong quá trình rơi, nếu nó rơi vào một phương thức không có khai báo throws phù hợp hay khối try/catch phù hợp, nghĩa là phương thức đó không cho nó lọt qua, cũng không lấy rổ hứng, thì trình biên dịch sẽ báo lỗi. 186
  44. (1) (2) PrintWriter() (3) write() né ném ngoại lệ, main() bắt và ngoại lệ, nó rơi nó rơi xuống xử lý ngoại lệ xuống main() write() Hình 11.12: Ngo ại l ệ r ơi ra từ bên trong ph ươ ng th ức ném, lọt qua ph ươ ng th ức né nó, rồi r ơi xu ống ph ươ ng th ức bắt nó. Hình 11.12 minh họa quá trình rơi của một ngoại lệ FileNotFoundException với cài đặt như trong Hình 11.11. Trong đó, để đối phó với FileNotFoundException, WriteToFile.main có khối try/catch, FileWriter khai báo throws, và ta còn nhớ trong Hình 11.4, hàm khởi tạo PrintWriter(File) cũng khai báo throws đối với loại ngoại lệ này. Với trình tự main gọi write, còn write gọi hàm khởi tạo PrintWriter, ngoại lệ được ném ra từ trong PrintWriter, lọt qua write, rơi xuống main và được bắt tại đó. Các phương thức được đại diện bởi hình chữ nhật có cạnh là những đường đứt đoạn là những phương thức đã kết thúc do ngoại lệ. Việc né ngoại lệ thực ra chỉ trì hoãn việc xử lý ngoại lệ chứ không tránh được hoàn toàn. Nếu nơi cuối cùng trong chương trình là hàm main cũng né, ngoại lệ sẽ không được xử lý ở bất cứ khâu nào. Trong trường hợp đó, tuy trình biên dịch sẽ cho qua, nhưng khi chạy chương trình, nếu có ngoại lệ xảy ra, máy ảo Java sẽ ngắt chương trình y như những trường hợp ngoại lệ không được xử lý khác. 187
  45. (3) (4) main() né, máy ảo Java ngoại lệ rơi ra ngoài ngắt chương trình Hình 11.13: Nếu không được b ắt thì ngo ại l ệ r ơi ra ngoài ch ươ ng trình. Tổng kết lại, quy tắc hành xử mỗi khi gọi một phương thức có thể phát sinh ngoại lệ là: bắt hoặc né . Ta bắt bằng khối try/catch với khối try bọc ngoài đoạn mã sinh ngoại lệ và một khối catch phù hợp với loại ngoại lệ. Ta né bằng khai báo throws cho loại ngoại lệ đó ở đầu phương thức. Phương thức write của FileWriter có hai lựa chọn khi gọi new Printer(File): (1) bắt ngoại lệ như trong Hình 11.5. (2) né ngoại lệ để đẩy trách nhiệm cho nơi gọi nó như trong Hình 11.11. Trách nhiệm nay thuộc về main của WriteToFile. Nếu một ngoại lệ ném ra sớm hay muộn cũng phải được bắt và xử lý, tại sao đôi khi ta nên trì hoãn việc đó? Lí do là không phải lúc nào ta cũng có đủ thông tin để có thể khắc phục sự cố một cách thích hợp. Giả sử ta là người viết lớp FileWriter cung cấp tiện ích xử lý file, và FileWriter được thiết kế để có thể dùng được cho nhiều ứng dụng khác nhau. Để xử lý sự cố ghi file – ngoại lệ FileNotFoundException, ta có thể làm gì tại phương thức write với chức năng như các ví dụ ở trên? Hiển thị lời thông báo lỗi? Yêu cầu cung cấp tên file khác? Im lặng không làm gì cả? Lẳng lặng ghi vào một file mặc định? Tất cả các giải pháp đó đều không ổn. Ta không thể biết hành động nào thì phù hợp với chính sách của ứng dụng đang chạy (nơi sử dụng FileWriter của ta), ta không có thẩm quyền để tự tương tác với người dùng (không rõ có hay không) hoặc tự thay đổi phương án với tên file khác. Đơn giản là, tại write, ta không có đủ thông tin để khắc phục sự cố. Vậy thì đừng làm gì cả, hãy tránh sang một bên để cho nơi có đủ thông tin xử lý nhận trách nhiệm. Ngay cả khi lựa chọn bắt ngoại lệ để xử lý, một phương thức vẫn có thể ném tiếp chính ngoại lệ vừa bắt được sau khi đã xử lí một phần theo khả năng và trách nhiệm của mình. Ví dụ: 188
  46. 11.5. NGOẠI LỆ ĐƯỢC KIỂM TRA VÀ KHÔNG ĐƯỢC KIỂM TRA Nhớ lại các chương trình ví dụ có lỗi do không xử lý ngoại lệ trong Hình 11.1 và Hình 11.3. Ví dụ thứ nhất biên dịch thành công còn ví dụ thứ hai có lỗi về ngoại lệ ngay khi biên dịch. Ngoài ra, có lẽ đến đây bạn đọc đã gặp những sự cố khi chạy chương trình như NullPointerException (dùng tham chiếu null để truy nhập các biến thực thể hay phương thức thực thể), ArrayIndexOutOfBoundException (truy nhập mảng với chỉ số không hợp lệ). Ta đã không bị buộc phải bắt và xử lý các ngoại lệ đó. Tại sao lại có sự khác biệt này? Lí do là các kiểu ngoại lệ của Java được chia thành hai loại: được kiểm tra (checked ) và không được kiểm tra (unchecked ) bởi trình biên dịch. Loại không được kiểm tra bao gồm các đối tượng thuộc lớp RuntimeException và các lớp con của nó, chẳng hạn NullPointerException, ArrayIndexOutOfBoundException , InputMismatchException hay ArithmeticException (như trong ví dụ Hình 11.1) Với những ngoại lệ loại không được kiểm tra, trình biên dịch không quan tâm ai tuyên bố ném, ai ném, và có ai bắt hay không. Tất cả trách nhiệm thuộc về người lập trình. Loại được kiểm tra bao gồm ngoại lệ thuộc tất cả các lớp còn lại, nghĩa là các lớp không thuộc loại RuntimeException và các lớp con của nó. Một ví dụ là ngoại lệ FileNotFoundException trong Hình 11.3. Loại được kiểm tra được trình biên dịch kiểm tra xem đã được xử lý trong mã hay chưa. Hầu hết các ngoại lệ thuộc loại RuntimeException xuất phát từ một vấn đề trong lô-gic chương trình của ta chứ không phải từ một sự cố xảy ra trong khi chương trình chạy mà ta không thể lường trước hoặc đề phòng. Ta không thể đảm bảo rằng một file cần mở chắc chắn có ở đó để ta dùng. Ta không thể đảm bảo rằng server sẽ chạy ổn định đúng vào lúc ta cần. Nhưng ta có thể đảm bảo rằng chương trình của ta sẽ không dùng chỉ số quá lớn truy nhập vượt ra ngoài mảng (mảng thuộc tính .length để ta kiểm soát việc này). Hơn nữa, ta muốn rằng các lỗi run-time phải được phát hiện và sửa chữa ngay trong thời gian phát triển và kiểm thử phần mềm. Ta không muốn viết thêm những khối try/catch kèm theo sự trả giá về hiệu năng không cần thiết để bắt những lỗi mà đáng ra không nên xảy ra, đáng ra phải được loại bỏ trước khi chương trình được đưa vào sử dụng. 189
  47. Mục đích sử dụng của các khối try/catch là để xử lí các tình huống bất thường chứ không phải để khắc phục lỗi trong mã của lập trình viên . Hãy dùng các khối catch để cố gắng khắc phục sự cố của các tình huống mà ta không thể đảm bảo sẽ thành công. Ít nhất, ta cũng có thể in ra một thông điệp cho người dùng và thông tin về dấu vết của ngoại lệ trong ngăn xếp các lời gọi phương thức (stack trace) để ai đó có thể hiểu được chuyện gì đã xảy ra. 11.6. ĐỊNH NGHĨA KIỂU NGOẠI LỆ MỚI Thông thường, khi viết mã sử dụng các thư viện có sẵn, lập trình viên cần xử lý các ngoại lệ có sẵn mà các phương thức trong thư viện đó ném để tạo ra được những chương trình có khả năng chống chịu lỗi cao. Còn nếu ta viết các lớp để cho các lập trình viên khác sử dụng trong chương trình của họ, ta có thể cần định nghĩa các kiểu ngoại lệ đặc thù cho các sự cố có thể xảy ra khi các lớp này được dùng trong các chương trình khác. Một lớp ngoại lệ mới cần phải là lớp chuyên biệt hóa của một lớp ngoại lệ có sẵn để loại ngoại lệ mới có thể dùng được với cơ chế xử lý ngoại lệ thông thường. Một lớp ngoại lệ điển hình chỉ chứa hai hàm khởi tạo, một hàm không lấy đối số và truyền một thông báo lỗi mặc định cho hàm khởi tạo của lớp cha, một hàm lấy một xâu kí tự là thông báo lỗi tùy chọn và truyền nó cho hàm khởi tạo của lớp cha. Còn trong phần lớn các trường hợp, ta chỉ cần một lớp con rỗng với một cái tên thích hợp là đủ. Nên dành cho mỗi loại sự cố nghiêm trọng một lớp ngoại lệ được đặt tên thích hợp để tăng tính trong sáng của chương trình. Nên chọn lớp ngoại lệ cơ sở là một lớp có liên quan. Ví dụ, nếu định tạo lớp ngoại lệ mới cho sự cố phép chia cho 0, ta có thể lấy lớp cha là lớp ngoại lệ cho tính toán số học là ArithmeticException. Nếu không có lớp ngoại lệ có sẵn nào thích hợp làm lớp cha, ta nên xét đến việc ngoại lệ mới nên thuộc loại được kiểm tra (checked) hay không (unchecked). Nếu cần bắt buộc chương trình sử dụng xử lý ngoại lệ, ta dùng loại được kiểm tra, nghĩa là là lớp con của Exception nhưng không phải lớp con của RuntimeException. Còn nếu có thể cho phép chương trình ứng dụng bỏ qua ngoại lệ này, ta chọn lớp cha là RuntimeException. 190
  48. 11.7. NGOẠI LỆ VÀ CÁC PHƯƠNG THỨC CÀI ĐÈ Giả sử ta viết một lớp con và cài đè một phương thức của lớp cha. Có những ràng buộc gì về việc ném ngoại lệ từ trong phương thức của lớp con? Ta nhớ lại nguyên lý "Các đối tượng thuộc lớp con có thể được đối xử như thể chúng là các đối tượng thuộc lớp cha". Nói cách khác, đoạn mã nào chạy được với một lớp cha cũng phải chạy được với bất kì lớp nào được dẫn xuất từ lớp đó. Đặt trong ngữ cảnh cụ thể hơn của lời gọi phương thức từ tham chiếu tới lớp cha, ta có quy tắc rằng phương thức cài đè chỉ được ném các kiểu ngoại lệ đã được khai báo tại phiên bản của lớp cha, hoặc ngoại lệ thuộc các lớp con của các kiểu nói trên, hoặc không ném ngoại lệ nào . Hình 11.14: Ném ngo ại l ệ t ừ ph ươ ng th ức cài đè. Lấy ví dụ trong Hình 11.14. Phương thức blah() vốn được viết cho đối số thuộc kiểu A. Khối catch (ExceptionA e) trong đó bắt loại ngoại lệ mà phương thức methodA() của A có thể ném. B là lớp con của A, do đó có thể chạy blah() cho kiểu B. Nếu khối catch nói trên không thể bắt được các loại ngoại lệ mà phiên bản methodA() của B ném, thì phương thức blah() không thể được coi là chạy được đối với kiểu con của A. Do đó, kiểu ExceptionB mà phiên bản methodA() của B tuyên bố có thể ném phải được định nghĩa là một lớp dẫn xuất từ lớp ExceptionA. 191
  49. Những điểm quan trọng: • Một phương thức có thể ném ngoại lệ khi gặp sự cố trong khi đang chạy • Một ngoại lệ là một đối tượng thuộc kiểu Exception hoặc lớp con của Exception. • Trình biên dịch không quan tâm đến các ngoại lệ kiểu RuntimeException. Các ngoại lệ kiểu RuntimeException không bắt buộc phải được phương thức xử lý bằng khối try/catch hay khai báo throws để né. • Tất cả các loại ngoại lệ mà trình biên dịch quan tâm được gọi là các ngoại lệ được kiểm tra. Các ngoại lệ còn lại (các loại RuntimeException) được gọi là ngoại lệ không được kiểm tra. • Một phương thức ném một ngoại lệ bằng lệnh throw, tiếp theo là một đối tượng ngoại lệ mới. • Các phương thức có thể ném một ngoại lệ loại được kiểm tra phải khai báo ngoại lệ đó với dạng throws Exception • Nếu một phương thức của ta gọi một phương thức có ném ngoại lệ loại được kiểm tra, phương thức đó phải đảm bảo rằng ngoại lệ đó được quan tâm xử lý. • Nếu muốn xử lý ngoại lệ phát sinh từ một đoạn mã, ta bọc đoạn mã đó vào trong một khối try/catch và đặt phần mã xử lý ngoại lệ/khắc phục sự cố vào trong khối catch. • Nếu không định xử lý ngoại lệ, ta có thể 'né' ngoại lệ bằng khai báo throws. • Nếu một lớp con cài đè phương thức của lớp cha thì phiên bản của lớp con chỉ được ném các kiểu ngoại lệ đã được khai báo tại phiên bản của lớp cha, hoặc ngoại lệ thuộc các lớp con của các kiểu nói trên, hoặc không ném ngoại lệ nào. 192
  50. Bài tập 1. Liệt kê 5 ngoại lệ thông dụng. 2. Nếu không có ngoại lệ được ném trong một khối try, điều khiển sẽ đi tới đâu khi khối try chạy xong? 3. Chuyện gì xảy ra nếu không có khối catch nào bắt được đối tượng ngoại lệ bị ném? 4. Chuyện gì xảy ra nếu nhiều hơn một khối catch có thể bắt đối tượng ngoại lệ bị ném? 5. Khối finally dùng để làm gì? 6. Chuyện gì xảy ra với một tham chiếu địa phương trong một khối try khi khối đó ném một ngoại lệ? 7. Trong các phát biểu sau đâu, phát biểu nào đúng/sai? a) Sau một khối try phải là một khối catch kèm theo một khối finally. b) Nếu ta viết một phương thức có thể phát sinh một ngoại lệ mà trình biên dịch kiểu tra, ta phải bọc đoạn mã đó vào trong một khối try/catch. c) Các khối catch có thể mang tính đa hình. d) Chỉ có thể bắt được các loại ngoại lệ mà trình biên dịch kiểm tra. e) Nếu ta viết một khối try/catch, có thể viết khối finally, có thể không. f) Nếu ta viết một khối try, ta có thể viết kèm một khối catch hoặc một khối try tương ứng, hoặc cả hai. g) Phương thức main() trong chương trình phải xử lý tất cả các ngoại kệ chưa được xử lí rơi xuống cho nó. h) Một khối try có thể kèm theo nhiều khối catch. i) Một phương thức chỉ được ném một loại ngoại lệ, j) Một khối finally sẽ chạy bất kể ngoại lệ có được ném hay không. k) Một khối finally có thể tồn tại mà không cần đi kèm khối try nào l) Thư tự của các khối catch không quan trọng. m) Một phương thức có một khối try/catch vẫn có thể khai báo cả phần throws. n) Các ngoại lệ run-time bắt buộc phải được bắt để xử lý hoặc được khai báo ném. 8. (Dùng lớp cơ sở khi bắt ngoại lệ ) Sử dụng quan hệ thừa kế để tạo một lớp cơ sở ExceptionA và các lớp dẫn xuất ExceptionB và ExceptionC, trong đó ExceptionB thừa kế ExceptionA và ExceptionC thừa kế ExceptionB. Viết một chương trình 193
  51. minh họa cho việc khối catch cho loại ExceptionA bắt các ngoại lệ thuộc loại ExceptionB và ExceptionC. 9. (Dùng lớp Exception khi bắt ngoại lệ ) Viết một chương trình minh họa việc bắt các ngoại lệ khác nhau bằng khối catch ( Exception exception ) Gợi ý: Đầu tiên, viết lớp ExceptionA là lớp con của Exception và ExceptionB là lớp con của ExceptionA. Trong chương trình, bạn hãy tạo khối try ném các ngoại lệ thuộc các kiểu ExceptionA, ExceptionB, NullPointerException và IOException. Tất cả các ngoại lệ đó cần được bắt bởi các khối catch có khai báo bắt loại Exception. 10. (Thứ tự của các khối catch ) Viết một chương trình cho thấy thứ tự của các khối catch là quan trọng. Nếu bạn cố bắt ngoại lệ lớp cha trước khi bắt ngoại lệ lớp con, trình biên dịch sẽ sinh lỗi. 11. (Sự cố tại constructor ) Viết một chương trình demo việc một hàm khởi tạo gửi thông tin về một sự cố của hàm khởi tạo đó tới một đoạn mã xử lý ngoại lệ. Định nghĩa lớp SomeException, lớp này ném một đối tượng Exception từ bên trong hàm khởi tạo. Chương trình của bạn cần tạo một đối tượng thuộc loại SomeException, và bắt ngoại lệ được ném từ bên trong hàm khởi tạo. 12. (Ném tiếp ngoại lệ ) Viết một chương trình minh họa việc ném tiếp một ngoại lệ. Định nghĩa các phương thức someMethod() và someMethod2(). Phương thức someMethod2() cần ném một ngoại lệ. Phương thức someMethod() cần gọi someMethod2(), bắt ngoại lệ và ném tiếp. Gọi someMethod() từ trong phương thức main và bắt ngoại lệ vừa được ném tiếp. Hãy in thông tin lần vết ( stack trace ) của ngoại lệ đó. 13. (Bắt ngoại lệ ở bên ngoài hàm xảy ra ngoại lệ ) Viết một chương trình minh họa việc một phương thức với khối try không phải bắt tất cả các ngoại lệ được tạo ra từ trong khối try đó. Một số ngoại lệ có thể trượt qua, rơi ra ngoài phương thức và được xử lý ở nơi khác. 14. Với các lớp Account, Fee, NickleNDime, Gambler đã được viết từ bài tập cuối Ch-¬ng 7, bổ sung các đoạn mã ném và xử lý ngoại lệ để kiểm soát các điều kiện sau: a) Tài khoản khi tạo mới phải có số tiền ban đầu lớn hơn 0. b) Số tiền rút hoặc gửi phải lớn hơn 0 và không được vượt quá số tiền hiện có trong tài khoản. Riêng tài khoản loại Gambler không được rút quá ½ số tiền hiện có. Tạo các lớp ngoại lệ InvalidAmountException (số tiền không hợp lệ) và OverWithdrawException (rút tiền quá lượng cho phép) để sử dụng trong các trường hợp trên. Trong đó OverWithdrawException là lớp con của InvalidAmountException. 194
  52. Viết chương trình AccountExceptionTest để chạy thử các trường hợp gây lỗi. 195
  53. Ch−¬ng 12. Chuçi hãa ®èi t−îng vµ vµo ra file Các đối tượng có trạng thái và hành vi. Các hành vi lưu trú trong lớp, còn trạng thái nằm tại từng đối tượng. Vậy chuyện gì xảy ra nếu ta cần lưu trạng thái của một đối tượng? Chẳng hạn, trong một ứng dụng trò chơi, ta cần lưu trạng thái của một ván chơi, rồi khi người chơi quay lại chơi tiếp ván chơi đang dở, ta cần nạp lại trạng thái đã lưu. Cách làm truyền thống vất vả là lấy từng giá trị dữ liệu lưu trong mỗi đối tượng, rồi ghi các giá trị đó vào một file theo định dạng mà ta tự quy định. Hoặc theo phương pháp hướng đối tượng, ta chỉ việc là phẳng, hay đập bẹp, đối tượng khi lưu nó, rồi thổi phồng nó lên khi cần sử dụng trở lại. Cách truyền thống đôi khi vẫn cần đến, đặc biệt khi các file mà ứng dụng ghi sẽ được đọc bởi các ứng dụng không viết bằng Java. Chương này sẽ nói đến cả hai phương pháp lưu trữ đối tượng. Có hai lựa chọn cho việc lưu trữ dữ liệu: Nếu file dữ liệu sẽ được dùng bởi chính chương trình đã sinh ra nó, ta dùng phương pháp chuỗi hóa (serialization ): chương trình ghi các đối tượng đã được chuỗi hóa vào một file, rồi khi cần thì đọc các đối tượng chuỗi hóa từ file và biến chúng trở lại thành các đối tượng hoạt động trong bộ nhớ heap. Nếu file dữ liệu sẽ được sử dụng bởi các chương trình khác, ta dùng file lưu trữ dạng text : Viết một file dạng text với cú pháp mà các chương trình khác có thể hiểu được. Ví dụ, dùng tab để tách giữa các giá trị dữ liệu, dùng dấu xuống dòng để tách giữa các đối tượng. Tất nhiên, đó không phải các lựa chọn duy nhất. Ta có thể lưu dữ liệu theo cú pháp bất kì mà ta chọn. Chẳng hạn, thay vì ghi dữ liệu bằng các kí tự (text), ta có thể ghi bằng dạng byte (nhị phân). Hoặc ta có thể ghi dữ liệu kiểu cơ bản theo cách Java trợ giúp ghi kiểu dữ liệu đó – có các phương thức riêng để ghi các giá trị kiểu int, long, boolean, v.v Nhưng bất kể ta dùng phương pháp nào, các kĩ thuật vào ra dữ liệu cơ bản đều gần như không đổi: ghi dữ liệu vào cái gì đó , thường là một file trên đĩa hoặc một kết nối mạng; đọc dữ liệu là quy trình ngược lại: đọc từ file hoặc một kết nối mạng. Ta lấy một ví dụ. Giả sử ta có một chương trình trò chơi kéo dài nhiều bài. Trong trò chơi, các nhân vật khỏe lên hoặc yếu đi, thu thập, sử dụng, đánh mất một số loại vũ khí. Người chơi không thể chơi liên tục từ bài 1 cho đến khi 'phá đảo' 10 mà phải ngừng giữa chừng cho các hoạt động khác trong cuộc sống. Mỗi khi người chơi tạm dừng, chương trình cần lưu trạng thái của các nhân vật trò chơi để khôi phục lại 10 'Phá đảo' có nghĩa là chơi xong bài cuối cùng của trò chơi điện tử có nhiều bài để chơi lần lượt. 196
  54. trạng thái trò chơi khi người chơi tiếp tục. Cụ thể, ta hiện có ba nhân vật / đối tượng: xác sống (zombie), súng đậu (pea shooter), và nấm thần (magic mushroom). Hình 12.1: Hai cách ghi đối t ượng ra file. Nếu dùng lựa chọn 1, ta ghi dạng chuỗi hóa ba đối tượng trên vào một file. File đó sẽ ở dạng nhị phân, nếu ta thử đọc theo dạng text thì khó có thể hiểu được nội dung. Nếu dùng lựa chọn 2, ta có thể tạo một file và ghi vào đó ba dòng text, mỗi dòng dành cho một đối tượng, các trường dữ liệu của mỗi đối tượng được tách nhau bởi dấu phảy. Xem minh họa tại Hình 12.1. File chứa các đối tượng chuỗi hóa khó đọc đối với con người. Tuy nhiên đối với việc chương trình khôi phục lại ba đối tượng từ file, biểu diễn chuỗi hóa lại là dạng dễ hiểu và an toàn hơn là dạng text. Chẳng hạn, đối với file text, do lỗi lô-gic của lập trình viên mà chương trình có thể đọc nhầm thứ tự các trường dữ liệu, kết quả là đối tượng zombie bị khôi phục thành nhân vật loại hands và có các vũ khí là zombie và teeth. 12.1. QUY TRÌNH GHI ĐỐI TƯỢNG Cách ghi đối tượng chuỗi hóa sẽ được trình bày một cách chi tiết sau. Tạm thời, ta chỉ giới thiệu các bước cơ bản: 197
  55. – – ượ 3. Ghi các đối tượng 4. Đóng dòng ObjectOutputStream Bước 1 tạo một dòng ra dạng file, FileOutputStream, đối tượng dòng ra này kết nối với file có tên 'game.dat', nếu chưa có file với tên đó thì nó sẽ tạo mới một file như vậy. Bước 2 tạo một đối tượng kiểu ObjectOutputStream – dòng ra cho dữ liệu dạng đối tượng. Nó cho phép ghi đối tượng nhưng nó lại không thể kết nối trực tiếp với một file. Vậy nên ta nối nó với đối tượng dòng ra dạng file để 'giúp đỡ' nó trong việc ghi ra file. Bước 3 chuỗi hóa các đối tượng mà zombie, peaShooter, và mushroom chiếu tới, rồi ghi nó ra file qua dòng ra os. Bước 4 đóng dòng ra dạng đối tượng. Khi đóng một dòng ra, dòng mà nó nối tới, ở đây là FileOutputStream, sẽ được đóng tự động. Việc ghi dữ liệu đến đây kết thúc. Chúng ta đã nói đến các dòng, vậy bản chất chúng là cái gì? Có thể hình dung dòng (stream ) như một đường ống mà dữ liệu di chuyển trong đó để đi từ nơi này sang nơi khác. Thư viện vào ra dữ liệu của Java có các dòng kết nối (connection stream ) đại diện cho các kết nối tới các đích và các nguồn như các file hay socket mạng, và các dòng nối tiếp (chain stream ) không thể kết nối với các đích và nguồn mà chỉ có thể chạy được nếu được nối với các dòng khác. Thông thường, để làm việc gì đó, ta cần dùng ít nhất hai dòng nối với nhau: một dòng đại diện cho kết nối với nguồn hay đích của dữ liệu, dòng kia cung cấp tiện ích đọc/ghi. Lí do là dòng kết nối thường hỗ trợ ở mức quá thấp. Ví dụ, dòng kết nối FileOutputStream chỉ cung cấp các phương thức ghi byte. Còn ta không muốn ghi từng byte hoặc chuỗi byte. Ta muốn ghi đối tượng, do đó ta cần một dòng nối tiếp ở mức cao hơn, chẳng hạn ObjectOutputStream là dòng nối tiếp cho phép ghi đối tượng. Vậy tại sao thư viện không có một dòng mà mình nó làm được chính xác những gì ta cần, phía trên thì cho ta phương thức ghi đối tượng còn phía dưới thì biến đổi ra chuỗi byte và đổ ra file? Với tư tưởng hướng đối tượng, mỗi lớp chỉ nên làm một nhiệm vụ. FileOutputStream ghi byte ra file, còn ObjectOutputStream biến đối tượng thành dạng dữ liệu có thể ghi được vào một dòng. Thế cho nên, ta tạo một FileOutputStream để có thể ghi ra file, và ta nối một ObjectOutputStream vào đầu kia. Và khi ta gọi writeObject() từ ObjectOutputStream, đối tượng được bơm vào 198
  56. dòng, chuyển thành chuỗi byte, và di chuyển tới FileOutputStream, nơi nó được ghi vào một file. Khả năng lắp ghép các tổ hợp khác nhau của các dòng kết nối và các dòng nối tiếp mang lại cho ta khả năng linh hoạt. Ta có thể tự lắp ghép một chuỗi các dòng theo nhu cầu của ta chứ không phải đợi những người phát triển thư viện Java xây dựng cho ta một dòng chứa tất cả những gì ta muốn. 12.2. CHUỖI HÓA ĐỐI TƯỢNG Chuyện gì xảy ra khi một đối tượng bị chuỗi hóa? Các đối tượng tại heap có trạng thái là giá trị của các biến thực thể của đối tượng. Các giá trị này tạo nên sự khác biệt giữa các thực thể khác nhau của cùng một lớp. Đối tượng bị chuỗi hóa lưu lại các giá trị của các biến thực thể, để sau này có thể khôi phục lại một đối tượng giống hệt tại heap. Ví dụ, một đối tượng b kiểu Box có hai biến thực thể thuộc kiểu cơ bản width = 37 và height = 70. Khi gọi lệnh os.writeObject(b), các giá trị đó được lấy ra và bơm vào dòng, kèm theo một số thông tin khác, chẳng hạn như tên lớp, mà sau này máy ảo Java sẽ cần đến để khôi phục đối tượng. Tất cả được ghi vào file ở dạng nhị phân. Đối với các biến thực thể kiểu cơ bản thì chỉ đơn giản như vậy, còn các biến thực thể kiểu tham chiếu đối tượng thì sao? Nếu như một đối tượng có biến thực thể là tham chiếu tới một đối tượng khác, và chính đối tượng đó lại có các biến thực thể? Khi một đối tượng được chuỗi hóa, tất cả các đối tượng được chiếu tới từ các biến thực thể của nó cũng được chuỗi hóa. Và tất cả các đối tượng mà các đối tượng đó chiếu tới cũng được chuỗi hóa, Toàn bộ công việc đệ quy này được thực hiện một cách tự động. Ví dụ, một đối tượng ContactList (danh bạ điện thoại) có một tham chiếu tới một đối tượng mảng Contact[]. Đối tượng kiểu Contact[] lưu các tham chiếu tới hai đối tượng Contact. Mỗi đối tượng Contact có tham chiếu tới một String và một đối tượng PhoneNumber. Đối tượng String có một loạt các kí tự và đối tượng PhoneNumber có một số kiểu long. Khi ta lưu đối tượng ContactList, tất cả các đối tượng trong đồ thị tham chiếu nói trên đều được lưu. Có như vậy thì sau này mới có thể khôi phục đối tượng ContactList đó về đúng trạng thái này. 199
  57. Hình 12.2: Đồ th ị tham chi ếu c ủa đố i t ượng ContactList. Ta đã nói về khái niệm và lý thuyết của việc chuỗi hóa đối tượng. Vậy về mặt viết mã thì như thế nào? Không phải đối tượng thuộc lớp nào cũng nghiễm nhiên chuỗi hóa được. Nếu ta muốn các đối tượng thuộc một lớp nào đó có thể chuỗi hóa được, ta phải cho lớp đó cài đặt interface Serializable. Serializable là một interface thuộc loại dùng để đánh dấu (dạng marker hoặc tag). Các interface loại này không có phương thức nào để cài. Mục đích duy nhất của Serializable là để tuyên bố rằng lớp cài nó có thể chuỗi hóa được. Nói cách khác là có thể dùng cơ chế chuỗi hóa để lưu các đối tượng thuộc loại đó. Nếu một lớp chuỗi hóa được thì tất cả các lớp con cháu của nó đều tự động chuỗi hóa được mà không cần phải khai báo implements Serializable. (Ta còn nhớ ý nghĩa của quan hệ IS-A.) Nếu một lớp không thuộc loại chuỗi hóa được, chương trình nào gọi phương thức writeObject cho đối tượng thuộc lớp đó có thể biên dịch không lỗi nhưng khi chạy đến lệnh đó sẽ gặp ngoại lệ NonSerializableException. 200
  58. Như đã nói ở trên, khi lưu một đối tượng, toàn bộ các đối tượng trong đồ thị tham chiếu của nó cũng được lưu. Do đó, tất cả các lớp đó đều phải thuộc loại Serializable. Như trong ví dụ Hình 12.2 thì các lớp ContactList, Contact, PhoneNumber, String đều phải thuộc loại chuỗi hóa được nếu không muốn xảy ra ngoại lệ NonSerializableException khi chương trình chạy. Ta đi đến tình huống khi trong một đối tượng cần lưu lại có một biến thực thể là tham chiếu tới đối tượng thuộc lớp không chuỗi hóa được. Và ta không thể sửa cài đặt lớp để cho nó chuỗi hóa được, chẳng hạn khi lớp đó do người khác viết. Giải pháp là khai báo biến thực thể đó với từ khóa transient. Từ khóa này có tác dụng tuyên bố rằng "hãy bỏ qua biến này khi chuỗi hóa". Bên cạnh tình huống biến thực thể thuộc loại không thể chuỗi hóa, ta còn cần đến khai báo transient trong những trường hợp khác. Chẳng hạn như khi người thiết kế lớp đó quên không cho lớp đó khả năng chuỗi hóa. Hoặc vì đối tượng đó phụ thuộc vào thông tin đặc thù cho từng lần chạy chương trình mà thông tin đó không thể lưu được. Ví dụ về dạng đối tượng đó là các đối tượng luồng (thread), kết nối mạng, hoặc file trong thư viện Java. Chúng thay đổi tùy theo từng lần chạy của chương 201
  59. trình, từng platform cụ thể, từng máy ảo Java cụ thể. Một khi chương trình tắt, không có cách gì khôi phục chúng một cách hữu ích, chúng phải được tạo lại từ đầu mỗi lần cần dùng đến. 12.3. KHÔI PHỤC ĐỐI TƯỢNG Mục đích của việc chuỗi hóa một đối tượng là để ta có thể khôi phục nó về trạng thái cũ vào một thời điểm khác, tại một lần chạy khác của máy ảo Java (thậm chí tại máy ảo khác). Việc khôi phục đối tượng (deserialization ) gần như là quá trình ngược lại của chuỗi hóa. Bước 1 tạo một dòng vào dạng file, FileInputStream, đối tượng dòng vào này kết nối với file có tên 'game.dat', nếu không tìm thấy file với tên đó thì ta sẽ nhận được một ngoại lệ. Bước 2 tạo một đối tượng dòng vào dạng đối tượng, ObjectInputStream. Nó cho phép đọc đối tượng nhưng nó lại không thể kết nối trực tiếp với một file. Nó cần được nối với một đối tượng kết nối, ở đây là FileInputStream, để có thể ghi ra file. Bước 3, mỗi lần gọi readObject(), ta sẽ lấy được đối tượng tiếp theo từ trong dòng ObjectInputStream. Do đó, ta sẽ đọc các đối tượng theo đúng thứ tự mà chúng đã được ghi. Ta sẽ nhận được ngoại lệ nếu cố đọc nhiều hơn số đối tượng đã được ghi vào file. Bước 4, giá trị trả về của readObject() là tham chiếu kiểu Object, do đó ta cần ép kiểu cho nó trở lại kiểu thực sự của đối tượng mà ta biết. Bước 4 đóng ObjectInputStream. Khi đóng một dòng vào, các dòng mà nó nối tới, ở đây là FileInputStream, sẽ được đóng tự động. Việc đọc dữ liệu đến đây kết thúc. Quá trình khôi phục đối tượng diễn ra theo các bước như sau: 202
  60. 1. Đối tượng được đọc từ dòng vào dưới dạng một chuỗi byte. 2. Máy ảo Java xác định xem đối tượng thuộc lớp gì, qua thông tin lưu trữ tại đối tượng được chuỗi hóa. 3. Máy ảo tìm và nạp lớp đó. Nếu không tìm thấy hoặc không nạp được, máy ảo sẽ ném một ngoại lệ và quá trình khôi phục thất bại. 4. Một đối tượng mới được cấp phát bộ nhớ tại heap, nhưng hàm khởi tạo của đối tượng đó không chạy. Nếu chạy thì nó sẽ khởi tạo về trạng thái ban đầu như kết quả của lệnh new. Ta muốn đối tượng được khôi phục về trạng thái khi nó được chuỗi hóa, chứ không phải trạng thái khi nó mới được sinh ra. 5. Nếu đối tượng có một lớp tổ tiên thuộc loại không chuỗi hóa được, hàm khởi tạo cho lớp đó sẽ được chạy cùng với các hàm kh ởi t ạo của các lớp bên trên nó trên cây phả hệ. 6. Các biến thực thể của đối tượng được gán giá trị từ trạng thái đã được chuỗi hóa. Các biến transient được gán giá trị mặc định: null cho tham chiếu và 0/false/ cho kiểu cơ bản. 203
  61. Hình 12.3: Ghi đối t ượng vào file và đọc t ừ file. 204
  62. Hình 12.4: Cài đặt các l ớp chu ỗi hóa được. Tổng kết lại, ta cài đặt hoàn chỉnh ví dụ ghi và đọc các đối tượng nhân vật trò chơi trong Hình 12.3. Phiên bản cài đặt tối thiểu của GameCharacter và các lớp cần thiết được cho trong Hình 12.4. Lưu ý rằng đó chỉ là nội dung cơ bản phục vụ mục đích thử nghiệm đọc và ghi đối tượng chứ không phải dành cho một chương trình trò chơi thực sự. 12.4. GHI CHUỖI KÍ TỰ RA TỆP VĂN BẢN Sử dụng cơ chế chuỗi hóa cho việc lưu trữ đối tượng là cách dễ dàng nhất để lưu trữ và khôi phục dữ liệu giữa các lần chạy của một chương trình Java. Nhưng đôi khi, ta cũng cần lưu dữ liệu vào một file văn bản, chẳng hạn khi file đó để cho một chương trình khác (có thể không viết bằng Java) đọc. Việc ghi một chuỗi kí tự ra file văn b ản tương tự với việc ghi một đối tượng, chỉ khác ở chỗ ta ghi một đối tượng String thay vì một đối tượng chung chung, và ta dùng các dòng khác thay cho FileOutputStream và ObjectOutputStream. 205
  63. Hình 12.5: Ghi file văn b ản. Hình 12.5 là ví dụ cơ bản nhất minh họa việc ghi file văn bản. Java cho ta nhiều cách để tinh chỉnh chuỗi các dòng ra dùng cho việc ghi file. 12.4.1. Lớp File Đối tượng thuộc lớp java.io.File đại diện cho một file hoặc một thư mục. Lớp này không có các tiện ích ghi đọc file, nhưng nó là đại diện an toàn cho file hơn là chuỗi kí tự tên file. Hầu hết các lớp lấy tên file làm tham số cho hàm khởi tạo, chẳng hạn FileWriter hay FileInputStream, cũng cung cấp hàm khởi tạo lấy một đối tượng File. Ta có thể tạo một đối tượng File, kiểm tra xem đường dẫn có hợp lệ hay không, v.v rồi chuyển đối tượng File đó cho FileWriter hay FileInputStream. Với một đối tượng File, ta có thể làm một số việc hữu ích như: 1. Tạo một đối tượng File đại diện cho một file đang tồn tại: File f = new File("foo.txt"); 2. Tạo một thư mục mới: File dir = new File("Books"); dir.mkdir(); 3. Liệt kê nội dung của một thư mục: if (dir.isDirectory()) { String[] dirContents = dir.list(); for (int i = 0; i < dirContents; i++) System.out.println(dirContents[i]); } 4. Lấy đường dẫn tuyệt đối của file hoặc thư mục: System.out.println(dir.getAbsolutePath()); 5. Xóa file hoặc thư mục (trả về true nếu thành công): boolean isDeleted = f.delete(); 206
  64. 12.4.2. Bộ nhớ đệm Bộ nhớ đệm (buffer) cho ta một nơi lưu trữ tạm thời để tăng hiệu quả của thao tác đọc/ghi dữ liệu. Cách sử dụng BufferWriter như sau: BufferWriter writer = new BufferWriter(new FileWriter(aFile); Sau lệnh trên thì ta chỉ cần làm việc với BufferWriter mà không cần để ý đến đối tượng FileWriter vừa tạo nữa. Lợi ích của việc sử dụng BufferWriter được giải thích như sau: Nếu chỉ dùng FileWriter, mỗi lần ta yêu cầu FileWriter ghi một chuỗi dữ liệu nào đó, chuỗi đó lập tức được đổ vào file. Chi phí về thời gian xử lý cho mỗi lần ghi file là rất lớn so với chi phí cho các thao tác trong bộ nhớ. Khi nối một dòng BufferWriter với một FileWriter, BufferWriter sẽ giữ những gì ta ghi vào nó cho đến khi đầy. Chỉ khi bộ nhớ đệm BufferWriter đầy thì FileWriter mới được lệnh ghi dữ liệu ra đĩa. Như vậy, ta tăng được hiệu quả về mặt thời gian của việc ghi dữ liệu do giảm số lần ghi đĩa cứng. Nếu ta muốn đổ dữ liệu ra đĩa trước khi bộ nhớ đệm đầy, ta có thể gọi writer.flush() để lập tức xả toàn bộ nội dung trong bộ nhớ đệm. 12.5. ĐỌC TỆP VĂN BẢN Đọc từ file văn b ản là công việc có quy trình tương tự ghi file, chỉ khác là giờ ta dùng một đối tượng FileReader để trực tiếp thực hiện công việc đọc file và một đối tượng BufferReader nối với nó để tăng hiệu quả đọc. Hình 12.6 là ví dụ đơn giản về việc đọc một file văn b ản. Trong đó, một đối tượng FileReader – một dòng kết nối cho dạng kí tự – được nối với một file để đọc trực tiếp. Tiếp theo là một đối tượng BufferReader được nối với FileReader để tăng hiệu quả đọc. Vòng while lặp đi lặp lại việc đọc một dòng từ BufferReader cho đến khi dòng đọc được là rỗng (tham chiếu null), đó là khi không còn gì để đọc nữa - đã chạm đến cuối file. 207
  65. Hình 12.6: Đọc file văn b ản. Như vậy với cách đọc này, ta đọc được dữ liệu dưới dạng các dòng văn b ản. Để tách các giá trị dữ liệu tại mỗi dòng, ta cần xử lý chuỗi theo định dạng mà dữ liệu gốc đã được ghi. Chẳng hạn, nếu dữ liệu là các chuỗi kí tự cách nhau bởi dấu phảy thì ta sẽ phải tìm vị trí của các dấu phảy để tách các giá trị dữ liệu ra. Phương thức split của lớp String cho phép ta làm điều này. Ví dụ sử dụng phương thức split được cho trong Hình 12.7. Có thể tra cứu chi tiết về phương thức này tại tài liệu Java API. 208
  66. Hình 12.7: Ví d ụ s ử d ụng ph ươ ng th ức split. 12.6. CÁC DÒNG VÀO/RA TRONG Java API Mục này trình bày lại một cách có hệ thống các kiến thức về thư viện vào ra dữ liệu của Java mà ta đã nói đến rải rác ở các mục trước. Nội dung mục này chỉ ở mức giới thiệu sơ qua về một số dòng vào ra quan trọng. Các chi tiết cần được tra cứu ở tài liệu Java API. Java coi mỗi file như là một dòng tuần tự các byte. Mỗi dòng như vậy có thể được hiểu là thuộc về một trong hai dạng: dòng kí tự (character-based stream ) dành cho vào ra dữ liệu dạng kí tự và dòng byte (byte-based stream ) dành cho dữ liệu dạng nhị phân. Ví dụ, nếu 5 được lưu với dòng byte, nó sẽ được lưu trữ ở dạng nhị phân của giá trị số 5, hay chuỗi bit 101. Còn nếu lưu bằng dòng kí tự, nó sẽ được lưu trữ ở dạng nhị phân của kí tự 5, hay chuỗi bit 00000000 00110101 (dạng nhị phân của giá trị 53, là mã Unicode của kí tự 5). File được tạo bằng dòng byte là file nhị phân, còn file được tạo bằng dòng kí tự là file văn bản. Con người có thể đọc nội dung file văn bản bằng các trình soạn thảo văn bản, còn các file nhị phân được đọc bởi các chương trình biến đổi dữ liệu nhị phân ra định dạng con người đọc được. Để trao đổi dữ liệu với một file hay một thiết bị, chương trình Java tạo một dòng kết nối và nối với file hay thiết bị đó. Ví dụ, ta đã có sẵn ba dòng: System.in là dòng vào chuẩn (thường nối với bàn phím), System.out là dòng ra chuẩn (thường nối với cửa sổ lệnh), và System.err là dòng báo lỗi chuẩn (luôn nối với cửa sổ lệnh). Các dòng dành cho việc xử lý dữ liệu nhị phân nằm trong hai cây phả hệ: các dòng có tổ tiên là InputStream để đọc dữ liệu, còn các dòng có tổ tiên là OutputStream để ghi dữ liệu. Các dòng cơ sở InputStream/OutputStream chỉ cung cấp các phương thức cho phép đọc/ghi dữ liệu thô ở dạng byte. Các lớp con của chúng cho phép đọc/ghi các giá trị thuộc các kiểu dữ liệu phức tạp hơn hoặc cho phép kết nối với các loại thiết bị cụ thể. Một số dòng quan trọng trong đó gồm có: 209
  67.  FileInputStream/FileOutputStream: dòng kết nối để nối trực tiếp với file nhị phân cần đọc/ghi theo dạng tuần tự.  ObjectInputStream/ObjectOutputStream: dòng nối tiếp, có thể nối với một InputStream/OutputStream khác. Các dòng này cho phép đọc/ghi từng đối tượng thuộc loại chuỗi hóa được.  DataInputStream/DataOutputStream: dòng nối tiếp, có thể nối với một InputStream/OutputStream khác, cho phép đọc/ghi các giá trị thuộc các kiểu cơ bản như int, long, boolean, (xem ví dụ trong Hình 12.8) Hình 12.8: Đọc và ghi d ữ li ệu ki ểu c ơ b ản. Các dòng dành cho việc xử lý dữ liệu văn bản nằm trong hai cây phả hệ: các dòng có tổ tiên là Reader đọc dữ liệu, còn các dòng có tổ tiên là Writer ghi dữ liệu. Các dòng cơ sở Reader/Writer chỉ cung cấp các phương thức cho phép đọc/ghi dữ liệu ở dạng char hoặc chuỗi char. Các lớp con của chúng cho phép đọc/ghi với hiệu 210
  68. quả cao hơn và cung cấp các tiện ích bổ sung. Một số dòng quan trọng trong đó gồm có:  FileReader/FileWriter: dòng kết nối để nối trực tiếp với file cần đọc/ghi dữ liệu văn bản theo dạng tuần tự. FileReader cho phép đọc String từ file. FileWriter cho phép ghi String ra file.  BufferedReader/BufferedWriter: dòng nối tiếp, có thể nối với một Reader/Writer khác để đọc/ghi văn bản với bộ nhớ đệm nhằm tăng tốc độ xử lý.  InputStreamReader/OutputStreamWriter : dòng nối tiếp, là cầu nối từ dòng kí tự tới dòng byte, có thể nối với một InputStream/OutputStream. Nó cho phép đọc/ghi dữ liệu dạng kí tự được mã hóa trong một dòng byte theo một bộ mã cho trước.  PrintWriter: cho phép ghi dữ liệu có định dạng ra dòng kí tự, có thể kết nối trực tiếp với File, String, hoặc nối tiếp với một Writer hay OutputStream. Ví dụ về InputStreamReader được cho trong Hình 12.9. Trong đó, kết nối Internet là nguồn dữ liệu dòng byte. Đầu tiên, nguồn vào được nối với một InputStream để có thể đọc dữ liệu byte thô. Sau đó, nó được nối với một InputStreamReader để chuyển từ dữ liệu byte sang dữ liệu văn bản. Cuối dùng, ta nối một BufferReader vào InputStreamReader để có thể đọc văn bản với tốc độ cao hơn. 211
  69. Hình 12.9: Đọc d ữ li ệu văn b ản từ k ết n ối Internet. Ví dụ về sử dụng dòng PrintWriter được cho trong Hình 12.10. Dòng này cung cấp các phương thức ghi dữ liệu ra tương tự như ta quen dùng với dòng System.out. Hình 12.10: Dùng PrintWriter. 212
  70. Đọc thêm Chương này nói về các nét cơ bản và nguyên lý sử dụng của dòng vào ra dữ liệu, chỉ dừng lại ở việc giới thiệu sơ lược chứ không đi sâu vào việc sử dụng vào ra dữ liệu sử dụng thư viện chuẩn Java. Để tìm hiểu sâu hơn về hỗ trợ của Java cho việc quản lí và vào ra dữ liệu file, người đọc có thể đọc thêm tại các tài liệu đi sâu vào nội dung lập trình Java như: 1. Basic I/O, The Java TM Tutorials, 2. Chương 14, Deitel & Deitel, Java How to Program , 6 th edition, Prentice Hall, 2005. Một chủ đề khá liên quan đến vào ra dữ liệu là lập trình mạng. Người đọc có thể đọc thêm về chủ đề này tại các tài liệu như: 1. Networking Basics, The Java TM Tutorials, 2. Chương 15, Sierra, Bert Bates, Head First Java , 2 nd edition, O'Reilly, 2008. 213
  71. Bài tập 1. Đúng hay sai? a) Chuỗi hóa là phương pháp thích hợp khi lưu dữ liệu cho các chương trình không được viết bằng Java sử dụng. b) Chuỗi hóa là cách duy nhất để lưu trạng thái của đối tượng c) Có thể dùng ObjectOutputStream để lưu các đối tượng được chuỗi hóa. d) Các dòng nối tiếp có thể được dùng riêng hoặc kết hợp với các dòng kết nối. e) Có thể dùng một lời gọi tới writeObject() có thể lưu nhiều đối tượng. f) Mặc định, tất cả các lớp đều thuộc diện chuỗi hóa được. g) Từ khóa transient đánh dấu các biến thực thể chuỗi hóa được h) nếu một lớp cha không chuỗi hóa được thì lớp con của nó cũng không thể chuỗi hóa được. i) Khi một đối tượng được khôi phục (khử chuỗi hóa), hàm khởi tạo của nó không chạy. j) Khi các đối tượng được khôi phục (khử chuỗi hóa), chúng được đọc theo thứ tự "ghi sau - đọc trước". k) Cả hai việc chuỗi hóa đối tượng và lưu ra file văn bản đều có thể ném ngoại lệ. l) BufferedWriter có thể nối với FileWriter. m) Các đối tượng File đại diện cho file chứ không đại diện cho thư mục n) Ta không thể buộc một buffer gửi dữ liệu của nó nếu nó chưa đầy. o) Thay đổi bất kì đối với một lớp sẽ phá hỏng các đối tượng của lớp đó đã được chuỗi hóa từ trước. 2. Viết lớp Contact mô tả một mục trong danh bạ điện thoại, các trường dữ liệu gồm: tên, địa chỉ, số điện thoại; lớp ContactList quản lý danh bạ điện thoại, là một danh sách các đối tượng Contact. Lớp ContactList cần cung cấp các phương thức cho phép thêm mục mới, xóa mục cũ trong danh bạ, lưu danh bạ ra file và nạp từ file. Dùng cơ chế cài chồng để cho phép sử dụng cả hai cơ chế chuỗi hóa đối tượng và dùng file văn bản. 214