Chuyển tới nội dung chính

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.

Lỗi thường gặp với tác dụng phụ trong Lambda
  • 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 sang parallelStreamArrayList không an toàn đa luồng; cách đúng là map rồi collect.
  • 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đú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 defaultstatic; 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.

Cách nhận ra giao tiếp hàm

Đế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 defaultstatic 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ếpPhương thứcKiểuÝ nghĩa
Function<T, R>R apply(T t)T thành RBiến đổi đầu vào T thành kết quả R.
Predicate<T>boolean test(T t)T thành booleanKiể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 TCung 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ì.

Bốn giao tiếp hàm chuẩn
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. FunctionandThen, compose; Predicateand, 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
Còn nhiều biến thể

Gói java.util.function còn BiFunction<T, U, R> nhận hai đầu vào, UnaryOperator<T>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ếpPhươ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: andor là ngắn mạch y như &&||, 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.

Hai lỗi kinh điển

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 forbreakcontinue, 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ạngCú phápLambda tương đương
Phương thức tĩnhInteger::parseInts -> Integer.parseInt(s)
Phương thức của một đối tượng cụ thểSystem.out::printlnx -> System.out.println(x)
Phương thức của một đối tượng bất kỳ thuộc kiểuString::toUpperCases -> s.toUpperCase()
Hàm dựngArrayList::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
Chưa gọi ngay

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 defaultand để 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 * 3g = n -> n - 1, cùng kiểu Function<Integer, Integer>. Đoán giá trị của f.andThen(g).apply(4)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

Kiểm tra nhanh: lập trình chức năng0/4 đúngchưa trả lời
  1. 1f.andThen(g).apply(x) chạy hai hàm theo thứ tự nào?
  2. 2Giao tiếp nào KHÔNG tồn tại trong gói java.util.function?
  3. 3Đâu là đặc điểm của một hàm thuần?
  4. 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: Function biến đổi, Predicate kiểm tra, Consumer nhận, Supplier cho; thêm tiền tố Bi khi cần hai đầu vào.
  • Ghép hàm: andThen chạy trái trước, compose chạy phải trước; Predicateand/or/negate.