我平時主要使用 C#、JavaScript 和 TypeScript。但是最近由於某些原因需要用 Java,不得不再撿起來。回想起來,最近一次使用 Java 寫完整的應用程序時,Java 還是 1.4 版本。
這麼多年過去,Java 確實有不少改進,像 Stream,var 之類的,我還是知道一些。但用起來感覺還是有點縛手縛腳,施展不開的感覺。這肯定是和語法習慣有關,但也不乏 Java 自身的原因。比如,我在 C# 中常用的「擴展方法」在 Java 中就沒有。
C# 的「擴展方法」的語法,可以在不修改類定義,也不繼承類的情況下,為某些類及其子類添加公開方法。這些類的對象在調用擴展方法的時候,就跟調用類自己聲明的方法一樣,毫不違和。為了理解這種語法,下面給一個示例(不管你會不會 C#,只要有 OOP 基礎應該都能看明白的示例)
using System;
// 定義一個 Person 類,沒有定義方法
public class Person {
public string Name { get; set; }
}
// 下面這個類中定義擴展方法 PrintName()
public static class PersonExtensions {
public static void PrintName(this Person person) {
Console.WriteLine($"Person name: {person.Name}");
}
}
// 主程序,提供 Main 入口,並在這裏使用擴展方法
public class Program {
public static void Main(string[] args) {
Person person = new Person { Name = "John" };
person.PrintName();
}
}
有 OOP 基礎的開發者可以從常規知識判斷:Person 類沒有定義方法,應該不能調用 person.PrintName()。但既然 PrintName() 是一個靜態方法,那麼應該可以使用 PersonExtensions.PrintName(person)。
的確,如果嘗試了 PersonExtensions.PrintName(person) 這樣的語句就會發現,這句話也可以正確運行。但是注意到 PrintName() 聲明的第一個參數加了 this 修飾。這是 C# 特有的擴展方法語法,編譯器會識別擴展方法,然後將 person.PrintName() 翻譯成 PersonExtensions.PrintName(person) 來調用 —— 這就是一個語法糖。
C# 在 2007 年發佈的 3.0 版本中就添加了「擴展方法」這一語法,那已經是 10 多年前的事情了,不知道 Java 什麼時候能支持呢。不過要説 Java 不支持擴展方法,也不全對。畢竟存在一個叫 Manifold 的東東,以 Java 編譯器插件的形式提供了擴展方法特性,在 IDEA 中需要插件支持,用起來和 C# 的感覺差不多 —— 遺憾的是每月 $19.9 租用費直接把我勸退。
但是程序員往往會有一種不撞南牆不回頭的執念,難道就沒有近似的方法來處理這個問題嗎?
分析痛苦之源
需要使用擴展方法,其實主要原因就一點:想擴展 SDK 中的類,但是又不想用靜態調用形式。尤其是需要鏈式調用的時候,靜態方法真的不好用。還是拿 Person 來舉例(這回是 Java 代碼):
class Person {
private String name;
public Person(String name) { this.name = name; }
public String getName() { return name;}
}
class PersonExtension {
public static Person talk(Person person) { ... }
public static Person walk(Person person) { ... }
public static Person eat(Person person) { ... }
public static Person sleep(Person person) { ... }
}
業務過程是:談妥了出去吃飯,再回來睡覺。用鏈接調用應該是:
person.talk().walk().eat().walk().sleep()
注意:別説改Person,我們假設它是第三方 SDK 封裝好的,PersonExtension才是我們寫的業務處理類
但顯然不能這麼調用,按 PersonExtension 中的方法,應該這麼調用:
sleep(walk(eat(walk(talk(person)))));
痛苦吧?!
痛苦之餘來分析下我們當前的需求:
- 鏈式調用
- 沒別的了……
鏈式調用的典型應用場景
既然需要的就是鏈式調用,那我們來想一想鏈式調用的典型應用場景:建造者模式。如果我們用建造式模式來寫 Extension 類,使用時候把原對象封裝起來,就可以實現鏈式調用了麼?
class PersonExtension {
private final Person person;
PersonExtension(Person person) {
this.person = person;
}
public PersonExtension walk() {
out.println(person.getName() + ":walk");
return this;
}
public PersonExtension talk() {
out.println(person.getName() + ":talk");
return this;
}
public PersonExtension eat() {
out.println(person.getName() + ":eat");
return this;
}
public PersonExtension sleep() {
out.println(person.getName() + ":sleep");
return this;
}
}
用起來很方便:
new PersonExtension(person).talk().walk().eat().walk().sleep();
擴展到一般情況
如果到此為止,這篇博文就太水了。
我們繞了個彎解決了鏈式調用的問題,但是人心總是不容易得到滿足,一個新的要求出現了:擴展方法可以寫無數個擴展類,有沒有辦法讓這無數個類中定義的方法連接調用下去呢?
你看,在當前的封裝類中,我們是沒辦法調用第二個封裝類的方法的。但是,如果我們能從當前封裝類轉換到第二個封裝類,不是就可以了嗎?
這個轉換過程,大概過程是拿到當前封裝的對象(如 person),把它作為參數傳遞下一個封裝類的構造函數,構造這個類的對象,把它作為調用主體繼續寫下去……這樣一 來,我們需要有一個約定:
- 擴展類必須提供一個可傳入封裝對象類型參數的構造函數;
- 擴展類必須實現轉換到另一個擴展類的方法
在程序中,約定通常會用接口來描述,所以這裏定義一個接口:
public interface Extension<T> {
<E extends Extension<T>> E to(Class<E> type);
}
這個接口的意思很明確:
- 被封裝的對象類型是
T to提供從當前Extension對象換到另一個實現了Extension<T>接口的對象上去
可以想象,這個 to 要乾的事情就是去找 E 的構造函數,用它構造一個 E 的對象。這個構造函數需要定義了有唯一參數,且參數類型是 T 或其父類型(可傳入)。這樣在構造 E 對象的時候才能把當前擴展對象中封裝的 T 對象傳遞到 E 對象中去。
如果找不到合適的構造函數,或者構造時發生錯誤,應該拋出異常,用來描述類型 E 不正確。既然 E 是一個類型參數,不妨就使用 IllegalArgumentException 好了。此外,多數擴展類的 to 行為應該是一樣的,可以用默認方法提供支持。另外,還可以給 Extension 加一個靜態的 create() 方法來代替使用 new 創建擴展類對象 —— 讓一切都從 Extension 開始。
完整的 Extension 來了:
public interface Extension<T> {
/**
* 給一個被封裝的對象 value,構造一個 E 類的對象來封裝它。
*/
@SuppressWarnings("unchecked")
static <T, E extends Extension<T>> E create(T value, Class<E> extensionType)
throws IllegalArgumentException {
Constructor<T> cstr = (Constructor<T>) Arrays
.stream(extensionType.getConstructors())
// 在構造方法中找到符合要求的那一個
.filter(c -> c.getParameterCount() == 1
&& c.getParameterTypes()[0].isAssignableFrom(value.getClass())
)
.findFirst()
.orElse(null);
try {
// 如果沒找到合適的構造函數 (cstr == null),或者其他情況下出錯
// 就拋出 IllegalArgumentException
return (E) Objects.requireNonNull(cstr).newInstance(value);
} catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
throw new IllegalArgumentException("invalid implementation of Extension", e);
}
}
// 為了給拿到當前封裝的對象給 wrapTo 用,必須要 getValue() 接口
T getValue();
// wrapTo 接口及其默認實現
default <E extends Extension<T>> E to(Class<E> type) throws IllegalArgumentException {
return create(getValue(), type);
}
}
現在把上面的 PersonExtension 拆成兩個擴展類來作演示:
class PersonExt1 implements Extension<Person> {
private final Person person;
PersonExt1(Person person) { this.person = person; }
@Override
public Person getValue() { return person; }
public PersonExt1 walk() {
out.println(person.getName() + ":walk");
return this;
}
public PersonExt1 talk() {
out.println(person.getName() + ":talk");
return this;
}
}
class PersonExt2 implements Extension<Person> {
private final Person person;
public PersonExt2(Person person) { this.person = person; }
@Override
public Person getValue() { return person; }
public PersonExt2 eat() {
out.println(person.getName() + ":eat");
return this;
}
public PersonExt2 sleep() {
out.println(person.getName() + ":sleep");
return this;
}
}
調用示例:
public class App {
public static void main(String[] args) throws Exception {
Person person = new Person("James");
Extension.create(person, PersonExt1.class)
.talk().walk()
.to(PersonExt2.class).eat()
.to(PersonExt1.class).walk()
.to(PersonExt2.class).sleep();
}
}
結語
總的來説,在沒有語法支持的基礎上要實現擴展方法,基本思路就是
- 認識到目標對象上調用的所謂的擴展方法,實際是靜態方法調用的語法糖。該靜態方法的第一個參數是目標對象。
- 把靜態方法的第一參數拿出來,封裝到擴展類中,同時把靜態方法改為實例方法。這樣來避免調用時傳入目標對象。
- 如果需要鏈式調用,需要通過接口約定並提供一些工具函數來輔助目標對象穿梭於各擴展類之中。
本文主要是嘗試在沒有語法/編譯器支持的情況下在 Java 中模塊 C# 的擴展方法。雖然有結果,但在實際使用中並不見得就好用,請讀者在實際開發時注意分析,酌情考慮。