Python 的变量看起来简单——赋值就行,不需要声明类型。但恰恰是这种"简单"藏着最多坑:同一个名字在不同位置指向不同对象,类型提示写成了摆设,命名混乱让三个月后的自己读不懂代码。这篇文章把变量从创建到最佳实践的关键细节拆开,配上可以直接跑的例子。
变量不是"盒子",是"标签"
很多教程把变量比作装数据的盒子,但 Python 的变量更像贴在对象上的标签。赋值操作并不复制数据,只是让一个名字指向一个已有对象:
a = [1, 2, 3]
b = a # b 和 a 指向同一个列表对象
b.append(4)
print(a) # [1, 2, 3, 4] — a 也变了
理解这一点,就能避免最常见的"意外共享"bug。如果确实需要独立副本,用 b = a.copy() 或 b = list(a)。
动态类型:灵活与风险并存
Python 变量没有固定类型,同一个名字可以先绑整数再绑字符串:
x = 42
x = "hello" # 完全合法,x 现在指向一个 str 对象
灵活是好事,但在多人协作或长期维护的项目里,一个变量中途"变脸"往往意味着逻辑混乱。最佳实践:一个变量在整个生命周期内应保持同一类型,如果语义变了,换一个变量名。
type() 和 isinstance() 是运行时检查类型的工具,后者更推荐,因为它能正确处理继承关系:
isinstance(42, int) # True
isinstance(True, int) # True — bool 是 int 的子类
isinstance(True, bool) # True
type(True) is int # False — type() 不考虑继承
命名:让代码自解释
Python 的命名约定在 PEP 8 里有明确规定,核心几条:
| 对象类型 | 命名风格 | 示例 |
|---|---|---|
| 变量 / 函数 | snake_case |
user_count, fetch_data |
| 类 | PascalCase |
HttpRequest, DataFrame |
| 常量 | UPPER_SNAKE |
MAX_RETRIES, PI |
| 秓名 / 私方法 | 前缀 _ |
_internal_cache, _parse() |
几个容易踩的坑:
- 避免
l、O、I作单字符名——在不少字体里和1、0几乎无法区分。 - 别用内置名覆盖:
list = [1, 2]之后,你再写list((1, 2))就报错。 - 名字要有意图:
tmp、ret、val之类的名字只在极短作用域里勉强可接受;超过 5 行的作用域,用unverified_users而不是tmp_list。
作用域:LEGB 规则与常见陷阱
Python 查找变量名时按 LEGB 顺序搜索:Local → Enclosing → Global → Built-in。
最常踩的两个坑:
1. 函数内修改全局变量必须声明 global
counter = 0
def increment():
global counter # 缺了这行,下一行会报 UnboundLocalError
counter += 1
increment()
print(counter) # 1
不加 global,Python 会把 counter += 1 里的 counter 当作局部变量,而赋值前又没定义,于是抛 UnboundLocalError。
2. 循环变量不会自动局限在循环块里
for item in [1, 2, 3]:
pass
print(item) # 3 — item 还在,指向最后一个值
在列表推导或生成器表达式里则不会泄漏:
[item for item in [1, 2, 3]]
print(item) # NameError — 推导式有自己的局部作用域
类型提示:从"建议"到"契约"
类型提示(type hints)不改变运行时行为,但让 IDE、mypy 和队友能提前发现问题。基本写法:
from typing import Optional, List, Dict
def find_user(user_id: int) -> Optional[Dict[str, str]]:
"""按 ID 查用户,找不到返回 None。"""
...
Python 3.9+ 可以用内置泛型替代 typing 里的对应类型:
# 3.9+ 推荐
def batch_process(ids: list[int]) -> dict[int, str]:
...
# 3.8 及更早
from typing import List, Dict
def batch_process(ids: List[int]) -> Dict[int, str]:
...
几个实用进阶提示:
Final——标记不应被重新赋值的变量(比UPPER_SNAKE常量约定更强):
from typing import Final
MAX_CONNECTIONS: Final[int] = 100
MAX_CONNECTIONS = 200 # mypy 会报错
TypeAlias——给复杂类型起个短名:
from typing import TypeAlias
UserMap: TypeAlias = dict[int, dict[str, str]]
def load_users() -> UserMap:
...
实战示例:一个带完整类型提示的小模块
下面是一个可以直接跑的示例,把上面提到的命名、作用域、类型提示综合在一起。保存为 user_registry.py,用 mypy user_registry.py 检查类型,再用 python user_registry.py 运行:
"""用户注册表——演示变量命名、作用域与类型提示的综合实践。"""
from typing import Final, TypeAlias
# 常量:用 Final + UPPER_SNAKE 双重约束
MAX_USERS: Final[int] = 50
# 类型别名:让函数签名更清晰
UserInfo: TypeAlias = dict[str, str]
# 全局注册表——模块级变量,用 snake_case
_registry: dict[int, UserInfo] = {} # 前缀 _ 表示模块内部使用
def register(user_id: int, name: str, email: str) -> UserInfo:
"""注册一个用户,返回用户信息字典;超限抛 ValueError。"""
if len(_registry) >= MAX_USERS:
raise ValueError(f"注册人数已达上限 {MAX_USERS}")
user: UserInfo = {"name": name, "email": email}
_registry[user_id] = user
return user
def lookup(user_id: int) -> UserInfo | None:
"""按 ID 查用户,找不到返回 None。"""
return _registry.get(user_id)
def list_all() -> list[UserInfo]:
"""返回所有已注册用户的信息列表。"""
return list(_registry.values())
# ---- 运行演示 ----
if __name__ == "__main__":
register(1, "Alice", "alice@example.com")
register(2, "Bob", "bob@example.com")
found = lookup(1)
print(f"查找结果: {found}")
all_users = list_all()
print(f"全部用户: {all_users}")
# 演示作用域:函数内的局部变量不会污染全局
def _demo_scope():
local_count = len(_registry) # local_count 只在此函数内可见
print(f"局部计数: {local_count}")
_demo_scope()
# print(local_count) # 取消注释会报 NameError
运行前确保 Python ≥ 3.10(UserInfo | None 语法需要 3.10+;3.9 请改回 Optional[UserInfo])。mypy 检查命令:
mypy --strict user_registry.py
写好变量的检查清单
收尾时,用这张清单审视自己的代码:
- 赋值即绑定——记住变量是标签不是盒子,警惕意外共享可变对象。
- 类型稳定——一个变量名不应中途换类型;语义变了就换名字。
- 命名有意图——遵循 PEP 8 风格,避开单字符歧义名和内置名覆盖。
- 作用域最小化——数据尽量局限在最小必要范围;函数内改全局变量必须
global声明;留意循环变量泄漏。 - 类型提示当契约——公共 API 的函数签名必须加类型提示;用
Final保护常量;用TypeAlias简化复杂类型;跑 mypy 而不是只当注释。
变量是 Python 最基础的概念,但"基础"不等于"可以随便写"。把上面这些细节落实,代码的可读性和可维护性会有肉眼可见的提升。