It should also be emphasized that Python will remain a dynamically typed language, and the authors have no desire to ever make type hints mandatory, even by convention.

本章的主要内容包括:

  • 使用 Mypy 进行渐进式类型的实践入门
  • 鸭子类型和名义类型的互补视角
  • 可出现在注解中的主要类型概述 —— 这部分内容约占本章的 60%
  • 可变参数(*args,**kwargs)的类型提示
  • 类型提示和静态类型的局限性与缺点

1、About Gradual Typing

在 Python 中,PEP 484 引入了渐进式(Gradual Typing)类型系统。通过这份笔记,我们将重点讲解如下几个方面:

  1. 什么是渐进式类型系统
  2. 渐进式类型系统的特点
  3. 类型提示(Type Hints)的作用和局限性
  4. 使用渐进式类型检查工具 Mypy 的背景与逻辑
  5. 渐进式类型系统的应用场景和最佳实践

1. 什么是“渐进式类型系统”?

Python 是一种动态类型语言,这意味着变量类型在运行时才能确定,而不是像某些静态类型语言(如 Java、C++)那样在编译时就需要定义类型。渐进式类型系统是一种用来平衡动态类型和静态类型的方式,允许我们在 Python 中 逐步引入 类型提示,如有必要,也可以完全不使用类型提示。

类似的渐进式类型语言:

  • TypeScript: JavaScript 的一个静态类型超集。
  • Dart: Google 开发的编程语言(Flutter 框架使用)。
  • Hack: PHP 的一种方言(由 Facebook 开发)。

为什么需要渐进式类型?

  • 代码中的类型信息可提升代码可读性,便于开发者理解函数输入/输出的意图。
  • 静态分析工具如 Mypy, PyCharm 等可以用类型提示,在开发时捕捉潜在的类型错误。
  • 可选性使得用户可以逐步采用,而不是强制要求。

2. 渐进式类型系统的特点

特点 1:类型提示是可选的

  • 没有类型提示的代码不会生成额外的类型检查警告。
  • 对于未明确类型的变量或函数参数,类型检查工具会默认将其视为 Any 类型。
    • Any 类型: 代表可以是任何类型的数据,是所有类型的超集。
例子:
# 没有类型注解的函数
def add(a, b):
    return a + b

# 有类型注解的函数
def add(a: int, b: int) -> int:
    return a + b

无论是否加注解,这段代码都能运行,但是加了类型注解后,**静态类型检查工具(如 Mypy)**可以捕捉如下潜在问题:

# 错误函数调用
add("hello", 5)  # 静态检查工具会警告:a 是 str 但期望是 int

特点 2:不捕获运行时的类型错误

  • 类型提示只在开发期间通过工具来标记潜在问题,无法在运行时阻止错误。
  • 类型检查器(如 Mypy)是静态检查工具,而不是动态检查工具。
  • 变量或参数的实际类型仍然由运行时决定。
例子:
def add_numbers(x: int, y: int) -> int:
    return x + y

# 错误调用,但不会阻止运行
print(add_numbers("hello", 3))  # 运行时会报错 TypeError,但类型检查工具只能在开发期间捕捉
  • 动态特性:Python 程序运行时实际上不会验证 xy 的类型,这就是为什么它适合快速开发和实验。
  • 类比:如果你对某些语言如 Java 等比较熟悉,它们会在编译阶段捕获类似类型问题,而不是在运行的时候抛出错误。

特点 3:不提升性能

  • 类型注解的主要作用是 增强代码的可读性和开发期错误检查,并不会提升 Python 解释器在运行时的性能。
  • 虽然理论上类型信息可能用于优化字节码生成,但目前(2021年7月)没有 Python 解释器真正实现这一点。
对比:JIT 编译器的优化能力
  • JIT(Just-in-Time Compiler)如 PyPy 可以监控运行时的类型,生成有针对性的优化代码,但这与静态类型提示无关。

总结

  • 渐进式类型系统 是 Python 的一大特色,带来了类型安全性和开发效率之间的平衡。
  • 主要优点:
    • 类型提示可选,无需重构已有代码。
    • 可以辅助静态工具发现潜在错误。
  • 注意事项:
    • 类型注解不会影响 Python 的运行时行为,也不会提升性能。
    • 不要强求 100% 类型覆盖,灵活使用类型注解才能体现 Python 的优势。

2、Gradual Typing in Practice

1. 什么是逐步类型标注 (Gradual Typing)?

逐步类型标注是指可以逐步为代码添加类型提示,而不必一开始就为整个代码库提供完整的类型标注。它的优势在于:

  • 不破坏现有代码。
  • 允许在项目中根据需求逐步增强类型标注。
  • 配合 Mypy 等工具找出潜在的类型问题。

常用的 Python 类型检查工具

Python 提供了多种符合 PEP 484 标准的类型检查工具:

  • Mypy: 最常用的类型检查工具。
  • Pytype: 更宽容无类型标注代码,适合无标注代码库;还可自动生成类型注解。
  • Pyright: 微软开发的快速类型检查工具,特别适配 TypeScript 用户。
  • Pyre: Facebook 开发的工具,性能优化突出。
  • IDE 自带: 如 PyCharm 提供嵌入式类型检查器。

2. 基础示例:为函数添加类型标注

我们以一个简单的函数为例,逐步为它添加类型标注并通过 Mypy 检查代码中的潜在错误。

示例:未标注的函数

# 示例 1:未标注的函数
def show_count(count, word):
    if count == 1:
        return f'1 {word}'
    count_str = str(count) if count else 'no'
    return f'{count_str} {word}s'
输入输出示例
>>> show_count(99, 'bird')
'99 birds'

>>> show_count(1, 'bird')
'1 bird'

>>> show_count(0, 'bird')
'no birds'

此时函数是未标注的,因此 Mypy 默认忽略其类型检查。


3. 开始使用 Mypy

安装 Mypy

$ pip install mypy

我们运行 Mypy 检查未标注的代码:

$ mypy messages.py
Success: no issues found in 1 source file

注意:如果函数没有任何类型标注,Mypy 会默认忽略它。

强制检查无类型标注

通过以下选项可以强制 Mypy 查看未标注的函数:

  • --disallow-untyped-defs: 标记未完全标注的函数。
  • --disallow-incomplete-defs: 标记部分类型标注不完整的函数。

例如:

$ mypy --disallow-untyped-defs messages.py
messages.py:1: error: Function is missing a type annotation for one or more arguments

4. 添加类型标注

逐步添加类型

我们从简单的返回类型开始:

# 添加返回类型标注
def show_count(count, word) -> str:

再次运行 Mypy:

$ mypy messages.py
messages.py:1: error: Function is missing a type annotation for one or more arguments

Mypy 提示我们需要进一步标注参数类型:

# 完整标注
def show_count(count: int, word: str) -> str:

此时,Mypy 将报告“Success”。

最佳配置:自动化类型检查选项

可以将常用的 Mypy 配置保存到 mypy.ini 文件中:

[mypy]
python_version = 3.9
disallow_incomplete_defs = True
warn_unused_configs = True

5. 可选参数与默认值

当函数需要支持可选或带默认值的参数时,添加类型标注的同时还应注意变量的默认值。

处理不规则的复数形式

考虑以下改进需求:让用户指定复数形式。

>>> show_count(3, 'mouse', 'mice')
'3 mice'

改进代码

