Lập trình khai báo (declarative programming) là một kĩ thuật mang lại rất nhiều lợi ích: code ngắn gọn hơn, dễ thay đổi, dễ bảo trì và mở rộng.
Java là một ngôn ngữ thuần túy OOP theo hướng lập trình mệnh lệnh (imperative programming), “trường phái” ngược lại của declarative. Nhưng kể từ Java 8 với lambda expression, Java đã có thể tham gia vào cuộc chơi này.
Trong bài viết này, mình sẽ giới thiệu một số kĩ thuật để code Java theo phong cách declarative.
Một “chân lý” quan trọng chính là cố gắng tận dụng
List
vàMap
nhiều nhất có thể.
1. Sử dụng List
thay cho if else
Trong trường hợp cần so sánh nhiều điều kiện, mà các điều kiện ấy hoặc cùng AND &&
hoặc cùng OR ||
, thì chúng ta có thể suy nghĩ việc sử dụng List
để gom các phép so sánh lại.
Xét ví dụ sau đây, kiểm tra một đối tượng xem nó có phải số nguyên hay không.
publicbooleanisInteger(Object o){if(o instanceofByte|| o instanceofShort||
o instanceofInteger|| o instanceofLong){returntrue;}returnfalse;}
Chúng ta có thể thay thế bằng List
như sau:
staticfinalList<Class<?>> INT =Arrays.asList(Byte.class,Short.class,Integer.class,Long.class);publicbooleanisInteger(Object obj){return INT.stream().anyMatch(aClass -> aClass.isInstance(obj));}
Code lúc này sẽ dễ mở rộng hơn. Giả sử muốn thêm/bớt 1 điều kiện thì chỉ cần sửa List INT
, thay vì phải “đụng chạm” đến code thực thi. Hơn nữa, chỉ với một chút refactor, chúng ta có thể khiến đoạn code trên tái sử dụng với các loại data khác.
staticList<Class<?>> INT =Arrays.asList(Byte.class,Short.class,Integer.class,Long.class);staticList<Class<?>> DECIMAL =Arrays.asList(Float.class,Double.class);staticList<Class<?>> DATE =Arrays.asList(Date.class,LocalDate.class,Calendar.class);publicstatic<T>booleanisInstanceOf(Collection<Class<?>> classes,T obj){return classes.stream().anyMatch(aClass -> aClass.isInstance(obj));}...boolean isInteger =isInstanceOf(INT, value);boolean isDecimal =isInstanceOf(DECIMAL, value);[...]
Bonus:
Nếu là phép
||
thì ta dùng.anyMatch()
, nếu là phép&&
thì ta dùng.allMatch()
.
2. Sử dụng Predicate
, Function
, Consumer
thay cho method
Lợi ích lớn nhất khi dùng lambda expression, chính là việc có thể gán method vào 1 đối tượng và lưu vào List
hoặc Map
. (Đây là đặc tính function as first-class citizen trong lập trình hàm)
Xét ví dụ sau, đối tượng Account
là một tài khoản trong ngân hàng.
classAccount{String owner;int balance;}staticvoidaddMoney(Account acc,int amount){
acc.balance += amount;}staticvoidsubtractMoney(Account acc,int amount){if(acc.balance < amount){System.out.println("Balance not enough.");return;}
acc.balance -= amount;}staticvoidsendOwnerNotification(Account acc){System.out.println("Your current balance is "+ acc.balance);}
Lúc này khi thực hiện thao tác cộng/trừ số dư, hoặc thông báo số dư hiện tại, ta có thể viết như sau:
publicstaticvoiddoTransaction1(Account account){addMoney(account,100);addMoney(account,500);subtractMoney(account,200);sendOwnerNotification(account);}publicstaticvoiddoTransaction2(Account account){addMoney(account,200);sendOwnerNotification(account);subtractMoney(account,100);addMoney(account,800);addMoney(account,2000);sendOwnerNotification(account);}...
Đây là cách làm truyền thống, thuần túy mệnh lệnh (imperative). Và dễ nhận thấy, các transaction đang rơi vào tình trạng hardcode. Với sự ra đời của lambda expression, chúng ta có thể cải tiến bằng cách dùng Consumer<T>
.
List<Consumer<Account>> transaction1 =Arrays.asList((account)->addMoney(account,100),(account)->addMoney(account,500),(account)->subtractMoney(account,200),(account)->sendOwnerNotification(account));List<Consumer<Account>> transaction2 =...staticvoiddoTransaction(Account account,List<Consumer<Account>> transaction){for(Consumer<Account> step : transaction){
step.accept(account);}}
Nhờ lambda expression mà chúng ta có thể “đóng gói” những step của 1 transaction thành list và truyền vào ở dạng param. Code trở nên ngắn gọn và có thể tái sử dụng. Thậm chí có thể cho user thực hiện một transaction do chính họ quy định bằng cách gọi API (điều mà hardcode không làm được).
3. Sử dụng Map
thay cho switch case
Trong một số trường hợp, nếu các logic bên trong switch case
có sự tương đồng và lặp lại, chúng ta có thể cân nhắc sử dụng Map
.
Xét ví dụ sau, có 3 lựa chọn thanh toán khi đăng kí membership ở 1 website.
enumPolicy{
MONTHLY,
YEARLY,
LIFE_TIME
}
Mỗi lựa chọn có 1 mức giá khác nhau. Code xử lý thanh toán có thể viết đại loại như sau.
switch(policy){case MONTHLY:doPayment(account,17.5);break;case YEARLY:doPayment(account,180);break;case LIFE_TIME:doPayment(account,1000);break;default:// do something}
Chúng ta refactor bằng cách sử dụng Map
.
staticMap<Policy,Consumer<Account>> options =newHashMap<>();static{
options.put(MONTHLY, acc ->doPayment(acc,17.5));
options.put(YEARLY, acc ->doPayment(acc,180));
options.put(LIFE_TIME, acc ->doPayment(acc,100));}...Consumer<Account> option = options.get(policy);if(option !=null){
option.accept(account);}else{// default do something}
Dễ dàng nhận thấy, cũng như ví dụ 1 và 2, chúng ta cố gắng tách logic nghiệp vụ từ dạng code sang dạng constant và khiến phần code xử lý abstract nhất có thể.
Bạn có thể nói rằng số lượng line of code không có nhiều sự khác biệt. Nhưng một trong những ưu điểm của việc sử dụng Map
, là ta có thể kiểm tra đối tượng dạng object như Class<?>
, trong khi switch
bị giới hạn ở bốn kiểu data là số nguyên, kí tự, String và enum. (Hiện tại Java 17 đã hỗ trợ điều này với tính năng pattern matching dành cho switch
)
Xét ví dụ sau, khi ghi dữ liệu ra file Excel với thư viện Apache POI. Thông thường, chúng ta sẽ phải dùng if else bởi switch không hỗ trợ Class<T>
.
Class<?> type = cellValue.getClass();if(type.equals(long.class)|| type.equals(Long.class)){
cell.setCellValue((long) cellValue);}elseif(type.equals(double.class)|| type.equals(Double.class)){
cell.setCellValue((double) cellValue);}elseif(type.equals(Date.class)){
cell.setCellValue((Date) cellValue);}elseif...
Nỗi sợ hãi maintain tỉ lệ thuận với số lượng if-else trong code. Hãy cùng refactor lại bằng Map
như dưới đây.
staticMap<Class<?>,BiConsumer<Cell,Object>> handlers =newHashMap<>();static{
handlers.put(Long.class,(cell, val)-> cell.setCellValue((long) val));
handlers.put(long.class,(cell, val)-> cell.setCellValue((long) val));
handlers.put(Date.class,(cell, val)-> cell.setCellValue((Date) val));
handlers.put(Double.class,(cell, val)-> cell.setCellValue((double) val));[...]}...BiConsumer<Cell,Object> handler = handlers.get(cellValue.getClass());if(handler !=null){
handler.accept(cell, cellValue);}
Mỗi lần muốn thêm một loại dữ liệu mới, chỉ cần sửa phần constant handlers
, chứ không cần “đụng chạm” vào logic xử lý, giúp cho phần code này luôn sạch sẽ gọn gàng.
Nhận xét
3 kĩ thuật được nêu trên đều có một đặc điểm chung, đó là chúng ta luôn cố gắng tách dữ liệu nghiệp vụ từ dạng code ở phần body sang dạng constant (input param). Lợi ích lớn nhất của điều này là ngăn ngừa hardcode nghiệp vụ. Chúng ta có thể tách và lưu nghiệp vụ xuống DB, mỗi lần thay đổi chỉ cần cập nhật DB mà không cần compile/build lại app.
Ngoài ra, code xử lý cũng ngắn gọn và sạch sẽ hơn.
Kết
Trên đây là một vài kinh nghiệm đúc kết của mình. Các kĩ thuật này hoàn toàn có thể áp dụng với những ngôn ngữ khác, chứ không duy nhất cho Java. Declarative programming là một chủ đề thú vị và vẫn còn nhiều thứ để nghiên cứu.
Mình sẽ không gọi là Functional Programming (FP), bởi Java vốn dĩ rất nặng về OOP và vẫn còn ở rất xa với tiêu chuẩn của FP. FP có nhiều pattern cao cấp (curry, recursion) mà đôi khi áp dụng không cẩn thận có thể khiến code trở nên khó maintain hơn thay vì OOP. Chỉ dừng ở cấp độ Declarative là đủ rồi.
Hẹn gặp lại các bạn trong những bài viết tiếp theo.
Nguồn: viblo.asia