python基本语法

这份笔记整理的是 Python 的基础语法:三元表达式、and / or / not、列表推导式、lambda、tuple、yield 生成器、try / except、with ... as ...、类型标注、@dataclass、Literal。

这些都是和js不一样的,所以放在这里边讲一下。

1. 三元表达式

Python 的三元表达式,写法是把”取哪个值”写在前面,条件夹在中间:

1
值1 if 条件 else 值2
1
args = json.loads(json_accum) if json_accum else {}

读法就是英语语序——“用 A,如果条件成立;否则用 B”。Python 强调可读性,官方认为 A if 条件 else B 这种写法更接近自然语言。

1
2
age = 20
status = "成年" if age >= 18 else "未成年"

嵌套也能写,但别写:

1
result = "A" if x > 0 else "B" if x < 0 else "C"

这行能跑,但三个分支压在一行里,读的人得来回数 if 和 else 才配得上对。超过两个分支就换回 if / elif / else。

2. and / or / not 和默认值写法

三个逻辑运算符:

运算符 含义 例子
and 并且(两边都为真才为真) A and B
or 或者(有一个为真就为真) A or B
not 取反 not A

or 最常见的一个用法是设置默认值:

1
timeout = options.get("timeout") or 30

它取的是”第一个为真的值”。所以要判断这个写法合不合适,得先知道 Python 里哪些值算假:

值 真 / 假
False 假
None 假
0、0.0 假
"" 假
[]、()、{}、set() 假
其它(包括 float("nan")) 真

空容器一律是假值,这一点要记住。所以上面那句里,传进来的 0、""、[] 都会被换成 30。

如果 0 是一个合法的取值,就不能这么写,只能显式判断:

1
2
3
timeout = options.get("timeout")
if timeout is None:
timeout = 30

一段实际的兜底代码

1
2
if not input_tokens and delta_input_tokens:
input_tokens = delta_input_tokens

逐词拆开:

部分 含义
not input_tokens input_tokens 是假值(0、None、空等)
and 并且
delta_input_tokens delta_input_tokens 有真实值
整体 如果原来的是空的,并且备用值有内容,就用备用值替换

翻译过来就是:如果 input_tokens 没有值,并且 delta_input_tokens 有值,就把 delta_input_tokens 赋给 input_tokens。

为什么需要这层兜底?因为有些兼容 Claude 的 provider(MiniMax 这类)在 message_start 里把 input_tokens 报成 0,真正的用量藏在后面的 message_delta 里。所以逻辑是:

  1. 优先用 message_start 里的 usage
  2. 如果它是 0(没拿到),就降级使用之前从 message_delta 里抓到的 delta_xxx 值

这就是一种兼容兜底写法。

3. 列表推导式

Python 的列表推导式(List Comprehension),语法本身就支持”一边过滤一边转换”:

1
parts = [s.content.strip() for s in self._sections if s.content.strip()]

等价于下面这段普通写法:

1
2
3
4
5
parts = []

for s in self._sections:
if s.content.strip(): # 过滤条件
parts.append(s.content.strip()) # 转换 + 收集

结构读法:[对每个元素做什么 for 元素 in 集合 if 条件]。for 前面的表达式是转换,if 是过滤,一前一后,别搞混。

上面那句 strip() 被算了两遍(判断一次、取值一次)。要省掉重复计算,用海象运算符在条件里赋值:

1
parts = [t for s in self._sections if (t := s.content.strip())]

还有一个容易搞错的地方:推导式有自己的作用域,循环变量不会漏到外面。

1
2
[x for x in range(3)]
print(x) # NameError: name 'x' is not defined

除了列表,字典和集合也能这么写:

1
2
{name: len(name) for name in names}    # 字典推导式
{len(s) for s in sections} # 集合推导式

4. lambda:写匿名函数

lambda 是 Python 里用来写匿名函数的语法(没有名字的函数)。

基本语法:

1
lambda 参数: 表达式

它等价于一个很简单的函数,但不用写 def。对比一下,用 def 写:

1
2
def get_length(tr):
return len(tr.content)

用 lambda 写,效果完全一样:

1
lambda tr: len(tr.content)

