跳转到内容

Python 速成 Day 1:用 JS/TS 经验理解数据结构与函数参数

这篇文章面向有 JS/TS 和 Node.js 经验的开发者。目标是在约 150 分钟内,能编写常见数据处理函数,并准确判断函数是否会修改原始数据。重点是迁移语言经验,不重复解释变量、条件和循环。

学习顺序是:容器与字段 → 名称与对象 → 拷贝与默认值 → 数据转换与函数参数。先完成讲解与输出预测,再做三个练习,最后用面试 Q&A 检查理解。

建议准备 day1.py,先预测示例输出再运行。示例语法使用 Python 3.12;文章不涉及具体项目源码。

时间 内容
30 分钟 容器与 JS/TS 对照
25 分钟 对象、参数传递与拷贝
25 分钟 函数参数与输出预测
45 分钟 三个编码练习
25 分钟 面试自测与复盘

按数据的用途选择,而不是机械翻译 JS 类型。

Python JS 中的相近用途 关键区别与使用场景
list Array 有序、可变;适合文档列表,append() 返回 None
tuple 固定结构的数组 不能替换元素;适合 (id, status),内部对象仍可能可变
dict Map / Object 键值映射,保留插入顺序;键需要可哈希
set Set 去重和成员判断;元素需要可哈希,不承诺遍历顺序
str string 不可变文本,适合文本内容与标识符
bytes Buffer / Uint8Array 的部分用途 不可变字节序列,用于文件和网络数据;与文本之间需要编码解码

可哈希指对象能提供符合相等规则的稳定哈希值,供字典和集合定位。常见字符串、整数可以作为键,列表和字典不可以。dict/set 查找平均为 O(1),list 成员查找通常为 O(n),不是所有输入下的绝对保证。

names = ["Alice", "Bob"]
assert names.append("Carol") is None
point = (10, 20)
user = {"id": 1, "name": "Alice"}
tags = {"python", "agent", "python"}
assert len(tags) == 2
assert isinstance({}, dict)
assert isinstance(set(), set)
text = "你好"
encoded = text.encode("utf-8")
assert isinstance(encoded, bytes)
assert encoded.decode("utf-8") == text

空集合写成 set(),{} 是空字典。先掌握 UTF-8 转换,今天跳过完整 Unicode 规范和容器底层实现。

方括号访问缺失键会抛 KeyError,不是 JS 的 undefined。 字典键也不能直接用属性语法访问。

user = {"name": "Alice"}
assert user["name"] == "Alice"
assert user.get("age") is None
assert user.get("age", 18) == 18
try:
user["age"]
except KeyError:
pass
else:
raise AssertionError("缺失键应该抛出 KeyError")
user["age"] = None
assert user.get("age", 18) is None

get() 的默认值仅处理键不存在,不会替换已有的 None。必填字段应明确校验,不要只是为了避免异常而改成 get()。

对字典使用 in 默认检查键:"name" in user。对元组使用 outcome in ("created", "updated") 则检查是否等于其中一个元素,类似 JS 数组的 includes()。

读取字典时,字段缺失和值为空已经需要分别处理;设置默认值时,还要进一步区分空值与假值。Python 空列表、空字典和空集合都是假值,与 JS 的空数组、空对象不同。or 会处理所有假值。

assert bool([]) is False
assert bool({}) is False
assert bool(set()) is False
limit = 0
assert (limit or 10) == 10
assert (10 if limit is None else limit) == 0

当 0 表示合法配置,或空列表表示明确不选择任何项时,不能把它们随意替换为默认值。

4. 名称绑定、可变性与参数传递

Section titled “4. 名称绑定、可变性与参数传递”

能够访问和筛选数据后,下一个问题是函数会不会改变这些数据。这里先建立“名称指向对象”的理解,再看拷贝与默认参数。

对普通对象参数而言,两者有相近直觉:修改对象和重新绑定局部参数是两件事。 Python 参数名称绑定到传入的对象,修改可变对象会被调用者观察到,重新绑定局部参数不会改变调用者的变量绑定。

def update(items):
items.append(1) # 修改共享对象
items = [2] # 只重新绑定局部名称
values = []
update(values)
assert values == [1]

这常称为对象共享传递,比单独背“引用传递”准确。

常见可变对象是 list、dict、set;常见不可变对象是数字、bool、str、tuple、bytes。不可变不表示名称不能重新赋值,也不表示递归冻结内部对象:

name = "Alice"
name = "Bob" # 重新绑定合法
record = (1, ["python"])
record[1].append("agent")
assert record == (1, ["python", "agent"])

同样,列表 append(item) 保存的是对象引用,不会复制字典。字典 result[outcome] += 1 则读取整数、计算新值,再更新字典条目;不是把整数对象本身修改了。

