“我学过 Python 第一课:print('hello world')。”
这行代码几乎每个人都写过。但如果你现在打开搜索引擎,输入print这个词,排在前面的大概率不是 Python 教程,而是“打印服务 print spooler 启动报错 193”“hp print and scan doctor”这一类的打印机问题。这个反差本身就说明了一件事:Python 开发者对print的认知,普遍停留在“它能输出文字”这一层,很少有人认真问一句:这一行代码,从按下回车到屏幕上出现 hello world,CPython 到底做了多少事?
答案比你想象的要长得多。print不是 Python 的语法,只是一个内建函数;而你写下的那段源码,也不会被 CPython 直接执行。它会先被切碎、再被组装成语法树、又被编译成一种叫“字节码”的中间指令,最后由一个虚拟机器逐条解释执行,最终调用操作系统接口把字符写到终端。
这篇文章要做的事很明确:以print("hello world")这一行代码为线索,把 CPython 从源码到屏幕输出的完整执行链路拆开看清楚。同时,再回到真实项目里,看看print的高阶用法、常见坑,以及什么时候不应该用print。
1. 这篇文章真正要解决的问题
很多 Python 开发者能写出能跑的业务代码,但遇到几类问题时只能靠猜。
比如,为什么print在多线程程序里输出会乱序?为什么同一个print在终端里立刻出现,管道重定向到文件后却迟迟看不到内容?为什么有人不小心写了print = "abc"之后,整个文件就报TypeError: 'str' object is not callable?这些问题如果只看print外表,永远解释不清;但只要理解了 CPython 的执行模型,它们就都是同一个问题:名字查找和输出缓冲。
本文的核心收益是:帮你在脑子里建立一个完整的链路模型。
我建议你把下面这条链路当成一个“管道”来记:
源码文本 -> 词法分析 tokenize -> 语法分析 ast -> 编译 compile -> 字节码 code object -> 虚拟机解释执行 -> 系统 stdout -> 终端显示建立这个模型之后,你再遇到“Python 报错”“输出异常”“环境配置问题”,就不需要死记硬背错误信息了。你会知道错误发生在第几个环节、该用哪个标准库去观察它。
这篇文章适合下面几类读者:
- Python 入门者:你最早学的就是
print,从它入手理解 Python 最自然。 - 想深入语言原理的开发者:本文会带你实际操作
tokenize、ast、dis三个标准库。 - 准备面试的学生:
print是不是关键字、Python 是编译型还是解释型,这类问题在面试里出现频率不低。 - 想排查 Python 疑难环境问题的工程师:理解执行链路后,许多“诡异问题”会变得非常清晰。
先做一个小判断:print是理解 CPython 的最好切入点,没有之一。
2. 环境准备与前置条件
本文的实践代码全部基于 Python 官方实现 CPython。建议使用 Python 3.8 以上版本,本文示例按 Python 3.10 / 3.11 的输出来讲。不同小版本之间,字节码名称和偏移量可能略有差异,这不影响理解主线。
先确认你的本机 Python 环境是否可用。
在终端执行:
python --version如果这条命令没有输出,说明 Python 没有加入系统 PATH,或者你的机器上 Python 命令不叫python。Windows 下可以试试:
py -3 --versionLinux / macOS 下可以试试:
python3 --version如果你平时使用 VSCode 写 Python,推荐先做两件事:
- 安装官方 Python 扩展。
- 按
Ctrl + Shift + P,执行Python: Select Interpreter,选择你刚确认好的那个解释器。
这一步很关键。VSCode 里出现“print 跑了没输出”这类问题,多半是解释器选错了,或者你正在运行的不是当前编辑的文件。
建议给本文的练习单独建一个虚拟环境,避免污染全局环境:
python -m venv .venvWindows 激活:
.venv\Scripts\activateLinux / macOS 激活:
source .venv/bin/activate下面所有代码都不需要安装第三方包,只用 Python 标准库。
3. 先破除误区:print 是内建函数,不是 Python 关键字
先说一个最常见的误区。
很多人把print("hello world")当成“语法”,以为它和if、for、def一样是 Python 的关键字。实际上完全不是。
在 Python 交互式环境里执行:
print(print)输出是:
<class 'builtin_function_or_method'>这说明print是一个内建函数对象,类型是builtin_function_or_method。它由 CPython 官方实现提供,和你用def定义的函数相比,来源不同,但调用方式一致。
再进一步确认:
import builtins print(builtins.print is print) # Trueprint这个名字,实际上是从builtins模块里导入到全局命名空间的一个对象。Python 在启动解释器时,会把builtins模块中的函数、类型自动暴露出来,让你不用写import builtins就能直接用print、len、range。
这个设计带来一个重要后果:print的名字可以被覆盖。
# 仅用于理解名字查找机制,不要在生产代码里这么做 print = "out of service"执行到这行之后,当前作用域里的print就不再是函数,而是一个字符串。此时再调用:
print("hello")会报错:
TypeError: 'str' object is not callable这个报错信息本身,恰恰证明了print只是一个可被替换的名字。Python 执行字节码时,会按照“局部作用域 -> 全局作用域 -> 内建作用域”的顺序查找这个名字。你在局部作用域里把它覆盖成字符串,解释器找不到可调用的函数对象,于是抛出类型错误。
再来看print的函数签名:
print(*objects, sep=' ', end='\n', file=sys.stdout, flush=False)这个签名非常关键。它意味着:
file参数决定输出到哪里,默认是sys.stdout。end参数决定打印完后追加什么,默认是换行符。sep参数决定多个对象之间用什么分隔。flush参数决定是否立即刷新缓冲区。
如果只看print("hello world")这一行,你根本不会意识到后面还有这么多参数。但它们是后面很多实战技巧的基础。
结论:print是一个函数对象,是一个可以传递、可以赋值、可以被替换的名字。
4. CPython 执行流程总览
先明确一个概念:CPython 是 Python 语言的参考实现,也是官网默认下载的那个 Python。它由 C 语言写成,核心工作包含三部分:编译器、虚拟机、标准库。
Python 被归类为“解释型语言”,这没有错,但不够完整。CPython 并不是直接逐行读取你的源码并执行,而是先完成一次“编译”,把源码编译成字节码。字节码是 Python 虚拟机可以识别的一种中间指令序列。
整个流程可以分成五个阶段:
| 阶段 | 作用 | 对应工具 |
|---|---|---|
| 源码文本 | 你写下的.py文件内容 | 普通文本 |
| 词法分析 | 把文本切成有意义的 token | tokenize模块 |
| 语法分析 | 把 token 组装成语法树 | ast模块 |
| 编译 | 把语法树编译成字节码对象 | compile()函数 /.pyc文件 |
| 解释执行 | 虚拟机逐条执行字节码 | CPython 虚拟机 |
需要强调一个细节:Python 里“编译”和“解释”并不冲突。CPython 先把.py文件编译成字节码,再把字节码交给虚拟机执行。你会在项目里看到__pycache__文件夹,里面存的.pyc文件,就是字节码缓存。第二次运行同一份源码时,CPython 会优先加载.pyc,从而省掉一次编译开销。
这里还有一个很多人搞混的点:字节码不是机器码。
机器码是 CPU 直接执行的二进制指令,不同 CPU 架构不兼容。字节码是虚拟机的“指令”,只面向 Python 虚拟机,与底层 CPU 无关。这也是为什么同一份.py源码,在 Windows、Linux、macOS 上都能运行,但__pycache__里的.pyc缓存不能跨平台复制。
下一章,我们用一行代码,把这条链路的每一步都“看”到。
5. 亲手拆解:从源码到字节码
这一章是全文的核心。建议你打开 Python 交互式环境,或者新建一个demo.py,把下面的代码逐个跑一遍。
我们统一使用这一行源码:
print('hello world')5.1 词法分析:看 token
词法分析要做的事情,是把print('hello world')这串字符,拆成一个个有类型的 token。
用标准库tokenize观察:
import io import tokenize source = "print('hello world')" tokens = list(tokenize.generate_tokens(io.StringIO(source).readline)) for tok in tokens: print(tok)输出结果类似于:
TokenInfo(type=1 (NAME), string='print', start=(1, 0), end=(1, 5), line='print(\'hello world\')') TokenInfo(type=3 (STRING), string="'hello world'", start=(1, 6), end=(1, 20), line='print(\'hello world\')') TokenInfo(type=54 (OP), string='(', start=(1, 5), end=(1, 6), line='print(\'hello world\')') TokenInfo(type=54 (OP), string=')', start=(1, 21), end=(1, 22), line='print(\'hello world\')') TokenInfo(type=4 (NEWLINE), string='', start=(1, 23), end=(1, 23), line='print(\'hello world\')') TokenInfo(type=0 (ENDMARKER), string='', start=(2, 0), end=(2, 0), line='')可以看到,源码被拆成了几种 token:
NAME:标识符,这里就是print。STRING:字符串字面量,这里是'hello world'。OP:操作符/分隔符,这里是括号。NEWLINE:换行符,表示一条语句结束。ENDMARKER:整个源码结束标记。
词法分析阶段会直接发现一些低级错误,比如字符串引号缺失。如果source = "print('hello world)",那个未闭合的引号在词法分析阶段就会报错。
5.2 语法分析:看 AST
词法分析只负责“切词”,不负责理解结构。语法分析阶段会把 token 组装成一棵抽象语法树,也就是 AST。
用标准库ast观察:
import ast source = "print('hello world')" tree = ast.parse(source) print(ast.dump(tree, indent=4))输出一棵树状结构:
Module( body=[ Expr( value=Call( func=Name(id='print', ctx=Load()), args=[ Constant(value='hello world')], keywords=[]))])AST 的结构可以这么理解:
Module是整棵树的根,代表一个 Python 模块。Expr表示这是一个表达式语句。Call表示这是一个函数调用。Name(id='print')表示这个调用的是名为print的对象。Constant(value='hello world')表示传给它的参数是字符串常量。
语法分析阶段负责发现语法错误。比如你少写了一个右括号:
ast.parse("print('hello world'")会直接抛出SyntaxError。这说明语法错误发生在编译阶段之前,程序还没开始“运行”。
5.3 编译成字节码:看 dis
AST 生成之后,CPython 会把它编译成字节码。标准库dis负责把字节码“反汇编”成可读的助记符。
import dis def hello(): print("hello world") dis.dis(hello)输出类似于:
2 0 LOAD_GLOBAL 0 (print) 2 LOAD_CONST 1 ('hello world') 4 CALL_FUNCTION 1 6 POP_TOP 8 LOAD_CONST 0 (None) 10 RETURN_VALUE不同 Python 版本里,CALL_FUNCTION在 3.11 之后可能显示为CALL,偏移量也可能不同。重点看这四类字节码的含义:
| 字节码 | 含义 |
|---|---|
LOAD_GLOBAL/LOAD_NAME | 按名字查找对象,并压入栈 |
LOAD_CONST | 从常量表中加载一个常量,压入栈 |
CALL_FUNCTION/CALL | 调用栈顶的函数,参数数量由操作数决定 |
POP_TOP | 弹出栈顶元素并丢弃,也就是忽略函数返回值 |
RETURN_VALUE | 函数返回None |
这段字节码的逻辑是:先加载print函数对象,再加载字符串常量'hello world',然后调用一次函数,最后把返回值丢掉。因为hello()函数没有写return,所以最后隐式返回None。
5.4 从 code object 中看数据
字节码里的LOAD_GLOBAL 0 (print)和LOAD_CONST 1 ('hello world'),这里的数字0和1是索引。它们指向的是代码对象内部的两张表:名字表和常量表。
用代码对象可以看得更清楚:
co = hello.__code__ print("co_names:", co.co_names) print("co_consts:", co.co_consts) print("co_varnames:", co.co_varnames)输出类似于:
co_names: ('print',) co_consts: (None, 'hello world') co_varnames: ()这里非常关键:
co_names里存的是'print'这个字符串,不是函数本身。解释器执行LOAD_GLOBAL时,才会拿这个名字去作用域里查找对应的函数对象。co_consts里存的是常量,包括None和'hello world'。字符串'hello world'被编译期当作常量保存。
这再次印证了第三章的观点:print只是一个名字,真正的函数对象是在运行时通过名字查找得到的。
6. 解释器执行字节码时,才真正走到了 print
很多人以为“CPython 执行 print”就是调用了 C 语言里的一个打印函数,这个想法在最终意义上是对的,但中间隔着虚拟机,并没有表面上那么直接。
字节码解释执行的起点,是 CPython 虚拟机的主循环。这个循环做的事情可以概括为:
取一条字节码指令 -> 解码指令 -> 执行指令 -> 取下一跳虚拟机是“基于栈”的。LOAD_GLOBAL会把查找到的对象压入栈,LOAD_CONST会把常量压入栈,CALL_FUNCTION从栈上取出函数和参数,执行完之后把返回值再压入栈。
具体到我们的例子,伪代码是这样的:
LOAD_GLOBAL 0:拿到co_names[0],也就是字符串'print',然后在当前作用域和内建作用域里查找,找到内建函数print,把它压入栈。LOAD_CONST 1:拿到co_consts[1],也就是'hello world',压入栈。CALL_FUNCTION 1:从栈上取一个函数和一个参数,执行函数调用。POP_TOP:调用完成后,print的返回值None在栈顶,直接丢弃。RETURN_VALUE:函数结束,返回None。
当你调用print("hello world")时,实际上发生了一连串 C 层操作。CPython 在 C 语言层面提供了print函数的实现(通常叫做builtin_print),它的步骤概括为:
- 遍历传入的参数对象;
- 调用每个对象转字符串的方法,得到字符串表示;
- 按照
sep参数拼接成完整字符串; - 在字符串末尾追加
end参数指定的结尾; - 把最终字符串写入
file参数指定的对象,默认是sys.stdout。
sys.stdout是 Python 标准输出流对象。它最终会把字符交给操作系统,写入标准输出文件描述符,也就是你终端窗口对应的那个文件描述符。终端拿到字符后,再渲染成显示在屏幕上的hello world。
所以,那句“print 输出到屏幕”的完整解释是:print 函数把对象转成字符串,写入 sys.stdout,sys.stdout 通过系统调用写入标准输出文件描述符,终端收到内容后显示出来。
这里还要回应一个非常常见的疑惑:为什么有时候print没有“立刻”显示?
罪魁祸首是缓冲。当我们把print的输出重定向到文件或管道时,Python 会对标准输出做块缓冲,而不是行缓冲。也就是说,程序攒够一定字节数才真正写入一次。如果程序中途崩溃,你可能会发现日志文件里根本没有刚才的输出。这也是为什么 Python 提供了一个很实用的命令行参数:
python -u script.py-u表示无缓冲。或者在每个print调用里显式加flush=True:
import time print("start...", flush=True) time.sleep(2) print("end...", flush=True)在练习环境里,flush=True能保证print的输出立即出现在屏幕上。
7. 回到实战:print 的高阶用法与业务场景
如果你只想了解原理,第六章就可以打住了。但“Python 实战应用开发”不能只停在原理,接下来看print在真实代码里最常见的几个用法。
7.1 模拟下载脚本:f-string 与随机等待
很多开发者在写脚本时,都喜欢用print打日志。一个典型的场景是“模拟下载任务”:
import time import random def download(name): print(f"开始下载 {name}") wait = random.randint(2, 5) print(f"预计等待 {wait} 秒") time.sleep(wait) print(f"{name} 下载成功") if __name__ == "__main__": download("Python入门01")运行结果可能是:
开始下载 Python入门01 预计等待 3 秒 Python入门01 下载成功这里有几个值得拆解的细节。
第一,random.randint(2, 5)返回闭区间[2, 5]内的随机整数,这个写法常用来模拟网络延迟。random.randint(a, b)两端的值都可能取到。
第二,time.sleep(wait)会让当前线程阻塞指定的秒数,它的返回值是None。如果你写成:
b = time.sleep(random.randint(1, 5)) print(b)就会输出None。原因很简单:time.sleep这个函数不返回时间,它只负责“睡一会儿”。不少初学者在这个地方踩过坑,以为sleep会返回休息了多久。
第三,f-string 会在运行时调用对象的__str__或__format__方法,把自己转成字符串。f"开始下载 {name}"本质上等价于:
"开始下载 " + str(name)但 f-string 的写法明显更直观。在 3.8 以上版本里,f-string 还支持调试语法:
x = 100 print(f"{x = }") # x = 1007.2 单行动态进度:end 与 \r
print每次默认以换行符结尾,这在打进度条时很麻烦。如果不想频繁换行,可以把end参数设置成空字符串,再配合\r回车符,实现一行动态刷新:
import time for i in range(101): print(f"\r下载进度: {i}%", end="", flush=True) time.sleep(0.05) print("\n下载完成")\r会把光标移动到当前行开头,所以每次输出会覆盖上一次内容,看起来就像一个动态进度条。注意这里必须加上flush=True,否则end=""意味着没有换行符,文本层缓冲可能不会及时把内容刷到终端,你会看到进度条卡住不动。
这个技巧在日常脚本里非常实用,比如批量处理文件时,显示“已处理第 N 个文件”。
7.3 sep 与 end:一次打印精简格式
print的sep参数可以用来控制多个对象之间的分隔符。最常见的需求是打印日期格式:
year, month, day = 2025, 4, 1 print(year, month, day, sep="-") # 2025-4-1也可以把多个值拼成一行,不换行:
print("2025", "04", "01", sep="-", end="\n") # 2025-04-01如果你有一个列表,想要按逗号拼接后输出,通常有几种写法:
items = ["Python", "print", "CPython"] print(",".join(items)) # 推荐 print(*items, sep=",") # 也可以第二种写法利用了*items把列表解包成多个位置参数。这两种方式都能得到Python,print,CPython,推荐场景不同:join更明确,*解包更灵活。
7.4 把 print 输出重定向到文件
项目脚本里,经常需要把print的内容同时输出到终端和文件。最简单的方式是用file参数:
with open("run.log", "w", encoding="utf-8") as fp: print("download finished", file=fp) print("elapsed: 3s", file=fp)另一种常见做法是临时替换sys.stdout:
import sys with open("run.log", "w", encoding="utf-8") as fp: old_stdout = sys.stdout sys.stdout = fp print("download finished") print("elapsed: 3s") sys.stdout = old_stdout这种写法在旧项目里很常见,但不推荐大规模使用,因为它会让print的行为变得隐式,而且容易忘记恢复sys.stdout。下一章会说明为什么更推荐logging。
7.5 用 print 观察循环:经典题“李白打酒”
很多经典编程题都可以用几行print完成调试。“李白打酒”是流传比较广的一道题,常见描述是“李白街上走,提壶去买酒;遇店加一倍,见花喝一斗”。我们可以用一个正向模拟的版本来练习循环和状态跟踪:
wine = 2.0 steps = ["店", "花", "店", "花", "店", "花"] for i, step in enumerate(steps, 1): if step == "店": wine *= 2 else: wine -= 1 print(f"第{i}步遇到{step},壶中剩{wine}斗") print(f"最终:{wine}斗")运行输出:
第1步遇到店,壶中剩4.0斗 第2步遇到花,壶中剩3.0斗 第3步遇到店,壶中剩6.0斗 第4步遇到花,壶中剩5.0斗 第5步遇到店,壶中剩10.0斗 第6步遇到花,壶中剩9.0斗 最终:9.0斗这道题在不同资料里有不同问法,有的要求逆推出初始酒量,我们不在这里争论题面。想强调的是:print是新手最直接的调试工具。一旦你能看到循环中每个变量的变化,逻辑推理就变得容易很多。等你熟练了,再考虑用断点或logging替代它。
7.6 多线程场景的一个提醒
真实项目中的“下载任务”常常是多线程或异步的。多线程并发调用print时,由于标准输出共享同一个流,两个线程的字符串可能交错输出,显得很乱。解决方式通常是:用logging模块、加锁,或统一由主线程打印日志。
如果你在代码里看到:
开始下载 file1 开始下载 file2 file2 下载成功 file1 下载成功这不一定代表逻辑错误,只是并发调度的自然结果。下一章会进一步谈这个问题。
8. 常见问题与排查思路
下面这些场景,基本覆盖了 Python 开发中与print关系最密切的疑难杂症。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
python --version没输出 | Python 未加入系统 PATH | Windows 执行where python,Linux/macOS 执行which python3 | 重新安装并勾选Add Python to PATH,或手动编辑 PATH |
| VSCode 里运行 print 没有结果 | 解释器选错,或运行的不是当前文件 | 查看 VSCode 终端;检查右下角解释器路径 | Ctrl + Shift + P执行Python: Select Interpreter,重新选择 |
| 输出中文乱码 | Windows 控制台编码与 Python 编码不一致 | 执行python -c "import sys; print(sys.stdout.encoding)" | 设置PYTHONIOENCODING=utf-8,或使用 UTF-8 模式启动 |
| print 重定向到文件后迟迟不出现 | 标准输出被块缓冲 | 启动时加-u参数,或在print加flush=True | python -u script.py,或统一封装输出函数 |
print报TypeError: 'str' object is not callable | 变量名覆盖了内建print | 搜索代码中print = | 删除覆盖语句,改用其他变量名,或转向logging |
| 打包成 exe 后 print 不显示 | 程序被构建为 GUI 子系统,没有控制台窗口 | 查看打包工具的子系统参数 | 使用 console 模式打包,或把日志写入文件 |
| 多线程里 print 输出乱序 | 多个线程同时竞争标准输出 | 观察输出顺序与加锁后的对比 | 使用logging,或在线程外层加threading.Lock |
如果遇到一个和print相关的诡异问题,不知道怎么定位,可以按下面的顺序排查:
- 先确认程序真的执行到了这行代码。加一个特征明显的输出,比如
print("HERE", flush=True)。 - 再确认输出去了哪里。是终端、文件,还是被 VSCode 的“输出”面板吞掉了。
- 最后确认输出确实被“写入”了。如果重定向到文件,检查是否遇到缓冲;如果在终端,检查是否被
\r或控制字符覆盖。
大部分“print 没有输出”的问题,都卡在第二步和第三步之间。
9. 最佳实践:什么时候别用 print,以及如何深入
print很强大,但不是万能的。一个成熟的 Python 项目,越到后期越要克制使用print。
9.1 print 与 logging 的简单边界
给一个非常实用的判断标准:
- 一次性脚本、教学示例、
CLI工具里,print完全没问题。 - 需要长期运行的服务、库、框架,或者程序要给别人部署时,建议优先使用
logging。
最简单的logging用法:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) logging.info("download finished")输出可能是:
2025-04-01 12:00:01,234 INFO download finished和print相比,logging天生支持时间戳、日志级别、输出到不同目标。生产环境里你可以通过配置控制哪些级别的日志写到文件、哪些写到控制台,这是print很难做到的。
9.2 生产环境 print 的三大风险
第一,输出不可控。print没有级别概念,调试信息、正常状态、错误堆栈全混在一起。上线调试时,你还要去代码里找哪些print需要删。
第二,缓冲导致日志丢失。前面讲过,重定向到文件时存在块缓冲。程序一旦崩溃,最后几条日志可能根本来不及落盘。
第三,容易输出敏感信息。脚本里print(api_key)这种写法,一旦日志被上传,密钥就泄露了。logging至少还方便你在输出端做脱敏处理。
9.3 打调试信息时,多用 repr
调试输出的另一个常见问题是区分不清“字符串内容”和“对象结构”。
s = "100" n = 100 print(s, n) print(f"{s!r}, {n!r}")第一行输出看起来都是100 100,但s是字符串、n是整数。第二行输出'100', 100,字符串的引号被repr保留,一眼就能看出类型差异。
处理字典或列表时,pprint比print更适合:
import pprint data = {"name": "python", "items": ["print", "ast", "dis"]} pprint.pprint(data, indent=2)它能格式化嵌套结构,避免一行输出挤成一团。
9.4 下一步可以怎么深入
这篇文章只拆到了“执行链路”这层。如果你想继续深入,推荐按下面的顺序学习:
- 读 CPython 源码。先关注
Python/bltinmodule.c里的builtin_print实现,以及Python/ceval.c里的字节码主循环。 - 看 Python 源码目录。
Lib/下是标准库的纯 Python 实现,Include/和Objects/下是解释器核心。 - 尝试写 C 扩展模块。用 C 语言写一个
hello模块,再在 Python 里调用它,体会“Python 调用 C 函数”的完整过程。 - 对比其他解释器。用 PyPy 跑一遍
dis.dis(hello),再看输出差异,理解不同解释器的执行模型。 - 做一次完整复盘。拿一个你日常使用的第三方库,用
dis.dis看它的某个函数,找出执行开销比较大的字节码指令。
如果你把本文里的tokenize、ast、dis三部分亲手跑一遍,再想清楚每一段输出意味着什么,你对 Python 的理解会比那些只会调 API 的开发者深入一大截。
建议先收藏这篇文章,下次遇到“print 没输出”“print 覆盖报错”“中文乱码”这类问题时,直接回来对照排查表。