尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python原型模式实战:深拷贝与浅拷贝的正确打开方式

Python原型模式实战:深拷贝与浅拷贝的正确打开方式 有句话说得好写代码写久了最烦的不是“写不出来”而是“同样的东西要我写第二遍”。对象创建这件事尤其典型——你有一个初始化极其昂贵的对象每次重新 new 一个都是一次完整的计算、读配置、连资源可实际业务里你往往只需要在已有对象基础上改一两个字段而已。原型模式Prototype Pattern就是为这个场景量身定制的它不走构造函数不重新跑初始化直接复制一份现成的对象出来改改差异就能用。在 Python 里这个模式的核心就是copy模块但真正落地的时候牵扯到浅拷贝、深拷贝、拷贝钩子、注册表设计坑并不少。这篇文章我就从实际项目的视角把原型模式怎么用、为什么这么用、踩过的坑都有哪些一次讲清楚。1. 原型模式到底解决什么问题1.1 创建对象太贵复制却很便宜咱们先换个视角想一个业务场景你现在要生成一份销售周报对象构造函数里要拉取销售明细、计算同比环比、加载历史基线数据平均每次初始化耗时 2 秒。现在运营一次性要 10 份结构完全相同的周报只是标题、日期、负责人不同。如果你老老实实调用 10 次构造函数20 秒没了用户早就划走页面了。但如果你先创建一份“模板对象”把销售明细、基线数据都算好放进去然后复制它 9 份只替换标题和日期呢一次 2 秒的初始化加上 9 次近乎瞬间的内存拷贝总共也就两三秒。这就是原型模式的核心思路——用复制代替创建。这个思路一点都不玄乎你甚至可以把它理解成“CtrlC / CtrlV”在设计模式里的正规化表达。但直接赋值new_obj old_obj不行那只是复制了一个引用改一个等于改全部。真正要复制的是对象本身Python 里就靠copy模块的copy()和deepcopy()来实现。1.2 对比三种创建方案构造器、序列化、原型有的读者会说我不复制我重新 new 一个不行吗可以但要看看代价。我把这几种方案放一起对比一下方案做法优点缺点构造函数重置每次调用__init__思路简单人人会写初始化开销全量重跑参数漏传难排查构造函数逻辑一变所有调用点跟着改序列化/反序列化json.dumps后再loads或pickle走一遍能拿到完全独立的副本性能差json 不支持复杂对象pickle 有安全性风险绕一大圈只为拷贝不划算原型模式copy.deepcopy(prototype)保留已初始化状态性能高调用方无需知道构造细节需要正确区分深浅拷贝内置类型默认可用自定义又要补__copy__实际项目里凡是我遇到“对象结构复杂、初始化有副作用、但又要频繁生成同构实例”的代码第一反应都是原型模式。它最大的优势不是“能复制”而是复制的时候完全绕过了构造函数省去了所有副作用和重复计算。1.3 什么场景适合用什么场景别硬上我见过不少人学了设计模式就到处套这里把适用边界也说清楚免得你走弯路。适合用原型模式的场景对象初始化开销大比如要查库、调接口、做计算而复制成本远低于初始化成本同一份对象需要生产多个变体比如同一份交易订单模板生成不同币种、不同收款账户的订单系统里对象类型在运行时才能确定工厂类都无法提前写死构造逻辑但现有实例可以当作“种子”来克隆。不适合用原型模式的场景对象本身非常简单一个__init__赋值就结束复制带来的抽象反而增加理解成本对象持有文件句柄、网络连接、数据库连接等外部资源直接复制会导致多个对象共享同一资源关一个全炸对象有全局唯一性要求比如单例或者带 id 自增的实体复制会破坏唯一约束。判断标准就一句话创建成本和后续修改成本哪个高如果复制之后你还要改一大堆字段那多出来的修改成本会把复制的收益吃回去这时候老老实实构造函数反而更清晰。2. 先分清深浅拷贝再谈复制2.1 浅拷贝复制的是外壳不是内核Python 里copy.copy()是浅拷贝copy.deepcopy()是深拷贝这两个函数是原型模式的地基。很多初学者在这里翻车不是不知道这两个函数而是不清楚它们复制到什么程度。看一段最直白的代码import copy origin [[1, 2], [3, 4]] shallow copy.copy(origin) shallow[0].append(99) print(origin) # [[1, 2, 99], [3, 4]] print(shallow) # [[1, 2, 99], [3, 4]]外层列表shallow确实是新对象但列表里的两个子列表仍然是原来的引用。所以你改shallow[0]的内容origin[0]也跟着变。这就是“浅拷贝复制外壳不复制内核”。顺带说一个记忆技巧判断深浅拷贝的影响范围盯着对象里面的“可变子对象”看。如果对象里全是不可变类型int、str、不可变元组浅拷贝和深拷贝效果一样一旦嵌套了 list、dict、自定义对象这些可变类型浅拷贝就埋雷了。2.2 深拷贝递归复制独立到不再共享任何可变引用再来看深拷贝import copy origin [[1, 2], [3, 4]] deep copy.deepcopy(origin) deep[0].append(99) print(origin) # [[1, 2], [3, 4]] print(deep) # [[1, 2, 99], [3, 4]]深拷贝会沿着引用关系递归下去把每一层可变对象都复制成新对象。结果就是deep和origin完全独立改谁都不影响对方。做原型克隆的时候如果不确定嵌套结构有多深默认用 deepcopy 是最保险的。我在实际项目里统计过因为拷贝引发脏数据的 bug八成都是该用深拷贝结果用了浅拷贝。剩下的两成才是自定义复制逻辑写错。2.3 想控制拷贝行为实现__copy__和__deepcopy__有些对象不能直接交给默认拷贝机制比如对象里含有一个线程锁、一个连接池对象。你希望锁和连接池是全局共享的但业务数据要每份独立。这时候就得自定义拷贝行为。import copy import threading class Report: def __init__(self, title: str, data: list): self.title title self.data data self._lock threading.Lock() def __copy__(self): 浅拷贝时只复制业务字段不复制锁 new type(self)(self.title, self.data) return new def __deepcopy__(self, memo): 深拷贝时业务字段深复制锁仍然共享 new type(self)( copy.deepcopy(self.title, memo), copy.deepcopy(self.data, memo), ) memo[id(self)] new return new这里有个很多人忽略的细节实现__deepcopy__的时候一定要处理memo参数。memo是一个字典deepcopy内部靠它记录哪些对象已经复制过了目的是应对循环引用。如果你忽略memo当对象 A 里面引用 BB 里面又引用 A 的时候会陷入无限递归直接RecursionError。所以自定义深拷贝时记得在创建完新对象后往memo里登记一下memo[id(self)] new。3. 手写一个原型工厂的完整过程3.1 场景建模一个订阅通知对象纸上谈兵没意思咱们造一个真实一点的工程场景。假设你在做一个订阅通知系统每个订阅者会收到一份“订阅摘要”里面包含用户的基本信息、订阅的频道列表、每天的推送统计。这个对象的构造函数要做这几件事从用户服务查用户详情的接口网络 IO耗时约 300ms从订阅库拉出最近 30 天的推送统计数据库查询耗时约 200ms根据用户等级计算摘要里的展示优先级CPU 计算耗时约 50ms。总耗时虽然不到 1 秒但假如运营要一键给 100 个同类用户生成摘要100 秒就出去了。更关键的是同一批用户往往是同一套餐、同一权限等级很多中间计算结果完全一样。这时候用原型模式先把第一份摘要算好后面全部克隆再替换用户 id 和昵称效率提升非常明显。代码大体长这样class SubscriptionDigest: def __init__(self, user_id: int, nickname: str, channels: list, stats: dict): self.user_id user_id self.nickname nickname self.channels channels self.stats stats def clone(self, **kwargs): new copy.deepcopy(self) for key, value in kwargs.items(): setattr(new, key, value) return newclone方法里的**kwargs是参数化克隆的写法深拷贝之后直接覆盖差异字段一步到位。3.2 原型注册表让克隆变成“查字典”如果只是单个对象复制上面那段代码足够了。但真实项目里往往有几十种模板你总不能今天记一个变量明天再记一个变量。更规范的做法是搞一个原型注册表把所有模板对象登记在一个字典里需要哪个就把哪个克隆一份出去。class PrototypeRegistry: def __init__(self): self._prototypes {} def register(self, name: str, prototype: object): self._prototypes[name] prototype def unregister(self, name: str): del self._prototypes[name] def create_clone(self, name: str, /, **kwargs): prototype self._prototypes.get(name) if prototype is None: raise KeyError(fPrototype {name} not registered) clone copy.deepcopy(prototype) for key, value in kwargs.items(): setattr(clone, key, value) return clone用起来是这样registry PrototypeRegistry() base_digest SubscriptionDigest( user_id0, nickname默认用户, channels[科技, 财经], stats{views: 0, likes: 0}, ) registry.register(default_digest, base_digest) # 一键克隆只改差异字段 user_1 registry.create_clone(default_digest, user_id1001, nickname小明) user_2 registry.create_clone(default_digest, user_id1002, nickname小红)这里有一个设计要点注册表返回的一定是克隆对象而不是模板对象本身。很多人会犯的错误是图省事直接把self._prototypes[name]返回出去。一旦调用方改了返回值模板就被污染了下一次所有人拿到的都是改过的版本。这种 bug 藏得很深往往上线一两周才被线上数据不对劲暴露出来。3.3 参数化克隆为什么比“复制后再改”更稳从代码上看参数化克隆就是deepcopy之后加一层setattr但这层封装带来的收益不小。首先它把“克隆 修改”这个动作收敛成一个方法调用方不需要知道类内部有哪些字段。其次它强制你明确说出要覆盖哪些字段避免复制完忘改业务 id 这种低级错误。最后它天然契合了“模板模式”的思路——模板负责提供默认值参数负责注入差异化。不过要注意**kwargs这种方式不校验字段名你写错了一个字段名比如user_iid它不会报错而是动态给对象加了一个新属性然后原字段还是默认值。最后线上数据显示异常排查半天才意识到是拼写问题。想稳妥一点可以在clone里加一层校验def clone(self, **kwargs): allowed_fields {user_id, nickname, channels, stats} unknown set(kwargs) - allowed_fields if unknown: raise TypeError(fUnknown fields: {unknown}) new copy.deepcopy(self) for key, value in kwargs.items(): setattr(new, key, value) return new宁可开发阶段多报几个错也不要把拼写错误带到线上。3.4 嵌套对象深拷贝的完整验证前面例子里的channels是列表stats是字典都属于可变对象。如果克隆的时候用浅拷贝模板的channels和stats就会被所有克隆对象共享。举个典型现象运营把某个订阅者的stats[views]改成 999结果模板和其他所有用户摘要的浏览量全变成 999整个页面数据全乱套。所以这类嵌套结构一律用deepcopy。验证方法也很简单写完克隆逻辑后检查内外层对象的id是否不同import copy clone registry.create_clone(default_digest, user_id1001, nickname小明) print(id(base_digest.stats) id(clone.stats)) # False说明 stats 已独立 print(base_digest.stats clone.stats) # True内容相等但对象不同注意区分两个概念比较的是内容is和id()比较的是同一性。原型克隆要的是“内容相同、对象独立”所以为 True、id不同的组合才是正确结果。4. 实战中坑最多的四个场景4.1 浅拷贝导致数据串味调试半天发现是复制错了症状创建了多个克隆对象修改其中一个对象的列表字段其他对象的同名字段也变了。原因用了copy.copy而不是copy.deepcopy嵌套的可变子对象还是同一个引用。排查思路先看字段类型是不是 list、dict、set 这类可变容器打印字段的id()确认是否共享引用全局搜一下是不是把copy.deepcopy写成了copy.copy。教训只要不确定对象内部有没有深层嵌套的可变结构就直接用deepcopy。性能差一点可以接受数据串味是事故级别的。4.2 注册表 get 方法返回了模板本身症状第一次调用克隆方法后修改返回对象第二次再调用发现模板已经不是原始状态了。原因get直接返回了self._prototypes[name]没有走deepcopy。排查思路断点打在get返回处看返回对象的id和模板的id是否一致。一致就是返回了模板本身。教训注册表的设计准则就一条——模板只进不出。任何对外的读取入口都必须返回副本。这是整个原型模式在工程里最容易被破坏的约束。4.3 自定义__deepcopy__没处理 memo循环引用直接炸症状对象内部存在 A 引用 B、B 引用 A 的循环结构克隆时抛出RecursionError: maximum recursion depth exceeded。原因自己实现了__deepcopy__但忘了往memo里登记新对象导致deepcopy不知道该对象已经在处理中继续递归展开。解决方式自定义深拷贝的标准骨架就三件事——创建新对象、深拷贝业务字段、memo[id(self)] new。def __deepcopy__(self, memo): new type(self)( copy.deepcopy(self.field_a, memo), copy.deepcopy(self.field_b, memo), ) memo[id(self)] new return new4.4 全量深拷贝性能扛不住只复制可变部分症状对象里大部分字段是只读配置只有一两个字段需要独立。每次全量deepcopy耗时太长批量克隆时有明显卡顿。解决思路没必要所有字段都深拷贝。保留一个共享的只读对象克隆时只深拷贝可变业务字段。def clone(self, **kwargs): # 只深拷贝需要独立的部分 new SubscriptionDigest( user_idself.user_id, nicknameself.nickname, channelscopy.deepcopy(self.channels), # 可变需要独立 statskwargs.get(stats, copy.deepcopy(self.stats)), # 可变需要独立 ) # 只读配置直接共享不复制 new.rules self.rules return new用折中方案既保证数据独立又减少不必要的复制开销。这也是我实践中比较喜欢的方式先写一个简单可用的deepcopy版本性能确实成为瓶颈了再针对热点字段做手动优化。5. 原型模式与其他模式的搭配思路最后聊聊原型模式跟工厂模式、建造者模式配合的实战经验。很多设计模式文章会把它们分开讲但真实项目里它们经常是叠加出现的。原型模式擅长“复制已有实例”但它不擅长“怎么优雅地创建第一份原型”。所以最舒服的组合是用工厂或建造者完成第一份对象的构建把构建结果注册到原型注册表后续批量生产全部走克隆。class SubscriptionDigestFactory: staticmethod def build_default() - SubscriptionDigest: # 这里执行耗时初始化逻辑查用户、拉统计、算优先级 channels [科技, 财经, 健康] stats {views: 0, likes: 0, shares: 0} return SubscriptionDigest(0, 默认用户, channels, stats)工厂负责昂贵的创建原型负责廉价地复制。这个组合在批处理任务、报表生成、配置模板场景里尤其好用。我个人实际开发中的体会是设计模式大部分时候不是“救火用的”而是“让代码结构提前长对”。原型模式最大的价值不是省那几毫秒而是让你在初始化逻辑特别重的时候仍然可以轻松批量生成对象同时不破坏对象内部状态的独立性。每次写克隆逻辑多想一句“这个字段能不能共享”就能少踩很多坑。
返回列表