第八天的主题是面向对象。如果你前七天跟着一路写下来,现在应该已经掌握了变量、分支循环、数组、方法这些基础语法,可以写一些简单的顺序程序了。但从今天开始,要有一个思维上的重要转变:以前你是站在“计算机怎么执行”的角度写代码,从今天开始,你要站在“业务和真实世界怎么建模”的角度写代码。Java 是一门以面向对象为核心的编程语言,类、对象、封装、继承、多态这些概念如果理解不透,后面学集合、IO、多线程,再后面学框架,都会非常吃力。这篇内容我把核心概念、代码场景、常见坑一次讲透,适合刚入门需要系统理解面向对象的读者,也适合学完一遍但对细节模模糊糊想回头补齐的人。
1. 面向对象到底在解决什么问题
1.1 从面向过程到面向对象的思维切换
前七天大家在学基础语法的时候,写的程序基本是“从上往下按顺序执行”——输入数据、处理数据、输出结果。这种写法的思路叫做面向过程。面向过程本身没什么不好,处理一些小问题的时候它反而简单直接。但一旦你面对的软件规模变大,比如一个学生成绩管理系统要管学生、班级、课程、成绩、老师、考勤,面向过程的代码就会很快失控。你会发现所有变量堆在一起,所有方法互相调用,改一个功能,另外三个功能跟着出错。
面向对象的做法完全不同。它让你把学生不是当作几个零散的变量——姓名、学号、成绩、班级——而是当作一个完整的“对象”。这个对象自己带着属性,也带着行为。成绩管理要关心学生的时候,就直接和学生这个对象打交道,而不是和一堆散落的变量打交道。这个转变,就是从“关注计算机怎么执行”变成“关注业务世界怎么建模”。
很多新手在第一时间并不能真正接受这种转变,会觉得这样“绕”。我当年学的时候也觉得,直接写一个方法算平均分多简单,为什么要搞这么多类?但慢慢你会发现,一旦进入团队协作、需求迭代、系统维护,面向对象的建模方式几乎是唯一可行的路。
1.2 面向对象解决了什么现实问题
往深了说,面向对象提供的封装、继承、多态,分别解决了几个非常现实的问题。
第一个是职责边界问题。封装让每个类把自己的数据和行为管好,外部只能通过规定好的入口去访问。就像银行柜员不会直接打开金库,而是通过柜台窗口办事。多人开发时,你负责学生模块,我负责课程模块,只要接口稳定,互不干扰。
第二个是复用与扩展问题。继承允许你在已有类的基础上扩展新类,不改动旧代码就能增加新能力。真实项目里最贵的东西不是写新代码,而是改旧代码不出错。继承让你面对“新增了一个需求”时,优先选择“加一个新类”而不是“改一个老类”。
第三个是协作与抽象问题。多态和接口让上层代码不依赖具体实现,上层依赖的是抽象。还是拿日志记录举例:多个系统都要记录日志,有的写文件、有的写数据库、有的发消息队列。如果你让上层代码直接依赖“写文件日志类”,数据库日志一引入,上层代码就要改。面向接口编程之后,上层只认“日志接口”,具体实现随便换。
我给新手总结一句话:面向对象不是 Java 的语法要求,而是你用代码表达世界的一种方式。Java 只是把这种方式用语法固定了下来。
2. 类与对象:先搞懂最基础的两个概念
2.1 类就是图纸,对象就是按图纸造出来的实体
在面向对象里,类(Class)和对象(Object)是最基础的两个概念。我上课的时候经常用盖房子类比:类就是设计图纸,对象就是按照图纸盖出来的那一栋一栋具体的房子。
图纸上写得很清楚:这栋房子有几个房间、墙是什么颜色、大门朝向哪边——这些叫做成员变量,也叫属性。图纸上还画清楚了:房子能开灯、能开门、能调节室温——这些叫做成员方法,也叫行为。但图纸本身不能住人,只有按照图纸盖出来的房子才能住人。类也一样,它只是定义了“这类事物有什么属性和行为”,真正要操作数据,必须创建具体的对象。
来看一段最简单的代码:
public class Student { String name; int studentId; double score; public void printInfo() { System.out.println("姓名:" + name + ",学号:" + studentId + ",成绩:" + score); } }这个 Student 类里,name、studentId、score 是成员变量,printInfo 是成员方法。它只是一个模板,你不能直接写 Student.name = "小明",因为模板本身不占具体的数据空间。要先创建对象:
Student s1 = new Student(); s1.name = "小明"; s1.studentId = 1001; s1.score = 92.5; s1.printInfo();注意这个过程中的一个关键点:同一个类可以创建很多个对象,每个对象之间互不干扰。s1 是“班里一个叫小明的学生”,你完全可以再创建一个 s2 代表另一个学生。这就是对象让数据有了“归属”的地方。
2.2 从内存视角看 new 关键字
新手最常见的困惑之一是:为什么对象必须要用 new 创建?为什么不像 int a = 10 那样简单?原因在于内存分配的位置不同。
基础数据类型(int、double、boolean 这些)通常存储在栈内存上,栈的特点是速度快、空间小、生命周期由作用域自动管理。而对象不是,对象往往更大,生命周期也不确定——一个学生对象可能要被成绩单、班级、活动报名三个地方同时引用。所以对象需要存放在堆内存上,由垃圾回收器在合适的时候回收。
当你写 Student s1 = new Student() 的时候,实际上发生了三件事。第一步,在堆内存中开辟一块空间,准备存放 Student 对象的数据。第二步,调用 Student 的构造方法,给这块空间里的成员变量赋初始值。第三步,把这块堆内存的地址赋值给栈上的引用变量 s1。严格来说,s1 并不是“学生本身”,而是“指向学生的遥控器”。你通过 s1.name 操作的是堆上的对象,而不是 s1 自己。
用遥控器类比很好理解:遥控器在手,你就能控制电视机;你把遥控器复制一份给别人,别人也能控制这台电视——复制的是遥控器,不是电视机本身。Java 里的引用变量就是遥控器,对象就是电视机。把 s1 赋值给 s2,只是多了一个遥控器,电视机还是那一台。
这部分内容如果只停留在语法层面,很容易产生“对象太大了所以很慢”这种误解。实际上,操作对象的时候开销并不大,你操作的无非是引用地址。关键在于要清楚“引用”和“对象”之间的区别,这在后面比较对象、传递参数时非常重要。
3. 构造方法与 this:对象出生的那点事
3.1 构造方法为什么是对象出生的入口
类里面有一种特殊的方法叫构造方法(Constructor)。它有两个特点:方法名必须和类名完全一致,并且不写返回值类型,连 void 都不能写。它的职责就是“初始化一个新创建的对象”。
每个类都有一个默认的构造方法,就算你一个构造方法都没写,编译器也会悄悄给你补一个无参构造。但如果你自己写了任何一个构造方法,默认的无参构造就不再提供了。这是个非常经典的坑:
public class Student { private String name; // 自己写了一个带参构造 public Student(String name) { this.name = name; } } Student s = new Student(); // 编译报错!无参构造不存在很多人刚开始写的时候,自己写了带参构造,然后还想 new Student() 创建对象,结果编译报错,一脸懵。原因就是我上面说的——默认构造方法被覆盖了。解决办法是要么显式再写一个无参构造,要么创建对象时带上参数。
构造方法可以重载。比如 Student 可以有无参构造、有参构造、带多个参数的构造。重载的意思就是“方法名相同、参数列表不同”。这样就能根据不同场景,用不同的方式创建对象。构造方法虽然在 new 的时候被调用,但它依然是方法,可以被重载,这是新手需要记住的。
3.2 this 关键字的三种用法
this 指的是“当前对象”,谁调用方法,this 就是谁。它有三种主要用法。
第一种,区分成员变量和局部变量。最常见的就是构造方法里:
public Student(String name) { this.name = name; }如果不加 this,写 name = name,编译器会认为两个都是局部变量,成员变量根本没被赋值。这种问题最难调试,因为代码不报错,程序却运行得不符预期。所以这条经验建议直接记死:形参名和成员变量名重名时,必须用 this 强调赋值目标是成员变量。
第二种,调用本类的另一个构造方法。一个构造方法里可以用 this(参数) 调用另一个构造方法,但是必须写在第一行:
public Student() { this("未知", 0); } public Student(String name, int age) { this.name = name; this.age = age; }这种写法的好处是避免重复初始化代码,让无参构造也复用有参构造的逻辑。
第三种,返回当前对象。在一些链式调用的编码风格里会有这种写法:return this。它让代码可以写成一串:对象.方法A().方法B().方法C()。初学者知道有这种用法就行,目前阶段不用刻意追求。
3.3 一个对象从编写到出生的完整过程
很多新手对 new 背后到底执行了什么没概念,我把整个过程整理一下。
第一步,类加载。JVM 首次用到这个类的时候,会把 .class 文件加载到方法区,静态变量和静态代码块会在这个阶段初始化。这也是为什么静态变量只初始化一次。
第二步,在堆内存中分配对象空间。空间大小由这个类的成员变量决定,包括从父类继承下来的成员变量。
第三步,成员变量初始化。int 默认 0、boolean 默认 false、引用类型默认 null。如果成员变量声明时直接赋值了,比如 private int age = 12,就在这一步赋值。
第四步,调用构造方法。构造方法内部如果有 this(...) 调用,会先执行对应的构造方法,然后执行构造方法体中的代码。
顺序搞明白之后,你就知道为什么子类构造方法里 super 必须放在第一行了。因为在逻辑上,父类的属性要先于子类产生,基础要先于扩展初始化。这部分在下一章讲继承的时候还会再提。
4. 封装:给数据穿上铠甲
4.1 访问修饰符到底在控制什么
封装是面向对象三大特性之一,也是最容易被新手误解的。很多人以为封装就是把字段写成 private、再写一堆 getter/setter,其实这只是形式。封装真正的目的是“隐藏内部细节,控制外部对数据的访问方式”。
Java 提供了四个访问修饰符来控制可见范围,从宽到窄分别是 public、protected、默认(也叫 package-private)、private。我常建议新手把这个权限范围记成一句话:public 谁都能用,protected 自己包和子类能用,默认自己包能用,private 只有自己类里能用。
实际写代码的时候,成员变量基本都设为 private,然后提供 public 的访问方法。为什么要这么绕?直接 public 变量不是更省事吗?因为直接暴露变量,外部就可以随便赋值,你拦不住。你想拦“年龄不能是负数”,但如果有代码直接写 student.age = -30,你完全没有机会插手。
4.2 在入口处做校验才是封装的核心
来看一个标准的学生类:
public class Student { private String name; private int age; public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { if (age < 0 || age > 150) { System.out.println("年龄不合法,赋值失败"); return; } this.age = age; } }看到 setAge 方法里的 if 了吗?这就是封装和“单纯 getter/setter”的本质区别。外部想设置年龄,只能走 setAge 这个方法,这个方法就可以对数据做合法性检查。数据合法,赋值;不合法,拒绝。数据的安全性就通过这一层“入口”守住了。
我自己带新人的时候,会让他们做一个练习:设计一个银行账户类,balance 是 private,只有 deposit 和 withdraw 两个方法能修改余额,而且 withdraw 不能把余额扣成负数。这个练习做完,封装的理解立刻就不一样了。
4.3 过度封装同样是问题
封装的边界也要把握好。我见过不少同学,把所有字段都加上 getter/setter,哪怕这个字段根本不会对外访问。这是错误的。封装的目的不是“每个字段必须有访问方法”,而是“每个数据的访问路径都受到控制”。
如果一个内部计算的临时状态,只在类内部使用,那就让它 private 且没有 getter 就行。如果一个字段初始化之后就不该变了,那就不该提供 setter。正确的做法是先问自己:外部真的需要访问吗?如果需要读也需要写,才考虑同时给 get 和 set。如果只需要读,就给 get。如果根本不需要让外部知道,就什么也别给。
有句话说得好:封装是面向对象的“卫生习惯”,但过度封装就像洗三遍手还要洗胳膊,反而是成本。
5. 继承:站在父类的肩膀上
5.1 继承描述的是 is-a 关系
继承用 extends 关键字来实现。它要表达的关系非常明确:子类是一个父类。狗是一种动物,学生是一个人。所以 Dog extends Animal、Student extends Person 都符合直觉。兄弟关系、拥有关系,都不应该用继承——继承是“是什么”的关系,不是“有什么”的关系。
我从实际开发的角度建议:学继承的时候,不要只想着“代码复用”。代码复用只是继承的附带好处,更重要的是它让所有子类共享同一个类型,可以统一处理。比如一个公司要发工资,全职员工和兼职员工都继承了员工类,那么人力资源系统只需要面对“员工”这个类型,不用分别写两套逻辑。
5.2 方法重写与 super 关键字
子类可以扩展自己的成员变量和成员方法,也可以改变父类已有的方法行为。改变父类方法行为,这个方法叫重写(Override)。重写的规则有几个关键点:方法名、参数列表必须和父类完全一致;返回值类型可以相同,也可以是父类返回类型的子类型;访问权限不能低于父类方法的权限;不能抛出比父类更宽泛的异常。新手最容易忽略的是访问权限这一点——父类是 public,子类改成 private,编译直接报错。
重写之后,如果还想要调用父类被覆盖的那套逻辑,就用 super 关键字:
public class Dog extends Animal { public Dog(String name) { super(name); } @Override public void eat() { System.out.println("开始前先闻一闻"); super.eat(); System.out.println("吃完摇摇尾巴"); } }super 有两种主要用法:一是调用父类构造方法,必须写在子类构造方法的第一行;二是调用父类的成员方法或成员变量。我在实际写代码的时候,super.调用父类方法这个动作很常用,因为子类往往是在父类逻辑之上增强,而不是完全推翻。
5.3 继承时构造方法的调用链
继承之后,构造方法的执行顺序是一个特别有意思的考点。规则很简单:子类构造方法执行前,一定会先执行父类的构造方法。
原因在前面内存分配那部分已经说过了:子类对象拥有的空间包含父类的成员变量,这些父类的部分必须先被初始化。JVM 会保证这一点,即使你不在子类构造方法里写 super(),编译器也会自动插入一个无参的 super() 调用。
但如果你父类没有无参构造,只有带参构造,编译器就插入不了 super(),只能你自己手动在子类构造方法第一行写 super(参数)。这就是很多人遇到“父类构造方法未定义”报错的原因。解决办法两种:要么给父类补一个无参构造,要么子类构造函数里显式调用父类带参构造。后者在我实际项目里更常见,因为父类往往有需要初始化的核心字段。
6. 多态:一种引用,多种形态
6.1 多态的三个条件和动态绑定
多态是三大特性里最难直观理解的一个。它的标准定义是:同一个父类引用,指向不同子类对象时,调用同一个方法会表现出不同的行为。Java 中形成多态需要三个条件:有继承关系、子类重写了父类方法、父类类型的引用指向子类对象。
Animal a1 = new Dog("旺财"); Animal a2 = new Cat("咪咪"); a1.eat(); // 输出:旺财在啃骨头 a2.eat(); // 输出:咪咪在吃鱼a1 和 a2 的声明类型都是 Animal,但是它们实际指向的对象是 Dog 和 Cat。调用 eat() 时,Java 运行时会根据对象的真实类型,找到 Dog.eat() 和 Cat.eat() 来执行。这个过程叫动态绑定,也叫运行时多态。编译的时候,编译器只看得到 a1 是 Animal 类型,所以它只允许你调用 Animal 类里声明过的方法。如果你试图调用 a1.watchDoor(),哪怕 a1 实际上指向 Dog,编译也过不了,因为编译器不知道也不可能知道运行时的真实类型。
6.2 多态带来的设计红利
多态的威力在于,你可以写“面向抽象”的代码。举一个非常经典的支付例子:
public class PaymentProcessor { public void process(PaymentMethod method) { method.pay(); } } public interface PaymentMethod { void pay(); } public class Alipay implements PaymentMethod { public void pay() { System.out.println("使用支付宝支付"); } } public class WechatPay implements PaymentMethod { public void pay() { System.out.println("使用微信支付"); } }当系统要支持一种新的支付方式时,不需要去改 PaymentProcessor 的任何代码,只需要新增一个实现了 PaymentMethod 接口的类,然后传入 process 方法就行。这就是“对扩展开放,对修改关闭”的开闭原则。每次看到这个例子,我都觉得设计模式这个东西真不是虚的,它就是从这些基础能力里长出来的。
6.3 重载和重写有什么区别
新手非常容易把重载(Overload)和重写(Override)搞混。它们名字像,但其实不是同一个层面的事情。
重载发生在同一个类中,方法名相同、参数列表不同,它是编译期就能确定调用哪个方法的行为。重写发生在继承关系的子类和父类之间,方法签名在父类已经存在,子类重新实现,它是运行期动态绑定的行为。
话是这么简单,但实际写代码时我见过不少翻车现场:有人以为自己在重写,结果参数类型写错了,变成了重载。这个方法还恰好能编译过,程序跑起来却完全没走预期逻辑。避免的办法就一个:重写父类方法时,一定在方法上写 @Override 注解。加了注解之后,编译器会帮你检查,如果这个方法并不是有效的重写,编译阶段就会报错。这是一个成本极低、收益极高的习惯。
7. 抽象类与接口:从实现到契约
7.1 抽象类:把公共行为抽象出来
抽象类用 abstract 修饰,它和普通类最大的区别是:抽象类不能直接 new,它的存在意义就是要被继承。抽象类里可以有普通方法,也可以有抽象方法。抽象方法只有方法签名,没有方法体,强制要求子类实现。
什么时候该用抽象类?我的判断标准是:当你发现一组类有公共的成员变量和公共的行为逻辑,但其中某个行为在不同子类里实现方式不一样,就可以把这部分公共内容抽到父类。如果父类的某个方法自己都不知道具体怎么实现,那就把它定义为抽象方法。
举个最实在的例子,报表导出。不管是导出 PDF、Excel 还是 CSV,流程都是:查询数据、处理数据、生成文件。前两步可以写在抽象类里做成公共方法,第三步差异很大,就定义成抽象方法让子类实现。这样上层只调用一个统一的导出入口,子类各做各的格式。
7.2 接口是能力的契约
接口(interface)用 interface 关键字定义,它比抽象类更抽象。接口里的方法默认是 public abstract,从 Java 8 开始接口里可以有 default 方法和 static 方法。接口表达的是“能做什么”的能力契约,一个类可以实现多个接口,弥补了 Java 单继承的限制。
我习惯这么理解抽象类和接口的分工:抽象类管“它是什么”,接口管“它能干什么”。一个类继承抽象类的同时,可以实现多个接口。比如一个无人机类,继承飞行器抽象类,实现可拍照接口和可录像接口。这种设计让能力可以自由组合,非常灵活。
从 Java 8 开始,接口里还能写 default 方法。我见过一些团队用 default 方法在接口里给旧方法提供默认实现,这样下游实现类不升级也不会编译失败,做框架平滑演进的时候很好用。不过新手阶段知道有 default 方法的存在即可,不要一上来就到处用。
7.3 抽象类和接口怎么选
每次讲到这,都有人问:到底该用抽象类还是接口?我给一个非常实用的判断方式:
如果两个类之间确实是 is-a 关系,并且你需要抽取共同的状态字段、共同的方法实现,用抽象类。如果类之间没有继承关系,但都具备同一种能力,比如都会飞、都能播放、都能排序,用接口。
抽象类能提供实现代码,但一个类只能继承一个抽象类。接口能提供能力契约,一个类可以实现多个接口,但接口默认不提供实现。Java 8 之后接口的 default 方法可以在一定程度上提供实现,但通常不建议把复杂逻辑写进去。
这么多年写下来,我的个人体会是:日常业务代码里,接口比抽象类用得更频繁。因为业务系统的核心是稳定地对模块之间做契约约束,而不是追求复杂的类继承关系。除非确实有公共状态和公共逻辑要复用,否则优先选接口。
8. 第八天最容易踩的坑与排查清单
8.1 用 == 比较对象是新手第一坑
比较两个 int 用 == 没问题,但比较两个 Student 对象的时候,== 比较的是引用变量里保存的地址值,而不是对象的内容。哪怕两个对象所有字段完全一样,只要不是同一个对象,== 的结果也是 false。
如果要比内容,必须重写 equals 方法。Object 类里默认的 equals 就是用 == 实现的,所以在自定义类里你要是没重写 equals,那效果跟 == 一样。这也就是为什么很多框架、容器都要求实体类重写 equals 和 hashCode,而且重写 equals 时必须同时重写 hashCode,否则对象放进哈希集合里会出各种奇怪问题。
8.2 空指针异常是第二大坑
空指针异常(NullPointerException)是 Java 程序员遇到最多的运行时异常。它的本质是:你在一个人为空的引用上调用方法或访问字段。比如一个对象创建失败了,或者从数据库查出来没有赋值,你直接调用它的方法,就会触发。
解决空指针没有奇技淫巧,最有用的办法是防患于未然。接口入口的地方做参数校验,返回值有可能是 null 时,调用方主动判断。Java 8 提供了 Optional 可以用来消除一部分不必要的判空,但在对象很复杂、嵌套很深的时候,也别过度依赖 Optional,先把调用链路理清楚才是根本。
8.3 静态方法不能真正重写
静态方法属于类,不属于对象。子类里写一个和父类静态方法签名完全相同的方法,那只叫“隐藏”,不叫重写,也不会走动态绑定。判断方法很简单:调用的时候,如果通过父类引用调用,执行的是父类版本;通过子类引用调用,执行的是子类版本。对于普通实例方法,这种情况应该执行子类版本。这就是静态方法和实例方法在多态上的本质区别。
更麻烦的是,Java 不允许你给静态方法加 @Override 注解,编译直接报错。所以如果你发现某个方法加了 @Override 却报错,先看一眼它是不是 static。
8.4 常见坑速查表
我整理了一张第八天总结用的速查表,后面复习的时候可以拿这张表做回忆。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 编译报错:无参构造不存在 | 写了带参构造后,默认构造被覆盖 | 显式补充无参构造,或用带参构造 |
| 成员变量没有被赋值 | 形参名和成员变量重名,this 没加 | 赋值时写 this.成员变量 |
| 父类无无参构造导致子类编译失败 | 编译器无法插入默认 super() | 子类构造方法第一行显式写 super(参数) |
| 重写方法不生效,走了父类逻辑 | 参数列表不一致,实际变成了重载 | 加 @Override 注解让编译器检查 |
| 两个相同内容的对象 == 返回 false | == 比较的是引用地址 | 重写 equals 和 hashCode |
| 调用方法报空指针 | 引用为 null 还继续调用 | 入口校验、返回判空、用好 Optional |
| 静态方法重写后没有多态效果 | 静态方法属于类,不走动态绑定 | 静态方法按类调用,别把它当成重写 |
| 所有字段都设了 getter/setter | 过度封装 | 只暴露外部真正需要的访问方式 |
这张表里的每一条,都是我在实际带新手时反复看见过的。列表本身没有多深,关键是真的去写代码的时候能想起来。
8.5 关于学习节奏的一点个人体会
学到第八天,Java 面向对象的核心概念大概就过完一遍了。但我必须说,看懂和会用是两码事。我见过非常多同学,上课看例子觉得都懂,一到自己写一个小项目就不知从哪下手。原因只有一个:动手量不够。面向对象的能力不是背出来的,是写完几百行甚至几千行代码之后长出来的。
我个人建议接下来几天不要急着往后面学集合和 IO,先停下来,用面向对象重构之前写过的小程序。比如把学生成绩管理改成 Student、Course、Score 三个类,把打印逻辑封装进每个类自己的方法里。跑起来,加点数据,再试着用继承和多态把不同打印格式统一起来。这个过程走完,你对今天所有内容的理解会完全不同。
最后再分享一个小技巧:遇到“这个类该不该拆”的问题时,先问它是不是承担了两种职责;遇到“这个方法该放哪”的问题时,先问哪种设计改动最小。把这两个问题想明白,面向对象差不多就入门了。第八天,继续加油。