Day19 · 面向对象(一)类、实例与方法

Day19 · 面向对象(一)类、实例与方法

阶段:阶段一 · 核心基础 | 前置:Day18 | 技术基准:Python 3.15.0

学习目标

  • 理解类(Class)与实例(Instance)的关系,会用 class 定义类、创建实例
  • 掌握 __init__ 构造方法与 self 参数的含义
  • 区分实例属性与类属性,理解访问控制(单下划线 _ 与双下划线 __ 名称修饰)
  • 掌握类的常量(类级全大写固定值)与类的私有变量(私有类属性)的写法与访问方式
  • 会写实例方法、类方法 @classmethod、静态方法 @staticmethod
  • 初步了解 __str__ / __repr__,完成一个银行账户小项目
  • 理清类级成员(类属性/类常量/私有类变量/类方法/静态方法)的存放与"实例字典 → 类字典 → MRO"查找链,会用 __dict__ 探查对象;掌握反射四件套 getattr/hasattr/setattr/delattr

一、面向对象初体验

前面我们学的是"面向过程":用函数一步步处理数据。当数据和操作它的函数越来越多,就需要把"数据"和"操作"打包成一个整体——这就是面向对象编程(OOP,Object-Oriented Programming)。

两个核心概念:

  • 类(Class):是一张"图纸",描述一类事物共有的属性和行为。
  • 实例(Instance):按照图纸造出来的具体对象。比如"学生"是类,"小嘉"是一个具体实例。

二、定义类与创建实例

2.1、最简单的类

class Student:
    """学生类(这是类的文档字符串)"""
    pass


# 用"函数调用"的方式创建实例
s = Student()
print(type(s))          # 输出:<class '__main__.Student'>

2.2、__init__ 构造方法与 self

__init__ 是"构造方法",创建实例时会自动调用,用来给实例设置初始属性。self 代表"当前这个实例本身",是每个实例方法的第一个参数(调用时不用你传,Python 自动传):

class Student:
    def __init__(self, name, age):
        self.name = name      # 给实例绑定属性
        self.age = age

    def say_hi(self):
        print(f"大家好,我是 {self.name},今年 {self.age} 岁")


s1 = Student("小嘉", 18)      # 创建实例时自动调用 __init__
s2 = Student("阿明", 20)

s1.say_hi()                   # 输出:大家好,我是 小嘉,今年 18 岁
s2.say_hi()                   # 输出:大家好,我是 阿明,今年 20 岁

小嘉提醒:self 这个名字只是约定俗成(就像 C++/Java 里的 this),写成别的名字语法上也能跑,但全行业都用 self,请遵守约定,否则别人读你的代码会很痛苦。

2.3、动态绑定属性

Python 是动态语言,可以随时给实例加新属性(不推荐滥用,但要知道):

s = Student("小红", 19)
s.gender = "女"              # 临时给这个实例加一个属性
print(s.gender)             # 输出:女

三、实例属性与类属性

3.1、两者的区别

  • 实例属性:在 __init__ 里用 self.xxx 定义,每个实例各有一份。
  • 类属性:直接写在类里、所有实例共享一份。
class Dog:
    species = "犬科"           # 类属性:所有狗共享

    def __init__(self, name):
        self.name = name      # 实例属性:每只狗名字不同


d1 = Dog("旺财")
d2 = Dog("来福")

print(d1.species, d1.name)     # 输出:犬科 旺财
print(d2.species, d2.name)     # 输出:犬科 来福

Dog.species = "哺乳动物·犬科"   # 改类属性,所有实例跟着变
print(d1.species)              # 输出:哺乳动物·犬科

3.2、一个常见坑

如果通过实例去"修改"类属性,其实是给这个实例新建了一个同名实例属性,并不会改到类本身:

class Counter:
    count = 0                 # 类属性

    def __init__(self):
        Counter.count += 1    # 通过"类名.属性"才是真的改类属性


a = Counter()
b = Counter()
print(Counter.count)         # 输出:2

3.3、类的常量:类级"只读"的固定值

类属性里有一类特殊的——类的常量:用全大写命名、约定"只读、不修改"的固定值。Python 没有真正的常量,靠的是"全大写 + 别去改它"的团队约定。常量的好处是:把"魔法数字"集中到一处,改一处全项目生效(本机 py -3 实测):

class Config:
    MAX_SIZE = 100          # 类常量:全大写命名,约定只读
    DEFAULT_NAME = "guest"
    TIME_OUT = 5            # 秒


print(Config.MAX_SIZE)          # 100
print(Config.DEFAULT_NAME)      # guest

# 和普通类属性一样用"类名.常量名"读取,也被所有实例共享
cfg = Config()
print(cfg.MAX_SIZE)             # 100(实例也能读到类常量)

小嘉建议:把固定值集中成类常量(或模块级全大写常量),不要在代码里散落 100、5 这种"魔法数字"。哪天要改上限,只改 Config.MAX_SIZE 一处。想真正只读,可用 Day20 的 @property 做成只读属性,或用第六节讲的 enum 枚举。

四、访问控制:_ 与 __

Python 没有真正的"私有",但用命名约定表达访问限制:

写法 含义
name 公开属性,随便访问
_name 单下划线:约定俗成的"内部使用",但外界仍可访问(君子协定)
__name 双下划线:Python 会做"名称修饰(name mangling)",改名为 _类名__name,防止子类意外覆盖
class Account:
    def __init__(self, owner, balance):
        self.owner = owner            # 公开
        self._type = "储蓄"           # 约定内部用
        self.__password = "123456"    # 名称修饰


acc = Account("小嘉", 1000)
print(acc.owner)         # 输出:小嘉
print(acc._type)          # 输出:储蓄(虽然约定不建议,但技术上能访问)

# print(acc.__password)   # AttributeError!
print(acc._Account__password)   # 输出:123456(被改名了,仍可强行访问)

小嘉提醒:双下划线不是"保险箱",它只是避免命名冲突的手段。真正想控制属性的读写,请用 Day20 会讲的 @property。

4.1、类的私有变量:私有类属性

上一节的 _ / __ 规则不仅适用于实例属性,也适用于类属性——把"类的内部状态"写成 _xxx(单下划线)或 __xxx(双下划线),外面就别乱动。私有类变量通常配合类方法来读写(本机 py -3 实测):

class Config:
    _DEFAULT_NAME = "guest"   # 私有类变量:约定内部用
    __SECRET_KEY = "hidden"   # 私有类变量:双下划线,名称修饰

    @classmethod
    def get_default(cls):
        return cls._DEFAULT_NAME

    @classmethod
    def set_default(cls, name):
        cls._DEFAULT_NAME = name

    @classmethod
    def secret(cls):
        # 类方法内部可以访问 __ 私有类变量
        return cls.__SECRET_KEY


print(Config.get_default())    # guest
print(Config.secret())         # hidden
Config.set_default("admin")    # 通过类方法改私有类变量
print(Config.get_default())    # admin

# 类外"君子协定":单下划线仍能访问(但不建议)
print(Config._DEFAULT_NAME)    # admin
# 双下划线被名称修饰,不能直接访问:
# print(Config.__SECRET_KEY)   # AttributeError!
print(Config._Config__SECRET_KEY)  # hidden(被改名了,仍可强行访问)

要点:私有类变量和私有实例属性的规则一致——单下划线是"约定内部用",双下划线触发名称修饰(_类名__变量)。区别只在"挂在类上"(通过 cls 访问)而非"挂在实例上"(通过 self 访问)。

五、三种方法

5.1、实例方法(最常用)

第一个参数是 self,操作实例属性:

class Student:
    def __init__(self, name):
        self.name = name

    def say_hi(self):                # 实例方法
        print("我是", self.name)

5.2、类方法 @classmethod

第一个参数是 cls(代表类本身),用 @classmethod 装饰。常用于"工厂方法"——提供另一种创建实例的方式:

class Student:
    def __init__(self, name):
        self.name = name

    @classmethod
    def from_string(cls, text):
        # 从 "Student:小嘉" 这样的字符串创建实例
        name = text.split(":")[1]
        return cls(name)             # cls() 等价于 Student()


s = Student.from_string("Student:小嘉")
print(s.name)        # 输出:小嘉

5.3、静态方法 @staticmethod

不需要 self 也不需要 cls,就是"放在类里的普通函数",通常放和类相关但不需要操作对象的工具函数:

class MathUtils:
    @staticmethod
    def is_even(n):
        return n % 2 == 0


print(MathUtils.is_even(4))      # 输出:True

六、__str__ 与 __repr__

默认直接打印实例,只会看到一串 __main__.Student object at 0x...,不友好。定义 __str__ 可以让 print(实例) 输出好看的内容:

class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return f"Student({self.name}, {self.age})"

    def __repr__(self):
        return f"Student(name={self.name!r}, age={self.age})"


s = Student("小嘉", 18)
print(s)                # 输出:Student(小嘉, 18) —— 调 __str__
print(repr(s))          # 输出:Student(name='小嘉', age=18) —— 调 __repr__

简单记:__str__ 给用户看,__repr__ 给开发者看(最好能"照着重建对象")。

七、小项目:银行账户类

把今天学的串起来——一个支持开户、存款、取款、查询余额、隐藏密码的银行账户类:

class BankAccount:
    """简单银行账户类"""

    bank_name = "小嘉银行"          # 类属性

    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self.balance = balance
        self.__password = "123456"      # 私有密码

    def deposit(self, money):
        if money <= 0:
            print("存款金额必须大于 0")
            return
        self.balance += money
        print(f"{self.owner} 存入 {money},余额 {self.balance}")

    def withdraw(self, money):
        if money > self.balance:
            print("余额不足")
            return
        self.balance -= money
        print(f"{self.owner} 取出 {money},余额 {self.balance}")

    def show(self):
        print(f"[{self.bank_name}] {self.owner} 账户余额:{self.balance}")

    def __str__(self):
        return f"BankAccount(owner={self.owner!r}, balance={self.balance})"


# 测试
acc = BankAccount("小嘉", 1000)
acc.deposit(500)        # 输出:小嘉 存入 500,余额 1500
acc.withdraw(200)        # 输出:小嘉 取出 200,余额 1300
acc.withdraw(9999)       # 输出:余额不足
acc.show()               # 输出:[小嘉银行] 小嘉 账户余额:1300
print(acc)              # 输出:BankAccount(owner='小嘉', balance=1300)

八、对象之间的协作:班级管理案例

真实的面向对象系统里,一个对象的属性往往是另一个类的实例(而不只是数字和字符串)。下面这个例子里,SchoolClass(班级)内部装了一堆 Student(学生)实例——这就是"对象协作":

