如果你已经跟着这个系列一路学到了函数和模块,应该已经感受到了代码从"一行行执行"变成"被组织起来"的那种整齐感。但今天这篇面向对象编程开讲之前,我想先抛出一个真实场景:上周我在整理一个个人图书管理脚本,写了八个函数分别管书名、价格、库存、打折和借还状态,结果函数之间全靠传参连接,三个列表的下标稍微一乱,整个脚本就崩了。也正是那个下午,我彻底理解了为什么要学"类"和"对象"。这并不是让你去背封装、继承、多态三个概念,而是为了解决一个再实际不过的问题:当一堆函数要操作同一组数据时,怎么让它们天生就绑在一起,而不是靠程序员小心维护列表下标。这篇适合已经学完函数、但还没接触过对象的朋友,我会用完整可运行的代码,带你从零写出自己的第一个类,再一步步拆开封装、继承、多态,最后分享零基础写类最容易踩的几个坑。
1. 两版图书管理脚本的对比:函数做到了,但类做得更好
1.1 用函数写图书管理的真实痛点
想象你在管理一个小书架,每本书有名称、价格、库存三个信息。函数版的常见写法是用三个列表平行存放,再写几个操作函数:
book_names = ["Python零基础入门", "数据结构与算法", "图解网络"] book_prices = [59.0, 48.0, 42.0] book_stocks = [10, 5, 8] def discount(price, rate): return round(price * rate, 2) def has_stock(name): index = book_names.index(name) return book_stocks[index] > 0 print(discount(book_prices[0], 0.8)) # 47.2 print(has_stock("数据结构与算法")) # True这段代码能跑,但问题很明显。所有数据都分散在三个顶层列表里,每次写新函数都要把相关数据作为参数传来传去;只要有某处忘了维护下标的一致性,book_names[2]和book_prices[2]就不再是同一本书了。更麻烦的是,一旦"书"这个概念还要增加出版社、作者、ISBN 这些新字段,平行列表的数量会跟着膨胀,每个函数都要跟着改签名,维护成本直线上升。说白了,数据与操作数据的函数之间是割裂的,纽带完全靠人的记忆和纪律。
1.2 类和对象的关系:先有饼干模具,后有饼干
面向对象的第一口气,就是把"数据"和"操作数据的方法"放回同一个容器。为了把这件事讲透,我常用一个生活类比:类就像饼干模具,模具定义了饼干是什么形状、什么大小,但你手里拿到的从来不是模具,而是用模具压出来的一个个饼干。代码里class Book:定义的是模具,Book(...)创建出来的就是具体的饼干实例。
在中文语境里,类(Class)就是"分类、类型",对象(Object)就是"某个具体的东西"。我们不会说"我要买书",而会说"我要买《Python零基础入门》这本 59 元的书"。前者是类型,后者是实例。在代码里同样成立:Book是类,book1是对象,也叫实例。别被这两个术语吓到,记住"类是被抽象出来的模板,对象是这个模板生成的具体实体",后面所有内容都建立在这两者的关系之上。
1.3 第一个类 Book:逐行拆解定义过程
把上面的书架改写成类,第一版大概长这样:
class Book: def __init__(self, name, price, stock): self.name = name self.price = price self.stock = stock def discount(self, rate): self.price = round(self.price * rate, 2) def has_stock(self): return self.stock > 0 book1 = Book("Python零基础入门", 59.0, 10) book1.discount(0.8) print(book1.price) # 47.2 print(book1.has_stock()) # True相比前面那一版,它的清晰之处在于:所有关于一本书该有什么数据、能做什么操作的内容,都被收纳进class Book这个代码块里。__init__是一个特殊方法,名字来自 initialize,也就是"初始化",它在对象创建时自动执行,通常用来给新对象设置初始属性。self表示"当前正在操作的这个对象本尊",self.name = name的意思是"把传入的 name 存到我这个对象的 name 属性上"。
方法discount和普通函数最大的区别就是第一个参数必须是self,因为调用book1.discount(0.8)时,Python 会自动把book1作为第一个参数传进去,等价于Book.discount(book1, 0.8)。许多零基础同学第一次看到self觉得多余,其实它承担着"对象与类内部状态"之间的桥梁作用,没有它,方法就不知道要操作哪一份数据。
提示:创建对象时不需要手动调用
__init__,Book("Python零基础入门", 59.0, 10)这一行里,Python 会自动完成"创建空白对象 + 调用__init__初始化"两步,你只要保证传入参数个数和__init__定义一致即可。
2. 封装入门:从属性保护开始,而不是背概念
2.1 直接改属性会带来的问题
封装(Encapsulation)看着是个大词,翻译成人话就是:对象内部的数据不应该被外部随意改动,外部应该通过我允许的方式去读写。为什么需要这么麻烦?看个最简单的反例:
book1 = Book("Python零基础入门", 59.0, 10) book1.price = -100控制台不会报错,但你的价格变成了负一百。原因在于price只是个普通属性,外部代码可以直接给它赋值。孤立看这一行问题不大,但放到真实项目里,就可能出现"用户传了个负数、打折后又乘折扣、最终订单金额变成负数"的诡异连锁 bug。封装的核心理念,就是限制这种"随手一改"带来的不可控影响。
有同学会反驳:我小心一点不去乱改不就行了?但项目是多人协作的,三个月后你自己都可能忘了某个字段的规则。封装的本质不是不信任别人,而是把"规则"收拢到唯一的位置,让任何入口都必须经过同一套逻辑,从源头减少错误发生的可能。
2.2 @property 读写器的标准写法
Python 不提供其他语言那种强制的 private 关键字,但它提供了一套优雅的替代方案:@property。它可以把一个方法伪装成属性,让外部代码仍然用book.price这种直觉方式读写,但真正执行的是你写好的方法逻辑。
class Book: def __init__(self, name, price): self._name = name self._price = price @property def price(self): return self._price @price.setter def price(self, value): if value < 0: raise ValueError("价格不能是负数") self._price = value book1 = Book("Python零基础入门", 59.0) print(book1.price) # 59.0 book1.price = 45.0 # 走 setter print(book1.price) # 45.0这里我把真实数据存到了_price变量里,外部访问book1.price时,Python 会调用@property修饰的读取方法返回_price;赋值book1.price = 45.0时,则调用@price.setter修饰的方法,在方法内加入校验逻辑。一旦写成负数,程序直接抛异常,而不是悄悄接收错误值。
为什么不直接在外部用if value < 0检查?因为这种判断逻辑散落在每个赋值的地方,根本防不住别人从另一个模块直接操作。把校验收进 setter,是"把规则集中在一处管理"的思路,和函数的意义一脉相承。以后想调整上限、下限、精度,只需要改一个方法,所有实例的行为同步变化。
2.3 Python 的"君子协定":_x、__x 与名称改写
细心的你大概注意到了,我上面的写法用了_price。在 Python 里,单个下划线开头的属性代表"这是内部数据,请勿直接动",它只是一种约定,并不强制阻止访问。双下划线开头的属性,比如__price,则会触发"名称改写"机制,实际存的名字变成_ClassName__price,外部直接访问book.__price会报错:
class Book: def __init__(self, price): self.__price = price b = Book(59.0) print(b.__price) # AttributeError print(b._Book__price) # 59.0,改了名之后的真实名字这个机制叫 name mangling,本意是防止子类和父类之间意外覆盖同名字段,并不是为了做严格的访问控制。Python 社区信奉"我们都是成年人了",既然你看到了_price,就默认你不会乱摸内部数据。你完全可以把所有字段都改成__price这样的形式,藏得更深,但实际项目中,单下划线加@property的组合已经能覆盖九成场景,过度使用__反而会在调试、序列化、子类访问时带来额外麻烦。
注意:不要为了"看起来更封装"就把所有属性都改成
__name。Python 官方的设计哲学是"简单即可",真正需要校验的字段(价格、库存这类有业务规则的值)用@property保护起来,普通字段保持直接访问,可读性反而更高。
3. 继承:从 Book 扩展到 EBook 和 AudioBook
3.1 一个父类和两个子类:实例演示
继续图书管理系统的例子。不久后你会发现,不仅有实体书,还有电子书和有声书。电子书比实体书多一个文件大小,有声书多一个时长。最笨的办法是把类定义复制两份,改几个字段。这种"多个东西很像,只是略有差异"的需求,正是继承的主场:
class Book: def __init__(self, name, price): self.name = name self.price = price def info(self): return f"《{self.name}》 价格:{self.price}元" class EBook(Book): def __init__(self, name, price, size_mb): super().__init__(name, price) self.size_mb = size_mb def info(self): return f"《{self.name}》 电子版,大小 {self.size_mb}MB,价格:{self.price}元" class AudioBook(Book): def __init__(self, name, price, duration_min): super().__init__(name, price) self.duration_min = duration_min def info(self): return f"《{self.name}》 有声版,时长 {self.duration_min} 分钟,价格:{self.price}元" ebook = EBook("Python零基础入门", 39.0, 12) print(ebook.info()) # 《Python零基础入门》 电子版,大小 12MB,价格:39.0元 audio = AudioBook("Python零基础入门", 29.0, 180) print(audio.info()) # 《Python零基础入门》 有声版,时长 180 分钟,价格:29.0元class EBook(Book)括号里的Book表示让 EBook 继承 Book。子类自动获得父类的name、price属性和info方法,再按需添加自己的size_mb。info方法在父类和子类中同名,子类版本覆盖父类版本,这种技术叫方法重写。
3.2 super().init() 到底做了什么
子类的__init__第一行super().__init__(name, price)非常关键。super()返回一个"代表父类的代理对象",调用它的__init__(name, price),等于执行父类里写好的初始化逻辑,先把name和price这两个通用字段设置好,再执行子类自己的额外初始化。如果漏掉这一行,父类的赋值语句不会执行,子类对象就会缺少name、price属性,后续打印直接报 AttributeError。
初学阶段常见的疑问是:EBook 自己已经写了__init__,为什么还要调用父类的?因为Book.__init__里可能不止两行赋值,未来还会增加出版社、ISBN 等公共字段。通过super()把初始化链条串起来,公共逻辑只写一遍,子类之间不会各自为政。这比复制父类代码到子类再修改要健康得多,也是"单一职责"的一种体现。
3.3 组合优于继承:什么时候别用继承
不过,我并不是劝你看到相似结构就无脑上继承。在真实的项目评审里,更常见的建议是"组合优于继承"。什么是组合?就是一个类的属性中持有另一个类的对象。比如书架不应该继承书,而应该是"书架拥有许多书":
class Bookshelf: def __init__(self): self.books = [] def add_book(self, book): self.books.append(book) def total_price(self): return sum(book.price for book in self.books)继承适合表达"是一个"的关系:电子书是一本书,所以可以继承Book。书架不是书的子类,它是书的容器,应该用组合。初学者最容易犯的错,是为了复用几个方法就强行继承,结果产出一条"会游泳的鱼"式的继承链,比如让Car继承GasTank去复用加油方法,这在代码评审里一定会被打回。记住一句话:继承不是为了抄代码,而是为了保持类型关系的一致。
4. 多态与鸭子类型:Python 多态的独特脾气
4.1 同一个函数,处理不同类的对象
多态(Polymorphism)的字面意思是"多种形态",放到面向对象场景里,指同一个方法名在不同类里可以有不同实现,外部代码不关心对象具体是谁,只要它会那个方法就行。还是用一个最小例子:
class Dog: def speak(self): return "汪汪" class Cat: def speak(self): return "喵喵" def make_sound(animal): print(animal.speak()) make_sound(Dog()) # 汪汪 make_sound(Cat()) # 喵喵make_sound这个函数根本没有判断参数是 Dog 还是 Cat,它只是调用对象的speak()方法。传入狗对象和猫对象时,两个对象各自用自己的实现,输出不同的声音。这就是多态:同样的调用接口,不同的执行行为。
4.2 Python 的多态是鸭子类型
在 Java 这类静态语言里,实现多态通常要先定义一个接口或父类,再让 Dog、Cat 去实现它,编译器才会放心调用。Python 要自由得多,它采用"鸭子类型"哲学:如果它走起路来像鸭子、叫起来像鸭子,那它就是鸭子。Python 不强制检查对象是否声明过某个父类或接口,只要对象身上有speak这个方法,make_sound就照单全收。
这意味着你甚至可以让两个毫不相关的类都实现speak(),然后被同一个函数调用。它们的共同点不是血缘关系,而是"长得一样"。很多同学学完继承之后,下意识以为make_sound能同时处理 Dog 和 Cat,是因为它们继承了共同祖先。在这个例子里并不需要祖先关系,只需要方法名对得上就行。这是 Python 与 C++、Java 思路的重要差异,越早理解,写出来的代码越有 Python 味。
4.3 多态带来的实际好处
多态最直接的好处是"面向扩展开放":以后再来一只 Bird、一个 Robot,只要实现speak()方法,make_sound一行都不用改。你在真实项目里会频繁遇到这类场景:多个订单类型都实现calculate_total(),结算模块只需要统一调用方法,而不必在结算函数里写一长串if order_type == ...分支。这让主干代码保持简洁,差异逻辑分散到各个类内部,新增类型时旧代码零改动。
另一个容易被忽略的细节是,Python 内置函数本身就是多态理念的体现。比如len(),它既能统计字符串长度,又能统计列表、字典的元素个数,背后是这些类型都实现了__len__魔法方法。你在自定义类里加上__len__,len(book_shelf)就能直接返回书架上的书本数。多态到了这个层面,就不只是"三个名词"之一,而是每天都在用的语言机制。
5. 零基础写类最容易踩的四个坑
5.1 忘写 self,或把init拼错
第一个高频坑是方法定义时忘了写self:
class Book: def __init__(self, name): self.name = name def info(): # 少了 self return self.name调用book.info()时,Python 会自动把对象本身作为第一个参数传进去,可info()定义时一个参数都不收,于是报TypeError: info() takes 0 positional arguments but 1 was given。解决方法是给每个实例方法的第一位都加上self。注意self只是约定俗成的名字,你完全可以换成this,但最好不要,因为可读性也是代码的一部分。
第二个高频坑是把__init__写成了_init_或__int__。一旦拼错,创建对象时初始化逻辑不会执行,你访问属性会得到 AttributeError,而且这个错误很隐蔽,因为类定义本身不报错。遇到"明明写了赋值为什么属性不存在"的诡异情况,先检查魔法方法是不是被敲成了普通方法名。
5.2 可变默认参数:空列表会莫名共享
这个坑不仅出现在函数里,出现在类里更隐蔽:
class ShoppingCart: def __init__(self, items=[]): self.items = items cart1 = ShoppingCart() cart1.items.append("Python零基础入门") cart2 = ShoppingCart() print(cart2.items) # ['Python零基础入门']原因在于默认参数[]在函数定义时只创建一次,之后所有没传items的实例拿到的都是同一个列表对象。cart1 往里加元素,cart2 一打开就发现里面已经有东西。这绝对让人防不胜防。正确写法是把默认值设为None,在__init__里再创建新列表:
class ShoppingCart: def __init__(self, items=None): self.items = [] if items is None else items这样每次调用都会得到一个全新的空列表。凡是默认参数是列表、字典、集合这类可变对象,都要留个心眼,这几乎是每家公司的 Python 面试必问题。
5.3 类属性与实例属性混用
看这段代码:
class Book: discount_rate = 0.85 def __init__(self, name, price): self.name = name self.price = price book1 = Book("Python零基础入门", 59.0) book2 = Book("数据结构", 48.0) print(book1.discount_rate) # 0.85 book1.discount_rate = 1.0 print(book1.discount_rate) # 1.0,book1 实例上有了新属性 print(book2.discount_rate) # 0.85,book2 仍读类属性discount_rate写在__init__外面,所以它是类属性,由所有实例共享。给book1.discount_rate赋值时,Python 不会去改类属性,而是在 book1 实例上创建了一个新的实例属性把它盖住了,于是两个对象读到的值不一样。代码看起来是"同一个类但状态不同",非常容易把人绕晕。如果想修改全局折扣,应该用Book.discount_rate = 0.9来改,那才作用于类本身。零基础阶段记住最简单的规则:所有与具体对象有关的数据都放__init__里,类属性只放那些所有对象都真正共享的常量,省去后面的迷惑。
写到这里,面向对象编程的三大特性都过了一遍,每一段代码你都可以直接复制到本地跑。我自己辅导过不少零基础的朋友,最大的体会是:面向对象并不是一个需要"顿悟"的高深概念,它更像一种组织代码的习惯,把数据和操作放在一起,限制不合理的改动,保留合理的复用与扩展。学的时候不必急着把所有类都写得豪华,先从具体的业务对象开始建模,比如书、购物车、订单,再慢慢体会封装、继承、多态分别在解决什么问题。建议你跟着代码敲完后,自己定义一个"学生"类,带上姓名、分数、及格判断三个方法,跑通后再回来读一遍这篇,你会发现所有概念都串起来了。