Java 中模拟 C# 的扩展方法

我平时主要使用 C#、JavaScript 和 TypeScript。但是最近由于某些原因需要用 Java,不得不再捡起来。回想起来,最近一次使用 Java 写完整的应用程序时,Java 还是 1.4 版本。
【Java 中模拟 C# 的扩展方法】这么多年过去,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 { > E to(Class type); }

这个接口的意思很明确:
  • 被封装的对象类型是 T
  • to 提供从当前 Extension 对象换到另一个实现了 Extension 接口的对象上去
可以想象,这个 to 要干的事情就是去找 E 的构造函数,用它构造一个 E 的对象。这个构造函数需要定义了有唯一参数,且参数类型是 T 或其父类型(可传入)。这样在构造 E 对象的时候才能把当前扩展对象中封装的 T 对象传递到 E 对象中去。
如果找不到合适的构造函数,或者构造时发生错误,应该抛出异常,用来描述类型 E 不正确。既然 E 是一个类型参数,不妨就使用 IllegalArgumentException 好了。此外,多数扩展类的 to 行为应该是一样的,可以用默认方法提供支持。另外,还可以给 Extension 加一个静态的 create() 方法来代替使用 new 创建扩展类对象 —— 让一切都从 Extension 开始。
完整的 Extension 来了:
public interface Extension { /** * 给一个被封装的对象 value,构造一个 E 类的对象来封装它。 */ @SuppressWarnings("unchecked") static > E create(T value, Class extensionType) throws IllegalArgumentException { Constructor cstr = (Constructor) 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 to(Class type) throws IllegalArgumentException { return create(getValue(), type); } }

现在把上面的 PersonExtension 拆成两个扩展类来作演示:
class PersonExt1 implements Extension { 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 { 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# 的扩展方法。虽然有结果,但在实际使用中并不见得就好用,请读者在实际开发时注意分析,酌情考虑。

    推荐阅读