Swift6 @retroactive:Swift 的重复协议遵循陷阱

作者:RickeyBoy日期:2025/11/20

欢迎大家给我点个 star!Github: RickeyBoy

背景:一个看似简单的 bug

App 内有一个电话号码输入界面,在使用时用户需要从中选择注册电话对应的国家,以获取正确的电话区号前缀(比如中国是 +86,英国是 +44 等)。

Step 1:入口Step 2:缺少区号期望结果

这是一个看似很简单的 bug,无非就是写 UI 的时候漏掉了区号,那么把对应字段拼上去就行了嘛。不过一番调查之后发现事情没有那么简单。

列表是一个公用组件,我们需要在列表中显示国家及其电话区号,格式像这样:"🇬🇧 United Kingdom (+44)"。所以之前在 User 模块中添加了这个extension:

1    extension Country: @retroactive DropdownSelectable {
2        public var id: String {
3            code
4        }
5    
6        public var displayValue: String {
7            emoji + "\t(englishName) ((phoneCode))"
8        }
9    }
10

原理一看就明白,displayValue 代表的是展示的内容。但是最终结果展示错误了:明明将电话区号 ((phoneCode)) 拼在了上面,为什么只显示了国家名称:"🇬🇧 United Kingdom"?

代码可以编译。测试通过。没有警告。但功能在生产环境中却是坏的。

顺便说一下,什么是 DropdownSelectable?

DropdownSelectable 是我们 DesignSystem 模块中的一个协议,它使任何类型都能与我们的下拉 UI 组件配合使用:

1    protocol DropdownSelectable {
2        var id: String { get }           // 唯一标识符
3        var displayValue: String { get } // 列表中显示的内容
4    }
5

Part 1: extension 不起作用了

发现问题

经过调试后,我们发现了根本原因:Addresses 模块已经有一个类似的 extension

1    //  Addresses 模块中
2    extension Country: @retroactive DropdownSelectable {
3        public var displayValue: String {
4            emoji + "\t(englishName)"  // 没有电话区号
5        }
6    }
7
Step 1Step 2

Addresses 模块不需要电话区号,只需要国家名称。这对地址列表来说是合理的。

但关键是:Addresses extension 在运行时覆盖了我们 User extension。我们以为在使用 User 模块的extension(带电话区号),但 Swift 随机选择了 Addresses 的 extension(不带电话区号)。

这就是关键问题。

冲突:同时存在两个拓展协议

代码中发现的两处冲突的拓展协议:

在 User 模块中(我们以为在使用的):

1    extension Country: @retroactive DropdownSelectable {
2        public var id: String {
3            code
4        }
5        public var displayValue: String {
6            emoji + "\t(englishName) ((phoneCode))"  //  带电话区号
7        }
8    }
9

在 Addresses 模块中(实际被使用的):

1    extension Country: @retroactive DropdownSelectable {
2        public var id: String {
3            code
4        }
5        public var displayValue: String {
6            emoji + "\t(englishName)"  //  不带电话区号
7        }
8    }
9

两个模块都有各自合理的实现理由:

  • User 模块:电话号码输入界面需要电话区号
  • Addresses 模块:地址表单不需要电话区号,只需要国家名称

每个开发者都在实现需求时添加了他们需要的内容。代码编译没有警告,新需求测试通过,没人预料到会对旧的需求产生影响。

同时,确实 Swift 也是允许在不同模块中使用相同的 extension。那么到底发生了什么,我们又是如何解决的呢?

Part 2: 为什么会发生这种情况 - Swift 模块系统解析

要理解为什么这是一个问题,我们需要理解 Swift 的模块系统是如何工作的。有趣的是:通常情况下,在不同模块中有相同的 extension 是完全没问题的。但协议遵循是一个特殊情况。

正常情况:extension 在模块间通常工作良好

假设你为一个类型添加了一个辅助方法:

1    //  UserModule 
2    extension Country {
3        var displayValue: String {
4            return emoji + "\t(englishName) ((phoneCode))"
5        }
6    }
7    //  AddressesModule 
8    extension Country {
9        var displayValue: String {
10            return emoji + "\t(englishName)"
11        }
12    }
13

