如何编写随机函数:伪随机数、种子与结果复现

如何编写随机函数:伪随机数、种子与结果复现

如何编写随机函数:伪随机数、种子与结果复现

随机函数看起来很简单:取一个范围,返回一个数。但实际开发时,问题往往不在“怎么抽一个数”,而在于这个结果是否均匀、边界是否符合预期、测试能否复现,以及随机值是否会被攻击者猜到。

本文以 Python 为例,介绍伪随机数的工作方式、怎样编写可测试的随机函数、如何复现结果,以及什么时候不能使用普通随机数。示例适用于 Python 3;其中 random 用于一般模拟和抽样,secrets 用于令牌、验证码等安全敏感场景。

先分清三件事:随机、伪随机与安全随机

程序通常无法凭空获得理想数学意义上的“真随机”。Python 的 random 模块使用伪随机数生成器:它根据内部状态和确定性的算法产生一串看起来随机的数。给定相同的初始状态和相同的调用过程,通常会得到相同的序列。

这种性质不是缺点。它使模拟、抽样和测试可以重复执行。但伪随机也意味着:输出不是天然不可预测的。普通随机数适合大多数统计和游戏逻辑,不适合密码、会话令牌、重置密码链接等需要抵抗预测的用途。

  • random:用于模拟、抽样、随机排序、游戏中的非安全随机事件。
  • secrets:用于生成密码学用途的随机值,例如令牌和一次性验证码。
  • 固定种子:用于复现一次伪随机序列,不能用来增加安全性。

先看最基本的随机函数

下面的函数从闭区间 [low, high] 中返回一个整数。这里特意检查范围,避免调用方传入反向区间或不符合整数要求的参数。

import random

def random_int(low: int, high: int) -> int:
    """返回闭区间 [low, high] 中的整数。"""
    if not isinstance(low, int) or not isinstance(high, int):
        raise TypeError("low 和 high 必须是整数")
    if low > high:
        raise ValueError("low 不能大于 high")
    return random.randint(low, high)

print(random_int(1, 6))

random.randint(a, b) 两端都包含,也就是可以返回 a,也可以返回 b。这和很多编程接口使用的左闭右开区间不同。若希望写成左闭右开区间 [low, high),可以用 randrange:

import random

def random_index(length: int) -> int:
    """返回 0 到 length - 1 之间的整数。"""
    if not isinstance(length, int):
        raise TypeError("length 必须是整数")
    if length <= 0:
        raise ValueError("length 必须大于 0")
    return random.randrange(length)

print(random_index(5))  # 可能是 0、1、2、3 或 4

对空范围调用 randrange 会触发 ValueError。因此,长度为零的列表不能直接拿来生成随机下标。不要把异常当作正常控制流程,也不要通过给上界加减一来“凑”区间而不说明边界约定。

常见接口和参数边界

整数:闭区间还是左闭右开

randint(a, b) 等价于从 randrange(a, b + 1) 取值,包含两端;randrange(start, stop, step) 则遵循 Python 的范围规则,包含 start 方向上的起点,不包含 stop。步长为零会报错,步长方向与范围不匹配时也可能得到空范围并报错。

import random

print(random.randint(1, 10))       # 1 到 10,包含 10
print(random.randrange(1, 10))    # 1 到 9,不包含 10
print(random.randrange(2, 11, 2)) # 2、4、6、8、10

设计自己的接口时,应在文档和变量名附近明确区间语义。诸如“随机取一个范围”过于含糊;“闭区间”或“左闭右开区间”才便于调用者正确使用。

浮点数:不要依赖端点一定出现

random.random() 返回一个大于等于 0 且小于 1 的浮点数。random.uniform(a, b) 返回一个落在两个端点之间的浮点数;文档对是否包含其中一个端点留有实现相关的空间,因此不要把它用作严格的边界判断。浮点舍入也会影响端点附近的表现。

import random

unit_value = random.random()       # 0.0 <= x < 1.0
scaled_value = random.uniform(2.5, 8.0)

print(unit_value)
print(scaled_value)

如果业务规则要求严格满足某个精确区间,最好明确检查或改用整数域抽样后再转换。也不要用浮点数是否“恰好等于某个值”来判断抽样结果;浮点计算本身存在精度限制。

从序列中选择:空集合要先处理

random.choice(seq) 从非空序列中选择一个元素。传入空序列会报错,因此如果空集合是业务上可能出现的情况,应在调用前定义处理方式。

import random

def choose_item(items):
    if not items:
        return None
    return random.choice(items)

print(choose_item(["red", "green", "blue"]))

如果空列表代表错误,而不是“没有结果”,就应该抛出明确异常,而不是返回 None。返回值约定取决于业务语义。

