☰
Java面向对象核心:类、对象、封装与构造方法详解
2026/9/30 12:34:19 网站建设 项目流程

1. 先搞清楚一件事:面向对象到底解决了什么问题

很多人学Java面向对象,一上来就被class、new、封装这些术语砸晕,仿佛在学玄学。我之前带过不少转行的新人,也面试过很多自称“熟悉Java”的候选人,最常出现的场景是:能背出“面向对象三大特性”,但真要他写一个能用的东西,立马露馅。原因很简单——他没搞明白面向对象到底要解决什么。

在Java里面,面向对象不是语法,是组织代码的思维。Java语言的语法设计,从类到接口到泛型,几乎全是为了把“面向对象”这件事做扎实。所以如果第一节课就把对象当成“new一个东西然后用”,后面学集合、学IO、学框架全靠硬背,越学越累。反过来,把面向对象搞透了,后面看HashMap、看Spring那些东西,你会觉得它们都是顺着这套思路长出来的。

那面向对象到底解决了什么问题?一句话:它让代码更好地模拟现实世界,从而应对复杂业务。

举个例子。你要写一个教务系统,里面有学生、老师、课程、成绩。如果用面向过程写,就是一堆函数:registerStudent()、calculateScore()、printReport(),数据靠全局变量传来传去。系统小还好,一旦业务复杂,你会发现函数之间互相咬合,改一个逻辑牵一发动全身。面向对象做的是什么?它把“学生”这个实体以及跟它相关的数据和操作放在一起,谁的事情谁管。代码的结构从“按流程走”变成“按对象分”,条理天然清晰。

生活化一点去理解:面向过程像你自己在家做饭,买菜、洗菜、切菜、炒菜全是你的活,锅碗瓢盆摆在明面上,动一下就乱。面向对象像下馆子,你要的是“一份宫保鸡丁”,后厨有配菜的、掌勺的、装盘的,各管一摊。你不需要知道配菜工怎么切,掌勺的怎么颠勺,你只拿到结果。这就是封装。以后需求变了——比如餐具要换,你只换负责餐具的那个对象,不用把整个后厨推翻。

这一篇是系列的第一篇,咱们不贪多,把最核心的三件事讲透:类与对象、封装、构造方法和this。这套东西搞明白,后面讲继承多态、接口抽象类,就是顺水推舟的事。

2. 类与对象:整个面向对象的基石

2.1 类是模板,对象是实实在在的东西

先说结论:**类是图纸,对象是按照图纸造出来的实物。**一张图纸可以造无数个房子,每个房子的朝向、装修可以不同,但它们都遵循同一套结构。在Java里,图纸就是class,造出来的房子就是object(也叫实例)。

看代码:

public class Student { // 成员变量:描述对象有哪些数据 String name; int age; String studentId; // 成员方法:描述对象能做什么 void introduce() { System.out.println("大家好,我是" + name + ",今年" + age + "岁,学号是" + studentId); } }

这只是定义了一个“模板”,它本身不占实际业务数据空间。你要真的用一个学生,得new一个出来:

public class Main { public static void main(String[] args) { Student s1 = new Student(); s1.name = "张三"; s1.age = 20; s1.studentId = "2024001"; s1.introduce(); Student s2 = new Student(); s2.name = "李四"; s2.age = 21; s2.studentId = "2024002"; s2.introduce(); } }

new Student()就是在“照着图纸造一个具体的学生”。同一个类,造出来的s1和s2是两个独立的对象,改s1的name不会影响s2——这一点很重要,很多新手以为它们共用一个模板所以数据也共享,其实完全是误会。类定义了“有什么”和“能做什么”,但每个对象自己的数据是独立的一份。

2.2 new关键字背后:内存里发生了什么

这里值得稍微往下挖一层。new到底干了什么?简单说三步:在堆内存中分配一块空间、执行构造方法(后面会细讲)、把这块空间的地址返回给引用变量。

拿上面的例子说,Student s1 = new Student();这一行,等号左边s1是一个“引用”,它存的是一个地址,指向堆内存里真正的那块Student数据。等号右边的new负责在堆里把对象造出来。

理解这一点最大的用处,是后面你踩到空指针异常时能自己定位问题。比如:

Student s1; s1.introduce();

编译都过不去?不,编译能过,但运行时直接抛NullPointerException。为什么?因为s1只声明了,没new,它现在是个空引用——手里拿着个空的地址,你让它去调方法,JVM跑到那里发现“你这地址指向的是null,没法干活”,当场给你炸了。

你把内存模型想清楚了,这种问题基本一眼就能看出来。新手阶段别急着看什么JVM调优,先把栈、堆、引用这三者的关系理顺,已经能应付绝大部分日常问题了。

2.3 成员变量和局部变量:别把两者混为一谈

在类里面定义的变量叫成员变量,在方法里面定义的变量叫局部变量。这两者最本质的区别是生命周期和作用域。

成员变量属于对象,对象在堆里活着,它就跟着活着;局部变量属于方法,方法一执行完,栈帧一弹出,它基本就没了。还有一个新手容易忘的:成员变量有默认值,比如int默认0,String默认null,你不在代码里显式赋值,对象创建出来它也是一个“合法的默认状态”。但局部变量不一样,你不初始化直接拿来用,编译器直接报错:Variable 'x' might not have been initialized。

我见过不少把局部变量和成员变量搞混导致的bug,尤其在写构造方法或者setter的时候,参数名和成员变量名一撞,忘了用this,赋值赋值了个寂寞。这个放到后面讲this的时候细说。

这里给一张对比表,方便你后面自查:

对比项成员变量局部变量
定义位置类内部、方法外部方法内部(含参数列表)
生命周期随对象创建而存在,随对象回收而消失方法调用期间存在,方法结束即释放
默认值有默认值(0、null、false等)没有默认值,必须先初始化
作用域整个类内部的方法都可以访问只在定义它的方法内部有效
存储区域堆内存(对象里)栈内存(栈帧里)

这张表不是让你背的,是让你在写代码想不通的时候回来看一眼,比死记硬背管用。

3. 封装:给数据装一道“门禁”

3.1 为什么不能把字段直接裸奔在外面

“封装”这个词听起来高大上,其实核心就是一句话:对象内部的数据不能被外部随便乱动,必须通过它规定好的方法来访问。

为什么要这样?我给你看个反例。假设你的Student类里age直接是public的:

public class Student { public int age; } // 在别的类里: Student s = new Student(); s.age = -100;

年龄是负数,这在业务上根本说不通。但Java编译器不会拦你,因为你把age完全暴露了。等到后面计算平均年龄、按年龄统计的时候,一堆莫名其妙的负数混进来,你查半天不知道哪来的。

这就是封装存在的意义:它不让外部直接碰数据,而是强制走一道“检查门”。比如写一个setAge方法,在里面校验:

public class Student { private int age; public void setAge(int age) { if (age < 0 || age > 150) { throw new IllegalArgumentException("年龄不合法"); } this.age = age; } public int getAge() { return age; } }

这时候外部再想改年龄,必须走setAge(20)这条路,非法值直接给你拦下来。数据的安全和合法性,从源头上就保证了。

3.2 访问修饰符:public、private、protected、默认包内可见

实现封装的工具是访问修饰符,Java里有四种,很多新手会搞混,尤其是protected和默认(不写)的区别:

修饰符本类同包子类(不同包)任何地方
private可以不行不行不行
默认(不写)可以可以不行不行
protected可以可以可以不行
public可以可以可以可以

新手阶段记住一个保守原则就行:字段一律用private,方法根据暴露需求选public或private,能不放宽就不放宽。有个工具方法只想让类内部自己用,就设private;对外暴露的行为才设public。

好多同学写代码图省事,字段全public或者全默认,小项目跑起来确实没毛病,但有个隐患:你写的类将来换个人维护,他不知道哪些字段是“内部实现细节”,乱改一通,表面功能没坏,实际上已经埋雷了。封装的第一价值不是给未来用户加麻烦,是给未来维护的人划好边界。

3.3 getter/setter到底该怎么写

这里有不少争论——是不是每个字段都要配一套getter/setter?我的建议是:不要机械地配,要想清楚这个字段需不需要被外部读或者写。

举个例子,一个“订单”类,订单号是在创建时生成的,之后不允许修改。那你就不该给订单号提供setter,只提供getter。再比如,有些字段是中间计算结果,比如“总分”,它应该由各科成绩相加得到,更合理的做法是只提供getTotalScore()方法,方法内部动态计算,而不是让外部可以随便setTotalScore(599)。

还有一个非常容易被忽视的细节:当你的字段是可变对象(比如数组、ArrayList、Date)时,getter不要直接返回内部引用。

看这个坑:

public class Student { private String[] courses; public String[] getCourses() { return courses; // 危险! } }

外部拿到数组引用后,student.getCourses()[0] = "音乐课",直接把你内部数组改了。你以为自己封装好了,其实内部数据还是裸奔的。正确做法是返回一个副本:

public String[] getCourses() { return courses == null ? null : Arrays.copyOf(courses, courses.length); }

同理,setter接收外部数组时,也要copyOf复制一份再存,防止外部改它的原数组间接影响你的对象。这个坑我面试的时候经常拿来考人,能答上来的候选人,基本对封装的理解已经过关了。

注意:见过不少同学在setter里做校验,但校验逻辑只写一半,比如只判断null没判断空字符串,或者判断了却忘了throw异常,哑巴式失败。记住,校验失败要么抛异常,要么给一个明确的兜底值,千万别什么都不做就返回,不然调用方连哪里错了都无从查起。

4. 构造方法与this:对象出生的第一道程序

4.1 构造方法的基本规矩

构造方法(Constructor)是在new的时候被调用的特殊方法,它决定了“对象一出生是什么状态”。它的规矩不多,但都很严格:

  • 方法名必须和类名完全一致;
  • 没有返回值类型,写void都是错的;
  • 可以用public、protected、private修饰,但通常都是public;
  • 如果你自己不写任何构造方法,Java会送一个无参构造;但如果你写了哪怕一个带参构造,无参构造就没了,需要显式写出来。

最后一条很多人踩坑。比如你写了一个Student(String name)的构造方法,然后代码里还想着new Student(),结果编译报错——编译器在提示你“你定义构造方法的时候,把无参构造挤掉了”。

构造方法用得最多的场景是“必须有数据才能创建对象”。比如学生必须有姓名和学号,那你就可以设计成:

public class Student { private String name; private String studentId; public Student(String name, String studentId) { this.name = name; this.studentId = studentId; } }

这样设计的好处是——你根本造不出一个“没有姓名的学生”。从语法层面就杜绝了半成品对象。相比之下,全用setter然后允许new Student(),你就得时刻提醒自己别忘给字段赋值,代码靠自觉,出事是早晚的。

4.2 this:到底指的是哪个对象

this在Java里就是“当前对象的引用”。它的用途主要有两个:

第一个用途,消除参数和成员变量的歧义。最常见的场景就是构造方法里参数名和字段名一样:

public Student(String name, String studentId) { name = name; // 这里的name是哪边?两边都是参数,赋值了个寂寞 }

你写上this.name = name;,左边this.name指当前对象的成员变量,右边name是参数,清清楚楚。所以我的习惯是:构造方法和setter里,参数名尽量和字段名保持一致,然后用this区分,这样读代码的人一眼就能看懂“你要把参数值赋给哪个字段”。

第二个用途,在构造方法里调用另一个构造方法。比如你想支持多种创建方式:

public Student(String name, String studentId) { this.name = name; this.studentId = studentId; } public Student(String name) { this(name, "未知学号"); // 调用上面那个两个参数的构造方法 }

上面this(...)的写法必须放在构造方法的第一行,这是语法强制的,规则也很合理:你要先让通用逻辑跑完,再干别的,否则状态可能用的是默认值,逻辑就出错了。

4.3 static成员:属于类的,不属于某个对象

聊完构造方法,顺手把static讲一下,因为后面到处都是,而且理解不到位的人特别多。

static修饰的成员属于类本身,不依赖具体对象。用类名直接访问(不是非得用对象访问,但用对象访问容易产生“它是对象成员”的误解)。

最常见的static场景是工具方法和常量。比如:

public class MathUtils { public static final double PI = 3.14159; public static int max(int a, int b) { return a > b ? a : b; } }

调用:MathUtils.max(3, 5);,不需要new MathUtils(),因为max这个方法内部不依赖任何对象的状态。

新手容易犯的一个错误:在static方法里访问实例变量。比如在main方法里:

public class Demo { String name = "编程"; public static void main(String[] args) { System.out.println(name); // 编译报错 } }

为什么不行?因为main是static的,它执行的时候压根没有“当前对象”,你让它访问name,它找不到对象。解决方式要么在main里new Demo()然后再访问,要么把name也设成static。这个问题非常多新手遇到,理解了“static不依赖对象”这个本质,就再也不会犯了。

5. 新手最容易踩的坑:四个高频问题排查实录

下面这些坑不夸张地说,我几乎每周都能在答疑群里看到。整理出来,给你排排雷。

5.1 空指针异常:对象都没造出来,就想让它干活

场景:写了个方法接收Student参数,直接调用student.getName(),一运行就抛NullPointerException,而且日志只告诉你哪一行,不告诉你为什么。

定位思路:判断这个对象是从哪来的。如果是外部传入的,就要警惕调用方可能传了null;如果是自己new的,看看是不是.equals()、比较等操作写错了对象名。快速验证手段是在调用前加个打印,把对象的引用打出来,看看是不是null。

根治办法:外部传入对象时,先做非空校验,该抛异常抛异常,该给默认值给默认值,别让null在系统里“流浪”。另外,我习惯用Objects.requireNonNull(student, "student不能为null"),一行代码搞定校验,比if判断省事。

5.2 值传递和引用传递:为什么我的对象没被改回来?

场景:写了个方法想交换两个对象,执行完发现原对象没变。

Java有一个经典结论:Java只有值传递,没有引用传递。基础类型传的是值的副本,引用类型传的是引用的副本——注意,引用副本指向同一个对象。所以你在方法里改引用指向的对象的字段,外部能看到变化;但你直接改引用本身(比如重新new),外部不受影响。

最典型的反例,交换两个对象:

public static void swap(Student a, Student b) { Student temp = a; a = b; b = temp; // 这里的交换只改了a、b两个引用的副本,原对象没变 }

调用完,外部的两个引用还是老样子。因为你改的是“指向关系”,不是“指向的对象的内容”。想真的交换,得交换对象内部的字段值,或者干脆用返回新对象的方式来实现。这个知识点面试也常考,但理解它不是为了面试,是为了写代码时心里有数:方法内对外部传入的对象做了什么,会影响什么,不会影响什么。

5.3 对象数组和集合:只new了数组,忘了new对象

场景:想要存10个Student,于是这样写:

Student[] students = new Student[10]; students[0].name = "张三"; // 空指针!这个位置是null

数组分配了10个空引用,每个位置都是null。你得一个个new Student()放进去,或者用循环:

for (int i = 0; i < students.length; i++) { students[i] = new Student(); }

同样的道理也适用于ArrayList:new ArrayList<>()只是建了个空的容器,里面一个对象都没有,往里放东西前必须自己new好。很多同学在初始化容器这一环上掉链子,记住:容器负责装东西,不负责造东西。

5.4 封装封了个寂寞:getter返回内部可变对象

场景:字段是List<String>,getter直接返回这个List,外部拿到后add("随便加"),内部数据被改了,但没人知道。

这个问题在3.3里详细说过,这里再强调一次:当字段是可变对象时,getter记得返回防御性副本,setter记得防御性复制。在写代码时看到return一个数组或者集合,就条件反射想一下,“这个引用给出去,会不会出事”。养成这个肌肉记忆,比什么技巧都实用。

6. 这套基础学完之后,怎么检验自己真的会了

我会给新人留一个检验作业,你学到这里也可以自测一下:别用IDE的自动生成,手写一个完整的“图书管理”类,要求包括:私有化字段、合理设计构造方法、带校验的setter、防御性复制的getter、一个static工具方法,然后在main里创建3本图书、遍历打印信息。

这个作业不大,但覆盖了本篇所有核心点。你写的时候不用追求一次通过,关键是每一步都要知道自己为什么这么写。写完对照本文检查一遍,尤其是getter返回可变对象的地方、构造方法参数和字段赋值的地方,再想想每个选择背后的原因。如果一个都想不出来,回头再看看对应章节,把图景补完整。

再分享一个我自己常用的学习技巧:javap命令。写完类之后,在命令行跑一下javap -c 你的类名,你能看到Java编译器到底把你的代码翻译成了什么字节码,new、构造、方法调用这些操作的底层痕迹清清楚楚。很多人说“学了知识记不住”,其实是没把知识变成直观经验,字节码看几回,类的本质就刻在脑子里了。

这个系列讲到这里差不多告一段落。下一篇我会接着讲继承、多态、接口与抽象类——如果说这一篇解决的是“类的自我修养”,下一篇就是“类与类之间怎么协作、怎么扩展”。到时候你会发现在这个基础上往上盖楼,顺畅得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询