Python 中的 Return Self 到底是个啥?

Python
184
0
0
2024-05-20
标签   Python基础

题目中的 return self 并不是我们常见的 self 参数,而本文的首要任务是需要了解什么是类型提示以及它们如何工作。类型提示我们可以显式地指明变量类型、函数参数和返回值。这可以使代码更具可读性和可维护性,尤其是当代码的规模和复杂性不断增加时。

我们可以使用冒号(:)指定变量和函数参数类型,然后是数据类型,而返回值注释则使用破折号(->),然后是返回类型。

举例说明,我们可以编写一个函数,输入我们购买的馅饼数量和每个馅饼的价格,然后输出一个总结我们的交易的字符串:

>>> def buy_pies(num_pies: int, price_per_pie: float) -> str:
...     total_cost = num_pies * price_per_pie
...     return f"Yum! You spent ${total_cost} dollars on {num_pies} pies!"
...

buy_pies() 中,num_pies 变量使用 int 类型,price_per_pie 使用 float 类型。因为返回值是字符串,所以用 str 类型注释返回值。

注意:可以将局部变量 total_cost 类型提示为 total_cost: float。虽然这看起来不错,但是类型检查器可以自动从 num_piesprice_per_pie 中推断出 total_cost 的类型,因此 total_cost 不需要进行类型注释。

Python 中的类型和注释通常不会影响代码的功能,但是许多静态类型检查器和 IDE 可以识别它们。例如,如果你在 VS Code 中悬停在 buy_pies() 上,那么你可以看到每个参数或返回值的类型:

在处理类时,我们还可以使用注释。这可以帮助其他开发人员了解方法的返回类型,在处理复杂的类层次结构时尤其有用。甚至可以对返回类实例的方法进行注释

类型和注释可以用来注释返回类实例的方法。这对于 class 方法特别有用,可以防止在处理继承和方法链时出现的混乱。

不幸的是,对这些方法进行注释可能会造成混淆并导致意外错误。注释此类方法的一种自然方法是使用类名,但下面的方法行不通:

# incorrect_self_type.py

from typing import Any

class Queue:
    def __init__(self):
        self.items: list[Any] = []

    def enqueue(self, item: Any) -> Queue:
        self.items.append(item)
        return self

在上面的示例中,Queue 中的 .enqueue() 将一个项目添加到队列中并返回类实例。用类名注释 .enqueue() 看起来很好,但这会导致静态类型检查和运行时错误。大多数静态类型检查器都会监测到在使用Queue前没有定义,如果运行该代码,则会抛出以下NameError异常:

Traceback (most recent call last):
  ...
NameError: name 'Queue' is not defined

从类 Queue 继承时也会出现问题。特别像 .enqueue() 这样的方法将返回 Queue,即使你在 Queue 的子类上调用它。

好消息!Python 的 Self 类型可以处理这些情况,它提供了一个非常出彩的注释,处理了注释返回外层类实例的方法的微妙之处

本文中,云朵君将和大家一起学习 Self 类型,并学习如何使用它来编写可读性和可维护性更强的代码。特别将学习如何用 Self 类型注释方法,并确保 IDE 能够识别它。我们还将研究注释返回类实例的方法的其他策略,并探讨为什么 Self 类型是第一选择。

如何在Python中使用Self类型来注释方法

Self 类型语法直观和简洁,成为注释返回类实例的首选方法。在 3.11 及以后的版本中,Self 类型可以直接从 Python 的类型模块中导入。对于小于 3.11 的 Python 版本,Self 类型可以在 typing_extensions 中使用。

例如,我们将注释一个堆栈数据结构。请特别注意 .push() 注释:

 # stack.py
 
 from typing import Any, Self
 
 class Stack:
     def __init__(self) -> None:
         self.items: list[Any] = []
 
     def push(self, item: Any) -> Self:
        self.items.append(item)
        return self

    def pop(self) -> Any:
        if self.__bool__():
            return self.items.pop()
        else:
            raise ValueError("Stack is empty")

    def __bool__(self) -> bool:
        return len(self.items) > 0

在第3行中从类型中导入Self类型,并在第9行中用 -> Self注释.push()。这将告诉静态类型检查器 .push() 返回一个 Stack 实例,从可以将多个 push 串联起来。注意,通常没有必要注释selfcls参数。

