指针异类 | 成员函数指针 & 成员变量指针

导读:指针的大小居然还能是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
  { };

因此,可以得出结论:成员函数指针,不是指针。那么问题来了:

  1. 成员函数指针,既然不是指针,那是什么类型?
  2. 更为关键的是,多出来的8字节又是啥?

走近 name mangling,揭秘函数重载本质 一文中阐述过,C++代码在编译期都有个name mangling的行为,并且对于类的成员函数,会在成员函数的第一个参数前,插入this指针,以Foo::just_for_test成员函数为例,即:

void _ZN3Foo13just_for_testEv(Foo* const this);

这样,就可以通过 this指针来调用类Foo对象的成员变量。

但是,考虑这么一个多继承场景:有三个类,其中基类是Base_1Base_2,子类Derived多继承Base_1Base_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_1just_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届秋招进度交流##学习路径#
全部评论
感谢参与【创作者计划3期·技术干货场】!欢迎更多牛油来写干货,瓜分总计20000元奖励!!技术干货场活动链接:https://www.nowcoder.com/link/czz3jsgh3(参与奖马克杯将于每周五结算,敬请期待~)
点赞 回复
分享
发布于 2021-05-24 14:37

相关推荐

2 7 评论
分享
牛客网
牛客企业服务