ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用真实爬虫项目学会Python面向对象编程:从类设计到继承封装实战

用真实爬虫项目学会Python面向对象编程:从类设计到继承封装实战 说个很真实的场景你自学Python三个月函数、列表、字典都玩得挺溜看教程做习题没问题可一旦打开别人写的项目源码满屏的class、self、definit瞬间就懵了。这几乎是每个Python入门者都会撞上的墙。我当年也是这样——函数我懂字典我懂可这些类是怎么组合到一起的为什么要绕这么多层等到我自己在公司代码库里摸爬滚打几年之后才明白面向对象编程不是一种花哨语法而是一种组织代码的方式。如果你已经开始接触Python的类与对象但总觉得“会写class但不会设计class”这期内容就是为你准备的。这期是面向对象编程实践篇重点不是再讲一遍“什么是类”而是讲清楚三个层面怎么把业务需求拆成类怎么用继承和多态让代码变得好维护以及实战中会踩到哪些文档里不写的坑。为了让内容落地我会用一个真实的爬虫小项目做演进演示从一团函数改造成清晰的OOP架构。整个过程有完整代码、有设计思路、有避坑记录学完你可以直接拿这套思路去重构自己的脚本。1. 从函数堆砌到类的组织OOP到底解决了什么问题1.1 我经历过的“散装代码”阶段先讲讲我自己走过的弯路。刚写Python那阵子我的代码风格非常朴素——拿到需求就写函数一个脚本从第一行一路写到最后一个return。一个数据抓取脚本大概是这样的开头定义几个全局变量中间是request_get、parse_html、save_to_file三个函数最后一段for循环依次调用。脚本跑起来没问题可需求一变就完蛋。比如某天产品说“我们的数据源从A网站换到B网站字段也变了。”好我需要改动parse_html函数然后发现A网站特有的字段判断逻辑跟B网站的混在一起改一个函数牵扯出一堆if分支。再比如我想给这个脚本加一个“断点续抓”功能结果发现全局变量满天飞状态全靠几个模块级的列表维护一加功能就担心破坏别的逻辑。那段时间我每天最怕的事情就是改自己的老代码。后来我才意识到问题不是代码写得不够熟练而是组织方式不适合复杂需求。函数式写法适合线性流程可一旦涉及多种实体、多种状态、多种行为函数之间互相引用、共享全局数据复杂度就会指数上升。面向对象编程解决的本质问题就是把你关注的“事物”打包成一个独立的整体数据和操作这个数据的方法待在一起互不污染外部只跟这个整体打交道。1.2 类与对象的核心直觉模板与实例很多人被“类”这个概念吓住其实用生活类比特别简单。类是模具对象是模具倒出来的成品。同一个模具可以倒出无数个成品每个成品都有自己的小差异——这就是一个类可以创建多个实例每个实例有自己独立的属性值。拿用户系统举例。User这个类定义了用户这个事物有哪些数据用户名、邮箱、权限等级以及能做什么事登录、登出、改密码。每来一个真实用户就new一个User实例填上各自的用户名和邮箱。这么做的好处是用户相关的所有代码都聚集在同一个地方不会出现“改用户密码的函数在util.py查用户状态的逻辑又散落在views.py”这种糟糕局面。这种把数据和操作绑在一起的组织方式就是“封装”。封装之后外部代码不需要知道User内部怎么存储密码、怎么校验权限只需要调用user.change_password(new_pwd)就行。这就是为什么你会在工程里看到那么多类的根本原因——它不是炫技是为了让代码的每个部分职责清楚、边界明确。2. 亲手写一个类从设计到实现的完整套路2.1 类的三件套初始化、属性、方法实践面向对象编程第一步不是狂写class而是先学会把一个类写完整。一个正常的Python类通常包含三个部分初始化方法init、属性、方法。我见过太多入门者把三者混为一谈导致类写出来像一锅粥。先看一段典型代码class Order: def __init__(self, order_id, items, customer): self.order_id order_id self.items items self.customer customer self.status pending def total_price(self): return sum(item[price] * item[qty] for item in self.items) def mark_shipped(self): self.status shipped这个类的设计逻辑很清晰__init__负责“造出一个订单”并设置初始状态属性order_id、items、customer、status是这个订单的数据方法total_price和mark_shipped是这个订单能做的事。外部使用的时候先Order(...)创建实例再调用实例方法完成操作数据变化只发生在实例自己身上不会污染其他订单。我见过很多人写类的时候习惯把所有计算逻辑都塞进methods然后属性随便定义这在我看来顺序反了。正确的做法是反过来先想清楚这个类需要维护哪些状态属性再根据状态的变化来设计方法。属性是这个类的“底盘”方法是在底盘上操作的手段。底盘不稳方法再多也是空中楼阁。2.2 self到底是个什么鬼self大概是Python初学者最常问的问题之一。别的语言写thisPython写self而且每个方法定义都要带上它传参数的时候又不用传非常迷惑。一句话讲明白self就是这个实例本身。当你执行order Order(A001, items, 张三)的时候Python默默把order这个实例作为第一个参数传给了__init__所以__init__里self.order_id A001的意思就是“给order这个实例设置一个order_id属性”。同理调用order.total_price()时Python自动把order传进去作为self方法里就能用self.items拿到这个实例自己的数据。理解self之后很多怪现象就解释通了。比如你在方法里写name local那只是局部变量跟实例一点关系没有只有写了self.name local才会成为实例属性。这也是我自己早年间经常踩的坑方法里算着算着忘记加self结果外部永远取不到那个值。2.3 实例变量和类变量用错会出大问题属性也有分类实例变量和类变量不是一回事。实例变量是每个对象自己的类变量是整个类共享的。这个区别在数字、字符串之类的不可变类型上表现不明显但一旦遇到可变类型如列表、字典就非常危险。class Employee: department 研发部 # 类变量所有员工共享 def __init__(self, name): self.name name # 实例变量每个员工独立 e1 Employee(张三) e2 Employee(李四) e2.department 市场部 # 注意这会给e2新建一个实例属性不会改类变量 print(e1.department) # 研发部 print(Employee.department) # 研发部这条行为还算安全。真正危险的是不赋值、直接修改内部内容class Team: members [] # 危险可变类变量 def __init__(self, name): self.name name t1 Team(前端组) t1.members.append(王五) t2 Team(后端组) print(t2.members) # [王五]整个类共享了我在实际项目里见过这种bug导致的生产事故排查了很久。解决办法很简单可变对象一律在__init__里初始化为实例变量比如self.members []。类变量只用来放常量、共享配置之类真正需要共享的东西。3. 继承与多态让代码真正可复用3.1 继承的第一条准则先抽象再复用继承是OOP里看着最爽、用起来最容易出问题的特性。很多初学者恨不得把所有类都放进一条继承链里结果是父类臃肿、子类互相干扰改一处崩一片。我自己实践下来的经验是继承前先做抽象别为了复用而继承。什么叫做“先抽象”就是先从多个相似对象里提炼出公共的东西让父类只保留真正通用的行为和属性子类只做各自特化的部分。举个例子我在写数据采集器的时候需要同时抓取几个不同来源的数据。它们的第一步都是构建请求、发出请求、检查响应这部分高度一致适合放进基类而解析响应的逻辑每个来源都不一样就应该作为抽象方法留在子类里实现。class BaseFetcher: def __init__(self, url, timeout10): self.url url self.timeout timeout self.session self._build_session() def _build_session(self): import requests s requests.Session() s.headers.update({User-Agent: Mozilla/5.0}) return s def fetch(self): resp self.session.get(self.url, timeoutself.timeout) resp.raise_for_status() return self.parse(resp.text) def parse(self, html): raise NotImplementedError(子类必须实现parse方法)仔细看这个基类fetch流程是固定的子类只需要实现parse。这样做的价值是将来新增第三个数据源我只要再写一个子类继承BaseFetcher实现parse就行请求逻辑、异常处理、会话管理这些完全不用重写。3.2 super()与协作式多重继承子类里写__init__的时候千万别忘了把父类的初始化逻辑接上。Python提供了super()来做这件事。这个函数我多解释一句因为理解它的人真的不多。class JsonFetcher(BaseFetcher): def __init__(self, url, timeout10, encodingutf-8): super().__init__(url, timeout) self.encoding encoding def parse(self, text): import json return json.loads(text)子类的__init__先通过super().__init__把自己的url和timeout传给父类让父类把公共部分初始化好然后再设置子类自己的encoding。很多初学者会忘记这一步导致父类的__init__没执行session为None后面调用就报错。这不是语法问题是设计习惯问题——子类应当把自己当成父类的一种特化而不是完全独立的个体。至于多重继承我的建议很简单能少用就少用。Python的MRO方法解析顺序虽然规则清楚但多重继承一旦超过两层可读性急剧下降。现实业务里的协作式多重继承我在公司代码里见到的成功案例屈指可数。相比而言把公共部分拆出来作为模块、用组合的方式去调用往往更方便排查问题。3.3 多态与鸭子类型不写接口也能统一调用多态这个词听着学术其实就是一句话不同类的对象如果拥有相同的方法名调用方可以用同样的方式使用它们而不需要关心具体类型。爬虫例子里JsonFetcher.parse和HtmlFetcher.parse的实现完全不同但只要它们都叫parsefetch流程里一行parse(text)就能通吃。Python里更极致的多态是鸭子类型只要会叫管它是不是鸭子。我不需要让所有抓取器都继承同一个基类只要它们都实现了fetch和parse方法在循环里就可以统一调用fetchers [ JsonFetcher(https://api.example.com/data), HtmlFetcher(https://example.com/page), ] for fetcher in fetchers: data fetcher.fetch() save_data(data)这种写法的好处是极其灵活新加一个采集源不用改循环逻辑。缺点也很明显缺少类型约束传进来一个没有parse方法的对象就会在运行时才报错。工程上通常的做法是用abc模块里的ABCMeta和abstractmethod给基类加一道保险子类忘记实现parse一实例化就报错而不是等到运行fetch才痛苦排查。4. 封装的艺术把内部细节藏起来4.1 私有属性下划线不是摆设Python没有真正的private关键词但下划线约定真的是很重要的工程默契。一个下划线开头的属性或方法比如_parse_result表示“内部使用的外部别碰”。这不是强制规则但团队协作中遵守这个约定代码边界就会清晰很多。两个下划线开头的比如__secret会触发名字重整name manglingPython在类定义时自动把属性名改成_ClassName__secret。很多教程拿这个讲“私有”但说实话两个下划线更适合用于避免子类意外的属性覆盖而不是为了实现严格的访问控制。我自己写类时更常用单下划线来表示“内部细节”只有在确实要防止子类误重名时才用双下划线。class Account: def __init__(self, owner, balance): self.owner owner self._balance balance def deposit(self, amount): if amount 0: raise ValueError(存款金额必须大于0) self._balance amount def withdraw(self, amount): if amount self._balance: raise ValueError(余额不足) self._balance - amount外部如果想改balance只能通过deposit和withdraw而这俩方法内部校验了规则。这就是封装的意义不是禁止你访问而是给你一个合规的操作入口把非法状态变化挡在外面。4.2 property装饰器像访问属性一样使用方法有时候你希望外部用属性访问的方式去获取一个经过计算的值而不是调用方法。property装饰器就是干这个的。这个特性看似不起眼却能把接口做得很优雅。class Circle: def __init__(self, radius): self.radius radius property def area(self): return 3.14159 * self.radius ** 2 c Circle(5) print(c.area) # 不用写c.area()跟属性一样直接读这个方法非常适合用于“只读、由其他属性计算得来”的值。另外一个常见需求是给属性加校验逻辑可以用setter版本class Temperature: def __init__(self, celsius): self._celsius celsius property def celsius(self): return self._celsius celsius.setter def celsius(self, value): if value -273.15: raise ValueError(温度不能低于绝对零度) self._celsius value t Temperature(25) t.celsius -300 # 抛异常非法值进不来这种写法的价值在于未来如果想在读取或设置时加入日志、缓存或权限校验不用改外部代码只需改property内部逻辑即可。我重构老代码的时候经常用property收益立竿见影。4.3 封装边界什么时候该公开什么时候该隐藏封装不是越严越好我见过太多“过度封装”的代码一个简单工具类内部所有方法都是下划线开头外部只有一个run入口想定制个什么参数都只能靠改源码。这种封装是给自己找麻烦。我自己的经验是三条原则。第一稳定的、外部确定需要的能力公开不确定的、容易变的内部实现细节隐藏。第二通过公开接口读写的属性尽量做成property一旦要加校验还能兜底。第三不要为了“以后可能用”去预封装YAGNI原则照样适用于面向对象设计。等你真正有第二个调用方了再抽公共接口都来得及。5. 魔法方法与数据模型让你的类像原生类型一样好用5.1str__与__repr打印对象的正确姿势我发现很多入门者写类的时候不重写魔法方法导致调试的时候print(instance)只看到一堆main.Order object at 0x7f...挠头半天也看不出这个对象里到底是什么。魔法方法就是Python留给你的钩子重写之后你的对象能像内置类型一样融入语言本身。__str__是为了让人看懂print的时候调用__repr__是为了让开发者看清楚交互式环境直接输入对象名时显示的就是它。两者可以都实现但至少要保证__repr__存在。一个实用的技巧__repr__返回一个能重新创建这个对象的表达式字符串。class Product: def __init__(self, name, price): self.name name self.price price def __repr__(self): return fProduct(name{self.name!r}, price{self.price!r}) def __str__(self): return f{self.name}: ¥{self.price:.2f}有了这俩调试体验直线上升。我在排查数据采集问题时经常直接在一个列表里print一堆Product实例日志里一眼就能看出哪条数据价格不对不需要为每个类写专门的print方法。5.2 比较与排序让对象能放进sorted()默认情况下自定义类的对象不能直接用比较大小排序时也会报错。如果你希望Order可以按订单金额排序、Student可以按成绩排序就需要实现__lt__小于。从Python 3开始排序只需要一个__lt__就够但如果需要别的比较可以让functools.total_ordering帮你补齐。from functools import total_ordering total_ordering class Order: def __init__(self, order_id, amount): self.order_id order_id self.amount amount def __eq__(self, other): return self.amount other.amount def __lt__(self, other): return self.amount other.amount orders [Order(A001, 200), Order(A002, 50), Order(A003, 999)] for o in sorted(orders): print(o.order_id, o.amount)这套在数据分析场景很管用。之前我在做量化策略回测的时候把每笔交易封装成Trade对象有了__lt__和__eq__只要一个sorted(trades)就能按盈亏排序复盘效率高很多。再多说一句实现__eq__时记得判断other的类型不然拿Order跟数字比较时可能把程序搞崩。5.3 上下文管理器enter/__exit__与with语句with open(...) as f是大家熟悉的写法但你也可以让自己的类支持with。只要实现__enter__和__exit__对象就能放进with语句里资源自动清理。这个特性特别适合管理数据库连接、文件句柄、锁资源。class DatabaseConnection: def __enter__(self): print(打开连接) return self def __exit__(self, exc_type, exc_val, exc_tb): print(关闭连接) return False # 返回False表示异常继续抛出 with DatabaseConnection() as db: print(执行查询)__exit__的返回值有个讲究返回True表示吞掉异常返回False表示异常照常抛出。默认建议写False除非你真的有非常明确的理由要吞异常。否则调试的时候异常莫名其妙消失那才是真灾难。除了上述魔法方法call__也值得知道让实例像函数一样被调用适合构建可配置的回调对象。还有__iter/next可以让你的类变成一个可迭代对象在for循环里直接用。这些能力拼在一起你的类在调用者眼里就跟内置类型几乎没差别了。6. 实战案例把爬虫脚本改造成OOP架构6.1 原始脚本的样子与痛点前面讲了这么多理论现在用完整案例串一遍。假设我们要做一个简单的数据采集程序从两个不同风格的网站抓取商品信息最后汇总导出。大多数入门者的第一版会写成函数堆叠的脚本样式import requests import json BASE_URL_A https://api.shop-a.com/products BASE_URL_B https://api.shop-b.com/items def fetch_a(): resp requests.get(BASE_URL_A, timeout10) data resp.json() result [] for item in data[products]: result.append({ name: item[title], price: item[sale_price], source: A, }) return result def fetch_b(): resp requests.get(BASE_URL_B, timeout10) data resp.json() result [] for item in data[item_list]: result.append({ name: item[name], price: item[price] * 0.9, source: B, }) return result all_items fetch_a() fetch_b() with open(output.json, w) as f: json.dump(all_items, f, ensure_asciiFalse, indent2)这个脚本能跑但痛点很多。每次要加一个站点就要写一个fetch_c然后在列表里再拼一个。请求异常处理重复、超时设置不统一、站点字段差异逻辑散布在各函数里。将来想加代理、加限速、加导出Excel所有函数都要动一遍。这种代码就是典型的“能跑但改不起”。6.2 重构第一步用类封装请求与解析我重构的第一步是先把“数据采集”这个动作抽象成类。一个采集器该有的能力非常清楚发请求、拿响应、解析内容、返回结构化数据。把请求公共逻辑放进基类站点差异留给子类。import requests import json class BaseCollector: def __init__(self, base_url, timeout10): self.base_url base_url self.timeout timeout self.session requests.Session() self.session.headers.update({User-Agent: Mozilla/5.0 (compatible)}) def fetch(self): resp self.session.get(self.base_url, timeoutself.timeout) resp.raise_for_status() return self.parse(resp.text) def parse(self, text): raise NotImplementedError这个结构的核心是fetch只有一份所有站点共用parse留给子类。现在写第一个子类class ShopACollector(BaseCollector): def parse(self, text): data json.loads(text) result [] for item in data[products]: result.append({ name: item[title], price: item[sale_price], source: A, }) return result再写第二个class ShopBCollector(BaseCollector): def parse(self, text): data json.loads(text) result [] for item in data[item_list]: result.append({ name: item[name], price: round(item[price] * 0.9, 2), source: B, }) return result你发现没有主流程已经非常干净了。想抓哪个站点就实例化哪个Collector。想抓全部就把它们放进一个列表循环调用。加新站点只需新增一个子类现有代码一行都不用改。6.3 重构第二步用组合处理导出逻辑数据抓回来之后往往还要写入Excel。这个需求我这里用组合的方式实现而不是让Collector来继承什么exporter类。组合的意思很直白一个类持有另一个类的实例通过调用方法完成协作而不是往上找共同的父类。class ExcelExporter: def export(self, items, filename): try: import pandas as pd except ImportError: raise RuntimeError(需要安装pandaspip install pandas) df pd.DataFrame(items) df.to_excel(filename, indexFalse) print(f已导出 {len(items)} 条数据到 {filename})组合的实践大概长这样collectors [ ShopACollector(https://api.shop-a.com/products), ShopBCollector(https://api.shop-b.com/items), ] all_items [] for collector in collectors: try: all_items.extend(collector.fetch()) except Exception as e: print(f采集失败{collector.base_url}错误{e}) ExcelExporter().export(all_items, products.xlsx)注意这里我做了异常隔离单个站点采集失败不会中断整个任务。这在真实场景里非常重要——外部接口总会出各种幺蛾子一个站点挂了不能让其他站点的数据全部流失。如果当初的代码是全函数堆在一个脚本里要实现这种隔离就得往每个函数里塞try/except而现在的结构天然就支持。6.4 演进后对比为什么这才是可维护的代码改造前后的功能几乎一样但代码结构完全不同了。旧版加一个站点要复制一段fetch逻辑改一下解析分支。新版加一个站点只需要写一个子类聚焦在parse方法上。旧版想换导出格式得改整个脚本底部的输出部分新版导出是独立的ExcelExporter换CSV导出自都不用动采集逻辑。这套设计直接呼应了面向对象编程的三个基石封装把采集请求和站点解析隔离在各自的类里继承把公共的请求流程沉淀在基类多态让不同站点可以用同一套循环逻辑统一调用。最关键的是这套结构的可测试性也大大提升。我能单独写一个测试文件实例化ShopACollector传入伪造的HTML文本测试parse再也不用被网络请求卡测试流程。7. 从入门到踩坑OOP实践中的高频问题与避坑清单7.1 可变默认参数隐藏的魔鬼我见过不少Python老手在函数默认参数上翻车类的构造函数也一样。如果你把可变对象当默认参数比如definit(self, items[])那么所有没有传items的实例会共享同一个列表。后面实例往里添加元素之前实例的items也会变。class Cart: def __init__(self, items[]): # 危险写法 self.items items c1 Cart() c1.items.append(apple) c2 Cart() print(c2.items) # [apple]出问题了正确写法是默认用None然后在方法内部新建列表class Cart: def __init__(self, itemsNone): self.items items if items is not None else []这个坑踩过一次就很难忘。我现在写任何构造函数第一反应就是检查默认参数是不是可变类型。7.2 isinstance与type别乱用在面向对象编程里isinstance和type都能用来判断类型但语义完全不同。type(obj) SomeClass是精确匹配isinstance(obj, SomeClass)还会考虑继承关系。绝大多数场景下你想要的其实是isinstance因为既然用了OOP就应当拥抱多态。不过isinstance也有它的隐性问题。如果代码里到处是isinstance(item, SomeClass)这种分支说明你的多态设计没做好——更好的做法是同一类型的对象都用同一种方法调用把差异下沉到各自的类里。用isinstance做分流的正确场景通常是处理外部第三方库的类型而不是自己类体系内部。7.3 继承层级别贪深组合优于继承继承树越深脑子越乱。三层以上的继承已经很难说清楚某个方法到底来自哪个父类。我自己的经验是超过两层继承就要警惕能用组合就用组合。组合的长处是关系清晰A拥有BA调用B的方法而不是A继承B后重写一堆方法。爬虫例子里我看似用了继承但只是一层基类加多个叶子子类属于最安全的继承形态。一旦你发现子类在重写父类时还要依赖父类内部的私有状态就有必要停下来想一下这个继承是不是搞错了方向。7.4 循环导入、属性遮蔽与过度封装三个现实教训循环导入是OOP实践里很常见的问题。你在模块a里定义了类A模块b里定义类BA要引用BB又要引用A结果一运行报出ImportError。解决办法不是硬着头皮调整导入顺序而是把公共依赖下沉到第三个模块或者把其中一个类里的引用改成延迟导入——就是在方法内部再用import。我在重构爬虫框架时就遇到过这个最后是把配置抽到了一个单独的config模块问题才干净地解决。属性遮蔽则是另一个容易忽略的坑。子类定义了和父类同名的属性或方法父类内部调用时却不知道子类已经把它换了。这不算bug但会让调试极为困惑。比如父类有个self._items子类不小心也用了self._items存放别的数据父类方法读的时候就会拿到子类的值。最稳的做法是内部私有属性统一命名规范子类尽量别碰父类的下划线属性需要修改一律通过方法完成。至于过度封装我在4.3已经提过这里再补一句现实观察我评审过很多新人代码问题往往不是封得不够而是封得太死。一个人工智能算法工程师朋友说过一句话我觉得放这里特别合适“设计类的时候多想想未来三个月要改需求的是什么部分给那个部分留呼吸的缝。”我认为这是衡量封装边界最务实的标准。我自己实践OOP这几年最大的体会是没有万能的项目架构只有不断权衡取舍的工程判断。面向对象编程无论是入门还是实践核心其实不在语法在于你能不能把复杂问题拆成一堆边界清晰的小对象让每个对象只操心自己那一亩三分地。按这个标准写出来的代码哪怕逻辑再复杂读起来也会轻松得多。
返回列表