浅谈Python中的注解和类型提示

通常,很多刚入门Python的人都会听说Python是一门动态类型的语言,即在Python中,解释器会以一种灵活的方式隐式处理变量的数据类型。虽然这在早些时候的确如此,但是在最近发布的几个版本Python解释器中,Python允许开发者显式指定类型提示,以使开发者可以更高效使用Python开发代码。

请注意,本文仅:

  • 简单介绍Python从注解类型提示的引入背景,并结合简单的案例介绍注解类型提示的语法;
  • 涉及Python对基本数据类型如strfloat以及bool等使用类型提示,关于Python对更加复杂的数据类型的类型提示,即注解复合类型数据请见浅谈Python对复合数据的注解和应用案例

一、类型系统

所有的编程语言都有所谓的类型系统,类型系统规定了该语言支持的数据类型以及这些数据类型的行为。

1. 动态类型

如前所述,Python就是一门典型的动态语言,这意味着Python解释器只在程序运行时才会做类型的检查,并且变量的类型在其生命周期内是可以改变的。如:

def main():
    if False:
        1 + "Two"  # 1
    else:
        1 + 2

    stuff = "Hello Eric Idle!"
    print(type(stuff))

    stuff = 2020
    print(type(stuff))


if __name__ == '__main__':
    main()

上述代码的运行结果为:

<class ‘str’>
<class ‘int’>

由上述运行结果可验证:

  • # 1处代码因为永远不会执行,所以程序运行时不会出错,即Python只在运行时进行类型检查;
  • 在变量stuff的生命周期内,可以分别将字符串和int数据赋给该变量,即Python变量的类型在其生命周期内可变。

2. 静态类型

与动态类型相对的是静态类型,在静态类型的语言中,类型检查在程序运行前就会完成,对于典型的编程语言,如:C、Java,这通常是在运行前的编译环节。

另外,对于静态类型语言,变量在其生命周期内一般不可改变其类型,当然了强制类型转换(casting)是例外,这是题外话。

下面是一段简单的Java代码片段:

String stuff;
stuff = "Hello";

上述代码的含义为:

  • 第一行声明了变量名称为stuff且指定其编译时的类型为String,在该变量的声明周期内,其不可以再被指定为其他数据类型;
  • 第二行中将一个字符串对象赋给了变量stuff,且在其生命周期内,变量的值只可以是字符串对象。

3. 鸭子类型

在谈到Python时,另一个经常被提及的术语是鸭子类型(Duck Typing),这一乍一听让人摸不着头脑的术语来自这样一段话:“如果它走路像只鸭子,呱呱呱的叫声也像一只鸭子,那么它肯定是一只鸭子。”1

3.1 类型与协议

鸭子类型的概念和Python中协议(protocol)的概念紧密相关,即对象的数据类型除了可以从名义类型(nominal type,如:strfloatintlisttupledict等)维度进行分类外,根据其支持的协议不同,对象还可以从结构类型(structural type)维度来分类,而这仅取决于协议所要求支持的方法有哪些。

实际上在使用鸭子类型时压根不关心对象的名义类型,而只关心对象是否实现了某协议所指定的方法。为了读者对上述鸭子类型协议有一个更直观的理解,下面打一个比方:

假设我们规定,一种动物如果实现了“鸭子协议”,且该协议中指定了描述鸭子特征的两个方法__waddle__()__quack__(),那么不管一个动物到底是鹅还是鸡,我们都可以称之为鸭子,这就是鸭子类型的含义。

Python中常见的为支持各类协议所预定义的类及需实现的方法如下表:

协议 方法
Container __contains__
Hashable __hash__
Iterable __iter__
Sized __len__
Callable __call__
Sequence __getitem____len__

3.2 鸭子类型案例

例如,对于下面的案例,你可以对Python中任何实现了__len__()方法(即支持Sized协议)的对象调用len()

class EricIdle(object):
    def __len__(self):
        return 2020


def main():
    eric = EricIdle()
    print("len(eric) = ", len(eric))


if __name__ == '__main__':
    main()

上述代码的运行结果为:

len(eric) = 2020

由上述代码的运行结果可知,调用len()得到了__len__()的返回值,即要想成功调用len(obj),唯一的限制仅在于obj中定义了__len__()方法。实际上,len()的实现类似于下列代码:

def len(obj):
    return obj.__len__()

二、注解和类型提示

1. 注解的引入

PEP 3107中,注解(annotation)被引入,关于注解被引入的缘由,PEP 3107中写得很清楚:

  • Because Python’s 2.x series lacks a standard way of annotating a function’s parameters and return values, a variety of tools and libraries have appeared to fill this gap.
    因为在Python 2.x中缺乏一种对函数2参数和返回值进行注解的统一方式,所以这就催生了各种不同的工具和类库以弥补这一空档。
  • Some utilise the decorators introduced in “PEP 318”, while others parse a function’s docstring, looking for annotations there.
    一些人使用PEP 318中引入的装饰器来实现注解的功能,而另外一些人通过查看函数的docstring来查找注解。
  • This PEP aims to provide a single, standard way of specifying this information, reducing the confusion caused by the wide variation in mechanism and syntax that has existed until this point.
    因此,该PEP旨在提供一种标准的解决方案,从而降低因截至目前多种实现方式和语法共存导致的困扰。

2. 函数的类型提示

关于Python中的注解,在其于PEP 3107被引入之初,仅用于对函数作统一的注释,关于注释所要达到的效果,即具体语义(semantics),PEP 3107并未做出限制,这留待第三方类库做出决定。

如,一个类库可能会使用字符串形式的注解来提供更好的帮助信息:

def user_compile(source: "something compilable",
                 filename: "where the compilable thing comes from",
                 mode: "is this a single statement or a suite?"):
    pass

再如,另外一个类库可能通过注解的方式指定某一函数期望接收的输入数据类型以及返回值类型:

class PackAnimal(object):
    pass


class Haulable(object):
    pass


class Distance(object):
    pass


def haul(item: Haulable, *vargs: PackAnimal) -> Distance:
    pass

但不管是实现哪种效果的语义,你都可以通过函数的__annotation__属性来查看注解信息,如:

def user_compile(source: "something compilable",
                 filename: "where the compilable thing comes from",
                 mode: "is this a single statement or a suite?"):
    pass


class PackAnimal(object):
    pass


class Haulable(object):
    pass


class Distance(object):
    pass


def haul(item: Haulable, *vargs: PackAnimal) -> Distance:
    pass


def main():
    print(user_compile.__annotations__)
    print(haul.__annotations__)


if __name__ == '__main__':
    main()

上述代码的运行结果为:

{‘source’: ‘something compilable’, ‘filename’: ‘where the compilable thing comes from’, ‘mode’: ‘is this a single statement or a suite?’}
{‘item’: <class ‘__main__.Haulable’>, ‘vargs’: <class ‘__main__.PackAnimal’>, ‘return’: <class ‘__main__.Distance’>}

由于Python的动态语言特性,有时候会引起一些难以避免的问题,如当调用别人编写的代码模块时,如果从其中定义的变量名难以判断类型,可能会导致调用时传递了错误类型的数据,进而导致代码无法正常运行。

一方面,为缓解上述提出的问题,另一方面,如前所述,PEP 3107中注解引入后一个主要的使用案例就是为函数指定参数和返回值类型,因此,Python在PEP 484中正式基于注解的概念引入了类型提示(type hint)的概念,而类型提示也叫作类型注解(type annotation)。

为进一步解释上述所指的问题以及类型提示的概念,首先看下面一段代码,该段代码将文本字符串通过首字母大写和装饰线的方式转换成了标题:

def headline(text, align=True):
    if align:
        return f"{text.title()}\n{'-' * len(text)}"
    else:
        return f"{text.title()}".center(50, "*")


def main():
    print(headline("python type hint"))
    print(headline("python type hint", align=False))


if __name__ == '__main__':
    main()

Python Type Hint
----------------
*****************Python Type Hint*****************

上述代码在语法上无任何问题,但是对于潜在的使用者来说,问题在于并不能仅从变量名称就知道其类型,这可能进而导致的问题是:在调用headline()时,希望为align传入一个布尔值,而实际上为其传入任何字符串对象也是没有问题的。

为避免上述的不确定性,Python中引入了类型提示的语法,使用者仅需要为形参和返回值指定类型即可:

def headline(text: str, align: bool = True) -> str:  # 1
    if align:
        return f"{text.title()}\n{'-' * len(text)}"
    else:
        return f"{text.title()}".center(50, "*")


def main():
    print(headline("python type hint"))
    print(headline("python type hint", align=False))
    print(headline("python type hint", align="Middle"))  # 2


if __name__ == '__main__':
    main()

上述代码的运行结果为:

Python Type Hint
----------------
*****************Python Type Hint*****************
Python Type Hint
----------------

结合上述代码及其运行结果可知:

  • 为形参和返回值添加类型提示的语法如# 1代码所示;
  • 类型提示并非是强制的,如:即使在# 2处传递了非布尔变量的字符串,程序依然正确执行。

3. 类型提示的好处

  • 类型提示可以帮助你更好的为代码书写帮助信息。初步接触Python时,你可能使用docstring来提供关于函数参数类型的帮助信息,但对于docstring并不支持自动类型检查。
  • 类型提示可以让你更好的利用IDE的代码提示和补全等功能,如下图所示,在PyCharm中,如果定义函数headline()时,通过类型提示指定了text参数为str类型,则PyCharm会提供自动提示:
    在这里插入图片描述
  • 类型提示可以帮助你构建更清晰干净的代码架构。在代码中添加类型提示的过程将迫使你思考你要在程序中使用哪种数据类型。

三、类型提示和类型注释

1. 类型注释

PEP 484中除了引入类型提示(也叫类型注解)的概念外,其还引入了类型注释的概念来注解变量,如:

from typing import Dict, List

# 'primes'是一个整型列表
primes = []  # type: List[int]

# 'captain'是一个字符串
captain = ...  # type: str


class Starship:
    # 'stats'是一个类变量
    stats = {}  # type: Dict[str, int]

2. 变量注解

然而,使用类型注释来实现对变量注解有如下几个问题:

  • 编辑器通常对注释和类型注解的高亮不同;
  • 无法使用类型注释来注解一个未赋值的变量;
  • 由于类型注释的本质还是注释,则其不是代码的一部分,所以如果一个Python脚本希望对其进行解析,则需要自定义的解析器而不是使用ast模块就可以;
  • 当普通注释和类型注释混用时,很难区分二者;
  • 对于类型注释,无法在程序运行时通过__annotation__属性获得注解。

鉴于上述原因,在PEP 526中向Python引入了注解变量的语法,而不是像上面一样通过注释的方式实现,如:

from typing import ClassVar, Dict, List
import sys


primes: List[int] = []

captain: str


class Starship:
    stats: ClassVar[Dict[str, int]] = {}


def main():
    print(sys.modules[__name__].__annotations__)  # 1
    print(Starship.__annotations__)


if __name__ == '__main__':
    main()

上述代码的运行结果为:

{‘primes’: typing.List[int], ‘captain’: <class ‘str’>}
{‘stats’: typing.ClassVar[typing.Dict[str, int]]}

在上述代码中,需要注意的是:

  • 上述用于注解变量的列表和字典都是typing模块中的,其首字母均为大写,且通过方括号指定列表或字典中元素数据类型;
  • 对于被注解的变量,注解信息保存在模块层级的__annotation__字典属性中,这即为# 1处代码的含义。

3. 注解鸭子类型

前面我们已经知道了如何为名义类型的变量添加类型提示,那么如何为鸭子类型的变量添加类型提示呢?具体地,对本文刚开头提到的len()函数添加类型提示:

from typing import Sized


def len(obj: Sized) -> int:  # 1
    return obj.__len__()


def main():
    print(len.__annotations__)


if __name__ == '__main__':
    main()

上述代码的运行结果为:

{‘obj’: <class ‘collections.abc.Sized’>, ‘return’: <class ‘int’>}

结合# 1处的代码可知,对于参数obj,函数len()并不关心其名义类型是什么,只要其实现了__len__方法即可,进一步地,即只要其支持Sized协议即可,故有如上的语法。

四、参考材料


  1. If it walks like a duck and it quacks like a duck, then it must be a duck. ↩︎

  2. 本节所指“函数”指代所有可调用对象。 ↩︎

猜你喜欢

转载自blog.csdn.net/weixin_37780776/article/details/106631045