Lodash源码阅读-baseIntersection

138 阅读10分钟

Lodash 源码阅读-baseIntersection

概述

baseIntersection 是 Lodash 内部的核心函数,用于计算多个数组的交集。它支持自定义迭代器和比较器,能高效处理大型数组,是 _.intersection_.intersectionBy_.intersectionWith 等公共方法的基础实现。

前置学习

依赖函数

  • arrayIncludes:检查数组中是否包含指定值,使用 SameValueZero 算法比较
  • arrayIncludesWith:使用自定义比较器检查数组中是否包含某值
  • arrayMap:对数组中的每个元素应用迭代器函数
  • baseUnary:将函数转换为只接收一个参数的函数
  • SetCache:专门用于存储唯一值的缓存结构
  • cacheHas:检查 SetCache 中是否存在某个值
  • nativeMin:原生 Math.min 方法的引用

技术知识

  • 数组交集算法:高效计算多个数组交集的算法策略
  • 大数据集优化:大型数组的性能优化方法和缓存使用
  • 标签跳转:JavaScript 中的标签式跳转语法(outer:continue outer
  • 短路计算:利用逻辑操作符的短路特性优化条件判断
  • 特殊值处理:处理 JavaScript 中的特殊值(如 -0 和 +0)

源码实现

function baseIntersection(arrays, iteratee, comparator) {
  var includes = comparator ? arrayIncludesWith : arrayIncludes,
    length = arrays[0].length,
    othLength = arrays.length,
    othIndex = othLength,
    caches = Array(othLength),
    maxLength = Infinity,
    result = [];

  while (othIndex--) {
    var array = arrays[othIndex];
    if (othIndex && iteratee) {
      array = arrayMap(array, baseUnary(iteratee));
    }
    maxLength = nativeMin(array.length, maxLength);
    caches[othIndex] =
      !comparator && (iteratee || (length >= 120 && array.length >= 120))
        ? new SetCache(othIndex && array)
        : undefined;
  }
  array = arrays[0];

  var index = -1,
    seen = caches[0];

  outer: while (++index < length && result.length < maxLength) {
    var value = array[index],
      computed = iteratee ? iteratee(value) : value;

    value = comparator || value !== 0 ? value : 0;
    if (
      !(seen
        ? cacheHas(seen, computed)
        : includes(result, computed, comparator))
    ) {
      othIndex = othLength;
      while (--othIndex) {
        var cache = caches[othIndex];
        if (
          !(cache
            ? cacheHas(cache, computed)
            : includes(arrays[othIndex], computed, comparator))
        ) {
          continue outer;
        }
      }
      if (seen) {
        seen.push(computed);
      }
      result.push(value);
    }
  }
  return result;
}

实现思路

baseIntersection 的核心思路是将第一个数组作为基准,然后检查其中的每个元素是否存在于所有其他数组中。为了提高性能,函数根据不同情况选择性地使用缓存:

  1. 首先确定使用哪种包含检查方法(普通或带比较器)
  2. 对于大型数组(≥120 元素)或使用迭代器的情况,创建缓存加速查找
  3. 遍历第一个数组的每个元素,检查它是否存在于所有其他数组中
  4. 跳过重复元素,确保结果没有重复值
  5. 当元素在所有数组中都存在时,将其添加到结果中

整个算法通过缓存、短路计算和标签跳转等方式实现了高效的交集计算。

源码解析

初始变量设置与功能选择

var includes = comparator ? arrayIncludesWith : arrayIncludes,
  length = arrays[0].length,
  othLength = arrays.length,
  othIndex = othLength,
  caches = Array(othLength),
  maxLength = Infinity,
  result = [];

这段代码设置了函数运行所需的基础变量:

  1. 动态选择检查函数
    includes 变量通过三元表达式根据是否有比较器决定使用哪个函数:

    • 有比较器时用 arrayIncludesWith,可以用自定义逻辑比较元素
    • 无比较器时用 arrayIncludes,使用内置的 SameValueZero 算法

    这种设计允许函数灵活处理不同的比较需求,同时避免了运行时的条件判断。

  2. 数组维度信息

    • length:第一个数组的长度,用于外层循环限制
    • othLength:数组总数,表示要计算交集的数组个数
    • othIndex:初始值等于数组总数,用于倒序遍历
  3. 结果与缓存准备

    • caches:与输入数组等长的数组,存储可能的缓存
    • maxLength:结果可能的最大长度,初始为 Infinity
    • result:存储最终交集的空数组

数组预处理和缓存创建

while (othIndex--) {
  var array = arrays[othIndex];
  if (othIndex && iteratee) {
    array = arrayMap(array, baseUnary(iteratee));
  }
  maxLength = nativeMin(array.length, maxLength);
  caches[othIndex] =
    !comparator && (iteratee || (length >= 120 && array.length >= 120))
      ? new SetCache(othIndex && array)
      : undefined;
}
array = arrays[0];

这个循环从后向前处理所有输入数组,为主算法做准备:

  1. 迭代器应用
    if (othIndex && iteratee) 确保只对除第一个外的其他数组应用迭代器。

    为什么第一个数组不预先应用迭代器?因为在主循环中,我们会按需处理第一个数组的元素,而其他数组需要提前处理以便缓存。

    // 例如,对于 Math.floor 迭代器和数组 [[1.1, 2.2], [1.9, 2.9]]
    // 第二个数组会变成 [1, 2]
    // 而第一个数组保持原样,在主循环中处理每个元素
    
  2. 最短长度追踪
    maxLength = nativeMin(array.length, maxLength) 记录所有数组中的最短长度。

    这是一个重要优化,因为交集的长度不可能超过任何一个数组的长度,所以我们可以限制结果大小。

  3. 缓存创建策略
    缓存创建条件是复杂而精确的:!comparator && (iteratee || (length >= 120 && array.length >= 120))

    这个条件可以分解为:

    • 没有比较器(缓存对自定义比较不友好)
    • 且满足以下任一条件:
      • 有迭代器(避免重复计算转换值)
      • 或者两个数组都足够大(≥120 元素)

    缓存条件的设计体现了几个性能考量:

    • 比较器冲突:当使用自定义比较器时,缓存的基于哈希的查找可能与比较器的逻辑不一致
    • 迭代计算成本:有迭代器时缓存可以避免重复计算
    • 大数组临界点:120 是经验值,表示何时缓存的好处超过其创建成本
    • 双数组大小要求:要求两个数组都大,因为如果只有一个大,性能瓶颈仍在较小的数组

    othIndex && array 确保不为第一个数组创建缓存(因为 othIndex 为 0 时,表达式求值为 0)。

  4. 基准数组设置
    循环结束后,array = arrays[0] 将基准设为第一个数组,这是交集计算的起点。

缓存初始化的隐藏细节

var index = -1,
  seen = caches[0];

这里有个容易被忽视的细节:seen = caches[0] 获取的是第一个数组的缓存,它的实际值需要仔细分析:

  1. othIndex 为 0 处理第一个数组时:

    • othIndex && array 短路为 0
    • 创建的是 new SetCache(0)
    • SetCache 构造函数接收 0 作为参数:
    function SetCache(values) {
      var index = -1,
        length = values == null ? 0 : values.length; // values 是 0,所以 length 为 0
      this.__data__ = new MapCache();
      while (++index < length) {
        // 循环不会执行
        this.add(values[index]);
      }
    }
    
  2. 因此 seen 初始状态只有两种可能:

    • undefined:不满足缓存创建条件
    • 空的 SetCache 实例:满足条件但内部没有元素

这种设计确保了初始检查必定失败(空缓存中找不到任何元素),然后随着我们找到交集元素,通过 seen.push(computed) 逐步填充缓存。

交集计算核心循环

outer: while (++index < length && result.length < maxLength) {
  var value = array[index],
    computed = iteratee ? iteratee(value) : value;

  value = comparator || value !== 0 ? value : 0;
  if (
    !(seen ? cacheHas(seen, computed) : includes(result, computed, comparator))
  ) {
    othIndex = othLength;
    while (--othIndex) {
      var cache = caches[othIndex];
      if (
        !(cache
          ? cacheHas(cache, computed)
          : includes(arrays[othIndex], computed, comparator))
      ) {
        continue outer;
      }
    }
    if (seen) {
      seen.push(computed);
    }
    result.push(value);
  }
}

这是函数的核心部分,实现交集计算的主要逻辑:

  1. 循环控制
    outer: 标签和 ++index < length && result.length < maxLength 条件共同控制循环:

    • 不超出第一个数组边界
    • 不超过可能的最大结果数量
    • 标签允许内层循环使用 continue outer 直接跳到外层循环的下一次迭代
  2. 值处理与转换

    var value = array[index],
      computed = iteratee ? iteratee(value) : value;
    
    value = comparator || value !== 0 ? value : 0;
    

    这段代码处理当前元素,包括:

    • 获取原始值 value
    • 如果有迭代器,计算其转换值 computed
    • 特殊处理数值 0:将 -0 和 +0 统一为 0(除非有比较器)

    对 0 的特殊处理是为了符合 JavaScript 中 SameValueZero 算法的行为,确保 -0 和 +0 被视为相等。

  3. 重复值跳过

    if (
      !(seen ? cacheHas(seen, computed) : includes(result, computed, comparator))
    ) {
      // 处理元素...
    }
    

    这个条件检查确保不处理重复元素:

    • 如果有缓存,检查元素是否已在缓存中
    • 否则,检查结果数组是否已包含此元素
    • 取反操作 ! 表示"如果元素不在结果中"才继续处理

    例如,对于输入 [[1, 1, 2], [1, 2]],如果不跳过重复值,结果可能是 [1, 1, 2],而正确结果应是 [1, 2]

  4. 交集判断

    othIndex = othLength;
    while (--othIndex) {
      var cache = caches[othIndex];
      if (
        !(cache
          ? cacheHas(cache, computed)
          : includes(arrays[othIndex], computed, comparator))
      ) {
        continue outer;
      }
    }
    

    这段代码是算法的核心,检查元素是否存在于所有其他数组中:

    • 遍历除第一个以外的所有数组(从后向前)
    • 对每个数组,用缓存或直接查找检查元素是否存在
    • 如果任何一个数组不包含该元素,立即使用 continue outer 跳到下一个元素

    这是一个短路优化:一旦确定元素不可能是交集的一部分,就立即停止当前元素的处理。

  5. 结果收集

    if (seen) {
      seen.push(computed);
    }
    result.push(value);
    

    当元素通过了所有检查,意味着它存在于所有数组中:

    • 如果有缓存,将转换后的值添加到缓存中(标记为已处理)
    • 将原始值(或标准化的 0)添加到结果数组

算法复杂度分析

函数的时间复杂度受到缓存使用的显著影响:

  1. 没有缓存时:O(n × m × k)

    • n: 第一个数组长度
    • m: 平均每个数组长度
    • k: 数组个数

    每个元素都需要在每个数组中线性查找。

  2. 使用缓存时:O(n × k)

    • 利用 SetCache 将查找复杂度从 O(m) 降为接近 O(1)
    • 对大型数组(≥120 元素)的优化非常显著

这解释了为什么函数对数组大小设置 120 的阈值:在此阈值下,缓存的创建和维护成本与其带来的性能提升达到平衡点。

具体案例分析

让我们通过一个简单例子看函数如何工作:

// 输入
var arrays = [
  [1, 2, 3, 4],
  [3, 4, 5, 6],
];

// 执行过程
// 1. 预处理:maxLength = 4, 数组不够大,不创建缓存
// 2. 遍历第一个数组元素:
//    - 元素 1:在第二个数组中不存在,跳过
//    - 元素 2:在第二个数组中不存在,跳过
//    - 元素 3:在第二个数组中存在,添加到结果
//    - 元素 4:在第二个数组中存在,添加到结果
// 3. 返回结果 [3, 4]

再看一个使用迭代器的例子:

// 输入
baseIntersection(
  [
    [2.1, 1.2],
    [2.3, 3.4, 4.2],
  ],
  Math.floor
);

// 执行过程
// 1. 预处理:第二个数组应用 Math.floor 变为 [2, 3, 4]
// 2. 遍历第一个数组元素:
//    - 元素 2.1:computed = Math.floor(2.1) = 2
//      在处理后的第二个数组中存在,添加到结果
//    - 元素 1.2:computed = Math.floor(1.2) = 1
//      在处理后的第二个数组中不存在,跳过
// 3. 返回结果 [2.1]

总结

baseIntersection 函数展示了 Lodash 在处理数组操作时的精湛设计和性能优化思路:

  1. 算法策略

    • 基于第一个数组遍历,减少不必要的比较
    • 短路逻辑避免多余计算
    • 标签跳转实现高效的循环控制
  2. 性能优化

    • 根据数组大小和操作类型动态决定是否使用缓存
    • 针对迭代器场景特别优化,避免重复计算
    • 维护最短长度限制,减少不必要的迭代
  3. 健壮性设计

    • 正确处理特殊值(如 -0 和 +0)
    • 支持自定义比较器和迭代器
    • 避免结果中的重复值

这个函数是一个很好的例子,展示了如何在 JavaScript 中实现高效的集合操作算法,以及如何在算法设计中权衡空间和时间复杂度。通过研究这样的代码,我们可以学习到许多实用的性能优化技巧和边界情况处理方法。