Python第七课:深入属性、封装与继承
文章目录
引言
如果把类比作一座房子的设计图,那么:
- 实例属性是每套房子里主人自己添置的家具
- 类属性是整栋楼共享的水电管道
- 封装是把电线和煤气管道藏进墙里,不让人乱碰
- 继承是在原有设计图基础上加一层楼,复用下面楼层的结构
面向对象的这几个概念,其实就藏在我们身边的生活里
本文会用代码把这些比喻落地,带你从“理解”走向“会用”
在开始之前先检查一下你的装备吧!!!
python环境不会装的看这里:从安装到Hello World:Python环境搭建完整指南
python编辑器不会装的看这里:零基础Python入门:手把手教你安装Python、新版PyCharm和VS Code
类属性 vs 实例属性
在学习此篇前,建议先看上篇:Python第六课:从零理解面向对象
在前面的文章中,我们已经学会了如何在 __init__ 方法里用 self.属性 = 值 给对象添加属性。
这种每个对象各自拥有的属性,叫作实例属性。
但有时候,我们希望某些数据是整个类共享的,比如:
- 所有狗都属于“哺乳动物”
- 记录一共创建了多少个学生对象
- 所有员工的公司名称相同
这时候就需要用到类属性。
1. 什么是类属性?
类属性是定义在类内部、方法外部的变量,它属于类本身,所有通过这个类创建的对象都共享这一份数据。
class Dog:
# 类属性
species = '哺乳动物'
def __init__(self, name):
self.name = name # 实例属性
在这个例子中,species 就是类属性,name 是实例属性。
拿更简单的例子解释:
想象一下,类就像是一个做饼干的模具,实例就是用这个模具做出来的一块块饼干。
- 类属性,就是刻在模具本身上的一个标记,比如“保质期:12个月”。
不管用这个模具做出多少块饼干,每一块饼干上都会有这个“保质期”信息,因为它们来自同一个模具。 - 实例属性,则是每一块饼干独有的特征,比如“这块是巧克力味的,那块是草莓味的”。
每块饼干可以有自己的口味,互不影响。
2. 如何访问类属性?
可以通过类名访问,也可以通过对象访问:
print(Dog.species) # 哺乳动物(通过类名)
dog1 = Dog('旺财')
dog2 = Dog('来福')
print(dog1.species) # 哺乳动物(通过对象)
print(dog2.species) # 哺乳动物(通过对象)
所有对象访问到的都是同一个 species。
3. 实例属性 vs 类属性:一张表看懂
| 对比维度 | 实例属性 | 类属性 |
|---|---|---|
| 定义位置 | 通常在 __init__ 中,用 self.属性 定义 |
直接在类内部,方法外部定义 |
| 归属 | 属于具体的对象 | 属于类本身 |
| 内存存储 | 每个对象独立保存一份 | 类对象(类的命名空间)中只保存一份,所有对象共享 |
| 访问方式 | 必须通过对象访问 | 可通过对象或类名访问 |
| 修改影响 | 只影响当前对象 | 通过类名修改会影响所有对象;通过对象修改则会创建同名的实例属性,不影响类属性 |
| 典型用途 | 描述对象特有的数据(姓名、年龄) | 描述类别共有特征(物种、常量)、计数器、默认配置 |
4. 内存示意图
类对象 Dog 的内存空间(只有一份):
+-------------------+
| species = '哺乳动物' | ← 类属性
+-------------------+
| __init__ 方法 |
| ... |
+-------------------+
对象 dog1 的内存空间:
+-------------------+
| name = '旺财' | ← 实例属性
+-------------------+
对象 dog2 的内存空间:
+-------------------+
| name = '来福' | ← 实例属性
+-------------------+
当通过 dog1.species 访问时,Python 先在 dog1 的实例空间找 species,找不到就去类空间找,所以能拿到 '哺乳动物'。
5. 类属性的典型使用场景
场景一:定义常量
class Circle:
PI = 3.14159 # 类属性,所有圆共用
def __init__(self, radius):
self.radius = radius
def area(self):
return self.PI * self.radius ** 2
场景二:计数器(记录创建了多少个实例)
class Student:
count = 0 # 类属性,记录学生总数
def __init__(self, name):
self.name = name
Student.count += 1 # 每创建一个学生,总数加1
s1 = Student('小明')
s2 = Student('小红')
s3 = Student('小刚')
print(Student.count) # 3
场景三:所有对象共享的默认配置
class Config:
debug = False # 所有地方共享同一个调试开关
6 最容易踩的坑:通过对象修改类属性
初学者常犯的错误是:想修改类属性,却通过对象去赋值,结果并没有修改类属性,反而给这个对象创建了一个同名的实例属性,掩盖了类属性。
class Dog:
species = '哺乳动物'
dog1 = Dog()
dog2 = Dog()
dog1.species = '鸟类' # ❌ 你以为在修改类属性,其实在创建实例属性
print(dog1.species) # 鸟类(现在 dog1 有自己的实例属性)
print(dog2.species) # 哺乳动物(类属性没变)
print(Dog.species) # 哺乳动物(类属性确实没变)
正确方式:如果要修改类属性,必须通过类名:
Dog.species = '爬行动物'
print(dog1.species) # 鸟类(dog1 的实例属性还在,所以优先用自己的)
print(dog2.species) # 爬行动物(dog2 没有实例属性,所以用类的)
记住:通过对象只能读取类属性,不能通过对象修改类属性。
赋值时,如果类属性存在,Python 不会修改它,而是给对象创建一个新的实例属性。
7. 总结
类属性和实例属性各有分工:
- 实例属性:描述对象的个体特征,每个对象独立。
- 类属性:描述类整体的特征或共享数据,所有对象共用。
简单来说:
- 实例属性 = 每块饼干独有的标签,各自独立。
- 类属性 = 刻在模具上的信息,所有饼干共享。
掌握它们的区别,能帮你设计出更清晰、更省内存的类。
现在你已经知道如何让数据“共享”或“私有”,但有些数据我们不仅希望共享,还希望保护起来,不让外部随意修改——这就是下一个主题:封装
封装
通过上一部分的学习,你已经能灵活运用类属性和实例属性来组织数据了。
但写代码时还会遇到一个问题:对象的某些属性,并不希望外部随意修改。
比如一个人的年龄,如果直接被外部改成负数,显然不符合逻辑;一个银行账户的余额,更不能让外部直接赋值。
这就需要引入面向对象的第二大核心概念:封装(Encapsulation)。
1. 什么是封装?
封装有两层含义:
- 把数据(属性)和操作数据的方法(行为)打包在一起——这一点我们在定义类时已经做到了。
- 隐藏对象的内部状态,仅通过定义好的接口与外界交互,防止数据被意外修改。
简单说:给属性加上“访问权限控制”,不让外部随便改。
2. 为什么需要封装?
看一个没有封装的例子:
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
s = Student('小明', 20)
print(s.age) # 20
# 外部直接修改
s.age = -5 # 年龄变成负数,不合理,但程序不报错
年龄为负数显然不符合业务逻辑,但 Python 并不会阻止这种操作。
为了保障数据的合理性,我们需要把 age “藏起来”,只允许通过特定的方法去修改,并在修改时进行校验。
3. Python 的封装哲学
Python 不像 Java 那样有严格的 private 关键字。
Python 的设计哲学是:信任程序员,通过约定而非强制来实现封装。
Python 提供了两种“提示”机制:单下划线 和 双下划线。
单下划线 _name:受保护的属性(约定)
在属性名前加一个下划线,例如 _age,表示“这是一个受保护的属性,外部不要直接访问,但如果你非要访问,我也不拦着”。
class Student:
def __init__(self, name, age):
self.name = name
self._age = age # 受保护的属性
def get_age(self):
return self._age
def set_age(self, age):
if age <= 0:
print('年龄必须大于0')
else:
self._age = age
s = Student('小明', 20)
print(s._age) # 20(虽然能访问,但 IDE 可能会警告)
s._age = -5 # 仍然能改,不报错
s.set_age(-5) # 通过方法修改,会触发校验
单下划线的作用:
- 告诉其他开发者:“这个属性是内部使用的,请别动。”
- 在 IDE 或工具中可能会给出警告。
- 它不会阻止实际访问,只是一种约定。
双下划线 __name:名称修饰
如果你想让属性更难被意外覆盖,可以用双下划线开头,例如 __age。
Python 会把这个属性名“修饰”成 _类名__age,从而在一定程度上避免直接访问。
class Student:
def __init__(self, name, age):
self.name = name
self.__age = age # 私有属性
def get_age(self):
return self.__age
def set_age(self, age):
if age <= 0:
print('年龄必须大于0')
else:
self.__age = age
s = Student('小明', 20)
# print(s.__age) # ❌ AttributeError: 'Student' object has no attribute '__age'
print(s._Student__age) # 20(通过修饰后的名字依然能访问,但不推荐)
双下划线的实际效果:
- 属性名被改成
_类名__属性名,防止子类意外覆盖。 - 外部直接通过原属性名访问会报错,但通过修饰后的名字仍能访问——不是真正的私有,只是防君子不防小人。
- 主要用于避免继承时的属性冲突。
4. 更 Pythonic 的方式:@property 装饰器
上面的 get_age() 和 set_age() 虽然实现了封装,但调用起来不够优雅:s.get_age() 和 s.set_age(18) 看起来不如 s.age 和 s.age = 18 直观。
Python 提供了 @property 装饰器,让我们可以像访问属性一样调用方法,同时保留封装的好处。
class Student:
def __init__(self, name, age):
self.name = name
self._age = age # 用单下划线表示“受保护”
@property
def age(self):
"""getter:读取年龄"""
return self._age
@age.setter
def age(self, value):
"""setter:设置年龄,带校验"""
if not isinstance(value, int):
raise ValueError('年龄必须是整数')
if value <= 0 or value > 120:
raise ValueError('年龄必须在1-120之间')
self._age = value
s = Student('小明', 20)
print(s.age) # 20,像访问属性一样调用 getter
s.age = 25 # 像赋值一样调用 setter
# s.age = -5 # ❌ ValueError: 年龄必须在1-120之间
原理:
@property把一个方法变成属性调用,s.age会执行被@property装饰的方法。@age.setter是配套的装饰器,当执行s.age = ...时自动调用。
这样既保留了简单的访问语法,又在背后实现了校验逻辑,完美体现了封装的思想。
5. 只读属性
如果你只想让外部读取属性,不允许修改,可以只定义 @property,不定义 @xxx.setter:
class Config:
def __init__(self):
self._version = '1.0.0'
@property
def version(self):
return self._version
c = Config()
print(c.version) # 1.0.0
# c.version = '2.0.0' # ❌ AttributeError: can't set attribute
这在定义配置项、常量等场景非常实用。
6. 综合案例:带封装的账户类
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance # 私有属性
@property
def balance(self):
"""余额只读,不允许直接修改"""
return self.__balance
def deposit(self, amount):
"""存款"""
if amount <= 0:
print('存款金额必须大于0')
return
self.__balance += amount
print(f'存款成功,当前余额:{self.__balance}')
def withdraw(self, amount):
"""取款"""
if amount <= 0:
print('取款金额必须大于0')
return
if amount > self.__balance:
print('余额不足')
return
self.__balance -= amount
print(f'取款成功,当前余额:{self.__balance}')
acc = BankAccount('张三', 1000)
print(acc.balance) # 1000
# acc.balance = 2000 # ❌ 不能直接赋值
acc.deposit(500) # 存款成功,当前余额:1500
acc.withdraw(2000) # 余额不足
这个例子中:
__balance被隐藏,外部无法直接修改。- 通过
deposit和withdraw方法操作余额,保证了业务逻辑的正确性。 balance属性只读,方便查询余额。
7. 总结
| 方式 | 含义 | 是否强制 | 推荐用途 |
|---|---|---|---|
name |
公有属性 | 否 | 可以直接暴露、无需保护的数据 |
_name |
受保护属性(约定) | 否(仅提示) | 内部使用,子类可访问,但外部不应直接修改 |
__name |
私有属性(名称修饰) | 一定程度避免意外覆盖 | 防止继承时属性名冲突,但不绝对安全 |
@property |
属性化访问 + 控制逻辑 | 是(通过代码实现) | 需要校验或计算后返回的属性,或只读属性 |
记住:Python 的封装靠的是程序员的自觉和良好的设计,而不是语言的强约束。使用 _ 和 @property 已经足够写出健壮的代码。
类继承
在前两部分中,我们学习了如何定义类的属性(实例属性与类属性)以及如何通过封装保护数据。这些都是针对单个类的设计。
但现实中,类与类之间往往存在关联。比如“学生”和“老师”都是“人”,他们共享姓名、年龄等属性,也都有介绍自己的行为。如果分别为它们编写独立的类,就会产生大量重复代码。
面向对象提供的解决方案是:继承(Inheritance)。
1. 什么是继承?
继承 允许我们基于一个已有的类创建一个新类。已有的类称为父类(Parent Class)或基类(Base Class),新创建的类称为子类(Child Class)或派生类(Derived Class)。
子类会自动获得父类的所有属性和方法,同时还可以添加自己的新属性和新方法,或者重写(覆盖)父类的方法。
用一句话概括:继承是为了复用代码,并建立类之间的层次关系。
2. 基本语法
定义子类时,在类名后面的括号里写上父类名:
class Parent:
pass
class Child(Parent): # Child 继承自 Parent
pass
来看一个具体的例子:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def introduce(self):
print(f'我叫{self.name},今年{self.age}岁')
class Student(Person): # Student 继承 Person
pass
s = Student('小明', 18)
s.introduce() # 我叫小明,今年18岁(子类直接使用了父类的方法)
即使 Student 类里什么也没写,它也已经有了 name、age 属性和 introduce 方法——这些都是从 Person 继承来的。
3. 子类可以做什么?
添加新的方法
子类可以在继承父类的基础上,增加自己特有的方法:
class Student(Person):
def study(self, course):
print(f'{self.name} 正在学习 {course}')
s = Student('小明', 18)
s.introduce() # 我叫小明,今年18岁
s.study('数学') # 小明 正在学习 数学
添加新的属性
如果子类需要额外的属性,可以在自己的 __init__ 中添加。但要注意:父类的 __init__ 不会自动执行,所以需要在子类的 __init__ 中手动调用父类的 __init__ 来初始化父类定义的属性。
class Student(Person):
def __init__(self, name, age, student_id):
# 调用父类的 __init__ 初始化 name 和 age
super().__init__(name, age)
# 然后添加自己的属性
self.student_id = student_id
s = Student('小明', 18, '2024001')
print(s.name, s.age, s.student_id) # 小明 18 2024001
这里用到了 super(),它的作用是获取父类的引用,从而调用父类的方法。后面会详细说明。
重写父类方法
如果父类的方法不满足子类的需求,子类可以重新定义该方法,这就叫方法重写(Override)。
class Student(Person):
def __init__(self, name, age, student_id):
super().__init__(name, age)
self.student_id = student_id
# 重写 introduce 方法
def introduce(self):
print(f'我叫{self.name},今年{self.age}岁,学号是{self.student_id}')
s = Student('小明', 18, '2024001')
s.introduce() # 我叫小明,今年18岁,学号是2024001
重写后,调用子类对象的 introduce 会执行子类的版本,父类的版本被覆盖了。
如果想在子类方法中继续使用父类方法的逻辑,可以用 super() 调用父类版本:
class Student(Person):
def __init__(self, name, age, student_id):
super().__init__(name, age)
self.student_id = student_id
def introduce(self):
# 先调用父类的方法,打印基本信息
super().introduce()
# 再补充自己的信息
print(f'我的学号是{self.student_id}')
s = Student('小明', 18, '2024001')
s.introduce()
# 输出:
# 我叫小明,今年18岁
# 我的学号是2024001
4. super() 的作用
super() 是一个特殊函数,返回一个代表父类的对象,让你能够调用父类的方法。
最常用的场景就是在子类的 __init__ 中调用父类的 __init__,确保父类定义的属性被正确初始化。同样,在其他方法中如果需要复用父类的逻辑,也可以通过 super() 调用。
为什么不用直接写 Person.__init__(self, name, age)?
在单继承下两者效果一样,但 super() 更简洁,而且在多重继承中能正确处理方法解析顺序(MRO),避免手动指定类名带来的问题。因此,始终推荐使用 super()。
5. 多重继承(简介)
Python 支持一个子类继承多个父类,称为多重继承。
语法很简单:
class A:
def method(self):
print('A method')
class B:
def method(self):
print('B method')
class C(A, B): # C 同时继承 A 和 B
pass
c = C()
c.method() # 输出什么?
输出是 A method。因为 Python 会按照**方法解析顺序(MRO,Method Resolution Order)**从左到右查找方法。可以使用 类名.__mro__ 查看顺序:
print(C.__mro__)
# (<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>)
多重继承很有用,但也容易带来复杂性,比如两个父类有同名方法时容易混淆,或者“钻石继承”问题。对于初学者,建议优先使用单继承,除非你非常清楚自己在做什么。
如果需要复用多个类的功能,有时组合是比多重继承更清晰的选择(后面会简单对比)。
6. 继承 vs 组合
继承描述的是 “是一个”(is-a) 关系:
- 学生 是 一个人
- 轿车 是 一种交通工具
组合描述的是 “有一个”(has-a) 关系:
- 汽车 有 一个引擎
- 班级 有 多个学生
组合的实现很简单:在一个类中使用另一个类的对象作为属性。
class Engine:
def start(self):
print('引擎启动')
class Car:
def __init__(self):
self.engine = Engine() # 组合
def start(self):
self.engine.start()
print('汽车启动')
什么时候用继承,什么时候用组合?
-
如果两个类确实是 is-a 关系,并且子类需要复用父类的大部分实现,用继承。
-
如果只是想复用某个类的功能,但两者没有本质的 is-a 关系,用组合更灵活,耦合度更低。
业界有句原则:“优先使用组合而不是继承”(Favor composition over inheritance)。因为组合更灵活,不容易破坏封装。初学者可以先掌握继承,但也要了解组合的存在。
7. 总结
通过继承,我们能够以更少的代码构建类之间的层次关系,实现有效的复用。但同时也要注意:
- 不要滥用继承,只有真正的 is-a 关系才适合。
- 多重继承虽然强大,但会增加复杂性,使用需谨慎。
- 学会用组合作为继承的补充。
至此,我们已经完整地学习了 Python 面向对象的三大核心:属性分类(类属性 vs 实例属性)、封装和继承。这三块拼图组合起来,你就能设计出结构清晰、易于扩展的 Python 程序了。
更多推荐




所有评论(0)