Python 与 C++ 双线入门:从「会用」到「懂原理」

你决定参加本科生科研项目,准备把 Python 从"会用"推进到"懂原理",同时把 C++ 从零基础补到能写能读。这条双线入门分六站走:先用三道探针题照亮你"以为会了"和"真会了"之间的差距,再把 Python 的栈帧、名字查找、可变/不可变三个盲区钉进直觉,然后把科研脚本的最小闭环搭起来;接着从 C++ 的编译四阶段切入,把类型系统、作用域与内存摆到 Python 已会的内容旁边对照;最后给一张决策表与一份路线图。


一、你以为会的 Python,真的会了吗?——三道探针照亮两道盲区

先回到问诊对话里的那几句话,把"以为会了"和"真会了"之间的距离点出来。这一站不给你答案,只把盲区照亮。

探针一:循环三要素你解释得出吗

for i in range(10): 这一行看上去稀松平常。问诊时你说,"得管住 i 存在哪、每次加多少、什么时候停"。这三句话其实就是循环的全部。i 存在哪:作为局部变量,随着循环块每次开始而被绑定。每次加多少:由 range(10) 这个迭代器决定,每轮吐一个值给 i。何时停:range(10) 吐到第 10 个值时不再有下一项,循环自动终止。这三件事你能说出名字,等于"会用"。但为什么 range(10) 不会一次性把 0 到 9 都打印出来?为什么 i 不会留一个奇怪的全局变量?这就是从"会用"到"懂原理"的第一道坎。Python 3 官方 Language Reference

探针二:函数调用那一刻内部发生了什么

问诊时你对"def add(a, b): return a + b 然后调用它"的回答是:"把函数体里的代码原地展开,a、b 换成实际的值再跑一遍。" 这是一幅直觉图,但不是事实。事实是:调用发生时,Python 会在调用栈(call stack)上挂上一个新的栈帧(execution frame),参数 a、b 与 return 之后的返回值都被记在这一帧里。返回时这一帧被拆掉。每一帧都有一根指针 f_back 指向上一帧,整条调用栈就是一串这样的帧由指针串起来。这个"栈帧入栈 / 返回时弹出"的机制正是你直觉里"原地展开替换"对应的事实。CPython Internals

下面这张对照图把"原地展开替换"的脑补与真实的栈帧入栈并弹出并列摆出:

图注:函数调用的假象是原地替换,事实是栈帧入栈与弹出

探针三:C++ 跑之前要先走什么工序

问诊时你对这一问的回答是:"说不太清,只知道 C++ 要先编译。" 这正是后面 C++ 起步线要正面回答的那个悬空感。Python 是解释一行跑一行,没有这道工序。C++ 必须先把整段源码预处理、编译、汇编、再链接成可执行文件,才开始执行。g++ hello.cpp 这条命令里到底发生了什么,等读到 C++ 起步线(一)"编译到底做了什么"那一节时,悬空感会被填满。GNU gcc手册

这一站只把三道探针摆出来:循环三要素能解释得清,但你以为"原地展开替换"是函数调用的真相,这是 Python 加速线(一)要补的第一个盲区;C++ 跑之前的工序你说不太清,这是 C++ 起步线(一)要补的第二个盲区。剩下的 Python 加速线与 C++ 起步线,就沿着这两条盲区向前推。


二、Python 加速线(一):从「会用」到「懂原理」——函数调用那一刻到底发生了什么

问诊时你给出的"原地展开替换"那一帧错觉,这一节给它正面回话。Python 函数调用那一刻并不是把函数体原地铺开,而是新栈帧入栈、返回时弹出。本节把这一件事连同它牵出来的两件事——名字查找与可变/不可变——一次性钉进脑子里。

调用栈与栈帧:每次函数调用都是一张临时工作台

可以先把栈帧想成便利店柜台上的一次性窗口。每次你调用一个函数,Python 就临时开一个带编号的窗口,参数与局部变量都写在窗口里的小便签上。返回时窗口关掉,便签清掉。Python 3 官方文档把这种窗口叫做 execution frame(执行帧),并明确说"每个 code block 都在一个 execution frame 里执行"Python 3 官方 Language Reference。栈帧是栈帧,调用栈是调用栈——一串这样的帧由 f_back 指针串成链表,就是 Python 里看到的"调用栈"CPython Internals。

把 def add(a, b): return a + b 跑三次:每一次调用都是同一个 code object,但会得到三个独立的栈帧。sys._current_frames() 这个函数能让你在 REPL 里一眼看出"现在哪一帧是活的、它的 f_back 指向谁"。

名字查找与 LEGB 作用域:变量名是字典里的标签,不是钉死的盒子

Python 里的变量名并不是编译期就钉在某个内存地址上的标签。运行时,解释器拿到一个名字时按 LEGB 顺序在四层 namespace 字典里查:Local(当前函数的 local namespace)、Enclosing(外层闭包的 namespace)、Global(模块全局 namespace)、Built-in(Python 自带的 builtins 模块 namespace)。Python 3 官方文档第 4.2.2 节的原文是"the nearest enclosing scope",global namespace 先于 builtins namespacePython 3 官方 Language Reference。LEGB 这个缩写是社区对官方规范行为的常用简称,不是官方术语本身。

"LEGB"四层可以画成这样的同心圆:

LEGB 里有一处隐藏规则最容易踩坑:Python 解释器并不是一行一行决定"这个名字是 local 还是 global",而是先扫一遍整个函数 block,把所有"会让这个名字成为 local"的语句(赋值、for 头、= 出现的位置)都找出来,再做名字解析。下面这段代码就因为这条"整块扫描"规则而抛 UnboundLocalError,不是因为运行时 x 还没绑:

x = 10
def f():
    print(x)   # 触发 UnboundLocalError
    x = 20     # 这一行让 x 整个 block 内都是 local
f()

看上去 print(x) 在 x = 20 之前,照理说应该打印 10。但 Python 解释器先扫一遍 f 的 block,发现 x = 20 存在,就把整个 block 里的 x 都当成 local 处理;于是 print(x) 时 local namespace 里 x 还没绑值,直接 UnboundLocalErrorPython 3 官方 Language Reference。

可变与不可变对象:传进去再改会不会影响外面

问诊时你没直接碰到这一处,但它是学校 Python 课里另一个常被略过的分水岭。Python 3 官方 Data model 第 3.1 节说得很直接:"objects whose value can change are said to be mutable; objects whose value is unchangeable once they are created are called immutable",并明文把 numbers、strings、tuples 归到不可变,把 dictionaries、lists 归到可变Python官方教程。

下表把内置类型按可变与不可变对照列出来:

不可变可变
int, float, bool, complexlist
str, bytesdict
tuple, frozensetset, bytearray

这里有个反直觉点:容器不可变不等于容器里的元素不可变。tuple 是不可变容器,但 tuple 里塞一个 list,list 自己的内容仍可改Python官方教程。在 REPL 里跑下面这段就能看到:

a = (1, 2, [3, 4])
a[2].append(5)
print(a)        # (1, 2, [3, 4, 5])
a[2] = [9, 9]   # TypeError: 'tuple' object does not support item assignment

最后一句报错,是因为它在试图替换 tuple 直接引用的对象本身,这与"tuple 里元素可变"是两码事。

dict 的键还有一条硬约束:必须是可哈希的(即 hash 值在生命周期内不变)。这等价于"必须不可变"Python官方教程。hash([1, 2]) 在 REPL 里直接 TypeError: unhashable type: 'list';hash((1, 2)) 则正常返回。这就是为什么 list 不能做 dict 键、tuple 在元素全不可变时可以做。

到这里,你"以为会了"的两个盲区都被照亮了。Python 函数调用不是把函数体原地铺开,而是新栈帧入栈、返回时弹出;变量名是字典里查键,而不是编译期定死的;"传进去再改会不会影响外面"由对象本身可变不可变决定,不是由"传值/传引用"决定。


三、Python 加速线(二):科研必备工具箱——从一行推导式到 numpy 广播

懂原理之后,下一步是把科研脚本的最小闭环搭起来。这一节覆盖四件事:推导式与生成器表达式、文件读写、异常处理、numpy ndarray 与广播。这四件是科研脚本最常用的拼脚,缺一不可。

推导式与生成器表达式:80% 的循环可以写成一行

[i*i for i in range(10)] 是 list comprehension。它等价于 list(map(lambda i: i*i, range(10))),但更短更可读Python官方教程。dict 与 set 也有同构写法:{i: i*i for i in range(10)} 是 dict comprehension,{i*i for i in range(10)} 是 set comprehensionPython官方教程。

生成器表达式与 list comprehension 形如孪生兄弟,差别只在括号。Python 3 官方教程第 9.10 节的原文是:"Generator expressions are more compact but less versatile than full generator definitions and tend to be more memory friendly than equivalent list comprehensions"Python官方教程。"More memory friendly" 翻译过来是:list comprehension 先把整张表建出来再喂给 sum,而生成器表达式是边算边喂,不占额外 list。在 REPL 里跑下面两行就能看出差异:

sum([i*i for i in range(10)])     # 先建一张 [0, 1, 4, ..., 81] 再求和
sum(i*i for i in range(10))       # 直接边算边喂

科研脚本里数据量大的时候,生成器表达式能在内存上省一个数量级。

文件读写与异常处理:用 with 取代 finally

科研脚本最常做的事之一是把数据写到磁盘再读回来。open(filename, mode, encoding=None) 是官方签名;'r' 默认只读、'w' 覆盖写、'a' 追加、'b' 后缀进 binary mode。Python 3 官方教程第 7.2 节明确推荐 encoding="utf-8":"UTF-8 is the modern de-facto standard"Python官方教程。

用 with 块而不是 try/finally 来确保文件被关闭,原因在官方教程第 8.8 节说得很直白:with 是 finally 的官方最佳替代,"ensures they are always cleaned up promptly and correctly"Python官方教程:

with open('workfile', encoding='utf-8') as f:
    read_data = f.read()
# 出了 with 块,f.closed == True

异常处理的标准姿势是 try / except / else / finally。Python 3 官方教程第 8.3 节给出执行步骤清单:try 子句执行 → 无异常则跳过 except → 异常类型匹配则执行对应 except → 不匹配则向外层传播。else 子句"必须跟在所有 except 之后",仅在 try 子句没抛异常时执行Python官方教程。finally 无论是否异常都执行——下面这段来自官方教程的 divide 函数把 finally 的行为演示得很完整Python官方教程:

def divide(x, y):
    try:
        result = x / y
    except ZeroDivisionError:
        print("division by zero!")
    else:
        print("result is", result)
    finally:
        print("executing finally clause")

divide(2, 1) 走 else + finally;divide(2, 0) 走 except + finally;divide("2", "1") 抛 TypeError,但 finally 仍执行。

numpy ndarray 与广播:把"对一列数做同样操作"写成一行

Python 原生 list 也能做数组操作,但 numpy ndarray 是科研数据处理的真正起点。NumPy quickstart 一节给出的核心定义是:"NumPy's main object is the homogeneous multidimensional array. It is a table of elements (usually numbers), all of the same type, indexed by a tuple of non-negative integers. In NumPy dimensions are called axes."NumPy官方手册 也就是说,ndarray 的每一行每一列都装同一种类型的数字,维度叫 axis。

理解 ndarray 要记住五个属性:

属性含义
ndarray.ndim轴的个数(几维数组)
ndarray.shape各维度大小,如 n×m 矩阵的 shape 是 (n, m)
ndarray.size总元素数,等于 shape 各元素之积
ndarray.dtype元素类型,如 int32 / float64
ndarray.itemsize每个元素字节数

数组创建有两种典型路径:np.array([...]) 从 Python list 推断 dtype;np.arange / np.zeros / np.ones 创建已知大小、内容占位的数组。基本运算"Arithmetic operators on arrays apply elementwise. A new array is created and filled with the result."NumPy官方手册 也就是说 A * B 是逐元素乘,不是矩阵乘(矩阵乘用 A @ B)。

广播(broadcasting)是 NumPy 最常用的省内存技巧:两个 shape 不完全相同的数组做按元素运算时,NumPy 会自动把较小的那个"拉伸"到与较大的形状兼容。NumPy Broadcasting 一节给出两条规则NumPy官方手册:

  1. 如果输入数组维度数不一致,在小数组的 shape 前反复补 1,直到所有数组维度数相同。
  2. 对任何维度,如果某数组在该维上的 size 是 1,它就被"概念地"扩展到与最大尺寸一致。

