本节目标:能判断两个方法是否算「同一个签名」,能预测一个调用会选中哪个重载,并知道可变参数在字节码里就是数组。
适用版本:JDK 25 LTS(Temurin 25.0.5);本节语法与解析规则在 JDK 21 上一致。
上一节留下的问题
到 5.3 为止,LibraryApp 已经能处理 add、search、report、quit,但它同时在做四件事:
- 读标准输入、切分命令(IO)
- 解析字段、校验年份(协议)
- 往
List<Book>里加书、遍历查找(存储) - 把结果打印成文本(展示)
这四件事混在一个类里,最直接的后果是没法单独测试业务规则。想验证「按作者搜索能不能命中」,必须构造一串标准输入,再解析打印出来的文本。第 16 章会讲测试,但测试的前提是业务逻辑能脱离 System.in 和 System.out 被调用。
拆分的动作很简单:把「加书、查书、计数」搬进 LibraryService,LibraryApp 只负责读命令和打印。真正要设计的是 LibraryService 的方法签名——参数放什么、返回什么、要不要提供几个版本。这需要先弄清楚 Java 眼里的「签名」到底是什么。
签名只由名字和参数类型组成
Java 的方法签名(signature)是方法名 + 参数类型列表。下面这些都不参与签名:
| 元素 | 是否参与签名 | 说明 |
|---|---|---|
| 方法名 | 是 | addBook 和 search 是不同方法 |
| 参数类型 | 是 | addBook(String) 和 addBook(Book) 是不同方法 |
| 返回值类型 | 否 | 只改返回值不算重载 |
| 参数名 | 否 | f(int x) 和 f(int y) 是同一个签名 |
static / final / 访问修饰符 | 否 | 不能靠它们区分方法 |
throws 子句 | 否 | 抛什么异常不影响签名 |
只改返回值:编译失败
static int f() { return 1; }
static String f() { return "x"; }
本机 JDK 25 报:
错误: 已在类 ReturnOnly中定义了方法 f()
static String f() { return "x"; }
^
调用方写 f() 时,编译器只看名字和参数就能确定调哪个方法;如果返回值也参与选择,int x = f(); 和 String s = f(); 就会指向不同实现,重载解析会变得依赖上下文,复杂度陡增。Java 的选择是:返回值不参与签名,也就不能用来重载。
只改参数名:同样是重复定义
static void f(int x) {}
static void f(int y) {}
这两个方法在编译器眼里完全一样,报「已在类中定义了方法 f(int)」。参数名只影响方法体内的可读性和调用方的 IDE 提示,不影响调用匹配。
static 也不参与
同一个类里不能同时有 static void f() 和 void f()。它们签名相同,只是一个属于类、一个属于实例。调用时编译器可以根据有没有实例来区分,但 Java 不允许这种「靠上下文猜」的重载。
重载解析:编译器按三道关选方法
重载(overload)是同一个类里多个同名、参数列表不同的方法。调用 pick(1) 时,编译器按固定顺序尝试三轮:
- 严格匹配:不装箱、不拆箱、不用可变参数,只允许基本类型拓宽(
int→long)和引用类型的子类赋值。 - 装箱/拆箱:允许
int↔Integer这类转换,仍然不用可变参数。 - 可变参数:把参数打包成数组,调用
T...形式的方法。
前一轮找到唯一匹配就停止,不会再看后面的轮次。这套顺序解释了很多「看起来奇怪」的结果。
一个可运行的对照
static String pick(int x) { return "int"; }
static String pick(Integer x) { return "Integer"; }
static String pick(int... x) { return "varargs(" + x.length + ")"; }
static String take(long x) { return "long"; }
static String take(Integer x) { return "Integer"; }
本机 JDK 25 的四次调用与结果:
pick(1) -> int
pick(Integer.valueOf(1)) -> Integer
pick() -> varargs(0)
pick(1, 2) -> varargs(2)
take(1) -> long
逐条解释:
pick(1):参数是int,第一轮就精确匹配pick(int),不会看Integer版本。pick(Integer.valueOf(1)):参数是Integer,第一轮精确匹配pick(Integer)。pick():没有无参重载,前两轮都失败,第三轮用零长度数组调用pick(int...)。pick(1, 2):两个int,只能走可变参数。take(1):int可以拓宽成long(第一轮),也可以装箱成Integer(第二轮)。第一轮先命中,所以选long。
最后一条最容易被误判。很多人以为「装箱更贴近 int」,但语言规范把基本类型拓宽排在装箱之前。如果两个重载分别是 take(long) 和 take(Integer),传 int 永远走 long;想让 Integer 版本生效,调用方必须显式写 take(Integer.valueOf(1))。
null 让重载变得不明确
static String name(String s) { return "String"; }
static String name(Integer i) { return "Integer"; }
System.out.println(name(null));
本机 JDK 25 报:
错误: 对name的引用不明确
System.out.println(name(null));
^
NullAmbiguous 中的方法 name(String) 和 NullAmbiguous 中的方法 name(Integer) 都匹配
null 可以赋给任何引用类型,两个重载都「能接」,编译器无法判断。解决办法不是加一个 name(Object) 去「兜底」——那只会让优先级更绕;正确做法是不要让两个重载在同一位置接受不相关的引用类型。用不同的方法名,或者让其中一个参数多一个明确的类型。
重载的设计原则
重载的目的是「同一个操作,接受不同的输入形式」,不是「省几个方法名」。两条实用规则:
- 同名方法应该做同一件事。
addBook(String, String, String)和addBook(String, String, String, int)都是「加一本书」,只是年份可省略,这样重载是合理的。 - 避免同参数个数、类型亲缘太近的重载。
f(int)/f(Integer)、f(String)/f(Object)这类组合,调用方很难一眼看出选哪个。JDK 自己的List.remove(int)与remove(Object)就是历史留下的坑,下面会看到。
可变参数就是数组
Book... 读作「零个或多个 Book」,编译后就是 Book[]。方法体里可以把它当数组用:
public void addBooks(Book... newBooks) {
for (Book book : newBooks) {
books.add(book);
}
}
三种调用方式等价:
service.addBooks(book1, book2);
service.addBooks(new Book[] { book1, book2 });
service.addBooks(); // 空数组
不能和数组版本共存
static void f(String... xs) {}
static void f(String[] xs) {}
本机 JDK 25 报:
错误: 无法在VarargsArray中同时声明f(String[])和f(String...)
因为可变参数在签名层面就是数组,两者签名相同。反过来,调用方传一个 String[] 给 f(String...) 是合法的,编译器不会再多包一层。
每次调用都会创建数组
可变参数不是「语法糖零成本」。每次调用 addBooks(a, b),编译器都会生成一个 new Book[] { a, b };在循环里调用就会反复分配。对 CLI 这种一天几百次调用的场景无所谓,但在热路径上,提供一个接受 List<Book> 的重载更合适:
public void addBooks(List<Book> newBooks) {
books.addAll(newBooks);
}
这和 addBooks(Book...) 签名不同(List vs Book[]),可以共存。调用方按手里已有的数据结构选择,不需要为了调方法先拆数组。
可变参数与 null
addBooks(null) 会让编译器发出「非 varargs 调用」警告,提示最后一个参数的类型不明确;运行时把 null 当成 Book[] 传进去,增强 for 立刻抛 NullPointerException。如果「空」是合法输入,调用方应该写 addBooks(),而不是传 null。方法内部也可以加一层 Objects.requireNonNull,把错误提前到入口。
在 library 里落地
LibraryService 的第一版只做内存存储和查询,不碰输入输出:
package com.plume.library;
import java.util.ArrayList;
import java.util.List;
public class LibraryService {
private final List<Book> books = new ArrayList<>();
public Book addBook(String isbn, String title, String author) {
return addBook(isbn, title, author, 0);
}
public Book addBook(String isbn, String title, String author, int publishedYear) {
Book book = new Book(isbn, title, author);
book.publishedYear = publishedYear;
books.add(book);
return book;
}
public void addBooks(Book... newBooks) {
for (Book book : newBooks) {
books.add(book);
}
}
public Book findByIsbn(String isbn) {
for (Book book : books) {
if (book.isbn.equals(isbn)) {
return book;
}
}
return null;
}
public List<Book> search(String keyword) {
return search(keyword, true);
}
public List<Book> search(String keyword, boolean includeAuthor) {
List<Book> hits = new ArrayList<>();
for (Book book : books) {
boolean matched = book.title.contains(keyword)
|| (includeAuthor && book.author.contains(keyword));
if (matched) {
hits.add(book);
}
}
return hits;
}
public int count() {
return books.size();
}
}
几个签名决策值得说明:
addBook返回Book,不是void。 调用方可能需要刚创建的对象的 ISBN 或标题来打印确认;返回引用不增加成本,却让调用方不必再查一次。- 三参版本委托给四参版本。
return addBook(isbn, title, author, 0);把「年份未知」编码成0,逻辑只有一份。第 7 章会把它定义成具名常量UNKNOWN_YEAR,让这个0在代码里有明确含义。 search(String)委托给search(String, boolean)。 默认包含作者,需要只搜标题时传false。两个重载做同一件事,符合前面的设计原则。findByIsbn返回null表示没找到。 这是本章的临时约定;第 12 章会换成Optional<Book>,把「可能没有」写进类型。
LibraryApp 相应变薄,只保留 IO 和协议解析:
private void handleSearch(String[] parts) {
if (parts.length < 2) {
System.out.println("usage: search <keyword>");
return;
}
List<Book> hits = service.search(parts[1]);
for (Book book : hits) {
System.out.printf("hit: %s (%d)%n", book.title, book.publishedYear);
}
System.out.println("hits = " + hits.size());
}
handleAdd 里的分支也很直接:
Book book;
if (fields.length == 3) {
book = service.addBook(fields[0], fields[1], fields[2]);
} else {
book = service.addBook(fields[0], fields[1], fields[2], Integer.parseInt(fields[3]));
}
System.out.println("added: " + book.title);
编译并回放:
javac -d out src/com/plume/library/*.java
java -cp out com.plume.library.LibraryApp
输入与输出(本机 JDK 25):
add 978-7-111-1|Effective Java|Joshua Bloch|2008
added: Effective Java
add 978-7-115-2|Java Concurrency in Practice|Brian Goetz|2006
added: Java Concurrency in Practice
add 978-7-118-3|Java Puzzlers|Joshua Bloch
added: Java Puzzlers
search Java
hit: Effective Java (2008)
hit: Java Concurrency in Practice (2006)
hit: Java Puzzlers (0)
hits = 3
count
books = 3
quit
bye
第三本书没给年份,走三参重载,publishedYear 保持 0,所以打印成 (0)。这个 0 是「未知」的占位,不是真实出版年——第 7 章会把字段收成 private final,并用具名常量 UNKNOWN_YEAR 把「年份未知」写清楚。
Integer.parseInt(fields[3]) 在输入不是数字时会抛 NumberFormatException,当前版本没有捕获,程序会退出。这是刻意的:第 13 章会统一处理异常,把「用户输入错误」变成一行可读提示,而不是一整段栈追踪。
常见错误与边界
以为返回值不同就是重载。 前面 ReturnOnly 的报错已经说明:返回值不参与签名。需要「同一输入、不同输出」时,换方法名(findOrNull / requireBook),或者返回一个能表达两种结果的对象。
用参数名区分重载。 f(int x) 和 f(int y) 是同一个方法。参数名只在方法体内可见,调用方甚至看不到它。
null 调重载。 name(null) 在两个不相关引用类型之间无法选择,编译失败。真实代码里更隐蔽的是 list.remove(null) 这类调用:List.remove(Object) 和 List.remove(int) 只有一个能接 null,编译器会选 remove(Object),但读代码的人往往以为它在按索引删。
List.remove 的 int / Object 陷阱。 本机 JDK 25 运行:
List<Integer> nums = new ArrayList<>(List.of(10, 20, 30, 20));
nums.remove(1);
System.out.println(nums);
nums.remove(Integer.valueOf(20));
System.out.println(nums);
[10, 30, 20]
[10, 30]
第一次 remove(1) 调的是 remove(int index),删掉下标 1 的元素 20;第二次 remove(Integer.valueOf(20)) 调的是 remove(Object o),删掉第一个值为 20 的元素。想按值删 Integer 列表,必须显式装箱。 这不是库的设计失误,而是重载 + 自动装箱共同作用的结果;知道解析顺序就能预测。
可变参数传 null。 addBooks(null) 编译时会有警告,运行时把 null 当数组,增强 for 抛 NPE。空输入写 addBooks()。
在重载之间复制逻辑。 三参 addBook 如果自己再写一遍 new Book(...)、books.add(...),两份逻辑迟早会分叉。让短版本委托给长版本,只保留一处实现。
小结
- 方法签名 = 方法名 + 参数类型;返回值、参数名、
static、throws都不参与。 - 重载解析按「严格匹配 → 装箱/拆箱 → 可变参数」三轮进行,前一轮命中就不再往后看。
- 基本类型拓宽优先于装箱,所以
take(1)会选take(long)而不是take(Integer)。 null在两个不相关引用类型的重载之间无法选择,会编译失败。T...编译后就是T[],不能与数组版本共存,每次调用会创建数组。library的LibraryService用重载的addBook、search表达了「可选参数」,LibraryApp只保留 IO 和协议解析。
方法拆开了,但还有一个更基础的问题没回答:调用 service.addBook(...) 时,参数到底是怎么传进去的?方法里改一个对象,调用方为什么能看到;改一个变量,调用方为什么看不到?下一节把值传递讲透。
阅读导航:上一节:5.3 instanceof 模式匹配与守卫 · 下一节:6.2 值传递的真相 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。