Fork me on GitHub

2022年10月

WebAssembly 学习笔记

WebAssembly(简称 WASM)是一种以安全有效的方式运行可移植程序的新兴 Web 技术,下面是引用 MDN 上对它的定义

WebAssembly 是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如 C/C++ 等语言提供一个编译目标,以便它们可以在 Web 上运行。它也被设计为可以与 JavaScript 共存,允许两者一起工作。

也就是说,无论你使用的是哪一种语言,我们都可以将其转换为 WebAssembly 格式,并在浏览器中以原生的性能运行。WebAssembly 的开发团队来自 Mozilla、Google、Microsoft 和 Apple,分别代表着四大网络浏览器 Firefox、Chrome、Microsoft Edge 和 Safari,从 2017 年 11 月开始,这四大浏览器就开始实验性的支持 WebAssembly。当时 WebAssembly 还没有形成标准,这么多的浏览器开发商对某个尚未标准化的技术 达成如此一致的意见,这在历史上是很罕见的,可以看出这绝对是一项值得关注的技术,被号称为 the future of web development

four-browsers.png

WebAssembly 在 2019 年 12 月 5 日被万维网联盟(W3C)推荐为标准,与 HTML,CSS 和 JavaScript 一起,成为 Web 的第四种语言。

WebAssembly 之前的历史

JavaScript 诞生于 1995 年 5 月,一个让人津津乐道的故事是,当时刚加入网景的 Brendan Eich 仅仅花了十天时间就开发出了 JavaScript 语言。开发 JavaScript 的初衷是为 HTML 提供一种脚本语言使得网页变得更动态,当时根本就没有考虑什么浏览器兼容性、安全性、移植性这些东西,对性能也没有特别的要求。但随着 Web 技术的发展,网页要解决的问题已经远不止简单的文本信息,而是包括了更多的高性能图像处理和 3D 渲染等方面,这时,JavaScript 的性能问题就凸显出来了。于是,如何让 JavaScript 执行的更快,变成了各大浏览器生产商争相竞逐的目标。

浏览器性能之战

这场关于浏览器的性能之战在 2008 年由 Google 带头打响,这一年的 9 月 2 日,Google 发布了一款跨时代的浏览器 Chrome,具备简洁的用户界面和极致的用户体验,内置的 V8 引擎采用了全新的 JIT 编译(Just-in-time compilation,即时编译)技术,使得浏览器的响应速度得到了几倍的提升。次年,Apple 发布了他们的浏览器新版本 Safari 4,其中引入新的 Nitro 引擎(也被称为 SquirrelFish 或 JavaScriptCore),同样使用的是 JIT 技术。紧接着,Mozilla 在 Firefox 3.5 中引入 TraceMonkey 技术,Microsoft 在 2011 年也推出 Chakra) 引擎。

使用 JIT 技术,极大的提高了 JavaScript 的性能。那么 JIT 是如何工作的呢?我们知道,JavaScript 是解释型语言,因此传统的 JavaScript 引擎需要逐行读取 JavaScript 代码,并将其翻译成可执行的机器码。很显然这是极其低效的,如果有一段代码需要执行上千次,那么 JavaScript 引擎也会傻傻的翻译上千次。JIT 技术的基本思路就是缓存,它将执行频次比较高的代码实时编译成机器码,并缓存起来,当下次执行到同样代码时直接使用相应的机器码替换掉,从而获得极大的性能提升。另外,对于执行频次非常高的代码,JIT 引擎还会使用优化编译器(Optimising Compiler)编译出更高效的机器码。关于 JIT 技术的原理可以参考 A crash course in just-in-time (JIT) compilers 这篇文章。

JIT 技术推出之后,JavaScript 的性能得到了飞速提升:

jit-performance.png

随着性能的提升,JavaScript 的应用范围也得到了极大的扩展,Web 内容变得更加丰富,图片、视频、游戏,等等等等,甚至有人将 JavaScript 用于后端开发(Node.js)。不过 JIT 也不完全是 “性能银弹”,因为通过 JIT 优化也是有一定代价的,比如存储优化后的机器码需要更多的内存,另外 JIT 优化对变量类型非常敏感,但是由于 JavaScript 动态类型 的特性,用户代码中对某个变量的类型并不会严格固定,这时 JIT 优化的效果将被大打折扣。比如下面这段简单的代码:

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}

假设 JIT 检测到 sum += arr[i]; 这行代码被执行了很多次,开始对其进行编译优化,它首先需要确认 sumarriarr[i] 这些变量的类型,如果 arr[i]int 类型,这就是整数相加的操作,但如果 arr[i]string 类型,这又变成了字符串拼接的操作,这两种情况编译成的机器码是完全不同的。所以 JIT 引擎会先根据代码执行情况假设变量为某种类型,然后再进行优化,当执行时会对类型进行检测,一旦检测到类型不同时,这个 JIT 优化将被作废,这个过程叫做 去优化(deoptimization,或者 bailing out)。假如用户写出这样的代码:

arr = [1, "hello"];

JavaScript 这种动态类型的特点对 JIT 引擎是非常不友好的,反复的优化和去优化不仅无法提高性能,甚至会有副作用。所以在实际的生产环境中,JIT 的效果往往没有那么显著,通过 JIT 的优化很快就遇到了瓶颈。

但是日益丰富的 Web 内容对 JavaScript 的性能提出了更高的要求,尤其是 3D 游戏,这些游戏在 PC 上跑都很吃力,更别说在浏览器里运行了。如何让 JavaScript 执行地更快,是摆在各大浏览器生产商面前的一大难题,很快,Google 和 Mozilla 交出了各自的答卷。

Google 的 NaCl 解决方案

Google 在 2008 年开源了 NaCl 技术,并在 2011 年的 Chrome 14 中正式启用。NaCl 的全称为 Native Client,这是一种可以在浏览器中执行原生代码(native code)的技术,听起来很像是 Microsoft 当时所使用的 ActiveX 技术,不过 ActiveX 由于其安全性一直被人所诟病。而 NaCl 定义了一套原生代码的安全子集,执行于独立的沙盒环境之中,并通过一套被称为 PPAPI(Pepper Plugin API)的接口来和 JavaScript 交互,避免了可能的安全问题。NaCl 采取了和 JIT 截然不同的 AOT 编译(Ahead-of-time compilation,即提前编译)技术,所以在性能上的表现非常突出,几乎达到了和原生应用一样的性能。不过由于 NaCl 应用是 C/C++ 语言编写的,与 CPU 架构强关联,不具有可移植性,因此需要针对不同的平台进行开发以及编译,用户使用起来非常痛苦。

为了解决这个问题,Google 在 2013 年又推出了 PNaCl 技术(Portable Native Client),PNaCl 的创新之处在于使用 LLVM IR(Intermediate Representation)来分发应用,而不是直接分发原生代码,LLVM IR 也被称为 Bitcode,它是一种平台无关的中间语言表示,实现了和 Java 一样的目标:一次编译,到处运行。

如果我们站在今天的视角来看,PNaCl 这项技术是非常超前的,它的核心理念和如今的 WebAssembly 如出一辙,只不过它出现的时机不对,当时很多人都对在浏览器中执行原生代码持怀疑态度,担心可能出现和 ActiveX 一样的安全问题,而且当时 HTML5 技术正发展的如火如荼,人们都在想着如何从浏览器中移除诸如 Flash 或 Java Applet 这些 JavaScript 之外的技术,所以 PNaCl 技术从诞生以来,一直不温不火,尽管后来 Firefox 和 Opera 等浏览器也开始支持 NaCl 和 PPAPI,但是一直无法得到普及(当时的 IE 还占领着浏览器市场的半壁江山)。

随着 WebAssembly 技术的发展,Google Chrome 最终在 2018 年移除了对 PNaCl 的支持,决定全面拥抱 WebAssembly 技术。

