Swift 与 Objective-C

方法签名包含返回类型吗?

当被问到“什么是方法签名?”时,大多数人都会含糊地回答:“方法名和参数……大概就是这些。”

6 分钟阅读
方法签名包含返回类型吗? 封面图

当被问到“什么是方法签名?”时,大多数人都会含糊地回答:“方法名和参数……大概就是这些。”

这话不算错,但再深入一步就会出现明显差异。“那么,返回类型属于签名吗?”从这里开始,答案就不再稳定。这既是技术面试中的常见问题,也是判断为什么允许或禁止重载的标准。

更有意思的是,这个问题的答案因语言而异。在 Java 中,返回类型不属于签名;而在 Swift 中,即使只有返回类型不同,也可以重载。

本文将依次整理签名的准确定义、不同语言之间的差异,以及签名在实际开发中为何重要。


签名是方法的身份证

方法签名简单来说就是编译器用来区分该方法与其他方法的标识信息

可以把它理解为人的身份证。仅凭姓名无法区分同名的人,因此身份证除了姓名,还会包含出生日期等额外信息。方法也是一样的。同名方法可以有多个(重载),所以必须通过名称以外的信息准确确定其中一个。

一般来说,签名由以下元素组成。

  • 方法名
  • 参数类型
  • 参数数量
  • 参数顺序
// 这四个方法的签名都不同
void print(int value)              // print(int)
void print(String value)           // print(String) — 类型不同
void print(int a, int b)           // print(int, int) — 数量不同
void print(String s, int n)        // print(String, int)
void print(int n, String s)        // print(int, String) — 顺序不同

有一点需要注意:参数名不属于签名print(int value)print(int number)的签名相同。从编译器的角度看,只根据调用处的print(3)无法区分它们。

访问修饰符(publicprivate)、staticfinal以及异常声明(throws)也不属于签名。签名只包含“决定调用哪个方法所需的信息”。


那么,返回类型包含在内吗?

以 Java 为例,答案是不包含。Java 语言规范(JLS 8.4.2)将签名定义为“方法名和参数类型”。

因此,仅返回类型不同的重载会导致编译错误。

int parse(String input) { ... }

// 编译错误: 'parse(String)' is already defined
String parse(String input) { ... }

看看调用处,就能理解这样设计的原因。

parse("42");  // 如果不接收返回值,就无法决定调用哪一个 parse

在 Java 中,忽略返回值调用方法始终是允许的。这种情况下,编译器没有依据判断两个parse中的哪一个应该被调用。因此,返回类型被彻底排除在签名之外。C++ 出于相同原因,也禁止仅返回类型不同的重载。

有个值得了解的细节。在 JVM 字节码层面,返回类型也包含在区分方法的信息中。在类文件中,方法通过类似parse(Ljava/lang/String;)I的描述符进行标识,最后的I就是返回类型(int)。也就是说,“返回类型不是签名”是 Java语言的规则,而不是 JVM运行环境的规则。泛型桥接方法等机制之所以可行,也得益于这一差异。

方法签名组成元素图 — 名称、参数类型、数量和顺序在 Java 与 Swift 中都包含;参数标签和返回类型仅在 Swift 中包含
同一个问题在 Java 和 Swift 中答案不同 — 两个橙色单元格表示语言差异

Swift 的答案不同

把同样的问题放到 Swift 中,答案就反过来了。在 Swift 中,即使只有返回类型不同也可以重载

func random() -> Int { Int.random(in: 0...100) }
func random() -> Double { Double.random(in: 0...1) }

let n: Int = random()     // Int 调用该版本
let x: Double = random()  // Double 调用该版本

Swift 编译器还会查看调用结果被当作什么类型使用(类型上下文),以此解析重载。不过,没有上下文时就会报错。

let value = random()  // 编译错误: ambiguous use of 'random()'

Swift 的签名还有一个独特元素:参数标签(argument label)是函数名的一部分

func move(from start: Point, to end: Point) { ... }
func move(in direction: Direction) { ... }

这两个函数的正式名称不是move,而分别是move(from:to:)move(in:)。即使参数类型相同,标签不同也代表不同函数。这与 Java 正好相反:Java 会将参数名完全排除在签名之外。

总结如下。

组成元素 Java Swift
方法名 包含 包含
参数类型、数量和顺序 包含 包含
参数名(标签) 不包含 包含(参数标签)
返回类型 不包含 包含(通过上下文解析)

如果有人问“返回类型属于签名吗?”,你能回答“取决于语言:Java 不属于,Swift 属于”,就说明你真正理解了这个概念。


签名在实际开发中重要的三个场景

只知道定义就结束,得到的只是考试知识。签名真正发挥作用的场景还在后面。

场景 1 — 区分重载与重写的标准

重载是“名称相同、签名不同”,重写则是“重新实现父类的相同签名”。没有签名,这两个概念都无法定义。

重写时只要签名有细微错误,编译器就会将其解释为新的重载,而不是重写。父类方法仍然正常存在,而你的方法不会被调用,形成隐蔽的 bug。Java 的@Override和 Swift 的override关键字,正是为了在编译时捕获这种问题。

场景 2 — 采用接口和协议

实现接口(协议)归根结底就是提供与要求的签名完全一致的方法

在 Swift 中实现了代理方法却没有被调用时,很多情况都是签名不匹配。参数是否为可选类型、标签是for还是at,这种细微差异都会让它变成不同于协议要求的独立方法。与编译器会检查的必需要求不同,可选要求会被静默忽略,因此更难排查。

场景 3 — 修改签名就是破坏兼容性

创建库或团队共用模块时,公开方法的签名是与外部建立的契约

增加一个参数、将类型从Int改为Int64,或在 Swift 中只修改一个参数标签,都会破坏所有调用该方法的代码。这就是 breaking change,也是语义化版本控制中需要提升主版本号的典型原因。

因此,成熟的库不会修改签名,而是添加使用新签名的方法,并将原方法保持为 deprecated。它们不会单方面撕毁既有签名代表的契约。

修改公开 API 契约后代码桥梁断裂的 breaking change 插图 — 修改方法签名导致的兼容性破坏
一旦在公开签名上落笔,曾调用该方法的桥梁就会坍塌

设计签名时需要记住的事

从签名是契约这一点出发,设计标准也会变得清晰。

**请以公开一次后就难以修改为前提来设计签名。**内部方法可以随意修改,但 public API 的签名应在首次公开前充分考虑。之后再修改,成本可能高出几十倍。

**如果有迹象表明参数会增加,就把它们封装成一个类型。**拥有 4~5 个参数的签名容易造成顺序错误,每增加一个参数都会变成 breaking change。将参数封装到配置对象或结构体中,就能在保持签名的同时扩展。

**同类型参数连续出现是危险信号。**如果只看签名无法判断transfer(account1, account2)哪一个是提款账户,在 Swift 中可以使用transfer(from:to:)这样的标签;在 Java 中则用有意义的类型封装参数,让签名本身说明用法。


总结

  • 方法签名是编译器用来区分方法的标识信息,共同核心是名称加上参数的类型、数量和顺序。
  • 是否包含返回类型取决于语言。Java 和 C++ 不包含(不能仅凭返回类型不同进行重载),而Swift 包含(通过类型上下文解析);在 Swift 中,参数标签也是函数名的一部分。
  • 签名是区分重载与重写的标准、实现接口时的匹配条件,也是公开 API 中与外部建立的契约
  • 修改公开签名就是 breaking change。与其修改,不如新增,并在最初设计时投入最多精力。

延伸阅读