在排序里的用法

1
ranked = sorted(remaining, key=lambda tr: len(tr.content))

这里的 lambda tr: len(tr.content) 就是告诉 sorted:请按照每个元素的 content 长度来排序。

如果不写 lambda,就得先定义一个函数再传进去,会麻烦一些:

1
2
3
4
def get_length(tr):
return len(tr.content)

ranked = sorted(remaining, key=get_length)

关于 sorted 本身,两点值得记:

  • key 对每个元素只算一次,不是每次比较都算。所以把耗时的事放进 key 函数,代价是 n 次,不是 n log n 次。
  • sorted() 返回新列表,list.sort() 原地改。

最重要的一条限制:lambda 只能是一个表达式

1
lambda 参数: 表达式

没有 {} 代码块,不能写语句,不能有 return(表达式的结果就是返回值),也不能加类型标注。所以它只够用在”给高阶函数塞一个一次性参数”这一种场合:

1
2
sorted(items, key=lambda x: x.score)
max(items, key=lambda x: len(x.name))

需要中间变量、需要循环、需要提前返回的,老实写 def:

1
2
3
4
5
6
7
def first_line(tr):
for line in tr.content.splitlines():
if line.strip():
return line.strip()
return ""

ranked = sorted(remaining, key=first_line)

把 first_line 这种逻辑硬塞进 lambda 是不可能的——它要一个循环加一个提前返回,都是语句。判断标准很简单:一个表达式写得下就用 lambda,写不下就 def。

5. tuple:有序、不可变

Tuple(元组)是 Python 中一种有序、不可变的数据结构。

特点 说明
有序 元素有固定顺序,可以通过索引访问
不可变 创建后不能修改、添加或删除元素
可包含任意类型 数字、字符串、列表、甚至另一个元组都可以
用圆括号定义 也可以不加括号(逗号才是关键)
1
2
3
4
5
6
7
t1 = (1, 2, 3)
t2 = "a", "b", "c" # 不加括号也可以
t3 = (1,) # 只有一个元素时,逗号不能省
t4 = () # 空元组

print(t1[0]) # 1
print(t1[-1]) # 3

解包很常用:

1
name, age, city = ("张三", 25, "北京")

“不可变”听着像个限制,但它换来两件实际有用的东西。

一、能当字典的 key。 一个坐标点可以直接拿去查表:

1
2
cache = {}
cache[(12.5, 30.1)] = "某条记录"

换成列表当 key,直接 TypeError: unhashable type: 'list'。原因就是元组不会变,哈希值稳定——不可变不是随便加的约束,它是”能当 key”这个能力的前提。

二、多返回值其实就是元组。 Python 的 return a, b 返回的是一个元组,所以可以原地拆开:

1
2
3
4
def min_max(nums):
return min(nums), max(nums)

low, high = min_max([3, 1, 2])

6. yield:算一个、交一个,下次接着算

yield 是 Python 里用来创建**生成器(generator)**的关键字。

普通函数用 return 会立刻结束并返回结果。用了 yield 的函数会变成生成器函数,调用它不会立刻执行完,而是返回一个生成器对象。每次用 next() 或 for 循环去取下一个值时,函数才会从上次暂停的地方继续执行,直到再次遇到 yield。

所以 yield 和 return 的分工是:

  • return:一次把结果全算完,交出去,函数结束
  • yield:交出一个值,函数在这里暂停;下次再来要,从暂停的地方接着往下跑
1
2
3
4
5
6
7
8
9
10
11
12
def count_up_to(n):
i = 1
while i <= n:
yield i # 交出 i,然后停在这里
i += 1

gen = count_up_to(3)

next(gen) # 1
next(gen) # 2
next(gen) # 3
next(gen) # StopIteration:取完了

把四次取值拆开看,就是函数在”跑 → 停 → 接着跑”之间来回:

1
2
3
4
next(gen) 第 1 次  ->  跑到 yield i,交出 1,暂停在这里
next(gen) 第 2 次 -> 从暂停处继续:i += 1,再跑到 yield i,交出 2,暂停
next(gen) 第 3 次 -> 继续:i += 1,交出 3,暂停
next(gen) 第 4 次 -> 循环条件不成立,函数走到结尾 -> 抛 StopIteration