class Student:
    def __init__(self, name, score):
        self.name = name
        self.score = score

    def __str__(self):
        return f"{self.name}({self.score})"


class SchoolClass:
    def __init__(self, class_name):
        self.class_name = class_name
        self.students = []          # 属性是一个装着 Student 对象的列表

    def add(self, student):
        self.students.append(student)

    def average(self):
        return sum(s.score for s in self.students) / len(self.students)

    def top(self):
        # max 配合 key,选出分数最高的学生对象
        return max(self.students, key=lambda s: s.score)


c = SchoolClass("三年二班")
c.add(Student("小嘉", 92))
c.add(Student("阿明", 78))
c.add(Student("小红", 96))

print(f"{c.class_name} 平均分:{c.average():.1f}")   # 输出:三年二班 平均分:88.7
print("最高分:", c.top())                              # 输出:最高分: 小红(96)

这个例子里发生了什么?c.students 里装的不是三个名字字符串,而是三个"会自己带分数、会被 str 友好打印"的学生对象。SchoolClass 不用关心学生内部怎么存分数,只管把对象收进来、算平均分、找最高分。这就是 OOP 的好处:把数据和行为各自封装好,对象之间通过干净的接口协作。

小嘉提醒:以后你会频繁看到"一个类的属性是另一个类的实例"这种写法(比如"订单类"里装"商品类"实例、"用户类"里装"地址类"实例),它是建模真实世界时最自然的方式。

九、类设计的几个实用建议

写类不是"把所有东西都塞进 __init__"就完事,有几条经验值得现在就记住:

  • 一个类只干一件事:银行账户类就管账户,不要顺手在里面再写"发邮件给客户"的逻辑——那应该是另一个类的事。
  • 属性能校验就校验:用 @property(Day20 细讲)把"分数不能是负数""余额不能被扣成负"这类规则挡在入口,而不是等出错了再到处查。
  • __init__ 只做初始化:别在构造方法里做费时的事(比如读文件、联网),否则一 new 就慢。
  • 多写 __str__:调试时 print(实例) 能直接看到关键字段,比一串内存地址好用十倍。
# 反面教材:__init__ 里干了太多事
class Bad:
    def __init__(self):
        self.data = open("huge.log", encoding="utf-8").read()  # 一创建实例就读大文件


# 正面教材:初始化保持轻量
class Good:
    def __init__(self, path):
        self.path = path

    def load(self):                       # 真要用时再调用
        return open(self.path, encoding="utf-8").read()

十·补充、静态属性与反射

前面几节我们零散地学过类属性、类常量、私有类变量、@classmethod、@staticmethod,这一节先把它们归拢成一套"类级成员体系",讲清属性到底是怎么被找到的;再引入一个框架编程里无处不在的技能——反射(Reflection)。

补.1、静态属性:类级成员体系与查找机制

1. 类级成员一览(都挂在类上,不属于某个实例)

把今天前面学过的成员按"挂在哪里"归拢一下:

成员 写法 存放位置 谁来访问
普通类属性 类体里直接写 species = "..." 类的 __dict__ 类名.属性 / 实例.属性(读)
类常量 全大写 MAX_SIZE = 100 类的 __dict__,约定只读 类名.常量
私有类变量 _xxx(约定内部)/__xxx(名称修饰) 类的 __dict__ 通常配类方法 cls.xxx
类方法 @classmethod,首参 cls 类的 __dict__ 类名.方法()
静态方法 @staticmethod,无 self/cls 类的 __dict__ 类名.方法()

小嘉提醒:实例属性存在实例自己的 __dict__ 里,每个实例一份;上面这些类级成员全部存在类的 __dict__ 里,所有实例共享同一份。用 __dict__ 直接看,比背概念清楚得多(本机 py -3 实测)。

2. 属性查找链:读的时候去哪找

访问 实例.属性 时,Python 按固定顺序找:① 先查实例自己的 __dict__;② 没有再查类的 __dict__;③ 类上也没有,就沿着 MRO(方法解析顺序,类.__mro__)逐级往上找基类。找到就停,都找不到才抛 AttributeError(本机 py -3 实测):

# Author: XiaoJia
class Animal:
    kind = "动物"                       # 基类的类属性


class Dog(Animal):
    species = "Canis lupus familiaris"   # 类属性

    def __init__(self, name):
        self.name = name                # 实例属性

    def bark(self):
        return f"{self.name}: Woof!"


d = Dog("Rex")

# 实例字典里只有自己的实例属性
print(d.__dict__)
# 输出:{'name': 'Rex'}

# 类字典里放着类属性和方法(过滤掉下划线开头的系统成员)
print([k for k in Dog.__dict__ if not k.startswith("_")])
# 输出:['species', 'bark']

# 实例上没有 kind,沿 MRO 一级级向上,在基类 Animal 上找到
print(Dog.__mro__)
# 输出:(<class '__main__.Dog'>, <class '__main__.Animal'>, <class 'object'>)
print(d.kind)
# 输出:动物

3. 读按查找链,写只写进当前对象

读是"沿链找",写(赋值/setattr)却不一样:写永远只往当前对象自己的 __dict__ 里写。所以通过实例给类属性"赋值",并不会改类,只是在实例字典里新增一个同名键,把类上的值遮蔽(shadow)掉(本机 py -3 实测;这与 3.2 的常见坑是同一回事,这里从字典层面看清它):

