Covariance và Contravariance trong Java
Bài đăng này đã không được cập nhật trong 6 năm
Để dễ hiểu về covariance và contravariance thì mình sẽ đưa ra vài ví dụ với mảng.
Mảng có tính covariant
Mảng được cho là có tính covariant là bởi với nguyên tắc kế thừa trong Java thì một mảng T[]
có thể bao gồm các phần tử có kiểu T
hoặc là một kiểu kế thừa từ T
. Ví dụ như:
Number[] numbers = newNumber[3];
numbers[0] = newInteger(10);
numbers[1] = newDouble(3.14);
numbers[2] = newByte(0);
Không chỉ vậy, nguyên tắc kế thừa trong Java còn nói rằng một mảng S[]
là lớp con của T[]
nếu S
là lớp con của T
. Vì vậy nên đoạn code sau đây là hợp lệ:
Integer[] myInts = {1,2,3,4};
Number[] myNumber = myInts;
Mảng Integer[]
là kiểu con của mảng Number[]
do Integer
là kiểu con của Number
.
Tuy nhiên điều gì sẽ xảy ra nếu chúng ta làm như sau:
myNumber[0] = 3.14 // khi làm như này là chúng ta đang thực hiện một heap pollution, các bạn google search nếu chưa rõ nhé :D
Đoạn code này sẽ được compile bình thường, tuy nhiên nếu chúng ta chạy đoạn code này thì sẽ gặp ArrayStoreException
do chúng ta đang cố cho một số thực vào một mảng số nguyên. Chúng ta đã có thể đánh lừa được compiler nhưng không thể lừa được hệ thống tại thời điểm run-time. Lý do là bởi tại thời điểm run-time thì Java đã biết rằng mảng này đã được khởi tạo dưới dạng một mảng số nguyên và đang được truy cập thông qua một tham chiếu kiểu Number[]
.
Qua ví dụ trên chúng ta có thể thấy rằng có một cái được gọi là kiểu thực của đối tượng và cái kia là kiểu tham chiếu mà chúng ta dùng để truy cập đối tượng.
Vấn đề trong Java Generics
Vấn đề gặp phải với kiểu generics trong Java là thông tin về kiểu của tham số kiểu sẽ được loại bỏ bởi compiler sau thời gian biên dịch code diễn ra, do đó nên thông tin về kiểu này không hề có tại thời điểm run-time. Quá trình này được gọi là type erasure. Do không hề có thông tin về kiểu tại thời điểm run-time cho nên không thể xác định được rằng liệu chúng ta đang thực hiện một heap pollution hay không.
Cùng xem đoạn code unsafe sau đây nhé:
List<Integer> myInts = newArrayList<Integer>;
myInts.add(1);
myInts.add(2);
List<Number> myNums = myInts; // lỗi compile
myNums.add(3.14) // heap pollution
Giả sử nếu trình biên dịch Java cho phép chúng ta code như trên thì tại thời điểm run-time hệ thống cũng sẽ không thể cản được bước tiến của chúng ta =)). Bởi như đã nói ở trên thì thông tin về kiểu không hề có tại thời điểm run-time, do đó nên hệ thống không thể biết được list đó là list chỉ dành riêng cho số nguyên. Java tại thời điểm run-time lúc này sẽ cho phép chúng ta bỏ bất cứ thứ gì chúng ta muốn vào list mà điều này thì hiển nhiên là không được phép rồi, đó là lý do mà thực tế trình biên dịch sẽ không coi dòng thứ 4 là hợp lệ.
Tóm lại, những người thiết kế Java đã đảm bảo rằng chúng ta không thể đánh lừa được trình biên dịch bởi nếu chúng ta không lừa được trình biên dịch thì chúng ta cũng sẽ không thể lừa được hệ thống tại thời điểm run-time.
Do vậy nên kiểu generic được coi là có tính non-reifiable, hiểu nôm na thì đó là kiểu mà thông tin về nó tại thời điểm runtime có được là ít hơn so với tại thời điểm compile. Đặc tính này của kiểu generic có ảnh hưởng không tốt đến tính đa hình trong Java. Hãy cùng xem ví dụ sau nhé:
static long sum(Number[] numbers){
long summation = 0;
for(Number number : numbers){
summation += number.longValue();
}
return summation;
}
Chúng ta có thể làm được như sau:
Integer[] myInts = {1,2,3,4,5};
Long[] myLongs = {1L,2L,3L,4L,5L};
System.out.println(sum(myInts));
System.out.println(sum(myLongs));
System.out.println(sum(myDoubles));
Tuy nhiên nếu chúng ta muốn làm điều tương tự với generics collections thì sẽ không thể làm được:
static long sum(List<Number> numbers){
long summation = 0;
for(Number number : numbers){
summation += number.longValue();
}
return summation;
}
Bởi chúng ta sẽ gặp lỗi biên dịch nếu làm như sau:
Lít<Integer> myInts = asList(1,2,3,4,5,);
List<Long> myLongs = asList(1L,2L,3L,4L,5L);
List<Double> myDoubles = asList(1.0, 2.0, 3.0, 4.0, 5.0);
System.out.println(sum(myInts)); //lỗi compile
System.out.println(sum(myLongs)); //lỗi compile
System.out.println(sum(myDoubles)); //lỗi compile
Vấn đề gặp phải là chúng ta không thể coi List<Integer>
là subtype của List<Number>
. Bởi như đã nói ở trên thì điều đó bị Java đánh giá là unsafe nên trình biên dịch sẽ báo lỗi ngay.
Điều này ảnh hưởng đến ứng dụng của tính đa hình trong Java cho nên những người thiết kế Java đã đưa ra hai tính năng mới trong Java generic để khắc phục điều này là covariance và contravariance.
Covariance
Thay vì sử dụng đối số kiểu T
đại diện cho kiểu generic thì chúng ta sẽ sử dụng ký tự đại diện ? extends T
với T
là một kiểu cơ sở biết trước.
Với covariance thì chúng ta có thể đọc một phần tử ra khỏi cấu trúc dữ liệu này đấy, nhưng không thể ghi bất cứ cái gì vào cấu trúc dữ liệu đó. Sau đây là cách khai báo covariant hợp lệ:
List<? extends Number> myNums = new ArrayList<Integer>();
List<? extends Number> myNums = new ArrayList<Float>();
List<? extends Number> myNums = new ArrayList<Double>();
Chúng ta có thể đọc từ danh sách myNums
như sau:
Number n = myNums.get(0);
Lý do chúng ta có thể làm được vậy là bởi chúng ta biết chắc là bất cứ phần tử nào trong danh sách cũng đều có thể ép kiểu thành Number
. Nhưng thêm vào sẽ bị cấm:
myNums.add(45L); //lỗi compile
Điều này là không được phép bởi trình biên dịch không thể xác định được kiểu thực tế của phần tử trong danh sách. Nó có thể là bất cứ kiểu gì kế thừa từ Number
nhưng trình biên dịch không thể biết được. Do đó nên giả sử nếu có thể thêm vào danh sách thì lúc này hành động lấy ra của chúng ta sẽ bị coi là unsafe và trình biên dịch sẽ báo lỗi. Chính vì vậy nên chúng ta có thể đọc nhưng không thể viết thêm.
Contravariance
Với contravariace chúng ta sử dụng ký tự đại diện là ? super T
với T
là kiểu cơ sở. Với contravariance thì chúng ta có thể làm ngược lại: có thể thêm vào cấu trúc dữ liệu kiểu generic nhưng không thể đọc được từ nó.
Trong trường hợp này thì bản chất thực của đối tượng ở đây là List
của Object
và thông qua contravariance chúng ta có thể thêm một Number
vào nó. Điều này được cho là hợp lý là bởi Number
có Object
là lớp cha, do đó nên tất cả các kiểu kế thừa Number
cũng đều là Object
.
Tuy nhiên thì chúng ta không thể đọc các phần tử cấu trúc dữ liệu này và luôn trông đợi sẽ luôn lấy được phần tử kiểu Number
từ nó:
Number myNum = myNums.get(0); //Lỗi compile
GIả sử nếu trình biên dịch cho phép chúng ta viết như trên thì đến thời điểm run-time chúng ta có thể sẽ gặp lỗi ClassCastException
nếu trong myNums
có một phần tử mà kiểu thực tế của nó không phải là Number
. Java không chấp nhận rủi ro gặp phải những trường hợp như thế cho nên ngay tại thời điểm biên dịch thì compiler sẽ báo lỗi ở dòng code này.
Quy tắc đọc/ghi
Tóm lại, chúng ta sẽ sử dụng covariance khi chúng ta chỉ mong muốn lấy các giá trị generic ra khỏi cấu trúc dữ liệu. Chúng ta sử dụng contravariance khi chúng ta chỉ mong muốn thêm các giá trị generic vào cấu trúc dữ liệu.
Ví dụ sau đây cho phép việc sao chép bất kỳ một Number
từ danh sách nào đó sang danh sách khác. Trong ví dụ thì nó chỉ lấy các phần tử ở danh sách nguồn và nó chỉ bỏ các phần tử vào danh sách đích.
public static void copy(List<? extends Number> source, List<? super Number> destination){
for(Number number : source){
destination.add(number);
}
}
Nhờ covariance và contravariace thì chúng ta có thể làm được điều sau:
List<Integer> myInts = as List(1,2,3,4);
List<Integer> myDoubles = as List(3.14, 6.28);
List<Object> myObjs = new ArrayList<Object>();
copy(myInts, myObjs);
copy(myDoubles, myObjs);
Nguồn
All rights reserved