平时一般不会手写 next(),直接 for 就行——for 会自动一次次取,取到 StopIteration 就结束,这个异常在循环里根本看不见:

1
2
for num in count_up_to(3):
print(num) # 1 2 3

为什么要用它:不把数据一次性装进内存

这是 yield 最实在的理由。读一个几百万行的日志:

1
2
3
4
5
6
7
def read_lines(path):
with open(path) as f:
for line in f:
yield line.strip() # 一次只在内存里放一行

for line in read_lines("huge.log"):
...

换成返回列表,写法只差一点,内存差很多:

1
2
3
def read_lines(path):
with open(path) as f:
return [line.strip() for line in f] # 一调用,整个文件全进内存

用 yield 的版本,处理到第 500 万行时,内存里只有那一行。这就是**”边走边给”和”先攒齐再给”**的区别。

判断标准:这份结果你是打算遍历一遍就扔,还是后面要反复用? 遍历一遍就扔,用 yield;要反复用、要取长度、要按下标访问,那就返回列表。

两个要记住的点

  • 生成器只能遍历一次。 取完就空了,再 for 什么都不出来;要重新调一次函数,拿一个新的生成器。
  • yield from 用来把另一个可迭代对象逐个交出去,等于省掉一层 for:
1
2
3
4
5
6
7
def all_lines(*paths):
for path in paths:
yield from read_lines(path)

# 等价于:
# for line in read_lines(path):
# yield line

7. try / except

这是 Python 的异常处理语法,用来捕获和处理代码运行时可能出现的错误,防止程序直接崩溃。

基本结构:

1
2
3
4
5
6
try:
# 可能出错的代码
...
except 错误类型:
# 如果出错了,执行这里
...

最简单的例子:

1
2
3
4
5
try:
num = int("abc") # 这里会报错,因为 "abc" 不能转成数字
print(num)
except ValueError:
print("转换失败,请输入数字")

输出:

1
转换失败,请输入数字

如果没有 try / except,程序会直接报错崩溃。

完整写法

1
2
3
4
5
6
7
8
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"出错了:{e}")
else:
print("没有出错")
finally:
print("无论是否出错都会执行")
子句 什么时候执行
except 出了对应的错才执行
else 没出错才执行,用得少
finally 无论有没有出错都执行,用来收尾

except 后面可以接具体类型,也可以接一个元组:

1
2
3
4
try:
...
except (ValueError, TypeError) as e:
...

except Exception as e 是最宽的那档”能捕获的都捕获”。注意它接的不是 BaseException,所以 KeyboardInterrupt(Ctrl+C)和 SystemExit 不会被吞掉——这是故意的。别写裸的 except:,它连 Ctrl+C 都拦。

raise 不带参数就是原样重抛当前这个异常:

1
2
3
4
5
try:
...
except ValueError:
log.warning("参数不对")
raise # 记完日志再抛出去,交给上层处理

还有一条和前几节一脉相承的规则:能 raise 的只有异常对象,必须是 BaseException 的子类实例,raise "字符串" 这种写法不合法。

8. with ... as ...:进入时拿资源,离开时自动还

with ... as ... 是 Python 的上下文管理器语法,主要用于:在进入代码块时自动获取资源,在离开代码块时自动释放资源。

1
2
with httpx.Client(timeout=30.0) as client:
response = client.post(url, headers=headers, json=payload)

这一行拆成三部分:

部分 含义
httpx.Client(timeout=30.0) 创建一个 HTTP 客户端,并设置默认超时时间为 30 秒
as client 把这个客户端对象绑定到变量名 client
with 进入一个受管理的代码块。代码块结束后,Python 会自动关闭客户端,即使中间发生异常,也会尽量完成清理

所以它大致相当于”创建 + 记得关闭”两件事合起来写,好处是你不容易忘记写 close()。

为什么 HTTP 客户端需要关闭

httpx.Client 内部可能管理:

  • TCP 连接
  • HTTPS 连接
  • 连接池
  • Socket
  • 系统文件描述符

