post-image

Bí mật đằng sau tính năng Generic trong Java

Generics

Generic trong ngôn ngữ Java hay còn gọi là “Tham số hóa kiểu dữ liệu”  là một tính năng vô cùng mạnh mẽ mà chắc hẳn ai đã học ngôn ngữ Java đều phải nắm rõ để có thể sử dụng một cách thuần thục cũng như phát huy hết thế mạnh của tính năng này. Bài viết này mình không có tham vọng có thể trình bày hết hoàn toàn mọi vấn đề về Generic một phần vì kiến thức chưa thể tìm hiểu hết được, phần khác nữa là trong giới hạn bài viết cũng không thể truyền đạt hết mọi khía cạnh của chủ đề này.

Generic trong Java bao gồm cả Wildcards với cú pháp như sau:

LinkedList (Collection<? Extends E>  c)

Tuy nhiên bài viết này chúng ta chỉ đề cập đến những vấn đề nền tảng nhất của Generic được sử dụng trong Class và Method. Generic bản chất chính là kiểu được tham số hóa (parameterized type). Chúng ta có thể sử dụng generic trong lớp (Class) hoặc cũng có thể trong phương thức (Method). mình sẽ chỉ cho các bạn biết cách sử dụng generic như thế nào và cũng đồng thời tìm hiểu xem bản chất đằng sau những câu lệnh khô cứng thực sự điều gì đã xảy ra.

Generic trong lớp (Generic Class)

Nhiều người băn khoăn tại sao lại cần có thêm tính năng Generic khi mà tính đa hình (polymorphism) trong ngôn ngữ hướng đối tượng như Java cũng có thể cung cấp đặc tính chung chung. Cụ thể là khi bạn viết một phương thức lấy 1 đối số là đối tượng có kiểu là kiểu cha (base class) sau đó bất cứ chỗ nào đó mà cần đến đối tượng có kiểu là kiểu cha (cơ sở) thì bạn cũng có thể thoải mái thay thế bằng kiểu con của nó, hay thậm chí với Interface bạn cũng có thể thu được tính “chung chung” như vậy vì với những chỗ bạn cần Object kiểu Interface thì bạn cũng có thể thay thế bằng bất cứ kiểu nào mà triển khai Interface đó. Tuy nhiên kể cả có sử dụng Interface hay tính kế thừa cha con thì bạn cũng vẫn bị hạn chế vào 1 kiểu Class hay Interface cụ thể (vì bắt buộc phải là kiểu con của kiểu cha hay kiểu triển khai Interface đó). Do đó Generic sẽ giải quyết hoàn toàn những vấn đề này cho bạn vì nó không ràng buộc bạn vào 1 kiểu cụ thể nào mà lại không làm mất đi tính đa hình (polymorphism) của Java.

Cách khai báo một lớp là Generic rất đơn giản bạn chỉ cần sử dụng vài ký tự viết hoa như sau: T, V, K, U, V … khai báo trong cặp ngoặc nhọn và đứng ngay đằng sau tên lớp.

Ví dụ:

public class Student<T> {
     private T a;     
     public Student(T a) {this.a = a;}
     public void set(T a) { this.a = a; }
     public T get() { return a; }
}

Như vậy là chúng ta đã có lớp chung chung (Generic Class) sau này, những chương trình Client hoàn toàn có thể truyền bất kì 1 kiểu cụ thể nào vào để sử dụng trong lớp Student đều được. Ví dụ:

public class Test {
   	public static void main(String[] args) {
          // Kiểu được xây dựng sẵn trong Java: String, Integer, Double,...
          Student<String> st = new Student<String>(“Cao Xuan Quy”);
          // Hoặc cũng có thể là kiểu mà bạn tự định nghĩa:
          Student<NameClass> st2 = new Student<NameClass>(“Name Class”);
     }
}

Chú ý đặc biệt:

Generic hoàn toàn không làm việc với kiểu căn bản (Primitive type) ví dụ: int, float, double …mà chúng chỉ làm việc với kiểu ĐỐI TƯỢNG cũng có thể do người dùng tự định nghĩa hoặc kiểu bao đóng Wrapper Type (là những kiểu Integer, Double, Float). Do có cơ chế tự động bao đóng (autoboxing) nên khi bạn sử dụng kiểu cơ bản int trong đoạn Code nằm trong lớp Generic  thì điều thực sự diễn ra là Java trước hết sẽ “bao đóng” int thành kiểu Integer sau đó mới có thể sử dụng kiểu Integer để làm việc được với Generic.

Giờ để hiểu rõ về lợi ích khi dùng Generic bạn hãy xem đoạn code sau:


