编程语言¶
编程语言是人类意图与机器执行之间的接口。本文涵盖语言范式、类型系统、内存管理策略、编译流程、解释与JIT编译、关键语言特性、领域特定语言以及设计权衡。
- 每一个软件、每一个机器学习模型、每一个操作系统都是用编程语言编写的。但有成百上千种语言,各有不同的优势。为什么?因为语言设计涉及根本性的权衡:性能 vs 安全性,表现力 vs 简洁性,控制力 vs 抽象能力。理解这些权衡有助于你为工作选择正确的工具,并理解你所处环境的约束。
语言范式¶
-
范式是一种编程风格:一套指导你如何组织代码和思考问题的原则。
-
命令式编程将计算描述为改变状态的一系列命令。“将 x 设为 5。将 3 加到 x。如果 x > 7,打印它。”C、Python 和 Java 本质上是命令式的。其思维模型是一台带有内存的机器,你一步步地修改它。
-
面向对象编程(OOP) 围绕对象组织代码:对象是数据(属性)和行为(方法)的捆绑。对象通过相互发送消息进行交互。关键思想是封装(将内部状态隐藏在公共接口之后)、继承(通过扩展现有类创建新类)和多态(通过共享接口统一处理不同类型)。Java、C++ 和 Python 支持 OOP。
-
函数式编程(FP) 将计算视为数学函数的求值。核心原则:不可变性(数据一旦创建就不能改变)、纯函数(输出仅依赖输入,无副作用)和一等函数(函数是可以作为参数传递、从其他函数返回、并存储在变量中的值)。Haskell 是纯函数式语言。Python、JavaScript 和 Scala 支持函数式风格。
-
纯函数易于推理、测试和并行化(没有共享的可变状态意味着没有竞态条件)。这就是为什么函数式思想越来越多地用于分布式系统和数据管道。通篇使用的 JAX 是函数式的:
jax.grad之所以能工作,正是因为 JAX 函数是纯的。 -
逻辑编程描述什么应该为真,而不是如何计算它。你陈述事实和规则,运行时寻找解。Prolog 是经典例子:给定“苏格拉底是人”和“所有人都会死”,引擎推导出“苏格拉底会死”。逻辑编程用于 AI 知识库和类型检查。
-
大多数现代语言是多范式的:Python 支持命令式、OOP 和函数式风格。Rust 支持命令式和函数式。范式是工具,不是信仰。
类型系统¶
-
类型对值进行分类,并确定哪些操作是合法的。整数 3 和字符串 "3" 是不同的类型:你可以将整数相加,但不能将字符串相加(嗯,你可以拼接字符串,但那是一种不同的操作)。
-
静态类型:类型在编译时(程序运行之前)检查。类型错误尽早被捕获。C、Java、Rust 和 Go 是静态类型的。你必须声明类型(或者编译器推断它们):
let x: i32 = 5; // Rust: x 是 32 位整数
let y: f64 = 3.14; // y 是 64 位浮点数
// let z = x + y; // 编译错误:不能将 i32 和 f64 相加
- 动态类型:类型在运行时(操作实际执行时)检查。更灵活,但类型错误只有在代码运行时才会暴露。Python、JavaScript 和 Ruby 是动态类型的:
-
强类型:语言阻止隐式类型转换。Python 是强类型的:
"3" + 5引发 TypeError。弱类型:语言静默地转换类型。JavaScript 是弱类型的:"3" + 5得到"35"(数字被强制转换为字符串)。C 是弱类型的:你可以将指针强制转换为整数。 -
类型推断让编译器无需显式注解即可推导类型:
- 泛型(参数多态)让你编写适用于任何类型的代码:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut max = &list[0];
for item in &list[1..] {
if item > max { max = item; }
}
max
}
// 适用于整数、浮点数、字符串 —— 任何支持比较的类型
- 对于机器学习:Python 的动态类型使实验快速,但隐藏了错误。生产型机器学习系统越来越多地使用类型提示(
def train(model: nn.Module, lr: float) -> float)和静态分析工具(mypy)以在部署前捕获错误。PyTorch 和 JAX 使用 Python 的灵活性;TensorRT 和 ONNX Runtime 使用 C++ 的性能。
内存管理¶
- 每个程序都会分配和释放内存。如何管理这是语言设计中影响最深远的决策之一。
-
栈存储局部变量和函数调用帧。分配是简单的(移动栈指针),释放是自动的(函数返回时弹出帧)。栈访问速度快,因为它总是位于缓存中。但是栈有固定大小(通常为 1-8 MB),并且仅支持后进先出(LIFO)分配。
-
堆存储动态分配的数据(对象、数组、编译时大小未知的字符串)。堆分配较慢(需要找到空闲块),并且需要显式或自动的释放。堆可以增长以填满可用内存。
-
手动内存管理(C、C++):程序员显式地分配(
malloc)和释放(free)堆内存。最大的控制力和性能,但极易出错:- 释放后使用:访问已被释放的内存。导致崩溃或安全漏洞。
- 双重释放:两次释放同一块内存。破坏分配器的内部数据结构。
- 内存泄漏:分配内存但从未释放。程序逐渐消耗所有可用 RAM。
-
垃圾回收(GC):运行时自动检测并释放不再可达的内存。程序员从不调用
free。-
追踪式 GC(Java、Go、Python 的循环检测器):定期从“根”(栈变量、全局变量)遍历所有可达对象,并释放不可达对象。简单但导致 GC 停顿:收集器运行时程序停止。现代收集器(Go 的并发 GC,Java 的 ZGC)将暂停时间降至亚毫秒级。
-
引用计数(Python 的主要机制、Swift、Objective-C):每个对象跟踪指向它的引用数量。当计数降至 0 时,对象立即被释放。没有停顿,但无法处理循环引用(A 引用 B,B 引用 A,两者计数都 >0 但都不可达)。Python 使用单独的循环检测器来处理这种情况。
-
-
所有权(Rust):编译器在编译时强制执行内存安全规则,运行时零开销。
- 每个值有且只有一个所有者。当所有者离开作用域时,该值被丢弃(释放)。
- 值可以被借用(引用),但编译器强制执行:要么一个可变引用,要么任意数量的不可变引用,两者不能同时存在。
- 这在编译时防止了释放后使用、双重释放、数据竞争和悬垂指针。没有 GC,没有运行时开销。
-
借用检查器是 Rust 的标志性特性,也是其最陡峭的学习曲线。它无需垃圾回收就能保证内存安全和线程安全,这就是为什么 Rust 越来越多地用于性能关键型系统(操作系统内核、游戏引擎、像 Candle 和 Burn 这样的机器学习推理运行时)。
编译流程¶
- 编译器在程序运行之前将源代码翻译成机器码(或其他目标语言)。流程包含几个阶段:
-
词法分析(分词):将源文本转换为标记流。
x = 3 + y变成[IDENT("x"), EQUALS, INT(3), PLUS, IDENT("y")]。词法分析器会去除空白和注释。 -
语法分析:从标记流构建抽象语法树(AST)。AST 表示程序的层次结构。
3 + y * 2解析为Add(3, Mul(y, 2))(乘法优先级更高)。语法分析器检查语法:不匹配的括号和缺失的分号在这里被捕获。 -
语义分析:检查类型、解析变量名称、验证函数调用时参数是否正确。静态类型检查发生在此阶段。输出是带有类型注解的 AST。
-
优化:在不改变程序行为的前提下,转换程序使其运行更快。常见优化:
- 常量折叠:在编译时计算
3 + 5,将其替换为8。 - 死代码消除:删除永远不会执行的代码。
- 循环展开:用重复的内联代码替换循环,以减少分支开销。
- 内联:将函数调用替换为函数体,消除调用开销。
- 常量折叠:在编译时计算
-
代码生成:将优化后的表示翻译成目标机器码(x86、ARM)或某种中间表示。
-
LLVM 是主流的编译器基础设施。它提供一个公共的中间表示(LLVM IR),许多语言都编译到它。LLVM 的优化器在此 IR 上工作,其后端为许多目标生成机器码。Clang(C/C++)、Rust、Swift、Julia 以及许多其他语言都使用 LLVM。这意味着对 LLVM 优化器的改进会同时惠及所有这些语言。
解释与 JIT 编译¶
-
解释器逐行(或逐语句)执行程序,而不产生机器码。这使得启动速度快,开发交互性强,但执行速度较慢(每次运行时每一行都会被重新分析)。
-
大多数解释型语言实际上编译成字节码:一种比源码简单但不是机器特定的中间表示。字节码在虚拟机(VM)上运行。
-
CPython(Python 的标准实现)将 Python 源码编译成字节码(
.pyc文件),由 CPython VM 执行。VM 逐条解释字节码。这就是为什么 Python 在计算密集型代码上比 C 慢约 100 倍。 -
JVM(Java 虚拟机):Java 编译成 JVM 字节码(
.class文件)。JVM 最初解释字节码,然后对频繁执行的代码路径(“热点”)进行 JIT 编译,生成本地机器码。这就是为什么 Java 启动比 C 慢(解释开销),但对于长时间运行的程序,其速度可以接近 C(通过 JIT 优化的热点路径)。
-
-
JIT(即时)编译在运行时将代码编译为机器码,使用只有在执行时才能获得的信息。JIT 可以根据实际的运行时数据进行优化:如果一个函数总是使用整数参数调用,JIT 会生成专门的整数机器码,跳过类型检查。
-
PyPy 是另一个带有 JIT 编译器的 Python 实现。通过将热点循环 JIT 编译为机器码,它运行大多数 Python 代码的速度比 CPython 快 5-10 倍。然而,它与 C 扩展模块(NumPy、PyTorch)的兼容性有限,这限制了它在机器学习中的使用。
-
从解释到编译的谱系不是二元对立的:
- 纯解释:Bash 脚本。
- 字节码解释:CPython。
- 字节码 + JIT:JVM、.NET CLR、LuaJIT、PyPy。
- 预先(AOT)编译:C、C++、Rust、Go。
- AOT + 运行时代码生成:JAX 的
jax.jit在第一次调用时将 Python 函数编译为优化的 XLA 代码,然后缓存编译后的版本。
关键语言特性¶
- 闭包:一个捕获其外围作用域中变量的函数。该函数“闭合”了定义它的环境:
def make_adder(n):
def add(x):
return x + n # n 从外围作用域捕获
return add
add5 = make_adder(5)
print(add5(3)) # 8
-
闭包是回调、装饰器和偏函数应用背后的机制。它们是函数式编程的基础。
-
模式匹配:一种强大的控制流机制,可以对数据进行解构并根据数据的形状进行分支:
match value {
Some(x) if x > 0 => println!("Positive: {}", x),
Some(0) => println!("Zero"),
Some(x) => println!("Negative: {}", x),
None => println!("Nothing"),
}
-
模式匹配比 if-else 链更具表现力:它检查数据的结构(是 Some 还是 None?是否包含匹配条件的值?),而不仅仅是相等性。Python 在 3.10 版本添加了结构化模式匹配(
match/case)。 -
代数数据类型(ADT):可以是几种变体之一的类型,每种变体携带不同的数据。
Result类型要么是Ok(value),要么是Err(error)。Tree要么是Leaf(value),要么是Node(left, right)。ADT 与模式匹配结合可以穷尽处理所有情况,从而消除整个类别的错误(空指针异常、未处理的错误代码)。 -
Trait 和接口:定义一个类型必须实现的一组方法,而不指定如何实现。这实现了多态性:一个接受“任何实现了 Display trait”的参数的函数可以处理整数、字符串和自定义类型。Rust 使用 trait,Java 使用接口,Go 使用隐式接口,Python 使用鸭子类型(“如果它走路像鸭子……”)。
领域特定语言¶
-
领域特定语言(DSL)是为特定问题领域设计的,在该领域内用通用性换取表现力。
-
SQL:关系数据库的语言。
SELECT name FROM users WHERE age > 30比等价的命令式循环更具可读性且更易优化。数据库引擎会优化查询执行计划,自动选择连接策略和索引使用。 -
正则表达式:一种用于文本模式匹配的微型语言。
\d{3}-\d{4}匹配像“555-1234”这样的电话号码。正则表达式引擎将模式编译成有限自动机以实现高效匹配。 -
着色器语言(GLSL、HLSL、Metal Shading Language):在 GPU 核心上运行的程序,用于计算像素颜色、顶点位置或计算操作。着色器是高度并行的:每个调用独立地处理一个像素或一个元素。这与 CUDA 用于机器学习计算的执行模型相同。
-
在机器学习中,像 PyTorch 和 JAX 这样的框架本质上就是嵌入在 Python 内部的用于张量计算的 DSL。它们提供领域特定的抽象(张量、自动微分、设备放置),同时利用 Python 的生态系统。
语言设计权衡¶
-
没有哪种语言在所有方面都是最好的。设计就是选择要做的权衡:
-
性能 vs 安全性:C 语言提供原始速度和硬件控制,但允许你破坏内存。Rust 提供相当的速度和编译时内存安全。Java 提供内存安全,但有垃圾收集开销。Python 提供最大的安全性和表现力,但执行速度慢 100 倍。
-
表现力 vs 简洁性:Haskell 的类型系统可以表达非常精确的约束,但学习曲线陡峭。Go 故意省略泛型(直到最近)、继承和异常,以保持简洁。Python 的“应该有一种——最好只有一种——显而易见的方法”的哲学使语言保持可学性。
-
控制力 vs 抽象能力:C/C++ 让你控制内存布局、缓存行为和硬件交互。Python 隐藏了所有这些。对于机器学习训练(GPU 计算占主导),Python 的开销可以忽略不计。对于机器学习推理(每一微秒都很重要),C++ 或 Rust 可能是必需的。
-
编译速度 vs 运行时速度:Go 编译只需几秒钟(简单的类型系统,最少的优化)。Rust 需要几分钟(复杂的类型系统,激进的优化)。权衡的是开发迭代速度与部署后的性能。
-
机器学习生态系统反映了这些权衡:Python 用于实验和训练(表现力胜出),C++/CUDA 用于内核和推理(性能胜出),Rust 用于基础设施和安全关键型系统(安全性胜出)。
编码任务(使用 CoLab 或 notebook)¶
-
探索闭包和高阶函数。实现一个简单的函数工厂,并验证闭包捕获了它们的环境。
def make_multiplier(factor): """返回一个将输入乘以 factor 的函数。""" def multiply(x): return x * factor return multiply double = make_multiplier(2) triple = make_multiplier(3) print(f"double(5) = {double(5)}") # 10 print(f"triple(5) = {triple(5)}") # 15 # 闭包通过引用捕获,而不是通过值 def make_counter(): count = [0] # 可变容器以允许修改 def increment(): count[0] += 1 return count[0] return increment counter = make_counter() print(f"counter() = {counter()}") # 1 print(f"counter() = {counter()}") # 2 print(f"counter() = {counter()}") # 3 -
比较动态与静态类型行为。展示 Python 的动态类型如何提供灵活性但也可能隐藏错误。
def add(a, b): return a + b # 对不同类型都能工作 —— 灵活! print(add(3, 5)) # 8 (int + int) print(add("hello ", "world")) # "hello world" (str + str) print(add([1, 2], [3, 4])) # [1, 2, 3, 4] (list + list) # 但类型错误只有在运行时才暴露: try: print(add("hello", 5)) # TypeError! str + int except TypeError as e: print(f"运行时错误: {e}") print("静态类型检查器会在运行前捕获此错误") -
对于计算密集型任务,测量解释型 Python 与编译/JIT 方法之间的性能差异。
import time import jax import jax.numpy as jnp n = 1_000_000 # 纯 Python 循环(解释执行) start = time.time() total = 0.0 for i in range(n): total += i * i python_time = time.time() - start # JAX(通过 XLA 编译) @jax.jit def sum_squares_jax(n): return jnp.sum(jnp.arange(n, dtype=jnp.float32) ** 2) _ = sum_squares_jax(10) # 预热 JIT start = time.time() result = sum_squares_jax(n) jax_time = time.time() - start print(f"Python 循环: {python_time:.4f}s") print(f"JAX (JIT): {jax_time:.6f}s") print(f"加速比: {python_time / jax_time:.0f}x")