第 11 章 · 模块、包、可见性与依赖管理
解决什么问题:代码多到一个文件放不下时,怎么切开、怎么决定哪些名字给外面用,又怎么引入别人写的代码并把版本固定下来。
四种语言的态度:Python 靠文件和下划线约定,JS/TS 靠文件和
export,C# 靠程序集和public、internal,Go 靠包目录和首字母大小写。
把“计算含税价”放进独立模块 pricing,只公开 with_tax(price),税率 13% 和四舍五入辅助函数留在内部。主程序 app 导入它,算原价 100 和折后价 90 的含税价。Python、JS、TS 用同一目录下的两个文件;C# 和 Go 的示例只能单文件运行,就在一个文件里演示规则,真实项目的多包组织在正文和图里讲。
模块(module)解决两个问题。一是命名空间(namespace):pricing 和报表代码各有一个 round2,各在各的模块里就不会互相覆盖。二是封装(encapsulation):调用方只用 with_tax,以后税率改成按地区查表、round2 改名,调用方一行不用改;调用方若直接用了 round2,改名那天就会报错。
决定哪些名字对外有三种做法。约定:语言不拦,大家约好下划线开头的不碰,如 Python。关键字:名字前写 public、internal、private,由编译器检查,如 C#;JS/TS 的 export 只有导出和不导出两档。命名规则:名字本身带着可见性,Go 规定首字母大写才导出。
看每一栏里 app 伸向内部名字的虚线:只有 Python 那条走得通;再看外框分别是文件、程序集和包目录。
依赖管理(dependency management)由三样东西组成:清单文件(manifest)写直接依赖和可接受的版本范围;锁文件(lock file)记下实际装上的每个包(含间接依赖)的确切版本和哈希,换台机器能装出同一批字节;版本选择算法决定范围内挑哪个版本。npm 和 uv 挑最新的,下个月装出来可能不同,要靠锁文件固定;NuGet 挑满足范围的最低版本,Go 取各处要求里最高的那个下限,都不会自动挑更新的,结果本身就确定。
Python 怎么做
Section titled “Python 怎么做”Python 的模块是原生的:一个 .py 文件是一个模块,一个目录是一个包(package),通常带 __init__.py。可见性只有约定。
"""pricing 模块:对外只公开 with_tax。"""
__all__ = ["with_tax"] # 只影响 from pricing import *
_TAX_RATE_PERCENT = 13 # 下划线开头:约定为内部使用,但谁都能导入
def _round2(value: float) -> float: return round(value, 2)
def with_tax(price: int) -> float: return _round2(price * (100 + _TAX_RATE_PERCENT) / 100)import sys
import pricing # 第一次导入:执行 pricing.py 的顶层代码,模块对象存进 sys.modulesfrom pricing import with_tax # 第二次:直接从 sys.modules 取,不再执行
assert sys.modules["pricing"] is pricingprint(f"含税价:{with_tax(100):.2f}")print(f"折后含税价:{pricing.with_tax(90):.2f}")
# 输出:# 含税价:113.00# 折后含税价:101.70运行:python app.py;单独运行 pricing.py 什么也不打印。from pricing import _round2 照样成功;__all__ 只决定 from pricing import * 拿到哪些名字。依赖写在 pyproject.toml,uv 生成 uv.lock;2025 年的 PEP 751 另定了标准锁文件格式 pylock.toml。
底层怎么实现。 import pricing 先查字典 sys.modules,有就直接返回;没有就沿 sys.path(第一项是脚本所在目录)找到文件,建一个空模块对象,先放进 sys.modules,再执行顶层代码,定义出的名字成为模块的属性。所以一个模块在一个进程里只执行一次。
- 循环导入:
a导入b,b又from a import x,这时a只执行了一半,还没有x,于是抛ImportError。 - 和标准库重名:自己的文件叫
random.py,脚本目录又排在sys.path最前面,import random拿到的就是它。
JS/TS 怎么做
Section titled “JS/TS 怎么做”JavaScript 有两套模块系统:Node 早年用的 CommonJS(require、module.exports)和 ES2015 写进标准的 ES 模块(ESM,import、export)。Node 看最近的 package.json 的 "type" 字段:"module" 时 .js 按 ESM 加载,否则按 CommonJS;.mjs、.cjs 不受它影响。
// pricing 模块:只有 export 出去的名字在模块外可见const TAX_RATE_PERCENT = 13; // 没有 export:别的模块拿不到
function round2(value) { return Math.round(value * 100) / 100;}
export function withTax(price) { return round2((price * (100 + TAX_RATE_PERCENT)) / 100);}import { withTax } from "./pricing.js"; // ES 模块的相对路径要写出扩展名
console.log(`含税价:${withTax(100).toFixed(2)}`);console.log(`折后含税价:${withTax(90).toFixed(2)}`);
// 输出:// 含税价:113.00// 折后含税价:101.70运行:node app.js(package.json 写了 "type": "module")。没有 export 的 round2 在模块外取不到。导出是活绑定(live binding):导出的 let count 在 pricing.js 里被修改,app.js 读到的是新值,自己赋值则抛 TypeError。import x from "lodash" 这样的裸名字,Node 从当前目录逐级往上找 node_modules/lodash。版本范围写在 package.json,确切版本和哈希写在 package-lock.json。
底层怎么实现。 ESM 先只解析 import、export,把模块图找齐;再链接,导入方的名字直接指向导出方变量的存储位置,这就是活绑定;最后从依赖末端起逐个执行,每个模块只执行一次。CommonJS 的 require 是同步函数调用:Node 把文件包进一个带 exports、require、module、__dirname 等参数的函数里执行,返回 module.exports。
- ESM 里没有
__dirname:它是 CommonJS 包装函数的参数,ESM 里引用抛ReferenceError;改用import.meta.dirname(Node 20.11 起)。 - 混用两套系统:CommonJS 里
require一个 ESM 包,Node 22.12(20.x 为 20.19)之前报ERR_REQUIRE_ESM;同一个包的两种版本都被加载时,会有两份互不相通的状态。
TypeScript 用 ESM 的写法,另外多了只导入导出类型的 import type、export type。可见性规则和 JS 相同。
// pricing 模块:只有 export 出去的名字在模块外可见export type Yuan = number; // 只有类型:编译成 JS 后这一行整个消失
const TAX_RATE_PERCENT = 13;
function round2(value: number): number { return Math.round(value * 100) / 100;}
export function withTax(price: Yuan): Yuan { return round2((price * (100 + TAX_RATE_PERCENT)) / 100);}// 写 .js:tsc 和 tsx 都会把它对应到 pricing.tsimport { withTax, type Yuan } from "./pricing.js";
const list: Yuan = 100;const sale: Yuan = 90;console.log(`含税价:${withTax(list).toFixed(2)}`);console.log(`折后含税价:${withTax(sale).toFixed(2)}`);
// 输出:// 含税价:113.00// 折后含税价:101.70运行:tsx app.ts。路径写 ./pricing.js,磁盘上只有 pricing.ts:TS 约定写编译后的文件名,编译器反查源文件。选项 moduleResolution 决定怎么找:nodenext 照搬 Node 规则,ESM 里必须写扩展名;bundler 模仿打包工具,可以不写。
底层怎么实现。 tsc 按 moduleResolution 找文件、跨文件检查类型,输出时删掉只涉及类型的导入导出。Node 自带的类型剥离(type stripping)一次只看一个文件,分不出别处的 Yuan 是类型还是值,要靠 type 标记;tsx 用的 esbuild 会删掉只在类型位置用到的导入。verbatimModuleSyntax 让编译器强制写清楚:带 type 的删,不带的留。
- 扩展名:
"./pricing"在bundler和 tsx 下能用,在nodenext的 ESM 里报 TS2835,Node 直接运行时找不到文件;"./pricing.ts"是 Node 直接运行要的写法,tsc却要打开allowImportingTsExtensions。本章用的./pricing.js在tsc和 tsx 下都通过。 - 导入类型忘写
type:tsx 照常运行,verbatimModuleSyntax下报 TS1484,Node 直接运行时抛SyntaxError,说没有名为Yuan的导出。
C# 怎么做
Section titled “C# 怎么做”C# 靠关键字,原生支持。命名空间只是给类型名分组;程序集(assembly,一个 .dll)才是部署和可见性的边界。
using System;using System.Globalization;using Shop;
// 单文件演示。真实项目里 Pricing 放在另一个项目(Shop.Pricing.csproj,编译成另一个程序集),// 那时 app 只能用 public 的 WithTax;internal 的 TaxRatePercent 只在那个程序集里可见。// 这里两者在同一个程序集里,所以 app 其实也能读 Pricing.TaxRatePercent。var inv = CultureInfo.InvariantCulture;Console.WriteLine($"含税价:{Pricing.WithTax(100).ToString("F2", inv)}");Console.WriteLine($"折后含税价:{Pricing.WithTax(90).ToString("F2", inv)}");
namespace Shop // 命名空间只是名字的分组,不管可见性{ public static class Pricing { internal const int TaxRatePercent = 13; // 同一程序集里可见
private static decimal Round2(decimal value) => Math.Round(value, 2); // 只有 Pricing 内部可见
public static decimal WithTax(int price) => Round2(price * (100 + TaxRatePercent) / 100m); }}
// 输出:// 含税价:113.00// 折后含税价:101.70运行:dotnet run app.cs。这个单文件不是真实项目的样子:真实项目有 Shop.Pricing.csproj 和 App.csproj 两个项目,后者用 ProjectReference 引用前者,编译出两个程序集,internal 才把 App 挡在外面。private 限于所在类型;C# 11 的 file 让类型只在所在源文件里可见;顶层类型默认 internal,成员默认 private。第三方包写成 .csproj 里的 PackageReference,由 NuGet 下载;锁文件 packages.lock.json 要打开才生成。
底层怎么实现。 编译器检查访问权限,并把修饰符写进元数据,运行时即时编译时会再查一遍。命名空间在 IL 里只是类型全名的一部分,运行时用“程序集加全名”认类型,所以两个程序集可以往同一个命名空间放类型。
- 以为命名空间能控制可见性:类放进
Shop.Internal挡不住任何人,同一个项目里internal也对所有代码可见。 - 测试项目是另一个程序集,看不到
internal,要在被测程序集上加InternalsVisibleTo放行。 - 反射用
BindingFlags.NonPublic能读private字段:修饰符防误用,不是安全边界。
Go 怎么做
Section titled “Go 怎么做”Go 的规则原生且不用关键字:一个目录是一个包(package),首字母大写的名字导出,小写的只在本包可见;带 go.mod 的目录树是一个模块(module)。
package main
import ( "fmt" "math")
// 单文件演示。真实项目里下面三个名字放在另一个包 pricing(目录 pricing/)里,// main 包写 import "example.com/shop/pricing",再调用 pricing.WithTax(100);// 小写开头的 taxRatePercent 和 round2 在包外引用会编译失败。// 同一个包里大小写不影响访问,所以这里 main 都能用。
const taxRatePercent = 13 // 小写开头:只在本包可见
func round2(v float64) float64 { return math.Round(v*100) / 100 }
// WithTax 大写开头:导出,别的包可以调用。func WithTax(price int) float64 { return round2(float64(price*(100+taxRatePercent)) / 100)}
func main() { fmt.Printf("含税价:%.2f\n", WithTax(100)) fmt.Printf("折后含税价:%.2f\n", WithTax(90))}
// 输出:// 含税价:113.00// 折后含税价:101.70运行:go run app.go。这个单文件同样不是真实布局:真实项目在 shop/ 放 go.mod(module example.com/shop),shop/pricing/pricing.go 写 package pricing,shop/cmd/app/main.go 是 package main。路径里带 internal 的目录另有限制:shop/internal/tax 只能被 shop/ 下的代码导入。
底层怎么实现。 导出与否看首字母就知道,编译器不需要额外元数据;internal 由 go 命令按导入路径检查。包之间禁止循环导入(import cycle not allowed),依赖图是有向无环图,可以从底层逐包编译。版本用最小版本选择(Minimal Version Selection,MVS):require 写的是“至少这个版本”,构建时对每个模块取各处要求里最高的那个,从不自动挑更新的,go.mod 本身就记录了确切要求,所以不用锁文件也能重现。go.sum 记录模块内容的哈希,新模块下载时还要和校验和数据库 sum.golang.org 核对。
- 结构体字段小写就不导出,
encoding/json也看不到,struct{ name string }序列化成{},不报错。 - 包名和目录名不一致:导入路径用目录名,代码里用
package声明的名字,目录叫pricing-v2、包叫pricing时很难看出pricing.WithTax从哪来。 internal按目录层级判断:shop/a/internal/x连同一模块的shop/b都不能导入。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 原生 | 原生(ESM、CommonJS 两套) | 原生 | 原生 |
| 实现机制 | 执行顶层代码 + sys.modules 缓存 |
ESM 先链接再执行,活绑定 | 编译器检查 + 元数据中的访问修饰符 | 按包编译,首字母决定导出 |
| 模块单位 | 文件;包是目录 | 文件 | 程序集;命名空间只是分组 | 包是目录;模块由 go.mod 定义 |
| 可见性规则 | 下划线约定、__all__ |
export |
public、internal、private、file |
首字母大写、internal/ 目录 |
| 依赖清单 | pyproject.toml |
package.json |
.csproj 的 PackageReference |
go.mod |
| 锁文件 | uv.lock / pylock.toml |
package-lock.json |
packages.lock.json(需打开) |
不需要;go.sum 只校验内容 |
| 版本选择 | 范围内最新 | 范围内最新 | 满足要求的最低版本 | 最小版本选择 |
差别最大的是“可见性规则”一行:同样是“不给外面用”,Python 只写给人看,JS/TS 和 Go 按文件或包挡住,C# 挡在程序集这一层。
Python 社区说“我们都是成年人”(we are all consenting adults):语言不设访问控制,下划线只提示“这是内部的”。好处是测试和调试时可以直接替换内部函数;后果是没有工具阻止调用方依赖 _round2,库作者改名时只能靠文档免责。
JavaScript 在浏览器里长期没有模块系统,脚本共享一个全局作用域。2009 年社区为服务端造了 CommonJS,Node 和 npm 生态都建在它上面;标准 ESM 到 2015 年才有,为了浏览器能异步加载,设计成静态的、先解析后执行。Node 丢不掉已有的 CommonJS 包,只好两套并存,用 "type" 切换,混用的问题由此而来。
C# 跟着 .NET 的部署模型走:程序集是独立分发、带版本号的 .dll,internal 的意思就是“不出这个 .dll”,命名空间只管避免重名。后果是同一个项目里的代码彼此没有屏障,要更硬的边界就得拆项目。
Go 诞生在 Google 的大型代码库里,设计者要的是编译快、规则一眼可见:调用处看到 pricing.WithTax 的大写就知道它导出,禁止循环导入让编译可以逐包进行。Russ Cox 2018 年设计最小版本选择,理由是构建结果只该由 go.mod 决定,不该随“今天最新的是哪版”变化。后果是升级依赖要显式执行 go get,别人发布新版本不会悄悄改变你的构建。
- 先定公开接口:Python 用
__all__和下划线写清意图,JS/TS 只export需要的名字,C# 类型默认留internal,Go 先写小写。 - 锁文件提交进版本库,CI 用
uv sync --locked、npm ci这类严格照锁文件安装的命令;Go 提交go.sum。 - TS 项目统一导入写法:用
nodenext就写.js扩展名并打开verbatimModuleSyntax。 - 需要硬边界时,C# 拆程序集,Go 放进
internal/;Python 只能靠代码审查和检查工具。
Python:写一个模块,含 __all__ = ["public"]、公开函数 public 和内部函数 _helper,在另一段代码里直接导入 _helper 并调用,再看 from 模块 import * 拿到哪些名字,都打印出来。Go:在一个文件里定义一个导出、一个不导出的标识符,打印每个名字能不能被别的包使用。
提示:Python 的模块可以在临时目录里写出来,再把目录加进 sys.path。Go 的 go/token 包能按名字判断是否导出;单文件里 main 两个都能用,为什么结论仍然成立?
- 模块解决命名空间和封装;可见性有约定(Python)、关键字(C#,以及 JS/TS 的
export)、命名规则(Go)三种做法。 - 边界:Python 和 JS/TS 是文件,C# 是程序集而非命名空间,Go 是包目录,
internal/再按层级收紧。 - Python 导入时执行一次并缓存进
sys.modules;ESM 先链接成活绑定再执行;Go 禁止循环导入。 - npm、uv 挑范围内最新,靠锁文件重现;NuGet 挑最低版本,Go 的最小版本选择不用锁文件也能重现,
go.sum只校验内容。