最常见的科研例子是把一个一维数组加到一个二维矩阵上。下面这张 NumPy 官方文档的图把 4×3 二维数组与长度为 3 的一维数组相加的过程画了出来:

图注:左侧是 4×3 二维数组画成方格矩阵,右侧是长度为 3 的一维数组。NumPy 把一维数组沿 axis 0 概念性地"拉伸"成 4 行,与 4×3 数组做逐元素加法得到 4×3 结果。这里"拉伸"是 NumPy 自己的话——它不会真的复制一份 4 倍内存,而是绕开复制做完运算NumPy官方手册。

配合 NumPy 官方文档给出的代码块:

import numpy as np
a = np.arange(12).reshape(4, 3)   # 4×3 二维数组
b = np.array([1, 2, 3])            # 长度为 3 的一维数组
c = a + b                           # 广播成功:c.shape == (4, 3)

但广播不是万能。下面这种 shape 配对会失败:

b = np.array([1, 2, 3, 4])         # 长度为 4 的一维数组
c = a + b                           # ValueError: operands could not be broadcast together

下面这张 NumPy 官方文档的图把"失败路径"画了出来:

图注:左侧是 4×3 二维数组、右侧是长度为 4 的一维数组。两组形状右起第一维 3 vs 4 不等、且都不是 1,所以 NumPy 抛 ValueErrorNumPy官方手册。红叉表达"shape 不兼容,所以没有结果",不是 NumPy 强制禁止——只是按规则做不了而已。

到这里,Python 加速线两条结束。def add(a, b) 调用那一刻发生的事已经讲透;科研脚本最小闭环——推导式、with 读写 UTF-8 文件、try/except 把可能出错的代码围起来、numpy 数组与广播把"对一列数做同样操作"写成一行——已经搭起来。


四、C++ 起步线(一):编译到底做了什么——从源码到可执行文件的四道关

问诊时你对"C++ 跑之前要先编译、但说不清做了什么"的回答,这一节正面回话。C++ 跑之前不是只过一道叫"编译"的工序,而是连续四道:预处理、编译、汇编、链接。亲手跑一遍 GCC 的 -E / -S / -c 三个开关之后,这四道工序会从黑盒变白盒。

四阶段:preprocessing / compilation / assembly / linking

GNU gcc 手册 DESCRIPTION 段一句话钉死了四阶段的定义:"When you invoke GCC, it normally does preprocessing, compilation, assembly and linking."GNU gcc手册 紧接着那句把"可以中途停下"这件事说死:"The 'overall options' allow you to stop this process at an intermediate stage."GNU gcc手册

下面这张图把四阶段流水线画了出来:

图注:四个圆角矩形节点从上到下依次是 Preprocessing(编译预处理)、Compilation(编译)、Assemble(汇编)、Linking(链接),节点间用向下箭头串联。每个节点右侧对应一行说明:Step 1 Preprocessor (cpp) / Step 2 Compiler (gcc, g++) / Step 3 Assembler (as) / Step 4 Linker (ld)。左侧 6 条文字标签带短箭头:Source Code (.c, .cpp, .h) → Preprocessing;Include Header, Expand Macro (.i, .ii) → Preprocessing 与 Compilation 之间;Assembly Code (.s) → Assemble 与 Compilation 之间;Machine Code (.o, .obj) → Assemble 与 Linking 之间;Static Library (.lib, .a) → Linking;Executable Machine Code (.exe) → 最终输出NTU GCC and Make教学站点。

四个阶段对应四套 GNU 工具:Step 1 预处理器 cpp、Step 2 编译器 gcc / g++、Step 3 汇编器 as、Step 4 链接器 ldNTU GCC and Make教学站点。

-E / -S / -c / -o:四个让你中途停下来的开关

GNU gcc 手册的 Overall Options 段给出 -c / -S / -E / -o file 四个官方开关的语义GNU gcc手册:

开关作用产物
-E停在预处理后,不跑编译器预处理后的源码,写到标准输出(一般用 > hello.i 重定向)
-S停在编译 proper 后,不汇编汇编代码文件 hello.s(针对特定 CPU 的汇编代码)
-c编译或汇编到 .o,但不链接目标文件 hello.o(每个源文件一份)
-o file指定输出文件名与 -E / -S / -c 任意一个组合时分别指代预处理后 / 汇编 / 目标文件 / 可执行文件