# Author: XiaoJia
class Animal:
    kind = "动物"                       # 基类的类属性


class Dog(Animal):
    species = "Canis lupus familiaris"   # 类属性

    def __init__(self, name):
        self.name = name                # 实例属性


d = Dog("Rex")

setattr(d, "species", "updated")   # 表面像在改类属性,实际只写进实例字典

print(d.__dict__)
# 输出:{'name': 'Rex', 'species': 'updated'}

print(d.species)        # 实例字典先命中,读到的是实例自己的 updated
# 输出:updated

print(getattr(d, "species"))   # 同样从实例字典取到 updated
# 输出:updated

print(Dog.species)      # 类上的类属性原封不动
# 输出:Canis lupus familiaris

小嘉建议:想真的改类属性,必须写 Dog.species = ...(或 setattr(Dog, "species", ...)),从类这一侧落笔;从实例一侧落笔永远只是遮蔽。

补.2、反射(Reflection):用字符串名字动态操作对象

反射指:程序在运行时不靠写死的 obj.xxx,而是拿着一个字符串形式的名字,动态地查看、取出、设置、删除对象的属性和方法。Python 提供四个内置函数来做这件事:

函数 等价写法 作用
getattr(obj, "名字"[, 默认值]) obj.名字 按字符串取出属性/方法;不存在且给了默认值就返回默认值,否则抛 AttributeError
hasattr(obj, "名字") —— 判断对象有没有这个名字,返回 True/False
setattr(obj, "名字", 值) obj.名字 = 值 动态设置属性
delattr(obj, "名字") del obj.名字 动态删除实例上的属性

先看实例上的完整用法(本机 py -3 实测):

# Author: XiaoJia
class Dog:
    species = "Canis lupus familiaris"

    def __init__(self, name):
        self.name = name

    def bark(self):
        return f"{self.name}: Woof!"


d = Dog("Rex")

# getattr:按字符串取出属性;第三个参数是"不存在时的默认值"
print(getattr(d, "name"))
# 输出:Rex
print(getattr(d, "bark")())        # 取出方法对象再调用,等价于 d.bark()
# 输出:Rex: Woof!
print(getattr(d, "age", "<不存在,返回默认值>"))
# 输出:<不存在,返回默认值>

# hasattr:判断名字是否存在
print(hasattr(d, "name"))         # 输出:True
print(hasattr(d, "age"))          # 输出:False
print(hasattr(Dog, "bark"))       # 类上的方法也能判断,输出:True

# setattr:等价于 obj.名字 = 值
setattr(d, "age", 3)
print(d.age)                      # 输出:3
setattr(d, "species", "updated") # 实例 setattr 类属性仍是遮蔽(见补.1)
print(d.species, Dog.species)
# 输出:updated Canis lupus familiaris
print(getattr(Dog, "species"))    # 从类一侧取,类属性原样
# 输出:Canis lupus familiaris

# delattr:等价于 del obj.名字
delattr(d, "age")
print(hasattr(d, "age"))          # 输出:False

反射对类本身和模块同样有效(本机 py -3 实测):

# Author: XiaoJia
import math

# 反射作用于模块:拿字符串名字取出常量和函数,再调用
print(getattr(math, "pi"))
# 输出:3.141592653589793
print(getattr(math, "sqrt")(16))
# 输出:4.0
print(hasattr(math, "nonexistent"))
# 输出:False
print(getattr(math, "nonexistent", None))   # 给了默认值 None,不报错
# 输出:None
# dir(模块) 列出全部名字,配合过滤可动态盘点模块里有什么
print([n for n in dir(math) if not n.startswith("_")][:5])
# 输出:['acos', 'acosh', 'asin', 'asinh', 'atan']

小嘉提醒:getattr(obj, "名字") 不带默认值、而这个名字又不存在时,会直接抛 AttributeError。稳妥写法是先 hasattr 判断,或给第三个参数默认值兜底。

动态调用方法是反射最常用的形态:当方法名字来自配置、用户输入或外部数据时,getattr(obj, 方法名字)() 就能把它找出来并调用,而不需要 if 名字 == "bark": d.bark() 一长串分支判断。

补.3、反射的应用场景(先混个脸熟)

今天先把四个内置函数用熟,它们在后面的框架里是底层地基,这里只点到为止:

  • 插件/框架按名字加载:配置文件里写着模块名、类名的字符串,程序运行时用 importlib + getattr 动态找到对应的类,不用把所有插件写死在代码里。
  • ORM 字段映射(Day73):数据库列名是字符串,ORM 用 getattr/setattr 把列的值动态填进实例属性。
  • Django 路由分发:URL 路径是字符串,框架按名字查找到对应的视图函数并调用。

小嘉建议:这些场景现在看懂概念即可,等学到对应章节再回头,你会发现"原来这里用的就是今天的四个函数"。

十一、dataclasses:数据类

写一个"主要就是装数据"的类时,手写 __init__、__repr__、__eq__ 全是模板活。@dataclass 装饰器自动帮你生成这些——只声明字段,剩下的交给它(本机实测)。

11.1、基本用法

# Author: XiaoJia
from dataclasses import dataclass, field, asdict, astuple

