我开始的时候,是给别人改一段生成客户名单的脚本。代码逻辑很简单:把两个表格里的名字合并、去重、按拼音排序。问题在于,这段逻辑在脚本里出现了7次,分别对应7个不同的输入文件。排到第5个文件时发现数据格式有出入,需要改逻辑,于是我把同样的修改做了7遍——最后发现第4遍改错了位置,导致第6个文件的结果还是旧的。这就是典型的没有函数意识造成的问题:代码能跑,但代价是后续每一次改动都要在一堆副本里找同一个bug。
这篇是Python核心语法系列的第四篇,讲函数。函数不光是"把代码包一块"那么简单,它是你从"能写脚本"走到"能写程序"的分水岭。这篇我会把定义规则、参数传递、返回值、作用域、闭包和装饰器这些底层行为都拆开讲,附带我实际踩过的坑。适合刚学完判断和循环、开始接触函数的初学者,也适合已经在写代码但希望把函数写得更规整的人。
1. 为什么必须把逻辑装进函数里
1.1 不用函数的代码长什么样
先看一段反面教材,很多新手的第一周就是这么写过来的:
file1 = open("data_2024.csv", encoding="utf-8") lines1 = file1.readlines()[1:] name_set = set() for line in lines1: parts = line.strip().split(",") name_set.add(parts[0]) file1.close() file2 = open("data_2023.csv", encoding="utf-8") lines2 = file2.readlines()[1:] for line in lines2: parts = line.strip().split(",") name_set.add(parts[0]) file2.close() file3 = open("data_2022.csv", encoding="utf-8") lines3 = file3.readlines()[1:] for line in lines3: parts = line.strip().split(",") name_set.add(parts[0]) file3.close()你是不是已经感觉到不对劲了:打开文件、跳过表头、逐行切割、取第一个字段、加到集合里,这段操作被原封不动复制了三遍。假如我要把分隔符从逗号换成制表符,就得改三处;假如我要跳过空格,又得改三处。这种写法有个专门的说法叫copy-paste编程,它在短时间内效率最高,但它埋的雷会在第一次需求变更时全部引爆。
1.2 函数改写了代码的三种可能性
把同一段逻辑抽成一个函数之后,事情变得很不一样:
def load_names_from_csv(file_path): names = set() with open(file_path, encoding="utf-8") as f: next(f) # 跳过表头 for line in f: parts = line.strip().split(",") names.add(parts[0]) return names all_names = load_names_from_csv("data_2024.csv") all_names |= load_names_from_csv("data_2023.csv") all_names |= load_names_from_csv("data_2022.csv")改动逻辑时只需要动一个地方,所有调用方同时生效。这个变化带来的是三个层面上的提升:
第一是复用。逻辑被写了一次,可以在任意位置、任意次数地使用,不需要复制粘贴。第二是抽象。当你调用load_names_from_csv("data_2024.csv")时,你不需要关心文件是怎么打开、怎么切分、怎么去重的,函数名本身就是一段注释。第三是可测试性。面对一堆复制出来的代码,你很难单独验证某一段是对是错;而一个独立的函数,可以丢给它各种输入,直接检查返回值。
一个简单的判断标准:同样的代码块如果出现了两遍以上,而且除了数据不同之外逻辑完全一样,就应该考虑抽成函数。这不是风格问题,是维护成本的实在差异。
2. def背后的规则:定义、调用与参数传递
2.1 函数的定义和调用
函数用def关键字定义,基本结构是:def+ 函数名 + 括号 + 参数列表 + 冒号,下一行开始是缩进的函数体。函数体里可以写任意多的语句,但如果函数体为空,直接写个pass占位,否则会报语法错误。
def greet(): """打印一句问候""" print("hello") def do_nothing(): pass调用时直接写函数名称加括号:greet()。这里有个容易忽略的细节:函数名本身是变量,真正执行函数体的是后面的括号。如果你写greet不带括号,得到的是一个函数对象;只有greet()才真的运行函数。我做代码审查时见过不少初学者在循环里写if condition: function然后困惑为什么函数没执行,其实就是少了括号。
调用顺序上,函数必须先定义后调用。Python是逐行解释执行的,解释器执行到调用语句时,如果发现这个名字还没有被定义过,就会抛NameError: name 'xx' is not defined。所以你通常会把函数定义放在文件前面,调用代码放在后面,或者在if __name__ == "__main__":块里做入口。
2.2 位置参数、关键字参数和默认值
调用函数传参有两种方式。按位置顺序传的叫位置参数,按参数名传的叫关键字参数:
def describe_person(name, age, city="未知"): print(f"{name},{age}岁,来自{city}") describe_person("张三", 25) # 纯位置参数 describe_person("张三", 25, "北京") # 位置参数覆盖默认值 describe_person(age=25, name="张三") # 关键字参数,顺序可乱 describe_person("李四", age=30, city="上海") # 混合这个例子里city="未知"就是默认参数。定义函数时给了默认值的参数,调用时可以不传;没给默认值的参数,调用时缺了会直接TypeError。
混合使用位置参数和关键字参数时,规则是:位置参数必须在关键字参数之前。describe_person(age=25, "张三")这种写法会报错,因为一旦写了age=25,后面的"张三"没法确认是给谁的。
默认参数的赋值时机也有讲究:默认值是在函数定义那一刻就被计算并绑定到函数上的,不是在每次调用时重新计算。这个概念直接关系到我后面第5章要讲的经典坑之一,这里先记住结论。
2.3 传参的本质:引用还是值?
新手最容易困惑的问题:Python的函数参数到底是传值还是传引用?我的回答是:传的是对象引用。这句话拆开讲:
不可变对象——整数、字符串、元组——作为参数时,函数内部对参数重新赋值,不会影响外部原变量。因为重新赋值相当于把局部变量指向了另一个新对象:
def change_int(x): x = 99 num = 10 change_int(num) print(num) # 10,没变可变对象——列表、字典、集合——作为参数时,函数内部如果修改这个对象的内容(调用append、赋值下标、update等),外部对象也会跟着变,因为外部变量和函数内部的参数指向同一个对象:
def add_item(lst): lst.append("new") my_list = ["a"] add_item(my_list) print(my_list) # ['a', 'new'],变了再看一个容易混淆的变体:
def reset_list(lst): lst = ["new"] # 重新赋值,不是修改对象 my_list = ["a"] reset_list(my_list) print(my_list) # ['a'],没变lst = ["new"]是让局部变量lst指向一个全新列表,和外部my_list再无关系。判断一条金标准:你是"修改了这个传入的对象"还是"重新绑定了参数这个名字"?前者影响外部,后者不影响。
用一张表归纳会清楚:
| 参数行为 | 代码操作 | 对外部变量的影响 |
|---|---|---|
| 修改可变对象本身 | lst.append(x)、d[k]=v | 影响 |
| 对参数名重新赋值 | lst = [...]、x = 99 | 不影响 |
| 向不可变对象做运算 | x = x + 1 | 不影响 |
2.4 *args和**kwargs:处理不确定数量的参数
真实业务里经常会遇到"参数个数不确定"的场景,比如一个求和函数要接收任意多个数。Python用*args接收任意多个位置参数、**kwargs接收任意多个关键字参数:
def total(*args): print(args) # 元组 return sum(args) def print_info(**kwargs): print(kwargs) # 字典 total(1, 2, 3, 4) # 10,args是(1,2,3,4) print_info(name="张三", age=25) # kwargs是{'name': '张三', 'age': 25}命名规则上,args和kwargs只是约定俗成的名字,真正起作用的是*和**。你写成*nums、**options也完全可以,语义反而更清楚。
*args和**kwargs还有一个反向作用:在调用时使用,可以把列表或字典"展开"传入函数:
def f(a, b, c): return a + b + c nums = [1, 2, 3] print(f(*nums)) # 等价于 f(1, 2, 3) data = {"a": 1, "b": 2, "c": 3} print(f(**data)) # 等价于 f(a=1, b=2, c=3)这一点在做函数转发、封装装饰器时特别有用,后面第4章会看到。
3. return和yield:函数输出的两种思想
3.1 return的隐含行为和返回约定
函数可以用return把结果交回调用方。这里先纠正一个大量初学者都有的误解:函数不一定非要有return。如果函数体里没有return,执行完最后一行后函数自动返回None。所以:
def just_print(): print("hello") result = just_print() print(result) # Nonereturn还有一个特性:它不只是返回数据,还是函数的结束标志。return之后的代码不会执行。拿这个特性做早期拦截,能让代码少一层嵌套:
def divide(a, b): if b == 0: return None return a / b比写成if b != 0: return a / b的逻辑更清晰。
返回多个值时,Python实际上是把这些值打包成一个元组返回:
def get_min_max(numbers): return min(numbers), max(numbers) result = get_min_max([3, 1, 4, 1, 5]) print(result) # (1, 5) lo, hi = get_min_max([3, 1, 4, 1, 5]) print(lo, hi) # 1 5建议函数返回值的类型尽量稳定。一会儿返回整数、一会儿返回字符串、一会儿返回None的函数,会让调用方写得非常痛苦。如果确实存在异常情况,与其返回一个含义模糊的None,不如直接让异常抛出去,由调用方决定怎么处理。
3.2 yield与生成器,内存友好从这一行开始
yield是return的兄弟,但思想不同。return返回一个结果就结束函数;yield把当前值交出去,然后函数挂起,等下一次被调用时从挂起的位置继续。包含yield的函数调用后得到的是一个生成器对象:
def countdown(n): while n > 0: yield n n -= 1 for num in countdown(5): print(num)这个特性真正的价值在大数据场景。假如一个文件有100万行,你要统计包含"error"的行数。一次性把文件全读进内存的写法:
with open("big.log", encoding="utf-8") as f: lines = f.readlines() # 100万行,内存直接吃满 for line in lines: if "error" in line: count += 1换成生成器逐行处理:
def log_lines(path): with open(path, encoding="utf-8") as f: for line in f: yield line for line in log_lines("big.log"): if "error" in line: count += 1区别的实质是时间换空间:生成器一次只产生一行,内存里永远只有当前这一行。我第一次用生成器处理几个G的日志文件时,内存占用从几个G降到了一百多兆,从那以后凡是涉及大文件我第一反应就是写成生成器。
判断什么时候用return、什么时候用yield:如果结果数量很少但计算复杂,用return;如果结果是大量数据且不需要一次性全部取出来,用yield。
4. 作用域、闭包与装饰器:函数也是对象
4.1 LEGB查找规则和global/nonlocal
函数内部访问一个变量时,Python按这个顺序去找:局部作用域(Local)→ 外层嵌套函数作用域(Enclosing)→ 全局作用域(Global)→ 内置作用域(Built-in),合称LEGB规则。
x = "global" def outer(): x = "outer" def inner(): x = "inner" print(x) inner() outer() # inner问题来了:如果我不想在inner里新建局部变量,而是要修改outer里的x,怎么办?用nonlocal声明;如果要修改全局的x,用global声明:
x = "global" def outer(): x = "outer" def inner(): nonlocal x x = "changed" inner() print(x) # changed outer() print(x) # global这里我要说句实在话:global能不用就不用。全局变量被函数随意修改,会让代码的执行流程变得极难追踪——你不知道函数是在哪一刻改了这个变量,也不知道还有谁依赖这个变量。我见过一个线上统计脚本,因为某处不小心用global把累计变量改了,导致三天报表全部出错。更稳妥的做法是:函数通过参数接收外部数据、通过return把结果传出来。数据流一目了然,排查问题只需要顺着函数调用链看。
4.2 闭包是怎么记住外层变量的
闭包是指:内层函数引用了外层函数的变量,即使外层函数已经执行完毕,内层函数仍然"记住"那个变量。看个例子:
def make_multiplier(factor): def multiply(x): return x * factor return multiply double = make_multiplier(2) print(double(5)) # 10 print(double(10)) # 20make_multiplier(2)执行完返回了multiply函数,按理说factor已经是过去时了,但double(5)依然正确返回10——因为multiply的闭包里保留了对factor=2的引用。
闭包底层机制可以这样理解:函数在创建时不只是保存自己的代码,还保存了一个"环境记录",里面记录了它引用的外层变量。这就是为什么函数不仅仅是代码,它还携带了数据。
闭包的典型应用场景是正则表达式的预编译封装、配置工厂、计数器等。这里不展开太多,因为它的高级形态就是下面要讲的装饰器。
4.3 装饰器的本质
先说结论:装饰器就是一个函数,它接收一个函数作为参数,返回一个新函数。它是一个语法糖,实际展开形式是:
@timer def work(): pass # 等价于 def work(): pass work = timer(work)看一个最简单可用的例子,给函数加计时能力:
import time from functools import wraps def timer(func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__} 耗时 {elapsed:.4f}s") return result return wrapper @timer def slow_work(): time.sleep(1) return "done" slow_work()拆开看这个装饰器的四个部分:
第一,timer接收原函数func。第二,它内部定义了一个wrapper函数,wrapper用*args, **kwargs把原函数的所有可能参数都接下来,保证原函数的各种调用方式都能被正确转发。第三,wrapper在调用func前后加了计时的逻辑,并且把func的返回值原样返回,这样对调用方来说,被装饰后的函数行为看起来和原来完全一致。第四,timer把wrapper作为新函数返回。
为什么要加@wraps(func)?因为如果不加,slow_work.__name__会变成'wrapper',一些依赖函数元信息的工具(比如调试器、文档工具)会失灵。wraps把原函数的名字、文档字符串等元信息复制到wrapper上,这是写装饰器的标准姿势。
装饰器解决了什么问题?所谓横切关注点。业务逻辑是竖着写的,但日志、权限、性能统计这些逻辑几乎每个函数都需要,如果把每个函数里都写上start = time.time()之类,代码会非常冗余。装饰器把这种跨函数的公共逻辑抽出来,不动原函数代码就能"附加"能力。我自己在项目里放过权限校验装饰器、缓存装饰器、重试装饰器,写业务的人只需要在函数上头加一行@retry(max_times=3),完全不用关心重试逻辑是怎么实现的。
5. 写函数时最容易踩的坑和我的工程习惯
5.1 经典坑之一:默认参数使用了可变对象
第2章提到过,默认值在函数定义时就绑定了。如果把可变对象作为默认值,就会出大事:
def add_item(item, lst=[]): lst.append(item) return lst print(add_item(1)) # [1] print(add_item(2)) # [1, 2] !!!! print(add_item(3)) # [1, 2, 3]第二次调用时,你期待的是一个空的[],但实际上lst还是第一次调用时那个列表,数据被不断累积。这就是因为默认值[]在定义函数时被创建了一次,之后每次调用都复用同一个列表对象。
正确写法是用None作为默认值,函数内部再创建新列表:
def add_item(item, lst=None): if lst is None: lst = [] lst.append(item) return lst这是一个非常经典、面试和实际生产中都会被反复碰到的坑。判断规则简单:默认参数只能用不可变对象,包括数字、字符串、None、元组。如果确实需要一个列表或字典作为默认值,一律用None占位并在函数体内部初始化。
5.2 参数太多、命名不规范和空循环式调用
我见过的另一个普遍问题是一个函数堆了六七个参数,远看像这样:
def create_order(user_name, user_age, user_address, product_name, product_price, product_count, coupon_code, discount_rate):这种参数一旦长得超过5个,调用方很容易搞混顺序,而且函数的扩展性很差。更合理的做法是把相关的参数聚合成一个对象或数据类:
from dataclasses import dataclass @dataclass class UserInfo: name: str age: int address: str @dataclass class ProductInfo: name: str price: float count: int def create_order(user: UserInfo, product: ProductInfo, coupon_code=None, discount_rate=1.0): ...参数少了,函数的语义也更清晰。注意观察:coupon_code和discount_rate给了默认值,因为它们是可选项。一个原则是必选参数放前面,可选参数放后面,这样调用方不需要传入任何可选参数也能调用。
命名上,我建议函数名用动词或动词短语,get_data、parse_response、save_record这类,让人一眼看出它做什么;变量名、参数名用名词。尽量避免foo、bar、temp这类无意义名字,过一个星期你再看自己写的代码,会感谢当初好好命名的自己。
还有一类问题是为调用而调用——写个函数,里面只是一条语句或者干脆是个打印,然后下一行代码立刻调用它。函数本身没有降低复杂度,反而多了一层跳转。有抽象意识是好事,但过度抽象和copy-paste编程一样都值得警惕。一个函数至少应该承载一个明确的、可描述的职责,如果函数名需要用"and"连接两个动作——比如update_and_validate——大概率它应该拆成两个函数。
5.3 我积攒下来的一套函数编写习惯
讲完踩过的坑,说点我现在写函数时坚持的几条习惯,这些是在真实项目里被验证过管用的:
第一是函数尽量短。如果一个函数超过几十行,我必然开始考虑拆分。一个函数做一件事,这句话听起来空,但它直接约束了我对"一件事"的定义。比如"读取并清洗数据"里面其实有"读取"和"清洗"两件事,拆成read_raw_data和clean_raw_data之后,任何一步出错都能很快定位。
第二是类型注解必须写。哪怕是def add(a: int, b: int) -> int:这种,对IDE补全、静态检查、同事阅读代码都有实际帮助。Python是动态类型语言,但这不代表不能给代码加"路标"。我建议从小项目就开始写类型注解,写顺手之后你会觉得没有注解的代码像没系鞋带的鞋,不放心。
第三是函数必须有文档字符串。不用写长篇大论,一句话说明函数干什么、参数是什么、返回值是什么就够了。这个习惯贵在坚持,三个月后回看代码,你最先依赖的不是文件里的笔记而是函数头顶的"""..."""。
第四是入参校验放在函数开头。用一个简短的判断加raise,把不合法情况挡在业务逻辑之前:
def get_ratio(numerator: float, denominator: float) -> float: """计算两个数的比值,分母为0时抛出异常""" if denominator == 0: raise ValueError("分母不能为0") return numerator / denominator这种防御式写法的好处是错误能在源头暴露。否则一个脏数据会在函数深处引发一个莫名其妙的异常,排查成本高得多。
第五也是最后一条:写函数的过程其实是在理清思路。如果我发现一个函数设计不出来,或者参数怎么摆都觉得别扭,通常不是技术问题,而是我自己对业务理解的还不够清楚。这时候先停手,把数据流画一遍、把边界条件列一遍,再回来写,通常就顺了。
函数这一章总结成一句话就是:量小的时候怎么写都能跑,量大、人多、需求变的时候,函数的边界就是你代码的边界。前几章学的变量、判断、循环是零件,函数是第一个把它们真正组装起来的结构体,后续无论是面向对象里的类方法还是各种框架里的回调函数,本质上都在复用这一章的理解。动手去把你已经写过的脚本里重复的代码抽出来,抽出来的过程会比看十遍文档更有用。