# 添加可选参数plural
def show_count(count: int, singular: str, plural: str = '') -> str:
    if count == 1:
        return f'1 {singular}'
    count_str = str(count) if count else 'no'
    if not plural:
        plural = singular + 's'
    return f'{count_str} {plural}'

优化使用 None 作为默认值

如果我们想使用 None 作为默认值,需借助 Optional 类型:

from typing import Optional

def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:
    if count == 1:
        return f'1 {singular}'
    count_str = str(count) if count else 'no'
    if plural is None:
        plural = singular + 's'
    return f'{count_str} {plural}'
解读类型
  • Optional[str]: 表示类型既可以是 str,也可以是 None
  • = None: 这是默认值设置,使得参数真正成为可选。

6. 避免常见错误

典型错误示例

# 错误:将默认值当作类型标注
def hex2rgb(color=str) -> tuple[int, int, int]: pass

以上代码的错误在于:color=str 实际上设置了默认值,而非类型标注。正确写法为:

def hex2rgb(color: str) -> tuple[int, int, int]: pass

代码风格规范

  • 参数名后紧跟冒号 :,紧随其后不留空格。
  • 默认值两侧保留空格,例如:plural: str = ''
  • 工具推荐: 使用 flake8blue,确保代码风格符合 PEP 8 要求。

7. 逐步类型标注的优势

逐步类型标注不仅易于实施,并且结合工具可以帮助我们:

  1. 主动规划代码设计:通过类型明确变量和函数意图。
  2. 提前发现错误:静态检查工具如 Mypy 提供了在运行前发现逻辑错误的能力。
  3. 增强代码可读性和协作性:提高代码质量,特别是在团队协同开发时。

3、Types Are Defined by Supported Operations

本笔记将详细讲解 Python 程序设计中如何通过类型定义实现支持的操作,并探讨 Duck Typing(鸭子类型)Nominal Typing(名义类型) 的对比。我们将从概念入手,通过原始内容中的例子分析原理,补充额外例子以澄清易混淆的地方,帮助您深入理解相关内容并更高效地使用 Python 类型提示。


1. 什么是类型?

官方定义

根据 PEP 483 的定义,类型(Type)可以被视为:

  • 一个值的集合(例如 int 类型表示所有整数),以及
  • 一组可以应用于这些值的函数或操作

在 Python 中,理解一个类型更为实际的方法是基于它支持的操作来定义。例如:

def double(x):
    return x * 2
理解 double 函数的类型

这里,参数 x 的类型取决于它支持的操作:

  • 如果 x 是数字(如 intfloatcomplex 等),支持乘法操作。
  • 如果 x 是序列(如 strlisttuple 等),也支持乘以整型,生成一个扩展后的序列。
  • 如果传入的对象没有实现 __mul__ 方法,调用 x * 2 会产生 TypeError

因此,函数是否能执行,依赖于运行时 x 是否支持当前操作,而不一定与类型检查一致。


2. Python 类型约束及其局限性

Python 的类型提示和检查器(如 Mypy)仅处理显式声明的类型,它无法像某些语言一样对类型值范围进行限制。例如:

  • 你无法通过类型提示定义一个介于 1 至 1000的整数类型或一个特定格式字符串(三个字母的机场代码)。
  • Python 标准库支持的类型通常范围极广(如 intstr)或者极窄(如 NoneTypebool)。

这样的松散性原本就是 Python 动态类型设计中的一部分。


示例分析:类型声明与运行时行为

错误的类型标注

以下代码中,x 被错误声明为 abc.Sequence 的类型(abc.Sequencelisttuple 等序列类型的抽象基类),但实际调用了序列 不支持的操作

from collections import abc

def double(x: abc.Sequence):
    return x * 2
  • 运行时:只要 x 支持乘法(__mul__ 方法),代码可以通过,不管 x 实际是什么类型。
  • 静态检查(如 Mypy):abc.Sequence 这一类型声明会报错,因为它并不定义 __mul__ 方法,静态检查工具会拒绝该代码。

黄色信号:静态类型检查器能发现潜在的问题,但这在运行时可能并不是真的错误。

修正版示例

正确的做法应当声明一个更为精准的类型,或避免错误声明:

from typing import Any

def double(x: Any):  # 允许任意支持 __mul__ 的类型
    return x * 2

备注:要为函数精确地定义参数类型,可能需要通过 自定义协议(Protocol),见“静态协议”章节。


3. Duck Typing vs. Nominal Typing

3.1 鸭子类型(Duck Typing)

定义:只关心对象“行为”的类型判断。如果一个对象支持“需要的操作”,它就是期望的类型。

核心理念:“如果看起来像鸭子,走路像鸭子,叫声像鸭子,那它就是鸭子”。

class Bird:
    pass

class Duck(Bird):
    def quack(self):
        print('Quack!')

def alert(birdie):
    birdie.quack()  # 不关心 birdie 实际的类是什么,只要它有 .quack() 方法就行

daffy = Duck()
alert(daffy)  # 输出 "Quack!",运行时一切正常
  • 优点:灵活,不需要繁琐的类型声明。
  • 缺点:错误可能延后到运行时才能发现,导致程序的健壮性降低。

3.2 名义类型(Nominal Typing)

定义:显式要求对象具备特定的类型及层级(依赖继承关系)。

from birds import Duck, Bird

def alert_bird(birdie: Bird):
    birdie.quack()  # 静态检查器会报错,因为 Bird 类没有 .quack() 方法
  • 静态检查:Mypy 报错
    error: "Bird" has no attribute "quack"
    
  • 运行时birdie 实为 Duck 实例):正常运行

名义类型的检查更严谨,但限制了动态性。它能提前发现一些潜在问题,但可能误报程序实际能运行的代码为错误。

对比总结
特点 鸭子类型(Duck Typing) 名义类型(Nominal Typing)
检查时间 运行时检查 编译时检查
首要属性 行为与操作是否匹配 对象的显式类型是否符合声明
优缺点 更灵活,但运行时易出错 更严格,能提前发现错误,但失去部分灵活性

示例:鸭子类型 vs. 名义类型

通过对比以下代码加深理解:

from birds import Duck, Bird

daffy = Duck()
alert_bird(daffy)  # 静态检查错,但运行时正常

woody = Bird()
alert_bird(woody)  # 静态检查错,运行时出错 -> AttributeError: 'Bird' object has no attribute 'quack'
  • 静态检查器(比如 Mypy)通过类型声明发现错误,如调用了 Bird 类未定义的 quack 方法。
  • 运行时则基于对象的行为来执行,因此 Duck 类型对象可以通过,但不匹配的实际类型会触发错误。

4. 从错误中学习

小结:避免两类常见错误

  1. 过于宽松的参数类型声明
    不用泛化类型(比如 Any)声明参数时,应当了解类型声明和实际行为的差异。

  2. 误用类继承
    如果类缺少所需属性/方法,仍忽略错误进行运行将可能导致运行时失败。


实践意义与大规模代码的类型提示

  • 在小型示例中,类型提示可能显得冗余且难以体现价值。但当代码越来越复杂时,类型检查器的作用会更加突出:
    • 大型代码审查更精确,减少错误。
    • 提升可维护性,让 IDE 提供的智能提示(补全/验证)更准确。
  • 特别是对于大型企业级项目(如 Facebook 和 Google),类型检查是减少故障成本的核心工具。