这完全可以!每个模块看到的是它自己的extension:

  • UserModule 中的代码调用 displayValue 会得到带 phoneCode 的结果
  • AddressesModule 中的代码调用 displayValue 会得到不带 phoneCode 的结果

为什么可以: 常规 extension 方法在编译时根据导入的模块来解析。Swift 根据当前模块的导入准确知道要调用哪个方法。

特殊情况:协议遵循是全局的

但协议遵循的工作方式不同。当你写:

1    extension Country: DropdownSelectable {
2        var displayValue: String { ... }
3    }
4

你不只是在添加一个方法。你在做一个全局声明:"对于整个应用程序,Country 遵循 DropdownSelectable。"

所以当你创建两个相同的遵循时,会导致重复遵循错误

1    //  UserModule 
2    extension Country: DropdownSelectable {
3        var displayValue: String {
4            return emoji + "\t(englishName) ((phoneCode))"
5        }
6    }
7    //  AddressesModule 
8    extension Country: DropdownSelectable {
9        var displayValue: String {
10            return emoji + "\t(englishName)"
11        }
12    }
13

当你构建链接两个模块的应用时,Swift 编译器或链接器会报错,类似这样:

'Country' declares conformance to protocol 'DropdownSelectable' multiple times

Part 3: 引入 @retroactive 破坏了编译器检查

剩余问题:这怎么能编译通过?

基本上,如果我们遇到重复遵循错误,编译器会阻止我们。但是为什么这段代码可以正常存在?

一切问题都可以被归咎于 @retroactive

什么是 @retroactive?

在 Swift 6 中,Apple 引入了 @retroactive 关键字来让跨模块遵循变得明确:

1    extension Country: @retroactive DropdownSelectable {
2        // 让一个外部类型
3        // 遵循一个外部协议
4    }
5

