ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java面向对象编程:从类与对象到封装继承多态的核心解析

Java面向对象编程:从类与对象到封装继承多态的核心解析 1. 为什么“面向对象”是所有Java工程师的第一道分水岭提到Java十个人里有九个都会先蹦出“面向对象”这四个字。不管是八股文面试、日常开发建模还是读Spring源码最终都要落到你能不能把一个真实业务场景抽象成类、对象、接口的组合。我最早学Java时也觉得“对象”这个词很虚——对象到底是什么买了个对象new了一个对象后来写的东西多了才明白对象就是把数据和操作数据的方法打包在一起的一个载体就这么简单。Java世界里所有东西本质上都在围着这个载体转。这篇文章写给三类人正在准备Java基础面试题、需要应付课程设计或者刚入门还没建立起“面向对象思维”的初学者。当然如果你已经写了两年代码但一直是用面向过程的方式堆方法这篇也能帮你把脑子里的代码组织方式重新梳理一遍。我会从设计思路讲起把封装、继承、多态、抽象这四大支柱拆开揉碎再用可运行的代码做演示最后把面试里最高频的“八股题”和实际开发里最容易踩的坑放在一起讲。先给一个最重要、也最容易被忽略的结论面向对象不是语法是一种组织代码的思维方式。语法是工具思维方式才是你能不能写出可维护代码的核心。这一点想通了后续所有知识点都只是顺势展开。2. 类和对象先有图纸再谈生产2.1 类是为了“建模”不是为了“定义变量”很多新手对类的第一印象是“类就是一组变量加一组方法”。这个理解不能说错但很容易让自己写出来的类退化成一个单纯的数据容器。正确的打开方式应该是把类当成一张图纸先想清楚你要描述的现实世界中的哪个事物、或者业务中的哪个概念然后把它的属性抽出来定义成字段把它能做的动作定义成方法。举个例子。你做一个学生管理系统脑子里先有“学生”这个概念。学生有什么学号、姓名、年龄、班级这是属性。学生能干什么选课、退课、查询成绩这是行为。于是类的骨架就出来了。这个建模过程需要主动去想而不是拿起键盘就敲。这里我特别想说一个实际教训不要在类里无脑塞一堆getter和setter。JavaBean规范确实要求属性私有化、提供访问方法但很多人是从MyBatis或者Spring的JSON序列化里习惯了“必须写getter/setter”然后在纯粹的业务逻辑类里也照抄一套。结果是类里一半代码都是机械的访问器业务方法反倒被淹没。正确的思路是按需提供——只有外部需要读写的属性才开放访问方法不需要的一律不写。2.2 从new开始理解对象的完整生命周期new关键字是每个Java工程师用得最多的操作之一但很多人对“new一个对象”背后发生了什么其实没什么感知。我简单拆一下步骤JVM先加载这个类如果还没加载过再给对象分配堆内存然后执行字段的默认初始化基本类型给0/0.0/false引用类型给null接着执行实例初始化块和构造函数体最后把对象的引用压到栈上的局部变量里。整个过程可以用“类加载、内存分配、初始化、返回引用”来概括。为什么不展开讲JVM细节因为面向对象阶段你更需要关注的是构造函数、初始化块、静态代码块这三者的执行顺序。我在面试里经常会问这个被我面过的人里大概只有三成能准确答对。直接给结论静态代码块只在类加载时执行一次优先于一切实例初始化实例初始化块在每次new对象时执行且先于构造函数体。这个顺序你实际跑一遍代码就忘不了。2.3 代码骨架一个能跑的简单类看代码比看理论快。下面这个Student类是最常见的学生建模包含了属性、构造函数、普通方法、静态字段和静态方法可以作为你练习的基础模板。public class Student { // 静态字段所有学生实例共享属于类本身 private static int totalCount 0; // 实例字段每个对象独立拥有 private String studentId; private String name; private int age; // 构造函数new对象时自动调用 public Student(String studentId, String name, int age) { this.studentId studentId; this.name name; this.age age; totalCount; // 每创建一个学生总数加1 } // 实例方法必须通过对象调用 public void introduce() { System.out.println(我是 name 学号 studentId 今年 age 岁); } // 静态方法通过类名直接调用 public static int getTotalCount() { return totalCount; } public static void main(String[] args) { Student s1 new Student(001, 张三, 20); Student s2 new Student(002, 李四, 21); s1.introduce(); s2.introduce(); System.out.println(当前共有学生 Student.getTotalCount()); } }这里有几个点值得琢磨。this关键字的作用是区分局部变量和成员变量this.studentId studentId中右边是构造函数的参数左边是当前对象的字段。静态字段totalCount是所有对象共享的计数变量它不是属于某个具体学生而是属于Student这个类整体。这个共享特性在后面讲内存时会反复用到。3. 封装与访问控制你的字段凭什么不让人随便改3.1 封装不是把字段设为private就完了封装是面向对象里最容易糊弄过去的知识点。很多人记住的只有一句“把字段设为private提供getter和setter”但很少有人追问为什么要这么做直接public不好吗我举个例子。假设你把学生的年龄字段设为public外部代码直接写stu.age -100程序不会报错但逻辑上完全错误。如果字段是private你就必须在setter里做校验把非法数据挡在门外。这才是封装的核心意图——通过访问控制把数据的使用规则收进类内部让外部只能通过你允许的方式操作数据。在团队协作里这个重要性更突出。别人调用你写的类时只关心方法签名和返回值不关心内部实现。你把内部数据结构藏起来后续想改实现、换存储方式只要方法签名不变调用方根本感知不到。这就是“降低耦合”的落地形式。3.2 访问修饰符的选择逻辑Java提供了四个访问级别private、默认包私有、protected、public。实际开发中我一般按这个思路做选择。private永远是默认首选凡是自己不打算对外暴露的字段和辅助方法统统私有。默认修饰符用于同一个包内的协作但如果你没想清楚包的边界很容易因为“同包可见”造成误用。protected用得最少它面向继承场景——子类需要访问父类成员但不希望外部类访问。public只给真正对外的API入口。这里有个很实际的经验不要因为“可能以后用得到”就把方法设为publicYAGNI原则在访问级别上同样适用。权限收得越紧后续重构的空间越大。3.3 一个容易犯的实际问题我在代码评审时见过一个非常典型的误用业务类里的集合字段比如private ListString courses;对外提供了getCourses()直接返回这个List的引用。外部代码拿到引用后直接getCourses().add(xx)绕过了你所有的业务校验数据就悄悄脏掉了。正确的做法是返回一个只读视图或者直接返回副本。// 错误示范返回了内部可变对象的引用 public ListString getCourses() { return courses; } // 正确思路返回只读视图或副本外部无法通过引用修改内部状态 public ListString getCourses() { return Collections.unmodifiableList(courses); }这个坑在面向对象设计里非常普遍因为它属于“封装毛刺”——看起来封装了实际上漏了一个洞。自查方法很简单检查所有返回类型是可变对象的方法确认你没有把内部引用直接交出去。4. 继承代码复用的蜜糖与毒药4.1 继承到底解决了什么问题继承解决的核心问题是共性抽取。当你发现多个类有相同的字段和方法时可以把这些公共部分上提到父类里子类通过继承自动获得这些成员只需要补充自己的差异部分。比如Teacher和Student都有姓名、年龄、联系方式可以从一个Person基类派生出来。但这里必须说一句很多教程不会强调的话继承是严格的is-a关系而不是代码复用工具。你只有在语义上确实满足“子类是父类的一种”时才应该使用继承。如果只是因为“这两个类有相同方法我抽个父类出来省代码”很可能会构造出语义扭曲的继承结构后面维护起来比复用省下来的那点时间贵得多。4.2 方法重写的完整规则与细节子类继承父类之后可以重写父类方法。重写有几个硬性规则面试必考方法签名必须相同返回值类型可以是父类返回值的子类型访问权限不能比父类更严格比如父类是public子类就不能改成protected不能抛出比父类更宽的受检异常。在实际写法上重写方法建议加Override注解。这个注解有两个作用一是让编译器帮你校验签名写错了直接编译报错二是让读代码的人一眼知道这里是重写可读性更好。我见过有人嫌麻烦不写结果方法的参数类型写错了重写变成了重载又因为重载不报错问题会在运行期悄悄暴露排查起来特别痛苦。4.3 构造函数的继承规则父类构造器必须先执行构造函数不能被子类继承但子类的构造函数必须直接或间接调用父类构造函数。这个规则背后是“父类先初始化”的原则——子类对象在逻辑上先是一个父类对象然后才是自己。如果你在子类构造函数里没有显式写super(...)编译器会默认调用父类的无参构造函数。如果父类没有无参构造编译直接失败这是新手最常见的报错之一。举个具体场景。父类Person只有带参构造Person(String name, int age)子类Student如果也想有自己的校验逻辑构造函数应该写成下面这样public Student(String studentId, String name, int age) { super(name, age); // 必须先初始化父类 this.studentId studentId; }super()必须是子类构造函数的第一条语句这也是硬性规定。为什么因为在Java的初始化顺序里父类字段和构造函数必须全部执行完毕子类才能初始化自己的字段。如果允许把super()放在后面就可能出现子类字段已经初始化的前提下父类还没初始化完对象状态会出现撕裂。4.4 组合优先与继承的关系现在很多企业级开发里我其实不太敢建议新人大量使用继承。原因很简单继承层级一旦超过三层改动父类会像地震一样波及所有子类Java的类又是单继承一旦继承了某个类就没法继承另一个。所以开发中更常见的做法是组合——把另一个类的对象作为自己的字段比如“学生有一个成绩单”而不是“学生是成绩单”。推荐的做法是这样的优先使用组合只有当is-a关系非常明确、并且你确认子类不会破坏父类不变量时才使用继承。接口用来定义行为契约抽象类用来抽取共性实现普通继承用来表达真正的类型关系。这几个工具各有各的用途用对场景才是关键。5. 多态让代码面对父类编程而不是面对具体类型5.1 从“重载”到“重写”多态的两个层面多态在Java里实际包含两块内容很多人混着讲面试也容易说串。第一块是编译期多态也就是方法重载——同一个类里方法名相同、参数列表不同编译时期就决定调用哪个版本。第二块是运行期多态也就是方法重写结合向上转型——父类引用指向子类对象调用重写方法时JVM在运行期动态决定实际执行的是子类版本。我重点讲运行期多态因为这是面向对象设计里最核心的武器。假设你有父类Animal和子类Dog、Cat都重写了speak()方法。代码写成Animal a1 new Dog(); Animal a2 new Cat(); a1.speak(); // 实际执行Dog的speak a2.speak(); // 实际执行Cat的speak编译时期编译器只认Animal类型的方法签名运行时期才去找到实际对象的类调用真正的方法。这个机制带来了一个惊人的能力你可以在完全不知道有多少个子类的前提下用父类引用统一处理它们。5.2 多态解决了什么实际问题我在做系统设计时最常用的技巧就是面向接口编程。比如你有短信发送需求可能对接阿里云也可能对接腾讯云甚至自建短信网关。每个渠道的发送逻辑完全不同但对外暴露的动作都是“发送短信”。做法是定义一个SmsSender接口让阿里实现类、腾讯实现类各自实现接口业务代码只依赖SmsSender接口。这样做的直接好处是切换短信供应商时业务代码一行都不用改只需要换注入的实现类。如果你不理解多态这里就只能用一堆if-else判断当前是哪家供应商然后调用对应方法每加一个渠道就要改一遍业务代码维护成本瞬间起飞。5.3 向下转型与instanceof的正确用法运行期多态是向上转型使用父类引用。但有些场景父类引用不够用你需要调子类独有的方法这就涉及到向下转型。向下转型要非常小心因为如果实际对象类型和转换目标类型不匹配运行期会抛ClassCastException。安全的写法是先用instanceof判断类型再强转if (animal instanceof Dog) { Dog dog (Dog) animal; dog.watchDoor(); } else if (animal instanceof Cat) { Cat cat (Cat) animal; cat.catchMouse(); }不过我还想补充一句instanceof用多了通常是设计信号说明你的多态没有用到位。如果每个分支都是“判断类型→强转→调独有方法”完全可以把这个逻辑下沉到各子类的重写方法里让外部代码统一调用一个接口方法从根上消灭类型判断。日常开发里我自己的习惯是向下转型能不用就不用用多态解决问题永远是更干净的路。6. 抽象类与接口建模的“虚”与“实”6.1 抽象类的核心作用抽象类用abstract修饰它是“没有完全设计好的类”。抽象类可以包含抽象方法只有签名没有方法体也可以包含具体实现方法。它的价值在于把子类必须拥有的行为定义成抽象方法强制子类去实现同时把公共逻辑放在具体方法里复用。举个实际例子。你开发一个报表导出模块不同格式PDF、Excel、CSV的报表导出步骤大致相同查询数据、格式化数据、生成文件、返回下载地址。只有“生成文件”这一步每种格式完全不同。这时候可以做一个抽象类ReportExporter把查询数据、格式化、返回地址写成具体方法只有generateFile()声明为抽象方法让各格式子类自己实现。这个设计比直接写一个接口多了一层优势公共流程代码只需写一次子类只需要关心自己那一步。如果全部用接口每个实现类都要重复实现完整的导出流程代码冗余度会很高。6.2 接口从行为契约到默认方法的演变接口的核心是“行为契约”它只关心你能干什么不关心你怎么干。Java 8之后接口允许default方法和静态方法老教程里“接口只能有抽象方法”的说法已经过时。default方法让接口在新增方法时不必强制所有实现类改动这是接口演化能力的重要提升。不过我要给出真实开发建议default方法谨慎使用。它的存在确实补充了接口的演进短板但滥用会让接口变成“半实现”的怪物实现类继承default方法的隐式行为时容易出现二义性。我通常在以下两个场景使用default一是接口需要新增一个可选方法不希望破坏现有实现类二是为接口提供一个基于现有方法组合出的通用实现比如and()方法可以基于test()给出默认逻辑。6.3 抽象类还是接口二选一的决策思路面试题里最经典的之一就是“抽象类和接口的区别”。从设计语义上我给一个直接可用的判断框架抽象类表达的是is-a关系接口表达的是has-a能力抽象类是模板复用接口是契约规范类是单继承接口可以多实现。具体来说如果几个类本质上是同一类事物只是实现细节不同优先抽象类如果几个类完全不相关、只是恰好都需要具备某个能力优先接口。比如Dog和Cat是动物用抽象类Animal合适Car和Drone都能“自动驾驶”它们不是同一类事物用接口AutoPilot合适。想清楚这个你建模就不会跑偏。7. 内存与细节对象在JVM里到底长什么样7.1 堆、栈、方法区三者之间怎么协同对象生命周期涉及JVM的三大区域。堆区存放对象实例本身栈区存放基本类型变量和对象引用方法区存放类元信息、静态变量和常量池数据。new出来的对象在堆里局部变量在栈里引用指向堆中的地址。很多人挂在嘴边的“内存泄漏”放到对象层面理解就是对象已经不再被业务使用但它仍然被某些引用链持有GC无法回收。最常见的场景是静态集合持有大量对象引用比如写了一个静态List用来缓存数据但只往里面加、从不删除堆里对象越积越多。我排查过不少线上OOM最后定位到根因都是在静态Map上——面向对象的思维里静态成员用不好就是内存管理的地雷。7.2 static与实例成员的区别属于类还是属于对象再强调一下static的本质static成员属于类本身所有实例共享同一个副本实例成员属于单个对象每个对象独立一份。这就是为什么前面例子里的totalCount可以统计总数——所有学生对象共享同一个静态变量。有一个面试高频问题值得在这里说透static方法能不能调用实例方法不能。因为静态方法通过类名.方法()调用时不一定存在某个具体对象自然无法确定要访问哪个实例的成员。反过来实例方法可以访问静态成员因为静态成员在任何实例存在之前就已经加载完毕了。这个对称关系想明白基本就理解了static的定位。7.3 、equals与hashCode的三方配合对象比较是日常开发高频场景。基本类型用比较数值引用类型用比较的是引用地址。想要比较两个对象的内容是否相同要重写equals()方法。重写equals()必须同时重写hashCode()因为Java规定两个对象equals相等时hashCode必须相等。违反了这个约定HashMap、HashSet这类散列集合就会出现“存入时在一个桶、查找时去了另一个桶”的诡异行为。public class Student { private String studentId; Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Student student (Student) o; return Objects.equals(studentId, student.studentId); } Override public int hashCode() { return Objects.hash(studentId); } }我平时写实体类做比较时也推荐用Objects.equals处理字符串这种可能为null的字段避免手写null判断丢三落四。还有一点值得留意equals里用getClass() ! o.getClass()比用instanceof更严格instanceof在子类继承场景下可能让不同子类对象被判定为相等而getClass()要求类型完全一致更适合实体类比较。8. 面试高频八股与实战避坑速查8.1 面试官最爱问的几个面向对象问题把最常见的面试题整理成一张速查表方便你自查。文字回答的关键是简短、有层次、能用代码佐证。问题核心作答框架面向对象和面向过程的区别面向过程以“步骤”为中心面向对象以“对象”为中心将数据和方法聚合建模重载和重写的区别重载是编译期同方法名不同参数重写是运行期同签名子类覆盖父类实现抽象类和接口怎么选模板复用用抽象类契约规范用接口is-a用抽象类has-a用接口多态的前提条件是什么继承或实现、方法重写、父类引用指向子类对象三者缺一不可构造方法能不能被重写不能构造方法名必须与类名相同没有继承关系equals和有什么区别比地址equals比内容重写equals必须同步重写hashCode回答这些题时我有个经验不要干背定义顺手报一个自己写的代码示例。面试官听到你能用实际代码解释“多态怎么用”比听到你倒背教科书定义要强得多。8.2 项目实战中最容易踩的四个坑第一个坑是继承体系脆弱。新人喜欢把业务逻辑一层层继承下去最后改底层公共方法时所有子类行为跟着变线上回归爆炸。我的建议是继承深度控制在两层以内超过就考虑拆组合。第二个坑是可变对象外泄。前面提到的getter直接返回内部集合引用就是典型的封装破坏。处理方式统一改成返回不可变视图或副本。第三个坑是equals和hashCode不同步重写。实体类仅重写equals、不重写hashCode丢进HashSet后出现重复元素排查起来非常隐蔽。建议所有自定义类一重写就是两个一起写不要拆开。第四个坑是接口滥用。一上来就定义几十个接口每个接口只有两个方法抽象层级过深导致代码跳来跳去根本读不懂。接口是为真实扩展场景服务的不是为了“看起来设计得好”。8.3 新人和资深工程师写出来的代码差在哪同样实现一个订单模块新人往往把订单的所有逻辑堆在一个OrderService里面方法接着方法几百行往下铺。资深工程师的思路通常是先识别出订单、订单项、商品、用户这几个核心领域对象再把状态流转、金额计算、库存扣减这些行为分别放到对应对象身上Service层只做流程编排。这两种写法的本质区别不是会不会语法而是有没有建立起“职责归属”的意识。面对一个业务需求时你不是急着写if-else而是先问自己一个问题这段逻辑的职责应该属于哪个对象数据和方法放一起行为内聚依赖关系清晰这才是面向对象建模的日常训练。9. 从基础到重构对象思维如何改变你写代码的习惯话说回来面向对象不是学完语法就能立刻熟练使用的它更像一个需要长期刻意练习的设计视角。我个人的体会是尽量每天花一点时间读那些写得好的开源代码观察别人怎么组织类、怎么设计接口、怎么处理继承和组合。你读得越多越能体会“好的设计是可以感知的”——类不大不小、职责单一、方法命名准确、扩展时不需要改老代码。如果你正在准备面试建议把封装、继承、多态、抽象这四块理解透之后再去看Spring的Bean生命周期、设计模式、集合类源码会顺畅很多。因为框架的很多设计本质都是面向对象思想的具体化比如Spring的IoC容器把对象创建和管理的职责抽走比如模板方法模式就是抽象类思想的经典应用比如策略模式就是多态加上接口组合的产物。掌握这些你会发现自己不仅是在写Java而是在用Java承载真实世界的模型。后面不管是做微服务、写并发、还是啃中间件源码对象思维始终是底层地基。
返回列表