357. Python 篇四:类、对象、继承与数据模型定义

2026.08.04

·教程python

仍然需要多读几遍,读一遍,一个小时又过去了

核心定位

本篇面向 JavaScript / TypeScript 开发者,彻底理清 Python 的类、对象、继承机制与数据模型。

在 FastAPI 等现代化后端框架中,面向对象不再是单纯的“画类图”,而是由多种职责不同的类配合协作。本篇先建立 Python OOP 的完整底座,最后收口到 FastAPI 的类职责拆解与依赖注入。

后端请求链路中各种“类”的职责分工

一次 HTTP 请求处理中,不同类型的“类”各自守住一道边界:

  • 请求模型(Pydantic BaseModel
    • 守住输入边界
    • 负责外部 JSON 数据的校验与类型转换
  • 服务类(Service Class)
    • 封装核心业务行为
    • 管理业务状态与多资源协作
  • ORM 类(SQLAlchemy / SQLModel)
    • 映射数据库表结构
    • 将单行记录转化为 Python 实体对象
  • 响应模型(Pydantic BaseModel
    • 决定哪些字段可以离开服务端
    • 过滤敏感数据并序列化输出

357. Python 篇四:类、对象、继承与数据模型定义 图表 1

定义类、创建对象与 self

Python

python
class Task:
    # __init__ 在实例创建后初始化这个实例
    def __init__(self, task_id: int, title: str) -> None:
        # self.task_id 和 self.title 都是实例属性
        self.task_id = task_id
        self.title = title
        self.done = False

    # 实例方法的第一个参数必须显式接收当前实例
    def complete(self) -> None:
        self.done = True


# 调用类,得到 Task 实例
task = Task(task_id=1, title="学习 Python 类")
task.complete()

print(task.title)  # 学习 Python 类
print(task.done)   # True

self 就是当前实例。定义方法时必须显式写出来,调用 task.complete() 时 Python 会自动把 task 传给 self

task.complete() 可以直接理解为:

python
# 两种调用的实际效果相同
task.complete()
Task.complete(task)

TypeScript

typescript
class Task {
  done = false;

  constructor(
    public taskId: number,
    public title: string,
  ) {}

  complete(): void {
    // this 由方法调用方式决定,不写进形参列表
    this.done = true;
  }
}

const task = new Task(1, "学习 Python 类");
task.complete();

补充

  • Python 创建实例不写 new,直接调用 Task(...)
  • Python 用缩进形成类体,不用 {}
  • Python 方法必须显式写 self;JS/TS 的 this 不出现在普通形参列表里。
  • self 只是约定俗成的名字,不是关键字,但不要改成别的名字。
  • Python 取方法时会得到绑定方法,因此 callback = task.complete 后再调用 callback(),仍然知道实例是 task
    • JS 把 const callback = task.complete 单独传走后,this 可能丢失;
      • 常见处理是 task.complete.bind(task) 或箭头函数。

__init__ 是初始化器,不是完整的构造过程

日常开发可以暂时把 __init__constructor 用,但机制上要知道:

  • __new__
    • 先创建并返回实例。
  • __init__
    • 再接收已经创建好的实例并初始化它。
    • __init__ 必须返回 None不能返回另一个对象

357. Python 篇四:类、对象、继承与数据模型定义 图表 2

python
class Task:
    def __new__(cls, task_id: int, title: str):
        # cls 是当前要创建实例的类
        instance = super().__new__(cls)
        print("先创建实例")
        return instance

    def __init__(self, task_id: int, title: str) -> None:
        # 再把数据写入已经创建好的实例
        print("再初始化实例")
        self.task_id = task_id
        self.title = title

普通业务类只写 __init__。只有不可变类型、自定义实例创建、缓存实例等少数场景才需要碰 __new__

和 TS 对照:

  • TS 的 constructor 是语言提供的构造入口。
  • Python 的 Task(...) 先经过类对象的调用机制,再进入 __new____init__
  • 不要在 Python 里为了“像构造函数”而主动调用 task.__init__(...)
    • 需要新对象就重新调用类。

实例属性与类属性

Python

python
class Task:
    # 类属性:所有实例通过同一个类读取
    category = "todo"

    def __init__(self, title: str) -> None:
        # 实例属性:每个实例各自保存
        self.title = title
        self.tags: list[str] = []


first = Task("学类")
second = Task("学 FastAPI")

first.tags.append("python")

print(first.category)  # todo
print(second.category) # todo
print(first.tags)      # ['python']
print(second.tags)     # []

属性查找可以先记成:

先找实例自己的属性;找不到,再沿着类和父类继续找。

最危险的坑:把可变对象写成类属性

357. Python 篇四:类、对象、继承与数据模型定义 图表 3

python
class BadTask:
    # 错误:这个列表由所有实例共享
    tags: list[str] = []


first = BadTask()
second = BadTask()

first.tags.append("python")
print(second.tags)  # ['python'],被另一个实例污染

正确写法:

python
class Task:
    def __init__(self) -> None:
        # 每次实例化都会新建一个列表
        self.tags: list[str] = []

TypeScript

typescript
class Task {
  // static 才是类本身共享的属性
  static category = "todo";

  // 实例字段会为每个实例初始化
  tags: string[] = [];
}

补充

  • 声明:
    • 类直接赋值即为类属性,无需 static 关键字
      • 同 TS 的 static 字段
  • 读写:
    • 读:
      • 实例可直接读取类属性
    • 写:
      • 但通过实例赋值会有副作用

实例方法、类方法与静态方法

Python 将类体内的函数划分为三类:

  • 实例方法:
    • 自动绑定实例 self
  • 类方法(@classmethod):
    • 自动绑定当前类 cls
  • 静态方法(@staticmethod):
    • 什么都不绑定,还原为普通函数

理解关键在于:底层的函数绑定机制,以及如何准确映射到 TS/JS 的 static 机制。

语法底层:装饰器做了什么?

  • 无装饰器(默认):
    • 通过类调用(User.method):
      • 属于普通函数,不绑定任何参数
    • 通过实例调用(user.method):
      • 属于绑定方法,自动把 user 实例作为第一个参数传入 self
  • @classmethod
    • 无论通过还是实例调用,都绑定为以当前类对象为首个参数 cls 的方法。
  • @staticmethod
    • 无论通过还是实例调用,都还原为普通函数,不会自动注入 selfcls

357. Python 篇四:类、对象、继承与数据模型定义 图表 4

区别在于 是通过类调用 还是 通过 实例调用

实例方法(Instance Method)

  • 核心用途:
    • 读写某个具体实例自身的数据与状态。
  • 参数绑定:
    • 首参固定为 self
  • TS 对照:
    • 完全等价于 TS 类中的普通原型方法(通过 this 访问实例)。
python
# Python
class User:
    def __init__(self, name: str) -> None:
        self.name = name

    def update_name(self, new_name: str) -> None:
        self.name = new_name
typescript
// TS 对照
class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  updateName(newName: string): void {
    this.name = newName;
  }
}

类方法(@classmethod

  • 核心用途:凡是需要显式操作“当前类 cls”的场景。
    • 备用构造器 / 工厂模式(配合继承实现多态实例化)。
    • 读取或修改类级别的共有属性。
  • 参数绑定:
    • 首参固定为 cls

TS 对照与关键差异

TS 的 static 方法中,this 默认指向当前调用的构造函数。如果使用 new this(...),TS 同样具备多态构造能力。

两者的真实差异在于:

  • TS 能力具备,但开发者极易习惯性硬编码 new User(...),从而丢失多态能力。
  • Python 的 @classmethod 在形参列表中直接显式声明 cls,在语法签名层面强约束了“获取当前类”。
python
# Python:使用 @classmethod 实现备用构造器(多态实例化)

class User:
    def __init__(self, name: str) -> None:
        self.name = name

    @classmethod
    def from_json(cls, data: dict) -> "User":
        # cls 代表调用该方法的类(可能是 User,也可能是其子类)
        print(f"当前传入的 cls 是: {cls}")
        
        # cls(name=...) 等价于调用当前类的 __init__ 创建新实例
        instance = cls(name=data["name"])
        
        # 必须显式 return!与 __init__ 禁止 return 不同,工厂函数必须将对象交付给外部调用者
        return instance


class VipUser(User):
    # 子类继承父类的 from_json 类方法
    pass


# 通过父类调用:cls 动态绑定为 User 类
u1 = User.from_json({"name": "张三"})
# 输出: 当前传入的 cls 是: <class '__main__.User'>

print(f"u1 的真实类型: {type(u1)}")
# 输出: u1 的真实类型: <class '__main__.User'>

print(f"u1 是否为 User 实例: {isinstance(u1, User)}")
# 输出: u1 是否为 User 实例: True

print("-" * 30)

# 通过子类调用:cls 动态绑定为 VipUser 类
u2 = VipUser.from_json({"name": "李四"})
# 输出: 当前传入的 cls 是: <class '__main__.VipUser'>

print(f"u2 的真实类型: {type(u2)}")
# 输出: u2 的真实类型: <class '__main__.VipUser'>

print(f"u2 是否为 VipUser 实例: {isinstance(u2, VipUser)}")
# 输出: u2 是否为 VipUser 实例: True

语法细节:为什么类型注解写成 "User" 字符串?

  • 现象:在类体定义内部,User 类尚未完全创建完成。直接写 -> User: 会触发 NameError: name 'User' is not defined
  • 解法:使用字符串 "User" 作为前向引用(Forward Reference),供静态类型检查工具(如 IDE、mypy)识别。
  • 终极方案:Python 3.7+ 可以在文件最开头加上 from __future__ import annotations,之后便可直接写 -> User: 而无需加引号。
typescript
// TS 对照:通过 new this(...) 实现多态实例化

class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  // this 指向调用该静态方法的类构造函数
  static fromJson(this: typeof User, data: { name: string }) {
    console.log("当前 this 构造函数是:", this.name);
    return new this(data.name);
  }
}

class VipUser extends User {}

// 父类调用
const u1 = User.fromJson({ name: "张三" });
// 控制台输出: 当前 this 构造函数是: User

console.log("u1 的类型是否为 User:", u1 instanceof User);
// 控制台输出: u1 的类型是否为 User: true

// 子类调用
const u2 = VipUser.fromJson({ name: "李四" });
// 控制台输出: 当前 this 构造函数是: VipUser

console.log("u2 的类型是否为 VipUser:", u2 instanceof VipUser);
// 控制台输出: u2 的类型是否为 VipUser: true

静态方法(@staticmethod

  • 核心用途:
    • 将一个与类存在逻辑关联、但完全不碰实例(self)也不碰类(cls)的纯工具函数收纳在类命名空间下。
  • 参数绑定:无隐式参数。

TS 对照

TS 的 static 关键字同时承担了 Python 中 @classmethod@staticmethod 的双重角色:

  • 若 TS 的 static 方法使用 this(当作构造函数或访问静态属性),概念映射为 Python 的 @classmethod
  • 若 TS 的 static 方法完全不碰 this(纯工具函数),概念映射为 Python 的 @staticmethod
python
# Python:纯工具逻辑
class Validator:
    @staticmethod
    def is_valid_email(email: str) -> bool:
        return "@" in email and "." in email
typescript
// TS 对照:纯工具逻辑(不使用 this)
class Validator {
  static isValidEmail(email: string): boolean {
    return email.includes("@") && email.includes(".");
  }
}
  • 选型建议:
    • 在 Python 中,如果函数既不用 self 也不用 cls,且无需在类命名空间中隔离,优先直接定义为模块顶级函数,比使用 @staticmethod 更符合 Python 规范。

“私有”只是约定和名称改写

Python

python
class TaskService:
    def __init__(self) -> None:
        # 单下划线:约定为内部实现,外部仍然能访问
        self._next_id = 1

        # 双下划线:触发名称改写,不是真正不可访问
        self.__token = "internal"

    def create_id(self) -> int:
        task_id = self._next_id
        self._next_id += 1
        return task_id

TypeScript

typescript
class TaskService {
  // private 主要由 TypeScript 类型系统限制访问
  private nextId = 1;

  // `#token` 是 JavaScript 运行时私有字段
  `#token` = "internal";
}

补充

  • _name
    • 告诉调用者“这是内部实现”,解释器不阻止访问。
    • 君子之约而已
  • __name
    • 会改写成近似 _ClassName__name,主要用于避免子类意外撞名。
  • __name 仍可被外部绕过,不等于 JS 的 #name
  • __init____repr__ 这种前后都有双下划线的是 Python 预留的特殊方法,和只在开头写双下划线不是一回事。

@property@<prop>.setter 控制属性读写

解决的核心问题

  • 消除无意义的 Getter/Setter 方法:
    • 无需像 Java/C++ 那样事先写满 get_title()set_title()
  • 保持外部接口一致:
    • 外部始终以 task.title(读取)和 task.title = "xxx"(赋值)的形式调用,内部平滑替换为方法逻辑。
  • 保护底层字段:
    • 通常配合下划线约定字段(如 self._title),防止外部绕过校验直接修改。

语法结构与核心组件

  • Getter(读取器):
    • 使用 @property 装饰,定义读取逻辑。
  • Setter(写入器):
    • 使用 @<prop_name>.setter 装饰,定义赋值校验逻辑。必须与 Getter 方法同名。
  • Deleter(删除器):
    • (可选)使用 @<prop_name>.deleter 装饰,定义执行 del task.title 时的清理逻辑。

357. Python 篇四:类、对象、继承与数据模型定义 图表 5

代码 Demo 与运行验证

python
# Python:使用 @property 和 @setter 管理属性访问

class Task:
    def __init__(self, title: str) -> None:
        # 内部真正的存取变量约定加上单下划线 _title
        self._title = title.strip()

    # Getter:读取 task.title 时隐式触发
    @property
    def title(self) -> str:
        print("触发 Getter:读取 title")
        return self._title

    # Setter:给 task.title = "xxx" 赋值时隐式触发
    @title.setter
    def title(self, value: str) -> None:
        print(f"触发 Setter:准备写入新值 '{value}'")
        normalized = value.strip()
        if not normalized:
            raise ValueError("title 不能为空")
        self._title = normalized

    # Deleter:执行 del task.title 时隐式触发
    @title.deleter
    def title(self) -> None:
        print("触发 Deleter:清理 title")
        del self._title


task = Task("  初始任务  ")

# 触发 Getter
print(f"当前标题: {task.title}")
# 输出: 触发 Getter:读取 title
# 输出: 当前标题: 初始任务

print("-" * 30)

# 触发 Setter 赋值与校验
task.title = "  更新后的任务  "
# 输出: 触发 Setter:准备写入新值 '  更新后的任务  '

print(f"更新后标题: {task.title}")
# 输出: 触发 Getter:读取 title
# 输出: 更新后标题: 更新后的任务

print("-" * 30)

# 触发 Deleter
del task.title
# 输出: 触发 Deleter:清理 title
typescript
// TS 对照:使用 get / set 关键字

class Task {
  `#title:` string;

  constructor(title: string) {
    this.#title = title.trim();
  }

  // Getter
  get title(): string {
    console.log("触发 Getter");
    return this.#title;
  }

  // Setter
  set title(value: string) {
    console.log("触发 Setter");
    const normalized = value.trim();
    if (!normalized) {
      throw new Error("title 不能为空");
    }
    this.#title = normalized;
  }
}

const task = new Task("  初始任务  ");
console.log("当前标题:", task.title);
task.title = "  更新后的任务  ";

关键踩坑点:无限递归(RecursionError)

在 Getter 或 Setter 内部,切记不能直接操作与 @property 同名的属性:

python
class WrongTask:
    @property
    def title(self) -> str:
        # 错误!内部又访问了 self.title,会再次触发 @property Getter,导致死循环抛出 RecursionError
        return self.title

正确做法是必须在内部维护一个下划线前缀的属性(如 self._title)。

使用建议

  • 适用场景:
    • 简单属性的读写校验、计算属性(如由 first_namelast_name 拼接 full_name)、只读属性(只写 @property 不写 @setter)。
  • 避免滥用:
    • 耗时较长、涉及网络 I/O、数据库查询或带有复杂副作用的操作,应明确写成普通方法(如 fetch_title()),不要隐藏在属性读取之后。

继承、重写与 super()

Python

python
class Task:
    def __init__(self, task_id: int, title: str) -> None:
        self.task_id = task_id
        self.title = title

    def label(self) -> str:
        return f"{self.task_id}: {self.title}"


class UrgentTask(Task):
    def __init__(self, task_id: int, title: str, deadline: str) -> None:
        # 让继承链中的下一个实现完成父类初始化
        super().__init__(task_id=task_id, title=title)
        self.deadline = deadline

    # 重写父类方法
    def label(self) -> str:
        return f"[紧急] {super().label()},截止 {self.deadline}"

TypeScript:extends

typescript
class UrgentTask extends Task {
  constructor(
    taskId: number,
    title: string,
    public deadline: string,
  ) {
    super(taskId, title);
  }

  override label(): string {
    return `[紧急] ${super.label()},截止 ${this.deadline}`;
  }
}

补充

  • Python 继承写成 class UrgentTask(Task):,不用 extends
  • Python 没有 TS 的 override 关键字;是否正确重写主要靠类型检查器、测试和代码审查。
  • Python 支持多继承:
    • class C(A, B):方法到底找谁由 C.mro() 给出的 MRO 决定。

什么是 MRO 与 super() 的真实含义?

MRO 是 Method Resolution Order(方法解析顺序)的缩写。
当一个类继承了多个父类时,Python 需要一套明确的规则来决定成员和方法的查找路线。

  • 查找顺序:C.mro() 会返回一个有序的类列表(例如 [C, A, B, object]),Python 在寻找属性或方法时,会严格自左向右按该列表顺序查找,谁先拥有就优先使用谁的。
  • super() 的真实含义:super() 并不是直接调用父类,而是调用 MRO 列表中的下一个类。在多继承(如菱形继承)中,这会导致 super() 调用到一个看起来跟当前类没有直接继承关系的兄弟类。

357. Python 篇四:类、对象、继承与数据模型定义 图表 6

  • 普通业务代码优先使用“组合”代替多继承,继承关系保持浅显;
  • 多继承和 MRO 复杂的链条一旦出现,维护成本会急剧增加

dataclass、Pydantic 模型与普通类不是一回事

在 Python 中,处理“数据”有三种主要方式:普通类、dataclass 和 Pydantic BaseModel

它们并非相互替代,而是为了解决不同阶段、不同场景下的具体痛点。

四种“数据与结构定义方式”核心定位速查

  • 普通类(Plain Class)
    • 背景定位:Python 原生的面向对象基石
    • 适用场景:包含复杂业务逻辑方法、带有内部状态变更的对象
    • 缺点:用于纯数据传递(DTO)时,手写初始化与打印逻辑导致样板代码冗长
  • 数据类(dataclass
    • 背景定位:Python 3.7 引入的标准库工具
    • 适用场景:内存中的纯数据容器、DTO 对象(只存数据,少写样板代码)
    • 缺点:只负责自动生成构造与打印代码,默认不做运行时类型校验与转换
  • Pydantic 模型(BaseModel
    • 背景定位:目前最流行的第三方数据校验框架(FastAPI 的数据基石)
    • 适用场景:Web API 接口输入输出、配置文件读取、JSON 数据解析等外部数据边界
    • 核心优势:强大的运行时数据校验、类型解析与自动转换
  • 结构化协议(Protocol
    • 背景定位:Python 3.8 引入的鸭子类型协议(结构类型系统)
    • 适用场景:不需要显式继承,只要方法签名对得上就能被类型检查器认可
    • TS 对照:最接近 TS 中的 interface

357. Python 篇四:类、对象、继承与数据模型定义 图表 7

普通类(Plain Class):手写样板代码的痛苦

在没有 dataclass 前,定义一个简单的纯数据对象,需要手写大量的 __init____repr__(打印信息)、__eq__(相等比较):

python
class PlainTask:
    def __init__(self, task_id: int, title: str, done: bool = False) -> None:
        self.task_id = task_id
        self.title = title
        self.done = done


t1 = PlainTask(1, "写代码")
print(t1)
# 输出: <__main__.PlainTask object at 0x1023a4510> (看不起具体属性内容!)

t2 = PlainTask(1, "写代码")
print(t1 == t2)
# 输出: False (即使属性全一样,由于默认比较内存地址,仍为 False!)

@dataclass:标准库的“免样板代码”利器

为了解决普通类装载数据时的笨重,Python 3.7 引入了标准库 @dataclass 装饰器。

核心作用

自动为你生成 __init__、友好可读的 __repr__ 打印、以及基于属性值比较的 __eq__

python
from dataclasses import dataclass, field

@dataclass(slots=True)
class DataTask:
    task_id: int
    title: str
    done: bool = False
    # 可变默认值(如 list/dict)必须使用 default_factory,防止所有实例共享同一个列表
    tags: list[str] = field(default_factory=list)


t1 = DataTask(1, "写代码")
print(t1)
# 输出: DataTask(task_id=1, title='写代码', done=False, tags=[]) (自动生成清晰的打印!)

t2 = DataTask(1, "写代码")
print(t1 == t2)
# 输出: True (自动按属性值比较,不再死板比较内存地址!)

什么是 slots=True?

  • 默认的 Python 类:每个实例对象底层都有一个叫 __dict__ 的字典,用来保存它的所有属性。因为字典支持动态添加任意新属性,所以很灵活,但代价是内存开销很大(每个对象需要额外消耗约 100~150 字节的字典结构开销)。
  • slots=True 的作用:告诉 Python 放弃动态字典,只在内存中留出固定几个字段(task_idtitle 等)的存储空间。
  • 带来的两个好处:
    • 极大节省内存:如果程序在内存里需要一次性加载几十万个数据对象,内存占用能降低 30% 到 50%。
    • 防止意外写错属性:因为固定了字段,外部如果尝试给实例添加未定义的属性(比如 t1.titlee = "..." 拼写错误),Python 会直接抛出 AttributeError,防止悄悄创建了一个垃圾属性。

关键边界:它不等于“类型校验”

许多人容易误以为加了类型注解 task_id: intdataclass 就会校验类型。

实际上,dataclass 运行时默认完全不拦截非法类型

python
# 传入字符串 "不是整数",运行时默认完全不报错!
bad_task = DataTask(task_id="不是整数", title="测试")
print(bad_task)
# 输出: DataTask(task_id='不是整数', title='测试', done=False, tags=[])

Pydantic BaseModel:FastAPI 的运行时数据边界

当进入 Web 开发(如 FastAPI),前端传来的 JSON 永远是外部不安全的数据。此时只用 @dataclass 是不够的,你需要 Pydantic

核心定义与作用

Pydantic 是一个第三方库,通过继承 pydantic.BaseModel,提供运行时强力数据校验与类型自动转换(Parsing)。

代码 Demo 与校验验证

python
from pydantic import BaseModel, Field

class TaskSchema(BaseModel):
    task_id: int
    title: str = Field(min_length=1, max_length=50) # 约束字符串长度
    done: bool = False


# 1. 自动类型转换(把字符串 "100" 自动转为整数 100)
t1 = TaskSchema(task_id="100", title="学习 FastAPI")
print(t1)
# 输出: task_id=100 title='学习 FastAPI' done=False

print(f"task_id 的实际类型: {type(t1.task_id)}")
# 输出: task_id 的实际类型: <class 'int'>


# 2. 运行时类型校验拦截(传入不合格数据直接抛出 ValidationError 异常)
try:
    TaskSchema(task_id="abc", title="")
except Exception as e:
    print("触发校验失败报错:")
    print(e)
    # 输出: 报错信息,清晰指明 task_id 需要合法整数,title 长度不能小于 1

Protocol:最接近 TS interface 的鸭子类型协议

在 TS 中,interface 是纯粹的“结构类型系统”(只要形状一样就能赋值)。
Python 3.8 引入了 Protocol 来提供相同的能力。

对齐 TS 认知:名义类型 vs 结构类型

  • 名义类型(Nominal Typing):在 Java、C++ 或传统 Python 抽象类中,如果一个函数声明了需要 Renderable 类型的参数,传入的对象所属的类必须显式继承自 Renderable(例如 class Button(Renderable)),否则类型检查器会报错。这是基于“名字和继承关系”的校验。
  • 结构类型(Structural Typing):在 TypeScript 中,接口是结构化的。如果函数要求参数满足 interface Renderable,你只需传入一个写了 render(): string 方法的任何对象即可,无需显式继承。这是基于“形状和行为”的校验,也被称为“鸭子类型(如果它走起路来像鸭子,叫起来也像鸭子,那它就是鸭子)”。

Python 的 Protocol 就是为了让静态类型检查器(如 mypy、Pyright)也支持这种“看形状、不看继承”的结构类型校验。

357. Python 篇四:类、对象、继承与数据模型定义 图表 8

核心定义

Protocol 允许你定义一个接口规范,实现类无需显式继承该 Protocol,只要实现了相同的方法签名,静态检查器就会认可。

python
from typing import Protocol

# 定义接口协议(无需被直接继承)
class Renderable(Protocol):
    def render(self) -> str:
        ...


class Button:
    def render(self) -> str:
        return "<button>按钮</button>"


def display(item: Renderable) -> None:
    print(item.render())


# Button 没有继承 Renderable,但结构匹配,可通过类型检查!
display(Button())
# 输出: <button>按钮</button>

数据结构选型总结

  • 普通类:
    • 写复杂业务逻辑、有状态交互时使用(最传统)。
  • dataclass
    • 只在内存中传递数据,追求轻量,无需校验外部输入时使用(标准库内置)。
  • Pydantic (BaseModel):
    • 处理 API 请求/响应、配置文件、需要严格校验和类型转换时使用(FastAPI 标配,TS 中对应 Zod / Valibot)。
  • Protocol
    • 做静态类型抽象、不需要显式继承的结构约束时使用(TS 中对应 interface)。

ORM 类:SQLAlchemy 与 SQLModel 的区别

在 Web 开发中,除了上述几种数据定义方式,最常接触到的就是 ORM 类(对象关系映射)

什么是 ORM 类?

用来把数据库里的表结构和单行记录,映射成 Python 的类和对象。这样你就可以直接用 Python 代码读写数据库,不用手写 SQL 语句。

在 FastAPI 生态中,最常用的两个 ORM 库是 SQLAlchemySQLModel

  • 它们的关系
    • SQLModel 是在 SQLAlchemy 和 Pydantic 之上包装出来的。它的底层就是 SQLAlchemy 负责数据库,Pydantic 负责校验。
  • 它们解决的痛点
    • 原生 SQLAlchemy 做法:
      • 你需要先定义一个 Pydantic 类用来做 API 接口的数据校验,再定义一个 SQLAlchemy 类做数据库映射。这两个类里的字段高度重复,修改字段需要改两份代码。
  • SQLModel 做法:
    • 把这两者合二为一。你定义一个 SQLModel 类,它既是 Pydantic 模型,也是 ORM 模型,只需要定义一次,两处复用。
  • 它们的使用决策
    • 常规的 FastAPI 项目,优先选择 SQLModel,可以大幅减少重复的样板代码,体验更顺畅。
  • 非常庞大、关联关系极度复杂、需要深度定制数据库特性的项目,直接使用原生的 SQLAlchemy 2.0 配合原生 Pydantic 自由度更高,控制边界更清晰。

双下划线方法(Dunder Methods):让对象接入 Python 语法

概念科普与名词解释

  • Dunder 的由来:
    • Double Underscore(双下划线)的简称。指的是前后各有两个下划线的方法(如 __init__)。
  • 核心作用:
    • Python 的语法糖基石(操作符重载)。
    • 解释器遇到内置语法(如 len(obj)print(obj)for in 循环)时,会在底层隐式调用对应的 Dunder 方法。
  • 常用魔法方法速查:
    • __init__
      • 构造初始化方法(创建对象时自动调用)。
    • __str__
      • 字符串转化方法。str(obj)print(obj) 时给用户看的易读输出。
    • __repr__
      • 调试输出方法。在终端直接回车或查看对象列表时显示的开发调试信息。
      • 即 print 输出
    • __len__
      • 长度获取方法。调用 len(obj) 时触发。
    • __iter__
      • 迭代器协议方法。让对象支持 for item in obj 循环遍历。
    • __eq__
      • 相等比较方法。当执行 obj1 == obj2 时触发。
    • __call__
      • 可调用对象方法。让类实例可以像普通函数一样使用 obj() 被直接调用。

代码 Demo 与内置语法接轨

python
# Python:使用 Dunder 方法让自定义类接入 Python 内置语法

class TaskCollection:
    def __init__(self, tasks: list[str]) -> None:
        self._tasks = tasks

    # 接入 len() 语法
    def __len__(self) -> int:
        return len(self._tasks)

    # 接入 for ... in 循环遍历语法
    def __iter__(self):
        return iter(self._tasks)

    # 接入 print() 与调试输出
    def __repr__(self) -> str:
        return f"TaskCollection(total={len(self)})"


collection = TaskCollection(["任务一", "任务二"])

# 1. 触发 __len__
print(f"任务数量: {len(collection)}")
# 输出: 任务数量: 2

# 2. 触发 __repr__
print(f"集合对象: {collection}")
# 输出: 集合对象: TaskCollection(total=2)

# 3. 触发 __iter__
for task in collection:
    print(f"当前遍历: {task}")
# 输出: 当前遍历: 任务一
# 输出: 当前遍历: 任务二

什么时候用类,什么时候只用函数和模块

选型决策:什么时候用类,什么时候用函数?

  • 选择使用
    • 同一组数据和行为必须绑定在一起
    • 一个对象要维护明确的生命周期或状态
    • 需要通过依赖注入替换实现
    • 需要利用某种协议、继承关系或 Python 数据模型
  • 优先使用函数和模块
    • 逻辑只依赖参数,返回结果,不保存状态
    • 只是把几个工具函数分组
    • 写成类后几乎全是 @staticmethod
    • 唯一的理由是“传统面向对象惯性”

FastAPI 后端分工边界速查

  • 路由函数:连接 HTTP 请求与业务调用
  • Pydantic 模型:校验外部输入,约束响应输出
  • 普通函数:完成无状态转换逻辑
  • 普通类:封装有状态协作、资源或一组业务行为
  • Depends:声明依赖提供者(按请求创建或复用,不等于全局单例)
  • ORM 类:映射数据库表与单行记录
  • Session:数据库工作单元,通过依赖按请求管理

补充:针对 JS/TS 开发者的 Python 类机制避坑指南

这里整理了几个直接影响真实编码、类型检查和 FastAPI 开发的底层机制。这些地方如果用 JS/TS 的思维来套,非常容易写出 Bug。

避坑一:类里面的代码在导入时就会执行(类本身也是个运行时对象)

  • 与 JS/TS 对比:
    • 在 JS/TS 中,class 是一份静态声明。但在 Python 中,类是一个真正的运行时对象。
  • 执行时机:
    • 当 Python 执行到 class Task:
      • 这行代码时,会立即从上到下执行类体内部的所有代码,然后再创建出这个类。
  • 典型表现:
    • 类属性的默认值计算、直接在类体里执行的表达式,都会在导入(import)该模块时被触发,而不是在实例化时触发。
python
def load_default_category() -> str:
    print("只要导入这个文件,这行字就会立即打印!")
    return "todo"


class Task:
    # 模块首次加载、还没实例化时,这行代码就会被执行并调用函数!
    category = load_default_category()

    def __init__(self, title: str) -> None:
        self.title = title
  • 核心警示:
    • 千万不要在类体内部直接写数据库连接、网络请求等有副作用的代码(除了定义方法和常量)。
    • 任何地方只要导入这个文件,这些代码就会立刻同步运行,导致测试、启动或部署时发生各种难以预料的问题。

避坑二:类型注解只是摆设,不代表会自动进行运行时校验

  • 与 TS 对比:
    • TypeScript 编译后擦除类型;
    • Python 的类型注解虽然保留在运行时,但默认情况下解释器完全忽略它
  • 运行时表现:
    • 即使声明了 title: str,依然可以直接传入整数等任意非法类型,Python 运行时默认根本不会报错。
python
class Task:
    # 这里的 title: str 只是给 IDE 看的提示,Python 运行时根本不阻止你传入别的数据类型
    title: str

    def __init__(self, title: str) -> None:
        self.title = title


task = Task("任务一")
# Python 运行时完全允许你传入一个整数,不会报错!
task.title = 123
  • 防线划分:
    • IDE 与静态检查:
      • 类型注解主要服务于 IDE 和静态类型检查工具(如 mypy、Pyright),用于开发期提示。
    • 标准库 dataclasses:
      • 仅用于自动生成 __init__ 等模板代码,依然不会在运行时校验类型。
    • Pydantic BaseModel:
      • 只有继承自 Pydantic 的类,才会在运行时主动读取类型注解并执行强数据校验与类型自动转换。

避坑三:对象属性是完全敞开的,默认可以随便在外部加新属性

  • 与 JS/TS 对比:
    • JS/TS 类的属性是比较严格的,不能在类定义之外随便加新属性。
  • 默认表现:
    • Python 的普通对象默认是极其松散的,底层使用 __dict__ 字典来存储属性,支持在外部直接动态添加任意新属性。
python
class LooseTask:
    def __init__(self, title: str) -> None:
        self.title = title


loose = LooseTask("普通任务")
# 运行时直接塞入一个完全没声明过的 owner 属性,完全合法!
loose.owner = "liguwe"
print(vars(loose))       # 打印出所有属性:{'title': '普通任务', 'owner': 'liguwe'}
  • slots 的作用:
    • 内存减半:
      • 省去了动态字典的结构开销,对大批量对象非常划算。
    • 拼写拦截:
      • 一旦在外部访问或修改未定义字段(例如拼写错成 loose.ownerr = "..."),直接抛出 AttributeError 报错拦截。可以通过 @dataclass(slots=True) 快速开启。

避坑四:is 比较的是内存地址,== 比较的是值

  • is 检查:
    • 判断两个变量是否指向内存中的同一个对象(类似 JS 中的 === 但比它更关注地址)。
    • 最常用于跟单例判断(比如 value is None)。
  • == 检查:
    • 判断两者的内容或字段值是否相等。它在底层会调用 __eq__ 方法。
python
class PlainTask:
    def __init__(self, task_id: int) -> None:
        self.task_id = task_id

first = PlainTask(1)
second = PlainTask(1)

print(first is second)  # False:因为是两个独立的内存对象
print(first == second)  # False:普通类默认也是按内存地址比较,值相同也没用
  • 默认逻辑差异:
    • 普通类:
      • 如果没有定义 __eq__== 默认退化为 is(即使两个不同实例的属性值完全相同,== 也会返回 False)。
    • 数据模型类:
      • @dataclassPydantic 会自动生成按字段内容比较的 __eq__,所以 == 可以直接比较字段值。

避坑五:Python 3.12 的三个高频类型新语法

  • Self 类型:
    • 应用场景:用于方法返回 self(链式调用)或类方法作为工厂函数(classmethod)创建子类实例时。
    • 作用:类型检查器会自动将其推导为实际调用的子类类型,而不是固定死在父类。
  • @override 装饰器:
    • 作用:类似 TS 的 override 关键字,表示该子类方法是覆盖父类的
  • 校验机制:
    • 仅在静态类型检查(如 Pyright)阶段生效,防止重构时写错父类方法名;
    • 运行时没有任何校验或执行开销。
  • 泛型类新语法 class Box[T]
    • 作用:对应 TS 的 class Box<T>,定义泛型类时不再需要写繁琐的 TypeVar 声明。
python
from typing import Self, override

class Task:
    def __init__(self, title: str) -> None:
        self.title = title

    def rename(self, title: str) -> Self:
        self.title = title
        return self

class UrgentTask(Task):
    @override
    def rename(self, title: str) -> Self:
        return super().rename(f"[紧急] {title}")

避坑六:Protocol 与抽象基类(ABC)解决不同的问题

  • Protocol(结构类型系统):
    • 特征:关注“看起来像就行”(鸭子类型)。不需要显式继承,只要类里实现了相同名字和签名的方法,就能通过类型校验。
  • TS 对比:非常类似 TS 的 interface
    • 适用场景:最适合用于依赖注入、测试替身和第三方解耦。
  • ABC(抽象基类 / 名义类型系统):
    • 特征:关注“继承关系是否正统”。子类必须显式继承自抽象父类,并实现所有被 @abstractmethod 标记的方法。
  • 运行时拦截:
    • 若子类未实现抽象方法,Python 运行时会在实例化时直接抛错拦截。
  • 适用场景:
    • 适合在团队内部强制规范复杂的类层级关系与架构约束。

避坑七:FastAPI 的 Depends(Class) 每次请求都会创建新对象,不是全局单例

  • 与 Java/Spring 对比:
    • Spring 或 NestJS 的依赖注入通常默认是全局单例容器
  • FastAPI 运行机制:
    • 按请求构建:
      • 每次收到新的 HTTP 请求,FastAPI 都会重新解析 Depends,读取类构造函数的入参,从请求参数中完成校验和类型转换,然后实例化一个崭新的对象注入路由函数。
    • 生命周期闭环:
      • 像数据库 Session 等资源,必须使用带 yield 的依赖函数管理生命周期,不要直接作为 Service 类的属性长期共享。
    • 单请求缓存:
      • 在同一个请求生命周期内,如果多个下级依赖共享同一个 Depends(Class),FastAPI 默认会缓存该实例;但不同请求之间完全独立。
python
class Pagination:
    def __init__(self, skip: int = 0, limit: int = 100) -> None:
        self.skip = skip
        self.limit = limit

# 声明依赖
PaginationDep = Annotated[Pagination, Depends()]

@app.get("/tasks")
def list_tasks(pagination: PaginationDep):
    ...

357. Python 篇四:类、对象、继承与数据模型定义 图表 9

到这里,类这一篇真正要形成的判断

  • Python 类是运行时创建的对象,远比 TS 类型声明动态。
  • 普通类型注解主要帮助静态检查;
    • Pydantic 才负责 FastAPI 外部边界的运行时校验。
  • 优先用实例方法表达对象行为,用类方法表达多态构造,用模块函数替代没有类状态的静态工具方法。
  • 优先组合和 Protocol
    • 只有确实存在稳定的“是一种”关系和共享机制时才使用继承或 ABC。
  • FastAPI Depends 负责构建一次请求需要的对象图,不负责替业务代码决定分层,也不意味着全局单例。
  • 类解决的是状态、行为和生命周期的组织问题;如果一个对象只有数据,用 dataclass 或 Pydantic;如果逻辑无状态,用函数。

最后,一个 HTTP 请求串起来所有

从 API 接收到数据库持久化的全流程

完整的请求链路、PostgreSQL 持久化和 FastAPI 后端生态,单独整理在:【内部:一个 HTTP 请求怎样串起 FastAPI 后端开发全貌?】。

参考