【C++基础】第一百零九课:[特殊工具与技术]类成员指针

成员指针,mem_fn

Posted by x-jeff on September 1, 2024

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.类成员指针

成员指针(pointer to member)是指可以指向类的非静态成员的指针。一般情况下,指针指向一个对象,但是成员指针指示的是类的成员,而非类的对象。类的静态成员不属于任何对象,因此无须特殊的指向静态成员的指针,指向静态成员的指针与普通指针没有什么区别。

成员指针的类型囊括了类的类型以及成员的类型。当初始化一个这样的指针时,我们令其指向类的某个成员,但是不指定该成员所属的对象;直到使用成员指针时,才提供成员所属的对象。

为了解释成员指针的原理,使用Screen类:

1
2
3
4
5
6
7
8
9
10
11
class Screen {
public:
    typedef std::string::size_type pos;
    char get_cursor() const { return contents[cursor]; }
    char get() const;
    char get(pos ht, pos wd) const;
private:
    std::string contents;
    pos cursor;
    pos height, width;
};

2.数据成员指针

和其他指针一样,在声明成员指针时我们也使用*来表示当前声明的名字是一个指针。与普通指针不同的是,成员指针还必须包含成员所属的类。因此,我们必须在*之前添加classname::以表示当前定义的指针可以指向classname的成员。例如:

1
2
//pdata可以指向一个常量(非常量)Screen对象的string成员
const string Screen::*pdata;

当我们初始化一个成员指针(或者向它赋值)时,需指定它所指的成员。例如,我们可以令pdata指向某个非特定Screen对象的contents成员:

1
pdata = &Screen::contents;

其中,我们将取地址运算符作用于Screen类的成员而非内存中的一个该类对象。

在C++11新标准中声明成员指针最简单的方法是使用auto或decltype:

1
auto pdata = &Screen::contents;

2.1.使用数据成员指针

当我们初始化一个成员指针或为成员指针赋值时,该指针并没有指向任何数据。成员指针指定了成员而非该成员所属的对象,只有当解引用成员指针时我们才提供对象的信息。

与成员访问运算符.->类似,也有两种成员指针访问运算符:.*->*,这两个运算符使得我们可以解引用指针并获得该对象的成员:

1
2
3
4
5
Screen myScreen, *pScreen = &myScreen;
//.*解引用pdata以获得myScreen对象的contents成员
auto s = myScreen.*pdata;
//->*解引用pdata以获得pScreen所指对象的contents成员
s = pScreen->*pdata;

2.2.返回数据成员指针的函数

常规的访问控制规则对成员指针同样有效。例如,Screen的contents成员是私有的,因此之前对于pdata的使用必须位于Screen类的成员或友元内部,否则程序将发生错误。

因为数据成员一般情况下是私有的,所以我们通常不能直接获得数据成员的指针。如果一个像Screen这样的类希望我们可以访问它的contents成员,最好定义一个函数,令其返回值是指向该成员的指针:

1
2
3
4
5
6
7
8
class Screen {
public:
    //data是一个静态成员,返回一个成员指针
    static const std::string Screen::*data() {
        return &Screen::contents;
    }
    //其他成员与之前的版本一致
};

当我们调用data函数时,将得到一个成员指针:

1
2
//data()返回一个指向Screen类的contents成员的指针
const string Screen::*pdata = Screen::data();

一如往常,pdata指向Screen类的成员而非实际数据。要想使用pdata,必须把它绑定到Screen类型的对象上:

1
2
//获得myScreen对象的contents成员
auto s = myScreen.*pdata;

3.成员函数指针

我们也可以定义指向类的成员函数的指针。与指向数据成员的指针类似,对于我们来说要想创建一个指向成员函数的指针,最简单的方法是使用auto来推断类型:

1
2
3
//pmf是一个指针,它可以指向Screen的某个常量成员函数
//前提是该函数不接受任何实参,并且返回一个char
auto pmf = &Screen::get_cursor;

和指向数据成员的指针一样,我们使用classname::*的形式声明一个指向成员函数的指针。类似于任何其他函数指针,指向成员函数的指针也需要指定目标函数的返回类型和形参列表。如果成员函数是const成员或者引用成员,则我们必须将const限定符或引用限定符包含进来。

和普通的函数指针类似,如果成员存在重载的问题,则我们必须显式地声明函数类型以明确指出我们想要使用的是哪个函数。例如,我们可以声明一个指针,令其指向含有两个形参的get:

1
2
char (Screen::*pmf2) (Screen::pos, Screen::pos) const;
pmf2 = &Screen::get;

出于优先级的考虑,上述声明中Screen::*两端的括号必不可少。如果没有这对括号的话,编译器将认为该声明是一个(无效的)函数声明:

1
2
//错误:非成员函数p不能使用const限定符
char Screen::*p(Screen::pos, Screen::pos) const;

这个声明试图定义一个名为p的普通函数,并且返回Screen类的一个char成员。因为它声明的是一个普通函数,所以不能使用const限定符。

