分类课程智能体AI
文章
订阅
分类课程AI导师
文章
价格
课程进度
5 / 10
上一节Java 循环结构下一节Java类和对象
自在学

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1

编程JavaJava方法(函数)

Java 方法:把一段流程拆成可靠、可复用的能力

程序刚开始变长时,最常见的写法是把读取输入、计算、判断和输出全部塞进 main。它当然可以运行,但任何一处规则发生变化,都要在一大段代码里寻找落点。方法的价值,就是把这条长流程切成一组边界清楚的小能力:每个方法接收明确的输入,完成一件可描述的事,再返回结果或产生约定好的效果。

这一章不把方法只当作“语法模板”。我们会沿着一次真实调用的全过程,理解声明、实参与形参、按值传递、局部作用域、调用栈、返回值、重载、可变参数、数组与对象参数、递归、包和标准 API;最后再把这些机制收束为一套可以直接用于设计、调试和测试的方法写法。

代码约定:带 public class 的代码块可以单独保存并运行;其余片段用于聚焦当前机制。示例优先使用 static 方法,让注意力集中在方法本身。实例方法会在学习类与对象时进一步展开。

本文按 Java SE 26 的正式语言规则讲解。所有参数传递都是按值传递;“传对象”并不会切换成另一套传递机制,它复制的是引用这个值。


先把方法看成“输入—契约—输出”

假设收银程序要计算订单应付金额。把所有规则直接堆在 main 中,短期看似省事,后来却很难回答几个简单问题:折扣从哪里开始算?负金额由谁拒绝?同一规则能否给网页端和批处理程序复用?测试时能否只验证计算,而不模拟键盘输入?

把计算提取成方法后,边界立刻清楚起来:

java
public class CheckoutDemo {
    public static void main(String[] args) {
        double payable = calculatePayable(260.0, true);
        System.out.printf("应付:%.2f 元%n", payable);
    }
 
    static double calculatePayable(double amount, boolean member) {
        if (amount < 0) {
            throw new IllegalArgumentException("订单金额不能为负数");
        }
 
        double discountRate = member ? 0.90 : 1.00;
        return amount * discountRate;
    }
}

从调用者的角度看,calculatePayable 像一个黑盒:交给它订单金额和会员状态,就得到应付金额。调用者不必知道折扣率存在哪个局部变量中,但必须知道这份契约:

  • 输入是什么:amount 表示非负订单金额,member 表示是否为会员。
  • 输出是什么:返回折扣后的金额。
  • 非法输入怎样处理:金额为负时抛出 IllegalArgumentException。
  • 是否有额外影响:这个方法不修改外部数据,也不负责打印。

“黑盒”不是不关心实现质量,而是把接口和实现分开思考。接口回答“如何正确使用”,实现回答“内部如何完成”。只要契约不变,内部算法可以优化,调用方不需要跟着改。

方法声明的五个部分

先观察声明首行:

java
static double calculatePayable(double amount, boolean member) {
部分当前示例作用
修饰符static说明它是类方法,可以通过类名调用
返回类型double约束正常完成时交回的值
方法名calculatePayable用动词短语表达能力
形参列表double amount, boolean member声明调用所需输入及其类型
方法体{ ... }保存校验、计算和 return

Java 方法契约结构图:输入、声明五部分、返回结果与异常边界

方法声明不只是一行语法:它把输入、返回类型、能力名称、实现边界和异常规则连接成调用者可以依赖的契约。

声明首行和方法体共同定义了能力。只记住括号和大括号还不够;真正值得先写下来的,是一句可验证的话,例如:

给定非负订单金额和会员状态,返回应用会员折扣后的金额;负金额被拒绝。

这句话会反过来约束方法的名字、参数、返回类型和测试。

用方法做逐步求精

一项需求往往不能一次拆到最细。可以先写出高层步骤,再继续拆分最复杂的步骤:

text
生成结算结果
├── 校验订单
├── 计算商品小计
├── 计算折扣
├── 计算运费
└── 格式化结果

好的拆分不是“每三行提取一个方法”,而是让每个方法都对应一个稳定概念。若一个方法同时读取文件、计算价格、修改库存并发送通知,它就拥有四个变化原因;任何一条规则调整,都可能牵动整段实现。相反,calculateDiscount 这样的边界容易命名、复用和单独测试。

判断是否值得提取方法,可以问三个问题:这段逻辑能否用一句动词短语命名?是否会被重复使用?是否值得脱离周边流程单独测试?任意一个答案为“是”,通常就有清晰的提取理由。

小节测试

5-1
对于只负责计算会员折扣的方法,哪一种契约最清楚?
5-2
下面哪些现象说明一段逻辑适合提取为独立方法?
5-3
只要方法的契约保持不变,调用者通常不需要知道方法体采用了哪一种等价算法。

声明、调用与 return 组成一次完整往返

方法不会因为被声明就自动执行。声明只是把能力放进类中;运行到方法调用表达式时,Java 才会计算实参、进入方法体,最后把控制权交还给调用位置。

从调用表达式进入方法体

java
double fee = ShippingRules.shippingFee(128.0, true);

这行代码包含四个连续动作:

  1. 确定要调用 ShippingRules 类中的 shippingFee。
  2. 从左到右计算实参表达式 128.0 和 true。
  3. 用得到的值初始化本次调用新建的形参。
  4. 执行方法体;遇到正常 return 后,把返回值交给调用表达式,再继续赋值。

同一个类中的静态方法可以直接写方法名;不同类中的静态方法,优先写成 类名.方法名(...),让能力的归属一眼可见:

java
public class ShippingRules {
    public static double shippingFee(double amount, boolean member) {
        if (amount < 0) {
            throw new IllegalArgumentException("金额不能为负数");
        }
        if (member || amount >= 199) {
            return 0.0;
        }
        return 12.0;
    }
}
java
public class ShippingApp {
    public static void main(String[] args) {
        double fee = ShippingRules.shippingFee(128.0, true);
        System.out.println("运费:" + fee);
    }
}

有返回值和 void 方法

返回类型不是装饰。它决定调用表达式能否产生值:

java
static int square(int value) {
    return value * value;
}
 
static void printDivider() {
    System.out.println("----------------");
}

square(6) 的结果可以赋值、参与计算或作为另一个调用的实参。执行 printDivider() 只产生约定好的输出,不能写成 int x = printDivider();。

void 方法也可以使用不带表达式的 return; 提前结束:

java
static void printPositive(int value) {
    if (value <= 0) {
        return;
    }
    System.out.println(value);
}

非 void 方法则必须保证每一条能够正常走到方法末尾的路径都返回兼容类型的值。下面的写法无法通过编译,因为 score < 0 时没有返回值:

java
static String level(int score) {
    if (score >= 90) {
        return "优秀";
    }
    if (score >= 0) {
        return "继续加油";
    }
    // 缺少对负分的处理
}

更好的版本把非法输入先挡在门外,再覆盖所有合法路径:

java
static String level(int score) {
    if (score < 0 || score > 100) {
        throw new IllegalArgumentException("分数必须在 0 到 100 之间");
    }
    if (score >= 90) {
        return "优秀";
    }
    return "继续加油";
}

这类开头就结束异常路径的判断叫守卫子句。它减少嵌套,并让后续代码在“输入已经合法”的前提下阅读。

return 会立即结束当前这一次方法调用。写在它后面且永远无法到达的语句会造成编译错误。它只返回一层调用者,不会直接结束整个程序;除非返回的正是 main,并且其他非守护线程也已结束。

小节测试

5-4
执行 double fee = ShippingRules.shippingFee(128.0, true); 时,形参在什么时候得到值?
5-5
关于 return,哪些说法正确?
5-6
void 方法调用可以直接赋给 int 变量,因为它会隐式返回 0。

参数进入方法时,复制的是值

声明中的参数叫形参,调用时括号里的表达式叫实参:

java
static double addTax(double price, double rate) { // price、rate 是形参
    return price * (1 + rate);
}
 
double total = addTax(80 + 20, 0.06);             // 两个表达式是实参

实参不必是变量,它可以是字面量、算术表达式或另一个方法调用。Java 从左到右计算它们,然后把结果分别复制给新的形参变量。形参只属于这一次调用;下一次调用会得到另一组形参。

改形参不会改掉调用者的基本类型变量

下面的 increase 改变的是形参 value,不是 main 中的 score:

java
public class PrimitiveParameterDemo {
    public static void main(String[] args) {
        int score = 70;
        increase(score);
        System.out.println(score); // 70
    }
 
    static void increase(int value) {
        value += 10;
        System.out.println(value); // 80
    }
}

可以把调用瞬间想成下面两只独立的盒子:

text
调用者 score: 70
                 │ 复制数值 70
                 ▼
形参   value: 70  → 方法内部改成 80

value 改成 80 后,score 那只盒子仍是 70。因此,试图用下面的方法交换两个基本类型变量不会成功:

java
static void swap(int left, int right) {
    int temp = left;
    left = right;
    right = temp;
}

调用 swap(a, b) 只交换两份副本。若需要把计算结果交还调用者,最直接的方式是 return:

java
static int increasedByTen(int value) {
    return value + 10;
}
 
score = increasedByTen(score);

参数顺序也是接口的一部分

两个相同类型的参数尤其容易被传反:

java
static boolean isWithin(int value, int min, int max) {
    return value >= min && value <= max;
}
 
boolean valid = isWithin(75, 0, 100);

方法名和参数名应该让顺序容易判断。若参数很多、布尔参数语义不清,继续增加位置参数会让调用变成猜谜:

java
createReport(true, false, true, 20, 3);

此时应考虑把相关配置收拢为有名字的对象,或拆成含义更具体的方法。Java 不提供“形参默认值”语法,也不会根据形参名匹配实参;位置和类型必须符合方法声明。

不要在多个实参中塞入彼此依赖的自增、赋值等副作用。Java 虽然规定从左到右求值,但 savePair(nextId(), nextId()) 仍会让读者额外模拟状态。先把中间值算清楚,再调用方法更容易检查。

小节测试

5-7
调用 addTax(80 + 20, 0.06) 时,第一个形参 price 得到什么?
5-8
哪些做法能把方法结果明确交还给调用者?
5-9
Java 会根据实参变量名与形参名自动配对,所以实参位置不重要。

调用栈让每一次调用拥有自己的局部世界

一个方法可以调用另一个方法,同一个方法也可以被反复调用。为了避免不同调用的形参和局部变量互相覆盖,运行时会为每次调用保存独立的执行状态。理解时可以把它看成一个栈帧:里面记录本次调用的参数、局部变量、计算中的临时状态以及返回位置。

java
public class CallStackDemo {
    public static void main(String[] args) {
        double result = checkout(120.0);
        System.out.println(result);
    }
 
    static double checkout(double amount) {
        double tax = calculateTax(amount);
        return amount + tax;
    }
 
    static double calculateTax(double amount) {
        double rate = 0.06;
        return amount * rate;
    }
}

执行到最深处时,可以这样阅读:

text
栈顶  calculateTax: amount=120.0, rate=0.06
      checkout:     amount=120.0, 等待 tax
栈底  main:         等待 result

calculateTax 返回 7.2 后,它的帧结束;checkout 用这个值初始化 tax,算出 127.2 后返回;最后 main 恢复执行。即使三个方法都把参数命名为 amount,它们也属于不同调用帧。

Java 方法调用栈:main、checkout 与 calculateTax 的独立栈帧

调用向上压入新的栈帧,返回则弹出当前帧并恢复上一层;同名形参也分别保存在各自的调用中。

下面的交互实验把这条调用链拆成八步。逐步推进时,同时观察高亮代码与右侧栈帧:新调用怎样压栈、局部变量保存在哪里、返回值又怎样交回等待中的调用者。

作用域决定“名字在哪里可见”

生命周期描述变量何时存在,作用域描述源码中哪里可以用这个名字。常见规则是:

  • 方法形参的作用域覆盖整个方法体。
  • 块内局部变量从声明处开始,到所在块的右大括号结束。
  • for 初始化部分声明的变量,只在该 for 的初始化、条件、更新和循环体中可见。
  • 不同方法可以放心使用同名局部变量,因为作用域彼此分离。
java
static int countPassing(int[] scores) {
    int count = 0;
 
    for (int score : scores) {
        if (score >= 60) {
            String mark = "及格";
            count++;
            System.out.println(score + ":" + mark);
        }
        // mark 在这里不可见
    }
 
    return count;
    // score 在这里也不可见
}

Java 不允许在作用域重叠时再次声明同名局部变量或形参。字段则可以被局部变量或形参遮蔽,此时应显式说明要访问哪一个字段;但比起到处依赖字段,更好的第一选择通常是用参数输入、用返回值输出。

“全局变量”会隐藏依赖

类的静态字段能被多个静态方法访问,有时会被口头称为全局变量:

java
static int totalRequests = 0;
 
static void recordRequest() {
    totalRequests++;
}

这种共享状态有合法用途,但它把输入和输出藏在方法签名之外。调用 recordRequest() 看不出它会修改什么,并发调用还需要同步策略。若一个值只服务于一次计算,就让它留在局部变量中;若方法需要外部数据,就优先通过参数明确传入。

局部变量不会像字段那样自动获得默认值。读取局部变量前,编译器必须能证明当前路径已经为它赋值。把变量声明得离首次使用更近,通常更容易满足这条规则,也更容易读。

小节测试

5-10
calculateTax 尚未返回时,checkout 中的局部变量为什么不会丢失?
5-11
关于局部作用域,哪些说法正确?
5-12
静态字段能被多个方法访问,所以把所有中间结果都存成静态字段会让依赖关系更清楚。

重载与可变参数解决“同一概念,不同输入”

同一个类中可以存在多个同名方法,只要它们的参数列表足以区分。这叫方法重载:

java
static String formatAmount(int yuan) {
    return yuan + " 元";
}
 
static String formatAmount(double yuan) {
    return String.format("%.2f 元", yuan);
}
 
static String formatAmount(double yuan, String currency) {
    return String.format("%.2f %s", yuan, currency);
}

调用 formatAmount(20) 时选择 int 版本,formatAmount(20.5) 选择 double 版本。编译器根据方法名、实参数量、类型以及允许的方法调用转换寻找适用候选。

返回类型不能单独构成重载

方法签名的核心是方法名和形参类型序列。下面两个声明不能同时存在:

java
static int parse(String text) {
    return Integer.parseInt(text);
}
 
static double parse(String text) { // 编译错误:签名与上一个方法相同
    return Double.parseDouble(text);
}

调用 parse("42") 时,Java 不能靠接收变量类型猜应该使用哪一个返回类型。若语义确实不同,应使用 parseInt、parseDouble 这样更具体的名字。

编译器分阶段寻找匹配

可以用三个阶段建立实用心智模型:

  1. 先找固定参数个数、无需装箱或拆箱即可调用的方法;基本类型拓宽也属于这一阶段。
  2. 找不到时,再允许装箱与拆箱等较宽松转换。
  3. 仍找不到时,最后才考虑可变参数方法。

每一阶段找到候选后,还要从中选择最具体的方法;如果没有唯一最具体者,调用就是二义性的。

java
static void pick(long value) {
    System.out.println("long");
}
 
static void pick(Integer value) {
    System.out.println("Integer");
}
 
pick(7); // 输出 long:基本类型拓宽在允许装箱的阶段之前

另一个经典边界是 null:

java
static void show(String value) { }
static void show(Integer value) { }
 
// show(null); // 编译错误:String 和 Integer 互不比对方更具体

重载最好表达同一概念的自然变体。若两个版本的副作用、异常规则或含义完全不同,仅仅因为“名字能复用”而重载,调用者反而更难预测。

可变参数在方法体里就是数组

当同类参数数量确实不固定,可以把最后一个形参声明为 类型...:

java
static double average(double... values) {
    if (values.length == 0) {
        throw new IllegalArgumentException("至少需要一个数值");
    }
 
    double sum = 0;
    for (double value : values) {
        sum += value;
    }
    return sum / values.length;
}

调用方式可以很自然:

java
double a = average(80, 90, 100);
double b = average(72.5, 88.0);
double[] scores = {60, 75, 90};
double c = average(scores);

方法体中的 values 是一个 double[]。调用者既可以给出零个或多个独立实参,也可以直接传入兼容数组。一个方法最多有一个可变参数,而且它必须位于参数列表最后,否则编译器无法判断后续实参应归给谁。

固定参数版本通常比可变参数版本更早被考虑:

java
static void log(String message) { }
static void log(String... messages) { }
 
log("启动"); // 选择固定参数版本

重载和可变参数组合得太密集时,很容易出现二义性,尤其是 null、装箱类型和多个可变参数候选同时出现的场景。公开 API 应优先追求调用点清楚,而不是展示尽可能多的重载技巧。

下面的实验把“编译器选中了哪个重载”拆成可以观察的筛选过程。先预测四个调用的结果,再逐阶段查看固定参数、装箱或拆箱、可变参数以及最具体候选如何影响结论。

小节测试

5-13
同时存在 pick(long) 与 pick(Integer) 时,调用 pick(7) 会优先选择哪个?
5-14
关于 double... values,哪些说法正确?
5-15
只改变返回类型,就能在同一个类中声明两个同名同参数的方法。

数组与对象参数复制的仍然是引用值

数组和对象没有改变“按值传递”规则。差别在于:基本类型变量保存数值本身,而引用类型变量保存一个指向对象的引用值。调用方法时,Java 复制这份引用,于是调用者变量与形参最初指向同一个对象。

java
public class ArrayParameterDemo {
    public static void main(String[] args) {
        int[] scores = {60, 70, 80};
 
        changeFirst(scores);
        System.out.println(scores[0]); // 100
 
        replace(scores);
        System.out.println(scores[0]); // 仍是 100
    }
 
    static void changeFirst(int[] values) {
        values[0] = 100;
    }
 
    static void replace(int[] values) {
        values = new int[] {1, 2, 3};
    }
}

两种行为要严格区分:

  • values[0] = 100 通过复制来的引用找到同一个数组,并修改数组内容,所以调用者能观察到变化。
  • values = new int[] {...} 只让形参改指向新数组,没有改写调用者的 scores 变量,所以方法返回后 scores 仍指向原数组。

对象参数完全相同。方法可以通过引用调用对象的方法、修改可变对象状态,但给形参重新赋引用不会让调用者变量改指向。

Java 按值传递:基本类型复制数值,引用类型复制引用值

两边遵守的是同一条规则:形参得到值的副本。区别只在于,引用值的副本仍能找到同一个可变对象。

明确选择“修改原对象”还是“返回新结果”

下面两个方法都能把分数加一,但契约不同:

java
static void addBonusInPlace(int[] scores) {
    for (int i = 0; i < scores.length; i++) {
        scores[i]++;
    }
}
 
static int[] withBonus(int[] scores) {
    int[] result = java.util.Arrays.copyOf(scores, scores.length);
    for (int i = 0; i < result.length; i++) {
        result[i]++;
    }
    return result;
}

addBonusInPlace 原地修改输入,适合调用者明确授权修改的场景;withBonus 返回副本,调用前后的原数组保持不变,更容易局部推理。方法名、注释和测试应说明选择了哪一种,而不是让调用者靠猜。

若方法要长期保存传入数组,也要考虑调用者之后仍能修改它。可以在边界处使用 Arrays.copyOf 防御性复制。复制一段连续数组时,System.arraycopy 也很高效,但源下标、目标下标和长度必须合法。

null 不是空数组

java
static int sum(int[] values) {
    if (values == null) {
        throw new IllegalArgumentException("values 不能为 null");
    }
 
    int total = 0;
    for (int value : values) {
        total += value;
    }
    return total;
}

null 表示没有数组对象,new int[0] 表示存在一个长度为零的数组。二者的业务含义可能不同。与其让空指针在方法深处偶然出现,不如在边界处明确接受、转换或拒绝 null。

看到“Java 对象参数是引用传递”时要立刻纠正:复制引用值不等于复制对象,也不等于把调用者变量本身交给方法。判断结果时分别追踪“形参指向哪里”和“被指向对象的内容是否改变”。

小节测试

5-16
方法内执行 values = new int[] {1, 2, 3}; 为什么不会让调用者数组变量改指向?
5-17
调用 change(int[] values) 后,哪些操作可能被调用者观察到?
5-18
new int[0] 与 null 完全相同,因为二者都没有元素。

递归要同时具备出口与收敛过程

方法可以直接或间接调用自身,这叫递归。递归适合描述“把问题缩小成同类子问题”的结构,例如目录树、表达式树、二分查找和分治算法。它不是一种神秘循环,而是许多次彼此独立的方法调用。

计算非负整数阶乘可以写成:

java
public class FactorialDemo {
    public static void main(String[] args) {
        System.out.println(factorial(5)); // 120
    }
 
    static long factorial(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("n 不能为负数");
        }
        if (n <= 1) {
            return 1;
        }
        return n * factorial(n - 1);
    }
}

正确递归至少需要三个要素:

  1. 基本情况:n <= 1 时直接返回,不再递归。
  2. 规模缩小:factorial(n - 1) 让参数持续靠近基本情况。
  3. 结果组合:当前层把 n 与子问题结果相乘。

factorial(4) 展开时,每层都有自己的 n:

text
factorial(4)
└── 4 * factorial(3)
        └── 3 * factorial(2)
                └── 2 * factorial(1)
                        └── 1

返回阶段再逐层合并为 2、6、24。这也解释了为什么递归能工作:上一层调用没有消失,它的栈帧正在等待下一层结果。

Java 阶乘递归:问题规模下降、基本情况与逐层返回

递归有两条同样重要的生命线:向下时问题规模必须持续缩小,触及基本情况后才能向上逐层组合结果。

二分查找更能体现“缩小同类问题”。它要求数组已经按升序排列,每次只保留一半区间:

java
static int binarySearch(int[] sorted, int target) {
    if (sorted == null) {
        throw new IllegalArgumentException("数组不能为 null");
    }
    return binarySearch(sorted, target, 0, sorted.length - 1);
}
 
private static int binarySearch(
        int[] sorted, int target, int low, int high) {
    if (low > high) {
        return -1;
    }
 
    int middle = low + (high - low) / 2;
    if (sorted[middle] == target) {
        return middle;
    }
    if (target < sorted[middle]) {
        return binarySearch(sorted, target, low, middle - 1);
    }
    return binarySearch(sorted, target, middle + 1, high);
}

low > high 表示候选区间为空,是基本情况;其余路径都把区间缩小到左半或右半。若数组没有预先升序排列,方法可能正常返回却给出错误结论,因此“数组已排序”也是契约中的前置条件。

出口存在不等于一定能到达

下面虽然写了基本情况,参数却朝错误方向移动:

java
static int brokenCountdown(int n) {
    if (n == 0) {
        return 0;
    }
    return brokenCountdown(n + 1);
}

对正数调用时,n 离 0 越来越远。调用不断创建新栈帧,最终通常以 StackOverflowError 结束。设计递归时,不只要问“出口是什么”,还要给出一个可度量的规模,并证明每一步都严格缩小。

递归与循环怎样选择

阶乘也能写成循环:

java
static long factorialLoop(int n) {
    if (n < 0) {
        throw new IllegalArgumentException("n 不能为负数");
    }
 
    long result = 1;
    for (int value = 2; value <= n; value++) {
        result *= value;
    }
    return result;
}

当问题本身是线性重复,循环通常更省调用开销,也没有深调用栈风险;当数据天然嵌套或算法本身分治,递归往往更贴近问题结构。无论选择哪一种,都要测试最小输入、典型输入、非法输入和可能触及容量上限的输入。

还要区分两个上限:调用层数受栈空间限制,long 阶乘还会很快数值溢出。递归正确并不意味着返回类型拥有无限容量。

StackOverflowError 通常说明调用层次过深或递归不收敛,不应把它当作普通业务分支捕获后继续。先检查基本情况、参数变化方向和输入规模,再决定是否改为循环或显式数据结构。

小节测试

5-19
一个递归方法即使写了基本情况,仍可能无限递归的主要原因是什么?
5-20
检查递归设计时,应确认哪些事项?
5-21
递归的每一层都共享同一组形参,所以修改 n 会同步改变所有调用层。

静态方法、包与标准 API 共同组织能力

static 方法属于类,不依赖某个特定实例:

java
double root = Math.sqrt(81.0);
int position = java.util.Arrays.binarySearch(new int[] {2, 5, 8}, 5);

Math.sqrt 和 Arrays.binarySearch 都通过类名调用。静态上下文没有“当前对象”,因此不能直接使用 this、super 或未指定实例的实例字段与实例方法。

访问修饰符控制谁能调用

常见方法声明会组合访问级别和 static:

  • public:在满足模块与包可见性等条件时,可供其他类调用。
  • 不写访问修饰符:只对同一个包中的代码开放。
  • protected:同包可访问,并为继承关系提供额外访问规则。
  • private:只在声明它的顶层类或相关嵌套范围内使用,适合隐藏实现步骤。

对外只暴露稳定能力,把辅助步骤留为 private,能减少调用者依赖:

java
public class PriceRules {
    public static double finalPrice(double amount, boolean member) {
        requireNonNegative(amount);
        return member ? amount * 0.9 : amount;
    }
 
    private static void requireNonNegative(double amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("金额不能为负数");
        }
    }
}

package 和 import 解决命名与归类

包把相关类型组织到命名空间中。包声明必须位于源文件开头附近:

java
package com.example.checkout;
 
import java.util.Arrays;
 
public class ScoreTools {
    public static int highest(int[] scores) {
        if (scores == null || scores.length == 0) {
            throw new IllegalArgumentException("至少需要一个分数");
        }
 
        int[] copy = Arrays.copyOf(scores, scores.length);
        Arrays.sort(copy);
        return copy[copy.length - 1];
    }
}

import java.util.Arrays; 只是让当前编译单元可以用简单名 Arrays,它不会“加载全部代码”,也不会把方法复制进当前类。java.lang 中的常用类型会被自动导入,所以 String、Math 通常不需要显式 import。

静态导入能省去类名:

java
import static java.lang.Math.sqrt;
 
double distance = sqrt(81.0);

少量、含义明确时可以使用;如果同一文件静态导入许多不同类的方法,调用来源会变得不清楚。教学和业务代码中保留 Math.sqrt 往往更易读。

阅读 API 文档先看方法签名

遇到标准库方法,不要只凭名字猜行为。阅读顺序可以固定为:

  1. 方法是否为 static,应该通过类还是对象调用。
  2. 每个参数的类型、含义、允许范围和 null 规则。
  3. 返回类型以及特殊返回值。
  4. 可能抛出的异常。
  5. 方法是否修改传入对象,以及边界情况如何处理。

例如 Arrays.sort(array) 会原地修改数组;若要保留原顺序,应先复制。又如 Arrays.binarySearch 要求待搜索范围已经按兼容顺序排序,找不到时的返回值也不只是固定的 -1。正确调用 API 的前提,是读完契约而不是只看到方法名。

main(String[] args) 本身也是方法:public 允许启动器访问,static 表示不必先创建对象,void 表示不向启动器返回 Java 值,String[] args 接收命令行参数。它并不是方法语法之外的特殊例外。

优先复用经过验证的标准 API,例如 Math 的数值方法、Arrays 的排序与查找。复用不是少写几行这么简单,它还能让代码使用共同术语,并把大量边界处理交给已有契约。

小节测试

5-22
import java.util.Arrays; 的直接作用是什么?
5-23
调用一个不熟悉的标准 API 方法前,应该核对哪些契约信息?
5-24
静态方法属于类,因此可以在没有具体对象的情况下直接使用 this。

用设计、测试与 Javadoc 把方法做完整

语法正确的方法不一定容易维护。一个成熟的方法还需要清楚的名字、稳定的契约、合理的职责、可预测的异常,以及覆盖边界的测试。可以把设计过程固定为六步:

  1. 用一句话写清方法完成什么,不要同时出现多个无关动词。
  2. 列出调用者必须提供的信息,决定参数类型与顺序。
  3. 决定正常结果是返回值还是明确副作用。
  4. 写下合法输入范围,以及非法输入如何失败。
  5. 先用几个典型与边界例子验证契约,再实现方法体。
  6. 实现后检查命名、复杂度、重复代码和文档是否仍与行为一致。

一个完整的拆分示例

下面的程序根据订单金额、会员状态和目的地区域计算报价。顶层方法像目录,细节交给更小的方法:

java
public class ShippingQuoteApp {
    public static void main(String[] args) {
        double quote = quote(268.0, true, "REMOTE");
        System.out.printf("最终应付:%.2f 元%n", quote);
    }
 
    /**
     * 计算订单折扣后再加运费的最终应付金额。
     *
     * @param amount 商品原始金额,必须大于或等于 0
     * @param member 是否为会员
     * @param zone 配送区域,只接受 LOCAL 或 REMOTE
     * @return 最终应付金额
     * @throws IllegalArgumentException 当金额或配送区域不合法时
     */
    public static double quote(double amount, boolean member, String zone) {
        requireNonNegative(amount);
        String normalizedZone = normalizeZone(zone);
 
        double discounted = applyDiscount(amount, member);
        double shipping = shippingFee(discounted, normalizedZone);
        return discounted + shipping;
    }
 
    private static void requireNonNegative(double amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("金额不能为负数");
        }
    }
 
    private static String normalizeZone(String zone) {
        if (zone == null) {
            throw new IllegalArgumentException("配送区域不能为 null");
        }
 
        String normalized = zone.trim().toUpperCase(java.util.Locale.ROOT);
        if (!normalized.equals("LOCAL") && !normalized.equals("REMOTE")) {
            throw new IllegalArgumentException("未知配送区域:" + zone);
        }
        return normalized;
    }
 
    private static double applyDiscount(double amount, boolean member) {
        return member ? amount * 0.90 : amount;
    }
 
    private static double shippingFee(double discounted, String zone) {
        if (discounted >= 199.0) {
            return 0.0;
        }
        return zone.equals("LOCAL") ? 8.0 : 18.0;
    }
}

quote 负责组织步骤,而不是埋进所有细节。每个辅助方法都有窄而稳定的职责:

  • requireNonNegative 只校验金额。
  • normalizeZone 负责处理空值、空白、大小写和允许集合。
  • applyDiscount 只计算折扣。
  • shippingFee 只根据折后金额和区域计算运费。

这种结构让测试可以直接瞄准规则。例如金额恰好为 199.0 时是否免运费,应该用方法调用验证,而不需要模拟整套输入输出。

前置条件、后置条件与异常

前置条件描述调用前必须成立的事实,例如“金额非负”;后置条件描述正常返回后保证成立的事实,例如“返回值等于折后金额加运费,且不会修改输入字符串”。

有三种常见处理方式:

  • 方法内部校验并抛出清楚的异常,适合公共边界或错误代价高的场景。
  • 调用者先保证条件,方法文档明确要求,适合受控内部热点代码。
  • 改变接口,让非法状态更难表达,例如用专门类型代替含义混杂的字符串。

不要用一个模糊的 return -1 同时表示“输入非法”“没有结果”和“真实计算结果”。返回值与异常各自应有单一、可读的含义。

Javadoc 记录调用者需要知道的事

传统 Javadoc 注释紧贴在声明前,以 /** 开始。方法文档应该优先写契约,而不是逐行翻译实现:

  • 第一段用一句话概括方法作用。
  • 每个 @param 说明参数含义、单位、范围和 null 规则。
  • 非 void 方法用 @return 说明返回值及特殊结果。
  • 用 @throws 说明异常在什么条件下发生。

如果实现从循环改成公式,文档通常不必变化;如果参数允许范围或副作用变了,文档和测试就必须同步更新。注释与行为冲突时,过期注释比没有注释更危险。

测试方法要覆盖契约边界

对 quote,至少应该覆盖:

类别示例目的
典型值100, false, LOCAL验证普通运费
阈值前折后 198.99验证仍收运费
阈值点折后 199.00验证开始免运费
非法金额-0.01验证异常
文本归一化" remote "验证空白与大小写
缺失区域null验证明确拒绝

调试失败调用时,先读异常类型和消息,再从栈轨迹最靠近自己代码的位置开始。栈轨迹中的一串方法名正是调用链:它告诉你错误在哪一层被发现、由哪一层调用而来。

完成一个方法后做最后检查:名字能否概括一件事;参数是否最少且顺序自然;返回值与副作用是否明确;所有正常路径是否返回;非法输入是否一致处理;数组或对象是否被意外修改;重载是否可能二义;递归是否收敛;文档和测试是否覆盖边界。

小节测试

5-25
Javadoc 中 @param 最值得记录哪类信息?
5-26
为免运费阈值 199.00 设计测试时,哪些输入最能检查边界?
5-27
只要方法可以运行,就可以让文档长期保留已经失效的参数规则,因为代码才是最终依据。
  • 先把方法看成“输入—契约—输出”
    • 方法声明的五个部分
    • 用方法做逐步求精
    • 小节测试
  • 声明、调用与 return 组成一次完整往返
    • 从调用表达式进入方法体
    • 有返回值和 void 方法
    • 小节测试
  • 参数进入方法时,复制的是值
    • 改形参不会改掉调用者的基本类型变量
    • 参数顺序也是接口的一部分
    • 小节测试
  • 调用栈让每一次调用拥有自己的局部世界
    • 作用域决定“名字在哪里可见”
    • “全局变量”会隐藏依赖
    • 小节测试
  • 重载与可变参数解决“同一概念,不同输入”
    • 返回类型不能单独构成重载
    • 编译器分阶段寻找匹配
    • 可变参数在方法体里就是数组
    • 小节测试
  • 数组与对象参数复制的仍然是引用值
    • 明确选择“修改原对象”还是“返回新结果”
    • null 不是空数组
    • 小节测试
  • 递归要同时具备出口与收敛过程
    • 出口存在不等于一定能到达
    • 递归与循环怎样选择
    • 小节测试
  • 静态方法、包与标准 API 共同组织能力
    • 访问修饰符控制谁能调用
    • package 和 import 解决命名与归类
    • 阅读 API 文档先看方法签名
    • 小节测试
  • 用设计、测试与 Javadoc 把方法做完整
    • 一个完整的拆分示例
    • 前置条件、后置条件与异常
    • Javadoc 记录调用者需要知道的事
    • 测试方法要覆盖契约边界
    • 小节测试

目录

  • 先把方法看成“输入—契约—输出”
    • 方法声明的五个部分
    • 用方法做逐步求精
    • 小节测试
  • 声明、调用与 return 组成一次完整往返
    • 从调用表达式进入方法体
    • 有返回值和 void 方法
    • 小节测试
  • 参数进入方法时,复制的是值
    • 改形参不会改掉调用者的基本类型变量
    • 参数顺序也是接口的一部分
    • 小节测试
  • 调用栈让每一次调用拥有自己的局部世界
    • 作用域决定“名字在哪里可见”
    • “全局变量”会隐藏依赖
    • 小节测试
  • 重载与可变参数解决“同一概念,不同输入”
    • 返回类型不能单独构成重载
    • 编译器分阶段寻找匹配
    • 可变参数在方法体里就是数组
    • 小节测试
  • 数组与对象参数复制的仍然是引用值
    • 明确选择“修改原对象”还是“返回新结果”
    • null 不是空数组
    • 小节测试
  • 递归要同时具备出口与收敛过程
    • 出口存在不等于一定能到达
    • 递归与循环怎样选择
    • 小节测试
  • 静态方法、包与标准 API 共同组织能力
    • 访问修饰符控制谁能调用
    • package 和 import 解决命名与归类
    • 阅读 API 文档先看方法签名
    • 小节测试
  • 用设计、测试与 Javadoc 把方法做完整
    • 一个完整的拆分示例
    • 前置条件、后置条件与异常
    • Javadoc 记录调用者需要知道的事
    • 测试方法要覆盖契约边界
    • 小节测试