@dataclass
class Point:
    x: int                       # 字段写"名字: 类型"
    y: int = 0                   # 有默认值
    label: str = field(default="原点", repr=False)   # repr=False 不在打印里显示

p = Point(3, 4)
print(p)                    # Point(x=3, y=4)   自动生成的 __repr__
print(asdict(p))            # {'x': 3, 'y': 4, 'label': '原点'}   转字典
print(astuple(p))           # (3, 4, '原点')                      转元组

11.2、default_factory:可变默认值不共享

Day14 踩过 dict.fromkeys 共享同一个列表的坑。数据类里"默认值是列表"这种可变对象,必须用 field(default_factory=list)——每次新建实例都造一个新列表:

# Author: XiaoJia
from dataclasses import dataclass, field

@dataclass
class Group:
    name: str
    members: list = field(default_factory=list)   # 每个实例各有一个新空列表

g1 = Group("A"); g1.members.append("小嘉")
g2 = Group("B")
print(g2.members)            # []   不共享,g2 还是自己的空列表

小嘉提醒:千万别写 members: list = []——那会变成所有实例共享同一个列表,和 Day14 的 fromkeys 陷阱一模一样。可变默认值一律走 default_factory。

11.3、frozen 与 slots

frozen=True 让实例建好就不能改(类似只读值对象,呼应 Day20 补.1 的值对象);slots=True(3.10+)给数据类加上 __slots__,海量实例省内存:

# Author: XiaoJia
from dataclasses import dataclass

@dataclass(frozen=True)
class Const:
    x: int
c = Const(1)
# c.x = 2     # FrozenInstanceError: cannot assign to field 'x'

@dataclass(slots=True)
class Pt:
    x: int
    y: int
pt = Pt(1, 2)
print(pt.x, pt.y)            # 1 2
# pt.z = 3    # AttributeError:slots 类没有 __dict__,不能加新属性

11.4、replace 与 post_init

dataclasses.replace(对象, 字段=新值) 返回一个改了某字段的新副本(和 Day15 提的 copy.replace 同源);InitVar 声明"只在初始化时用、不存为字段"的参数,并在 __post_init__ 里做加工:

# Author: XiaoJia
from dataclasses import dataclass, replace, InitVar

@dataclass
class C:
    name: str
    normal: InitVar[int] = 0          # 仅初始化用,不成为字段
    def __post_init__(self, normal):   # 所有 InitVar 会作为参数传进来
        self.name = self.name.upper()

cc = C("xiaojia", normal=5)
print(cc.name)             # XIAOJIA   在 __post_init__ 里被统一改成大写
p = Point(3, 4)
print(replace(p, x=10))    # Point(x=10, y=4)

小嘉提醒:数据类被继承时,父类的字段会和子类字段合并;若子类新增字段,要注意"有默认值的字段必须排在无默认值字段后面",否则 __init__ 的参数顺序会不合法。日常用 @dataclass 替代手写"纯数据类"是最干净的现代写法。

十二、abc:抽象基类

Day14 十二讲了 collections.abc 那些"判断你是不是某容器"的现成抽象基类。abc 模块本身则让你自己定义抽象基类——规定子类"必须实现某些方法",没实现就不让实例化。

12.1、ABC 与 abstractmethod

继承 abc.ABC,再用 @abstractmethod 标记"子类必须实现"的方法。只要子类没把所有抽象方法都实现,它自己也不能被 new 出来:

# Author: XiaoJia
from abc import ABC, abstractmethod

class Shape(ABC):                 # 抽象基类
    @abstractmethod
    def area(self):               # 规定:所有图形都得能算面积
        ...

class Circle(Shape):
    def __init__(self, r): self.r = r
    def area(self): return 3.14 * self.r ** 2   # 实现了,就是具体类

print(Circle(2).area())           # 12.56
# Shape()    # TypeError: Can't instantiate abstract class Shape without an implementation for abstract method 'area'

这是一种"模板约束":父类定下契约(必须有 area),子类必须兑现,否则报错——把"接口规范"写进语言,而不是靠口头约定。

12.2、subclasshook 与 register

抽象基类还能"被动认亲"。register(类) 可以把一个类登记成某个 ABC 的虚拟子类(不用真的继承);而像 collections.abc.Sequence 那样,只要你的类实现了 __len__、__getitem__,它会通过 __subclasshook__ 自动认作子类——这就是 Day14 十二里"只实现魔术方法就被 isinstance 认出来"的底层机制。

小嘉提醒:collections.abc(容器协议)和 abc(自定义抽象基类)是配套的:前者是后者写好的一堆现成实例。写框架时用 ABC+@abstractmethod 约束插件必须实现哪些方法;判断现有对象"是不是某类容器"时用 collections.abc 配合 isinstance。

十三、inspect:接口内省的瑞士军刀

十·补充的补.2 讲了反射四件套 getattr/hasattr/setattr/delattr——那是"拿字符串名字动态访问对象成员"。inspect 模块则是另一类:静态地审视对象"长什么样"——它是什么类型、参数签名是什么、源码在哪、文档说什么。写框架、调试、做代码生成时离不开它。

13.1、判断类型与拿签名

# Author: XiaoJia
import inspect

def foo(a: int, b: str = "x") -> bool:
    """这是个示例函数。"""
    return a > 0

