拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从传字典到面向对象:类、对象与方法在Python/Java中的落地

从传字典到面向对象:类、对象与方法在Python/Java中的落地

带过几届新人之后,我在代码评审里最常说的一句话就是:"你这段代码不是不会写,是没想清楚数据该由谁负责。"典型场景是这样的:一个订单信息,用Python的字典或者Java的Map在七八个函数之间传来传去,字段名一会儿叫userId一会儿叫uid,金额一会儿是分一会儿是元,等到产品经理说要加一个"优惠后金额"的字段时,你得把所有函数翻一遍。这就是类、对象与方法这三个词存在的意义——它们不是为了应付面试题背的八股,而是把"数据"和"操作数据的逻辑"绑在一起的那根绳子。这篇文章我从零开始讲:类到底是什么、对象是怎么被创建出来的、方法为什么和普通函数不一样,以及实际写业务代码时最容易栽进去的几个坑。不管你是刚开始学编程,还是写了两年代码但一直"凭感觉"在用类,下面这些内容应该都能对得上号。

1. 先把"传字典"的写法摆出来,看看它到底哪里疼

我从来不建议一上来就背"类是对象的抽象,对象是类的实例"这句话,因为背完你还是不知道什么时候该写类。更有效的路径是先看不用类的写法,疼过一次,自然就懂了。

1.1 散装数据的三条典型症状

假设要做一个简单的用户注册和登录功能,不用类的话,代码大概长这样:

def create_user(name, email, password): return {"name": name, "email": email, "pwd": password, "created": time.time()} def check_password(user, pwd): return user["pwd"] == pwd def change_email(user, new_email): user["email"] = new_email

能跑,功能也对。但问题会随着时间一点点暴露出来。

第一条症状是字段名靠约定,没有任何强制力。上面pwd这个键,你在另一个文件里写成password,程序不会报错,只会在运行时抛一个KeyError,而且还可能是上线之后才发现的。第二条症状是校验逻辑四散。密码长度校验写在哪里?邮箱格式校验写在哪里?每个调用create_user的地方都觉得自己该校验一遍,最后有五个版本的校验规则,其中一个还是上个版本遗留的。第三条症状是函数和数据是分离的。check_password这个函数,理论上只有"用户字典"能传进来,但语言层面它接受任何参数,你传个列表进去它也照样跑到报错为止。

这三条症状说白了就是一句话:数据在裸奔,规则没有归属地。

1.2 类给数据加上的,其实是"责任边界"

把上面那段代码改成类,变化不只是语法:

class User: def __init__(self, name, email, password): if not email or "@" not in email: raise ValueError("邮箱格式不合法") self.name = name self.email = email self._password = password self.created_at = time.time() def check_password(self, pwd): return self._password == pwd def change_email(self, new_email): if "@" not in new_email: raise ValueError("邮箱格式不合法") self.email = new_email

改完之后有两件事发生了质变。一是校验规则只有一个出处,想改规则只改这个类,所有调用方自动受益,不存在"漏改一处"的问题。二是调用方式变成了user.change_email(...),读到这行代码的人不用去猜这个函数属于哪个模块、参数顺序是什么,点进去就是实现。这就是面向对象最朴素的价值:把"谁的数据"和"谁能改这些数据"锁在一起。

我个人的经验是,判断一段数据该不该包成类,看两个信号就够了:这段数据是不是有多个字段必须一起传递,以及这些字段是不是有必须遵守的规则。两个都是"是",那就别犹豫了,写类。

1.3 "类是模板、对象是实例"这句话,哪里容易误导人

主流教材都说类是图纸、对象是按图纸造出来的房子,这个类比大体没错,但有两处容易让人跑偏。

第一处误导是让人以为类必须先设计得很完美才能写对象。实际上不是。类是可以演进的,你今天只有name和email两个字段,明天加phone,后天加last_login_at,这完全正常。真正要守住的是"这个类的职责是不是单一的",而不是"字段是不是一次就列全"。

第二处误导更隐蔽:让人以为类里的东西是所有对象共用的。不是。类里面分两种东西,一种是每个对象各自一份的(比如上面User的name、email),另一种是所有对象共享一份的。Python里前者叫实例属性,后者叫类属性;Java里前者叫实例字段,后者用static修饰。这个区别在下一节讲对象创建过程时会展开,它也是很多人写代码时莫名其妙出Bug的根源。

2. 对象被造出来的那一瞬间:__init__/构造方法到底干了什么