is 比较对象身份,== 按类型定义的规则比较相等。 Python 的列表可以按内容相等,而 JS 的数组 === 比较身份。

a = [1, 2]
b = [1, 2]
c = a
assert a == b
assert a is not b
assert a is c

判断空值一般用 is None,状态字符串用 ==。不要依赖数字或字符串缓存来使用 is。

6. 从对象共享理解浅拷贝与深拷贝

Section titled “6. 从对象共享理解浅拷贝与深拷贝”

现在已经能区分同一个对象与内容相等的不同对象,就可以准确判断复制到底隔离了哪一层。

浅拷贝只复制外层容器,嵌套对象仍可能共享。 与 JS { ...original } 的思路相近。

from copy import deepcopy
original = {"timeout": 10, "tags": ["python"]}
copied = original.copy()
isolated = deepcopy(original)
copied["timeout"] = 20
copied["tags"].append("agent")
assert original == {"timeout": 10, "tags": ["python", "agent"]}
assert isolated == {"timeout": 10, "tags": ["python"]}

深拷贝递归复制支持复制的嵌套对象,但连接、文件、模型等资源不能随意复制,大型结构也有成本。先明确修改范围,再选择复制哪些数据。

热身题:下面输出什么?

a = [{"tags": []}]
b = a.copy()
b[0]["tags"].append("python")
print(a)
print(a is b)
print(a[0] is b[0])
print(bool([]))

答案依次为 [{'tags': ['python']}]、False、True、False。外层列表不同,内部字典相同;Python 布尔值首字母大写。

对象共享不仅发生在赋值和拷贝中,也可能藏在函数定义里。这是 JS 开发者需要特别调整的一处直觉。

默认参数表达式在函数定义执行时求值,后续省略参数的调用会复用它。 JS 默认参数表达式在调用时求值,不能直接迁移这个直觉。

def add_tag_bad(tag, tags=[]):
tags.append(tag)
return tags
assert add_tag_bad("python") == ["python"]
assert add_tag_bad("agent") == ["python", "agent"]

使用 None 在函数内创建列表,可以解决默认对象共享。若还要求不修改显式传入的数据,可以返回新列表:

def add_tag(tag, tags=None):
current = [] if tags is None else tags
return [*current, tag]
existing = ["python"]
assert add_tag("agent", existing) == ["python", "agent"]
assert existing == ["python"]

解决默认对象共享,与保证不修改输入,是两个要求。

8. 用推导式、切片和解包转换数据

Section titled “8. 用推导式、切片和解包转换数据”

推导式按“遍历 → 筛选 → 产出”理解,切片不含终点,解包用于分配元素。

records = [{"id": "a", "active": True}, {"id": "b", "active": False}]
ids = [record["id"] for record in records if record["active"]]
by_id = {record["id"]: record for record in records}
assert ids == ["a"]
assert by_id["a"] is records[0]
items = [10, 20, 30, 40]
assert items[1:3] == [20, 30]
assert items[-1] == 40
first, *rest = items
assert first == 10
assert rest == [20, 30, 40]

字典推导式遇到重复键,后面的值覆盖前面的值;它不会复制每个记录。列表切片也只是浅拷贝。

排序区别也要记住:sorted(items) 返回新列表,items.sort() 原地排序且返回 None。不要写 items = items.sort()。

9. 位置参数、关键字参数与展开

Section titled “9. 位置参数、关键字参数与展开”

前面的解包发生在赋值或调用时。函数定义里的星号则控制参数怎样接收,不能把所有星号都理解成对象解构。独立 * 要求后面的参数只能按名称传入。

写法 定义处的作用
* 后续参数必须按名称传入
*args 收集额外位置参数为 tuple
**kwargs 收集额外关键字参数为 dict
def call_model(prompt, *args, timeout=10, **kwargs):
return prompt, args, timeout, kwargs
assert call_model("hello", "extra", timeout=5, temperature=0.2) == (
"hello", ("extra",), 5, {"temperature": 0.2}
)
def search(query, *, limit=10):
return query, limit
assert search("python", limit=5) == ("python", 5)
# search("python", 5) 会抛 TypeError。

JS 的 function search(query, { limit = 10 } = {}) 使用对象解构;Python 的关键字参数没有传入一个待解构的 options 对象。

调用位置的 *values、**options 则表示展开,与定义位置的收集方向相反。

每题约 15 分钟,先独立实现,再查看后面的参考答案。

实现 normalize_records(records):缺失 id 跳过;重复 id 保留最后一条;缺失 type 默认 unknown;缺失或为 None 的 size 默认 0;不修改输入。用重复 ID、零值和空输入验证。