5. 结论:灵活与严格的平衡

  • 小型项目/快速开发:更倾向于依赖 Duck Typing 的灵活性。
  • 大型项目/多人合作:名义类型与类型检查工具(如 Mypy)提供更高安全性和可维护性。

接下来,可以继续学习如何通过 静态协议 和更复杂的类型提示来进一步编码更加优雅和安全的 Python 程序。

3、Types Usable in Annotations

This section covers all the major types you can use with annotations:
• typing.Any
• Simple types and classes
• typing.Optional and typing.Union
• Generic collections, including tuples and mappings
• Abstract base classes
• Generic iterables
• Parameterized generics and TypeVar
• typing.Protocols—the key to static duck typing
• typing.Callable
• typing.NoReturn—a good way to end this list

We’ll cover each of these in turn, starting with a type that is strange, apparently use‐
less, but crucially important

The Any Type

1. Any 类型的基本概念

在渐进类型系统(Gradual Typing System)中,Any 类型是一个非常关键的概念。Any 类型也被称为动态类型,意味着它可以代表任何数据类型。

  • 未注解函数的默认类型是 Any:当类型检查器遇到一个未注解的函数时,会假定函数参数和返回值的类型为 Any。例如:

    def double(x):
        return x * 2
    

    实际上等同于:

    def double(x: Any) -> Any:
        return x * 2
    

    这里的 x 和返回值可以是任何类型。

2. Any 类型 vs. object 类型

Any 类型和 object 类型之间有一些显著的差异:

  • Any 类型:可以进行任何操作,类型检查器会假设 Any 类型支持所有可能的操作。

  • object 类型:虽然 object 类型的变量可以接受任何类型的值,但它不支持所有操作。例如:

    def double(x: object) -> object:
        return x * 2  # 这会导致类型检查错误
    

    由于 object 类型不支持 __mul__ 操作,类型检查器会报错。

    错误信息示例:

    error: Unsupported operand types for * ("object" and "int")
    

3. 类型层次结构中的 Any

  • Any 类型在类型层次结构中处于最顶端和最底端,既是最通用的类型,也是支持所有操作的最专业化的类型。
  • 使用 Any 可能会阻碍类型检查器的核心功能:在程序运行时异常崩溃之前检测潜在的非法操作。

4. 子类型与一致性

  • 子类型(is subtype-of):传统的面向对象类型系统中,类 T2 是类 T1 的子类型。

    class T1:
        ...
    
    class T2(T1):
        ...
    
    def f1(p: T1) -> None:
        ...
    
    o2 = T2()
    f1(o2)  # 合法
    

    这符合 Liskov 替换原则(LSP)。

    • LSP 违例

      def f2(p: T2) -> None:
          ...
      
      o1 = T1()
      f2(o1)  # 类型错误
      
  • 一致性(consistent-with):在渐进类型系统中,consistent-with 关系适用于 subtype-of,并对 Any 进行了特殊规定。

    规则包括:

    1. 子类型关系适用于一致性。
    2. 每种类型都与 Any 一致:可以将任何类型的对象传递给声明为 Any 类型的参数。
    3. Any 与每种类型一致:可以将 Any 类型的对象传递给任何其他类型的参数。

    示例:

    def f3(p: Any) -> None:
        ...
    
    o0 = object()
    o1 = T1()
    o2 = T2()
    f3(o0)  # 合法
    f3(o1)  # 合法
    f3(o2)  # 合法
    
    def f4():  # 隐式返回类型:`Any`
        ...
    
    o4 = f4()  # 推断类型:`Any`
    f1(o4)  # 合法
    f2(o4)  # 合法
    f3(o4)  # 合法
    

5. 类型推断

现代类型检查器可以推断许多表达式的类型,无需显式的类型声明。例如,x = len(s) * 10 中,如果 len 函数有类型提示,类型检查器可以推断 xint 类型。

通过了解 Any 类型及其在类型层次结构中的角色,可以更好地理解和应用 Python 的类型提示系统。这对于编写更安全和更可靠的代码至关重要。

Simple Types and Classes

简单类型

简单类型包括Python中的一些基础数据类型,它们可以直接用于类型提示。这些类型包括:

  • int: 整数类型
  • float: 浮点数类型
  • str: 字符串类型
  • bytes: 字节类型

具体类可以来自标准库、外部库,或者是用户自定义的类。以下是一些例子:

  • FrenchDeck: 可能是一个自定义的纸牌类。
  • Vector2d: 可能表示二维向量的类。
  • Duck: 可能是一个表示鸭子的类。

这些类同样可以用于类型提示。

抽象基类

抽象基类(Abstract Base Classes, ABC)在类型提示中也很有用。它们定义了一组规范,子类需要实现这些规范。我们将在后续学习集合类型时更详细地讨论这些。

类的一致性

在Python中,类之间的一致性(consistent-with)类似于子类关系:一个子类与其所有的超类都是一致的。但是,有一些例外情况,接下来我们会具体讨论。

特殊情况:intcomplex 的一致性

在Python的内置类型中,int, float, 和 complex 并不是通过继承直接相关的,它们都是 object 的直接子类。然而,根据 PEP 484,它们之间有一种一致性的声明:

  • int 是与 float 一致的
  • float 是与 complex 一致的

在实践中,这种一致性是有意义的:int 实现了所有 float 所支持的操作,并且还支持一些额外的操作(如位操作:&, |, << 等)。因此:

  • int 是与 complex 一致的
举例说明
i = 3
print(i.real)  # 输出: 3
print(i.imag)  # 输出: 0

在这个例子中,整数 i 具有 realimag 属性,与复数的行为一致。

对比例子

为了更好地理解这种一致性,我们可以对比不同类型之间的关系:

# int 和 float 的一致性
def add(x: float, y: float) -> float:
    return x + y

result = add(3, 4.5)  # 3 是 int 类型,但可以用于 float 参数
print(result)  # 输出: 7.5

# 对比:str 和 int 的不一致性
def repeat_string(s: str, times: int) -> str:
    return s * times

# 如果传递一个 str 类型给 times 参数将导致错误
# repeat_string('hello', '3')  # TypeError: can't multiply sequence by non-int of type 'str'

Optional and Union Types

Optional 类型

OptionalUnion 的一种特殊形式,用于表示某个变量可以是某种类型或 None。例如:

from typing import Optional

def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:

在这个例子中,Optional[str] 实际上是 Union[str, None] 的简写方式,表示 plural 参数可以是 strNone

Python 3.10 中的改进语法

从 Python 3.10 开始,我们可以使用 | 运算符来简化 Union 的表示:

# 旧语法
plural: Optional[str] = None
# 新语法
plural: str | None = None

这种新语法减少了代码输入量,并且不再需要从 typing 模块导入 OptionalUnion

isinstance 和 issubclass 的支持

| 运算符也可以用于 isinstanceissubclass 函数:

isinstance(x, int | str)

这种用法在于检查一个对象是否是多个类型中的一个。

Union 类型的使用示例

以下是一个使用 Union 的简单示例,ord 函数接受 strbytes,返回一个 int

def ord(c: Union[str, bytes]) -> int: ...

还有一个示例函数 parse_token,它接受一个 str,返回 strfloat

from typing import Union

def parse_token(token: str) -> Union[str, float]:
    try:
        return float(token)
    except ValueError:
        return token

在这个例子中,函数可能返回不同的数据类型,调用者需要在运行时检查返回值的类型。

