ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java私有属性与构造方法:从封装到不可变对象的设计实践

Java私有属性与构造方法:从封装到不可变对象的设计实践 前几天review新同事的代码看到一个很有意思的写法订单类里的金额字段直接public构造方法里只做了简单赋值没有任何校验。我问他为什么不把字段设为private他说“反正都在一个模块里能不能直接改无所谓”。这个回答让我决定把这篇文章写出来。私有属性和构造方法是Java里最基础的两个语法点几乎每本教材都会讲但真正到了生产代码里它们承担的责任远比语法本身复杂一个关系到所有调用方能否随意改动对象状态一个决定了对象能否以合法状态诞生。这篇笔记不打算重复教科书上的定义而是从我踩过的坑、review过的代码和面试中被反复追问的角度把这两块内容串起来讲清楚。如果你正在学Java基础或者准备面试或者单纯想看看别人写业务代码时是怎么思考封装的都可以往下看。1. 先搞清楚私有属性到底在保护什么很多初学者把private简单理解为“不让别人访问我的数据”这个理解不算错但太浅了。private真正提供的不是信息保密而是变更入口的收敛。你希望一个字段被改动时改动只发生在受控的代码路径上而不是散落在整个工程的任意角落。1.1 一个public字段引发的连锁修改假设你写了一个订单类public class Order { public BigDecimal amount; // 单位分 }开发初期一切正常大家直接order.amount new BigDecimal(100)。半年后需求变了金额需要同时记录币种且必须保证非负单位也要从分改成元。这时候你面临的是全局搜索“amount”赋值点挨个判断语义再修改调用逻辑。如果这个字段被100个地方引用你就得改100处漏掉一处就是一次线上事故。但如果你一开始就写成privatepublic class Order { private BigDecimal amount; }后续所有读写都收敛到方法里币种、非负校验、单位换算都只改一处。关键区别就在这里public字段把“维护对象状态合法”的责任分摊给了所有调用方而private把责任收回到了对象自己身上。现代Java代码里public字段也不是完全不能用——纯数据载体比如DTO、配置类没有业务规则用public反而省事。但一旦字段有真实的业务含义就必须用private包起来。1.2 可见性边界你最容易记错的那几个Java的四种访问级别经常在面试里被问到真正写代码时最容易搞混的是private和默认package-private的区别。修饰符同类同包子类任意类private是否否否默认不写是是否否protected是是是否public是是是是特别强调一点private不是包内可见。同包的其他类也访问不了private字段很多人把“默认可见性包可见”和private搞混一不留神就翻车。另外要泼一盆冷水private的限制只在编译期和普通运行期路径上有效反射可以绕过去后面专门讲。所以不要把密码、密钥这类敏感信息的安全性寄托在private上——那是加密和权限系统的职责。private保护的是“代码路径上的可控性”不是信息论意义上的保密。1.3 getter/setter不一定是封装我见过太多类是这样写的字段设为private然后IDE一键生成getter/setter就认为“我封装好了”。这其实只是把public字段换成了private字段加public方法并没有真正带来封装的收益。举个例子你给金额字段加了一个setterpublic void setAmount(BigDecimal amount) { this.amount amount; }调用方依然可以随时把金额改成负数和直接public字段有什么区别唯一的区别是调用方多敲了几个字符。封装的本质是对外暴露一个稳定的操作接口把内部状态变化的规则关在门里面。真正有业务约束的对象不该提供无脑setter而应该通过业务方法去表达操作语义public class Order { private OrderStatus status; public void pay() { if (status ! OrderStatus.CREATED) { throw new IllegalStateException(订单不在可支付状态); } this.status OrderStatus.PAID; } }status字段永远没有public setter外部想改变状态只能调用业务方法。这样才不会出现“直接调setStatus(CANCELED)跳过校验”的漏洞。这是我review代码时最常提的意见setter不是不能用但每个setter都得回答一个问题——“谁能合法地调用它它允许哪些值”。2. 构造方法里的隐藏规则默认构造、初始化顺序与参数校验构造方法是对象生命周期的起点。可惜大部分教程只在讲“构造方法的名字和类名相同、没有返回值”之后就草草带过真正写代码时才会发现里面全是细节。2.1 默认构造方法是怎么“悄悄消失”的当类里一个构造方法都没写时编译器会自动生成一个无参构造方法。但只要你写了一个带参构造方法那个无参构造就立刻消失。这个知识点看着简单实际生产里坑过我好几次。最典型的场景一个类原来没有构造方法Spring容器通过无参构造反射创建对象一切正常。某天你为了强制必填字段加了一个带参构造方法忘记补无参构造。项目启动时直接报BeanCreationException排查半天才发现是默认构造被覆盖了。所以规则很简单如果你的类被框架反射创建Spring Bean、JPA实体、DTO反序列化等无参构造几乎是必须的。即使你提供了带参构造也尽量保留一个无参构造除非你明确知道不需要。如果你希望类只能通过特定方式创建后面讲单例和工具类才刻意不提供无参构造。2.2 初始化顺序从静态块到构造方法的执行链路构造方法不是对象初始化链路上的唯一环节。一个Java对象在创建时执行顺序是固定的父类静态变量/静态块子类静态变量/静态块父类实例变量/实例块父类构造方法子类实例变量/实例块子类构造方法可以跑一个简单例子验证class Parent { static { System.out.println(Parent static); } { System.out.println(Parent instance); } Parent() { System.out.println(Parent constructor); } } class Child extends Parent { static { System.out.println(Child static); } { System.out.println(Child instance); } Child() { System.out.println(Child constructor); } }执行new Child()输出顺序是Parent static Child static Parent instance Parent constructor Child instance Child constructor为什么父类构造方法先执行因为子类对象的内存空间里包含父类的那部分结构必须先把父类部分初始化好子类才能在此基础上使用那些继承来的方法和字段。这和盖房子必须先打地基是一个道理。面试里常问的一个变体是构造方法里能不能调用可被重写的方法答案是能调但强烈不建议。因为在父类构造方法执行阶段子类对象还没完全初始化这时调用被子类重写的方法方法内部如果读取了子类字段拿到的是默认值0或null。这是经典Bug来源我见过不止一次线上数据被写坏追查到底都是构造方法里多态调用惹的祸。2.3 this()和super()构造方法重载的规矩构造方法可以重载多个构造方法之间可以通过this()互相调用子类构造方法可以通过super()调用父类构造方法。这里有两条硬规矩this()和super()必须是构造方法体的第一行代码。this()和super()不能出现在同一个构造方法里。如果父类没有无参构造方法子类构造方法里必须显式调用super(参数)否则编译直接报错。这其实是个很好的设计强制点父类需要参数才能正确初始化时编译器逼迫子类把参数传上来。构造方法重载时我习惯让一个核心构造方法做真正的初始化逻辑其他构造方法通过this()委托给它。这样校验和赋值逻辑只写一遍不会出现“两个构造方法初始化的字段不一致”的隐患。2.4 参数校验放在构造方法里是最早的一道防线构造方法是对象合法状态的第一道关卡。把非空、边界、格式校验放在这里对象一旦创建出来就是合法的后面所有方法都不用再做一遍防御性判断。public class Account { private final String id; private final BigDecimal balance; public Account(String id, BigDecimal balance) { this.id Objects.requireNonNull(id, id must not be null); if (balance null || balance.signum() 0) { throw new IllegalArgumentException(balance must be non-null and non-negative); } this.balance balance; } }注意到几个细节Objects.requireNonNull专门处理非空校验抛的是NPE语义明确。业务边界校验比如金额非负抛IllegalArgumentException不要在这里吞掉异常或只打日志。final字段在构造方法里赋初值后面再也不用担心被二次赋值。有人会问参数校验放setter里不也一样吗区别在于setter是创建后的修改路径而构造方法是创建路径。前者只能保证“调用者守规矩时没问题”却防不住“创建时就没把数据传对”。把校验放在构造方法里等于在源头设卡比下游设卡更省力。3. 私有属性与构造方法配合的经典设计不可变对象、静态工厂与Builderprivate字段和构造方法单独看都很朴素但它们是很多经典设计的基础材料。这一章讲三种最常见的配合方式都是我在生产环境里反复用到的。3.1 不可变对象是私有final字段的最佳实践组合Java标准库里最典型的不可变对象就是String、BigDecimal、LocalDate。不可变对象有几个硬性条件类是final的防止子类破坏不可变性。所有字段都是private final的。构造方法里完成所有字段的赋值创建后不提供任何setter。如果字段是可变引用类型比如集合构造方法里要防御性拷贝getter也不能直接返回原始引用。public final class Order { private final String orderId; private final ListItem items; public Order(String orderId, ListItem items) { this.orderId orderId; this.items Collections.unmodifiableList(new ArrayList(items)); } public String getOrderId() { return orderId; } public ListItem getItems() { return items; // 已经包装成不可修改视图可以直接返回 } }这段代码里有两个关键动作new ArrayList(items)是拷贝入参防止外部list之后被修改把脏数据渗进Order内部。Collections.unmodifiableList(...)是包装输出防止getter拿到list之后调用方直接add修改内部状态。不可变对象的好处是实实在在的天然线程安全多个线程共享同一个实例不需要加锁因为状态不变缓存起来非常安全用作HashMap的key时hashCode不会因为内部字段变化而失效。我写金额、日期、配置这类高频共享的数据时几乎一律设计成不可变对象。3.2 静态工厂方法加私有构造方法把new关进笼子里当一个类的构造方法是private时外部就没办法直接new它了。这时候类的内部可以通过静态工厂方法创建实例并对外暴露。这个模式我在多个场景里都尝到了甜头。public final class Money { private final BigDecimal amount; private final Currency currency; private Money(BigDecimal amount, Currency currency) { this.amount amount; this.currency currency; } public static Money of(BigDecimal amount, Currency currency) { if (amount null || currency null) { throw new IllegalArgumentException(amount and currency must not be null); } if (amount.scale() 2) { throw new IllegalArgumentException(amount scale must not exceed 2); } return new Money(amount, currency); } }静态工厂方法比直接new好在哪里方法名能表达语义。Money.of(amount, currency)比new Money(amount, currency)读起来更像人话LocalDate.now()、Optional.empty()都是例子。可以控制实例数量。需要单例或缓存时工厂方法里直接返回同一个实例。可以返回子类型。接口的静态工厂方法可以返回任意实现类调用方只知道接口名。可以拒绝非法入参。构造方法的校验当然也能做但静态工厂的校验逻辑更灵活还能在创建前做额外处理。3.3 Builder模式参数很多时的折中方案不可变对象搭配构造方法有个痛点参数一多构造方法签名就变得又臭又长调用方很难分清哪个参数对应哪个位置。这时候Builder模式是合适的补丁。什么情况下值得上Builder我的判断标准是参数超过5个或者有多个必填和多个可选参数。对象创建后仍然需要保持不可变。参数之间有依赖校验比如a和b必须同时传或同时不传。手写Builder的骨架大概是这样的public final class UserProfile { private final String name; private final int age; private final String email; private UserProfile(String name, int age, String email) { this.name name; this.age age; this.email email; } public static Builder builder() { return new Builder(); } public static class Builder { private String name; private int age; private String email; public Builder name(String name) { this.name name; return this; } public Builder age(int age) { this.age age; return this; } public Builder email(String email) { this.email email; return this; } public UserProfile build() { if (name null || name.isBlank()) { throw new IllegalStateException(name must not be blank); } if (age 0) { throw new IllegalStateException(age must not be negative); } return new UserProfile(name, age, email); } } }Builder里最关键的是build()方法——校验逻辑集中在这里校验失败抛IllegalStateException。构造方法保持private外部只能通过Builder创建不可变性没有被破坏。但说句实话我的建议是参数不多时不要为了“设计感”硬上Builder用构造方法重载或静态工厂就够了。Builder增加了一个类、一串方法代码量和理解成本都是实打实的。只有在参数多且校验复杂时才值得。4. 进阶场景反射、序列化、继承是如何“绕过”私有属性和构造方法的这一章是面试高频区。私有属性和构造方法在常规路径上很严密但在几个特殊机制面前会被“破防”。理解这些破防点反而更能帮助你理解它们真正的作用边界。4.1 反射setAccessible私有属性真的私密吗下面这段代码可以运行真实地修改一个对象的private字段Order order new Order(2024001); Field field Order.class.getDeclaredField(amount); field.setAccessible(true); field.set(order, BigDecimal.valueOf(-1));所以private不是绝对的安全屏障任何运行中的Java程序都允许通过反射绕过访问控制。那private的意义在哪里我的回答是private约束的是编译期和常规调用路径而不是穷凶极恶的恶意代码。这很像家门的锁它不是为了挡住专业盗窃犯而是为了挡住“顺手推门的人”让每个访客都明白“这里不是随意出入的”。框架代码Spring、Hibernate、JSON序列化库需要反射操作私有字段那是在明确知情的特殊通道下进行的不属于“常规路径”。所以设计时不要把敏感信息的安全性寄托在private上该加密的加密该脱敏的脱敏才是正解。面试时如果被问到“private可以被反射修改吗”标准的回答思路就是以上几点能但private管的是代码规范而非绝对安全。4.2 反序列化不调用构造方法单例被破坏的经典考题Java原生序列化ObjectInputStream的反序列化过程是直接根据字节流在堆上重建对象不会调用构造方法。这导出一个经常被面试官拿来考的知识点单例类如果实现了Serializable反序列化时会生成一个新实例直接破坏单例。解决办法是加上readResolve()方法public class Singleton implements Serializable { private static final Singleton INSTANCE new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } protected Object readResolve() { return INSTANCE; } }readResolve()的作用是反序列化完成后返回一个替代对象让调用方拿到的还是原来的单例。这也是为什么很多人推荐用枚举实现单例——枚举天然处理了反射和反序列化的破坏问题只是很多时候大家已经习惯了经典写法。4.3 继承视角私有成员不继承但构造方法必须调用再回到私有属性和构造方法的关系。继承场景下有几个点值得记牢私有字段不继承子类代码里不能直接写this.privateField访问父类的私有字段。但子类实例的内存空间里确实包含父类的私有字段——它们只是“你看不见”而已。私有方法不参与重写。子类可以定义一个同名同参的方法但这不是重写是一个全新的方法。父类内部调用自己的私有方法时永远不会派发到子类这个同名方法上。构造方法不继承但子类构造方法体里的第一行必须是super()或super(参数)。父类没有无参构造时编译器强迫子类显式调用带参构造。这些规则看起来零散但组合起来解释了一个常见设计问题为什么父类要提供一个受保护的构造方法而不是把所有构造方法都设为private因为一旦父类构造方法全部私有化任何子类都无法创建父类部分这个类就等效于被final了——子类直接编译失败。4.4 从私有构造方法看工具类与单例把构造方法设为private最常见的目的有两个防止实例化、防止继承。工具类就是典型代表。public final class StringUtils { private StringUtils() { // 工具类不允许实例化 } public static boolean isBlank(String str) { return str null || str.isBlank(); } }一个静态方法组成的类如果构造方法是public外部就能new StringUtils()完全没有意义还浪费内存。更严重的是如果构造方法不是private子类就能隐式调用super()破坏掉工具类“不承载状态”的设计意图。单例模式也是同理私有构造方法保证外部无法new配合静态字段保存唯一实例。但单例本身存在争议生产环境里我通常会优先用枚举、Spring管理的单例Bean或者是依赖注入容器来管理生命周期而不是手写一个双检锁单例。手写单例的私有构造方法加静态实例的方式面试要会但代码里尽可能不写。5. 实操建议我在业务代码中如何拿捏私有属性与构造方法前面讲了原理和设计最后聊点真正能从周一早上就开始用的操作建议。5.1 一套可直接复制的字段与构造方法设计清单我写业务类时心里会过一遍下面这张清单这个字段有业务不变量吗如果有就应该是private final并在构造方法里做校验。这个字段真的需要setter吗如果删掉setter编译能过那就删掉。不能过的看看调用方修改它的语义是什么改成业务方法。类被框架反射创建吗如果被Spring或序列化框架管理保留无参构造。参数超过5个了吗超过就考虑Builder不超过就用构造方法或静态工厂。这个类是纯数据载体DTO还是业务对象纯数据载体可以放松约束业务对象必须严格封装。这张清单不是教条而是帮助你在写代码时快速区分“能省”和“不能省”的地方。纯数据载体上硬套Builder和不可变只会让代码显得臃肿业务对象上放任public字段和全setter则是在埋雷。5.2 两个最常见的坏味道及修复示范坏味道一实体类写成“public字段 无参构造 全setter”。public class Order { public BigDecimal amount; public OrderStatus status; public Order() {} public void setAmount(BigDecimal amount) { this.amount amount; } public void setStatus(OrderStatus status) { this.status status; } }这个类的任何实例都可能处于“金额为负却已支付”的非法状态。因为没有任何机制保证字段间的联动约束。修复方式是改造成私有关键字段不可变业务操作通过方法表达。坏味道二无状态工具类被到处new。public class FileValidator { public boolean validate(String path) { return path ! null !path.isBlank(); } }调用方可能new FileValidator().validate(path)虽然能跑但每次创建无状态对象完全没必要。修复方式是改为静态方法加私有构造方法。我见过太多代码里隐藏着这两种坏味道它们不会立刻导致Bug但会在需求迭代中被触发。早改早省心。5.3 测试中如何面对私有成员这是团队里经常争论的问题到底要不要测试私有方法要不要在测试里反射读取私有字段我的立场很明确优先通过公开接口行为来验证不要为了私有成员硬塞测试。私有方法是实现细节它怎么改都不应该影响对象的对外契约。真正的契约是public方法的行为——输入是什么输出是什么有没有抛异常。不过有两个例外场景我会用反射测试框架需要模拟某个私有字段用于构造异常场景比如把金额字段反射改成负数验证后续逻辑是否正确拒绝。老代码没有测试接口必须通过反射验证内部状态才能确认修复有效。Java生态里Spring的ReflectionTestUtils提供了很好用的工具可以少写很多反射样板代码。但请记住这是“打破常规”的手段不是常规本身。5.4 团队代码评审中我反复强调的三句话文章最后分享几个我在代码评审里反复强调的点几乎每次都能引发讨论第一句“这个setter如果删掉编译能过吗能过就删。”多数情况下能过删掉之后调用方的实现才会被逼着思考“我到底想做什么业务操作”。第二句“这个字段能不能加final”如果能就说明它创建后不该被改那么final就是最好的文档。第三句“如果调用方有100个地方都能改你这个字段你打算怎么保证数据一致性”这句话专治那种“public字段很方便”的想法。数据一致性不是靠约定是靠结构约束。最后说点个人的体会。刚工作那两年我总觉得private和构造方法这种基础知识太低阶更愿意研究并发、JVM调优那些看起来很硬核的东西。后来被线上问题教育了几次——一个没有校验的构造方法放出了负数的金额一个public字段让同事跨服务传值传错了单位——才明白基础语法点的分量。写代码是跟人打交道的艺术private是在告诉同事“这里不要随便动”构造方法是告诉同事“这个对象只能以这种方式诞生”。把这些约定落到代码结构里比背一百个框架API都管用。
返回列表