实现 merge_config(defaults, overrides):后者覆盖前者,保留 timeout=0;tags 约定为字符串列表,返回列表不与输入共享。不写通用深度合并。

实现 build_request(query, *, limit=10, tags=None, **options):收集额外选项,复制标签列表,并说明重复参数如何处理。每题至少写三个断言。

要求:缺失 id 跳过,重复 id 保留最后一条;type 缺失默认 unknown;size 缺失或为 None 默认 0;不修改输入。本题不扩展到 ID 合法性校验。

from copy import deepcopy
def normalize_records(records):
by_id = {}
for record in records:
if "id" not in record:
continue
normalized = record.copy()
normalized.setdefault("type", "unknown")
if normalized.get("size") is None:
normalized["size"] = 0
by_id[normalized["id"]] = normalized
return list(by_id.values())
records = [
{"id": "a", "type": "image", "size": 100},
{"id": "b", "type": "video", "size": 0},
{"id": "a", "type": "image", "size": 200},
{"type": "audio", "size": 50},
{"id": "c", "size": None},
]
before = deepcopy(records)
assert normalize_records(records) == [
{"id": "a", "type": "image", "size": 200},
{"id": "b", "type": "video", "size": 0},
{"id": "c", "type": "unknown", "size": 0},
]
assert records == before
assert normalize_records([]) == []

setdefault 只处理缺失键。更新已有字典键不改变其位置,因此输出按 ID 首次出现排序,但内容取最后一条。浅拷贝满足本次不修改输入的要求,不承诺返回结果与所有嵌套数据彻底隔离。

要求:overrides 覆盖 defaults,保留 timeout=0,不修改输入;返回结果的 tags 不与输入共享。只考虑 tags 为字符串列表的已知结构。

def merge_config(defaults, overrides):
merged = {**defaults, **overrides}
merged["tags"] = merged["tags"].copy()
return merged
defaults = {"timeout": 10, "tags": ["base"]}
overrides = {"timeout": 0}
result = merge_config(defaults, overrides)
assert result["timeout"] == 0
assert result["tags"] is not defaults["tags"]
result["tags"].append("python")
assert defaults == {"timeout": 10, "tags": ["base"]}
assert overrides == {"timeout": 0}
overrides = {"tags": ["custom"]}
result = merge_config(defaults, overrides)
result["tags"].append("agent")
assert overrides["tags"] == ["custom"]

字典展开类似 JS 对象展开,后者覆盖前者。单独复制已知可变字段,不需要引入通用深度合并;没有使用 or,所以合法的 0 得到保留。

要求:返回 query、limit、tags 和额外选项;默认列表独立;显式传入的标签列表也要复制;额外选项不能覆盖核心字段。本题约定 tags 为列表或 None。

def build_request(query, *, limit=10, tags=None, **options):
return {
**options,
"query": query,
"limit": limit,
"tags": [] if tags is None else list(tags),
}
first = build_request("python")
second = build_request("agent")
first["tags"].append("backend")
assert second["tags"] == []
original_tags = ["rag"]
request = build_request("python", tags=original_tags)
request["tags"].append("agent")
assert original_tags == ["rag"]
assert request["tags"] == ["rag", "agent"]
request = build_request("python", limit=0, temperature=0.2)
assert request["limit"] == 0
assert request["temperature"] == 0.2
try:
build_request("python", limit=10, **{"limit": 5})
except TypeError:
pass
else:
raise AssertionError("重复传参应该失败")

这个签名已经将核心字段交给显式参数接收。build_request("python", **{"limit": 5}) 中,limit 进入显式参数,不进入 options;这是合法传参。重复提供 limit 时,Python 在进入函数体前抛 TypeError,所以不必再检查 "limit" in options。

  1. Python 参数如何传递? 参数绑定到传入对象;修改对象可以影响调用者,重新绑定局部名称不会。
  2. 四种容器怎么选? list 保存有序可变集合;tuple 表达固定组合;dict 按键查找;set 去重和做集合运算。
  3. is 与 == 有何不同? 前者比较身份,后者比较相等。不同对象可以相等。
  4. 可变默认参数为何共享? 默认表达式在函数定义执行时求值,后续调用复用;用 None 在内部创建独立容器。
  5. 浅拷贝与深拷贝隔离什么? 浅拷贝只复制外层,深拷贝递归处理嵌套对象;资源对象和成本需要另外考虑。
  6. tuple 一定可哈希吗? 不一定,所有元素都可哈希才可以;(1, ["python"]) 不能作为字典键。

合上参考答案,重新完成三个函数,并用断言验证零值、缺失字段、重复 ID 和对象隔离。能解释独立 *、浅拷贝和默认参数的行为,即完成今天的核心目标。

下一篇:Day 2:函数包装、装饰器、迭代器与生成器。