2026/10/11 3:43:59

Java面向对象核心详解:从类与对象到封装继承多态

Java面向对象核心详解:从类与对象到封装继承多态 第八天的主题是面向对象。如果你前七天跟着一路写下来现在应该已经掌握了变量、分支循环、数组、方法这些基础语法可以写一些简单的顺序程序了。但从今天开始要有一个思维上的重要转变以前你是站在“计算机怎么执行”的角度写代码从今天开始你要站在“业务和真实世界怎么建模”的角度写代码。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 三个类把打印逻辑封装进每个类自己的方法里。跑起来加点数据再试着用继承和多态把不同打印格式统一起来。这个过程走完你对今天所有内容的理解会完全不同。最后再分享一个小技巧遇到“这个类该不该拆”的问题时先问它是不是承担了两种职责遇到“这个方法该放哪”的问题时先问哪种设计改动最小。把这两个问题想明白面向对象差不多就入门了。第八天继续加油。