ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从字典到类:Python面向对象编程的实战演进

从字典到类:Python面向对象编程的实战演进

很多人刚开始学编程时,接触到的都是“很好用的简单结构”:列表里塞一串数字,字典里放几个键值对,往函数里一传,逻辑跑通,事就办完了。但等代码写到一定程度,你会发现事情变得不对劲起来——用户信息要到处传、每个函数都要核对字段名、业务状态一变就要改一堆if分支。直到某一天,你可能才开始正视那个绕不开的概念:更复杂的结构,类与对象。

这篇文章就是我自己从“用字典硬撑”到“老老实实画类图”那段经历的心得复盘。我尽量不写教科书式的定义,而是用能直接上手跑起来的示例讲清楚类与对象到底是什么、为什么要这样设计,以及在真实项目里应该怎么用而不翻车。适合刚从基础语法过渡到项目阶段的初学者,也适合想系统捋一遍面向对象知识点的朋友。

1. 为什么我们需要“更复杂的结构”

1.1 列表、字典这些基础结构的痛点在哪里

先看一段很常见的“字典式写法”。假设我们要管理一个宠物诊所里的狗:

dog1 = {"name": "小白", "age": 3, "breed": "柯基", "vaccinated": True} dog2 = {"name": "阿黄", "age": 5, "breed": "金毛", "vaccinated": False}

单看这两条数据,没什么问题。但一旦业务复杂起来,代码就会开始变臭。你要写一个函数判断狗能不能洗澡,大概是这样:

def can_bathe(dog): if dog["vaccinated"] and dog["age"] > 0: return True return False

然后在另一个文件里,你可能又写了一个函数去更新狗的疫苗状态:

def vaccinate(dog): dog["vaccinated"] = True

你没写错任何逻辑,但问题已经埋下了:dog这个字典的key全靠手写字符串,只要在某个角落把vaccinated拼成了vaccinateds,程序不会给你任何提醒。更麻烦的是,所有与狗相关的逻辑都被打散在多个模块里,你想搞清楚“一条狗到底能做什么事”,得翻遍整个项目。

这就是基础结构的天花板。列表和字典本质上是被动容器,它们负责存放数据,却不负责约束数据长什么样,也不负责提供针对数据的操作。你把一堆数据塞进去,剩下的逻辑都要自己在外面写,每次都要重复核对字段名,代码规模一大,光是保持一致就耗费大量精力。

1.2 类就是蓝图,对象就是按蓝图盖出来的房子

类与对象解决的核心问题,就是把“数据长什么样”和“数据能干什么”绑定在一起。

我用一个自己经常打的比方来理解:类就是建筑蓝图,对象就是按这张蓝图盖出来的房子。蓝图上标明客厅在哪儿、阳台多大、门往哪开,但蓝图本身不能住人。真正住人的,是照着蓝图盖出来的那一栋栋房子。

对应到代码里,class就是那张蓝图:

class Dog: pass

而对象,就是照着Dog这张蓝图“盖”出来的一个具体实例:

my_dog = Dog()

my_dog就是一只真实存在的狗对象,它有单独的年龄、单独的名字,它和大黄、旺财之间互不干扰,但它们都遵守同一个Dog蓝图所定义的规则。你往蓝图里加“疫苗状态”这个字段,所有盖出来的房子都会带这个空间;你在蓝图里加一个bark“行为”,每只狗都能叫。

不要小看这个转变。当数据和操作被封装进同一个结构时,你写代码的视角就从“操作一堆数据”变成了“对象之间互相协作”,这完全是两种思考方式。

1.3 数据与行为分离,是复杂度的第一来源

如果你觉得上面说的还不够有说服力,我可以再举一个我真实踩过的例子。

有一段时间,我在写一个小项目,里面有一个Order(订单)的概念。当时图省事,用字典表示订单,用一堆全局函数去操作它。订单状态有pending、paid、shipped、done几种,每个函数都要自己判断状态,比如:

def ship(order): if order["status"] != "paid": raise ValueError("订单未支付") order["status"] = "shipped"

这个逻辑单看不复杂,但订单相关的函数越加越多:取消订单、修改收货地址、申请退款、核对库存……每一个函数开头都要先判断status,而状态流转规则散落各处。最后加需求时,我根本说不清“一个订单到底有哪些合法动作”,因为答案分散在十几个函数里。

后来我重构成类,事情豁然开朗:订单自己知道自己的状态,自己知道自己能做什么动作,外部调用时只需要一句order.ship(),由它自己决定能不能发货。这就是类与对象真正的价值——它让你的代码从“描述过程”升级为“描述结构”,把复杂度收敛到每个对象内部,而不是让复杂度在函数之间到处游走。

2. 从零开始写一个类:最小可运行示例

2.1 类结构的最少语法

先把最基础的东西过一遍。Python里定义一个类只需要class关键字加上类名:

class Dog: pass

实例化也很简单:

my_dog = Dog() print(type(my_dog))

输出结果会告诉你,my_dog是一个Dog类的实例。但这个类太“空”了,它既没有数据,也没有行为,几乎没什么用。要让它真正可用,我们需要往里填两样东西:属性(数据)和方法(行为)。

在Python里,属性通常是在实例化时通过__init__方法定义的。这个概念很关键,稍微多说一句:__init__是在对象创建后自动调用的“初始化方法”,它负责把初始数据填进对象里。如果你在其他语言里见过“构造函数”,那就把它理解为Python版的构造函数。

2.2 __init__构造函数在做什么

下面这个类是多数教程都会给的示例:

class Dog: def __init__(self, name, age): self.name = name self.age = age def bark(self): print(f"{self.name} 汪汪叫")

创建对象时,参数会直接传进去:

my_dog = Dog("小白", 3) print(my_dog.name) # 小白 print(my_dog.age) # 3 my_dog.bark() # 小白 汪汪叫

这里有三件事值得真正记在心里。

第一,self代表当前实例本身。当my_dog这个对象调用bark方法时,Python会自动把my_dog作为第一个参数传给self,所以你不需要手动调用Dog.bark(my_dog),虽然它实际上是等价于这个过程的。

第二,__init__不是你的对象本体,它只是在对象诞生之后把数据“初始化”进去。真正构成对象的是冒号下面那些带self的属性。

第三,属性在Python里是动态绑定的。你在__init__里写了self.name,那么每个Dog对象都必然有name属性;但如果你在实例上直接执行my_dog.color = "白色",这个额外属性也会被加上去。这种灵活性很爽,但也意味着你需要自律,把该初始化的属性都放进__init__里,否则代码读起来会很分裂。

2.3 实例方法、类方法、静态方法有什么区别

除了最常见的实例方法,类里还可以定义类方法和静态方法。很多初学者会在这里困惑,我给出最简单的一套理解方式。

实例方法:第一个参数是self,它操作具体对象的数据。我们上面写的bark就是实例方法。

类方法:用@classmethod装饰,第一个参数是cls,代表类本身。它通常用于提供“另一种创建对象的方式”。比如:

class Dog: def __init__(self, name, age): self.name = name self.age = age @classmethod def newborn(cls, name): return cls(name, 0) baby = Dog.newborn("小奶狗") print(baby.age) # 0

静态方法:用@staticmethod装饰,它既不收self也不收cls,本质上就是一个放在类里的普通函数,只是逻辑上与这个类相关。比如检查一个年龄数字是否合法:

class Dog: @staticmethod def is_valid_age(age): return isinstance(age, int) and age >= 0

区别何在?实例方法管具体对象的状态,类方法管针对“类”的整体逻辑,静态方法只是把相关工具函数收拢到类命名空间下。我用的时候一般遵循一句话:需要拿对象数据就去操作实例方法,需要返回类的新实例就用类方法,什么都不依赖就写静态方法。

