给任务状态赋值,看起来可以把原先的 @property 换成“更通用”的描述符。当前对照运行把普通 Task 与候选 Candidate 放在同一组输入下:"unknown" 与 None 均抛 ValueError: unknown task state,[] 在两边均抛 TypeError: unhashable type: 'list'。原因不在描述符:主线 Task.state 也用 value not in TASK_STATES,集合成员判断要求输入可哈希。这里修正的是对原合同的描述,不是悄悄改变应用行为。

本文只回答一个选型问题:tasklab 现在是否需要把状态验证提升到描述符或元类?先运行相同输入,再看协议与维护成本。它不把抽象机制当作必须加入主线的新功能。12 的绑定方法和22 的状态断言是这里的前置。

属性访问经过谁

Task.state 已经是一个数据描述符:property 定义了读写逻辑,task.state = value 会调用 setter,不是简单地向实例字典塞一个同名键。现有 setter 在 RLock 下校验状态并更新 _state;snapshot() 再次取得锁来读取任务视图。只重写一个普通校验函数并不能自动保留这些锁和读取语义。RLock 允许同一线程嵌套获取,但它也不会凭属性协议自动保证整个任务状态迁移的业务原子性。

实验文件 experiments/E01_descriptor.py 定义一个可移除的 StateField,在 Candidate(Task) 中覆盖同名属性。__set_name__ 在创建类时得到字段名,__get__ 和 __set__ 分别在锁内读写原来的 _state,拒绝非法值时保留主线异常类型与消息。这里的候选实现并没有进入 src/tasklab/。

对普通 Task 和候选 Candidate 都执行 queued → running → succeeded,再分别赋 "unknown"、[]、None。合法路径的 snapshot() 相等;两个可哈希的非法值抛同一 ValueError,列表在两边均抛 TypeError,状态维持 running。输入并非只有字符串,因而“拒绝非法值”并不等于“所有非法值都抛 ValueError”。如果要给公开边界统一报错,应在输入校验入口单独确定合同,而非靠描述符碰巧兜底。

描述符的优先级也有实证:向候选实例 __dict__["state"] 塞入 "shadow",读 candidate.state 仍返回 "succeeded"。这只说明数据描述符在普通属性查找中优先于实例字典;直接修改 _state、使用低层 API 绕过业务入口,依然可能破坏约束。封装不是沙箱。Python 3.12 的描述符指南解释了数据描述符的优先级以及 property 如何使用描述符协议。

元类在哪一步出现

把描述符直接写在类体里,type.__new__ 在创建类时调用它的 __set_name__。实验还用一个极小的 NamedClasses(type) 在 __new__ 里给类补 declared_name;类创建之后运行结果为 MetaclassExample.declared_name == "MetaclassExample",同时类体里的 state 已获得名称。反过来,类创建完才赋上去的 late 描述符不会自动收到 __set_name__,实验需要手工调用才能使它知道自己绑定了哪个名字。语言参考的创建类与自定义元类将这一步限定在类创建过程,不应该用一次赋值成功推断回调必然发生。

元类可在创建很多类时统一调整命名空间、检查声明,但任务状态验证发生在实例赋值时。把已有的一个 setter 改成元类、再由元类注入字段,会增加类创建路径、调试位置与继承约束,并没有消掉第二处真实重复。候选描述符还需要同时照顾锁、存储名、错误信息和继承者;这些工作在现有一个属性上已经被 property 清楚表达了。真正有多类共享同一字段政策、且独立测试证明减少重复时,再讨论提取。当前保持 Task 原样,选修代码只做对照。

重放和练习

本次 completion 证据来自 examples/python-language/evidence/completion/。acceptance/installed-E01_descriptor.stdout 在已安装 wheel 环境中运行,记录三种错误、数据描述符遮蔽实例字典、动态赋值不会自动触发 __set_name__,退出码为 0;additional/runtime-E01-negative.stderr 保留非法状态的当前 traceback。累计回归的最终状态以 examples/python-language/evidence/completion/acceptance/ 中的本轮日志为准;PyPy 的完整边界单独放在 E04,不用作本文的绿色替代。

预测题。 给 Candidate 的实例字典写入 {"state": "shadow"},接着读取 task.state,画出查找顺序;若赋给 task.state 一个 [],原状态会怎样?答案:数据描述符先于实例字典返回 _state;赋值在集合成员判断处抛 TypeError,不会执行后面的 setattr,原状态不变。证据中的 data_descriptor_shadows_dict、本次负例 traceback 与最终状态可核对。

变更题。 给一个已经创建的类动态赋 StateField(),然后第一次调用 obj.late;能否按类体声明的方式工作?答案:没有自动触发 __set_name__,描述符的 storage_name 尚未初始化,必须手动调用 field.__set_name__(Owner, "late") 或改在类体声明;实验 late_set_name_automatic=false,并断言手动调用后名字正确。若改造主线,不只要看到读取成功,还必须重放拒绝非法值、并发读写与业务状态迁移断言。

取舍可以复用到其他任务字段:先数真正重复的政策,再比较新机制带来的创建时、访问时与错误路径成本。一个 property 已经把单字段意图说清楚,就没有为它单独写元类的收益。

参考资料

  • Python 3.12 Descriptor HowTo Guide:数据描述符、property 与 __set_name__。
  • Python 3.12 Data Model:Creating the class object、Metaclasses:类创建及元类行为;页面分支不是本机补丁版本。
  • examples/python-language/src/tasklab/model.py、examples/python-language/experiments/E01_descriptor.py、examples/python-language/evidence/completion/acceptance/installed-E01_descriptor.stdout 和 examples/python-language/evidence/completion/additional/runtime-E01-negative.stderr:任务合同、对照输入及当前运行记录。