在 C++ 的日常开发中,经常会遇到函数冲突或者找不到匹配的函数的问题,如果不了解编译器处理函数符号查找的行为,就很难去解决这些问题。
【参考 How C++ Resolves a Function Call - https://preshing.com/20210315/how-cpp-resolves-a-function-call/】
1、函数符号查找难点
C 语言的函数调用很简单,每个函数都有唯一的名字。
但 C++ 就复杂得多了,因为其有:
- 函数重载 (overloading)
- 运算符重载 (built-in operators)
- 函数模版 (function templates)
- 命名空间 (namespaces)
2、函数符号查找解析过程
那么 c++ 编译器是如何查找符号的,一图以蔽之。
3、Name Lookup
在符号查找 (name lookup) 阶段,会有三种主要的方式:
- Member name lookup (成员符号查找)
- 发生在符号是在
.
或->
标识符的右边,比如foo->bar
。这种方式是用来定位类的成员符号。
- 发生在符号是在
- Qualified name lookup (全限定名查找)
- 发生在符号带有
::
标识符的情况,比如std::sort
。这种方式的符号查找是非常明确、显式声明的。在::
标识符右边的符号只会在标识符左边的作用域内查找。
- 发生在符号带有
- Unqualified name lookup (非限定名查找)
- 不是上边的那两种。当编译器看到一个非限定名,它会根据上下文信息在各种各样的作用域内来查找能够匹配的声明。
4、Unqualified name lookup
【参考 https://en.cppreference.com/w/cpp/language/unqualified_lookup】
【注:只摘取部分查找符号的情况,感兴趣可以直接看参考链接原文】
4.1、File scope
在全局作用域下,即不处于任何其他作用域(函数、类、自定义命名空间)下,符号在使用之前会被校验。
int n = 1; // declaration of n
int x = n + 1; // OK: lookup finds ::n
int z = y - 1; // Error: lookup fails
int y = 2; // declaration of y
4.2、Namespace scope
在自定义的命名空间下,当前的命名空间在符号被使用之前的那部分会先被查找,然后再一直向外层命名空间的前面部分查找,知道找到或者到达了全局作用域。
int n = 1; // declaration
namespace N {
int m = 2;
namespace Y {
int x = n; // OK, lookup finds ::n
int y = m; // OK, lookup finds ::N::m
int z = k; // Error: lookup fails
}
int k = 3;
}
4.3、Definition outside of its namespace
对于使用命名空间下的符号,其符号查找也会先从命名空间下去查找。
namespace X {
extern int x; // declaration, not definition
int n = 1; // found 1st
};
int n = 2; // found 2nd.
int X::x = n; // finds X::n, sets X::x to 1
4.4、Non-member function definition
函数定义里的符号查找,会先从其 block 块内进行查找,然后就是更外层的 block 块内,最后才会去查找其相关的命名空间。
namespace A {
namespace N {
void f();
int i=3; // found 3rd (if 2nd is not present)
}
int i=4; // found 4th (if 3rd is not present)
}
int i=5; // found 5th (if 4th is not present)
void A::N::f() {
int i = 2; // found 2nd (if 1st is not present)
while(true) {
int i = 1; // found 1st: lookup is done
std::cout << i;
}
}
// int i; // not found
namespace A {
namespace N {
// int i; // not found
}
}
4.5、Class definition
namespace M {
// const int i = 1; // never found
class B {
// static const int i = 3; // found 3rd (but later rejected by access check)
};
}
// const int i = 5; // found 5th
namespace N {
// const int i = 4; // found 4th
class Y : public M::B {
// static const int i = 2; // found 2nd
class X {
// static const int i = 1; // found 1st
int a[i]; // use of i
// static const int i = 1; // never found
};
// static const int i = 2; // never found
};
// const int i = 4; // never found
}
// const int i = 5; // never found
4.6、Default argument
这里函数定义的默认参数,会先从函数的参数开始查找。
class X {
int a, b, i, j;
public:
const int& r;
X(int i): r(a), // initializes X::r to refer to X::a
b(i), // initializes X::b to the value of the parameter i
i(i), // initializes X::i to the value of the parameter i
j(this->i) // initializes X::j to the value of X::i
{ }
}
int a;
int f(int a, int b = a); // error: lookup for a finds the parameter a, not ::a
// and parameters are not allowed as default arguments
4.7、Catch clause of a function-try block
在函数的 try-catch 里的符号使用,首先查找当前 block,然后会看函数参数,最后才是外层的。
int n = 3; // found 3rd
int f(int n = 2) // found 2nd
try {
int n = -1; // never found
} catch(...) {
// int n = 1; // found 1st
assert(n == 2); // loookup for n finds function parameter f
throw;
}
注意:这里的 try-catch 是函数的定义。
#include <iostream>
#include <string>
struct S {
std::string m;
S(const std::string& str, int idx) try : m(str, idx) {
std::cout << "S(" << str << ", " << idx << ") constructed, m = " << m << '\n';
} catch(const std::exception& e) {
std::cout << "S(" << str << ", " << idx << ") failed: " << e.what() << '\n';
} // implicit "throw;" here
};
int main() {
S s1{"ABC", 1}; // does not throw (index is in bounds)
try {
S s2{"ABC", 4}; // throws (out of bounds)
} catch (std::exception& e) {
std::cout << "S s2... raised an exception: " << e.what() << '\n';
}
}
/* Output:
S(ABC, 1) constructed, m = BC
S(ABC, 4) failed: basic_string::basic_string: __pos (which is 4) > this->size() (which is 3)
S s2... raised an exception: basic_string::basic_string: __pos (which is 4) > this->size() (which is 3)
*/
4.8、Overloaded operator
当采用表达式的方式时,比如 a + a
,那么会分别进行两种查找:1、非成员运算符重载;2、成员运算符重载。最终这两种找到的符号会并入 build-in 内置的运算符中形成一个集合,最后在其中进行匹配。
而当采用方法来进行调用时,就会走正常的非限定名查找方式。
struct A {};
void operator+(A, A); // user-defined non-member operator+
struct B {
void operator+(B); // user-defined member operator+
void f ();
};
A a;
void B::f() // definition of a member function of B
{
operator+(a,a); // error: regular name lookup from a member function
// finds the declaration of operator+ in the scope of B
// and stops there, never reaching the global scope
a + a; // OK: member lookup finds B::operator+, non-member lookup
// finds ::operator+(A,A), overload resolution selects ::operator+(A,A)
}
5、Argument-dependent lookup
【参考:https://en.cppreference.com/w/cpp/language/adl】
Argument-dependent lookup,俗称 ADL,是用在函数调用表达式的非限定名的查找规则,包括重载运算符的调用。
#include <iostream>
int main()
{
std::cout << "Test\n"; // There is no operator<< in global namespace, but ADL
// examines std namespace because the left argument is in
// std and finds std::operator<<(std::ostream&, const char*)
operator<<(std::cout, "Test\n"); // same, using function call notation
// however,
std::cout << endl; // Error: 'endl' is not declared in this namespace.
// This is not a function call to endl(), so ADL does not apply
endl(std::cout); // OK: this is a function call: ADL examines std namespace
// because the argument of endl is in std, and finds std::endl
(endl)(std::cout); // Error: 'endl' is not declared in this namespace.
// The sub-expression (endl) is not a function call expression
}
5.1、Detail
首先,ADL 在以下的非限定名查找中不会生效:
1) 类成员的声明
2) 块内的函数声明
3) 非函数声明,或者函数模版
上述情况会使用常规的 unqualified lookup,否则对于函数调用表达式,会检查它的每一个参数来判断相关的命名空间和类,然后把他们加进 lookup 的集合当中。
6、Special Handling of Function Templates
对于函数模版而言,有一个问题:就是你不能直接调用它。因此,在符号查找完成后,编译器会遍历候选符号集合,会尝试把函数模版转换成函数。
6.1、template argument deduction
这个函数有一个模版参数 T,template argument deduction (模版参数推断) 会进行操作,编译器会比较调用者的参数和模版参数的类型,如果正确能推断,则模版参数 T 会被推断成一种类型。
比如这里的模版参数 T 被推断为 galaxy::Asteroid
。
如果无法正确推断,则这个函数模版会被候选符号集合去掉。
6.2、template argument substitution
所有在候选符号集合里面存活的函数模版,会进入下一个阶段 template argument substitution (模版参数替换)。
在这个阶段,模版参数 T 直接被替换为 galaxy::Asteroid
。
当然,也会存在模版参数替换失败的情况,比如下面这种情况,要求 T 还存在 Units 的成员:
当模版参数替换失败时,函数模版会被候选符号集合去掉。
6.3、SFINAE
利用函数模版的特性,形成了元编程的技术,比如 https://en.cppreference.com/w/cpp/language/sfinae (substitution failure is not an error),正如上面的操作,替换失败并不会造成编译报错。
这样就能在编译期间完成一些计算的需求,且最终让相关操作落到我们想要的逻辑当中。
template <int I> void div(char(*)[I % 2 == 0] = 0) {
// this overload is selected when I is even
}
template <int I> void div(char(*)[I % 2 == 1] = 0) {
// this overload is selected when I is odd
}
不过在 modern c++ 中,因为出现了 constraints 和 constexpr if 同样能够满足开发者的需要。
7、candidate functions
接下来需要从一众候选符号集合中,选出可行的符号。
最明显的需求就是参数需要能够匹配。至少要能进行隐式转换的。
c++20 还会有一个 constraints 的特性,用于自定义逻辑来排除一些模版,所以还需要查看是否满足其要求。
8、决斗时刻
最终还是剩下一些候选符号,编译器需要决出最佳匹配的可行的函数。
首先是参数能更好匹配的胜出,编译器偏好于需要进行更少的隐式转换的函数。
当然,如果需要进行隐式转换,某一些转换也会优于另外一些转换,参考 Ranking of implicit conversion sequences。
然后,编译器会偏好于非模版函数;
最后,编译器会偏好于更能确定 (more specialized) 的函数,这里面也有一些规则的判断,参考 rules to decide which function template is more specialized than another。
9、After the Function Call Is Resolved
在决出要调用哪个函数之后,编译器还会有一些工作需要去完成:
- 如果函数是类成员,编译器还需要去检查成员的访问属性 (access specifiers),如 private、protected 等。
- 如果函数是模版,如果它的定义是可见的,则编译器需要去实例化 (instantiate) 函数模版。
- 如果函数是虚函数(virtual function),那么编译器需要去生成特别的机器指令辅助运行时找到正确的函数。
参考链接
虽然我在写每一个点的时候直接把参考链接放在里面,这里还是再列一遍。
- How C++ Resolves a Function Call
- https://en.cppreference.com/w/cpp/language/unqualified_lookup
- https://en.cppreference.com/w/cpp/language/adl
- https://en.cppreference.com/w/cpp/language/sfinae
- constraints
- constexpr if
- Ranking of implicit conversion sequences
- rules to decide which function template is more specialized than another
- access specifiers
- instantiate
- virtual function