☰
Java基础进阶:接口、内部类、数组排序与成绩处理实战
2026/10/15 1:42:10 网站建设 项目流程

1. 从一段学生成绩处理代码说起:为什么要把这些基础串起来

我见过不少刚学完Java基础的人,代码能看懂,例题也会做,但一拿到一个稍微完整一点的任务——比如"把班里学生成绩排个序、过滤一下、输出前几名"——就不知道从哪下手了。问题往往不是语法没学会,而是这些知识点在脑子里是一个个孤岛:类名作参数是一个题,内部类是一个题,Arrays.sort是一个题,冒泡排序又是一个题,彼此之间没有任何连接。

其实这几块内容放在同一天讲,是有内在逻辑的。类名、抽象类名、接口名作形参和返回值,解决的是"怎么把一块逻辑交给别人去执行";内部类解决的是"临时凑一个实现类出来用";Arrays和冒泡排序解决的是"怎么对一堆数据排序";Math、System、toString()解决的是"处理数据时的常用工具和规范"。如果你把它们拼到一起,恰好就能写出一个结构还算像样的成绩处理小模块。

这篇文章我就按这个思路来讲:先拆开每个知识点,再在最后用一份成绩单把它们串起来。文章不追求讲得多深,但求把"为什么这么设计"以及"实际项目里到底怎么用"说透。

2. 把类型当参数传递:类名、抽象类名、接口名背后的多态思维

2.1 类名作形参:传递的是引用,而不是复制品

先看最朴素的写法。定义一个学生类:

public class Student { private String name; private int score; public Student(String name, int score) { this.name = name; this.score = score; } public String getName() { return name; } public int getScore() { return score; } public void showInfo() { System.out.println(name + " 的成绩是 " + score); } }

然后写一个方法,把一个Student对象传进去打印:

public static void printStudent(Student s) { s.showInfo(); }

这里最关键的一点是:Student s这个形参,保存的是对象的引用,而不是把整个对象复制一份。你在方法里修改了s指向的那个对象的内容,外面的对象也跟着变。很多初学者会把引用和对象搞混,我习惯打一个比方:引用就是门牌号,对象就是房子。你把门牌号传给别人,别人顺着门牌号进去把家具搬走了,你的房子自然就空了。

这也引出一个很重要的工程习惯:如果你不想让方法内部对外面的对象产生副作用,就要在方法内部复制一份再操作。比如:

public static void printStudentSafe(Student s) { Student temp = new Student(s.getName(), s.getScore()); temp.showInfo(); }

单看类名作参数,确实很简单,但它是后面抽象类和接口的基础——你要先理解"传的是引用、不是复制品",后面才能真正理解"传的是哪种引用、能接收哪些对象"。

2.2 抽象类名作形参:从"某种类"升级为"某类东西"

现在需求变了。你不只想打印一个学生,还想打印"所有能展示信息的对象"。假设你有一个抽象类:

public abstract class InfoPrinter { public abstract void print(); }

然后让Student继承它,实现print()方法。这时方法就可以写成:

public static void printAny(InfoPrinter p) { p.print(); }

这样做最大的好处是:调用方可以传入任何一个Student的子类。只要继承了InfoPrinter,不管以后你怎么增加新的子类,这个方法都不用改。这就是"向上转型"的运用——形参类型是父类,实参类型是子类,方法内部只关心抽象方法怎么被实现,不关心具体是哪个子类。

实际项目里,这种模式最常见的体现就是模板方法模式的一角:抽象类里定义好流程骨架,留出一两个抽象方法让子类去实现,然后你写一个接收抽象类参数的方法,统一调度流程。比如一个数据导入器,先读文件、再校验、再入库,其中校验规则是抽象的,不同业务传入不同的校验器子类就行。

2.3 接口名作形参和返回值:面向接口编程的落地点

抽象类还有个限制:Java是单继承,一个类只能有一个父类。所以更灵活的方案是接口。接口名作形参,意味着只要实现了某个接口的任何类,都能传进来,完全解耦了"调用方"和"具体实现类"。

public interface ScoreFilter { boolean pass(Student s); } public static void printPassed(ScoreFilter filter, Student[] students) { for (Student s : students) { if (filter.pass(s)) { System.out.println(s.getName() + " 通过"); } } }

