【python源码探究】dict的key不能是list

简介:

云栖号资讯:【点击查看更多行业资讯
在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来!


一条面试题

本文源自一条最常见的python面试题:

问:list对象能不能做dict的key?tuple呢?

答:不能,因为list是Mutable类型,不能作为dict的key。而tuple是Immutable类型,可以作为dict的key。

咱们做个实验,从dict的赋值代码抛错来感受一下上面的答案:

>>> l=[1,2,3]
>>> d[l]=123
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

抛错已经说明白了,因为list是unhashable类型,所以能否hashable就是关键点,再来看list与tuple之间在hashable上的区别:

mappingproxy({'__repr__': <slot wrapper '__repr__' of 'list' objects>, '__hash__': None, ...})
>>> tuple.__dict__
mappingproxy({'__repr__': <slot wrapper '__repr__' of 'tuple' objects>, '__hash__': <slot wrapper '__hash__' of 'tuple' objects>, ...})

这里注意到魔法方法__hash__,在list类型中__hash__实现为None,而tuple持有对应的实现。我们大胆猜测一下,tuple之所以hashable是因为实现了__hash__,再做个验证:

>>> l.__hash__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'NoneType' object is not callable
>>> t=(1,2,3)
>>> t.__hash__()  # 从输出的形式来看,这很可能就是对象的hash值
2528502973977326415

这样子的黑盒试验终究是没办法让人放心,难道真的只是因为__hash__方法的实现与否吗?尝试看list.__hash__和tuple.__hash__的源码,由于函数是由python解释器直接实现,所以无法得到更进一步的结论。为了整明白这个问题,这里拿官网下载python 3.8.2 的CPython源码继续深入探究。我们有两种思路:

  • 知道dict的赋值是调用魔法方法__setitem__,追溯该方法一层一层往下看,寻找出关键判断hashable的条件
  • 按照dict赋值的抛错文案进行全局搜索,直接定位相关代码

定位抛错文案

显然第二种反推思路效率会更高一些(实际上第一种思路是不通的,因为dict.__setitem__下面就是C源码,在python层没法得到更多信息),通过全局搜索unhashable type这个文案定位到两处python的C源码,代码如下:

static Py_hash_t PyCData_nohash(PyObject *self)
{
    PyErr_SetString(PyExc_TypeError, "unhashable type");
    return -1;
}

// ******************************分割线****************************** //

// 文件位置:Objects/object.c
Py_hash_t PyObject_HashNotImplemented(PyObject *v)
{
    PyErr_Format(PyExc_TypeError, "unhashable type: '%.200s'",
                 Py_TYPE(v)->tp_name);
    return -1;
}

很容易就可以判断源代码是Objects/object.c文件的实现,因为看unhashable type文案后面还跟有python对象的类型名,这样才可能打印出完整的抛错信息:

1

至此,我们知道了PyObject_HashNotImplemented()函数就是dict在赋值操作时,key为Mutable类型导致抛错的源头,接着只要跟踪这个函数在哪里被调用就可以知道dict具体判断key是否hashable的逻辑了。实际上,函数名PyObject_HashNotImplemented给了很多信息,隐约告诉我们,答案很可能就是一开始的推测——__hash__没有实现。

根据调用链逐步往上摸

