1. บทนำ
บทความนี้เป็นคำแนะนำเกี่ยวกับอินเทอร์เฟซการทำงานต่างๆที่มีอยู่ใน Java 8 กรณีการใช้งานทั่วไปและการใช้งานในไลบรารี JDK มาตรฐาน
2. Lambdas ใน Java 8
Java 8 นำการปรับปรุงไวยากรณ์ใหม่ที่มีประสิทธิภาพมาใช้ในรูปแบบของนิพจน์แลมบ์ดา แลมบ์ดาเป็นฟังก์ชันที่ไม่ระบุตัวตนซึ่งสามารถจัดการได้ในฐานะพลเมืองชั้นหนึ่งเช่นส่งผ่านไปยังหรือส่งคืนจากเมธอด
ก่อน Java 8 คุณมักจะสร้างคลาสสำหรับทุกกรณีที่คุณต้องการห่อหุ้มฟังก์ชันการทำงานเพียงชิ้นเดียว นี่เป็นการบ่งบอกถึงรหัสสำเร็จรูปที่ไม่จำเป็นจำนวนมากเพื่อกำหนดสิ่งที่ทำหน้าที่เป็นตัวแทนฟังก์ชันดั้งเดิม
Lambdas อินเทอร์เฟซที่ใช้งานได้และแนวทางปฏิบัติที่ดีที่สุดในการทำงานกับพวกเขาโดยทั่วไปมีอธิบายไว้ในบทความ“ แลมด้านิพจน์และอินเทอร์เฟซการทำงาน: คำแนะนำและแนวทางปฏิบัติที่ดีที่สุด” คู่มือนี้มุ่งเน้นไปที่อินเทอร์เฟซการทำงานเฉพาะบางอย่างที่มีอยู่ในแพ็คเกจjava.util.function
3. อินเทอร์เฟซการทำงาน
แนะนำให้ใช้อินเทอร์เฟซที่ใช้งานได้ทั้งหมดมีคำอธิบายประกอบ@FunctionalInterface ที่ให้ข้อมูล สิ่งนี้ไม่เพียง แต่สื่อสารถึงวัตถุประสงค์ของอินเทอร์เฟซนี้อย่างชัดเจน แต่ยังช่วยให้คอมไพเลอร์สร้างข้อผิดพลาดได้หากอินเทอร์เฟซที่มีคำอธิบายประกอบไม่เป็นไปตามเงื่อนไข
อินเทอร์เฟซใด ๆ ที่มี SAM (Single Abstract Method) เป็นอินเทอร์เฟซที่ใช้งานได้และการนำไปใช้งานอาจถือว่าเป็นนิพจน์แลมบ์ดา
โปรดทราบว่าวิธีการเริ่มต้นของ Java 8 ไม่ใช่นามธรรมและไม่นับรวม: อินเทอร์เฟซที่ใช้งานได้อาจยังมีวิธีการเริ่มต้นหลายวิธี คุณสามารถสังเกตสิ่งนี้ได้โดยดูเอกสารของฟังก์ชัน
4. ฟังก์ชั่น
กรณีที่เรียบง่ายและทั่วไปที่สุดของแลมด้าคืออินเทอร์เฟซที่ใช้งานได้กับเมธอดที่รับค่าหนึ่งและส่งกลับค่าอื่น ฟังก์ชันของอาร์กิวเมนต์เดียวนี้แสดงโดยอินเทอร์เฟซฟังก์ชันซึ่งกำหนดพารามิเตอร์ตามประเภทของอาร์กิวเมนต์และค่าที่ส่งคืน:
public interface Function { … }
หนึ่งในการใช้งานประเภทฟังก์ชันในไลบรารีมาตรฐานคือเมธอดMap.computeIfAbsentที่ส่งคืนค่าจากแผนที่ทีละคีย์ แต่จะคำนวณค่าหากไม่มีคีย์อยู่ในแผนที่ ในการคำนวณค่าจะใช้การใช้ฟังก์ชันที่ผ่าน:
Map nameMap = new HashMap(); Integer value = nameMap.computeIfAbsent("John", s -> s.length());
ในกรณีนี้ค่าจะถูกคำนวณโดยใช้ฟังก์ชันกับคีย์ใส่ในแผนที่และส่งคืนจากการเรียกใช้เมธอด โดยวิธีการที่เราอาจแทนที่แลมบ์ดามีการอ้างอิงวิธีการที่การแข่งขันผ่านไปและกลับประเภทค่า
โปรดจำไว้ว่าออบเจ็กต์ที่เรียกใช้เมธอดนั้นอันที่จริงแล้วอาร์กิวเมนต์แรกโดยปริยายของเมธอดซึ่งอนุญาตให้ส่งการอ้างอิงความยาวของวิธีอินสแตนซ์ไปยังอินเทอร์เฟซฟังก์ชัน :
Integer value = nameMap.computeIfAbsent("John", String::length);
ฟังก์ชั่นอินเตอร์เฟซที่ยังมีค่าเริ่มต้นการเขียนวิธีการที่จะช่วยให้การรวมฟังก์ชั่นหลายเป็นหนึ่งและดำเนินการให้ตามลำดับ:
Function intToString = Object::toString; Function quote = s -> "'" + s + "'"; Function quoteIntToString = quote.compose(intToString); assertEquals("'5'", quoteIntToString.apply(5));
quoteIntToStringฟังก์ชั่นคือการรวมกันของคำพูดที่ฟังก์ชั่นที่ใช้กับผลจากการintToStringฟังก์ชั่น
5. ความเชี่ยวชาญเฉพาะด้านฟังก์ชันดั้งเดิม
เนื่องจากชนิดดั้งเดิมไม่สามารถเป็นอาร์กิวเมนต์ประเภททั่วไปได้จึงมีเวอร์ชันของอินเทอร์เฟซฟังก์ชันสำหรับประเภทดั้งเดิมที่ใช้มากที่สุดdouble , int , longและการรวมกันในประเภทอาร์กิวเมนต์และประเภทผลตอบแทน:
- IntFunction , LongFunction , DoubleFunction:อาร์กิวเมนต์เป็นประเภทที่ระบุประเภทการส่งคืนเป็นพารามิเตอร์
- ToIntFunction , ToLongFunction , ToDoubleFunction: return type เป็นชนิดที่ระบุอาร์กิวเมนต์เป็นพารามิเตอร์
- DoubleToIntFunction , DoubleToLongFunction , IntToDoubleFunction , IntToLongFunction , LongToIntFunction , LongToDoubleFunction - มีทั้งอาร์กิวเมนต์และประเภทการส่งคืนที่กำหนดเป็นชนิดดั้งเดิมตามที่ระบุโดยชื่อ
ไม่มีอินเทอร์เฟซที่ใช้งานได้ทันทีสำหรับเช่นฟังก์ชันที่ใช้เวลาสั้น ๆและส่งกลับไบต์แต่ไม่มีอะไรหยุดคุณจากการเขียนของคุณเอง
@FunctionalInterface public interface ShortToByteFunction { byte applyAsByte(short s); }
ตอนนี้เราสามารถเขียนวิธีการที่แปลงอาร์เรย์สั้นเป็นอาร์เรย์ของไบต์โดยใช้กฎที่กำหนดโดยShortToByteFunction :
public byte[] transformArray(short[] array, ShortToByteFunction function) { byte[] transformedArray = new byte[array.length]; for (int i = 0; i < array.length; i++) { transformedArray[i] = function.applyAsByte(array[i]); } return transformedArray; }
นี่คือวิธีที่เราสามารถใช้มันเพื่อเปลี่ยนอาร์เรย์ของกางเกงขาสั้นเป็นอาร์เรย์ของไบต์คูณด้วย 2:
short[] array = {(short) 1, (short) 2, (short) 3}; byte[] transformedArray = transformArray(array, s -> (byte) (s * 2)); byte[] expectedArray = {(byte) 2, (byte) 4, (byte) 6}; assertArrayEquals(expectedArray, transformedArray);
6. ความเชี่ยวชาญของฟังก์ชัน Two-Arity
เพื่อกำหนด lambdas มีสองข้อโต้แย้งเราจะต้องใช้อินเตอร์เฟซเพิ่มเติมที่มี“ Bi”คำหลักในชื่อของเขาBiFunction , ToDoubleBiFunction , ToIntBiFunctionและToLongBiFunction
BiFunctionมีทั้งอาร์กิวเมนต์และประเภทการส่งคืนที่ระบุไว้ในขณะที่ToDoubleBiFunctionและอื่น ๆ อนุญาตให้คุณส่งคืนค่าดั้งเดิม
หนึ่งในตัวอย่างทั่วไปของการใช้อินเทอร์เฟซนี้ใน API มาตรฐานอยู่ในเมธอดMap.replaceAllซึ่งอนุญาตให้แทนที่ค่าทั้งหมดในแผนที่ด้วยค่าที่คำนวณได้
ลองใช้การใช้งานBiFunctionที่ได้รับคีย์และค่าเก่าเพื่อคำนวณค่าใหม่สำหรับเงินเดือนและส่งคืน
Map salaries = new HashMap(); salaries.put("John", 40000); salaries.put("Freddy", 30000); salaries.put("Samuel", 50000); salaries.replaceAll((name, oldValue) -> name.equals("Freddy") ? oldValue : oldValue + 10000);
7. ซัพพลายเออร์
The Supplier functional interface is yet another Function specialization that does not take any arguments. It is typically used for lazy generation of values. For instance, let's define a function that squares a double value. It will receive not a value itself, but a Supplier of this value:
public double squareLazy(Supplier lazyValue) { return Math.pow(lazyValue.get(), 2); }
This allows us to lazily generate the argument for invocation of this function using a Supplier implementation. This can be useful if the generation of this argument takes a considerable amount of time. We'll simulate that using Guava's sleepUninterruptibly method:
Supplier lazyValue = () -> { Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS); return 9d; }; Double valueSquared = squareLazy(lazyValue);
Another use case for the Supplier is defining a logic for sequence generation. To demonstrate it, let’s use a static Stream.generate method to create a Stream of Fibonacci numbers:
int[] fibs = {0, 1}; Stream fibonacci = Stream.generate(() -> { int result = fibs[1]; int fib3 = fibs[0] + fibs[1]; fibs[0] = fibs[1]; fibs[1] = fib3; return result; });
The function that is passed to the Stream.generate method implements the Supplier functional interface. Notice that to be useful as a generator, the Supplier usually needs some sort of external state. In this case, its state is comprised of two last Fibonacci sequence numbers.
To implement this state, we use an array instead of a couple of variables, because all external variables used inside the lambda have to be effectively final.
Other specializations of Supplier functional interface include BooleanSupplier, DoubleSupplier, LongSupplier and IntSupplier, whose return types are corresponding primitives.
8. Consumers
As opposed to the Supplier, the Consumer accepts a generified argument and returns nothing. It is a function that is representing side effects.
For instance, let’s greet everybody in a list of names by printing the greeting in the console. The lambda passed to the List.forEach method implements the Consumer functional interface:
List names = Arrays.asList("John", "Freddy", "Samuel"); names.forEach(name -> System.out.println("Hello, " + name));
There are also specialized versions of the Consumer — DoubleConsumer, IntConsumer and LongConsumer — that receive primitive values as arguments. More interesting is the BiConsumer interface. One of its use cases is iterating through the entries of a map:
Map ages = new HashMap(); ages.put("John", 25); ages.put("Freddy", 24); ages.put("Samuel", 30); ages.forEach((name, age) -> System.out.println(name + " is " + age + " years old"));
Another set of specialized BiConsumer versions is comprised of ObjDoubleConsumer, ObjIntConsumer, and ObjLongConsumer which receive two arguments one of which is generified, and another is a primitive type.
9. Predicates
In mathematical logic, a predicate is a function that receives a value and returns a boolean value.
The Predicate functional interface is a specialization of a Function that receives a generified value and returns a boolean. A typical use case of the Predicate lambda is to filter a collection of values:
List names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David"); List namesWithA = names.stream() .filter(name -> name.startsWith("A")) .collect(Collectors.toList());
In the code above we filter a list using the Stream API and keep only names that start with the letter “A”. The filtering logic is encapsulated in the Predicate implementation.
As in all previous examples, there are IntPredicate, DoublePredicate and LongPredicate versions of this function that receive primitive values.
10. Operators
Operator interfaces are special cases of a function that receive and return the same value type. The UnaryOperator interface receives a single argument. One of its use cases in the Collections API is to replace all values in a list with some computed values of the same type:
List names = Arrays.asList("bob", "josh", "megan"); names.replaceAll(name -> name.toUpperCase());
The List.replaceAll function returns void, as it replaces the values in place. To fit the purpose, the lambda used to transform the values of a list has to return the same result type as it receives. This is why the UnaryOperator is useful here.
Of course, instead of name -> name.toUpperCase(), you can simply use a method reference:
names.replaceAll(String::toUpperCase);
One of the most interesting use cases of a BinaryOperator is a reduction operation. Suppose we want to aggregate a collection of integers in a sum of all values. With Stream API, we could do this using a collector, but a more generic way to do it would be to use the reduce method:
List values = Arrays.asList(3, 5, 8, 9, 12); int sum = values.stream() .reduce(0, (i1, i2) -> i1 + i2);
The reduce method receives an initial accumulator value and a BinaryOperator function. The arguments of this function are a pair of values of the same type, and a function itself contains a logic for joining them in a single value of the same type. Passed function must be associative, which means that the order of value aggregation does not matter, i.e. the following condition should hold:
op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)
The associative property of a BinaryOperator operator function allows to easily parallelize the reduction process.
Of course, there are also specializations of UnaryOperator and BinaryOperator that can be used with primitive values, namely DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator, DoubleBinaryOperator, IntBinaryOperator, and LongBinaryOperator.
11. Legacy Functional Interfaces
อินเทอร์เฟซที่ใช้งานได้ทั้งหมดไม่ปรากฏใน Java 8 อินเทอร์เฟซจำนวนมากจาก Java เวอร์ชันก่อนหน้าเป็นไปตามข้อ จำกัด ของFunctionalInterfaceและสามารถใช้เป็น lambdas ได้ ตัวอย่างที่เห็นได้ชัดคืออินเทอร์เฟซRunnableและCallableที่ใช้ใน API พร้อมกัน ใน Java 8 อินเทอร์เฟซเหล่านี้ถูกทำเครื่องหมายด้วยหมายเหตุประกอบ@FunctionalInterface สิ่งนี้ช่วยให้เราลดความซับซ้อนของรหัสการทำงานพร้อมกันได้อย่างมาก:
Thread thread = new Thread(() -> System.out.println("Hello From Another Thread")); thread.start();
12. บทสรุป
ในบทความนี้เราได้อธิบายถึงอินเทอร์เฟซการทำงานต่างๆที่มีอยู่ใน Java 8 API ซึ่งสามารถใช้เป็นนิพจน์แลมบ์ดาได้ ซอร์สโค้ดของบทความมีอยู่บน GitHub