拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python函数定义与调用:从重复脚本到模块化复用

很多刚开始接触Python的朋友都经历过这样一个阶段脚本越写越长复制粘贴的代码块越来越多改一个逻辑就要全局搜索替换好几处。这个阶段的我对函数定义与调用几乎没什么概念直到被重复代码折磨到怀疑人生才慢慢明白函数不是一道语法题而是一道工程题。函数是Python里组织代码、实现复用的最小单元也是把“能跑的代码”变成“好用的代码”的分水岭。这篇文章我就围绕函数定义与调用展开先说清楚语法骨架再讲透参数、返回值、作用域这几个核心知识点最后带大家把一个真实场景里的乱脚本重构成可复用模块。不管你是刚开始学Python的在校学生还是写了一阵子脚本但总觉得代码像毛线团的开发者这篇内容应该都能给你一些参考。# 先看一个直观对比同样一件事不写函数和写函数的差别 dates [2024-01-05, 2024-02-14, 2024-03-20] # 不写函数每个地方都要重复一遍转换逻辑 for d in dates: parts d.split(-) print(f{parts[1]}/{parts[2]}/{parts[0]}) # 写函数定义一次到处调用 def format_date(d): parts d.split(-) return f{parts[1]}/{parts[2]}/{parts[0]} for d in dates: print(format_date(d))这个例子虽然简单但已经能看出函数带来的差异一处定义、多处复用、改格式只动一行。接下来进入正题先从反面教材说起把函数存在的意义彻底讲清楚。1. 从一个改到怀疑人生的脚本说起函数到底解决了什么问题1.1 没有函数时的代码长什么样正面教材之前先看一个反面教材。这是我早期写的一个数据统计脚本片段当时就是从网上找了个模板然后一段段复制、修改凑出了下面的逻辑# 反面教材一段重复到极致的代码 sales_a [1200, 1500, 980, 1340] total_a 0 for s in sales_a: total_a s print(fA组销售总额{total_a}平均{total_a / len(sales_a)}) sales_b [2100, 1800, 1600, 1450] total_b 0 for s in sales_b: total_b s print(fB组销售总额{total_b}平均{total_b / len(sales_b)}) sales_c [900, 1100, 1300, 1200] total_c 0 for s in sales_c: total_c s print(fC组销售总额{total_c}平均{total_c / len(sales_c)})这段代码能跑但问题非常明显求和逻辑复制了三遍每组数据单独维护一个total变量。如果哪天需求变了比如要在总额基础上加一个折扣系数你就得改三处万一有一处漏掉三组数据就对不上了。这种场景在真实项目里出现频率极高尤其是刚入门的时候总觉得代码短、多写几遍无所谓结果脚本越写越长重复代码像滚雪球一样膨胀最后自己都不想再打开那个文件。1.2 可重用代码的核心价值我后来才真正意识到重复代码最大的成本不是那几行重复的字面量而是它成了“不一致”的温床。你把同样的逻辑写成三份就等于允许它在三处慢慢演变成三份不同的逻辑。函数定义与调用这套机制本质上就是给一段逻辑起个名字把实现细节封装起来让调用方只关心“输入什么、得到什么”不必关心“中间怎么算”。这样做的好处总结起来就三条。第一单一维护点。逻辑只写一遍要改就只改一处所有调用它的地方同步生效。这比全局搜索替换靠谱多了尤其是项目上了规模以后搜索替换漏一次线上数据就会出一次问题。第二复用性。同一段逻辑可以在同一个脚本里反复调用也可以抽成模块、打包成库在多个项目里复用。写过一次format_date后面所有项目都能用这就是“可重用代码”最直观的体现。第三可读性。函数名本身就在描述意图。看到parse_line就知道是解析一行日志看到aggregate_by_user就知道是按用户聚合数据。读代码的人不用一头扎进那一堆split、循环、条件判断的细节里就能理解整体流程。提示我自己的判断标准很简单同一段逻辑出现超过两次就值得抽成函数。这不是教条是血泪教训。工程上管这个叫DRY原则也就是Dont Repeat Yourself翻译过来就是别重复自己。它讲的核心道理本质上就是函数定义与调用这套机制存在的意义。2. 函数定义与调用先把语法骨架搭对2.1 def语句的组成与基础规则这里开始搭语法骨架。Python里定义一个函数用def关键字标准格式长这样def 函数名(参数列表): 文档字符串可选 函数体代码 return 返回值可选拆开看有四个部分。第一个是def关键字它声明“我要定义一个函数了”。第二个是函数名命名规则和变量一样只能包含字母、数字、下划线不能以数字开头也不能占用Python的内置关键字。函数名尽量起得有意义最好用动词或动词短语来描述功能比如format_date、parse_line、calculate_total而不是叫func1、func2这种否则一个月之后你自己都记不住哪个是哪个。第三个是参数列表放在函数名后面的括号里可以没有参数也可以有多个参数参数之间用逗号分隔。第四个是函数体也就是冒号后面的缩进代码块。这里必须强调一下Python用缩进表示代码块所以函数体必须统一缩进通常约定是4个空格。括号后面那个冒号也特别容易被新手漏掉少写一个冒号解释器直接报SyntaxError这种报错我帮人排查过太多次了。def greet(name): 向指定用户打招呼 print(f你好{name}) greet(张三) # 调用函数注意上面这个函数只有print没有return。没有写return的时候函数执行完默认返回None。这个None会被赋值给调用方如果你后面要用这个函数的返回值做运算最好先确认它到底返回了什么。2.2 调用函数时解释器在背后做了什么对于带参数的函数一次完整的调用过程可以理解为“把实参绑定到形参上然后在独立的命名空间里执行函数体”。这里有两个概念容易混淆。形参是定义函数时括号里的名字比如def greet(name)里面的name实参是调用函数时真正传入的那个值比如greet(张三)里面的“张三”。调用greet(张三)时解释器做的事情大概是这样先在内存里为这次调用创建一块独立的命名空间也就是函数自己的局部作用域接着把“张三”这个对象的引用绑定到局部变量name上然后按顺序执行函数体内的语句最后函数体执行完毕遇到return或者自然结束就销毁这块命名空间把返回值交到调用方手里。理解了这个过程后面讲作用域和参数传递就顺理成章了。可以打个比方函数就像一个加工车间原料从入口送进去车间里机器怎么运转、内部变量怎么变化外部完全看不到最后只有成品从出口送出来。2.3 函数名本质上是变量名函数是一等公民在Python里函数是一等公民。定义一个函数本质上就是创建一个函数对象然后把这个对象绑定到函数名这个变量上。这意味着函数名可以像普通变量一样被复制、被放进数据结构里也可以作为参数传给另一个函数def add(a, b): return a b my_func add # 函数对象可以直接赋给另一个变量 print(my_func(3, 5)) # 8 funcs [add, max, min] # 函数对象也能放进列表 print(funcs[0](10, 2)) # 12这个特性让Python写起来非常灵活后面会讲到的闭包、装饰器、回调函数本质上都建立在“函数也是对象”这个基础之上。刚开始学不用想太深先记住一条就够了函数名后面不带括号的时候它只是对函数对象的引用并不会执行函数体。很多新手在写定时任务或者批量调用的时候容易把函数名和函数调用搞混传进去的是函数对象而不是调用的结果排查半天才发现问题。3. 参数传递的细节多数隐蔽bug都藏在这些规则里3.1 位置参数、关键字参数与调用顺序Python支持两种传参方式。第一种是位置参数按照定义时的顺序直接传值第二种是关键字参数用“参数名值”的形式传参顺序可以打乱。看个例子def describe_person(name, age, city): print(f{name}今年{age}岁住在{city}) # 位置参数按顺序传 describe_person(小红, 25, 上海) # 关键字参数按名字传顺序可以打乱 describe_person(city北京, age30, name小李) # 混用位置参数在前关键字参数在后 describe_person(小王, city广州, age28)混用的时候有一条硬性规则位置参数必须出现在关键字参数之前。这个规则我以前也吐槽过后来想想其实非常合理。假如允许关键字参数在前、位置参数在后解释器根本分不清那个值到底要填到哪个位置。关键字参数的好处是语义清晰调用describe_person(city北京, name小李)一眼就能看出每个值的含义缺点是代码会长一些。实际项目中参数多的时候我更喜欢用关键字参数参数少的时候用位置参数怎么清晰怎么来。3.2 默认参数的经典陷阱可变对象默认值给参数设置默认值可以简化调用但这里藏着一个几乎所有Python开发者都会踩的坑。来看这段代码# 危险写法 def add_item(item, items[]): items.append(item) return items print(add_item(苹果)) # [苹果] print(add_item(香蕉)) # [苹果, 香蕉]第一次调用add_item(苹果)返回[苹果]看着正常。第二次调用add_item(香蕉)本意应该是[香蕉]结果返回了[苹果, 香蕉]上一次调用的数据凭空出现在这一次的结果里。原因在于默认参数在函数定义的那一刻只创建一次之后每次调用如果没传这个参数拿到的都是同一个列表对象。第一次调用往里append了数据第二次调用拿到的还是那个已经被改过的列表。正确写法是把默认值设为None在函数体内判断并创建新对象# 推荐写法 def add_item(item, itemsNone): if items is None: items [] items.append(item) return items print(add_item(苹果)) # [苹果] print(add_item(香蕉)) # [香蕉]提示不要用可变对象当默认参数列表、字典、集合都不行。如果确实需要一个默认集合就用None加函数体内创建的写法。这个坑我至少目睹过二十次包括我自己在内第一次踩的时候也完全摸不着头脑。3.3 用*args和**kwargs接收数量不固定的参数有些场景需要函数接收数量不定的参数。比如写一个日志函数除了固定级别之外还想允许调用方追加任意多个附加信息。Python用*args收集多余的位置参数用**kwargs收集多余的关键字参数def log_message(level, *args, **kwargs): print(f级别: {level}) print(f位置参数: {args}) print(f关键字参数: {kwargs}) log_message(INFO, 用户登录, IP127.0.0.1, user_id1001, sourceweb)这里的args会把“用户登录”和“IP127.0.0.1”打包成一个元组**kwargs会把user_id和source打包成一个字典。名字可以随便起关键是那个星号。与定义时相反调用函数时也可以用和**把序列、字典解包成位置参数和关键字参数def calculate(a, b, c): return a b - c nums (10, 5, 3) print(calculate(*nums)) # 相当于 calculate(10, 5, 3) params {a: 10, b: 5, c: 3} print(calculate(**params)) # 相当于 calculate(a10, b5, c3)这一正一反两个用法在写装饰器、封装接口、处理配置项的时候几乎是必备技能。初学者可以先记住“定义时收集、调用时展开”这个口诀后面用多了自然就熟练了。3.4 参数传递的本质传递的是对象引用很多初学者对“Python参数到底是按值传还是按引用传”感到困惑。官方的说法是“传对象引用”。通俗地讲调用函数传参时会把实参指向的那个对象引用复制一份赋给形参。关键要区分两种操作通过形参修改可变对象的内容会影响到外部给形参重新绑定一个新对象则完全不影响外部。看代码def modify_list(lst): lst.append(99) # 修改传入的列表对象本身 data [1, 2, 3] modify_list(data) print(data) # [1, 2, 3, 99]外部被改了 def reassign_list(lst): lst [100, 200] # 把形参指向一个新对象 data [1, 2, 3] reassign_list(data) print(data) # [1, 2, 3]外部没变很多bug就出在这个区别上。最常见的情况是函数内部只是想临时处理一下传入的列表结果因为使用了append、extend、sort这类原地操作方法把外部的原始数据也改了。如果希望函数内不要影响外部对象最快的办法是传入副本比如modify_list(data[:])如果列表里还有嵌套的可变对象则需要用copy模块的deepcopy做深拷贝。4. 返回值的取值逻辑与变量的作用域边界4.1 return的几种正确姿势return在函数里有两个职责结束函数执行以及把结果返回给调用方。可以只写一个return表示结束也可以return一个具体的值。当函数逻辑出现分支时经常用多个return提前返回比如def safe_divide(a, b): if b 0: return None # 提前返回调用方可以感知到异常 return a / b result safe_divide(10, 0) print(result) # None这种“早返回”的风格在真实项目中很受欢迎。把边界情况、错误情况在前面处理掉后面的主逻辑就不需要再套一层层的if嵌套。不过也要注意不要在一个函数里塞太多return点否则读起来像迷宫。我的习惯是先集中处理完所有异常分支最后统一return主逻辑的结果最多两三个返回点足够。4.2 返回多个值其实是返回一个元组Python里想从一个函数返回多个值非常方便直接return后面跟多个值就行def get_user_info(): name 张三 age 28 email zhangsanexample.com return name, age, email user get_user_info() print(type(user)) # class tuple print(user) # (张三, 28, zhangsanexample.com) n, a, e get_user_info() print(n, a, e) # 张三 28 zhangsanexample.com底层真相是return后面跟多个值时Python会把它们打包成一个元组。因为元组支持拆包所以写起来像返回了多个值。理解了这点你就能解释调用方为什么可以像上面那样一次性拆成三个变量。如果只想要其中一两个值可以用下划线占位比如name, _, _ get_user_info()虽然实际中用得不多但看到了要能看懂。4.3 局部变量与全局变量作用域的边界函数内部定义的变量是局部变量只在函数内部有效函数外部定义的变量是全局变量模块内所有地方都能访问。听起来简单但两者一交互就容易出问题count 10 # 全局变量 def increase(): count count 1 # UnboundLocalError return count这段代码会直接抛UnboundLocalError。很多人不理解全局变量不是可以访问吗问题在于Python的规则是函数内部一旦给某个名字赋值这个名字就被视作局部变量。上面的count count 1等号右边的count被解释为尚未赋值的局部变量自然就报错了。想在函数内部修改全局变量必须显式声明globalcount 10 def increase(): global count count count 1 return count increase() print(count) # 11不过我个人的建议是尽量别用global。当全局变量被多个函数读写时程序一复杂就变成“谁先改、谁后改”的时序问题非常难排查。更优雅的做法是要么把需要共享的状态封装成类要么用下面要讲的闭包。4.4 从作用域到闭包函数里返回函数既然函数是一等公民一个函数完全可以返回另一个函数。如果内层函数引用了外层函数的局部变量这个组合就叫闭包def make_multiplier(factor): def multiplier(x): return x * factor return multiplier double make_multiplier(2) triple make_multiplier(3) print(double(5)) # 10 print(triple(5)) # 15这里最关键的地方在于make_multiplier(2)执行完之后factor2并没有被销毁而是被内层函数multiplier“记住”了。所以后面调用double(5)时依然能拿到2这个因子。闭包的这种“记忆”能力在写装饰器、做延迟计算、配置回调函数时非常实用。初学者不一定要马上精通闭包但至少要能看懂这种现象它其实是对作用域规则的一个自然延伸。5. 实战把乱糟糟的数据处理脚本重构成可复用模块5.1 一个真实场景订单日志的清洗、统计与报告看完了语法和原理这一节来个实战。假设我们要处理一份用户订单日志格式是“订单号|用户名|金额|日期”需要完成三个步骤清洗脏数据、统计每个用户的消费总额、生成报告。新手写的代码大概是这样的lines [ 1001|Alice|45.5|2024-01-05, 1002|Bob|30.0|2024-01-06, 1003|Alice|20.0|2024-01-06, bad_line_here, 1004|Carol|55.0|2024-01-07, 1005|Bob|15.0|2024-01-08, ] # 第一步清洗 clean_lines [] for line in lines: parts line.split(|) if len(parts) ! 4: continue try: amount float(parts[2]) except ValueError: continue clean_lines.append(parts) # 第二步按用户统计 user_total {} for parts in clean_lines: user parts[1] amount float(parts[2]) if user not in user_total: user_total[user] 0 user_total[user] amount # 第三步生成报告 for user, total in user_total.items(): print(f{user}: {total:.2f})这段代码能跑但很典型地暴露了三个问题所有逻辑堆在顶层没有一个清晰的边界清洗、统计、报告三段之间通过共享变量clean_lines和user_total耦合在一起以后想复用其中任何一段逻辑都只能复制粘贴。更重要的是代码里没有任何函数名来提示“这一步在做什么”读起来全靠逐行理解。5.2 按“输入-输出”把流程拆成三个函数重构的思路很简单把每个独立步骤抽成函数函数之间通过参数和返回值传递数据而不是共享外部变量。我的做法是分三步。第一步先想清楚每个函数的输入和输出把整条处理链画出来原始日志通过parse_line解析成单条有效记录记录列表通过aggregate_by_user聚合出用户消费总额字典统计结果通过format_report格式化成报告文本。这个设计阶段花五分钟后面写代码能省半小时。第二步按设计写函数def parse_line(line): 解析一行日志返回 (订单号, 用户名, 金额, 日期)格式非法返回 None parts line.split(|) if len(parts) ! 4: return None try: amount float(parts[2]) except ValueError: return None return parts[0], parts[1], amount, parts[3] def aggregate_by_user(records): 输入记录列表输出每个用户消费总额的字典 user_total {} for record in records: if record is None: continue _, user, amount, _ record user_total[user] user_total.get(user, 0.0) amount return user_total def format_report(user_total): 把统计字典格式化成报告文本 lines [] for user, total in user_total.items(): lines.append(f{user}: {total:.2f}) return \n.join(lines)第三步在主流程里按顺序组装调用def main(lines): records [parse_line(line) for line in lines] user_total aggregate_by_user(records) report format_report(user_total) print(report) if __name__ __main__: main(lines)注意parse_line返回的是一个四元素元组解析失败就返回None。aggregate_by_user里对None做了跳过处理这样即使某个字符串解析失败整个流程也不会崩。另外函数内部不再依赖外部的clean_lines变量所有数据都通过参数传入、返回值传出这就是解耦。5.3 重构之后到底赢在哪里重构完以后整体收益是肉眼可见的。首先每个函数只做一件事函数名加上文档字符串就是最清楚的注释读代码基本不用猜。其次想单独测试parse_line直接传一行字符串进去就能看结果不需要跑完整个流程想测试aggregate_by_user也可以自己造一批记录传进去。再有需求变化的时候优势就体现出来了。比如产品说统计要排除每个用户的最大单笔订单你只需要改aggregate_by_user的内部逻辑其他两个函数完全不用动。再比如输出格式要从纯文本改成CSV只需要重写format_report主流程一行都不用改。这背后靠的就是“单一职责”和“函数之间的松耦合”。对比之前复制粘贴的方案改动一处就要担心漏了另外几处这种重构带来的安全感是实实在在的。6. 函数相关的坑、调试技巧与一条长期受益的习惯6.1 函数相关的高频坑位盘点结合我自己写代码和帮别人做代码评审的经历函数这块有几个出现频率特别高的坑。第一个坑函数内的变量名写错导致函数意外读取到全局同名变量。比如函数里想用total手误写成了totla局部作用域里找不到这个名字Python会自动向外层找结果外层恰好有个同名全局变量程序不报错但算出来的结果完全不对。这种bug是最难查的一类因为它不报错需要你对变量名保持敏感。应对方法尽量少用全局变量函数内的变量起名要尽量有区分度用IDE的静态检查工具辅助发现。第二个坑默认参数只计算一次。前面详细说过这里再提醒一次默认参数的值在函数定义时就已经确定了不是每次调用时重新计算。所以时间戳、随机数、当前日期这类动态值千万不要放进默认参数里。比如想写一个记录当前时间的函数默认参数写成def log_time(tdatetime.now())最终拿到的一定是函数定义那一刻的时间而不是调用那一刻的时间。第三个坑调用函数时忘了写括号。add_item和add_item()是两个完全不同的东西前者是函数对象本身后者才是执行调用。最常见的翻车场景是在批量操作时不小心把一批函数对象塞进去了结果列表里存的全是函数对象调用的时候才发现根本不是预期的值。养成一个习惯想要函数执行结果就必须带括号调用。6.2 三个好用的函数调试手段我调试函数主要靠三个手段按从简单到复杂的顺序说。第一是print大法。在函数入口、关键分支、return前打印关键变量确认输入输出是否符合预期。方法虽然原始但我必须说它永远有效。第二种是logging模块把print换成不同级别的日志带上时间戳和模块名生产环境下排查问题比print强太多。特别是函数被多个地方调用的时候日志能告诉你到底是谁在什么时间调用了它、传了什么参数。第三种是写独立的冒烟测试直接import这个函数手动传几个典型值包括正常值、边界值、非法值立刻就能看出函数对边界情况的处理是否到位。对新手来说我建议先用“入口打印出口打印”的方式定位问题。先确认传进来的参数对不对再确认返回的结果对不对中间过程再逐步拆开看。根据我的经验大部分函数问题都出在“传参时数据格式不对”或者“返回值没接住”这两端函数中间的纯逻辑反而很少出错。6.3 一条值得长期坚持的习惯短函数加文档字符串最后分享一个我坚持了很多年的习惯。每个稍微复杂的函数我都要先写一两行文档字符串哪怕只是简单描述“输入什么、输出什么、注意什么”。原因特别简单三个月后的自己就是另一个陌生人。没有注释的函数等逻辑复杂起来连自己都看不懂。尤其是那些看起来“很简单很直观”的函数过几个月再看真的会卡在某个细节上。另外函数应该尽量短小。如果一个函数超过二三十行通常说明它承担了不止一件事该考虑拆分。短小的函数不仅容易读更容易测也更容易复用。把一个大函数拆成三四个小函数每个小函数有清晰的名字和职责组合起来读比读一个大函数舒服得多。这个习惯坚持下来写出的代码质量会有非常明显的提升。最后说一点个人的体会。我用Python这些年接触过不少看起来花哨的高级技巧但回过头来发现函数定义与调用始终是所有代码结构的地基。参数怎么传、返回值怎么设计、作用域怎么划分这些基本功决定了你的代码是积木还是毛线团。如果你现在正被重复代码折磨不用学什么高深设计模式先挑一个自己手头最乱的脚本按第五节的思路拆一遍那种“改一处、全盘生效”的感觉用一次就回不去了。以后再写新功能的时候也试着先想清楚“输入是什么、输出是什么、这一步叫什么名字”然后再动手写代码你会慢慢发现写代码这件事开始变得从容了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门