JDK 25 灵活构造函数体实战:super() 之前终于能写代码了,但有五条线不能踩

2026-09-29 0 324

上周在代码评审里看到一段代码,一个子类的构造函数长这样:

public class RetryPolicy extends BasePolicy {
    private final int maxAttempts;
    private final Duration backoff;

    public RetryPolicy(String name, int maxAttempts, long backoffMillis) {
        super(
            validateName(name),
            normalizeBackoff(backoffMillis),
            buildExtraConfig(maxAttempts, backoffMillis)
        );
        this.maxAttempts = maxAttempts;
        this.backoff = Duration.ofMillis(backoffMillis);
    }

    private static String validateName(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name required");
        }
        return name.trim();
    }

    private static long normalizeBackoff(long ms) {
        return Math.max(ms, 100L);
    }

    private static Map<String, Object> buildExtraConfig(int attempts, long ms) {
        return Map.of("attempts", attempts, "backoff", ms);
    }
}

三个私有静态方法,就为了让 super() 那一行看起来”干净”。问题是这种写法其实一点都不干净:校验逻辑被拆散了,参数之间的关系看不出来,将来加一个校验点又得再开一个静态方法。buildExtraConfig 更别扭——它只是把两个参数打个包,好让父类构造函数能一次性收下。

这就是过去十年 Java 构造函数最典型的妥协。而 JDK 25 里正式发布的 JEP 513(灵活构造函数体),终于能把这段代码改回正常样子了。

先看看旧的限制到底在哪里

Java 从第一天起就有一条规则:构造函数的第一条语句必须是 super(...) 或者 this(...)(不写就是隐式 super())。

这条规则的出发点是好的——保证父类先于子类完成初始化,避免子类构造过程里读到父类未初始化的字段。但副作用是:任何需要”先算一算再调父类”的场景都得绕路。

绕的方式主要有三种,各有各的难受:

第一种是静态辅助方法,也就是评审里看到的那种。缺点是参数校验和它的使用位置距离太远,读代码要在构造函数和辅助方法之间来回跳。而且静态方法里访问不到其它几个参数,某些校验做不了。

第二种是静态工厂方法,把构造函数设成 private,对外只暴露 of() / create()。这个方案本身没问题,但会带来新的问题:不能被子类继承了(因为构造函数私有),而且很多框架依赖公开构造函数做反射实例化,改成静态工厂之后要么加配置要么放弃。

第三种是把校验推迟到 setter 里或者干脆不校验,靠调用方自觉。这个最省事,也最容易埋雷。

我在项目里见过一个极端的例子:某个类的构造函数调了 super(loadConfig()),而 loadConfig() 里会读一个环境变量,读不到就抛异常。结果因为静态初始化顺序的问题,这个异常在某些类加载路径下会变成 ExceptionInInitializerError,排查了半天才找到根因。

JEP 513 到底放开了什么

规则从”第一句必须是 super()”变成了”super() 或 this() 在构造函数里必须被调用且只被调用一次,但不一定是第一句“。

前面可以写什么?官方描述里允许的事情包括:

  • 声明并初始化局部变量
  • 对参数做运算
  • 调用静态方法
  • 使用 if、switch、for 等控制流语句
  • 抛出异常
  • 使用 try/catch/finally

用一句话概括:只要这段代码不碰”这个还没构造完的对象”,就随便写。

把开头那个例子改一下:

public class RetryPolicy extends BasePolicy {
    private final int maxAttempts;
    private final Duration backoff;

    public RetryPolicy(String name, int maxAttempts, long backoffMillis) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name required");
        }
        if (maxAttempts < 1) {
            throw new IllegalArgumentException("maxAttempts must be >= 1");
        }
        if (backoffMillis < 100) {
            backoffMillis = 100;   // 直接改参数,不用绕一个静态方法
        }

        Map<String, Object> extra = Map.of(
            "attempts", maxAttempts,
            "backoff", backoffMillis
        );

        super(name.trim(), backoffMillis, extra);

        this.maxAttempts = maxAttempts;
        this.backoff = Duration.ofMillis(backoffMillis);
    }
}

三个静态辅助方法都没了。校验、归一化、组装参数,全部按顺序写在一个地方,读起来就是一段流水账。参数之间的关系(比如 extra 里放的 attempts 就是构造函数的第二个参数)也一眼能对上。

一个更贴近真实业务的重构

抽象一个具体的场景:我们要写一个分页查询的请求类,它继承一个通用的 BaseQuery。父类需要知道”从哪个游标开始”、”取多少条”、”排序字段是哪些”,而子类还要根据业务类型做一套额外的规则,比如导出场景最多只能取 5000 条,列表场景最多 100 条。

