我们学了那么多 API,为什么还是“缺直觉”?

作为 Java 开发,你大概率经历过这些瞬间:

  • Stream 已经很顺:map / filter / flatMap 一路链式调用,看起来很优雅,但心里偶尔还是会冒出一句:

“flatMap 到底在干嘛?为啥一会儿是 T -> Stream<R>,一会儿又变成了扁平的一维流?”

  • 大学线代课上学过矩阵、向量、点积、矩阵乘法,甚至知道“外积”这玩意儿,可一走出考场,这些东西就自动归类为:

“只在考试和深度学习论文里存在的生物。”

于是就出现了一个很有意思的反差:

API 会用,题也能做,但 缺一种一眼看穿本质的“直觉”。

这篇文章想做的事情很简单:

  • 不跟你讲高深数学,不和你科普 monad 定义;

  • 而是用一个极其朴素的问题,把两个世界串起来 ——

“这个操作,是在 升维 还是在 降维?”

我们会从你每天在用的 map / flatMap 出发,看看它们其实就是在玩“容器维度”;

然后转向矩阵,看看外积、flatten、点积、trace 怎么在“张量维度”上做类似的事情。

目标只一个:

以后你再看到一个新概念、新 API、新公式,脑子里先弹出一句:

“它到底在对哪些维度动手?”

当你能自然地问出这句话,抽象世界会立刻变得好懂很多。

flatMap 世界里的“升维 / 降维” —— 先从 Java 容器开始

先别谈 FP,先看“多一层包裹”是什么意思

我们从最简单的几个类型开始:

String s;                // A
List<String> list;       // List<A>
List<List<String>> ll;   // List<List<A>>

Optional<Integer> x;          // Optional<A>
Optional<Optional<Integer>> y; // Optional<Optional<A>>

如果你把 每一层容器 想象成“多了一维的上下文”,那:

  • 没有容器:A → “0 维的纯值”

  • 一层容器:List<A>Optional<A> → “1 维”

  • 两层嵌套:List<List<A>>Optional<Optional<A>> → “2 维”

这里的“维度”跟三维空间没关系,只是一个方便记忆的说法:

容器嵌套的层数 = 你要处理的“上下文维度”。

接下来,mapflatMap 做的事情就可以用“维度变化”来描述了。

map:只在当前维度里“涂颜料”,不改维度

先看最熟悉的 map

Stream<Integer> s = Stream.of(1, 2, 3);

// A -> B
Stream<String> s2 = s.map(i -> "num-" + i);

类型层面是:

map: Stream<A> × (A -> B) -> Stream<B>

你可以这么理解:

  • 容器维度:从 Stream<A>Stream<B>没有变(都是 1 层 Stream)。

  • 真正在变的是“里面的元素类型”而已。

所以:

map = 在 固定维度的容器里,换一批内容

像是你拿着一支画笔,在同一张画布上换颜色,而不是换画布的层数。

flatMap:A -> M<B> + 自动“压扁” = 降一维

再看 flatMap

Stream<Integer> s = Stream.of(1, 2, 3);

// i -> Stream<String>
Stream<String> s3 = s.flatMap(i -> Stream.of("a" + i, "b" + i));

它的类型是:

flatMap: Stream<A> × (A -> Stream<B>) -> Stream<B>

如果只看 map 部分会发生什么?

Stream<Stream<String>> tmp = s.map(i -> Stream.of("a" + i, "b" + i));

这一步之后,你其实得到了一个 Stream<Stream<String>> ,也就是“2 维”的结构:

[
  ["a1", "b1"],
  ["a2", "b2"],
  ["a3", "b3"]
]

然后 flatMap 在这个基础上做了一次“flatten”:

// flatten 之后:
["a1", "b1", "a2", "b2", "a3", "b3"]

所以可以把 flatMap 分成两步:

  1. map升维 一点点(从 Stream<A>Stream<Stream<B>>,加深了嵌套层数);

  2. flatten:把两层 Stream 压成一层:Stream<Stream<B>> -> Stream<B>降一维

整体效果是什么?

对 “从 A 到 Stream<B>” 的映射做了一次整体的 降维,避免你手里多出一层“嵌套容器”。

记住这一点就够了:

有嵌套就会想降维;有纯值就可能想升维。

flatMap 是最典型的“降一维”工具。

升维:把值“丢进一个世界里”

既然有降维,肯定也有升维。

在 Java 里很常见:

// 标量 -> 容器(0 维 -> 1 维)
Optional<Integer> o = Optional.of(42);
Stream<String> s = Stream.of("hello");