User(...)这一行看起来轻飘飘,实际上背后发生了好几步。搞清楚这几步,你对"对象是什么"的理解会从"语法"跳到"内存模型"。

2.1 构造方法不是被"调用"的普通方法

以Python为例,User("张三", "a@b.com", "123456")执行时,顺序是这样的:先由类对象创建一个空白的实例(此时它还没有任何自己的属性),然后把这个空白实例作为第一个参数传给__init__,也就是self,最后__init__往这个实例上挂属性。注意,__init__的返回值被语言强制忽略,它只能返回None。这一点很多新手不知道,试着在__init__里return something,会直接报错。

Java里的分工更明确,分成了两个概念:new负责在堆上分配内存并把字段初始化为默认值(数值类型是0,引用类型是null),构造方法负责把这些默认值改成你想要的值。所以Java里如果构造方法里忘了赋值,字段不会报错,只会默默是0或者null——这也是空指针异常的一大来源。

2.2 引用与实体:为什么改了A,B也跟着变

这是初学者最容易懵的地方,我一般用一段代码来讲:

List<Integer> a = new ArrayList<>(); a.add(1); List<Integer> b = a; b.add(2); System.out.println(a); // [1, 2]

打印出来是[1, 2]而不是[1],因为b = a并没有复制一份列表,只是让b和a指向了同一个实体。变量里存的不是数据本身,而是"数据在哪"这个地址。所以改b和改a是同一件事。

Python完全一样:

a = [1] b = a b.append(2) print(a) # [1, 2]

理解了这个,你就会明白为什么Python函数里修改传入的列表会影响外部,而修改一个整数不会:

def add_item(lst, n): lst.append(n) # 影响外部 n = n + 1 # 不影响外部 nums = [1] x = 10 add_item(nums, x) print(nums) # [1, 10] print(x) # 10

因为lst.append是在操作同一个实体,而n = n + 1只是让函数内部的局部名字指向了一个新的整数对象,外部那个x指向的实体没动。

提示:如果你想在函数里"安全地"处理列表,用lst[:]或者list(lst)复制一份再操作,或者干脆设计成不修改入参的风格。这不是语言特性问题,是设计决策问题。

2.3 一个对象从生到死,中间经历了什么

Python对象从被创建到被回收,大致经历"创建→引用→可能被多个变量引用→引用数归零→被垃圾回收"这几个阶段。CPython主要用引用计数,配合分代回收处理循环引用(比如两个对象互相持有对方的引用,计数永远不会归零,就需要标记清除来兜底)。

Java则是另一套路子,用可达性分析:从一组叫GC Roots的对象出发(栈上的局部变量、静态变量、常量等),能走到的对象就是"活的",走不到的就是垃圾。所以Java里你可以放心地把一个对象的所有引用都置为null,它不会立刻被回收,但下次GC时就会被收掉。

这两种机制差异带来一个实际影响:Python写长期驻留的服务时,要留意循环引用和大对象缓存;Java则要留意对象创建速率和GC停顿。这属于进阶话题,现阶段记住一句话就够了:对象不是免费的,创建有成本,销毁也有成本。

2.4==和equals/is,为什么两个内容一样的对象不相等

Java里这段代码是很多人的第一次翻车:

String s1 = new String("abc"); String s2 = new String("abc"); System.out.println(s1 == s2); // false System.out.println(s1.equals(s2)); // true

==比的是引用地址,两个new出来的对象在堆上是两块不同的内存,当然不相等。equals才是比内容,但前提是这个类正确重写了equals(以及配套的hashCode)。如果你自己写的类没重写,那equals默认行为和==一样,都是比地址。

Python里对应的是is和==,is比身份(地址),==能比内容是因为列表、字典这些内置类型实现了__eq__。你自定义的类如果不实现__eq__,默认也是比身份。

这一点在"对象数组去重"、"把对象放进HashSet/Set"这类场景里特别致命。放进HashSet时,容器先用hashCode定位桶,再用equals判重。你只重写了equals没重写hashCode,就会出现"明明相等的两个对象,Set里存了两份"这种诡异现象。我见过不少人排查这个问题排查了半天,最后发现就是漏了一个方法。

3. 方法的三副面孔:它们凭什么叫"方法"而不是"函数"

严格说,方法是"依附于类或对象"的函数。这个"依附"带来的差异,比你想象的大。

3.1self和this:方法怎么知道自己在操作谁