如果创建了客户端却一直不关闭,可能导致资源泄漏——短期看不出来,跑久了是句柄耗尽或者连接堆积。用 with 的话,代码块结束时等同于自动调用:

1
client.close()

return 也不会跳过清理

这里有一个很重要的点:

1
2
3
4
5
6
7
with httpx.Client(timeout=30.0) as client:
response = client.post(f"{self.base_url}/web-search", headers=headers, json=payload)

if response.status_code == 200:
return self._format_result(response.json(), query)

return {"success": False, "error": f"API请求失败: {response.status_code}"}

上面有两条 return 路径,两条都一样:即使代码块中执行了 return,客户端也会先关闭,然后函数才真正返回。

清理的执行时机是”控制流离开这个块”,return 只是离开的一种方式,绕不过去。

自己写一个:@contextmanager + yield

如果前面 yield 那节看懂了,这里正好接上——写一个上下文管理器最简单的方式就是用生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
import time
from contextlib import contextmanager

@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield # 这里面的部分,就是 with 块
finally:
print(f"{label}: {time.perf_counter() - start:.3f}s")

with timer("请求"):
response = client.post(url)

yield 之前的代码是”进入时”,yield 之后的代码是”离开时”。 外面包 try / finally 是为了块里抛异常时也能走到打印那句。

两个坑:

  • 手写类的时候,__exit__ 返回真值会把异常吞掉。 with 块里抛出的异常会静默消失,排错时非常难受。@contextmanager 装饰出来的版本不会这样(除非你自己用 except 把它接住),所以能用装饰器就别手写类。
  • 多个资源从前往后依次进入、从后往前依次退出。 所以下面这种写法里 fout 先关、fin 后关,比嵌套两层 with 干净:
1
2
with open("in.txt") as fin, open("out.txt", "w") as fout:
fout.write(fin.read())

9. 类型标注:list[str]、dict[str, Any]、type[X]

1
2
names: list[str] = ["张三", "李四"]
scores: list[int] = [90, 85, 100]

意思是:names 是一个列表,列表里的元素应该是字符串 str。

冒号后面是标注,等号后面才是值。标注不影响运行——Python 不会因为你标了 list[str] 就往里做检查,它只给 IDE 和 mypy 这类静态检查器看。

list[str] 这种小写泛型要 Python 3.9+(PEP 585)。老代码里写成 List[str],那是从 typing import 的,现在没必要了。

最常用的其实是嵌套的字典列表,AI / Agent 代码里到处都是:

1
2
3
4
5
6
7
8
9
10
11
12
13
from typing import Any

messages: list[dict[str, Any]] = []

messages.append({
"role": "user",
"content": "你好",
"tokens": 5,
})

history: list[dict[str, Any]] = [] # 对话历史
tool_results: list[dict[str, Any]] = [] # 工具调用结果
events: list[dict[str, Any]] = [] # 事件流

dict[str, Any] 读作”键是字符串、值是任何类型”。Any 表示 value 可以是任何类型,比较宽松;想更严格就把 Any 换成具体类型,比如 dict[str, int]。

列表里放对象也一样,写类名就行:

1
2
3
4
5
6
7
8
9
class User:
def __init__(self, name: str, age: int):
self.name = name
self.age = age

users: list[User] = [User("张三", 18), User("李四", 20)]

for user in users:
print(user.name, user.age)

这里 list[User] 表示:users 是一个列表,列表中的每个元素都应该是 User 类型的对象。

type[X]:传的是类,不是实例

有一个标注容易读反:

1
2
def make_new_user(user_class: type[User]) -> User:
return user_class() # 调用类本身来创建实例

type[X] 表示的是”X 的类对象本身“,也就是 X 这个类,或者它的子类。它不是表示”X 的实例”,而是表示”能创建 X 实例的那个类”。

1
2
3
4
make_new_user(User)       # ✅ 传的是类
make_new_user(ProUser) # ✅ 子类也可以,type[X] 是协变的
make_new_user(User()) # ❌ 传的是实例,不是类
make_new_user(int) # ❌ int 不是 User 的子类