注意: 我们实现了 .__bool__() 来检查堆栈是否为空。这个方法是 Python 数据模型的一部分,被称为 dunder 或特殊方法。在这种情况下,定义 .__bool__() 从类内部或外部调用 bool() 内置函数来检查堆栈是否为空。

.__bool__()的加入使得该类可以在 Pythonic 条件句中使用,例如 if not stack:...,因为 if 语句中的表达式在内部使用 bool() 进行评估。这构成了确定布尔值是True还是False的基础。

对于小于 3.11 的 Python 版本,可以使用 typing_extensions 模块来导入 Self 类型,其余的代码可以保持不变:

# stack.py

from typing import Any
from typing_extensions import Self

# ...

通过从 typing_extensions 导入 Self,你可以像在 Python 3.11 中使用类型模块一样使用 Self 来注释方法。

注意: typing_extensions 是使用 pip 安装的第三方库。因为 typing 是标准库的一部分,它只能在 Python 本身的定期版本中更新,而 typing_extensions 是将新特性反向移植到旧 Python 版本中。

.push()items追加到堆栈并返回更新的堆栈实例,因此需要使用 Self 注释。这样,我们就可以按顺序将 .push() 方法链入堆栈实例,从而使代码更加简洁易读:

>>> from stack import Stack
>>> stack = Stack()
>>> stack.push(1).push(2).push(3).pop()
3
>>> stack.items
[1, 2]

在上面的示例中,我们实例化了一个 Stack 实例,将三个元素依次推入堆栈,并弹出一个元素。通过包含 Self 作为注释,我们可以直接从实例化对象检查 .push(),查看它返回什么:

VS代码识别.push()的返回类型

当在 VS 代码中将鼠标悬停在.push()上时,可以看到返回类型是 Stack,正如注释所指出的那样。有了这个注释,其他人阅读我们的代码时就不必查看堆栈定义就能知道.push()返回的是类实例。

接下来,我们将看到一个表示银行账户状态和逻辑的类。BankAccount类支持多种操作,如存入和取出资金,这些操作更新账户状态并返回类实例。例如,.deposit()将美元金额作为输入,增加账户的内部余额,并返回实例,这样我们就可以链入其他方法:

# accounts.py

from dataclasses import dataclass
from typing import Self

@dataclass
class BankAccount:
    account_number: int
    balance: float

    def display_balance(self) -> Self:
        print(f"Account Number: {self.account_number}")
        print(f"Balance: ${self.balance:,.2f}\n")
        return self

    def deposit(self, amount: float) -> Self:
        self.balance += amount
        return self

    def withdraw(self, amount: float) -> Self:
        if self.balance >= amount:
            self.balance -= amount
        else:
            print("Insufficient balance")
        return self

在 BankAccount 中,我们可以用Self注释.display_balance().deposit().withdraw(),以返回该类的实例。我们可以将BankAccount实例化,并存入或取出任意次数的资金:

>>> from accounts import BankAccount
>>> account = BankAccount(account_number=1534899324, balance=50)
>>> (
...     account.display_balance()
...     .deposit(50)
...     .display_balance()
...     .withdraw(30)
...     .display_balance()
... )
Account Number: 1534899324
Balance: $50.00

Account Number: 1534899324
Balance: $100.00

Account Number: 1534899324
Balance: $70.00

BankAccount(account_number=1534899324, balance=70)

在这里,我们定义了一个具有帐号和初始余额的BankAccount实例。然后,我们用多个方法链执行存款、取款和余额显示,每个方法都返回 SelfREPL将自动打印方法链中最后一个表达式 .display_balance() 的返回值。其输出 BankAccount(account_number=1534899324, balance=50),为该类提供了一个很好的表示。

注意:BankAccount是一个数据类。数据类是定义类的一种很好的方法,它们具有许多有用的特性。因为BankAccount是一个数据类,所以你不需要定义构造函数,并且该类可以通过默认的.__repr__()方法得到一个很好的字符串表示。

Self类型的其他用例包括类方法和继承层次结构。例如,如果父类和子类都有返回 Self 的方法,那么我们可以用 Self 类型来注释这两个方法。

