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

浙公网安备 33010602011771号