PAYATHON 2026

如何让 Python UserString 兼容 str,并保留惰性求值?

数据小陈

给 UserString 增加方法,无法让所有要求 str 的接口自动接受它。继承 str 也无法通过一个通用钩子拦截所有字符串读取。可以保留惰性对象,在写文件、生成 JSON 或传入模型时显式求值,得到真正的 str。

为什么 UserString 看起来像字符串,却仍被拒绝?

问题中的实现把转换逻辑放在 data 属性里:访问该属性时,执行 our_func(self.original_str)。这样可以支持通过 UserString 方法或 str() 转换进行的操作,但对象的类型不会因此改变。

根据 Python 的 UserString 文档,UserString 是字符串包装类,普通实现通过 data 保存内部的 str;它本身不是 str 的子类。

from collections import UserString

value = UserString("hello")

isinstance(value, UserString)  # True
isinstance(value, str)         # False
isinstance(str(value), str)    # True

一个对象支持字符串方法、可以与字符串比较,或者实现了 __str__(),都不意味着接收方会自动调用 str()。文本 I/O 接口要求字符串数据,也没有承诺对任意对象执行这种转换,参见 Python 文本 I/O 文档。

修改类型标注或使用 typing.cast(str, value) 也不会转换运行时对象,因此不能解决这类写入或序列化错误。

为什么继承 str 仍会绕过惰性函数?

str 是不可变类型。子类创建时就有实际的字符串内容,重写 __str__() 只能影响调用该方法的路径,无法让所有字符串操作都改为读取动态结果。

以 CPython 的 JSON 编码为例,编码器可以直接读取字符串内部的 Unicode 数据,无须先调用对象重写的 __str__()。因此,下面的对象会有两套内容:

import json

class LooksLazy(str):
    def __str__(self):
        return "translated"

value = LooksLazy("original")

str(value)         # "translated"
json.dumps(value)  # '"original"',CPython 的标准 JSON 编码路径

从 CPython 的 JSON 实现可以看出,重写 __str__() 不足以实现全面兼容。某个消费者调用了该方法,也不代表所有接口都会这样做。

__getattribute__() 同样无法拦截 C 实现对字符串内部数据的直接读取。将转换放进 __new__() 可以得到内容正确的字符串,但转换会发生在对象创建时,失去所需的惰性。

在实际输出时转换为 str

下面的示例沿用原来的思路,用大写转换代替业务中的 our_func,展示求值时机:

from collections import UserString


def our_func(text: str) -> str:
    return text.upper()


class LazyString(UserString):
    def __init__(self, original_str: str):
        self.original_str = original_str

    @property
    def data(self) -> str:
        return our_func(self.original_str)


message = LazyString("hello")  # 此处不调用 our_func
resolved = str(message)        # 此处求值,得到普通 str

with open("message.txt", "w", encoding="utf-8") as fp:
    fp.write(resolved)

业务中可以将 our_func 换成已配置好的 gettext 翻译函数,并在目标语言上下文生效时执行 str(message)。

由于 data 是只读属性,示例没有调用会给 self.data 赋值的 UserString.__init__()。这个最小实现不能保证所有继承方法都符合业务需要。部分操作会构造新的同类对象,可能把已经转换的结果再次交给转换函数。如果翻译或转换操作不是幂等的,需要检查实际使用到的方法。

如果只需要“保存原文,输出时求值”,一个仅实现 __str__() 的独立代理类也可以满足需求,不一定要继承 UserString。

JSON 的值和字典键需要分别处理

如果惰性对象只出现在 JSON 的值中,可以提供一个限定范围的转换函数:

import json


def encode_lazy(value):
    if isinstance(value, LazyString):
        return str(value)
    raise TypeError(
        f"Object of type {type(value).__name__} is not JSON serializable"
    )


payload = {"message": message}
result = json.dumps(payload, default=encode_lazy, ensure_ascii=False)

default 不会转换不受支持的字典键。标准编码器会单独检查键的类型;skipkeys=True 会跳过不支持的键,不会将其转为字符串。相关规则见 json 文档和 JSONEncoder 实现。

如果键和值中都有惰性对象,可以在编码前统一转换。下面的辅助函数适用于由无循环引用的 dict、list、tuple 和 JSON 基本值组成的数据:

def resolve_lazy(value):
    if isinstance(value, LazyString):
        return str(value)

    if isinstance(value, dict):
        result = {}
        for key, item in value.items():
            resolved_key = (
                str(key) if isinstance(key, LazyString) else key
            )
            if resolved_key in result:
                raise ValueError("Duplicate key after lazy conversion")
            result[resolved_key] = resolve_lazy(item)
        return result

    if isinstance(value, (list, tuple)):
        return [resolve_lazy(item) for item in value]

    return value


result = json.dumps(resolve_lazy(payload), ensure_ascii=False)

这个函数只转换指定的惰性类型,不会把所有未知对象都转成字符串。其他不受支持的值仍会由 JSON 编码器报错。

对于 gettext,通常更合适的做法是使用稳定的消息标识或固定字段名作为字典键,把翻译结果放在值中。不同原文可能翻译成相同文本,直接用译文作键会产生冲突。

Pydantic 应在哪里处理?

原问题没有提供 Pydantic 版本、字段定义或具体错误,因此无法确定对象是在哪一阶段被拒绝的。可以先根据模型用途选择转换位置:

  • 如果模型表示已经准备输出的数据,在构造模型前执行 str(message),让 str 字段接收普通字符串。
  • 如果模型需要一直保存惰性对象,就需要为该类型配置相应的验证和序列化逻辑。具体 API 应按所用 Pydantic 版本确定。

如果很多入口都需要同样的转换,可以把逻辑集中在模型构造函数、数据适配层或该版本支持的验证器中。仅放宽字段类型或允许任意类型,不能保证后续 JSON 导出也自动成功。

惰性翻译还需要确定哪些语义?

当前 data 属性每次读取都会调用转换函数,不会自动缓存。如果结果依赖当前语言,缓存首次结果可能导致切换语言后仍使用旧译文;完全不缓存则可能在多次读取时得到不同结果。需要先确定对象代表的是固定语言的文本,还是随当前上下文变化的消息。

也不宜把结果随上下文变化的对象用作 dict 键或 set 元素。UserString 的哈希与内部字符串有关,惰性结果变化可能破坏哈希容器要求的稳定性。应在求值后使用普通 str,或者使用不随语言变化的标识。

备注:内容仅供参考。