从Day1配环境变量折腾到怀疑人生到Day4被数组下标折磨得够呛第五天总算摸到了Java真正的地基——面向对象。这篇Java学习笔记_Day5记的是我学习类和对象、方法重载、封装、构造器以及this关键字的过程里面有不少当天踩过的坑和后来才想明白的道理。如果你正好学到数组之后、准备进入面向对象这篇笔记应该能帮你少走点弯路。1. 第五天的学习地图这里和前面四天画风完全不同1.1 为什么Day5几乎所有人都会卡一下前面四天学变量、运算符、流程控制、数组虽然也有语法要记但本质上都是直线思维代码从上往下写每一步都很直观。到了Day5突然要切换到设计思维——你得先定义一种类型再拿这种类型去生成具体的东西。这个切换不是靠多写几行代码就能适应的它更像是一种看问题的角度转变。我当时最大的困惑是类和对象到底是什么关系为什么不能像C语言那样直接怼着一堆变量和函数写后来我用了一个特别土的比喻才想通类是图纸对象是按图纸造出来的房子。图纸规定了房子有哪几面墙、几扇窗户、什么户型房子则是图纸在现实世界里的具体存在。你可以用一张图纸造一万套户型完全一样的房子每套房子的门牌号、里面住的住户各不相同。Java里也一样。你写一个类定义它有哪些属性成员变量和哪些行为方法然后通过new关键字按这个模板创建出无数个对象。每个对象有自己独立的数据互不打扰。这个理解一旦建立后面学封装、继承、多态都会顺很多。1.2 今天的知识任务清单Day5的知识点看起来多实际上可以拆成一张清单我建议按顺序逐个击破别一上来就啃面向对象三大特征。知识点核心问题重要程度类与对象类和对象到底是什么关系必须掌握成员变量与局部变量变量定义在不同的位置有什么区别必须掌握方法的定义与调用怎么把一段逻辑封装成一个方法必须掌握参数传递机制Java到底是值传递还是引用传递必须掌握方法重载同名方法怎么区分必须掌握封装与访问修饰符为什么字段通常设成private必须掌握构造器与this对象创建时发生了什么必须掌握我在Day5当天只完成了前四行后面三行是Day6才补上的。如果你时间紧张我的建议是前四行打牢地基后三行理解概念即可代码实例可以参考但不强求全部默写出来。2. 类与对象图纸、房子和门口的保安2.1 类到底是什么先忘掉教科书定义我先讲一个我踩过的理解误区。刚学类的时候我很想给类找一个精确定义于是背了好几遍类是具有相同属性和行为的对象的集合但一写代码还是不知道从哪下手。后来我发现对新手来说把类当成自定义数据类型反而更好用。Java有int、double、String这些内置类型它们描述的是某个数据长什么样。而类描述的是某一种业务对象长什么样。比如要管理学生信息学生有学号、姓名、成绩还有展示信息的行为那我就可以定义一个新的类型叫Student。定义完成之后Student就像int一样可以用来声明变量、创建具体的实例。class Student { String name; double score; void printInfo() { System.out.println(姓名 name 成绩 score); } }这段代码定义了一个Student类型。但注意class关键字只是画图纸它本身不占内存也不会产生任何数据。只有执行下面的代码才会真正造出一个对象Student s1 new Student(); s1.name 张三; s1.score 92.5; s1.printInfo();s1就是根据Student图纸造出来的具体对象。你可以再new一个s2s2也会有name和score但和s1的值完全独立。这一点和数组很像——new数组时每个元素都有独立的存储空间。2.2 从类到对象的完整过程new关键字到底做了什么很多人把new当成创建一个对象的口诀但不知道背后发生了什么。我当时用debug模式单步跟踪了一遍发现new Student()执行时其实是分三步走的第一步在堆内存中为这个对象分配一块连续空间。堆内存是专门存放对象实例的地方和栈内存是分开的。第二步调用对应的构造器构造器后面细说对这块空间的成员变量做初始化。如果类里面没有显式写构造器Java会提供一个无参默认构造器把成员变量设成类型的默认值。第三步把这块空间的首地址返回赋值给引用变量。用个生活化的类比你租房子的时候物业会先给你一把钥匙你和房东签了合同那块房子就是你的了。钥匙相当于对象的引用房子本身相当于堆内存里的对象。你用钥匙开门、开灯操作的是房子里的设施而不是钥匙本身。理解了这一点很多莫名其妙的问题就都能解释了。比如下面这段代码为什么打印出来是null和0Student s new Student(); System.out.println(s.name); // null System.out.println(s.score); // 0.0因为new的时候构造器默认的会把成员变量初始化为该类型的默认值引用类型是null数值类型是0boolean是false。所以对象一出生就带着一套出厂设定不是凭空什么都没有。2.3 成员变量与局部变量的领地意识我第一次写类的时候把变量随便到处声明结果在某个方法里想用另一个方法里定义的变量直接编译报错。后来才搞清楚变量的作用范围取决于它声明在哪。成员变量声明在类内部、方法外部属于整个对象。它的生命周期和对象一致对象创建时分配空间对象被垃圾回收时跟着消失。局部变量声明在方法内部包括方法的参数它的生命周期只在方法执行期间。方法一调用结束栈帧弹出局部变量就没了。两者的默认值规则也完全不同。成员变量有默认值即使你不赋值也能读读到的就是类型的零值。但局部变量没有默认值不初始化直接使用编译器直接报错。class Demo { int a; // 成员变量默认值0可以读 void test() { int b; // 局部变量没有默认值 System.out.println(a); // 合法打印0 // System.out.println(b); // 编译报错可能尚未初始化 } }这个差异背后是JVM的内存模型成员变量存在堆里的对象上JVM在分配对象空间时会自动清零局部变量在栈上JVM为了性能不会自动清零所以Java编译器强制要求你手动初始化。3. 方法不是所有代码都该堆在main里3.1 方法签名与参数传递Java是值传递别搞混刚开始练习的时候我习惯把所有逻辑全部塞进main方法几百行代码层层嵌套看得自己都想吐。后来老师跟我说了一句很关键的话方法不仅仅是为了被调用更是为了给一段逻辑起个名字让代码变成可以朗读的段落。写方法的时候最容易被面试考、也最容易出问题的就是参数传递。Java官方明确说了参数传递只有一种机制值传递。对于基本类型传的是值本身的一个副本。你在方法里改参数外面的变量不受影响。这很好理解就像是复印了一份文件你在复印件上涂改原件还是干净的。但很多人栽在引用类型上。class User { String name; User(String name) { this.name name; } } static void changeName1(User u) { u new User(李四); // 修改的是引用副本的指向 } static void changeName2(User u) { u.name 王五; // 修改的是引用指向的对象的内容 } public static void main(String[] args) { User u new User(张三); changeName1(u); System.out.println(u.name); // 张三 changeName2(u); System.out.println(u.name); // 王五 }为什么因为changeName1里u是main里u的引用的副本两个引用都指向同一个User对象。给引用副本重新赋值只是让副本指向了新对象main里的引用还是指向原来的对象。但changeName2没有改变引用的指向而是直接通过引用副本找到了那个对象改了对象里面的name字段。这就好比你和朋友各拿了一把同一间房的钥匙朋友把钥匙扔掉换了把新钥匙你的钥匙还是能开那间房的门但朋友用钥匙进屋把家具换了你推门进去看到的家具当然也变了。3.2 重载同名方法的看菜下碟同一个类里允许定义多个同名方法只要参数列表不同个数、类型、顺序不一样这就叫方法重载。调用的时候编译器会根据你传的参数个数和类型自动去匹配最合适的那个方法。Java标准库里到处都是重载最典型的就是println。你传String、传int、传double它都能打印就是因为PrintStream类里定义了一大堆同名但参数类型不同的println方法。我自己写过一个练习给Student类写两个printInfo方法一个不带参数打印基本信息一个带boolean参数控制在打印后面是否加分隔线。void printInfo() { System.out.println(姓名 name 成绩 score); } void printInfo(boolean withLine) { printInfo(); if (withLine) { System.out.println(); } }这里有个新手特别容易犯的错认为返回值不同也能构成重载。这是不对的。你光靠返回值类型不同编译器是无法区分两个方法的因为它不知道调用方到底要不要接收返回值。比如两个方法都叫test一个返回int一个返回void你直接写test();编译器根本不知道该调用哪一个。3.3 递归我踩过的栈溢出坑递归不是Day5的必学内容但很多练习题会用到我在这上面吃过一次大亏值得记一笔。递归就是方法自己调用自己当时我用它算阶乘static int factorial(int n) { if (n 1) { return 1; } return n * factorial(n - 1); }这段代码本身没问题基线条件是n等于1时返回1。但我刚开始写的时候把基线条件漏了直接写return n * factorial(n - 1);结果一运行控制台刷了一屏错误最后一行写着StackOverflowError。原因很简单每调用一次方法JVM就会往栈里压一个栈帧保存局部变量和返回地址。如果递归没有出口方法就会无限压栈栈空间耗尽之后JVM只能抛异常。递归能写出很优雅的代码但它不是免费的。每层调用都有栈帧开销深度太大时栈空间说爆就爆。平时练习用递归没问题但在正式项目里如果递归深度不可控最好改成循环或者设置一个明确的深度上限。4. 封装为什么老师总让你把字段设为private4.1 封装的本质把数据关进保险箱我最初对封装的理解特别狭隘以为就是把字段写成private然后生成一堆getter和setter。后来写了一个模拟银行账户的小案例才明白封装的本质是控制访问权限保护数据的合法性。想象一台饮水机你只需要打开红色开关出热水、蓝色开关出冷水。如果厂家把加热棒、温度传感器、水箱内胆全暴露在外面你可以随便摸、随便调那这台饮水机不仅难用而且危险。类也一样字段就是内部零件方法就是对外按钮。如果不封装让外部代码直接操作成员变量数据的合法性就没人把关。比如成绩管理系统里谁都可以写s1.score -100成绩成了负数后面统计、排名全乱套。封装之后所有对score的修改都必须经过setScore方法我们就有机会在入口处拦截非法值。class Student { private double score; public void setScore(double score) { if (score 0 || score 100) { throw new IllegalArgumentException(成绩必须在0到100之间); } this.score score; } public double getScore() { return score; } }这样调用方想给成绩赋负数代码直接抛异常问题在源头就被堵住了而不是等到数据被到处使用之后才发现。4.2 访问修饰符的四档权限Java的访问权限一共有四档从松到严分别是public、protected、默认也叫包私有、private。我刚学的时候经常记混后来靠一个生活化的类比记住了小区里的公共广场谁都能进public同一个小区的业主能进小区内部道路protected同一个楼道单元的住户才能进单元门默认而你家的卧室只有你自己能进private。修饰符同类内同包内子类任意位置public可访问可访问可访问可访问protected可访问可访问可访问不可访问默认无修饰符可访问可访问不可访问不可访问private可访问不可访问不可访问不可访问Day5阶段你只需要盯住private和public记住一条原则成员变量用private对外提供的方法用public。protected和默认权限涉及到继承和包的概念等后面学了再回头深化也不迟。4.3 getter/setter不是摆设但也不能乱写很多教程会让你给每个私有字段生成getter和setter这话没错但容易被理解偏。如果一个字段本身没有额外逻辑只有简单的存取getter和setter写不写取决于外部到底需不需要访问它。我见过一个反面例子把类的所有字段都私有化然后用IDE一键生成全套getter和setter看起来是把数据封装好了实际上是把类变成了一个透明的袋子——外面想拿什么拿什么想改什么改什么没有任何规则。这种封装只是形式上的。真正的封装是把字段私有化并且把业务规则放进方法里。比如一个订单类有amount字段和status字段外部不应该能随意把status改成已支付因为支付状态的变更需要经过支付逻辑的校验。所以比getter/setter更重要的是设计出承载业务逻辑的方法。5. 构造器与this对象诞生的那点事5.1 构造器对象出生时的第一道工序new Student()里Student()就是构造器。构造器的名字必须和类名完全一致而且不能写返回值类型连void都不行。因为构造器不是普通方法它是在对象初始化阶段被JVM调用的它的返回值就是这个对象本身只是语法上不写罢了。我第一次写构造器的时候手痒写了个void Student()结果编译器提示非法组合。后来才知道如果写了void它就不再是构造器而是一个普通方法名字还得遵循方法命名规范不能大写开头。默认构造器是个容易踩坑的点。如果你在类里一个构造器都没有写编译器会自动生成一个无参构造器所以你才能直接new Student()。但只要你写了任何一个带参构造器编译器就不再自动生成无参构造器了。class Student { private String name; Student(String name) { this.name name; } }这时候你再写new Student()就编译报错了因为无参构造器已经不存在了。解决方法是显式补一个无参构造器或者所有调用处都改成带参构造。这个问题在写框架代码的时候特别容易引发连锁反应我后来养成了一个习惯只要自定义了带参构造器就一定顺手写一个无参构造器除非确定不需要。5.2 this关键字解决同名冲突和链式调用this在Java里指的是当前对象。它最朴素也最常用的场景就是区分成员变量和局部变量。构造器参数和成员变量都叫name时this.name name里的this.name指向当前对象的name字段右边的name指向构造器的参数。你不写this就会出问题在构造器里写name name编译器认为左边和右边都是局部变量结果成员变量根本没被赋值对象创建出来之后name还是null。这是新手最经典的低级错误。this还有两个进阶用法。一个是在构造器里调用另一个构造器避免代码重复class Student { private String name; private double score; Student() { this(未命名, 0); } Student(String name, double score) { this.name name; this.score score; } }注意this()调用另一个构造器的语句必须放在构造器第一行否则编译报错。原因是Java规定构造器必须先完成本对象的初始化再执行后续逻辑而初始化步骤不能被打断。另一个进阶用法是实现链式调用每个方法返回this就可以连续调用。比如this.setName(张三).setScore(90);。这个写法在Java标准库里大量使用但Day5阶段作为了解即可真正频繁用到是后面学builder模式的时候。5.3 构造器重载与默认构造器构造器也支持重载。你可以定义无参构造器、只带姓名的构造器、带姓名和成绩的构造器只要参数列表不同。调用时按实参匹配。我练过这样一个场景给Student类设计三种创建方式。第一种是不知道任何信息先创建一个空对象等着后续setter赋值。第二种是只知道姓名成绩默认0后面再补。第三种是姓名和成绩都知道一步到位。这三种情况对应三个构造器既方便了调用方又避免了外部到处直接用setter修改数据。构造器重载本质上就是为不同的初始化场景提供入口。设计构造器的时候要想清楚调用方在什么场景下需要用哪些字段来创建对象。能用一个无参构造器加两个setter解决的问题就不必非得写五个重载构造器个数过多反而会让类难用。6. 配套练习与高频踩坑记录6.1 练习模拟学生管理系统的最小闭环Day5最好的收尾练习是做一个能跑起来的小闭环而不是空读概念。我当时写了一个最简单的学生管理程序一个Student类一个测试类测试类里做一个数组存三个学生然后循环打印每个人的信息。练习要求是这样的Student类里有name、studentId、score三个私有字段提供带参构造器和getter/setter测试类里创建一个Student数组手工填充三条数据用一个for-each循环遍历数组调用printInfo方法再写一个静态方法按成绩高低对学生对象数组排序这里顺便复习一下对象数组的交换。这个练习几乎覆盖了Day5的所有知识点类定义、私有字段、构造器、this、方法调用、对象数组。我当时在排序那块卡了半小时因为对象数组排序和基本类型数组排序的思维不太一样——交换的不是值而是引用。做完这个练习我再回头看类和对象是什么这个问题已经不需要背定义了类写了字段和方法new产生了对象数组里一个个独立的实例方法调用通过引用完成一切都串到了一起。6.2 高频坑1空指针异常为什么总在new之前出现Day5练习阶段最常见的运行时异常就是NullPointerException空指针异常。我当时被它折磨得最多的场景是声明了一个Student类型的数组想都没想就直接给数组元素调方法结果一运行就崩。问题出在new Student[3]只是创建了长度为3的引用类型数组数组里的每个元素在初始状态下都是null它们并没有真正指向任何Student对象。你得先手动new一个对象赋给数组元素再通过这个元素去调用方法。Student[] students new Student[3]; students[0] new Student(张三, 90); students[1] new Student(李四, 85); students[2] new Student(王五, 70);排查空指针的思路也很套路化先看异常堆栈找到报错的具体行数再分析这一行里哪个变量可能是null。如果是集合、数组、字符串这类引用类型还要检查它们是否已经被初始化。我用过一个笨但有效的办法在报错行前面加System.out.println打印所有怀疑对象的引用值看谁打印出来是null。6.3 高频坑2在类内部直接println调试的思路初学阶段我也是靠System.out.println一路走天下的。但很多人println打得没章法打完了还是看不懂程序在干什么。我在Day5阶段总结出两个固定检查点后来调试效率高了不少。第一个检查点是构造器入口。在构造器第一行打印所有参数再在最后一行打印对象关键字段这样每次new一个对象都能确认初始化过程是否符合预期。第二个检查点是方法调用前后。在方法入口打印进入某某方法参数是xxx在方法出口打印方法结束结果是xxx。这相当于给代码加了一组临时的路标。等以后用上IDE的debug断点功能就会发现println的思路和debug的思路完全一致——确认每一步的数据是什么、走到了哪一段代码。只是debug更直观可以随时停下来看所有变量的当前值。6.4 关于心态第一遍学不完是正常的Day5内容含量比前面四天加起来都多如果当天没能把所有知识点全部练熟我建议不要焦虑。我第二天再回头看构造器重载和this有种原来如此的恍然大悟感原因不是第二天的我变聪明了而是前一天代码里的报错已经把疑问埋在了脑子里睡了一觉之后大脑自动把碎片做了整合。我的建议是当天把类、对象、方法调用、成员变量和局部变量这四件事练到闭眼能写剩下的封装、构造器、this如果卡住了可以放在Day6继续消化。学习编程本来就不是线性增长而是螺旋上升今天看不懂的先抄下来明天写代码时多碰几次突然就通了。我在实际学习中的体会是Day5是Java从脚本式思维过渡到工程式思维最关键的一天。不要急着背封装继承多态的定义先老老实实写一个类、new几个对象、调几次方法、让数据在对象之间流动起来。概念会在某个瞬间自己串起来。最后再分享一个小技巧用IDE的debug模式单步跟踪一次new Student()的完整过程看着对象在堆内存里一点点成型、引用被赋值比看十遍书都管用。