10. @dataclass:让类型标注替你写 __init__

@dataclass 是 Python 标准库 dataclasses 模块提供的装饰器(Python 3.7+ 内置)。它的作用是:自动帮你生成一堆常用方法,让你不用手写大量样板代码。

1
2
3
4
5
6
7
8
9
from dataclasses import dataclass

@dataclass
class StreamEnd:
stop_reason: str
input_tokens: int = 0
output_tokens: int = 0
cache_read: int = 0
cache_creation: int = 0

加上 @dataclass 之后,上面这段会自动长出:

  • __init__ —— 构造函数。字段声明顺序就是参数顺序,带默认值的必须排在后面
  • __repr__ —— 打印出来是 StreamEnd(stop_reason='end_turn', input_tokens=12, ...),不再是 <object at 0x7f...>
  • __eq__ —— 按字段逐个比,== 能直接用
  • __hash__ —— frozen=True 时才生成

等价于自己手写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class StreamEnd:
def __init__(self, stop_reason, input_tokens=0, output_tokens=0,
cache_read=0, cache_creation=0):
self.stop_reason = stop_reason
self.input_tokens = input_tokens
self.output_tokens = output_tokens
self.cache_read = cache_read
self.cache_creation = cache_creation

def __repr__(self):
return (f"StreamEnd(stop_reason={self.stop_reason!r}, "
f"input_tokens={self.input_tokens!r}, ...)")

def __eq__(self, other):
...

一个必须知道的坑:可变类型不能直接当默认值。

1
2
3
4
5
6
7
8
9
from dataclasses import dataclass, field

@dataclass
class Bad:
items: list = [] # ❌ 直接报错

@dataclass
class Good:
items: list = field(default_factory=list) # ✅ 每个实例各建一个新列表

原因和 Python 默认参数那个经典坑是同一个:默认值在定义时求值一次,所有实例会共享同一个列表,改一个全都变。default_factory 是让”每次新建实例时调一次这个函数”。

11. Literal 比 Enum 轻在哪

要表达”这个字段只能取三个值之一”,传统写法是用 Enum:

1
2
3
4
5
6
7
8
9
from enum import Enum

class ToolCategory(Enum):
READ = "read"
WRITE = "write"
COMMAND = "command"

category = ToolCategory.READ
category.value # "read" —— 还得再取一层

缺点:

  • 必须额外定义一个类
  • 运行时是 Enum 对象,不是普通字符串
  • 序列化成 JSON 时比较麻烦(需要自己处理 .value,否则 json.dumps 抛 TypeError)
  • 代码稍微啰嗦一点

现在更推荐用 Literal:

1
2
3
4
5
6
7
from typing import Literal

ToolCategory = Literal["read", "write", "command"]

category: ToolCategory = "read" # ✅ 合法
category = "write" # ✅ 合法
category = "delete" # ❌ 类型检查器直接报错,IDE 里会标红

优点:

优点 说明
更轻量 不需要单独定义一个类
运行时就是普通字符串 category 的值直接是 "read",不是对象
序列化零成本 直接能转 JSON,不用额外处理
类型检查依然有效 静态检查器能拦住非法值
IDE 提示好 自动补全只会出现 "read" / "write" / "command"

代价也要说清楚:Literal 只给类型检查器看,运行时不做任何校验。 从 JSON 里读进来一个字符串,标了 Literal 也拦不住非法值——要运行时校验还得自己写判断,或者用 Enum / pydantic。它管的是”写代码时 IDE 会拦”,不管”跑起来时数据是脏的”。

12. 小结

真正要记住的只有三件事:

一、判断和推导都是”先说结果、后说条件”。 A if 条件 else B、[对每个元素做什么 for 元素 in 集合 if 条件],条件和过滤都放在后面。

二、空容器是假值。 0、""、[]、{}、() 全都算假,所以 or 当默认值用会把它们一起兜掉。0 是有效值的时候,只能写显式判断。

三、类型标注不参与运行。 list[str]、type[X]、Literal 管的是写代码时的提示和补全,管不了运行时的数据。

参考

作者

wangyawei

发布于

2026-09-14

更新于

2026-09-14

许可协议