Java面向对象编程入门:从类设计到封装继承多态的完整实践
你有没有过这种经历看别人的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的ages1的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 ListOrderItem items; // 添加订单项、计算总价 } public class Customer { private String name; private ListOrder orders; // 下单、查看历史订单 } public class Menu { private ListDish 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先填最明显的类型如果表示“一个列表”就用ListT如果表示“另一个东西”就用那个类构造器传参混乱经常漏传参数或顺序搞混想清楚“这个对象一出生必须有什么”把必须的东西全放构造器可后补的才用setter方法覆盖写错方法名拼错导致没有覆盖覆盖方法时记得加Override注解编译器会帮你检查父类是否有这个方法这些卡点都是正常现象没有任何人写Java可以一步到位。我在实际项目里也经常先写一个能用版本然后通过重构把它变成好用版本。面向对象是一门手艺手艺就得靠手练光靠眼睛看是远远不够的。这个系列写到第五篇面向对象基础算是一个重要的分水岭。可能你短时间之内没法把抽象、封装、继承、多态都用得炉火纯青但只要你每次拿到需求都先逼自己“找名词、找动词、画关系”很快就会发现那些原来只在别人代码里见过的设计你自己也能做出来了。我自己的经验是面向对象编程不是“学”会的而是“写”会的。与其花时间去找更全的教程不如今天就从这个小练习开始写下你第一个类。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →