Python面向对象入门:类、对象、属性与方法

前面 12 篇我们一直在用"面向过程"的方式写代码——数据是数据,函数是函数,分开处理。对于 50 行的程序,这完全够用。但当你的程序变得复杂——一个学生管理系统有几十种数据和操作——你需要一种能把"数据"和"操作数据的方法"绑在一起的方式。这就是面向对象编程(OOP)的核心思想。

不要被"面向对象"这个名字吓到。它的底层逻辑和你日常生活中的思维方式非常接近——你看到一辆车,它是一个"对象";它有"属性"(颜色、速度、油量);它能执行"方法"(加速、刹车、转向)。在 Python 里定义一个"车"类型,和你在脑子里定义"车"这个概念的思维方式,本质上是一样的。

这篇从零开始,把类、对象、属性、方法这些概念用最直观的方式讲清楚。

一、面向对象基础

从一段没有使用类的代码开始

# 用字典表示学生
student1 = {"name": "小明", "age": 20, "score": 85}
student2 = {"name": "小红", "age": 21, "score": 92}

def get_level(student):
    s = student["score"]
    if s >= 90: return "A"
    elif s >= 80: return "B"
    elif s >= 70: return "C"
    elif s >= 60: return "D"
    return "F"

def print_student(student):
    print(f"{student['name']}{student['age']}岁,{student['score']}分,等级{get_level(student)}")

print_student(student1)
print_student(student2)

这种写法能用,但数据和操作它们的函数是分离的——student1 这个字典和 get_level 这个函数之间没有语法层面的联系。如果你不小心给 get_level 传了一个不是学生的字典(比如 {"product": "苹果", "price": 5}),只有在运行时才会报错。随着数据种类增多,这种"数据漫游——函数处理"的模式会越来越难以维护。

类:自定义的数据类型

class Student:
    """学生类:描述一个学生"""
    
    def __init__(self, name, age, score):
        self.name = name        # 属性:姓名
        self.age = age          # 属性:年龄
        self.score = score      # 属性:成绩
    
    def get_level(self):
        """根据成绩返回等级"""
        if self.score >= 90:
            return "A"
        elif self.score >= 80:
            return "B"
        elif self.score >= 70:
            return "C"
        elif self.score >= 60:
            return "D"
        return "F"
    
    def introduce(self):
        """自我介绍"""
        return f"{self.name}{self.age}岁,{self.score}分,等级{self.get_level()}"

# 创建对象(实例化)
s1 = Student("小明", 20, 85)
s2 = Student("小红", 21, 92)

print(s1.introduce())  # 小明,20岁,85分,等级B
print(s2.introduce())  # 小红,21岁,92分,等级A

现在数据(nameagescore)和操作(get_level()introduce())被封装在同一个 Student 类型里。你创建 s1 = Student("小明", 20, 85) 时,Python 自动调用 __init__ 方法初始化这个学生的所有属性。之后调用 s1.get_level() 时,self 自动指向 s1,用的是 s1 自己的数据。

理解类和对象的关系

是模板——它定义了"一个学生应该有哪些属性、能做什么"。对象是用模板造出来的具体实例——小明和小红是两个不同的学生对象,他们共享同一套方法,但有自己的独立数据。

用生活场景来理解:类就像饼干模具,对象就是用模具压出来的饼干。你只有一个模具(类),但可以压出很多块饼干(对象)。每块饼干的形状一样(结构相同),但上面的糖霜不同(数据不同)。

# 类 = 模板
class Student:
    def __init__(self, name, age, score):
        self.name = name
        self.age = age
        self.score = score

# 对象 = 用模板创建的实例
s1 = Student("小明", 20, 85)   # s1 是 Student 的一个实例
s2 = Student("小红", 21, 92)   # s2 是另一个实例

print(type(s1))          # <class '__main__.Student'>
print(isinstance(s1, Student))  # True
print(s1 is s2)          # False —— 两个不同的对象

init 方法:对象的初始化器

__init__ 是 Python 类里最特殊的方法。它不是在"创建"对象(创建由 __new__ 负责),而是在对象创建好之后初始化属性。你不用显式调用它——当你写 Student("小明", 20, 85) 时,Python 自动调用了 __init__

# 你看到的
s1 = Student("小明", 20, 85)

