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

资讯详情

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

类型与对象:掌握面向对象编程中的Essential Operations

类型与对象:掌握面向对象编程中的Essential Operations 1. 阶段三讲义为什么我会把“类型与对象”单独抽出来讲这份讲义是我带第三阶段培训时反复打磨过的一版内容核心就两个关键词类型type和对象object。很多同学在完成基础语法学习后能写循环、能写函数但一进入项目就难受因为项目里的代码不是靠“从第一行往下执行”看懂的而是靠一堆类型、对象和对象之间的操作串起来的。阶段三要把视角从“我怎么写出这段逻辑”切换到“我该怎么组织这些数据和行为”而组织的基本单位正是类和对象。我带的班级里到了这个阶段最典型的分水岭是这样的一批同学还在用全局变量传数据写出来的代码所有函数都在互相依赖改一个参数要连带改七八处另一批同学已经开始用类把数据和行为收拢在一起新增需求时只需要在对应类里加方法。差距不是智商而是对类型和对象的体感。这份讲义就是用来补这个体感的它不讲高深的框架原理只讲清楚三件事类型到底约束了什么、怎么根据类型做安全操作、以及如何用Classes把数据和操作组织成可复用的对象。所以这份讲义的受众很明确已经学完语法、正准备做模块化开发的读者。你可以是刚入行的开发新人也可以是想回来把基础补扎实的从业者。看完之后你会发现所谓“Essential Operations”并不是某种高深设计模式而是对象在真实代码里每天都要做的那几件基本动作比如创建、访问、比较、文本化和释放。把这些动作做好后面的设计模式、架构分层才有讨论基础。2. 类型系统任何语言都绕不开的那张“解释说明书”2.1 从内存看类型变量名只是一个入口我上课时喜欢先抛一个问题同样是内存里的八位二进制“01000001”它到底是字符“A”还是数字65答案是取决于你把它当作什么类型来用。这就是类型的本质——它不是给编译器找茬用的而是告诉语言运行时应该用什么样的规则去解释一段二进制数据。没有类型这个概念程序根本不知道该对数据做加法还是做拼接。在这个基础上还得把“值类型”和“引用类型”的差异讲透。像Java、C#、Python里的字符串、整数这类基础类型经常是直接把值复制给变量而对象类型变量里保存的是对象的引用类似门牌号真正的内容在堆内存里。这个差异会导致一个非常经典的坑你以为自己复制了一个对象改的是副本结果两个变量指向同一个对象一改全改。我让学员做过一个小实验把同一个对象放进多个列表从其中一个列表里修改字段另一个列表里看数据也变了很多人的第一反应是“见鬼了”。这不是灵异事件是引用被共享了。想要判断自己当前用的是值还是引用可以在代码里打印变量的内存地址或者id值再对比另一个变量。Python里可以用id()Java里可以用System.identityHashCode()C里可以直接打印指针地址。记住一个判断标准如果两个变量指向同一个地址那它们就是“同一个人”只是叫了两个名字。2.2 类型转换的正确姿势和翻车现场类型转换是日常开发里最频繁的操作之一也最容易无声地出错。最怕的不是显式转换因为显式转换至少写代码的人有意识最怕的是语言自动帮你做了隐式转换转换结果还和你的预期不一样。举一个经典例子JavaScript里“1” 2 的结果是字符串“12”而不是数学上的3再比如Python里 int(“123”) 能正常返回123但 int(“12a”) 会直接抛异常。糟糕的是这两种情况在真实项目里都出现过前者是表单数字被拼进了字符串后者是外部接口返回了一个带特殊字符的编号。我在讲义里定了一条规矩跨类型操作前先想清楚目标类型是什么别让语言替你“猜”。尤其在写接口、写数据导入逻辑时入口处就要做类型校验和统一转换。比如Python里写一个安全转int的函数套一层try-exceptJava里用工具类统一转换并定义好转换失败时的默认值和日志SQL和存储过程里字符转数值更要小心空字符串和NULL转换前先判断。这样可以避免一大类“时好时坏”的线上问题。转换还有一个隐含陷阱精度损失。把Long转成Integer如果数值超出范围结果会变成一个奇怪的负数这类问题在对接第三方接口时特别常见把浮点数转成整型小数部分会被截断Java的(int)1.9结果是1而不是四舍五入后的2。所以涉及金额、积分、费率这类数据尽量不要用浮点数做计算优先用高精度类型或字符串传递否则很容易出现“订单对不上账”的尴尬局面。2.3 基础类型选择bool、char、long、枚举这些细节别踩坑基础类型看起来简单但选择不当会直接拉低代码可读性。布尔类型就是为二值逻辑准备的true/false不要在业务里用0、1、-1表达三种状态更不要把方法返回值设计成“1代表成功0代表失败-1代表参数错误”调用方根本记不住。如果确实有多个状态应该用枚举或常量加注释。char类型有个特殊用法是表示较小的整数因为它在底层就是一个整数类型。技术上可行但工程上不推荐把char当数字用比如用字符变量存年龄你的代码读起来会非常痛苦还要靠ASCII码换算。相比之下直接用int、long更直白。long类型本身也有问题比如两个long相加如果结果超过Long_MAX会静默溢出成一个负数且没有异常提示。我自己在积分系统和计数器场景里踩过这个坑最后排查了半天才发现是累加器溢出了。处理办法很简单对可能有上限的累计值提前用更大范围类型或者加一个超过阈值就报警的判断。枚举类型则是把“魔法值”从代码里赶走的好工具。一个状态字段如果直接用字符串“1”、“2”表示写判断的时候很容易拼错拼错了也不会报编译错误只能等运行时报错换成枚举后逻辑判断里写的是OrderStatus.PAID多写一个字母IDE立刻报错。这就是把钱花在编译期的好处。3. Classes把类型升级成对象模板这一步怎么跨过去3.1 类与实例先有菜谱再有菜类Class和对象Object的关系我最常用的类比是菜谱和菜。菜谱定义了需要哪些材料、按什么顺序处理材料、最后出什么成品类也是在定义对象的结构和行为它说明一个对象有哪些属性、能做哪些操作。而对象就是照着菜谱做出来的那道真实的菜。一道菜可以端上桌但菜谱本身不能吃——同理类本身不能直接参与业务计算必须先创建出一个实例。创建实例的过程在不同的语言里有略微不同的叫法但本质都一样分配内存、初始化状态。Java和C里用newPython里直接调用类名JavaScript里也要用new或类工厂函数。很多初学者才会写类的时候容易把“类里定义的东西”误认为“对象上已经有的值”比如在类里写了count 0但每次创建实例时count都是0想让每个对象都不一样必须在构造函数里做初始化。创建过程中特别有意思的一个问题是为什么JavaScript里“new”还没执行完对象上的方法却已经能用了因为方法并不存放在每个对象里它存在于类的原型链prototype上对象内部只是持有一个指向原型的引用。所以只要对象引用一产生顺着原型链就能找到方法不需要等构造函数把所有字段都填完。这在很多语言里是类似的设计——方法表常驻内存对象只记录自己的数据。理解这一点就不会拿“对象字段还没初始化就去调方法”这种问题来折磨自己了。3.2 Essential Operations一个对象必须掌握的“标配技能”所谓Essential Operations指的不是某个业务特有操作而是几乎每个对象在真实代码里都躲不掉的基本动作。我在讲义里总结成五件事创建、取值、比较、文本化、释放。别看只有五件事没做好的话后面所有涉及集合、缓存、日志、持久化的代码都会变得很难受。拿学员最熟悉的订单为例。订单对象创建的时候要接收用户ID、商品ID和金额这叫构造订单状态被改之后需要提供getStatus()、setStatus()这样的入口这叫取值和设置运营想看某个用户是否购买过某个课程就需要定义“两个订单对象在什么情况下算相等”是订单号相同就算还是用户ID和商品ID都相同才算这属于比较。订单要打印到日志里、要存进数据库、要返回给前端就需要定义它的文本表示而不是直接输出对象地址。最后订单被取消或者过期如果没有自定义释放逻辑至少也要让不再使用的对象不再被别处引用这叫释放。我建议上课时用一个非常小的类来演示这五个操作。比如下面这个学生类class Student: def __init__(self, sid: str, name: str, score: int 0): self.sid sid self.name name self.score score def is_pass(self) - bool: return self.score 60 def __eq__(self, other): return isinstance(other, Student) and self.sid other.sid def __hash__(self): return hash(self.sid) def __repr__(self): return fStudent({self.sid}, {self.name}, {self.score})这里__init__对应创建is_pass对应业务操作__eq__和__hash__对应比较__repr__对应文本化。一个类只要把这些基本动作补齐用起来就会顺手很多放进集合、打印日志、按业务编号查重都顺理成章。比较这块特别容易犯错因为“相等”其实有三层含义第一层是引用是否同一个对象第二层是对象的所有字段是否都相同第三层是业务意义上是否代表同一个东西。比如两个学生的姓名和成绩都不同但学号相同那业务上就是同一个学生。如果只靠默认的对象地址比较去重、合并、缓存都会失灵。所以必须想清楚你的业务规则然后重写相等判断和哈希方法。3.3 对象的生命周期创建、使用、释放对象的生命周期管理是很多新人直到线上出故障才重视的环节。在一个进程里对象从创建出来开始占用内存到不再被任何人引用时才能被垃圾回收或者手动释放。Java和Python有垃圾回收机制看起来省心但仍会出问题一个不再需要的大对象因为被某个静态集合一直引用内存就永远释放不掉运行几天后OOM。C没有自动回收需要自己管理但现代C推荐使用unique_ptr、shared_ptr这类智能指针来自动释放能不用裸指针就不用。有个C学员遇到过一个问题用unique_ptr生成动态char数组后能不能转成char*类型使用答案是可以通过get()方法拿到裸指针但要注意unique_ptr生命周期结束时这个裸指针会随之失效如果外部还存着指针就会变成悬空指针。正确做法是确保使用指针的范围内unique_ptr仍然存活或者干脆把数据拷贝出来。这类问题看起来是语法问题深层次其实是生命周期意识还不够清晰。对Java/Python开发来说我觉得更实用的建议是别在不需要的地方长期持有引用。比如把大对象存放在一个“全局缓存”里但这个缓存根本没有失效策略那和内存泄漏也没什么区别。写代码时养成习惯这个对象用到哪一步就不需要了有意识地缩小作用域。在阶段三阶段不需要掌握多么高深的JVM调优只要记住“对象是有生命的别死死抱着不放”就已经很厉害了。4. 实操用类重构成订单模块体验“Essential Operations”4.1 原始写法过程式堆叠的问题每次讲到这里我都会用一个线上课程订单的案例。最初的写法是这样的course_price 199.0 discount 0.8 user_score 50 if user_score 40: final_price course_price * discount else: final_price course_price print(最终价格:, final_price)这段代码能跑逻辑也简单。但它的坏处在新增需求时立刻暴露没过几天产品说老用户可以有优惠券白金会员再打九折限时活动期间满300减50。你会发现所有逻辑都必须堆在main函数里或者靠层层if-else往下传参数改一个规则就要在好几个地方同步修改。用户信息和课程信息也没有做边界隔离user_score可以被任何函数随手改掉。这种“过程式堆叠”在练习阶段没什么问题一旦代码超过两三百行就会让人很崩溃。这也是为什么阶段三要引入类——不是赶时髦而是为了给不断膨胀的业务逻辑一个分格间。4.2 用类重构定义Course、User、Order我把同一段逻辑用类拆开class Course: def __init__(self, name: str, base_price: float): self.name name self.base_price base_price def price(self) - float: return self.base_price class User: def __init__(self, name: str, score: int): self.name name self.score score def eligible_for_discount(self) - bool: return self.score 40 class Order: def __init__(self, user: User, course: Course, discount: float 1.0): self.user user self.course course self.discount discount def final_price(self) - float: if self.user.eligible_for_discount(): return round(self.course.price() * self.discount, 2) return round(self.course.price(), 2) def __repr__(self): return fOrder(user{self.user.name}, course{self.course.name}, price{self.final_price()}) if __name__ __main__: alice User(Alice, 50) python_course Course(Python进阶, 199.0) order Order(alice, python_course, 0.8) print(order)这个重构好在哪里第一Course只负责课程本身的价格User只负责用户自己的属性以及有没有资格参与优惠Order只负责把用户和课程组合起来计算最终成交价格。三个类各管各的后续如果要加“优惠券”、“会员等级”直接在对应类里加字段和方法就可以了Order里的计算逻辑不会被改得面目全非。从类型用语角度讲Order这个对象里持有的是User类型和Course类型的引用而不是散落一地的变量这让代码的“语义密度”高了很多。读代码的人看到Order(user, course)这一行就知道这个订单是“哪个用户买哪个课程”非常直观。4.3 补全Essential Operations并验证上一步重构出来的类已经能跑但还不够完整。我把“基本操作”又补齐了一遍给Order加上“是否为同一订单”的判定约定同一个用户买同一个课程就算重复订单加上__eq__和__hash__方便后续去重和放入集合再补充一个表达清楚的方法方便在日志里直接打印。class Order: def __init__(self, user: User, course: Course, discount: float 1.0, order_id: str ): self.user user self.course course self.discount discount self.order_id order_id def __eq__(self, other): return isinstance(other, Order) and self.order_id other.order_id def __hash__(self): return hash(self.order_id) def __repr__(self): return fOrder(id{self.order_id}, user{self.user.name}, course{self.course.name}, price{self.final_price()})验证代码也很简单创建两个相同order_id的订单放进一个set看是不是只剩一个。这就能直接检验__hash__和__eq__写得对不对。我在课堂上会故意让学员先不重写这两个方法然后看到set里出现两个长得一模一样的订单再让他们自己总结原因。你一旦经历过“看起来一样但程序认为不一样”的困惑就会真正理解为什么要显式定义业务上的相等规则。再往后把Order对象转成JSON返回给前端、写入数据库都是“文本化”这个基本操作的自然延伸。很多框架里只要定义好对象的字段序列化就能自动完成但是手动拼JSON时没有稳定的to_dict或者__dict__风格的方法早晚会出错。所以请把这个习惯养成每个核心类都预留一个“变成可打印/可传输结构”的方法这不是多余。5. 常见问题与排查技巧实录5.1 高频问题速查表下面这些场景是阶段三同学在实际作业和项目里最容易碰到的问题。我把现象、本质和处理建议整理成了一个表排查时可以按图索骥。现象本质原因处理建议修改一个对象其他地方的数据也跟着变变量共享了同一个引用没有做深拷贝确认数据是否是独立副本必要时用深拷贝创建新对象对象数组去重结果不对没有重写equals和hashCode默认按引用地址判重定义业务主键重写相等判断和哈希方法比较两个对象一直返回false只比较了引用而不是业务值检查对象所在语言按字段或业务键重写比较逻辑对空对象调用方法报“表达式必须包含类类型”对象为null或变量类型声明与实际不符先判空再做类型强转或校验long类型相加数值异常溢出或与int混用造成截断明确所有运算数类型必要时使用高精度类型两个unsigned long相除结果是错的整型除法会把小数部分直接截断先把操作数转换成浮点型再相除再按需取整Vue里给对象赋了新属性页面不更新新增属性不是响应式的用Vue.set或重新赋值整个对象确保响应式系统能感知某个deprecated的版本类还在被使用依赖了旧的版本比较API优先切换到官方推荐的packaging.version实现我用“对象数组去重”这个例子再多说两句。很多人以为去重就是把元素塞进HashSet就行但HashSet判断重复时先看hashCode是否一致再看equals是否相等。如果你定义的对象没有重写这两个方法每个对象的hashCode都不一样即使字段值完全一样也会被当成不同对象。所以凡是需要放进Set、用HashMap做键的业务对象必须自己把hashCode和equals一起维护好。5.2 排查思路先确认内存再确认业务逻辑面对对象相关的bug我总结了一套排查顺序可以有效减少瞎猜的时间。第一步先搞清楚问题到底是“引用问题”还是“业务逻辑问题”。打印一下相关对象的内存地址或id看看操作前后是不是同一个对象如果地址变了那大概率是对象被重建了数据没带过来。如果地址没变但字段不对那就是业务逻辑在别处改了它。第二步判断是不是“比较规则”的问题。当一个对象放进Set却出现重复时不要先去翻SDK文档直接看一眼这个类的equals和hashCode是怎么实现的。很多时候它们根本没有被重写或者只比较了其中一个字段而业务上需要比较两个字段。第三步再往对象生命周期方向排查看是不是有人把一个本该局部使用的对象存到了全局变量里面。我记得有个学员调试了很久的“订单并发状态错乱”问题最后发现是所有线程共享了同一个Order对象一个订单被改状态时其他订单也变了。听到结论时他自己都很震惊明明每次都new了一个Order但是订单内部引用的User是从同一个缓存里取出来的。这就是引用共享带来的连锁反应。排这种问题打印内存地址是非常有效的。6. 分享一个判断对象设计是否合理的土办法课程最后我会让学员用一个非常朴素的标准来评估自己写出来的类能不能用一句简单的话说清楚“谁在给谁发消息”。如果User调用OrderOrder又调用User反过来再调回去你只能说“用户下了订单、订单又改变用户状态”这其实也能接受。但如果一句话里出现了三个“的”比如“这个会员等级变更通知服务需要获取用户最新订单的优惠券的适用规则”说明类已经拆得不够干净了应该考虑把“会员等级变更通知”和“优惠券规则”拆成独立对象。还有一个我常提醒学员的做法把类里对外暴露的方法名挨个读一遍读起来像一份说明书的是好类读起来像一篇散文的就要考虑是不是职责不清。比如一个类有createOrder()、getOrder()、cancelOrder()、changePassword()、sendEmail()很明显最后两个方法不属于订单类。“下单、查单、取消”才是一个订单类该管的事情发邮件应该放到通知类里。最后再分享一个我上课时的经验阶段三学到这里最怕的其实不是理解不了类而是“觉得自己已经懂了”然后跳过练习。类型和对象不是靠看会的是靠写会的。试着把一个已经写好的过程式小工具强制改成三个以上相互协作的类再补上构造、比较、文本化这些基本操作跑通一遍你就能理解这份讲义讲的东西比背十遍概念都管用。等你发现自己可以在两百行代码里自然而然地组织出三五个类型协作时恭喜阶段三可以顺利毕业了。
返回列表