前言

如果把时间拉到一年前我肯定不会写关于类型提示 (Type Hint) 或者 mypy 的内容。印象里在之前的博客或者知乎回答中明确提过「拒绝在代码中指定变量类型」,另外一个原因是 mypy 和类型提示相关的功能还在不断完善,业界还没有大范围应用。

众所周知,Python 是动态类型语言,声明变量时不需要显式的指定变量类型,程序会在运行时候解析出变量的类型,这样能够减少一部分代码量,加快程序的编写速度。不过缺点大家也清楚,除了降低可读性和容易因为类型问题异常之外,相对于其他编程语言 Python 一直被诟病:慢。

作为一个 Python 开发者,我过去认为「在代码中声明参数和返回值的类型」不是什么重要的事情,这有赖于我的一些编程习惯和对业务的了解,说几个例子解释下我为什么很少在这里踩坑吧。

值可能是多个类型的

在模型中经常有一些 XX.id,比如 subject.id,这个 id 属性的值可能是 int、str 甚至由于一些特殊逻辑为 None,相信在豆瓣做过开发的都能理解这件事吧?我在这里有非常统一的原则:属性的值和存在数据库里面的值的类型一致,所以绝大情况下 id 都是 int,如果有些地方需要字符串的值,例如 API 返回时,拿就在 schema 里面定好类型用于展示,但是在后端逻辑中无论在哪里都是 int。

但为什么这对于很多开发者是一个问题呢?没有统一规范,有的开发者用字符串,有的开发者用的很随意,那么时间久了,这个 id 的值类型就不好猜了。不过对我来说这不是问题,第一只要是我写的代码我一眼就知道是什么,第二是别人写的代码,了解多了我绝大部分能猜到对方用的是什么,这真的是用心不用心的问题。

代码写的看不出参数和返回值的类型

在看历史遗留的或者其他人写的代码时,这种情况很多。这个就需要用到编辑器的搜索、函数定义这方面的功能,了解调用方是怎么用的,其实花不了多少时间。如果代码非常重要,我一般会防御性的修改它,举个例子:

def get_follower_ids(to_id, start=0, limit=20):
    queryset = query.limit(limit).offset(start)

获取一个用户的关注列表,我认为to_id应该是 int,limit 可以是一个大于零的数字或者 None,我会这么改:

def get_follower_ids(to_id, start=0, limit=20):
    to_id = int(to_id)
    queryset = query.offset(start)
    if limit is None:
        queryset = query.limit(limit)

当然有必要时应该抛错给调用方。

异常处理

一个典型例子,就是开发者忘记处理 None 或者其它一些特殊条件的情况,借着前面提到的 id 值的类型可能为 None 来说,id 为 None 这本身就很奇怪。这种情况下很多时候是由于创建模型实例是异常了,例如 SQL 语句语法错误,数据库出现了问题等。有些开发者会在错误时返回一个实例,但是 id 是 None,就可能造成未来一系列不符合预期。说到这里先说一下 Go 的常见写法:

func createStatus() (string, bool) {
    return "Test", true
}

func main() {
    if value, ok := createStatus(); ok {
        fmt.Println(value)
    }
}

而我写的代码非常像 Go 的体验 (如 lyanna 项目的一处,延伸阅读链接 3):

ok, user = await validate_login(username, password)
if not ok:
    raise exceptions.AuthenticationFailed('User or Password is incorrect.')

我大概从 14 年开始这么写:把执行是否成功和返回的实例分开,如果 ok 为 True,那么 user 就是可用的,否则根本就不走下面的逻辑。

日常开发里面我有很多这样的开发习惯和技巧,它们帮我逃避了很多坑儿

那我为什么开始重视类型提示问题?

首先是身边 Python 开发者质量的下降。在大型项目中涉及多人开发,如果没有一套完整类型检查体系,且开发团队没有规范意识,这简直就是灾难。你们现在看到的某蔬菜厂的各种线上挂掉,其中绝大多数都是代码质量问题。

然后是业界如 Dropbox、Instagram、Google 等知名应用 Python 的公司都在努力做好这件事,大势不可逆,未来类型提示回变得越来越普遍,我们得顺应甚至去引领。

第三是希望它帮助我提高开发效率,减少 BUG。

我认为如果迁移到 Python 3,一定要补类型提示,把 mypy 用起来。

