码农高天
普通类型别名无法阻止相同底层类型变量的错位赋值,引入NewType虽能在静态期杜绝逻辑隐患,但其强制显式包装要求也引发了代码背离Python原生风格的争议。
AI-assisted summary of 【python】Type Hint的进阶知识,这下总该有你没学过的内容了吧? — original source. Check the original for full context.
Source published:
Key ideas
- 局部变量显式类型标注有助于 mypy 提前捕获空容器初始化后混入错误类型的隐患;显式标注 Any 比留空更有利于表达代码意图。 (0:00)
- 函数无显式返回值时应标注为 None 而非 Any;对于直接抛出异常或退出程序的函数,应标注为 NoReturn,IDE 可据此自动识别并灰显后续死代码。 (0:00)
- Callable 可通过方括号细化入参与返回值签名,配合 Literal 字面量类型能有效杜绝高阶函数传参错误与字符串拼写疏漏。 (5:00)
- 普通类型别名无法防止具有相同底层类型(如 int)的变量相互错位赋值,而 NewType 可实现强类型派生隔离,但需要显式构造包装。 (10:00)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2024 年 2 月 24 日发布。视频简介说明,本期内容基于上一期视频进一步探讨 Python 类型标注(Type Hint)的进阶用法,重点介绍一些相对常用但更不直观、日常容易忽略的类型检查知识点。
## TL;DR 视频系统性地讲解了 Python 类型系统中的进阶概念与实践技巧。首先厘清了局部变量类型标注的必要性,并严格区分了 Any、None 与 NoReturn 在函数返回值和控制流提示中的不同语义;接着深入介绍了高阶函数中 Callable 的参数与返回值签名约束,以及使用 Literal 限制合法取值范围的方法;最后对比了类型别名(Type Alias)与 NewType 的本质区别,探讨了如何通过 NewType 实现同底层类型的强类型安全,以及这种做法在类型安全与代码风格 Pythonic 之间的权衡。
## Atmosphere & style 整期视频呈现出清晰严谨、条理分明的技术布道风格,高天以沉稳且富有逻辑的语调娓娓道来。讲解紧扣实际开发中容易踩坑的边界场景,通过具体的代码正反例和 mypy 报错直观展现类型系统的运作逻辑。在探讨 NewType 的强类型约束时,他既指出了其防错价值,也客观指明了它对动态灵活风格的侵蚀,留给观众务实的权衡空间,整体氛围干货满满且具启发性。
## Bulletin - [00:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=0s) 局部变量显式类型标注有助于 mypy 提前捕获空容器初始化后混入错误类型的隐患;显式标注 Any 比留空更有利于表达代码意图。 - [00:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=0s) 函数无显式返回值时应标注为 None 而非 Any;对于直接抛出异常或退出程序的函数,应标注为 NoReturn,IDE 可据此自动识别并灰显后续死代码。 - [05:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=300s) Callable 可通过方括号细化入参与返回值签名,配合 Literal 字面量类型能有效杜绝高阶函数传参错误与字符串拼写疏漏。 - [10:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=600s) 普通类型别名无法防止具有相同底层类型(如 int)的变量相互错位赋值,而 NewType 可实现强类型派生隔离,但需要显式构造包装。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=0s) 类型标注不仅适用于函数入参和返回值,亦可直接用于局部变量,例如限制空列表 users 的内部元素类型。 - [00:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=0s) 显式声明好于隐式推导,暂时未想好类型或确实接受任意类型时显式标注 Any 比直接留空更清晰规范。 - [00:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=0s) 没有显式 return 的函数在运行时返回 None,若错误标注为 Any 将导致静态检查器无法拦截非法赋值。 - [00:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=0s) 直接抛出异常或调用 sys.exit() 的函数应标注为 NoReturn,IDE 能借助该标注将后续不可达代码标记为死代码。 - [05:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=300s) 编写高阶函数或装饰器时使用 Callable 标注参数,可静态拦截传入非可调用对象的调用错误。 - [05:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=300s) Callable 支持以 Callable[[Arg1Type, Arg2Type], ReturnType] 的形式精确约束入参列表及返回值的具体类型。 - [05:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=300s) Literal 用于限定变量的取值必须严格等于特定字面量,可有效预防类似性别枚举中的拼写错误。 - [05:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=300s) 在将字面量赋值给中间变量再传参时,mypy 默认将其推导为宽泛类型,需要对局部变量也进行 Literal 显式标注。 - [10:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=600s) 类型标注本身是 Python 对象,可以赋值给变量创建类型别名(Type Alias)以提高复杂签名的复用性与可读性。 - [10:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=600s) 普通类型别名仅是同义词,无法静态拦截两个底层类型相同(如 int)但业务语义不同的变量之间的错位赋值。 - [10:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=600s) NewType 能够在静态检查层面创建出独立派生类型,杜绝语义不同但底层相同类型的变量相互混用。 - [10:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=600s) 使用 NewType 声明的类型在运行时开销基本等同于空操作(no-op),但要求开发者显式调用构造方法进行包装转换。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=0s) 变量类型标注与函数的返回值标注(Any、None 与 NoReturn)
在实际编写 Python 代码时,类型标注(Type Hint)不仅适用于函数参数和返回值,也可以直接用于局部变量。码农高天举例说明:如果初始化一个空的列表 `users = []`,随后向其中追加元素,原本构想中 `users` 应该是一个存放用户名的字符串列表(`list[str]`),但 Python 默认允许向其中追加任意类型的对象(例如追加整型 `1`)。此时如果给变量加上显式类型标注 `users: list[str] = []`,静态类型检查工具 mypy 就会立刻捕获错误,指出追加整型 `1` 不符合 `users` 的类型定义。
紧接着,高天介绍了几个常用但容易混淆的类型标注。第一个是 `Any`,它代表“什么类型都有可能”。在不提供类型标注时系统默认按 `Any` 处理,但显式标注的意义重大:码农高天指出,“在大部分时候我们认为显式是要好于隐式的,所以无论你是觉得这个地方我现在还没有想好怎么标注,还是说我 take 的这个东西确实就是什么都行,标注一个 Any 都是比把它留空要更好的方法。”
针对函数的返回值标注,一个常见误区是“函数没有显式返回值所以它的返回类型是 Any”。高天明确纠正了该观点: - Python 中没有显式 `return` 的函数,在运行时实际上返回的是 `None`。如果标注为 `Any`,当外部执行 `i: int = f()` 时,mypy 无法报错,因为类型检查器认为 `f` 返回任何值都是合法的;但实际上该变量永远拿不到 `int`。当把 `f` 的返回值正确标注为 `None` 时,类型检查器就会准确拦截并提示错误。 - 另有一类函数是真的“不返回”——例如在执行过程中直接抛出异常(`raise Exception`)或直接退出了程序(如调用 `sys.exit()`)。对于这类函数,应该标注为 `typing.NoReturn`。标注后,IDE 能够自动识别该函数之后的代码为死代码(Dead Code),并将其置灰变暗以提示开发者该行代码永远不会被执行。
[05:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=300s) 可调用对象标注(Callable)与字面量类型(Literal)
在编写高阶函数或装饰器(Decorator)时,经常需要限制某个参数必须是一个可以被调用的对象,这就需要用到 `Callable`。如果仅写一个装饰器接受任意 `func`,从语法层面开发者可以给装饰器传入任意非可调用对象(如数字 `1`),运行期间才会崩溃。将参数标注为 `Callable` 后,静态检查就能直接拦截非法调用。
进一步地,`Callable` 还可以像 `list` 和 `dict` 一样使用方括号进行细化定义:`Callable[[Arg1Type, Arg2Type], ReturnType]`。方括号内包含两项内容,第一项是用方括号包裹的输入参数类型列表,第二项是返回值类型。例如定义一个用于 debug 的装饰器,要求被修饰的函数必须接收两个 `int` 并返回一个 `int`: ```mermaid flowchart LR accTitle: Callable 类型标注校验机制 accDescr: 展示 Callable 如何静态检查被修饰函数的参数列表与返回值类型。 subgraph CallableSpec["Callable 规范: Callable[[int, int], int]"] A["输入: [int, int]"] B["输出: int"] end subgraph Functions["待修饰函数"] F1["add(a: int, b: int) -> int"] -->|"匹配成功"| V["类型检查通过"] F2["abs(x: int) -> int"] -->|"参数数量不符"| E1["mypy 报错"] F3["add(a: str, b: str) -> str"] -->|"参数类型不符"| E2["mypy 报错"] end ``` 如果传入只有一个参数的绝对值函数,或者入参为 `str` 的函数,mypy 就会立即报错,指明两个 `Callable` 的签名不匹配,从而在静态期杜绝了运行时参数不符的问题。
随后,高天介绍了 `Literal`(字面量类型),其含义是参数的值“必须就得是这个样子的”。例如一个 `Person` 类中用字符串来表示性别 `gender`,但业务上只允许为 `'male'` 或 `'female'`。使用 `Literal['male', 'female']` 标注后,传入 `'woman'` 就会引发类型检查报错。这不仅能约束状态,还能防止开发者拼写错误。然而,如果开发者先声明变量 `g = 'female'` 再将其传入构造函数,mypy 仍会报错,因为系统默认推导 `g` 的类型是宽泛的 `str`;此时必须对局部变量也进行显式标注 `g: Literal['male', 'female'] = 'female'`。
[10:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=600s) 类型别名(Type Alias)与强类型包装(NewType)
针对重复书写长类型签名的问题,高天指出类型标注本身也是 Python 对象,可以赋值给变量作为类型别名(Type Alias)。例如直接声明 `GenderType = Literal['male', 'female']`,之后所有地方都可以复用 `GenderType`。不仅修改时只需改动一处,还能极大提升代码可读性: - 将普通类型重命名(如 `NameType = str`),能向阅读代码的人传达更多语义信息。 - 如果多个函数的返回值均为结构相同的元组(如 `tuple[int, Optional[str]]`,第一项为错误码,第二项为错误信息),给该元组起一个名字(例如 `ReturnType`),可以让函数签名更加直观清晰。
然而,单纯的类型别名只是同义词,类型检查器依然认为别名和原类型完全等价。高天用玩家数据类的例子阐述了其中的隐患:假设玩家类中有玩家 ID(`user_id`)和攻击力(`attack_point`),两者底层都是 `int`。即使定义了别名 `UserId = int` 和 `AttackPoint = int`,如果在更新方法中误将 `attack_point` 赋值给了 `user_id`,mypy 仍旧会判定通过,因为它们本质上都是 `int`,静态检查因此错失了捕获 Bug 的机会。
为了彻底解决这一问题,Python 引入了 `NewType`: - 使用 `UserId = NewType('UserId', int)` 和 `AttackPoint = NewType('AttackPoint', int)` 能够真正创建出独立的新类型。 - 此时若将 `AttackPoint` 赋值给 `UserId`,mypy 便会立即报错,提示两者类型不一致。 - 代价在于赋值时的灵活性降低:直接传入原始整数 `100` 会被判定类型不匹配,必须显式调用构造方法将其包裹(如 `UserId(1)` 与 `AttackPoint(100)`)。高天指出,虽然这两个包装函数在运行时(run time)基本等同于空操作(no-op),不会执行额外计算,但这种代码风格会引发争议:“这种方式当然让我们的程序变得不那么容易出错了,但是从另一个角度它也让 Python 变得更不 Python 了,就很多人到这个点的时候他就会认为不行这样实在是太多了,当然用与不用、怎么用、用多少,最终呢还是取决于你自己。”
- 类型别名与 NewType 的行为对比: | 特性 | 类型别名(Type Alias,如 `UserId = int`) | NewType(如 `UserId = NewType('UserId', int)`) | |:--- |:--- |:--- | | **类型等价性** | 与原类型完全等价,彼此可隐式混用 | 视为全新派生类型,与原类型及其他别名互不兼容 | | **交叉赋值检查** | 无法检测不同别名之间的错位赋值错误 | 能够严格静态拦截同底层类型的变量错位赋值 | | **初始化方式** | 直接传入原生字面量(如 `uid: UserId = 1`) | 必须显式包装转换(如 `uid = UserId(1)`) | | **运行时开销** | 无开销(纯静态别名) | 极低(运行时等效为恒等函数 no-op) |
## Notable quotes > [00:00](https://www.youtube.com/watch?v=6rgBwA7TRfE&t=0s) 在大部分时候我们认为显式是要好于隐式的,所以无论你是觉得这个地方我现在还没有想好怎么标注,还是说我 take 的这个东西确实就是什么都行,标注一个 Any 都是比把它留空要更好的方法。 — 码农高天
## Mentioned 码农高天, Python, mypy, typing
## Video description 这期视频我们讲一下type hint,也就是类型标注的进阶内容。在上一期视频的基础上,继续介绍一些相对也比较常用的用法。这次的知识点就没有上次那么直观了,一定会有你没学过的东西!
[Source](https://www.youtube.com/watch?v=6rgBwA7TRfE)