资讯详情

资讯详情

Java 类名作为形参和返回值:对象引用、副作用与防御性拷贝

“类名作为形参和返回值”这一节很多人在学 Java 基础时是直接翻过去的。前面刚啃完构造方法、this、static看到这个标题第一反应往往是“不就是把 int 换成 Student 吗能有什么好讲的”。我带过几个刚转行的朋友几乎每个人都在这里栽过一次跟头代码能编译、能跑但跑着跑着对象里的字段莫名其妙变了或者方法返回一个集合调用方顺手 add 了一下结果另一个地方的统计数字对不上。这类问题不打条件断点基本找不到原因而根子就在这节“看起来最没技术含量”的内容上——类名作为形参和返回值本质是把对象的引用交出去交出去之后谁都能改改完还不告诉你。这篇东西我想把这层窗户纸捅破。前半段讲清 Java 在传参和返回时到底复制了什么、没复制什么中间段落到实操说说形参写成接口或抽象类之后代码结构会发生什么变化、返回值该返回可变对象还是不可变对象、什么时候该把 this 返回出去做链式调用。后面会给一张排查速查表全是我和身边人在真实项目里撞过的坑。适合刚学完面向对象、正在写第一个小项目的人也适合写了几年但一直靠“能跑就行”混过来的朋友——你可能没意识到很多设计模式的地基就在这两行代码里。1. 类名当类型用传过去的到底是什么1.1 引用是一张写着地址的纸条不是房子本身先建立一个最朴素的模型。Student s new Student()这一行干了三件事在堆里盖了一间房子对象在栈里的局部变量表上放了一张纸条引用纸条上写着房子的门牌号。当你把s传给一个方法Java 做的事是把这张纸条复印一份递给对方而不是把房子搬过去。所以形参拿到的是同一张门牌号的复印件。对方按地址进门把沙发挪了位置你回家一看沙发确实动了。但如果对方把手里那张复印件撕了、换成另一间房子的地址你手里那张纸条完全不受影响。这就是 Java 里常说的“只有值传递”——对象场景下传的值就是引用的副本。很多人第一次听“Java 没有引用传递”会觉得反直觉其实关键在于区分“改对象的内容”和“改引用本身”前者看得见效果后者看不见。public class Student { private String name; private int score; public Student(String name, int score) { this.name name; this.score score; } // getter/setter 省略 } public class Demo { static void changeContent(Student s) { s.setScore(100); // 顺着地址进门改东西外面看得见 } static void changeRef(Student s) { s new Student(新同学, 0); // 把复印件换成新地址外面看不见 } public static void main(String[] args) { Student stu new Student(A同学, 60); changeContent(stu); System.out.println(stu.getScore()); // 100 changeRef(stu); System.out.println(stu.getName()); // 还是 A同学 } }这段代码不长但它是后面所有内容的地基。changeContent能改到外面是因为它顺着引用找到了堆里那唯一一个对象changeRef改不到是因为它只换了手里那张纸。搞清楚这个区别你就不会写出“我在方法里 new 了一个新对象赋给形参为什么调用方没变”这种困惑。1.2 一次方法调用在内存里发生了什么把调用过程拆开看会更清楚。JVM 执行changeContent(stu)的时候会在虚拟机栈上压入一个新的栈帧栈帧的局部变量表里分配一个槽位给形参s然后把stu这个引用的值也就是门牌号复制进去。方法返回时栈帧弹出槽位销毁但堆里的对象还在因为你外面的stu还指着它。这里有个容易被忽略的点如果方法执行完堆里的对象没有任何引用指向它了才会被垃圾回收判定为可回收。所以“方法里创建对象返回出去”这种写法对象是活在堆里的栈帧销毁不影响它——这一点是理解“类名作为返回值”的前提很多人误以为方法结束对象就没了。再补一个常被问到的细节形参和实参是两个不同的变量只是初始值相同。你在方法里对形参重新赋值不会反向影响实参但你对形参指向的对象做修改实参“看”过去是同一个对象自然就变了。判断一段代码会不会互相影响问自己一句话就行这次操作是“换纸条”还是“进门搬东西”。1.3 为什么这一节总在代码评审里被挑出来因为它是“能跑”和“好维护”的分界线。新手阶段大家只关心结果对不对等到代码上到几千行、一个对象被五六个方法来回传的时候副作用就成了噩梦。你调一个calculate(order)回来发现 order 里的金额被改了、状态被改了、连创建时间都被重置了——这种 bug 藏得深复现还依赖调用顺序。我在评审别人代码时有个习惯只要看到形参是自定义类就去方法体里找有没有set调用。如果有再看这个方法名有没有暗示“我会修改你”比如normalize、fill、apply。名字叫get、build、parse却偷偷改参数一律打回去。这不是洁癖是让调用方能靠方法名预判行为省掉每次都要点进去看实现的成本。2. 类名作形参从“能编译”到“用得对”2.1 最朴素的写法先解决“传一堆参数”的问题设想一个成绩打印功能不封装的话签名会变成printReport(String name, int chinese, int math, int english, String className, int rank)六个参数调用方写错顺序编译器都发现不了——因为String和String挨着int和int挨着类型检查形同虚设。把学生抽象成一个类之后签名变成printReport(Student s)一个参数搞定而且以后加字段不用改所有调用点。public class ScorePrinter { public void printReport(Student s) { int total s.getChinese() s.getMath() s.getEnglish(); System.out.printf(%s 总分 %d平均 %.2f%n, s.getName(), total, total / 3.0); } }这段代码的价值不在于短而在于“参数的内聚性”。多个参数如果描述的是同一件事就应该打包成对象。判断标准很简单这些参数是不是总是一起出现、一起变化如果是那就是缺一个类。这个思路在重构里叫“引入参数对象”是很基础也很有效的一招。2.2 形参写成接口或抽象类解耦才真正开始如果只是把Student写进签名耦合其实更强了——打印器只能打印学生。想让同一套逻辑处理老师、处理课程就得让形参“退一步”。Java 的形参类型可以是接口运行时传实现类的对象进去这叫面向接口编程。public interface ScoreAble { String getName(); int getTotalScore(); } public class ScoreService { public void print(ScoreAble target) { System.out.println(target.getName() - target.getTotalScore()); } }对比一下两种写法的差别。形参写具体类方法体里能调用的只有这个类及其父类暴露的方法而且以后想复用必须改签名形参写接口方法体里只依赖接口承诺的行为任何实现了这个接口的类都能传进来。这就是“依赖倒置”最落地的样子——高层模块不依赖具体实现只依赖抽象。实操里有个度要注意别为了解耦而解耦。一个只在两个地方调用、永远只处理一种对象的工具方法形参写具体类完全没问题强行抽接口只会让代码变绕。我个人的判断标准是“未来半年内有没有第二种实现的可能”有就抽没有就先放着。过度设计带来的维护成本往往比耦合本身更高。2.3 可变对象作形参的三种典型事故第一种是“方法偷偷改参数”。看着不起眼的几行赋值埋的是顺序依赖的雷。第二种是“两个方法共用同一个对象一个改一个读”多线程下更刺激。第三种最隐蔽——传集合进去被清空。比如某个方法内部为了排序方便直接对传入的 list 做了clear()和重新填充调用方手里的 list 就变了。// 反例悄悄改掉了调用方的集合 public ListString sortNames(ListString names) { names.sort(String::compareTo); // 原地排序副作用外溢 return names; } // 正例要么明确声明会修改要么干脆不动 public ListString sortedNames(ListString names) { ListString copy new ArrayList(names); copy.sort(String::compareTo); return copy; }还有一种经常被忽略的可变类型java.util.Date。它所有 setter 都是公开的你把它当形参传进方法方法里setTime()一下外面那个“创建时间”就变了。新项目里能用LocalDateTime、LocalDate就别用Date不可变对象天然免疫这类问题——你没法改它也就没法改错。Calendar、StringBuilder、数组、以及绝大多数集合实现都属于可变对象。传它们进方法时心里要有根弦对方只要想改随时能改。要么在方法名上写清楚fillIn、reset、applyTo要么进门先拷贝一份。2.4 防御性拷贝什么时候必须做代价是什么防御性拷贝这个词听着玄其实就是“进门先复印一份自己的”。构造函数里接收集合参数时尤其要做否则调用方在构造完之后还能往集合里塞东西你的对象状态就被人从后门改了。public class Order { private final ListString items; public Order(ListString items) { // 拷贝一份切断和外部集合的联系 this.items new ArrayList(items); } public ListString getItems() { // 出去的时候也别把内部集合直接交出去 return Collections.unmodifiableList(items); } }这里有两个方向的拷贝进去时拷一次防止外部修改影响内部出去时包装成不可变视图防止外部拿到引用后直接改内部状态。两边都做对象才是真正“自己管自己”。代价要说清楚每次调用都 new 一个集合内存和 GC 压力上来了。一个对象本身只有几十字节但集合拷贝可能是几百上千字节高频调用路径上确实有影响。我这边的经验是DTO、配置对象、缓存对象这类创建频率不极端的该拷贝就拷贝如果是每秒上万次的高频路径再考虑用不可变集合List.copyOf、ImmutableList或者改成返回视图的方式同时必须把不可变的约定写进注释。安全第一性能第二改性能之前先测。3. 类名作返回值方法把对象递出来时你交付了什么3.1 返回值是引用的副本不是对象的克隆和形参完全对称方法return stu;交出去的是门牌号的复印件调用方拿着它就能进同一间房子。所以“方法返回一个对象”从来不等于“方法返回一份安全的副本”。public Student findTopStudent() { return this.topStudent; // 内部持有的对象直接交出去了 }调用方拿到之后一句setScore(0)你内部统计的数据就烂了。这类问题在“缓存对象”“配置对象”“单例对象”上尤其常见因为它们是长期存活、被多处共享的。解决思路和形参一样要么返回拷贝要么返回不可变视图要么在类设计上就让对象不可变所有字段 final、没有 setter、集合类型用不可变实现。顺带说个容易被忽略的点return一个基本类型和return一个对象语义差别巨大。前者是值拷贝调用方随便改都影响不到你后者是共享。写方法的时候脑子里过一下“我返回的东西别人改了我怕不怕”答案是要怕就别直接返回。3.2 返回 null 还是返回 Optionalreturn null是 Java 里最贵的三个字符。它把“有没有”这个问题推给了调用方而调用方十次有八次会忘记判空然后在某个不相干的业务链路上炸出一个 NPE堆栈还指不到你这里。近几年的主流做法是两套并存对外部调用、老接口继续用 null 加文档说明对自己可控的内部代码用Optional明确表达“可能没有”。public OptionalStudent findByName(String name) { return studentMap.values().stream() .filter(s - s.getName().equals(name)) .findFirst(); // 本身就返回 Optional }用Optional有三个约定得守住不拿它当字段类型不可序列化还占内存、不拿它当方法形参调用方还是得自己判空等于没解决问题、不用optional.get()直接取值要用orElse、orElseThrow、ifPresent。它适合出现在“返回值”这一个位置别的地方面目全非了反而更糟。还有个务实的选择如果“找不到”属于正常业务分支返回一个空对象Null Object 模式往往比 Optional 更省事。比如findByName找不到时返回一个 name 为空字符串、分数为 0 的Student调用方不用处理 null代码一路平坦。前提是这个空对象不会被误当成真实数据写进数据库——这一点必须在设计时说清楚。3.3 返回集合List.copyOf和unmodifiableList的区别先说结论新代码优先List.copyOf老代码兼容用Collections.unmodifiableList。差别在于前者是真拷贝返回一个不可变的新列表后者是包装视图——你对原列表的修改通过视图是能看见的。private final ListString tags new ArrayList(); // 方式一不可变视图原列表变了它跟着变 public ListString tagsView() { return Collections.unmodifiableList(tags); } // 方式二真拷贝返回后和内部彻底断开 public ListString tagsCopy() { return List.copyOf(tags); }方式一的坑在于“看起来安全其实不安全”调用方不能通过返回值修改但如果这个类的其他地方还在往tags里加东西调用方手里的视图会同步变化遍历时还可能抛ConcurrentModificationException。方式二多一次拷贝开销但语义干净返回值就是那一刻的快照。顺带提醒Arrays.asList()返回的是固定长度的列表add会抛异常subList()返回的是原列表的视图对子列表的操作会反映到原列表上而且原列表结构变化后子列表就废了。这两个都是我在排查“集合莫名其妙变了”时的高频嫌疑对象。3.4 返回 this把链式调用写出来如果方法的作用是“配置这个对象”返回值写成类名本身、并且return this就能串起来。这是链式调用和流式 API 的实现基础StringBuilder.append就是这么干的。public class QueryBuilder { private String table; private int limit 10; public QueryBuilder from(String table) { this.table table; return this; } public QueryBuilder limit(int limit) { this.limit limit; return this; } public String build() { return SELECT * FROM table LIMIT limit; } } // 调用 String sql new QueryBuilder().from(student).limit(20).build();用return this有个前提这个对象是可变的、而且你接受它被一步步改。如果对象设计成不可变就得改成“每次返回一个新对象”像LocalDate.plusDays那样String的concat也是这个路子。两种都行怕的是混着来——同一个类里一半方法改自己、一半返回新对象调用方根本猜不出调完是哪个对象变了。选一种贯彻到底。4. 把两者组合起来几种真正在项目里用到的套路4.1 静态工厂方法形参进去成品出来这是“类名作形参 类名作返回值”最常见的组合形态。工厂方法接收原材料形参内部组装返回成品返回值是目标类。相比直接new它的好处是有名字、可缓存、可返回子类型。public class Student { private final String name; private final int score; private Student(String name, int score) { this.name name; this.score score; } public static Student of(String name, int score) { if (name null || name.isBlank()) { throw new IllegalArgumentException(名字不能为空); } return new Student(name, Math.max(0, Math.min(100, score))); } }工厂里的参数校验和边界收敛是很实在的价值所有Student都得从这条管道过非法数据进不来后面几十个使用点上就都不用再判一遍。这也是我把构造器设成 private 的原因——留个口子就行别让外部绕过校验。命名上有几个约定俗成的词of、valueOf做类型转换和简单构造from做单参数转换getInstance取可能是缓存的实例newInstance每次新建create语义上强调“创建了个新东西”。团队里统一一下就行别一个类里of和create混着用看代码的人会很累。4.2 建造者模式build()返回的就是目标类参数一多构造器就变成一长串 positional 参数调用方完全记不住顺序。建造者的每一个 setter 都返回建造者自己return this最后build()返回目标类——两种返回类型在同一个类里各司其职。public class Order { private final String id; private final String customer; private final ListString items; private Order(Builder b) { this.id b.id; this.customer b.customer; this.items List.copyOf(b.items); } public static class Builder { private String id; private String customer; private final ListString items new ArrayList(); public Builder id(String id) { this.id id; return this; } public Builder customer(String c) { this.customer c; return this; } public Builder item(String i) { this.items.add(i); return this; } public Order build() { if (id null || customer null || items.isEmpty()) { throw new IllegalStateException(字段不完整不能构建); } return new Order(this); } } }注意build()里那句List.copyOf它把建造者手里的可变集合转成了订单内部的不可变快照。少了这一句建造者对象被复用或者被外部持有的时候已构建好的订单内容还能被改。这类 bug 我见过一次查了大半天最后发现是建造者被缓存复用导致的。4.3 泛型T T这种返回值的坑在哪基础教材里不太讲这个但实际写工具类绕不开。一个方法声明成T T get(ClassT type)调用方不用强转写起来很爽。问题在于泛型在运行时会被擦除方法内部拿到type之前它其实不知道T是什么。public class Container { private final MapString, Object pool new HashMap(); public T void put(String key, T value) { pool.put(key, value); } SuppressWarnings(unchecked) public T T get(String key, ClassT type) { Object o pool.get(key); return type.isInstance(o) ? (T) o : null; } }这个写法能工作靠的是调用方把Class对象传进来当“类型凭证”。如果不传Class写成public T T get(String key)编译器会在调用点插入一个隐式强转运行时如果类型对不上ClassCastException 会抛在调用方那一行堆栈看起来极其莫名其妙——错误抛出的位置和真正出错的位置隔了好几层。所以我对泛型返回值的态度是能用具体类型就别用泛型非要用把ClassT当参数带上让类型检查落在方法内部出错信息更准确。4.4 回调接口作形参、无返回值也是一种“交付”还有一种组合值得提形参是接口方法返回void方法内部在合适的时机调用接口的方法。这就是回调异步任务、事件监听、集合遍历都在用。public interface Callback { void onDone(Student result); } public void loadAsync(String id, Callback callback) { // 模拟异步加载 Student s findByName(id).orElse(null); callback.onDone(s); }这种写法把“什么时候做”交给被调用方把“做什么”留给调用方是控制反转最轻量的一种形式。要注意的就是老生常谈的 null 判断和异常处理回调里抛出的异常会不会被吞掉、会不会导致后续流程中断取决于调用它的位置有没有 try-catch。这一点在异步场景下尤其容易翻车。5. 常见问题与排查实录5.1 一张速查表覆盖八成现场现象大概率原因排查动作处理方式调用方法后原对象字段变了方法内部对形参调了 setter搜方法体里的set、add、sort、clear改名表明副作用或改成拷贝后操作方法里重新赋值形参外部没变只换了引用副本没改对象对照 1.1 的模型确认意图若要影响外部改返回值让调用方接收返回集合后原对象也跟着变返回的是内部集合引用看返回值有没有拷贝用List.copyOf或不可变视图遍历时报并发修改异常视图背后原集合被改查是否有另一处写操作返回快照或改成写时复制容器明明判了 null 还是 NPE判的是形参返回的字段没判沿调用链逐个字段确认用 Optional 收敛或空对象兜底ClassCastException 抛在调用方泛型方法没传Class校验看方法签名有没有T T补ClassT参数内部做isInstance静态工厂返回的对象状态被改缓存实例被外部修改看工厂有没有走缓存分支缓存对象必须不可变或每次返回拷贝Arrays.asList加元素报错返回的是固定长度列表看列表来源用new ArrayList(Arrays.asList(...))这张表我基本是照着踩坑记录整理的左边那列出现任何一个先去右边第二列对号入座比顺着代码一行行看快得多。5.2 定位“值莫名其妙变了”的三步法第一步怀疑所有能改对象的地方。在字段的 setter 上打断点条件设成“当前线程 调用栈里不包含本类”跑一遍就能抓到是谁改的。IntelliJ 里对字段打断点可以直接选“Field Watchpoint”读写都能拦。第二步确认对象是不是同一个。有时候你以为是同一个对象被改了其实是两个长得像的对象搞混了。重写toString()打印关键字段或者干脆System.identityHashCode(obj)打一下身份哈希两个对象是不是一个立刻清楚。我遇到过一次“配置没生效”最后发现是两处各自 new 了一个配置对象改的是 A用的是 B。第三步检查有没有集合视图在背后捣鬼。subList、unmodifiableList、Arrays.asList、Map.keySet()返回的都是视图底层数据一变视图跟着变。排查时把这些调用的返回值全部替换成List.copyOf试一遍问题消失就锁定位置了。5.3 我个人踩过的几个坑有一年写一个数据导出功能方法签名是export(ListRecord records)我在里面按时间排了个序直接用的records.sort(...)。本地测没问题上线后另一个模块的统计报表顺序全乱了——因为两处传的是同一个 list。这个 bug 花了一整天才定位到改法很简单方法开头加一行ListRecord sorted new ArrayList(records)。还有一次是缓存。写了个getConfig()第一次从数据库读、放进 Map、直接返回那个对象。上层有个地方为了临时改个超时时间对返回的对象调了 setter结果全站配置都变了而且重启才恢复。后来改成缓存里存不可变对象返回时用with风格生成新对象问题根除。第三个坑是关于return this的。早期写了个链式配置类所有方法都返回 this其中有个方法是“按条件追加过滤”内部会根据参数决定加不加。调用方写了一段builder.filter(a).filter(b).build()看着很顺问题是这个 builder 被复用了两次第二次构建出来的查询带着第一次的条件。链式调用本身没错错在对象被跨流程复用。后来在build()里加了个“已构建”标记第二次调用直接抛异常问题在开发阶段就暴露了。6. 几个可以直接抄走的习惯写到这里把前面散落的东西收一收给几条我日常真在用的习惯。形参这块我的默认动作是自定义类作形参时方法名必须能读出“改不改”。读操作叫getXxx、calcXxx、toXxx一律不许动参数写操作叫fillXxx、applyXxx、resetXxx允许改但要在注释里写明改哪些字段。集合和Date这类可变类型进方法第一行先拷贝除非这个方法名本身就带“原地”的意思。返回值这块我的默认动作是返回内部集合一律List.copyOf返回可能不存在的对象一律Optional但只在返回值位置用对象能被外部拿到的地方优先把类设计成不可变——字段全final、集合用不可变实现、需要改就用with风格返回新对象。这套做法前期写起来啰嗦一点但省下的是后期排查副作用的时间账算得过来。还有一个练习建议找一段你以前写的、参数超过四个的方法把它改成“一个对象进、一个对象出”。改的过程中你会自然发现哪些参数该内聚成类、哪些返回值该合并成一个结果对象。这个动作做上十遍你对“类名作为形参和返回值”的理解就不再停留在语法层面了——你会开始用对象的边界去思考问题而不是用变量的边界。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →