指针异类 | 成员函数指针 & 成员变量指针
导读:指针的大小居然还能是16?
这一期来谈谈成员函数指针,,一个可能刷新你对指针认识的名词,此外,本期来源群里一个小伙伴在工作中遇到的。
成员函数指针
众所周知,在64位CPU上,指针大小是8个字节,即64bit。但是,指针大小真的只有8个字节吗?
先看如下一个demo。
#include <iostream> #include <string.h> struct Foo { Foo() =default; void just_for_test() { } char buffer[32]; }; int main(int argc, char const* argv[]) { std::cout << sizeof(&main) << std::endl; // 普通函数指针 std::cout << sizeof(int*) << std::endl; // int* 类型指针 std::cout << sizeof(Foo*) << std::endl; // Foo* 指针 std::cout << sizeof(&Foo::just_for_test) << std::endl; // 成员函数指针 }
输出如下:
$ g++ -g -O0 main.cc -o main && ./main 8 8 8 16 # !!!
发现什么没有?
成员函数指针大小竟然不是8,在我的x86-64 CPU上显示是16个字节(在其他CPU上还不一定是16)。此刻,肯定想尝试打印出成员函数Foo::just_for_test
的地址,看看地址情况。
int main(int argc, char const* argv[]) { std::cout<< &Foo::just_for_test <<std::endl; }
输出如下:
$ g++ -g -O0 main.cc -o main && ./main 1
小朋友,你心中是否充满了问号???
怎么会输出1呢!!!
对上述std::cout
进行gdb调试,会发现进入了输入参数为bool
类型的重载版本:
std::ostream &std::ostream::operator<<(bool __n);
是否心中,又多了许多???
因为类 std::ostream
有输入参数类型为void*
的重载版本啊,为啥没有进入那个版本呢?
这一现象,说明成员函数指针类型void (Foo::*)()
无法转换为void*
。
为了验证这一观点,基于std::is_pointer
写了个demo:
int main(int argc, char const* argv[]) { std::cout<<std::boolalpha; std::cout<< std::is_pointer<decltype(&Foo::just_for_test)>::value << std::endl; }
输出如下:
$ g++ -g -O0 main.cc -o main && ./main false
由于std::is_pointer
底层判断某个类型是不是指针,也是看这个类型能不能转化为void*
类型:
template<typename> struct __is_pointer_helper : public false_type { }; template<typename _Tp> struct __is_pointer_helper<_Tp*> : public true_type { }; /// is_pointer template<typename _Tp> struct is_pointer : public __is_pointer_helper<typename remove_cv<_Tp>::type>::type { };
因此,可以得出结论:成员函数指针,不是指针。那么问题来了:
- 成员函数指针,既然不是指针,那是什么类型?
- 更为关键的是,多出来的8字节又是啥?
在 走近 name mangling,揭秘函数重载本质 一文中阐述过,C++代码在编译期都有个name mangling的行为,并且对于类的成员函数,会在成员函数的第一个参数前,插入this
指针,以Foo::just_for_test
成员函数为例,即:
void _ZN3Foo13just_for_testEv(Foo* const this);
这样,就可以通过 this
指针来调用类Foo
对象的成员变量。
但是,考虑这么一个多继承场景:有三个类,其中基类是Base_1
、Base_2
,子类Derived
多继承Base_1
、Base_2
,那么当子类 Derived
对象调用基类的成员函数时,编译器怎么判断这个成员函数是属于哪个基类的呢,Base_1
or Base_2
?
现在,我们来实现这个场景,demo如下。
struct Base_1 { Base_1() =default; void just_for_test_1() const { std::cout<<"Base_1:\t" <<this<<std::endl; } char buffer[32]; }; struct Base_2 { Base_2() =default; void just_for_test_2() const { std::cout<<"Base_2:\t" <<this<<std::endl; } char buffer[16]; }; struct Derived : public Base_1, Base_2 { };
然后,我们通过一个单独的全局函数 call_mem_func
来调用成员函数,第一个参数传入类Derived
对象的地址:
void call_mem_func(const Derived* const self, void(Derived::* mem_func)()const) { std::cout << "this:\t" << self << std::endl; (self->*mem_func)(); } int main(int argc, char const* argv[]) { Derived derived; call_mem_func(&derived, &Derived::just_for_test_1); std::cout << "----" << std::endl; call_mem_func(&derived, &Derived::just_for_test_2); return 0; }
输出如下:
$ g++ -g -O0 main.cc -o main && ./main this: 0x7ffffee1f830 Base_1: 0x7ffffee1f830 ---- this: 0x7ffffee1f830 Base_2: 0x7ffffee1f850
可以发现,当通过子类对象derived
来调用基类just_for_test_1
、just_for_test_2
成员函数时,just_for_test_2
函数中输出的this
指针地址比 just_for_test_1
中输出的 this
地址大了32,即0x20,而这正好等于 sizeof(Base_1)
。
关于继承体系中的内存布局在 编译器优化之 Empty Base Class Optimization 中也提到过,此刻子类Derived
的内存布局,等效于下面的类Derived_eq
:
struct Derived_eq { Derived_eq() =default; //... Base_1 base_1; Base_2 base_2; };
因此,调用Derived::just_for_test_2
时,输出的this
指针地址比 Derived::just_for_test_1
增加了sizeof(Base_1)
个字节,也就理解了。
总结下:当通过self
调用基类中的just_for_test_1
成员函数时,需要将调整self
指针到基类Base_1
对象的位置,自然,调用just_for_test_2
成员函数时,需要将self
调整到Base_2
对象的位置,这样才能完成顺利调用。
但问题是,call_mem_func
函数就传入了一个self
指针,指向子类对象derived
,编译器是如何定位到这个成员函数属于哪个基类呢。这一功能,需要借助成员函数指针中多出的8个字节。
在GCC的实现中,成员函数指针类型,是一个结构体,它包含了两个字段:
ptr
:类型是fnptr
,会转换为合适的函数指针类型,指向了该成员函数的地址adj
:类型是ptrdiff_t
,是this
指针要调整的偏移量,比如上面的sizeof(Base_1)
个字节。
成员函数指针类型,完整如下:
struct { fnptr_t ptr; ptrdiff_t adj; };
为了验证这一点,我们可以将这两个字段打印出来看看:
void test_mem_func(const Derived* const self, void(Derived::* mem_func)()const) { std::cout << "self:\t" << self << std::endl; void* buffer[2]; ::memcpy(buffer, &mem_func, sizeof(buffer)); std::cout << "ptr:\t" << buffer[0] << '\n' << "adj:\t" << buffer[1] << std::endl; (self->*mem_func)(); }
输出如下:
$ g++ -g -O0 main.cc -o main && ./main self: 0x7fffe173b310 ptr: 0x7f841089345e adj: 0 # 定位到 Base_1,无需调整 Base_1: 0x7fffe173b310 ---- self: 0x7fffe173b310 ptr: 0x7f84108934ac adj: 0x20 # 增加 sizeof(Base_1) 个字节 Base_2: 0x7fffe173b330
从输出也是可以看出,这是符合上面分析的。
注意:成员函数指针没有统一的ABI规范,因此不同编译器、不同平台下表现都可能不同,这都取决于具体实现。
成员变量指针
类里的数据成员指针,本质上也不是指针,即无法转换为void*
类型,而是 ptrdiff_t
类型,表达的距离this
指针的偏移量,其效果类似于c语言中的offset
函数。
struct Test { Test() = default; int a; int b; int c; }; int main(int argc, char const* argv[]) { std::cout << offsetof(Test, b) << std::endl; std::cout << &Test::b << std::endl; // int Test::* printf("%d\n", offsetof(Test, b)); printf("%d\n", &Test::b); return 0; }
输出如下:
$ g++ -g -O0 main.cc -o main && ./main 4 1 4 4
从输出可以看出,由于成员变量指针类型 int Test::*
也是无法转换为void*
,因此使用std::cout
输出时,也会触发bool
类型重载版本,但是可以使用print
函数输出。
好嘞,指针中的两个「异类」的分析就到此为止。
#2021届秋招进度交流##学习路径#