Python里每个实例方法的第一个参数必须是self(名字可以改,但不建议),它代表调用这个方法的那个对象。user.check_password("123")实际上相当于User.check_password(user, "123")。所以你完全可以这样调用,效果一模一样:

User.check_password(user, "123")

Java把这个参数藏了起来,用关键字this访问。this.balance = balance这句里,左边的this.balance是对象的字段,右边的balance是方法参数,没有this的话就变成自己给自己赋值,字段永远是0。这是Java新手非常经典的一个Bug,而且编译器不会提醒你。

3.2 什么时候该用静态方法,什么时候绝对不该用

方法按依附对象分三类,用表格对照最清楚:

类型Python写法Java写法能访问实例数据吗典型用途
实例方法def f(self)普通方法能操作对象自身状态
类方法@classmethod def f(cls)static方法(略有差异)不能,能访问类级数据工厂方法、替代构造器
静态方法@staticmethod def f()static方法不能纯工具函数

关于静态方法,我给的经验规则是:只要这个函数的逻辑里用到了任何一个实例字段,它就不该是静态的。反过来,如果一个函数纯粹是"输入A、输出B",跟对象状态毫无关系,那把它硬塞进类里当实例方法,只会让人误以为它依赖对象状态。

举个我实际改过的例子。原代码里有个工具类,里面塞了十几个静态方法,包括日期格式化、字符串截断、金额计算。这些方法本身没问题,但金额计算混在里面就不合适——因为金额计算是有业务规则的,汇率从哪来、保留几位小数,这些都应该属于某个"钱"的概念。后来把这部分拆成了一个Money类,问题就清楚了。

还有一类场景特别适合类方法,就是多种方式创建对象。比如从数据库查出来是字典,从接口拿到的JSON也是字典,你可以在类里写:

class User: @classmethod def from_dict(cls, data): return cls(data["name"], data["email"], data["password"])

这样调用方写User.from_dict(row)就行,不用关心字段顺序。比起在外部写一个build_user(dict)函数,好处是这个构造逻辑留在了类内部,字段改名时只需要改一处。

3.3 方法签名就是契约:参数和返回值怎么设计

方法名加参数列表,合起来叫方法签名,它其实是一份对调用方的承诺。这份承诺设计得好不好,直接决定了代码好不好用。

我踩过的一个坑是布尔参数。曾经有个方法update(user, true, false),这俩true和false是什么意思,除了作者没人知道。后来改成两个独立方法activate(user)和disable_notification(user),可读性立刻上去了。经验是:参数里出现两个以上的布尔值,基本就该拆方法或者用枚举了。

另一个坑是返回null。Java里返回null简直就是灾难制造机,调用方忘了判空就是NullPointerException。我更倾向于返回空集合、空对象,或者用Optional。Python里虽然不会NPE,但返回None同样会让调用方不断写if x is not None,本质上是一回事。

还有一个经验是关于返回值个数的。如果一个方法需要返回三个以上的值,通常说明它做了太多事,应该考虑拆开,或者返回一个小的数据类。比如Python里有人喜欢返回元组(status, data, err),调用方必须记清顺序,一不小心就解包错了位置。

4. 把面向对象写成面向过程:三个高频翻车现场

语法会了不等于会用。下面这三个坑,我自己踩过,也见别人踩过,而且踩的时候往往不觉得有问题。

4.1 上帝类:一个类两千行是什么体验

先说现象。有一个叫OrderService的类,行数两千多,方法三十几个,从创建订单、计算价格、扣库存、发通知到生成对账单,全在里面。每次改需求都得先翻半天代码,两个人同时改这个类,合并冲突能搞一下午。

根因不是"这个类写得太长",而是这个类承担了太多职责。判断标准很简单:描述一个类的时候,如果你需要用"和"来连接它的功能,那就该拆了。"负责订单的创建和价格计算和库存扣减和通知发送"——这里面每一个"和"后面都是一个可以独立出去的类。

拆的过程也有讲究。不要按技术分层拆(比如把所有校验方法抽到一个OrderValidator),要按变化的原因拆。价格计算规则会因为促销政策变,库存扣减会因为仓储系统对接变,通知发送会因为渠道变,它们变化的原因不同,就该分成不同的类。这个原则在行业里被反复讲,但在实际代码里被违反的次数也非常多。

4.2 贫血模型:只有getter和setter的类

这是Java项目里最普遍的一种情况:实体类里全是字段加一对getXxx/setXxx,所有业务逻辑都在Service里,Service拿到实体之后一顿get,算完再一顿set放回去。

// 典型的贫血写法 User user = userRepository.findById(id); if (user.getStatus() == 1 && user.getVipLevel() > 3) { user.setDiscount(0.8); user.setStatus(2); } userRepository.save(user);

问题在哪?这段判断"什么情况下能打折"的规则,散落在所有需要打折的地方。哪天规则改成"VIP等级大于3且注册满一年",你得在所有出现这段判断的地方都改一遍。

把规则挪回对象内部之后是这样的:

public class User { public void applyDiscount() { if (this.status == 1 && this.vipLevel > 3) { this.discount = 0.8; this.status = 2; } } }

调用方变成user.applyDiscount(),规则只有一处。这不是什么高深的设计模式,就是把该类自己管的事还给它自己。

4.3 继承用错地方:为什么正方形不该继承长方形

经典的例子:数学上正方形是特殊的长方形,于是Square extends Rectangle。然后Rectangle有个setWidth和setHeight,Square重写它们保证宽高相等。这时候问题来了:把Square对象当成Rectangle用,先setWidth(5)再setHeight(3),你以为得到一个5x3的长方形,实际上得到一个3x3的正方形,面积算出来是9而不是15。这就是里氏替换原则被破坏的典型表现。

判断能不能继承,我的土办法是问自己一句:子类对象替换掉父类对象之后,调用方的预期会不会被打破。会,就别继承。正方形的例子应该用组合:Square持有一个边长字段,需要当矩形用的时候提供一个toRectangle()方法。

实际业务里更常见的错误是为了复用几行代码而继承。比如两个类都要打印日志,就抽一个BaseClass让它们继承。这种共性是"实现细节的共享",不是"概念的从属关系",硬套继承会让类层级越来越深、越来越脆。代码复用的优先顺序应该是:组合 > 接口/抽象类 > 继承。继承放在最后,因为它耦合最紧。

5. 同一件事,Python和Java各写一遍

概念讲完了,我们用同一段业务逻辑把两种语言都写一遍,对照着看差异,体会会更直观。需求是:一个银行账户,支持存款、取款、查余额,且不允许透支,同时要能按年利率计算利息。

5.1 Python版本

class BankAccount: interest_rate = 0.02 # 类属性,所有账户共用 def __init__(self, owner, balance=0.0): self.owner = owner self._balance = balance # 下划线表示"内部使用" self._transactions = [] def deposit(self, amount): self._check_amount(amount) self._balance += amount self._transactions.append(("deposit", amount)) return self._balance def withdraw(self, amount): self._check_amount(amount) if amount > self._balance: raise ValueError("余额不足") self._balance -= amount self._transactions.append(("withdraw", amount)) return self._balance @property def balance(self): return self._balance def add_interest(self): self._balance += self._balance * self.interest_rate def _check_amount(self, amount): if not isinstance(amount, (int, float)) or amount <= 0: raise ValueError("金额必须是正数") @classmethod def from_dict(cls, data): return cls(data.get("owner", "unknown"), data.get("balance", 0.0)) def __repr__(self): return f"<BankAccount owner={self.owner} balance={self._balance}>"

几个值得说的点。interest_rate写在类层级,所有实例共享,想调整全局利率可以直接改类属性。_balance前面的下划线是Python社区的约定,表示"别从外面动它",语言层面其实拦不住,靠的是团队自觉。@property把balance做成了只读属性,外部写account.balance就能读到值,但写account.balance = 100会报错,这就实现了"能读不能随便改"。__repr__是给调试用的,打印对象时能直接看到关键信息,这个习惯我强烈建议养成,能省下大量打日志的时间。

5.2 Java版本

public class BankAccount { private static final double INTEREST_RATE = 0.02; private final String owner; private double balance; private final List<String> transactions = new ArrayList<>(); public BankAccount(String owner, double balance) { this.owner = owner; this.balance = balance; } public double deposit(double amount) { checkAmount(amount); this.balance += amount; this.transactions.add("deposit:" + amount); return this.balance; } public double withdraw(double amount) { checkAmount(amount); if (amount > this.balance) { throw new IllegalArgumentException("余额不足"); } this.balance -= amount; this.transactions.add("withdraw:" + amount); return this.balance; } public double getBalance() { return this.balance; } public void addInterest() { this.balance += this.balance * INTEREST_RATE; } private void checkAmount(double amount) { if (amount <= 0) { throw new IllegalArgumentException("金额必须是正数"); } } @Override public String toString() { return "BankAccount{owner=" + owner + ", balance=" + balance + "}"; } }

