ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python函数核心语法:定义、参数、作用域、装饰器详解

Python函数核心语法:定义、参数、作用域、装饰器详解 我开始的时候是给别人改一段生成客户名单的脚本。代码逻辑很简单把两个表格里的名字合并、去重、按拼音排序。问题在于这段逻辑在脚本里出现了7次分别对应7个不同的输入文件。排到第5个文件时发现数据格式有出入需要改逻辑于是我把同样的修改做了7遍——最后发现第4遍改错了位置导致第6个文件的结果还是旧的。这就是典型的没有函数意识造成的问题代码能跑但代价是后续每一次改动都要在一堆副本里找同一个bug。这篇是Python核心语法系列的第四篇讲函数。函数不光是把代码包一块那么简单它是你从能写脚本走到能写程序的分水岭。这篇我会把定义规则、参数传递、返回值、作用域、闭包和装饰器这些底层行为都拆开讲附带我实际踩过的坑。适合刚学完判断和循环、开始接触函数的初学者也适合已经在写代码但希望把函数写得更规整的人。1. 为什么必须把逻辑装进函数里1.1 不用函数的代码长什么样先看一段反面教材很多新手的第一周就是这么写过来的file1 open(data_2024.csv, encodingutf-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, encodingutf-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, encodingutf-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, encodingutf-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(age25, name张三) # 关键字参数顺序可乱 describe_person(李四, age30, city上海) # 混合这个例子里city未知就是默认参数。定义函数时给了默认值的参数调用时可以不传没给默认值的参数调用时缺了会直接TypeError。混合使用位置参数和关键字参数时规则是位置参数必须在关键字参数之前。describe_person(age25, 张三)这种写法会报错因为一旦写了age25后面的张三没法确认是给谁的。默认参数的赋值时机也有讲究默认值是在函数定义那一刻就被计算并绑定到函数上的不是在每次调用时重新计算。这个概念直接关系到我后面第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) # 10args是(1,2,3,4) print_info(name张三, age25) # 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(a1, b2, c3)这一点在做函数转发、封装装饰器时特别有用后面第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, encodingutf-8) as f: lines f.readlines() # 100万行内存直接吃满 for line in lines: if error in line: count 1换成生成器逐行处理def log_lines(path): with open(path, encodingutf-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的闭包里保留了对factor2的引用。闭包底层机制可以这样理解函数在创建时不只是保存自己的代码还保存了一个环境记录里面记录了它引用的外层变量。这就是为什么函数不仅仅是代码它还携带了数据。闭包的典型应用场景是正则表达式的预编译封装、配置工厂、计数器等。这里不展开太多因为它的高级形态就是下面要讲的装饰器。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_times3)完全不用关心重试逻辑是怎么实现的。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, lstNone): 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_codeNone, discount_rate1.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这种防御式写法的好处是错误能在源头暴露。否则一个脏数据会在函数深处引发一个莫名其妙的异常排查成本高得多。第五也是最后一条写函数的过程其实是在理清思路。如果我发现一个函数设计不出来或者参数怎么摆都觉得别扭通常不是技术问题而是我自己对业务理解的还不够清楚。这时候先停手把数据流画一遍、把边界条件列一遍再回来写通常就顺了。函数这一章总结成一句话就是量小的时候怎么写都能跑量大、人多、需求变的时候函数的边界就是你代码的边界。前几章学的变量、判断、循环是零件函数是第一个把它们真正组装起来的结构体后续无论是面向对象里的类方法还是各种框架里的回调函数本质上都在复用这一章的理解。动手去把你已经写过的脚本里重复的代码抽出来抽出来的过程会比看十遍文档更有用。
返回列表