# Python 内部做的(简化版)
# 1. 创建一个空的 Student 对象
# 2. 调用 Student.__init__(这个空对象, "小明", 20, 85)
# 3. __init__ 给空对象设置 name、age、score 属性
# 4. 返回设置好的对象

self 就是那个"正在被初始化的对象"。在 __init__ 函数里,self.name = name 这行代码把传入的参数 name(“小明”)设成对象的 name 属性。self 这个名字不是 Python 的关键字——写成 thisme 都可以,但 Python 社区约定俗成用 self

类属性和实例属性

class Student:
    school = "第一中学"    # 类属性:所有实例共享
    
    def __init__(self, name, score):
        self.name = name   # 实例属性:每个实例独立
        self.score = score

s1 = Student("小明", 85)
s2 = Student("小红", 92)

print(s1.school)   # 第一中学 —— 通过实例访问类属性
print(s2.school)   # 第一中学 —— 同一个值

Student.school = "第二中学"   # 修改类属性
print(s1.school)   # 第二中学 —— 所有实例都看到变化
print(s2.school)   # 第二中学

类属性适合存放"所有实例共享"的数据——比如数据库连接、配置常量、计数器。实例属性适合存放"每个实例独有"的数据——姓名、成绩、年龄。初学时的一个好习惯是:凡是不确定该不该共享的数据,都放在 __init__ 里做成实例属性
在这里插入图片描述

图13-1 类与对象关系:类是模具,对象是用模具创建的具体实例。每个对象有独立的属性值,共享类的方法定义。

方法的本质

类里的函数叫方法。它们和普通函数的唯一区别是第一个参数永远是 self——它告诉方法"你在处理哪个对象的数据":

class Dog:
    def __init__(self, name):
        self.name = name
    
    def bark(self, count):
        """叫 count 声"""
        for _ in range(count):
            print(f"{self.name}:汪汪!")

dog1 = Dog("旺财")
dog2 = Dog("来福")

dog1.bark(2)   # 旺财叫 2 声 —— self 就是 dog1
dog2.bark(1)   # 来福叫 1 声 —— self 就是 dog2

# 等价于(实际上 Python 内部就是这么做的)
Dog.bark(dog1, 2)   # 类.方法(实例, 参数...)

Python 在调用 dog1.bark(2) 时,自动把 dog1 作为第一个参数传给 bark 方法。这就是为什么你在定义方法时不用写 self 这个参数在调用时的值——Python 会自动补上。

封装:用属性访问控制保护数据

面向对象的一个核心原则是封装——对象的内部数据不应该被随意修改。在 Python 里,用下划线约定来表示"这个属性是内部的,请勿直接访问":

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance   # 单下划线:约定为"内部属性"
    
    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("存款金额必须大于 0")
        self._balance += amount
        print(f"存入 {amount} 元,余额 {self._balance} 元")
    
    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("取款金额必须大于 0")
        if amount > self._balance:
            raise ValueError("余额不足")
        self._balance -= amount
        print(f"取出 {amount} 元,余额 {self._balance} 元")
    
    @property
    def balance(self):
        """余额只读属性"""
        return self._balance

account = BankAccount("小明", 1000)
account.deposit(500)         # 存入 500 元,余额 1500 元
account.withdraw(300)        # 取出 300 元,余额 1200 元
print(account.balance)       # 1200  ——可以读
# account.balance = 9999     # AttributeError ——不能直接改

_balance 前面的单下划线是一种约定——“我是内部变量,不要直接碰我”。Python 不会真的阻止你写 account._balance = 9999,但如果你这么做,就等于在跟写这个类的人说"我知道不应该,但我就是要冒险"。@property 装饰器把 balance 变成了一个只读属性——读起来像属性,背后调的是方法。

在这里插入图片描述

图13-2 self 指向当前对象:调用 stu.show() 时 Python 自动将 stu 传给 show(self) 的第一个参数。

二、进阶技巧

特殊方法:让你的对象更像 Python 内置类型

以双下划线开头和结尾的方法叫特殊方法(也叫魔法方法)。它们让自定义对象能配合 Python 内置函数使用:

