开篇:编程世界的"世界观"
在之前的篇章里,我们写的程序主要是以"方法"为核心的。我们定义了数据,然后写一堆方法来处理这些数据。这种方式叫做面向过程编程,它关注的是"怎么做"。
而从今天开始,我们要换一种更高级的"世界观"来看待编程,这就是面向对象编程。它关注的是"谁来做"。
你可以把Java想象成一个虚拟世界。在这个世界里,最小的单元不是函数,而是"对象"。一个对象既拥有属性 (比如一个人的年龄、姓名),也拥有行为(比如一个人会走路、说话)。而我们编写程序,就是在创造和指挥这些对象,让他们相互协作来完成复杂的任务。
1. 类与对象:图纸与实物的关系
1.1 什么是类?
类 就是分类 ,是对具有相同特性和行为的一类事物的抽象描述,就像是一张设计图纸。
比如,我们可以设计一个"人类"的图纸。这张图纸上写着:所有人都应该有名字、年龄(这是属性 ),所有人都应该会说话、会走路(这是方法)。
java
// 这就是一个类,可以看作是一张图纸
class Person {
// 属性(也叫成员变量):描述对象有什么
String name;
int age;
// 方法:描述对象能做什么
public void speak() {
System.out.println(name + "说:你好呀!");
}
}
1.2 什么是对象?
对象 就是根据图纸真正制造出来的实物。图纸只有一个,但根据这张图纸,我们可以制造出千千万万个具体的人。
java
public class Main {
public static void main(String[] args) {
// 根据Person类(图纸)创建一个具体的对象(实物)
Person person1 = new Person();
person1.name = "张三";
person1.age = 18;
Person person2 = new Person();
person2.name = "李四";
person2.age = 20;
person1.speak(); // 输出:张三说:你好呀!
person2.speak(); // 输出:李四说:你好呀!
}
}
区别与联系总结:
- 类 是抽象的,是概念;对象是具体的,是实实在在的存在。
- 类 是创建对象的模板;对象是类的具体实例。
- 当你执行
new Person()时,就是在实例化一个对象。
1.3 对象在内存中的存在形式
用上面的 person1 = new Person(); 来分析内存。
假设 Person 类中有 String name; 和 int age;。
内存分配机制:
- 栈 :当JVM执行
main方法时,会在栈中创建一个main方法的栈帧。在main方法中,person1是一个变量(引用),它存储在栈中。 - 堆 :当执行
new Person()时,JVM会在堆内存 中开辟一块空间,用来真正存储这个对象的实例数据(比如age=0,name=null)。 - 引用指向 :栈中的
person1变量保存的并不是堆中的对象本身,而是对象在堆中的内存地址 (比如0x1234)。我们常说person1是一个引用,它指向了堆中的对象。
图解:
lua
栈内存 堆内存
-------- ------------------------------
main() 方法栈帧 Person对象实例
+----------+ +----------------------------+
| person1 | ---------------> | 对象头 (Mark Word, Class指针)|
| (引用) | 地址 0x1234 | 实例数据: |
+----------+ | name = null (引用) |
| age = 0 |
+----------------------------+
当我们执行 person1.name = "张三" 时,JVM会通过 person1 存储的地址 0x1234 找到堆中的对象,然后将 "张三" 这个字符串对象的地址赋值给 name 属性。
2. 成员方法:赋予对象行为
2.1 方法的调用机制原理(重点)
方法就是让对象干活儿的指令。方法调用的背后,是JVM一场精密的"栈帧"管理工作。
执行流程分析:
- 程序运行,JVM调用
main方法,在栈中为main方法创建一个栈帧,压入栈底。 - 当在
main方法中执行person1.speak()时,JVM会在栈中为speak方法创建一个新的栈帧,并压入栈顶。当前正在执行的就是栈顶的方法。 speak方法执行完毕后,它的栈帧会被销毁(弹出),释放局部变量。程序回到main方法的栈帧,继续向下执行。
示意图:
scss
栈(先进后出)
+-------------------------+
| speak() 方法的栈帧 | <--- 正在执行
| (执行完后弹出) |
+-------------------------+
| main() 方法的栈帧 |
| (等待中) |
+-------------------------+
细节总结:
- 方法调用就是压栈 ,方法结束就是弹栈。
- 每个方法都有自己的独立空间,方法内的局部变量互不影响。
- 方法执行完,返回给调用者结果后,自己的生命就结束了。
3. 成员方法传参机制
Java的参数传递本质上只有一种:值传递。 但这个"值"的内容,对于基本类型和引用类型来说,意义不同。
3.1 基本数据类型的传参
传递的是具体的数值。方法内对形参的修改,不影响实参。
java
public class Test {
public void swap(int a, int b) { // a=10, b=20
int temp = a;
a = b;
b = temp;
System.out.println("swap内:a=" + a + ", b=" + b); // a=20, b=10
}
public static void main(String[] args) {
Test t = new Test();
int x = 10, y = 20;
t.swap(x, y);
System.out.println("main内:x=" + x + ", y=" + y); // x=10, y=20
}
}
原理解析 :main 把 x 和 y 的值 (10和20)复制了一份,传递给了 swap 方法。swap 方法修改的是自己栈帧里的副本,和 main 里的 x, y 无关。
3.2 引用数据类型的传参(重点)
传递的是内存地址的值。方法内通过这个地址修改了堆中的对象,那么所有指向该对象的引用都能看到这个变化。
java
class Person {
String name;
int age;
}
public class Test {
// 方法接收一个 Person 对象的引用(地址)
public void changeAge(Person p) { // p 复制了 main 中的地址,比如 0x1234
p.age = 100; // 通过地址 0x1234 找到对象,把 age 改成 100
// p = null; // 如果加上这一行,只是把形参 p 的指向改了,和实参无关!
}
public static void main(String[] args) {
Test t = new Test();
Person person = new Person(); // 在堆中创建对象,地址假设为 0x1234
person.age = 18;
t.changeAge(person);
System.out.println(person.age); // 输出 100,因为指向的是同一个对象
}
}
传参机制总结:
- 基本类型:传递数据值,形参修改不影响实参。
- 引用类型 :传递地址值,形参和实参指向堆中同一个对象,通过形参修改对象属性,实参能看到变化。
- 特别提醒 :如果方法内让形参重新
new了一个对象或者指向null(p = new Person()或p = null),那只是改变了形参这个"遥控器"的指向,和原来的对象无关,不影响实参。
3.3 应用实例:克隆对象
利用引用类型传参,我们可以实现一个克隆对象的方法。
java
class Person {
String name;
int age;
// 编写一个方法,克隆当前对象
public Person copyPerson() {
// 1. 创建一个新的 Person 对象
Person clone = new Person();
// 2. 将当前对象(this)的属性赋值给新对象
clone.name = this.name;
clone.age = this.age;
// 3. 返回新对象
return clone;
}
}
// 使用
Person p1 = new Person();
p1.name = "张三";
Person p2 = p1.copyPerson(); // p2 是一个全新的对象,但内容与 p1 一样
System.out.println(p2.name); // 输出:张三
此时 p1 == p2 是 false,因为它们在堆中是两块不同的内存。
4. 方法重载
4.1 什么是重载?
在同一个类中,允许存在一个以上的同名方法,只要它们的参数列表不同即可。
java
public class Calculator {
// 求两个整数的和
public int sum(int a, int b) {
return a + b;
}
// 重载:求三个整数的和(参数个数不同)
public int sum(int a, int b, int c) {
return a + b + c;
}
// 重载:求两个小数的和(参数类型不同)
public double sum(double a, double b) {
return a + b;
}
}
4.2 重载的好处
- 减少方法名的数量 :不用为了类似的功能起多个名字(比如
sumInt,sumDouble,sumThreeInt)。 - 便于调用:调用者只需要记住一个方法名,传入不同的参数即可。
4.3 重载的细节
- 必须满足:方法名相同 ,参数列表不同(类型、个数、顺序至少有一项不同)。
- 与返回值类型无关:仅仅是返回值不同,不足以构成重载(编译器无法区分该调用哪个)。
- 与形参名称无关 :
sum(int a, int b)和sum(int x, int y)是一样的。
5. 递归
5.1 什么是递归?
递归就是方法自己调用自己。递归能解决很多复杂的问题,让代码变得极其简洁,比如著名的"斐波那契数列"、"汉诺塔"等。
5.2 递归的规则
一个正确的递归必须包含两部分:
- 递归出口 :什么时候停下来(结束条件)。没有出口就是死循环,最终导致栈溢出。
- 递归规则:如何把原问题分解成更小的、解决方法相同的子问题。
5.3 练习:用递归求阶乘
求 n! (n的阶乘) = n * (n-1) * ... * 1
java
public class Factorial {
public static int factorial(int n) {
// 递归出口:1! = 1
if (n == 1) {
return 1;
}
// 递归规则:n! = n * (n-1)!
return n * factorial(n - 1);
}
public static void main(String[] args) {
System.out.println(factorial(5)); // 输出 120
}
}
执行过程分析(递推与回溯):
factorial(5)执行,发现n != 1,执行5 * factorial(4),此时factorial(5)暂停 ,等待factorial(4)的结果。factorial(4)执行,需要4 * factorial(3),暂停,等待。factorial(3)执行,需要3 * factorial(2),暂停,等待。factorial(2)执行,需要2 * factorial(1),暂停,等待。factorial(1)执行,碰到出口return 1。- 回溯开始 :
factorial(2)得到结果2 * 1 = 2,返回给上一级。 factorial(3)得到结果3 * 2 = 6,返回。factorial(4)得到结果4 * 6 = 24,返回。factorial(5)得到最终结果5 * 24 = 120。
6. 可变参数
6.1 概念与语法
当我们需要定义一个方法,但不确定调用者会传入多少个参数时,就可以使用可变参数。
语法:方法名(参数类型... 形参名)
java
public class VarArgs {
// 计算任意数量整数的和
public static int sum(int... numbers) {
int total = 0;
// 在方法内部,numbers 被当作数组处理
for (int n : numbers) {
total += n;
}
return total;
}
public static void main(String[] args) {
System.out.println(sum(1, 2)); // 输出 3
System.out.println(sum(1, 2, 3, 4)); // 输出 10
System.out.println(sum()); // 输出 0,可以传0个参数
}
}
6.2 细节注意
- 一个方法最多只能有一个可变参数。
- 可变参数必须是方法的最后一个参数 。例如:
public void test(String name, double... scores) { }是正确的。 - 本质上,可变参数就是一个数组,所以调用时可以传数组,也可以直接罗列元素。
7. 作用域
7.1 局部变量与成员变量
- 成员变量 :定义在类里面、方法外面的变量。作用域在整个类内部,有默认初始值(如
int默认0,String默认null)。 - 局部变量 :定义在方法里面、或者方法的参数列表中的变量。作用域只在它所在的花括号
{}内,没有默认初始值,使用前必须赋值,否则编译报错。
java
class ScopeTest {
int age; // 成员变量,作用域整个类,默认值0
public void method1() {
int num = 10; // 局部变量,作用域仅在 method1 内
System.out.println(age); // 可以访问成员变量
System.out.println(num);
}
public void method2() {
// System.out.println(num); // 报错!无法访问 method1 的局部变量
for (int i = 0; i < 3; i++) { // i 的作用域仅在这个 for 循环内
// ...
}
// System.out.println(i); // 报错! i 已经出了作用域
}
}
7.2 注意事项
- **属性(成员变量)**的生命周期长,随着对象的创建而创建,随着对象的销毁而销毁。
- 局部变量的生命周期短,随着方法的调用而创建,随着方法结束(弹栈)而销毁。
- 当成员变量和局部变量重名 时,在方法内部默认遵循"就近原则 ",访问的是局部变量。如果想访问成员变量,需要使用
this关键字。
8. 构造器
8.1 什么是构造器?
构造器也叫构造方法,是创建对象时自动调用的特殊方法 ,主要作用是完成对象的初始化工作(给属性赋初值)。
8.2 构造器的特点与注意事项
- 方法名必须和类名完全一致。
- 没有返回值类型 ,连
void也不能写。 - 当你没有定义任何构造器时,系统会默认提供一个无参构造器 (比如
Person(){})。 - 一旦你显式地定义了一个有参(或无参)构造器,系统就不再提供默认的无参构造器了。
java
class Student {
String name;
int age;
// 无参构造器
public Student() {
System.out.println("Student对象被创建了!");
}
// 有参构造器(重载)
public Student(String sName, int sAge) {
name = sName;
age = sAge;
}
}
// 使用
Student s1 = new Student(); // 调用无参构造器
Student s2 = new Student("王五", 22); // 调用有参构造器
9. 深入理解 this
9.1 this 是什么?
this 关键字,代表当前对象的引用 。哪个对象调用了方法,方法里的 this 就代表谁。
this 就像是每个人说话时的"我"。张三说话时,"我"指张三;李四说话时,"我"指李四。
9.2 this 的核心用途
-
区分重名的成员变量和局部变量 :这是最常见的用法。
javaclass Teacher { String name; public Teacher(String name) { // 左边的 name 是成员变量,右边的 name 是形参(局部变量) this.name = name; // 必须用 this 来指定给成员变量赋值 } } -
调用本类的其他构造器 :可以在一个构造器中调用另一个构造器,避免代码重复。必须放在构造器的第一行 。
javaclass Employee { String name; int id; public Employee() { this("匿名", 0); // 调用下面的有参构造器 System.out.println("无参构造器"); } public Employee(String name, int id) { this.name = name; this.id = id; } } -
作为参数传递 :可以将
this作为参数传递给其他方法,告诉方法当前是哪个对象在调用。
10. 对象创建流程分析
通过以上学习,我们现在可以完整地梳理一下,当写下 Person p = new Person("张三", 18); 时,JVM 在背后都干了些什么:
- 加载类信息 :JVM 先在方法区中查找
Person类的信息。如果没有,先加载Person.class文件,将类的信息(成员变量定义、方法定义等)存入方法区。 - 栈中声明引用 :在
main方法的栈帧中,为p变量分配空间。 - 堆中分配内存 :执行
new,在堆中为Person对象分配内存空间。这个空间包含了对象头和所有实例变量(此时变量是默认值,如name=null,age=0)。 - 初始化默认值:将堆中分配到的内存空间初始化为零值(确保不出现随机值)。
- 显式初始化 :执行在类定义中属性直接赋值的操作(如果有
int age = 10,这一步会把age从0改为10)。 - 执行构造器 :调用构造器。如果构造器第一行有
this(...),则先调用另一个构造器;否则执行构造器内的代码,将"张三"和18赋值给属性。 - 返回地址 :构造器执行完毕,将堆中对象的地址返回给栈中的引用变量
p。
至此,一个完整可用的对象才算是创建完成。
动手实践:
- 类的设计与对象创建 定义一个
Dog类,包含属性name(字符串)、age(整数)、color(字符串),以及一个方法show()用于打印狗狗的信息。然后在main方法中创建两个不同的Dog对象,并调用它们的show()方法。 - 方法重载------计算器升级 在
Calculator类中,为add方法实现多个重载版本,分别支持:
- 两个
int相加 - 两个
double相加 - 三个
int相加 - 一个
int和一个double相加(顺序可变)
- 递归------斐波那契数列。 斐波那契数列的定义是:第1项为1,第2项为1,从第3项起,每一项等于前两项之和。即 f(1)=1,f(2)=1,f(n)=f(n-1)+f(n-2) (n≥3)。请用递归方法编写一个函数 int fib(int n),返回第 n 项的值。并在 main 中打印前10项
- 可变参数------求任意数量数字的最大值 定义一个方法 max,接收可变参数的 int 数字,返回其中的最大值。如果调用时不传任何参数,则返回 Integer.MIN_VALUE。
- 构造器与 this 的使用 定义一个
Book类,属性包括title(书名)、price(价格)。要求:
- 提供无参构造器,在其中调用有参构造器,设置默认书名为"未知",价格为0.0。
- 提供有参构造器,使用
this为属性赋值。 - 提供一个
setPrice方法,如果传入的价格小于0,则打印错误信息,否则修改价格。 - 在
main中测试不同方式创建对象,并调用setPrice方法。
动手实践:零基础学Java | 面向对象编程的类与对象(基础)
如果在练习中遇到问题,欢迎在评论区留言。我们下期见! 🚀