2.4 self不是魔法,只是个约定

关于self,我曾经见过不少初学者被它绊住。其实self不是内置关键字,就是Python语法的约定:实例方法的第一个参数永远指向调用这个方法的实例对象。你用this、me甚至whatever命名,语法上也能跑,但没人会这么干,因为可读性太差了。

一个值得动手验证的小实验是:直接用类名去调用实例方法,手动传对象进去。

Dog.bark(my_dog) # 等价于 my_dog.bark()

这行代码能跑通,原因就是我们刚才说的:Python在调用my_dog.bark()时会自动把对象传给self。你手动写全了,反而是理解self机制的最好方式。

3. 面向对象的三大支柱:封装、继承与多态

3.1 封装:给属性设好边界,而不是锁死一切

封装这个词听着挺唬人,翻译成大白话就是:对象把自己的数据保护起来,只通过对外提供的方法让别人操作。

先看一个反例:

class Dog: def __init__(self, name, age): self.name = name self.age = age d = Dog("小白", 3) d.age = -5 # 这合理吗?显然不合理

外部代码直接给age赋了一个负数,程序也没意见,但一条3岁的狗不可能变成负5岁。封装要解决的就是这类问题:我们希望年龄只能被合理修改,至少不能被随意改成负数。

Python不像一些语言那样有绝对的private关键字,而是用约定和工具来约束。最常用的是属性名前面加单个下划线,表示“这是内部的,外部别乱动”:

class Dog: def __init__(self, name, age): self.name = name self._age = age def celebrate_birthday(self): self._age += 1

再配上@property装饰器,就能在读取属性时夹带一些校验逻辑:

class Dog: def __init__(self, name, age): self.name = name self._age = age @property def age(self): return self._age @age.setter def age(self, value): if value < 0: raise ValueError("年龄不能是负数") self._age = value

这样你的调用方可以继续用dog.age来读写年龄,但写入时会经过校验。对外接口没变,对数据的保护却加上了,这就是封装带来的实际好处。它不是把属性彻底藏起来让你什么都没法碰,而是明确画出“哪些事你可以直接做,哪些事要通过方法来做”的边界。

3.2 继承:子类复用父类的能力和状态

继承解决的问题是共性抽离。如果金毛和柴犬都有名字年龄,都会叫,不同的是某些生活习惯,那我们把共性放进父类,把差异留在子类。

class Dog: def __init__(self, name, age): self.name = name self.age = age def bark(self): print(f"{self.name} 汪汪叫") class GoldenRetriever(Dog): def fetch(self): print(f"{self.name} 捡回飞盘") class Shiba(Dog): def attitude(self): print(f"{self.name} 扭头不理你")

GoldenRetriever和Shiba都自动拥有了name、age和bark能力。子类可以再添加属于自己的方法,也可以重写父类的方法来改变行为。

这里有一个最常见的坑:子类如果写了__init__,默认不会调用父类的__init__,除非你显式用super()调用它。

class GoldenRetriever(Dog): def __init__(self, name, age, toy_count): super().__init__(name, age) self.toy_count = toy_count

我见过无数新手在这块卡住,现象是“父类属性什么都没有,一访问name就报错”。记住:子类重写__init__时,第一件该做的事就是调用super().init(),把父类负责初始化的字段先搞定,再初始化自己的部分。

3.3 多态:用同一个方法名,做不同的事

多态的意思是:不同类的对象,实现了同一个方法名,调用时由对象自己决定具体行为。

class Cat: def speak(self): print("喵") class Dog: def speak(self): print("汪") animals = [Cat(), Dog()] for animal in animals: animal.speak()

输出是“喵”和“汪”。调用方完全不用关心循环里到底是猫还是狗,只要它们都有speak方法,就能统一调用。

