- Java 21, phát hành ngày 19/9/2023, đưa cách biểu đạt pattern mang tính hàm gần với Kotlin·Rust·C# vào trong Java thông qua record patterns và switch pattern matching
- Các thay đổi tích lũy từ switch expressions của Java 14, records và
instanceof pattern matching của Java 16, cùng sealed classes của Java 17 đã kết nối lại trong Java 21 để tạo nền tảng xử lý kiểu dữ liệu đại số
- records khiến việc tách dữ liệu ổn định hơn nhờ các ràng buộc như final, tham chiếu bất biến và getter theo khuôn mẫu; record pattern cho phép lấy trực tiếp dữ liệu lồng nhau ngay trong switch
- sealed classes/interfaces chỉ mở cho các kiểu con được cho phép, giúp tạo mô hình gần với sum type; khi dùng sealed interface cùng record, có thể giới hạn các biến thể như RGB·CMYK·YUV·HSL
- switch của Java 21 còn hỗ trợ case
null và guard when, nhưng accessor của record viết sai hoặc ngoại lệ phát sinh khi chạy guard có thể dẫn đến java.lang.MatchException
Pattern matching được ổn định trong Java 21
- Java 21 được phát hành ngày 19/9/2023 và hỗ trợ record patterns trong switch block và switch expression
- Cú pháp này được xem là bước ngoặt giúp Java cũng có thể biểu đạt các pattern của lập trình hàm theo cách tương tự Kotlin, Rust, C#
- Những thay đổi cú pháp lớn ở các phiên bản Java gần đây đã dẫn tới pattern matching của Java 21
- Java 14: ổn định hóa switch expressions
- Java 16: ổn định hóa records,
instanceof pattern matching
- Java 17: ổn định hóa sealed classes
- Java 21: ổn định hóa record patterns, switch pattern matching
- Với nhóm thay đổi này, Java giờ có thể xử lý kiểu dữ liệu đại số (algebraic data types) và cách dùng mang tính thành ngữ của chúng, vốn trước đây rất khó biểu đạt
Những khái niệm tối thiểu cần biết trong lý thuyết kiểu
- Để hiểu các tính năng của Java 21, cần nắm một vài khái niệm của lý thuyết kiểu
- bottom/empty type biểu thị tập giá trị không thể được tính ra; trong ngôn ngữ lập trình thông thường, đây thường là tập rỗng
Nothing của Kotlin có constructor là private nên không thể tồn tại instance
Void của Java cũng có constructor private nhưng vẫn có thể chứa null, nên khó xem là bottom type đúng nghĩa
- primitive
void của Java không thể dùng làm kiểu biến, nên ở điểm này hành xử gần hơn
- top type là tập phổ quát biểu thị mọi giá trị của mọi kiểu
- Trong Kotlin,
Any đóng vai trò này
Object của Java tách biệt với primitive trong mô hình đối tượng, nên khó xem là tương đương hoàn toàn với top type ở các ngôn ngữ khác
- unit type là kiểu chỉ có đúng một giá trị
void của Java có thể được xem như unit type ở giá trị trả về của method, nhưng không thể truyền làm kiểu tham số
Unit của Kotlin được định nghĩa bằng object và cũng có thể dùng làm tham số method
- boolean type có hai giá trị
true và false; về lý thuyết có thể biểu diễn bằng nullable unit type, nhưng không thực tế
Product type và Java records
- product type là kiểu kết hợp từ hai hay nhiều kiểu thành phần, và số lượng kiểu thành phần được gọi là arity hoặc degree
struct của C là một ví dụ về product type
- Các kiểu thành phần như
int, char *, double, int có thể lặp lại
- Với kiểu lặp lại, có thể phân biệt chúng như ordered pair đi kèm tên field
- tuple trong Python hay Rust cũng có thể xem là product type; khi đó, chỉ số đóng vai trò tên của thành phần
- record class được ổn định trong Java 16 là một ví dụ tốt của product type
- field trong record là final, và record không thể bị kế thừa
- trạng thái của record được thiết lập tại thời điểm tạo và được giữ nguyên sau đó
- tuy nhiên, nếu đặt mutable data type bên trong record thì không thể đảm bảo tính bất biến của chính nội dung đó
- Java class thông thường có thể trộn lẫn trạng thái public/private, trạng thái ẩn phát sinh từ kế thừa, mutable/static field, và getter phi chuẩn, nên khó khái quát các thành phần của nó
- records đảm bảo một cấu trúc đủ ổn định để các tính năng ngôn ngữ như pattern matching hoạt động tin cậy nhờ các ràng buộc sau
- record ngầm định là final class và không thể bị kế thừa
- không thể
extend lớp nào ngoài java.lang.Record
- record component không thể gắn visibility modifier
- tham chiếu tới component luôn là final và được xem là bất biến
- getter mặc định dùng nguyên tên field, nên getter của field
a sẽ là a()
- backing field ngầm định là private và được truy cập qua getter
Phân rã dữ liệu lồng nhau bằng record pattern
- switch pattern của Java 21 cho phép phân rã dữ liệu record lồng nhau mà không cần lặp đi lặp lại việc kiểm tra
instanceof và ép kiểu tường minh
- Trong ví dụ, dùng
record A(Record inner), record B(char b), record SomeOtherRecord()
- Cách cũ là sau
if (r instanceof A) lại ép kiểu, rồi tiếp tục lặp instanceof và ép kiểu với giá trị bên trong
- switch pattern cho phép trích xuất trực tiếp giá trị lồng nhau như
case A(B(char a)) -> String.valueOf(a)
- switch block có cấu trúc rõ ràng hơn if-else ladder, và phù hợp để lấy nhanh dữ liệu lồng sâu
- Để chạy trực tiếp trên Java 21, có thể đặt code vào
main.java và dùng lệnh sau
java --enable-preview --source 21 main.java
Sum type và sealed classes/interfaces
- Khi cần biểu diễn số lựa chọn hữu hạn, có thể dùng enum của Java, nhưng các biểu diễn màu có cấu trúc dữ liệu khác nhau như RGB·HSL·YUV·CMYK sẽ khá bất tiện nếu chỉ dùng enum
- Có thể tạo đa hình dựa trên kế thừa bằng abstract class
Color cùng các class RGB, CMYK, YUV, HSL, nhưng class hierarchy thông thường là mở
- Người dùng thư viện có thể tạo class mới như
RYB để kế thừa Color
- Nếu API không chủ đích cho phép mở rộng, biến thể mới có thể gây crash hoặc bug tinh vi ở đoạn code rất xa
- sealed classes được dùng để biểu đạt khái niệm sum type trong Java
- sum type là kiểu mà tại một thời điểm chỉ có thể là một trong các thành phần của nó
- còn được gọi là tagged union type
- Khi dùng modifier
sealed và mệnh đề permits, chỉ các class cụ thể mới được phép kế thừa
public sealed class Color permits RGB, CMYK, YUV, HSL {
}
- Trong sealed class hierarchy, các lớp kế thừa trực tiếp hoặc gián tiếp phải có một trong ba modifier
sealed, non-sealed, final; nếu không sẽ phát sinh compile error
sealed: chỉ các kiểu được nêu trong permits mới có thể kế thừa
non-sealed: có thể kế thừa như class thông thường
final: là leaf của cây kế thừa và không thể mở rộng thêm
Cách kết hợp sealed interface và record
- destructuring trong switch pattern matching hoạt động với record, nhưng record không thể kế thừa lớp nào ngoài
Record
- Cách giải quyết là dùng sealed interface
- sealed interface hoạt động tương tự sealed class
- record và enum cũng có thể
implement sealed interface
- Trong ví dụ,
Color được tạo thành sealed interface, còn RGB, CMYK, YUV, HSL được triển khai bằng record
public sealed interface Color permits RGB, CMYK, YUV, HSL {
String getDescription();
}
record RGB(int red, int green, int blue) implements Color {
public String getDescription() {
return "RGB Color: (" + red + ", " + green + ", " + blue + ")";
}
}
- Sau đó có thể trích xuất trực tiếp giá trị của từng record trong switch
switch (color) {
case RGB(int red, int green, int blue) -> {
}
case CMYK(double cyan, double magenta, double yellow, double black) -> {
}
case YUV(int y, int u, int v) -> {
}
case HSL hsl -> {
System.out.println(hsl.getDescription());
}
case null -> {
System.out.println("How did color become null?!");
}
}
- Java 21 cho phép xử lý case
null trong switch block và expression, nên không cần kiểm tra null riêng trước switch
- Nếu
Color là một sealed type, Java có thể biết liệu mọi case đã được xử lý hay chưa, nhờ đó có thể viết exhaustive switch mà không cần default case
Guard clause và when
- Java 21 hỗ trợ guard clause để gắn điều kiện bổ sung vào từng switch arm
- guard clause tích hợp điều kiện ngay trong case label bằng từ khóa
when
switch (color) {
case RGB(int red, int green, int blue) when red > 200 -> {
System.out.println("Very red.");
}
case RGB rgb when rgb.green > 100 -> {
System.out.println("Sort of green...");
}
case RGB rgb -> {
System.out.println("Not that red...");
}
}
- Trước đây, phải đặt lại
if (red > 200) bên trong phần thân của case RGB(...)
- Java sẽ khớp một cách eager với case đầu tiên đánh giá là true, nên cần đặt case cụ thể hơn ở trước và case ít cụ thể hơn ở sau
- Sau một RGB case có guard, vẫn cần
case RGB rgb thông thường để giữ tính exhaustive
Khi nào xảy ra MatchException
- Với pattern matching của Java 21,
java.lang.MatchException cũng là một ngoại lệ cần lưu ý
- Nếu record accessor ném ngoại lệ, switch pattern sẽ thất bại và có thể phát sinh
MatchException
record R(int i) {
public int i() {
return i / 0;
}
}
static void exampleAnR(R r) {
switch(r) {
case R(var i): System.out.println(i);
}
}
- Trong ví dụ trên, accessor
i() ném ArithmeticException, nên switch block sẽ ném MatchException
- Theo JEP 441, record accessor luôn ném ngoại lệ là trường hợp rất bất thường, và việc exhaustive pattern switch ném
MatchException cũng rất hiếm gặp
- Ngay cả với exhaustive switch, ngoại lệ vẫn có thể xảy ra nếu selector không khớp với bất kỳ variant nào được chỉ định
- JEP 441 giải thích trường hợp exhaustive switch với enum thất bại khi khớp là do enum class đã bị thay đổi sau khi switch được biên dịch
- Ngoại lệ phát sinh khi chạy guard clause cũng có thể dẫn đến
MatchException
static void example(Object obj) {
switch (obj) {
case R r when (r.i / 0 == 1): System.out.println("It's an R!");
default: break;
}
}
Phần còn lại
- Khi kết hợp records, sealed types, switch pattern matching và guard clause trong Java 21, có thể đưa các khối xây dựng của lập trình hàm vào code Java
- Một số chủ đề như cách generics tương tác với switch patterns không được đề cập ở đây
- Bài viết tiếp theo sẽ nói về các quirks và ví dụ thực tế có thể dùng để cải thiện cách viết code Java
1 bình luận
Ý kiến trên Hacker News
Tính năng lớn nhất của Java 21 là việc phát hành virtual threads: https://openjdk.org/jeps/444
Không hiểu sao bài viết lại bỏ sót. Nếu có tính năng nào có thể kéo các lập trình viên Go hiện tại sang Java thì có thể chính là nó, và có lẽ nó cũng sẽ thuyết phục được những người vốn không thích các mẫu đồng thời kiểu reactive.
Ứng dụng và thư viện Java quá khó suy luận và hiểu so với Go vì kế thừa, đóng gói, hướng đối tượng, công cụ build, v.v.
Go đơn giản, dễ hiểu, dễ đọc và dễ bảo trì. Cách đóng gói giống với việc sắp xếp file trên máy tính vào một thư mục duy nhất, và công cụ cũng được tích hợp sẵn trong ngôn ngữ. Nó cũng không tạo cảm giác phải có IDE như IntelliJ thì mới tạm dùng được.
Có thể bây giờ đã khác, nhưng hầu hết các thư viện Java tôi thấy gần đây vẫn trông như vậy.
Có nhiều điều tôi thích ở JVM và hệ công cụ của nó, nhưng giờ viết mã Java thì tôi không thích lắm. JRuby phần nào đem lại ưu điểm của cả hai phía.
Bài trình bày ở đây, và phần demo virtual threads bắt đầu khoảng phút 45:
https://youtu.be/pzm6I4liJlg?si=GtxQ4MThEaNDfC67
Ngay cả
sync.Mapcũng không phải là thứ tổng quát nhưConcurrentMapcủa Java, mà được chuyên biệt cho hai trường hợp sử dụng cụ thể. Java có tập hợp đồng thời, hàng đợi, barrier, phaser, fork-join pool, v.v. Dù có goroutine, những container như vậy vẫn đủ hữu ích; ít nhất thì fork-join không phải thứ quá tầm thường để triển khai. Dùng mutex ở khắp nơi tạo cảm giác quá cấp thấp.Tôi biết có các triển khai bên thứ ba, nhưng đồng thời quá khó để làm cho đúng, nên nếu không đạt mức trưởng thành và được nhiều người dùng lẫn nhà phát triển hậu thuẫn như JCTools của Java hay Google Guava, tôi sẽ ngần ngại khi dùng một gói bên thứ ba.
Executor.newVirtualThreadPerTaskExecutorvàgocho thấy rất rõ lý do cốt lõi khiến các lập trình viên Go sẽ không chuyển sang Java.Nói chính xác hơn, thực ra nó gần với
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(...) }thay vìgo.Tôi nghĩ tiêu đề bài blog được chọn chưa hay. Phụ đề ẩn là "Algebraic data types in Java", và câu này mô tả nội dung phù hợp hơn nhiều. Tiêu đề tốt hơn lẽ ra là Algebraic data types in Java 21.
Có lẽ vì tiêu đề mà khá nhiều bình luận ở đây đi chệch chủ đề. Tôi muốn thấy nhiều hơn về kiểu dữ liệu đại số, ưu nhược điểm của triển khai trong Java, và so sánh kỹ thuật với các ngôn ngữ khác.
Mã Java hiện có sẽ không biến mất, nên nếu loại mã này bị trộn lẫn ngẫu nhiên vào đó thì liệu có thật sự tốt hơn không?
Tính năng sealed classes được giải thích ở đây có cảm giác như sai hoàn toàn
Lập luận là nếu có một interface thông thường thì bất kỳ ai cũng có thể tạo lớp mới triển khai nó, và đoạn mã như
if (x instanceof Foo) { ... } else if (x instanceof Bar) { ... }sẽ hỏng lúc runtime nếu ai đó thêm một lớp mới. Vì vậy, với tính năng sealed interface mới, nếu không ai có thể tạo lớp mới triển khai interface đó thì câu lệnhifsẽ không bị hỏngNhưng chẳng phải lập trình hướng đối tượng đã nghĩ đến và giải quyết vấn đề này rồi sao? Tôi biết hiện nay hướng đối tượng không còn thời thượng như trước, nhưng Java là ngôn ngữ hướng đối tượng
Cách giải quyết là thêm một method vào interface và bắt tất cả các lớp triển khai method đó. Khi đó thay vì liệt kê mọi lựa chọn trong một câu lệnh if/switch khổng lồ, ta chỉ cần gọi method
Cách này tốt hơn việc chặn không cho mở rộng code; ngược lại, nó cho phép mở rộng. Người viết implementation mới chỉ cần triển khai method đó, và vì compiler bắt buộc nên cũng không thể vô tình bỏ sót
Ví dụ về không gian màu trong bài (RGB, CMYK, v.v.) là một phản ví dụ rất hay. Bạn viết code dùng không gian màu, nhưng người dùng hoặc khách hàng có thể cần dùng một không gian màu lạ và hiếm mà bạn chưa nghĩ tới. Tôi không muốn tạo ra code không thể mở rộng vì cấu trúc đó chỉ hỗ trợ những không gian màu được liệt kê trong một câu lệnh if/switch khổng lồ
Sealed classes giải quyết vấn đề này. Nhưng đổi lại nó tạo ra vấn đề mới: “nếu cần thêm các lớp mở rộng và không thể biết trước tất cả chúng thì sao?” Cuối cùng câu hỏi là có cách nào đạt được cả hai hay không
Vấn đề này được gọi là expression problem [1]
Có những ngôn ngữ kiểu tĩnh giải được expression problem, và Java cũng là một trong số đó [2]. Tuy nhiên cách làm trong Java vẫn rất phức tạp và bất tiện nên hầu như không được dùng. Nếu muốn ở lại với Haskell hoặc thế giới JVM, Scala làm phần này tốt hơn nhiều
[1] https://en.wikipedia.org/wiki/Expression_problem
[2] https://koerbitz.me/posts/Solving-the-Expression-Problem-in-...
Cách dùng method trong interface và virtual call rất kém linh hoạt khi bạn muốn thêm một phép toán mới thay vì thêm lớp mới. Chỉ cần thêm một phép toán mới thôi cũng phải đi đến mọi implementation để thêm method mới, và có thể làm hỏng các implementation mà bạn không có quyền truy cập. Các method không liên quan với nhau cũng phải được định nghĩa trong cùng một lớp, khiến khả năng đọc code giảm mạnh; virtual call cũng không miễn phí nên còn ảnh hưởng đến hiệu năng
Trong trường hợp này sealed class mở rộng tốt hơn nhiều. Chỉ cần thêm một switch mới ở một nơi là xong, và cũng không phá vỡ tương thích ngược
Đây chính là expression problem nổi tiếng
https://pkolaczk.github.io/in-defense-of-switch/
instanceof, và cũng đã thấy Bob Martin nói rất dài về chuyện đó, nhưng tôi không đồng ýĐể làm polymorphic dispatch kiểu này, đối tượng phải tự xử lý nhiều mối quan tâm khác nhau
Trong một trò chơi điện tử,
Carcó thể có.render(),.collide(),.playSound(). Sau này thêmDogthì chỉ cần triển khai ba method này, không cần sửa hay biên dịch lạiRenderer,PhysicsEngine,SoundEngine. Lập trình viên khác cũng có thể thêm entity mà không đưa bug vào đoạn code quý giá của tôi. Nghe có vẻ tốtNhưng giờ
CarvàDogđều phải biết về đồ họa, vật lý và âm thanh. Và entity không tồn tại cô lập. Xe và chó phải được render theo đúng thứ tự và có thể che khuất nhau. Va chạm cũng phải kiểm tra lẫn nhau. Như tôi từng gặp trong một game jam thực tế, cũng có tình huống người phụ trách âm thanh phải chui vào mọi đối tượng để thêm hành vi âm thanhThay vào đó, khi nghĩ về vật lý thì làm việc trong
Physics.collideAll()và xử lý đặc biệt bằnginstanceofnếu cần; khi nghĩ về đồ họa thì làm việc trongGraphics.renderAll()sẽ tốt hơn nhiềuTrong phát triển web backend Java hằng ngày cũng tương tự. Khi quyết định cách chuyển Java object thành HTTP response trong REST controller, tốt hơn là nhìn toàn bộ trong một method và map
{instanceof Forbidden}thành 403,{instanceof NotFound}thành 404. Tôi không muốn nhétgetCode()hay nội dung riêng cho REST vào chính các class JavaStringlàfinalcó lý do của nó, và thậm chí có thể cho rằng nên đặtfinallàm mặc định, chỉ những lớp cho phép subclass mới cần được ghi rõ làopenTrong lập trình hàm, ví dụ điển hình của sum type là list. Nó chỉ có
Element(T head, List tail)vàNil(). Không có lý do gì để mở rộng nó, và trên thực tế nếu mở rộng thì có thể trở thành code sai khi kết hợp với mọi hàm xử lý listNgoài ra, visitor pattern tương tự pattern matching rất dài dòng và dựa vào một thủ thuật tận dụng ngữ nghĩa dispatch method thông thường của Java. Ở đây tôi cho rằng pattern matching dễ đọc hơn nhiều lần
Ví dụ có thể nghĩ đến một interface bảo mật để xác thực security token
Nếu là interface thông thường, rất dễ triển khai nó để bỏ qua token (cho phép tất cả), đánh cắp token, hoặc cài backdoor. Nếu một lớp như vậy được inject vào nơi thực hiện kiểm tra bảo mật, bảo mật có thể bị phá vỡ
Với sealed interface, implementation mới chưa được phê duyệt không thể tồn tại. Nếu nhận được một object tuyên bố triển khai interface đó, ta được đảm bảo rằng nó là một trong những implementation đã được xác minh và thực sự thực hiện kiểm tra bảo mật. Như vậy đã loại bỏ hẳn một nhóm bug bảo mật và exploit
Đây là một bài viết hay đối với người biết về sum type nhưng chưa rõ sum type trong Java
Tuy nhiên, tôi không chắc chỉ riêng sum type có khiến tôi thích Java hay không. Khả năng null trên diện rộng vẫn còn đó, và trong bài này nó cũng nhiều lần lộ diện
Tôi rất kỳ vọng vào https://jspecify.dev/, nơi Google, Meta, Microsoft, v.v. đang cố gắng chuẩn hóa annotation, bắt đầu từ
@NullableCâu trả lời trong bài cho câu hỏi "tại sao gọi là product type?" không sai, nhưng nói trực quan và ngắn gọn hơn thì tổng số giá trị khả dĩ của một product type là tích số lượng giá trị khả dĩ của các kiểu thành phần
Nếu thay product bằng sum thì điều tương tự cũng đúng
Thú vị là tổng số hàm riêng biệt có dạng
a -> b, nếu chỉ xét đầu vào và đầu ra, có thể tính bằng lũy thừa. Tức là(số giá trị khả dĩ của b) ^ (số giá trị khả dĩ của a)Viết dưới dạng toán học, với list của Bool, bên trái là số phần tử còn bên phải là tổng số khả năng
0 : 1
1 : 2
2 : 4
3 : 8
4 : 16
5 : 32
Và cứ tiếp tục như vậy
Tôi đang chờ Project Valhalla hoàn tất để cuối cùng Java có value type. Khi đó, với sum type, value type và coroutine, có lẽ nó sẽ trở thành một trong những ngôn ngữ khá ổn
Java vốn không thật sự là một ngôn ngữ tệ
Vấn đề là con người. Vấn đề là sự over-engineering khổng lồ, quá nhiều khái niệm trừu tượng khiến khó nắm bắt codebase, những thứ bùa chú mã dưới dạng annotation giống như câu lệnh GOTO ngược, và các framework DI
Thứ cần sửa không phải ngôn ngữ mà là hệ sinh thái. Trong hệ sinh thái Java cần một phong trào kiểu "cải cách tôn giáo"
Chỉ chuyển sang Kotlin, Clojure, Scala là chưa đủ
HammerFactoryFactoryđể sinh raHammerFactory. Nhưng hệ sinh thái Java khuyến khích và cổ vũ cách giải quyết vấn đề kiểu này. Tôi nghĩ C# cũng tương tựMột thứ Java thật sự cần là hàm độc lập, hoặc hàm có namespace. Đôi khi không cần class, chỉ cần một hàm trong module hoặc namespace là đủ, nhưng tôi không hiểu vì sao lại không được
Tác giả giải thích vì sao cần Records, và nêu rằng hầu hết object Java đều để mọi field là private và chỉ truy cập thông qua các phương thức accessor đọc/ghi
Nhưng không có quy ước bắt buộc ở cấp độ ngôn ngữ để định nghĩa accessor, nên nếu đặt tên getter của
foolàgetBarthì vẫn chạy, nhưng có thể gây nhầm lẫn cho người muốn truy cậpbarScala hỗ trợ pattern matching cho object triển khai phương thức
unapply. Cách này có bị xem là có hại không? Vì sao Java không đi theo hướng này?unapplycó thể đang được chuẩn bị, nên hy vọng vẫn chưa hoàn toàn tắtJava luôn là một ngôn ngữ tuyệt vời. Thứ khiến người ta muốn nôn là hệ sinh thái kiểu enterprise. Tôi từng thấy người ta dùng hàng chục class và interface chỉ để triển khai một dòng logic