改造前:

public class ReportQuery extends BaseQuery {
    private final String reportType;
    private final LocalDate bizDate;

    public ReportQuery(String reportType, LocalDate bizDate,
                       Integer pageSize, String cursor) {
        super(
            resolveMaxSize(reportType, pageSize),
            cursor == null ? "" : cursor.trim(),
            resolveSort(reportType)
        );
        this.reportType = reportType;
        this.bizDate = bizDate;
    }

    private static int resolveMaxSize(String type, Integer size) {
        int limit = "EXPORT".equals(type) ? 5000 : 100;
        if (size == null) return Math.min(20, limit);
        if (size < 1) throw new IllegalArgumentException("pageSize must be positive");
        return Math.min(size, limit);
    }

    private static List<String> resolveSort(String type) {
        if ("EXPORT".equals(type)) return List.of("created_at", "id");
        return List.of("updated_at");
    }
}

改造后:

public class ReportQuery extends BaseQuery {
    private final String reportType;
    private final LocalDate bizDate;

    public ReportQuery(String reportType, LocalDate bizDate,
                       Integer pageSize, String cursor) {
        if (!"EXPORT".equals(reportType) && !"LIST".equals(reportType)) {
            throw new IllegalArgumentException("unknown reportType: " + reportType);
        }
        if (bizDate == null) {
            throw new IllegalArgumentException("bizDate required");
        }

        int limit = "EXPORT".equals(reportType) ? 5000 : 100;
        int size = pageSize == null ? Math.min(20, limit) : pageSize;
        if (size < 1) {
            throw new IllegalArgumentException("pageSize must be positive");
        }
        if (size > limit) {
            size = limit;   // 静默截断,而不是报错
        }

        String cleanCursor = cursor == null ? "" : cursor.trim();
        List<String> sort = "EXPORT".equals(reportType)
            ? List.of("created_at", "id")
            : List.of("updated_at");

        super(size, cleanCursor, sort);

        this.reportType = reportType;
        this.bizDate = bizDate;
    }
}

改动之后最大的感知不是”少了两个静态方法”,而是业务规则和它的作用范围在一起了。以前看 resolveMaxSize 的时候,得先确认它在哪些地方被调用才能判断影响面;现在这个逻辑只属于这个构造函数,改的时候心理负担小得多。

五条不能踩的线

这个特性看起来很宽松,但实际上编译器管得比我预想的严。第一次改的时候连续撞了几堵墙,列一下。

线一:不能在 super() 之前访问 this

这包括三件事:不能用 this.xxx 读字段、不能调实例方法、不能把 this 传给别人。

// 编译报错:cannot reference this before supertype constructor
public ReportQuery(...) {
    this.reportType = reportType;   // 不行
    super(...);
}

原因不难理解:这时候父类的构造函数还没跑,子类对象处于半初始化状态,任何一个实例方法都可能读到不完整的状态。

线二:不能读字段,哪怕是通过无前缀的简单名

下面这个写法也是错的:

public class Foo extends Bar {
    private int cached;

    public Foo() {
        if (cached > 0) {   // 报错
            super(cached);
        } else {
            super(0);
        }
    }
}

编译器会直接告诉你 cached 在这里不可访问。这个报错信息挺直白,但第一次看到的时候容易反应不过来——明明写的是字段名,为什么说不能访问?因为无前缀的字段引用在编译后就是 this.cached。

线三:super() 或 this() 仍然只能在代码里出现一次

灵活的是位置,不是数量。下面这种不行:

public Foo(int x) {
    if (x > 0) {
        super(x);
    } else {
        super(0);
    }
    // 报错:构造函数里必须恰好有一次 super/this 调用
}

正确的写法是把判断收敛到参数计算上,让 super() 只出现一次:

public Foo(int x) {
    int safe = Math.max(x, 0);
    super(safe);
}

这个限制在 try/catch 里同样适用,需要留意控制流的所有分支最后都汇聚到同一个 super() 上。

线四:不能给 final 字段赋值,等 super() 之后再说

这一点容易忽略。JEP 513 允许在 super() 之前声明局部变量,但“局部变量”不包括 final 字段。所以下面这种写法是编译不过的:

public class Foo extends Bar {
    private final String normalized;

    public Foo(String input) {
        String tmp = input.trim().toLowerCase();   // 局部变量,可以
        super(tmp);
        this.normalized = tmp;                     // 字段赋值必须放在后面
    }
}

庆幸的是这种限制很自然,改写成本几乎为零。