// 值 -> Future:0 维 -> 异步世界这 1 维
CompletableFuture<String> f = CompletableFuture.completedFuture("ok");

这些“of / just / completedFuture / singletonList”之类的方法,本质上干的是:

把一个裸值放进某个“上下文维度”里

在 FP 里,他们经常被叫做 unit / pure 之类的名字,你完全可以用更程序员一点的话记它:

升维:A -> M<A>(把值装进某个世界)

降维:M<M<A>> -> M<A>(把多余的一层世界压掉)

把这套“维度直觉”应用到你的日常 Java 代码

以后你看到类似的类型:

Stream<Stream<T>>
Optional<Optional<T>>
CompletableFuture<CompletableFuture<R>>
Stream<Optional<T>>

可以下意识问自己:

1)这里有多少层“世界”?

  • Stream<Stream<T>> 是“流里还有流”

  • Optional<Optional<T>> 是“可能里还有可能”

2)接下来我要做的事,是升维还是降维?

  • 如果你要“拆开一层世界”,就会想到 flatMap / thenCompose/ 自己写的 flattenXXX

  • 如果你要“先包一层再交给别的 API”,就会想到 of / completedFuture / just 等等。

这就是 flatMap 世界里的升维 / 降维

只是你以前叫它“链式调用”,现在多了一个更形象的词:维度变化

接下来,我们去矩阵的世界看一眼:

那里的 升维 / 降维,其实跟你现在理解的这些,非常像。

矩阵世界里的“升阶 / 降阶” —— 外积、flatten、点积、trace

从容器切到矩阵,我们先约定一下要用的“维度词汇”,避免概念混乱。

三种“维度”,先对齐再说话

在矩阵世界,大家一说“维度”,可能指的是三件不同的事:

1)数组维度(轴的个数)

  • double[]:1D 数组

  • double[][]:2D 数组(矩阵)

  • double[][][]:3D 数组(高阶张量)

2)张量阶数(索引个数)

  • 标量:0 阶(不需要索引)

  • 向量:1 阶(1 个索引 i)

  • 矩阵:2 阶(2 个索引 i、j)

  • 三维数组:3 阶张量(i、j、k)

3)线性空间的维数(ℝⁿ 里的 n)

  • “这个向量有多少个分量” = “所在空间的维数”。

  • 比如 v ∈ ℝ⁵,说的是它在 5 维空间里。

这篇文章接下来主要用 第 2 个意义

把标量 / 向量 / 矩阵 / 张量看作 0 阶 / 1 阶 / 2 阶 / 3 阶, 然后谈 升阶(升维)和降阶(降维)

外积:两个向量如何“长成”一个矩阵?——典型升维

你大概对“点积”很熟:

v·w = v1*w1 + v2*w2 + ...,结果是一个标量。

而“外积”是另外一件事:

给定两个向量 v 和 w,生成一个矩阵 A

其中每个元素是 A[i][j] = v[i] * w[j]

简单画一下:

v = [v1, v2, v3]      (3 维向量)
w = [w1, w2]          (2 维向量)

外积 A = v ⊗ w =

[
  [v1*w1, v1*w2],
  [v2*w1, v2*w2],
  [v3*w1, v3*w2]
]    // 3x2 的矩阵

从“维度/阶数”的角度看:

  • v 是 1 阶张量(只需要一个索引 i)

  • w 是 1 阶张量(索引 j)

  • A 是 2 阶张量(索引 i、j)

所以外积做的是:

1 阶 + 1 阶 → 2 阶

非常典型的 升阶 / 升维 操作。

如果用 Java 粗暴实现一下:

double[][] outerProduct(double[] v, double[] w) {
    int m = v.length;
    int n = w.length;
    double[][] A = new double[m][n];
    for (int i = 0; i < m; i++) {
        for (int j = 0; j < n; j++) {
            A[i][j] = v[i] * w[j];
        }
    }
    return A;
}

你可以把它想象成:

把一个一维的东西“拉成行/列”,然后在二维平面上铺开。

flatten / vec / reshape:矩阵如何“捋直”? —— 降维 + 换形状

我们再来看跟 flatMap 很像的一个动作:把矩阵“捋直”成向量

假设有一个 2×3 的矩阵:

A =
[
  [1, 2, 3],
  [4, 5, 6]
]

如果我们按行展开:

flatten(A) =
[1, 2, 3, 4, 5, 6]   // 一个 6 维向量

从张量阶数的角度:

  • A:2 阶(两个索引 i, j)

  • flatten(A):1 阶(一个索引 k)

这就是一个 标准的降阶 / 降维

