刚学 Python 的时候,很多人都会有这么一个阶段:变量、判断、循环、函数都写得挺顺,遇到字典和列表也能玩出花来,结果一碰到“类与对象”这四个字,心里就开始打鼓。我见过太多新手在 class 关键字前面卡住,不是语法背不下来,而是完全想不明白——这个东西到底解决了什么问题?我明明用函数也能写,为什么要多此一举搞个类出来?
这篇文章就是专门来治这个病的。我会用 Python 入门阶段最朴素的视角,把“类(class)”和“对象(object)”拆开揉碎讲清楚,全程不扯高深术语,完全围绕一个目标:让你看完之后,能自己动手写一个类、创建对象、调用方法,并且知道每一步背后到底发生了什么。这篇属于“类与对象”系列的第一篇,主题限定在最核心的概念、语法、实例化原理和属性机制上,继承、多态这些进阶玩法后面再说。
1. 为什么非要引入类和对象?一个真实痛点告诉你答案
1.1 先看一段“没有类”的代码是怎么变乱的
假设我们要写一个学生成绩管理的小程序。每个学生有名字、有成绩,我们要能给学生加分,能打印学生信息。如果你是刚学完函数的阶段,最自然的写法是用字典表示一个学生,然后用函数去操作这个字典:
python复制def create_student(name, score):
return {"name": name, "score": score}
def add_score(student, value):
student["score"] = student["score"] + value
def show_student(student):
print(f"{student['name']} 的成绩是 {student['score']} 分")
stu1 = create_student("张三", 90)
add_score(stu1, 5)
show_student(stu1)
这段代码能跑,功能也没问题。但问题出在什么地方?你仔细品一下:create_student、add_score、show_student 这三个函数,全部依赖一个“约定”——我们约定学生是一个字典,且字典里必须有 name 和 score 这两个键。这个约定没有被写进代码里,它只存在于程序员脑子里。一旦项目变大、人多手杂,有人不小心往字典里塞了一个 "grade" 键,或者把 "score" 写成了 "scroe",你的函数接收到的对象就走了样,报错的时候你根本找不出是哪个环节搞坏的。
更头疼的是,函数和它操作的数据是分离的。所有关于“学生”的函数散落在代码各处,没有形成一个整体。你要想找“所有能对学生做的事”,得在代码文件里到处翻。
1.2 类把数据和操作绑成了一个整体
类的出现,就是为了解决这个问题。它让你把“学生有什么”(数据,也就是属性)和“学生能干什么”(行为,也就是方法)定义在一个代码块里,形成一个独立的模板。同一个模板可以批量制造无数个“学生对象”,每个对象有自己的数据,但又共享同一套行为逻辑。
还是刚才那个学生案例,用类重写:
python复制class Student:
def __init__(self, name, score):
self.name = name
self.score = score
def add_score(self, value):
self.score = self.score + value
def show(self):
print(f"{self.name} 的成绩是 {self.score} 分")
stu1 = Student("张三", 90)
stu1.add_score(5)
stu1.show()
对比一下你会发现,代码量差不多,但结构发生了质的变化。学生的属性(name、score)和方法(add_score、show)被收进了一个 class Student 的代码块里。以后你要加新功能,比如“判断是否及格”“计算奖学金等级”,直接往这个类里加方法就行,其他用到 Student 的地方不用改。这种“低耦合、高内聚”的组织方式,正是面向对象编程的核心价值。
1.3 类与对象的生活化类比:图纸和房子的关系
为了把这两个概念焊死在脑子里,我用一个最经典的类比:类是房子的设计图纸,对象是按照图纸盖出来的那一栋栋房子。
图纸上写明了房子有几个房间、厨房在哪、阳台多大,但图纸本身不能住人。你照着同一张图纸,可以在不同的地方盖出无数栋结构相同但装修各异的房子——每一栋房子都是一个“对象”。图纸上定义的是“结构和规则”,这就是类;具体的某一栋房子自带地址、朝向、配色,这就是对象。
对应到代码里:Student 类是一张图纸,它规定了每个学生对象都有 name 和 score 属性,都有 add_score 和 show 方法。Student("张三", 90) 这行代码就是在“按图纸施工”,造出一栋具体的、属于“张三”的房子。再执行一次 Student("李四", 88),就是盖了另一栋结构相同但数据不同的房子。
牢记这个类比,后面所有内容都好理解了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手把手写出你的第一个类:语法、init 和 self 详解
2.1 一个最小可用的类长什么样
先看最简单的形态。你可以先定义一个什么都不干的空类,这完全合法:
python复制class Dog:
pass
这个 Dog 类有一个专属命名空间,目前里面什么属性都没有。但我们可以创建它的实例:
python复制dog = Dog()
print(dog) # <__main__.Dog object at 0x...>
注意这里的 Dog() 写法,它是在“调用”类来创建对象,这个过程叫实例化。在执行 Dog() 的时候,Python 会分配一块内存,创建一个新的空对象,然后把这块内存的地址返回给你。你看到的 0x... 就是对象在内存中的地址。
当然,实际开发中没人会写一个完全空的类。我们来给它加上属性和方法。一个带初始属性、带方法的典型类是这样:
python复制class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def bark(self):
print(f"{self.name} 汪汪叫")
def birthday(self):
self.age = self.age + 1
dog1 = Dog("旺财", 2)
dog1.bark() # 旺财 汪汪叫
dog1.birthday()
print(dog1.age) # 3
网上很多用 Python 写的宠物小游戏、人狗大战之类的入门项目,核心套路就是这个:定义一个 Dog 类,给它挂上血量、攻击力、技能这些属性和方法,然后创建两个对象互相打架。你把这个例子吃透了,那些项目代码你基本就能看懂了。
2.2 init 到底是什么?它其实不是构造函数
__init__ 是新手最容易被吓到的地方,名字前后各有两个下划线,看起来很高级。其实它的作用非常简单:它是在对象创建之后,自动执行的一段初始化代码,用来给新对象设置初始状态。
顺便纠正一个流传很广的误解:很多人把 __init__ 叫作“构造函数”,严格来说这并不准确。Python 里真正负责创建对象的,是另一个叫 __new__ 的方法,它负责分配内存、返回一个空对象。__init__ 是在 __new__ 之后被调用的,它接收这个已经存在的空对象,然后往里塞初始数据。可以理解为:__new__ 负责“造房子”,__init__ 负责“搬家具”。
在执行 Dog("旺财", 2) 的时候,Python 大致做了三件事:
- 调用
Dog.__new__,在内存中创建一个空的Dog对象; - 自动调用
Dog.__init__(空对象, "旺财", 2),把"旺财"和2分别存入刚创建的那个空对象; - 把这个初始化完成的对象返回给外部变量
dog1。
为什么你平时几乎感觉不到 __new__ 的存在?因为 Python 的 object 基类已经帮你实现了默认的 __new__,你如果不主动去改写它,它就会默默完成“创建空对象”这一步。入门阶段你只需要记住:写类的时候,用 __init__ 来初始化对象就好了。
2.3 self 是新手最容易懵的参数
初学类的时候,几乎所有方法定义里都有一个看起来多余的 self 参数。很多新手不理解:为什么 bark(self) 里有个 self?我调用的时候也没传任何参数啊?
关键在于:当你调用 dog1.bark() 的时候,Python 在背后把你写的是 Dog.bark(dog1, )。也就是说不存在“没有参数”这回事——对类的方法而言,谁调用了这个方法,那个谁就会被自动传进来,作为第一个参数的实参。self 只是一个变量名,它的值就是当前调用这个方法的那个对象本身。
所以你在方法内部写 self.name,其实就是在取“当前这个对象”的 name 属性。为什么必须这样做?因为同一个方法会被无数个对象共享,方法本身不知道现在是谁在调用它,必须通过第一个参数告诉它“现在是你‘旺财’在叫,还是‘小白’在叫”。
self 这个名字是强大的约定,Python 社区所有人都这么写,你也应该这么写。语言本身并不强制你用 self 这个词——你把第一个参数写成 this、写成 me,程序照样能跑——但谁也不该这么干,因为那会让所有读你代码的人抓狂。
还有一个新手经常踩的坑:在方法内部定义局部变量和访问实例属性搞混。比如你在方法里写 name = "小黑",这只是在方法作用域里创建了一个局部变量,不会对 self.name 产生任何影响。想访问对象的属性,永远要走 self.属性名。
2.4 实例属性是动态的,可以在任何地方添加
Python 的类和 Java 这类静态语言有个很大的区别:Java 要求在类里提前声明好所有字段,而 Python 中实例属性并不需要预先声明。除了在 __init__ 里添加属性,你在任意一个方法里也可以给对象挂上新的属性:
python复制class Cat:
def __init__(self, name):
self.name = name
c = Cat("小白")
c.color = "白色" # 在类外直接动态添加属性
print(c.color) # 白色
这个特性很灵活,但也很危险。万一某处代码因为拼写错误,无意中给对象添加了一个意料之外的属性,程序不一定立刻报错,而是在你用到这个属性的时候才表现出奇怪的行为,排查起来非常费劲。所以我个人的经验是:所有关键的初始属性,一律放在 __init__ 里初始化,这是规范,也是保护自己。
3. 实例化的底层原理:对象到底是什么,属性又是怎么找到的
3.1 实例和类是两套独立的命名空间
很多人以为“对象就是类的一份拷贝”,这是不对的。实际上,类和对象各自维护着一套独立的命名空间(namespace),你可以把命名空间理解成一个大字典,里面存着名字和值的对应关系。
用一个简单的类来演示:
python复制class Cat:
species = "猫科"
def __init__(self, name):
self.name = name
这里 species = "猫科" 是一个类属性,它存在于 Cat 类的命名空间里,就像 Cat.__dict__["species"] = "猫科"。而 self.name = name 给每个实例添加的是实例属性,它存在于具体某个对象的命名空间里。
我们可以直接打印这两个命名空间来看:
python复制c1 = Cat("小白")
c2 = Cat("小黑")
print(Cat.__dict__)
# {'__module__': '__main__', 'species': '猫科', '__init__': <function Cat.__init__ at 0x...>, ...}
print(c1.__dict__)
# {'name': '小白'}
print(c2.__dict__)
# {'name': '小黑'}
看到了吗?c1 和 c2 各自有独立的字典,Cat 也有自己的字典。三个字典互不干扰。理解这一点至关重要,后面几乎所有所谓的“坑”,根源都在这里。
3.2 属性查找顺序:实例要不到,才去找类
当你写 c1.species 的时候,Python 的查找路径是这样的:
- 先到
c1.__dict__里找,看有没有species这个键; - 没找到,再去
Cat.__dict__里找; - 如果类里也没有,就继续去父类找,一直找到最顶级的
object; - 全找不到,抛
AttributeError。
因为 c1 的实例字典里没有 species,所以字典会落在类属性 "猫科" 上,打印出来就是 "猫科"。
但是注意下面这行操作,它是无数人的疑惑点:
python复制c1.species = "宠物猫"
print(c1.species) # 宠物猫
print(c2.species) # 猫科
print(Cat.species) # 猫科
print(c1.__dict__) # {'name': '小白', 'species': '宠物猫'}
执行 c1.species = "宠物猫",Python 不会去修改类属性,而是直接在 c1 的实例字典里新建了一个 species 键。从那以后,c1.species 的查找就命中了自己的实例属性,把它“遮蔽”了。c2 和 Cat 完全不受影响。
这个机制往好了说是灵活,往坏了说是容易产生误解。很多新手想“给所有对象统一改一个默认值”,结果只写了一个实例的赋值,其他对象一点变化都没有,然后怀疑自己是不是写错了。记住:给实例属性赋值,永远只影响这个实例;想改类属性,要用类名去改,比如 Cat.species = "新值"。
3.3 一个经典陷阱:可变对象作为类属性
理解了命名空间之后,一个经典坑就浮出水面了。看看这段代码:
python复制class BadExample:
cache = [] # 这是一个类属性,所有实例共享同一个列表
def add_cache(self, item):
self.cache.append(item)
a = BadExample()
b = BadExample()
a.add_cache(1)
print(b.cache) # [1],为什么 b 也能看到?
原因很简单:a.cache 在实例字典里找不到,就找到了类的 cache 列表。append 是在那个共享列表对象上做修改,而 a 和 b 指向的是同一个列表对象,所以 b 也“看到”了修改。
如果你就是想要共享数据,这没问题;但如果你想让每个实例有自己独立的缓存,那应该是:
python复制class GoodExample:
def __init__(self):
self.cache = []
def add_cache(self, item):
self.cache.append(item)
把列表放到 __init__ 里,每次创建对象都会执行赋值,生成一个新的空列表,每个实例的 cache 就互不相干了。
3.4 判断一个对象的类型:type 还是 isinstance
游戏打 boss 的时候,你得先知道对面是哥布林还是史莱姆,代码里也一样。判断一个对象的类型,最常见的两种写法是 type(obj) == 某个类 和 isinstance(obj, 某个类)。
入门阶段,强烈建议直接用 isinstance(obj, 某个类)。原因在于,type(obj) == 某个类 要求类型完全一致,一旦涉及继承,父类对象和子类对象就无法区分;而 isinstance 会顺着继承链往上判断,子类对象也被认为“是”父类类型,这更符合直觉。比如 isinstance(True, int) 的结果是 True,因为 bool 是 int 的子类,但 type(True) == int 的结果是 False。用 isinstance 基本不会在这类细节上吃亏。
4. 类属性、实例方法、类方法、静态方法:怎么选才不纠结
4.1 类属性和实例属性怎么选
我把两种属性的区别整理成一张表,方便对照:
| 维度 | 类属性 | 实例属性 |
|---|---|---|
| 定义位置 | 直接在类内部定义 | 通常在 __init__ 里通过 self.属性 定义 |
| 存储位置 | 类的 __dict__ |
实例自己的 __dict__ |
| 访问方式 | 类名访问,或实例访问 | 只能通过实例访问 |
| 修改影响 | 用类名修改,影响所有实例 | 只影响当前实例 |
| 典型用途 | 常量、所有对象共享的默认值、计数器 | 每个对象各自的数据 |
区分的核心标准就一句话:这份数据是所有对象共用的,还是每个对象各自拥有的? 物种是所有猫共用的,所以适合做类属性;名字和年龄是每只猫自己的,所以适合做实例属性。
顺带提一句,__init__ 里千万别写成 self.value = [] 之后以为这是每个对象独有的就万事大吉——只要你在方法里不重新赋值,而是对共享对象做修改,刚才 3.3 节的问题依然可能出现。用可变对象做默认状态时,先想清楚“我到底要不要共享”。
4.2 三种方法的定义和调用区别
几乎每个新手第一次看到 @staticmethod 和 @classmethod 时都会懵一下。先用一张表把它们的核心差异讲明白:
| 方法类型 | 第一个参数约定 | 能访问实例属性吗 | 能访问类属性吗 | 典型用途 |
|---|---|---|---|---|
| 实例方法 | self,绑定实例 |
可以 | 可以(通过 self) | 需要操作具体某个对象的数据 |
| 类方法 | cls,绑定类 |
不可以 | 可以(通过 cls) | 批量创建对象、操作类级别状态 |
| 静态方法 | 没有特殊参数 | 不可以 | 不可以,但不强制 | 工具函数,逻辑上属于这个类但不需要数据 |
python复制class Date:
year = 2025
def __init__(self, day, month, year):
self.day = day
self.month = month
self.year = year
@classmethod
def from_string(cls, date_str):
# 用 "25-12-2024" 这种字符串创建 Date 对象
day, month, year = map(int, date_str.split("-"))
return cls(day, month, year)
@staticmethod
def is_valid(day, month, year):
# 校验日期的合法性,不需要任何对象数据
return 1 <= day <= 31 and 1 <= month <= 12
from_string 就是典型的类方法用法:它不依赖某个具体实例,但需要根据传入参数创建本类的实例。注意它的返回值是 cls(...),这里的 cls 是 Python 自动传入的“当前类”,以后如果这个类被继承,子类调用 from_string 时会自动返回子类对象,省去很多重复代码。
is_valid 是典型的静态方法用法:判断一个日期的 31 号是否合法,这件事既不需要实例数据,也不需要类数据,纯粹是一个“逻辑上归 Date 管”的工具函数。把它放在类里,主要是为了集中管理、方便调用。
4.3 设计方法时的三条经验法则
我在实际写代码时,会按这个顺序做决策:
- 方法里要用到
self.xxx,必须写成实例方法,这是绝大多数情况; - 方法里不需要实例数据,但需要用到类的属性、类级别的常量,或者需要创建/修改类本身,用类方法;
- 方法既不需要实例数据也不需要类数据,只是一个纯函数,用静态方法。
不要为了“显得面向对象”而把所有东西都塞成实例方法。一个方法如果从头到尾都没碰过 self,把它改成静态方法,代码会更清晰,别人读了也知道“这个方法不会修改对象状态”。
5. 新手最容易踩的坑:常见错误与排查技巧实录
5.1 五个高频错误逐一拆解
错误一:方法定义时忘记写 self
python复制class Dog:
def bark(): # 少了 self
print("汪汪")
执行 dog.bark() 时,Python 会把 dog 当作第一个实参传进去,和 bark() 定义时没有参数对不上,直接报 TypeError: bark() takes 0 positional arguments but 1 was given。这个报错信息经常让新手一头雾水——我明明没传参啊?其实是因为实例方法的第一参数必须留出来接收对象本身。
错误二:在 init 里写 return
Python 不允许 __init__ 返回任何值(除了 None)。它是初始化方法,不是工厂函数。如果你写 return 123,解释器直接抛 TypeError: __init__() should return None。
错误三:把类当成对象用
python复制class Calc:
def add(self, x, y):
return x + y
Calc.add(1, 2) # 报错:缺一个位置参数
正确姿势是先创建实例,再调用实例方法:c = Calc(); c.add(1, 2)。这里的逻辑还是那个:c.add(1, 2) 等价于 Calc.add(c, 1, 2),3 和 1,2 一起才凑齐三个参数。如果你想让“不用实例化就能调用”,那就该用静态方法。
错误四:修改类属性时,用实例赋值导致“改了但没完全改”
前面已经详细讲过:c1.species = "宠物猫" 只是给 c1 创建了一个实例属性,并不会修改类里的 species。如果你想统一改所有对象的默认值,必须用类名:Cat.species = "宠物猫"。
错误五:在类方法里试图访问实例属性
python复制class Foo:
def __init__(self):
self.id = 1
@classmethod
def get_id(cls):
return cls.id # 类里并没有 id,AttributeError
类方法拿到的 cls 是类对象,不是某个实例,自然访问不到实例属性。遇到这种需求,要么用实例方法,要么你在方法内部手动传一个实例进去。
5.2 调试类相关问题,掌握这三个工具就够了
新手遇到类相关的 bug,最容易慌乱。其实你根本不需要什么高端调试技巧,记住三个方法:
第一,print(obj.__dict__),直接看实例的命名空间里到底存了哪些属性、值是什么。绝大多数“为什么这个属性变了/没变”的问题,一看这个就明白了。
第二,dir(obj),列出对象能访问到的所有属性和方法名。如果你怀疑某方法没定义,或者某属性不存在,用 dir 找一找,重点看有没有下划线开头的魔法方法以外的名字。
第三,help(类名),查看类的文档字符串和方法签名。不过这是在写自己的类时作用更大;排查别人写的类,直接进源码看是最快的。
5.3 顺带说一句“抽象类”和“普通类”的区别
热搜词里有个“抽象类和普通类的区别”,Python 里这个概念很多新手也会碰到。抽象类就是不能被实例化的类,它存在的意义是提供一个“模板标准”:规定子类必须有某些方法,但细节由子类各自实现。普通类可以直接创建对象,而抽象类会拒绝实例化,给你报 TypeError: Can't instantiate abstract class。
抽象类需要用标准库 abc 模块定义:
python复制from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def make_sound(self):
pass
class Cat(Animal):
def make_sound(self):
print("喵")
这个 Animal 就是抽象类,它本身不能被直接创建对象,但所有继承它的子类必须实现 make_sound,否则同样无法实例化。入门阶段先知道有这个概念就好,真正用起来是在写框架、设计公共接口的时候。
5.4 空类有什么用
既然提到了 pass,再多说一句。有些时候你会写一个“什么内容都没有”的类:
python复制class Student:
pass
它有什么用?第一,可以用来当简单的数据容器,需要挂属性的时候直接动态赋值;第二,可以当自定义异常类型,配合异常捕获让错误类型更清晰;第三,在开发初期做占位符,先把结构定下来,实现以后再补。我见过很多人觉得 class ...: pass 毫无意义,其实它在快速原型阶段很实用。
6. 一点实操心得:我怎么从“看不懂类”到“随手写类”
最后分享几个我自己的体会,都是踩过坑之后才明白的。
第一个建议:别一上来就背着背语法写类,先用函数把功能跑通,再回头改成类。我印象很深,自己最初学类的时候,硬背了三天 __init__ 和 self 的语法,写出来的代码全是照葫芦画瓢。后来换了一种学法——先写一个纯函数的成绩管理程序,功能跑通了,再对着功能把代码改成类,边改边想“这里的数据是共享的还是独有的”“这个方法需要访问实例吗”。这样改了两次之后,类的基本设计思路就通了。
第二个建议:命名和格式从一开始就规规矩矩。类名用大驼峰式(StudentInfo),函数和变量用小写下划线(add_score),私有逻辑以单下划线开头(_helper)。这些不是形式主义,因为 Python 里很多机制(包括后面的继承、魔法方法)都依赖命名约定,养成好习惯能少踩很多坑。
第三个建议:给类加上 __repr__,平时调试会舒服得多。这属于一个小技巧:
python复制class Student:
def __init__(self, name, score):
self.name = name
self.score = score
def __repr__(self):
return f"Student(name={self.name!r}, score={self.score})"
s = Student("张三", 90)
print(s) # 直接输出 Student(name='张三', score=90)
有了这个方法,每次调试打印对象都能看到可读的内容,而不是一串内存地址。这只是“类与对象”冰山一角,后面还有继承里的 super()、多态、魔法方法、属性装饰器等等,都建立在今天这些基础之上。你如果已经能写出自己的第一个类、能解释清楚 self 和 __init__ 的作用,那这一篇的目的就达到了,我们下一篇再见。
