引言: 自 Python 3.5 起,类型注解成为官方语言特性,极大提升了代码的可读性与可维护性。它让调用者在使用函数或类时明确知道参数与返回值的预期类型,而且现代IDE智能提示也依赖于此。此外,在一些无法方便调试的场景如硬件控制或网络交互中,依赖类型注解和静态类型检查器在运行前发现问题变得尤为重要。
类型注解的发展过程中也暴露出一个需求:我们需要能表达“多种类型但性质统一”的情况,这正是泛型(Generic)存在的意义。
在3.12+ 版本中,可以使用如下写法,在方括号内定义了类型变量T和U,在函数签名中使用T和U来指定入参的类型。而输出值指定为包含分别为T类型U类型的两个元素的元组。
def pair[T, U](first: T, second: U) -> tuple[T, U]:
return (first, second)
替代了旧式冗杂的写法
from typing import TypeVar, Generic
T = TypeVar('T')
U = TypeVar('U')
def pair(first: T, second: U) -> tuple[T, U]:
return (first, second)
当然,我们还拥有类型约束写法确定类型上界
def pair[T: str, U: int](first: T, second: U) -> tuple[T, U]:
return (first, second)
我们可以使用如下写法定义一个泛型类ClassA,T是一个类型变量,约束为str类型。这样在类内各处都可以使用该泛型T作为类型注解,比如在方法method1中返回值类型也被指定为T。
class ClassA[T: str]:
def method1(self) -> T:
...
用于替代以前的
from typing import Generic, TypeVar
_T_co = TypeVar("_T_co", covariant=True, bound=str)
class ClassA(Generic[_T_co]):
def method1(self) -> _T_co:
...
可以注意到新式写法不再关注协变逆变概念。
我们可以有别名typing.TypeAliasType
。
type Point2D = tuple[float,float] # type(Point2D)运行时输出<class 'typing.TypeAliasType'>
type Point3D = tuple[float,float,float] # 对类型的索引语法[]将会调用__class_getitem__方法(ENUM例外)。如果是自定义类型,则需要继承自内置类型,或者继承自typing.Generic然后手动实现__class_getitem__。
type ListOrSet[T] = list[T] | set[T] # 泛型别名,表示要么是包含若干T类型的列表,要么是包含若干T类型的集合。这里和直接构造list用法不同的是,无法指定存储多种元素类型的列表类型。
type StudentInfo[T] = tuple[str,bool,int,T] # 泛型别名,表示一个包含姓名、是否活跃、年龄和额外信息的元组,其中额外信息可以是任意类型T。
student001: StudentInfo[dict] = ("Bob", True, 22, {
"Country": "US",
"Hobby": "tennis",
"Height": 182,
"Married": False
})
# 这里的StudentInfo[dict]是将泛型实例化,代表最后一个具体类型是dict
相比起class定义一个类,以上写法可使得书写泛型更加简洁,但是丧失了强制性,并且在运行时无法使用isinstance检查(纯注解行为)。
仅针对StudentInfo这样场景的话,我们可以定义一个数据类,使用@dataclasses.dataclass装饰器
根据新式写法,我们可以构造一些python中没有内置的泛型。 值得注意的是,泛型的概念源于强类型语言,而python本身就是动态类型,运行时天然支持泛型,所以如此写法仅有助于静态类型检查。
Python 泛型类型 | C++ STL 容器 | 说明 |
---|---|---|
list[T] |
std::vector<T> |
动态数组 |
collections.deque[T] |
std::deque<T> |
双端队列,从 3.9 开始支持泛型 |
set[T] |
std::set<T> |
集合 |
set[T] |
std::unordered_set<T> |
Python 的 set 本身就是基于哈希 |
dict[K, V] |
std::map<K, V> |
键值映射 |
dict[K, V] |
std::unordered_map<K, V> |
Python 的 dict 是哈希表 |
tuple[T1, T2] |
std::pair<T1, T2> |
固定长度的元组 |
tuple[T1, T2, ...] |
std::tuple<Ts...> |
多值元组(异质) |
T| None |
std::optional<T> |
可选T类型 |
Callable[[A], R] |
std::function<R(A)> |
函数类型(类型注解用) |
tuple[T, ...] |
std::array<T, N> |
数组 |
Python中有两个方面和强类型语言比如C++有重大区别:
- 类型系统
- 面向对象
众所周知,Python属于完全的动态类型语言。注解语法完全无法限制python的运行时类型行为,可以认为任何地方,包括全局和类函数的所有入参和返回值,还有全局变量和类属性,其类型都隐式地指定为typing.Any。这里的Any是指任意而不是任一。
C++的所有运行时类型,都是确定的某一类型。造成其根本性区别是在于C++编译期即把所有类型确定(即使有dynamic_cast<>亦或者类型擦除),根据不同的类型生成了不同的汇编指令的具体写法,而Python在运行期依旧保留了所有数据所有类的元信息,只有在具体执行到该代码处才根据元信息把类型确定下来(同时此举给python带来了反射reflection和自省introspection的能力)。
泛型写法对于C++和Python更像一场双向奔赴:C++的模板使得其拥有了“一种定义,多种版本”的能力,尽管只定义了一处模板,模板实例化却可以支持很多不同的参数类型、返回类型(但不是任意类型)。Python的泛型则在提示和静态类型检查层面,局限了原本类型的任意性。
比如:
def pair[T, U](first: T, second: U) -> tuple[T, U]:
return (first, second)
这里在语义上告知,返回值是和输入值一致的类型,不再任意。另外,类型上界bound的语法,也在语义和类型检查上限制了类型的范围。这样使得Python的任意类型收缩为某些类型。Python当中的泛型约定形似C++中的concepts之于模板。
再说类型别名:一个类型别名的语句对于运行时依旧没有任何影响(如果不考虑专为类型检查器生成的dunder方法诸如“__bound__” “__make_typealias__”之类),并且类型别名不会生成新的类型对象,不能用于 isinstance 检查,也不能用于构造数据,它仅是注解层的符号替换,依旧依托原始类型的构造器,比如:
type Point2D = tuple[float,float]
pointA: Point2D = (2.1, 4.5)
print(type(pointA)) # <class 'tuple'>
在运行时并无Point2D这个类型。 那么引起这样一个思考,到底要不要使用这种仅有提示意义的语法呢?是不是应该所有类型都使用class定义以保证运行时的合法性呢?我觉得可以放在下面引申的面向对象说完以后一起给出结论。
Python和C++都是支持面向对象的语言,都支持继承和多态。但Python有一个灵活的特性即鸭子类型duck typing。其意:只要会呱呱叫就认为是鸭子,只要有特定的行为就认为是某种类型,而不需要显式继承。鸭子类型一般和Protocol配合使用方便接受静态类型检查器检查。
C++没有这种隐式注册子类型的行为,所有继承都需要从祖宗辈开始显式继承。这在定义类的时候,语义会非常清晰: 比如继承了某些接口,那么子类必须实现/重写接口的纯虚函数。这样的好处是规范,能尽早发现实现过程的问题,如果没有实现虚函数,静态检查期和编译期报错。
python使用鸭子类型的话,直到运行期都不一定能够明显的报错。即使mypy或者pyright能够静态检查,但这种松散的完全解耦的关系在项目复杂时候很不直观,完全背离了面向对象中继承多态的严谨逻辑。
所以我会拒绝以下的写法:
from typing import Protocol
class Speaker(Protocol):
def speak(self) -> None: ...
class Cat:
def speak(self):
print("Meow")
class Dog:
def speak(self):
print("Woof")
def make_it_speak(animal: Speaker):
animal.speak()
make_it_speak(Cat()) # Meow
make_it_speak(Dog()) # Woof
转而使用更加规范的
from abc import ABCMeta, abstractmethod
from typing import override
class Speakable(metaclass=ABCMeta):
@abstractmethod
def speak(self) -> None:...
class Cat(Speakable):
@override
def speak(self) -> None:
print("Meow")
class Dog(Speakable):
@override
def speak(self) -> None:
print("Woof")
def make_it_speak(animal: Speakable):
animal.speak()
make_it_speak(Cat()) # Meow
make_it_speak(Dog()) # Woof
可以看出来,我对于Python面向对象的继承多态行为还是比较pedantic的,现在可以回到上面类型系统的问题:是否应该使用type别名?
结论:只有在非常简单的场景下可以使用类型别名typing.TypeAliasType而没必要去定义一个运行时可见的class,任何其他情况都应该定义明确的class,传参或者返回也应该指定该class,构造数据时候使用该类型()调用。
比如之前的这个例子:
type Point2D = tuple[float,float]
type Point3D = tuple[float,float,float]
type StudentInfo[T] = tuple[str,bool,int,T]
student001: StudentInfo[dict] = ("Bob", True, 22, {
"Country": "US",
"Hobby": "tennis",
"Height": 182,
"Married": False
})
pointA: Point2D = (2.1, 4.5)
其中Point2D 和 Point3D 别名对应的类型相对简单,使用数据时候也有简单的构造方式pointA: Point2D = (2.1, 4.5)
,所以一般不用增加一个类。
StudentInfo相对复杂,使用别名方式无法进行类型具体内容的约束,还使用tuple的构造方式如果笔误也不一定有运行期的报错,我们改为以下形式
class StudentInfo[T](tuple):
def __new__(cls, name: str, is_active: bool, age: int, extra: T):
return super().__new__(cls, (name, is_active, age, extra))
@property
def name(self) -> str:
return self[0]
@property
def is_active(self) -> bool:
return self[1]
@property
def age(self) -> int:
return self[2]
@property
def extra(self) -> T:
return self[3]
student001 = StudentInfo[dict]("Bob", True, 22, {
"Country": "US",
"Hobby": "tennis",
"Height": 182,
"Married": False
})
print(student001.name) # Bob
print(student001[2]) # 22
print(type(student001.extra)) # <class 'dict'>
泛型在近年来逐步受到程序员社区的重视。我们在未来的编码实践中,可以尝试利用泛型的优势提升我们代码的质量。