有趣的是,当子类对象调用返回自身的父类方法时,类型检查器将指示该方法返回子类的实例。我们可以通过创建一个继承自BankAccountSavingsAccount类来了解这一思想:

# accounts.py

import random
from dataclasses import dataclass
from typing import Self

# ...

@dataclass
class SavingsAccount(BankAccount):
    interest_rate: float

    @classmethod
    def from_application(
        cls, deposit: float = 0, interest_rate: float = 1
    ) -> Self:
        # Generate a random seven-digit bank account number
        account_number = random.randint(1000000, 9999999)
        return cls(account_number, deposit, interest_rate)

    def calculate_interest(self) -> float:
        return self.balance * self.interest_rate / 100

    def add_interest(self) -> Self:
        self.deposit(self.calculate_interest())
        return self

SavingsAccount有一个.from_application()方法,该方法通过申请人参数创建类实例,而不是通过常规的构造函数。它还有.add_interest()方法,用于将利息存入账户余额并返回类实例。我们可以使用Self类型提高这两个方法的可读性和可维护性。

接下来,从一个新的应用程序中创建一个SavingsAccount对象,进行存款和取款,并增加利息:

>>> from accounts import SavingsAccount
>>> savings = SavingsAccount.from_application(deposit=100, interest_rate=5)
>>> (
...     savings.display_balance()
...     .add_interest()
...     .display_balance()
...     .deposit(50)
...     .display_balance()
...     .withdraw(30)
...     .add_interest()
...     .display_balance()
... )
Account Number: 3631051
Balance: $100.00

Account Number: 3631051
Balance: $105.00

Account Number: 3631051
Balance: $155.00

Account Number: 3631051
Balance: $131.25

SavingsAccount(account_number=3631051, balance=131.25, interest_rate=5)

VS Code识别.from_application()返回一个SavingsAccount的实例:

VS代码识别.from_application()的返回类型

当你悬停在.from_application()上时,类型检查器显示返回类型是SavingsAccount。VS Code也识别出.deposit()的返回类型是SavingsAccount,尽管这个方法是在BankAccount父类中定义的:

VS代码识别继承方法的返回类型

总的来说,Self 类型是一个直观和 Pythonic 的选择,用于注释返回 Self 的方法,或者更广泛地说,返回类实例的方法。静态类型检查器可以识别 Self,你也可以导入这个符号,这样运行代码就不会导致名称错误。

在接下来的章节中,我们将探索 Self 类型的替代方法并查看它们的实现。Self 是一种相当新的类型,在添加 Self 之前已经存在几种替代方法。我们在阅读旧代码时可能会遇到这些其他注释,因此了解它们如何工作以及它们的局限性非常重要。

使用TypeVar注释

另一种注释返回类实例的方法是使用TypeVar。类型变量是一种类型,它可以在类型检查过程中作为特定类型的占位符。类型变量通常用于通用类型,例如特定对象的列表,如list[str]list[BankAccount]

TypeVar 允许你声明泛型类型和函数定义的参数,这使它成为注释返回类实例的方法的有效候选。要在这种情况下使用 TypeVar,我们可以从 Python 的类型模块中导入它,并在构造函数中给我们的类型命名:

# stack.py

from typing import TypeVar

TStack = TypeVar("TStack", bound="Stack")

在上面的示例中,我们创建了TStack类型变量,我们可以用它来注释Stack类中的.push()。在这种情况下,TStack 被 Stack 绑定,允许类型变量具体化为 Stack 或 Stack 的子类型。现在,我们可以使用 TStack 对方法进行注释:

# stack.py

from typing import Any, TypeVar

TStack = TypeVar("TStack", bound="Stack")

class Stack:
    def __init__(self) -> None:
        self.items: list[Any] = []

    def push(self: TStack, item: Any) -> TStack:
        self.items.append(item)
        return self

    # ...

在第 11 行中,我们用 TStack 类型注释了 .push()。同时注意到你用TStack注释了 Self 参数。这是静态类型检查器正确地将TStack实体化为Stack所必需的。被类绑定的TypeVar可以具体化为任何子类。这在BankAccount和SavingsAccount示例中非常有用:

# accounts.py
from typing import TypeVar

# 创建由BankAccount类绑定的TBankAccount类型
TBankAccount = TypeVar("TBankAccount", bound="BankAccount")

这里,TBankAccount 被 BankAccount 绑定,我们可以正确地注释在 BankAccount 中返回 Self 的方法:

# accounts.py

from dataclasses import dataclass
from typing import TypeVar

TBankAccount = TypeVar("TBankAccount", bound="BankAccount")

@dataclass
class BankAccount:
    account_number: int
    balance: float

    def display_balance(self: TBankAccount) -> TBankAccount:
        print(f"Account Number: {self.account_number}")
        print(f"Balance: ${self.balance:,.2f}\n")
        return self

    # ...

我们可以用 TBankAccount 来注释 .display_balance() 以指定它将返回一个类实例。重要的是要记住 TBankAccount 与 BankAccount 并不相同。相反,它是一个类型变量,在类型检查时代表 BankAccount 类型。

TBankAccount 除了在不能直接使用 BankAccount 的注释中表示 BankAccount 类型外没有其他用途。我们还可以使用该类型来注释 SavingsAccount 子类中的方法:

# accounts.py

import random
from dataclasses import dataclass
from typing import TypeVar

TBankAccount = TypeVar("TBankAccount", bound="BankAccount")

# ...

@dataclass
class SavingAccount(BankAccount):
    interest_rate: float

    @classmethod
    def from_application(
        cls: type[TBankAccount], deposit: float = 0, interest_rate: float = 1
    ) -> TBankAccount:
        # Generate a random seven-digit bank account number
        account_number = random.randint(1000000, 9999999)
        return cls(account_number, deposit, interest_rate)

    # ...

我们在 SavingsAccount.from_application() 中注释了 TBankAccount 类型变量,并在 cls 参数中注释了 type[TBankAccount]。对于 BankAccount 和 SavingsAccount 来说,大多数静态类型检查程序应该能够识别出这是有效的类型提示。

主要的缺点是TypeVar是冗长的,开发者很容易忘记实例化一个TypeVar实例或正确的绑定实例到一个类。还需要注意的是,并不是所有的集成开发环境在检查方法时都能识别TypeVar。这些就是为什么Self类型比更受欢迎的主要原因。

在下一节中,我们将探索 Self 和 TypeVar 的另一种选择,__future__ 模块。你将看到这种方法如何克服 TypeVar 的冗长,但仍然不是 Self 类型的首选,因为它不能很好地支持继承。

使用 __future__ 模块

Python 的 __future__模块为注释返回外层类的方法提供了一种不同的方法。__future__ 模块有时被用来引入不兼容的变化,这些变化将成为未来 Python 版本的一部分。

对于大于 3.7 的 Python 版本,你可以在脚本的顶部从 __future__ 模块导入注释功能,并直接使用类名作为注释。我们可以在 Stack 类中看到这一点:

# stack.py

from __future__ import annotations

from typing import Any

class Stack:
    def __init__(self) -> None:
        self.items: list[Any] = []

    def push(self, item: Any) -> Stack:
        self.items.append(item)
        return self

    # ...

在第 3 行,我们从 __future__ 导入了注释,我们可以使用注释特性,这些特性在我们使用的 Python 版本中可能是不可用的。在第 11 行,我们直接使用类名作为 .push() 的注释。你可以在检查 .push() 时看到注释,就像前面一样。

注意: 你必须在脚本的顶部导入 __future__ 模块。这是必需的,因为 __future__ 改变了解析 Python 代码的方式,允许使用不兼容的特性。

在引擎盖下,注释不会被执行,而是存储为字符串,可以在以后执行。这种评估注释的方式引起了一些讨论,在未来的 Python 版本中可能会有更好的方法。

虽然 __future__ 模块可以用类名注释方法,但这并不是最好的做法,因为 Self 类型更直观,更符合 Pythonic。另外,在脚本的顶部记住从 __future__ 导入可能会很麻烦。更重要的是,当使用 __future__ 进行注释时,继承并没有得到正确的支持。看看当你使用 __future__ 注释时,SavingsAccount 方法会发生什么:

# accounts.py

from __future__ import annotations

import random
from dataclasses import dataclass
from typing import Self

