ARTICLE DETAIL

资讯详情

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

Java面向对象思想核心:类、对象与封装深度解析

Java面向对象思想核心:类、对象与封装深度解析 很多人在复习Java面试题的时候第一个被问倒的问题往往不是JVM也不是并发而是“面向对象是什么”。这题看着基础却特别能看出一个人对Java到底有没有自己的理解。八股文背得再熟如果只是脱口而出“封装继承多态”面试官追问两句“那封装到底封装了什么”“对象在内存里长什么样”很多人就含糊了。这篇文章是“Java面向对象之面向对象思想”系列的第一篇我会把类与对象、封装、构造方法、内存逻辑这些最核心的概念拆开讲顺便聊聊初学者最常见的几个误区以及一条我验证过很多次的学习路线。适合准备Java基础面试、正在啃Java语法的同学也适合那些写了一段代码但始终觉得“面向对象很虚”的人。1. 为什么面向对象思想是所有Java基础题的起点1.1 面试问的不是定义而是建模思路Java面试题里有一个保留节目请你说说面向对象和面向过程的区别。我见过很多候选人回答“面向对象有三大特性封装、继承、多态”然后就没有然后了。这种答案本身没错但面试官真正想听的是你有没有把一个业务流程拆成对象、把职责分配给对象、再通过对象之间的协作完成功能的能力。在真实的Java项目里这种建模能力直接决定代码能不能维护。比如一个订单系统你不用面向对象也能实现用几个数组存订单、用户、商品再写一堆方法去操作这些数组。一开始确实能跑但需求一旦复杂比如要增加优惠券、库存扣减、支付回调你就会发现代码到处耦合改一个地方要牵连好几个方法。用面向对象的方式把订单、用户、商品、优惠券都建模成对象每个对象管好自己的状态和行为新增需求时往往只需要新增一个类或者修改某个类内部的方法不用把整个流程重写一遍。这其实就是面向对象思想的核心价值它把现实世界里的业务概念映射到代码里让代码的结构和业务的结构保持一致。业务里有用户代码里就有User类业务里有下单动作代码里就有Order类里的createOrder方法。当两者一致时新加入的同事只要看类名就能大概猜到代码的组织方式维护成本会低很多。1.2 面向对象解决的是“需求变化”问题很多人学Java基础时会觉得面向对象是一堆抽象名词跟实际代码没什么关系。其实面向对象是为“变化”而生的。软件行业最不变的事情就是需求一直在变而面向过程的写法把数据和操作数据的方法完全分开一旦需求变化你往往要把整条流程都改一遍。面向对象则把数据和操作数据的方法封装在同一个对象里外部只跟对象交互只要对象对外暴露的接口不变内部随便怎么重构外部都不用动。举个例子一个报表模块最初只需要从数据库查出数据然后打印。面向过程的写法可能是一个类里的五个静态方法按顺序调用。后来你需要在打印前做数据校验再后来需要支持导出Excel每次改动都要在原有方法里插入代码或者新增一个方法去调用旧方法流程越来越复杂。如果用面向对象你可以定义一个ReportData对象把校验、格式化、导出的逻辑分散在对应的对象里主流程只需要把它组装起来。这样每次改动的影响范围都被限制在某个对象内部而不是牵连全局。这也是为什么Java面试题里面向对象相关的内容总是被反复问。因为Java体系里几乎所有的框架Spring、MyBatis、Hibernate底层都在围绕对象做文章。MyBatis-Plus能从Java实体类生成建表SQL本质也是读取类结构、字段注解再生成对应的DDL。你没搞懂类是什么、对象是什么就没法真正理解这些框架为什么会这么设计。1.3 Java基础里那些看似无关的知识点其实都在为面向对象服务Java数据类型、方法、数组、循环这些基础语法看起来和面向对象没什么关系但它们是构建对象的建筑材料。int、String、数组这些类型是你定义类里面字段时用的方法是你给对象定义行为时用的循环和条件判断是你实现这些方法内部逻辑时用的。没有这些基础类就是空壳对象就是空对象。学Java学习路线的时候我一直建议把面向对象放到“基础语法”和“常用API”之间。语法教你怎么写代码面向对象教你怎么组织代码。顺序反了会很难受因为你会带着面向过程的思维去写Java最后写出来的代码虽然能跑但看起来就像一个装满静态方法的工具类仓库完全没有发挥Java的优势。这一点在蓝桥杯算法题里也能感受到很多算法题用普通结构化写法完全没问题但当你需要实现链表、树、图这些数据结构时节点类本身就是面向对象的产物理解了对象和引用你才真正明白指针在Java里是怎么样的一种形态。2. 面向对象与面向过程的真正区别从“我该怎么做”到“谁来做”2.1 面向过程以动词为中心自己动手先看面向过程的编程方式。它的核心思想是把一件事拆成步骤然后用变量保存数据用函数处理数据按顺序调用。整个过程以“动词”为中心关注的是“我该怎么做”。比如你想炒一盘番茄炒蛋面向过程的思路是准备番茄、准备鸡蛋、热油、炒蛋、盛出、炒番茄、混合、调味、出锅。每一步对应一个函数中间用变量传递状态比如boolean eggCooked、int heatLevel。这种思路很直观因为人类思考步骤本来就是线性的适合解决逻辑明确的小问题。但在大型项目里这种写法就会遇到麻烦。数据是全局的函数是公用的谁都可以去修改同一个变量。比如电商系统里有一个orderStatus变量下单、支付、取消订单都可能去改它。某一天你发现订单状态莫名其妙被改了你想排查是谁干的得把所有可能修改它的函数都翻一遍。数据和方法之间的归属关系不清晰才是面向过程在大型项目里最大的痛点。2.2 面向对象以名词为中心职责分工面向对象则是以“名词”为中心关注的是“谁来做”。它不再把数据和函数拆开而是把相关的数据和操作这些数据的方法打包到一个类里然后用这个类创建对象让对象之间互相协作。还是番茄炒蛋的例子。面向对象的思路会先抽象出原料类、厨师类。番茄是一个对象鸡蛋是一个对象厨师是一个对象。厨师有方法cook(番茄鸡蛋)番茄有自己的状态比如新鲜程度鸡蛋有状态比如是否打散。整个过程不是一串线性函数调用而是厨师对象接收食材对象食材对象提供自己的状态厨师对象负责烹饪最后产出一盘菜对象。这种设计的好处是每个角色的职责都很清楚。以后你想加一道菜不要改番茄和鸡蛋只要给厨师加一个方法甚至新增一个厨师类原来那些对象都不受影响。代码的可扩展性和可维护性就是从这种“职责分工”里来的。2.3 用“计算矩形面积”对比两种实现这样说可能还是有点抽象我用一段最简单的代码来对比你马上就能感受到差异。面向过程的写法关注两个变量和一个方法public class Main { public static void main(String[] args) { double width 5.0; double height 3.0; double area width * height; System.out.println(矩形面积 area); } }这段代码能用但width和height只是散落在main方法里的局部变量。如果这个矩形在业务中要被多个方法使用你得把这两个参数到处传递或者定义成全局变量。数据的归属关系不清晰。面向对象的写法先把矩形抽象成类public class Rectangle { private double width; private double height; public Rectangle(double width, double height) { this.width width; this.height height; } public double area() { return width * height; } }使用的时候public class Main { public static void main(String[] args) { Rectangle rect new Rectangle(5.0, 3.0); System.out.println(矩形面积 rect.area()); } }你注意区别在哪。面向过程代码里矩形的数据是给别人用的面向对象代码里矩形自己知道自己的宽和高自己会算自己的面积。以后你给矩形增加颜色、位置、周长、缩放方法都在这个类内部扩展外部调用方不需要知道内部细节。这就是从“操作数据”到“数据自管理”的转变。2.4 为什么Java选择了面向对象Java从诞生之初就选择了面向对象并不是因为这种风格更高级而是因为它更适合大型软件工程的协作场景。企业级项目通常由多个人、多个团队同时开发每个人负责一个模块。面向对象通过类和接口定义好模块之间的边界不同团队只要按照约定好的对象接口交互内部实现可以自由变化。这种“边界清晰”的能力在多人协作里比单纯追求执行效率重要得多。当然面向对象不是银弹也有缺点。类多了之后设计不合理反而会变得臃肿对象关系比业务关系还复杂。但在这个阶段你要先理解一个核心观点面向对象让代码更贴近业务贴近人理解世界的方式这是它最大的价值。至于性能开销现代JVM的优化能力很强绝大多数业务场景里代码的可维护性远比你省下的那几微秒性能值钱。3. 类与对象模板和实物的关系没有你想的那么玄乎3.1 类就是图纸对象就是按图纸造出来的实物很多初学者卡在“类”和“对象”的区别上其实你只需要记住一个类比类是图纸对象是按图纸造出来的房子。一张图纸可以造出很多结构相同的房子每套房子的具体装修、住户、物品都不一样但承重墙、门窗位置这些结构都由图纸决定。public class House { String color; int rooms; }这里的House就是图纸它规定了每栋房子都有颜色和房间数。通过new出来的每个实例就是具体房子House home1 new House(); home1.color 白色; home1.rooms 3; House home2 new House(); home2.color 灰色; home2.rooms 4;home1和home2是两个独立对象它们共享类的结构定义但拥有各自的数据。你在home1里把rooms改成5不会影响home2。这个“独立”的概念特别重要几乎所有和对象赋值相关的Bug根源都是没搞清对象实例之间到底是共享还是独立。3.2 手动定义一个类字段、构造器、方法定义一个类通常包含三部分字段属性、构造器初始化方法、普通方法行为。我拿一个学生类举例public class Student { private String name; private int age; public Student() { } public Student(String name, int age) { this.name name; this.age age; } public void introduce() { System.out.println(我叫 name 今年 age 岁); } }字段name和age用来描述这个对象有什么数据构造器Student(...)用来在对象创建时初始化数据方法introduce()用来描述这个对象能干什么。一个类就是围绕某个业务概念的属性和行为集合。为什么字段要写成private这是封装的第一步后面我会详细讲。现在你记住类的外部不应该直接操作对象的内部数据而是通过方法去完成操作。这个原则虽然简单但能帮你避免很多低级错误。3.3 new关键字在内存里做了什么很多Java面试题会问new对象的过程这也是理解引用类型的关键。当你写下这样一行代码Student s new Student(张三, 18);JVM大致会做这几件事在堆内存中给这个Student对象分配一块空间所有字段按默认值初始化。name是nullage是0。调用你写的构造方法把张三和18赋值给对应字段。把堆内存中这块空间的地址作为返回值保存到栈上的引用变量s里。也就是说变量s本身并没有直接装下整个Student对象它只装了一个地址。真正对象数据在堆里。这个区别非常关键直接决定了为什么Java里“对象赋值”和“基本类型赋值”表现完全不同。3.4 基本类型与引用类型的本质差异Java的数据类型分两类基本类型和引用类型。int、double、boolean这些是基本类型变量直接存值。而类、接口、数组这些是引用类型变量存的是对象的地址。int a 10; int b a; b 20; System.out.println(a); // 还是10因为基本类型赋值是拷贝值a和b是两个独立的存储单元改b不会影响a。对象就完全不同Student s1 new Student(张三, 18); Student s2 s1; s2.setName(李四); System.out.println(s1.getName()); // 李四s1和s2指向同一个堆对象赋值拷贝的是地址。你通过s2改了对象里的names1再去看也是改过后的值。很多初学者在写集合、传参的时候撞上这个坑才回头看引用类型的概念。这里提前搞清楚后面能省大量排查时间。3.5 构造方法的隐藏规则构造方法默认是不需要自己写的但这里有个很常见的陷阱。如果类里一个构造方法都没有编译器会自动生成一个无参构造方法。一旦你手动写了一个带参构造方法编译器就不会再自动生成无参构造方法了。public class User { private String name; public User(String name) { this.name name; } }这时候如果你写User u new User();会直接编译报错因为类里没有无参构造。很多项目会因此遇到问题比如Spring框架在创建Bean时经常需要无参构造加上setter注入你一旦只写了带参构造而忘了无参构造框架会启动失败。正确的习惯是如果你给类加了自定义构造方法并且业务上需要无参构造就显式写出来。别指望编译器自动帮你补。4. 封装为什么要把字段藏起来4.1 不封装的后果一个负数年龄引发的连锁反应假设你写了一个学生类字段没有私有化public class Student { public String name; public int age; }看起来很方便直接student.age 20就能赋值。但很快你就会发现问题没有任何东西能阻止别人写出student.age -100。年龄为负数于业务上完全没有意义可代码里却允许它存在。不封装的问题就在这里对象内部的数据可以被外部随意修改一旦数据非法排查源头会非常痛苦。你可能在一个很大的项目里到处搜索age 最后发现某个模块里写了个负数。数据校验逻辑如果分散在几十个地方你很难统一管理规则。4.2 private加getter/setter到底在保护什么封装的经典做法是把字段用private隐藏起来对外只提供getter和setter方法。private是Java访问修饰符之一它表示字段只能在本类内部访问外部类不能直接访问。然后你在setter方法里加入校验逻辑在getter方法里控制返回的数据。public class Student { private String name; private int age; public String getName() { return name; } public void setName(String name) { if (name null || name.trim().isEmpty()) { throw new IllegalArgumentException(姓名不能为空); } this.name name; } public int getAge() { return age; } public void setAge(int age) { if (age 0 || age 150) { throw new IllegalArgumentException(年龄必须在0到150之间); } this.age age; } }你用setAge(-100)的时候就会立刻抛异常非法数据在入口就被拦住了。如果你不设setter只提供getter那这个字段就是只读的对象创建后就不能随便改。这种控制能力正是封装的意义所在对象内部的数据结构和取值规则是它自己的私事外部只通过公开的方法与它打交道。4.3 JavaBean规范与封装的边界很多人一说到封装就想到private和getter/setter这个理解没错但不完整。JavaBean规范是Java社区里很常用的一套约定字段私有、提供公有的getter/setter、提供无参构造。这套规范被大量框架使用MyBatis-Plus生成建表SQL时会读取实体类的字段和注解Spring注入属性时也会调用setter方法。所以掌握JavaBean写法其实是面向对象思想落到框架层面的第一块敲门砖。但要提醒一句getter/setter只是封装的一种实现手段不是封装本身。一个类如果所有字段都无脑暴露getter/setter别人照样可以拿到数据后自己去做各种判断那类内部的规则还是没有真正封装起来。封装真正的价值在于“隐藏内部实现暴露行为”——比如转账不是先setBalance再setBalance而是调用transferTo(account, amount)方法把余额变动的规则锁在方法内部。这一点理解了你的面向对象才算真正上了一个台阶。5. 方法重载与构造器的配合同一行为的不同入口5.1 重载是什么为什么需要重载方法重载Overload指的是同一个类里方法名相同但参数列表不同。参数列表可以不同在类型、个数或顺序上。返回值不能作为区分重载的依据因为调用方法时编译器只看方法名和参数不看返回值。为什么需要重载最典型的场景是构造器。假设你要创建一个订单对象有时候只知道订单ID有时候知道订单ID、用户ID有时候还需要商品列表。如果每一种创建方式都起不同的方法名比如createById、createByIdAndUserId、createFull调用方光是记方法名就很累。重载让你可以用同一个名字Order(...)通过参数不同表达不同的初始化方式。public class Order { private Long orderId; private Long userId; private ListString itemIds; public Order(Long orderId) { this(orderId, null, null); } public Order(Long orderId, Long userId) { this(orderId, userId, null); } public Order(Long orderId, Long userId, ListString itemIds) { this.orderId orderId; this.userId userId; this.itemIds itemIds; } }调用方可以根据场景选择new Order(1001L)还是new Order(1001L, 2002L)类内部负责把不完整的参数补全。这样的API使用起来非常自然。5.2 构造器重载与this关键字上面那段代码里出现了this(orderId, null, null)这种写法。this关键字在Java里有两个常用场景第一个场景是区分字段和参数。比如常见的this.name name方法参数叫name字段也叫name编译器无法区分this.name指当前对象的字段右侧的name指方法参数。第二个场景是构造器里调用另一个构造器。this(...)必须是构造器里的第一行语句它会把当前对象的初始化职责委托给参数匹配的另一个构造方法。这样做的好处是公共的初始化逻辑只需要写一遍其他构造器都调用它避免代码重复。public class Person { private String name; private int age; public Person() { this(未设置, 0); } public Person(String name, int age) { this.name name; this.age age; } }这里无参构造里调用了带两个参数的构造器把默认值传进去既保证了字段都被初始化又避免了在两个构造器里重复写赋值逻辑。注意this()调用必须放在第一行这是Java语法规定。5.3 static成员属于类而不是对象的成员static是Java里一个很容易混淆的关键字。实例变量和方法都依赖对象存在你必须new一个对象才能访问。而static修饰的成员属于类本身不需要对象就能访问甚至推荐用类名直接访问。public class Counter { public static int count 0; private String name; public Counter(String name) { this.name name; Counter.count; } }每次创建Counter对象静态变量count都会加1。静态变量在内存里只有一份所有对象共享。这就解释了为什么计数器用static会很方便但也正是因为它共享不适合用来存放每个对象独有的数据。main方法为什么是public static void main(String[] args)因为JVM启动时还没有任何对象需要一个不依赖实例就能调用的入口方法所以它必须是static。还有一点初学者容易踩坑静态方法里不能直接访问非静态的实例字段或实例方法因为静态方法不依赖对象而实例字段必须依赖具体对象。你可以在静态方法里先new一个对象再用那个对象去调用实例方法但不能直接写一个裸的name字段。6. 初学者最常见的五个误区以及我踩过的坑6.1 误区一把代码放进类里就是面向对象很多同学刚开始学Java写了一个类把一堆功能全都堆进去就觉得这是在面向对象编程。其实如果这个类里全是静态方法没有任何实例字段也没有对象之间的协作它本质上还是面向过程的写法只不过穿了一件类的马甲。我见过有人用一个Utils类装了四十多个静态方法项目里到处都是Utils.doXxx()。这种代码不是不能用但它很难扩展也很难测试。真正面向对象的做法是把状态和行为打包让对象承担清晰的职责。就算工具类在某些场景下是合理的主流业务代码也应该以对象协作为主。6.2 误区二对象赋值就是在复制对象这个误区我之前提到过但这里必须单独说一下因为它引发的Bug实在太多了。User u2 u1写代码的人觉得u2就是u1的副本结果改u2的时候把u1也改了。这是因为对象变量保存的是堆内存中的地址赋值操作复制的是地址而不是堆里的对象。如果你确实需要复制一个独立对象一般有三种办法手动new一个对象再把字段逐一赋值用拷贝构造器public User(User other)或者使用更复杂的原型模式、序列化方式。无论哪种前提都是先理解普通等号赋值不会帮你创建新对象。6.3 误区三private字段外部绝对访问不到private字段确实不能通过普通写法从外部直接访问但技术世界里没有绝对。有两个常见例外一是通过反射机制修改字段的访问权限Field.setAccessible(true)之后可以读写private字段二是内部类可以访问外部类的私有成员因为编译器会把内部类编译成独立的类文件同时生成访问外部类私有字段的桥接方法。这部分内容在框架层面经常用到比如很多ORM框架反射创建实体对象时就可能绕过private直接给字段赋值。你不能因为存在例外就不设private了private依然是保护类内部状态最重要的手段只是要知道它不是物理级别的不可访问而是语法层级的设计约束。6.4 误区四构造方法写不写都无所谓构造方法的作用是在对象创建时完成初始化。你不写编译器自动生成无参构造字段默认是0、null或false。但如果你希望对象创建时就是合法状态无参构造往往不够用。举个例子一个用户对象如果没有用户名这个对象创建的瞬间就是非法的。如果你提供无参构造别人可能创建一个空用户然后再慢慢setter中间有一段时间对象处于不完整状态。更推荐的做法是提供有参构造把必填字段作为构造参数创建对象时就确保它是完整的再配合无参构造满足框架需求。6.5 误区五封装等于getter和setter这个误区特别普遍。很多人一看到所有字段都是private、都有getter和setter就说这个类封装得很好。其实如果只是机械地暴露所有字段的读写入口那和直接给public字段差不了太多。封装的本质是隐藏内部变化。比如一个支付类内部有余额字段、手续费字段、日志字段。对外你只需要暴露pay(amount)和getBalance()不要把所有中间状态都暴露出去。真正的封装是让使用方只关心“做什么”不关心“怎么变”。getter和setter只是实现封装的一种手段用不用、怎么用要看业务需求。7. 从“面向对象思想-1”到下一步的学习路线建议7.1 用真实业务练建模而不是背语法面向对象思想如果只停留在语法层面学完就忘。我的建议是找一个小而完整的业务场景手动建模练习。不要直接去写增删改查而是先想一想这个业务里有哪些概念这些概念有哪些属性有哪些动作概念和概念之间是什么关系一个经典的练习是“购物车”。你可以定义Product类包含商品ID、名称、价格定义CartItem类包含商品、数量、计算小计的方法定义Cart类包含一个商品条目列表以及添加、删除、计算总价的方法。写完之后你再看哪些逻辑应该属于商品哪些应该属于购物车哪些应该单独拎出来作为服务。这个过程就是在锻炼你的面向对象建模能力。7.2 手画类图把对象关系可视化很多人写代码之前从不画图想到哪写到哪。对于初学者我强烈建议用最简单的方式画类图一个方框分三部分上面写类名中间写字段下面写方法。把类之间的关系用箭头标出来继承用空心三角箭头关联用普通箭头。画图不是为了好看而是为了逼自己想清楚对象之间的关系。比如一台电脑和一个CPU是“拥有”关系一个用户和订单是“一对多”关系一个订单和商品需要中间表表示“多对多”关系。你在代码里傻傻分不清的这些关系画出来一目了然。等你真正进入Spring Boot项目会发现数据库表设计、POJO类设计、接口设计全都建立在对象关系思考上。7.3 接下来要重点啃的四个主题这个系列既然叫“面向对象思想-1”那后续就一定有继承、多态、抽象类、接口这些大主题。你学完类与对象、封装这两个地基后下一步按这个顺序学会顺畅很多继承extends理解子类复用父类的能力以及is-a关系。重写Override理解子类如何改变父类的方法行为Override注解的含义。多态理解父类引用指向子类对象理解动态绑定。抽象类和接口理解什么是“规范”为什么Java设计者要区分这两者。这些主题都会围绕同一个核心展开如何让代码更具扩展性、降低耦合度。等到你理解了接口和多态再回头看Spring的依赖注入就会觉得顺理成章。最后说一点我自己的体会。面向对象思想不是靠看一遍就能掌握的你必须在一个个具体的需求里反复练习去体会“为什么这个字段要私有”“为什么这里要设计一个接口”“为什么这个对象要单独抽出来”。我也是在写了很多烂代码、踩了很多坑之后才慢慢形成自己的判断标准。你现在觉得面向对象抽象很正常把这个系列第一篇里的基础概念吃透再把前面的练习题亲手敲一遍你会发现后面的路会越走越顺畅。
返回列表