class Student:
    def __init__(self, name, score):
        self.name = name
        self.score = score
    
    def __str__(self):
        """print() 和 str() 调用时显示的字符串"""
        return f"Student({self.name}, {self.score}分)"
    
    def __repr__(self):
        """在交互模式和调试时显示的字符串"""
        return f"Student('{self.name}', {self.score})"
    
    def __lt__(self, other):
        """定义 < 比较——让两个学生可以按成绩比较"""
        return self.score < other.score
    
    def __eq__(self, other):
        """定义 == 比较"""
        return self.name == other.name and self.score == other.score

s1 = Student("小明", 85)
s2 = Student("小红", 92)

print(s1)              # Student(小明, 85分) ——调用了 __str__
print(s1 < s2)         # True ——调用了 __lt__
print(s1 == s2)        # False ——调用了 __eq__

有了 __lt__,你可以直接用 sorted() 对学生列表排序。有了 __str__print() 输出的是有意义的信息而不是 <__main__.Student object at 0x...> 这样的内存地址。这些特殊方法让你的自定义类看起来、用起来都像 Python 的原生类型。

dataclass:用最少的代码写数据类

如果一个类主要是用来存数据的(像上面的 Student,属性多、方法少),Python 3.7 引入了 dataclass 让你省去大量样板代码:

from dataclasses import dataclass

@dataclass
class Student:
    name: str
    age: int
    score: float
    
    def get_level(self):
        if self.score >= 90: return "A"
        elif self.score >= 80: return "B"
        elif self.score >= 70: return "C"
        elif self.score >= 60: return "D"
        return "F"

s1 = Student("小明", 20, 85)
s2 = Student("小红", 21, 92)

# 自动生成了 __init__、__repr__、__eq__ 等方法
print(s1)              # Student(name='小明', age=20, score=85)
print(s1 == s2)        # False
print(s1 == Student("小明", 20, 85))  # True ——自动比较所有字段

三行类型注解 name: strage: intscore: float 省去了你手写 __init____repr____eq__ 的十几行代码。对于以数据为中心的类,dataclass 是首选——代码少、自动生成的逻辑靠谱、可读性高。

图13-3 从类到对象调用全流程:class 定义 → 调用 Student() 触发 init → 创建实例 → 调用实例方法,self 自动指向当前对象。

什么时候应该用类而不是字典

这是一个很多人困惑的问题。一个简单的判断标准:当你的数据结构上面有固定的操作时,用类;如果只是单纯地存放数据,字典和 dataclass 都可以

# 适合用字典:纯数据,没有专门的操作
config = {"host": "localhost", "port": 8080, "debug": True}

# 适合用类:数据结构附带专门的操作
class DatabaseConfig:
    def __init__(self, host, port):
        self.host = host
        self.port = port
    
    def get_connection_string(self):
        return f"mysql://{self.host}:{self.port}"
    
    def validate(self):
        if not self.host:
            raise ValueError("host 不能为空")

当你发现自己在写很多"接收同一个字典参数、对这个字典做操作"的函数时,就是把这些函数和字典合并成一个类的时机。类不强求——如果你觉得字典够用,就用字典。面向对象是工具,不是义务。

类和字典——什么时候该用哪个

这个问题值得单独拿出来讲,因为它在实际编码中每天都会遇到。一个简单的判断:如果你的数据只是一堆键值对,没有专属的操作逻辑——用字典或 dataclass。如果你的数据带着固定的、专属的操作——用类

比如一个 API 返回的 JSON 数据:{"name": "小明", "score": 85}。你只是把它从 API 传递到数据库,不需要对"小明"做什么操作——字典就够了。但如果你的系统里到处都在计算"这个学生及格了吗"“这个学生排名第几”,而且计算逻辑在十几个地方重复——这时候就该封装成 Student 类了。

一个实用经验:先用字典写,当发现相同的处理逻辑在三个以上地方重复出现时,重构为类。过早地引入类会让代码过度工程化——100 行的程序被拆成了七八个类,每个类只有两行逻辑。但过晚地引入类会让代码难以维护——到处是复制粘贴的相似函数。掌握"刚刚好"的时机需要经验,而经验来自于不断地写代码、不断地感受痛点。

为什么 init 不是构造函数

