[Học lập trình Java] Các bộ sưu tập (Collections)

Giới thiệu về các collection

Hầu hết các ứng dụng phần mềm của thế giới thực đều có liên quan đến các bộ sưu tập sự vật nào đó (các tệp, các biến, các dòng của tệp, …). Thông thường, các chương trình hướng đối tượng đều có liên quan đến bộ sưu tập các đối tượng. Ngôn ngữ Java có một Khung công tác các bộ sưu tập (Collections Framework) khá tinh vi cho phép bạn tạo và quản lý các bộ sưu tập đối tượng thuộc các kiểu khác nhau. Bản thân framework này đã có thể đủ để viết riêng nguyên cả một cuốn sách hướng dẫn, do đó chúng tôi sẽ không bàn tất cả trong tài liệu này. Thay vào đó, chúng tôi sẽ đề cập đến các collection thường dùng nhất và một vài kỹ thuật sử dụng nó. Những kỹ thuật đó áp dụng cho hầu hết các collection có trong ngôn ngữ Java.

Mảng

Hầu hết các ngôn ngữ lập trình đều có khái niệm mảng để chứa một bộ sưu tập các sự vật và Java cũng không ngoại lệ. Mảng thực chất là một bộ sưu tập các phần tử có cùng kiểu.

Có hai cách để khai báo một mảng:

  • Tạo một mảng có kích thước cố định và kích thước này không bao giờ thay đổi.
  • Tạo một mảng với một tập các giá trị ban đầu. Kích thước của tập giá trị này sẽ quyết định kích cỡ của mảng – nó sẽ vừa đủ lớn để chứa toàn bộ các giá trị đó. Sau đó thì kích cỡ này sẽ cố định mãi.

Nói chung, bạn khai báo một mảng như sau:

new  elementType [ arraySize ]

Để tạo một mảng số nguyên gồm có 5 phần tử, bạn phải thực hiện theo một trong hai cách sau:

int[] integers = new int[5];

int[] integers = new int[] { 1, 2, 3, 4, 5 };

Câu lệnh đầu tiên tạo một mảng rỗng gồm có 5 phần tử. Câu lệnh thứ hai là cách tắt để khởi tạo một mảng. Câu lệnh này cho phép bạn xác định một danh sách các giá trị khởi tạo, phân tách nhau bằng dấu phẩy (,), nằm trong cặp ngoặc nhọn. Chú ý là chúng ta không khai báo kích cỡ trong cặp ngoặc vuông – số các mục trong khối khởi tạo quyết định kích cỡ của mảng là 5 phần tử. Cách làm này dễ hơn là tạo một mảng rồi sau đó viết mã lệnh cho một vòng lặp để đặt các giá trị vào, giống như sau:

int[] integers = new int[5];

for (int i = 1; i <= integers.length; i++) {

          integers[i] = i;

          System.out.print(integers[i] + ” “);

}

Đoạn mã lệnh này cũng khai báo một mảng số nguyên có 5 phần tử. Nếu ta thử xếp nhiều hơn 5 phần tử vào mảng này, ta sẽ gặp ngay vấn đề khi chạy đoạn mã lệnh này. Để nạp mảng, chúng ta phải lặp đi qua các số nguyên từ 1 cho đến số bằng chiều dài của mảng, chiều dài của mảng ta có thể biết được nhờ truy cập phương thức length() của đối tượng mảng. Mỗi lần lặp qua mảng, chúng ta đặt một số nguyên vào mảng. Khi gặp số 5 thì dừng lại.

Khi mảng đã nạp xong, chúng ta có thể truy nhập vào các phần tử trong mảng nhờ vòng lặp tương tự:

for (int i = 0; i < integers.length; i++) {

          System.out.print(integers[i] + ” “);

}

Bạn hãy coi mảng như một dãy các khoang. Mỗi phần tử trong mảng nằm trong một khoang, mỗi khoang được gán một chỉ số khi bạn tạo mảng. Bạn truy nhập vào các phần tử nằm trong khoang cụ thể nào đó bằng cách viết:

 arrayName [ elementIndex ]

Chỉ số của mảng bắt đầu từ 0, có nghĩa là phần tử đầu tiên ở vị trí số 0. Điều đó làm cho vòng lặp thêm ý nghĩa. Chúng ta bắt đầu vòng lặp bằng số 0 vì mảng được đánh chỉ số bắt đầu từ 0 và chúng ta lặp qua từng phần tử trong mảng, in ra giá trị của từng chỉ số phần tử.

Collection là gì?

Mảng cũng tốt, nhưng làm việc với chúng cũng có đôi chút bất tiện. Nạp giá trị cho mảng cũng mất công, và một khi khai báo mảng, bạn chỉ có thể nạp vào mảng những phần tử đúng kiểu đã khai báo và với số lượng phần tử đúng bằng số lượng mà mảng có thể chứa. Mảng chắc chắn là không có vẻ hướng đối tượng lắm. Thực tế, lý do chính để Java có mảng là vì nó được giữ lại để dùng như di sản từ những ngày tiền lập trình hướng đối tượng. Mảng có trong mọi phần mềm, bởi vậy không có mảng sẽ khiến cho ngôn ngữ khó mà tồn tại trong thế giới thực, đặc biệt khi bạn phải tương tác với các hệ thống khác có dùng mảng. Nhưng Java cung cấp cho bạn nhiều công cụ để quản lý collection hơn. Những công cụ này thực sự rất hướng đối tượng.

Khái niệm collection không khó để có thể hiểu được. Khi bạn cần một số lượng cố định các phần tử có cùng kiểu, bạn có thể dùng mảng. Khi bạn cần các phần tử có kiểu khác nhau hoặc số lượng các phần tử có thể thay đổi linh hoạt, bạn dùng các Collection của Java.

ArrayList

Trong tài liệu này, chúng ta sẽ đề cập đến chỉ một dạng của collection, đó là ArrayList. Trong lúc trình bày, bạn sẽ biết được một lý do khác khiến cho nhiều người thuần túy chủ nghĩa hướng đối tượng công kích ngôn ngữ Java.

Để dùng ArrayList, bạn phải thêm một lệnh quan trọng vào lớp của mình:

import java.util.ArrayList;

Bạn khai báo một ArrayList rỗng như sau:

ArrayList referenceVariableName = new ArrayList();

Bổ sung và loại bỏ các phần tử trong danh sách khá dễ dàng. Có nhiều phương thức để làm điều ấy, nhưng có hai phương thức thường dùng nhất như sau:

someArrayList.add(someObject);

Object removedObject = someArrayList.remove(someObject);

Đóng hộp và mở hộp các kiểu nguyên thủy.

Các Collection của Java chứa các đối tượng, chứ không phải các kiểu nguyên thủy. Mảng có thể chứa cả hai, nhưng lại không hướng đối tượng như ta muốn. Nếu bạn muốn lưu trữ bất cứ kiểu gì là kiểu con của Object vào một danh sách, bạn đơn giản chỉ cần gọi một trong số nhiều phương thức của ArrayList để làm việc này. Cách đơn giản nhất là:

referenceVariableName.add(someObject);

Câu lệnh này thêm một đối tượng vào cuối danh sách. Cho đến đây mọi việc đều ổn. Nhưng liệu điều gì sẽ xảy ra khi bạn muốn thêm một kiểu nguyên thủy vào danh sách? Bạn không thể làm việc này trực tiếp. Thay vào đó, bạn phải bọc kiểu nguyên thủy thành đối tượng. Mỗi kiểu nguyên thủy có một lớp bao bọc tương ứng:

  • Boolean dành cho các Boolean
  • Byte dành cho các byte
  • Character dành cho các char
  • Integer dành cho các int
  • Short dành cho các short
  • Long dành cho các long
  • Float dành cho các float
  • Double dành cho các double

Ví dụ, để đưa kiểu nguyên thủy int vào một ArrayList, chúng ta sẽ phải viết mã lệnh như sau:

Integer boxedInt = new Integer(1);

someArrayList.add(boxedInt);

Bao bọc kiểu nguyên thủy trong một cá thể của lớp bao bọc (wrapper) cũng được gọi là thao tác đóng hộp (boxing) kiểu nguyên thủy. Để nhận lại kiểu nguyên thủy ban đầu bằng cách mở hộp (unboxing) nó. Có nhiều phương thức hữu dụng trong các lớp bao bọc, nhưng sử dụng chúng khá phiền toái đối với hầu hết các lập trình viên vì nó đòi hỏi nhiều thao tác phụ thêm để sử dụng kiểu nguyên thủy với các collection. Java 5.0 đã giảm bớt những vất vả ấy bằng cách hỗ trợ các thao tác đóng hộp/mở hộp tự động.

Sử dụng các collection

Trong thực tế, hầu hết người trưởng thành đều mang theo tiền. Giả sử các Adult đều có ví để đựng tiền của mình. Với hướng dẫn này, chúng ta sẽ giả sử rằng:

  • Chỉ các tờ giấy bạc là biểu hiện của tiền tệ
  • Mệnh giá của tờ giấy bạc (như một số nguyên) đồng nhất với tờ giấy bạc đó.
  • Tất cả tiền trong ví đều là đô la Mỹ.
  • Mỗi đối tượng Adult khởi đầu cuộc đời được lập trình rằng không có đồng tiền nào

Bạn nhớ mảng các số nguyên chứ? Thay vào đó ta hãy tạo một ArrayList. Import gói ArrayList, sau đó thêm một ArrayList vào lớp Adult ở cuối danh sách các biến cá thể khác:

protected ArrayList wallet = new ArrayList();

Chúng ta tạo một ArrayList và khởi tạo nó là danh sách rỗng vì đối tượng Adult phải kiếm từng đồng đô la. Chúng ta cũng có thể bổ sung thêm vài phương thức truy cập wallet nữa:

public ArrayList getWallet() {

          return wallet;

}