random.choices(population, weights=None, k=1) 支持有放回抽样:同一个元素可以被抽中多次。指定 weights 时,权重应与元素一一对应,且不能全部为零;权重不必归一化为总和 1。若需要按相对权重抽样,直接传入相对权重即可。

import random

items = ["common", "uncommon", "rare"]
weights = [80, 18, 2]

print(random.choices(items, weights=weights, k=5))

random.sample(population, k) 则是不放回抽样,适合从互不重复的候选项中抽取多个元素。要求 k 不大于可抽取的总体大小;否则会报错。对集合等非序列对象,建议先转换成有明确顺序的序列,以免数据来源本身的迭代顺序造成困惑。

import random

participants = ["Lin", "Mei", "Bo", "An"]
winners = random.sample(participants, k=2)
print(winners)

随机种子如何让结果复现

设置种子会初始化伪随机数生成器的状态。对于同一 Python 环境、同一生成器和相同调用顺序,固定种子可以让结果重复:

import random

random.seed(202405)

first = [random.randint(1, 100) for _ in range(5)]
print(first)

random.seed(202405)

second = [random.randint(1, 100) for _ in range(5)]
print(second)

assert first == second

种子可以是整数,也可以是支持的字节串或字符串等类型;具体允许类型与转换规则应以所用 Python 版本的文档为准。整数种子最直观,适合写在测试和实验配置中。固定种子只固定随机源的起点,并不保证业务逻辑永远不变。

一个常见误解是:“设置相同种子,程序以后升级也一定逐项得到完全相同的结果。”不应作这种跨版本承诺。Python 文档只对部分随机模块行为提供稳定性保证;算法实现、种子处理或调用方式在不同版本和环境中的变化,都可能使序列不同。若长期实验要求严格复现,应记录 Python 版本、依赖版本、生成器类型、种子、参数、数据输入及调用流程。

避免共享全局随机状态

random.seed() 会重置模块级的共享生成器。一个模块为了测试而重新设种子,可能改变程序其他部分接下来抽到的值。尤其在较大的项目中,多个组件共用全局随机状态,会让结果依赖调用先后顺序:添加一行随机调用,就可能改变后续所有抽样结果。

更稳妥的做法是创建独立的 Random 实例,并把它作为依赖传给需要随机行为的函数。

import random

def roll_dice(rng: random.Random) -> int:
    return rng.randint(1, 6)

rng_a = random.Random(123)
rng_b = random.Random(123)

print(roll_dice(rng_a))
print(roll_dice(rng_b))  # 与上一行相同

这样每个组件可以拥有自己的随机状态,测试也可以明确控制随机输入。与在函数内部临时调用 random.seed() 相比,依赖注入不会悄悄影响其他代码。

编写可复现、可测试的随机函数

测试随机函数时,重点通常不是断言“多次运行的结果一定不同”或“每个结果恰好出现相同次数”。有限样本会有自然波动,过于严格的频率断言容易造成偶发失败。更可靠的策略是把随机生成器作为参数传入,在单元测试中使用固定种子,验证范围、边界和业务规则。

import random

def pick_discount(rng: random.Random, low: int, high: int) -> int:
    """返回闭区间 [low, high] 中的折扣百分比。"""
    if low < 0 or high > 100:
        raise ValueError("折扣范围必须位于 0 到 100 之间")
    if low > high:
        raise ValueError("low 不能大于 high")
    return rng.randint(low, high)

def test_pick_discount():
    rng = random.Random(7)
    value = pick_discount(rng, 5, 20)
    assert 5 <= value <= 20

test_pick_discount()
print("test passed")

这个测试没有假设种子 7 必须产生某个特定折扣,而是检查函数契约:输出必须在允许范围内。若测试的确需要验证特定序列,应把种子、Python 版本和预期结果视为测试环境的一部分,并意识到它可能对实现细节较敏感。

另一个常用方式是给函数传入“随机策略”,在测试里替换成固定返回值。这适合验证某个分支,比如“抽中稀有物品后是否正确记账”。不过,策略接口应保持简单,不必为了少量随机行为构造复杂框架。

按比例随机并不等于简单取模

如果想让某些结果更常出现,不要自己先生成一个大整数,再用取模映射到目标范围,除非已经分析并接受由此带来的偏差。取模法在生成范围不能被目标数量整除时,可能让部分结果出现概率更高。

对普通应用,可以使用标准库提供的接口,例如 choices 处理权重抽样,或使用 randrange 抽取离散范围。它们比手工实现缩放、取模和边界处理更清楚,也更不容易因整数范围或舍入方式写错。

打乱列表时注意原地修改

random.shuffle(x) 会原地打乱可变序列,并返回 None。下面的写法会把 shuffled 设为 None,这是常见错误:

import random

names = ["A", "B", "C"]
random.shuffle(names)
print(names)  # names 已被原地打乱