类型提示的历史

简单说一下类型提示的历史吧。

  • Python 3.0。GvR (Guido van Rossum) 其实在 04 年 (延伸阅读链接 6) 就提过给在 Python 编译时期就如类型检查,但是核心开发反对声音很大后来就作罢了。而最早是付诸实际行动的是他支持了在 2006 年提交的《PEP 3107 Function Annotations》(延伸阅读链接 5),不过这个提议没什么人回应,虽然最终 Python 3.0 是依照这个提案添加了函数注解,不过还是没有引起什么反响。
  • Python 3.5。事情的转机出现在 Pycon2013 年,mypy 的作者 Jukka Lehtosalo 做了《Mypy: Optional Static Typing for Python》(延伸阅读链接 7) 这个主题演讲,不过很遗憾没有找到对应视频,Jukka 在这场演讲中介绍说自己正在开发一门叫「Mypy」的 Python 方言,其最大的特点便是同时支持静态和动态类型。该想法与 GvR 很早之前的想法不谋而合。和 Jukka 交谈后,GvR 得到灵感,撰写出《PEP 483 The Theory of Type Hints》(延伸阅读 4)。而后 GvR 和 Jukka 一同把这篇草稿扩展成了《PEP 484 Type Hint》(延伸阅读链接 8) 并在 2015 年作为 Python 3.5 的新功能发布,到这里 Python 就有了可选的类型标注的协议,新增了 typing 模块。
  • Python 3.6。基于《PEP 526 Syntax for Variable Annotations》(延伸阅读链接 9) 添加了用来注释变量 (包括类变量和实例变量) 类型的语法
  • Python 3.7。基于《PEP 563 Postponed Evaluation of Annotations》(延伸阅读链接 10)支持了延迟标注求值,我之前专门写过 from __future__ import annotations 介绍它
  • Python 3.8。基于 PEP 591 (Final qualifier), PEP 586 (Literal types) 和 PEP 589 (TypedDict),完善了 typing。

可以感受到现在 (2020 年) Python 的类型已经非常完善了。而 Mypy 经过不断地调整,已经将自己定位成对 Python 做静态类型检查的工具。

类型提示的价值 (优点)

那用类型提示的价值是什么呢?

  • 提高开发效率。其实就是增加可读性和可维护性。之前说我会猜别人写的代码,但是「猜」这个词本身不靠谱也没法复制经验给所有人。另外我虽然能肯定我写的代码且无论几个月之后都不会忘记,但是对别人来说不也是阅读「别人」代码嘛?既然这样,不如直接在代码旁边标注参数的类型和返回类型,那么开发者就非常轻松了。
  • 降低 BUG 发生几率。这也是类型提示的核心价值,如果配合 mypy 或者 pyright 这样的类型检查工具,可以在代码合并前就发现大部分很低级的类型边界错误,当然也可以发现一些开发者自己都没有想到的隐藏问题。
  • IDE / 编辑器友好。以 PyCharm 和 Visual Studio Code 为代表的 IDE 和编辑器可利用类型注释实现代码补全、高亮显示错误等功能,因为当知道对象的类型时,IDE 可以告诉你对象上有哪些方法 / 属性可用。另外如果用户尝试调用不存在的内容或传递不正确类型的参数,IDE 可以立即警告,让开发者在编辑阶段就能发现类型问题。

虽然过去我内心不愿意接受和承认这个它的价值,但现在客观的说,在大型项目中应该补好类型提示。

在 lyanna 项目中使用 mypy

工作中目前还没有机会使用它,所以我先在个人项目中开始尝试使用它。本文不是 mypy 的使用篇,学习和了解 mypy 应该看官方文档,这里只记录我的经验和吐槽。

怎么处理历史遗留代码

lyanna 历经了一年的迭代,Python 代码行数 3k+,但之前 Python 代码完全没有标注过类型,如果人工补齐需要花费很多的时间。相信之前做这件事的 Dropbox、Instagram、Google 等公司肯定会有这样的经验,所以我先搜索了对应的内容。最终找到 2 个项目

它俩我都尝试了,各有各的问题。

Instagram/MonkeyType

MonkeyType 开源于 2017 年,它能在运行时收集函数参数和返回值的类型,还可以自动生成存根文件 (Stub Files)。它的思路是一个脚本文件包含你需要标注的模块并调用其中的逻辑。下面是我当时写的补充 models 部分脚本中 Post 相关的部分代码:

from tortoise import Tortoise, run_async

from ext import init_db
from models import *
from models.user import *
from models.comment import *
from models.utils import *

async def main():
    await init_db()
    try:
        await Post.create(title='test', content='', author_id=1,
                          slug='x', summary='summary')
    except:
        ...
    p = await Post.cache(1)
    await p.to_sync_dict()
    await p.comments
    await p.tags
    await p.content
    await p.author
    await p.html_content
    await p.get_all()
    await p.incr_pageview()
    await p.set_content('222')
    await p.get_by_slug('sddd')
    await p.get_related()
    await Post.sync_filter(orderings=['-id'])
    await Post.sync_all()
    await Post.get_or_404(1)
    await p.toc
    await p.update_tags(['ddd'])


run_async(main())

然后运行它:

❯ monkeytype run model_fill.py
# 默认会生成monkeytype.sqlite3数据库,存放了收集到的模块和对应类型
❯ sqlite3 monkeytype.sqlite3
sqlite> select * from monkeytype_call_traces limit 1;  # 感受一下记录内容
2020-01-05 21:12:03.633521|config|AttrDict.__init__|{"self": {"module": "config", "qualname": "AttrDict"}}|{"module": "builtins", "qualname": "NoneType"}|
sqlite> SELECT DISTINCT module from monkeytype_call_traces;  # 去重后的模块列表
config
aiomcache.client
models.mc
models.base
models.blog
ext
aiomcache.pool
models.comment
models.toc
# 接着就可以挨个应用了:
❯ monkeytype -v apply models.blog  # 模块名字来自于上面的monkeytype_call_traces表,-v会输出某些标注未成功的原因
# 现在git diff就可以看到monkeytype对应的修改了
❯ gd models/blog.py

monkeytype 的原理是用sys.setprofile钩子记录参数、返回值。它适合用在纯 Python 逻辑,如 model,util,lib 等,可以放在单元测试中,但是对于 Web 开发的 View 部分没有用,我试过:

❯ monkeytype run app.py

之后数据库中并没有产生视图部分的内容,这对于 Web 开发它的价值就差了很多了。

另外你需要做好准备,apply 的过程会遇到一些问题,如循环引用 (可以用from __future__ import annotations,或者字符串的类名,不要直接引用),见招拆招就可以,总体上问题好解决。

第三个不好的地方源于它的设计。你需要把想被标注的函数方法等都实际的调用一遍,所以编写起来这个脚本是很长的 (lyanna 项目写了约 300 行),如果不深入业务很容易漏一些,不过用它还是很有价值的。

最后强调一下,monkeytype 产生的是类型标注 (Type annotations),它的特点是:

  • 使用冒号 (:) 将信息附加到变量或函数参数中
  • -> 运算符用于将信息附加到函数 / 方法的返回值中

感受一点修改:

def trunc_utf8(string: str, num: int, etc: str = '...') -> str:
    # 原来的逻辑

这里参数string是 str 类型,num是 int 类型,etc是 str 类型默认值...,函数返回值是 str 类型

Dropbox/pyannotate

mypy 就是 Dropbox 技术团队做出来的,在这里必须赞扬先一下 Dropbox 的开源精神。Jukka 在延伸阅读链接 14 那篇翻译过来叫做《Dropbox 如何用四年完成 400 万行 Python 代码检查》中提到了 PyAnnotate,它能够在运行测试的同时收集类型,并根据类型结果插入类型注释 —— 但最终这款工具并没能得到广泛采用。理由很简单:收集类型的速度很慢,而生成的类型通常也需要大量人为调整。

它的用法看起来更合理,在我们这个 Web 服务的例子中直接在 app.py 里面添加收集逻辑即可:

from pyannotate_runtime import collect_types
collect_types.init_types_collection()
collect_types.start()

...  # 原来的代码

@app.listener('after_server_stop')
async def stop(app, loop):
    collect_types.stop()
    collect_types.dump_stats('type_info.json')

在代码中一开始收集,在after_server_stop这个监听事件里面停止收集并写入到 (默认的) 文件中。用原来的方式python app.py启动等待用户访问即可,在本地你可以挨个页面访问一下,再停止就可以了。

此时打开type_info.json可以看到已经记录了很多内容。不过当我准备写入中发现都在报一个错误:

❯ pyannotate -w models/blog.py
...
pyannotate_tools.annotations.parse.ParseError: Invalid type comment: (mako.codegen:_GenerateRenderMethod.write_toplevel.<locals>.FindTopLevel, mako.parsetree.InheritTag) -> None

这是因为它收集了 Mako 渲染模板的那部分,不过是不可用的,当然我还尝试了忽略这部分:

In : import json

In : r = json.loads(open('type_info.json').read())

In : len(r)
Out: 3681

In : rs = []

In : for l in r:
...:     if not (l['path'].endswith('_html') or 'mako' in l['func_name'] or 'mako' in l['type_comments'][0] or
...: 'venv' in l['path']):
...:         rs.append(l)
...:

In : len(rs)
Out: 106 # 筛选后不足原来的3%

In : with open('type_info.json', 'w') as f:
...:     f.write(json.dumps(rs, indent=4))
...:

现在用-w写入源文件:

❯ pyannotate -w models/
... 省略输出

有加号的就是新增的修改,表示在对应的函数下面添加了类型注解。不过这里面有很多问题:

  • 没有把类型直接写在代码行,而是以注释的形式携带下面,我觉得这种效果不好
  • 不支持 Python 3.8 的海象运算符
  • 同样有循环引用问题

pyannotate 和 monkeytype 不同,它产生的是类型注释 (Type comments),我认为主要是给 Python 2.7 不支持Type annotations时使用,如上例会是这样:

def trunc_utf8(string, num, etc='...'):
    # type: (str, int, str) -> str
    ...

在 lyanna 里面我用了很多# type: ignore忽略对此行的类型检查,一会会说为什么要忽略。

不过类型注释有以下缺点:

  • 容易和其他注释标记的工具冲突,比如 flake8 的 noqaisort:skip 等
  • 类型信息不和参数、函数结尾在一行,代码和 Type annotations 不好理解
  • 容易出现超长长度的类型提示,为了美化格式不得不需要让参数分成多行逐个加入提示,从而让行数变的比以前多了很多

后记

试用 monkeytype 和 pyannotate 后,发现都不尽如人意。最终我用 monkeytype 再加上人工补解决了遗留代码的类型标准。

那么选Type Annotations还是Type Comments呢?这部分我和延伸阅读链接 15RealPython 文章观点一样:

尽可能选择 Type Annotations,只在必要时用 Type Comments(如还在用 Python 2.7),这也是官方推荐。

这部分内容也非常推荐阅读延伸阅读链接 2。

mypy 的一些问题和吐槽

需要寻找最适合你的项目的配置项

好像 mypy 是我见过的配置项非常多的一个工具,每个项的值都可以让结果差别很大,你可能需要某个角度非常严格,另外一个角度宽松甚至完全不关注,那么就要好好地学习它的每一项配置。举个例子:

项目一开始使用 mypy 时你肯定见过:

views/blog.py:6: error: Cannot find implementation or library stub for module named 'sanic'
tasks.py:8: error: Cannot find implementation or library stub for module named 'mako.lookup'

这样错误,看错误输出很明显,这些第三方库没有类型提示也没有存根文件,对于我们这种使用者是没办法解决的,那么可以在配置文件 (我喜欢放在 setup.py 里,如果未来支持 pyproject.toml 我应该会放在这里) 用配置项忽略这类型的错误:

[mypy]
ignore_missing_imports = True

另外在一开始我加了一些disallow_前缀的配置,包含了disallow_subclassing_any = True,也就是不允许子类 Any 类型的类,结果除了很多错误:

app.py: note: In class "LyannaSanic":
app.py:63: error: Class cannot subclass 'Sanic' (has type 'Any')
    class LyannaSanic(Sanic):

我理解这就是找不到 Sanic 的类型,把它当做了 Any,看 mypy 输出都是这类错误,那么这个最终我去掉了,因为留着它我还得挨个地方加type: ignore

全部配置可以看 mypy 官方文档,你也可以参考 lyanna 项目的 配置文件

dataclasses

用出了一个 BUG:

from typing import Optional, Union
from dataclasses import dataclass


@dataclass
class Attachment:
    LAYOUTS = (LAYOUT_LINK, LAYOUT_PHOTO, LAYOUT_VIDEO) = range(3)
    layout: int