2D → 1D,把“行 + 列”合成了一个新的线性索引。

反过来,reshape 则是:

reshape(flatten(A), 2, 3)  // 1D -> 2D

在数学上,它既可以被看作升维,也可以看作单纯“换形状”(因为元素没变,只是重新组织索引)。

在工程上我们更关心的是直觉:

  • flatten:把一个 2D 世界“压成一条线”(降维)

  • reshape:把一条线重新铺成 2D 网格(升维 / 改坐标)

点积 / 矩阵×向量 / trace:各种“收缩维度”的降阶操作

外积是升维,那哪些是矩阵世界里的降维?

只要你看到“对某个索引求和、把它干掉了”,基本就是降维 / 降阶。

点积:1 阶 + 1 阶 → 0 阶(标量)

点积定义:

v·w = Σ v[i] * w[i]

从维度角度:

  • 输入是两个 1 阶张量(向量)

  • 输出是 0 阶张量(标量)

你可以说:

对索引 i 做了一次“求和收缩”,把这个维度“合并掉了”。

这就是一个标准的 降一阶 操作。

矩阵 × 向量:2 阶 + 1 阶 → 1 阶

矩阵乘向量:

y[i] = Σ A[i][j] * x[j]
  • A:2 阶(i, j)

  • x:1 阶(j)

  • y:1 阶(i)

这里发生的事情是:

  • 对 j 这个索引做了求和 ⇒ 把 j 这一个维度“收缩掉”

  • 于是整体的阶数从 “2 + 1” 变成 “1”

这和 FP 里什么感觉像?

有点像你拿一个“二维容器 + 一维容器”,经过某种规则的“聚合”,最后只剩下一维结果。

矩阵的迹 trace:2 阶 → 0 阶

迹的定义:

tr(A) = Σ A[i][i]
  • A:2 阶(i, j)

  • tr(A):0 阶(标量)

这里是不是很像:

“对 i = j 这条对角线上的元素求和,把两个索引一起收缩掉了”

这就是 把 2 阶对象压缩成 0 阶对象的降维操作

矩阵世界的“维度操作表”

我们可以用一句话把前面的说完:

1)升维 / 升阶:

  • 外积:向量 × 向量 -> 矩阵

  • 把一堆向量堆成矩阵:k 个 1D -> 1 个 2D

  • reshape(从 1D 到 2D 的那一侧)

2)降维 / 降阶:

  • flatten:矩阵 -> 向量

  • 点积:向量 × 向量 -> 标量

  • 矩阵×向量:矩阵 × 向量 -> 向量

  • trace:矩阵 -> 标量

  • 按行 / 按列求和:对某个轴做收缩

如果你把这些操作的“维度变化”内化成直觉,你会发现:

线代书上很多让人眼晕的符号,其实都可以翻译成一句话:

“我现在在对哪几个索引求和,把哪几个维度合并掉?”

而这一点,和你在 Java 容器世界里学到的 升维 / 降维直觉,是可以共用的。

用一小段 Java Cheat Sheet,把两个世界串在一起

前面我们分别站在“Java 容器世界”和“矩阵 / 张量世界”里讲升维 / 降维。

这里,我们就干一件很务实的事:

用一小段 Java 代码

把这两个世界塞进同一张“维度操作表”里。

你可以把这当成是:

  • 一份可以直接复制进项目的 double[] / double[][] 工具类

  • 再加上一张: FP ↔ 线代 ↔ Java 实现 ↔ 维度变化 对照表。

3.1 面向 Java 的最小线代工具箱:double[] / double[][]

先约定一下表示:

  • 向量double[]

  • 矩阵double[][]

我们写一个特别迷你的工具类,只保留“跟维度变化有关”的几个操作:

public class LinAlg {

    // --- 升维 / 升阶 ---

    // 标量 -> 向量(0D -> 1D)
    public static double[] scalarToVector(double x) {
        return new double[]{x};
    }

    // 向量 -> 单行矩阵(1D -> 2D)
    public static double[][] vectorToRowMatrix(double[] v) {
        return new double[][]{v.clone()};
    }

    // 向量 -> 单列矩阵(1D -> 2D)
    public static double[][] vectorToColMatrix(double[] v) {
        double[][] m = new double[v.length][1];
        for (int i = 0; i < v.length; i++) {
            m[i][0] = v[i];
        }
        return m;
    }