传入的实参可以是一个根据"及格线"过滤的实现类,也可以是一个按"优秀线"过滤的实现类,printPassed方法本身不必修改。很多框架底层就是靠这个思想把扩展点开放的,比如某种数据库的驱动接口、某种消息队列的消费者接口,全都是"你传接口实现进来,框架帮你干活"。

返回值同理。方法声明返回ScoreFilter,实际return的可以是任何一个实现类对象:

public static ScoreFilter getFilter(int threshold) { if (threshold >= 90) { return new ExcellentFilter(); } else { return new CommonFilter(); } }

调用方拿着ScoreFilter类型的引用去调方法,不用关心具体拿到的是哪个实现类——这个"不关心"就是多态带来的灵活。

我在实际项目里还经常用接口名作返回值来做"工厂方法":一个方法根据参数决定返回哪个实现类,调用方只依赖接口编程。这样以后想再增加一种实现,只需要新增一个类,调用方代码完全可以不动。

2.4 什么时候用类名、抽象类名还是接口名

直接给你一个选择依据:

形参/返回值类型使用场景灵活度
具体类名你确实只需要接收这一个类,或者这个类本身就是最终实现最低
抽象类名多个子类有公共代码,需要继承来复用,形参只关心抽象方法中等
接口名需要最大解耦,调用方只依赖行为契约,与具体实现无关最高

我个人的经验是一句话:能用接口就不用抽象类,能用抽象类就不用具体类。虽然具体类作参数写起来最省事,但一旦第二天产品说"再支持一种新的方式",你就得回头改方法签名,而改动接口或抽象类的签名,所有调用方都得跟着改。提前设计成接口形参,新增实现类时方法一行都不用改。这是我在实际项目里踩过的坑,希望你别再踩一遍。

3. 内部类:为什么Java要把一个类放进另一个类里

3.1 成员内部类:类里的类,共享外部类的自有资源

内部类,字面意思就是定义在另一个类内部的类。比如:

public class School { private String schoolName = "某中学"; public class Student { public void showSchool() { System.out.println("来自 " + schoolName); } } }

成员内部类最大的特点是:它是外部类的一个实例成员,所以它天然持有外部类对象的引用,可以直接访问外部类的私有字段。这里Student里的showSchool()能直接用schoolName,就是因为内部类对象内部藏了一个指向外部类对象的引用,语法上叫OuterClass.this。

这个特性在实际项目里用处不小。比如在某个服务类内部需要定义一套回调对象,这套对象就是要紧密访问服务类的状态和配置,你把它设计成成员内部类,就不用像普通类那样费劲地把状态通过构造方法传进传出。

不过也有一个必须注意的地方:不能直接new一个非静态内部类对象,得先有外部类对象:

School school = new School(); School.Student stu = school.new Student();

这个语法很怪,很多初学者在这卡住。所以我在实际开发里更喜欢用静态内部类——它不持有外部类引用,形态上更像普通类,只是在命名空间上属于外部类。

3.2 局部内部类:方法里临时造出来的类

局部内部类是定义在方法内部的类,它的生命周期只在这个方法范围里。比如:

public void printNames(String[] names) { class NamePrinter { public void printAll() { for (String name : names) { System.out.println(name); } } } new NamePrinter().printAll(); }

局部内部类有一个很经典的限制:在Java 8之前,它访问方法的局部变量时,该变量必须是final的。Java 8以后放宽为"effectively final",也就是变量值只要没被重新赋值,就可以直接访问。

原理也很简单:局部变量的生命周期是跟着方法走的,方法一结束,局部变量栈帧就没了,但局部内部类的对象可能还被外部引用持有,它还活着。Java为了让这个对象能继续访问局部变量,实际上做了一层隐式拷贝。为了不让拷贝值和原值出现不一致,干脆强制这个变量不允许再被修改。包装类和基本类型靠这个"值不得变"的约定来保证一致性。

我第一次接触这个概念时觉得挺玄乎,后来明白了"隐式拷贝"这个词就全通了。记住一句话:编译器在你面前放了块玻璃,看起来变量没变,其实是帮你在内部类里复制了一份,为了防止"原值变了但复制值没变"这种诡异情况,才要求不许重新赋值。

3.3 匿名内部类:最常用的"一次性用品"

匿名内部类是局部内部类的简写形式,适合"这个类只用一次,且代码量不大"的场景。比如上面那个ScoreFilter接口,你可以不新建任何类文件,直接现场造一个实现:

Student[] students = ...; printPassed(new ScoreFilter() { @Override public boolean pass(Student s) { return s.getScore() >= 60; } }, students);

这里面new ScoreFilter() {...}的语法,就是在创建匿名内部类对象的同时实现接口方法。代码里没有给这个类起名字,所以叫"匿名"。

这种写法在Java 8之前是无处不在的:启动一个线程要用new Thread(new Runnable() {...}),创建比较器要用new Comparator<T>() {...},各种回调接口都要靠它。我自己当年写Swing界面时,一个按钮的点击事件就必须靠匿名内部类来注册监听器:

button.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { System.out.println("按钮被点击了"); } });

它的核心价值在于:把"要执行的逻辑"和"当前这段代码"绑在同一个地方,让阅读代码的人不用跳来跳去。你不用再去创建一个单独的类文件,逻辑就地呈现,非常符合"就近原则"。

3.4 Lambda又是什么角色

现在你大概率会经常看到这种写法:

printPassed(s -> s.getScore() >= 60, students);

这就是Lambda表达式。它的前提是接口必须是函数式接口——也就是说接口里只有一个抽象方法。Lambda本质上是匿名内部类的语法简化版本,底层仍然会生成类似匿名内部类的实现。

那你可能想问:既然有了Lambda,还有必要学匿名内部类吗?我的看法是有必要,原因有三点:

  1. 不是所有接口都是函数式接口,比如有多个抽象方法的接口,Lambda就用不了,只能老老实实写匿名内部类。
  2. 很多老项目、旧框架的代码里还是大量使用匿名内部类,你不认识它就改动不了这些代码。
  3. 匿名内部类里可以使用this指向当前匿名类对象本身,而Lambda里的this指向的是外部类的对象,这个语义差异在排查问题时非常关键。

4. 四个高频API的真实用法:Math、System、toString()、Arrays

4.1 Math类:随机数、四舍五入和那个经典的开区间

Math类全是静态方法,直接用就行。我们在成绩系统里最常用的几个:

方法作用注意点
Math.abs(x)取绝对值对Integer.MIN_VALUE取绝对值会溢出,返回负数
Math.ceil(x)向上取整2.1 -> 3.0
Math.floor(x)向下取整2.9 -> 2.0
Math.round(x)四舍五入返回long或int,2.5 -> 3,-2.5 -> -2
Math.pow(a, b)求a的b次幂返回值是double
Math.sqrt(x)求平方根负数返回NaN
Math.random()随机数范围是[0.0, 1.0),左闭右开

Math.random()这个左闭右开要注意。如果你想生成60到100之间的随机整数,标准写法是:

int score = (int)(Math.random() * 41 + 60);

这里Math.random() * 41产生[0.0, 41.0)之间的数,加60得到[60.0, 101.0),再强转成int就是60到100的整数。很多人漏了后面的+60,导致分数永远从0开始,我见过不止一次这种bug。

另一个坑是Math.abs(Integer.MIN_VALUE)。因为int类型的范围是-2147483648到2147483647,绝对值最大只到2147483647,所以你给Integer.MIN_VALUE取绝对值,结果还是负的-2147483648。这个现象看着邪门,但只要记住"abs的结果不能超过int的表示范围"就不会被吓到。

4.2 System类:计时、拷贝数组和退出程序

System类里我们平时用得最多的就是currentTimeMillis()和arraycopy()。

currentTimeMillis()返回从1970年1月1日00:00:00 UTC到当前时刻的毫秒数。最经典的用途就是测性能:

long start = System.currentTimeMillis(); // 执行一段排序 long end = System.currentTimeMillis(); System.out.println("耗时 " + (end - start) + " ms");

这段代码在对比冒泡排序和Arrays.sort的性能时特别直观,我后面会用到。

arraycopy()则是高效拷贝数组的底层方法,它的原型是:

System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length)

参数分别是源数组、源数组起始位置、目标数组、目标数组起始位置、要拷贝的长度。比如把一个数组从索引2开始拷贝到另一个数组的索引0:

int[] src = {1, 2, 3, 4, 5}; int[] dest = new int[3]; System.arraycopy(src, 2, dest, 0, 3); // dest: [3, 4, 5]

这个方法我最初学的时候觉得烦,直到项目里要给一个消息队列的ring buffer腾挪数据、批量搬移数组片段时才意识到它的强大——底层走的是native方法,比你手写for循环要快不少。

4.3 重写toString():不重写会输出什么

每个Java类都继承自Object,Object里有个toString()方法,默认实现是这样的:

public String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); }

所以你不重写的话,直接System.out.println(student),控制台输出的是类似Student@1b6d3586的字符串。类名后面跟一串十六进制的哈希值,完全没有业务含义。

这就是为什么"打印一个对象"会出现这种鬼东西。你想要的是"这个学生的姓名和分数",默认给你的是"这个对象的身份标识"。

重写规范也很简单,我一般这么写:

@Override public String toString() { return "Student{name='" + name + "', score=" + score + "}"; }

后来项目里用JSON比较多,我习惯重写成JSON格式,这样在日志里能直接复制粘贴进行排查。所有自己定义的、需要出现在日志或界面的数据类,都应该重写toString()。这是我在团队里反复强调的规范,否则出错时打日志看到一堆@符号,排查效率极低。

4.4 Arrays工具类:排序、查找、拷贝一步到位

Arrays是操作数组的瑞士军刀,全都封装成静态方法了。我在这个阶段最推荐先掌握这几个:

Arrays.toString(int[] arr) // 把数组转成可读字符串 Arrays.sort(int[] arr) // 升序排序 Arrays.binarySearch(int[] arr, int key) // 二分查找 Arrays.copyOf(int[] arr, int newLength) // 拷贝并指定新长度 Arrays.fill(int[] arr, int val) // 用指定值填充数组

Arrays.sort()在Java 7以后对基本类型用的是双轴快速排序,对对象类型用的是TimSort,都是高效算法,比后面要讲的冒泡快得多。所以生产环境排序请优先用Arrays.sort(),冒泡排序的价值更多在于理解算法本身。

binarySearch有个坑:必须先在数组有序的前提下调用,否则结果不可靠。而且当数组里找不到目标值时,它返回的不是-1,而是-(插入点) - 1。这个负值的含义是"如果要把目标插入到数组中合适位置,应该插在哪"。比如:

int[] arr = {10, 20, 30, 40}; Arrays.binarySearch(arr, 25); // 返回 -3,表示应该插在索引2的位置

很多人写代码只判断返回值是否非负,忽略了"未找到时返回插入点"这个机制,其实这个机制在"找到比当前值大的第一个位置"这类需求中非常实用。

5. 冒泡排序:别只背代码,把两层循环吃透

5.1 冒泡的暴力写法为什么慢

冒泡排序的思路特别直白:从头开始,相邻两个元素两两比较,如果前一个比后一个大,就交换位置。每一轮下来,最大的那个数会像气泡一样"浮"到数组末尾。重复这个过程,直到所有元素都排好。

暴力版写法长这样:

public static void bubbleSort(int[] arr) { for (int i = 0; i < arr.length - 1; i++) { for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }

两层循环的分工:外层循环控制"整轮比较"执行多少次,内层循环控制"这一轮里相邻元素比较到哪个位置"。为什么内层循环的上限是arr.length - 1 - i?因为每完成一轮,末尾就会多一个已经就位的"最大数",下一轮不用再去碰它。

这段代码我会建议每一个初学者亲手走一遍。比如数组是{5, 1, 4, 2, 8},第一轮结束后变成{1, 4, 2, 5, 8},8就位;第二轮结束后变成{1, 2, 4, 5, 8},5就位;第三轮其实已经有序了,但暴力写法仍然会把剩余轮次全部跑完。这就是它慢的原因之一——即使已经有序,它也要机械地跑完所有比较。

时间复杂度最好情况是O(n²),最坏情况也是O(n²),平均也是O(n²)。空间复杂度O(1),因为它只需要一个临时变量做交换。

5.2 加一个标志位,已经有序就提前收工

优化冒泡排序有一个非常经典的技巧:加一个布尔标志,记录某一轮是否发生过交换。如果某一轮从头到尾没有任何交换发生,说明数组已经有序,直接结束排序。

public static void bubbleSortOptimized(int[] arr) { for (int i = 0; i < arr.length - 1; i++) { boolean swapped = false; for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; swapped = true; } } if (!swapped) { break; } } }

