Skip to main content

你好,类型

·384 words·2 mins
Table of Contents

简单地列一下最近在个人项目中使用 mypy 的一些体会,import cycles 这种常见的问题就不再多提了。另外本文所使用的 mypy 版本为 0.600。因为版本变动比较大,可能有些问题以后并不会复现

艰难的选择
#

def set_non_blocking(fd: int) -> None:
    flags = fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK
    fcntl.fcntl(fd, fcntl.F_SETFL, flags)

这段代码传入 socket.socket 也是可以工作的。但正确地讲,应当传入 sock.fileno() 的。等等我刚才说的对么?

其实这段代码细究一下还是比较有意思的。根据 CPython 3.6.3 的源代码来说,所有传入 fcntl.fcntl 的第一个参数(即fd)都会先被 conv_descriptor 处理。其内部实现就是把 Python 的外衣脱了然后调用 FCNTL(2)

// Modules/fcntlmodule.c
static int
conv_descriptor(PyObject *object, int *target)
{
    int fd = PyObject_AsFileDescriptor(object);

    if (fd < 0)
        return 0;
    *target = fd;
    return 1;
}

PyObject_AsFileDescriptor 的作用见注释

// Objects/fileobject.c
/* Try to get a file-descriptor from a Python object.  If the object
   is an integer, its value is returned.  If not, the
   object's fileno() method is called if it exists; the method must return
   an integer, which is returned as the file descriptor value.
   -1 is returned on failure.
*/

int
PyObject_AsFileDescriptor(PyObject *o)
{
    int fd;
    PyObject *meth;
    _Py_IDENTIFIER(fileno);

    if (PyLong_Check(o)) {
        fd = _PyLong_AsInt(o);
    }
    else if ((meth = _PyObject_GetAttrId(o, &PyId_fileno)) != NULL)
    {
        PyObject *fno = PyEval_CallObject(meth, NULL);
        Py_DECREF(meth);
        if (fno == NULL)
            return -1;

        if (PyLong_Check(fno)) {
            fd = _PyLong_AsInt(fno);
            Py_DECREF(fno);
        }
        else {
            PyErr_SetString(PyExc_TypeError,
                            "fileno() returned a non-integer");
            Py_DECREF(fno);
            return -1;
        }
    }
    else {
        PyErr_SetString(PyExc_TypeError,
                        "argument must be an int, or have a fileno() method.");
        return -1;
    }

    if (fd == -1 && PyErr_Occurred())
        return -1;
    if (fd < 0) {
        PyErr_Format(PyExc_ValueError,
                     "file descriptor cannot be a negative integer (%i)",
                     fd);
        return -1;
    }
    return fd;
}

可见 fcntl.fcntl 实际上是可以传入两种类型的参数。这就如同我们在某个函数中先判断传入的参数是 str,还是 int,如果是则转成 str 的那些套路一样。实际上这种问题可以归为

  • 允许传入多种类型,但在代码中尝试类型转换
  • 参数类型单一,在传入前进行转换

因为编程水平有限,我无法衡量出哪种更具优势。但在代码中我将 set_non_blocking 的参数统一为 int

下面的代码也会出现类似的问题

try:
    1 / 0
except ZeroDivisionError as e:
    logger.error(e)

e 是一个 Exception 子类的实例。logger.error 却接收的是一个 str。这里我选择 # type: ignore

内置的坑
#

上一段简单的代码

import socket

def create_nonblock_sock(addr):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # ...
    return sock

这个函数返回什么,如果你使用 monkeytype 去追踪运行时类型则会得到下面的结果

import socket

from socket import socket
from typing import Tuple

def create_listening_sock(addr: Tuple[str, int]) -> socket:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # ...
    return sock

这显然不能 work 了,为什么会产生这种结果呢。因为 socket.socket 是一个类而且恰巧它的类名和模块名相同。WTF,如果代码遵循 PEP8 规范则应该不会出现这种问题。我在代码中是使用 from socket import socket as socket_t 来解决的

尚有不足
#

from collections import UserDict


class Config(UserDict):

    def t(self) -> None:
        self.data['x'] = 'y'  # line 7

上面的代码会报 t.py:7: error: Unsupported target for indexed assignment

这个是因为 mypy 所依赖的 typeshed 有个 bug

# https://github.com/python/typeshed/blob/2d4bb04ab3946e08b6a8802b03c9552905d8b99d/stdlib/3/collections/__init__.pyi#L83
class UserDict(MutableMapping[_KT, _VT]):
    data: Mapping[_KT, _VT]
    def __init__(self, dict: Optional[Mapping[_KT, _VT]] = ..., **kwargs: _VT) -> None: ...
    def __len__(self) -> int: ...
    def __getitem__(self, key: _KT) -> _VT: ...

这里 data 的类型是 Mapping[_KT, _VT],它是只读的。所以应当改为 Dict[_KT, _VT],目前 master 分支中已经修复