和普通函数指针不同的是,在成员函数和指向该成员的指针之间不存在自动转换规则:

1
2
3
//pmf指向一个Screen成员,该成员不接受任何实参且返回类型是char
pmf = &Screen::get; //必须显式地使用取地址运算符
pmf = Screen::get; //错误:在成员函数和指针之间不存在自动转换规则

3.1.使用成员函数指针

和使用指向数据成员的指针一样,我们使用.*或者->*运算符作用于指向成员函数的指针,以调用类的成员函数:

1
2
3
4
5
Screen myScreen, *pScreen = &myScreen;
//通过pScreen所指的对象调用pmf所指的函数
char c1 = (pScreen->*pmf)();
//通过myScreen对象将实参0,0传给含有两个形参的get函数
char c2 = (myScreen.*pmf2)(0,0);

之所以(pScreen->*pmf)()(myScreen.*pmf2)(0,0)的括号必不可少,原因是调用运算符的优先级要高于指针指向成员运算符的优先级。

假设去掉括号的话:

1
myScreen.*pmf()

其含义将等同于下面的式子:

1
myScreen.*(pmf())

这行代码的意思是调用一个名为pmf的函数,然后使用该函数的返回值作为指针指向成员运算符(.*)的运算对象。然而pmf并不是一个函数,因此代码将发生错误。

因为函数调用运算符的优先级较高,所以在声明指向成员函数的指针并使用这样的指针进行函数调用时,括号必不可少:(C::*p)(parms)(obj.*p)(args)

3.2.使用成员指针的类型别名

使用类型别名或typedef可以让成员指针更容易理解。例如,下面的类型别名将Action定义为两参数get函数的同义词:

1
2
//Action是一种可以指向Screen成员函数的指针,它接受两个pos实参,返回一个char
using Action = char (Screen::*)(Screen::pos, Screen::pos) const;

通过使用Action,我们可以简化指向get的指针定义:

1
Action get = &Screen::get; //get指向Screen的get成员

和其他函数指针类似,我们可以将指向成员函数的指针作为某个函数的返回类型或形参类型。其中,指向成员的指针形参也可以拥有默认实参:

1
2
//action接受一个Screen的引用,和一个指向Screen成员函数的指针
Screen& action(Screen&, Action = &Screen::get);

action是包含两个形参的函数,其中一个形参是Screen对象的引用,另一个形参是指向Screen成员函数的指针。当我们调用action时,只需将Screen的一个符合要求的函数的指针或地址传入即可:

1
2
3
4
5
Screen myScreen;
//等价的调用:
action(myScreen); //使用默认实参
action(myScreen, get); //使用我们之前定义的变量get
action(myScreen, &Screen::get); //显式地传入地址

3.3.成员指针函数表

对于普通函数指针和指向成员函数的指针来说,一种常见的用法是将其存入一个函数表当中。如果一个类含有几个相同类型的成员,则这样一张表可以帮助我们从这些成员中选择一个。假定Screen类含有几个成员函数,每个函数负责将光标向指定的方向移动:

1
2
3
4
5
6
7
8
9
class Screen {
public:
    //其他接口和实现成员与之前一致
    Screen& home(); //光标移动函数
    Screen& forward();
    Screen& back();
    Screen& up();
    Screen& down();
};

这几个新函数有一个共同点:它们都不接受任何参数,并且返回值是发生光标移动的Screen的引用。

我们希望定义一个move函数,使其可以调用上面的任意一个函数并执行对应的操作。为了支持这个新函数,我们将在Screen中添加一个静态成员,该成员是指向光标移动函数的指针的数组:

1
2
3
4
5
6
7
8
9
10
11
class Screen {
public:
    //其他接口和实现成员与之前一致
    //Action是一个指针,可以用任意一个光标移动函数对其赋值
    using Action = Screen& (Screen::*)();
    //指定具体要移动的方向
    enum Directions { HOME, FORWARD, BACK, UP, DOWN };
    Screen& move(Directions);
private:
    static Action Menu[]; //函数表
};

数组Menu依次保存每个光标移动函数的指针,这些函数将按照Directions中枚举成员对应的偏移量存储。move函数接受一个枚举成员并调用相应的函数:

1
2
3
4
5
Screen& Screen::move(Directions cm)
{
    //运行this对象中索引值为cm的元素
    return (this->*Menu[cm])(); //Menu[cm]指向一个成员函数
}

move中的函数调用的原理是:首先获取索引值为cm的Menu元素,该元素是指向Screen成员函数的指针。我们根据this所指的对象调用该元素所指的成员函数。

当我们调用move函数时,给它传入一个表示光标移动方向的枚举成员:

1
2
3
Screen myScreen;
myScreen.move(Screen::HOME); //调用myScreen.home
myScreen.move(Screen::DOWN); //调用myScreen.down

剩下的工作就是定义并初始化函数表本身了:

1
2
3
4
5
6
7
Screen::Action Screen::Menu[] = {
    &Screen::home,
    &Screen::forward,
    &Screen::back,
    &Screen::up,
    &Screen::down
};

4.将成员函数用作可调用对象

如我们所知,要想通过一个指向成员函数的指针进行函数调用,必须首先利用.*运算符或->*运算符将该指针绑定到特定的对象上。因此与普通的函数指针不同,成员指针不是一个可调用对象,这样的指针不支持函数调用运算符(参见:lambda表达式)。

因为成员指针不是可调用对象,所以我们不能直接将一个指向成员函数的指针传递给算法。举个例子,如果我们想在一个string的vector中找到第一个空string,显然不能使用下面的语句:

1
2
3
auto fp = &string::empty; //fp指向string的empty函数
//错误,必须使用.*或->*调用成员指针
find_if(svec.begin(), svec.end(), fp);

find_if算法需要一个可调用对象,但我们提供给它的是一个指向成员函数的指针fp。因此在find_if的内部将执行如下形式的代码,从而导致无法通过编译:

1
2
//检查对当前元素的断言是否为真
if (fp(*it)) //错误:要想通过成员指针调用函数,必须使用->*运算符

显然该语句试图调用的是传入的对象,而非函数。

4.1.使用function生成一个可调用对象

从指向成员函数的指针获取可调用对象的一种方法是使用标准库模板function

1
2
function<bool (const string&)> fcn = &string::empty;
find_if(svec.begin(), svec.end(), fcn);

我们告诉function一个事实:即empty是一个接受string参数并返回bool值的函数。通常情况下,执行成员函数的对象将被传给隐式的this形参。当我们想要使用function为成员函数生成一个可调用对象时,必须首先“翻译”该代码,使得隐式的形参变成显式的。

当一个function对象包含有一个指向成员函数的指针时,function类知道它必须使用正确的指向成员的指针运算符来执行函数调用。也就是说,我们可以认为在find_if当中含有类似于如下形式的代码:

1
2
//假设it是find_if内部的迭代器,则*it是给定范围内的一个对象
if (fcn(*it)) //假设fcn是find_if内部的一个可调用对象的名字

其中,function将使用正确的指向成员的指针运算符。从本质上来看,function类将函数调用转换成了如下形式:

1
2
//假设it是find_if内部的迭代器,则*it是给定范围内的一个对象
if (((*it).*p)()) //假设p是fcn内部的一个指向成员函数的指针

当我们定义一个function对象时,必须指定该对象所能表示的函数类型,即可调用对象的形式。如果可调用对象是一个成员函数,则第一个形参必须表示该成员是在哪个(一般是隐式的)对象上执行的。同时,我们提供给function的形式中还必须指明对象是否是以指针或引用的形式传入的。

以定义fcn为例,我们想在string对象的序列上调用find_if,因此我们要求function生成一个接受string对象的可调用对象。又因为我们的vector保存的是string的指针,所以必须指定function接受指针:

1
2
3
4
vector<string*> pvec;
function<bool (const string*)> fp = &string::empty;
//fp接受一个指向string的指针,然后使用->*调用empty
find_if(pvec.begin(), pvec.end(), fp);

4.2.使用mem_fn生成一个可调用对象

通过上面的介绍可知,要想使用function,我们必须提供成员的调用形式。我们也可以采取另外一种方法,通过使用标准库功能mem_fn来让编译器负责推断成员的类型。和function一样,mem_fn也定义在functional头文件中,并且可以从成员指针生成一个可调用对象;和function不同的是,mem_fn可以根据成员指针的类型推断可调用对象的类型,而无须用户显式地指定:

1
find_if(svec.begin(), svec.end(), mem_fn(&string::empty));

我们使用mem_fn(&string::empty)生成一个可调用对象,该对象接受一个string实参,返回一个bool值。

mem_fn生成的可调用对象可以通过对象调用,也可以通过指针调用:

1
2
3
auto f = mem_fn(&string::empty); //f接受一个string或者一个string*
f(*svec.begin()); //正确:传入一个string对象,f使用.*调用empty
f(&svec[0]); //正确:传入一个string的指针,f使用->*调用empty

实际上,我们可以认为mem_fn生成的可调用对象含有一对重载的函数调用运算符:一个接受string*,另一个接受string&

4.3.使用bind生成一个可调用对象

出于完整性的考虑,我们还可以使用bind从成员函数生成一个可调用对象:

1
2
//选择范围中的每个string,并将其bind到empty的第一个隐式实参上
auto it = find_if(svec.begin(), svec.end(), bind(&string::empty, _1));

和function类似的地方是,当我们使用bind时,必须将函数中用于表示执行对象的隐式形参转换成显式的。和mem_fn类似的地方是,bind生成的可调用对象的第一个实参既可以是string的指针,也可以是string的引用:

1
2
3
auto f = bind(&string::empty, _1);
f(*svec.begin()); //正确:实参是一个string,f使用.*调用empty
f(&svec[0]); //正确:实参是一个string的指针,f使用->*调用empty