类和对象:从"照着图纸造车"到真正理解面向对象
很多初学者学面向对象,被"类""对象""实例化""封装""继承""多态"这些词砸得头晕。我刚开始学Java那会儿也是这样,"类"听起来像数学课上的"集合","对象"又让我联想到"对象"在感情里的意思,完全不知道它们到底在说啥。直到后来用面向对象的思想写了几个像样的项目,回过头来才发现,类和对象其实没有那么玄乎——它就是我们对现实世界的一种建模方式,把"东西长什么样"和"东西能干什么"整理成代码而已。
这篇内容围绕"第8章 面向对象之类和对象"展开,适合刚开始接触Java或Python、对类与对象只有模糊概念的读者,也适合那些已经会写类但没想清楚"为什么要这样设计"的人。我会从最基础的"类是什么、对象是什么"开始,一路讲到对象创建时内存里到底发生了什么、三大特性怎么用、以及我在真实项目中踩过的坑和积累的设计经验。每一段都能直接对应到你的代码里,而不是停留在概念层面。
1. 类和对象的本质:一张图纸和一辆真车的关系
1.1 先建立一个直观的类比
如果让我用一句话向新手解释类和对象,我会说:类就是图纸,对象就是按照图纸造出来的那辆车。
你先定义Car这个类,写明它有哪些属性(颜色、品牌、速度),有哪些行为(加速、刹车、转弯),这就像设计师画了一张汽车图纸。图纸本身不能开,它只是一份规范。但要造车的时候,你就根据这份图纸去生产实实在在的车——这每一辆生产出来的车,就是一个对象,也就是"实例"。
你可以在同一张图纸的基础上造一千辆车,它们结构相同,但每一辆的颜色可能不同、车牌号不同、里程数不同,它们是独立的个体。
这张图和车的类比,几乎能套到所有面向对象的场景里。比如你写一个用户管理系统,User类就是用户模板,里面有用户名、密码、邮箱这些属性,有登录、登出这些方法。而张三、李四这些具体用户,就是User类创建出来的对象。你不可能直接拿User类去登录,你只能拿某个具体的User对象去登录,这就像你不可能开着图纸上路,你只能开着具体的某辆车出门。
1.2 为什么编程需要"类"这个东西
在没有面向对象的时代,写程序更像是"流水线"——数据是数据,函数是函数,数据在函数之间传来传去。这种方式写小脚本没问题,但一旦程序规模变大,比如做一个电商系统,里面有用户、商品、订单、购物车,你会发现这些概念之间天然就有关联:一个订单关联着一个用户和若干商品,一个商品又关联着它的分类、库存、价格。
如果你用传统方式,就得维护一大堆平行的数组或者结构体,人为去记"users[0]的订单是orders[3]"这种对应关系,极容易出错。而类这个东西,能把"用户"这个概念所包含的数据(姓名、电话、地址)和操作(修改资料、查看订单)打包在一起,变成一个完整的高内聚模块。
再说直白一点,类是为了让代码跟人类的思维方式对齐。人脑天然习惯"物以类聚",说到"猫",你脑子里马上浮现的是猫有毛、会喵喵叫、会抓老鼠,而不是一张写着"名字、颜色、体重"的数据表。面向对象编程就是把这种自然的认知方式搬进代码里,从"机器视角"转向"人类视角"。
1.3 类与对象的经典定义,用代码再说一遍
教科书上的定义总是很绕:"类是对象的抽象,对象是类的实例"。我用代码来说就清楚多了。
Python版本:
python复制class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def bark(self):
print(f"{self.name} 正在汪汪叫")
dog1 = Dog("旺财", 3)
dog2 = Dog("来福", 5)
dog1.bark()
Java版本:
java复制public class Dog {
private String name;
private int age;
public Dog(String name, int age) {
this.name = name;
this.age = age;
}
public void bark() {
System.out.println(name + " 正在汪汪叫");
}
}
Dog dog1 = new Dog("旺财", 3);
Dog dog2 = new Dog("来福", 5);
dog1.bark();
这两段代码里,Dog就是类,它描述了所有狗共有的特征;dog1和dog2就是对象,它们是具体的狗狗。同一个类可以产出无数个对象,对象和对象之间数据互不干扰——dog1改了自己的名字,dog2的名字不会跟着变。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 类的构成要素拆解:属性、方法、构造器分别扛什么活
2.1 属性描述对象的"状态"
属性,也叫成员变量、字段,它的作用是描述一个对象"是什么样"。比如一个人有姓名、年龄、身高、体重,一辆车有品牌、颜色、排量。在代码里,属性就是在类内部声明的变量。
在Java里我特别强调一点:属性声明在类里面、方法外面。很多初学者会把属性声明在方法里面,那就变成了局部变量,每个方法各用各的,根本存不住对象的状态。
java复制public class Student {
private String name; // 这是属性,属于整个学生对象
private int score; // 这也是属性
public void setName(String name) {
this.name = name; // this.name 指属性,右边的 name 指传入的参数
}
}
从"状态"的角度理解属性特别重要。一个对象从创建到销毁,它的属性值在不断变化,这些值的集合,就是对象当前的"状态"。比如订单对象,刚开始状态是"待支付",支付后变成"已支付",发货后变成"已发货"。这个状态就是靠属性记录下来的。
2.2 方法描述对象的"行为"
如果说属性是名词,那方法就是动词。方法是对象能执行的动作,它定义了对象"能干什么"。比如Dog类有bark方法,Car类有run方法,Order类有pay方法。
方法可以访问自己所属对象的属性,这既好理解又容易出设计问题。好理解的方面是,Dog的bark方法当然要能读到狗的名字;容易出问题的方面是,初学者写方法时经常把参数和属性搞混。
拿2.1里Student的例子来说,setName方法里如果不写this.name而是写name = name,那程序会以为两个name都是参数,赋值根本没生效。这是新人最常踩的坑。
设计方法时还有一个底层逻辑:方法应该只做一件事,并且通过方法名把这件事说清楚。我见过很多新人的方法叫doSomething或handle,一个方法里干五件事,过两周自己都看不懂。建议你写方法时想象自己在给它起词典里的词条名——updatePassword、calculateTotalPrice、sendEmail,看到名字基本就知道里面该写什么。
2.3 构造器:对象的"出厂设置"
构造器(Constructor)是类里一个很特别的方法,它的名字必须和类名完全一样,没有返回值类型。它的作用是什么?是完成对象的初始化。
还是用造车类比:图纸上只说明车有颜色、有发动机,但每一辆车出厂时,总得确定它到底是红的还是蓝的、发动机是2.0T还是1.5L。这个"确定出厂设置"的过程,就是构造器干的活。
java复制public class Car {
private String brand;
private String color;
// 无参构造器
public Car() {
this.brand = "未命名";
this.color = "白色";
}
// 带参构造器
public Car(String brand, String color) {
this.brand = brand;
this.color = color;
}
}
这里有两件事值得记住:
第一,如果你不写任何构造器,编译器会悄悄给你一个无参构造器。但只要你写了任何一个带参构造器,那个默认的无参构造器就没了。这时候如果你还想用new Car()来创建对象,就得自己把无参构造器写出来。
第二,Java里对象的创建都离不开构造器。你写new Car("大众", "黑色"),实际上的流程是:先在堆内存里给新对象分配一块空间,然后调用Car类的构造器,把"大众"和"黑色"这两个值填进对象的属性里,最后返回这个对象的引用。这一点我会在下一部分详细展开。
Python里的构造器是__init__这个方法,但注意它的作用更纯粹——它只是初始化器,不是真正分配内存的地方。Python在调用__init__之前其实已经通过__new__方法创建了对象。这个区别在普通开发里不用深究,但面试时经常被问到,知道一下没坏处。
2.4 类属性与类方法:属于整个类的东西
除了每个对象各自拥有的实例属性和实例方法,类还可以有"类级别的成员"。Java里用static关键字修饰,Python里定义在类内但通过类名调用。
类属性的典型应用场景是计数器。比如你希望统计一个类一共创建了多少个对象,就可以用一个静态变量count,每次构造器执行时count加1。这个count不属于任何一个单独的对象,它属于整个类,所有对象共享。
java复制public class User {
private static int totalCount = 0;
private String name;
public User(String name) {
this.name = name;
totalCount++; // 每次创建新用户,总数加1
}
public static int getTotalCount() {
return totalCount;
}
}
这里我提醒一个实战中的坑:静态变量是全局共享的,如果你的项目跑在多线程环境下,多个线程同时操作一个静态变量,会引发并发问题。需要保证线程安全时,要么加synchronized,要么用AtomicInteger这类原子类,这也是热搜词里"java的atomic类"在实际中发挥作用的地方。
3. 对象创建的完整链路:new关键字背后发生了什么
3.1 栈、堆、引用的三角关系
很多新手学面向对象,最不理解的就是"对象到底存在哪里"。Java和Python都是这样:对象本体存在堆内存里,而变量名存的是指向堆内存的引用。
栈内存(存放引用)和堆内存(存放对象)的关系,可以理解成一间房子和门牌号。房子盖在社区的某个具体位置(堆里的对象),你手里的地址卡片写着"幸福小区3栋502"(栈里的引用)。你拿着地址卡片就能找到房子,但卡片本身不是房子。
java复制Dog dog = new Dog("旺财", 3);
这行代码做的事情拆开来看就是三步:
- 在堆内存里创建一个Dog对象。
- 调用构造器,把"旺财"和3这两个值填进对象的属性。
- 把对象在堆内存中的地址赋给栈里的dog变量。
然后狗的名字,也就是dog.name,本质上是"通过地址卡片找到房子里面的名字属性"。这就是为什么Java里比较两个对象时,用==比较的是引用地址而不是内容——比较的内容必须用equals方法。
3.2 匿名对象和对象引用的小问题
实操里有一个现象经常让人迷惑:new Dog("旺财", 3)这个表达式本身,到底算不算一个对象?算。它确实在堆里创建了一个Dog对象,只是这个对象没有赋值给任何变量,所以叫匿名对象。
java复制new Dog("旺财", 3).bark();
这一行里,Dog对象用完一次之后就没人再指向它了,接下来就会成为垃圾回收的候选者。匿名对象适合只需要调用一次方法、不需要复用的场景,比如new Thread(new Runnable() {...}).start()。
但如果你需要反复使用同一个对象,就必须把它保存到变量里。否则你就写两遍new Dog("旺财", 3),结果看起来一样,实际是两个完全不同的对象。这是很多"为什么改了A不影响B"问题的根源。
3.3 对象判空和空指针的恩怨
热搜词里有一条"判断对象为空",这几乎是每个Java开发者每天都要面对的事情。你创建一个对象,然后调用它的方法,但如果这个对象实际上是null,程序一跑就抛NullPointerException。
java复制User user = findUserById(id);
System.out.println(user.getName()); // 如果user是null,这里直接崩
正常的防御性写法是提前判断:
java复制if (user != null) {
System.out.println(user.getName());
} else {
// 处理用户不存在的情况
}
Java 8之后推荐用Optional来优雅处理判空:
java复制Optional<User> userOpt = findUserById(id);
userOpt.ifPresent(user -> System.out.println(user.getName()));
我说句实在话,Optional用不好反而会让代码更啰嗦,动不动ifPresent和orElseGet套起来,可读性很差。我的经验是:方法内部能确认必不为空就直接用,外部接口传进来的对象才需要判空,别处处都判,否则代码全是防御代码,业务逻辑反而看不见了。
3.4 对象的创建频率与性能
对象创建不是免费的。每new一个对象,都要在堆里分配内存、调用构造器、后续还要被垃圾回收器盯上。在高并发或高频调用场景下,这都是一笔开销。
我做过一个支付相关的接口,QPS比较高。里面有个金额格式化工具类,一开始每次请求都new一个SimpleDateFormat对象,后来压测发现这一块的CPU占用高得离谱。最后改成用ThreadLocal或者直接换DateTimeFormatter,性能立刻上来了。
但这不意味着要为了省对象把代码写得极其别扭。在多数业务系统里,对象创建的代价可以忽略不计,真正的性能瓶颈基本在数据库、网络IO和慢查询上。我见过有人为了"性能"把所有地方都写成静态方法,结果把面向对象整没了,维护成本飙升,得不偿失。合理做法是:短生命周期的小对象随便new;重量级对象(比如数据库连接、线程池、对象工厂)复用。
4. 面向对象三大特性的实战理解:不是背概念,而是解决问题
4.1 封装:把不该暴露的藏起来
封装的含义是:类的内部细节对外部不可见,外部只能通过你暴露的方法来操作对象。
为什么需要封装?拿银行账户举例子。假设Account类的balance属性是public的,外部代码可以直接写account.balance = -10000,这合理吗?显然不合理,余额怎么可能是负数。如果balance是private的,外部就无法直接修改,只能通过deposit和withdraw方法操作,你就能在这两个方法里加业务校验——取款不能超过余额,存款不能是负数。
java复制public class Account {
private double balance;
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("取款金额必须大于0");
}
if (amount > balance) {
throw new IllegalStateException("余额不足");
}
balance -= amount;
}
}
封装还有一种被我反复验证的价值:它降低了修改代码时的爆炸半径。只要外部通过方法访问对象,你改内部实现时(比如把余额从double改成BigDecimal),外部代码一行都不用动。如果你把属性公开了,外部到处直接用account.balance,那你改类型时所有相关代码都要跟着改,牵一发动全身。
实际项目中我一般遵循这条规则:属性默认private,需要被外部访问时提供getter/setter方法。但要警惕getter/setter滥用——一个类的getter/setter如果比业务方法还多,那它可能就是个"数据袋子",不是真正的面向对象。
4.2 继承:代码复用,但不建议深度继承
继承解决的是"is-a"关系:Dog是Animal,Student是Person。子类拥有父类的属性和方法,同时可以扩展自己的特有行为。
java复制public class Animal {
protected String name;
public void eat() {
System.out.println(name + " 正在吃东西");
}
}
public class Dog extends Animal {
public void bark() {
System.out.println(name + " 正在汪汪叫");
}
}
继承的好处是代码复用明显,坏处是父类和子类的耦合度会变得很高。网上经常有人提"上帝类cpp"——一个类继承的层级特别深,或者积累了超多功能,最后变成没人敢动的大泥球。我见过一个老系统里有个BaseDAO,下面又继承出BaseEntityDAO、BaseUserDAO、BaseOrderDAO……三层下去,任何一层改动都会影响下面所有子类,根本不敢碰。
我的建议是:优先使用组合而不是继承。组合的意思是一个类持有另一个类的引用,用它来复用功能。比如奔驰车是车,但它和发动机的关系是"HAS-A"而不是"IS-A",所以引擎应该作为Car的属性,而不是通过继承来获得。
java复制public class Car {
private Engine engine; // 组合关系
public void start() {
engine.ignite();
}
}
只有当"is-a"关系非常明确且父类蕴含了强一致的行为时,才值得考虑继承。比如图形类(Shape)和圆形类(Circle),Circle一定属于Shape,且能继承draw()这类行为。
4.3 多态:同一个接口,多种实现
多态的本质是:同一个方法调用,在不同对象身上表现出不同的行为。最典型的场景是接口或者父类引用指向子类对象。
java复制Animal a = new Dog();
a.eat(); // 输出"旺财 正在吃东西"
Animal a2 = new Cat();
a2.eat(); // 输出"咪咪 正在吃鱼"
这里需要注意的是,如果Animal里有speak()方法,Dog和Cat各自override实现(一个汪汪叫,一个喵喵叫),那么最后调用speak()时,执行的是实际对象类型的方法,而不是引用类型的方法。Java的运行时多态就是靠方法表和动态分派实现的。
多态最大的价值是"让代码对扩展开放,对修改封闭"。如果需求来了一个新动物Pig,你只需要写一个Pig类继承Animal并override相关方法,不需要改任何调用方的代码。否则,每加一种动物你都要去修改那些if-else判断,代码会越来越臃肿。
4.4 抽象类与接口:什么时候用哪个
热搜词里有"抽象类和普通类的区别",这是面试高频题。我结合项目经验说清楚它们的定位。
抽象类:用abstract修饰的类,可以有抽象方法(没有方法体的方法),也可以有普通方法。它强调"这个类本身不完整,等着子类去补全"。
接口:Java里的interface,只声明方法签名,不提供实现(Java 8之后可以有default方法)。它强调"你能做什么",是一种能力契约。
怎么选?我总结出一套实用判断标准:
- 如果多个类之间是"is-a"关系,并且它们共享了大部分代码逻辑,用抽象类。
- 如果多个类之间没有血缘关系,但都有共同的行为能力(比如能fly、能run),用接口。
举个例子,Bird和Airplane都会fly,但你不会让Airplane继承Bird。这时候fly就应该放接口里,让Bird和Airplane各自实现。
还有一个经验之谈:在Java里一个类只能继承一个父类,但可以实现多个接口。所以你通常会看到一个类继承一个抽象类,同时实现两三个接口。这是Java设计者故意留的口子——既保留代码复用,又给你行为扩展的余地。
5. 实际项目中的类设计要点与避坑指南
5.1 一个类的职责应该足够单一
单一职责原则,英文叫Single Responsibility Principle,是SOLID原则里的第一条,也是我在日常代码评审里最常提的一条。
它要求一个类只负责一件事。如果哪个类里既有用户登录逻辑,又有订单支付逻辑,还有短信发送逻辑,那它就是个"上帝类"。上帝类的问题在于,改动任何一个功能都要动这个类,而一次改动人越多,越容易互相影响,代码质量会快速下滑。
我见过一个项目,Utility类里堆了三十多个静态方法,有的是字符串处理、有的是日期格式化、有的是Excel导入导出。看起来方便,实际用起来找方法都费劲,而且那些方法之间几乎没有联系,完全可以把它们拆成StringUtils、DateUtils、ExcelUtils三个类。
拆类的原则很简单:你给这个类起名字的时候,如果名字能概括它所有的职责,通常就是没问题的;如果一个名字需要"和"字才能概括,比如"用户和订单工具类",那就要考虑拆开。
5.2 对象数组去重:equals和hashCode必须一起重写
热搜词里有一条"对象数组去重",这是一个非常实际的需求。如果你用List.contains()或者Stream的distinct()来对对象数组去重,你会发现结果完全没按你的预期来——因为默认的equals方法比较的是引用地址,就算两个对象的属性完全一样,只要不是同一个对象,就被判定为"不相等"。
正确做法是重写equals和hashCode:
java复制@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return age == user.age && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
关于hashCode,有一条硬性约束:equals返回true的两个对象,hashCode必须相等。这是因为HashMap、HashSet这些基于哈希的容器,第一步是拿hashCode去找桶,如果两个相等的对象hashCode不同,它们会散落到不同的桶里,明明相等却永远无法匹配上。
我奉劝所有Java开发者:只要重写了equals,就必须重写hashCode。IDE里都有快捷键自动生成,别自己手写,手写容易漏掉字段导致逻辑不一致。
5.3 关于"java 对象转 json 保持顺序"的教训
又一个实用的热搜词。有些业务场景要求把对象转成JSON后,字段顺序必须和定义顺序一致。但常见的序列化库默认行为可能不保证顺序,尤其当目标是Map或者JSONObject时。
比如你用Gson序列化对象:
java复制Gson gson = new GsonBuilder().disableHtmlEscaping().create();
String json = gson.toJson(obj);
Gson序列化对象字段时,默认按Java反射出来的顺序处理,但如果你用TreeMap装数据,转出来的JSON会自动按字典序排列,这就不是你想要的了。要保证顺序,你应该用LinkedHashMap,它内部维护了插入顺序。
再比如用Jackson,你可以这样配置:
java复制ObjectMapper mapper = new ObjectMapper();
mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, false);
但这里有个更稳妥的经验:如果需要保证JSON顺序,直接在测试里写断言验证字段顺序,而不是依赖序列化库的行为。因为库在升级过程中可能调整默认行为,你的代码就被无征兆地破坏了。
5.4 类成员的可访问性设计:谁该被看到
Java提供了四种访问修饰符:private(私有,只有本类可见)、default(包级,同包可见)、protected(包级+子类可见)、public(公共可见)。
设计类的时候,我给自己定了几条铁律:
- 属性一律private,外部想操作属性只能通过方法。
- 工具方法如果只在本类内部使用,一律private,不要默认public。
- 需要被子类重写的方法才用protected,否则private。
- public方法是你对外发布的API,一旦公开了,之后改动就要小心别破坏调用方的兼容性。
这样设计的好处是,你给外部留下的"可触碰面"足够小。面越小,别人误用你代码的概率越低,你将来重构的胆量也越大。
5.5 泛型类和工具类的正确用法
热搜词里的"java线程安全的类""php接口数组对象""java匿名类"都指向同一个背后命题——不同语言和场景下,类的形态是多样的。
先说说匿名类。Java里匿名类是一个没有名字的局部类,通常用于一次性实现接口或抽象类:
java复制Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("任务执行中...");
}
};
new Thread(task).start();
Java 8之后lambda表达式基本取代了匿名类的很多场景,上面的代码可以简化为:
java复制Runnable task = () -> System.out.println("任务执行中...");
但匿名类并没有消失,比如需要实现含有多个方法的接口时,仍然得用匿名类。
再说线程安全的类。Java的java.util.concurrent包里有大量线程安全的类——ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue等。它们的设计思路各不相同,有的用分段锁,有的用CAS自旋,有的用写时复制。核心思想都是让多个线程能安全地同时操作数据结构,而不用手动加锁。实际使用中,我优先选择现成的并发容器,而不是自己加synchronized去包一层ArrayList——自己写锁容易出死锁和性能问题,并发容器是通过大量测试验证过的。
泛型类的意义在于让你的类能够适配多种类型。比如List
5.6 从"类加载"到"类的本质":一个容易被忽视的知识点
热搜词里的"类加载"其实是一个非常有价值的扩展话题。类和对象不只是写代码时的语法结构,在JVM或者Python解释器里,类本身也是运行时的一部分。Java的类加载机制包括加载、验证、准备、解析、初始化这几个阶段,类在被首次使用时才被加载到内存中。
有一次面试,面试官问我:"一个类的静态代码块什么时候执行?"我说:"类初始化的时候。"他又问:"那类什么时候初始化?"我说:"首次创建该类的对象,或者首次调用该类的静态方法时。"他点了点头。这个问题看起来简单,但背后就是类加载的知识。
理解类加载对排查问题也很有用。比如你改了某个类的代码,但服务不重启就不生效,这就是类已经被加载到JVM中,新的代码不会自动重新加载。除非用热部署工具,比如JRebel、Arthas等,否则只能重启应用。
Python里也有类的加载机制,import模块时类定义就会被执行,所以你在模块顶层写了print语句,import时就会打印。这些小知识点,平时不影响写代码,但排查诡异问题时就格外重要。
6. 我的类设计方法论:从需求到代码的完整思考
6.1 先找名词,再找动词
每次要设计一个新功能的类结构,我习惯从需求文档中把名词圈出来。比如"用户下单购买商品",这里面的名词有用户、订单、商品、购物车,这些通常就是候选的类。再看每个名词有哪些动词跟它相关:用户要注册、登录、修改资料;订单要创建、支付、取消;商品要上架、下架、修改价格。这些动词就是方法。
这个"找名词、找动词"的方法看似简单,但极其好用。它能保证你不会遗漏核心概念,也能帮你自然地把数据和行为放在一起,而不是散落在各个功能函数里。
6.2 处理"类爆炸"问题:什么时候应该合并
不过按这个思路做下来,会遇到另一个问题:类的数量越来越多。一个小的订单系统,可能拆出十几个类,真的每个类都需要吗?
我的判断标准是看类的变化频率和复用价值。如果一个类只有一个方法,别的地方也用不到,那它可能更适合做工具方法。如果一个类只有属性没有行为,它可能只是一个DTO(数据传输对象),这种类虽然价值有限,但在分层架构里有它的合理性——比如controller层从service层拿数据时,DTO能帮你隔离内部实体和外部接口,防止内部字段泄露。
但那种"函数式编程硬写成类"的情况,我建议直接改回函数。Java里你可以写一个类放静态方法,Python里直接用模块级函数就好,没必要为了"面向对象"而强行组织类。
6.3 测试驱动的类设计
设计类时还有一个屡试不爽的思路:先写测试代码,想象自己怎么使用这个类,再回去设计类本身。
比如我打算写一个OrderService,我先写测试:
java复制OrderService service = new OrderService();
Order order = service.createOrder(userId, productId, quantity);
boolean success = service.payOrder(order.getId(), paymentMethod);
先不考虑OrderService内部怎么实现,就看着这段代码问自己:createOrder方法需要哪些参数?返回什么?payOrder呢?这样写出来的类,接口设计更符合使用直觉,不会出现"调用方要传五个参数但有两个根本没用"的情况。
测试驱动设计还有一个额外好处:类一旦能被测试驱动设计出来,天然就是可测试的——依赖被注入了、方法职责单一、副作用可控。这比事后单测补覆盖率高多了。
6.4 类设计应该避免的常见坏味道
最后分享几个我评审代码时对"坏味道"的识别方法,全是经验总结,没有教科书那么严谨,但很实用:
- 过长的参数列表:一个方法超过5个参数,要么封装成参数对象,要么检查方法是否职责过多。
- 数据泥团:三个字段总是同时出现,比如地址的省、市、区,这时候就该封装一个Address类。
- 夸夸其谈的通用性:一开始就疯狂加泛型、继承、抽象,最后发现只有一种实现。YAGNI原则(你不会需要它)告诉我们,代码写到恰好满足当前需求就好,过度设计也是负债。
- 依恋情结:某个方法大量使用别的类的getter和setter来操作属性,这种方法往往应该挪到被操作的那个类里去。
把坏味道提前扼杀在设计阶段,比后期重构省太多事。我在项目里通常要求代码提交前做一次自我审查,对照这份清单过一遍,很多问题当场就能改掉。
面向对象这件事,学到后面你会发现,真正值钱的不是那套名词和语法,而是藏在背后的思维方式——把真实世界的秩序映射成代码的秩序。类和对象是这套秩序的第一块基石,地基打牢了,封装、继承、多态、设计模式这些上层建筑才立得起来。
我到现在还记得自己第一次写出"Mall系统有Store类、Product类、Order类,它们各司其职又相互配合"时的那种成就感。希望你看完这篇,再去翻教材的第8章,会觉得那些定义不再是一堆枯燥的黑体字,而是一套能实实在在帮你理清思路的工具。