Mozilla 的 asm.js 解决方案

2010 年,刚刚加入 Mozilla 的 Alon Zakai 在工作之余突发奇想,能不能将自己编写的 C/C++ 游戏引擎运行在浏览器上?当时 NaCl 技术还没怎么普及,Alon Zakai 一时之间并没有找到什么好的技术方案。好在 C/C++ 是强类型语言,JavaScript 是弱类型语言,所以将 C/C++ 代码转换为 JavaScript 在技术上是完全可行的。Alon Zakai 于是便开始着手编写这样的一个编译器,Emscripten 便由此诞生了!

Emscripten 和传统的编译器很类似,都是将某种语言转换为另一种语言形式,不过他们之间有着本质的区别。传统的编译器是将一种语言编译成某种 low-level 的语言,比如将 C/C++ 代码编译成二进制文件(机器码),这种编译器被称为 Compiler;而 Emscripten 是将 C/C++ 代码编译成和它 same-level 的 JavaScript 代码,这种编译器被称为 Transpiler 或者 Source to source compiler

Emscripten 相比于 NaCl 来说兼容性更好,于是很快就得到了 Mozilla 的认可。之后 Alon Zakai 被邀请加入 Mozilla 的研究团队并全职负责 Emscripten 的开发,以及通过 Emscripten 编译生成的 JavaScript 代码的性能优化上。在 2013 年,Alon Zakai 联合 Luke Wagner,David Herman 一起发布了 asm.js 规范,同年,Mozilla 也发布了 Firefox 22,并内置了新一代的 OdinMonkey 引擎,它是第一个支持 asm.js 规范的 JavaScript 引擎。

asm.js 的思想很简单,就是尽可能的在 JavaScript 中使用类型明确的参数,并通过 TypedArray 取消了垃圾回收机制,这样可以让 JIT 充分利用和优化,进而提高 JavaScript 的执行性能。比如下面这样一段 C 代码:

int f(int i) {
  return i + 1;
}

使用 Emscripten 编译生成的 JavaScript 代码如下:

function f(i) {
  i = i|0;
  return (i + 1)|0;
}

通过在变量和返回值后面加上 |0 这样的操作,我们明确了参数和返回值的数据类型,当 JIT 引擎检测到这样的代码时,便可以跳过语法分析和类型推断这些步骤,将代码直接转成机器语言。据称,使用 asm.js 能达到原生代码 50% 左右的速度,虽然没有 NaCl 亮眼,但是这相比于普通的 JavaScript 代码而言已经是极大的性能提升了。而且我们可以看出 asm.js 采取了和 NaCl 截然不同的思路,asm.js 其实和 JavaScript 没有区别,它只是 JavaScript 的一个子集而已,这样做不仅可以充分发挥出 JIT 的最大功效,而且能兼容所有的浏览器。

但是 asm.js 也存在着不少的问题。首先由于它还是和 JavaScript一样是文本格式,因此加载和解析都会花费比较长的时间,这被称为慢启动问题;其次,asm.js 除了在变量后面加 |0 之外,还有很多类似这样的标注代码:

asmjs.png

很显然,这让代码的可读性和可扩展性都变的很差;最后,仍然是性能问题,通过 asm.js 无论怎么优化最终生成的都还是 JavaScript 代码,性能自然远远比不上原生代码;因此这并不是一个非常理想的技术方案。

其他解决方案

除了 NaCl 和 asm.js,实际上还有一些其他的解决方案,但最终的结果要么夭折,要么被迫转型。其中值得一提的是 Google 发明的 Dart 语言,Dart 语言的野心很大,它最初的目的是要取代 JavaScript 成为 Web 的首选语言,为此 Google 还开发了一款新的浏览器 Dartium,内置 Dart 引擎可以执行 Dart 程序,而且对于不支持 Dart 程序的浏览器,它还提供了相应的工具将 Dart 转换为 JavaScript。这一套组合拳可谓是行云流水,可是结果如何可想而知,不仅很难得到用户的承认,而且也没得到其他浏览器的认可,最终 Google 在 2015 年取消了该计划。目前 Dart 语言转战移动开发领域,比如跨平台开发框架 Flutter 就是采用 Dart 开发的。

WebAssembly = NaCl + asm.js

随着技术的发展,Mozilla 和 Google 的工程师出现了很多次的交流和合作,通过汲取 NaCl 和 asm.js 两者的优点,双方推出了一种全新的技术方案:

  • 和 NaCl/PNaCl 一样,基于二进制格式,从而能够被快速解析,达到原生代码的运行速度;
  • 和 PNaCl 一样,依赖于通用的 LLVM IR,这样既具备可移植性,又便于其他语言快速接入;
  • 和 asm.js 一样,使用 Emscripten 等工具链进行编译;另外,Emscripten 同时支持生成 asm.js 和二进制格式,当浏览器不兼容新的二进制格式时,asm.js 可以作为降级方案;
  • 和 asm.js 一样,必须以非常自然的方式直接操作 Web API,而不用像 PNaCl 一样需要处理与 JavaScript 之间的通信;

这个技术方案在 2015 年正式命名为 WebAssembly,2017 年各大浏览器生产商纷纷宣布支持 WebAssembly,2019 年 WebAssembly 正式成为 W3C 标准,一场关于浏览器的性能革命已经悄然展开。

wasm-performance.png

WebAssembly 入门示例

从上面的学习中我们知道,WebAssembly 是一种通用的编码格式,并且已经有很多编程语言支持将源码编译成这种格式了,官方的 Getting Started 有一个详细的列表。这一节我们就跟着官方的教程实践一下下面这三种语言:

将 C/C++ 程序编译成 WebAssembly

首先我们参考 Emscripten 的官方文档 上的步骤下载并安装 Emscripten SDK,安装完成后通过下面的命令检查环境是否正常:

$ emcc --check
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.24 (68a9f990429e0bcfb63b1cde68bad792554350a5)
shared:INFO: (Emscripten: Running sanity checks)

环境准备就绪后,我们就可以将 C/C++ 的代码编译为 WebAssembly 了。写一个简单的 Hello World 程序 hello.c

#include <stdio.h>

int main() {
    printf("Hello World\n");
    return 0;
}

然后使用 emcc 进行编译:

$ emcc hello.c -o hello.html

上面这个命令会生成三个文件:

  • hello.wasm - 这就是生成的 WebAssembly 二进制字节码文件
  • hello.js - 包含一段胶水代码(glue code)通过 JavaScript 来调用 WebAssembly 文件
  • hello.html - 方便开发调试,在页面上显示 WebAssembly 的调用结果

我们不能直接用浏览器打开 hello.html 文件,因为浏览器不支持 file:// 形式的 XHR 请求,所以在 HTML 中无法加载 .wasm 等相关的文件,为了看到效果,我们需要一个 Web Server,比如 Nginx、Tomcat 等,不过这些安装和配置都比较麻烦,我们还有很多其他的方法快速启动一个 Web Server。

比如通过 npm 启动一个本地 Web Server:

$ npx serve .

或者使用 Python3 的 http.server 模块:

$ python3 -m http.server

访问 hello.html 页面如下:

hello-html.png

可以看到我们在 C 语言中打印的 Hello World 成功输出到浏览器了。

另外,我们也可以将 C 语言中的函数暴露出来给 JavaScript 调用。默认情况下,Emscripten 生成的代码只会调用 main() 函数,其他函数忽略。我们可以使用 emscripten.h 中的 EMSCRIPTEN_KEEPALIVE 来暴露函数,新建一个 greet.c 文件如下:

#include <stdio.h>
#include <emscripten/emscripten.h>

int main() {
    printf("Hello World\n");
    return 0;
}

#ifdef __cplusplus
#define EXTERN extern "C"
#else
#define EXTERN
#endif