这样在最好情况下(数组本身就有序),第一轮扫描发现没有交换,立刻break,时间复杂度降为O(n)。这也是为什么很多面试官喜欢问"冒泡排序能不能优化"——答出标志位,能体现你有没有理解算法,而不只是背代码。

我还想补充一个更进阶但不常考的点:每一轮扫描可以同时记录最后一次发生交换的位置,下一轮只需要扫描到这个位置即可,因为该位置之后的元素已经有序。你可以理解为对标志位优化的进一步增强,不过初学阶段先把标志位版本写好就够了。

5.3 稳定性是个重要属性

排序算法的稳定性指:相等元素的相对顺序在排序前后是否保持一致。冒泡排序是稳定的,因为它的交换条件写的是arr[j] > arr[j + 1],即只有前一个严格大于后一个才交换。如果写成>=,相等元素也会交换,稳定性就破坏了。

为什么稳定性和我们这篇文章有关系?因为你的成绩表里可能有两个学生同分,如果只按分数排序,我们希望"先出现在数组里的那个学生在排序后仍然排在前面",这就是稳定排序的意义。如果算法不稳定,同分的两个人顺序可能对调,后续再做第二次排序时会破坏第一次排序的结果。

以成绩表为例:先按姓名排序,再按分数排序。如果第二次排序是稳定的,那么同分的学生内部仍然保持姓名顺序;如果不稳定,同分学生的姓名顺序就乱了。所以稳定性真不是书上一个无用的概念,做实际系统时会遇到。

5.4 为什么实际项目里几乎不用冒泡

一句话:太慢了。同样是排序,Arrays.sort()对几万个元素能在几毫秒内完成,冒泡排序可能要几十上百毫秒。这个差距在有性能要求的数据处理场景里是天壤之别。

那为什么还要学冒泡?我的看法是:它是最容易理解的排序算法,能帮你建立"排序到底在做什么"的直观感受。你先学会冒泡,知道怎么比较、怎么交换、怎么通过外层内层循环控制比较范围,再去学快速排序、归并排序,理解成本会低很多。算法学习讲究由易到难,冒泡就是这个序列的起点。

6. 用一个完整的例子收官:一份成绩单的完整处理流程

现在我们把这一天的知识点全部串起来,写一个贴近实际的小模块。需求是这样:有一个学生数组,我们要按成绩从高到低排出前两名,并输出结果,还要看一下排序耗时。

我用接口回调来设计过滤逻辑,用匿名内部类来写Comparator,用Arrays.sort完成排序,用System.currentTimeMillis计时,用Math处理成绩的小数位,最后用重写过的toString()打印。

第一步,定义学生类和接口:

public class Student { private String name; private double score; public Student(String name, double score) { this.name = name; this.score = score; } public String getName() { return name; } public double getScore() { return score; } @Override public String toString() { return "Student{name='" + name + "', score=" + score + "}"; } } public interface ScoreFilter { boolean pass(Student s); }

第二步,写一个接收接口形参的筛选方法:

public static void filterAndPrint(ScoreFilter filter, Student[] students) { for (Student s : students) { if (filter.pass(s)) { System.out.println(s); } } }

第三步,在主方法里组装逻辑:

public static void main(String[] args) { Student[] students = { new Student("张三", 87.4), new Student("李四", 92.6), new Student("王五", 76.0), new Student("赵六", 92.6) }; // 1. 用匿名内部类实现Comparator,按成绩降序,成绩相同则按姓名 Arrays.sort(students, new Comparator<Student>() { @Override public int compare(Student a, Student b) { int byScore = Double.compare(b.getScore(), a.getScore()); return byScore != 0 ? byScore : a.getName().compareTo(b.getName()); } }); // 2. 输出排序后结果 System.out.println("排序结果(从高到低):"); System.out.println(Arrays.toString(students)); // 3. 用接口名作形参的filterAndPrint筛选80分以上 System.out.println("80分以上的学生:"); filterAndPrint(new ScoreFilter() { @Override public boolean pass(Student s) { return s.getScore() >= 80; } }, students); // 4. 计时对比:Arrays.sort vs 自己写的冒泡 int[] scores = {87, 92, 76, 65, 88, 99, 70, 83, 91, 58}; int[] copy1 = Arrays.copyOf(scores, scores.length); int[] copy2 = Arrays.copyOf(scores, scores.length); long start1 = System.currentTimeMillis(); Arrays.sort(copy1); long end1 = System.currentTimeMillis(); long start2 = System.currentTimeMillis(); bubbleSortOptimized(copy2); long end2 = System.currentTimeMillis(); System.out.println("Arrays.sort 耗时:" + (end1 - start1) + " ms"); System.out.println("冒泡排序耗时:" + (end2 - start2) + " ms"); // 5. 用Math.round把成绩取整打印 System.out.println("取整后的前两名成绩:"); for (int i = 0; i < Math.min(2, students.length); i++) { System.out.println(students[i].getName() + ":" + Math.round(students[i].getScore())); } }

这段代码里,你能看到这篇文章讲的所有知识点同时在场:

  • Comparator<Student>用了匿名内部类,这是内部类最常见的实用场景。
  • Arrays.sort和Arrays.toString、Arrays.copyOf,是数组三大常用操作。
  • ScoreFilter以接口类型作方法形参,调用时传匿名内部类实现,是多态的典型应用。
  • System.currentTimeMillis()负责性能计时。
  • Math.round和Math.min处理数值计算。
  • 重写后的toString()让输出内容可读。

如果数据量从10个变成10万个,System.currentTimeMillis()测出来的差距会非常明显——Arrays.sort可能只需要几十毫秒,而冒泡排序要几秒甚至更久。你自己可以跑一跑,这个体验会比你背十遍"冒泡排序时间复杂度是O(n²)"更直观。

7. 最后聊点学习建议和我的个人体会

这一天的内容,表面上是五个互不相干的知识模块,实际上它们都服务于一个共同目标:让代码能够以灵活的方式组织和执行逻辑。接口做参数是为了解耦,匿名内部类是为了就地实现接口,Arrays.sort是为了高效排序,Math和System是辅助工具,toString则是让对象"会说人话"。我在面试实习生时,很少直接问"什么是多态",更多是给一个场景让候选人写代码,看TA会不会用接口名作参数、会不会用匿名内部类传Comparator。能写出来的人,说明基础是真的通了;写不出来的,往往只能背概念。

如果你现在正在学这一块,我给你三个建议:

第一,不要只读,一定亲手敲一遍。尤其是冒泡排序的两层循环和匿名内部类的语法,手敲三遍和读十遍的效果完全不同。我当年教过的一个朋友,就是靠把文中的完整例子敲了三遍,才真正从"看得懂"变成"写得出来"。

第二,把知识点串成一个项目。哪怕是最简单的"成绩管理"练习,也比单独刷十道练习题强。因为项目需要你同时考虑"接口怎么定义""排序怎么调用""输出怎么可读",这个综合思考的过程就是能力提升的过程。

第三,每个知识点都向自己问一个"这在实际开发哪里会用"。比如接口作形参在框架里怎么用,匿名内部类在事件回调里怎么用,Arrays.sort在面对真实数据时比冒泡好在哪里。想清楚了这些,学到的就不再是死知识,而是解决问题的工具。

这一天的内容学完,你应该已经具备独立写出一个"带筛选、带排序、带计时、有清晰输出"的小工具的能力。下一天往后再学集合框架时,你会发现数组的这些操作思路会平滑地迁移到List、Map上——因为底层的逻辑其实是相通的。顺手把今天写的代码里那个Student[]换成ArrayList<Student>,你就提前预习了下一章的内容,而且会觉得特别自然。

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

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

立即咨询