g++ -E hello.cpp 让 GCC 在预处理后就停,输出写到标准输出(手册原文:"The output is in the form of preprocessed source code, which is sent to the standard output."GNU gcc手册)。要把结果落到文件,配合重定向 g++ -E hello.cpp > hello.i。

亲手跑一遍三个开关能让你把四阶段从黑盒变白盒。先准备一份 hello.cpp:

#include <iostream>
int main() {
    std::cout << "Hello, world!" << std::endl;
    return 0;
}

然后依次跑:

g++ -E hello.cpp > hello.i      # 看到宏被展开、头文件被插进来的预处理结果
g++ -S hello.cpp                # 产出 hello.s,是 x86 / ARM 汇编代码
g++ -c hello.cpp                # 产出 hello.o,是机器码目标文件
g++ -o hello hello.cpp          # 产出 hello(Linux/macOS 下)或 hello.exe(Windows 下),是可执行文件

四个命令各停一步,最后那个命令把四阶段全走完。-c 这条特别值得记,因为它正是多文件 C++ 项目"分文件编译"的基础:先 g++ -c file1.cpp / g++ -c file2.cpp 各自得到 file1.o / file2.o,再 g++ -o myprog file1.o file2.o 把它们链接起来,不必每次都全部重编NTU GCC and Make教学站点。

与 Python 的对照:解释一行 vs 整段先编译

Python 是解释一行跑一行:拿到一段源码,解释器逐条解析字节码、逐条执行。C++ 是整段先编译:拿到 .cpp,先过预处理、编译、汇编、链接四道工序变成可执行文件,再开始执行。这个差异直接决定了 C++ 写错一个类型不会到运行时才报错——编译器在编译期就把"类型不匹配"拦下来了,因为整段先编译意味着所有符号、类型、地址都得在运行前钉死。这一点是 Python 转过来的读者最缺的概念之一,留到 C++ 起步线(二)展开。

到这里,问诊时你说的"只知道 C++ 要先编译、说不太清做了什么"已被填满:C++ 跑之前要先走四道——预处理 / 编译 / 汇编 / 链接;-E / -S / -c / -o 四个开关可以让你在任一阶段停下来看中间产物。


五、C++ 起步线(二):同一件事两种语言怎么写——类型、作用域与内存

Python 已会的"声明一个变量、跑一段循环、调用一个函数",C++ 都能做——但要换一套新规则。这一节把 C++ 的类型系统、声明 vs 定义、作用域与内存模型摆到 Python 已会的内容旁边对照,重点落在 Python 没有的两个新概念上。

类型系统:编译期就把类型焊死

C++ Primer 第 5 版第 2.2.2 节末尾的 Key Concept 块原文是:"C++ is a statically typed language, which means that types are checked at compile time. The process by which types are checked is referred to as type checking."C++ Primer第5版 这是与 Python 的根本分水岭。Python 的 x = 42 不需要声明类型,解释器在运行时看到 42 才知道 x 是 int;而 C++ 的 int x = 42; 是先告诉编译器"我要一块放 int 的内存",编译器在编译期就把"x 当作 int"焊死,后续 x = "hello"; 会编译失败,根本不会生成可执行文件C++ Primer第5版。

变量定义的最简形式是 type specifier + 名字列表 + 分号C++ Primer第5版:

int sum = 0, value, units_sold = 0;   // sum、value、units_sold 都是 int

C++ 里还要区分"声明"(declaration)与"定义"(definition)。声明让名字被程序知道;定义则创建那个对应的实体(分配内存)C++ Primer第5版。要在声明的同时不做定义,加 extern 关键字并不带 initializerC++ Primer第5版:

extern int i;            // 只是声明,不分配内存
int j;                   // 既是声明也是定义(隐式初始化)
extern double pi = 3.1416;   // 带 initializer 退化成定义

末尾 INFO 框一句话定调:"Variables must be defined exactly once but can be declared many times."C++ Primer第5版 这是 Python 转过来的读者最缺的概念——写多文件 C++ 程序的第一关就是区分"声明一个名字"与"分配一块内存"。

还有一个 Python 没有的细节:built-in 类型在函数体内如果不初始化,值未定义C++ Primer第5版:

int main() {
    int local_int;        // 未初始化,值未定义(undefined value)
    std::string local_str; // 默认为空串(std::string 不是 built-in type)
}

Python 里 x = None 显然不等于这种情况——前者是个明确的"空"对象,后者是"编译器没安排具体值、那块内存里碰巧是什么就是什么"。所以 C++ Primer 建议"永远给 built-in type 一个 initializer"。

作用域:编译期花括号定界 vs Python LEGB 运行时字典查找

Python 的 LEGB 是"运行时在四个 namespace 字典里一层一层找"。C++ 的 scope 是"编译期用花括号静态划分可见范围"C++ Primer第5版。两套机制都解决"同名变量在不同地方代表不同实体"的问题,但 Python 在运行时做查找,C++ 在编译期做检查,名字从声明点起到所在 scope 结束为止可见,编译后不再有"查找"这一动作。

C++ 入门级 scope 三件套:

  1. 全局 scope:定义在任何花括号之外(如 main 这个名字),一旦声明整个程序都可见。
  2. block scope:在 {} 内定义(如 sum 在 main 的函数体内),从声明点到所在块结束为止可见。
  3. 嵌套 scope 与名字遮蔽:内层 scope 可以定义与外层同名的变量,内层名字遮蔽外层;用 ::name(scope operator)可显式访问全局名字C++ Primer第5版:
#include <iostream>
int reused = 42;          // 全局 reused
int main() {
    int unique = 0;
    std::cout << reused << " " << unique << std::endl;  // 42 0
    int reused = 0;       // 局部 reused 遮蔽全局 reused
    std::cout << reused << " " << unique << std::endl;  // 0 0
    std::cout << ::reused << " " << unique << std::endl; // 42 0(用 :: 取全局)
    return 0;
}

C++ Primer 一句 WARNING 提醒很到位:"It is almost always a bad idea to define a local variable with the same name as a global variable that the function uses or might use."C++ Primer第5版

内存模型:栈向下 vs 堆向上,与 std::vector 的连续内存

C++ 里局部变量在栈上、动态容器在堆上。栈向下增长(高地址往低地址),堆向上增长(低地址往高地址)。int x; 与 std::vector<int> v; 这两个看上去相似的声明,落到内存上的差别是这张自绘图的核心:

图注:std::vector 的栈句柄与堆连续元素的内存布局

std::vector 的 cppreference 原文是:"std::vector is a sequence container that encapsulates dynamic size arrays",并且"The elements are stored contiguously"cppreference。这就是图里"句柄在栈、元素在堆、连续排布"的官方背书。std::vector 是 Python list 在 C++ 里最接近的对应物——两者都是变长、动态增长、同类型元素、按下标随机访问。但底层差别在专题里要点出来:Python list 元素是任意 PyObject* 指针(不连续存值,存的是指针),std::vector 元素是连续 T 值(存的是真实数据)。

cppreference 还给出 std::vector 的复杂度表cppreference:

操作复杂度
随机访问𝓞(1)
尾部插入 / 删除均摊 𝓞(1)
中段插入 / 删除𝓞(n)

中段插入变慢的原因是后续元素都要往后挪位置——这也是"连续内存"这把双刃剑的另一面。

到这里,C++ 起步线两条结束。同一件事两种语言怎么写已经摆到一张桌上:C++ 写变量必须先声明类型,类型错在编译期就拦下;extern 是"声明一个名字但不分配内存"的开关;C++ 用花括号划分可见范围而 Python 用字典查找;std::vector 是 Python list 的最近似对应物,但底层是连续内存、句柄在栈、元素在堆。


六、收尾:两条线怎么合起来用——一张决策表与一份路线图

最后这一站把前面五站收口。下次坐到科研项目面前,先想"我要解决什么问题",再选语言;遇到瓶颈不要绕开它,先用 Python 把最小原型跑通,再用 C++ 把慢的那一块替换。

场景决策:什么场景用哪门语言

下表把科研项目里的常见场景分成两栏:左栏 Python,右栏 C++,每行给出"为什么用这门"的最小理由NumPy官方手册cppreference:

场景PythonC++
数据清洗、字符串处理字符串 / list / dict 一把梭,pandas 生态完备字符串处理繁琐,没有同体量生态
画图、科研出图matplotlib 一行出图,与 numpy 衔接顺滑没有同等画图生态,要自己接 OpenGL / Qt
快速原型、glue 代码边写边跑、REPL 即时反馈编辑-编译-运行循环较慢
numpy / scipy 数值计算向量化 + 广播,免写循环自己写循环 + 手动 SIMD 优化
性能瓶颈(热循环、密集数值)受 GIL 与解释器开销拖累编译期优化 + 接近硬件
底层库调用、系统级接口通过 ctypes / pybind11 间接调用直接调系统 API
对实时性要求高的部分解释器开销 + GIL 让延迟不可预测编译后可预测的延迟

一般经验:先用 Python 把最小原型跑通,再用 C++ 把慢的那一块替换——不要为了快而一开始就用 C++ 写所有代码。

后续路线图:CMake / gdb / STL 进阶 / pybind11 互通

按"先会写 → 再会调试 → 再会写得更通用 → 再会两语言互通"的递进排列:

  1. CMake(多文件 C++ 项目怎么组织构建):本专题你只手动跑了 g++ -c file1.cpp / g++ -o myprog file1.o file2.o 两步,但项目大了之后文件依赖复杂得手写 CMakeLists.txt。CMake 是 C++ 项目的事实标准构建系统。
  2. gdb(程序崩了怎么定位):C++ 程序段错误时栈回溯用 gdb / lldb。Python 转过来的读者习惯 pdb 加断点,gdb 的思路一致但命令不同。
  3. STL 进阶(容器适配器、算法、迭代器):本专题你只学了 std::vector,STL 还有 std::map / std::unordered_map(dict 的对应物)、std::sort / std::find 等算法。容器适配器 std::stack / std::queue / std::priority_queue 在科研里也常用。
  4. pybind11 互通:让 Python 调用 C++ 写的库(或反过来)。这一项是双线收尾的真正归宿——你既能把 Python 的易用性发挥出来,又能把 C++ 的高性能发挥出来。
  5. Python 虚拟环境与依赖管理(venv / pip / requirements.txt):做科研项目必然要装第三方包,但本专题以语言机制为主、未覆盖这一工程实践。

双线对照小抄

维度PythonC++
执行模型解释一行跑一行整段先编译再执行(预处理 / 编译 / 汇编 / 链接)
名字查找LEGB(运行时字典查找)Scope of a Name(编译期花括号定界)
类型绑定运行时(解释器看到值才确定类型)编译时(编译器看到类型就焊死)
动态数组list(元素是 PyObject* 指针,不连续存值)std::vector(元素是连续 T 值)
内存管理GC 自动手动 new / delete 或 RA 智能

下次坐到科研项目面前:先用 Python 把数据清洗、画图、快速原型搭起来;遇到性能瓶颈,用 C++ 把慢的那一块替换。Python 这条线负责"快",C++ 这条线负责"狠"。


参考文献

本专题引用的核心资料如下,建议深入学习时阅读原文:

  1. Python 3 官方文档 Execution model — https://docs.python.org/3/reference/executionmodel.html
  2. CPython Internals — Frame Objects / Interpreter — https://github.com/zpoint/CPython-Internals/blob/master/Interpreter/frame/frame.md
  3. Python 3 官方文档 Data model — https://docs.python.org/3/reference/datamodel.html
  4. Python 3 官方 Tutorial — https://docs.python.org/3/tutorial/index.html
  5. GNU gcc 手册 (man7.org) — https://man7.org/linux/man-pages/man1/gcc.1.html
  6. GCC and Make — A Tutorial on how to compile, link and build C/C++ programs (NTU) — https://www3.ntu.edu.sg/home/ehchua/programming/cpp/gcc_make.html
  7. C++ Primer (5th Edition) — Chapter 2: Variables and Basic Types — https://cpp-primer.pages.dev/book/022-2.2._variables
  8. cppreference std::vector + NumPy quickstart + Broadcasting — https://en.cppreference.com/w/cpp/container/vector、https://numpy.org/doc/stable/user/quickstart.html、https://numpy.org/doc/stable/user/basics.broadcasting.html
Python 与 C++ 双线入门:从「会用」到「懂原理」