EXTERN EMSCRIPTEN_KEEPALIVE void greet(char* name) {
    printf("Hello, %s!\n", name);
}

上面的代码定义了一个 void greet(char* name) 函数,为了让这个函数可以在 JavaScript 中调用,编译时还需要指定 NO_EXIT_RUNTIMEEXPORTED_RUNTIME_METHODS 参数,将 ccall 导出来:

$ emcc -o greet.html greet.c -s NO_EXIT_RUNTIME=1 -s EXPORTED_RUNTIME_METHODS=ccall

greet.html 文件和上面的 hello.html 几乎是一样的,我们在该文件中加几行代码来测试我们的 greet() 函数,首先加一个按钮:

<button id="mybutton">Click me!</button>

然后为它添加点击事件,可以看到 JavaScript 就是通过上面导出的 ccall 来调用 greet() 函数的:

document.getElementById("mybutton").addEventListener("click", () => {
  const result = Module.ccall(
    "greet",         // name of C function
    null,            // return type
    ["string"],      // argument types
    ["WebAssembly"]  // arguments
  );
});

除了 ccall,我们还可以使用 -s EXPORTED_RUNTIME_METHODS=ccall,cwrap 同时导出 ccallcwrap 函数。ccall 的作用是直接调用某个 C 函数,而 cwrap 是将 C 函数编译为一个 JavaScript 函数,并可以反复调用,这在正式项目中更实用。

点击这个按钮,可以在页面和控制台上都看到 greet() 函数打印的内容:

greet-html.png

将 Rust 程序编译成 WebAssembly

首先按照官方文档 安装 Rust,安装包含了一系列常用的命令行工具,包括 rustuprustccargo 等,其中 cargo 是 Rust 的包管理器,可以使用它安装 wasm-pack

$ cargo install wasm-pack

wasm-pack 用于将 Rust 代码编译成 WebAssembly 格式,不过要注意它不支持 bin 项目,只支持 lib 项目,所以我们通过 --lib 来创建项目:

$ cargo new --lib rust-demo
     Created library `rust-demo` package

打开 ./src/lib.rs,输入以下代码:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    pub fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

在上面的代码中我们使用了 wasm-bindgen 这个工具,它实现了 JavaScript 和 Rust 之间的相互通信,关于它的详细说明可以参考 《The wasm-bindgen Guide》 这本电子书。我们首先通过 extern 声明了一个 JavaScript 中的 alert() 函数,然后我们就可以像调用正常的 Rust 函数一样调用这个外部函数。下面再通过 pub fngreet() 函数暴露出来,这样我们也可以从 JavaScript 中调用这个 Rust 函数。

接着修改 ./Cargo.toml 文件,添加如下内容:

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

其中 crate-type = ["cdylib"] 表示生成一个 动态系统库。使用 wasm-pack 进行构建:

$ wasm-pack build --target web

这个命令会生成一个 pkg 目录,里面包含了 wasm 文件和对应的 JavaScript 胶水代码,这和上面的 emcc 结果类似,不过并没有生成相应的测试 HTML 文件。我们手工创建一个 index.html 文件,内容如下:

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>hello-wasm example</title>
  </head>
  <body>
    <script type="module">
      import init, { greet } from "./pkg/rust_demo.js";
      init().then(() => {
        greet("WebAssembly");
      });
    </script>
  </body>
</html>

然后启动一个 Web Server,并在浏览器中打开测试页面:

rust-demo-html.png

我们成功在浏览器中调用了使用 Rust 编写的 greet() 函数!

将 Go 程序编译成 WebAssembly

首先确保你已经 安装了 Go

$ go version
go version go1.19 linux/amd64

使用 go mod init 初始化模块:

$ mkdir go-demo && cd go-demo
$ go mod init com.example

新建一个 main.go 文件:

package main

import "fmt"

func main() {
    fmt.Println("Hello, WebAssembly!")
}

使用 go build 可以将它编译成可执行文件,通过在命令之前指定 GOOS=js GOARCH=wasm 可以将它编译成 WebAssembly 文件:

$ GOOS=js GOARCH=wasm go build -o main.wasm

和上面的 C 语言或 Rust 语言的例子一样,为了测试这个 main.wasm 文件,我们还需要 JavaScript 胶水代码和一个测试 HTML 文件。Go 的安装目录下自带了一个 wasm_exec.js 文件,我们将其拷贝到当前目录:

$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

然后创建一个 index.html 文件(也可以直接使用 Go 自带的 wasm_exec.html 文件):

<html>
  <head>
    <meta charset="utf-8"/>
      <script src="wasm_exec.js"></script>
      <script>
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
          go.run(result.instance);
        });
      </script>
  </head>
  <body></body>
</html>

启动 Web Server 后在浏览器中打开该页面:

go-demo-html.png

在控制台中我们就可以看到程序运行的结果了。

除了在浏览器中测试 WebAssembly 文件,也可以使用 Go 安装目录自带的 go_js_wasm_exec 工具来运行它:

$ $(go env GOROOT)/misc/wasm/go_js_wasm_exec ./main.wasm
Hello, WebAssembly!

或者 go run 时带上 -exec 参数来运行:

$ GOOS=js GOARCH=wasm go run -exec="$(go env GOROOT)/misc/wasm/go_js_wasm_exec" .
Hello, WebAssembly!

运行这个命令需要安装 Node.js v12 以上的版本,打开 go_js_wasm_exec 文件可以看到它实际上就是执行 node wasm_exec_node.js 这个命令。

上面的例子是直接在 JavaScript 中执行 Go 程序,如果我们需要将 Go 中的函数导出给 JavaScript 调用,可以通过 syscall/js 来实现:

package main

import (
    "syscall/js"
)

func addFunction(this js.Value, p []js.Value) interface{} {
    sum := p[0].Int() + p[1].Int()
    return js.ValueOf(sum)
}

func main() {
    js.Global().Set("add", js.FuncOf(addFunction))
    select {} // block the main thread forever
}

注意在 main() 函数中我们使用 select {} 将程序阻塞住,防止程序退出,否则 JavaScript 在调用 Go 函数时会报下面这样的错误:

wasm_exec.js:536 Uncaught Error: Go program has already exited
    at globalThis.Go._resume (wasm_exec.js:536:11)
    at wasm_exec.js:549:8
    at <anonymous>:1:1

由于 add 函数是直接添加到 js.Global() 中的,我们可以直接通过 window.add 来访问它:

go-add-html.png

js.Global() 为我们提供了一个 Go 和 JavaScript 之间的桥梁,我们不仅可以将 Go 函数暴露给 JavaScript 调用,甚至可以通过 js.Global() 来操作 DOM:

func hello(this js.Value, args []js.Value) interface{} {
    doc := js.Global().Get("document")
    h1 := doc.Call("createElement", "h1")
    h1.Set("innerText", "Hello World")
    doc.Get("body").Call("append", h1)
    return nil
}

除了官方的 go build 可以将 Go 程序编译成 WebAssembly 文件,你也可以尝试使用 TinyGo,这是 Go 语言的一个子集实现,它对 Go 规范做了适当的裁剪,只保留了一些比较重要的库,这让它成为了一种更加强大和高效的语言,你可以在意想不到的地方运行它(比如很多物联网设备)。另外,使用 TinyGo 编译 WebAssembly 还有一个很大的优势,它编译出来的文件比 Go 官方编译出来的文件小得多(上面的例子中 C/C++ 或 Rust 编译出来的 wasm 文件只有 100~200K,而 Go 编译出来的 wasm 文件竟然有 2M 多)。

WebAssembly 文本格式

