1. 先别急着上嘴脸,说说为什么我决定写这篇"面向对象(上)"
前阵子帮一个团队做Code Review,看到一堆小两万行的类,里面放满了getter/setter,方法之间互相调用全靠直觉,没有谁说得清这个类的核心职责是什么。问当事人,回答是"我按面向对象写的"。我就想笑也笑不出来——写类和理解面向对象,中间隔着一条很宽的河。
网上关于面向对象的教程一抓一大把,但绝大多数是"背定义"级别的:封装就是private,继承就是extends,多态就是重写父类方法。字面都对,实际一写就废。原因很单纯:你知道了语法按钮在哪,却不理解这个按钮为什么存在。等真正遇到复杂项目,一堆类互相纠缠,改一个功能横跨七八个文件,你就会明白语法只是表,设计思想才是里子。
这篇文章定位很明确——面向对象的上半场,把基础概念掰开揉碎讲清楚:类是什么、封装到底保护什么、继承怎么用才不会烂、多态为什么是面向对象最值钱的部分。不管你是刚学Java/C++/Python的初学者,还是写了一两年却总觉得哪里没开窍的"半熟手",我都建议把这一篇读完。代码示例我会用Java为主,C++和Python做对照,这样你在不同语言里都能无障碍对上号。
说实话,我见过太多人把面向对象学成了"用class装函数",然后还觉得挺美。这不是你的错,是大多数教程就没把"为什么"讲明白。今天这篇,我打算换个讲法——从问题出发,看看是哪些痛点逼着前辈们设计出了这套东西。理解了它们的动机,你再看那些概念,会顺很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 会写类不等于懂面向对象,先搞清楚它到底在解决什么问题
2.1 面向过程的尴尬:数据躺在一边,函数堆在另一边
你可以把面向过程的程序设计想象成一个大厨房:食材(数据)统一放在仓库区,菜谱(函数)贴在墙上。你要做一道菜,先跑仓库拿食材,再跑到墙边看菜谱,然后火急火燎开工。这在菜少的时候没问题,但餐厅规模一大,问题就来了。
写代码也是。假设你在做一个订单系统,最开始用面向过程的方式:
c复制// 订单数据结构定义
typedef struct {
int id;
double total;
int customerType; // 1:普通, 2:会员
} Order;
// 运费计算
double calcShipping(Order order) {
if (order.customerType == 2) {
return 0;
}
return order.total > 99 ? 0 : 15;
}
这代码刚写完的时候很清爽。但业务迭代三个月后,订单模型加了字段:重量、体积、配送区域、是否加急。运费规则也越来越多:会员怎么算、大件怎么算、偏远地区怎么算、加急怎么算……于是你的代码开始长这样:
c复制double calcShipping(Order order, User user) {
// 一大堆if-else
}
double calcDiscount(Order order, User user, Coupon coupon) {
// 又是一大堆if-else
}
void sendNotification(Order order, User user) {
// 需要判断订单状态、用户偏好……
}
发现没?数据在Order里躺着,操作这些数据的函数散落在各个工具模块里。每次给Order加一个新字段,比如加一个"虚拟商品标志",你就要把所有相关函数翻出来,逐个检查要不要加这个字段的判断。这个过程叫"散弹式修改",改一个需求,弹药打遍全项目。
2.2 面向对象的药方:数据和操作数据的逻辑,绑在一起过日子
面向对象的解法其实非常朴素:既然数据经常变、操作这些数据的逻辑也经常变,而且它们的变动总是成对出现,为什么不把两者装在一个容器里?
这个容器,就叫对象。
java复制public class Order {
private int id;
private double total;
private Customer customer;
private List<OrderItem> items;
private ShippingAddress address;
public double calcShipping() {
if (customer.isVip()) return 0;
if (total > 99) return 0;
return 15;
}
}
同样一个运费计算,现在变成了Order自己会算运费。你不再满世界找函数,而是直接问订单:"你的运费是多少?"这就是面向对象最核心的思想转变——从"以函数为中心操作数据"变成"以对象为主体,让它自己对自身负责"。
C语言时代的老前辈们其实早就意识到这个问题了。在正式支持class之前,C语言里常见的做法是结构体里塞函数指针,模拟出"对象"的效果:
c复制typedef struct {
int (*calcShipping)(struct Order*);
} OrderVtbl;
typedef struct {
int id;
double total;
OrderVtbl* vtbl;
} Order;
这套用函数指针模拟虚函数的手艺,在Linux内核源码里依然大量存在。不是没替代方案,而是这种"数据+行为"绑定的需求如此本质,以至于语言不支持的时候,大家在用各种姿势手动实现。
所以说,类不是语法彩蛋,它是被真实痛点逼出来的设计。你现在用class的时候,应该想的是"我将数据和操作这些数据的方法聚合在一起",而不是"我开了一个放函数和变量的文件"。
2.3 三种主流语言怎么定义类:语法不同,内核一致
既然这篇面向的语言横跨Java、C++、Python,我先把三者的定义一个基本轮廓:
| 语言 | 类定义关键字 | 构造方式 | 实例化 |
|---|---|---|---|
| Java | class |
构造函数与类同名 | new Order() |
| C++ | class(也可以struct) |
构造函数与类同名,可定义析构 | new Order() 或栈上 Order o; |
| Python | class |
__init__ 特殊方法 |
Order() |
无论哪种,核心都是同一件事:定义一种新类型,别人可以通过这个类型创建实例,实例里既有数据,也有操作数据的方法。这个本质定住了,你学任何新语言的面向对象语法都只会快不会慢。
比如Python里的定义:
python复制class Order:
def __init__(self, id, total, customer):
self.id = id
self.total = total
self.customer = customer
def calc_shipping(self):
if self.customer.is_vip():
return 0
if self.total > 99:
return 0
return 15
注意Python里的self,它跟Java里的this是同一个东西——代表"当前这个实例"。为什么Python要显式写?因为Python手写函数绑定的历史包袱,但好处是方法签名明确告诉了你:这个方法是操作哪一个对象。
3. 类不是数据容器,是契约和类型的定义——从struct到class的质变
3.1 struct描述"有什么",class描述"是什么且能做什么"
很多人把class理解成"更强大的结构体"——struct存数据,class存数据加函数。这个理解不能说错,但很危险,因为它忽略了一个关键的语义变化。
struct的核心语义是"数据结构":它是描述一组字段的集合。你说"Order有一个id,有一个total",这强调的是存储布局。而class的核心语义是"类型":它不只告诉你有这些字段,还定义了这个对象的完整行为边界和交互方式。
举个例子,你定义了一个BankAccount类:
java复制public class BankAccount {
private final String accountNo;
private double balance;
public BankAccount(String accountNo, double initialBalance) {
this.accountNo = accountNo;
this.balance = initialBalance;
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("存款金额必须为正数");
}
balance += amount;
}
public boolean withdraw(double amount) {
if (amount <= 0 || amount > balance) {
return false;
}
balance -= amount;
return true;
}
}
这里的重点不是BankAccount里有两个字段,而是:任何代码想操作余额,都只能通过deposit和withdraw这两个方法。存款负数的检查、取款超额的拦截,全部收拢在这两个方法里。这就是一个类型——它不只是装两个数的容器,它约定了"什么叫合法的银行账户操作"。
3.2 "类的方法不会复制到每个对象里"——很多人挂在嘴边的直觉是错的
第一次接触对象的人,很容易脑补出这样一幅图:每个对象都是一个独立的小背包,里面装着它自己的数据,还装着一份它自己的方法。实际上,方法根本不进背包。
类的方法在内存里只有一份,放在代码段里,所有实例共享。每个实例只保存自己的属性值(非静态的)。当实例调用方法时,编译器或运行时隐藏地把"当前实例的引用"(Java里的this,Python里的self)传进去,于是方法才知道该操作哪个对象的数据。
这也是为什么没有实例也能访问类方法、静态方法(static方法)时不涉及this的原因——这种方法和具体实例其实没绑定关系,它操作的是类级别的东西。
你看看这段Java代码:
java复制BankAccount a = new BankAccount("A001", 1000);
BankAccount b = new BankAccount("B002", 2000);
a.deposit(500); // 方法代码只有一份,但this指向a,改的是a.balance
b.withdraw(300); // this指向b,改的是b.balance
这个机制如此重要,是因为它决定了你在设计类的时候,方法放哪个类,本质就是"谁负责维护哪部分数据"。如果一个方法操作了A类的数据,又操作了B类的数据,那它属于谁?这种"归属感"问题,正是类职责划分的起点。
3.3 构造:对象不是一张空表,是一出生就合法的状态机
面向过程的struct可以声明完不初始化,字段全凭良心。面向对象的class则给你提供了构造函数,强迫你定义一个对象"出生时该长什么样"。
Java里叫构造器,C++里叫构造函数,Python里是__init__。名字不同,作用相同:确保对象一创建就处于合理状态。
java复制public class Student {
private final String studentId;
private final String name;
private int score;
public Student(String studentId, String name) {
this.studentId = studentId;
this.name = name;
this.score = 0; // 初始分数定为0,这是业务上合理的最小状态
}
}
构造函数还有一个容易被忽略的作用:它决定了创建对象的门槛。如果一个类的构造函数要求必须传studentId和name,那你就不可能创建一个没有学号的"半残"学生对象。这在工程上叫"无法表达非法状态"——非法对象在编译期就被堵死了,而不是拖到运行时到处爆空指针。
C++除了构造函数,还多了一个析构函数。这个设计跟资源管理强绑定,后面讲C++特性时会细说,但你先记住一个结论:构造函数决定对象如何生,析构函数决定对象如何死,这两件事都是"完整对象"的一部分。
3.4 Python特殊方法的哲学:__init__不是构造函数
Python里的__init__经常被说成是构造函数,严格说有偏差。Python对象的真正构造函数是__new__,它负责分配内存并返回实例;__init__只是实例创建之后的初始化钩子。你日常基本只碰__init__,知道这层区别就够了。
不过Python有一个别的语言少见的习惯:双下划线方法(dunder methods)可以直接决定对象支持哪些语法语义。比如你定义了__eq__,两个对象就能用==比较;定义了__lt__,对象就能sorted排序;定义了__len__,就能用len(obj)。本质还是"绑定数据和操作",只是Python把这个机制铺展得更语言级。
这也引出一个实际建议:写Python类时,不要一上来就搞一堆工具方法,先把__init__、__repr__、__eq__这几个基础的特殊方法写对,对象的基础体验就有了八十分。
4. 封装:它保护的不是数据,是业务规则的不可绕过性
4.1 public字段为什么是给自己埋雷
新手最常见的写法是这个:
java复制public class Account {
public double balance;
public String customerName;
public String phone;
}
然后业务代码到处出现account.balance -= 500;、account.balance += 1300;。表面上看,这比每个操作都写个方法省事得多。但问题来了:如果有一天产品说,账户余额减少超过5万需要风控审批,你怎么办?
你满项目搜索balance -=,把所有改余额的地方全翻出来加判断。漏掉一个,风控就破了个洞。这个问题的根源不是需求变态,而是你给了所有代码直接改余额的资格,却没有一个统一的关卡。
封装的本质,就是在这条路的唯一隘口上设置关卡。
java复制public class Account {
private double balance;
public void decrease(double amount) {
if (amount <= 0 || amount > balance) {
throw new IllegalStateException("扣款金额异常");
}
if (amount > 50000) {
riskControlService.approve(customerName, amount);
}
balance -= amount;
}
}
改回私有字段之后,所有扣款路径都必须经过decrease,风控逻辑只需要写在这里一次。将来加更多规则——比如单日累计扣款限额、本地余额不足自动切换其他账户——都是在decrease里继续叠就行,外面调用的代码一行不用改。这就是封装带来的一个词:收拢。
4.2 Java/C++/Python的访问控制差异,别混在一起用
访问控制是封装的主要语法工具,但三种语言的做法不一样,学习时容易混。
Java提供四种:public(任何地方可见)、protected(包内加子类可见)、default(包内可见)、private(仅类内可见)。C++的访问控制和Java很像,但C++的继承本身也可以跟着修饰符走——private继承、protected继承,这个比Java复杂,一般场景说实话用得不多。
Python则是另一套风格:没有强制的private,靠约定和名称改写(name mangling)。单下划线_val是"内部使用,外部请勿访问"的约定;双下划线__val会触发名称改写,变成_ClassName__val,但也不是绝对封死——你依然能通过改写后的名字访问到。
说实话,Python这种设计在工程上更考验自觉性。如果你在一个团队里写Python,建议明确约定:凡是内部状态一律单下划线开头,外部代码不要强行访问。要不要双下划线?看项目风格。过度用双下划线会让排查问题变麻烦,尤其是调试和继承覆盖的时候。
4.3 别把封装理解成"把所有字段都private"
一个常见的跑偏:为了封装,把所有字段private,然后每个字段配一个纯getter和纯setter,八九十行样板代码一摆,就觉得自己很面向对象了。
那只是把字段藏起来,门口连个检查的人都没设。真正的封装是:你的外部交互接口要能表达业务操作,而不是暴露字段读写。
比如余额这个字段,正确的面向对象接口是decrease、increase——这表达的是业务动作。如果你的类里全是getBalance/setBalance,那意味着任何调用方都可以绕过业务规则直接改值,跟public字段没本质区别,只是多了一层形式主义。
而且这种行为也让后面做领域驱动设计(DDD)变得很难受。DDD里的实体、值对象之所以强调封装,就是因为业务规则必须内聚在领域对象内部。你把规则漏到service层满街跑,那跟面向过程的散弹式修改没什么两样。
4.4 实战案例:把"未读消息数"封装成一个正经类
来看一个真实业务里很常见的场景。很多App有未读消息数。最朴素的实现就是:
java复制int unreadCount = 0;
// 来消息了:
unreadCount++;
// 用户打开会话:
unreadCount = 0;
// 显示:
badge.setText(unreadCount + "");
后来需求变成了:未读数超过99要显示"99+",打开会话时清零,但只清当前会话的未读数。于是你在各处加判断,控制逻辑分散在一堆Activity和Fragment里。代码量一膨胀,你就能体会到这事的痛苦。
用面向对象重写,可以先做一个UnreadCount类:
java复制public class UnreadCount {
private static final int MAX_DISPLAY = 99;
private int count;
public void increase() {
if (count < MAX_DISPLAY) {
count++;
}
}
public void reset() {
count = 0;
}
public String displayText() {
return count >= MAX_DISPLAY ? "99+" : String.valueOf(count);
}
}
然后每个会话维护自己的UnreadCount实例,UI层只掉displayText()展示。以后再怎么变策略——比如加未读数合并、折叠、置顶——都只改这一个类。别人调你的代码时,需要理解的东西也少了很多:不就是一个会增加、会清零、会格式化显示的数字吗?这种"接口看得到意图,实现藏得住变化"的感觉,就是封装给你的回报。
5. 继承:理解is-a不是全部,你更需要警惕三条最常见的烂用之路
5.1 继承和接口,先说清楚is-a这个关系
继承的教科书定义是:子类是一种父类。Dog extends Animal,因为狗是动物。Circle extends Shape,因为圆是图形。这个关系叫is-a。
真正到了代码里,要判断一个继承是否合理,有一个非常实用的测试:凡是能用父类的地方,换成子类应该同样成立,而且不破坏行为约定。这句话来自里氏替换原则(LSP),别看名字拗口,意思其实很家常。
下面这个反例你可能见过——正方形继承矩形:
java复制public class Rectangle {
private int width;
private int height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int w) {
super.setWidth(w);
super.setHeight(w);
}
@Override
public void setHeight(int h) {
super.setWidth(h);
super.setHeight(h);
}
}
数学上正方形是矩形,但代码世界里,Square继承Rectangle会让你怀疑人生。假如有一段代码信任Rectangle的行为约定:
java复制Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
// 约定: 面积应该是 5 * 10 = 50
// 实际: Square 把宽高都改成了10, 面积 100
调用方按父类的文档和逻辑推理,结果被子类打了脸。问题就出在:"矩形"这个类隐含了宽高可以独立设置的约定,而正方形违反了这条约定。所以你脑子里的is-a(数学里的正方形是矩形)和代码世界里的is-a(行为约定上的可替换)是两回事。代码里的继承,讲的是"行为契约上的is-a",不是我们现实里的分类。
5.2 误用一:为了复用代码而继承
这是新手频率最高的继承误用。两个类有一模一样的工具方法,就抽一个父类出来,让它们继承。比如一个BaseController里放了一堆工具方法——格式日期、解析JSON、拼URL,然后所有Controller继承它,美其名曰"公共代码抽取"。
这个做法的直接后果是:子类被迫继承了它根本不需要也无意义的父类职责。你后来想换个公共方法实现,动一下父类,所有子类都可能受影响。更麻烦的是,Controller之间本来没有is-a关系,你为了复用硬生生造一个祖宗出来,整个体系就扭曲了。
正确的姿势是组合优先于继承:把公共逻辑放到独立的类里,让需要它的类持有一个这样的类作为成员:
java复制public class DateFormatter {
public String format(Date date) {
// 日期格式化统一逻辑
}
}
public class OrderController {
private final DateFormatter dateFormatter = new DateFormatter();
}
这样每个类只保留自己的职责,公共能力是通过组合带进来的,不是靠血缘遗传的。
5.3 误用二:继承层次太深,脆弱基类问题
三层四层的继承树是能写出来的,但每一层都意味着父类的改动会被无限放大。这叫脆弱基类问题:基类表现合理,但子类某个方法被覆盖后,基类的其他方法在调用这个被覆盖的方法时,行为就会改变,而且这个改变往往出其不意。
Java里的经典案例是Stack继承Vector。Stack只需要栈的能力——push、pop、peek,结果它继承了Vector的add、remove、get,于是你能在Stack中间随意插入一个元素。这是从语法层面就允许的"栈操作非法行为"。JDK官方后来都不推荐直接用Stack,就是这个原因。
所以实践中的经验法则:继承层次控制在两层,撑死三层。再往下走,抽象层级越来越虚,改动任何一个基类都像抽积木。更好的做法是让多个类实现同一个接口,各写各的实现,而不是在一个继承树上无限延伸。
5.4 误用三:子类违反了父类的核心约定
还有一种继承,语法完全正确,is-a也勉强说得通,但它覆盖了方法之后,悄悄改变了父类对外承诺的语义。比如父类的save()文档里写着"保存成功返回true,失败返回false",子类覆盖后变成"保存失败抛异常,成功返回true",再或者覆盖后的save()会顺带发通知邮件。
这样的继承会让整个调用链变得无法预测。所有以父类类型为参数的方法,本来可以信任父类的约定,现在因为子类的花样,行为变得魔幻。解决这个问题刻不容缓的一句话是:覆盖方法时,不能收窄父类承诺的能力,也不能扩展调用方无需知道的行为。
5.5 正确用继承:模板方法模式是典型正例
继承的正向用法不是"继承实现实现在继承字段",而是利用继承实现一种设计——模板方法模式。父类把算法骨架定好,把可变步骤留成抽象方法,让子类去补足差异。
举个例子,一个数据导入器,整体流程都是一样的:读文件、解析每行、校验数据、写库。但不同业务导入的文件格式不同、校验规则不同:
java复制public abstract class FileImporter {
public final void importFile(String path) {
List<String> rawLines = readLines(path);
List<Record> records = parse(rawLines);
List<Record> validRecords = validate(records);
save(validRecords);
}
protected abstract List<Record> parse(List<String> lines);
protected abstract List<Record> validate(List<Record> records);
}
子类只需要实现parse和validate,主流程父类锁死。这种用法的核心是"固定框架、开放差异",子类和父类的约定非常清晰,继承的语义是真正成立的。你可以把这种模式记下来,遇到"流程固定但局部变化"的场景时很有用。
6. 多态:同一个方法名,不同的行为,这才是面向对象的王炸
6.1 重载、重写、动态分派,先来分清这三组容易混的概念
多态这个术语上,很多人先把三个东西搅在一起了。
第一个是重载(overload)。同一个类里方法名相同、参数列表不同,这是编译期就定死的选择,属于编译时多态。比如print(int)和print(String)。
第二个是重写(override)。子类覆盖父类的方法,方法签名一致、返回值兼容。这是运行期多态的基础。
第三个是动态分派(dynamic dispatch)。运行的时候,程序根据对象的实际类型,找到真正该执行的那个方法。Java默认支持,C++需要virtual关键字显式开启,Python天生动态。
为了把这些概念落进代码,来看一个最简单的例子:
java复制public abstract class Shape {
public abstract double area();
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius) { this.radius = radius; }
@Override
public double area() {
return Math.PI * radius * radius;
}
}
public class Rectangle extends Shape {
private final double w;
private final double h;
public Rectangle(double w, double h) { this.w = w; this.h = h; }
@Override
public double area() {
return w * h;
}
}
然后我们写一个"绘制所有图形面积"的场景:
java复制public static void printAreas(List<Shape> shapes) {
for (Shape s : shapes) {
System.out.println(s.area());
}
}
这里有三个值得注意的点:
第一,调用s.area()这句代码,在编译期看起来不知道具体执行哪个类的方法——Circle的还是Rectangle的——它只知道Shape有area()这个抽象方法。
第二,运行期拿到一个Circle对象时,它会自动调用Circle.area();拿到Rectangle对象时,自动调用Rectangle.area()。这个"自动"就是动态分派。
第三,将来你要加一个Triangle类,不需要改动printAreas方法。新增类型的行为在运行期自然被分派,这是多态带来的扩展性——符合开闭原则的开,对扩展开放,对修改闭合。
6.2 C++里的virtual关键字:为什么别的语言默认多态,C++偏要你声明
一个小插曲值得说说。C++里,如果父类的area()不加virtual,子类覆盖了方法,但通过父类指针/引用调用时,行为会非常微妙:
cpp复制#include <iostream>
using namespace std;
class Shape {
public:
double area() { return 0; }
};
class Circle : public Shape {
public:
double area() { return 3.14 * r * r; }
Circle(double r) : r(r) {}
double r = 0;
};
int main() {
Shape* s = new Circle(10);
cout << s->area() << endl; // 输出 0, 而不是 314!
delete s;
}
原因在于C++默认是静态绑定,s->area()按Shape*的静态类型找方法,直接编译期就定了调用Shape::area()。要把这个行为扭成运行期动态分派,必须显式声明virtual。
这个设计的动机是性能——不想要动态分派的对象可以不付出虚表间接调用的代价。但代价是C++新手(甚至老手)会在这里踩坑:忘了加virtual,多态不出来,还查不出是为什么。所以我的建议是:在C++里,只要这个类有被继承的意图,且方法需要在子类中替换,一律加virtual,别省。
6.3 Python的鸭子类型:不继承也能多态
Java和C++的多态强依赖继承和接口,Python就比较特别了——它走的是鸭子类型:如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子。只要对象有对应的方法,就可以被当作那种类型用。
python复制class Circle:
def area(self):
return 3.14 * self.radius ** 2
class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
def print_area(shape):
print(shape.area())
print_area(Circle(10)) # 可以
print_area(Rectangle(3, 4)) # 也可以
注意这里的Circle和Rectangle并没有继承同一个父类,它们甚至互不相识,但print_area照样工作。这就是Python风格的多态:不追求类型上的血缘关系,只关心对象是否具备当前所需的方法。
这种写法让Python代码很灵活,但也带来一个问题:方法名冲突或者拼写错误很容易在运行期才暴露。所以写Python多态的时候,建议配合类型标注和协议(Protocol)来引入一些静态约束,不能只靠运行时撞运气。
6.4 多态和依赖倒置:面向抽象编程的起点
多态除了让代码扩展方便,还支撑了一个更重要的设计原则——依赖倒置。反过来直白说就是:模块之间不要直接依赖具体实现,要依赖抽象。
一个实际例子。假设系统需要发送消息到MQ,最开始直接写:
java复制public class OrderService {
private MQClient mq = new MQClient();
public void createOrder(Order order) {
mq.send("ORDER_CREATED", order);
}
}
这个写法一旦消息渠道改成Kafka,或者把MQClient实例化方式改了,OrderService就要跟着翻改。用多态重构:
java复制public interface MessageSender {
void send(String topic, Object payload);
}
public class MQMessageSender implements MessageSender {
@Override
public void send(String topic, Object payload) {
// 具体发送逻辑
}
}
public class OrderService {
private final MessageSender sender;
public OrderService(MessageSender sender) {
this.sender = sender;
}
public void createOrder(Order order) {
sender.send("ORDER_CREATED", order);
}
}
OrderService现在只依赖MessageSender这个接口,不管实现是MQ、Kafka还是本地日志,它一概不关心。将来换实现,只是换个构造参数而已。这就是面向接口/抽象编程的威力,也是多态从语法层面上升到架构层面的桥梁。
7. 三个自检问题:帮你判断自己的类设计得够不够面向对象
写到这里,语法和原理都过了一遍,最后我把自己日常Review代码时最常问的三个问题分享出来。拿任何一个类出来,回答不了这三个问题,设计大概率还有改进空间。
第一个问题:"这个类对外暴露的方法,是不是都在描述这一类对象的业务行为?"如果一堆方法本质上是在给你开数据库方便之门,或者让你扒开内部结构随意存取,那封装是假的。类应该对外回答"能干什么",而不是"里面有什么"。
第二个问题:"这个类能不能用一个通俗的名词解释清楚它的职责?"如果一句话说不清自己的类到底管什么,或者一个类既管用户认证,又管通知发送,还管数据解析,建议拆。职责混乱是大部分代码难维护的头号源头,与语言无关。
第三个问题:"改动这个类的一个内部逻辑,会不会让外部代码跟着遭殃?"如果会,说明接口泄漏了内部实现;如果不会,恭喜,封装到位了。这个问题的另一个版本是:当你需要新增一种行为时,是去改这个类,还是加一个新实现?答案应该是后者。
这三个问题不是面试题,是我日常判断代码可维护性的压舱石。你可以拿自己手头的代码试试,不用多,挑三个类出来对照,多数情况下你会发现问题不少。发现问题本身,就是改进的开始。
面向对象上半场就先聊到这里。这篇把类、封装、继承、多态这四个基本功讲透了,下一篇适合聊聊抽象类与接口怎么选、组合和聚合怎么配、以及那些面向对象中更进阶的设计问题。学编程始终要记得:语法是工具,设计是手艺,多写多拆多复盘,代码自己会说话的。
