刚接触编程的人,十有八九都会在“类和对象”这里卡上一阵子。我自己带过不少新人,也见过形形色色的学习代码,发现大家不是听不懂“类”是什么,而是实在想不明白:为什么非要绕这么大一圈,把一个简简单单的函数调用,搞出一堆“对象”“实例”“属性”“方法”的名词来?我当年的感受也是一样:类这个东西,看起来不难,但总感觉隔着一层窗户纸。这篇就打算把窗户纸捅破。我先讲清楚为什么要用类、类的核心逻辑是什么,再手把手拆解class语法、__init__、self、类变量与实例变量的区别,最后把新手写类时最容易踩的报错和误区全部列出来。内容定位是“类和对象(上)”,所以继承、多态、魔法方法这些先不展开,把基础地基打牢,下一篇再往深走。适合刚学完函数、开始接触面向对象的人,也适合工作里用了很久代码却一直没系统梳理过类概念的开发者。1. 什么时候该用类:从“散装函数”到“数据与行为绑定”1.1 一个真实的翻车现场:用列表记宠物数据先看一个我经常拿来举例的场景。假设你要给一个小区的宠物登记系统写代码,最早的需求很简单:记下每只宠物的名字、种类、年龄,还有是否打过疫苗。很多刚学会列表和字典的人,第一时间会写出这样的代码:pet_names = ["旺财", "小白", "豆包"] pet_ages = [2, 5, 3] pet_types = ["狗", "猫", "狗"] pet_vaccinated = [True, False, True] def print_pet(index): print(f"{pet_names[index]}是一只{pet_types[index]},今年{pet_ages[index]}岁,打完疫苗了没?{pet_vaccinated[index]}")写完之后看着好像挺工整,四个列表一摆,函数一调,完事。但过了一个星期,需求来了:要给每只宠物加一个“绝育状态”,你要改几个列表?再加一个“最近一次看病日期”呢?更麻烦的是,如果某只宠物去世或被领养,你需要同时从四个列表里删掉同一下标的数据——漏删一个,所有数据就错位了。有一次我带的新人就是在这种代码上翻车的:他在print_pet里用了pet_types[index],但另一个函数里删数据时只删了pet_names,结果后续所有打印的内容全都指错了宠物。排查到半夜,最后发现是“多个平行列表的同步维护”这个设计问题,而不是某个逻辑写错了。这就是典型的数据与行为分离造成的维护灾难。你手里拿着四组不相干的数据,需要用下标把它们的“关系”强行关联起来,而这个关系完全没有被代码本身表达出来。1.2 类的本质:一张可以反复使用的“设计图纸”类和对象解决的就是上面这个问题。它的核心思想很简单:把“一只宠物”应该拥有哪些数据,以及能够做哪些事情,打包成一个整体。你可以把类理解成一张设计图纸。图纸上写着:一只宠物有名字、有年龄、要能叫、要能打疫苗。这张图纸本身不是什么实际的东西,但它规定了将来生产出来的每一只宠物长什么样。而对象,就是按照图纸实际造出来的那一个具体的“东西”——旺财、小白、豆包都是对象,它们都符合图纸的规定,但各自的属性值不同。用代码来表达就是:class Pet: def __init__(self, name, age, pet_type, vaccinated): self.name = name self.age = age self.pet_type = pet_type self.vaccinated = vaccinated def print_info(self): print(f"{self.name}是一只{self.pet_type},今年{self.age}岁,打完疫苗了没?{self.vaccinated}") wangcai = Pet("旺财", 2, "狗", True) xiaobai = Pet("小白", 5, "猫", False)你看,数据不再躺在四个互不相干的列表里,而是作为属性挂在每个对象身上。想增加“绝育状态”,只需在Pet类里加一个属性,所有对象立刻都有了;想找某只宠物的信息,直接从对象上拿,不再依赖下标对齐。这就是类的第一个价值:把数据和行为绑定在一起,让代码里的每一个对象都对应现实世界里的一个具体事物,读起来直观,维护起来也安全。1.3 什么时候不该用类不过我也要泼一盆冷水:类不是什么时候都要用。很多人学完面向对象之后会产生一种冲动——不管写什么都要塞进一个类里,结果一个二十行的小脚本,硬是写成了三五个类互相调用的“大工程”,完全没必要。比如你就是算个文件里有多少行、统计一下高频词,用几个函数串联就足够了。类的优势在于“多个同类型对象并存、且各自的内部状态需要被组织和管理”。如果全程只有一个“对象”,或者根本没有任何状态需要保存,那用类只会增加阅读负担。判断标准很简单:如果你发现自己在一个项目里反复维护多组并列的数据结构,或者多个函数都在操作同一组数据,那就可以考虑用类来重构了。如果只是临时处理一次数据,写函数就够了。这算是我的个人经验——先让代码痛了,再引入对象,理解会更深刻;反过来一上来就把一切包进类里,反而经常写出为了结构而结构的形式主义代码。2. 亲手写出第一个类:语法骨架与__init__的底层逻辑2.1 一个最小可用的类到底长什么样很多教程上来就甩一个类定义,然后一行行解释,但新手看着看着就迷了。我习惯先给出一个“最小可用”的版本,让读者先感受一下“这个类真的能跑起来”的感受。class Dog: def __init__(self, name, age): self.name = name self.age = age def bark(self): print(f"{self.name}:汪汪!") dog1 = Dog("旺财", 2) print(dog1.name) dog1.bark()这段代码里一共有四个关键部分:class Dog:定义了一个名为Dog的类。def __init__(self, name, age):这个__init__方法负责在创建对象时接收参数、给对象设置初始属性。self.name = name中的self指代将来创建出来的那个对象本身,它用于把参数“保存到对象上”。def bark(self):是普通的方法(函数),描述这个对象能做什么。创建对象靠的是Dog(...)这种写法。很多人会误以为Dog("旺财", 2)是在调用某个叫Dog的函数,这个理解其实不算全错——严格来说,它调用的是类的构造过程。在 Python 里,这个过程会先创建出一个空白实例,然后自动调用__init__把参数填进去。等整行执行完,dog1就是一个真实的Dog实例,有自己的名字和年龄。建议你动手跑一遍,然后试着自己往类里加一个新的属性,比如hair_color,在创建对象时一并传入。这种“加属性”的练习能最快帮助你建立“类定义就是模板、对象是模板的实例”这个认知。2.2__init__不是构造函数,是初始化函数这里有个很常见的概念误区,值得单独拿出来说。很多语言比如 Java、C++ 里,类的构造方法负责“造出对象并初始化它”,但在 Python 里,真正的“构造过程”被分成了两步:第一步是__new__,负责实际分配内存、创建出一个空对象;第二步才是__init__,负责给这个空对象填入初始值。所以__init__严格说应该是“初始化方法”,而不是“构造方法”。大部分情况下你根本不会重写__new__,只需要用__init__把各种属性填好就行。这样设计的好处是:创建对象时你只需要关心“我应该提供哪些初始数据”,而不需要操心内存分配的底层细节。再补一个点:__init__里不强制要求定义所有属性。你可以只初始化一部分,其他属性之后动态添加:class Dog: def __init__(self, name): self.name = name dog2 = Dog("小白") dog2.age = 3 # 动态添加一个新属性,Python 允许这么做 print(dog2.age)这种写法虽然灵活,但对于真实项目,我会建议尽量在__init__里把所有属性一次性定义清楚,并且给出默认值。否则不同对象拥有的属性可能不一致,代码里到处都是hasattr()判断,维护起来非常痛苦。“能跑”和“好维护”是两回事,这一点在写类的时候特别明显。2.3 点号访问属性:对象本质上是命名空间当你写下dog1.name时,Python 做了一件很简单的事:在dog1这个对象的内部字典里查找name这个键。每个对象在 Python 内部都维护着一个字典,键是属性名,值就是属性值。你可以通过dog1.__dict__把它打出来看看:print(dog1.__dict__) # {'name': '旺财', 'age': 2}这个理解非常有用。它解释了为什么同一个类创建出来的不同对象之间互不干扰:因为每个对象都有自己独立的那本“小字典”,你给dog1.name重新赋值,dog2.name完全不受影响。这也解释了一个新手经常糊涂的点:为什么类的属性访问格式是“对象名.属性名”?实际上就是“去哪本小字典里找哪个键”。方法也不例外——dog1.bark本质上是去小字典里找bark,找到后发现它对应的是一个函数对象,于是把它取出来调用。这个“命名空间”视角,能帮你把之后遇到的类变量、实例变量、作用域等问题都串起来。3. self参数为什么必须存在:初学者最容易卡住的那道坎3.1 self的真实身份:谁调用,谁就是self到了这一步,大家基本能照葫芦画瓢写出简单的类了,但内心一定有个疑问一直盘旋着:这个self到底是什么?为什么每个方法都要写它?去掉行不行?先给结论:在 Python 里,当你写object.method(...)的时候,Python 会隐式地把object自身作为第一个参数传给method,而这个参数在定义时,写的就是self。换句话说:dog1.bark()等价于:Dog.bark(dog1)在第二种写法里,你清楚地看到dog1被作为参数传进去了。于是self就是“接收这次调用消息的那个对象本身”。当dog1.bark()执行时,self就是dog1;当dog2.bark()执行时,self就是dog2。所以self使得同一个方法可以被不同对象共用一套逻辑,而内部通过self拿到的是各自的数据。如果没有self,bark方法根本不知道自己是“哪只狗”在叫,也就拿不到名字了。你可以自己验证一下:故意把dog1.bark()换成Dog.bark(dog1)跑,结果完全一样。遇到任何“为什么 self 能访问到那些属性”的疑问,用这种方式写出来,基本立刻能看懂。3.2 名字可以换,但请遵守约定有同学会问:self 必须是这个名字吗?答案:不是,只是约定。你完全可以把第一个参数命名为this或me:class Dog: def __init__(me, name, age): me.name = name me.age = age这样写 Python 也能正常运行。但强烈建议不要这么干。原因很实际:几乎所有 Python 开发者都已经习惯了看到第一个参数写self就意味着“这是实例方法”。你换来一个奇怪的名字,阅读你代码的人(包括两个月后的你自己)都容易愣一下,然后还得去翻定义确认这里到底是什么逻辑。而且很多 IDE 和静态检查工具也默认self来做自动补全和提示。我之前见过一个同事把所有 self 换成了 this,理由是“写起来更像 Java”,结果代码风格跟整个项目里其他类完全不统一,代码评审时候大家一致要求改回来。类本身就是一种通过命名来表达结构的工具,连约定性的self都改了,就破坏了这种表达力。3.3 什么时候不需要 self:静态方法和类方法的引入理解 self 之后,自然会出现一个疑问:如果某个方法根本用不到实例的任何数据——它不读self.name,也不写任何属性——那它还要不要写在类里?这就是引入@staticmethod和@classmethod的实际场景。先看一个静态方法的例子:class Dog: species = "犬科" def __init__(self, name): self.name = name @staticmethod def get_common_species(): return "犬科" @classmethod def get_species(cls): return cls.species区别在哪里?实例方法(普通方法)第一个参数是self,调用方式通常是instance.method(),它访问和修改实例数据。类方法第一个参数是cls,通过class.method()或instance.method()都可以调用,它访问的是类本身的数据(比如类变量)。静态方法既不需要self也不需要cls,相当于放在类命名空间里的普通函数,只是逻辑上和这个类相关才放进来。可能对初学者来说,这三者的区分显得有些早。但我见过太多人学完基础之后,一旦往类里加了不需要 self 的方法就报错,然后陷入“为什么它给我传了参数”的迷茫,再夜不能寐。所以这里先简单打个铺垫,你不需要现在完全搞懂,只需要知道:方法不一定都要带 self,如果某个方法不使用实例属性,可以考虑不用 self。等你回头看到报错里出现“missing 1 required positional argument: 'self'”时,再回来对照这一节,会非常有帮助。4. 类变量与实例变量:共享与独立的分界线4.1 变量写在谁那里,决定它属于谁很多人学了类的基本语法之后,会卡在一个特别隐蔽但特别关键的地方:同样是“变量”,写在class内部但不在任何方法里,和写在__init__里用self.前缀定义,到底有什么区别?区别大了。直接看对比:class Dog: species = "犬科" # 类变量:所有实例共享 count = 0 # 类变量:记录整个类的状态 def __init__(self, name): self.name = name # 实例变量:每个实例各自持有 Dog.count += 1 # 修改类变量,用 Dog.count变量位置决定了它的归属:变量类型定义位置归属修改方式示例典型用途类变量class 方法体内、方法外类自身,所有实例共享同一个值Dog.count = 1全局统计数、常量、共享配置实例变量方法内通过self.定义单个实例,互不影响dog1.name = "新名字"描述每个对象自身的状态用图纸类比的话:类变量是“图纸上统一的规格”,实例变量是“每间房交付时业主自己装修的风格”。图纸改了,未来造出来的房子都会受影响;但业主装修只影响自己那一套,不会改动图纸。什么时候使用哪种变量,我的原则是:**如果一个数值对所有对象来说都应该是相同的,且沿类型统一变化,就用类变量;如果每个对象可能各自不同,就是实例变量。**比如所有狗都是“犬科”,这是一个类变量;每只狗的名字、年龄,一定是实例变量。4.2 经典陷阱:可变对象当类变量的惨痛教训类变量看似简单,但有个特别经典的坑,几乎所有写过类的人都踩过——把可变对象(列表、字典、集合)当作类变量,然后在实例方法里直接修改它。看这个例子:class Dog: friends = [] # 类变量,列表是可变对象 def __init__(self, name): self.name = name def add_friend(self, friend_name): self.friends.append(friend_name) dog1 = Dog("旺财") dog2 = Dog("小白") dog1.add_friend("来福") print(dog2.friends) # ['来福'] ?!是不是很意外?我只给dog1加了一个朋友,结果dog2.friends里也出现了“来福”。为什么?因为friends是类变量,它在类创建时只初始化一次,所有实例共享同一个列表对象。self.friends.append("来福")这个操作,虽然是通过self写的,但self.friends仍然指向那个类级别的列表——于是你修改的是所有狗共享的那份列表。这种情况怎么处理?最简单的方法:干脆不要在类变量里放可变对象,所有需要每个实例独立持有的数据,一律放到__init__里:class Dog: def __init__(self, name): self.name = name self.friends = []如果确实需要类级别共享一份数据,那你可以显式地用Dog.friends来操作,或者在修改时复制一份再赋给实例。重点在于,你要在心里建立一个判断:读类变量和写类变量是两个完全不同的动作。读取时,实例找不到自己的属性会顺着去类的字典里找,这个设计可以给我们便利;但写入时,如果你用的是append、extend这类修改原地对象的方法,那改的就是类变量本身,于是所有实例全部中招。这也是我经常看到新人崩溃的时刻,提前了解,能帮你少踩一个深坑。4.3 修改类变量的两种姿势,以及“遮蔽”现象既然说到改,那就有必要讲清楚两种写法之间的微妙差别。第一种,通过类名修改:Dog.species = "犬科哺乳动物"这会真正修改类变量本身,之后创建的新对象、以及所有从类上读取该属性的旧对象,看到的都是新值。第二种,通过实例修改:dog1.species = "猫科" # 实际上你是在给 dog1 新建了一个实例属性这个操作并没有改动类变量,只是给dog1这个实例单独创建了一个名为species的属性,它“遮蔽”了从类继承来的那个类变量。之后dog1.species读到的是它自己的值,而dog2.species仍然是类变量里的值。新手经常在这个地方翻车:某个代码片段里写了self.count = 1,本意是想“所有实例都统一改成 1”,但由于它在实例上创建了新属性,其他实例完全没变化。等调试的时候,明明看到上一行给某个对象赋值了,下一行另一个对象的同名属性却是原值,让人一头雾水。记住一条规则:在没有给实例单独赋值的情况下,实例查找属性是“先找自己的字典,找不到再去类的字典”。一旦你给实例赋了值,它就有了自己的属性,类变量就被盖住了。这跟前面说的“对象就是一个小字典”完全吻合,理解了这一点,遮蔽、共享、读取这些行为就都顺理成章了。5. 刚写完类最常见的报错与排查思路5.1 定位报错:missing 1 required positional argument: 'self'写类必然会遇到报错,有些报错信息对新手极度不友好,比如这一条:TypeError: Dog.bark() missing 1 required positional argument: 'self'很多人的第一反应是:我明明调用了bark(),怎么还说少参数?其实原因大概率是你用类名直接调用了实例方法:Dog.bark() # 报错 dog1.bark() # 正确由于bark的定义里有self参数,当你用Dog.bark()调用时,没有传入任何实例,self没有着落,自然报错。解决办法要么改成dog1.bark(),要么明确传入一个实例:Dog.bark(dog1)。这条报错出现时,先检查自己是不是用类名调用了实例方法,或是在写装饰器、回调函数时不经意地丢了实例信息。5.2 方法调用忘了加括号:打印出的是一大串怪东西另一个新手高频场景:print(dog1.bark)运行结果不是“汪汪”,而是类似这样的输出:<bound method Dog.bark of <__main__.Dog object at 0x...>>这很容易让人一头雾水,但实际上 Python 在告诉你:dog1.bark是一个“方法对象”,它还不是“调用方法的结果”。是你自己忘了在后面加括号,方法并没有真正执行。原理很简单:dog1.bark是“把方法从对象上取出来”这个动作,方法要真正运行需要向后调用,也就是再跟一对括号:dog1.bark()。如果你不小心把加括号的环节漏掉了,拿到的是方法本身,而不是方法的返回值。类似的还有类属性与方法的混淆——当属性名和方法名重名时,也容易踩到这类坑,所以命名时尽量让属性用名词、方法用动宾短语,能减少很多误读。5.3 一个隐蔽的雷区:'Dog' object is not callable这种报错信息干脆利落:TypeError: 'Dog' object is not callable。意思是,你把一个对象当作函数调用了。常见的触发场景之一,是创建对象时误把“类的名字”和“对象名”搞混,或者某个地方变量被覆盖了:class Dog: def __init__(self, name): self.name = name Dog = Dog("旺财") # 把类名重新绑定成了实例,类从此消失 new_dog = Dog("小白") # 报错:Dog 对象不可调用这里的错误比较极端,但真实发生。更多的是这种场景:你在类内部定义了一个属性叫call,然后在外面写instance.call,Python 会把这个属性当作可调用对象使用,结果类型对不上,直接抛not callable。排查思路很固定:报错说谁不可调用,就先用print(type(x))去看看那个“谁”到底是个什么类型。90% 的情况是类型与预期不符——你以为它是个函数或方法,其实它是别的。打印出类型的那一刻,大部分问题都会迎刃而解。5.4 三查法:类报错快速定位方法论最后分享一个我自己总结的排查流程,记不住所有报错细节也没关系,按这个顺序查,基本都能定位:查定义:确认类的属性、方法是不是都写在了正确的位置。实例变量写到类变量区域、方法缩进错误导致不在类里面,是最常见、最容易忽略的问题。看到方法里用了self,但缩进比class层级少了一格,那它其实已经逃出类了。查创建:创建对象时参数个数、顺序有没有对上__init__的签名?最常见的报错是takes 2 positional arguments but 3 were given——通常是__init__定义里漏了self。查调用:调用方法的对象是不是你预期的那个实例?有没有通过类名调用实例方法?有没有忘了加括号?属性名与用户名有没有因为大小写、拼写问题导致找不到,进而触发AttributeError?这三步做完,绝大多数类相关的入门报错都能在几分钟内解决。我第一次接触面向对象时,被self搞得几乎崩溃,后来把上面的流程记熟了,才建立起信心。所以别急,报错不是坏事,反而是你逐步理解类内部运行机制的最直接窗口。写到这里,“类和对象(上)”的核心内容算是聊透了:从为什么需要类,到如何写一个类,再到self的本质、类变量与实例变量的分界,最后是新手阶段最常遇到的报错和排查思路。我个人的体会是,学类和对象这关,真正难的不是某一条语法,而是思维方式的转变——把数据和行为绑在一起,用图纸和实例的眼光去看待代码。一旦你用这种眼光重新审视旧代码,会发现很多原本乱七八糟的逻辑都能被重新整理得井井有条。下一篇再继续聊继承、多态和__repr__等魔法方法,先别急着往前赶,把基础的每一个细节都亲手敲一遍才算真正拿下了。
刚接触编程的人,十有八九都会在“类和对象”这里卡上一阵子。我自己带过不少新人,也见过形形色色的学习代码,发现大家不是听不懂“类”是什么,而是实在想不明白:为什么非要绕这么大一圈,把一个简简单单的函数调用,搞出一堆“对象”“实例”“属性”“方法”的名词来?我当年的感受也是一样:类这个东西,看起来不难,但总感觉隔着一层窗户纸。这篇就打算把窗户纸捅破。我先讲清楚为什么要用类、类的核心逻辑是什么,再手把手拆解class语法、__init__、self、类变量与实例变量的区别,最后把新手写类时最容易踩的报错和误区全部列出来。内容定位是“类和对象(上)”,所以继承、多态、魔法方法这些先不展开,把基础地基打牢,下一篇再往深走。适合刚学完函数、开始接触面向对象的人,也适合工作里用了很久代码却一直没系统梳理过类概念的开发者。1. 什么时候该用类:从“散装函数”到“数据与行为绑定”1.1 一个真实的翻车现场:用列表记宠物数据先看一个我经常拿来举例的场景。假设你要给一个小区的宠物登记系统写代码,最早的需求很简单:记下每只宠物的名字、种类、年龄,还有是否打过疫苗。很多刚学会列表和字典的人,第一时间会写出这样的代码:pet_names = ["旺财", "小白", "豆包"] pet_ages = [2, 5, 3] pet_types = ["狗", "猫", "狗"] pet_vaccinated = [True, False, True] def print_pet(index): print(f"{pet_names[index]}是一只{pet_types[index]},今年{pet_ages[index]}岁,打完疫苗了没?{pet_vaccinated[index]}")写完之后看着好像挺工整,四个列表一摆,函数一调,完事。但过了一个星期,需求来了:要给每只宠物加一个“绝育状态”,你要改几个列表?再加一个“最近一次看病日期”呢?更麻烦的是,如果某只宠物去世或被领养,你需要同时从四个列表里删掉同一下标的数据——漏删一个,所有数据就错位了。有一次我带的新人就是在这种代码上翻车的:他在print_pet里用了pet_types[index],但另一个函数里删数据时只删了pet_names,结果后续所有打印的内容全都指错了宠物。排查到半夜,最后发现是“多个平行列表的同步维护”这个设计问题,而不是某个逻辑写错了。这就是典型的数据与行为分离造成的维护灾难。你手里拿着四组不相干的数据,需要用下标把它们的“关系”强行关联起来,而这个关系完全没有被代码本身表达出来。1.2 类的本质:一张可以反复使用的“设计图纸”类和对象解决的就是上面这个问题。它的核心思想很简单:把“一只宠物”应该拥有哪些数据,以及能够做哪些事情,打包成一个整体。你可以把类理解成一张设计图纸。图纸上写着:一只宠物有名字、有年龄、要能叫、要能打疫苗。这张图纸本身不是什么实际的东西,但它规定了将来生产出来的每一只宠物长什么样。而对象,就是按照图纸实际造出来的那一个具体的“东西”——旺财、小白、豆包都是对象,它们都符合图纸的规定,但各自的属性值不同。用代码来表达就是:class Pet: def __init__(self, name, age, pet_type, vaccinated): self.name = name self.age = age self.pet_type = pet_type self.vaccinated = vaccinated def print_info(self): print(f"{self.name}是一只{self.pet_type},今年{self.age}岁,打完疫苗了没?{self.vaccinated}") wangcai = Pet("旺财", 2, "狗", True) xiaobai = Pet("小白", 5, "猫", False)你看,数据不再躺在四个互不相干的列表里,而是作为属性挂在每个对象身上。想增加“绝育状态”,只需在Pet类里加一个属性,所有对象立刻都有了;想找某只宠物的信息,直接从对象上拿,不再依赖下标对齐。这就是类的第一个价值:把数据和行为绑定在一起,让代码里的每一个对象都对应现实世界里的一个具体事物,读起来直观,维护起来也安全。1.3 什么时候不该用类不过我也要泼一盆冷水:类不是什么时候都要用。很多人学完面向对象之后会产生一种冲动——不管写什么都要塞进一个类里,结果一个二十行的小脚本,硬是写成了三五个类互相调用的“大工程”,完全没必要。比如你就是算个文件里有多少行、统计一下高频词,用几个函数串联就足够了。类的优势在于“多个同类型对象并存、且各自的内部状态需要被组织和管理”。如果全程只有一个“对象”,或者根本没有任何状态需要保存,那用类只会增加阅读负担。判断标准很简单:如果你发现自己在一个项目里反复维护多组并列的数据结构,或者多个函数都在操作同一组数据,那就可以考虑用类来重构了。如果只是临时处理一次数据,写函数就够了。这算是我的个人经验——先让代码痛了,再引入对象,理解会更深刻;反过来一上来就把一切包进类里,反而经常写出为了结构而结构的形式主义代码。2. 亲手写出第一个类:语法骨架与__init__的底层逻辑2.1 一个最小可用的类到底长什么样很多教程上来就甩一个类定义,然后一行行解释,但新手看着看着就迷了。我习惯先给出一个“最小可用”的版本,让读者先感受一下“这个类真的能跑起来”的感受。class Dog: def __init__(self, name, age): self.name = name self.age = age def bark(self): print(f"{self.name}:汪汪!") dog1 = Dog("旺财", 2) print(dog1.name) dog1.bark()这段代码里一共有四个关键部分:class Dog:定义了一个名为Dog的类。def __init__(self, name, age):这个__init__方法负责在创建对象时接收参数、给对象设置初始属性。self.name = name中的self指代将来创建出来的那个对象本身,它用于把参数“保存到对象上”。def bark(self):是普通的方法(函数),描述这个对象能做什么。创建对象靠的是Dog(...)这种写法。很多人会误以为Dog("旺财", 2)是在调用某个叫Dog的函数,这个理解其实不算全错——严格来说,它调用的是类的构造过程。在 Python 里,这个过程会先创建出一个空白实例,然后自动调用__init__把参数填进去。等整行执行完,dog1就是一个真实的Dog实例,有自己的名字和年龄。建议你动手跑一遍,然后试着自己往类里加一个新的属性,比如hair_color,在创建对象时一并传入。这种“加属性”的练习能最快帮助你建立“类定义就是模板、对象是模板的实例”这个认知。2.2__init__不是构造函数,是初始化函数这里有个很常见的概念误区,值得单独拿出来说。很多语言比如 Java、C++ 里,类的构造方法负责“造出对象并初始化它”,但在 Python 里,真正的“构造过程”被分成了两步:第一步是__new__,负责实际分配内存、创建出一个空对象;第二步才是__init__,负责给这个空对象填入初始值。所以__init__严格说应该是“初始化方法”,而不是“构造方法”。大部分情况下你根本不会重写__new__,只需要用__init__把各种属性填好就行。这样设计的好处是:创建对象时你只需要关心“我应该提供哪些初始数据”,而不需要操心内存分配的底层细节。再补一个点:__init__里不强制要求定义所有属性。你可以只初始化一部分,其他属性之后动态添加:class Dog: def __init__(self, name): self.name = name dog2 = Dog("小白") dog2.age = 3 # 动态添加一个新属性,Python 允许这么做 print(dog2.age)这种写法虽然灵活,但对于真实项目,我会建议尽量在__init__里把所有属性一次性定义清楚,并且给出默认值。否则不同对象拥有的属性可能不一致,代码里到处都是hasattr()判断,维护起来非常痛苦。“能跑”和“好维护”是两回事,这一点在写类的时候特别明显。2.3 点号访问属性:对象本质上是命名空间当你写下dog1.name时,Python 做了一件很简单的事:在dog1这个对象的内部字典里查找name这个键。每个对象在 Python 内部都维护着一个字典,键是属性名,值就是属性值。你可以通过dog1.__dict__把它打出来看看:print(dog1.__dict__) # {'name': '旺财', 'age': 2}这个理解非常有用。它解释了为什么同一个类创建出来的不同对象之间互不干扰:因为每个对象都有自己独立的那本“小字典”,你给dog1.name重新赋值,dog2.name完全不受影响。这也解释了一个新手经常糊涂的点:为什么类的属性访问格式是“对象名.属性名”?实际上就是“去哪本小字典里找哪个键”。方法也不例外——dog1.bark本质上是去小字典里找bark,找到后发现它对应的是一个函数对象,于是把它取出来调用。这个“命名空间”视角,能帮你把之后遇到的类变量、实例变量、作用域等问题都串起来。3. self参数为什么必须存在:初学者最容易卡住的那道坎3.1 self的真实身份:谁调用,谁就是self到了这一步,大家基本能照葫芦画瓢写出简单的类了,但内心一定有个疑问一直盘旋着:这个self到底是什么?为什么每个方法都要写它?去掉行不行?先给结论:在 Python 里,当你写object.method(...)的时候,Python 会隐式地把object自身作为第一个参数传给method,而这个参数在定义时,写的就是self。换句话说:dog1.bark()等价于:Dog.bark(dog1)在第二种写法里,你清楚地看到dog1被作为参数传进去了。于是self就是“接收这次调用消息的那个对象本身”。当dog1.bark()执行时,self就是dog1;当dog2.bark()执行时,self就是dog2。所以self使得同一个方法可以被不同对象共用一套逻辑,而内部通过self拿到的是各自的数据。如果没有self,bark方法根本不知道自己是“哪只狗”在叫,也就拿不到名字了。你可以自己验证一下:故意把dog1.bark()换成Dog.bark(dog1)跑,结果完全一样。遇到任何“为什么 self 能访问到那些属性”的疑问,用这种方式写出来,基本立刻能看懂。3.2 名字可以换,但请遵守约定有同学会问:self 必须是这个名字吗?答案:不是,只是约定。你完全可以把第一个参数命名为this或me:class Dog: def __init__(me, name, age): me.name = name me.age = age这样写 Python 也能正常运行。但强烈建议不要这么干。原因很实际:几乎所有 Python 开发者都已经习惯了看到第一个参数写self就意味着“这是实例方法”。你换来一个奇怪的名字,阅读你代码的人(包括两个月后的你自己)都容易愣一下,然后还得去翻定义确认这里到底是什么逻辑。而且很多 IDE 和静态检查工具也默认self来做自动补全和提示。我之前见过一个同事把所有 self 换成了 this,理由是“写起来更像 Java”,结果代码风格跟整个项目里其他类完全不统一,代码评审时候大家一致要求改回来。类本身就是一种通过命名来表达结构的工具,连约定性的self都改了,就破坏了这种表达力。3.3 什么时候不需要 self:静态方法和类方法的引入理解 self 之后,自然会出现一个疑问:如果某个方法根本用不到实例的任何数据——它不读self.name,也不写任何属性——那它还要不要写在类里?这就是引入@staticmethod和@classmethod的实际场景。先看一个静态方法的例子:class Dog: species = "犬科" def __init__(self, name): self.name = name @staticmethod def get_common_species(): return "犬科" @classmethod def get_species(cls): return cls.species区别在哪里?实例方法(普通方法)第一个参数是self,调用方式通常是instance.method(),它访问和修改实例数据。类方法第一个参数是cls,通过class.method()或instance.method()都可以调用,它访问的是类本身的数据(比如类变量)。静态方法既不需要self也不需要cls,相当于放在类命名空间里的普通函数,只是逻辑上和这个类相关才放进来。可能对初学者来说,这三者的区分显得有些早。但我见过太多人学完基础之后,一旦往类里加了不需要 self 的方法就报错,然后陷入“为什么它给我传了参数”的迷茫,再夜不能寐。所以这里先简单打个铺垫,你不需要现在完全搞懂,只需要知道:方法不一定都要带 self,如果某个方法不使用实例属性,可以考虑不用 self。等你回头看到报错里出现“missing 1 required positional argument: 'self'”时,再回来对照这一节,会非常有帮助。4. 类变量与实例变量:共享与独立的分界线4.1 变量写在谁那里,决定它属于谁很多人学了类的基本语法之后,会卡在一个特别隐蔽但特别关键的地方:同样是“变量”,写在class内部但不在任何方法里,和写在__init__里用self.前缀定义,到底有什么区别?区别大了。直接看对比:class Dog: species = "犬科" # 类变量:所有实例共享 count = 0 # 类变量:记录整个类的状态 def __init__(self, name): self.name = name # 实例变量:每个实例各自持有 Dog.count += 1 # 修改类变量,用 Dog.count变量位置决定了它的归属:变量类型定义位置归属修改方式示例典型用途类变量class 方法体内、方法外类自身,所有实例共享同一个值Dog.count = 1全局统计数、常量、共享配置实例变量方法内通过self.定义单个实例,互不影响dog1.name = "新名字"描述每个对象自身的状态用图纸类比的话:类变量是“图纸上统一的规格”,实例变量是“每间房交付时业主自己装修的风格”。图纸改了,未来造出来的房子都会受影响;但业主装修只影响自己那一套,不会改动图纸。什么时候使用哪种变量,我的原则是:**如果一个数值对所有对象来说都应该是相同的,且沿类型统一变化,就用类变量;如果每个对象可能各自不同,就是实例变量。**比如所有狗都是“犬科”,这是一个类变量;每只狗的名字、年龄,一定是实例变量。4.2 经典陷阱:可变对象当类变量的惨痛教训类变量看似简单,但有个特别经典的坑,几乎所有写过类的人都踩过——把可变对象(列表、字典、集合)当作类变量,然后在实例方法里直接修改它。看这个例子:class Dog: friends = [] # 类变量,列表是可变对象 def __init__(self, name): self.name = name def add_friend(self, friend_name): self.friends.append(friend_name) dog1 = Dog("旺财") dog2 = Dog("小白") dog1.add_friend("来福") print(dog2.friends) # ['来福'] ?!是不是很意外?我只给dog1加了一个朋友,结果dog2.friends里也出现了“来福”。为什么?因为friends是类变量,它在类创建时只初始化一次,所有实例共享同一个列表对象。self.friends.append("来福")这个操作,虽然是通过self写的,但self.friends仍然指向那个类级别的列表——于是你修改的是所有狗共享的那份列表。这种情况怎么处理?最简单的方法:干脆不要在类变量里放可变对象,所有需要每个实例独立持有的数据,一律放到__init__里:class Dog: def __init__(self, name): self.name = name self.friends = []如果确实需要类级别共享一份数据,那你可以显式地用Dog.friends来操作,或者在修改时复制一份再赋给实例。重点在于,你要在心里建立一个判断:读类变量和写类变量是两个完全不同的动作。读取时,实例找不到自己的属性会顺着去类的字典里找,这个设计可以给我们便利;但写入时,如果你用的是append、extend这类修改原地对象的方法,那改的就是类变量本身,于是所有实例全部中招。这也是我经常看到新人崩溃的时刻,提前了解,能帮你少踩一个深坑。4.3 修改类变量的两种姿势,以及“遮蔽”现象既然说到改,那就有必要讲清楚两种写法之间的微妙差别。第一种,通过类名修改:Dog.species = "犬科哺乳动物"这会真正修改类变量本身,之后创建的新对象、以及所有从类上读取该属性的旧对象,看到的都是新值。第二种,通过实例修改:dog1.species = "猫科" # 实际上你是在给 dog1 新建了一个实例属性这个操作并没有改动类变量,只是给dog1这个实例单独创建了一个名为species的属性,它“遮蔽”了从类继承来的那个类变量。之后dog1.species读到的是它自己的值,而dog2.species仍然是类变量里的值。新手经常在这个地方翻车:某个代码片段里写了self.count = 1,本意是想“所有实例都统一改成 1”,但由于它在实例上创建了新属性,其他实例完全没变化。等调试的时候,明明看到上一行给某个对象赋值了,下一行另一个对象的同名属性却是原值,让人一头雾水。记住一条规则:在没有给实例单独赋值的情况下,实例查找属性是“先找自己的字典,找不到再去类的字典”。一旦你给实例赋了值,它就有了自己的属性,类变量就被盖住了。这跟前面说的“对象就是一个小字典”完全吻合,理解了这一点,遮蔽、共享、读取这些行为就都顺理成章了。5. 刚写完类最常见的报错与排查思路5.1 定位报错:missing 1 required positional argument: 'self'写类必然会遇到报错,有些报错信息对新手极度不友好,比如这一条:TypeError: Dog.bark() missing 1 required positional argument: 'self'很多人的第一反应是:我明明调用了bark(),怎么还说少参数?其实原因大概率是你用类名直接调用了实例方法:Dog.bark() # 报错 dog1.bark() # 正确由于bark的定义里有self参数,当你用Dog.bark()调用时,没有传入任何实例,self没有着落,自然报错。解决办法要么改成dog1.bark(),要么明确传入一个实例:Dog.bark(dog1)。这条报错出现时,先检查自己是不是用类名调用了实例方法,或是在写装饰器、回调函数时不经意地丢了实例信息。5.2 方法调用忘了加括号:打印出的是一大串怪东西另一个新手高频场景:print(dog1.bark)运行结果不是“汪汪”,而是类似这样的输出:<bound method Dog.bark of <__main__.Dog object at 0x...>>这很容易让人一头雾水,但实际上 Python 在告诉你:dog1.bark是一个“方法对象”,它还不是“调用方法的结果”。是你自己忘了在后面加括号,方法并没有真正执行。原理很简单:dog1.bark是“把方法从对象上取出来”这个动作,方法要真正运行需要向后调用,也就是再跟一对括号:dog1.bark()。如果你不小心把加括号的环节漏掉了,拿到的是方法本身,而不是方法的返回值。类似的还有类属性与方法的混淆——当属性名和方法名重名时,也容易踩到这类坑,所以命名时尽量让属性用名词、方法用动宾短语,能减少很多误读。5.3 一个隐蔽的雷区:'Dog' object is not callable这种报错信息干脆利落:TypeError: 'Dog' object is not callable。意思是,你把一个对象当作函数调用了。常见的触发场景之一,是创建对象时误把“类的名字”和“对象名”搞混,或者某个地方变量被覆盖了:class Dog: def __init__(self, name): self.name = name Dog = Dog("旺财") # 把类名重新绑定成了实例,类从此消失 new_dog = Dog("小白") # 报错:Dog 对象不可调用这里的错误比较极端,但真实发生。更多的是这种场景:你在类内部定义了一个属性叫call,然后在外面写instance.call,Python 会把这个属性当作可调用对象使用,结果类型对不上,直接抛not callable。排查思路很固定:报错说谁不可调用,就先用print(type(x))去看看那个“谁”到底是个什么类型。90% 的情况是类型与预期不符——你以为它是个函数或方法,其实它是别的。打印出类型的那一刻,大部分问题都会迎刃而解。5.4 三查法:类报错快速定位方法论最后分享一个我自己总结的排查流程,记不住所有报错细节也没关系,按这个顺序查,基本都能定位:查定义:确认类的属性、方法是不是都写在了正确的位置。实例变量写到类变量区域、方法缩进错误导致不在类里面,是最常见、最容易忽略的问题。看到方法里用了self,但缩进比class层级少了一格,那它其实已经逃出类了。查创建:创建对象时参数个数、顺序有没有对上__init__的签名?最常见的报错是takes 2 positional arguments but 3 were given——通常是__init__定义里漏了self。查调用:调用方法的对象是不是你预期的那个实例?有没有通过类名调用实例方法?有没有忘了加括号?属性名与用户名有没有因为大小写、拼写问题导致找不到,进而触发AttributeError?这三步做完,绝大多数类相关的入门报错都能在几分钟内解决。我第一次接触面向对象时,被self搞得几乎崩溃,后来把上面的流程记熟了,才建立起信心。所以别急,报错不是坏事,反而是你逐步理解类内部运行机制的最直接窗口。写到这里,“类和对象(上)”的核心内容算是聊透了:从为什么需要类,到如何写一个类,再到self的本质、类变量与实例变量的分界,最后是新手阶段最常遇到的报错和排查思路。我个人的体会是,学类和对象这关,真正难的不是某一条语法,而是思维方式的转变——把数据和行为绑在一起,用图纸和实例的眼光去看待代码。一旦你用这种眼光重新审视旧代码,会发现很多原本乱七八糟的逻辑都能被重新整理得井井有条。下一篇再继续聊继承、多态和__repr__等魔法方法,先别急着往前赶,把基础的每一个细节都亲手敲一遍才算真正拿下了。