前阵子朋友给我发来一段代码,说程序偶尔崩、偶尔又能跑:
std::vector<int> lhs{1, 2, 3, 4}; std::vector<int> rhs{1, 2, 3}; bool same = std::equal(lhs.begin(), lhs.end(), rhs.begin());崩不崩纯看运气。因为std::equal的三迭代器重载根本不会去检查第二个范围的长度,越界访问属于未定义行为。这类问题在 STL 算法里非常典型:接口看着简单,实际上一大堆隐含契约全压在调用者身上。今天就把std::equal这个算法彻底拆开聊一聊:两个重载的区别、带谓词的"按条件相等"怎么写、什么时候该用它而不是别的算法,以及那些平时文档里不会写、但实战一定会遇到的坑。适合用过 STL 但没细看实现细节的开发者,也适合准备写数据比对、单元测试断言逻辑的人参考。
1. 先把语义钉死:equal() 到底在比较什么
1.1 "两个范围相等"不是"两个集合相同"
std::equal的语义可以概括成一句话:检查两个范围是否长度相同,并且按位置一一对应的每一对元素都满足相等条件。注意关键词是"按位置一一对应"。
这意味着:
{1, 2, 3}和{1, 2, 3}→ true{1, 2, 3}和{3, 2, 1}→ false,虽然是同样的元素集合{1, 2, 3}和{1, 2}→ 在三迭代器版本里是未定义行为;在六迭代器版本里是 false{1, 2, 3}和{1, 2, 3, 3}→ 六迭代器版本返回 false
很多人刚接触时会把 equal 和"元素集合相同"搞混。如果你真正想要的是"两个序列里出现的元素完全一样、但顺序无所谓",那std::equal从一开始就不该出现在备选名单里。正确的做法是先排序再std::equal,或者用std::unordered_multiset做频率统计。明确语义边界,是正确使用这个算法的第一步。
1.2 为什么很多场景不能直接用 ==
有人会问:两个 vector 比较内容,直接写lhs == rhs不就行了吗?确实,对同一个容器类型来说,operator==内部基本就是逐元素比较,和 equal 干的事几乎一样。但operator==有两个明显限制。
第一个限制:必须是同一种容器类型。std::vector<int>和std::deque<int>之间没有operator==,如果我只关心元素内容是否一致、不在乎底层容器是谁,==直接编译不过,而std::equal只要两边迭代器能解引用、元素能比较就行:
std::vector<int> vec{1, 2, 3, 4}; std::deque<int> deq{1, 2, 3, 4}; // vec == deq // 编译错误,类型不匹配 bool ok = std::equal(vec.begin(), vec.end(), deq.begin()); // true第二个限制:数组和裸指针没有你想要的那个==。两个const char*做==比较的是指针地址,不是字符串内容。两个内建数组连==都不支持,直接编译报错。这些场景都需要 equal 或者其他专门手段来兜底。
2. 两个重载版本:三迭代器和六迭代器到底差在哪
2.1 从历史包袱到 C++14 的补全
std::equal在最早期只有四个参数的形态:
template<class InputIt1, class InputIt2> bool equal(InputIt1 first1, InputIt1 last1, InputIt2 first2);也就是平时说的"三迭代器版本":接收第一个范围的起止,再接收第二个范围的起点。标准库认为调用者有责任保证第二个范围至少有last1 - first1个元素。这也是我朋友那段代码崩溃的根源——他第二个 vector 只有 3 个元素,但算法会往后读 4 个。
C++14 开始补上了完整版本:
template<class InputIt1, class InputIt2> bool equal(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2);这就是"六迭代器版本"。两个范围的起止都传进去,算法内部自己判断长度关系:
- 如果
std::distance(first1, last1) != std::distance(first2, last2),直接返回false - 长度相同时,逐元素比较
需要强调的是,这个补全等了十几年,原因是早期 STL 设计里大量算法(copy、transform、fill)都采用"另一边只需要起始迭代器"的约定,方便配合插入迭代器使用。从便捷性来说这个约定有它的道理,但从安全性来说它把大量责任推给了调用者。所以如果你用的 C++ 标准是 14 或更高,没有特殊理由就别再用三迭代器版本。
2.2 两个版本的语义差异对照
三迭代器版本和第二范围长度无关,也就没有"长度不等返回 false"这个分支;六迭代器版本则把长度判断内建在算法逻辑里。两者的对比可以整理成一张表:
| 对比项 | 三迭代器版本equal(f1, l1, f2) | 六迭代器版本equal(f1, l1, f2, l2) |
|---|---|---|
| 第二范围长度校验 | 不校验,调用者保证 | 算法内部检查 |
| 第二范围更短时 | 未定义行为 | 返回 false |
| 第二范围更长时 | 忽略多余部分 | 返回 false |
| 比较次数上限 | 最多last1 - first1次 | 最多min(len1, len2)次 |
| C++ 标准 | C++98 起就有 | C++14 起 |
| 工程安全性 | 低,容易越界 | 高 |
实际写代码时,我几乎总是优先选择六迭代器版本。即使我事先知道两个容器size()一样,也会选择把end()也传进去。原因有两个:一是代码自文档化,读者一看就知道两个范围都是完整的;二是将来如果有人把其中一个容器换成另一种结构、长度关系发生变化,代码不会悄悄变成 UB,而是返回一个可预期的 false。
3. "按条件相等"的谓词版本:从忽略大小写到浮点容差
3.1 谓词版本的语义其实很宽松
带谓词的std::equal长这样:
template<class InputIt1, class InputIt2, class BinaryPredicate> bool equal(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2, BinaryPredicate pred);它的语义是:对每一对位置对应的*first1和*first2调用pred(*first1, *first2),一旦结果为false,整个算法返回false。换句话说,这里的"相等"完全由你的谓词定义。
这个设计非常开放,也带来几个很容易被忽略的点。
第一,谓词只按pred(a, b)这个方向调用,不要求对称。如果你写了一个pred(x, y)只在某个方向成立,那比较结果会出乎意料。比如std::less<>作为谓词传给 equal,语义就变成了"检查每个左边元素是否严格小于右边对应元素",这显然不是大多数人口中的"相等",但它合法。
第二,谓词接收的是元素的引用,而不是迭代器。很多初学者误以为可以写一个接收迭代器的谓词,其实不行。元素的类型可以不同:比如第一个范围是int,第二个范围是double,只要谓词能同时接受这两种类型,算法就能工作。
第三,标准没有规定谓词被调用的次数上限,只说"没发现不匹配就持续比较"。因此不要在谓词里放计数器、改外部状态、做 IO 这类有副作用的事情。你要一个统计结果,自己在循环外算好,别指望谓词恰好被调用了多少次。
3.2 场景一:忽略大小写的字符比较
最常见的"按条件相等"需求就是忽略大小写的字符串比较。很多人会写成:
auto iequals = [](char a, char b) { return std::tolower(a) == std::tolower(b); };这段代码有个隐蔽问题:std::tolower的参数要求是非负的unsigned char或EOF,直接传char在部分平台(比如 char 是 signed 的)上,遇到扩展 ASCII 字符会变成负数,属于未定义行为。稳妥写法:
auto iequals = [](char a, char b) { return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b)); };配合std::equal就可以比较两个std::string是否在忽略大小写后一致。由于实际上我们应该优先用std::string_view而不是const char*来传递字符串,比较代码也会干净很多。顺便提一句:如果你的项目是 C++20 或以后,直接用std::ranges::equal配合投影会更优雅,后文会展开说。
3.3 场景二:浮点数的容差比较
浮点数的==在绝大多数工程场景下都不靠谱。某个计算结果经过不同的汇编指令顺序、不同编译优化等级,最后一位可能完全不同。这种情况下"是否相等"应该变成"是否足够接近"。最基础写法:
auto close_enough = [](double a, double b) { return std::fabs(a - b) < 1e-9; }; bool ok = std::equal(v1.begin(), v1.end(), v2.begin(), v2.end(), close_enough);但这里有个新手很容易忽略的细节:绝对容差 1e-9 对 1.0 和 1.000000001 是合适的,对 1e9 就不合适了——浮点数的精度是相对量级变化的。更稳的工程做法是相对容差:
auto close_enough = [](double a, double b) { return std::fabs(a - b) <= 1e-6 * std::max({1.0, std::fabs(a), std::fabs(b)}); };这个写法表示"相对误差不超过百万分之一,同时绝对误差至少以 1.0 为基准",比固定 eps 靠谱得多。还有一点:NaN。如果两个序列里都有 NaN,==判断为 false,fabs差值判断也是 false,于是 equal 返回 false。但很多算法场景里 NaN 应该被视为"非正常数据"而不是"不相等",所以比较浮点序列前,最好先约定好 NaN 的处理策略,再写谓词。
3.4 场景三:比较一个对象的某个维度
谓词版本最常见的价值在于"投影后比较",虽然 C++20 之前没有真正的投影,但你可以直接在谓词里访问成员。比如比较两个Point数组是否在忽略颜色的条件下几何坐标一致:
struct Point { double x, y; int color; }; auto same_coords = [](const Point& a, const Point& b) { return a.x == b.x && a.y == b.y; }; bool ok = std::equal(pts1.begin(), pts1.end(), pts2.begin(), pts2.end(), same_coords);再比如比较两个"文件路径解析结果"集合时,只关心文件名而不关心大小写敏感字段;比较两个网络报文结构时,只关心协议字段不关心时间戳。这些都是谓词版本的主场。我接手过不少老项目,里面用memcmp直接比较整个结构体的逻辑,一旦结构体里有 padding 字节、指针值或浮点,就全是地雷。用std::equal加谓词,语义清晰,也把"哪些字段参与比较"显式写出来了,可维护性高很多。
4. 最容易翻车的几个场景:UB、指针比较、迭代器失效
4.1 三迭代器版本的长度不匹配:经典未定义行为
这个坑我已经在开头见过了。值得强调的是,它不是每次都崩、每次都给错误结果。未定义行为的意思就是:有时候越界读恰好读到的是想要的数据,返回 true;有时候读到别的内存,返回 false;有时候直接段错误。这导致问题极难复现,也极难排查。
排查这一类问题,第一利器是 ASan/UBSan。如果代码里大量使用了三迭代器版本的std::equal、std::copy、std::transform,我建议直接在 CI 里开上 AddressSanitizer,它能在越界读的第一时间帮你抓到现场。第二利器是改用六迭代器版本。第三利器是在调用前主动断言长度:
assert(lhs.size() == rhs.size());但要注意,assert 只在 debug 构建生效。不要把 assert 当运行时保护。
4.2 C 字符串和裸指针:你以为在比内容,其实在比地址
字符串是 equal 使用中误用重灾区。先看这段:
std::equal(str1.begin(), str1.end(), str2.begin()); // 如果 str1 和 str2 是 std::string,没问题但如果是const char*指针,事情就不一样了。有两个错位点。
第一个错位点是str1 == str2比较的是指针地址,而字符串内容相同不等于指针地址相同。第二个错位点是如果用 equal 去比较两个字符数组,比如:
char a[] = {'a', 'b', 'c', '\0'}; char b[] = {'a', 'b', 'c', '\0'}; bool ok = std::equal(a, a + sizeof(a), b);这个判断是 true,但如果你写的是std::equal(a, a + 3, b),'\0' 没有被纳入比较,就不代表整个 C 字符串相等。反过来,某些以\0结尾的字符串内容相同,但如果用sizeof去比较两个长度不同的数组,结果会是 false,即使它们作为 C 字符串是"同一个字符串"。处理 C 字符串请记住一条原则:不要用数组长度去比较字符串,而是先strlen或者直接用std::string_view,让字符串的长度概念消失:
std::string_view sv1(str1), sv2(str2); bool ok = std::equal(sv1.begin(), sv1.end(), sv2.begin(), sv2.end());4.3 元素本身是指针的情况
还有一个不太容易立刻反应过来的场景:std::vector<const char*>之间用 equal 比较,默认的相等逻辑比较的是指针值,不是指针指向的字符串。两个 vector 里分别存了不同的字符串字面量,但内容一样,用默认的 equal 得到 false,很多开发者想不明白为什么。
这时候需要显式传入字符串比较谓词:
std::vector<const char*> v1{"hello", "world"}; std::vector<const char*> v2{"hello", "world"}; auto cstr_eq = [](const char* a, const char* b) { return std::strcmp(a, b) == 0; }; bool ok = std::equal(v1.begin(), v1.end(), v2.begin(), v2.end(), cstr_eq);从 C++17 起更推荐直接改用std::vector<std::string_view>,这样默认 equal 逻辑就能用上string_view的内容比较,但如果你要维护老代码,谓词参数绕不开。这个例子也再次说明:equal 默认的"相等"是元素类型的operator==,元素是什么,编译器就按什么来比。
4.4 迭代器失效与重叠范围
很多 STL 算法都要求"输入范围与输出范围不能重叠",否则行为未定义。std::equal不是输出型算法,它只读取,所以两个被比较的范围理论上可以重叠。比如自己和自己偏移一位比较,这是合法且有用的:
std::vector<int> v{1, 1, 2, 2, 3}; bool has_adjacent_dup = std::equal(v.begin(), v.end() - 1, v.begin() + 1);但使用六迭代器版本时,如果两个范围重叠且迭代器类型是单向的,要小心迭代器在比较过程中是否会互相影响。实践中我见到的重叠场景大多出现在字符串/数组的相邻元素比较上,用原始指针或随机访问迭代器通常没问题。真正的危险是另一种情况:在调用 equal 的过程中,某个元素的容器被其他线程修改了,导致迭代器失效。多线程下涉及容器读取比较的,该加锁加锁,该用std::shared_mutex保护就保护,算法本身不提供任何并发安全保证。
4.5 谓词的副作用与性能陷阱
前面提到过谓词不能有副作用。补充一个更实际的例子:曾经有人想用 equal 统计两个数组有多少个位置不相等,于是写了一个谓词,每返回 false 就++counter。这个想法从根上就错了:std::equal一旦遇到第一个 false 就会提前返回,后面的元素根本不会被遍历到,counter 必然偏小。想要统计不相等位置的数量,应该用std::mismatch循环,或者直接用std::transform配合计数。
另外一个性能陷阱是谓词的调用开销。如果谓词是虚函数、或者捕获了大量状态的 lambda,编译器很可能做不了内联,比较成本会翻几倍。对性能敏感的代码路径,优先让谓词成为无捕获的 lambda 或者普通函数,给编译器内联机会。实测下来,std::equal使用无捕获 lambda 和一个手写的 for 循环在开启优化后几乎不差,但代码可读性高出一大截。
5. 选型对比:equal、mismatch、lexicographical_compare 还是 operator==
5.1 equal 和 mismatch:一个只回答 yes/no,一个告诉你位置
std::mismatch是 equal 的"兄弟算法"。它返回一对迭代器,指向两个范围中第一对不匹配的元素:
auto [p1, p2] = std::mismatch(v1.begin(), v1.end(), v2.begin()); if (p1 == v1.end()) { // 全部匹配 }在实现层面,equal 完全可以由 mismatch 表达:mismatch(f1, l1, f2).first == l1。反过来 mismatch 做不了 equal 的事——它给了你精确诊断信息。什么时候需要 mismatch?比如你要在比对两个配置文件解析结果时,不仅要判断"是否相等",还要在不等时告诉用户"第一个差异出现在第 347 个字段,左边是 x,右边是 y"。这种输出差异明细的需求,equal 给不了,mismatch 是天然选择。
需要注意的是,mismatch 同样有三迭代器版本的长度不匹配 UB 问题,C++14 后也补了六迭代器版本。选型建议:只要你的业务需求是"知道是否全等",用 equal;只要你的业务需求是"找到第一处不同并处理",用 mismatch。不要为了省一个函数调用而用 mismatch 再手判 end(),语义上不如 equal 直接。
5.2 equal 和 lexicographical_compare:一页相等,一页是排序
std::lexicographical_compare做的事情是字典序比较:从头开始比较,第一个不同的元素决定整个序列的比较结果;如果一个序列是另一个的前缀,短的那个更小。它主要服务于排序、查找最大最小等需要全序关系的场景。
光看名字容易搞混:两者都涉及"比较元素",但问题完全不同。equal 回答"两个序列对应位置是否全部相同",lexicographical_compare 回答"两个序列谁应该在前面"。如果你需要对一堆字符串/结构体做排序,绝不能用 equal 作为比较器,它不满足严格弱序(strict weak ordering),因为 equal 的结果不是全序。反之,如果你只想判断两个序列内容是否一致,用 lexicographical_compare 也显得绕。
一个典型场景最容易混淆:判断两个字符串是否相等时,有人用!lexicographical_compare(a, b) && !lexicographical_compare(b, a)。这在功能上等价于 equal(对同类型范围),但读代码的人要多想三步。直接 equal 就是最清晰的表达。
5.3 operator==、equal 和 memcmp:三个层次的比较
| 需求 | 推荐手段 | 原因 |
|---|---|---|
| 同类型容器整体比较 | a == b | 语义最直观,内部基本也是逐元素比较 |
| 不同类型容器、子范围比较 | std::equal | 摆脱容器类型限制,可指定范围 |
| 整个结构体内存块比较 | 不推荐memcmp | padding、指针、浮点表示都会坑人 |
| 字符串内容比较 | std::string::operator==或string_view | 自带长度解析,不会比到 '\0' 外 |
| 忽略大小写/容差比较 | std::equal+ 谓词 | 谓词完全掌控"相等"的定义 |
关于memcmp多说一句。标准库的某个实现完全可以在内部用memcmp优化std::equal,那是它的事;你自己在主业务代码里用memcmp去比较结构体,是另一回事。结构体有 padding 字节时,成员赋值不保证 padding 位一致,两个"逻辑上相等"的结构体memcmp可能不相等。含指针成员时,memcmp比较的是地址值。含浮点时,+0.0和-0.0在memcmp眼里不同但==相等。这些边界条件决定了我们写应用层代码时不该把memcmp当万能钥匙。
5.4 手写循环什么时候能赢
很多人会问:equal 本身就是一个循环,我直接手写 for 是不是更灵活?
手写循环在某些场景确实更灵活:你可以在循环体里同时处理差异统计、日志打印、异常收集;你也可以在比较过程中根据某些条件跳过部分元素。这些需求已经超出了 equal 的能力边界,强行用 equal 会让代码变得很拧巴。但如果只是单纯地"逐元素判断是否相等",手写循环没有任何优势,反而有三个劣势:
- 容易忘记处理长度不匹配;
- 容易把 short-circuit 逻辑写错,多比较了不必要的元素;
- 可读性差,review 代码的人要重新理解你的循环意图。
std::equal这个名字本身就是文档,看到它就知道代码想表达什么。能用算法表达的逻辑,就不要退化成循环。
6. 实现细节、性能观察与工程建议
6.1 短路行为与复杂度
std::equal在遇到第一对不匹配元素时立即返回 false,不会继续往后比较。这个"短路"特性在很多场景下是巨大的性能红利。比如两个很长的 vector 在开头第 3 个元素就不一样,equal 只需要做 3 次比较,复杂度远小于 O(N)。
但反过来要意识到:当两个范围完全相等时,equal 的复杂度是 O(N),而且必须把每个元素都看一遍。这没有优化空间,因为"两个序列完全相等"这个结论本身就需要读取全部元素才能证明。所以如果 someone 告诉你"可以用 equal 比较两个文件内容一致性",请提醒他,equal 只适合在内存里的数据之间比较,文件级的大规模比对应该先比大小、比哈希,进一步再逐块比较。
6.2 标准库可能做的一些优化
标准库没有义务做某些优化,但主流实现都做了。一个常见优化是:当迭代器是连续存储迭代器(比如裸指针、vector 迭代器),且元素类型满足"可平凡复制 + 可逐字节比较"时,std::equal的实现会退化成memcmp级别的快速比较。这些细节在文档里通常不写,但对实际性能影响很大。
这给我们的启发是:不要自己写for (i...) if (a[i] != b[i]) return false去"优化" equal。标准库团队早就把这一层优化做进去了,而且做得很稳。你手写循环反而可能因为没开优化、没做矢量化而更慢。同时这也解释了为什么我们应该尽量避免在"逐字节可比较"的容器里塞带 padding 的结构体——会失去这种优化空间。
6.3 C++20 的 ranges::equal:一句代码搞定投影比较
C++20 以后,std::ranges::equal带了投影(projection)支持:
namespace ranges = std::ranges; bool ok = ranges::equal(v1, v2, ranges::equal_to{}, [](const Point& p) { return std::tie(p.x, p.y); }, [](const Point& p) { return std::tie(p.x, p.y); });这个版本最大的好处是,你不再需要把"取哪个字段"塞进谓词里,投影参数直接把参与比较的维度提取出来,比较器本身可以用标准的equal_to{}。代码语义更清晰,也更容易复用投影函数。
如果你还在维护 C++17 或更早的代码,用谓词版 equal 也能做到同样的事,只是写法稍显繁琐。新项目我建议直接上 ranges 版本,毕竟 C++20 已经是2020年的标准了,主流编译器支持得都很成熟。不过要注意,std::ranges::equal的返回值语义和旧版完全一致,不要太激进地一次性替换老代码,留点时间给测试。
6.4 工程上的几条硬建议
先说最重要的一条:能传 last2 就传 last2。无论你内心多么确信第二个范围一定够长,传上 end() 都不吃亏,代码也更安全。遇到那种"从外部接口拿到的迭代器对只有 begin() 没有 end()"的场合,老老实实先算长度再调用三迭代器版本,并加上长度断言。
第二条:比较之前先判断长度。六迭代器版本内部会做长度判断,但对随机访问迭代器来说,这个判断的开销可以忽略。问题在于某些自定义迭代器的distance是 O(N) 的,提前调用size()判断就能省掉一次 O(N) 扫描。对各种自定义容器做 equal 前,我习惯先检查lhs.size() != rhs.size()。
第三条:谓词一律写成不修改外部状态的纯函数。谓词可能在 compare 期间被调用多次,也可能因为短路而少调用,任何 side effect 都靠不住。如果确实需要记录比较过程,用 mismatch 加循环。
第四条:移动语义和类型转换要小心。std::equal不会移动元素,不会改变序列,这点毋庸置疑。但元素类型的隐式转换可能在谓词触发时产生临时对象,如果临时对象构造代价高,性能会受影响。自定义类型之间做跨类型比较时,最好让谓词显式接收两种类型,避免隐式转换。
6.5 一次"全等但总报错"的排查经历
最后分享一个我印象很深的排查经历。某次数据同步工具上线后,日志里频繁出现"两份数据不一致"的告警,但手工检查数据文件,肉眼看上去都一样。排查了很久,最后发现代码写的是:
bool same = std::equal(data1.begin(), data1.end(), data2.begin(), [](const Record& a, const Record& b) { return std::tie(a.id, a.amount, a.timestamp) == std::tie(b.id, b.amount, b.timestamp); });Record 里明明还有一个internal_flags字段,用来记录数据在内存中的临时状态,磁盘落盘时不该参与比较。但因为当初偷懒,直接把这个内部字段也放进了 tie 里,导致两边 internal_flags 不一致时总是报错。把字段从比较维度里摘除后,告警立刻消失。
这件事给我的教训是:用谓词版 equal 时,"参与比较的字段"是业务逻辑的一部分,要像设计接口一样认真设计,不能默认结构体里所有字段都要参与比较。很多线上数据校验问题,根源不是算法不会用,而是"什么叫相等"这件事没和业务方对齐。std::equal给了你完全控制权,你就得把比较规则明确写出来,最好再配几条单测锁定行为。
7. 一些真正值得养成的习惯
7.1 给 equal 写单元测试时,覆盖边界比覆盖主路径更重要
很多人写 equal 相关单测时只测"两个完全相同数组返回 true,一个不同返回 false"。但以我的经验,最容易出问题的是边界:空范围与空范围比较返回 true;空范围与非空范围比较返回 false;两个范围等长但最后一个元素不同;两个范围等长但第一个元素不同。尤其是空范围,STL 算法对空范围的语义往往是"空集合之间相等",如果业务代码没注意这一点,可能在加载空配置文件时误判为"配置一致"。
建议测试用例里至少包含这些:全等、首元素不同、尾元素不同、中间元素不同、一个为空、两个都为空、带谓词且谓词对某些元素有特殊处理(比如忽略大小写)。用谓词版 equal 时,额外测试谓词不要产生副作用。
7.2 和"长度相等"搭配使用时,考虑要不要先比大小再比较
对于 vector、string、array 这类随机访问容器,size()是 O(1),先比大小再 equal 通常没有坏处。但对于 list、forward_list 这类容器,size()在 C++11 前是 O(N),C++11 后 O(1),但调用size()一次 + equal 再遍历一次,等于多一次完整遍历。如果两个容器长度不等是高频场景,先比大小能短路,收益明显;如果长度相等是高频场景,多一次 size 遍历反而亏。工程上没有绝对答案,结合数据分布选择就好。
我个人的默认写法是:
template<typename L, typename R> bool same_content(const L& l, const R& r) { if (l.size() != r.size()) { return false; } return std::equal(l.begin(), l.end(), r.begin(), r.end()); }这个模板对任何"提供 size() 和双向迭代器"的容器都适用,语义也比直接写六迭代器版本更直白。六迭代器版本适合不关心 length equality 或在长度无法获知的场合使用的场景。
7.3 别用 equal 比较"有序映射的键集合"
有人想比较两个std::map的键是否相等,会写std::equal(m1.begin(), m1.end(), m2.begin(), [](const auto& p1, const auto& p2){ return p1.first == p2.first; })。功能上没问题,但效率上很亏——std::map的迭代器不是随机访问,每次operator++都伴随指针跳转。更好的做法是直接用容器的operator==,它内部会同时比较键和值,并且利用了红黑树的比较结构。总之,能用容器自带比较操作符解决的,不要让 equal 插手;equal 更适合处理子区间、跨类型容器、自定义谓词这些"容器比较操作符表达不了"的需求。
7.4 使用 equal 前,先想清楚"相等"意味着什么
给一段老代码做重构时,我看到过这样的注释:"这里用 equal 比较两份订单数据是否一致"。但实际比较时用的是默认operator==,而订单结构体里的浮点金额是直接计算的,没有经过任何舍入处理。结果就是同一笔订单在不同机器上计算出的金额末位不同,导致同步任务反复失败。
这个问题不是 equal 的锅,但 equal 让它暴露得特别明显:当你把"相等"完全交给默认行为时,你就放弃了定义权。使用这个算法的最重要姿势,不是记牢参数个数,而是先问自己一句:对我来说,这两个元素在什么条件下算"相等"?想清楚了,再去选默认版本还是谓词版本。
回到开头那个朋友的问题,如果他当初写的是六迭代器版本,那段代码根本不会崩。很多 STL 的坑都是这样:算法本身没有任何错误,只是调用者没有意识到自己的责任边界在哪里。std::equal的完整故事讲完,我希望你记住两件事:一是能传 end 就传 end;二是遇到"比较标准需要自定义"的场景,谓词不是备选方案,而是正路。