我见过不少学到第三章就卡住的人,原因倒不是语法变难了,而是从这一章开始,“写代码”和“设计代码”的差别开始显现。前面几章学的是“语句”,这一章学的是“组织”。函数、模块、包这三个词串的是同一个核心诉求——把代码拆成可以独立理解、独立复用、独立调试的小块。这篇指南的目标读者是已经会写循环、条件、列表推导,但还没真正整理过自己代码的初学者,也适合用过 Python 写过一堆脚本、却始终没搞清 import 与__init__.py背后逻辑的“野生开发者”。读完这一章,你不仅能分清函数、模块、包三者的层次,还能亲手搭出一个不臃肿、不报错、可以被别人 import 的项目结构。
1. 函数的本质:为什么说函数是 Python 的“第一公民”
1.1 重复代码是第一个敌人
先看一个最常见的场景:你写了一个求两点间距离的脚本,第一次也许直接这么写:
# 已有两个点 p1 和 p2,存放在列表或元组里 distance = ((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) ** 0.5 print("p1 到 p2 的距离是:", distance)然后第二个场景需要求 p3 到 p4 的距离,你又写了一遍。到第三个场景时你已经开始烦躁,因为公式一旦要调整,你得全文搜索手动改三处。更麻烦的是,期中有一处你写成了p2[0] - p1[0],虽然平方之后结果一样,但这份代码根本说不清它的意图。复制粘贴看起来省事,实际上是把错误复制了三份。
这时候就应该上函数。函数的核心价值不是“少打字”,而是给一段逻辑一个名字,把它变成一个可反复调用的单元,同时把“输入”和“输出”明确暴露出来。下面这一点非常关键:你一旦写成函数,测试、修改、组合都变得异常顺手。比如把上面的距离计算写成函数以后:
def distance(point_a, point_b): """计算二维平面上两个点的欧氏距离,返回浮点数。""" dx = point_a[0] - point_b[0] dy = point_a[1] - point_b[1] return (dx ** 2 + dy ** 2) ** 0.5调用方看到的是清晰的名字和参数,不需要关心内部细节。等哪天你需要支持三维坐标,只需要改这一个函数,所有调用点同步受益。这就是“抽象”最朴素的体现。
1.2 def 是执行语句,函数名是普通变量
很多从 C、Java 转过来的朋友会下意识以为def是“声明一个函数”,但在 Python 里,def是一条执行语句。当解释器执行到def distance(...)时,它在当前作用域里创建了一个函数对象,然后把函数对象绑定到distance这个名字上。所以你可以把函数名当成普通变量来用:
# 给函数起别名 calc_distance = distance print(calc_distance((0, 0), (3, 4))) # 5.0 # 把函数放进列表、字典,再统一调用 ops = [distance, math.sqrt] for op in ops: print(type(op), op([1, 2], [1, 2])) # 把函数作为参数传给另一个函数 def apply_twice(func, value): return func(func(value)) def square(x): return x * x print(apply_twice(square, 3)) # 81这种“函数即对象”的特性,让 Python 天然支持高阶函数,也让 lambda 表达式、装饰器这些高级玩法有了存在的基础。很多人学装饰器时一头雾水,其实是没理解前面这句“函数名是普通变量”。
有个实操细节值得注意:函数对象的__name__、__doc__都是可以直接访问的。比如 Python 内置的map、filter等函数,你在交互环境里打印它们时看到<class 'function'>,这说明它们和普通def定义的函数地位完全一致,没有所谓“内置函数更高贵”的说法。这一点会直接影响你后面理解装饰器和函数式工具。
1.3 return 与 None:最容易出隐形 bug 的地方
我见过太多初学者写“有去无回”的函数:
def add(a, b): result = a + b # 忘了写 return调用add(1, 2)得到的是None。麻烦在于,在 Python 里不带 return 时函数默认返回 None,这并不是语法错误,所以代码不会报错,结果却莫名其妙。尤其当你对一个列表做sort()操作时,更要注意它就地排序并返回 None,而sorted()返回新列表并保留原列表。
这里建议养成两个习惯:一是所有“应当有返回值”的函数,必须显式写return;二是当你需要“提前退出”时,return后面可以什么都不跟,等价于返回 None,但通常更清晰的做法是返回明确的占位值,比如return ""或return [],这样调用方就不必额外判断 None。到底用哪种?依赖于场景。如果""本身可能是合法结果,就返回 None;如果""不可能是合法输出,返回""会让后续代码更省心。
判断一个函数要不要返回值,可以问自己一个问题:调用方是否需要这个计算结果?如果需要,就返回;如果函数只是执行某个副作用(打印、写入文件、修改列表),可以不返回,但还是建议不要混用“有返回值的逻辑”和“纯副作用逻辑”。一个函数做一件事,比一个函数既修改全局状态又返回结果,要好调试得多。
2. 参数系统的完整拆解:五类参数一次理清
2.1 位置参数、关键字参数与默认参数的绑定规则
Python 函数定义里的形参,按传参方式可以分为几个层次。最简单的是位置参数:调用时按顺序传值。接着是关键字参数:调用时用形参名=值的方式传参,优势是调用方可读性高,也更不容易传错顺序。
但默认参数这里有一个非常重要的规则:在函数定义中,带默认值的形参必须排在所有不带默认值的形参之后。也就是说def f(a, b=1)合法,def f(a=1, b)会直接语法错误。原因是 Python 要靠位置匹配“先必选后可选”,否则调用时没办法确定某个参数到底被给了没有。
调用时的规则对称:位置参数必须在关键字参数之前,否则 Python 也会报SyntaxError: positional argument follows keyword argument。从实际经验来看,在项目里定义函数时,我会把“调用方很可能省略”的参数放后面并给上默认值;把“每次调用几乎都要传”的参数放前面。比如:
def log_message(message, level="INFO", timestamp=None): if timestamp is None: timestamp = datetime.now() print(f"[{timestamp:%Y-%m-%d %H:%M:%S}] [{level}] {message}")调用log_message("服务启动")得到默认级别,log_message("出现错误", level="ERROR")则覆盖级别。这种设计兼顾了简洁和可读性。
2.2 可变默认参数的坑:列表和字典是共享的
这是 Python 函数里最有名的一个坑,没有之一。看这个例子:
def add_task(task, tasks=[]): tasks.append(task) return tasks print(add_task("写文档")) # ['写文档'] print(add_task("写代码")) # ['写文档', '写代码']第二个调用返回了两个任务?原因:tasks=[]这个默认列表在函数定义执行时只创建一次,之后每次调用如果没有传tasks,用的都是同一个列表对象。它就像一个你每次开会都默认带出去的“公共记事本”,只要你不换新本子,上一位同事写的内容就会残留下来。
要修复,标准做法是使用 None 作为哨兵:
def add_task(task, tasks=None): if tasks is None: tasks = [] tasks.append(task) return tasks这里我多说一句:不要把它当成“语法巧合”,要理解它的底层逻辑。Python 的默认参数是在def语句执行时求值的,等于在定义阶段就把默认值绑定给了形参。对于整数、字符串、元组这类不可变对象没有任何问题,但对于列表、字典、集合这类可变对象,你实际得到的是一个随时会被函数内部修改的共享引用。以后自己写工具函数时,凡是默认参数可能是可变对象,一律用 None 哨兵处理。在你没把握之前,宁可多写三行判断,也不要省这段防御代码。
2.3 *args 与 **kwargs:收集与解包两个方向
*args和**kwargs是 Python 里非常实用的语法糖,但在实际项目中它有两个完全不同的使用方向:一个是收集,一个是解包。
先说收集。当你在函数定义里写出def f(*args)时,调用方传入的所有多余位置参数会被打包成一个元组args:
def sum_all(*numbers): total = 0 for n in numbers: total += n return total print(sum_all(1, 2, 3, 4)) # 10类似地,**kwargs把所有多余关键字参数打包成一个字典:
def register_user(name, **attributes): print(f"注册用户: {name}") for key, value in attributes.items(): print(f" {key}: {value}") register_user("张三", age=25, city="杭州", role="admin")再说解包。当你在调用函数时使用*或**,作用正好相反,是把序列或字典拆开传入:
data = [1, 2, 3, 4] print(sum_all(*data)) # 相当于 sum_all(1, 2, 3, 4) config = {"city": "上海", "role": "visitor"} register_user("李四", **config) # 相当于关键字参数传参这个特性在装饰器、路由注册、配置合并、把一个函数的返回值作为另一个函数入参时特别常用。关键是要想清楚:*args拿到的是元组还是列表?答案是元组。元组是不可变的,所以如果你要修改收集到的参数集合,需要先list(args)转一下,或者直接用解包后的新序列操作。
接下来我把几种参数组合的适用场景整理成一个表,方便你写代码时对照:
| 情形 | 定义写法 | 调用写法 | 典型场景 |
|---|---|---|---|
| 固定参数 | def f(a, b) | f(1, 2)或f(a=1, b=2) | 参数明确的普通函数 |
| 带默认值 | def f(a, b=0) | f(1)或f(1, b=2) | 可省略的可选参数 |
| 可变位置参数 | def f(*args) | f(1, 2, 3) | 需要接收任意数量数值 |
| 可变关键字参数 | def f(**kwargs) | f(x=1, y=2) | 配置项、关键字选项 |
| 参数解包 | 正常定义 | f(*list)/f(**dict) | 动态传参、代理调用 |
这五种情形可以组合出现,但顺序必须严格是必选位置参数 -> 默认参数 -> *args -> keyword-only 参数 -> **kwargs。这个顺序记不住的话,就用最稳妥的做法:必选参数放最前,默认参数放中间,*args、**kwargs放最后。
2.4 keyword-only 参数与 positional-only 参数
Python 3 加入了一个容易被初学者忽略的能力:通过*分隔,可以强制某个参数必须以关键字形式传递。比如:
def configure(host, port, *, timeout=30, retries=3): print(host, port, timeout, retries) configure("127.0.0.1", 8080) # 合法 configure("127.0.0.1", 8080, 60) # 报错,timeout 必须用关键字传入 configure("127.0.0.1", 8080, timeout=60) # 合法为什么需要这个能力?设想一个函数有很多参数,其中有几个含义特别容易混淆(比如timeout和retries都是数字),如果你允许调用方按位置传,很容易把 60 传给 retries。强制关键字参数以后,调用方写代码时清晰度大幅提高,因为你希望调用者明确知道每个额外参数的作用。
Python 3.8 又加入了positional-only参数,用/分隔:
def div(a, b, /): return a / b div(10, 2) # 合法 div(a=10, b=2) # 报错,a 和 b 只能按位置传这种写法的意义是实现方可以自由调整参数名而不必担心破坏调用方代码,因为调用方本来就不能按名传。这个特性在标准库里比较常见,但在普通业务代码里并不常用。你了解它的存在即可,真正需要时再深入。
3. 作用域、闭包与延迟绑定:函数面试题的高发区
3.1 LEGB 规则:Python 到底去哪里找变量
作用域这个词听起来抽象,其实可以理解为“名字的查字典顺序”。Python 在访问一个变量时,会按 Local(局部)→ Enclosing(外层)→ Global(全局)→ Built-in(内置)的顺序查找。这样说可能还是太干,直接上例子:
x = "global" def outer(): x = "enclosing" def inner(): x = "local" print(x) inner() outer() # 输出 local把注释里的x = "local"删掉,inner 会接着去 outer 里找,输出enclosing;再删掉 outer 里的赋值,会去全局找,输出global。这个规则决定了你在函数内部没有为变量赋值时,访问到的可能是外层同名变量。但有一个关键限制:在函数内部可以读取全局变量,但直接赋值时不会改全局变量,而是创建了一个同名局部变量。这说明 Python 的赋值语句是一个“绑定”动作,而不是单纯的“修改”。
这里有一个日常调试里多人踩的坑:函数内部先引用再赋值会报错。
count = 0 def inc(): count = count + 1 # UnboundLocalError因为函数体内只要有count = ...这种赋值语句,Python 就把count判为局部变量,于是count + 1读取的是一个尚未绑定的局部变量,直接报错。解决办法是用global count声明,或者在函数内不要使用同名局部变量。
3.2 global 与 nonlocal:什么时候需要它们
全局变量默认可以被读,不能直接写。想在函数内修改一个全局变量,必须使用global声明:
counter = 0 def reset_counter(): global counter counter = 0 def increment_counter(): global counter counter += 1对应地,对于嵌套函数,你想修改外层函数作用域的变量,要使用nonlocal:
def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter c1 = make_counter() print(c1()) # 1 print(c1()) # 2 c2 = make_counter() print(c2()) # 1nonlocal的核心价值在于:它让嵌套函数能够“记住”且“修改”外层状态,同时又不污染全局命名空间。这个模式是实现闭包的重要基础,也是后面装饰器的雏形。注意nonlocal只能作用于嵌套函数的外层函数变量,不能跨模块,也不能直接指向全局变量。
在实际项目中我的建议是:尽量少用global。全局状态一旦被多个函数修改,排查起来非常痛苦。我更推荐把需要共享的状态封装成类,或者通过闭包隐藏状态,只在局部范围内操作。如果确实需要全局配置,可以用一个专门的配置模块管理,并只允许明确的更新接口去改它。
3.3 闭包与延迟绑定:循环里 lambda 为什么全是同一个值
闭包就是“函数对象 + 创建它时的外部变量环境”。前面make_counter返回的counter就是一个典型的闭包,它“记住”了count这个变量,即使make_counter调用结束后,count仍被内部函数引用而不被回收。
闭包最容易踩的坑是延迟绑定。看这段代码:
funcs = [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f())你可能会期望输出 0, 1, 2,但实际输出的是 2, 2, 2。原因是 lambda 函数体里的i是延迟绑定的:它在函数被调用时才去查找i的当前值,而循环结束后全局变量i已经变成 2,三个函数看到的都是同一个 2。
一个经典的解决办法是利用默认参数在函数定义时立即绑定:
funcs = [] for i in range(3): funcs.append(lambda i=i: i) for f in funcs: print(f()) # 0 1 2它的原理就是前面说的“默认参数在 def 执行时求值”——创建 lambda 时,i=i把当时的循环变量值保存成默认参数,之后函数体里的i优先取局部默认值,不再去外部找延迟变量。
类似的问题在装饰器、回调、事件处理器里都可能出现。记住一条经验:如果一个闭包引用循环变量,且你希望保留每个迭代时刻的值,就用默认参数或再套一层函数把它“锁住”。
4. 模块:把函数放进 .py 的一门学问
4.1 模块就是一个 .py 文件
所有关于“模块”的知识,都可以压缩成一句话:任何一个 .py 文件都是一个模块。当你写下import math,Python 就是去某个路径下找到了math.py(实际是内置模块或标准库的二进制实现),加载并执行它,然后把模块对象绑定到math这个名字上。
按导入方式,常见有三种:
# 方式一:导入整个模块,通过模块名访问 import math print(math.sqrt(16)) # 方式二:从模块中导入具体名字 from math import sqrt, pi print(sqrt(16)) print(pi) # 方式三:给模块或名字起别名 import numpy as np from collections import OrderedDict as OD方式一和方式二各有取舍。方式一的好处是命名空间明确,你看到math.sqrt,就知道这个函数来自 math 模块,不容易和自定义函数冲突;缺点是多打几个字。方式二代码更简洁,但当你from math import sqrt之后,如果再定义一个同名sqrt,后者会把前者遮蔽掉,极易产生隐蔽错误。我个人偏好:标准库和大模块用方式一,自己项目内部代码用方式二。
还有一个看着方便但要慎用的写法:from module import *。它会把模块里所有不以_开头的名字一股脑导进当前命名空间。之所以要慎用,是因为你不知道导入了哪些名字,也没法立刻看出来和已有名字有没有冲突。如果你想公开发布一个明确的公共接口,应该在模块里定义__all__列表:
# myutils.py __all__ = ["process_data", "clean_text"] def process_data(data): ... def clean_text(text): ... def _private_helper(text): ... # 下划线开头,不会被 import *这样from myutils import *只导入process_data和clean_text。import *在使用上仍建议只用在交互式环境或明确的工具模块里,正式项目里我更推荐显式导入具体函数。
4.2 import 的查找顺序与 sys.path
当你执行import mymodule时,Python 做的事情不止是“找到文件”这么简单。它的查找路径主要由sys.path决定,这个列表包含四个来源:
- 当前脚本所在目录,如果交互式环境就是当前工作目录。
PYTHONPATH环境变量中指定的路径。- 标准库所在目录与 site-packages 等第三方库安装目录。
想确认实际路径,直接打印:
import sys for path in sys.path: print(path)这里有一个高频问题:给自己的 .py 文件起名不要和标准库或常用第三方库重名。比如你建一个random.py,然后import random就会导入你自己的文件而不是标准库 random。轻则拿不到你想用的函数,重则导致其他依赖标准库 random 的代码集体报错。我在实际项目里就见过有人建了struct.py,结果整个项目的二进制解包逻辑全线崩溃,排查了一个多小时才发现是文件名冲突。
另一个高频问题是“找不到模块”。初学者把module.py放在别处,然后在另一个目录的脚本里import module,结果 ModuleNotFoundError。这时候你需要做的就是检查sys.path是否包含该文件所在目录。两种常见解决路径:一种是把当前工作目录切到文件所在目录,另一种是把目录加入PYTHONPATH环境变量。注意,Python 不会自动搜索“脚本的同级目录的子目录的兄弟目录”,一切以sys.path为准。
4.3 ifname== "main" 到底在判断什么
这是 Python 初学者最迷惑的代码之一,真正懂了之后它又特别简单。每个模块在被运行时,Python 都会给它设置一个隐藏变量__name__。当模块作为主脚本直接运行时,__name__被设置为字符串"__main__";当模块被 import 导入时,__name__被设置为模块自己的名字,比如导入tools.py时它是"tools"。
来做一个直观实验。文件demo.py:
print("模块名:", __name__) if __name__ == "__main__": print("我是作为主脚本运行的") else: print("我是被导入的")直接运行python demo.py,输出模块名: __main__;在另一个文件里import demo,输出模块名: demo。所以if __name__ == "__main__"的本质是在问:这个文件是被直接执行的,还是被当作模块引入的?
实际价值在于:一个 .py 文件可以有两种身份——当工具用(直接运行,执行测试或演示代码),当库用(被 import,只暴露函数和类)。把只应在“直接运行时”执行的代码放进if __name__ == "__main__"块,就能避免被导入时执行多余的副作用。我写项目时,几乎每个模块底部都会保留这样一个入口,用来做快速自测。
4.4 还值得了解的:模块缓存与 reload
Python 对已导入的模块有缓存,存放在sys.modules字典里。同一个模块即使被多个文件 import,也只会真正执行一次模块代码。这个设计的好处是提高效率,坏处是修改模块代码后,已经导入的旧模块不会自动更新。在交互式环境调试时可以用importlib.reload(module)强制重载,但在正式项目中尽量不要依赖 reload,而是重新启动进程。
统一点来说,sys.modules是“已经加载过的模块”,sys.path是“去哪找还没加载的模块”。两者不要混淆。排查导入问题时,先看sys.path里路径对不对,再看sys.modules里模块是不是被旧版本缓存了。
5. 包:从单文件脚本进化到项目结构的最后一公里
5.1 目录与init.py:包到底是什么
当文件越来越多,你会自然想把相关的模块放进同一个目录。这个目录如果被 Python 当作一个整体来导入,就是“包”。最传统的包结构是这样的:
myproject/ ├── main.py └── mypackage/ ├── __init__.py ├── math_tools.py └── text_tools.py只要目录里有__init__.py,Python 就会把这个目录当成一个包。__init__.py有两个作用:一是标记目录,二是初始化包。历史上(Python 3.3 之前)没有这个文件就没办法 import;现在即使没有它,Python 也能通过“命名空间包”机制导入,但为了兼容性和明确的初始化逻辑,我仍然建议保留。
__init__.py最常见的实用价值是二次导出。比如mypackage/math_tools.py里有distance函数,你希望使用方直接from mypackage import distance而不是from mypackage.math_tools import distance,就可以在__init__.py里写:
# mypackage/__init__.py from .math_tools import distance from .text_tools import normalize __all__ = ["distance", "normalize"]这样一来,包的对外 API 变得非常清爽。内部文件想怎么拆就怎么拆,外部使用者只需要面对__init__.py暴露的接口。这是我在维护项目时认为最值得养成的好习惯:用init.py 控制包的公共界面,而不是让使用方去深入内部目录。
5.2 绝对导入与相对导入:别在“点”上栽跟头
包内模块互相导入时,有两种写法。一种是绝对导入,从包的最顶层开始写:
# mypackage/text_tools.py from mypackage.math_tools import distance另一种是相对导入,用.表示当前包,..表示上一级包:
# mypackage/text_tools.py from .math_tools import distance from ..parent_package import helper相对导入看起来省事,但它有一个严格的限制:相对导入只能在包内使用,不能直接运行包内的某个文件当脚本。比如你在 myproject 目录下执行python mypackage/text_tools.py,Python 会把mypackage/text_tools.py当作顶层模块运行,此时它里面写from .math_tools import xxx就会报ImportError: attempted relative import beyond top-level package。原因是“.”所代表的包上下文已经不存在了。
这个坑十个人里至少有八个踩过。我给的建议是:写包内模块时优先用绝对导入,因为绝对导入的路径清晰、不受运行方式影响。只有在做一个不可能作为单独脚本运行的包内部模块时,才考虑相对导入。另外就是要重新理解运行入口:一个完整项目,通常只有一个主入口文件从项目根目录运行,其他模块都以“包内成员”的身份被导入,而不是被单独执行。把“目录结构”和“运行入口”的边界想清楚,导入报错能少掉一大半。
5.3 第三方包、venv 与 requirements:别再往环境里乱装
模块是自带能力,包目录是你自己组织的 API,而第三方包则是从 PyPI 上安装的外部库。安装工具基本就是 pip:pip install numpy、pip install requests这样。你可能会看到一句话:“Python 有多少包,生态就有多强大”,这话大体不假,但真正重要的不是装了多少包,而是你怎么管理这些包。
最容易被忽略的是虚拟环境。所谓虚拟环境,就是给当前项目单独建一个 Python 环境,这样不同项目之间互相隔离,互不污染。命令很简单:
# 创建虚拟环境,一般命名为 venv python -m venv venv # 激活虚拟环境 # Windows: venv\Scripts\activate # Linux / macOS: source venv/bin/activate # 退出虚拟环境 deactivate激活以后,你的pip install只会装到当前虚拟环境里,不会影响系统的 Python。这能避免一个项目要 A 版本、另一个项目要 B 版本时互相打架。
一个项目要可复现,就要把依赖版本固化下来。最朴素的做法是:
pip freeze > requirements.txt然后别人拿到项目后执行:
pip install -r requirements.txtpip freeze会列出当前环境中所有包和版本号,好处是完整,坏处是它会把你环境里无关的包也带进去。更推荐的做法是只记录你代码里直接import的顶层依赖,用pip install requests==2.31.0这类精确版本,或者干脆只写主版本约束。如果你装的是 numpy、pandas、opencv-python 这类重包,版本冲突是家常便饭,这时候可以优先用虚拟环境把项目隔离开来,避免反复卸载重装。
5.4 亲手搭一个最小可发布的项目结构
现在把前面所有点串起来。我以一个非常常见的场景举例:假设你想做一个“文本小工具”包,内部有两个模块,一个负责统计词频,一个负责清洗文本,最后能从 main.py 里调用它。
目录结构如下:
textstats/ ├── main.py └── textstats/ ├── __init__.py ├── cleaner.py └── counter.pytextstats/cleaner.py先写清洗函数:
import re def remove_punctuation(text: str) -> str: """去除常见中英文标点。""" return re.sub(r"[,。!?、;:,.!?;:]", " ", text) def normalize_spaces(text: str) -> str: """把连续空白压缩为单个空格。""" return re.sub(r"\s+", " ", text).strip()textstats/counter.py写词频统计:
from collections import Counter def word_frequency(text: str): """统计每个词出现的次数,返回 Counter 对象。""" words = text.split() return Counter(words)textstats/__init__.py控制公共接口:
from .cleaner import normalize_spaces, remove_punctuation from .counter import word_frequency __all__ = ["normalize_spaces", "remove_punctuation", "word_frequency"]main.py作为唯一的运行入口:
from textstats import normalize_spaces, remove_punctuation, word_frequency raw = "Python 函数,模块与包;这是 一个 简单的测试。" cleaned = remove_punctuation(raw) cleaned = normalize_spaces(cleaned) freq = word_frequency(cleaned) print(freq)在这个结构里有几个细节值得记住。第一,main.py与textstats包放在同一层,main.py运行时sys.path包含当前目录,所以from textstats import ...能直接工作。第二,包内模块cleaner.py和counter.py使用的是绝对导入,在__init__.py里用from .cleaner import ...这类相对导入,因为__init__.py明确知道自己属于textstats包。第三,你只需要把一个textstats/目录和main.py打包给别人,别人就可以直接运行或引用,不会出现找不到模块的问题。
如果你想再进一步,让这个包可以被 pip 安装,就需要加入pyproject.toml或setup.py这类打包配置文件,内容会涉及包元数据、依赖声明、入口点等。这是另一套完整的知识体系,这一章先不展开。但你从上面这个最小结构再到 pip 安装包,理解起来会比直接看一团乱的文件要顺得多。
最后再说一个我用 Python 组织项目时的习惯:函数尽量短小——如果一个函数超过三四十行,就考虑拆成多个子函数,让每个子函数做一件事;模块之间通过__init__.py暴露接口,模块内部可以随意重组;入口文件保持极薄,它只负责“读取输入、调用包逻辑、输出结果”这几个动作,把所有真实计算都放进包里。这样写的代码,责任边界清晰,调试时也能很快定位问题。函数负责“一行逻辑的复用”,模块负责“一组函数的复用”,包负责“一组模块的复用”——三个层次各司其职,你的 Python 项目才真正从“能跑”走向了“能维护”。