使用 Union 的注意事项

尽量避免创建返回 Union 类型的函数,因为它们会给用户增加额外的负担,要求用户在运行时检查返回值的类型。然而,在一些特定场景下,例如简单的表达式求值器中,这种用法是合理的。

返回类型根据输入类型而定的场景

在某些情况下,函数的返回类型取决于输入类型。例如,一个函数接受 strbytes,并且返回与输入类型一致的类型。在这种情况下,使用 Union 并不准确。可以使用类型变量(TypeVar)或函数重载(Overloading)来准确描述这种行为。

Union 的嵌套类型

Union 需要至少包含两个类型。嵌套的 Union 类型可以被简化为一个平坦的 Union。例如:

Union[A, B, Union[C, D, E]]

等价于:

Union[A, B, C, D, E]

一些冗余的 Union 用法

如果类型之间是相容的,那么使用 Union 是多余的。例如:

Union[int, float]

是冗余的,因为 int 可以在需要 float 的地方使用,只需使用 float 来注解即可。

Generic Collections

Python 中的集合通常是异构的,这意味着你可以在一个列表中混合放置不同类型的对象。然而,在实践中,为了便于对集合中的对象进行操作,通常希望这些对象至少共享一个通用的方法。这就引出了泛型类型的概念,它允许通过类型参数来声明集合中元素的类型。

泛型类型提示

在 Python 3.9 及以上版本中,可以使用 list[str] 这样的语法来对列表中的元素类型进行约束。例如:

def tokenize(text: str) -> list[str]:
    return text.upper().split()

这个例子中,函数 tokenize 返回一个字符串类型的列表。这意味着列表中的每个元素都是 str 类型。

兼容性支持

对于 Python 3.8 及更早版本,需要使用更多代码来实现类似的功能。以下是针对不同 Python 版本的解决方案:

  • Python 3.7 和 3.8
    使用 from __future__ import annotations 来启用 list[str] 这样的语法。

    from __future__ import annotations
    
    def tokenize(text: str) -> list[str]:
        return text.upper().split()
    
  • Python 3.5 及以上
    使用 typing 模块提供的泛型类型。

    from typing import List
    
    def tokenize(text: str) -> List[str]:
        return text.upper().split()
    
Python 版本更新对泛型的影响

PEP 585 提出了一系列改进,以提高泛型类型提示的可用性:

  1. Python 3.7:引入 from __future__ import annotations,以便使用标准库类作为泛型。
  2. Python 3.9:默认支持 list[str] 这样的语法,不再需要 future 导入。
  3. 弃用 typing 模块中的冗余泛型类型:这些类型不会在解释器中触发弃用警告,但类型检查器会在目标版本为 Python 3.9 或更高时标记这些类型。
  4. 五年后移除冗余泛型类型:可能在 Python 3.14 中移除。
其他集合类型的泛型支持

以下是标准库中支持泛型类型提示的集合:

  • list
  • collections.deque
  • abc.Sequence
  • abc.MutableSequence
  • set
  • abc.Container
  • abc.Set
  • abc.MutableSet
  • frozenset
  • abc.Collection

这些集合类型大多使用简单的 container[item] 形式的泛型类型提示。

复杂类型提示

元组和映射类型支持更复杂的类型提示。比如:

from typing import Tuple, Dict

def process_data(data: Tuple[int, str, float]) -> Dict[str, int]:
    # 示例逻辑
    return {data[1]: data[0]}

在这个例子中,process_data 接受一个包含 intstrfloat 的元组并返回一个字符串到整数的映射。

注意事项
  • 不同版本的 Python 对泛型类型提示的支持程度不同,使用时需注意兼容性。
  • 使用类型提示有助于代码的可读性和可维护性,并能够在代码编辑器中提供更好的自动补全和错误检查功能。

Tuple Types

1. 元组作为记录 (Tuples as Records)

当元组被用作记录时,可以使用内置的 tuple 类型,并在方括号 [] 中声明字段的类型。例如,类型注解 tuple[str, float, str] 表示一个元组,其中包含城市名、人口和国家,如 ('Shanghai', 24.28, 'China')

示例:
考虑一个函数,它接收一对地理坐标并返回一个 GeoHash。使用时如下:

shanghai = 31.2304, 121.4737
geohash(shanghai)  # 返回 'wtw3sjq6q'

函数定义:

from geolib import geohash as gh  # type: ignore
PRECISION = 9

def geohash(lat_lon: tuple[float, float]) -> str:
    return gh.encode(*lat_lon, PRECISION)

注意:

  • 参数 lat_lon 被注解为一个包含两个浮点数的元组。
  • 在 Python 版本小于 3.9 时,应使用 typing.Tuple 进行类型注解。

2. 带命名字段的元组 (Tuples as Records with Named Fields)

对于具有多个字段的元组或者在代码中多次使用的特定类型的元组,推荐使用 typing.NamedTuple。这种方式不仅提高了代码的可读性,还提供了更多功能。

示例:

from typing import NamedTuple
from geolib import geohash as gh  # type: ignore
PRECISION = 9

class Coordinate(NamedTuple):
    lat: float
    lon: float

def geohash(lat_lon: Coordinate) -> str:
    return gh.encode(*lat_lon, PRECISION)

说明:

  • Coordinate 类是 NamedTuple 的一个实例,可以被视为 tuple[float, float] 的扩展。
  • Coordinate 提供了额外的方法,例如 ._asdict(),并且可以定义用户自定义方法。

在实践中,传递 Coordinate 实例给以下函数是类型安全的:

def display(lat_lon: tuple[float, float]) -> str:
    lat, lon = lat_lon
    ns = 'N' if lat >= 0 else 'S'
    ew = 'E' if lon >= 0 else 'W'
    return f'{abs(lat):0.1f}°{ns}, {abs(lon):0.1f}°{ew}'

3. 作为不可变序列的元组 (Tuples as Immutable Sequences)

当需要注解长度不定的元组(用作不可变列表)时,需指定一个类型,后跟一个逗号和 ...(Python 的省略号标记)。

示例:

def columnize(sequence: Sequence[str], num_columns: int = 0) -> list[tuple[str, ...]]:
    if num_columns == 0:
        num_columns = round(len(sequence) ** 0.5)
    num_rows, reminder = divmod(len(sequence), num_columns)
    num_rows += bool(reminder)
    return [tuple(sequence[i::num_rows]) for i in range(num_rows)]

说明:

  • 类型 tuple[int, ...] 表示一个包含任意数量整数的元组。
  • stuff: tuple[Any, ...]stuff: tuple 意味着 stuff 是一个包含任意类型对象的元组,其长度未指定。

用例:

animals = 'drake fawn heron ibex koala lynx tahr xerus yak zapus'.split()
table = columnize(animals)
for row in table:
    print(''.join(f'{word:10}' for word in row))

输出:

drake     koala     yak       
fawn      lynx      zapus     
heron     tahr                
ibex      xerus               

总结:通过这三种不同的方式为元组添加类型注解,可以根据具体需求选择合适的方法,确保代码的类型安全性和可读性。

Generic Mappings

基本概念

  • 泛型映射类型在 Python 中可以使用 MappingType[KeyType, ValueType] 来注解。
  • 在 Python 3.9 及更高版本中,内置的 dict 和来自 collectionscollections.abc 的映射类型支持这种注解。
  • 在更早的版本中,需使用 typing.Dict 和其他类型注解,如 typing 模块中的类型。

示例:反向索引函数

我们来看一个实际例子:一个函数返回一个反向索引,用于通过名称搜索 Unicode 字符。这个函数的返回值是一个字典,键为单词,值为包含这些单词的字符集合。

示例代码 (charindex.py)
import sys
import re
import unicodedata
from collections.abc import Iterator

RE_WORD = re.compile(r'\w+')
STOP_CODE = sys.maxunicode + 1

def tokenize(text: str) -> Iterator[str]:
    """返回大写单词的可迭代对象"""
    for match in RE_WORD.finditer(text):
        yield match.group().upper()

def name_index(start: int = 32, end: int = STOP_CODE) -> dict[str, set[str]]:
    index: dict[str, set[str]] = {}
    for char in (chr(i) for i in range(start, end)):
        if name := unicodedata.name(char, ''):
            for word in tokenize(name):
                index.setdefault(word, set()).add(char)
    return index
使用示例

假设我们创建了一个索引,从 ASCII 字符 32 到 64:

>>> index = name_index(32, 65)
>>> index['SIGN']
{'$', '>', '=', '+', '<', '%', '#'}
>>> index['DIGIT']
{'8', '5', '6', '2', '3', '0', '1', '4', '7', '9'}
>>> index['DIGIT'] & index['EIGHT']
{'8'}

代码详解

  1. tokenize 函数:这是一个生成器函数,它使用正则表达式 RE_WORD 提取文本中的单词,并将其转换为大写。生成器函数在后续章节中详细讲解。

  2. 局部变量 index 的注解:我们使用类型注解 dict[str, set[str]] 来说明 index 是一个字典,键是字符串,值是字符串集合。这种注解有助于工具(如 Mypy)进行类型检查。

  3. 海象运算符 :=:在条件中使用海象运算符可以简化代码。if name := unicodedata.name(char, ''): 这行代码同时执行赋值和条件判断。若 name 为空字符串(false 值),则不会更新索引。

注意事项

  • 使用字典作为记录时,通常键为 str 类型,而值可能因键而异。可以使用 TypedDict 来更精确地定义这种字典结构(将在后续章节介绍)。

Abstract Base Classes

抽象基类与类型提示概述

在 Python 中,抽象基类(Abstract Base Classes,简称 ABC)提供了一种机制,用于定义类的共同接口。使用 ABC 可以让代码更具灵活性和可扩展性,特别在类型检测和提示中显得尤为重要。

Postel’s Law(鲁棒性原则):在发送信息时应该保守,而在接收信息时应该宽容。这个原则在编程中同样适用,尤其是在设计函数接口和类型提示时。

使用抽象基类进行类型提示

考虑以下函数签名:

from collections.abc import Mapping

def name2hex(name: str, color_map: Mapping[str, int]) -> str:
  • 在这里,color_map 的类型提示使用了 abc.Mapping。这意味着调用者可以传入 dictdefaultdictChainMapUserDict 子类或任何 Mapping 的子类型。

对比另一个函数签名:

def name2hex(name: str, color_map: dict[str, int]) -> str:
  • 在这种情况下,color_map 必须是 dict 或其子类型,但不能是 collections.UserDict 的子类,因为 UserDictdict 是兄弟关系,都是 abc.MutableMapping 的子类。

函数返回值的类型提示

Postel’s Law 提到的保守原则应用于函数返回值:返回值的类型提示应该是具体类型。这是因为函数返回的总是一个具体的对象。例如:

def tokenize(text: str) -> list[str]:
    return text.upper().split()
  • 这里返回值类型是 list[str],一个具体类型。

使用抽象集合类型进行参数类型提示

Python 文档建议在参数类型提示中使用抽象集合类型,如 SequenceIterable,而不是具体类型 list。这使得代码更具灵活性。例如:

from collections.abc import Sequence

def process_items(items: Sequence[int]) -> None:
    for item in items:
        print(item)

数值类型的选择

在数值类型提示方面,Python 提供了以下选择:

  1. 使用具体类型 intfloatcomplex,如 PEP 488 推荐。
  2. 使用联合类型,如 Union[float, Decimal, Fraction]
  3. 使用数值协议(numeric protocols),如 SupportsFloat,避免硬编码具体类型。

结论与补充示例

在进行类型提示时,选择合适的抽象基类可以提高代码的灵活性和兼容性。以下是一个补充示例,说明如何使用 Iterable 来进行类型提示:

from collections.abc import Iterable

def print_items(items: Iterable[str]) -> None:
    for item in items:
        print(item)

# 使用不同的可迭代对象调用
print_items(['apple', 'banana', 'cherry'])
print_items({'a', 'b', 'c'})
  • 在这个例子中,items 可以是列表、集合、甚至是生成器,只要它是 Iterable 的子类即可。

Iterable

Iterable 概念

Iterable 是指可以被迭代的对象。任何实现了 __iter__()__getitem__() 方法的对象都可以被认为是可迭代的。这意味着我们可以使用 for 循环遍历它。Iterable 通常用于函数参数的类型提示中。

示例:math.fsum 函数
def fsum(__seq: Iterable[float]) -> float:
    ...
  • __seq 参数采用 Iterable[float] 类型提示,表示这个参数是一个可迭代的浮点数集合。
  • __seq 中的双下划线是 PEP 484 的惯例,表示仅限位置的参数。
示例:zip_replace 函数
from collections.abc import Iterable

FromTo = tuple[str, str]

def zip_replace(text: str, changes: Iterable[FromTo]) -> str:
    for from_, to in changes:
        text = text.replace(from_, to)
    return text
  • changes 参数被定义为 Iterable[FromTo],即可迭代的包含 (str, str) 元组的集合。
  • 函数通过遍历 changes 来替换 text 中的字符。
使用示例:
>>> l33t = [('a', '4'), ('e', '3'), ('i', '1'), ('o', '0')]
>>> text = 'mad skilled noob powned leet'
>>> zip_replace(text, l33t)
'm4d sk1ll3d n00b p0wn3d l33t'

使用 TypeAlias

在 Python 3.10 中,引入了 TypeAlias 来增强类型别名的可读性和可检查性:

from typing import TypeAlias

FromTo: TypeAlias = tuple[str, str]

Iterable vs. Sequence

共同点
  1. 可迭代性IterableSequence 都是可迭代的,这意味着你可以使用 for 循环来遍历它们的元素。

  2. 接口基础Sequence 继承自 Iterable,因此所有的 Sequence 对象也是 Iterable。这意味着所有 Sequence 都可以被迭代。

不同点
  1. 接口定义

    • Iterable 是一个更基础的接口,表示任何可以返回迭代器的对象。它只需要实现 __iter__() 方法。
    • Sequence 是一个更具体的接口,除了实现 __iter__() 方法之外,还需要实现 __getitem__()__len__() 方法。这意味着 Sequence 不仅可迭代,还支持索引访问和长度查询。
  2. 适用场景

    • Iterable: 适用于只需要遍历数据而不关心其长度和索引访问的场景。常用于数据流、生成器等懒加载的数据结构。
    • Sequence: 适用于需要随机访问、获取长度以及需要进行索引操作的场景。常用于列表、元组等固定大小的数据结构。
  3. 性能和内存

    • Iterable: 通常可以实现懒加载,意味着只有在需要时才会生成数据。这对于大型数据集是一个优势,因为它可以减少内存使用。
    • Sequence: 通常是预先加载的,意味着所有数据在访问之前已经加载到内存中。这虽然增加了内存使用,但提供了更快的访问速度。
细节和使用示例
  • Iterable 的实现细节

    • 只需要实现 __iter__() 方法,该方法返回一个迭代器对象。迭代器对象需要实现 __next__() 方法。
    class MyIterable:
        def __init__(self, data):
            self.data = data
    
        def __iter__(self):
            return iter(self.data)
    
    my_iterable = MyIterable([1, 2, 3])
    for item in my_iterable:
        print(item)  # 输出:1 2 3
    
  • Sequence 的实现细节

    • 除了 __iter__(),还需要实现 __getitem__()__len__(),以支持索引访问和长度获取。
    class MySequence:
        def __init__(self, data):
            self.data = data
    
        def __getitem__(self, index):
            return self.data[index]
    
        def __len__(self):
            return len(self.data)
    
    my_sequence = MySequence([1, 2, 3])
    print(len(my_sequence))  # 输出:3
    print(my_sequence[1])    # 输出:2
    

总结

  • 使用 Iterable 时,关注的是数据的流动性和生成方式,适合处理不确定大小或动态生成的数据。
  • 使用 Sequence 时,关注的是数据的可访问性和固定性,适合处理需要频繁访问和修改的静态数据。

Parameterized Generics and TypeVar

概念概述

在 Python 的类型提示中,Parameterized Generics(参数化泛型)TypeVar为类型的灵活性和准确性提供了强有力的支持。通过这些工具,我们可以在函数的参数和返回值中使用类型变量,使得这些类型能够在不同使用场景中绑定到特定的实际类型。

类型变量 TypeVar 是通过 typing 模块中的 TypeVar 构造函数引入的,它允许我们定义一个可以在函数中任意变化的类型。这个类型变量随后可以在函数参数和返回值中统一使用。

例子解析

考虑以下 sample 函数,它接收一个 Sequence 类型的参数和一个整数,并返回一个元素类型相同的列表。

from collections.abc import Sequence
from random import shuffle
from typing import TypeVar

T = TypeVar('T')

def sample(population: Sequence[T], size: int) -> list[T]:
    if size < 1:
        raise ValueError('size must be >= 1')
    result = list(population)
    shuffle(result)
    return result[:size]
  • 解释: 这个函数使用了 TypeVar,使得 sample 可以处理任意类型的 Sequence。例如:
    • 如果传入一个 tuple[int, ...],返回值类型为 list[int]
    • 如果传入一个 str,返回值类型为 list[str]

为什么需要 TypeVar?

在 Python 的类型提示中,TypeVar 是必需的,因为 Python 选择通过引入 typing 模块来支持类型提示,而不是更改语言本身的语法。TypeVar 在当前命名空间中引入了类型变量的名称。

使用 TypeVar 的例子

我们通过一个例子来展示如何使用 TypeVar 提升函数的灵活性。考虑 statistics.mode 函数,它返回序列中最常见的数据点:

from collections import Counter
from collections.abc import Iterable
from typing import TypeVar

T = TypeVar('T')

def mode(data: Iterable[T]) -> T:
    pairs = Counter(data).most_common(1)
    if len(pairs) == 0:
        raise ValueError('no mode for empty data')
    return pairs[0][0]
  • 解释: 这里 T 可以是任何类型,因此 mode 可以适用于不同类型的可迭代对象。

限制 TypeVar 的类型

为了限制 TypeVar 可以被绑定的类型,我们可以使用额外的参数。例如:

from collections.abc import Iterable
from decimal import Decimal
from fractions import Fraction
from typing import TypeVar

NumberT = TypeVar('NumberT', float, Decimal, Fraction)

def mode(data: Iterable[NumberT]) -> NumberT:
    ...
  • 解释: 现在 NumberT 只能是 floatDecimalFraction 类型。

有界 TypeVar

有时候,我们需要限制 TypeVar 的类型范围,使其必须是某个父类的子类。这可以通过 bound 参数实现:

from collections import Counter
from collections.abc import Iterable, Hashable
from typing import TypeVar

HashableT = TypeVar('HashableT', bound=Hashable)

def mode(data: Iterable[HashableT]) -> HashableT:
    pairs = Counter(data).most_common(1)
    if len(pairs) == 0:
        raise ValueError('no mode for empty data')
    return pairs[0][0]
  • 解释: 这里 HashableT 必须是 Hashable 或其子类的实例。

预定义的类型变量 AnyStr

typing 模块中定义了一个预定义的类型变量 AnyStr,用于处理 bytesstr 类型的数据:

from typing import AnyStr

AnyStr = TypeVar('AnyStr', bytes, str)
  • 应用: 常用于接受 bytesstr 类型的函数中。

疑问

从类型变量定义的技术角度来看,AnyStr = TypeVar('AnyStr', bytes, str)NumberT = TypeVar('NumberT', bytes, str) 确实是等价的:它们都创建了一个可以是 bytesstr 类型的类型变量。在函数或类的使用中,这两个类型变量在类型约束上没有区别。

但是,重要的区别在于:

  1. 语义和可读性

    • 使用 AnyStr 是一种惯例,它立即让熟悉 Python 类型系统的开发者知道,这个类型变量是用来处理字符串和字节串的。
    • 使用 NumberT 可能会让人误解,因为通常以 Number 开头的类型变量会让人联想到数值类型(如 intfloat),而不是 bytesstr
  2. 惯用法和约定

    • AnyStr 是 Python typing 模块的惯用术语,广泛用于需要同时处理文本字符串和字节字符串的场景。
    • NumberT 作为名称没有这样的惯例支持,可能会让团队中的其他开发者感到困惑。

因此,尽管在类型约束上它们的功能是相同的,但选择合适的名称有助于代码的可读性和维护性。遵循惯例和约定能帮助团队成员更快理解代码意图,并减少误解和错误。

Static Protocols

协议的基本概念

协议类型在PEP 544中被描述为结构化子类型(static duck typing),类似于Go语言中的接口。协议类型是通过指定一个或多个方法来定义的,类型检查器会验证在需要该协议类型的地方是否实现了这些方法。

Python中的协议定义

在Python中,协议定义为一个typing.Protocol的子类。然而,实现协议的类不需要继承、注册或声明与定义协议的类有任何关系。类型检查器负责找到可用的协议类型并强制其使用。

示例:top函数

假设我们希望创建一个函数top(it, n),它返回可迭代对象it中最大的n个元素:

>>> top([4, 1, 5, 2, 6, 7, 3], 3)
[7, 6, 5]
>>> l = 'mango pear apple kiwi banana'.split()
>>> top(l, 3)
['pear', 'mango', 'kiwi']

一个参数化的泛型top函数定义如下:

from collections.abc import Iterable
from typing import TypeVar

T = TypeVar('T')

def top(series: Iterable[T], length: int) -> list[T]:
    ordered = sorted(series, reverse=True)
    return ordered[:length]

以下是对原文的重新书写笔记,旨在更清晰地解释如何约束类型参数 T 并提供一些额外的例子说明难点和易错点。

问题:如何约束类型参数 T

top 函数中,我们需要对 T 类型参数进行约束,以确保它能够与 sorted 函数一起正常工作。sorted 函数实际上接受 Iterable[Any],因为它的可选参数 key 可以接受一个函数,该函数从每个元素计算一个任意的排序键。

