Java8函数式编程核心笔记
2021-04-11 12:26:16 20 举报
AI智能生成
Java16都已经发布了,Java8还没玩溜呢~
作者其他创作
大纲/内容
回复【Java8】获取精选视频资料
Java8函数式编程核心笔记
六、数据并行化
并行和并发
并发是两个任务共享时间段,并行则是两个任务在同一时间发生。比如运行在多核CPU上,如果一个程序要运行两个任务,并且只有一个CPU 给它们分配了不同的时间片,那么这就是并发,而不是并行。
并行化是指为缩短任务执行时间,将一个任务分解成几部分,然后并行执行。
数据并行化是指将数据分成块,为每块数据分配单独的处理单元。
为什么并行化如此重要
主流的芯片厂商转向了多核处理器,服务器通过几个物理单元搭载32 或64 核的情况已不鲜见,而且,这种趋势尚无减弱的征兆。
阿姆达尔定律是一个简单规则,预测了搭载多核处理器的机器提升程序速度的理论最大值。以一段完全串行化的程序为例,如果将其一半改为并行化处理,则不管增加多少处理器,其理论上的最大速度只是原来的2 倍。
并行化流操作
并行化操作流只需改变一个方法调用。如果已经有一个Stream 对象, 调用它的parallel 方法就能让其拥有并行操作的能力。如果想从一个集合类创建一个流,调用parallelStream 就能立即获得一个拥有并行能力的流。
并行化计算专辑曲目长度public int parallelArraySum() { return albums.parallelStream() .flatMap(Album::getTracks) .mapToInt(Track::getLength) .sum();}
关于性能:在一个四核电脑上,如果有10 张专辑,串行化代码的速度是并行化代码速度的8 倍;如果将专辑数量增至100 张,串行化和并行化速度相当;如果将专辑数量增值10 000 张,则并行化代码的速度是串行化代码速度的2.5 倍。
模拟系统
蒙特卡洛模拟法会重复相同的模拟很多次,每次模拟都使用随机生成的种子。每次模拟的结果都被记录下来,汇总得到一个对系统的全面模拟。蒙特卡洛模拟法被大量用在工程、金融和科学计算领域。
限制
reduce初值必须为组合函数的恒等值。拿恒等值和其他值做reduce 操作时,其他值保持不变。
reduce 操作的另一个限制是组合操作必须符合结合律。这意味着只要序列的值不变,组合操作的顺序不重要。
要避免的是持有锁。流框架会在需要时,自己处理同步操作,没有必要为自己的数据结构加锁。
性能
数据大小:输入数据的大小会影响并行化处理对性能的提升。只有数据足够大、每个数据处理管道花费的时间足够多时,并行化处理才有意义。
源数据结构:性能好(ArrayList、数组或IntStream.range)、性能一般(HashSet、TreeSet)、性能差(LinkedList、Streams.iterate、 BufferedReader.lines)
装箱:处理基本类型比处理装箱类型要快。
核的数量:在实践中,核的数量不单指你的机器上有多少核,更是指运行时你的机器能使用多少核。
单元处理开销:花在流中每个元素身上的时间越长,并行操作带来的性能提升越明显。
如果能避开有状态,选用无状态操作,就能获得更好的并行性能。无状态操作包括map、filter 和flatMap,有状态操作包括sorted、distinct 和limit。
并行化数组操作
Java 8 引入了一些针对数组的并行操作,脱离流也可以使用Lambda 表达式。
这些操作都在工具类Arrays 中parallelPrefix 任意给定一个函数,计算数组的和parallelSetAll 使用Lambda 表达式更新数组元素parallelSort 并行化对数组元素排序
要点回顾
• 数据并行化是把工作拆分,同时在多核 CPU上执行的方式。• 可通过调用 parallel 或者 parallelStream 方法实现数据并行化操作。• 影响性能的五要素是:数据大小、源数据结构、值是否装箱、可用的 CPU 核数量,以及处理每个元素所花的时间。
七、测试、调试和重构
重构候选项
使用Lambda 表达式重构代码有个时髦的称呼:Lambda 化。Java 8 中的核心类库就曾经历过这样一场重构。
使用工厂方法进行ThreadLocal值的初始化ThreadLocal<Album> thisAlbum = ThreadLocal.withInitial(() -> database.lookupCurrentAlbum());
使用领域方法重构public long countFeature(ToLongFunction<Album> function) { return albums.stream().mapToLong(function).sum();}public long countTracks() { return countFeature(album -> album.getTracks().count());}public long countRunningTime() { return countFeature(album -> album.getTracks() .mapToLong(track -> track.getLength()).sum());}public long countMusicians() { return countFeature(album -> album.getMusicians().count());}
Lambda表达式的单元测试
Lambda 表达式给单元测试带来了一些麻烦,Lambda 表达式没有名字,无法直接在测试代码中调用。
第一种是将Lambda 表达式放入一个方法测试,这种方式要测那个方法,而不是Lambda 表达式本身。
另外一种是用方法引用。任何Lambda 表达式都能被改写为普通方法,然后使用方法引用直接引用。
在测试替身时使用Lambda表达式
编写单元测试的常用方式之一是使用测试替身描述系统中其他模块的期望行为。
测试代码时,使用Lambda 表达式的最简单方式是实现轻量级的测试存根。
使用Mockito 这样的框架有助于更容易地产生测试替身。Answer 本身就是一个函数接口。
惰性求值和调试
使用流时,调试可能会变得更加复杂,因为迭代已交由类库控制,而且很多流操作是惰性求值的。
日志和打印消息
在流中很嫩做到每一步打印出集合中的值,因为一些中间步骤是惰性求值的。
解决方案:peak
流有一个方法让你能查看每个值,同时能继续操作流。这就是peek 方法。
使用peek 方法记录中间值Set<String> nationalities = album.getMusicians() .filter(artist -> artist.getName().startsWith("The")) .map(artist -> artist.getNationality()) .peek(nation -> System.out.println("Found nationality: " + nation)) .collect(Collectors.<String>toSet());
在流中间设置断点
记录日志这是peek 方法的用途之一。为了像调试循环那样一步一步跟踪,可在peek 方法中加入断点,这样就能逐个调试流中的元素了。
• 重构遗留代码时考虑如何使用Lambda 表达式,有一些通用的模式。• 想要对复杂的 Lambda表达式编写单元测试,将其抽取成一个常规的方法。• peek 方法能记录中间值,在调试时非常有用。
八、设计和架构的原则
Lambda表达式改变了设计模式
命令者模式:在GUI Editor组件的例子中所有的命令类,Save、Open 都是Lambda 表达式,只是暂时藏在类的外壳下。它们是一些行为,我们通过创建类将它们在对象之间传递。Lambda 表达式能让这个模式变得非常简单。
策略模式:在压缩算法的例子中可以去掉具体的策略实现,使用一个方法实现算法,这里的算法由构造函数中对应的OutputStream 实现。
观察者模式:在观察人类登陆月球的例子中,使用传统的代码需要有一层模板类,如果使用Lambda 表达式,就不用编写这些类了。
模板方法模式:模板方法模式真正要做的是将一组方法调用按一定顺序组织起来。如果用函数接口表示函数,用Lambda 表达式或者方法引用实现这些接口,相比使用继承构建算法,就会得到极大的灵活性。
使用Lambda表达式的领域专用语言
DSL 高度专用:不求面面俱到,但求有所专长。
人们通常将DSL 分为两类:内部DSL 和外部DSL。外部DSL 脱离程序源码编写,然后单独解析和实现(如CSS和正则表达式)。内部DSL 嵌入编写它们的编程语言中,虽然简单 却功能强大,可以让你的代码变得更加精炼、易读。
例子:使用Lambda表达式构建BDD的DSL语言。
使用Lambda表达式的SOLID原则
单一功能原则:Lambda 表达式在方法级别能更容易实现单一功能原则。
开闭原则:高阶函数也展示出了同样的特性:对扩展开放,对修改闭合。如ThreadLocal类的静态方法withInitial 是一个高阶函数,传入一个负责生成初始值的Lambda 表达式。不可变对象实现了开闭原则,是因为它们的内部状态无法改变,可以安全地为其增加新的方法。新增加的方法无法改变对象的内部状态,因此对修改是闭合的;但它们又增加了新的行为,因此对扩展是开放的。
依赖反转原则:Lambda 表达式的很多高阶函数都符合依赖反转原则。比如map 函数重用了在两个集合之间转换的代码。map 函数不依赖于转换的细节,而是依赖于抽象的概念。在这里,就是依赖函数接口:Function。
• Lambda 表达式能让很多现有设计模式更简单、可读性更强。• 在 Java 8中,创建领域专用语言有更多的灵活性。• 在 Java 8中,有应用 SOLID 原则的新机会。
九、使用Lambda 表达式编写并发程序
为什么要使用非阻塞式I/O
阻塞式I/O,是一种通用且易于理解的方式,缺点是需要和服务器建立大量TCP 连接,因此扩展性不是很好。
非阻塞式I/O,有时也叫异步I/O,可以处理大量并发网络连接,而且一个线程可以为多个连接服务。和阻塞式I/O 不同,对聊天程序客户端的读写调用立即返回,真正的读写操作则在另一个独立的线程执行。
Java 标准类库的NIO 提供了非阻塞式I/O 的接口,但难于理解和调试。
回调
我们将使用Vert.x 框架实现该应用。
向connectHandler 方法输入一个Lambda 表达式,每当有用户连接到聊天应用时,都会调用该Lambda 表达式。这就是一个回调,
消息传递架构
verticle 对象之间通过向事件总线发送消息通信,这就是说我们不需要保护任何共享状态,因此根本不需要在代码中添加锁或使用synchronized 关键字,编写并发程序变得更加简单。
Vert.x 没有限制只能使用字符串传递消息,我们可以使用更复杂的JSON 对象,甚至使用Buffer 类构建自己的消息。Vert.x 框架通过在发送消息时复制消息的方式来避免消息读写问题。
末日金字塔
多层嵌套的回调形成了一个末日金字塔,这是一个众所周知的反模式,让代码难于阅读和理解。同时,将代码的逻辑分散在了多个方法里。
将逻辑分散在多个方法里,解决了末日金字塔问题,但代码还是难于阅读。
Future
构建复杂并行操作的另外一种方案是使用Future。Future 像一张欠条,方法不是返回一个值,而是返回一个Future 对象,该对象第一次创建时没有值,但以后能拿它“换回”一个值。
要将Future 对象的结果传给其他任务,会阻塞当前线程的执行。这会成为一个性能问题,任务不是平行执行了,而是(意外地)串行执行。
我们真正需要的是不必调用get 方法阻塞当前线程,就能操作Future 对象返回的结果。我们需要将Future 和回调结合起来使用。
CompletableFuture
CompletableFuture结合了Future 对象打欠条的主意和使用回调处理事件驱动的任务。其要点是可以组合不同的实例,而不用担心末日金字塔问题。
CompletableFuture 对于处理并发任务非常有用,让我们简单看一下其中的一些用例。• 如果你想在链的末端执行一些代码而不返回任何值,比如 Consumer 和 Runnable,那就看看thenAccept 和thenRun 方法。• 可使用thenApply方法转换CompletableFuture对象的值,有点像使用Stream的map方法。• 在 CompletableFuture 对象出现异常时,可使用 exceptionally 方法恢复,可以将一个函数注册到该方法,返回一个替代值。• 如果你想有一个 map,包含异常情况和正常情况,请使用 handle 方法。• 要找出 CompletableFuture 对象到底出了什么问题,可使用 isDone 和 isCompletedExceptionally 方法辅助调查。
响应式编程
CompletableFuture 背后的概念可以从单一的返回值推广到数据流,这就是响应式编程。响应式编程其实是一种声明式编程方法,它让程序员以自动流动的变化和数据流来编程。
RxJava 类库将这种响应式的理念移植到了JVM。RxJava 类库引入了一个叫做Observable 的类,该类代表了一组待响应的事件,可以理解为一沓欠条。Observable 定义的很多操作都和Stream 的相同:map、filter、reduce。最大的不同在于用例。Stream 是为构建内存中集合的计算流程而设计的,而RxJava 则是为了组合异步和基于事件的系统流程而设计的。它没有取数据,而是把数据放进去。换个角度理解RxJava,它是处理一组值,而CompletableFuture 用来处理一个值。
使用CompletableFuture 时,我们通过给complete 方法一个值来偿还欠条。而Observable代表了一个事件流,我们需要有能力传入多个值。observer.onNext("a");observer.onNext("b");observer.onNext("c");observer.onCompleted();
和CompletableFuture 类似,Observable 也能处理异常。如果出现错误,调用onError 方法。
何时何地使用新技术
事件驱动和响应式应用正在变得越来越流行,而且经常会是为你的问题建模的最好方式之一。
相比阻塞式设计,有两种情况可能特别适合使用响应式或事件驱动:第一种情况是业务逻辑本身就使用事件来描述。另一种显然的用例是应用需要同时处理大量I/O 操作。
• 使用基于Lambda 表达式的回调,很容易实现事件驱动架构。• CompletableFuture 代表了 IOU,使用 Lambda表达式能方便地组合、合并。• Observable 继承了 CompletableFuture 的概念,用来处理数据流。
五、高级集合类和收集器
方法引用
使用Artist::getName替换artist -> artist.getName()
还可以用这种方式创建数组,下面的代码创建了一个字符串型的数组:String[]::new
元素顺序
在一个有序集合中创建一个流时,流中的元素就按出现顺序排列。
如果集合本身就是无序的,由此生成的流也是无序的。
流的目的不仅是在集合类之间做转换,而且同时提供了一组处理数据的通用操作。有些集合本身是无序的,但这些操作有时会产生顺序,比如sorted()。
一些中间操作会产生顺序,比如对值做映射时,映射后的值是有序的,这种顺序就会保留下来。
一些操作在有序的流上开销更大,调用unordered 方法消除这种顺序就能解决该问题。大多数操作都是在有序流上效率更高,比如filter、map 和reduce 等。
使用并行流时,forEach 方法不能保证元素是按顺序处理的,如果需要保证按顺序处理,应该使用forEachOrdered 方法。
使用收集器
收集器,一种通用的、从流生成复杂值的结构。只要将它传给collect 方法,所有的流就都可以使用它了。
toList、toSet 、toCollection
可以指定该集合的类型 stream.collect(toCollection(TreeSet::new))
还有些收集器实现了一些常用的数值运算。找出一组专辑上曲目的平均数public double averageNumberOfTracks(List<Album> albums) { return albums.stream().collect(averagingInt(album -> album.getTrackList().size()));}
重构和定制收集器
combine 方法很像reduce 操作的第三个方法。如果有两个容器我们将其合并。public BinaryOperator<StringCombiner> combiner() { return StringCombiner::merge;}
关于收集器,还有一点一直没有提及,那就是特征。特征是一组描述收集器的对象,框架可以对其适当优化。characteristics 方法定义了特征。
一些细节
Lambda 表达式的引入也推动了一些新方法被加入集合类。让我们来看看Map 类的一些变化。
• 方法引用是一种引用方法的轻量级语法,形如:ClassName::methodName。• 收集器可用来计算流的最终值,是 reduce 方法的模拟。• Java 8提供了收集多种容器类型的方式,同时允许用户自定义收集器。
四、类库
基本类型
整型在内存中占用4 字节,整型对象却要占用16 字节。最坏的情况下,同样大小的数组,Integer[] 要比int[] 多占用6 倍内存。
对于需要大量数值运算的算法来说,装箱和拆箱的计算开销,以及装箱类型占用的额外内存,会明显减缓程序的运行速度。
Stream 类的某些方法对基本类型和装箱类型做了区分,对整型、长整型和双浮点型做了特殊处理。 T -> ToLongFunction -> longlong -> LongFunction -> T
这些基本类型都有与之对应的Stream,以基本类型名为前缀,如LongStream。事实上,mapToLong 方法返回的不是一个一般的Stream,而是一个特殊处理的Stream。在这个特殊的Stream 中,map 方法的实现方式也不同。
重载解析
Lambda 表达式的类型就是对应的函数接口类型,因此,将Lambda 表达式作为参数传递时,javac 会挑出最具体的类型。
Lambda 表达式作为参数时,其类型由它的目标类型推导得出,推导过程遵循如下规则:• 如果只有一个可能的目标类•型,由相应函数接口里的参数类型推导得出;• 如果有多个可能的目标类型,由最具体的类型推导得出;• 如果有多个可能的目标类型且最具体的类型不明确,则需人为指定类型。
@FunctionalInterface
该注释会强制javac 检查一个接口是否符合函数接口的标准。如果该注释添加给一个枚举类型、类或另一个注释,或者接口包含不止一个抽象方法,javac 就会报错。重构代码时,使用它能很容易发现问题。
默认方法
Java 8 通过如下方法解决该问题:Collection 接口告诉它所有的子类:“如果你没有实现stream 方法,就使用我的吧。”接口中这样的方法叫作默认方法,在任何接口中,无论函数接口还是非函数接口,都可以使用该方法。
Iterator接口的默认方法forEachdefault void forEach(Consumer<? super T> action) { for (T t : this) { action.accept(t); }}
类中重写的方法胜出。这样的设计主要是由增加默认方法的目的决定的,增加默认方法主要是为了在接口上向后兼容。让类中重写方法的优先级高于默认方法能简化很多继承问题。
多重继承
接口允许多重继承,因此有可能碰到两个接口包含签名相同的默认方法的情况。此时,javac 并不明确应该继承哪个接口中的方法,因此编译器会报错。
如果对默认方法的工作原理,特别是在多重继承下的行为还没有把握,如下三条简单的定律可以帮助大家。1. 类胜于接口。如果在继承链中有方法体或抽象的方法声明,那么就可以忽略接口中定义的方法。2. 子类胜于父类。如果一个接口继承了另一个接口,且两个接口都定义了一个默认方法,那么子类中定义的方法胜出。3. 没有规则三。如果上面两条规则不适用,子类要么需要实现该方法,要么将该方法声明为抽象方法。其中第一条规则是为了让代码向后兼容。
接口的静态方法
Stream 是个接口,Stream.of 是接口的静态方法。这也是Java 8 中添加的一个新的语言特性,旨在帮助编写类库的开发人员,但对于日常应用程序的开发人员也同样适用。
Stream 和其他几个子类还包含另外几个静态方法。特别是range 和iterate方法提供了产生Stream 的其他方式。
Optional
reduce 方法有两种形式,一种如前面出现的需要有一个初始值,另一种变式则不需要有初始值。没有初始值的情况下,reduce 的第一步使用Stream 中的前两个元素。有时,reduce 操作不存在有意义的初始值,这样做就是有意义的,此时,reduce 方法返回一个Optional 对象。
Optional 是为核心类库新设计的一个数据类型,用来替换null 值。
使用Optional 对象有两个目的:首先,Optional 对象鼓励程序员适时检查变量是否为空,以避免代码缺陷;其次,它将一个类的API 中可能为空的值文档化,这比阅读实现代码要简单很多。
Optional<String> a = Optional.of("a");Optional emptyOptional = Optional.empty();Optional alsoEmpty = Optional.ofNullable(null);
使用Optional 对象的方式之一是在调用get() 方法前,先使用isPresent 检查Optional对象是否有值。使用orElse 方法则更简洁,当Optional 对象为空时,该方法提供了一个备选值。如果计算备选值在计算上太过繁琐,即可使用orElseGet 方法。该方法接受一个Supplier 对象,只有在Optional 对象真正为空时才会调用。
• 使用为基本类型定• 制的 Lambda表达式和 Stream,如IntStream 可以显著提升系统性能。• 默认方法是指接口中定义的包含方法体的方法,方法名有 default 关键字做前缀。• 在一个值可能为空的建模情况下,使用 Optional 对象能替代使用 null 值。
三、流
从外部迭代到内部迭代
int count = 0;Iterator<Artist> iterator = allArtists.iterator();while(iterator.hasNext()) { Artist artist = iterator.next(); if (artist.isFrom("London")) { count++; }}
long count = allArtists.stream().filter(artist -> artist.isFrom("London")).count();
外部迭代从本质上来讲是一种串行化操作。使用for 循环会将行为和方法混为一谈。内部迭代调用stream() 方法,它和调用iterator() 的作用一样,但该方法不是返回一个控制迭代的Iterator 对象,而是返回内部迭代中的相应接口:Stream。Stream 是用函数式编程方式在集合类上进行复杂操作的工具。
实现机制
Stream 对象却不是一个新集合,而是创建新集合的配方。filter 只刻画出了Stream,但没有产生新的集合。
像filter 这样只描述Stream,最终不产生新集合的方法叫作惰性求值方法;而像count 这样最终会从Stream 产生值的方法叫作及早求值方法。
判断一个操作是惰性求值还是及早求值很简单:只需看它的返回值。如果返回值是Stream,那么是惰性求值;如果返回值是另一个值或为空,那么就是及早求值。使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果,这正是它的合理之处。
常用的流操作
collect(toList()) 方法由Stream 里的值生成一个列表,是一个及早求值操作。
重构遗留代码
假定选定一组专辑,找出其中所有长度大于1 分钟的曲目名称。
遗留代码:找出长度大于1 分钟的曲目public Set<String> findLongTracks(List<Album> albums) {\tSet<String> trackNames = new HashSet<>();\tfor(Album album : albums) {\t\tfor (Track track : album.getTrackList()) {\t\t\tif (track.getLength() > 60) {\t\t\t\tString name = track.getName();\t\t\t\ttrackNames.add(name);\t\t\t}\t\t}\t}\treturn trackNames;}
重构的代码public Set<String> findLongTracks(List<Album> albums) { return albums.stream() .flatMap(album -> album.getTracks()) .filter(track -> track.getLength() > 60) .map(track -> track.getName()) .collect(toSet());}
多次调用流操作
每一步强制对函数求值的缺点:• 代码可读性差,样板代码太多,隐藏了真正的业务逻辑;• 效率差,每一步都要对流及早求值,生成新的集合;• 代码充斥一堆垃圾变量,它们只用来保存中间结果,除此之外毫无用处;• 难于自动并行化处理。
高阶函数
本章中不断出现被函数式编程程序员称为高阶函数的操作。高阶函数是指接受另外一个函数作为参数,或返回一个函数的函数。
正确使用Lambda表达式
明确了要达成什么转化,而不是说明如何转化。这种方式写出的代码,潜在的缺陷更少,更直接地表达了程序员的意图。
另外一层含义在于写出的函数没有副作用。这一点非常重要,这样只通过函数的返回值就能充分理解函数的全部作用。
无论何时,将Lambda 表达式传给Stream 上的高阶函数,都应该尽量避免副作用。唯一的例外是forEach 方法,它是一个终结方法。
• 内部迭代将更多控制权交给了集合类。• 和 Iterator 类似,Stream 是一种内部迭代方式。• 将 Lambda表达式和 Stream 上的方法结合起来,可以完成很多常见的集合操作。
二、Lambda表达式
第一个Lambda表达式
button.addActionListener(event -> System.out.println("button clicked"));
代码量更少,意图更清晰,传入的是行为而非对象。
使用匿名内部类时需要显式地声明参数类型ActionEvent event,而在Lambda 表达式中无需指定类型。
如何辨别Lambda表达式
Runnable noArguments = () -> System.out.println("Hello World");
ActionListener oneArgument = event -> System.out.println("button clicked");
Runnable multiStatement = () -> {System.out.print("Hello");System.out.println(" World");};
引用值,而不是变量
String name = getUserName();name = formatUserName(name);button.addActionListener(event -> System.out.println("hi " + name));
未使用既成事实上的final 变量,导致无法通过编译。显示出错信息:local variables referenced from a Lambda expression must be final or effectively final。
这种行为也解释了为什么Lambda 表达式也被称为闭包。未赋值的变量与周边环境隔离起来,进而被绑定到一个特定的值。
函数接口
函数接口是只有一个抽象方法的接口,用作Lambda 表达式的类型。
接口中方法的命名并不重要,只要方法签名和Lambda表达式的类型匹配即可。
类型推断
一开始类型信息是有用的,但随后可以只在真正需要时才加上类型信息。
javac 根据Lambda 表达式上下文信息就能推断出参数的正确类型。
一、简介
为什么需要再次修改Java
多核CPU 的兴起。涉及锁的编程算法不但容易出错,而且耗费时间。
Java抽象级别还不够。面对大型数据集合,Java 还欠缺高效的并行操作。
面向对象编程是对数据进行抽象,而函数式编程是对行为进行抽象。
函数式编程的代码更多地表达了业务逻辑的意图,而不是它的实现机制。
函数式编程让事件处理系统变得更加简单。能将函数方便地传递也让编写惰性代码变得容易。
什么是函数式编程
每个人对函数式编程的理解不尽相同。但其核心是:在思考问题时,使用不可变值和函数,函数对一个值进行处理,映射成另一个值。
0 条评论
回复 删除
下一页