线五:构造过程里抛出的异常,栈帧对调试不太友好

这条不是编译限制,是个使用体会。把大量校验逻辑放在 super() 之前,如果抛异常,堆栈信息会指向构造函数内部的具体行号——大部分情况下没问题,但如果你用了 lambda 或者方法引用,行号可能会指向 super() 那一行,让人误以为是父类抛的。

我的应对办法是把校验抛异常的语句集中放在前面几行,不夹在别的逻辑中间,出问题的时候一眼能看到。这算半个规范,不强制。

什么时候建议用,什么时候不建议用

新语法上市之后总有一种”处处能用”的诱惑。但这几个月改下来,我自己的判断标准是这样:

建议用的场景:

  • 构造函数需要对参数做一次性的校验或者归一化,然后再传给父类;
  • 参数之间需要做一致性检查,比如”如果 A 是 X 类型,那么 B 不能为空”;
  • 父类构造函数的签名和子类的对外签名差别较大,需要在中间做一次转换;
  • 参数组合需要用 switch 或者 if-else 分派。

建议不用、或者改用别方案的场景:

  • 纯数据对象——优先用 record。record 的紧凑构造函数(compact constructor)做参数校验其实比这个特性更直奔主题,而且天然就是不可变对象;
  • 构造逻辑超过 20 行——这时候不是语法的问题,是这个类承担了太多职责,应该拆出一个专门负责参数解析的工厂或者 Builder;
  • 需要在构造过程中做 I/O 或者远程调用——这本身就是反模式,新语法反而会让这种写法变得更”自然”,要小心;
  • 参数合法组合非常多,需要大量条件分支——建议改用 builder,让非法状态根本无法构造出来。

前面第二条是我在一次重构里加的规则。有个类的构造函数在引入新语法之后一口气长到了 40 多行,参数校验、默认值填充、字段转换全塞在一起。虽然编译完全正确,但 reviewer 一致认为应该拆成 XxxParams 加 XxxParams.from(...)。语法给了便利,但设计边界不能因此模糊。

和 record、紧凑构造函数的关系

有人可能会问:既然 record 已经能写紧凑构造函数了,为什么还需要这个特性?

两者面向的问题不一样。

record 的紧凑构造函数处理的是”字段直接从参数赋值”这种情况,语法更短,语义更清晰。它的限制是不能有 extends(record 只能实现接口),所以有继承关系的场景用不了。

灵活构造函数体处理的是”父类构造函数需要参数,而参数得先经过计算”这种场景。它和继承强绑定。

我个人的偏好是:如果一个类没有必须继承的父类,优先写成 record;如果确实需要继承(比如框架要求、或者需要复用父类的行为),那用这个特性去清理 super() 之前的静态辅助方法。

团队落地的几个建议

特性好不好是一回事,能不能在团队里顺畅用起来是另一回事。我们内部推的时候做了三件事,效果还不错。

第一,加一条 checkstyle 规则。 禁止在构造函数里调用私有静态方法做参数预处理,也就是原来那个写法的典型症状。当然有例外情况——静态方法里确实有独立复用价值的情况还是允许的,规则上写成可忽略的软性提示。

第二,重构旧的类时不强制。 已有的代码没必要专门发一个版本去改。什么时候因为别的原因动到了那个类,顺手改一下就行。大规模重构带来的 review 成本反而比收益高。

第三,在团队的编码规范文档里加一节。 把”什么场景用、什么场景不用”写清楚,特别强调”不要为了用新语法而把逻辑堆进构造函数”这一点。这个风险很真实,因为新语法让构造函数”万能”起来,容易让人忽略它其实是对象构造的入口,不是业务逻辑的家。

最后说一句

这个特性单个拎出来看,只是省了几个静态方法,好像没什么大不了。但它解决的是一类积攒了二十年的妥协写法,这一类写法在很多项目里已经固化成了”常识”——大家甚至不觉得它是问题,只会抱怨”Java 构造函数真别扭”。

“别扭”变成了”顺手”,日积月累省下来的阅读成本和重构阻力,比那几行代码本身重要得多。

如果你正打算把项目升到 JDK 25,建议先挑一个身上挂着两三个 validateXxx 静态方法的类,拿它试一次。改写完之后再看代码,感受一下思路有没有变轻。这比看十篇 JEP 解读文章都直观。

JDK 25 灵活构造函数体实战:super() 之前终于能写代码了,但有五条线不能踩
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java JDK 25 灵活构造函数体实战:super() 之前终于能写代码了,但有五条线不能踩 https://www.taomawang.com/server/java/2834.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务