上面我们使用了三种不同的编程语言来体验 WebAssembly,学习了如何编译,以及如何在浏览器中使用 JavaScript 调用它。不过这里有一个问题,由于 wasm 文件是二进制格式,对我们来说是完全黑盒的,不像 JavaScript 是纯文本的,我们可以方便地通过浏览器自带的开发者工具对其进行调试,而 wasm 如果调用出问题,我们将很难排查。实际上,WebAssembly 在设计之初就已经考虑了这样的问题,所以它不仅具有 二进制格式,而且还有一种类似于汇编语言的 文本格式,方便用户查看、编辑和调试。

下面是 WebAssembly 文本格式的一个简单例子:

(module
  (func $add (param $lhs i32) (param $rhs i32) (result i32)
    local.get $lhs
    local.get $rhs
    i32.add)
  (export "add" (func $add))
)

WebAssembly 代码中的基本单元是一个模块,每个模块通过一个大的 S-表达式 来表示,S-表达式是一种嵌套结构,实际上它是树的一种表示形式。上面的代码首先通过 (module) 定义了一个模块,然后模块中使用 (func $add (param $lhs i32) (param $rhs i32) (result i32)) 定义了一个 add() 函数,这个 S-表达式转换为比较好理解的形式就是 i32 add(i32 lhs, i32 rhs),最后通过 (export "add" (func $add)) 将该函数暴露出来,关于这段代码的详细解释可以参考 Mozilla 官方文档中的 Understanding WebAssembly text format

我们将上面的代码保存到 add.wat 文件中,并通过 WABT 工具包(The WebAssembly Binary Toolkit)中的 wat2wasm 将其转换为 wasm 格式:

$ wat2wasm add.wat -o add.wasm

使用下面的 JavaScript 脚本加载 wasm 并调用 add() 函数:

fetchAndInstantiate('add.wasm').then(function(instance) {
    console.log(instance.exports.add(1, 2));  // "3"
});

// fetchAndInstantiate() found in wasm-utils.js
function fetchAndInstantiate(url, importObject) {
    return fetch(url).then(response =>
    response.arrayBuffer()
    ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
    ).then(results =>
    results.instance
    );
}

将这段 JavaScript 脚本放在一个 HTML 文件中,然后启动 Web Server 访问,可以看到控制台输出了 3,也就是 add(1, 2) 的结果,并且我们还可以通过 Chrome 提供的 开发者工具对 wasm 文件进行调试

wasm-debug.png

参考

  1. WebAssembly 官网
  2. WebAssembly | MDN
  3. WebAssembly 中文网
  4. WebAssembly Design Documents
  5. WebAssembly Specification
  6. WebAssembly - 维基百科
  7. asm.js 和 Emscripten 入门教程
  8. 浏览器是如何工作的:Chrome V8 让你更懂JavaScript
  9. WebAssembly完全入门——了解wasm的前世今身
  10. 浅谈WebAssembly历史
  11. A cartoon intro to WebAssembly Articles
  12. 一个白学家眼里的 WebAssembly
  13. 使用 Docker 和 Golang 快速上手 WebAssembly
  14. 如何评论浏览器最新的 WebAssembly 字节码技术?
  15. 如何看待 WebAssembly 这门技术?
  16. 系统学习WebAssembly(1) —— 理论篇
  17. 快 11K Star 的 WebAssembly,你应该这样学
  18. WebAssembly 与 JIT
  19. WebAssembly 初步探索
  20. WebAssembly 實戰 – 讓 Go 與 JS 在瀏覽器上共舞

更多

在非浏览器下运行 WebAssembly

WebAssembly 最早只应用于 Web 浏览器中,但鉴于它所拥有 可移植、安全及高效 等特性,WebAssembly 也被逐渐应用在 Web 领域之外的一些其他场景中,并为此提出了一项新的接口标准 —— WASI(WebAssembly System Interface)

要让 WebAssembly 跑在非 Web 环境下,我们必须有一款支持 WASI 接口的运行时(WASI runtime),目前比较流行的有:wasttimewasmerWasmEdge 等,这些运行时提供了不同编程语言的 SDK,可以使得我们在各种不同的语言中调用 WebAssembly 模块。

使用 WABT 工具包

WABT 工具包中除了上文所使用的 wat2wasm 之外,还提供了很多其他有用的工具:

扫描二维码,在手机上阅读!

Java 8 之 Stream API 用法总结

Java 编程语言发展迅速,从 Java 9 开始,Java 采取了小步迭代的发布方式,以每 6 个月发布一个版本的速度在持续更新,目前最新的版本已经升到 19 了

java-versions.png

尽管如此,据 JRebel 2022 年发布的 Java 开发者生产力报告 显示,Java 8 作为第一个 LTS 版本(另两个是 Java 11 和 17),仍然是使用最多的一个版本。

java-version-usage.png

Java 8 由 Oracle 公司于 2014 年 3 月 18 日发布,在这个版本中新增了大量的特性,首次引入了 Lambda 表达式和方法引用,开启了 Java 语言函数式编程的大门,其中新增的 Stream API(java.util.stream)特性更是将函数式编程发挥到了淋漓尽致的地步。

Stream API 概述

在 Java 8 之前,处理集合数据的常规方法是 for 循环:

List<String> words = List.of("A", "B", "C");
for (String word: words) {
    System.out.println(word.toLowerCase());
}

或者使用 iterator 迭代器:

List<String> words = List.of("A", "B", "C");
Iterator<String> iterator = words.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next().toLowerCase());
}

这种集合的遍历方式被称为 外部迭代,也就是说由用户来决定 “做什么”(大写转小写) 和 “怎么做”(通过 foriterator 遍历)。

而在 Java 8 中,新增的 Stream API 通过 内部迭代 来处理集合数据,使用了 访问者设计模式(Visitor Pattern),用户只需要通过函数式的方法提供 “做什么” 即可,“怎么做” 交给 Stream API 内部实现:

List<String> words = List.of("A", "B", "C");
words.stream().forEach(word -> System.out.println(word.toLowerCase()));

使用内部迭代可以让用户更聚焦待解决的问题,编写代码不易出错,而且通常编写的代码更少也更易读。这是 Stream API 的一大特征。其实,上面的两种代码实际上对应着两种截然不同的编程风格,那种用户需要关注怎么做,需要 step-by-step 地告诉计算机执行细节的编程风格,被称为 命令式编程(Imperative programming),而用户只关注做什么,只需要告诉计算机想要什么结果,计算过程由计算机自己决定的编程风格,被称为 声明式编程(Declarative programming)

另外,正如 Stream API 的名字一样,Stream API 中有很多方法都会返回流对象本身,于是我们就可以将多个操作串联起来形成一个管道(pipeline),写出下面这样流式风格(fluent style)的代码:

List<String> names = students.stream()
    .filter(s -> s.getScore() >= 60)
    .sorted((x, y) -> x.getScore() - y.getScore())
    .map(Student::getName)
    .collect(Collectors.toList());

Stream API 使用

流的创建

JDK 中提供了很多途径来创建一个流,这一节总结一些常用的创建流的方法。流有一个很重要的特性:不会对数据源进行修改,所以我们可以对同一个数据源创建多个流。

创建一个空流

我们可以通过 Stream.empty() 创建一个不包含任何数据的空流:

Stream<String> streamEmpty = Stream.empty();

在代码中使用空指针是一种不好的编程风格,空流的作用就是为了避免在程序中返回空指针:

public Stream<String> streamOf(List<String> list) {
    return list == null || list.isEmpty() ? Stream.empty() : list.stream();
}

从集合类创建流

JDK 中自带了大量的集合类,比如 ListSetQueue 以及它们的子类,这些类都继承自 Collection 接口:

jdk-collections.gif

注意 Map 不是集合类,但是 Map 中的 keySet()values()entrySet() 方法返回的是集合类。

我们可以通过任何一个集合类的 stream() 方法创建一个流:

List<String> collection = Arrays.asList("a", "b", "c");
Stream<String> streamOfCollection = collection.stream();

从数组创建流

数组和集合类都是用于存储多个对象,只不过数组的长度固定,而集合的长度可变。我们可以使用 Arrays.stream() 静态方法从一个数组创建流:

String[] array = new String[]{"a", "b", "c"};
Stream<String> streamOfArray = Arrays.stream(array);

也可以使用 Stream.of() 方法来创建:

Stream<String> streamOfArray2 = Stream.of(array);

由于 Stream.of() 函数的入参定义是一个可变参数,本质上是个数组,所以既可以像上面那样传入一个数组,也可以直接传入数组元素创建:

Stream<String> streamOfArray3 = Stream.of("a", "b", "c");

使用 Stream.builder() 手工创建流

有时候流中的数据不是来自某个数据源,而是需要手工添加,我们可以使用 Stream.builder() 方法手工创建流:

Stream<String> streamOfBuilder = Stream.<String>builder()
    .add("a")
    .add("b")
    .add("c")
    .build();

也可以往 builder 中依次添加:

Stream.Builder<String> builder = Stream.<String>builder();
builder.add("a");
builder.add("b");
builder.add("c");
Stream<String> streamOfBuilder2 = builder.build();

使用 Stream.generate() 生成流

Stream.generate() 方法也可以用于手工创建流,这个方法需要提供一个 Supplier<T> 的实现,生成的是一个无限流,一般通过 limit 来限定数量:

Stream<String> streamOfGenerate = Stream.generate(() -> "hello").limit(3);

上面的例子中通过 Lambda 表达式 () -> "hello" 一直生成 hello 字符串。如果要生成不一样的数据,可以将变量传到 Lambda 表达式中,比如下面的例子生成 1 2 3 这样的连续整数:

AtomicInteger num = new AtomicInteger(0);
Stream<Integer> streamOfGenerate2 = Stream.generate(() -> num.incrementAndGet()).limit(3);

使用 Stream.iterate() 生成流

在上面的例子中,我们通过将变量传到 Lambda 表达式来生成一个整数数列,像这种根据迭代来生成数据的场景,还有一种更简单的实现:

Stream<Integer> streamOfIterate = Stream.iterate(1, n -> n + 1).limit(3);

iterate() 函数第一个参数为流的第一个元素,后续的元素通过第二个参数中的 UnaryOperator<T> 来迭代生成。

生成基础类型的流

由于 Stream<T> 接口使用了泛型,它的类型参数只能是对象类型,所以我们无法生成基础类型的流,我们只能使用相应的封装类型来生成流,这样就会导致自动装箱和拆箱(auto-boxing),影响性能。

于是 JDK 提供了几个特殊的接口来方便我们创建基础类型的流。JDK 一共有 8 个基础类型,包括 4 个整数类型(byteshortintlong),2 个浮点类型(floatdouble),1 个字符型(char)和 1 个布尔型(boolean),不过只提供了 3 个基础类型的流:IntStreamLongStreamDoubleStream

基础类型流和普通流接口基本一致,我们可以通过上面介绍的各种方法来创建基础类型流。JDK 还针对不同的基础类型提供了相应的更便捷的生成流的方法,比如 IntStream.range() 函数用于方便的生成某个范围内的整数序列:

IntStream intStream = IntStream.range(1, 4);

要注意的是这个数列是左闭右开的,不包含第二个参数,IntStream.rangeClosed() 函数生成的数列是左右都是闭区间:

IntStream intStream2 = IntStream.rangeClosed(1, 3);

此外,Random 类也提供了一些生成基础类型流的方法,比如下面的代码生成 3 个随机的 int 型整数:

IntStream intStream = new Random().ints(3);

生成随机的 longdouble 类型:

LongStream longStream = new Random().longs(3);
DoubleStream doubleStream = new Random().doubles(3);

使用 String.chars() 生成字符流

String 类提供了一个 chars() 方法,用于从字符串生成字符流,正如上面所说,JDK 只提供了 IntStreamLongStreamDoubleStream 三种基础类型流,并没有 CharStream 一说,所以返回值使用了 IntStream

IntStream charStream = "abc".chars();

使用 Pattern.splitAsStream() 生成字符串流

我们知道,String 类里有一个 split() 方法可以将一个字符串分割成子串,但是返回值是一个数组,如果要生成一个子串流,可以使用正则表达式包中 Pattern 类的 splitAsStream() 方法:

Stream<String> stringStream = Pattern.compile(", ").splitAsStream("a, b, c");

从文件生成字符串流

另外,Java NIO 包中的 Files 类提供了一个 lines() 方法,它依次读取文件的每一行并生成字符串流:

try (Stream<String> stringStream = Files.lines(Paths.get(filePath + "test.txt"));) {
    stringStream.forEach(System.out::println);
}

注意使用 try-with-resources 关闭文件。

中间操作

上一节主要介绍了一些常用的创建流的方法,流一旦创建好了,就可以对流执行各种操作。我们将对流的操作分成两种类型:中间操作(Intermediate operation)结束操作(Terminal operation),所有的中间操作返回的结果都是流本身,所以可以写出链式的代码,而结束操作会关闭流,让流无法再访问。

中间操作又可以分成 无状态操作(Stateless operation)有状态操作(Stateful operation) 两种,无状态是指元素的处理不受前面元素的影响,而有状态是指必须等到所有元素处理之后才知道最终结果。

下面通过一些实例来演示不同操作的具体用法,首先创建一个流,包含一些学生数据:

Stream<Student> students = Stream.of(
    Student.builder().name("张三").gender("男").age(27).number(3L).interests("画画、篮球").build(),
    Student.builder().name("李四").gender("男").age(29).number(2L).interests("篮球、足球").build(),
    Student.builder().name("王二").gender("女").age(27).number(1L).interests("唱歌、跳舞、画画").build(),
    Student.builder().name("麻子").gender("女").age(31).number(4L).interests("篮球、羽毛球").build()
);

无状态操作

filter

filter 用于对数据流进行过滤,它接受一个 Predicate<? super T> predicate 参数,返回符合该 Predicate 条件的元素:

students = students.filter(s -> s.getAge() > 30);
map / mapToInt / mapToLong / mapToDouble

map 接受一个 Function<? super T, ? extends R> mapper 类型的参数,对数据流的类型进行转换,从 T 类型转换为 R 类型,比如下面的代码将数据流 Stream<Student> 转换为 Stream<StudentDTO>

Stream<StudentDTO> studentDTOs = students.map(s -> {
    return StudentDTO.builder().name(s.getName()).age(s.getAge()).build();
});

如果要转换成基本类型流,可以使用 mapToIntmapToLongmapToDouble 方法:

LongStream studentAges = students.mapToLong(s -> s.getAge());

上面的 Lambda 也可以写成方法引用:

LongStream studentAges2 = students.mapToLong(Student::getAge);
flatMap / flatMapToInt / flatMapToLong / flatMapToDouble

flatMap 接受一个 Function<? super T, ? extends Stream<? extends R>> mapper 类型的参数,和 map 不同的是,他将 T 类型转换为 R 类型的流,而不是转换为 R 类型,然后再将流中所有数据平铺得到最后的结果:

Stream<String> studentInterests = students.flatMap(s -> Arrays.stream(s.getInterests().split("、")));

每个学生可能有一个或多个兴趣,使用 分割,上面的代码首先将每个学生的兴趣拆开得到一个字符串流,然后将流中的元素平铺,最后得到汇总后的字符串流,该流中包含了所有学生的所有兴趣(元素可能重复)。可以看出 flatMap 实际上是对多个流的数据进行合并。

peek

peek 一般用来调试,它接受一个 Consumer<? super T> action 参数,可以在流的计算过程中对元素进行处理,无返回结果,比如打印出元素的状态:

Stream<String> studentNames = students.filter(s -> s.getAge() > 20)
    .peek(System.out::println)
    .map(Student::getName)
    .peek(System.out::println);
unordered

相遇顺序(encounter order) 是流中的元素被处理时的顺序,创建流的数据源决定了流是否有序,比如 List 或数组是有序的,而 HashSet 是无序的。一些中间操作也可以修改流的相遇顺序,比如 sorted() 用于将无序流转换为有序,而 unordered() 也可以将一个有序流变成无序。

对于 串行流(sequential streams),相遇顺序并不会影响性能,只会影响确定性。如果一个流是有序的,每次执行都会得到相同的结果,如果一个流是无序的,则可能会得到不同的结果。

不过根据官方文档的说法,我使用 unordered() 将一个流改成无序流,重复执行得到的结果还是一样的 [2, 4, 6],并没有得到不同的结果:

List<Integer> ints = Stream.of(1, 2, 3).unordered()
   .map(x -> x*2)
   .collect(Collectors.toList());

网上有说法 认为,这是因为 unordered() 并不会打乱流原本的顺序,只会 消除流必须保持有序的约束,从而允许后续操作使用不必考虑排序的优化。

对于 并行流(parallel streams),去掉有序约束后可能会提高流的执行效率,有些聚合操作,比如 distinct()Collectors.groupingBy() 在不考虑元素有序时具备更好的性能。

有状态操作

distinct

distinct() 方法用于去除流中的重复元素:

Stream<Integer> intStream = Stream.of(1, 2, 3, 2, 4, 3, 1, 2);
intStream = intStream.distinct();

distinct() 是根据流中每个元素的 equals() 方法来去重的,所以如果流中是对象类型,可能需要重写其 equals() 方法。

sorted

sorted() 方法根据 自然序(natural order) 对流中的元素进行排序,流中的元素必须实现 Comparable 接口:

Stream<Integer> intStream = Stream.of(1, 3, 2, 4);
intStream = intStream.sorted();

如果流中的元素没有实现 Comparable 接口,我们可以提供一个比较器 Comparator<? super T> comparator 对流进行排序:

students = students.sorted(new Comparator<Student>() {

    @Override
    public int compare(Student o1, Student o2) {
        return o1.getAge().compareTo(o2.getAge());
    }
    
});

上面是通过匿名内部类的方式创建了一个比较器,我们可以使用 Lambda 来简化它的写法:

students = students.sorted((o1, o2) -> o1.getAge().compareTo(o2.getAge()));

另外,Comparator 还内置了一些静态方法可以进一步简化代码:

students = students.sorted(Comparator.comparing(Student::getAge));

甚至可以组合多个比较条件写出更复杂的排序逻辑:

students = students.sorted(
    Comparator.comparing(Student::getAge).thenComparing(Student::getNumber)
);
skip / limit

skiplimit 这两个方法有点类似于 SQL 中的 LIMIT offset, rows 语句,用于返回指定的记录条数,最常见的一个用处是用来做分页查询。

Stream<Integer> intStream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
intStream = intStream.skip(3).limit(3);
dropWhile / takeWhile

dropWhiletakeWhile 这两个方法的作用也是返回指定的记录条数,只不过条数不是固定的,而是根据某个条件来决定返回哪些元素:

Stream<Integer> intStream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
intStream = intStream.dropWhile(x -> x <= 3).takeWhile(x -> x <= 6);

结束操作

流的中间操作其实只是一个标记,它是延迟执行的,要等到结束操作时才会触发实际的计算,而且每个流只能有一个结束操作。结束操作会关闭流,对已经关闭的流再执行操作会抛出 IllegalStateException 异常。

结束操作也可以分成两种类型:短路操作(Short-Circuit operation)非短路操作(Non-Short-Circuit operation),短路操作是指不用处理全部元素就可以返回结果,它必须一个元素处理一次,而非短路操作可以批量处理数据,但是需要等全部元素都处理完才会返回结果。

短路操作

anyMatch / allMatch / nonMatch

这几个 match 方法非常类似,它们都接受一个 Predicate<? super T> predicate 条件,用于判断流中元素是否满足某个条件。

anyMatch 表示只要有一个元素满足条件即返回 true

boolean hasAgeGreaterThan30 = students.anyMatch(s -> s.getAge() > 30);

allMatch 表示所有元素都满足条件才返回 true

boolean allAgeGreaterThan20 = students.allMatch(s -> s.getAge() > 20);

noneMatch 表示所有元素都不满足条件才返回 true

boolean noAgeGreaterThan40 = students.noneMatch(s -> s.getAge() > 40);
findFirst / findAny

这两个 find 方法也是非常类似,都是从流中返回一个元素,如果没有,则返回一个空的 Optional,它们经常和 filter 方法联合使用。

findFirst 用于返回流中第一个元素:

// 返回的是 李四
Optional<Student> student = students.filter(s -> s.getAge() > 28).findFirst();

findAny() 返回的元素是不确定的,如果是串行流,返回的是第一个元素:

// 返回的是 李四
Optional<Student> student = students.filter(s -> s.getAge() > 28).findAny();

如果是并行流,则返回值是随机的:

// 返回不确定
Optional<Student> student = students.parallel().filter(s -> s.getAge() > 28).findAny();

非短路操作

forEach / forEachOrdered

这两个 forEach 方法有点类似于 peek 方法,都是接受一个 Consumer<? super T> action 参数,对流中每一个元素进行处理,只不过 forEach 是结束操作,而 peek 是中间操作。

intStream.forEach(System.out::println);

这两个方法的区别在于 forEach 的处理顺序是不确定的,而 forEachOrdered 会按照流中元素的 相遇顺序(encounter order) 来处理。比如下面的代码:

intStream.parallel().forEach(System.out::println);

由于这里使用了并行流,forEach 输出结果是随机的。如果换成 forEachOrdered,则会保证输出结果是有序的:

intStream.parallel().forEachOrdered(System.out::println);
toArray

toArray 方法用于将流转换为一个数组,默认情况下数组类型是 Object[]

Object[] array = students.toArray();

如果要转换为确切的对象类型,toArray 还接受一个 IntFunction<A[]> generator 参数,也是数组的构造函数:

Student[] array = students.toArray(Student[]::new);
reduce

在英语中 reduce 这个单词的意思是 “减少、缩小”,顾名思义,reduce 方法的作用也是如此,它会根据某种规则依次处理流中的元素,经过计算与合并后返回一个唯一的值。早在 2004 年,Google 就研究并提出了一种面向大规模数据处理的并行计算模型和方法,被称为 MapReduce,这里的 Map 表示 映射,Reduce 表示 规约,它们和 Java Stream API 中的 mapreduce 方法有着异曲同工之妙,都是从函数式编程语言中借鉴的思想。

reduce 方法有三种不同的函数形式,第一种也是最简单的:

Optional<T> reduce(BinaryOperator<T> accumulator);

它接受一个 BinaryOperator<T> accumulator 参数,BinaryOperator 是一个函数式接口,它是 BiFunction 接口的特殊形式,BiFunction 表示的是两个入参和一个出参的函数:

@FunctionalInterface
public interface BiFunction<T, U, R> {
    // ...
}

BinaryOperator 同样也是两个入参和一个出参的函数,但是它的两个入参的类型和出参的类型是一样的:

@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
    // ...
}

accumulator 的意思是累加器,它是一个函数,它有两个参数。它的第一个参数是上次函数执行的返回值(也称为中间结果),第二个参数是流中的元素,函数将两个值按照方法进行处理,得到值赋给下次执行这个函数的参数。第一次执行的时候第一参数的值是流中第一元素,第二个元素是流中第二元素,因为流可能为空,所以这个方法的返回值为 Optional

最容易想到的一个例子是通过 reduce 来求和:

Optional<Integer> result = students.map(Student::getAge).reduce((x, y) -> x + y);

其中的 Lambda 表达式 (x, y) -> x + y 也可以简写成方法引用 Integer::sum

Optional<Integer> result = students.map(Student::getAge).reduce(Integer::sum);

不仅如此,稍微改一下 accumulator 函数,我们还可以实现其他的功能,比如求最大值:

Optional<Integer> result = students.map(Student::getAge).reduce((x, y) -> x > y ? x : y);

求最小值:

Optional<Integer> result = students.map(Student::getAge).reduce((x, y) -> x < y ? x : y);

这些参数同样也都可以使用方法引用 Integer::maxInteger::min 进行简化。

reduce 的第二种形式是:

T reduce(T identity, BinaryOperator<T> accumulator);

它和第一种形式的区别在于多了一个和流中元素同类型的 T identity 参数,这个参数的作用是设置初始值,当流中元素为空时,返回初始值。这个形式的好处是不会返回 Optional 类型,代码看起来更简单,所以一般更推荐使用这种形式:

Integer result = students.map(Student::getAge).reduce(0, Integer::sum);

reduce 的 JDK 源码注释里,有一段伪代码很好地解释了 reduce 内部的处理逻辑:

U result = identity;
for (T element : this stream)
    result = accumulator.apply(result, element)
return result;

reduce 的第三种形式如下:

<U> U reduce(U identity, 
    BiFunction<U, ? super T, U> accumulator, 
    BinaryOperator<U> combiner);

可以看到第三种形式要稍微复杂一点,它接受三个参数,第一个参数 identity 表示初始值,第二个参数 accumulator 表示累加器,这和形式二是一样的,不过注意看会发现这两个参数的类型发生了变化,而且返回值的类型也变了,不再局限于和流中元素同类型。第三个参数 BinaryOperator<U> combiner 被称为组合器,这个参数有什么作用呢?在上面的例子中,我们使用的都是串行流,当我们处理并行流时,流会被拆分成多个子流进行 reduce 操作,很显然我们还需要将多个子流的处理结果进行汇聚,这个汇聚操作就是 combiner

不过如果你的汇聚操作和累加器逻辑是一样的,combiner 参数也可以省略:

Integer result = intStream.parallel().reduce(0, Integer::sum);

这个写法和下面的写法没有任何区别:

Integer result = intStream.parallel().reduce(0, Integer::sum, Integer::sum);

到目前为止我们还没有看到 reduce 方法的特别之处,可能你会觉得它不过就是普通的方法,用于 对流中的所有元素累积处理,最终得到一个处理结果。其实这是一个非常强大的工具,也是一个抽象程度非常高的概念,它的用法可以非常灵活,从下面几个例子可以一窥 reduce 的冰山一角。

统计元素个数:

Stream<Integer> intStream = Stream.of(1, 3, 2, 4, 2, 4, 2);
Map<Integer, Integer> countMap = intStream.reduce(new HashMap<>(), (x, y) -> {
    if (x.containsKey(y)) {
        x.put(y, x.get(y) + 1);
    } else {
        x.put(y, 1);
    }
    return x;
}, (x, y) -> new HashMap<>());

数组去重:

Stream<Integer> intStream = Stream.of(1, 3, 2, 4, 2, 4, 2);
List<Integer> distinctMap = intStream.reduce(new ArrayList<>(), (x, y) -> {
    if (!x.contains(y)) {
        x.add(y);
    }
    return x;
}, (x, y) -> new ArrayList<>());

List 转 Map:

Map<Long, Student> studentMap = students.reduce(new HashMap<Long, Student>(), (x, y) -> {
    x.put(y.getNumber(), y);
    return x;
}, (x, y) -> new HashMap<Long, Student>());

可以看到,一旦这个返回类型不做限制时,我们能做的事情就太多了。只要是类似的汇聚操作,都可以用 reduce 实现,这也是 MapReduce 可以用于大规模数据处理的原因。不过上面处理的都是串行流,所以 combiner 参数并没有什么用,随便写都不影响处理结果,但是当我们处理并行流时,combiner 参数就不能乱写了,也不能省略,这是因为它和累加器的参数是不一样的,而且它们的处理逻辑也略有区别。比如上面的 List 转 Map 的例子,如果使用并行流,则必须写 combiner 参数:

Map<Long, Student> studentMap = students.parallel().reduce(new HashMap<Long, Student>(), (x, y) -> {
    x.put(y.getNumber(), y);
    return x;
}, (x, y) -> {
    for (Map.Entry<Long, Student> entry : y.entrySet()) {
        x.put(entry.getKey(), entry.getValue());
    }
    return x;
});
collect

collect 函数正如它的名字一样,可以将流中的元素经过处理并收集起来,得到收集后的结果,这听起来感觉和 reduce 函数有点像,而且它的函数定义也和 reduce 函数很类似:

<R> R collect(Supplier<R> supplier,
    BiConsumer<R, ? super T> accumulator,
    BiConsumer<R, R> combiner);

不过区别还是有的,collect 函数的第一个参数也是用于设置初始值,不过它是通过一个 Supplier<R> supplier 来设置,这是一个没有参数的函数,函数的返回值就是初始值。第二个和第三个参数也是累加器 accumulator 和组合器 combiner,它们的作用和在 reduce 中是一样的,不过它们的类型是 BiConsumer 而不是 BinaryOperator(也不是 BiFunction),这也就意味着累加器和组合器是没有返回值的,所以需要在累加器中使用引用类型来储存中间结果,下面是使用 collect 对流中元素求和的例子:

Stream<Integer> intStream = Stream.of(1, 3, 2, 4);
AtomicInteger result = intStream.collect(
    () -> new AtomicInteger(),
    (a, b) -> a.addAndGet(b), 
    (a, b) -> {}
);

将上面的代码和 reduce 求和的代码对比一下,可以看出两者几乎是一样的,一般来说 reduce 能实现的功能,collect 基本上也都能实现,区别在于它的初始值是一个引用变量,并且中间的计算结果也一直储存在这个引用变量中,最后的返回值也是这个引用变量。很显然,这个引用变量是一个 可变的容器(mutable container),所以 collect 在官方文档中也被称为 Mutable reduction 操作。

而且 collect 相比于 reduce 来说显得更强大,因为它还提供了一个更简单的形式,它将 supplieraccumulatorcombiner 抽象为收集器 Collector 接口:

<R, A> R collect(Collector<? super T, A, R> collector);

这个函数的定义虽然看上去非常简单,但是不得不说,collect 可能是 Stream API 中最为复杂的函数,其复杂之处就在于收集器的创建,为了方便我们创建收集器,Stream API 提供了一个工具类 Collectors,它内置了大量的静态方法可以创建一些常用的收集器,比如我们最常用的 Collectors.toList() 可以将流中元素收集为一个列表:

List<Integer> result = intStream.collect(Collectors.toList());

从源码中可以看出这个收集器是由 ArrayList::newList::add 组成的:

public static <T>
Collector<T, ?, List<T>> toList() {
    return new CollectorImpl<>(
        (Supplier<List<T>>) ArrayList::new, 
        List::add,
        (left, right) -> { left.addAll(right); return left; },
        CH_ID);
}

上面 reduce 中的几个例子,我们一样可以使用 collect 来实现,比如求和:

Integer result = intStream.collect(Collectors.summingInt(Integer::valueOf));

求最大值:

Optional<Integer> result = intStream.collect(Collectors.maxBy(Integer::compareTo));

统计元素个数:

Map<Integer, Long> result = intStream.collect(Collectors.groupingBy(i -> i, Collectors.counting()));

数组去重:

Map<Integer, Integer> result = intStream.collect(Collectors.toMap(i -> i, i -> i, (i, j) -> i));

