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(),那两个接口的默认方法都会被忽略,直接用父类那版。规则连起来就是:

  1. 父类里的方法 > 接口的默认方法;
  2. 接口之间冲突 → 编译器报错,必须手写,要么改名。

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 数组:想要字典序就传字典序比较器,想要长度序就传长度序,想要倒序再换一个——数据本身一个字不用动,排法穷举不完。

ComparableComparator
写在哪类内部,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::printlnx -> System.out.println(x)
类::静态方法Math::sinx -> 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),不用每次自己造:

接口方法含义
Predicateboolean test(T)判断
Consumervoid accept(T)吃一个,不返回
FunctionR apply(T)转换
SupplierT get()生产一个
Runnablevoid 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 的办法很简单:让实现类自己报名。

  1. 定接口。JDBC 定的是 java.sql.Driver。
  2. 实现方自己登记。每个驱动 jar 里放一个文本文件,路径是固定的:

文件内容就一行,写自己的类名:

  1. 调用方去读名单:

就这么回事:一个接口 + 一个固定路径的文本文件,运行时就能把所有实现找出来。

这个机制叫 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 阶段会正面碰到。
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论