第一次真正被"类和对象"卡住,是在我照着教程敲完一个学生信息类之后整整两天。教程里那几十行代码我每一行都能读懂,__init__认得,self认得,方法调用也认得,可是轮到我自己写一个"图书借阅"的小练习,手停在编辑器里,不知道第一行该敲什么。那种感觉很怪——知识点拆开看都会,合起来就无从下手。后来我才慢慢想明白,卡住的不是语法,是脑子里没有建立起"数据和操作数据的行为应该住在一起"这个模型。这篇内容就是围绕这件事展开的:把类和对象从语法层面拉回到建模层面,讲清楚类到底在描述什么、对象在内存里长什么样、封装的边界该画在哪里、继承和多态什么时候该用什么时候不该用。适合刚学完基础语法、能写函数但一写类就发懵的读者,也适合写过一些类但总觉得"设计得别扭"的人对照着看。下面所有例子以 Python 为主,关键处会拿 Java、C++ 做对照,因为很多概念在静态语言里反而更容易看清。
1. 为什么初学"类和对象"总会卡在同一道坎上
我观察过不少初学者的学习路径,卡点高度一致:函数和循环学得很顺,一进到类就开始机械模仿教程的写法,写出来的东西能跑,但自己都说不清为什么要这么写。问题不出在勤奋程度上,而出在缺少一次"从散装到成形"的思维转换训练。这一节先把这道坎拆开看。
1.1 散装变量和"有行为的数据"之间差了什么
假设要处理一个学生的成绩,最直觉的写法是用字典:
student = {"name": "A同学", "scores": [88, 92, 79]} average = sum(student["scores"]) / len(student["scores"])这段代码没问题,但它有三个隐患。第一,键名是字符串,写错一个字母不会报错,只会在运行到某一行时突然KeyError;第二,计算平均分的逻辑散落在外面,如果有十个地方要算平均分,就有十份几乎一样的代码;第三,"学生"这个概念没有被固化下来,谁都可以往字典里塞任意键,student["age"] = "二十"也能顺利执行,类型和语义全凭自觉。
类解决的正是这三件事。把name和scores变成属性,键名拼错立刻变成AttributeError;把算平均分的行为变成方法,逻辑只有一份;把scores的写入收拢到一个入口里,可以在里面做校验。换句话说,类不是把函数塞进一个命名空间,而是给一组数据划定了一个有守卫的边界。
我第一次真正理解这一点,是写了一个带校验的add_score方法之后。以前我习惯在业务代码里到处append,改成必须走方法之后,非法分数在入口就被挡住了,后面所有读取这个列表的地方都省掉了防御性判断。这个体验很关键——它让你意识到封装不是形式主义,是在减少整条链路上的心智负担。
1.2 图纸和实物:类、对象、实例这三个词的关系
很多人把"对象"和"实例"当成两个不同的东西,其实在绝大多数语境里它们是同义词,只是视角不同:类(class)是描述,对象(object)/实例(instance)是按这份描述真正造出来的东西。用图纸和实物类比最直观,但类比要继续往下推才有价值。
图纸阶段,你只规定了"这栋楼有几层、每层几个房间、门朝哪开",这些信息在程序里对应类体中的属性和方法定义。造楼阶段,每栋楼都会拿到自己独立的一份房间状态——A 栋 3 楼住着谁,不影响 B 栋 3 楼。放到代码里验证:
class Student: school = "某中学" # 类属性,所有实例共享 def __init__(self, name): self.name = name # 实例属性,每个对象独立 self.scores = [] a = Student("A同学") b = Student("B同学") a.scores.append(90) print(a.scores, b.scores) # [90] [] print(a.school, b.school) # 某中学 某中学 print(id(a), id(b)) # 两个不同的地址a.scores和b.scores是两个不同的列表对象,这一点初学者通常能理解;但school只有一个,存在类对象上,a.school实际是"顺着查找链往上找"找到的。这个差别在下一节会展开,因为它是后面一大半坑的源头。
还有一个容易被忽略的点:类本身也是对象。在 Python 里,Student这个类在定义语句执行完之后,就是一个存在于内存里的对象,你可以把它赋值给变量、放进列表、当参数传。理解这一点之后,type(Student)返回type、装饰器能修改类、元类这些概念才不会显得突兀。C++ 里没有这层,Java 里靠Class对象部分模拟,Python 则是把"类是对象"贯彻得比较彻底的语言,学习时值得专门留意。
2. 类定义的四个组成部分与它们的真实分工
一个类体里通常会出现四类东西:构造相关的方法、实例属性、类属性、各种方法。教程往往一次性铺开,但每一类的职责边界其实差别很大,混着讲就容易糊。这一节按照"对象从创建到销毁"的顺序,把分工讲清楚。
2.1__init__的边界:它负责初始化,不负责生产对象
初学者最常见的误解是把__init__当成构造函数。严格说,生产对象的是__new__,__init__只负责给已经存在的对象装上初始状态。在 Python 里这条链路是:调用Student("A同学")→ 触发Student.__new__(Student)分配内存并返回一个新对象 → 把这个对象和参数一起交给__init__。__init__的返回值必须是None,因为它改的是传进来的那个对象本身,如果返回别的东西,Python 会直接报错。
为什么这个区分重要?因为一旦你需要控制对象的创建过程——比如实现单例、对象池、或者让某个类只能通过特定工厂方法创建——改的就是__new__,而不是__init__。我在带新人的时候遇到过这样的需求:某配置类全进程只允许存在一份。有人第一反应是在__init__里加个标志位判断,但__init__拦不住对象已经被创建的事实,内存里仍然会出现多个实例,只是初始化被跳过。正确做法是在__new__里判断并返回已存在的那个。这个例子很能说明"生产"和"初始化"是两件事。
还有一处实践细节:__init__里尽量只做"赋值和轻量校验",不要在里面发起网络请求、读大文件、连数据库。原因很实际——构造失败时抛出的异常往往夹杂在这次调用的上下文里,排查时很难区分是"对象没造出来"还是"造出来了但配置读失败"。我现在的习惯是把重活放到一个显式的load()或connect()方法里,让对象的诞生过程保持廉价、可预测、可测试。
2.2 实例属性、类属性、方法三者的作用域边界
这三个东西的查找规则值得单独拿出来讲,因为大量"明明赋值了却没生效"的 bug 都源自这里。先看一张对照表:
| 类型 | 定义位置 | 存储位置 | 修改影响范围 | 典型用途 |
|---|---|---|---|---|
| 实例属性 | 方法内self.x = ... | 实例的__dict__ | 仅当前对象 | 对象各自的状态 |
| 类属性 | 类体中直接赋值 | 类的__dict__ | 所有未覆盖该名字的实例 | 常量、共享配置 |
| 实例方法 | 类体中def f(self) | 类的__dict__ | 无(行为共享) | 操作对象状态 |
| 类方法 | @classmethod且首参为cls | 类的__dict__ | 作用于类本身 | 替代构造器、工厂 |
| 静态方法 | @staticmethod | 类的__dict__ | 与类实例无关 | 纯工具函数归置 |
关键在读取和写入的不对称。读取时,a.school先查a.__dict__,找不到再去Student.__dict__查;写入时,a.school = "某附中"不会去改类属性,而是在a.__dict__里新建一个同名条目,从此a用自己的,b和类本身不受影响。这解释了一个经典困惑:
class Counter: total = 0 def add_wrong(self): self.total += 1 # 相当于 self.total = self.total + 1,新建实例属性 def add_right(self): Counter.total += 1 # 显式作用在类上 c1, c2 = Counter(), Counter() c1.add_wrong(); c1.add_wrong() print(Counter.total, c1.total, c2.total) # 0 2 0add_wrong每次执行都会给c1新建一个total,类上的total纹丝不动。这类 bug 在计数、缓存、状态标记场景里特别隐蔽,因为第一次调用看起来"结果是对的"——c1.total确实变成 1 了,直到你要统计全局数量时才发现数字不对。
顺便说清@classmethod和@staticmethod的实际取舍。类方法最实用的场景是提供多个构造入口,比如Student.from_dict(data),比在外部拼参数可读性好得多,而且子类继承时会自动拿到子类本身(因为cls是动态的)。静态方法我一般只在"这个函数逻辑上属于这个类,但完全不碰对象状态"时才用,比如日期解析、单位换算。如果连类都不想碰,那它就该是模块级函数——为了归置而强行塞进类里,反而增加了阅读时的跳转成本。
2.3self到底是什么:一次方法调用的完整链路
self是初学者另一个高频困惑点。它的本质非常简单:实例方法在类里定义时只是普通函数,第一个参数留着放"调用它的那个对象",Python 不会自动帮你填,是绑定机制帮你填的。
class Demo: def greet(self): return f"hi, {self.name}" d = Demo() d.name = "A同学" print(d.greet()) # 等价于 Demo.greet(d) print(Demo.greet(d)) # 显式传,结果一样d.greet这一步并不是直接取到函数。Python 会先看Demo.__dict__["greet"]拿到函数对象,发现它实现了描述符协议(有__get__),于是返回一个绑定方法,把d预先绑到第一个参数上。所以你调用d.greet()时不需要传self。这也是为什么把方法从类里取出来单独存成变量会出问题:
f = d.greet print(f()) # 正常,绑定关系在取出的那一刻就固定了 g = Demo.greet # g() # 报错:缺少位置参数 self理解这条链路之后,一些"魔法"就不神秘了:为什么self名字可以随便改(叫this也行,只是约定俗成用self)、为什么忘记写self会在调用时报参数数量错误、为什么继承时父类方法里self实际上指向子类实例。最后这一点是理解多态的关键前置,第 4 节会展开。
3. 我在练习中反复踩到的六个坑
下面这六个问题,几乎每一个我都在不同阶段遇到过,而且它们都有共同特征:代码能跑通,结果不一定对,排查时容易被表象误导。所以我不只写"正确做法",也把当时的排查过程写出来,方便你遇到类似现象时对照。
3.1 可变默认参数:一个列表被所有对象悄悄共享
这是类和对象学习中杀伤力最大的坑,没有之一:
class Cart: def __init__(self, items=[]): # 危险 self.items = items c1, c2 = Cart(), Cart() c1.items.append("书") print(c2.items) # ['书'] —— c2 也中招了原因在于默认参数的值是在函数定义时求值一次的,[]只创建了一个列表对象,之后所有没传参的实例都拿着同一个引用。这和类属性共享是两回事,即使你把items写成实例属性也躲不过,因为源头是函数默认值。
正确的写法是用None占位,在函数体内再创建:
class Cart: def __init__(self, items=None): self.items = list(items) if items else []这里用list(items)而不是直接self.items = items,是有意做了一次浅拷贝,防止调用方传进来的列表在外部被改动时连带影响对象状态。这条经验在写库代码时尤其重要——构造器不应该持有外部传入的可变对象引用,除非你明确就是要共享。当年我排查一个"两个订单的商品莫名混在一起"的问题,查了大半天最后定位到就是这个默认参数,改完之后顺手把所有构造器里的可变默认值都清了一遍。
顺带一提,同理的还有在类体里直接写data = {}当实例状态用的写法,本质是类属性共享,症状相似,根因不同,排查时看__dict__最快:
print(c1.__dict__, c2.__dict__) # 看看各自的字典里到底有什么3.2 用==比较对象却得到意外结果
自定义类的对象默认用身份比较,两个内容完全一样的对象==返回False:
class Point: def __init__(self, x, y): self.x, self.y = x, y print(Point(1, 2) == Point(1, 2)) # False这个行为本身不算 bug,但当你把对象放进list、用in判断、或者放进set去重时,结果就会非常反直觉——去重完全失效,因为每个对象身份都不同。修复要成对实现__eq__和__hash__:
class Point: def __init__(self, x, y): self.x, self.y = x, y def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return (self.x, self.y) == (other.x, other.y) def __hash__(self): return hash((self.x, self.y))两个细节值得强调。返回NotImplemented而不是False,是为了让 Python 有机会去尝试对方的反射比较,这在跨类型比较时更健壮;实现__eq__后必须显式实现__hash__,否则 Python 会把__hash__置为None,对象直接不可哈希,放进set会报错。我第一次遇到这个报错时完全没往__eq__上想,因为报错信息指向的是set.add那一行。
3.3super()的调用位置和继承链顺序
继承写多了之后,super()的坑集中在两处。第一处是忘记调用父类初始化:
class Animal: def __init__(self, name): self.name = name class Dog(Animal): def __init__(self, name, breed): self.breed = breed # 漏了 super().__init__(name)这样写不会报错,但dog.name访问时才炸,错误信息和真正的根因隔了好几层调用。第二处更隐蔽,出现在多重继承里。Python 用 MRO(方法解析顺序)决定找方法的路径,菱形继承下super()不是"调父类",而是"调 MRO 链上的下一个"。我用一个简化例子演示:
class A: def __init__(self): print("A") super().__init__() class B(A): def __init__(self): print("B") super().__init__() class C(A): def __init__(self): print("C") super().__init__() class D(B, C): def __init__(self): print("D") super().__init__() D() print([c.__name__ for c in D.__mro__])输出顺序是 D → B → C → A,而不是 D → B → A 然后 C。原因是super()沿着D.__mro__往后走,B 里的super()指向的是 C。这个机制保证了每个父类的初始化只执行一次,但也要求所有协作的类都必须一致地调用super(),只要有一个环节漏掉,整条链就断了。实务上我的建议很直接:多重继承尽量只用于"混入"式的窄接口,继承层次不要超过两层,复杂场景优先用组合。
3.4 属性名和方法名撞车
这个坑我犯过两次,都是因为起名太随意:
class User: def __init__(self, name): self.name = name def name(self): # 方法名和属性名重复 return self.name类体里先定义__init__,再定义name方法,两者在类__dict__里会互相覆盖(这里是方法覆盖了……其实不会,因为self.name是实例属性,位于实例字典里,优先级更高)。实际运行时u.name拿到的是字符串,u.name()就会报"str is not callable"。这类报错信息看起来像类型问题,实际是命名冲突。
更麻烦的是反过来:先有属性赋值,后来加了个同名方法,所有原来读属性的地方突然读到方法对象,报错位置全都变了。我的规避办法很简单——动词化的方法名、名词化的属性名,get_name()这种写法虽然啰嗦,但在团队协作里歧义最小;同时避免用list、dict、id、type这类内置名做属性名,否则在方法里想用内置函数时会被self.xxx遮蔽,得写builtins.list才行,非常别扭。
3.5 对象之间互相持有引用导致的状态混乱
做练习项目时很容易写出这种结构:Order里有User,User里又有orders列表,两边都存一份。任何一侧更新,另一侧就不同步。这类问题不是语法坑,是建模坑,但初学阶段特别容易踩,因为双向引用写起来最"自然"。
我的处理原则是单向持有 + 查询方法:订单持有用户引用(因为订单必然属于某个用户),用户不直接持有订单列表,需要时通过仓储对象或外部查询获得。这样状态只有一个真相来源,避免了"改了这边忘了那边"的经典问题。如果确实需要双向导航,那就要把同步逻辑收进唯一一个入口方法里,绝不允许外部直接改其中一侧的集合。
3.6 在方法里无节制地链式访问
封装被破坏最常见的形态不是有人直接改私有属性,而是有人写出了order.user.address.city.name这样的长链。乍看是只读访问没问题,但它把三个类的内部结构硬编码进了一个调用点,任何一层结构变动都会连锁影响到这里。
我在重构时遇到过一段代码,统计"某城市的订单量",写的是遍历订单然后链式取城市名。后来地址结构从address.city改成address.region.city,全项目几十处链式访问全要改。如果当初在Order上提供city_name()这么个方法,改动点就只有一个。迪米特法则听起来很抽象,落到实操就是一句话:一个对象应该只和它的直接伙伴说话。
4. 封装、继承、多态落到代码里的真实样子
这三个词被讲烂了,但真正的问题从来不是"它们是什么意思",而是"什么时候该用、用错了会怎样"。这一节按这个角度来拆。
4.1 封装的控制点画在哪里
封装的本质是给状态变更划定唯一入口,语言层面的下划线只是提示,不是强制。Python 里_x是约定,__x会做名字改写(变成_ClassName__x),但都不是真正的外部不可访问。所以封装的可靠性来自设计纪律,而不是语法。
判断封装是否到位,我一般用两个问题自测。第一,这个属性有没有可能被改成一个非法值?如果有可能,就必须有方法来做校验。第二,这个属性的变更会不会引起其他属性不一致?如果会,就必须把变更收进方法里,在方法内部同时维护多个字段。
举个具体例子。一个账户类如果有balance和frozen两个状态,直接允许外部改balance,就会出现"已冻结账户余额还在变"的不一致。更好的写法是只暴露deposit、withdraw、freeze三个操作,余额只读:
class Account: def __init__(self, balance=0): self._balance = balance self._frozen = False @property def balance(self): return self._balance def deposit(self, amount): if self._frozen: raise RuntimeError("账户已冻结") if amount <= 0: raise ValueError("金额必须为正") self._balance += amount@property在这里的作用不是"把属性变成方法",而是保留读取语法的同时去掉写入入口,读起来像属性,改起来必须走方法。这个折中在 Python 里非常好用,Java 里对应的写法是 getter 加私有字段,思路一致。
不过要提醒一句:不是所有字段都值得包一层property。我见过把每个字段都写一遍 getter/setter 的类,几十行样板代码,除了增加阅读负担没有任何校验逻辑,这种封装是无效封装。只有当读取或写入需要附加行为时,包一层才有意义。
4.2 继承的取舍:什么时候组合更划算
继承表达的是"是一种"的关系,这个判断标准大家都知道,但真到写代码还是容易滥用。我的经验是加一道额外检查:子类能不能完全替代父类而不出问题。如果替换之后行为变了、调用方需要额外判断类型,那这个继承关系就是错的。
一个典型反例:
class Bird: def fly(self): ... class Penguin(Bird): # 企鹅是鸟,但不会飞 def fly(self): raise NotImplementedError强行继承的结果是调用方拿到Bird之后还得判断能不能飞,多态直接失效。更好的做法是把"飞行"抽成能力接口,让会飞的类去实现;或者干脆不用继承,用组合——Penguin持有一个游泳行为的对象。
组合的优势在于耦合更松:被组合的对象可以随时替换、可以注入不同的实现用于测试、不会因为父类加了个方法就莫名继承一堆用不着的东西。代价是多一层跳转,读代码时要在两个文件之间来回看。我的实际取舍是:行为复用优先组合,接口统一优先继承。换句话说,我为了"少写重复代码"时用组合,为了"让调用方按同一套约定处理不同对象"时才用继承(通常是抽象基类或协议)。
还有一点关于抽象基类的实践体会。Python 里可以用abc.ABC加@abstractmethod来强制子类实现某些方法,好处是把"接口约定"从口头约定变成运行时检查——子类漏实现时,实例化那一刻就报错,而不是等到某个分支被跑到才炸。代价是引入了一点继承依赖。在团队项目里我倾向于用它,因为新人漏实现方法的概率实在不低;个人小工具里就宽松一些,靠文档和类型注解约束。
4.3 多态的判定标准:同一调用、不同实现
多态最常见的误解是"父类引用指向子类对象"这句话本身,它描述的是现象,不是标准。真正的标准只有一条:调用方用同一段代码处理不同子类,且不需要知道具体类型。
class Shape: def area(self): raise NotImplementedError class Circle(Shape): def __init__(self, r): self.r = r def area(self): return 3.14159 * self.r ** 2 class Rect(Shape): def __init__(self, w, h): self.w, self.h = w, h def area(self): return self.w * self.h def total_area(shapes): return sum(s.area() for s in shapes) # 不关心具体是哪种形状total_area完全不知道shapes里装的什么,只依赖"有area()方法"这一约定。这段代码要新增三角形,total_area一行都不用改——这才是多态带来的实际收益。
由此可以反推一个判断方法:如果你在调用方写了isinstance或type() ==来判断分支,那么多态就没有用起来。这种代码在改动时是最痛的,每加一个类型就要去改所有分支。我重构过一段用if type == "A"处理接口响应的代码,把每个分支抽成一个处理类之后,新增类型变成了"加一个文件",而不是"改五个地方"。
在 Python 里还要补一句:多态不依赖继承。只要两个类都有同名方法,就能一起用,这叫鸭子类型。这种灵活性很好用,但也意味着错误发现得更晚——把方法名拼错,要到运行时才报AttributeError。缓解办法是用typing.Protocol声明期望的接口,配合类型检查工具在写代码阶段就发现不一致。这个技巧在接口边界多、参与人多的时候价值很高。
5. 用一个记账本把知识点串成一条线
光看概念容易散,我习惯用一个小项目把知识兜一遍。下面这个记账本足够小,但覆盖了构造、属性、方法、封装、继承、多态六个点,可以当成练习模板。
5.1 先把职责拆出来再动手写类
动手之前先问一句:这个系统里有几类"会变化的东西"?记账本里有两类:一笔记录(Entry)和账本(Ledger)。金额、类别、日期属于记录的属性;增删查、汇总属于账本的行为。这样的划分理由是:记录是值对象,创建后基本不变;账本是容器,负责维护集合的一致性。
再往上一层,如果要做多种报表输出,就会引出第三个抽象:报表(Report),它接收账本、产出文本。这类"输入数据、输出结果"的对象很适合用多态来组织,因为报表种类会不断新增,而账本的代码不该跟着改。
有心的人会注意到,这个拆解过程本身没有写一行代码,但它决定了后面所有代码的形状。我见过太多一上手就写class Manager然后什么都往里塞的例子,几百行之后谁也不敢改动。类的边界应该在写第一行之前就想清楚,而不是写完再拆。
5.2 核心代码走读
from dataclasses import dataclass, field from datetime import date @dataclass(frozen=True) class Entry: amount: float category: str day: date class Ledger: def __init__(self): self._entries = [] def add(self, entry): if entry.amount == 0: raise ValueError("金额不能为零") self._entries.append(entry) def total(self, category=None): items = self._entries if category is not None: items = [e for e in items if e.category == category] return sum(e.amount for e in items) def __len__(self): return len(self._entries) def __iter__(self): return iter(self._entries)这里几个选择都值得说明。Entry用@dataclass(frozen=True)而不是手写__init__,是因为它是纯数据,不需要变,冻结之后可以安全地放进set、当作字典键,也避免了"记录被中途改掉导致汇总对不上"的问题。Ledger的_entries用单下划线,表示内部状态;add里做了一次基本校验,把非法数据挡在入口。__len__和__iter__让账本可以直接用len()和for,调用方不用关心内部是列表还是别的结构。
接着用多态组织报表:
class Report: def render(self, ledger) -> str: raise NotImplementedError class CategoryReport(Report): def render(self, ledger): cats = {} for e in ledger: cats[e.category] = cats.get(e.category, 0) + e.amount lines = [f"{k}: {v:.2f}" for k, v in sorted(cats.items())] return "\n".join(lines) class MonthlyReport(Report): def render(self, ledger): months = {} for e in ledger: key = e.day.strftime("%Y-%m") months[key] = months.get(key, 0) + e.amount return "\n".join(f"{k}: {v:.2f}" for k, v in sorted(months.items()))现在新增一种报表,只需要写一个新类,Ledger和已有的报表代码都不用动。这就是第 4 节讲的多态在真实代码里的收益。而且报表类不持有账本,每次render传入,避免了双向引用,也方便拿固定数据做测试。
5.3 测试切入点与几个扩展方向
写完代码后我一般先测三个地方。第一是边界值:金额为零、负数、超大值;第二是集合操作的一致性:add之后len是否同步、迭代出来的顺序是否稳定;第三是多态的替换性:任何Report的子类都能被同一个调用方正确使用。
扩展方向上比较自然的有三个。加一个remove方法,但要注意frozen的Entry怎么定位——这正好引出"值对象用相等性查找"的话题;加持久化,把Ledger的读写抽成一个Storage接口,文件、内存实现各写一份,用组合注入到Ledger里;加过滤报表的装饰器,比如"只统计最近 30 天",用包装而非修改原有报表类的方式实现。这三个扩展都不需要改动已有逻辑,是检验设计是否松耦合的好办法。
6. 从会写类到会设计类的进阶路子
能写出正确的类之后,下一个台阶是写"让别人愿意读"的类。这个阶段更多是习惯和约定问题,语法层面的新东西不多。
6.1 魔术方法要挑着学,别一次铺开
Python 的魔术方法清单很长,但日常真正高频的就那么几个,按使用频率我排个序:__init__、__repr__、__str__、__eq__、__len__、__iter__、__call__,再往后是上下文管理的__enter__/__exit__、比较运算的一组、属性访问的__getattr__/__setattr__。
其中__repr__的优先级被我故意提前——它是调试时的第一手信息。默认的<object object at 0x7f...>打印出来对你毫无帮助,而一句Entry(amount=12.0, category="餐饮", day=...)能省掉无数次断点。我的习惯是凡是自定义的数据类,第一件事就是补__repr__,用dataclass的话自动就有了。
__getattr__和__setattr__这类要格外小心,因为它们会拦截所有未定义的属性访问,一旦写错,AttributeError会被吞掉或者打出让人崩溃的递归调用。我一般只在做代理对象或者懒加载时用__getattr__,并且第一行就把下划线开头的属性放行,避免内部访问也被拦。
6.2 一份可以照着自测的清单
下面这十条是我给阶段自查用的,能全部答上来,说明类和对象的基本功已经过关:
| 序号 | 自测问题 | 答不上来通常意味着 |
|---|---|---|
| 1 | __new__和__init__的调用顺序和职责差别 | 对对象创建过程不清楚 |
| 2 | 类属性和实例属性在读写时的不对称规则 | 还没理解属性查找链 |
| 3 | 为什么可变对象不能直接当默认参数 | 没意识到默认值只求值一次 |
| 4 | self是绑定机制填的,不是语言强制的名字 | 只用过不会解释 |
| 5 | __eq__和__hash__为什么必须成对 | 没碰过集合去重的坑 |
| 6 | MRO 顺序怎么算,super()到底调谁 | 没碰过多重继承 |
| 7 | 什么时候该用组合而不是继承 | 设计层面还没建立起判断标准 |
| 8 | 多态的判定标准是不需要isinstance | 还停留在语法记忆 |
| 9 | property适合用在什么场景 | 封装停留在形式层面 |
| 10 | 鸭子类型的风险以及怎么缓解 | 没用过Protocol或类型检查 |
我在实际带人的过程中发现,第 6 条和第 7 条是最能区分"知道语法"和"会设计"的。前者要求你在脑子里能跑一遍 MRO,后者要求你放弃"复用代码就用继承"的直觉。等这两个问题能张口就答,类和对象这块基本就过去了。
最后分享一个我自己用的练习方法,比读教程有效得多:找一个自己写过的小脚本(比如处理 CSV 的、爬取页面的、算统计的),把它重构成两三个类,重构前后各写一份 README 说明结构。这个过程中你会被迫回答"这个函数为什么放在这个类里"这类问题,而正是在回答这些问题的过程中,类和对象才从语法变成你手里的工具。我当年这么练完之后,再看到别人的类定义,脑子里想的第一件事已经从"这段语法是什么"变成了"他为什么这样划分边界",这个视角的转变,就是入门和入门之后的分水岭。