1. 先别急着写语法:类和对象到底在解决什么问题
我见过太多新手第一次接触类和对象时的状态:照着教程敲了一行class Person {},然后又敲了一行Person p = new Person(),看起来好像都懂了,但关上电脑自己写需求的时候,完全不知道从哪里下手。这不是学习能力的问题,而是很多人一上来就扎进语法细节,脑子里缺少一张“为什么要这么设计”的全局图。
类和对象是所有面向对象语言的基石。说得直白一点,面向对象就是“用代码模拟现实世界”的一套方法论。我们在程序里处理的不再是零散的数据和函数,而是一个个具有属性和行为的“物体”。这个物体在代码里长什么样、怎么造出来、造出来之后放在哪里,就是这篇“类和对象(上)”要解决的核心问题。
如果你刚开始学 Java,或者学了一段时间但对new、构造方法、内存分配这些概念还是模模糊糊,那你来对地方了。这篇内容不讲玄学,不堆术语,我会结合实际的代码和踩坑经历,把类和对象从设计思路到代码落地完整串一遍。看完之后,你至少能做到:看到一个需求,知道怎么抽象出类来;看到一段代码,能说清楚哪些是类、哪些是对象、它们之间怎么协作。
1.1 从“面向过程”到“面向对象”的思维跳跃
在学会类和对象之前,多数人写的代码都是“面向过程”的。什么意思?就是从头到尾按步骤执行:先拿到用户输入,然后做判断,再计算结果,最后输出。程序像一条流水线,数据在这条流水线上被加工、传递。
面向对象则完全换了一种组织方式。你不再关注步骤,而是关注“参与者”。比如开发一个学生管理系统,面向过程的思路是:先声明一堆数组存放学生信息,然后写函数去增删改查。但面向对象的思路是:先抽象出“学生”这个类,它自带姓名、学号、成绩这些属性,也自带“修改成绩”“查询信息”这些行为。然后程序里的操作,基本就是创建学生对象、让对象自己干活。
这种思维方式刚切换的时候确实不习惯,我当年也经历过这种别扭期。但相信我,一旦你学会用“谁在做什么”的角度去拆解需求,代码的条理性和可维护性会直接上一个台阶。类与对象的核心价值用三句话就能说清:类定义了共同的模板,对象是模板的实际实例,而new就是那个“依照模板造实体”的动作。
1.2 设计图纸与真实房子:一个屡试不爽的类比
为了把这层关系讲透,我一直喜欢用设计图纸和房子的类比。类就是那张图纸,它规定了房子应该有几间卧室、几个窗户、什么风格的装修,但图纸本身不是房子。对象才是真正建好的那一栋房子,它照着图纸来,可以住人,有自己的门牌号和实际位置。
同一个类,可以造出无数个对象,就像同一张图纸可以盖出无数栋房子。每栋房子的基本结构一样,但每栋房子里的住户、家具、朝向都可以不同。对应到代码里,就是同一个Student类,可以new出好几个学生对象,每个对象的姓名和分数各不相同,互不干扰。
这个类比还能解释一个关键点:操作对象只是在操作“那栋具体的房子”。你不可能通过改其中一栋房子,把图纸也给改了。同理,你修改某个对象的属性,一般不会影响类本身,也不会影响其他对象。理解了这层关系,后面理解静态变量、类变量和实例变量的区别就顺理成章了。
2. 类的基本结构:从零定义一个标准的类
语法是理解一切的骨架。我们先看一个最典型的类定义长什么样。以一个游戏角色的类为例:
public class Hero { // 属性:成员变量 private String name; private int level; private int hp; // 行为:成员方法 public void attack() { System.out.println(name + " 发动了一次攻击!"); } public void move() { System.out.println(name + " 正在移动"); } }这里有几个点必须说透。第一,类里面的name、level、hp是成员变量,它们描述的是这个类“有什么”。第二,attack()、move()是成员方法,它们描述的是这个类“能干什么”。第三,变量的类型、方法的返回值、访问修饰符这些都是约束,它们保证了类不会被乱用。
很多初学者会问:为什么不直接把变量公开,非要写一堆private加上getter、setter?“上篇”阶段我会先把这个疑惑放在这里,等讲完对象的创建和内存机制,再回头看封装才会真正有感觉。现在你只需要记住:把属性藏起来,只留下可控的方法去操作属性,这是设计上的刻意为之,不是多此一举。
2.1 成员变量的类型与默认值细节
成员变量可以有各种类型:基本数据类型、字符串、数组,甚至是后面要学的其他自定义类。这就是面向对象的组合能力——一个类可以持有别的类的对象作为自己的属性。
这里有一个极其容易踩的坑,就是成员变量的默认值。如果你在类里声明了int hp;,并没有赋初值,那么这个变量的默认值是 0。基本数据类型的默认值对应关系如下:
| 类型 | 默认值 |
|---|---|
| byte / short / int / long | 0 |
| float / double | 0.0 |
| boolean | false |
| char | 空字符 '\u0000' |
| 引用类型(String、数组、对象) | null |
这个默认值机制在局部变量身上是不存在的。局部变量必须显式初始化才能用,否则编译直接报错。我见过有人把成员变量和局部变量搞混,在外面声明一个int count;就以为默认是 0 可以直接用,结果放到某个方法里当作局部变量使用时,编译器立刻给出 “variable might not have been initialized” 的提示。这个问题下面第 5 章会专门展开。
2.2 成员方法的定义与调用约定
方法用来描述行为。一个完整的方法由访问修饰符、返回值类型、方法名、参数列表和方法体组成。比如public void attack()表示这是一个公开的、没有返回值的方法;public int getLevel()表示这是一个返回int类型数据的方法。
有一点需要特别强调:方法定义在类里面,但只有在“对象”被创建后,你才能通过对象去调用它。调用语法是对象名.方法名(参数)。比如:
Hero garen = new Hero(); garen.attack();这个地方初学者很容易写成Hero.attack(),结果报错。为什么不能直接用类名调用?因为类只是模板,模板上的行为并没有绑定到任何具体的“英雄”身上。你得先有一个具体的英雄对象,让它去攻击。这跟现实是一致的:图纸上的“发动攻击”只是一个条目,真正能攻击的是照着图纸盖出来的那栋“人形建筑”。
3. 对象的创建过程:new 背后的三步动作
我把这个单独拎出来作为重点,因为它是“类和对象(上)”里承上启下的核心环节。很多人代码写得飞起,但一问new到底干了什么就沉默。面试的时候这道题也很常考,考察的就是你有没有真正理解对象创建的内存机制。
Hero garen = new Hero();这行代码不是一步,而是三步:
Hero garen:在栈内存中声明一个引用变量garen,注意此时它还没有指向任何对象。new Hero():在堆内存中为新的Hero对象分配空间,并且执行构造方法完成初始化。=:把堆内存中这个对象的地址赋值给栈中的garen变量。从此,garen就持有了这个对象的引用,可以通过它来访问对象。
明白这三步,你就能理解为什么两个对象new出来之后,互不影响。因为每次new都会在堆内存里独立开辟一块新空间,两个对象是两块完全独立的内存。你不可能通过修改garen.hp去影响另一个Hero对象。
3.1 引用类型与值类型的本质差异
很多教材喜欢讲“栈存引用、堆存对象”,但这句话太抽象。我来换个说法:基本类型变量(比如int a = 10)存的就是数值本身,你把这个值赋给另一个变量,相当于复制了一份;引用类型变量存的是“对象的门牌号”,你把这个变量赋给另一个变量,相当于把同一个门牌号又抄了一份递给别人,别人拿着同一个门牌号,找的还是同一栋房子。
理解了这个,你就明白什么是“按引用传递”。把对象作为参数传给一个方法,方法内部修改了对象的属性,外面的对象也会变,因为方法拿到的是同一个地址。
下面这张对比表可以帮你快速掌握两者的差异:
| 维度 | 基本类型变量 | 引用类型变量 |
|---|---|---|
| 存储内容 | 实际的数值 | 对象在堆内存中的地址 |
| 默认值 | 有固定的默认值 | null(代表什么都不指向) |
| 赋值行为 | 复制值 | 复制引用(地址) |
| 相等判断 | 比较值是否相等 | 比较地址是否相同 |
3.2 栈与堆:对象的记忆体布局
我还是用房子来打比方。栈区像一个收纳小纸条的盒子,每条纸条上都写着“门牌号”,这个门牌号指向堆区里真正的大房子。每个方法调用的时候,会在栈里创建一条记录,方法跑完,这条记录连同里面的局部变量纸条一起被清理。所以栈的生命周期短且快。
堆区则是大房子的集中区域。只要还有引用变量指着这栋房子,它就还活着;当你把指向它的所有引用都断开(比如把变量设为null),它就成了无主之物,等着垃圾回收器来清理。这也是为什么创建对象时会有内存分配开销,而简单数值的int操作要快得多。
新手不用把 JVM 内存分区背得滚瓜烂熟,但脑子里一定要有这张图:栈里存引用,堆里存对象;局部变量在栈,成员变量在堆中随对象而生。有了这张图,后面学垃圾回收、学容器、学并发,都很轻松。
4. 构造方法与初始化细节:对象诞生时该做什么
类里面有一种非常特殊的方法,叫做构造方法(Constructor)。它的名字必须和类名完全一致,没有返回值类型,甚至连void都不能写。它的作用是在new的时候,把对象的初始状态设置好。
举个例子:
public class Hero { private String name; private int level; // 构造方法 public Hero(String heroName, int startLevel) { name = heroName; level = startLevel; } }创建对象:
Hero garen = new Hero("盖伦", 1);这段代码执行后,garen这个对象的名字是“盖伦”,等级是 1。如果没写构造方法,Java 会提供一个默认无参构造器,把成员变量保留默认值。一旦你自定义了构造方法,默认的无参构造器就没了。很多人在这上面翻过车:自己写了一个有参构造器之后,又直接new Hero(),编译器就开始报错。原因就是默认的无参构造器被你顶掉了。
4.1 构造方法重载与 this 关键字
Java 允许同一个类里有多个构造方法,只要参数列表不同。这叫构造器重载。这样设计的好处是:你可以让对象的创建方式更加灵活。比如一个Hero类,既支持只传名字创建,也支持传名字和等级创建。
这个时候就会用到this。this在这个上下文里的含义是“当前正在被创建的那个对象”。最常见的写法是解决局部变量遮蔽成员变量的问题:
public Hero(String name, int level) { this.name = name; this.level = level; }左边this.name是成员变量,右边name是参数。没有this,程序会认为你只是把参数赋给了自己,成员变量根本没被赋值,结果就是对象的属性全是默认值。这是新手最容易忽视的细节之一。
另外一个实用技巧是构造器间的互相调用。在一个构造器里可以写this(参数)去调用当前类的另一个构造器,这样可以避免重复代码。注意this(...)必须是构造器里的第一行语句。
4.2 new 后面到底发生了什么:初始化顺序
一个对象从出生到可用,内部有严格的初始化顺序。我把它梳理成下面这条链路:
- 检查类是否已经加载,如果内存里还没有,先完成类的加载和类变量的初始化。
- 在堆区为对象分配内存空间。
- 对成员变量执行显式赋值,比如你在声明处写了
private int level = 1;,此时会赋成 1。 - 执行构造方法中的代码,这里是最后一次“手动”设置初始状态的机会。
- 返回堆区对象的引用,赋值给栈中的变量。
掌握这条链路的作用是:遇到“为什么成员变量先有默认值,后来又变成乱值”的问题,你能快速定位是构造器没写对,还是赋值顺序出了问题。
5. 实操落地:用“学生成绩记录”小项目串起所有知识
学了这么多,不动手做一遍等于白学。下面我带着用类和对象写一个极简的学生成绩记录功能,刻意不让代码复杂,但必须把前面讲的每个核心点都用上。
需求很简单:能创建一个学生对象,设置姓名和分数,能修改分数,能打印学生信息。虽然简单,但覆盖了成员变量、构造方法、成员方法、对象创建、引用传递五大重点。
5.1 学生类的设计与完整代码
public class Student { private String name; private int score; public Student(String name, int score) { this.name = name; this.score = score; } public void updateScore(int newScore) { if (newScore < 0 || newScore > 100) { System.out.println("分数不合法,修改失败"); return; } this.score = newScore; } public void printInfo() { System.out.println("学生:" + name + ",分数:" + score); } }注意这里我故意在updateScore里加了合法性校验,就是为了演示“通过方法来操作属性”的好处。如果你直接公开score字段,外部代码就可以随意写成 999,没有任何保护。把属性私有、提供公开方法,才是真正的面向对象风格。
5.2 程序入口:创建对象并让对象协作
public class Test { public static void main(String[] args) { Student stu1 = new Student("小明", 88); Student stu2 = new Student("小红", 92); stu1.printInfo(); stu2.printInfo(); stu1.updateScore(95); stu1.printInfo(); } }运行之后:
学生:小明,分数:88 学生:小红,分数:92 学生:小明,分数:95这个例子虽然短,但你把内存图画出来,就能看到stu1和stu2在堆里各占一块独立空间,栈中的引用各指向各的。修改stu1的成绩,stu2纹丝不动。这就是对象的独立性。
实际操作时,我建议你在 IDE 里打断点,分别在new语句执行前后观察变量视图,你能清楚看到栈里的引用从 null 变成了一个地址。这种直观的体感,比你看十遍理论都有用。
5.3 把系统做深一点的扩展思路
如果你觉得上面这个小例子不过瘾,可以试着给它加这些功能,每一处都能巩固前面的知识:
- 给
Student加一个id字段,并提供一个无参构造器,自动生成递增编号。这需要用到静态变量,正好可以感受类变量与实例变量的差别。 - 写一个方法接收
Student对象作为参数,比如public static void printScore(Student s),体会引用传递的特性。 - 新增一个
Course类,Student类里持有Course[]数组,一个学生可以选择多门课程,这就是对象与对象之间的组合关系。
这些扩展都不算难,但做完之后,你对类与对象的理解会扎实很多。特别是静态变量那块,一旦涉及编号自动生成,你就会理解为什么类变量可以跨对象共享。
6. 常见问题与排查技巧实录
最后这部分是我最想让你认真看的。下面这些坑,都是我在实际写代码和带新人时反复遇到的,每一个都有真实场景支撑。
6.1 空指针异常:引用变量指向了不存在的对象
Exception in thread "main" java.lang.NullPointerException这是所有 Java 初学者最常见的报错。原因很简单:你声明了一个引用类型变量,但它没有指向任何对象,也就是值为 null,然后你试图通过它调用方法或访问属性。
典型场景:
Student stu; stu.printInfo(); // 报错,stu 是 null还有一种隐蔽的版本:一个类持有另一个类的对象,但你在使用前忘了初始化。比如Student里有个Course类型的属性,直接调用this.course.getName(),如果course没被赋值,一样是空指针。
排查这类问题,我的经验是:看报错栈指到哪一行,把那一行里所有的“点调用”拆开,逐个确认左边那一串引用变量是否为 null。这就是基本功,越早掌握越快。
6.2 局部变量未初始化与成员变量默认值的混淆
前面提过这个问题,但这里值得再强调一遍。成员变量有默认值,局部变量没有默认值。下面的代码会编译报错:
public void test() { int count; System.out.println(count); // 报错,局部变量可能未初始化 }而如果count是类的成员变量,上面的代码就不会报错,输出 0。这种区别背后的原因是:局部变量的生命周期极短,编译器认为你应该明确给它赋初值,避免读到垃圾数据;成员变量则跟对象走,对象分配内存时会被默认清零,所以有默认值。
6.3 自定义了构造方法后“无参构造”消失了
你写了一个带参构造器,然后再new Student()发现报错。这是“构造器陷阱”里最经典的一个。解决办法有两个:要么创建对象时传参,要么在类里显式补一个无参构造器。
我建议在写实体类的时候,养成两个习惯:第一个,显式写出无参构造器,哪怕里面什么都不做;第二个,使用重载时务必检查一下参数列表是否真的有区分度,别让两个构造器签名一样。
6.4 重名覆盖问题:this 忘写的后果
没有写this.name = name而写成了name = name,对象初始化的结果就是成员变量永远是 null 或 0。表面上代码能跑,运行结果却莫名其妙。排查这种问题的技巧是检查构造方法里,左边参数名和右边参数名是否一样,只要一样就得警惕this。
我见过不少自称“学了一个月”的人栽在这上面,而且很难自查。最简单的方法是给参数起一个更好的名字,比如heroName,但这只能治标;真正治本的是理解this表示“当前对象的引用”。
6.5 引用传参的连锁反应
写一个方法修改传入对象的属性,结果方法外的对象也跟着变了,你的第一反应可能是“我的数据怎么被动了手脚?”这不是 bug,而是引用传递的正常表现。
public static void changeScore(Student s) { s.updateScore(30); }调用之后,原对象的分数确实变成了 30。因为参数s接收到的是引用副本,但引用的目标是同一个堆对象。这和第 3 节讲的值传递、引用传递完全对应。如果你不想让外部方法修改对象,就要在方法内部先复制一个新对象再操作。这种设计在真实项目里非常常见。
7. 一些带着经验的额外建议
以上内容,我按“是什么、怎么用、为什么、会踩哪些坑”四条线把类和对象讲了一轮。“类和对象(上)”这个范围,到这里其实已经非常完整。我个人带新人的时候,往往会在这个节点给三条硬建议。
第一条:不要只会照着教程敲例子。关掉教程,自己随手定义一个业务场景类,比如“图书”或者“订单”,给它设计字段、构造器、若干个操作方法,再写个主类造对象试试看。这个过程哪怕错得鬼哭狼嚎,也比机械复制十遍有价值得多。
第二条:善用调试器,而不是用System.out.println到处打印。在 IDE 里打断点看变量图,比任何文档都直观。我第一次画出“栈引用指向堆对象”的图,是在 debugger 里看到的,那一瞬间很多概念全部通了。
第三条:把类和对象的思想迁移到 Java 的 API 类上。比如String、ArrayList,它们本质就是别人写好的类,你在代码里new ArrayList()就是在创建对象。看源码时多问一句“这个类封装了哪些数据、哪些方法”,慢慢你的设计能力就上来了。
类和对象的“上篇”到这里,最核心的基础已经全部落地。后面还有封装、继承、多态这些重量级内容等着你,但只要这一篇里的底层图景搭起来了,后面那些其实就是在这个地基上继续盖楼。建议你把文章里的代码都自己敲一遍,特别是那个 Student 成绩小项目,敲完再画一画内存图,这门课的根基就算真正扎稳了。