第五章 面向对象编程------基础

65 阅读32分钟

【本章主要内容】

  • 类与对象的概念和关系
  • 类的成员之------属性、方法、构造器

一、面向对象与面向过程(了解)

在开始学习 Java 的核心——面向对象编程(OOP)之前,我们首先需要理解它与面向过程编程(POP)的根本区别。

1. 两种编程思想的对比

  • 【面向过程】 (Procedure Oriented Programming, POP)

    • 核心:“过程” 为中心。
    • 思维方式: 思考“如何一步步解决问题”,然后将这些步骤封装成函数,并依次调用。这种思想更侧重于 “怎么做” ,如同一个按部就班的执行者。
    • 适用场景: 解决规模较小或更接近底层的性能攸关问题。
    • 缺点: 代码扩展能力较弱,后期维护难度相对较大。
  • 【面向对象】 (Object Oriented Programming, OOP)

    • 核心:“对象” 为中心。
    • 思维方式: 思考“需要哪些对象来协同完成任务”,然后创建这些对象,并调用它们各自的功能来解决问题。这种思想更侧重于 “谁来做” ,如同一位统筹全局的指挥者。
    • 适用场景: 解决中大型、业务逻辑复杂的项目。
    • 优点: 代码可复用性、扩展性和可维护性更高。

结论: 两种思想并无高下之分,而是相辅相成的。在面向对象的宏观设计下,对象的具体方法内部依然是由面向过程的代码来实现的。选择哪种思想,取决于问题的性质和规模。

2. 一个形象的例子:人把大象装进冰箱

  • 面向过程的实现思路:

    1. 执行“打开冰箱”这个动作。
    2. 执行“把大象塞进去”这个动作。
    3. 执行“关闭冰箱门”这个动作。
  • 面向对象的实现思路: 在这个场景中,我们识别出三个核心对象:人、大象、冰箱。它们各自拥有自己的功能(方法):

    • 人 (Person) 对象:

      • 开(冰箱)​ 方法
      • 操作(大象)​ 方法
      • 关(冰箱)​ 方法
    • 冰箱 (Refrigerator) 对象:

      • 被打开()​ 方法
      • 被关闭()​ 方法
    • 大象 (Elephant) 对象:

      • 进入(冰箱)​ 方法

    整个过程变成了对象之间的交互:人调用冰箱的“打开”方法,然后调用大象的“进入”方法,最后再调用冰箱的“关闭”方法。

3. 面向对象学习主线

  1. 类及类的成员:

    • 重点: 属性、方法、构造器。
    • 熟悉: 代码块、内部类。
  2. 面向对象的核心特征:

    • 封装、继承、多态(以及抽象)。
  3. 其他关键字的使用:

    • this​、super​、static​、final​、package​、import​、abstract​、interface​ 等。

二、类与对象

类(Class) 和 对象(Object) 是面向对象的核心概念。

1. 概述

  • 类 (Class): 对一类具有相同特征和行为的事物的抽象描述。它是抽象的、概念性的定义,可以看作是创建对象的 “蓝图”“模板”
  • 对象 (Object): 该类事物的具体存在的个体。它是具体的、实际的,因此也称为类的 实例 (Instance)

总结: "万物皆对象"。我们通过抽象归纳,先定义“类”,再根据“类”这个蓝图,创造出具体的“对象”。

2. 类的声明与成员

一个类主要由属性、方法、构造器这三类成员构成。

类的声明语法:

[修饰符] class 类名 {
    // 0个或多个 属性(成员变量)
    // 0个或多个 构造器
    // 0个或多个 方法
}

说明:

修饰符 ​可以是 public​ 等,class​ 是定义类的关键字。习惯上,我们会按照 属性 -> 构造器 -> 方法​ 的顺序编写代码。

类的成员除了这 3 种,还有内部类代码块,这些在后续章节中学习。

  • ① 属性 (Attribute / Field): 描述对象的状态

    • 属性用于描述对象的数据特征,本质上是 成员变量,例如:人的姓名、年龄,商品的价格、颜色等。
    • 同一个类的所有对象都拥有相同的属性,但每个对象的属性值是独立的。
  • ② 方法 (Method): 描述对象的行为

    • 方法用于描述对象能执行的功能或动作,例如:人可以“吃饭”,计算器可以“求和”。
    • 调用对象的方法,就是让这个对象完成一个特定的功能。
  • ③ 构造器 (Constructor): 用于创建对象

    • 构造器的主要作用是创建并初始化对象。当使用 new​ 关键字时,就是在调用一个类的构造器。
    • 如果类中没有显式定义任何构造器,Java 编译器会自动提供一个无参数的默认构造器。

3. 对象的创建与使用

  • 创建对象 (类的实例化)

    • 语法:

      类名 对象名 = new 类名();
      
    • 理解 Person p1 = new Person();

      • Person​ 是一种引用数据类型,类似于 int​ 这样的基本数据类型。Java 语言把类当成一种自定义数据类型,可以使用类来声明变量,这种类型的变量统称为引用型变量。
      • p1​ 是一个引用型变量,它存储的不是对象本身,而是对象在内存中的 首地址(引用)
      • new Person()​ 是通过调用构造器在内存的堆区中创建了一个 Person​ 类的实例(对象)。
      • =​ 赋值操作,是把新创建对象的地址赋给了变量 p1​。
    • 匿名对象: 如果只写 new 类名()​ 而没有变量接收,这个对象就是匿名对象,通常只能使用一次。

  • 使用对象 (访问属性和方法)

    • 通过对象名和 .​ 操作符,可以访问其内部的属性和方法。

    • 语法:

      对象名.属性;    // 读取或修改属性值
      对象名.方法();  // 调用方法,执行功能
      

小结:面向对象编程三部曲

  1. 声明类并定义类的成员(属性、方法)。
  2. 创建类的对象(通过 new​ 关键字)。
  3. 通过对象调用其属性和方法,完成具体功能。
【声明类】
(步骤1:)
  修饰符 class 类名{
   0个或多个属性定义;
   0个或多个构造器定义;
   0个或多个方法定义;
   }
  }

【创建对象】
(步骤2:)
  类名 对象名 = new 类名();

【通过对象调用属性或方法】
(步骤3:)
  对象名.属性
  对象名.方法

4. Java 中的内存分配

HotSpot Java 虚拟机的架构图如下。其中我们主要关心的是运行时数据区部分(Runtime Data Area)。

image-20250529233210254

【说明】:

  • 堆(Heap)所有通过 new关键字创建的对象实例以及数组都存放在堆中。堆是垃圾回收(GC)的主要工作区域。

  • 栈(Stack) :是指虚拟机栈。虚拟机栈用于存储局部变量等。这包括基本数据类型的变量值,以及引用类型变量的地址值。方法执行时,会在栈中创建一块称为“栈帧”的区域,方法执行结束,栈帧自动销毁,局部变量也随之释放。

  • 方法区(Method Area) :这是一个逻辑上的区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

    在 HotSpot 虚拟机中,JDK 7 及之前,方法区由“永久代”(Permanent Generation)实现。从 JDK 8 开始,永久代被移除,取而代之的是元空间(Metaspace) ,它使用的是本地内存(Native Memory),而非虚拟机内存。这一变化解决了永久代常见的内存溢出问题。

5. 对象内存解析

举例:

// 类定义
class Person {
    String name;
    int age;
    boolean isMale;
}

// 测试代码
public class PersonTest {
    public static void main(String[] args) {
        // 1. 创建p1对象
        Person p1 = new Person();
        // 2. 为p1的属性赋值
        p1.name = "赵同学";
        p1.age = 20;
        p1.isMale = true;

        // 3. 创建p2对象
        Person p2 = new Person();
        // 4. 为p2的属性赋值
        p2.age = 10;

        // 5. 将p1的地址赋给p3
        Person p3 = p1;
        // 6. 通过p3修改属性
        p3.name = "郭同学";
    }
}

内存解析:

  • Person p1 = new Person();​:

    • 栈内存: 创建一个 Person​ 类型的引用变量 p1​。
    • 堆内存: 创建一个 Person​ 对象实例,该实例包含 name​、age​、isMale​ 三个属性(此时均为默认初始值,如 null​, 0​, false​)。
    • 赋值: 将堆中新对象的地址赋给栈中的 p1​。
  • p1.name = "赵同学";​ 等赋值操作:通过 p1​ 的地址找到堆中的对象,并对其内部的属性进行赋值。

  • Person p2 = new Person();​:同理,在堆中创建了一个新的 Person​ 对象,并由栈中的变量 p2​ 指向它。

  • Person p3 = p1;​:

    • 栈内存: 创建一个新的引用变量 p3​。
    • 赋值:p1​ 变量存储的地址值复制一份给 p3​。此时,p1​ 和 p3​ 指向了堆内存中同一个 Person对象
  • p3.name = "郭同学";​:通过 p3​ 的地址找到堆中的对象,将其 name​ 属性修改为 "郭同学"。因为 p1​ 和 p3​ 指向同一个对象,所以此时如果访问 p1.name​,其值也变成了 "郭同学"。

内存解析图:

image-20250529234001833

6. new对象 vs new数组

在 Java 中,new​ 关键字统一用于在堆内存中创建实例,但两者在语法、初始化和内存内容上有本质区别。

方面new​ 对象 (如 new Student()​)new​ 数组 (如 new int[5]​)
语法核心()​ 调用构造方法[]​ 指定长度
初始化两步:1. 默认零值 2. 执行构造方法一步:仅用默认零值填充所有元素
内存内容存储成员变量存储连续的元素 (值或引用)
  • new​ 一个对象是调用构造器,一步到位得到一个完整的实例。
  • new​ 一个数组是创建容器,得到一个预设大小、元素被填满默认值的线性空间。

关键提醒:

数组本身就是一种特殊的对象,因此它有属性(.length​)并存储在堆内存中。

对象数组(如 new Student[5]​)的容器里,存放的是 null​ 引用,需要再次 new​ 来创建每个学生对象。

三、类的成员之:属性 (Field / 成员变量)

属性定义了对象的状态和数据特征。

1. 核心概念与声明

  • 定义: 属性(Field​),也常被称为成员变量,是在类中、方法外声明的变量,用于描述该类对象所共有的数据特征。

  • 语法格式:

    [修饰符] 数据类型 属性名 [= 初始化值];
    
  • 关键点:

    • 位置: 必须在类体 {}​ 内部,但在任何方法体 ()​ 外部。
    • 修饰符:public​, private​, protected​, static​, final​ 等。
    • 数据类型: 可以是任何基本类型或引用类型。
    • 初始化: 可以显式赋值,若不赋值,系统会根据其数据类型赋予默认值。

2. 属性的分类与访问

属性根据是否被 static​ 修饰,分为两类:

  • 实例变量 (Instance Variable):

    • 定义: 不使用 static修饰的成员变量。
    • 特点: 每个对象实例都拥有自己的一套独立的实例变量。它们的值互不影响。例如,每个人的“姓名”和“年龄”都是独立的。
    • 内存: 随着对象的创建而在堆内存中分配空间。
    • 访问: 必须通过 对象名.实例变量名​ 的方式访问。
  • 静态变量 (Static Variable / 类变量):

    • 定义: 使用 static修饰的成员变量。
    • 特点: 该类所有对象共享同一个静态变量。任何一个对象修改了该变量的值,其他对象看到的值也会随之改变。例如,所有“中国人”对象的“国籍”都是“中国”。
    • 内存: 随着类的加载而在方法区中分配空间,且只分配一次。
    • 访问: 推荐使用 类名.静态变量名​ 进行访问,也可以通过 对象名.静态变量名​ 访问(但不推荐,会引起混淆)。

访问规则总结表:

变量类型在其他类中访问在本类的静态方法中访问在本类的非静态方法中访问
静态变量类名.静态变量直接访问直接访问
实例变量对象.实例变量无法直接访问直接访问

代码示例 (Chinese类):

class Chinese {
    // 静态变量(类变量)
    static String country = "中国";
    // 实例变量
    String name;
    int age;
}

public class TestChinese {
    public static void main(String[] args) {
        // 访问静态变量,推荐使用类名
        System.out.println("国籍: " + Chinese.country);

        // 创建对象
        Chinese c1 = new Chinese();
        c1.name = "张三";
        c1.age = 25;

        // 访问实例变量,必须使用对象
        System.out.println("姓名: " + c1.name + ", 年龄: " + c1.age);
    }
}

3. 属性的默认初始化值

如果成员变量在声明时未被显式赋值,系统会为其分配默认值。

成员变量类型默认初始值
byte​, short​, int​, long0
float​, double0.0
char空字符
booleanfalse
所有引用类型null

4. 成员变量 vs. 局部变量

  • 成员变量(属性):在方法体外,类体内声明的变量。
  • 局部变量:在方法体内部等位置声明的变量。

四、类的成员之 :方法 (Method)

方法定义了对象的行为和功能。

1. 方法的基础

  • 定义: 方法(Method),也称函数,是将一个独立的功能封装起来的代码块,可以被重复调用。

  • 好处: 提高代码的复用性,便于组织和维护。

  • 核心规则:

    • Java 中的方法必须定义在类中,不能独立存在。
    • 方法不调用,不执行。

2. 方法的解构:从声明到实现

一个完整的方法由方法头 (Method Header)方法体 (Method Body) 两部分组成。

标准语法结构:

// 方法头
[修饰符] 返回值类型 方法名([参数列表]) [throws 异常列表] 

// 方法体
{ 
    // 实现具体功能的代码逻辑...
    [return 返回值;]
} 

(1)方法头

定义了一个方法的“外部特征”。它包含了[修饰符]、返回值类型、方法名、([参数列表])和[throws 列表]。

  • [修饰符] (可选):

    定义方法的访问权限特殊行为

    • 访问控制修饰符: public​, protected​, private​, 缺省​ (不写),决定了该方法可以被哪些类访问。
    • 其他行为修饰符: static​ (静态)、final​ (最终)、abstract​ (抽象)、synchronized​ (同步)等。
  • 返回值类型 (必需):

    定义了方法执行完毕后,返回给调用者的数据类型。它是方法的“输出”。

    • void 如果方法执行后不返回任何数据,则使用 void​ 关键字。
    • 具体类型: 如果方法需要返回数据,则必须声明其具体类型(如 int​, String​, double[]​ 或自定义的类类型)。
  • 方法名 (必需):

    方法的唯一标识符。

    • 命名规范:遵循“小驼峰命名法”,且应“见名知意”,通常使用动词或动宾短语如 calculateSum()​, getUserName()​。
  • 参数列表 ([参数列表])(可选,但 ()必需):

    定义了方法执行时,需要从调用者那里接收的数据。它是方法的“输入”。

    • 格式: (数据类型1 形参名1, 数据类型2 形参名2, ...)​。
    • 即使方法不需要任何参数,也必须保留一对空的圆括号 () ​。
  • [throws 异常列表] (可选):

    • 声明该方法在执行过程中可能抛出的异常类型。这是 Java 异常处理机制的一部分,后续章节会详细学习。

(2)方法体

方法体是包裹在花括号 {}​ 中的代码,它是方法功能的具体实现。

  • 组成: 由一系列 Java 语句、变量声明、逻辑控制(if-else, for, while)以及对其他方法的调用等构成。

  • 核心关键字:return

    • 作用一 : return​ 语句会立即结束当前方法的执行。其后的任何代码都将无法到达,编译器会报错。
    • 作用二 : 如果方法的返回值类型不是 void​,方法体中必须包含 return​ 语句,并且 return​ 后面必须跟一个与声明的返回值类型相匹配(或兼容)的值。
    • void方法中: 可以使用 return;​(不带任何值)来提前退出方法。如果省略,方法会在执行完最后一行代码后自动结束。

示例解析:

// 方法头:公开的、静态的、返回int类型、名为max、需要两个int参数
public static int max(int num1, int num2) {  
    
    // 方法体开始
    int result; // 局部变量声明
    if (num1 > num2) { // 逻辑控制
        result = num1;
    } else {
        result = num2;
    }
    return result; // 结束方法,并返回计算结果
    // 方法体结束
}

3. 方法的分类与调用

与属性类似,方法也根据 static​ 关键字分为两类:

  • 实例方法 (非静态方法):

    • 定义: 不使用 static修饰的方法。
    • 特点: 方法的执行与具体对象的状态(实例变量)相关。
    • 调用: 必须通过 对象名.实例方法() ​ 来调用。
  • 静态方法 (类方法):

    • 定义: 使用 static修饰的方法。又称为类方法。
    • 特点: 方法的执行与具体对象无关,通常用于实现工具类功能。静态方法内部不能直接访问实例变量和实例方法
    • 调用: 推荐使用 类名.静态方法() ​ 调用。

调用规则总结表:

方法类型在其他类中调用在本类的静态方法中调用在本类的非静态方法中调用
静态方法类名.静态方法()直接调用直接调用
实例方法对象.实例方法()无法直接调用直接调用

方法调用的内存分析:

  • 方法代码存储在方法区
  • 当方法被调用时,会在内存中创建一个栈帧 (Stack Frame) ,用于存储该方法的局部变量、操作数等信息(入栈)。
  • 方法执行完毕后,其对应的栈帧会从栈中销毁(出栈)。遵循“先进后出,后进先出”的原则。

4 . 方法的参数传递机制:值传递

Java 中方法的参数传递机制只有一种:值传递。这意味着传递给方法的是实参值的副本

形参 :定义方法时声明的参数。

实参 :调用方法时传入的具体值。

  1. 当参数是 基本数据类型时:

    传递的是数据值的副本。方法内部对形参的修改,不会影响到外部的实参。

    //声明测试类Test
    public class Test {
        //声明交换方法
        public static void swap(int a, int b) { //基本数据类型的形参
            int temp = a;
            a = b;
            b = temp;
        }
    
        //主方法
        public static void main(String[] args) {
            int x = 1;
            int y = 2;
    
            System.out.println("调用方法前,x=" + x + ",y=" + y); // x=1, y=2
            swap(x, y); //实参
            System.out.println("调用方法后,x=" + x + ",y=" + y); // x=1, y=2
        }
    }
    /*
    运行结果:
    调用方法前,x=1,y=2
    调用方法后,x=1,y=2
    */
    
  2. 当参数是 引用数据类型时:

    传递的是地址值(引用)的副本。这意味着形参和实参现在指向堆内存中的同一个对象。

    可以理解为,方法获得了一把指向堆内存中同一个对象的“备用钥匙”。

    基于此,会产生两种截然不同的情况:

    • 情况一:通过形参修改对象的状态 (属性)

      结论: 外部对象被修改。

      原理: 因为形参(备用钥匙)和实参(原始钥匙)指向的是同一个对象(房子) 。在方法内部通过形参修改对象的属性,等同于用备用钥匙进屋改变了家具的摆设,原始钥匙的持有者回来时,看到的是改变后的状态。

      代码示例:

      //声明MyData类
      class MyData {
          int a;
      }
      
      //声明测试类Test1
      public class Test1 {
          //声明change方法
          public static void change(MyData myData) { //引用数据类型形参
              myData.a *= 2;
          }
      
          //主方法
          public static void main(String[] args) {
              MyData my = new MyData();
              my.a = 1;
      
              System.out.println("调用前: my.a = " + my.a); // 输出: 1
              change(my);
              System.out.println("调用后: my.a = " + my.a); // 输出: 2 (对象内容被修改)
          }
      }
      
    • 情况二:让形参指向一个新对象

      结论: 外部对象不会受任何影响。

      原理: 在方法内部执行 new 操作,相当于让形参(备用钥匙)不再指向原来的对象,而是指向了一个全新的对象(新房子)。后续所有操作都是针对这个新对象进行的,与原来的对象(以及持有原始钥匙的实参)再无任何关系。

      代码示例:

      // MyData类同上
      
      public class Test2 {
          public static void change(MyData myData) { // myData是地址副本
              // 关键一步:形参myData指向了一个新对象
              myData = new MyData(); 
              myData.a = 10; // 修改的是这个新对象的属性
          }
      
          public static void main(String[] args) {
              MyData my = new MyData();
              my.a = 1;
      
              System.out.println("调用前: my.a = " + my.a); // 输出: 1
              change(my);
              System.out.println("调用后: my.a = " + my.a); // 输出: 1 (原始对象未受影响)
          }
      }
      

5 . 方法的重载 (Overload)

(1)什么是方法重载

  • 定义:同一个类中,允许存在一个或多个同名的方法,只要它们的参数列表不同即可。满足这样特征的多个方法,彼此之间构成方法的重载
  • 注意: 方法重载与返回值类型、修饰符、形参名无关
  • 小结:“两同一不同” —— 同一个类​,同名方法​,参数列表不同​(参数个数、类型、顺序至少有一个不同)。

方法签名 (Method Signature) 是方法头的一个子集,它在 Java 中有一个非常精确的定义:仅包含方法名和参数类型列表。例如,max(int, double)就是一个方法签名。

方法的签名是区分方法是否构成重载(Overload)的唯一依据。 返回值类型、修饰符、形参名和 throws子句都不属于方法签名的一部分。

(2)为什么需要重载

编译器会根据调用时传入的实参类型和数量,自动匹配最合适的方法版本,方便了调用者。

示例代码:

public class MathTools {
    // 重载方法 1: 比较两个整数
    public static int max(int a, int b) {
        // 这条打印语句用于追踪哪个具体的方法被调用
        System.out.println("方法 int max(int a, int b) 被调用");
        return a > b ? a : b;
    }

    // 重载方法 2: 比较两个浮点数 (参数类型不同)
    public static double max(double a, double b) {
        System.out.println("方法 double max(double a, double b) 被调用");
        return a > b ? a : b;
    }

    // 重载方法 3: 比较三个整数 (参数个数不同)
    public static int max(int a, int b, int c) {
        System.out.println("方法 int max(int a, int b, int c) 被调用");
        // 在重载方法内部可以调用其他重载版本,实现代码复用
        return max(max(a, b), c);
    }
}

// 测试类
class TestMathTools {
    public static void main(String[] args) {
        // 场景1: 精确匹配
        System.out.println("3, 5 之间较大的是:" + MathTools.max(3, 5));
        
        // 场景2: 精确匹配
        System.out.println("3.0, 5.0 之间较大的是:" + MathTools.max(3.0, 5.0));

        // 场景3: 精确匹配
        System.out.println("3, 5, 7 之间较大的是:" + MathTools.max(3, 5, 7));
        
        // 场景4: 自动类型提升
        System.out.println("3, 5.0 之间较大的是:" + MathTools.max(3, 5.0));
        
        // 场景5: 匹配失败 (此行代码会编译报错)
        // System.out.println("3, 5.0, 7 之间较大的是:" + MathTools.max(3, 5.0, 7));
    }
}

案例解析:编译器的决策过程

编译器在处理方法调用时,会拿着实参列表去和类中所有同名方法进行匹配,遵循以下原则:

  • ① 精确匹配: MathTools.max(3, 5)

    • 实参列表是 (int, int)​。
    • 编译器找到了一个方法签名完全匹配的 max(int a, int b)​,直接调用。
  • ② 自动类型提升 (兼容匹配): MathTools.max(3, 5.0)

    • 实参列表是 (int, double)​。
    • 编译器没有找到精确匹配的 max(int, double)​ 方法。
    • 它会尝试将参数进行自动类型提升(从小范围类型到大范围类型),看能否找到兼容的匹配项。
    • int​ 类型的 3​ 可以被安全地提升为 double​ 类型的 3.0​。
    • 提升后的参数列表变为 (double, double)​,成功匹配到 max(double a, double b)​ 方法,并进行调用。
  • ③ 方法的嵌套调用: MathTools.max(3, 5, 7)

    • 该调用精确匹配 max(int a, int b, int c)​。
    • 值得注意的是,其方法体内 return max(max(a, b), c);​ 展示了重载方法间的相互调用,这是一种非常好的代码复用实践。
  • ④ 匹配失败: MathTools.max(3, 5.0, 7)

    • 实参列表是 (int, double, int)​。

    • 编译器开始寻找匹配项:

      1. 精确匹配: 没有 max(int, double, int)​ 方法。
      2. 兼容匹配: 编译器尝试进行类型提升。它可以将两个 int​ 提升为 double​,构成 (double, double, double)​。但类中并没有提供 max(double, double, double)​ 方法。
      3. 由于无法找到任何一个可以匹配的方法签名,编译器最终放弃并报告一个编译时错误 (compile-time error)

结论: 方法的重载为 Java 提供了强大的编译时多态能力。编译器会基于一套严格的规则(精确匹配优先,然后是兼容匹配)来决定最终调用哪个方法版本,这使得代码既灵活又安全。

6 . 特殊方法参数

方法的特殊参数包括命令行参数和可变参数,特殊参数在进行数据传递时有特殊的意义。

(1)命令行参数:

命令行参数主要指 main 方法中的 String[ ](字符串数组),main 方法的声明如下所示:

public static void main(String[] args)
  • public​: 访问修饰符,表示该方法是公共的,可以被 JVM(Java 虚拟机)从任何位置调用。
  • static​: 关键字,表示该方法是静态的,属于类本身。JVM 在启动程序时,无需创建类的实例即可直接调用 main ​方法。
  • void​: 返回值类型,表示 main ​方法执行完毕后不返回任何数据。
  • main​: 方法名,这是 JVM 寻找程序入口的规定名称。
  • String[] args​: 方法的参数列表,它是一个字符串数组,用于接收和存储所有传递进来的命令行参数。

【示例代码】

public class TestCommandParam {
    public static void main(String[] args) {
        System.out.println("main方法参数args数组的长度为:" + args.length);
        for (int i = 0; i < args.length; i++) {
            System.out.println("第" + (i + 1) + "个参数值:" + args[i]);
        }
    }
}
情况一:不传递任何参数

如果你直接运行程序而不附加任何参数:

java TestCommandParam

运行结果:

main方法接收到的参数个数为: 0

说明: 在没有传递任何参数的情况下,args不是 null​,而是一个长度为 0 的空数组 (new String[0]​)。这是一个重要的细节,可以避免空指针异常。

情况二:通过命令行传递参数

在命令行中,在主类名后用空格隔开多个参数值。

命令:

java TestCommandParam I Love Java

运行结果:

main方法接收到的参数个数为: 3
第1个参数值是:I
第2个参数值是:Love
第3个参数值是:Java

说明: JVM 将 "I", "Love", "Java" 这三个字符串依次放入 args​ 数组中,即 args[0]​ 为 "I",args[1]​ 为 "Love",args[2]​ 为 "Java"。

情况三:通过 IDE 传递参数(以 IntelliJ IDEA 为例)

在开发环境中,我们可以通过配置来模拟命令行传参。

  1. 打开 "Run/Debug Configurations" (运行/调试配置)。
  2. 找到 "Program arguments" (程序参数)输入框。
  3. 在输入框中填入参数,同样以空格分隔,例如 I Love Java​。

image-20250522205537985

直接运行程序,将会得到与命令行传参完全相同的结果。

实际意义

虽然在图形界面(GUI)或 Web 应用开发中,命令行参数不那么常用,但它在许多场景下依然至关重要:

  • 开发工具或脚本: 许多基于命令行的工具(如编译器、构建工具)都依赖它来接收指令和文件路径。
  • 服务器应用配置: 在启动服务器时,可以通过命令行参数传入端口号、配置文件路径等初始配置。
  • 简单的控制台程序: 它是控制台程序接收用户输入的最直接方式。

(2)可变参数

问题背景:当参数个数不确定时

在设计方法时,我们通常会预先定义好参数的类型和数量。但设想一个场景:我们需要编写一个方法来计算任意个数整数的和。

在 JDK 5 之前,最常见的解决方案是使用数组作为参数:

// 使用数组作为参数
public int sum(int[] numbers) {
    int total = 0;
    for (int num : numbers) {
        total += num;
    }
    return total;
}

// 调用时,必须先创建一个数组
int sumOfTwo = sum(new int[]{10, 20});
int sumOfFive = sum(new int[]{1, 2, 3, 4, 5});

这种方式虽然能解决问题,但调用起来显得有些繁琐——每次调用都必须手动创建一个数组实例。为了让代码更简洁、调用更灵活,Java 5 引入了一个优雅的解决方案:可变参数

可变参数的声明与使用

可变参数允许我们在调用方法时,传入任意数量的同类型参数(包括零个)。

声明语法格式:

修饰符 返回值类型 方法名(数据类型... 参数名) {
    // 方法体
}

这里的 ...​ (三个点)是可变参数的核心语法,它告诉编译器:这里可以接收零个或多个该类型的参数。

底层机制: 非常重要的一点是,可变参数本质上是一个语法糖 (Syntactic Sugar) 。在方法内部,可变参数 nums被当作一个数组来处理。你可以使用数组的一切操作,比如 nums.length​ 来获取参数个数,或者通过索引 nums[i]​ 来访问具体参数。

核心规则:

  1. 一个方法最多只能声明一个可变参数。

  2. 可变参数必须是方法参数列表中的最后一个参数。

    // 正确的声明:普通参数在前,可变参数在后
    public void process(String type, int... values) { ... }
    
    // 错误的声明:可变参数不在最后
    // public void process(int... values, String type) { ... } // 编译错误!
    
    
可变参数与方法重载
  • 可变参数可以和其它同名方法构成重载。

  • 一个重要的特例: 一个接受可变参数的方法 method(String... args)​ 和一个接受同类型数组的方法 method(String[] args)​ 是无法构成重载的。因为它们在编译后的方法签名是完全一样的,会导致“方法已定义”的编译错误。但它们在调用上,可变参数版本提供了更大的便利性:

    // 拥有可变参数方法:void show(String... items)
    show("Hello", "World"); // 调用非常自然
    
    // 拥有数组参数方法:void show(String[] items)
    show(new String[]{"Hello", "World"}); // 调用时必须手动创建数组实例
    
案例

需求 1:使用可变参数实现 n 个整数求和

public class MathUtils {
    // 使用可变参数定义sum方法
    public static int sum(int... nums) {
        System.out.println("接收到的参数个数: " + nums.length);
        int result = 0;
        // 在方法内部,nums就是一个int数组
        for (int num : nums) {
            result += num;
        }
        return result;
    }

    public static void main(String[] args) {
        // 灵活的调用方式
        System.out.println("求和结果:" + sum());           // 传入0个参数
        System.out.println("求和结果:" + sum(10));         // 传入1个参数
        System.out.println("求和结果:" + sum(10, 20, 30));  // 传入3个参数

        // 也可以直接传入一个数组,完全兼容
        System.out.println("求和结果:" + sum(new int[]{1, 2, 3, 4, 5}));
    }
}

分析: 如上所示,可变参数极大地提升了方法的调用便利性。调用者可以像传递普通参数一样,用逗号隔开多个值,而无需关心数组的创建。

需求 2:将多个字符串用指定分隔符拼接

这个例子展示了如何将普通参数与可变参数结合使用。

public class StringUtils {
    /**
     * 使用指定的分隔符连接多个字符串。
     * @param separator 分隔符 (普通参数)
     * @param parts     要连接的字符串 (可变参数)
     * @return 连接后的字符串
     */
    public static String join(char separator, String... parts) {
        if (parts.length == 0) {
            return "";
        }

        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < parts.length; i++) {
            sb.append(parts[i]);
            if (i < parts.length - 1) {
                sb.append(separator);
            }
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        String csv = join(',', "apple", "banana", "orange");
        System.out.println(csv); // 输出: apple,banana,orange

        String single = join(';', "hello");
        System.out.println(single); // 输出: hello
    }
}

分析: 在调用 join(',', "apple", "banana", "orange")​ 时:

  • 第一个参数 ','​ 被赋给普通参数 separator​。
  • 后续所有参数 "apple"​, "banana"​, "orange"​ 被编译器自动打包成一个字符串数组,并赋给可变参数 parts​。

7 . 方法的递归调用

(1)核心概念:方法调用自己

通常,我们会在一个方法中调用另一个方法。而递归 (Recursion) 是一种强大的编程技巧,它指的是一个方法在自己的方法体内直接或间接地调用其自身

从概念上讲,递归是将一个复杂的问题,分解为一系列规模更小但结构相同的子问题来解决的策略。本质上,递归是一种通过方法调用实现的隐式循环

(2)构成递归的两个基本要素

  1. 递归步骤 : 定义了如何将当前问题分解为一个或多个更小的、同类型子问题,并调用自身来解决这些子问题。这通常表现为一个数学公式,例如 factorial(n) = n * factorial(n-1)​。
  2. 终止条件 : 定义了递归的“出口”。这是一个或多个最简单、可以直接求解的场景,当满足这些条件时,方法将不再调用自身,而是直接返回一个确定的结果。这是防止无限递归、保证程序能够正常结束的关键

(3)错误的递归:无限循环与栈溢出

如果一个递归方法只有递归步骤而没有终止条件,将会发生什么?

示例代码:一个没有出口的递归

public class TestError {
    public static void main(String[] args) {
        method();
    }

    public static void method(){
        System.out.println("method方法被调用");
        method();
    }
}

运行结果与内存分析:

  • 解释: 这段代码会迅速抛出 java.lang.StackOverflowError​ 异常,即栈内存溢出错误
  • 原理: 每当一个方法被调用时,JVM 都会在调用栈上为其分配一块独立的内存空间,称为栈帧,用于存储该方法的局部变量等信息(这个过程称为“入栈”)。只有当方法执行完毕后,其对应的栈帧才会被销毁(“出栈”)。
  • 在上述例子中,method()​ 不断地调用自己,导致栈帧持续地“入栈”而从不“出栈”。调用栈的容量是有限的,当栈被占满后,就发生了栈溢出。

(4)正确的递归:斐波那契数列

让我们来看一个正确实现递归的经典例子:计算斐波那契数列。

f(0) = 1

f(1) = 1 ...

f(n) = f(n-2) + f(n-1)

public class Fibonacci {
    public static long fibonacci(int n) {
        // 1. 终止条件
        // 当问题规模小到n<=1时,直接返回结果,不再递归
        if (n <= 1) {
            return 1;
        }
        // 2. 递归步骤
        // 将计算F(n)的问题,分解为计算F(n-1)和F(n-2)两个子问题
        else {
            return fibonacci(n - 1) + fibonacci(n - 2);
        }
    }

    public static void main(String[] args) {
        int number = 10;
        System.out.println("斐波那契数列的第 " + number + " 项是: " + fibonacci(number));
    }
}

(5)总结

递归是一把双刃剑,在使用前应充分理解其优缺点。

  • 优点:

    • 代码简洁: 对于某些问题(如树的遍历、分治算法),递归的实现方式在逻辑上非常清晰、代码极其简洁优雅。
  • 缺点:

    • 性能开销大: 每一次递归调用都会创建新的栈帧,这带来了额外的时间和空间开销。当递归深度很大时,性能会远低于使用循环的迭代实现。
    • 内存消耗: 递归深度与内存消耗成正比,容易导致栈内存溢出。

核心建议: 在追求高性能或处理大规模数据的场景下,应优先考虑使用循环迭代。递归更适合作为一种解决复杂问题的思维模型和编写逻辑清晰代码的工具。

五、类的成员之构造器 Constructor

1、为什么需要构造器?

当我们使用 new​ 关键字创建一个对象时,例如 Person p = new Person();​,我们实际上是在请求 JVM 为我们构建一个 Person ​类型的实例。创建之后,这个新对象的成员变量(属性)会被赋予其数据类型的默认值(如 int​ 为 0​,引用类型为 null​)。

然而,一个刚刚诞生、所有属性都是默认值的“空”对象,在很多业务场景下是无意义的。我们通常希望对象在被创建的那一刻,就拥有明确的、有意义的初始状态。如果每次都通过 p.name = "Tom"; p.age = 25;​ 这样逐一赋值,会非常繁琐且容易遗漏。

为了解决这个问题,Java 提供了一种特殊的“方法”——构造器 (Constructor) ,也常被称为构造方法。它的核心使命就是: new关键字创建对象的过程中被自动调用,以完成对新对象的初始化工作。

2、构造器的两大核心作用

  1. 创建对象: 它是 new​ 关键字能够实例化一个对象的底层机制。new Person()​ 这句代码实际上就是在调用 Person ​类的构造器。
  2. 初始化对象: 在对象被创建的同时,为其成员变量赋上初始值,让对象“生而有意义”。

3、语法格式

[修饰符] class 类名{
 [修饰符] 构造器名/类名 ([参数列表]) [throws 异常列表]{
  语句;
 }
}

从上述语法格式可以看出,构造起的声明与方法的声明非常类似,所以构造器也被称为构造方法,也是被调用时才会执行,不调用时不执行。

注意:

  1. 名称必须与类名完全一致: 这是编译器识别构造器的首要依据。
  2. 没有任何返回值类型: 这一点至关重要, void都不写。这是构造器与普通方法在语法上的最大区别。
  3. 构造器的修饰符只能是 public​、protected​、缺省​、private​,不能有其他修饰符

4、默认构造器

Java 的设计原则是:任何类都必须有构造器

  • 当你不提供任何构造器时: Java 编译器会提供一个公开的 (public)、无参数的构造器,我们称之为默认构造器 (Default Constructor) 。这就是为什么即使我们编写一个空类,也能够成功执行 new MyClass();​ 的原因。
  • 当你提供了任何一个构造器时: 编译器将不再提供默认的无参构造器。如果你定义了一个有参构造器,但还想保留无参创建对象的能力,就必须手动地、显式地将无参构造器也定义出来

5、构造器的使用

【案例】

class Person {
    String name;
    int age;

    // 当提供了有参构造器时,默认的无参构造器就不会自动生成了
    public Person() {
    }

    // 自定义有参构造器
    public Person(String n, int a) {
        name = n;
        age = a;
    }

    public String getInfo() {
        return "名字是:" + name + ",年龄是:" + age;
    }
}

public class TestConstructor {
    public static void main(String[] args) {
        // 调用有参构造器创建对象,无需p.name = "Tom"; p.age = 25;逐一赋值
        Person p = new Person("Tom", 30);
        System.out.println(p.getInfo());
    }
}
/*
运行结果:
名字是:Tom,年龄是:30
*/

【注意】

在实际开发中,为了代码更具可读性,我们通常会将参数名成员变量名起成一样的,并使用 this 关键字来区分(this 关键字后续学习):

class Person {
    String name;
    int age;

    // 这是更常见的写法
    public Person(String name, int age) {
        // this.name 指的是对象的成员变量name
        // 右边的 name 指的是参数name
        this.name = name; 
        this.age = age;
    }
    
    public String getInfo(){
        return "名字是:" + name + ",年龄是:" + age;
    }
}