List 转 Map:

Map<Long, Student> result = students.collect(Collectors.toMap(Student::getNumber, Function.identity()));

除此之外,Collectors 还内置了很多其他的静态方法,比如字符串拼接:

String result = students.map(Student::getName).collect(Collectors.joining("、"));

按条件将数据分为两组:

Map<Boolean, List<Student>> result = students.collect(Collectors.partitioningBy(x -> x.getAge() > 30));

按字段值将数据分为多组:

Map<Integer, List<Student>> result = students.collect(Collectors.groupingBy(Student::getAge));

partitioningBygroupingBy 函数非常类似,只不过一个将数据分成两组,一个将数据分为多组,它们的第一个参数都是 Function<? super T, ? extends K> classifier,又被称为 分类函数(classification function),分组返回的 Map 的键就是由它产生的,而对应的 Map 的值是该分类的数据列表。很容易想到,既然得到了每个分类的数据列表,我们当然可以继续使用 Stream API 对每个分类的数据进一步处理。所以 groupingBy 函数还提供了另一种形式:

Collector<T, ?, Map<K, D>> groupingBy(
    Function<? super T, ? extends K> classifier, 
    Collector<? super T, A, D> downstream)

第二个参数仍然是一个收集器 Collector,这被称为 下游收集器(downstream collector),比如上面那个统计元素个数的例子:

Map<Integer, Long> result = intStream.collect(Collectors.groupingBy(i -> i, Collectors.counting()));

这里就使用了下游收集器 Collectors.counting() 对每个分组的数据进行计数。我们甚至可以对下游收集器返回的结果继续使用下游收集器处理,比如我希望得修改分组后的数据类型:

Map<String, List<String>> result = students.collect(Collectors.groupingBy(
    Student::getGender, Collectors.mapping(
        Student::getName, Collectors.toList())));

这里我希望按学生性别分组,并得到每个性别的学生姓名列表,而不是学生列表。首先使用收集器 Collectors.mapping() 将 Student 对象转换为姓名,然后再使用 Collectors.toList() 将学生姓名收集到一个列表。这种包含一个或多个下游收集器的操作被称为 Multi-level reduction

count

count 比较简单,用于统计流中元素个数:

long count = students.count();
max / min

maxmin 函数用于计算流中的最大元素和最小元素,元素的大小通过比较器 Comparator<? super T> comparator 来决定。比如获取年龄最大的学生:

Optional<Student> maxAgeStudent = students.max(Comparator.comparingInt(Student::getAge));

不过对于基础类型流,maxmin 函数进行了简化,不需要比较器参数:

OptionalInt maxAge = students.mapToInt(Student::getAge).max();
sum / average / summaryStatistics

另外,对于基础类型流,还特意增加了一些统计类的函数,比如 sum 用于对流中数据进行求和:

int sumAge = students.mapToInt(Student::getAge).sum();

average 用于求平均值:

OptionalDouble averageAge = students.mapToInt(Student::getAge).average();

summaryStatistics 用于一次性获取流中数据的统计信息(包括最大值、最小值、总和、数量、平均值):

IntSummaryStatistics summaryStatistics = students.mapToInt(Student::getAge).summaryStatistics();
System.out.println("Max = " + summaryStatistics.getMax());
System.out.println("Min = " + summaryStatistics.getMin());
System.out.println("Sum = " + summaryStatistics.getSum());
System.out.println("Count = " + summaryStatistics.getCount());
System.out.println("Average = " + summaryStatistics.getAverage());

参考

  1. Java8 Stream的总结
  2. Java 8 新特性 | 菜鸟教程
  3. Java 8 Stream | 菜鸟教程
  4. Package java.util.stream Description
  5. https://www.baeldung.com/java-streams
  6. https://www.baeldung.com/tag/java-streams/
  7. https://www.cnblogs.com/wangzhuxing/p/10204894.html
  8. https://www.cnblogs.com/yulinfeng/p/12561664.html

更多

Collectors 静态方法一览

  • 转换为集合

    • Collector<T, ?, C> toCollection(Supplier<C> collectionFactory)
    • Collector<T, ?, List<T>> toList()
    • Collector<T, ?, Set<T>> toSet()
  • 统计计算

    • Collector<T, ?, IntSummaryStatistics> summarizingInt(ToIntFunction<? super T> mapper)
    • Collector<T, ?, LongSummaryStatistics> summarizingLong(ToLongFunction<? super T> mapper)
    • Collector<T, ?, DoubleSummaryStatistics> summarizingDouble(ToDoubleFunction<? super T> mapper)
    • Collector<T, ?, Optional<T>> minBy(Comparator<? super T> comparator)
    • Collector<T, ?, Optional<T>> maxBy(Comparator<? super T> comparator)
    • Collector<T, ?, Integer> summingInt(ToIntFunction<? super T> mapper)
    • Collector<T, ?, Long> summingLong(ToLongFunction<? super T> mapper)
    • Collector<T, ?, Double> summingDouble(ToDoubleFunction<? super T> mapper)
    • Collector<T, ?, Double> averagingInt(ToIntFunction<? super T> mapper)
    • Collector<T, ?, Double> averagingLong(ToLongFunction<? super T> mapper)
    • Collector<T, ?, Double> averagingDouble(ToDoubleFunction<? super T> mapper)
    • Collector<T, ?, Long> counting()
  • 字符串拼接

    • Collector<CharSequence, ?, String> joining()
    • Collector<CharSequence, ?, String> joining(CharSequence delimiter)
    • Collector<CharSequence, ?, String> joining(CharSequence delimiter, CharSequence prefix, CharSequence suffix)
  • Map & Reduce

    • Collector<T, ?, R> mapping(Function<? super T, ? extends U> mapper, Collector<? super U, A, R> downstream)
    • Collector<T, ?, T> reducing(T identity, BinaryOperator<T> op)
    • Collector<T, ?, Optional<T>> reducing(BinaryOperator<T> op)
    • Collector<T, ?, U> reducing(U identity, Function<? super T, ? extends U> mapper, BinaryOperator<U> op)
  • 分组

    • Collector<T, ?, Map<K, List<T>>> groupingBy(Function<? super T, ? extends K> classifier)
    • Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier, Collector<? super T, A, D> downstream)
    • Collector<T, ?, M> groupingBy(Function<? super T, ? extends K> classifier, Supplier<M> mapFactory, Collector<? super T, A, D> downstream)
    • Collector<T, ?, ConcurrentMap<K, List<T>>> groupingByConcurrent(Function<? super T, ? extends K> classifier)
    • Collector<T, ?, ConcurrentMap<K, D>> groupingByConcurrent(Function<? super T, ? extends K> classifier, Collector<? super T, A, D> downstream)
    • Collector<T, ?, M> groupingByConcurrent(Function<? super T, ? extends K> classifier, Supplier<M> mapFactory, Collector<? super T, A, D> downstream)
    • Collector<T, ?, Map<Boolean, List<T>>> partitioningBy(Predicate<? super T> predicate)
    • Collector<T, ?, Map<Boolean, D>> partitioningBy(Predicate<? super T> predicate, Collector<? super T, A, D> downstream)
  • List 转 Map

    • Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper)
    • Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction)
    • Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier)
    • Collector<T, ?, ConcurrentMap<K,U>> toConcurrentMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper)
    • Collector<T, ?, ConcurrentMap<K,U>> toConcurrentMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction)
    • Collector<T, ?, M> toConcurrentMap(Function<? super T, ? extends K> keyMapper, Function<? super T, ? extends U> valueMapper, BinaryOperator<U> mergeFunction, Supplier<M> mapSupplier)
  • 其他

    • Collector<T,A,RR> collectingAndThen(Collector<T,A,R> downstream, Function<R,RR> finisher)
扫描二维码,在手机上阅读!