import java.util.*;
class A {}
class B extends A{
      public void methodB() {}
}
class C {}
public class Test {
      public static void main(String[] args) {
             List list = new ArrayList();
             list.add(new A());
             list.add(new B());
             list.add(new C());
             ((B)list.get(0).methodB(); // OK compile time
             ((B)list.get(1).methodB(); // OK compile time
             ((B)list.get(2).methodB(); // OK compile time
        }
}

Bạn có thể tự code đoạn code trên trong IDE, bạn sẽ thấy rằng IDE hoàn toàn có thể biên dịch được chương trình này mà không báo lỗi nào (Compile Time) nhưng khi bạn chạy chương trình (Run Time) thì thật đau đớn khi biệt lệ đã được quăng ra ngay tức thì (java.lang.ClassCastException). Bạn có thể hiểu tại sao lại thế không, câu trả lời là khi bạn khai báo biến list có kiểu là List tức là cũng ngấm ngầm chỉ ra là List này có thể chứa bất cứ kiểu gì là kiểu con của kiểu Object mà trong java thì bất cứ kiểu nào thì cũng được coi là kiểu con của kiểu Object. Cho nên bạn có thể thoải mái gán bất kỳ đối tượng nào có kiểu bất kỳ vào List mà không gặp phải lỗi nào, sau đó bạn có thể gọi hàm methodB() trên bất kỳ đối tượng nào được ép kiểu về kiểu B mà lấy ra từ List cũng không bị báo lỗi vì thực sự thì với List nó không biết được là nó đang chứa chính xác đối tượng có kiểu gì vì với nó đối tượng nào cũng đều là kiểu Object hết. Nhưng khi chạy chương trình (tức là thời điểm Runtime) thì biệt lệ mới quăng ra vì lúc này JVM mới phát hiện được là bạn đang gọi phương thức methodB() trên 1 đối tượng mà không phải là kiểu B hơn nưa là bạn đang thực hiện ép kiểu C sang kiểu B (hoàn toàn bất hợp lệ). Do đó bạn cần một cơ chế để ngăn chặn những lỗi kiểu này và Generic đáp ứng hoàn toàn việc ngăn chặn lỗi kiểu này bằng cách bắt buộc phải truyền đúng kiểu cụ thể khi gán đối tượng vào List để sau này khi lấy đối tượng từ trong List thì tránh được việc ép kiểu sai đối tượng. 

public class Test {
      public static void main(String[] args) {
             List<A> list = new ArrayList<A>();
             list.add(new A()); // OK
             list.add(new B()); // OK
             // list.add(new C()); Không hợp lệ vì C không phải là kiểu con của A
              for (A item : list) {
                  if (item instanceof B) 
                      ((B)item).methodB();
              }
      }
}

Chương trình trên sử dụng Generic trong List ở đây List<A> được khai báo cụ thể là chỉ chấp nhận đối tượng có kiểu A hoặc con của A mà thôi do đó khi bạn truyền đối Tượng C vào thì IDE sẽ báo lỗi ngay tại thời điểm biên dịch (Compile Time) và tại thời điểm Runtime chương trình cũng chạy ổn vì lúc đó nó biết chắc chắn chỉ có kiểu A hoặc con của A (B) trong nó mà thôi nên khi lấy đối tượng ngược trở lại bạn có thể dùng toán tử instanceof để kiểm tra xem đó có phải là đối tượng B hay ko để gọi hàm method B(). Như vậy nhờ Generic chúng ta đã loại bỏ hoàn toàn được lỗi ép kiểu sai ngay từ thời điểm biên dịch điều này sẽ tránh được những bug có thể xảy ra tại thời điểm Runtime.

Phương thức Generic (Generic Method)

Một lời khuyên của mình là bạn nên sử dụng phương thức generic bất cứ khi nào có thể. Chúng ta có thể chung chung hóa 1 phương thức cụ thể thay vì chung chung hóa toàn bộ một lớp. Generic method có thể nằm trong 1 lớp cũng là generic hay thậm chí lớp không cần phải là generic. Việc bạn chỉ tham biến hóa 1 phương thức thay cho cả lớp sẽ khiến cho việc bạn thay đổi phương thức đó 1 cách linh hoạt hơn mà không phải phụ thuộc vào lớp mà phương thức được khai báo.

Cách khai báo phương thức Generic như sau:

Chỉ đơn giản là đặt danh sách các kiểu được tham biến hóa vào phía trước của kiểu trả về của phương thức. Ví dụ:

public class GenericMethod {
          public <T> void f(T x) {
          System.out.println(“Đây là phương thức generic”);
     }
}

Sau đó bạn có truyền đối số có kiểu bất kỳ vào hàm f(T x) (tất nhiên là trừ các kiểu cơ bản).

Một số vấn đề với việc khởi tạo đối tượng có kiểu là Generic và mảng generic

Bây giờ mình sẽ chỉ cho các bạn cách làm thế nào để khởi tạo được 1 đối tượng Generic hay thậm chí là 1 mảng kiểu Generic.

A/ Bạn đang băn khoăn là nếu có Generic Class (kiểu được tham biến hóa) rồi thì có thể khởi tạo được đối tượng cụ thể của nó hay không. Câu trả lời là có thể nhưng tất nhiên không phải khởi tạo theo cách thông thường (T obj = new T(); // Error không hợp lệ).

Ẩn đằng sau cơ chế hoạt động của generic là sự tẩy xóa thông tin (Erased Information), nguyên nhân không khởi tạo Object Generic theo cách thông thường là bởi vì cho dù bạn có khai báo 1 kiểu là Generic thì khi chương trình thực thi cơ chế “Tẩy xóa” Erase sẽ xóa đi toàn bộ thông tin về kiểu do đó nó sẽ không có thông tin để có thể thực hiện việc khởi tạo đối tượng mới bằng câu lệnh : T obj = new T(). Mặt khác cũng là vì nó không biết được là đối tượng T có Constructor mặc định hay không để mà khởi tạo. Do đó chúng ta không thể sử dụng cách thức tạo như tạo đối tượng thông thường được.

Vậy thì không có cách nào sao ??? Khoan đã hãy thử suy nghĩ xem nào. À như mình vừa nói ở trên là do trong quá trình thực thi toàn bộ thông tin của kiểu đã bị mất đi do cơ chế “Tẩy xóa” Erase, vậy thì ta hãy thử tìm cách nào đó đưa thông tin về kiểu cho chương trình để xem chương trình có dựa vào thông tin đó mà khởi tạo cho ta 1 đối tượng Generic được hay không. Vấn đề ở đây là làm thế nào để có được thông tin đó. Có, chắc chắn là phải có 1 cách nào đó, và câu trả lời ở đây chính là “Thông tin kiểu” (Type Information), thông tin kiểu có được là thông qua 1 đối tượng đặc biệt đó là đối tượng có kiểu là Class (bạn đừng nhầm từ Class ở đây với lớp nhe ^^). Khi bất kì một đối tượng nào tạo ra thì 1 đối tượng có kiểu là Class cũng được tạo ra để lưu lại mọi thông tin về kiểu của đối tượng đó đối tượng này có dạng là Class object. Bạn để ý khi biên dịch 1 file .java sẽ sinh ra 1 file .class nằm trong thư mục bin không. Đối tượng Class chứa thông tin của đối tượng được tạo ra đó sẽ nằm trong file .class đó. Do đó để khởi tạo 1 đối tượng có kiểu là generic bạn chỉ cần đưa thông tin về kiểu của đối tượng Generic đó là gì thì chắc chắn đối tượng Generic đó sẽ được tạo. Cú pháp tạo sẽ là thế này: newInstance();

class CreatorGenericObject<T> {
       T x;
       public CreatorGenericObject(Class<T> kind) {
            try {
                x = kind.newInstance(); // Không thể dùng T x = new T[] sẽ báo lỗi compile time ngay !!!
                } catch (Exception ex) {
                    throw new RuntimeException(ex);
               }
       }
}

Và khi ở chương trình mà sử dụng lớp phía trên kia của mình họ chỉ cần gọi thế này:

class Employee{}
       public class Test {
          public static void main(String[] args) {
               CreatorGenericObject<Employee> obj = new                  
CreatorGenericObject(Employee.class);
      }
}

Chú ý: Employee.class nhằm mục đích tạo ra tham chiếu có kiểu là Class<Employee> (ở đây là tham chiếu kind) vì với Class<Employee> chứa thông tin kiểu Employee chúng ta đã hoàn toàn có được thông tin về kiểu Employee rồi nên giờ thì có thể khởi tạo được đối tượng Employee rồi. (Hãy đọc kĩ lại đoạn code trên để hiểu rõ hơn).

Như vậy mình đã tạo ra được đối tượng Generic rồi đấy.

Tạo mảng generic

Giờ chúng ta sẽ nói đến việc tạo ra mảng generic nhé!

Do array generic cũng là kiểu được chung chung hóa (tham biến hóa) nên khi chạy chương trình nó cũng hoàn toàn bị mất đi thông tin kiểu, do đó ta sẽ lấy kiểu thông tin thông qua Class<T> như ở trên.

import java.lang.reflect.*;
public class CreatorArrayGeneric<T> {
     private T[] array;
     public CreaterArrayGeneric(Class<T> kind, int length) {
          array = (T[]) Array.newInstance(kind, length);
     }
}

Như vậy là mình đã trình bày qua một số khái niệm nền tảng về Generic hi vọng qua bài viết này chúng ta có được cái nhìn tổng quát hơn về một trong những thú vị nhất trong Java.


Hãy tham gia nhóm Học lập trình để thảo luận thêm về các vấn đề cùng quan tâm.

Tags:
,

Leave a Reply

Your email address will not be published. Required fields are marked *