「大家来找茬」,你知道问题所在吗?
会写这篇文章的原因并非是我想要水一篇文章,而是因为我确确实实被这个合乎语法的「Bug」坑了将近一个小时。如果正在读这篇文章的你,不看标题给出的答案,你会认为下面两段代码有什么不同嘛?(代码片段已稍作修改)
片段一:
import pandas as pd
from myproject.conf import settings
class MyDataObject:
def __init__(self, sql_result):
self.data = pd.DataFrame(sql_result)
defalgorithm_handler(self):
ifnot self.data.empty:
try:
class_val = settings.CLASSVALUES #存放指定类别的列表
keywords = settings.KEYWORDS #文本描述中的关键词列表
df = (
self.data
.query(
"""
MY_CLASS.isin(@class_val) &
DESC_CONTENT.str.contains(r'|'.join(@keywords))
"""
)
.groupby("grp")
.agg({"RECORD_ID": len})
.sort_values(ascending = False)
.reset_index()
.rename(columns = {"RECORD_ID": "FREQ"})
.assign(TYPE = "XX")
.loc[:, [...]]
)
return df
except ValueError:
log.exception(...)
片段二:
import pandas as pd
from myproject.conf import settings
class MyDataObject:
def __init__(self, sql_result):
self.data = pd.DataFrame(sql_result)
def algorithm_handler(self):
if not self.data.empty:
try:
class_val = settings.CLASSVALUES, #存放指定类别的列表
keywords = settings.KEYWORDS #文本描述中的关键词列表
df = (
self.data
.query(
"""
MY_CLASS.isin(@class_val) &
DESC_CONTENT.str.contains(r'|'.join(@keywords))
"""
)
.groupby("grp")
.agg({"RECORD_ID": len})
.sort_values(ascending = False)
.reset_index()
.rename(columns = {"RECORD_ID": "FREQ"})
.assign(TYPE = "XX")
.loc[:, [...]]
)
return df
except ValueError:
log.exception(...)
最近在做的项目是基于 FastAPI 框架构建一个算法端分析的 API 接口,因此在当中会集成类似于 Pandas 以及其他深度学习的模型。我的部分主要是负责 Pandas 的部分,一些用 SQL 或 ORM 不太好写的部分都通过 Pandas 来进行处理,最后将结果以 JSON 的形式通过 API 接口返回给 Java 后端和前端。
而恰巧这个出错的部分就是上述 Pandas 这一部分的代码。因为是在使用DataFrame.query()
时,由于表达式字符串不能够被 IDE 很好地解析,因此也不容易暴露出问题,这也就是在我以前这篇文章《高性能 Pandas 方法:query 和 eval》里所谈到的缺点。
在这出错的部分里,我通过链式调用的写法将处理和计算的逻辑写好并加以封装。在调试初期能够准确地输出DataFrame
结果,所以在后期出错时,我几乎是从头到尾进行比对代码好几次都还没发现什么问题,就可能会和没看标题的你一样,丝毫没办法察觉当中有什么错误。
那么错误究竟是由什么引发的呢?
这两段代码差异的地方其实就在class_val = settings.CLASSVALUES
这一行代码后面的「,」逗号。但是因为这个逗号则改变了我在配置文件中这么用于存放特定分类的列表的class_val
变量的数据结构,加之表达式字符串的问题,所以无法能够第一时间发现问题所在。
当我发现其错误竟然是由一个小小的逗号「,」所导致时,我内心几乎是崩溃的。
谁能想到,这样一个小小的逗号分布在一个写满了许多代码的*.py
文件中以不起眼地姿态埋伏着,若不是靠着 Pycharm 的断点功能 Debug,仅凭借肉眼我们是几乎无法发现,找错找得几近绝望。
这个带有逗号的特殊写法在 Python 解释器中并不会报错,而且是符合语法的,这也就是为什么我从头到尾完整核对代码后觉得准确无误却依然存在错误的原因。正如它在 Python 的官方文档里是这样被描述的:
Ugly, but effective.
这个逗号它导致了什么问题
找问题的最好方式就是直接将其输出打印看看它是什么东西。
In [1]: a = [1,2],
In [2]: a
Out[2]: ([1, 2],)
In [3]: b = [1,2]
In [4]: b
Out[4]: [1, 2]
正如你所见,从符号上来看,由于我在a = [1,2],
后面多追加了一个逗号,最终的结果是变量a
存储的是包含列表[1,2]
的一个元组;而变量b
则仍旧是一个列表。
因此这个逗号导致了我原本想要传入的class_val
列表转换成了一个元组,因此表达式字符串里的表达式应该写成MY_CLASS.isin(@class_val[0])
通过索引把列表取出来的形式才是正确;而DataFrame.isin()
方法是将每个值和传入的比较对象的元素进行比对,但由于整个列表被包含进了元组里,所以实际比较的是元组中的元素,即整个列表,而不是列表中的单个元素。
但这个逗号的写法并不仅仅只局限于列表,我们轻易就能复现:
In [5]: a = 1, ; a
Out[5]: (1,)
In [6]: a = "1","2", ; a
Out[6]: ('1', '2')
In [7]: a = {"age":10}, ; a
Out[7]: ({'age': 10},)
In [8]: a = "10", [1,2,], ; a
Out[8]: ('10', [1, 2])
可以看到,只要在赋值表达式结尾留有逗号的,都会被解释器解析成元组并进行赋值。这个语法对于经常复制粘贴代码或者像我有在列表或者字典里留下一个尾逗号习惯的人来说,是极具杀伤力的。
因为在通常的复制粘贴的过程中,很有可能我们就会在不经意间把用于分隔的逗号给一起复制过来;又或者是为了让 Git 能更好比对差异而在行尾留下的逗号,但却在不注意的时候调整代码结果逗号遗留没有清理。这两种场景或者情况必然会导致我们在操作时抛出异常和报错。
逗号在赋值表达式中的作用
从前面所述我们可以知道在赋值表达式后面加上一个逗号,它确实是个创建元组的方式。无论是初学者还是 Python 老鸟都知道,创建元组的形式有两种,一种是通过圆括号来创建,如mytuple = (1,2)
;另一种是通过关键字tuple
关键字来转换数据类型并创建,如mytuple = tuple([1,2])
。而这个逗号创建元组的方式其实可以算是第三种,但它本质上是等价于「打包」
(Packing)。
因为 Python 具备返回多个值的特性,所以每当我们在一个函数或者方法中返回多个值时,这些值就会被打包成一个元组进行返回:
In [18]: def tuple_val():
...: return 1,2,3
...:
In [19]: type(tuple_val())
Out[19]: tuple
我们也知道可以通过一行进行多个变量的赋值进行接收:
In [23]: a, b, c = tuple_val()
In [24]: print("{a}-{b}-{c}".format(a=a, b=b, c=c))
1-2-3
这样的方式都很常见,但是当我们将留有为逗号的表达方式从右边转移到左边时,看看会发生什么:
In [26]: a, = [1,]
In [27]: a
Out[27]: 1
In [29]: a, = [1,2]
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-29-d160930f24aa> in <module>
----> 1 a, = [1,2]
ValueError: too many values to unpack (expected 1)
这个ValueError
的报错信息已经告诉了我们缺少了变量用以接受解包(unpacking)的值。所以这个逗号所起的作用已经显而易见了,官方文档对元组的定义是:
A tuple consists of a number of values separated by commas
即元组是包含了一组由逗号分隔的值。但这个定义的语境更加确切地说是在赋值过程中或赋值表达式中。
我们可以发现如果不是在赋值表达式过程中,那么也不会出现解析为元组的情况:
ValueError: too many values to unpack (expected 1)
In [30]: a = [
...: 1,
...: 2,
...: 3,
...: ]
In [31]: a
Out[31]: [1, 2, 3]
In [32]: def tuple_test(a, b, c,):
...: print(a, b, c,)
...:
In [33]: tuple_test(1,2,3,)
1 2 3
结语
这次的错误也暴露出像 Python 这样动态语言的弊端,语法太过灵活而且缺少编译过程,很难在程序运行时将错误暴露出来;另一方面,后续我发现规避掉这个问题最好的办法就是加上 「Type Hint」就能及时发现问题。Type Hint 其实对于有过 C/C++、Java、Go 等这类编译型语言的朋友并不陌生,其实就是类型注解之类的东西:
from typing import Union
f: str = "hello, world"
def foo(
x: Union[int, float],
y: Union[int, float],
) -> Union[int, float]:
return x + y
只要事先进行类型注解,那么借助 IDE(我用的是 Pycharm),就能直接暴露出问题或给出相对应的提示:
现在火热的 TypeScript 也是在 JavaScript 的基础上加上了静态类型提示,使得在编译时能够及时发现问题。
可能对于写惯 Python 的人来说多写一些类型注解是那么的繁琐,也有人会觉得用 Python 的最初目的就是能够通过简单的代码来实现其他编译型语言的一些功能,如果还要为这样的动态语言加上一些加锁,那为什么不直接用 Java 或者是 Go 这样的编译型语言呢?
对我个人来说,使用 Python 的初心确实是因为它简单易学好上手,但事上没有十全十美的东西,简单,就意味着要付出代价。至于这个代价是什么,每个人的解读是因人而异。
但为了让自己少掉点头发、少加些班、程序少些 Bug,还是由衷希望协作的同事甚至是每一个 Pythonic 都能采用 Type Hint 的写法,让代码更加健壮的同时也能少踩一些坑。