引言

如果把类比作一座房子的设计图,那么:

  • 实例属性是每套房子里主人自己添置的家具
  • 类属性是整栋楼共享的水电管道
  • 封装是把电线和煤气管道藏进墙里,不让人乱碰
  • 继承是在原有设计图基础上加一层楼,复用下面楼层的结构

面向对象的这几个概念,其实就藏在我们身边的生活里
本文会用代码把这些比喻落地,带你从“理解”走向“会用”


在开始之前先检查一下你的装备吧!!!

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. 什么是封装?

封装有两层含义:

  1. 把数据(属性)和操作数据的方法(行为)打包在一起——这一点我们在定义类时已经做到了。
  2. 隐藏对象的内部状态,仅通过定义好的接口与外界交互,防止数据被意外修改。

简单说:给属性加上“访问权限控制”,不让外部随便改


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.ages.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 被隐藏,外部无法直接修改。
  • 通过 depositwithdraw 方法操作余额,保证了业务逻辑的正确性。
  • 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 类里什么也没写,它也已经有了 nameage 属性和 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 程序了。

更多推荐