顺腾摸瓜,寻找`PyObject_HashNotImplemented()函数被调用的地方,源码中有很多地方都有调用,但这个函数引起了我的注意,它的实现中带有对类型的hash函数存在与否的判断逻辑,代码如下:

Py_hash_t PyObject_Hash(PyObject *v)
{
    PyTypeObject *tp = Py_TYPE(v);
    if (tp->tp_hash != NULL)
        return (*tp->tp_hash)(v);
    /* To keep to the general practice that inheriting
     * solely from object in C code should work without
     * an explicit call to PyType_Ready, we implicitly call
     * PyType_Ready here and then check the tp_hash slot again
     */
    if (tp->tp_dict == NULL) {
        if (PyType_Ready(tp) < 0)
            return -1;
        if (tp->tp_hash != NULL)
            return (*tp->tp_hash)(v);
    }
    
    // 备注:如果tp_hash为NULL,就会调用PyObject_HashNotImplemented导致抛错
    /* Otherwise, the object can't be hashed */
    return PyObject_HashNotImplemented(v);
}

ok,咱们继续寻找PyObject_Hash()被调用的地方,感觉离真相已经不远了,同样,整个源码中存在大量对它的调用,有很多C文件从名字上一眼就能识别出跟dict类型不相关,最终这个特殊的C文件名和函数名吸引了我,简直就是明明白白告诉我,这里就是dict的C实现😂,代码如下:

int PyDict_SetItem(PyObject *op, PyObject *key, PyObject *value)
{
    PyDictObject *mp;
    Py_hash_t hash;
    if (!PyDict_Check(op)) {
        PyErr_BadInternalCall();
        return -1;
    }
    assert(key);
    assert(value);
    mp = (PyDictObject *)op;
    if (!PyUnicode_CheckExact(key) ||
        (hash = ((PyASCIIObject *) key)->hash) == -1)
    {
        // 备注:获取key的hash函数,如果hash函数为NULL(参考 PyObject_Hash 的实现),则返回 -1(同时抛出类型错误)
        hash = PyObject_Hash(key);
        if (hash == -1)
            return -1;
    }

    if (mp->ma_keys == Py_EMPTY_KEYS) {
        return insert_to_emptydict(mp, key, hash, value);
    }
    /* insertdict() handles any resizing that might be necessary */
    return insertdict(mp, key, hash, value);
}

其实到了这里已经算真相大白了,已经找到dict的set函数C实现了,里面有判断key是否可hash的逻辑,如果key不可hash则向上返回-1。不过本着打破砂锅问到底的心态,我们来看看这个PyDict_SetItem()究竟会在哪里被调用吧🤔。

// 为了阅读的方便,下面的变量、函数摆放的前后顺序做了调整

// 1.跟踪PyDict_SetItem,这里封装dict赋值与删值的,对外暴露单一入口
static int dict_ass_sub(PyDictObject *mp, PyObject *v, PyObject *w)
{
    if (w == NULL)
        return PyDict_DelItem((PyObject *)mp, v);
    else
        return PyDict_SetItem((PyObject *)mp, v, w);
}

// 2.跟踪dict_ass_sub,这是保存dict函数指针的数组
static PyMappingMethods dict_as_mapping = {
    (lenfunc)dict_length, /*mp_length*/
    (binaryfunc)dict_subscript, /*mp_subscript*/
    (objobjargproc)dict_ass_sub, /*mp_ass_subscript*/
};

// 3.跟踪dict_as_mapping,最终发现PyDict_Type里存了这个数组变量
PyTypeObject PyDict_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "dict",
    // ...
    &dict_as_mapping,                           /* tp_as_mapping */
    PyObject_HashNotImplemented,                /* tp_hash */
    // ...
    dict_new,                                   /* tp_new */
    PyObject_GC_Del,                            /* tp_free */
};

// 4.顺带再确认PyDict_Type被调用的地方,dict_new函数应该就是python dict分配内存时的调用,至此整个追溯过程就结束了
static PyObject *
dict_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    PyObject *self;
    PyDictObject *d;

    // 为dict类型分配内存空间
    assert(type != NULL && type->tp_alloc != NULL);
    self = type->tp_alloc(type, 0);
    if (self == NULL)
        return NULL;
    d = (PyDictObject *)self;

    /* The object has been implicitly tracked by tp_alloc */
    if (type == &PyDict_Type)
        _PyObject_GC_UNTRACK(d);

    d->ma_used = 0;
    d->ma_version_tag = DICT_NEXT_VERSION();
    d->ma_keys = new_keys_object(PyDict_MINSIZE);
    if (d->ma_keys == NULL) {
        Py_DECREF(self);
        return NULL;
    }
    ASSERT_CONSISTENT(d);
    return self;
}

另外再找了一下,在文件Objects/odictobject.c下发现了这样的注释说明:

2

虽然odictobject.c与dictobject.c是两种不同用处的dict的实现,但讲道理两种实现对外的api应该接近一致,所以上面的注释侧面说明了dict的赋值函数就是PyDict_SetItem。

推断验证

上面的过程让我们明确了在dict赋值key时会判断是否实现hash函数,我们还可以在list和tuple的角度验证一下。list是Mutable类型,它不实现hash函数,tp_hash指向函数PyObject_HashNotImplemented;tuple是Immutable类型,它实现了hash函数,tp_hash指向对应的hash函数。代码如下,结果符合预期:

    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "tuple",
    // ...
    (hashfunc)tuplehash,                        /* tp_hash */
    // ...
};
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "list",
    // ...
    PyObject_HashNotImplemented,                /* tp_hash */
    // ...
};

总结

咱们追了好阵子源码,该总结一下了。

原问题:为什么dict的key不能是list?

引申问题:为什么dict的key不能是可变类型,可变与不可变类型的区别是啥?

结论:通过追溯CPython源码,发现对dict赋值时会调用PyDict_SetItem检查key对象是否实现hash函数,如果没实现hash函数则抛错并提示类型unhashable(通过函数指针是否为NULL来判断是否实现hash函数)。这里还引出了Mutable与Immutable类型,但本文暂未确定两者除了hash函数外还有无更多区别。

【云栖号在线课堂】每天都有产品技术专家分享!
课程地址:https://yqh.aliyun.com/live

立即加入社群,与专家面对面,及时了解课程最新动态!
【云栖号在线课堂 社群】https://c.tb.cn/F3.Z8gvnK

原文发布时间:2020-04-21
本文作者:枉信焕
本文来自:“掘金”,了解相关信息可以关注“掘金”

相关文章
|
2月前
|
Python
用python进行视频剪辑源码
这篇文章提供了一个使用Python进行视频剪辑的源码示例,通过结合moviepy和pydub库来实现视频的区间切割和音频合并。
55 2
|
22天前
|
测试技术 开发者 Python
在 Python 中创建列表时,应该写 `[]` 还是 `list()`?
在 Python 中,创建列表有两种方法:使用方括号 `[]` 和调用 `list()` 函数。虽然两者都能创建空列表,但 `[]` 更简洁、高效。性能测试显示,`[]` 的创建速度比 `list()` 快约一倍。此外,`list()` 可以接受一个可迭代对象作为参数并将其转换为列表,而 `[]` 则需要逐一列举元素。综上,`[]` 适合创建空列表,`list()` 适合转换可迭代对象。
在 Python 中创建列表时,应该写 `[]` 还是 `list()`?
|
1月前
|
自然语言处理 Java 编译器
为什么要看 Python 源码?它的结构长什么样子?
为什么要看 Python 源码?它的结构长什么样子?
23 2
|
1月前
|
Python
源码解密 Python 的 Event
源码解密 Python 的 Event
33 1
|
1月前
|
数据采集 前端开发 Python
Python pygame 实现游戏 彩色 五子棋 详细注释 附源码 单机版
Python pygame 实现游戏 彩色 五子棋 详细注释 附源码 单机版
63 0
|
1月前
|
索引 Python
Python列表操作-推导式(List Comprehension)
Python列表操作-推导式(List Comprehension)
|
2月前
|
Python
Python量化炒股的获取数据函数— get_billboard_list()
Python量化炒股的获取数据函数— get_billboard_list()
|
3月前
|
Ubuntu Linux 数据安全/隐私保护
使用Cython库包对python的py文件(源码)进行加密,把python的.py文件生成.so文件并调用
本文介绍了在Linux系统(Ubuntu 18.04)下将Python源代码(`.py文件`)加密为`.so文件`的方法。首先安装必要的工具如`python3-dev`、`gcc`和`Cython`。然后通过`setup.py`脚本使用Cython将`.py文件`转化为`.so文件`,从而实现源代码的加密保护。文中详细描述了从编写源代码到生成及调用`.so文件`的具体步骤。此方法相较于转化为`.pyc文件`提供了更高的安全性。
114 2
|
5月前
|
安全 Java
java线程之List集合并发安全问题及解决方案
java线程之List集合并发安全问题及解决方案
853 1
|
4月前
|
Java API Apache
怎么在在 Java 中对List进行分区
本文介绍了如何将列表拆分为给定大小的子列表。尽管标准Java集合API未直接支持此功能,但Guava和Apache Commons Collections提供了相关API。