很多资料把 __init__ 称为"构造函数",这在技术上是不准确的。Python 真正创建对象的方法叫 __new__——它在 __init__ 之前被调用,负责创建并返回一个新的空对象。__init__ 接收这个空对象(即 self),给它装填属性。由于 __new__ 在 99.9% 的场景下不需要你手动干预,日常说"构造"的时候指的都是 __init__

了解这个区别在实际编码中很少用到,但它解释了一个现象:为什么你不能在 __init__return 一个不同的对象。因为对象的创建已经由 __new__ 完成了,__init__ 只能给这个对象添加属性,不能"狸猫换太子"。

三、实战与思维

实战:用类重写学生管理系统

from dataclasses import dataclass
from typing import List

@dataclass
class Student:
    student_id: str
    name: str
    scores: List[float]
    
    def average(self):
        if not self.scores:
            return 0
        return sum(self.scores) / len(self.scores)
    
    def get_level(self):
        avg = self.average()
        if avg >= 90: return "A"
        elif avg >= 80: return "B"
        elif avg >= 70: return "C"
        elif avg >= 60: return "D"
        return "F"

class Classroom:
    def __init__(self, name):
        self.name = name
        self.students = []
    
    def add(self, student):
        self.students.append(student)
    
    def find(self, student_id):
        for s in self.students:
            if s.student_id == student_id:
                return s
        return None
    
    def remove(self, student_id):
        s = self.find(student_id)
        if s:
            self.students.remove(s)
            return True
        return False
    
    def get_top_students(self, n=3):
        return sorted(self.students, key=lambda s: s.average(), reverse=True)[:n]
    
    def report(self):
        print(f"===== {self.name} 班级报告 =====")
        print(f"总人数:{len(self.students)}")
        if not self.students:
            return
        all_scores = [s.average() for s in self.students]
        print(f"班级平均分:{sum(all_scores)/len(all_scores):.1f}")
        print(f"最高分:{max(all_scores):.1f}")
        print(f"前三名:")
        for rank, s in enumerate(self.get_top_students(), 1):
            print(f"  {rank}. {s.name} (均分 {s.average():.1f}, {s.get_level()})")

# 使用
classroom = Classroom("一年级一班")
classroom.add(Student("001", "小明", [85, 92, 78]))
classroom.add(Student("002", "小红", [92, 88, 95]))
classroom.add(Student("003", "小刚", [78, 82, 80]))
classroom.add(Student("004", "小美", [95, 98, 91]))

classroom.report()

看,Student 负责描述一个学生,Classroom 负责管理一群学生。每个类的职责单一清晰。这就是面向对象在实际项目中的应用方式——不是炫技,而是用更贴近人类思维的方式组织代码。

学会区分类方法和静态方法——@classmethod@staticmethod

类里的方法不只有一种。除了以 self 为第一个参数的普通方法,还有两种特殊方法值得了解:

class Student:
    school = "第一中学"
    
    def __init__(self, name, score):
        self.name = name
        self.score = score
    
    @classmethod
    def from_string(cls, data_str):
        """工厂方法:从字符串创建对象,如 "小明,85" """
        name, score = data_str.split(",")
        return cls(name, int(score))
    
    @classmethod
    def change_school(cls, new_name):
        """修改类属性"""
        cls.school = new_name
    
    @staticmethod
    def is_valid_score(score):
        """不依赖类或实例的独立工具函数"""
        return 0 <= score <= 100

# 类方法的使用
s = Student.from_string("小明,85")
print(s.name, s.score)  # 小明 85

# 静态方法的使用
print(Student.is_valid_score(95))   # True
print(Student.is_valid_score(150))  # False

@classmethod 的第一个参数是 cls(类本身,而非实例),常用于"工厂方法"——用不同的输入方式创建对象。@staticmethod 不需要 selfcls,就是一个放在类里的普通函数——放在类里只是因为它和这个类在逻辑上相关。这两种方法在初学阶段用得不多,但当你在框架源码或别人的设计中看到它们时,知道它们是什么就不会困惑了。