我最早觉得多态没什么了不起,直到后来维护代码时才发现,它意味着“新加一个类,不用改调用处”。只要新来的动物也实现speak方法,这层循环可以一路跑下去,这就是面向对象的扩展性。真正需要用心思考的,不是“怎么实现多态”,而是“这个接口到底叫什么名字才能让所有子类都解释得通”,这往往比多态本身更重要。

4. 真实项目里的类设计:什么时候用类、什么时候别硬用

4.1 判断是否该用类的三个信号

我知道有一部分读者心里会有个疑惑:是不是所有代码都得写成类?答案当然是否定的。脚本是一次性过程,用几个列表函数解决,完全正确;只有当你发现以下三个信号之一时,才值得启动类设计。

信号一:同一组数据出现在太多地方,并且总是配套出现。比如用户信息的 name、email、status 在十几个函数里同时传来传去,那就该考虑用一个User类收拢。

信号二:数据本身的合法性需要持续维护。年龄不能变负、订单状态不能乱跳,这种业务规则很自然地依赖封装和状态校验。

信号三:你发现自己写了大量“按条件分支处理不同类型数据”的函数,这时候多态很可能更合适。

反过来,如果只是两三组独立数据,代码总量很小,硬抽出类反而是过度设计。写类不是为了显得专业,而是为了降低维护成本。用最小的结构解决问题,是更要紧的原则。

4.2 实例变量和类变量:一个遍布全国的经典坑

类变量和实例变量的问题,几乎每个Python开发者的职业生涯中都会踩一次。

先看正确的写法。类变量定义在类的缩进块下层,不属于单独的实例:

class Dog: species = "Canis familiaris" def __init__(self, name): self.name = name

species是所有Dog实例共享的。你访问任意一个Dog对象的species,都是同一个值,修改Dog.species,所有实例看到的都是新值。

再看危险写法。有人想给每只狗一个玩具列表,图省事直接写成类变量:

class Dog: toys = [] def __init__(self, name): self.name = name self.add_toys("球") def add_toys(self, toy): self.toys.append(toy) a = Dog("豆豆") b = Dog("旺财") print(a.toys) # ['球', '球']

检查一下执行结果,你会发现a.toys里出现了两个“球”,因为两个对象共享了同一个列表。正确做法是把这个变量放到__init__里:

class Dog: def __init__(self, name): self.name = name self.toys = [] def add_toys(self, toy): self.toys.append(toy)

这样每个对象才有自己独立的玩具列表。这个坑的核心诱因是:可变对象放在类变量里会被所有实例共享,而初学者往往没意识到“类体里定义的变量会变成类属性”。我的建议很简单:凡是会随对象变化的数据,都放进__init__。

4.3 可变默认参数、浅拷贝与引用错乱

与类变量类似,还有一个非常隐蔽的坑藏在默认参数里。看这个例子:

class Dog: def __init__(self, name, toys=[]): self.name = name self.toys = toys

两个不同的Dog如果都没传toys,它们得到的其实是同一个列表对象,因为默认参数在函数定义时只会被创建一次。更安全的写法是把可变默认值设为None,然后在内部创建新列表:

class Dog: def __init__(self, name, toys=None): self.name = name self.toys = toys if toys is not None else []

关于引用错乱,还有一层要理解:对象的赋值只是把引用复制了一份,而不是复制对象本身。两个变量指向同一个对象时,修改任何一个变量看到的属性,另一个也会跟着变。如果需要复制,应该用copy模块:

import copy new_dog = copy.deepcopy(old_dog)

浅拷贝会复制外层对象,但嵌套的可变结构仍然共享。判断不清的时候,用deepcopy更安全,代价是慢一些。在实际项目里,这条经验帮我避免过多次线上数据串味的尴尬。

4.4 一些值得长期保留的设计习惯

最后放几条我在实际项目里存了很久的设计习惯,都是吃过亏之后才当成规矩的。

类名用名词,方法名用动词。看到Order表示订单,调用order.cancel()时,读代码的人能瞬间反应过来业务动作。反过来写一个cancel_order(order)就差点意思。