如果不提供 key 参数并给 sorted 一个普通对象的列表,会出现什么问题?

>>> l = [object() for _ in range(4)]
>>> sorted(l)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'object' and 'object'

如错误信息所示,sorted 使用 '<’ 运算符对可迭代对象的元素进行排序。因此,T 类型参数应该被限制为实现了 __lt__ 方法的类型。

解决方案:定义支持 < 运算符的协议

由于 typingabc 中没有合适的类型可用,我们需要创建一个支持小于运算符的协议类型:

from typing import Protocol, Any

class SupportsLessThan(Protocol):
    def __lt__(self, other: Any) -> bool: ...

这个协议定义了 __lt__ 方法,表明任何实现了该方法的类型都可以被认为与该协议一致。

... 在 Python 中被称为省略号(ellipsis),通常用三个连续的点号表示。在这个上下文中,... 被用作一个占位符,表示这个方法不需要在协议类(Protocol)中具体实现。

SupportsLessThan 协议中,__lt__ 方法是一个抽象方法,意味着这个协议定义了一个结构,任何实现这个协议的类都必须提供自己的 __lt__ 方法实现。协议本身不需要给出这个方法的实现细节,只需要声明其存在即可。

使用 Protocol 的好处是它允许你定义一个“接口”,即一组必须被实现的方法,而不需要提供具体的实现。这对于类型检查和静态分析非常有用,因为它可以帮助确保某些类具有某些行为。

所以,在 SupportsLessThan 协议中,使用 ... 表示 __lt__ 方法需要在实现此协议的具体类中定义,而不是在协议定义中实现。

使用协议的 top 函数

我们可以使用协议来定义一个更具约束力的 top 函数:

from collections.abc import Iterable
from typing import TypeVar
from comparable import SupportsLessThan

LT = TypeVar('LT', bound=SupportsLessThan)

def top(series: Iterable[LT], length: int) -> list[LT]:
    ordered = sorted(series, reverse=True)
    return ordered[:length]

在这个版本的 top 函数中,LT 类型变量被绑定到 SupportsLessThan 协议,这意味着 series 中的元素必须支持小于运算符。

测试用例

我们可以通过测试套件来验证 top 函数的行为:

import pytest
from top import top

def test_top_tuples() -> None:
    fruit = 'mango pear apple kiwi banana'.split()
    series = ((len(s), s) for s in fruit)
    length = 3
    expected = [(6, 'banana'), (5, 'mango'), (5, 'apple')]
    result = top(series, length)
    assert result == expected

def test_top_objects_error() -> None:
    series = [object() for _ in range(4)]
    with pytest.raises(TypeError) as excinfo:
        top(series, 3)
    assert "'<' not supported" in str(excinfo.value)

这些测试确保了 top 函数在处理支持 < 运算符的类型时正常工作,而在不支持的情况下抛出错误。

说白了,不用这玩意,静态类型检查不出来,对整体结果没影响。

难点解析

  1. 为何不能直接使用 Anyobject

    • Anyobject 没有实现 __lt__ 方法,因此不能用于需要排序的场景。
  2. 协议的优势

    • 通过协议,我们可以在不修改现有类的情况下,约束类型参数。这种方式也称为静态鸭子类型化,它允许我们显式地告诉类型检查器某个类型只要实现了某些方法就可以被接受。

通过协议类型,我们可以在不更改现有类定义的前提下,灵活地约束类型参数,而这种方式被称为“静态鸭子类型化”。

Callable

Callable 类型详解

在 Python 中,Callable 类型用于表示可以像函数一样被调用的对象。这在定义回调函数和高阶函数中返回的可调用对象时尤为有用。Callable 可以从 collections.abc 模块导入,而对于尚未使用 Python 3.9 的情况,可以从 typing 模块导入。

Callable 的基础语法

Callable 的基本格式如下:

Callable[[ParamType1, ParamType2, ...], ReturnType]
  • 参数列表:这里的 [ParamType1, ParamType2, ...] 表示函数接受的参数类型,可以有零个或多个参数。
  • 返回类型ReturnType 表示函数调用后返回的值的类型。
示例解读

考虑一个简单的交互式解释器函数 repl,它可以使用不同的输入函数:

from typing import Callable, Any

def repl(input_fn: Callable[[Any], str] = input) -> None:
    pass
  • repl 函数默认使用 Python 的内置 input 函数来读取用户输入。
  • input_fn 是一个可选参数,类型为 Callable[[Any], str],与 input 函数的签名一致。

typeshed 中,input 的签名为:

def input(__prompt: Any = ...) -> str: ...

这与 Callable[[Any], str] 的类型提示相匹配。

处理灵活签名的 Callable

如果需要定义一个带有灵活签名的可调用对象,可以使用省略号 ... 替代具体的参数列表:

Callable[..., ReturnType]

这表示该可调用对象可以接受任意数量和类型的参数。

Callable 的变体(Variance)

在泛型编程中,变体描述了类型参数在继承体系中的关系。以下示例展示了 Callable 的协变和逆变特性:

from collections.abc import Callable

def update(
    probe: Callable[[], float],
    display: Callable[[float], None]
) -> None:
    temperature = probe()
    display(temperature)

def probe_ok() -> int:
    return 42

def display_wrong(temperature: int) -> None:
    print(hex(temperature))

update(probe_ok, display_wrong)  # 类型错误

def display_ok(temperature: complex) -> None:
    print(temperature)

update(probe_ok, display_ok)  # 正常
  • probe 可调用对象:不接受参数,返回 float 类型。probe_ok 返回 int,可兼容 float,因此是可接受的(协变性)。
  • display 可调用对象:接受 float 参数,返回 Nonedisplay_wrong 只接受 int,因而不兼容(逆变性)。而 display_ok 可以接受 complex,因此是可接受的,因为 complex 可以处理 float
协变与逆变总结
  • 协变(Covariant):返回类型允许使用子类型。例如,Callable[[], int]Callable[[], float] 的子类型,因为 intfloat 的子类型。
  • 逆变(Contravariant):参数类型必须是父类型。例如,Callable[[float], None]Callable[[int], None] 的子类型。

大多数的参数化泛型类型是**不变(invariant)**的,这意味着它们必须严格匹配定义的类型。例如:

scores: list[float]
  • list[int] 不可用于 scores,因为不能存储 float
  • list[complex] 也不可用于 scores,因为 complex 类型没有实现比较操作,无法排序。

结论

理解 Callable 类型和泛型变体是开发健壮和灵活 API 的关键。通过正确使用类型提示,可以减少错误并提高代码的可维护性和清晰度。

NoReturn

概念解析:NoReturn

NoReturn 是 Python 类型提示中一个特殊的类型,用于标注那些永远不返回值的函数。这类函数通常会引发异常来终止程序的执行。例如,Python 标准库中有很多这样的函数。

示例:sys.exit()

sys.exit() 是一个常见的例子,它通过引发 SystemExit 异常来终止 Python 进程。在 typeshed 中,它的函数签名定义为:

def exit(__status: object = ...) -> NoReturn: ...
  • __status 参数是一个仅限位置的参数,并且有一个默认值。类型定义中使用 ... 来表示默认值,而不具体写出。
  • __status 的类型是 object,这意味着它可以是任何类型,包括 None。因此,不需要标注为 Optional[object],因为 object 本身已经涵盖了所有可能的类型。