对照着看,几个差异挺有意思。Java的balance必须写private才能在语言层面拦住外部访问,Python靠约定;Java的"只读"通过只提供getBalance()实现,Python用@property;Java的transactions用final保证引用不被替换,Python没这个概念。另外Java里的INTEREST_RATE用static final,语义上对应Python的类属性。

5.3 两种语言背后的取舍

Python的思路是"我们都是成年人",能约定就约定,代码短、写得快,代价是约束靠自觉,团队水平不齐时容易失控。Java的思路是"编译器帮你能拦多少拦多少",啰嗦但边界清晰,适合大团队长期维护。

我的看法是,无论用哪种语言,设计思想是一致的:状态私有、行为内聚、对外只暴露必要的方法。语言的强制力只是手段,不是目的。用Python写代码时,我会在脑子里模拟Java的那套约束;用Java写代码时,我会避免为了"符合规范"而写出成堆的无意义getter/setter。

6. 落地时的几条经验,都是踩过才知道的

前面的内容偏概念和对照,最后这部分我说几个实际写项目时的具体做法,属于文档里通常不会写、但影响很大的东西。

6.1 类的命名直接影响你会不会写错代码

UserManager、DataHelper、CommonUtil这类名字,我建议能不用就不用。不是因为它们丑,是因为这类名字没有任何约束力。叫UserManager,那用户校验可以放进去,用户查询可以放进去,用户头像上传也可以放进去,因为它叫"管理器",管什么都说得通。等到这个类三百行的时候,你才发现自己从来没限制过它。

换成UserAuthenticator,职责立刻清晰了:只负责认证。想往里塞头像上传逻辑,你自己都会觉得别扭。这就是命名的作用——好的名字是一种自我约束机制。

方法名也一样。process、handle、doSomething这种动词,看完不知道发生了什么。改成calculateTotalPrice、archiveExpiredOrders,光看名字就知道输入输出是什么。我个人的标准是:方法名应该能读出一个完整的句子,主语是对象,谓语是动词,宾语是操作的东西。

6.2 封装不是把字段全设成private就完事

很多人理解封装就是"字段加private,提供get/set"。但如果你给每个字段都配了getter和setter,本质上和public字段没区别,只是多敲了几行代码。

真正的封装是控制"哪些变化是允许的"。比如账户余额,只提供getBalance()不提供setBalance(),因为余额只能通过存款和取款变化。再比如订单状态,不提供setStatus(),而是提供pay()、cancel()、ship()这些方法,每个方法内部去改状态并做相应的校验。这样做的好处是,状态变化的所有路径都是明确的、可审计的。

我遇到过一个真实问题:某系统的订单状态被七八个地方直接set过,后来排查"为什么会出现状态是已发货但金额是0"这种数据时,完全找不到是哪条路径改的。改成只能通过方法变更之后,每个变更点都在方法里加了日志,问题很快就定位了。

6.3 有些场景真的不需要类

说了这么多类的好处,也得说清楚什么时候别用。只有数据、没有行为、且只在局部使用的结构,用字典或者结构体就够了。比如解析一段JSON拿到临时数据,直接data["name"]取出来用掉,不需要为它专门建一个类。

还有就是纯函数式的逻辑。一个字符串处理函数、一个数学计算函数,写成类里的静态方法反而增加了一层无意义的包装。Python里直接放模块级函数就行,Java里用一个带私有构造器的工具类(防止被实例化)也够了。

判断标准其实还是那句:有规则要守、有状态要维护、或者这个结构需要在多个模块之间传递,才值得建类。为建类而建类,只会让代码变得绕。

最后分享一个我自己用了很多年的小习惯。每次写完一个类,我会回头扫一遍它的所有方法,问自己三个问题:这些方法是不是都在围绕同一件事服务?有没有哪个方法其实是在操作别的对象的数据?这个类如果换个人来接手,看名字能猜到它的职责吗?三个问题都能答"是",这个类基本就是健康的。答不上来,说明拆分的时机到了。

另外,初学阶段特别建议动手写两遍:先用"散装"的方式实现一遍功能,再把它重构成类,然后对比两版代码在"加一个新需求"时分别要改多少地方。这种对比做过一次,你对类和对象的理解就会从"知道"变成"感觉到",而后者才是真正能在项目里用得上的东西。

返回列表