一个方法只做一件事。如果一个方法超过二十行,或者你要用and在注释里连接两件不相干的事儿,就该考虑拆分了。

对外暴露的接口宁少勿多。每多一个公共方法,就多一个需要维护的边界。那些只给内部使用的方法,一律加下划线前缀,让别人一眼就知道“别乱碰”。

不要害怕重构。没人能在第一版就设计出完美的类。随着需求变化,把一个类拆成两个或者给两个类抽出一个公共父类,都是再正常不过的事。只要测试覆盖得住,大胆重构就好。

5. 常见问题速查与调试实录

5.1 高频错误现象与处理方案速查表

下面的表格是我整理的高频问题,新手期几乎都能对上号。

现象常见原因处理思路
实例化的对象上一个值改了,另一个对象也跟着变类变量里放了可变对象,或构造方法里共享了同一个可变默认值将此类数据移至__init__中,默认值写成None,在内部新建对象
访问子类属性时提示没有该属性子类重写了__init__但没有调用super().init()在子类__init__第一行调用super().init(...)
方法里改了属性,但对象创建后属性数值没变变量名写错了,Python动态绑定了一个新属性检查属性名拼写,尽量在__init__中统一初始化所有属性
输出对象时看到<main.Dog object at 0x...>没有实现__repr__或__str__在类中定义__repr__返回可读字符串,方便调试
直接调用类里的实例方法报缺少self用类名Dog.bark()调用实例方法改成实例调用my_dog.bark(),或手动传入对象my_dog
子类方法想复用父类逻辑,但父类逻辑没执行子类方法里没有调用父类同名方法使用super()显式调用父类方法
外部能随意修改内部状态,导致业务规则被绕过属性没有做任何保护使用单下划线前缀表示内部,并通过property或方法提供受控访问

5.2 当对象行为不符合预期时,建议从这三个位置开始排查

如果程序报错或者行为诡异,我自己的排查顺序是固定的。

第一,先确认对象到底是哪个类创建出来的。多打印几次type(obj),尤其是有继承关系时,有些对象你以为子类创建,实际却是父类或别的类型。

第二,检查有没有“同名属性覆盖”问题。比如你在方法里写self.tyes而__init__里是self.toys,Python不会报错,它只会默默创建一个新属性,原属性纹丝不动。这类问题用IDE或者grep搜一下属性名就能发现。

第三,检查所有共享对象。把多个对象传进同一个函数,然后某个对象的状态被改了,多数情况下都是别名共享导致。给对象做一次copy,或者把它们之间的关系画一下,基本无所遁形。

快问快答之外的几个底层念头

写到这里,我想再分享一点偏“底层”的体会。

学习类与对象的真正难点,不是记住class怎么写、__init__怎么用,而是思维方式的转变。以前你看代码,看到的是“第一步、第二步、第三步”;开始用面向对象之后,你看到的是“谁提供数据、谁负责行为、谁与谁协作”。这个转变不会一蹴而就,我用了两三个项目才真正适应。

那种“知道类是什么,但不知道在项目里该放在哪儿”的阶段,我经历过,正常得很。后来我的突破点是画简单的关系图:把核心概念圈出来,然后问自己,哪些属性属于它、哪些行为应该由它负责。这张图画出来,类的基本轮廓就有了。

还有一个小建议:练习时别总是拿“小狗”“汽车”当案例,它们太理想化了。试着把你手上正在写的那个字典,哪怕只是收件地址和备注,认认真真改成一个类。你会发现,在真实业务里,类的设计才真正需要权衡,也才能真正训练到设计思路。

面向对象的路上没有唯一标准答案,一个类这样设计可以,那样设计也没错,关键是代码的可读性和可维护性能不能让你自己满意。拿到一个想法时,先写出来,然后再在一次一次重构里越磨越亮。这比追求一步到位的完美设计,更重要。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进