
1. 为什么先从函数讲起代码复用与逻辑抽象我刚工作那会儿带过几个新人看他们写Python经常是同一个重复的逻辑复制粘贴好几遍改需求的时候这里改漏一个、那里改多一个几十行代码憋得满头大汗。后来我就常跟他们说一句话凡是你写了超过一次的东西都有资格成为函数。很多人搜fun函数的作用其实Python里并没有专门的fun函数你看到的那些fun、f0、foo之类的命名只是开发者随手起的函数名。真正定义函数靠的是def关键字名字想叫什么叫什么fun之所以常见更多是历史习惯——早期教程喜欢用foo/bar当作占位名后来演变成fun。那么函数到底是什么我用一个生活类比来解释做饭这件事。你不可能每次做饭都把洗菜、切菜、下锅、调味从头到尾重新发明一遍你脑子里有做个番茄炒蛋这个流程随时可以调用传入番茄和鸡蛋输出一盘菜。函数就是这样的流程包它接收特定输入参数执行固定的步骤函数体返回结果返回值。在Python里最基础的定义长这样def fun(x, y): result x y return result print(fun(2, 3)) # 输出: 5这里def是关键字fun是函数名x和y是参数return把结果交还给调用方。就这么简单但它是整个Python程序结构化的重要基础。函数至少要解决三件事复用、抽象、可测试。复用一段逻辑只需要写一遍。比如你要算均值sum(values) / len(values)写一次以后到处调用不用每次复制。抽象调用方不需要知道内部怎么实现。你的同事调用你写的函数时只需要关心传什么、拿什么不关心你用了几层循环。这隔离了复杂度。可测试把纯计算逻辑抽成函数后可以直接测试它而不用为了验证一段逻辑去跑整个程序。Python函数还有一个经常被新手忽略的价值函数是一等公民。意思是你可以把函数当作变量来用传给别的函数、被别的函数返回、放进列表里。这在后面讲装饰器、高阶函数时非常关键比如map、filter这类内置函数接收的就是一个函数作为参数。def square(x): return x ** 2 numbers [1, 2, 3, 4] squared list(map(square, numbers)) # square 作为参数传入 print(squared) # [1, 4, 9, 16]一旦意识到函数也是对象你再看很多高级特性就会豁然开朗。2. 参数传递的真相可变对象与不可变对象的天壤之别这是Python面试出现频率很高的地方也是实际开发里很多人翻车的地方。先给结论Python函数的参数传递本质是传对象引用但对象的可变性决定了对调用方的影响。你可以把变量名理解成一张便签便签贴在一个盒子上。函数调用时把便签的复印件传进去——注意传的是复印件但复印件指向的还是原来那个盒子。于是如果盒子里的东西是不可变的比如整数、字符串、元组那么函数里重新绑定或运算只会生成新盒子并让局部的便签贴上去原来的盒子不受影响。如果盒子里的东西是可变的比如列表、字典、集合那么函数内部对盒子内容做修改append、remove、修改元素原盒子一起变。看这个经典例子def modify(some_list): some_list.append(100) # 修改可变对象本身 my_list [1, 2, 3] modify(my_list) print(my_list) # [1, 2, 3, 100]外部列表被改了但下面这种就不一样def reassign(some_list): some_list [4, 5, 6] # 重新绑定相当于给新盒子贴便签 my_list [1, 2, 3] reassign(my_list) print(my_list) # [1, 2, 3]外部列表没变这里some_list [4, 5, 6]做的事情是创建一个新列表盒子把函数内部的便签贴上去。它没有动原来的盒子所以外部自然毫无感知。这个区别如果搞不清楚排查为什么我函数里改了外面却不变或者为什么外面跟着变了就会很痛苦。再来看一个我见得特别多的坑可变对象当默认参数。def add_item(item, target_list[]): target_list.append(item) return target_list你以为每次调用不传target_list时都会生成一个全新的空列表但实际上默认参数只在函数定义时创建一次之后所有调用共享这一个列表。连续调用三次结果是这样的print(add_item(a)) # [a] print(add_item(b)) # [a, b] print(add_item(c)) # [a, b, c]正确的写法是默认用None占位def add_item(item, target_listNone): if target_list is None: target_list [] target_list.append(item) return target_list至于*args和**kwargs它们的作用是接收不定数量的参数。*args把多余的按位置传入的参数打包成元组**kwargs把多余的按关键字传入的参数打包成字典。写函数时用它们可以增加接口的弹性比如你要写一个日志函数允许调用方自由传入额外的属性信息。def log(level, message, *args, **kwargs): print(f[{level}] {message}) print(额外位置参数:, args) print(额外关键字参数:, kwargs) log(INFO, 用户登录成功, 101, admin, ip1.2.3.4)还有一点和参数相关函数的返回值也是对象可以返回多个值。Python里实际是打包成一个元组返回def get_user(): name 张三 age 25 return name, age # 等价于 return (name, age) name, age get_user() # 自动解包这个解包语法在很多场景里很顺手比如遍历字典时for key, value in dict.items():思路是一样的。3. 作用域规则LEGB与闭包为什么函数能记住外部变量函数的另一个核心规则是作用域。Python查找名字有一个固定顺序缩写为LEGBLLocal当前函数内部定义的局部变量。EEnclosing外层嵌套函数里定义的变量内层函数可以看见它。GGlobal模块顶层定义的全局变量。BBuilt-inPython内置的名字比如len、print、range。x 10 # G def outer(): y 20 # Eouter 的局部但对于 inner 是 enclosing def inner(): z 30 # L print(x, y, z) inner() outer()当你在函数内给一个名字赋值Python默认会把这个名字当成局部变量。于是会出现这类很经典的报错count 0 def increment(): count 1 # UnboundLocalError: local variable count referenced before assignment原因就是赋值让Python认为count是局部变量但count 1又需要先读取它——此时局部还没有定义。解决方法是声明global count。同理在内层函数里修改外层函数的变量需要用nonlocal。count 0 def increment(): global count count 1 increment() print(count) # 1接下来我要重点说说闭包。闭包就是内层函数记住了外层函数的变量即使外层函数已经执行完毕内层函数仍然能拿到这些变量。这个能力非常有用比如你写一个计数器def make_counter(): count 0 def counter(): nonlocal count # 因为要修改 count必须 nonlocal count 1 return count return counter c1 make_counter() print(c1()) # 1 print(c1()) # 2 print(c1()) # 3 c2 make_counter() print(c2()) # 1 每个闭包独立保留自己的 count这里counter返回的是一个函数对象它把count这个变量关在了自己的作用域里。使用闭包可以实现数据隐藏外部不能直接访问count只能通过返回的函数操作这和面向对象里的私有属性逻辑上很相似。配合函数是一等公民你自然能理解装饰器。装饰器本质上是一个接收函数的函数它包裹原函数在调用前后追加逻辑。最常见的场景是计时器、日志、权限校验。import time def timer(func): def wrapper(*args, **kwargs): start time.time() result func(*args, **kwargs) print(f{func.__name__} 耗时 {time.time() - start:.4f} 秒) return result return wrapper timer def slow_task(): time.sleep(0.5) return done slow_task()装饰器不是你死记硬背的语法糖当你理解了函数是对象、函数可以被返回、闭包记住了外层变量这三件事装饰器就是它们组合产生的水到渠成。我会建议初学者把这个过程亲手拆开跑一遍效果比背任何结论都好。4. 模块import到底在做什么以及为什么总是报ModuleNotFoundError聊完了函数进入更大的组织单位模块。一个.py文件就是一个模块内部可以包含函数、类、变量以及可以被import的任意代码。模块的作用是多方面的划分命名空间、避免文件过大、便于复用和协同开发。先拆解import的执行流程很多人只看到表象能导入/不能导入不理解其中的机制检查sys.modules这个全局字典如果模块已经被导入过直接复用不会重复执行。如果没有根据模块名去sys.path列表里的路径中查找同名的.py文件或包目录。找到后先创建一个模块对象然后从头到尾执行该模块的代码。执行完毕把模块对象放进sys.modules并将它绑定到当前命名空间中的名字。这个流程解释了三个常见现象第一个现象模块的顶层代码只执行一次。就算你在不同文件里多次import os实际只有第一次会真正执行模块文件后续都是从sys.modules取缓存。这一点在写一些带初始化逻辑的模块时要留心。第二个现象为什么当前目录的文件能被import因为sys.path的第一项通常是当前脚本所在目录或者当在交互式环境时是空字符串代表当前工作目录。所以只要文件在目录里import自然能找到。但当你换了一个工作目录启动Python原来能导入的模块可能就找不到了这也是很多为什么昨天还能跑今天报ModuleNotFoundError的原因。第三个现象为什么会有循环导入错误。比如a.py里import b而b.py里又import a。执行import a时a还没执行完又去执行bb回过头想加载a但此时sys.modules里虽然已经有a的条目可a的模块属性还没有完全初始化于是你会得到ImportError: cannot import name xxx from partially initialized module a。循环导入的解决思路不是背一个固定方案而是想清楚依赖方向。一般有三种处理方式把公共部分抽到第三个模块消除循环依赖。把其中一方的导入移到函数内部延迟到调用时才导入因为那时两个模块往往都已经加载完毕。精简模块边界的依赖梳理哪些代码应该放在哪个模块。还有一个连很多写了几年Python的人都未必完全讲清楚的点if __name__ __main__:。__name__是Python每个模块都有的内置变量。当文件作为脚本直接运行时Python会给它的__name__赋值为__main__当文件被import时__name__则等于模块名。于是这段判断的作用是把执行脚本时的测试或启动逻辑和作为模块被导入时应该暴露的接口分开。# demo.py def useful_func(): print(可以被导入的接口) if __name__ __main__: useful_func()如果你不在模块里加这个判断别人import你的模块时会直接跑一遍你的顶层代码轻则打印一堆不该出现的内容重则引发副作用。我在项目里见过有人写了一个test.py里面有大段测试数据加载和打印结果别的模块import test时把几十M数据加载了一遍程序启动慢得吓人。这就是没有做好模块副作用隔离。5. 包与模块拆分从单文件脚本到真实项目代码该怎么摆单个模块文件毕竟能力有限真实项目会把模块组织成包Package。包是一个包含__init__.py文件的目录目录下可以再放子模块或子包。__init__.py主要有两个作用一是让Python把目录当作包来识别二是控制包的导入行为比如导入包时默认加载哪些东西。举一个具体例子很多人搜菜单模块搜索假设我们要写一个点餐系统后台单纯把所有代码堆在一个order.py里不是不行但做大了就绝对会失控。一个合理的拆分可以是这样restaurant/ ├── __init__.py ├── menu/ # 菜单相关 │ ├── __init__.py │ ├── models.py # 菜品数据模型 │ ├── search.py # 菜单搜索逻辑 │ └── views.py # 展示相关 ├── order/ # 订单相关 │ ├── __init__.py │ ├── cart.py # 购物车 │ └── checkout.py # 结算流程 └── common/ # 公共工具 ├── __init__.py └── utils.py # 通用函数拆分时有个判断标准我一直很推荐一个模块只关心一个领域的职责。搜索逻辑放menu/search.py结算逻辑放order/checkout.py公共的日期格式化、金额计算放common/utils.py。这样一旦某个模块出问题你打开对应文件就能找到而不是在一个3000行的文件里CtrlF找几百遍。包内模块之间的导入有两种方式# 绝对导入基于包的最顶层来导入推荐 import restaurant.common.utils # 相对导入基于当前模块的位置来导入适合包内部相互引用 from .models import Dish from ..common import utils我个人在包内部推荐尽量用相对导入因为目录结构调整时相对导入能减少一连串前缀修改。但如果你是新手一上来想快速跑通绝对导入也很好理解。记住一个原则两种方式混用没问题但别在同一个项目里随意切换否则后面看代码会乱。再来说说第三方模块的安装这是拦住无数新手的一个坎。Python自带的标准库非常丰富比如os、sys、json、datetime、collections、functools、math、random这些不需要额外安装。但像numpy、pandas、matplotlib、requests这类第三方库则需要通过pip安装。pip install numpy pip install requests为了不让不同项目的依赖互相干扰强烈建议使用虚拟环境venv。虚拟环境的本质是创建了一个独立目录里面装着一套独立的Python解释器和独立的site-packages库目录。你在这个环境里安装的包不会污染系统环境其他项目的包也不会污染你。python -m venv venv # Windows venv\Scripts\activate # Linux/macOS source venv/bin/activate有些人装了很多包结果还是要经历python安装numpy库这种搜索过程多半是环境不对。最常见的毛病是pip install装到了一个环境但运行import numpy的Python是另一个环境的解释器。排查思路其实很简单先看两个位置对应的是什么pip --version python --version pip show numpy # 显示numpy装在哪 python -c import numpy; print(numpy.__file__) # 实际导入的numpy在哪个路径如果pip和python来自不同的解释器就统一用python -m pip install numpy这种形式能确保装到当前python对应的环境里。6. 常用模块搭配函数一个上手就能用的工具箱与几个实战场景标准库里很多模块本身就是为了配合函数而生的。这里我挑几个我个人使用频次最高、对新手价值最大的组合配套代码快速过一遍。同时结合你搜到的softmax函数python构建邻接矩阵python画图横坐标太密集这些具体场景来说说。6.1 os与sys操作路径和运行参数os模块负责和操作系统交互比如文件路径拼接、环境变量读取、遍历目录。写脚本时必须注意跨平台问题不要自己拼/或\用os.path.join更稳妥。import os import sys base_dir os.path.dirname(os.path.abspath(__file__)) config_path os.path.join(base_dir, config, settings.ini) print(配置文件路径:, config_path) if len(sys.argv) 1: mode sys.argv[1] print(命令行参数:, mode)sys.argv是运行脚本时传入的命令行参数第一个元素始终是脚本文件本身。比如python my_script.py --verbosesys.argv就是[my_script.py, --verbose]。这是很多初学者想做让程序接收外部参数时最直接的方案。6.2 json与datetime做数据处理时必配的搭档几乎所有真实项目都离不开序列化或日期处理。json模块可以在Python对象和JSON字符串之间互相转换datetime则负责时间运算和格式化。import json from datetime import datetime data {user: 张三, age: 25, tags: [python, 函数]} json_str json.dumps(data, ensure_asciiFalse) print(json_str) now datetime.now() formatted now.strftime(%Y-%m-%d %H:%M:%S) print(formatted)ensure_asciiFalse是个很容易踩的坑微信小程序接口传中文时如果不加这个参数中文会被转成\uXXXX格式服务端可能解析出乱码或直接报错。6.3 用函数封装常用的数据处理逻辑很多人在搜softmax函数softmax就是机器学习里把一个向量变成概率分布的常用函数它在每个元素上做指数运算再除以所有指数之和。如果不想深究直接用numpy实现也很快import numpy as np def softmax(x): x x - np.max(x, axis-1, keepdimsTrue) # 防止指数爆炸 exp_x np.exp(x) return exp_x / np.sum(exp_x, axis-1, keepdimsTrue) scores np.array([2.0, 1.0, 0.1]) print(softmax(scores))减法操作涉及数值稳定性问题如果直接用np.exp(x)当x较大时指数会溢出变成inf。这个细节很多人只抄教程没细看自己写函数时容易忽略。类似的调整在数值计算里很常见这也是为什么我不建议从零造轮子但要求你能看懂轮子内部的思路。还有一个高频需求是python构建邻接矩阵。图结构在社交网络分析、路径搜索、神经网络里都很常见。邻接矩阵就是一个方阵矩阵第i行第j列表示节点i和节点j是否有边。用Python实现时最简单的是用二维列表或numpy数组import numpy as np def build_adjacency_matrix(edges, num_nodes): mat np.zeros((num_nodes, num_nodes), dtypeint) for u, v in edges: mat[u][v] 1 # 如果是无向图对称位置也置1 # mat[v][u] 1 return mat edges [(0, 1), (1, 2), (2, 0)] adj build_adjacency_matrix(edges, 3) print(adj)这种封装的好处是以后你再做最短路径、连通分量检测时直接调用这个函数生成邻接矩阵不需要重复写初始化逻辑。关键是让代码模块化为后续功能留出接口。6.4 画图遇到横坐标太密集解决方案其实藏在模块参数里python画图横坐标太密集是matplotlib使用中的高频问题。说白了就是x轴刻度太多标签挤在一起看不清。解决办法通常有几种旋转刻度、只显示部分刻度、手动指定刻度位置。我最常用的是用plt.xticks控制步长。import matplotlib.pyplot as plt x list(range(0, 100)) y [i ** 0.5 for i in x] plt.figure(figsize(10, 5)) plt.plot(x, y) plt.xticks(x[::10]) # 每隔10个显示一个刻度 plt.xticks(rotation45) # 如果还是挤旋转45度 plt.tight_layout() plt.show()如果你要保存图片到文件记得在plt.show()之前调用plt.savefig(result.png, dpi300)并注意bbox_inchestight防止边缘被裁剪。这些细节平时没人告诉你等你画出来发现图片边角被切了再回头看才会知道是保存参数的问题。7. 模块与函数使用中的常见坑从ImportError到环境变量一次讲透这一章我照着真实项目里最容易出现的报错场景来写每个都附上我的排查逻辑你可以把这些当作检测清单用。7.1 ModuleNotFoundError / ImportError 的完整排查链路这个错误出现时我的习惯是依次检查以下四项模块是不是真装了pip show 模块名没有就先装。装到了哪个环境用pip --version和python --version确认是否同一个环境。当前工作目录下是不是有同名文件比如你自己写了一个math.py那么import math会先找到你的math.py从而覆盖标准库的math。这种情况非常隐蔽代码不报标准库找不到但行为异常。排查时可以打印math.__file__看路径。是不是包内相对导入用错了在没有__init__.py的普通目录里用from . import xxx也会报错。上述四点如果都正常才考虑循环导入、系统变量缺失等更复杂的因素。按这个顺序排查大多数情况能在1分钟内定位。7.2 环境变量问题从python不是内部或外部命令说起搜索热词里出现很多无法将某命令识别为cmdlet、函数、脚本文件或可运行程序的名称虽然搜索词可能是pnpm或make但Python新手遇到的经典版本是python 不是内部或外部命令也不是可运行的程序或批处理文件。本质是操作系统在PATH环境变量里找不到python.exe所在的目录。解决办法也很简单在安装Python时勾选Add Python to PATH或者在系统环境变量里手动把Python安装路径和Scripts目录加进去。Scripts目录很重要因为pip、virtualenv这类命令都在那里。虚拟环境激活后你的PATH头部会指向当前虚拟环境目录所以刚装的依赖能被找到。如果你激活了虚拟环境还报找不到模块优先怀疑是不是装错环境了。7.3 函数里的名称遮蔽问题命名冲突是我认为函数使用中最隐蔽的坑。比如你在模块顶层定义了data又在某个函数内部用data ...赋值函数内引用的data就是局部变量如果函数内还想用全局的data又不声明global会直接报错或得到错误结果。还有一种情况你给函数参数起名叫list、dict、str这些名字会遮蔽Python内置函数。在函数内部你用list(...)想把元组转成列表结果得到一行报错list object is not callable——因为参数里的list已经覆盖了内建函数。建议是永远不要用内置类型名做变量名这算是最基本的水卫生问题。7.4 把调试利器用起来很多刚入门的人遇到问题第一反应是各种print。print当然有用但函数调用多了以后print输出混在一起根本分不清来自哪一层。我建议尽早掌握两种调试手段针对函数返回值的校验写函数时直接用单元测试库unittest或更简明的断言例如assert add(2, 2) 4。使用pdb或IDE的断点调试。IDE比如VS Code里你在函数某一行打一个断点运行到这里程序暂停你可以查看局部变量、调用栈比print调试省一半时间。说到这个我想到一个很典型的排查场景函数返回值怎么都对不上预期网上搜fun函数的作用看到一堆教学帖最后问题出在自己在调用前提前把某个变量覆盖了。如果当时用断点看一眼调用栈一秒钟就能看见调用方的data被谁改过。8. 函数式编程思维与模块化设计让代码从能跑到好维护最后我想跳出来讲一下编程思维的问题。函数和模块不只是语法它们在逼迫你重新思考代码的组织方式。8.1 函数就像项目管理中的接口协议我常跟团队成员说写函数之前先想好接口这个函数叫什么、接收什么、返回什么、有没有副作用。定好了以后函数内部怎么实现是实现细节。这其实和团队协作中对函数依赖的理解一致只要接口不变内部优化互不影响。def get_user_by_id(user_id): 根据用户ID返回用户信息字典找不到时返回None。 ...写这样的docstring不是浪费时间它在为调用方建立契约后面你自己写别的模块时也是在和过去的自己协作。8.2 模块拆分要避免上帝模块和碎片化两个极端一个模块写了上千行什么逻辑都往里面塞改动一个细节要小心翼翼怕影响其他地方这是上帝模块反过来把代码拆成几十个只有几个函数的碎片文件光是追踪一个请求在各模块间跳来跳去就能绕晕这是碎片化。我比较推荐的做法是先按业务领域或功能域拆分等模块真的超过一定规模比如超过300~500行再做下一级拆分。模块的边界应该对应一个可以描述的职责而不是纯粹为了看起来整洁。8.3 函数的纯度与副作用是系统稳定性的关键尽量让函数纯一些。纯函数的意思是给定相同输入永远返回相同输出并且不修改外部状态。这样函数没有隐藏依赖测试容易、缓存容易、并发安全也天然更好。比如全局变量作为函数内部状态虽然写了增量逻辑不会报错但多个地方调用时状态会互相影响非常难排查。很多人网搜vector函数或fun函数的作用兜兜转转发现问题的原因就是自己的函数内部读了不该读的全局变量。functools.lru_cache是Python里一个我很推荐的内置装饰器它可以把函数的计算结果缓存起来相同参数调用时直接返回缓存值。但前提是函数要足够纯——如果函数依赖外部可变状态缓存就会给出过时结果。from functools import lru_cache lru_cache(maxsize128) def fibonacci(n): if n 2: return n return fibonacci(n - 1) fibonacci(n - 2) print(fibonacci(50))这个例子能直观看出纯函数配合缓存带来的性能提升。如果你用递归求斐波那契数列不做缓存是指数级的复杂度加上lru_cache之后几乎瞬间出结果。但要注意这个缓存是按位置参数和关键字参数做键的所以你传个可变对象进去会有问题——这又回到了前面讲的参数传递和可变性。8.4 把常用逻辑沉淀成自己的个人工具库我工作了一段时间后慢慢把自己常用的一二十个通用函数收集到一个utils.py里比如时间格式化、路径安全拼接、数值归一化、日志输出封装。随着项目增多这套工具库会越来越顺手它带来的好处不是每次省几行代码而是你大脑里形成了工具化的反射——遇到相似需求先想想能不能复用已有的函数而不是从头写。一个真实的体会代码里重复逻辑消失得越彻底出Bug的概率越低。Python本身就是一门强调可读性、强调组合的语言函数和模块是它的两根支柱。你写的每一个小函数、拆出来的每一个小模块都是在给未来的自己和同事减轻负担。以上就是这次分享的全部内容从函数定义到参数传递、作用域、闭包、模块机制、包组织再到常见坑的排查思路都是我实际踩过或带人踩过之后总结出来的。第一次写代码时你可能觉得这些东西很简单长期实践后你会意识到def和import背后藏着Python生态半壁江山的哲学。