这篇文章主要讲讲c++的ADL,顺便说说为什么很多c++的IDE都会让你尽量不要include用不上的头文件。

和其他c++文章一样,这篇也会有基础回顾环节,所以不用担心看不懂,但读者最好还是得有c++的基础知识并且对c++11之后的内容有所了解。

好了,下面我们进入正题吧。

偶遇报错

最近工作收尾有了不少空闲时间,于是准备试试手头环境的编译器对新标准的支持,以便选择合适的时机给自己的几个项目做个升级。

虽然有现成的工具的网站可以查询编译器对新标准的支持情况,但这些网站给的信息还是不够详细,有时候得写些例子手动编译做测试。我是个懒人,所以我不愿意花时间自己写,而AI又对新标准理解的不够透彻,可能是语料太少的缘故,总是写出点离谱的东西。无奈之下我只能去网上找现成的吃了,cppreference是个不错的选择,用的人很多而且比较权威,更棒的是对于新特性它一般都给出了示例代码,这正中我的下怀。

于是我搬了这样一段代码进行测试,预想中要么编译成功要么新特性不支持导致编译失败:

#include <array>
#include <iostream>
#include <list>
#include <ranges>
#include <string>
#include <tuple>
#include <vector> void print(auto const rem, auto const& range)
{
for (std::cout << rem; auto const& elem : range)
std::cout << elem << ' ';
std::cout << '\n';
} int main()
{
auto x = std::vector{1, 2, 3, 4};
auto y = std::list<std::string>{"α", "β", "γ", "δ", "ε"};
auto z = std::array{'A', 'B', 'C', 'D', 'E', 'F'}; print("Source views:", "");
print("x: ", x);
print("y: ", y);
print("z: ", z); print("\nzip(x,y,z):", ""); for (std::tuple<int&, std::string&, char&> elem : std::views::zip(x, y, z))
{
std::cout << std::get<0>(elem) << ' '
<< std::get<1>(elem) << ' '
<< std::get<2>(elem) << '\n'; std::get<char&>(elem) += ('a' - 'A'); // modifies the element of z
} print("\nAfter modification, z: ", z);
}

很简单的代码,测试一下c++23的ranges::views::zip,如果要报错那么多半也是和这个zip有关。

然而事实出人意料:

$ clang++ -std=c++23 -Wall test.cpp
test.cpp:23:5: error: call to 'print' is ambiguous
23 | print("x: ", x);
| ^~~~~
test.cpp:9:6: note: candidate function [with rem:auto = const char *, range:auto = std::vector<int>]
9 | void print(auto const rem, auto const& range)
| ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/v1/print:343:28: note: candidate function [with _Args = <std::vector<int> &>]
343 | _LIBCPP_HIDE_FROM_ABI void print(format_string<_Args...> __fmt, _Args&&... __args) {
| ^
test.cpp:24:5: error: call to 'print' is ambiguous
24 | print("y: ", y);
| ^~~~~
test.cpp:9:6: note: candidate function [with rem:auto = const char *, range:auto = std::list<std::string>]
9 | void print(auto const rem, auto const& range)
| ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/v1/print:343:28: note: candidate function [with _Args = <std::list<std::string> &>]
343 | _LIBCPP_HIDE_FROM_ABI void print(format_string<_Args...> __fmt, _Args&&... __args) {
| ^
test.cpp:25:5: error: call to 'print' is ambiguous
25 | print("z: ", z);
| ^~~~~
test.cpp:9:6: note: candidate function [with rem:auto = const char *, range:auto = std::array<char, 6>]
9 | void print(auto const rem, auto const& range)
| ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/v1/print:343:28: note: candidate function [with _Args = <std::array<char, 6> &>]
343 | _LIBCPP_HIDE_FROM_ABI void print(format_string<_Args...> __fmt, _Args&&... __args) {
| ^
test.cpp:38:5: error: call to 'print' is ambiguous
38 | print("\nAfter modification, z: ", z);
| ^~~~~
test.cpp:9:6: note: candidate function [with rem:auto = const char *, range:auto = std::array<char, 6>]
9 | void print(auto const rem, auto const& range)
| ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/v1/print:343:28: note: candidate function [with _Args = <std::array<char, 6> &>]
343 | _LIBCPP_HIDE_FROM_ABI void print(format_string<_Args...> __fmt, _Args&&... __args) {
| ^
4 errors generated.

print函数报错了,和zip完全不相关,难道说cppreference上例子会有这么明显的错误?但检查了一下print也只用到了早就支持的c++20的语法并不存在错误,而且换成gcc和Linux上的clang18之后都能正常编译。

这还只是第一个点异常,仔细阅读报错信息就会发现第二点了:我们没有导入c++23的新标准库<print>,为什么我们自定义的print会和std::print冲突呢?

看到这里是不是已经按耐不住自己想转投Rust的心了?不过别急,尽管报错很离奇但原因没那么复杂,听我慢慢解释。

基础回顾

基础回顾是c++博客少不了的环节,因为语法太多太琐碎,不回顾下容易看不懂后续的内容。

限定和非限定名称

第一个要回顾的是限定名称非限定名称这两个概念。国内有时候也会把非限定名称叫做无限定名称,我觉得后者更符合中文的语用习惯,不过我这儿一直非限定非限定的习惯了所以就不改了。

如果要照着标准规范念经,那可有得念了,所以我会有通俗易懂的方式解释,这样多少会和真正的标准有那么点出入,还请语言律师们海涵。

简单的说,c++里如果一个标识符光秃秃的,比如print,那么它是非限定名称;而如果一个名字前面包含命名空间限定符,比如::print, std::print, classA::print,那么它是限定名称。

他俩有啥区别呢?限定名称的限定指的是指定了这标识符出现在那个命名空间/类里,编译器只能去限定的地方查找,没找到就是编译错误。而非限定名称,因为没限制编译器去哪找这个标识符,所以编译器会从当前作用域开始,一路往上走查找每个父作用域/类以找到这个标识符,注意同级的命名空间/类不会进行搜索。

举个例子:

#include <iostream>

namespace A {
int a = 1;
int b = 2;
namespace B {
int b = 3; void print()
{
std::cout << b << '\n'; // 非限定名称,就近找到A::B::b
std::cout << a << '\n'; // 非限定名称,找到父命名空间的A::a
std::cout << A::b << '\n'; // 限定名称,直接找到A::b
// 下面这行会报错,因为使用了限定名称,只允许编译器搜索B,B中没有a
// std::cout << B::a << '\n';
}
}
} int main()
{
A::B::print(); // 这也是限定名称
// 输出 3 1 2
}

顺带一提每个编译单元都有一个默认存在的匿名的命名空间,所有没有明确定义在其他命名空间中的标识符都会被归入这个匿名的命名空间。举个例子,前文里我们定义的print函数就是在这个匿名的命名空间中,这个空间和std是平级关系。

非限定名称可以让程序员以自然的方式引入外层作用域的名字,而限定名称则提供了一个防止名称冲突的机制。

ADL

理解了限定和非限定名称,下面我们再看看这行代码:

std::cout << A::b << '\n';

注意那个<<,c++允许进行运算符重载,所以它的真身其实是std::ostream& operator<<(...),并且这个运算符是定义在std这个命名空间中的。

因为我们没有限定运算符的命名空间(按照运算符当前的调用方式我们也没法进行限定),所以编译器会从当前作用域开始逐层往上查找。但我们的代码中没有定义过这个运算符,std则不在非限定名称的搜索范围内,理论上编译器不应该报错说找不到operator<<吗?

事实上程序可以正常编译,因为c++还有另外一套名称查找策略,叫ADL——Argument Dependent Lookup。

简单的说,如果一个函数/运算符是非限定名称,而它的实际参数的类型所在的命名空间里定义有同名的函数,那么编译器就会把这个和实参类型在同一空间的函数当成这个非限定名称指代的函数/运算符。当然真实环境下编译器还得考虑可见性和函数重载决议,这里我们不细究了。

还是以上面那行代码为例,虽然我们没有重载<<,但<iostream>里有在std里重载,而我们的实际参数是std::cout,类型是std::ostream&,所以ADL会去命名空间std中查找是否有符合调用形式的operator<<,编译器会发现正好有完全合适的运算符存在,所以编译成功不会报错。

另外ADL只适用于函数和运算符(也算一种特殊的函数),lambda、functor等东西触发不了ADL。

ADL最大的用处是方便了运算符重载的使用。否则,我们不得不写很多std::operator<<(a, b)这样的代码,这既繁琐又不符合自然习惯。此外c++还有一些基于ADL的惯用法,例如我之前介绍过的copy-and-swap惯用法。

不过除了少数正面作用,ADL更多的时候是个trouble maker,本文开头那个报错就是活生生的例子。

报错原因

复习完基础我们再看报错信息:

test.cpp:23:5: error: call to 'print' is ambiguous
23 | print("x: ", x);
| ^~~~~
test.cpp:9:6: note: candidate function [with rem:auto = const char *, range:auto = std::vector<int>]
9 | void print(auto const rem, auto const& range)
| ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/v1/print:343:28: note: candidate function [with _Args = <std::vector<int> &>]
343 | _LIBCPP_HIDE_FROM_ABI void print(format_string<_Args...> __fmt, _Args&&... __args) {
| ^

我们的x,y,z都是std里的容器类的实例,print是非限定名称,于是非限定名称的查找触发,找到了我们定义的print,ADL也被触发,因为编译器要找出所有可行的函数或者函数模板然后用重载决议确定调用哪一个,于是c++23的新函数std::print被找到。

不巧的是两个函数虽然参数形式不太一样,但谁也不比谁更特殊化,导致出现调用的二义性,编译器不知道该用我们的模板函数还是标准库的,报错了。

正是ADL把我们不需要的函数加入了重载决议过程,cppreference上那段代码才会报错。

排查和处理

首先要排查问题是谁引起的。

看起来锅全是ADL的,但引入了<print>的家伙其实要分一半的锅,因为不引入这东西我们的代码里是没有std::print的,编译器就算用了ADL也不会看到这个干扰项。

那么多头文件,一个个看是看不完根本看不完。不过我们能缩小范围。

std::print是输出相关的,标准库实际上有一定要求不能随便乱include文件,所以我们可以先锁定<iostream>;其次标准库的容器有时候会对一些模板做特殊化,这些特殊化的模板当然也能被ADL找出来,所以容器的头文件也需要检查,万一他们特殊处理了std::print也说不定,不过鉴于vector,array,list都报错了,那说明我们只需要看其中一个就行,我选择<array>,因为比起另外两个std::array的结构更简单功能相对也少一些,所以代码也相对更少更方便检查。

我先检查了<array>和它include的所有文件,并未发现<print>

所以我又检查了<iostream>,bingo,罪魁祸首是它include的<ostream>

#if _LIBCPP_STD_VER >= 23
# include <__ostream/print.h>
#endif

检测到在用c++23就导入<__ostream/print.h>,而这个头文件里直接#include <print>了。

原因找到,现在该想想如何修复了。

修起来也简单,要么让我们自定义的print更加特殊使其在重载决议中胜出,要么使用限定名称直接屏蔽掉std,或者干脆给函数改个名字。

我只是想试试编译器支不支持新的ranges函数,懒劲发作不想动脑子,所以选了第二种,毕竟加个::就完事了:

int main()
{
auto x = std::vector{1, 2, 3, 4};
auto y = std::list<std::string>{"α", "β", "γ", "δ", "ε"};
auto z = std::array{'A', 'B', 'C', 'D', 'E', 'F'}; - print("Source views:", "");
- print("x: ", x);
- print("y: ", y);
- print("z: ", z);
+ ::print("Source views:", "");
+ ::print("x: ", x);
+ ::print("y: ", y);
+ ::print("z: ", z); - print("\nzip(x,y,z):", "");
+ ::print("\nzip(x,y,z):", ""); for (std::tuple<int&, std::string&, char&> elem : std::views::zip(x, y, z))
{
std::cout << std::get<0>(elem) << ' '
<< std::get<1>(elem) << ' '
<< std::get<2>(elem) << '\n'; std::get<char&>(elem) += ('a' - 'A'); // modifies the element of z
} - print("\nAfter modification, z: ", z);
+ ::print("\nAfter modification, z: ", z);
}

修改后的代码可以用g++和clang正常编译,不再会报错。

为什么不能乱include

现代C++ IDE一般都会在你include没用的头文件时给出提示或警告,这不仅仅是因为会拖累编译速度。

上面的例子告诉你了:include了没用的东西有时候会影响c++的名称查找导致莫名其妙的错误。

但话说回来,同样的代码g++并未报错,为啥呢,因为g++用的libstdc++直接实现了std::printstd::ostream的重载,而没#include <print>,事实上从libstdc++的代码来看这个include也没有必要。Linux上的clang除非特殊指定否则和g++用的同一套标准库代码,所以没有报错。macOS上的clang用的是libcxx,就遇上问题了。

当然我没看libcxx的代码不好说它这个include是对是错,也许它的代码里不得不这样做也未可知。

总结

c++就像古神,要不是我正好熟悉这块的语言规则好奇心也比较重,这个诡异的报错就要让我陷入疯狂了。

cppreference上的例子如果有人有兴趣可以尝试下修改,推荐选择给print函数重命名这个方案,这也是为社区做贡献的一次好机会。链接在这里:link

当然我懒抽筋了,这个机会就让给有缘人喽。

记一次ADL导致的C++代码编译错误的更多相关文章

  1. PowerDesginer 生成的Oracle 11g 组合触发器代码编译错误(29): PLS-00103

    问题描述: 采用PowerDesigner15针对Oracle 11g 创建物理数据模型,想实现一个字段的自增,采用如下步骤: 1.创建序列,命名为Sequence_1; 2.在自增字段编辑窗口中,选 ...

  2. Android Studio中解决jar包重复依赖导致的代码编译错误

    在原本的代码中已经使用了OKHTTP和rxjava,然后今天依赖retrofit的时候一直报错 Program type already present: okhttp3.internal.ws.Re ...

  3. 解决TensorFlow最新代码编译错误问题

    老是有个习惯,看到开源代码更新了,总是想更新到最新版,如果置之不理的话,就感觉自己懒惰了或有的不负责任了,这个也可能是一种形式的强迫症吧: 前几天晚上git pull TensorFlow,完事后也没 ...

  4. Maven常见异常及解决方法---测试代码编译错误

    [ERROR] Please refer to E:\maven\web_nanchang\target\surefire-reports for the individual test result ...

  5. c++代码编译错误查找方法之宏

    1.关于 本文演示环境: win10+vs2017 好久不用这法子了,都快忘了 排查错误,思路很重要,且一定要思路清晰(由于自己思路不清晰,查找错误耽误了不少时间,其实问题很简单,只是你要找到他需要不 ...

  6. 解Bug之路-记一次中间件导致的慢SQL排查过程

    解Bug之路-记一次中间件导致的慢SQL排查过程 前言 最近发现线上出现一个奇葩的问题,这问题让笔者定位了好长时间,期间排查问题的过程还是挺有意思的,正好博客也好久不更新了,就以此为素材写出了本篇文章 ...

  7. 因用了NeatUpload大文件上传控件而导致Nonfile portion > 4194304 bytes错误的解决方法

    今天遇到一个问题,就是“NeatUpload大文件上传控件而导致Nonfile portion > 4194304 bytes错误”,百度后发现了一个解决方法,跟大家分享下: NeatUploa ...

  8. JVM原理(Java代码编译和执行的整个过程+JVM内存管理及垃圾回收机制)

    转载注明出处: http://blog.csdn.net/cutesource/article/details/5904501 JVM工作原理和特点主要是指操作系统装入JVM是通过jdk中Java.e ...

  9. 如何提升代码编译的速度 iOS

    前阵子有遇到代码编译速度慢的问题,特别是在swift和object-c混编的过程中问题很突显. 网上找到一篇蛮好的文章里面又一些解决方法 推荐一下 http://www.open-open.com/l ...

  10. Java 代码编译和执行的整个过程

    Java 代码编译是由 Java 源码编译器来完成,流程图如下所示: Java 字节码的执行是由 JVM 执行引擎来完成,流程图如下所示: Java 代码编译和执行的整个过程包含了以下三个重要的机制: ...

随机推荐

  1. RegisterClass注册后getclass总是nil,why?

    这个问题有点老.但是有点烦人. 一般流程是 RegisterClass后通过getclass or findclass就会成功. 可是莫名其妙出现总是返回nil.咱也不清楚,网上找了好久,一个久远的帖 ...

  2. 「硬核实战」回调函数到底是个啥?一文带你从原理到实战彻底掌握C/C++回调函数

    大家好,我是小康. 网上讲回调函数的文章不少,但大多浅尝辄止.缺少系统性,更别提实战场景和踩坑指南了.作为一个在生产环境中与回调函数打了多年交道的开发者,今天我想分享一些真正实用的经验,带你揭开回调函 ...

  3. Devops工程师需要具备的10项技能

    Facebook.Amazon和Microsoft等公司正在大量使用DevOps技术来确保软件的一致交付,DevOps的的工作机会和所需要的技能集也是越来越多. 在这里,我们将讨论Devops工程师需 ...

  4. .net6 中间件

    参照资料: ASP.NET Core 中间件 | Microsoft Learn ASP.NET Core端点路由 作用原理 - 知乎 (zhihu.com) 一.概念 中间件是一种装配到应用管道以处 ...

  5. Oracle、MySQL、SQL Server、PostgreSQL、Redis 五大数据库的区别

    以下是 Oracle.MySQL.SQL Server.PostgreSQL.Redis 五大数据库的对比分析,从用途.数据处理方式.高并发能力.优劣势等维度展开: 一.数据库分类 数据库 类型 核心 ...

  6. Excel工具类之“参数汇总”

    一.SXSSFWorkbook技术 1.冻结行数 代码 SXSSFWorkbook wb = new SXSSFWorkbook(); SXSSFSheet sheet = wb.createShee ...

  7. vue3 学习-初识体验-组件 component

    组件可以简单理解为 "页面构成的一部分". 组件化是 Vue 最为重要的设计理念之一吧. 早期的前端页面基本上就拆分为一个个的html, css, js 文件, 然后不断" ...

  8. 保护网站免受黑客攻击:Web安全的重要性和保护方法

    @charset "UTF-8"; .markdown-body { line-height: 1.75; font-weight: 400; font-size: 15px; o ...

  9. Seata源码—4.全局事务拦截与开启事务处理

    大纲 1.Seata Server的启动入口的源码 2.Seata Server的网络服务器启动的源码 3.全局事务拦截器的核心变量 4.全局事务拦截器的初始化源码 5.全局事务拦截器的AOP切面拦截 ...

  10. 记录一下 nas 当本地网络存储,玩大型游戏再也不用担心硬盘不够用了

    最近由于家里的PC 在玩steam的游戏,原来1T的固态,安装了几个大型的3A游戏,一个黑神话悟空就用了200多G,硬盘就不够用了 查看了京东的2T的固态,还是很贵,要大几百块钱.去年配了一台极空间的 ...