04接口-lambda-内部类
第 6 章 接口、lambda 表达式与内部类
6.1 接口
6.1.1 接口的概念
想给一个 Employee[] 排序,用 Arrays.sort 就行。但通用排序代码怎么知道两个 Employee 谁在前?它是 JDK 里的通用代码,不可能认识 Employee。
办法是约定一个方法名:想被我排序的类,必须提供一个叫 compareTo 的方法。这个约定写成类型,就是接口:
尖括号里的 是泛型(Ch8 才正式讲)。这里只记一句:Comparable 就是「能和 Employee 比」。 具体怎么回事,现在不用管。
类用 implements 兑现这个约定,把方法真正写出来:
Arrays.sort 只认「你能被比较」这件事,不关心你比的是薪水还是姓名——接口规定「有什么能力」,不规定「怎么做」。
为什么非得是个独立接口,不能就在类里写个 compareTo? 因为 Arrays.sort 是 JDK 写的——它写的时候,你的 Employee 还没出生。它没法「看」你的类,只能检查你是什么类型:
你在类里写个 compareTo,只有你自己调得到;Arrays.sort 不认识你的类,也不会去翻你有哪些方法——真要它去找,只能靠反射去猜方法名(6.5 代理那套),又慢又脆。
换成接口,约定就从「口头说好」变成一条编译期就查得出的规则:没实现?编译不过。这也是它比「方法名叫 compareTo」那句口头约定强的地方。
说得再土一点:接口是互不相识的双方共用的插座。Arrays.sort(JDK,早写完了)和你的Employee(还没写)互相不认识,但都认Comparable这个标准。所以Integer、String、Employee彼此毫无血缘,照样能被同一个Arrays.sort排。
vs C:C 没有这个机制。想让通用排序函数知道你「能被比较」,只能塞一个函数指针进去,每个调用点自己传「怎么比」。关于回调,6.1.7 会再讲。
6.1.2 接口的属性
几条硬性规定:
- 接口不是类,不能
new。new Comparable()编译不过。 - 接口里的方法默认
public abstract(公开 + 只有签名);字段默认public static final(常量)。
- 接口类型的变量可以声明,指向实现类对象。这就是 5.1 的那个多态——只是「父类」换成了「接口」:接口变量只能调接口里有的方法;真正执行的则是实现类写的那个方法——毕竟接口里那个只有签名,没实现可跑。
- 一个类可以
implements多个接口,逗号隔开:
6.1.3 接口与抽象类
5.6 的抽象类也能「只给方法签名、不给实现」,看着和接口是一回事。区别只有两条。
第一条:一个类只能有一个父类,接口能有多个。
extends 后面只能写一个类:
想再「多一样能力」,只能放到 implements 后面。那里可以写一串,用逗号隔开:
所以一个类要同时具备好几样能力,只能靠接口——这就是它存在的头号理由。
第二条:抽象类里有「现成的东西」,接口里基本没有。
- 抽象类能有字段,用来存数据;接口的字段只能是常量(
public static final)。 - 抽象类能有普通方法(已经写好的实现);接口里的方法默认全是空签名(能带实现的
default方法是 6.1.5 才加的)。
两条合起来,选法就一句:
- 想给类多挂几样能力 → 用接口;
- 想给子类留一份现成的状态和代码 → 用抽象类。
| 抽象类 | 接口 | |
|---|---|---|
| 一个类能挂几个 | 1 个 | 任意多个 |
| 字段 | 能存数据 | 只能是常量 |
| 方法 | 空签名和现成实现都有 | 默认全是空签名 |
6.1.4 静态和私有方法
Java 8 起,接口可以写 static 方法。 它就是挂在接口名下的普通工具方法:
调用时写接口名:Greeting.hello()。Comparator 上那批 comparing / naturalOrder 也是这样挂上去的——6.2.8 会用到。
Java 9 起,接口还能写 private 方法:
理由很实际:接口里几个方法想共用一小段代码,又不想把这段代码变成接口的 API——实现类不该看到它。私有方法只服务于接口里其他方法。
6.1.5 默认方法
问题在演进:接口已经发布,有几十个实现类。现在想给它加一个方法——直接加抽象方法,那几十个类都没实现,全部编译失败。加不了。
default 解决它:直接在接口里给出实现,实现类可重写、也可不管:
老实现类只写了 getName,greet 白得一个默认实现,一行不用改就编译通过。
现实里的例子是集合:Java 8 给 Collection 加 stream() 时,用的就是默认方法。
6.1.6 解决默认方法冲突
默认方法带来一个新麻烦:一个类实现两个接口,两个接口的默认方法同名同参数,用谁的?
编译器不给猜,要求必须自己重写,用 接口名.super 明确挑一个:
接口名.super——这是唯一能写 super 的接口场景。
还有一条优先级要记:父类里的方法永远赢。如果 C 的父类里有一个具体的 f(),那两个接口的默认方法都会被忽略,直接用父类那版。规则连起来就是:
- 父类里的方法 > 接口的默认方法;
- 接口之间冲突 → 编译器报错,必须手写,要么改名。
6.1.7 接口与回调
回调 = 把「要执行的代码」交给别人,时机到了再调回来。 注意它和接口没有必然联系。
JDK 的 javax.swing.Timer 是干净的例子:它每隔一段时间触发一次。触发时干什么,它不知道——它只要求你交一个实现了 ActionListener 的对象:
你交的代码是被「叫回来」执行的一方,控制权在 Timer 手上:它决定什么时候调、调多少次。
注意这里「接口」和「类」的分工:new 的永远是类(Timer、MyListener);ActionListener 只当类型用——变量、参数的位置都能写它,但它自己不能 new。所以上面既没有、也没法 new 一个接口。
注意 ActionListener 只有一个方法。下一节的 lambda 就是为缩写这种接口准备的。
vs C:C 用函数指针做回调,把函数地址传出去即可。Java 早年没有函数指针,只能拿「只有一个方法的接口」顶替:先把函数包进一个对象,再把这个对象传出去。
6.1.8 Comparator
Comparator 是外部比较器:compare(a, b) 返回值小于 0 / 等于 0 / 大于 0,分别表示 a < b / a == b / a > b。
6.1.1 的 Arrays.sort 要求元素自己能比(Comparable)。Comparator 反过来:比法由外部给。Arrays.sort 和 List.sort 都有收 Comparator 的重载:
好处是把「怎么比」和「数据」分开。同一个 String 数组:想要字典序就传字典序比较器,想要长度序就传长度序,想要倒序再换一个——数据本身一个字不用动,排法穷举不完。
Comparable | Comparator | |
|---|---|---|
| 写在哪 | 类内部,compareTo | 类外部,compare |
| 一个类能有几种排法 | 1 种(写死) | 想要几种有几几种 |
| 用处 | 这是「我的自然顺序」 | 这是一次性临时排法 |
6.1.9 对象克隆
clone() 定义在 Object 上,是 protected 的。要用它,类得先声明 implements Cloneable:
Cloneable 是个标记接口——里面一个方法都没有,只用来「打标记」,告诉 JVM「这个类允许克隆」。没打这个标记调 clone(),抛 CloneNotSupportedException。
关键在 super.clone() 给的是什么:
浅拷贝——字段逐个复制,但引用字段只复制引用:副本和原对象指向同一个内部对象。谁改了它,两边一起变(4.2 讲过,对象变量里存的是地址,不是对象本身)。
要真正独立,得深拷贝——把可变的引用字段也克隆一份,接进新对象:
一句话版clone:clone() 只复制字段的值;引用字段的值是地址,所以副本和原对象指向同一个内层对象——要让副本彻底独立,就得把内层对象也各自复制一份,然后把默认的Object类强转成具体类。
vs C:这和memcpy是同一个坑——memcpy也逐字节浅拷贝,指针字段复制过去的还是同一个地址。想复制指向的内容,只能自己手工再malloc+memcpy一遍,和这里的深拷贝是一个意思。
关于 (Ch8 正式讲):定义时随便起个名当占位,使用时必须填真类型。 填进去之后,接口里所有用这个名字的地方一起替换成那个类型。
6.2 lambda 表达式
先记一句话:lambda 必须和接口搭配使用。 它自己没有类型,必须由一个函数式接口(只有一个抽象方法的接口)接住——下面每一处都会用到这一点。
6.2.1 为什么引入 lambda 表达式
回到 6.1.7 的回调。给数组按名字长度排序,得先造一个比较器对象:
六行,真正的逻辑只有 a.length() - b.length() 这一行。其余全是「起个类、写个方法签名、new 出来」的样板——这一类接口(只有一个抽象方法)实在太像函数了,Java 8 干脆给了个函数字面量,lambda:
这一行看不见接口名——它藏在方法签名里:Arrays.sort 的第二个参数要的就是 Comparator。lambda 的目标类型就是从这儿来的。
lambda 就是「方法的字面量」:一段代码,没有名字、没有所属的类,直接当成值传出去。
6.2.2 lambda 表达式的语法
lambda 本身没有类型,必须由左边某个函数式接口接住。所以下面都是完整写法:
Function/Supplier是 JDK 自带的函数式接口(java.util.function),6.2.7 会列全。这里先当它们是「收一个参数」和「不收参数」的接口。
参数类型能省掉,是因为编译器能从上下文推断出你要实现哪个方法——6.2.3 说清这件事。
6.2.3 函数式接口
lambda 只能装进函数式接口:抽象方法恰好只有一个的接口。
注意别读成「接口只能有一个方法」。 接口可以有任意多个方法,函数式接口只数抽象方法——default / static / private 方法都有实现,不算数。Comparator 就是现成的例子:
除了 compare,它还有 reversed(6.2.8 会用到)这类有实现的方法——都不影响函数式接口的身份。
为什么必须「只有一个」:lambda 只写了一段代码,编译器得知道它实现的是哪一个方法——接口里只有一个抽象方法,才没有歧义。
@FunctionalInterface 可写可不写,作用同 5.1 的 @Override:写了编译器替你检查「是不是真的只有一个抽象方法」,不满足就报错。不加也不影响运行。
6.2.4 方法引用
如果 lambda 写出来是「把参数原样转交给某个方法」,还能再简写,用 :::
System.out::println 字面就是「println 这个方法本身」。三种形式:
| 形式 | 例子 | 等价于 |
|---|---|---|
| 对象::实例方法 | System.out::println | x -> System.out.println(x) |
| 类::静态方法 | Math::sin | x -> Math.sin(x) |
| 类::实例方法 | String::compareToIgnoreCase | (a, b) -> a.compareToIgnoreCase(b) |
第三行要解释:String::compareToIgnoreCase 是实例方法,但它没有调用者——省略的调用者从参数里补:第一个参数 a 当接收者,剩下的 b 当实参。
三种写法,干的是同一件事。 从最啰嗦到最简洁:
① 写一个类(最啰嗦)
② lambda(类不要了)
③ 方法引用(参数也省了)
三个版本输出都是 hello。方法引用能成立的前提只有一条:lambda 里除了「把参数原样转交给某个方法」,什么都没干。
6.2.5 构造器引用
类名::new,把「造对象」这件事本身交出去。同样得有个接口接着:
展开收起
一个类可能有多个构造器,Point::new 具体挑哪个,由目标接口的方法签名决定——接口方法收两个参数,就挑两参的那个构造器。
6.2.6 变量作用域
lambda 能读外层局部变量:
但那个变量必须「初始化后不再改」(事实最终,effectively final)。一旦改过就编译不过:
为什么禁止:lambda 捕获的是那一刻的副本,不是外层变量本身。副本里 count++ 改的是副本,外层的 count 纹丝不动——既然改了也没用,语言索性禁止,免得你以为改了。
所以 lambda 里不能改外层变量。要累计结果,得换一个可变的载体(比如对象的某个字段、数组元素)。
顺带:lambda 里的 this 指的是外围对象本身,不是这个 lambda。
6.2.7 处理 lambda 表达式
这节讲一件事:你自己写的方法,怎么接收 lambda。
lambda 没有自己的类型,只能落在函数式接口上。所以「接收 lambda 的方法」,参数就写成函数式接口:
JDK 已经备好一批常用的(java.util.function),不用每次自己造:
| 接口 | 方法 | 含义 |
|---|---|---|
Predicate | boolean test(T) | 判断 |
Consumer | void accept(T) | 吃一个,不返回 |
Function | R apply(T) | 转换 |
Supplier | T get() | 生产一个 |
Runnable | void run() | 无参无返回 |
(这些接口卷 II 讲 Stream API 时会大量出现,那时再细看。)
6.2.8 再谈 Comparator
Comparator 上有一批静态工厂方法,配方法引用把排序规则压成一行:
而且能接着链下去:
.reversed() 和 .thenComparing() 都是 Comparator 上的方法,返回的还是 Comparator,所以能一环扣一环——这就是链式调用成立的前提:每个方法都返回同类型的对象。
(6.1.4 留的问题,现在看懂不亏)签名里那个Comparator.comparing的签名是static Comparator comparing(...)。static和返回类型中间多出来的 叫泛型方法:方法自己声明一个类型参数,作用范围只在这个方法里。它跟接口头上interface Comparator那个T毫无关系,只是恰好重名——接口那个跟着对象走,而static方法没有对象,够不着它,只能自己再造一个。验证办法:把它改名叫U(static Comparator comparing(...)),一模一样能用;真是承接的话,改名早报错了。还是看不懂就跳过——Ch8 会正式讲,不耽误往下读。
6.3 内部类
6.3.1 使用内部类访问对象状态
内部类最直接的价值:能访问外围类的私有字段。
TimePrinter 能读 interval,别的类读不了。不违反封装——private 对「写在外围类代码里的成员」全都放行,内部类就在外围类的代码里。
(ActionListener + Timer 就是 6.1.7 的回调:外围类把内部类对象交给定时器,到点了回调 actionPerformed。)
6.3.2 内部类的特殊语法规则
内部类对象里隐含着一个指向外围对象的引用——这就是它能读 interval 的物理原因。
三样不是一类东西:Outer.this 是对象引用(① 能赋给 Outer 变量);outer.new Inner() 是创建对象(② 先有外围对象才能造);Outer.Inner 是类型名(③ 从类外面引用必须写全,类内部写 Inner 就够)。
容易混的是Outer.class——那个才代表「这个类」,是个Class对象(5.9 讲过)。Outer.this跟它完全不是一回事。
编译产物:内部类被编译成独立的 class 文件,文件名是 Outer$Inner.class。
6.3.3 内部类是否有用、必要和安全
有用:能访问外围私有状态(6.3.1);能把一个类藏起来——只服务外围那一个类,不占用类的成员列表,外界根本看不到它。
必要:不是非它不可。同样的事用「普通类 + 构造器传引用」也能做,只是啰嗦。
安全:编译器会生成 access$0 这类合成方法,让内部类绕过 private 检查去读外围字段。这说明 private 是编译期检查,不是运行时铁幕——5.9 的反射能开同一道门。
今天内部类的用途已经大幅收窄:匿名内部类那部分基本被 lambda 取代了(6.2.1),剩下的主要就是「需要直接访问外围私有状态」这一个场景。
6.3.4 局部内部类
类也能定义在方法体内,作用域只在这个方法里:
出了方法没人认识它。
6.3.5 由外部方法访问变量
局部内部类能捕获外层方法的局部变量,规则和 lambda 完全一样:读可以,改不行,且必须是事实最终(6.2.6)。
原因也一样:捕获的是副本,改了也没用。
6.3.6 匿名内部类
new 接口名() { ... }:没有名字、当场创建、只用一次。
省掉了「先起个名字、再 new」两步,代价是这个实现只能在创建它的地方用这一次。
lambda 出现前,这是回调的标准写法(6.2.1 那段六行代码就是匿名内部类)。现在单方法接口的匿名内部类基本都能换成 lambda;接口里有多个方法要一起实现时,还得用匿名内部类。
6.3.7 静态内部类
加了 static 的内部类不绑定外围类实例,等于一个普通类、只是嵌套放在别人里面:
两种内部类的区别只有一条:非静态内部类带着「外围对象引用」(6.3.2 那个隐含引用),静态内部类没有。由此派生两点:静态内部类创建时不需要外围对象,也访问不到外围类的实例字段。
static 只管它自己那一层,不往下传。 外层是静态、内层不是,内层照样要实例:
所以准确的说法是:每个非静态内部类,都要依附「它紧邻的那一层」的实例——不是最外层。
需要「按功能打包、又不想单独开文件」时,首选静态内部类——少一个隐式引用,语义最简单。
6.4 服务加载器
场景:你定了一个接口,别人写了很多实现,散在各处。你想把它们全部找出来用,但你不知道它们叫什么名字。
JDBC 就是这种情况。连数据库要靠「驱动」(一个 jar 包,里面是某个数据库「怎么连」的 Java 代码),而驱动有 MySQL 的、Oracle 的、PostgreSQL 的……你的代码不可能把每种驱动的类名都写一遍——写了 MySQL,换 Oracle 就得改代码、重新编译。
ServiceLoader 的办法很简单:让实现类自己报名。
- 定接口。JDBC 定的是
java.sql.Driver。 - 实现方自己登记。每个驱动 jar 里放一个文本文件,路径是固定的:
文件内容就一行,写自己的类名:
- 调用方去读名单:
就这么回事:一个接口 + 一个固定路径的文本文件,运行时就能把所有实现找出来。
这个机制叫 SPI(Service Provider Interface)。JDBC 靠它工作,所以换数据库只需要换 jar,Java 代码一行不用改。
6.5 代理
代理 = 你以为在调真的,其实中间隔了一层。
找人办事,对方门口有个秘书:你说「我要办 X」,秘书先记下来(可能还加一句「已登记」),再进去转达给本人。——秘书就是代理。
代码里也一样:你拿到一个对象,调它的方法;实际上它是个假对象,方法调用先被转交给你自己写的一段代码,那段代码干完自己的活,再去调真对象。
6.5.1 什么时候用
日志、监控、权限检查这类事,每个方法都要有,但又不该混进业务代码里。
比如一个接口有 20 个方法,你想每个方法被调用时都打一行日志——总不能 20 个方法里各写一遍吧?代理能统一插在中间:20 个方法全走同一个入口,你在那个入口写一次就够了。
6.5.2 造一个代理
先有接口和真实现:
Proxy.newProxyInstance(...) 替你把这个假类现造出来,你要写的只有「方法被调到时干什么」:
然后用它造代理:
输出:
这段里三样东西现在不用懂,各有去处:
| 看不懂的 | 在哪讲 |
|---|---|
Method、method.invoke(...) | 5.9 反射——认得即可 |
Greeter.class.getClassLoader() | JVM(类加载那一篇) |
Proxy、InvocationHandler | 就是本节 |
看不懂不影响往下读。记住一句就够:代理是「中间站一个人,替你转达」。
6.5.3 代理类的特性
- 代理类是运行时生成的,不是编译出来的,硬盘上没有对应源码或 class 文件;
- 它继承
Proxy,类名是$Proxy0这类; - 只能代理接口,不能代理类。原因就是它已经
extends Proxy了——Java 单继承,没法再成为你那个类的子类;而「实现接口」不受数量限制,所以代理接口没问题; - 代理类里每个接口方法都转给
handler.invoke。
这就是 Spring AOP 的底层机制之一:接口用 JDK 动态代理,类用 CGLIB 生成子类。SSM 阶段会正面碰到。