# 判断它到底是函数/方法/类/模块
print(inspect.isfunction(foo), inspect.ismethod(foo))   # True False
print(inspect.isclass(str), inspect.ismodule(inspect))  # True True

# 看函数签名(参数、默认值、返回标注)
print(inspect.signature(foo))        # (a: int, b: str = 'x') -> bool
print(inspect.getdoc(foo))           # 这是个示例函数。

13.2、拿源码、看成员、定位调用栈

# Author: XiaoJia
import inspect

# getmembers:列出对象的所有成员(可配合过滤)
print([n for n, _ in inspect.getmembers(foo) if not n.startswith("_")][:5])

# getsource:直接把对象的源码文本取出来
print(inspect.getsource(foo).strip().splitlines()[0])   # def foo(a: int, b: str = "x") -> bool:

# getmodule:这个对象定义在哪个模块
print(inspect.getmodule(foo).__name__)    # __main__

它还有 inspect.stack()(看当前调用栈,谁调了谁)、inspect.currentframe()(当前栈帧)等,做调试日志、中间件、AOP 切面时用来"知道自己是被谁调进来的"。

小嘉提醒:一句话区分补.2 和本节——补.2 的反射是"运行时用字符串名字去取/改对象成员"(动态访问);本节的 inspect 是"把对象/函数拆开看它的结构和定义"(静态审视)。框架靠前者按名字加载插件,靠后者读取用户函数的签名、文档和源码来生成文档或做参数校验。

13.3、3.15 视角:inspect.getdoc() 新增文档继承开关

本机探测(Python 3.13.16):inspect.signature(inspect.getdoc) 输出就是 (object),只有一个位置参数,没有任何关键字参数。下面两个新参数 3.15 才有,本机无法实测,按官方文档编写。

13.1 节用 inspect.getdoc(foo) 拿函数的文档字符串。它有个历史行为:子类自己不写 docstring 时,默认并不会自动从父类继承文档——这对子类作者其实不友好。3.15 给 getdoc 加了两个关键字参数来控制这件事:

  • inherit_class_doc: bool:查询的是一个类时,若类自身没有 docstring,是否沿 MRO 向上找最近的、有 docstring 的基类;
  • fallback_to_class_doc: bool:查询的是实例/方法、而它自己没有文档时,是否回退去取所属类的 docstring。
# Author: XiaoJia
# 仅 Python 3.15+ 可运行
import inspect

class Base:
    """基类文档。"""
    def run(self): ...

class Derived(Base):
    def run(self): ...        # 子类没写自己的 docstring

# 3.15 起:显式要求沿 MRO 继承类文档
print(inspect.getdoc(Derived, inherit_class_doc=True))           # 输出:基类文档。
print(inspect.getdoc(Derived().run, fallback_to_class_doc=True)) # 回退到类文档

小嘉提醒:写文档生成器(pdoc、Sphinx 自动 API 文档)时这两个参数很有用——子类不想重复粘贴父类说明,又希望文档站能自动补全。默认行为保持向后兼容,不显式传参时和 3.13 一致。

十四、方法对象与类/实例的特殊属性

十·补充我们讲了属性查找链(实例字典 → 类字典 → MRO)和反射四件套。这一节补几个"探查对象内部"时绕不开的特殊属性:方法对象身上的 __self__/__func__、实例的 __class__、类字典为什么是只读的、__mro__ 与 mro() 的区别,以及 __subclasses__()。它们是后面读框架源码、写元类时天天要打交道的"内部零件"。

14.1、方法对象:__self__ 与 __func__

调用 实例.方法() 时,Python 其实做了两步:先把"方法"从类字典里取出来,再把它和这个实例"绑"在一起。绑完之后得到的东西叫绑定方法(bound method),它身上带两个关键属性:

  • __self__:绑定的那个实例(类方法则绑定的是类本身)。
  • __func__:底层的普通函数(也就是类字典里真正存的那个函数对象)。

而直接从类上取方法(类名.方法名)时没有实例可绑,拿到的就是一个普通函数(unbound)。二者关系:实例.方法.__func__ 正好等于 类名.方法(本机 python -X utf8 实测)。

# Author: XiaoJia
class Student:
    def __init__(self, name):
        self.name = name
    def say_hi(self):
        return f"hi, {self.name}"

s = Student("小嘉")

# 实例取方法 -> 绑定方法(bound method)
bm = s.say_hi
print(type(bm))                       # 输出:<class 'method'>
print(bm.__self__ is s)               # 输出:True   __self__ 就是这个实例
print(bm.__func__)                   # 输出:<function Student.say_hi ...>(就是类字典里那个函数) 
print(bm())                          # 输出:hi, 小嘉

# 类取方法 -> 普通函数(unbound),没有 self
fm = Student.say_hi
print(type(fm))                       # 输出:<class 'function'>
print(fm is bm.__func__)             # 输出:True   二者是同一个函数对象
print(fm(s))                          # 输出:hi, 小嘉   手动把 s 当 self 传进去

# 绕过绑定:直接用 __func__ 调,等价于 fm(s)
print(bm.__func__(s))                # 输出:hi, 小嘉

类方法(@classmethod)也一样有 __self__,只不过它绑的是类而不是实例:

# Author: XiaoJia
class Counter:
    n = 0
    @classmethod
    def bump(cls):
        cls.n += 1
        return cls.n

