彭某的技术折腾笔记

彭某的技术折腾笔记

Python 范型概览

2023-07-18

Python 范型概览

2023年7月18日

摘要

在 Python 这样的含有大量面向对象(OOP)特性的语言中,类型系统的设计都是至关重要的。然而由于 Python 同时又是一种弱类型的脚本语言,并没有编译过程,当然也更不会存在编译期间的类型检查,甚至,一个程序可以完全不标记任何类型就能完整执行。由于这些原因,Python 的范型系统也会有所不同,本文就将对其进行一个概述。

背景信息

此处背景信息极为重要。

还是由于前面提到的原因:Python 是一种弱类型语言,且不存在编译期类型检查,代码中的类型标注对于程序运行而言并不是必须,所有类型相关的错误都只会在运行时才能造成后果。在这样的背景下,编写类型标注大多是出于软件工程相关的考量,为了让代码更具有可读性,且能够让现代开发工具的智能检查工具(Linter)通过提示(Hint)让开发者更清晰的知晓所面对的数据的类型。除此之外,还可以使用额外的工具根据类型注解生成代码注释和相关文档。

在 Python 中,类型注解可以按照以下格式进行:

# Type Annotation for Variable
num_example: int = 1
# Type Annotation for Function
def some_function(input_a: int = 1, input_b: str = None) -> bool:
  pass

Python 中各种各样的类型相关的定义都在自带的 typing 包内。

再次提醒!使用类型注解只是能够提高代码可读性!并不会在参数类型不匹配的时候报错!只会在实际运行中由于错误类型无法继续执行时程序才会崩溃!

类型别名(Type Alias)

绑定(Binding)

有时为了代码编写的方便,我们会想要给一个类型取一个更加简短的名字。得益于 Python 的设计,类型本身也是对象,因此可以非常简单的达到此目的。

下面的代码将展示类型别名的创建方式:

# Create an alias `string` for built-in type `str`
string = str
# Create an alias 'NNModule' for class `torch.nn.Module`
NNModule = torch.nn.Module

这种方式创建的新类型将完全等价于原本的类型,且在后续的代码中可以混用,他们是真正的类型。

复制(Duplicate)

在某些情况下,我们会需要让两个数据结构上完全相同的变量拥有两个逻辑上不同的类型,例如一篇文章的标题和正文,虽然都是字符串,但有时我们想在代码的逻辑层面将其分成两种类型,以提高代码的可读性,此时可以这样做:

from typing import NewType

Title = NewType('Title', str)
Content = NewType('Content', str)

用于存放新类型的变量名需要和 NewType 的第一个参数中的内容完全相同。

此时,TitleContent 将代表两个不一样的类型,可以理解为是创建了两个结构和内容都完全一样的类,但二者属于不同的个体,并且,TitleContent 类型的实例不应该互相赋值(因为在逻辑上二者是不同的类型,Linter 会提示这样的异常行为,但由于二者拥有完全一致的数据结构,所以在不检查类型的运行时,也不会出错,能够正常运行,但不建议这么做)。

更加需要注意的是,新创建的两个类型变量和 str 本身拥有完全不同的类型,可以通过 type(VALUE) 查看:

  • str 的类型就是 type
  • TitleContent 的类型是 NewType

这样的区别带来了一个(也许是)负面影响,那就是 NewType 创建的类型本身并不是一个类型,而只是一个能够当作原本的类型使用的一个名称副本,用于增加可读性和给予 Linter 更多信息。除此之外,由于其根本不是一个类,也就不能在 isinstance()issubclass() 中被当作类名作为第二个参数了。

类型变量(Type Variable)

在定义一些函数时,我们会希望用户传入的参数符合要求,但同时也允许一定的灵活性,不一定只是严格的某一种类型。此时,就可以通过 TypeVar 来创建一个范型类型变量来进行一定范围内的约束。以下几个例子或许能帮助理解。

要求相同类型

如果一个函数要求用户输入两个拥有相同类型的参数,但并不在意具体是哪种类型,那么,可以按照以下方式定义:

from typing import TypeVar

T = TypeVar('T')

def some_function(input_a: T, input_b: T):
  pass

用于存放新类型的变量名需要和 TypeVar 的第一个参数中的内容完全相同。

此时创建了一个类型变量 T,可以代表任何类型, Linter 只会检查传入的两个参数类型是否一致。

要求类型范围

如果一个函数要求用户输入的两个参数属于某些类型之一,但并不在意具体是哪种类型,那么,可以按照以下方式定义:

from typing import TypeVar

T = TypeVar('T', int, float)

def some_function(input_a: T, input_b: T):
  pass

此时,两个输入都必须一致,且属于 intfloat 之一。

如果不要求他们一致,可以创建两个类型变量:

from typing import TypeVar

T = TypeVar('T', int, float)
S = TypeVar('S', int, float)

def some_function(input_a: T, input_b: S):
  pass

这样就只需要两个输入都属于 intfloat 之一,无所谓二者是否一致。

要求父类边界

除了可以要求两个参数所属类型的可选项,还可以要求其属于哪个类的子类,这在对各种具有不同类型但衍生于同一父类具有共同属性的对象的处理中十分有用。对于父类边界的限制可以这样实现:

from typing import TypeVar

T = TypeVar('T', bound=SuperClass)

def some_function(input_a: T) -> T:
  pass

此种定义方式的 T 只能指代父类 SuperClass 及其子类。

注意

此处所有类型变量的定义与否,条件满足与否都不影响程序运行,Python 并不会阻止错误类型的传参,类型变量依然只是用来增加可读性和给 Linter 提供提示信息用。

范型类

如果说前一章节使用类型变量的函数定义比较类似 C++ 的模版函数,那 C++ 中的模版类在 Python 中依然有类似的对应版本,以下用一个例子来说明:

from typing import Generic, TypeVar, List

Num = TypeVar('Num', int, float)


class NumList(Generic[Num]):
    def __init__(self, origin: List[Num] = None):
        if origin is None:
            self.list: List[Num] = []
        else:
            self.list = origin

    def __add__(self, other: Num):
        self.list.append(other)


some_list_a = NumList([1, 2, 3])        
some_list_b = NumList[int]([1, 2, 3])

此处定义了一个列表类型,用于存储一些数(intfloat)类型。其中元素类型 Num 使用了 TypeVar 进行创建。对于类型 NumList,用继承的语法通过 Generic[Num] 继承了一个抽象基类(Abstract Base Class)Generic 来指定了他是一个范型类,可以根据类型变量 Num 实际指代的类型的不同生成不同的实际类型。

创建一个实例时,可以使用最后一排代码所示的方式,用中括号来显式指定类型,也可以不写。

还是那句话,此处所有类型变量的定义与否,条件满足与否都不影响程序运行,Python 并不会阻止错误类型的传参,类型变量依然只是用来增加可读性和给 Linter 提供提示信息用。

总结

编写类型注解可以显著的增加代码的可读性并给 Linter 一定的机会在代码编写时给予开发者足够的提示来规避一些潜在的错误。Python 虽然提供了各种各样的类型相关的语法功能,但其毕竟是个弱类型语言,很多类型相关的错误只有在运行时才能显现,因此,还是要多加注意。

  • 0