Java 中模擬 C# 的擴充套件方法

邊城 發表於 2021-11-27
Java C#

我平時主要使用 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)))));

痛苦吧?!

痛苦之餘來分析下我們當前的需求:

  1. 鏈式呼叫
  2. 沒別的了……

鏈式呼叫的典型應用場景

既然需要的就是鏈式呼叫,那我們來想一想鏈式呼叫的典型應用場景:建造者模式。如果我們用建造式模式來寫 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),把它作為引數傳遞下一個封裝類的建構函式,構造這個類的物件,把它作為呼叫主體繼續寫下去……這樣一 來,我們需要有一個約定:

  1. 擴充套件類必須提供一個可傳入封裝物件型別引數的建構函式;
  2. 擴充套件類必須實現轉換到另一個擴充套件類的方法

在程式中,約定通常會用介面來描述,所以這裡定義一個介面:

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();
    }
}

結語

總的來說,在沒有語法支援的基礎上要實現擴充套件方法,基本思路就是

  1. 認識到目標物件上呼叫的所謂的擴充套件方法,實際是靜態方法呼叫的語法糖。該靜態方法的第一個引數是目標物件。
  2. 把靜態方法的第一引數拿出來,封裝到擴充套件類中,同時把靜態方法改為例項方法。這樣來避免呼叫時傳入目標物件。
  3. 如果需要鏈式呼叫,需要通過介面約定並提供一些工具函式來輔助目標物件穿梭於各擴充套件類之中。

本文主要是嘗試在沒有語法/編譯器支援的情況下在 Java 中模組 C# 的擴充套件方法。雖然有結果,但在實際使用中並不見得就好用,請讀者在實際開發時注意分析,酌情考慮。