Chào mừng các bạn đến với bài học Java số 52, bài học về Generic, phần tiếp theo. Bài học này nằm trong chuỗi bài học lập trình ngôn ngữ Java của Yellow Code Books.
Hi vọng bài học hôm trước đã giúp cho các bạn có cái nhìn rõ ràng ban đầu về Generic, khiến bạn có hứng thú và tò mò hơn trong việc tìm hiểu xem Generic sẽ được sử dụng như thế nào trong code Java của các bạn. Mình sẽ giúp các bạn giải quyết sự tò mò đó qua bài học hôm nay. Bài học sẽ nói rõ hơn cách dùng Generic, vận dụng nó trong tổ chức xây dựng ở phương thức và cả cho lớp nữa. Và điều quan trọng hơn, có thể giúp các bạn mới làm quen với Generic đỡ “hoa mắt” hơn khi nhìn vào một mớ ký hiệu <> trong code.
Cách Khai Báo Generic
Khoan hãy nghĩ đến Generic được dùng ở đâu khi mà bạn còn chưa biết một vài nguyên tắc ban đầu trong việc khai báo chúng.
Các kiểu dữ liệu mới, hay như mình có nói với cái tên Typed parameter, sẽ được khai báo bên trong cặp ngoặc nhọn (< >), một số tài liệu gọi đây là ký tự diamond, bạn thấy nó giống viên kim cương không, mình thì thấy chả giống gì.
Cũng giống như đặt tên biến, bạn cũng cần đặt tên cho kiểu dữ liệu mới với một cái tên đại diện nào đó, chẳng hạn là T (quy ước đặt tên mình sẽ nói thêm bên dưới). Như ở ví dụ của bài trước, mới vừa làm quen đến Generic, bạn đã khai báo một kiểu dữ liệu <T>, điều này có nghĩa bạn đã định nghĩa cho hệ thống biết rằng bạn vừa mới “chế” ra một tập hợp các kiểu dữ liệu chung nào đó, mà tập hợp này bạn đặt tên là T, vậy đó.
Để thống nhất với nhau trong cách đặt tên, bạn nên dùng các chữ cái in hoa. Điều này vừa thể hiện sự ngắn gọn, vừa làm nổi bật kiểu dữ liệu mới lên, vừa không làm lập trình viên phân tâm, ảnh hưởng đến các dòng code logic khác. Hơn nữa Typed parameter này không đại diện cho các kiểu dữ liệu nguyên thủy, chính vì vậy chúng ta mới cần dùng đến chữ cái in hoa, thể hiện rằng nó là một lớp nào đó.
Về chữ cái, thì người ta hay dùng các chữ E, K, V, T, U, S. Trong đó E hay dùng để thể hiện viết tắt của từ Element, dùng để đại diện cho các kiểu dữ liệu của các phần tử trong collection. K và V hay dùng để đại diện cho các kiểu cho Key và Value dùng trong table, nếu bạn chưa từng dùng đến kiểu table này thì từ từ nhé, mình cũng sẽ nói đến nó sớm thôi. Các chữ cái còn lại sẽ dùng cho các mục đích còn lại khác, chẳng hạn code của bài hôm trước, mình dùng chữ T để đại diện. Nếu trong một phương thức hay một lớp có nhiều hơn một kiểu dữ liệu được định nghĩa mới, thì U và S sẽ được dùng tiếp theo.
Ngoài các chữ cái mình liệt kê trên đây, nếu bạn có nhu cầu phát sinh các kiểu dữ liệu cần được chuyển sang dùng Generic, thì bạn cứ đặt tên bằng các chữ cái khác mà bạn nghĩ ra cũng được nhé. Chẳng hạn I nếu bạn nghĩ rằng muốn dùng cho kiểu Info nào đó, hay N dùng cho kiểu Number chẳng hạn.
Chú ý về cách đặt tên cho kiểu dữ liệu trong Generic
Phương Thức Generic
Lý thuyết chung cho việc bắt đầu dùng Generic là như vậy. Giờ thì chúng ta sẽ cùng nói rõ hơn về việc sử dụng Generic cho các phương thức.
Khai Báo
Để khai báo kiểu dữ liệu mới được dùng cho phương thức, bạn định nghĩa Typed parameter này ngay sau khai báo khả năng truy cập của phương thức. Trong trường hợp ví dụ dưới đây, <T> được khai báo sau từ khóa public.
public <T> void printCoordinate(T latitude, T longitude) { System.out.println("(" + latitude + ", " + longitude + ")"); }
Bạn nên nhìn qua nhìn lại một chút khai báo phương thức printCoordinate() như trên. Nếu lần đầu tiên tiếp cận với Generic, bạn sẽ hơi rối mắt xíu đấy, nhưng khi thường xuyên sử dụng, bạn sẽ quen dần thôi.
Phương thức trên cũng bình thường như các phương thức mà bạn đã biết khác, chỉ khác một chỗ tham số truyền vào phương thức lúc này là hai phần tử thuộc kiểu dữ liệu T nào đó. T được định nghĩa bên trong khai báo <T> sau từ khóa public. Bạn nên nhớ phải khai báo <T> trước khi dùng kiểu dữ liệu này cho hai tham số latitude và longitude nhé.
Bạn hãy thử gõ phương thức trên vào project Java của bạn để thấy rõ ràng hiệu ứng, hãy thoải mái xóa bỏ khai báo <T> hay thay đổi T bằng một chữ cái khác để xem hệ thống báo lỗi và đáp ứng như thế nào, bạn sẽ học được vài bài học từ việc mày mò ban đầu này đấy.
Nhắc bạn
Với các phương thức cần trả về kết quả là một Typed parameter thì sao, thì bạn cứ thay void như printCoordinate() trên kia bằng kiểu dữ liệu mới thôi. Như sau.
public <T> T getFirst(T[] a) { return a[0]; }
Phương thức getFist() cũng có một kiểu T được định nghĩa bên trong khai báo <T> sau từ khóa public. getFirst() nhận tham số đầu vào là một mảng các T. Hơn nữa, nó lại trả về một kiểu T. Chính vì vậy mà bên trong phương thức này, nó phải return một kiểu T, chính là một phần tử bên trong mảng các T này, phần tử đầu tiên, a[0]. Một lần nữa, bạn hãy tập nhìn cho kỹ rồi thử code lại ở phía bạn, chỉnh sửa một chút để học hỏi thêm từ chính kinh nghiệm của các bạn nữa nhé.
Trong trường hợp bạn muốn khai báo nhiều hơn một Typed parameter cho phương thức thì sao. Khi này bạn cứ thoải mái dùng nhiều chữ cái bên trong cặp ngoặc nhọn, và cách nhau giữa chúng bằng dấu phẩy, như phương thức sau.
public <T, U> boolean isValidValues(T latitude, T longitude, U[] a) { if (latitude == null || longitude == null || a == null || a.length <= 0) { return false; } return true; }
Phương thức isValidValues() nhận vào hai tham số latitude và longitude cùng kiểu dữ liệu T, còn tham số mảng a thì là kiểu dữ liệu U. isValidValues() sẽ trả về false nếu một trong các giá trị truyền vào không thể dùng được, như mang giá trị null hoặc mảng rỗng, ngược lại nó sẽ trả về true. Ví dụ này cho thấy cách khai báo nhiều Typed parameter trong một phương thức thôi, chứ thực ra hai kiểu T và U ở phương thức ví dụ này của mình chưa thực sự rõ ràng về nhu cầu phải sử dụng hai kiểu tách biệt như thế, đến phần lớp Generic dưới đây bạn sẽ thấy rõ ràng hơn nhu cầu cần đến hai kiểu dữ liệu trong cùng một lớp hay phương thức.
Sử Dụng
Việc sử dụng một phương thức Generic cũng gần giống như sử dụng một phương thức bình thường vậy.
Hãy thử với printCoordinate() trên kia.
public static <T> void printCoordinate(T latitude, T longitude) { System.out.println("(" + latitude + ", " + longitude + ")"); } public static void main(String[] args) { System.out.print("Tọa độ 1: "); printCoordinate(10.78838, 106.64811); System.out.print("Tọa độ 2: "); printCoordinate("10.79226", "106.69437"); } // Kết quả // Tọa độ 1: (10.78838, 106.64811) // Tọa độ 2: (10.79226, 106.69437)
Do được gọi đến từ phương thức main() – Là một phương thức static – Nên printCoordinate() khi này cũng phải trở thành phương thức static luôn (điều này mình có nói sơ qua ở mục này rồi nhé). Ngoài việc thêm từ khóa static vào phương thức ra, và <T> khi này phải nằm sau từ khóa static luôn, thì không có gì khác ảnh hưởng đến kiến thức về Generic mà chúng ta đang nói đến đâu nhé.
Quay lại printCoordinate(), khi bạn sử dụng đến nó, bạn thấy rằng bạn có thể truyền vào phương thức này bất cứ dữ liệu gì, nó cũng đều chịu. Và khi này bạn không cần phải chỉ ra kiểu dữ liệu đang dùng cho tham số của phương thức, hệ thống sẽ tự hiểu và suy luận ra giúp bạn kiểu dữ liệu cụ thể khi thực thi. Ví dụ lời gọi printCoordinate(10.78838, 106.64811) thì hệ thống hiểu rằng bạn đang muốn truyền vào hai tham số kiểu Double, nó sẽ chuyển từ định nghĩa chung T của bạn thành Double và sử dụng tham số. Hay lời gọi printCoordinate(“10.79226”, “106.69437”) sẽ hiểu rằng bạn đang sử dụng String thay cho T.
Giờ thì chúng ta qua đến phương thức getFirst().
public static <T> T getFirst(T[] a) { return a[0]; } public static void main(String[] args) { Integer[] arr = new Integer[] { 10, 20, 6 }; Integer first = getFirst(arr); System.out.println("Giá trị đầu tiên: " + first); } // Kết quả // Giá trị đầu tiên: 10
Cũng giống như printCoordinate(), khi bạn truyền vào phương thức này một mảng các Integer, tuy không nói gì với hệ thống cả, nhưng khi này hệ thống sẽ tự suy luận ra rằng bạn muốn thay thế T bằng kiểu Integer vậy thôi. Nếu bạn trắc nghiệm hệ thống, thử truyền vào một mảng Integer nhưng nhận về một kiểu khác xem, bạn sẽ thấy hệ thống nhận ra và báo lỗi ngay, cái hay của Generic là vậy.
Lớp Generic
Khai báo
Nếu bạn đã nhìn quen một chút với phương thức Generic, thì lớp Generic cũng được khai báo và sử dụng gần như vậy. Để khai báo kiểu dữ liệu được dùng cho lớp, bạn cũng định nghĩa Typed parameter ngay sau tên lớp. Với việc khai báo thêm kiểu dữ liệu này ở ngay đầu mỗi lớp, thì các thuộc tính và phương thức bên trong lớp đó sẽ không cần định nghĩa lại các kiểu dữ liệu này nữa.
Chúng ta hãy cùng xem việc khai báo một lớp Pair như sau.
public class Pair<T> { private T first; private T second; public Pair() { first = null; second = null; } public Pair(T first, T second) { this.first = first; this.second = second; } public T getFirst() { return first; } public void setFirst(T first) { this.first = first; } public T getSecond() { return second; } public void setSecond(T second) { this.second = second; } }
Bạn có thể thấy lớp Pair định nghĩa thêm một kiểu dữ liệu gọi là T. Khi này các thuộc tính của nó, như first và second đều có thể xem T là một kiểu dữ liệu mới mà không cần định nghĩa lại. Hơn nữa các phương thức cũng có thể khai báo T như là tham số truyền vào, như setFirst(T first), hay trả về kết quả là T mà cũng chẳng cần phải định nghĩa thêm như việc sử dụng phương thức Generic đơn lẻ trên kia.
Qua đó bạn có thể hiểu, Pair này được dựng lên để chứa một cặp giá trị (first và second) với kiểu dữ liệu bất kỳ. Nó chỉ dùng để lưu trữ vậy thôi, nó có các phương thức khởi tạo để gán giá trị ban đầu cho cặp giá trị này, kèm với các phương thức getter/setter để nhận và trả từng giá trị cụ thể của cặp giá trị này mà thôi.
Trong trường hợp bạn muốn khai báo nhiều hơn một Typed parameter cho lớp Generic này thì bạn cứ khai báo y như với phương thức Generic trên kia vậy, như sau (nhưng chúng ta không nói về Pair<T, U> này ngay mục này đâu nhé mà hãy dành cho bài thực hành ở cuối bài học).
public class Pair<T, U> { ... }
Sử Dụng
Bạn có thể nghĩ rằng việc sử dụng Pair<T> cũng đơn giản như sử dụng phương thức Generic trên kia đúng không nào, ví dụ bạn có thể new lớp Pair này như sau.
Pair pairString = new Pair(); Pair pairInteger = new Pair(15, 20);
Vâng bạn đã đúng rồi đó, trong hầu hết các trường hợp, bạn khởi tạo các lớp Pair như trên không có bất kỳ lỗi nào từ trình biên dịch cả, hệ thống sẽ tự suy luận ra kiểu dữ liệu thay thế cho T là gì. Nhưng với một lớp, việc định nghĩa ra một kiểu T rồi sau đó dùng cho khá nhiều các thuộc tính hay phương thức bên trong lớp đó khiến nó cần được khởi tạo rõ ràng hơn. Do đó việc chỉ định tường minh kiểu dữ liệu mà bạn mong muốn khi khởi tạo một lớp Generic nhiều khi cũng khá cần thiết. Bạn nên sửa lại việc khởi tạo hai lớp Pair trên kia như sau.
Pair<String> pairString = new Pair<String>(); Pair<Integer> pairInteger = new Pair<Integer>(15, 20);
Khai báo tường minh thì tốt, nhưng khai báo như trên này lại dư thừa, như bạn thấy với pairString bạn có tới hai <String> trong một dòng khởi tạo, do đó để ngắn gọn hơn, bạn nên bỏ bớt một String ở sau đi. Rốt lại thì chỉ còn như này thôi.
Pair<String> pairString = new Pair<>(); Pair<Integer> pairInteger = new Pair<>(15, 20);
Đến đây thì bạn đã cơ bản hiểu và sử dụng được một vài khai báo kiểu dữ liệu Generic vào project của bạn được rồi đấy. Để tăng thêm tính ứng dụng và nhìn thấy rõ hơn sự kết hợp giữa phương thức Generic và lớp Generic, thì chúng ta cùng đến với vài bài thực hành nho nhỏ sau đây.
Bài Thực Hành Số 1: Ứng Dụng Lớp Pair<T> Để Chứa Giá Trị Lớn Nhất & Nhỏ Nhất Trong Mảng
Chúng ta cùng sử dụng lại lớp Pair<T> đã khai báo trên kia. Áp dụng vào bài toán muốn tìm ra giá trị lớn nhất và nhỏ nhất trong mảng.
Bạn có thấy quen không, thực ra code ví dụ của phần trước, phương thức minFromTwoNumbers() đã giúp bạn tự chế ra một phương thức minFromArray() có sử dụng Generic được rồi đúng không nào. Rồi sau đó bạn xây dựng thêm maxFromArray() nữa là đã có thể xong bài thực hành này rồi. Tuy nhiên để tăng tính thực tế, mình sẽ xây dựng một phương thức thôi, có tên minMaxFromArray(), thay vì trả về hoặc giá trị min hoặc giá trị max ở từng phương thức, thì phương thức tổng hợp này sẽ lưu hai giá trị min và max này vào trong một lớp Pair mà chúng ta đã xây dựng trên kia. Thú vị không nào.
Phương thức minMaxFromArray() như sau.
public static <T extends Comparable> Pair<T> minMaxFromArray(T[] arr) { T min = arr[0]; T max = arr[0]; for (int i = 0; i < arr.length; i++) { if (min.compareTo(arr[i]) > 0) min = arr[i]; if (max.compareTo(arr[i]) < 0) max = arr[i]; } return new Pair<>(min, max); }
Mình mượn lại việc khai báo kiểu dữ liệu <T> là <T extends Comparable> như bài hôm trước. Mình sẽ giải thích các khai báo kiểu này ở bài sau. Đại loại với cách khai báo như vậy thì chúng ta sẽ dùng được phương thức khá hữu hiệu của T (lúc này T là con của Comparable) đó là compareTo().
Qua đó bạn có thể thấy Pair là một lớp Generic, lớp này cho phép làm việc với một kiểu dữ liệu mới toanh là T. Còn minMaxFromArray() là phương thức Generic, vì bản thân phương thức này không có nằm trong một lớp Generic nào, nó tự định nghĩa ra một kiểu dữ liệu T luôn.
Sau đây là code ở main() sử dụng các thành phần chúng ta đã định nghĩa ra.
public static void main(String[] args) { String[] arrStr = new String[] { "Một", "Hai", "Ba", "Bốn", "Năm", "Sáu", "Bảy" }; Pair<String> pairStr = minMaxFromArray(arrStr); System.out.println("Giá trị nhỏ nhất của màng String là " + pairStr.getFirst() + "; Lớn nhất là: " + pairStr.getSecond()); Integer[] arrInt = new Integer[] { 5, -19, 40, 33, 25, -7 }; Pair<Integer> pairInt = minMaxFromArray(arrInt); System.out.println("Giá trị nhỏ nhất của mảng Integer là " + pairInt.getFirst() + "; Lớn nhất là: " + pairInt.getSecond()); } // Kết quả // Giá trị nhỏ nhất của màng String là Ba; Lớn nhất là: Sáu // Giá trị nhỏ nhất của mảng Integer là -19; Lớn nhất là: 40
Bài Thực Hành Số 2: Xây Dựng Ứng Dụng Từ Điển
Nói xây dựng ứng dụng từ điển thì nghe ghê quá. Nhưng lớp Dictionary mà bạn sắp làm quen đây cũng có thể giúp bạn có thêm một ý tưởng để xây dựng một ứng dụng từ điển hoàn chỉnh cho riêng bạn chăng.
Đầu tiên, từ điển thì cần phải chứa từng từ cần tra và nghĩa của từ đó. Chúng ta xây dựng lớp Word chứa cả từ cần tra và nghĩa của nó ngay trong một lớp. Chúng ta gọi từ cần tra là key, và nghĩa của nó là value cho nó ngắn gọn. Dĩ nhiên key chưa chắc phải là String và value cũng vậy, vì biết đâu sau này bạn nâng cấp từ điển này sao cho chứa các kiểu dữ liệu nào đó khác thì sao. Do đó chúng ta áp dụng lớp Generic vào Word như sau.
public class Word<K, V> { private K key; private V value; public Word(K key, V value) { this.key = key; this.value = value; } public K getKey() { return key; } public void setKey(K key) { this.key = key; } public V getValue() { return value; } public void setValue(V value) { this.value = value; } // Phương thức giúp so sánh key này với key nào đó khác public boolean isKeyEquals(K anotherKey) { return (this.key.equals(anotherKey)); } }
Lần này Word cần hai Typed parameter là K và V đại diện cho key và value. Các phương thức của nó cũng không có gì quá khó khăn đúng không nào. Mình thêm vào một phương thức isKeyEquals() giúp nó so sánh key của nó với anotherKey mà chúng ta sẽ dùng sau.
Chúng ta cần một lớp Dictionary để tổ chức lưu trữ các Word, đồng thời xây dựng giải thuật tra từ ở đây.
public class Dictionary<K, V> { private Word<K, V>[] words; public Dictionary(Word<K, V>[] words) { this.words = words; } public V findWord(K keySearch) { for (Word<K, V> word : words) { if (word.isKeyEquals(keySearch)) { return word.getValue(); } } return null; } }
Bạn có thể thấy Dictionary cũng cần khai báo hai kiểu K và V để vào trong nó còn làm việc với Word<K, V> nữa chứ. Bạn có thể cũng nhận ra phương thức findWord() là phương thức tra từ trong từ điển, cơ mà lại dùng vòng lặp mà duyệt như thế này thì ứng dụng từ điển sẽ chạy chậm lắm đấy. Bạn đã nghĩ đúng, đây chỉ là code mẫu để chúng ta làm quen với Generic thôi. Nếu bạn muốn xây dựng một ứng dụng từ điển thực sự thì bạn phải nghĩ đến một giải thuật khác để lưu trữ hay tra từ. “Bảng băm” là một từ khóa mà bạn có thể nghĩ ra cho một ứng dụng từ điển, tuy nhiên chúng ta không nói dông dài vào cấu trúc dữ liệu này, mình chỉ gợi ý là Java cũng có hỗ trợ các kiểu dữ liệu cao cấp là HashMap và HashTable mà bạn có thể xem qua để tham khảo và học hỏi, chúng ta sẽ cùng tìm hiểu các kiểu dữ liệu này sau.
Tổ chức xong rồi, sau đây là code ở main(). Mình dùng lại vòng lặp do while để giúp người dùng nhập vào từng từ muốn tra cho đến khi người dùng không còn muốn dùng ứng dụng của chúng ta nữa thì cứ nhấn “q” hoặc “Q” ở console để thoát. À mà từ điển mình muốn thử là từ điển về các ngôn ngữ lập trình nhé.
public static void main(String[] args) { // Khai báo dữ liệu cho từ điển Word<String, String>[] words = new Word[] { new Word<>("Java", "Là một ngôn ngữ lập trình cấp cao, hướng đối tượng mà bạn đang học"), new Word<>("Kotlin", "Là một ngôn ngữ lập trình đa nền tảng, tương thích hoàn toàn với Java, nếu thích bạn cứ học."), new Word<>("C", "Là ngôn ngữ lập trình kinh điển trong các trường học."), new Word<>("Objective-C", "Ngôn ngữ được dùng để viết ứng dụng trên các thiết bị của Apple."), new Word<>("Swift", "Là ngôn ngữ được dùng để thay thế cho Objective-C."), }; // Nạp dữ liệu vào từ điển thông qua phương thức khởi tạo Dictionary<String, String> dictionary = new Dictionary<>(words); Scanner scanner = new Scanner(System.in); String language; do { // Lặp đến khi language là "q" hoặc "Q" System.out.print("Nhập ngôn ngữ bạn muốn biết, nhấn Q để thoát: "); language = scanner.nextLine(); // Tra từ String result = dictionary.findWord(language); if (result != null) { // In kết quả nếu tra ra từ System.out.println(result); } else { // Không tìm thấy từ cần tra System.out.println("Chưa có dữ liệu về ngôn ngữ bạn cần"); } } while (language != null && !language.equalsIgnoreCase("q")); }
Bạn có thấy Word<String, String> và Dictionary<String, String> khi được sử dụng nên khai báo tường minh kiểu dữ liệu cụ thể mà nó cần dùng, khi này K và V từ khai báo nay đã cụ thể thành String và String rồi đúng không nào.
Dưới đây là kết quả thực thi chương trình.
Kết Luận
Qua bài học này các bạn đã biết Generic được sử dụng trong một phương thức riêng lẽ, hay trong một tổ chức lớp như thế nào. Với các bạn mới làm quen với Generic, hi vọng các bạn cũng bắt đầu nhìn “quen mắt” hơn kiểu cấu trúc này. Chúng ta sẽ cùng nói sâu hơn vể Generic ở các bài học sắp tới.
Cảm ơn bạn đã đọc các bài viết của Yellow Code Books. Bạn hãy ủng hộ blog bằng cách:
– Đánh giá 5 sao ở mỗi bài viết nếu thấy thích.
– Comment bên dưới mỗi bài viết nếu có thắc mắc.
– Để lại địa chỉ email của bạn ở thanh bên phải để nhận được thông báo sớm nhất khi có bài viết mới.
– Chia sẻ các bài viết của Yellow Code Books đến nhiều người khác.
– Ủng hộ blog theo hướng dẫn ở thanh bên phải để blog ngày càng phát triển hơn.
U là trời, lâu quá rồi mới thấy add tái xuất.
Loạt bài viết hay nhất về học java được viết bằng tiếng việt.
thanks bạn nhiều nhé.
Mình đang chờ đợi bài số 53 và các bài sau đây ạ
sr bạn tui vote nhầm h sửa k đc
ad làm về phần lập trình giao diện thử xem
Cảm ơn bạn, cách viết bài rất hay và dễ hiểu. Mình đã đọc nhiều tài liệu Tiếng Việt nhưng đối với mình đây là trang tài liệu về Java dễ hiểu và hệ thống nhất. Hi vọng bạn sẽ có thêm nhiều series nữa