给大家留一道Python的思考题

284 阅读6分钟
原文链接: zhuanlan.zhihu.com

今天在写一个检查输入数据类型的小程序,发现这个问题很有意思,可以考验下大家思考问题是否全面,对Python中的数据结构理解是否准确。大家可以思考一下,然后尝试写一下这个方法的实现。

问题的背景是我们从YAML格式的文件中读取了一个配置,我们需要检查配置是否符合格式,并且对于支持输入列表的地方,比如:

scripts:
  - abc

如果列表只有一项,也允许写成

scripts: abc

并且某些字段可能接受多种不同的格式,比如既可以简单输入字符串,也可以输入包含更多信息的对象。需要在检查时检查能否匹配到任意格式之一。

要实现的方法接口是这样的:

def check_type(value, type):
    ...

其中value是一个普通的Python的值,可能包括列表、字典以及它们的嵌套等等。要检查的type比较有意思,它可以像Java之类的泛型一样,指定列表当中的值的类型之类。在这里我们是这么规定的(就不按标准的方式写了,意思到就行):

type ::= python_type
         | None
         | (type, type, ...)
         | ()
         | []
         | [type]
         | {}
         | {"key": type, "key": type, ...}

也就是说type可以是:

  1. 任意合法的Python类型,比如str,int,list等。注意object可以匹配所有可能的值(包括None)
  2. None,只能匹配None。由于Python中None是个特殊的type的实例,也就相当于types.NoneType。
  3. 一个元组,其中每个元素是一个合法的type(注意:不一定是Python类型,而是递归定义的type)。表示可以匹配其中任意一个类型,返回第一个成功匹配的结果。比如说(str, None),可以匹配str类型,或者None。(str, int)可以匹配str或者int。(str, [str])可以匹配str或者[str]。
  4. 特殊的,如果是一个空的元组(),表示任意非None的值。
  5. 一个列表,其中仅包含一个元素,这个元素是个合法的type。它能匹配两种值:如果是一个list或者tuple,则要求其中所有的元素都能匹配内部的type,返回时自动将tuple也转换成list;如果是非list的值,则这个值必须匹配内部的type,在返回时会自动将这个值转换成包含一个元素的列表,比如"abc"在匹配[str]的时候,会成功匹配并返回["abc"]。注意[list]只能匹配[[1]],而不能匹配[1] —— 在有歧义的情况下不允许自动转换。同时注意[[str]]可以匹配"abc"——因为"abc"可以匹配str,进而可以匹配[str],因此可以匹配[[str]]。虽然有点奇怪不过这的确是允许的。
  6. 如果是空列表,等同于[object]——注意,它代表如果这个位置是个非list或tuple的值,就将它转换为包含一个值的列表;否则保留list。
  7. 如果是字典,表示这个值必须是个字典。
  8. 如果这个字典中包含了一些key,则相应的key在字典中对应值的类型会进一步做检查。type字典中每个key对应的值也必须是合法的type。key必须是个字符串。为了让这个功能更有用,我们规定:
    1. 如果key以?开头,比如"?abc",表示"abc"是一个可选的key,可以不存在,但如果存在则必须匹配相应的type。
    2. 如果key以!开头,比如"!abc",表示"abc"是一个必须存在的key,如果不存在则不能成功匹配。
    3. 如果key以~开头,比如"~abc.*def",表示任意匹配~之后的正则表达式的key都需要符合相应的type。例外的情形是,如果某个key已经匹配到了前两种情况,则不再考虑正则表达式的规则,这样可以用"~"简单匹配所有没有定义的其他的key。于是可以用"~": NoMatchType (NoMatchType是一个不可以构造的用户类,所以任意对象都不是它的实例)来防止出现定义以外的key。
    4. 其他情况,视同前面有!,也就是必选的key。

    不知道是否说清楚了,我们来举几个例子:

    >>> check_type("abc", str)
    "abc"
    >>> check_type([1,2,3], [int])
    [1,2,3]
    >>> check_type((1,2,3), [int])
    [1,2,3]
    >>> check_type([1,2,"abc"], [int])
    (Exception...)
    >>> check_type("abc", [str])
    ["abc"]
    >>> check_type(None, str)
    (Exception...)
    >>> check_type(None, (str, None))
    None
    >>> check_type([1,2,"abc",["def","ghi"]], [(int, [str])])
    [1,2,["abc"],["def","ghi"]]
    >>> check_type({"abc":123, "def":"ghi"}, {"abc": int, "def": str})
    {"abc":123, "def":"ghi"}
    >>> check_type({"abc": {"def": "test", "ghi": 5}, "def": 1}, {"abc": {"def": str, "ghi": int}, "def": [int]})
    {"abc": {"def": "test", "ghi": 5}, "def": [1]}

    聪明的读者,你想到应该怎样实现这个方法了吗?自己动脑思考一下,动手练习一下吧,看看你的实现能够处理哪些奇奇怪怪的情况,思考下在哪些输入时会不会有bug。

    小提示

    推荐先思考后再看

    这个问题有一个巨大的陷阱。

    在Python中,像list,dict这样的类型由于保存引用,是可以自己包含自己的,形成递归的结构,比如:

    a = []
    a.append(a)

    按我们的定义,它是一个完全合法的类型,它可以匹配任意多层、任意结构的只由括号组成的数据,比如[[],[[]],[[],[]]]。

    更严重的是,输入数据也可能包含这样的递归。想象一下你的程序执行下面这条语句的时候会怎么样?

    a = []
    a.append(a)
    check_type(a, a)

    某些规则要求将输入数据进行修改然后返回,比如"abc"在匹配[str]的时候需要返回["abc"],这些规则也需要对递归数据生效,比如

    my_type = []
    my_type.append(([str], my_type))
    
    my_data = ["abc"]
    my_data.append(my_data)
    
    check_type(my_data, my_type)
    # should return [["abc"], [["abc"], ...]]

    递归结构修改之后也必须要返回递归结构,这会是一个实现上的难点。

    同一个包含递归定义的对象,可能会匹配到不同的类型,从而生成不同的值,不一定一个源对象就对应一个目的对象:

    my_type = {}
    my_type["abc"] = my_type
    my_type["def"] = [my_type]
    
    my_data = {}
    my_data["abc"] = my_data
    my_data["def"] = my_data
    check_type(my_data, my_type)
    # should return {"abc": {"abc": {...}, "def": [{...}]}, "def": [{"abc": {...}, "def": [{...}]}]}

    你的程序是否能够正确处理这些情形呢?

    题外话,许多人也许不知道,YAML当中真的是可以输入这样的递归的结构的,只要运用anchor就行了:

    >>> import yaml
    >>> data="""
    ... &my_data
    ...   abc:
    ...      abc: 1
    ...      def: *my_data
    ... """
    >>> a = yaml.safe_load(data)
    >>> a
    {'abc': {'abc': 1, 'def': {...}}}
    >>> a['abc']['def']
    {'abc': {'abc': 1, 'def': {...}}}
    其实递归结构处理不好也不能怪大家,你看CPython自己
    >>> a = []
    >>> a.append(a)
    >>> b = []
    >>> b.append(b)
    >>> a == b
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    RecursionError: maximum recursion depth exceeded in comparison