首页 新闻 会员 周边 捐助

print() 的底层流程(CPython 伪代码)

0
[已解决问题] 解决于 2025-08-29 14:17

# 简化版
def print(*args, sep=' ', end='\n', file=sys.stdout, flush=False):
text = sep.join(str(arg) for arg in args) + end
file.write(text)
if flush:
file.flush()

 

  • 对每个实参 arg 都执行一次 str(arg)
  • str(arg) 的 C 级实现(PyObject_Str)大致逻辑:
    1. 如果 type(arg) 自定义了 __str__,直接调用 arg.__str__()
    2. 否则,如果定义了 __repr__ 但没定义 __str__不会自动回落到 __repr__;而是使用父类__str__(这正是你刚才碰到的情况)
    3. 如果定义了 __repr__ 但没定义 __str__,没有继承父类,那么调用__repr__
    4. 如果都没有,最终使用默认的 object 字符串 <__main__.MyException object at 0x...>
_java_python的主页 _java_python | 小虾三级 | 园豆:984
提问于:2025-08-29 13:47
< >
分享
最佳答案
0

不是只有 print() 才会触发 __str__,但 print() 是最常见的触发场景

  • 内置函数 format(obj) / f-string → 默认走 __format__,但如果不指定格式,也会回落到 str(obj),于是间接调用 __str__,
    f"{obj}" 不指定格式   隐式 str(obj)
    "{0}".format(obj)   同上
  • 日志模块 logging.info("%s", obj)   内部 str(obj)
  • 内置函数 str(obj) → 直接调用 obj.__str__()
一句话总结
  • print(obj) → 内部调用 str(obj) → 调用 obj.__str__()
    • 在 CPython 里,所有最终想把“任意对象”变成字符串的公开 API(str()、f-string、"{}".format(obj)、日志里的 %s 等)都会走到同一条 C 级函数:PyObject_Str
    • PyObject_Str 的伪代码逻辑就是
      1. 如果 type(obj)->tp_str 非空 → 直接调用 obj.__str__()
      2. 否则走默认路径(object.__str__,生成 <__main__.Foo object at 0x...>
        因而可以认为:最终底层确实都是一次 str() 行为,再调用 __str__
      3. str(obj)
        字节码只有一条:
        LOAD_NAME    0 (obj)
        CALL_FUNCTION 1 (str)
        
        CPython 内部 builtin_str → PyObject_Str(obj) → obj.__str__()
        
        这是 C 语言 的写法,不是 Python。
        type(obj) 在 CPython 的 C API 里是一个指向“类型对象”的指针,->tp_str 就是去取这个类型对象里的一个字段,名字叫 tp_str,它保存着指向该类型的 __str__ 实现函数的指针。
        逐字解释:
        type(obj)
        在 C 层等价于 obj.__class__(拿到“类型对象”)。
        type(obj)->tp_str
        用 C 的“箭头运算符” -> 访问结构体成员,相当于
        obj.__class__.__dict__.get("__str__") 的 C 版本,只不过这里取出来的是 C 函数指针。
        如果这个指针非空(即类里真正定义了 __str__),就直接调用它;
        如果是空指针(没有自定义 __str__),就回落到 object.__str__。
        
        f-string f'prefix{obj}'
        编译期把 f-string 拆成 CONST + FORMAT_VALUE:
        LOAD_CONST    'prefix'
        LOAD_NAME     0 (obj)
        FORMAT_VALUE  0        # 0 表示默认格式
        BUILD_STRING  2
        
        FORMAT_VALUE 的 C 实现:
        if (fmt_spec == NULL)
            res = PyObject_Str(value);   // 直接调用 __str__
        
        日志 logging.info("%s", obj)
        logging 内部通过 Formatter → record.getMessage() → msg % args → 最终 PyUnicode_Format → PyObject_Str(obj)。

         

  • 只要没有显式或隐式地调用 str(obj),就不会触发 __str__
  • __repr__ 只在需要“官方字符串表示”时被调用(交互式回显、repr(obj)、调试器显示等)

PyObject_Str 是 C 语言级别的运行时函数(在 Objects/object.c 里),永远也不会出现在 Python 字节码里。

表格

复制
你在 Python 层写的字节码看到的解释器运行时最终调用的 C 函数
str(obj) CALL_FUNCTION 1 builtin_str() → PyObject_Str()
f'{obj}' FORMAT_VALUE FORMAT_VALUE 字节码 → PyObject_Str()
  • 字节码 只是“发指令”
  • 解释器(C 代码) 才是“真正执行”
  • PyObject_Str 属于后者,不出现在字节码层面
  1. 字节码(bytecode)
    是 Python 源代码 经过编译器(compile() / import 时)生成的 中间指令序列,保存在 .pyc 文件里。
    它里面只有 操作码(opcode)+ 操作数,本质就是“虚拟机指令”,不直接包含任何 C 函数地址。
  2. 操作数 = 这条指令工作时需要用到的“数据”。
    在 CPython 字节码里,操作数就是紧跟在 opcode 后面的 1~4 个字节,解释器按规则把它取出来,当作:
    • 常量池索引
    • 局部变量索引
    • 跳转偏移量
    • 其他立即数
    • x = 1 + 2
      
      操作数 = 这条指令工作时需要用到的“数据”。
      在 CPython 字节码里,操作数就是紧跟在 opcode 后面的 1~4 个字节,解释器按规则把它取出来,当作:
      常量池索引
      局部变量索引
      跳转偏移量
      其他立即数
      举例
      Python 源码
      Python
      复制
      x = 1 + 2
      编译后字节码(dis 输出)
      复制
        0 LOAD_CONST 0 (1)   # opcode=LOAD_CONST, 操作数=0
        2 LOAD_CONST 1 (2)   # opcode=LOAD_CONST, 操作数=1
        4 BINARY_ADD         # opcode=BINARY_ADD, 无操作数
        6 STORE_NAME 0 (x)   # opcode=STORE_NAME, 操作数=0
      对 LOAD_CONST 来说,操作数是 常量表索引(co_consts[0] 得到 1)。
      对 STORE_NAME 来说,操作数是 名字表索引(co_names[0] 得到字符串 'x')。
      BINARY_ADD 不需要额外数据,因此没有操作数字节
      1. CPython 虚拟机(解释器)
        启动后把字节码逐条喂给一个巨大的 switch(opcode) 循环,每个分支里再 调用对应的 C 函数。
        这些 C 函数都已经在解释器二进制里链接好了,地址在编译时就固定;字节码里并没有“函数指针”,只有 opcode 常量。
      2. 所谓“符号表”
        只在 编译期 用来把变量名、常量名映射成字节码里的索引;运行期已经不需要符号表。
        真正“跳转”到哪个 C 函数,是解释器根据 opcode 在 switch 里决定,而不是字节码里存的地址。
      3. 每个名字(LOAD_CONSTBINARY_ADD …)在 CPython 源码中都对应一个整数值(如 LOAD_CONST = 100, BINARY_ADD = 23)。
        解释器拿到这个整数后,在一个巨大的 switch(opcode) 里跳转到对应的 C 代码分支,再调用相应的 C 函数完成具体操作。
_java_python | 小虾三级 |园豆:984 | 2025-08-29 14:08
清除回答草稿
   您需要登录以后才能回答,未注册用户请先注册