    // 外积:两个向量 -> 矩阵(1阶 + 1阶 -> 2阶)
    public static double[][] outer(double[] v, double[] w) {
        int m = v.length, n = w.length;
        double[][] a = new double[m][n];
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a[i][j] = v[i] * w[j];
            }
        }
        return a;
    }

    // --- 降维 / 降阶 ---

    // flatten:矩阵 -> 向量(2D -> 1D),按行展开
    public static double[] flatten(double[][] a) {
        int rows = a.length;
        int cols = a[0].length;
        double[] r = new double[rows * cols];
        int k = 0;
        for (int i = 0; i < rows; i++) {
            if (a[i].length != cols) {
                throw new IllegalArgumentException("Row length mismatch");
            }
            for (int j = 0; j < cols; j++) {
                r[k++] = a[i][j];
            }
        }
        return r;
    }

    // 点积:向量 × 向量 -> 标量(1阶 + 1阶 -> 0阶)
    public static double dot(double[] x, double[] y) {
        if (x.length != y.length) {
            throw new IllegalArgumentException("Length mismatch");
        }
        double s = 0.0;
        for (int i = 0; i < x.length; i++) {
            s += x[i] * y[i];
        }
        return s;
    }

    // 矩阵 × 向量:2阶 + 1阶 -> 1阶
    public static double[] matVec(double[][] a, double[] x) {
        int rows = a.length;
        int cols = a[0].length;
        if (x.length != cols) {
            throw new IllegalArgumentException("Size mismatch");
        }
        double[] y = new double[rows];
        for (int i = 0; i < rows; i++) {
            if (a[i].length != cols) {
                throw new IllegalArgumentException("Row length mismatch");
            }
            double s = 0.0;
            for (int j = 0; j < cols; j++) {
                s += a[i][j] * x[j];
            }
            y[i] = s;
        }
        return y;
    }

    // 迹:矩阵 -> 标量(2阶 -> 0阶)
    public static double trace(double[][] a) {
        int rows = a.length;
        int cols = a[0].length;
        int n = Math.min(rows, cols);
        double s = 0.0;
        for (int i = 0; i < n; i++) {
            s += a[i][i];
        }
        return s;
    }

    // --- 改形状 / 对某个轴降维 ---

    // reshape:向量 -> 矩阵(1D -> 2D),纯改形状
    public static double[][] reshape(double[] flat, int rows, int cols) {
        if (flat.length != rows * cols) {
            throw new IllegalArgumentException("Size mismatch");
        }
        double[][] a = new double[rows][cols];
        int k = 0;
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                a[i][j] = flat[k++];
            }
        }
        return a;
    }

    // 按行求和:对列维度降维,矩阵 -> 向量
    public static double[] sumRows(double[][] a) {
        int rows = a.length;
        int cols = a[0].length;
        double[] r = new double[rows];
        for (int i = 0; i < rows; i++) {
            if (a[i].length != cols) {
                throw new IllegalArgumentException("Row length mismatch");
            }
            double s = 0.0;
            for (int j = 0; j < cols; j++) {
                s += a[i][j];
            }
            r[i] = s;
        }
        return r;
    }

    // 按列求和:对行维度降维,矩阵 -> 向量
    public static double[] sumCols(double[][] a) {
        int rows = a.length;
        int cols = a[0].length;
        double[] r = new double[cols];
        for (int j = 0; j < cols; j++) {
            double s = 0.0;
            for (int i = 0; i < rows; i++) {
                if (a[i].length != cols) {
                    throw new IllegalArgumentException("Row length mismatch");
                }
                s += a[i][j];
            }
            r[j] = s;
        }
        return r;
    }
}

这个类你可以直接扔到项目里玩。

它其实就是用 Java 把我们在前面讲过的那几个“升阶 / 降阶”操作全部具象化了。

接下来,有趣的事情来了:

我们可以把 FP 操作 / 矩阵操作 / Java 实现 / 维度变化 写成一张表。

对照表:FP vs 线代 vs Java 代码里的“维度动作”

下面这张表是这篇文章的“核心记忆点”:

领域 操作名字 类型/公式视角 Java 例子 维度变化
FP(容器) unit / of / just A -> M<A> Optional.of(x)Stream.of(x) 0D → 1D(升维:把值装进世界)
FP(容器) map M<A> -> M<B> stream.map(f) 维度不变,只改内容
FP(容器) flatMap M<A> × (A -> M<B>) -> M<B> stream.flatMap(f) 先升一点(map 变嵌套),再整体降一维(flatten)
线代(张量) 外积 v ⊗ w ℝ^m × ℝ^n -> ℝ^{m×n} LinAlg.outer(v, w) 1阶 + 1阶 → 2阶(升阶 / 升维)
线代(数组) flatten / vec ℝ^{m×n} -> ℝ^{mn} LinAlg.flatten(A) 2阶 → 1阶(降阶 / 降维)
线代 reshape ℝ^{mn} -> ℝ^{m×n} LinAlg.reshape(flat, m, n) 通常 1D → 2D,可视为升维 / 换形状
线代 点积 v·w ℝ^n × ℝ^n -> ℝ LinAlg.dot(v, w) 1阶 + 1阶 → 0阶(对索引 i 收缩)
线代 矩阵×向量 A x ℝ^{m×n} × ℝ^n -> ℝ^m LinAlg.matVec(A, x) 2阶 + 1阶 → 1阶(对 j 收缩)
线代 tr(A) ℝ^{n×n} -> ℝ LinAlg.trace(A) 2阶 → 0阶(对 i=j 收缩)
线代 按行/列求和 Σ axis sumRows / sumCols 对某个轴做降维(2D → 1D)

你可以这么理解这张表:

左边几列,是你已经会用的 API / 数学操作;

右边这一列,是你希望内化的“本质动作”:

  • 玩哪个维度

  • 是在升维 / 降维 / 换形状

有了这层对照,你以后再看到一个新东西,比如:

  • 某个深度学习库里的 conv2d / attention

  • Reactor 里的各种 flatMapManybuffer, window

  • Stream 里的 collectingAndThengroupingBy

你都可以先问自己三件事:

  1. 输入输出分别在哪些维度 / 容器层数?

  2. 中间有没有引入“多一层世界”(升维)?

  3. 最终结果是不是通过某种聚合 / 求和 / flatten 降回去了?

一旦你能用这三个问题“审问”它,这个抽象就已经一半是你自己的了。

在日常 Java 工作里,怎么用这套维度直觉?

说得再具体一点,这套东西怎么落地到你每天写的业务代码里?

可以给你几个很直接的使用场景:

场景 1:设计 DTO / 返回值结构

比如你写一个接口,返回的是:

List<List<OrderItemDto>>

你可以立刻问自己:

  • 我是真的需要一个“二维列表世界”吗?

  • 还是其实业务上只是“单层列表 + 分组信息”就够了(比如 Map + List)?

有了“维度意识”之后,你会更谨慎地增加嵌套层数,因为你知道:

每多一层嵌套,就意味着未来的某个你,要多写一层 flatMap / 多绕一圈逻辑来“降维”。

场景 2:Stream / Reactor 链路设计

当你在链式调用里狂写 map / flatMap / buffer / window 时,可以停下来想一眼:

  • 这一步是在 把事件流升维 (比如按窗口分组,Flux<T> -> Flux<List<T>>)?

  • 还是在 把流降维 (比如 flatMapFlux<Flux<T>> 打平)?

你会发现,很多“看不懂自己写了啥”的链式调用,本质就是你没在脑子里画清楚“维度怎么变”而已。

场景 3:做一点数学/算法实现时

当你需要自己实现个简单的算法(比如最小二乘、线性回归预测),你就可以直接用上面那几个方法:

  • outer:构造某种协方差矩阵 / 特征矩阵;

  • flatten / reshape:在矩阵 / 向量之间来回切换;

  • matVec / dot / sumRows / sumCols:做各种简单的线代运算。

把它们当作“线代领域的 flatMap / map / unit”,你就不会只停留在“API 调库调用”的层面,而是真正知道:

“我现在是在把数据升到什么维度、再降到什么维度。”

结语:以后遇到新抽象,先问它“你在动哪个维度?”

回到一开始那句话:

我们学了那么多 API,为什么还是“缺直觉”?

很多时候不是你不聪明,也不是 API 太多,而是 缺了一把统一的“观察尺子”

这里,我们尝试用 “维度变化” 当这把尺子:

在 FP / Java 容器的世界里:

  • unit / of = 升维,把值丢进一个世界;

  • flatMap / flatten = 降维,把多余的一层世界压掉。

在线性代数 / 矩阵的世界里:

  • 外积 / 堆向量成矩阵 = 升阶 / 升维;

  • flatten / 点积 / trace / 按轴求和 = 各种收缩维度的降阶操作。

前面的小 Java cheat sheet,只是帮你确认一件事:

这些东西不是“哲学问题”,

它们真的可以翻译成你每天写的代码,而且是跑得起来的那种。

如果你愿意,从下一次开始,遇到任意一个新概念 —— 无论是 FP 的、线代的,还是深度学习里的 —— 都可以先在心里默默问一句:

“它在对哪些维度做什么事?”

当这句话变成你的条件反射,你就不再只是“会用 API 的程序员”,

而是开始有了自己的一套 抽象世界的直觉坐标系

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