Python 开发者经常把 MAX_RETRIES、API_TIMEOUT 这类全大写变量称为常量。但与 Java 的 final 或 Rust 的 const 不同,Python 通常不会阻止代码重新给它们赋值。所谓常量,更多是一份由命名约定、类型检查和数据结构共同维护的工程契约。
理解这一区别,能避免两类常见问题:一类是误以为全大写变量受到运行时保护;另一类是把配置、密钥和业务枚举全部塞进一个越来越难维护的 constants.py。
全大写是一种约定,不是访问控制
Python 社区通常使用全大写加下划线的形式标记模块级常量:
DEFAULT_PAGE_SIZE = 20
MAX_RETRIES = 3
REQUEST_TIMEOUT_SECONDS = 10.0
这种写法告诉阅读代码的人:“这个名字在初始化后不应该再被修改。”然而,解释器并不会执行这条规则:
MAX_RETRIES = 3
MAX_RETRIES = 100 # 合法,Python 不会报错
print(MAX_RETRIES) # 100
因此,大写命名的价值主要体现在可读性、代码审查和团队协作上。它不能用来构建安全边界,也不能防止第三方模块重新绑定该名称。
Python 确实会保护少数语言级名称。例如,给 None、True 或 False 赋值会直接产生语法错误。但这不意味着应用代码可以声明同等级别的自定义常量。
Final 能发现错误,但不会锁住变量
typing.Final 可以把“不应重新赋值”的意图交给 mypy、Pyright 或 IDE 检查:
from typing import Final
MAX_RETRIES: Final[int] = 3
REQUEST_TIMEOUT_SECONDS: Final = 10.0
如果随后重新赋值,静态检查器通常会报告错误:
from typing import Final
MAX_RETRIES: Final[int] = 3
MAX_RETRIES = 5 # 静态类型检查错误
print(MAX_RETRIES) # 直接运行时,Python 仍会输出 5
可以用下面的命令亲自验证:
cat > final_demo.py <<'PY'
from typing import Final
MAX_RETRIES: Final[int] = 3
MAX_RETRIES = 5
print(MAX_RETRIES)
PY
python final_demo.py
python -m pip install mypy
python -m mypy final_demo.py
python final_demo.py 可以正常执行,而 mypy 会指出对 Final 名称的再次赋值。也就是说,Final 是开发阶段的护栏,不是运行时锁。
还要注意,Final 限制的是名称重新绑定,并不会自动让对象变成不可变对象:
from typing import Final
SUPPORTED_FORMATS: Final = ["json", "csv"]
SUPPORTED_FORMATS.append("xml") # 运行时允许,列表本身仍然可变
如果值本身也不应该变化,应优先选择不可变类型:
from typing import Final
SUPPORTED_FORMATS: Final[tuple[str, ...]] = ("json", "csv")
ADMIN_ROLES: Final[frozenset[str]] = frozenset({"owner", "administrator"})
这里形成了两层意图:Final 表示名称不应重新绑定,tuple 和 frozenset 则降低值被原地修改的风险。
常量应该放在哪里
常量不一定都要进入全局 constants.py。更实用的规则是:把名称放在最接近其使用范围的位置。
- 只被一个模块使用:定义在该模块顶部。
- 被同一功能包中的多个模块共享:放入功能包自己的
constants.py。 - 表示一组有业务含义的有限选项:考虑使用
Enum。 - 来自部署环境且会变化:它是配置,不是源代码常量。
- API 密钥、密码和令牌:它们是秘密信息,不应硬编码为常量。
例如,订单状态与普通字符串常量相比,更适合建模为枚举:
from enum import Enum
class OrderStatus(str, Enum):
PENDING = "pending"
PAID = "paid"
CANCELLED = "cancelled"
def can_refund(status: OrderStatus) -> bool:
return status is OrderStatus.PAID
print(can_refund(OrderStatus.PAID))
Enum 提供成员集合、类型语义和运行时身份,不容易把任意字符串误传进业务函数。不过,它并非所有常量的替代品。超时时间、默认页大小和正则表达式等简单值,继续使用模块级全大写名称通常更清楚。
部署配置则应从环境中读取,并在程序启动时完成解析和校验:
import os
DEFAULT_TIMEOUT_SECONDS = 10.0
def load_timeout() -> float:
raw = os.getenv("REQUEST_TIMEOUT_SECONDS")
timeout = DEFAULT_TIMEOUT_SECONDS if raw is None else float(raw)
if timeout <= 0:
raise ValueError("REQUEST_TIMEOUT_SECONDS must be positive")
return timeout
print(load_timeout())
运行时可以覆盖配置:
REQUEST_TIMEOUT_SECONDS=2.5 python app.py
这里的 DEFAULT_TIMEOUT_SECONDS 是代码默认值,而 REQUEST_TIMEOUT_SECONDS 是部署配置。区分两者后,测试、发布和故障排查都会更直接。
一个可以直接改造的项目示例
下面的最小项目把共享常量、枚举和环境配置分开。复制这些命令即可运行:
mkdir -p constants_example
cd constants_example
cat > constants.py <<'PY'
from enum import Enum
from typing import Final
DEFAULT_PAGE_SIZE: Final[int] = 20
MAX_PAGE_SIZE: Final[int] = 100
SUPPORTED_EXPORT_FORMATS: Final[tuple[str, ...]] = ("json", "csv")
class Environment(str, Enum):
DEVELOPMENT = "development"
PRODUCTION = "production"
PY
cat > app.py <<'PY'
import os
from constants import DEFAULT_PAGE_SIZE, Environment, MAX_PAGE_SIZE
def read_page_size() -> int:
value = int(os.getenv("PAGE_SIZE", str(DEFAULT_PAGE_SIZE)))
if not 1 <= value <= MAX_PAGE_SIZE:
raise ValueError(f"PAGE_SIZE must be between 1 and {MAX_PAGE_SIZE}")
return value
def read_environment() -> Environment:
return Environment(os.getenv("APP_ENV", Environment.DEVELOPMENT.value))
if __name__ == "__main__":
print(f"environment={read_environment().value}")
print(f"page_size={read_page_size()}")
PY
python app.py
APP_ENV=production PAGE_SIZE=50 python app.py
这个结构刻意没有把所有内容都称为常量:
- 默认值和上限是稳定的代码级常量。
Environment是一组封闭的业务选项。APP_ENV与PAGE_SIZE是外部配置。- 程序在边界处把字符串转换成明确的类型并进行校验。
采用常量时的检查清单
提交代码前,可以快速检查以下几点:
- 名称是否使用了清楚的单位,例如
TIMEOUT_SECONDS而不是含糊的TIMEOUT? - 常量是否定义在最小合理作用域,而不是全部堆进公共模块?
- 是否用
Final配合静态检查器发现意外重新赋值? - 值需要不可变时,是否使用了
tuple、frozenset等合适类型? - 有限的业务选项是否更适合使用
Enum? - 可随环境变化的值是否应改为配置?
- 密钥和令牌是否已经从源代码中移除?
Python 常量的关键不是寻找一个不存在的 const 关键字,而是组合使用命名约定、静态检查、不可变数据类型、枚举和配置边界。这样做无法把 Python 变成强制不可变的语言,却能让维护者准确理解哪些值应该保持稳定,以及违反约定时应由哪一层工具发现问题。