《Java 编程入门》6.1 方法签名、重载与可变参数

从 LibraryApp 越堆越多的职责出发,讲清方法签名的组成、重载解析的三道关(严格匹配、装箱、可变参数),以及可变参数背后的数组语义;最后拆出 LibraryService,用重载的 addBook 和 search 把 CLI 与业务逻辑分开。

本节目标:能判断两个方法是否算「同一个签名」,能预测一个调用会选中哪个重载,并知道可变参数在字节码里就是数组。
适用版本: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) 时,编译器按固定顺序尝试三轮:

  1. 严格匹配:不装箱、不拆箱、不用可变参数,只允许基本类型拓宽(int → long)和引用类型的子类赋值。
  2. 装箱/拆箱:允许 int ↔ Integer 这类转换,仍然不用可变参数。
  3. 可变参数:把参数打包成数组,调用 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 值传递的真相 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发