动手练习

  1. 银行账户类:完善 BankAccount 类,添加交易历史记录(每次存取款都记录下来),添加 print_history() 方法打印交易明细。

  2. 图书类:创建 Book 类(属性:书名、作者、ISBN、是否在馆),创建 Library 类(管理书籍、支持借书和还书、支持按书名/作者搜索)。

  3. dataclass 实战:用 dataclass 定义 Product(商品名、单价、库存),写一个 ShoppingCart 类管理购物车(添加商品、移除商品、计算总价、应用折扣)。

  4. 用类改写一段面向过程代码:找一段你之前写的、超过 30 行的面向过程代码(比如第 6 篇的成绩统计程序),用类和对象的思想重写。体验一下"把数据和相关函数绑在一起"和"让它们各自飘在外面"在代码组织上的区别。做完之后问自己:这个程序用面向对象写是不是更好?如果答案是"好像也没什么区别"——那说明这个程序确实不需要面向对象。能够理性地选择"不用 OOP",比盲目地"到处都是类"更成熟。OOP 不是目的——写出清晰、可维护的代码才是目的。有些问题用函数式风格写出来更简洁,有些问题用 OOP 写出来更清晰。随着经验的积累,你会逐渐形成对"这道题该用哪种范式"的直觉。在那之前,一个简单的参考标准是:如果你发现自己在函数间反复传递同一组相关的数据,而且这些数据上面有固定的操作逻辑——那就是面向对象的信号。如果数据只是数据,操作只是操作,两者没有内在的绑定关系——那就不是必须用类。

  5. __repr__ 改进调试体验:给你之前写的 Student 类添加 __repr__ 方法,使得在交互模式中直接输入 s 就能看到 Student(name='小明', score=85) 而不是 <__main__.Student object at 0x7f...>。对比有无 __repr__ 时在交互模式下操作一堆对象的便利性差异——这个区别在调试复杂数据结构时会放大十倍。

  6. 设计一个"停车场"系统(选做):有几种不同类型的车辆(小汽车、卡车、摩托车),每种车的停车费计算方式不同。停车场有固定容量,满了之后不允许进入。设计一个包含 ParkingLotVehicle 及其子类的系统,支持车辆进入、离开、查询当前空位数。这个练习会逼你同时运用面向对象的四个核心概念:继承(不同车辆类型)、多态(统一计算停车费)、封装(保护停车场内部状态)、组合(停车场包含车辆列表)。做完之后你会对 OOP 的设计感觉有质的飞跃。:有几种不同类型的车辆(小汽车、卡车、摩托车),每种车的停车费计算方式不同。停车场有固定容量,满了之后不允许进入。设计一个包含 ParkingLotVehicle 及其子类的系统,支持车辆进入、离开、查询当前空位数。这是现实生活中 OOP 继承和多态的经典场景。


面向对象的思维转变——从"这件事怎么做"到"这件事该谁做"

面向对象不仅仅是语法——它在你学了 class 之后不会自动到来。它是一种思维方式的转变。面向过程编程的思路是"第一步做什么,第二步做什么,第三步做什么",像一个操作手册。面向对象编程的思路是"这个系统里有几种角色,每种角色有什么属性和能力"。

用订餐系统来打比方。面向过程的思路:获取用户输入 → 查找餐厅 → 生成菜单 → 计算价格 → 下单 → 支付。面向对象的思路:有一个 User 类(有姓名、地址、余额),有一个 Restaurant 类(有名称、菜单、评分),有一个 Order 类(关联用户和餐厅、包含菜品列表、能计算总价)。对象之间通过方法来协作——user.place_order(restaurant, items)restaurant.confirm(order)user.pay(order)

没有哪种思维方式天生更优。但在处理复杂系统时,面向对象的思想往往更接近人类的直觉——我们本来就是用"谁有什么、谁能做什么"的方式来理解世界的。学了类之后,不是每段代码都必须用类来写,而是你在遇到"数据带着固定操作"的场景时,多了一个强大的工具来选择。


要点回顾

  • 类是模板,对象是用模板创建的具体实例
  • __init__ 初始化对象属性,self 指向当前实例
  • 类属性所有实例共享,实例属性每个对象独有
  • 用单下划线约定"内部属性",@property 创建只读属性
  • 特殊方法(__str____lt__ 等)让自定义对象像内置类型一样工作
  • dataclass 帮你省去写 __init__ 等样板代码——以数据为中心的类的首选

下篇预告

下一篇我们进入面向对象进阶——继承、多态、封装和更多的魔法方法。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