cm = Counter.bump
print(type(cm))                       # 输出:<class 'method'>
print(cm.__self__ is Counter)         # 输出:True   __self__ 绑的是类
print(cm())                           # 输出:1

小嘉提醒:这就是为什么 Student.say_hi(s) 能跑通——从类上取出来的就是个普通函数,第一个参数 self 没有"自动填",得你手动传。平时写 s.say_hi() 时,Python 自动把 s 塞进了 __self__,调用时再把它作为第一个参数传给 __func__。__func__ 在写装饰器、序列化方法、做"方法转发"时经常要拿来用——拿到它就绕过了绑定。

14.2、__class__:实例的类

每个实例都有一个 __class__ 属性,指向它所属的类,和 type(实例) 完全等价:

# Author: XiaoJia
class Dog:
    def __init__(self, name):
        self.name = name

d = Dog("Rex")
print(d.__class__)                    # 输出:<class '__main__.Dog'>
print(type(d))                        # 输出:<class '__main__.Dog'>
print(d.__class__ is type(d))         # 输出:True
print(d.__class__.__name__)           # 输出:Dog   拿类名字符串

__class__ 是可以赋值的——但 CPython 会做对象布局安全检查:新旧两个类必须"内存布局兼容"(都有 __dict__、__slots__ 一致),否则直接抛 TypeError。这是为了防止你把一个实例"换"到一个结构完全不同的类上导致内存损坏(本机 python -X utf8 实测)。

# Author: XiaoJia
class Plain:     # 普通类:实例自带 __dict__
    pass
class Slotted:   # slots 类:实例没有 __dict__
    __slots__ = ("x",)

p = Plain()
try:
    p.__class__ = Slotted
except TypeError as e:
    print(e)
# 输出:__class__ assignment: 'Slotted' object layout differs from 'Plain'

小嘉提醒:日常业务代码几乎不会去给 __class__ 赋值(这属于"黑魔法"),但要知道:① type(x) 和 x.__class__ 是一回事;② x.__class__.__name__ 是运行时拿到类名字符串最常用的写法;③ 想"换"实例的类不是随便换,布局不兼容会被解释器拦下。

14.3、__dict__ 衔接:类字典是只读的 mappingproxy

十·补充的补.1 已经讲过"实例属性存实例 __dict__、类成员存类 __dict__"。这里补一个之前没展开的差异——两者的字典类型不一样:

  • 实例的 __dict__ 是普通 dict,随便读写。
  • 类的 __dict__ 是一个 mappingproxy(只读映射代理),不能直接往里加键/改键。

想改类属性,得用 类名.属性 = 值 或 setattr(类名, ...),而不是去动 类.__dict__(本机 python -X utf8 实测)。

# Author: XiaoJia
class Book:
    category = "计算机图书"
    def __init__(self, title):
        self.title = title

b = Book("Python")
print(type(Book.__dict__))            # 输出:<class 'mappingproxy'>
print(type(b.__dict__))               # 输出:<class 'dict'>
print(b.__dict__)                      # 输出:{'title': 'Python'}

# 类字典只读:直接赋值会被拒
try:
    Book.__dict__["hacked"] = 1
except TypeError as e:
    print(e)
# 输出:'mappingproxy' object does not support item assignment

# 实例字典可写
b.__dict__["extra"] = "ok"
print(b.__dict__)                      # 输出:{'title': 'Python', 'extra': 'ok'}

小嘉提醒:mappingproxy 是 Python 故意加的一层保护——类字典是所有实例共享的查找源,放任你随便改键会把类的成员表搞乱。要加/改类成员,走 Book.category = ...(或 setattr)这扇正规门;Book.__dict__ 只用来"看"。

14.4、__mro__ 元组与 mro() 方法

补.1 提过 MRO 是属性查找链。这里把两个入口讲清:

  • 类.__mro__:一个只读元组,解释器在类创建时按 C3 算法算好的查找链,存着就不动了。
  • 类.mro():一个方法,返回查找链列表;它会被元类或类本身重写,是"动态计算"的入口。

正常情况下两者内容一致;但如果你(或某个元类)重写了 mro(),__mro__ 仍然是解释器按规则算出来的那份,不会跟着你的重写走(本机 python -X utf8 实测)。

# Author: XiaoJia
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass          # 多重继承

print(D.__mro__)
# 输出:(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
print(type(D.__mro__))          # 输出:<class 'tuple'>   只读元组
print(D.mro())
# 输出:[<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]
print(D.__mro__ == tuple(D.mro()))   # 输出:True   正常情况下二者一致

# 故意重写 mro() 看后果
class E: pass
class F(E):
    @classmethod
    def mro(cls):
        return (cls, object)        # 假装查找链只有自己和 object

print(F.mro())                  # 输出:(<class '__main__.F'>, <class 'object'>)
print(F.__mro__)
# 输出:(<class '__main__.F'>, <class '__main__.E'>, <class 'object'>)
# __mro__ 仍是解释器算好的 C3 链,没被我们的 mro() 带偏

小嘉提醒:读源码时看到 Cls.__mro__ 就是在"看"这条只读链;看到 Cls.mro() 就是在"调"方法——后者能被元类拦截,是元编程干预继承顺序的钩子。日常用 __mro__ 打印调试最稳,不要去重写 mro(),除非你在写元类框架。

14.5、__subclasses__():直接子类清单