你需要使用 @retroactive 当:

  • 类型定义在不同的模块中(例如,来自模块 A 的 Country
  • 协议定义在不同的模块中(例如,来自模块 B 的 DropdownSelectable
  • 你在第三个模块中添加遵循(例如,在 UserModuleAddressesModule 中)

为什么 @retroactive 会破坏编译器检查重复编译问题?

没有 @retroactive 的情况下,重复遵循已经是编译时错误。但有了 @retroactive,问题变得更加棘手 —— 因为现在你明确声明了影响整个应用运行时的东西,而不仅仅是你的模块。

当你写 @retroactive 时,你在说:

"我要为一个我不拥有的现有类型添加遵循,作用于整个 App。"

这意味着编译器允许你 追溯地/逆向地(retroactively) 为在其他地方定义的类型添加遵循。这很强大,但也改变了 Swift 检查重复的方式。

关键点:

Swift 在每个模块内强制执行重复遵循规则,但不跨模块。换句话说,编译器只检查它当前正在构建的代码。

  • 每个生产者模块(UserModule、AddressesModule)单独编译时是正常的(它只"看到"自己的遵循)。到目前为止是正常的。
  • 导入两者的消费者(至少你有一个,就是你的 app target!),会构建失败,因为它看到了两个相同的协议遵循

添加 @retroactive 之后:

使用 @retroactive,Swift 将一些检查推迟到链接时,所以两个模块都能成功编译,即使它们都在声明相同的全局遵循。

重复只有在链接之后才会变得可见,当两个模块都被加载到同一个运行时镜像中时 —— 而那时,编译器已经太晚无法阻止它了。

这就是为什么这些重复可以"逃过"编译器的安全检查,导致令人困惑的运行时级别的 bug。

运行时发生了什么

当链接器发现 (Country, DropdownSelectable) 有两个实现时:

  • 选项 A:UserModule 的实现(带电话区号)
  • 选项 B:AddressesModule 的实现(不带电话区号)

它只能注册一个。所以它根据链接顺序选择一个 —— 基本上是链接器首先处理的那个模块。另一个遵循会被静默忽略。

这解释了为什么 UserModule 的实现被忽略了。

Part 4: 解决方案 - 包装结构体来拯救

幸运的是我们有一个非常简单的修复方法:使用包装类型

解决方案模式

不要让 Country 本身遵循协议,而是包装它:

1    // UserModule 示例
2    struct CountryWithPhoneDropdown: DropdownSelectable {
3        let country: Country
4        var id: String { country.code }
5        var displayValue: String {
6            country.emoji + "\t(country.englishName) ((country.phoneCode))"
7        }
8    }
9    // AddressModule 示例
10    struct CountryAddressDropdown: DropdownSelectable {
11        let country: Country
12
13        var id: String { country.code }
14        var displayValue: String {
15            country.emoji + "\t(country.englishName)"
16        }
17    }
18    // 使用方式
19    countries.map { CountryWithPhoneDropdown(country: $0) }
20    countries.map { CountryAddressDropdown(country: $0) }
21

Part 5: 预防 — 如何防止它再次发生

当然,如果想要不仅是修复这个问题,而是预防这个问题,那么可以通过在工作流程中添加静态分析CI 检查来轻松避免重复的 @retroactive 遵循。

这确保任何重复的 @retroactive 遵循在到达生产环境之前被发现,避免类似的运行时错误。

结语

这个 bug 根本不是简单的 UI 问题,想要彻底解决就需要深度理解 Swift 的运行机制。协议拓展可以跨模块重复,但协议遵循是全局的,@retroactive 叠加 Swift 的这种能力造成了这次的 bug。

一旦我们理解了这一点,修复就很简单了。


Swift6 @retroactive:Swift 的重复协议遵循陷阱》 是转载文章,点击查看原文


相关推荐


如视发布空间大模型Argus1.0,支持全景图等多元输入,行业首创!
机器之心2025/11/19

近来,世界模型(World Model)很火。多个 AI 实验室纷纷展示出令人惊艳的 Demo:仅凭一张图片甚至一段文字,就能生成一个可交互、可探索的 3D 世界。这些演示当然很是炫酷,它们展现了 AI 强大的生成能力。 但一个关键问题随之而来:这些由 AI 生成的世界中,绝大部分事物都是模型想象和虚构的。 如果我们不满足于「创造」一个虚拟世界,而是想把我们当下生活的这个真实世界(比如我们的家、办公室、工厂和城市)完整地变成一个可交互、可计算的 3D 世界呢? 这正是如视(Realsee)想要解


pytest1-接口自动化测试场景
文人sec2025/11/17

课程:B站大学 记录python学习,直到学会基本的爬虫,使用python搭建接口自动化测试,后续进阶UI自动化测试 接口自动化测试 接口自动化测试的场景测试金字塔模型自动化测试前需要思考什么?Pytest是什么?Pytest 有哪些格式要求?在pycharm下安装pytestpytest知识点测试用例示例类级别的用例示例断言测试装置介绍参数化参数化测试函数使用Mark:标记测试用例Skip:使用场景pytest命令运行测试用例文件pytest中执行顺序如何调整pytest中py文件执


Next.js第五章(动态路由)
小满zs2025/11/16

动态路由 动态路由是指在路由中使用方括号[]来定义路由参数,例如/blog/[id],其中[id]就是动态路由参数,因为在某些需求下,我们需要根据不同的id来显示不同的页面内容,例如商品详情页,文章详情页等。 基本用法[slug] 使用动态路由只需要在文件夹名加上方括号[]即可,例如[id],[params]等,名字可以自定义。 来看demo: 我们在app/shop目录下创建一个[id]目录 //app/shop/[id]/page.tsx export default function Pa


基于脚手架微服务的视频点播系统-脚手架开发部分(完结)elasticsearch与libcurl的简单使用与二次封装及bug修复
加班敲代码的Plana2025/11/15

基于脚手架微服务的视频点播系统-脚手架开发部分elasticsearch与libcurl的简单使用与二次封装及bug修复-完结 1.ElasticClient的使用1.1ES检索原理正排索引倒排索引 1.2ES核心概念1.2.1索引(index)1.2.2类型(Field)1.2.3字段(Field)1.2.4映射(mapping)1.2.5文档(document) 1.3 Kibana访问es进行测试1.3.1创建索引1.3.2新增数据1.3.3查看并搜索数据1.3.4删除索引


qinkun的缓存机制也有弊端,建议官方个参数控制
石小石Orz2025/11/14

公司前端基于qiankun架构,主应用通过qiankun加载子应用,子应用也可能通过qiankun继续加载子应用,反复套娃。经过测试,不断打开子应用后,会导致内存不断上上。通过快照分析,发现内存升高的元凶是qiankun内置的# import-html-entry。 import-html-entry 的作用是什么 import-html-entry 是 qiankun / single-spa 微前端生态的核心模块之一,用来: 加载远程 HTML 入口文件(entry HTML),并提取出其中


Python 的内置函数 int
IMPYLH2025/11/13

Python 内建函数列表 > Python 的内置函数 int Python 的内置函数 int() 是一个用于将其他数据类型转换为整数类型的重要函数。它具有以下详细特性: 基本功能: 将数字或字符串转换为整数语法:int(x, base=10)示例:int('123') # 返回 123 int(12.34) # 返回 12 参数说明: 第一个参数可以是: 数字(整数或浮点数)字符串(仅包含数字字符)布尔值(True 转为 1,False 转为 0) 可


✍️记录自己的git分支管理实践
你的人类朋友2025/11/11

前言 👋 你好啊,我是你的人类朋友! 因为本人的开发经常涉及各个分支间的同步,这一套同步的流程从刚开始的小心翼翼,到现在相对熟悉了 所以我想记录下自己工作中常用的分支同步的步骤 😆 顺便研究康康有没有可以优化的地方 🍃 正文 先介绍下背景情况吧 首先主分支为 master 其次,因为开发分为多个阶段,比如 phase_1、phase_2、phase_3 等 那就在 master 之后再创建 feature/phase_1、feature/phase_2 这样的分支,作为每一个 phase


草梅 Auth 1.11.0 发布与 GitHub 依赖安全更新 | 2025 年第 45 周草梅周报
草梅友仁2025/11/9

本文在 草梅友仁的博客 发布和更新,并在多个平台同步发布。如有更新,以博客上的版本为准。您也可以通过文末的 原文链接 查看最新版本。 前言 欢迎来到草梅周报!这是一个由草梅友仁基于 AI 整理的周报,旨在为您提供最新的博客更新、GitHub 动态、个人动态和其他周刊文章推荐等内容。 本周依旧在开发 草梅 Auth 中。 你也可以直接访问官网地址:auth.cmyr.dev/ Demo 站:auth-demo.cmyr.dev/ 文档地址:auth-docs.cmyr.dev/ 本周 草梅


理解 LangChain 智能体:create_react_agent 与 create_tool_calling_agent
奇舞精选2025/11/7

本文译者为 360 奇舞团前端开发工程师 原文标题:理解 LangChain 智能体:create_react_agent 与 create_tool_calling_agent 原文作者:Anil Goyal 原文地址:medium.com/@anil.goyal… 当我们使用 LangChain 构建 AI 智能体时,首先要做的是选择正确的智能体架构。 目前常用的2种架构是create_react_agent和create_tool_calling_agent。两者都可以让AI使用外部工具


windows npm打包无问题,但linux npm打包后部分样式缺失
悢七2025/11/4

原因 前端package.json中指定的是依赖版本范围,而linux中使用npm install安装的版本与windows不同。 例如"@ant-design/icons": “^4.0.0” 插入符号^意味着它可以安装最新的兼容版本。如果希望它安装特定版本,可以在版本前面删除^。 详见package.json文档和符号学 插入符号将让它安装一个不改变第一个数字的更高版本。例如,你的package.json为@ant-design/icons指定了^4.0.0,但它安装了4.6.2。由

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2025 聚合阅读