你有没有过这种经历:看别人的Java代码,每一行都认识,类、对象、继承、多态这些概念也都能说出个一二三,可真让你自己动手写一个东西,打开IDE就卡在空白的第一行。这种“看得懂代码,却写不出代码”的体验,在学Java的过程里特别常见,尤其是接触到面向对象编程的时候,很多人会突然觉得自己前面好像白学了。
这个系列到这一篇正好是第五篇,主题就是面向对象编程基础。前面几篇我们把变量、流程控制、数组、方法这些基础语法过了一遍,现在终于到了Java最核心的地带。这篇我不会把OOP的教科书概念重新抄一遍,而是想站在“为什么你看完例子还是不会写”这个角度,把类、对象、封装、继承、多态这些概念,用写代码时的真实顺序重新梳理一遍。内容适合两类人:一类是刚学完基本语法、正要进入面向对象的初学者,另一类是能读懂教程代码但自己动手就卡壳的同学。
1. “看得懂”和“写得出”之间,到底差了什么
1.1 阅读代码时你其实在用“翻译思维”
读代码和写代码,看上去是同一件事的两面,实际上用的是两种完全不同的能力。读代码时,你是在做“翻译”:看到一个Student类,你知道它有name、age属性,有study()方法,你的大脑会不自觉地把这些和“学生”“学习”这些日常概念对应起来。这种翻译工作很轻松,因为代码已经被作者加工过了,你只需要把Java语法映射回现实语义就行。
写代码则完全是另一回事。面对空白的编辑器和闪烁的光标,没有人帮你把“学生管理系统”翻译成Student类。你需要自己决定:这个系统里有哪些类?每个类应该有哪些属性和行为?谁和谁是什么关系?这些问题在阅读别人代码时根本不出现,你能看到的只是作者思考之后的最终结果。
打个比方,读代码就像看一幅已经画好的作品,构图、配色、笔触都被处理完了,你要做的只是欣赏;写代码则像让你自己画一幅画,你得先在心里完成构图和配色的决策。很多初学者没有意识到这种差异,以为自己读得懂就等于会了,结果一到写的时候就露馅。阅读时被自动忽略掉的那些“设计决策”,恰恰是你要补上的核心能力。
1.2 面向对象首先是观察世界的方式,然后才是语法
很多Java教程一上来就讲class、public、private、extends、implements,语法密密麻麻,让初学者以为面向对象编程就是学一套语法规则。这个印象是误导的。面向对象最内核的东西不是语法,而是一种观察世界的方式。
在真实世界里,我们看一辆车,不会想“这里有一段钢铁、一个发动机、四个轮子”,而是直接想“这是一辆车”。车有颜色、品牌、速度这些状态,有启动、刹车、转弯这些动作。面向对象编程就是让代码按同样的方式来组织:把“车”这个概念写成一个class,把颜色和速度写成字段,把启动和刹车写成方法,再通过new关键字,按这张“概念图纸”造出一辆辆具体的车。
Java里几乎所有语法,都可以在现实世界找到对应物:类描述“有什么”,对象表示“具体个体”,继承表达“是一种”,组合表达“拥有一个”。理解了这些对应,再回头看语法,你会发现它们不是一堆需要死记硬背的规则,而是一套描述世界结构的工具。很多看得懂代码的人,卡就卡在把面向对象当成语法题,天天抠单词和符号,却从没想过这个类为什么这样设计、这个方法为什么放在这个类里。
1.3 写不出来的真正原因:缺少“设计动作”
把“看得懂写不出”归结为“练得少”,这个说法表面没错,但没说到点子上。练得少的背后,真正缺的是一个具体动作——设计动作。你读代码时能看懂里面的方法,是因为作者把方法写好了;轮到你写,你得从需求里把方法提炼出来。你读代码时能看懂继承关系,是因为作者设计完了;轮到你设计,你得判断这里到底该不该用继承。
缺少设计动作,具体表现就是三个“不知道”:不知道从哪里开始、不知道类该拆多细、不知道方法该放哪个类。想突破这一关,得有意识地训练三个习惯。第一个习惯是拿到需求后先把名词和动词圈出来,名词通常是类或属性,动词通常是方法。第二个习惯是为每个候选类确定三到五个核心属性,再确定两个以上核心行为,不要一上来就追求完整。第三个习惯是用箭头画出类与类之间的关系,哪怕只在纸上画两分钟,也能帮你避免写到一半推翻重来。
这三个习惯不是耍花架子。我带过不少新人,凡是动手前愿意做这一步的,后面写代码的效率明显更高;凡是拿到需求就狂敲代码的,十有八九会陷入“删了重写”的循环。设计动作的熟练度,就是区分“看得懂”和“写得出”的分水岭。
2. 类设计的三板斧:属性、行为、关系的建模顺序
2.1 从现实事物中抽取属性和行为:名词动词练习
拿到需求,最忌讳的是立刻打开IDE敲代码。正确做法是先从自然语言里抽取信息。有一个很笨但很有效的套路:找名词和找动词。
假设需求是“给学校设计一个简单的学生管理系统,能记录学生的姓名、学号,学生可以选课、上课”。把这句话里的名词和动词列出来,很快就能理出头绪。
| 词 | 类型 | 初步判断 |
|---|---|---|
| 学校 | 名词 | 系统边界,先不急着建模 |
| 学生 | 名词 | 核心类 |
| 姓名 | 名词 | 学生的属性 |
| 学号 | 名词 | 学生的属性 |
| 课 | 名词 | 核心类 |
| 记录 | 动词 | 系统提供的功能 |
| 选课 | 动词 | 学生的行为 |
| 上课 | 动词 | 学生的行为 |
这样一拆,答案其实已经浮出来了:围绕“学生”和“课程”两个核心类来建。学生有name和studentId两个属性,有selectCourse()和takeClass()两个行为;“记录”这个动作可以单独做一个StudentManager类,专门负责增删改查和存储。你看别人代码时看不到这个提炼过程,因为作者早就把它做完了,并且藏进了类名和方法名里。可这个提炼过程,恰恰是你自己动手时最该先做的事。
这个练习看起来简单,但非常值得反复练。初期可以拿业务需求、产品文档甚至一段需求描述来练,每次都做同一件事:把名词分成“可以作为类的名词”和“只能作为属性的名词”,把动词分成“属于这个类的方法”和“属于另一个类的方法”。练上十几次,你再看到一个需求时,脑子里自然就会开始拆结构,而不是一团乱麻。
2.2 类与对象的关系:new到底做了什么
很多初学者嘴上说着“Java是面向对象的”“一切皆对象”,但一直不太清楚new这个关键字到底做了什么。用一个比喻来解释:class是图纸,new是按图纸造房子。
比如Student类里定义了name和age两个字段,这相当于图纸上写着“这套房子会有两个房间,一个叫name,一个叫age”。当你写下new Student("张三", 20)时,Java会先在方法区找到Student这个类定义(图纸),然后在堆内存里开辟一块独立的空间,把name放上字符串“张三”,把age放上数字20。图纸只有一份,但你可以依据它new一百次,造出一百个各自独立的学生对象。
这些对象之间互不干扰。我修改了对象a的age,对象b的age不会跟着变,因为它们是在堆内存里完全独立的两块区域。这一点看似简单,实际操作中却时不时有人踩坑,尤其是把对象赋值给另一个变量时:假如写Student s1 = new Student("张三", 20); Student s2 = s1;,此时的s1和s2指向的是同一个对象,你改s2的age,s1的age也会跟着变。理解“引用”和“对象”的区别,是编写Java绕不开的关口,也是以后理解集合、框架源码的基础。
2.3 一个“危险”的习惯:一上来就写代码
新手最普遍的问题,是拿到需求就开始敲键盘,认为“代码写得越多,进度就越快”。这个习惯在中大型需求面前特别危险,因为类结构一旦定错,后续所有代码都会建立在一个错误的地基上,返工成本相当高。
我建议的最小流程是三步。第一步,用文字列出候选类,并给每个类写上三到五个核心属性和行为,写不全也没关系,能写出多少是多少。第二步,用箭头简单画出关系,比如谁依赖谁、谁继承谁、谁组合了谁,只需要画出自己认知里的初步判断。第三步,全部想清楚了,再打开IDE写代码。
这三步不会花很多时间,却能避免最大的坑——“写到一半发现类设计错了,全部推翻重来”。很多觉得自己写代码慢的人,问题恰恰出在前期想得太少。我见过的新人里,凡是先画两分钟草图的,最后代码完成质量和完成速度都明显高于直接开写的。磨刀不误砍柴工,在类设计这个环节,这句老话尤其成立。
3. 封装、继承、多态:把三大特性变成肌肉记忆
3.1 封装:不是把变量设为private就完事了
面向对象有三大特性,封装最容易让人产生“我已经会了”的错觉。看到private字段加getter/setter,很多人点头说“我看懂了,就是私有化字段,再提供公开方法”。但你有没有认真想过,这一层封装到底图什么?
看一段最典型的代码。
public class Student { private String name; private int age; public Student(String name, int age) { this.name = name; setAge(age); } public String getName() { return name; } public void setAge(int age) { if (age < 0 || age > 150) { throw new IllegalArgumentException("年龄不合法"); } this.age = age; } public int getAge() { return age; } }关键就在setAge方法里的校验。如果不封装,外部代码可以直接拿student.age = -5,把这个对象的数据改坏;有了这一层setter,就等于给数据的入口装了一个守门员,所有进入对象的年龄都必须先经过合法性检查。这才是封装的实际价值,而不是为了“看起来规范”把字段统统私有一遍。
自己动手写类时,一个小建议是:字段默认用private,对外提供的方法默认用public,但别把getter/setter写成无脑自动生成。好的setter能体现业务规则,好的getter可以只暴露别人真正需要的内容。封装是手段,保证数据安全和对象状态合理才是目的。
3.2 继承:什么时候该用,什么时候慎用
继承的语法很简单:子类用extends关键字继承父类的字段和方法,然后可以覆盖父类方法,也可以添加自己的新方法。难的一直不是语法,而是“到底该不该用继承”。
一个常用判断标准是is-a关系:只有“子类是一种父类”的时候才适合继承。狗是一种动物,所以Dog extends Animal没问题;学生是一个人,所以Student extends Person顺理成章。但如果只是“学生”拥有“班级”、“订单”包含“订单项”这种组合或关联关系,就不该用继承硬套。
继承用得好,能大量复用公共代码;用得不好,会把代码越绑越死。举个典型的反例:你写了一个Bird类,里面有fly()方法。后来需求要加企鹅类,你觉得企鹅也是鸟,于是让Penguin extends Bird,结果Penguin继承了fly(),很尴尬。这时候更合理的做法,要么把鸟拆成“会飞的鸟”和“不会飞的鸟”两层,要么把fly()提炼成接口,让企鹅干脆不实现它。
所以在设计继承关系时,动手前先问自己一句:这个子类能不能完全替代父类?如果能,继承大概率没问题;如果不能,就该考虑用组合或其他方式。这个判断训练得多了,慢慢就会形成条件反射。
3.3 多态:接口和父类引用指向子类对象的真实意义
多态是初学者最容易“会背不会用”的概念。定义背得很熟:同一个方法调用,在不同对象上有不同的行为。可真写代码时,很多人的疑问是:知道这个定义能干什么?
多态最常见的写法,是父类引用指向子类对象。
Animal animal = new Dog(); animal.sound();看到这行,很多人的第一反应是觉得多此一举,直接Dog dog = new Dog()不是更直接吗?但在实际项目里,父类引用指向子类对象的价值,要放在方法参数和容器里才看得清楚。
public void makeSound(Animal animal) { animal.sound(); }这个makeSound方法只依赖Animal类型,却可以接收Animal的任意子类。今天传Dog,明天传Cat,后天再传Cow,这个方法本身一行都不用改。代码写的是抽象类型,真正干活的是具体对象,这就是面向接口/父类编程的思路。理解了这一层,你再看很多框架里“参数写成接口、实参传实现类”的写法,就会有茅塞顿开的感觉。
多态不是一道概念题,而是一种日常设计手段。当你的代码能自然地接收更抽象的类型,扩展性就会好很多。以后系统要加新功能,大概率不需要改动已有的核心方法,只需要新增一个子类或实现类就行。
3.4 三大特性配合起来是什么样:一个小例子
封装、继承、多态不是三个彼此孤立的概念,它们经常同时出现。用一个动物园的小例子,把三者串一遍。
先设计一个Animal父类,里面有一个私有的name字段,通过构造器赋值;再提供一个sound()方法,默认打印一行提示。让Dog类和Cat类都继承Animal,并各自覆盖sound()方法。最后写一个ZooKeeper类,里面放一个makeAnimalSound(Animal animal)方法,在方法内部调用animal.sound()。
public class Animal { private String name; public Animal(String name) { this.name = name; } public String getName() { return name; } public void sound() { System.out.println("动物发出声音"); } } public class Dog extends Animal { public Dog(String name) { super(name); } @Override public void sound() { System.out.println(getName() + ":汪汪"); } }在这个例子里,封装保证了name只能通过构造器或getter访问;继承让Dog和Cat复用了“动物都有名字”的公共结构;多态让ZooKeeper写一份代码,就能处理所有种类的动物。如果后面需要加一只羊,只要新增一个Sheep类,继承Animal并覆盖sound(),现有代码一行都不用动。
写代码时要记住一个原则:别为了“用上三大特性”而刻意设计。什么时候自然的业务关系催生了需求,再使用对应的特性。如果用了继承反而让代码变复杂,那多半是设计出了问题。
4. 构造器、this、super:那些“看不见”的代码
4.1 构造器:对象出生时的初始化清单
很多新手把构造器理解成“和类名同名的方法”,这个理解不太本质。构造器的真正作用,是回答一个问题:这个对象一出生,应该处于什么状态?
比如这个类:
public class Student { private String name; private int age; public Student(String name, int age) { this.name = name; this.age = age; } }当执行new Student("张三", 20)时,Java先为对象分配内存,然后调用构造器,把name赋成“张三”,把age赋成20。如果你不写任何构造器,Java会提供一个默认的无参构造器,对象创建后所有字段都保持默认值。这个差别在实际开发中影响很大。
我建议自己设计类的时候,先想清楚:这个对象创建时,哪些信息是“必须有的”。对于学生类,姓名几乎一定是必须的,那就把它放进构造器参数里;年龄可能之后才补录,那就用setter。把必要的初始化放在构造器里,类的使用入口就会非常清晰,别人看你代码时也能在第一时间知道哪几个字段缺一不可。
4.2 this和super:从调用者角度理解它们
this和super两个关键字,难点不在含义,而在于怎么记忆使用场景。我提供一种更直观的理解方式:this代表当前这个对象,super代表父类对象的那一部分。
this主要有三个使用场景。第一个是通过this.xxx来区分成员变量和局部变量,比如构造器里的this.name = name,左面的this.name是成员变量,右面的name是参数。第二个是this(...)调用本类的另一个构造器,这样做可以避免构造器之间重复初始化代码。第三个是把当前对象本身作为参数传给别的方法,比如this.sendEmail()。
super的场景相对简单。super.xxx可以访问父类的成员变量和被覆盖的实例方法;super(...)则必须在子类构造器的第一行调用父类构造器。为什么必须第一行?因为子类对象内部其实包含了一部分父类的结构,父类的字段必须先初始化好,子类才能在此基础上继续工作,就像盖楼之前必须先把地基打完。
很多初学者写子类构造器时忘记调用父类的有参构造器,然后看到报错“There is no default constructor available in class xxx”就懵了。解决办法很简单:遇到这个报错,第一反应就是去子类构造器第一行补上super(...),传入父类需要的参数。这个错误我在教学里见过无数次,问题本身很小,理解了super的作用后就能轻松解决。
4.3 初始化顺序:对象创建的隐藏流程
还有一个隐藏流程值得讲清楚,就是对象创建时各部分的执行顺序。以子类为例,new一个子类对象时,完整流程是:
- 加载类的定义,先加载父类,再加载子类。
- 在堆内存中分配对象空间,所有实例字段先设为默认值。
- 执行父类的实例字段初始化和父类构造器。
- 执行子类的实例字段初始化和子类构造器。
这个顺序解释了为什么子类构造器第一行必须调用父类构造器,也解释了为什么在声明处给实例字段初始化的代码,会先于构造器里的赋值生效。一旦你理解了这条顺序线,很多看似莫名其妙的运行结果都能找到原因。
了解这个流程对写代码还有一条实际指导:不要在构造器里做太复杂的计算,更不要在构造器里调用可以被覆盖的方法。因为执行父类构造器时,子类对象还未完全初始化,如果这时调用了一个被子类覆盖的方法,方法里可能访问到还没赋值的子类字段,导致结果和预期不符。这个坑连资深工程师都不小心踩过,新手更要提前建立意识。
5. 一个“写得出”的实战练习:从需求到类设计
5.1 需求拆解:模拟一个简单的点餐系统
理论说再多,不如完整走一遍。为了尽量贴近真实,我用一个常见的“餐厅点餐”场景,把前面所有的概念串起来。需求描述如下。
顾客可以查看菜单上的菜品,下单,订单包含所选菜品和数量;系统能计算订单总价;顾客可以查看自己的历史订单。
按第二节的做法,先找名词和动词。
- 名词:顾客、菜单、菜品、订单、订单项
- 动词:查看、下单、计算、查看历史订单
接下来判断哪些名词应该成为类。顾客是一个核心类,包含姓名等基本信息,也承担下单和查看订单的行为。菜品是一个核心类,包含菜名和单价。订单是一个核心类,归属于某个顾客,里面由多个订单项组成。订单项也是类,因为它同时包含“哪个菜品”和“多少份”两个信息。菜单则是菜品的一个集合,负责展示所有菜品。
这样分析下来,五个候选类基本清晰:Customer、Dish、Menu、Order、OrderItem。你看,如果不做这一步,直接写代码很容易写到一半才发现漏了订单项这个关键类,届时要改动的范围可就大了。
5.2 先搭骨架,再逐类实现
设计阶段我习惯先只写类骨架,不写方法体,先把结构摆出来,别急着填实现。
public class Dish { private String name; private double price; // 构造器、getter/setter } public class OrderItem { private Dish dish; private int count; // 计算小计金额 } public class Order { private List<OrderItem> items; // 添加订单项、计算总价 } public class Customer { private String name; private List<Order> orders; // 下单、查看历史订单 } public class Menu { private List<Dish> dishes; // 显示所有菜品 }注意Order里用的字段是List ,而不是单独的OrderItem。一个订单对应多个订单项,这是典型的一对多关系,在代码里自然应该用集合来表达。这种“一对多就用List”的直觉,阅读时代码里到处都是,但只有自己设计时真正做出这个选择,才算掌握。
这个过程还说明了一个重要道理:写类的顺序不需要从头到尾一气呵成。先写骨架,再填细节,最后补全方法和逻辑,这样每步面对的复杂度都低很多,不容易被细节淹没。
5.3 一个容易忽略的设计点:方法该放在哪个类
骨架搭好之后,最难的一步往往不是语法,而是决定某个方法到底该放在哪个类里。以“计算订单总价”为例,第一次自己设计的同学很可能写一个OrderService工具类,然后在里面定义一个静态方法来完成计算。如果系统非常复杂,这种拆法无可厚非,但在当前这个简单场景里,我更建议把总价计算直接写成Order类的实例方法。
public double getTotalPrice() { double sum = 0; for (OrderItem item : items) { sum += item.getSubtotal(); } return sum; }为什么放这里?因为总价的计算只依赖订单自己内部的订单项,不依赖其他任何外部数据。按照“高内聚、低耦合”的思路,和数据关系最紧密的操作,就应该放在数据所在的类里。同理,订单项的小计金额也应该由OrderItem类自己负责计算,而不是让外部代码凑到OrderItem里拿字段再自己算。
这个“方法放哪个类”的决策,正是从“看得懂”走向“写得出”的关键步骤。读代码时,方法的位置已经固定,你看不出作者的犹豫;轮到自己写,你会发现几乎每个方法都在考验你对类职责的理解。一个实用的判断标准是:谁最了解这个操作所需要的数据,方法就放在谁那里。
5.4 扩展思考:这个设计还能怎么改
基础版本写完后,可以继续想一想“如果需求变了怎么办”。比如菜品要增加分类,订单要支持多种支付方式,菜单要支持按分类筛选。这些新需求都会推动设计演化。
首先,给Dish增加一个category字段,或者单独抽象一个Category类,都是合理选择。其次,支付方式非常适合用接口表达,例如定义一个Payment接口,微信支付、支付宝、现金分别实现它。Order只面向Payment编程,运行时传入具体的支付实现,这样以后再加一种支付方式,只需要新增一个类,不改动Order的核心逻辑。
这些扩展思路就是对封装、继承、多态,以及“面向抽象编程”的活学活用。我带人时最常用的训练方式,就是让学员把基础版本写完,然后人为加一个新需求,逼自己再改一版。每改一版,你对类设计的感觉就会深一层。这个过程没有人能替你走,必须亲自动手。
6. 写给“看得懂写不出”的同学:几个实操建议
6.1 用“抄写加改造”代替死记硬背
很多同学觉得自己“看得懂但写不出”,于是拼命背代码。背代码的坏处很明显:你只是记住了某一个具体答案,换个需求就不会了。我的建议是把死记硬背换成“抄写加改造”的组合训练。
第一步,找一份结构清晰的示例代码,一行一行抄一遍,边抄边想每个类为什么这样写。第二步,抄完以后做改造:把类名换掉、把属性换成类似的业务字段、把方法实现改成完成一个不同的小需求。第三步,故意改错几个地方,比如把方法名改错,看编译器会报什么错。报错信息是很好的学习材料,看多了,你看到异常就能快速定位。
举个例子,示例代码写了学生类,那你就改造出一个图书类;示例代码有计算面积的方法,那你就改成计算周长的方法。在安全范围内反复试错,比光看不练高效得多。你很快会发现,原来那些“看不懂”的报错,慢慢都能读懂原因了。
6.2 面向对象能力的四周刻意练习计划
面向对象不是看一遍就能掌握的技能,它需要反复练习。我建议按四周的时间安排来做刻意练习,每周聚焦一个能力点。
第一周,每天写一个简单的类,重点练封装:私有字段、构造器、getter/setter,再额外写一个业务方法。比如写一个图书类,里面有书名、作者、价格,再加一个涨价的方法。第二周,每天写一个包含继承的小例子,并刻意区分is-a和has-a的关系。比如写一个图形类,让圆形、矩形继承它,同时想清楚圆形和画布之间是组合关系而不是继承。第三周,每天写一个使用多态的小方法,比如方法参数写父类类型,然后传入不同子类对象,观察行为差异。第四周,找一个完整的小型项目,从需求分析、类设计到代码实现全部自己完成。
这个计划不是机械重复,而是每个阶段都盯住一个明确能力目标。四周结束后,你面对空白页面时,不再是一团乱麻,而是有了“先找名词、再定关系、再写方法”的清晰流程。
6.3 常见卡点与解决办法
最后分享几个我辅导别人时反复遇到的卡点,以及对应解决思路。
| 卡点 | 表现 | 解决办法 |
|---|---|---|
| 不知道写哪些方法 | 类建好后,不知道加什么行为 | 把需求里的动词列出来,一个动词一般对应一个方法;再想想谁最了解这个数据,方法就放谁那里 |
| 不确定字段类型 | 不知道某属性该用String还是List | 先填最明显的类型,如果表示“一个列表”就用List<T>,如果表示“另一个东西”就用那个类 |
| 构造器传参混乱 | 经常漏传参数或顺序搞混 | 想清楚“这个对象一出生必须有什么”,把必须的东西全放构造器,可后补的才用setter |
| 方法覆盖写错 | 方法名拼错导致没有覆盖 | 覆盖方法时记得加@Override注解,编译器会帮你检查父类是否有这个方法 |
这些卡点都是正常现象,没有任何人写Java可以一步到位。我在实际项目里也经常先写一个能用版本,然后通过重构把它变成好用版本。面向对象是一门手艺,手艺就得靠手练,光靠眼睛看是远远不够的。
这个系列写到第五篇,面向对象基础算是一个重要的分水岭。可能你短时间之内没法把抽象、封装、继承、多态都用得炉火纯青,但只要你每次拿到需求都先逼自己“找名词、找动词、画关系”,很快就会发现,那些原来只在别人代码里见过的设计,你自己也能做出来了。我自己的经验是,面向对象编程不是“学”会的,而是“写”会的。与其花时间去找更全的教程,不如今天就从这个小练习开始,写下你第一个类。