实际例子:抛出异常

在实际开发中,我们可能会定义一些函数专门用于抛出异常并终止程序执行,例如:

def terminate_with_error(message: str) -> NoReturn:
    raise RuntimeError(message)

# 使用示例
try:
    terminate_with_error("Unexpected error occurred")
except RuntimeError as e:
    print(e)

上述函数 terminate_with_error 就是一个不返回的函数,因为它总是抛出 RuntimeError 异常。

4、Annotating Positional Only and Variadic Parameters

1. 函数签名回顾

我们来看一个函数签名的例子,这个函数名为 tag,它的定义如下:

def tag(name, /, *content, class_=None, **attrs):

解释:

  • name: 这是一个位置参数,用于接收标签名。
  • /: 斜杠表示在它之前的参数是仅限位置参数。这在 Python 3.8 及以上版本中可用。
  • *content: 可变位置参数,接收多个字符串。
  • class_: 这是一个命名关键字参数,默认值为 None
  • **attrs: 可变关键字参数,用于接收任意数量的其他属性。

2. 完整的函数注释

现在,我们为这个函数添加类型注释,并以更易读的方式分行显示:

from typing import Optional

def tag(
    name: str,
    /,
    *content: str,
    class_: Optional[str] = None,
    **attrs: str,
) -> str:
    ...

类型注释解释:

  • name: str: name 参数必须是字符串类型。
  • *content: str: 所有可变位置参数必须是字符串类型。在函数内部,content 将是 tuple[str, ...] 类型。
  • class_: Optional[str]: class_ 参数是可选的,可以是字符串类型或 None
  • **attrs: str: 所有可变关键字参数的值必须是字符串类型,在函数内部,attrs 将是 dict[str, str] 类型。

3. 可变参数类型的灵活性

如果需要 **attrs 接受不同类型的值,可以使用 UnionAny,例如:

from typing import Any

def tag(
    name: str,
    /,
    *content: str,
    class_: Optional[str] = None,
    **attrs: Any,
) -> str:
    ...

在这种情况下,attrs 的类型将是 dict[str, Any]

4. 位置参数的兼容性

在 Python 3.7 及更早版本中,/ 语法不可用。PEP 484 提供了一种兼容的方式,即在仅限位置参数名前加上两个下划线:

from typing import Optional

def tag(
    __name: str,
    *content: str,
    class_: Optional[str] = None,
    **attrs: str,
) -> str:
    ...

5. 静态类型系统的局限性

尽管类型注释可以帮助我们理解和维护代码,但静态类型检查并不能捕获所有错误。类型注释主要是为了提高代码的可读性和减少运行时错误,而不是替代动态类型检查。

以上是关于如何为仅限位置和可变参数添加类型注释的详细讲解,希望这个学习笔记可以帮助你更好地理解和运用这些概念。

5、Imperfect Typing and Strong Testing

1. 类型检查与自动化测试的对比

1.1 类型检查的优缺点

  • 优点

    • 能够在编译或静态分析阶段发现一些明显的类型错误。
    • 在大型企业代码库中,类型检查器可以提前捕捉错误,降低修复成本。
  • 缺点

    • 存在误报(False Positives):即工具报告的类型错误实际上是正确的代码。
    • 存在漏报(False Negatives):即工具未能报告的错误实际上是有问题的代码。
    • 一些 Python 的高级特性(如属性、描述符、元类、元编程)对类型检查器的支持有限。
    • 类型检查器常常落后于 Python 新版本的发布,导致对新特性支持不佳。

1.2 自动化测试的优缺点

  • 优点

    • 能够覆盖业务逻辑层面的错误,特别是复杂的条件判断和边界情况。
    • 不依赖于类型信息,即使是在动态特性丰富的语言中也能有效工作。
  • 缺点

    • 编写和维护测试代码需要额外的时间和资源。
    • 测试的覆盖率和深度取决于开发者的经验和测试策略。

2. 类型提示的局限性

类型提示在 Python 中的使用越来越广泛,但它并不能表达所有的数据约束。例如:

  • 无法确保“数量必须是大于 0 的整数”。
  • 无法确保“标签必须是 6 到 12 个 ASCII 字母的字符串”。

因此,类型提示不能是软件质量的唯一保障。

2.1 实际例子

以下是对比类型提示与自动化测试的例子:

# 类型提示的误报例子
def add(x: int, y: int) -> int:
    return x + y

# 实际上,add 函数可以接收任何支持加法运算的对象,例如浮点数或自定义的数值类
result = add(3.5, 4.5)  # 静态类型检查器可能会报错,但在运行时是可以正常工作的

# 自动化测试来确保功能正确性
def test_add():
    assert add(1, 2) == 3
    assert add(3.5, 4.5) == 8.0  # 通过测试确保代码逻辑正确

3. 在持续集成中的角色

在持续集成(CI)管道中,类型检查器应作为工具之一,与测试运行器、代码风格检查器(linters)等共同工作。其目的是减少软件故障,并通过多种方式捕捉错误。

4. 结论

正如 Bruce Eckel 所言,“如果一个 Python 程序有足够的单元测试,它可以像 C++、Java 或 C# 程序一样健壮”。类型提示可以提升代码的可读性和维护性,但不能替代全面的测试策略。

在后续的学习中,我们将深入探讨类型提示在泛型类、变异、重载签名和类型转换中的应用。同时,在本书的多个例子中,类型提示将继续出现,帮助我们更好地理解 Python 的动态特性与静态分析工具的结合。

6、Chapter Summary

本章首先简要介绍了渐进式类型概念,然后开始实践。不借助读取类 型提示的工具很难理解渐进式类型,因此我们在 Mypy 错误报告的指 引之下,为一个函数添加了注解。

接着,我们又回到渐进式类型概念上,指出这其实是一种混合概念, 综合了 Python 传统的鸭子类型和 Java、C++ 等静态类型语言的名义 类型。

本章大部分篇幅分门别类介绍了注解可用的主要类型。本章讲到的很 多类型与我们熟悉的 Python 对象类型(例如容器、元组和可调用对 象)有关,不过也延伸到了泛型表示法(例如 Sequence[float])。这些类型中有很多是在 typing 模块中临时 实现的,因为直到 Python 3.9 改造标准类型之后才支持泛化。

有些类型是特殊的实体。Any、Optional、Union 和 NoReturn 不 关联内存中的实际对象,只存在于类型系统的抽象层面上。

我们研究了参数化泛型和类型变量,为类型提示提供了更大的灵活 性,而且不失类型安全性。

引入 Protocol 后,参数化泛型更具表现力。Protocol 在 Python 3.8 中才出现,还未大范围使用,但是重要性不容忽视。Protocol 使得静态鸭子类型成为可能。静态鸭子类型是 Python 内在的鸭子类型和名义类型之间的重要桥梁,令静态类型检查工具能捕获更多的 bug。

介绍一些类型时,我们使用 Mypy 做试验,利用 Mypy 提供的魔法函 数 reveal_type() 观察类型检查错误和推导的类型。

最后又介绍了如何注解仅限位置参数和变长参数。 类型提示是一个复杂的话题,还在不断发展中。幸运的是,这是可选 功能,因此 Python 广泛的用户群体不受影响。请不要听信类型布道 者的话,认为所有 Python 代码都需要类型提示。

Logo

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。

更多推荐