C++中指向类成员的指针并非指针

“指向类成员的指针(Pointers to members)”,是一种在C++不常用的特性,但是这里使用术语“指针”略有不妥,因为它们并不包含地址,行为也不像指针。
本篇文章会通过LLVM-IR来分析clang中对于“指向类成员的指针”的实现方式,以及穿插C++14标准内定义的相关内容和涉及到的LLVM-IR的语法。

首先,C++标准中对于“指向类成员的指针”应该如何实现并没有要求,同样是依赖于编译器的实现。但是标准指明了“指向成员的指针”是明显区别于普通指针的。

The type “pointer to member” is distinct from the type “pointer”, that is, a pointer to member is declared only by the pointer to member declarator syntax, and never by the pointer declarator syntax. There is no “reference-to-member” type in C++.

对于普通的指针而言,其包含一个地址,可以对其进行解引用来间接访问所指向的对象。

1
2
3
int x=123;
int *xp=&x;
*xp=456;

但是一个指向成员的指针并不指向某一个具体对象的内存地址。它指向的是一个类的特定成员,而不是指定某一个特定对象的成员。
下面我们从编译器Clang的实现来分析一下“指向成员的指针”究竟是什么东西。假定我们具有以下类类型:

指向类数据成员的指针

1
2
3
4
5
6
7
8
9
struct A{
A(int x=0,double y=0.0,char z='\0'):a{x},b{y},c{z}{}
void func(){
std::cout<<"void A::func()"<<std::endl;
}
int a;
double b;
char c;
};

然后我们通过以下代码来创建一个“指向类成员的指针”:

1
2
3
4
// non-initielizer
int A::*ap;
double A::*bp;
char A::*cp;

以上是并没有初始化的版本,再写一份具有初始化的版本,稍后通过LLVM-IR的对比来看“指向类数据成员的指针”究竟被初始化为了什么。

1
2
3
4
// initializer
int A::*ap=&A::a;
double A::*bp=&A::b;
char A::*cp=&A::c;

通过diff可以看到两者LLVM-IR代码的区别:

可以看到,具有初始化的并非是将某种地址存储到“指向类数据成员的指针”中,而其实际上是一种整数类型。
而clang这里实现的,恰好他们均等于该数据成员各自在类中的偏移值,关于类内偏移值的内容详情可看我的另一篇文章:结构体成员内存对齐问题
这意味着“指向类成员的指针”的实现方式是获取该类成员在类中的偏移值,这也同样印证了“指向类成员的指针”不可以单独访问(依赖于某一特定对象)的原因——它只是偏移值,需要通过特定的对象来访问该对象此偏移值处的子对象。
我们来尝试通过一个类对象和“指向数据成员的指针”来访问特定的对象:

1
2
3
A x{123};
int A::*ap=&A::a;
x.*ap=888;

依然查看其的LLVM-IR代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 创建A类对象x并调用A的构造函数
%2 = alloca %struct.A, align 8
call void @_ZN1AC2Eidc(%struct.A* %2, i32 123, double 0.000000e+00, i8 0)
# 初始化“指向类的数据成员的指针”ap(获取其偏移值)
%3 = alloca i64, align 8
store i64 0, i64* %3, align 8
%4 = load i64, i64* %3, align 8
%5 = bitcast %struct.A* %2 to i8*
# 通过特定对象的指针和偏移量来访问类的子对象(数据成员)
%6 = getelementptr inbounds i8, i8* %5, i64 %4
%7 = bitcast i8* %6 to i32*
store i32 888, i32* %7, align 4

可以在LLVM Language Reference Manual查看LLVM-IR的语法。
这里比较繁琐的部分为getelementptr inbounds的用法:

1
2
3
4
# Syntax
<result> = getelementptr <ty>, <ty>* <ptrval>{, [inrange] <ty> <idx>}* 
<result> = getelementptr inbounds <ty>, <ty>* <ptrval>{, [inrange] <ty> <idx>}* 
<result> = getelementptr <ty>, <ptr vector> <ptrval>, [inrange] <vector index type> <idx>

The getelementptr instruction is used to get the address of a subelement of an aggregate data structure. It performs address calculation only and does not access memory. The instruction can also be used to calculate a vector of such addresses.

  • The first argument is always a type used as the basis for the calculations.
  • The second argument is always a pointer or a vector of pointers, and is the base address to start from.
  • The remaining arguments are indices that indicate which of the elements of the aggregate object are indexed.

The interpretation of each index is dependent on the type being indexed into. The first index always indexes the pointer value given as the first argument, the second index indexes a value of the type pointed to (not necessarily the value directly pointed to, since the first index can be non-zero), etc. The first type indexed into must be a pointer value, subsequent types can be arrays, vectors, and structs. Note that subsequent types being indexed into can never be pointers, since that would require loading the pointer before continuing calculation.

更多LLVM-IR的东西不再赘述,言归正传继续来分析“指向数据成员的指针”。

在我们对一个“指向类内数据成员的指针”赋予初始值,实际上是获得了该数据成员在类内的偏移量。除非是对一个类内的static数据成员进行&操作,否则不会带来一个实际的地址,而是一个偏移量。
这也应该是C++标准规定“指向类成员的指针”不能指向static成员:

[ISO/IEC 14882:2014]A pointer to member shall not point to a static member of a class (9.4), a member with reference type, or “cv void.”

上面已经简单提到了“指向成员的指针”不能单独访问——需要依赖于某一特定的类A对象。
这是由于“指向成员的指针”只是该成员在类内的偏移量,为了访问位于那个偏移量的子对象(数据成员),则我们需要该类(A)的一个对象的地址。
当我们使用.*或者->*通过一个类对象或者类指针来访问一个偏移量时,执行的即是上面列出的LLVM-IR代码里getelementptr inbounds部分,通过调用的对象的地址和偏移量计算出位于该偏移量的数据成员的地址。

指向类成员函数的指针

上面的部分写到了“指向类数据成员的指针”,以及其在clang中的实现方式,而“指向类成员函数的指针”与“指向类数据成员的指针”略有不同。
一个指向成员函数指针的实现自身必须存储一些信息,比如它所指向的函数是虚函数还是非虚函数,如何找到适当的虚函数表指针,所以通常指向类成员函数的指针的实现为一个小型的结构来存储这些关键的信息。

注意:并没有什么指向虚函数的指针,虚函数是函数本身的属性,而不是指向类成员函数的属性。

同样按照上一部分相同的逻辑:首先先创建一个“指向类成员函数的指针”,不同于普通的函数指针,对于“指向类成员函数的指针”初始化必须对类内的成员函数使用取地址符:

1
void (A::*funcp)();

其LLVM-IR代码为:

1
%3 = alloca { i64, i64 }, align 8

可以看到,clang中对于指向类成员函数的指针不同于指向类数据成员的指针,“指向类成员函数的指针”是具有两个i64对象的结构。
尝试对其进行初始化操作:

1
void (A::*funcp)()=&A::func;

然后再查看其LLVM-IR代码:

1
2
%3 = alloca { i64, i64 }, align 8
store { i64, i64 } { i64 ptrtoint (void (%struct.A*)* @_ZN1A4funcEv to i64), i64 0 }, { i64, i64 }* %3, align 8

可以看到这里是把A::func的函数地址(接收一个类A的指针并没有返回)转换为了i64之后存入到了该成员指针的结构中,第二个参数是对this指针的偏移修饰,因为在类的继承层次里,数据成员的位置并不是绝对的,而是相对于基类的相对位置,该偏移可以使用ptrdiff_t类型来表示,可以看如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class A{
public:
void func()
{
std::cout<<"A::func"<<",this ptr address is "<<this<<std::endl;
}
char pad16[16];
};
class B{
public:
void bar(){
std::cout<<"B::Bar"<<",this ptr address is "<<this<<std::endl;
}
char pad8[8];
};
class C:public A,public B{};
int main()
{
C cobj;
void(C::*Afunc)()=&C::func;
void(C::*Bbar)()=&C::bar;
(cobj.*Afunc)();
(cobj.*Bbar)();
return 0;
}
// output
A::func,this ptr address is 0x61fe30
B::Bar,this ptr address is 0x61fe40

可以看到两个this地址的差值就是A类对象的内存布局大小,再来看一下其对成员函数指针赋值部分的IR代码:

1
2
3
4
5
6
7
8
9
define i32 @main() #4 {
// ...
%2 = alloca %class.C, align 1
%3 = alloca { i64, i64 }, align 8
%4 = alloca { i64, i64 }, align 8
store { i64, i64 } { i64 ptrtoint (void (%class.A*)* @_ZN1A4funcEv to i64), i64 0 }, { i64, i64 }* %3, align 8
store { i64, i64 } { i64 ptrtoint (void (%class.B*)* @_ZN1B3barEv to i64), i64 16 }, { i64, i64 }* %4, align 8
// ...
}

上面的代码就是分配了两个成员函数的结构——两个int64,该结构的第一个成员用来存储成员函数函数指针,第二个用来存储this指针的偏移。

所以我们在使用的时候需要用一个类A对象对其进行调用(因为.*以及->*的优先级低于(),所以对x.*funcp要加上括号):

1
2
3
A x(123);
void (A::*funcp)()=&A::func;
(x.*funcp)();

其LLVM-IR代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
%2 = alloca %struct.A, align 8
call void @_ZN1AC2Eidc(%struct.A* %2, i3 123, double 0.000000e+00, i8 0)
%3 = alloca { i64, i64 }, align 8
# 将成员函数地址存放到分配的空间中(%3)
store { i64, i64 } { i64 ptrtoint (void (%struct.A*)* @_ZN1A4funcEv to i64), i64 0 }, { i64, i64 }* %3, align 8
%4 = load { i64, i64 }, { i64, i64 }* %3, align 8
# 取出结构中的第二个i64数据,并放到%5
%5 = extractvalue { i64, i64 } %4, 1
%6 = bitcast %struct.A* %2 to i8*
# 访问该对象偏移的第%5个对象
%7 = getelementptr inbounds i8, i8* %6, i64 %5
%8 = bitcast i8* %7 to %struct.A*
# 取出结构中的第一个i64数据,并放到%9
%9 = extractvalue { i64, i64 } %4, 0
# 对%91执行位与(&)运算
%10 = and i64 %9, 1
# 判断%10是否不等于0(ne为not equal)
%11 = icmp ne i64 %10, 0
# 根据上面判断的结果执行不同的分支
br i1 %11, label %12, label %19
; <label>:12: ; preds = %0
%13 = bitcast %struct.A* %8 to i8**
%14 = load i8*, i8** %13, align 8
%15 = sub i64 %9, 1
%16 = getelementptr i8, i8* %14, i64 %15
%17 = bitcast i8* %16 to void (%struct.A*)**
%18 = load void (%struct.A*)*, void (%struct.A*)** %17, align 8
br label %21
; <label>:19: ; preds = %0
%20 = inttoptr i64 %9 to void (%struct.A*)*
br label %21
; <label>:21: ; preds = %19, %12
%22 = phi void (%struct.A*)* [ %18, %12 ], [ %20, %19 ]
# 通过类指针来调用成员函数(类似于类内成员函数具有this指针)
# 成员函数指针必须需要对象或指针调用的原因就在于需要在这里补上单独的成员函数指针所需要的this指针
call void %22(%struct.A* %8)

而且,指向成员函数的指针表现出一种逆变性:存在指向基类成员函数指针到指向派生类成员函数指针的转换,反之则不行。
如我们具有以下类:

1
2
3
4
5
6
7
8
9
10
struct base{
virtual void func(){
cout<<"base::func()"<<endl;
}
};
struct A:public base{
void func(){
cout<<"A::func"<<endl;
}
};

可以从指向基类成员函数指针到指向派生类成员函数指针的转换(具有多态性):

1
2
3
void (A::*AfuncP)()=&base::func;
A aobj;
(aobj.*AfuncP)(); // output:A::func()

而反之则是编译错误:

1
2
// error: cannot initialize a variable of type 'void (base::*)()' with an rvalue of type 'void (A::*)()':different classes ('base' vs 'A')
void (base::*baseFuncp)()=&A::func;

与STL的组合

指向成员函数的指针可以通过标准库的mem_func适配器实现类似于函数对象(仿函数),使其可以应用在相关算法中(若容器中是指针用std::mem_fun,若是引用则用std::mem_fun_ref)。
比如:对容器中存储的所有对象执行其成员函数的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct C{
void func(){
cout<<"C::func"<<endl;
}
};
int main()
{
vector<C> x;
x.resize(5);
std::for_each(x.begin(), x.end(),std::mem_fun_ref(&C::func));
}
// 输出
/*
C::func
C::func
C::func
C::func
C::func
*/

来看一下std::mem_fun的一个实现(SGISTL,有些老了):

1
2
3
4
5
6
7
8
9
10
11
12
template <class _Ret, class _Tp>
class mem_fun_ref_t : public unary_function<_Tp,_Ret> {
public:
explicit mem_fun_ref_t(_Ret (_Tp::*__pf)()) : _M_f(__pf) {}
_Ret operator()(_Tp& __r) const { return (__r.*_M_f)(); }
private:
_Ret (_Tp::*_M_f)();
};
template <class _Ret, class _Tp>
inline mem_fun_ref_t<_Ret,_Tp> mem_fun_ref(_Ret (_Tp::*__f)())
{ return mem_fun_ref_t<_Ret,_Tp>(__f); }

可以看到,SGISTL中实现的是,通过std::mem_fun创建一个包裹该成员函数的函数对象,该函数对象接收一个传入成员函数类的实参(指针或引用,这里只列出了std::mem_fun_ref的实现,而std::mem_fun则大同小异),通过该参数来调用成员函数指针。

通过上面的SGISTL实现可以看到,它有很大的局限性——只能调用无参的成员函数(这个要依赖于不同的STL实现)。
如果想要调用有参的成员函数呢?可以使用std::bind!不同于C++11之前的std::bind1ststd::bind2st那么蹩脚,C++11中的std::bind不限定参数个数才是真神器!
来将上面的类C稍微改动一下,使其接收一个参数:

1
2
3
4
5
6
7
8
9
struct C{
C(const int& x):ival{x}{}
void addNum(const int& iArg){
this->ival+=iArg;
std::cout<<this->ival<<std::endl;
};
private:
int ival;
};

如果此时再使用std::mem_fun来适配会提示没有匹配的函数,我们可以使用std::bind:

1
2
std::vector<C> c{1,2,3,4,5};
std::for_each(x.begin(), x.end(),std::bind(&C::func,_1,3));

因为成员函数指针需要通过类指针或者类对象访问,所以要将其第一个参数传递给bind绑定的成员函数指针。
前面提到for_each

从成员函数指针到普通函数指针的转换

通过上面可以了解到,成员函数指针是一个结构,其第一个元素存储着成员函数的函数指针,第二个元素存储着对this指针的偏移。
它们两个构成了成员函数指针,其实你肯定也想到了,类的成员函数就是带有this指针的函数:

1
2
3
4
class A{
public:
void func(int,double,void*){}
};

其成员函数func的函数指针则是:

1
void(*)(A*,int,double,void*);

那么,如果我们想要把一个成员函数指针转换为函数指针的形式,只需要取成员函数指针结构的第一个元素即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class A{
public:
void func(int,double,void*){
printf("A::func\n");
}
};
void gfunc(void(*pfunc)(A*,int,double,void*))
{
A obj;
// (obj.*pfunc)();
pfunc(&obj,0,1.1,0);
}
union U{
void(A::*func)(int,double,void*);
void(*pure_func)(A*,int,double,void*);
};
int main()
{
U unionObj;
unionObj.func=&A::func;
gfunc(unionObj.pure_func);
return 0;
}

这样就可以从成员函数指针到普通的函数指针赋值了。直接转换是不允许的(看编译器)。用union可以骗过编译器,这个方法比较通用。

更新日志

2017.05.11

  • 增加标准库组合使用指向成员函数指针的内容

2018.09.25

  • 补充成员函数指针的结构初始化部分内容

2018.11.06*

  • 增加从成员函数指针到普通函数指针的转换
全文完,若有不足之处请评论指正。

扫描二维码,分享此文章

本文标题:C++中指向类成员的指针并非指针
文章作者:ZhaLiPeng
发布时间:2017年04月29日 21时28分
更新时间:2018年11月06日 00时38分
本文字数:本文一共有3,684字
更新历史: Blame, History  文本模式: .md Raw
原始链接:https://imzlp.me/posts/27615/
许可协议: CC BY-NC-SA 4.0
转载请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!