省流:用不到的不要include進來。
這篇文章主要講講c++的ADL,順便說說為什么很多c++的IDE都會讓你盡量不要include用不上的頭文件。
和其他c++文章一樣,這篇也會有基礎(chǔ)回顧環(huán)節(jié),所以不用擔(dān)心看不懂,但讀者最好還是得有c++的基礎(chǔ)知識并且對c++11之后的內(nèi)容有所了解。
好了,下面我們進入正題吧。
偶遇報錯
最近工作收尾有了不少空閑時間,于是準備試試手頭環(huán)境的編譯器對新標準的支持,以便選擇合適的時機給自己的幾個項目做個升級。
雖然有現(xiàn)成的工具的網(wǎng)站可以查詢編譯器對新標準的支持情況,但這些網(wǎng)站給的信息還是不夠詳細,有時候得寫些例子手動編譯做測試。我是個懶人,所以我不愿意花時間自己寫,而AI又對新標準理解的不夠透徹,可能是語料太少的緣故,總是寫出點離譜的東西。無奈之下我只能去網(wǎng)上找現(xiàn)成的吃了,cppreference是個不錯的選擇,用的人很多而且比較權(quán)威,更棒的是對于新特性它一般都給出了示例代碼,這正中我的下懷。
于是我搬了這樣一段代碼進行測試,預(yù)想中要么編譯成功要么新特性不支持導(dǎo)致編譯失敗:
#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有關(guān)。
然而事實出人意料:
$ 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函數(shù)報錯了,和zip完全不相關(guān),難道說cppreference上例子會有這么明顯的錯誤?但檢查了一下print
也只用到了早就支持的c++20的語法并不存在錯誤,而且換成gcc和Linux上的clang18之后都能正常編譯。
這還只是第一個點異常,仔細閱讀報錯信息就會發(fā)現(xiàn)第二點了:我們沒有導(dǎo)入c++23的新標準庫<print>
,為什么我們自定義的print
會和std::print
沖突呢?
看到這里是不是已經(jīng)按耐不住自己想轉(zhuǎn)投Rust的心了?不過別急,盡管報錯很離奇但原因沒那么復(fù)雜,聽我慢慢解釋。
基礎(chǔ)回顧
基礎(chǔ)回顧是c++博客少不了的環(huán)節(jié),因為語法太多太瑣碎,不回顧下容易看不懂后續(xù)的內(nèi)容。
限定和非限定名稱
第一個要回顧的是限定名稱
和非限定名稱
這兩個概念。國內(nèi)有時候也會把非限定名稱叫做無限定名稱,我覺得后者更符合中文的語用習(xí)慣,不過我這兒一直非限定非限定的習(xí)慣了所以就不改了。
如果要照著標準規(guī)范念經(jīng),那可有得念了,所以我會有通俗易懂的方式解釋,這樣多少會和真正的標準有那么點出入,還請語言律師們海涵。
簡單的說,c++里如果一個標識符光禿禿的,比如print
,那么它是非限定名稱;而如果一個名字前面包含命名空間限定符,比如::print, std::print, classA::print
,那么它是限定名稱。
他倆有啥區(qū)別呢?限定名稱的限定指的是指定了這標識符出現(xiàn)在那個命名空間/類里,編譯器只能去限定的地方查找,沒找到就是編譯錯誤。而非限定名稱,因為沒限制編譯器去哪找這個標識符,所以編譯器會從當(dāng)前作用域開始,一路往上走查找每個父作用域/類以找到這個標識符,注意同級的命名空間/類不會進行搜索。
舉個例子:
#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
函數(shù)就是在這個匿名的命名空間中,這個空間和std
是平級關(guān)系。
非限定名稱可以讓程序員以自然的方式引入外層作用域的名字,而限定名稱則提供了一個防止名稱沖突的機制。
ADL
理解了限定和非限定名稱,下面我們再看看這行代碼:
std::cout << A::b << '\n';
注意那個<<
,c++允許進行運算符重載,所以它的真身其實是std::ostream& operator<<(...)
,并且這個運算符是定義在std
這個命名空間中的。
因為我們沒有限定運算符的命名空間(按照運算符當(dāng)前的調(diào)用方式我們也沒法進行限定),所以編譯器會從當(dāng)前作用域開始逐層往上查找。但我們的代碼中沒有定義過這個運算符,std則不在非限定名稱的搜索范圍內(nèi),理論上編譯器不應(yīng)該報錯說找不到operator<<
嗎?
事實上程序可以正常編譯,因為c++還有另外一套名稱查找策略,叫ADL——Argument Dependent Lookup。
簡單的說,如果一個函數(shù)/運算符是非限定名稱,而它的實際參數(shù)的類型所在的命名空間里定義有同名的函數(shù),那么編譯器就會把這個和實參類型在同一空間的函數(shù)當(dāng)成這個非限定名稱指代的函數(shù)/運算符。當(dāng)然真實環(huán)境下編譯器還得考慮可見性和函數(shù)重載決議,這里我們不細究了。
還是以上面那行代碼為例,雖然我們沒有重載<<
,但<iostream>
里有在std里重載,而我們的實際參數(shù)是std::cout
,類型是std::ostream&
,所以ADL會去命名空間std中查找是否有符合調(diào)用形式的operator<<
,編譯器會發(fā)現(xiàn)正好有完全合適的運算符存在,所以編譯成功不會報錯。
另外ADL只適用于函數(shù)和運算符(也算一種特殊的函數(shù)),lambda、functor等東西觸發(fā)不了ADL。
ADL最大的用處是方便了運算符重載的使用。否則,我們不得不寫很多std::operator<<(a, b)
這樣的代碼,這既繁瑣又不符合自然習(xí)慣。此外c++還有一些基于ADL的慣用法,例如我之前介紹過的copy-and-swap慣用法。
不過除了少數(shù)正面作用,ADL更多的時候是個trouble maker,本文開頭那個報錯就是活生生的例子。
報錯原因
復(fù)習(xí)完基礎(chǔ)我們再看報錯信息:
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
是非限定名稱,于是非限定名稱的查找觸發(fā),找到了我們定義的print,ADL也被觸發(fā),因為編譯器要找出所有可行的函數(shù)或者函數(shù)模板然后用重載決議確定調(diào)用哪一個,于是c++23的新函數(shù)std::print
被找到。
不巧的是兩個函數(shù)雖然參數(shù)形式不太一樣,但誰也不比誰更特殊化,導(dǎo)致出現(xiàn)調(diào)用的二義性,編譯器不知道該用我們的模板函數(shù)還是標準庫的,報錯了。
正是ADL把我們不需要的函數(shù)加入了重載決議過程,cppreference上那段代碼才會報錯。
排查和處理
首先要排查問題是誰引起的。
看起來鍋全是ADL的,但引入了<print>
的家伙其實要分一半的鍋,因為不引入這東西我們的代碼里是沒有std::print
的,編譯器就算用了ADL也不會看到這個干擾項。
那么多頭文件,一個個看是看不完根本看不完。不過我們能縮小范圍。
std::print
是輸出相關(guān)的,標準庫實際上有一定要求不能隨便亂include文件,所以我們可以先鎖定<iostream>
;其次標準庫的容器有時候會對一些模板做特殊化,這些特殊化的模板當(dāng)然也能被ADL找出來,所以容器的頭文件也需要檢查,萬一他們特殊處理了std::print
也說不定,不過鑒于vector,array,list都報錯了,那說明我們只需要看其中一個就行,我選擇<array>
,因為比起另外兩個std::array
的結(jié)構(gòu)更簡單功能相對也少一些,所以代碼也相對更少更方便檢查。
我先檢查了<array>
和它include的所有文件,并未發(fā)現(xiàn)<print>
。
所以我又檢查了<iostream>
,bingo,罪魁禍首是它include的<ostream>
:
#if _LIBCPP_STD_VER >= 23
# include <__ostream/print.h>
#endif
檢測到在用c++23就導(dǎo)入<__ostream/print.h>
,而這個頭文件里直接#include <print>
了。
原因找到,現(xiàn)在該想想如何修復(fù)了。
修起來也簡單,要么讓我們自定義的print更加特殊使其在重載決議中勝出,要么使用限定名稱直接屏蔽掉std,或者干脆給函數(shù)改個名字。
我只是想試試編譯器支不支持新的ranges
函數(shù),懶勁發(fā)作不想動腦子,所以選了第二種,畢竟加個::
就完事了:
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
現(xiàn)代C++ IDE一般都會在你include沒用的頭文件時給出提示或警告,這不僅僅是因為會拖累編譯速度。
上面的例子告訴你了:include了沒用的東西有時候會影響c++的名稱查找導(dǎo)致莫名其妙的錯誤。
但話說回來,同樣的代碼g++并未報錯,為啥呢,因為g++用的libstdc++直接實現(xiàn)了std::print
對std::ostream
的重載,而沒#include <print>
,事實上從libstdc++的代碼來看這個include也沒有必要。Linux上的clang除非特殊指定否則和g++用的同一套標準庫代碼,所以沒有報錯。macOS上的clang用的是libcxx,就遇上問題了。
當(dāng)然我沒看libcxx的代碼不好說它這個include是對是錯,也許它的代碼里不得不這樣做也未可知。
總結(jié)
c++就像古神,要不是我正好熟悉這塊的語言規(guī)則好奇心也比較重,這個詭異的報錯就要讓我陷入瘋狂了。
轉(zhuǎn)自https://www.cnblogs.com/apocelipes/p/18968246
該文章在 2025/7/12 17:14:59 編輯過