@dataclass
class Link(Attachment):
    layout = Attachment.LAYOUT_LINK

是不是看着也没什么问题,其实:

❯ mypy activity.py
activity.py:12: error: INTERNAL ERROR -- Please try using mypy master on Github:
https://mypy.rtfd.io/en/latest/common_issues.html#using-a-development-mypy-build
If this issue continues with mypy master, please report a bug at https://github.com/python/mypy/issues
version: 0.761
activity.py:12: : note: please use --show-traceback to print a traceback when reporting a bug

直接抛错了,我当时深入找了下,是因为子类继承后没有继承父类的属性类型,所以这里改成layout: int = Attachment.LAYOUT_LINK就可以了。不过当时看了下 mypy 的代码设计不好改,太忙了就改了下 lyanna 先用了。

对海象运算符的支持不佳

先上views/admin.py中的一部分代码 (L318-L327):

FORM_REGEX = re.compile(r'posts\[(?P<index>\d+)\]\[(?P<key>\w+)\]')

dct: DefaultDict[str, Dict[str, int]] = defaultdict(dict)
for k in copy.copy(request.form):
    if k.startswith('posts'):
        match = FORM_REGEX.search(k)
        if (match := FORM_REGEX.search(k)):
            key = match['key']  # 323行
            val = request.form[k][0]
            dct[match['index']][key] = (  # 325行
                int(val) if key == 'id' else val)
            del request.form[k]

运行 mypy 会出现这样的错误:

❯ mypy views/admin.py
views/admin.py: note: In function "_topic":
views/admin.py:323: error: Value of type "Optional[Match[str]]" is not indexable
                        key = match['key']
                              ^
views/admin.py:325: error: Value of type "Optional[Match[str]]" is not indexable
                        dct[match['index']][key] = (
                            ^
Found 2 errors in 1 file (checked 1 source file)

在 python/typeshed issue #3010 也提到了这个问题,是由于 re.pyi 里面对 match 对象标注的是Optional[Match[str]],需要先判断 match 后再拿 key 的值,不过我这里用了Assignment expressions,这个错抛的就不对了。然后我给这个 issue 加了个评论,也去 mypy 发了个 issue,因为不确认是那边的问题

MyPy issue: https://github.com/python/mypy/issues/8254

接口存根 (Stub)

其实除了Type Annotations还是Type Comments还有第三个选项,就是在 PEP 484 里面描述的存根文件。它主要用在给第三方库添加类型,如果基于某些原因不能更改原始源代码,使用存根文件这种方法也可以。

存根文件可以在任何版本的 Python 中使用,但是代价是必须维护第二组文件。

如果去掉ignore_missing_imports = True这个配置可以看到如下错误:

app.py:11: error: Cannot find implementation or library stub for module named 'sanic'
    from sanic import Sanic
    ^
app.py:12: error: Cannot find implementation or library stub for module named 'sanic.exceptions'
    from sanic.exceptions import FileNotFound, NotFound, ServerError
    ^
app.py:13: error: Cannot find implementation or library stub for module named 'sanic.handlers'
    from sanic.handlers import ErrorHandler as _ErrorHandler
    ^
app.py:14: error: Cannot find implementation or library stub for module named 'sanic.response'
    from sanic.response import HTTPResponse, text

其实 mypy 项目中已经通过 python/typeshed 这个库收集到的存根文件自动处理了标准库和一些第三方库的类型标注问题,如 six、tornado、click、flask、jinja2、pymysql 等。

但是 sanic 并不在此列,所以才会抛上述错误,怎么办呢?根本办法是让它补全类型提示,要不然在 typeshed 项目中添加它的存根。mypy 提供了一个自动创建存根的脚本,可以使用它:Python各个阶段学习资料/技术交流学习+扣裙882776158

❯ stubgen -m sanic -m sanic.exceptions -m sanic.handlers -m sanic.response -m sanic.request
❯ export MYPYPATH=out

这次再检查就会发现不再抛上面 sanic 相关的错误了,当然事实上对于第三方库的类型并不需要特别关注,所以可以使用ignore_missing_imports = True忽略这部分。

值得提醒的是,stubgen 这种工具不适合在实际开发环境中使用。你可以试试运行它,自动创建的代码中类型有非常多的 Any: 这是没有价值的一种标注,应该尽量避免,应该按需标记成正确的类型。

延伸阅读