@dataclass
class BankAccount:
    account_number: int
    balance: float

    # ...

    def deposit(self, amount: float) -> BankAccount:
        self.balance += amount
        return self
    # ...

@dataclass
class SavingsAccount(BankAccount):
    interest_rate: float

    @classmethod
    def from_application(
        cls, deposit: float = 0, interest_rate: float = 1
    ) -> SavingsAccount:
        # Generate a random seven-digit bank account number
        account_number = random.randint(1000000, 9999999)
        
        return cls(account_number, deposit, interest_rate)

    def calculate_interest(self) -> float:
        return self.balance * self.interest_rate / 100

    def add_interest(self) -> SavingsAccount:
        self.deposit(self.calculate_interest())
        return self

上面的代码重新定义了SavingsAccount,它继承自BankAccount。请注意我们是如何在BankAccount中用BankAccount注释.deposit()的,又是如何在SavingsAccount中用SavingsAccount注释返回Self的方法的。一切看起来都很好,但是看看当你从SavingsAccount的实例中检查.deposit()的类型时会发生什么:

从SavingsAccount继承的方法被错误地注释为BankAccount。

.deposit() 的返回类型显示为 BankAccount,尽管该对象是 SavingsAccount 的实例。这是因为 SavingsAccount 继承自 BankAccount,而 future 注释并不正确地支持继承。当你检查 .add_interest() 时,这会产生更多的类型检查问题:

.add_interest()的类型检查失败是因为.deposit()的注释不正确。

.add_interest() 的类型检查失败是因为类型检查器认为 deposit() 返回一个 BankAccount 实例,但是 BankAccount 没有名为.add_interest() 的方法。这说明了 __future__ 注释最突出的缺陷。虽然 __future__ 注释可能适用于许多类,但在键入继承方法时却不合适。

在下一节中,我们将探索一个注释,它在功能上类似于__future__注释,但比__future__注释更直接。这将帮助你理解 __future__ 在引擎盖下的作用。请记住,返回类实例的方法的所有替代注释都不再被认为是最佳实践。你应该选择 Self 类型,但是理解这些替代注释是有好处的,因为你可能会在代码中遇到它们。

字符串类型提示

最后,你可以使用字符串来注释返回类实例的方法。对于小于 3.7 的 Python 版本,或者当其它方法都不起作用时,应该使用字符串注释。字符串注释不需要任何导入,大多数静态类型检查器都能识别它:

# stack.py

from typing import Any

class Stack:
    def __init__(self) -> None:
        self.items: list[Any] = []

    def push(self, item: Any) -> "Stack":
        self.items.append(item)
        return self

    # ...

在这种情况下,字符串注释应该包含类的名称。否则,静态类型检查器不会将返回类型识别为有效的 Python 对象。字符串注释直接完成类似于 __future__注释在幕后所做的事情。

字符串注释的一个主要缺点是它们不会随继承而保留。当子类从超类继承方法时,超类中指定为字符串的注释不会自动传播到子类中。这意味着,如果我们依赖字符串注释来进行类型提示或文档说明,那么我们需要在每个子类中重新声明注释,这可能会容易出错且耗时。

许多开发者还发现字符串注释的语法与 Python 的其它特性相比显得不寻常或不习惯。在 Python 3 的早期版本中,当类型提示被引入时,字符串注释是唯一可用的选项。然而,随着typing模块和类型提示语法的引入,我们现在有了一种更标准、更有表现力的方式来注释类型。

结论

在 Python 中使用类型提示注释可以使你的代码更具可读性和可维护性,尤其是当代码的大小和复杂性增加时。通过指明变量函数参数返回值的类型,我们可以帮助其他开发者理解变量的预期类型以及函数调用的预期。

Self类型是一种特殊的类型提示,我们可以使用它来注释返回类实例的方法。这使得返回类型显式化,有助于防止在处理继承和子类时可能出现的微妙 bug。虽然我们可以使用其它选项,如 TypeVar、__future__ 模块和字符串来注释返回类实例的方法,但在可能的情况下,我们应该使用 Self 类型。

通过从 typing 模块导入 Self 类型,或者在 Python 3.10 及更早版本中从 typing_extensions 中导入,你可以注释返回类实例的方法,使你的代码更易于维护和阅读。