在台子上坐了三周,你接手的策略组发现一个尴尬场面:那段计算 stylized P&L 的代码 (p1 - p0) * shares - fee 被复制粘贴到了七个文件里。今天 A 股印花税口径调整(卖出方向 0.03% 的一个示例性常数,真实税率以监管口径为准),你要在收盘前把这条修改打到七个地方。你改了六个,漏了第七个,周一对账时某条策略的 P&L 偏了三块钱。修法不是「下次更小心」,而是 def:把公式起一个名字、放在一处,让其它六个文件都来调用。这就是本课要讲的事情——再加上作用域规则(确定函数体内的 price 到底指向哪一处绑定),再加上「默认参数陷阱」(mutable default argument trap,过去一年这条坑大约咬过组里一位新人)。
定义一个函数
def 定义一个函数;函数体只有在被调用时才执行。函数若以裸 return 结束或自然走到尾,返回 None。调用时参数默认按位置匹配(positional),写 name=value 时按关键字匹配(keyword),可以混用——位置参数在前、关键字参数在后:
def pnl(p0, p1, shares=100, fee=0.0):
"""Stylized P&L: price move times shares, minus commission."""
return (p1 - p0) * shares - fee
# positional call
pnl(18.30, 18.55) # 25.0
# mixed positional + keyword
pnl(18.30, 18.55, fee=5.565) # 19.435
# all-keyword call site reads like a form
pnl(p0=18.30, p1=18.55, shares=1000, fee=0.0003 * 18.55 * 1000)
shares=100 和 fee=0.0 是默认参数(default parameter values),调用方可省略;省略则取默认值,传入则覆盖。def 行下方的三引号字符串是文档字符串(docstring),help(pnl) 会把它打出来——养成写 docstring 的习惯。
还有两类参数形态在 stdlib 里很常见。*args 把多余的位置参数收成一个元组(tuple of extras),**kwargs 把多余的关键字参数收成一个字典(dict of extras),二者都放在签名末尾:
def trace(label, *args, **kwargs):
print(label, args, kwargs)
trace("pnl", 18.30, 18.55, shares=1000)
# pnl (18.3, 18.55) {'shares': 1000}
签名里裸 / 与 * 形如 def f(a, b, /, c, *, d),声明 a、b 为「仅位置」(positional-only)、d 为「仅关键字」(keyword-only)。读 stdlib 签名时识得即可,自己写得不多。
LEGB:Python 怎么解析名字
函数体里出现的名字按四级作用域顺序查找:L 局部(函数自己的绑定)、E 闭包(外层函数)、G 全局(函数所在模块)、B 内置(print、len、range 这类始终在场的名字)。先命中即胜。
threshold = 0.05 # module-global
def classify(r):
if r > threshold: # threshold not bound locally -> falls through to global
return "up-shock"
return "ok"
关键陷阱:函数体内的裸赋值会创建一个局部新名字,而不会去改外层的同名变量。def bump(): threshold = 0.10 只是在函数内造了一个 threshold,模块级的那一份纹丝不动。要在外层重新绑定,需显式 global threshold(模块级)或 nonlocal counter(外层函数)写在函数体最上方。日常代码里,优先用参数传入、用 return 传出;global/nonlocal 是非常态的逃生口。
函数即一等公民
Python 里函数是一等对象(first-class object):可以绑定到名字、作为参数传递、作为返回值、装进列表。教科书上的例子是 sorted(seq, key=...)——它接收一个单参函数,按其返回值排序:
stocks = [
{"code": "600519", "turnover": 5_300_000},
{"code": "000001", "turnover": 3_100_000},
{"code": "510300", "turnover": 9_800_000},
]
# pass the function itself (note: no parentheses)
sorted(stocks, key=lambda s: s["turnover"])
lambda s: s["turnover"] 是单表达式匿名函数,等价于 def _key(s): return s["turnover"] 去掉名字。lambda 留给 sort/map/filter 这类单行 key 函数;一旦函数体超过一行或想写 docstring,立刻退回 def。
默认参数陷阱
默认参数值在 def 执行那一刻仅求值一次,不在每次调用时重新求值。对 0.0、"flat" 这种不可变默认值毫无影响;但默认值若是可变对象(如 list),就会在多次调用间累积状态:
def push(x, acc=[]):
acc.append(x)
return acc
push(1) # [1]
push(2) # [1, 2] — the default list from the def-time was reused
push(3) # [1, 2, 3]
绝大多数情况下这都不是你想要的。修法是惯用的 acc=None 模式:
def push(x, acc=None):
if acc is None:
acc = []
acc.append(x)
return acc
每次调用时若未传入 acc,函数体里就新建一只空 list。所有 Python 风格指南都写了这条,原因正是因此——背成肌肉记忆。
练习
Exercise
Write a function moving_total(values, window=3) that returns a list whose i-th entry is the sum of values[max(0, i-window+1):i+1]. Then call it as moving_total([10, 20, 30, 40, 50]) and confirm the result is [10, 30, 60, 90, 120].
提示
i ∈ range(len(values)),取切片 values[max(0, i - window + 1) : i + 1],然后 sum(...)。max(0, ...) 把起点钉在左边界,处理前几个元素。提示
i = 0 时切片是 values[0:1] → [10],和 10;i = 2 时切片是 values[0:3] → [10, 20, 30],和 60。两端对就基本对了。衔接下一课
你已经能把一段逻辑命名、参数化、传来传去,并且绕开默认参数陷阱。但目前你一次只能拿着一个数:一个价格、一个收益、一个 ticker。沪深300 真实交易台一次面对的是一串 tick、一只持仓字典、一只代码集合。下一课覆盖 Python 四种内置容器(list、tuple、dict、set)、索引切片、并发遍历的 enumerate/zip/dict.items(),以及把三行 for-with-append 收成一行可读表达式的推导式(comprehension)。学完那一课,你会把本课里一半的循环改写成一行——而且不损失任何可读性。延伸阅读:廖雪峰函数节、《流畅的 Python》第 2 版第 7 章。