Lập trình chức năng
Trong phong cách hướng đối tượng quen thuộc, đơn vị mà ta truyền đi giữa các phần của chương trình là đối tượng, tức dữ liệu kèm hành vi. Lập trình chức năng (functional programming) bổ sung một góc nhìn khác: bản thân hành vi cũng là một giá trị, có thể đặt tên, lưu vào biến, truyền làm tham số và trả về từ phương thức. Java không bỏ mô hình đối tượng; thay vào đó, từ phiên bản 8 ngôn ngữ cho phép gói một mẩu hành vi gọn gàng dưới dạng một thể hiện của giao tiếp hàm, để ta dùng hành vi như dùng một con số hay một chuỗi.
Vì sao điều này hữu ích? Rất nhiều bài toán có dạng chung là một khung xử lý cố định cộng một mẩu logic thay đổi: duyệt một danh sách (phần cố định) rồi làm gì đó với từng phần tử (phần thay đổi). Nếu truyền được phần thay đổi như một giá trị, ta tách bạch khung xử lý khỏi logic cụ thể, mã ngắn hơn và dễ tái dùng hơn.
Ví dụ rất thật trong đồ án: bạn cần lọc sinh viên điểm trên tám, rồi lọc theo lớp, rồi lọc nhóm chưa đóng học phí. Theo lối cũ, ba phương thức giống hệt nhau ở phần khung, chỉ khác đúng một dòng điều kiện. Phong cách chức năng cho phép viết một phương thức lọc, còn điều kiện truyền vào như tham số.
Sự trùng lặp đó gây hai thiệt hại. Thứ nhất, mỗi lần cần một tiêu chí lọc mới ta lại chép thêm một
phương thức, mã phình ra tuyến tính theo số tiêu chí. Thứ hai, nếu phát hiện một khiếm khuyết trong
phần khung, chẳng hạn quên xử lý danh sách gốc bằng null, ta phải sửa ở ba chỗ và gần như chắc
chắn bỏ sót một chỗ. Trước Java 8, cách sạch nhất để tách phần thay đổi ra là dùng lớp nặc danh cài
một giao tiếp, nhưng phải viết năm dòng khung mới đặt được một dòng logic vào giữa nên phần lớn lập
trình viên thà chép phương thức còn hơn. Lambda không mang lại năng lực biểu đạt nào mà lớp nặc danh
chưa có; nó chỉ làm cho cách viết đúng trở nên ngắn tới mức người ta chịu dùng.
Hàm thuần và tính bất biến
Một hàm thuần (pure function) thoả hai tính chất: cùng đầu vào thì luôn cho cùng đầu ra, và không gây tác dụng phụ (side effect), tức không sửa biến toàn cục, không sửa tham số truyền vào, không ghi tệp.
// impure: the answer depends on a mutable field outside the method
private int discount = 10;
int price(int p) { return p - discount; }
// pure: everything it needs comes from the parameters
int price(int p, int discount) { return p - discount; }
Hàm thuần kiểm thử được mà không cần dựng môi trường, và an toàn khi chạy song song vì không
có trạng thái dùng chung. Tính bất biến (immutability) là mặt kia của cùng đồng xu: đối
tượng tạo xong thì không đổi nội dung nữa, muốn thay đổi thì tạo đối tượng mới. String là
ví dụ quen thuộc, toUpperCase() trả về chuỗi mới chứ không viết đè chuỗi cũ. Từ Java 16,
record khai báo lớp bất biến chỉ trong một dòng vì mọi trường của nó là final.
Cái ta nhận lại từ hai tính chất khắt khe đó rất cụ thể. Trước hết là khả năng suy luận cục bộ:
muốn hiểu một hàm thuần, chỉ cần đọc thân nó. Với hàm không thuần, muốn biết price(100) trả về bao
nhiêu, ta phải đi tìm xem ai đã đổi trường discount chưa, và phạm vi cần đọc lan ra không có điểm
dừng. Tiếp theo là khả năng kiểm thử: truyền vào rồi so kết quả, không cần dựng cơ sở dữ liệu, không
cần đặt lại trạng thái giữa các lần chạy. Cuối cùng là an toàn khi chạy song song, vì mọi lỗi tranh
chấp dữ liệu đều sinh ra từ việc nhiều luồng cùng đọc và ghi một trạng thái chia sẻ; không sửa gì
thì không có gì để tranh chấp. Đây cũng là lý do các thao tác trên Stream đòi hỏi Lambda truyền vào
không có tác dụng phụ: chỉ cần đổi .stream() thành .parallelStream() là mã chạy song song, và
một Lambda lén sửa trạng thái bên ngoài sẽ cho kết quả sai một cách ngẫu nhiên.
Tất nhiên một chương trình toàn hàm thuần thì vô dụng, vì tác dụng phụ chính là mục đích tồn tại của phần mềm. Ý của phong cách chức năng không phải xoá bỏ tác dụng phụ mà là dồn chúng ra rìa: lõi tính toán viết bằng hàm thuần, việc đọc ghi đẩy ra một lớp mỏng ngoài cùng, nhờ đó phần khó nhất của chương trình cũng là phần dễ kiểm thử nhất.
- Sửa một danh sách bên ngoài từ trong Lambda, kiểu
ds.forEach(sv -> ketQua.add(sv.ten())). Nguyên nhân gốc: đây là tác dụng phụ trá hình, chạy đúng trên luồng đơn nhưng sai ngẫu nhiên khi chuyển sangparallelStreamvìArrayListkhông an toàn đa luồng; cách đúng làmaprồicollect. - Cố tăng một biến đếm cục bộ trong Lambda: không biên dịch được. Nguyên nhân gốc: Lambda chỉ đọc được biến cục bộ không bị gán lại (effectively final), bởi thân Lambda có thể chạy sau khi phương thức chứa nó đã kết thúc và biến cục bộ đã biến mất khỏi ngăn xếp.
Giao tiếp hàm
Một giao tiếp hàm (functional interface) là một interface có đúng một
phương thức trừu tượng (single abstract method). Vì chỉ có một phương thức cần cài,
trình biên dịch suy được phần thân ta cung cấp dành cho phương thức nào, nhờ vậy ta thay
được thể hiện của nó bằng một biểu thức Lambda hoặc một tham chiếu phương thức. Giao
tiếp hàm vẫn được phép chứa thêm phương thức default và static; những phương thức
này không tính vào số phương thức trừu tượng.
@FunctionalInterface
interface Validator {
boolean isValid(String input); // the single abstract method
// default methods do not count toward the SAM rule
default Validator and(Validator other) {
return s -> this.isValid(s) && other.isValid(s); // compose two rules
}
}
Các giao tiếp như Runnable, Comparator, Comparable đã có sẵn từ trước Java 8 và
đều thoả tính chất một phương thức trừu tượng, nên tự động dùng được với Lambda.
Đếm số phương thức cần cài đặt: phải đúng một thì mới là giao tiếp hàm. Số phương
thức default và static không giới hạn vì chúng đã có thân sẵn. Gắn chú giải
@FunctionalInterface để trình biên dịch kiểm tra giúp ràng buộc này.
Bốn giao tiếp hàm chuẩn
Gói java.util.function đặt sẵn một bộ giao tiếp hàm dùng chung. Bốn cái quan trọng
nhất ứng với bốn dạng hành vi cơ bản: biến đổi một giá trị, kiểm tra một điều
kiện, tiêu thụ một giá trị mà không trả về gì, và cung cấp một giá trị mới.
| Giao tiếp | Phương thức | Kiểu | Ý nghĩa |
|---|---|---|---|
Function<T, R> | R apply(T t) | T thành R | Biến đổi đầu vào T thành kết quả R. |
Predicate<T> | boolean test(T t) | T thành boolean | Kiểm tra một điều kiện trên T. |
Consumer<T> | void accept(T t) | T thành (không) | Nhận một T, làm việc gì đó, không trả về. |
Supplier<T> | T get() | (không) thành T | Cung cấp một giá trị T khi được gọi. |
Cách đọc tên rất hệ thống. Function có cả đầu vào và đầu ra nên dùng hai tham số kiểu
T (đầu vào) và R (kết quả). Predicate là trường hợp riêng trả về boolean, dùng
để lọc. Consumer chỉ nhận, không trả gì. Supplier chỉ cho, không nhận gì.
import java.util.function.*;
Function<String, Integer> length = s -> s.length(); // String -> Integer
Predicate<Integer> isEven = n -> n % 2 == 0; // Integer -> boolean
Consumer<String> printer = s -> System.out.println(s); // String -> void
Supplier<String> greeter = () -> "Hello, DAU"; // () -> String
int n = length.apply("functional"); // n = 10
boolean even = isEven.test(n); // even = true
printer.accept("ket qua = " + even); // prints: ket qua = true
String msg = greeter.get(); // msg = "Hello, DAU"
Mỗi giao tiếp còn kèm vài phương thức default để ghép nối. Function có andThen,
compose; Predicate có and, or, negate. Nhờ vậy ta lắp các mẩu hành vi nhỏ
thành hành vi lớn mà không viết lớp mới.
Function<String, String> trim = String::strip;
Function<String, String> upper = String::toUpperCase;
Function<String, String> clean = trim.andThen(upper); // trim first, then upper
System.out.println(clean.apply(" dau ")); // prints: DAU
Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> small = n -> n < 100;
Predicate<Integer> inRange = positive.and(small); // 0 < n < 100
System.out.println(inRange.test(42)); // prints: true
Gói java.util.function còn BiFunction<T, U, R> nhận hai đầu vào, UnaryOperator<T>
là Function có đầu vào và đầu ra cùng kiểu, cùng các bản chuyên cho kiểu nguyên thuỷ
như IntPredicate, IntFunction để tránh đóng hộp. Quen bốn giao tiếp gốc thì đọc tên
các biến thể này là hiểu ngay.
Các biến thể hai đầu vào
Bốn giao tiếp gốc nhận tối đa một đầu vào. Khi cần hai, ta thêm tiền tố Bi.
| Giao tiếp | Phương thức | Ý nghĩa |
|---|---|---|
BiFunction<T, U, R> | R apply(T t, U u) | Hai đầu vào khác kiểu, trả về kiểu thứ ba. |
BiPredicate<T, U> | boolean test(T t, U u) | Kiểm tra điều kiện trên một cặp giá trị. |
BiConsumer<T, U> | void accept(T t, U u) | Nhận hai giá trị, không trả về. |
BinaryOperator<T> | T apply(T a, T b) | BiFunction mà cả ba kiểu đều là T. |
Không có BiSupplier, vì Supplier không nhận đầu vào nào. BiConsumer gặp thường xuyên
hơn bạn tưởng: Map.forEach((k, v) -> ...) chính là nhận một BiConsumer.
Ghép hàm
Function có hai phương thức ghép, khác nhau ở thứ tự chạy. f.andThen(g) chạy f
trước rồi đưa kết quả cho g, đọc xuôi từ trái sang phải. f.compose(g) ngược lại, chạy g
trước rồi mới tới f, giống ký hiệu toán học f(g(x)). Cùng hai hàm con và cùng một đầu
vào, đổi cách ghép là đổi kết quả.
Function<Integer, Integer> nhanHai = n -> n * 2;
Function<Integer, Integer> congBa = n -> n + 3;
Function<Integer, Integer> a = nhanHai.andThen(congBa); // (5 * 2) + 3
System.out.println(a.apply(5)); // prints: 13
Function<Integer, Integer> b = nhanHai.compose(congBa); // (5 + 3) * 2
System.out.println(b.apply(5)); // prints: 16
Cách nhớ chắc nhất là bám vào nghĩa của từ. andThen dịch ra là "rồi sau đó", nên
nhanHai.andThen(congBa) đọc thẳng thành "nhân hai, rồi sau đó cộng ba": hàm nhận lời gọi chạy
trước, hàm trong ngoặc chạy sau, đúng chiều mắt đọc từ trái sang phải. compose mượn ký hiệu hợp
hàm của toán học, f.compose(g) chính là f(g(x)), mà trong toán thì cái nằm trong ngoặc bao giờ
cũng tính trước, nên g chạy trước f. Hai phương thức cho cùng khả năng biểu đạt, chỉ khác chiều
đọc, và trong mã ứng dụng thật thì andThen gần như luôn dễ đọc hơn.
Predicate có bộ ghép riêng gồm and, or, negate. Nhờ chúng, ta xây một điều kiện phức tạp từ
các mảnh nhỏ, mỗi mảnh đặt tên riêng và kiểm thử riêng được, thay vì nhét cả một biểu thức luận lý
dài vào một dòng if. Một chi tiết đáng biết: and và or là ngắn mạch y như && và ||, nếu vị
từ đầu đã quyết định được kết quả thì vị từ sau không hề được gọi, nên đặt vị từ rẻ lên trước là
tiết kiệm thật khi vị từ kia phải truy vấn cơ sở dữ liệu.
import java.util.List;
import java.util.function.Predicate;
public class LocSinhVien {
public record SinhVien(String ten, double diem, boolean daDongHocPhi) {}
// one filter method serves every rule the caller can invent
public static List<SinhVien> loc(List<SinhVien> ds, Predicate<SinhVien> dieuKien) {
return ds.stream().filter(dieuKien).toList();
}
public static void chay(List<SinhVien> ds) {
Predicate<SinhVien> gioi = sv -> sv.diem() >= 8.0;
Predicate<SinhVien> daDong = SinhVien::daDongHocPhi;
Predicate<SinhVien> chuaDong = daDong.negate(); // logical not
// short-circuit: daDong is only tested when gioi already passed
System.out.println(loc(ds, gioi.and(daDong)).size());
System.out.println(loc(ds, chuaDong).size());
}
}
Đây chính là lời hứa ở đầu bài được thực hiện: chỉ một phương thức loc, còn mọi tiêu chí lọc
trở thành giá trị truyền vào và ghép lại được với nhau. Thêm một tiêu chí mới không còn nghĩa là
chép thêm một phương thức, mà chỉ là khai báo thêm một Predicate.
andThen nghĩa là "rồi sau đó", nên cái viết trước chạy trước; compose mượn ký hiệu toán
f(g(x)), nên cái trong ngoặc chạy trước. Đừng đoán bừa, phân vân thì cứ dùng andThen.
Lỗi thứ hai: trong thân Lambda, bạn chỉ đọc được biến cục bộ không bị gán lại sau khi
khởi tạo (effectively final). Viết int dem = 0; rồi trong Lambda làm dem++ sẽ không
biên dịch được. Cần đếm thì dùng count(), cần cộng dồn thì dùng reduce.
Khi nào dùng phong cách chức năng
Nên dùng khi xử lý một tập dữ liệu theo kiểu lọc, biến đổi, tổng hợp; khi một khung xử lý
cố định cần nhận nhiều mẩu logic khác nhau (bộ lọc, bộ so sánh, bộ xử lý sự kiện); khi muốn
hoãn thực thi tới lúc thật sự cần, vì Supplier chỉ tạo giá trị khi ai đó gọi get().
Nên tránh khi logic nhiều nhánh rẽ và ngoại lệ đan nhau, vì Lambda không xử lý ngoại lệ
có kiểm tra một cách gọn gàng; khi công việc là thay đổi trạng thái từng bước; khi cần break
sớm hoặc cần cả chỉ số lẫn giá trị.
// imperative: describe HOW, step by step, with mutable state
double tongLap = 0;
for (SinhVien sv : ds) {
if (sv.diem() >= 8.0) tongLap += sv.diem();
}
// functional: describe WHAT, no mutable state
double tongStream = ds.stream()
.filter(sv -> sv.diem() >= 8.0) // what to keep
.mapToDouble(SinhVien::diem) // what to extract
.sum(); // what to compute
Lối mệnh lệnh mô tả cách làm, lối chức năng mô tả cái cần. Nhưng một vòng for rõ
ràng vẫn tốt hơn một dây chuyền hàm rối rắm.
Câu vừa rồi đáng được nói kỹ, vì người mới học hay đi quá đà và ép mọi thứ vào dây chuyền hàm. Có bốn tình huống mà phong cách chức năng là lựa chọn tệ hơn.
Vòng lặp mang trạng thái. Khi việc cần làm bản chất là cập nhật một trạng thái qua từng bước,
chẳng hạn chạy một máy trạng thái, hay khi bước sau phụ thuộc kết quả bước trước theo cách không gộp
lại thành một phép cộng dồn đơn giản, thì ép vào reduce cho ra biểu thức rối hơn hẳn vòng for
đọc thẳng.
Cần thoát sớm hoặc cần chỉ số. Vòng for có break và continue, dây chuyền hàm thì không.
Stream bù lại bằng findFirst hay anyMatch cho vài trường hợp, nhưng nếu logic thoát sớm phức tạp
hơn thế, hoặc nếu ta cần cả chỉ số lẫn giá trị, thì vòng lặp thắng.
Hiệu năng ở đường nóng. Mỗi Lambda là một đối tượng, mỗi thao tác trung gian dựng thêm một tầng
bọc, và Stream<Integer> còn phải đóng hộp rồi mở hộp từng số. Trên vài chục lần lặp thì chi phí ấy
vô hình, trên vài chục triệu lần thì đo được. Nếu buộc phải viết theo lối hàm ở đó, ít nhất hãy dùng
IntStream để tránh đóng hộp, và chỉ đổi sang vòng lặp sau khi đã đo chứ đừng tối ưu theo cảm tính.
Cần gỡ lỗi từng bước. Đây là điểm yếu bị đánh giá thấp nhất. Đặt điểm dừng vào giữa một vòng
for là chuyện tự nhiên, ta xem được mọi biến ở mọi vòng lặp. Đặt điểm dừng vào giữa một dây chuyền
filter().map().collect() thì trình gỡ lỗi nhảy vào ruột thư viện Stream, vết ngăn xếp dài ngoằng
đầy khung nội bộ, còn giá trị trung gian thì không có biến nào để xem.
Kết luận thực dụng: dùng phong cách hàm khi nó làm ý định hiện rõ hơn, quay về vòng lặp khi nó làm ý định mờ đi. Tiêu chí là độ rõ của mã, không phải lòng trung thành với một trường phái.
Tham chiếu phương thức
Khi phần thân của một Lambda chỉ làm đúng một việc là gọi lại một phương thức đã có,
viết hẳn Lambda là thừa. Java cho phép trỏ thẳng tới phương thức đó qua tham chiếu
phương thức (method reference), dùng cú pháp hai dấu hai chấm ::. Ví dụ
s -> s.length() viết gọn thành String::length. Có bốn dạng, phân theo cái đứng
trước dấu ::.
| Dạng | Cú pháp | Lambda tương đương |
|---|---|---|
| Phương thức tĩnh | Integer::parseInt | s -> Integer.parseInt(s) |
| Phương thức của một đối tượng cụ thể | System.out::println | x -> System.out.println(x) |
| Phương thức của một đối tượng bất kỳ thuộc kiểu | String::toUpperCase | s -> s.toUpperCase() |
| Hàm dựng | ArrayList::new | () -> new ArrayList<>() |
Dạng dễ nhầm nhất là hai dòng giữa. Với System.out::println, đối tượng System.out
đã cố định nên giá trị truyền vào trở thành tham số của println. Với
String::toUpperCase, không có đối tượng cố định, nên giá trị truyền vào chính là đối
tượng nhận lời gọi toUpperCase().
Function<String, Integer> parse = Integer::parseInt; // static method
Consumer<String> print = System.out::println; // bound instance method
Function<String, String> upper = String::toUpperCase; // unbound instance method
Supplier<List<String>> maker = ArrayList::new; // constructor reference
System.out.println(parse.apply("17") + 1); // prints: 18
print.accept(upper.apply("dau")); // prints: DAU
List<String> list = maker.get(); // creates a new empty ArrayList
Tham chiếu phương thức không gọi phương thức ngay tại chỗ viết. String::toUpperCase
là một giá trị kiểu giao tiếp hàm, sẽ được gọi về sau khi ta dùng apply. Đừng
nhầm với "dau".toUpperCase() vốn chạy ngay lập tức.
Bài tập thực hành
Bài tập 1: ghép nối Predicate
Tạo hai Predicate<String>: một kiểm tra chuỗi dài hơn ba ký tự, một kiểm tra chuỗi bắt
đầu bằng chữ hoa. Ghép chúng bằng and thành một điều kiện duy nhất rồi kiểm tra trên
vài chuỗi mẫu.
Lời giải chi tiết
Bước 1: viết từng vị từ bằng Lambda. Bước 2: dùng phương thức default là and để ghép.
Vì Predicate là giao tiếp hàm, mỗi Lambda là một thể hiện của nó, và and trả về một
Predicate mới gọi cả hai vị từ con.
import java.util.function.Predicate;
Predicate<String> longEnough = s -> s.length() > 3;
Predicate<String> capital = s -> Character.isUpperCase(s.charAt(0));
Predicate<String> both = longEnough.and(capital); // compose two rules
System.out.println(both.test("Hello")); // true: long and capital
System.out.println(both.test("hi")); // false: too short
System.out.println(both.test("door")); // false: not capital
Ý chính: ghép vị từ nhỏ thành vị từ lớn bằng and/or/negate, không cần viết lớp mới.
Bài tập 2: tham chiếu phương thức bốn dạng
Cho một List<String> các từ. Dùng tham chiếu phương thức (không viết Lambda đầy đủ) để:
in từng từ ra màn hình bằng System.out::println, và đổi từng từ sang chữ hoa bằng
String::toUpperCase. Giải thích vì sao hai tham chiếu này thuộc hai dạng khác nhau.
Lời giải chi tiết
System.out::println là tham chiếu phương thức của một đối tượng cụ thể: đối tượng
System.out đã cố định, từ được truyền vào trở thành tham số. String::toUpperCase là
tham chiếu phương thức của một đối tượng bất kỳ thuộc kiểu: không có đối tượng cố
định, mỗi từ chính là đối tượng nhận lời gọi.
import java.util.List;
List<String> words = List.of("an", "binh", "cuong");
// bound instance method reference: System.out is fixed
words.forEach(System.out::println); // an, binh, cuong
// unbound instance method reference: each element is the receiver
words.stream()
.map(String::toUpperCase) // s -> s.toUpperCase()
.forEach(System.out::println); // AN, BINH, CUONG
Ý chính: cùng cú pháp :: nhưng vế trước quyết định giá trị truyền vào là tham số
hay là đối tượng nhận lời gọi.
Bài tập 3: đoán kết quả của andThen và compose
Cho f = n -> n * 3 và g = n -> n - 1, cùng kiểu Function<Integer, Integer>. Đoán giá
trị của f.andThen(g).apply(4) và f.compose(g).apply(4) trước khi chạy thử.
Lời giải chi tiết
andThen chạy hàm nhận lời gọi trước: f rồi mới g. compose chạy hàm trong ngoặc trước:
g rồi mới f.
Function<Integer, Integer> f = n -> n * 3;
Function<Integer, Integer> g = n -> n - 1;
System.out.println(f.andThen(g).apply(4)); // (4 * 3) - 1 = 11
System.out.println(f.compose(g).apply(4)); // (4 - 1) * 3 = 9
Ý chính: cùng hai hàm con, đổi thứ tự ghép là đổi kết quả.
Bài tập 4: biến hàm không thuần thành hàm thuần
Phương thức congDiemThuong dưới đây đọc một trường heSo bên ngoài và sửa thẳng vào danh sách
người gọi truyền vào. Hãy chỉ ra nó không thuần ở những điểm nào, viết lại thành hàm thuần, rồi nêu
bản mới dễ kiểm thử hơn ở chỗ nào.
private double heSo = 1.1;
// impure on purpose: reads a field, and mutates its own argument
public void congDiemThuong(java.util.List<Double> diem) {
for (int i = 0; i < diem.size(); i++) {
diem.set(i, diem.get(i) * heSo);
}
}
Lời giải chi tiết
Nó không thuần vì hai lý do độc lập. Thứ nhất, kết quả phụ thuộc trường heSo nằm ngoài danh sách
tham số, nên cùng một đầu vào có thể cho hai đầu ra khác nhau nếu ai đó đổi heSo giữa chừng. Thứ
hai, nó sửa thẳng vào danh sách người gọi truyền vào, tức gây tác dụng phụ lên dữ liệu không thuộc
về nó, và người gọi mất bản gốc mà không hề được báo trước.
import java.util.List;
public class TinhDiem {
// pure: output depends only on the arguments, nothing outside is touched
public static List<Double> congDiemThuong(List<Double> diem, double heSo) {
return diem.stream()
.map(d -> d * heSo) // build new values
.toList(); // hand back a new immutable list
}
}
Bản mới dễ kiểm thử hơn vì một trường hợp kiểm thử chỉ cần truyền vào rồi so kết quả, không phải
dựng trạng thái của đối tượng trước khi gọi và không phải đặt lại heSo giữa các lần chạy. Nó cũng
an toàn khi chạy song song vì không có trạng thái chia sẻ, và người gọi giữ nguyên được danh sách gốc.
Câu hỏi tự kiểm
- 1f.andThen(g).apply(x) chạy hai hàm theo thứ tự nào?
- 2Giao tiếp nào KHÔNG tồn tại trong gói java.util.function?
- 3Đâu là đặc điểm của một hàm thuần?
- 4Trong p1.and(p2), nếu p1 trả về false thì điều gì xảy ra?
Tóm tắt
Bài này đi từ một quan sát đơn giản: rất nhiều đoạn mã có chung một khung xử lý và chỉ khác nhau ở một mẩu logic nhỏ, nên nếu mẩu logic ấy truyền đi được như một giá trị thì ta viết khung một lần rồi tái dùng mãi. Java 8 hiện thực hoá ý đó bằng giao tiếp hàm và Lambda; kỹ thuật không mới, cái mới là cú pháp đủ ngắn để người ta thật sự chịu dùng.
Nền tảng tư duy là hàm thuần và tính bất biến, vì chúng cho ta suy luận cục bộ, kiểm thử dễ và an
toàn khi chạy song song; chiến lược đúng là viết lõi tính toán bằng hàm thuần rồi dồn tác dụng phụ ra
rìa. Bộ giao tiếp chuẩn phủ bốn dạng hành vi cơ bản, thêm tiền tố Bi khi cần hai đầu vào. Về ghép
hàm, nhớ rằng f.andThen(g) chạy f trước rồi tới g, còn f.compose(g) chạy g trước rồi tới
f, và Predicate.and với Predicate.or là ngắn mạch nên đặt vị từ rẻ lên trước.
Cuối cùng, phong cách hàm là công cụ chứ không phải tín ngưỡng. Nó toả sáng khi ta lọc, biến đổi, tổng hợp một tập dữ liệu; nó lép vế khi công việc là cập nhật trạng thái từng bước, khi cần thoát sớm hay cần chỉ số, khi đang ở đường nóng về hiệu năng, và khi cần gỡ lỗi từng bước.
Ghi nhớ
- Hàm thuần: cùng đầu vào cho cùng đầu ra, không tác dụng phụ. Bất biến: sửa là tạo mới.
- Giao tiếp hàm có đúng một phương thức trừu tượng, nên thay được bằng Lambda.
- Bốn giao tiếp gốc:
Functionbiến đổi,Predicatekiểm tra,Consumernhận,Suppliercho; thêm tiền tốBikhi cần hai đầu vào. - Ghép hàm:
andThenchạy trái trước,composechạy phải trước;Predicatecóand/or/negate.