public void setWallet(ArrayList aWallet) {

          wallet = aWallet;

}

Cung cấp những phương thức truy cập nào là tùy theo óc suy xét, nhưng trong trường hợp này ta đi đến những phương thức truy cập thông thường. Chẳng có lý do gì mà chúng ta không thể gọi setWallet() giống như gọi resetWallet(), hay thậm chí là goBankrupt() vì chúng ta đang thiết đặt lại nó thành ArrayList rỗng. Liệu một đối tượng khác có thể thiết đặt lại wallet của chúng ta với một giá trị mới không? Một lần nữa ta lại phải viện đến óc xét đoán. Đó là những gì mà thiết kế hướng đối tượng tính đến (OOD)!

Bây giờ chúng ta sẽ thiết đặt mọi thứ để bổ sung một vài phương thức cho phép ta tương tác với wallet:

public void addMoney(int bill) {

          Integer boxedBill = new Integer(bill);

          wallet.add(boxedBill);

}

public void spendMoney(int bill) {

          Integer boxedBill = new Integer(bill);

          boolean haveThatBill = wallet.contains(boxedBill);

          if(haveThatBill) {

                   wallet.remove(boxedBill);

          } else {

                    System.out.println(“I don’t have that bill.”);

          }

}

Chúng ta sẽ nghiên cứu chi tiết hơn trong mấy phần tiếp theo đây.

Tương tác với các collection

Phương thức addMoney() cho phép chúng ta đưa thêm một tờ giấy bạc vào ví. Ta hãy nhớ lại rằng tờ giấy bạc của chúng ta ở đây chỉ đơn giản là những số nguyên. Để thêm chúng vào sưu tập, ta phải bao bọc một số kiểu int thành đối tượng Integer.

Phương thức spendMoney() lại sử dụng cách đóng hộp để kiểm tra tờ giấy bạc có trong wallet không bằng cách gọi contains(). Nếu ta có tờ giấy bạc đó, ta gọi remove() để lấy nó đi. Nếu ta không thực hiện thì ta cũng nói như vậy.

Hãy dùng các phương thức này trong main(). Thay thế nội dung hiện tại trong main() bằng nội dung sau:

public static void main(String[] args) {

          Adult myAdult = new Adult();

          myAdult.addMoney(5);

          myAdult.addMoney(1);

          myAdult.addMoney(10);

          StringBuffer bills = new StringBuffer();

          Iterator iterator = myAdult.getWallet().iterator();

          while (iterator.hasNext()) {

                   Integer boxedInteger = (Integer) iterator.next();

                   bills.append(boxedInteger);

          }

          System.out.println(bills.toString());

}

Cho đến thời điểm này ta thấy phương thức main() tổng hợp rất nhiều thứ. Đầu tiên, chúng ta gọi phương thức addMoney() vài lần để nhét tiền vào trong wallet. Sau đó ta lặp đi qua nội dung của wallet để in ra những gì có trong đó. Chúng ta dùng vòng lặp while để làm điều này, nhưng ta còn phải làm thêm một số việc nữa. Đó là:

  • Lấy một Iterator cho danh sách, nó sẽ giúp chúng ta truy nhập từng phần tử trong danh sách.
  • Gọi hasNext() của Iterator với vai trò biểu thức logic để chạy vòng lặp xem liệu ta có còn phần tử nào cần xử lý nữa không
  • Gọi next() của Iterator để lấy phần tử tiếp theo mỗi lần đi qua vòng lặp
  • Ép kiểu đối tượng trả về thành kiểu mà ta biết trong danh sách (trong trường hợp này là Integer)

Đó là cách diễn đạt chuẩn dành cho vòng lặp qua một sưu tập trong ngôn ngữ Java. Một cách làm khác là ta có thể gọi phương thức toArray() của danh sách và nhận lại một mảng, sau đó ta có thể lặp qua mảng này, sử dụng vòng for như ta đã làm với vòng while. Cách làm hướng đối tượng hơn là khai thác sức mạnh của khung công tác sưu tập của Java.

Khái niệm mới duy nhất ở đây là ý tưởng ép kiểu (casting). Đó là gì? Như ta đã biết, đối tượng trong ngôn ngữ Java có kiểu, hay là lớp. Nếu bạn nhìn vào chữ ký của phương thức next(), bạn sẽ thấy nó trả lại một Object, chứ không phải là một lớp con cụ thể của Object. Tất cả các đối tượng trong thế giới lập trình Java đều là lớp con của Object, nhưng Java cần biết kiểu chính xác của đối tượng để bạn có thể gọi các phương thức tương ứng với kiểu mà bạn muốn có. Nếu bạn không ép kiểu, bạn sẽ bị giới hạn chỉ được dùng các phương thức có sẵn dành cho Object, thực sự chỉ gồm một danh sách ngắn mà thôi. Trong ví dụ cụ thể này, chúng ta không cần gọi bất kỳ phương thức nào của Integer mà không có trong danh sách, nhưng nếu ta cần gọi thì ta phải ép kiểu trước đã.

Nhận xét