目录
引言
日志打印的问题
返回参数默认值的问题
问题产生的原因
关于参数默认值的最佳实践
总结
引言
在前一篇关于Python函数的文章中,我们介绍了函数的基本使用、函数的默认参数、lambda函数的用法,相当于对Python中的函数有了一个入门的介绍。
今天这篇文章打算就上一篇提到的函数的参数默认值,进一步展开来讲。因为,这个看似简单实用的技巧,如果不理解相关的底层细节,可能反而导致意想不到的BUG。
本文先以两个应用参数默认值可能导致的问题来展开,然后探究相关问题产生的底层原理,最后给出应对的最佳实践。
日志打印的问题
假如现在有这样一个日志记录的需求,我们这样简化模拟一下:
1、打印日志只需要记录日志内容,及对应的时间;
2、默认情况下,记录的时间,为当前日志打印的时间即可;但是,不排除业务流程处理时间较长,可能需要记录业务开始时间,而非当前时间的场景,所以要支持传入一个时间的需求。
根据上面的需求,参数默认值,是我们最先想到的,所以,可以定义如下函数:
from datetime import datetime
def log(msg, when=datetime.now()):
print(f"{when}: {msg}")
但是,实际执行的结果,可能不是我们想要的:
from datetime import datetime
import time
def log(msg, when=datetime.now()):
print(f"{when}: {msg}")
log('订单001:下单成功')
time.sleep(5)
log('订单001:用户付款成功')
执行结果:
明明等待了5秒,为啥日志打印的时间都是相同的……
返回参数默认值的问题
有些情况下,我们函数需要返回一个容器对象,用户需要基于这个容器进行,进一步的操作,使用了参数默认值可能也是存在问题的。
比如,有如下场景:
api传入的请求参数以字符串的形式拼接在一起,我们需要将其解析为字典格式,并返回,如果这个api没有请求参数,则返回一个空字典。用户需要对返回的请求参数字典进行进一步的处理,比如从cookie中提取信息,比如userid等,加入到请求参数字典中。
根据需求,可能会选择定义一个如下的函数:
def parse_args(request_url, default={}):
if '?' in request_url:
args = request_url.split('?')[1]
return {arg.split('=')[0]: arg.split('=')[1] for arg in args.split('&')}
return default
正常情况下,应该都是没有问题的,但是,如果走了默认情况下,可能存在问题:
from rich import print
from rich.console import Console
console = Console()
def parse_args(request_url, default={}):
if '?' in request_url:
args = request_url.split('?')[1]
return {arg.split('=')[0]: arg.split('=')[1] for arg in args.split('&')}
return default
# 用户1的请求
args1 = parse_args('/api/goods/detail?goods_id=123')
args1['userid'] = '1'
# 用户2的请求
args2 = parse_args('/api/goods/list')
args2['userid'] = '2'
# 用户3的请求
args3 = parse_args('/api/store/list')
args3['userid'] = '3'
console.rule('用户1请求参数')
print(args1)
console.rule('用户2请求参数')
print(args2)
console.rule('用户3请求参数')
print(args3)
执行结果:
用户2和用户3都是无参数请求api,可是最终处理完成后,两个请求中的userid都变成了3……
问题产生的原因
不管是日志打印中的默认当前时间,还是请求参数解析的返回空的参数字典,似乎都出现了我们预料之外的情况:函数的多次重复调用,默认值参数的默认值,我们以为在每次发生时,都会变化,我们理解的是无固定值的默认值,可是函数似乎给我们固定住了……
原因在于,参数默认值如果是一个表达式,这个表达式会在函数定义时,计算出来,并生成一个对象,存储下来,以后的每次调用,参数的默认值都指向一个相同的对象。
通过字节码,我们可以更加清晰地看到这一点:
以日志打印为例:
通过如上的字节码与源码的对照,可以轻易发现,函数参数的默认值的计算,确实是在函数定义时完成的,函数调用时,直接取之前计算出来的结果,不会重新计算。
此外,即使不看对应的字节码,我们还有更简单的方法,来看到参数默认值的情况:
由于Python中一切皆对象,函数也是一个特殊的对象,函数对象,有自身的一些属性,其中一个属性就是__defaults__,以元组的形式存储了函数的参数默认值:
from datetime import datetime
import time
def log(msg, when=datetime.now()):
print(f"{when}: {msg}")
print(log.__defaults__)
time.sleep(5)
log('订单001:下单成功')
time.sleep(5)
log('订单001:用户付款成功')
如上代码,我们在调用函数log()之前,首先输出了log函数对象的__defaults__属性,然后是两次函数调用。
执行结果如下:
两次函数调用,输出的参数默认值,均为函数对象在定义时,存储在函数对象的__defaults__中的默认值。
同样的,在请求参数解析的函数中,我们定义的默认的请求参数空字典对象,也是在定义时生成的。我们可以通过查看函数对象的参数默认值对象的id,以及args2、args3的id,清楚地看到这一点:
from rich import print
from rich.console import Console
console = Console()
def parse_args(request_url, default={}):
if '?' in request_url:
args = request_url.split('?')[1]
return {arg.split('=')[0]: arg.split('=')[1] for arg in args.split('&')}
return default
# 用户1的请求
args1 = parse_args('/api/goods/detail?goods_id=123')
args1['userid'] = '1'
# 用户2的请求
args2 = parse_args('/api/goods/list')
args2['userid'] = '2'
# 用户3的请求
args3 = parse_args('/api/store/list')
args3['userid'] = '3'
console.rule('用户1请求参数')
print(args1)
console.rule('用户2请求参数')
print(args2)
console.rule('用户3请求参数')
print(args3)
# 新增3行字段,验证参数默认值对应的字典对象,是同一个对象
console.rule('参数默认值对象id')
print(id(parse_args.__defaults__[0]))
print(id(args2))
print(id(args3))
执行结果:
可以看到,3个对象的id是相同的,印证了参数默认值在函数定义时生成对象,并存储到函数对象的__defaults__属性中的论断。
关于参数默认值的最佳实践
关于以上两种场景中,涉及到参数默认值使用中的异常情况,一个相对较好的解决方案是,使用None默认值,并结合docstirng进行使用说明。
同样以日志打印为例,进行代码的改写,以示说明:
from datetime import datetime
import time
def log(msg, when=None):
"""
根据调用传参,进行日志的打印
:param msg: 日志内容
:param when: 日志记录时间,默认为None,表示记录当前时间
:return:
"""
if when is None:
when = datetime.now()
print(f"{when}: {msg}")
print(log.__defaults__)
time.sleep(5)
log('订单001:下单成功')
time.sleep(5)
log('订单001:用户付款成功')
执行结果:
这次执行,获得了我们想要的结果。
总结
虽然函数参数的默认值,语法很简单,使用很方便。但是,稍微一不留意,可能也会导致一些异常的结果。
基础很简单,但也很重要。
真正掌握基础并不简单,只是把语法记住了,并不是真正掌握。
遇到问题不要慌,关注底层的细节,能够更加容易的定位问题所在,并理解问题的产生。
而所谓的编程学习,学的并不是写几行代码,而是通过写代码,逐渐习得并强化自己定位问题、解决问题的能力。