每个类都有一个 __subclasses__() 方法,返回它的直接子类列表(不含间接子类)。内部用弱引用(weakref)维护——子类被垃圾回收后,会自动从这个列表里消失,不会残留死类(本机 python -X utf8 实测)。

# Author: XiaoJia
import gc

class Base: pass
class Mid(Base): pass        # Base 的直接子类
class Leaf(Mid): pass       # Mid 的直接子类(对 Base 是间接)
class Other(Base): pass     # Base 的直接子类

print(Base.__subclasses__())   # 输出:[<class '__main__.Mid'>, <class '__main__.Other'>]
# 只列直接子类:Mid、Other;Leaf 是 Mid 的子类,不在 Base 这里出现
print(Mid.__subclasses__())   # 输出:[<class '__main__.Leaf'>]

# 弱引用:临时子类被 GC 后自动移除
class Temp(Base): pass
print(Base.__subclasses__())
# 输出:[<class '__main__.Mid'>, <class '__main__.Other'>, <class '__main__.Temp'>]
del Temp
gc.collect()
print(Base.__subclasses__())
# 输出:[<class '__main__.Mid'>, <class '__main__.Other'>]   Temp 已不在

小嘉提醒:写插件系统时常用 基类.__subclasses__() 枚举"所有注册过的插件类"——只要插件继承了基类,不用手动登记就能被发现。注意它只列直接子类,想拿全部后代得自己沿 __subclasses__() 递归下去。

练习题

  1. 定义一个 Book 类,有 title、author 两个实例属性和一个 show() 方法打印书籍信息;创建两本书并调用 show()。
  2. 给 Book 类加一个类属性 category = "计算机图书",再通过类名修改它,观察所有实例是否跟着变。
  3. 写一个 Rectangle 类,__init__ 接收宽和高,提供 area() 方法算面积;用 @classmethod 再提供一个 from_square(size) 方法创建正方形。
  4. 把银行账户类的取款方法改成"余额不足时不要静默失败,而是返回 False,成功返回 True",并测试。
  5. 思考题:_name 和 __name 有什么区别?为什么说双下划线不是"真正的私有"?
  6. 为你的 Book 类加 __str__ 方法,让 print(book) 输出 Book(书名, 作者) 而不是一串内存地址。
  7. 写一个 Settings 类:定义类常量 MAX_ITEMS = 50(全大写);再定义私有类变量 _theme = "light",并用 @classmethod 提供 get_theme() / set_theme() 读写;在类外分别验证 Settings.MAX_ITEMS、get_theme()、set_theme(),并试一下直接访问 _theme(看看能不能访问到、应不应该这样用)。
  8. 自定义一个带类属性的类,创建实例后分别打印 实例.__dict__ 和 类.__dict__(用列表推导过滤下划线开头的键);再用 setattr(实例, 类属性名, 新值) 覆盖一次,对比实例字典与类字典的变化,解释为什么类属性没变。
  9. 写一个"安全调用"小片段:接收一个对象和一个方法名字符串,先用 hasattr 判断,存在则 getattr(obj, 名字)() 调用,不存在则打印"方法不存在",全程不抛 AttributeError。

小结

  • 类是图纸、实例是具体对象;class 类名: 定义类,类名() 创建实例。
  • __init__ 是构造方法,创建实例时自动调用;self 代表当前实例,是实例方法的第一个参数。
  • 实例属性每个实例一份(self.xxx),类属性所有实例共享(直接写在类里)。
  • 类的常量:全大写命名的类属性,约定只读、集中管理"魔法数字";类的私有变量:_xxx(约定内部用)/__xxx(名称修饰),通常配类方法读写。
  • 访问控制靠约定:_name 表示内部使用,__name 触发名称修饰(不是真正私有)。
  • 三种方法:实例方法(self)、类方法(@classmethod + cls,适合工厂方法)、静态方法(@staticmethod,放在类里的工具函数)。
  • 类级成员都存放在类的 __dict__:读按"实例字典 → 类字典 → MRO 基类"查找,写只写进当前对象(实例赋值只是遮蔽类属性);反射四件套 getattr/hasattr/setattr/delattr 用字符串名字动态操作对象,是框架、插件、ORM 的底层手法。
  • __str__ 给用户看、__repr__ 给开发者看,定义后 print(实例) 才友好。
  • @dataclass 自动生成 __init__/__repr__/__eq__;可变默认值用 field(default_factory=list),frozen=True 做只读值对象,slots=True 省内存,asdict/astuple/replace 转换与派生冷拷贝,InitVar+__post_init__ 做初始化加工。
  • abc.ABC+@abstractmethod 自己写抽象基类、约束子类必须实现哪些方法;inspect 静态审视对象(signature/getsource/getdoc/getmembers/isfunction、stack),与补.2"动态属性访问"的反射四件套互补。
  • 方法对象:实例.方法 是 bound method,__self__ 绑实例(类方法绑类)、__func__ 是底层普通函数,类.方法 取出来就是 __func__;__class__ 等价 type(实例),赋值时 CPython 做布局安全检查;类 __dict__ 是只读 mappingproxy、实例 __dict__ 是普通 dict;__mro__ 是只读元组、mro() 是可重写方法;__subclasses__() 返回直接子类(弱引用维护,GC 后自动消失)。

扩展阅读

posted @ 2026-11-11 08:22  小嘉游戏  阅读(7)  评论(0)    收藏  举报