如果要保留原列表,可以先复制:

import random

names = ["A", "B", "C"]
shuffled = names.copy()
random.shuffle(shuffled)

print("原列表:", names)
print("打乱后:", shuffled)

不要在迭代列表的同时打乱它,也不要假设重复元素会变成不同元素。打乱改变的是元素的位置;两个内容相同的值仍然无法通过值本身区分。

安全敏感场景改用 secrets

普通的 random 不是为抵抗攻击设计的。若随机结果会被外部用户观察、用于认证或影响访问权限,应使用 secrets。例如生成随机令牌、密码重置标识或不可预测的一次性值。

import secrets

# 生成 32 个随机字节,并编码为便于传输的十六进制字符串
token = secrets.token_hex(32)
print(token)

# 在 0 到 999999 范围内生成一个整数,包含两端
code = secrets.randbelow(1_000_000)
print(f"{code:06d}")

secrets.randbelow(n) 返回范围为 0 <= x < n 的整数,n 必须大于零。示例中的六位格式只是显示格式:数值可能以零开头,因此用格式化补足位数;如果业务要求一次性验证码,还需要考虑有效期、尝试次数限制、传输和存储保护等问题。随机生成本身并不构成完整的认证方案。

secrets.token_hex(32) 中的参数是请求的字节数,不是输出字符数;十六进制编码后长度通常是请求字节数的两倍。选择令牌长度时,应按所需安全强度和系统约束设计,不要把“看起来够长”当作安全分析。

切勿使用固定种子为安全令牌“保证复现”。安全令牌需要不可预测,固定种子会削弱这一目标。也不要把用户名、时间戳或递增编号当作随机种子来补救普通随机数的安全问题。

多进程、并发与复现边界

随机状态是有状态的:每次调用都会推进它。在并发程序中,多个任务共享同一个生成器时,实际调用顺序可能受调度影响,因此即使开始时设置了种子,输出序列与任务之间的对应关系也可能变化。多进程程序还要注意进程启动方式和状态继承等因素;复制了相同随机状态的进程可能产生相关或重复的序列。

如果需要并行模拟,应根据所用库的文档使用专门的独立随机流、子种子或并行随机数方案,而不是让所有进程简单地使用同一个整数种子。科学计算常用 NumPy 等库时,也应使用它们提供的生成器接口,并记录生成器类型与版本;不要把 Python 标准库的复现规则直接套用到其他库。

此外,复现不仅是随机序列复现。外部数据、排序稳定性、时区、浮点运算、依赖版本和程序分支都可能影响最终输出。需要复现实验时,应保存足以重建整个运行环境的信息,而不是只保存一个种子。

常见错误清单

  • 把 randint 当成右端不包含。 它包含两端;右端不包含时使用 randrange,并写清区间约定。
  • 对空序列直接调用 choice。 先决定空输入应返回什么或抛出什么异常。
  • 把 shuffle 的返回值赋给新变量。 它原地修改并返回 None;需要副本就先复制。
  • 测试时在业务函数中反复设种子。 这会重置共享状态;优先传入独立的生成器。
  • 把固定种子当成安全措施。 种子用于复现,不用于防止预测。
  • 用少量样本的频率判断均匀性。 随机波动并不表示实现有偏;测试应针对契约,统计检验需要足够样本和合适方法。
  • 用普通伪随机数生成令牌。 安全用途应使用 secrets,并同时设计令牌生命周期与访问控制。
  • 只保存种子,不保存环境信息。 版本、参数、输入和调用顺序也可能决定结果。

一个可直接运行的小结示例

下面的程序把一般模拟和安全令牌分开处理:模拟使用可控的 Random 实例,令牌则交给 secrets。这样代码意图清楚,也便于测试。

import random
import secrets

def simulate_round(rng: random.Random, sides: int = 6) -> int:
    if not isinstance(sides, int):
        raise TypeError("sides 必须是整数")
    if sides < 1:
        raise ValueError("sides 必须大于 0")
    return rng.randint(1, sides)

def make_reset_token() -> str:
    return secrets.token_urlsafe(32)

if __name__ == "__main__":
    # 相同种子和调用顺序可用于复现这组模拟结果
    rng = random.Random(202405)
    results = [simulate_round(rng) for _ in range(5)]
    print("模拟结果:", results)

    # 安全敏感的重置令牌不使用固定种子
    print("重置令牌:", make_reset_token())

编写随机函数时,先明确用途,再明确参数区间、空输入和错误行为;一般模拟用 random,安全敏感值用 secrets;需要测试时把随机生成器作为依赖传入,并记录复现所需的环境信息。把这些边界写清楚,随机代码才既好用,也不容易在测试或安全上留下隐患。

© 版权声明

相关文章